taskplane 0.30.3 → 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.
package/README.md
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
# Taskplane
|
|
2
2
|
|
|
3
|
-
Multi-agent AI orchestration for coding with [pi](https://github.com/
|
|
3
|
+
Multi-agent AI orchestration for coding with [pi](https://github.com/earendil-works/pi) — parallel task execution, mono- and poly-repo support, fresh-context worker loops, cross-model reviews, automated merges and a killer dashboard!
|
|
4
4
|
|
|
5
5
|
> **Status:** Initial release.
|
|
6
6
|
|
|
@@ -50,7 +50,7 @@ Taskplane is a pi package. You need Node.js 22+, pi and Git installed first.
|
|
|
50
50
|
| Dependency | Required | Notes |
|
|
51
51
|
|-----------|----------|-------|
|
|
52
52
|
| [Node.js](https://nodejs.org/) ≥ 22 | Yes | Runtime |
|
|
53
|
-
| [pi](https://github.com/
|
|
53
|
+
| [pi](https://github.com/earendil-works/pi) | Yes | Agent framework |
|
|
54
54
|
| [Git](https://git-scm.com/) | Yes | Version control, worktrees |
|
|
55
55
|
|
|
56
56
|
IMPORTANT: If you just installed pi, make sure you've configured at least one model provider and tested before installing Taskplane.
|
|
@@ -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
|
+
}
|
|
@@ -2020,7 +2020,16 @@ export function presentBatchSummary(
|
|
|
2020
2020
|
(batchState.failedTasks > 0 ? `- **Failed:** ${batchState.failedTasks} task(s)\n` : "") +
|
|
2021
2021
|
`\nFull summary written to \`.pi/supervisor/${filename}\`.`;
|
|
2022
2022
|
|
|
2023
|
-
|
|
2023
|
+
// #597 (post-Sage-review): `presentBatchSummary` is a terminal best-effort
|
|
2024
|
+
// operation, and it can be reached from a timer-origin call chain via
|
|
2025
|
+
// `startHeartbeat → deactivateSupervisor → (state.pendingSummaryDeps)`.
|
|
2026
|
+
// If the captured `pi` handle has gone stale by the time the heartbeat
|
|
2027
|
+
// fires, an unwrapped `pi.sendMessage` here re-introduces the exact
|
|
2028
|
+
// uncaughtException pattern #597 is meant to prevent. Use the same
|
|
2029
|
+
// never-throw wrapper as the timer call sites: deliver the message if
|
|
2030
|
+
// pi is healthy, drop it silently if pi is stale.
|
|
2031
|
+
safeSendMessageFromTimer(
|
|
2032
|
+
pi,
|
|
2024
2033
|
{
|
|
2025
2034
|
customType: "supervisor-batch-summary",
|
|
2026
2035
|
content: [{ type: "text", text: conciseText }],
|
|
@@ -3037,6 +3046,23 @@ export async function activateSupervisor(
|
|
|
3037
3046
|
};
|
|
3038
3047
|
writeLockfile(stateRoot, lock);
|
|
3039
3048
|
|
|
3049
|
+
// #597: Defensive teardown before installing new timers.
|
|
3050
|
+
//
|
|
3051
|
+
// `state` is a mutable singleton that persists across activate/deactivate
|
|
3052
|
+
// cycles. In re-activation paths (a previous activation that didn't go
|
|
3053
|
+
// through `deactivateSupervisor` cleanly, session churn after takeover,
|
|
3054
|
+
// etc.) `state.heartbeatTimer` and `state.eventTailer` may still reference
|
|
3055
|
+
// running timers that captured a now-stale `pi` handle. If we just
|
|
3056
|
+
// reassign `state.heartbeatTimer = startHeartbeat(...)` the previous
|
|
3057
|
+
// timer is orphaned — still ticking, still holding the stale `pi`, and
|
|
3058
|
+
// at the next tick its `pi.sendMessage()` call throws `assertActive` and
|
|
3059
|
+
// crashes the host process. Tear down explicitly before replacing.
|
|
3060
|
+
stopEventTailer(state.eventTailer);
|
|
3061
|
+
if (state.heartbeatTimer) {
|
|
3062
|
+
clearInterval(state.heartbeatTimer);
|
|
3063
|
+
state.heartbeatTimer = null;
|
|
3064
|
+
}
|
|
3065
|
+
|
|
3040
3066
|
// Start heartbeat timer — updates lockfile every 30s, detects takeover
|
|
3041
3067
|
state.heartbeatTimer = startHeartbeat(stateRoot, state, pi);
|
|
3042
3068
|
|
|
@@ -3174,13 +3200,18 @@ export async function deactivateSupervisor(
|
|
|
3174
3200
|
*
|
|
3175
3201
|
* @since TP-128
|
|
3176
3202
|
*/
|
|
3177
|
-
|
|
3178
|
-
|
|
3179
|
-
|
|
3180
|
-
|
|
3181
|
-
)
|
|
3182
|
-
|
|
3183
|
-
|
|
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 {
|
|
3184
3215
|
// Tear down batch-monitoring infrastructure
|
|
3185
3216
|
stopEventTailer(state.eventTailer);
|
|
3186
3217
|
|
|
@@ -3197,6 +3228,16 @@ export async function transitionToRoutingMode(
|
|
|
3197
3228
|
}
|
|
3198
3229
|
}
|
|
3199
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);
|
|
3200
3241
|
|
|
3201
3242
|
// Present deferred batch summary if any
|
|
3202
3243
|
if (state.pendingSummaryDeps && state.batchStateRef && state.stateRoot) {
|
|
@@ -3710,6 +3751,87 @@ export function buildTakeoverSummary(stateRoot: string, batchState: PersistedBat
|
|
|
3710
3751
|
*
|
|
3711
3752
|
* @since TP-041
|
|
3712
3753
|
*/
|
|
3754
|
+
|
|
3755
|
+
// ── Stale extension-context guard (#597) ──────────────────────────────
|
|
3756
|
+
//
|
|
3757
|
+
// Pi throws "This extension ctx is stale after session replacement or reload."
|
|
3758
|
+
// from `assertActive` when an extension uses a captured `pi` handle after
|
|
3759
|
+
// `ctx.newSession()`, `ctx.fork()`, `ctx.switchSession()`, or `ctx.reload()`.
|
|
3760
|
+
// Background timers in this file (`startHeartbeat`, `startEventTailer`)
|
|
3761
|
+
// capture `pi` in a closure and call `pi.sendMessage()` at arbitrary times;
|
|
3762
|
+
// when the captured handle goes stale between timer ticks, an uncaught
|
|
3763
|
+
// throw from `pi.sendMessage()` becomes a process-fatal `uncaughtException`
|
|
3764
|
+
// that kills the entire Pi process — issue #597.
|
|
3765
|
+
//
|
|
3766
|
+
// `isStaleExtensionCtx` and `safeSendMessageFromTimer` together harden the
|
|
3767
|
+
// timer call sites: stale-ctx errors are recognized and swallowed (the timer
|
|
3768
|
+
// caller then stops itself), other errors are logged to stderr but do not
|
|
3769
|
+
// propagate. The supervisor surrenders its UI surface gracefully instead of
|
|
3770
|
+
// taking Pi down.
|
|
3771
|
+
|
|
3772
|
+
/**
|
|
3773
|
+
* Returns true when `err` is Pi's distinctive stale-extension-ctx error.
|
|
3774
|
+
*
|
|
3775
|
+
* Matched by error-message substring rather than by class identity because
|
|
3776
|
+
* the error class is not exported by `@earendil-works/pi-coding-agent` and
|
|
3777
|
+
* the message text is the stable, documented contract
|
|
3778
|
+
* (`core/extensions/loader.js:assertActive`).
|
|
3779
|
+
*
|
|
3780
|
+
* Defensive against non-Error throws (string throws, null, undefined, etc.)
|
|
3781
|
+
* which are not stale-ctx errors and should propagate to the caller's
|
|
3782
|
+
* normal error path — we only swallow the specific Pi case.
|
|
3783
|
+
*
|
|
3784
|
+
* @since #597
|
|
3785
|
+
*/
|
|
3786
|
+
export function isStaleExtensionCtx(err: unknown): boolean {
|
|
3787
|
+
if (err === null || err === undefined) return false;
|
|
3788
|
+
// Read message off either an Error instance or a plain object with .message
|
|
3789
|
+
const message =
|
|
3790
|
+
typeof err === "object" && err !== null && "message" in err
|
|
3791
|
+
? String((err as { message: unknown }).message ?? "")
|
|
3792
|
+
: typeof err === "string"
|
|
3793
|
+
? err
|
|
3794
|
+
: "";
|
|
3795
|
+
return message.includes("This extension ctx is stale");
|
|
3796
|
+
}
|
|
3797
|
+
|
|
3798
|
+
/**
|
|
3799
|
+
* `pi.sendMessage()` wrapper for timer-context callers (heartbeat / event
|
|
3800
|
+
* tailer / digest timer).
|
|
3801
|
+
*
|
|
3802
|
+
* Returns `true` on success, `false` when the call was swallowed because
|
|
3803
|
+
* the extension context has gone stale (per `isStaleExtensionCtx`). Other
|
|
3804
|
+
* exceptions are logged via `console.error` but also do not propagate —
|
|
3805
|
+
* the timer caller stays alive and continues, since the safe-default for a
|
|
3806
|
+
* background timer in a long-running process is to keep ticking rather
|
|
3807
|
+
* than crash the host. Callers should treat a `false` return as "Pi has
|
|
3808
|
+
* replaced us; stop trying" and clear their own interval.
|
|
3809
|
+
*
|
|
3810
|
+
* @since #597
|
|
3811
|
+
*/
|
|
3812
|
+
export function safeSendMessageFromTimer(
|
|
3813
|
+
pi: ExtensionAPI,
|
|
3814
|
+
message: Parameters<ExtensionAPI["sendMessage"]>[0],
|
|
3815
|
+
options?: Parameters<ExtensionAPI["sendMessage"]>[1],
|
|
3816
|
+
): boolean {
|
|
3817
|
+
try {
|
|
3818
|
+
pi.sendMessage(message, options);
|
|
3819
|
+
return true;
|
|
3820
|
+
} catch (err) {
|
|
3821
|
+
if (isStaleExtensionCtx(err)) {
|
|
3822
|
+
// Pi has replaced us. The timer caller will see `false` and stop.
|
|
3823
|
+
return false;
|
|
3824
|
+
}
|
|
3825
|
+
// Unexpected error — log for diagnosis but do not crash the host.
|
|
3826
|
+
console.error(
|
|
3827
|
+
`[supervisor] pi.sendMessage from timer callback threw: ${
|
|
3828
|
+
err instanceof Error ? err.message : String(err)
|
|
3829
|
+
}`,
|
|
3830
|
+
);
|
|
3831
|
+
return true; // not stale; let the caller continue ticking
|
|
3832
|
+
}
|
|
3833
|
+
}
|
|
3834
|
+
|
|
3713
3835
|
export function startHeartbeat(
|
|
3714
3836
|
stateRoot: string,
|
|
3715
3837
|
state: SupervisorState,
|
|
@@ -3731,9 +3853,14 @@ export function startHeartbeat(
|
|
|
3731
3853
|
// Read current lockfile to detect force takeover — async (TP-070)
|
|
3732
3854
|
const currentLock = await readLockfileAsync(stateRoot);
|
|
3733
3855
|
if (currentLock && currentLock.sessionId !== sessionId) {
|
|
3734
|
-
// Another session has taken over — yield gracefully
|
|
3856
|
+
// Another session has taken over — yield gracefully.
|
|
3857
|
+
// #597: the captured `pi` handle may be stale at this point;
|
|
3858
|
+
// use safeSendMessageFromTimer so a stale-ctx throw cannot
|
|
3859
|
+
// escape and become an uncaughtException that kills the
|
|
3860
|
+
// whole Pi process.
|
|
3735
3861
|
clearInterval(timer);
|
|
3736
|
-
|
|
3862
|
+
safeSendMessageFromTimer(
|
|
3863
|
+
pi,
|
|
3737
3864
|
{
|
|
3738
3865
|
customType: "supervisor-yield",
|
|
3739
3866
|
content: [
|
|
@@ -4444,7 +4571,12 @@ export function startEventTailer(
|
|
|
4444
4571
|
setStatus("supervisor", `🔀 ${statusText}`);
|
|
4445
4572
|
}
|
|
4446
4573
|
|
|
4447
|
-
pi.
|
|
4574
|
+
// #597: guard against stale-ctx throws from the captured `pi` handle.
|
|
4575
|
+
// If Pi has replaced us between event-tailer ticks, swallow the throw
|
|
4576
|
+
// and stop the tailer rather than letting an uncaughtException kill
|
|
4577
|
+
// the Pi process.
|
|
4578
|
+
const ok = safeSendMessageFromTimer(
|
|
4579
|
+
pi,
|
|
4448
4580
|
{
|
|
4449
4581
|
customType: "supervisor-event",
|
|
4450
4582
|
content: [{ type: "text", text }],
|
|
@@ -4452,6 +4584,9 @@ export function startEventTailer(
|
|
|
4452
4584
|
},
|
|
4453
4585
|
{ triggerTurn: true },
|
|
4454
4586
|
);
|
|
4587
|
+
if (!ok) {
|
|
4588
|
+
stopEventTailer(tailer);
|
|
4589
|
+
}
|
|
4455
4590
|
};
|
|
4456
4591
|
|
|
4457
4592
|
// ── TP-043: Integration is triggered by triggerSupervisorIntegration() ──
|