94 lines
7.3 KiB
TypeScript
94 lines
7.3 KiB
TypeScript
/** P3 exit scaffold: real world git, scripted coordination, and worker SIGKILL recovery. */
|
|
import { BACKGROUND_CONTEXT as context, awaitWithContext } from '@earendil-works/chord/context';
|
|
import { createModels } from '@earendil-works/pi-ai/models';
|
|
import { Harness, createRegistry, defineExtension, defineTool } from '@earendil-works/pi-durable';
|
|
import { obj } from '@earendil-works/pi-schema';
|
|
import { loadExtensions, fs, exec } from './extensions';
|
|
import { openStorage } from './sqlite';
|
|
import { scriptedModel, testReplay, type Rule } from './scripted-model';
|
|
const assert = (ok: any, message: string) => { if (!ok) throw new Error(message); };
|
|
testReplay();
|
|
if (globalThis.p3Mode !== 'unit') {
|
|
const rules: Rule[] = [
|
|
{ id: 'start-worker', role: 'coordinator', match: { kind: 'user', text: 'begin' }, answer: { tools: [{ name: 'subagent', args: { action: 'start', agent: 'worker', isolate: 'none', task: 'build feature', background: true } }] } },
|
|
{ id: 'start-reviewer', role: 'coordinator', match: { kind: 'user', text: 'worker complete' }, answer: { tools: [{ name: 'subagent', args: { action: 'start', agent: 'reviewer', isolate: 'none', task: 'review feature', background: true } }] } },
|
|
{ id: 'coordinator-done', role: 'coordinator', match: { kind: 'user', text: 'approve' }, answer: { text: 'scenario complete' } },
|
|
{ id: 'coordinator-wait', role: 'coordinator', match: { text: '' }, answer: { text: 'Wait for child report.' } },
|
|
{ id: 'worker-write', role: 'worker', match: { kind: 'user', text: 'build feature' }, answer: { tools: [{ name: 'write-feature', args: {} }] } },
|
|
{ id: 'worker-commit', role: 'worker', match: { text: 'feature written' }, answer: { tools: [{ name: 'commit-feature', args: {} }] } },
|
|
{ id: 'worker-done', role: 'worker', match: { text: 'feature committed' }, answer: { text: 'worker complete' } },
|
|
{ id: 'reviewer-approve', role: 'reviewer', match: { kind: 'user', text: 'review feature' }, answer: { tools: [{ name: 'review-feature', args: {} }] } },
|
|
{ id: 'reviewer-done', role: 'reviewer', match: { text: 'feature verified' }, answer: { text: 'approve' } },
|
|
];
|
|
const models = createModels();
|
|
models.setProvider(scriptedModel(rules, rule => Deno.core.ops.op_output(`scripted rule: ${rule}\n`)));
|
|
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);
|
|
const root = await harness.root(context, { agent: { model: { provider: 'faux', modelId: 'faux-1' }, instructions: 'P3 role:ultimate', cwd: '/src/demo' } });
|
|
const git = async (...args: string[]) => {
|
|
const result = await exec(['/bin/git.wasm', ...args], { cwd: '/src/demo', env: [['GIT_AUTHOR_NAME', 'Test'], ['GIT_AUTHOR_EMAIL', 'test@example.com'], ['GIT_COMMITTER_NAME', 'Test'], ['GIT_COMMITTER_EMAIL', 'test@example.com']] });
|
|
assert(result.code === 0, `World git ${args}: ${JSON.stringify(result)}`);
|
|
return result;
|
|
};
|
|
const tools = defineExtension({ name: 'p3-tools', tools: [
|
|
defineTool({ name: 'write-feature', description: 'Write the demo feature', parameters: obj({}), replay: 'safe', execute: async (_args, _api, ctx) => {
|
|
if (globalThis.p3Mode === 'kill-worker') {
|
|
await fs.write('/worker-running', 'yes');
|
|
await awaitWithContext(new Promise(() => {}), ctx);
|
|
}
|
|
await fs.write('/src/demo/feature.txt', 'P3 feature\n');
|
|
return { output: [{ type: 'text', text: 'feature written' }] };
|
|
} }),
|
|
defineTool({ name: 'commit-feature', description: 'Commit the demo feature', parameters: obj({}), replay: 'safe', execute: async () => {
|
|
await git('add', 'feature.txt');
|
|
// A replay after a committed tool side effect must not create a second commit.
|
|
if ((await git('status', '--porcelain')).stdout.trim()) await git('commit', '--no-gpg-sign', '-m', 'P3 feature');
|
|
return { output: [{ type: 'text', text: 'feature committed' }] };
|
|
} }),
|
|
defineTool({ name: 'review-feature', description: 'Verify the committed demo feature', parameters: obj({}), replay: 'safe', execute: async () => {
|
|
assert(await fs.read('/src/demo/feature.txt') === 'P3 feature\n', 'Reviewer lost feature');
|
|
const result = await git('show', 'HEAD:feature.txt');
|
|
assert(result.stdout === 'P3 feature\n', 'Reviewer did not read the committed feature');
|
|
return { output: [{ type: 'text', text: 'feature verified' }] };
|
|
} }),
|
|
] });
|
|
const loader = await loadExtensions(harness, registry, storage, models, context, [tools]);
|
|
assert(!loader.state().error, `Extension load: ${loader.state().error}`);
|
|
try {
|
|
if (globalThis.p3Mode === 'load-all') {
|
|
assert((await root.agent(context)).tools.some((tool: any) => tool.name === 'branch'), 'Every extension must load, including messages');
|
|
} else {
|
|
if (globalThis.p3Mode === 'kill-worker') {
|
|
const coordinator = await globalThis.p3Models.start({ agent: 'coordinator', cwd: '/src/demo' }, context);
|
|
await fs.write('/coordinator', String(coordinator.id));
|
|
await coordinator.submit({ type: 'input', content: 'begin', requestId: 'p3-begin' }, context);
|
|
}
|
|
harness.resume();
|
|
if (globalThis.p3Mode === 'kill-worker') await new Promise(() => {});
|
|
const coordinator = (await harness.conversation(Number(await fs.read('/coordinator')), context))!;
|
|
// waitForIdle does not wait for background children. Poll the durable outcome first.
|
|
const deadline = Date.now() + 30000;
|
|
while (!JSON.stringify((await coordinator.context(context)).entries).includes('scenario complete')) {
|
|
if (Date.now() > deadline) throw new Error(`P3 did not finish: ${JSON.stringify((await coordinator.context(context)).entries)}`);
|
|
await new Promise(resolve => setTimeout(resolve, 0));
|
|
}
|
|
await harness.waitForIdle(context);
|
|
const entries = (await coordinator.context(context)).entries;
|
|
const reports = entries.filter((entry: any) => entry.kind === 'subagents.report');
|
|
assert(reports.length === 2, `Expected two child reports: ${JSON.stringify(entries)}`);
|
|
assert(new Set(reports.map((report: any) => report.data.sender)).size === 2, 'Expected one report from each child');
|
|
for (const answer of ['worker complete', 'approve']) assert(reports.filter((report: any) => JSON.stringify(report).includes(answer)).length === 1, `Expected exactly one report for ${answer}`);
|
|
assert(JSON.stringify(entries).includes('scenario complete'), 'Coordinator did not finish');
|
|
const faults = Deno.core.ops.op_sql('all', "SELECT id FROM tasks WHERE status = 'faulted'", []);
|
|
assert(faults.length === 0, `Faulted tasks: ${JSON.stringify(faults)}`);
|
|
await fs.write('/p3-idle', 'yes');
|
|
}
|
|
} finally { await loader.close(); await harness.close(context); }
|
|
}
|
|
// PENDING T49: @ultimate calls branch to create the coordinator, then receives one board.reply.
|
|
// PENDING T52: worker isolate:worktree, subagent land, workspace submit, and worktree release.
|
|
// Kill point 2 (ignored until T52): SIGKILL while the lander check waits for /check-release.
|
|
// Restart, release the marker, and run to idle.
|
|
// Kill point 3 (ignored until T52): SIGKILL after the target fast-forward log entry.
|
|
// Restart and run to idle. Assert one target commit, workspaces.landed event, and landing card.
|