@bermudi/pi-delegate 0.1.2 → 0.1.3
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/concurrency.ts +20 -3
- package/config.ts +61 -0
- package/delegate.ts +1 -0
- package/dispatch.ts +94 -15
- package/extension.ts +292 -61
- package/lifecycle.ts +46 -14
- package/manual.ts +3 -2
- package/package.json +1 -1
- package/schema.ts +1 -1
- package/telemetry.ts +738 -0
- package/tickets.ts +34 -10
- package/types.ts +17 -0
- package/usage.ts +19 -0
package/extension.ts
CHANGED
|
@@ -30,10 +30,87 @@ import {
|
|
|
30
30
|
notifyActiveTicketsOnSettled,
|
|
31
31
|
syncDelegateStatus,
|
|
32
32
|
} from "./status.ts";
|
|
33
|
+
import {
|
|
34
|
+
beginCall,
|
|
35
|
+
closeTelemetry,
|
|
36
|
+
getTelemetryGeneration,
|
|
37
|
+
prepareTelemetryForSession,
|
|
38
|
+
sealTelemetryWrites,
|
|
39
|
+
} from "./telemetry.ts";
|
|
33
40
|
import type { DelegateArguments } from "./types.ts";
|
|
34
41
|
|
|
42
|
+
const DEFAULT_SHUTDOWN_DRAIN_TIMEOUT_MS = 10_000;
|
|
43
|
+
let shutdownDrainTimeoutMs = DEFAULT_SHUTDOWN_DRAIN_TIMEOUT_MS;
|
|
44
|
+
|
|
45
|
+
type ShutdownDrainResult =
|
|
46
|
+
{ drained: true; failures: unknown[] } | { drained: false; failures: [] };
|
|
47
|
+
|
|
48
|
+
/** Wait for shutdown workers without allowing a stuck provider/tool to hold
|
|
49
|
+
* Pi's reload or exit hostage. The allSettled promise is intentionally left
|
|
50
|
+
* attached after timeout so a late rejection cannot become unhandled. */
|
|
51
|
+
export async function drainAsyncTickets(
|
|
52
|
+
completions: readonly Promise<void>[],
|
|
53
|
+
timeoutMs = shutdownDrainTimeoutMs,
|
|
54
|
+
): Promise<ShutdownDrainResult> {
|
|
55
|
+
if (!completions.length) return { drained: true, failures: [] };
|
|
56
|
+
|
|
57
|
+
const drain = Promise.allSettled(completions).then((results) => ({
|
|
58
|
+
drained: true as const,
|
|
59
|
+
failures: results
|
|
60
|
+
.filter(
|
|
61
|
+
(result): result is PromiseRejectedResult =>
|
|
62
|
+
result.status === "rejected",
|
|
63
|
+
)
|
|
64
|
+
.map((result) => result.reason),
|
|
65
|
+
}));
|
|
66
|
+
|
|
67
|
+
let timer: ReturnType<typeof setTimeout> | undefined;
|
|
68
|
+
const timeout = new Promise<ShutdownDrainResult>((resolve) => {
|
|
69
|
+
timer = setTimeout(
|
|
70
|
+
() => resolve({ drained: false, failures: [] }),
|
|
71
|
+
timeoutMs,
|
|
72
|
+
);
|
|
73
|
+
});
|
|
74
|
+
const result = await Promise.race([drain, timeout]);
|
|
75
|
+
if (timer !== undefined) clearTimeout(timer);
|
|
76
|
+
return result;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/** Wait for another shutdown cleanup operation without allowing a stuck
|
|
80
|
+
* session lock to hold reload/quit forever. Rejection is consumed here; the
|
|
81
|
+
* caller reports it when it wins the race, and a late rejection stays handled.
|
|
82
|
+
*/
|
|
83
|
+
async function boundedShutdownCleanup(
|
|
84
|
+
cleanup: Promise<void>,
|
|
85
|
+
timeoutMs = shutdownDrainTimeoutMs,
|
|
86
|
+
): Promise<"settled" | "timed-out" | "failed"> {
|
|
87
|
+
let timer: ReturnType<typeof setTimeout> | undefined;
|
|
88
|
+
const outcome = cleanup.then(
|
|
89
|
+
() => "settled" as const,
|
|
90
|
+
() => "failed" as const,
|
|
91
|
+
);
|
|
92
|
+
const timeout = new Promise<"timed-out">((resolve) => {
|
|
93
|
+
timer = setTimeout(() => resolve("timed-out"), timeoutMs);
|
|
94
|
+
});
|
|
95
|
+
const result = await Promise.race([outcome, timeout]);
|
|
96
|
+
if (timer !== undefined) clearTimeout(timer);
|
|
97
|
+
return result;
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
/** @internal Test-only timeout override. */
|
|
101
|
+
export function _setShutdownDrainTimeoutForTesting(
|
|
102
|
+
timeoutMs: number | undefined,
|
|
103
|
+
): void {
|
|
104
|
+
shutdownDrainTimeoutMs = timeoutMs ?? DEFAULT_SHUTDOWN_DRAIN_TIMEOUT_MS;
|
|
105
|
+
}
|
|
106
|
+
|
|
35
107
|
/** Register the delegate tool and clean up its parent-session resources. */
|
|
36
108
|
export default function delegateExtension(pi: ExtensionAPI): void {
|
|
109
|
+
// A /reload can reuse this module instance after the previous runtime closed
|
|
110
|
+
// its SQLite handle. Permit the new runtime to open a fresh backend; stale
|
|
111
|
+
// workers from the old runtime remain blocked from reopening it.
|
|
112
|
+
prepareTelemetryForSession();
|
|
113
|
+
|
|
37
114
|
pi.registerTool({
|
|
38
115
|
name: "delegate",
|
|
39
116
|
label: "Delegate to Subagents",
|
|
@@ -45,23 +122,70 @@ export default function delegateExtension(pi: ExtensionAPI): void {
|
|
|
45
122
|
prepareArguments: normalizeDelegateArguments,
|
|
46
123
|
|
|
47
124
|
async execute(_id, params: DelegateArguments, signal, onUpdate, ctx) {
|
|
125
|
+
const parentModelId = ctx.model?.id;
|
|
126
|
+
const tasks = params.tasks ?? [];
|
|
127
|
+
const parentSessionFile = (
|
|
128
|
+
ctx as { sessionManager?: { getSessionFile?(): string | undefined } }
|
|
129
|
+
).sessionManager?.getSessionFile?.();
|
|
130
|
+
|
|
131
|
+
let mode: string;
|
|
132
|
+
if (params.ticketAction) {
|
|
133
|
+
mode = params.ticketAction;
|
|
134
|
+
} else if (tasks.length === 0) {
|
|
135
|
+
mode = "manual";
|
|
136
|
+
} else if (params.async) {
|
|
137
|
+
mode = "async";
|
|
138
|
+
} else {
|
|
139
|
+
mode = "sync";
|
|
140
|
+
}
|
|
141
|
+
const taskCount = params.ticketAction ? 0 : tasks.length;
|
|
142
|
+
const callSpan = beginCall({
|
|
143
|
+
parentModel: parentModelId,
|
|
144
|
+
mode,
|
|
145
|
+
taskCount,
|
|
146
|
+
parentSessionFile,
|
|
147
|
+
});
|
|
148
|
+
|
|
149
|
+
function failCall(): void {
|
|
150
|
+
callSpan.finish({
|
|
151
|
+
status: "failed",
|
|
152
|
+
totalTokens: 0,
|
|
153
|
+
totalCost: 0,
|
|
154
|
+
wallMs: Date.now() - callSpan.startedAt,
|
|
155
|
+
});
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
function succeedCall(): void {
|
|
159
|
+
callSpan.finish({
|
|
160
|
+
status: "success",
|
|
161
|
+
totalTokens: 0,
|
|
162
|
+
totalCost: 0,
|
|
163
|
+
wallMs: Date.now() - callSpan.startedAt,
|
|
164
|
+
});
|
|
165
|
+
}
|
|
166
|
+
|
|
48
167
|
// Guard against pi dropping/renaming a symbol this extension imports
|
|
49
168
|
// before any operation-specific validation or early return.
|
|
50
169
|
const compatError = hostCompatError();
|
|
51
|
-
if (compatError)
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
170
|
+
if (compatError) {
|
|
171
|
+
failCall();
|
|
172
|
+
return compatError;
|
|
173
|
+
}
|
|
55
174
|
|
|
56
175
|
const operationResult = validateDelegateOperationResult(
|
|
57
176
|
params,
|
|
58
177
|
parentModelId,
|
|
59
178
|
);
|
|
60
|
-
if (operationResult)
|
|
179
|
+
if (operationResult) {
|
|
180
|
+
failCall();
|
|
181
|
+
return operationResult;
|
|
182
|
+
}
|
|
61
183
|
|
|
62
184
|
// ── Poll action ───────────────────────────────────────────────────
|
|
63
185
|
if (params.ticketAction === "poll") {
|
|
64
|
-
|
|
186
|
+
const result = handlePoll(params, ctx);
|
|
187
|
+
succeedCall();
|
|
188
|
+
return result;
|
|
65
189
|
}
|
|
66
190
|
|
|
67
191
|
// ── Cancel action ─────────────────────────────────────────────────
|
|
@@ -70,12 +194,20 @@ export default function delegateExtension(pi: ExtensionAPI): void {
|
|
|
70
194
|
// A forced cancel flips the ticket to "cancelling" — keep the
|
|
71
195
|
// footer status in step (deduped; the preview path is a no-op).
|
|
72
196
|
syncDelegateStatus(ctx);
|
|
197
|
+
succeedCall();
|
|
73
198
|
return result;
|
|
74
199
|
}
|
|
75
200
|
|
|
76
201
|
// ── Wait action ────────────────────────────────────────────────────
|
|
77
202
|
if (params.ticketAction === "wait") {
|
|
78
|
-
|
|
203
|
+
try {
|
|
204
|
+
const result = await handleWait(params, signal, onUpdate, ctx);
|
|
205
|
+
succeedCall();
|
|
206
|
+
return result;
|
|
207
|
+
} catch (err) {
|
|
208
|
+
failCall();
|
|
209
|
+
throw err;
|
|
210
|
+
}
|
|
79
211
|
}
|
|
80
212
|
|
|
81
213
|
// Agent discovery is intentionally parent-cwd-scoped: agent profiles are a
|
|
@@ -87,6 +219,7 @@ export default function delegateExtension(pi: ExtensionAPI): void {
|
|
|
87
219
|
|
|
88
220
|
// ── Help mode ─────────────────────────────────────────────────
|
|
89
221
|
if (!tasks.length) {
|
|
222
|
+
succeedCall();
|
|
90
223
|
return {
|
|
91
224
|
content: [{ type: "text", text: getSubagentManualMarkdown(agents) }],
|
|
92
225
|
details: {
|
|
@@ -108,25 +241,38 @@ export default function delegateExtension(pi: ExtensionAPI): void {
|
|
|
108
241
|
// across dispatches: edits to auth/models/settings/context files must be
|
|
109
242
|
// visible without restarting Pi.
|
|
110
243
|
invalidateHostDepsCache();
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
244
|
+
try {
|
|
245
|
+
return await dispatchDelegate({
|
|
246
|
+
pi,
|
|
247
|
+
params,
|
|
248
|
+
ctx,
|
|
249
|
+
agents,
|
|
250
|
+
parentModelId,
|
|
251
|
+
parentDefaults: {
|
|
252
|
+
thinking: pi.getThinkingLevel(),
|
|
253
|
+
tools: pi.getActiveTools(),
|
|
254
|
+
},
|
|
255
|
+
signal,
|
|
256
|
+
onUpdate,
|
|
257
|
+
callSpan,
|
|
258
|
+
});
|
|
259
|
+
} catch (err) {
|
|
260
|
+
failCall();
|
|
261
|
+
throw err;
|
|
262
|
+
}
|
|
124
263
|
},
|
|
125
264
|
|
|
126
265
|
renderCall: renderDelegateCall,
|
|
127
266
|
renderResult: renderDelegateResult,
|
|
128
267
|
});
|
|
129
268
|
|
|
269
|
+
// A reload may rebuild the extension runtime without re-invoking this
|
|
270
|
+
// module's default export. Re-open telemetry only after the prior shutdown
|
|
271
|
+
// handler has drained old workers and closed its connection.
|
|
272
|
+
pi.on("session_start", () => {
|
|
273
|
+
prepareTelemetryForSession();
|
|
274
|
+
});
|
|
275
|
+
|
|
130
276
|
// ── Background-work visibility (see status.ts) ──────────────────────────
|
|
131
277
|
// The turn settling with live tickets is the "looks idle but isn't" moment:
|
|
132
278
|
// warn once per ticket. The footer status carries it from there.
|
|
@@ -155,52 +301,137 @@ export default function delegateExtension(pi: ExtensionAPI): void {
|
|
|
155
301
|
|
|
156
302
|
// ── Session shutdown: abort tickets and dispose live pooled sessions ──
|
|
157
303
|
pi.on("session_shutdown", async (event, ctx) => {
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
304
|
+
const shutdownGeneration = getTelemetryGeneration();
|
|
305
|
+
const shutdownDeadline = Date.now() + shutdownDrainTimeoutMs;
|
|
306
|
+
const ticketCompletions: Promise<void>[] = [];
|
|
307
|
+
let drainAttempted = false;
|
|
308
|
+
let telemetrySealed = false;
|
|
309
|
+
|
|
310
|
+
try {
|
|
311
|
+
// Quit and /reload kill background work with no cancellable hook, so
|
|
312
|
+
// leave a trace. For quit the TUI is already stopped — stderr lands in
|
|
313
|
+
// the scrollback. For reload the TUI survives — warn in place. Switch
|
|
314
|
+
// and fork already passed the confirm guard above.
|
|
315
|
+
const active = activeTicketSummary();
|
|
316
|
+
if (active.tickets.length) {
|
|
317
|
+
if (event.reason === "quit") {
|
|
318
|
+
console.error(
|
|
319
|
+
`[delegate] pi exited with ${describeActiveTickets(active)} — aborted.`,
|
|
320
|
+
);
|
|
321
|
+
} else if (event.reason === "reload") {
|
|
322
|
+
try {
|
|
323
|
+
ctx.ui.notify(
|
|
324
|
+
`[delegate] reload aborted ${describeActiveTickets(active)}`,
|
|
325
|
+
"warning",
|
|
326
|
+
);
|
|
327
|
+
} catch (error) {
|
|
328
|
+
console.error(
|
|
329
|
+
"[delegate] reload shutdown notification failed",
|
|
330
|
+
error,
|
|
331
|
+
);
|
|
332
|
+
}
|
|
333
|
+
}
|
|
334
|
+
}
|
|
335
|
+
|
|
336
|
+
for (const ticket of ticketRegistry.values()) {
|
|
337
|
+
if (ticket.status === "running" || ticket.status === "cancelling") {
|
|
338
|
+
cancelTicketForShutdown(ticket);
|
|
339
|
+
}
|
|
340
|
+
// Include already-cancelled tickets too: a repeated shutdown event can
|
|
341
|
+
// race the first handler while its workers are still unwinding.
|
|
342
|
+
if (ticket.completion) ticketCompletions.push(ticket.completion);
|
|
343
|
+
}
|
|
344
|
+
syncDelegateStatus(ctx);
|
|
345
|
+
// The runtime is invalidated right after this handler returns; aborted
|
|
346
|
+
// tickets keep unwinding asynchronously and must find no cached ctx (or
|
|
347
|
+
// captured pi) to touch. The cancelled completion path still writes one
|
|
348
|
+
// final aggregate after late task results arrive, but never delivers UI.
|
|
349
|
+
clearDelegateStatusContext();
|
|
350
|
+
// A replacement session starts on its own leaf; stale tracking would make
|
|
351
|
+
// every ticket look cross-leaf (or, worse, look same-leaf by accident).
|
|
352
|
+
resetLeafTracking();
|
|
353
|
+
// Do NOT clear the ticket registry here — completed tickets are retained
|
|
354
|
+
// until their TTL cleanup. Pooled AgentSessions, however, own listeners
|
|
355
|
+
// and must be disposed before the parent session exits.
|
|
356
|
+
//
|
|
357
|
+
// Start pooled cleanup before waiting for ticket completion. Its immediate
|
|
358
|
+
// abort requests can help a worker blocked on a pooled session unwind;
|
|
359
|
+
// SQLite still stays open until both cleanup paths finish.
|
|
360
|
+
let poolCleanup: Promise<void>;
|
|
361
|
+
try {
|
|
362
|
+
poolCleanup = closeAllPooledAgents();
|
|
363
|
+
} catch (error) {
|
|
364
|
+
console.error("[delegate] pooled-session shutdown start failed", error);
|
|
365
|
+
poolCleanup = Promise.resolve();
|
|
366
|
+
}
|
|
367
|
+
// Drain cooperatively, but never let a provider/tool hold reload or
|
|
368
|
+
// quit forever. A timed-out old runtime is sealed before a new one can
|
|
369
|
+
// reopen telemetry; its eventual task completion remains harmless.
|
|
370
|
+
drainAttempted = true;
|
|
371
|
+
const drain = await drainAsyncTickets(
|
|
372
|
+
ticketCompletions,
|
|
373
|
+
Math.max(0, shutdownDeadline - Date.now()),
|
|
374
|
+
);
|
|
375
|
+
if (!drain.drained) {
|
|
165
376
|
console.error(
|
|
166
|
-
`[delegate]
|
|
377
|
+
`[delegate] async-ticket shutdown drain exceeded ${shutdownDrainTimeoutMs}ms; continuing without late results`,
|
|
167
378
|
);
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
379
|
+
// Seal immediately, before waiting on pooled-session cleanup. A worker
|
|
380
|
+
// that finishes during that second phase belongs to the old runtime
|
|
381
|
+
// and must not write into a freshly reopened backend.
|
|
382
|
+
telemetrySealed = sealTelemetryWrites(shutdownGeneration);
|
|
383
|
+
} else if (drain.failures.length) {
|
|
384
|
+
console.error(
|
|
385
|
+
"[delegate] async-ticket cleanup failed during shutdown:",
|
|
386
|
+
drain.failures,
|
|
172
387
|
);
|
|
173
388
|
}
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
// captured pi) to touch. See the "cancelled"-at-entry guard in dispatch.
|
|
182
|
-
clearDelegateStatusContext();
|
|
183
|
-
// A replacement session starts on its own leaf; stale tracking would make
|
|
184
|
-
// every ticket look cross-leaf (or, worse, look same-leaf by accident).
|
|
185
|
-
resetLeafTracking();
|
|
186
|
-
// Do NOT clear the ticket registry here — completed tickets are retained
|
|
187
|
-
// until their TTL cleanup. Pooled AgentSessions, however, own listeners
|
|
188
|
-
// and must be disposed before the parent session exits.
|
|
189
|
-
//
|
|
190
|
-
// closeAllPooledAgents attempts every session (Promise.allSettled) before
|
|
191
|
-
// aggregating failures into an AggregateError, so swallowing here does not
|
|
192
|
-
// abandon remaining cleanup. Catch and log so a wedged session's failure
|
|
193
|
-
// stays observable instead of becoming an unhandled rejection — pi.on is
|
|
194
|
-
// EventEmitter-style and does not surface handler rejections — and so
|
|
195
|
-
// shutdown completes even when one pooled session failed to abort/dispose.
|
|
196
|
-
try {
|
|
197
|
-
await closeAllPooledAgents();
|
|
198
|
-
} catch (error) {
|
|
199
|
-
const failures = error instanceof AggregateError ? error.errors : [error];
|
|
200
|
-
console.error(
|
|
201
|
-
"[delegate] pooled-session cleanup failed during shutdown:",
|
|
202
|
-
failures,
|
|
389
|
+
|
|
390
|
+
// closeAllPooledAgents attempts every session (Promise.allSettled) before
|
|
391
|
+
// aggregating failures into an AggregateError, so swallowing here does
|
|
392
|
+
// not abandon remaining cleanup.
|
|
393
|
+
const poolResult = await boundedShutdownCleanup(
|
|
394
|
+
poolCleanup,
|
|
395
|
+
Math.max(0, shutdownDeadline - Date.now()),
|
|
203
396
|
);
|
|
397
|
+
if (poolResult === "timed-out") {
|
|
398
|
+
console.error(
|
|
399
|
+
`[delegate] pooled-session shutdown cleanup exceeded ${shutdownDrainTimeoutMs}ms; continuing`,
|
|
400
|
+
);
|
|
401
|
+
} else if (poolResult === "failed") {
|
|
402
|
+
// closeAllPooledAgents aggregates every session failure before
|
|
403
|
+
// rejecting, so the rejection is already a complete diagnostic.
|
|
404
|
+
try {
|
|
405
|
+
await poolCleanup;
|
|
406
|
+
} catch (error) {
|
|
407
|
+
const failures =
|
|
408
|
+
error instanceof AggregateError ? error.errors : [error];
|
|
409
|
+
console.error(
|
|
410
|
+
"[delegate] pooled-session cleanup failed during shutdown:",
|
|
411
|
+
failures,
|
|
412
|
+
);
|
|
413
|
+
}
|
|
414
|
+
}
|
|
415
|
+
} catch (error) {
|
|
416
|
+
// pi.on is EventEmitter-style and does not surface handler rejections.
|
|
417
|
+
// Keep shutdown moving, but leave the failure visible.
|
|
418
|
+
console.error("[delegate] shutdown cleanup failed", error);
|
|
419
|
+
} finally {
|
|
420
|
+
if (!drainAttempted) {
|
|
421
|
+
// An unexpected earlier cleanup failure must not leave a worker with an
|
|
422
|
+
// unhandled rejection, but this fallback is bounded too.
|
|
423
|
+
await drainAsyncTickets(
|
|
424
|
+
ticketCompletions,
|
|
425
|
+
Math.max(0, shutdownDeadline - Date.now()),
|
|
426
|
+
);
|
|
427
|
+
}
|
|
428
|
+
// Once the bounded drain expires, old workers may still unwind. Seal
|
|
429
|
+
// their generation before closing SQLite so a later runtime cannot
|
|
430
|
+
// receive stale task/call writes.
|
|
431
|
+
if (!telemetrySealed) {
|
|
432
|
+
telemetrySealed = sealTelemetryWrites(shutdownGeneration);
|
|
433
|
+
}
|
|
434
|
+
closeTelemetry(shutdownGeneration);
|
|
204
435
|
}
|
|
205
436
|
});
|
|
206
437
|
}
|
package/lifecycle.ts
CHANGED
|
@@ -27,6 +27,7 @@ import { resolveCwd, validateResumeFromPath } from "./utils.ts";
|
|
|
27
27
|
import { getWholeTaskMaxRetries, getWholeTaskBaseDelayMs } from "./config.ts";
|
|
28
28
|
import { addUsage, emptyUsage } from "./usage.ts";
|
|
29
29
|
import { scheduleDeadline } from "./timer.ts";
|
|
30
|
+
import { recordTask } from "./telemetry.ts";
|
|
30
31
|
|
|
31
32
|
/** Internal seam for lifecycle-level tests without replacing session ownership. */
|
|
32
33
|
type RunAgentSession = typeof runAgentSession;
|
|
@@ -187,8 +188,22 @@ function finishTask(
|
|
|
187
188
|
env: TaskRunEnv,
|
|
188
189
|
p: TaskProgress,
|
|
189
190
|
r: TaskResult,
|
|
191
|
+
task: ResolvedTask,
|
|
192
|
+
retries = 0,
|
|
190
193
|
): TaskResult {
|
|
191
194
|
updateProgressFromResult(p, r);
|
|
195
|
+
if (env.telemetryCallId) {
|
|
196
|
+
recordTask({
|
|
197
|
+
callId: env.telemetryCallId,
|
|
198
|
+
generation: env.telemetryGeneration,
|
|
199
|
+
async: env.async ?? false,
|
|
200
|
+
taskIndex: p.index,
|
|
201
|
+
task,
|
|
202
|
+
progress: p,
|
|
203
|
+
result: r,
|
|
204
|
+
retries,
|
|
205
|
+
});
|
|
206
|
+
}
|
|
192
207
|
env.onStatusChange?.();
|
|
193
208
|
return r;
|
|
194
209
|
}
|
|
@@ -553,7 +568,7 @@ async function runResolvedTaskUnlocked(
|
|
|
553
568
|
try {
|
|
554
569
|
// ── Aborted before we started? ───────────────────────────────────
|
|
555
570
|
if (env.signal?.aborted) {
|
|
556
|
-
return finishTask(env, p, failTask(task, "Aborted"));
|
|
571
|
+
return finishTask(env, p, failTask(task, "Aborted"), task);
|
|
557
572
|
}
|
|
558
573
|
|
|
559
574
|
// ── Session busy guard (defense-in-depth) ────────────────────────
|
|
@@ -563,7 +578,7 @@ async function runResolvedTaskUnlocked(
|
|
|
563
578
|
const busyTicketId = isSessionBusy(task.sessionId);
|
|
564
579
|
if (busyTicketId && busyTicketId !== env.ticketId) {
|
|
565
580
|
const msg = `Session '${task.sessionId}' is already in use by ticket ${busyTicketId}. Each session can only handle one task at a time.`;
|
|
566
|
-
return finishTask(env, p, failTask(task, msg));
|
|
581
|
+
return finishTask(env, p, failTask(task, msg), task);
|
|
567
582
|
}
|
|
568
583
|
}
|
|
569
584
|
|
|
@@ -577,6 +592,7 @@ async function runResolvedTaskUnlocked(
|
|
|
577
592
|
env,
|
|
578
593
|
p,
|
|
579
594
|
failTask(task, "sessionAction='close' requires sessionId."),
|
|
595
|
+
task,
|
|
580
596
|
);
|
|
581
597
|
}
|
|
582
598
|
// The per-session lock for action-based operations is already held by the
|
|
@@ -593,6 +609,7 @@ async function runResolvedTaskUnlocked(
|
|
|
593
609
|
: `Session '${task.sessionId}' not found.`,
|
|
594
610
|
Date.now() - env.delegateStartedAt,
|
|
595
611
|
),
|
|
612
|
+
task,
|
|
596
613
|
);
|
|
597
614
|
}
|
|
598
615
|
|
|
@@ -605,6 +622,7 @@ async function runResolvedTaskUnlocked(
|
|
|
605
622
|
`Active sessions:\n${pool.listPooledAgents().join("\n")}`,
|
|
606
623
|
Date.now() - env.delegateStartedAt,
|
|
607
624
|
),
|
|
625
|
+
task,
|
|
608
626
|
);
|
|
609
627
|
}
|
|
610
628
|
|
|
@@ -821,16 +839,22 @@ async function runResolvedTaskUnlocked(
|
|
|
821
839
|
task,
|
|
822
840
|
err instanceof Error ? err.message : String(err),
|
|
823
841
|
);
|
|
824
|
-
return finishTask(
|
|
825
|
-
|
|
826
|
-
|
|
827
|
-
|
|
828
|
-
|
|
829
|
-
|
|
842
|
+
return finishTask(
|
|
843
|
+
env,
|
|
844
|
+
p,
|
|
845
|
+
{
|
|
846
|
+
...failure,
|
|
847
|
+
durationMs: Math.max(failure.durationMs, Date.now() - taskStartedAt),
|
|
848
|
+
tokens: accumulatedUsage.totalTokens,
|
|
849
|
+
usage: accumulatedUsage,
|
|
850
|
+
},
|
|
851
|
+
task,
|
|
852
|
+
);
|
|
830
853
|
}
|
|
831
854
|
|
|
832
855
|
const maxRetries = resolvedWholeTaskMaxRetries();
|
|
833
856
|
const baseDelayMs = resolvedWholeTaskBaseDelayMs();
|
|
857
|
+
let retriesExecuted = 0;
|
|
834
858
|
for (
|
|
835
859
|
let retry = 0;
|
|
836
860
|
retry < maxRetries && canRetryWholeTask(task, result, hasBashExecution);
|
|
@@ -864,6 +888,7 @@ async function runResolvedTaskUnlocked(
|
|
|
864
888
|
p.error = undefined;
|
|
865
889
|
p.failureKind = undefined;
|
|
866
890
|
env.onStatusChange?.();
|
|
891
|
+
retriesExecuted++;
|
|
867
892
|
try {
|
|
868
893
|
result = await runAttempt();
|
|
869
894
|
} catch (err) {
|
|
@@ -881,12 +906,18 @@ async function runResolvedTaskUnlocked(
|
|
|
881
906
|
}
|
|
882
907
|
}
|
|
883
908
|
|
|
884
|
-
return finishTask(
|
|
885
|
-
|
|
886
|
-
|
|
887
|
-
|
|
888
|
-
|
|
889
|
-
|
|
909
|
+
return finishTask(
|
|
910
|
+
env,
|
|
911
|
+
p,
|
|
912
|
+
{
|
|
913
|
+
...result,
|
|
914
|
+
durationMs: Math.max(result.durationMs, Date.now() - taskStartedAt),
|
|
915
|
+
tokens: Math.max(cumulativeTokens, accumulatedUsage.totalTokens),
|
|
916
|
+
usage: accumulatedUsage,
|
|
917
|
+
},
|
|
918
|
+
task,
|
|
919
|
+
retriesExecuted,
|
|
920
|
+
);
|
|
890
921
|
} catch (err) {
|
|
891
922
|
// Any acquired session is released by runAttempt's finally before an
|
|
892
923
|
// exception reaches this boundary. This outer catch handles unexpected
|
|
@@ -896,6 +927,7 @@ async function runResolvedTaskUnlocked(
|
|
|
896
927
|
env,
|
|
897
928
|
p,
|
|
898
929
|
failTask(task, err instanceof Error ? err.message : String(err)),
|
|
930
|
+
task,
|
|
899
931
|
);
|
|
900
932
|
}
|
|
901
933
|
}
|
package/manual.ts
CHANGED
|
@@ -185,8 +185,9 @@ export function getSubagentManualMarkdown(
|
|
|
185
185
|
"```",
|
|
186
186
|
"",
|
|
187
187
|
'- `delegate({ ticketAction: "poll" })` \u2014 list all tickets',
|
|
188
|
-
'- `delegate({ ticketAction: "poll", ticket: "abc123" })` \u2014
|
|
189
|
-
'- `delegate({ ticketAction: "wait", ticket: "abc123"
|
|
188
|
+
'- `delegate({ ticketAction: "poll", ticket: "abc123" })` \u2014 take one progress snapshot',
|
|
189
|
+
'- `delegate({ ticketAction: "wait", ticket: "abc123" })` \u2014 block until finished; omit `timeoutMs` when the result is needed this turn',
|
|
190
|
+
'- `delegate({ ticketAction: "wait", ticket: "abc123", timeoutMs: 600000 })` \u2014 bounded wait; timeout includes the latest snapshot, so do not poll afterward',
|
|
190
191
|
'- `delegate({ ticketAction: "cancel", ticket: "abc123" })` \u2014 preview activity and partial effects before cancelling',
|
|
191
192
|
'- `delegate({ ticketAction: "cancel", ticket: "abc123", force: true })` \u2014 abort after review',
|
|
192
193
|
"",
|
package/package.json
CHANGED
package/schema.ts
CHANGED
|
@@ -139,7 +139,7 @@ export const delegateArgumentsSchema = Type.Object({
|
|
|
139
139
|
Type.Number({
|
|
140
140
|
minimum: 0,
|
|
141
141
|
description:
|
|
142
|
-
"Bounds wait (ms); omit to block until settled. Timeout
|
|
142
|
+
"Bounds wait (ms); omit to block until settled. Timeout returns a snapshot; do not poll afterward.",
|
|
143
143
|
}),
|
|
144
144
|
),
|
|
145
145
|
tasks: Type.Optional(
|