pi-agent-squad 0.8.4 → 0.9.0
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 +47 -1
- package/cypher-status.ts +401 -0
- package/index.ts +614 -103
- package/package.json +18 -1
- package/session.ts +2 -0
- package/spawn.ts +86 -39
- package/task-delivery.ts +201 -0
- package/task-recovery.ts +41 -0
- package/task-state.ts +286 -0
- package/task-status.ts +107 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "pi-agent-squad",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.9.0",
|
|
4
4
|
"description": "Interactive multi-agent orchestration, messaging, and live sessions for the Pi Coding Agent",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"keywords": [
|
|
@@ -20,11 +20,28 @@
|
|
|
20
20
|
"dependencies": {
|
|
21
21
|
"pi-compact-ui": "^0.1.0"
|
|
22
22
|
},
|
|
23
|
+
"devDependencies": {
|
|
24
|
+
"@earendil-works/pi-coding-agent": "0.84.3",
|
|
25
|
+
"@earendil-works/pi-tui": "0.84.3",
|
|
26
|
+
"@types/node": "^24.0.0",
|
|
27
|
+
"@typescript-eslint/parser": "^8.0.0",
|
|
28
|
+
"eslint": "^9.0.0",
|
|
29
|
+
"jiti": "^2.7.0",
|
|
30
|
+
"tsx": "^4.20.0",
|
|
31
|
+
"typebox": "^1.0.0",
|
|
32
|
+
"typescript": "^5.9.0"
|
|
33
|
+
},
|
|
23
34
|
"peerDependencies": {
|
|
24
35
|
"@earendil-works/pi-coding-agent": "*",
|
|
25
36
|
"@earendil-works/pi-tui": "*",
|
|
26
37
|
"typebox": "*"
|
|
27
38
|
},
|
|
39
|
+
"scripts": {
|
|
40
|
+
"build": "tsc -p tsconfig.json --noEmit",
|
|
41
|
+
"typecheck": "tsc -p tsconfig.json --noEmit",
|
|
42
|
+
"lint": "eslint .",
|
|
43
|
+
"test": "node --import tsx --test test/**/*.test.ts"
|
|
44
|
+
},
|
|
28
45
|
"pi": {
|
|
29
46
|
"extensions": [
|
|
30
47
|
"./index.ts"
|
package/session.ts
CHANGED
|
@@ -1,5 +1,7 @@
|
|
|
1
1
|
export interface SubagentSessionHandle {
|
|
2
2
|
readonly agent: string;
|
|
3
|
+
/** Child Pi session file when the child is configured to persist one. */
|
|
4
|
+
readonly sessionFile?: string;
|
|
3
5
|
getMessages(): Promise<any[]>;
|
|
4
6
|
isStreaming(): Promise<boolean>;
|
|
5
7
|
send(message: string): Promise<void>;
|
package/spawn.ts
CHANGED
|
@@ -51,9 +51,13 @@ export interface SpawnOptions {
|
|
|
51
51
|
childIndex: number;
|
|
52
52
|
signal?: AbortSignal;
|
|
53
53
|
timeoutMs?: number;
|
|
54
|
+
/** Persist the child Pi session so full background results remain recoverable. */
|
|
55
|
+
persistSession?: boolean;
|
|
54
56
|
}
|
|
55
57
|
|
|
56
58
|
export interface InteractiveSpawnOptions extends SpawnOptions {
|
|
59
|
+
/** Called after the RPC child has started, before the initial task prompt. */
|
|
60
|
+
onStarted?: (childSessionFile?: string) => void;
|
|
57
61
|
onSession?: (session: SubagentSessionHandle) => void;
|
|
58
62
|
onEvent?: (event: any) => void;
|
|
59
63
|
}
|
|
@@ -63,6 +67,38 @@ const FORCE_KILL_DELAY_MS = 5000;
|
|
|
63
67
|
const TEARDOWN_ABORT_CAP_MS = 3000;
|
|
64
68
|
const CRASH_STDERR_MAX_CHARS = 2000;
|
|
65
69
|
|
|
70
|
+
/**
|
|
71
|
+
* Keep the owning task lifecycle alive while a routed message is waiting for
|
|
72
|
+
* the same child session to settle. RpcClient can emit agent_settled before
|
|
73
|
+
* the waiting caller has read the routed reply; tearing down at that instant
|
|
74
|
+
* would kill the child and turn a successful reply into exit 143.
|
|
75
|
+
*/
|
|
76
|
+
export class SessionSettleGate {
|
|
77
|
+
private activeWaits = 0;
|
|
78
|
+
private settleDeferred = false;
|
|
79
|
+
|
|
80
|
+
constructor(private readonly settle: () => void) {}
|
|
81
|
+
|
|
82
|
+
beginWait(): () => void {
|
|
83
|
+
this.activeWaits++;
|
|
84
|
+
let finished = false;
|
|
85
|
+
return () => {
|
|
86
|
+
if (finished) return;
|
|
87
|
+
finished = true;
|
|
88
|
+
this.activeWaits = Math.max(0, this.activeWaits - 1);
|
|
89
|
+
if (this.activeWaits === 0 && this.settleDeferred) {
|
|
90
|
+
this.settleDeferred = false;
|
|
91
|
+
this.settle();
|
|
92
|
+
}
|
|
93
|
+
};
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
onAgentSettled(): void {
|
|
97
|
+
if (this.activeWaits > 0) this.settleDeferred = true;
|
|
98
|
+
else this.settle();
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
|
|
66
102
|
/** Bound a best-effort teardown step so a stuck child can never stall cleanup. */
|
|
67
103
|
async function raceWithCap(promise: Promise<void>, capMs: number): Promise<void> {
|
|
68
104
|
let capTimer: ReturnType<typeof setTimeout> | undefined;
|
|
@@ -204,6 +240,7 @@ export async function spawnInteractiveSubagent(opts: InteractiveSpawnOptions): P
|
|
|
204
240
|
let childExitListener: ((code: number | null, exitSignal: string | null) => void) | undefined;
|
|
205
241
|
let timeoutTimer: ReturnType<typeof setTimeout> | undefined;
|
|
206
242
|
const sessionListeners = new Set<(event: any) => void>();
|
|
243
|
+
const sessionSettleGate = new SessionSettleGate(() => settleLifecycle({ kind: "settled" }));
|
|
207
244
|
|
|
208
245
|
const emitSessionEvent = (event: any) => {
|
|
209
246
|
for (const listener of sessionListeners) {
|
|
@@ -222,7 +259,7 @@ export async function spawnInteractiveSubagent(opts: InteractiveSpawnOptions): P
|
|
|
222
259
|
};
|
|
223
260
|
|
|
224
261
|
try {
|
|
225
|
-
const args: string[] = ["--no-session"];
|
|
262
|
+
const args: string[] = opts.persistSession ? [] : ["--no-session"];
|
|
226
263
|
if (agent.thinking) args.push("--thinking", agent.thinking);
|
|
227
264
|
const tools = agent.tools ? [...agent.tools] : [];
|
|
228
265
|
for (const tool of ["send_message", "read_inbox", "reply_message"]) {
|
|
@@ -286,7 +323,9 @@ export async function spawnInteractiveSubagent(opts: InteractiveSpawnOptions): P
|
|
|
286
323
|
// be cancelled during teardown). The subscription above is live
|
|
287
324
|
// before the prompt is sent, so a very fast run cannot settle in the
|
|
288
325
|
// gap between prompt acceptance and waiter setup.
|
|
289
|
-
if (event?.type === "agent_settled")
|
|
326
|
+
if (event?.type === "agent_settled") {
|
|
327
|
+
sessionSettleGate.onAgentSettled();
|
|
328
|
+
}
|
|
290
329
|
});
|
|
291
330
|
|
|
292
331
|
// Timeout and abort feed the same terminal-cause promise as settle/crash.
|
|
@@ -315,8 +354,11 @@ export async function spawnInteractiveSubagent(opts: InteractiveSpawnOptions): P
|
|
|
315
354
|
childExitListener(childProcess.exitCode, childProcess.signalCode);
|
|
316
355
|
}
|
|
317
356
|
}
|
|
357
|
+
const initialState = await activeClient.getState();
|
|
358
|
+
opts.onStarted?.(initialState.sessionFile);
|
|
318
359
|
const session: SubagentSessionHandle = {
|
|
319
360
|
agent: address,
|
|
361
|
+
sessionFile: initialState.sessionFile,
|
|
320
362
|
getMessages: () => activeClient.getMessages(),
|
|
321
363
|
isStreaming: async () => (await activeClient.getState()).isStreaming,
|
|
322
364
|
send: async (message: string) => {
|
|
@@ -326,47 +368,52 @@ export async function spawnInteractiveSubagent(opts: InteractiveSpawnOptions): P
|
|
|
326
368
|
else await activeClient.prompt(message);
|
|
327
369
|
},
|
|
328
370
|
sendAndWait: async (message: string, messageTimeoutMs: number, messageSignal?: AbortSignal) => {
|
|
329
|
-
|
|
330
|
-
if (messageSignal?.aborted) throw new Error("Message delivery cancelled.");
|
|
331
|
-
const before = await activeClient.getMessages();
|
|
332
|
-
// Own settle detection so the timer and listener are always
|
|
333
|
-
// cancelled below (RpcClient.waitForIdle would leak both).
|
|
334
|
-
let resolveSettled!: () => void;
|
|
335
|
-
let rejectSettled!: (error: unknown) => void;
|
|
336
|
-
const settled = new Promise<void>((resolve, reject) => {
|
|
337
|
-
resolveSettled = resolve;
|
|
338
|
-
rejectSettled = reject;
|
|
339
|
-
});
|
|
340
|
-
let unsubscribeSettle: (() => void) | undefined;
|
|
341
|
-
let settleTimer: ReturnType<typeof setTimeout> | undefined;
|
|
342
|
-
let abortHandler: (() => void) | undefined;
|
|
343
|
-
const aborted = new Promise<never>((_resolve, reject) => {
|
|
344
|
-
abortHandler = () => reject(new Error("Message delivery cancelled."));
|
|
345
|
-
if (messageSignal?.aborted) abortHandler();
|
|
346
|
-
else messageSignal?.addEventListener("abort", abortHandler, { once: true });
|
|
347
|
-
});
|
|
371
|
+
const finishSessionWait = sessionSettleGate.beginWait();
|
|
348
372
|
try {
|
|
349
|
-
|
|
350
|
-
|
|
373
|
+
await initialPromptAcceptedPromise;
|
|
374
|
+
if (messageSignal?.aborted) throw new Error("Message delivery cancelled.");
|
|
375
|
+
const before = await activeClient.getMessages();
|
|
376
|
+
// Own settle detection so the timer and listener are always
|
|
377
|
+
// cancelled below (RpcClient.waitForIdle would leak both).
|
|
378
|
+
let resolveSettled!: () => void;
|
|
379
|
+
let rejectSettled!: (error: unknown) => void;
|
|
380
|
+
const settled = new Promise<void>((resolve, reject) => {
|
|
381
|
+
resolveSettled = resolve;
|
|
382
|
+
rejectSettled = reject;
|
|
383
|
+
});
|
|
384
|
+
let unsubscribeSettle: (() => void) | undefined;
|
|
385
|
+
let settleTimer: ReturnType<typeof setTimeout> | undefined;
|
|
386
|
+
let abortHandler: (() => void) | undefined;
|
|
387
|
+
const aborted = new Promise<never>((_resolve, reject) => {
|
|
388
|
+
abortHandler = () => reject(new Error("Message delivery cancelled."));
|
|
389
|
+
if (messageSignal?.aborted) abortHandler();
|
|
390
|
+
else messageSignal?.addEventListener("abort", abortHandler, { once: true });
|
|
351
391
|
});
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
);
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
392
|
+
try {
|
|
393
|
+
unsubscribeSettle = activeClient.onEvent((event: any) => {
|
|
394
|
+
if (event?.type === "agent_settled") resolveSettled();
|
|
395
|
+
});
|
|
396
|
+
settleTimer = setTimeout(() => {
|
|
397
|
+
rejectSettled(
|
|
398
|
+
new Error(`Timeout waiting for agent to become idle. Stderr: ${activeClient.getStderr()}`),
|
|
399
|
+
);
|
|
400
|
+
}, messageTimeoutMs);
|
|
401
|
+
settleTimer.unref?.();
|
|
402
|
+
const state = await activeClient.getState();
|
|
403
|
+
if (state.isStreaming) await activeClient.steer(message);
|
|
404
|
+
else await activeClient.prompt(message);
|
|
405
|
+
// `crashed` fails fast if the child dies mid-message.
|
|
406
|
+
await Promise.race([settled, aborted, crashed]);
|
|
407
|
+
} finally {
|
|
408
|
+
if (settleTimer) clearTimeout(settleTimer);
|
|
409
|
+
unsubscribeSettle?.();
|
|
410
|
+
if (messageSignal && abortHandler) messageSignal.removeEventListener("abort", abortHandler);
|
|
411
|
+
}
|
|
412
|
+
const after = await activeClient.getMessages();
|
|
413
|
+
return getFinalOutput(after.slice(before.length) as unknown as SingleResult["messages"]);
|
|
363
414
|
} finally {
|
|
364
|
-
|
|
365
|
-
unsubscribeSettle?.();
|
|
366
|
-
if (messageSignal && abortHandler) messageSignal.removeEventListener("abort", abortHandler);
|
|
415
|
+
finishSessionWait();
|
|
367
416
|
}
|
|
368
|
-
const after = await activeClient.getMessages();
|
|
369
|
-
return getFinalOutput(after.slice(before.length) as unknown as SingleResult["messages"]);
|
|
370
417
|
},
|
|
371
418
|
abort: () => activeClient.abort(),
|
|
372
419
|
subscribe: (listener) => {
|
package/task-delivery.ts
ADDED
|
@@ -0,0 +1,201 @@
|
|
|
1
|
+
import {
|
|
2
|
+
TASK_RESULT_DELIVERY_PREFIX,
|
|
3
|
+
type PersistedTaskState,
|
|
4
|
+
} from "./task-state.ts";
|
|
5
|
+
|
|
6
|
+
export const BACKGROUND_EVENT_TYPE = "pi_subagent_background_event";
|
|
7
|
+
export const DELIVERY_ID_PREFIX = TASK_RESULT_DELIVERY_PREFIX;
|
|
8
|
+
|
|
9
|
+
export interface BackgroundEventDetails {
|
|
10
|
+
agent?: string;
|
|
11
|
+
address?: string;
|
|
12
|
+
status?: "done" | "error";
|
|
13
|
+
body?: string;
|
|
14
|
+
elapsedMs?: number;
|
|
15
|
+
runId?: string;
|
|
16
|
+
deliveryId?: string;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export interface BackgroundResultPayload {
|
|
20
|
+
body: string;
|
|
21
|
+
content?: string;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export function resultDeliveryId(runId: string): string {
|
|
25
|
+
return `${DELIVERY_ID_PREFIX}${runId}`;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
function isPlainObject(value: unknown): value is Record<string, unknown> {
|
|
29
|
+
if (value === null || typeof value !== "object" || Array.isArray(value)) return false;
|
|
30
|
+
try {
|
|
31
|
+
const prototype = Object.getPrototypeOf(value);
|
|
32
|
+
return prototype === Object.prototype || prototype === null;
|
|
33
|
+
} catch {
|
|
34
|
+
return false;
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/** Match the real Pi CustomMessageEntry shape persisted by sendMessage(). */
|
|
39
|
+
export function entryHasResultDelivery(entry: unknown, deliveryId: string): boolean {
|
|
40
|
+
if (!isPlainObject(entry)) return false;
|
|
41
|
+
if (entry.type !== "custom_message" || entry.customType !== BACKGROUND_EVENT_TYPE) return false;
|
|
42
|
+
if (!isPlainObject(entry.details)) return false;
|
|
43
|
+
return (
|
|
44
|
+
entry.details.deliveryId === deliveryId &&
|
|
45
|
+
typeof entry.details.runId === "string" &&
|
|
46
|
+
entry.details.deliveryId === resultDeliveryId(entry.details.runId) &&
|
|
47
|
+
typeof entry.details.agent === "string" &&
|
|
48
|
+
typeof entry.details.address === "string" &&
|
|
49
|
+
entry.details.status === "done"
|
|
50
|
+
);
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
export function branchHasResultDelivery(entries: readonly unknown[], deliveryId: string): boolean {
|
|
54
|
+
return entries.some((entry) => entryHasResultDelivery(entry, deliveryId));
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
export function createBackgroundResultMessage(
|
|
58
|
+
state: PersistedTaskState,
|
|
59
|
+
payload?: BackgroundResultPayload,
|
|
60
|
+
): {
|
|
61
|
+
customType: typeof BACKGROUND_EVENT_TYPE;
|
|
62
|
+
content: string;
|
|
63
|
+
display: true;
|
|
64
|
+
details: BackgroundEventDetails;
|
|
65
|
+
} {
|
|
66
|
+
const body = payload?.body || state.resultSummary || "(result summary unavailable)";
|
|
67
|
+
const contentBody = payload?.content || body;
|
|
68
|
+
return {
|
|
69
|
+
customType: BACKGROUND_EVENT_TYPE,
|
|
70
|
+
content: `[background subagent done] Subagent ${state.agent} done (run ${state.runId.slice(0, 8)})\n\n--- result ---\n${contentBody}`,
|
|
71
|
+
display: true,
|
|
72
|
+
details: {
|
|
73
|
+
agent: state.agent,
|
|
74
|
+
address: state.address,
|
|
75
|
+
status: "done",
|
|
76
|
+
body,
|
|
77
|
+
elapsedMs: Math.max(0, (state.endedAt ?? state.updatedAt) - state.startedAt),
|
|
78
|
+
runId: state.runId,
|
|
79
|
+
deliveryId: state.deliveryId,
|
|
80
|
+
},
|
|
81
|
+
};
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
interface OutboxJob {
|
|
85
|
+
state: PersistedTaskState;
|
|
86
|
+
payload?: BackgroundResultPayload;
|
|
87
|
+
sessionToken: string;
|
|
88
|
+
timer?: ReturnType<typeof setTimeout>;
|
|
89
|
+
sent: boolean;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
export interface RecoveryOutboxOptions {
|
|
93
|
+
getSessionToken(): string | undefined;
|
|
94
|
+
getBranch(): readonly unknown[];
|
|
95
|
+
getTaskState(runId: string): PersistedTaskState | undefined;
|
|
96
|
+
send(
|
|
97
|
+
message: ReturnType<typeof createBackgroundResultMessage>,
|
|
98
|
+
options: { triggerTurn: true; deliverAs: "steer" },
|
|
99
|
+
): void;
|
|
100
|
+
markInjected(state: PersistedTaskState): void;
|
|
101
|
+
onError?(error: unknown, state: PersistedTaskState): void;
|
|
102
|
+
pollMs?: number;
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
/**
|
|
106
|
+
* In-memory scheduler for completed-but-undelivered results. Durable state
|
|
107
|
+
* remains in the Pi session; losing this object cannot lose recovery data.
|
|
108
|
+
*/
|
|
109
|
+
export class RecoveryOutbox {
|
|
110
|
+
private readonly jobs = new Map<string, OutboxJob>();
|
|
111
|
+
private readonly pollMs: number;
|
|
112
|
+
|
|
113
|
+
constructor(private readonly options: RecoveryOutboxOptions) {
|
|
114
|
+
this.pollMs = Math.max(10, options.pollMs ?? 250);
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
get size(): number {
|
|
118
|
+
return this.jobs.size;
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
enqueue(state: PersistedTaskState, payload?: BackgroundResultPayload): boolean {
|
|
122
|
+
const deliveryId = state.deliveryId;
|
|
123
|
+
const sessionToken = this.options.getSessionToken();
|
|
124
|
+
if (
|
|
125
|
+
state.status !== "completed" ||
|
|
126
|
+
state.resultInjected !== false ||
|
|
127
|
+
!deliveryId ||
|
|
128
|
+
!sessionToken ||
|
|
129
|
+
this.jobs.has(deliveryId)
|
|
130
|
+
) {
|
|
131
|
+
return false;
|
|
132
|
+
}
|
|
133
|
+
const job: OutboxJob = { state, payload, sessionToken, sent: false };
|
|
134
|
+
this.jobs.set(deliveryId, job);
|
|
135
|
+
job.timer = setTimeout(() => this.run(deliveryId, job), 0);
|
|
136
|
+
return true;
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
cancelAll(): void {
|
|
140
|
+
for (const job of this.jobs.values()) {
|
|
141
|
+
if (job.timer) clearTimeout(job.timer);
|
|
142
|
+
}
|
|
143
|
+
this.jobs.clear();
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
private finish(deliveryId: string, job: OutboxJob): void {
|
|
147
|
+
if (job.timer) clearTimeout(job.timer);
|
|
148
|
+
if (this.jobs.get(deliveryId) === job) this.jobs.delete(deliveryId);
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
private schedulePoll(deliveryId: string, job: OutboxJob): void {
|
|
152
|
+
job.timer = setTimeout(() => this.run(deliveryId, job), this.pollMs);
|
|
153
|
+
job.timer.unref?.();
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
private run(deliveryId: string, job: OutboxJob): void {
|
|
157
|
+
if (this.jobs.get(deliveryId) !== job) return;
|
|
158
|
+
if (this.options.getSessionToken() !== job.sessionToken) {
|
|
159
|
+
this.finish(deliveryId, job);
|
|
160
|
+
return;
|
|
161
|
+
}
|
|
162
|
+
const current = this.options.getTaskState(job.state.runId);
|
|
163
|
+
if (
|
|
164
|
+
!current ||
|
|
165
|
+
current.status !== "completed" ||
|
|
166
|
+
current.resultInjected !== false ||
|
|
167
|
+
current.deliveryId !== deliveryId
|
|
168
|
+
) {
|
|
169
|
+
this.finish(deliveryId, job);
|
|
170
|
+
return;
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
// Check immediately before every possible send. This is the durable
|
|
174
|
+
// dedupe barrier for both crash window B and concurrent recovery events.
|
|
175
|
+
if (branchHasResultDelivery(this.options.getBranch(), deliveryId)) {
|
|
176
|
+
try {
|
|
177
|
+
this.options.markInjected(current);
|
|
178
|
+
this.finish(deliveryId, job);
|
|
179
|
+
} catch (error) {
|
|
180
|
+
this.options.onError?.(error, current);
|
|
181
|
+
this.schedulePoll(deliveryId, job);
|
|
182
|
+
}
|
|
183
|
+
return;
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
if (!job.sent) {
|
|
187
|
+
try {
|
|
188
|
+
this.options.send(createBackgroundResultMessage(current, job.payload), {
|
|
189
|
+
triggerTurn: true,
|
|
190
|
+
deliverAs: "steer",
|
|
191
|
+
});
|
|
192
|
+
job.sent = true;
|
|
193
|
+
} catch (error) {
|
|
194
|
+
this.options.onError?.(error, current);
|
|
195
|
+
this.finish(deliveryId, job);
|
|
196
|
+
return;
|
|
197
|
+
}
|
|
198
|
+
}
|
|
199
|
+
this.schedulePoll(deliveryId, job);
|
|
200
|
+
}
|
|
201
|
+
}
|
package/task-recovery.ts
ADDED
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
import type { PersistedTaskState } from "./task-state.ts";
|
|
2
|
+
|
|
3
|
+
export interface TaskRecoveryPlan {
|
|
4
|
+
interrupt: PersistedTaskState[];
|
|
5
|
+
markInjected: PersistedTaskState[];
|
|
6
|
+
deliver: PersistedTaskState[];
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
export type DeliveryPresence = (deliveryId: string) => boolean;
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* Build a recovery plan without performing any process launch. The caller
|
|
13
|
+
* applies append-only snapshots and schedules result delivery after the
|
|
14
|
+
* session_start handlers have finished.
|
|
15
|
+
*/
|
|
16
|
+
export function planTaskRecovery(
|
|
17
|
+
states: ReadonlyMap<string, PersistedTaskState>,
|
|
18
|
+
ownerRuntimeId: string,
|
|
19
|
+
liveRunIds: ReadonlySet<string>,
|
|
20
|
+
hasDelivery: DeliveryPresence,
|
|
21
|
+
): TaskRecoveryPlan {
|
|
22
|
+
const plan: TaskRecoveryPlan = { interrupt: [], markInjected: [], deliver: [] };
|
|
23
|
+
for (const state of states.values()) {
|
|
24
|
+
if (state.status === "starting" || state.status === "running") {
|
|
25
|
+
const genuinelyLive =
|
|
26
|
+
state.ownerRuntimeId === ownerRuntimeId && liveRunIds.has(state.runId);
|
|
27
|
+
if (!genuinelyLive) plan.interrupt.push(state);
|
|
28
|
+
continue;
|
|
29
|
+
}
|
|
30
|
+
if (
|
|
31
|
+
state.status === "completed" &&
|
|
32
|
+
state.resultInjected === false &&
|
|
33
|
+
state.deliveryId
|
|
34
|
+
) {
|
|
35
|
+
if (hasDelivery(state.deliveryId)) plan.markInjected.push(state);
|
|
36
|
+
else plan.deliver.push(state);
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
return plan;
|
|
40
|
+
}
|
|
41
|
+
|