Merge branch 'v3' into v3-t17

This commit is contained in:
2026-11-08 01:08:21 +00:00
44 changed files with 62405 additions and 1309 deletions
+20 -2
View File
@@ -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 = ''
+3
View File
@@ -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]`.
+1 -1
View File
@@ -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) : []
+1
View File
@@ -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'
+25 -1
View File
@@ -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.
+4 -3
View File
@@ -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
View File
File diff suppressed because one or more lines are too long
+14 -1
View File
@@ -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 () => {
+8 -2
View File
@@ -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);
+8 -2
View File
@@ -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)
+7 -2
View File
@@ -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 () => {},
};
File diff suppressed because it is too large Load Diff
+31 -2
View File
@@ -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);
+5
View File
@@ -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
View File
@@ -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
View File
@@ -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);
+30 -1
View File
@@ -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);
}
+27 -2
View File
@@ -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)}
+14 -1
View File
@@ -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 -1
View File
@@ -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
+17
View File
@@ -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');
}
+47
View File
@@ -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
View File
@@ -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 () => {
+14 -1
View File
@@ -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 () => {
+4 -2
View File
@@ -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
View File
@@ -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();
+87
View File
@@ -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
View File
@@ -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.
+1
View File
@@ -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 {
+165
View File
@@ -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"]
);
}
+241
View File
@@ -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;
}
+135
View File
@@ -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(())
}
+6 -1
View File
@@ -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"],
+107
View File
@@ -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
}
+1 -1
View File
@@ -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'} }
}
+3 -1
View File
@@ -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(
+1 -1
View File
@@ -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.
+4 -1
View File
@@ -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))?;
+2
View File
@@ -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}
+3
View File
@@ -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
}
+4
View File
@@ -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
View File
@@ -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
View File
@@ -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(),