flowviant 0.124.0 → 0.125.0

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 (63) hide show
  1. package/README.md +6 -0
  2. package/bin/cli.mjs +7 -0
  3. package/bin/lib/agentInternalWait.mjs +2 -1
  4. package/bin/lib/agentPlan.mjs +9 -0
  5. package/bin/lib/agentRepositories.mjs +48 -0
  6. package/bin/lib/agentTaskKinds.mjs +2 -0
  7. package/bin/lib/checkoutIdentity.mjs +25 -0
  8. package/bin/lib/claudeInput.mjs +32 -0
  9. package/bin/lib/claudeSessions.mjs +20 -0
  10. package/bin/lib/credentials.mjs +52 -17
  11. package/bin/lib/daemonLocks.mjs +21 -3
  12. package/bin/lib/fleet.mjs +19 -6
  13. package/bin/lib/fleetRoster.mjs +1 -89
  14. package/bin/lib/fleetStartup.mjs +11 -14
  15. package/bin/lib/holder.mjs +4 -37
  16. package/bin/lib/instance.mjs +20 -7
  17. package/bin/lib/landed.mjs +2 -2
  18. package/bin/lib/login.mjs +39 -4
  19. package/bin/lib/mergeBatch.mjs +172 -0
  20. package/bin/lib/placeLock.mjs +9 -7
  21. package/bin/lib/prWorkflow.mjs +15 -0
  22. package/bin/lib/projectLanded.mjs +16 -0
  23. package/bin/lib/projectRepositories.mjs +60 -0
  24. package/bin/lib/projectTools.mjs +2 -1
  25. package/bin/lib/projectWorktreeHome.mjs +25 -0
  26. package/bin/lib/prompts.mjs +128 -48
  27. package/bin/lib/repositoryDelivery.mjs +64 -0
  28. package/bin/lib/repositoryMerge.mjs +116 -0
  29. package/bin/lib/repositoryPlaces.mjs +179 -0
  30. package/bin/lib/repositorySetup.mjs +58 -0
  31. package/bin/lib/repositoryTool.mjs +71 -0
  32. package/bin/lib/runTurn.mjs +23 -2
  33. package/bin/lib/runtimeAntigravity.mjs +2 -1
  34. package/bin/lib/runtimeClaude.mjs +5 -2
  35. package/bin/lib/runtimeCodex.mjs +2 -1
  36. package/bin/lib/shipMerge.mjs +5 -2
  37. package/bin/lib/startProject.mjs +4 -4
  38. package/bin/lib/trace.mjs +8 -3
  39. package/bin/lib/turnDeclaration.mjs +88 -0
  40. package/bin/lib/turnLock.mjs +48 -8
  41. package/bin/lib/work.mjs +23 -24
  42. package/bin/lib/workAgentCheck.mjs +6 -5
  43. package/bin/lib/workAgentMerges.mjs +24 -4
  44. package/bin/lib/workAgentPrecheck.mjs +5 -5
  45. package/bin/lib/workAgentPublish.mjs +18 -5
  46. package/bin/lib/workAgentRecovery.mjs +85 -0
  47. package/bin/lib/workAgentReview.mjs +5 -3
  48. package/bin/lib/workAgentTurnBegun.mjs +6 -4
  49. package/bin/lib/workAgentTurnExecution.mjs +79 -9
  50. package/bin/lib/workAgentTurnOutcome.mjs +20 -5
  51. package/bin/lib/workAgentTurns.mjs +33 -9
  52. package/bin/lib/workCappedTurn.mjs +6 -4
  53. package/bin/lib/workDelivery.mjs +21 -0
  54. package/bin/lib/workDiffs.mjs +18 -11
  55. package/bin/lib/workLiveMessages.mjs +129 -0
  56. package/bin/lib/workMergeBatch.mjs +104 -0
  57. package/bin/lib/workPlaceReports.mjs +5 -0
  58. package/bin/lib/workPlaces.mjs +6 -1
  59. package/bin/lib/workRetire.mjs +5 -0
  60. package/bin/lib/workSessionTurns.mjs +72 -40
  61. package/bin/lib/workShip.mjs +17 -16
  62. package/package.json +1 -1
  63. package/npm-shrinkwrap.json +0 -69
@@ -1,3 +1,4 @@
1
+ import { finishRepositoryDelivery, allResolutionsCommitted } from './repositoryDelivery.mjs';
1
2
  /**
2
3
  * ONE AGENT TURN, RUN: the worktree, the CLI, and what it left behind.
3
4
  *
@@ -38,12 +39,17 @@ import { runTurnResumingOnce } from './resumeLost.mjs';
38
39
  import { INTERNAL_WAIT_CONTRACT, runAgentWithInternalWaits } from './agentInternalWait.mjs';
39
40
  import { CODEX_THREAD_RE } from './workSessionRuntime.mjs';
40
41
  import { brainFor } from './workBrain.mjs';
42
+ import { refreshRestartBase } from './workAgentRecovery.mjs';
41
43
  import { begunTurnRefusal } from './workAgentTurnBegun.mjs';
42
44
  import { agentTurnSettlement } from './workAgentTurnOutcome.mjs';
43
45
  import { ensureArtifactDir, ensureFenceScratch } from './artifacts.mjs';
44
46
  import { agentBrowserHome } from './noWindowEnv.mjs';
47
+ import { CLAUDE_SESSION_RE, RESUME_MARKERS } from './conversationIds.mjs';
48
+ import { createDeliveryReporter } from './workDelivery.mjs';
49
+ import { createLiveMessages } from './workLiveMessages.mjs';
45
50
 
46
51
  export function createAgentTurnExecution({
52
+ agentRepositories,
47
53
  makeBrief,
48
54
  baseRef,
49
55
  baseDir,
@@ -63,7 +69,9 @@ export function createAgentTurnExecution({
63
69
  postAgentActivity,
64
70
  postAgentParked,
65
71
  fetchAgentFiles,
72
+ makeLiveMessages = createLiveMessages,
66
73
  }) {
74
+ const reportDelivery = createDeliveryReporter();
67
75
  /** How long the final flush may hold the settle. Bounded because the settle
68
76
  * is the turn's contract and the trace is a readout: a wedged uplink costs
69
77
  * the tail of a trace, never the answer behind it. */
@@ -114,6 +122,11 @@ export function createAgentTurnExecution({
114
122
  /** `run` is the lane's record of this turn (`interrupted`, set by
115
123
  * `interruptAgentTurns`); absent, nothing can interrupt it. */
116
124
  const runAgentTurnInPlace = async (job, { turnId, agentId, place, releaseSlot, postAgentTurn, measured, run = { interrupted: false } }) => {
125
+ let repositoryRun = null;
126
+ const setupController = new AbortController();
127
+ run.abortSetup = () => setupController.abort();
128
+ if (run.interrupted) setupController.abort();
129
+ try {
117
130
  // WORK THAT HAS BEGUN LIVES ON EXACTLY ONE BOX — read BEFORE anything is
118
131
  // cut, because `placeWtFor` would cut a rival branch of the same name. The
119
132
  // rule and its three-state branch measurement: workAgentTurnBegun.mjs.
@@ -124,7 +137,10 @@ export function createAgentTurnExecution({
124
137
  return;
125
138
  }
126
139
  }
127
- const dir = placeWtFor(place);
140
+ if (!(await refreshRestartBase(job, repoRoot))) return;
141
+ repositoryRun = await agentRepositories?.prepare(job, place, { signal: setupController.signal });
142
+ measured.repositoryReceipts = repositoryRun?.receipts;
143
+ const dir = repositoryRun ?? placeWtFor(place);
128
144
  if (!dir) {
129
145
  // No worktree and none could be cut. `nothing`, WITH git's own words:
130
146
  // without them the agent sat in Stuck with no question and no reason
@@ -294,10 +310,13 @@ export function createAgentTurnExecution({
294
310
  }
295
311
  }
296
312
  let seenThreadId = null;
313
+ const claudeMarker = rt === 'claude' ? sessionMetaPath(wt, RESUME_MARKERS.claude, agentId) : null;
314
+ let claudeResumeId = null;
315
+ try { const id = readFileSync(claudeMarker, 'utf8').trim(); if (CLAUDE_SESSION_RE.test(id)) claudeResumeId = id; } catch { /* unknown */ }
297
316
  const resume =
298
317
  rt === 'codex'
299
318
  ? Boolean(codexResumeId)
300
- : rt === 'claude' && Boolean(ranMarker && existsSync(ranMarker));
319
+ : rt === 'claude' && (Boolean(claudeResumeId) || Boolean(ranMarker && existsSync(ranMarker)));
301
320
  /**
302
321
  * …AND THE HOME THAT THREAD IS FILED UNDER, KEPT PER AGENT (2026-09-29).
303
322
  *
@@ -454,7 +473,14 @@ export function createAgentTurnExecution({
454
473
  try {
455
474
  // A turn reads the base ref afresh. Changes the agent has made to its
456
475
  // own worktree cannot silently change the next turn's tools.
457
- projectTools = prepareAgentTools(readBaseTools(repoRoot, baseRef()), rt, process.env, wt, { codexHome: codexAgentHome });
476
+ const sources = repositoryRun?.sources ?? [];
477
+ const source = sources.find((r) => r.wt === wt);
478
+ const snapshot = readBaseTools(source?.repoRoot ?? repoRoot, source?.baseRef ?? baseRef());
479
+ for (const other of sources.filter((r) => r !== source)) {
480
+ const base = readBaseTools(other.repoRoot, other.baseRef);
481
+ snapshot.instructions.push(...[...base.instructions, ...base.skillFiles].map((file) => ({ ...file, path: `repositories/${other.folder}/${file.path}` })));
482
+ }
483
+ projectTools = prepareAgentTools(snapshot, rt, process.env, wt, { codexHome: codexAgentHome, turnServers: repositoryRun?.tool ? { card_repositories: repositoryRun.tool.config } : {} });
458
484
  const mountsProjectMcp = TURN_PROFILES[posture]?.projectMcp === true && (rt === 'claude' || rt === 'codex');
459
485
  // A fenced posture on a runner that ignores its own file there (Codex's
460
486
  // `--ignore-user-config`) takes the servers as `fencedMount` instead.
@@ -475,6 +501,16 @@ export function createAgentTurnExecution({
475
501
  unmounted: [...projectTools.mcpUnmounted.map((u) => ({ ...u, source: 'project' })), ...(fencedMount ? fencedMount.unmounted : [])],
476
502
  });
477
503
  const agentTurnArgs = {
504
+ ...(rt === 'claude' ? { liveMessages: (run.live = makeLiveMessages({
505
+ scope: 'agent', turnId, cwd: wt, sessionMetaPath, interrupted: () => run.interrupted,
506
+ format: async (followup) => {
507
+ const attached = followup.attachments?.length ? await fetchAgentFiles(wt, followup.attachments.map((file) => ({ ...file, from: 'message' }))) : { message: [], card: [] };
508
+ return AGENT_HUMAN_KICKOFF({ agentName: job.agentName, message: followup.body + (followup.libraryPrompt ?? ''),
509
+ askedByName: followup.askedByName, task: job.task, cardThread: job.cardThread,
510
+ investigationContext: job.investigationContext, researchContext: job.researchContext,
511
+ messageFiles: attached.message, cardFiles: files.card, position: job.position ?? 1, total: job.total ?? 1 });
512
+ },
513
+ })) } : {}),
478
514
  prompt:
479
515
  job.kind === 'task' && job.task
480
516
  ? AGENT_TASK_KICKOFF({
@@ -485,6 +521,7 @@ export function createAgentTurnExecution({
485
521
  // on it (0.112.0), after it, the same way.
486
522
  resumeBody: job.body,
487
523
  investigationContext: job.investigationContext,
524
+ researchContext: job.researchContext,
488
525
  cardThread: job.cardThread,
489
526
  cardFiles: files.card,
490
527
  position: job.position ?? 1,
@@ -496,6 +533,7 @@ export function createAgentTurnExecution({
496
533
  interrupted: job.interrupted === true,
497
534
  message: job.body ?? '',
498
535
  investigationContext: job.investigationContext,
536
+ researchContext: job.researchContext,
499
537
  askedByName: job.askedByName,
500
538
  task: job.task,
501
539
  // The discussion on the one card this message is about, in a
@@ -518,9 +556,10 @@ export function createAgentTurnExecution({
518
556
  // ARTIFACTS (0.94.0), while the server can show one — an agent's
519
557
  // land on its page, under the facts row.
520
558
  artifacts: getArtifactsAccepted(),
521
- }) + (toolsNote ? `\n\n${toolsNote}` : '') + (artifactInstructions ? `\n\n${artifactInstructions}` : '') + `\n\n${INTERNAL_WAIT_CONTRACT}` + (projectTools.instructions ? `\n\n${projectTools.instructions}` : ''),
559
+ }) + (toolsNote ? `\n\n${toolsNote}` : '') + (artifactInstructions ? `\n\n${artifactInstructions}` : '') + `\n\n${INTERNAL_WAIT_CONTRACT}` + (projectTools.instructions ? `\n\n${projectTools.instructions}` : '') + (repositoryRun ? `\n\n${repositoryRun.note}\nRepository command argv: ${JSON.stringify([repositoryRun.tool?.config.command, ...(repositoryRun.tool?.config.args.slice(0, -1) ?? []), "__repository", "<name>"])}` : ''),
522
560
  agentTools: projectTools,
523
- ...((projectTools.codexHome || fencedMount) ? { mcpEnv: { ...(projectTools.codexHome ? { CODEX_HOME: projectTools.codexHome } : {}), ...(fencedMount?.env ?? {}) } } : {}),
561
+ writableDirs: repositoryRun?.writableDirs,
562
+ ...((projectTools.codexHome || fencedMount || repositoryRun?.tool) ? { mcpEnv: { ...(projectTools.codexHome ? { CODEX_HOME: projectTools.codexHome } : {}), ...(fencedMount?.env ?? {}), ...(repositoryRun?.tool?.env ?? {}) } } : {}),
524
563
  // NOBODY IS AT THIS SCREEN (2026-09-29): no display, BROWSER=none, and
525
564
  // a browser profile of the agent's own, kept beside its Codex home —
526
565
  // so a Chrome it starts to look at its page never reaches the
@@ -542,7 +581,11 @@ export function createAgentTurnExecution({
542
581
  cwd: wt,
543
582
  runtime: rt,
544
583
  resume,
545
- resumeThreadId: codexResumeId || undefined,
584
+ resumeThreadId: codexResumeId || claudeResumeId || undefined,
585
+ onInit: (init) => {
586
+ if (claudeMarker && CLAUDE_SESSION_RE.test(init.sessionId ?? ''))
587
+ try { writeFileSync(claudeMarker, init.sessionId); } catch { /* best effort */ }
588
+ },
546
589
  onThreadId: (id) => {
547
590
  seenThreadId = String(id ?? '').trim() || seenThreadId;
548
591
  },
@@ -558,7 +601,7 @@ export function createAgentTurnExecution({
558
601
  // board past ~90 seconds.
559
602
  onActivity: (a) => {
560
603
  brief?.append(a?.kind ?? 'activity', a?.full ?? a?.label);
561
- const line = a?.label;
604
+ let line = a?.label;
562
605
  if (!line) return;
563
606
  // THE TRACE TAKES EVERY LINE; the pulse takes one every two
564
607
  // seconds. Two channels, one stream, and the drop-sampler stays a
@@ -573,7 +616,12 @@ export function createAgentTurnExecution({
573
616
  // daemon carried for all of them. The PULSE below is deliberately
574
617
  // still `line`: it is one overwritten line on a board, and a
575
618
  // paragraph there would be a paragraph nobody can read.
576
- if (!doubledKinds || !doubledKinds.has(a.kind)) trace.prose(a.kind, a.full ?? line);
619
+ if (!doubledKinds || !doubledKinds.has(a.kind)) {
620
+ const full = a.full ?? line;
621
+ const readable = trace.prose(a.kind, full);
622
+ if (readable === null) return;
623
+ if (readable !== full) line = envScrub(readable).replace(/\s+/g, ' ').trim().slice(0, 160);
624
+ }
577
625
  const now = Date.now();
578
626
  if (now - (lastAgentBeat.get(agentId) ?? 0) < 2_000) return;
579
627
  lastAgentBeat.set(agentId, now);
@@ -612,10 +660,12 @@ export function createAgentTurnExecution({
612
660
  // (runTurn.mjs, `initHarvestFor`). An agent-only box learns from the
613
661
  // startup probe and its Terminal tabs.
614
662
  onSpawn: (ch) => {
663
+ if (rt !== 'claude') reportDelivery('agent', job, 'taken');
615
664
  brief?.running(true);
616
665
  ch.once('close', () => { if (child === ch) brief?.running(false); });
617
666
  child = ch;
618
667
  spawnedChildren.add(ch);
668
+ measured.ranCli = true;
619
669
  // The AGENT it serves — what the machine snapshot charges this
620
670
  // child's memory to.
621
671
  workChildren.set(ch, agentId);
@@ -664,12 +714,30 @@ export function createAgentTurnExecution({
664
714
  void postAgentActivity(agentId, 'resuming after an internal wait');
665
715
  } }
666
716
  );
717
+ out = await finishRepositoryDelivery({
718
+ job, run: repositoryRun, out, answer: () => lastAnswer,
719
+ resume: async (prompt) => {
720
+ lastAnswer = null;
721
+ const counted = usage;
722
+ return runCli({
723
+ ...agentTurnArgs, prompt, liveContinuation: true,
724
+ onUsage: (next) => agentTurnArgs.onUsage(Object.fromEntries(
725
+ ['input', 'output', 'cacheCreate', 'cacheRead'].map((k) =>
726
+ [k, (counted?.[k] ?? 0) + (next?.[k] ?? 0)])
727
+ )),
728
+ writableDirs: repositoryRun.sourcesNow().filter((r) => r.wt !== wt).map((r) => r.wt),
729
+ resume: true,
730
+ resumeThreadId: seenThreadId ?? agentTurnArgs.resumeThreadId,
731
+ });
732
+ },
733
+ });
667
734
  } finally {
668
735
  await brief?.stop();
669
736
  // What the CLI counted, for the lane's catch if anything below throws.
670
737
  measured.usage = usage;
671
738
  measured.model = model;
672
739
  projectTools?.cleanup();
740
+
673
741
  /**
674
742
  * THE TAIL, BEFORE THE SETTLE — so the last thing the agent did is on
675
743
  * the record by the time the board is told the turn is over.
@@ -721,7 +789,7 @@ export function createAgentTurnExecution({
721
789
  }
722
790
 
723
791
 
724
- const commits = commitsBetween(wt, before);
792
+ const commits = repositoryRun ? [...new Set(repositoryRun.receipts().map((r) => r.sha))].slice(0, 50) : commitsBetween(wt, before);
725
793
  /**
726
794
  * A PERSON INTERRUPTED IT (Interrupt now). Whatever the CLI printed was
727
795
  * cut off, so there is no outcome to read: the turn reports `interrupted`
@@ -771,12 +839,14 @@ export function createAgentTurnExecution({
771
839
  // reported as `nothing` — it did not deliver and it did not ask.
772
840
  await postAgentParked(limit, rt);
773
841
  }
842
+ if (job.kind === 'merge_resolve' && repositoryRun) body.mergeResolved = allResolutionsCommitted(repositoryRun.sourcesNow());
774
843
  const reply = await postAgentTurn(body);
775
844
  // The queue just emptied. Run the project's own check and the AI
776
845
  // pre-review HERE, in the worktree we are already standing in and still
777
846
  // hold the lock on — see `runReviewEntry`. Only after the settle that may
778
847
  // say delivered: the three `nothing` settles never opened it.
779
848
  if (final && reply?.review === true) await runReviewEntry(agentId, wt, job.agentName);
849
+ } finally { await repositoryRun?.tool?.close(); }
780
850
  };
781
851
 
782
852
  return { runAgentTurnInPlace, commitsBetween };
@@ -26,6 +26,7 @@ import { parseTurnResult } from './agentPlan.mjs';
26
26
  import { changedArtifacts, scanArtifacts } from './artifacts.mjs';
27
27
  import { scrub as envScrub } from './uplinkScrub.mjs';
28
28
  import { usableImageArtifact } from './imageArtifact.mjs';
29
+ import { withoutTurnDeclaration } from './turnDeclaration.mjs';
29
30
 
30
31
  export function agentTurnSettlement({ turnId, job, kind, out, lastAnswer, usage, model = null, commits, artifactsBefore, branch, wt }) {
31
32
  const res = (lastAnswer && parseTurnResult(lastAnswer)) || parseTurnResult(out);
@@ -39,7 +40,9 @@ export function agentTurnSettlement({ turnId, job, kind, out, lastAnswer, usage,
39
40
  * makes the match mean what it says: the CLI failed and this is the
40
41
  * sentence it failed with.
41
42
  */
42
- const limit = res ? null : limitLine(out);
43
+ // An unreadable protocol object is not a native CLI diagnostic either.
44
+ const readableOut = res ? '' : withoutTurnDeclaration(out);
45
+ const limit = res ? null : limitLine(readableOut);
43
46
  /** What the caller posts, and whether it is the one settle that may say
44
47
  * delivered (the only one whose reply can open the review-entry beat).
45
48
  * Every one carries the model the CLI said it ran on, when it said
@@ -60,13 +63,16 @@ export function agentTurnSettlement({ turnId, job, kind, out, lastAnswer, usage,
60
63
  }
61
64
 
62
65
  if (!res) {
66
+ const raw = lastAnswer || out;
67
+ const readable = lastAnswer ? withoutTurnDeclaration(raw) : readableOut;
63
68
  return report({
64
69
  turnId,
65
70
  outcome: 'nothing',
66
71
  // The CLI's own words when it produced any. A turn that explained why
67
72
  // it stopped is far more use than "the agent stopped".
68
- answer: out.trim()
69
- ? envScrub(out).slice(-1500)
73
+ answer: raw.trim()
74
+ ? readable.trim() ? envScrub(readable).slice(-1500)
75
+ : 'The turn returned an unreadable outcome declaration. This work has not been delivered.'
70
76
  : 'the turn produced no output on the machine — its CLI may be signed out',
71
77
  ...(commits.length ? { commits } : {}),
72
78
  ...(usage ? { usage } : {}),
@@ -109,7 +115,15 @@ export function agentTurnSettlement({ turnId, job, kind, out, lastAnswer, usage,
109
115
  * Commits are still reported if any exist — the posture prevents them,
110
116
  * and a report never lies by omission about what is on the branch.
111
117
  */
112
- if (res.outcome === 'delivered' && kind.artifact) {
118
+ // Existing document deliveries default to the Markdown proof. Only an
119
+ // explicit temporary-use declaration can deliver through the stored answer.
120
+ const briefUse = /^Research intended use: (tasks|context|document)(?:\r?\n|$)/.exec(job.task?.brief ?? '')?.[1];
121
+ const researchUse = kind.posture === 'research' ? briefUse ?? res.researchUse : null;
122
+ const temporaryResearch = ['tasks', 'context'].includes(researchUse);
123
+ if (res.outcome === 'delivered' && temporaryResearch && (!res.answer?.trim() || res.answer.length > 7500)) {
124
+ return report({ turnId, outcome: 'nothing', answer: res.answer?.trim() ? 'Keep the research findings, sources and uncertainties within 7500 characters so the full answer can be retained.' : 'The research turn returned no findings.', branch, worktree: wt });
125
+ }
126
+ if (res.outcome === 'delivered' && kind.artifact && !temporaryResearch) {
113
127
  const want = kind.artifact.match;
114
128
  const has = (l) => l.some((e) => want.test(e.name)
115
129
  && (kind.posture !== 'image' || usableImageArtifact(e)));
@@ -139,7 +153,8 @@ export function agentTurnSettlement({ turnId, job, kind, out, lastAnswer, usage,
139
153
  turnId,
140
154
  ...(prerequisite && !cannotRequest ? { prerequisite } : {}),
141
155
  outcome: res.outcome,
142
- answer: cannotRequest ?? envScrub(res.answer ?? '').slice(0, 8000),
156
+ answer: cannotRequest ?? envScrub(kind.posture === 'research' && researchUse
157
+ ? `Research intended use: ${researchUse}\n\n${res.answer ?? ''}` : res.answer ?? '').slice(0, 8000),
143
158
  ...(commits.length ? { commits } : {}),
144
159
  // SCRUBBED like the answer beside it: a raised card lands in the
145
160
  // project doc every member reads, and a brief quoting the `.env` the
@@ -26,9 +26,11 @@ import { createAgentTurnReports } from './workAgentTurnReports.mjs';
26
26
  import { createAgentTurnExecution } from './workAgentTurnExecution.mjs';
27
27
  import { failedTurnSettlement } from './workAgentTurnOutcome.mjs';
28
28
  import { killGroup } from './processGroup.mjs';
29
+ import { createDeliveryReporter } from './workDelivery.mjs';
29
30
  import { createAgentBriefRunner } from './workAgentBriefRunner.mjs';
30
31
 
31
32
  export function createWorkAgentTurns({
33
+ agentRepositories,
32
34
  REJECT_RETRY_MS,
33
35
  baseRef,
34
36
  inPlace,
@@ -44,6 +46,7 @@ export function createWorkAgentTurns({
44
46
  artifacts,
45
47
  runReviewEntry,
46
48
  publishAgentBranch,
49
+ stopAgentPublishing = () => {},
47
50
  admit,
48
51
  workChildren,
49
52
  agentPublished,
@@ -52,6 +55,7 @@ export function createWorkAgentTurns({
52
55
  fetchAgentFiles,
53
56
  makeBrief = createAgentBriefRunner({ admit, workChildren }),
54
57
  }) {
58
+ const reportDelivery = createDeliveryReporter();
55
59
  const {
56
60
  postAgentTurn,
57
61
  postAgentTrace,
@@ -68,11 +72,9 @@ export function createWorkAgentTurns({
68
72
  // A TIME. The server types the next prompt when this one lands; this side
69
73
  // does the work and reports what happened.
70
74
  //
71
- // NO MCP ON THIS TURN AT ALL. Everything the agent needs to say fits in its
72
- // final JSON object, and everything it needs to PROVE is measured from git
73
- // here afterwards — an agent naming its own commit shas would be a receipt
74
- // pointing at whatever it liked. That is also what keeps this feature from
75
- // adding a token kind to a scope map that has silently shipped empty twice.
75
+ // Outcomes stay in the final JSON; commits are measured from git. The
76
+ // local card-repository tool is a turn-scoped capability, never a machine
77
+ // credential handed to the CLI.
76
78
  //
77
79
  // UNLEASED, unlike a plan or a kill. The server hands out at most one turn
78
80
  // per agent per poll and its settle is conditional on the row still being
@@ -110,6 +112,7 @@ export function createWorkAgentTurns({
110
112
  const stoppedFor = new Map(); // agentId -> when
111
113
 
112
114
  const { runAgentTurnInPlace, commitsBetween } = createAgentTurnExecution({
115
+ agentRepositories,
113
116
  makeBrief,
114
117
  baseRef,
115
118
  baseDir,
@@ -184,7 +187,8 @@ export function createWorkAgentTurns({
184
187
  let settled = false;
185
188
  const settleOnce = (body) => {
186
189
  settled = true;
187
- return postAgentTurn(body);
190
+ const liveMessages = agentRuns.get(turnId)?.live?.receipts();
191
+ return postAgentTurn({ ...body, ...(liveMessages?.ids.length ? { liveMessages } : {}) });
188
192
  };
189
193
 
190
194
  // A WRITER on the agent's own place — see the lane header. Only `a-<id>`
@@ -192,14 +196,15 @@ export function createWorkAgentTurns({
192
196
  // be a tab's directory, where a turn is a reader by the product's own law.
193
197
  const run = agentRuns.get(turnId) ?? { place, interrupted: false };
194
198
  await inPlace(place, place.startsWith('a-'), async () => {
199
+ run.waitingReason = null;
195
200
  try {
196
201
  await runAgentTurnInPlace(job, { turnId, agentId, place, releaseSlot, postAgentTurn: settleOnce, measured, run });
197
202
  if (run.interrupted && measured.ranCli) stoppedFor.set(agentId, Date.now());
198
203
  } catch (error) {
199
204
  if (settled) return;
200
- const commits = measured.wt ? commitsBetween(measured.wt, measured.before) : [];
205
+ const commits = measured.repositoryReceipts ? [...new Set(measured.repositoryReceipts().map((r) => r.sha))].slice(0, 50) : measured.wt ? commitsBetween(measured.wt, measured.before) : [];
201
206
  if (run.interrupted) {
202
- await postAgentTurn({ turnId, outcome: 'nothing', interrupted: true, ran: true, commits, ...(measured.usage ? { usage: measured.usage } : {}) });
207
+ await postAgentTurn({ turnId, outcome: 'nothing', interrupted: true, ran: measured.ranCli === true, commits, ...(measured.usage ? { usage: measured.usage } : {}) });
203
208
  return;
204
209
  }
205
210
  await postAgentTurn(
@@ -214,6 +219,9 @@ export function createWorkAgentTurns({
214
219
  })
215
220
  );
216
221
  }
222
+ }, () => {
223
+ run.waitingReason = 'waiting for this turn to finish';
224
+ reportDelivery('agent', job, 'queued', run.waitingReason);
217
225
  });
218
226
  /**
219
227
  * …AND THEN PUBLISH, IF THE PROJECT PUBLISHES.
@@ -266,7 +274,12 @@ export function createWorkAgentTurns({
266
274
  let saidPressure = false;
267
275
  for (const job of list.slice(0, 4)) {
268
276
  const id = String(job?.id || '');
269
- if (!id || agentTurns.has(id)) continue;
277
+ if (!id) continue;
278
+ if (agentTurns.has(id)) {
279
+ const reason = agentRuns.get(id)?.waitingReason;
280
+ if (reason) reportDelivery('agent', job, 'queued', reason);
281
+ continue;
282
+ }
270
283
  const held = agentReported.get(id);
271
284
  if (reportsOnly && !held) continue;
272
285
  // A body the server REFUSED waits out its backoff — still held, so the
@@ -321,8 +334,15 @@ export function createWorkAgentTurns({
321
334
  * would all be admitted against the same stale count. The reserved slot
322
335
  * is what the next iteration sees. See admission.mjs.
323
336
  */
337
+ const missing = agentRepositories?.missing(job);
338
+ if (missing) {
339
+ if (!saidPressure) { saidPressure = true; note(missing); }
340
+ void postAgentActivity(String(job.agentId), missing);
341
+ continue; // no CLI, no failed settle; login and the next poll remeasure
342
+ }
324
343
  const hold = admit('churn');
325
344
  if (hold) {
345
+ reportDelivery('agent', job, 'queued', hold.reason);
326
346
  if (!saidPressure) {
327
347
  saidPressure = true;
328
348
  note(`${c.cyan('agent')} ${c.dim(`— holding off: ${hold.reason}`)}`);
@@ -367,6 +387,9 @@ export function createWorkAgentTurns({
367
387
  * a wedged uplink must not hold the stand-down open.
368
388
  */
369
389
  const settleAgentTurns = async (sentence) => {
390
+ // Stand-down precedes flushing held replies; a late accepted reply must
391
+ // never push this displaced machine's work after the holder has moved.
392
+ stopAgentPublishing();
370
393
  const answer = String(sentence ?? '').slice(0, 1000);
371
394
  const posts = [];
372
395
  for (const turnId of agentTurns) {
@@ -393,6 +416,7 @@ export function createWorkAgentTurns({
393
416
  if (run) {
394
417
  if (!run.interrupted) note(`${c.cyan('agent')} ${c.dim('— interrupting the running turn')}`);
395
418
  run.interrupted = true;
419
+ run.abortSetup?.();
396
420
  const child = agentChildren.get(run.place);
397
421
  if (child) killGroup(child);
398
422
  continue;
@@ -18,9 +18,10 @@
18
18
  * doing both is the normal case (admission.mjs).
19
19
  * · THE CAP IS ARMED AT THE SPAWN, not at entry: time spent waiting for a
20
20
  * checkout lock is not a wedged CLI. It SIGKILLs the CHILD and never its
21
- * group — the rule teardown keeps: a read-only turn starts no server, so
21
+ * group by default — the rule teardown keeps: a read-only turn starts no server, so
22
22
  * there is nothing behind it worth signalling and everything to lose by
23
- * signalling somebody else's.
23
+ * signalling somebody else's. A build caller may supply its owned-process
24
+ * group killer because its turn can start tools.
24
25
  * · THE CAP RESOLVES THE WAIT ITSELF rather than waiting for `close` after
25
26
  * the kill: a SIGKILLed process whose stdio a grandchild still holds can be
26
27
  * slow to emit `close`, or never emit it, and the caller is holding a
@@ -61,6 +62,7 @@ export async function runCappedTurn({
61
62
  onInit = () => {},
62
63
  removeTranscript = removeTranscriptSoon,
63
64
  signal,
65
+ killChild,
64
66
  }) {
65
67
  // Wiring mistakes, refused before anything spawns: a hook inside turnArgs
66
68
  // would be silently replaced below, and a missing cap would fire at once.
@@ -77,7 +79,7 @@ export async function runCappedTurn({
77
79
  });
78
80
  const cancel = () => {
79
81
  wedged = true;
80
- try { child?.kill('SIGKILL'); } catch { /* already gone */ }
82
+ try { if (killChild) killChild(child); else child?.kill('SIGKILL'); } catch { /* already gone */ }
81
83
  stopWaiting('');
82
84
  };
83
85
  signal?.addEventListener('abort', cancel, { once: true });
@@ -96,7 +98,7 @@ export async function runCappedTurn({
96
98
  timer = setTimeout(() => {
97
99
  wedged = true;
98
100
  try {
99
- ch.kill('SIGKILL');
101
+ if (killChild) killChild(ch); else ch.kill('SIGKILL');
100
102
  } catch {
101
103
  /* already gone */
102
104
  }
@@ -0,0 +1,21 @@
1
+ /** A measured wait belongs to its message. Reports decorate the queue and
2
+ * never gate a turn; retries come with the next measurement/poll. */
3
+ import { DAEMON_INSTANCE, FLEET_URL } from './config.mjs';
4
+ import { fleetRequest } from './fleetPost.mjs';
5
+ import { fleetEndpoint } from './fleetWire.mjs';
6
+
7
+ export function createDeliveryReporter(request = fleetRequest, now = Date.now) {
8
+ const said = new Map();
9
+ return (scope, job, state, reason) => {
10
+ const key = `${scope}:${job.id}`;
11
+ const value = `${state}:${reason ?? ''}`;
12
+ const prior = said.get(key);
13
+ if (prior?.value === value && now() - prior.at < 10_000) return;
14
+ said.set(key, { value, at: now() });
15
+ // Bound a day-long daemon's decoration cache.
16
+ if (said.size > 500) said.delete(said.keys().next().value);
17
+ void request(fleetEndpoint('turn-delivery', FLEET_URL), { timeoutMs: 10_000,
18
+ body: { scope, turnId: job.id, instance: DAEMON_INSTANCE, ...(Number.isInteger(job.attempt) ? { attempt: job.attempt } : {}), state, ...(reason ? { reason } : {}) },
19
+ }).then(result => { if (!result.ok && said.get(key)?.value === value) said.delete(key); }).catch(() => { said.delete(key); });
20
+ };
21
+ }
@@ -1,5 +1,6 @@
1
1
  /** Commit diff requests and their bounded reports. */
2
2
  import { FLEET_URL, FLEET_TOKEN, USER_AGENT } from './config.mjs';
3
+ import { listStoredProjects } from './credentials.mjs';
3
4
  import { git, GIT_WIDE_BUFFER, OBJECT_ID_RE } from './git.mjs';
4
5
  import { fleetEndpoint } from './fleetWire.mjs';
5
6
  import { scrub, scrubCut } from './uplinkScrub.mjs';
@@ -21,7 +22,9 @@ function gitBig(args, cwd) {
21
22
  }
22
23
  }
23
24
 
24
- export function createWorkDiffs({ repoRoot }) {
25
+ export function createWorkDiffs({ repoRoot, repositories }) {
26
+ // The picker may have changed the active token since config was imported.
27
+ const roots = repositories ?? listStoredProjects().find((entry) => entry.fleetToken === FLEET_TOKEN)?.repositories ?? {};
25
28
  const DIFF_DONE_URL = fleetEndpoint('diff-done', FLEET_URL);
26
29
 
27
30
  /**
@@ -82,24 +85,28 @@ export function createWorkDiffs({ repoRoot }) {
82
85
  // In-flight guard, not a cache: the server stops offering a sha the
83
86
  // moment it settles, so this only stops the SAME poll's job being
84
87
  // started twice while its `git show` is still running.
85
- if (servedDiffs.has(sha)) continue;
86
- servedDiffs.add(sha);
88
+ const jobKey = `${job.repo ?? ''}:${sha}`;
89
+ if (servedDiffs.has(jobKey)) continue;
90
+ servedDiffs.add(jobKey);
91
+ const cwd = job.repo ? roots[job.repositoryName] : repoRoot;
92
+ const report = (body) => postDiff({ ...body, ...(job.repo ? { repo: job.repo } : {}) });
87
93
  void (async () => {
88
94
  try {
95
+ if (!cwd) { await report({ sha, error: 'This repository has no checkout on this machine. Run flowviant login in that checkout.' }); return; }
89
96
  let commit = '';
90
97
  try {
91
- commit = gitBig(['rev-parse', '--verify', '--quiet', `${sha}^{commit}`], repoRoot);
98
+ commit = gitBig(['rev-parse', '--verify', '--quiet', `${sha}^{commit}`], cwd);
92
99
  } catch (e) {
93
100
  // Exit 1 is `--verify --quiet`'s "not a commit here" (absent,
94
101
  // ambiguous, or another object type); anything else is git's own
95
102
  // failure, relayed in its words as before.
96
103
  if (e?.status !== 1) {
97
- await postDiff({ sha, error: scrubCut(e?.message || e, 500) });
104
+ await report({ sha, error: scrubCut(e?.message || e, 500) });
98
105
  return;
99
106
  }
100
107
  }
101
108
  if (!/^[0-9a-f]{40,64}$/.test(commit)) {
102
- await postDiff({ sha, error: 'no such commit on this machine' });
109
+ await report({ sha, error: 'no such commit on this machine' });
103
110
  return;
104
111
  }
105
112
  let patch = '';
@@ -109,13 +116,13 @@ export function createWorkDiffs({ repoRoot }) {
109
116
  // date already reached the card on the worktree sweep, and
110
117
  // repeating them inside the patch would put a second copy above
111
118
  // every hunk.
112
- patch = scrub(gitBig(['show', '--patch', '--format=', commit], repoRoot));
119
+ patch = scrub(gitBig(['show', '--patch', '--format=', commit], cwd));
113
120
  } catch (e) {
114
- await postDiff({ sha, error: scrubCut(e?.message || e, 500) });
121
+ await report({ sha, error: scrubCut(e?.message || e, 500) });
115
122
  return;
116
123
  }
117
124
  try {
118
- const raw = gitBig(['show', '--numstat', '--format=', commit], repoRoot);
125
+ const raw = gitBig(['show', '--numstat', '--format=', commit], cwd);
119
126
  for (const line of raw.split('\n')) {
120
127
  if (!line.trim()) continue;
121
128
  const [a, d, ...rest] = line.split('\t');
@@ -133,7 +140,7 @@ export function createWorkDiffs({ repoRoot }) {
133
140
  /* a header without counts still beats no diff */
134
141
  }
135
142
  const truncated = Buffer.byteLength(patch, 'utf8') > MAX_PATCH_BYTES;
136
- await postDiff({
143
+ await report({
137
144
  sha,
138
145
  // Cut on a LINE boundary: half a hunk header renders as garbage,
139
146
  // and the viewer says the patch was truncated either way.
@@ -144,7 +151,7 @@ export function createWorkDiffs({ repoRoot }) {
144
151
  truncated,
145
152
  });
146
153
  } finally {
147
- servedDiffs.delete(sha);
154
+ servedDiffs.delete(jobKey);
148
155
  }
149
156
  })();
150
157
  }