@qodeca/xezar 0.11.2 → 0.13.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (138) hide show
  1. package/README.md +69 -18
  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/store.d.ts +2 -0
  54. package/dist/runs/store.js +13 -2
  55. package/dist/runs/store.js.map +1 -1
  56. package/dist/runs/task-markers.d.ts +4 -0
  57. package/dist/runs/task-markers.js +63 -5
  58. package/dist/runs/task-markers.js.map +1 -1
  59. package/dist/server/capabilities.d.ts +7 -5
  60. package/dist/server/capabilities.js +11 -7
  61. package/dist/server/capabilities.js.map +1 -1
  62. package/dist/server/project-context.js +2 -0
  63. package/dist/server/project-context.js.map +1 -1
  64. package/dist/server/server.d.ts +199 -47
  65. package/dist/server/server.js +195 -11
  66. package/dist/server/server.js.map +1 -1
  67. package/dist/workflows/run.d.ts +67 -2
  68. package/dist/workflows/run.js +190 -44
  69. package/dist/workflows/run.js.map +1 -1
  70. package/dist/workflows/types.d.ts +41 -0
  71. package/dist/workflows/types.js +72 -0
  72. package/dist/workflows/types.js.map +1 -1
  73. package/dist/workspace/config.d.ts +42 -0
  74. package/dist/workspace/config.js +132 -3
  75. package/dist/workspace/config.js.map +1 -1
  76. package/dist/workspace/semaphore.d.ts +63 -3
  77. package/dist/workspace/semaphore.js +76 -3
  78. package/dist/workspace/semaphore.js.map +1 -1
  79. package/package.json +3 -3
  80. package/web/dist/assets/{alert-dialog-CGskMvgc.js → alert-dialog-BVsVcbFy.js} +1 -1
  81. package/web/dist/assets/arrow-down-CYgAlZtW.js +1 -0
  82. package/web/dist/assets/arrow-left-BOqE0a3k.js +1 -0
  83. package/web/dist/assets/{centered-state-B_V_Ai2h.js → centered-state-Djgly6Ni.js} +1 -1
  84. package/web/dist/assets/chevron-right-Cc9BWnbN.js +1 -0
  85. package/web/dist/assets/{collapsible-B_8uYwHb.js → collapsible-DZ4D-DHG.js} +1 -1
  86. package/web/dist/assets/{commit-list-C4Dv6vUL.js → commit-list-B-z1JL9G.js} +1 -1
  87. package/web/dist/assets/{compare-variants-et0mplGi.js → compare-variants-CdS8r8E2.js} +1 -1
  88. package/web/dist/assets/{diff-CbozNUJq.js → diff-BKew9rQX.js} +2 -2
  89. package/web/dist/assets/{diff-stat-btAmKFJr.js → diff-stat-D0mc5V6Z.js} +1 -1
  90. package/web/dist/assets/{diff-view-BpFTcG3R.js → diff-view-xpIcuLIC.js} +1 -1
  91. package/web/dist/assets/{dropdown-menu-Z80KMInL.js → dropdown-menu-CEQziH7X.js} +1 -1
  92. package/web/dist/assets/editable-title-BCX3T2BY.js +1 -0
  93. package/web/dist/assets/{ellipsis-vertical-Bmi2u2TG.js → ellipsis-vertical-ByVdDc4z.js} +1 -1
  94. package/web/dist/assets/{file-DKoYWRsv.js → file-xBSB5hXO.js} +1 -1
  95. package/web/dist/assets/{folder-u01HScRT.js → folder-CldgbZ5A.js} +1 -1
  96. package/web/dist/assets/{git-toolbar-Q2kk2F7v.js → git-toolbar-BYlnf4qf.js} +1 -1
  97. package/web/dist/assets/github-CqGK62Lw.js +1 -0
  98. package/web/dist/assets/{image-preview-BR3iIIhf.js → image-preview-Cv5mZr0n.js} +1 -1
  99. package/web/dist/assets/index-3nxVmdn5.css +2 -0
  100. package/web/dist/assets/index-qX7n11FX.js +9 -0
  101. package/web/dist/assets/{markdown-5FuCZDA4.js → markdown-CgEEE2ND.js} +1 -1
  102. package/web/dist/assets/{new-task-form-BydHBbef.js → new-task-form-1r2bgsdD.js} +1 -1
  103. package/web/dist/assets/{pill-vNzaaYxj.js → pill-q-x22_ej.js} +1 -1
  104. package/web/dist/assets/{project-router-BbCzrHKs.js → project-router-VDN4jQM8.js} +1 -1
  105. package/web/dist/assets/{prompt-templates-B7Fs2b1S.js → prompt-templates-BmZtCe-L.js} +1 -1
  106. package/web/dist/assets/{refresh-cw-DGliez3E.js → refresh-cw-DcNCj2nu.js} +1 -1
  107. package/web/dist/assets/{repo-git-h1RFfly3.js → repo-git-C2On8SlI.js} +1 -1
  108. package/web/dist/assets/{run-diff-Bq9vkSNK.js → run-diff-vguEMJzh.js} +1 -1
  109. package/web/dist/assets/{run-header-Cd8mSKBG.js → run-header-DM7MiKj9.js} +1 -1
  110. package/web/dist/assets/{search-x-69n_672Y.js → search-x-QmhaAbg5.js} +1 -1
  111. package/web/dist/assets/{skill-empty-hint-CL0g7WBL.js → skill-empty-hint-BrzUwByZ.js} +1 -1
  112. package/web/dist/assets/{skills-BqAzyGS3.js → skills-Bj-uG_Hz.js} +1 -1
  113. package/web/dist/assets/{sparkles-B8agSxBZ.js → sparkles-CP8QDOgX.js} +1 -1
  114. package/web/dist/assets/{square-terminal-Cf4Og6ct.js → square-terminal-CGypshSD.js} +1 -1
  115. package/web/dist/assets/{tab-link-Cp2-CJS8.js → tab-link-D4lFzzI1.js} +1 -1
  116. package/web/dist/assets/{task-changes-DwXPFXqG.js → task-changes-6aopqAmV.js} +1 -1
  117. package/web/dist/assets/{task-commits-Bessua-c.js → task-commits-u-dwaCyw.js} +1 -1
  118. package/web/dist/assets/{task-files-DxFIhRPP.js → task-files-IMOZnIUK.js} +1 -1
  119. package/web/dist/assets/task-thread-CbMPmrWq.js +9 -0
  120. package/web/dist/assets/{textarea--cpYshee.js → textarea-CyBCyKIk.js} +1 -1
  121. package/web/dist/assets/{thread-loading-ZhJref0J.js → thread-loading-Cuq2jchC.js} +1 -1
  122. package/web/dist/assets/{trash-2--ZtrzUx3.js → trash-2-B6Dp3ZNs.js} +1 -1
  123. package/web/dist/assets/{triangle-alert-D5tJwRD2.js → triangle-alert-xBya8FUp.js} +1 -1
  124. package/web/dist/assets/{upload-SnwVxKOA.js → upload-D6MXEB9p.js} +1 -1
  125. package/web/dist/assets/{use-desktop-Sr1QlxhT.js → use-desktop-fLDKEJoB.js} +1 -1
  126. package/web/dist/assets/{use-submit-shortcut-JY8M_MPL.js → use-submit-shortcut-JTpNzU-u.js} +1 -1
  127. package/web/dist/assets/{utils-Bb8ohcr3.js → utils--z9hRtl3.js} +1 -1
  128. package/web/dist/assets/{workflows-9HC240Wx.js → workflows-BY3KTkFw.js} +3 -3
  129. package/web/dist/assets/{zoomable-image-0G3IBXWC.js → zoomable-image-GTYwLtkS.js} +1 -1
  130. package/web/dist/index.html +23 -23
  131. package/web/dist/assets/arrow-down-G8adPhbu.js +0 -1
  132. package/web/dist/assets/arrow-left-BJsIW6A0.js +0 -1
  133. package/web/dist/assets/chevron-right-CFzn-von.js +0 -1
  134. package/web/dist/assets/editable-title-wS5NWwob.js +0 -1
  135. package/web/dist/assets/github-DjEYCxwt.js +0 -1
  136. package/web/dist/assets/index-BcI5hug3.css +0 -2
  137. package/web/dist/assets/index-Cz4GSAqV.js +0 -7
  138. package/web/dist/assets/task-thread-Dhkupf1-.js +0 -9
@@ -4,7 +4,15 @@ import { type Skill } from '../skills.ts';
4
4
  import type { QueuedMessage, RunRecord, RunStore } from '../runs/store.ts';
5
5
  import { WorkspaceSemaphore, type AccountHolds } from '../workspace/semaphore.ts';
6
6
  import { type WorkflowDef } from './types.ts';
7
- /** An interactive session that hears nothing from the user closes itself. */
7
+ /**
8
+ * An interactive session that hears nothing from the user closes itself.
9
+ *
10
+ * Kept as the module default so `stepTimeoutMs`-style callers and old imports still
11
+ * resolve, but enforcement no longer reads it directly: `armIdleTimer` asks the shared
12
+ * `WorkspaceSemaphore`, so `resources.idleTimeoutMinutes` takes effect on the next park
13
+ * without a restart. This constant IS that setting's default, via
14
+ * `DEFAULT_IDLE_TIMEOUT_MINUTES`.
15
+ */
8
16
  export declare const IDLE_TIMEOUT_MS: number;
9
17
  /**
10
18
  * Preserve boundaries between complete assistant text blocks while a turn is
@@ -28,6 +36,9 @@ export declare function periodicAutosaveEnabled(env?: NodeJS.ProcessEnv): boolea
28
36
  * files or Git state. Isolated worktree runs are unaffected.
29
37
  */
30
38
  export declare function repositoryRootLockDisabled(env?: NodeJS.ProcessEnv): boolean;
39
+ /** Safety cap on autonomous auto-continues per run — stops a stuck agent from nudging forever.
40
+ * Exported so the regression suite pins the bound against the real number instead of a copy. */
41
+ export declare const MAX_AUTO_CONTINUES = 40;
31
42
  /**
32
43
  * Auto-resume after a provider usage limit (spec 2026-08-03-auto-resume-after-usage-limit).
33
44
  *
@@ -254,6 +265,15 @@ export declare class RunManager {
254
265
  private readonly offUsage;
255
266
  /** The stalled-queue watchdog (see `rescueStalledQueue`). */
256
267
  private readonly queueWatchdog;
268
+ /** The rescue sweeps that have STARTED and not settled yet (#125). `clearInterval` cancels the
269
+ * next tick; it cannot cancel the tick already running, and a sweep is async — it awaits
270
+ * `reviveWorkflow` before appending NDJSON. dispose() settles these so a torn-down manager is
271
+ * provably finished writing before its caller removes the data root under it. */
272
+ private readonly rescuesInFlight;
273
+ /** Set by dispose(): this manager makes no further moves. Re-checked at every await boundary a
274
+ * rescue crosses, because a sweep that was already past its first check when dispose() landed
275
+ * would otherwise write an event — and re-populate the queue dispose() had just emptied. */
276
+ private disposed;
257
277
  /** Set by the watchdog for exactly one sweep: ignore the usage-limit hold and make progress. */
258
278
  private forceNextPump;
259
279
  /** Runs the watchdog started despite the hold. The spawn-time gate (`requeueWhileHeld`) would
@@ -278,8 +298,19 @@ export declare class RunManager {
278
298
  * repo-root locks, and empties the queued state so nothing fires later.
279
299
  * Live sessions are NOT ended here: run lifecycle stays the caller's policy;
280
300
  * dispose only guarantees the manager makes no further moves on its own.
301
+ *
302
+ * Every side effect below is SYNCHRONOUS, so a caller that ignores the return value behaves
303
+ * exactly as it did before #125. The returned promise settles the one thing dispose cannot do
304
+ * synchronously: a queue-watchdog rescue that had already started. `clearInterval` stops the
305
+ * next tick, never the running one — the same discipline `AGENTS.md` records for the e2e
306
+ * fixture servers, where `kill()` only delivers the signal and the helper awaits the exit.
307
+ * Await it whenever the data root is about to be removed; a test that deletes its temp
308
+ * directory without awaiting is the ENOENT in #125.
309
+ *
310
+ * It never rejects: a sweep that fails still belongs to whoever started it (the watchdog floats
311
+ * it exactly as before), and teardown must not become a second place that error surfaces.
281
312
  */
282
- dispose(): void;
313
+ dispose(): Promise<void>;
283
314
  /**
284
315
  * Pause any active run whose whole process tree exceeds the WORKSPACE
285
316
  * `resources.memoryLimitMb`, freeing its slot so the queue advances
@@ -384,6 +415,11 @@ export declare class RunManager {
384
415
  * `continue-N` step and the session before it are durable, which is enough. Otherwise the
385
416
  * workflow is revived from the record. A run that can be neither is failed loudly rather than
386
417
  * left in the queue as a ghost.
418
+ *
419
+ * Both effects here — an NDJSON append and a queue push — must not outlive dispose(), so the
420
+ * flag is re-read after `reviveWorkflow`, the one await this method crosses (#125). Bailing
421
+ * leaves the record `queued`, which is exactly what it was: re-adoption is what boot recovery
422
+ * does for the next owner of this data directory.
387
423
  */
388
424
  private reviveQueuedRun;
389
425
  /**
@@ -466,8 +502,14 @@ export declare class RunManager {
466
502
  * honest hold, with a real deadline behind it this time.
467
503
  *
468
504
  * Public so a test can drive the wedge directly instead of waiting out the interval.
505
+ *
506
+ * The sweep itself is `sweepStalledQueue`; this wrapper only publishes it to `rescuesInFlight`
507
+ * so dispose() can settle it (#125). Every entry point goes through here — the interval and the
508
+ * tests alike — so nothing can start a sweep that teardown cannot see. The schedule, the
509
+ * conditions and the effects are untouched.
469
510
  */
470
511
  rescueStalledQueue(now?: number): Promise<void>;
512
+ private sweepStalledQueue;
471
513
  /**
472
514
  * The accounts this project is currently holding: one key per run parked on a usage-limit
473
515
  * resume that has not come due yet (spec 2026-08-03-auto-resume-after-usage-limit).
@@ -639,6 +681,21 @@ export declare class RunManager {
639
681
  error?: string;
640
682
  };
641
683
  private runContinuation;
684
+ /**
685
+ * Autonomous turn end (#489): never hand the ball back to the user — nudge the agent to keep
686
+ * going and report whether the ball stayed with it. `false` means "park normally", which is
687
+ * every non-autonomous run, a run whose pause cleared `autonomous` (the memory-limit
688
+ * suppression above), a cancelled one, a closed session that cannot take the message, and a
689
+ * run that has spent its `MAX_AUTO_CONTINUES` budget.
690
+ *
691
+ * ONE helper, called from BOTH turn-end handlers, because the split is exactly what broke:
692
+ * `execute` set `autonomous` on its `ActiveRun` and its handler never read it, while
693
+ * `runContinuation`'s handler read it and its `ActiveRun` never set it — so the nudge fired
694
+ * nowhere and every autonomous run parked after one turn (#141). AGENTS.md names both traps:
695
+ * grep the TYPE at every construction site, and treat the two near-identical turn-end handlers
696
+ * as one place. Keep this the only sender of `AUTONOMOUS_NUDGE`.
697
+ */
698
+ private autoContinueTurn;
642
699
  private execute;
643
700
  /** Returns an error message, or null on success. */
644
701
  private runAgentStep;
@@ -747,6 +804,14 @@ export declare class RunManager {
747
804
  * shows the tool result's `[screenshot]` placeholder (or the image count).
748
805
  */
749
806
  private persistAttachment;
807
+ /**
808
+ * Arm the idle wall clock for a session that just parked at `waiting`.
809
+ *
810
+ * The duration comes from the shared semaphore's cached `resources.idleTimeoutMinutes`,
811
+ * so a change made in Settings applies to the next park with no restart. `null` means
812
+ * the operator chose "never close on idle": no timer is armed, and the only exits left
813
+ * are a user message and Cancel — both of which a `waiting` run already advertises.
814
+ */
750
815
  private armIdleTimer;
751
816
  private clearIdleTimer;
752
817
  private reconcileMonitoringWakeTimers;
@@ -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)
@@ -1963,11 +2034,23 @@ export class RunManager {
1963
2034
  const record = this.store.getRun(runId);
1964
2035
  // The env is a live ceiling: a run created while the inbox was on must not keep writing
1965
2036
  // follow-ups after it is switched off.
1966
- const generateFollowups = followupsEnabled() && record?.generateFollowups !== false;
2037
+ const generateFollowups = this.semaphore.followupsEnabled() && record?.generateFollowups !== false;
1967
2038
  const cwd = record?.worktreePath && existsSync(record.worktreePath)
1968
2039
  ? record.worktreePath
1969
2040
  : this.repoRoot;
1970
- const state = { cancelled: false, interrupt: () => undefined, cwd };
2041
+ // Autonomy is re-read from the RECORD, the way restart recovery re-threads it into
2042
+ // `execute`'s input: a continuation builds its OWN `ActiveRun`, and omitting the field here
2043
+ // is what made every Continue and every recovered resume silently non-autonomous while the
2044
+ // record still said otherwise (#141) — the same construction-site asymmetry `state.skills`
2045
+ // hit in #811. `autoContinues` starts at 0 rather than `undefined` so `MAX_AUTO_CONTINUES`
2046
+ // bounds this path too; the budget is per `ActiveRun`, so a Continue starts a fresh one.
2047
+ const state = {
2048
+ cancelled: false,
2049
+ interrupt: () => undefined,
2050
+ cwd,
2051
+ autonomous: record?.autonomous === true,
2052
+ autoContinues: 0,
2053
+ };
1971
2054
  this.active.set(runId, state);
1972
2055
  this.starting.delete(runId);
1973
2056
  if (state.cwd === this.repoRoot) {
@@ -2091,23 +2174,12 @@ export class RunManager {
2091
2174
  state.session?.end();
2092
2175
  return;
2093
2176
  }
2177
+ let autoContinued = false;
2094
2178
  if (sessionOpen) {
2095
2179
  // 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
- })();
2180
+ // keep going (bounded by MAX_AUTO_CONTINUES) instead of parking at `waiting`. Shared
2181
+ // with `execute`'s turn-end handler — one sender, both sites (#141).
2182
+ autoContinued = this.autoContinueTurn(runId, state);
2111
2183
  if (!autoContinued) {
2112
2184
  // `XEZ:ASK` → park `waiting` (attention) AND surface the structured
2113
2185
  // question as an ask card (#473). `XEZ:MONITORING` → non-attention
@@ -2143,7 +2215,10 @@ export class RunManager {
2143
2215
  if (this.store.getRun(runId)?.autoResumeAttempts !== undefined) {
2144
2216
  this.store.updateRun(runId, { autoResumeAttempts: undefined });
2145
2217
  }
2146
- appendHandoffHeartbeat(this.dataDir, runId, `turn complete — status=${monitoring ? 'monitoring' : sessionOpen ? 'waiting' : 'running'}`);
2218
+ appendHandoffHeartbeat(this.dataDir, runId,
2219
+ // A nudged turn did NOT park — the agent is working again, so the heartbeat must not
2220
+ // read `waiting` (#141; before the fix this branch was unreachable).
2221
+ `turn complete — status=${autoContinued ? 'running' : monitoring ? 'monitoring' : sessionOpen ? 'waiting' : 'running'}`);
2147
2222
  }
2148
2223
  };
2149
2224
  // Backend + model come off the record: the run's current backend by default, or the
@@ -2264,11 +2339,37 @@ export class RunManager {
2264
2339
  if (session.pid !== undefined)
2265
2340
  registerRunProcess(runId, session.pid);
2266
2341
  const finishedAt = () => new Date().toISOString();
2342
+ /**
2343
+ * The turn's teardown — usage peaks, timers, and the turn-end autosave commit — runs
2344
+ * BEFORE this turn's terminal status is published, which is the order `execute` already
2345
+ * uses on the workflow path (`run finalize`, then `settleSuccess`, then `dropActive`,
2346
+ * with no await between the last two). The continuation path had it the other way round,
2347
+ * and that asymmetry is the second half of #155: `settleSuccess` published `review` and
2348
+ * only then did the `finally` spawn git for `autosaveCommit`, so for the whole width of
2349
+ * that commit the run advertised the review gate while still sitting in `active`.
2350
+ * `finish()` refuses to accept a `review` run while `isActive(runId)` holds — the guard
2351
+ * that stops a Finish from racing a send-back continuation into `done` — so a ✓ Accept
2352
+ * arriving in that window answered `409 no open session` and the accept was lost, with
2353
+ * the run left parked at `review` forever. Idempotent, because the terminal paths call it
2354
+ * explicitly and `finally` calls it again for a throw that never reached one.
2355
+ */
2356
+ let turnTornDown = false;
2357
+ const endTurn = async () => {
2358
+ if (turnTornDown)
2359
+ return;
2360
+ turnTornDown = true;
2361
+ this.recordUsagePeaks(runId);
2362
+ this.clearIdleTimer(state);
2363
+ this.clearAutosaveTimer(state);
2364
+ if (state.cwd !== this.repoRoot)
2365
+ await autosaveCommit(state.cwd, 'turn end');
2366
+ };
2267
2367
  try {
2268
2368
  await session.result;
2269
2369
  if (sessionError)
2270
2370
  throw new Error(sessionError);
2271
2371
  sink.sessionEnded(state.cancelled ? 'cancelled' : 'end_turn');
2372
+ await endTurn();
2272
2373
  if (state.cancelled) {
2273
2374
  this.store.updateStep(runId, stepId, { status: 'cancelled', finishedAt: finishedAt() });
2274
2375
  this.store.updateRun(runId, { status: 'cancelled', finishedAt: finishedAt(), currentStepId: undefined });
@@ -2285,6 +2386,7 @@ export class RunManager {
2285
2386
  catch (err) {
2286
2387
  const message = err instanceof Error ? err.message : String(err);
2287
2388
  sink.sessionEnded('error', message);
2389
+ await endTurn();
2288
2390
  this.store.updateStep(runId, stepId, { status: 'failed', error: message, finishedAt: finishedAt() });
2289
2391
  appendHandoffHeartbeat(this.dataDir, runId, `step "${stepId}" complete — status=failed`);
2290
2392
  this.store.updateRun(runId, {
@@ -2296,14 +2398,38 @@ export class RunManager {
2296
2398
  this.store.appendEvent(runId, { type: 'lifecycle', message: `continue failed — ${message}` });
2297
2399
  }
2298
2400
  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');
2401
+ await endTurn();
2304
2402
  this.dropActive(runId);
2305
2403
  }
2306
2404
  }
2405
+ /**
2406
+ * Autonomous turn end (#489): never hand the ball back to the user — nudge the agent to keep
2407
+ * going and report whether the ball stayed with it. `false` means "park normally", which is
2408
+ * every non-autonomous run, a run whose pause cleared `autonomous` (the memory-limit
2409
+ * suppression above), a cancelled one, a closed session that cannot take the message, and a
2410
+ * run that has spent its `MAX_AUTO_CONTINUES` budget.
2411
+ *
2412
+ * ONE helper, called from BOTH turn-end handlers, because the split is exactly what broke:
2413
+ * `execute` set `autonomous` on its `ActiveRun` and its handler never read it, while
2414
+ * `runContinuation`'s handler read it and its `ActiveRun` never set it — so the nudge fired
2415
+ * nowhere and every autonomous run parked after one turn (#141). AGENTS.md names both traps:
2416
+ * grep the TYPE at every construction site, and treat the two near-identical turn-end handlers
2417
+ * as one place. Keep this the only sender of `AUTONOMOUS_NUDGE`.
2418
+ */
2419
+ autoContinueTurn(runId, state) {
2420
+ if (!state.autonomous || state.cancelled)
2421
+ return false;
2422
+ if ((state.autoContinues ?? 0) >= MAX_AUTO_CONTINUES)
2423
+ return false;
2424
+ if (!state.session?.sendMessage([{ type: 'text', text: AUTONOMOUS_NUDGE }]))
2425
+ return false;
2426
+ state.autoContinues = (state.autoContinues ?? 0) + 1;
2427
+ this.store.appendEvent(runId, {
2428
+ type: 'note',
2429
+ message: `autonomous — continuing without pausing (${state.autoContinues}/${MAX_AUTO_CONTINUES})`,
2430
+ });
2431
+ return true;
2432
+ }
2307
2433
  // ---- execution -----------------------------------------------------------
2308
2434
  async execute(runId, workflow, input) {
2309
2435
  const state = {
@@ -2730,7 +2856,13 @@ export class RunManager {
2730
2856
  return;
2731
2857
  }
2732
2858
  const waiting = interactive && sessionOpen;
2733
- if (waiting) {
2859
+ // Autonomous (#autonomous): the turn is over and the session is open, which is exactly
2860
+ // the moment this run must NOT park. Same helper, same bound, same note as
2861
+ // `runContinuation`'s handler (#141) — the field `execute` has always set is finally
2862
+ // read here. `interactive` still gates it: a mid-workflow step ends its own session and
2863
+ // the next step follows, so there is no ball to hand back and nothing to nudge.
2864
+ const autoContinued = waiting ? this.autoContinueTurn(runId, state) : false;
2865
+ if (waiting && !autoContinued) {
2734
2866
  // Turn over, session open. Either the ball is in the user's court
2735
2867
  // (`waiting`) — optionally with a structured `XEZ:ASK` question the
2736
2868
  // cockpit renders as an ask card (#473) — or the agent declared it is
@@ -2764,7 +2896,7 @@ export class RunManager {
2764
2896
  }
2765
2897
  // Xez's own heartbeat — the handoff stays current even when the
2766
2898
  // agent forgets to write (spec 007).
2767
- appendHandoffHeartbeat(this.dataDir, runId, `turn complete — status=${monitoring ? 'monitoring' : waiting ? 'waiting' : 'running'}`);
2899
+ appendHandoffHeartbeat(this.dataDir, runId, `turn complete — status=${autoContinued ? 'running' : monitoring ? 'monitoring' : waiting ? 'waiting' : 'running'}`);
2768
2900
  }
2769
2901
  };
2770
2902
  const stepBackend = step.runner ?? taskBackend;
@@ -2797,7 +2929,7 @@ export class RunManager {
2797
2929
  let stepProfile;
2798
2930
  try {
2799
2931
  stepProfile = await this.agentEnvForStep(runId, stepBackend, {
2800
- generateFollowups: followupsEnabled() && input.generateFollowups !== false,
2932
+ generateFollowups: this.semaphore.followupsEnabled() && input.generateFollowups !== false,
2801
2933
  });
2802
2934
  }
2803
2935
  catch (err) {
@@ -2814,7 +2946,7 @@ export class RunManager {
2814
2946
  session = runner.startSession({
2815
2947
  // Skill body, then the run's extra prompt (POST override or config
2816
2948
  // default), then the handoff/todos contract — every agent step.
2817
- systemPrompt: composeSystemPrompt(systemPrompt, extraSystemPrompt, followupsEnabled() && input.generateFollowups !== false
2949
+ systemPrompt: composeSystemPrompt(systemPrompt, extraSystemPrompt, this.semaphore.followupsEnabled() && input.generateFollowups !== false
2818
2950
  ? HANDOFF_INSTRUCTIONS
2819
2951
  : HANDOFF_ONLY_INSTRUCTIONS),
2820
2952
  userPrompt,
@@ -2827,8 +2959,10 @@ export class RunManager {
2827
2959
  env: stepProfile.env,
2828
2960
  model: backendModel,
2829
2961
  sessionId,
2830
- // Interactive sessions have no wall clock — the idle timer rules.
2831
- timeoutMs: interactive ? 0 : undefined,
2962
+ // Interactive sessions have no wall clock — the idle timer rules. A
2963
+ // step's own `timeout` (#22) outranks that; with the field absent
2964
+ // this is byte-for-byte the pre-#22 `interactive ? 0 : undefined`.
2965
+ timeoutMs: stepTimeoutMs(step, interactive),
2832
2966
  }, onEvent, {
2833
2967
  autoEndAfterFirstTurn: !interactive,
2834
2968
  onUiEvent: (event) => this.handleRunnerUiEvent(runId, state, sink, event),
@@ -3235,17 +3369,29 @@ export class RunManager {
3235
3369
  return null;
3236
3370
  }
3237
3371
  }
3372
+ /**
3373
+ * Arm the idle wall clock for a session that just parked at `waiting`.
3374
+ *
3375
+ * The duration comes from the shared semaphore's cached `resources.idleTimeoutMinutes`,
3376
+ * so a change made in Settings applies to the next park with no restart. `null` means
3377
+ * the operator chose "never close on idle": no timer is armed, and the only exits left
3378
+ * are a user message and Cancel — both of which a `waiting` run already advertises.
3379
+ */
3238
3380
  armIdleTimer(runId, state) {
3239
3381
  this.clearIdleTimer(state);
3382
+ const minutes = this.semaphore.idleTimeoutMinutes();
3383
+ if (minutes === null)
3384
+ return;
3385
+ const timeoutMs = minutes * 60_000;
3240
3386
  state.idleTimer = setTimeout(() => {
3241
3387
  if (state.session?.open && !state.cancelled) {
3242
3388
  this.store.appendEvent(runId, {
3243
3389
  type: 'lifecycle',
3244
- message: `session closed after ${Math.round(IDLE_TIMEOUT_MS / 60_000)}m of inactivity`,
3390
+ message: `session closed after ${Math.round(timeoutMs / 60_000)}m of inactivity`,
3245
3391
  });
3246
3392
  state.session.end();
3247
3393
  }
3248
- }, IDLE_TIMEOUT_MS);
3394
+ }, timeoutMs);
3249
3395
  state.idleTimer.unref?.();
3250
3396
  }
3251
3397
  clearIdleTimer(state) {