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.
- package/README.md +66 -12
- package/bin/cli.mjs +50 -17
- package/bin/lib/admission.mjs +7 -14
- package/bin/lib/agentAsk.mjs +123 -0
- package/bin/lib/agentBrief.mjs +36 -0
- package/bin/lib/agentFootprint.mjs +76 -0
- package/bin/lib/agentInternalWait.mjs +83 -0
- package/bin/lib/agentPlan.mjs +134 -31
- package/bin/lib/agentPrerequisiteContract.mjs +43 -0
- package/bin/lib/agentPublish.mjs +12 -13
- package/bin/lib/agentTaskKinds.mjs +5 -8
- package/bin/lib/agySessions.mjs +3 -3
- package/bin/lib/artifactContracts.mjs +91 -20
- package/bin/lib/artifactIteration.mjs +182 -0
- package/bin/lib/artifactPreview.mjs +92 -18
- package/bin/lib/artifactRecovery.mjs +36 -0
- package/bin/lib/artifactReporter.mjs +194 -22
- package/bin/lib/artifacts.mjs +70 -63
- package/bin/lib/authproxy.mjs +2 -1
- package/bin/lib/childEnv.mjs +9 -4
- package/bin/lib/claude.mjs +10 -20
- package/bin/lib/claudeAuth.mjs +15 -5
- package/bin/lib/claudePersonal.mjs +71 -6
- package/bin/lib/claudePosture.mjs +227 -54
- package/bin/lib/claudeSessions.mjs +7 -7
- package/bin/lib/claudeStream.mjs +27 -13
- package/bin/lib/cliLimit.mjs +2 -2
- package/bin/lib/codexHome.mjs +17 -0
- package/bin/lib/codexPersonal.mjs +44 -3
- package/bin/lib/codexProjectRead.mjs +63 -0
- package/bin/lib/codexRelay.mjs +21 -5
- package/bin/lib/codexResearch.mjs +193 -0
- package/bin/lib/commandSpecs.mjs +6 -0
- package/bin/lib/config.mjs +37 -24
- package/bin/lib/conversationIds.mjs +51 -0
- package/bin/lib/credentials.mjs +2 -7
- package/bin/lib/daemonLocks.mjs +90 -0
- package/bin/lib/daemonState.mjs +5 -5
- package/bin/lib/deploy.mjs +6 -9
- package/bin/lib/deployCheckout.mjs +26 -22
- package/bin/lib/deployRunner.mjs +54 -18
- package/bin/lib/deployWire.mjs +32 -23
- package/bin/lib/desktopContract.mjs +6 -5
- package/bin/lib/discoveryContract.mjs +19 -0
- package/bin/lib/env.mjs +104 -35
- package/bin/lib/fleet.mjs +53 -13
- package/bin/lib/fleetPost.mjs +172 -25
- package/bin/lib/fleetReports.mjs +172 -171
- package/bin/lib/fleetStartup.mjs +6 -6
- package/bin/lib/fleetWire.mjs +42 -0
- package/bin/lib/git.mjs +111 -61
- package/bin/lib/gitMergeIdentity.mjs +2 -8
- package/bin/lib/grant.mjs +23 -7
- package/bin/lib/holder.mjs +108 -81
- package/bin/lib/hooks/mcpAllow.mjs +60 -0
- package/bin/lib/hooks/readGuard.mjs +54 -11
- package/bin/lib/imageArtifact.mjs +74 -0
- package/bin/lib/install.mjs +80 -33
- package/bin/lib/instance.mjs +36 -83
- package/bin/lib/intakeDraft.mjs +4 -1
- package/bin/lib/knowledgeFileSync.mjs +31 -0
- package/bin/lib/knowledgeFiles.mjs +28 -83
- package/bin/lib/landed.mjs +22 -29
- package/bin/lib/launchCommand.mjs +91 -0
- package/bin/lib/listeners.mjs +0 -5
- package/bin/lib/localSessions.mjs +52 -39
- package/bin/lib/login.mjs +22 -12
- package/bin/lib/loginPath.mjs +22 -17
- package/bin/lib/machineListing.mjs +6 -3
- package/bin/lib/maintenance.mjs +47 -0
- package/bin/lib/maintenanceInventory.mjs +39 -0
- package/bin/lib/mcp-cli.mjs +9 -10
- package/bin/lib/mcpInstall.mjs +163 -0
- package/bin/lib/mcpInventory.mjs +119 -0
- package/bin/lib/noWindowEnv.mjs +23 -3
- package/bin/lib/ownedDirs.mjs +99 -0
- package/bin/lib/pinnedCapture.mjs +383 -0
- package/bin/lib/planDependencies.mjs +70 -0
- package/bin/lib/postureTools.mjs +85 -0
- package/bin/lib/prWorkflow.mjs +94 -17
- package/bin/lib/preflight.mjs +8 -25
- package/bin/lib/preview.mjs +19 -25
- package/bin/lib/previewGatePolicy.mjs +34 -9
- package/bin/lib/previewRegistry.mjs +4 -11
- package/bin/lib/printable.mjs +70 -7
- package/bin/lib/procRegistry.mjs +33 -13
- package/bin/lib/procScan.mjs +2 -3
- package/bin/lib/procStat.mjs +76 -0
- package/bin/lib/processGroup.mjs +48 -0
- package/bin/lib/processes.mjs +10 -24
- package/bin/lib/projectToolRuntimes.mjs +328 -19
- package/bin/lib/projectTools.mjs +109 -34
- package/bin/lib/prompts.mjs +205 -60
- package/bin/lib/repoPath.mjs +19 -0
- package/bin/lib/repoState.mjs +17 -19
- package/bin/lib/repositoryBaseline.mjs +68 -0
- package/bin/lib/runTurn.mjs +286 -209
- package/bin/lib/runtimeAntigravity.mjs +16 -5
- package/bin/lib/runtimeCapabilities.mjs +215 -21
- package/bin/lib/runtimeClaude.mjs +78 -10
- package/bin/lib/runtimeCodex.mjs +217 -209
- package/bin/lib/runtimeDetection.mjs +8 -2
- package/bin/lib/runtimeEvents.mjs +28 -11
- package/bin/lib/runtimeLimits.mjs +5 -6
- package/bin/lib/runtimeOfferings.mjs +99 -14
- package/bin/lib/runtimes.mjs +10 -0
- package/bin/lib/shipSweep.mjs +5 -3
- package/bin/lib/shot.mjs +91 -17
- package/bin/lib/sleep.mjs +11 -0
- package/bin/lib/startProject.mjs +5 -5
- package/bin/lib/statePaths.mjs +40 -0
- package/bin/lib/tty.mjs +25 -15
- package/bin/lib/turnDiagnostics.mjs +93 -0
- package/bin/lib/turnLock.mjs +7 -14
- package/bin/lib/turnProfile.mjs +16 -4
- package/bin/lib/ui.mjs +0 -3
- package/bin/lib/uninstall.mjs +30 -26
- package/bin/lib/update.mjs +79 -27
- package/bin/lib/uplinkScrub.mjs +296 -31
- package/bin/lib/vault.mjs +78 -10
- package/bin/lib/vaultDiff.mjs +5 -2
- package/bin/lib/views.mjs +37 -12
- package/bin/lib/wikiRunner.mjs +40 -61
- package/bin/lib/work.mjs +44 -13
- package/bin/lib/workAdoptCarry.mjs +2 -4
- package/bin/lib/workAgentBrief.mjs +92 -0
- package/bin/lib/workAgentBriefRunner.mjs +59 -0
- package/bin/lib/workAgentCheck.mjs +104 -57
- package/bin/lib/workAgentMerges.mjs +62 -57
- package/bin/lib/workAgentPlans.mjs +82 -199
- package/bin/lib/workAgentPrecheck.mjs +76 -138
- package/bin/lib/workAgentPublish.mjs +6 -8
- package/bin/lib/workAgentTurnBegun.mjs +15 -11
- package/bin/lib/workAgentTurnExecution.mjs +131 -67
- package/bin/lib/workAgentTurnOutcome.mjs +11 -2
- package/bin/lib/workAgentTurnReports.mjs +9 -37
- package/bin/lib/workAgentTurns.mjs +64 -7
- package/bin/lib/workArtifactReconcile.mjs +79 -0
- package/bin/lib/workAttachments.mjs +66 -11
- package/bin/lib/workBrain.mjs +9 -4
- package/bin/lib/workCappedTurn.mjs +119 -0
- package/bin/lib/workChildren.mjs +5 -5
- package/bin/lib/workCommandAudit.mjs +18 -2
- package/bin/lib/workDiffs.mjs +52 -6
- package/bin/lib/workIntake.mjs +35 -60
- package/bin/lib/workPlaceReports.mjs +7 -4
- package/bin/lib/workPlaces.mjs +113 -31
- package/bin/lib/workPreviews.mjs +47 -112
- package/bin/lib/workProcesses.mjs +8 -35
- package/bin/lib/workPullRequests.mjs +37 -56
- package/bin/lib/workReportQueue.mjs +100 -148
- package/bin/lib/workRetire.mjs +2 -1
- package/bin/lib/workSessionRuntime.mjs +38 -37
- package/bin/lib/workSessionTurns.mjs +256 -199
- package/bin/lib/workShip.mjs +43 -23
- package/bin/lib/workToolLog.mjs +70 -3
- package/bin/lib/workTurnShape.mjs +165 -0
- package/npm-shrinkwrap.json +69 -0
- package/package.json +9 -4
- package/bin/lib/fleetJobs.mjs +0 -120
- 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
|
-
*
|
|
14
|
-
*
|
|
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 './
|
|
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
|
-
|
|
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 });
|
package/bin/lib/fleetPost.mjs
CHANGED
|
@@ -1,40 +1,187 @@
|
|
|
1
1
|
/**
|
|
2
|
-
*
|
|
3
|
-
*
|
|
4
|
-
* `
|
|
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
|
|
7
|
-
*
|
|
8
|
-
*
|
|
9
|
-
*
|
|
10
|
-
*
|
|
11
|
-
*
|
|
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
|
-
*
|
|
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
|
-
|
|
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
|
|
26
|
-
|
|
27
|
-
|
|
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
|
+
}
|