@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.
- package/README.md +97 -43
- package/dist/agent-config/catalog.d.ts +10 -0
- package/dist/agent-config/catalog.js.map +1 -1
- package/dist/automations/coordinator.js +8 -0
- package/dist/automations/coordinator.js.map +1 -1
- package/dist/automations/types.d.ts +2 -0
- package/dist/contract/automations.d.ts +8 -0
- package/dist/contract/events.test.d.ts +1 -0
- package/dist/contract/index.js +87 -3
- package/dist/contract/runs.d.ts +25 -0
- package/dist/contract/runs.test.d.ts +1 -0
- package/dist/contract/skills.d.ts +1 -0
- package/dist/contract/workflows.d.ts +6 -0
- package/dist/contract/workspace.d.ts +37 -4
- package/dist/core/agent-env.js +8 -1
- package/dist/core/agent-env.js.map +1 -1
- package/dist/core/agent-runner.d.ts +13 -0
- package/dist/core/agent-runner.js +22 -0
- package/dist/core/agent-runner.js.map +1 -1
- package/dist/core/backend-detect.js +11 -1
- package/dist/core/backend-detect.js.map +1 -1
- package/dist/core/claude-cli-runner.js +8 -2
- package/dist/core/claude-cli-runner.js.map +1 -1
- package/dist/core/codex-app-server-runner.js +6 -2
- package/dist/core/codex-app-server-runner.js.map +1 -1
- package/dist/core/opencode-server-runner.d.ts +12 -0
- package/dist/core/opencode-server-runner.js +326 -17
- package/dist/core/opencode-server-runner.js.map +1 -1
- package/dist/core/pi-model-catalog.d.ts +52 -0
- package/dist/core/pi-model-catalog.js +135 -0
- package/dist/core/pi-model-catalog.js.map +1 -0
- package/dist/core/pi-runner.d.ts +3 -0
- package/dist/core/pi-runner.js +139 -12
- package/dist/core/pi-runner.js.map +1 -1
- package/dist/core/pi-ui-mapper.js +39 -3
- package/dist/core/pi-ui-mapper.js.map +1 -1
- package/dist/core/runner-model-catalog.js +11 -2
- package/dist/core/runner-model-catalog.js.map +1 -1
- package/dist/index.js +2 -0
- package/dist/index.js.map +1 -1
- package/dist/paths.d.ts +7 -0
- package/dist/paths.js +8 -0
- package/dist/paths.js.map +1 -1
- package/dist/runs/event-corrections.d.ts +12 -0
- package/dist/runs/event-corrections.js +141 -0
- package/dist/runs/event-corrections.js.map +1 -0
- package/dist/runs/event-history.d.ts +5 -1
- package/dist/runs/event-history.js +66 -5
- package/dist/runs/event-history.js.map +1 -1
- package/dist/runs/project-writer.d.ts +11 -0
- package/dist/runs/project-writer.js +115 -0
- package/dist/runs/project-writer.js.map +1 -0
- package/dist/runs/retention.d.ts +1 -1
- package/dist/runs/retention.js +1 -1
- package/dist/runs/store.d.ts +2 -0
- package/dist/runs/store.js +13 -2
- package/dist/runs/store.js.map +1 -1
- package/dist/runs/task-markers.d.ts +4 -0
- package/dist/runs/task-markers.js +63 -5
- package/dist/runs/task-markers.js.map +1 -1
- package/dist/server/capabilities.d.ts +7 -5
- package/dist/server/capabilities.js +11 -7
- package/dist/server/capabilities.js.map +1 -1
- package/dist/server/project-context.js +2 -0
- package/dist/server/project-context.js.map +1 -1
- package/dist/server/server.d.ts +199 -47
- package/dist/server/server.js +195 -11
- package/dist/server/server.js.map +1 -1
- package/dist/workflows/run.d.ts +67 -2
- package/dist/workflows/run.js +233 -62
- package/dist/workflows/run.js.map +1 -1
- package/dist/workflows/types.d.ts +41 -0
- package/dist/workflows/types.js +72 -0
- package/dist/workflows/types.js.map +1 -1
- package/dist/workspace/config.d.ts +42 -0
- package/dist/workspace/config.js +132 -3
- package/dist/workspace/config.js.map +1 -1
- package/dist/workspace/semaphore.d.ts +63 -3
- package/dist/workspace/semaphore.js +76 -3
- package/dist/workspace/semaphore.js.map +1 -1
- package/package.json +3 -3
- package/web/dist/assets/{alert-dialog-CGskMvgc.js → alert-dialog-BVsVcbFy.js} +1 -1
- package/web/dist/assets/arrow-down-CYgAlZtW.js +1 -0
- package/web/dist/assets/arrow-left-BOqE0a3k.js +1 -0
- package/web/dist/assets/{centered-state-B_V_Ai2h.js → centered-state-Djgly6Ni.js} +1 -1
- package/web/dist/assets/chevron-right-Cc9BWnbN.js +1 -0
- package/web/dist/assets/{collapsible-B_8uYwHb.js → collapsible-DZ4D-DHG.js} +1 -1
- package/web/dist/assets/{commit-list-C4Dv6vUL.js → commit-list-B-z1JL9G.js} +1 -1
- package/web/dist/assets/{compare-variants-et0mplGi.js → compare-variants-D62m4j3B.js} +1 -1
- package/web/dist/assets/{diff-CbozNUJq.js → diff-BKew9rQX.js} +2 -2
- package/web/dist/assets/{diff-stat-btAmKFJr.js → diff-stat-D0mc5V6Z.js} +1 -1
- package/web/dist/assets/{diff-view-BpFTcG3R.js → diff-view-xpIcuLIC.js} +1 -1
- package/web/dist/assets/{dropdown-menu-Z80KMInL.js → dropdown-menu-CEQziH7X.js} +1 -1
- package/web/dist/assets/editable-title-BCX3T2BY.js +1 -0
- package/web/dist/assets/{ellipsis-vertical-Bmi2u2TG.js → ellipsis-vertical-ByVdDc4z.js} +1 -1
- package/web/dist/assets/{file-DKoYWRsv.js → file-xBSB5hXO.js} +1 -1
- package/web/dist/assets/{folder-u01HScRT.js → folder-CldgbZ5A.js} +1 -1
- package/web/dist/assets/{git-toolbar-Q2kk2F7v.js → git-toolbar-DJANqg7Y.js} +1 -1
- package/web/dist/assets/github-D6vK0jFn.js +1 -0
- package/web/dist/assets/{image-preview-BR3iIIhf.js → image-preview-Cv5mZr0n.js} +1 -1
- package/web/dist/assets/index-CkmR6h7s.css +2 -0
- package/web/dist/assets/index-DVDqF4bM.js +9 -0
- package/web/dist/assets/{markdown-5FuCZDA4.js → markdown-CgEEE2ND.js} +1 -1
- package/web/dist/assets/{new-task-form-BydHBbef.js → new-task-form-1r2bgsdD.js} +1 -1
- package/web/dist/assets/{pill-vNzaaYxj.js → pill-q-x22_ej.js} +1 -1
- package/web/dist/assets/{project-router-BbCzrHKs.js → project-router-VDN4jQM8.js} +1 -1
- package/web/dist/assets/{prompt-templates-B7Fs2b1S.js → prompt-templates-BmZtCe-L.js} +1 -1
- package/web/dist/assets/{refresh-cw-DGliez3E.js → refresh-cw-DcNCj2nu.js} +1 -1
- package/web/dist/assets/{repo-git-h1RFfly3.js → repo-git-btG0sdbO.js} +1 -1
- package/web/dist/assets/{run-diff-Bq9vkSNK.js → run-diff-vguEMJzh.js} +1 -1
- package/web/dist/assets/{run-header-Cd8mSKBG.js → run-header-DM7MiKj9.js} +1 -1
- package/web/dist/assets/{search-x-69n_672Y.js → search-x-QmhaAbg5.js} +1 -1
- package/web/dist/assets/{skill-empty-hint-CL0g7WBL.js → skill-empty-hint-BrzUwByZ.js} +1 -1
- package/web/dist/assets/{skills-BqAzyGS3.js → skills-DMfxQtrD.js} +1 -1
- package/web/dist/assets/{sparkles-B8agSxBZ.js → sparkles-CP8QDOgX.js} +1 -1
- package/web/dist/assets/{square-terminal-Cf4Og6ct.js → square-terminal-CGypshSD.js} +1 -1
- package/web/dist/assets/{tab-link-Cp2-CJS8.js → tab-link-D4lFzzI1.js} +1 -1
- package/web/dist/assets/{task-changes-DwXPFXqG.js → task-changes-BtZId14z.js} +1 -1
- package/web/dist/assets/{task-commits-Bessua-c.js → task-commits-BoeyJXrv.js} +1 -1
- package/web/dist/assets/{task-files-DxFIhRPP.js → task-files-DIy2oUnj.js} +1 -1
- package/web/dist/assets/task-thread-CbMPmrWq.js +9 -0
- package/web/dist/assets/{textarea--cpYshee.js → textarea-CyBCyKIk.js} +1 -1
- package/web/dist/assets/{thread-loading-ZhJref0J.js → thread-loading-Cuq2jchC.js} +1 -1
- package/web/dist/assets/{trash-2--ZtrzUx3.js → trash-2-B6Dp3ZNs.js} +1 -1
- package/web/dist/assets/{triangle-alert-D5tJwRD2.js → triangle-alert-xBya8FUp.js} +1 -1
- package/web/dist/assets/{upload-SnwVxKOA.js → upload-D6MXEB9p.js} +1 -1
- package/web/dist/assets/{use-desktop-Sr1QlxhT.js → use-desktop-CPBwzQ7M.js} +1 -1
- package/web/dist/assets/{use-submit-shortcut-JY8M_MPL.js → use-submit-shortcut-JTpNzU-u.js} +1 -1
- package/web/dist/assets/{utils-Bb8ohcr3.js → utils--z9hRtl3.js} +1 -1
- package/web/dist/assets/{workflows-9HC240Wx.js → workflows-DG115scU.js} +3 -3
- package/web/dist/assets/{zoomable-image-0G3IBXWC.js → zoomable-image-GTYwLtkS.js} +1 -1
- package/web/dist/index.html +23 -23
- package/web/dist/assets/arrow-down-G8adPhbu.js +0 -1
- package/web/dist/assets/arrow-left-BJsIW6A0.js +0 -1
- package/web/dist/assets/chevron-right-CFzn-von.js +0 -1
- package/web/dist/assets/editable-title-wS5NWwob.js +0 -1
- package/web/dist/assets/github-DjEYCxwt.js +0 -1
- package/web/dist/assets/index-BcI5hug3.css +0 -2
- package/web/dist/assets/index-Cz4GSAqV.js +0 -7
- package/web/dist/assets/task-thread-Dhkupf1-.js +0 -9
package/dist/workflows/run.js
CHANGED
|
@@ -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,
|
|
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
|
-
/**
|
|
42
|
-
|
|
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
|
-
|
|
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
|
-
//
|
|
508
|
-
//
|
|
509
|
-
//
|
|
510
|
-
//
|
|
511
|
-
|
|
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.
|
|
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
|
-
|
|
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
|
-
|
|
2098
|
-
|
|
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
|
|
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,
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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(
|
|
3415
|
+
message: `session closed after ${Math.round(timeoutMs / 60_000)}m of inactivity`,
|
|
3245
3416
|
});
|
|
3246
3417
|
state.session.end();
|
|
3247
3418
|
}
|
|
3248
|
-
},
|
|
3419
|
+
}, timeoutMs);
|
|
3249
3420
|
state.idleTimer.unref?.();
|
|
3250
3421
|
}
|
|
3251
3422
|
clearIdleTimer(state) {
|