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.
Files changed (38) hide show
  1. package/package.json +2 -2
  2. package/src/runtime/agent/orchestrator/agent-trace-format.mjs +4 -2
  3. package/src/runtime/agent/orchestrator/providers/oauth-usage.mjs +125 -16
  4. package/src/runtime/agent/orchestrator/session/manager.mjs +3 -0
  5. package/src/runtime/agent/orchestrator/tools/builtin/builtin-tools.mjs +9 -2
  6. package/src/runtime/agent/orchestrator/tools/env-scrub.mjs +8 -0
  7. package/src/runtime/agent/orchestrator/tools/patch-tool-defs.mjs +4 -3
  8. package/src/runtime/agent/orchestrator/tools/token-manifest.json +23 -2
  9. package/src/runtime/memory/lib/memory-action-handlers.mjs +4 -1
  10. package/src/runtime/memory/tool-defs.mjs +19 -3
  11. package/src/session-runtime/lifecycle-api.mjs +15 -0
  12. package/src/session-runtime/provider-auth-api.mjs +8 -0
  13. package/src/session-runtime/provider-usage.mjs +30 -5
  14. package/src/session-runtime/runtime-core.mjs +267 -481
  15. package/src/session-runtime/session-lifecycle.mjs +378 -0
  16. package/src/standalone/agent-shard/shard-pool.mjs +6 -5
  17. package/src/standalone/agent-tool/job-views.mjs +329 -0
  18. package/src/standalone/agent-tool/spawn-flow.mjs +629 -0
  19. package/src/standalone/agent-tool/tag-registry.mjs +340 -0
  20. package/src/standalone/agent-tool.mjs +100 -1151
  21. package/src/standalone/usage-dashboard.mjs +23 -1
  22. package/src/tui/App.jsx +362 -2576
  23. package/src/tui/app/app-view.jsx +504 -0
  24. package/src/tui/app/create-app-pickers.mjs +313 -0
  25. package/src/tui/app/prompt-submit.mjs +501 -0
  26. package/src/tui/app/shell-layout.mjs +563 -0
  27. package/src/tui/app/usage-context-panels.mjs +288 -0
  28. package/src/tui/app/use-copy-selection.mjs +56 -0
  29. package/src/tui/app/use-global-key-input.mjs +151 -0
  30. package/src/tui/app/use-pasted-buffers.mjs +115 -0
  31. package/src/tui/app/use-prompt-draft-flow.mjs +167 -0
  32. package/src/tui/app/use-prompt-hint.mjs +58 -0
  33. package/src/tui/app/use-prompt-queue-history.mjs +100 -0
  34. package/src/tui/app/use-terminal-chrome.mjs +72 -0
  35. package/src/tui/app/use-transcript-activity.mjs +131 -0
  36. package/src/tui/app/use-welcome-prompt-hint.mjs +96 -0
  37. package/src/tui/dist/index.mjs +9802 -9024
  38. 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
+ }