flowviant 0.125.7 → 0.125.9

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (55) hide show
  1. package/README.md +80 -1
  2. package/bin/cli.mjs +7 -1
  3. package/bin/lib/activityFeed.mjs +69 -0
  4. package/bin/lib/agentMergeReports.mjs +46 -0
  5. package/bin/lib/agentMergeResolution.mjs +44 -0
  6. package/bin/lib/agentPlan.mjs +6 -2
  7. package/bin/lib/claudePosture.mjs +21 -0
  8. package/bin/lib/claudeStream.mjs +25 -4
  9. package/bin/lib/codexResearch.mjs +33 -5
  10. package/bin/lib/commandSpecs.mjs +8 -5
  11. package/bin/lib/daemonJournal.mjs +102 -0
  12. package/bin/lib/daemonLogging.mjs +22 -18
  13. package/bin/lib/fleet.mjs +16 -6
  14. package/bin/lib/fleetReports.mjs +12 -5
  15. package/bin/lib/hooks/orchestrator.mjs +3 -1
  16. package/bin/lib/logReplay.mjs +133 -0
  17. package/bin/lib/nativeAgentTools.mjs +20 -3
  18. package/bin/lib/observedLines.mjs +63 -0
  19. package/bin/lib/orchestrator.mjs +40 -7
  20. package/bin/lib/orchestratorNative.mjs +133 -3
  21. package/bin/lib/orchestratorPrompt.mjs +50 -4
  22. package/bin/lib/postureTools.mjs +14 -6
  23. package/bin/lib/printable.mjs +1 -1
  24. package/bin/lib/repositoryMerge.mjs +1 -1
  25. package/bin/lib/researchHandoff.mjs +144 -0
  26. package/bin/lib/researchHandoffText.mjs +3 -0
  27. package/bin/lib/researchKnowledge.mjs +60 -0
  28. package/bin/lib/researchRestoration.mjs +38 -0
  29. package/bin/lib/runTurn.mjs +76 -43
  30. package/bin/lib/runtimeClaude.mjs +9 -4
  31. package/bin/lib/runtimeCodex.mjs +51 -22
  32. package/bin/lib/runtimeEvents.mjs +17 -7
  33. package/bin/lib/turnDiagnostics.mjs +2 -1
  34. package/bin/lib/turnOutput.mjs +63 -0
  35. package/bin/lib/turnRecord.mjs +11 -0
  36. package/bin/lib/turnSubagents.mjs +13 -3
  37. package/bin/lib/ui.mjs +2 -2
  38. package/bin/lib/uplinkScrub.mjs +49 -0
  39. package/bin/lib/views.mjs +49 -58
  40. package/bin/lib/wikiRunner.mjs +2 -2
  41. package/bin/lib/work.mjs +2 -2
  42. package/bin/lib/workActivity.mjs +65 -0
  43. package/bin/lib/workAgentBriefRunner.mjs +2 -2
  44. package/bin/lib/workAgentMerges.mjs +57 -52
  45. package/bin/lib/workAgentPlans.mjs +76 -27
  46. package/bin/lib/workAgentPrecheck.mjs +1 -1
  47. package/bin/lib/workAgentTurnExecution.mjs +42 -8
  48. package/bin/lib/workAgentTurnReports.mjs +17 -3
  49. package/bin/lib/workAgentTurnShape.mjs +6 -4
  50. package/bin/lib/workAgentTurns.mjs +8 -6
  51. package/bin/lib/workIntake.mjs +8 -2
  52. package/bin/lib/workMergeBatch.mjs +7 -1
  53. package/bin/lib/workPlanRecovery.mjs +50 -0
  54. package/bin/lib/workSessionTurns.mjs +5 -2
  55. package/package.json +1 -1
@@ -1,4 +1,7 @@
1
+ import { planWork, reportPlanOutcome } from './workActivity.mjs';
1
2
  /** Agent plan claims, read-only planning turns, and proposal reports. */
3
+ import { PLAN_RECOVERY_SYSTEM, parsePlanDiagnosis, watchPlanAttempt } from './workPlanRecovery.mjs';
4
+ import { readClaudeAuthStatus } from './runtimeAuth.mjs';
2
5
  import { discoveryGuide } from './discoveryContract.mjs';
3
6
  import { compareRepositoryBaselines, measureRepositoryBaseline, repositoryBaselineOf } from './repositoryBaseline.mjs';
4
7
  import { knowledgeDirFor } from './knowledgeFiles.mjs';
@@ -33,10 +36,12 @@ export function createWorkAgentPlans({
33
36
  runTurn = defaultRunTurn,
34
37
  pickRuntimeFor = defaultPickRuntimeFor,
35
38
  detectRuntimes = defaultDetectRuntimes,
39
+ authStatus = readClaudeAuthStatus,
36
40
  }) {
37
41
  const AGENT_PLAN_DONE_URL = fleetEndpoint('agent-plan-done', FLEET_URL);
38
42
  const AGENT_PLAN_ACTIVITY_URL = fleetEndpoint('agent-plan-activity', FLEET_URL);
39
43
  const AGENT_PLAN_CLAIM_URL = fleetEndpoint('agent-plan-claim', FLEET_URL);
44
+ const AGENT_PLAN_STATE_URL = fleetEndpoint('agent-plan-state', FLEET_URL);
40
45
 
41
46
  // ── AGENT PLAN JOBS: the Deploy press ──────────────────────────────────────
42
47
  //
@@ -60,13 +65,13 @@ export function createWorkAgentPlans({
60
65
  // tabs. It writes nothing, so a writer lock would only starve real work.
61
66
  const planning = new Set(); // press ids in flight on this tick
62
67
 
63
- const postAgentPlan = async (body) => {
68
+ const sendAgentPlan = async (body) => {
64
69
  // Bounded retry on 408/429/5xx, an other-4xx counted as delivered — see
65
70
  // postBestEffort's own docblock. Previously this swallowed every
66
71
  // response including ordinary 5xx and rate limits, so a transient
67
72
  // failure looked identical to a hostile refusal: nothing retried, and
68
73
  // the proposal sat unsettled until the server's own expiry.
69
- await postBestEffort(AGENT_PLAN_DONE_URL, { ...body, instance: DAEMON_INSTANCE });
74
+ return postBestEffort(AGENT_PLAN_DONE_URL, { ...body, instance: DAEMON_INSTANCE });
70
75
  };
71
76
 
72
77
  /**
@@ -90,7 +95,7 @@ export function createWorkAgentPlans({
90
95
  * turn it describes. A daemon that never calls this is a machine that has
91
96
  * not narrated, which is the only thing an absent line can mean.
92
97
  */
93
- const postAgentPlanActivity = async (planId, line) => {
98
+ const postAgentPlanActivity = async (planId, line, attempt, phase, diagnosis) => {
94
99
  const text = envScrub(String(line ?? '')).slice(0, 400);
95
100
  if (!text) return;
96
101
  try {
@@ -98,7 +103,7 @@ export function createWorkAgentPlans({
98
103
  method: 'POST',
99
104
  headers: fleetHeaders(),
100
105
  signal: AbortSignal.timeout(15_000),
101
- body: JSON.stringify({ planId, line: text }),
106
+ body: JSON.stringify({ planId, line: text, instance: DAEMON_INSTANCE, ...(attempt !== undefined ? { attempt } : {}), ...(phase ? { phase, diagnosis } : {}) }),
102
107
  });
103
108
  } catch {
104
109
  /* a readout — losing a line costs nothing */
@@ -134,7 +139,7 @@ export function createWorkAgentPlans({
134
139
  const PLAN_TURN_TIMEOUT_MS = 15 * 60_000;
135
140
 
136
141
  // The peer may hold it; doing nothing is the safe answer.
137
- const claimAgentPlan = (id) => claimFleetLease(AGENT_PLAN_CLAIM_URL, { id });
142
+ const claimAgentPlan = (id, attempt) => claimFleetLease(AGENT_PLAN_CLAIM_URL, { id, ...(attempt !== undefined ? { attempt } : {}) });
138
143
 
139
144
  /** See `runAgentTurn` — the admission reservation, released the moment the
140
145
  * planner's CLI exists. */
@@ -149,11 +154,27 @@ export function createWorkAgentPlans({
149
154
  // kill keeps (refuse cheaply, then claim) and it is because these two
150
155
  // lanes settle differently: a kill's settle does not check a lease for the
151
156
  // not-found case, and a plan's always does.
152
- if (!(await claimAgentPlan(id))) return;
157
+ if (!(await claimAgentPlan(id, job.attempt))) return;
158
+ const control = job.recoveryEnabled ? await watchPlanAttempt(AGENT_PLAN_STATE_URL, job) : null;
159
+ const report = async (body) => {
160
+ const result = { ...body, ...(job.attempt !== undefined ? { attempt: job.attempt } : {}) };
161
+ reportPlanOutcome(job, result);
162
+ // Keep the measured result while its acknowledgement is unknown. A
163
+ // network retry delivers this report, never another paid planning turn.
164
+ while (!control?.signal.aborted) {
165
+ const sent = await sendAgentPlan(result);
166
+ if (sent !== false || !job.recoveryEnabled) return;
167
+ await postAgentPlanActivity(id, 'The Plan result has not been acknowledged. Retrying delivery.', job.attempt);
168
+ await new Promise(resolve => setTimeout(resolve, 3000));
169
+ }
170
+ };
171
+ const unavailable = (reason) => report({ id, ...(job.recoveryEnabled ? { question: { question: `${reason}. Can you make that CLI available on the machine and tell me when it is ready?`, context: reason } } : { error: reason }) });
172
+ try {
173
+ if (control?.signal.aborted) return;
153
174
  if (tasks.length === 0) {
154
175
  // The press named cards the doc no longer has. A real answer, and one the
155
176
  // board can explain, rather than a turn that plans nothing.
156
- await postAgentPlan({ id, error: 'those cards are no longer on the board' });
177
+ await report({ id, error: 'those cards are no longer on the board' });
157
178
  return;
158
179
  }
159
180
 
@@ -172,23 +193,27 @@ export function createWorkAgentPlans({
172
193
  rt = pickedRuntime;
173
194
  const runtime = runtimeById(rt);
174
195
  if (!runtime) {
175
- await postAgentPlan({ id, error: `${rt} is not a CLI this machine knows` });
196
+ await unavailable(`${rt} is not a CLI this machine knows`);
176
197
  return;
177
198
  }
178
199
  const label = runtime.label;
179
200
  if (!detectRuntimes().find((r) => r.id === rt)?.installed) {
180
- await postAgentPlan({ id, error: `${label} is not installed on this machine` });
201
+ await unavailable(`${label} is not installed on this machine`);
181
202
  return;
182
203
  }
183
204
  if (!canRun(runtime, 'consult')) {
184
- await postAgentPlan({ id, error: `${label} can't run a read-only turn on this machine` });
205
+ await unavailable(`${label} can't run a read-only turn on this machine`);
185
206
  return;
186
207
  }
187
208
  } else {
188
209
  rt = pickRuntimeFor('consult');
189
210
  }
190
211
  if (!rt) {
191
- await postAgentPlan({ id, error: 'no CLI on this machine can run a read-only turn' });
212
+ await unavailable('No CLI on this machine can run a read-only turn');
213
+ return;
214
+ }
215
+ if (job.recoveryEnabled && job.recovery && rt === 'claude' && authStatus({ refresh: true }).signedIn === false) {
216
+ await report({ id, question: { question: 'Claude Code reports that it is signed out. Can you sign in on this machine and tell me when it is ready?', context: 'The CLI’s native auth status reported signed out.' } });
192
217
  return;
193
218
  }
194
219
 
@@ -204,7 +229,7 @@ export function createWorkAgentPlans({
204
229
  const now = Date.now();
205
230
  if (now - lastPlanBeat < 2_000) return;
206
231
  lastPlanBeat = now;
207
- void postAgentPlanActivity(id, line);
232
+ void postAgentPlanActivity(id, line, job.attempt);
208
233
  };
209
234
  /**
210
235
  * THE MACHINE'S OWN VOICE, for the moments only this side can see — the
@@ -219,10 +244,11 @@ export function createWorkAgentPlans({
219
244
  */
220
245
  const say = (line) => {
221
246
  lastPlanBeat = Date.now();
222
- void postAgentPlanActivity(id, line);
247
+ void postAgentPlanActivity(id, line, job.attempt);
223
248
  };
224
249
 
225
250
  let out = '';
251
+ let diagnosis = null;
226
252
  /** The cap fired: the CLI was still running when this machine stopped it. */
227
253
  let wedged = false;
228
254
  /**
@@ -234,7 +260,7 @@ export function createWorkAgentPlans({
234
260
  */
235
261
  let usage = null;
236
262
  const discovery = createToolLog(repoRoot);
237
- const spent = () => ({ ...(usage ? { usage } : {}), tools: discovery.toolLog });
263
+ const spent = () => ({ ...(usage ? { usage } : {}), tools: discovery.toolLog, ...(diagnosis ? { diagnosis: diagnosis.summary } : {}) });
238
264
  // Said BEFORE the lock is asked for, because a reader only waits when a
239
265
  // writer holds the checkout or is queued ahead of it — which is exactly
240
266
  // this condition, read one line before we join the queue.
@@ -261,20 +287,17 @@ export function createWorkAgentPlans({
261
287
  discovery.toolLog.baseline = measureRepositoryBaseline({ cwd: repoRoot, baseRef, knowledgeDir });
262
288
  discovery.toolLog.captureBaseline = repositoryBaselineOf(job.captureBaseline);
263
289
  discovery.toolLog.reconciliation = compareRepositoryBaselines(discovery.toolLog.baseline, discovery.toolLog.captureBaseline);
264
- ({ out, wedged } = await runCappedTurn({
290
+ const consult = async (prompt, system) => await runCappedTurn({
265
291
  runTurn,
292
+ signal: control?.signal,
266
293
  capMs: PLAN_TURN_TIMEOUT_MS,
267
294
  workChildren,
268
295
  releaseSlot,
269
296
  transcriptCwd: repoRoot,
270
297
  turnArgs: {
271
298
  diagnosticPlanId: id,
272
- prompt: AGENT_PLAN_KICKOFF({
273
- tasks,
274
- liveAgents,
275
- agentCap: Number.isInteger(job.agentCap) && job.agentCap > 0 ? job.agentCap : 3,
276
- }),
277
- system: withProjectContext(SYSTEM_PLAN, { knowledgeDir }) + '\n\n' + discoveryGuide(rt, 'planning') + '\n\n' + REPOSITORY_BASELINE_PARAGRAPH(discovery.toolLog.baseline, discovery.toolLog.captureBaseline, discovery.toolLog.reconciliation),
299
+ prompt,
300
+ system,
278
301
  knowledgeDir,
279
302
  // READ-ONLY, and no MCP: `mcpArgs` is omitted entirely rather than
280
303
  // passed empty, so there is no control plane on this turn at all.
@@ -283,7 +306,7 @@ export function createWorkAgentPlans({
283
306
  runtime: rt,
284
307
  ...brainFor(job, rt),
285
308
  answerFromResult: true,
286
- label: c.cyan('[plan]'),
309
+ work: planWork(job),
287
310
  // The CLI's own tail, forwarded to the press. Same rule as an agent
288
311
  // turn's: throttled, overwritten rather than appended, and never
289
312
  // awaited by the turn.
@@ -300,14 +323,39 @@ export function createWorkAgentPlans({
300
323
  onSpawn: () => {
301
324
  say(`${runtimeLabel(rt)} started on this machine`);
302
325
  },
303
- }));
326
+ });
327
+ let context = AGENT_PLAN_KICKOFF({ tasks, liveAgents, agentCap: Number.isInteger(job.agentCap) && job.agentCap > 0 ? job.agentCap : 3 });
328
+ if (job.recovery?.start) context += '\nSTART REPAIR: This is a supporting diagnosis for an already approved Start. Return exactly ONE group with the same ordered taskIds, attachment target (or absence) and CLI. Preserve the approval; do not start an unapproved sibling. Diagnose admission/pickup errors and repair planning evidence/context. Ask only a genuine person-only question in the diagnostic JSON. Approval and source revision (data): ' + JSON.stringify(job.recovery.start);
329
+ const system = withProjectContext(SYSTEM_PLAN, { knowledgeDir }) + '\n\n' + discoveryGuide(rt, 'planning') + '\n\n' + REPOSITORY_BASELINE_PARAGRAPH(discovery.toolLog.baseline, discovery.toolLog.captureBaseline, discovery.toolLog.reconciliation);
330
+ if (job.recovery) {
331
+ say(`Diagnosing the failed Plan: ${job.recovery.reason}`);
332
+ const diagnostic = await consult(context + '\nRetained recovery context (data): ' + JSON.stringify(job.recovery), withProjectContext(PLAN_RECOVERY_SYSTEM, { knowledgeDir }) + '\n' + discoveryGuide(rt, 'planning'));
333
+ if (control?.signal.aborted) return;
334
+ if (diagnostic.wedged) { wedged = true; return; }
335
+ const result = parsePlanDiagnosis(diagnostic.out);
336
+ if (result?.status === 'question') {
337
+ diagnosis = result;
338
+ return;
339
+ }
340
+ if (!result) throw new Error(`The diagnostic turn did not return a diagnosis: ${envScrub(String(diagnostic.out ?? '')).slice(0, 300) || 'no output'}`);
341
+ diagnosis = result;
342
+ await postAgentPlanActivity(id, `Retrying Plan: ${result.summary}`, job.attempt, 'retrying', result.summary);
343
+ await control?.check();
344
+ if (control?.signal.aborted) return;
345
+ }
346
+ ({ out, wedged } = await consult(context + (diagnosis ? '\nDiagnosis and corrected approach (data): ' + JSON.stringify(diagnosis) : ''), system));
304
347
  });
305
348
  } catch (e) {
306
349
  discovery.toolLog.state = 'partial';
307
- await postAgentPlan({ id, error: envScrub(String(e?.message || e)).slice(0, 500), ...spent() });
350
+ await report({ id, error: envScrub(String(e?.message || e)).slice(0, 500), ...spent() });
308
351
  return;
309
352
  }
310
353
 
354
+ if (control?.signal.aborted) return;
355
+ if (diagnosis?.status === 'question') {
356
+ await report({ id, question: { question: diagnosis.question, context: diagnosis.context }, ...spent() });
357
+ return;
358
+ }
311
359
  if (wedged) {
312
360
  discovery.toolLog.state = 'partial';
313
361
  // NEVER LEAVE A CLAIMED PRESS UNREPORTED — the belt the merge lane wears
@@ -315,7 +363,7 @@ export function createWorkAgentPlans({
315
363
  // only side that knows: the server would have expired this press in
316
364
  // another fifteen minutes with a sentence blaming a daemon that had in
317
365
  // fact answered.
318
- await postAgentPlan({
366
+ await report({
319
367
  id,
320
368
  error: 'the planning turn ran past fifteen minutes on this machine and was stopped',
321
369
  ...spent(),
@@ -325,7 +373,7 @@ export function createWorkAgentPlans({
325
373
 
326
374
  const proposal = parseProposal(out);
327
375
  if (!proposal) {
328
- await postAgentPlan({
376
+ await report({
329
377
  id,
330
378
  // The CLI's own words when it has any — a turn that explained why it
331
379
  // could not plan is far more use than "planning failed".
@@ -336,7 +384,8 @@ export function createWorkAgentPlans({
336
384
  });
337
385
  return;
338
386
  }
339
- await postAgentPlan({ id, proposal, sessionRef: repoRoot, ...spent() });
387
+ await report({ id, proposal, sessionRef: repoRoot, ...spent() });
388
+ } finally { control?.stop(); }
340
389
  };
341
390
 
342
391
  const processAgentPlanJobs = (jobs) => {
@@ -239,7 +239,7 @@ export function createWorkAgentPrecheck({ baseRef, admit, sessionMetaPath, workC
239
239
  // agent grading its own homework out of its own context; this is a
240
240
  // stranger reading a diff.
241
241
  answerFromResult: true,
242
- label: c.cyan('[pre-review]'),
242
+ work: { kind: 'pre-review', id: agentId, name: agentName },
243
243
  // The CLI's own count for this reading. Set, never accumulated: one
244
244
  // turn is one `result` event, and the adding-up is the server's.
245
245
  // Tagged with the CLI, so a Claude pre-review of a Codex agent is
@@ -1,3 +1,6 @@
1
+ import { agentWork } from './workActivity.mjs';
2
+ import { relayedText } from './prWorkflow.mjs';
3
+ import { measuredMergeResolution } from './agentMergeResolution.mjs';
1
4
  import { finishRepositoryDelivery, allResolutionsCommitted } from './repositoryDelivery.mjs';
2
5
  /**
3
6
  * ONE AGENT TURN, RUN: the worktree, the CLI, and what it left behind.
@@ -18,7 +21,7 @@ import { finishRepositoryDelivery, allResolutionsCommitted } from './repositoryD
18
21
  * answered. `measured` is the same bargain the other way: the branch facts and
19
22
  * the spend, written here as they are learned, read by that catch.
20
23
  */
21
- import { PROJECT_ID } from './config.mjs';
24
+ import { MODEL, PROJECT_ID } from './config.mjs';
22
25
  import { setRuntimeLimit } from './daemonState.mjs';
23
26
  import { gitOrNull } from './git.mjs';
24
27
  import { turnCommits } from './turnReceipts.mjs';
@@ -49,6 +52,8 @@ import { createDeliveryReporter } from './workDelivery.mjs';
49
52
  import { createLiveMessages } from './workLiveMessages.mjs';
50
53
  import { createOrchestrator } from './orchestrator.mjs';
51
54
  import { claudeOrchestrationSafe } from './orchestratorHooks.mjs';
55
+ import { ORCHESTRATED_POSTURES, orchestratesPosture } from './orchestratorPrompt.mjs';
56
+ import { knownCodexModels } from './runtimeOfferings.mjs';
52
57
  import { codexSubagentsSafe } from './runtimeCodex.mjs';
53
58
  import { measuredRuntimeVersion } from './runtimeDetection.mjs';
54
59
 
@@ -304,10 +309,12 @@ export function createAgentTurnExecution({
304
309
  }
305
310
 
306
311
  // ORCHESTRATED ONLY WHERE THE CLI CAN BE GATED (2026-10-17). v2 reaches every
307
- // job at the floor; a fenced card or a CLI below its measured floor (unknown
308
- // included) has no controller, so no marker: its notes fold on receipt.
312
+ // job at its kind's floor; an artifact card (design, model, image, deck) or
313
+ // a CLI below its measured floor (unknown included) has no controller, so
314
+ // no marker: its notes fold on receipt. Research is orchestrated too since
315
+ // 2026-10-19, with read-only children (orchestratorPrompt.mjs).
309
316
  const codexVersion = rt === 'codex' ? measuredRuntimeVersion('codex')?.version : undefined;
310
- const orchestrating = job.orchestrationVersion === 2 && posture === 'build' &&
317
+ const orchestrating = job.orchestrationVersion === 2 && orchestratesPosture(posture) &&
311
318
  (rt === 'claude' ? claudeOrchestrationSafe(measuredRuntimeVersion('claude')?.version) : rt === 'codex' && codexSubagentsSafe(codexVersion));
312
319
  /**
313
320
  * THE WHOLE STREAM, not just its latest line — see trace.mjs.
@@ -351,6 +358,10 @@ export function createAgentTurnExecution({
351
358
  * neither key.
352
359
  */
353
360
  const brain = brainFor(job, rt);
361
+ // The card's model and effort as the parent runs them, for an orchestrated
362
+ // run's children and contract: Claude always names --model (the daemon's
363
+ // pin when the card has none); Codex's unpinned default is its own.
364
+ const cardBrain = { model: brain.model ?? (rt === 'claude' ? MODEL : null), effort: brain.effort ?? null };
354
365
 
355
366
  let out = '';
356
367
  let child = null;
@@ -384,7 +395,7 @@ export function createAgentTurnExecution({
384
395
  /** THE MODEL THE TURN RAN ON, as its CLI named it (turnModel.mjs) — rides
385
396
  * every settle `usage` rides, for the same reason; null sends no key. */
386
397
  let model = null;
387
- const brief = makeBrief?.({ agentId, turnId, runtime: rt, model: () => model ?? job.model });
398
+ const brief = makeBrief?.({ agentId, turnId, workName: job.task?.title || job.agentName, runtime: rt, model: () => model ?? job.model });
388
399
  brief?.append('prompt', job.task ? { title: job.task.title, message: job.body, discussion: job.cardThread, brief: job.task.brief, criteria: job.task.criteria } : job.body);
389
400
  /**
390
401
  * THE AGENT'S LAST MESSAGE, ALONE — where the final JSON object lives.
@@ -404,6 +415,9 @@ export function createAgentTurnExecution({
404
415
  try {
405
416
  if (orchestrating) {
406
417
  orchestrator = await createOrchestrator({ turnId, runtime: rt, trace, admit,
418
+ // Codex's spawn takes only a catalog model (knownCodexModels, the
419
+ // measured models_cache.json rows the roster already reports).
420
+ children: { access: ORCHESTRATED_POSTURES[posture], ...cardBrain, ...(rt === 'codex' ? { catalog: knownCodexModels() } : {}) },
407
421
  // Whether the words reached the CLI; Codex reads its inbox from hooks.
408
422
  notify: message => liveInput ? run.live?.inject(message) === true : false,
409
423
  taken: id => run.live?.onTaken(id) });
@@ -424,8 +438,10 @@ export function createAgentTurnExecution({
424
438
  })) } : {}),
425
439
  // The kickoff and the system prompt, composed by the agent lane's
426
440
  // shape (workAgentTurnShape.mjs) from what this run measured.
441
+ researchHandoff: job.researchHandoff,
442
+ researchIdentity: { projectId: PROJECT_ID, agentId, turnId, taskId: job.task?.id ?? job.repositoryTaskId },
427
443
  prompt: agentKickoff(job, { cardFiles: files.card, messageFiles: files.message }),
428
- system: agentTurnSystem({ taskKind, runtimeLabel: runtimeById(rt)?.label, prerequisitesAccepted: job.prerequisitesAccepted === true, knowledgeDir: knowledgeDirFor(repoRoot), artifactsAccepted: getArtifactsAccepted(), toolsNote, artifactInstructions, toolInstructions: projectTools.instructions, repositoryRun, orchestrated: Boolean(orchestrator) }),
444
+ system: agentTurnSystem({ taskKind, runtimeLabel: runtimeById(rt)?.label, prerequisitesAccepted: job.prerequisitesAccepted === true, knowledgeDir: knowledgeDirFor(repoRoot), artifactsAccepted: getArtifactsAccepted(), toolsNote, artifactInstructions, toolInstructions: projectTools.instructions, repositoryRun, orchestration: orchestrator ? { posture, runtime: rt, ...cardBrain } : null }),
429
445
  agentTools: projectTools,
430
446
  writableDirs: repositoryRun?.writableDirs,
431
447
  ...((tools.envNeeded || repositoryRun?.tool || orchestrator) ? { mcpEnv: { ...tools.env, ...(repositoryRun?.tool?.env ?? {}), ...(orchestrator?.env ?? {}) } } : {}),
@@ -465,7 +481,7 @@ export function createAgentTurnExecution({
465
481
  // Present only when the container named one — see brainFor.
466
482
  ...brain,
467
483
  answerFromResult: true,
468
- label: c.cyan('[agent]'),
484
+ work: agentWork(job),
469
485
  // The CLI's own context measure while the turn runs (claudeInput.mjs),
470
486
  // the latest one at a time; the server clears it at turn end.
471
487
  onContext: (() => {
@@ -752,7 +768,25 @@ export function createAgentTurnExecution({
752
768
  // reported as `nothing` — it did not deliver and it did not ask.
753
769
  await postAgentParked(limit, rt);
754
770
  }
755
- if (job.kind === 'merge_resolve' && repositoryRun) body.mergeResolved = allResolutionsCommitted(repositoryRun.sourcesNow());
771
+ if (job.kind === 'merge_resolve') {
772
+ const resolution = () => repositoryRun
773
+ ? allResolutionsCommitted(repositoryRun.sourcesNow())
774
+ : measuredMergeResolution(wt, before, baseRef());
775
+ body.mergeResolved = resolution();
776
+ if (body.outcome === 'delivered' && body.mergeResolved) {
777
+ const rows = repositoryRun?.sourcesNow() ?? [{ wt }];
778
+ for (const r of rows) {
779
+ const head = gitOrNull(['rev-parse', 'HEAD'], r.wt);
780
+ const check = await runReviewEntry(agentId, r.wt, job.agentName, repositoryRun ? r : undefined);
781
+ if (check?.status === 'failed') {
782
+ body.mergeResolved = false;
783
+ body.mergeDetail = relayedText(check.text || check.output || 'The repository check failed during merge recovery.').slice(0, 2000);
784
+ }
785
+ if (!head || head !== gitOrNull(['rev-parse', 'HEAD'], r.wt)) body.mergeResolved = false;
786
+ }
787
+ body.mergeResolved = body.mergeResolved && resolution();
788
+ }
789
+ }
756
790
  const reply = await postAgentTurn(body);
757
791
  // The queue just emptied. Run the project's own check and the AI
758
792
  // pre-review HERE, in the worktree we are already standing in and still
@@ -14,11 +14,13 @@
14
14
  * Daemon→server reports, every one of them: no version floor, and a permanent
15
15
  * 4xx is the server saying it will never take the body (see each POST).
16
16
  */
17
+ import { join } from 'node:path';
18
+ import { createReportJournal } from './reportJournal.mjs';
17
19
  import { FLEET_URL } from './config.mjs';
18
20
  import { fleetEndpoint, isConsideredRefusal } from './fleetWire.mjs';
19
21
  import { fleetHeaders, postFleetJson } from './fleetPost.mjs';
20
22
 
21
- export function createAgentTurnReports({ REJECT_RETRY_MS }) {
23
+ export function createAgentTurnReports({ REJECT_RETRY_MS, baseDir = null }) {
22
24
  const AGENT_TURN_DONE_URL = fleetEndpoint('agent-turn-done', FLEET_URL);
23
25
  const AGENT_TRACE_URL = fleetEndpoint('agent-trace', FLEET_URL);
24
26
  const AGENT_ACTIVITY_URL = fleetEndpoint('agent-activity', FLEET_URL);
@@ -40,8 +42,17 @@ export function createAgentTurnReports({ REJECT_RETRY_MS }) {
40
42
  * BOUNDED by the roster itself: a held body's clock is refreshed while the
41
43
  * server keeps offering its turn, and once offering stops — settled by the
42
44
  * re-POST, or expired server-side — the grace below is all that keeps it.
45
+ *
46
+ * EVERY HELD BODY IS JOURNALED (2026-10-20), not only a merge recovery's.
47
+ * Any agent turn whose body was held in memory alone lost it to a restart
48
+ * between the answer and the server's 200, and the server's re-offer to the
49
+ * successor ran the turn again. The settle is bound to the machine
50
+ * credential, not the process, so the successor's re-POST lands it.
43
51
  */
44
- const agentReported = new Map(); // turnId -> { body, at }
52
+ const journal = baseDir ? createReportJournal(join(baseDir, 'agent-recovery-reports')) : null;
53
+ const agentReported = new Map((journal?.load().work ?? []).filter(([id, held]) =>
54
+ held?.body?.turnId === id && typeof held.at === 'number'));
55
+ const persistRecovery = () => journal?.save(new Map(agentReported), new Map()); // turnId -> { body, at }
45
56
  const agentRejectedUntil = new Map(); // turnId -> earliest re-POST of a refused body
46
57
  const AGENT_REPORT_GRACE_MS = 30 * 60_000;
47
58
 
@@ -53,6 +64,7 @@ export function createAgentTurnReports({ REJECT_RETRY_MS }) {
53
64
  const postAgentTurn = async (body) => {
54
65
  const turnId = String(body.turnId);
55
66
  agentReported.set(turnId, { body, at: Date.now() });
67
+ persistRecovery();
56
68
  try {
57
69
  const res = await postFleetJson(AGENT_TURN_DONE_URL, body, { timeoutMs: 30_000 });
58
70
  const j = await res.json().catch(() => null);
@@ -68,9 +80,10 @@ export function createAgentTurnReports({ REJECT_RETRY_MS }) {
68
80
  * class. So a refused body stays HELD — it is the skip-guard — and its
69
81
  * re-POST backs off instead of hammering a body the server just refused.
70
82
  */
71
- if (res.ok) {
83
+ if (res.ok && (body.mergeResolved === undefined || typeof j?.data?.settled === 'boolean')) {
72
84
  agentReported.delete(turnId);
73
85
  agentRejectedUntil.delete(turnId);
86
+ persistRecovery();
74
87
  } else if (isConsideredRefusal(res.status)) {
75
88
  agentRejectedUntil.set(turnId, Date.now() + REJECT_RETRY_MS);
76
89
  }
@@ -160,6 +173,7 @@ export function createAgentTurnReports({ REJECT_RETRY_MS }) {
160
173
  else if (now - held.at > AGENT_REPORT_GRACE_MS) {
161
174
  agentReported.delete(id);
162
175
  agentRejectedUntil.delete(id);
176
+ persistRecovery();
163
177
  }
164
178
  }
165
179
  };
@@ -24,7 +24,7 @@
24
24
  */
25
25
  import { AGENT_TASK_KICKOFF, AGENT_HUMAN_KICKOFF, SYSTEM_AGENT_FOR, withProjectContext } from './prompts.mjs';
26
26
  import { INTERNAL_WAIT_CONTRACT } from './agentInternalWait.mjs';
27
- import { ORCHESTRATOR_CONTRACT } from './orchestratorPrompt.mjs';
27
+ import { orchestratorContract } from './orchestratorPrompt.mjs';
28
28
 
29
29
  /** What every kickoff of this job is handed: the agent, the card and its
30
30
  * discussion, the job's context handoffs, the card's files, the place in
@@ -74,8 +74,10 @@ export const agentFollowupKickoff = (job, followup, { cardFiles, messageFiles, o
74
74
  messageFiles,
75
75
  });
76
76
 
77
- /** The turn's system prompt, in its fixed section order. */
78
- export function agentTurnSystem({ taskKind, runtimeLabel, prerequisitesAccepted, knowledgeDir, artifactsAccepted, toolsNote, artifactInstructions, toolInstructions, repositoryRun, orchestrated }) {
77
+ /** The turn's system prompt, in its fixed section order. `orchestration` is
78
+ * null on a solo run, else what the contract names: `{ posture, runtime,
79
+ * model, effort }` (orchestratorPrompt.mjs). */
80
+ export function agentTurnSystem({ taskKind, runtimeLabel, prerequisitesAccepted, knowledgeDir, artifactsAccepted, toolsNote, artifactInstructions, toolInstructions, repositoryRun, orchestration = null }) {
79
81
  // The knowledge paragraph, when this box holds a library — the same
80
82
  // composer the tabs use, so an agent and a tab can never be told two
81
83
  // different things about the same directory.
@@ -86,5 +88,5 @@ export function agentTurnSystem({ taskKind, runtimeLabel, prerequisitesAccepted,
86
88
  // ARTIFACTS (0.94.0), while the server can show one — an agent's
87
89
  // land on its page, under the facts row.
88
90
  artifacts: artifactsAccepted,
89
- }) + (toolsNote ? `\n\n${toolsNote}` : '') + (artifactInstructions ? `\n\n${artifactInstructions}` : '') + `\n\n${INTERNAL_WAIT_CONTRACT}` + (toolInstructions ? `\n\n${toolInstructions}` : '') + (repositoryRun ? `\n\n${repositoryRun.note}\nRepository command argv: ${JSON.stringify([repositoryRun.tool?.config.command, ...(repositoryRun.tool?.config.args.slice(0, -1) ?? []), "__repository", "<name>"])}` : '') + (orchestrated ? `\n\n${ORCHESTRATOR_CONTRACT}` : '');
91
+ }) + (toolsNote ? `\n\n${toolsNote}` : '') + (artifactInstructions ? `\n\n${artifactInstructions}` : '') + `\n\n${INTERNAL_WAIT_CONTRACT}` + (toolInstructions ? `\n\n${toolInstructions}` : '') + (repositoryRun ? `\n\n${repositoryRun.note}\nRepository command argv: ${JSON.stringify([repositoryRun.tool?.config.command, ...(repositoryRun.tool?.config.args.slice(0, -1) ?? []), "__repository", "<name>"])}` : '') + (orchestration ? `\n\n${orchestratorContract(orchestration)}` : '');
90
92
  }
@@ -1,3 +1,4 @@
1
+ import { reportAgentOutcome } from './workActivity.mjs';
1
2
  /**
2
3
  * THE AGENT-TURN LANE: which offered turns run on this machine, and that each
3
4
  * one is settled exactly once.
@@ -69,7 +70,7 @@ export function createWorkAgentTurns({
69
70
  pruneHeldReports,
70
71
  agentReported,
71
72
  agentRejectedUntil,
72
- } = createAgentTurnReports({ REJECT_RETRY_MS });
73
+ } = createAgentTurnReports({ REJECT_RETRY_MS, baseDir });
73
74
 
74
75
  // ── AGENT TURNS: one task per prompt ───────────────────────────────────────
75
76
  //
@@ -205,7 +206,8 @@ export function createWorkAgentTurns({
205
206
  };
206
207
  const settleOnce = (body) => {
207
208
  settled = true;
208
- return postAgentTurn(withLiveReceipts(body));
209
+ reportAgentOutcome(job, body);
210
+ return postAgentTurn(withLiveReceipts(job.kind === 'merge_resolve' ? { ...body, mergeResolved: body.mergeResolved ?? false } : body));
209
211
  };
210
212
 
211
213
  // A WRITER on the agent's own place — see the lane header. Only `a-<id>`
@@ -221,11 +223,11 @@ export function createWorkAgentTurns({
221
223
  if (settled) return;
222
224
  const commits = turnCommits({ repositoryReceipts: measured.repositoryReceipts, wt: measured.wt, before: measured.before, baseRef });
223
225
  if (run.interrupted) {
224
- await postAgentTurn(withLiveReceipts(interruptedTurnSettlement({ turnId, ran: measured.ranCli === true, commits, usage: measured.usage, model: measured.model, branch: measured.branch, wt: measured.wt })));
226
+ await settleOnce(interruptedTurnSettlement({ turnId, ran: measured.ranCli === true, commits, usage: measured.usage, model: measured.model, branch: measured.branch, wt: measured.wt }));
225
227
  return;
226
228
  }
227
- await postAgentTurn(
228
- withLiveReceipts(failedTurnSettlement({
229
+ await settleOnce(
230
+ failedTurnSettlement({
229
231
  turnId,
230
232
  error,
231
233
  commits,
@@ -233,7 +235,7 @@ export function createWorkAgentTurns({
233
235
  model: measured.model,
234
236
  branch: measured.branch,
235
237
  wt: measured.wt,
236
- }))
238
+ })
237
239
  );
238
240
  }
239
241
  }, () => {
@@ -1,3 +1,4 @@
1
+ import { reportWorkOutcome } from './workActivity.mjs';
1
2
  /** One leased incoming ticket, drafted by this machine's read-only CLI. */
2
3
  import { FLEET_URL, DAEMON_INSTANCE } from './config.mjs';
3
4
  import { fleetEndpoint } from './fleetWire.mjs';
@@ -39,12 +40,17 @@ export function createWorkIntake({
39
40
  const claim = (id) =>
40
41
  claimFleetLease(claiming, { id }, { won: (r) => r.status === 200 && r.json?.ok === true });
41
42
 
42
- const report = async (body) => {
43
+ const sendReport = async (body) => {
43
44
  await postBestEffort(done, { ...body, instance: DAEMON_INSTANCE });
44
45
  };
45
46
 
46
47
  const run = async (job, releaseSlot) => {
47
48
  const id = String(job.id);
49
+ const work = { kind: 'intake', id, name: job.title || 'Incoming ticket', turnId: id };
50
+ const report = body => {
51
+ reportWorkOutcome(work, { outcome: body.outcome === INTAKE_OUTCOMES.drafted ? 'drafted' : 'nothing', answer: body.error });
52
+ return sendReport(body);
53
+ };
48
54
  // No stand-down check here, as in the plan lane: a standing-down loop stops
49
55
  // processing the roster, and the one spawn gate is runTurn's own
50
56
  // (standDownGate.mjs; its pin keeps the askers few).
@@ -80,7 +86,7 @@ export function createWorkIntake({
80
86
  cwd: repoRoot,
81
87
  runtime,
82
88
  answerFromResult: true,
83
- label: c.cyan('[intake]'),
89
+ work,
84
90
  // There is no MCP principal for a ticket, and no model or effort pin:
85
91
  // this one-shot reader uses the machine's consult defaults.
86
92
  onUsage: (u) => { usage = addUsage(usage, { ...u, runtime }); },
@@ -1,3 +1,5 @@
1
+ import { reportWorkOutcome } from './workActivity.mjs';
2
+ import { createTurnOutput } from './turnOutput.mjs';
1
3
  /** The daemon holds every selected place while one personal CLI integrates it. */
2
4
  import { trackedRepositorySetup } from './repositorySetup.mjs';
3
5
  import { git } from './git.mjs';
@@ -25,6 +27,7 @@ export function createWorkMergeBatch({ repositoryPlaces, repoRoot, baseDir, inPl
25
27
  };
26
28
  return async (job) => {
27
29
  const spec = job.batch.spec;
30
+ const work = { kind: 'batch merge', id: spec.id, name: 'Selected merge', turnId: spec.id };
28
31
  const hold = admit?.('churn');
29
32
  if (hold) return;
30
33
  const release = admit?.reserve?.() ?? (() => {});
@@ -33,6 +36,7 @@ export function createWorkMergeBatch({ repositoryPlaces, repoRoot, baseDir, inPl
33
36
  const claim = await request('agent-merge-batch-claim', { batchId: spec.id });
34
37
  if (!claim?.claimed) return;
35
38
  claimed = true;
39
+ createTurnOutput({ work }).event('Merge started', 'start');
36
40
  if (spec.members.some((m) => !isSafePathSegment(m.placeId))) throw new Error('A selected agent has no valid worktree identity.');
37
41
  // Renewal covers a long coordinator or validation command, not just Git beats.
38
42
  timer = setInterval(() => { void request('agent-merge-batch-progress', { batchId: spec.id, progress: {} }).catch(() => {}); }, 60_000);
@@ -68,7 +72,7 @@ export function createWorkMergeBatch({ repositoryPlaces, repoRoot, baseDir, inPl
68
72
  turnArgs: { prompt: mergeBatchPrompt(context), system: MERGE_BATCH_SYSTEM,
69
73
  runtime, profile: 'build', knowledgeDir: knowledgeDirFor(repoRoot), model: leader.model || undefined, effort: leader.effort || undefined,
70
74
  cwd: context.repositories[0].integrationWorktree, writableDirs: context.repositories.filter((r) => !r.alreadyLanded).map((r) => r.integrationWorktree),
71
- answerFromResult: true, label: '[batch merge]' } });
75
+ answerFromResult: true, work } });
72
76
  if (result.wedged) throw new Error('The coordinating CLI ran out of time. Retry the batch to continue with its saved integration work.');
73
77
  return candidateObjects(String(result.out ?? '')).find((r) => r.status === 'delivered' || r.status === 'blocked');
74
78
  } finally { if (child) groupKillChildren?.delete(child); }
@@ -84,9 +88,11 @@ export function createWorkMergeBatch({ repositoryPlaces, repoRoot, baseDir, inPl
84
88
  reporting = true;
85
89
  const done = await request('agent-merge-batch-done', { batchId: spec.id, ok: true });
86
90
  if (!done?.settled) throw new Error('The combined merge result has not been acknowledged. Retry will remeasure base.');
91
+ reportWorkOutcome(work, { outcome: 'merged' });
87
92
  onRepoChanged(); landed.observe();
88
93
  });
89
94
  } catch (e) {
95
+ if (claimed) reportWorkOutcome(work, { outcome: 'nothing', answer: relayedText([e?.stdout, e?.stderr, e?.message].filter(Boolean).join('\n')) });
90
96
  // After a successful push/report attempt, leave the lease for replay: a transport
91
97
  // failure says nothing about whether the server committed the final receipt.
92
98
  if (claimed && !reporting) {