mixdog 0.9.12 → 0.9.14

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 (58) hide show
  1. package/package.json +1 -1
  2. package/scripts/internal-comms-bench.mjs +1 -0
  3. package/scripts/internal-comms-smoke.mjs +11 -7
  4. package/src/defaults/cycle3-review-prompt.md +14 -0
  5. package/src/defaults/memory-promote-prompt.md +9 -9
  6. package/src/lib/rules-builder.cjs +15 -11
  7. package/src/mixdog-session-runtime.mjs +10 -0
  8. package/src/rules/agent/00-common.md +5 -17
  9. package/src/rules/agent/00-core.md +21 -0
  10. package/src/rules/lead/lead-brief.md +7 -0
  11. package/src/runtime/agent/orchestrator/agent-runtime/agent-dispatch.mjs +39 -2
  12. package/src/runtime/agent/orchestrator/agent-runtime/agent-loop-policy.mjs +0 -25
  13. package/src/runtime/agent/orchestrator/agent-runtime/agent-progress-watchdog.mjs +120 -3
  14. package/src/runtime/agent/orchestrator/agent-trace.mjs +43 -1
  15. package/src/runtime/agent/orchestrator/context/collect.mjs +1 -1
  16. package/src/runtime/agent/orchestrator/providers/gemini-stream.mjs +48 -3
  17. package/src/runtime/agent/orchestrator/providers/openai-compat-stream.mjs +42 -4
  18. package/src/runtime/agent/orchestrator/providers/openai-compat-xai.mjs +1 -1
  19. package/src/runtime/agent/orchestrator/providers/openai-compat.mjs +24 -3
  20. package/src/runtime/agent/orchestrator/providers/openai-oauth-http-sse.mjs +6 -0
  21. package/src/runtime/agent/orchestrator/providers/openai-oauth-ws.mjs +61 -6
  22. package/src/runtime/agent/orchestrator/providers/openai-oauth.mjs +31 -0
  23. package/src/runtime/agent/orchestrator/providers/openai-ws-stream.mjs +57 -2
  24. package/src/runtime/agent/orchestrator/providers/retry-classifier.mjs +40 -4
  25. package/src/runtime/agent/orchestrator/session/compact/budget.mjs +0 -62
  26. package/src/runtime/agent/orchestrator/session/compact.mjs +0 -2
  27. package/src/runtime/agent/orchestrator/session/loop/completion-guards.mjs +12 -9
  28. package/src/runtime/agent/orchestrator/session/loop/pre-dispatch-deny.mjs +12 -0
  29. package/src/runtime/agent/orchestrator/session/loop/recall-fasttrack.mjs +48 -157
  30. package/src/runtime/agent/orchestrator/session/loop/steering-ladder.mjs +6 -23
  31. package/src/runtime/agent/orchestrator/session/loop/termination.mjs +0 -13
  32. package/src/runtime/agent/orchestrator/session/loop.mjs +32 -77
  33. package/src/runtime/agent/orchestrator/session/manager/compaction-runner.mjs +33 -45
  34. package/src/runtime/agent/orchestrator/session/manager/pending-messages.mjs +132 -11
  35. package/src/runtime/agent/orchestrator/session/manager/tool-resolution.mjs +7 -6
  36. package/src/runtime/agent/orchestrator/session/manager.mjs +30 -15
  37. package/src/runtime/agent/orchestrator/stall-policy.mjs +16 -0
  38. package/src/runtime/channels/index.mjs +28 -1
  39. package/src/runtime/channels/lib/memory-client.mjs +200 -15
  40. package/src/runtime/memory/index.mjs +18 -13
  41. package/src/runtime/memory/lib/core-memory-store.mjs +108 -4
  42. package/src/runtime/memory/lib/cycle-scheduler.mjs +52 -18
  43. package/src/runtime/memory/lib/memory-cycle1.mjs +166 -23
  44. package/src/runtime/memory/lib/memory-cycle2-gate.mjs +37 -25
  45. package/src/runtime/memory/lib/memory-cycle2-mutations.mjs +37 -0
  46. package/src/runtime/memory/lib/memory-cycle2.mjs +17 -7
  47. package/src/runtime/memory/lib/memory-cycle3.mjs +106 -6
  48. package/src/runtime/memory/lib/memory-embed.mjs +35 -1
  49. package/src/runtime/memory/lib/memory.mjs +7 -1
  50. package/src/runtime/memory/lib/query-handlers.mjs +1 -0
  51. package/src/standalone/agent-tool.mjs +89 -7
  52. package/src/tui/dist/index.mjs +203 -10
  53. package/src/tui/engine/tui-steering-persist.mjs +175 -0
  54. package/src/tui/engine.mjs +46 -2
  55. package/src/ui/statusline.mjs +2 -4
  56. package/src/workflows/default/WORKFLOW.md +2 -0
  57. package/src/workflows/sequential/WORKFLOW.md +2 -0
  58. package/src/workflows/solo/WORKFLOW.md +2 -0
@@ -24,7 +24,7 @@ import { join } from 'path'
24
24
  import { fileURLToPath } from 'url'
25
25
  import { resolveMaintenancePreset } from '../../shared/llm/index.mjs'
26
26
  import { callAgentDispatch } from './agent-ipc.mjs'
27
- import { listCore, editCore, deleteCore, CORE_SUMMARY_MAX } from './core-memory-store.mjs'
27
+ import { listCore, editCore, deleteCore, archiveCore, CORE_SUMMARY_MAX } from './core-memory-store.mjs'
28
28
  import { loadCurrentRulesDigest } from './memory-cycle2.mjs'
29
29
  import { embedText } from './embedding-provider.mjs'
30
30
  import { searchRelevantHybrid } from './memory-recall-store.mjs'
@@ -207,6 +207,16 @@ function parseVerdicts(raw, idSet) {
207
207
  actions.push({ id, verb: 'merge', targetId, sourceIds })
208
208
  } else if (verb === 'delete') {
209
209
  actions.push({ id, verb: 'delete' })
210
+ } else if (verb === 'superseded' || verb === 'supersede') {
211
+ // Require newer-id proof: `id|superseded|<newer_id>`. Without a valid
212
+ // newer active core id the supersession has no evidence → drop to keep.
213
+ const newerId = Number((parts[2] ?? '').trim())
214
+ if (!Number.isFinite(newerId) || !idSet.has(newerId) || newerId === id) {
215
+ __mixdogMemoryLog(`[cycle3] superseded rejected: id=${id} invalid/missing newer_id=${parts[2] ?? ''} → keep\n`)
216
+ actions.push({ id, verb: 'keep' })
217
+ continue
218
+ }
219
+ actions.push({ id, verb: 'superseded', newerId })
210
220
  }
211
221
  }
212
222
  if (!sawValid) return null
@@ -481,9 +491,66 @@ async function _runCycle3Impl(db, config, dataDir, options = {}) {
481
491
  if (!actionIds.has(id)) parsed.actions.push({ id, verb: 'keep' })
482
492
  }
483
493
 
484
- let kept = 0, updated = 0, merged = 0, deleted = 0
485
- const proposed = { kept: 0, updated: 0, merged: 0, deleted: 0 }
486
- const held = { updated: 0, merged: 0, deleted: 0 }
494
+ // Supersession target liveness: a `superseded` verdict points at a newerId
495
+ // that must remain LIVE after this batch. If newerId is itself superseded/
496
+ // delete/merged-away in the same batch, archiving against it retires a row
497
+ // against a non-live replacement. Resolve the newerId transitively through
498
+ // chained supersessions to the final live target; if that target is retired
499
+ // (deleted/merged-away or a supersession cycle/dead-end) → downgrade to keep.
500
+ {
501
+ const byId = new Map(parsed.actions.map(a => [Number(a.id), a]))
502
+ // Two-phase, cycle-safe. Phase 1 computes every resolution against a FROZEN
503
+ // snapshot of the original verbs/newerIds — the walk never observes another
504
+ // action's in-progress mutation, so an A→B, B→A mutual cycle can't have A
505
+ // downgrade first and then let B see A as live. Any id that enters a
506
+ // supersession cycle yields null (no live target) for ALL its members.
507
+ // Phase 2 applies the computed resolutions in one pass.
508
+ const snap = new Map(parsed.actions.map(a => [Number(a.id), {
509
+ verb: a.verb,
510
+ newerId: a.newerId != null ? Number(a.newerId) : null,
511
+ id: Number(a.id),
512
+ sourceIds: a.sourceIds,
513
+ targetId: a.targetId,
514
+ }]))
515
+ // ids removed from the live pool by their own verdict (not superseded —
516
+ // that is chased transitively below).
517
+ const retiredBySnap = (s) => s && (s.verb === 'delete' ||
518
+ (s.verb === 'merge' && s.sourceIds?.includes(s.id) && s.targetId !== s.id))
519
+ const resolveLiveTarget = (startId) => {
520
+ let cur = Number(startId)
521
+ const seen = new Set()
522
+ while (true) {
523
+ if (seen.has(cur)) return null // supersession cycle → no live target
524
+ seen.add(cur)
525
+ const s = snap.get(cur)
526
+ if (!s) return null
527
+ if (retiredBySnap(s)) return null // target itself removed this batch
528
+ if (s.verb === 'superseded') { cur = Number(s.newerId); continue }
529
+ return cur // keep/update/merge-survivor → live
530
+ }
531
+ }
532
+ // Phase 1: resolve against frozen snapshot.
533
+ const resolutions = []
534
+ for (const a of parsed.actions) {
535
+ if (a.verb !== 'superseded') continue
536
+ resolutions.push({ action: a, live: resolveLiveTarget(a.newerId) })
537
+ }
538
+ // Phase 2: apply.
539
+ for (const { action: a, live } of resolutions) {
540
+ if (live == null) {
541
+ __mixdogMemoryLog(`[cycle3] superseded downgraded to keep: id=${a.id} newerId=${a.newerId} not live after batch\n`)
542
+ a.verb = 'keep'
543
+ delete a.newerId
544
+ } else if (live !== Number(a.newerId)) {
545
+ __mixdogMemoryLog(`[cycle3] superseded newerId resolved transitively: id=${a.id} ${a.newerId}->${live}\n`)
546
+ a.newerId = live
547
+ }
548
+ }
549
+ }
550
+
551
+ let kept = 0, updated = 0, merged = 0, deleted = 0, superseded = 0
552
+ const proposed = { kept: 0, updated: 0, merged: 0, deleted: 0, superseded: 0 }
553
+ const held = { updated: 0, merged: 0, deleted: 0, superseded: 0 }
487
554
  const details = []
488
555
  const touched = new Set() // ids already acted on this cycle — avoid double action
489
556
 
@@ -550,6 +617,38 @@ async function _runCycle3Impl(db, config, dataDir, options = {}) {
550
617
  }
551
618
  continue
552
619
  }
620
+ if (a.verb === 'superseded') {
621
+ proposed.superseded++
622
+ // Supersession archives (status flip), never physical DELETE, and applies
623
+ // in DEFAULT (conservative) mode — reversible and audit-retained, so it is
624
+ // safe without the confirmed gate that outright deletes require.
625
+ if (!mutate) {
626
+ held.superseded++
627
+ details.push({
628
+ id: a.id, verb: 'superseded', applied: false, held: true,
629
+ reason: 'proposal mode',
630
+ })
631
+ touched.add(a.id)
632
+ continue
633
+ }
634
+ try {
635
+ const core = coreById.get(a.id)
636
+ const res = await archiveCore(dataDir, a.id, core ? { element: core.element, summary: core.summary } : null)
637
+ if (res?.skipped) {
638
+ held.superseded++
639
+ details.push({ id: a.id, verb: 'superseded', newerId: a.newerId, applied: false, held: true, reason: res.reason })
640
+ } else {
641
+ superseded++
642
+ details.push({ id: a.id, verb: 'superseded', newerId: a.newerId, applied: true })
643
+ }
644
+ touched.add(a.id)
645
+ } catch (err) {
646
+ if (signal?.aborted) throw signal.reason ?? err
647
+ __mixdogMemoryLog(`[cycle3] supersede archive failed id=${a.id}: ${err.message}\n`)
648
+ details.push({ id: a.id, verb: 'superseded', error: err.message })
649
+ }
650
+ continue
651
+ }
553
652
  if (a.verb === 'merge') {
554
653
  if (a.targetId !== a.id && !a.sourceIds.includes(a.id)) {
555
654
  __mixdogMemoryLog(
@@ -638,9 +737,10 @@ async function _runCycle3Impl(db, config, dataDir, options = {}) {
638
737
  `[cycle3] reviewed=${cores.length} kept=${kept}` +
639
738
  ` proposed_update=${proposed.updated} proposed_merge=${proposed.merged} proposed_delete=${proposed.deleted}` +
640
739
  ` applied_update=${updated} applied_merge=${merged} applied_delete=${deleted}` +
641
- ` held_update=${held.updated} held_merge=${held.merged} held_delete=${held.deleted}` +
740
+ ` applied_superseded=${superseded}` +
741
+ ` held_update=${held.updated} held_merge=${held.merged} held_delete=${held.deleted} held_superseded=${held.superseded}` +
642
742
  ` mode=${applyMode}\n`,
643
743
  )
644
744
 
645
- return { reviewed: cores.length, kept, updated, merged, deleted, proposed, held, applied: mutate, applyMode, details }
745
+ return { reviewed: cores.length, kept, updated, merged, deleted, superseded, proposed, held, applied: mutate, applyMode, details }
646
746
  }
@@ -430,7 +430,41 @@ async function syncBatchEmbeddings(db, ids, options = {}) {
430
430
  throwIfAborted(signal)
431
431
  const writtenIds = (res.rows || []).map(r => Number(r.id))
432
432
  if (writtenIds.length < updates.length) {
433
- __mixdogMemoryLog(`[embed-sync] ${updates.length - writtenIds.length} stale-write(s) skipped — text changed since read\n`)
433
+ const writtenSet = new Set(writtenIds)
434
+ const missed = updates.filter((u) => !writtenSet.has(u.id))
435
+ const missedIds = missed.map((u) => u.id)
436
+ const curRows = missedIds.length
437
+ ? (await db.query(
438
+ `SELECT id, embedding IS NOT NULL AS has_embedding, element, summary
439
+ FROM memory.entries WHERE id = ANY($1::bigint[]) AND is_root = 1`,
440
+ [missedIds],
441
+ )).rows
442
+ : []
443
+ const curById = new Map(curRows.map((r) => [Number(r.id), r]))
444
+ let staleSkips = 0
445
+ let concurrentSkips = 0
446
+ for (const u of missed) {
447
+ const cur = curById.get(u.id)
448
+ if (!cur) continue
449
+ const textUnchanged =
450
+ (cur.element === u.element || (cur.element == null && u.element == null)) &&
451
+ (cur.summary === u.summary || (cur.summary == null && u.summary == null))
452
+ if (!textUnchanged) {
453
+ staleSkips++
454
+ continue
455
+ }
456
+ if (cur.has_embedding) {
457
+ concurrentSkips++
458
+ continue
459
+ }
460
+ staleSkips++
461
+ }
462
+ if (staleSkips > 0) {
463
+ __mixdogMemoryLog(`[embed-sync] ${staleSkips} stale-write(s) skipped — text changed since read\n`)
464
+ }
465
+ if (concurrentSkips > 0 && process.env.MIXDOG_DEBUG_EMBED) {
466
+ __mixdogMemoryLog(`[embed-sync] ${concurrentSkips} concurrent embed write(s) skipped — already embedded\n`)
467
+ }
434
468
  }
435
469
  return writtenIds
436
470
  }
@@ -455,6 +455,11 @@ export async function ensureCurrentSchemaExtensions(db, dims) {
455
455
  // re-nominates the same entry.
456
456
  await db.exec(`ALTER TABLE entries ADD COLUMN IF NOT EXISTS core_candidate_status text`)
457
457
  await db.exec(`ALTER TABLE entries ADD COLUMN IF NOT EXISTS core_candidate_at bigint`)
458
+ // cycle3 supersession retirement: archive (status flip) instead of physical
459
+ // DELETE for facts a newer active fact replaced. Additive nullable columns —
460
+ // legacy rows read as NULL status = active. No data migration required.
461
+ await db.exec(`ALTER TABLE core_entries ADD COLUMN IF NOT EXISTS status text`)
462
+ await db.exec(`ALTER TABLE core_entries ADD COLUMN IF NOT EXISTS archived_at bigint`)
458
463
  // No index on core_candidate_status by design (round-2 finding #4): the only
459
464
  // readers are listCoreCandidates (user picker, on-demand) and
460
465
  // nominateCoreCandidates (once per cycle2, hourly). Both are rare and the
@@ -470,7 +475,8 @@ export async function ensureCurrentSchemaExtensions(db, dims) {
470
475
  SELECT id,
471
476
  row_number() OVER (
472
477
  PARTITION BY project_id, element
473
- ORDER BY updated_at DESC NULLS LAST, id DESC
478
+ ORDER BY (CASE WHEN status IS NULL OR status = 'active' THEN 0 ELSE 1 END),
479
+ updated_at DESC NULLS LAST, id DESC
474
480
  ) AS rn
475
481
  FROM core_entries
476
482
  ), deleted AS (
@@ -241,6 +241,7 @@ export function createQueryHandlers({
241
241
  (${hitExpr}) AS hit_count
242
242
  FROM core_entries
243
243
  WHERE ${where.join(' AND ')}
244
+ AND (status IS NULL OR status = 'active')
244
245
  ORDER BY hit_count DESC, updated_at DESC, id ASC
245
246
  LIMIT $${params.length}
246
247
  `, params)).rows
@@ -16,9 +16,13 @@ import { updateJsonAtomicSync } from '../runtime/shared/atomic-file.mjs';
16
16
  import { normalizeAgentPermission } from '../runtime/shared/markdown-frontmatter.mjs';
17
17
  import { prepareAgentSession } from '../runtime/agent/orchestrator/agent-runtime/session-builder.mjs';
18
18
  import {
19
+ abortAgentProgressWatchdog,
19
20
  agentWatchdogPolicyActive,
20
21
  evaluateAgentWatchdogAbort,
21
22
  resolveAgentWatchdogPolicy,
23
+ resolveHandoffMessageStartIndex,
24
+ watchdogPartialHandoffFromError,
25
+ AgentStallAbortError,
22
26
  } from '../runtime/agent/orchestrator/agent-runtime/agent-progress-watchdog.mjs';
23
27
  import { buildAgentTaskProgressFields } from './agent-task-status.mjs';
24
28
  import { AGENT_OWNER } from '../runtime/agent/orchestrator/agent-owner.mjs';
@@ -962,13 +966,15 @@ export function createStandaloneAgent({ cfgMod, reg, mgr, dataDir, cwd: defaultC
962
966
  }, notifyContext);
963
967
  }
964
968
 
965
- function startProgressIdleWatchdog(sessionId, watchdogPolicy) {
969
+ function startProgressIdleWatchdog(sessionId, watchdogPolicy, agent = null) {
966
970
  if (!sessionId || !agentWatchdogPolicyActive(watchdogPolicy)) return null;
967
971
  if (typeof mgr.getSessionProgressSnapshot !== 'function' && typeof mgr.getSessionLastProgressAt !== 'function') return null;
968
972
  if (typeof mgr.linkParentSignalToSession !== 'function') return null;
969
973
  const controller = new AbortController();
974
+ const anchorTs = Date.now();
970
975
  try { mgr.linkParentSignalToSession(sessionId, controller.signal); } catch { return null; }
971
976
  const timer = setInterval(() => {
977
+ if (controller.signal?.aborted) return;
972
978
  const now = Date.now();
973
979
  const snapshot = typeof mgr.getSessionProgressSnapshot === 'function'
974
980
  ? mgr.getSessionProgressSnapshot(sessionId)
@@ -976,15 +982,36 @@ export function createStandaloneAgent({ cfgMod, reg, mgr, dataDir, cwd: defaultC
976
982
  const abortErr = snapshot
977
983
  ? evaluateAgentWatchdogAbort(snapshot, now, watchdogPolicy)
978
984
  : null;
985
+ const sess = typeof mgr.getSession === 'function' ? mgr.getSession(sessionId) : null;
986
+ const iteration = typeof sess?.lastIterationIndex === 'number' ? sess.lastIterationIndex : null;
979
987
  if (!abortErr && !snapshot) {
980
- const last = mgr.getSessionLastProgressAt(sessionId);
981
- if (watchdogPolicy.idleStaleMs > 0 && last && now - last > watchdogPolicy.idleStaleMs) {
982
- try { controller.abort(new Error(`agent task stale (${watchdogPolicy.idleStaleMs}ms without progress)`)); } catch {}
988
+ const reported = mgr.getSessionLastProgressAt(sessionId);
989
+ const last = reported || anchorTs;
990
+ if (watchdogPolicy.idleStaleMs > 0 && now - last > watchdogPolicy.idleStaleMs) {
991
+ abortAgentProgressWatchdog(controller, {
992
+ sessionId,
993
+ agent,
994
+ error: new AgentStallAbortError(`agent task stale (${watchdogPolicy.idleStaleMs}ms without progress)`),
995
+ policy: watchdogPolicy,
996
+ now,
997
+ anchorTs,
998
+ lastProgressAt: reported,
999
+ iteration,
1000
+ });
983
1001
  }
984
1002
  return;
985
1003
  }
986
1004
  if (abortErr) {
987
- try { controller.abort(abortErr); } catch {}
1005
+ abortAgentProgressWatchdog(controller, {
1006
+ sessionId,
1007
+ agent,
1008
+ error: abortErr,
1009
+ snapshot,
1010
+ policy: watchdogPolicy,
1011
+ now,
1012
+ anchorTs,
1013
+ iteration,
1014
+ });
988
1015
  }
989
1016
  }, 1000);
990
1017
  timer.unref?.();
@@ -1072,7 +1099,7 @@ export function createStandaloneAgent({ cfgMod, reg, mgr, dataDir, cwd: defaultC
1072
1099
 
1073
1100
  async function runSpawn(prepared, notifyContext = null, job = null) {
1074
1101
  const { args, tag, session, agent, preset, presetName, workerCwd, prompt, watchdogPolicy } = prepared;
1075
- const watchdog = startProgressIdleWatchdog(session.id, watchdogPolicy);
1102
+ const watchdog = startProgressIdleWatchdog(session.id, watchdogPolicy, agent);
1076
1103
  let finalStatus = 'idle';
1077
1104
  // SubagentStart: a worker session is about to run its first turn.
1078
1105
  emitSubagentEvent('start', agent, { session_id: session.id, tag });
@@ -1086,6 +1113,7 @@ export function createStandaloneAgent({ cfgMod, reg, mgr, dataDir, cwd: defaultC
1086
1113
  status: 'running',
1087
1114
  stage: 'running',
1088
1115
  });
1116
+ let handoffMsgStart = 0;
1089
1117
  try {
1090
1118
  const completionValue = (result) => {
1091
1119
  // Promote an abnormal finish (iteration cap, truncation, or a public
@@ -1107,6 +1135,7 @@ export function createStandaloneAgent({ cfgMod, reg, mgr, dataDir, cwd: defaultC
1107
1135
  ...(abnormalError ? { error: abnormalError } : {}),
1108
1136
  };
1109
1137
  };
1138
+ handoffMsgStart = resolveHandoffMessageStartIndex(mgr.getSession(session.id));
1110
1139
  const result = await mgr.askSession(session.id, prompt, args.context || null, null, workerCwd, null, {
1111
1140
  notifyFn: workerNotifyFn(session.id, notifyContext || {}),
1112
1141
  ...(job ? {
@@ -1155,6 +1184,33 @@ export function createStandaloneAgent({ cfgMod, reg, mgr, dataDir, cwd: defaultC
1155
1184
  }
1156
1185
  return finalValue;
1157
1186
  } catch (error) {
1187
+ const partial = watchdogPartialHandoffFromError(error, mgr.getSession(session.id), handoffMsgStart);
1188
+ if (partial) {
1189
+ finalStatus = 'idle';
1190
+ const value = {
1191
+ tag,
1192
+ sessionId: session.id,
1193
+ agent,
1194
+ preset: presetKey(preset) || presetName,
1195
+ provider: preset.provider,
1196
+ model: preset.model,
1197
+ effort: preset.effort || null,
1198
+ fast: preset.fast === true,
1199
+ content: partial,
1200
+ stallAbort: true,
1201
+ };
1202
+ if (job) job._terminalResultValue = value;
1203
+ if (job?.taskId) {
1204
+ try {
1205
+ reconcileBackgroundTask(job.taskId, {
1206
+ status: 'completed',
1207
+ result: value,
1208
+ terminalReason: 'agent-watchdog-partial',
1209
+ });
1210
+ } catch {}
1211
+ }
1212
+ return value;
1213
+ }
1158
1214
  finalStatus = 'error';
1159
1215
  // Part C: a mid-stream stall (StreamStalledError / ESTREAMSTALL) throws
1160
1216
  // here WITHOUT a terminal result, so the finally reconcile below (gated on
@@ -1229,10 +1285,11 @@ export function createStandaloneAgent({ cfgMod, reg, mgr, dataDir, cwd: defaultC
1229
1285
  async function runSend(prepared, notifyContext = null, job = null) {
1230
1286
  const { args, session, sessionId, prompt } = prepared;
1231
1287
  const sendAgent = session.agent || normalizeAgentName(args.agent);
1232
- const watchdog = startProgressIdleWatchdog(sessionId, resolveAgentWatchdogPolicy(sendAgent));
1288
+ const watchdog = startProgressIdleWatchdog(sessionId, resolveAgentWatchdogPolicy(sendAgent), sendAgent);
1233
1289
  const tag = tagForSession(sessionId);
1234
1290
  let finalStatus = 'idle';
1235
1291
  upsertWorkerSessionDeferred(session, tag, { status: 'running', stage: 'running' });
1292
+ let handoffMsgStart = 0;
1236
1293
  try {
1237
1294
  const completionValue = (result) => {
1238
1295
  // Same abnormal-empty → error promotion as runSpawn: a reused/`send`
@@ -1249,6 +1306,7 @@ export function createStandaloneAgent({ cfgMod, reg, mgr, dataDir, cwd: defaultC
1249
1306
  ...(abnormalError ? { error: abnormalError } : {}),
1250
1307
  };
1251
1308
  };
1309
+ handoffMsgStart = resolveHandoffMessageStartIndex(mgr.getSession(sessionId));
1252
1310
  const result = await mgr.askSession(sessionId, prompt, args.context || null, null, session.cwd || defaultCwd, null, {
1253
1311
  notifyFn: workerNotifyFn(sessionId, notifyContext || {}),
1254
1312
  ...(job ? {
@@ -1287,6 +1345,30 @@ export function createStandaloneAgent({ cfgMod, reg, mgr, dataDir, cwd: defaultC
1287
1345
  }
1288
1346
  return finalValue;
1289
1347
  } catch (error) {
1348
+ const partial = watchdogPartialHandoffFromError(error, mgr.getSession(sessionId), handoffMsgStart);
1349
+ if (partial) {
1350
+ finalStatus = 'idle';
1351
+ const value = {
1352
+ tag,
1353
+ sessionId,
1354
+ agent: session.agent || null,
1355
+ provider: session.provider,
1356
+ model: session.model,
1357
+ content: partial,
1358
+ stallAbort: true,
1359
+ };
1360
+ if (job) job._terminalResultValue = value;
1361
+ if (job?.taskId) {
1362
+ try {
1363
+ reconcileBackgroundTask(job.taskId, {
1364
+ status: 'completed',
1365
+ result: value,
1366
+ terminalReason: 'agent-watchdog-partial',
1367
+ });
1368
+ } catch {}
1369
+ }
1370
+ return value;
1371
+ }
1290
1372
  finalStatus = 'error';
1291
1373
  // Part C (send path mirror): a mid-stream stall throws with no terminal
1292
1374
  // result — reconcile to `failed` so the owner is notified rather than the