Merge v3 messages compatibility fix into v3-t51
This commit is contained in:
@@ -1,8 +1,8 @@
|
||||
/** Fork a tree root, record its starter, and admit the first message exactly once. */
|
||||
import { bool, int, obj, opt, str } from '@earendil-works/pi-schema'
|
||||
import { configure, defineTool, ROOT_CONVERSATION_ID } from '@earendil-works/pi-durable'
|
||||
import { discover } from '../subagents/definitions.ts'
|
||||
import { childConfiguration } from '../subagents/children.ts'
|
||||
import { defineTool, ROOT_CONVERSATION_ID } from '@earendil-works/pi-durable'
|
||||
import { ModelsApi } from '../models/api.ts'
|
||||
import { configureChild } from '../subagents/children.ts'
|
||||
import { LineageApi, Roots } from '../lineage/api.ts'
|
||||
import { imagesParameter } from './images.ts'
|
||||
import { send } from './send.ts'
|
||||
@@ -17,14 +17,13 @@ export const branchTool = (host: any) => defineTool({
|
||||
const roots = (await api.snapshot(Roots, ROOT_CONVERSATION_ID, ctx))?.roots ?? []
|
||||
const root = input.root ?? await lineage.rootOf(api.conversationId, ctx) ?? (roots.length === 1 ? roots[0] : undefined)
|
||||
if (root === undefined || !roots.includes(root)) throw new Error('Choose a listed root')
|
||||
const {definitions} = await discover(host, input.repo)
|
||||
const {definitions} = await host.need(ModelsApi).discover(input.repo)
|
||||
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 config = childConfiguration({definition: coordinator, parentAgent: rootAgent, cwd: input.repo})
|
||||
const branch = await lineage.branch({from: root, repo: input.repo, prompt: input.prompt, title: input.title, isolated: input.isolated ?? false, deliver: false, by: api.conversationId, receipt: {taskId: api.taskId, callId: api.callId}, model: config.model, thinkingLevel: config.thinkingLevel}, ctx)
|
||||
const branch = await lineage.branch({from: root, repo: input.repo, prompt: input.prompt, title: input.title, isolated: input.isolated ?? false, deliver: false, by: api.conversationId, receipt: {taskId: api.taskId, callId: api.callId}}, ctx)
|
||||
await host.harness().commit(async (tx: any) => {
|
||||
await configure(tx, branch.id, config)
|
||||
await configureChild(host, tx, branch.id, {definition: coordinator, parentAgent: rootAgent, cwd: input.repo})
|
||||
;(await tx.doc(host.metadata, branch.id)).label = 'coordinator'
|
||||
}, ctx)
|
||||
id = await api.memo('branch-id', branch.id, ctx)
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,93 @@
|
||||
/** 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.
|
||||
@@ -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 automations-test.ts --bundle --format=esm --platform=browser --outfile=automations-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" },
|
||||
"dependencies": {
|
||||
"@earendil-works/pi-durable": "1.5.0",
|
||||
"@earendil-works/pi-ai": "1.5.0",
|
||||
|
||||
@@ -0,0 +1,38 @@
|
||||
/** Stateless role/input rules for deterministic faux-model replay in integration tests. */
|
||||
import { fauxProvider, fauxAssistantMessage, fauxToolCall } from '@earendil-works/pi-ai/providers/faux';
|
||||
export type Answer = { text?: string; tools?: { name: string; args: any }[] };
|
||||
export type Rule = { id: string; role: string; match: { kind?: string; text: string }; answer: Answer };
|
||||
export const textOf = (content: any): string => typeof content === 'string' ? content : (content ?? []).filter((part: any) => part.type === 'text').map((part: any) => part.text).join('\n');
|
||||
export function answerFor(rules: Rule[], transcript: any) {
|
||||
const system = JSON.stringify(transcript.messages.filter((message: any) => message.role === 'system'));
|
||||
const role = /P3 role:([a-z]+)/.exec(system)?.[1];
|
||||
const last = transcript.messages.findLast((message: any) => message.role !== 'system');
|
||||
const rule = rules.find(rule => rule.role === role && (!rule.match.kind || rule.match.kind === last?.role) && textOf(last?.content).includes(rule.match.text));
|
||||
if (!rule) throw new Error(`No scripted rule for ${role}: ${JSON.stringify(last)}`);
|
||||
return { rule: rule.id, answer: rule.answer };
|
||||
}
|
||||
export function scriptedModel(rules: Rule[], report: (rule: string) => void) {
|
||||
const faux = fauxProvider({ tokensPerSecond: 100000 });
|
||||
const respond = (transcript: any): any => {
|
||||
faux.appendResponses([respond]);
|
||||
const selected = answerFor(rules, transcript);
|
||||
report(selected.rule);
|
||||
const calls = selected.answer.tools ?? [];
|
||||
return fauxAssistantMessage([
|
||||
...(selected.answer.text ? [{ type: 'text' as const, text: selected.answer.text }] : []),
|
||||
...calls.map((call, index) => fauxToolCall(call.name, call.args, { id: `${selected.rule}-${index}` })),
|
||||
], { stopReason: calls.length ? 'toolUse' : 'stop', timestamp: 0 });
|
||||
};
|
||||
faux.setResponses([respond]);
|
||||
return faux.provider;
|
||||
}
|
||||
/** Unit test: history length and a provider restart must not change rule selection or the answer. */
|
||||
export function testReplay() {
|
||||
const rules: Rule[] = [{ id: 'worker-write', role: 'worker', match: { kind: 'user', text: 'build' }, answer: { tools: [{ name: 'write', args: { path: 'feature.txt' } }] } }];
|
||||
const system = { role: 'system', content: 'P3 role:worker' }, input = { role: 'user', content: 'build' };
|
||||
const first = answerFor(rules, { messages: [system, input] });
|
||||
const replay = answerFor(JSON.parse(JSON.stringify(rules)), { messages: [system, { role: 'assistant', content: [] }, input] });
|
||||
if (JSON.stringify(first) !== JSON.stringify(replay)) throw new Error('Scripted replay changed the answer');
|
||||
const toolRules: Rule[] = [{ id: 'worker-done', role: 'worker', match: { text: 'committed' }, answer: { text: 'done' } }];
|
||||
if (answerFor(toolRules, { messages: [system, { role: 'toolResult', content: [{ type: 'text', text: 'committed' }] }] }).rule !== 'worker-done') throw new Error('Tool result rule did not match');
|
||||
}
|
||||
@@ -46,6 +46,7 @@ fn prepare(dir: &Path) -> anyhow::Result<()> {
|
||||
"extensions/subagents",
|
||||
"extensions/messages",
|
||||
"extensions/lineage",
|
||||
"extensions/models",
|
||||
"extensions/core",
|
||||
"agents",
|
||||
] {
|
||||
@@ -55,6 +56,7 @@ fn prepare(dir: &Path) -> anyhow::Result<()> {
|
||||
"extensions/subagents",
|
||||
"extensions/messages",
|
||||
"extensions/lineage",
|
||||
"extensions/models",
|
||||
] {
|
||||
for entry in std::fs::read_dir(source.join(directory))? {
|
||||
let entry = entry?;
|
||||
|
||||
@@ -0,0 +1,215 @@
|
||||
//! P3 exit scaffold with a worker SIGKILL and pending lander crash boundaries.
|
||||
use sausage::{harness::run_module, world::World};
|
||||
use std::{
|
||||
path::Path,
|
||||
process::Command,
|
||||
time::{Duration, Instant},
|
||||
};
|
||||
const BUNDLE: &str = include_str!("../js/p3-test-bundle.js");
|
||||
fn git(repo: &Path, args: &[&str]) -> anyhow::Result<String> {
|
||||
let output = Command::new("git")
|
||||
.args(args)
|
||||
.current_dir(repo)
|
||||
.env("GIT_AUTHOR_NAME", "Test")
|
||||
.env("GIT_AUTHOR_EMAIL", "test@example.com")
|
||||
.env("GIT_COMMITTER_NAME", "Test")
|
||||
.env("GIT_COMMITTER_EMAIL", "test@example.com")
|
||||
.output()?;
|
||||
anyhow::ensure!(
|
||||
output.status.success(),
|
||||
"git {args:?}: {}",
|
||||
String::from_utf8_lossy(&output.stderr)
|
||||
);
|
||||
Ok(String::from_utf8(output.stdout)?.trim().into())
|
||||
}
|
||||
fn copy_tree(source: &Path, destination: &Path) -> anyhow::Result<()> {
|
||||
std::fs::create_dir_all(destination)?;
|
||||
for entry in std::fs::read_dir(source)? {
|
||||
let entry = entry?;
|
||||
let target = destination.join(entry.file_name());
|
||||
if entry.file_type()?.is_dir() {
|
||||
copy_tree(&entry.path(), &target)?;
|
||||
} else {
|
||||
std::fs::copy(entry.path(), target)?;
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
fn prepare(dir: &Path, all_extensions: bool) -> anyhow::Result<()> {
|
||||
World::open(dir)?;
|
||||
let disk = dir.join("world");
|
||||
let repo = disk.join("src/sausage");
|
||||
let source = Path::new(env!("CARGO_MANIFEST_DIR")).join("..");
|
||||
let target = Path::new(env!("CARGO_TARGET_TMPDIR")).parent().unwrap();
|
||||
let userland = source.join("userland");
|
||||
if !userland.join("cwd-init/init.o").exists() {
|
||||
anyhow::ensure!(
|
||||
Command::new("nu")
|
||||
.args(["--no-config-file", "cwd-init/build.nu"])
|
||||
.current_dir(&userland)
|
||||
.status()?
|
||||
.success(),
|
||||
"cwd-init build failed"
|
||||
);
|
||||
}
|
||||
anyhow::ensure!(
|
||||
Command::new("cargo")
|
||||
.args(["build", "--release", "-p", "git", "--manifest-path"])
|
||||
.arg(userland.join("Cargo.toml"))
|
||||
.args(["--target", "wasm32-wasip2", "--target-dir"])
|
||||
.arg(target)
|
||||
.args(["-j", "2", "--locked"])
|
||||
.current_dir(&userland)
|
||||
.status()?
|
||||
.success(),
|
||||
"world git build failed"
|
||||
);
|
||||
std::fs::create_dir_all(disk.join("bin"))?;
|
||||
std::fs::copy(
|
||||
target.join("wasm32-wasip2/release/git.wasm"),
|
||||
disk.join("bin/git.wasm"),
|
||||
)?;
|
||||
if all_extensions {
|
||||
copy_tree(&source.join("extensions"), &repo.join("extensions"))?;
|
||||
} else {
|
||||
for extension in ["subagents", "models", "core"] {
|
||||
copy_tree(
|
||||
&source.join("extensions").join(extension),
|
||||
&repo.join("extensions").join(extension),
|
||||
)?;
|
||||
}
|
||||
}
|
||||
std::fs::create_dir_all(repo.join("extensions/probe"))?;
|
||||
std::fs::write(
|
||||
repo.join("extensions/probe/server.ts"),
|
||||
"export default host => { globalThis.p3Models=host.need({name:'models'}); return {name:'probe'}; }",
|
||||
)?;
|
||||
std::fs::create_dir_all(repo.join("agents"))?;
|
||||
std::fs::write(
|
||||
repo.join("agents/models.json"),
|
||||
r#"{"default":{"model":"faux/faux-1"}}"#,
|
||||
)?;
|
||||
for role in ["ultimate", "coordinator", "worker", "reviewer"] {
|
||||
let denial = if role == "worker" || role == "reviewer" {
|
||||
"denyTools: [subagent]\n"
|
||||
} else {
|
||||
""
|
||||
};
|
||||
std::fs::write(
|
||||
repo.join(format!("agents/{role}.md")),
|
||||
format!(
|
||||
"---\nname: {role}\ndescription: P3 {role}\nisolate: none\n{denial}---\nP3 role:{role}\n"
|
||||
),
|
||||
)?;
|
||||
}
|
||||
let demo = disk.join("src/demo");
|
||||
std::fs::create_dir_all(&demo)?;
|
||||
std::fs::write(demo.join("README.md"), "P3 demo\n")?;
|
||||
for repo in [&repo, &demo] {
|
||||
git(repo, &["init", "-b", "main"])?;
|
||||
git(repo, &["add", "."])?;
|
||||
git(repo, &["commit", "--no-gpg-sign", "-m", "P3 fixture"])?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
async fn run(dir: &Path, mode: &str) -> anyhow::Result<()> {
|
||||
let world = World::open(dir)?;
|
||||
let exec = sausage::process::exec(World {
|
||||
root: world.root.clone(),
|
||||
host_dir: world.host_dir.clone(),
|
||||
processes: world.processes.clone(),
|
||||
})?;
|
||||
run_module(
|
||||
world,
|
||||
exec,
|
||||
&format!("globalThis.p3Mode={mode:?};\n{BUNDLE}"),
|
||||
)
|
||||
.await
|
||||
}
|
||||
#[tokio::test(flavor = "current_thread")]
|
||||
async fn scripted_model_replays() -> anyhow::Result<()> {
|
||||
let dir = tempfile::tempdir()?;
|
||||
run(dir.path(), "unit").await
|
||||
}
|
||||
#[tokio::test(flavor = "current_thread")]
|
||||
async fn all_extensions_load() -> anyhow::Result<()> {
|
||||
let dir = tempfile::tempdir()?;
|
||||
prepare(dir.path(), true)?;
|
||||
run(dir.path(), "load-all").await
|
||||
}
|
||||
#[tokio::test(flavor = "current_thread")]
|
||||
async fn restart_child() -> anyhow::Result<()> {
|
||||
if let Ok(dir) = std::env::var("P3_DIR") {
|
||||
run(Path::new(&dir), &std::env::var("P3_MODE")?).await?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
#[test]
|
||||
fn worker_sigkill_recovery() -> anyhow::Result<()> {
|
||||
let dir = tempfile::tempdir()?;
|
||||
prepare(dir.path(), false)?;
|
||||
let spawn = |mode: &str| {
|
||||
Command::new(std::env::current_exe().unwrap())
|
||||
.args(["--exact", "restart_child", "--nocapture"])
|
||||
.env("P3_DIR", dir.path())
|
||||
.env("P3_MODE", mode)
|
||||
.spawn()
|
||||
};
|
||||
let mut child = spawn("kill-worker")?;
|
||||
let deadline = Instant::now() + Duration::from_secs(45);
|
||||
while !dir.path().join("world/worker-running").exists() {
|
||||
if let Some(status) = child.try_wait()? {
|
||||
anyhow::bail!("Worker child exited before marker: {status}");
|
||||
}
|
||||
if Instant::now() > deadline {
|
||||
child.kill()?;
|
||||
child.wait()?;
|
||||
anyhow::bail!("Worker did not reach kill point 1");
|
||||
}
|
||||
std::thread::yield_now();
|
||||
}
|
||||
child.kill()?;
|
||||
let killed = child.wait()?;
|
||||
use std::os::unix::process::ExitStatusExt;
|
||||
assert_eq!(killed.signal(), Some(9));
|
||||
// A second reopen proves that idle recovery does not duplicate child reports or commits.
|
||||
for _ in 0..2 {
|
||||
let mut child = spawn("resume")?;
|
||||
let deadline = Instant::now() + Duration::from_secs(45);
|
||||
loop {
|
||||
if let Some(status) = child.try_wait()? {
|
||||
anyhow::ensure!(status.success(), "P3 recovery failed: {status}");
|
||||
break;
|
||||
}
|
||||
if Instant::now() > deadline {
|
||||
child.kill()?;
|
||||
child.wait()?;
|
||||
anyhow::bail!("P3 recovery did not reach idle");
|
||||
}
|
||||
std::thread::yield_now();
|
||||
}
|
||||
let demo = dir.path().join("world/src/demo");
|
||||
assert_eq!(git(&demo, &["rev-list", "--count", "HEAD"])?, "2");
|
||||
assert_eq!(git(&demo, &["log", "-1", "--format=%s"])?, "P3 feature");
|
||||
assert_eq!(git(&demo, &["status", "--porcelain"])?, "");
|
||||
assert_eq!(
|
||||
std::fs::read_to_string(demo.join("feature.txt"))?,
|
||||
"P3 feature\n"
|
||||
);
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
#[test]
|
||||
#[ignore = "Pending T52: kill point 2 needs the lander check and marker-file gate"]
|
||||
fn lander_check_sigkill_recovery() {
|
||||
// Wait for the check-start marker, SIGKILL, reopen, release /check-release, and run to idle.
|
||||
// Assert one landed event/card and no duplicate board reply (T49).
|
||||
panic!("Pending T52 lander check");
|
||||
}
|
||||
#[test]
|
||||
#[ignore = "Pending T52: kill point 3 needs the target fast-forward log entry"]
|
||||
fn target_fast_forward_sigkill_recovery() {
|
||||
// Detect the durable fast-forward log entry, SIGKILL, reopen, and run to idle.
|
||||
// Assert one target commit, one landed event/card, no faulted tasks, and worktree release.
|
||||
panic!("Pending T52 target fast-forward");
|
||||
}
|
||||
Reference in New Issue
Block a user