@pat-lewczuk/cezar 0.1.4 → 0.1.5
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 +124 -37
- package/dist/config.d.ts +46 -0
- package/dist/config.js +31 -0
- package/dist/config.js.map +1 -1
- package/dist/core/agent-runner.d.ts +16 -0
- package/dist/core/agent-runner.js +9 -0
- package/dist/core/agent-runner.js.map +1 -1
- package/dist/core/claude-cli-runner.d.ts +6 -0
- package/dist/core/claude-cli-runner.js +27 -43
- package/dist/core/claude-cli-runner.js.map +1 -1
- package/dist/core/codex-app-server-runner.js +42 -5
- package/dist/core/codex-app-server-runner.js.map +1 -1
- package/dist/core/opencode-server-runner.js +42 -5
- package/dist/core/opencode-server-runner.js.map +1 -1
- package/dist/git-worktree.d.ts +21 -0
- package/dist/git-worktree.js +48 -4
- package/dist/git-worktree.js.map +1 -1
- package/dist/handoff.d.ts +1 -1
- package/dist/handoff.js +2 -0
- package/dist/handoff.js.map +1 -1
- package/dist/index.js +34 -4
- package/dist/index.js.map +1 -1
- package/dist/runs/store.d.ts +82 -1
- package/dist/runs/store.js +57 -5
- package/dist/runs/store.js.map +1 -1
- package/dist/server/git.js +12 -1
- package/dist/server/git.js.map +1 -1
- package/dist/server/github.d.ts +8 -29
- package/dist/server/github.js +7 -139
- package/dist/server/github.js.map +1 -1
- package/dist/server/pr.d.ts +5 -20
- package/dist/server/pr.js +4 -95
- package/dist/server/pr.js.map +1 -1
- package/dist/server/server.d.ts +30 -1
- package/dist/server/server.js +448 -19
- package/dist/server/server.js.map +1 -1
- package/dist/workflows/run.d.ts +90 -0
- package/dist/workflows/run.js +412 -29
- package/dist/workflows/run.js.map +1 -1
- package/dist/workflows/types.d.ts +16 -16
- package/package.json +44 -6
- package/scripts/check-pack.mjs +46 -0
- package/scripts/dev.mjs +74 -0
- package/scripts/mock-claude.mjs +54 -2
- package/scripts/test-process-usage.mjs +47 -0
- package/web/dist/assets/{toaster-BS2E1bhz.js → centered-state-zgEVNWsj.js} +1 -1
- package/web/dist/assets/compare-variants-ChHCcSQX.js +1 -0
- package/web/dist/assets/dialog-DDRjj-uK.js +1 -0
- package/web/dist/assets/{diff-view-CctnJHWe.js → diff-view-CWRO0GMe.js} +1 -1
- package/web/dist/assets/dist-id2CeyV-.js +1 -0
- package/web/dist/assets/git-toolbar-C4jo602L.js +1 -0
- package/web/dist/assets/github-BOIzQFwE.js +1 -0
- package/web/dist/assets/index-Cv1pQs9U.js +11 -0
- package/web/dist/assets/repo-git-BuyJiqna.js +1 -0
- package/web/dist/assets/{run-diff-C7mfxj7f.js → run-diff-BocG6LVd.js} +1 -1
- package/web/dist/assets/run-header-CDKw22ek.js +1 -0
- package/web/dist/assets/skill-detail-CcFFKmGO.js +1 -0
- package/web/dist/assets/skills-Bc_4U3yq.js +1 -0
- package/web/dist/assets/tab-link-C77EP3i6.js +1 -0
- package/web/dist/assets/task-changes-DsDgN8-x.js +1 -0
- package/web/dist/assets/task-commits-Dve6Fozc.js +1 -0
- package/web/dist/assets/{task-files-FOAAz2FB.js → task-files-BDMc_z0B.js} +1 -1
- package/web/dist/assets/{task-thread-Bncxe8Os.js → task-thread-CmRuPj9H.js} +2 -2
- package/web/dist/assets/use-desktop-CyEWsxSU.js +3 -0
- package/web/dist/assets/{workflows-Dg2wQg6i.js → workflows-J3z3kYic.js} +1 -1
- package/web/dist/index.html +5 -5
- package/web/app/node_modules/.vite/vitest/ca84d1343b96baa8137c943ed1860e522cacb238/results.json +0 -1
- package/web/app/src/components/app-shell-container.tsx +0 -76
- package/web/app.js +0 -3428
- package/web/dist/assets/compare-variants-D6fPL9cS.js +0 -1
- package/web/dist/assets/dialog-BEgEWFDS.js +0 -1
- package/web/dist/assets/dist-CW7M_Jhs.js +0 -1
- package/web/dist/assets/git-toolbar-D8OPPqBP.js +0 -1
- package/web/dist/assets/github-BYIHwIJB.js +0 -1
- package/web/dist/assets/index-9qG98Orw.js +0 -11
- package/web/dist/assets/repo-git-XTqlVOl2.js +0 -1
- package/web/dist/assets/run-header-CfVeY3uM.js +0 -1
- package/web/dist/assets/skill-detail-C9cniyId.js +0 -1
- package/web/dist/assets/skills-DXgY1uXJ.js +0 -1
- package/web/dist/assets/tab-link-phWEqSBy.js +0 -1
- package/web/dist/assets/task-changes-DYwnPnTN.js +0 -1
- package/web/dist/assets/task-commits-DZzxEzbn.js +0 -1
- package/web/dist/assets/use-desktop-eHm2iTXX.js +0 -3
- package/web/index.html +0 -108
- package/web/style.css +0 -1767
package/dist/workflows/run.js
CHANGED
|
@@ -2,20 +2,65 @@ import { randomUUID } from 'node:crypto';
|
|
|
2
2
|
import { spawn } from 'node:child_process';
|
|
3
3
|
import { existsSync, mkdirSync, writeFileSync } from 'node:fs';
|
|
4
4
|
import { join } from 'node:path';
|
|
5
|
+
import { onUsage, registerRunProcess, unregisterRunProcess } from '../core/process-usage.js';
|
|
5
6
|
import { createRunner } from '../core/runner-factory.js';
|
|
6
7
|
import { HANDOFF_INSTRUCTIONS, appendHandoffHeartbeat, handoffPath, seedHandoffFile, } from '../handoff.js';
|
|
7
8
|
import { todosPath } from '../todos.js';
|
|
8
9
|
import { discoverSkills } from '../skills.js';
|
|
9
10
|
import { materializeSkillDir } from '../skills-remote.js';
|
|
10
11
|
import { loadConfig } from '../config.js';
|
|
11
|
-
import { autosaveCommit, createWorktree, resolveBaseRef, worktreeDiff } from '../git-worktree.js';
|
|
12
|
+
import { autosaveCommit, createWorktree, resolveBaseRef, worktreeDiff, worktreeShortstat } from '../git-worktree.js';
|
|
12
13
|
import { getRepoInfo } from '../server/git.js';
|
|
14
|
+
import { loadWorkflows } from './load.js';
|
|
15
|
+
import { deriveTitleSummary } from '../runs/title-summary.js';
|
|
16
|
+
import { UiEventSink } from '../runs/ui-event-sink.js';
|
|
13
17
|
import { DEFAULT_ALLOWED_TOOLS, stepKind } from './types.js';
|
|
14
18
|
const CHECK_OUTPUT_CAP = 20_000;
|
|
15
19
|
/** An interactive session that hears nothing from the user closes itself. */
|
|
16
20
|
export const IDLE_TIMEOUT_MS = 15 * 60_000;
|
|
21
|
+
/**
|
|
22
|
+
* Task-completion marker from the agent contract (HANDOFF_INSTRUCTIONS): a
|
|
23
|
+
* turn whose text ends with `CEZ:DONE` means "goal achieved, nothing to ask" —
|
|
24
|
+
* the session is closed right away instead of parking at `waiting` (#347).
|
|
25
|
+
* Detection runs on the accumulated turn text so delta-streaming backends
|
|
26
|
+
* (codex, opencode) can't split the marker across text events.
|
|
27
|
+
*/
|
|
28
|
+
const DONE_MARKER_RE = /CEZ:DONE\s*$/;
|
|
29
|
+
/** Strip a trailing marker from one text event so transcripts stay free of
|
|
30
|
+
* protocol noise. Delta backends may split the marker across events — then
|
|
31
|
+
* it stays visible; detection above is unaffected. */
|
|
32
|
+
function stripDoneMarker(text) {
|
|
33
|
+
return text.replace(/\s*CEZ:DONE\s*$/, '');
|
|
34
|
+
}
|
|
17
35
|
/** Periodic "cezar autosave" commit in the task worktree (spec 006). */
|
|
18
36
|
export const AUTOSAVE_INTERVAL_MS = 90_000;
|
|
37
|
+
/** Safety cap on autonomous auto-continues per run — stops a stuck agent from nudging forever. */
|
|
38
|
+
const MAX_AUTO_CONTINUES = 40;
|
|
39
|
+
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.';
|
|
40
|
+
/**
|
|
41
|
+
* The effective "extra" system prompt for a run (spec §protocol v2, R2 2.3):
|
|
42
|
+
* the per-run override (`POST /api/runs` `systemPrompt`) REPLACES the
|
|
43
|
+
* `config.json` default — they are the same knob at two scopes, so the more
|
|
44
|
+
* specific one wins outright; they never concatenate. Whichever wins is
|
|
45
|
+
* ADDITIVE to the skill body and the handoff contract, which always ride
|
|
46
|
+
* along (see `composeSystemPrompt`). Blank strings count as unset.
|
|
47
|
+
*/
|
|
48
|
+
export function resolveExtraSystemPrompt(override, configDefault) {
|
|
49
|
+
return override?.trim() || configDefault?.trim() || undefined;
|
|
50
|
+
}
|
|
51
|
+
/**
|
|
52
|
+
* Joins the parts of one agent step's system prompt in fixed order — skill
|
|
53
|
+
* body (most task-specific), then the run's extra prompt (user guidance, can
|
|
54
|
+
* amend the skill), then the handoff contract (always last, never optional in
|
|
55
|
+
* practice). Blank parts drop out; survivors join with the same `\n\n---\n\n`
|
|
56
|
+
* divider the skill+handoff composition has always used.
|
|
57
|
+
*/
|
|
58
|
+
export function composeSystemPrompt(...parts) {
|
|
59
|
+
return parts
|
|
60
|
+
.map((p) => p?.trim())
|
|
61
|
+
.filter((p) => Boolean(p))
|
|
62
|
+
.join('\n\n---\n\n');
|
|
63
|
+
}
|
|
19
64
|
/** Variant letters + the fixed diversification hints (spec 010). A runs the
|
|
20
65
|
* task verbatim; B/C get one constant sentence each — zero configuration. */
|
|
21
66
|
export const VARIANT_LETTERS = ['A', 'B', 'C'];
|
|
@@ -43,14 +88,66 @@ export class RunManager {
|
|
|
43
88
|
// registering in `active`, so parallel-slot counting is never racy.
|
|
44
89
|
queue = [];
|
|
45
90
|
starting = new Set();
|
|
91
|
+
// Runs parked at `waiting` (open session, ball in the user's court). They
|
|
92
|
+
// don't consume a `maxParallel` slot (#347) — an idle claude process costs
|
|
93
|
+
// memory but no tokens, queued work progressing matters more, and the idle
|
|
94
|
+
// timeout already bounds how long a session can sit open. Invariant:
|
|
95
|
+
// `waiting ⊆ active` — always cleared together via dropActive().
|
|
96
|
+
waiting = new Set();
|
|
46
97
|
pendingJobs = new Map();
|
|
47
98
|
pumping = false;
|
|
48
99
|
/** `.ai/cezar` — where the per-task handoff files and todos.json live. */
|
|
49
100
|
dataDir;
|
|
101
|
+
/** Runs currently being paused by the memory guard — dedupes the ~2 s samples so one breach
|
|
102
|
+
* triggers one pause, not a burst. Cleared in dropActive when the run leaves the registry. */
|
|
103
|
+
memoryPausing = new Set();
|
|
50
104
|
constructor(store, repoRoot) {
|
|
51
105
|
this.store = store;
|
|
52
106
|
this.repoRoot = repoRoot;
|
|
53
107
|
this.dataDir = join(repoRoot, '.ai/cezar');
|
|
108
|
+
// Memory guard (#memory-guard): the shared process-tree sampler already ticks ~every 2 s for
|
|
109
|
+
// the runs table; piggyback on it to enforce the per-task memory ceiling.
|
|
110
|
+
onUsage((snapshot) => void this.enforceMemoryLimit(snapshot));
|
|
111
|
+
}
|
|
112
|
+
/**
|
|
113
|
+
* Pause any active run whose whole process tree exceeds `config.memoryLimitMb`, freeing its
|
|
114
|
+
* slot so the queue advances (#memory-guard). "Pause" closes the session — freeing the tree's
|
|
115
|
+
* memory — and leaves the run resumable via Continue; a loud warning explains why. No-op when
|
|
116
|
+
* no limit is set or the sampler has no data (e.g. `ps`/PowerShell unavailable).
|
|
117
|
+
*/
|
|
118
|
+
async enforceMemoryLimit(snapshot) {
|
|
119
|
+
const runIds = Object.keys(snapshot);
|
|
120
|
+
if (runIds.length === 0)
|
|
121
|
+
return;
|
|
122
|
+
const limitMb = (await loadConfig(this.repoRoot)).memoryLimitMb;
|
|
123
|
+
if (!limitMb || limitMb <= 0)
|
|
124
|
+
return;
|
|
125
|
+
const limitBytes = limitMb * 1024 * 1024;
|
|
126
|
+
for (const runId of runIds) {
|
|
127
|
+
const usage = snapshot[runId];
|
|
128
|
+
if (!usage || usage.rssBytes <= limitBytes)
|
|
129
|
+
continue;
|
|
130
|
+
if (this.memoryPausing.has(runId))
|
|
131
|
+
continue;
|
|
132
|
+
const state = this.active.get(runId);
|
|
133
|
+
if (!state?.session?.open || state.cancelled)
|
|
134
|
+
continue;
|
|
135
|
+
this.memoryPausing.add(runId);
|
|
136
|
+
const usedMb = Math.round(usage.rssBytes / (1024 * 1024));
|
|
137
|
+
this.store.appendEvent(runId, {
|
|
138
|
+
type: 'note',
|
|
139
|
+
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.`,
|
|
140
|
+
});
|
|
141
|
+
this.store.appendEvent(runId, {
|
|
142
|
+
type: 'lifecycle',
|
|
143
|
+
message: `paused — memory limit exceeded (${usedMb} MiB > ${limitMb} MiB)`,
|
|
144
|
+
});
|
|
145
|
+
// Closing the session frees the tree and lets the normal exit path settle the run and
|
|
146
|
+
// pump the queue. Suppress autonomous auto-continue so the pause actually holds.
|
|
147
|
+
state.autonomous = false;
|
|
148
|
+
this.clearIdleTimer(state);
|
|
149
|
+
state.session.end();
|
|
150
|
+
}
|
|
54
151
|
}
|
|
55
152
|
/** Env the spawned claude gets so the agent can find its handoff file and
|
|
56
153
|
* the global inbox (spec 007). */
|
|
@@ -72,6 +169,9 @@ export class RunManager {
|
|
|
72
169
|
variant: group?.variant,
|
|
73
170
|
steps: workflow.steps.map((s) => ({ id: s.id, name: s.name ?? s.id, kind: stepKind(s) })),
|
|
74
171
|
});
|
|
172
|
+
// Persist the full definition so a queued run survives a restart (#367) —
|
|
173
|
+
// ad-hoc "(planned)" chains exist nowhere else to re-resolve from.
|
|
174
|
+
this.store.updateRun(run.id, { workflowDef: workflow });
|
|
75
175
|
this.pendingJobs.set(run.id, { workflow, input });
|
|
76
176
|
this.queue.push(run.id);
|
|
77
177
|
void this.pump();
|
|
@@ -104,7 +204,11 @@ export class RunManager {
|
|
|
104
204
|
try {
|
|
105
205
|
const repo = await getRepoInfo(this.repoRoot);
|
|
106
206
|
const maxParallel = repo ? (await loadConfig(this.repoRoot)).maxParallel : 1;
|
|
107
|
-
|
|
207
|
+
// `waiting` runs don't hold a slot (#347). A message into a waiting run
|
|
208
|
+
// resumes it even when that momentarily exceeds maxParallel — resumed
|
|
209
|
+
// conversations must never be blocked by the queue.
|
|
210
|
+
const busy = () => this.active.size + this.starting.size - this.waiting.size;
|
|
211
|
+
while (this.queue.length > 0 && busy() < maxParallel) {
|
|
108
212
|
const runId = this.queue.shift();
|
|
109
213
|
if (!runId)
|
|
110
214
|
break;
|
|
@@ -126,7 +230,7 @@ export class RunManager {
|
|
|
126
230
|
this.clearAutosaveTimer(state);
|
|
127
231
|
}
|
|
128
232
|
this.starting.delete(runId);
|
|
129
|
-
this.
|
|
233
|
+
this.dropActive(runId);
|
|
130
234
|
void this.pump();
|
|
131
235
|
});
|
|
132
236
|
}
|
|
@@ -135,6 +239,98 @@ export class RunManager {
|
|
|
135
239
|
this.pumping = false;
|
|
136
240
|
}
|
|
137
241
|
}
|
|
242
|
+
/**
|
|
243
|
+
* Startup recovery (#367) — re-adopt runs that were live when the previous
|
|
244
|
+
* cezar process exited (requires the store opened with `keepLive`):
|
|
245
|
+
* - `queued` → back into the queue (FIFO by createdAt), from the persisted
|
|
246
|
+
* workflowDef (or the catalog by name for older records);
|
|
247
|
+
* - `waiting` → the turn was over and the ball was in the user's court —
|
|
248
|
+
* settle exactly like a closed session (review/done, Continue still works);
|
|
249
|
+
* - `running` → mark interrupted, then immediately resume the last agent
|
|
250
|
+
* session via the Continue path, pointing the agent at its handoff file.
|
|
251
|
+
* Call once, before the server starts taking requests.
|
|
252
|
+
*/
|
|
253
|
+
async recover() {
|
|
254
|
+
const live = this.store
|
|
255
|
+
.listRuns()
|
|
256
|
+
.filter((r) => ['queued', 'waiting', 'running'].includes(r.status))
|
|
257
|
+
.sort((a, b) => a.createdAt.localeCompare(b.createdAt));
|
|
258
|
+
for (const run of live) {
|
|
259
|
+
if (run.status === 'queued') {
|
|
260
|
+
const workflow = await this.reviveWorkflow(run);
|
|
261
|
+
if (workflow) {
|
|
262
|
+
this.pendingJobs.set(run.id, {
|
|
263
|
+
workflow,
|
|
264
|
+
input: { task: run.task, model: run.model, runner: run.runner },
|
|
265
|
+
});
|
|
266
|
+
this.queue.push(run.id);
|
|
267
|
+
this.store.appendEvent(run.id, { type: 'lifecycle', message: 'cezar restarted — task re-queued' });
|
|
268
|
+
}
|
|
269
|
+
else {
|
|
270
|
+
this.store.updateRun(run.id, {
|
|
271
|
+
status: 'failed',
|
|
272
|
+
error: 'interrupted — workflow definition not recoverable after a restart',
|
|
273
|
+
finishedAt: new Date().toISOString(),
|
|
274
|
+
});
|
|
275
|
+
this.store.appendEvent(run.id, {
|
|
276
|
+
type: 'lifecycle',
|
|
277
|
+
message: 'cezar restarted — workflow definition not recoverable, task failed',
|
|
278
|
+
});
|
|
279
|
+
}
|
|
280
|
+
continue;
|
|
281
|
+
}
|
|
282
|
+
if (run.status === 'waiting') {
|
|
283
|
+
for (const step of run.steps) {
|
|
284
|
+
if (step.status === 'waiting' || step.status === 'running') {
|
|
285
|
+
this.store.updateStep(run.id, step.id, { status: 'done', finishedAt: new Date().toISOString() });
|
|
286
|
+
}
|
|
287
|
+
}
|
|
288
|
+
this.store.appendEvent(run.id, {
|
|
289
|
+
type: 'lifecycle',
|
|
290
|
+
message: 'cezar restarted — the open session was settled',
|
|
291
|
+
});
|
|
292
|
+
await this.settleSuccess(run.id);
|
|
293
|
+
continue;
|
|
294
|
+
}
|
|
295
|
+
// `running`: the process died mid-turn. Mark it interrupted (the state
|
|
296
|
+
// continueRun expects), then pick the work back up from the last session.
|
|
297
|
+
const finishedAt = new Date().toISOString();
|
|
298
|
+
for (const step of run.steps) {
|
|
299
|
+
if (step.status === 'running' || step.status === 'waiting') {
|
|
300
|
+
this.store.updateStep(run.id, step.id, { status: 'failed', finishedAt });
|
|
301
|
+
}
|
|
302
|
+
}
|
|
303
|
+
this.store.updateRun(run.id, {
|
|
304
|
+
status: 'failed',
|
|
305
|
+
error: 'interrupted — cezar process exited during the run',
|
|
306
|
+
finishedAt,
|
|
307
|
+
currentStepId: undefined,
|
|
308
|
+
});
|
|
309
|
+
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.');
|
|
310
|
+
this.store.appendEvent(run.id, {
|
|
311
|
+
type: 'lifecycle',
|
|
312
|
+
message: resumed.ok
|
|
313
|
+
? 'cezar restarted — resuming the interrupted task from its last session'
|
|
314
|
+
: `cezar restarted — could not resume the interrupted task (${resumed.error ?? 'unknown'})`,
|
|
315
|
+
});
|
|
316
|
+
}
|
|
317
|
+
void this.pump();
|
|
318
|
+
}
|
|
319
|
+
/** The persisted definition when it looks sane, else the catalog by name. */
|
|
320
|
+
async reviveWorkflow(run) {
|
|
321
|
+
const def = run.workflowDef;
|
|
322
|
+
if (def && Array.isArray(def.steps)) {
|
|
323
|
+
return def;
|
|
324
|
+
}
|
|
325
|
+
const { workflows } = await loadWorkflows(this.repoRoot);
|
|
326
|
+
return workflows.find((w) => w.name === run.workflow) ?? null;
|
|
327
|
+
}
|
|
328
|
+
/** Remove a run from the live registries — keeps `waiting ⊆ active`. */
|
|
329
|
+
dropActive(runId) {
|
|
330
|
+
this.waiting.delete(runId);
|
|
331
|
+
this.active.delete(runId);
|
|
332
|
+
this.memoryPausing.delete(runId);
|
|
333
|
+
}
|
|
138
334
|
cancel(runId) {
|
|
139
335
|
// Still waiting in the queue: just drop it there.
|
|
140
336
|
const queuedAt = this.queue.indexOf(runId);
|
|
@@ -169,16 +365,24 @@ export class RunManager {
|
|
|
169
365
|
.filter((b) => b.type === 'text')
|
|
170
366
|
.map((b) => b.text)
|
|
171
367
|
.join('\n');
|
|
172
|
-
|
|
368
|
+
// Persist the attached images so the thread can render them (not just count them) — the same
|
|
369
|
+
// on-disk store + `/images/` route the agent's own screenshots use.
|
|
370
|
+
const images = content
|
|
371
|
+
.filter((b) => b.type === 'image')
|
|
372
|
+
.map((b) => this.persistImage(runId, state, b.source.media_type, b.source.data))
|
|
373
|
+
.filter((saved) => saved !== null)
|
|
374
|
+
.map((saved) => saved.url);
|
|
173
375
|
this.store.appendEvent(runId, {
|
|
174
376
|
type: 'user-message',
|
|
175
377
|
stepId: state.currentStepId,
|
|
176
378
|
text,
|
|
177
|
-
imageCount,
|
|
379
|
+
imageCount: content.filter((b) => b.type === 'image').length,
|
|
380
|
+
images,
|
|
178
381
|
});
|
|
179
382
|
const delivered = state.session.sendMessage(content);
|
|
180
383
|
if (delivered) {
|
|
181
384
|
this.clearIdleTimer(state);
|
|
385
|
+
this.waiting.delete(runId); // resumed — the run counts against slots again
|
|
182
386
|
this.store.updateRun(runId, { status: 'running' });
|
|
183
387
|
if (state.currentStepId) {
|
|
184
388
|
this.store.updateStep(runId, state.currentStepId, { status: 'running' });
|
|
@@ -236,7 +440,7 @@ export class RunManager {
|
|
|
236
440
|
error: `continue crashed: ${message}`,
|
|
237
441
|
finishedAt: new Date().toISOString(),
|
|
238
442
|
});
|
|
239
|
-
this.
|
|
443
|
+
this.dropActive(runId);
|
|
240
444
|
void this.pump();
|
|
241
445
|
});
|
|
242
446
|
return { ok: true };
|
|
@@ -268,6 +472,8 @@ export class RunManager {
|
|
|
268
472
|
this.store.appendEvent(runId, { type: 'step-start', stepId, name: 'Continue', kind: 'agent', iteration: 1 });
|
|
269
473
|
this.store.appendEvent(runId, { type: 'user-message', stepId, text: prompt, imageCount: 0 });
|
|
270
474
|
let stepCost = 0;
|
|
475
|
+
let turnText = '';
|
|
476
|
+
const sink = this.makeUiSink(runId, stepId);
|
|
271
477
|
const onEvent = (event) => {
|
|
272
478
|
if (event.type === 'image') {
|
|
273
479
|
const saved = this.persistImage(runId, state, event.mediaType, event.data);
|
|
@@ -275,6 +481,13 @@ export class RunManager {
|
|
|
275
481
|
this.store.appendEvent(runId, { type: 'image', stepId, ...saved });
|
|
276
482
|
return;
|
|
277
483
|
}
|
|
484
|
+
if (event.type === 'text') {
|
|
485
|
+
turnText += event.text;
|
|
486
|
+
const text = stripDoneMarker(event.text);
|
|
487
|
+
if (text)
|
|
488
|
+
this.store.appendEvent(runId, { type: 'text', text, stepId });
|
|
489
|
+
return;
|
|
490
|
+
}
|
|
278
491
|
this.store.appendEvent(runId, { ...event, stepId });
|
|
279
492
|
if (event.type === 'session') {
|
|
280
493
|
this.store.updateStep(runId, stepId, { sessionId: event.sessionId });
|
|
@@ -287,18 +500,55 @@ export class RunManager {
|
|
|
287
500
|
this.store.updateStep(runId, stepId, { costUsd: stepCost });
|
|
288
501
|
}
|
|
289
502
|
if (event.type === 'turn-end') {
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
503
|
+
// Belt-and-braces: v2 `turn.completed` already flushed the delta
|
|
504
|
+
// coalescers; the v1 turn boundary flushes again (idempotent) so no
|
|
505
|
+
// buffered delta can outlive its turn.
|
|
506
|
+
sink.flushAll();
|
|
507
|
+
void this.recordTurnEnd(runId, turnText); // titleSummary + diffStat (#389)
|
|
508
|
+
const sessionOpen = !state.cancelled && state.session?.open;
|
|
509
|
+
const done = sessionOpen && DONE_MARKER_RE.test(turnText.trimEnd());
|
|
510
|
+
turnText = '';
|
|
511
|
+
if (done) {
|
|
512
|
+
// Goal achieved (agent contract, #347) — same as in runAgentStep.
|
|
513
|
+
this.store.appendEvent(runId, { type: 'lifecycle', message: 'goal achieved — session closed' });
|
|
514
|
+
appendHandoffHeartbeat(this.dataDir, runId, 'turn complete — goal achieved, session closed');
|
|
515
|
+
state.session?.end();
|
|
516
|
+
return;
|
|
295
517
|
}
|
|
296
|
-
|
|
518
|
+
if (sessionOpen) {
|
|
519
|
+
// Autonomous (#autonomous): never hand the ball back to the user. Nudge the agent to
|
|
520
|
+
// keep going (bounded by MAX_AUTO_CONTINUES) instead of parking at `waiting`.
|
|
521
|
+
const autoContinued = state.autonomous &&
|
|
522
|
+
(state.autoContinues ?? 0) < MAX_AUTO_CONTINUES &&
|
|
523
|
+
!state.cancelled &&
|
|
524
|
+
(() => {
|
|
525
|
+
const sent = state.session?.sendMessage([{ type: 'text', text: AUTONOMOUS_NUDGE }]);
|
|
526
|
+
if (!sent)
|
|
527
|
+
return false;
|
|
528
|
+
state.autoContinues = (state.autoContinues ?? 0) + 1;
|
|
529
|
+
this.store.appendEvent(runId, {
|
|
530
|
+
type: 'note',
|
|
531
|
+
message: `autonomous — continuing without pausing (${state.autoContinues}/${MAX_AUTO_CONTINUES})`,
|
|
532
|
+
});
|
|
533
|
+
return true;
|
|
534
|
+
})();
|
|
535
|
+
if (!autoContinued) {
|
|
536
|
+
this.store.updateRun(runId, { status: 'waiting' });
|
|
537
|
+
this.store.updateStep(runId, stepId, { status: 'waiting' });
|
|
538
|
+
this.waiting.add(runId);
|
|
539
|
+
this.armIdleTimer(runId, state);
|
|
540
|
+
void this.pump();
|
|
541
|
+
}
|
|
542
|
+
}
|
|
543
|
+
appendHandoffHeartbeat(this.dataDir, runId, `turn complete — status=${sessionOpen ? 'waiting' : 'running'}`);
|
|
297
544
|
}
|
|
298
545
|
};
|
|
299
546
|
const runner = createRunner(record?.runner ?? 'claude');
|
|
300
547
|
const session = runner.startSession({
|
|
301
|
-
|
|
548
|
+
// The Continue step is a fresh agent session on the same run — the
|
|
549
|
+
// run's extra system prompt (already resolved at execute time and
|
|
550
|
+
// echoed on the record) rides along with the handoff contract.
|
|
551
|
+
systemPrompt: composeSystemPrompt(record?.systemPrompt, HANDOFF_INSTRUCTIONS),
|
|
302
552
|
userPrompt: prompt,
|
|
303
553
|
cwd: state.cwd,
|
|
304
554
|
allowedTools: DEFAULT_ALLOWED_TOOLS,
|
|
@@ -307,13 +557,16 @@ export class RunManager {
|
|
|
307
557
|
sessionId,
|
|
308
558
|
resume: true,
|
|
309
559
|
timeoutMs: 0,
|
|
310
|
-
}, onEvent);
|
|
560
|
+
}, onEvent, { onUiEvent: (event) => sink.handle(event) });
|
|
311
561
|
state.session = session;
|
|
312
562
|
state.currentStepId = stepId;
|
|
313
563
|
state.interrupt = () => session.interrupt();
|
|
564
|
+
if (session.pid !== undefined)
|
|
565
|
+
registerRunProcess(runId, session.pid);
|
|
314
566
|
const finishedAt = () => new Date().toISOString();
|
|
315
567
|
try {
|
|
316
568
|
await session.result;
|
|
569
|
+
sink.sessionEnded(state.cancelled ? 'cancelled' : 'end_turn');
|
|
317
570
|
if (state.cancelled) {
|
|
318
571
|
this.store.updateStep(runId, stepId, { status: 'cancelled', finishedAt: finishedAt() });
|
|
319
572
|
this.store.updateRun(runId, { status: 'cancelled', finishedAt: finishedAt(), currentStepId: undefined });
|
|
@@ -329,6 +582,7 @@ export class RunManager {
|
|
|
329
582
|
}
|
|
330
583
|
catch (err) {
|
|
331
584
|
const message = err instanceof Error ? err.message : String(err);
|
|
585
|
+
sink.sessionEnded('error', message);
|
|
332
586
|
this.store.updateStep(runId, stepId, { status: 'failed', error: message, finishedAt: finishedAt() });
|
|
333
587
|
appendHandoffHeartbeat(this.dataDir, runId, `step "${stepId}" complete — status=failed`);
|
|
334
588
|
this.store.updateRun(runId, {
|
|
@@ -340,39 +594,56 @@ export class RunManager {
|
|
|
340
594
|
this.store.appendEvent(runId, { type: 'lifecycle', message: `continue failed — ${message}` });
|
|
341
595
|
}
|
|
342
596
|
finally {
|
|
597
|
+
this.recordUsagePeaks(runId);
|
|
343
598
|
this.clearIdleTimer(state);
|
|
344
599
|
this.clearAutosaveTimer(state);
|
|
345
600
|
if (state.cwd !== this.repoRoot)
|
|
346
601
|
await autosaveCommit(state.cwd);
|
|
347
|
-
this.
|
|
602
|
+
this.dropActive(runId);
|
|
348
603
|
void this.pump();
|
|
349
604
|
}
|
|
350
605
|
}
|
|
351
606
|
// ---- execution -----------------------------------------------------------
|
|
352
607
|
async execute(runId, workflow, input) {
|
|
353
|
-
const state = {
|
|
608
|
+
const state = {
|
|
609
|
+
cancelled: false,
|
|
610
|
+
interrupt: () => undefined,
|
|
611
|
+
cwd: this.repoRoot,
|
|
612
|
+
autonomous: input.autonomous === true,
|
|
613
|
+
autoContinues: 0,
|
|
614
|
+
};
|
|
354
615
|
this.active.set(runId, state);
|
|
355
616
|
this.starting.delete(runId);
|
|
356
617
|
const emit = (event) => this.store.appendEvent(runId, event);
|
|
357
618
|
// Resolve the agent backend for this run: the task choice (GUI) wins over
|
|
358
619
|
// the config default. Per-step `runner` can still override it below.
|
|
359
|
-
const
|
|
620
|
+
const config = await loadConfig(this.repoRoot);
|
|
621
|
+
const taskBackend = input.runner ?? config.defaultRunner;
|
|
622
|
+
// Extra system prompt (R2 2.3): POST override > config default; echoed on
|
|
623
|
+
// the record so the UI/API can show what the run actually used.
|
|
624
|
+
const extraSystemPrompt = resolveExtraSystemPrompt(input.systemPrompt, config.systemPrompt);
|
|
360
625
|
this.store.updateRun(runId, {
|
|
361
626
|
status: 'running',
|
|
362
627
|
startedAt: new Date().toISOString(),
|
|
363
628
|
runner: taskBackend,
|
|
629
|
+
systemPrompt: extraSystemPrompt,
|
|
364
630
|
});
|
|
365
631
|
emit({ type: 'lifecycle', message: `run started — workflow "${workflow.name}" (runner: ${taskBackend})` });
|
|
366
632
|
// Worktree per task (spec 006): the agent works on its own branch in
|
|
367
633
|
// `.ai/cezar/worktrees/<id>`, never in the user's working tree. Not a git
|
|
368
634
|
// repo, or worktree creation failed → degrade to running in place.
|
|
369
635
|
const repo = await getRepoInfo(this.repoRoot);
|
|
370
|
-
if (repo) {
|
|
636
|
+
if (repo && input.worktree === false) {
|
|
637
|
+
// Composer opt-out: run in the repo working tree, no branch/worktree
|
|
638
|
+
// (read-only skills — review, summarize — that never need isolation).
|
|
639
|
+
emit({ type: 'note', message: 'worktree off — running in the repo working tree' });
|
|
640
|
+
}
|
|
641
|
+
else if (repo) {
|
|
371
642
|
// Fork from the configured base branch (config.json `baseBranch`, e.g.
|
|
372
643
|
// `develop`) — also the target of the eventual draft PR. Unresolvable
|
|
373
644
|
// (typo, not fetched) → note + the currently checked-out branch.
|
|
374
645
|
let base = repo.branch;
|
|
375
|
-
const configured =
|
|
646
|
+
const configured = config.baseBranch;
|
|
376
647
|
if (configured) {
|
|
377
648
|
const resolved = await resolveBaseRef(this.repoRoot, configured);
|
|
378
649
|
if (resolved) {
|
|
@@ -413,6 +684,17 @@ export class RunManager {
|
|
|
413
684
|
const retriesUsed = new Map();
|
|
414
685
|
let checkFailure = null;
|
|
415
686
|
let runError = null;
|
|
687
|
+
// Persist the task's attached images so the thread's initial bubble can render them
|
|
688
|
+
// (#image-display); they still ride the first agent step's opening message below.
|
|
689
|
+
if (input.images?.length) {
|
|
690
|
+
const urls = input.images
|
|
691
|
+
.filter((b) => b.type === 'image')
|
|
692
|
+
.map((b) => this.persistImage(runId, state, b.source.media_type, b.source.data))
|
|
693
|
+
.filter((saved) => saved !== null)
|
|
694
|
+
.map((saved) => saved.url);
|
|
695
|
+
if (urls.length)
|
|
696
|
+
this.store.updateRun(runId, { taskImages: urls });
|
|
697
|
+
}
|
|
416
698
|
// Task screenshots go with the FIRST agent step's opening message only —
|
|
417
699
|
// later steps and retry loops run in fresh sessions without them.
|
|
418
700
|
let startImages = input.images;
|
|
@@ -437,7 +719,7 @@ export class RunManager {
|
|
|
437
719
|
// The last agent step of the workflow is interactive: after its turn
|
|
438
720
|
// the session stays open for follow-ups until finish/idle/cancel.
|
|
439
721
|
const interactive = i === lastAgentIdx && i === workflow.steps.length - 1;
|
|
440
|
-
const failure = await this.runAgentStep(runId, state, step, input, skills, checkFailure, interactive, emit, startImages, taskBackend);
|
|
722
|
+
const failure = await this.runAgentStep(runId, state, step, input, skills, checkFailure, interactive, emit, startImages, taskBackend, extraSystemPrompt);
|
|
441
723
|
startImages = undefined;
|
|
442
724
|
checkFailure = null;
|
|
443
725
|
if (state.cancelled)
|
|
@@ -505,11 +787,11 @@ export class RunManager {
|
|
|
505
787
|
await this.settleSuccess(runId);
|
|
506
788
|
}
|
|
507
789
|
this.clearIdleTimer(state);
|
|
508
|
-
this.
|
|
790
|
+
this.dropActive(runId);
|
|
509
791
|
void this.pump();
|
|
510
792
|
}
|
|
511
793
|
/** Returns an error message, or null on success. */
|
|
512
|
-
async runAgentStep(runId, state, step, input, skills, checkFailure, interactive, emit, images, taskBackend) {
|
|
794
|
+
async runAgentStep(runId, state, step, input, skills, checkFailure, interactive, emit, images, taskBackend, extraSystemPrompt) {
|
|
513
795
|
let systemPrompt;
|
|
514
796
|
if (step.skill) {
|
|
515
797
|
const skill = skills.find((s) => s.name === step.skill);
|
|
@@ -554,6 +836,8 @@ export class RunManager {
|
|
|
554
836
|
const stepRecord = this.store.getRun(runId)?.steps.find((s) => s.id === step.id);
|
|
555
837
|
const startTokens = stepRecord?.tokensUsed ?? 0;
|
|
556
838
|
let stepCost = stepRecord?.costUsd ?? 0;
|
|
839
|
+
let turnText = '';
|
|
840
|
+
const sink = this.makeUiSink(runId, step.id);
|
|
557
841
|
const onEvent = (event) => {
|
|
558
842
|
if (event.type === 'image') {
|
|
559
843
|
const saved = this.persistImage(runId, state, event.mediaType, event.data);
|
|
@@ -561,6 +845,13 @@ export class RunManager {
|
|
|
561
845
|
emit({ type: 'image', stepId: step.id, ...saved });
|
|
562
846
|
return;
|
|
563
847
|
}
|
|
848
|
+
if (event.type === 'text') {
|
|
849
|
+
turnText += event.text;
|
|
850
|
+
const text = stripDoneMarker(event.text);
|
|
851
|
+
if (text)
|
|
852
|
+
emit({ type: 'text', text, stepId: step.id });
|
|
853
|
+
return;
|
|
854
|
+
}
|
|
564
855
|
emit({ ...event, stepId: step.id });
|
|
565
856
|
if (event.type === 'session') {
|
|
566
857
|
// Codex/OpenCode mint their own session id — persist it so resume works.
|
|
@@ -574,12 +865,29 @@ export class RunManager {
|
|
|
574
865
|
this.store.updateStep(runId, step.id, { costUsd: stepCost });
|
|
575
866
|
}
|
|
576
867
|
if (event.type === 'turn-end') {
|
|
577
|
-
|
|
868
|
+
// v2 `turn.completed` already flushed the coalescers; the v1 turn
|
|
869
|
+
// boundary flushes again (idempotent) as a backstop.
|
|
870
|
+
sink.flushAll();
|
|
871
|
+
void this.recordTurnEnd(runId, turnText); // titleSummary + diffStat (#389)
|
|
872
|
+
const sessionOpen = !state.cancelled && state.session?.open;
|
|
873
|
+
const done = interactive && sessionOpen && DONE_MARKER_RE.test(turnText.trimEnd());
|
|
874
|
+
turnText = '';
|
|
875
|
+
if (done) {
|
|
876
|
+
// Goal achieved (agent contract, #347): close the session instead
|
|
877
|
+
// of parking at `waiting` — the run completes and frees its slot.
|
|
878
|
+
emit({ type: 'lifecycle', message: 'goal achieved — session closed' });
|
|
879
|
+
appendHandoffHeartbeat(this.dataDir, runId, 'turn complete — goal achieved, session closed');
|
|
880
|
+
state.session?.end();
|
|
881
|
+
return;
|
|
882
|
+
}
|
|
883
|
+
const waiting = interactive && sessionOpen;
|
|
578
884
|
if (waiting) {
|
|
579
885
|
// Turn over, session open: the ball is in the user's court.
|
|
580
886
|
this.store.updateRun(runId, { status: 'waiting' });
|
|
581
887
|
this.store.updateStep(runId, step.id, { status: 'waiting' });
|
|
888
|
+
this.waiting.add(runId);
|
|
582
889
|
this.armIdleTimer(runId, state);
|
|
890
|
+
void this.pump(); // the freed slot can start a queued run right away
|
|
583
891
|
}
|
|
584
892
|
// Cez's own heartbeat — the handoff stays current even when the
|
|
585
893
|
// agent forgets to write (spec 007).
|
|
@@ -590,10 +898,9 @@ export class RunManager {
|
|
|
590
898
|
let session;
|
|
591
899
|
try {
|
|
592
900
|
session = runner.startSession({
|
|
593
|
-
//
|
|
594
|
-
|
|
595
|
-
|
|
596
|
-
: HANDOFF_INSTRUCTIONS,
|
|
901
|
+
// Skill body, then the run's extra prompt (POST override or config
|
|
902
|
+
// default), then the handoff/todos contract — every agent step.
|
|
903
|
+
systemPrompt: composeSystemPrompt(systemPrompt, extraSystemPrompt, HANDOFF_INSTRUCTIONS),
|
|
597
904
|
userPrompt,
|
|
598
905
|
images,
|
|
599
906
|
cwd: state.cwd,
|
|
@@ -606,7 +913,7 @@ export class RunManager {
|
|
|
606
913
|
sessionId,
|
|
607
914
|
// Interactive sessions have no wall clock — the idle timer rules.
|
|
608
915
|
timeoutMs: interactive ? 0 : undefined,
|
|
609
|
-
}, onEvent, { autoEndAfterFirstTurn: !interactive });
|
|
916
|
+
}, onEvent, { autoEndAfterFirstTurn: !interactive, onUiEvent: (event) => sink.handle(event) });
|
|
610
917
|
}
|
|
611
918
|
catch (err) {
|
|
612
919
|
return err instanceof Error ? err.message : String(err);
|
|
@@ -614,21 +921,97 @@ export class RunManager {
|
|
|
614
921
|
state.session = session;
|
|
615
922
|
state.currentStepId = step.id;
|
|
616
923
|
state.interrupt = () => session.interrupt();
|
|
924
|
+
if (session.pid !== undefined)
|
|
925
|
+
registerRunProcess(runId, session.pid);
|
|
617
926
|
try {
|
|
618
927
|
const result = await session.result;
|
|
928
|
+
// v2 counterpart of v1's `done` (spec: the mappers leave session-close
|
|
929
|
+
// events to the RunManager — only it knows how the session settled).
|
|
930
|
+
sink.sessionEnded(state.cancelled ? 'cancelled' : 'end_turn');
|
|
619
931
|
this.store.updateStep(runId, step.id, { tokensUsed: startTokens + result.tokensUsed });
|
|
620
932
|
return null;
|
|
621
933
|
}
|
|
622
934
|
catch (err) {
|
|
623
|
-
|
|
935
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
936
|
+
sink.sessionEnded('error', message); // alongside v1's fatal `error`
|
|
937
|
+
return message;
|
|
624
938
|
}
|
|
625
939
|
finally {
|
|
940
|
+
this.recordUsagePeaks(runId);
|
|
626
941
|
this.clearIdleTimer(state);
|
|
627
942
|
state.session = undefined;
|
|
628
943
|
state.currentStepId = undefined;
|
|
629
944
|
state.interrupt = () => undefined;
|
|
630
945
|
}
|
|
631
946
|
}
|
|
947
|
+
/**
|
|
948
|
+
* Protocol-v2 sink for one agent session (R2 step 2.1): the runner's
|
|
949
|
+
* `onUiEvent` stream flows through here. Persisted snapshots ride the same
|
|
950
|
+
* NDJSON file as v1 (the store stamps `seq`/`ts`, `appendEvent` fans them
|
|
951
|
+
* out live too); coalesced `item.delta` flushes go out live-only via
|
|
952
|
+
* `emitEphemeral` — raw deltas never hit disk (spec §performance
|
|
953
|
+
* guardrails). One sink per session: cumulative usage dedup and the
|
|
954
|
+
* item-shape cache are session-scoped, like the mapper state feeding them.
|
|
955
|
+
*/
|
|
956
|
+
makeUiSink(runId, stepId) {
|
|
957
|
+
return new UiEventSink({
|
|
958
|
+
persist: (event) => this.store.appendEvent(runId, { ...event, stepId }),
|
|
959
|
+
emitLive: (event) => this.store.emitEphemeral(runId, { ...event, stepId }),
|
|
960
|
+
});
|
|
961
|
+
}
|
|
962
|
+
/**
|
|
963
|
+
* Turn-end bookkeeping (#389), shared by `runAgentStep` and
|
|
964
|
+
* `runContinuation` — called (fire-and-forget) from every `turn-end` event:
|
|
965
|
+
*
|
|
966
|
+
* - `titleSummary`: derived from the turn's text, set ONCE — only while the
|
|
967
|
+
* record has none. A user's inline edit also lands in `titleSummary`
|
|
968
|
+
* (see `PATCH /api/runs/:id`), so an edit is never overwritten either.
|
|
969
|
+
* - `diffStat`: cheap `git diff --shortstat` vs the base, refreshed every
|
|
970
|
+
* turn. Async and best-effort — a git failure becomes at most a `note`
|
|
971
|
+
* event, NEVER a run failure. `updateRun` fans the record out over SSE,
|
|
972
|
+
* so the list views pick both up with no extra wiring.
|
|
973
|
+
*
|
|
974
|
+
* Not `private` so the integration tests can drive a turn-end directly —
|
|
975
|
+
* a real agent session is the only other way to reach this path.
|
|
976
|
+
*/
|
|
977
|
+
async recordTurnEnd(runId, turnText) {
|
|
978
|
+
try {
|
|
979
|
+
const run = this.store.getRun(runId);
|
|
980
|
+
if (!run)
|
|
981
|
+
return;
|
|
982
|
+
if (run.titleSummary === undefined) {
|
|
983
|
+
const summary = deriveTitleSummary(turnText, run.task);
|
|
984
|
+
if (summary)
|
|
985
|
+
this.store.updateRun(runId, { titleSummary: summary });
|
|
986
|
+
}
|
|
987
|
+
if (!run.worktreePath || !existsSync(run.worktreePath))
|
|
988
|
+
return;
|
|
989
|
+
const stat = await worktreeShortstat(run.worktreePath, run.baseBranch ?? 'HEAD');
|
|
990
|
+
if (stat)
|
|
991
|
+
this.store.updateRun(runId, { diffStat: stat });
|
|
992
|
+
else
|
|
993
|
+
this.store.appendEvent(runId, { type: 'note', message: 'diff stat unavailable — git diff --shortstat failed in the worktree' });
|
|
994
|
+
}
|
|
995
|
+
catch {
|
|
996
|
+
// Bookkeeping only — nothing here may disturb the run.
|
|
997
|
+
}
|
|
998
|
+
}
|
|
999
|
+
/**
|
|
1000
|
+
* End-of-session telemetry (#348): stop sampling the run's process tree and
|
|
1001
|
+
* fold the session's peaks into the run record. `max` with existing values —
|
|
1002
|
+
* a run can hold several sessions (multiple agent steps, Continue) and the
|
|
1003
|
+
* record keeps the highest water mark across all of them.
|
|
1004
|
+
*/
|
|
1005
|
+
recordUsagePeaks(runId) {
|
|
1006
|
+
const peaks = unregisterRunProcess(runId);
|
|
1007
|
+
if (!peaks)
|
|
1008
|
+
return;
|
|
1009
|
+
const run = this.store.getRun(runId);
|
|
1010
|
+
this.store.updateRun(runId, {
|
|
1011
|
+
peakRssBytes: Math.max(run?.peakRssBytes ?? 0, peaks.peakRssBytes),
|
|
1012
|
+
peakProcCount: Math.max(run?.peakProcCount ?? 0, peaks.peakProcCount),
|
|
1013
|
+
});
|
|
1014
|
+
}
|
|
632
1015
|
/**
|
|
633
1016
|
* Diff-first review gate (spec 009), shared by `execute` and
|
|
634
1017
|
* `runContinuation`: a *successful* run whose worktree holds changes rests
|