@bermudi/pi-delegate 0.1.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 +92 -0
- package/agents.ts +347 -0
- package/concurrency.ts +126 -0
- package/config.ts +358 -0
- package/constants.ts +41 -0
- package/delegate.ts +115 -0
- package/dispatch.ts +362 -0
- package/extension.ts +126 -0
- package/file-tracking.ts +57 -0
- package/format.ts +506 -0
- package/host-compat.ts +73 -0
- package/host.ts +814 -0
- package/lifecycle.ts +704 -0
- package/manual.ts +184 -0
- package/model.ts +81 -0
- package/package.json +43 -0
- package/parent-context.ts +42 -0
- package/pool.ts +420 -0
- package/render-branches.ts +380 -0
- package/render-result.ts +182 -0
- package/runner.ts +686 -0
- package/schema.ts +289 -0
- package/sessions.ts +102 -0
- package/settings.ts +78 -0
- package/spill.ts +161 -0
- package/task-resolution.ts +321 -0
- package/tickets.ts +795 -0
- package/timer.ts +46 -0
- package/tools.ts +41 -0
- package/types.ts +243 -0
- package/usage.ts +121 -0
- package/utils.ts +131 -0
package/lifecycle.ts
ADDED
|
@@ -0,0 +1,704 @@
|
|
|
1
|
+
import * as fs from "node:fs";
|
|
2
|
+
import {
|
|
3
|
+
createAgentSession,
|
|
4
|
+
SessionManager,
|
|
5
|
+
type AgentSession,
|
|
6
|
+
} from "@earendil-works/pi-coding-agent";
|
|
7
|
+
import type {
|
|
8
|
+
AgentProgressUpdate,
|
|
9
|
+
AcquiredSession,
|
|
10
|
+
ResolvedTask,
|
|
11
|
+
TaskProgress,
|
|
12
|
+
TaskResult,
|
|
13
|
+
TaskRunEnv,
|
|
14
|
+
} from "./types.ts";
|
|
15
|
+
import * as pool from "./pool.ts";
|
|
16
|
+
import { isSessionBusy } from "./tickets.ts";
|
|
17
|
+
import {
|
|
18
|
+
createSubagentSessionManager,
|
|
19
|
+
setParentSession,
|
|
20
|
+
persistSessionHeader,
|
|
21
|
+
} from "./sessions.ts";
|
|
22
|
+
import { runAgentSession } from "./runner.ts";
|
|
23
|
+
import { getGitChangedFiles } from "./file-tracking.ts";
|
|
24
|
+
import { getHostDeps } from "./host.ts";
|
|
25
|
+
import { resolveCwd, validateResumeFromPath } from "./utils.ts";
|
|
26
|
+
import { getWholeTaskMaxRetries, getWholeTaskBaseDelayMs } from "./config.ts";
|
|
27
|
+
import { addUsage, emptyUsage } from "./usage.ts";
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* Test-only overrides for whole-task retry settings. When set, these bypass
|
|
31
|
+
* the config-driven values so retry integration tests don't sleep real seconds.
|
|
32
|
+
* Set via `_setWholeTaskRetryForTesting`.
|
|
33
|
+
*/
|
|
34
|
+
let testWholeTaskMaxRetries: number | undefined;
|
|
35
|
+
let testWholeTaskBaseDelayMs: number | undefined;
|
|
36
|
+
|
|
37
|
+
/** @internal Test-only override for whole-task retry count and base delay. */
|
|
38
|
+
export function _setWholeTaskRetryForTesting(
|
|
39
|
+
opts:
|
|
40
|
+
| {
|
|
41
|
+
maxRetries?: number;
|
|
42
|
+
baseDelayMs?: number;
|
|
43
|
+
}
|
|
44
|
+
| undefined,
|
|
45
|
+
): void {
|
|
46
|
+
testWholeTaskMaxRetries = opts?.maxRetries;
|
|
47
|
+
testWholeTaskBaseDelayMs = opts?.baseDelayMs;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
function resolvedWholeTaskMaxRetries(): number {
|
|
51
|
+
return testWholeTaskMaxRetries ?? getWholeTaskMaxRetries();
|
|
52
|
+
}
|
|
53
|
+
function resolvedWholeTaskBaseDelayMs(): number {
|
|
54
|
+
return testWholeTaskBaseDelayMs ?? getWholeTaskBaseDelayMs();
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/** Build a failed TaskResult. Used for early-failure paths (abort, busy, validation). */
|
|
58
|
+
function failTask(
|
|
59
|
+
task: ResolvedTask,
|
|
60
|
+
error: string,
|
|
61
|
+
sessionFile?: string,
|
|
62
|
+
): TaskResult {
|
|
63
|
+
return {
|
|
64
|
+
agent: task.agentName,
|
|
65
|
+
output: "",
|
|
66
|
+
error,
|
|
67
|
+
durationMs: 0,
|
|
68
|
+
tokens: 0,
|
|
69
|
+
usage: emptyUsage(),
|
|
70
|
+
sessionFile,
|
|
71
|
+
touchedFiles: [],
|
|
72
|
+
};
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/** Build a successful TaskResult for session-management actions (close/list).
|
|
76
|
+
* Pass elapsedMs to record wall time since delegate started (matches the live progress UI). */
|
|
77
|
+
function completeSessionAction(
|
|
78
|
+
task: ResolvedTask,
|
|
79
|
+
output: string,
|
|
80
|
+
elapsedMs?: number,
|
|
81
|
+
): TaskResult {
|
|
82
|
+
return {
|
|
83
|
+
agent: task.agentName,
|
|
84
|
+
output,
|
|
85
|
+
durationMs: elapsedMs ?? 0,
|
|
86
|
+
tokens: 0,
|
|
87
|
+
usage: emptyUsage(),
|
|
88
|
+
sessionFile: undefined,
|
|
89
|
+
touchedFiles: [],
|
|
90
|
+
};
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
/** Dispose a materialized session that remains lifecycle-owned.
|
|
94
|
+
* Pool hits and successfully committed sessions remain pool-owned. */
|
|
95
|
+
function disposeOwnedSession(acquired: AcquiredSession): void {
|
|
96
|
+
if (!acquired.lifecycleOwnsSession) return;
|
|
97
|
+
try {
|
|
98
|
+
acquired.session.dispose();
|
|
99
|
+
} catch (error) {
|
|
100
|
+
// Cleanup must not replace the task's primary result, but it must emit a
|
|
101
|
+
// signal: extension-bearing sessions can retain callbacks/resources when a
|
|
102
|
+
// provider's dispose implementation misbehaves.
|
|
103
|
+
console.error("[delegate] uncommitted subagent disposal failed", error);
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
/** Mirror a progress update from runAgent into a TaskProgress row. */
|
|
108
|
+
export function updateProgressFromRun(
|
|
109
|
+
p: TaskProgress,
|
|
110
|
+
u: AgentProgressUpdate,
|
|
111
|
+
): void {
|
|
112
|
+
p.tokens = u.tokens;
|
|
113
|
+
p.toolUses = u.toolUses;
|
|
114
|
+
p.durationMs = u.durationMs;
|
|
115
|
+
p.lastActivityAt = u.lastActivityAt;
|
|
116
|
+
p.activities = u.activities;
|
|
117
|
+
p.failureKind = u.failureKind;
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
/** Mirror a completed TaskResult into a TaskProgress row (status/duration/error). */
|
|
121
|
+
function updateProgressFromResult(p: TaskProgress, r: TaskResult): void {
|
|
122
|
+
p.status = r.error ? "failed" : "done";
|
|
123
|
+
p.durationMs = r.durationMs;
|
|
124
|
+
p.tokens = r.tokens;
|
|
125
|
+
p.error = r.error;
|
|
126
|
+
p.failureKind = r.failureKind;
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
/** Apply a TaskResult to progress and notify the env (sync fires onUpdate).
|
|
130
|
+
* Used at every return point in runResolvedTask — mirrors the old fire() pattern
|
|
131
|
+
* that the duplicated sync/async bodies used after every early-return. */
|
|
132
|
+
function finishTask(
|
|
133
|
+
env: TaskRunEnv,
|
|
134
|
+
p: TaskProgress,
|
|
135
|
+
r: TaskResult,
|
|
136
|
+
): TaskResult {
|
|
137
|
+
updateProgressFromResult(p, r);
|
|
138
|
+
env.onStatusChange?.();
|
|
139
|
+
return r;
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
/** A failure attributable to the resolved model/provider — not transient for
|
|
143
|
+
* that model, so same-model retry is pointless. Distinguished from a bare
|
|
144
|
+
* transient 429 (per-minute rate limit) by the *account-level* wording:
|
|
145
|
+
* "usage limit", "quota", "upgrade for higher limits", "exceeded your
|
|
146
|
+
* … quota", or an auth/credential failure (401/403 with word boundaries so a
|
|
147
|
+
* port like 4019 doesn't false-positive). The parent should resume with a
|
|
148
|
+
* different `model` (see `resumeFrom` + `model`). */
|
|
149
|
+
export function isModelAttributableError(error: string | undefined): boolean {
|
|
150
|
+
if (!error) return false;
|
|
151
|
+
const e = error.toLowerCase();
|
|
152
|
+
if (e.includes("abort")) return false;
|
|
153
|
+
return (
|
|
154
|
+
e.includes("usage limit") ||
|
|
155
|
+
e.includes("upgrade for higher limits") ||
|
|
156
|
+
e.includes("quota") ||
|
|
157
|
+
e.includes("exceeded your") ||
|
|
158
|
+
e.includes("insufficient credit") ||
|
|
159
|
+
e.includes("insufficient quota") ||
|
|
160
|
+
e.includes("insufficient funds") ||
|
|
161
|
+
e.includes("billing") ||
|
|
162
|
+
e.includes("unauthorized") ||
|
|
163
|
+
e.includes("unauthenticated") ||
|
|
164
|
+
e.includes("authentication") ||
|
|
165
|
+
e.includes("invalid api key") ||
|
|
166
|
+
(e.includes("api key") && e.includes("invalid")) ||
|
|
167
|
+
/\b401\b/.test(e) ||
|
|
168
|
+
/\b403\b/.test(e)
|
|
169
|
+
);
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
function isClearlyTransientFinalError(error: string | undefined): boolean {
|
|
173
|
+
if (!error) return false;
|
|
174
|
+
const e = error.toLowerCase();
|
|
175
|
+
if (e.includes("abort")) return false;
|
|
176
|
+
// A model-attributable error (usage limit, auth) is NOT transient for this
|
|
177
|
+
// model — exclude it so whole-task retry doesn't burn attempts into the same
|
|
178
|
+
// wall. The parent gets one clean failure and a "switch model" hint instead.
|
|
179
|
+
if (isModelAttributableError(error)) return false;
|
|
180
|
+
return (
|
|
181
|
+
/\b429\b/.test(e) ||
|
|
182
|
+
/\b5\d\d\b/.test(e) ||
|
|
183
|
+
e.includes('"code":"1305"') ||
|
|
184
|
+
e.includes("temporarily overloaded") ||
|
|
185
|
+
e.includes("temporarily unavailable") ||
|
|
186
|
+
e.includes("overloaded") ||
|
|
187
|
+
e.includes("rate limit") ||
|
|
188
|
+
e.includes("too many requests") ||
|
|
189
|
+
e.includes("timeout") ||
|
|
190
|
+
e.includes("timed out") ||
|
|
191
|
+
e.includes("connection reset") ||
|
|
192
|
+
e.includes("econnreset") ||
|
|
193
|
+
e.includes("connection refused") ||
|
|
194
|
+
e.includes("network error")
|
|
195
|
+
);
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
function canRetryWholeTask(task: ResolvedTask, result: TaskResult): boolean {
|
|
199
|
+
// Whole-task retry can repeat tool side effects. Keep it to stateless fresh
|
|
200
|
+
// tasks, and only when our touched-file accounting says the failed attempt
|
|
201
|
+
// did not write/edit anything. A `model_error` (usage limit, auth, quota) is
|
|
202
|
+
// not transient for the resolved model — retrying with the same model just
|
|
203
|
+
// hits the same wall, so skip it and let the parent resume with a different
|
|
204
|
+
// model (see the hint in formatFailedTask).
|
|
205
|
+
return (
|
|
206
|
+
result.failureKind !== "stalled" &&
|
|
207
|
+
result.failureKind !== "model_error" &&
|
|
208
|
+
!task.sessionId &&
|
|
209
|
+
!task.resumeFrom &&
|
|
210
|
+
result.touchedFiles.length === 0 &&
|
|
211
|
+
isClearlyTransientFinalError(result.error)
|
|
212
|
+
);
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
async function sleepForWholeTaskRetry(
|
|
216
|
+
signal: AbortSignal | undefined,
|
|
217
|
+
delayMs: number,
|
|
218
|
+
): Promise<void> {
|
|
219
|
+
if (signal?.aborted) return;
|
|
220
|
+
await new Promise<void>((resolve) => {
|
|
221
|
+
let timeout: ReturnType<typeof setTimeout>;
|
|
222
|
+
const done = () => {
|
|
223
|
+
clearTimeout(timeout);
|
|
224
|
+
signal?.removeEventListener("abort", done);
|
|
225
|
+
resolve();
|
|
226
|
+
};
|
|
227
|
+
timeout = setTimeout(done, delayMs);
|
|
228
|
+
if (!signal) return;
|
|
229
|
+
signal.addEventListener("abort", done, { once: true });
|
|
230
|
+
});
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
/** Build the AgentSession for a fresh or resumed subagent via createAgentSession.
|
|
234
|
+
* Reuses the caller-supplied sessionManager (so parent-linking + per-task .jsonl
|
|
235
|
+
* files stay under our control). Extension-free host deps may be cached, while
|
|
236
|
+
* provider-configured or allowlisted-extension deps are session-local because
|
|
237
|
+
* Pi binds mutable extension callbacks onto each loader runtime. */
|
|
238
|
+
async function buildDelegateSession(
|
|
239
|
+
task: ResolvedTask,
|
|
240
|
+
sessionManager: SessionManager,
|
|
241
|
+
modelRegistry: TaskRunEnv["modelRegistry"],
|
|
242
|
+
): Promise<AgentSession> {
|
|
243
|
+
// Resolve host deps for this task's cwd + system prompt. Extension-free
|
|
244
|
+
// resource loaders are cached after the first call; provider-configured or
|
|
245
|
+
// allowlisted-extension loaders are deliberately fresh per session because
|
|
246
|
+
// their extension runtime is mutable. The resourceLoader is cwd-scoped (it
|
|
247
|
+
// scans for AGENTS.md/skills) and the system prompt is per named-agent.
|
|
248
|
+
// The custom prompt overrides the default AgentSession system prompt.
|
|
249
|
+
// Pass only the provider needed by this task. This keeps a non-Kilo task
|
|
250
|
+
// from receiving Kilo's provider/auth adapter merely because Kilo is also
|
|
251
|
+
// configured in the parent runtime.
|
|
252
|
+
const providerConfig = modelRegistry.getRegisteredProviderConfig?.(
|
|
253
|
+
task.model.provider,
|
|
254
|
+
);
|
|
255
|
+
const providerConfigs = providerConfig
|
|
256
|
+
? ([[task.model.provider, providerConfig]] as const)
|
|
257
|
+
: [];
|
|
258
|
+
const hostDeps = await getHostDeps({
|
|
259
|
+
cwd: task.cwd,
|
|
260
|
+
systemPrompt: task.systemPrompt,
|
|
261
|
+
providerConfigs,
|
|
262
|
+
modelProvider: task.model.provider,
|
|
263
|
+
});
|
|
264
|
+
|
|
265
|
+
const { session } = await createAgentSession({
|
|
266
|
+
cwd: task.cwd,
|
|
267
|
+
model: task.model,
|
|
268
|
+
thinkingLevel: task.thinking,
|
|
269
|
+
tools: task.tools,
|
|
270
|
+
sessionManager,
|
|
271
|
+
// Extension-free host deps may be shared: the canonical model/auth runtime
|
|
272
|
+
// (reads the same ~/.pi/agent files as the parent), settings manager, and
|
|
273
|
+
// resource loader. Provider-configured or allowlisted-extension tasks get
|
|
274
|
+
// fresh instances so Pi's mutable extension runtime cannot cross-wire
|
|
275
|
+
// sessions. Since pi 0.80.8 `createAgentSession` takes `modelRuntime` in
|
|
276
|
+
// place of the removed `authStorage`/`modelRegistry` options.
|
|
277
|
+
modelRuntime: hostDeps.modelRuntime,
|
|
278
|
+
settingsManager: hostDeps.settingsManager,
|
|
279
|
+
resourceLoader: hostDeps.resourceLoader,
|
|
280
|
+
});
|
|
281
|
+
return session;
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
/** Resolve the agent + session for a task. Single source of truth for pool, resume, and miss logic. */
|
|
285
|
+
async function acquireAgentSession(
|
|
286
|
+
env: TaskRunEnv,
|
|
287
|
+
task: ResolvedTask,
|
|
288
|
+
p: TaskProgress,
|
|
289
|
+
): Promise<AcquiredSession | { error: TaskResult }> {
|
|
290
|
+
let sessionManager: SessionManager | undefined;
|
|
291
|
+
let sessionFile: string | undefined;
|
|
292
|
+
|
|
293
|
+
// ── Pool hit (reuse live stateful session) ───────────────────────────────
|
|
294
|
+
// The SessionPool owns the freeze compare — checkout returns a structured
|
|
295
|
+
// mismatch; lifecycle only formats the error. checkout is pure (no lastUsed
|
|
296
|
+
// bump), so a speculative checkout that bails leaves no trace; lastUsed is
|
|
297
|
+
// bumped by commit() on a successful run.
|
|
298
|
+
if (task.sessionId) {
|
|
299
|
+
const co = pool.checkout(task.sessionId, {
|
|
300
|
+
cwd: task.cwd,
|
|
301
|
+
thinking: task.thinking,
|
|
302
|
+
tools: task.tools,
|
|
303
|
+
...task.reuseIntent,
|
|
304
|
+
});
|
|
305
|
+
if (co.status === "mismatch") {
|
|
306
|
+
const detail = co.mismatches
|
|
307
|
+
.map((m) => `${m.field}: '${m.frozen}' vs '${m.requested}'`)
|
|
308
|
+
.join("; ");
|
|
309
|
+
return {
|
|
310
|
+
error: failTask(
|
|
311
|
+
task,
|
|
312
|
+
`Session '${task.sessionId}' config mismatch. Close and recreate: ${detail}`,
|
|
313
|
+
),
|
|
314
|
+
};
|
|
315
|
+
}
|
|
316
|
+
if (co.status === "hit") {
|
|
317
|
+
// A pooled session has its own accumulated context — resumeFrom pointing
|
|
318
|
+
// elsewhere is contradictory. This folds the old defensive agentPool.has
|
|
319
|
+
// precheck: checkout already told us the session is live.
|
|
320
|
+
if (task.resumeFrom) {
|
|
321
|
+
return {
|
|
322
|
+
error: failTask(
|
|
323
|
+
task,
|
|
324
|
+
`resumeFrom conflicts with active sessionId '${task.sessionId}'. The pooled session has its own accumulated context. Close the session first if you want to resume from a different point.`,
|
|
325
|
+
),
|
|
326
|
+
};
|
|
327
|
+
}
|
|
328
|
+
p.model = co.modelId;
|
|
329
|
+
return {
|
|
330
|
+
session: co.session,
|
|
331
|
+
sessionManager: co.sessionManager,
|
|
332
|
+
sessionFile: co.sessionFile,
|
|
333
|
+
lifecycleOwnsSession: false,
|
|
334
|
+
};
|
|
335
|
+
}
|
|
336
|
+
// status === "miss" → fall through to resume / fresh materialization.
|
|
337
|
+
}
|
|
338
|
+
|
|
339
|
+
// ── Resume from a previous session file ──────────────────────────────────
|
|
340
|
+
// Resume takes precedence over a fresh sessionId miss: resumeFrom points at a
|
|
341
|
+
// concrete prior conversation we must continue, whereas a sessionId miss just
|
|
342
|
+
// means "create a new pooled session under this id".
|
|
343
|
+
if (task.resumeFrom) {
|
|
344
|
+
const resumeFromPathError = validateResumeFromPath(task.resumeFrom);
|
|
345
|
+
if (resumeFromPathError) {
|
|
346
|
+
return {
|
|
347
|
+
error: failTask(
|
|
348
|
+
task,
|
|
349
|
+
`resumeFrom: invalid session path: ${resumeFromPathError}; got ${JSON.stringify(task.resumeFrom)}`,
|
|
350
|
+
),
|
|
351
|
+
};
|
|
352
|
+
}
|
|
353
|
+
const resolvedPath = resolveCwd(task.resumeFrom);
|
|
354
|
+
if (!fs.existsSync(resolvedPath)) {
|
|
355
|
+
return {
|
|
356
|
+
error: failTask(
|
|
357
|
+
task,
|
|
358
|
+
`resumeFrom: file not found: ${resolvedPath}`,
|
|
359
|
+
resolvedPath,
|
|
360
|
+
),
|
|
361
|
+
};
|
|
362
|
+
}
|
|
363
|
+
// Open the existing session and let createAgentSession restore its messages
|
|
364
|
+
// internally (sdk.js reads buildSessionContext().messages + model/thinking).
|
|
365
|
+
let resumed: SessionManager;
|
|
366
|
+
try {
|
|
367
|
+
resumed = SessionManager.open(resolvedPath);
|
|
368
|
+
} catch {
|
|
369
|
+
return {
|
|
370
|
+
error: failTask(
|
|
371
|
+
task,
|
|
372
|
+
`resumeFrom: corrupt session: ${resolvedPath}`,
|
|
373
|
+
resolvedPath,
|
|
374
|
+
),
|
|
375
|
+
};
|
|
376
|
+
}
|
|
377
|
+
// Non-empty sessions have at least the header + the restored branch. An
|
|
378
|
+
// empty/corrupt file surfaces as a session with no restorable messages.
|
|
379
|
+
if (!resumed.buildSessionContext().messages.length) {
|
|
380
|
+
return {
|
|
381
|
+
error: failTask(
|
|
382
|
+
task,
|
|
383
|
+
`resumeFrom: empty session: ${resolvedPath}`,
|
|
384
|
+
resolvedPath,
|
|
385
|
+
),
|
|
386
|
+
};
|
|
387
|
+
}
|
|
388
|
+
|
|
389
|
+
// Link resumed session to parent for /resume discoverability.
|
|
390
|
+
const parentFile = env.parentSessionManager?.getSessionFile?.();
|
|
391
|
+
if (parentFile) setParentSession(resumed, parentFile);
|
|
392
|
+
|
|
393
|
+
const session = await buildDelegateSession(
|
|
394
|
+
task,
|
|
395
|
+
resumed,
|
|
396
|
+
env.modelRegistry,
|
|
397
|
+
);
|
|
398
|
+
return {
|
|
399
|
+
session,
|
|
400
|
+
sessionManager: resumed,
|
|
401
|
+
sessionFile: resolvedPath,
|
|
402
|
+
lifecycleOwnsSession: true,
|
|
403
|
+
};
|
|
404
|
+
}
|
|
405
|
+
|
|
406
|
+
// ── Fresh session (no resume) ────────────────────────────────────────────
|
|
407
|
+
const fresh = createSubagentSessionManager(
|
|
408
|
+
env.parentSessionManager,
|
|
409
|
+
task.cwd,
|
|
410
|
+
);
|
|
411
|
+
if (!fresh) {
|
|
412
|
+
return { error: failTask(task, "Internal: could not create session file") };
|
|
413
|
+
}
|
|
414
|
+
sessionManager = fresh.manager;
|
|
415
|
+
sessionFile = fresh.file;
|
|
416
|
+
|
|
417
|
+
const session = await buildDelegateSession(
|
|
418
|
+
task,
|
|
419
|
+
sessionManager,
|
|
420
|
+
env.modelRegistry,
|
|
421
|
+
);
|
|
422
|
+
return {
|
|
423
|
+
session,
|
|
424
|
+
sessionManager,
|
|
425
|
+
sessionFile,
|
|
426
|
+
lifecycleOwnsSession: true,
|
|
427
|
+
};
|
|
428
|
+
}
|
|
429
|
+
|
|
430
|
+
/**
|
|
431
|
+
* Resolve the `sessionFile` to report on a TaskResult.
|
|
432
|
+
*
|
|
433
|
+
* On failure, the upstream SessionManager may never have written the `.jsonl`
|
|
434
|
+
* (it gates the first write behind an assistant message — a documented contract).
|
|
435
|
+
* A first-call failure (e.g. Cloudflare 524) leaves the planned path uncreated,
|
|
436
|
+
* yet delegate would otherwise report it as if it existed. So when a run failed,
|
|
437
|
+
* force-flush the header first so the path becomes real and resumable.
|
|
438
|
+
*
|
|
439
|
+
* Defense-in-depth: regardless of success/failure, only report the path if the
|
|
440
|
+
* file actually exists on disk, so the TaskResult never points at nothing.
|
|
441
|
+
*/
|
|
442
|
+
function resolveResumableSessionFile(
|
|
443
|
+
sessionFile: string | undefined,
|
|
444
|
+
sessionManager: SessionManager | undefined,
|
|
445
|
+
error: string | undefined,
|
|
446
|
+
): string | undefined {
|
|
447
|
+
if (!sessionFile) return undefined;
|
|
448
|
+
// On failure, the header may not have been written yet — force it now.
|
|
449
|
+
if (error && sessionManager) persistSessionHeader(sessionManager);
|
|
450
|
+
return fs.existsSync(sessionFile) ? sessionFile : undefined;
|
|
451
|
+
}
|
|
452
|
+
|
|
453
|
+
/** Run a single resolved task. Single source of truth for the per-task lifecycle.
|
|
454
|
+
* Used by both sync (params.async === false) and async (params.async === true) paths.
|
|
455
|
+
* When task.sessionId is set, the entire acquire/run/close lifecycle runs under
|
|
456
|
+
* a per-session mutex so concurrent tasks with the same sessionId serialize
|
|
457
|
+
* cleanly. The lock also covers action='close' and the early-busy/abort paths. */
|
|
458
|
+
export async function runResolvedTask(
|
|
459
|
+
env: TaskRunEnv,
|
|
460
|
+
task: ResolvedTask,
|
|
461
|
+
p: TaskProgress,
|
|
462
|
+
taskIndex: number,
|
|
463
|
+
): Promise<TaskResult> {
|
|
464
|
+
if (task.sessionId) {
|
|
465
|
+
return pool.withSessionLock(task.sessionId, () =>
|
|
466
|
+
runResolvedTaskUnlocked(env, task, p, taskIndex),
|
|
467
|
+
);
|
|
468
|
+
}
|
|
469
|
+
return runResolvedTaskUnlocked(env, task, p, taskIndex);
|
|
470
|
+
}
|
|
471
|
+
|
|
472
|
+
async function runResolvedTaskUnlocked(
|
|
473
|
+
env: TaskRunEnv,
|
|
474
|
+
task: ResolvedTask,
|
|
475
|
+
p: TaskProgress,
|
|
476
|
+
taskIndex: number,
|
|
477
|
+
): Promise<TaskResult> {
|
|
478
|
+
try {
|
|
479
|
+
// ── Aborted before we started? ───────────────────────────────────
|
|
480
|
+
if (env.signal?.aborted) {
|
|
481
|
+
return finishTask(env, p, failTask(task, "Aborted"));
|
|
482
|
+
}
|
|
483
|
+
|
|
484
|
+
// ── Session busy guard (defense-in-depth) ────────────────────────
|
|
485
|
+
// Primary validation is in execute() before ticket creation.
|
|
486
|
+
// This catches edge cases where validation missed a conflict.
|
|
487
|
+
if (task.sessionId) {
|
|
488
|
+
const busyTicketId = isSessionBusy(task.sessionId);
|
|
489
|
+
if (busyTicketId && busyTicketId !== env.ticketId) {
|
|
490
|
+
const msg = `Session '${task.sessionId}' is already in use by ticket ${busyTicketId}. Each session can only handle one task at a time.`;
|
|
491
|
+
return finishTask(env, p, failTask(task, msg));
|
|
492
|
+
}
|
|
493
|
+
}
|
|
494
|
+
|
|
495
|
+
p.status = "running";
|
|
496
|
+
p.model = task.model?.id;
|
|
497
|
+
|
|
498
|
+
// ── Session action handling ───────────────────────────────────────
|
|
499
|
+
if (task.action === "close") {
|
|
500
|
+
if (!task.sessionId) {
|
|
501
|
+
return finishTask(
|
|
502
|
+
env,
|
|
503
|
+
p,
|
|
504
|
+
failTask(task, "action='close' requires sessionId."),
|
|
505
|
+
);
|
|
506
|
+
}
|
|
507
|
+
const closed = await pool.closePooledAgent(task.sessionId);
|
|
508
|
+
return finishTask(
|
|
509
|
+
env,
|
|
510
|
+
p,
|
|
511
|
+
completeSessionAction(
|
|
512
|
+
task,
|
|
513
|
+
closed
|
|
514
|
+
? `Session '${task.sessionId}' closed.`
|
|
515
|
+
: `Session '${task.sessionId}' not found.`,
|
|
516
|
+
Date.now() - env.delegateStartedAt,
|
|
517
|
+
),
|
|
518
|
+
);
|
|
519
|
+
}
|
|
520
|
+
|
|
521
|
+
if (task.action === "list") {
|
|
522
|
+
return finishTask(
|
|
523
|
+
env,
|
|
524
|
+
p,
|
|
525
|
+
completeSessionAction(
|
|
526
|
+
task,
|
|
527
|
+
`Active sessions:\n${pool.listPooledAgents().join("\n")}`,
|
|
528
|
+
Date.now() - env.delegateStartedAt,
|
|
529
|
+
),
|
|
530
|
+
);
|
|
531
|
+
}
|
|
532
|
+
|
|
533
|
+
const runAttempt = async (): Promise<TaskResult> => {
|
|
534
|
+
// ── Pool / resume / fresh-agent resolution ────────────────────────
|
|
535
|
+
const acquired = await acquireAgentSession(env, task, p);
|
|
536
|
+
if ("error" in acquired) return acquired.error;
|
|
537
|
+
|
|
538
|
+
// A pool hit is already owned by the pool. Fresh/resumed sessions belong
|
|
539
|
+
// to this attempt until commit() explicitly transfers ownership. Keeping
|
|
540
|
+
// this state local makes cleanup a finally invariant rather than a list
|
|
541
|
+
// of special cases for aborts, stalls, and provider failures.
|
|
542
|
+
let sessionReleased = !acquired.lifecycleOwnsSession;
|
|
543
|
+
try {
|
|
544
|
+
// Re-check abort after acquisition. The pre-acquire check at the top can
|
|
545
|
+
// miss a signal that fires during getHostDeps/createAgentSession/git
|
|
546
|
+
// baseline. runAgentSession re-checks after attaching its listener, but a
|
|
547
|
+
// cancelled ticket should not even start the subagent (no file writes, no
|
|
548
|
+
// pool insert).
|
|
549
|
+
if (env.signal?.aborted) return failTask(task, "Aborted");
|
|
550
|
+
|
|
551
|
+
// Snapshot git status before the run so touchedFiles can diff after.
|
|
552
|
+
// AgentSession owns retry/compaction internally — runAgentSession just
|
|
553
|
+
// drives the prompt and maps events to the progress model.
|
|
554
|
+
const gitBaseline = await getGitChangedFiles(task.cwd);
|
|
555
|
+
let r = await runAgentSession(
|
|
556
|
+
acquired.session,
|
|
557
|
+
task.prompt,
|
|
558
|
+
{ cwd: task.cwd },
|
|
559
|
+
env.signal,
|
|
560
|
+
(u) => env.onProgress(p, u),
|
|
561
|
+
gitBaseline,
|
|
562
|
+
Date.now(),
|
|
563
|
+
);
|
|
564
|
+
|
|
565
|
+
// The signal can fire after the pre-run check or while the runner is
|
|
566
|
+
// collecting post-prompt evidence. Keep cancellation from looking like
|
|
567
|
+
// success; finally below releases any uncommitted session.
|
|
568
|
+
if (env.signal?.aborted && !r.error) {
|
|
569
|
+
r = { ...r, error: "Aborted" };
|
|
570
|
+
}
|
|
571
|
+
|
|
572
|
+
// A stalled prompt was explicitly aborted and is no longer a safe
|
|
573
|
+
// continuation. Close a pooled hit; a fresh/resumed session is still
|
|
574
|
+
// released by the finally below. closePooledAgent removes the pooled
|
|
575
|
+
// entry before surfacing cleanup errors, so a pool-owned session is
|
|
576
|
+
// never directly disposed here.
|
|
577
|
+
if (r.failureKind === "stalled" && task.sessionId) {
|
|
578
|
+
try {
|
|
579
|
+
if (await pool.closePooledAgent(task.sessionId)) {
|
|
580
|
+
sessionReleased = true;
|
|
581
|
+
}
|
|
582
|
+
} catch (error) {
|
|
583
|
+
// Preserve the primary stalled result while logging the cleanup
|
|
584
|
+
// failure explicitly. A pooled session is already removed by the
|
|
585
|
+
// pool; a pool miss remains lifecycle-owned and is handled below.
|
|
586
|
+
console.error(
|
|
587
|
+
`[delegate] failed to dispose stalled pooled session '${task.sessionId}'`,
|
|
588
|
+
error,
|
|
589
|
+
);
|
|
590
|
+
}
|
|
591
|
+
}
|
|
592
|
+
|
|
593
|
+
const sessionFile = resolveResumableSessionFile(
|
|
594
|
+
acquired.sessionFile,
|
|
595
|
+
acquired.sessionManager,
|
|
596
|
+
r.error,
|
|
597
|
+
);
|
|
598
|
+
|
|
599
|
+
// Pool bookkeeping. commit() is the sole mutator: it decides
|
|
600
|
+
// insert-vs-recordUse by map presence (sound because the session lock
|
|
601
|
+
// serializes same-sessionId tasks). A successful insert transfers
|
|
602
|
+
// ownership; a failed insert leaves finally responsible for disposal.
|
|
603
|
+
if (task.sessionId && !r.error) {
|
|
604
|
+
sessionReleased = pool.commit(task.sessionId, {
|
|
605
|
+
session: acquired.session,
|
|
606
|
+
sessionManager: acquired.sessionManager,
|
|
607
|
+
sessionFile: acquired.sessionFile,
|
|
608
|
+
frozen: {
|
|
609
|
+
systemPrompt: task.systemPrompt,
|
|
610
|
+
model: task.model,
|
|
611
|
+
thinking: task.thinking,
|
|
612
|
+
tools: task.tools,
|
|
613
|
+
cwd: task.cwd,
|
|
614
|
+
},
|
|
615
|
+
tokens: r.tokens,
|
|
616
|
+
});
|
|
617
|
+
}
|
|
618
|
+
|
|
619
|
+
return {
|
|
620
|
+
agent: task.agentName,
|
|
621
|
+
output: r.output,
|
|
622
|
+
error: r.error,
|
|
623
|
+
// Classify the failure: the runner sets `stalled` for the inactivity
|
|
624
|
+
// watchdog; here we add `model_error` for failures attributable to
|
|
625
|
+
// the resolved model (usage limit, auth, quota) so the parent gets a
|
|
626
|
+
// "switch model" hint instead of a same-model retry hint, and so
|
|
627
|
+
// canRetryWholeTask skips the pointless same-model retry.
|
|
628
|
+
failureKind:
|
|
629
|
+
r.failureKind ??
|
|
630
|
+
(r.error && isModelAttributableError(r.error)
|
|
631
|
+
? "model_error"
|
|
632
|
+
: undefined),
|
|
633
|
+
durationMs: r.durationMs,
|
|
634
|
+
tokens: r.tokens,
|
|
635
|
+
usage: r.usage,
|
|
636
|
+
sessionFile,
|
|
637
|
+
touchedFiles: r.touchedFiles,
|
|
638
|
+
};
|
|
639
|
+
} finally {
|
|
640
|
+
// This runs for ordinary success, normal provider failure, whole-task
|
|
641
|
+
// retry attempts, abort races, stalls, and unexpected throws. Pool hits
|
|
642
|
+
// remain pool-owned; successful inserts were explicitly released above.
|
|
643
|
+
if (!sessionReleased) disposeOwnedSession(acquired);
|
|
644
|
+
}
|
|
645
|
+
};
|
|
646
|
+
|
|
647
|
+
// The session lock is now taken at the top of runResolvedTask (covers the
|
|
648
|
+
// full acquire/run/close lifecycle), so attempts execute serially per
|
|
649
|
+
// sessionId without needing an inner lock here.
|
|
650
|
+
let result = await runAttempt();
|
|
651
|
+
// Accumulate usage across whole-task retries: the parent pays for every
|
|
652
|
+
// attempt, including the transient failures that retry. Keep tokens tied
|
|
653
|
+
// to the accumulated usage as well; otherwise the final result and the
|
|
654
|
+
// final progress row describe different amounts of work.
|
|
655
|
+
let accumulatedUsage = result.usage;
|
|
656
|
+
result = {
|
|
657
|
+
...result,
|
|
658
|
+
tokens: accumulatedUsage.totalTokens,
|
|
659
|
+
usage: accumulatedUsage,
|
|
660
|
+
};
|
|
661
|
+
const maxRetries = resolvedWholeTaskMaxRetries();
|
|
662
|
+
const baseDelayMs = resolvedWholeTaskBaseDelayMs();
|
|
663
|
+
for (
|
|
664
|
+
let retry = 0;
|
|
665
|
+
retry < maxRetries && canRetryWholeTask(task, result);
|
|
666
|
+
retry++
|
|
667
|
+
) {
|
|
668
|
+
const delayMs = baseDelayMs * 2 ** retry;
|
|
669
|
+
await sleepForWholeTaskRetry(env.signal, delayMs);
|
|
670
|
+
if (env.signal?.aborted) {
|
|
671
|
+
// Preserve any partial output/session path from the last failed attempt
|
|
672
|
+
// while recording that the retry loop was aborted. The task already
|
|
673
|
+
// paid for every completed attempt, including the one before sleep.
|
|
674
|
+
result = {
|
|
675
|
+
...result,
|
|
676
|
+
error: "Aborted",
|
|
677
|
+
tokens: accumulatedUsage.totalTokens,
|
|
678
|
+
usage: accumulatedUsage,
|
|
679
|
+
};
|
|
680
|
+
break;
|
|
681
|
+
}
|
|
682
|
+
p.status = "running";
|
|
683
|
+
p.error = undefined;
|
|
684
|
+
p.failureKind = undefined;
|
|
685
|
+
env.onStatusChange?.();
|
|
686
|
+
result = await runAttempt();
|
|
687
|
+
accumulatedUsage = addUsage(accumulatedUsage, result.usage);
|
|
688
|
+
result = {
|
|
689
|
+
...result,
|
|
690
|
+
tokens: accumulatedUsage.totalTokens,
|
|
691
|
+
usage: accumulatedUsage,
|
|
692
|
+
};
|
|
693
|
+
}
|
|
694
|
+
return finishTask(env, p, result);
|
|
695
|
+
} catch (err) {
|
|
696
|
+
// Any acquired session is released by runAttempt's finally before an
|
|
697
|
+
// exception reaches this boundary.
|
|
698
|
+
return finishTask(
|
|
699
|
+
env,
|
|
700
|
+
p,
|
|
701
|
+
failTask(task, err instanceof Error ? err.message : String(err)),
|
|
702
|
+
);
|
|
703
|
+
}
|
|
704
|
+
}
|