@pat-lewczuk/cezar 0.1.0 → 0.1.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/config.d.ts +55 -0
- package/dist/config.js +44 -0
- package/dist/config.js.map +1 -0
- package/dist/core/agent-runner.d.ts +13 -0
- package/dist/core/claude-cli-runner.js +23 -2
- package/dist/core/claude-cli-runner.js.map +1 -1
- package/dist/git-worktree.d.ts +49 -0
- package/dist/git-worktree.js +129 -0
- package/dist/git-worktree.js.map +1 -0
- package/dist/handoff.d.ts +42 -0
- package/dist/handoff.js +120 -0
- package/dist/handoff.js.map +1 -0
- package/dist/index.js +27 -6
- package/dist/index.js.map +1 -1
- package/dist/planner.d.ts +17 -0
- package/dist/planner.js +244 -0
- package/dist/planner.js.map +1 -0
- package/dist/runs/store.d.ts +40 -13
- package/dist/runs/store.js +33 -3
- package/dist/runs/store.js.map +1 -1
- package/dist/server/github.d.ts +28 -0
- package/dist/server/github.js +133 -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/pr.d.ts +22 -0
- package/dist/server/pr.js +92 -0
- package/dist/server/pr.js.map +1 -0
- package/dist/server/server.js +370 -15
- package/dist/server/server.js.map +1 -1
- 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 +18 -6
- package/dist/skills.js +8 -4
- package/dist/skills.js.map +1 -1
- package/dist/todos.d.ts +60 -0
- package/dist/todos.js +166 -0
- package/dist/todos.js.map +1 -0
- package/dist/workflows/load.js +5 -9
- package/dist/workflows/load.js.map +1 -1
- package/dist/workflows/run.d.ts +62 -3
- package/dist/workflows/run.js +332 -43
- package/dist/workflows/run.js.map +1 -1
- package/dist/workflows/types.d.ts +27 -20
- package/dist/workflows/types.js +21 -0
- package/dist/workflows/types.js.map +1 -1
- package/package.json +1 -1
- package/scripts/mock-claude.mjs +118 -0
- package/web/app.js +1851 -149
- package/web/index.html +73 -22
- package/web/style.css +1271 -223
package/dist/workflows/run.js
CHANGED
|
@@ -1,47 +1,149 @@
|
|
|
1
1
|
import { randomUUID } from 'node:crypto';
|
|
2
2
|
import { spawn } from 'node:child_process';
|
|
3
|
+
import { existsSync, mkdirSync, writeFileSync } from 'node:fs';
|
|
4
|
+
import { join } from 'node:path';
|
|
3
5
|
import { ClaudeCliRunner } from '../core/claude-cli-runner.js';
|
|
6
|
+
import { HANDOFF_INSTRUCTIONS, appendHandoffHeartbeat, handoffPath, seedHandoffFile, } from '../handoff.js';
|
|
7
|
+
import { todosPath } from '../todos.js';
|
|
4
8
|
import { discoverSkills } from '../skills.js';
|
|
9
|
+
import { materializeSkillDir } from '../skills-remote.js';
|
|
10
|
+
import { loadConfig } from '../config.js';
|
|
11
|
+
import { autosaveCommit, createWorktree, worktreeDiff } from '../git-worktree.js';
|
|
12
|
+
import { getRepoInfo } from '../server/git.js';
|
|
5
13
|
import { DEFAULT_ALLOWED_TOOLS, stepKind } from './types.js';
|
|
6
14
|
const CHECK_OUTPUT_CAP = 20_000;
|
|
7
15
|
/** An interactive session that hears nothing from the user closes itself. */
|
|
8
16
|
export const IDLE_TIMEOUT_MS = 15 * 60_000;
|
|
17
|
+
/** Periodic "cezar autosave" commit in the task worktree (spec 006). */
|
|
18
|
+
export const AUTOSAVE_INTERVAL_MS = 90_000;
|
|
19
|
+
/** Variant letters + the fixed diversification hints (spec 010). A runs the
|
|
20
|
+
* task verbatim; B/C get one constant sentence each — zero configuration. */
|
|
21
|
+
export const VARIANT_LETTERS = ['A', 'B', 'C'];
|
|
22
|
+
const VARIANT_HINTS = {
|
|
23
|
+
A: undefined,
|
|
24
|
+
B: 'Approach hint: prefer the minimal, surgical change.',
|
|
25
|
+
C: 'Approach hint: prefer a thorough, structural approach.',
|
|
26
|
+
};
|
|
9
27
|
/**
|
|
10
28
|
* The mini workflow engine: executes a `WorkflowDef` against a repo, one step
|
|
11
29
|
* at a time, persisting every event to the RunStore (which the SSE endpoints
|
|
12
30
|
* relay live to the GUI). No GitHub choreography — agent steps and shell
|
|
13
31
|
* checks with bounded retry loops, plus live sessions: the last agent step
|
|
14
32
|
* stays open for follow-ups (`waiting`) until "finish", idle timeout, or
|
|
15
|
-
* cancel.
|
|
33
|
+
* cancel. Runs queue behind `maxParallel` slots and each run executes in its
|
|
34
|
+
* own git worktree on a `cez/<id8>` branch (spec 006), autosave-committed
|
|
35
|
+
* every 90 s — the user's working tree is never touched.
|
|
16
36
|
*/
|
|
17
37
|
export class RunManager {
|
|
18
38
|
store;
|
|
19
39
|
repoRoot;
|
|
20
40
|
active = new Map();
|
|
41
|
+
// Queue + `starting` set (spec 006, janitor's pump() pattern): `starting`
|
|
42
|
+
// covers the window between shifting a run off the queue and the run
|
|
43
|
+
// registering in `active`, so parallel-slot counting is never racy.
|
|
44
|
+
queue = [];
|
|
45
|
+
starting = new Set();
|
|
46
|
+
pendingJobs = new Map();
|
|
47
|
+
pumping = false;
|
|
48
|
+
/** `.ai/cezar` — where the per-task handoff files and todos.json live. */
|
|
49
|
+
dataDir;
|
|
21
50
|
constructor(store, repoRoot) {
|
|
22
51
|
this.store = store;
|
|
23
52
|
this.repoRoot = repoRoot;
|
|
53
|
+
this.dataDir = join(repoRoot, '.ai/cezar');
|
|
24
54
|
}
|
|
25
|
-
|
|
55
|
+
/** Env the spawned claude gets so the agent can find its handoff file and
|
|
56
|
+
* the global inbox (spec 007). */
|
|
57
|
+
agentEnv(runId) {
|
|
58
|
+
return {
|
|
59
|
+
CEZ_HANDOFF_FILE: handoffPath(this.dataDir, runId),
|
|
60
|
+
CEZ_TODOS_FILE: todosPath(this.dataDir),
|
|
61
|
+
CEZ_TASK_ID: runId,
|
|
62
|
+
};
|
|
63
|
+
}
|
|
64
|
+
startRun(workflow, input, group) {
|
|
26
65
|
const run = this.store.createRun({
|
|
27
|
-
title: makeTitle(input.task),
|
|
66
|
+
title: makeTitle(input.task) + (group ? ` (${group.variant})` : ''),
|
|
28
67
|
workflow: workflow.name,
|
|
29
68
|
task: input.task,
|
|
30
69
|
model: input.model,
|
|
70
|
+
groupId: group?.groupId,
|
|
71
|
+
variant: group?.variant,
|
|
31
72
|
steps: workflow.steps.map((s) => ({ id: s.id, name: s.name ?? s.id, kind: stepKind(s) })),
|
|
32
73
|
});
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
status: 'failed',
|
|
37
|
-
error: `engine crashed: ${message}`,
|
|
38
|
-
finishedAt: new Date().toISOString(),
|
|
39
|
-
});
|
|
40
|
-
this.active.delete(run.id);
|
|
41
|
-
});
|
|
74
|
+
this.pendingJobs.set(run.id, { workflow, input });
|
|
75
|
+
this.queue.push(run.id);
|
|
76
|
+
void this.pump();
|
|
42
77
|
return run;
|
|
43
78
|
}
|
|
79
|
+
/**
|
|
80
|
+
* Parallel variants (spec 010): N runs of the same workflow on the same
|
|
81
|
+
* task, sharing a groupId. Variant A gets the task verbatim; B and C get a
|
|
82
|
+
* fixed one-line approach hint appended to the *task input* (not the step
|
|
83
|
+
* template), so diversification works with any workflow. The normal queue
|
|
84
|
+
* applies — with maxParallel=2 a third variant simply waits.
|
|
85
|
+
*/
|
|
86
|
+
startVariants(workflow, input, count) {
|
|
87
|
+
const groupId = randomUUID();
|
|
88
|
+
return VARIANT_LETTERS.slice(0, Math.min(Math.max(count, 1), VARIANT_LETTERS.length)).map((variant) => {
|
|
89
|
+
const hint = VARIANT_HINTS[variant];
|
|
90
|
+
const task = hint ? `${input.task}\n\n${hint}` : input.task;
|
|
91
|
+
return this.startRun(workflow, { ...input, task }, { groupId, variant });
|
|
92
|
+
});
|
|
93
|
+
}
|
|
94
|
+
/**
|
|
95
|
+
* Start queued runs while parallel slots are free. `maxParallel` comes from
|
|
96
|
+
* `.ai/cezar/config.json` (default 2); a non-git directory degrades to 1
|
|
97
|
+
* sequential run in the repo root (spec 006 degradation rule).
|
|
98
|
+
*/
|
|
99
|
+
async pump() {
|
|
100
|
+
if (this.pumping)
|
|
101
|
+
return;
|
|
102
|
+
this.pumping = true;
|
|
103
|
+
try {
|
|
104
|
+
const repo = await getRepoInfo(this.repoRoot);
|
|
105
|
+
const maxParallel = repo ? (await loadConfig(this.repoRoot)).maxParallel : 1;
|
|
106
|
+
while (this.queue.length > 0 && this.active.size + this.starting.size < maxParallel) {
|
|
107
|
+
const runId = this.queue.shift();
|
|
108
|
+
if (!runId)
|
|
109
|
+
break;
|
|
110
|
+
const job = this.pendingJobs.get(runId);
|
|
111
|
+
this.pendingJobs.delete(runId);
|
|
112
|
+
if (!job)
|
|
113
|
+
continue;
|
|
114
|
+
this.starting.add(runId);
|
|
115
|
+
void this.execute(runId, job.workflow, job.input).catch((err) => {
|
|
116
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
117
|
+
this.store.updateRun(runId, {
|
|
118
|
+
status: 'failed',
|
|
119
|
+
error: `engine crashed: ${message}`,
|
|
120
|
+
finishedAt: new Date().toISOString(),
|
|
121
|
+
});
|
|
122
|
+
const state = this.active.get(runId);
|
|
123
|
+
if (state) {
|
|
124
|
+
this.clearIdleTimer(state);
|
|
125
|
+
this.clearAutosaveTimer(state);
|
|
126
|
+
}
|
|
127
|
+
this.starting.delete(runId);
|
|
128
|
+
this.active.delete(runId);
|
|
129
|
+
void this.pump();
|
|
130
|
+
});
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
finally {
|
|
134
|
+
this.pumping = false;
|
|
135
|
+
}
|
|
136
|
+
}
|
|
44
137
|
cancel(runId) {
|
|
138
|
+
// Still waiting in the queue: just drop it there.
|
|
139
|
+
const queuedAt = this.queue.indexOf(runId);
|
|
140
|
+
if (queuedAt >= 0) {
|
|
141
|
+
this.queue.splice(queuedAt, 1);
|
|
142
|
+
this.pendingJobs.delete(runId);
|
|
143
|
+
this.store.updateRun(runId, { status: 'cancelled', finishedAt: new Date().toISOString() });
|
|
144
|
+
this.store.appendEvent(runId, { type: 'lifecycle', message: 'cancelled while queued' });
|
|
145
|
+
return true;
|
|
146
|
+
}
|
|
45
147
|
const state = this.active.get(runId);
|
|
46
148
|
if (!state)
|
|
47
149
|
return false;
|
|
@@ -51,7 +153,7 @@ export class RunManager {
|
|
|
51
153
|
return true;
|
|
52
154
|
}
|
|
53
155
|
isActive(runId) {
|
|
54
|
-
return this.active.has(runId);
|
|
156
|
+
return this.active.has(runId) || this.starting.has(runId) || this.queue.includes(runId);
|
|
55
157
|
}
|
|
56
158
|
/**
|
|
57
159
|
* Deliver a user message into the run's live claude session (mid-turn or
|
|
@@ -83,15 +185,26 @@ export class RunManager {
|
|
|
83
185
|
}
|
|
84
186
|
return delivered;
|
|
85
187
|
}
|
|
86
|
-
/** Close the open session gracefully — the run then completes as `done
|
|
188
|
+
/** Close the open session gracefully — the run then completes as `done`
|
|
189
|
+
* (or rests at `review` when the worktree holds changes, spec 009).
|
|
190
|
+
* On a run already resting at `review` (no session — the engine loop is
|
|
191
|
+
* over), "Finish" is the third review exit: accept the changes without a
|
|
192
|
+
* PR and flip straight to `done`. */
|
|
87
193
|
finish(runId) {
|
|
88
194
|
const state = this.active.get(runId);
|
|
89
|
-
if (
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
195
|
+
if (state?.session?.open) {
|
|
196
|
+
this.clearIdleTimer(state);
|
|
197
|
+
this.store.appendEvent(runId, { type: 'lifecycle', message: 'session closed by user' });
|
|
198
|
+
state.session.end();
|
|
199
|
+
return true;
|
|
200
|
+
}
|
|
201
|
+
const run = this.store.getRun(runId);
|
|
202
|
+
if (run?.status === 'review' && !this.isActive(runId)) {
|
|
203
|
+
this.store.updateRun(runId, { status: 'done' });
|
|
204
|
+
this.store.appendEvent(runId, { type: 'lifecycle', message: 'review accepted — finished without a PR' });
|
|
205
|
+
return true;
|
|
206
|
+
}
|
|
207
|
+
return false;
|
|
95
208
|
}
|
|
96
209
|
/**
|
|
97
210
|
* "Continue" (spec 003): reopen a finished run's claude session in-process
|
|
@@ -105,7 +218,8 @@ export class RunManager {
|
|
|
105
218
|
const run = this.store.getRun(runId);
|
|
106
219
|
if (!run)
|
|
107
220
|
return { ok: false, error: 'not found' };
|
|
108
|
-
|
|
221
|
+
// `review` is continuable too — that's the "Send back" path (spec 009).
|
|
222
|
+
if (!['done', 'failed', 'cancelled', 'review'].includes(run.status)) {
|
|
109
223
|
return { ok: false, error: `cannot continue a ${run.status} run` };
|
|
110
224
|
}
|
|
111
225
|
const sessionId = [...run.steps].reverse().find((s) => s.sessionId)?.sessionId;
|
|
@@ -122,12 +236,22 @@ export class RunManager {
|
|
|
122
236
|
finishedAt: new Date().toISOString(),
|
|
123
237
|
});
|
|
124
238
|
this.active.delete(runId);
|
|
239
|
+
void this.pump();
|
|
125
240
|
});
|
|
126
241
|
return { ok: true };
|
|
127
242
|
}
|
|
128
243
|
async runContinuation(runId, stepId, sessionId, prompt) {
|
|
129
|
-
|
|
244
|
+
// Continuation runs in the task's worktree when it still exists (spec
|
|
245
|
+
// 006) — the resumed session sees exactly what the original run left.
|
|
246
|
+
const record = this.store.getRun(runId);
|
|
247
|
+
const cwd = record?.worktreePath && existsSync(record.worktreePath)
|
|
248
|
+
? record.worktreePath
|
|
249
|
+
: this.repoRoot;
|
|
250
|
+
const state = { cancelled: false, interrupt: () => undefined, cwd };
|
|
130
251
|
this.active.set(runId, state);
|
|
252
|
+
this.armAutosave(state);
|
|
253
|
+
if (record)
|
|
254
|
+
seedHandoffFile(this.dataDir, record); // idempotent — normally already there
|
|
131
255
|
this.store.updateRun(runId, {
|
|
132
256
|
status: 'running',
|
|
133
257
|
error: undefined,
|
|
@@ -144,6 +268,12 @@ export class RunManager {
|
|
|
144
268
|
this.store.appendEvent(runId, { type: 'user-message', stepId, text: prompt, imageCount: 0 });
|
|
145
269
|
let stepCost = 0;
|
|
146
270
|
const onEvent = (event) => {
|
|
271
|
+
if (event.type === 'image') {
|
|
272
|
+
const saved = this.persistImage(runId, state, event.mediaType, event.data);
|
|
273
|
+
if (saved)
|
|
274
|
+
this.store.appendEvent(runId, { type: 'image', stepId, ...saved });
|
|
275
|
+
return;
|
|
276
|
+
}
|
|
147
277
|
this.store.appendEvent(runId, { ...event, stepId });
|
|
148
278
|
if (event.type === 'token-usage') {
|
|
149
279
|
this.store.updateStep(runId, stepId, { tokensUsed: event.tokensUsed });
|
|
@@ -152,17 +282,24 @@ export class RunManager {
|
|
|
152
282
|
stepCost += event.usd;
|
|
153
283
|
this.store.updateStep(runId, stepId, { costUsd: stepCost });
|
|
154
284
|
}
|
|
155
|
-
if (event.type === 'turn-end'
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
285
|
+
if (event.type === 'turn-end') {
|
|
286
|
+
const waiting = !state.cancelled && state.session?.open;
|
|
287
|
+
if (waiting) {
|
|
288
|
+
this.store.updateRun(runId, { status: 'waiting' });
|
|
289
|
+
this.store.updateStep(runId, stepId, { status: 'waiting' });
|
|
290
|
+
this.armIdleTimer(runId, state);
|
|
291
|
+
}
|
|
292
|
+
appendHandoffHeartbeat(this.dataDir, runId, `turn complete — status=${waiting ? 'waiting' : 'running'}`);
|
|
159
293
|
}
|
|
160
294
|
};
|
|
161
295
|
const runner = new ClaudeCliRunner();
|
|
162
296
|
const session = runner.startSession({
|
|
297
|
+
systemPrompt: HANDOFF_INSTRUCTIONS,
|
|
163
298
|
userPrompt: prompt,
|
|
164
|
-
cwd:
|
|
299
|
+
cwd: state.cwd,
|
|
165
300
|
allowedTools: DEFAULT_ALLOWED_TOOLS,
|
|
301
|
+
additionalDirectories: [join(this.dataDir, 'runs')],
|
|
302
|
+
env: this.agentEnv(runId),
|
|
166
303
|
sessionId,
|
|
167
304
|
resume: true,
|
|
168
305
|
timeoutMs: 0,
|
|
@@ -177,17 +314,19 @@ export class RunManager {
|
|
|
177
314
|
this.store.updateStep(runId, stepId, { status: 'cancelled', finishedAt: finishedAt() });
|
|
178
315
|
this.store.updateRun(runId, { status: 'cancelled', finishedAt: finishedAt(), currentStepId: undefined });
|
|
179
316
|
this.store.appendEvent(runId, { type: 'lifecycle', message: 'run cancelled' });
|
|
317
|
+
appendHandoffHeartbeat(this.dataDir, runId, `step "${stepId}" complete — status=cancelled`);
|
|
180
318
|
}
|
|
181
319
|
else {
|
|
182
320
|
this.store.updateStep(runId, stepId, { status: 'done', finishedAt: finishedAt() });
|
|
183
|
-
this.store.updateRun(runId, { status: 'done', finishedAt: finishedAt(), currentStepId: undefined });
|
|
184
321
|
this.store.appendEvent(runId, { type: 'step-end', stepId, status: 'done' });
|
|
185
|
-
this.
|
|
322
|
+
await this.settleSuccess(runId);
|
|
323
|
+
appendHandoffHeartbeat(this.dataDir, runId, `step "${stepId}" complete — status=done`);
|
|
186
324
|
}
|
|
187
325
|
}
|
|
188
326
|
catch (err) {
|
|
189
327
|
const message = err instanceof Error ? err.message : String(err);
|
|
190
328
|
this.store.updateStep(runId, stepId, { status: 'failed', error: message, finishedAt: finishedAt() });
|
|
329
|
+
appendHandoffHeartbeat(this.dataDir, runId, `step "${stepId}" complete — status=failed`);
|
|
191
330
|
this.store.updateRun(runId, {
|
|
192
331
|
status: 'failed',
|
|
193
332
|
error: `continue failed: ${message}`,
|
|
@@ -198,20 +337,57 @@ export class RunManager {
|
|
|
198
337
|
}
|
|
199
338
|
finally {
|
|
200
339
|
this.clearIdleTimer(state);
|
|
340
|
+
this.clearAutosaveTimer(state);
|
|
341
|
+
if (state.cwd !== this.repoRoot)
|
|
342
|
+
await autosaveCommit(state.cwd);
|
|
201
343
|
this.active.delete(runId);
|
|
344
|
+
void this.pump();
|
|
202
345
|
}
|
|
203
346
|
}
|
|
204
347
|
// ---- execution -----------------------------------------------------------
|
|
205
348
|
async execute(runId, workflow, input) {
|
|
206
|
-
const state = { cancelled: false, interrupt: () => undefined };
|
|
349
|
+
const state = { cancelled: false, interrupt: () => undefined, cwd: this.repoRoot };
|
|
207
350
|
this.active.set(runId, state);
|
|
351
|
+
this.starting.delete(runId);
|
|
208
352
|
const emit = (event) => this.store.appendEvent(runId, event);
|
|
209
353
|
this.store.updateRun(runId, { status: 'running', startedAt: new Date().toISOString() });
|
|
210
354
|
emit({ type: 'lifecycle', message: `run started — workflow "${workflow.name}"` });
|
|
355
|
+
// Worktree per task (spec 006): the agent works on its own branch in
|
|
356
|
+
// `.ai/cezar/worktrees/<id>`, never in the user's working tree. Not a git
|
|
357
|
+
// repo, or worktree creation failed → degrade to running in place.
|
|
358
|
+
const repo = await getRepoInfo(this.repoRoot);
|
|
359
|
+
if (repo) {
|
|
360
|
+
try {
|
|
361
|
+
const wt = await createWorktree(this.repoRoot, runId, repo.branch);
|
|
362
|
+
state.cwd = wt.path;
|
|
363
|
+
this.store.updateRun(runId, {
|
|
364
|
+
worktreePath: wt.path,
|
|
365
|
+
branch: wt.branch,
|
|
366
|
+
baseBranch: wt.baseBranch,
|
|
367
|
+
});
|
|
368
|
+
emit({ type: 'note', message: `worktree ready — branch ${wt.branch} (base ${wt.baseBranch})` });
|
|
369
|
+
this.armAutosave(state);
|
|
370
|
+
}
|
|
371
|
+
catch (err) {
|
|
372
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
373
|
+
emit({ type: 'note', message: `worktree creation failed (${message}) — running in the repo working tree` });
|
|
374
|
+
}
|
|
375
|
+
}
|
|
376
|
+
else {
|
|
377
|
+
emit({ type: 'note', message: 'not a git repository — running in place, one task at a time' });
|
|
378
|
+
}
|
|
379
|
+
// Handoff journal (spec 007) — seeded after the worktree exists so the
|
|
380
|
+
// header can name the branch. Idempotent: an existing file stays as-is.
|
|
381
|
+
const seeded = this.store.getRun(runId);
|
|
382
|
+
if (seeded)
|
|
383
|
+
seedHandoffFile(this.dataDir, seeded);
|
|
211
384
|
const skills = await discoverSkills(this.repoRoot);
|
|
212
385
|
const retriesUsed = new Map();
|
|
213
386
|
let checkFailure = null;
|
|
214
387
|
let runError = null;
|
|
388
|
+
// Task screenshots go with the FIRST agent step's opening message only —
|
|
389
|
+
// later steps and retry loops run in fresh sessions without them.
|
|
390
|
+
let startImages = input.images;
|
|
215
391
|
const lastAgentIdx = findLastAgentStepIndex(workflow);
|
|
216
392
|
let i = 0;
|
|
217
393
|
while (i < workflow.steps.length) {
|
|
@@ -233,7 +409,8 @@ export class RunManager {
|
|
|
233
409
|
// The last agent step of the workflow is interactive: after its turn
|
|
234
410
|
// the session stays open for follow-ups until finish/idle/cancel.
|
|
235
411
|
const interactive = i === lastAgentIdx && i === workflow.steps.length - 1;
|
|
236
|
-
const failure = await this.runAgentStep(runId, state, step, input, skills, checkFailure, interactive, emit);
|
|
412
|
+
const failure = await this.runAgentStep(runId, state, step, input, skills, checkFailure, interactive, emit, startImages);
|
|
413
|
+
startImages = undefined;
|
|
237
414
|
checkFailure = null;
|
|
238
415
|
if (state.cancelled)
|
|
239
416
|
break;
|
|
@@ -277,6 +454,10 @@ export class RunManager {
|
|
|
277
454
|
runError = `check "${step.id}" failed${step.onFail ? ` after ${used + 1} attempts` : ''}`;
|
|
278
455
|
break;
|
|
279
456
|
}
|
|
457
|
+
// Final autosave: the branch always ends holding the finished state.
|
|
458
|
+
this.clearAutosaveTimer(state);
|
|
459
|
+
if (state.cwd !== this.repoRoot)
|
|
460
|
+
await autosaveCommit(state.cwd);
|
|
280
461
|
const finishedAt = new Date().toISOString();
|
|
281
462
|
if (state.cancelled) {
|
|
282
463
|
const run = this.store.getRun(runId);
|
|
@@ -293,25 +474,39 @@ export class RunManager {
|
|
|
293
474
|
emit({ type: 'lifecycle', message: `run failed — ${runError}` });
|
|
294
475
|
}
|
|
295
476
|
else {
|
|
296
|
-
this.
|
|
297
|
-
emit({ type: 'lifecycle', message: 'run finished' });
|
|
477
|
+
await this.settleSuccess(runId);
|
|
298
478
|
}
|
|
299
479
|
this.clearIdleTimer(state);
|
|
300
480
|
this.active.delete(runId);
|
|
481
|
+
void this.pump();
|
|
301
482
|
}
|
|
302
483
|
/** Returns an error message, or null on success. */
|
|
303
|
-
async runAgentStep(runId, state, step, input, skills, checkFailure, interactive, emit) {
|
|
484
|
+
async runAgentStep(runId, state, step, input, skills, checkFailure, interactive, emit, images) {
|
|
304
485
|
let systemPrompt;
|
|
305
486
|
if (step.skill) {
|
|
306
487
|
const skill = skills.find((s) => s.name === step.skill);
|
|
307
488
|
if (skill) {
|
|
308
489
|
systemPrompt = skill.body.trim();
|
|
490
|
+
// Directory team skills (SKILL.md + references/) get materialized
|
|
491
|
+
// into <cwd>/.claude/skills/<name>/ — the run's worktree when there
|
|
492
|
+
// is one — so claude sees the companion files on disk; the shared
|
|
493
|
+
// info/exclude keeps them out of git (and out of autosave commits).
|
|
494
|
+
if (skill.source === 'team' && skill.team?.dir) {
|
|
495
|
+
const seeded = await materializeSkillDir(state.cwd, skill).catch(() => false);
|
|
496
|
+
if (seeded) {
|
|
497
|
+
emit({
|
|
498
|
+
type: 'note',
|
|
499
|
+
stepId: step.id,
|
|
500
|
+
message: `team skill "${skill.name}" materialized to .claude/skills/${skill.name}/`,
|
|
501
|
+
});
|
|
502
|
+
}
|
|
503
|
+
}
|
|
309
504
|
}
|
|
310
505
|
else {
|
|
311
506
|
emit({
|
|
312
507
|
type: 'note',
|
|
313
508
|
stepId: step.id,
|
|
314
|
-
message: `skill "${step.skill}" not found in .ai/cezar/skills
|
|
509
|
+
message: `skill "${step.skill}" not found in .ai/cezar/skills, .ai/skills or the team skills repo — running with the plain prompt`,
|
|
315
510
|
});
|
|
316
511
|
}
|
|
317
512
|
}
|
|
@@ -319,12 +514,25 @@ export class RunManager {
|
|
|
319
514
|
if (checkFailure) {
|
|
320
515
|
userPrompt += `\n\nA verification command failed after the previous attempt. Fix the cause. Failing output:\n\n${checkFailure}`;
|
|
321
516
|
}
|
|
517
|
+
if (images?.length) {
|
|
518
|
+
emit({
|
|
519
|
+
type: 'note',
|
|
520
|
+
stepId: step.id,
|
|
521
|
+
message: `${images.length} screenshot${images.length > 1 ? 's' : ''} attached to the task`,
|
|
522
|
+
});
|
|
523
|
+
}
|
|
322
524
|
const sessionId = randomUUID();
|
|
323
525
|
this.store.updateStep(runId, step.id, { sessionId });
|
|
324
526
|
const stepRecord = this.store.getRun(runId)?.steps.find((s) => s.id === step.id);
|
|
325
527
|
const startTokens = stepRecord?.tokensUsed ?? 0;
|
|
326
528
|
let stepCost = stepRecord?.costUsd ?? 0;
|
|
327
529
|
const onEvent = (event) => {
|
|
530
|
+
if (event.type === 'image') {
|
|
531
|
+
const saved = this.persistImage(runId, state, event.mediaType, event.data);
|
|
532
|
+
if (saved)
|
|
533
|
+
emit({ type: 'image', stepId: step.id, ...saved });
|
|
534
|
+
return;
|
|
535
|
+
}
|
|
328
536
|
emit({ ...event, stepId: step.id });
|
|
329
537
|
if (event.type === 'token-usage') {
|
|
330
538
|
this.store.updateStep(runId, step.id, { tokensUsed: startTokens + event.tokensUsed });
|
|
@@ -333,22 +541,35 @@ export class RunManager {
|
|
|
333
541
|
stepCost += event.usd;
|
|
334
542
|
this.store.updateStep(runId, step.id, { costUsd: stepCost });
|
|
335
543
|
}
|
|
336
|
-
if (event.type === 'turn-end'
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
544
|
+
if (event.type === 'turn-end') {
|
|
545
|
+
const waiting = interactive && !state.cancelled && state.session?.open;
|
|
546
|
+
if (waiting) {
|
|
547
|
+
// Turn over, session open: the ball is in the user's court.
|
|
548
|
+
this.store.updateRun(runId, { status: 'waiting' });
|
|
549
|
+
this.store.updateStep(runId, step.id, { status: 'waiting' });
|
|
550
|
+
this.armIdleTimer(runId, state);
|
|
551
|
+
}
|
|
552
|
+
// Cez's own heartbeat — the handoff stays current even when the
|
|
553
|
+
// agent forgets to write (spec 007).
|
|
554
|
+
appendHandoffHeartbeat(this.dataDir, runId, `turn complete — status=${waiting ? 'waiting' : 'running'}`);
|
|
341
555
|
}
|
|
342
556
|
};
|
|
343
557
|
const runner = new ClaudeCliRunner();
|
|
344
558
|
let session;
|
|
345
559
|
try {
|
|
346
560
|
session = runner.startSession({
|
|
347
|
-
|
|
561
|
+
// The handoff/todos contract rides along on every agent step.
|
|
562
|
+
systemPrompt: systemPrompt
|
|
563
|
+
? `${systemPrompt}\n\n---\n\n${HANDOFF_INSTRUCTIONS}`
|
|
564
|
+
: HANDOFF_INSTRUCTIONS,
|
|
348
565
|
userPrompt,
|
|
349
|
-
|
|
566
|
+
images,
|
|
567
|
+
cwd: state.cwd,
|
|
350
568
|
allowedTools: step.allowedTools ?? DEFAULT_ALLOWED_TOOLS,
|
|
351
569
|
bashAllowlist: step.bashAllowlist,
|
|
570
|
+
// The handoff file lives outside the worktree — grant access.
|
|
571
|
+
additionalDirectories: [join(this.dataDir, 'runs')],
|
|
572
|
+
env: this.agentEnv(runId),
|
|
352
573
|
model: step.model ?? input.model,
|
|
353
574
|
sessionId,
|
|
354
575
|
// Interactive sessions have no wall clock — the idle timer rules.
|
|
@@ -376,6 +597,57 @@ export class RunManager {
|
|
|
376
597
|
state.interrupt = () => undefined;
|
|
377
598
|
}
|
|
378
599
|
}
|
|
600
|
+
/**
|
|
601
|
+
* Diff-first review gate (spec 009), shared by `execute` and
|
|
602
|
+
* `runContinuation`: a *successful* run whose worktree holds changes rests
|
|
603
|
+
* at `review` instead of `done` — the user inspects the diff first, then
|
|
604
|
+
* sends feedback back, opens a draft PR, or just finishes. Failed/cancelled
|
|
605
|
+
* runs never enter review; no worktree or an empty diff means plain `done`.
|
|
606
|
+
*/
|
|
607
|
+
async settleSuccess(runId) {
|
|
608
|
+
const run = this.store.getRun(runId);
|
|
609
|
+
let review = false;
|
|
610
|
+
if (run?.worktreePath && existsSync(run.worktreePath)) {
|
|
611
|
+
const diff = await worktreeDiff(run.worktreePath, run.baseBranch ?? 'HEAD');
|
|
612
|
+
review = diff.trim().length > 0 && !diff.startsWith('(diff failed');
|
|
613
|
+
}
|
|
614
|
+
this.store.updateRun(runId, {
|
|
615
|
+
status: review ? 'review' : 'done',
|
|
616
|
+
finishedAt: new Date().toISOString(),
|
|
617
|
+
currentStepId: undefined,
|
|
618
|
+
});
|
|
619
|
+
this.store.appendEvent(runId, {
|
|
620
|
+
type: 'lifecycle',
|
|
621
|
+
message: review
|
|
622
|
+
? 'changes ready for review — send feedback, open a draft PR, or finish'
|
|
623
|
+
: 'run finished',
|
|
624
|
+
});
|
|
625
|
+
}
|
|
626
|
+
/**
|
|
627
|
+
* Agent screenshot (an image block inside a tool result): the base64 data
|
|
628
|
+
* never enters the NDJSON event log — it lands as a file under
|
|
629
|
+
* `.ai/cezar/runs/<id>-images/` and the transcript event carries only the
|
|
630
|
+
* name + serving URL. Best effort: on failure the screenshot is dropped,
|
|
631
|
+
* the transcript still shows the tool result's `[screenshot]` placeholder.
|
|
632
|
+
*/
|
|
633
|
+
persistImage(runId, state, mediaType, data) {
|
|
634
|
+
try {
|
|
635
|
+
const ext = /png/.test(mediaType) ? 'png'
|
|
636
|
+
: /jpe?g/.test(mediaType) ? 'jpg'
|
|
637
|
+
: /webp/.test(mediaType) ? 'webp'
|
|
638
|
+
: /gif/.test(mediaType) ? 'gif'
|
|
639
|
+
: 'img';
|
|
640
|
+
state.imageSeq = (state.imageSeq ?? 0) + 1;
|
|
641
|
+
const name = `screenshot-${state.imageSeq}.${ext}`;
|
|
642
|
+
const dir = join(this.dataDir, 'runs', `${runId}-images`);
|
|
643
|
+
mkdirSync(dir, { recursive: true });
|
|
644
|
+
writeFileSync(join(dir, name), Buffer.from(data, 'base64'));
|
|
645
|
+
return { name, url: `/api/runs/${runId}/images/${name}` };
|
|
646
|
+
}
|
|
647
|
+
catch {
|
|
648
|
+
return null;
|
|
649
|
+
}
|
|
650
|
+
}
|
|
379
651
|
armIdleTimer(runId, state) {
|
|
380
652
|
this.clearIdleTimer(state);
|
|
381
653
|
state.idleTimer = setTimeout(() => {
|
|
@@ -395,11 +667,27 @@ export class RunManager {
|
|
|
395
667
|
state.idleTimer = undefined;
|
|
396
668
|
}
|
|
397
669
|
}
|
|
670
|
+
/** Autosave-commit the worktree every 90 s while the run lives (spec 006). */
|
|
671
|
+
armAutosave(state) {
|
|
672
|
+
if (state.cwd === this.repoRoot || state.autosaveTimer)
|
|
673
|
+
return;
|
|
674
|
+
state.autosaveTimer = setInterval(() => {
|
|
675
|
+
void autosaveCommit(state.cwd);
|
|
676
|
+
}, AUTOSAVE_INTERVAL_MS);
|
|
677
|
+
state.autosaveTimer.unref?.();
|
|
678
|
+
}
|
|
679
|
+
clearAutosaveTimer(state) {
|
|
680
|
+
if (state.autosaveTimer) {
|
|
681
|
+
clearInterval(state.autosaveTimer);
|
|
682
|
+
state.autosaveTimer = undefined;
|
|
683
|
+
}
|
|
684
|
+
}
|
|
398
685
|
runCheckStep(state, step, emit) {
|
|
399
686
|
const command = step.command;
|
|
400
687
|
emit({ type: 'note', stepId: step.id, message: `$ ${command}` });
|
|
401
688
|
return new Promise((resolve) => {
|
|
402
|
-
|
|
689
|
+
// Check steps run in the same cwd as the agent steps — the worktree.
|
|
690
|
+
const child = spawn('bash', ['-lc', command], { cwd: state.cwd, env: process.env });
|
|
403
691
|
state.interrupt = () => child.kill('SIGTERM');
|
|
404
692
|
let output = '';
|
|
405
693
|
const collect = (chunk) => {
|
|
@@ -432,6 +720,7 @@ export class RunManager {
|
|
|
432
720
|
finishedAt: new Date().toISOString(),
|
|
433
721
|
});
|
|
434
722
|
emit({ type: 'step-end', stepId, status, ...(error ? { error } : {}) });
|
|
723
|
+
appendHandoffHeartbeat(this.dataDir, runId, `step "${stepId}" complete — status=${status}`);
|
|
435
724
|
}
|
|
436
725
|
}
|
|
437
726
|
function findLastAgentStepIndex(workflow) {
|