pi-crew 0.11.4 → 0.11.6
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/CHANGELOG.md +109 -0
- package/README.md +2 -2
- package/dist/index.mjs +498 -492
- package/docs/actions-reference.md +22 -0
- package/docs/dynamic-workflows.md +37 -0
- package/index.ts +9 -5
- package/package.json +18 -14
- package/scripts/analyze-run.mjs +101 -14
- package/skills/model-routing-context/SKILL.md +4 -3
- package/skills/real-test-pi-crew/SKILL.md +10 -7
- package/src/config/env-vars.ts +6 -0
- package/src/extension/crew-vibes/index.ts +29 -10
- package/src/extension/pi-api.ts +1 -0
- package/src/extension/registration/lifecycle-handlers.ts +65 -3
- package/src/extension/team-tool/dispatch/index.ts +4 -3
- package/src/extension/team-tool/dispatch/status.ts +7 -3
- package/src/extension/team-tool/inspect.ts +90 -0
- package/src/extension/team-tool/respond.ts +54 -0
- package/src/extension/team-tool/status.ts +10 -0
- package/src/extension/team-tool.ts +393 -3
- package/src/extension/webhook-notify.ts +23 -1
- package/src/observability/exporters/otlp-exporter.ts +93 -11
- package/src/prompt/prompt-runtime.ts +13 -4
- package/src/runtime/agent-control.ts +1 -15
- package/src/runtime/background-runner.ts +3 -0
- package/src/runtime/broker/crew-broker.ts +12 -103
- package/src/runtime/broker/protocol/request-parsers.ts +9 -1
- package/src/runtime/broker/protocol/wait-resolve.ts +168 -0
- package/src/runtime/child-pi/child-pi-spawn.ts +5 -4
- package/src/runtime/dispatch-batch.ts +3 -1
- package/src/runtime/goal-workflow/dynamic-workflow-context.ts +111 -12
- package/src/runtime/goal-workflow/dynamic-workflow-runner.ts +19 -0
- package/src/runtime/live-session/live-agent-manager.ts +35 -0
- package/src/runtime/live-session/live-session-runtime.ts +34 -5
- package/src/runtime/mcp-proxy.ts +110 -7
- package/src/runtime/model/pi-args.ts +14 -11
- package/src/runtime/model/provider-extensions.ts +67 -1
- package/src/runtime/recovery/crash-recovery.ts +6 -1
- package/src/runtime/scheduling/run-coalesced-task-group.ts +89 -6
- package/src/runtime/stale-reconciler.ts +55 -0
- package/src/runtime/task-output-context.ts +11 -1
- package/src/runtime/task-runner/child-executor.ts +130 -4
- package/src/runtime/task-runner/output-splitter.ts +48 -5
- package/src/runtime/task-runner/post-execution.ts +2 -2
- package/src/runtime/task-runner/pre-execution.ts +37 -24
- package/src/runtime/task-runner/retrieval-orchestrator.ts +28 -2
- package/src/schema/team-tool-schema.ts +27 -1
- package/src/state/contracts.ts +16 -0
- package/src/state/crew-init.ts +114 -0
- package/src/state/stores/active-run-registry.ts +13 -6
- package/src/state/types.ts +0 -8
- package/src/ui/goal-flag.ts +44 -0
- package/src/ui/live-run-sidebar.ts +5 -1
- package/src/ui/pi-ui-compat.ts +20 -3
- package/src/ui/run-dashboard.ts +2 -1
- package/src/ui/widget/widget-model.ts +6 -2
- package/src/utils/ip-literal.ts +145 -0
- package/src/workflows/workflow-config.ts +6 -0
|
@@ -62,6 +62,61 @@ function cachedResult(settingsPath: string): { mtimeMs: number; result: Discover
|
|
|
62
62
|
return undefined;
|
|
63
63
|
}
|
|
64
64
|
|
|
65
|
+
/**
|
|
66
|
+
* GH #61: normalize a DIRECTORY `pi.extensions` entry to the entry FILE that
|
|
67
|
+
* Pi's own package auto-load registers. Pi's chain (package-manager
|
|
68
|
+
* `collectAutoExtensionEntries` → `resolveExtensionEntries`) resolves a
|
|
69
|
+
* directory to its nested pi-manifest entries, else `index.ts`/`index.js`.
|
|
70
|
+
* If pi-crew injects the directory as-is, the child worker loads the same
|
|
71
|
+
* extension twice under two path identities (".../dist" via --extension vs
|
|
72
|
+
* ".../dist/index.js" via package auto-discovery) and pi's
|
|
73
|
+
* detectExtensionConflicts kills the worker at startup (exit 1, every tool
|
|
74
|
+
* reported as conflicting). Always inject the FILE identity.
|
|
75
|
+
*/
|
|
76
|
+
function resolveExtensionDirEntry(dir: string, depth = 0): string | undefined {
|
|
77
|
+
if (depth > 2) return undefined; // guard pathological nesting loops
|
|
78
|
+
const pkgJsonPath = path.join(dir, "package.json");
|
|
79
|
+
if (fs.existsSync(pkgJsonPath)) {
|
|
80
|
+
try {
|
|
81
|
+
const pkg = JSON.parse(fs.readFileSync(pkgJsonPath, "utf8")) as { pi?: { extensions?: string[] } };
|
|
82
|
+
const piExts = pkg.pi?.extensions;
|
|
83
|
+
if (Array.isArray(piExts) && piExts.length > 0) {
|
|
84
|
+
for (const rel of piExts) {
|
|
85
|
+
const abs = path.resolve(dir, rel);
|
|
86
|
+
if (!fs.existsSync(abs)) continue;
|
|
87
|
+
return fs.statSync(abs).isDirectory() ? resolveExtensionDirEntry(abs, depth + 1) : abs;
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
} catch {
|
|
91
|
+
/* malformed package.json — fall through to index probes */
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
// Order mirrors Pi's resolveExtensionEntries EXACTLY for the first two
|
|
95
|
+
// (index.ts → index.js): when Pi's auto-load registers the extension, the
|
|
96
|
+
// injected identity MUST match its pick. index.mjs and package.json `main`
|
|
97
|
+
// are fallbacks for packages Pi's auto-load would NOT register at all (it
|
|
98
|
+
// checks neither) — a single injected instance there cannot conflict, and
|
|
99
|
+
// `main` preserves the legacy jiti dir-loading behaviour (GH #61 follow-up).
|
|
100
|
+
for (const name of ["index.ts", "index.js"]) {
|
|
101
|
+
const abs = path.join(dir, name);
|
|
102
|
+
if (fs.existsSync(abs) && fs.statSync(abs).isFile()) return abs;
|
|
103
|
+
}
|
|
104
|
+
const mjs = path.join(dir, "index.mjs");
|
|
105
|
+
if (fs.existsSync(mjs) && fs.statSync(mjs).isFile()) return mjs;
|
|
106
|
+
if (fs.existsSync(pkgJsonPath)) {
|
|
107
|
+
try {
|
|
108
|
+
const main = (JSON.parse(fs.readFileSync(pkgJsonPath, "utf8")) as { main?: string }).main;
|
|
109
|
+
if (main) {
|
|
110
|
+
const abs = path.resolve(dir, main);
|
|
111
|
+
if (fs.existsSync(abs) && fs.statSync(abs).isFile()) return abs;
|
|
112
|
+
}
|
|
113
|
+
} catch {
|
|
114
|
+
/* malformed package.json — skip */
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
return undefined;
|
|
118
|
+
}
|
|
119
|
+
|
|
65
120
|
/**
|
|
66
121
|
* Resolve the extension entry point for an installed Pi package.
|
|
67
122
|
* Order: package.json `pi.extensions` (array) → `index.ts` → `index.mjs` → `index.js` → `src/index.ts`.
|
|
@@ -80,7 +135,18 @@ function resolvePackageEntry(pkgDir: string): string | undefined {
|
|
|
80
135
|
if (Array.isArray(piExts) && piExts.length > 0) {
|
|
81
136
|
for (const rel of piExts) {
|
|
82
137
|
const abs = path.resolve(pkgDir, rel);
|
|
83
|
-
if (fs.existsSync(abs))
|
|
138
|
+
if (!fs.existsSync(abs)) continue;
|
|
139
|
+
// GH #61: a DIRECTORY entry (e.g. pi-web-access "./dist") must be
|
|
140
|
+
// normalized to the file identity Pi's auto-load uses — injecting the
|
|
141
|
+
// raw dir double-loads the extension in child workers (tool conflicts,
|
|
142
|
+
// exit 1). An unresolvable dir is SKIPPED, matching Pi's own view of
|
|
143
|
+
// the package (its auto-load would not register it either).
|
|
144
|
+
if (fs.statSync(abs).isDirectory()) {
|
|
145
|
+
const entry = resolveExtensionDirEntry(abs);
|
|
146
|
+
if (entry) return entry;
|
|
147
|
+
continue;
|
|
148
|
+
}
|
|
149
|
+
return abs;
|
|
84
150
|
}
|
|
85
151
|
}
|
|
86
152
|
// Fall back to `main` when it points at a loadable entry (rare for extensions).
|
|
@@ -750,7 +750,12 @@ export async function reconcileAllStaleRuns(
|
|
|
750
750
|
const result = reconcileStaleRun(fresh.manifest, fresh.tasks, now);
|
|
751
751
|
if (result.repaired || result.verdict === "result_exists") {
|
|
752
752
|
if (result.repairedTasks) {
|
|
753
|
-
|
|
753
|
+
// NEW-1 (SDD 2026-09-30 WI-2): persist the FULL task array — the
|
|
754
|
+
// individual-stale branch filters `repairedTasks` down to the stale
|
|
755
|
+
// subset, and saveRunTasks full-overwrites tasks.json, which used to
|
|
756
|
+
// drop every healthy task from disk. `persistTasks` carries the full
|
|
757
|
+
// array; every other branch leaves it unset and falls back here.
|
|
758
|
+
saveRunTasks(fresh.manifest, result.persistTasks ?? result.repairedTasks);
|
|
754
759
|
for (const task of result.repairedTasks) {
|
|
755
760
|
try {
|
|
756
761
|
upsertCrewAgent(fresh.manifest, recordFromTask(fresh.manifest, task, "scaffold"));
|
|
@@ -102,6 +102,18 @@ export async function runCoalescedTaskGroup(input: CoalescedTaskGroupInput): Pro
|
|
|
102
102
|
// (now-terminal) updatedTasks AFTER its own save resolves, so the terminal
|
|
103
103
|
// state always lands last regardless of disk timing.
|
|
104
104
|
let finalWriteStarted = false;
|
|
105
|
+
// NEW-3 (SDD-4 W-D): the coalesced path was the only dispatch path with NO
|
|
106
|
+
// pid evidence at all — the singleton path records checkpoint.childPid at
|
|
107
|
+
// child-spawned (child-executor.ts), but this module never calls
|
|
108
|
+
// checkpointTask, so heartbeat.pid stayed undefined AND checkpoint.childPid
|
|
109
|
+
// stayed undefined → the PID-liveness gates in stale-reconciler.ts and
|
|
110
|
+
// heartbeat-watcher.ts (both read `heartbeat?.pid ?? checkpoint?.childPid`)
|
|
111
|
+
// resolved to undefined for EVERY task in EVERY coalesced group; an
|
|
112
|
+
// alive-but-silent coalesced worker could be falsely repaired. Capture the
|
|
113
|
+
// real worker pid at spawn — onSpawn fires per spawn attempt, so
|
|
114
|
+
// retry/model-fallback overwrite this with the latest live pid (mirroring
|
|
115
|
+
// the singleton ownership-entry semantics in child-executor.ts).
|
|
116
|
+
let workerPid: number | undefined;
|
|
105
117
|
if (!executeWorkers) {
|
|
106
118
|
rawOutput = buildScaffoldOutput(groupTasks);
|
|
107
119
|
success = true;
|
|
@@ -121,7 +133,7 @@ export async function runCoalescedTaskGroup(input: CoalescedTaskGroupInput): Pro
|
|
|
121
133
|
// snapshot of the FULL task array — a sibling cancelled on disk after
|
|
122
134
|
// dispatch could be un-cancelled by a late heartbeat save (the map
|
|
123
135
|
// only mutated group tasks, but the SAVE wrote the whole stale array).
|
|
124
|
-
await persistGroupHeartbeats(manifest, taskIds);
|
|
136
|
+
await persistGroupHeartbeats(manifest, taskIds, workerPid);
|
|
125
137
|
} catch {
|
|
126
138
|
// Run may have been pruned mid-dispatch — best-effort only.
|
|
127
139
|
} finally {
|
|
@@ -188,6 +200,11 @@ export async function runCoalescedTaskGroup(input: CoalescedTaskGroupInput): Pro
|
|
|
188
200
|
maxTurns: input.runtimeConfig?.maxTurns,
|
|
189
201
|
graceTurns: input.runtimeConfig?.graceTurns,
|
|
190
202
|
onJsonEvent: (e) => input.onJsonEvent?.(firstTask.id, manifest.runId, e),
|
|
203
|
+
// NEW-3: capture the spawned worker's real pid for the heartbeat
|
|
204
|
+
// persists (see `workerPid` declaration above).
|
|
205
|
+
onSpawn: (pid) => {
|
|
206
|
+
workerPid = pid;
|
|
207
|
+
},
|
|
191
208
|
});
|
|
192
209
|
} finally {
|
|
193
210
|
if (timeoutHandle) clearTimeout(timeoutHandle);
|
|
@@ -246,13 +263,70 @@ export async function runCoalescedTaskGroup(input: CoalescedTaskGroupInput): Pro
|
|
|
246
263
|
|
|
247
264
|
const split = splitCoalescedOutput(rawOutput, taskIds);
|
|
248
265
|
|
|
266
|
+
// G20 (2026-10-02): fail-closed partial delimiter-hit. When the splitter
|
|
267
|
+
// reports the output as unpartitionable (strategy "deadletter" — the
|
|
268
|
+
// worker delimited SOME but not ALL of the group's tasks), broadcasting
|
|
269
|
+
// the full raw output to every task used to leak each task's delimited
|
|
270
|
+
// body to every OTHER task. Instead, record the group in the deadletter
|
|
271
|
+
// store using the same deliberate US-003 pattern as the retry-exhaustion
|
|
272
|
+
// hook above: ONE entry against the group's FIRST task, with the whole
|
|
273
|
+
// group's taskIds named in the message — the group shares one worker, so
|
|
274
|
+
// a single post-mortem record is the deliberate granularity. Each task
|
|
275
|
+
// then gets a content-free marker and a terminal "failed" status (see the
|
|
276
|
+
// mapping loop below); no task receives another task's content.
|
|
277
|
+
const deadlettered = split.some((s) => s.strategy === "deadletter");
|
|
278
|
+
if (deadlettered) {
|
|
279
|
+
appendDeadletter(manifest, {
|
|
280
|
+
runId: manifest.runId,
|
|
281
|
+
taskId: firstTask.id,
|
|
282
|
+
reason: "manual",
|
|
283
|
+
attempts: 1,
|
|
284
|
+
lastError: `coalesced group (${taskIds.length} task(s): ${taskIds.join(", ")}) — unpartitionable worker output: partial delimiter hit; per-task results withheld (G20 fail-closed, cross-task broadcast suppressed)`,
|
|
285
|
+
timestamp: new Date().toISOString(),
|
|
286
|
+
agent: agent.name,
|
|
287
|
+
role: firstTask.role,
|
|
288
|
+
runStatus: manifest.status,
|
|
289
|
+
});
|
|
290
|
+
}
|
|
291
|
+
|
|
249
292
|
const finishedAt = new Date().toISOString();
|
|
250
293
|
const newArtifacts: TeamRunManifest["artifacts"] = [];
|
|
251
294
|
updatedTasks = updatedTasks.map((t) => {
|
|
252
295
|
if (!taskIds.includes(t.id)) return t;
|
|
253
296
|
const entry = split.find((s) => s.taskId === t.id);
|
|
297
|
+
// G20 deadletter branch: unpartitionable output. Fail closed — every
|
|
298
|
+
// group task gets a marker that carries NO cross-task content (no
|
|
299
|
+
// delimited bodies, no full raw output) and a terminal "failed" status
|
|
300
|
+
// regardless of the worker's exit code: a worker whose output cannot be
|
|
301
|
+
// partitioned has NOT produced this task's result.
|
|
302
|
+
if (deadlettered) {
|
|
303
|
+
const markerText = `[coalesced-output-unpartitionable] task ${t.id}: worker output could not be partitioned (partial delimiter hit) — per-task result withheld (G20 fail-closed); see deadletter entry for group ${groupId}`;
|
|
304
|
+
const resultArtifact = writeArtifact(manifest.artifactsRoot, {
|
|
305
|
+
kind: "result",
|
|
306
|
+
relativePath: `results/${t.id}.txt`,
|
|
307
|
+
content: markerText,
|
|
308
|
+
producer: t.id,
|
|
309
|
+
});
|
|
310
|
+
newArtifacts.push(resultArtifact);
|
|
311
|
+
return {
|
|
312
|
+
...t,
|
|
313
|
+
status: "failed" as const,
|
|
314
|
+
finishedAt,
|
|
315
|
+
result: {
|
|
316
|
+
text: markerText,
|
|
317
|
+
producer: groupId,
|
|
318
|
+
strategy: "deadletter",
|
|
319
|
+
},
|
|
320
|
+
resultArtifact,
|
|
321
|
+
};
|
|
322
|
+
}
|
|
254
323
|
const ok = success && Boolean(entry?.text);
|
|
255
|
-
|
|
324
|
+
// G20 (second leak path closed): a task with NO split entry previously
|
|
325
|
+
// fell back to the FULL rawOutput — i.e. every other task's content.
|
|
326
|
+
// That fallback was exactly the cross-task leak; a missing entry now
|
|
327
|
+
// gets an empty marker instead. (splitCoalescedOutput returns an entry
|
|
328
|
+
// per taskId in every branch today, so this is defensive.)
|
|
329
|
+
const text = entry?.text ?? `[no partition entry for task ${t.id} — result withheld]`;
|
|
256
330
|
// BUGFIX (M6 real dispatch): write to artifactsRoot via writeArtifact
|
|
257
331
|
// so task.resultArtifact is set and aggregateTaskOutputs can read
|
|
258
332
|
// the per-task text. Previously the coalesced path used a raw
|
|
@@ -315,8 +389,10 @@ export async function runCoalescedTaskGroup(input: CoalescedTaskGroupInput): Pro
|
|
|
315
389
|
await appendEventAsync(updatedManifest.eventsPath, {
|
|
316
390
|
type: "task.coalesced_dispatch_end",
|
|
317
391
|
runId: manifest.runId,
|
|
318
|
-
message:
|
|
319
|
-
|
|
392
|
+
message: deadlettered
|
|
393
|
+
? `Coalesced dispatch failed — unpartitionable output deadlettered (${taskIds.length} tasks, partial delimiter hit, G20 fail-closed)`
|
|
394
|
+
: `Coalesced dispatch ${success ? "completed" : cancelled ? "cancelled" : "failed"} (${taskIds.length} tasks, ${split[0]?.strategy ?? "broadcast"} split)`,
|
|
395
|
+
data: { groupId, taskIds, success, cancelled, strategy: split[0]?.strategy, deadlettered },
|
|
320
396
|
});
|
|
321
397
|
|
|
322
398
|
return { manifest: updatedManifest, tasks: updatedTasks, taskIds, rawOutput, success };
|
|
@@ -335,7 +411,7 @@ export async function runCoalescedTaskGroup(input: CoalescedTaskGroupInput): Pro
|
|
|
335
411
|
* with heartbeats touched; sibling tasks are preserved untouched from the
|
|
336
412
|
* fresh disk read. Keeps the FIND-06 'never replace terminal state' guard.
|
|
337
413
|
*/
|
|
338
|
-
async function persistGroupHeartbeats(manifest: TeamRunManifest, taskIds: string[]): Promise<void> {
|
|
414
|
+
async function persistGroupHeartbeats(manifest: TeamRunManifest, taskIds: string[], workerPid?: number): Promise<void> {
|
|
339
415
|
const fresh = loadRunManifestById(manifest.cwd, manifest.runId);
|
|
340
416
|
if (!fresh) return; // run pruned mid-dispatch — best-effort only.
|
|
341
417
|
const merged = fresh.tasks.map((t) => {
|
|
@@ -345,7 +421,14 @@ async function persistGroupHeartbeats(manifest: TeamRunManifest, taskIds: string
|
|
|
345
421
|
if (t.status === "completed" || t.status === "failed" || t.status === "cancelled") return t;
|
|
346
422
|
return {
|
|
347
423
|
...t,
|
|
348
|
-
|
|
424
|
+
// NEW-3: stamp the worker's real pid on BOTH the create fallback and
|
|
425
|
+
// the touch update. Conditional spread is required — a literal
|
|
426
|
+
// `pid: undefined` would clobber a pid recorded by an earlier tick
|
|
427
|
+
// (touchWorkerHeartbeat spreads `updates` verbatim).
|
|
428
|
+
heartbeat: touchWorkerHeartbeat(t.heartbeat ?? createWorkerHeartbeat(t.id, workerPid), {
|
|
429
|
+
alive: true,
|
|
430
|
+
...(workerPid ? { pid: workerPid } : {}),
|
|
431
|
+
}),
|
|
349
432
|
};
|
|
350
433
|
});
|
|
351
434
|
await saveRunTasksAsync(manifest, merged);
|
|
@@ -4,6 +4,7 @@ import * as path from "node:path";
|
|
|
4
4
|
import { getCrewEnv } from "../config/env-vars.ts";
|
|
5
5
|
import { errors } from "../errors.ts";
|
|
6
6
|
import { atomicWriteFile, atomicWriteJson } from "../state/atomic-write.ts";
|
|
7
|
+
import { type ActiveRunRegistryEntry, activeRunEntries } from "../state/stores/active-run-registry.ts";
|
|
7
8
|
import { getCurrentPlanRecord } from "../state/stores/plan-store.ts";
|
|
8
9
|
import { loadManifestWithRecovery, loadTasksWithRecovery, saveRunManifest } from "../state/stores/state-store.ts";
|
|
9
10
|
import type { TeamRunManifest, TeamTaskState } from "../state/types.ts";
|
|
@@ -44,6 +45,13 @@ export interface ReconcileResult {
|
|
|
44
45
|
detail: string;
|
|
45
46
|
/** Repaired task state, returned to a locked caller for persistence. */
|
|
46
47
|
repairedTasks?: TeamTaskState[];
|
|
48
|
+
/** NEW-1 (SDD 2026-09-30 WI-2): what the caller should PERSIST — the FULL
|
|
49
|
+
* task array, including tasks the repair did not touch. Only the
|
|
50
|
+
* individual-stale branch sets it (its `repairedTasks` is filtered down to
|
|
51
|
+
* the stale subset, which must NOT be full-overwrite-persisted or healthy
|
|
52
|
+
* tasks vanish from tasks.json). Callers fall back to `repairedTasks`
|
|
53
|
+
* when absent — every other branch already returns the full array there. */
|
|
54
|
+
persistTasks?: TeamTaskState[];
|
|
47
55
|
}
|
|
48
56
|
|
|
49
57
|
/**
|
|
@@ -353,6 +361,25 @@ function buildStaleReconcileError(task: TeamTaskState, reason: string): Error {
|
|
|
353
361
|
return errors.runStale(reason, heartbeatAgeSeconds);
|
|
354
362
|
}
|
|
355
363
|
|
|
364
|
+
/**
|
|
365
|
+
* G24 (SDD-4 WI-2): find a LIVE active-run-registry entry for this runId.
|
|
366
|
+
* activeRunEntries() already applies the full liveness filter (terminal status,
|
|
367
|
+
* dead async PID, >30min-stale non-async manifests, symlink safety), so an
|
|
368
|
+
* entry found here means some live session or runner claims the run RIGHT NOW.
|
|
369
|
+
* Read-only consult (register/unregister are never imported here). Fail-open:
|
|
370
|
+
* on an unexpected registry error return undefined so normal staleness
|
|
371
|
+
* reconciliation proceeds — a broken registry must not shield dead runs from
|
|
372
|
+
* repair forever.
|
|
373
|
+
*/
|
|
374
|
+
function findLiveRegistryEntry(runId: string): ActiveRunRegistryEntry | undefined {
|
|
375
|
+
try {
|
|
376
|
+
return activeRunEntries().find((entry) => entry.runId === runId);
|
|
377
|
+
} catch (err) {
|
|
378
|
+
logInternalError("stale-reconciler", new Error(`active-run registry consult failed for ${runId}: ${err}`), undefined, "warn");
|
|
379
|
+
return undefined;
|
|
380
|
+
}
|
|
381
|
+
}
|
|
382
|
+
|
|
356
383
|
function repairStaleRun(manifest: TeamRunManifest, tasks: TeamTaskState[], reason: string): TeamTaskState[] {
|
|
357
384
|
const now = new Date().toISOString();
|
|
358
385
|
const repairedTasks = tasks.map((task) => {
|
|
@@ -425,6 +452,33 @@ export function reconcileStaleRun(manifest: TeamRunManifest, tasks: TeamTaskStat
|
|
|
425
452
|
};
|
|
426
453
|
}
|
|
427
454
|
|
|
455
|
+
// G24 (SDD-4 WI-2, plan §2 upgrade-2026-09-29): foreign-LIVE skip. The
|
|
456
|
+
// active-run registry is the cross-session source of truth for "a live
|
|
457
|
+
// session/runner owns this run right now" (registerActiveRun at dispatch;
|
|
458
|
+
// entries self-expire via PID liveness + the 30min freshness horizon). Until
|
|
459
|
+
// now ownership was only checked as "run of the CURRENT session"
|
|
460
|
+
// (crash-recovery.ts ownerSessionId filter), so a child session — or the /tmp
|
|
461
|
+
// orphan scan, which has no session identity at all — could repair (cancel)
|
|
462
|
+
// a run whose workers were alive but heartbeat-frozen (G25 silent turn:
|
|
463
|
+
// 12m27s measured on a live worker). Must run BEFORE Phase 1/2/3: no phase
|
|
464
|
+
// below may act on a run a live owner still owns. Placement AFTER the
|
|
465
|
+
// intentional-wait guards keeps their more precise verdicts (both already
|
|
466
|
+
// return repaired:false, so no repair was ever at risk from them).
|
|
467
|
+
const liveEntry = findLiveRegistryEntry(runId);
|
|
468
|
+
if (liveEntry) {
|
|
469
|
+
return {
|
|
470
|
+
runId,
|
|
471
|
+
verdict: "healthy",
|
|
472
|
+
repaired: false,
|
|
473
|
+
// Honest text (G24): says exactly what happened — skipped because a live
|
|
474
|
+
// owner exists — and does not claim any repair. The "healthy" verdict
|
|
475
|
+
// keeps callers passive: crash-recovery does not push it into the
|
|
476
|
+
// notify result list, and reconcileOrphanedTempWorkspaces marks the
|
|
477
|
+
// workspace hasRunning so the foreign run's state is preserved.
|
|
478
|
+
detail: `Foreign-LIVE run: active-run registry entry is alive (cwd ${liveEntry.cwd}, updatedAt ${liveEntry.updatedAt}); reconcile skipped — a live session/runner owns this run, nothing was changed`,
|
|
479
|
+
};
|
|
480
|
+
}
|
|
481
|
+
|
|
428
482
|
// Phase 1: Check if results already exist
|
|
429
483
|
const phase1 = checkResultFile(manifest, tasks);
|
|
430
484
|
if (phase1.found) {
|
|
@@ -490,6 +544,7 @@ export function reconcileStaleRun(manifest: TeamRunManifest, tasks: TeamTaskStat
|
|
|
490
544
|
repaired: true,
|
|
491
545
|
detail: `No PID; ${staleTaskIds.length} individually stale task(s) repaired: ${staleTaskIds.join(", ")}`,
|
|
492
546
|
repairedTasks: repaired.filter((t) => staleTaskIds.includes(t.id)),
|
|
547
|
+
persistTasks: repaired, // NEW-1: persist FULL array (data-loss fix — SDD WI-2)
|
|
493
548
|
};
|
|
494
549
|
}
|
|
495
550
|
// Fall through: no recent activity but not all tasks stale enough yet.
|
|
@@ -526,6 +526,16 @@ export function collectDependencyOutputContext(
|
|
|
526
526
|
return { dependencies: trimmedDependencies, sharedReads };
|
|
527
527
|
}
|
|
528
528
|
|
|
529
|
+
// ADR-5 §trust-fence: dependency output is DATA, never instructions. Mirror the
|
|
530
|
+
// ask/delegate seams in src/prompt/prompt-runtime.ts (:323, :426, :589): strip
|
|
531
|
+
// control chars and neutralize a smuggled closing fence tag so worker-controlled
|
|
532
|
+
// content can never close the <dependency-context> fence early (see SDD 2026-09-30 WI-1).
|
|
533
|
+
const DEPENDENCY_CONTROL_CHAR_PATTERN = /[\x00-\x08\x0B\x0C\x0E-\x1F\x7F]/g;
|
|
534
|
+
|
|
535
|
+
function sanitizeFencedBody(body: string): string {
|
|
536
|
+
return body.replace(DEPENDENCY_CONTROL_CHAR_PATTERN, "").replace(/<\/dependency-context/g, "</dependency-context");
|
|
537
|
+
}
|
|
538
|
+
|
|
529
539
|
export function renderDependencyOutputContext(context: DependencyOutputContext): string {
|
|
530
540
|
const parts: string[] = [];
|
|
531
541
|
if (context.dependencies.length) {
|
|
@@ -565,7 +575,7 @@ export function renderDependencyOutputContext(context: DependencyOutputContext):
|
|
|
565
575
|
parts.push("", read.content.trim(), "");
|
|
566
576
|
}
|
|
567
577
|
}
|
|
568
|
-
return parts.join("\n").trim();
|
|
578
|
+
return sanitizeFencedBody(parts.join("\n").trim());
|
|
569
579
|
}
|
|
570
580
|
|
|
571
581
|
export function writeTaskSharedOutput(manifest: TeamRunManifest, step: WorkflowStep, task: TeamTaskState): ArtifactDescriptor | undefined {
|
|
@@ -28,7 +28,7 @@
|
|
|
28
28
|
import * as fs from "node:fs";
|
|
29
29
|
import * as path from "node:path";
|
|
30
30
|
import { loadConfig } from "../../config/config.ts";
|
|
31
|
-
import { getCrewEnv } from "../../config/env-vars.ts";
|
|
31
|
+
import { getCrewEnv, getCrewEnvInt } from "../../config/env-vars.ts";
|
|
32
32
|
import { errors } from "../../errors.ts";
|
|
33
33
|
import { appendEventAsync, appendEventBuffered } from "../../state/event-log/event-log.ts";
|
|
34
34
|
import { writeArtifact } from "../../state/stores/artifact-store.ts";
|
|
@@ -64,6 +64,7 @@ import { readEnabledModelsPatterns } from "../model/model-scope.ts";
|
|
|
64
64
|
import { type ParsedPiJsonOutput, parsePiJsonOutput } from "../output/pi-json-output.ts";
|
|
65
65
|
import { type ProgressEventSummary, shouldAppendProgressEventUpdate } from "../output/progress-event-coalescer.ts";
|
|
66
66
|
import { buildSyntheticTerminalEvidence, cancellationReasonFromSignal } from "../process/cancellation.ts";
|
|
67
|
+
import { checkProcessLiveness } from "../process-status.ts";
|
|
67
68
|
import { DEFAULT_RETRY_POLICY } from "../recovery/retry-executor.ts";
|
|
68
69
|
import { runWorker } from "../run-worker.ts";
|
|
69
70
|
import { parseSessionUsage } from "../session-usage.ts";
|
|
@@ -152,6 +153,90 @@ export function computeSpawnBudgetMax(attemptModelsCount: number, configuredMaxA
|
|
|
152
153
|
return attemptModelsCount * (configuredMaxAttempts + 1);
|
|
153
154
|
}
|
|
154
155
|
|
|
156
|
+
/**
|
|
157
|
+
* NEW-4/G25 (SDD-4 WI-4): default liveness-pulse interval, in ms.
|
|
158
|
+
*
|
|
159
|
+
* Why a pulse at all: `persistHeartbeat` was only invoked from stdout-driven
|
|
160
|
+
* callbacks (onStdoutLine / onJsonEvent / onSurfaceActivity). A worker turn
|
|
161
|
+
* that stays silent for longer than the stale windows — observed live:
|
|
162
|
+
* 12m27s (heartbeat gradient deadMs=300s, staleMs=60s; stale-reconciler
|
|
163
|
+
* NO_PID_HEARTBEAT_STALE_MS=300s) — froze heartbeat.lastSeenAt while the
|
|
164
|
+
* worker process was alive and mid-turn, so liveness consumers (heartbeat
|
|
165
|
+
* watcher gradient, widget, stale reconciler no-PID path) saw a "dead"
|
|
166
|
+
* worker that was actually healthy.
|
|
167
|
+
*
|
|
168
|
+
* 15s keeps the gradient at "healthy" (warnMs=30s) with 2× headroom, stays
|
|
169
|
+
* 4× under staleMs (60s) and 20× under deadMs (300s), and costs at most one
|
|
170
|
+
* throttled tasks.json persist per running task per 15s (persistHeartbeat's
|
|
171
|
+
* 1s throttle binds first for tighter configured intervals).
|
|
172
|
+
*
|
|
173
|
+
* Design choice (producer-side pulse vs reconciler corroboration): the
|
|
174
|
+
* reconciler and heartbeat-watcher ALREADY carry a PID-liveness gate
|
|
175
|
+
* (stale-reconciler.ts isTaskHeartbeatStale, heartbeat-watcher.ts
|
|
176
|
+
* dead→stale downgrade) keyed on `heartbeat.pid ?? checkpoint.childPid`.
|
|
177
|
+
* Those gates only help when a pid was recorded AND the consumer runs it;
|
|
178
|
+
* the pulse fixes the PRODUCER side for every consumer at once (gradient
|
|
179
|
+
* levels, widget, no-pid repair path, ambient notifications) without
|
|
180
|
+
* touching stale-reconciler.ts (owned by WI-2). Both are wanted; this is
|
|
181
|
+
* the least invasive in-lane half. See handoff for the recommendation.
|
|
182
|
+
*/
|
|
183
|
+
const DEFAULT_HEARTBEAT_PULSE_INTERVAL_MS = 15_000;
|
|
184
|
+
|
|
185
|
+
/** Opaque handle for the per-attempt heartbeat liveness pulse. */
|
|
186
|
+
export interface WorkerHeartbeatPulse {
|
|
187
|
+
/** Stop the pulse and clear its timer. Idempotent. */
|
|
188
|
+
stop(): void;
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
/**
|
|
192
|
+
* Resolve the liveness-pulse interval from `PI_CREW_HEARTBEAT_PULSE_MS`.
|
|
193
|
+
* Unset/invalid → default (15s). Values ≤ 0 disable the pulse entirely
|
|
194
|
+
* (escape hatch for operators/batteries that want the pre-fix behaviour).
|
|
195
|
+
*/
|
|
196
|
+
export function resolveHeartbeatPulseIntervalMs(): number {
|
|
197
|
+
const configured = getCrewEnvInt("PI_CREW_HEARTBEAT_PULSE_MS");
|
|
198
|
+
if (configured === undefined || !Number.isFinite(configured)) return DEFAULT_HEARTBEAT_PULSE_INTERVAL_MS;
|
|
199
|
+
if (configured <= 0) return 0;
|
|
200
|
+
return configured;
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
/**
|
|
204
|
+
* Start a periodic heartbeat liveness pulse: every `intervalMs`, if
|
|
205
|
+
* `isAlive()` reports the worker incarnation as alive, call `touch()`.
|
|
206
|
+
* This decouples heartbeat freshness from stdout events (NEW-4/G25): a
|
|
207
|
+
* silent-but-alive turn keeps the heartbeat fresh; a dead worker stops
|
|
208
|
+
* being touched the moment its pid checks dead (see [pulse-2]).
|
|
209
|
+
*
|
|
210
|
+
* Failure containment: a throwing `touch`/`isAlive` is logged and the timer
|
|
211
|
+
* keeps ticking — the pulse must never crash the host process ([pulse-3]).
|
|
212
|
+
* The timer is `unref`'d so it can never hold the event loop open.
|
|
213
|
+
*/
|
|
214
|
+
export function startWorkerHeartbeatPulse(opts: { intervalMs: number; isAlive: () => boolean; touch: () => void }): WorkerHeartbeatPulse {
|
|
215
|
+
if (opts.intervalMs <= 0) {
|
|
216
|
+
return {
|
|
217
|
+
stop() {
|
|
218
|
+
// Intentional no-op: a disabled pulse (interval ≤ 0) has no timer to clear.
|
|
219
|
+
},
|
|
220
|
+
};
|
|
221
|
+
}
|
|
222
|
+
let stopped = false;
|
|
223
|
+
const timer = setInterval(() => {
|
|
224
|
+
if (stopped) return;
|
|
225
|
+
try {
|
|
226
|
+
if (opts.isAlive()) opts.touch();
|
|
227
|
+
} catch (err) {
|
|
228
|
+
logInternalError("task-runner.heartbeat-pulse", err as Error);
|
|
229
|
+
}
|
|
230
|
+
}, opts.intervalMs);
|
|
231
|
+
timer.unref?.();
|
|
232
|
+
return {
|
|
233
|
+
stop() {
|
|
234
|
+
stopped = true;
|
|
235
|
+
clearInterval(timer);
|
|
236
|
+
},
|
|
237
|
+
};
|
|
238
|
+
}
|
|
239
|
+
|
|
155
240
|
/**
|
|
156
241
|
* Resolve the model fallback policy from crew config + env. Best-effort: any
|
|
157
242
|
* config read failure returns undefined (legacy unbounded/unordered behaviour).
|
|
@@ -442,12 +527,16 @@ export async function runChildProcessTask(ctx: TaskExecutionContext): Promise<Ta
|
|
|
442
527
|
// is informational and re-derivable from per-agent records.
|
|
443
528
|
// appendEventBuffered coalesces into a single lock acquire after bufferMs,
|
|
444
529
|
// reducing producer p95 from ~13µs (serial) to ~0µs (bench M7).
|
|
530
|
+
// .catch is REQUIRED (CI 2026-10-03 char-core5-6): the buffered flush
|
|
531
|
+
// rejects queued promises on write failure (e.g. ENOENT after cwd
|
|
532
|
+
// cleanup) — a naked `void` turns that into an unhandledRejection
|
|
533
|
+
// that kills the host test/process long after the caller returned.
|
|
445
534
|
void appendEventBuffered(manifest.eventsPath, {
|
|
446
535
|
type: "task.progress",
|
|
447
536
|
runId: manifest.runId,
|
|
448
537
|
taskId: task.id,
|
|
449
538
|
data: { ...summary, coalesceReason: decision.reason },
|
|
450
|
-
});
|
|
539
|
+
}).catch((error) => logInternalError("child-executor.progress-buffered", error, `taskId=${task.id}, runId=${manifest.runId}`));
|
|
451
540
|
lastRunProgressSummary = summary;
|
|
452
541
|
lastRunProgressPersistedAt = now;
|
|
453
542
|
}
|
|
@@ -535,6 +624,30 @@ export async function runChildProcessTask(ctx: TaskExecutionContext): Promise<Ta
|
|
|
535
624
|
}, taskTimeoutMs);
|
|
536
625
|
timeoutHandle.unref?.();
|
|
537
626
|
}
|
|
627
|
+
// NEW-4/G25 (SDD-4 WI-4): per-attempt heartbeat liveness pulse. Keeps
|
|
628
|
+
// heartbeat.lastSeenAt fresh while THIS attempt's worker process is
|
|
629
|
+
// alive, independent of stdout events (a silent turn no longer freezes
|
|
630
|
+
// the heartbeat → liveness consumers stop repairing live workers).
|
|
631
|
+
// Liveness predicate:
|
|
632
|
+
// - attempt aborted (wall-clock timeout / external cancel) → stop
|
|
633
|
+
// asserting liveness; kill escalation owns the remainder.
|
|
634
|
+
// - pid known (onSpawn fired) → hard corroboration via kill(pid,0);
|
|
635
|
+
// a dead pid is never touched, so genuine zombie repair keeps working.
|
|
636
|
+
// - pid not yet known (pre-spawn window / mock fixtures that bypass
|
|
637
|
+
// spawn) → the pending runWorker promise is the best available
|
|
638
|
+
// evidence of an in-flight incarnation; touch (fail-open, bounded:
|
|
639
|
+
// real spawns pin the pid within ms, and the reconciler's ESRCH
|
|
640
|
+
// verdict can never be overridden by a fresh heartbeat).
|
|
641
|
+
let attemptWorkerPid: number | undefined;
|
|
642
|
+
const heartbeatPulse = startWorkerHeartbeatPulse({
|
|
643
|
+
intervalMs: resolveHeartbeatPulseIntervalMs(),
|
|
644
|
+
isAlive: () => {
|
|
645
|
+
if (timeoutController.signal.aborted) return false;
|
|
646
|
+
if (attemptWorkerPid === undefined) return true;
|
|
647
|
+
return checkProcessLiveness(attemptWorkerPid).alive;
|
|
648
|
+
},
|
|
649
|
+
touch: () => persistHeartbeat(),
|
|
650
|
+
});
|
|
538
651
|
let childResult: ChildPiRunResult;
|
|
539
652
|
try {
|
|
540
653
|
childResult = await runWorker({
|
|
@@ -561,6 +674,9 @@ export async function runChildProcessTask(ctx: TaskExecutionContext): Promise<Ta
|
|
|
561
674
|
attempt: i,
|
|
562
675
|
steeringFile: resolveRealContainedPath(`${manifest.artifactsRoot}/steering`, `${task.id}.jsonl`),
|
|
563
676
|
onSpawn: (pid) => {
|
|
677
|
+
// NEW-4/G25: pin this incarnation's pid FIRST so the liveness pulse
|
|
678
|
+
// corroborates against the real process from its first tick on.
|
|
679
|
+
attemptWorkerPid = pid;
|
|
564
680
|
try {
|
|
565
681
|
// WP-1/R1 (H6): dispatch-time ownership writer — record the
|
|
566
682
|
// task ⇄ pid ⇄ artifactsDir leg of the ownership map as soon as
|
|
@@ -621,7 +737,9 @@ export async function runChildProcessTask(ctx: TaskExecutionContext): Promise<Ta
|
|
|
621
737
|
// Fire-and-forget async write for steering events (the file was
|
|
622
738
|
// truncated just above; this incarnation receives only its own
|
|
623
739
|
// pending steers).
|
|
624
|
-
void appendSteeringAsync(steeringDir, task.id, task.pendingSteers)
|
|
740
|
+
void appendSteeringAsync(steeringDir, task.id, task.pendingSteers).catch((error) =>
|
|
741
|
+
logInternalError("child-executor.steering-append", error as Error, `taskId=${task.id}`),
|
|
742
|
+
);
|
|
625
743
|
// RT-8: spread before clearing pendingSteers instead of mutating
|
|
626
744
|
// in place — preserves the immutable-snapshot invariant (the same
|
|
627
745
|
// object may already be referenced by the tasks array / snapshots).
|
|
@@ -692,7 +810,9 @@ export async function runChildProcessTask(ctx: TaskExecutionContext): Promise<Ta
|
|
|
692
810
|
const bgLogPath = `${manifest.stateRoot}/background.log`;
|
|
693
811
|
const eventLine = typeof event === "object" && !Array.isArray(event) ? JSON.stringify(event) : String(event);
|
|
694
812
|
// Fire-and-forget async write for background log
|
|
695
|
-
void appendBackgroundLogAsync(bgLogPath, eventLine)
|
|
813
|
+
void appendBackgroundLogAsync(bgLogPath, eventLine).catch((error) =>
|
|
814
|
+
logInternalError("child-executor.background-log", error as Error, `taskId=${task.id}`),
|
|
815
|
+
);
|
|
696
816
|
}
|
|
697
817
|
// Always keep in-memory agentProgress fresh (cheap) so the UI/events see
|
|
698
818
|
// the latest progress, but THROTTLE the disk persist. Previously this
|
|
@@ -764,6 +884,12 @@ export async function runChildProcessTask(ctx: TaskExecutionContext): Promise<Ta
|
|
|
764
884
|
// the persisted events/output after a task always match what the
|
|
765
885
|
// unbuffered implementation would have written.
|
|
766
886
|
flushCrewAgentRecordBuffer(manifest, task.id);
|
|
887
|
+
// NEW-4/G25: the attempt is over (success, failure, or throw) — stop
|
|
888
|
+
// the liveness pulse in the same per-attempt cleanup as the wall-clock
|
|
889
|
+
// timeout (R3 contract block): nothing may touch this incarnation's
|
|
890
|
+
// heartbeat anymore, including across the model-fallback respawn gap
|
|
891
|
+
// (where nothing is alive and the heartbeat must be allowed to age).
|
|
892
|
+
heartbeatPulse.stop();
|
|
767
893
|
if (timeoutHandle) clearTimeout(timeoutHandle);
|
|
768
894
|
// W2 fix — release the listener so it doesn't leak. {once:true}
|
|
769
895
|
// only auto-removes when the listener FIRES; if the timeout
|
|
@@ -9,25 +9,40 @@
|
|
|
9
9
|
* Strategy (in priority order):
|
|
10
10
|
* 1. Delimiter parse — exact match of `<<<TASK_RESULT:id>>>` markers
|
|
11
11
|
* 2. Section heading parse — `### Task N of M` / `## Task N` headers
|
|
12
|
+
* (only reachable when the output contains ZERO delimiter markers)
|
|
12
13
|
* 3. Whole-output broadcast — assign full raw output to ALL taskIds
|
|
13
|
-
* (safe fallback for read-only roles where identical context is OK
|
|
14
|
+
* (safe fallback for read-only roles where identical context is OK;
|
|
15
|
+
* only reachable when the output contains ZERO delimiter markers —
|
|
16
|
+
* see the deadletter note below for why)
|
|
17
|
+
* Deadletter (G20 fail-closed) — a PARTIAL delimiter hit (some but not
|
|
18
|
+
* all taskIds delimited) marks the whole output unpartitionable: no
|
|
19
|
+
* task may receive any partition of it. Previously this case fell
|
|
20
|
+
* through to Strategy 3 and broadcast the full raw output — including
|
|
21
|
+
* other tasks' delimited bodies — to every task in the group. The
|
|
22
|
+
* caller (run-coalesced-task-group) records the group via
|
|
23
|
+
* appendDeadletter and fails the tasks instead.
|
|
14
24
|
*
|
|
15
25
|
* Returns an array of { taskId, text } records in input-order. When the
|
|
16
26
|
* delimiter strategy succeeds, each entry contains that task's delimited
|
|
17
27
|
* text. When broadcast fallback is used, all entries contain the full
|
|
18
|
-
* raw output.
|
|
28
|
+
* raw output. When the deadletter strategy is returned, all entries have
|
|
29
|
+
* empty text — the caller decides what marker to surface per task.
|
|
19
30
|
*
|
|
20
31
|
* Note: pure function — no I/O, no LLM calls, deterministic. This is
|
|
21
32
|
* deliberately the simplest possible splitter; we don't try fuzzy
|
|
22
33
|
* matching, NL extraction, or retry-the-LLM. The MVP trusts workers
|
|
23
|
-
* to follow delimiters 99% of the time
|
|
34
|
+
* to follow delimiters 99% of the time; a zero-marker miss degrades to
|
|
35
|
+
* broadcast (nothing task-specific exists to leak), and a partial
|
|
36
|
+
* violation now fails closed (G20) instead of leaking cross-task
|
|
37
|
+
* delimited bodies.
|
|
24
38
|
*/
|
|
25
39
|
|
|
26
40
|
export interface SplitResult {
|
|
27
41
|
taskId: string;
|
|
28
42
|
text: string;
|
|
29
|
-
/** Which strategy produced this entry.
|
|
30
|
-
|
|
43
|
+
/** Which strategy produced this entry. `deadletter` = G20 fail-closed:
|
|
44
|
+
* partial delimiter hit — output is unpartitionable, text is empty. */
|
|
45
|
+
strategy: "delimiter" | "section" | "broadcast" | "deadletter";
|
|
31
46
|
}
|
|
32
47
|
|
|
33
48
|
/**
|
|
@@ -69,8 +84,30 @@ export function splitCoalescedOutput(rawOutput: string, taskIds: string[]): Spli
|
|
|
69
84
|
}));
|
|
70
85
|
}
|
|
71
86
|
|
|
87
|
+
// G20 fail-closed: PARTIAL delimiter hit (0 < hits < taskIds.length).
|
|
88
|
+
// The worker started following the delimiter contract but did not finish
|
|
89
|
+
// it — the output mixes delimited per-task bodies with unattributed
|
|
90
|
+
// prose. No per-task partition can be trusted at this point, not even
|
|
91
|
+
// for the ids that DID get a delimiter: attributing a body to task X is
|
|
92
|
+
// only sound when every other body is provably not X's, and partial
|
|
93
|
+
// coverage cannot prove that. Previously this case skipped Strategy 2
|
|
94
|
+
// entirely (the `delimiterHits.size === 0` guard below) and fell through
|
|
95
|
+
// to Strategy 3, broadcasting the FULL raw output — including other
|
|
96
|
+
// tasks' delimited bodies — to every task in the group (cross-task
|
|
97
|
+
// content leak). Fail closed instead: mark the whole group deadletter;
|
|
98
|
+
// the caller records it and withholds per-task results.
|
|
99
|
+
if (delimiterHits.size > 0) {
|
|
100
|
+
return taskIds.map((id) => ({
|
|
101
|
+
taskId: id,
|
|
102
|
+
text: "",
|
|
103
|
+
strategy: "deadletter" as const,
|
|
104
|
+
}));
|
|
105
|
+
}
|
|
106
|
+
|
|
72
107
|
// Strategy 2: section heading parse.
|
|
73
108
|
// Look for `### Task N of M` or `## Task {id}` markers. We try both.
|
|
109
|
+
// Only reachable with ZERO delimiter hits — partial hits return
|
|
110
|
+
// deadletter above.
|
|
74
111
|
if (delimiterHits.size === 0) {
|
|
75
112
|
const bySection = parseBySectionHeadings(rawOutput, taskIds);
|
|
76
113
|
if (bySection.size === taskIds.length) {
|
|
@@ -85,6 +122,12 @@ export function splitCoalescedOutput(rawOutput: string, taskIds: string[]): Spli
|
|
|
85
122
|
// Strategy 3: broadcast fallback. All tasks get the full raw output.
|
|
86
123
|
// This is safe for read-only roles (explorers, reviewers) where
|
|
87
124
|
// downstream consumers care about findings, not strict per-task partitioning.
|
|
125
|
+
// G20: unreachable for partial delimiter hits (those return deadletter
|
|
126
|
+
// above). Broadcast is ONLY reachable when the output contains ZERO
|
|
127
|
+
// delimiter markers and section parsing failed — i.e. the raw output has
|
|
128
|
+
// no delimited per-task bodies at all, so broadcasting it cannot leak one
|
|
129
|
+
// task's delimited content to another task. If that zero-marker premise
|
|
130
|
+
// ever changes, this fallback must be re-audited for cross-task leaks.
|
|
88
131
|
return taskIds.map((id) => ({
|
|
89
132
|
taskId: id,
|
|
90
133
|
text: rawOutput,
|