flowviant 0.124.0 → 0.124.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.
Files changed (59) 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 +8 -0
  5. package/bin/lib/agentRepositories.mjs +48 -0
  6. package/bin/lib/checkoutIdentity.mjs +25 -0
  7. package/bin/lib/claudeInput.mjs +32 -0
  8. package/bin/lib/claudeSessions.mjs +20 -0
  9. package/bin/lib/credentials.mjs +52 -17
  10. package/bin/lib/daemonLocks.mjs +21 -3
  11. package/bin/lib/fleet.mjs +19 -6
  12. package/bin/lib/fleetRoster.mjs +1 -89
  13. package/bin/lib/fleetStartup.mjs +11 -14
  14. package/bin/lib/holder.mjs +4 -37
  15. package/bin/lib/instance.mjs +20 -7
  16. package/bin/lib/landed.mjs +2 -2
  17. package/bin/lib/login.mjs +39 -4
  18. package/bin/lib/placeLock.mjs +9 -7
  19. package/bin/lib/prWorkflow.mjs +15 -0
  20. package/bin/lib/projectLanded.mjs +16 -0
  21. package/bin/lib/projectRepositories.mjs +60 -0
  22. package/bin/lib/projectTools.mjs +2 -1
  23. package/bin/lib/projectWorktreeHome.mjs +25 -0
  24. package/bin/lib/prompts.mjs +6 -1
  25. package/bin/lib/repositoryDelivery.mjs +64 -0
  26. package/bin/lib/repositoryMerge.mjs +116 -0
  27. package/bin/lib/repositoryPlaces.mjs +179 -0
  28. package/bin/lib/repositorySetup.mjs +58 -0
  29. package/bin/lib/repositoryTool.mjs +71 -0
  30. package/bin/lib/runTurn.mjs +23 -2
  31. package/bin/lib/runtimeAntigravity.mjs +2 -1
  32. package/bin/lib/runtimeClaude.mjs +5 -2
  33. package/bin/lib/runtimeCodex.mjs +2 -1
  34. package/bin/lib/shipMerge.mjs +5 -2
  35. package/bin/lib/startProject.mjs +4 -4
  36. package/bin/lib/trace.mjs +8 -3
  37. package/bin/lib/turnDeclaration.mjs +88 -0
  38. package/bin/lib/turnLock.mjs +48 -8
  39. package/bin/lib/work.mjs +22 -24
  40. package/bin/lib/workAgentCheck.mjs +6 -5
  41. package/bin/lib/workAgentMerges.mjs +19 -2
  42. package/bin/lib/workAgentPrecheck.mjs +5 -5
  43. package/bin/lib/workAgentPublish.mjs +18 -5
  44. package/bin/lib/workAgentRecovery.mjs +85 -0
  45. package/bin/lib/workAgentReview.mjs +5 -3
  46. package/bin/lib/workAgentTurnBegun.mjs +6 -4
  47. package/bin/lib/workAgentTurnExecution.mjs +76 -9
  48. package/bin/lib/workAgentTurnOutcome.mjs +9 -3
  49. package/bin/lib/workAgentTurns.mjs +33 -9
  50. package/bin/lib/workDelivery.mjs +21 -0
  51. package/bin/lib/workDiffs.mjs +18 -11
  52. package/bin/lib/workLiveMessages.mjs +129 -0
  53. package/bin/lib/workPlaceReports.mjs +5 -0
  54. package/bin/lib/workPlaces.mjs +6 -1
  55. package/bin/lib/workRetire.mjs +5 -0
  56. package/bin/lib/workSessionTurns.mjs +72 -40
  57. package/bin/lib/workShip.mjs +17 -16
  58. package/package.json +1 -1
  59. package/npm-shrinkwrap.json +0 -69
@@ -27,17 +27,19 @@ export function createWorkAgentReview({ runCheck, runPrecheck }) {
27
27
  * INSIDE the place's writer lock on a path whose callers settle real work —
28
28
  * `runAgentMerge` in particular reports a claimed merge after this returns.
29
29
  */
30
- const runReviewEntry = async (agentId, wt, agentName) => {
30
+ const runReviewEntry = async (agentId, wt, agentName, repository) => {
31
+ let result = { status: 'unknown' };
31
32
  try {
32
- await runCheck(agentId, wt);
33
+ result = await runCheck(agentId, wt, repository) ?? result;
33
34
  } catch {
34
35
  /* the row keeps its previous check answer, which is null the first time */
35
36
  }
36
37
  try {
37
- await runPrecheck(agentId, wt, agentName);
38
+ await runPrecheck(agentId, wt, agentName, repository);
38
39
  } catch {
39
40
  /* no pre-review is posted, and absence renders nothing */
40
41
  }
42
+ return result;
41
43
  };
42
44
 
43
45
  return { runReviewEntry };
@@ -40,11 +40,11 @@ import { worktreePath } from './workPlaces.mjs';
40
40
  * cleaned up on THIS box is same-box recovery of real committed work and
41
41
  * behaves exactly as it did before this guard existed.
42
42
  *
43
- * The remedy is the stop path and nothing else. "Reconnect the other
44
- * machine" is not reachable from here once holdership has moved, and a
45
- * remedy somebody cannot carry out is worse than none.
43
+ * This remains the same-box and older-server refusal. The recovery coordinator
44
+ * now inspects foreign work before this ordinary turn path: after a person's
45
+ * machine swap it can replace missing work with an explicit start from briefs.
46
46
  */
47
- export function begunTurnRefusal(job, place, { baseDir, repoRoot, fetchPublishedBranch }) {
47
+ export function begunTurnRefusal(job, place, { baseDir, repoRoot, fetchPublishedBranch, onAvailable = () => {} }) {
48
48
  // The caller validated `place` (workAgentTurns); an unsafe one would give a
49
49
  // null directory AND a null ref below, and the null ref stands the guard
50
50
  // down — the same fail-open direction as an unreadable repo.
@@ -105,9 +105,11 @@ export function begunTurnRefusal(job, place, { baseDir, repoRoot, fetchPublished
105
105
  (fetched ? ` Its published branch could not be fetched (${fetched.why}).` : '')
106
106
  );
107
107
  }
108
+ onAvailable();
108
109
  // The branch is here and measured. `placeWtFor`'s attach fallback
109
110
  // opens a worktree on it below — the same path a directory somebody
110
111
  // cleaned up already takes, on commits this box now genuinely holds.
111
112
  }
113
+ if (hasBranch || (wtDir && existsSync(wtDir))) onAvailable();
112
114
  return null;
113
115
  }
@@ -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,15 @@ 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
+ messageFiles: attached.message, cardFiles: files.card, position: job.position ?? 1, total: job.total ?? 1 });
511
+ },
512
+ })) } : {}),
478
513
  prompt:
479
514
  job.kind === 'task' && job.task
480
515
  ? AGENT_TASK_KICKOFF({
@@ -518,9 +553,10 @@ export function createAgentTurnExecution({
518
553
  // ARTIFACTS (0.94.0), while the server can show one — an agent's
519
554
  // land on its page, under the facts row.
520
555
  artifacts: getArtifactsAccepted(),
521
- }) + (toolsNote ? `\n\n${toolsNote}` : '') + (artifactInstructions ? `\n\n${artifactInstructions}` : '') + `\n\n${INTERNAL_WAIT_CONTRACT}` + (projectTools.instructions ? `\n\n${projectTools.instructions}` : ''),
556
+ }) + (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
557
  agentTools: projectTools,
523
- ...((projectTools.codexHome || fencedMount) ? { mcpEnv: { ...(projectTools.codexHome ? { CODEX_HOME: projectTools.codexHome } : {}), ...(fencedMount?.env ?? {}) } } : {}),
558
+ writableDirs: repositoryRun?.writableDirs,
559
+ ...((projectTools.codexHome || fencedMount || repositoryRun?.tool) ? { mcpEnv: { ...(projectTools.codexHome ? { CODEX_HOME: projectTools.codexHome } : {}), ...(fencedMount?.env ?? {}), ...(repositoryRun?.tool?.env ?? {}) } } : {}),
524
560
  // NOBODY IS AT THIS SCREEN (2026-09-29): no display, BROWSER=none, and
525
561
  // a browser profile of the agent's own, kept beside its Codex home —
526
562
  // so a Chrome it starts to look at its page never reaches the
@@ -542,7 +578,11 @@ export function createAgentTurnExecution({
542
578
  cwd: wt,
543
579
  runtime: rt,
544
580
  resume,
545
- resumeThreadId: codexResumeId || undefined,
581
+ resumeThreadId: codexResumeId || claudeResumeId || undefined,
582
+ onInit: (init) => {
583
+ if (claudeMarker && CLAUDE_SESSION_RE.test(init.sessionId ?? ''))
584
+ try { writeFileSync(claudeMarker, init.sessionId); } catch { /* best effort */ }
585
+ },
546
586
  onThreadId: (id) => {
547
587
  seenThreadId = String(id ?? '').trim() || seenThreadId;
548
588
  },
@@ -558,7 +598,7 @@ export function createAgentTurnExecution({
558
598
  // board past ~90 seconds.
559
599
  onActivity: (a) => {
560
600
  brief?.append(a?.kind ?? 'activity', a?.full ?? a?.label);
561
- const line = a?.label;
601
+ let line = a?.label;
562
602
  if (!line) return;
563
603
  // THE TRACE TAKES EVERY LINE; the pulse takes one every two
564
604
  // seconds. Two channels, one stream, and the drop-sampler stays a
@@ -573,7 +613,12 @@ export function createAgentTurnExecution({
573
613
  // daemon carried for all of them. The PULSE below is deliberately
574
614
  // still `line`: it is one overwritten line on a board, and a
575
615
  // paragraph there would be a paragraph nobody can read.
576
- if (!doubledKinds || !doubledKinds.has(a.kind)) trace.prose(a.kind, a.full ?? line);
616
+ if (!doubledKinds || !doubledKinds.has(a.kind)) {
617
+ const full = a.full ?? line;
618
+ const readable = trace.prose(a.kind, full);
619
+ if (readable === null) return;
620
+ if (readable !== full) line = envScrub(readable).replace(/\s+/g, ' ').trim().slice(0, 160);
621
+ }
577
622
  const now = Date.now();
578
623
  if (now - (lastAgentBeat.get(agentId) ?? 0) < 2_000) return;
579
624
  lastAgentBeat.set(agentId, now);
@@ -612,10 +657,12 @@ export function createAgentTurnExecution({
612
657
  // (runTurn.mjs, `initHarvestFor`). An agent-only box learns from the
613
658
  // startup probe and its Terminal tabs.
614
659
  onSpawn: (ch) => {
660
+ if (rt !== 'claude') reportDelivery('agent', job, 'taken');
615
661
  brief?.running(true);
616
662
  ch.once('close', () => { if (child === ch) brief?.running(false); });
617
663
  child = ch;
618
664
  spawnedChildren.add(ch);
665
+ measured.ranCli = true;
619
666
  // The AGENT it serves — what the machine snapshot charges this
620
667
  // child's memory to.
621
668
  workChildren.set(ch, agentId);
@@ -664,12 +711,30 @@ export function createAgentTurnExecution({
664
711
  void postAgentActivity(agentId, 'resuming after an internal wait');
665
712
  } }
666
713
  );
714
+ out = await finishRepositoryDelivery({
715
+ job, run: repositoryRun, out, answer: () => lastAnswer,
716
+ resume: async (prompt) => {
717
+ lastAnswer = null;
718
+ const counted = usage;
719
+ return runCli({
720
+ ...agentTurnArgs, prompt, liveContinuation: true,
721
+ onUsage: (next) => agentTurnArgs.onUsage(Object.fromEntries(
722
+ ['input', 'output', 'cacheCreate', 'cacheRead'].map((k) =>
723
+ [k, (counted?.[k] ?? 0) + (next?.[k] ?? 0)])
724
+ )),
725
+ writableDirs: repositoryRun.sourcesNow().filter((r) => r.wt !== wt).map((r) => r.wt),
726
+ resume: true,
727
+ resumeThreadId: seenThreadId ?? agentTurnArgs.resumeThreadId,
728
+ });
729
+ },
730
+ });
667
731
  } finally {
668
732
  await brief?.stop();
669
733
  // What the CLI counted, for the lane's catch if anything below throws.
670
734
  measured.usage = usage;
671
735
  measured.model = model;
672
736
  projectTools?.cleanup();
737
+
673
738
  /**
674
739
  * THE TAIL, BEFORE THE SETTLE — so the last thing the agent did is on
675
740
  * the record by the time the board is told the turn is over.
@@ -721,7 +786,7 @@ export function createAgentTurnExecution({
721
786
  }
722
787
 
723
788
 
724
- const commits = commitsBetween(wt, before);
789
+ const commits = repositoryRun ? [...new Set(repositoryRun.receipts().map((r) => r.sha))].slice(0, 50) : commitsBetween(wt, before);
725
790
  /**
726
791
  * A PERSON INTERRUPTED IT (Interrupt now). Whatever the CLI printed was
727
792
  * cut off, so there is no outcome to read: the turn reports `interrupted`
@@ -771,12 +836,14 @@ export function createAgentTurnExecution({
771
836
  // reported as `nothing` — it did not deliver and it did not ask.
772
837
  await postAgentParked(limit, rt);
773
838
  }
839
+ if (job.kind === 'merge_resolve' && repositoryRun) body.mergeResolved = allResolutionsCommitted(repositoryRun.sourcesNow());
774
840
  const reply = await postAgentTurn(body);
775
841
  // The queue just emptied. Run the project's own check and the AI
776
842
  // pre-review HERE, in the worktree we are already standing in and still
777
843
  // hold the lock on — see `runReviewEntry`. Only after the settle that may
778
844
  // say delivered: the three `nothing` settles never opened it.
779
845
  if (final && reply?.review === true) await runReviewEntry(agentId, wt, job.agentName);
846
+ } finally { await repositoryRun?.tool?.close(); }
780
847
  };
781
848
 
782
849
  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 } : {}),
@@ -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;
@@ -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
  }