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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
3204
|
-
|
|
3205
|
-
|
|
3206
|
-
|
|
3207
|
-
)
|
|
3208
|
-
|
|
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) {
|