@open-mercato/cezar 0.1.5-pr506.357
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 +573 -0
- package/dist/config.d.ts +144 -0
- package/dist/config.js +106 -0
- package/dist/config.js.map +1 -0
- package/dist/core/agent-runner.d.ts +169 -0
- package/dist/core/agent-runner.js +23 -0
- package/dist/core/agent-runner.js.map +1 -0
- package/dist/core/backend-detect.d.ts +16 -0
- package/dist/core/backend-detect.js +118 -0
- package/dist/core/backend-detect.js.map +1 -0
- package/dist/core/claude-cli-runner.d.ts +56 -0
- package/dist/core/claude-cli-runner.js +416 -0
- package/dist/core/claude-cli-runner.js.map +1 -0
- package/dist/core/claude-ui-mapper.d.ts +71 -0
- package/dist/core/claude-ui-mapper.js +567 -0
- package/dist/core/claude-ui-mapper.js.map +1 -0
- package/dist/core/codex-app-server-runner.d.ts +29 -0
- package/dist/core/codex-app-server-runner.js +489 -0
- package/dist/core/codex-app-server-runner.js.map +1 -0
- package/dist/core/codex-ui-mapper.d.ts +63 -0
- package/dist/core/codex-ui-mapper.js +491 -0
- package/dist/core/codex-ui-mapper.js.map +1 -0
- package/dist/core/ndjson.d.ts +6 -0
- package/dist/core/ndjson.js +23 -0
- package/dist/core/ndjson.js.map +1 -0
- package/dist/core/opencode-server-runner.d.ts +30 -0
- package/dist/core/opencode-server-runner.js +488 -0
- package/dist/core/opencode-server-runner.js.map +1 -0
- package/dist/core/opencode-ui-mapper.d.ts +105 -0
- package/dist/core/opencode-ui-mapper.js +588 -0
- package/dist/core/opencode-ui-mapper.js.map +1 -0
- package/dist/core/process-usage.d.ts +63 -0
- package/dist/core/process-usage.js +181 -0
- package/dist/core/process-usage.js.map +1 -0
- package/dist/core/runner-factory.d.ts +8 -0
- package/dist/core/runner-factory.js +22 -0
- package/dist/core/runner-factory.js.map +1 -0
- package/dist/core/tool-display.d.ts +30 -0
- package/dist/core/tool-display.js +156 -0
- package/dist/core/tool-display.js.map +1 -0
- package/dist/core/ui-events.d.ts +308 -0
- package/dist/core/ui-events.js +25 -0
- package/dist/core/ui-events.js.map +1 -0
- package/dist/core/usage.d.ts +11 -0
- package/dist/core/usage.js +15 -0
- package/dist/core/usage.js.map +1 -0
- package/dist/git-worktree.d.ts +88 -0
- package/dist/git-worktree.js +297 -0
- package/dist/git-worktree.js.map +1 -0
- package/dist/handoff.d.ts +62 -0
- package/dist/handoff.js +147 -0
- package/dist/handoff.js.map +1 -0
- package/dist/index.d.ts +2 -0
- package/dist/index.js +466 -0
- package/dist/index.js.map +1 -0
- package/dist/install-as-command.d.ts +39 -0
- package/dist/install-as-command.js +51 -0
- package/dist/install-as-command.js.map +1 -0
- package/dist/pack-check.d.ts +17 -0
- package/dist/pack-check.js +27 -0
- package/dist/pack-check.js.map +1 -0
- package/dist/paths.d.ts +23 -0
- package/dist/paths.js +34 -0
- package/dist/paths.js.map +1 -0
- package/dist/planner.d.ts +17 -0
- package/dist/planner.js +248 -0
- package/dist/planner.js.map +1 -0
- package/dist/release/snapshot.d.ts +64 -0
- package/dist/release/snapshot.js +75 -0
- package/dist/release/snapshot.js.map +1 -0
- package/dist/release/stable.d.ts +46 -0
- package/dist/release/stable.js +57 -0
- package/dist/release/stable.js.map +1 -0
- package/dist/runs/auto-name.d.ts +75 -0
- package/dist/runs/auto-name.js +168 -0
- package/dist/runs/auto-name.js.map +1 -0
- package/dist/runs/retention.d.ts +60 -0
- package/dist/runs/retention.js +86 -0
- package/dist/runs/retention.js.map +1 -0
- package/dist/runs/review-gate.d.ts +18 -0
- package/dist/runs/review-gate.js +21 -0
- package/dist/runs/review-gate.js.map +1 -0
- package/dist/runs/store.d.ts +408 -0
- package/dist/runs/store.js +526 -0
- package/dist/runs/store.js.map +1 -0
- package/dist/runs/task-refs.d.ts +24 -0
- package/dist/runs/task-refs.js +74 -0
- package/dist/runs/task-refs.js.map +1 -0
- package/dist/runs/ui-event-sink.d.ts +81 -0
- package/dist/runs/ui-event-sink.js +210 -0
- package/dist/runs/ui-event-sink.js.map +1 -0
- package/dist/server/capabilities.d.ts +33 -0
- package/dist/server/capabilities.js +43 -0
- package/dist/server/capabilities.js.map +1 -0
- package/dist/server/forge/github.d.ts +70 -0
- package/dist/server/forge/github.js +375 -0
- package/dist/server/forge/github.js.map +1 -0
- package/dist/server/forge/index.d.ts +24 -0
- package/dist/server/forge/index.js +45 -0
- package/dist/server/forge/index.js.map +1 -0
- package/dist/server/forge/types.d.ts +80 -0
- package/dist/server/forge/types.js +2 -0
- package/dist/server/forge/types.js.map +1 -0
- package/dist/server/git-changes.d.ts +195 -0
- package/dist/server/git-changes.js +497 -0
- package/dist/server/git-changes.js.map +1 -0
- package/dist/server/git.d.ts +26 -0
- package/dist/server/git.js +103 -0
- package/dist/server/git.js.map +1 -0
- package/dist/server/github.d.ts +8 -0
- package/dist/server/github.js +8 -0
- package/dist/server/github.js.map +1 -0
- package/dist/server/launch-key.d.ts +7 -0
- package/dist/server/launch-key.js +33 -0
- package/dist/server/launch-key.js.map +1 -0
- package/dist/server/open-in-app.d.ts +51 -0
- package/dist/server/open-in-app.js +189 -0
- package/dist/server/open-in-app.js.map +1 -0
- package/dist/server/open-in-terminal.d.ts +25 -0
- package/dist/server/open-in-terminal.js +120 -0
- package/dist/server/open-in-terminal.js.map +1 -0
- package/dist/server/pr.d.ts +7 -0
- package/dist/server/pr.js +7 -0
- package/dist/server/pr.js.map +1 -0
- package/dist/server/server.d.ts +41 -0
- package/dist/server/server.js +1390 -0
- package/dist/server/server.js.map +1 -0
- package/dist/server/static-ui.d.ts +49 -0
- package/dist/server/static-ui.js +92 -0
- package/dist/server/static-ui.js.map +1 -0
- package/dist/server/wsl.d.ts +25 -0
- package/dist/server/wsl.js +92 -0
- package/dist/server/wsl.js.map +1 -0
- package/dist/server-install/engine.d.ts +33 -0
- package/dist/server-install/engine.js +314 -0
- package/dist/server-install/engine.js.map +1 -0
- package/dist/server-install/platforms/macosx-ngrok.d.ts +6 -0
- package/dist/server-install/platforms/macosx-ngrok.js +345 -0
- package/dist/server-install/platforms/macosx-ngrok.js.map +1 -0
- package/dist/server-install/platforms/ubuntu-vps.d.ts +39 -0
- package/dist/server-install/platforms/ubuntu-vps.js +746 -0
- package/dist/server-install/platforms/ubuntu-vps.js.map +1 -0
- package/dist/server-install/state.d.ts +26 -0
- package/dist/server-install/state.js +125 -0
- package/dist/server-install/state.js.map +1 -0
- package/dist/server-install/steps.d.ts +109 -0
- package/dist/server-install/steps.js +330 -0
- package/dist/server-install/steps.js.map +1 -0
- package/dist/server-install/strategies.d.ts +4 -0
- package/dist/server-install/strategies.js +20 -0
- package/dist/server-install/strategies.js.map +1 -0
- package/dist/server-install/types.d.ts +597 -0
- package/dist/server-install/types.js +98 -0
- package/dist/server-install/types.js.map +1 -0
- package/dist/server-install/ui.d.ts +42 -0
- package/dist/server-install/ui.js +130 -0
- package/dist/server-install/ui.js.map +1 -0
- package/dist/skills-banner.d.ts +21 -0
- package/dist/skills-banner.js +40 -0
- package/dist/skills-banner.js.map +1 -0
- package/dist/skills-remote.d.ts +35 -0
- package/dist/skills-remote.js +266 -0
- package/dist/skills-remote.js.map +1 -0
- package/dist/skills.d.ts +45 -0
- package/dist/skills.js +182 -0
- package/dist/skills.js.map +1 -0
- package/dist/todos.d.ts +63 -0
- package/dist/todos.js +168 -0
- package/dist/todos.js.map +1 -0
- package/dist/ui-state.d.ts +12 -0
- package/dist/ui-state.js +25 -0
- package/dist/ui-state.js.map +1 -0
- package/dist/update-check.d.ts +12 -0
- package/dist/update-check.js +41 -0
- package/dist/update-check.js.map +1 -0
- package/dist/workflows/load.d.ts +15 -0
- package/dist/workflows/load.js +56 -0
- package/dist/workflows/load.js.map +1 -0
- package/dist/workflows/run.d.ts +281 -0
- package/dist/workflows/run.js +1511 -0
- package/dist/workflows/run.js.map +1 -0
- package/dist/workflows/types.d.ts +307 -0
- package/dist/workflows/types.js +171 -0
- package/dist/workflows/types.js.map +1 -0
- package/package.json +90 -0
- package/scripts/check-pack.mjs +46 -0
- package/scripts/dev.mjs +74 -0
- package/scripts/install-as-command.mjs +120 -0
- package/scripts/mock-claude.mjs +310 -0
- package/scripts/release-snapshot.mjs +130 -0
- package/scripts/release.mjs +119 -0
- package/scripts/test-process-usage.mjs +47 -0
- package/web/dist/assets/arrow-left-BE957rJs.js +1 -0
- package/web/dist/assets/bundle-mjs-DLPpRFyl.js +1 -0
- package/web/dist/assets/centered-state-BddhEEtO.js +43 -0
- package/web/dist/assets/chunk-BO2N2NFS-f5igTtpB.js +131 -0
- package/web/dist/assets/command-CjhFbABo.js +1 -0
- package/web/dist/assets/compare-variants-Cs52IAEG.js +1 -0
- package/web/dist/assets/core-BuMqAF_1.js +12 -0
- package/web/dist/assets/css-BsVw1vtW.js +1 -0
- package/web/dist/assets/dialog-DFvXTkFv.js +1 -0
- package/web/dist/assets/diff-view-C9b8AITq.js +4 -0
- package/web/dist/assets/diff-woXpYk--.js +1 -0
- package/web/dist/assets/dist-FBs4wmIY.js +1 -0
- package/web/dist/assets/dist-o_kVMXjg.js +1 -0
- package/web/dist/assets/engine-javascript-FQvzRocB.js +141 -0
- package/web/dist/assets/file-BVuztAOd.js +1 -0
- package/web/dist/assets/folder-0pJ1NZ_S.js +1 -0
- package/web/dist/assets/git-pull-request-DJkAMT0f.js +1 -0
- package/web/dist/assets/git-toolbar-CwhQdx3J.js +1 -0
- package/web/dist/assets/github-D0rAX4-r.js +1 -0
- package/web/dist/assets/go-rLFTqkRN.js +1 -0
- package/web/dist/assets/highlighted-body-OFNGDK62-DGHHie_q.js +1 -0
- package/web/dist/assets/highlighter-BKVEsACy.js +3 -0
- package/web/dist/assets/html-BY76lXLH.js +1 -0
- package/web/dist/assets/index-CEkI7QxN.css +2 -0
- package/web/dist/assets/index-CO2lH2et.js +11 -0
- package/web/dist/assets/inter-latin-ext-wght-normal-DO1Apj_S.woff2 +0 -0
- package/web/dist/assets/inter-latin-wght-normal-Dx4kXJAl.woff2 +0 -0
- package/web/dist/assets/javascript-BgS3c2Ky.js +1 -0
- package/web/dist/assets/jetbrains-mono-latin-ext-wght-normal-DBQx-q_a.woff2 +0 -0
- package/web/dist/assets/jetbrains-mono-latin-wght-normal-B9CIFXIH.woff2 +0 -0
- package/web/dist/assets/json-qhed-kSA.js +1 -0
- package/web/dist/assets/jsonc-CYpm1nAK.js +1 -0
- package/web/dist/assets/jsx-Bz0zcwM4.js +1 -0
- package/web/dist/assets/lib-CNGa3Dig.js +1 -0
- package/web/dist/assets/loader-circle-BV3DO9mE.js +1 -0
- package/web/dist/assets/markdown-BYOwaDjH.js +1 -0
- package/web/dist/assets/markdown-D4-ADgH9.js +1 -0
- package/web/dist/assets/mermaid-GHXKKRXX-DM2VsGBG.js +1 -0
- package/web/dist/assets/open-mercato-toBr6SOa.svg +11 -0
- package/web/dist/assets/prompt-templates-C7ptKvDd.js +15 -0
- package/web/dist/assets/python-gzcpVVnB.js +1 -0
- package/web/dist/assets/react-dom-Ddtik4qM.js +1 -0
- package/web/dist/assets/refresh-cw-DCa-Rlwz.js +1 -0
- package/web/dist/assets/repo-git-DIhryGDx.js +1 -0
- package/web/dist/assets/run-diff-Dyow8B1q.js +3 -0
- package/web/dist/assets/run-header-jx9-18b8.js +1 -0
- package/web/dist/assets/rust-Cfkwpbl8.js +1 -0
- package/web/dist/assets/search-x-CoCyl7zT.js +1 -0
- package/web/dist/assets/shellscript-CLZ0U2zV.js +1 -0
- package/web/dist/assets/skill-detail-DB7x_FQp.js +1 -0
- package/web/dist/assets/skills-98vCroU8.js +1 -0
- package/web/dist/assets/skills-BKIoqD6K.js +1 -0
- package/web/dist/assets/sql-BsFa4tDR.js +1 -0
- package/web/dist/assets/square-terminal-B6PZX4Qp.js +1 -0
- package/web/dist/assets/tab-link-C5--YMQd.js +1 -0
- package/web/dist/assets/task-changes-ejxPGVJw.js +1 -0
- package/web/dist/assets/task-commits-C6GO5qT-.js +1 -0
- package/web/dist/assets/task-files-CatpCBXa.js +2 -0
- package/web/dist/assets/task-thread-BMbXDM3d.js +5 -0
- package/web/dist/assets/tasks-table-dgezlCcg.js +1 -0
- package/web/dist/assets/trash-2-CDtxmmc8.js +1 -0
- package/web/dist/assets/triangle-alert-6su4Js5O.js +1 -0
- package/web/dist/assets/tsx-udAQXfEw.js +1 -0
- package/web/dist/assets/typescript-bsJCZSQ-.js +1 -0
- package/web/dist/assets/upload-Bp7hQxll.js +1 -0
- package/web/dist/assets/use-desktop-CvTwojOJ.js +3 -0
- package/web/dist/assets/utils-rQGbOrwc.js +1 -0
- package/web/dist/assets/workflows-Dy2c6790.js +11 -0
- package/web/dist/assets/x-Br_jIDBw.js +1 -0
- package/web/dist/assets/yaml-rwi0_p6S.js +1 -0
- package/web/dist/index.html +70 -0
- package/web/open-mercato.svg +11 -0
|
@@ -0,0 +1,1511 @@
|
|
|
1
|
+
import { randomUUID } from 'node:crypto';
|
|
2
|
+
import { spawn } from 'node:child_process';
|
|
3
|
+
import { existsSync, mkdirSync, writeFileSync } from 'node:fs';
|
|
4
|
+
import { join } from 'node:path';
|
|
5
|
+
import { onUsage, registerRunProcess, unregisterRunProcess } from '../core/process-usage.js';
|
|
6
|
+
import { createRunner } from '../core/runner-factory.js';
|
|
7
|
+
import { HANDOFF_ONLY_INSTRUCTIONS, HANDOFF_INSTRUCTIONS, appendHandoffHeartbeat, followupsEnabled, handoffPath, seedHandoffFile, } from '../handoff.js';
|
|
8
|
+
import { todosPath } from '../todos.js';
|
|
9
|
+
import { discoverSkills } from '../skills.js';
|
|
10
|
+
import { materializeSkillDir } from '../skills-remote.js';
|
|
11
|
+
import { loadConfig } from '../config.js';
|
|
12
|
+
import { autosaveCommit, createWorktree, resolveBaseRef, worktreeDiff, worktreeShortstat } from '../git-worktree.js';
|
|
13
|
+
import { getRepoInfo } from '../server/git.js';
|
|
14
|
+
import { loadWorkflows } from './load.js';
|
|
15
|
+
import { reclaimWorktrees, rematerializeReclaimedWorktree } from '../runs/retention.js';
|
|
16
|
+
import { extractTaskRefs, refineTaskRefs, titleRefNumber } from '../runs/task-refs.js';
|
|
17
|
+
import { autoNamingActive, generateRunName, liveTitleUpdatesEnabled } from '../runs/auto-name.js';
|
|
18
|
+
import { reviewGateEnabled } from '../runs/review-gate.js';
|
|
19
|
+
import { UiEventSink } from '../runs/ui-event-sink.js';
|
|
20
|
+
import { chainStepNote, DEFAULT_ALLOWED_TOOLS, stepKind } from './types.js';
|
|
21
|
+
const CHECK_OUTPUT_CAP = 20_000;
|
|
22
|
+
/** An interactive session that hears nothing from the user closes itself. */
|
|
23
|
+
export const IDLE_TIMEOUT_MS = 15 * 60_000;
|
|
24
|
+
/**
|
|
25
|
+
* Task-completion marker from the agent contract (HANDOFF_INSTRUCTIONS): a
|
|
26
|
+
* turn whose text ends with `CEZ:DONE` means "goal achieved, nothing to ask" —
|
|
27
|
+
* the session is closed right away instead of parking at `waiting` (#347).
|
|
28
|
+
* Detection runs on the accumulated turn text so delta-streaming backends
|
|
29
|
+
* (codex, opencode) can't split the marker across text events.
|
|
30
|
+
*/
|
|
31
|
+
const DONE_MARKER_RE = /CEZ:DONE\s*$/;
|
|
32
|
+
/**
|
|
33
|
+
* Still-working marker from the agent contract (spec
|
|
34
|
+
* 2026-07-18-subagent-monitoring-status, #490): a turn whose text ends with
|
|
35
|
+
* `CEZ:MONITORING` means "I ended this turn but I'm still working on my own
|
|
36
|
+
* downstream work (a sub-agent / a command I'm monitoring), not waiting on the
|
|
37
|
+
* user" — cezar parks it as `running`/`activity:'monitoring'` instead of
|
|
38
|
+
* `waiting`, so the cockpit shows a non-attention state. `CEZ:DONE` wins if both
|
|
39
|
+
* appear. Detected on accumulated turn text (like `CEZ:DONE`) so delta-streaming
|
|
40
|
+
* backends can't split the marker across text events.
|
|
41
|
+
*/
|
|
42
|
+
const MONITORING_MARKER_RE = /CEZ:MONITORING\s*$/;
|
|
43
|
+
/** Strip a trailing marker from one text event so transcripts stay free of
|
|
44
|
+
* protocol noise. Delta backends may split the marker across events — then
|
|
45
|
+
* it stays visible; detection above is unaffected. */
|
|
46
|
+
function stripDoneMarker(text) {
|
|
47
|
+
return text.replace(/\s*CEZ:DONE\s*$/, '');
|
|
48
|
+
}
|
|
49
|
+
/** Strip a trailing `CEZ:MONITORING` marker from one text event (see
|
|
50
|
+
* `stripDoneMarker`; same delta-backend caveat). */
|
|
51
|
+
function stripMonitoringMarker(text) {
|
|
52
|
+
return text.replace(/\s*CEZ:MONITORING\s*$/, '');
|
|
53
|
+
}
|
|
54
|
+
/** Periodic "cezar autosave" commit in the task worktree (spec 006). */
|
|
55
|
+
export const AUTOSAVE_INTERVAL_MS = 90_000;
|
|
56
|
+
/** The periodic autosave timer is opt-in (#471): off, a task branch carries only the
|
|
57
|
+
* agent's own commits plus the turn-end/pre-PR flushes — no mid-run "cezar autosave"
|
|
58
|
+
* noise interleaving PR history. The flushes (`autosaveCommit` at turn end and before
|
|
59
|
+
* a draft PR) are NOT gated: the branch must still end holding the finished state. */
|
|
60
|
+
export function periodicAutosaveEnabled(env = process.env) {
|
|
61
|
+
return env.CEZ_AUTOSAVE === '1';
|
|
62
|
+
}
|
|
63
|
+
/** Safety cap on autonomous auto-continues per run — stops a stuck agent from nudging forever. */
|
|
64
|
+
const MAX_AUTO_CONTINUES = 40;
|
|
65
|
+
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.';
|
|
66
|
+
/**
|
|
67
|
+
* The effective "extra" system prompt for a run (spec §protocol v2, R2 2.3):
|
|
68
|
+
* the per-run override (`POST /api/runs` `systemPrompt`) REPLACES the
|
|
69
|
+
* `config.json` default — they are the same knob at two scopes, so the more
|
|
70
|
+
* specific one wins outright; they never concatenate. Whichever wins is
|
|
71
|
+
* ADDITIVE to the skill body and the handoff contract, which always ride
|
|
72
|
+
* along (see `composeSystemPrompt`). Blank strings count as unset.
|
|
73
|
+
*/
|
|
74
|
+
export function resolveExtraSystemPrompt(override, configDefault) {
|
|
75
|
+
return override?.trim() || configDefault?.trim() || undefined;
|
|
76
|
+
}
|
|
77
|
+
/**
|
|
78
|
+
* Joins the parts of one agent step's system prompt in fixed order — skill
|
|
79
|
+
* body (most task-specific), then the run's extra prompt (user guidance, can
|
|
80
|
+
* amend the skill), then the handoff contract (always last, never optional in
|
|
81
|
+
* practice). Blank parts drop out; survivors join with the same `\n\n---\n\n`
|
|
82
|
+
* divider the skill+handoff composition has always used.
|
|
83
|
+
*/
|
|
84
|
+
export function composeSystemPrompt(...parts) {
|
|
85
|
+
return parts
|
|
86
|
+
.map((p) => p?.trim())
|
|
87
|
+
.filter((p) => Boolean(p))
|
|
88
|
+
.join('\n\n---\n\n');
|
|
89
|
+
}
|
|
90
|
+
/** Variant letters + the fixed diversification hints (spec 010). A runs the
|
|
91
|
+
* task verbatim; B/C get one constant sentence each — zero configuration. */
|
|
92
|
+
export const VARIANT_LETTERS = ['A', 'B', 'C'];
|
|
93
|
+
const VARIANT_HINTS = {
|
|
94
|
+
A: undefined,
|
|
95
|
+
B: 'Approach hint: prefer the minimal, surgical change.',
|
|
96
|
+
C: 'Approach hint: prefer a thorough, structural approach.',
|
|
97
|
+
};
|
|
98
|
+
/**
|
|
99
|
+
* The mini workflow engine: executes a `WorkflowDef` against a repo, one step
|
|
100
|
+
* at a time, persisting every event to the RunStore (which the SSE endpoints
|
|
101
|
+
* relay live to the GUI). No GitHub choreography — agent steps and shell
|
|
102
|
+
* checks with bounded retry loops, plus live sessions: the last agent step
|
|
103
|
+
* stays open for follow-ups (`waiting`) until "finish", idle timeout, or
|
|
104
|
+
* cancel. Runs queue behind `maxParallel` slots and each run executes in its
|
|
105
|
+
* own git worktree on a `cez/<id8>` branch (spec 006), autosave-committed at
|
|
106
|
+
* turn end and before a draft PR — plus every 90 s when opted in via
|
|
107
|
+
* CEZ_AUTOSAVE=1 (#471). The user's working tree is never touched.
|
|
108
|
+
*/
|
|
109
|
+
export class RunManager {
|
|
110
|
+
store;
|
|
111
|
+
repoRoot;
|
|
112
|
+
active = new Map();
|
|
113
|
+
// Queue + `starting` set (spec 006, janitor's pump() pattern): `starting`
|
|
114
|
+
// covers the window between shifting a run off the queue and the run
|
|
115
|
+
// registering in `active`, so parallel-slot counting is never racy.
|
|
116
|
+
queue = [];
|
|
117
|
+
starting = new Set();
|
|
118
|
+
// Runs parked at `waiting` (open session, ball in the user's court). They
|
|
119
|
+
// don't consume a `maxParallel` slot (#347) — an idle claude process costs
|
|
120
|
+
// memory but no tokens, queued work progressing matters more, and the idle
|
|
121
|
+
// timeout already bounds how long a session can sit open. Invariant:
|
|
122
|
+
// `waiting ⊆ active` — always cleared together via dropActive().
|
|
123
|
+
waiting = new Set();
|
|
124
|
+
pendingJobs = new Map();
|
|
125
|
+
pumping = false;
|
|
126
|
+
/**
|
|
127
|
+
* Runs normally isolate in worktrees and may execute in parallel. When that
|
|
128
|
+
* isolation is unavailable (or explicitly disabled), serialize access to
|
|
129
|
+
* `repoRoot` so two agents can never edit/revert the same files (#438).
|
|
130
|
+
*/
|
|
131
|
+
repoRootTail = Promise.resolve();
|
|
132
|
+
/** `.ai/cezar` — where the per-task handoff files and todos.json live. */
|
|
133
|
+
dataDir;
|
|
134
|
+
/** Runs currently being paused by the memory guard — dedupes the ~2 s samples so one breach
|
|
135
|
+
* triggers one pause, not a burst. Cleared in dropActive when the run leaves the registry. */
|
|
136
|
+
memoryPausing = new Set();
|
|
137
|
+
constructor(store, repoRoot) {
|
|
138
|
+
this.store = store;
|
|
139
|
+
this.repoRoot = repoRoot;
|
|
140
|
+
this.dataDir = join(repoRoot, '.ai/cezar');
|
|
141
|
+
// Memory guard (#memory-guard): the shared process-tree sampler already ticks ~every 2 s for
|
|
142
|
+
// the runs table; piggyback on it to enforce the per-task memory ceiling.
|
|
143
|
+
onUsage((snapshot) => void this.enforceMemoryLimit(snapshot));
|
|
144
|
+
}
|
|
145
|
+
/**
|
|
146
|
+
* Pause any active run whose whole process tree exceeds `config.memoryLimitMb`, freeing its
|
|
147
|
+
* slot so the queue advances (#memory-guard). "Pause" closes the session — freeing the tree's
|
|
148
|
+
* memory — and leaves the run resumable via Continue; a loud warning explains why. No-op when
|
|
149
|
+
* no limit is set or the sampler has no data (e.g. `ps`/PowerShell unavailable).
|
|
150
|
+
*/
|
|
151
|
+
async enforceMemoryLimit(snapshot) {
|
|
152
|
+
const runIds = Object.keys(snapshot);
|
|
153
|
+
if (runIds.length === 0)
|
|
154
|
+
return;
|
|
155
|
+
const limitMb = (await loadConfig(this.repoRoot)).memoryLimitMb;
|
|
156
|
+
if (!limitMb || limitMb <= 0)
|
|
157
|
+
return;
|
|
158
|
+
const limitBytes = limitMb * 1024 * 1024;
|
|
159
|
+
for (const runId of runIds) {
|
|
160
|
+
const usage = snapshot[runId];
|
|
161
|
+
if (!usage || usage.rssBytes <= limitBytes)
|
|
162
|
+
continue;
|
|
163
|
+
if (this.memoryPausing.has(runId))
|
|
164
|
+
continue;
|
|
165
|
+
const state = this.active.get(runId);
|
|
166
|
+
if (!state?.session?.open || state.cancelled)
|
|
167
|
+
continue;
|
|
168
|
+
this.memoryPausing.add(runId);
|
|
169
|
+
const usedMb = Math.round(usage.rssBytes / (1024 * 1024));
|
|
170
|
+
this.store.appendEvent(runId, {
|
|
171
|
+
type: 'note',
|
|
172
|
+
message: `⚠ memory limit exceeded — this task's process tree is using ${usedMb} MiB (limit ${limitMb} MiB). Pausing it and letting the next queued task run; resume it with Continue.`,
|
|
173
|
+
});
|
|
174
|
+
this.store.appendEvent(runId, {
|
|
175
|
+
type: 'lifecycle',
|
|
176
|
+
message: `paused — memory limit exceeded (${usedMb} MiB > ${limitMb} MiB)`,
|
|
177
|
+
});
|
|
178
|
+
// Closing the session frees the tree and lets the normal exit path settle the run and
|
|
179
|
+
// pump the queue. Suppress autonomous auto-continue so the pause actually holds.
|
|
180
|
+
state.autonomous = false;
|
|
181
|
+
this.clearIdleTimer(state);
|
|
182
|
+
state.session.end();
|
|
183
|
+
}
|
|
184
|
+
}
|
|
185
|
+
/** Env the spawned claude gets so the agent can find its handoff file and
|
|
186
|
+
* the global inbox (spec 007; the inbox only when the run opted in).
|
|
187
|
+
*
|
|
188
|
+
* `CEZ_TODOS_FILE` is set to `''` rather than omitted when follow-ups are
|
|
189
|
+
* off: runners spawn with `{ ...process.env, ...spec.env }`, so omitting the
|
|
190
|
+
* key would let a value inherited from *this* process through — a nested
|
|
191
|
+
* cezar (an agent running `cez serve`/`cez run`/the test suite) would then
|
|
192
|
+
* write follow-ups into the parent's inbox despite the opt-out. Empty is the
|
|
193
|
+
* established "absent" spelling — consumers guard with `if (todosFile)`. */
|
|
194
|
+
agentEnv(runId, generateFollowups = true) {
|
|
195
|
+
return {
|
|
196
|
+
CEZ_HANDOFF_FILE: handoffPath(this.dataDir, runId),
|
|
197
|
+
CEZ_TASK_ID: runId,
|
|
198
|
+
CEZ_TODOS_FILE: generateFollowups ? todosPath(this.dataDir) : '',
|
|
199
|
+
};
|
|
200
|
+
}
|
|
201
|
+
startRun(workflow, input, group) {
|
|
202
|
+
const run = this.store.createRun({
|
|
203
|
+
title: makeRunTitle(input.task, workflow) + (group ? ` (${group.variant})` : ''),
|
|
204
|
+
workflow: workflow.name,
|
|
205
|
+
task: input.task,
|
|
206
|
+
model: input.model,
|
|
207
|
+
runner: input.runner,
|
|
208
|
+
// The global inbox is the ceiling on the per-run flag (#471). Enforced here rather than
|
|
209
|
+
// at the HTTP route because `cezar run`, the inbox's own "▶ Run" and variants all reach
|
|
210
|
+
// startRun directly — a route-level gate would leave those writing todos.json.
|
|
211
|
+
generateFollowups: followupsEnabled() ? input.generateFollowups : false,
|
|
212
|
+
// Persist autonomy on the record (#489) so the terminal review gate
|
|
213
|
+
// (`settleSuccess`) and the group-pick winner-park can honor it — mid-run
|
|
214
|
+
// auto-nudge reads `input.autonomous` (`execute`), but the record is the
|
|
215
|
+
// only source those after-the-fact consumers have.
|
|
216
|
+
autonomous: input.autonomous === true,
|
|
217
|
+
groupId: group?.groupId,
|
|
218
|
+
variant: group?.variant,
|
|
219
|
+
steps: workflow.steps.map((s) => ({ id: s.id, name: s.name ?? s.id, kind: stepKind(s) })),
|
|
220
|
+
});
|
|
221
|
+
// Persist the full definition so a queued run survives a restart (#367) —
|
|
222
|
+
// ad-hoc "(planned)" chains exist nowhere else to re-resolve from.
|
|
223
|
+
this.store.updateRun(run.id, { workflowDef: workflow });
|
|
224
|
+
// Step-0 reference extraction (task auto-naming spec): the regex layer's
|
|
225
|
+
// numbers persist immediately; the namer may add the kind it verified later.
|
|
226
|
+
const skillHint = workflow.steps.find((s) => stepKind(s) === 'agent' && s.skill)?.skill?.trim();
|
|
227
|
+
const refs = refineTaskRefs(extractTaskRefs(input.task), skillHint);
|
|
228
|
+
if (refs.prNumber !== undefined || refs.issueNumber !== undefined) {
|
|
229
|
+
this.store.updateRun(run.id, {
|
|
230
|
+
...(refs.prNumber !== undefined ? { prNumber: refs.prNumber } : {}),
|
|
231
|
+
...(refs.issueNumber !== undefined ? { issueNumber: refs.issueNumber } : {}),
|
|
232
|
+
});
|
|
233
|
+
}
|
|
234
|
+
// Fire-and-forget LLM naming (task auto-naming spec): the heuristic title
|
|
235
|
+
// above shows instantly; the namer's short title replaces it when (and if)
|
|
236
|
+
// the model answers. Never awaited, never fails the run.
|
|
237
|
+
void this.autoNameRun(run.id, skillHint, input.task);
|
|
238
|
+
this.pendingJobs.set(run.id, { workflow, input });
|
|
239
|
+
this.queue.push(run.id);
|
|
240
|
+
void this.pump();
|
|
241
|
+
return run;
|
|
242
|
+
}
|
|
243
|
+
/**
|
|
244
|
+
* Parallel variants (spec 010): N runs of the same workflow on the same
|
|
245
|
+
* task, sharing a groupId. Variant A gets the task verbatim; B and C get a
|
|
246
|
+
* fixed one-line approach hint appended to the *task input* (not the step
|
|
247
|
+
* template), so diversification works with any workflow. The normal queue
|
|
248
|
+
* applies — with maxParallel=2 a third variant simply waits.
|
|
249
|
+
*/
|
|
250
|
+
startVariants(workflow, input, count) {
|
|
251
|
+
const groupId = randomUUID();
|
|
252
|
+
return VARIANT_LETTERS.slice(0, Math.min(Math.max(count, 1), VARIANT_LETTERS.length)).map((variant) => {
|
|
253
|
+
const hint = VARIANT_HINTS[variant];
|
|
254
|
+
const task = hint ? `${input.task}\n\n${hint}` : input.task;
|
|
255
|
+
return this.startRun(workflow, { ...input, task }, { groupId, variant });
|
|
256
|
+
});
|
|
257
|
+
}
|
|
258
|
+
/**
|
|
259
|
+
* Start queued runs while parallel slots are free. `maxParallel` comes from
|
|
260
|
+
* `.ai/cezar/config.json` (default 2); a non-git directory degrades to 1
|
|
261
|
+
* sequential run in the repo root (spec 006 degradation rule).
|
|
262
|
+
*/
|
|
263
|
+
async pump() {
|
|
264
|
+
if (this.pumping)
|
|
265
|
+
return;
|
|
266
|
+
this.pumping = true;
|
|
267
|
+
try {
|
|
268
|
+
const repo = await getRepoInfo(this.repoRoot);
|
|
269
|
+
const maxParallel = repo ? (await loadConfig(this.repoRoot)).maxParallel : 1;
|
|
270
|
+
// `waiting` runs don't hold a slot (#347). A message into a waiting run
|
|
271
|
+
// resumes it even when that momentarily exceeds maxParallel — resumed
|
|
272
|
+
// conversations must never be blocked by the queue.
|
|
273
|
+
const busy = () => this.active.size + this.starting.size - this.waiting.size;
|
|
274
|
+
while (this.queue.length > 0 && busy() < maxParallel) {
|
|
275
|
+
const runId = this.queue.shift();
|
|
276
|
+
if (!runId)
|
|
277
|
+
break;
|
|
278
|
+
const job = this.pendingJobs.get(runId);
|
|
279
|
+
this.pendingJobs.delete(runId);
|
|
280
|
+
if (!job)
|
|
281
|
+
continue;
|
|
282
|
+
this.starting.add(runId);
|
|
283
|
+
void this.execute(runId, job.workflow, job.input).catch((err) => {
|
|
284
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
285
|
+
this.store.updateRun(runId, {
|
|
286
|
+
status: 'failed',
|
|
287
|
+
error: `engine crashed: ${message}`,
|
|
288
|
+
finishedAt: new Date().toISOString(),
|
|
289
|
+
});
|
|
290
|
+
const state = this.active.get(runId);
|
|
291
|
+
if (state) {
|
|
292
|
+
this.clearIdleTimer(state);
|
|
293
|
+
this.clearAutosaveTimer(state);
|
|
294
|
+
}
|
|
295
|
+
this.starting.delete(runId);
|
|
296
|
+
this.dropActive(runId);
|
|
297
|
+
void this.pump();
|
|
298
|
+
});
|
|
299
|
+
}
|
|
300
|
+
}
|
|
301
|
+
finally {
|
|
302
|
+
this.pumping = false;
|
|
303
|
+
}
|
|
304
|
+
}
|
|
305
|
+
/**
|
|
306
|
+
* Startup recovery (#367) — re-adopt runs that were live when the previous
|
|
307
|
+
* cezar process exited (requires the store opened with `keepLive`):
|
|
308
|
+
* - `queued` → back into the queue (FIFO by createdAt), from the persisted
|
|
309
|
+
* workflowDef (or the catalog by name for older records);
|
|
310
|
+
* - `waiting` → the turn was over and the ball was in the user's court —
|
|
311
|
+
* settle exactly like a closed session (review/done, Continue still works);
|
|
312
|
+
* - `running` → mark interrupted, then immediately resume the last agent
|
|
313
|
+
* session via the Continue path, pointing the agent at its handoff file.
|
|
314
|
+
* Call once, before the server starts taking requests.
|
|
315
|
+
*/
|
|
316
|
+
async recover() {
|
|
317
|
+
const live = this.store
|
|
318
|
+
.listRuns()
|
|
319
|
+
.filter((r) => ['queued', 'waiting', 'running'].includes(r.status))
|
|
320
|
+
.sort((a, b) => a.createdAt.localeCompare(b.createdAt));
|
|
321
|
+
for (const run of live) {
|
|
322
|
+
if (run.status === 'queued') {
|
|
323
|
+
const workflow = await this.reviveWorkflow(run);
|
|
324
|
+
if (workflow) {
|
|
325
|
+
// Re-apply the inbox ceiling (#471). `execute()` gates again at spawn time, so the
|
|
326
|
+
// agent is safe either way — but a run queued while the inbox was on and recovered
|
|
327
|
+
// after it was switched off would otherwise keep echoing `generateFollowups: true`
|
|
328
|
+
// on a run that demonstrably produced none. Normalize the record, the way startRun
|
|
329
|
+
// does, so the stored answer matches what actually happens.
|
|
330
|
+
const generateFollowups = followupsEnabled() ? run.generateFollowups : false;
|
|
331
|
+
if (generateFollowups !== run.generateFollowups) {
|
|
332
|
+
this.store.updateRun(run.id, { generateFollowups });
|
|
333
|
+
}
|
|
334
|
+
this.pendingJobs.set(run.id, {
|
|
335
|
+
workflow,
|
|
336
|
+
input: {
|
|
337
|
+
task: run.task,
|
|
338
|
+
model: run.model,
|
|
339
|
+
runner: run.runner,
|
|
340
|
+
generateFollowups,
|
|
341
|
+
// Re-thread autonomy (#489): the rebuilt input feeds `execute`,
|
|
342
|
+
// whose mid-run auto-nudge reads `input.autonomous`. Without this a
|
|
343
|
+
// recovered queued autonomous run would run non-autonomously (no
|
|
344
|
+
// auto-nudge) and later wrongly park at `review`.
|
|
345
|
+
autonomous: run.autonomous,
|
|
346
|
+
},
|
|
347
|
+
});
|
|
348
|
+
this.queue.push(run.id);
|
|
349
|
+
this.store.appendEvent(run.id, { type: 'lifecycle', message: 'cezar restarted — task re-queued' });
|
|
350
|
+
}
|
|
351
|
+
else {
|
|
352
|
+
this.store.updateRun(run.id, {
|
|
353
|
+
status: 'failed',
|
|
354
|
+
error: 'interrupted — workflow definition not recoverable after a restart',
|
|
355
|
+
finishedAt: new Date().toISOString(),
|
|
356
|
+
});
|
|
357
|
+
this.store.appendEvent(run.id, {
|
|
358
|
+
type: 'lifecycle',
|
|
359
|
+
message: 'cezar restarted — workflow definition not recoverable, task failed',
|
|
360
|
+
});
|
|
361
|
+
}
|
|
362
|
+
continue;
|
|
363
|
+
}
|
|
364
|
+
if (run.status === 'waiting') {
|
|
365
|
+
for (const step of run.steps) {
|
|
366
|
+
if (step.status === 'waiting' || step.status === 'running') {
|
|
367
|
+
this.store.updateStep(run.id, step.id, { status: 'done', finishedAt: new Date().toISOString() });
|
|
368
|
+
}
|
|
369
|
+
}
|
|
370
|
+
this.store.appendEvent(run.id, {
|
|
371
|
+
type: 'lifecycle',
|
|
372
|
+
message: 'cezar restarted — the open session was settled',
|
|
373
|
+
});
|
|
374
|
+
await this.settleSuccess(run.id);
|
|
375
|
+
continue;
|
|
376
|
+
}
|
|
377
|
+
// `running`: the process died mid-turn. Mark it interrupted (the state
|
|
378
|
+
// continueRun expects), then pick the work back up from the last session.
|
|
379
|
+
const finishedAt = new Date().toISOString();
|
|
380
|
+
for (const step of run.steps) {
|
|
381
|
+
if (step.status === 'running' || step.status === 'waiting') {
|
|
382
|
+
this.store.updateStep(run.id, step.id, { status: 'failed', finishedAt });
|
|
383
|
+
}
|
|
384
|
+
}
|
|
385
|
+
this.store.updateRun(run.id, {
|
|
386
|
+
status: 'failed',
|
|
387
|
+
error: 'interrupted — cezar process exited during the run',
|
|
388
|
+
finishedAt,
|
|
389
|
+
currentStepId: undefined,
|
|
390
|
+
});
|
|
391
|
+
const resumed = this.continueRun(run.id, 'The cezar process restarted while you were working on this task. Read the handoff file (CEZ_HANDOFF_FILE) to recover context, then continue the task from where you left off.');
|
|
392
|
+
this.store.appendEvent(run.id, {
|
|
393
|
+
type: 'lifecycle',
|
|
394
|
+
message: resumed.ok
|
|
395
|
+
? 'cezar restarted — resuming the interrupted task from its last session'
|
|
396
|
+
: `cezar restarted — could not resume the interrupted task (${resumed.error ?? 'unknown'})`,
|
|
397
|
+
});
|
|
398
|
+
}
|
|
399
|
+
void this.pump();
|
|
400
|
+
}
|
|
401
|
+
/** The persisted definition when it looks sane, else the catalog by name. */
|
|
402
|
+
async reviveWorkflow(run) {
|
|
403
|
+
const def = run.workflowDef;
|
|
404
|
+
if (def && Array.isArray(def.steps)) {
|
|
405
|
+
return def;
|
|
406
|
+
}
|
|
407
|
+
const { workflows } = await loadWorkflows(this.repoRoot);
|
|
408
|
+
return workflows.find((w) => w.name === run.workflow) ?? null;
|
|
409
|
+
}
|
|
410
|
+
/** Remove a run from the live registries — keeps `waiting ⊆ active`. */
|
|
411
|
+
dropActive(runId) {
|
|
412
|
+
const state = this.active.get(runId);
|
|
413
|
+
state?.releaseRepoRoot?.();
|
|
414
|
+
if (state)
|
|
415
|
+
state.releaseRepoRoot = undefined;
|
|
416
|
+
this.waiting.delete(runId);
|
|
417
|
+
this.active.delete(runId);
|
|
418
|
+
this.memoryPausing.delete(runId);
|
|
419
|
+
this.lastNamerKey.delete(runId);
|
|
420
|
+
// A run leaving the active registry is a terminal transition (done/review/
|
|
421
|
+
// failed/cancelled) — the one moment the finished-worktree count can grow.
|
|
422
|
+
// Enforce count-based retention (#483) here so a single hook covers every
|
|
423
|
+
// terminal path. Fire-and-forget: retention must never delay or throw into
|
|
424
|
+
// the lifecycle.
|
|
425
|
+
void this.enforceRetention();
|
|
426
|
+
}
|
|
427
|
+
/** Reclaim finished worktrees beyond the keep-limit (#483) — directory only,
|
|
428
|
+
* `cez/<id8>` branch kept. Best-effort; a failure never affects run
|
|
429
|
+
* lifecycle. `review`/live runs are excluded by the selector. */
|
|
430
|
+
async enforceRetention() {
|
|
431
|
+
try {
|
|
432
|
+
const keep = (await loadConfig(this.repoRoot)).worktreeRetention;
|
|
433
|
+
await reclaimWorktrees(this.repoRoot, this.store, keep);
|
|
434
|
+
}
|
|
435
|
+
catch {
|
|
436
|
+
// retention is best-effort; swallow so terminal transitions never break.
|
|
437
|
+
}
|
|
438
|
+
}
|
|
439
|
+
/** Last live-refresh namer inputs per run — unchanged inputs skip the call. */
|
|
440
|
+
lastNamerKey = new Map();
|
|
441
|
+
/**
|
|
442
|
+
* Acquire the one-at-a-time lease for runs executing in `repoRoot`.
|
|
443
|
+
*
|
|
444
|
+
* A lease waiter is idle, so it parks in `waiting` and gives its
|
|
445
|
+
* `maxParallel` slot back (the #347 rule): isolated worktrees keep using
|
|
446
|
+
* every configured slot while root runs line up. The store status stays
|
|
447
|
+
* `running` — only the queue's busy count changes, so the GUI never shows a
|
|
448
|
+
* lease-blocked run as awaiting user input.
|
|
449
|
+
*
|
|
450
|
+
* The lease is held for the run's whole lifetime, including the idle
|
|
451
|
+
* `waiting` parks between agent turns. A parked session is still live and
|
|
452
|
+
* writes to the working tree the moment it resumes, so handing the tree to
|
|
453
|
+
* another run there would reintroduce the concurrent-edit bug (#438) this
|
|
454
|
+
* lease exists to prevent.
|
|
455
|
+
*
|
|
456
|
+
* Returns false when the run was cancelled while waiting: the lease was
|
|
457
|
+
* never granted and the caller must not touch the working tree.
|
|
458
|
+
*/
|
|
459
|
+
async acquireRepoRoot(runId, state) {
|
|
460
|
+
// `cancel()` can land between the run going `running` and reaching here,
|
|
461
|
+
// while `interrupt` is still the default no-op — never enter the chain.
|
|
462
|
+
if (state.cancelled)
|
|
463
|
+
return false;
|
|
464
|
+
const previous = this.repoRootTail;
|
|
465
|
+
let release = () => undefined;
|
|
466
|
+
this.repoRootTail = new Promise((resolve) => {
|
|
467
|
+
release = resolve;
|
|
468
|
+
});
|
|
469
|
+
// Until `previous` resolves this run does not own the tree yet, so a drop
|
|
470
|
+
// during the wait must not hand the tree to the next waiter — chain our
|
|
471
|
+
// release behind `previous` instead of resolving the tail early.
|
|
472
|
+
state.releaseRepoRoot = () => {
|
|
473
|
+
void previous.then(release);
|
|
474
|
+
};
|
|
475
|
+
let abort = () => undefined;
|
|
476
|
+
const cancelled = new Promise((resolve) => {
|
|
477
|
+
abort = resolve;
|
|
478
|
+
});
|
|
479
|
+
const parked = state.interrupt;
|
|
480
|
+
state.interrupt = () => {
|
|
481
|
+
parked();
|
|
482
|
+
abort();
|
|
483
|
+
};
|
|
484
|
+
this.waiting.add(runId);
|
|
485
|
+
void this.pump();
|
|
486
|
+
try {
|
|
487
|
+
await Promise.race([previous, cancelled]);
|
|
488
|
+
}
|
|
489
|
+
finally {
|
|
490
|
+
state.interrupt = parked;
|
|
491
|
+
this.waiting.delete(runId);
|
|
492
|
+
}
|
|
493
|
+
if (state.cancelled)
|
|
494
|
+
return false;
|
|
495
|
+
state.releaseRepoRoot = release;
|
|
496
|
+
return true;
|
|
497
|
+
}
|
|
498
|
+
cancel(runId) {
|
|
499
|
+
// Still waiting in the queue: just drop it there.
|
|
500
|
+
const queuedAt = this.queue.indexOf(runId);
|
|
501
|
+
if (queuedAt >= 0) {
|
|
502
|
+
this.queue.splice(queuedAt, 1);
|
|
503
|
+
this.pendingJobs.delete(runId);
|
|
504
|
+
this.store.updateRun(runId, { status: 'cancelled', finishedAt: new Date().toISOString() });
|
|
505
|
+
this.store.appendEvent(runId, { type: 'lifecycle', message: 'cancelled while queued' });
|
|
506
|
+
return true;
|
|
507
|
+
}
|
|
508
|
+
const state = this.active.get(runId);
|
|
509
|
+
if (!state)
|
|
510
|
+
return false;
|
|
511
|
+
state.cancelled = true;
|
|
512
|
+
this.clearIdleTimer(state);
|
|
513
|
+
state.interrupt();
|
|
514
|
+
return true;
|
|
515
|
+
}
|
|
516
|
+
isActive(runId) {
|
|
517
|
+
return this.active.has(runId) || this.starting.has(runId) || this.queue.includes(runId);
|
|
518
|
+
}
|
|
519
|
+
/**
|
|
520
|
+
* Deliver a user message into the run's live claude session (mid-turn or
|
|
521
|
+
* while `waiting`). Returns false when there is no open session — the GUI
|
|
522
|
+
* then offers "Continue" instead.
|
|
523
|
+
*/
|
|
524
|
+
sendMessage(runId, content) {
|
|
525
|
+
const state = this.active.get(runId);
|
|
526
|
+
if (!state?.session?.open || state.cancelled)
|
|
527
|
+
return false;
|
|
528
|
+
const text = content
|
|
529
|
+
.filter((b) => b.type === 'text')
|
|
530
|
+
.map((b) => b.text)
|
|
531
|
+
.join('\n');
|
|
532
|
+
// Persist the attached images so the thread can render them (not just count them) — the same
|
|
533
|
+
// on-disk store + `/images/` route the agent's own screenshots use.
|
|
534
|
+
const images = content
|
|
535
|
+
.filter((b) => b.type === 'image')
|
|
536
|
+
.map((b) => this.persistImage(runId, state, b.source.media_type, b.source.data))
|
|
537
|
+
.filter((saved) => saved !== null)
|
|
538
|
+
.map((saved) => saved.url);
|
|
539
|
+
this.store.appendEvent(runId, {
|
|
540
|
+
type: 'user-message',
|
|
541
|
+
stepId: state.currentStepId,
|
|
542
|
+
text,
|
|
543
|
+
imageCount: content.filter((b) => b.type === 'image').length,
|
|
544
|
+
images,
|
|
545
|
+
});
|
|
546
|
+
const delivered = state.session.sendMessage(content);
|
|
547
|
+
if (delivered) {
|
|
548
|
+
this.clearIdleTimer(state);
|
|
549
|
+
this.waiting.delete(runId); // resumed — the run counts against slots again
|
|
550
|
+
// Clear any `monitoring` activity — the agent is actively working again
|
|
551
|
+
// (spec 2026-07-18-subagent-monitoring-status, #490).
|
|
552
|
+
this.store.updateRun(runId, { status: 'running', activity: undefined });
|
|
553
|
+
if (state.currentStepId) {
|
|
554
|
+
this.store.updateStep(runId, state.currentStepId, { status: 'running' });
|
|
555
|
+
}
|
|
556
|
+
}
|
|
557
|
+
return delivered;
|
|
558
|
+
}
|
|
559
|
+
/** Close the open session gracefully — the run then completes as `done`
|
|
560
|
+
* (or rests at `review` when the worktree holds changes, spec 009).
|
|
561
|
+
* On a run already resting at `review` (no session — the engine loop is
|
|
562
|
+
* over), "Finish" is the third review exit: accept the changes without a
|
|
563
|
+
* PR and flip straight to `done`. */
|
|
564
|
+
finish(runId) {
|
|
565
|
+
const state = this.active.get(runId);
|
|
566
|
+
if (state?.session?.open) {
|
|
567
|
+
this.clearIdleTimer(state);
|
|
568
|
+
this.store.appendEvent(runId, { type: 'lifecycle', message: 'session closed by user' });
|
|
569
|
+
state.session.end();
|
|
570
|
+
return true;
|
|
571
|
+
}
|
|
572
|
+
const run = this.store.getRun(runId);
|
|
573
|
+
if (run?.status === 'review' && !this.isActive(runId)) {
|
|
574
|
+
this.store.updateRun(runId, { status: 'done' });
|
|
575
|
+
this.store.appendEvent(runId, { type: 'lifecycle', message: 'review accepted — finished without a PR' });
|
|
576
|
+
return true;
|
|
577
|
+
}
|
|
578
|
+
return false;
|
|
579
|
+
}
|
|
580
|
+
/**
|
|
581
|
+
* "Continue" (spec 003): reopen a finished run's claude session in-process
|
|
582
|
+
* (`claude --resume <sessionId>`) as a new synthetic step. The session then
|
|
583
|
+
* behaves exactly like an interactive step: `waiting` after each turn,
|
|
584
|
+
* messages via sendMessage, closed by finish/idle/cancel.
|
|
585
|
+
*/
|
|
586
|
+
continueRun(runId, text) {
|
|
587
|
+
if (this.active.has(runId))
|
|
588
|
+
return { ok: false, error: 'run is still active' };
|
|
589
|
+
const run = this.store.getRun(runId);
|
|
590
|
+
if (!run)
|
|
591
|
+
return { ok: false, error: 'not found' };
|
|
592
|
+
// `review` is continuable too — that's the "Send back" path (spec 009).
|
|
593
|
+
if (!['done', 'failed', 'cancelled', 'review'].includes(run.status)) {
|
|
594
|
+
return { ok: false, error: `cannot continue a ${run.status} run` };
|
|
595
|
+
}
|
|
596
|
+
const sessionId = [...run.steps].reverse().find((s) => s.sessionId)?.sessionId;
|
|
597
|
+
if (!sessionId)
|
|
598
|
+
return { ok: false, error: 'no agent session to resume' };
|
|
599
|
+
const continuations = run.steps.filter((s) => s.id.startsWith('continue-')).length;
|
|
600
|
+
const stepId = `continue-${continuations + 1}`;
|
|
601
|
+
this.store.addStep(runId, { id: stepId, name: 'Continue', kind: 'agent' });
|
|
602
|
+
void this.runContinuation(runId, stepId, sessionId, text?.trim() || 'Continue.').catch((err) => {
|
|
603
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
604
|
+
this.store.updateRun(runId, {
|
|
605
|
+
status: 'failed',
|
|
606
|
+
error: `continue crashed: ${message}`,
|
|
607
|
+
finishedAt: new Date().toISOString(),
|
|
608
|
+
});
|
|
609
|
+
this.dropActive(runId);
|
|
610
|
+
void this.pump();
|
|
611
|
+
});
|
|
612
|
+
return { ok: true };
|
|
613
|
+
}
|
|
614
|
+
async runContinuation(runId, stepId, sessionId, prompt) {
|
|
615
|
+
// Continuation runs in the task's worktree when it still exists (spec
|
|
616
|
+
// 006) — the resumed session sees exactly what the original run left.
|
|
617
|
+
// Retention (#483) may have reclaimed this run's worktree directory while
|
|
618
|
+
// keeping its branch and worktreePath. Re-materialize it on resume and clear
|
|
619
|
+
// the stamp so the session regains its isolated tree and the run is eligible
|
|
620
|
+
// for retention again — otherwise it keeps a dir on disk while staying
|
|
621
|
+
// invisible to the enforcer forever. Best-effort; falls back to repoRoot.
|
|
622
|
+
await rematerializeReclaimedWorktree(this.repoRoot, this.store, runId);
|
|
623
|
+
const record = this.store.getRun(runId);
|
|
624
|
+
// The env is a live ceiling: a run created while the inbox was on must not keep writing
|
|
625
|
+
// follow-ups after it is switched off.
|
|
626
|
+
const generateFollowups = followupsEnabled() && record?.generateFollowups !== false;
|
|
627
|
+
const cwd = record?.worktreePath && existsSync(record.worktreePath)
|
|
628
|
+
? record.worktreePath
|
|
629
|
+
: this.repoRoot;
|
|
630
|
+
const state = { cancelled: false, interrupt: () => undefined, cwd };
|
|
631
|
+
this.active.set(runId, state);
|
|
632
|
+
if (state.cwd === this.repoRoot) {
|
|
633
|
+
this.store.appendEvent(runId, {
|
|
634
|
+
type: 'note',
|
|
635
|
+
message: 'waiting for exclusive access to the repository working tree',
|
|
636
|
+
});
|
|
637
|
+
if (!(await this.acquireRepoRoot(runId, state))) {
|
|
638
|
+
this.store.updateRun(runId, {
|
|
639
|
+
status: 'cancelled',
|
|
640
|
+
finishedAt: new Date().toISOString(),
|
|
641
|
+
currentStepId: undefined,
|
|
642
|
+
});
|
|
643
|
+
this.store.appendEvent(runId, { type: 'lifecycle', message: 'run cancelled' });
|
|
644
|
+
this.dropActive(runId);
|
|
645
|
+
void this.pump();
|
|
646
|
+
return;
|
|
647
|
+
}
|
|
648
|
+
}
|
|
649
|
+
this.armAutosave(state);
|
|
650
|
+
if (record)
|
|
651
|
+
seedHandoffFile(this.dataDir, record); // idempotent — normally already there
|
|
652
|
+
this.store.updateRun(runId, {
|
|
653
|
+
status: 'running',
|
|
654
|
+
error: undefined,
|
|
655
|
+
finishedAt: undefined,
|
|
656
|
+
currentStepId: stepId,
|
|
657
|
+
activity: undefined, // resuming a monitoring run — it's actively working again (#490)
|
|
658
|
+
});
|
|
659
|
+
this.store.updateStep(runId, stepId, {
|
|
660
|
+
status: 'running',
|
|
661
|
+
iterations: 1,
|
|
662
|
+
startedAt: new Date().toISOString(),
|
|
663
|
+
sessionId,
|
|
664
|
+
});
|
|
665
|
+
this.store.appendEvent(runId, { type: 'step-start', stepId, name: 'Continue', kind: 'agent', iteration: 1 });
|
|
666
|
+
this.store.appendEvent(runId, { type: 'user-message', stepId, text: prompt, imageCount: 0 });
|
|
667
|
+
let stepCost = 0;
|
|
668
|
+
let turnText = '';
|
|
669
|
+
const sink = this.makeUiSink(runId, stepId);
|
|
670
|
+
const onEvent = (event) => {
|
|
671
|
+
if (event.type === 'image') {
|
|
672
|
+
const saved = this.persistImage(runId, state, event.mediaType, event.data);
|
|
673
|
+
if (saved)
|
|
674
|
+
this.store.appendEvent(runId, { type: 'image', stepId, ...saved });
|
|
675
|
+
return;
|
|
676
|
+
}
|
|
677
|
+
if (event.type === 'text') {
|
|
678
|
+
turnText += event.text;
|
|
679
|
+
const text = stripMonitoringMarker(stripDoneMarker(event.text));
|
|
680
|
+
if (text)
|
|
681
|
+
this.store.appendEvent(runId, { type: 'text', text, stepId });
|
|
682
|
+
return;
|
|
683
|
+
}
|
|
684
|
+
this.store.appendEvent(runId, { ...event, stepId });
|
|
685
|
+
if (event.type === 'session') {
|
|
686
|
+
this.store.updateStep(runId, stepId, { sessionId: event.sessionId });
|
|
687
|
+
}
|
|
688
|
+
if (event.type === 'token-usage') {
|
|
689
|
+
this.store.updateStep(runId, stepId, { tokensUsed: event.tokensUsed });
|
|
690
|
+
}
|
|
691
|
+
if (event.type === 'cost') {
|
|
692
|
+
stepCost += event.usd;
|
|
693
|
+
this.store.updateStep(runId, stepId, { costUsd: stepCost });
|
|
694
|
+
}
|
|
695
|
+
if (event.type === 'turn-end') {
|
|
696
|
+
// Belt-and-braces: v2 `turn.completed` already flushed the delta
|
|
697
|
+
// coalescers; the v1 turn boundary flushes again (idempotent) so no
|
|
698
|
+
// buffered delta can outlive its turn.
|
|
699
|
+
sink.flushAll();
|
|
700
|
+
void this.recordTurnEnd(runId, turnText); // titleSummary + diffStat (#389)
|
|
701
|
+
const sessionOpen = !state.cancelled && state.session?.open;
|
|
702
|
+
const done = sessionOpen && DONE_MARKER_RE.test(turnText.trimEnd());
|
|
703
|
+
const monitoring = sessionOpen && !done && MONITORING_MARKER_RE.test(turnText.trimEnd());
|
|
704
|
+
turnText = '';
|
|
705
|
+
if (done) {
|
|
706
|
+
// Goal achieved (agent contract, #347) — same as in runAgentStep.
|
|
707
|
+
this.store.appendEvent(runId, { type: 'lifecycle', message: 'goal achieved — session closed' });
|
|
708
|
+
appendHandoffHeartbeat(this.dataDir, runId, 'turn complete — goal achieved, session closed');
|
|
709
|
+
state.session?.end();
|
|
710
|
+
return;
|
|
711
|
+
}
|
|
712
|
+
if (sessionOpen) {
|
|
713
|
+
// Autonomous (#autonomous): never hand the ball back to the user. Nudge the agent to
|
|
714
|
+
// keep going (bounded by MAX_AUTO_CONTINUES) instead of parking at `waiting`.
|
|
715
|
+
const autoContinued = state.autonomous &&
|
|
716
|
+
(state.autoContinues ?? 0) < MAX_AUTO_CONTINUES &&
|
|
717
|
+
!state.cancelled &&
|
|
718
|
+
(() => {
|
|
719
|
+
const sent = state.session?.sendMessage([{ type: 'text', text: AUTONOMOUS_NUDGE }]);
|
|
720
|
+
if (!sent)
|
|
721
|
+
return false;
|
|
722
|
+
state.autoContinues = (state.autoContinues ?? 0) + 1;
|
|
723
|
+
this.store.appendEvent(runId, {
|
|
724
|
+
type: 'note',
|
|
725
|
+
message: `autonomous — continuing without pausing (${state.autoContinues}/${MAX_AUTO_CONTINUES})`,
|
|
726
|
+
});
|
|
727
|
+
return true;
|
|
728
|
+
})();
|
|
729
|
+
if (!autoContinued) {
|
|
730
|
+
// `CEZ:MONITORING` → non-attention `running`/`activity:'monitoring'`
|
|
731
|
+
// instead of `waiting` (spec 2026-07-18-subagent-monitoring-status,
|
|
732
|
+
// #490). Same lifecycle as waiting (frees the slot, keeps the idle
|
|
733
|
+
// timer). The autonomous nudge above still wins over monitoring.
|
|
734
|
+
if (monitoring) {
|
|
735
|
+
this.store.updateRun(runId, { status: 'running', activity: 'monitoring' });
|
|
736
|
+
this.store.updateStep(runId, stepId, { status: 'running' });
|
|
737
|
+
}
|
|
738
|
+
else {
|
|
739
|
+
this.store.updateRun(runId, { status: 'waiting', activity: undefined });
|
|
740
|
+
this.store.updateStep(runId, stepId, { status: 'waiting' });
|
|
741
|
+
}
|
|
742
|
+
this.waiting.add(runId);
|
|
743
|
+
this.armIdleTimer(runId, state);
|
|
744
|
+
void this.pump();
|
|
745
|
+
}
|
|
746
|
+
}
|
|
747
|
+
appendHandoffHeartbeat(this.dataDir, runId, `turn complete — status=${monitoring ? 'monitoring' : sessionOpen ? 'waiting' : 'running'}`);
|
|
748
|
+
}
|
|
749
|
+
};
|
|
750
|
+
const runner = createRunner(record?.runner ?? 'claude');
|
|
751
|
+
const session = runner.startSession({
|
|
752
|
+
// The Continue step is a fresh agent session on the same run — the
|
|
753
|
+
// run's extra system prompt (already resolved at execute time and
|
|
754
|
+
// echoed on the record) rides along with the handoff contract.
|
|
755
|
+
systemPrompt: composeSystemPrompt(record?.systemPrompt, generateFollowups ? HANDOFF_INSTRUCTIONS : HANDOFF_ONLY_INSTRUCTIONS),
|
|
756
|
+
userPrompt: prompt,
|
|
757
|
+
cwd: state.cwd,
|
|
758
|
+
allowedTools: DEFAULT_ALLOWED_TOOLS,
|
|
759
|
+
additionalDirectories: [join(this.dataDir, 'runs')],
|
|
760
|
+
env: this.agentEnv(runId, generateFollowups),
|
|
761
|
+
sessionId,
|
|
762
|
+
resume: true,
|
|
763
|
+
timeoutMs: 0,
|
|
764
|
+
}, onEvent, { onUiEvent: (event) => sink.handle(event) });
|
|
765
|
+
state.session = session;
|
|
766
|
+
state.currentStepId = stepId;
|
|
767
|
+
state.interrupt = () => session.interrupt();
|
|
768
|
+
if (session.pid !== undefined)
|
|
769
|
+
registerRunProcess(runId, session.pid);
|
|
770
|
+
const finishedAt = () => new Date().toISOString();
|
|
771
|
+
try {
|
|
772
|
+
await session.result;
|
|
773
|
+
sink.sessionEnded(state.cancelled ? 'cancelled' : 'end_turn');
|
|
774
|
+
if (state.cancelled) {
|
|
775
|
+
this.store.updateStep(runId, stepId, { status: 'cancelled', finishedAt: finishedAt() });
|
|
776
|
+
this.store.updateRun(runId, { status: 'cancelled', finishedAt: finishedAt(), currentStepId: undefined });
|
|
777
|
+
this.store.appendEvent(runId, { type: 'lifecycle', message: 'run cancelled' });
|
|
778
|
+
appendHandoffHeartbeat(this.dataDir, runId, `step "${stepId}" complete — status=cancelled`);
|
|
779
|
+
}
|
|
780
|
+
else {
|
|
781
|
+
this.store.updateStep(runId, stepId, { status: 'done', finishedAt: finishedAt() });
|
|
782
|
+
this.store.appendEvent(runId, { type: 'step-end', stepId, status: 'done' });
|
|
783
|
+
await this.settleSuccess(runId);
|
|
784
|
+
appendHandoffHeartbeat(this.dataDir, runId, `step "${stepId}" complete — status=done`);
|
|
785
|
+
}
|
|
786
|
+
}
|
|
787
|
+
catch (err) {
|
|
788
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
789
|
+
sink.sessionEnded('error', message);
|
|
790
|
+
this.store.updateStep(runId, stepId, { status: 'failed', error: message, finishedAt: finishedAt() });
|
|
791
|
+
appendHandoffHeartbeat(this.dataDir, runId, `step "${stepId}" complete — status=failed`);
|
|
792
|
+
this.store.updateRun(runId, {
|
|
793
|
+
status: 'failed',
|
|
794
|
+
error: `continue failed: ${message}`,
|
|
795
|
+
finishedAt: finishedAt(),
|
|
796
|
+
currentStepId: undefined,
|
|
797
|
+
});
|
|
798
|
+
this.store.appendEvent(runId, { type: 'lifecycle', message: `continue failed — ${message}` });
|
|
799
|
+
}
|
|
800
|
+
finally {
|
|
801
|
+
this.recordUsagePeaks(runId);
|
|
802
|
+
this.clearIdleTimer(state);
|
|
803
|
+
this.clearAutosaveTimer(state);
|
|
804
|
+
if (state.cwd !== this.repoRoot)
|
|
805
|
+
await autosaveCommit(state.cwd);
|
|
806
|
+
this.dropActive(runId);
|
|
807
|
+
void this.pump();
|
|
808
|
+
}
|
|
809
|
+
}
|
|
810
|
+
// ---- execution -----------------------------------------------------------
|
|
811
|
+
async execute(runId, workflow, input) {
|
|
812
|
+
const state = {
|
|
813
|
+
cancelled: false,
|
|
814
|
+
interrupt: () => undefined,
|
|
815
|
+
cwd: this.repoRoot,
|
|
816
|
+
autonomous: input.autonomous === true,
|
|
817
|
+
autoContinues: 0,
|
|
818
|
+
};
|
|
819
|
+
this.active.set(runId, state);
|
|
820
|
+
this.starting.delete(runId);
|
|
821
|
+
const emit = (event) => this.store.appendEvent(runId, event);
|
|
822
|
+
// Resolve the agent backend for this run: the task choice (GUI) wins over
|
|
823
|
+
// the config default. Per-step `runner` can still override it below.
|
|
824
|
+
const config = await loadConfig(this.repoRoot);
|
|
825
|
+
const taskBackend = input.runner ?? config.defaultRunner;
|
|
826
|
+
// Extra system prompt (R2 2.3): POST override > config default; echoed on
|
|
827
|
+
// the record so the UI/API can show what the run actually used.
|
|
828
|
+
const extraSystemPrompt = resolveExtraSystemPrompt(input.systemPrompt, config.systemPrompt);
|
|
829
|
+
this.store.updateRun(runId, {
|
|
830
|
+
status: 'running',
|
|
831
|
+
startedAt: new Date().toISOString(),
|
|
832
|
+
runner: taskBackend,
|
|
833
|
+
systemPrompt: extraSystemPrompt,
|
|
834
|
+
});
|
|
835
|
+
emit({ type: 'lifecycle', message: `run started — workflow "${workflow.name}" (runner: ${taskBackend})` });
|
|
836
|
+
// Worktree per task (spec 006): the agent works on its own branch in
|
|
837
|
+
// `.ai/cezar/worktrees/<id>`, never in the user's working tree. A Git task
|
|
838
|
+
// that requests isolation fails closed if the worktree cannot be
|
|
839
|
+
// established; only explicit opt-out and non-Git modes run in place.
|
|
840
|
+
const repo = await getRepoInfo(this.repoRoot);
|
|
841
|
+
if (repo && input.worktree === false) {
|
|
842
|
+
// Composer opt-out: run in the repo working tree, no branch/worktree
|
|
843
|
+
// (read-only skills — review, summarize — that never need isolation).
|
|
844
|
+
emit({ type: 'note', message: 'worktree off — running in the repo working tree' });
|
|
845
|
+
}
|
|
846
|
+
else if (repo) {
|
|
847
|
+
// Fork from the configured base branch (config.json `baseBranch`, e.g.
|
|
848
|
+
// `develop`) — also the target of the eventual draft PR. Unresolvable
|
|
849
|
+
// (typo, not fetched) → note + the currently checked-out branch.
|
|
850
|
+
//
|
|
851
|
+
// A task that already recorded a fork point keeps it: its worktree is
|
|
852
|
+
// reused as-is, and re-resolving against a since-changed config would
|
|
853
|
+
// silently re-anchor the `merge-base` every diff/shortstat is measured
|
|
854
|
+
// from, shifting "what did this task change" under an existing task.
|
|
855
|
+
const recorded = this.store.getRun(runId)?.baseBranch;
|
|
856
|
+
let base = recorded ?? repo.branch;
|
|
857
|
+
const configured = recorded ? undefined : config.baseBranch;
|
|
858
|
+
if (configured) {
|
|
859
|
+
const resolved = await resolveBaseRef(this.repoRoot, configured);
|
|
860
|
+
if (resolved) {
|
|
861
|
+
base = resolved;
|
|
862
|
+
}
|
|
863
|
+
else {
|
|
864
|
+
emit({
|
|
865
|
+
type: 'note',
|
|
866
|
+
message: `configured base branch "${configured}" not found (locally or on origin) — using "${repo.branch}"`,
|
|
867
|
+
});
|
|
868
|
+
}
|
|
869
|
+
}
|
|
870
|
+
try {
|
|
871
|
+
const wt = await createWorktree(this.repoRoot, runId, base);
|
|
872
|
+
state.cwd = wt.path;
|
|
873
|
+
this.store.updateRun(runId, {
|
|
874
|
+
worktreePath: wt.path,
|
|
875
|
+
branch: wt.branch,
|
|
876
|
+
baseBranch: wt.baseBranch,
|
|
877
|
+
});
|
|
878
|
+
emit({ type: 'note', message: `worktree ready — branch ${wt.branch} (base ${wt.baseBranch})` });
|
|
879
|
+
this.armAutosave(state);
|
|
880
|
+
}
|
|
881
|
+
catch (err) {
|
|
882
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
883
|
+
const error = `worktree creation failed: ${message}`;
|
|
884
|
+
emit({ type: 'note', message: `${error} — task stopped before workflow execution` });
|
|
885
|
+
this.store.updateRun(runId, {
|
|
886
|
+
status: 'failed',
|
|
887
|
+
error,
|
|
888
|
+
finishedAt: new Date().toISOString(),
|
|
889
|
+
currentStepId: undefined,
|
|
890
|
+
});
|
|
891
|
+
emit({ type: 'lifecycle', message: `run failed — ${error}` });
|
|
892
|
+
this.dropActive(runId);
|
|
893
|
+
void this.pump();
|
|
894
|
+
return;
|
|
895
|
+
}
|
|
896
|
+
}
|
|
897
|
+
else {
|
|
898
|
+
emit({ type: 'note', message: 'not a git repository — running in place, one task at a time' });
|
|
899
|
+
}
|
|
900
|
+
if (state.cwd === this.repoRoot) {
|
|
901
|
+
emit({
|
|
902
|
+
type: 'note',
|
|
903
|
+
message: 'waiting for exclusive access to the repository working tree',
|
|
904
|
+
});
|
|
905
|
+
// A cancel during the wait leaves the lease ungranted; the step loop
|
|
906
|
+
// below breaks on `cancelled` before touching the tree and settles the
|
|
907
|
+
// run through the usual path.
|
|
908
|
+
await this.acquireRepoRoot(runId, state);
|
|
909
|
+
}
|
|
910
|
+
// Handoff journal (spec 007) — seeded after the worktree exists so the
|
|
911
|
+
// header can name the branch. Idempotent: an existing file stays as-is.
|
|
912
|
+
const seeded = this.store.getRun(runId);
|
|
913
|
+
if (seeded)
|
|
914
|
+
seedHandoffFile(this.dataDir, seeded);
|
|
915
|
+
const skills = await discoverSkills(this.repoRoot);
|
|
916
|
+
const retriesUsed = new Map();
|
|
917
|
+
let checkFailure = null;
|
|
918
|
+
let runError = null;
|
|
919
|
+
// Persist the task's attached images so the thread's initial bubble can render them
|
|
920
|
+
// (#image-display); they still ride the first agent step's opening message below.
|
|
921
|
+
if (input.images?.length) {
|
|
922
|
+
const urls = input.images
|
|
923
|
+
.filter((b) => b.type === 'image')
|
|
924
|
+
.map((b) => this.persistImage(runId, state, b.source.media_type, b.source.data))
|
|
925
|
+
.filter((saved) => saved !== null)
|
|
926
|
+
.map((saved) => saved.url);
|
|
927
|
+
if (urls.length)
|
|
928
|
+
this.store.updateRun(runId, { taskImages: urls });
|
|
929
|
+
}
|
|
930
|
+
// Task screenshots go with the FIRST agent step's opening message only —
|
|
931
|
+
// later steps and retry loops run in fresh sessions without them.
|
|
932
|
+
let startImages = input.images;
|
|
933
|
+
const lastAgentIdx = findLastAgentStepIndex(workflow);
|
|
934
|
+
let i = 0;
|
|
935
|
+
while (i < workflow.steps.length) {
|
|
936
|
+
if (state.cancelled)
|
|
937
|
+
break;
|
|
938
|
+
const step = workflow.steps[i];
|
|
939
|
+
const kind = stepKind(step);
|
|
940
|
+
const record = this.store.getRun(runId)?.steps.find((s) => s.id === step.id);
|
|
941
|
+
const iteration = (record?.iterations ?? 0) + 1;
|
|
942
|
+
this.store.updateRun(runId, { currentStepId: step.id });
|
|
943
|
+
this.store.updateStep(runId, step.id, {
|
|
944
|
+
status: 'running',
|
|
945
|
+
iterations: iteration,
|
|
946
|
+
startedAt: new Date().toISOString(),
|
|
947
|
+
error: undefined,
|
|
948
|
+
});
|
|
949
|
+
emit({ type: 'step-start', stepId: step.id, name: step.name ?? step.id, kind, iteration });
|
|
950
|
+
if (kind === 'agent') {
|
|
951
|
+
// The last agent step of the workflow is interactive: after its turn
|
|
952
|
+
// the session stays open for follow-ups until finish/idle/cancel.
|
|
953
|
+
const interactive = i === lastAgentIdx && i === workflow.steps.length - 1;
|
|
954
|
+
const failure = await this.runAgentStep(runId, state, step, input, skills, checkFailure, interactive, emit, startImages, taskBackend, extraSystemPrompt, chainStepNote(workflow.steps, i));
|
|
955
|
+
startImages = undefined;
|
|
956
|
+
checkFailure = null;
|
|
957
|
+
if (state.cancelled)
|
|
958
|
+
break;
|
|
959
|
+
if (failure) {
|
|
960
|
+
this.finishStep(runId, step.id, 'failed', failure, emit);
|
|
961
|
+
runError = `step "${step.id}" failed: ${failure}`;
|
|
962
|
+
break;
|
|
963
|
+
}
|
|
964
|
+
this.finishStep(runId, step.id, 'done', undefined, emit);
|
|
965
|
+
i++;
|
|
966
|
+
continue;
|
|
967
|
+
}
|
|
968
|
+
const { ok, output } = await this.runCheckStep(state, step, emit);
|
|
969
|
+
if (state.cancelled)
|
|
970
|
+
break;
|
|
971
|
+
if (ok) {
|
|
972
|
+
this.finishStep(runId, step.id, 'done', undefined, emit);
|
|
973
|
+
i++;
|
|
974
|
+
continue;
|
|
975
|
+
}
|
|
976
|
+
const used = retriesUsed.get(step.id) ?? 0;
|
|
977
|
+
if (step.onFail && used < step.onFail.max) {
|
|
978
|
+
retriesUsed.set(step.id, used + 1);
|
|
979
|
+
checkFailure = output;
|
|
980
|
+
this.finishStep(runId, step.id, 'failed', 'check failed — looping back', emit);
|
|
981
|
+
const retryIdx = workflow.steps.findIndex((s) => s.id === step.onFail?.retry);
|
|
982
|
+
emit({
|
|
983
|
+
type: 'note',
|
|
984
|
+
stepId: step.id,
|
|
985
|
+
message: `check failed — retrying from "${step.onFail.retry}" (attempt ${used + 1}/${step.onFail.max})`,
|
|
986
|
+
});
|
|
987
|
+
// Steps we're about to re-run go back to pending so the GUI rail
|
|
988
|
+
// reads top-to-bottom truthfully.
|
|
989
|
+
for (const s of workflow.steps.slice(retryIdx, i + 1)) {
|
|
990
|
+
this.store.updateStep(runId, s.id, { status: 'pending' });
|
|
991
|
+
}
|
|
992
|
+
i = retryIdx;
|
|
993
|
+
continue;
|
|
994
|
+
}
|
|
995
|
+
this.finishStep(runId, step.id, 'failed', `\`${step.command}\` exited non-zero`, emit);
|
|
996
|
+
runError = `check "${step.id}" failed${step.onFail ? ` after ${used + 1} attempts` : ''}`;
|
|
997
|
+
break;
|
|
998
|
+
}
|
|
999
|
+
// Final autosave: the branch always ends holding the finished state.
|
|
1000
|
+
this.clearAutosaveTimer(state);
|
|
1001
|
+
if (state.cwd !== this.repoRoot)
|
|
1002
|
+
await autosaveCommit(state.cwd);
|
|
1003
|
+
const finishedAt = new Date().toISOString();
|
|
1004
|
+
if (state.cancelled) {
|
|
1005
|
+
const run = this.store.getRun(runId);
|
|
1006
|
+
for (const s of run?.steps ?? []) {
|
|
1007
|
+
if (s.status === 'running' || s.status === 'waiting') {
|
|
1008
|
+
this.store.updateStep(runId, s.id, { status: 'cancelled' });
|
|
1009
|
+
}
|
|
1010
|
+
}
|
|
1011
|
+
this.store.updateRun(runId, { status: 'cancelled', finishedAt, currentStepId: undefined });
|
|
1012
|
+
emit({ type: 'lifecycle', message: 'run cancelled' });
|
|
1013
|
+
}
|
|
1014
|
+
else if (runError) {
|
|
1015
|
+
this.store.updateRun(runId, { status: 'failed', error: runError, finishedAt, currentStepId: undefined });
|
|
1016
|
+
emit({ type: 'lifecycle', message: `run failed — ${runError}` });
|
|
1017
|
+
}
|
|
1018
|
+
else {
|
|
1019
|
+
await this.settleSuccess(runId);
|
|
1020
|
+
}
|
|
1021
|
+
this.clearIdleTimer(state);
|
|
1022
|
+
this.dropActive(runId);
|
|
1023
|
+
void this.pump();
|
|
1024
|
+
}
|
|
1025
|
+
/** Returns an error message, or null on success. */
|
|
1026
|
+
async runAgentStep(runId, state, step, input, skills, checkFailure, interactive, emit, images, taskBackend, extraSystemPrompt,
|
|
1027
|
+
/** The chain-boundary note for this step (#410), or undefined when the
|
|
1028
|
+
* workflow has a single agent step and there is no boundary to explain. */
|
|
1029
|
+
chainNote) {
|
|
1030
|
+
let systemPrompt;
|
|
1031
|
+
if (step.skill) {
|
|
1032
|
+
const skill = skills.find((s) => s.name === step.skill);
|
|
1033
|
+
if (skill) {
|
|
1034
|
+
// The body alone often does not identify the selected skill. Keep its
|
|
1035
|
+
// name and catalog description in the normalized runner payload so a
|
|
1036
|
+
// numeric task such as "432" still gives the model enough context to
|
|
1037
|
+
// describe the work — and therefore derive a useful title (#432).
|
|
1038
|
+
systemPrompt = skillSystemPrompt(skill);
|
|
1039
|
+
// Directory team skills (SKILL.md + references/) get materialized
|
|
1040
|
+
// into <cwd>/.claude/skills/<name>/ — the run's worktree when there
|
|
1041
|
+
// is one — so claude sees the companion files on disk; the shared
|
|
1042
|
+
// info/exclude keeps them out of git (and out of autosave commits).
|
|
1043
|
+
if (skill.source === 'team' && skill.team?.dir) {
|
|
1044
|
+
const seeded = await materializeSkillDir(state.cwd, skill).catch(() => false);
|
|
1045
|
+
if (seeded) {
|
|
1046
|
+
emit({
|
|
1047
|
+
type: 'note',
|
|
1048
|
+
stepId: step.id,
|
|
1049
|
+
message: `team skill "${skill.name}" materialized to .claude/skills/${skill.name}/`,
|
|
1050
|
+
});
|
|
1051
|
+
}
|
|
1052
|
+
}
|
|
1053
|
+
}
|
|
1054
|
+
else {
|
|
1055
|
+
emit({
|
|
1056
|
+
type: 'note',
|
|
1057
|
+
stepId: step.id,
|
|
1058
|
+
message: `skill "${step.skill}" not found in .ai/cezar/skills, .ai/skills or the team skills repo — running with the plain prompt`,
|
|
1059
|
+
});
|
|
1060
|
+
}
|
|
1061
|
+
}
|
|
1062
|
+
let userPrompt = applyTemplate(step.prompt ?? '{{task}}', input.task);
|
|
1063
|
+
if (chainNote)
|
|
1064
|
+
userPrompt = `${chainNote}\n\n---\n\n${userPrompt}`;
|
|
1065
|
+
if (checkFailure) {
|
|
1066
|
+
userPrompt += `\n\nA verification command failed after the previous attempt. Fix the cause. Failing output:\n\n${checkFailure}`;
|
|
1067
|
+
}
|
|
1068
|
+
if (images?.length) {
|
|
1069
|
+
emit({
|
|
1070
|
+
type: 'note',
|
|
1071
|
+
stepId: step.id,
|
|
1072
|
+
message: `${images.length} screenshot${images.length > 1 ? 's' : ''} attached to the task`,
|
|
1073
|
+
});
|
|
1074
|
+
}
|
|
1075
|
+
const sessionId = randomUUID();
|
|
1076
|
+
this.store.updateStep(runId, step.id, { sessionId });
|
|
1077
|
+
const stepRecord = this.store.getRun(runId)?.steps.find((s) => s.id === step.id);
|
|
1078
|
+
const startTokens = stepRecord?.tokensUsed ?? 0;
|
|
1079
|
+
let stepCost = stepRecord?.costUsd ?? 0;
|
|
1080
|
+
let turnText = '';
|
|
1081
|
+
const sink = this.makeUiSink(runId, step.id);
|
|
1082
|
+
const onEvent = (event) => {
|
|
1083
|
+
if (event.type === 'image') {
|
|
1084
|
+
const saved = this.persistImage(runId, state, event.mediaType, event.data);
|
|
1085
|
+
if (saved)
|
|
1086
|
+
emit({ type: 'image', stepId: step.id, ...saved });
|
|
1087
|
+
return;
|
|
1088
|
+
}
|
|
1089
|
+
if (event.type === 'text') {
|
|
1090
|
+
turnText += event.text;
|
|
1091
|
+
const text = stripMonitoringMarker(stripDoneMarker(event.text));
|
|
1092
|
+
if (text)
|
|
1093
|
+
emit({ type: 'text', text, stepId: step.id });
|
|
1094
|
+
return;
|
|
1095
|
+
}
|
|
1096
|
+
emit({ ...event, stepId: step.id });
|
|
1097
|
+
if (event.type === 'session') {
|
|
1098
|
+
// Codex/OpenCode mint their own session id — persist it so resume works.
|
|
1099
|
+
this.store.updateStep(runId, step.id, { sessionId: event.sessionId });
|
|
1100
|
+
}
|
|
1101
|
+
if (event.type === 'token-usage') {
|
|
1102
|
+
this.store.updateStep(runId, step.id, { tokensUsed: startTokens + event.tokensUsed });
|
|
1103
|
+
}
|
|
1104
|
+
if (event.type === 'cost') {
|
|
1105
|
+
stepCost += event.usd;
|
|
1106
|
+
this.store.updateStep(runId, step.id, { costUsd: stepCost });
|
|
1107
|
+
}
|
|
1108
|
+
if (event.type === 'turn-end') {
|
|
1109
|
+
// v2 `turn.completed` already flushed the coalescers; the v1 turn
|
|
1110
|
+
// boundary flushes again (idempotent) as a backstop.
|
|
1111
|
+
sink.flushAll();
|
|
1112
|
+
void this.recordTurnEnd(runId, turnText); // titleSummary + diffStat (#389)
|
|
1113
|
+
const sessionOpen = !state.cancelled && state.session?.open;
|
|
1114
|
+
const done = interactive && sessionOpen && DONE_MARKER_RE.test(turnText.trimEnd());
|
|
1115
|
+
const monitoring = interactive && sessionOpen && !done && MONITORING_MARKER_RE.test(turnText.trimEnd());
|
|
1116
|
+
turnText = '';
|
|
1117
|
+
if (done) {
|
|
1118
|
+
// Goal achieved (agent contract, #347): close the session instead
|
|
1119
|
+
// of parking at `waiting` — the run completes and frees its slot.
|
|
1120
|
+
emit({ type: 'lifecycle', message: 'goal achieved — session closed' });
|
|
1121
|
+
appendHandoffHeartbeat(this.dataDir, runId, 'turn complete — goal achieved, session closed');
|
|
1122
|
+
state.session?.end();
|
|
1123
|
+
return;
|
|
1124
|
+
}
|
|
1125
|
+
const waiting = interactive && sessionOpen;
|
|
1126
|
+
if (waiting) {
|
|
1127
|
+
// Turn over, session open. Either the ball is in the user's court
|
|
1128
|
+
// (`waiting`), or the agent declared it is still working on its own
|
|
1129
|
+
// downstream work with `CEZ:MONITORING` — then park as
|
|
1130
|
+
// `running`/`activity:'monitoring'`, a non-attention state, instead of
|
|
1131
|
+
// raising "needs you" (spec 2026-07-18-subagent-monitoring-status, #490).
|
|
1132
|
+
// Lifecycle is identical either way: the run frees its slot and keeps
|
|
1133
|
+
// the idle timer, so even a stalled monitoring run is reclaimed.
|
|
1134
|
+
if (monitoring) {
|
|
1135
|
+
this.store.updateRun(runId, { status: 'running', activity: 'monitoring' });
|
|
1136
|
+
this.store.updateStep(runId, step.id, { status: 'running' });
|
|
1137
|
+
}
|
|
1138
|
+
else {
|
|
1139
|
+
this.store.updateRun(runId, { status: 'waiting', activity: undefined });
|
|
1140
|
+
this.store.updateStep(runId, step.id, { status: 'waiting' });
|
|
1141
|
+
}
|
|
1142
|
+
this.waiting.add(runId);
|
|
1143
|
+
this.armIdleTimer(runId, state);
|
|
1144
|
+
void this.pump(); // the freed slot can start a queued run right away
|
|
1145
|
+
}
|
|
1146
|
+
// Cez's own heartbeat — the handoff stays current even when the
|
|
1147
|
+
// agent forgets to write (spec 007).
|
|
1148
|
+
appendHandoffHeartbeat(this.dataDir, runId, `turn complete — status=${monitoring ? 'monitoring' : waiting ? 'waiting' : 'running'}`);
|
|
1149
|
+
}
|
|
1150
|
+
};
|
|
1151
|
+
const runner = createRunner(step.runner ?? taskBackend);
|
|
1152
|
+
let session;
|
|
1153
|
+
try {
|
|
1154
|
+
session = runner.startSession({
|
|
1155
|
+
// Skill body, then the run's extra prompt (POST override or config
|
|
1156
|
+
// default), then the handoff/todos contract — every agent step.
|
|
1157
|
+
systemPrompt: composeSystemPrompt(systemPrompt, extraSystemPrompt, followupsEnabled() && input.generateFollowups !== false
|
|
1158
|
+
? HANDOFF_INSTRUCTIONS
|
|
1159
|
+
: HANDOFF_ONLY_INSTRUCTIONS),
|
|
1160
|
+
userPrompt,
|
|
1161
|
+
images,
|
|
1162
|
+
cwd: state.cwd,
|
|
1163
|
+
allowedTools: step.allowedTools ?? DEFAULT_ALLOWED_TOOLS,
|
|
1164
|
+
bashAllowlist: step.bashAllowlist,
|
|
1165
|
+
// The handoff file lives outside the worktree — grant access.
|
|
1166
|
+
additionalDirectories: [join(this.dataDir, 'runs')],
|
|
1167
|
+
env: this.agentEnv(runId, followupsEnabled() && input.generateFollowups !== false),
|
|
1168
|
+
model: step.model ?? input.model,
|
|
1169
|
+
sessionId,
|
|
1170
|
+
// Interactive sessions have no wall clock — the idle timer rules.
|
|
1171
|
+
timeoutMs: interactive ? 0 : undefined,
|
|
1172
|
+
}, onEvent, { autoEndAfterFirstTurn: !interactive, onUiEvent: (event) => sink.handle(event) });
|
|
1173
|
+
}
|
|
1174
|
+
catch (err) {
|
|
1175
|
+
return err instanceof Error ? err.message : String(err);
|
|
1176
|
+
}
|
|
1177
|
+
state.session = session;
|
|
1178
|
+
state.currentStepId = step.id;
|
|
1179
|
+
state.interrupt = () => session.interrupt();
|
|
1180
|
+
if (session.pid !== undefined)
|
|
1181
|
+
registerRunProcess(runId, session.pid);
|
|
1182
|
+
try {
|
|
1183
|
+
const result = await session.result;
|
|
1184
|
+
// v2 counterpart of v1's `done` (spec: the mappers leave session-close
|
|
1185
|
+
// events to the RunManager — only it knows how the session settled).
|
|
1186
|
+
sink.sessionEnded(state.cancelled ? 'cancelled' : 'end_turn');
|
|
1187
|
+
this.store.updateStep(runId, step.id, { tokensUsed: startTokens + result.tokensUsed });
|
|
1188
|
+
return null;
|
|
1189
|
+
}
|
|
1190
|
+
catch (err) {
|
|
1191
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
1192
|
+
sink.sessionEnded('error', message); // alongside v1's fatal `error`
|
|
1193
|
+
return message;
|
|
1194
|
+
}
|
|
1195
|
+
finally {
|
|
1196
|
+
this.recordUsagePeaks(runId);
|
|
1197
|
+
this.clearIdleTimer(state);
|
|
1198
|
+
state.session = undefined;
|
|
1199
|
+
state.currentStepId = undefined;
|
|
1200
|
+
state.interrupt = () => undefined;
|
|
1201
|
+
}
|
|
1202
|
+
}
|
|
1203
|
+
/**
|
|
1204
|
+
* Protocol-v2 sink for one agent session (R2 step 2.1): the runner's
|
|
1205
|
+
* `onUiEvent` stream flows through here. Persisted snapshots ride the same
|
|
1206
|
+
* NDJSON file as v1 (the store stamps `seq`/`ts`, `appendEvent` fans them
|
|
1207
|
+
* out live too); coalesced `item.delta` flushes go out live-only via
|
|
1208
|
+
* `emitEphemeral` — raw deltas never hit disk (spec §performance
|
|
1209
|
+
* guardrails). One sink per session: cumulative usage dedup and the
|
|
1210
|
+
* item-shape cache are session-scoped, like the mapper state feeding them.
|
|
1211
|
+
*/
|
|
1212
|
+
makeUiSink(runId, stepId) {
|
|
1213
|
+
return new UiEventSink({
|
|
1214
|
+
persist: (event) => this.store.appendEvent(runId, { ...event, stepId }),
|
|
1215
|
+
emitLive: (event) => this.store.emitEphemeral(runId, { ...event, stepId }),
|
|
1216
|
+
});
|
|
1217
|
+
}
|
|
1218
|
+
/**
|
|
1219
|
+
* Turn-end bookkeeping (#389), shared by `runAgentStep` and
|
|
1220
|
+
* `runContinuation` — called (fire-and-forget) from every `turn-end` event:
|
|
1221
|
+
*
|
|
1222
|
+
* - `titleSummary`: derived from the turn's text, set ONCE — only while the
|
|
1223
|
+
* record has none. A user's inline edit also lands in `titleSummary`
|
|
1224
|
+
* (see `PATCH /api/runs/:id`), so an edit is never overwritten either.
|
|
1225
|
+
* - `diffStat`: cheap `git diff --shortstat` vs the base, refreshed every
|
|
1226
|
+
* turn. Async and best-effort — a git failure becomes at most a `note`
|
|
1227
|
+
* event, NEVER a run failure. `updateRun` fans the record out over SSE,
|
|
1228
|
+
* so the list views pick both up with no extra wiring.
|
|
1229
|
+
*
|
|
1230
|
+
* Not `private` so the integration tests can drive a turn-end directly —
|
|
1231
|
+
* a real agent session is the only other way to reach this path.
|
|
1232
|
+
*/
|
|
1233
|
+
/**
|
|
1234
|
+
* The namer's apply path (task auto-naming spec). Fire-and-forget: called
|
|
1235
|
+
* without await from `startRun` (creation) and `recordTurnEnd` (live
|
|
1236
|
+
* refresh). A user-owned title (`titleOrigin: 'user'`) is never overwritten;
|
|
1237
|
+
* namer-owned titles may be replaced by fresher namer results.
|
|
1238
|
+
*/
|
|
1239
|
+
async autoNameRun(runId, skillName, task, live) {
|
|
1240
|
+
// CEZ_AUTONAME=0 kills all LLM naming; dry-run skips it too unless
|
|
1241
|
+
// CEZ_AUTONAME=1 forces the mock path — see autoNamingActive.
|
|
1242
|
+
if (!autoNamingActive())
|
|
1243
|
+
return;
|
|
1244
|
+
try {
|
|
1245
|
+
let skillDescription;
|
|
1246
|
+
if (skillName) {
|
|
1247
|
+
const skills = await discoverSkills(this.repoRoot).catch(() => []);
|
|
1248
|
+
skillDescription = skills.find((s) => s.name === skillName)?.description;
|
|
1249
|
+
}
|
|
1250
|
+
const result = await generateRunName(this.repoRoot, { task, skillName, skillDescription, ...live });
|
|
1251
|
+
if (!result)
|
|
1252
|
+
return;
|
|
1253
|
+
const run = this.store.getRun(runId);
|
|
1254
|
+
if (!run || run.titleOrigin === 'user')
|
|
1255
|
+
return;
|
|
1256
|
+
this.store.updateRun(runId, {
|
|
1257
|
+
titleSummary: result.titleSummary,
|
|
1258
|
+
titleOrigin: 'auto',
|
|
1259
|
+
...(result.prNumber !== undefined ? { prNumber: result.prNumber } : {}),
|
|
1260
|
+
...(result.issueNumber !== undefined ? { issueNumber: result.issueNumber } : {}),
|
|
1261
|
+
});
|
|
1262
|
+
}
|
|
1263
|
+
catch {
|
|
1264
|
+
// Naming is best-effort — nothing here may disturb the run.
|
|
1265
|
+
}
|
|
1266
|
+
}
|
|
1267
|
+
async recordTurnEnd(runId, turnText) {
|
|
1268
|
+
try {
|
|
1269
|
+
const run = this.store.getRun(runId);
|
|
1270
|
+
if (!run)
|
|
1271
|
+
return;
|
|
1272
|
+
// Titles are the namer's job (task auto-naming spec) — turn text is
|
|
1273
|
+
// deliberately NEVER a title source; see maybeRefreshTitle below.
|
|
1274
|
+
if (run.worktreePath && existsSync(run.worktreePath)) {
|
|
1275
|
+
const stat = await worktreeShortstat(run.worktreePath, run.baseBranch ?? 'HEAD');
|
|
1276
|
+
if (stat)
|
|
1277
|
+
this.store.updateRun(runId, { diffStat: stat });
|
|
1278
|
+
else
|
|
1279
|
+
this.store.appendEvent(runId, { type: 'note', message: 'diff stat unavailable — git diff --shortstat failed in the worktree' });
|
|
1280
|
+
}
|
|
1281
|
+
await this.maybeRefreshTitle(runId, turnText);
|
|
1282
|
+
}
|
|
1283
|
+
catch {
|
|
1284
|
+
// Bookkeeping only — nothing here may disturb the run.
|
|
1285
|
+
}
|
|
1286
|
+
}
|
|
1287
|
+
/**
|
|
1288
|
+
* Live title refresh (task auto-naming spec, step 3): re-run the namer with
|
|
1289
|
+
* the turn's context. Skips: toggle off (`liveTitleUpdates` config over
|
|
1290
|
+
* `CEZ_TITLE_UPDATES` env, default ON), user-owned title, dry-run mocks
|
|
1291
|
+
* (canned answers add nothing), empty turn text, unchanged namer inputs.
|
|
1292
|
+
*/
|
|
1293
|
+
async maybeRefreshTitle(runId, turnText) {
|
|
1294
|
+
if (!autoNamingActive())
|
|
1295
|
+
return;
|
|
1296
|
+
if (!turnText.trim())
|
|
1297
|
+
return;
|
|
1298
|
+
const config = await loadConfig(this.repoRoot);
|
|
1299
|
+
if (!liveTitleUpdatesEnabled(config))
|
|
1300
|
+
return;
|
|
1301
|
+
const run = this.store.getRun(runId);
|
|
1302
|
+
if (!run || run.titleOrigin === 'user')
|
|
1303
|
+
return;
|
|
1304
|
+
const statText = run.diffStat ? `${run.diffStat.files} files, +${run.diffStat.adds} -${run.diffStat.dels}` : undefined;
|
|
1305
|
+
const key = `${turnText.slice(0, 200)}|${statText ?? ''}`;
|
|
1306
|
+
if (this.lastNamerKey.get(runId) === key)
|
|
1307
|
+
return;
|
|
1308
|
+
this.lastNamerKey.set(runId, key);
|
|
1309
|
+
const workflow = await this.reviveWorkflow(run);
|
|
1310
|
+
const skillName = workflow?.steps.find((s) => stepKind(s) === 'agent' && s.skill)?.skill?.trim();
|
|
1311
|
+
void this.autoNameRun(runId, skillName, run.task, { turnText, diffStat: statText });
|
|
1312
|
+
}
|
|
1313
|
+
/**
|
|
1314
|
+
* End-of-session telemetry (#348): stop sampling the run's process tree and
|
|
1315
|
+
* fold the session's peaks into the run record. `max` with existing values —
|
|
1316
|
+
* a run can hold several sessions (multiple agent steps, Continue) and the
|
|
1317
|
+
* record keeps the highest water mark across all of them.
|
|
1318
|
+
*/
|
|
1319
|
+
recordUsagePeaks(runId) {
|
|
1320
|
+
const peaks = unregisterRunProcess(runId);
|
|
1321
|
+
if (!peaks)
|
|
1322
|
+
return;
|
|
1323
|
+
const run = this.store.getRun(runId);
|
|
1324
|
+
this.store.updateRun(runId, {
|
|
1325
|
+
peakRssBytes: Math.max(run?.peakRssBytes ?? 0, peaks.peakRssBytes),
|
|
1326
|
+
peakProcCount: Math.max(run?.peakProcCount ?? 0, peaks.peakProcCount),
|
|
1327
|
+
});
|
|
1328
|
+
}
|
|
1329
|
+
/**
|
|
1330
|
+
* Diff-first review gate (spec 009), shared by `execute` and
|
|
1331
|
+
* `runContinuation`: a *successful* run whose worktree holds changes rests
|
|
1332
|
+
* at `review` instead of `done` — the user inspects the diff first, then
|
|
1333
|
+
* sends feedback back, opens a draft PR, or just finishes. Failed/cancelled
|
|
1334
|
+
* runs never enter review; no worktree or an empty diff means plain `done`.
|
|
1335
|
+
*
|
|
1336
|
+
* The gate is opt-in (#489): the review park happens only when it is enabled
|
|
1337
|
+
* (`reviewGateEnabled` — config toggle over the `CEZ_REVIEW_GATE` env, default
|
|
1338
|
+
* OFF) AND the run is not autonomous. Autonomous runs — and runs with the gate
|
|
1339
|
+
* off — settle straight to `done`, leaving the diff in the worktree untouched.
|
|
1340
|
+
*/
|
|
1341
|
+
async settleSuccess(runId) {
|
|
1342
|
+
const run = this.store.getRun(runId);
|
|
1343
|
+
let review = false;
|
|
1344
|
+
if (run?.worktreePath && existsSync(run.worktreePath)) {
|
|
1345
|
+
const diff = await worktreeDiff(run.worktreePath, run.baseBranch ?? 'HEAD');
|
|
1346
|
+
const hasDiff = diff.trim().length > 0 && !diff.startsWith('(diff failed');
|
|
1347
|
+
const config = await loadConfig(this.repoRoot);
|
|
1348
|
+
review = hasDiff && reviewGateEnabled(config) && run.autonomous !== true;
|
|
1349
|
+
}
|
|
1350
|
+
this.store.updateRun(runId, {
|
|
1351
|
+
status: review ? 'review' : 'done',
|
|
1352
|
+
finishedAt: new Date().toISOString(),
|
|
1353
|
+
currentStepId: undefined,
|
|
1354
|
+
});
|
|
1355
|
+
this.store.appendEvent(runId, {
|
|
1356
|
+
type: 'lifecycle',
|
|
1357
|
+
message: review
|
|
1358
|
+
? 'changes ready for review — send feedback, open a draft PR, or finish'
|
|
1359
|
+
: 'run finished',
|
|
1360
|
+
});
|
|
1361
|
+
}
|
|
1362
|
+
/**
|
|
1363
|
+
* Agent screenshot (an image block inside a tool result): the base64 data
|
|
1364
|
+
* never enters the NDJSON event log — it lands as a file under
|
|
1365
|
+
* `.ai/cezar/runs/<id>-images/` and the transcript event carries only the
|
|
1366
|
+
* name + serving URL. Best effort: on failure the screenshot is dropped,
|
|
1367
|
+
* the transcript still shows the tool result's `[screenshot]` placeholder.
|
|
1368
|
+
*/
|
|
1369
|
+
persistImage(runId, state, mediaType, data) {
|
|
1370
|
+
try {
|
|
1371
|
+
const ext = /png/.test(mediaType) ? 'png'
|
|
1372
|
+
: /jpe?g/.test(mediaType) ? 'jpg'
|
|
1373
|
+
: /webp/.test(mediaType) ? 'webp'
|
|
1374
|
+
: /gif/.test(mediaType) ? 'gif'
|
|
1375
|
+
: 'img';
|
|
1376
|
+
state.imageSeq = (state.imageSeq ?? 0) + 1;
|
|
1377
|
+
const name = `screenshot-${state.imageSeq}.${ext}`;
|
|
1378
|
+
const dir = join(this.dataDir, 'runs', `${runId}-images`);
|
|
1379
|
+
mkdirSync(dir, { recursive: true });
|
|
1380
|
+
writeFileSync(join(dir, name), Buffer.from(data, 'base64'));
|
|
1381
|
+
return { name, url: `/api/runs/${runId}/images/${name}` };
|
|
1382
|
+
}
|
|
1383
|
+
catch {
|
|
1384
|
+
return null;
|
|
1385
|
+
}
|
|
1386
|
+
}
|
|
1387
|
+
armIdleTimer(runId, state) {
|
|
1388
|
+
this.clearIdleTimer(state);
|
|
1389
|
+
state.idleTimer = setTimeout(() => {
|
|
1390
|
+
if (state.session?.open && !state.cancelled) {
|
|
1391
|
+
this.store.appendEvent(runId, {
|
|
1392
|
+
type: 'lifecycle',
|
|
1393
|
+
message: `session closed after ${Math.round(IDLE_TIMEOUT_MS / 60_000)}m of inactivity`,
|
|
1394
|
+
});
|
|
1395
|
+
state.session.end();
|
|
1396
|
+
}
|
|
1397
|
+
}, IDLE_TIMEOUT_MS);
|
|
1398
|
+
state.idleTimer.unref?.();
|
|
1399
|
+
}
|
|
1400
|
+
clearIdleTimer(state) {
|
|
1401
|
+
if (state.idleTimer) {
|
|
1402
|
+
clearTimeout(state.idleTimer);
|
|
1403
|
+
state.idleTimer = undefined;
|
|
1404
|
+
}
|
|
1405
|
+
}
|
|
1406
|
+
/** Autosave-commit the worktree every 90 s while the run lives (spec 006).
|
|
1407
|
+
* Opt-in via CEZ_AUTOSAVE=1 (#471) — see periodicAutosaveEnabled. */
|
|
1408
|
+
armAutosave(state) {
|
|
1409
|
+
if (!periodicAutosaveEnabled())
|
|
1410
|
+
return;
|
|
1411
|
+
if (state.cwd === this.repoRoot || state.autosaveTimer)
|
|
1412
|
+
return;
|
|
1413
|
+
state.autosaveTimer = setInterval(() => {
|
|
1414
|
+
void autosaveCommit(state.cwd);
|
|
1415
|
+
}, AUTOSAVE_INTERVAL_MS);
|
|
1416
|
+
state.autosaveTimer.unref?.();
|
|
1417
|
+
}
|
|
1418
|
+
clearAutosaveTimer(state) {
|
|
1419
|
+
if (state.autosaveTimer) {
|
|
1420
|
+
clearInterval(state.autosaveTimer);
|
|
1421
|
+
state.autosaveTimer = undefined;
|
|
1422
|
+
}
|
|
1423
|
+
}
|
|
1424
|
+
runCheckStep(state, step, emit) {
|
|
1425
|
+
const command = step.command;
|
|
1426
|
+
emit({ type: 'note', stepId: step.id, message: `$ ${command}` });
|
|
1427
|
+
return new Promise((resolve) => {
|
|
1428
|
+
// Check steps run in the same cwd as the agent steps — the worktree.
|
|
1429
|
+
const child = spawn('bash', ['-lc', command], { cwd: state.cwd, env: process.env });
|
|
1430
|
+
state.interrupt = () => child.kill('SIGTERM');
|
|
1431
|
+
let output = '';
|
|
1432
|
+
const collect = (chunk) => {
|
|
1433
|
+
if (output.length < CHECK_OUTPUT_CAP) {
|
|
1434
|
+
output += chunk.toString('utf8');
|
|
1435
|
+
if (output.length >= CHECK_OUTPUT_CAP)
|
|
1436
|
+
output += '\n… (output truncated)';
|
|
1437
|
+
}
|
|
1438
|
+
};
|
|
1439
|
+
child.stdout.on('data', collect);
|
|
1440
|
+
child.stderr.on('data', collect);
|
|
1441
|
+
child.on('error', (err) => {
|
|
1442
|
+
state.interrupt = () => undefined;
|
|
1443
|
+
const message = `failed to spawn: ${err.message}`;
|
|
1444
|
+
emit({ type: 'check-output', stepId: step.id, command, text: message, exitCode: -1 });
|
|
1445
|
+
resolve({ ok: false, output: message });
|
|
1446
|
+
});
|
|
1447
|
+
child.on('close', (code) => {
|
|
1448
|
+
state.interrupt = () => undefined;
|
|
1449
|
+
const trimmed = output.trim() || '(no output)';
|
|
1450
|
+
emit({ type: 'check-output', stepId: step.id, command, text: trimmed, exitCode: code ?? -1 });
|
|
1451
|
+
resolve({ ok: code === 0, output: trimmed });
|
|
1452
|
+
});
|
|
1453
|
+
});
|
|
1454
|
+
}
|
|
1455
|
+
finishStep(runId, stepId, status, error, emit) {
|
|
1456
|
+
this.store.updateStep(runId, stepId, {
|
|
1457
|
+
status,
|
|
1458
|
+
error,
|
|
1459
|
+
finishedAt: new Date().toISOString(),
|
|
1460
|
+
});
|
|
1461
|
+
emit({ type: 'step-end', stepId, status, ...(error ? { error } : {}) });
|
|
1462
|
+
appendHandoffHeartbeat(this.dataDir, runId, `step "${stepId}" complete — status=${status}`);
|
|
1463
|
+
}
|
|
1464
|
+
}
|
|
1465
|
+
function findLastAgentStepIndex(workflow) {
|
|
1466
|
+
for (let i = workflow.steps.length - 1; i >= 0; i--) {
|
|
1467
|
+
const step = workflow.steps[i];
|
|
1468
|
+
if (step && stepKind(step) === 'agent')
|
|
1469
|
+
return i;
|
|
1470
|
+
}
|
|
1471
|
+
return -1;
|
|
1472
|
+
}
|
|
1473
|
+
function applyTemplate(template, task) {
|
|
1474
|
+
return template.replaceAll('{{task}}', task);
|
|
1475
|
+
}
|
|
1476
|
+
/**
|
|
1477
|
+
* Immediate title shown while a run is queued. The namer's `titleSummary`
|
|
1478
|
+
* replaces it once the model answers; this is the honest, permanent fallback
|
|
1479
|
+
* when no model is available (#432, spec 2026-07-17-task-auto-naming). When
|
|
1480
|
+
* the task references a PR/issue, the number leads: `469: /om-auto-review-pr`.
|
|
1481
|
+
*/
|
|
1482
|
+
export function makeRunTitle(task, workflow) {
|
|
1483
|
+
const firstLine = task.trim().split('\n')[0] ?? '';
|
|
1484
|
+
const skill = workflow.steps.find((step) => stepKind(step) === 'agent' && step.skill)?.skill?.trim();
|
|
1485
|
+
const contextual = skill && !firstLine.startsWith(`/${skill}`)
|
|
1486
|
+
? `/${skill}${firstLine ? ` ${firstLine}` : ''}`
|
|
1487
|
+
: firstLine;
|
|
1488
|
+
const refNumber = titleRefNumber(refineTaskRefs(extractTaskRefs(task), skill));
|
|
1489
|
+
// `469` or `/om-auto-review-pr 469` reads as `469: /om-auto-review-pr` — the
|
|
1490
|
+
// number leads so it survives the tasks table's narrow truncation.
|
|
1491
|
+
const skillArg = skill && contextual.startsWith(`/${skill}`) ? contextual.slice(skill.length + 1).trim() : null;
|
|
1492
|
+
const body = refNumber !== undefined && skill && (skillArg === '' || /^#?\d+$/.test(skillArg ?? ''))
|
|
1493
|
+
? `/${skill}`
|
|
1494
|
+
: contextual;
|
|
1495
|
+
const prefixed = refNumber !== undefined && !body.trimStart().replace(/^#/, '').startsWith(String(refNumber))
|
|
1496
|
+
? `${refNumber}: ${body}`
|
|
1497
|
+
: body;
|
|
1498
|
+
const chars = [...(prefixed || '(untitled task)')];
|
|
1499
|
+
return chars.length > 80 ? `${chars.slice(0, 79).join('').trimEnd()}…` : chars.join('');
|
|
1500
|
+
}
|
|
1501
|
+
/** Skill identity is context, while the Markdown body remains instructions. */
|
|
1502
|
+
export function skillSystemPrompt(skill) {
|
|
1503
|
+
return [
|
|
1504
|
+
`Selected skill: /${skill.name}`,
|
|
1505
|
+
...(skill.description ? [`Description: ${skill.description}`] : []),
|
|
1506
|
+
'',
|
|
1507
|
+
'Skill instructions:',
|
|
1508
|
+
skill.body.trim(),
|
|
1509
|
+
].join('\n');
|
|
1510
|
+
}
|
|
1511
|
+
//# sourceMappingURL=run.js.map
|