@qodeca/xezar 0.11.2 → 0.13.1

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 (140) hide show
  1. package/README.md +97 -43
  2. package/dist/agent-config/catalog.d.ts +10 -0
  3. package/dist/agent-config/catalog.js.map +1 -1
  4. package/dist/automations/coordinator.js +8 -0
  5. package/dist/automations/coordinator.js.map +1 -1
  6. package/dist/automations/types.d.ts +2 -0
  7. package/dist/contract/automations.d.ts +8 -0
  8. package/dist/contract/events.test.d.ts +1 -0
  9. package/dist/contract/index.js +87 -3
  10. package/dist/contract/runs.d.ts +25 -0
  11. package/dist/contract/runs.test.d.ts +1 -0
  12. package/dist/contract/skills.d.ts +1 -0
  13. package/dist/contract/workflows.d.ts +6 -0
  14. package/dist/contract/workspace.d.ts +37 -4
  15. package/dist/core/agent-env.js +8 -1
  16. package/dist/core/agent-env.js.map +1 -1
  17. package/dist/core/agent-runner.d.ts +13 -0
  18. package/dist/core/agent-runner.js +22 -0
  19. package/dist/core/agent-runner.js.map +1 -1
  20. package/dist/core/backend-detect.js +11 -1
  21. package/dist/core/backend-detect.js.map +1 -1
  22. package/dist/core/claude-cli-runner.js +8 -2
  23. package/dist/core/claude-cli-runner.js.map +1 -1
  24. package/dist/core/codex-app-server-runner.js +6 -2
  25. package/dist/core/codex-app-server-runner.js.map +1 -1
  26. package/dist/core/opencode-server-runner.d.ts +12 -0
  27. package/dist/core/opencode-server-runner.js +326 -17
  28. package/dist/core/opencode-server-runner.js.map +1 -1
  29. package/dist/core/pi-model-catalog.d.ts +52 -0
  30. package/dist/core/pi-model-catalog.js +135 -0
  31. package/dist/core/pi-model-catalog.js.map +1 -0
  32. package/dist/core/pi-runner.d.ts +3 -0
  33. package/dist/core/pi-runner.js +139 -12
  34. package/dist/core/pi-runner.js.map +1 -1
  35. package/dist/core/pi-ui-mapper.js +39 -3
  36. package/dist/core/pi-ui-mapper.js.map +1 -1
  37. package/dist/core/runner-model-catalog.js +11 -2
  38. package/dist/core/runner-model-catalog.js.map +1 -1
  39. package/dist/index.js +2 -0
  40. package/dist/index.js.map +1 -1
  41. package/dist/paths.d.ts +7 -0
  42. package/dist/paths.js +8 -0
  43. package/dist/paths.js.map +1 -1
  44. package/dist/runs/event-corrections.d.ts +12 -0
  45. package/dist/runs/event-corrections.js +141 -0
  46. package/dist/runs/event-corrections.js.map +1 -0
  47. package/dist/runs/event-history.d.ts +5 -1
  48. package/dist/runs/event-history.js +66 -5
  49. package/dist/runs/event-history.js.map +1 -1
  50. package/dist/runs/project-writer.d.ts +11 -0
  51. package/dist/runs/project-writer.js +115 -0
  52. package/dist/runs/project-writer.js.map +1 -0
  53. package/dist/runs/retention.d.ts +1 -1
  54. package/dist/runs/retention.js +1 -1
  55. package/dist/runs/store.d.ts +2 -0
  56. package/dist/runs/store.js +13 -2
  57. package/dist/runs/store.js.map +1 -1
  58. package/dist/runs/task-markers.d.ts +4 -0
  59. package/dist/runs/task-markers.js +63 -5
  60. package/dist/runs/task-markers.js.map +1 -1
  61. package/dist/server/capabilities.d.ts +7 -5
  62. package/dist/server/capabilities.js +11 -7
  63. package/dist/server/capabilities.js.map +1 -1
  64. package/dist/server/project-context.js +2 -0
  65. package/dist/server/project-context.js.map +1 -1
  66. package/dist/server/server.d.ts +199 -47
  67. package/dist/server/server.js +195 -11
  68. package/dist/server/server.js.map +1 -1
  69. package/dist/workflows/run.d.ts +67 -2
  70. package/dist/workflows/run.js +233 -62
  71. package/dist/workflows/run.js.map +1 -1
  72. package/dist/workflows/types.d.ts +41 -0
  73. package/dist/workflows/types.js +72 -0
  74. package/dist/workflows/types.js.map +1 -1
  75. package/dist/workspace/config.d.ts +42 -0
  76. package/dist/workspace/config.js +132 -3
  77. package/dist/workspace/config.js.map +1 -1
  78. package/dist/workspace/semaphore.d.ts +63 -3
  79. package/dist/workspace/semaphore.js +76 -3
  80. package/dist/workspace/semaphore.js.map +1 -1
  81. package/package.json +3 -3
  82. package/web/dist/assets/{alert-dialog-CGskMvgc.js → alert-dialog-BVsVcbFy.js} +1 -1
  83. package/web/dist/assets/arrow-down-CYgAlZtW.js +1 -0
  84. package/web/dist/assets/arrow-left-BOqE0a3k.js +1 -0
  85. package/web/dist/assets/{centered-state-B_V_Ai2h.js → centered-state-Djgly6Ni.js} +1 -1
  86. package/web/dist/assets/chevron-right-Cc9BWnbN.js +1 -0
  87. package/web/dist/assets/{collapsible-B_8uYwHb.js → collapsible-DZ4D-DHG.js} +1 -1
  88. package/web/dist/assets/{commit-list-C4Dv6vUL.js → commit-list-B-z1JL9G.js} +1 -1
  89. package/web/dist/assets/{compare-variants-et0mplGi.js → compare-variants-D62m4j3B.js} +1 -1
  90. package/web/dist/assets/{diff-CbozNUJq.js → diff-BKew9rQX.js} +2 -2
  91. package/web/dist/assets/{diff-stat-btAmKFJr.js → diff-stat-D0mc5V6Z.js} +1 -1
  92. package/web/dist/assets/{diff-view-BpFTcG3R.js → diff-view-xpIcuLIC.js} +1 -1
  93. package/web/dist/assets/{dropdown-menu-Z80KMInL.js → dropdown-menu-CEQziH7X.js} +1 -1
  94. package/web/dist/assets/editable-title-BCX3T2BY.js +1 -0
  95. package/web/dist/assets/{ellipsis-vertical-Bmi2u2TG.js → ellipsis-vertical-ByVdDc4z.js} +1 -1
  96. package/web/dist/assets/{file-DKoYWRsv.js → file-xBSB5hXO.js} +1 -1
  97. package/web/dist/assets/{folder-u01HScRT.js → folder-CldgbZ5A.js} +1 -1
  98. package/web/dist/assets/{git-toolbar-Q2kk2F7v.js → git-toolbar-DJANqg7Y.js} +1 -1
  99. package/web/dist/assets/github-D6vK0jFn.js +1 -0
  100. package/web/dist/assets/{image-preview-BR3iIIhf.js → image-preview-Cv5mZr0n.js} +1 -1
  101. package/web/dist/assets/index-CkmR6h7s.css +2 -0
  102. package/web/dist/assets/index-DVDqF4bM.js +9 -0
  103. package/web/dist/assets/{markdown-5FuCZDA4.js → markdown-CgEEE2ND.js} +1 -1
  104. package/web/dist/assets/{new-task-form-BydHBbef.js → new-task-form-1r2bgsdD.js} +1 -1
  105. package/web/dist/assets/{pill-vNzaaYxj.js → pill-q-x22_ej.js} +1 -1
  106. package/web/dist/assets/{project-router-BbCzrHKs.js → project-router-VDN4jQM8.js} +1 -1
  107. package/web/dist/assets/{prompt-templates-B7Fs2b1S.js → prompt-templates-BmZtCe-L.js} +1 -1
  108. package/web/dist/assets/{refresh-cw-DGliez3E.js → refresh-cw-DcNCj2nu.js} +1 -1
  109. package/web/dist/assets/{repo-git-h1RFfly3.js → repo-git-btG0sdbO.js} +1 -1
  110. package/web/dist/assets/{run-diff-Bq9vkSNK.js → run-diff-vguEMJzh.js} +1 -1
  111. package/web/dist/assets/{run-header-Cd8mSKBG.js → run-header-DM7MiKj9.js} +1 -1
  112. package/web/dist/assets/{search-x-69n_672Y.js → search-x-QmhaAbg5.js} +1 -1
  113. package/web/dist/assets/{skill-empty-hint-CL0g7WBL.js → skill-empty-hint-BrzUwByZ.js} +1 -1
  114. package/web/dist/assets/{skills-BqAzyGS3.js → skills-DMfxQtrD.js} +1 -1
  115. package/web/dist/assets/{sparkles-B8agSxBZ.js → sparkles-CP8QDOgX.js} +1 -1
  116. package/web/dist/assets/{square-terminal-Cf4Og6ct.js → square-terminal-CGypshSD.js} +1 -1
  117. package/web/dist/assets/{tab-link-Cp2-CJS8.js → tab-link-D4lFzzI1.js} +1 -1
  118. package/web/dist/assets/{task-changes-DwXPFXqG.js → task-changes-BtZId14z.js} +1 -1
  119. package/web/dist/assets/{task-commits-Bessua-c.js → task-commits-BoeyJXrv.js} +1 -1
  120. package/web/dist/assets/{task-files-DxFIhRPP.js → task-files-DIy2oUnj.js} +1 -1
  121. package/web/dist/assets/task-thread-CbMPmrWq.js +9 -0
  122. package/web/dist/assets/{textarea--cpYshee.js → textarea-CyBCyKIk.js} +1 -1
  123. package/web/dist/assets/{thread-loading-ZhJref0J.js → thread-loading-Cuq2jchC.js} +1 -1
  124. package/web/dist/assets/{trash-2--ZtrzUx3.js → trash-2-B6Dp3ZNs.js} +1 -1
  125. package/web/dist/assets/{triangle-alert-D5tJwRD2.js → triangle-alert-xBya8FUp.js} +1 -1
  126. package/web/dist/assets/{upload-SnwVxKOA.js → upload-D6MXEB9p.js} +1 -1
  127. package/web/dist/assets/{use-desktop-Sr1QlxhT.js → use-desktop-CPBwzQ7M.js} +1 -1
  128. package/web/dist/assets/{use-submit-shortcut-JY8M_MPL.js → use-submit-shortcut-JTpNzU-u.js} +1 -1
  129. package/web/dist/assets/{utils-Bb8ohcr3.js → utils--z9hRtl3.js} +1 -1
  130. package/web/dist/assets/{workflows-9HC240Wx.js → workflows-DG115scU.js} +3 -3
  131. package/web/dist/assets/{zoomable-image-0G3IBXWC.js → zoomable-image-GTYwLtkS.js} +1 -1
  132. package/web/dist/index.html +23 -23
  133. package/web/dist/assets/arrow-down-G8adPhbu.js +0 -1
  134. package/web/dist/assets/arrow-left-BJsIW6A0.js +0 -1
  135. package/web/dist/assets/chevron-right-CFzn-von.js +0 -1
  136. package/web/dist/assets/editable-title-wS5NWwob.js +0 -1
  137. package/web/dist/assets/github-DjEYCxwt.js +0 -1
  138. package/web/dist/assets/index-BcI5hug3.css +0 -2
  139. package/web/dist/assets/index-Cz4GSAqV.js +0 -7
  140. package/web/dist/assets/task-thread-Dhkupf1-.js +0 -9
@@ -9,7 +9,7 @@ import { createRunner } from '../core/runner-factory.js';
9
9
  import { modelConflictsWithRunner } from '../core/model-presets.js';
10
10
  import { AGENT_MODELS_LOCKED_ERROR, agentModelsLocked } from '../core/agent-model-policy.js';
11
11
  import { ModelIdentityError, formatModelIdentity, normalizeModelForBackend, } from '../core/model-identity.js';
12
- import { HANDOFF_ONLY_INSTRUCTIONS, HANDOFF_INSTRUCTIONS, appendHandoffHeartbeat, followupsEnabled, handoffPath, seedHandoffFile, } from '../handoff.js';
12
+ import { HANDOFF_ONLY_INSTRUCTIONS, HANDOFF_INSTRUCTIONS, appendHandoffHeartbeat, handoffPath, seedHandoffFile, } from '../handoff.js';
13
13
  import { todosPath } from '../todos.js';
14
14
  // Contract VALUES, like `workspaceUiStateSchema` in workspace/migrations.ts: the attachment
15
15
  // vocabulary the routes validate with is the same one the engine stores and re-reads by, so the
@@ -31,15 +31,24 @@ import { autoNamingActive, generateRunName, liveTitleUpdatesEnabled, postValidat
31
31
  import { reviewGateEnabled } from '../runs/review-gate.js';
32
32
  import { resolveProfileEnvForRoot } from '../workspace/agent-profiles.js';
33
33
  import { DEFAULT_AGENT_ACCOUNT_ID } from '../workspace/agent-accounts.js';
34
+ import { DEFAULT_IDLE_TIMEOUT_MINUTES } from '../workspace/config.js';
34
35
  import { WorkspaceSemaphore } from '../workspace/semaphore.js';
35
36
  import { UiEventSink } from '../runs/ui-event-sink.js';
36
- import { chainStepNote, DEFAULT_ALLOWED_TOOLS, stepKind } from './types.js';
37
+ import { chainStepNote, DEFAULT_ALLOWED_TOOLS, stepKind, stepTimeoutMs, } from './types.js';
37
38
  const CHECK_OUTPUT_CAP = 20_000;
38
39
  async function configuredModelProvider(backend, repoRoot) {
39
40
  return readAgentModelProvider(backend, repoRoot).catch(() => undefined);
40
41
  }
41
- /** An interactive session that hears nothing from the user closes itself. */
42
- export const IDLE_TIMEOUT_MS = 15 * 60_000;
42
+ /**
43
+ * An interactive session that hears nothing from the user closes itself.
44
+ *
45
+ * Kept as the module default so `stepTimeoutMs`-style callers and old imports still
46
+ * resolve, but enforcement no longer reads it directly: `armIdleTimer` asks the shared
47
+ * `WorkspaceSemaphore`, so `resources.idleTimeoutMinutes` takes effect on the next park
48
+ * without a restart. This constant IS that setting's default, via
49
+ * `DEFAULT_IDLE_TIMEOUT_MINUTES`.
50
+ */
51
+ export const IDLE_TIMEOUT_MS = DEFAULT_IDLE_TIMEOUT_MINUTES * 60_000;
43
52
  /**
44
53
  * Task-completion marker from the agent contract (HANDOFF_INSTRUCTIONS): a
45
54
  * turn whose text ends with `XEZ:DONE` means "goal achieved, nothing to ask" —
@@ -157,8 +166,9 @@ export function repositoryRootLockDisabled(env = process.env) {
157
166
  return env.XEZ_DISABLE_REPO_LOCK === '1';
158
167
  }
159
168
  const REPOSITORY_ROOT_LOCK_DISABLED_NOTE = 'repository-root lock disabled by XEZ_DISABLE_REPO_LOCK=1 (shared checkout is unsafe)';
160
- /** Safety cap on autonomous auto-continues per run — stops a stuck agent from nudging forever. */
161
- const MAX_AUTO_CONTINUES = 40;
169
+ /** Safety cap on autonomous auto-continues per run — stops a stuck agent from nudging forever.
170
+ * Exported so the regression suite pins the bound against the real number instead of a copy. */
171
+ export const MAX_AUTO_CONTINUES = 40;
162
172
  const AUTONOMOUS_NUDGE = 'Continue working autonomously until the task is fully complete. Do not ask me for confirmation or clarification — make reasonable assumptions and proceed. When everything is done, end the session with your done signal.';
163
173
  const MONITORING_WAKE_NUDGE = 'Re-check the downstream work you were monitoring. Continue toward the task goal; emit XEZ:MONITORING again only if it is still pending.';
164
174
  /**
@@ -428,6 +438,15 @@ export class RunManager {
428
438
  offUsage;
429
439
  /** The stalled-queue watchdog (see `rescueStalledQueue`). */
430
440
  queueWatchdog;
441
+ /** The rescue sweeps that have STARTED and not settled yet (#125). `clearInterval` cancels the
442
+ * next tick; it cannot cancel the tick already running, and a sweep is async — it awaits
443
+ * `reviveWorkflow` before appending NDJSON. dispose() settles these so a torn-down manager is
444
+ * provably finished writing before its caller removes the data root under it. */
445
+ rescuesInFlight = new Set();
446
+ /** Set by dispose(): this manager makes no further moves. Re-checked at every await boundary a
447
+ * rescue crosses, because a sweep that was already past its first check when dispose() landed
448
+ * would otherwise write an event — and re-populate the queue dispose() had just emptied. */
449
+ disposed = false;
431
450
  /** Set by the watchdog for exactly one sweep: ignore the usage-limit hold and make progress. */
432
451
  forceNextPump = false;
433
452
  /** Runs the watchdog started despite the hold. The spawn-time gate (`requeueWhileHeld`) would
@@ -466,8 +485,20 @@ export class RunManager {
466
485
  * repo-root locks, and empties the queued state so nothing fires later.
467
486
  * Live sessions are NOT ended here: run lifecycle stays the caller's policy;
468
487
  * dispose only guarantees the manager makes no further moves on its own.
488
+ *
489
+ * Every side effect below is SYNCHRONOUS, so a caller that ignores the return value behaves
490
+ * exactly as it did before #125. The returned promise settles the one thing dispose cannot do
491
+ * synchronously: a queue-watchdog rescue that had already started. `clearInterval` stops the
492
+ * next tick, never the running one — the same discipline `AGENTS.md` records for the e2e
493
+ * fixture servers, where `kill()` only delivers the signal and the helper awaits the exit.
494
+ * Await it whenever the data root is about to be removed; a test that deletes its temp
495
+ * directory without awaiting is the ENOENT in #125.
496
+ *
497
+ * It never rejects: a sweep that fails still belongs to whoever started it (the watchdog floats
498
+ * it exactly as before), and teardown must not become a second place that error surfaces.
469
499
  */
470
500
  dispose() {
501
+ this.disposed = true;
471
502
  this.offUsage();
472
503
  this.offSemaphore();
473
504
  clearInterval(this.queueWatchdog);
@@ -489,6 +520,7 @@ export class RunManager {
489
520
  this.pendingContinuations.clear();
490
521
  this.memoryPausing.clear();
491
522
  this.lastNamerKey.clear();
523
+ return Promise.allSettled([...this.rescuesInFlight]).then(() => undefined);
492
524
  }
493
525
  /**
494
526
  * Pause any active run whose whole process tree exceeds the WORKSPACE
@@ -504,11 +536,12 @@ export class RunManager {
504
536
  const runIds = Object.keys(snapshot).filter((runId) => this.active.has(runId));
505
537
  if (runIds.length === 0)
506
538
  return;
507
- // Workspace limit from the shared semaphore's in-memory cache (step 2.5:
508
- // refreshed at boot and on PUT /api/workspace/config — never N per-tick
509
- // file reads across N projects). Legacy per-repo `memoryLimitMb` keys are
510
- // ignored post-migration.
511
- const limitMb = this.semaphore.memoryLimitMb();
539
+ // Limit from the shared semaphore's in-memory cache (step 2.5: refreshed at boot and
540
+ // on a config PUT — never N per-tick file reads across N projects). This repo's OWN
541
+ // `.xezar/config.json` `memoryLimitMb` wins when it sets one and the workspace ceiling
542
+ // applies otherwise (B2) — the same more-specific-wins rule `projectMaxParallel`
543
+ // follows. Before B2 the per-repo key saved successfully and was then ignored here.
544
+ const limitMb = this.semaphore.projectMemoryLimitMb(this.repoRoot);
512
545
  if (!limitMb || limitMb <= 0)
513
546
  return;
514
547
  const limitBytes = limitMb * 1024 * 1024;
@@ -558,6 +591,14 @@ export class RunManager {
558
591
  XEZ_HANDOFF_FILE: handoffPath(this.dataDir, runId),
559
592
  XEZ_TASK_ID: runId,
560
593
  XEZ_TODOS_FILE: generateFollowups ? todosPath(this.dataDir) : '',
594
+ // The stored env-passthrough list (F), carried on the per-run env so it reaches EVERY
595
+ // backend through the one path they all share (`buildChildEnv(spec.env)`) — no runner
596
+ // seam change, and it is live because the semaphore refreshes on a config PUT. Set to
597
+ // `''` rather than omitted when the list is empty, for the same reason
598
+ // `XEZ_TODOS_FILE` is: the child inherits `process.env`, so an omitted key would let
599
+ // this process's own `XEZ_ENV_PASSTHROUGH` shine through and quietly override the
600
+ // stored "forward nothing" choice.
601
+ XEZ_ENV_PASSTHROUGH: this.semaphore.agentEnvPassthrough().join(','),
561
602
  ...agentTmpEnv(this.dataDir, runId),
562
603
  };
563
604
  }
@@ -607,7 +648,7 @@ export class RunManager {
607
648
  // The global inbox is the ceiling on the per-run flag (#471). Enforced here rather than
608
649
  // at the HTTP route because `xezar run`, the inbox's own "▶ Run" and variants all reach
609
650
  // startRun directly — a route-level gate would leave those writing todos.json.
610
- generateFollowups: followupsEnabled() ? input.generateFollowups : false,
651
+ generateFollowups: this.semaphore.followupsEnabled() ? input.generateFollowups : false,
611
652
  // Persist autonomy on the record (#489) so the terminal review gate
612
653
  // (`settleSuccess`) and the group-pick winner-park can honor it — mid-run
613
654
  // auto-nudge reads `input.autonomous` (`execute`), but the record is the
@@ -846,8 +887,15 @@ export class RunManager {
846
887
  * `continue-N` step and the session before it are durable, which is enough. Otherwise the
847
888
  * workflow is revived from the record. A run that can be neither is failed loudly rather than
848
889
  * left in the queue as a ghost.
890
+ *
891
+ * Both effects here — an NDJSON append and a queue push — must not outlive dispose(), so the
892
+ * flag is re-read after `reviveWorkflow`, the one await this method crosses (#125). Bailing
893
+ * leaves the record `queued`, which is exactly what it was: re-adoption is what boot recovery
894
+ * does for the next owner of this data directory.
849
895
  */
850
896
  async reviveQueuedRun(run, reason) {
897
+ if (this.disposed)
898
+ return;
851
899
  const queuedContinuation = [...run.steps]
852
900
  .reverse()
853
901
  .find((step) => step.status === 'pending' && step.id.startsWith('continue-'));
@@ -872,6 +920,8 @@ export class RunManager {
872
920
  return;
873
921
  }
874
922
  const workflow = await this.reviveWorkflow(run);
923
+ if (this.disposed)
924
+ return;
875
925
  if (!workflow) {
876
926
  this.store.updateRun(run.id, {
877
927
  status: 'failed',
@@ -888,7 +938,7 @@ export class RunManager {
888
938
  // safe either way — but a run queued while the inbox was on and recovered after it was
889
939
  // switched off would otherwise keep echoing `generateFollowups: true` on a run that
890
940
  // demonstrably produced none. Normalize the record, the way startRun does.
891
- const generateFollowups = followupsEnabled() ? run.generateFollowups : false;
941
+ const generateFollowups = this.semaphore.followupsEnabled() ? run.generateFollowups : false;
892
942
  if (generateFollowups !== run.generateFollowups) {
893
943
  this.store.updateRun(run.id, { generateFollowups });
894
944
  }
@@ -1248,13 +1298,32 @@ export class RunManager {
1248
1298
  * honest hold, with a real deadline behind it this time.
1249
1299
  *
1250
1300
  * Public so a test can drive the wedge directly instead of waiting out the interval.
1301
+ *
1302
+ * The sweep itself is `sweepStalledQueue`; this wrapper only publishes it to `rescuesInFlight`
1303
+ * so dispose() can settle it (#125). Every entry point goes through here — the interval and the
1304
+ * tests alike — so nothing can start a sweep that teardown cannot see. The schedule, the
1305
+ * conditions and the effects are untouched.
1251
1306
  */
1252
1307
  async rescueStalledQueue(now = Date.now()) {
1308
+ if (this.disposed)
1309
+ return;
1310
+ const sweep = this.sweepStalledQueue(now);
1311
+ this.rescuesInFlight.add(sweep);
1312
+ try {
1313
+ await sweep;
1314
+ }
1315
+ finally {
1316
+ this.rescuesInFlight.delete(sweep);
1317
+ }
1318
+ }
1319
+ async sweepStalledQueue(now) {
1253
1320
  // First, the worst shape: a record that says `queued` while the engine holds no job, no
1254
1321
  // continuation and no queue entry for it. `pump()` cannot see such a run — it iterates the
1255
1322
  // queue, and this one is not in it — so nothing will ever start it. Re-adopt it through the
1256
1323
  // same path boot recovery uses.
1257
1324
  for (const run of this.store.listRuns()) {
1325
+ if (this.disposed)
1326
+ return;
1258
1327
  if (run.status !== 'queued')
1259
1328
  continue;
1260
1329
  if (this.active.has(run.id) || this.starting.has(run.id))
@@ -1266,6 +1335,8 @@ export class RunManager {
1266
1335
  console.warn(`[xez] queue watchdog: re-adopting queued run ${run.id} the engine had lost`);
1267
1336
  await this.reviveQueuedRun(run, 'queue watchdog');
1268
1337
  }
1338
+ if (this.disposed)
1339
+ return;
1269
1340
  if (this.queue.length === 0)
1270
1341
  return;
1271
1342
  if (this.busySlots() > 0 || this.starting.size > 0)
@@ -1910,6 +1981,12 @@ export class RunManager {
1910
1981
  // human got there first — and then the counter starts over, because the cap only exists to
1911
1982
  // bound UNATTENDED resumes.
1912
1983
  this.clearAutoResume(runId);
1984
+ // Accepted Continue is explicit new work: make it discoverable in Active again.
1985
+ // Refusals above leave the user's archive decision untouched.
1986
+ if (run.archived) {
1987
+ this.store.setArchived(runId, false);
1988
+ this.store.appendEvent(runId, { type: 'note', message: 'unarchived for continuation' });
1989
+ }
1913
1990
  const continuations = run.steps.filter((s) => s.id.startsWith('continue-')).length;
1914
1991
  const stepId = `continue-${continuations + 1}`;
1915
1992
  this.store.addStep(runId, { id: stepId, name: 'Continue', kind: 'agent' });
@@ -1952,22 +2029,68 @@ export class RunManager {
1952
2029
  * reconstructed at dequeue. Keep them separate from fresh `images` so
1953
2030
  * opening a recovered continuation does not persist duplicate files. */
1954
2031
  persistedImages = [], persistedAttachments = []) {
2032
+ // Continue has already been accepted. Preserve its input before asynchronous
2033
+ // recovery or isolation refusal can stop startup; persistence is not runner receipt.
2034
+ // Attachments pasted into the follow-up composer, on the same terms as a live-session
2035
+ // message (#357): persisted to the run's own attachment store so the thread renders the
2036
+ // bubble's images rather than a bare count, and handed to the agent as absolute paths
2037
+ // appended to the prompt (so it can operate on them — and because codex/opencode drop image
2038
+ // blocks before they reach the model). An image ALSO rides along as a base64 block so the
2039
+ // model can view it; a file (#950) has nothing to view and travels as its path alone.
2040
+ const freshAttachments = this.persistPastedAttachments(runId, images);
2041
+ const openingImages = [...contentBlocksOf(images), ...persistedImages];
2042
+ const attachments = [...freshAttachments, ...persistedAttachments];
2043
+ this.store.appendEvent(runId, {
2044
+ type: 'user-message',
2045
+ stepId,
2046
+ text: prompt,
2047
+ imageCount: openingImages.filter((b) => b.type === 'image').length,
2048
+ ...(attachments.length ? { images: attachments.map((saved) => saved.url) } : {}),
2049
+ });
1955
2050
  // Continuation runs in the task's worktree when it still exists (spec
1956
2051
  // 006) — the resumed session sees exactly what the original run left.
1957
2052
  // Retention (#483) may have reclaimed this run's worktree directory while
1958
2053
  // keeping its branch and worktreePath. Re-materialize it on resume and clear
1959
2054
  // the stamp so the session regains its isolated tree and the run is eligible
1960
2055
  // for retention again — otherwise it keeps a dir on disk while staying
1961
- // invisible to the enforcer forever. Best-effort; falls back to repoRoot.
2056
+ // invisible to the enforcer forever. A failed restoration must not widen isolation.
1962
2057
  await rematerializeReclaimedWorktree(this.repoRoot, this.store, runId);
1963
2058
  const record = this.store.getRun(runId);
2059
+ // A recorded path/branch proves prior isolation; an absent policy alone does not
2060
+ // (legacy and non-git runs legitimately ran in place). Never resume that isolated
2061
+ // session against primary while that isolation identity is still recorded.
2062
+ // Legacy cleanup that erased BOTH path and branch cannot be distinguished
2063
+ // from an old in-place run; it is not a qualified isolation recovery route.
2064
+ if (record?.worktree !== false && (record?.worktreePath || record?.branch) &&
2065
+ (!record.worktreePath || !existsSync(record.worktreePath))) {
2066
+ const error = 'continuation isolation unavailable — restore the task worktree before retrying';
2067
+ const finishedAt = new Date().toISOString();
2068
+ this.store.updateStep(runId, stepId, { status: 'failed', error, finishedAt });
2069
+ this.store.updateRun(runId, { status: 'failed', error, finishedAt, currentStepId: undefined });
2070
+ this.store.appendEvent(runId, { type: 'lifecycle', message: `run failed — ${error}` });
2071
+ this.starting.delete(runId);
2072
+ this.dropActive(runId);
2073
+ return;
2074
+ }
1964
2075
  // The env is a live ceiling: a run created while the inbox was on must not keep writing
1965
2076
  // follow-ups after it is switched off.
1966
- const generateFollowups = followupsEnabled() && record?.generateFollowups !== false;
2077
+ const generateFollowups = this.semaphore.followupsEnabled() && record?.generateFollowups !== false;
1967
2078
  const cwd = record?.worktreePath && existsSync(record.worktreePath)
1968
2079
  ? record.worktreePath
1969
2080
  : this.repoRoot;
1970
- const state = { cancelled: false, interrupt: () => undefined, cwd };
2081
+ // Autonomy is re-read from the RECORD, the way restart recovery re-threads it into
2082
+ // `execute`'s input: a continuation builds its OWN `ActiveRun`, and omitting the field here
2083
+ // is what made every Continue and every recovered resume silently non-autonomous while the
2084
+ // record still said otherwise (#141) — the same construction-site asymmetry `state.skills`
2085
+ // hit in #811. `autoContinues` starts at 0 rather than `undefined` so `MAX_AUTO_CONTINUES`
2086
+ // bounds this path too; the budget is per `ActiveRun`, so a Continue starts a fresh one.
2087
+ const state = {
2088
+ cancelled: false,
2089
+ interrupt: () => undefined,
2090
+ cwd,
2091
+ autonomous: record?.autonomous === true,
2092
+ autoContinues: 0,
2093
+ };
1971
2094
  this.active.set(runId, state);
1972
2095
  this.starting.delete(runId);
1973
2096
  if (state.cwd === this.repoRoot) {
@@ -2017,22 +2140,6 @@ export class RunManager {
2017
2140
  backend,
2018
2141
  });
2019
2142
  this.store.appendEvent(runId, { type: 'step-start', stepId, name: 'Continue', kind: 'agent', iteration: 1 });
2020
- // Attachments pasted into the follow-up composer, on the same terms as a live-session
2021
- // message (#357): persisted to the run's own attachment store so the thread renders the
2022
- // bubble's images rather than a bare count, and handed to the agent as absolute paths
2023
- // appended to the prompt (so it can operate on them — and because codex/opencode drop image
2024
- // blocks before they reach the model). An image ALSO rides along as a base64 block so the
2025
- // model can view it; a file (#950) has nothing to view and travels as its path alone.
2026
- const freshAttachments = this.persistPastedAttachments(runId, images);
2027
- const openingImages = [...contentBlocksOf(images), ...persistedImages];
2028
- const attachments = [...freshAttachments, ...persistedAttachments];
2029
- this.store.appendEvent(runId, {
2030
- type: 'user-message',
2031
- stepId,
2032
- text: prompt,
2033
- imageCount: openingImages.filter((b) => b.type === 'image').length,
2034
- ...(attachments.length ? { images: attachments.map((saved) => saved.url) } : {}),
2035
- });
2036
2143
  let stepCost = 0;
2037
2144
  let turnText = '';
2038
2145
  let sessionError;
@@ -2091,29 +2198,19 @@ export class RunManager {
2091
2198
  state.session?.end();
2092
2199
  return;
2093
2200
  }
2201
+ let autoContinued = false;
2094
2202
  if (sessionOpen) {
2095
2203
  // Autonomous (#autonomous): never hand the ball back to the user. Nudge the agent to
2096
- // keep going (bounded by MAX_AUTO_CONTINUES) instead of parking at `waiting`.
2097
- const autoContinued = state.autonomous &&
2098
- (state.autoContinues ?? 0) < MAX_AUTO_CONTINUES &&
2099
- !state.cancelled &&
2100
- (() => {
2101
- const sent = state.session?.sendMessage([{ type: 'text', text: AUTONOMOUS_NUDGE }]);
2102
- if (!sent)
2103
- return false;
2104
- state.autoContinues = (state.autoContinues ?? 0) + 1;
2105
- this.store.appendEvent(runId, {
2106
- type: 'note',
2107
- message: `autonomous — continuing without pausing (${state.autoContinues}/${MAX_AUTO_CONTINUES})`,
2108
- });
2109
- return true;
2110
- })();
2204
+ // keep going (bounded by MAX_AUTO_CONTINUES) instead of parking at `waiting`. Shared
2205
+ // with `execute`'s turn-end handler — one sender, both sites (#141).
2206
+ autoContinued = !monitoring && this.autoContinueTurn(runId, state);
2111
2207
  if (!autoContinued) {
2112
2208
  // `XEZ:ASK` → park `waiting` (attention) AND surface the structured
2113
2209
  // question as an ask card (#473). `XEZ:MONITORING` → non-attention
2114
2210
  // `running`/`activity:'monitoring'` (#490). Both share the waiting
2115
2211
  // lifecycle (free the slot, keep the idle timer); the autonomous
2116
- // nudge above still wins over either.
2212
+ // nudge may answer ASK in autonomous mode, but explicit monitoring
2213
+ // parks with its bounded wake timer instead of spinning immediately.
2117
2214
  if (ask)
2118
2215
  emitAskRequested(sink, ask);
2119
2216
  if (monitoring) {
@@ -2143,7 +2240,10 @@ export class RunManager {
2143
2240
  if (this.store.getRun(runId)?.autoResumeAttempts !== undefined) {
2144
2241
  this.store.updateRun(runId, { autoResumeAttempts: undefined });
2145
2242
  }
2146
- appendHandoffHeartbeat(this.dataDir, runId, `turn complete — status=${monitoring ? 'monitoring' : sessionOpen ? 'waiting' : 'running'}`);
2243
+ appendHandoffHeartbeat(this.dataDir, runId,
2244
+ // A nudged turn did NOT park — the agent is working again, so the heartbeat must not
2245
+ // read `waiting` (#141; before the fix this branch was unreachable).
2246
+ `turn complete — status=${autoContinued ? 'running' : monitoring ? 'monitoring' : sessionOpen ? 'waiting' : 'running'}`);
2147
2247
  }
2148
2248
  };
2149
2249
  // Backend + model come off the record: the run's current backend by default, or the
@@ -2264,11 +2364,37 @@ export class RunManager {
2264
2364
  if (session.pid !== undefined)
2265
2365
  registerRunProcess(runId, session.pid);
2266
2366
  const finishedAt = () => new Date().toISOString();
2367
+ /**
2368
+ * The turn's teardown — usage peaks, timers, and the turn-end autosave commit — runs
2369
+ * BEFORE this turn's terminal status is published, which is the order `execute` already
2370
+ * uses on the workflow path (`run finalize`, then `settleSuccess`, then `dropActive`,
2371
+ * with no await between the last two). The continuation path had it the other way round,
2372
+ * and that asymmetry is the second half of #155: `settleSuccess` published `review` and
2373
+ * only then did the `finally` spawn git for `autosaveCommit`, so for the whole width of
2374
+ * that commit the run advertised the review gate while still sitting in `active`.
2375
+ * `finish()` refuses to accept a `review` run while `isActive(runId)` holds — the guard
2376
+ * that stops a Finish from racing a send-back continuation into `done` — so a ✓ Accept
2377
+ * arriving in that window answered `409 no open session` and the accept was lost, with
2378
+ * the run left parked at `review` forever. Idempotent, because the terminal paths call it
2379
+ * explicitly and `finally` calls it again for a throw that never reached one.
2380
+ */
2381
+ let turnTornDown = false;
2382
+ const endTurn = async () => {
2383
+ if (turnTornDown)
2384
+ return;
2385
+ turnTornDown = true;
2386
+ this.recordUsagePeaks(runId);
2387
+ this.clearIdleTimer(state);
2388
+ this.clearAutosaveTimer(state);
2389
+ if (state.cwd !== this.repoRoot)
2390
+ await autosaveCommit(state.cwd, 'turn end');
2391
+ };
2267
2392
  try {
2268
2393
  await session.result;
2269
2394
  if (sessionError)
2270
2395
  throw new Error(sessionError);
2271
2396
  sink.sessionEnded(state.cancelled ? 'cancelled' : 'end_turn');
2397
+ await endTurn();
2272
2398
  if (state.cancelled) {
2273
2399
  this.store.updateStep(runId, stepId, { status: 'cancelled', finishedAt: finishedAt() });
2274
2400
  this.store.updateRun(runId, { status: 'cancelled', finishedAt: finishedAt(), currentStepId: undefined });
@@ -2285,6 +2411,7 @@ export class RunManager {
2285
2411
  catch (err) {
2286
2412
  const message = err instanceof Error ? err.message : String(err);
2287
2413
  sink.sessionEnded('error', message);
2414
+ await endTurn();
2288
2415
  this.store.updateStep(runId, stepId, { status: 'failed', error: message, finishedAt: finishedAt() });
2289
2416
  appendHandoffHeartbeat(this.dataDir, runId, `step "${stepId}" complete — status=failed`);
2290
2417
  this.store.updateRun(runId, {
@@ -2296,14 +2423,38 @@ export class RunManager {
2296
2423
  this.store.appendEvent(runId, { type: 'lifecycle', message: `continue failed — ${message}` });
2297
2424
  }
2298
2425
  finally {
2299
- this.recordUsagePeaks(runId);
2300
- this.clearIdleTimer(state);
2301
- this.clearAutosaveTimer(state);
2302
- if (state.cwd !== this.repoRoot)
2303
- await autosaveCommit(state.cwd, 'turn end');
2426
+ await endTurn();
2304
2427
  this.dropActive(runId);
2305
2428
  }
2306
2429
  }
2430
+ /**
2431
+ * Autonomous turn end (#489): never hand the ball back to the user — nudge the agent to keep
2432
+ * going and report whether the ball stayed with it. `false` means "park normally", which is
2433
+ * every non-autonomous run, a run whose pause cleared `autonomous` (the memory-limit
2434
+ * suppression above), a cancelled one, a closed session that cannot take the message, and a
2435
+ * run that has spent its `MAX_AUTO_CONTINUES` budget.
2436
+ *
2437
+ * ONE helper, called from BOTH turn-end handlers, because the split is exactly what broke:
2438
+ * `execute` set `autonomous` on its `ActiveRun` and its handler never read it, while
2439
+ * `runContinuation`'s handler read it and its `ActiveRun` never set it — so the nudge fired
2440
+ * nowhere and every autonomous run parked after one turn (#141). AGENTS.md names both traps:
2441
+ * grep the TYPE at every construction site, and treat the two near-identical turn-end handlers
2442
+ * as one place. Keep this the only sender of `AUTONOMOUS_NUDGE`.
2443
+ */
2444
+ autoContinueTurn(runId, state) {
2445
+ if (!state.autonomous || state.cancelled)
2446
+ return false;
2447
+ if ((state.autoContinues ?? 0) >= MAX_AUTO_CONTINUES)
2448
+ return false;
2449
+ if (!state.session?.sendMessage([{ type: 'text', text: AUTONOMOUS_NUDGE }]))
2450
+ return false;
2451
+ state.autoContinues = (state.autoContinues ?? 0) + 1;
2452
+ this.store.appendEvent(runId, {
2453
+ type: 'note',
2454
+ message: `autonomous — continuing without pausing (${state.autoContinues}/${MAX_AUTO_CONTINUES})`,
2455
+ });
2456
+ return true;
2457
+ }
2307
2458
  // ---- execution -----------------------------------------------------------
2308
2459
  async execute(runId, workflow, input) {
2309
2460
  const state = {
@@ -2730,7 +2881,13 @@ export class RunManager {
2730
2881
  return;
2731
2882
  }
2732
2883
  const waiting = interactive && sessionOpen;
2733
- if (waiting) {
2884
+ // Autonomous (#autonomous): the turn is over and the session is open, which is exactly
2885
+ // the moment this run must NOT park. Same helper, same bound, same note as
2886
+ // `runContinuation`'s handler (#141) — the field `execute` has always set is finally
2887
+ // read here. `interactive` still gates it: a mid-workflow step ends its own session and
2888
+ // the next step follows, so there is no ball to hand back and nothing to nudge.
2889
+ const autoContinued = waiting && !monitoring ? this.autoContinueTurn(runId, state) : false;
2890
+ if (waiting && !autoContinued) {
2734
2891
  // Turn over, session open. Either the ball is in the user's court
2735
2892
  // (`waiting`) — optionally with a structured `XEZ:ASK` question the
2736
2893
  // cockpit renders as an ask card (#473) — or the agent declared it is
@@ -2764,7 +2921,7 @@ export class RunManager {
2764
2921
  }
2765
2922
  // Xez's own heartbeat — the handoff stays current even when the
2766
2923
  // agent forgets to write (spec 007).
2767
- appendHandoffHeartbeat(this.dataDir, runId, `turn complete — status=${monitoring ? 'monitoring' : waiting ? 'waiting' : 'running'}`);
2924
+ appendHandoffHeartbeat(this.dataDir, runId, `turn complete — status=${autoContinued ? 'running' : monitoring ? 'monitoring' : waiting ? 'waiting' : 'running'}`);
2768
2925
  }
2769
2926
  };
2770
2927
  const stepBackend = step.runner ?? taskBackend;
@@ -2797,7 +2954,7 @@ export class RunManager {
2797
2954
  let stepProfile;
2798
2955
  try {
2799
2956
  stepProfile = await this.agentEnvForStep(runId, stepBackend, {
2800
- generateFollowups: followupsEnabled() && input.generateFollowups !== false,
2957
+ generateFollowups: this.semaphore.followupsEnabled() && input.generateFollowups !== false,
2801
2958
  });
2802
2959
  }
2803
2960
  catch (err) {
@@ -2814,7 +2971,7 @@ export class RunManager {
2814
2971
  session = runner.startSession({
2815
2972
  // Skill body, then the run's extra prompt (POST override or config
2816
2973
  // default), then the handoff/todos contract — every agent step.
2817
- systemPrompt: composeSystemPrompt(systemPrompt, extraSystemPrompt, followupsEnabled() && input.generateFollowups !== false
2974
+ systemPrompt: composeSystemPrompt(systemPrompt, extraSystemPrompt, this.semaphore.followupsEnabled() && input.generateFollowups !== false
2818
2975
  ? HANDOFF_INSTRUCTIONS
2819
2976
  : HANDOFF_ONLY_INSTRUCTIONS),
2820
2977
  userPrompt,
@@ -2827,8 +2984,10 @@ export class RunManager {
2827
2984
  env: stepProfile.env,
2828
2985
  model: backendModel,
2829
2986
  sessionId,
2830
- // Interactive sessions have no wall clock — the idle timer rules.
2831
- timeoutMs: interactive ? 0 : undefined,
2987
+ // Interactive sessions have no wall clock — the idle timer rules. A
2988
+ // step's own `timeout` (#22) outranks that; with the field absent
2989
+ // this is byte-for-byte the pre-#22 `interactive ? 0 : undefined`.
2990
+ timeoutMs: stepTimeoutMs(step, interactive),
2832
2991
  }, onEvent, {
2833
2992
  autoEndAfterFirstTurn: !interactive,
2834
2993
  onUiEvent: (event) => this.handleRunnerUiEvent(runId, state, sink, event),
@@ -3235,17 +3394,29 @@ export class RunManager {
3235
3394
  return null;
3236
3395
  }
3237
3396
  }
3397
+ /**
3398
+ * Arm the idle wall clock for a session that just parked at `waiting`.
3399
+ *
3400
+ * The duration comes from the shared semaphore's cached `resources.idleTimeoutMinutes`,
3401
+ * so a change made in Settings applies to the next park with no restart. `null` means
3402
+ * the operator chose "never close on idle": no timer is armed, and the only exits left
3403
+ * are a user message and Cancel — both of which a `waiting` run already advertises.
3404
+ */
3238
3405
  armIdleTimer(runId, state) {
3239
3406
  this.clearIdleTimer(state);
3407
+ const minutes = this.semaphore.idleTimeoutMinutes();
3408
+ if (minutes === null)
3409
+ return;
3410
+ const timeoutMs = minutes * 60_000;
3240
3411
  state.idleTimer = setTimeout(() => {
3241
3412
  if (state.session?.open && !state.cancelled) {
3242
3413
  this.store.appendEvent(runId, {
3243
3414
  type: 'lifecycle',
3244
- message: `session closed after ${Math.round(IDLE_TIMEOUT_MS / 60_000)}m of inactivity`,
3415
+ message: `session closed after ${Math.round(timeoutMs / 60_000)}m of inactivity`,
3245
3416
  });
3246
3417
  state.session.end();
3247
3418
  }
3248
- }, IDLE_TIMEOUT_MS);
3419
+ }, timeoutMs);
3249
3420
  state.idleTimer.unref?.();
3250
3421
  }
3251
3422
  clearIdleTimer(state) {