taskplane 0.30.4 → 0.30.5

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.
@@ -0,0 +1,158 @@
1
+ /**
2
+ * In-flight tool_use/tool_result ordering repair — issue #621 (defense in depth).
3
+ *
4
+ * The supervisor injects `custom` display messages via pi.sendMessage(). Any
5
+ * such injection that lands while the interactive agent has a tool call in
6
+ * flight splices a message BETWEEN an assistant `tool_use` and its
7
+ * `toolResult`. Anthropic then rejects the request:
8
+ *
9
+ * 400 messages.N.content.M: unexpected `tool_use_id` found in `tool_result`
10
+ * blocks ... Each `tool_result` block must have a corresponding `tool_use`
11
+ * block in the previous message.
12
+ *
13
+ * The batch-end epilogue gate (supervisor-dispatch.ts) prevents the most common
14
+ * source, but the supervisor has many other background `pi.sendMessage(...,
15
+ * {triggerTurn:false})` sites (integration progress/result, heartbeat, routing)
16
+ * that can splice the same way. Rather than gate each one, this module repairs
17
+ * the ORDERING of the outgoing message array on the pi `context` event, which
18
+ * fires before every provider request (`transformContext`, on the pi-internal
19
+ * AgentMessage[] before convertToLlm). Each assistant's tool results are pulled
20
+ * to immediately follow it (in tool-call order); any spliced-in `custom`/`user`
21
+ * messages move to after the tool-result group. The request is therefore always
22
+ * valid regardless of where a stray message was appended, and a mistimed
23
+ * injection can never wedge the session.
24
+ *
25
+ * This does not mutate the persisted session tree — it only transforms the
26
+ * per-request context — so it is safe, idempotent, and self-correcting across
27
+ * reloads.
28
+ */
29
+
30
+ interface ToolCallBlock {
31
+ type: string;
32
+ id?: string;
33
+ [key: string]: unknown;
34
+ }
35
+ interface AgentMessageLike {
36
+ role?: string;
37
+ content?: unknown;
38
+ toolCallId?: string;
39
+ [key: string]: unknown;
40
+ }
41
+
42
+ function toolUseIds(msg: AgentMessageLike): string[] {
43
+ if (!msg || msg.role !== "assistant" || !Array.isArray(msg.content)) return [];
44
+ const ids: string[] = [];
45
+ for (const block of msg.content as ToolCallBlock[]) {
46
+ if (
47
+ block &&
48
+ typeof block === "object" &&
49
+ block.type === "toolCall" &&
50
+ typeof block.id === "string"
51
+ ) {
52
+ ids.push(block.id);
53
+ }
54
+ }
55
+ return ids;
56
+ }
57
+
58
+ /**
59
+ * Reorder `messages` so every assistant `tool_use` is immediately followed by
60
+ * its matching `toolResult`(s), relocating any spliced-in non-tool messages to
61
+ * after the tool-result group.
62
+ *
63
+ * Robustness (Sage #621 review):
64
+ * - Duplicate `toolResult` messages sharing a `toolCallId` are all preserved
65
+ * (queue-based grouping, not last-wins), so repair never drops data.
66
+ * - Emitted results are tracked by message identity, not by id.
67
+ * - Also repairs the result-before-assistant shape: a `toolResult` whose owning
68
+ * assistant appears later is held and pulled forward at the owner.
69
+ * - A final safety-net pass appends any never-emitted result, guaranteeing no
70
+ * `toolResult` is ever lost regardless of input malformation.
71
+ *
72
+ * Returns the SAME array reference when already well-formed (no reordering
73
+ * needed), so callers can cheaply detect a no-op. Otherwise returns a new,
74
+ * reordered array. Pure: never mutates the input array or its elements.
75
+ */
76
+ export function repairToolResultOrdering<T extends AgentMessageLike>(messages: T[]): T[] {
77
+ if (!Array.isArray(messages) || messages.length < 3) return messages;
78
+
79
+ // Collect ALL toolResult messages per toolCallId, preserving original order.
80
+ // A queue (array) rather than last-wins so duplicate results for the same id
81
+ // are never dropped (Sage #621 review: last-wins could silently lose data).
82
+ const resultsById = new Map<string, T[]>();
83
+ for (const m of messages) {
84
+ if (m && m.role === "toolResult" && typeof m.toolCallId === "string") {
85
+ const list = resultsById.get(m.toolCallId);
86
+ if (list) list.push(m);
87
+ else resultsById.set(m.toolCallId, [m]);
88
+ }
89
+ }
90
+ if (resultsById.size === 0) return messages;
91
+
92
+ // First-occurrence index of the assistant that owns each toolCallId. Lets us
93
+ // HOLD an in-place toolResult whose owning assistant appears LATER, repairing
94
+ // the result-before-assistant shape (Sage #621 review) instead of emitting it
95
+ // in a position that would still be invalid.
96
+ const ownerIndexById = new Map<string, number>();
97
+ for (let i = 0; i < messages.length; i++) {
98
+ for (const id of toolUseIds(messages[i])) {
99
+ if (!ownerIndexById.has(id)) ownerIndexById.set(id, i);
100
+ }
101
+ }
102
+
103
+ const out: T[] = [];
104
+ // Track emitted results by message IDENTITY, not by id, so duplicate result
105
+ // messages sharing a toolCallId are each accounted for individually.
106
+ const emitted = new Set<T>();
107
+
108
+ for (let i = 0; i < messages.length; i++) {
109
+ const m = messages[i];
110
+ if (m && m.role === "toolResult" && typeof m.toolCallId === "string") {
111
+ if (emitted.has(m)) continue; // already pulled forward next to its assistant
112
+ const owner = ownerIndexById.get(m.toolCallId);
113
+ // Owner appears later → hold; it will be pulled forward at the owner.
114
+ // Owner earlier (normal splice case) or orphan (no owner) → emit in place.
115
+ if (owner !== undefined && owner > i) continue;
116
+ out.push(m);
117
+ emitted.add(m);
118
+ continue;
119
+ }
120
+
121
+ out.push(m);
122
+
123
+ // Pull every matching toolResult (all of them, in original order) to
124
+ // immediately follow this assistant, in tool-call order.
125
+ for (const id of toolUseIds(m)) {
126
+ const list = resultsById.get(id);
127
+ if (!list) continue; // genuinely unanswered tool_use — not repairable here
128
+ for (const result of list) {
129
+ if (emitted.has(result)) continue;
130
+ out.push(result);
131
+ emitted.add(result);
132
+ }
133
+ }
134
+ }
135
+
136
+ // Safety net: guarantee no toolResult is ever dropped. Any result not emitted
137
+ // above (only reachable via a held-but-never-pulled edge case) is appended in
138
+ // original order. Guarded by identity so it can never double-emit.
139
+ for (const m of messages) {
140
+ if (m && m.role === "toolResult" && typeof m.toolCallId === "string" && !emitted.has(m)) {
141
+ out.push(m);
142
+ emitted.add(m);
143
+ }
144
+ }
145
+
146
+ // Return the original reference when nothing moved (cheap no-op detection).
147
+ if (out.length === messages.length) {
148
+ let identical = true;
149
+ for (let i = 0; i < out.length; i++) {
150
+ if (out[i] !== messages[i]) {
151
+ identical = false;
152
+ break;
153
+ }
154
+ }
155
+ if (identical) return messages;
156
+ }
157
+ return out;
158
+ }
@@ -107,6 +107,7 @@ import {
107
107
  activateSupervisor,
108
108
  deactivateSupervisor,
109
109
  transitionToRoutingMode,
110
+ stopBatchMonitoring,
110
111
  freshSupervisorState,
111
112
  registerSupervisorPromptHook,
112
113
  checkSupervisorLockOnStartup,
@@ -118,6 +119,8 @@ import {
118
119
  presentBatchSummary,
119
120
  resolveModelFromString,
120
121
  } from "./supervisor.ts";
122
+ import { SupervisorNoticeGate } from "./supervisor-dispatch.ts";
123
+ import { repairToolResultOrdering } from "./context-repair.ts";
121
124
  import type {
122
125
  SupervisorConfig,
123
126
  SupervisorRoutingContext,
@@ -1834,6 +1837,123 @@ export default function (pi: ExtensionAPI) {
1834
1837
  let supervisorState = freshSupervisorState();
1835
1838
  let supervisorConfig: SupervisorConfig = { ...DEFAULT_SUPERVISOR_CONFIG };
1836
1839
 
1840
+ // ── #621: Batch-end epilogue gate ────────────────────────────────
1841
+ // The batch-end epilogue appends display banners via
1842
+ // pi.sendMessage(..., {triggerTurn:false}), which immediately splices a
1843
+ // custom entry into the session tree. If the interactive agent has a tool
1844
+ // call in flight, that splice lands between an assistant `tool_use` and its
1845
+ // `tool_result` and produces an Anthropic 400 that wedges the session. The
1846
+ // gate runs the epilogue immediately when idle, else defers it to the next
1847
+ // `agent_settled` boundary. `batchGeneration` tags deferred work so a newer
1848
+ // batch invalidates a stale pending epilogue.
1849
+ const noticeGate = new SupervisorNoticeGate();
1850
+ let batchGeneration = 0;
1851
+
1852
+ // #621: Both /orch (doOrchStart) and /orch-resume (doOrchResume) must, on
1853
+ // (re)start, supersede any batch-end epilogue still deferred from a previous
1854
+ // batch: bump the generation (so a later agent_settled no longer matches the
1855
+ // stale pending work) AND drop the pending closure. Extracted into one helper
1856
+ // so the two entry points cannot drift apart again (the original /orch-resume
1857
+ // gap was exactly this drift). Call immediately after freshOrchBatchState().
1858
+ function supersedeDeferredEpilogue(): void {
1859
+ batchGeneration++;
1860
+ noticeGate.invalidate();
1861
+ }
1862
+
1863
+ // #621: The batch-end epilogue, shared by /orch (doOrchStart) and
1864
+ // /orch-resume (doOrchResume). Appends the batch-summary / integration-skipped
1865
+ // banners and transitions the supervisor to routing mode. Both entry points
1866
+ // historically inlined identical logic differing only in `repoRoot` vs
1867
+ // `execCtx!.repoRoot` — the same value, since doOrchStart destructures
1868
+ // `const { repoRoot } = execCtx`. Deferring the WHOLE epilogue (rather than
1869
+ // individual sends) also protects the completed->triggerSupervisorIntegration
1870
+ // branch, whose progress/result messages are the same splice hazard.
1871
+ function runSupervisorBatchEndEpilogue(): void {
1872
+ const mode = orchConfig.orchestrator.integration;
1873
+ const opId = resolveOperatorId(orchConfig);
1874
+ const sDeps: SummaryDeps = {
1875
+ opId,
1876
+ diagnostics: orchBatchState.diagnostics ?? null,
1877
+ mergeResults: (orchBatchState.mergeResults || []).map((mr) => ({
1878
+ waveIndex: mr.waveIndex,
1879
+ status: mr.status,
1880
+ failedLane: mr.failedLane,
1881
+ failureReason: mr.failureReason,
1882
+ })),
1883
+ };
1884
+ if (orchBatchState.phase === "completed" && (mode === "supervised" || mode === "auto")) {
1885
+ triggerSupervisorIntegration(
1886
+ pi,
1887
+ supervisorState,
1888
+ orchBatchState,
1889
+ mode,
1890
+ execCtx!.repoRoot,
1891
+ buildIntegrationExecutor(execCtx!.repoRoot, opId, execCtx!.workspaceRoot),
1892
+ buildCiDeps(execCtx!.repoRoot, execCtx!.workspaceRoot),
1893
+ sDeps,
1894
+ );
1895
+ return;
1896
+ }
1897
+ if ((mode === "supervised" || mode === "auto") && orchBatchState.phase !== "completed") {
1898
+ pi.sendMessage(
1899
+ {
1900
+ customType: "supervisor-integration-skipped",
1901
+ content: [
1902
+ {
1903
+ type: "text",
1904
+ text:
1905
+ `📋 **Batch ended** (phase: ${orchBatchState.phase}). ` +
1906
+ `Integration skipped — only completed batches are eligible.\n` +
1907
+ `Use \`/orch-resume\` to continue or \`/orch-integrate\` manually after resolving issues.`,
1908
+ },
1909
+ ],
1910
+ display: `Integration skipped — batch ${orchBatchState.phase}`,
1911
+ },
1912
+ { triggerTurn: false },
1913
+ );
1914
+ }
1915
+ presentBatchSummary(
1916
+ pi,
1917
+ orchBatchState,
1918
+ execCtx!.workspaceRoot,
1919
+ opId,
1920
+ orchBatchState.diagnostics,
1921
+ sDeps.mergeResults,
1922
+ );
1923
+ const postBatchContext: SupervisorRoutingContext =
1924
+ orchBatchState.phase === "completed"
1925
+ ? {
1926
+ routingState: "completed-batch",
1927
+ contextMessage:
1928
+ `Batch **${orchBatchState.batchId}** completed — ` +
1929
+ `${orchBatchState.succeededTasks}/${orchBatchState.totalTasks} tasks succeeded.\n\n` +
1930
+ `The orch branch \`${orchBatchState.orchBranch}\` is ready to integrate.\n` +
1931
+ `Would you like me to integrate it, or would you prefer to review first?\n\n` +
1932
+ `You can also:\n` +
1933
+ `• Run \`/orch-integrate\` (or \`/orch-integrate --pr\`) to integrate\n` +
1934
+ `• Create new tasks for the next batch\n` +
1935
+ `• Run a health check`,
1936
+ }
1937
+ : {
1938
+ routingState: "no-tasks",
1939
+ contextMessage:
1940
+ `Batch **${orchBatchState.batchId}** ended (${orchBatchState.phase}).\n\n` +
1941
+ `${orchBatchState.succeededTasks} succeeded, ${orchBatchState.failedTasks} failed, ` +
1942
+ `${orchBatchState.skippedTasks} skipped.\n\n` +
1943
+ `What would you like to do next?`,
1944
+ };
1945
+ transitionToRoutingMode(pi, supervisorState, postBatchContext);
1946
+ }
1947
+
1948
+ // #621: Route the batch-end epilogue through the idle gate. When the agent
1949
+ // has a tool call in flight, eagerly stop batch monitoring (so a heartbeat
1950
+ // timer send can't splice either) and defer the epilogue to the next settle.
1951
+ function dispatchBatchEndEpilogue(ctx: ExtensionContext): void {
1952
+ const idle = ctx.isIdle();
1953
+ if (!idle) stopBatchMonitoring(supervisorState);
1954
+ noticeGate.runOrDefer(idle, batchGeneration, runSupervisorBatchEndEpilogue);
1955
+ }
1956
+
1837
1957
  // TP-187 (#538): Zombie-alert filter state
1838
1958
  // Lane numbers and agent IDs that have reached a terminal state (no-progress
1839
1959
  // kill, hard-fail, or supervisor-takeover). Supervisor-alert IPC messages
@@ -2377,6 +2497,10 @@ export default function (pi: ExtensionAPI) {
2377
2497
  orchBatchState = freshOrchBatchState();
2378
2498
  latestMonitorState = null;
2379
2499
 
2500
+ // #621: a new batch supersedes any epilogue still deferred from the
2501
+ // previous batch. Bump the generation and drop the stale pending work.
2502
+ supersedeDeferredEpilogue();
2503
+
2380
2504
  // TP-187 (#538): Clear zombie-alert filter for the new batch.
2381
2505
  clearTerminationFilter("new_batch_started");
2382
2506
 
@@ -2424,80 +2548,7 @@ export default function (pi: ExtensionAPI) {
2424
2548
  if (changed) updateOrchWidget();
2425
2549
  },
2426
2550
  () => {
2427
- const mode = orchConfig.orchestrator.integration;
2428
- const opId = resolveOperatorId(orchConfig);
2429
- const sDeps: SummaryDeps = {
2430
- opId,
2431
- diagnostics: orchBatchState.diagnostics ?? null,
2432
- mergeResults: (orchBatchState.mergeResults || []).map((mr) => ({
2433
- waveIndex: mr.waveIndex,
2434
- status: mr.status,
2435
- failedLane: mr.failedLane,
2436
- failureReason: mr.failureReason,
2437
- })),
2438
- };
2439
- if (orchBatchState.phase === "completed" && (mode === "supervised" || mode === "auto")) {
2440
- triggerSupervisorIntegration(
2441
- pi,
2442
- supervisorState,
2443
- orchBatchState,
2444
- mode,
2445
- repoRoot,
2446
- buildIntegrationExecutor(repoRoot, opId, execCtx!.workspaceRoot),
2447
- buildCiDeps(repoRoot, execCtx!.workspaceRoot),
2448
- sDeps,
2449
- );
2450
- return;
2451
- }
2452
- if ((mode === "supervised" || mode === "auto") && orchBatchState.phase !== "completed") {
2453
- pi.sendMessage(
2454
- {
2455
- customType: "supervisor-integration-skipped",
2456
- content: [
2457
- {
2458
- type: "text",
2459
- text:
2460
- `📋 **Batch ended** (phase: ${orchBatchState.phase}). ` +
2461
- `Integration skipped — only completed batches are eligible.\n` +
2462
- `Use \`/orch-resume\` to continue or \`/orch-integrate\` manually after resolving issues.`,
2463
- },
2464
- ],
2465
- display: `Integration skipped — batch ${orchBatchState.phase}`,
2466
- },
2467
- { triggerTurn: false },
2468
- );
2469
- }
2470
- presentBatchSummary(
2471
- pi,
2472
- orchBatchState,
2473
- execCtx!.workspaceRoot,
2474
- opId,
2475
- orchBatchState.diagnostics,
2476
- sDeps.mergeResults,
2477
- );
2478
- const postBatchContext: SupervisorRoutingContext =
2479
- orchBatchState.phase === "completed"
2480
- ? {
2481
- routingState: "completed-batch",
2482
- contextMessage:
2483
- `Batch **${orchBatchState.batchId}** completed — ` +
2484
- `${orchBatchState.succeededTasks}/${orchBatchState.totalTasks} tasks succeeded.\n\n` +
2485
- `The orch branch \`${orchBatchState.orchBranch}\` is ready to integrate.\n` +
2486
- `Would you like me to integrate it, or would you prefer to review first?\n\n` +
2487
- `You can also:\n` +
2488
- `• Run \`/orch-integrate\` (or \`/orch-integrate --pr\`) to integrate\n` +
2489
- `• Create new tasks for the next batch\n` +
2490
- `• Run a health check`,
2491
- }
2492
- : {
2493
- routingState: "no-tasks",
2494
- contextMessage:
2495
- `Batch **${orchBatchState.batchId}** ended (${orchBatchState.phase}).\n\n` +
2496
- `${orchBatchState.succeededTasks} succeeded, ${orchBatchState.failedTasks} failed, ` +
2497
- `${orchBatchState.skippedTasks} skipped.\n\n` +
2498
- `What would you like to do next?`,
2499
- };
2500
- transitionToRoutingMode(pi, supervisorState, postBatchContext);
2551
+ dispatchBatchEndEpilogue(ctx);
2501
2552
  },
2502
2553
  // ── TP-076: Supervisor alert handler — injects alerts as user messages ──
2503
2554
  (alert) => {
@@ -2812,6 +2863,14 @@ export default function (pi: ExtensionAPI) {
2812
2863
  orchBatchState = freshOrchBatchState();
2813
2864
  latestMonitorState = null;
2814
2865
 
2866
+ // #621: a resume supersedes any epilogue still deferred from the previous
2867
+ // batch, exactly as doOrchStart does. Without this, an epilogue deferred
2868
+ // mid-tool by the prior batch keeps the same batchGeneration; if the user
2869
+ // resumes before `agent_settled` flushes it, onSettled() sees a matching
2870
+ // generation and fires the stale epilogue against the resumed batch
2871
+ // (wrong/duplicate banner). Shared helper mirrors doOrchStart exactly.
2872
+ supersedeDeferredEpilogue();
2873
+
2815
2874
  // TP-187 (#538): Clear zombie-alert filter so post-resume alerts pass through.
2816
2875
  clearTerminationFilter("orch_resume_called");
2817
2876
 
@@ -2844,80 +2903,7 @@ export default function (pi: ExtensionAPI) {
2844
2903
  updateOrchWidget();
2845
2904
  },
2846
2905
  () => {
2847
- const mode = orchConfig.orchestrator.integration;
2848
- const opId = resolveOperatorId(orchConfig);
2849
- const sDeps: SummaryDeps = {
2850
- opId,
2851
- diagnostics: orchBatchState.diagnostics ?? null,
2852
- mergeResults: (orchBatchState.mergeResults || []).map((mr) => ({
2853
- waveIndex: mr.waveIndex,
2854
- status: mr.status,
2855
- failedLane: mr.failedLane,
2856
- failureReason: mr.failureReason,
2857
- })),
2858
- };
2859
- if (orchBatchState.phase === "completed" && (mode === "supervised" || mode === "auto")) {
2860
- triggerSupervisorIntegration(
2861
- pi,
2862
- supervisorState,
2863
- orchBatchState,
2864
- mode,
2865
- execCtx!.repoRoot,
2866
- buildIntegrationExecutor(execCtx!.repoRoot, opId, execCtx!.workspaceRoot),
2867
- buildCiDeps(execCtx!.repoRoot, execCtx!.workspaceRoot),
2868
- sDeps,
2869
- );
2870
- return;
2871
- }
2872
- if ((mode === "supervised" || mode === "auto") && orchBatchState.phase !== "completed") {
2873
- pi.sendMessage(
2874
- {
2875
- customType: "supervisor-integration-skipped",
2876
- content: [
2877
- {
2878
- type: "text",
2879
- text:
2880
- `📋 **Batch ended** (phase: ${orchBatchState.phase}). ` +
2881
- `Integration skipped — only completed batches are eligible.\n` +
2882
- `Use \`/orch-resume\` to continue or \`/orch-integrate\` manually after resolving issues.`,
2883
- },
2884
- ],
2885
- display: `Integration skipped — batch ${orchBatchState.phase}`,
2886
- },
2887
- { triggerTurn: false },
2888
- );
2889
- }
2890
- presentBatchSummary(
2891
- pi,
2892
- orchBatchState,
2893
- execCtx!.workspaceRoot,
2894
- opId,
2895
- orchBatchState.diagnostics,
2896
- sDeps.mergeResults,
2897
- );
2898
- const postBatchContext: SupervisorRoutingContext =
2899
- orchBatchState.phase === "completed"
2900
- ? {
2901
- routingState: "completed-batch",
2902
- contextMessage:
2903
- `Batch **${orchBatchState.batchId}** completed — ` +
2904
- `${orchBatchState.succeededTasks}/${orchBatchState.totalTasks} tasks succeeded.\n\n` +
2905
- `The orch branch \`${orchBatchState.orchBranch}\` is ready to integrate.\n` +
2906
- `Would you like me to integrate it, or would you prefer to review first?\n\n` +
2907
- `You can also:\n` +
2908
- `• Run \`/orch-integrate\` (or \`/orch-integrate --pr\`) to integrate\n` +
2909
- `• Create new tasks for the next batch\n` +
2910
- `• Run a health check`,
2911
- }
2912
- : {
2913
- routingState: "no-tasks",
2914
- contextMessage:
2915
- `Batch **${orchBatchState.batchId}** ended (${orchBatchState.phase}).\n\n` +
2916
- `${orchBatchState.succeededTasks} succeeded, ${orchBatchState.failedTasks} failed, ` +
2917
- `${orchBatchState.skippedTasks} skipped.\n\n` +
2918
- `What would you like to do next?`,
2919
- };
2920
- transitionToRoutingMode(pi, supervisorState, postBatchContext);
2906
+ dispatchBatchEndEpilogue(ctx);
2921
2907
  },
2922
2908
  // ── TP-076: Supervisor alert handler — injects alerts as user messages ──
2923
2909
  (alert) => {
@@ -5611,6 +5597,33 @@ export default function (pi: ExtensionAPI) {
5611
5597
 
5612
5598
  // ── Session Lifecycle ────────────────────────────────────────────
5613
5599
 
5600
+ // #621 (defense in depth): repair tool_use/tool_result ordering on every
5601
+ // outgoing request. The `context` event fires before each provider call on
5602
+ // the pi-internal AgentMessage[] (before convertToLlm). Any supervisor
5603
+ // `custom` message that was spliced between an assistant tool_use and its
5604
+ // tool_result (from ANY send site — batch summary, integration progress/
5605
+ // result, heartbeat, routing) is moved back after the tool-result group, so
5606
+ // the request is always valid and a mistimed injection can never wedge the
5607
+ // session. Only transforms the per-request context; the persisted tree is
5608
+ // untouched (self-correcting across reloads).
5609
+ pi.on("context", (event: { messages: unknown[] }) => {
5610
+ const repaired = repairToolResultOrdering(event.messages as Array<Record<string, unknown>>);
5611
+ if (repaired !== event.messages) return { messages: repaired };
5612
+ });
5613
+
5614
+ // #621: Flush a deferred batch-end epilogue once the interactive agent has
5615
+ // fully settled (all tool_results appended). Re-check idleness here because a
5616
+ // prior settle handler may have started another run.
5617
+ pi.on("agent_settled", (_event: unknown, ctx: ExtensionContext) => {
5618
+ noticeGate.onSettled(ctx.isIdle(), batchGeneration);
5619
+ });
5620
+
5621
+ // #621: Drop any deferred epilogue and disable the gate on shutdown so a
5622
+ // stale closure cannot fire against a replaced session.
5623
+ pi.on("session_shutdown", () => {
5624
+ noticeGate.dispose();
5625
+ });
5626
+
5614
5627
  pi.on("session_start", async (_event, ctx) => {
5615
5628
  // Store widget context for dashboard updates (needed even if startup fails)
5616
5629
  orchWidgetCtx = ctx;
@@ -0,0 +1,103 @@
1
+ /**
2
+ * Batch-end epilogue gate — issue #621.
3
+ *
4
+ * PROBLEM
5
+ * -------
6
+ * The supervisor's batch-end epilogue appends display banners to the session via
7
+ * `pi.sendMessage(msg, { triggerTurn: false })`. In pi's `sendCustomMessage`,
8
+ * `{ triggerTurn: false }` always takes the branch that IMMEDIATELY appends a
9
+ * `custom` entry to the session tree at the current leaf — even while the
10
+ * interactive agent is streaming. If the agent has a tool call in flight (an
11
+ * assistant `tool_use` has been appended but its `tool_result` has not yet
12
+ * landed), that append splices a user-role `custom` message BETWEEN the
13
+ * `tool_use` and its `tool_result`. On the next request Anthropic rejects the
14
+ * conversation with a 400 ("`tool_result` ... must have a corresponding
15
+ * `tool_use` block in the previous message"), permanently wedging the session.
16
+ *
17
+ * FIX
18
+ * ---
19
+ * Run the epilogue immediately when the agent is idle (the leaf is a terminal
20
+ * message, so an append is safe and the banner renders now). Otherwise defer it
21
+ * to the next `agent_settled` boundary — the first lifecycle point at which all
22
+ * tool results, retries, compaction, and queued continuations have finished, so
23
+ * an append can no longer split a `tool_use`/`tool_result` pair.
24
+ *
25
+ * Notes:
26
+ * - `deliverAs: "nextTurn"` is NOT usable here: it pushes into the next turn's
27
+ * in-memory context only, without persisting the entry or emitting
28
+ * message_start/end, so display banners would be silently dropped.
29
+ * - The gate is session-scoped (constructed inside the extension factory), holds
30
+ * only a plain closure + a generation tag (no captured `ctx`), and is
31
+ * invalidated by a newer batch or by session shutdown.
32
+ */
33
+
34
+ /**
35
+ * Gates the supervisor batch-end epilogue on interactive-agent idleness so its
36
+ * display banners can never be appended between a `tool_use` and its
37
+ * `tool_result` (#621).
38
+ */
39
+ export class SupervisorNoticeGate {
40
+ private pending: (() => void) | null = null;
41
+ private pendingGeneration = -1;
42
+ private active = true;
43
+
44
+ /**
45
+ * Run `epilogue` now when `idle`, otherwise defer it to the next settle.
46
+ *
47
+ * @param idle Current `ctx.isIdle()` at the batch-end callback.
48
+ * @param generation Monotonic batch counter; tags the deferred work so a
49
+ * newer batch can invalidate a stale pending epilogue.
50
+ * @param epilogue Side-effecting closure that performs the batch-end sends
51
+ * (integration-skipped banner, batch summary, routing
52
+ * transition). Must be safe to run at a settle boundary.
53
+ */
54
+ runOrDefer(idle: boolean, generation: number, epilogue: () => void): void {
55
+ if (!this.active) return;
56
+ if (idle) {
57
+ epilogue();
58
+ return;
59
+ }
60
+ // Coalesce: keep only the most recent epilogue for the latest generation.
61
+ this.pending = epilogue;
62
+ this.pendingGeneration = generation;
63
+ }
64
+
65
+ /**
66
+ * Flush a deferred epilogue at an `agent_settled` boundary.
67
+ *
68
+ * Re-checks idleness (another extension may have started a run from its own
69
+ * settle handler) and the generation (a newer batch supersedes it).
70
+ */
71
+ onSettled(idle: boolean, generation: number): void {
72
+ if (!this.active) return;
73
+ if (!this.pending) return;
74
+ if (!idle) return;
75
+ if (this.pendingGeneration !== generation) {
76
+ // A newer batch superseded this epilogue — drop it.
77
+ this.pending = null;
78
+ this.pendingGeneration = -1;
79
+ return;
80
+ }
81
+ const epilogue = this.pending;
82
+ this.pending = null;
83
+ this.pendingGeneration = -1;
84
+ epilogue();
85
+ }
86
+
87
+ /** Drop any deferred epilogue (a newer batch supersedes it). */
88
+ invalidate(): void {
89
+ this.pending = null;
90
+ this.pendingGeneration = -1;
91
+ }
92
+
93
+ /** Permanently disable the gate and drop pending work (session shutdown). */
94
+ dispose(): void {
95
+ this.active = false;
96
+ this.invalidate();
97
+ }
98
+
99
+ /** Test/inspection helper: whether an epilogue is currently deferred. */
100
+ hasPending(): boolean {
101
+ return this.pending !== null;
102
+ }
103
+ }
@@ -3200,13 +3200,18 @@ export async function deactivateSupervisor(
3200
3200
  *
3201
3201
  * @since TP-128
3202
3202
  */
3203
- export async function transitionToRoutingMode(
3204
- pi: ExtensionAPI,
3205
- state: SupervisorState,
3206
- routingContext: SupervisorRoutingContext,
3207
- ): Promise<void> {
3208
- if (!state.active) return;
3209
-
3203
+ /**
3204
+ * Tear down batch-monitoring infrastructure (event tailer, heartbeat timer,
3205
+ * lockfile). Idempotent — safe to call multiple times.
3206
+ *
3207
+ * Extracted from `transitionToRoutingMode` (#621) so the batch-end epilogue can
3208
+ * stop background timers EAGERLY when it must defer its display banners past an
3209
+ * in-flight tool call. Stopping the heartbeat immediately prevents a
3210
+ * timer-origin `pi.sendMessage(..., {triggerTurn:false})` from splicing a custom
3211
+ * entry between an assistant `tool_use` and its `tool_result` during the defer
3212
+ * window.
3213
+ */
3214
+ export function stopBatchMonitoring(state: SupervisorState): void {
3210
3215
  // Tear down batch-monitoring infrastructure
3211
3216
  stopEventTailer(state.eventTailer);
3212
3217
 
@@ -3223,6 +3228,16 @@ export async function transitionToRoutingMode(
3223
3228
  }
3224
3229
  }
3225
3230
  state.lockSessionId = "";
3231
+ }
3232
+
3233
+ export async function transitionToRoutingMode(
3234
+ pi: ExtensionAPI,
3235
+ state: SupervisorState,
3236
+ routingContext: SupervisorRoutingContext,
3237
+ ): Promise<void> {
3238
+ if (!state.active) return;
3239
+
3240
+ stopBatchMonitoring(state);
3226
3241
 
3227
3242
  // Present deferred batch summary if any
3228
3243
  if (state.pendingSummaryDeps && state.batchStateRef && state.stateRoot) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "taskplane",
3
- "version": "0.30.4",
3
+ "version": "0.30.5",
4
4
  "description": "AI agent orchestration for pi — parallel task execution with checkpoint discipline",
5
5
  "keywords": [
6
6
  "pi-package",