@bermudi/pi-delegate 0.1.2 → 0.1.4
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 +27 -1
- package/concurrency.ts +20 -3
- package/config.ts +62 -1
- package/delegate.ts +4 -0
- package/dispatch.ts +94 -15
- package/extension.ts +292 -61
- package/format.ts +5 -1
- package/host-compat.ts +1 -0
- package/lifecycle.ts +235 -25
- package/manual.ts +3 -2
- package/package.json +1 -1
- package/schema.ts +16 -2
- package/task-resolution.ts +6 -0
- package/telemetry.ts +743 -0
- package/tickets.ts +34 -10
- package/types.ts +22 -0
- package/usage.ts +19 -0
- package/workspace.ts +672 -0
package/README.md
CHANGED
|
@@ -38,6 +38,31 @@ Parent extension/MCP tools are not copied, and project instructions are rebuilt
|
|
|
38
38
|
for the task's `cwd`. Omit `agent` when you want an ad-hoc task using delegate's
|
|
39
39
|
normal inline defaults instead.
|
|
40
40
|
|
|
41
|
+
### Disposable scratch workspace
|
|
42
|
+
|
|
43
|
+
For review, tests, or other commands whose project changes should be thrown
|
|
44
|
+
away, run a one-shot task in a CoW copy:
|
|
45
|
+
|
|
46
|
+
```ts
|
|
47
|
+
delegate({
|
|
48
|
+
tasks: [
|
|
49
|
+
{ prompt: "Review this change and run its tests", workspace: "scratch" },
|
|
50
|
+
],
|
|
51
|
+
});
|
|
52
|
+
```
|
|
53
|
+
|
|
54
|
+
Delegate reflink-copies the containing Git repository beside the original, runs
|
|
55
|
+
the subagent in the corresponding copied directory, then deletes the copy. It
|
|
56
|
+
requires Linux with `/proc/self/fd`, GNU `cp`, and a reflink-capable filesystem
|
|
57
|
+
such as Btrfs; it never falls back to an expensive full copy. Scratch mode
|
|
58
|
+
cannot use `sessionId`,
|
|
59
|
+
`resumeFrom`, session actions, linked Git worktrees, or project symlinks that
|
|
60
|
+
point outside the copied tree.
|
|
61
|
+
|
|
62
|
+
This protects the real project from ordinary relative writes. It is not a
|
|
63
|
+
security sandbox: unrestricted commands and absolute paths can still reach the
|
|
64
|
+
host filesystem.
|
|
65
|
+
|
|
41
66
|
### Token accounting
|
|
42
67
|
|
|
43
68
|
Sync delegate calls report aggregate subagent `Usage` on the tool result, so Pi
|
|
@@ -98,7 +123,8 @@ over an installed extension.
|
|
|
98
123
|
|
|
99
124
|
- **Delegate task** — One item in `delegate({ tasks: [...] })`. This is the
|
|
100
125
|
core unit of work: a prompt plus optional overrides such as `agent`, `tools`,
|
|
101
|
-
`systemPrompt`, `thinking`, `cwd`, `context`, `sessionId`, or
|
|
126
|
+
`systemPrompt`, `thinking`, `cwd`, `context`, `workspace`, `sessionId`, or
|
|
127
|
+
`resumeFrom`.
|
|
102
128
|
`model` is also accepted but should be rare — subagents inherit the parent
|
|
103
129
|
model by default.
|
|
104
130
|
- **Default subagent** — The reserved built-in `agent: "default"` profile. It
|
package/concurrency.ts
CHANGED
|
@@ -115,7 +115,17 @@ async function mapConcurrent<T, R>(
|
|
|
115
115
|
results[i] = await fn(items[i]!, i);
|
|
116
116
|
}
|
|
117
117
|
};
|
|
118
|
-
|
|
118
|
+
// Promise.all would reject as soon as one task throws while sibling workers
|
|
119
|
+
// are still unwinding. Wait for every worker first so callers can safely use
|
|
120
|
+
// this promise as the batch-settled barrier (notably shutdown telemetry).
|
|
121
|
+
const outcomes = await Promise.allSettled(
|
|
122
|
+
Array.from({ length: limit }, () => worker()),
|
|
123
|
+
);
|
|
124
|
+
const rejection = outcomes.find(
|
|
125
|
+
(outcome): outcome is PromiseRejectedResult =>
|
|
126
|
+
outcome.status === "rejected",
|
|
127
|
+
);
|
|
128
|
+
if (rejection) throw rejection.reason;
|
|
119
129
|
return results;
|
|
120
130
|
}
|
|
121
131
|
|
|
@@ -155,8 +165,10 @@ export async function mapConcurrentByModel<T, R>(
|
|
|
155
165
|
group.indices.push(i);
|
|
156
166
|
}
|
|
157
167
|
|
|
158
|
-
// Run all groups in parallel, each with its own concurrency limit + global cap
|
|
159
|
-
|
|
168
|
+
// Run all groups in parallel, each with its own concurrency limit + global cap.
|
|
169
|
+
// As above, wait for every group before surfacing an unexpected rejection so
|
|
170
|
+
// a late sibling cannot mutate a ticket after its completion barrier resolves.
|
|
171
|
+
const outcomes = await Promise.allSettled(
|
|
160
172
|
[...groups.entries()].map(([, group]) => {
|
|
161
173
|
const groupItems = group.indices.map((i) => items[i]!);
|
|
162
174
|
return mapConcurrent(
|
|
@@ -185,5 +197,10 @@ export async function mapConcurrentByModel<T, R>(
|
|
|
185
197
|
);
|
|
186
198
|
}),
|
|
187
199
|
);
|
|
200
|
+
const rejection = outcomes.find(
|
|
201
|
+
(outcome): outcome is PromiseRejectedResult =>
|
|
202
|
+
outcome.status === "rejected",
|
|
203
|
+
);
|
|
204
|
+
if (rejection) throw rejection.reason;
|
|
188
205
|
return results;
|
|
189
206
|
}
|
package/config.ts
CHANGED
|
@@ -8,6 +8,52 @@ import {
|
|
|
8
8
|
OUTPUT_SPILL_THRESHOLD_CHARS,
|
|
9
9
|
} from "./constants.ts";
|
|
10
10
|
|
|
11
|
+
export interface TelemetryConfig {
|
|
12
|
+
/** Whether to record delegate calls to the local SQLite store. Default true. */
|
|
13
|
+
enabled?: boolean;
|
|
14
|
+
/** Path to the SQLite database. Defaults to `~/.pi/agent/delegate-usage.db`. */
|
|
15
|
+
dbPath?: string;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
const DEFAULT_TELEMETRY_CONFIG: TelemetryConfig = {
|
|
19
|
+
enabled: true,
|
|
20
|
+
};
|
|
21
|
+
|
|
22
|
+
function isRecord(value: unknown): value is Record<string, unknown> {
|
|
23
|
+
return value !== null && typeof value === "object" && !Array.isArray(value);
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* Validate the user-editable telemetry block at its boundary. An explicitly
|
|
28
|
+
* malformed block disables telemetry rather than silently turning it on with
|
|
29
|
+
* the default database path. Missing telemetry is different: it means the
|
|
30
|
+
* user did not configure the feature, so the default remains enabled.
|
|
31
|
+
*/
|
|
32
|
+
export function normalizeTelemetryConfig(raw: unknown): TelemetryConfig {
|
|
33
|
+
if (raw === undefined) return { ...DEFAULT_TELEMETRY_CONFIG };
|
|
34
|
+
if (!isRecord(raw)) return { enabled: false };
|
|
35
|
+
|
|
36
|
+
const hasEnabled = Object.prototype.hasOwnProperty.call(raw, "enabled");
|
|
37
|
+
const hasDbPath = Object.prototype.hasOwnProperty.call(raw, "dbPath");
|
|
38
|
+
const enabled = raw.enabled;
|
|
39
|
+
const dbPath = raw.dbPath;
|
|
40
|
+
|
|
41
|
+
if (hasEnabled && typeof enabled !== "boolean") {
|
|
42
|
+
return { enabled: false };
|
|
43
|
+
}
|
|
44
|
+
if (hasDbPath && (typeof dbPath !== "string" || dbPath.trim().length === 0)) {
|
|
45
|
+
return { enabled: false };
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
const normalizedEnabled =
|
|
49
|
+
typeof enabled === "boolean" ? enabled : DEFAULT_TELEMETRY_CONFIG.enabled;
|
|
50
|
+
const normalizedDbPath = typeof dbPath === "string" ? dbPath : undefined;
|
|
51
|
+
return {
|
|
52
|
+
enabled: normalizedEnabled,
|
|
53
|
+
...(normalizedDbPath === undefined ? {} : { dbPath: normalizedDbPath }),
|
|
54
|
+
};
|
|
55
|
+
}
|
|
56
|
+
|
|
11
57
|
export interface DelegateConfig {
|
|
12
58
|
agent: {
|
|
13
59
|
/** Global default model for all agent types. */
|
|
@@ -41,6 +87,8 @@ export interface DelegateConfig {
|
|
|
41
87
|
providerExtensions?: {
|
|
42
88
|
[provider: string]: readonly string[];
|
|
43
89
|
};
|
|
90
|
+
/** Local SQLite telemetry for usage/health analytics. See `telemetry.ts`. */
|
|
91
|
+
telemetry?: TelemetryConfig;
|
|
44
92
|
/** LLM-facing output bounding: over-threshold final output is spilled to a
|
|
45
93
|
* temp file with a tail kept in-context. See `spill.ts`. */
|
|
46
94
|
output?: {
|
|
@@ -83,7 +131,7 @@ function normalizeProviderExtensions(
|
|
|
83
131
|
// `providerExtensions` replaces a provider's entries (it does not append); an
|
|
84
132
|
// empty array is ignored so the default persists.
|
|
85
133
|
const DEFAULT_PROVIDER_EXTENSIONS: Record<string, readonly string[]> = {
|
|
86
|
-
"openai-codex": ["
|
|
134
|
+
"openai-codex": ["git:github.com/bermudi/manaflow-pi-codex"],
|
|
87
135
|
};
|
|
88
136
|
|
|
89
137
|
function resolveProviderExtensions(
|
|
@@ -108,6 +156,9 @@ const DEFAULT_DELEGATE_CONFIG: DelegateConfig = {
|
|
|
108
156
|
wholeTaskBaseDelayMs: 1_000,
|
|
109
157
|
},
|
|
110
158
|
providerExtensions: DEFAULT_PROVIDER_EXTENSIONS,
|
|
159
|
+
telemetry: {
|
|
160
|
+
enabled: true,
|
|
161
|
+
},
|
|
111
162
|
output: {
|
|
112
163
|
spillThresholdChars: OUTPUT_SPILL_THRESHOLD_CHARS,
|
|
113
164
|
spillTailChars: OUTPUT_SPILL_TAIL_CHARS,
|
|
@@ -119,6 +170,7 @@ let __delegateConfig: DelegateConfig = {
|
|
|
119
170
|
...DEFAULT_DELEGATE_CONFIG,
|
|
120
171
|
agent: { ...DEFAULT_DELEGATE_CONFIG.agent },
|
|
121
172
|
concurrency: { ...DEFAULT_DELEGATE_CONFIG.concurrency },
|
|
173
|
+
telemetry: { ...DEFAULT_DELEGATE_CONFIG.telemetry },
|
|
122
174
|
};
|
|
123
175
|
let stallTimeoutOverrideForTesting: number | undefined;
|
|
124
176
|
|
|
@@ -140,6 +192,7 @@ export function loadDelegateConfig(): DelegateConfig {
|
|
|
140
192
|
},
|
|
141
193
|
retry: { ...DEFAULT_DELEGATE_CONFIG.retry, ...(parsed.retry ?? {}) },
|
|
142
194
|
providerExtensions: resolveProviderExtensions(parsed.providerExtensions),
|
|
195
|
+
telemetry: normalizeTelemetryConfig(parsed.telemetry),
|
|
143
196
|
output: { ...DEFAULT_DELEGATE_CONFIG.output, ...(parsed.output ?? {}) },
|
|
144
197
|
} as DelegateConfig;
|
|
145
198
|
} catch {
|
|
@@ -203,6 +256,7 @@ export function _setDelegateConfigForTesting(
|
|
|
203
256
|
...(config.retry ?? {}),
|
|
204
257
|
},
|
|
205
258
|
providerExtensions: resolveProviderExtensions(config.providerExtensions),
|
|
259
|
+
telemetry: normalizeTelemetryConfig(config.telemetry),
|
|
206
260
|
output: {
|
|
207
261
|
...DEFAULT_DELEGATE_CONFIG.output,
|
|
208
262
|
...(config.output ?? {}),
|
|
@@ -356,3 +410,10 @@ export function resolveModelSpec(options: {
|
|
|
356
410
|
(v): v is string => typeof v === "string" && v.length > 0,
|
|
357
411
|
);
|
|
358
412
|
}
|
|
413
|
+
|
|
414
|
+
/** Get the configured telemetry settings. */
|
|
415
|
+
export function getTelemetryConfig(
|
|
416
|
+
config: DelegateConfig = __delegateConfig,
|
|
417
|
+
): TelemetryConfig {
|
|
418
|
+
return normalizeTelemetryConfig(config.telemetry);
|
|
419
|
+
}
|
package/delegate.ts
CHANGED
|
@@ -3,6 +3,7 @@ export { default } from "./extension.ts";
|
|
|
3
3
|
export type {
|
|
4
4
|
AgentConfig,
|
|
5
5
|
SessionAction,
|
|
6
|
+
WorkspaceMode,
|
|
6
7
|
TicketAction,
|
|
7
8
|
DelegateAction,
|
|
8
9
|
DelegateArguments,
|
|
@@ -77,6 +78,8 @@ export {
|
|
|
77
78
|
isCrossLeafTicket,
|
|
78
79
|
} from "./leaf.ts";
|
|
79
80
|
export { runAgentSession } from "./runner.ts";
|
|
81
|
+
export { createScratchWorkspace } from "./workspace.ts";
|
|
82
|
+
export type { ScratchWorkspace } from "./workspace.ts";
|
|
80
83
|
export {
|
|
81
84
|
activeTicketSummary,
|
|
82
85
|
buildStatusText,
|
|
@@ -92,6 +95,7 @@ export type { ActiveTicketSummary } from "./status.ts";
|
|
|
92
95
|
export { getHostDeps, invalidateHostDepsCache } from "./host.ts";
|
|
93
96
|
export type { HostDeps, HostDepsOptions } from "./host.ts";
|
|
94
97
|
export {
|
|
98
|
+
aggregateTaskResults,
|
|
95
99
|
emptyUsage,
|
|
96
100
|
snapshotSessionUsage,
|
|
97
101
|
usageDelta,
|
package/dispatch.ts
CHANGED
|
@@ -12,7 +12,7 @@ import {
|
|
|
12
12
|
import { getConcurrencyLimit, getMaxAsyncTickets } from "./config.ts";
|
|
13
13
|
import { getCurrentLeafId } from "./leaf.ts";
|
|
14
14
|
import { getModelKey, mapConcurrentByModel } from "./concurrency.ts";
|
|
15
|
-
import { sumUsage } from "./usage.ts";
|
|
15
|
+
import { aggregateTaskResults, sumUsage } from "./usage.ts";
|
|
16
16
|
import { runResolvedTask, updateProgressFromRun } from "./lifecycle.ts";
|
|
17
17
|
import {
|
|
18
18
|
fmtDuration,
|
|
@@ -24,6 +24,7 @@ import {
|
|
|
24
24
|
import { validateDelegateOperation } from "./schema.ts";
|
|
25
25
|
import { notifyCrossLeafDelivery, syncDelegateStatus } from "./status.ts";
|
|
26
26
|
import { validateTasks, resolveTasks } from "./task-resolution.ts";
|
|
27
|
+
import type { CallSpan } from "./telemetry.ts";
|
|
27
28
|
import type {
|
|
28
29
|
AgentConfig,
|
|
29
30
|
AsyncTicket,
|
|
@@ -113,6 +114,7 @@ export interface AsyncDispatchInput {
|
|
|
113
114
|
resolved: ResolvedTask[];
|
|
114
115
|
progress: TaskProgress[];
|
|
115
116
|
parentModelId: string | undefined;
|
|
117
|
+
callSpan?: CallSpan;
|
|
116
118
|
}
|
|
117
119
|
|
|
118
120
|
/** Inputs needed by the sync (blocking) dispatch path. */
|
|
@@ -124,6 +126,7 @@ export interface SyncDispatchInput {
|
|
|
124
126
|
parentModelId: string | undefined;
|
|
125
127
|
signal: AbortSignal | undefined;
|
|
126
128
|
fire: () => void;
|
|
129
|
+
callSpan?: CallSpan;
|
|
127
130
|
}
|
|
128
131
|
|
|
129
132
|
/** Inputs for the normal task-validation, resolution, and dispatch path. */
|
|
@@ -136,6 +139,7 @@ export interface DelegateDispatchInput {
|
|
|
136
139
|
parentDefaults: ParentAgentDefaults;
|
|
137
140
|
signal: AbortSignal | undefined;
|
|
138
141
|
onUpdate: AgentToolUpdateCallback<DelegateDetails> | undefined;
|
|
142
|
+
callSpan?: CallSpan;
|
|
139
143
|
}
|
|
140
144
|
|
|
141
145
|
/** Validate, resolve, and dispatch a non-short-circuit delegate operation. */
|
|
@@ -151,11 +155,20 @@ export async function dispatchDelegate(
|
|
|
151
155
|
parentDefaults,
|
|
152
156
|
signal,
|
|
153
157
|
onUpdate,
|
|
158
|
+
callSpan,
|
|
154
159
|
} = input;
|
|
155
160
|
const tasks = params.tasks ?? [];
|
|
156
161
|
|
|
157
162
|
const validationError = validateTasks(tasks, agents, parentModelId);
|
|
158
|
-
if (validationError)
|
|
163
|
+
if (validationError) {
|
|
164
|
+
callSpan?.finish({
|
|
165
|
+
status: "failed",
|
|
166
|
+
totalTokens: 0,
|
|
167
|
+
totalCost: 0,
|
|
168
|
+
wallMs: Date.now() - callSpan.startedAt,
|
|
169
|
+
});
|
|
170
|
+
return validationError;
|
|
171
|
+
}
|
|
159
172
|
|
|
160
173
|
const resolved = resolveTasks(tasks, ctx, agents, parentDefaults);
|
|
161
174
|
const progress = initProgress(resolved);
|
|
@@ -176,6 +189,7 @@ export async function dispatchDelegate(
|
|
|
176
189
|
resolved,
|
|
177
190
|
progress,
|
|
178
191
|
parentModelId,
|
|
192
|
+
callSpan,
|
|
179
193
|
});
|
|
180
194
|
}
|
|
181
195
|
|
|
@@ -187,6 +201,7 @@ export async function dispatchDelegate(
|
|
|
187
201
|
parentModelId,
|
|
188
202
|
signal,
|
|
189
203
|
fire,
|
|
204
|
+
callSpan,
|
|
190
205
|
});
|
|
191
206
|
}
|
|
192
207
|
|
|
@@ -199,17 +214,39 @@ function finishTicketDelivery(pi: ExtensionAPI, ticket: AsyncTicket): void {
|
|
|
199
214
|
}
|
|
200
215
|
}
|
|
201
216
|
|
|
217
|
+
function settleAsyncCall(
|
|
218
|
+
ticket: AsyncTicket,
|
|
219
|
+
callSpan: CallSpan | undefined,
|
|
220
|
+
): void {
|
|
221
|
+
if (!callSpan) return;
|
|
222
|
+
const { totalTokens, totalCost } = aggregateTaskResults(ticket.results);
|
|
223
|
+
const wallMs = (ticket.completedAt ?? Date.now()) - callSpan.startedAt;
|
|
224
|
+
const status =
|
|
225
|
+
ticket.status === "done"
|
|
226
|
+
? "done"
|
|
227
|
+
: ticket.status === "cancelled"
|
|
228
|
+
? "cancelled"
|
|
229
|
+
: "failed";
|
|
230
|
+
callSpan.finish({ status, totalTokens, totalCost, wallMs });
|
|
231
|
+
}
|
|
232
|
+
|
|
202
233
|
/** Fire-and-forget background execution. Registers an `AsyncTicket`, kicks off
|
|
203
234
|
* the concurrent run, and returns the ticket acknowledgment immediately.
|
|
204
235
|
* Results are delivered via `deliverTicketResults` when all tasks settle. */
|
|
205
236
|
export function dispatchAsync(input: AsyncDispatchInput): DelegateToolResult {
|
|
206
|
-
const { pi, ctx, tasks, resolved, progress, parentModelId } = input;
|
|
237
|
+
const { pi, ctx, tasks, resolved, progress, parentModelId, callSpan } = input;
|
|
207
238
|
|
|
208
239
|
sweepTickets();
|
|
209
240
|
const runningCount = [...ticketRegistry.values()].filter(
|
|
210
241
|
(t) => t.status === "running" || t.status === "cancelling",
|
|
211
242
|
).length;
|
|
212
243
|
if (runningCount >= getMaxAsyncTickets()) {
|
|
244
|
+
callSpan?.finish({
|
|
245
|
+
status: "failed",
|
|
246
|
+
totalTokens: 0,
|
|
247
|
+
totalCost: 0,
|
|
248
|
+
wallMs: Date.now() - (callSpan?.startedAt ?? Date.now()),
|
|
249
|
+
});
|
|
213
250
|
return {
|
|
214
251
|
content: [
|
|
215
252
|
{
|
|
@@ -236,8 +273,13 @@ export function dispatchAsync(input: AsyncDispatchInput): DelegateToolResult {
|
|
|
236
273
|
// Leaf affinity for delivery: a ticket that outlives a /tree navigation
|
|
237
274
|
// must not wake the agent on the branch the user moved to (issue #30).
|
|
238
275
|
spawnLeafId: getCurrentLeafId(),
|
|
276
|
+
callId: callSpan?.id,
|
|
277
|
+
callStartedAt: callSpan?.startedAt,
|
|
278
|
+
callRecord: callSpan ? { ...callSpan.baseRecord() } : undefined,
|
|
279
|
+
telemetryGeneration: callSpan?.generation,
|
|
239
280
|
};
|
|
240
281
|
ticketRegistry.set(ticketId, ticket);
|
|
282
|
+
callSpan?.spawn();
|
|
241
283
|
// Footer visibility for the new background work (see status.ts). Uses the
|
|
242
284
|
// ctx cached from the dispatch path in extension.ts — DelegateToolCtx is
|
|
243
285
|
// the intentionally narrowed surface and does not carry `ui`.
|
|
@@ -254,6 +296,9 @@ export function dispatchAsync(input: AsyncDispatchInput): DelegateToolResult {
|
|
|
254
296
|
parentSessionManager: ctx.sessionManager,
|
|
255
297
|
ticketId,
|
|
256
298
|
delegateStartedAt: ticket.created,
|
|
299
|
+
telemetryCallId: callSpan?.id,
|
|
300
|
+
telemetryGeneration: callSpan?.generation,
|
|
301
|
+
async: true,
|
|
257
302
|
onProgress: (p, u) => {
|
|
258
303
|
updateProgressFromRun(p, u);
|
|
259
304
|
notifyWaiters(ticket);
|
|
@@ -271,7 +316,7 @@ export function dispatchAsync(input: AsyncDispatchInput): DelegateToolResult {
|
|
|
271
316
|
// Worker must store the TaskResult back into ticket.results, since
|
|
272
317
|
// formatCompletedTicket/handlePoll read from there. Without the write,
|
|
273
318
|
// completed async tasks would be reported as PENDING.
|
|
274
|
-
mapConcurrentByModel(
|
|
319
|
+
const completion = mapConcurrentByModel(
|
|
275
320
|
resolved,
|
|
276
321
|
(t) => getModelKey(t.model),
|
|
277
322
|
getConcurrencyLimit,
|
|
@@ -283,12 +328,14 @@ export function dispatchAsync(input: AsyncDispatchInput): DelegateToolResult {
|
|
|
283
328
|
ticketSignal,
|
|
284
329
|
)
|
|
285
330
|
.then(() => {
|
|
286
|
-
//
|
|
287
|
-
//
|
|
288
|
-
//
|
|
289
|
-
//
|
|
290
|
-
|
|
291
|
-
|
|
331
|
+
// Shutdown marks the ticket terminal before cooperative worker aborts
|
|
332
|
+
// have finished. Still write one final aggregate after every result has
|
|
333
|
+
// landed; the immediate shutdown snapshot may have missed late usage.
|
|
334
|
+
// The runtime is being torn down, so never attempt UI delivery here.
|
|
335
|
+
if (ticket.status === "cancelled") {
|
|
336
|
+
settleAsyncCall(ticket, callSpan);
|
|
337
|
+
return;
|
|
338
|
+
}
|
|
292
339
|
// All tasks settled — determine final ticket status.
|
|
293
340
|
// Use progress (set by runResolvedTask) for settled-ness so the
|
|
294
341
|
// status reflects work completion, not just result-array density.
|
|
@@ -309,12 +356,17 @@ export function dispatchAsync(input: AsyncDispatchInput): DelegateToolResult {
|
|
|
309
356
|
syncTicketBusyIndex(ticket);
|
|
310
357
|
}
|
|
311
358
|
syncDelegateStatus();
|
|
359
|
+
settleAsyncCall(ticket, callSpan);
|
|
312
360
|
finishTicketDelivery(pi, ticket);
|
|
313
361
|
})
|
|
314
362
|
.catch((err) => {
|
|
315
363
|
// Defense-in-depth — should not happen if individual tasks catch properly.
|
|
316
|
-
//
|
|
317
|
-
|
|
364
|
+
// Even an unexpected worker rejection must leave the shutdown aggregate
|
|
365
|
+
// with every result that did settle, without touching the stale UI.
|
|
366
|
+
if (ticket.status === "cancelled") {
|
|
367
|
+
settleAsyncCall(ticket, callSpan);
|
|
368
|
+
return;
|
|
369
|
+
}
|
|
318
370
|
if (ticket.status === "cancelling") {
|
|
319
371
|
ticket.status = "cancelled";
|
|
320
372
|
} else if (ticket.status === "running") {
|
|
@@ -324,8 +376,10 @@ export function dispatchAsync(input: AsyncDispatchInput): DelegateToolResult {
|
|
|
324
376
|
ticket.completedAt = Date.now();
|
|
325
377
|
syncTicketBusyIndex(ticket);
|
|
326
378
|
syncDelegateStatus();
|
|
379
|
+
settleAsyncCall(ticket, callSpan);
|
|
327
380
|
finishTicketDelivery(pi, ticket);
|
|
328
381
|
});
|
|
382
|
+
ticket.completion = completion;
|
|
329
383
|
|
|
330
384
|
return {
|
|
331
385
|
content: [
|
|
@@ -335,8 +389,8 @@ export function dispatchAsync(input: AsyncDispatchInput): DelegateToolResult {
|
|
|
335
389
|
`Async ticket: ${ticketId}`,
|
|
336
390
|
`${resolved.length} task(s) dispatched · ${runningCount + 1}/${getMaxAsyncTickets()} async slots in use`,
|
|
337
391
|
"",
|
|
338
|
-
"
|
|
339
|
-
`
|
|
392
|
+
"Work is detached. Stop this turn to let final results auto-deliver.",
|
|
393
|
+
`If this turn must block for the result, call once: delegate({ ticketAction: "wait", ticket: "${ticketId}" }) — omit timeoutMs and do not poll`,
|
|
340
394
|
`Cancel if needed: delegate({ ticketAction: "cancel", ticket: "${ticketId}", force: true }) — first call without force is a preview`,
|
|
341
395
|
].join("\n"),
|
|
342
396
|
},
|
|
@@ -357,7 +411,16 @@ export function dispatchAsync(input: AsyncDispatchInput): DelegateToolResult {
|
|
|
357
411
|
export async function dispatchSync(
|
|
358
412
|
input: SyncDispatchInput,
|
|
359
413
|
): Promise<DelegateToolResult> {
|
|
360
|
-
const {
|
|
414
|
+
const {
|
|
415
|
+
ctx,
|
|
416
|
+
tasks,
|
|
417
|
+
resolved,
|
|
418
|
+
progress,
|
|
419
|
+
parentModelId,
|
|
420
|
+
signal,
|
|
421
|
+
fire,
|
|
422
|
+
callSpan,
|
|
423
|
+
} = input;
|
|
361
424
|
|
|
362
425
|
const startedAt = Date.now();
|
|
363
426
|
const syncEnv: TaskRunEnv = {
|
|
@@ -366,6 +429,9 @@ export async function dispatchSync(
|
|
|
366
429
|
parentSessionManager: ctx.sessionManager,
|
|
367
430
|
ticketId: undefined,
|
|
368
431
|
delegateStartedAt: startedAt,
|
|
432
|
+
telemetryCallId: callSpan?.id,
|
|
433
|
+
telemetryGeneration: callSpan?.generation,
|
|
434
|
+
async: false,
|
|
369
435
|
onProgress: (p, u) => {
|
|
370
436
|
updateProgressFromRun(p, u);
|
|
371
437
|
fire();
|
|
@@ -401,6 +467,19 @@ export async function dispatchSync(
|
|
|
401
467
|
);
|
|
402
468
|
if (overlapWarning) parts.push("", overlapWarning);
|
|
403
469
|
|
|
470
|
+
const status = finalResults.some((r) => r.error) ? "failed" : "success";
|
|
471
|
+
const totalTokens = finalResults.reduce((sum, r) => sum + r.tokens, 0);
|
|
472
|
+
const totalCost = finalResults.reduce(
|
|
473
|
+
(sum, r) => sum + r.usage.cost.total,
|
|
474
|
+
0,
|
|
475
|
+
);
|
|
476
|
+
callSpan?.finish({
|
|
477
|
+
status,
|
|
478
|
+
totalTokens,
|
|
479
|
+
totalCost,
|
|
480
|
+
wallMs: Date.now() - callSpan.startedAt,
|
|
481
|
+
});
|
|
482
|
+
|
|
404
483
|
return {
|
|
405
484
|
content: [{ type: "text", text: parts.join("\n\n") }],
|
|
406
485
|
details: {
|