Merge branch 'v3' into v3-t17
This commit is contained in:
@@ -7,7 +7,7 @@ import type { ConversationId, ConversationRecord, Cursor, EntryId, JsonObject, M
|
||||
import type { ModelThinkingLevel } from "@earendil-works/pi-ai"
|
||||
import { onCommit } from "../messages/compat.ts"
|
||||
import { Started as BoardStarted } from "../messages/addresses.ts"
|
||||
import { Standing, Started } from "../subagents/api.ts"
|
||||
import { Standing, Started, Subagents } from "../subagents/api.ts"
|
||||
import { Branch, Branched, LineageApi, Rooted, Roots } from "./api.ts"
|
||||
import { TallyDoc, SubmissionLanded } from "./refs.ts"
|
||||
import { readMemory, applyMemoryOps, memoryBrief, memoryIndex, memoryText, memorizeTask, Memorized, MEMORY_RULES, MEMORY_SLUG, LINE_CHARS, BODY_CHARS } from "./memory.ts"
|
||||
@@ -174,6 +174,24 @@ async function liveTask(tx: Tx, id: ConversationId, kind: string) {
|
||||
return false
|
||||
}
|
||||
|
||||
/** Include background reporters: their deliveries can start another coordinator turn. */
|
||||
async function waitForBranchIdle(host: ExtensionHost, id: ConversationId, ctx: Context): Promise<void> {
|
||||
const harness = host.harness()
|
||||
const conversation = await harness.conversation(id, ctx)
|
||||
if (!conversation) return
|
||||
for (;;) {
|
||||
await conversation.waitForIdle(ctx)
|
||||
const state = await harness.snapshot(Subagents, id, ctx)
|
||||
const actions = Object.values(state?.actions ?? {})
|
||||
for (const action of actions) await harness.waitForTask(action.reporter, ctx)
|
||||
for (const child of Object.values(state?.agents ?? {}))
|
||||
await waitForBranchIdle(host, child.conversationId, ctx)
|
||||
await conversation.waitForIdle(ctx)
|
||||
const current = await harness.snapshot(Subagents, id, ctx)
|
||||
if (Object.keys(current?.actions ?? {}).length === actions.length) return
|
||||
}
|
||||
}
|
||||
|
||||
function lineage(host: ExtensionHost) {
|
||||
/**
|
||||
* Delivers a new branch's prompt as its first input; created with the branch, so a crash cannot leave it without
|
||||
@@ -230,7 +248,7 @@ function lineage(host: ExtensionHost) {
|
||||
written.resolve()
|
||||
})
|
||||
try {
|
||||
await (await runtime.conversation(target, ctx))?.waitForIdle(ctx)
|
||||
await waitForBranchIdle(host, target, ctx)
|
||||
// A fork inherits its source's entries; only an answer written in the target itself counts as its own.
|
||||
const view = await runtime.context(target, ctx)
|
||||
said = ''
|
||||
|
||||
@@ -8,6 +8,7 @@ They use world files and world git. They do not use native filesystem or process
|
||||
`branch` requires an absolute `repo` path and a prompt.
|
||||
It forks a listed tree root and writes `board.started.by` in the same transaction.
|
||||
The coordinator definition supplies the branch configuration.
|
||||
The branch start event reports the coordinator's model, not the inherited root model.
|
||||
Worktree isolation requires the later workspaces port. The default is a shared checkout.
|
||||
|
||||
The tool admits the prompt as a reply-enabled message.
|
||||
@@ -22,6 +23,8 @@ A branch reports to its starter when its work ends.
|
||||
While a child, landing, or reply remains pending, its final answer becomes a passive note.
|
||||
One-way updates to the starter also become passive notes.
|
||||
The starter can answer a report without an endless exchange of routed answers.
|
||||
The starter's final answer reaches the branch without a faulted routing task.
|
||||
A static test checks free runtime identifiers in every extension module, including function bodies.
|
||||
|
||||
The grant chain contains fork ancestry before starter ancestry.
|
||||
Each conversation occurs once. A root fork that @ultimate starts includes `[branch, root, ultimate]`.
|
||||
|
||||
@@ -22,7 +22,7 @@ export const branchTool = (host: any) => defineTool({
|
||||
const coordinator = definitions.find((definition: any) => definition.name === 'coordinator')
|
||||
if (!coordinator) throw new Error('Missing coordinator definition')
|
||||
const rootAgent = await (await host.harness().conversation(root, ctx))!.agent(ctx)
|
||||
const branch = await lineage.branch({from: root, repo: input.repo, prompt: input.prompt, title: input.title, deliver: false, by: api.conversationId, receipt: {taskId: api.taskId, callId: api.callId}}, ctx)
|
||||
const branch = await lineage.branch({from: root, repo: input.repo, prompt: input.prompt, title: input.title, model: coordinator.runs.model, deliver: false, by: api.conversationId, receipt: {taskId: api.taskId, callId: api.callId}}, ctx)
|
||||
const existing = await workspaces?.get(branch.id, ctx)
|
||||
const cwd = existing ? (await (await host.harness().conversation(branch.id, ctx)).agent(ctx)).cwd : input.repo
|
||||
const ancestors = workspaces ? (await workspaces.chain(branch.id, ctx)).slice(1) : []
|
||||
|
||||
@@ -14,6 +14,7 @@ import type {
|
||||
import type { Host as ExtensionHost } from '../../host/js/extensions.ts'
|
||||
|
||||
import { Subagents } from '../subagents/api.ts'
|
||||
import { ConversationsApi } from '../conversations/api.ts'
|
||||
|
||||
import { Messages } from './api.ts'
|
||||
import { terminal } from './log.ts'
|
||||
|
||||
@@ -19,7 +19,31 @@ Without that provider, the definition or default family selects the model.
|
||||
No login fallback runs.
|
||||
|
||||
Existing registered models retain their transport.
|
||||
New logical model references reuse the host's OpenAI-compatible proxy provider and its auth resolver.
|
||||
New model references reuse a template from their own registered provider and retain its proxy auth resolver.
|
||||
They never inherit another provider's transport. Unknown providers fail instead of silently using OpenAI.
|
||||
Models absent from the catalog receive zero prices, not the template's prices.
|
||||
The extension never reads a credential or registers a direct provider SDK.
|
||||
|
||||
## Host proxy transports and prices
|
||||
|
||||
`host/js/proxy-models.ts` reads the authenticated `/v1/models` catalog once at host startup.
|
||||
OpenAI models use pi-ai's Responses transport. Anthropic models use its Messages transport.
|
||||
The catalog endpoint determines each model's base URL.
|
||||
For Anthropic, effort capabilities select adaptive thinking and its advertised effort levels.
|
||||
Both providers use the existing `llm-proxy` key and usage tag. No provider login or direct endpoint fallback runs.
|
||||
|
||||
If the catalog fails or exceeds five seconds, a small table supplies these defaults:
|
||||
|
||||
| Provider/model | API | Proxy path |
|
||||
| --- | --- | --- |
|
||||
| `openai/gpt-6.1-sol` | Responses | `/v1/responses` |
|
||||
| `anthropic/claude-opus-5-5` | Messages, adaptive thinking | `/apis/anthropic/v1/messages` |
|
||||
|
||||
Catalog prices use USD per million tokens, with conversion from `unit_tokens` when necessary.
|
||||
The host uses the first advertised route's quote, including cache reads and cache writes.
|
||||
These costs are estimates, not billing totals. Another proxy route can charge different prices.
|
||||
Unavailable prices remain zero. Catalog changes apply after a host restart.
|
||||
World family edits still apply at the next run boundary without a restart.
|
||||
`host/tests/proxy_models.rs` tests the real transports against a local fake proxy.
|
||||
|
||||
`host/tests/core_ext.rs` tests these services through the snapshot loader.
|
||||
|
||||
@@ -28,14 +28,15 @@ export default function models(host: any) {
|
||||
const found = discoverAgents(files, '/src/sausage', root, families, refs)
|
||||
return {families, ...found}
|
||||
}
|
||||
const transport = host.models().getProvider('openai')
|
||||
const template = host.models().getModels('openai')[0]
|
||||
const register = (ref: any) => {
|
||||
const store = host.models()
|
||||
if (store.getModel(ref.provider, ref.modelId)) return
|
||||
const transport = store.getProvider(ref.provider)
|
||||
const template = store.getModels(ref.provider)[0]
|
||||
if (!transport || !template) throw new Error(`No proxy transport for ${ref.provider}/${ref.modelId}`)
|
||||
const prior = store.getModels(ref.provider)
|
||||
store.setProvider({...transport, id:ref.provider, getModels:()=>[...prior, {...template, provider:ref.provider, id:ref.modelId, name:ref.modelId}]})
|
||||
store.setProvider({...transport, getModels:()=>[...prior, {...template, id:ref.modelId, name:ref.modelId,
|
||||
cost:{input:0,output:0,cacheRead:0,cacheWrite:0}}]})
|
||||
}
|
||||
const apply = async (tx: any, id: number) => {
|
||||
const state = await tx.doc(AgentDoc, id)
|
||||
|
||||
+17324
-867
File diff suppressed because one or more lines are too long
@@ -21374,13 +21374,23 @@ function environment(cwd = "/", conversation, invocation) {
|
||||
if (!appended.ok) throw new Error(appended.error.message);
|
||||
}
|
||||
};
|
||||
let rid;
|
||||
const abort = () => {
|
||||
if (rid !== void 0) {
|
||||
Deno.core.close(rid);
|
||||
rid = void 0;
|
||||
}
|
||||
};
|
||||
try {
|
||||
const rid = ops.op_exec(command, options?.cwd ?? cwd, options?.timeout, void 0, void 0, conversations, tag);
|
||||
context.abortSignal?.throwIfAborted();
|
||||
rid = ops.op_exec(command, options?.cwd ?? cwd, options?.timeout, void 0, void 0, conversations, tag);
|
||||
context.abortSignal?.addEventListener("abort", abort, { once: true });
|
||||
const decoders = { stdout: new TextDecoder(), stderr: new TextDecoder() };
|
||||
let result;
|
||||
while (true) {
|
||||
const chunk = await ops.op_exec_next(rid);
|
||||
if (!chunk.stream) {
|
||||
rid = void 0;
|
||||
result = chunk;
|
||||
break;
|
||||
}
|
||||
@@ -21394,6 +21404,9 @@ function environment(cwd = "/", conversation, invocation) {
|
||||
return ok({ exitCode: result.exitCode, ...spillPath ? { spillPath } : {} });
|
||||
} catch (error) {
|
||||
return { ok: false, error: Object.assign(new Error(String(error)), { code: "unknown", ...spillPath ? { spillPath } : {} }) };
|
||||
} finally {
|
||||
context.abortSignal?.removeEventListener("abort", abort);
|
||||
abort();
|
||||
}
|
||||
},
|
||||
cleanup: async () => {
|
||||
|
||||
@@ -31693,6 +31693,8 @@ var faux = fauxProvider({ tokensPerSecond: 1e5 });
|
||||
models.setProvider(faux.provider);
|
||||
var proxyTemplate = { ...models.getModels("faux")[0], provider: "openai", baseUrl: "https://proxy.invalid/v1" };
|
||||
models.setProvider({ ...faux.provider, id: "openai", getModels: () => [proxyTemplate] });
|
||||
models.setProvider({ ...faux.provider, id: "anthropic", getModels: () => [{ ...proxyTemplate, provider: "anthropic", baseUrl: "https://proxy.invalid/apis/anthropic" }] });
|
||||
var proxyAuth = models.getProvider("openai").auth;
|
||||
var storage = await openStorage(globalThis.storagePath);
|
||||
var registry = createRegistry();
|
||||
var harness = await Harness.open(storage, { models, registry, settings: { retry: { enabled: false }, get extensions() {
|
||||
@@ -31743,10 +31745,14 @@ try {
|
||||
faux.setResponses([fauxAssistantMessage("second")]);
|
||||
await (await worker.submit({ type: "input", content: "second" }, BACKGROUND_CONTEXT)).wait(BACKGROUND_CONTEXT);
|
||||
assert((await harness.snapshot(AgentDoc, worker.id, BACKGROUND_CONTEXT)).model.modelId === "second", "edited family changes next model without restart");
|
||||
await fs.write("/src/sausage/agents/models.json", JSON.stringify({ default: { model: "faux/faux-1" }, fast: { model: "logical/new" } }));
|
||||
await fs.write("/src/sausage/agents/models.json", JSON.stringify({ default: { model: "faux/faux-1" }, fast: { model: "openai/new" } }));
|
||||
faux.setResponses([fauxAssistantMessage("proxy")]);
|
||||
await (await worker.submit({ type: "input", content: "proxy" }, BACKGROUND_CONTEXT)).wait(BACKGROUND_CONTEXT);
|
||||
assert(models.getModel("logical", "new").baseUrl === proxyTemplate.baseUrl && models.getProvider("logical").auth === models.getProvider("openai").auth, "new model retains proxy transport and auth");
|
||||
assert(models.getModel("openai", "new").baseUrl === proxyTemplate.baseUrl && models.getModel("openai", "new").cost.input === 0 && models.getProvider("openai").auth === proxyAuth, "new model retains proxy transport and auth");
|
||||
await fs.write("/src/sausage/agents/models.json", JSON.stringify({ default: { model: "faux/faux-1" }, fast: { model: "anthropic/new" } }));
|
||||
faux.setResponses([fauxAssistantMessage("anthropic")]);
|
||||
await (await worker.submit({ type: "input", content: "anthropic" }, BACKGROUND_CONTEXT)).wait(BACKGROUND_CONTEXT);
|
||||
assert(models.getModel("anthropic", "new").baseUrl === "https://proxy.invalid/apis/anthropic", "new Anthropic model retains its own provider transport");
|
||||
globalThis.modelPin = { provider: "faux", modelId: "faux-1" };
|
||||
faux.setResponses([fauxAssistantMessage("pin")]);
|
||||
await (await worker.submit({ type: "input", content: "pin" }, BACKGROUND_CONTEXT)).wait(BACKGROUND_CONTEXT);
|
||||
|
||||
@@ -9,6 +9,8 @@ const assert = (ok:any,message:string)=>{if(!ok)throw new Error(message);Deno.co
|
||||
const models=createModels(), faux=fauxProvider({tokensPerSecond:100000});models.setProvider(faux.provider)
|
||||
const proxyTemplate={...models.getModels('faux')[0],provider:'openai',baseUrl:'https://proxy.invalid/v1'}
|
||||
models.setProvider({...faux.provider,id:'openai',getModels:()=>[proxyTemplate]})
|
||||
models.setProvider({...faux.provider,id:'anthropic',getModels:()=>[{...proxyTemplate,provider:'anthropic',baseUrl:'https://proxy.invalid/apis/anthropic'}]})
|
||||
const proxyAuth=models.getProvider('openai').auth
|
||||
const storage=await openStorage(globalThis.storagePath), registry=createRegistry()
|
||||
const harness=await Harness.open(storage,{models,registry,settings:{retry:{enabled:false},get extensions(){return registry.snapshot().installed()}}},context)
|
||||
await harness.root(context,{agent:{model:{provider:'faux',modelId:'faux-1'}}})
|
||||
@@ -56,10 +58,14 @@ try {
|
||||
faux.setResponses([fauxAssistantMessage('second')])
|
||||
await (await worker.submit({type:'input',content:'second'},context)).wait(context)
|
||||
assert((await harness.snapshot(AgentDoc,worker.id,context)).model.modelId==='second','edited family changes next model without restart')
|
||||
await fs.write('/src/sausage/agents/models.json',JSON.stringify({default:{model:'faux/faux-1'},fast:{model:'logical/new'}}))
|
||||
await fs.write('/src/sausage/agents/models.json',JSON.stringify({default:{model:'faux/faux-1'},fast:{model:'openai/new'}}))
|
||||
faux.setResponses([fauxAssistantMessage('proxy')])
|
||||
await (await worker.submit({type:'input',content:'proxy'},context)).wait(context)
|
||||
assert(models.getModel('logical','new').baseUrl===proxyTemplate.baseUrl && models.getProvider('logical').auth===models.getProvider('openai').auth,'new model retains proxy transport and auth')
|
||||
assert(models.getModel('openai','new').baseUrl===proxyTemplate.baseUrl && models.getModel('openai','new').cost.input===0 && models.getProvider('openai').auth===proxyAuth,'new model retains proxy transport and auth')
|
||||
await fs.write('/src/sausage/agents/models.json',JSON.stringify({default:{model:'faux/faux-1'},fast:{model:'anthropic/new'}}))
|
||||
faux.setResponses([fauxAssistantMessage('anthropic')])
|
||||
await (await worker.submit({type:'input',content:'anthropic'},context)).wait(context)
|
||||
assert(models.getModel('anthropic','new').baseUrl==='https://proxy.invalid/apis/anthropic','new Anthropic model retains its own provider transport')
|
||||
globalThis.modelPin={provider:'faux',modelId:'faux-1'}
|
||||
faux.setResponses([fauxAssistantMessage('pin')])
|
||||
await (await worker.submit({type:'input',content:'pin'},context)).wait(context)
|
||||
|
||||
@@ -119,13 +119,17 @@ export function environment(cwd = '/', conversation?: string | number | string[]
|
||||
if (!appended.ok) throw new Error(appended.error.message);
|
||||
}
|
||||
};
|
||||
let rid: number | undefined;
|
||||
const abort = () => { if (rid !== undefined) { Deno.core.close(rid); rid = undefined; } };
|
||||
try {
|
||||
const rid = ops.op_exec(command, options?.cwd ?? cwd, options?.timeout, undefined, undefined, conversations, tag);
|
||||
context.abortSignal?.throwIfAborted();
|
||||
rid = ops.op_exec(command, options?.cwd ?? cwd, options?.timeout, undefined, undefined, conversations, tag);
|
||||
context.abortSignal?.addEventListener('abort', abort, { once: true });
|
||||
const decoders = { stdout: new TextDecoder(), stderr: new TextDecoder() };
|
||||
let result;
|
||||
while (true) {
|
||||
const chunk = await ops.op_exec_next(rid);
|
||||
if (!chunk.stream) { result = chunk; break; }
|
||||
if (!chunk.stream) { rid = undefined; result = chunk; break; }
|
||||
await output(decoders[chunk.stream].decode(new Uint8Array(chunk.bytes), { stream: true }), chunk.stream);
|
||||
}
|
||||
if (result.error) throw new Error(result.error);
|
||||
@@ -135,6 +139,7 @@ export function environment(cwd = '/', conversation?: string | number | string[]
|
||||
}
|
||||
return ok({ exitCode: result.exitCode, ...(spillPath ? { spillPath } : {}) });
|
||||
} catch (error) { return { ok: false, error: Object.assign(new Error(String(error)), { code: 'unknown', ...(spillPath ? { spillPath } : {}) }) }; }
|
||||
finally { context.abortSignal?.removeEventListener('abort', abort); abort(); }
|
||||
},
|
||||
cleanup: async () => {},
|
||||
};
|
||||
|
||||
+2201
-308
File diff suppressed because it is too large
Load Diff
@@ -2,9 +2,11 @@
|
||||
import { BACKGROUND_CONTEXT as context } from '@earendil-works/chord/context';
|
||||
import { createModels } from '@earendil-works/pi-ai/models';
|
||||
import { fauxProvider, fauxAssistantMessage, fauxToolCall } from '@earendil-works/pi-ai/providers/faux';
|
||||
import { Harness, createRegistry, defineDoc, defineExtension, defineTask, GenerationTask, hook, InboxDoc } from '@earendil-works/pi-durable';
|
||||
import { Harness, createRegistry, defineDoc, defineExtension, defineTask, GenerationTask, hook, InboxDoc, defineTool } from '@earendil-works/pi-durable';
|
||||
import { openStorage } from './sqlite';
|
||||
import { serve } from './input';
|
||||
import { environment } from './environment';
|
||||
import { obj } from '@earendil-works/pi-schema';
|
||||
const assert = (value: any, message: string) => { if (!value) throw new Error(message); };
|
||||
const equal = (a: any, b: any) => assert(JSON.stringify(a) === JSON.stringify(b), `Expected ${JSON.stringify(b)}, got ${JSON.stringify(a)}`);
|
||||
const until = async (condition: () => any) => {
|
||||
@@ -47,11 +49,24 @@ models.setProvider(faux.provider);
|
||||
const registry = createRegistry();
|
||||
registry.install(Notes);
|
||||
registry.install(defineExtension({ name: 'hold-test', tasks: [Park] }));
|
||||
const Shutdown = defineExtension({name: 'shutdown-test', tools: [defineTool({name: 'long_tool', description: 'Run a long world process', parameters: obj({}), replay: 'safe', execute: async (_args, _api, ctx) => {
|
||||
const env = environment('/');
|
||||
const resume = (await env.readTextFile('/resume')).ok;
|
||||
const work = env.exec(['/bin/sleep.wasm', resume ? '0' : '600'], {}, ctx);
|
||||
if (!resume) {
|
||||
await until(async () => (await env.readTextFile('/proc/processes')).value?.includes('600'));
|
||||
await env.writeFile('/sleep-ready', 'yes');
|
||||
}
|
||||
const result = await work;
|
||||
assert(result.ok, `Sleep failed: ${JSON.stringify(result)}`);
|
||||
return {output: [{type: 'text', text: 'sleep completed'}]};
|
||||
}})]});
|
||||
registry.install(Shutdown);
|
||||
const storage = await openStorage(globalThis.storagePath);
|
||||
const harness = await Harness.open(storage, { models, registry, settings: { followUpMode: 'all' } }, context);
|
||||
const scenario = globalThis.foundationCase;
|
||||
const root = await harness.root(context, { agent: { model: { provider: 'faux', modelId: 'faux-1' },
|
||||
extensions: scenario === 'placement' ? [Notes] : [] } });
|
||||
extensions: scenario === 'placement' ? [Notes] : scenario === 'shutdown' ? [Shutdown] : [] } });
|
||||
try {
|
||||
if (scenario === 'placement' || scenario === 'followups') {
|
||||
faux.setResponses([async () => {
|
||||
@@ -113,6 +128,20 @@ try {
|
||||
await until(() => second);
|
||||
await harness.waitForIdle(context);
|
||||
equal((await harness.getTask(task, context))?.state.status, 'terminal');
|
||||
} else if (scenario === 'shutdown') {
|
||||
faux.setResponses([fauxAssistantMessage([fauxToolCall('long_tool', {})], {stopReason: 'toolUse'}), fauxAssistantMessage('resumed answer')]);
|
||||
const resumed = (await environment('/').readTextFile('/resume')).ok;
|
||||
if (!resumed) await root.submit({type: 'input', content: 'sleep now', requestId: 'sleep-request'}, context);
|
||||
harness.resume();
|
||||
if (resumed) {
|
||||
faux.setResponses([fauxAssistantMessage('resumed answer')]);
|
||||
await harness.waitForIdle(context);
|
||||
const transcript = JSON.stringify(await root.context(context));
|
||||
assert(transcript.includes('resumed answer'), 'Shutdown did not resume');
|
||||
assert(transcript.includes('sleep completed'), 'Shutdown did not replay the interrupted tool');
|
||||
const submissions = await storage.scanSubmissions({conversationId: root.id}, 100, undefined, context);
|
||||
equal(submissions.items.length, 1);
|
||||
} else await serve(harness, context);
|
||||
} else if (scenario === 'headless') {
|
||||
faux.setResponses([fauxAssistantMessage('channel answer')]);
|
||||
await serve(harness, context);
|
||||
|
||||
@@ -31971,6 +31971,11 @@ async function serve(harness2, context, afterSubmit, api2) {
|
||||
};
|
||||
while (true) {
|
||||
const input = await Deno.core.ops.op_input();
|
||||
if (input?.type === "shutdown") {
|
||||
await harness2.close(context);
|
||||
await Promise.all(pending);
|
||||
return;
|
||||
}
|
||||
if (input === null || input.type === "stop") {
|
||||
await harness2.waitForIdle(context);
|
||||
await Promise.all(pending);
|
||||
|
||||
+7
-1
@@ -1,4 +1,4 @@
|
||||
// Channel submissions target any conversation. EOF and Stop close the harness cleanly.
|
||||
// EOF and Stop drain submissions. Shutdown cancels invocations and preserves durable tasks for restart.
|
||||
export async function serve(harness: any, context: any, afterSubmit?: () => Promise<void>, api?: any) {
|
||||
const pending = new Set<Promise<void>>();
|
||||
const track = (work: Promise<void>) => {
|
||||
@@ -7,6 +7,12 @@ export async function serve(harness: any, context: any, afterSubmit?: () => Prom
|
||||
};
|
||||
while (true) {
|
||||
const input = await Deno.core.ops.op_input();
|
||||
if (input?.type === 'shutdown') {
|
||||
// Close cancels invocations without terminal outcomes, so durable tasks resume after restart.
|
||||
await harness.close(context);
|
||||
await Promise.all(pending);
|
||||
return;
|
||||
}
|
||||
if (input === null || input.type === 'stop') {
|
||||
await harness.waitForIdle(context);
|
||||
await Promise.all(pending);
|
||||
|
||||
+3
-17
@@ -1,7 +1,6 @@
|
||||
// Durable root conversation and console. SQLite stays outside the world.
|
||||
import { BACKGROUND_CONTEXT as context } from '@earendil-works/chord/context';
|
||||
import { createModels } from '@earendil-works/pi-ai/models';
|
||||
import { openaiProvider } from '@earendil-works/pi-ai/providers/openai';
|
||||
import { proxyModels } from './proxy-models';
|
||||
import { Harness, AgentDoc, createRegistry } from '@earendil-works/pi-durable';
|
||||
import { CodingTools } from './environment';
|
||||
import { openStorage } from './sqlite';
|
||||
@@ -12,21 +11,8 @@ import { harnessApi } from './harness-api';
|
||||
import { loadExtensions } from './extensions';
|
||||
const proxy = Deno.core.ops.op_model_proxy();
|
||||
if (!proxy?.base_url || !proxy?.api_key) throw new Error('The llm-proxy credential is required');
|
||||
const models = createModels();
|
||||
const provider = openaiProvider();
|
||||
const model = {
|
||||
id: 'gpt-6.1-sol', name: 'gpt-6.1-sol', provider: 'openai', api: 'openai-responses',
|
||||
baseUrl: `${proxy.base_url}/v1`, reasoning: true, input: ['text'],
|
||||
thinkingLevelMap: { off: null, minimal: null },
|
||||
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 }, contextWindow: 200000, maxTokens: 4096,
|
||||
};
|
||||
models.setProvider({ ...provider, baseUrl: model.baseUrl, getModels: () => [model],
|
||||
auth: { apiKey: { name: 'Sausage proxy', resolve: async () => ({
|
||||
auth: { apiKey: proxy.api_key, baseUrl: model.baseUrl,
|
||||
headers: { 'Shopify-Usage-Tag': JSON.stringify(['pi', 'sausage']) } },
|
||||
source: 'llm-proxy',
|
||||
}) } },
|
||||
});
|
||||
const models = await proxyModels(proxy);
|
||||
const model = models.getModel('openai', 'gpt-6.1-sol');
|
||||
const registry = createRegistry();
|
||||
registry.install(CodingTools);
|
||||
registry.install(CredentialRequests);
|
||||
|
||||
@@ -31696,6 +31696,8 @@ var until2 = async (test) => {
|
||||
var models = createModels();
|
||||
var faux = fauxProvider({ tokensPerSecond: 1e5 });
|
||||
models.setProvider(faux.provider);
|
||||
var template = models.getModels("faux")[0];
|
||||
models.setProvider({ ...faux.provider, getModels: () => [template, { ...template, id: "coordinator" }] });
|
||||
var registry = createRegistry();
|
||||
var storage = await openStorage(globalThis.storagePath);
|
||||
var harness = await Harness.open(storage, { models, registry, settings: { get extensions() {
|
||||
@@ -31704,6 +31706,13 @@ var harness = await Harness.open(storage, { models, registry, settings: { get ex
|
||||
var root = await harness.root(BACKGROUND_CONTEXT, { agent: { model: { provider: "faux", modelId: "faux-1" }, cwd: "/src/sausage" } });
|
||||
var gate = Promise.withResolvers();
|
||||
var holding = /* @__PURE__ */ new Set();
|
||||
var workerGate = Promise.withResolvers();
|
||||
var workerStarted = false;
|
||||
var worker = defineExtension({ name: "worker-hold", tools: [defineTool({ name: "worker_hold", description: "Hold a background worker", parameters: obj({}), replay: "safe", execute: async (_args, _api, ctx) => {
|
||||
workerStarted = true;
|
||||
await awaitWithContext(workerGate.promise, ctx);
|
||||
return { output: [{ type: "text", text: "worker released" }] };
|
||||
} })] });
|
||||
var held2 = defineExtension({ name: "hold", tools: [defineTool({ name: "hold", description: "Wait for the test gate", parameters: obj({}), replay: "safe", execute: async (_args, api, ctx) => {
|
||||
holding.add(api.conversationId);
|
||||
await awaitWithContext(gate.promise, ctx);
|
||||
@@ -31746,11 +31755,12 @@ var route = (transcript) => {
|
||||
const input = JSON.parse(text.slice(5));
|
||||
return fauxAssistantMessage([fauxToolCall(input.name, input.args)], { stopReason: "toolUse" });
|
||||
}
|
||||
if (last.role === "user" && text === "HOLD WORKER") return fauxAssistantMessage([fauxToolCall("worker_hold", {})], { stopReason: "toolUse" });
|
||||
if (last.role === "user" && text === "HOLD") return fauxAssistantMessage([fauxToolCall("hold", {})], { stopReason: "toolUse" });
|
||||
return fauxAssistantMessage(last.role === "user" && /^(Reply|Message|Note) from/.test(text) ? "Received" : `Answer: ${text}`);
|
||||
};
|
||||
faux.setResponses([route]);
|
||||
var loader = await loadExtensions(harness, registry, storage, models, BACKGROUND_CONTEXT, [held2]);
|
||||
var loader = await loadExtensions(harness, registry, storage, models, BACKGROUND_CONTEXT, [held2, worker]);
|
||||
assert(!loader.state().error, `Extension load: ${loader.state().error}`);
|
||||
var snapshot = await fs.read("/src/sausage/.git/sausage-last-good");
|
||||
var base = `file:///run/sausage/extensions/${snapshot}/extensions`;
|
||||
@@ -31814,12 +31824,21 @@ try {
|
||||
assert((await entries2(branch)).some((e) => e.kind === "board.message" && e.data.text === "first task"), "The first message did not reach the branch");
|
||||
assert((await harness.commit((tx) => tx.conversation(branch.id), BACKGROUND_CONTEXT)).parent.conversationId === root.id, "Branch must fork its root");
|
||||
assert((await harness.snapshot(Started2, branch.id, BACKGROUND_CONTEXT)).by === primary.id, "Missing starter");
|
||||
const starts = (await globalThis.testHost.eventLog.recent()).filter((event) => event.name === "subagents.started" && event.data.id === branch.id);
|
||||
assert(starts.length === 1 && starts[0].data.model === "faux/coordinator", `Effective branch model: ${JSON.stringify(starts)}`);
|
||||
assert((await branch.agent(BACKGROUND_CONTEXT)).model.modelId === "coordinator", "The branch must use its advertised model");
|
||||
assert(JSON.stringify(await conversationChains(harness, BACKGROUND_CONTEXT)(branch.id)) === JSON.stringify([String(branch.id), String(root.id), String(primary.id)]), "Fork and starter grant ancestry");
|
||||
const before = (await entries2(primary)).length;
|
||||
await (await branch.submit({ type: "input", content: "later work" }, BACKGROUND_CONTEXT)).wait(BACKGROUND_CONTEXT);
|
||||
await until2(async () => (await entries2(primary)).some((e) => e.kind === "board.message" && e.data.from === branch.id && e.data.text === "Answer: later work" && e.data.delivery !== "note"));
|
||||
await harness.waitForIdle(BACKGROUND_CONTEXT);
|
||||
assert((await entries2(primary)).length > before, "An idle branch must report");
|
||||
assert((await entries2(branch)).some((entry2) => entry2.kind === "board.message" && entry2.data.from === primary.id && entry2.data.routed === true), "The final answer must route back to the branch");
|
||||
await until2(async () => {
|
||||
const routeTasks = (await harness.commit((tx) => tx.scanTasks({ kind: "board.route" }, 1e3), BACKGROUND_CONTEXT)).items;
|
||||
assert(routeTasks.every((task) => task.state.status !== "terminal" || task.state.outcome.status === "completed"), `Final-answer routing fault: ${JSON.stringify(routeTasks)}`);
|
||||
return routeTasks.length > 0 && routeTasks.every((task) => task.state.status === "terminal");
|
||||
});
|
||||
await tool(branch, "subagent", { action: "start", agent: "scout", task: "HOLD", background: true });
|
||||
await until2(() => holding.size > 0);
|
||||
await until2(async () => (await entries2(primary)).some((e) => e.kind === "board.message" && e.data.from === branch.id && e.data.delivery === "note"));
|
||||
@@ -31874,13 +31893,23 @@ try {
|
||||
assert((await entries2(branch)).some((e) => e.kind === "lineage.note" && e.data.source === "main" && e.data.text.includes("Catch-up commit") && e.data.text.includes(`#${branch.id}`)), "Attributed commit catch-up");
|
||||
assert((await entries2(branch)).some((e) => e.kind === "lineage.note" && e.data.source === "main" && e.data.text.includes("Use memory")), "Tally catch-up must include the new branch");
|
||||
await harness.waitForIdle(BACKGROUND_CONTEXT);
|
||||
await tool(branch, "subagent", { action: "start", agent: "coordinator", task: "HOLD WORKER", background: true });
|
||||
await until2(() => workerStarted);
|
||||
await branch.waitForIdle(BACKGROUND_CONTEXT);
|
||||
await tool(primary, "notify", { id: branch.id, wake: false });
|
||||
await new Promise((resolve2) => setTimeout(resolve2, 50));
|
||||
assert(!(await entries2(primary)).some((e) => e.kind === "lineage.note" && e.data.source === "finished"), "Idle coordinator with a live worker was called finished");
|
||||
workerGate.resolve();
|
||||
await until2(async () => (await entries2(primary)).some((e) => e.kind === "lineage.note" && e.data.source === "finished"));
|
||||
await harness.waitForIdle(BACKGROUND_CONTEXT);
|
||||
const finished = (await entries2(primary)).filter((e) => e.kind === "lineage.note" && e.data.source === "finished");
|
||||
assert(finished.length === 1, "Worker completion must notify once");
|
||||
assert(finished[0].data.text.includes("worker released"), "Notice must contain the coordinator answer after the worker report");
|
||||
assert((await harness.snapshot(Roots, root.id, BACKGROUND_CONTEXT)).roots.includes(root.id), "Listed root");
|
||||
}
|
||||
} finally {
|
||||
gate.resolve();
|
||||
workerGate.resolve();
|
||||
await loader.close();
|
||||
await harness.close(BACKGROUND_CONTEXT);
|
||||
}
|
||||
|
||||
@@ -10,10 +10,15 @@ import {openStorage} from './sqlite';
|
||||
const assert = (value: any, message: string) => {if (!value) throw new Error(message)};
|
||||
const until = async (test: () => any) => {const deadline = Date.now() + 15000; while (!await test()) {if (Date.now() > deadline) throw new Error('Messages deadline'); await new Promise(r => setTimeout(r, 0))}};
|
||||
const models = createModels(), faux = fauxProvider({tokensPerSecond: 100000}); models.setProvider(faux.provider);
|
||||
const template = models.getModels('faux')[0];
|
||||
models.setProvider({...faux.provider, getModels: () => [template, {...template, id: 'coordinator'}]});
|
||||
const registry = createRegistry(), storage = await openStorage(globalThis.storagePath);
|
||||
const harness = await Harness.open(storage, {models, registry, settings: {get extensions() {return registry.snapshot().installed()}}}, context);
|
||||
const root = await harness.root(context, {agent: {model: {provider: 'faux', modelId: 'faux-1'}, cwd: '/src/sausage'}});
|
||||
const gate = Promise.withResolvers<void>(), holding = new Set<number>();
|
||||
const workerGate = Promise.withResolvers<void>();
|
||||
let workerStarted = false;
|
||||
const worker = defineExtension({name: 'worker-hold', tools: [defineTool({name: 'worker_hold', description: 'Hold a background worker', parameters: obj({}), replay: 'safe', execute: async (_args, _api, ctx) => {workerStarted = true; await awaitWithContext(workerGate.promise, ctx); return {output: [{type: 'text', text: 'worker released'}]}}})]});
|
||||
const held = defineExtension({name: 'hold', tools: [defineTool({name: 'hold', description: 'Wait for the test gate', parameters: obj({}), replay: 'safe', execute: async (_args, api, ctx) => {holding.add(api.conversationId); await awaitWithContext(gate.promise, ctx); return {output: [{type: 'text', text: 'released'}]}}})]});
|
||||
if (globalThis.messagesMode === 'kill') {
|
||||
const install = registry.install.bind(registry);
|
||||
@@ -39,11 +44,12 @@ const route = (transcript: any): any => {
|
||||
const last = transcript.messages.findLast((m: any) => m.role !== 'system');
|
||||
const text = typeof last.content === 'string' ? last.content : last.content.filter((p: any) => p.type === 'text').map((p: any) => p.text).join('');
|
||||
if (last.role === 'user' && text.startsWith('CALL ')) {const input = JSON.parse(text.slice(5)); return fauxAssistantMessage([fauxToolCall(input.name, input.args)], {stopReason: 'toolUse'})}
|
||||
if (last.role === 'user' && text === 'HOLD WORKER') return fauxAssistantMessage([fauxToolCall('worker_hold', {})], {stopReason: 'toolUse'});
|
||||
if (last.role === 'user' && text === 'HOLD') return fauxAssistantMessage([fauxToolCall('hold', {})], {stopReason: 'toolUse'});
|
||||
return fauxAssistantMessage(last.role === 'user' && /^(Reply|Message|Note) from/.test(text) ? 'Received' : `Answer: ${text}`);
|
||||
};
|
||||
faux.setResponses([route]);
|
||||
const loader = await loadExtensions(harness, registry, storage, models, context, [held]);
|
||||
const loader = await loadExtensions(harness, registry, storage, models, context, [held, worker]);
|
||||
assert(!loader.state().error, `Extension load: ${loader.state().error}`);
|
||||
const snapshot = await fs.read('/src/sausage/.git/sausage-last-good');
|
||||
const base = `file:///run/sausage/extensions/${snapshot}/extensions`;
|
||||
@@ -99,12 +105,21 @@ try {
|
||||
assert((await entries(branch)).some((e: any) => e.kind === 'board.message' && e.data.text === 'first task'), 'The first message did not reach the branch');
|
||||
assert((await harness.commit((tx: any) => tx.conversation(branch.id), context)).parent.conversationId === root.id, 'Branch must fork its root');
|
||||
assert((await harness.snapshot(Started, branch.id, context)).by === primary.id, 'Missing starter');
|
||||
const starts = (await globalThis.testHost.eventLog.recent()).filter((event: any) => event.name === 'subagents.started' && event.data.id === branch.id);
|
||||
assert(starts.length === 1 && starts[0].data.model === 'faux/coordinator', `Effective branch model: ${JSON.stringify(starts)}`);
|
||||
assert((await branch.agent(context)).model.modelId === 'coordinator', 'The branch must use its advertised model');
|
||||
assert(JSON.stringify(await conversationChains(harness, context)(branch.id)) === JSON.stringify([String(branch.id), String(root.id), String(primary.id)]), 'Fork and starter grant ancestry');
|
||||
const before = (await entries(primary)).length;
|
||||
await (await branch.submit({type: 'input', content: 'later work'}, context)).wait(context);
|
||||
await until(async () => (await entries(primary)).some((e: any) => e.kind === 'board.message' && e.data.from === branch.id && e.data.text === 'Answer: later work' && e.data.delivery !== 'note'));
|
||||
await harness.waitForIdle(context);
|
||||
assert((await entries(primary)).length > before, 'An idle branch must report');
|
||||
assert((await entries(branch)).some((entry: any) => entry.kind === 'board.message' && entry.data.from === primary.id && entry.data.routed === true), 'The final answer must route back to the branch');
|
||||
await until(async () => {
|
||||
const routeTasks = (await harness.commit((tx: any) => tx.scanTasks({kind: 'board.route'}, 1000), context)).items;
|
||||
assert(routeTasks.every((task: any) => task.state.status !== 'terminal' || task.state.outcome.status === 'completed'), `Final-answer routing fault: ${JSON.stringify(routeTasks)}`);
|
||||
return routeTasks.length > 0 && routeTasks.every((task: any) => task.state.status === 'terminal');
|
||||
});
|
||||
await tool(branch, 'subagent', {action: 'start', agent: 'scout', task: 'HOLD', background: true});
|
||||
await until(() => holding.size > 0);
|
||||
await until(async () => (await entries(primary)).some((e: any) => e.kind === 'board.message' && e.data.from === branch.id && e.data.delivery === 'note'));
|
||||
@@ -151,9 +166,19 @@ try {
|
||||
assert((await entries(branch)).some((e: any) => e.kind === 'lineage.note' && e.data.source === 'main' && e.data.text.includes('Catch-up commit') && e.data.text.includes(`#${branch.id}`)), 'Attributed commit catch-up');
|
||||
assert((await entries(branch)).some((e: any) => e.kind === 'lineage.note' && e.data.source === 'main' && e.data.text.includes('Use memory')), 'Tally catch-up must include the new branch');
|
||||
await harness.waitForIdle(context);
|
||||
await tool(branch, 'subagent', {action: 'start', agent: 'coordinator', task: 'HOLD WORKER', background: true});
|
||||
await until(() => workerStarted);
|
||||
await branch.waitForIdle(context);
|
||||
await tool(primary, 'notify', {id: branch.id, wake: false});
|
||||
// Allow the notify invocation to reach its wait. The active worker must prevent delivery.
|
||||
await new Promise(resolve => setTimeout(resolve, 50));
|
||||
assert(!(await entries(primary)).some((e: any) => e.kind === 'lineage.note' && e.data.source === 'finished'), 'Idle coordinator with a live worker was called finished');
|
||||
workerGate.resolve();
|
||||
await until(async () => (await entries(primary)).some((e: any) => e.kind === 'lineage.note' && e.data.source === 'finished'));
|
||||
await harness.waitForIdle(context);
|
||||
const finished = (await entries(primary)).filter((e: any) => e.kind === 'lineage.note' && e.data.source === 'finished');
|
||||
assert(finished.length === 1, 'Worker completion must notify once');
|
||||
assert(finished[0].data.text.includes('worker released'), 'Notice must contain the coordinator answer after the worker report');
|
||||
assert((await harness.snapshot(Roots, root.id, context)).roots.includes(root.id), 'Listed root');
|
||||
}
|
||||
} finally {gate.resolve(); await loader.close(); await harness.close(context)}
|
||||
} finally {gate.resolve(); workerGate.resolve(); await loader.close(); await harness.close(context)}
|
||||
|
||||
@@ -21353,13 +21353,23 @@ function environment(cwd = "/", conversation, invocation) {
|
||||
if (!appended.ok) throw new Error(appended.error.message);
|
||||
}
|
||||
};
|
||||
let rid;
|
||||
const abort = () => {
|
||||
if (rid !== void 0) {
|
||||
Deno.core.close(rid);
|
||||
rid = void 0;
|
||||
}
|
||||
};
|
||||
try {
|
||||
const rid = ops.op_exec(command, options?.cwd ?? cwd, options?.timeout, void 0, void 0, conversations, tag);
|
||||
context.abortSignal?.throwIfAborted();
|
||||
rid = ops.op_exec(command, options?.cwd ?? cwd, options?.timeout, void 0, void 0, conversations, tag);
|
||||
context.abortSignal?.addEventListener("abort", abort, { once: true });
|
||||
const decoders = { stdout: new TextDecoder(), stderr: new TextDecoder() };
|
||||
let result;
|
||||
while (true) {
|
||||
const chunk = await ops.op_exec_next(rid);
|
||||
if (!chunk.stream) {
|
||||
rid = void 0;
|
||||
result = chunk;
|
||||
break;
|
||||
}
|
||||
@@ -21373,6 +21383,9 @@ function environment(cwd = "/", conversation, invocation) {
|
||||
return ok({ exitCode: result.exitCode, ...spillPath ? { spillPath } : {} });
|
||||
} catch (error) {
|
||||
return { ok: false, error: Object.assign(new Error(String(error)), { code: "unknown", ...spillPath ? { spillPath } : {} }) };
|
||||
} finally {
|
||||
context.abortSignal?.removeEventListener("abort", abort);
|
||||
abort();
|
||||
}
|
||||
},
|
||||
cleanup: async () => {
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"scripts": { "bundle": "nu apply-patch.nu && esbuild main.ts --bundle --format=esm --platform=browser --outfile=bundle.js && esbuild test.ts --bundle --format=esm --platform=browser --outfile=test-bundle.js && esbuild durable-test.ts --bundle --format=esm --platform=browser --outfile=durable-test-bundle.js && esbuild foundation-test.ts --bundle --format=esm --platform=browser --outfile=foundation-test-bundle.js && esbuild extensions-test.ts --bundle --format=esm --platform=browser --outfile=extensions-test-bundle.js && esbuild events-test.ts --bundle --format=esm --platform=browser --outfile=events-test-bundle.js && esbuild subagents-test.ts --bundle --format=esm --platform=browser --outfile=subagents-test-bundle.js && esbuild core-ext-test.ts --bundle --format=esm --platform=browser --outfile=core-ext-test-bundle.js && esbuild plan-todos-test.ts --bundle --format=esm --platform=browser --outfile=plan-todos-test-bundle.js && esbuild p2-test.ts --bundle --format=esm --platform=browser --outfile=p2-test-bundle.js && esbuild chain-test.ts --bundle --format=esm --platform=browser --outfile=chain-test-bundle.js && esbuild undo-harness-test.ts --bundle --format=esm --platform=browser --outfile=undo-harness-test-bundle.js && esbuild messages-test.ts --bundle --format=esm --platform=browser --outfile=messages-test-bundle.js && esbuild p3-test.ts --bundle --format=esm --platform=browser --outfile=p3-test-bundle.js && esbuild automations-test.ts --bundle --format=esm --platform=browser --outfile=automations-test-bundle.js && esbuild watchdog-test.ts --bundle --format=esm --platform=browser --outfile=watchdog-test-bundle.js && esbuild workspaces-test.ts --bundle --format=esm --platform=browser --outfile=workspaces-test-bundle.js && esbuild goal-questions-test.ts --bundle --format=esm --platform=browser --outfile=goal-questions-test-bundle.js && esbuild dream-test.ts --bundle --format=esm --platform=browser --outfile=dream-test-bundle.js && esbuild p4-test.ts --bundle --format=esm --platform=browser --outfile=p4-test-bundle.js && esbuild harness-api-test.ts --bundle --format=esm --platform=browser --outfile=harness-api-test-bundle.js" },
|
||||
"scripts": { "bundle": "nu apply-patch.nu && esbuild main.ts --bundle --format=esm --platform=browser --outfile=bundle.js && esbuild test.ts --bundle --format=esm --platform=browser --outfile=test-bundle.js && esbuild durable-test.ts --bundle --format=esm --platform=browser --outfile=durable-test-bundle.js && esbuild foundation-test.ts --bundle --format=esm --platform=browser --outfile=foundation-test-bundle.js && esbuild extensions-test.ts --bundle --format=esm --platform=browser --outfile=extensions-test-bundle.js && esbuild events-test.ts --bundle --format=esm --platform=browser --outfile=events-test-bundle.js && esbuild subagents-test.ts --bundle --format=esm --platform=browser --outfile=subagents-test-bundle.js && esbuild core-ext-test.ts --bundle --format=esm --platform=browser --outfile=core-ext-test-bundle.js && esbuild plan-todos-test.ts --bundle --format=esm --platform=browser --outfile=plan-todos-test-bundle.js && esbuild p2-test.ts --bundle --format=esm --platform=browser --outfile=p2-test-bundle.js && esbuild chain-test.ts --bundle --format=esm --platform=browser --outfile=chain-test-bundle.js && esbuild undo-harness-test.ts --bundle --format=esm --platform=browser --outfile=undo-harness-test-bundle.js && esbuild messages-test.ts --bundle --format=esm --platform=browser --outfile=messages-test-bundle.js && esbuild p3-test.ts --bundle --format=esm --platform=browser --outfile=p3-test-bundle.js && esbuild automations-test.ts --bundle --format=esm --platform=browser --outfile=automations-test-bundle.js && esbuild watchdog-test.ts --bundle --format=esm --platform=browser --outfile=watchdog-test-bundle.js && esbuild workspaces-test.ts --bundle --format=esm --platform=browser --outfile=workspaces-test-bundle.js && esbuild goal-questions-test.ts --bundle --format=esm --platform=browser --outfile=goal-questions-test-bundle.js && esbuild dream-test.ts --bundle --format=esm --platform=browser --outfile=dream-test-bundle.js && esbuild p4-test.ts --bundle --format=esm --platform=browser --outfile=p4-test-bundle.js && esbuild harness-api-test.ts --bundle --format=esm --platform=browser --outfile=harness-api-test-bundle.js && esbuild proxy-models-test.ts --bundle --format=esm --platform=browser --outfile=proxy-models-test-bundle.js" },
|
||||
"dependencies": {
|
||||
"@earendil-works/pi-durable": "1.5.0",
|
||||
"@earendil-works/pi-ai": "1.5.0",
|
||||
|
||||
File diff suppressed because one or more lines are too long
@@ -0,0 +1,17 @@
|
||||
/** Exercise both real pi-ai transports and their catalog prices, without provider SDK mocks. */
|
||||
import { proxyModels } from './proxy-models';
|
||||
const models = await proxyModels(globalThis.testProxy);
|
||||
for (const [provider, id] of [['openai', 'gpt-6.1-sol'], ['anthropic', 'claude-opus-5-5']]) {
|
||||
const model = models.getModel(provider, id);
|
||||
if (model.cost.input !== 2 || model.cost.cacheWrite !== 3) throw new Error('catalog prices lost');
|
||||
const result = await models.completeSimple(model, {messages: [{role: 'user', content: 'Say hello.', timestamp: Date.now()}]}, {reasoning: 'low', maxTokens: 128});
|
||||
if (result.stopReason === 'error' || !result.content.some(block => block.type === 'text' && block.text === 'hello')) {
|
||||
throw new Error(`${provider}: ${result.errorMessage ?? JSON.stringify(result.content)}`);
|
||||
}
|
||||
if (!(result.usage.cost.total > 0)) throw new Error('turn cost missing');
|
||||
}
|
||||
const fallback = await proxyModels({...globalThis.testProxy, base_url: `${globalThis.testProxy.base_url}/offline`});
|
||||
for (const [provider, api] of [['openai', 'openai-responses'], ['anthropic', 'anthropic-messages']]) {
|
||||
const model = fallback.getModels(provider)[0];
|
||||
if (model.api !== api || model.cost.input !== 0) throw new Error('offline transport fallback failed');
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
/** Host-only proxy catalog, provider transports, authentication, and quoted token prices. */
|
||||
import { createModels } from '@earendil-works/pi-ai/models';
|
||||
import { openaiProvider } from '@earendil-works/pi-ai/providers/openai';
|
||||
import { anthropicProvider } from '@earendil-works/pi-ai/providers/anthropic';
|
||||
|
||||
export async function proxyModels(proxy: {base_url: string, api_key: string}) {
|
||||
const models = createModels();
|
||||
const base = proxy.base_url.replace(/\/$/, '');
|
||||
let catalog: any[] = [];
|
||||
try {
|
||||
const response = await fetch(`${base}/v1/models`, {
|
||||
headers: {Authorization: `Bearer ${proxy.api_key}`}, signal: AbortSignal.timeout(5000),
|
||||
});
|
||||
if (response.ok) catalog = (await response.json()).data ?? [];
|
||||
} catch { /* An unavailable catalog must not prevent durable recovery. */ }
|
||||
for (const [provider, api, path, fallback] of [
|
||||
[openaiProvider(), 'openai-responses', '/v1/responses', 'gpt-6.1-sol'],
|
||||
[anthropicProvider(), 'anthropic-messages', '/apis/anthropic/v1/messages', 'claude-opus-5-5'],
|
||||
] as const) {
|
||||
const rows = catalog.filter(row => row.creator === provider.id && row.endpoints?.some((endpoint: any) => endpoint.api === api));
|
||||
const entries = (rows.length ? rows : [{id: fallback}]).map(row => {
|
||||
const endpoint = row.endpoints?.find((endpoint: any) => endpoint.api === api)?.path ?? path;
|
||||
// pi-ai appends /responses or /v1/messages, respectively.
|
||||
const suffix = api === 'anthropic-messages' ? '/v1/messages' : '/responses';
|
||||
const baseUrl = `${base}${endpoint.slice(0, -suffix.length)}`;
|
||||
const price = row.providers?.[0]?.pricing;
|
||||
const scale = price?.currency === 'USD' ? 1000000 / price.unit_tokens : 0;
|
||||
return {
|
||||
id: row.id, name: row.name ?? row.id, provider: provider.id, api, baseUrl,
|
||||
reasoning: row.capabilities?.reasoning ?? true, input: ['text'],
|
||||
thinkingLevelMap: {off: null, minimal: null,
|
||||
...Object.fromEntries((row.capabilities?.reasoning_options?.find((option: any) => option.type === 'effort')?.values ?? []).map((level: string) => [level, level]))},
|
||||
compat: api === 'anthropic-messages' ? {forceAdaptiveThinking: row.capabilities?.reasoning_options?.some((option: any) => option.type === 'effort') ?? true} : undefined,
|
||||
cost: {input: (price?.input ?? 0) * scale, output: (price?.output ?? 0) * scale,
|
||||
cacheRead: (price?.cached_input ?? 0) * scale, cacheWrite: (price?.cache_write ?? 0) * scale},
|
||||
contextWindow: row.limits?.context_window ?? 200000, maxTokens: 4096,
|
||||
};
|
||||
});
|
||||
models.setProvider({...provider, baseUrl: entries[0].baseUrl, getModels: () => entries as any,
|
||||
auth: {apiKey: {name: 'Sausage proxy', resolve: async () => ({
|
||||
auth: {apiKey: proxy.api_key, headers: {'Shopify-Usage-Tag': JSON.stringify(['pi', 'sausage'])}},
|
||||
source: 'llm-proxy',
|
||||
})}},
|
||||
});
|
||||
}
|
||||
return models;
|
||||
}
|
||||
+14
-1
@@ -21353,13 +21353,23 @@ function environment(cwd = "/", conversation, invocation) {
|
||||
if (!appended.ok) throw new Error(appended.error.message);
|
||||
}
|
||||
};
|
||||
let rid;
|
||||
const abort = () => {
|
||||
if (rid !== void 0) {
|
||||
Deno.core.close(rid);
|
||||
rid = void 0;
|
||||
}
|
||||
};
|
||||
try {
|
||||
const rid = ops.op_exec(command, options?.cwd ?? cwd, options?.timeout, void 0, void 0, conversations, tag);
|
||||
context.abortSignal?.throwIfAborted();
|
||||
rid = ops.op_exec(command, options?.cwd ?? cwd, options?.timeout, void 0, void 0, conversations, tag);
|
||||
context.abortSignal?.addEventListener("abort", abort, { once: true });
|
||||
const decoders = { stdout: new TextDecoder(), stderr: new TextDecoder() };
|
||||
let result2;
|
||||
while (true) {
|
||||
const chunk = await ops.op_exec_next(rid);
|
||||
if (!chunk.stream) {
|
||||
rid = void 0;
|
||||
result2 = chunk;
|
||||
break;
|
||||
}
|
||||
@@ -21373,6 +21383,9 @@ function environment(cwd = "/", conversation, invocation) {
|
||||
return ok({ exitCode: result2.exitCode, ...spillPath ? { spillPath } : {} });
|
||||
} catch (error) {
|
||||
return { ok: false, error: Object.assign(new Error(String(error)), { code: "unknown", ...spillPath ? { spillPath } : {} }) };
|
||||
} finally {
|
||||
context.abortSignal?.removeEventListener("abort", abort);
|
||||
abort();
|
||||
}
|
||||
},
|
||||
cleanup: async () => {
|
||||
|
||||
@@ -21353,13 +21353,23 @@ function environment(cwd = "/", conversation, invocation) {
|
||||
if (!appended.ok) throw new Error(appended.error.message);
|
||||
}
|
||||
};
|
||||
let rid;
|
||||
const abort = () => {
|
||||
if (rid !== void 0) {
|
||||
Deno.core.close(rid);
|
||||
rid = void 0;
|
||||
}
|
||||
};
|
||||
try {
|
||||
const rid = ops.op_exec(command, options?.cwd ?? cwd, options?.timeout, void 0, void 0, conversations, tag);
|
||||
context.abortSignal?.throwIfAborted();
|
||||
rid = ops.op_exec(command, options?.cwd ?? cwd, options?.timeout, void 0, void 0, conversations, tag);
|
||||
context.abortSignal?.addEventListener("abort", abort, { once: true });
|
||||
const decoders = { stdout: new TextDecoder(), stderr: new TextDecoder() };
|
||||
let result2;
|
||||
while (true) {
|
||||
const chunk = await ops.op_exec_next(rid);
|
||||
if (!chunk.stream) {
|
||||
rid = void 0;
|
||||
result2 = chunk;
|
||||
break;
|
||||
}
|
||||
@@ -21373,6 +21383,9 @@ function environment(cwd = "/", conversation, invocation) {
|
||||
return ok({ exitCode: result2.exitCode, ...spillPath ? { spillPath } : {} });
|
||||
} catch (error) {
|
||||
return { ok: false, error: Object.assign(new Error(String(error)), { code: "unknown", ...spillPath ? { spillPath } : {} }) };
|
||||
} finally {
|
||||
context.abortSignal?.removeEventListener("abort", abort);
|
||||
abort();
|
||||
}
|
||||
},
|
||||
cleanup: async () => {
|
||||
|
||||
@@ -24,6 +24,7 @@ pub enum Input {
|
||||
reply: mpsc::UnboundedSender<Value>,
|
||||
},
|
||||
Stop,
|
||||
Shutdown,
|
||||
}
|
||||
|
||||
pub type Sender = mpsc::UnboundedSender<Input>;
|
||||
@@ -78,7 +79,8 @@ pub(super) fn console(sender: Sender) {
|
||||
});
|
||||
}
|
||||
|
||||
pub(super) fn stop_signal(
|
||||
/// Register SIGTERM and SIGINT before the harness starts.
|
||||
pub fn stop_signal(
|
||||
sender: Sender,
|
||||
) -> std::io::Result<impl std::future::Future<Output = ()> + Send> {
|
||||
// Register before V8 starts, so a stop cannot arrive before the listener.
|
||||
@@ -89,6 +91,6 @@ pub(super) fn stop_signal(
|
||||
_ = interrupt.recv() => {}
|
||||
_ = terminate.recv() => {}
|
||||
}
|
||||
let _ = sender.send(Input::Stop);
|
||||
let _ = sender.send(Input::Shutdown);
|
||||
})
|
||||
}
|
||||
|
||||
+175
-4
@@ -130,7 +130,15 @@ impl Compiler {
|
||||
}
|
||||
}
|
||||
}
|
||||
/// Compile programs in both command folders without blocking world startup.
|
||||
fn priority(name: &str) -> usize {
|
||||
[
|
||||
"sh", "git", "rg", "grep", "ls", "cat", "sed", "find", "cargo",
|
||||
]
|
||||
.iter()
|
||||
.position(|tool| *tool == name.strip_suffix(".wasm").unwrap_or(name))
|
||||
.unwrap_or(9)
|
||||
}
|
||||
/// Compile common tools first in both command folders without blocking world startup.
|
||||
pub(super) fn precompile(
|
||||
compiler: Arc<Compiler>,
|
||||
root: Arc<dyn sausage_vfs::Layer>,
|
||||
@@ -140,18 +148,20 @@ pub(super) fn precompile(
|
||||
// Leave CPU and file bandwidth for commands during startup.
|
||||
let slots = Arc::new(tokio::sync::Semaphore::new(2));
|
||||
for folder in ["/bin", "/usr/local/bin"] {
|
||||
let entries = match root.list(folder).await {
|
||||
let mut entries = match root.list(folder).await {
|
||||
Ok(entries) => entries,
|
||||
Err(e) if e.kind() == std::io::ErrorKind::NotFound => continue,
|
||||
Err(e) => return Err(e.into()),
|
||||
};
|
||||
entries.sort_by_key(|entry| priority(&entry.name));
|
||||
for entry in entries.into_iter().filter(|entry| !entry.directory) {
|
||||
// Reserve before spawning so task scheduling cannot reorder admission.
|
||||
let permit = slots.clone().acquire_owned().await?;
|
||||
let root = root.clone();
|
||||
let compiler = compiler.clone();
|
||||
let path = format!("{folder}/{}", entry.name);
|
||||
let slots = slots.clone();
|
||||
tasks.spawn(async move {
|
||||
let _permit = slots.acquire().await?;
|
||||
let _permit = permit;
|
||||
let file = root
|
||||
.open(
|
||||
&path,
|
||||
@@ -186,6 +196,167 @@ mod tests {
|
||||
use crate::exec::{Command, Exec, Options};
|
||||
use std::{future::Future, sync::atomic::Ordering};
|
||||
|
||||
#[test]
|
||||
fn common_tools_precede_other_programs() {
|
||||
let mut names = [
|
||||
"z", "cat.wasm", "cargo", "sh.wasm", "git.wasm", "rg", "ls", "grep", "sed", "find",
|
||||
"arch",
|
||||
];
|
||||
names.sort_by_key(|name| priority(name));
|
||||
assert_eq!(
|
||||
names,
|
||||
[
|
||||
"sh.wasm", "git.wasm", "rg", "grep", "ls", "cat.wasm", "sed", "find", "cargo", "z",
|
||||
"arch"
|
||||
]
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread")]
|
||||
async fn cold_compilation_does_not_spend_command_timeout() -> anyhow::Result<()> {
|
||||
use std::time::Duration;
|
||||
let temp = tempfile::tempdir()?;
|
||||
let compiler = Compiler::new(&temp.path().join("cache"))?;
|
||||
let world = crate::world::World::open(temp.path())?;
|
||||
std::fs::create_dir_all(temp.path().join("world/bin"))?;
|
||||
let program = temp.path().join("world/bin/program.wasm");
|
||||
std::fs::write(
|
||||
&program,
|
||||
wat::parse_str(r#"(module (memory (export "memory") 1) (func (export "_start")))"#)?,
|
||||
)?;
|
||||
let (started_tx, started_rx) = tokio::sync::oneshot::channel();
|
||||
let (finish_tx, finish_rx) = std::sync::mpsc::channel();
|
||||
*compiler.before_compile.lock().unwrap() = Some(Box::new(move || {
|
||||
let _ = started_tx.send(());
|
||||
let _ = finish_rx.recv();
|
||||
}));
|
||||
let runtime = super::super::Runtime::with_compiler(world, compiler)?;
|
||||
started_rx.await?;
|
||||
let command_runtime = runtime.clone();
|
||||
let mut command = tokio::spawn(async move {
|
||||
command_runtime
|
||||
.exec(
|
||||
Command::Argv(vec!["/bin/program".into()]),
|
||||
Options {
|
||||
timeout: Some(Duration::from_secs(2)),
|
||||
..Default::default()
|
||||
},
|
||||
&mut |_, _| {},
|
||||
)
|
||||
.await
|
||||
});
|
||||
let pending = tokio::time::timeout(Duration::from_millis(2100), &mut command).await;
|
||||
// Always release the blocking compiler, even when the old deadline fails this assertion.
|
||||
finish_tx.send(())?;
|
||||
assert!(
|
||||
pending.is_err(),
|
||||
"Compilation consumed the execution deadline: {pending:?}"
|
||||
);
|
||||
assert_eq!(command.await??, 0);
|
||||
std::fs::write(
|
||||
&program,
|
||||
wat::parse_str(
|
||||
r#"(module (memory (export "memory") 1) (func (export "_start") (loop br 0)))"#,
|
||||
)?,
|
||||
)?;
|
||||
let result = runtime
|
||||
.exec(
|
||||
Command::Argv(vec!["/bin/program".into()]),
|
||||
Options {
|
||||
timeout: Some(Duration::from_millis(100)),
|
||||
..Default::default()
|
||||
},
|
||||
&mut |_, _| {},
|
||||
)
|
||||
.await;
|
||||
assert!(
|
||||
result
|
||||
.unwrap_err()
|
||||
.to_string()
|
||||
.contains("command timed out")
|
||||
);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread")]
|
||||
async fn child_compilation_pauses_the_parent_deadline() -> anyhow::Result<()> {
|
||||
use super::super::{GroupGuard, Request, Stdio, deadline::Deadline};
|
||||
use std::time::Duration;
|
||||
let temp = tempfile::tempdir()?;
|
||||
let world = crate::world::World::open(temp.path())?;
|
||||
let compiler = Compiler::new(&temp.path().join("cache"))?;
|
||||
let parent = wat::parse_str(
|
||||
r#"(module (memory (export "memory") 1) (func (export "_start") (loop br 0)))"#,
|
||||
)?;
|
||||
std::fs::write(temp.path().join("world/parent.wasm"), &parent)?;
|
||||
std::fs::write(
|
||||
temp.path().join("world/child.wasm"),
|
||||
wat::parse_str(r#"(module (memory (export "memory") 1) (func (export "_start")))"#)?,
|
||||
)?;
|
||||
compiler.get(parent).await?;
|
||||
let runtime = super::super::Runtime::with_compiler(world, compiler.clone())?;
|
||||
let deadline = Deadline::new(Duration::ZERO);
|
||||
let request = |name: &str| Request {
|
||||
program: name.into(),
|
||||
argv: vec![name.into()],
|
||||
cwd: "/".into(),
|
||||
env: Vec::new(),
|
||||
group: None,
|
||||
stdin: Stdio::Null,
|
||||
stdout: Stdio::Null,
|
||||
stderr: Stdio::Null,
|
||||
};
|
||||
let parent = runtime
|
||||
.start(
|
||||
request("/parent.wasm"),
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
Vec::new(),
|
||||
None,
|
||||
None,
|
||||
Some(deadline.clone()),
|
||||
)
|
||||
.await?;
|
||||
let _guard = GroupGuard {
|
||||
runtime: runtime.clone(),
|
||||
group: parent.pid,
|
||||
};
|
||||
let (started_tx, started_rx) = tokio::sync::oneshot::channel();
|
||||
let (finish_tx, finish_rx) = std::sync::mpsc::channel();
|
||||
*compiler.before_compile.lock().unwrap() = Some(Box::new(move || {
|
||||
let _ = started_tx.send(());
|
||||
let _ = finish_rx.recv();
|
||||
}));
|
||||
let child_runtime = runtime.clone();
|
||||
let child = tokio::spawn(async move {
|
||||
child_runtime
|
||||
.start(
|
||||
request("/child.wasm"),
|
||||
parent.pid,
|
||||
parent.pid,
|
||||
parent.group,
|
||||
Vec::new(),
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
)
|
||||
.await
|
||||
});
|
||||
let started = tokio::time::timeout(Duration::from_secs(5), started_rx).await;
|
||||
let expired = deadline.expired();
|
||||
tokio::pin!(expired);
|
||||
let paused = std::future::poll_fn(|cx| {
|
||||
std::task::Poll::Ready(expired.as_mut().poll(cx).is_pending())
|
||||
})
|
||||
.await;
|
||||
let _ = finish_tx.send(());
|
||||
started??;
|
||||
assert!(paused, "Child compilation did not pause the parent budget");
|
||||
child.await??;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn compilation_survives_the_originating_runtime() {
|
||||
let temp = tempfile::tempdir().unwrap();
|
||||
|
||||
@@ -0,0 +1,87 @@
|
||||
//! Command-wide execution budgets exclude the union of component compilation waits, including child spawns.
|
||||
use std::{sync::Arc, time::Duration};
|
||||
use tokio::{sync::watch, time::Instant};
|
||||
|
||||
#[derive(Clone, Copy)]
|
||||
struct State {
|
||||
deadline: Instant,
|
||||
compiling: usize,
|
||||
paused_at: Instant,
|
||||
}
|
||||
|
||||
pub(super) struct Deadline(watch::Sender<State>);
|
||||
|
||||
impl Deadline {
|
||||
pub fn new(duration: Duration) -> Arc<Self> {
|
||||
let now = Instant::now();
|
||||
Arc::new(Self(
|
||||
watch::channel(State {
|
||||
deadline: now + duration,
|
||||
compiling: 0,
|
||||
paused_at: now,
|
||||
})
|
||||
.0,
|
||||
))
|
||||
}
|
||||
|
||||
pub fn pause(self: &Arc<Self>) -> Compilation {
|
||||
self.0.send_modify(|state| {
|
||||
if state.compiling == 0 {
|
||||
state.paused_at = Instant::now();
|
||||
}
|
||||
state.compiling += 1;
|
||||
});
|
||||
Compilation(self.clone())
|
||||
}
|
||||
|
||||
pub async fn expired(&self) {
|
||||
let mut state = self.0.subscribe();
|
||||
loop {
|
||||
let current = *state.borrow_and_update();
|
||||
if current.compiling == 0 {
|
||||
tokio::select! {
|
||||
biased;
|
||||
changed = state.changed() => { changed.unwrap(); }
|
||||
_ = tokio::time::sleep_until(current.deadline) => return,
|
||||
}
|
||||
} else {
|
||||
state.changed().await.unwrap();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) struct Compilation(Arc<Deadline>);
|
||||
|
||||
impl Drop for Compilation {
|
||||
fn drop(&mut self) {
|
||||
self.0.0.send_modify(|state| {
|
||||
state.compiling -= 1;
|
||||
if state.compiling == 0 {
|
||||
state.deadline += state.paused_at.elapsed();
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn overlapping_compilations_share_one_pause_and_drop_resumes() {
|
||||
let deadline = Deadline::new(Duration::from_secs(2));
|
||||
let first = deadline.pause();
|
||||
let paused_at = deadline.0.borrow().paused_at;
|
||||
let second = deadline.pause();
|
||||
assert_eq!(deadline.0.borrow().compiling, 2);
|
||||
assert_eq!(deadline.0.borrow().paused_at, paused_at);
|
||||
let original = deadline.0.borrow().deadline;
|
||||
drop(first);
|
||||
assert_eq!(deadline.0.borrow().compiling, 1);
|
||||
assert_eq!(deadline.0.borrow().deadline, original);
|
||||
drop(second);
|
||||
assert_eq!(deadline.0.borrow().compiling, 0);
|
||||
assert!(deadline.0.borrow().deadline >= original);
|
||||
}
|
||||
}
|
||||
+112
-78
@@ -47,6 +47,7 @@ mod bindings {
|
||||
}
|
||||
use bindings::sausage::process::host::{Host, HostChild, Request, Stdio};
|
||||
mod cache;
|
||||
mod deadline;
|
||||
mod http;
|
||||
pub use http::Router;
|
||||
pub mod net;
|
||||
@@ -83,6 +84,7 @@ struct Entry {
|
||||
command: u32,
|
||||
group: u32,
|
||||
cancelled: Arc<AtomicBool>,
|
||||
deadline: Option<Arc<deadline::Deadline>>,
|
||||
tag: sausage_vfs::journal::Tag,
|
||||
abort: AbortHandle,
|
||||
}
|
||||
@@ -329,16 +331,17 @@ impl Runtime {
|
||||
conversations: Vec<String>,
|
||||
inherited_io: Option<Io>,
|
||||
tool_call: Option<String>,
|
||||
deadline: Option<Arc<deadline::Deadline>>,
|
||||
) -> anyhow::Result<Started> {
|
||||
anyhow::ensure!(!request.argv.is_empty(), "empty argv");
|
||||
let cancelled = if parent == 0 {
|
||||
Arc::new(AtomicBool::new(false))
|
||||
let (cancelled, deadline) = if parent == 0 {
|
||||
(Arc::new(AtomicBool::new(false)), deadline)
|
||||
} else {
|
||||
self.entries
|
||||
.lock()
|
||||
.unwrap()
|
||||
.get(&parent)
|
||||
.map(|entry| entry.cancelled.clone())
|
||||
.map(|entry| (entry.cancelled.clone(), entry.deadline.clone()))
|
||||
.ok_or_else(|| anyhow::anyhow!("parent exited"))?
|
||||
};
|
||||
if !request.env.iter().any(|(k, _)| k == "PATH") {
|
||||
@@ -366,7 +369,9 @@ impl Runtime {
|
||||
let bytes = file
|
||||
.read(0, usize::try_from(file.stat().await?.size)?)
|
||||
.await?;
|
||||
let compilation = deadline.as_ref().map(|deadline| deadline.pause());
|
||||
let pre = self.compiler.get(bytes.clone()).await?;
|
||||
drop(compilation);
|
||||
self.cache.lock().unwrap().insert(bytes);
|
||||
let pid = self.next.fetch_add(1, Ordering::Relaxed);
|
||||
let tag = if parent == 0 {
|
||||
@@ -425,7 +430,13 @@ impl Runtime {
|
||||
None
|
||||
};
|
||||
let mut mounts = Mounts::default();
|
||||
mounts.mount("/", root, false);
|
||||
let operations = mounts.mount("/", root, false);
|
||||
let creations =
|
||||
matches!(executable.rsplit('/').next(), Some("git" | "git.wasm")).then(|| {
|
||||
sausage_vfs::Layer::observer(&mounts)
|
||||
.unwrap()
|
||||
.exclusive_creations()
|
||||
});
|
||||
let mut wasi = WasiCtxBuilder::new();
|
||||
wasi.args(&request.argv)
|
||||
.envs(&request.env)
|
||||
@@ -485,6 +496,8 @@ impl Runtime {
|
||||
runtime: self.clone(),
|
||||
pid,
|
||||
tx,
|
||||
creations,
|
||||
operations,
|
||||
};
|
||||
// The start gate makes the entry visible before a guest can spawn children or exit.
|
||||
let (gate, ready) = tokio::sync::oneshot::channel();
|
||||
@@ -528,6 +541,7 @@ impl Runtime {
|
||||
command,
|
||||
group,
|
||||
cancelled: cancelled.clone(),
|
||||
deadline,
|
||||
tag,
|
||||
abort: task.abort_handle(),
|
||||
},
|
||||
@@ -553,19 +567,40 @@ struct Cleanup {
|
||||
runtime: Arc<Runtime>,
|
||||
pid: u32,
|
||||
tx: watch::Sender<Option<i32>>,
|
||||
creations: Option<sausage_vfs::observe::Creations>,
|
||||
operations: sausage_vfs::Process,
|
||||
}
|
||||
impl Cleanup {
|
||||
fn reaped(runtime: &Runtime, pid: u32) {
|
||||
runtime.entries.lock().unwrap().remove(&pid);
|
||||
runtime.world.processes.lock().unwrap().remove(&pid);
|
||||
}
|
||||
}
|
||||
impl Drop for Cleanup {
|
||||
fn drop(&mut self) {
|
||||
if self.tx.borrow().is_none() {
|
||||
self.tx.send_replace(Some(137));
|
||||
if self.tx.borrow().is_none()
|
||||
&& let Some(creations) = self.creations.clone()
|
||||
{
|
||||
let (runtime, pid, tx, operations) = (
|
||||
self.runtime.clone(),
|
||||
self.pid,
|
||||
self.tx.clone(),
|
||||
self.operations.clone(),
|
||||
);
|
||||
tokio::spawn(async move {
|
||||
operations.idle().await;
|
||||
// A cancelled guest cannot run Git's lockfile destructors. Retain
|
||||
// ownership across descriptor closure, but never remove a replacement.
|
||||
let result = creations.remove(|path| path.ends_with("/index.lock")).await;
|
||||
Cleanup::reaped(&runtime, pid);
|
||||
tx.send_replace(Some(if result.is_ok() { 137 } else { 125 }));
|
||||
});
|
||||
} else {
|
||||
if self.tx.borrow().is_none() {
|
||||
self.tx.send_replace(Some(137));
|
||||
}
|
||||
Cleanup::reaped(&self.runtime, self.pid);
|
||||
}
|
||||
self.runtime.entries.lock().unwrap().remove(&self.pid);
|
||||
self.runtime
|
||||
.world
|
||||
.processes
|
||||
.lock()
|
||||
.unwrap()
|
||||
.remove(&self.pid);
|
||||
}
|
||||
}
|
||||
impl Host for State {
|
||||
@@ -583,6 +618,7 @@ impl Host for State {
|
||||
self.conversations.clone(),
|
||||
Some(self.stdio.clone()),
|
||||
None,
|
||||
None,
|
||||
)
|
||||
.await
|
||||
{
|
||||
@@ -707,79 +743,77 @@ impl Exec for Arc<Runtime> {
|
||||
options: Options,
|
||||
output: &mut (dyn for<'chunk> FnMut(Stream, &'chunk [u8]) + Send),
|
||||
) -> anyhow::Result<i32> {
|
||||
let deadline = options
|
||||
.timeout
|
||||
.map(|duration| tokio::time::Instant::now() + duration);
|
||||
let argv = match command {
|
||||
Command::Shell(script) => vec![
|
||||
"/bin/sh".into(),
|
||||
"--noprofile".into(),
|
||||
"--norc".into(),
|
||||
"-c".into(),
|
||||
script,
|
||||
],
|
||||
Command::Argv(argv) => argv,
|
||||
};
|
||||
let env = options.env;
|
||||
let start = self.start(
|
||||
Request {
|
||||
program: argv.first().cloned().unwrap_or_default(),
|
||||
argv,
|
||||
env,
|
||||
cwd: options.cwd,
|
||||
group: None,
|
||||
stdin: Stdio::Pipe,
|
||||
stdout: Stdio::Pipe,
|
||||
stderr: Stdio::Pipe,
|
||||
},
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
options.conversations,
|
||||
None,
|
||||
options.tool_call,
|
||||
);
|
||||
let mut child = if let Some(deadline) = deadline {
|
||||
tokio::time::timeout_at(deadline, start)
|
||||
.await
|
||||
.map_err(|_| anyhow::anyhow!("command timed out"))??
|
||||
} else {
|
||||
start.await?
|
||||
};
|
||||
let _guard = GroupGuard {
|
||||
runtime: self.clone(),
|
||||
group: child.pid,
|
||||
};
|
||||
drop(child.input);
|
||||
let mut stdout = child.output.take().unwrap();
|
||||
let mut stderr = child.error.take().unwrap();
|
||||
let deadline = options.timeout.map(deadline::Deadline::new);
|
||||
let run = async {
|
||||
let (mut out_open, mut err_open) = (true, true);
|
||||
let (mut out, mut err) = ([0; 8192], [0; 8192]);
|
||||
while out_open || err_open {
|
||||
tokio::select! {
|
||||
n = stdout.read(&mut out), if out_open => { let n = n?; out_open = n != 0; if n != 0 { output(Stream::Stdout, &out[..n]); } }
|
||||
n = stderr.read(&mut err), if err_open => { let n = n?; err_open = n != 0; if n != 0 { output(Stream::Stderr, &err[..n]); } }
|
||||
let argv = match command {
|
||||
Command::Shell(script) => vec![
|
||||
"/bin/sh".into(),
|
||||
"--noprofile".into(),
|
||||
"--norc".into(),
|
||||
"-c".into(),
|
||||
script,
|
||||
],
|
||||
Command::Argv(argv) => argv,
|
||||
};
|
||||
let env = options.env;
|
||||
let mut child = self
|
||||
.start(
|
||||
Request {
|
||||
program: argv.first().cloned().unwrap_or_default(),
|
||||
argv,
|
||||
env,
|
||||
cwd: options.cwd,
|
||||
group: None,
|
||||
stdin: Stdio::Pipe,
|
||||
stdout: Stdio::Pipe,
|
||||
stderr: Stdio::Pipe,
|
||||
},
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
options.conversations,
|
||||
None,
|
||||
options.tool_call,
|
||||
deadline.clone(),
|
||||
)
|
||||
.await?;
|
||||
let _guard = GroupGuard {
|
||||
runtime: self.clone(),
|
||||
group: child.pid,
|
||||
};
|
||||
drop(child.input);
|
||||
let mut stdout = child.output.take().unwrap();
|
||||
let mut stderr = child.error.take().unwrap();
|
||||
let run = async {
|
||||
let (mut out_open, mut err_open) = (true, true);
|
||||
let (mut out, mut err) = ([0; 8192], [0; 8192]);
|
||||
while out_open || err_open {
|
||||
tokio::select! {
|
||||
n = stdout.read(&mut out), if out_open => { let n = n?; out_open = n != 0; if n != 0 { output(Stream::Stdout, &out[..n]); } }
|
||||
n = stderr.read(&mut err), if err_open => { let n = n?; err_open = n != 0; if n != 0 { output(Stream::Stderr, &err[..n]); } }
|
||||
}
|
||||
}
|
||||
}
|
||||
loop {
|
||||
if let Some(code) = *child.code.borrow_and_update() {
|
||||
return Ok(code);
|
||||
loop {
|
||||
if let Some(code) = *child.code.borrow_and_update() {
|
||||
return Ok(code);
|
||||
}
|
||||
child.code.changed().await?;
|
||||
}
|
||||
child.code.changed().await?;
|
||||
};
|
||||
let result = run.await;
|
||||
if let Some(journal) = self.world.root.journal() {
|
||||
journal.retain().await?;
|
||||
}
|
||||
result
|
||||
};
|
||||
let result = if let Some(deadline) = deadline {
|
||||
tokio::time::timeout_at(deadline, run)
|
||||
.await
|
||||
.map_err(|_| anyhow::anyhow!("command timed out"))?
|
||||
if let Some(deadline) = &deadline {
|
||||
tokio::select! {
|
||||
result = run => result,
|
||||
_ = deadline.expired() => anyhow::bail!("command timed out"),
|
||||
}
|
||||
} else {
|
||||
run.await
|
||||
};
|
||||
if let Some(journal) = self.world.root.journal() {
|
||||
journal.retain().await?;
|
||||
}
|
||||
result
|
||||
}
|
||||
}
|
||||
/// Construct the harness's shared executor over the same open world.
|
||||
|
||||
@@ -34,6 +34,7 @@ pub fn supervise(runtime: Arc<Runtime>) -> Supervisor {
|
||||
Vec::new(),
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
);
|
||||
let child = tokio::select! { _ = &mut stop => return, child = start => child };
|
||||
if let Ok(mut child) = child {
|
||||
|
||||
@@ -0,0 +1,165 @@
|
||||
//! Parse extension modules and reject free runtime identifiers outside the world runtime globals.
|
||||
use deno_ast::swc::{
|
||||
ast::{Ident, TsInterfaceDecl, TsType},
|
||||
common::SyntaxContext,
|
||||
ecma_visit::{Visit, VisitWith},
|
||||
};
|
||||
use deno_ast::{MediaType, ParseParams, ProgramRef, parse_module};
|
||||
use std::path::Path;
|
||||
|
||||
// Standard JavaScript globals. World extensions also see Deno.
|
||||
const GLOBALS: &[&str] = &[
|
||||
"undefined",
|
||||
"globalThis",
|
||||
"Deno",
|
||||
"console",
|
||||
"crypto",
|
||||
"fetch",
|
||||
"performance",
|
||||
"setTimeout",
|
||||
"clearTimeout",
|
||||
"setInterval",
|
||||
"clearInterval",
|
||||
"queueMicrotask",
|
||||
"structuredClone",
|
||||
"atob",
|
||||
"btoa",
|
||||
"parseInt",
|
||||
"parseFloat",
|
||||
"isNaN",
|
||||
"Infinity",
|
||||
"NaN",
|
||||
"Object",
|
||||
"Function",
|
||||
"Boolean",
|
||||
"Number",
|
||||
"BigInt",
|
||||
"String",
|
||||
"Symbol",
|
||||
"Array",
|
||||
"Map",
|
||||
"Set",
|
||||
"WeakMap",
|
||||
"WeakSet",
|
||||
"WeakRef",
|
||||
"FinalizationRegistry",
|
||||
"Promise",
|
||||
"Date",
|
||||
"RegExp",
|
||||
"Error",
|
||||
"TypeError",
|
||||
"RangeError",
|
||||
"SyntaxError",
|
||||
"AggregateError",
|
||||
"JSON",
|
||||
"Math",
|
||||
"Reflect",
|
||||
"Proxy",
|
||||
"Intl",
|
||||
"Iterator",
|
||||
"Uint8Array",
|
||||
"ArrayBuffer",
|
||||
"TextEncoder",
|
||||
"TextDecoder",
|
||||
"URL",
|
||||
"URLSearchParams",
|
||||
"AbortController",
|
||||
"AbortSignal",
|
||||
"Request",
|
||||
"Response",
|
||||
"Headers",
|
||||
"ReadableStream",
|
||||
"WritableStream",
|
||||
"TransformStream",
|
||||
"Blob",
|
||||
"File",
|
||||
"FormData",
|
||||
"DOMException",
|
||||
"encodeURIComponent",
|
||||
"decodeURIComponent",
|
||||
];
|
||||
fn undefined(source: &str) -> Vec<String> {
|
||||
let parsed = parse_module(ParseParams {
|
||||
specifier: "file:///extension.ts".parse().unwrap(),
|
||||
text: source.into(),
|
||||
media_type: MediaType::TypeScript,
|
||||
capture_tokens: false,
|
||||
scope_analysis: true,
|
||||
maybe_syntax: None,
|
||||
})
|
||||
.unwrap();
|
||||
struct FreeNames {
|
||||
context: SyntaxContext,
|
||||
names: Vec<String>,
|
||||
}
|
||||
impl Visit for FreeNames {
|
||||
// Type signatures contain parameter names but do not execute.
|
||||
fn visit_ts_type(&mut self, _node: &TsType) {}
|
||||
fn visit_ts_interface_decl(&mut self, _node: &TsInterfaceDecl) {}
|
||||
fn visit_ident(&mut self, ident: &Ident) {
|
||||
if ident.ctxt == self.context && !GLOBALS.contains(&ident.sym.as_ref()) {
|
||||
self.names.push(ident.sym.to_string());
|
||||
}
|
||||
}
|
||||
}
|
||||
let mut free = FreeNames {
|
||||
context: parsed.unresolved_context(),
|
||||
names: Vec::new(),
|
||||
};
|
||||
match parsed.program_ref() {
|
||||
ProgramRef::Module(module) => module.visit_with(&mut free),
|
||||
ProgramRef::Script(script) => script.visit_with(&mut free),
|
||||
}
|
||||
free.names.sort();
|
||||
free.names.dedup();
|
||||
free.names
|
||||
}
|
||||
fn scan(directory: &Path, failures: &mut Vec<String>) {
|
||||
for entry in std::fs::read_dir(directory).unwrap() {
|
||||
let path = entry.unwrap().path();
|
||||
if path.is_dir() {
|
||||
scan(&path, failures);
|
||||
} else if path.extension().is_some_and(|ext| ext == "ts")
|
||||
&& !path.to_string_lossy().ends_with(".test.ts")
|
||||
{
|
||||
let names = undefined(&std::fs::read_to_string(&path).unwrap());
|
||||
if !names.is_empty() {
|
||||
failures.push(format!("{}: {}", path.display(), names.join(", ")));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
#[test]
|
||||
fn extension_identifiers_are_defined() {
|
||||
let mut failures = Vec::new();
|
||||
scan(
|
||||
&Path::new(env!("CARGO_MANIFEST_DIR")).join("../extensions"),
|
||||
&mut failures,
|
||||
);
|
||||
assert!(
|
||||
failures.is_empty(),
|
||||
"Undefined identifiers:\n{}",
|
||||
failures.join("\n")
|
||||
);
|
||||
}
|
||||
#[test]
|
||||
fn catches_the_missing_route_import() {
|
||||
let source = std::fs::read_to_string(
|
||||
Path::new(env!("CARGO_MANIFEST_DIR")).join("../extensions/messages/route.ts"),
|
||||
)
|
||||
.unwrap();
|
||||
let old = source.replace(
|
||||
"import { ConversationsApi } from '../conversations/api.ts'\n",
|
||||
"",
|
||||
);
|
||||
assert!(
|
||||
undefined(&old)
|
||||
.iter()
|
||||
.any(|name| name == "ConversationsApi")
|
||||
);
|
||||
assert!(undefined("import { Api } from 'service'; type Callback = (arg: UnknownType) => void; interface Service { use(parameter: UnknownType): void }; function use(host: any) { const local = Api; return host.need(local) }").is_empty());
|
||||
assert_eq!(
|
||||
undefined("function use(host: any) { return host.need(MissingApi) }"),
|
||||
["MissingApi"]
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,241 @@
|
||||
//! Git stays responsive through the shell, and cancellation removes only its own index lock.
|
||||
use sausage::{
|
||||
exec::{Command, Exec, Options},
|
||||
process::Runtime,
|
||||
world::World,
|
||||
};
|
||||
use sausage_vfs::{Layer, Open};
|
||||
use std::{
|
||||
path::{Path, PathBuf},
|
||||
sync::{Arc, OnceLock},
|
||||
time::Duration,
|
||||
};
|
||||
|
||||
fn guests() -> &'static Path {
|
||||
static BUILT: OnceLock<PathBuf> = OnceLock::new();
|
||||
BUILT.get_or_init(|| {
|
||||
let repo = Path::new(env!("CARGO_MANIFEST_DIR")).parent().unwrap();
|
||||
let target = PathBuf::from(std::env::var("CARGO_TARGET_DIR").unwrap());
|
||||
for script in ["userland/cwd-init/build.nu", "userland/brush/build.nu"] {
|
||||
if script == "userland/cwd-init/build.nu"
|
||||
&& repo.join("userland/cwd-init/init.o").is_file()
|
||||
{
|
||||
continue;
|
||||
}
|
||||
assert!(
|
||||
std::process::Command::new("nu")
|
||||
.args(["--no-config-file", script])
|
||||
.current_dir(repo)
|
||||
.status()
|
||||
.unwrap()
|
||||
.success()
|
||||
);
|
||||
}
|
||||
assert!(
|
||||
std::process::Command::new("cargo")
|
||||
.args([
|
||||
"build",
|
||||
"--release",
|
||||
"--locked",
|
||||
"--target",
|
||||
"wasm32-wasip2",
|
||||
"-p",
|
||||
"git"
|
||||
])
|
||||
.current_dir(repo.join("userland"))
|
||||
.status()
|
||||
.unwrap()
|
||||
.success()
|
||||
);
|
||||
target.join("wasm32-wasip2/release")
|
||||
})
|
||||
}
|
||||
fn native(cwd: &Path, args: &[&str]) {
|
||||
let output = std::process::Command::new("git")
|
||||
.args(args)
|
||||
.current_dir(cwd)
|
||||
.output()
|
||||
.unwrap();
|
||||
assert!(
|
||||
output.status.success(),
|
||||
"{}",
|
||||
String::from_utf8_lossy(&output.stderr)
|
||||
);
|
||||
}
|
||||
struct Fixture {
|
||||
_temp: tempfile::TempDir,
|
||||
runtime: Arc<Runtime>,
|
||||
root: Arc<dyn Layer>,
|
||||
processes: sausage::process::Processes,
|
||||
}
|
||||
fn fixture() -> Fixture {
|
||||
let programs = guests();
|
||||
let temp = tempfile::tempdir().unwrap();
|
||||
let root = temp.path().join("world");
|
||||
for path in ["bin", "src/demo/src", "worktrees"] {
|
||||
std::fs::create_dir_all(root.join(path)).unwrap();
|
||||
}
|
||||
for (source, name) in [("brush", "sh"), ("git", "git")] {
|
||||
std::fs::copy(
|
||||
programs.join(format!("{source}.wasm")),
|
||||
root.join(format!("bin/{name}.wasm")),
|
||||
)
|
||||
.unwrap();
|
||||
}
|
||||
let repo = root.join("src/demo");
|
||||
std::fs::write(repo.join("src/main.rs"), "old\n").unwrap();
|
||||
native(&repo, &["init", "-b", "main"]);
|
||||
native(&repo, &["add", "."]);
|
||||
native(
|
||||
&repo,
|
||||
&[
|
||||
"-c",
|
||||
"user.name=Test",
|
||||
"-c",
|
||||
"user.email=test@example.com",
|
||||
"commit",
|
||||
"--no-gpg-sign",
|
||||
"-m",
|
||||
"initial",
|
||||
],
|
||||
);
|
||||
native(
|
||||
&repo,
|
||||
&[
|
||||
"worktree",
|
||||
"add",
|
||||
"-b",
|
||||
"worker",
|
||||
root.join("worktrees/160").to_str().unwrap(),
|
||||
],
|
||||
);
|
||||
std::fs::write(
|
||||
root.join("worktrees/160/.git"),
|
||||
"gitdir: /src/demo/.git/worktrees/160\n",
|
||||
)
|
||||
.unwrap();
|
||||
std::fs::write(
|
||||
repo.join(".git/worktrees/160/gitdir"),
|
||||
"/worktrees/160/.git\n",
|
||||
)
|
||||
.unwrap();
|
||||
let world = World::open(temp.path()).unwrap();
|
||||
let root = world.root.clone();
|
||||
let processes = world.processes.clone();
|
||||
let runtime = Runtime::new(world).unwrap();
|
||||
Fixture {
|
||||
_temp: temp,
|
||||
runtime,
|
||||
root,
|
||||
processes,
|
||||
}
|
||||
}
|
||||
fn options() -> Options {
|
||||
Options {
|
||||
cwd: "/worktrees/160".into(),
|
||||
env: [
|
||||
("GIT_AUTHOR_NAME", "Test"),
|
||||
("GIT_AUTHOR_EMAIL", "test@example.com"),
|
||||
("GIT_COMMITTER_NAME", "Test"),
|
||||
("GIT_COMMITTER_EMAIL", "test@example.com"),
|
||||
]
|
||||
.map(|(k, v)| (k.into(), v.into()))
|
||||
.into(),
|
||||
..Default::default()
|
||||
}
|
||||
}
|
||||
async fn run(fixture: &Fixture, script: &str) -> String {
|
||||
let mut output = Vec::new();
|
||||
let code = fixture
|
||||
.runtime
|
||||
.exec(Command::Shell(script.into()), options(), &mut |_, bytes| {
|
||||
output.extend_from_slice(bytes)
|
||||
})
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(code, 0, "{}", String::from_utf8_lossy(&output));
|
||||
String::from_utf8(output).unwrap()
|
||||
}
|
||||
async fn edit(fixture: &Fixture) {
|
||||
let file = fixture
|
||||
.root
|
||||
.open(
|
||||
"/worktrees/160/src/main.rs",
|
||||
Open {
|
||||
write: true,
|
||||
truncate: true,
|
||||
..Default::default()
|
||||
},
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
file.write(0, b"changed\n".to_vec()).await.unwrap();
|
||||
}
|
||||
#[cfg(not(debug_assertions))]
|
||||
#[tokio::test(flavor = "current_thread")]
|
||||
async fn linked_worktree_commands_stay_under_two_seconds() {
|
||||
let fixture = fixture();
|
||||
run(&fixture, "git status --short").await;
|
||||
edit(&fixture).await;
|
||||
for script in [
|
||||
"git status --short",
|
||||
"git log -1",
|
||||
"git add src/main.rs && git commit --no-gpg-sign -m changed",
|
||||
] {
|
||||
let start = std::time::Instant::now();
|
||||
let output = run(&fixture, script).await;
|
||||
let elapsed = start.elapsed();
|
||||
println!("{script}: {elapsed:?}\n{output}");
|
||||
assert!(elapsed < Duration::from_secs(2), "{script}: {elapsed:?}");
|
||||
}
|
||||
}
|
||||
#[tokio::test(flavor = "current_thread")]
|
||||
async fn cancelled_add_releases_its_index_lock() {
|
||||
let fixture = fixture();
|
||||
run(&fixture, "git status --short").await;
|
||||
edit(&fixture).await;
|
||||
let path = "/src/demo/.git/worktrees/160/index.lock";
|
||||
let mut changes = fixture
|
||||
.root
|
||||
.observer()
|
||||
.unwrap()
|
||||
.subscribe(vec![path.into()]);
|
||||
let mut output = Vec::new();
|
||||
let mut collect = |_, bytes: &[u8]| output.extend_from_slice(bytes);
|
||||
let mut command = Box::pin(fixture.runtime.exec(
|
||||
Command::Argv(vec![
|
||||
"/bin/git.wasm".into(),
|
||||
"add".into(),
|
||||
"src/main.rs".into(),
|
||||
]),
|
||||
options(),
|
||||
&mut collect,
|
||||
));
|
||||
tokio::time::timeout(Duration::from_secs(30), async {
|
||||
tokio::select! {
|
||||
biased;
|
||||
change = changes.recv() => { assert_eq!(change.unwrap().0, path); },
|
||||
result = &mut command => panic!("add completed before cancellation: {result:?}"),
|
||||
}
|
||||
})
|
||||
.await
|
||||
.unwrap();
|
||||
assert!(
|
||||
!fixture.processes.lock().unwrap().is_empty(),
|
||||
"Git must still run when cancellation occurs"
|
||||
);
|
||||
drop(command);
|
||||
tokio::time::timeout(Duration::from_secs(30), async {
|
||||
while !fixture.processes.lock().unwrap().is_empty() {
|
||||
tokio::task::yield_now().await;
|
||||
}
|
||||
})
|
||||
.await
|
||||
.unwrap();
|
||||
assert!(fixture.root.stat(path, false).await.is_err());
|
||||
run(
|
||||
&fixture,
|
||||
"git add src/main.rs && git commit --no-gpg-sign -m recovered",
|
||||
)
|
||||
.await;
|
||||
}
|
||||
@@ -0,0 +1,135 @@
|
||||
//! SIGTERM cancels a world tool and preserves its durable invocation for restart.
|
||||
use sausage::{
|
||||
cred::Cred,
|
||||
harness::{input, run_module_with_input},
|
||||
world::World,
|
||||
};
|
||||
use std::{
|
||||
path::Path,
|
||||
process::{Command, Stdio},
|
||||
sync::Arc,
|
||||
time::{Duration, Instant},
|
||||
};
|
||||
|
||||
#[tokio::test(flavor = "current_thread")]
|
||||
async fn shutdown_child() -> anyhow::Result<()> {
|
||||
let Ok(path) = std::env::var("SHUTDOWN_WORLD") else {
|
||||
return Ok(());
|
||||
};
|
||||
let world = World::open(Path::new(&path))?;
|
||||
let processes = world.processes.clone();
|
||||
let cred = Arc::new(Cred::open(&world.host_dir)?);
|
||||
let exec = sausage::process::exec(World {
|
||||
root: world.root.clone(),
|
||||
host_dir: world.host_dir.clone(),
|
||||
processes: processes.clone(),
|
||||
})?;
|
||||
let (sender, receiver) = input::channel();
|
||||
let signal = tokio::spawn(input::stop_signal(sender)?);
|
||||
run_module_with_input(
|
||||
world,
|
||||
exec,
|
||||
cred,
|
||||
receiver,
|
||||
concat!(
|
||||
"globalThis.foundationCase = 'shutdown';\n",
|
||||
include_str!("../js/foundation-test-bundle.js")
|
||||
),
|
||||
)
|
||||
.await?;
|
||||
signal.abort();
|
||||
tokio::time::timeout(Duration::from_secs(1), async {
|
||||
while !processes.lock().unwrap().is_empty() {
|
||||
tokio::task::yield_now().await;
|
||||
}
|
||||
})
|
||||
.await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn sigterm_cancels_tool_and_resumes() -> anyhow::Result<()> {
|
||||
let source = Path::new(env!("CARGO_MANIFEST_DIR")).join("..");
|
||||
let target = Path::new(env!("CARGO_TARGET_TMPDIR")).parent().unwrap();
|
||||
assert!(
|
||||
Command::new("cargo")
|
||||
.args([
|
||||
"build",
|
||||
"--release",
|
||||
"--locked",
|
||||
"-p",
|
||||
"sausage-coreutils",
|
||||
"--bin",
|
||||
"sleep",
|
||||
"--manifest-path",
|
||||
])
|
||||
.arg(source.join("userland/Cargo.toml"))
|
||||
.args(["--target", "wasm32-wasip2", "--target-dir"])
|
||||
.arg(target)
|
||||
.args(["-j", "2"])
|
||||
.status()?
|
||||
.success()
|
||||
);
|
||||
let dir = tempfile::tempdir()?;
|
||||
World::open(dir.path())?;
|
||||
std::fs::create_dir_all(dir.path().join("world/bin"))?;
|
||||
std::fs::copy(
|
||||
target.join("wasm32-wasip2/release/sleep.wasm"),
|
||||
dir.path().join("world/bin/sleep.wasm"),
|
||||
)?;
|
||||
let spawn = || {
|
||||
Command::new(std::env::current_exe().unwrap())
|
||||
.args(["--exact", "shutdown_child", "--nocapture"])
|
||||
.env("SHUTDOWN_WORLD", dir.path())
|
||||
.stdin(Stdio::null())
|
||||
.spawn()
|
||||
};
|
||||
let mut child = spawn()?;
|
||||
let deadline = Instant::now() + Duration::from_secs(60);
|
||||
while !dir.path().join("world/sleep-ready").exists() {
|
||||
if let Some(status) = child.try_wait()? {
|
||||
anyhow::bail!("Tool child exited early: {status}");
|
||||
}
|
||||
if Instant::now() > deadline {
|
||||
child.kill()?;
|
||||
child.wait()?;
|
||||
anyhow::bail!("World sleep did not start");
|
||||
}
|
||||
std::thread::yield_now();
|
||||
}
|
||||
assert!(
|
||||
Command::new("kill")
|
||||
.args(["-TERM", &child.id().to_string()])
|
||||
.status()?
|
||||
.success()
|
||||
);
|
||||
let deadline = Instant::now() + Duration::from_secs(5);
|
||||
loop {
|
||||
if let Some(status) = child.try_wait()? {
|
||||
assert!(status.success());
|
||||
break;
|
||||
}
|
||||
if Instant::now() > deadline {
|
||||
child.kill()?;
|
||||
child.wait()?;
|
||||
anyhow::bail!("SIGTERM exceeded five seconds");
|
||||
}
|
||||
std::thread::yield_now();
|
||||
}
|
||||
std::fs::write(dir.path().join("world/resume"), "yes")?;
|
||||
let mut child = spawn()?;
|
||||
let deadline = Instant::now() + Duration::from_secs(20);
|
||||
loop {
|
||||
if let Some(status) = child.try_wait()? {
|
||||
assert!(status.success());
|
||||
break;
|
||||
}
|
||||
if Instant::now() > deadline {
|
||||
child.kill()?;
|
||||
child.wait()?;
|
||||
anyhow::bail!("Restart did not complete");
|
||||
}
|
||||
std::thread::yield_now();
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
@@ -48,6 +48,7 @@ fn prepare(dir: &Path) -> anyhow::Result<()> {
|
||||
"extensions/lineage",
|
||||
"extensions/models",
|
||||
"extensions/core",
|
||||
"extensions/conversations",
|
||||
"agents",
|
||||
] {
|
||||
std::fs::create_dir_all(repo.join(directory))?;
|
||||
@@ -70,6 +71,10 @@ fn prepare(dir: &Path) -> anyhow::Result<()> {
|
||||
repo.join("extensions/probe/server.ts"),
|
||||
"export default host => { globalThis.testHost = host; globalThis.subagentService = host.need({name:'subagents'}); globalThis.messageService = host.need({name:'messages'}); globalThis.lineageService = host.need({name:'lineage'}); host.implement({name:'conversations'}, {summaryModel: async () => globalThis.summaryRef, concludedIn: async () => new Set(), generatedTitle: async () => {}, setStanding: async () => {}, setAutomaticSummaries: async () => {}}); return {name:'probe'}; }",
|
||||
)?;
|
||||
std::fs::copy(
|
||||
source.join("extensions/conversations/api.ts"),
|
||||
repo.join("extensions/conversations/api.ts"),
|
||||
)?;
|
||||
std::fs::copy(
|
||||
source.join("extensions/core/agents.ts"),
|
||||
repo.join("extensions/core/agents.ts"),
|
||||
@@ -88,7 +93,7 @@ fn prepare(dir: &Path) -> anyhow::Result<()> {
|
||||
)?;
|
||||
std::fs::write(
|
||||
repo.join("agents/coordinator.md"),
|
||||
"---\nname: coordinator\ndescription: Coordinate\nisolate: none\n---\nCoordinator agent\n",
|
||||
"---\nname: coordinator\ndescription: Coordinate\nmodel: faux/coordinator\nisolate: none\n---\nCoordinator agent\n",
|
||||
)?;
|
||||
for args in [
|
||||
vec!["init", "-b", "main"],
|
||||
|
||||
@@ -0,0 +1,107 @@
|
||||
//! The host proxy catalog selects distinct pi-ai transports with shared proxy authentication.
|
||||
use sausage::{
|
||||
exec::{Command, Exec, Options, Stream},
|
||||
world::World,
|
||||
};
|
||||
use std::{sync::Arc, time::Duration};
|
||||
struct NoExec;
|
||||
#[async_trait::async_trait]
|
||||
impl Exec for NoExec {
|
||||
async fn exec(
|
||||
&self,
|
||||
_: Command,
|
||||
_: Options,
|
||||
_: &mut (dyn for<'a> FnMut(Stream, &'a [u8]) + Send),
|
||||
) -> anyhow::Result<i32> {
|
||||
anyhow::bail!("unexpected world command")
|
||||
}
|
||||
}
|
||||
fn event(name: &str, value: serde_json::Value) -> String {
|
||||
format!("event: {name}\ndata: {value}\n\n")
|
||||
}
|
||||
#[tokio::test(flavor = "current_thread")]
|
||||
async fn provider_transport_and_prices() -> anyhow::Result<()> {
|
||||
use serde_json::json;
|
||||
// Production initializes this in the process runtime; this fixture uses NoExec.
|
||||
let _ = rustls::crypto::aws_lc_rs::default_provider().install_default();
|
||||
let server = tiny_http::Server::http("127.0.0.1:0").map_err(|e| anyhow::anyhow!(e))?;
|
||||
let base = format!("http://{}", server.server_addr());
|
||||
let proxy = json!({"base_url":base,"api_key":"test-proxy-key"});
|
||||
let thread = std::thread::spawn(move || -> anyhow::Result<()> {
|
||||
for path in [
|
||||
"/v1/models",
|
||||
"/v1/responses",
|
||||
"/apis/anthropic/v1/messages?beta=true",
|
||||
"/offline/v1/models",
|
||||
] {
|
||||
let mut request = server
|
||||
.recv_timeout(Duration::from_secs(20))?
|
||||
.ok_or_else(|| anyhow::anyhow!("request timeout"))?;
|
||||
assert_eq!(request.url(), path);
|
||||
let authenticated = request.headers().iter().any(|h| {
|
||||
(h.field.equiv("Authorization") && h.value.as_str() == "Bearer test-proxy-key")
|
||||
|| (h.field.equiv("x-api-key") && h.value.as_str() == "test-proxy-key")
|
||||
});
|
||||
assert!(authenticated, "missing proxy key");
|
||||
if path == "/offline/v1/models" {
|
||||
request.respond(tiny_http::Response::empty(503))?;
|
||||
continue;
|
||||
}
|
||||
let (body, mime) = if path == "/v1/models" {
|
||||
let rows: Vec<_> = [("openai", "gpt-6.1-sol", "openai-responses", "/v1/responses"),
|
||||
("anthropic", "claude-opus-5-5", "anthropic-messages", "/apis/anthropic/v1/messages")]
|
||||
.into_iter().map(|(creator,id,api,path)|json!({"creator":creator,"id":id,"endpoints":[{"api":api,"path":path}],
|
||||
"providers":[{"pricing":{"currency":"USD","unit_tokens":1000,"input":0.002,"output":0.008,"cached_input":0.0001,"cache_write":0.003}}]})).collect();
|
||||
(json!({"data":rows}).to_string(), "application/json")
|
||||
} else {
|
||||
let mut body = String::new();
|
||||
request.as_reader().read_to_string(&mut body)?;
|
||||
let body: serde_json::Value = serde_json::from_str(&body)?;
|
||||
assert_eq!(body["stream"], true);
|
||||
assert!(
|
||||
request
|
||||
.headers()
|
||||
.iter()
|
||||
.any(|h| h.field.equiv("Shopify-Usage-Tag")
|
||||
&& h.value.as_str() == "[\"pi\",\"sausage\"]")
|
||||
);
|
||||
let stream = if path == "/v1/responses" {
|
||||
assert_eq!(body["model"], "gpt-6.1-sol");
|
||||
[
|
||||
event("response.output_item.added", json!({"type":"response.output_item.added","output_index":0,"item":{"type":"message","id":"m","role":"assistant","content":[]}})),
|
||||
event("response.content_part.added", json!({"type":"response.content_part.added","output_index":0,"content_index":0,"part":{"type":"output_text","text":""}})),
|
||||
event("response.output_text.delta", json!({"type":"response.output_text.delta","output_index":0,"content_index":0,"delta":"hello"})),
|
||||
event("response.completed", json!({"type":"response.completed","response":{"id":"r","status":"completed","output":[],"usage":{"input_tokens":10,"output_tokens":1,"total_tokens":11}}})),
|
||||
].concat()
|
||||
} else {
|
||||
assert_eq!(body["model"], "claude-opus-5-5");
|
||||
assert_eq!(body["thinking"]["type"], "adaptive");
|
||||
assert_eq!(body["output_config"]["effort"], "low");
|
||||
[
|
||||
event("message_start", json!({"type":"message_start","message":{"id":"m","type":"message","role":"assistant","model":"claude-opus-5-5","content":[],"usage":{"input_tokens":10,"output_tokens":0}}})),
|
||||
event("content_block_start", json!({"type":"content_block_start","index":0,"content_block":{"type":"text","text":""}})),
|
||||
event("content_block_delta", json!({"type":"content_block_delta","index":0,"delta":{"type":"text_delta","text":"hello"}})),
|
||||
event("content_block_stop", json!({"type":"content_block_stop","index":0})),
|
||||
event("message_delta", json!({"type":"message_delta","delta":{"stop_reason":"end_turn"},"usage":{"output_tokens":1}})),
|
||||
event("message_stop", json!({"type":"message_stop"})),
|
||||
].concat()
|
||||
};
|
||||
(stream, "text/event-stream")
|
||||
};
|
||||
request.respond(
|
||||
tiny_http::Response::from_string(body)
|
||||
.with_header(tiny_http::Header::from_bytes("Content-Type", mime).unwrap()),
|
||||
)?;
|
||||
}
|
||||
Ok(())
|
||||
});
|
||||
let dir = tempfile::tempdir()?;
|
||||
let source = format!(
|
||||
"globalThis.testProxy = {proxy};\n{}",
|
||||
include_str!("../js/proxy-models-test-bundle.js")
|
||||
);
|
||||
let result =
|
||||
sausage::harness::run_module(World::open(dir.path())?, Arc::new(NoExec), &source).await;
|
||||
thread.join().unwrap()?;
|
||||
result
|
||||
}
|
||||
@@ -39,6 +39,6 @@ def main [] {
|
||||
$env.SAUSAGE_CWD_INIT = ($here | path join .. cwd-init init.o | path expand)
|
||||
$env.CARGO_TARGET_WASM32_WASIP2_LINKER = ($here | path join .. cwd-init link.nu | path expand)
|
||||
$env.CARGO_TARGET_WASM32_WASIP2_RUSTFLAGS = '-C link-arg=sausage-cwd-init'
|
||||
^cargo build --manifest-path ($source | path join Cargo.toml) -p brush-shell --release --features minimal --no-default-features --target wasm32-wasip2 --locked
|
||||
^cargo build --manifest-path ($source | path join Cargo.toml) -p brush-shell --release --features minimal,tokio/time --no-default-features --target wasm32-wasip2 --locked
|
||||
if $env.LAST_EXIT_CODE != 0 { error make {msg: 'brush build failed'} }
|
||||
}
|
||||
|
||||
@@ -114,7 +114,9 @@ pub(crate) fn spawn(mut request: LaunchRequest) -> std::io::Result<ChildProcess>
|
||||
return Ok(ExecutionResult::new(code as u8));
|
||||
}
|
||||
}
|
||||
tokio::task::yield_now().await;
|
||||
// A guest-only yield keeps this WASM invocation runnable and starves
|
||||
// the host tasks that supply the child's filesystem and stream IO.
|
||||
tokio::time::sleep(std::time::Duration::from_millis(1)).await;
|
||||
}
|
||||
});
|
||||
Ok(
|
||||
|
||||
@@ -56,7 +56,7 @@ It checks multiple revisions, detached status, branch deletion messages, and mer
|
||||
It also checks these forms:
|
||||
|
||||
- `status -sb` and `status --porcelain=v1`.
|
||||
- `log -n`, `--oneline`, `-p`, `--stat`, and one literal path after `--`.
|
||||
- `log -1`, `-n`, `--oneline`, `-p`, `--stat`, and one literal path after `--`.
|
||||
- `log --format` and `--pretty=format:` with `%H %h %s %an %ae %ad %d`.
|
||||
- `log A..B`, `--name-only`, `--no-renames`, and `--diff-merges=first-parent`.
|
||||
- `%x00`, `%x1e`, and `%(trailers:key=Sausage-Conversation,valueonly)` in log formats.
|
||||
|
||||
@@ -209,6 +209,8 @@ enum Command {
|
||||
oneline: bool,
|
||||
#[arg(short = 'n')]
|
||||
limit: Option<usize>,
|
||||
#[arg(short = '1')]
|
||||
one: bool,
|
||||
#[arg(long)]
|
||||
format: Option<String>,
|
||||
#[arg(long)]
|
||||
@@ -958,6 +960,7 @@ pub fn run(cli: Cli) -> Result<i32> {
|
||||
diff_merges,
|
||||
oneline,
|
||||
limit,
|
||||
one,
|
||||
format,
|
||||
pretty,
|
||||
patch,
|
||||
@@ -965,7 +968,7 @@ pub fn run(cli: Cli) -> Result<i32> {
|
||||
revision,
|
||||
paths,
|
||||
} => {
|
||||
let limit = limit.unwrap_or(usize::MAX);
|
||||
let limit = if one { 1 } else { limit.unwrap_or(usize::MAX) };
|
||||
let (tip, excluded) =
|
||||
plumbing::range(&repository, revision.as_deref().unwrap_or("HEAD"))?;
|
||||
let commit = repository.find_commit(tip).map_err(|e| error("log", e))?;
|
||||
|
||||
@@ -56,6 +56,8 @@ def main [component: path, server: path = '/tmp/p1-git/server-target/debug/git-t
|
||||
{args: [branch --show-current], compare: true}
|
||||
{args: [branch -a], compare: true}
|
||||
{args: [log -n 1], compare: true}
|
||||
{args: [log -1], compare: true}
|
||||
{args: [log --oneline -1], compare: true}
|
||||
{args: [log -n 1 '--format=%H %h %s %an %ae %ad %d'], compare: true}
|
||||
{args: [log -n 1 '--pretty=format:%H %h %s %an %ae %ad %d'], compare: true}
|
||||
{args: [log -n 1 -p], compare: true}
|
||||
|
||||
@@ -157,6 +157,9 @@ impl Handle {
|
||||
}
|
||||
#[async_trait]
|
||||
impl File for Handle {
|
||||
fn unlink_if_named(&self, path: String) -> Option<crate::observe::Unlink> {
|
||||
self.file.unlink_if_named(path)
|
||||
}
|
||||
async fn stat(&self) -> Result<Stat> {
|
||||
self.file.stat().await
|
||||
}
|
||||
|
||||
@@ -52,6 +52,10 @@ pub struct Open {
|
||||
/// An open file keeps its identity after rename or unlink.
|
||||
#[async_trait]
|
||||
pub trait File: Send + Sync {
|
||||
/// Prepare an unlink that does nothing if this file no longer has the given name.
|
||||
fn unlink_if_named(&self, _path: String) -> Option<observe::Unlink> {
|
||||
None
|
||||
}
|
||||
async fn stat(&self) -> Result<Stat>;
|
||||
async fn read(&self, offset: u64, length: usize) -> Result<Vec<u8>>;
|
||||
async fn write(&self, offset: u64, bytes: Vec<u8>) -> Result<usize>;
|
||||
|
||||
+5
-3
@@ -19,11 +19,13 @@ impl Default for Mounts {
|
||||
}
|
||||
}
|
||||
impl Mounts {
|
||||
/// Add or replace a mount. Read-only mounts reject every mutation.
|
||||
pub fn mount(&mut self, path: &str, layer: Arc<dyn Layer>, readonly: bool) {
|
||||
/// Add or replace a mount and return its operation tracker. Read-only mounts reject every mutation.
|
||||
pub fn mount(&mut self, path: &str, layer: Arc<dyn Layer>, readonly: bool) -> Process {
|
||||
let path = resolve("/", path);
|
||||
let layer = self.observer.layer(layer, &path, Process::default());
|
||||
let process = Process::default();
|
||||
let layer = self.observer.layer(layer, &path, process.clone());
|
||||
self.mounts.insert(path, Mount { layer, readonly });
|
||||
process
|
||||
}
|
||||
pub(crate) async fn metadata_file(&self, path: &str, follow: bool) -> Result<Arc<dyn File>> {
|
||||
let (m, p) = self.route(path, true)?;
|
||||
|
||||
+129
-1
@@ -14,6 +14,23 @@ pub enum ChangeKind {
|
||||
Removed,
|
||||
}
|
||||
|
||||
/// Deferred removal of one observed file, checked against its current name.
|
||||
pub type Unlink = std::pin::Pin<Box<dyn Future<Output = Result<()>> + Send>>;
|
||||
/// Exclusive creations in one process namespace, retained after descriptor closure.
|
||||
#[derive(Clone, Default)]
|
||||
pub struct Creations(Arc<Mutex<Vec<(String, Unlink)>>>);
|
||||
impl Creations {
|
||||
/// Remove selected files that still have their original names. First drain the mount's operations.
|
||||
pub async fn remove(self, selected: fn(&str) -> bool) -> Result<()> {
|
||||
let entries = std::mem::take(&mut *self.0.lock().unwrap());
|
||||
for (path, unlink) in entries {
|
||||
if selected(&path) {
|
||||
unlink.await?;
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
type Namespace = Mutex<BTreeMap<String, Weak<RwLock<()>>>>;
|
||||
enum Held {
|
||||
Read { _guard: OwnedRwLockReadGuard<()> },
|
||||
@@ -37,6 +54,7 @@ pub struct Observer {
|
||||
// Only open-registration versus rename/remove needs ordering. Lock the affected
|
||||
// subtrees, not all IO: a blocked open must not stall unrelated namespaces.
|
||||
namespace: Arc<Namespace>,
|
||||
creations: Arc<Mutex<Option<Creations>>>,
|
||||
}
|
||||
impl Observer {
|
||||
pub fn new(changed: impl Fn(&str) + Send + Sync + 'static) -> Self {
|
||||
@@ -45,8 +63,15 @@ impl Observer {
|
||||
events: Arc::default(),
|
||||
paths: Arc::default(),
|
||||
namespace: Arc::default(),
|
||||
creations: Arc::default(),
|
||||
}
|
||||
}
|
||||
/// Track exclusive creations in this namespace for optional cancellation cleanup.
|
||||
pub fn exclusive_creations(&self) -> Creations {
|
||||
let creations = Creations::default();
|
||||
*self.creations.lock().unwrap() = Some(creations.clone());
|
||||
creations
|
||||
}
|
||||
/// Subscribe before the first mutation of interest. Lag means callers must rescan.
|
||||
pub fn subscribe(
|
||||
&self,
|
||||
@@ -169,6 +194,8 @@ impl Observed {
|
||||
}
|
||||
struct Handle {
|
||||
file: Arc<dyn File>,
|
||||
layer: Arc<dyn Layer>,
|
||||
prefix: String,
|
||||
path: Path,
|
||||
observer: Observer,
|
||||
process: Process,
|
||||
@@ -203,6 +230,7 @@ impl Layer for Observed {
|
||||
self.layer.list(path).await
|
||||
}
|
||||
async fn open(&self, path: &str, options: Open) -> Result<Arc<dyn File>> {
|
||||
let prefix = self.prefix.clone();
|
||||
let (layer, local, path, observer, process) = (
|
||||
self.layer.clone(),
|
||||
path.to_owned(),
|
||||
@@ -217,10 +245,19 @@ impl Layer for Observed {
|
||||
let file = layer.open(&local, options).await?;
|
||||
let handle = Arc::new(Handle {
|
||||
file,
|
||||
layer,
|
||||
prefix,
|
||||
path: observer.path(path.clone()),
|
||||
observer: observer.clone(),
|
||||
process,
|
||||
});
|
||||
if options.create
|
||||
&& options.exclusive
|
||||
&& let Some(creations) = observer.creations.lock().unwrap().as_ref()
|
||||
&& let Some(unlink) = handle.unlink_if_named(path.clone())
|
||||
{
|
||||
creations.0.lock().unwrap().push((path.clone(), unlink));
|
||||
}
|
||||
drop(order);
|
||||
if options.create || options.truncate {
|
||||
observer.notify(
|
||||
@@ -316,6 +353,29 @@ impl Observed {
|
||||
}
|
||||
#[async_trait]
|
||||
impl File for Handle {
|
||||
fn unlink_if_named(&self, expected: String) -> Option<Unlink> {
|
||||
// Prefer the innermost observer: its namespace sees renames by every process.
|
||||
if let Some(unlink) = self.file.unlink_if_named(expected.clone()) {
|
||||
return Some(unlink);
|
||||
}
|
||||
let observer = Observer {
|
||||
creations: Arc::default(),
|
||||
..self.observer.clone()
|
||||
};
|
||||
let (path, layer, prefix) = (self.path.clone(), self.layer.clone(), self.prefix.clone());
|
||||
Some(Box::pin(async move {
|
||||
let order = observer.order(&[(&expected, true)]).await;
|
||||
if path.lock().unwrap().as_deref() != Some(expected.as_str()) {
|
||||
return Ok(());
|
||||
}
|
||||
let local = expected.strip_prefix(&prefix).unwrap();
|
||||
layer.remove_file(local).await?;
|
||||
observer.moved(&expected, None);
|
||||
drop(order);
|
||||
observer.notify(&expected, ChangeKind::Removed);
|
||||
Ok(())
|
||||
}))
|
||||
}
|
||||
async fn stat(&self) -> Result<Stat> {
|
||||
self.file.stat().await
|
||||
}
|
||||
@@ -378,6 +438,72 @@ mod tests {
|
||||
|
||||
use super::*;
|
||||
use tokio::sync::mpsc;
|
||||
#[tokio::test]
|
||||
async fn exclusive_cleanup_survives_close_and_preserves_replacements() {
|
||||
for renamed in [false, true] {
|
||||
let mut shared = Mounts::default();
|
||||
shared.mount("/", Arc::new(Memory::default()), false);
|
||||
let shared: Arc<dyn Layer> = Arc::new(shared);
|
||||
let mut process = Mounts::default();
|
||||
process.mount("/", shared.clone(), false);
|
||||
let creations = process.observer().unwrap().exclusive_creations();
|
||||
let file = process
|
||||
.open(
|
||||
"/index.lock",
|
||||
Open {
|
||||
create: true,
|
||||
exclusive: true,
|
||||
write: true,
|
||||
..Default::default()
|
||||
},
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
file.write(0, b"old".to_vec()).await.unwrap();
|
||||
drop(file);
|
||||
if renamed {
|
||||
// Another namespace publishes the old file and creates a new lock.
|
||||
shared.rename("/index.lock", "/index").await.unwrap();
|
||||
let file = shared
|
||||
.open(
|
||||
"/index.lock",
|
||||
Open {
|
||||
create: true,
|
||||
exclusive: true,
|
||||
write: true,
|
||||
..Default::default()
|
||||
},
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
file.write(0, b"new".to_vec()).await.unwrap();
|
||||
}
|
||||
creations.remove(|_| true).await.unwrap();
|
||||
assert_eq!(shared.stat("/index.lock", false).await.is_ok(), renamed);
|
||||
if renamed {
|
||||
assert_eq!(
|
||||
shared
|
||||
.open("/index.lock", Open::default())
|
||||
.await
|
||||
.unwrap()
|
||||
.read(0, 3)
|
||||
.await
|
||||
.unwrap(),
|
||||
b"new"
|
||||
);
|
||||
assert_eq!(
|
||||
shared
|
||||
.open("/index", Open::default())
|
||||
.await
|
||||
.unwrap()
|
||||
.read(0, 3)
|
||||
.await
|
||||
.unwrap(),
|
||||
b"old"
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
#[test]
|
||||
fn component_matching() {
|
||||
assert!(under("/mailbox/a", "/mailbox"));
|
||||
@@ -577,7 +703,7 @@ mod tests {
|
||||
"/run",
|
||||
Process::default(),
|
||||
);
|
||||
let other = observer.layer(memory, "/run", Process::default());
|
||||
let other = observer.layer(memory.clone(), "/run", Process::default());
|
||||
let opening_task = tokio::spawn(async move {
|
||||
slow.open(
|
||||
"/slow/file",
|
||||
@@ -627,6 +753,8 @@ mod tests {
|
||||
// this write is held in flight. Its notification uses the rebased path.
|
||||
let handle = Handle {
|
||||
file: raw.clone(),
|
||||
layer: memory.clone(),
|
||||
prefix: "/run".into(),
|
||||
path: observer.path("/run/moved/file".into()),
|
||||
observer,
|
||||
process: Process::default(),
|
||||
|
||||
Reference in New Issue
Block a user