agent-dealer 0.2.0 → 1.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/bundle/server/dist/adapters/agent-deck-bind.js +443 -0
- package/bundle/server/dist/adapters/agent-deck-bind.test.js +454 -0
- package/bundle/server/dist/adapters/agent-deck.js +38 -50
- package/bundle/server/dist/adapters/agent-deck.test.js +63 -0
- package/bundle/server/dist/adapters/agent-health.js +230 -33
- package/bundle/server/dist/adapters/agent-health.test.js +237 -0
- package/bundle/server/dist/adapters/authority-lifecycle.js +289 -0
- package/bundle/server/dist/adapters/codex-scoped-config.js +103 -0
- package/bundle/server/dist/adapters/execution-authority.js +103 -0
- package/bundle/server/dist/adapters/execution-authority.test.js +166 -0
- package/bundle/server/dist/adapters/external.js +0 -1
- package/bundle/server/dist/adapters/git-worktree.js +514 -0
- package/bundle/server/dist/adapters/git-worktree.test.js +502 -0
- package/bundle/server/dist/adapters/github.js +170 -0
- package/bundle/server/dist/adapters/github.test.js +205 -0
- package/bundle/server/dist/adapters/linear-inbox.js +199 -30
- package/bundle/server/dist/adapters/linear-inbox.test.js +35 -0
- package/bundle/server/dist/adapters/linear-sync.js +3 -101
- package/bundle/server/dist/adapters/managed-repo.js +168 -0
- package/bundle/server/dist/adapters/managed-repo.test.js +63 -0
- package/bundle/server/dist/adapters/outbound-delivery.js +83 -0
- package/bundle/server/dist/adapters/outbound-delivery.test.js +53 -0
- package/bundle/server/dist/adapters/reflect-authority.js +43 -0
- package/bundle/server/dist/cli-env.js +26 -0
- package/bundle/server/dist/cli-env.test.js +14 -1
- package/bundle/server/dist/coordinator/abort.integration.test.js +74 -0
- package/bundle/server/dist/coordinator/admission.js +330 -0
- package/bundle/server/dist/coordinator/admission.test.js +338 -0
- package/bundle/server/dist/coordinator/args.js +128 -0
- package/bundle/server/dist/coordinator/args.test.js +132 -0
- package/bundle/server/dist/coordinator/authority-recovery.integration.test.js +661 -0
- package/bundle/server/dist/coordinator/auto-merge.integration.test.js +363 -0
- package/bundle/server/dist/coordinator/auto-merge.js +286 -0
- package/bundle/server/dist/coordinator/auto-merge.timeout.test.js +14 -0
- package/bundle/server/dist/coordinator/branch-progress.js +81 -0
- package/bundle/server/dist/coordinator/clock-jump.js +98 -0
- package/bundle/server/dist/coordinator/commands.js +1088 -0
- package/bundle/server/dist/coordinator/commands.test.js +554 -0
- package/bundle/server/dist/coordinator/deck-outage-config.js +32 -0
- package/bundle/server/dist/coordinator/deck-outage.integration.test.js +200 -0
- package/bundle/server/dist/coordinator/dependencies.js +242 -0
- package/bundle/server/dist/coordinator/dependency-readiness.integration.test.js +535 -0
- package/bundle/server/dist/coordinator/developer-effect.js +959 -0
- package/bundle/server/dist/coordinator/developer-effect.test.js +1143 -0
- package/bundle/server/dist/coordinator/dispatcher.js +58 -0
- package/bundle/server/dist/coordinator/dispatcher.test.js +81 -0
- package/bundle/server/dist/coordinator/effect-registry.js +17 -0
- package/bundle/server/dist/coordinator/execution-env.integration.test.js +122 -0
- package/bundle/server/dist/coordinator/failure-reason.js +273 -0
- package/bundle/server/dist/coordinator/failure-reason.test.js +234 -0
- package/bundle/server/dist/coordinator/guidance.js +33 -0
- package/bundle/server/dist/coordinator/guidance.test.js +110 -0
- package/bundle/server/dist/coordinator/human-resolution.js +88 -0
- package/bundle/server/dist/coordinator/human-resolution.test.js +72 -0
- package/bundle/server/dist/coordinator/intent-forecast.js +13 -0
- package/bundle/server/dist/coordinator/intent-forecast.test.js +34 -0
- package/bundle/server/dist/coordinator/latest-failure.js +100 -0
- package/bundle/server/dist/coordinator/latest-failure.test.js +125 -0
- package/bundle/server/dist/coordinator/metrics.js +35 -0
- package/bundle/server/dist/coordinator/metrics.test.js +52 -0
- package/bundle/server/dist/coordinator/permissions.js +127 -0
- package/bundle/server/dist/coordinator/permissions.test.js +184 -0
- package/bundle/server/dist/coordinator/presumed-dead-budget.integration.test.js +146 -0
- package/bundle/server/dist/coordinator/process-liveness.js +191 -0
- package/bundle/server/dist/coordinator/profile-snapshot.js +20 -0
- package/bundle/server/dist/coordinator/profile-snapshot.test.js +118 -0
- package/bundle/server/dist/coordinator/projection.js +147 -0
- package/bundle/server/dist/coordinator/projection.test.js +121 -0
- package/bundle/server/dist/coordinator/prompts.js +164 -0
- package/bundle/server/dist/coordinator/prompts.test.js +177 -0
- package/bundle/server/dist/coordinator/queue-by-default.integration.test.js +271 -0
- package/bundle/server/dist/coordinator/reclaim-republish.integration.test.js +372 -0
- package/bundle/server/dist/coordinator/recovery.js +466 -0
- package/bundle/server/dist/coordinator/recovery.test.js +164 -0
- package/bundle/server/dist/coordinator/reflect-trigger.js +218 -0
- package/bundle/server/dist/coordinator/reflect-trigger.test.js +324 -0
- package/bundle/server/dist/coordinator/repair-cycle.integration.test.js +234 -0
- package/bundle/server/dist/coordinator/restart-recovery.integration.test.js +363 -0
- package/bundle/server/dist/coordinator/reviewer-effect.js +507 -0
- package/bundle/server/dist/coordinator/reviewer-effect.test.js +612 -0
- package/bundle/server/dist/coordinator/reviewer-result.js +38 -0
- package/bundle/server/dist/coordinator/reviewer-result.test.js +45 -0
- package/bundle/server/dist/coordinator/routing.js +160 -0
- package/bundle/server/dist/coordinator/routing.test.js +336 -0
- package/bundle/server/dist/coordinator/session-lifecycle.integration.test.js +127 -0
- package/bundle/server/dist/coordinator/session-lifecycle.js +280 -0
- package/bundle/server/dist/coordinator/session-lifecycle.test.js +121 -0
- package/bundle/server/dist/coordinator/session-progress.js +351 -0
- package/bundle/server/dist/coordinator/session-progress.test.js +190 -0
- package/bundle/server/dist/coordinator/session-timeouts.js +33 -0
- package/bundle/server/dist/coordinator/sleep-recovery.integration.test.js +233 -0
- package/bundle/server/dist/coordinator/spawn.js +49 -0
- package/bundle/server/dist/coordinator/sync-issue-base-branch.js +18 -0
- package/bundle/server/dist/coordinator/usage-cap-config.js +10 -0
- package/bundle/server/dist/coordinator/usage-cap-defer.js +189 -0
- package/bundle/server/dist/coordinator/usage-cap.integration.test.js +193 -0
- package/bundle/server/dist/coordinator/usage.js +40 -0
- package/bundle/server/dist/coordinator/usage.test.js +69 -0
- package/bundle/server/dist/coordinator/verification-receipt.js +333 -0
- package/bundle/server/dist/coordinator/verification-receipt.test.js +382 -0
- package/bundle/server/dist/coordinator/worker-loop.js +363 -0
- package/bundle/server/dist/coordinator/worker-loop.test.js +327 -0
- package/bundle/server/dist/coordinator/workflow-instances.test.js +45 -0
- package/bundle/server/dist/coordinator/worktree-owner-liveness.js +82 -0
- package/bundle/server/dist/coordinator/worktree-owner-liveness.test.js +199 -0
- package/bundle/server/dist/db/artifacts-issue-columns.test.js +18 -0
- package/bundle/server/dist/db/authority-attempts-stale-at-migration.test.js +65 -0
- package/bundle/server/dist/db/idempotency-index-migration.test.js +57 -0
- package/bundle/server/dist/db/index.js +359 -7
- package/bundle/server/dist/db/linear-inbox-unlock.test.js +67 -0
- package/bundle/server/dist/db/migrate-to-issues.js +824 -0
- package/bundle/server/dist/db/migrate-to-issues.test.js +842 -0
- package/bundle/server/dist/db/schema.sql +311 -1
- package/bundle/server/dist/db/schema.test.js +66 -0
- package/bundle/server/dist/db/worker-session-pid-migration.test.js +112 -0
- package/bundle/server/dist/dev-review-cli-happy-path.integration.test.js +338 -0
- package/bundle/server/dist/direct-start-liveness.integration.test.js +350 -0
- package/bundle/server/dist/index.js +58 -5
- package/bundle/server/dist/paths.js +40 -4
- package/bundle/server/dist/paths.test.js +42 -0
- package/bundle/server/dist/pid-marker-claim-child.js +18 -0
- package/bundle/server/dist/pid-marker-hold-arbiter-child.js +24 -0
- package/bundle/server/dist/pid-marker.js +141 -0
- package/bundle/server/dist/pid-marker.test.js +208 -0
- package/bundle/server/dist/queue/approve-deliver.js +128 -28
- package/bundle/server/dist/queue/reflect-schedule.js +25 -0
- package/bundle/server/dist/queue/result-qa.js +1 -1
- package/bundle/server/dist/queue/send-gate.test.js +172 -36
- package/bundle/server/dist/repository/agents.js +55 -18
- package/bundle/server/dist/repository/artifacts-for-issue.js +42 -0
- package/bundle/server/dist/repository/artifacts-for-issue.test.js +48 -0
- package/bundle/server/dist/repository/artifacts.js +47 -0
- package/bundle/server/dist/repository/artifacts.test.js +48 -0
- package/bundle/server/dist/repository/authority-attempts.js +189 -0
- package/bundle/server/dist/repository/findings.js +70 -0
- package/bundle/server/dist/repository/findings.test.js +68 -0
- package/bundle/server/dist/repository/human-actions.js +142 -0
- package/bundle/server/dist/repository/human-actions.test.js +123 -0
- package/bundle/server/dist/repository/intake-settings.js +5 -59
- package/bundle/server/dist/repository/issues.js +305 -0
- package/bundle/server/dist/repository/issues.test.js +69 -0
- package/bundle/server/dist/repository/outbound-drafts.js +28 -0
- package/bundle/server/dist/repository/queue-entries.js +162 -0
- package/bundle/server/dist/repository/review-publications.js +153 -0
- package/bundle/server/dist/repository/review-publications.test.js +78 -0
- package/bundle/server/dist/repository/runs-external-id.test.js +5 -3
- package/bundle/server/dist/repository/runs.js +4 -211
- package/bundle/server/dist/repository/runtime-availability.js +54 -0
- package/bundle/server/dist/repository/usage-events.js +60 -0
- package/bundle/server/dist/repository/usage-events.test.js +56 -0
- package/bundle/server/dist/repository/work-items.js +305 -0
- package/bundle/server/dist/repository/work-items.test.js +142 -0
- package/bundle/server/dist/repository/worker-sessions.js +219 -0
- package/bundle/server/dist/repository/worker-sessions.test.js +60 -0
- package/bundle/server/dist/repository/workflow-events.js +193 -0
- package/bundle/server/dist/repository/workflow-events.test.js +89 -0
- package/bundle/server/dist/resolve-tsx-bin.js +19 -0
- package/bundle/server/dist/routes/human-actions.js +68 -0
- package/bundle/server/dist/routes/human-actions.test.js +284 -0
- package/bundle/server/dist/routes/index.js +29 -496
- package/bundle/server/dist/routes/issues.js +240 -0
- package/bundle/server/dist/routes/issues.test.js +451 -0
- package/bundle/server/dist/routes/queue.js +41 -0
- package/bundle/server/dist/routes/queue.test.js +78 -0
- package/bundle/server/dist/runners/claude-args.js +18 -30
- package/bundle/server/dist/runners/claude.js +19 -163
- package/bundle/server/dist/runners/claude.test.js +15 -70
- package/bundle/server/dist/runners/codex-args.js +59 -0
- package/bundle/server/dist/runners/codex-args.test.js +72 -0
- package/bundle/server/dist/runners/codex-jsonl.js +150 -0
- package/bundle/server/dist/runners/codex-jsonl.test.js +36 -0
- package/bundle/server/dist/runners/codex-resume.test.js +19 -0
- package/bundle/server/dist/runners/codex.js +46 -0
- package/bundle/server/dist/runners/models-codex.test.js +14 -0
- package/bundle/server/dist/runners/models.js +53 -1
- package/bundle/server/dist/runners/persist.js +4 -1
- package/bundle/server/dist/runners/process-registry.js +11 -0
- package/bundle/server/dist/runners/prompts.js +13 -280
- package/bundle/server/dist/runners/prompts.test.js +22 -153
- package/bundle/server/dist/runners/qa.js +17 -1
- package/bundle/server/dist/runners/reflect.js +4 -16
- package/bundle/server/dist/runners/spawn-cli.js +197 -0
- package/bundle/server/dist/runners/spawn-cli.test.js +130 -0
- package/bundle/server/dist/runners/stream-json.js +0 -141
- package/bundle/server/dist/runners/usage-cap.js +242 -0
- package/bundle/server/dist/runners/usage-cap.test.js +97 -0
- package/bundle/server/dist/server-liveness.js +37 -0
- package/bundle/server/dist/server-liveness.test.js +82 -0
- package/bundle/server/dist/static-ui.test.js +44 -0
- package/bundle/server/package.json +4 -2
- package/bundle/server/static-ui/assets/index-BTtPGYpY.js +60 -0
- package/bundle/server/static-ui/assets/index-BxTx4b1d.css +1 -0
- package/bundle/server/static-ui/index.html +2 -2
- package/bundle/shared/dist/agents.d.ts +262 -133
- package/bundle/shared/dist/agents.js +85 -19
- package/bundle/shared/dist/cursor-auth-health.d.ts +12 -0
- package/bundle/shared/dist/cursor-auth-health.js +36 -0
- package/bundle/shared/dist/cursor-auth-health.test.d.ts +1 -0
- package/bundle/shared/dist/cursor-auth-health.test.js +40 -0
- package/bundle/shared/dist/findings.d.ts +46 -0
- package/bundle/shared/dist/findings.js +17 -0
- package/bundle/shared/dist/findings.test.d.ts +1 -0
- package/bundle/shared/dist/findings.test.js +20 -0
- package/bundle/shared/dist/github-repo.d.ts +28 -0
- package/bundle/shared/dist/github-repo.js +98 -0
- package/bundle/shared/dist/github-repo.test.d.ts +1 -0
- package/bundle/shared/dist/github-repo.test.js +32 -0
- package/bundle/shared/dist/human-actions.d.ts +66 -0
- package/bundle/shared/dist/human-actions.js +51 -0
- package/bundle/shared/dist/human-actions.test.d.ts +1 -0
- package/bundle/shared/dist/human-actions.test.js +57 -0
- package/bundle/shared/dist/index.d.ts +567 -757
- package/bundle/shared/dist/index.js +22 -36
- package/bundle/shared/dist/issues.d.ts +385 -0
- package/bundle/shared/dist/issues.js +169 -0
- package/bundle/shared/dist/issues.test.d.ts +1 -0
- package/bundle/shared/dist/issues.test.js +72 -0
- package/bundle/shared/dist/outbound-draft.d.ts +6 -0
- package/bundle/shared/dist/outbound-draft.js +4 -0
- package/bundle/shared/dist/plan-triage.d.ts +6 -6
- package/bundle/shared/dist/profile-snapshot.d.ts +154 -0
- package/bundle/shared/dist/profile-snapshot.js +145 -0
- package/bundle/shared/dist/profile-snapshot.test.d.ts +1 -0
- package/bundle/shared/dist/profile-snapshot.test.js +67 -0
- package/bundle/shared/dist/queue-entries.d.ts +89 -0
- package/bundle/shared/dist/queue-entries.js +19 -0
- package/bundle/shared/dist/result-qa.d.ts +2 -2
- package/bundle/shared/dist/runtime-auth-health.d.ts +66 -0
- package/bundle/shared/dist/runtime-auth-health.js +186 -0
- package/bundle/shared/dist/runtime-auth-health.test.d.ts +1 -0
- package/bundle/shared/dist/runtime-auth-health.test.js +200 -0
- package/bundle/shared/dist/runtime-availability.d.ts +51 -0
- package/bundle/shared/dist/runtime-availability.js +13 -0
- package/bundle/shared/dist/runtime.d.ts +1 -1
- package/bundle/shared/dist/runtime.js +1 -1
- package/bundle/shared/dist/usage-events.d.ts +36 -0
- package/bundle/shared/dist/usage-events.js +13 -0
- package/bundle/shared/dist/worker-sessions.d.ts +140 -0
- package/bundle/shared/dist/worker-sessions.js +70 -0
- package/bundle/shared/dist/worker-sessions.test.d.ts +1 -0
- package/bundle/shared/dist/worker-sessions.test.js +39 -0
- package/bundle/shared/dist/workflow.d.ts +78 -0
- package/bundle/shared/dist/workflow.js +59 -0
- package/bundle/shared/dist/workflow.test.d.ts +1 -0
- package/bundle/shared/dist/workflow.test.js +61 -0
- package/bundle/shared/package.json +1 -1
- package/dist/action.d.ts +1 -0
- package/dist/action.js +50 -0
- package/dist/action.test.d.ts +1 -0
- package/dist/action.test.js +70 -0
- package/dist/agent.d.ts +1 -0
- package/dist/agent.js +17 -0
- package/dist/agent.test.d.ts +1 -0
- package/dist/agent.test.js +19 -0
- package/dist/cli-check.d.ts +7 -0
- package/dist/cli-check.js +40 -0
- package/dist/doctor.js +59 -1
- package/dist/http.d.ts +5 -0
- package/dist/http.js +19 -0
- package/dist/index.js +28 -0
- package/dist/issue.d.ts +35 -0
- package/dist/issue.js +143 -0
- package/dist/issue.test.d.ts +1 -0
- package/dist/issue.test.js +240 -0
- package/dist/lifecycle.contract.test.d.ts +1 -0
- package/dist/lifecycle.contract.test.js +135 -0
- package/dist/queue.d.ts +11 -0
- package/dist/queue.js +60 -0
- package/dist/queue.test.d.ts +1 -0
- package/dist/queue.test.js +46 -0
- package/dist/test-fetch-stub.d.ts +6 -0
- package/dist/test-fetch-stub.js +31 -0
- package/package.json +2 -1
- package/templates/prod.env.example +1 -1
- package/bundle/server/static-ui/assets/index-B_7S7xJb.js +0 -79
- package/bundle/server/static-ui/assets/index-QYv3zmE6.css +0 -1
|
@@ -7,19 +7,10 @@ process.env.AGENT_DEALER_HOME = fs.mkdtempSync(path.join(os.tmpdir(), "dealer-pr
|
|
|
7
7
|
process.env.MAX_CONCURRENT_RUNS = "0";
|
|
8
8
|
const { migrate } = await import("../db/index.js");
|
|
9
9
|
const { BUILTIN_AGENT_CLAUDE_ID } = await import("@agent-dealer/shared");
|
|
10
|
-
const { updateAgent } = await import("../repository/agents.js");
|
|
11
10
|
const { addArtifact, createRun } = await import("../repository/runs.js");
|
|
12
|
-
const {
|
|
13
|
-
const QUESTIONS = [
|
|
14
|
-
{
|
|
15
|
-
id: "q1",
|
|
16
|
-
question: "Which storage backend?",
|
|
17
|
-
options: [{ label: "SQLite" }, { label: "Postgres" }],
|
|
18
|
-
},
|
|
19
|
-
];
|
|
11
|
+
const { buildReflectPrompt } = await import("./prompts.js");
|
|
20
12
|
before(() => {
|
|
21
13
|
migrate();
|
|
22
|
-
updateAgent(BUILTIN_AGENT_CLAUDE_ID, { workspaceRoot: process.env.AGENT_DEALER_HOME });
|
|
23
14
|
});
|
|
24
15
|
function makeRun() {
|
|
25
16
|
return createRun({
|
|
@@ -27,159 +18,37 @@ function makeRun() {
|
|
|
27
18
|
taskCategory: "other",
|
|
28
19
|
status: "plan_pending",
|
|
29
20
|
agentId: BUILTIN_AGENT_CLAUDE_ID,
|
|
21
|
+
repo: "acme/app"
|
|
30
22
|
});
|
|
31
23
|
}
|
|
32
|
-
test("
|
|
33
|
-
const prompt =
|
|
34
|
-
assert.match(prompt, /
|
|
35
|
-
assert.match(prompt, /
|
|
36
|
-
assert.match(prompt, /
|
|
24
|
+
test("reflect prompt restates the task and the read-only patch contract", () => {
|
|
25
|
+
const prompt = buildReflectPrompt(makeRun(), { trigger: "approve" });
|
|
26
|
+
assert.match(prompt, /Prompt test task/);
|
|
27
|
+
assert.match(prompt, /do NOT call update_playbook or propose_playbook_patch/);
|
|
28
|
+
assert.match(prompt, /"rationale"/);
|
|
29
|
+
assert.match(prompt, /Human approved this run without retry feedback/);
|
|
37
30
|
});
|
|
38
|
-
test("
|
|
39
|
-
const run = createRun({
|
|
40
|
-
title: "Send a test message to Yusuke",
|
|
41
|
-
description: "Say hello from agent-dealer",
|
|
42
|
-
taskCategory: "communication",
|
|
43
|
-
status: "plan_pending",
|
|
44
|
-
agentId: BUILTIN_AGENT_CLAUDE_ID,
|
|
45
|
-
});
|
|
46
|
-
const prompt = buildPlanPrompt(run);
|
|
47
|
-
assert.match(prompt, /Outbound send gate/);
|
|
48
|
-
assert.match(prompt, /send gate reviews the exact payload/i);
|
|
49
|
-
assert.match(prompt, /Do not ask plan questions about recipient/i);
|
|
50
|
-
assert.doesNotMatch(prompt, /When in doubt, use "needs_review"/);
|
|
51
|
-
assert.match(prompt, /brief bullet plan/i);
|
|
52
|
-
assert.doesNotMatch(prompt, /step-by-step plan with risks/);
|
|
53
|
-
});
|
|
54
|
-
test("other category plan prompt keeps conservative when-in-doubt rule", () => {
|
|
55
|
-
const prompt = buildPlanPrompt(makeRun());
|
|
56
|
-
assert.match(prompt, /When in doubt, use "needs_review"/);
|
|
57
|
-
assert.match(prompt, /step-by-step plan with risks/);
|
|
58
|
-
});
|
|
59
|
-
test("execution prompt renders human answers as Q→A pairs", () => {
|
|
60
|
-
const run = makeRun();
|
|
61
|
-
addArtifact(run.id, "draft_plan", { markdown: "# Plan" }, "agent");
|
|
62
|
-
addArtifact(run.id, "plan_triage", { verdict: "needs_review", rationale: "r", questions: QUESTIONS, parseFallback: false, consumed: false }, "agent");
|
|
63
|
-
addArtifact(run.id, "approved_plan", { markdown: "# Plan" }, "human");
|
|
64
|
-
addArtifact(run.id, "plan_answers", {
|
|
65
|
-
answers: [{ questionId: "q1", selectedLabel: "SQLite" }],
|
|
66
|
-
outcome: "approved",
|
|
67
|
-
answeredAt: new Date().toISOString(),
|
|
68
|
-
}, "human");
|
|
69
|
-
const prompt = buildExecutionPrompt(run);
|
|
70
|
-
assert.match(prompt, /## Human answers to plan questions/);
|
|
71
|
-
assert.match(prompt, /Which storage backend\?.*SQLite/s);
|
|
72
|
-
});
|
|
73
|
-
test("execution prompt omits answers section when none exist", () => {
|
|
74
|
-
const run = makeRun();
|
|
75
|
-
addArtifact(run.id, "approved_plan", { markdown: "# Plan" }, "human");
|
|
76
|
-
assert.doesNotMatch(buildExecutionPrompt(run), /Human answers to plan questions/);
|
|
77
|
-
});
|
|
78
|
-
test("feedback replan prompt includes human comments and contract", () => {
|
|
79
|
-
const run = makeRun();
|
|
80
|
-
const prompt = buildPlanFeedbackPrompt(run, "Add integration tests and skip the migration step.");
|
|
81
|
-
assert.match(prompt, /Add integration tests/);
|
|
82
|
-
assert.match(prompt, /"verdict"/);
|
|
83
|
-
});
|
|
84
|
-
test("edited replan prompt includes human-edited markdown", () => {
|
|
85
|
-
const run = makeRun();
|
|
86
|
-
const prompt = buildPlanEditedReplanPrompt(run, "# Revised plan\n\n1. Ship feature");
|
|
87
|
-
assert.match(prompt, /Revised plan/);
|
|
88
|
-
assert.match(prompt, /Ship feature/);
|
|
89
|
-
});
|
|
90
|
-
test("revise prompt pairs each answer with its question and re-states the contract", () => {
|
|
91
|
-
const run = makeRun();
|
|
92
|
-
const prompt = buildPlanRevisePrompt(run, QUESTIONS, [{ questionId: "q1", freeText: "Use flat files instead" }]);
|
|
93
|
-
assert.match(prompt, /Which storage backend\?/);
|
|
94
|
-
assert.match(prompt, /Use flat files instead/);
|
|
95
|
-
assert.match(prompt, /"verdict"/);
|
|
96
|
-
});
|
|
97
|
-
test("delegated plan answers surface the unanswered questions to the executor", () => {
|
|
98
|
-
const run = makeRun();
|
|
99
|
-
addArtifact(run.id, "draft_plan", { markdown: "# Plan" }, "agent");
|
|
100
|
-
addArtifact(run.id, "plan_triage", {
|
|
101
|
-
verdict: "needs_review", rationale: "r", questions: QUESTIONS, parseFallback: false, consumed: true,
|
|
102
|
-
}, "agent");
|
|
103
|
-
addArtifact(run.id, "approved_plan", { markdown: "# Plan" }, "human");
|
|
104
|
-
addArtifact(run.id, "plan_answers", { answers: [], outcome: "delegated", answeredAt: new Date().toISOString() }, "human");
|
|
105
|
-
const prompt = buildExecutionPrompt(run);
|
|
106
|
-
assert.match(prompt, /## Unanswered plan questions/);
|
|
107
|
-
assert.match(prompt, /Which storage backend\?/);
|
|
108
|
-
assert.match(prompt, /SQLite/);
|
|
109
|
-
assert.match(prompt, /best judgment/i);
|
|
110
|
-
assert.doesNotMatch(prompt, /## Human answers to plan questions/);
|
|
111
|
-
});
|
|
112
|
-
test("execution prompt renders answered review Q&A from the lineage parent", () => {
|
|
113
|
-
const parent = makeRun();
|
|
114
|
-
addArtifact(parent.id, "approved_plan", { markdown: "# Plan" }, "human");
|
|
115
|
-
addArtifact(parent.id, "result_qa", {
|
|
116
|
-
exchangeId: "x1",
|
|
117
|
-
question: "Did you run the tests?",
|
|
118
|
-
answer: "Yes, all 12 pass.",
|
|
119
|
-
status: "answered",
|
|
120
|
-
sessionResumed: true,
|
|
121
|
-
askedAt: "2026-07-08T00:00:00.000Z",
|
|
122
|
-
answeredAt: "2026-07-08T00:01:00.000Z",
|
|
123
|
-
}, "agent");
|
|
124
|
-
addArtifact(parent.id, "result_qa", {
|
|
125
|
-
exchangeId: "x2",
|
|
126
|
-
question: "Pending one",
|
|
127
|
-
status: "pending",
|
|
128
|
-
sessionResumed: true,
|
|
129
|
-
askedAt: "2026-07-08T00:02:00.000Z",
|
|
130
|
-
}, "human");
|
|
131
|
-
const retry = createRun({
|
|
132
|
-
title: "Prompt test task",
|
|
133
|
-
taskCategory: "other",
|
|
134
|
-
status: "plan_approved",
|
|
135
|
-
agentId: BUILTIN_AGENT_CLAUDE_ID,
|
|
136
|
-
}, { lineageId: parent.lineageId ?? parent.id });
|
|
137
|
-
addArtifact(retry.id, "approved_plan", { markdown: "# Plan" }, "human");
|
|
138
|
-
addArtifact(retry.id, "feedback", { markdown: "Tighten the summary" }, "human");
|
|
139
|
-
const prompt = buildExecutionPrompt(retry);
|
|
140
|
-
assert.match(prompt, /## Review Q&A/);
|
|
141
|
-
assert.match(prompt, /Did you run the tests\?/);
|
|
142
|
-
assert.match(prompt, /Yes, all 12 pass\./);
|
|
143
|
-
assert.doesNotMatch(prompt, /Pending one/);
|
|
144
|
-
});
|
|
145
|
-
test("execution prompt omits the Q&A section when there are no answered exchanges", () => {
|
|
146
|
-
const run = makeRun();
|
|
147
|
-
addArtifact(run.id, "approved_plan", { markdown: "# Plan" }, "human");
|
|
148
|
-
assert.doesNotMatch(buildExecutionPrompt(run), /## Review Q&A/);
|
|
149
|
-
});
|
|
150
|
-
test("grounded qa prompt asks the question without re-stating artifacts", () => {
|
|
151
|
-
const run = makeRun();
|
|
152
|
-
addArtifact(run.id, "approved_plan", { markdown: "# Plan\n1. Use SQLite" }, "human");
|
|
153
|
-
const prompt = buildQaPrompt(run, "Why SQLite?", { grounded: true });
|
|
154
|
-
assert.match(prompt, /Why SQLite\?/);
|
|
155
|
-
assert.match(prompt, /Do NOT modify anything/i);
|
|
156
|
-
assert.doesNotMatch(prompt, /## Approved plan/);
|
|
157
|
-
});
|
|
158
|
-
test("ungrounded qa prompt rebuilds context from artifacts", () => {
|
|
31
|
+
test("reflect prompt carries the plan and execution artifacts when they exist", () => {
|
|
159
32
|
const run = makeRun();
|
|
160
33
|
addArtifact(run.id, "approved_plan", { markdown: "# Plan\n1. Use SQLite" }, "human");
|
|
161
34
|
addArtifact(run.id, "execution_result", { phase: "execute", exitCode: 0, resultText: "Wrote the doc", isError: false }, "agent");
|
|
162
|
-
|
|
163
|
-
const prompt = buildQaPrompt(run, "Why SQLite?", { grounded: false });
|
|
35
|
+
const prompt = buildReflectPrompt(run, { trigger: "approve" });
|
|
164
36
|
assert.match(prompt, /## Approved plan/);
|
|
37
|
+
assert.match(prompt, /Use SQLite/);
|
|
165
38
|
assert.match(prompt, /## Execution outcome/);
|
|
166
|
-
assert.match(prompt,
|
|
167
|
-
assert.match(prompt, /# Deliverable body/);
|
|
168
|
-
assert.match(prompt, /Why SQLite\?/);
|
|
39
|
+
assert.match(prompt, /Wrote the doc/);
|
|
169
40
|
});
|
|
170
|
-
test("
|
|
41
|
+
test("retry reflect prompt prefers the explicit feedback over the stored artifact", () => {
|
|
171
42
|
const run = makeRun();
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
assert.
|
|
43
|
+
addArtifact(run.id, "feedback", { markdown: "Stored feedback" }, "human");
|
|
44
|
+
const prompt = buildReflectPrompt(run, { trigger: "retry", feedback: "Tighten the summary" });
|
|
45
|
+
assert.match(prompt, /## Human feedback \(highest signal\)/);
|
|
46
|
+
assert.match(prompt, /Tighten the summary/);
|
|
47
|
+
assert.doesNotMatch(prompt, /Stored feedback/);
|
|
175
48
|
});
|
|
176
|
-
test("
|
|
49
|
+
test("retry reflect prompt falls back to the run's own human feedback artifact", () => {
|
|
177
50
|
const run = makeRun();
|
|
178
|
-
addArtifact(run.id, "
|
|
179
|
-
const prompt =
|
|
180
|
-
assert.match(prompt, /
|
|
181
|
-
assert.match(prompt, /call_service_tool IS available/);
|
|
182
|
-
assert.doesNotMatch(prompt, /call_service_tool is blocked/);
|
|
183
|
-
assert.match(prompt, /service_tool_call/);
|
|
184
|
-
assert.match(prompt, /"actionType"/);
|
|
51
|
+
addArtifact(run.id, "feedback", { markdown: "Stored feedback" }, "human");
|
|
52
|
+
const prompt = buildReflectPrompt(run, { trigger: "retry" });
|
|
53
|
+
assert.match(prompt, /Stored feedback/);
|
|
185
54
|
});
|
|
@@ -1,6 +1,8 @@
|
|
|
1
1
|
import { runClaude, runCursor } from "./claude.js";
|
|
2
|
+
import { runCodex } from "./codex.js";
|
|
2
3
|
import { buildQaPrompt } from "./prompts.js";
|
|
3
4
|
import { extractResultIsError, extractResultText, extractUsage, parseNdjson } from "./stream-json.js";
|
|
5
|
+
import { normalizeCodexEvents, parseCodexJsonl } from "./codex-jsonl.js";
|
|
4
6
|
/** One read-only Q&A turn against a finished run. Resumes the execute session when we still have it. */
|
|
5
7
|
export async function runQa(run, question, resumeSessionId) {
|
|
6
8
|
let result;
|
|
@@ -19,13 +21,27 @@ export async function runQa(run, question, resumeSessionId) {
|
|
|
19
21
|
});
|
|
20
22
|
}
|
|
21
23
|
}
|
|
24
|
+
else if (runtime === "codex_local") {
|
|
25
|
+
const prompt = buildQaPrompt(run, question, { grounded: Boolean(resumeSessionId) });
|
|
26
|
+
result = await runCodex(run, "qa", executeModel ?? undefined, {
|
|
27
|
+
promptOverride: prompt,
|
|
28
|
+
...(resumeSessionId ? { resumeSessionId } : {}),
|
|
29
|
+
});
|
|
30
|
+
if (result.exitCode !== 0 && resumeSessionId) {
|
|
31
|
+
result = await runCodex(run, "qa", executeModel ?? undefined, {
|
|
32
|
+
promptOverride: buildQaPrompt(run, question, { grounded: false }),
|
|
33
|
+
});
|
|
34
|
+
}
|
|
35
|
+
}
|
|
22
36
|
else {
|
|
23
37
|
result = await runClaude(run, "qa", executeModel, {
|
|
24
38
|
promptOverride: buildQaPrompt(run, question, { grounded: Boolean(resumeSessionId) }),
|
|
25
39
|
...(resumeSessionId ? { resumeSessionId } : {}),
|
|
26
40
|
});
|
|
27
41
|
}
|
|
28
|
-
const events =
|
|
42
|
+
const events = runtime === "codex_local"
|
|
43
|
+
? normalizeCodexEvents(parseCodexJsonl(result.transcript))
|
|
44
|
+
: parseNdjson(result.transcript);
|
|
29
45
|
const usage = extractUsage(events, "qa", runtime);
|
|
30
46
|
const answer = extractResultText(events)?.trim() ?? "";
|
|
31
47
|
const failed = result.exitCode !== 0 || extractResultIsError(events) || !answer;
|
|
@@ -1,7 +1,7 @@
|
|
|
1
|
-
import {
|
|
2
|
-
import { addArtifact
|
|
1
|
+
import { ReflectProposalSchema } from "@agent-dealer/shared";
|
|
2
|
+
import { addArtifact } from "../repository/runs.js";
|
|
3
3
|
import { agentDeckPatchesUrl, checkAgentDeckHealth, fetchPlaybook, proposePlaybookPatch, } from "../adapters/agent-deck.js";
|
|
4
|
-
import {
|
|
4
|
+
import { runClaudeReflect } from "./claude.js";
|
|
5
5
|
import { buildReflectPrompt } from "./prompts.js";
|
|
6
6
|
import { extractResultText, parseNdjson } from "./stream-json.js";
|
|
7
7
|
function parseReflectProposal(text) {
|
|
@@ -35,7 +35,7 @@ export async function runReflect(run, opts) {
|
|
|
35
35
|
return;
|
|
36
36
|
}
|
|
37
37
|
const playbook = await fetchPlaybook(run.playbookId);
|
|
38
|
-
const result = await
|
|
38
|
+
const result = await runClaudeReflect(run, { promptOverride: buildReflectPrompt(run, opts) });
|
|
39
39
|
const events = parseNdjson(result.transcript);
|
|
40
40
|
const resultText = extractResultText(events) ?? result.transcript;
|
|
41
41
|
const proposal = parseReflectProposal(resultText);
|
|
@@ -76,15 +76,3 @@ export async function runReflect(run, opts) {
|
|
|
76
76
|
addArtifact(run.id, "reflect_status", { status: "failed", trigger: opts.trigger, error: String(err) }, "system");
|
|
77
77
|
}
|
|
78
78
|
}
|
|
79
|
-
export function latestProposedPatch(runId) {
|
|
80
|
-
const art = getLatestArtifact(runId, "playbook_patch");
|
|
81
|
-
if (!art?.contentJson)
|
|
82
|
-
return null;
|
|
83
|
-
try {
|
|
84
|
-
const parsed = PlaybookPatchContent.parse(JSON.parse(art.contentJson));
|
|
85
|
-
return parsed.status === "proposed" ? parsed : null;
|
|
86
|
-
}
|
|
87
|
-
catch {
|
|
88
|
-
return null;
|
|
89
|
-
}
|
|
90
|
-
}
|
|
@@ -0,0 +1,197 @@
|
|
|
1
|
+
import { spawn } from "node:child_process";
|
|
2
|
+
import fs from "node:fs";
|
|
3
|
+
import path from "node:path";
|
|
4
|
+
import { getTemporalLogsDir } from "../paths.js";
|
|
5
|
+
import { acquireSpawnSlot, killRunProcess, registerChild, releaseSpawnSlot, unregisterChild, } from "./process-registry.js";
|
|
6
|
+
export { getActiveLogPath, killRunProcess } from "./process-registry.js";
|
|
7
|
+
export function timeoutMsForMode(mode) {
|
|
8
|
+
const envKey = mode === "plan"
|
|
9
|
+
? "PLAN_TIMEOUT_MS"
|
|
10
|
+
: mode === "execute"
|
|
11
|
+
? "EXECUTE_TIMEOUT_MS"
|
|
12
|
+
: mode === "reflect"
|
|
13
|
+
? "REFLECT_TIMEOUT_MS"
|
|
14
|
+
: "QA_TIMEOUT_MS";
|
|
15
|
+
const defaults = {
|
|
16
|
+
plan: 15 * 60_000,
|
|
17
|
+
execute: 60 * 60_000,
|
|
18
|
+
reflect: 10 * 60_000,
|
|
19
|
+
qa: 5 * 60_000,
|
|
20
|
+
};
|
|
21
|
+
const raw = process.env[envKey];
|
|
22
|
+
if (raw !== undefined && raw !== "")
|
|
23
|
+
return Number(raw);
|
|
24
|
+
return defaults[mode];
|
|
25
|
+
}
|
|
26
|
+
/**
|
|
27
|
+
* Grace period between the SIGTERM an abort sends and the SIGKILL that follows it. The
|
|
28
|
+
* agent CLIs spawn their own children (login shells, test runners), and they reap those
|
|
29
|
+
* themselves on SIGTERM the way they do on Ctrl-C — so terminating politely first is what
|
|
30
|
+
* actually cleans up the whole tree. SIGKILL is only the backstop for a CLI that ignores
|
|
31
|
+
* the first signal.
|
|
32
|
+
*/
|
|
33
|
+
function abortKillGraceMs() {
|
|
34
|
+
return Number(process.env.SPAWN_ABORT_KILL_GRACE_MS ?? 5_000);
|
|
35
|
+
}
|
|
36
|
+
export async function spawnCli(runId, cmd, args, cwd, opts) {
|
|
37
|
+
await acquireSpawnSlot();
|
|
38
|
+
try {
|
|
39
|
+
return await new Promise((resolve, reject) => {
|
|
40
|
+
const stdoutChunks = [];
|
|
41
|
+
const stderrChunks = [];
|
|
42
|
+
let timedOut = false;
|
|
43
|
+
let settled = false;
|
|
44
|
+
let killEscalation;
|
|
45
|
+
const logStream = fs.createWriteStream(opts.logPath, { flags: "w" });
|
|
46
|
+
// A WriteStream's 'error' event has no default handler — left unguarded, any
|
|
47
|
+
// stream error (a write after end, ENOSPC, a permissions problem) is an uncaught
|
|
48
|
+
// exception that crashes this entire process, taking down every other in-flight
|
|
49
|
+
// issue's coordinator work along with it. Best-effort: the transcript this
|
|
50
|
+
// function resolves with is already buffered in memory regardless of whether the
|
|
51
|
+
// log file write succeeds.
|
|
52
|
+
logStream.on("error", (err) => {
|
|
53
|
+
console.error(`[spawn-cli] log stream error for ${opts.logPath}`, err);
|
|
54
|
+
});
|
|
55
|
+
const child = spawn(cmd, args, {
|
|
56
|
+
cwd,
|
|
57
|
+
// Extra vars (e.g. codex's bearer-token env var for its per-attempt CODEX_HOME
|
|
58
|
+
// MCP config, agent-deck-bind.ts) are added on top of, never in place of, the
|
|
59
|
+
// process env the CLI itself needs to run.
|
|
60
|
+
env: { ...process.env, ...opts.env },
|
|
61
|
+
stdio: ["ignore", "pipe", "pipe"],
|
|
62
|
+
});
|
|
63
|
+
registerChild(runId, child, opts.logPath);
|
|
64
|
+
// Best-effort bookkeeping: a throwing callback must never take down the spawn.
|
|
65
|
+
if (child.pid !== undefined) {
|
|
66
|
+
try {
|
|
67
|
+
opts.onSpawn?.(child.pid);
|
|
68
|
+
}
|
|
69
|
+
catch (err) {
|
|
70
|
+
console.error(`[spawn-cli] onSpawn for ${runId}`, err);
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
const onAbort = () => {
|
|
74
|
+
if (settled)
|
|
75
|
+
return;
|
|
76
|
+
try {
|
|
77
|
+
child.kill("SIGTERM");
|
|
78
|
+
}
|
|
79
|
+
catch {
|
|
80
|
+
// Already exited between the abort firing and this kill — nothing to signal.
|
|
81
|
+
}
|
|
82
|
+
killEscalation = setTimeout(() => {
|
|
83
|
+
try {
|
|
84
|
+
child.kill("SIGKILL");
|
|
85
|
+
}
|
|
86
|
+
catch {
|
|
87
|
+
// Same race as above; the 'close' handler still settles the promise.
|
|
88
|
+
}
|
|
89
|
+
}, abortKillGraceMs());
|
|
90
|
+
killEscalation.unref?.();
|
|
91
|
+
};
|
|
92
|
+
// Not cleared on settle alone — `cleanupAbort` also drops the listener, so a
|
|
93
|
+
// long-lived signal (one AbortController per work item, reused across the
|
|
94
|
+
// handler's later stages) never retains this closure after the child is gone.
|
|
95
|
+
const cleanupAbort = () => {
|
|
96
|
+
if (killEscalation)
|
|
97
|
+
clearTimeout(killEscalation);
|
|
98
|
+
opts.signal?.removeEventListener("abort", onAbort);
|
|
99
|
+
};
|
|
100
|
+
if (opts.signal) {
|
|
101
|
+
if (opts.signal.aborted)
|
|
102
|
+
onAbort();
|
|
103
|
+
else
|
|
104
|
+
opts.signal.addEventListener("abort", onAbort, { once: true });
|
|
105
|
+
}
|
|
106
|
+
const finish = (exitCode) => {
|
|
107
|
+
if (settled)
|
|
108
|
+
return;
|
|
109
|
+
settled = true;
|
|
110
|
+
clearTimeout(timer);
|
|
111
|
+
cleanupAbort();
|
|
112
|
+
unregisterChild(runId);
|
|
113
|
+
// Write any stderr BEFORE ending the stream — writing after end() throws
|
|
114
|
+
// ERR_STREAM_WRITE_AFTER_END (this crashed the whole process on any real CLI
|
|
115
|
+
// invocation that produced stderr output; every fixture-based test's fake spawn
|
|
116
|
+
// never wrote stderr, so this path went unexercised until a real session hit it).
|
|
117
|
+
const stderr = stderrChunks.join("");
|
|
118
|
+
// write()/end() only queue the I/O — resolving immediately (PR #27 review) races
|
|
119
|
+
// the actual flush, so a caller reading opts.logPath right after this promise
|
|
120
|
+
// settles can see a file missing the stderr trailer (or, on a slow disk, even
|
|
121
|
+
// the tail of stdout). Wait for the stream to actually finish (or, if the
|
|
122
|
+
// underlying write itself errors, the 'error' handler above already logged it —
|
|
123
|
+
// finalize anyway rather than hang forever on a transcript that's already fully
|
|
124
|
+
// buffered in memory regardless of the file write's outcome).
|
|
125
|
+
let finalized = false;
|
|
126
|
+
let finalizeTimer;
|
|
127
|
+
const finalize = () => {
|
|
128
|
+
if (finalized)
|
|
129
|
+
return;
|
|
130
|
+
finalized = true;
|
|
131
|
+
if (finalizeTimer)
|
|
132
|
+
clearTimeout(finalizeTimer);
|
|
133
|
+
resolve({
|
|
134
|
+
exitCode,
|
|
135
|
+
transcript: stdoutChunks.join(""),
|
|
136
|
+
timedOut,
|
|
137
|
+
});
|
|
138
|
+
};
|
|
139
|
+
if (logStream.destroyed || logStream.errored) {
|
|
140
|
+
// The stream already failed (e.g. ENOENT on open, or a write error during
|
|
141
|
+
// stdout streaming — before this function ever attached the listeners below)
|
|
142
|
+
// — end() on an already-destroyed stream emits neither 'finish' nor a fresh
|
|
143
|
+
// 'error', so waiting for either would hang this promise forever and leak the
|
|
144
|
+
// spawn slot (PR #27 review, round 2). The transcript is already fully
|
|
145
|
+
// buffered in memory regardless of the file write's outcome.
|
|
146
|
+
finalize();
|
|
147
|
+
}
|
|
148
|
+
else {
|
|
149
|
+
logStream.once("finish", finalize);
|
|
150
|
+
logStream.once("error", finalize);
|
|
151
|
+
// Belt-and-suspenders: 'finish'/'error' are expected to fire quickly once
|
|
152
|
+
// end() is called, but a stream wedged on some other, unanticipated failure
|
|
153
|
+
// mode must still never pin this work item's spawn slot indefinitely.
|
|
154
|
+
finalizeTimer = setTimeout(finalize, 2000);
|
|
155
|
+
finalizeTimer.unref?.();
|
|
156
|
+
if (stderr.trim()) {
|
|
157
|
+
logStream.end(`\n--- stderr ---\n${stderr}`);
|
|
158
|
+
}
|
|
159
|
+
else {
|
|
160
|
+
logStream.end();
|
|
161
|
+
}
|
|
162
|
+
}
|
|
163
|
+
};
|
|
164
|
+
const timer = setTimeout(() => {
|
|
165
|
+
timedOut = true;
|
|
166
|
+
killRunProcess(runId);
|
|
167
|
+
setTimeout(() => finish(124), 500);
|
|
168
|
+
}, opts.timeoutMs);
|
|
169
|
+
child.stdout?.on("data", (buf) => {
|
|
170
|
+
const chunk = buf.toString();
|
|
171
|
+
stdoutChunks.push(chunk);
|
|
172
|
+
logStream.write(buf);
|
|
173
|
+
});
|
|
174
|
+
child.stderr?.on("data", (buf) => {
|
|
175
|
+
stderrChunks.push(buf.toString());
|
|
176
|
+
});
|
|
177
|
+
child.on("error", (err) => {
|
|
178
|
+
if (settled)
|
|
179
|
+
return;
|
|
180
|
+
settled = true;
|
|
181
|
+
clearTimeout(timer);
|
|
182
|
+
cleanupAbort();
|
|
183
|
+
unregisterChild(runId);
|
|
184
|
+
logStream.end();
|
|
185
|
+
reject(err);
|
|
186
|
+
});
|
|
187
|
+
child.on("close", (code) => finish(code ?? 1));
|
|
188
|
+
});
|
|
189
|
+
}
|
|
190
|
+
finally {
|
|
191
|
+
releaseSpawnSlot();
|
|
192
|
+
}
|
|
193
|
+
}
|
|
194
|
+
export function logPathFor(run, mode) {
|
|
195
|
+
const logDir = getTemporalLogsDir();
|
|
196
|
+
return path.join(logDir, `${run.id}-${mode}-${Date.now()}.ndjson`);
|
|
197
|
+
}
|
|
@@ -0,0 +1,130 @@
|
|
|
1
|
+
// Regression test for a crash discovered live on the very first real (non-fixture) dev
|
|
2
|
+
// session: `finish()` called `logStream.end()` and then, if the child produced any
|
|
3
|
+
// stderr, `logStream.write(...)` on the now-ended stream -- ERR_STREAM_WRITE_AFTER_END,
|
|
4
|
+
// an unhandled stream 'error' event, which crashes the entire coordinator process (every
|
|
5
|
+
// fixture-based test's fake spawn never wrote real stderr, so this path was never
|
|
6
|
+
// exercised until a real CLI invocation hit it).
|
|
7
|
+
import { test } from "node:test";
|
|
8
|
+
import assert from "node:assert/strict";
|
|
9
|
+
import fs from "node:fs";
|
|
10
|
+
import os from "node:os";
|
|
11
|
+
import path from "node:path";
|
|
12
|
+
import { spawnCli } from "./spawn-cli.js";
|
|
13
|
+
test("spawnCli does not crash when the child process writes to stderr", async () => {
|
|
14
|
+
const logPath = path.join(fs.mkdtempSync(path.join(os.tmpdir(), "dealer-spawn-cli-")), "out.ndjson");
|
|
15
|
+
const result = await spawnCli("test-run-stderr", "sh", ["-c", "echo out-line; echo err-line 1>&2"], process.cwd(), { logPath, timeoutMs: 5000 });
|
|
16
|
+
assert.equal(result.exitCode, 0);
|
|
17
|
+
assert.equal(result.timedOut, false);
|
|
18
|
+
assert.match(result.transcript, /out-line/);
|
|
19
|
+
// The stderr section was actually appended to the log file, not lost -- proves the
|
|
20
|
+
// write happened before end(), not just that it failed to throw.
|
|
21
|
+
const logged = fs.readFileSync(logPath, "utf8");
|
|
22
|
+
assert.match(logged, /out-line/);
|
|
23
|
+
assert.match(logged, /--- stderr ---/);
|
|
24
|
+
assert.match(logged, /err-line/);
|
|
25
|
+
});
|
|
26
|
+
test("spawnCli does not crash when the child writes only stdout (no stderr)", async () => {
|
|
27
|
+
const logPath = path.join(fs.mkdtempSync(path.join(os.tmpdir(), "dealer-spawn-cli-")), "out.ndjson");
|
|
28
|
+
const result = await spawnCli("test-run-nostderr", "sh", ["-c", "echo out-only"], process.cwd(), {
|
|
29
|
+
logPath,
|
|
30
|
+
timeoutMs: 5000,
|
|
31
|
+
});
|
|
32
|
+
assert.equal(result.exitCode, 0);
|
|
33
|
+
assert.match(result.transcript, /out-only/);
|
|
34
|
+
const logged = fs.readFileSync(logPath, "utf8");
|
|
35
|
+
assert.doesNotMatch(logged, /--- stderr ---/);
|
|
36
|
+
});
|
|
37
|
+
// PR #27 review, round 2: waiting for finish()/error on the write stream only helps if
|
|
38
|
+
// those listeners are attached before the stream already failed. A stream that errors
|
|
39
|
+
// (and self-destroys) BEFORE the child closes -- e.g. ENOENT opening the log file --
|
|
40
|
+
// never emits either event again once end() is called, so a naive wait-then-resolve
|
|
41
|
+
// would hang forever, leaking the spawn slot. Set a short per-test timeout so a
|
|
42
|
+
// regression fails fast instead of hanging the whole suite.
|
|
43
|
+
test("spawnCli still resolves (does not hang) when the log stream already errored before the child closed", { timeout: 5000 }, async () => {
|
|
44
|
+
// A path whose parent directory doesn't exist -- fs.createWriteStream fails with
|
|
45
|
+
// ENOENT almost immediately, well before this "sleep" child exits.
|
|
46
|
+
const logPath = path.join(os.tmpdir(), `dealer-spawn-cli-missing-${Date.now()}`, "nested", "out.ndjson");
|
|
47
|
+
const result = await spawnCli("test-run-stream-error", "sh", ["-c", "sleep 0.2; echo done"], process.cwd(), {
|
|
48
|
+
logPath,
|
|
49
|
+
timeoutMs: 5000,
|
|
50
|
+
});
|
|
51
|
+
assert.equal(result.exitCode, 0);
|
|
52
|
+
assert.match(result.transcript, /done/, "the in-memory transcript is unaffected by the log file write failing");
|
|
53
|
+
});
|
|
54
|
+
// NOT-126: an attempt that loses its lease aborts, but the abort used to stop at the
|
|
55
|
+
// handler -- the spawned CLI kept running, kept editing the worktree, and the successor
|
|
56
|
+
// attempt was then handed that same worktree, so two live agents shared one tree. These
|
|
57
|
+
// use `process.execPath` rather than `sh -c '... sleep 30'` deliberately: a shell's
|
|
58
|
+
// orphaned grandchild inherits the stdout pipe and holds it open, so 'close' would not
|
|
59
|
+
// fire promptly and the test would pass for the wrong reason.
|
|
60
|
+
const nodeChild = (body) => ({ cmd: process.execPath, args: ["-e", body] });
|
|
61
|
+
async function waitForStart(logPath, timeoutMs = 5000) {
|
|
62
|
+
const deadline = Date.now() + timeoutMs;
|
|
63
|
+
while (Date.now() < deadline) {
|
|
64
|
+
try {
|
|
65
|
+
if (fs.readFileSync(logPath, "utf8").includes("started"))
|
|
66
|
+
return;
|
|
67
|
+
}
|
|
68
|
+
catch {
|
|
69
|
+
// The log file is created asynchronously by the write stream -- keep polling.
|
|
70
|
+
}
|
|
71
|
+
await new Promise((r) => setTimeout(r, 25));
|
|
72
|
+
}
|
|
73
|
+
throw new Error("child never reported that it had started");
|
|
74
|
+
}
|
|
75
|
+
test("spawnCli terminates the child when the attempt is aborted", { timeout: 15_000 }, async () => {
|
|
76
|
+
const logPath = path.join(fs.mkdtempSync(path.join(os.tmpdir(), "dealer-spawn-cli-")), "out.ndjson");
|
|
77
|
+
const { cmd, args } = nodeChild("console.log('started'); setInterval(() => {}, 1000);");
|
|
78
|
+
const controller = new AbortController();
|
|
79
|
+
const startedAt = Date.now();
|
|
80
|
+
// timeoutMs is deliberately far longer than the test timeout: if the abort does not
|
|
81
|
+
// kill the child, nothing else will, and this test fails by timing out.
|
|
82
|
+
const promise = spawnCli("test-run-abort", cmd, args, process.cwd(), {
|
|
83
|
+
logPath,
|
|
84
|
+
timeoutMs: 600_000,
|
|
85
|
+
signal: controller.signal,
|
|
86
|
+
});
|
|
87
|
+
await waitForStart(logPath);
|
|
88
|
+
controller.abort();
|
|
89
|
+
const result = await promise;
|
|
90
|
+
assert.ok(Date.now() - startedAt < 15_000, "resolved on the abort, not on the 10-minute timeout");
|
|
91
|
+
assert.equal(result.timedOut, false, "an abort is a lost lease, not a wall-clock timeout");
|
|
92
|
+
assert.match(result.transcript, /started/, "output produced before the abort is still returned");
|
|
93
|
+
});
|
|
94
|
+
test("spawnCli escalates to SIGKILL when the child ignores SIGTERM", { timeout: 15_000 }, async () => {
|
|
95
|
+
const previous = process.env.SPAWN_ABORT_KILL_GRACE_MS;
|
|
96
|
+
process.env.SPAWN_ABORT_KILL_GRACE_MS = "250";
|
|
97
|
+
try {
|
|
98
|
+
const logPath = path.join(fs.mkdtempSync(path.join(os.tmpdir(), "dealer-spawn-cli-")), "out.ndjson");
|
|
99
|
+
const { cmd, args } = nodeChild("process.on('SIGTERM', () => {}); console.log('started'); setInterval(() => {}, 1000);");
|
|
100
|
+
const controller = new AbortController();
|
|
101
|
+
const promise = spawnCli("test-run-abort-sigkill", cmd, args, process.cwd(), {
|
|
102
|
+
logPath,
|
|
103
|
+
timeoutMs: 600_000,
|
|
104
|
+
signal: controller.signal,
|
|
105
|
+
});
|
|
106
|
+
await waitForStart(logPath);
|
|
107
|
+
controller.abort();
|
|
108
|
+
// Only the SIGKILL backstop can end this child -- it swallows SIGTERM outright.
|
|
109
|
+
const result = await promise;
|
|
110
|
+
assert.equal(result.timedOut, false);
|
|
111
|
+
}
|
|
112
|
+
finally {
|
|
113
|
+
if (previous === undefined)
|
|
114
|
+
delete process.env.SPAWN_ABORT_KILL_GRACE_MS;
|
|
115
|
+
else
|
|
116
|
+
process.env.SPAWN_ABORT_KILL_GRACE_MS = previous;
|
|
117
|
+
}
|
|
118
|
+
});
|
|
119
|
+
test("spawnCli runs normally when an un-aborted signal is supplied", { timeout: 10_000 }, async () => {
|
|
120
|
+
const logPath = path.join(fs.mkdtempSync(path.join(os.tmpdir(), "dealer-spawn-cli-")), "out.ndjson");
|
|
121
|
+
const controller = new AbortController();
|
|
122
|
+
const result = await spawnCli("test-run-abort-unused", "sh", ["-c", "echo untouched"], process.cwd(), {
|
|
123
|
+
logPath,
|
|
124
|
+
timeoutMs: 5000,
|
|
125
|
+
signal: controller.signal,
|
|
126
|
+
});
|
|
127
|
+
assert.equal(result.exitCode, 0);
|
|
128
|
+
assert.equal(result.timedOut, false);
|
|
129
|
+
assert.match(result.transcript, /untouched/);
|
|
130
|
+
});
|