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.
- package/bin/cli.mjs +87 -474
- package/bin/lib/admission.mjs +19 -3
- package/bin/lib/agentPlan.mjs +2 -2
- package/bin/lib/agentTaskKinds.mjs +93 -0
- package/bin/lib/agySessions.mjs +158 -0
- package/bin/lib/artifactPolicy.mjs +44 -0
- package/bin/lib/artifactPreview.mjs +52 -0
- package/bin/lib/artifactReporter.mjs +163 -0
- package/bin/lib/artifacts.mjs +47 -227
- package/bin/lib/authReject.mjs +16 -4
- package/bin/lib/authproxy.mjs +47 -304
- package/bin/lib/boxIdentity.mjs +245 -0
- package/bin/lib/childEnv.mjs +11 -9
- package/bin/lib/claude.mjs +14 -851
- package/bin/lib/claudeAuth.mjs +3 -3
- package/bin/lib/claudePosture.mjs +367 -0
- package/bin/lib/claudeSessions.mjs +411 -0
- package/bin/lib/claudeStream.mjs +207 -0
- package/bin/lib/cliLimit.mjs +34 -0
- package/bin/lib/commandSpecs.mjs +173 -0
- package/bin/lib/config.mjs +11 -42
- package/bin/lib/credentialDate.mjs +22 -0
- package/bin/lib/credentials.mjs +21 -26
- package/bin/lib/daemonLogging.mjs +55 -0
- package/bin/lib/daemonState.mjs +64 -0
- package/bin/lib/deploy.mjs +148 -647
- package/bin/lib/deployCheckout.mjs +265 -0
- package/bin/lib/deployConfig.mjs +142 -0
- package/bin/lib/deployRunner.mjs +311 -0
- package/bin/lib/deployWire.mjs +41 -0
- package/bin/lib/desktopContract.mjs +56 -105
- package/bin/lib/env.mjs +49 -484
- package/bin/lib/fleet.mjs +183 -2383
- package/bin/lib/fleetJobs.mjs +120 -0
- package/bin/lib/fleetPost.mjs +40 -0
- package/bin/lib/fleetReports.mjs +392 -0
- package/bin/lib/fleetRoster.mjs +470 -0
- package/bin/lib/fleetStartup.mjs +196 -0
- package/bin/lib/fleetWake.mjs +45 -0
- package/bin/lib/fleetWire.mjs +46 -0
- package/bin/lib/git.mjs +2 -2
- package/bin/lib/gitMergeIdentity.mjs +51 -0
- package/bin/lib/grant.mjs +5 -3
- package/bin/lib/help.mjs +3 -125
- package/bin/lib/holder.mjs +482 -0
- package/bin/lib/instance.mjs +124 -46
- package/bin/lib/knowledge.mjs +16 -799
- package/bin/lib/knowledgeFetch.mjs +38 -0
- package/bin/lib/knowledgeFileSync.mjs +112 -0
- package/bin/lib/knowledgeFiles.mjs +487 -0
- package/bin/lib/knowledgeLibrary.mjs +249 -0
- package/bin/lib/landed.mjs +2 -1
- package/bin/lib/listeners.mjs +99 -141
- package/bin/lib/localSessions.mjs +30 -555
- package/bin/lib/login.mjs +3 -2
- package/bin/lib/loginPath.mjs +3 -5
- package/bin/lib/machineDisconnect.mjs +203 -0
- package/bin/lib/machineEnv.mjs +22 -0
- package/bin/lib/machineLimits.mjs +61 -0
- package/bin/lib/machineListing.mjs +338 -0
- package/bin/lib/machines.mjs +78 -455
- package/bin/lib/machinesCommand.mjs +214 -0
- package/bin/lib/mcp-cli.mjs +2 -1
- package/bin/lib/patchRevert.mjs +60 -0
- package/bin/lib/prWorkflow.mjs +157 -0
- package/bin/lib/preflight.mjs +2 -1
- package/bin/lib/pressurePolicy.mjs +104 -0
- package/bin/lib/preview.mjs +2 -272
- package/bin/lib/previewFramePolicy.mjs +112 -0
- package/bin/lib/previewGatePolicy.mjs +236 -0
- package/bin/lib/previewRegistry.mjs +235 -0
- package/bin/lib/printable.mjs +2 -2
- package/bin/lib/procRegistry.mjs +28 -18
- package/bin/lib/procScan.mjs +146 -0
- package/bin/lib/processes.mjs +29 -37
- package/bin/lib/projectToolRuntimes.mjs +138 -0
- package/bin/lib/projectTools.mjs +13 -88
- package/bin/lib/prompts.mjs +28 -42
- package/bin/lib/repoPath.mjs +26 -0
- package/bin/lib/resources.mjs +7 -69
- package/bin/lib/resumeLost.mjs +107 -0
- package/bin/lib/runTurn.mjs +324 -0
- package/bin/lib/runtimeAntigravity.mjs +230 -0
- package/bin/lib/runtimeCapabilities.mjs +334 -0
- package/bin/lib/runtimeClaude.mjs +126 -0
- package/bin/lib/runtimeCodex.mjs +217 -0
- package/bin/lib/runtimeDetection.mjs +121 -0
- package/bin/lib/runtimeEvents.mjs +482 -0
- package/bin/lib/runtimeOfferings.mjs +4 -1
- package/bin/lib/runtimes.mjs +42 -1402
- package/bin/lib/safeFileName.mjs +74 -0
- package/bin/lib/standDownExit.mjs +181 -0
- package/bin/lib/standDownGate.mjs +48 -0
- package/bin/lib/startProject.mjs +279 -0
- package/bin/lib/trace.mjs +3 -3
- package/bin/lib/turnLock.mjs +68 -0
- package/bin/lib/turnProfile.mjs +77 -0
- package/bin/lib/uninstall.mjs +55 -45
- package/bin/lib/uninstallCopies.mjs +112 -0
- package/bin/lib/update.mjs +30 -17
- package/bin/lib/uplinkScrub.mjs +265 -0
- package/bin/lib/vault.mjs +22 -151
- package/bin/lib/vaultDiff.mjs +183 -0
- package/bin/lib/versionOrder.mjs +42 -0
- package/bin/lib/views.mjs +17 -17
- package/bin/lib/wikiRunner.mjs +630 -0
- package/bin/lib/work.mjs +188 -3034
- package/bin/lib/workAdoptCarry.mjs +94 -0
- package/bin/lib/workAgentCheck.mjs +278 -0
- package/bin/lib/workAgentMerges.mjs +54 -142
- package/bin/lib/workAgentPlans.mjs +14 -12
- package/bin/lib/workAgentPrecheck.mjs +393 -0
- package/bin/lib/workAgentPublish.mjs +177 -0
- package/bin/lib/workAgentReview.mjs +8 -666
- package/bin/lib/workAgentTurnBegun.mjs +109 -0
- package/bin/lib/workAgentTurnExecution.mjs +604 -0
- package/bin/lib/workAgentTurnOutcome.mjs +207 -0
- package/bin/lib/workAgentTurnReports.mjs +189 -0
- package/bin/lib/workAgentTurns.mjs +91 -898
- package/bin/lib/workAttachments.mjs +93 -0
- package/bin/lib/workBrain.mjs +53 -0
- package/bin/lib/workChildren.mjs +110 -0
- package/bin/lib/workCommandAudit.mjs +62 -0
- package/bin/lib/workDiffs.mjs +2 -1
- package/bin/lib/workNarration.mjs +152 -0
- package/bin/lib/workPlaceReports.mjs +426 -0
- package/bin/lib/workPlaces.mjs +228 -0
- package/bin/lib/workPreviews.mjs +31 -9
- package/bin/lib/workProcesses.mjs +4 -5
- package/bin/lib/workPullRequests.mjs +52 -126
- package/bin/lib/workReportQueue.mjs +254 -0
- package/bin/lib/workRetire.mjs +185 -0
- package/bin/lib/workSessionGroups.mjs +166 -0
- package/bin/lib/workSessionRuntime.mjs +164 -0
- package/bin/lib/workSessionTokens.mjs +68 -0
- package/bin/lib/workSessionTurns.mjs +892 -0
- package/bin/lib/workShip.mjs +9 -29
- package/bin/lib/workToolLog.mjs +76 -0
- package/package.json +3 -2
- package/bin/lib/patch.mjs +0 -363
package/bin/lib/fleet.mjs
CHANGED
|
@@ -1,21 +1,23 @@
|
|
|
1
1
|
/**
|
|
2
|
-
*
|
|
3
|
-
*
|
|
4
|
-
*
|
|
5
|
-
*
|
|
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
|
|
41
|
-
import {
|
|
42
|
-
import {
|
|
43
|
-
import { emitMachineEvent
|
|
44
|
-
import {
|
|
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 {
|
|
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 {
|
|
63
|
-
import {
|
|
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
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
} from './
|
|
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
|
|
80
|
-
import {
|
|
81
|
-
import {
|
|
82
|
-
import {
|
|
83
|
-
import {
|
|
84
|
-
import {
|
|
85
|
-
import {
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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 —
|
|
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
|
|
1614
|
-
* machine's ceiling would be a ceiling with
|
|
1615
|
-
* a large repo is one of the heaviest turns
|
|
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
|
-
* `
|
|
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: () => (
|
|
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
|
-
|
|
310
|
+
repoStateChanged();
|
|
1653
311
|
},
|
|
1654
312
|
});
|
|
1655
313
|
workShutdown = shutdownWork; // teardown can now reach the live session CLIs
|
|
1656
314
|
|
|
1657
|
-
const
|
|
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
|
-
*
|
|
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
|
-
*
|
|
1897
|
-
*
|
|
1898
|
-
*
|
|
1899
|
-
*
|
|
1900
|
-
*
|
|
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
|
|
1903
|
-
|
|
1904
|
-
|
|
1905
|
-
|
|
1906
|
-
|
|
1907
|
-
|
|
1908
|
-
|
|
1909
|
-
|
|
1910
|
-
|
|
1911
|
-
|
|
1912
|
-
|
|
1913
|
-
|
|
1914
|
-
|
|
1915
|
-
|
|
1916
|
-
|
|
1917
|
-
|
|
1918
|
-
|
|
1919
|
-
|
|
1920
|
-
|
|
1921
|
-
|
|
1922
|
-
|
|
1923
|
-
|
|
1924
|
-
|
|
1925
|
-
|
|
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
|
|
2348
|
-
//
|
|
2349
|
-
|
|
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
|
-
//
|
|
2380
|
-
//
|
|
2381
|
-
//
|
|
2382
|
-
//
|
|
2383
|
-
|
|
2384
|
-
|
|
2385
|
-
|
|
2386
|
-
|
|
2387
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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)
|
|
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
|
-
//
|
|
2492
|
-
//
|
|
2493
|
-
//
|
|
2494
|
-
// process
|
|
2495
|
-
//
|
|
2496
|
-
//
|
|
2497
|
-
|
|
2498
|
-
|
|
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
|
-
|
|
454
|
+
flushWorkReports,
|
|
2549
455
|
teardown,
|
|
2550
|
-
exit: (code) =>
|
|
2551
|
-
|
|
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 =
|
|
2611
|
-
//
|
|
2612
|
-
//
|
|
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
|
-
// "
|
|
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
|
-
|
|
2621
|
-
|
|
2622
|
-
|
|
2623
|
-
|
|
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
|
-
|
|
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
|
-
//
|
|
2769
|
-
//
|
|
2770
|
-
//
|
|
2771
|
-
//
|
|
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
|
-
|
|
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.
|
|
2806
|
-
|
|
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
|
-
//
|
|
2858
|
-
|
|
2859
|
-
|
|
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
|
}
|