flowviant 0.103.0 → 0.104.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 (140) hide show
  1. package/bin/cli.mjs +87 -474
  2. package/bin/lib/admission.mjs +19 -3
  3. package/bin/lib/agentPlan.mjs +2 -2
  4. package/bin/lib/agentTaskKinds.mjs +93 -0
  5. package/bin/lib/agySessions.mjs +158 -0
  6. package/bin/lib/artifactPolicy.mjs +44 -0
  7. package/bin/lib/artifactPreview.mjs +52 -0
  8. package/bin/lib/artifactReporter.mjs +163 -0
  9. package/bin/lib/artifacts.mjs +47 -227
  10. package/bin/lib/authReject.mjs +16 -4
  11. package/bin/lib/authproxy.mjs +47 -304
  12. package/bin/lib/boxIdentity.mjs +245 -0
  13. package/bin/lib/childEnv.mjs +11 -9
  14. package/bin/lib/claude.mjs +14 -851
  15. package/bin/lib/claudeAuth.mjs +3 -3
  16. package/bin/lib/claudePosture.mjs +367 -0
  17. package/bin/lib/claudeSessions.mjs +411 -0
  18. package/bin/lib/claudeStream.mjs +207 -0
  19. package/bin/lib/cliLimit.mjs +34 -0
  20. package/bin/lib/commandSpecs.mjs +173 -0
  21. package/bin/lib/config.mjs +11 -42
  22. package/bin/lib/credentialDate.mjs +22 -0
  23. package/bin/lib/credentials.mjs +21 -26
  24. package/bin/lib/daemonLogging.mjs +55 -0
  25. package/bin/lib/daemonState.mjs +64 -0
  26. package/bin/lib/deploy.mjs +148 -647
  27. package/bin/lib/deployCheckout.mjs +265 -0
  28. package/bin/lib/deployConfig.mjs +142 -0
  29. package/bin/lib/deployRunner.mjs +311 -0
  30. package/bin/lib/deployWire.mjs +41 -0
  31. package/bin/lib/desktopContract.mjs +56 -105
  32. package/bin/lib/env.mjs +49 -484
  33. package/bin/lib/fleet.mjs +183 -2383
  34. package/bin/lib/fleetJobs.mjs +120 -0
  35. package/bin/lib/fleetPost.mjs +40 -0
  36. package/bin/lib/fleetReports.mjs +392 -0
  37. package/bin/lib/fleetRoster.mjs +470 -0
  38. package/bin/lib/fleetStartup.mjs +196 -0
  39. package/bin/lib/fleetWake.mjs +45 -0
  40. package/bin/lib/fleetWire.mjs +46 -0
  41. package/bin/lib/git.mjs +2 -2
  42. package/bin/lib/gitMergeIdentity.mjs +51 -0
  43. package/bin/lib/grant.mjs +5 -3
  44. package/bin/lib/help.mjs +3 -125
  45. package/bin/lib/holder.mjs +482 -0
  46. package/bin/lib/instance.mjs +124 -46
  47. package/bin/lib/knowledge.mjs +16 -799
  48. package/bin/lib/knowledgeFetch.mjs +38 -0
  49. package/bin/lib/knowledgeFileSync.mjs +112 -0
  50. package/bin/lib/knowledgeFiles.mjs +487 -0
  51. package/bin/lib/knowledgeLibrary.mjs +249 -0
  52. package/bin/lib/landed.mjs +2 -1
  53. package/bin/lib/listeners.mjs +99 -141
  54. package/bin/lib/localSessions.mjs +30 -555
  55. package/bin/lib/login.mjs +3 -2
  56. package/bin/lib/loginPath.mjs +3 -5
  57. package/bin/lib/machineDisconnect.mjs +203 -0
  58. package/bin/lib/machineEnv.mjs +22 -0
  59. package/bin/lib/machineLimits.mjs +61 -0
  60. package/bin/lib/machineListing.mjs +338 -0
  61. package/bin/lib/machines.mjs +78 -455
  62. package/bin/lib/machinesCommand.mjs +214 -0
  63. package/bin/lib/mcp-cli.mjs +2 -1
  64. package/bin/lib/patchRevert.mjs +60 -0
  65. package/bin/lib/prWorkflow.mjs +157 -0
  66. package/bin/lib/preflight.mjs +2 -1
  67. package/bin/lib/pressurePolicy.mjs +104 -0
  68. package/bin/lib/preview.mjs +2 -272
  69. package/bin/lib/previewFramePolicy.mjs +112 -0
  70. package/bin/lib/previewGatePolicy.mjs +236 -0
  71. package/bin/lib/previewRegistry.mjs +235 -0
  72. package/bin/lib/printable.mjs +2 -2
  73. package/bin/lib/procRegistry.mjs +28 -18
  74. package/bin/lib/procScan.mjs +146 -0
  75. package/bin/lib/processes.mjs +29 -37
  76. package/bin/lib/projectToolRuntimes.mjs +138 -0
  77. package/bin/lib/projectTools.mjs +13 -88
  78. package/bin/lib/prompts.mjs +28 -42
  79. package/bin/lib/repoPath.mjs +26 -0
  80. package/bin/lib/resources.mjs +7 -69
  81. package/bin/lib/resumeLost.mjs +107 -0
  82. package/bin/lib/runTurn.mjs +324 -0
  83. package/bin/lib/runtimeAntigravity.mjs +230 -0
  84. package/bin/lib/runtimeCapabilities.mjs +334 -0
  85. package/bin/lib/runtimeClaude.mjs +126 -0
  86. package/bin/lib/runtimeCodex.mjs +217 -0
  87. package/bin/lib/runtimeDetection.mjs +121 -0
  88. package/bin/lib/runtimeEvents.mjs +482 -0
  89. package/bin/lib/runtimeOfferings.mjs +4 -1
  90. package/bin/lib/runtimes.mjs +42 -1402
  91. package/bin/lib/safeFileName.mjs +74 -0
  92. package/bin/lib/standDownExit.mjs +181 -0
  93. package/bin/lib/standDownGate.mjs +48 -0
  94. package/bin/lib/startProject.mjs +279 -0
  95. package/bin/lib/trace.mjs +3 -3
  96. package/bin/lib/turnLock.mjs +68 -0
  97. package/bin/lib/turnProfile.mjs +77 -0
  98. package/bin/lib/uninstall.mjs +55 -45
  99. package/bin/lib/uninstallCopies.mjs +112 -0
  100. package/bin/lib/update.mjs +30 -17
  101. package/bin/lib/uplinkScrub.mjs +265 -0
  102. package/bin/lib/vault.mjs +22 -151
  103. package/bin/lib/vaultDiff.mjs +183 -0
  104. package/bin/lib/versionOrder.mjs +42 -0
  105. package/bin/lib/views.mjs +17 -17
  106. package/bin/lib/wikiRunner.mjs +630 -0
  107. package/bin/lib/work.mjs +188 -3034
  108. package/bin/lib/workAdoptCarry.mjs +94 -0
  109. package/bin/lib/workAgentCheck.mjs +278 -0
  110. package/bin/lib/workAgentMerges.mjs +54 -142
  111. package/bin/lib/workAgentPlans.mjs +14 -12
  112. package/bin/lib/workAgentPrecheck.mjs +393 -0
  113. package/bin/lib/workAgentPublish.mjs +177 -0
  114. package/bin/lib/workAgentReview.mjs +8 -666
  115. package/bin/lib/workAgentTurnBegun.mjs +109 -0
  116. package/bin/lib/workAgentTurnExecution.mjs +604 -0
  117. package/bin/lib/workAgentTurnOutcome.mjs +207 -0
  118. package/bin/lib/workAgentTurnReports.mjs +189 -0
  119. package/bin/lib/workAgentTurns.mjs +91 -898
  120. package/bin/lib/workAttachments.mjs +93 -0
  121. package/bin/lib/workBrain.mjs +53 -0
  122. package/bin/lib/workChildren.mjs +110 -0
  123. package/bin/lib/workCommandAudit.mjs +62 -0
  124. package/bin/lib/workDiffs.mjs +2 -1
  125. package/bin/lib/workNarration.mjs +152 -0
  126. package/bin/lib/workPlaceReports.mjs +426 -0
  127. package/bin/lib/workPlaces.mjs +228 -0
  128. package/bin/lib/workPreviews.mjs +31 -9
  129. package/bin/lib/workProcesses.mjs +4 -5
  130. package/bin/lib/workPullRequests.mjs +52 -126
  131. package/bin/lib/workReportQueue.mjs +254 -0
  132. package/bin/lib/workRetire.mjs +185 -0
  133. package/bin/lib/workSessionGroups.mjs +166 -0
  134. package/bin/lib/workSessionRuntime.mjs +164 -0
  135. package/bin/lib/workSessionTokens.mjs +68 -0
  136. package/bin/lib/workSessionTurns.mjs +892 -0
  137. package/bin/lib/workShip.mjs +9 -29
  138. package/bin/lib/workToolLog.mjs +76 -0
  139. package/package.json +3 -2
  140. package/bin/lib/patch.mjs +0 -363
package/bin/lib/fleet.mjs CHANGED
@@ -1,21 +1,23 @@
1
1
  /**
2
- * Fleet daemon. Install ONCE with a fleet credential; manage everything from
3
- * Flowviant. The daemon polls GET /api/fleet/agents, reconciles one persistent
4
- * git worktree + worker loop per roster agent, rotates each worker's short-lived
5
- * MCP token, and only spawns Claude when the server says an agent has work.
2
+ * The machine daemon — what `flowviant` runs once a credential is resolved.
3
+ * It starts (lock, preflight, the worktree home, the signal handlers), then
4
+ * polls GET /api/fleet/agents forever and hands each roster key to the lane
5
+ * that owns it. Nothing here spawns a CLI on its own initiative: every turn,
6
+ * ship, plan, merge or wiki sweep starts because a roster key named it.
7
+ *
8
+ * THIS FILE IS THE START SEQUENCE AND THE RECONCILE LOOP (SOLID audit
9
+ * 2026-09-26, F038): on every roster it decides what each key means and in
10
+ * what order. What a lane DOES lives beside it — the poll's query string
11
+ * (fleetRoster.mjs), the machine's own reports (fleetReports.mjs), holdership
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).
15
+ *
16
+ * Over the 500-line mark as ONE LIFECYCLE, not as debt: what is left is the
17
+ * intake order over roster keys, and most of its length is the argument for
18
+ * that order. Moving the order out would move the reconciliation itself.
6
19
  */
7
20
 
8
- import {
9
- mkdirSync,
10
- existsSync,
11
- rmSync,
12
- readdirSync,
13
- statSync,
14
- } from 'node:fs';
15
- import { execFileSync } from 'node:child_process';
16
- import { createHash } from 'node:crypto';
17
- import { homedir } from 'node:os';
18
- import { join, basename } from 'node:path';
19
21
  import {
20
22
  VERSION,
21
23
  FLEET_URL,
@@ -23,1137 +25,64 @@ import {
23
25
  USER_AGENT,
24
26
  MCP_URL,
25
27
  SAFE,
26
- DAEMON_INSTANCE,
27
- MACHINE_HOST,
28
- PROCESS_STARTED_AT,
29
- POLL_SECONDS,
30
- MAX_CONCURRENT,
31
- IDLE_SECONDS,
32
28
  RECONCILE_SECONDS,
33
- REFRESH_BEFORE_SECONDS,
34
- LIVE,
35
29
  AUTO_UPDATE,
36
30
  CREDENTIAL,
37
31
  learnProjectId,
38
32
  storedCredentialInUse,
39
33
  } from './config.mjs';
40
- import { projectLabel, safeName, setStoredProjectName } from './credentials.mjs';
41
- import { credentialRejected } from './authReject.mjs';
42
- import { handleVersionSignal, cmpVersion } from './update.mjs';
43
- import { emitMachineEvent, writeDaemonState } from './desktopContract.mjs';
44
- import { git, gitNetAsync, resetWorktree, repoRootOrDie, detectBaseRef, originSlug, baseBranchName, isValidPrUrl, isValidBranch, isSafePathSegment, excludeInWorktree, usableBaseRef } from './git.mjs';
34
+ import { projectLabel } from './credentials.mjs';
35
+ import { handleVersionSignal } from './update.mjs';
36
+ import { compareVersions } from './versionOrder.mjs';
37
+ import { emitMachineEvent } from './daemonLogging.mjs';
38
+ import { writeDaemonState } from './daemonState.mjs';
39
+ import { repoRootOrDie, detectBaseRef, baseBranchName, excludeInWorktree, usableBaseRef } from './git.mjs';
45
40
  import { c, info, note, ok, warn, fail } from './ui.mjs';
46
- import { revertPatch, withPatchLock } from './patch.mjs';
47
- import {
48
- sleep,
49
- mcpFor,
50
- runTurn,
51
- sawSentinel,
52
- blockedId,
53
- SYSTEM_WIKI,
54
- WIKI_KICKOFF,
55
- SYSTEM_REGROUND,
56
- REGROUND_KICKOFF,
57
- } from './claude.mjs';
58
- import { reapOrphanPreviews } from './preview.mjs';
59
- import { acquireInstanceLock } from './instance.mjs';
41
+ import { sleep } from './claude.mjs';
42
+ import { reapOrphanPreviews } from './previewRegistry.mjs';
60
43
  import { preflight } from './preflight.mjs';
61
44
  import { connectStream } from './stream.mjs';
62
- import { ensureVault, syncVault } from './vault.mjs';
63
- import { envQueryParams, myPubB64, scanEnvForScrub, scrub as envScrub } from './env.mjs';
45
+ import { myPubB64 } from './boxIdentity.mjs';
46
+ import { scanEnvForScrub } from './env.mjs';
64
47
  import { sweepVaultArtefactsOnce } from './vaultArtefacts.mjs';
65
- import { processDeployJobs, reportDeployConfig } from './deploy.mjs';
66
- import { machineSnapshot } from './resources.mjs';
67
48
  import {
68
- detectRuntimes,
69
- knownMcpServers,
70
- knownSkills,
71
- pickRuntimeFor,
72
- probeSkillsOnce,
73
- recordMcpServers,
74
- recordSkills,
75
- RUNTIMES,
76
- THINK_MARKER,
77
- } from './runtimes.mjs';
49
+ deploysInFlight,
50
+ deploysInFlightLabels,
51
+ processDeployJobs,
52
+ reportDeploysAbandoned,
53
+ whenDeploysSettle,
54
+ } from './deploy.mjs';
55
+ import { createLeave } from './standDownExit.mjs';
56
+ import { stopWorkLanes } from './standDownGate.mjs';
57
+ import { markLockDraining } from './instance.mjs';
58
+ import { reportDeployConfig } from './deployConfig.mjs';
59
+ import { probeSkillsOnce } from './runtimeCapabilities.mjs';
78
60
  import { createWorkManager } from './work.mjs';
79
- import { createKnowledgeSync, knowledgeFetcher, FLOWVIANT_OWN_PATHS } from './knowledge.mjs';
80
- import { effectiveMaxTurns, setServerMaxTurns } from './admission.mjs';
81
- import { scanLocalSessions, ourConversationIds } from './localSessions.mjs';
82
- import { repoState } from './repoState.mjs';
83
- import { claudeAuthContext } from './claudeAuth.mjs';
84
- import { readBaseTools, runnerToolCapabilities, toolReadout } from './projectTools.mjs';
85
- import { runtimeOfferings } from './runtimeOfferings.mjs';
86
-
87
- /** Said once per process — see the catch around `envQueryParams` below. */
88
- let warnedEnvIdentity = false;
89
-
90
- /**
91
- * HAS THIS PROCESS ALREADY ASKED FOR THE MACHINE? (0.91.0)
92
- *
93
- * The owner's ruling on two boxes running one project: "it should kill the
94
- * first one and take over". So a daemon asks on the FIRST poll of its life and
95
- * never again, and this pair is the whole of that "never again".
96
- *
97
- * ONE ASK PER PROCESS IS THE SAFETY, NOT A POLITENESS. A param that kept asking
98
- * would be two boxes trading a machine back and forth every ten seconds. Asked
99
- * once, the story ends at the first exchange, because the box that loses
100
- * SETTLES ITS TURNS AND EXITS rather than restarting — and a process that has
101
- * exited sends no more first polls.
102
- *
103
- * THAT LAST SENTENCE IS TRUE OF THE PROCESS AND NOT OF THE UNIT, which is why
104
- * the server stopped relying on it (2026-09-19). Under `Restart=always`, pm2 or
105
- * docker, exit 0 is a relaunch and the relaunch's first poll is a genuine one.
106
- * `takeMachineHolderNow` refuses a take by the box it just displaced for the
107
- * length of the displaced window, so the ping-pong is bounded on the side that
108
- * can see both boxes. This module still keeps its half of the bargain.
109
- *
110
- * SPENT ON A DELIVERED POLL, NOT ON AN ATTEMPTED ONE, which is why this is two
111
- * functions instead of one consuming read. A daemon that starts while the wire
112
- * is down retries every ten seconds; spending the ask on the failed attempt
113
- * would mean the box you walked over to and started never takes the machine,
114
- * and nothing anywhere would say why.
115
- *
116
- * IT IS NOT THE DELETED FLAG COMING BACK. There is still nothing to type: the
117
- * terminal surface is `npx flowviant` and `npx flowviant login`, and what
118
- * claims here is starting the daemon, which is already one of those two.
119
- *
120
- * Exported so both properties can be proved without a server, a credential or a
121
- * second box — they are the contract, and nothing else in the file states them.
122
- */
123
- /**
124
- * AND A RESTART IS NOT A PERSON (2026-09-19, the review).
125
- *
126
- * `update.mjs` re-execs the daemon with `FLOWVIANT_REEXEC='1'` after an
127
- * UNATTENDED auto-update, which is on by default. Born `false`, the new process
128
- * would then spend a fresh ask and TAKE THE PROJECT'S MACHINE — so a standby
129
- * sitting quietly on somebody's second computer would seize the machine off the
130
- * live holder in the middle of the night, settling its running turns as moved,
131
- * because npm published a patch. Nobody was at either keyboard, and nothing on
132
- * any surface would say why.
133
- *
134
- * The ask belongs to the GESTURE, not to the process: what claims a machine is
135
- * a person typing `npx flowviant`, and a re-exec is the same start continuing.
136
- * So a re-executed daemon is born with the ask already spent and behaves
137
- * exactly as it did before the update — it keeps the machine if it had it (arm
138
- * (a) stamps it), and stands by if it did not.
139
- */
140
- let machineAskSpent = process.env.FLOWVIANT_REEXEC === '1';
141
- export function machineAskPending() {
142
- return !machineAskSpent;
143
- }
144
- export function spendMachineAsk() {
145
- machineAskSpent = true;
146
- }
147
-
148
- async function fetchRoster(
149
- haveIds,
150
- livePreviewSessionIds = [],
151
- heldSessionIds = [],
152
- /** The churn ADMISSION verdict, taken by the caller from the same `admit`
153
- * every unattended lane asks — see the `pr` param below for why it is the
154
- * admission and not the pressure reading alone. Undefined where the caller
155
- * has no admission to offer, which reads exactly like an older daemon. */
156
- churnHold = undefined,
157
- /** The checkout this daemon serves, for the `cp` report below. Passed in
158
- * rather than re-derived: `repoRootOrDie` is resolved once at startup and
159
- * running git on every poll to re-learn a constant would be a syscall for
160
- * a readout. */
161
- repoRoot = null,
162
- baseRef = null
163
- ) {
164
- const url = new URL(FLEET_URL);
165
- if (haveIds.length) url.searchParams.set('have', haveIds.join(','));
166
- // What this machine will run at once. The server grows lanes to meet waiting
167
- // work beneath this, instead of the user pre-sizing a pool by hand — only the
168
- // machine knows its cores, its RAM and whose Claude quota is being spent.
169
- // Older servers ignore the param, so sending it is always safe.
170
- url.searchParams.set('capacity', String(MAX_CONCURRENT));
171
- // WHICH DAEMON this machine runs, so the server can gate version-dependent
172
- // work — codex Workbench tabs are only created for machines whose daemon can
173
- // actually serve them (dv >= 0.46.0). The same source the self-update check
174
- // compares against the roster's daemon.latest (config.mjs VERSION, read off
175
- // our own package.json). Older servers ignore unknown params, so sending it
176
- // unconditionally is always safe.
177
- url.searchParams.set('dv', VERSION);
178
- // The permission posture this machine runs turns under — '1' when
179
- // FLOWVIANT_SAFE narrows the toolset, '0' when everything is granted. A
180
- // statement of configuration, not a request: the app SHOWS it in Settings
181
- // so a team can see whether the shared box runs wide open, and enforces
182
- // nothing (membership is the consent boundary). Older servers ignore it.
183
- url.searchParams.set('safe', SAFE ? '1' : '0');
184
- // WHICH PROCESS, so the server can lease preview work to exactly one of two
185
- // daemons on one credential. Older servers ignore unknown params.
186
- url.searchParams.set('di', DAEMON_INSTANCE);
187
- // WHICH BOX, by name, so the app can say "your machine is mac-mini" instead
188
- // of naming a public key. Display only: arbitration is keyed on `envpub`,
189
- // which is durable per box, while a hostname is neither unique nor stable.
190
- // Absent when the host has no readable name — an older daemon looks the same,
191
- // and both mean "nobody said", which is what the nameless fallback renders.
192
- if (MACHINE_HOST) url.searchParams.set('mh', MACHINE_HOST);
193
- /**
194
- * WHICH CHECKOUT, WHICH PROCESS, AND SINCE WHEN (0.91.0) — the three facts
195
- * that turn "a box polled" into a row somebody can act on.
196
- *
197
- * The owner could not answer a plain question about his own machines: "im not
198
- * sure if i have any duplicate or redundant daemons running". The hostname
199
- * alone cannot answer it either, because ONE box legitimately runs several
200
- * daemons — "i can have 1 daemon in one directory, 1 in another as a
201
- * differentiator of projects" — so `cp` is what makes two rows on one box
202
- * legible, and `pid` + `st` are what let somebody standing at that box find
203
- * the process and tell a long-lived daemon from one that has restarted nine
204
- * times since they last looked.
205
- *
206
- * DAEMON→SERVER REPORTS, so no floor: an older daemon sends none of these and
207
- * the server stores null, which reads as "it did not say" rather than as a
208
- * box with no checkout. Bounded here as well as at the boundary — a query
209
- * string is not a log, and this one is rendered into a terminal.
210
- */
211
- if (repoRoot) url.searchParams.set('cp', String(repoRoot).slice(0, 256));
212
- url.searchParams.set('pid', String(process.pid));
213
- url.searchParams.set('st', PROCESS_STARTED_AT);
214
- /**
215
- * THE FIRST POLL OF THIS PROCESS ASKS FOR THE MACHINE (`claim`, 0.91.0).
216
- *
217
- * The owner, on two boxes running one project: "it should kill the first one
218
- * and take over." Starting the daemon on the computer in front of you IS the
219
- * gesture — there is no flag, no env var and no third command, so the ruling
220
- * that deleted the old machine-moving flag ("i dont intend to run or do
221
- * anything in the terminal besides npx flowviant or npx flowviant login")
222
- * stands untouched.
223
- *
224
- * SENT ONCE PER PROCESS, and that is the safety rather than a politeness: see
225
- * `askForMachineOnce`. The server takes holdership there and then, the box it
226
- * displaces learns through the existing named-and-windowed `displaced` signal
227
- * and exits 0, and nothing restarts to ask again.
228
- *
229
- * AN OLDER SERVER IGNORES IT and the old standby behaviour stands, which is
230
- * why this needs no floor of its own — the server applies one (it will not
231
- * take a machine from a box too old to be told it lost it).
232
- */
233
- if (machineAskPending()) url.searchParams.set('claim', '1');
234
- // A poll reports what this box IS. Moving the project's machine onto it, when
235
- // this process has already spent its one ask above, is a gesture a person
236
- // makes in the app — and the daemon learns that outcome on its next poll like
237
- // every other holder fact.
238
- // Which shares this machine is still serving. It rides the poll rather than
239
- // taking an endpoint of its own: one beat, no floor, and the stale window is
240
- // the reconcile interval instead of minutes — which matters, because a share
241
- // the server still calls live is a 530 on somebody's phone. Always set, even
242
- // empty: '' means "serving none", absent would mean "an older daemon".
243
- url.searchParams.set('pv', livePreviewSessionIds.join(','));
244
- // The sessions this daemon holds a worktree for. Its LEASE on each renews
245
- // here — one beat, no extra endpoint, and the server can tell "this daemon is
246
- // still serving that tab" from "it went away" within a reconcile interval
247
- // instead of minutes. Always set, even empty: '' means "holding none".
248
- url.searchParams.set('ws', heldSessionIds.join(','));
249
- // WHICH CLIs this machine actually has, so the app can stop guessing.
250
- //
251
- // Until now every surface that listed Gemini or Codex said "not wired up yet"
252
- // and meant it literally: nothing had ever looked. That was the honest answer
253
- // while it was true, and it stops being honest the moment a second runtime can
254
- // run — an app that cannot tell "Codex is not installed" from "we never
255
- // checked" will confidently tell you the wrong one.
256
- //
257
- // A statement about this MACHINE and nothing else: no account, no quota, no
258
- // entitlement. Detection is cached after the first poll (one version probe per
259
- // CLI), so this costs a query param thereafter. Older servers ignore an
260
- // unknown param, so sending it is always safe.
261
- try {
262
- const drivable = detectRuntimes()
263
- .filter((r) => r.dispatchable)
264
- .map((r) => r.id);
265
- if (drivable.length) url.searchParams.set('runtimes', drivable.join(','));
266
- } catch {
267
- /* detection is best-effort — a probe must never fail the poll */
268
- }
269
- // WHAT THE CLI CAN BE ASKED FOR BY NAME, so the composer can autocomplete a
270
- // `/` the way the terminal does. Learned from the init event of a turn we
271
- // already ran (runtimes.mjs) — never probed, because spawning a CLI to fill a
272
- // dropdown would spend the operator's quota on an affordance.
273
- //
274
- // NOT SENT until a turn has taught us: absent means "no turn has run here
275
- // yet", and the app renders no menu rather than asserting this machine has no
276
- // skills. An empty report, though, IS a fact and is sent as such — hence the
277
- // null check rather than a truthiness check on the array.
278
- try {
279
- const skills = knownSkills();
280
- if (skills !== null) url.searchParams.set('skills', skills.join(','));
281
- } catch {
282
- /* best-effort — the poll must never fail on a readout */
283
- }
284
- // WHICH MCP SERVERS AND CONNECTORS THE CLI MOUNTED, AND HOW EACH STANDS
285
- // (0.97.0) — `[{n, s}]`, learned off the same init event as the skills, this
286
- // daemon's own `flowviant` server excluded AND every `connected` one with it:
287
- // only what needs something leaves the box (runtimes.mjs, recordMcpServers —
288
- // a teammate has no business reading which services the operator signed
289
- // into). The same three states: NOT SENT
290
- // until a turn (or the one probe) has taught us, `[]` sent as the fact it is.
291
- // The app names every server that is not connected — a connector that needs
292
- // a sign-in AT THIS BOX above all — under the serving machine's row. A
293
- // daemon→server report: an older server ignores the unknown param.
294
- try {
295
- const mcp = knownMcpServers();
296
- if (mcp !== null) url.searchParams.set('mcp', JSON.stringify(mcp));
297
- } catch {
298
- /* best-effort — the poll must never fail on a readout */
299
- }
300
- if (repoRoot && baseRef) {
301
- try {
302
- const offerings = detectRuntimes().some((rt) => rt.id === 'codex' && rt.installed)
303
- ? runtimeOfferings(repoRoot, baseRef) : {};
304
- for (const [param, report] of Object.entries(offerings)) {
305
- if (Object.keys(report).length) {
306
- const payload = JSON.stringify(report);
307
- if (payload.length <= 8_000 && url.toString().length + encodeURIComponent(payload).length <= 14_000) url.searchParams.set(param, payload);
308
- }
309
- }
310
- } catch { /* local readouts never fail the poll */ }
311
- }
312
- // THIS BOX'S IDENTITY — `envpub`, and since 2026-09-21 nothing else.
313
- //
314
- // It carried two more params while the secrets vault existed: `envv` (the
315
- // materialized bundle version) and `envskip` (the target files the
316
- // materializer refused to write, where the EMPTY STRING was a real report and
317
- // a truthiness filter here silently dropped it). Both died with the vault and
318
- // the server no longer reads either, so the loop's `!= null` has nothing left
319
- // to defend — it stays anyway, because it is the correct general shape for
320
- // forwarding a param map and re-deriving that lesson is how it was lost the
321
- // first time.
322
- //
323
- // `envpub` ITSELF IS UNCHANGED: same key, same base64, same keypair file. A
324
- // box upgrading into this release must stay the SAME box to holdership
325
- // arbitration and to the machine registry.
326
- try {
327
- for (const [k, v] of Object.entries(await envQueryParams())) {
328
- if (v != null) url.searchParams.set(k, v);
329
- }
330
- } catch (e) {
331
- /* env identity is best-effort — the poll must never fail on it */
332
- /**
333
- * …BUT IT IS NOT SILENT, because since 2026-09-14 losing it costs
334
- * something visible. `envpub` is how the server tells two computers apart,
335
- * so a poll without one is EXEMPT from arbitration — it is served in full,
336
- * which is right — and nothing stamps `holder_heard_at`, which is the
337
- * column presence is now read from. The app therefore says the machine is
338
- * offline while this daemon is sitting here answering turns, and before
339
- * this line the only evidence anywhere was a keypair file nobody looks at.
340
- *
341
- * Once per process: it is the same failure on every poll, and a reason
342
- * repeated every few seconds is a reason nobody reads.
343
- */
344
- if (!warnedEnvIdentity) {
345
- warnedEnvIdentity = true;
346
- console.warn(
347
- `[flowviant] could not read ${'~/.flowviant/env-keypair.json'} (${e?.message ?? e}).\n` +
348
- ' This machine cannot identify itself, so the app may show it as offline while it works.\n' +
349
- ' It has NOT been replaced — that file is what the project secrets are sealed to.'
350
- );
351
- }
352
- }
353
- /**
354
- * WHY THIS MACHINE IS NOT TAKING NEW WORK, in its own measured words.
355
- *
356
- * Three states, and the third is why this is a param and not a header:
357
- * · a reason — the churn ADMISSION refused; new unattended work is being
358
- * deferred, and the board can say so AT the agent that is
359
- * waiting instead of leaving it looking like a slow model.
360
- * · `-` — asked, and nothing is holding anything back. A POSITIVE
361
- * fact, which is what lets the server clear a stale reason
362
- * rather than letting one sit there being true-looking
363
- * forever.
364
- * · absent — an older daemon, or a poll with no admission to ask. The
365
- * reserved meaning, and the reason nothing is sent in that
366
- * case: silence must not be readable as "fine".
367
- *
368
- * `FLOWVIANT_NO_PRESSURE_GUARD` no longer silences the param, and should not:
369
- * it turns off the MEMORY AND LOAD half, and the concurrency half it does not
370
- * touch is still a true account of why nothing is starting. What the operator
371
- * asked for is a box that is not second-guessed about its own memory, not an
372
- * agent that sits still with no explanation.
373
- *
374
- * IT IS THE WHOLE ADMISSION, AND IT USED TO BE THE PRESSURE HALF ONLY. The
375
- * argument for narrowing it was that "four turns are running" is a capacity
376
- * statement — but the effect was worse than the thing it avoided: a machine
377
- * refusing every agent turn at its ceiling sent `pr=-`, which says MEASURED
378
- * AND FINE, so the server cleared any stored reason and the board fell
379
- * through to "nothing has polled this turn for 12m" over a daemon that was
380
- * polling every ten seconds and declining on purpose. The machine positively
381
- * asserted health at the one moment it was refusing.
382
- *
383
- * And CLAUDE.md already carves this exact shape out: queueing is said "AT THE
384
- * THING THAT IS WAITING, in the moment, never budgeted for in advance on a
385
- * global chip". The relayed sentence is `admission.mjs`'s, and it names
386
- * ACTIVITY — "the machine is already running 3 CLI turns" — never the
387
- * ceiling, never headroom, and never anywhere but on the row that is stalled.
388
- * That is the same shape as a CLI relaying that it hit its own limit.
389
- *
390
- * `URLSearchParams` does its own encoding; the slice is the belt against a
391
- * pathological reason.
392
- */
393
- try {
394
- if (churnHold !== undefined) {
395
- url.searchParams.set('pr', churnHold ? String(churnHold.reason).slice(0, 160) : '-');
396
- /**
397
- * …AND THE BOUND THE REFUSAL IS ABOUT (2026-09-17, `mt`).
398
- *
399
- * THIS AMENDS THE PARAGRAPH DIRECTLY ABOVE, which says the relayed
400
- * sentence "names ACTIVITY … never the ceiling, never headroom". That
401
- * rule was written to stop a capacity meter, and it succeeded so
402
- * completely that a machine which could only ever run ONE turn had no way
403
- * to say so — the owner, verbatim: "we should have ui indicating this on
404
- * the board view and a ui indicating or showing the agent as waiting or
405
- * queued as a result of that resource constraint. thats why i was
406
- * immediately confused. theres nothing telling me that i could only have
407
- * one agent on the board."
408
- *
409
- * So the ceiling crosses the wire, and the narrow shape that survives is
410
- * the same carve-out the sentence beside it already lives under: it is
411
- * shown in SETTINGS (beside the dial that sets it — a control with no
412
- * readout is not a control) and on the BOARD only while a real agent is
413
- * actually being deferred. Never a resting chip, and never headroom:
414
- * nothing anywhere subtracts this from the live count to advertise room.
415
- *
416
- * It rides the `pr` gate deliberately — this is the bound that admission
417
- * measured against, so a poll with no admission to ask has no effective
418
- * ceiling to report either, and absence keeps meaning "an older daemon".
419
- * `capacity` above is the DERIVED number and predates the dial; it is a
420
- * dispatch-era fossil the server ignores, and the two are not the same
421
- * fact.
422
- */
423
- url.searchParams.set('mt', String(effectiveMaxTurns()));
424
- }
425
- } catch {
426
- /* a readout — the poll must never fail on one */
427
- }
428
- // An explicit User-Agent is required: Node's default ("node"/empty) trips
429
- // Cloudflare Bot Fight Mode (403). A descriptive product UA passes.
430
- const res = await fetch(url, {
431
- headers: { Authorization: `Bearer ${FLEET_TOKEN}`, 'User-Agent': USER_AGENT },
432
- signal: AbortSignal.timeout(30_000), // a black-holed poll must not stall the loop
433
- });
434
- if (res.status === 401 || res.status === 403) {
435
- // Revoked or expired — retrying can't recover, so signal exit. But ONLY on
436
- // the API's own refusal: an edge 403 (Cloudflare's bot checks, which this
437
- // very client class trips) is a retryable failure, and exiting on one took
438
- // a machine offline for good over a credential that was still valid.
439
- if (await credentialRejected(res)) {
440
- const e = new Error(`fleet credential rejected (${res.status})`);
441
- e.auth = true;
442
- throw e;
443
- }
444
- throw new Error(`fleet poll refused by something in front of the API (${res.status})`);
445
- }
446
- if (!res.ok) throw new Error(`fleet poll failed (${res.status})`);
447
- // THE ASK IS SPENT HERE AND NOWHERE ELSE — on a poll the server actually
448
- // answered. Spending it where the param is SET would lose the claim of a
449
- // daemon that started while the wire was down: it would retry, silently
450
- // without `claim`, and the box somebody walked over to and started would
451
- // never take the machine.
452
- spendMachineAsk();
453
- const body = await res.json();
454
- // Validate the shape here so a malformed 200 (deploy hiccup, error envelope)
455
- // throws a NORMAL retryable error inside the loop's try/catch, instead of a
456
- // `roster.agents.map` TypeError escaping to top-level and killing the daemon.
457
- const data = body?.data;
458
- if (!data || !Array.isArray(data.agents)) {
459
- throw new Error('fleet poll returned an unexpected shape');
460
- }
461
- // Drop roster agents with an unsafe id BEFORE they're used as a path segment.
462
- data.agents = data.agents.filter((a) => {
463
- if (isSafePathSegment(a?.agentId)) return true;
464
- warn(`ignoring roster agent with an invalid id: ${JSON.stringify(a?.agentId)}`);
465
- return false;
466
- });
467
- return data; // { mcpUrl, leaseTtlSeconds, agents: [{agentId,name,token,reviewGate,hasWork}] }
468
- }
469
-
470
- const RUN_DIFFSTAT_URL = FLEET_URL.replace(/\/agents\/?$/, '/run-diffstat');
471
-
472
- /**
473
- * Post what a run has changed, every 20s, until the returned stop() is called.
474
- *
475
- * Daemon-side rather than an MCP tool the agent calls: the agent forgets, each
476
- * call costs tokens, and anything the agent reports about itself is downstream
477
- * of whatever it is currently reading. The daemon owns the worktree, so it can
478
- * just look.
479
- *
480
- * Posts when the numbers MOVED, and otherwise once every couple of minutes to
481
- * say the worktree is still being watched. Both halves are needed. Writing the
482
- * same row every 20s would make a wedged turn look busy; never re-writing it
483
- * makes a HEALTHY run look dead, because the reader treats a sample it has not
484
- * seen refreshed in three minutes as a daemon that stopped — and an agent that
485
- * finishes editing and then spends fifteen minutes running the test suite
486
- * produces exactly the same silence as one that died. REFRESH_MS sits well
487
- * inside that window so an idle-but-live worktree keeps its panel.
488
- */
489
- const DIFFSTAT_REFRESH_MS = 120_000;
490
-
491
-
492
- /**
493
- * Terminal-session presence: tell the server which Claude sessions exist in
494
- * this repo (localSessions.mjs reads them off Claude's own on-disk state), so
495
- * the Workbench can offer "adopt this terminal session as a tab". Best-effort
496
- * in exactly the way the env/runtimes blocks are — a presence report that can
497
- * fail a poll is worse than no presence at all — with three quiet economies:
498
- * the scan runs at most once a minute (the reconcile loop ticks far faster), a
499
- * report identical to the last DELIVERED one is not re-sent, and a 404 means
500
- * an older server that has never heard of the endpoint, after which this
501
- * process stops asking (a deploy that adds it also restarts nothing on this
502
- * machine, so silence-until-restart costs one daemon restart, not a feature).
503
- */
504
- const LOCAL_SESSIONS_URL = FLEET_URL.replace(/\/agents\/?$/, '/local-sessions');
505
- const LOCAL_SESSIONS_SCAN_MS = 60_000;
506
- // The web hides a report older than 10 minutes (presence must not linger as
507
- // fact after the machine dies), so an UNCHANGED report is re-sent inside that
508
- // window anyway — the re-send is the machine's heartbeat on this fact, and
509
- // suppressing it entirely would blank the strip while everything still holds.
510
- const LOCAL_SESSIONS_RESEND_MS = 5 * 60_000;
511
- let localSessionsUnsupported = false; // the server 404'd — quiet until restart
512
- let localSessionsScanAt = 0;
513
- let localSessionsSent = null; // last payload the server ACCEPTED, stringified
514
- let localSessionsSentAt = 0;
515
- async function maybeReportLocalSessions({ repoRoot, excludeDirs, excludeIds }) {
516
- if (localSessionsUnsupported) return;
517
- if (Date.now() - localSessionsScanAt < LOCAL_SESSIONS_SCAN_MS) return;
518
- localSessionsScanAt = Date.now();
519
- let payload;
520
- try {
521
- // scanLocalSessions orders deterministically, so this string only changes
522
- // when the facts on disk do — the dedup below compares whole payloads.
523
- payload = JSON.stringify({
524
- sessions: scanLocalSessions({ repoRoot, excludeDirs, excludeIds }),
525
- });
526
- } catch {
527
- return; // presence must never throw into the poll loop
528
- }
529
- if (payload === localSessionsSent && Date.now() - localSessionsSentAt < LOCAL_SESSIONS_RESEND_MS)
530
- return;
531
- try {
532
- const res = await fetch(LOCAL_SESSIONS_URL, {
533
- method: 'POST',
534
- headers: {
535
- Authorization: `Bearer ${FLEET_TOKEN}`,
536
- 'User-Agent': USER_AGENT,
537
- 'Content-Type': 'application/json',
538
- },
539
- signal: AbortSignal.timeout(15_000),
540
- body: payload,
541
- });
542
- if (res.status === 404) {
543
- localSessionsUnsupported = true; // older server — it REPLACED nothing here
544
- return;
545
- }
546
- // Only an accepted report counts as sent; anything else forgets the
547
- // last-sent payload so the next pass retries instead of dedup-suppressing
548
- // a report the server never received.
549
- localSessionsSent = res.ok ? payload : null;
550
- localSessionsSentAt = res.ok ? Date.now() : 0;
551
- } catch {
552
- localSessionsSent = null;
553
- localSessionsSentAt = 0;
554
- }
555
- }
556
-
557
- /**
558
- * EVERY BRANCH AND WORKTREE ON THIS MACHINE, pushed on the same beat as
559
- * presence — the answer to "is my Claude leaving a mess in here?".
560
- *
561
- * Same three economies as the presence report above and for the same reasons:
562
- * scanned at most once a minute, not re-sent while identical (repoState orders
563
- * deterministically so the string only moves when the repo does), and silent
564
- * for the rest of the process once an older server 404s.
565
- *
566
- * ONE DIFFERENCE, deliberate: there is no re-send heartbeat window. Presence
567
- * expires in the UI because a session that ENDED must stop reading as live;
568
- * a branch list is not presence — a branch that existed a minute ago still
569
- * exists — so re-posting an unchanged list would be a write per machine per
570
- * five minutes to say nothing at all.
571
- */
572
- const REPO_STATE_URL = FLEET_URL.replace(/\/agents\/?$/, '/repo-state');
573
- const REPO_STATE_SCAN_MS = 60_000;
574
- let repoStateUnsupported = false; // the server 404'd — quiet until restart
575
- let repoStateScanAt = 0;
576
- let repoStateSent = null; // last payload the server ACCEPTED, stringified
577
- async function maybeReportRepoState({ repoRoot, baseRef }) {
578
- if (repoStateUnsupported) return;
579
- if (Date.now() - repoStateScanAt < REPO_STATE_SCAN_MS) return;
580
- repoStateScanAt = Date.now();
581
- let payload;
582
- try {
583
- // The PARAM, and nothing else: this function sits at MODULE scope, where
584
- // runFleetDaemon's `getBaseRef` closure does not exist. An earlier version
585
- // reached for it anyway, the ReferenceError landed in the catch below —
586
- // written for an unreadable repo, silent by design — and this endpoint was
587
- // never once posted to. The caller reads the loop's live `baseRef` at call
588
- // time, so the value here is always current.
589
- const state = repoState(repoRoot, baseRef);
590
- if (!state) return; // not readable — say nothing rather than say "none"
591
- /**
592
- * WHICH CREDENTIAL THIS MACHINE'S CLI RESOLVES, on the same beat.
593
- *
594
- * It rides this report rather than getting an endpoint of its own because
595
- * it is the same KIND of fact — something only the machine can see, pushed
596
- * because a pull client can never be asked — and it inherits this
597
- * function's three economies for free: scanned once a minute, deduped
598
- * against the last ACCEPTED payload, and silent forever once an older
599
- * server 404s.
600
- *
601
- * The dedup keeps it cheap: `claudeAuthContext` is presence plus two dates,
602
- * so the string is stable across scans and only moves when something about
603
- * the credential actually moves. It DOES move when the CLI refreshes its
604
- * access token — that stamp is carried for diagnostics — which costs a
605
- * write roughly twice a day and is the whole of the extra traffic. The
606
- * WARNING is built on the 22-day refresh clock instead, so a note never
607
- * fires on the ~12h cycle.
608
- *
609
- * NO VERSION FLOOR, and none is possible to need: this is a daemon→server
610
- * report, and an older SERVER strips the unknown key in its zod parse and
611
- * stores the rest exactly as before.
612
- */
613
- payload = JSON.stringify({ ...state, auth: claudeAuthContext() });
614
- } catch {
615
- return; // a readout must never throw into the poll loop
616
- }
617
- if (payload === repoStateSent) return;
618
- try {
619
- const res = await fetch(REPO_STATE_URL, {
620
- method: 'POST',
621
- headers: {
622
- Authorization: `Bearer ${FLEET_TOKEN}`,
623
- 'User-Agent': USER_AGENT,
624
- 'Content-Type': 'application/json',
625
- },
626
- signal: AbortSignal.timeout(15_000),
627
- body: payload,
628
- });
629
- if (res.status === 404) {
630
- repoStateUnsupported = true; // older server — nothing was replaced here
631
- return;
632
- }
633
- // Only an ACCEPTED report counts: anything else forgets it so the next
634
- // pass retries rather than dedup-suppressing a report nobody received.
635
- repoStateSent = res.ok ? payload : null;
636
- } catch {
637
- repoStateSent = null;
638
- }
639
- }
640
-
641
- /**
642
- * WHAT IS IN THIS BOX'S ENVIRONMENT, BY NAME (2026-09-21) — the one readout the
643
- * deleted secrets vault was genuinely wanted for.
644
- *
645
- * The owner, on the vault: *"no i dont want it. unless its needed where i want
646
- * to show the env of each of the machines (for comparison)."* So the custody
647
- * went and the comparison stayed, rebuilt as a plain report: for each `.env*`
648
- * file in the CHECKOUT ROOT, the variable NAMES and an 8-hex VALUE FINGERPRINT.
649
- * **The value never leaves the box.** Two machines holding the same fingerprint
650
- * for `DATABASE_URL` agree; two different fingerprints is the whole answer to
651
- * "why does it work over there", and neither requires Flowviant to hold a
652
- * secret, seal a key, or own a recovery code.
653
- *
654
- * ONE POST PER BOX PER CHANGE. The report is hashed and compared against the
655
- * last one the server ACCEPTED, so a box whose env is stable posts exactly once
656
- * per daemon and then never again — the same economy `maybeReportRepoState`
657
- * keeps next door, and for the same reason: an env file is not PRESENCE, so
658
- * re-posting an unchanged list would be a write per machine per minute to say
659
- * nothing.
660
- *
661
- * A PERMANENT 4xx IS TREATED AS DELIVERED, which is the `/fleet/agent-trace`
662
- * precedent stated there: an older SERVER 404s every batch, and a daemon that
663
- * held them would keep re-posting a body nobody will ever read. Which statuses
664
- * count as permanent is `envReportIsPermanent`, and it is a SHORT list for the
665
- * reason stated there; everything else forgets the dedup so the next beat tries
666
- * again.
667
- *
668
- * THE TOTALS RIDE BESIDE THE CAPPED LIST (2026-09-21, the review). The report
669
- * is bounded at eight files and two hundred variables, and a list silently cut
670
- * at a cap reads as the whole directory — so `filesTotal` and `varsTotal` (the
671
- * numbers BEFORE the caps) go on the wire beside it and the app can say "N more
672
- * not shown". Same rule `repoState` already keeps for branches and worktrees.
673
- * Both are optional on the server, so an older server parsing only `files` is
674
- * unaffected and needs no floor.
675
- *
676
- * NO VERSION FLOOR, and none is possible to need: it is a daemon→server report
677
- * on a NEW endpoint, so an older server answers 404 once and is never asked
678
- * again, and an older daemon simply never posts. The report's presence IS the
679
- * capability.
680
- *
681
- * IT ALSO FEEDS THE SCRUBBER, on every scan and whether or not anything is
682
- * posted (`scanEnvForScrub`). That is the half that must not be skipped: with
683
- * the vault gone these files are what `scrub()` redacts out of turn streams,
684
- * tool events, traces and deploy logs, and the dedup above is about the WIRE,
685
- * never about what this box knows to hide.
686
- */
687
- /**
688
- * WHICH REFUSAL IS FINAL — pure, exported, and deliberately a SHORT list
689
- * (2026-09-21, the review).
690
- *
691
- * The first cut treated EVERY 4xx as permanent, which quietly turned one bad
692
- * minute into a dead lane for the life of the process. A 429 is a rate limit
693
- * and says "later", not "never". A 401 or 403 during a credential blip — a
694
- * rotation, a clock skew, a roster the box is momentarily not the holder of —
695
- * is transient by construction. A 408 is a timeout wearing a 4xx. Every one of
696
- * those stopped env reporting until somebody restarted the daemon, with NO
697
- * sign anywhere that it had stopped: the report is deduped and silent by
698
- * design, so "posted once and never again" is indistinguishable from "working
699
- * normally on a box whose env has not changed". A lane that can die invisibly
700
- * must not die for a reason that will pass.
701
- *
702
- * So permanent means exactly three things, and each of them is a fact about
703
- * the REQUEST rather than about the moment: 404, the route does not exist (an
704
- * older server — the `/fleet/agent-trace` precedent this lane is built on);
705
- * 400 and 422, the server will never accept this shape. Retrying any of those
706
- * forever is the daemon arguing with a decision already made.
707
- *
708
- * Pure and exported so the decision can be proved without a credential, a
709
- * server or a poll — the same shape `shouldStop` and `createHolderWatch` keep
710
- * below.
711
- */
712
- export function envReportIsPermanent(status) {
713
- return status === 400 || status === 404 || status === 422;
714
- }
715
-
716
- const ENV_REPORT_URL = FLEET_URL.replace(/\/agents\/?$/, '/env-report');
717
- const ENV_REPORT_SCAN_MS = 60_000;
718
- let envReportUnsupported = false; // the server refused permanently — quiet until restart
719
- let envReportScanAt = 0;
720
- let envReportSent = null; // the last report the server ACCEPTED, stringified
721
- /**
722
- * Returns a one-word VERDICT — 'throttled' | 'scan-failed' | 'quiet' |
723
- * 'deduped' | 'accepted' | 'retry' | 'stopped'. The daemon ignores it (the
724
- * caller is a bare `void`); it exists so the lane's decisions can be driven
725
- * against a real HTTP server in a test rather than pinned as source text. A
726
- * readout whose only proof is that its source LOOKS right is the inert-pin
727
- * class this repo has caught five times.
728
- */
729
- export async function maybeReportEnv(repoRoot) {
730
- if (Date.now() - envReportScanAt < ENV_REPORT_SCAN_MS) return 'throttled';
731
- envReportScanAt = Date.now();
732
- let report;
733
- try {
734
- // The SCAN happens even when the POST cannot — see the docblock: the
735
- // scrubber has no dedup and must reflect the newest read every time.
736
- report = scanEnvForScrub(repoRoot);
737
- } catch {
738
- return 'scan-failed'; // a readout must never throw into the poll loop
739
- }
740
- if (envReportUnsupported) return 'quiet';
741
- const payload = JSON.stringify({
742
- pubkey: myPubB64(),
743
- files: report.files,
744
- filesTotal: report.filesTotal,
745
- varsTotal: report.varsTotal,
746
- });
747
- if (payload === envReportSent) return 'deduped';
748
- try {
749
- const res = await fetch(ENV_REPORT_URL, {
750
- method: 'POST',
751
- headers: {
752
- Authorization: `Bearer ${FLEET_TOKEN}`,
753
- 'User-Agent': USER_AGENT,
754
- 'Content-Type': 'application/json',
755
- },
756
- signal: AbortSignal.timeout(15_000),
757
- body: payload,
758
- });
759
- // Only a refusal that cannot change stops the lane — see
760
- // `envReportIsPermanent`. A 429 or a 401 during a credential blip is a
761
- // "later", and treating it as a "never" killed env reporting for the life
762
- // of the process with nothing anywhere saying so.
763
- if (envReportIsPermanent(res.status)) {
764
- envReportUnsupported = true;
765
- return 'stopped';
766
- }
767
- // Only an ACCEPTED report counts, the rule `maybeReportRepoState` keeps:
768
- // anything else forgets it so the next pass retries rather than
769
- // dedup-suppressing a report nobody received.
770
- envReportSent = res.ok ? payload : null;
771
- return res.ok ? 'accepted' : 'retry';
772
- } catch {
773
- envReportSent = null;
774
- return 'retry';
775
- }
776
- }
777
-
778
- const TOOLS_REPORT_URL = FLEET_URL.replace(/\/agents\/?$/, '/tools-report');
779
- let toolsReportSent = null;
780
- let toolsReportUnsupported = false;
781
- let toolsReportAt = 0;
782
- /** Repo tool readiness is a per-box daemon report. Never include env values. */
783
- export async function maybeReportTools(repoRoot, baseRef) {
784
- if (Date.now() - toolsReportAt < 60_000) return;
785
- toolsReportAt = Date.now();
786
- if (toolsReportUnsupported) return;
787
- const pubkey = myPubB64();
788
- if (!pubkey) return;
789
- let payload;
790
- try {
791
- const snapshot = readBaseTools(repoRoot, baseRef);
792
- const installed = detectRuntimes().filter((r) => r.installed);
793
- const runner = installed.find((r) => runnerToolCapabilities(r.id).mcp)?.id ?? installed[0]?.id ?? 'none';
794
- payload = JSON.stringify({ pubkey, tools: toolReadout(snapshot, runner) });
795
- } catch { return; }
796
- if (payload === toolsReportSent) return;
797
- try {
798
- const res = await fetch(TOOLS_REPORT_URL, {
799
- method: 'POST',
800
- headers: { Authorization: `Bearer ${FLEET_TOKEN}`, 'User-Agent': USER_AGENT, 'Content-Type': 'application/json' },
801
- signal: AbortSignal.timeout(15_000), body: payload,
802
- });
803
- if (envReportIsPermanent(res.status)) toolsReportUnsupported = true;
804
- toolsReportSent = res.ok ? payload : null;
805
- } catch { toolsReportSent = null; }
806
- }
807
-
808
- /**
809
- * A STOP COMMANDED BY FLOWVIANT, read off the roster poll.
810
- *
811
- * The daemon is a PULL client — the /fleet/stream socket is a one-way wake
812
- * nudge with no server→daemon request path — so "stop this machine" can never
813
- * be a request the server makes of us. It rides the roster RESPONSE instead, on
814
- * the same `daemon` object the version signal already travels on, which is why
815
- * it needs no new endpoint and no version floor: an older daemon reads an
816
- * unknown key as nothing and keeps running, and fail-open is the safe direction
817
- * for a switch whose failure mode is "your machine went dark".
818
- *
819
- * The server decides whether a stop is LIVE — it stamps the credential and only
820
- * sends the key inside a short honor window — and the daemon does NOT re-derive
821
- * that. The key's PRESENCE is the command. Evaluating the same TTL on both
822
- * sides would make clock skew the arbiter of whether a machine may run, and get
823
- * it wrong in the direction that bricks the box: a relaunch that re-reads an
824
- * old timestamp and stops itself again, forever.
825
- *
826
- * Pure and exported so the decision can be proved without a credential or a
827
- * live server. `null` means keep running.
828
- */
829
- export function shouldStop(rosterDaemon) {
830
- const stop = rosterDaemon?.stop;
831
- // An OBJECT, and not an array: `typeof [] === 'object'`, so the plain typeof
832
- // guard let `stop: []` — an empty list, which is how this codebase spells "no
833
- // jobs" on every other roster key — read as a live stop with no reason. A
834
- // switch that kills a machine gets the narrow test.
835
- if (!stop || typeof stop !== 'object' || Array.isArray(stop)) return null;
836
- // Re-sanitized HERE even though the server wrote it: this string is operator
837
- // prose typed into a SQL UPDATE and then printed straight to a terminal, so
838
- // control bytes would let a stop reason repaint the console it is being read
839
- // on, and an unbounded one would bury the line that matters. The WORDING is
840
- // untouched — the operator's own sentence is the whole point of the field,
841
- // and paraphrasing it would leave the person at the keyboard guessing.
842
- const reason = String(stop.reason ?? '')
843
- .replace(/[\u0000-\u001f\u007f]/g, ' ')
844
- .replace(/\s+/g, ' ')
845
- .trim()
846
- .slice(0, 300);
847
- return { stop: true, reason };
848
- }
849
-
850
- /**
851
- * HOW LONG AGO, in the words the standby sentence needs. Milliseconds in, one
852
- * short label out; anything that is not a finite, non-negative number renders
853
- * NOTHING and the caller drops the clause rather than printing "heard NaN ago".
854
- * The three-state rule applied to a duration: measured, or say nothing.
855
- */
856
- export function agoLabel(ms) {
857
- // `typeof`, not `Number()`: `Number(null)` is 0, so a coercing guard turns
858
- // "the server said nothing" into "heard 0s ago" — a measurement nobody took,
859
- // printed at the one moment the person is deciding whether to wait.
860
- if (typeof ms !== 'number' || !Number.isFinite(ms) || ms < 0) return null;
861
- const s = Math.round(ms / 1000);
862
- if (s < 60) return `${s}s`;
863
- const m = Math.round(s / 60);
864
- if (m < 60) return `${m}m`;
865
- const h = Math.round(m / 60);
866
- if (h < 48) return `${h}h`;
867
- return `${Math.round(h / 24)}d`;
868
- }
869
-
870
- /**
871
- * WHOSE MACHINE THIS IS — read off the roster, said once, and never a refusal.
872
- *
873
- * A project has ONE machine credential and `device/approve` hands every device
874
- * the same raw token, so two boxes running `npx flowviant` are two daemons that
875
- * both believe they are the machine. The old behaviour was a silent race: the
876
- * loser could not see the winner at all, and the moment the winner went quiet
877
- * long enough for a lease to lapse it cut a FRESH branch for an agent whose work
878
- * exists only on the other box's disk, then ran a CLI with no conversation
879
- * behind it. A confident, context-free redo of work somebody was mid-way
880
- * through.
881
- *
882
- * The server arbitrates (it is the only party that can see both boxes) and the
883
- * answer rides the poll RESPONSE as `holder`. This turns that answer into what
884
- * the person at the keyboard needs to know, and nothing more:
885
- *
886
- * · ABSENT -> this server does not arbitrate machines. Behave exactly
887
- * as every daemon before 0.84.0 did — zero new paths.
888
- * · mine: true -> we are the machine. Announce it only if we have been
889
- * inactive, so an ordinary daemon prints nothing new.
890
- * · mine: false -> GO INACTIVE. Keep polling quietly; the restricted roster
891
- * serves us nothing, the auto-handover makes this box the
892
- * machine when the holder dies, and the app is where a
893
- * person moves it sooner. Never exit: an inactive box that
894
- * quits is one somebody has to go and restart by hand.
895
- *
896
- * THE WORD A PERSON READS IS `inactive` (2026-09-21) — the owner replaced
897
- * "standing by" outright: *"no, it can [be] inactive instead"*. The server's
898
- * role enum says `inactive`, `flowviant machines` prints it, and so do the two
899
- * sentences below. The STATE NAME returned here is still `'standby'`, and that
900
- * is deliberate: it names the server's arbitration arm, it is what every gate
901
- * in this file branches on, and renaming an identifier to match a copy change
902
- * is how a rename becomes a behaviour change nobody reviewed.
903
- *
904
- * Printed ONCE PER DISTINCT HOLDER rather than per poll — a true sentence
905
- * restated every ten seconds is a scrolling console nobody reads, and the fact
906
- * only CHANGES when the holder does.
907
- *
908
- * Pure except for the injected `say`, so the whole decision can be proved
909
- * without a credential, a server or a second box.
910
- */
911
- export function createHolderWatch({ say = () => {} } = {}) {
912
- let standbyKey = null; // the holder we last announced, or null while we serve
913
- return {
914
- /** Returns 'absent' | 'mine' | 'standby' — the state, for the caller's
915
- * own gating and for tests that must not read the console. */
916
- observe(holder) {
917
- if (!holder || typeof holder !== 'object' || Array.isArray(holder)) {
918
- // An older server, or a poll it did not arbitrate. Silence, and not one
919
- // new path: this is the 0.83.0 daemon.
920
- return 'absent';
921
- }
922
- if (holder.mine === true) {
923
- /**
924
- * WE TOOK IT, AND THE SERVER SAID SO (0.91.0).
925
- *
926
- * Sent on the one poll that moved holdership and never again, so this
927
- * is news by construction — no key to remember, no window to judge.
928
- * RELAYED AND NEVER DERIVED: the box we displaced is named because the
929
- * server named it, and an unnamed one gets the nameless fallback the
930
- * rest of this file uses rather than a guess. The turns clause DROPS
931
- * WHOLE when the count is absent — "0 turns were running" is a
932
- * measurement nobody took, printed at the one moment somebody is
933
- * deciding whether they have just interrupted their own work.
934
- */
935
- const took = holder.took;
936
- if (took && typeof took === 'object' && !Array.isArray(took)) {
937
- standbyKey = null;
938
- // scrubbed before it reaches a terminal (2026-09-24, the audit) —
939
- // see printable.mjs.
940
- const from = safeName(took.from)?.slice(0, 64) ?? 'another machine';
941
- const turns = Number.isInteger(took.turns) && took.turns > 0 ? took.turns : null;
942
- say(
943
- `took this project's machine from ${from}` +
944
- (turns
945
- ? ` — ${turns} turn${turns === 1 ? '' : 's'} ${turns === 1 ? 'was' : 'were'} running there and ${turns === 1 ? 'was' : 'were'} settled as moved.`
946
- : '.')
947
- );
948
- return 'mine';
949
- }
950
- if (standbyKey !== null) {
951
- standbyKey = null;
952
- say('this machine now serves the project.');
953
- }
954
- return 'mine';
955
- }
956
- // The NAME is all the response carries about the other box, so it is also
957
- // the only thing "a distinct holder" can be keyed on. An unnamed holder
958
- // keys on the empty string, which is stable — one announcement, not one
959
- // per poll. Scrubbed before it reaches a terminal (2026-09-24, the
960
- // audit) — see printable.mjs.
961
- const name = safeName(holder.name)?.slice(0, 64) ?? null;
962
- const key = name ?? '';
963
- if (key !== standbyKey) {
964
- standbyKey = key;
965
- const ago = agoLabel(holder.heardAgo);
966
- // THE SENTENCE POINTS AT THE APP, NEVER AT A COMMAND. The owner's
967
- // ruling, verbatim: "i dont intend to run or do anything in the
968
- // terminal besides npx flowviant or npx flowviant login." So the
969
- // terminal surface is those two commands, full stop — this box waits
970
- // or a person moves the machine from project settings, and there is no
971
- // third thing to type here. An earlier cut of this sentence ended by
972
- // telling the person to re-run this daemon with a claim flag; that flag
973
- // is deleted, and the gesture is the app's — where the server can see
974
- // both boxes and every daemon learns the outcome on its next poll.
975
- //
976
- // The ago clause DROPS whole when unmeasured — agoLabel returns null
977
- // rather than a zero — because "heard 0s ago" is a measurement nobody
978
- // took, printed at the one moment the person is deciding whether to
979
- // wait.
980
- //
981
- // THE PROMISE IS GONE (0.91.0), and its absence is the honest half.
982
- // This sentence used to end "It moves here automatically once that
983
- // machine has been quiet 10 minutes", which was true while starting a
984
- // daemon did nothing but wait. Since 0.91.0 a process START asks for
985
- // the machine on its first poll — the owner's ruling, "it should kill
986
- // the first one and take over" — so reaching this branch at all means
987
- // the ask was REFUSED or never spent. Four ways: an older server that
988
- // does not honour it; a daemon this server will not hand a machine to;
989
- // an UNATTENDED RE-EXEC, which is born with the ask spent because a
990
- // restart is not a person; and — the one worth naming — a box this
991
- // credential DISPLACED inside the last ten minutes, which the server
992
- // refuses so that two supervised daemons cannot trade a machine back
993
- // and forth forever (see `takeMachineHolderNow`). In that last case the
994
- // right next move is a decision, not a wait, which is exactly what this
995
- // sentence already points at. Restating the staleness clock instead
996
- // would promise a handover on the one path where it is least likely to
997
- // be what happens. So the line relays the fact and points at the door,
998
- // which is the same door it always pointed at.
999
- //
1000
- // THE WORD IS `inactive` (2026-09-21). It was "standing by" until the
1001
- // owner replaced it — asked whether a box should keep saying that, he
1002
- // answered *"no, it can [be] inactive instead"* — and the server's role
1003
- // enum moved in the same pass (`serving` / `inactive`, which is why
1004
- // `machines.mjs` no longer respells anything). A terminal saying one
1005
- // word about this box while the app says another about the same box is
1006
- // the confusion these readouts exist to end, so the two ends say the
1007
- // same thing.
1008
- //
1009
- // THE POINTER SURVIVES THE REWORD. The sentence still ends at the app's
1010
- // project settings rather than at "another machine is serving this
1011
- // project", which the line's first half has already said — restating it
1012
- // would spend the only clause the person can act on repeating a fact.
1013
- say(
1014
- `This project's machine is ${name ?? 'another machine'}${ago ? ` (heard ${ago} ago)` : ''}. ` +
1015
- "This one is inactive — move it here from the app's project settings."
1016
- );
1017
- }
1018
- return 'standby';
1019
- },
1020
- };
1021
- }
1022
-
1023
- /** The sentence an in-flight agent turn is settled with when the machine moves
1024
- * out from under it. MEASURED, not inferred: the server named the box that
1025
- * took over, and an unnamed one says so rather than guessing. Scrubbed
1026
- * before it is relayed anywhere (2026-09-24, the audit) — see printable.mjs. */
1027
- export function displacedTurnSentence(by) {
1028
- const name = safeName(by)?.slice(0, 64) ?? 'another machine';
1029
- return `The project's machine moved to ${name} while this turn was running.`;
1030
- }
1031
-
1032
- /**
1033
- * …AND THE SENTENCE FOR THE OTHER WAY A BOX STOPS BEING THIS PROJECT'S
1034
- * MACHINE (2026-09-21): it was REMOVED in the app.
1035
- *
1036
- * Kept apart from the displaced one rather than generalised into "the machine
1037
- * is no longer this box", because the two are different facts and the person
1038
- * reading a stuck turn needs the one that happened. A move names where the work
1039
- * went and implies somebody will pick it up there; a removal names nothing,
1040
- * because there is nowhere for it to have gone. Guessing between them — or
1041
- * blurring them into one sentence that is true of both — is the product
1042
- * inventing a state.
1043
- *
1044
- * It takes NO name: there is no box that took over. The PROJECT is named on the
1045
- * console line instead, where it answers "removed from what".
1046
- */
1047
- export function removedTurnSentence() {
1048
- return 'This machine was removed from the project in the app while this turn was running.';
1049
- }
1050
-
1051
- /**
1052
- * THE MACHINE MOVED. STAND DOWN.
1053
- *
1054
- * The server sends `displaced` only when it is aimed at THIS box (it matches the
1055
- * poll's own `envpub`) and only inside a short window, so there is nothing to
1056
- * re-derive here and nothing to honour from last week — the same shape, and the
1057
- * same reasoning, as the commanded stop above.
1058
- *
1059
- * Unlike the signal handlers this path is poll-response-driven, so it CAN await:
1060
- * every in-flight agent turn is settled first, because a turn this daemon
1061
- * abandons silently sits pending until the server's six-hour expiry while the
1062
- * board shows an agent working on a machine that has gone. `nothing` is the
1063
- * honest outcome and the sentence says what happened.
1064
- *
1065
- * EXIT 0, for the reason the two existing terminal paths (the commanded stop,
1066
- * and the revoked credential above it) both document: under `Restart=on-failure`
1067
- * a nonzero code has systemd relaunch this daemon immediately, where it would
1068
- * poll, be told again that it is not the machine, and stand down again — a
1069
- * restart loop fighting a decision somebody made on purpose.
1070
- *
1071
- * Every dependency is injected so the whole stand-down can be proved without a
1072
- * server, a repo or a process to kill.
1073
- */
1074
- export async function standDownDisplaced({
1075
- by,
1076
- kind = 'moved',
1077
- project,
1078
- settleAgentTurns,
1079
- flushReports,
1080
- teardown,
1081
- exit,
1082
- log = { warn: () => {}, note: () => {} },
1083
- }) {
1084
- /** A bound on a wedged uplink, and never a reason to hang: the timer is
1085
- * unref'd, so the only thing that keeps this process alive is the work. */
1086
- const bounded = (p, seconds) =>
1087
- Promise.race([
1088
- p,
1089
- new Promise((resolve) => {
1090
- const t = setTimeout(resolve, seconds * 1000);
1091
- t.unref?.();
1092
- }),
1093
- ]);
1094
- // Scrubbed before it reaches this box's own console (2026-09-24, the
1095
- // audit) — see printable.mjs.
1096
- const name = safeName(by)?.slice(0, 64) ?? null;
1097
- /**
1098
- * TWO KINDS, ONE CHOREOGRAPHY (2026-09-21).
1099
- *
1100
- * `moved` is the original: another box took the machine, and it is the
1101
- * DEFAULT so the displaced call site reads exactly as it always did.
1102
- * `removed` is the project saying this box is not its machine at all any
1103
- * more — somebody pressed a button in the app — and the only thing that
1104
- * differs is the WORDS. Everything below this point is the same, and it must
1105
- * be: settle the turns first, flush the reports, tear down the detached
1106
- * children, keep the worktrees, exit 0.
1107
- *
1108
- * The sentences are picked HERE rather than passed in, so the two cannot
1109
- * drift and neither call site can invent a third.
1110
- */
1111
- const removed = kind === 'removed';
1112
- const projectName = safeName(project)?.slice(0, 64) ?? 'this project';
1113
- log.warn(
1114
- removed
1115
- ? `removed from ${projectName} in the app — stopping.`
1116
- : name
1117
- ? `this project's machine moved to ${name} — standing down.`
1118
- : "this project's machine moved to another box — standing down."
1119
- );
1120
- if (removed) emitMachineEvent({ event: 'stopped', reason: 'removed' });
1121
- else emitMachineEvent({ event: 'displaced', message: displacedTurnSentence(by) });
1122
- // FIRST, and awaited: an unsettled turn is the one thing here that no later
1123
- // poll from anybody can fix — this process holds the only copy of the fact
1124
- // that it was running.
1125
- try {
1126
- await bounded(
1127
- settleAgentTurns(removed ? removedTurnSentence() : displacedTurnSentence(by)),
1128
- 10
1129
- );
1130
- } catch {
1131
- /* an unsettled turn expires server-side with words of its own */
1132
- }
1133
- // THEN the queued settles, bounded exactly as the commanded stop bounds them:
1134
- // a queued report is a COMPLETED turn whose side effects already happened, and
1135
- // dropping it re-runs the whole turn somewhere else.
1136
- try {
1137
- await bounded(flushReports(), 5);
1138
- } catch {
1139
- /* undelivered reports re-run; delivering them was best-effort */
1140
- }
1141
- // NOT optional, for the reason the commanded stop states: detached preview
1142
- // tunnels survive this process by design, and a public hostname pointed into a
1143
- // worktree on a box that no longer serves the project is the worst thing this
1144
- // path can leave behind.
1145
- teardown();
1146
- log.note('worktrees are kept — the branches here are the only copy of this box\'s work.');
1147
- exit(0);
1148
- }
1149
-
1150
- // One roster agent's loop: persistent worktree, one intent per turn, reset to
1151
- // base between tasks (fresh conversation), resume in place while on a blocker.
61
+ import { createKnowledgeSync } from './knowledge.mjs';
62
+ import { knowledgeFetcher } from './knowledgeFetch.mjs';
63
+ import { FLOWVIANT_OWN_PATHS } from './knowledgeLibrary.mjs';
64
+ import { setServerMaxTurns } from './admission.mjs';
65
+ import { ourConversationIds } from './localSessions.mjs';
66
+ import { announceFirstPoll, fetchRoster } from './fleetRoster.mjs';
67
+ import {
68
+ maybeReportEnv,
69
+ maybeReportLocalSessions,
70
+ maybeReportRepoState,
71
+ maybeReportTools,
72
+ repoStateChanged,
73
+ reportMachine,
74
+ } from './fleetReports.mjs';
75
+ import { createHolderWatch, obeyRosterCommands } from './holder.mjs';
76
+ import { createWikiRunner } from './wikiRunner.mjs';
77
+ import { createCleanupLane, createPatchRevertLane } from './fleetJobs.mjs';
78
+ import { installSignalHandlers, takeInstanceLockOrExit, tidyWorktreeHome, worktreeHome } from './fleetStartup.mjs';
79
+ import { createReconcileWait } from './fleetWake.mjs';
1152
80
 
1153
81
  export async function runFleetDaemon({ afterLock = null } = {}) {
1154
82
  let desktopProjectId = CREDENTIAL?.entry?.projectId ?? null;
1155
83
  let wasServing = false;
1156
- if (process.env.FLOWVIANT_UPDATE_TARGET && cmpVersion(VERSION, process.env.FLOWVIANT_UPDATE_TARGET) >= 0)
84
+ // `?? -1`: an unreadable target is not an applied update (and `null >= 0` is true in JS).
85
+ if (process.env.FLOWVIANT_UPDATE_TARGET && (compareVersions(VERSION, process.env.FLOWVIANT_UPDATE_TARGET) ?? -1) >= 0)
1157
86
  emitMachineEvent({ event: 'update-applied', from: process.env.FLOWVIANT_UPDATE_FROM ?? null, to: VERSION });
1158
87
  console.log('');
1159
88
  console.log(` ${c.bold(c.cyan('◣ flowviant'))} ${c.dim(`machine daemon · v${VERSION}`)}`);
@@ -1203,67 +132,7 @@ export async function runFleetDaemon({ afterLock = null } = {}) {
1203
132
  info(`server · ${FLEET_URL}`);
1204
133
  console.log('');
1205
134
 
1206
- // ONE DAEMON PER CREDENTIAL. Before preflight, before the preview reap,
1207
- // before anything with a side effect — a second daemon must not so much as
1208
- // install a CLI or clear a registry on its way to being refused. Keyed on the
1209
- // credential rather than the repo, because two checkouts on one credential is
1210
- // the SAME project served twice, and the worst version of this: their session
1211
- // worktrees are in different directories, so the per-turn lock cannot even see
1212
- // across them. See instance.mjs for why that lock is not enough on its own.
1213
- // Same repo -> this run replaces whatever was serving it. Different repo ->
1214
- // refused, and nothing is signalled. See instance.mjs's header for the rule.
1215
- //
1216
- // `--takeover` ARBITRATES PROCESSES ON THIS BOX and nothing more — which
1217
- // daemon serves this repo. It says nothing about which BOX serves the
1218
- // project: that is holdership, the server decides it because only the server
1219
- // can see both boxes, and a person moves it from the app.
1220
- const instance = acquireInstanceLock(FLEET_TOKEN, repoRoot, {
1221
- takeover:
1222
- process.argv.includes('--takeover') || process.argv.includes('--takeover-downgrade'),
1223
- noTakeover:
1224
- process.argv.includes('--no-takeover') || process.env.FLOWVIANT_NO_TAKEOVER === '1',
1225
- allowDowngrade: process.argv.includes('--takeover-downgrade'),
1226
- log: (m) => info(m),
1227
- });
1228
- if (!instance.ok) {
1229
- const h = instance.holder;
1230
- console.log('');
1231
- // Two different refusals, because they are two different mistakes and the
1232
- // fix is not the same. Same CREDENTIAL: one project is being served twice.
1233
- // Same REPO under another credential: two daemons in one working tree,
1234
- // which the credential-keyed lock cannot see on its own.
1235
- if (instance.takeoverFailed) {
1236
- fail(`could not replace the running daemon: ${instance.takeoverFailed}`);
1237
- } else if (instance.sameRepo) {
1238
- fail('a flowviant daemon is already running in this repo.');
1239
- } else {
1240
- fail('a flowviant daemon is already running for this credential.');
1241
- }
1242
- if (h?.pid) info(`holder · pid ${h.pid}${h.repoRoot ? ` in ${h.repoRoot}` : ''}`);
1243
- // The two-checkouts case is the one nobody spots on their own: both tabs
1244
- // look healthy, and the damage is doubled cards and doubled edits in a repo
1245
- // you are not looking at. Name the other repo when it is a different one.
1246
- if (!instance.sameRepo && h?.repoRoot && h.repoRoot !== repoRoot) {
1247
- warn('that is a DIFFERENT checkout — one credential serves one project, so both would answer the same tabs.');
1248
- // Not offered lightly: that daemon is serving other work, and this
1249
- // command was run somewhere else. Replacing it is a decision, not a
1250
- // restart, so it takes a word.
1251
- note('run with --takeover to stop it and serve this repo instead.');
1252
- }
1253
- // WITHHELD when we could not identify the holder. ALLOW_MULTI runs this
1254
- // daemon unguarded beside one we just admitted we cannot see, and in the
1255
- // same repo that is two `git fetch`, two worktree sweeps, and one
1256
- // `retireWorkSessions` deleting directories the other is serving. Offering
1257
- // it as the way out of "I don't know what that process is" would be handing
1258
- // someone the worst option at the moment they have the least information.
1259
- if (!instance.unidentified) {
1260
- note('or run this one with FLOWVIANT_ALLOW_MULTI=1 if you know what you are doing.');
1261
- }
1262
- console.log('');
1263
- process.exit(1);
1264
- }
1265
- if (instance.unguarded)
1266
- warn('could not take the single-instance lock (unwritable ~/.flowviant) — running unguarded');
135
+ takeInstanceLockOrExit(repoRoot);
1267
136
  // The repo binding the start path's picker or confirm was answered with.
1268
137
  // Persisted HERE, after the lock, so a refused start moves nothing: moving it
1269
138
  // first stranded the daemon already serving that project's own checkout at
@@ -1304,11 +173,7 @@ export async function runFleetDaemon({ afterLock = null } = {}) {
1304
173
  // running (detached children survive an ungraceful exit) before we start fresh.
1305
174
  reapOrphanPreviews((m) => info(m));
1306
175
 
1307
- // Persistent worktree home (0.9.0) — survives daemon restarts AND reboots,
1308
- // so Ctrl+C mid-task never loses local work. Keyed per repo path.
1309
- const repoKey = `${basename(repoRoot)}-${createHash('sha256').update(repoRoot).digest('hex').slice(0, 8)}`;
1310
- const baseDir = join(homedir(), '.flowviant', 'worktrees', repoKey);
1311
- mkdirSync(baseDir, { recursive: true });
176
+ const { repoKey, baseDir } = worktreeHome(repoRoot);
1312
177
 
1313
178
  // WHAT THE DELETED VAULT LEFT ON THIS DISK — once, here, because this is the
1314
179
  // first point at which both directories it wrote into are known. Deleting the
@@ -1321,84 +186,22 @@ export async function runFleetDaemon({ afterLock = null } = {}) {
1321
186
  // unless it actually removed something.
1322
187
  sweepVaultArtefactsOnce({ roots: [repoRoot, baseDir], log: (m) => info(m) });
1323
188
 
1324
- // ONE CHECKOUT PER TASK, named after the task. Worktrees used to be
1325
- // `agent-<agentId>` — a long-lived tree per lane, reset to base between
1326
- // tasks — and that was the last thing a lane owned. Now a lane is a
1327
- // credential and nothing more, which is what makes it disposable: the server
1328
- // can hand any lane any task, and two tasks can never be in each other's
1329
- // files even when one is mid-edit.
1330
- const taskWorktreePath = (intentId) => join(baseDir, `task-${intentId}`);
1331
- try {
1332
- const kb = Number(execFileSync('du', ['-sk', baseDir], { encoding: 'utf8' }).split('\t')[0]);
1333
- if (kb > 1024)
1334
- info(
1335
- `disk · worktrees ${(kb / 1024 / 1024).toFixed(1)} GB at ~/.flowviant/worktrees — \`flowviant clean\` reclaims`
1336
- );
1337
- } catch {
1338
- /* du unavailable (Windows) — skip the disk line */
1339
- }
1340
-
1341
- // Reap long-dead task checkouts. Per-lane trees were self-limiting — N lanes,
1342
- // N directories, reused forever. Per-task trees are not: every task ever
1343
- // built leaves one behind, so without this the disk grows without bound and
1344
- // `flowviant clean` becomes a chore rather than a convenience.
1345
- //
1346
- // Age, not state, is the test. The daemon has no list of which intents are
1347
- // still open, and asking the server for one would put a delete behind a
1348
- // network call that can fail — so anything untouched for a fortnight goes,
1349
- // which is far beyond how long a task stays reviewable and far beyond any
1350
- // pause a human takes mid-build. Runs at startup only: mid-run this would
1351
- // race a worker that is quietly parked on a blocker.
1352
- try {
1353
- const cutoff = Date.now() - 14 * 24 * 60 * 60 * 1000;
1354
- let reaped = 0;
1355
- for (const name of readdirSync(baseDir)) {
1356
- if (!name.startsWith('task-')) continue;
1357
- const p = join(baseDir, name);
1358
- try {
1359
- if (statSync(p).mtimeMs > cutoff) continue;
1360
- // Through git, so the worktree REGISTRATION goes too — an rm -rf leaves
1361
- // a stale entry that blocks re-adding the same path later.
1362
- git(['worktree', 'remove', '--force', p], repoRoot);
1363
- reaped++;
1364
- } catch {
1365
- /* held, gone, or not ours — leave it for `flowviant clean` */
1366
- }
1367
- }
1368
- if (reaped) info(`disk · reclaimed ${reaped} task worktree${reaped === 1 ? '' : 's'} idle > 14d`);
1369
- } catch {
1370
- /* the worktree home may not exist yet on a first run */
1371
- }
1372
- const tokenByAgent = new Map(); // agentId -> latest worker token
1373
- const mintedAt = new Map(); // agentId -> ms when we last got a fresh token
1374
- const hasWorkByAgent = new Map(); // agentId -> server says it has claimable work
1375
- // agentId -> the intent the server would hand this lane next: { intentId,
1376
- // title, model, effort }. `--model`/`--effort` are fixed when Claude starts,
1377
- // and by then nothing has been claimed — so the server names the task first
1378
- // and the turn pins its claim to that id. Absent on older servers, in which
1379
- // case the lane behaves exactly as it did before: generic kickoff, machine
1380
- // defaults.
1381
- const nextByAgent = new Map();
189
+ tidyWorktreeHome(baseDir, repoRoot);
1382
190
  let leaseTtlSeconds = 24 * 60 * 60; // updated from each roster response
1383
191
  let mcpUrl = MCP_URL;
1384
- // DEAD, and kept only because unpicking it is a rewire rather than a
1385
- // deletion: nothing calls `workers.set` anywhere in this tree. It held
1386
- // DISPATCH lanes, and the server has sent `agents: []` permanently since
1387
- // 2026-08-19, so every loop below iterates nothing. Two `stopPreview` calls
1388
- // hung off it until 2026-08-21 and read as live preview wiring; they were
1389
- // deleted, not rewired. When the Workbench preview lands, its teardown is
1390
- // keyed on sessionId and belongs beside retireWorkSessions in work.mjs —
1391
- // NOT here.
1392
- const workers = new Map(); // agentId -> { state, promise, wt, label }
1393
192
  let daemonAlive = true; // flipped false on shutdown so the stream stops reconnecting
1394
193
  let stream = null; // push channel handle (set once the loop is set up)
1395
194
  let workShutdown = null; // kills live session-turn CLIs (set with the work manager below)
195
+ let wiki = null; // the living-wiki runner (set once the work manager exists)
1396
196
 
1397
197
  // Shutdown KEEPS the worktrees: in-flight local work survives Ctrl+C and
1398
198
  // resumes in place on the next run (the task marker matches). Worktrees are
1399
199
  // only removed when an agent is deleted from the roster, or by
1400
200
  // `flowviant clean`.
1401
201
  const teardown = () => {
202
+ // FIRST: no lane spawns again, and no turn killed below hands back its
203
+ // output — the process may live on to drain a deploy (standDownGate.mjs).
204
+ stopWorkLanes();
1402
205
  daemonAlive = false;
1403
206
  try {
1404
207
  stream?.close();
@@ -1409,7 +212,7 @@ export async function runFleetDaemon({ afterLock = null } = {}) {
1409
212
  // burning quota, and a restarted daemon would start a SECOND sweep racing
1410
213
  // it on the same vault dir + sync state.
1411
214
  try {
1412
- wikiChild?.kill('SIGKILL');
215
+ wiki?.kill();
1413
216
  } catch {
1414
217
  /* best-effort */
1415
218
  }
@@ -1421,163 +224,24 @@ export async function runFleetDaemon({ afterLock = null } = {}) {
1421
224
  } catch {
1422
225
  /* best-effort */
1423
226
  }
1424
- for (const [, w] of workers) {
1425
- w.state.alive = false;
1426
- try {
1427
- w.state.child?.kill('SIGKILL');
1428
- } catch {
1429
- /* best-effort */
1430
- }
1431
- }
1432
227
  // Detached tunnels survive our exit by design, so leaving them would strand
1433
228
  // a public hostname until the box rebooted.
1434
229
  shutdownPreviews();
1435
- // AND THE DEV SERVERS, on every path that reaches here — all four are a
1436
- // stand-down: Ctrl-C, a service manager's SIGTERM, a revoked credential, a
1437
- // commanded stop.
1438
- //
1439
- // A SAME-REPO TAKEOVER IS DELIBERATELY NOT DISTINGUISHED, and that is a
1440
230
  };
1441
- process.on('SIGINT', () => {
1442
- emitMachineEvent({ event: 'stopped', reason: 'signal' });
1443
- console.log('');
1444
- note('shutting down — stopping workers. Worktrees are kept: in-flight work resumes next run.');
1445
- teardown();
1446
- process.exit(130);
1447
- });
1448
- // A service manager stops the daemon with SIGTERM, not Ctrl+C. Without this
1449
- // handler every child survived a `systemctl stop` — the exact orphaning the
1450
- // teardown exists to prevent.
1451
- process.on('SIGTERM', () => {
1452
- emitMachineEvent({ event: 'stopped', reason: 'signal' });
1453
- console.log('');
1454
- note('shutting down (SIGTERM) — stopping workers. Worktrees are kept: in-flight work resumes next run.');
1455
- teardown();
1456
- process.exit(143);
1457
- });
1458
- // Keep the daemon alive on a stray rejection. Many loops here are fire-and-
1459
- // forget (`void drainWiki()`, dispatch, sync) and rely on their callees never
1460
- // rejecting; Node ≥15 terminates the process on an unhandled rejection, which
1461
- // would kill every in-flight agent worker over one transient error. Log and
1462
- // survive instead — a wedged sub-task self-heals on the next poll.
1463
- process.on('unhandledRejection', (reason) => {
1464
- warn(`unhandled rejection (daemon kept alive): ${reason?.stack || reason}`);
1465
- });
1466
-
1467
- // Merge jobs (Flowvy-commanded): approved PRs to squash-merge to main on the
1468
- // user's own gh. `merging` guards against re-processing a job mid-flight.
1469
- const MERGE_DONE_URL = FLEET_URL.replace(/\/agents\/?$/, '/merge-done');
1470
- const MERGE_FAILED_URL = FLEET_URL.replace(/\/agents\/?$/, '/merge-failed');
1471
- const merging = new Set();
1472
- const mergeAttempts = new Map(); // job.id -> transient-failure count
1473
- /** Returns whether the server actually accepted it. Callers that spend a
1474
- * Claude turn per attempt need to know: swallowing the failure silently made
1475
- * an unreachable endpoint look identical to a settled job, so the turn
1476
- * re-ran on every poll. */
1477
- const reportMergeOutcome = async (url, body) => {
1478
- try {
1479
- const res = await fetch(url, {
1480
- method: 'POST',
1481
- headers: {
1482
- Authorization: `Bearer ${FLEET_TOKEN}`,
1483
- 'User-Agent': USER_AGENT,
1484
- 'Content-Type': 'application/json',
1485
- },
1486
- signal: AbortSignal.timeout(30_000),
1487
- body: JSON.stringify(body),
1488
- });
1489
- return res.ok;
1490
- } catch {
1491
- /* best-effort — the job reappears next poll if this failed */
1492
- return false;
1493
- }
1494
- };
1495
- /** Same POST, but hands back the parsed `data`. A compare-and-set answers in
1496
- * the BODY (`taken: false` is a perfectly successful 200), so reading only
1497
- * `res.ok` would tell a lane it won a race it actually lost. */
1498
- const postForData = async (url, body) => {
1499
- try {
1500
- const res = await fetch(url, {
1501
- method: 'POST',
1502
- headers: {
1503
- Authorization: `Bearer ${FLEET_TOKEN}`,
1504
- 'User-Agent': USER_AGENT,
1505
- 'Content-Type': 'application/json',
1506
- },
1507
- signal: AbortSignal.timeout(30_000),
1508
- body: JSON.stringify(body),
1509
- });
1510
- if (!res.ok) return null;
1511
- return (await res.json())?.data ?? null;
1512
- } catch {
1513
- return null;
1514
- }
1515
- };
1516
- // Patch reverts: a patch landed straight in this checkout, and a human took it
1517
- // back. The commits are HERE, not on the server, so the reverse-apply happens
1518
- // here too — a revert, never a reset, because the owner has almost certainly
1519
- // worked on top by now. Serialised through the same lock as applies.
1520
- const PATCH_REVERT_DONE_URL = FLEET_URL.replace(/\/agents\/?$/, '/patch-revert-done');
1521
- const reverting = new Set();
1522
- const processPatchRevertJobs = (jobs) => {
1523
- for (const job of jobs ?? []) {
1524
- if (!job || typeof job.id !== 'string' || !Array.isArray(job.shas)) continue;
1525
- if (reverting.has(job.id)) continue;
1526
- reverting.add(job.id);
1527
- (async () => {
1528
- try {
1529
- note(`${c.cyan('revert')} ${c.dim(`— ${job.title}`)}`);
1530
- const res = await withPatchLock(() =>
1531
- Promise.resolve(revertPatch({ repoRoot, shas: job.shas }))
1532
- );
1533
- if (res.ok) ok(`${c.dim('reverted')} ${job.title}`);
1534
- else warn(`revert failed for "${job.title}": ${res.error}`);
1535
- // ALWAYS report, success or not. Without this the flag stays set, the
1536
- // roster re-serves the job every poll, and each pass reverts the
1537
- // revert — the change flapping in and out of the owner's tree forever.
1538
- await reportMergeOutcome(PATCH_REVERT_DONE_URL, {
1539
- taskId: job.id,
1540
- ok: res.ok,
1541
- error: res.ok ? undefined : String(res.error ?? 'revert failed'),
1542
- });
1543
- } finally {
1544
- reverting.delete(job.id);
1545
- }
1546
- })();
1547
- }
1548
- };
1549
-
1550
-
1551
- /**
1552
- * Everything that reads or rewrites the shared `wikiWt` worktree takes this:
1553
- * the wiki sweep, the post-merge re-ground, the plan check, and consults.
1554
- *
1555
- * They are one directory. The wiki queue hard-resets it (`checkout --detach`,
1556
- * `reset --hard`, `clean -fd`) between tasks, which pulls the files out from
1557
- * under anything else mid-read — and two Claude turns in one working tree is
1558
- * incoherent even without the reset.
1559
- */
1560
- let wikiLock = Promise.resolve();
1561
- const withWikiLock = (fn) => {
1562
- const run = wikiLock.then(fn, fn);
1563
- wikiLock = run.then(
1564
- () => {},
1565
- () => {}
1566
- );
1567
- return run;
1568
- };
1569
-
1570
- // Machine telemetry — what the box is doing with itself, for the admin view.
1571
- const MACHINE_URL = FLEET_URL.replace(/\/agents\/?$/, '/machine');
1572
-
1573
-
1574
231
 
232
+ // THE DISPATCH-ERA MERGE LANE IS DELETED (review 2026-09-26). It squash-
233
+ // merged Flowvy-approved PRs on the user's gh — `--squash`, against the
234
+ // never-squash law — and every server since the pivot sends `mergeJobs: []`
235
+ // (a wire fossil, `rosterResponse.ts`). Merges run through `prWorkflow.mjs`
236
+ // (session PR job, agent approve) and `shipMerge.mjs`, and nowhere else.
237
+ const processPatchRevertJobs = createPatchRevertLane({ repoRoot });
1575
238
 
1576
239
  // ── Work sessions — the Workbench tabs ─────────────────────────────────────
1577
240
  //
1578
241
  // The whole machinery — per-session turn/ship chains, per-session work
1579
242
  // tokens, the settle-every-turn contract, the ship executor, worktree
1580
- // retirement — lives in work.mjs; this hands it the loop's mutable state.
243
+ // retirement — is wired by work.mjs (each lane in its own work*.mjs); this
244
+ // hands it the loop's mutable state.
1581
245
  const {
1582
246
  flushWorkReports,
1583
247
  learnPlaces,
@@ -1610,23 +274,17 @@ export async function runFleetDaemon({ afterLock = null } = {}) {
1610
274
  getLeaseTtl: () => leaseTtlSeconds,
1611
275
  /**
1612
276
  * The cartographer is a CLI turn too, and it is the one this manager cannot
1613
- * see — it lives in this closure, not in `workChildren`. Without it the
1614
- * machine's ceiling would be a ceiling with a hole in it: a wiki sweep over
1615
- * a large repo is one of the heaviest turns the daemon runs.
277
+ * see — it lives in the wiki runner (wikiRunner.mjs), not in
278
+ * `workChildren`. Without it the machine's ceiling would be a ceiling with
279
+ * a hole in it: a wiki sweep over a large repo is one of the heaviest turns
280
+ * the daemon runs.
1616
281
  *
1617
282
  * Read lazily (it is only ever called from the reconcile loop, long after
1618
- * `wikiChild` is declared below), for the same reason `onRepoChanged` is a
1619
- * callback: work.mjs is imported BY this file and cannot import back.
1620
- *
1621
- * `wikiBusy` COUNTS, not just the live child, and that is the wiki lane's
1622
- * version of the reservation `admission.mjs` describes: the drain sets the
1623
- * flag the moment it is admitted and the CLI does not exist until several
1624
- * awaits later, so counting the child alone left a hole exactly wide enough
1625
- * for the other lanes to spend the slot this one had already taken. The
1626
- * drain runs at most one CLI at a time, so the flag and the child are the
1627
- * same one turn and this can never double-count.
283
+ * `wiki` is created below), for the same reason `onRepoChanged` is a
284
+ * callback: work.mjs is imported BY this file and cannot import back. What
285
+ * it counts is the runner's to say — see `liveTurns` there.
1628
286
  */
1629
- extraLiveTurns: () => (wikiBusy || wikiChild ? 1 : 0),
287
+ extraLiveTurns: () => wiki?.liveTurns() ?? 0,
1630
288
  /**
1631
289
  * Whether the server takes artifacts — the roster's own word, latest poll
1632
290
  * (2026-09-22). Read lazily for the reason above: the roster loop that
@@ -1649,754 +307,84 @@ export async function runFleetDaemon({ afterLock = null } = {}) {
1649
307
  * next reconcile does the scan, on the beat it already runs.
1650
308
  */
1651
309
  onRepoChanged: () => {
1652
- repoStateScanAt = 0;
310
+ repoStateChanged();
1653
311
  },
1654
312
  });
1655
313
  workShutdown = shutdownWork; // teardown can now reach the live session CLIs
1656
314
 
1657
- const processMergeJobs = (jobs) => {
1658
- for (const job of jobs ?? []) {
1659
- if (!job || typeof job.id !== 'string') continue; // a null element would wedge the loop
1660
- if (merging.has(job.id)) continue;
1661
- merging.add(job.id);
1662
- (async () => {
1663
- try {
1664
- note(`${c.cyan('merge')} ${c.dim(`— ${job.title}`)}`);
1665
- let merged = false;
1666
- let failedReason = null; // permanent — tell the thread, clear the flag
1667
- // Refuse a PR URL that isn't an https github.com PR in THIS repo — a
1668
- // bad/hostile server must not merge a PR in another repo the user's
1669
- // gh can write to (and a leading '-' would be a gh flag).
1670
- if (!isValidPrUrl(job.prUrl, originSlug(repoRoot))) {
1671
- mergeAttempts.delete(job.id);
1672
- await reportMergeOutcome(MERGE_FAILED_URL, {
1673
- taskId: job.id,
1674
- message: 'refused: PR URL is not a pull request in this repository',
1675
- });
1676
- warn(`merge REFUSED for "${job.title}": untrusted PR URL ${String(job.prUrl)}`);
1677
- return;
1678
- }
1679
- // STACKED PR: it targets its blocker's branch so the review shows only
1680
- // its own diff. The server holds this job until that blocker merged, so
1681
- // by now the blocker's commits are in the base ref — re-point before
1682
- // squashing, or the change lands in the blocker's branch and never
1683
- // reaches the trunk while the card cheerfully says "Merged".
1684
- if (job.retargetToBase) {
1685
- try {
1686
- // baseBranchName, not baseRef: `gh pr edit --base` needs a branch
1687
- // that exists in the repo, and detectBaseRef hands back a
1688
- // remote-tracking ref (origin/main) that GitHub 422s on.
1689
- execFileSync('gh', ['pr', 'edit', job.prUrl, '--base', baseBranchName(baseRef)], {
1690
- cwd: repoRoot,
1691
- stdio: ['ignore', 'pipe', 'pipe'],
1692
- timeout: 30_000,
1693
- });
1694
- } catch (e) {
1695
- // Already targeting base is the common no-op; anything else is
1696
- // reported rather than merged into the wrong place.
1697
- const err = e.stderr?.toString?.() || e.message || '';
1698
- if (!/no changes|already/i.test(err)) {
1699
- mergeAttempts.delete(job.id);
1700
- await reportMergeOutcome(MERGE_FAILED_URL, {
1701
- taskId: job.id,
1702
- message: `could not retarget the stacked PR onto ${baseBranchName(baseRef)} — merging it now would land in the branch below it, not ${baseBranchName(baseRef)}`,
1703
- });
1704
- warn(`merge held for "${job.title}": retarget failed — ${err.split('\n')[0]}`);
1705
- return;
1706
- }
1707
- }
1708
- }
1709
- try {
1710
- execFileSync('gh', ['pr', 'merge', job.prUrl, '--squash', '--delete-branch'], {
1711
- cwd: repoRoot,
1712
- stdio: ['ignore', 'pipe', 'pipe'],
1713
- timeout: 120_000,
1714
- });
1715
- merged = true;
1716
- } catch (e) {
1717
- const err = e.stderr?.toString?.() || e.message || '';
1718
- const line = err.split('\n')[0] || 'gh pr merge failed';
1719
- // Only "already merged" is a real success; a CLOSED-without-merge PR
1720
- // also matches "not open"/"closed" but nothing landed on main —
1721
- // report it as a failure so the thread learns the truth.
1722
- if (/already merged/i.test(err)) merged = true;
1723
- else if (/not open|closed/i.test(err)) {
1724
- failedReason = 'the PR was closed without merging';
1725
- } else if (/conflict|not mergeable|CONFLICTING/i.test(err)) {
1726
- // Permanent until a human/agent acts — don't spin on it.
1727
- failedReason = `merge conflict with ${baseRef} — the branch needs a rebase`;
1728
- } else {
1729
- // Transient (auth hiccup, network, CI requirement): retry a few
1730
- // polls, then surface it instead of silently looping forever.
1731
- const n = (mergeAttempts.get(job.id) ?? 0) + 1;
1732
- mergeAttempts.set(job.id, n);
1733
- if (n >= 3) failedReason = line;
1734
- else warn(`merge failed for "${job.title}": ${line} — will retry`);
1735
- }
1736
- }
1737
- if (merged) {
1738
- mergeAttempts.delete(job.id);
1739
- await reportMergeOutcome(MERGE_DONE_URL, { taskId: job.id });
1740
- ok(`${c.cyan('merged')} ${c.dim(`— ${job.title} → ${baseRef}`)}`);
1741
- // The code just landed — re-ground the living wiki for what shipped
1742
- // (touched nodes re-read + a persistent feature-history node).
1743
- // Direct enqueue = immediacy; the server's durable regroundJobs list
1744
- // (created by merge-done above, cleared by our reground-done report)
1745
- // is the restart-safe backstop — dedup'd here by groundedIntents.
1746
- enqueueReground(job.id, job.prUrl, job.title, job.dirtiesPages, job.shas);
1747
- } else if (failedReason) {
1748
- // Report into the thread (server narrates + re-arms the merge
1749
- // button + notifies) — the job disappears from the roster.
1750
- mergeAttempts.delete(job.id);
1751
- await reportMergeOutcome(MERGE_FAILED_URL, {
1752
- taskId: job.id,
1753
- message: failedReason,
1754
- });
1755
- warn(`merge failed for "${job.title}": ${failedReason} — reported to the thread`);
1756
- }
1757
- } finally {
1758
- merging.delete(job.id);
1759
- }
1760
- })();
1761
- }
1762
- };
1763
-
1764
- // Cleanup jobs (task restarts): close the abandoned PR + delete its remote
1765
- // branch on the user's own gh, so a restart doesn't litter the repo.
1766
- const CLEANUP_DONE_URL = FLEET_URL.replace(/\/agents\/?$/, '/cleanup-done');
1767
- const cleaning = new Set();
1768
- const processCleanupJobs = (jobs) => {
1769
- for (const job of jobs ?? []) {
1770
- if (!job || typeof job.id !== 'string') continue; // a null element would wedge the loop
1771
- if (cleaning.has(job.id)) continue;
1772
- cleaning.add(job.id);
1773
- (async () => {
1774
- try {
1775
- note(`${c.cyan('cleanup')} ${c.dim(`— ${job.title} (restarted)`)}`);
1776
- // Same guards as merge: only close a PR in THIS repo, only delete a
1777
- // well-formed non-base branch. A bad server must not close a stranger's
1778
- // PR or delete `main` (`--delete` with `main`) via a cleanup job.
1779
- if (job.prUrl && isValidPrUrl(job.prUrl, originSlug(repoRoot))) {
1780
- try {
1781
- execFileSync(
1782
- 'gh',
1783
- [
1784
- 'pr',
1785
- 'close',
1786
- job.prUrl,
1787
- '--comment',
1788
- 'Task restarted in Flowviant — this attempt was discarded.',
1789
- '--delete-branch',
1790
- ],
1791
- { cwd: repoRoot, stdio: ['ignore', 'pipe', 'pipe'], timeout: 60_000 }
1792
- );
1793
- } catch (e) {
1794
- // Already closed/merged/missing = fine; anything else we still
1795
- // report done — a restart must never wedge on stale remotes.
1796
- const err = e.stderr?.toString?.() || e.message || '';
1797
- warn(`cleanup for "${job.title}": ${err.split('\n')[0] || 'gh pr close failed'}`);
1798
- }
1799
- } else if (job.branch && isValidBranch(job.branch, repoRoot, baseRef)) {
1800
- try {
1801
- // Explicit refspec form so a leading '-' can't be a git flag.
1802
- // NETWORK call, so timed and non-interactive (2026-09-24, the
1803
- // audit): a bare execFileSync here had no timeout and could
1804
- // prompt on /dev/tty, freezing every poll and lease on the
1805
- // machine behind this cleanup's own async loop.
1806
- await gitNetAsync(['push', 'origin', `:refs/heads/${job.branch}`], repoRoot);
1807
- } catch {
1808
- /* branch already gone — fine */
1809
- }
1810
- } else if (job.prUrl || job.branch) {
1811
- warn(`cleanup REFUSED for "${job.title}": untrusted PR/branch value`);
1812
- }
1813
- await reportMergeOutcome(CLEANUP_DONE_URL, { taskId: job.id });
1814
- ok(`${c.cyan('cleaned')} ${c.dim(`— ${job.title}`)}`);
1815
- } finally {
1816
- cleaning.delete(job.id);
1817
- }
1818
- })();
1819
- }
1820
- };
1821
-
1822
- // Living-wiki work runs ONE turn at a time in a dedicated repo worktree (off
1823
- // the agents' checkouts). Claude READS the repo there and writes the markdown
1824
- // VAULT (~/.flowviant/vaults/<projectId>) — plain files, no MCP tools; the
1825
- // daemon hash-diff syncs the vault to the server after each turn. Two
1826
- // triggers enqueue: a Regenerate click (full SWEEP, finalize-prunes) and a
1827
- // successful merge (incremental RE-GROUND). One queue + runner serializes
1828
- // them so they never collide on the worktree or the vault. Wiki work needs no
1829
- // agent online.
1830
- const wikiWt = join(baseDir, 'wiki');
1831
- const REGROUND_DONE_URL = FLEET_URL.replace(/\/agents\/?$/, '/reground-done');
1832
- const WIKI_VAULT_URL = FLEET_URL.replace(/\/agents\/?$/, '/wiki-vault');
1833
- const WIKI_PROGRESS_URL = FLEET_URL.replace(/\/agents\/?$/, '/wiki-progress');
1834
- const WIKI_ABANDONED_URL = FLEET_URL.replace(/\/agents\/?$/, '/wiki-abandoned');
1835
- const wikiQueue = [];
1836
- let wikiBusy = false;
1837
- let wikiChild = null; // the wiki turn's Claude process — tracked so teardown can kill it
1838
- let wikiHoldSaidAt = 0; // last time the drain said it was waiting on the box
1839
- let lastSweepAt = null; // dedup: run each Regenerate request once
1840
- // …UNLESS IT FAILED. A sweep that ends without WIKI_DONE never finalizes, so
1841
- // the server's `regen_requested_at` stays set and the roster keeps offering
1842
- // the same `requestedAt` — which this dedup then swallowed forever. The
1843
- // console said "retry from the app", to a console nobody reads. Bounded the
1844
- // same way the re-ground path is: a full sweep is an expensive model turn, so
1845
- // a repo that fails one every time must not be able to loop-burn quota.
1846
- let sweepAttempts = 0;
1847
- // Which request the counter belongs to, so a NEW Regenerate click starts with
1848
- // a full budget rather than inheriting an exhausted one.
1849
- let sweepAttemptsFor = null;
1850
- const MAX_SWEEP_ATTEMPTS = 3;
1851
- const groundedIntents = new Set(); // dedup: re-ground each delivery once
1852
- // The vault is keyed by the server project this fleet credential serves
1853
- // (learned from the roster); until the first poll names it, fall back to a
1854
- // repo-keyed dir so a stale-server daemon still works.
1855
- let wikiProjectId = null;
1856
- const vaultDirFor = () =>
1857
- wikiProjectId && isSafePathSegment(wikiProjectId)
1858
- ? join(homedir(), '.flowviant', 'vaults', wikiProjectId)
1859
- : join(homedir(), '.flowviant', 'vaults', repoKey);
1860
-
1861
- // Stream what the wiki turn is doing to the app (the canvas renders the read
1862
- // phase). Throttled to ~1/s — the FIRST activity of a run and the terminal
1863
- // `done` frame force-send so the cover appears fast and clears cleanly.
1864
- let lastProgressAt = 0;
1865
- const postWikiProgress = async (body, force = false) => {
1866
- const now = Date.now();
1867
- if (!force && now - lastProgressAt < 600) return;
1868
- lastProgressAt = now;
1869
- // Uplink scrub: narration/labels can quote repo content, and repo content
1870
- // can contain a synced secret — redact known values before anything leaves
1871
- // this machine.
1872
- const safe = {
1873
- ...body,
1874
- ...(typeof body.activity === 'string' ? { activity: envScrub(body.activity) } : {}),
1875
- ...(Array.isArray(body.recent) ? { recent: body.recent.map((s) => envScrub(s)) } : {}),
1876
- };
1877
- try {
1878
- await fetch(WIKI_PROGRESS_URL, {
1879
- method: 'POST',
1880
- headers: {
1881
- Authorization: `Bearer ${FLEET_TOKEN}`,
1882
- 'User-Agent': USER_AGENT,
1883
- 'Content-Type': 'application/json',
1884
- },
1885
- signal: AbortSignal.timeout(15_000),
1886
- body: JSON.stringify(safe),
1887
- });
1888
- } catch {
1889
- /* best-effort — a dropped frame is harmless, the next one supersedes it */
1890
- }
1891
- };
315
+ const processCleanupJobs = createCleanupLane({ repoRoot, getBaseRef });
1892
316
 
317
+ // Living-wiki work: one cartographer turn at a time, off the agents'
318
+ // checkouts (wikiRunner.mjs). Wiki work needs no agent online.
319
+ wiki = createWikiRunner({ repoRoot, baseDir, repoKey, getBaseRef, admit });
1893
320
  /**
1894
- * Tell the server this daemon has stopped retrying the pending sweep.
321
+ * IS THIS MACHINE WORKING — every lane this process runs: the wiki
322
+ * cartographer, session and agent work (`workBusy` — turns, plans, merges,
323
+ * ships, undelivered settles), and deploys.
1895
324
  *
1896
- * The retry budget is a `let` in this process; `regen_requested_at` is a
1897
- * durable column with a 24-hour TTL. Without this the two disagreed — the
1898
- * daemon had permanently given up while every surface went on calling the
1899
- * sweep queued, for the rest of the day. Best-effort: an older server 404s
1900
- * once and the TTL is still the backstop it always was.
325
+ * One predicate for the two callers that must not restart the daemon under
326
+ * work: the self-update gate above, and the desktop app's automatic install,
327
+ * which reads it from `status --json` (`busy`, written each tick by
328
+ * `reportBusy`, 2026-09-26 — SOLID F034). The tray used to infer idleness
329
+ * from the server's list of WORKING AGENTS, which cannot see a Workbench
330
+ * turn, a ship or a deploy.
1901
331
  */
1902
- const postWikiAbandoned = async () => {
1903
- try {
1904
- await fetch(WIKI_ABANDONED_URL, {
1905
- method: 'POST',
1906
- headers: {
1907
- Authorization: `Bearer ${FLEET_TOKEN}`,
1908
- 'User-Agent': USER_AGENT,
1909
- 'Content-Type': 'application/json',
1910
- },
1911
- signal: AbortSignal.timeout(15_000),
1912
- body: '{}',
1913
- });
1914
- } catch {
1915
- /* best-effort — the request's own TTL still expires it */
1916
- }
1917
- };
1918
-
1919
- const enqueueSweep = (job) => {
1920
- if (!job || job.requestedAt === lastSweepAt) return;
1921
- if (job.requestedAt !== sweepAttemptsFor) {
1922
- sweepAttemptsFor = job.requestedAt;
1923
- sweepAttempts = 0;
1924
- }
1925
- lastSweepAt = job.requestedAt;
1926
- // A full sweep is expensive — never stack two. One queued sweep already
1927
- // covers any newer Regenerate click (it reads the repo fresh when it runs).
1928
- // A failed/partial sweep stays recoverable: re-clicking Regenerate always
1929
- // refreshes requestedAt server-side, beating this dedup.
1930
- if (wikiQueue.some((t) => t.type === 'sweep')) return;
1931
- wikiQueue.push({ type: 'sweep' });
1932
- void drainWiki();
1933
- };
1934
- const enqueueReground = (intentId, prUrl, title, dirtiesPages, shas) => {
1935
- if (!intentId || groundedIntents.has(intentId)) return;
1936
- groundedIntents.add(intentId);
1937
- wikiQueue.push({
1938
- type: 'reground',
1939
- intentId,
1940
- prUrl,
1941
- title: title || 'a delivered task',
1942
- // What the PLAN thought this would invalidate. A hint, not the truth —
1943
- // the turn still reads the real changed files; this catches pages whose
1944
- // frontmatter file list has drifted, or that document a concept rather
1945
- // than a directory.
1946
- //
1947
- // SANITIZED AT THE INTAKE, because every entry is server-supplied text
1948
- // that ends up interpolated into a prompt and printed by the drain's
1949
- // narration: a control byte can repaint the console it lands on, a
1950
- // newline can break out of the prompt's own list framing, and an
1951
- // unbounded array of unbounded strings is an unbounded prompt. This is
1952
- // the belt at the intake; the prompt keeps its own fence at the
1953
- // interpolation.
1954
- dirtiesPages: (Array.isArray(dirtiesPages) ? dirtiesPages : [])
1955
- .filter((p) => typeof p === 'string')
1956
- .slice(0, 40)
1957
- .map((p) =>
1958
- p
1959
- .replace(/[\u0000-\u001f\u007f]/g, ' ')
1960
- .trim()
1961
- .slice(0, 300)
1962
- )
1963
- .filter(Boolean),
1964
- // THE COMMITS THAT SHIPPED — what changedFilesForShas resolves against.
1965
- // Dropping this here was the whole 0.54.0/0.54.1 defect: the server sent
1966
- // shas on every reground job, this function never stored them, and the
1967
- // drain's `task.shas` was undefined on every job — so the re-ground
1968
- // "revived" on 2026-08-22 retried three times against nothing and gave
1969
- // up, on a console nobody reads, on every single ship.
1970
- shas: Array.isArray(shas) ? shas : [],
1971
- });
1972
- void drainWiki();
1973
- };
1974
-
1975
- // Changed files of a (merged) PR, for the re-ground prompt. Capped so a huge
1976
- // PR can't blow up the prompt. prUrl was already validated before the merge.
1977
- // WHICH FILES A SHIP CHANGED, read from the commits it landed.
1978
- //
1979
- // This asked `gh pr view <prUrl> --json files` until 2026-08-22, and `prUrl`
1980
- // has been null by construction since dispatch was deleted on 2026-08-19 —
1981
- // the server writes null and says so in a comment. Node threw on the null
1982
- // argument, the catch below read it as "gh failed", and the re-ground retried
1983
- // three times and gave up. Every post-ship re-ground for three months did
1984
- // that silently, while the spec said ship re-grounds the wiki.
1985
- //
1986
- // Returns null when it learned NOTHING (no shas, or none of them resolvable),
1987
- // which the caller still treats as retryable — distinct from a ship that
1988
- // genuinely changed no files.
1989
- const changedFilesForShas = (shas) => {
1990
- if (!Array.isArray(shas) || shas.length === 0) return null;
1991
- const files = new Set();
1992
- for (const sha of shas.slice(0, 50)) {
1993
- if (!/^[0-9a-f]{7,40}$/i.test(String(sha))) continue;
1994
- try {
1995
- const out = execFileSync(
1996
- 'git',
1997
- ['show', '--name-only', '--pretty=format:', String(sha)],
1998
- { cwd: repoRoot, encoding: 'utf8', stdio: ['ignore', 'pipe', 'pipe'] }
1999
- );
2000
- for (const line of out.split('\n')) {
2001
- const f = line.trim();
2002
- if (f) files.add(f);
2003
- if (files.size >= 60) break;
2004
- }
2005
- } catch {
2006
- // One unreachable commit is not a failed re-ground — the ship merged
2007
- // to main and the rest of the shas still name real files. Only an
2008
- // EMPTY result is treated as "we learned nothing".
2009
- }
2010
- if (files.size >= 60) break;
2011
- }
2012
- return files.size ? [...files] : null;
2013
- };
2014
- const regroundAttempts = new Map(); // intentId -> gh-failure count
2015
-
2016
- async function drainWiki() {
2017
- if (wikiBusy || wikiQueue.length === 0) return;
2018
- /**
2019
- * NOT WHILE THE BOX IS UNDER PRESSURE. A sweep is a CLI reading a whole
2020
- * repository, which is the heaviest turn the daemon runs and the one
2021
- * nobody is waiting on — so it is the first thing to yield.
2022
- *
2023
- * The queue is left INTACT: nothing is claimed, nothing is consumed, and
2024
- * the reconcile loop calls this again on its next poll. The one thing that
2025
- * must not happen is setting `wikiBusy` and returning, which would strand
2026
- * the drain until a restart.
2027
- */
2028
- const hold = admit('churn');
2029
- if (hold) {
2030
- // Said at most every five minutes: this runs on every poll, and a queued
2031
- // sweep can sit through a long stretch of pressure — a line every twenty
2032
- // seconds would be the console restating one unchanged fact all evening.
2033
- if (Date.now() - wikiHoldSaidAt > 5 * 60_000) {
2034
- wikiHoldSaidAt = Date.now();
2035
- note(`${c.cyan('wiki')} ${c.dim(`— holding off: ${hold.reason}`)}`);
2036
- }
2037
- return;
2038
- }
2039
- wikiHoldSaidAt = 0;
2040
- wikiBusy = true;
2041
- // Held for the WHOLE drain: this loop resets the worktree between tasks, and
2042
- // a consult reading it mid-reset sees files vanish under it.
2043
- return withWikiLock(async () => {
2044
- try {
2045
- while (wikiQueue.length) {
2046
- const task = wikiQueue.shift();
2047
- // The vault is plain files — the turn needs no MCP server and no
2048
- // cartographer token; the daemon itself syncs afterwards on the fleet
2049
- // credential.
2050
- // Guard the mkdir: this runs OUTSIDE the per-task try below, so an
2051
- // ENOSPC/EACCES here (disk full is an anticipated prod condition —
2052
- // worktrees + vault history grow) would escape drainWiki as an unhandled
2053
- // rejection and take down the whole daemon mid-work. Skip this sweep on
2054
- // failure instead.
2055
- let vaultDir;
2056
- try {
2057
- vaultDir = vaultDirFor();
2058
- ensureVault(vaultDir);
2059
- } catch (e) {
2060
- warn(`wiki sweep skipped — vault dir unavailable: ${e?.message || e}`);
2061
- continue;
2062
- }
2063
- // Live progress for this turn: a rolling FEED of everything Claude does
2064
- // (thinking, narration, reads, node writes), the file count, and the
2065
- // phase — streamed to the app (throttled; each frame carries the whole
2066
- // recent tail so a dropped POST loses nothing). elapsedSec is the
2067
- // daemon's own clock.
2068
- const mode = task.type === 'sweep' ? 'sweep' : 'reground';
2069
- const startedAt = Date.now();
2070
- let filesRead = 0;
2071
- let phase = 'reading';
2072
- // Distinct vault pages this turn has written. Counted HERE, from the
2073
- // stream, because it is the only place that knows mid-turn: the daemon
2074
- // syncs the vault to the server once, AFTER the turn returns, so a
2075
- // server-side count of "rows touched since the turn began" is zero for
2076
- // the entire writing phase — which is exactly how long the bar needs it.
2077
- // A Set, not a counter: pages get written once and then edited, and
2078
- // three tool calls on one page are one page.
2079
- const pagesSeen = new Set();
2080
- const feed = [];
2081
- const frame = (extra) => ({
2082
- mode,
2083
- phase,
2084
- activity: feed[feed.length - 1] ?? '',
2085
- recent: feed.slice(-24),
2086
- filesRead,
2087
- pagesWritten: pagesSeen.size,
2088
- elapsedSec: Math.round((Date.now() - startedAt) / 1000),
2089
- ...extra,
2090
- });
2091
- const onActivity = (a) => {
2092
- if (a.kind === 'read') filesRead++;
2093
- if (a.kind === 'write') {
2094
- phase = 'writing';
2095
- pagesSeen.add(a.path || a.label);
2096
- }
2097
- // Collapse runs of bare "thinking…" so the feed doesn't fill with it.
2098
- // Keyed on the SHARED constant (runtimes.mjs), not on the literal: the
2099
- // labels being compared here are the ones claude.mjs now builds from
2100
- // that constant, so a reworded marker would leave this comparison
2101
- // matching nothing and the 48-slot feed filling with the repeat — the
2102
- // exact noise this line exists to stop, and silent, because a collapse
2103
- // that stops collapsing fails no test.
2104
- if (!(a.label === THINK_MARKER && feed[feed.length - 1] === THINK_MARKER)) {
2105
- feed.push(a.label);
2106
- if (feed.length > 48) feed.shift();
2107
- }
2108
- void postWikiProgress(frame());
2109
- };
2110
- // Heartbeat: re-send the current frame every 5s even with no new stream
2111
- // event, so the app's freshness window never lapses during a long
2112
- // thinking block or slow tool (which emit nothing until they finish) —
2113
- // otherwise the cover would flap back to the empty state mid-sweep.
2114
- let heartbeat = null;
2115
- /**
2116
- * Did THIS sweep finish? Read by the `finally` below, which owns the
2117
- * retry decision for every way out of this block.
2118
- *
2119
- * It used to be decided inline on the one branch where the turn
2120
- * returned without its sentinel — so the two OTHER ways a sweep fails,
2121
- * `pickRuntimeFor` finding no CLI and the catch around the whole turn,
2122
- * left the request pinned and never retried at all. Those are the
2123
- * failures most worth retrying: a missing runtime is fixed by
2124
- * installing one, and a thrown turn is exactly the transient case.
2125
- */
2126
- let sweepCompleted = false;
2127
- try {
2128
- // Immediate frame so the cover shows the daemon feed right away (the
2129
- // "reading your code" phase), not a static message, while Claude warms up.
2130
- feed.push('starting…');
2131
- await postWikiProgress(frame(), true);
2132
- heartbeat = setInterval(() => void postWikiProgress(frame(), true), 5000);
2133
- if (!existsSync(wikiWt)) {
2134
- try {
2135
- git(['worktree', 'add', '--detach', wikiWt, baseRef], repoRoot);
2136
- } catch {
2137
- git(['worktree', 'prune'], repoRoot);
2138
- git(['worktree', 'add', '--detach', wikiWt, baseRef], repoRoot);
2139
- }
2140
- }
2141
- resetWorktree(wikiWt, baseRef);
2142
- let sha = '';
2143
- try {
2144
- sha = git(['rev-parse', 'HEAD'], wikiWt);
2145
- } catch {
2146
- /* detached/no HEAD — still writes the map, just ungrounded */
2147
- }
2148
- // Sync the vault after the turn regardless of the sentinel: a died
2149
- // sweep's partial pages still persist (merge, no prune) — only a
2150
- // COMPLETED sweep finalizes, so an interrupted one can't erase pages.
2151
- const runSync = async (finalize) => {
2152
- try {
2153
- const r = await syncVault({
2154
- dir: vaultDir,
2155
- url: WIKI_VAULT_URL,
2156
- token: FLEET_TOKEN,
2157
- userAgent: USER_AGENT,
2158
- finalize,
2159
- groundedAtSha: sha || undefined,
2160
- // Powers the GitHub blob links behind every cited file path.
2161
- repoFullName: originSlug(repoRoot) || undefined,
2162
- warn,
2163
- // Redact synced secrets a page may have quoted from the repo.
2164
- scrub: envScrub,
2165
- });
2166
- if (r.skipped) note(`${c.cyan('wiki')} ${c.dim('— vault unchanged, nothing to sync')}`);
2167
- else
2168
- ok(
2169
- `${c.cyan('wiki')} ${c.dim(
2170
- `— synced ${r.uploaded} page${r.uploaded === 1 ? '' : 's'} (${r.pages} total${r.deleted ? `, ${r.deleted} removed` : ''})`
2171
- )}`
2172
- );
2173
- } catch (e) {
2174
- warn(`wiki vault sync failed: ${e.message} — pages stay local; next turn retries`);
2175
- }
2176
- };
2177
- // The cartographer needs to read the repo and write ONLY the vault —
2178
- // a narrower promise than "build", so it is its own profile.
2179
- const wikiRt = pickRuntimeFor('wiki');
2180
- if (!wikiRt) {
2181
- warn('wiki generation skipped — no installed CLI can run a vault-scoped turn');
2182
- return;
2183
- }
2184
- const wikiLabel = RUNTIMES[wikiRt].label;
2185
- if (task.type === 'sweep') {
2186
- sweepAttempts++;
2187
- note(`${c.cyan('wiki')} ${c.dim(`— regenerating: your ${wikiLabel} is reading the repo…`)}`);
2188
- const out = await runTurn({
2189
- prompt: WIKI_KICKOFF(sha, vaultDir),
2190
- resume: false,
2191
- system: SYSTEM_WIKI(vaultDir),
2192
- cwd: wikiWt,
2193
- wikiPerm: true,
2194
- vaultDir,
2195
- runtime: wikiRt,
2196
- label: c.cyan('[wiki]'),
2197
- streamJson: true,
2198
- onActivity,
2199
- // FREE SKILLS, off a turn that was running anyway. This stream is
2200
- // already parsed and its init event already carries the CLI's own
2201
- // resolved skill set — the same fact a tab turn teaches — so the
2202
- // only thing missing was the handler. The wiki turn runs in a
2203
- // detached worktree of THIS repo, so its `.claude/skills` and the
2204
- // machine's personal ones resolve identically to a tab's.
2205
- onInit: (i) => {
2206
- recordSkills(i.skills);
2207
- recordMcpServers(i.mcpServers);
2208
- },
2209
- onSpawn: (ch) => {
2210
- wikiChild = ch;
2211
- },
2212
- });
2213
- const complete = sawSentinel(out, 'WIKI_DONE');
2214
- if (complete) {
2215
- sweepCompleted = true;
2216
- ok(`${c.cyan('wiki')} ${c.dim('— vault regenerated from your code.')}`);
2217
- } else {
2218
- warn('wiki sweep ended without WIKI_DONE — partial pages synced');
2219
- }
2220
- await runSync(complete);
2221
- } else {
2222
- const files = changedFilesForShas(task.shas);
2223
- if (files === null) {
2224
- // gh failed (network/auth) — retry via the durable job a couple
2225
- // of times before consuming it, so a transient outage doesn't
2226
- // silently drop the re-ground.
2227
- const n = (regroundAttempts.get(task.intentId) ?? 0) + 1;
2228
- regroundAttempts.set(task.intentId, n);
2229
- if (n < 3) {
2230
- warn(`wiki re-ground for "${task.title}": no changed files resolved — will retry (${n}/3)`);
2231
- groundedIntents.delete(task.intentId); // let the roster re-offer it
2232
- continue;
2233
- }
2234
- warn(`wiki re-ground for "${task.title}": could not resolve changed files ${n} times — giving up (heals on the next full sweep)`);
2235
- } else if (files.length === 0) {
2236
- note(`${c.cyan('wiki')} ${c.dim(`— "${task.title}": no changed files to re-ground`)}`);
2237
- } else {
2238
- note(`${c.cyan('wiki')} ${c.dim(`— re-grounding after "${task.title}"…`)}`);
2239
- const out = await runTurn({
2240
- prompt: REGROUND_KICKOFF({
2241
- sha,
2242
- title: task.title,
2243
- files,
2244
- vaultDir,
2245
- predictedPages: task.dirtiesPages ?? [],
2246
- }),
2247
- resume: false,
2248
- system: SYSTEM_REGROUND(vaultDir),
2249
- cwd: wikiWt,
2250
- wikiPerm: true,
2251
- vaultDir,
2252
- runtime: wikiRt,
2253
- label: c.cyan('[wiki]'),
2254
- streamJson: true,
2255
- onActivity,
2256
- // Same free harvest as the sweep above.
2257
- onInit: (i) => {
2258
- recordSkills(i.skills);
2259
- recordMcpServers(i.mcpServers);
2260
- },
2261
- onSpawn: (ch) => {
2262
- wikiChild = ch;
2263
- },
2264
- });
2265
- if (sawSentinel(out, 'REGROUND_DONE'))
2266
- ok(`${c.cyan('wiki')} ${c.dim(`— vault updated for "${task.title}".`)}`);
2267
- else warn(`wiki re-ground for "${task.title}" ended without REGROUND_DONE.`);
2268
- await runSync(false);
2269
- }
2270
- // Consume the durable job: attempted = done (success or not — the
2271
- // sync is idempotent and a failed turn heals on the next full
2272
- // sweep), so a failing re-ground can't loop-burn quota. Only a
2273
- // crash BEFORE this line leaves the job listed for a retry.
2274
- regroundAttempts.delete(task.intentId);
2275
- await reportMergeOutcome(REGROUND_DONE_URL, { taskId: task.intentId });
2276
- // The dedup was DAEMON-LIFETIME, which wedged a reopened card: its
2277
- // second ship writes a fresh durable job, this Set still holds the
2278
- // taskId, enqueueReground refuses it on every poll forever, and
2279
- // the never-consumed job churns the wiki-writer lease until a
2280
- // restart. The job is consumed now, so the guard has done its work;
2281
- // a FUTURE ship of the same card is new work, not a duplicate.
2282
- groundedIntents.delete(task.intentId);
2283
- }
2284
- } catch (e) {
2285
- warn(`wiki ${task.type} failed: ${e.message}`);
2286
- } finally {
2287
- wikiChild = null;
2288
- if (heartbeat) clearInterval(heartbeat);
2289
- /**
2290
- * THE RETRY DECISION, IN ONE PLACE, FOR EVERY WAY OUT OF THIS BLOCK.
2291
- *
2292
- * Deciding it inline on the no-sentinel branch covered one of the
2293
- * three ways a sweep fails and silently declined the other two. Here
2294
- * it covers the thrown turn and the no-runtime return as well, which
2295
- * are the two most worth retrying.
2296
- *
2297
- * A successful sweep resets the budget. A failed one with budget left
2298
- * clears `lastSweepAt` so the roster's next offer of the SAME request
2299
- * is accepted — partial pages are synced without pruning either way,
2300
- * so a retry resumes rather than starting over. A failed one with the
2301
- * budget spent tells the SERVER, because the counter is process-local
2302
- * and the request it bounds is durable for 24 hours.
2303
- */
2304
- if (task.type === 'sweep') {
2305
- if (sweepCompleted) {
2306
- sweepAttempts = 0;
2307
- } else if (sweepAttempts < MAX_SWEEP_ATTEMPTS) {
2308
- lastSweepAt = null;
2309
- warn(`wiki sweep failed — retrying (${sweepAttempts}/${MAX_SWEEP_ATTEMPTS})`);
2310
- } else {
2311
- warn(
2312
- `wiki sweep failed ${sweepAttempts} times — giving up on this request; press Regenerate to try again.`
2313
- );
2314
- await postWikiAbandoned();
2315
- }
2316
- }
2317
- // Terminal frame so the app cover clears promptly (don't wait for the
2318
- // freshness window to lapse). force-sent past the throttle.
2319
- await postWikiProgress(frame({ done: true }), true);
2320
- // Safety net: the wiki turn is read-only on the repo by CONTRACT, but
2321
- // permission enforcement is a curated tool list, not a path jail —
2322
- // discard anything a confused turn wrote to the worktree so it can
2323
- // never leak into a later turn or a push.
2324
- try {
2325
- resetWorktree(wikiWt, baseRef);
2326
- } catch {
2327
- /* best-effort */
2328
- }
2329
- }
2330
- }
2331
- } finally {
2332
- wikiBusy = false;
2333
- }
2334
- });
2335
- }
332
+ const machineBusy = () => wiki.busy() || workBusy() || deploysInFlight() > 0;
333
+ const reportBusy = () =>
334
+ writeDaemonState(desktopProjectId, { busy: machineBusy(), busyAt: new Date().toISOString() });
335
+ /**
336
+ * HOW EVERY STAND-DOWN LEAVES — after the teardown, and after any deploy in
337
+ * flight has finished and reported (standDownExit.mjs, ruling 2026-09-26).
338
+ * The signal handlers are installed here, beside it, rather than at the
339
+ * teardown: nothing between the two awaits, so no signal could have been
340
+ * handled any earlier.
341
+ */
342
+ const leave = createLeave({
343
+ inFlight: deploysInFlight,
344
+ labels: deploysInFlightLabels,
345
+ settled: whenDeploysSettle,
346
+ abandon: reportDeploysAbandoned,
347
+ markDraining: (what) => markLockDraining(FLEET_TOKEN, what),
348
+ // The tray reads this as working (its `stopped` came first, from the
349
+ // stand-down itself); `deploy` is the same `target → env` words.
350
+ announce: (what) => emitMachineEvent({ event: 'draining', deploy: what }),
351
+ reportBusy,
352
+ exit: (code) => process.exit(code),
353
+ log: { note, warn },
354
+ });
355
+ installSignalHandlers(teardown, leave);
2336
356
 
2337
357
  let connected = false; // log the first successful poll once
2338
358
  let rosterSig = null; // last roster membership, to log changes only
2339
359
  let idleBeatAt = 0; // throttle the "still alive" idle heartbeat
2340
- let cappedWarned = false; // say once, not every reconcile, why extra lanes idle
2341
360
  // WHOSE MACHINE THIS IS, as of the last poll the server arbitrated. 'absent'
2342
361
  // is the reserved meaning — an older server, or a poll with no envpub — and
2343
362
  // everything downstream of it must read exactly as it did before 0.84.0.
2344
363
  const holderWatch = createHolderWatch({ say: (m) => note(m) });
2345
364
  let holderState = 'absent';
2346
365
 
2347
- // ── Push channel: a server wake short-circuits the reconcile sleep so a job is
2348
- // picked up in ~a round trip instead of on the next poll. The socket only
2349
- // nudges — we still fetch the roster below — so it's pure latency, and the
2350
- // poll stays the fallback whenever the socket is down. `waitReconcile()`
2351
- // resolves on either a wake or the RECONCILE_SECONDS timeout, whichever first.
2352
- let wakeSignal = null; // { resolve, timer } while the loop is idling
2353
- let pendingWake = false; // a wake that landed mid-reconcile — honored next wait
2354
- const fireWake = () => {
2355
- if (wakeSignal) {
2356
- clearTimeout(wakeSignal.timer);
2357
- const { resolve } = wakeSignal;
2358
- wakeSignal = null;
2359
- resolve();
2360
- } else {
2361
- pendingWake = true; // not idling right now; don't lose the wake
2362
- }
2363
- };
2364
- const waitReconcile = () => {
2365
- if (pendingWake) {
2366
- pendingWake = false;
2367
- return Promise.resolve();
2368
- }
2369
- return new Promise((resolve) => {
2370
- const timer = setTimeout(() => {
2371
- wakeSignal = null;
2372
- resolve();
2373
- }, RECONCILE_SECONDS * 1000);
2374
- wakeSignal = { resolve, timer };
2375
- });
2376
- };
366
+ // ── Push channel: a server wake short-circuits the reconcile sleep (see
367
+ // fleetWake.mjs). The socket only nudges — we still fetch the roster below.
368
+ const { fireWake, waitReconcile } = createReconcileWait();
2377
369
  stream = connectStream({ onWake: () => fireWake(), isAlive: () => daemonAlive });
2378
-
2379
- // Which agents to tell the server we already hold a good token for. We keep
2380
- // our token (omit a re-mint) UNLESS it's near expiry AND the worker is idle
2381
- // (no child mid-turn) — then we drop it from `have` to force a fresh token,
2382
- // safely between turns so we never swap a credential out from under a run.
2383
- const buildHave = () =>
2384
- [...tokenByAgent.keys()].filter((id) => {
2385
- const ageS = (Date.now() - (mintedAt.get(id) ?? 0)) / 1000;
2386
- const nearExpiry = ageS > leaseTtlSeconds - REFRESH_BEFORE_SECONDS;
2387
- const midTurn = workers.get(id)?.state.child != null;
2388
- return !nearExpiry || midTurn;
2389
- });
2390
-
2391
- // Reconcile loop: poll roster, start new workers, stop removed ones.
370
+ // Reconcile loop: poll the roster, hand each key to its lane, report, wait.
371
+ //
372
+ // A STAND-DOWN ENDS THE LOOP, WHICHEVER DOOR IT CAME THROUGH. The roster
373
+ // stand-downs and the revoked credential `return` where they happen; a
374
+ // SIGNAL arrives between awaits, from outside, so every await in the loop is
375
+ // followed by the same question (`standingDown`) and the loop returns into
376
+ // `leave`'s wait. Past this line nothing takes work: no poll, no turn, no
377
+ // share, no new deploy — only the deploys already claimed run on to their
378
+ // report (standDownExit.mjs).
379
+ const standingDown = () => leave.leaving();
2392
380
  for (;;) {
381
+ if (standingDown()) return leave.drained();
2393
382
  let roster;
2394
383
  try {
2395
384
  // The churn admission, asked ONCE here and relayed as `pr`: the same
2396
385
  // question every unattended lane asks a few lines later, so what the
2397
386
  // board is told and what the machine then does cannot disagree.
2398
387
  roster = await fetchRoster(
2399
- buildHave(),
2400
388
  livePreviewIds(),
2401
389
  heldSessionIds(),
2402
390
  admit('churn'),
@@ -2404,6 +392,7 @@ export async function runFleetDaemon({ afterLock = null } = {}) {
2404
392
  getBaseRef()
2405
393
  );
2406
394
  } catch (e) {
395
+ if (standingDown()) return leave.drained();
2407
396
  if (e.auth) {
2408
397
  emitMachineEvent({ event: 'stopped', reason: 'credential-revoked' });
2409
398
  fail(`${e.message} — credential revoked or invalid. Shutting down.`);
@@ -2413,58 +402,23 @@ export async function runFleetDaemon({ afterLock = null } = {}) {
2413
402
  // can never fix it. Under `Restart=on-failure` a nonzero code has
2414
403
  // systemd relaunch the daemon immediately — a restart loop hammering
2415
404
  // dead-credential polls, fighting the Disconnect that revoked it, and
2416
- // ending in a unit that reads as a crash rather than a kill.
2417
- process.exit(0);
405
+ // ending in a unit that reads as a crash rather than a kill. Through
406
+ // `leave`, so a deploy in flight still finishes; its report will most
407
+ // likely be refused too, and the lease says so in words.
408
+ await leave(0);
409
+ return;
2418
410
  }
2419
411
  warn(`roster poll failed: ${e.message} — retrying in ${RECONCILE_SECONDS}s`);
412
+ // Work already running keeps running offline; the measurement keeps up.
413
+ reportBusy();
2420
414
  await sleep(RECONCILE_SECONDS);
2421
415
  continue;
2422
416
  }
417
+ if (standingDown()) return leave.drained();
2423
418
  if (!connected) {
2424
419
  connected = true;
2425
420
  ok('Connected to Flowviant — watching your roster.');
2426
- // Name the scoped project so a mismatch (this daemon serves project A, but
2427
- // you're viewing project B's wiki) is obvious instead of a silent no-op.
2428
- if (roster.project) {
2429
- note(
2430
- `${c.cyan('project')} · ${c.bold(projectLabel({ name: roster.project.name, projectId: roster.project.id }))} ${c.dim(`(${safeName(roster.project.id) ?? ''})`)}`
2431
- );
2432
- note(c.dim(' wiki + agents stream to THIS project — view its Code canvas in Flowviant.'));
2433
- // Remember the NAME beside the stored credential, so the picker and
2434
- // `flowviant projects` can say "skadooble" instead of an id — a
2435
- // credential saved before the server sent names backfills here. No-op
2436
- // for a --fleet/env token (nothing stored to annotate).
2437
- setStoredProjectName(roster.project.id, roster.project.name);
2438
- }
2439
- /**
2440
- * WHAT ELSE IS SERVING THIS PROJECT — said ONCE, right after the first
2441
- * poll lands, and never again (0.91.0).
2442
- *
2443
- * The owner could not answer a plain question about his own setup: "im
2444
- * not sure if i have any duplicate or redundant daemons running". This is
2445
- * the half of the answer that belongs in the terminal you just typed
2446
- * into, because that is where you are standing when the question occurs
2447
- * to you — the app's Home is where the whole account's answer lives.
2448
- *
2449
- * A RELAY, and it withholds nothing on failure: an older server has no
2450
- * boxes route and 404s, which prints NOTHING rather than a complaint
2451
- * about a feature nobody asked for. Fire-and-forget and never awaited on
2452
- * the poll path — a listing must never delay a roster tick.
2453
- */
2454
- void (async () => {
2455
- try {
2456
- const { boxesUrlFrom, fetchBoxesFor, otherBoxesLine } = await import('./machines.mjs');
2457
- const res = await fetchBoxesFor(
2458
- { fleetToken: FLEET_TOKEN },
2459
- { url: boxesUrlFrom(FLEET_URL), envpub: myPubB64() ?? undefined }
2460
- );
2461
- if (!res.boxes) return;
2462
- const line = otherBoxesLine(res.boxes, res.me);
2463
- if (line) note(line);
2464
- } catch {
2465
- /* a readout — it must never disturb the daemon that produced it */
2466
- }
2467
- })();
421
+ announceFirstPoll(roster);
2468
422
  }
2469
423
  if (roster.project?.id) desktopProjectId = roster.project.id;
2470
424
  writeDaemonState(desktopProjectId, { lastPoll: new Date().toISOString() });
@@ -2483,114 +437,27 @@ export async function runFleetDaemon({ afterLock = null } = {}) {
2483
437
  * reports is therefore the value that was actually in force.
2484
438
  */
2485
439
  setServerMaxTurns(roster.maxTurns);
2486
- if (roster.project?.id) wikiProjectId = roster.project.id; // keys the vault dir
440
+ if (roster.project?.id) wiki.setProjectId(roster.project.id); // keys the vault dir
2487
441
  // The server's word on which project this token serves — settles the env
2488
442
  // report's salt for a token that came from --fleet or the environment.
2489
443
  learnProjectId(roster.project?.id);
2490
444
  if (roster.leaseTtlSeconds) leaseTtlSeconds = roster.leaseTtlSeconds;
2491
- // A COMMANDED STOP OUTRANKS AN UPDATE, and that ordering is the whole reason
2492
- // this sits ABOVE the version signal rather than inside it. Both read the
2493
- // same `roster.daemon` object, but `handleVersionSignal` can re-exec this
2494
- // process into a newer build — so checked second, a machine somebody just
2495
- // told to stop would come back up wearing a different version instead of
2496
- // going away.
2497
- const stopSignal = shouldStop(roster.daemon);
2498
- if (stopSignal) {
2499
- emitMachineEvent({ event: 'stopped', reason: stopSignal.reason ?? null });
2500
- warn(
2501
- stopSignal.reason
2502
- ? `stopped by Flowviant — ${stopSignal.reason}`
2503
- : 'stopped by Flowviant — no reason given.'
2504
- );
2505
- note('shutting down — stopping workers. Worktrees are kept: in-flight work resumes next run.');
2506
- // FLUSH the settle queue first, bounded: a queued-but-undelivered report
2507
- // is a COMPLETED turn whose side effects already happened, and dropping
2508
- // it re-runs the whole turn on the next start — quota spent twice and
2509
- // every card write doubled. This path is async (unlike the signal
2510
- // handlers, which cannot await), so the stop can afford five seconds of
2511
- // delivery before it obeys.
2512
- try {
2513
- await Promise.race([flushWorkReports(), sleep(5)]);
2514
- } catch {
2515
- /* undelivered reports re-run; delivering them was best-effort */
2516
- }
2517
- // teardown() is NOT optional on this path. Detached preview tunnels
2518
- // survive this process BY DESIGN, so exiting without it strands a public
2519
- // hostname pointed into a worktree until somebody reboots the box — which
2520
- // is precisely the state a remote stop is usually being used to end. It
2521
- // also kills the session CLIs and the wiki Claude, which would otherwise
2522
- // keep editing worktrees and burning quota for a machine nobody is
2523
- // watching any more.
2524
- teardown();
2525
- // EXIT 0, and this is load-bearing: the stop was ASKED FOR, so it is not
2526
- // a failure. Under `Restart=on-failure` a nonzero code has systemd
2527
- // relaunch the daemon immediately, fighting the very command that stopped
2528
- // it; exit 0 reads as "the job is done" and leaves it down. The server's
2529
- // honor window is what makes the other half work — a deliberate relaunch
2530
- // minutes later comes up clean instead of stopping itself forever.
2531
- process.exit(0);
2532
- }
2533
- /**
2534
- * THE MACHINE MOVED OUT FROM UNDER US — checked BEFORE the version signal
2535
- * for the reason the stop above is: `handleVersionSignal` can re-exec this
2536
- * process, and a box that has just been displaced coming back up wearing a
2537
- * newer version is the one outcome nobody asked for.
2538
- *
2539
- * The key's PRESENCE is the command, exactly as it is for a stop: the server
2540
- * sends it only when it names THIS box's `envpub` and only inside its own
2541
- * window, so there is no TTL to re-evaluate here and no way for a relaunch
2542
- * to obey a displacement aimed at somebody else.
2543
- */
2544
- if (roster.displaced && typeof roster.displaced === 'object' && !Array.isArray(roster.displaced)) {
2545
- await standDownDisplaced({
2546
- by: roster.displaced.by,
445
+ // THE THREE SIGNALS THAT END THIS DAEMON — a commanded stop, then a
446
+ // displacement, then a removal (holder.mjs, `obeyRosterCommands`). ABOVE
447
+ // the holder observation and the version signal: `handleVersionSignal` can
448
+ // re-exec this process, and a machine somebody just stopped, moved or
449
+ // removed coming back up wearing a newer version is the one outcome nobody
450
+ // asked for.
451
+ if (
452
+ await obeyRosterCommands(roster, {
2547
453
  settleAgentTurns,
2548
- flushReports: flushWorkReports,
454
+ flushWorkReports,
2549
455
  teardown,
2550
- exit: (code) => process.exit(code),
2551
- log: { warn, note },
2552
- });
2553
- return;
2554
- }
2555
- /**
2556
- * REMOVED FROM THE PROJECT IN THE APP — the second way this box stops being
2557
- * this project's machine, and it goes through the same door for the same
2558
- * reason (2026-09-21).
2559
- *
2560
- * IMMEDIATELY AFTER THE DISPLACEMENT, AND BEFORE THE VERSION SIGNAL, which
2561
- * is the ordering the stop and the displacement above both document:
2562
- * `handleVersionSignal` can RE-EXEC this process, and a box that was just
2563
- * removed coming back up wearing a newer version is the one outcome nobody
2564
- * asked for. The two removal-shaped signals sit together so a reader cannot
2565
- * find one without the other.
2566
- *
2567
- * THE KEY'S PRESENCE IS THE COMMAND, exactly as it is for a stop and a
2568
- * displacement: the server sends it only when it names THIS box and only
2569
- * inside its own window, so there is no TTL to re-evaluate here and no way
2570
- * for a relaunch to obey a removal aimed at somebody else.
2571
- *
2572
- * The choreography is identical — settle every in-flight turn, flush the
2573
- * queued reports, tear down the detached children, keep the worktrees,
2574
- * exit 0 — and only the sentences differ. Exit 0 for the reason every
2575
- * terminal path here states: this was ASKED FOR, so under
2576
- * `Restart=on-failure` a nonzero code would relaunch the daemon straight
2577
- * into being removed again.
2578
- */
2579
- if (roster.standDown && typeof roster.standDown === 'object' && !Array.isArray(roster.standDown)) {
2580
- await standDownDisplaced({
2581
- kind: 'removed',
2582
- // The project's NAME rather than the box that took over, because no box
2583
- // took over. Falls back to "this project" inside — an unnamed project
2584
- // says so rather than being guessed at.
2585
- project: safeName(roster.standDown.project) ?? safeName(roster.project?.name) ?? undefined,
2586
- settleAgentTurns,
2587
- flushReports: flushWorkReports,
2588
- teardown,
2589
- exit: (code) => process.exit(code),
2590
- log: { warn, note },
2591
- });
456
+ exit: (code) => leave(code),
457
+ })
458
+ )
2592
459
  return;
2593
- }
460
+ if (standingDown()) return leave.drained();
2594
461
  /**
2595
462
  * WHOSE MACHINE THIS IS. Absent = a server that does not arbitrate, and then
2596
463
  * this is a no-op and the daemon behaves exactly as 0.83.0 did.
@@ -2607,20 +474,21 @@ export async function runFleetDaemon({ afterLock = null } = {}) {
2607
474
  });
2608
475
  if (holderState === 'mine' && !wasServing) emitMachineEvent({ event: 'serving' });
2609
476
  wasServing = holderState === 'mine';
2610
- // Keep the daemon current. Safe = no worker mid-task (true at startup, since
2611
- // no workers are spawned yet). If it self-updates it re-execs into the new
2612
- // version and this process becomes a proxy — stop the loop.
477
+ // Keep the daemon current. Safe = nothing mid-work (`machineBusy`). If it
478
+ // self-updates it re-execs into the new version and this process becomes a
479
+ // proxy — stop the loop.
2613
480
  if (roster.daemon) {
2614
- // "No worker mid-task" must include the wiki runner: updating mid-sweep
481
+ // "Nothing mid-work" must include the wiki runner: updating mid-sweep
2615
482
  // re-execs the daemon, orphans the wiki Claude, and the fresh process
2616
483
  // starts a second sweep racing it on the same vault. And it must include
2617
- // SESSION work (workBusy — turns, ships, undelivered settle reports):
2618
- // dispatch workers' children say nothing about the Workbench tabs, and a
484
+ // SESSION work (workBusy — turns, ships, undelivered settle reports): a
2619
485
  // re-exec mid-turn SIGTERMs the tab's CLI and settles a partial answer.
2620
- const safeToUpdate =
2621
- !wikiBusy &&
2622
- !workBusy() &&
2623
- [...workers.values()].every((w) => w.state.child == null);
486
+ //
487
+ // ONE ANSWER to "is this machine working?" (`machineBusy`), the
488
+ // same one the desktop app's auto-install reads — which is how a deploy
489
+ // in flight joined this gate (2026-09-26): re-exec'ing under one would
490
+ // stop its heartbeat mid-run.
491
+ const safeToUpdate = !machineBusy();
2624
492
  const updating = handleVersionSignal({
2625
493
  latest: roster.daemon.latest,
2626
494
  min: roster.daemon.min,
@@ -2629,12 +497,12 @@ export async function runFleetDaemon({ afterLock = null } = {}) {
2629
497
  teardown,
2630
498
  });
2631
499
  if (await updating) return;
500
+ if (standingDown()) return leave.drained();
2632
501
  }
2633
502
  // Settle any turn/ship answers whose earlier report POST failed BEFORE
2634
503
  // taking new work — the skip-if-pending guards make the ordering safe, but
2635
504
  // delivering first keeps the tab honest a poll sooner.
2636
505
  void flushWorkReports();
2637
- processMergeJobs(roster.mergeJobs);
2638
506
  processPatchRevertJobs(roster.patchRevertJobs);
2639
507
  // WHERE each live tab works, BEFORE anything measures one: the sweep below
2640
508
  // and every preview check ask `placeOf`, and a tab nobody has typed into
@@ -2747,10 +615,8 @@ export async function runFleetDaemon({ afterLock = null } = {}) {
2747
615
  // gets measured too, without needing a restart to earn its own menu.
2748
616
  probeSkillsOnce(repoRoot);
2749
617
  processCleanupJobs(roster.cleanupJobs);
2750
- const rosterIds = new Set(roster.agents.map((a) => a.agentId));
2751
-
2752
- // Announce roster size only when it changes (not every poll).
2753
- const sig = [...rosterIds].sort().join(',');
618
+ // Announce roster membership only when it changes (not every poll).
619
+ const sig = [...new Set(roster.agents.map((a) => a.agentId))].sort().join(',');
2754
620
  if (sig !== rosterSig) {
2755
621
  rosterSig = sig;
2756
622
  // `agents` is permanently [] — the lanes it counted died with dispatch
@@ -2765,10 +631,10 @@ export async function runFleetDaemon({ afterLock = null } = {}) {
2765
631
  info('Machine online. Open a tab in Flowviant → Workbench to start working.');
2766
632
  }
2767
633
  // Heartbeat so a quiet daemon visibly stays alive. Gated on REAL work —
2768
- // `rosterIds` is built from `roster.agents`, which the server sends
2769
- // permanently empty, so gating on it printed "waiting" once a minute even
2770
- // while a tab's turn was running. `workBusy()` is the honest question: are
2771
- // there session turns, ships or unsettled reports in flight?
634
+ // it was once gated on `roster.agents`, which the server sends permanently
635
+ // empty, and printed "waiting" once a minute even while a tab's turn was
636
+ // running. `workBusy()` is the honest question: are there session turns,
637
+ // ships or unsettled reports in flight?
2772
638
  if (!workBusy() && Date.now() - idleBeatAt > 60_000) {
2773
639
  idleBeatAt = Date.now();
2774
640
  // `inactive`, the owner's own word for this state since 2026-09-21 — the
@@ -2783,18 +649,7 @@ export async function runFleetDaemon({ afterLock = null } = {}) {
2783
649
  );
2784
650
  }
2785
651
 
2786
- // Living-wiki work (runs under its own minted wiki token — no agent
2787
- // needed). enqueueSweep queues a Regenerate; regroundJobs re-offers merged
2788
- // deliveries whose re-ground never ran (e.g. we restarted between merge and
2789
- // turn) until we report reground-done; the bare drain flushes anything
2790
- // whose earlier mint failed.
2791
- enqueueSweep(roster.codeMapJob);
2792
- for (const j of roster.regroundJobs ?? []) {
2793
- const rid = j && (j.taskId ?? j.intentId); // new name first, old as fallback
2794
- if (!j || typeof rid !== 'string') continue; // a null element would throw + wedge the loop
2795
- enqueueReground(rid, j.prUrl, j.title, j.dirtiesPages, j.shas);
2796
- }
2797
- void drainWiki();
652
+ wiki.onRoster(roster);
2798
653
 
2799
654
  // WHAT IS IN THIS BOX'S ENV, by name, on its own 60s beat. Never awaited,
2800
655
  // throttled and deduped inside, and silent forever on an older server.
@@ -2802,35 +657,8 @@ export async function runFleetDaemon({ afterLock = null } = {}) {
2802
657
  void maybeReportEnv(repoRoot);
2803
658
  void maybeReportTools(repoRoot, getBaseRef());
2804
659
 
2805
- // Tell the app what this machine is doing with itself. Every reconcile,
2806
- // best-effort, and never awaited — telemetry that can delay a dispatch is
2807
- // worse than no telemetry.
2808
- //
2809
- // The one thing Flowviant could never answer about a task was "why is it
2810
- // slow", because the box was somebody's laptop and only they could look at
2811
- // it. Centralising is supposed to make one machine easier to manage than N
2812
- // laptops; that is only true if the machine is visible. Per-task RSS is the
2813
- // load-bearing part — "the box is full" is not actionable, "this task is
2814
- // holding 9GB" is.
2815
- //
2816
- // …AND IT HAD NEVER BEEN POPULATED ONCE (fixed 2026-09-14). This read the
2817
- // dispatch-era `workers` map, which nothing has `.set()` since the lane was
2818
- // deleted on 2026-08-19 — so the list was permanently empty, the server
2819
- // stored an empty array every poll, and the column its own handler calls
2820
- // the load-bearing half of this report was blank on every machine that has
2821
- // ever run. The same shape as the env rotation two blocks up, which
2822
- // iterated the same dead map and reached no worktree at all.
2823
- //
2824
- // `liveTurns()` is the live registry: every CLI child this daemon is
2825
- // holding, with the session or agent id it serves. Bounded inside the
2826
- // snapshot, because each row costs a /proc tree walk.
2827
- void reportMergeOutcome(
2828
- MACHINE_URL,
2829
- machineSnapshot({
2830
- worktreeDir: baseDir,
2831
- tasks: liveTurns().map((t) => ({ intentId: t.id, pid: t.pid })),
2832
- })
2833
- );
660
+ // Tell the app what this machine is doing with itself (fleetReports.mjs).
661
+ reportMachine({ worktreeDir: baseDir, liveTurns });
2834
662
 
2835
663
  // Deploy: a daemon on a project with deploy ALLOWED reports its
2836
664
  // .flowviant/deploy.json and runs queued deploy jobs (the server only sends
@@ -2854,37 +682,9 @@ export async function runFleetDaemon({ afterLock = null } = {}) {
2854
682
  processDeployJobs(roster.deployJobs, { repoRoot, baseRef: getBaseRef(), myPubB64 });
2855
683
  }
2856
684
 
2857
- // Stop workers whose agent left the roster (removed in the app).
2858
- for (const [id, w] of [...workers]) {
2859
- if (!rosterIds.has(id)) {
2860
- warn(`${w.label} removed — stopping it now.`);
2861
- w.state.alive = false;
2862
- // Immediate teardown (Q6=B): kill the in-flight Claude process now; its
2863
- // task was already requeued server-side on removal.
2864
- try {
2865
- w.state.child?.kill('SIGKILL');
2866
- } catch {
2867
- /* best-effort */
2868
- }
2869
- // Only poll mode's per-lane tree dies with the lane. A live lane owns no
2870
- // checkout: the task it was building has its own, which must SURVIVE —
2871
- // removing a lane requeues its task, and the next lane to pick that task
2872
- // up resumes in that same directory rather than starting over.
2873
- if (w.wt) {
2874
- try {
2875
- git(['worktree', 'remove', '--force', w.wt], repoRoot);
2876
- } catch {
2877
- /* best-effort */
2878
- }
2879
- }
2880
- workers.delete(id);
2881
- tokenByAgent.delete(id);
2882
- hasWorkByAgent.delete(id);
2883
- nextByAgent.delete(id);
2884
- mintedAt.delete(id); // was leaked on removal (finding 14)
2885
- }
2886
- }
2887
-
685
+ // THE LOCAL BUSY MEASUREMENT, after this tick's intake so a turn it just
686
+ // started is already counted (see `machineBusy`).
687
+ reportBusy();
2888
688
  // Idle until the next poll deadline OR a push wake — whichever comes first.
2889
689
  await waitReconcile();
2890
690
  }