flowviant 0.116.0 → 0.124.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 (161) hide show
  1. package/README.md +66 -12
  2. package/bin/cli.mjs +50 -17
  3. package/bin/lib/admission.mjs +7 -14
  4. package/bin/lib/agentAsk.mjs +123 -0
  5. package/bin/lib/agentBrief.mjs +36 -0
  6. package/bin/lib/agentFootprint.mjs +76 -0
  7. package/bin/lib/agentInternalWait.mjs +83 -0
  8. package/bin/lib/agentPlan.mjs +134 -31
  9. package/bin/lib/agentPrerequisiteContract.mjs +43 -0
  10. package/bin/lib/agentPublish.mjs +12 -13
  11. package/bin/lib/agentTaskKinds.mjs +5 -8
  12. package/bin/lib/agySessions.mjs +3 -3
  13. package/bin/lib/artifactContracts.mjs +91 -20
  14. package/bin/lib/artifactIteration.mjs +182 -0
  15. package/bin/lib/artifactPreview.mjs +92 -18
  16. package/bin/lib/artifactRecovery.mjs +36 -0
  17. package/bin/lib/artifactReporter.mjs +194 -22
  18. package/bin/lib/artifacts.mjs +70 -63
  19. package/bin/lib/authproxy.mjs +2 -1
  20. package/bin/lib/childEnv.mjs +9 -4
  21. package/bin/lib/claude.mjs +10 -20
  22. package/bin/lib/claudeAuth.mjs +15 -5
  23. package/bin/lib/claudePersonal.mjs +71 -6
  24. package/bin/lib/claudePosture.mjs +227 -54
  25. package/bin/lib/claudeSessions.mjs +7 -7
  26. package/bin/lib/claudeStream.mjs +27 -13
  27. package/bin/lib/cliLimit.mjs +2 -2
  28. package/bin/lib/codexHome.mjs +17 -0
  29. package/bin/lib/codexPersonal.mjs +44 -3
  30. package/bin/lib/codexProjectRead.mjs +63 -0
  31. package/bin/lib/codexRelay.mjs +21 -5
  32. package/bin/lib/codexResearch.mjs +193 -0
  33. package/bin/lib/commandSpecs.mjs +6 -0
  34. package/bin/lib/config.mjs +37 -24
  35. package/bin/lib/conversationIds.mjs +51 -0
  36. package/bin/lib/credentials.mjs +2 -7
  37. package/bin/lib/daemonLocks.mjs +90 -0
  38. package/bin/lib/daemonState.mjs +5 -5
  39. package/bin/lib/deploy.mjs +6 -9
  40. package/bin/lib/deployCheckout.mjs +26 -22
  41. package/bin/lib/deployRunner.mjs +54 -18
  42. package/bin/lib/deployWire.mjs +32 -23
  43. package/bin/lib/desktopContract.mjs +6 -5
  44. package/bin/lib/discoveryContract.mjs +19 -0
  45. package/bin/lib/env.mjs +104 -35
  46. package/bin/lib/fleet.mjs +53 -13
  47. package/bin/lib/fleetPost.mjs +172 -25
  48. package/bin/lib/fleetReports.mjs +172 -171
  49. package/bin/lib/fleetStartup.mjs +6 -6
  50. package/bin/lib/fleetWire.mjs +42 -0
  51. package/bin/lib/git.mjs +111 -61
  52. package/bin/lib/gitMergeIdentity.mjs +2 -8
  53. package/bin/lib/grant.mjs +23 -7
  54. package/bin/lib/holder.mjs +108 -81
  55. package/bin/lib/hooks/mcpAllow.mjs +60 -0
  56. package/bin/lib/hooks/readGuard.mjs +54 -11
  57. package/bin/lib/imageArtifact.mjs +74 -0
  58. package/bin/lib/install.mjs +80 -33
  59. package/bin/lib/instance.mjs +36 -83
  60. package/bin/lib/intakeDraft.mjs +4 -1
  61. package/bin/lib/knowledgeFileSync.mjs +31 -0
  62. package/bin/lib/knowledgeFiles.mjs +28 -83
  63. package/bin/lib/landed.mjs +22 -29
  64. package/bin/lib/launchCommand.mjs +91 -0
  65. package/bin/lib/listeners.mjs +0 -5
  66. package/bin/lib/localSessions.mjs +52 -39
  67. package/bin/lib/login.mjs +22 -12
  68. package/bin/lib/loginPath.mjs +22 -17
  69. package/bin/lib/machineListing.mjs +6 -3
  70. package/bin/lib/maintenance.mjs +47 -0
  71. package/bin/lib/maintenanceInventory.mjs +39 -0
  72. package/bin/lib/mcp-cli.mjs +9 -10
  73. package/bin/lib/mcpInstall.mjs +163 -0
  74. package/bin/lib/mcpInventory.mjs +119 -0
  75. package/bin/lib/noWindowEnv.mjs +23 -3
  76. package/bin/lib/ownedDirs.mjs +99 -0
  77. package/bin/lib/pinnedCapture.mjs +383 -0
  78. package/bin/lib/planDependencies.mjs +70 -0
  79. package/bin/lib/postureTools.mjs +85 -0
  80. package/bin/lib/prWorkflow.mjs +94 -17
  81. package/bin/lib/preflight.mjs +8 -25
  82. package/bin/lib/preview.mjs +19 -25
  83. package/bin/lib/previewGatePolicy.mjs +34 -9
  84. package/bin/lib/previewRegistry.mjs +4 -11
  85. package/bin/lib/printable.mjs +70 -7
  86. package/bin/lib/procRegistry.mjs +33 -13
  87. package/bin/lib/procScan.mjs +2 -3
  88. package/bin/lib/procStat.mjs +76 -0
  89. package/bin/lib/processGroup.mjs +48 -0
  90. package/bin/lib/processes.mjs +10 -24
  91. package/bin/lib/projectToolRuntimes.mjs +328 -19
  92. package/bin/lib/projectTools.mjs +109 -34
  93. package/bin/lib/prompts.mjs +205 -60
  94. package/bin/lib/repoPath.mjs +19 -0
  95. package/bin/lib/repoState.mjs +17 -19
  96. package/bin/lib/repositoryBaseline.mjs +68 -0
  97. package/bin/lib/runTurn.mjs +286 -209
  98. package/bin/lib/runtimeAntigravity.mjs +16 -5
  99. package/bin/lib/runtimeCapabilities.mjs +215 -21
  100. package/bin/lib/runtimeClaude.mjs +78 -10
  101. package/bin/lib/runtimeCodex.mjs +217 -209
  102. package/bin/lib/runtimeDetection.mjs +8 -2
  103. package/bin/lib/runtimeEvents.mjs +28 -11
  104. package/bin/lib/runtimeLimits.mjs +5 -6
  105. package/bin/lib/runtimeOfferings.mjs +99 -14
  106. package/bin/lib/runtimes.mjs +10 -0
  107. package/bin/lib/shipSweep.mjs +5 -3
  108. package/bin/lib/shot.mjs +91 -17
  109. package/bin/lib/sleep.mjs +11 -0
  110. package/bin/lib/startProject.mjs +5 -5
  111. package/bin/lib/statePaths.mjs +40 -0
  112. package/bin/lib/tty.mjs +25 -15
  113. package/bin/lib/turnDiagnostics.mjs +93 -0
  114. package/bin/lib/turnLock.mjs +7 -14
  115. package/bin/lib/turnProfile.mjs +16 -4
  116. package/bin/lib/ui.mjs +0 -3
  117. package/bin/lib/uninstall.mjs +30 -26
  118. package/bin/lib/update.mjs +79 -27
  119. package/bin/lib/uplinkScrub.mjs +296 -31
  120. package/bin/lib/vault.mjs +78 -10
  121. package/bin/lib/vaultDiff.mjs +5 -2
  122. package/bin/lib/views.mjs +37 -12
  123. package/bin/lib/wikiRunner.mjs +40 -61
  124. package/bin/lib/work.mjs +44 -13
  125. package/bin/lib/workAdoptCarry.mjs +2 -4
  126. package/bin/lib/workAgentBrief.mjs +92 -0
  127. package/bin/lib/workAgentBriefRunner.mjs +59 -0
  128. package/bin/lib/workAgentCheck.mjs +104 -57
  129. package/bin/lib/workAgentMerges.mjs +62 -57
  130. package/bin/lib/workAgentPlans.mjs +82 -199
  131. package/bin/lib/workAgentPrecheck.mjs +76 -138
  132. package/bin/lib/workAgentPublish.mjs +6 -8
  133. package/bin/lib/workAgentTurnBegun.mjs +15 -11
  134. package/bin/lib/workAgentTurnExecution.mjs +131 -67
  135. package/bin/lib/workAgentTurnOutcome.mjs +11 -2
  136. package/bin/lib/workAgentTurnReports.mjs +9 -37
  137. package/bin/lib/workAgentTurns.mjs +64 -7
  138. package/bin/lib/workArtifactReconcile.mjs +79 -0
  139. package/bin/lib/workAttachments.mjs +66 -11
  140. package/bin/lib/workBrain.mjs +9 -4
  141. package/bin/lib/workCappedTurn.mjs +119 -0
  142. package/bin/lib/workChildren.mjs +5 -5
  143. package/bin/lib/workCommandAudit.mjs +18 -2
  144. package/bin/lib/workDiffs.mjs +52 -6
  145. package/bin/lib/workIntake.mjs +35 -60
  146. package/bin/lib/workPlaceReports.mjs +7 -4
  147. package/bin/lib/workPlaces.mjs +113 -31
  148. package/bin/lib/workPreviews.mjs +47 -112
  149. package/bin/lib/workProcesses.mjs +8 -35
  150. package/bin/lib/workPullRequests.mjs +37 -56
  151. package/bin/lib/workReportQueue.mjs +100 -148
  152. package/bin/lib/workRetire.mjs +2 -1
  153. package/bin/lib/workSessionRuntime.mjs +38 -37
  154. package/bin/lib/workSessionTurns.mjs +256 -199
  155. package/bin/lib/workShip.mjs +43 -23
  156. package/bin/lib/workToolLog.mjs +70 -3
  157. package/bin/lib/workTurnShape.mjs +165 -0
  158. package/npm-shrinkwrap.json +69 -0
  159. package/package.json +9 -4
  160. package/bin/lib/fleetJobs.mjs +0 -120
  161. package/bin/lib/patchRevert.mjs +0 -60
package/bin/lib/fleet.mjs CHANGED
@@ -10,8 +10,8 @@
10
10
  * what order. What a lane DOES lives beside it — the poll's query string
11
11
  * (fleetRoster.mjs), the machine's own reports (fleetReports.mjs), holdership
12
12
  * and the terminal signals (holder.mjs), the wiki runner (wikiRunner.mjs), the
13
- * checkout job lanes (fleetJobs.mjs), the start steps (fleetStartup.mjs), the
14
- * reconcile wait (fleetWake.mjs), and session/agent work (work.mjs).
13
+ * start steps (fleetStartup.mjs), the reconcile wait (fleetWake.mjs), and
14
+ * session/agent work (work.mjs).
15
15
  *
16
16
  * Over the 500-line mark as ONE LIFECYCLE, not as debt: what is left is the
17
17
  * intake order over roster keys, and most of its length is the argument for
@@ -36,9 +36,10 @@ import { handleVersionSignal } from './update.mjs';
36
36
  import { compareVersions } from './versionOrder.mjs';
37
37
  import { emitMachineEvent } from './daemonLogging.mjs';
38
38
  import { writeDaemonState } from './daemonState.mjs';
39
+ import { maintenanceTick, readMaintenance } from './maintenance.mjs';
39
40
  import { repoRootOrDie, detectBaseRef, baseBranchName, excludeInWorktree, usableBaseRef } from './git.mjs';
40
41
  import { c, info, note, ok, warn, fail } from './ui.mjs';
41
- import { sleep } from './claude.mjs';
42
+ import { sleep } from './sleep.mjs';
42
43
  import { reapOrphanPreviews } from './previewRegistry.mjs';
43
44
  import { preflight } from './preflight.mjs';
44
45
  import { connectStream } from './stream.mjs';
@@ -56,6 +57,7 @@ import { createLeave } from './standDownExit.mjs';
56
57
  import { stopWorkLanes } from './standDownGate.mjs';
57
58
  import { markLockDraining } from './instance.mjs';
58
59
  import { reportDeployConfig } from './deployConfig.mjs';
60
+ import { mcpInstallsInFlight, processMcpInstallJobs } from './mcpInstall.mjs';
59
61
  import { probeSkillsOnce } from './runtimeCapabilities.mjs';
60
62
  import { createWorkManager } from './work.mjs';
61
63
  import { INTAKE_ROSTER_KEY } from './workIntake.mjs';
@@ -70,12 +72,12 @@ import {
70
72
  maybeReportLocalSessions,
71
73
  maybeReportRepoState,
72
74
  maybeReportTools,
75
+ recheckProjectMcp,
73
76
  repoStateChanged,
74
77
  reportMachine,
75
78
  } from './fleetReports.mjs';
76
79
  import { createHolderWatch, obeyRosterCommands } from './holder.mjs';
77
80
  import { createWikiRunner } from './wikiRunner.mjs';
78
- import { createCleanupLane, createPatchRevertLane } from './fleetJobs.mjs';
79
81
  import { installSignalHandlers, takeInstanceLockOrExit, tidyWorktreeHome, worktreeHome } from './fleetStartup.mjs';
80
82
  import { createReconcileWait } from './fleetWake.mjs';
81
83
 
@@ -235,7 +237,10 @@ export async function runFleetDaemon({ afterLock = null } = {}) {
235
237
  // never-squash law — and every server since the pivot sends `mergeJobs: []`
236
238
  // (a wire fossil, `rosterResponse.ts`). Merges run through `prWorkflow.mjs`
237
239
  // (session PR job, agent approve) and `shipMerge.mjs`, and nowhere else.
238
- const processPatchRevertJobs = createPatchRevertLane({ repoRoot });
240
+ // Its two checkout siblings went the same way (audit 2026-10-10, F083): no
241
+ // server sends `patchRevertJobs` (a `git revert` in this checkout) or
242
+ // `cleanupJobs` (`gh pr close --delete-branch`, a remote branch delete), and
243
+ // their settle routes died with dispatch, so nothing here reads either key.
239
244
 
240
245
  // ── Work sessions — the Workbench tabs ─────────────────────────────────────
241
246
  //
@@ -247,6 +252,7 @@ export async function runFleetDaemon({ afterLock = null } = {}) {
247
252
  flushWorkReports,
248
253
  learnPlaces,
249
254
  processWorkTurns,
255
+ interruptWorkTurns,
250
256
  processShipJobs,
251
257
  processDiffJobs,
252
258
  processKillJobs,
@@ -254,6 +260,7 @@ export async function runFleetDaemon({ afterLock = null } = {}) {
254
260
  processAgentPlanJobs,
255
261
  processIntakeJobs,
256
262
  processAgentTurnJobs,
263
+ interruptAgentTurns,
257
264
  processAgentMergeJobs,
258
265
  freshenManualPlaces,
259
266
  heldSessionIds,
@@ -263,9 +270,11 @@ export async function runFleetDaemon({ afterLock = null } = {}) {
263
270
  shutdownPreviews,
264
271
  retireWorkSessions,
265
272
  reportWorktrees,
273
+ reconcileIdleArtifacts,
266
274
  shutdownWork,
267
275
  settleAgentTurns,
268
276
  workBusy,
277
+ runningAgentCount,
269
278
  admit,
270
279
  liveTurns,
271
280
  } = createWorkManager({
@@ -314,8 +323,6 @@ export async function runFleetDaemon({ afterLock = null } = {}) {
314
323
  });
315
324
  workShutdown = shutdownWork; // teardown can now reach the live session CLIs
316
325
 
317
- const processCleanupJobs = createCleanupLane({ repoRoot, getBaseRef });
318
-
319
326
  // Living-wiki work: one cartographer turn at a time, off the agents'
320
327
  // checkouts (wikiRunner.mjs). Wiki work needs no agent online.
321
328
  wiki = createWikiRunner({ repoRoot, baseDir, repoKey, getBaseRef, admit });
@@ -331,9 +338,9 @@ export async function runFleetDaemon({ afterLock = null } = {}) {
331
338
  * from the server's list of WORKING AGENTS, which cannot see a Workbench
332
339
  * turn, a ship or a deploy.
333
340
  */
334
- const machineBusy = () => wiki.busy() || workBusy() || deploysInFlight() > 0;
341
+ const machineBusy = () => wiki.busy() || workBusy() || deploysInFlight() > 0 || mcpInstallsInFlight() > 0;
335
342
  const reportBusy = () =>
336
- writeDaemonState(desktopProjectId, { busy: machineBusy(), busyAt: new Date().toISOString() });
343
+ writeDaemonState(desktopProjectId, { busy: machineBusy(), liveAgents: runningAgentCount(), maintenance: null, busyAt: new Date().toISOString() });
337
344
  /**
338
345
  * HOW EVERY STAND-DOWN LEAVES — after the teardown, and after any deploy in
339
346
  * flight has finished and reported (standDownExit.mjs, ruling 2026-09-26).
@@ -379,6 +386,16 @@ export async function runFleetDaemon({ afterLock = null } = {}) {
379
386
  // share, no new deploy — only the deploys already claimed run on to their
380
387
  // report (standDownExit.mjs).
381
388
  const standingDown = () => leave.leaving();
389
+ let lastAgentJobs = [];
390
+ const reportMaintenance = (jobs = lastAgentJobs) => maintenanceTick({
391
+ busy: machineBusy,
392
+ agents: runningAgentCount,
393
+ flush: async () => {
394
+ await flushWorkReports();
395
+ processAgentTurnJobs(jobs, { reportsOnly: true });
396
+ },
397
+ report: (state) => writeDaemonState(desktopProjectId, state),
398
+ });
382
399
  for (;;) {
383
400
  if (standingDown()) return leave.drained();
384
401
  let roster;
@@ -412,7 +429,7 @@ export async function runFleetDaemon({ afterLock = null } = {}) {
412
429
  }
413
430
  warn(`roster poll failed: ${e.message} — retrying in ${RECONCILE_SECONDS}s`);
414
431
  // Work already running keeps running offline; the measurement keeps up.
415
- reportBusy();
432
+ if (!await reportMaintenance()) reportBusy();
416
433
  await sleep(RECONCILE_SECONDS);
417
434
  continue;
418
435
  }
@@ -490,7 +507,7 @@ export async function runFleetDaemon({ afterLock = null } = {}) {
490
507
  // same one the desktop app's auto-install reads — which is how a deploy
491
508
  // in flight joined this gate (2026-09-26): re-exec'ing under one would
492
509
  // stop its heartbeat mid-run.
493
- const safeToUpdate = !machineBusy();
510
+ const safeToUpdate = !machineBusy() && !readMaintenance();
494
511
  const updating = handleVersionSignal({
495
512
  latest: roster.daemon.latest,
496
513
  min: roster.daemon.min,
@@ -501,11 +518,23 @@ export async function runFleetDaemon({ afterLock = null } = {}) {
501
518
  if (await updating) return;
502
519
  if (standingDown()) return leave.drained();
503
520
  }
521
+ // Keep polling to renew the running agents' leases and honour stops and
522
+ // interrupts. A paused tick admits no roster jobs, even after becoming
523
+ // idle; only held answers are retried until the tray releases its lease.
524
+ // Interrupts precede both paths so a stop arriving while paused still
525
+ // reaches its running CLI, and a successor sees the stop before intake.
526
+ interruptWorkTurns(roster.interruptTurns);
527
+ interruptAgentTurns(roster.interruptAgentTurns);
528
+ lastAgentJobs = roster.agentTurnJobs;
529
+ if (await reportMaintenance()) {
530
+ reportMachine({ worktreeDir: baseDir, liveTurns });
531
+ await sleep(RECONCILE_SECONDS);
532
+ continue;
533
+ }
504
534
  // Settle any turn/ship answers whose earlier report POST failed BEFORE
505
535
  // taking new work — the skip-if-pending guards make the ordering safe, but
506
536
  // delivering first keeps the tab honest a poll sooner.
507
537
  void flushWorkReports();
508
- processPatchRevertJobs(roster.patchRevertJobs);
509
538
  // WHERE each live tab works, BEFORE anything measures one: the sweep below
510
539
  // and every preview check ask `placeOf`, and a tab nobody has typed into
511
540
  // yet has taught it nothing.
@@ -595,6 +624,10 @@ export async function runFleetDaemon({ afterLock = null } = {}) {
595
624
  // themselves from a browser, relayed. After retirement so a directory that
596
625
  // just went away is not reported as a place.
597
626
  reportWorktrees(roster.activeWorkSessions);
627
+ // …and an idle agent's delivery against what its worktree still holds:
628
+ // once per process, beside no turn (workArtifactReconcile.mjs). After the
629
+ // turn intake above, so a card about to run in a place is not raced.
630
+ void reconcileIdleArtifacts(roster.activeWorkSessions)?.catch(() => {});
598
631
  // Terminal-session presence, throttled + dedup'd inside; never awaited —
599
632
  // the daemon's own worktrees are carved out (a session the daemon spawned
600
633
  // is already a tab, not something to offer adopting).
@@ -617,7 +650,6 @@ export async function runFleetDaemon({ afterLock = null } = {}) {
617
650
  // startup on purpose: a daemon that has been up since before this release
618
651
  // gets measured too, without needing a restart to earn its own menu.
619
652
  probeSkillsOnce(repoRoot);
620
- processCleanupJobs(roster.cleanupJobs);
621
653
  // Announce roster membership only when it changes (not every poll).
622
654
  const sig = [...new Set(roster.agents.map((a) => a.agentId))].sort().join(',');
623
655
  if (sig !== rosterSig) {
@@ -659,6 +691,14 @@ export async function runFleetDaemon({ afterLock = null } = {}) {
659
691
  // This is what replaced the vault's sync tick — see `maybeReportEnv`.
660
692
  void maybeReportEnv(repoRoot);
661
693
  void maybeReportTools(repoRoot, getBaseRef());
694
+ // INSTALLS AN OWNER CONFIRMED FOR THIS BOX (mcpInstall.mjs) — handed to
695
+ // the picked box whether it holds the machine or stands by; each is
696
+ // claimed, run exactly as confirmed, and followed by a re-check.
697
+ processMcpInstallJobs(roster.mcpInstallJobs, {
698
+ repoRoot,
699
+ myPubB64,
700
+ recheck: () => recheckProjectMcp(repoRoot, getBaseRef()),
701
+ });
662
702
 
663
703
  // Tell the app what this machine is doing with itself (fleetReports.mjs).
664
704
  reportMachine({ worktreeDir: baseDir, liveTurns });
@@ -1,40 +1,187 @@
1
1
  /**
2
- * ONE DAEMON→SERVER POST THAT ANSWERS ONLY "WAS IT ACCEPTED" — the settle a
3
- * job lane sends when it is done (`patch-revert-done`, `cleanup-done`,
4
- * `reground-done`) and the machine telemetry beat (`/fleet/machine`).
2
+ * THE DAEMON'S MACHINE-CREDENTIAL REQUEST — the headers, the timeout and the
3
+ * body every `/fleet/*` call shares, and the few answers built on it: a settle
4
+ * that says only "was it accepted" (`postToFleet`), the lease claim that stops
5
+ * two daemons acting on one job (`claimFleetLease`), the lease settle that
6
+ * follows it (`settleLease`), and the bounded best-effort settle
7
+ * (`postBestEffort`).
5
8
  *
6
- * Split out of fleet.mjs (SOLID audit 2026-09-26, F038) because four lanes in
7
- * three modules share it — the job lanes (fleetJobs.mjs), the wiki runner
8
- * (wikiRunner.mjs) and the machine report (fleetReports.mjs) — and it was a
9
- * closure inside `runFleetDaemon` that only one of them could reach. It was
10
- * called `reportMergeOutcome` there, a name left over from the deleted
11
- * dispatch-era merge lane; the body is unchanged.
9
+ * Split out of fleet.mjs (SOLID audit 2026-09-26, F038) because lanes in
10
+ * other modules share `postToFleet` — today the wiki runner (wikiRunner.mjs)
11
+ * and the machine report (fleetReports.mjs) — and it was a closure inside
12
+ * `runFleetDaemon` that only one of them could reach. It was called
13
+ * `reportMergeOutcome` there, a name left over from the deleted dispatch-era
14
+ * merge lane. The patch-revert and cleanup lanes that also settled through it
15
+ * were deleted with their dead roster keys (audit 2026-10-10, F083).
12
16
  *
13
- * A DAEMON→SERVER REPORT, so no floor: the report's presence is the
17
+ * ONE REQUEST SKELETON (audit 2026-10-10, F071/F082/F102/F136/F138/F154/F744).
18
+ * The `Authorization: Bearer …` / `User-Agent` / `Content-Type` block and its
19
+ * `AbortSignal.timeout` were hand-built at ~37 sites in 23 files, and the
20
+ * copies had drifted (mcp-cli's had no timeout at all; wikiRunner's abandon
21
+ * post was a near copy of `postToFleet`). The lease claim — the double-run
22
+ * guard — was five byte-identical copies. Each caller still passes its OWN
23
+ * timeout and keeps its OWN status policy; only the request and the claim's
24
+ * winner test moved here.
25
+ *
26
+ * `fetch` is the global, resolved at CALL time inside each function — tests
27
+ * swap `globalThis.fetch` after import, and that must keep working.
28
+ *
29
+ * A DAEMON→SERVER REPORT needs no floor: the report's presence is the
14
30
  * capability, and a lane that gets `false` back retries on its own beat.
15
31
  */
16
32
 
17
- import { FLEET_TOKEN, USER_AGENT } from './config.mjs';
33
+ import { FLEET_TOKEN, USER_AGENT, DAEMON_INSTANCE } from './config.mjs';
34
+ import { isCredentialRejection } from './authReject.mjs';
35
+ import { isConsideredRefusal, isNotNow, retryAfterMs } from './fleetWire.mjs';
36
+
37
+ /** The machine credential's headers. `token` defaults to the LIVE binding
38
+ * (config.mjs's `let FLEET_TOKEN`, which a project switch re-points), read
39
+ * at call time. `json: false` for a GET or a multipart body. */
40
+ export function fleetHeaders({ json = true, token = FLEET_TOKEN } = {}) {
41
+ return {
42
+ Authorization: `Bearer ${token}`,
43
+ 'User-Agent': USER_AGENT,
44
+ ...(json ? { 'Content-Type': 'application/json' } : {}),
45
+ };
46
+ }
47
+
48
+ /** A body as it goes on the wire: a string is already serialised (the
49
+ * readout lanes dedup on the exact string they post), anything else is
50
+ * JSON-encoded, and `undefined` sends no body. */
51
+ const wireBody = (body) => (body === undefined || typeof body === 'string' ? body : JSON.stringify(body));
52
+
53
+ /**
54
+ * A JSON POST with the machine credential, returning the RESPONSE — for a
55
+ * caller that reads it itself. Rejects on a network error or a timeout
56
+ * exactly as `fetch` does.
57
+ */
58
+ export async function postFleetJson(url, body, { timeoutMs = 30_000, token, signal } = {}) {
59
+ return fetch(url, {
60
+ method: 'POST',
61
+ headers: fleetHeaders({ token }),
62
+ signal: signal ?? AbortSignal.timeout(timeoutMs),
63
+ body: wireBody(body),
64
+ });
65
+ }
66
+
67
+ /**
68
+ * ONE REQUEST, ANSWERED AS A SHAPE — never a throw.
69
+ *
70
+ * { ok, status, json, credentialRejected, retryAfterMs, error }
71
+ *
72
+ * `status` is null (and `error` set) when nothing answered: a network error
73
+ * or the timeout. `json` is the parsed body, or null when it was not JSON.
74
+ * `credentialRejected` is authReject.mjs's one rule — a 401/403 wearing the
75
+ * API's own `{ success: false }` envelope, never the bare status (an edge
76
+ * 403 is somebody else's answer). `retryAfterMs` is the server's Retry-After,
77
+ * capped, or null when it sent none.
78
+ */
79
+ export async function fleetRequest(url, { method = 'POST', body, timeoutMs = 30_000, token, signal } = {}) {
80
+ let res;
81
+ try {
82
+ const sent = wireBody(body);
83
+ res = await fetch(url, {
84
+ method,
85
+ headers: fleetHeaders({ json: sent !== undefined, token }),
86
+ signal: signal ?? AbortSignal.timeout(timeoutMs),
87
+ ...(sent !== undefined ? { body: sent } : {}),
88
+ });
89
+ } catch (error) {
90
+ return { ok: false, status: null, json: null, credentialRejected: false, retryAfterMs: null, error };
91
+ }
92
+ let json = null;
93
+ try {
94
+ json = typeof res.json === 'function' ? await res.json() : null;
95
+ } catch {
96
+ json = null; // not JSON — an edge page, an empty body
97
+ }
98
+ return {
99
+ ok: Boolean(res.ok),
100
+ status: res.status,
101
+ json,
102
+ credentialRejected: isCredentialRejection(res.status, res.headers?.get?.('content-type'), json),
103
+ retryAfterMs: retryAfterMs(res, null),
104
+ error: null,
105
+ };
106
+ }
18
107
 
19
108
  /** Returns whether the server actually accepted it. Callers that spend a
20
109
  * Claude turn per attempt need to know: swallowing the failure silently made
21
110
  * an unreachable endpoint look identical to a settled job, so the turn
22
- * re-ran on every poll. */
23
- export async function postToFleet(url, body) {
111
+ * re-ran on every poll. Never throws — the job reappears next poll if this
112
+ * failed. */
113
+ export async function postToFleet(url, body, { timeoutMs = 30_000 } = {}) {
114
+ return (await fleetRequest(url, { body, timeoutMs })).ok;
115
+ }
116
+
117
+ /**
118
+ * CLAIM A LEASE BEFORE ACTING — the double-run guard (audit 2026-10-10,
119
+ * F138). Two daemons legitimately share one credential and are handed the
120
+ * same job array, so a lane that opens a tunnel, signals a pid, pushes a
121
+ * branch or runs a deploy claims the job first and does NOTHING unless it
122
+ * won. The rules, which were five hand copies:
123
+ * - the body names this PROCESS (`instance: DAEMON_INSTANCE`), never only
124
+ * the credential — a token cannot tell two holders apart;
125
+ * - the winner is read from the BODY (`data.claimed === true`), never
126
+ * inferred from the status;
127
+ * - any failure — a network error, a timeout, a non-JSON answer — is "not
128
+ * claimed": the peer may hold it, and doing nothing is the safe answer.
129
+ * `won` is for a route that answers in another shape (intake's `{ ok: true }`
130
+ * on a 200); it is handed `fleetRequest`'s result.
131
+ */
132
+ export async function claimFleetLease(
133
+ url,
134
+ fields,
135
+ { timeoutMs = 15_000, won = (r) => r.json?.data?.claimed === true } = {}
136
+ ) {
24
137
  try {
25
- const res = await fetch(url, {
26
- method: 'POST',
27
- headers: {
28
- Authorization: `Bearer ${FLEET_TOKEN}`,
29
- 'User-Agent': USER_AGENT,
30
- 'Content-Type': 'application/json',
31
- },
32
- signal: AbortSignal.timeout(30_000),
33
- body: JSON.stringify(body),
34
- });
35
- return res.ok;
138
+ const r = await fleetRequest(url, { body: { ...fields, instance: DAEMON_INSTANCE }, timeoutMs });
139
+ if (r.error) return false;
140
+ return won(r) === true;
36
141
  } catch {
37
- /* best-effort — the job reappears next poll if this failed */
38
142
  return false;
39
143
  }
40
144
  }
145
+
146
+ /**
147
+ * SETTLE A HELD LEASE — the outcome POST that follows a claim, stamped with
148
+ * the same `instance` (only the holder may settle). Returns the parsed reply
149
+ * (a preview open reads `data.settled`), or null when nothing usable came
150
+ * back. Never throws: an unsettled lease expires server-side and the asker
151
+ * is told, never spun.
152
+ */
153
+ export async function settleLease(url, body, { timeoutMs = 30_000 } = {}) {
154
+ const r = await fleetRequest(url, { body: { ...body, instance: DAEMON_INSTANCE }, timeoutMs });
155
+ return r.json ?? null;
156
+ }
157
+
158
+ /**
159
+ * A best-effort settle POST, AWAITED INLINE by a caller with no per-poll
160
+ * queue of its own to retry from — unlike workReportQueue's `postSettle`,
161
+ * whose callers requeue a 'retry'/'reject' outcome themselves. Bounded
162
+ * attempts: 408/429 retry honouring Retry-After, 5xx retries on a short fixed
163
+ * backoff, and any OTHER 4xx is the server's considered answer — retrying it
164
+ * would just spend the same refusal again, so it counts as delivered, the
165
+ * existing trace convention. A network error retries the same way and is
166
+ * then swallowed: unsettled, and the server expires the job so the asker is
167
+ * told, never spun.
168
+ *
169
+ * Moved here from inside `createWorkReportQueue` (audit 2026-10-10, F136): it
170
+ * closed over nothing of the queue's. work.mjs still INJECTS it into the
171
+ * plan, check and intake lanes, so their tests hand in fakes.
172
+ */
173
+ export async function postBestEffort(url, body, { attempts = 4, timeoutMs = 30_000 } = {}) {
174
+ for (let i = 0; i < attempts; i += 1) {
175
+ const r = await fleetRequest(url, { body, timeoutMs });
176
+ if (r.ok) return true;
177
+ if (isConsideredRefusal(r.status)) return true; // the server's considered answer
178
+ const notNow = isNotNow(r.status);
179
+ // Only a network error, 408/429 or a 5xx retries; anything else (a stray
180
+ // 1xx/3xx) was never retried and still is not.
181
+ if (!r.error && !notNow && r.status < 500) return true;
182
+ if (i === attempts - 1) return false;
183
+ const backoff = 2_000 * (i + 1);
184
+ await new Promise((done) => setTimeout(done, notNow ? (r.retryAfterMs ?? backoff) : backoff));
185
+ }
186
+ return false;
187
+ }