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.
Files changed (58) hide show
  1. package/CHANGELOG.md +109 -0
  2. package/README.md +2 -2
  3. package/dist/index.mjs +498 -492
  4. package/docs/actions-reference.md +22 -0
  5. package/docs/dynamic-workflows.md +37 -0
  6. package/index.ts +9 -5
  7. package/package.json +18 -14
  8. package/scripts/analyze-run.mjs +101 -14
  9. package/skills/model-routing-context/SKILL.md +4 -3
  10. package/skills/real-test-pi-crew/SKILL.md +10 -7
  11. package/src/config/env-vars.ts +6 -0
  12. package/src/extension/crew-vibes/index.ts +29 -10
  13. package/src/extension/pi-api.ts +1 -0
  14. package/src/extension/registration/lifecycle-handlers.ts +65 -3
  15. package/src/extension/team-tool/dispatch/index.ts +4 -3
  16. package/src/extension/team-tool/dispatch/status.ts +7 -3
  17. package/src/extension/team-tool/inspect.ts +90 -0
  18. package/src/extension/team-tool/respond.ts +54 -0
  19. package/src/extension/team-tool/status.ts +10 -0
  20. package/src/extension/team-tool.ts +393 -3
  21. package/src/extension/webhook-notify.ts +23 -1
  22. package/src/observability/exporters/otlp-exporter.ts +93 -11
  23. package/src/prompt/prompt-runtime.ts +13 -4
  24. package/src/runtime/agent-control.ts +1 -15
  25. package/src/runtime/background-runner.ts +3 -0
  26. package/src/runtime/broker/crew-broker.ts +12 -103
  27. package/src/runtime/broker/protocol/request-parsers.ts +9 -1
  28. package/src/runtime/broker/protocol/wait-resolve.ts +168 -0
  29. package/src/runtime/child-pi/child-pi-spawn.ts +5 -4
  30. package/src/runtime/dispatch-batch.ts +3 -1
  31. package/src/runtime/goal-workflow/dynamic-workflow-context.ts +111 -12
  32. package/src/runtime/goal-workflow/dynamic-workflow-runner.ts +19 -0
  33. package/src/runtime/live-session/live-agent-manager.ts +35 -0
  34. package/src/runtime/live-session/live-session-runtime.ts +34 -5
  35. package/src/runtime/mcp-proxy.ts +110 -7
  36. package/src/runtime/model/pi-args.ts +14 -11
  37. package/src/runtime/model/provider-extensions.ts +67 -1
  38. package/src/runtime/recovery/crash-recovery.ts +6 -1
  39. package/src/runtime/scheduling/run-coalesced-task-group.ts +89 -6
  40. package/src/runtime/stale-reconciler.ts +55 -0
  41. package/src/runtime/task-output-context.ts +11 -1
  42. package/src/runtime/task-runner/child-executor.ts +130 -4
  43. package/src/runtime/task-runner/output-splitter.ts +48 -5
  44. package/src/runtime/task-runner/post-execution.ts +2 -2
  45. package/src/runtime/task-runner/pre-execution.ts +37 -24
  46. package/src/runtime/task-runner/retrieval-orchestrator.ts +28 -2
  47. package/src/schema/team-tool-schema.ts +27 -1
  48. package/src/state/contracts.ts +16 -0
  49. package/src/state/crew-init.ts +114 -0
  50. package/src/state/stores/active-run-registry.ts +13 -6
  51. package/src/state/types.ts +0 -8
  52. package/src/ui/goal-flag.ts +44 -0
  53. package/src/ui/live-run-sidebar.ts +5 -1
  54. package/src/ui/pi-ui-compat.ts +20 -3
  55. package/src/ui/run-dashboard.ts +2 -1
  56. package/src/ui/widget/widget-model.ts +6 -2
  57. package/src/utils/ip-literal.ts +145 -0
  58. package/src/workflows/workflow-config.ts +6 -0
@@ -621,10 +621,19 @@ function findAskResponse(manifest: TeamRunManifest, questionId: string): Mailbox
621
621
  * report — best-effort wait.resolve on EVERY terminal path (answered /
622
622
  * timed-out / aborted). A rejected resolve leaves the task parked; the
623
623
  * scheduler's TTL leak-guard (stale-reconciler) is the backstop, so the
624
- * failure is logged, never thrown. */
625
- async function resolvePark(client: AskBrokerClientSurface, taskId: string, questionId: string): Promise<void> {
624
+ * failure is logged, never thrown.
625
+ * G5 (deep-review 2026-10-01): the terminal outcome rides the resolve frame
626
+ * so the broker can emit `task.respond_delivered` ONLY when the worker
627
+ * actually picked up the leader's mailbox response ("answered") — a
628
+ * timed-out/aborted park was NOT a delivery and must not masquerade as one. */
629
+ async function resolvePark(
630
+ client: AskBrokerClientSurface,
631
+ taskId: string,
632
+ questionId: string,
633
+ outcome: "answered" | "timed-out" | "aborted",
634
+ ): Promise<void> {
626
635
  try {
627
- const res = await client.request("wait.resolve", { to: taskId, questionId });
636
+ const res = await client.request("wait.resolve", { to: taskId, questionId, outcome });
628
637
  if (!res.ok) {
629
638
  logInternalError(
630
639
  "prompt-runtime.ask-wait-resolve",
@@ -774,7 +783,7 @@ export function createAskTool(deps: AskToolDeps = {}): AskToolDefinition {
774
783
  }
775
784
  const waitedMs = now() - startedAt;
776
785
  // Terminal report (ADR item 8): best-effort un-park on EVERY path.
777
- await resolvePark(client, taskId, questionId);
786
+ await resolvePark(client, taskId, questionId, terminal);
778
787
  if (terminal === "answered" && answer) {
779
788
  return {
780
789
  content: [{ type: "text" as const, text: renderAskAnswer(questionId, answer.body) }],
@@ -1,6 +1,5 @@
1
- import { randomUUID } from "node:crypto";
2
1
  import type { PiTeamsConfig } from "../config/config.ts";
3
- import type { ControlReservation, TeamRunManifest } from "../state/types.ts";
2
+ import type { TeamRunManifest } from "../state/types.ts";
4
3
  import { appendTaskAttentionEvent } from "./attention-events.ts";
5
4
  import { upsertCrewAgent } from "./crew-agent-records.ts";
6
5
  import type { CrewAgentRecord } from "./crew-agent-runtime.ts";
@@ -200,16 +199,3 @@ function resetConsecutiveToolFailures(manifest: TeamRunManifest, agent: CrewAgen
200
199
  };
201
200
  upsertCrewAgent(manifest, updated);
202
201
  }
203
-
204
- /**
205
- * Reserve a control channel for a task before spawning its worker.
206
- * This ensures cancel/steer requests can be queued immediately
207
- * while the worker is still starting up.
208
- */
209
- export function reserveControlChannel(taskId: string, runId: string): ControlReservation {
210
- return {
211
- reservedAt: new Date().toISOString(),
212
- controllerId: `ctrl:${taskId}:${randomUUID()}`,
213
- acceptsControlEvents: true,
214
- };
215
- }
@@ -544,6 +544,9 @@ export async function executeBackgroundRun(
544
544
  workflow: wf as import("../workflows/workflow-config.ts").DynamicWorkflowConfig,
545
545
  signal,
546
546
  tokenBudget: wf.maxTokenBudget,
547
+ // SDD-3 W-C G13: workflow-config override for the agent-call cap
548
+ // (unset → ctx default; a run is always bounded).
549
+ maxAgentCalls: wf.maxAgentCalls,
547
550
  });
548
551
  await saveRunManifestAsync(dwfResult.manifest);
549
552
  earlyResult = dwfResult;
@@ -54,12 +54,12 @@ import {
54
54
  isRequestObject,
55
55
  parseMsgSendParams,
56
56
  parseWaitRequestParams,
57
- parseWaitResolveParams,
58
57
  safeStringify,
59
58
  WAIT_REQUEST_TIMEOUT_SEC_DEFAULT,
60
59
  WAIT_REQUEST_TIMEOUT_SEC_MAX,
61
60
  } from "./protocol/request-parsers.ts";
62
61
  import { recordWaitPolicyRejection, waitAuthError } from "./protocol/wait-auth.ts";
62
+ import { handleWaitResolve } from "./protocol/wait-resolve.ts";
63
63
  import { pushWaitingToForegroundWaiter } from "./wait-push.ts";
64
64
  import { WaitStatusCache } from "./wait-status-cache.ts";
65
65
 
@@ -1883,110 +1883,19 @@ export class CrewBroker {
1883
1883
  });
1884
1884
  }
1885
1885
 
1886
- /** WP-2/R2: terminal report of the parked `ask` tool — flips the task
1887
- * waiting→running and clears the park coordination state. Scoped to
1888
- * auth + state transition + ask.answered event ONLY (ADR item 6/8):
1889
- * answer DELIVERY is the mailbox respond path (step 6), not this
1890
- * method. A questionId mismatch (or a task that is not parked) is
1891
- * rejected WITHOUT clearing anything — fail-closed. */
1886
+ /** WP-2/R2/R3: wait.resolve handler lives in ./protocol/wait-resolve.ts
1887
+ * (moved out for the wc-gate M4 §5 — pure move, no behavior change). */
1892
1888
  private async handleWaitResolve(conn: ServerConnection, id: string, params: unknown): Promise<void> {
1893
- if (!conn.runId || !conn.taskId) {
1894
- this.sendError(conn, id, "auth", "not authed");
1895
- return;
1896
- }
1897
- const authErr = waitAuthError(conn);
1898
- if (authErr) {
1899
- this.sendError(conn, id, authErr.code, authErr.message);
1900
- return;
1901
- }
1902
- const parsed = parseWaitResolveParams(params);
1903
- if (!parsed) {
1904
- this.sendError(conn, id, "bad-params", "wait.resolve: invalid params");
1905
- return;
1906
- }
1907
- // Server-side identity enforcement (same rule as wait.request).
1908
- if (parsed.to !== conn.taskId) {
1909
- this.sendError(conn, id, "forbidden", "wait.resolve: 'to' must match the authenticated task");
1910
- return;
1911
- }
1912
- const cwd = this.options.cwd;
1913
- if (!cwd) {
1914
- this.sendError(conn, id, "no-manifest", "broker has no cwd configured");
1915
- return;
1916
- }
1917
- let loaded: NonNullable<ReturnType<typeof loadRunManifestById>>;
1918
- try {
1919
- const l = loadRunManifestById(cwd, conn.runId);
1920
- if (!l) {
1921
- this.sendError(conn, id, "no-manifest", `run '${conn.runId}' not found`);
1922
- return;
1923
- }
1924
- loaded = l;
1925
- } catch (err) {
1926
- this.sendError(conn, id, "no-manifest", (err as Error).message);
1927
- return;
1928
- }
1929
- if (this.options.waitMethodsEnabled !== true) {
1930
- recordWaitPolicyRejection(loaded.manifest, conn.taskId, "wait.resolve");
1931
- this.sendError(
1932
- conn,
1933
- id,
1934
- "policy-disabled",
1935
- "wait.resolve is disabled: broker.waitMethodsEnabled=false (fail-closed; policy.action recorded in events.jsonl)",
1936
- );
1937
- return;
1938
- }
1939
- const runId = conn.runId;
1940
- const taskId = conn.taskId;
1941
- const outcome = withRunLockSync(loaded.manifest, () => {
1942
- const fresh = loadRunManifestById(loaded.manifest.cwd, runId);
1943
- if (!fresh) return { code: "no-manifest" as const, message: `run '${runId}' not found` };
1944
- const task = fresh.tasks.find((t) => t.id === taskId);
1945
- if (!task) return { code: "no-task" as const, message: `task '${taskId}' not found` };
1946
- if (task.status !== "waiting" || task.waiting?.questionId !== parsed.questionId) {
1947
- return {
1948
- code: "bad-params" as const,
1949
- message: `wait.resolve: no parked question '${parsed.questionId}' on task '${taskId}'`,
1950
- };
1951
- }
1952
- const updatedTasks = fresh.tasks.map((t) => (t.id === taskId ? { ...t, status: "running" as const, waiting: undefined } : t));
1953
- // waitState is a single run-level slot: clear it ONLY when it points
1954
- // at this exact question — never clobber another task's newer park.
1955
- const waitState = fresh.manifest.waitState;
1956
- const clearWaitState = waitState !== undefined && waitState.taskId === taskId && waitState.questionId === parsed.questionId;
1957
- const updatedManifest = {
1958
- ...fresh.manifest,
1959
- ...(clearWaitState ? { waitState: undefined } : {}),
1960
- updatedAt: new Date().toISOString(),
1961
- };
1962
- saveRunTasks(updatedManifest, updatedTasks);
1963
- saveRunManifest(updatedManifest);
1964
- return { code: "ok" as const, message: "" };
1965
- });
1966
- if (outcome.code !== "ok") {
1967
- this.sendError(conn, id, outcome.code, outcome.message);
1968
- return;
1969
- }
1970
- const eventsPath = loaded.manifest.eventsPath;
1971
- void appendEventAsync(eventsPath, {
1972
- type: "ask.answered",
1973
- runId,
1974
- taskId,
1975
- message: `Question ${parsed.questionId} answered; task resumed.`,
1976
- data: { questionId: parsed.questionId },
1977
- }).catch((err) =>
1978
- logInternalError("crew-broker.wait.ask-answered-event", err instanceof Error ? err : new Error(String(err)), `runId=${runId}`),
1979
- );
1980
- void appendEventAsync(eventsPath, {
1981
- type: "task.resumed",
1982
- runId,
1983
- taskId,
1984
- message: `Task resumed after ask answer (question ${parsed.questionId}).`,
1985
- data: { questionId: parsed.questionId },
1986
- }).catch((err) =>
1987
- logInternalError("crew-broker.wait.task-resumed-event", err instanceof Error ? err : new Error(String(err)), `runId=${runId}`),
1889
+ await handleWaitResolve(
1890
+ conn,
1891
+ id,
1892
+ params,
1893
+ {
1894
+ sendError: (c, i, code, msg) => this.sendError(c, i, code, msg),
1895
+ sendResult: (c, i, r) => this.sendResult(c, i, r),
1896
+ },
1897
+ { cwd: this.options.cwd, waitMethodsEnabled: this.options.waitMethodsEnabled },
1988
1898
  );
1989
- this.sendResult(conn, id, { ok: true, taskId, questionId: parsed.questionId });
1990
1899
  }
1991
1900
  }
1992
1901
 
@@ -172,6 +172,13 @@ export function parseWaitRequestParams(value: unknown): WaitRequestParams | unde
172
172
  export interface WaitResolveParams {
173
173
  to: string;
174
174
  questionId: string;
175
+ /** G5 (deep-review 2026-10-01): the parked worker's terminal outcome —
176
+ * "answered" = it PICKED UP the leader's mailbox response for this
177
+ * questionId (the only outcome that triggers the broker's
178
+ * task.respond_delivered ack event); "timed-out"/"aborted" resolve the
179
+ * park WITHOUT a delivery claim. Optional + fail-closed: legacy workers
180
+ * that omit it never claim a delivery (no event is emitted). */
181
+ outcome?: "answered" | "timed-out" | "aborted";
175
182
  }
176
183
 
177
184
  export function parseWaitResolveParams(value: unknown): WaitResolveParams | undefined {
@@ -179,5 +186,6 @@ export function parseWaitResolveParams(value: unknown): WaitResolveParams | unde
179
186
  const v = value as Record<string, unknown>;
180
187
  if (typeof v.to !== "string" || v.to.length === 0 || v.to.length > 256) return undefined;
181
188
  if (typeof v.questionId !== "string" || v.questionId.length === 0 || v.questionId.length > 128) return undefined;
182
- return { to: v.to, questionId: v.questionId };
189
+ if (v.outcome !== undefined && v.outcome !== "answered" && v.outcome !== "timed-out" && v.outcome !== "aborted") return undefined;
190
+ return { to: v.to, questionId: v.questionId, ...(v.outcome !== undefined ? { outcome: v.outcome } : {}) };
183
191
  }
@@ -0,0 +1,168 @@
1
+ /**
2
+ * wait-resolve.ts — WP-2/R2 `wait.resolve` broker handler.
3
+ *
4
+ * Moved from crew-broker.ts (wc-gate M4 §5, GH #61 release audit 2026-10-02)
5
+ * — pure move, no behavior change. Terminal report of the parked `ask` tool:
6
+ * flips the task waiting→running and clears the park coordination state.
7
+ * Scoped to auth + state transition + ask.answered/task.resumed events +
8
+ * the G5 respond_delivered ack ONLY (ADR item 6/8): answer DELIVERY is the
9
+ * mailbox respond path (step 6), not this handler. A questionId mismatch
10
+ * (or a task that is not parked) is rejected WITHOUT clearing anything —
11
+ * fail-closed.
12
+ *
13
+ * Top-level function so the class method is a 1-line delegation
14
+ * (same pattern as msg-inbox.ts).
15
+ */
16
+
17
+ import { withRunLockSync } from "../../../state/coordination/locks.ts";
18
+ import { appendEventAsync } from "../../../state/event-log/event-log.ts";
19
+ import { loadRunManifestById, saveRunManifest, saveRunTasks } from "../../../state/stores/state-store.ts";
20
+ import { logInternalError } from "../../../utils/internal-error.ts";
21
+ import type { ServerConnection } from "./connection-state.ts";
22
+ import { parseWaitResolveParams } from "./request-parsers.ts";
23
+ import { recordWaitPolicyRejection } from "./wait-auth.ts";
24
+
25
+ export interface WaitResolveHelpers {
26
+ sendError(conn: ServerConnection, id: string, code: string, message: string): void;
27
+ sendResult(conn: ServerConnection, id: string, result: unknown): void;
28
+ }
29
+
30
+ export interface WaitResolveOptions {
31
+ /** Broker cwd (run state root). Undefined → no-manifest error. */
32
+ cwd?: string;
33
+ /** Fail-closed gate: wait.* methods disabled unless explicitly true. */
34
+ waitMethodsEnabled?: boolean;
35
+ }
36
+
37
+ export async function handleWaitResolve(
38
+ conn: ServerConnection,
39
+ id: string,
40
+ params: unknown,
41
+ helpers: WaitResolveHelpers,
42
+ options: WaitResolveOptions,
43
+ ): Promise<void> {
44
+ if (!conn.runId || !conn.taskId) {
45
+ helpers.sendError(conn, id, "auth", "not authed");
46
+ return;
47
+ }
48
+ const parsed = parseWaitResolveParams(params);
49
+ if (!parsed) {
50
+ helpers.sendError(conn, id, "bad-params", "wait.resolve: invalid params");
51
+ return;
52
+ }
53
+ // Server-side identity enforcement (same rule as wait.request).
54
+ if (parsed.to !== conn.taskId) {
55
+ helpers.sendError(conn, id, "forbidden", "wait.resolve: 'to' must match the authenticated task");
56
+ return;
57
+ }
58
+ if (!options.cwd) {
59
+ helpers.sendError(conn, id, "no-manifest", "broker has no cwd configured");
60
+ return;
61
+ }
62
+ let loaded: NonNullable<ReturnType<typeof loadRunManifestById>>;
63
+ try {
64
+ const l = loadRunManifestById(options.cwd, conn.runId);
65
+ if (!l) {
66
+ helpers.sendError(conn, id, "no-manifest", `run '${conn.runId}' not found`);
67
+ return;
68
+ }
69
+ loaded = l;
70
+ } catch (err) {
71
+ helpers.sendError(conn, id, "no-manifest", (err as Error).message);
72
+ return;
73
+ }
74
+ if (options.waitMethodsEnabled !== true) {
75
+ recordWaitPolicyRejection(loaded.manifest, conn.taskId, "wait.resolve");
76
+ helpers.sendError(
77
+ conn,
78
+ id,
79
+ "policy-disabled",
80
+ "wait.resolve is disabled: broker.waitMethodsEnabled=false (fail-closed; policy.action recorded in events.jsonl)",
81
+ );
82
+ return;
83
+ }
84
+ const runId = conn.runId;
85
+ const taskId = conn.taskId;
86
+ const outcome = withRunLockSync(loaded.manifest, () => {
87
+ const fresh = loadRunManifestById(loaded.manifest.cwd, runId);
88
+ if (!fresh) return { code: "no-manifest" as const, message: `run '${runId}' not found` };
89
+ const task = fresh.tasks.find((t) => t.id === taskId);
90
+ if (!task) return { code: "no-task" as const, message: `task '${taskId}' not found` };
91
+ if (task.status !== "waiting" || task.waiting?.questionId !== parsed.questionId) {
92
+ return {
93
+ code: "bad-params" as const,
94
+ message: `wait.resolve: no parked question '${parsed.questionId}' on task '${taskId}'`,
95
+ };
96
+ }
97
+ const updatedTasks = fresh.tasks.map((t) => (t.id === taskId ? { ...t, status: "running" as const, waiting: undefined } : t));
98
+ // G5 (deep-review 2026-10-01): capture the liveness pid (heartbeat-first,
99
+ // checkpoint-fallback — the same source stale-reconciler uses) before
100
+ // the flip, so respond_delivered records the same worker-pid evidence
101
+ // its respond_missed twin does root-side.
102
+ const workerPid = task.heartbeat?.pid ?? task.checkpoint?.childPid;
103
+ // waitState is a single run-level slot: clear it ONLY when it points
104
+ // at this exact question — never clobber another task's newer park.
105
+ const waitState = fresh.manifest.waitState;
106
+ const clearWaitState = waitState !== undefined && waitState.taskId === taskId && waitState.questionId === parsed.questionId;
107
+ const updatedManifest = {
108
+ ...fresh.manifest,
109
+ ...(clearWaitState ? { waitState: undefined } : {}),
110
+ updatedAt: new Date().toISOString(),
111
+ };
112
+ saveRunTasks(updatedManifest, updatedTasks);
113
+ saveRunManifest(updatedManifest);
114
+ return { code: "ok" as const, message: "", workerPid };
115
+ });
116
+ if (outcome.code !== "ok") {
117
+ helpers.sendError(conn, id, outcome.code, outcome.message);
118
+ return;
119
+ }
120
+ const eventsPath = loaded.manifest.eventsPath;
121
+ void appendEventAsync(eventsPath, {
122
+ type: "ask.answered",
123
+ runId,
124
+ taskId,
125
+ message: `Question ${parsed.questionId} answered; task resumed.`,
126
+ data: { questionId: parsed.questionId },
127
+ }).catch((err) =>
128
+ logInternalError("crew-broker.wait.ask-answered-event", err instanceof Error ? err : new Error(String(err)), `runId=${runId}`),
129
+ );
130
+ void appendEventAsync(eventsPath, {
131
+ type: "task.resumed",
132
+ runId,
133
+ taskId,
134
+ message: `Task resumed after ask answer (question ${parsed.questionId}).`,
135
+ data: { questionId: parsed.questionId },
136
+ }).catch((err) =>
137
+ logInternalError("crew-broker.wait.task-resumed-event", err instanceof Error ? err : new Error(String(err)), `runId=${runId}`),
138
+ );
139
+ // G5 (deep-review 2026-10-01): respond-delivery ack. wait.resolve fires on
140
+ // EVERY terminal path (answered/timed-out/aborted) — only the worker-claimed
141
+ // outcome "answered" is a DELIVERY: the parked ask tool found the
142
+ // questionId-tagged mailbox response and is resolving the park WITH the
143
+ // answer in hand (its findAskResponse exact-equality match). A
144
+ // timed-out/aborted resolve — or a legacy worker that sends no outcome —
145
+ // never claims a delivery: fail-closed, no event. This ack is the leader's
146
+ // only confirmation that a mailbox respond reached a live worker instead
147
+ // of falling into the void; the root-side respond_missed twin covers the
148
+ // dead-worker branch at write time (extension/team-tool/respond.ts).
149
+ if (parsed.outcome === "answered") {
150
+ void appendEventAsync(eventsPath, {
151
+ type: "task.respond_delivered",
152
+ runId,
153
+ taskId,
154
+ message: `Leader respond delivered: worker acknowledged the mailbox response for question ${parsed.questionId}.`,
155
+ data: {
156
+ questionId: parsed.questionId,
157
+ ...(outcome.workerPid !== undefined ? { workerPid: outcome.workerPid } : {}),
158
+ },
159
+ }).catch((err) =>
160
+ logInternalError(
161
+ "crew-broker.wait.respond-delivered-event",
162
+ err instanceof Error ? err : new Error(String(err)),
163
+ `runId=${runId}`,
164
+ ),
165
+ );
166
+ }
167
+ helpers.sendResult(conn, id, { ok: true, taskId, questionId: parsed.questionId });
168
+ }
@@ -357,10 +357,11 @@ export function prepareSpawnContext(
357
357
  // F4/S-1: the RAW (unredacted) snapshot must NEVER land in artifactsRoot —
358
358
  // point it at a temp dir; the worker reads it then writeArtifact()
359
359
  // (redact+atomic) is the ONLY writer into artifactsRoot.
360
- // R3-1: built.tempDir is only created by buildPiWorkerArgs when the agent
361
- // has a systemPrompt OR the task exceeds TASK_ARG_LIMIT — guard against
362
- // undefined (resolveRealContainedPath(undefined) would TypeError and crash
363
- // spawn). createSafeTempDir auto-tracks the dir for cleanupAllTrackedTempDirs.
360
+ // R3-1: since G3 (spill-always) buildPiWorkerArgs creates a tempDir for
361
+ // EVERY spawn (task.md) — the `??` guard stays as pure defense against
362
+ // future call shapes (resolveRealContainedPath(undefined) would
363
+ // TypeError and crash spawn). createSafeTempDir auto-tracks the dir for
364
+ // cleanupAllTrackedTempDirs.
364
365
  const scratchTempDir = built.tempDir ?? createSafeTempDir(getPiTempBase(), "pi-crew-scratchpad-");
365
366
  built.env.PI_CREW_SCRATCHPAD_SNAPSHOT = resolveRealContainedPath(scratchTempDir, `${input.agentId}.snapshot.json`);
366
367
  // Phase 2 crash-resume (D1/D1b/D2): locate the latest snapshot artifact of a
@@ -625,6 +625,8 @@ export async function dispatchBatch(ctx: SchedulerContext, decision: DispatchBat
625
625
  const { workflow, input, runtimeKind, runController } = ctx;
626
626
 
627
627
  // 2.2 caller migration: batch progress is high-frequency informational (M7 wire).
628
+ // .catch REQUIRED — buffered-flush rejections reject queued promises (see
629
+ // child-executor.ts:530 note, CI 2026-10-03).
628
630
  void appendEventBuffered(ctx.manifest.eventsPath, {
629
631
  type: "task.progress",
630
632
  runId: ctx.manifest.runId,
@@ -640,7 +642,7 @@ export async function dispatchBatch(ctx: SchedulerContext, decision: DispatchBat
640
642
  defaultConcurrency: concurrency.defaultConcurrency,
641
643
  concurrencyReason: approvalPending ? `${concurrency.reason};plan-approval-read-only` : concurrency.reason,
642
644
  },
643
- });
645
+ }).catch((error) => logInternalError("dispatch-batch.ready-batch-progress", error, `runId=${ctx.manifest.runId}`));
644
646
  // Execute before_task_start hooks for the batch — P1-10: run hooks in
645
647
  // parallel (each may be a subprocess), then apply skip mutations in order.
646
648
  const beforeTaskStartReports = await Promise.all(