Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
23 commits
Select commit Hold shift + click to select a range
1d8339a
fix(#620): harden engine-worker IPC callbacks against stale extension…
HenryLach Aug 27, 2026
623a186
test(#620): add mocked-fork behavioral test for IPC stale-ctx survival
HenryLach Aug 28, 2026
86e93ec
fix(mail): surface worker outbox mail live during the run, not just p…
HenryLach Sep 1, 2026
775188c
feat(review-boundary): notify supervisor at every review boundary (St…
HenryLach Sep 1, 2026
e192c64
feat(review-boundary): spiral detection + escalation + adjudication s…
HenryLach Sep 1, 2026
d8d6ad9
fix: remove stray merge-conflict markers from taskplane-tasks/depende…
HenryLach Sep 1, 2026
b9b4c21
docs(review-boundary): teach the supervisor to adjudicate reviews
HenryLach Sep 1, 2026
25cec37
fix(#624): stop spurious 'Reviewer unavailable' on every successful r…
HenryLach Sep 1, 2026
a194cd1
fix(#624): review_step gate must never fail open to APPROVE (worker-f…
HenryLach Sep 3, 2026
a32cde3
fix: three evidence-driven safety cuts from live Penster incidents (#…
HenryLach Sep 5, 2026
6afe364
fix(orch): retry resets v2 segment records; review-gate remediation s…
HenryLach Sep 6, 2026
dd068ea
fix(orch): verified engine ownership for inherited batches — identity…
HenryLach Sep 6, 2026
27118a7
fix(orch): hold-aware relaunch for workers awaiting a ruling; dead-pi…
HenryLach Sep 6, 2026
8d43958
feat(TP-114): step 1 create test files
HenryLach Sep 6, 2026
9334607
feat(TP-114): step 2 code analysis
HenryLach Sep 6, 2026
87136cc
docs(TP-114): step 3 completion summary
HenryLach Sep 6, 2026
0a5d8b5
checkpoint: TP-114 task artifacts (.DONE, STATUS.md)
HenryLach Sep 6, 2026
1ac2911
fix(supervisor): integration supersedes the deferred batch-end epilog…
HenryLach Sep 6, 2026
2de3f24
fix(#630): acknowledgement contract for holds (info keeps hold, steer…
HenryLach Sep 7, 2026
79bf2c2
fix(review-boundary): idempotent end boundaries — one review file, on…
HenryLach Sep 7, 2026
9c600cf
fix(orch): pause never skips tasks or completes the batch; resume mer…
HenryLach Sep 7, 2026
61eafec
fix(lane-runner): review-gate the step-completion heuristic; configur…
HenryLach Sep 7, 2026
622ee84
docs: name the taskplane-config.json (camelCase) spelling of exitInte…
HenryLach Sep 7, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
18 changes: 18 additions & 0 deletions .tmp-p.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
import { readFileSync, writeFileSync } from "node:fs";
function patch(f, pairs) { let s = readFileSync(f, "utf8"); for (const [a, b] of pairs) { const n = s.split(a).length - 1; if (n !== 1) { console.error("anchor", n, "in", f, ":", a.slice(0, 90)); process.exit(1); } s = s.replace(a, b); } writeFileSync(f, s); console.log("patched", f); }
patch("extensions/tests/exit-interception.test.ts", [
[` expect(agentHostSrc).toContain("INTERCEPTION_TIMEOUT_MS = 120_000");`,
` // Configurable safety race (defaults to 120s; lane passes window + 60s).
expect(agentHostSrc).toContain("INTERCEPTION_TIMEOUT_MS = opts.exitInterceptSafetyMs ?? 120_000");`],
[` expect(laneRunnerSrc).toContain("SUPERVISOR_REPLY_TIMEOUT_MS = 60_000");`,
` // Window is configurable (taskRunner.worker.exitInterceptTimeoutSec; default 60s, 15..1800).
expect(laneRunnerSrc.replace(/\s+/g, " ")).toContain(
"SUPERVISOR_REPLY_TIMEOUT_MS = Math.min(1800, Math.max(15, config.exitInterceptTimeoutSec ?? 60)) * 1000;",
);`],
]);
patch("extensions/tests/issue-629-retry-segment-reset.test.ts", [[
` expect(occurrences).toBe(4); // definition + finalize + pre-spawn + post-iteration re-check`,
` expect(occurrences).toBe(5); // definition + finalize + pre-spawn + post-iteration re-check + step-completion gate`]]);
patch("docs/reference/configuration/task-runner.yaml.md", [[
"| `worker.spawn_mode` | `\"subprocess\"` \| `\"tmux\"` | commented in template | Optional spawn mode override for task-runner. |\n",
"| `worker.spawn_mode` | `\"subprocess\"` \| `\"tmux\"` | commented in template | Optional spawn mode override for task-runner. |\n| `worker.exit_intercept_timeout_sec` | number | `60` (15..1800) | How long the lane waits for a supervisor reply when it intercepts a worker's premature exit before letting the session close. Raise it when the supervisor is often inside long tool calls (a blocking `--wait` cannot answer in 60 s). |\n"]]);
75 changes: 75 additions & 0 deletions .tmp-patch-630c.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,75 @@
import { readFileSync, writeFileSync } from "node:fs";
const f = "extensions/tests/issue-630-hold-exit.test.ts";
let s = readFileSync(f, "utf8");
function rep(a, b) {
const n = s.split(a).length - 1;
if (n !== 1) {
console.error("anchor", n, ":", a.slice(0, 80));
process.exit(1);
}
s = s.replace(a, b);
}
rep(
`let onSpawn: ((index: number, opts: { mailboxDir?: string; steeringPendingPath?: string | null }) => void) | null =\n\tnull;`,
`let onSpawn: ((index: number, opts: { mailboxDir?: string; steeringPendingPath?: string | null }) => void) | null =\n\tnull;\n/** Optional async hook run by the mock worker BEFORE it "exits" (drives onPrematureExit). */\nlet beforeExit:\n\t| ((index: number, opts: { onPrematureExit?: (m: string) => Promise<string | null> }) => Promise<void>)\n\t| null = null;`,
);
rep(
`\treturn { promise: Promise.resolve(result), kill: () => {} } as unknown as ReturnType<\n\t\ttypeof realAgentHost.spawnAgent\n\t>;\n});\nmock.module(`,
`\tconst promise = (async () => {\n\t\tif (beforeExit) await beforeExit(index, opts as never);\n\t\treturn result;\n\t})();\n\treturn { promise, kill: () => {} } as unknown as ReturnType<typeof realAgentHost.spawnAgent>;\n});\nmock.module(`,
);
const start = s.indexOf(`\tit("BLOCKER 1: a ruling consumed by the exit-intercept path releases the hold", async () => {`);
const end = s.indexOf(`\tit("BLOCKER 2:`, start);
if (start < 0 || end < 0) {
console.error("b1 markers", start, end);
process.exit(1);
}
const b1 = `\tit("BLOCKER 1: a ruling consumed by the exit-intercept path releases the hold", async () => {
const { writeMailboxMessage } = await import("../taskplane/mailbox.ts");
onSpawn = (i) => {
if (i === 0) {
writeOutboxMessage(tmpRoot, BATCH, AGENT, {
from: AGENT,
type: "escalate",
content: "Need a ruling.",
expectsReply: true,
});
}
};
// Spawn 1 (hold-resume relaunch): the worker "tries to exit"; agent-host calls
// onPrematureExit, which polls the INBOX (not .steering-pending) and returns the
// supervisor reply as the next prompt. Drive exactly that path.
beforeExit = async (i, opts) => {
if (i === 1 && opts.onPrematureExit) {
setTimeout(() => {
writeMailboxMessage(tmpRoot, BATCH, AGENT, {
from: "supervisor",
type: "steer",
content: "Ruling: proceed with option A and re-run the review.",
});
}, 300);
const reprompt = await opts.onPrematureExit("I am holding for a ruling.");
expect(reprompt).toContain("Ruling: proceed with option A");
}
};
const { unit, config } = buildUnitAndConfig(2);
const result = await executeTaskV2(
unit as Parameters<typeof executeTaskV2>[0],
config as unknown as Parameters<typeof executeTaskV2>[1],
{ paused: false },
);
const status = readFileSync(join(taskFolder, "STATUS.md"), "utf-8");
expect(status).toContain("Exit intercept reprompt");
expect(spawnPrompts[1]).toContain("YOU ARE ON HOLD"); // relaunch 1 was a hold-resume
// The hold was released by the intercept path: spawn 2 is a normal prompt and the
// task ends via ordinary stall accounting, NOT 'Hold unresolved'.
expect(spawnPrompts[2]).not.toContain("YOU ARE ON HOLD");
expect(result.outcome.exitReason).not.toContain("Hold unresolved");
});

`;
s = s.slice(0, start) + b1 + s.slice(end);
s = s
.split(`\t\tspawnPrompts = [];\n\t\tonSpawn = null;\n\t\talerts = [];`)
.join(`\t\tspawnPrompts = [];\n\t\tonSpawn = null;\n\t\tbeforeExit = null;\n\t\talerts = [];`);
writeFileSync(f, s);
console.log("ok");
178 changes: 178 additions & 0 deletions .tmp-patch-631-round7.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,178 @@
import { readFileSync, writeFileSync } from "node:fs";
function patch(f, pairs) {
let s = readFileSync(f, "utf8");
for (const [a, b, count = 1] of pairs) {
const n = s.split(a).length - 1;
if (n !== count) {
console.error("anchor count", n, "expected", count, "in", f, ":", a.slice(0, 90));
process.exit(1);
}
s = s.split(a).join(b);
}
writeFileSync(f, s);
console.log("patched", f);
}

// ── worktree.ts: deleteStaleBranches becomes BATCH-SCOPED for task/ and saved/task/ too ──
{
const f = "extensions/taskplane/worktree.ts";
let s = readFileSync(f, "utf8");
const start = s.indexOf("export function deleteStaleBranches(");
const sec1 = s.indexOf("// 1. Delete task/{opId}-lane-* branches", start);
const sec2 = s.indexOf("// 2. Delete saved/task/{opId}-lane-* branches", start);
const sec3 = s.indexOf("// 3. Delete saved/{opId}-*-{batchId} branches", start);
if (start < 0 || sec1 < 0 || sec2 < 0 || sec3 < 0) {
console.error("worktree markers", start, sec1, sec2, sec3);
process.exit(1);
}
const seg1 = s.slice(sec1, sec2);
const seg2 = s.slice(sec2, sec3);
const scope = (seg, header) => {
// insert a batch-suffix filter into the `for (const branch of branches)` loop
const loopIdx = seg.indexOf("for (const branch of branches) {");
if (loopIdx < 0) {
console.error("no loop in", header);
process.exit(1);
}
const insertAt = loopIdx + "for (const branch of branches) {".length;
return (
seg.slice(0, insertAt) +
`
// #631: BATCH-SCOPED. Lane branches carry a \`-{batchId}\` suffix
// (task/{opId}-lane-{N}-{batchId}); an operator-wide sweep deleted the
// refs of OTHER batches — including one whose engine was still alive and
// never passed the ownership gate. Only this batch's refs are cleaned.
if (!branch.endsWith(\`-\${batchId}\`)) continue;` +
seg.slice(insertAt)
);
};
s = s.slice(0, sec1) + scope(seg1, "sec1") + scope(seg2, "sec2") + s.slice(sec3);
writeFileSync(f, s);
console.log("patched", f);
}

// ── extension.ts ──
patch("extensions/taskplane/extension.ts", [
// resolver: explicit branch arg that differs from persisted state must not carry that state's batchId
[
`\t// Source 2: CLI positional branch arg overrides or fills in
if (parsed.orchBranchArg) {
orchBranch = parsed.orchBranchArg;
}`,
`\t// Source 2: CLI positional branch arg overrides or fills in
if (parsed.orchBranchArg) {
// #631: an explicit branch that differs from the persisted batch's branch
// must NOT inherit that batch's id — cleanup/history/ownership would then
// target an unrelated batch. The batch behind the selected branch (if any)
// is looked up from runtime artifacts by the caller.
if (orchBranch && batchId && orchBranch !== parsed.orchBranchArg) {
notices.push(
\`ℹ️ Persisted batch \${batchId} belongs to \${orchBranch}; integrating \${parsed.orchBranchArg} instead — \` +
\`batch-scoped cleanup/history will use the batch associated with that branch, if any.\`,
);
batchId = "";
}
orchBranch = parsed.orchBranchArg;
}`,
],
// integrate: after the gate, bind batchId to the branch's associated batch when unique
[
`\t\t\t// No batch is associated with this branch (pure branch integration):
// nothing an engine could be driving — proceed.
}
`,
`\t\t\t// No batch is associated with this branch (pure branch integration):
// nothing an engine could be driving — proceed.

// Bind cleanup/history to the batch behind THIS branch. The resolver
// leaves batchId empty when an explicit branch differs from persisted
// state; a unique associated runtime batch fills it in. Ambiguous (>1)
// → leave empty: batch-scoped cleanup is skipped rather than guessed.
if (!batchId) {
const branchBound = [...associated.values()].filter((t) => t.phase !== "completed" || true);
if (branchBound.length === 1) batchId = branchBound[0].batchId;
}
}
`,
],
[
`\t\tconst { orchBranch, baseBranch, batchId, currentBranch, notices } =
resolution as IntegrationContext;`,
`\t\tconst { orchBranch, baseBranch, currentBranch, notices } = resolution as IntegrationContext;
let batchId = (resolution as IntegrationContext).batchId;`,
],
// confirm: explicit target selector (batchId) → persisted → reconstructed → cached
[
`\tfunction doOrchConfirmEngineShutdown(note: string, stateRoot: string): string {
// Resolve the SAME target the recovery gates use: persisted state first,
// then runtime reconstruction (force-resume with no state file), and only
// then a cached id — a stale cached id must not confirm the wrong batch.
const target = resolveRecoveryTarget(stateRoot, true);
const batchId = target?.batchId || orchBatchState.batchId || supervisorState.batchId || "";
if (!batchId) return "❌ No batch to confirm shutdown for (no batch on disk, nothing reconstructable, nothing in memory).";`,
`\tfunction doOrchConfirmEngineShutdown(note: string, stateRoot: string, explicitBatchId?: string): string {
// Target: an EXPLICIT batchId (exactly the batch a refusal named — works for
// meta-only legacy batches that are not reconstructable), else the same
// target the recovery gates use: persisted → reconstructed → cached.
const explicit = (explicitBatchId ?? "").trim();
if (explicit && !/^[A-Za-z0-9._-]+$/.test(explicit)) {
return \`❌ Invalid batchId "\${explicit}".\`;
}
const target = explicit ? null : resolveRecoveryTarget(stateRoot, true);
const batchId =
explicit || target?.batchId || orchBatchState.batchId || supervisorState.batchId || "";
if (!batchId) {
return (
"❌ No batch to confirm shutdown for (no batch on disk, nothing reconstructable, nothing in memory). " +
"Pass the batchId named by the refusal explicitly."
);
}`,
],
// tool: optional batchId param
[
`\t\tparameters: Type.Object({
note: Type.String({
description: "What was verified and how (e.g. 'no engine-worker processes in Get-CimInstance output at 21:32')",
}),
}),
async execute(_toolCallId, params, _signal, _onUpdate, ctx) {
const text = doOrchConfirmEngineShutdown(params.note, resolveToolStateRoot(ctx));`,
`\t\tparameters: Type.Object({
note: Type.String({
description: "What was verified and how (e.g. 'no engine-worker processes in Get-CimInstance output at 21:32')",
}),
batchId: Type.Optional(
Type.String({
description:
"Exact batchId to confirm (the one named by the refusal). Omit to use the persisted/reconstructed batch.",
}),
),
}),
async execute(_toolCallId, params, _signal, _onUpdate, ctx) {
const text = doOrchConfirmEngineShutdown(params.note, resolveToolStateRoot(ctx), params.batchId);`,
],
// command: --batch <id>
[
`\t\thandler: async (args, ctx) => {
const note = (args ?? "").trim();
const stateRoot = execCtx?.workspaceRoot ?? execCtx?.repoRoot ?? ctx.cwd;
const result = doOrchConfirmEngineShutdown(note, stateRoot);`,
`\t\thandler: async (args, ctx) => {
// Syntax: /orch-confirm-engine-shutdown [--batch <batchId>] <note>
let raw = (args ?? "").trim();
let explicitBatchId: string | undefined;
const m = /(?:^|\\s)--batch\\s+(\\S+)/.exec(raw);
if (m) {
explicitBatchId = m[1];
raw = raw.replace(m[0], " ").trim();
}
const stateRoot = execCtx?.workspaceRoot ?? execCtx?.repoRoot ?? ctx.cwd;
const result = doOrchConfirmEngineShutdown(raw, stateRoot, explicitBatchId);`,
],
[
`\t\tdescription:
"Record operator-verified engine shutdown for a batch with no engine identity (#631): /orch-confirm-engine-shutdown <what you verified>",`,
`\t\tdescription:
"Record operator-verified engine shutdown for a batch with no engine identity (#631): /orch-confirm-engine-shutdown [--batch <batchId>] <what you verified>",`,
],
]);
Loading