@bermudi/pi-delegate 0.1.1 → 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/README.md +3 -2
- package/agents.ts +163 -17
- package/concurrency.ts +90 -10
- package/config.ts +61 -0
- package/delegate.ts +14 -0
- package/dispatch.ts +126 -21
- package/extension.ts +309 -62
- package/file-tracking.ts +27 -5
- package/format.ts +46 -7
- package/leaf.ts +48 -0
- package/lifecycle.ts +129 -35
- package/manual.ts +38 -10
- package/package.json +4 -1
- package/patches/@marcfargas%2Fpi-test-harness@0.6.1.patch +13 -0
- package/render-branches.ts +31 -13
- package/render-result.ts +12 -0
- package/runner.ts +237 -42
- package/schema.ts +204 -47
- package/status.ts +68 -2
- package/task-resolution.ts +101 -65
- package/telemetry.ts +738 -0
- package/tickets.ts +196 -61
- package/tools.ts +16 -15
- package/types.ts +71 -13
- package/usage.ts +19 -0
package/extension.ts
CHANGED
|
@@ -19,61 +19,195 @@ import {
|
|
|
19
19
|
import { renderDelegateCall, renderDelegateResult } from "./render-result.ts";
|
|
20
20
|
import { hostCompatError } from "./host-compat.ts";
|
|
21
21
|
import { invalidateHostDepsCache } from "./host.ts";
|
|
22
|
+
import { recordTreeNavigation, resetLeafTracking } from "./leaf.ts";
|
|
22
23
|
import { closeAllPooledAgents } from "./pool.ts";
|
|
23
24
|
import {
|
|
24
25
|
activeTicketSummary,
|
|
25
26
|
clearDelegateStatusContext,
|
|
26
27
|
describeActiveTickets,
|
|
27
28
|
guardSessionReplacement,
|
|
29
|
+
guardTreeNavigation,
|
|
28
30
|
notifyActiveTicketsOnSettled,
|
|
29
31
|
syncDelegateStatus,
|
|
30
32
|
} from "./status.ts";
|
|
33
|
+
import {
|
|
34
|
+
beginCall,
|
|
35
|
+
closeTelemetry,
|
|
36
|
+
getTelemetryGeneration,
|
|
37
|
+
prepareTelemetryForSession,
|
|
38
|
+
sealTelemetryWrites,
|
|
39
|
+
} from "./telemetry.ts";
|
|
31
40
|
import type { DelegateArguments } from "./types.ts";
|
|
32
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
|
+
|
|
33
107
|
/** Register the delegate tool and clean up its parent-session resources. */
|
|
34
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
|
+
|
|
35
114
|
pi.registerTool({
|
|
36
115
|
name: "delegate",
|
|
37
116
|
label: "Delegate to Subagents",
|
|
38
117
|
description:
|
|
39
|
-
"Run parallel subagents via tasks:[{prompt}]. Sync returns results; async
|
|
118
|
+
"Run parallel subagents via tasks:[{prompt}]. Sync returns results; async=ticket. tasks:[]=full manual.",
|
|
40
119
|
parameters: delegateArgumentsSchema,
|
|
41
120
|
// Runs before schema validation — recovers stringified `tasks` arrays
|
|
42
121
|
// (a common model mistake that would otherwise be rejected upstream).
|
|
43
122
|
prepareArguments: normalizeDelegateArguments,
|
|
44
123
|
|
|
45
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
|
+
|
|
46
167
|
// Guard against pi dropping/renaming a symbol this extension imports
|
|
47
168
|
// before any operation-specific validation or early return.
|
|
48
169
|
const compatError = hostCompatError();
|
|
49
|
-
if (compatError)
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
170
|
+
if (compatError) {
|
|
171
|
+
failCall();
|
|
172
|
+
return compatError;
|
|
173
|
+
}
|
|
53
174
|
|
|
54
175
|
const operationResult = validateDelegateOperationResult(
|
|
55
176
|
params,
|
|
56
177
|
parentModelId,
|
|
57
178
|
);
|
|
58
|
-
if (operationResult)
|
|
179
|
+
if (operationResult) {
|
|
180
|
+
failCall();
|
|
181
|
+
return operationResult;
|
|
182
|
+
}
|
|
59
183
|
|
|
60
184
|
// ── Poll action ───────────────────────────────────────────────────
|
|
61
|
-
if (params.
|
|
62
|
-
|
|
185
|
+
if (params.ticketAction === "poll") {
|
|
186
|
+
const result = handlePoll(params, ctx);
|
|
187
|
+
succeedCall();
|
|
188
|
+
return result;
|
|
63
189
|
}
|
|
64
190
|
|
|
65
191
|
// ── Cancel action ─────────────────────────────────────────────────
|
|
66
|
-
if (params.
|
|
192
|
+
if (params.ticketAction === "cancel") {
|
|
67
193
|
const result = handleCancel(params);
|
|
68
194
|
// A forced cancel flips the ticket to "cancelling" — keep the
|
|
69
195
|
// footer status in step (deduped; the preview path is a no-op).
|
|
70
196
|
syncDelegateStatus(ctx);
|
|
197
|
+
succeedCall();
|
|
71
198
|
return result;
|
|
72
199
|
}
|
|
73
200
|
|
|
74
201
|
// ── Wait action ────────────────────────────────────────────────────
|
|
75
|
-
if (params.
|
|
76
|
-
|
|
202
|
+
if (params.ticketAction === "wait") {
|
|
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
|
+
}
|
|
77
211
|
}
|
|
78
212
|
|
|
79
213
|
// Agent discovery is intentionally parent-cwd-scoped: agent profiles are a
|
|
@@ -85,6 +219,7 @@ export default function delegateExtension(pi: ExtensionAPI): void {
|
|
|
85
219
|
|
|
86
220
|
// ── Help mode ─────────────────────────────────────────────────
|
|
87
221
|
if (!tasks.length) {
|
|
222
|
+
succeedCall();
|
|
88
223
|
return {
|
|
89
224
|
content: [{ type: "text", text: getSubagentManualMarkdown(agents) }],
|
|
90
225
|
details: {
|
|
@@ -106,25 +241,38 @@ export default function delegateExtension(pi: ExtensionAPI): void {
|
|
|
106
241
|
// across dispatches: edits to auth/models/settings/context files must be
|
|
107
242
|
// visible without restarting Pi.
|
|
108
243
|
invalidateHostDepsCache();
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
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
|
+
}
|
|
122
263
|
},
|
|
123
264
|
|
|
124
265
|
renderCall: renderDelegateCall,
|
|
125
266
|
renderResult: renderDelegateResult,
|
|
126
267
|
});
|
|
127
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
|
+
|
|
128
276
|
// ── Background-work visibility (see status.ts) ──────────────────────────
|
|
129
277
|
// The turn settling with live tickets is the "looks idle but isn't" moment:
|
|
130
278
|
// warn once per ticket. The footer status carries it from there.
|
|
@@ -140,51 +288,150 @@ export default function delegateExtension(pi: ExtensionAPI): void {
|
|
|
140
288
|
guardSessionReplacement(ctx, "fork"),
|
|
141
289
|
);
|
|
142
290
|
|
|
291
|
+
// /tree navigation stays inside the same session: nothing is torn down and
|
|
292
|
+
// live tickets keep running, but their results would land on the branch the
|
|
293
|
+
// user moves to. Ask first, and record the new leaf either way so delivery
|
|
294
|
+
// can detect the mismatch (issue #30). `session_tree` also fires for
|
|
295
|
+
// extension-driven ctx.navigateTree, which never reaches the guard.
|
|
296
|
+
pi.on("session_before_tree", (_event, ctx) => guardTreeNavigation(ctx));
|
|
297
|
+
pi.on("session_tree", (event, ctx) => {
|
|
298
|
+
recordTreeNavigation(event.newLeafId);
|
|
299
|
+
syncDelegateStatus(ctx);
|
|
300
|
+
});
|
|
301
|
+
|
|
143
302
|
// ── Session shutdown: abort tickets and dispose live pooled sessions ──
|
|
144
303
|
pi.on("session_shutdown", async (event, ctx) => {
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
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) {
|
|
152
376
|
console.error(
|
|
153
|
-
`[delegate]
|
|
377
|
+
`[delegate] async-ticket shutdown drain exceeded ${shutdownDrainTimeoutMs}ms; continuing without late results`,
|
|
154
378
|
);
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
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,
|
|
159
387
|
);
|
|
160
388
|
}
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
// captured pi) to touch. See the "cancelled"-at-entry guard in dispatch.
|
|
169
|
-
clearDelegateStatusContext();
|
|
170
|
-
// Do NOT clear the ticket registry here — completed tickets are retained
|
|
171
|
-
// until their TTL cleanup. Pooled AgentSessions, however, own listeners
|
|
172
|
-
// and must be disposed before the parent session exits.
|
|
173
|
-
//
|
|
174
|
-
// closeAllPooledAgents attempts every session (Promise.allSettled) before
|
|
175
|
-
// aggregating failures into an AggregateError, so swallowing here does not
|
|
176
|
-
// abandon remaining cleanup. Catch and log so a wedged session's failure
|
|
177
|
-
// stays observable instead of becoming an unhandled rejection — pi.on is
|
|
178
|
-
// EventEmitter-style and does not surface handler rejections — and so
|
|
179
|
-
// shutdown completes even when one pooled session failed to abort/dispose.
|
|
180
|
-
try {
|
|
181
|
-
await closeAllPooledAgents();
|
|
182
|
-
} catch (error) {
|
|
183
|
-
const failures = error instanceof AggregateError ? error.errors : [error];
|
|
184
|
-
console.error(
|
|
185
|
-
"[delegate] pooled-session cleanup failed during shutdown:",
|
|
186
|
-
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()),
|
|
187
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);
|
|
188
435
|
}
|
|
189
436
|
});
|
|
190
437
|
}
|
package/file-tracking.ts
CHANGED
|
@@ -2,9 +2,19 @@ import { execFile } from "node:child_process";
|
|
|
2
2
|
import * as path from "node:path";
|
|
3
3
|
import type { ToolActivity } from "./types.ts";
|
|
4
4
|
|
|
5
|
-
/**
|
|
6
|
-
*
|
|
7
|
-
|
|
5
|
+
/**
|
|
6
|
+
* Return absolute paths reported as changed by Git in the task cwd.
|
|
7
|
+
*
|
|
8
|
+
* Touched-file tracking is best-effort, not authoritative. On success this
|
|
9
|
+
* returns the set of changed paths (possibly empty for a clean repo). On
|
|
10
|
+
* failure (non-git directory, git unavailable, timeout) it returns `undefined`
|
|
11
|
+
* so callers can tell "git failed" from "clean repo". A failed baseline
|
|
12
|
+
* suppresses git-based attribution in the runner; only explicit edit/write tool
|
|
13
|
+
* activity is captured by {@link extractTouchedFromActivities}.
|
|
14
|
+
*/
|
|
15
|
+
export async function getGitChangedFiles(
|
|
16
|
+
cwd: string,
|
|
17
|
+
): Promise<Set<string> | undefined> {
|
|
8
18
|
try {
|
|
9
19
|
const runGit = (args: string[]) =>
|
|
10
20
|
new Promise<string>((resolve, reject) => {
|
|
@@ -37,11 +47,22 @@ export async function getGitChangedFiles(cwd: string): Promise<Set<string>> {
|
|
|
37
47
|
}
|
|
38
48
|
return files;
|
|
39
49
|
} catch {
|
|
40
|
-
return
|
|
50
|
+
return undefined;
|
|
41
51
|
}
|
|
42
52
|
}
|
|
43
53
|
|
|
44
|
-
/**
|
|
54
|
+
/**
|
|
55
|
+
* Extract file paths from explicit edit/write tool calls in the activity log.
|
|
56
|
+
*
|
|
57
|
+
* This is the reliable, activity-based contribution to touched-file tracking.
|
|
58
|
+
* Only completed, successful tool calls are counted: an activity must have a
|
|
59
|
+
* terminal `result` and `result.isError` must be false. Interrupted or in-flight
|
|
60
|
+
* calls (no `result`) and failed calls (`result.isError` true) are skipped,
|
|
61
|
+
* because they did not actually mutate the file. bash mutations are NOT captured
|
|
62
|
+
* here; they are only captured by git diff when the task cwd is inside a git
|
|
63
|
+
* repo with git available. The combined touchedFiles list is therefore a lower
|
|
64
|
+
* bound: absence does not mean a file was unchanged.
|
|
65
|
+
*/
|
|
45
66
|
export function extractTouchedFromActivities(
|
|
46
67
|
activities: ToolActivity[],
|
|
47
68
|
cwd: string,
|
|
@@ -49,6 +70,7 @@ export function extractTouchedFromActivities(
|
|
|
49
70
|
const files = new Set<string>();
|
|
50
71
|
for (const a of activities) {
|
|
51
72
|
if (a.name !== "edit" && a.name !== "write") continue;
|
|
73
|
+
if (!a.result || a.result.isError) continue;
|
|
52
74
|
const raw = a.args?.path ?? a.args?.file_path ?? a.args?.filePath;
|
|
53
75
|
if (typeof raw !== "string" || !raw) continue;
|
|
54
76
|
files.add(path.resolve(cwd, raw));
|
package/format.ts
CHANGED
|
@@ -185,6 +185,11 @@ export function trunc(s: string, n: number): string {
|
|
|
185
185
|
return s.length <= n ? s : s.slice(0, n - 1) + "…";
|
|
186
186
|
}
|
|
187
187
|
|
|
188
|
+
/** Render an optional task `id` in a compact, visually distinct form. */
|
|
189
|
+
export function formatTaskId(id: string | undefined): string {
|
|
190
|
+
return id ? ` #${id}` : "";
|
|
191
|
+
}
|
|
192
|
+
|
|
188
193
|
/**
|
|
189
194
|
* Extract a single-line preview of agent output for collapsed final display.
|
|
190
195
|
*
|
|
@@ -340,7 +345,7 @@ function isResumableSessionFile(sessionFile: string): boolean {
|
|
|
340
345
|
* path that didn't exist on disk.
|
|
341
346
|
*
|
|
342
347
|
* Emits:
|
|
343
|
-
* [FAILED|ABORTED: <error> · session: <shortpath> · touched: <files>]
|
|
348
|
+
* [FAILED|ABORTED: <error> · session: <shortpath> · touched (best-effort): <files>]
|
|
344
349
|
* <partial output, when available>
|
|
345
350
|
* → To retry: delegate({ tasks: [{ resumeFrom: "<path>", prompt: "continue" }] })
|
|
346
351
|
*
|
|
@@ -351,12 +356,12 @@ function isResumableSessionFile(sessionFile: string): boolean {
|
|
|
351
356
|
*/
|
|
352
357
|
export function formatFailedTask(r: TaskResult, cwd?: string): string[] {
|
|
353
358
|
const parts: string[] = [];
|
|
354
|
-
const isAbort =
|
|
359
|
+
const isAbort = r.error === "Aborted";
|
|
355
360
|
// Empty string is falsy but not nullish — `||` covers both undefined and "".
|
|
356
361
|
const failParts = [r.error || "unknown error"];
|
|
357
362
|
if (r.sessionFile) failParts.push(`session: ${shortenPath(r.sessionFile)}`);
|
|
358
363
|
const touched = cwd ? relativeTouchedSummary(r.touchedFiles, cwd) : null;
|
|
359
|
-
if (touched) failParts.push(`touched: ${touched}`);
|
|
364
|
+
if (touched) failParts.push(`touched (best-effort): ${touched}`);
|
|
360
365
|
parts.push(`[${isAbort ? "ABORTED" : "FAILED"}: ${failParts.join(" · ")}]`);
|
|
361
366
|
|
|
362
367
|
// Surface partial assistant output even when the task did not complete.
|
|
@@ -398,7 +403,7 @@ export function formatFailedTask(r: TaskResult, cwd?: string): string[] {
|
|
|
398
403
|
* Emits:
|
|
399
404
|
* === <agent>: <truncated prompt> ===
|
|
400
405
|
* [WARNING: <w>] (per warning, if any)
|
|
401
|
-
* [FAILED: ...] / [OK | <duration> | <tokens> tokens · <sessionFile> · touched: <files>]
|
|
406
|
+
* [FAILED: ...] / [OK | <duration> | <tokens> tokens · <sessionFile> · touched (best-effort): <files>]
|
|
402
407
|
*
|
|
403
408
|
* <output> (success body only)
|
|
404
409
|
*
|
|
@@ -410,10 +415,10 @@ export function formatCompletedTask(
|
|
|
410
415
|
result: TaskResult,
|
|
411
416
|
): string[] {
|
|
412
417
|
const parts: string[] = [];
|
|
413
|
-
// `|| task.
|
|
418
|
+
// `|| task.sessionAction` covers action-only tasks (close/list/...) where prompt is
|
|
414
419
|
// empty. Async prompt tasks always set prompt, so this is a no-op there.
|
|
415
420
|
parts.push(
|
|
416
|
-
`=== ${result.agent}: ${trunc(task.prompt || task.
|
|
421
|
+
`=== ${result.agent}${formatTaskId(result.id ?? task.id)}: ${trunc(task.prompt || task.sessionAction || "", 80)} ===`,
|
|
417
422
|
);
|
|
418
423
|
if (task.warnings?.length) {
|
|
419
424
|
for (const w of task.warnings) parts.push(`[WARNING: ${w}]`);
|
|
@@ -426,7 +431,7 @@ export function formatCompletedTask(
|
|
|
426
431
|
];
|
|
427
432
|
if (result.sessionFile) meta.push(shortenPath(result.sessionFile));
|
|
428
433
|
const touched = relativeTouchedSummary(result.touchedFiles, task.cwd);
|
|
429
|
-
if (touched) meta.push(`touched: ${touched}`);
|
|
434
|
+
if (touched) meta.push(`touched (best-effort): ${touched}`);
|
|
430
435
|
parts.push(
|
|
431
436
|
`[${meta.join(" · ")}]\n\n${renderOutputForLLM(result.output, result.agent)}`,
|
|
432
437
|
);
|
|
@@ -504,3 +509,37 @@ export function relativeTouchedSummary(
|
|
|
504
509
|
.filter((f) => f && !f.startsWith(".."));
|
|
505
510
|
return rel.length ? rel.join(", ") : null;
|
|
506
511
|
}
|
|
512
|
+
|
|
513
|
+
/** Find absolute paths directly attributed to more than one task result.
|
|
514
|
+
*
|
|
515
|
+
* Overlap is computed from {@link TaskResult.attributedFiles} (edit/write
|
|
516
|
+
* tool calls), not from {@link TaskResult.touchedFiles}, so concurrent tasks
|
|
517
|
+
* in the same repository do not fabricate false conflicts from shared
|
|
518
|
+
* repository-wide git snapshots. */
|
|
519
|
+
export function findTouchedOverlaps(
|
|
520
|
+
results: readonly { attributedFiles?: string[] }[],
|
|
521
|
+
): string[] {
|
|
522
|
+
const counts = new Map<string, number>();
|
|
523
|
+
for (const r of results) {
|
|
524
|
+
for (const f of r.attributedFiles ?? []) {
|
|
525
|
+
counts.set(f, (counts.get(f) ?? 0) + 1);
|
|
526
|
+
}
|
|
527
|
+
}
|
|
528
|
+
return [...counts.entries()]
|
|
529
|
+
.filter(([, count]) => count > 1)
|
|
530
|
+
.map(([file]) => file)
|
|
531
|
+
.sort();
|
|
532
|
+
}
|
|
533
|
+
|
|
534
|
+
/**
|
|
535
|
+
* Format a post-dispatch overlap warning, or null when there is no overlap.
|
|
536
|
+
*
|
|
537
|
+
* The warning is deliberately conservative: it only reports paths that two or
|
|
538
|
+
* more tasks claimed to touch. It does NOT claim that disjoint touchedFiles
|
|
539
|
+
* mean there was no conflict, and it does NOT claim filesystem isolation or
|
|
540
|
+
* rollback.
|
|
541
|
+
*/
|
|
542
|
+
export function formatTouchedOverlapWarning(overlaps: string[]): string | null {
|
|
543
|
+
if (!overlaps.length) return null;
|
|
544
|
+
return `WARNING: These tasks reported touching the same file(s): ${overlaps.join(", ")}. Delegate does not isolate or serialize file access and does not roll back completed writes.`;
|
|
545
|
+
}
|
package/leaf.ts
ADDED
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Session-tree leaf affinity for async tickets.
|
|
3
|
+
*
|
|
4
|
+
* An async ticket outlives the turn that spawned it. `/tree` navigation moves
|
|
5
|
+
* the session to a different leaf **within the same session file**: no
|
|
6
|
+
* `session_shutdown` fires, the extension runtime stays live, and the ticket
|
|
7
|
+
* keeps running. When it finishes, `deliverTicketResults` wakes the agent at
|
|
8
|
+
* whatever leaf is active *now* — which may be a branch that knows nothing
|
|
9
|
+
* about the task. See GitHub issue #30.
|
|
10
|
+
*
|
|
11
|
+
* pi exposes no "what leaf am I on?" query, so the current leaf has to be
|
|
12
|
+
* tracked from the `session_tree` event (`newLeafId`). That event also fires
|
|
13
|
+
* for extension-driven `ctx.navigateTree`, so this tracking covers navigation
|
|
14
|
+
* that never passed the `session_before_tree` confirm guard.
|
|
15
|
+
*
|
|
16
|
+
* State is runtime-scoped: `resetLeafTracking()` on session shutdown, since a
|
|
17
|
+
* replacement session starts on its own (unknown) leaf.
|
|
18
|
+
*/
|
|
19
|
+
import type { AsyncTicket } from "./types.ts";
|
|
20
|
+
|
|
21
|
+
/** Leaf the session is currently on. `undefined` means no navigation has been
|
|
22
|
+
* observed by this runtime — i.e. the leaf the session opened on. */
|
|
23
|
+
let currentLeafId: string | null | undefined;
|
|
24
|
+
|
|
25
|
+
/** Record a completed `/tree` navigation. */
|
|
26
|
+
export function recordTreeNavigation(newLeafId: string | null): void {
|
|
27
|
+
currentLeafId = newLeafId;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/** Leaf id to stamp on a ticket at spawn time. */
|
|
31
|
+
export function getCurrentLeafId(): string | null | undefined {
|
|
32
|
+
return currentLeafId;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
export function resetLeafTracking(): void {
|
|
36
|
+
currentLeafId = undefined;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/** True when the session has navigated away from the leaf that spawned this
|
|
40
|
+
* ticket, so delivering its result would wake the agent on a foreign branch.
|
|
41
|
+
*
|
|
42
|
+
* Navigating away and back to the spawn leaf yields a fresh leaf id and is
|
|
43
|
+
* therefore reported as cross-leaf. That false positive is deliberate: the
|
|
44
|
+
* cross-leaf path only downgrades delivery to non-waking, and reconstructing
|
|
45
|
+
* true leaf identity across a round trip is not worth the complexity. */
|
|
46
|
+
export function isCrossLeafTicket(ticket: AsyncTicket): boolean {
|
|
47
|
+
return ticket.spawnLeafId !== currentLeafId;
|
|
48
|
+
}
|