mixdog 0.9.86 → 0.9.87
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/package.json +2 -2
- package/src/runtime/agent/orchestrator/agent-trace-format.mjs +4 -2
- package/src/runtime/agent/orchestrator/providers/oauth-usage.mjs +125 -16
- package/src/runtime/agent/orchestrator/session/manager.mjs +3 -0
- package/src/runtime/agent/orchestrator/tools/builtin/builtin-tools.mjs +9 -2
- package/src/runtime/agent/orchestrator/tools/env-scrub.mjs +8 -0
- package/src/runtime/agent/orchestrator/tools/patch-tool-defs.mjs +4 -3
- package/src/runtime/agent/orchestrator/tools/token-manifest.json +23 -2
- package/src/runtime/memory/lib/memory-action-handlers.mjs +4 -1
- package/src/runtime/memory/tool-defs.mjs +19 -3
- package/src/session-runtime/lifecycle-api.mjs +15 -0
- package/src/session-runtime/provider-auth-api.mjs +8 -0
- package/src/session-runtime/provider-usage.mjs +30 -5
- package/src/session-runtime/runtime-core.mjs +267 -481
- package/src/session-runtime/session-lifecycle.mjs +378 -0
- package/src/standalone/agent-shard/shard-pool.mjs +6 -5
- package/src/standalone/agent-tool/job-views.mjs +329 -0
- package/src/standalone/agent-tool/spawn-flow.mjs +629 -0
- package/src/standalone/agent-tool/tag-registry.mjs +340 -0
- package/src/standalone/agent-tool.mjs +100 -1151
- package/src/standalone/usage-dashboard.mjs +23 -1
- package/src/tui/App.jsx +362 -2576
- package/src/tui/app/app-view.jsx +504 -0
- package/src/tui/app/create-app-pickers.mjs +313 -0
- package/src/tui/app/prompt-submit.mjs +501 -0
- package/src/tui/app/shell-layout.mjs +563 -0
- package/src/tui/app/usage-context-panels.mjs +288 -0
- package/src/tui/app/use-copy-selection.mjs +56 -0
- package/src/tui/app/use-global-key-input.mjs +151 -0
- package/src/tui/app/use-pasted-buffers.mjs +115 -0
- package/src/tui/app/use-prompt-draft-flow.mjs +167 -0
- package/src/tui/app/use-prompt-hint.mjs +58 -0
- package/src/tui/app/use-prompt-queue-history.mjs +100 -0
- package/src/tui/app/use-terminal-chrome.mjs +72 -0
- package/src/tui/app/use-transcript-activity.mjs +131 -0
- package/src/tui/app/use-welcome-prompt-hint.mjs +96 -0
- package/src/tui/dist/index.mjs +9802 -9024
- package/src/tui/engine/session-api-ext.mjs +23 -0
|
@@ -0,0 +1,629 @@
|
|
|
1
|
+
// Spawn/job lifecycle, extracted from agent-tool.mjs: job start (foreground
|
|
2
|
+
// deferral, admission control), the progress-idle watchdogs, spawn prep
|
|
3
|
+
// (shard enqueue + in-process fallback paths), and the full runSpawn
|
|
4
|
+
// execution with turn-review collection and terminal accounting.
|
|
5
|
+
import { clean, clearAgentStatuslineRoute, nonNegativeInt, normalizeAgentName, positiveInt, presetKey, readAgentFrontmatterPermission, resolvePrompt, rowMatchesContext, terminalPidForContext, withCwdHeader, writeAgentStatuslineRoute } from './helpers.mjs';
|
|
6
|
+
import { createNotify } from './notify.mjs';
|
|
7
|
+
import { reconcileBackgroundTask, sanitizeTaskMeta, startBackgroundTask } from '../../runtime/shared/background-tasks.mjs';
|
|
8
|
+
import { abnormalEmptyFinishError, renderResult } from './render.mjs';
|
|
9
|
+
import { agentShardEnabled, shardRequest } from '../agent-shard/shard-pool.mjs';
|
|
10
|
+
import { resourceAdmission } from '../../runtime/shared/resource-admission.mjs';
|
|
11
|
+
import { createProgressWatchdogRegistry } from '../agent-watchdog-registry.mjs';
|
|
12
|
+
import { isTerminalWorkerStatus } from './worker-rows.mjs';
|
|
13
|
+
import { resolve } from 'node:path';
|
|
14
|
+
import { normalizeAgentPermission } from '../../runtime/shared/markdown-frontmatter.mjs';
|
|
15
|
+
import { resolveAgentSpawnPreset } from './spawn-preset.mjs';
|
|
16
|
+
import { resolveAgentWatchdogPolicy, resolveHandoffMessageStartIndex, watchdogPartialHandoffFromError } from '../../runtime/agent/orchestrator/agent-runtime/agent-progress-watchdog.mjs';
|
|
17
|
+
import { prepareAgentSession } from '../../runtime/agent/orchestrator/agent-runtime/session-builder.mjs';
|
|
18
|
+
import { AGENT_OWNER } from '../../runtime/agent/orchestrator/agent-owner.mjs';
|
|
19
|
+
export function createSpawnFlow({
|
|
20
|
+
mgr,
|
|
21
|
+
forgetTerminalSession,
|
|
22
|
+
pendingSpawnMeta,
|
|
23
|
+
DEFAULT_SPAWN_PREP_TIMEOUT_MS,
|
|
24
|
+
mergeJobMeta,
|
|
25
|
+
preparedSpawnMeta,
|
|
26
|
+
upsertWorkerSessionDeferred,
|
|
27
|
+
refreshTagsFromSessions,
|
|
28
|
+
readWorkerRows,
|
|
29
|
+
defaultCwd,
|
|
30
|
+
nextTag,
|
|
31
|
+
cancelReap,
|
|
32
|
+
bindTag,
|
|
33
|
+
emitSubagentEvent,
|
|
34
|
+
scheduleReap,
|
|
35
|
+
cfgMod,
|
|
36
|
+
dataDir,
|
|
37
|
+
STANDALONE_SOURCE_ROOT,
|
|
38
|
+
ensureProvider,
|
|
39
|
+
resolveTag,
|
|
40
|
+
wantsSessionScan,
|
|
41
|
+
createTurnReviewCollector,
|
|
42
|
+
}) {
|
|
43
|
+
function closePreparedSpawn(prepared, reason = 'agent-task-cancel') {
|
|
44
|
+
if (!prepared?.session?.id) return;
|
|
45
|
+
try { mgr.closeSession(prepared.session.id, reason); } catch {}
|
|
46
|
+
try { clearAgentStatuslineRoute(prepared.session.id); } catch {}
|
|
47
|
+
forgetTerminalSession(prepared.tag, prepared.session.id);
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
// Owner/worker completion notification lives in ./agent-tool/notify.mjs; the
|
|
51
|
+
// factory captures mgr and registers the canonical completion fallback.
|
|
52
|
+
const { workerNotifyFn, notifyOwnerAgentCompletionEarly } = createNotify(mgr);
|
|
53
|
+
|
|
54
|
+
function startJob(type, meta, run, notifyContext = null) {
|
|
55
|
+
const clientHostPid = terminalPidForContext(notifyContext);
|
|
56
|
+
const jobMeta = sanitizeTaskMeta({
|
|
57
|
+
...(meta || {}),
|
|
58
|
+
...(clientHostPid ? { clientHostPid } : {}),
|
|
59
|
+
});
|
|
60
|
+
let task;
|
|
61
|
+
const admissionController = new AbortController();
|
|
62
|
+
task = startBackgroundTask({
|
|
63
|
+
surface: 'agent',
|
|
64
|
+
operation: type,
|
|
65
|
+
label: jobMeta?.tag || jobMeta?.sessionId || type,
|
|
66
|
+
input: { type, tag: jobMeta?.tag || null, sessionId: jobMeta?.sessionId || null, agent: jobMeta?.agent || null },
|
|
67
|
+
context: notifyContext,
|
|
68
|
+
meta: jobMeta,
|
|
69
|
+
resultType: 'agent_task_result',
|
|
70
|
+
renderResult: (result) => renderResult(result),
|
|
71
|
+
cancel: () => {
|
|
72
|
+
try { admissionController.abort(new Error('agent task cancelled before resource admission')); } catch {}
|
|
73
|
+
const currentMeta = task?.meta || jobMeta;
|
|
74
|
+
if (currentMeta?.sessionId) {
|
|
75
|
+
try { mgr.closeSession(currentMeta.sessionId, 'agent-task-cancel'); } catch {}
|
|
76
|
+
}
|
|
77
|
+
// Sharded spawn: the live session exists only in the shard child —
|
|
78
|
+
// forward the cancel by sessionId (known after the `spawned` progress
|
|
79
|
+
// event) or tag.
|
|
80
|
+
if (agentShardEnabled() && (currentMeta?.tag || currentMeta?.sessionId)) {
|
|
81
|
+
try {
|
|
82
|
+
void shardRequest('cancel', {
|
|
83
|
+
tag: currentMeta?.tag || null,
|
|
84
|
+
sessionId: currentMeta?.sessionId || null,
|
|
85
|
+
}).catch(() => {});
|
|
86
|
+
} catch { /* best-effort */ }
|
|
87
|
+
}
|
|
88
|
+
},
|
|
89
|
+
run: async () => {
|
|
90
|
+
const lease = await resourceAdmission.acquire('agent', {
|
|
91
|
+
signal: admissionController.signal,
|
|
92
|
+
label: jobMeta?.tag || type,
|
|
93
|
+
});
|
|
94
|
+
try {
|
|
95
|
+
// Yield one macrotask before doing agent work. startBackgroundTask uses
|
|
96
|
+
// a Promise microtask, which otherwise begins CPU-heavy spawn prep
|
|
97
|
+
// before the TUI receives/render the "running" result.
|
|
98
|
+
await new Promise((resolve) => setImmediate(resolve));
|
|
99
|
+
if (task?.status === 'cancelled') return null;
|
|
100
|
+
return await resourceAdmission.runWithLease(lease, () => run(task));
|
|
101
|
+
} finally {
|
|
102
|
+
await lease.release();
|
|
103
|
+
}
|
|
104
|
+
},
|
|
105
|
+
});
|
|
106
|
+
return task;
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
function startDeferredSpawnJob(args, callerCwd, context, notifyContext, extras = {}) {
|
|
110
|
+
return startJob('spawn', pendingSpawnMeta(args, extras), async (job) => {
|
|
111
|
+
if (job?.status === 'cancelled') return null;
|
|
112
|
+
// P1 agent sharding (MIXDOG_AGENT_SHARD=1, default off): run the worker
|
|
113
|
+
// session in a shard child process instead of this event loop. Prompt
|
|
114
|
+
// resolution stays here (cheap); prep + the full agent loop run in the
|
|
115
|
+
// shard. Tag/index/watchdog parity lands in P2 (docs/agent-shard-design.md).
|
|
116
|
+
if (agentShardEnabled()) {
|
|
117
|
+
return await runShardSpawn(args, callerCwd, context, job);
|
|
118
|
+
}
|
|
119
|
+
// prepareSpawn (ensureProvider/prepareAgentSession) runs before runSpawn
|
|
120
|
+
// installs its progress watchdog, so guard prep with an internal env-
|
|
121
|
+
// backed cap rather than exposing per-call timeout knobs on the agent
|
|
122
|
+
// tool surface.
|
|
123
|
+
const prepDeadlineMs = nonNegativeInt(args.spawnPrepTimeoutMs ?? args.prepTimeoutMs)
|
|
124
|
+
?? DEFAULT_SPAWN_PREP_TIMEOUT_MS;
|
|
125
|
+
let prepared;
|
|
126
|
+
const prepState = { timedOut: false };
|
|
127
|
+
if (prepDeadlineMs > 0) {
|
|
128
|
+
let prepTimer = null;
|
|
129
|
+
let timedOut = false;
|
|
130
|
+
// If prep wins the race we use its result. If the timeout wins, the
|
|
131
|
+
// prepareSpawn promise may still resolve later with a fully-built
|
|
132
|
+
// session/tag/route — attach a cleanup so the late-arriving prepared is
|
|
133
|
+
// torn down, otherwise the orphaned tag would collide on re-spawn.
|
|
134
|
+
const prepPromise = prepareSpawn(args, callerCwd, context, prepState);
|
|
135
|
+
prepPromise.then((late) => {
|
|
136
|
+
if (timedOut) closePreparedSpawn(late, 'agent-spawn-prep-timeout');
|
|
137
|
+
}, () => {});
|
|
138
|
+
const timeout = new Promise((_resolve, reject) => {
|
|
139
|
+
prepTimer = setTimeout(() => {
|
|
140
|
+
timedOut = true;
|
|
141
|
+
prepState.timedOut = true;
|
|
142
|
+
reject(new Error(`agent spawn prep timed out (${prepDeadlineMs}ms) before model request`));
|
|
143
|
+
}, prepDeadlineMs);
|
|
144
|
+
prepTimer.unref?.();
|
|
145
|
+
});
|
|
146
|
+
try {
|
|
147
|
+
prepared = await Promise.race([prepPromise, timeout]);
|
|
148
|
+
} finally {
|
|
149
|
+
if (prepTimer) clearTimeout(prepTimer);
|
|
150
|
+
}
|
|
151
|
+
} else {
|
|
152
|
+
prepared = await prepareSpawn(args, callerCwd, context, prepState);
|
|
153
|
+
}
|
|
154
|
+
mergeJobMeta(job, preparedSpawnMeta(prepared, extras));
|
|
155
|
+
upsertWorkerSessionDeferred(prepared.session, prepared.tag, {
|
|
156
|
+
...preparedSpawnMeta(prepared, extras),
|
|
157
|
+
status: 'running',
|
|
158
|
+
stage: 'running',
|
|
159
|
+
task_id: job.taskId,
|
|
160
|
+
startedAt: job.startedAt,
|
|
161
|
+
});
|
|
162
|
+
if (job?.status === 'cancelled') {
|
|
163
|
+
closePreparedSpawn(prepared);
|
|
164
|
+
return null;
|
|
165
|
+
}
|
|
166
|
+
return await runSpawn(prepared, notifyContext, job);
|
|
167
|
+
}, notifyContext);
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
// Idle-progress watchdogs (shared sweep timer) live in
|
|
171
|
+
// agent-watchdog-registry.mjs.
|
|
172
|
+
const progressWatchdogs = createProgressWatchdogRegistry({ mgr });
|
|
173
|
+
const startProgressIdleWatchdog = (sessionId, watchdogPolicy, agent = null) =>
|
|
174
|
+
progressWatchdogs.start(sessionId, watchdogPolicy, agent);
|
|
175
|
+
|
|
176
|
+
async function prepareSpawn(args, callerCwd = null, context = {}, prepState = null) {
|
|
177
|
+
// (sharded spawns never reach here — see startDeferredSpawnJob)
|
|
178
|
+
refreshTagsFromSessions({ context });
|
|
179
|
+
return prepareSpawnInProcess(args, callerCwd, context, prepState);
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
// Tag-reuse routing for sharded workers (P2): a same-tag spawn/send while
|
|
183
|
+
// the shard runs the session mirrors the in-process busy-session enqueue.
|
|
184
|
+
// Discriminator: a non-terminal worker-index row whose session has no live
|
|
185
|
+
// runtime in THIS process. (mgr.getSession must not be used here — it
|
|
186
|
+
// falls back to the on-disk session file the shard child persists, which
|
|
187
|
+
// makes a shard-owned session look lead-live.) Returns the rendered queued
|
|
188
|
+
// result, or null to fall through to the normal path (unknown tag,
|
|
189
|
+
// terminal row, lead-owned runtime, or the shard no longer owns the
|
|
190
|
+
// session — e.g. after a shard crash, where respawn is the correct
|
|
191
|
+
// recovery).
|
|
192
|
+
async function tryShardEnqueue(target, args, context, extras = {}) {
|
|
193
|
+
if (!agentShardEnabled()) return null;
|
|
194
|
+
const value = clean(target);
|
|
195
|
+
if (!value) return null;
|
|
196
|
+
const row = readWorkerRows(context).find((r) => (
|
|
197
|
+
(clean(r.tag) === value || clean(r.sessionId) === value)
|
|
198
|
+
&& rowMatchesContext(r, context)
|
|
199
|
+
&& !isTerminalWorkerStatus(r.status || r.stage)
|
|
200
|
+
));
|
|
201
|
+
if (!row) return null;
|
|
202
|
+
const rowSessionId = clean(row.sessionId);
|
|
203
|
+
const runtime = rowSessionId ? mgr.getSessionRuntime?.(rowSessionId) : null;
|
|
204
|
+
if (runtime?.controller?.signal && !runtime.controller.signal.aborted) return null;
|
|
205
|
+
const prompt = await resolvePrompt(args, clean(row.cwd) || defaultCwd || process.cwd());
|
|
206
|
+
if (!clean(prompt)) return null;
|
|
207
|
+
let queued = null;
|
|
208
|
+
try {
|
|
209
|
+
queued = await shardRequest('enqueue', {
|
|
210
|
+
tag: clean(row.tag) || null,
|
|
211
|
+
sessionId: rowSessionId || null,
|
|
212
|
+
prompt,
|
|
213
|
+
});
|
|
214
|
+
} catch { return null; }
|
|
215
|
+
if (queued?.queued !== true) return null;
|
|
216
|
+
return renderResult({
|
|
217
|
+
queued: true,
|
|
218
|
+
...extras,
|
|
219
|
+
tag: clean(row.tag) || null,
|
|
220
|
+
sessionId: rowSessionId || null,
|
|
221
|
+
agent: clean(row.agent) || null,
|
|
222
|
+
queueDepth: queued.queueDepth || 1,
|
|
223
|
+
});
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
async function runShardSpawn(args, callerCwd, context, job) {
|
|
227
|
+
const agent = normalizeAgentName(args.agent);
|
|
228
|
+
if (!agent) throw new Error('agent spawn: agent is required');
|
|
229
|
+
const tag = clean(args.tag) || nextTag(agent, context);
|
|
230
|
+
const baseCwd = resolve(callerCwd || defaultCwd || process.cwd());
|
|
231
|
+
const workerCwd = clean(args.cwd) ? resolve(baseCwd, args.cwd) : baseCwd;
|
|
232
|
+
const prompt = withCwdHeader(await resolvePrompt(args, workerCwd), workerCwd);
|
|
233
|
+
const clientHostPid = terminalPidForContext(context) || null;
|
|
234
|
+
// P2 progress parity: the shard reports the bound session the moment it
|
|
235
|
+
// exists (`spawned`), so the lead mirrors its in-process spawn
|
|
236
|
+
// bookkeeping — tag maps, worker-index row, statusline route, job meta,
|
|
237
|
+
// SubagentStart — while the worker loop runs in the child.
|
|
238
|
+
let spawned = null;
|
|
239
|
+
const onSpawned = (payload = {}) => {
|
|
240
|
+
if (spawned || !payload.sessionId) return;
|
|
241
|
+
spawned = payload;
|
|
242
|
+
const sessionLike = {
|
|
243
|
+
id: payload.sessionId,
|
|
244
|
+
agent,
|
|
245
|
+
cwd: payload.cwd || workerCwd,
|
|
246
|
+
provider: payload.provider || null,
|
|
247
|
+
model: payload.model || null,
|
|
248
|
+
presetName: payload.presetName || null,
|
|
249
|
+
effort: payload.effort || null,
|
|
250
|
+
fast: payload.fast === true,
|
|
251
|
+
clientHostPid,
|
|
252
|
+
status: 'running',
|
|
253
|
+
};
|
|
254
|
+
cancelReap(payload.sessionId);
|
|
255
|
+
writeAgentStatuslineRoute(payload.sessionId, {
|
|
256
|
+
provider: payload.provider || null,
|
|
257
|
+
model: payload.model || null,
|
|
258
|
+
effort: payload.effort || null,
|
|
259
|
+
fast: payload.fast === true,
|
|
260
|
+
});
|
|
261
|
+
bindTag(tag, sessionLike, {
|
|
262
|
+
agent,
|
|
263
|
+
preset: payload.presetName || null,
|
|
264
|
+
provider: payload.provider || null,
|
|
265
|
+
model: payload.model || null,
|
|
266
|
+
effort: payload.effort || null,
|
|
267
|
+
fast: payload.fast === true,
|
|
268
|
+
status: 'running',
|
|
269
|
+
stage: 'running',
|
|
270
|
+
});
|
|
271
|
+
if (job) {
|
|
272
|
+
mergeJobMeta(job, {
|
|
273
|
+
tag,
|
|
274
|
+
sessionId: payload.sessionId,
|
|
275
|
+
agent,
|
|
276
|
+
preset: payload.presetName || null,
|
|
277
|
+
provider: payload.provider || null,
|
|
278
|
+
model: payload.model || null,
|
|
279
|
+
effort: payload.effort || null,
|
|
280
|
+
fast: payload.fast === true,
|
|
281
|
+
});
|
|
282
|
+
}
|
|
283
|
+
emitSubagentEvent('start', agent, { session_id: payload.sessionId, tag });
|
|
284
|
+
// Cancel raced the shard prep: the task is already terminal on the lead
|
|
285
|
+
// but the child just started its loop — abort it now that the sessionId
|
|
286
|
+
// is known, instead of letting it burn tokens to completion.
|
|
287
|
+
if (job?.status === 'cancelled') {
|
|
288
|
+
try { void shardRequest('cancel', { tag, sessionId: payload.sessionId }).catch(() => {}); } catch { /* best-effort */ }
|
|
289
|
+
}
|
|
290
|
+
};
|
|
291
|
+
let finalStatus = 'error';
|
|
292
|
+
try {
|
|
293
|
+
const response = await shardRequest('spawn', {
|
|
294
|
+
args: {
|
|
295
|
+
agent,
|
|
296
|
+
provider: args.provider,
|
|
297
|
+
model: args.model,
|
|
298
|
+
effort: args.effort,
|
|
299
|
+
fast: args.fast,
|
|
300
|
+
preset: args.preset,
|
|
301
|
+
},
|
|
302
|
+
tag,
|
|
303
|
+
cwd: workerCwd,
|
|
304
|
+
prompt,
|
|
305
|
+
context: args.context || null,
|
|
306
|
+
parentSessionId: clean(context?.callerSessionId || context?.sessionId) || null,
|
|
307
|
+
ownerSessionId: clean(context?.callerSessionId || context?.sessionId) || null,
|
|
308
|
+
clientHostPid,
|
|
309
|
+
}, {
|
|
310
|
+
onProgress: (event, payload) => { if (event === 'spawned') onSpawned(payload); },
|
|
311
|
+
});
|
|
312
|
+
// Same abnormal-empty promotion as the in-process completionValue; a
|
|
313
|
+
// watchdog partial handoff (stallAbort) is a valid completion and is
|
|
314
|
+
// exempt, matching runSpawn's catch path.
|
|
315
|
+
const abnormalError = response?.stallAbort === true
|
|
316
|
+
? null
|
|
317
|
+
: abnormalEmptyFinishError(response, agent);
|
|
318
|
+
const value = {
|
|
319
|
+
tag,
|
|
320
|
+
sessionId: response.sessionId,
|
|
321
|
+
agent,
|
|
322
|
+
preset: response.presetName || null,
|
|
323
|
+
provider: response.provider || null,
|
|
324
|
+
model: response.model || null,
|
|
325
|
+
effort: response.effort || null,
|
|
326
|
+
fast: response.fast === true,
|
|
327
|
+
content: response.content || '',
|
|
328
|
+
...(response.stallAbort === true ? { stallAbort: true } : {}),
|
|
329
|
+
...(abnormalError ? { error: abnormalError } : {}),
|
|
330
|
+
};
|
|
331
|
+
if (job) job._terminalResultValue = value;
|
|
332
|
+
if (value.error) throw new Error(value.error);
|
|
333
|
+
finalStatus = 'idle';
|
|
334
|
+
return value;
|
|
335
|
+
} finally {
|
|
336
|
+
// Terminal bookkeeping parity with runSpawn's finally: worker-index row
|
|
337
|
+
// transitions to idle/error, the terminal-reap timer starts (tombstone +
|
|
338
|
+
// statusline cleanup), and SubagentStop fires. Only after `spawned` —
|
|
339
|
+
// a prep failure inside the shard bound nothing on the lead.
|
|
340
|
+
if (spawned?.sessionId) {
|
|
341
|
+
upsertWorkerSessionDeferred({
|
|
342
|
+
id: spawned.sessionId,
|
|
343
|
+
agent,
|
|
344
|
+
cwd: spawned.cwd || workerCwd,
|
|
345
|
+
provider: spawned.provider || null,
|
|
346
|
+
model: spawned.model || null,
|
|
347
|
+
presetName: spawned.presetName || null,
|
|
348
|
+
effort: spawned.effort || null,
|
|
349
|
+
fast: spawned.fast === true,
|
|
350
|
+
clientHostPid,
|
|
351
|
+
status: finalStatus,
|
|
352
|
+
}, tag, {
|
|
353
|
+
agent,
|
|
354
|
+
preset: spawned.presetName || null,
|
|
355
|
+
provider: spawned.provider || null,
|
|
356
|
+
model: spawned.model || null,
|
|
357
|
+
effort: spawned.effort || null,
|
|
358
|
+
fast: spawned.fast === true,
|
|
359
|
+
status: finalStatus,
|
|
360
|
+
stage: finalStatus,
|
|
361
|
+
finishedAt: new Date().toISOString(),
|
|
362
|
+
});
|
|
363
|
+
scheduleReap(spawned.sessionId, spawned.provider || null);
|
|
364
|
+
emitSubagentEvent('stop', agent, { session_id: spawned.sessionId, tag, status: finalStatus });
|
|
365
|
+
}
|
|
366
|
+
}
|
|
367
|
+
}
|
|
368
|
+
|
|
369
|
+
async function prepareSpawnInProcess(args, callerCwd = null, context = {}, prepState = null) {
|
|
370
|
+
const config = cfgMod.loadConfig();
|
|
371
|
+
const agent = normalizeAgentName(args.agent);
|
|
372
|
+
if (!agent) throw new Error('agent spawn: agent is required');
|
|
373
|
+
const agentPermission = readAgentFrontmatterPermission(agent, dataDir, STANDALONE_SOURCE_ROOT);
|
|
374
|
+
const agentPerm = normalizeAgentPermission(agentPermission) || null;
|
|
375
|
+
const { presetName, preset } = resolveAgentSpawnPreset(config, args);
|
|
376
|
+
await ensureProvider(config, preset.provider);
|
|
377
|
+
if (prepState?.timedOut) {
|
|
378
|
+
throw new Error('agent spawn prep timed out before session bind');
|
|
379
|
+
}
|
|
380
|
+
|
|
381
|
+
const tag = clean(args.tag) || nextTag(agent, context);
|
|
382
|
+
// Any resolved same-tag binding in this terminal (live or lingering trace)
|
|
383
|
+
// blocks a fresh spawn. execute() routes live reuse before prepareSpawn.
|
|
384
|
+
if (resolveTag(tag, context, { scanSessions: wantsSessionScan(args) })) {
|
|
385
|
+
throw new Error(`agent spawn: tag "${tag}" already exists`);
|
|
386
|
+
}
|
|
387
|
+
const baseCwd = resolve(callerCwd || defaultCwd || process.cwd());
|
|
388
|
+
const workerCwd = clean(args.cwd) ? resolve(baseCwd, args.cwd) : baseCwd;
|
|
389
|
+
const prompt = withCwdHeader(await resolvePrompt(args, workerCwd), workerCwd);
|
|
390
|
+
if (prepState?.timedOut) {
|
|
391
|
+
throw new Error('agent spawn prep timed out before session bind');
|
|
392
|
+
}
|
|
393
|
+
const runtimeSpec = cfgMod.resolveRuntimeSpec(preset, { lane: 'agent', agentId: tag });
|
|
394
|
+
const maxLoopIterations = positiveInt(args.maxLoopIterations) || null;
|
|
395
|
+
const watchdogPolicy = resolveAgentWatchdogPolicy(agent);
|
|
396
|
+
const { session, effectiveCwd } = prepareAgentSession({
|
|
397
|
+
agent,
|
|
398
|
+
presetName,
|
|
399
|
+
preset,
|
|
400
|
+
runtimeSpec,
|
|
401
|
+
owner: AGENT_OWNER,
|
|
402
|
+
cwd: workerCwd,
|
|
403
|
+
sourceType: 'cli',
|
|
404
|
+
sourceName: agent,
|
|
405
|
+
parentSessionId: clean(context?.callerSessionId || context?.sessionId) || null,
|
|
406
|
+
ownerSessionId: clean(context?.callerSessionId || context?.sessionId) || null,
|
|
407
|
+
clientHostPid: terminalPidForContext(context) || null,
|
|
408
|
+
agentTag: tag,
|
|
409
|
+
taskType: clean(args.taskType) || clean(args.typeHint) || undefined,
|
|
410
|
+
maxLoopIterations: maxLoopIterations || undefined,
|
|
411
|
+
permission: agentPerm || undefined,
|
|
412
|
+
cacheKeyOverride: args.cacheKey || undefined,
|
|
413
|
+
});
|
|
414
|
+
// Lead sessions write a gateway-session route when created; agent sessions
|
|
415
|
+
// are built through prepareAgentSession(), so mirror that registration here
|
|
416
|
+
// or the vendored L1/L2 statusline cannot resolve the agent route/model.
|
|
417
|
+
writeAgentStatuslineRoute(session.id, preset);
|
|
418
|
+
bindTag(tag, session, {
|
|
419
|
+
agent,
|
|
420
|
+
preset: presetKey(preset) || presetName,
|
|
421
|
+
provider: preset.provider,
|
|
422
|
+
model: preset.model,
|
|
423
|
+
effort: preset.effort || null,
|
|
424
|
+
fast: preset.fast === true,
|
|
425
|
+
status: 'idle',
|
|
426
|
+
stage: 'idle',
|
|
427
|
+
});
|
|
428
|
+
cancelReap(session.id);
|
|
429
|
+
return {
|
|
430
|
+
args,
|
|
431
|
+
tag,
|
|
432
|
+
session,
|
|
433
|
+
agent,
|
|
434
|
+
preset,
|
|
435
|
+
presetName,
|
|
436
|
+
workerCwd: effectiveCwd || workerCwd,
|
|
437
|
+
prompt,
|
|
438
|
+
maxLoopIterations,
|
|
439
|
+
watchdogPolicy,
|
|
440
|
+
};
|
|
441
|
+
}
|
|
442
|
+
|
|
443
|
+
async function runSpawn(prepared, notifyContext = null, job = null) {
|
|
444
|
+
const { args, tag, session, agent, preset, presetName, workerCwd, prompt, watchdogPolicy } = prepared;
|
|
445
|
+
const watchdog = startProgressIdleWatchdog(session.id, watchdogPolicy, agent);
|
|
446
|
+
const turnReview = createTurnReviewCollector(session, tag, agent, notifyContext || {});
|
|
447
|
+
let finalStatus = 'idle';
|
|
448
|
+
// SubagentStart: a worker session is about to run its first turn.
|
|
449
|
+
emitSubagentEvent('start', agent, { session_id: session.id, tag });
|
|
450
|
+
upsertWorkerSessionDeferred(session, tag, {
|
|
451
|
+
agent,
|
|
452
|
+
preset: presetKey(preset) || presetName,
|
|
453
|
+
provider: preset.provider,
|
|
454
|
+
model: preset.model,
|
|
455
|
+
effort: preset.effort || null,
|
|
456
|
+
fast: preset.fast === true,
|
|
457
|
+
status: 'running',
|
|
458
|
+
stage: 'running',
|
|
459
|
+
});
|
|
460
|
+
let handoffMsgStart = 0;
|
|
461
|
+
try {
|
|
462
|
+
const completionValue = (result) => {
|
|
463
|
+
// Promote an abnormal finish (iteration cap, truncation, or a public
|
|
464
|
+
// agent's empty terminal turn) to an explicit error, so the Lead
|
|
465
|
+
// receives it as a failure with an accurate reason instead of a silent
|
|
466
|
+
// `completed` empty result. Keyed off loop.mjs terminationReason;
|
|
467
|
+
// hidden agents finishing normally-empty are left untagged (benign).
|
|
468
|
+
const abnormalError = abnormalEmptyFinishError(result, agent);
|
|
469
|
+
return {
|
|
470
|
+
tag,
|
|
471
|
+
sessionId: session.id,
|
|
472
|
+
agent,
|
|
473
|
+
preset: presetKey(preset) || presetName,
|
|
474
|
+
provider: preset.provider,
|
|
475
|
+
model: preset.model,
|
|
476
|
+
effort: preset.effort || null,
|
|
477
|
+
fast: preset.fast === true,
|
|
478
|
+
content: result?.content || '',
|
|
479
|
+
...(abnormalError ? { error: abnormalError } : {}),
|
|
480
|
+
};
|
|
481
|
+
};
|
|
482
|
+
handoffMsgStart = resolveHandoffMessageStartIndex(mgr.getSession(session.id));
|
|
483
|
+
const result = await mgr.askSession(session.id, prompt, args.context || null, null, workerCwd, null, {
|
|
484
|
+
notifyFn: workerNotifyFn(session.id, notifyContext || {}),
|
|
485
|
+
onToolResult: (message) => turnReview.onToolResult(message),
|
|
486
|
+
...(job ? {
|
|
487
|
+
onTerminalResult: (terminalResult) => {
|
|
488
|
+
turnReview.complete();
|
|
489
|
+
const value = completionValue(terminalResult);
|
|
490
|
+
if (job) job._terminalResultValue = value;
|
|
491
|
+
notifyOwnerAgentCompletionEarly(job, value, notifyContext || {});
|
|
492
|
+
// Mark the task terminal the moment the worker produces its final
|
|
493
|
+
// result, so a hung/slow post-result session save cannot strand the
|
|
494
|
+
// task (and the status card) in `running`. Idempotent; the finally
|
|
495
|
+
// reconcile remains a backup for the error/no-terminal-result path.
|
|
496
|
+
if (job?.taskId) {
|
|
497
|
+
try {
|
|
498
|
+
// An empty/abnormal finish is a failure, not a completion:
|
|
499
|
+
// reconcile as `failed` with the accurate error so the Lead
|
|
500
|
+
// card renders `error: …` instead of a header-only empty card.
|
|
501
|
+
reconcileBackgroundTask(job.taskId, value.error
|
|
502
|
+
? {
|
|
503
|
+
status: 'failed',
|
|
504
|
+
result: value,
|
|
505
|
+
error: value.error,
|
|
506
|
+
terminalReason: 'agent-empty-final',
|
|
507
|
+
}
|
|
508
|
+
: {
|
|
509
|
+
status: 'completed',
|
|
510
|
+
result: value,
|
|
511
|
+
terminalReason: 'agent-terminal-result',
|
|
512
|
+
});
|
|
513
|
+
} catch {}
|
|
514
|
+
}
|
|
515
|
+
},
|
|
516
|
+
} : {}),
|
|
517
|
+
});
|
|
518
|
+
// The early preview no longer promises body suppression, so the canonical
|
|
519
|
+
// notifyTaskCompletion is left to fire exactly once with output via the
|
|
520
|
+
// resolve/reconcile/finally path.
|
|
521
|
+
const finalValue = completionValue(result);
|
|
522
|
+
// Non-job return path (or job path where the terminal-result reconcile
|
|
523
|
+
// already ran): if the finish was abnormal-empty, surface it as a thrown
|
|
524
|
+
// error so finalStatus becomes 'error' and the caller's error path (and
|
|
525
|
+
// the finally reconcile below, as `failed`) render the accurate reason.
|
|
526
|
+
if (finalValue.error) {
|
|
527
|
+
finalStatus = 'error';
|
|
528
|
+
if (job) job._terminalResultValue = finalValue;
|
|
529
|
+
throw new Error(finalValue.error);
|
|
530
|
+
}
|
|
531
|
+
return finalValue;
|
|
532
|
+
} catch (error) {
|
|
533
|
+
const partial = watchdogPartialHandoffFromError(error, mgr.getSession(session.id), handoffMsgStart);
|
|
534
|
+
if (partial) {
|
|
535
|
+
finalStatus = 'idle';
|
|
536
|
+
const value = {
|
|
537
|
+
tag,
|
|
538
|
+
sessionId: session.id,
|
|
539
|
+
agent,
|
|
540
|
+
preset: presetKey(preset) || presetName,
|
|
541
|
+
provider: preset.provider,
|
|
542
|
+
model: preset.model,
|
|
543
|
+
effort: preset.effort || null,
|
|
544
|
+
fast: preset.fast === true,
|
|
545
|
+
content: partial,
|
|
546
|
+
stallAbort: true,
|
|
547
|
+
};
|
|
548
|
+
if (job) job._terminalResultValue = value;
|
|
549
|
+
if (job?.taskId) {
|
|
550
|
+
try {
|
|
551
|
+
reconcileBackgroundTask(job.taskId, {
|
|
552
|
+
status: 'completed',
|
|
553
|
+
result: value,
|
|
554
|
+
terminalReason: 'agent-watchdog-partial',
|
|
555
|
+
});
|
|
556
|
+
} catch {}
|
|
557
|
+
}
|
|
558
|
+
return value;
|
|
559
|
+
}
|
|
560
|
+
finalStatus = 'error';
|
|
561
|
+
// Part C: a mid-stream stall (StreamStalledError / ESTREAMSTALL) throws
|
|
562
|
+
// here WITHOUT a terminal result, so the finally reconcile below (gated on
|
|
563
|
+
// _terminalResultValue) would be skipped and only the outer task-reject
|
|
564
|
+
// path would notify. Belt-and-suspenders: reconcile this job to `failed`
|
|
565
|
+
// now so the owner (Lead) always gets a failure notification instead of a
|
|
566
|
+
// task stranded in `running`. Idempotent — completeBackgroundTask no-ops
|
|
567
|
+
// once terminal, so the outer reject path can't double-notify.
|
|
568
|
+
if (job?.taskId && job._terminalResultValue === undefined) {
|
|
569
|
+
try {
|
|
570
|
+
reconcileBackgroundTask(job.taskId, {
|
|
571
|
+
status: 'failed',
|
|
572
|
+
error,
|
|
573
|
+
terminalReason: 'agent-stream-stalled',
|
|
574
|
+
});
|
|
575
|
+
} catch {}
|
|
576
|
+
}
|
|
577
|
+
throw error;
|
|
578
|
+
} finally {
|
|
579
|
+
turnReview.complete();
|
|
580
|
+
watchdog?.stop?.();
|
|
581
|
+
upsertWorkerSessionDeferred(session, tag, {
|
|
582
|
+
agent,
|
|
583
|
+
preset: presetKey(preset) || presetName,
|
|
584
|
+
provider: preset.provider,
|
|
585
|
+
model: preset.model,
|
|
586
|
+
effort: preset.effort || null,
|
|
587
|
+
fast: preset.fast === true,
|
|
588
|
+
status: finalStatus,
|
|
589
|
+
stage: finalStatus,
|
|
590
|
+
finishedAt: new Date().toISOString(),
|
|
591
|
+
});
|
|
592
|
+
// Safety net: if a post-result step (session save) hung or threw after the
|
|
593
|
+
// worker already produced a terminal result, the task could otherwise be
|
|
594
|
+
// stranded in `running`. Reconcile it to a terminal state using the
|
|
595
|
+
// captured result so the owner gets a completion notification + the
|
|
596
|
+
// statusline clears. Idempotent once the task is already terminal.
|
|
597
|
+
if (job && job._terminalResultValue !== undefined) {
|
|
598
|
+
try {
|
|
599
|
+
reconcileBackgroundTask(job.taskId, {
|
|
600
|
+
status: finalStatus === 'error' ? 'failed' : 'completed',
|
|
601
|
+
result: job._terminalResultValue,
|
|
602
|
+
...(finalStatus === 'error' && job._terminalResultValue?.error
|
|
603
|
+
? { error: job._terminalResultValue.error }
|
|
604
|
+
: {}),
|
|
605
|
+
terminalReason: 'agent-finally-reconcile',
|
|
606
|
+
});
|
|
607
|
+
} catch {}
|
|
608
|
+
}
|
|
609
|
+
scheduleReap(session.id);
|
|
610
|
+
// SubagentStop: worker finished (terminal), regardless of outcome.
|
|
611
|
+
emitSubagentEvent('stop', agent, { session_id: session.id, tag, status: finalStatus });
|
|
612
|
+
}
|
|
613
|
+
}
|
|
614
|
+
|
|
615
|
+
return {
|
|
616
|
+
closePreparedSpawn,
|
|
617
|
+
workerNotifyFn,
|
|
618
|
+
notifyOwnerAgentCompletionEarly,
|
|
619
|
+
startJob,
|
|
620
|
+
startDeferredSpawnJob,
|
|
621
|
+
progressWatchdogs,
|
|
622
|
+
startProgressIdleWatchdog,
|
|
623
|
+
prepareSpawn,
|
|
624
|
+
tryShardEnqueue,
|
|
625
|
+
runShardSpawn,
|
|
626
|
+
prepareSpawnInProcess,
|
|
627
|
+
runSpawn,
|
|
628
|
+
};
|
|
629
|
+
}
|