flowviant 0.102.2 → 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 +184 -2396
- 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 +34 -3
- 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/shipMerge.mjs +33 -2
- 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 +35 -12
- package/bin/lib/wikiRunner.mjs +630 -0
- package/bin/lib/work.mjs +188 -3037
- 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 +92 -897
- 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
|
@@ -0,0 +1,470 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* THE ROSTER POLL — what this box tells the server on every beat, and what the
|
|
3
|
+
* first answered poll tells the person at the keyboard.
|
|
4
|
+
*
|
|
5
|
+
* Split out of fleet.mjs (SOLID audit 2026-09-26, F038). `fetchRoster` builds
|
|
6
|
+
* the poll's query string (the box's identity, its CLIs, the admission verdict,
|
|
7
|
+
* the one machine ask) and validates the answer's shape; the loop in fleet.mjs
|
|
8
|
+
* decides what to DO with the answer. They change for different reasons — a
|
|
9
|
+
* new daemon→server param lands here and nowhere else, a new roster key lands
|
|
10
|
+
* in the loop — so they live apart.
|
|
11
|
+
*
|
|
12
|
+
* Every param here is a DAEMON→SERVER report and needs no floor (the report's
|
|
13
|
+
* presence is the capability); absence keeps meaning "an older daemon".
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
import {
|
|
17
|
+
VERSION,
|
|
18
|
+
FLEET_URL,
|
|
19
|
+
FLEET_TOKEN,
|
|
20
|
+
USER_AGENT,
|
|
21
|
+
SAFE,
|
|
22
|
+
DAEMON_INSTANCE,
|
|
23
|
+
MACHINE_HOST,
|
|
24
|
+
PROCESS_STARTED_AT,
|
|
25
|
+
MAX_CONCURRENT,
|
|
26
|
+
} from './config.mjs';
|
|
27
|
+
import { projectLabel, safeName, setStoredProjectName } from './credentials.mjs';
|
|
28
|
+
import { credentialRejected } from './authReject.mjs';
|
|
29
|
+
import { isSafePathSegment } from './git.mjs';
|
|
30
|
+
import { c, note, warn } from './ui.mjs';
|
|
31
|
+
import { envQueryParams, myPubB64 } from './boxIdentity.mjs';
|
|
32
|
+
import { detectRuntimes, runtimesReport } from './runtimeDetection.mjs';
|
|
33
|
+
import { knownMcpServers, knownSkills } from './runtimeCapabilities.mjs';
|
|
34
|
+
import { effectiveMaxTurns } from './admission.mjs';
|
|
35
|
+
import { runtimeOfferings } from './runtimeOfferings.mjs';
|
|
36
|
+
|
|
37
|
+
let warnedEnvIdentity = false;
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* HAS THIS PROCESS ALREADY ASKED FOR THE MACHINE? (0.91.0)
|
|
41
|
+
*
|
|
42
|
+
* The owner's ruling on two boxes running one project: "it should kill the
|
|
43
|
+
* first one and take over". So a daemon asks on the FIRST poll of its life and
|
|
44
|
+
* never again, and this pair is the whole of that "never again".
|
|
45
|
+
*
|
|
46
|
+
* ONE ASK PER PROCESS IS THE SAFETY, NOT A POLITENESS. A param that kept asking
|
|
47
|
+
* would be two boxes trading a machine back and forth every ten seconds. Asked
|
|
48
|
+
* once, the story ends at the first exchange, because the box that loses
|
|
49
|
+
* SETTLES ITS TURNS AND EXITS rather than restarting — and a process that has
|
|
50
|
+
* exited sends no more first polls.
|
|
51
|
+
*
|
|
52
|
+
* THAT LAST SENTENCE IS TRUE OF THE PROCESS AND NOT OF THE UNIT, which is why
|
|
53
|
+
* the server stopped relying on it (2026-09-19). Under `Restart=always`, pm2 or
|
|
54
|
+
* docker, exit 0 is a relaunch and the relaunch's first poll is a genuine one.
|
|
55
|
+
* `takeMachineHolderNow` refuses a take by the box it just displaced for the
|
|
56
|
+
* length of the displaced window, so the ping-pong is bounded on the side that
|
|
57
|
+
* can see both boxes. This module still keeps its half of the bargain.
|
|
58
|
+
*
|
|
59
|
+
* SPENT ON A DELIVERED POLL, NOT ON AN ATTEMPTED ONE, which is why this is two
|
|
60
|
+
* functions instead of one consuming read. A daemon that starts while the wire
|
|
61
|
+
* is down retries every ten seconds; spending the ask on the failed attempt
|
|
62
|
+
* would mean the box you walked over to and started never takes the machine,
|
|
63
|
+
* and nothing anywhere would say why.
|
|
64
|
+
*
|
|
65
|
+
* IT IS NOT THE DELETED FLAG COMING BACK. There is still nothing to type: the
|
|
66
|
+
* terminal surface is `npx flowviant` and `npx flowviant login`, and what
|
|
67
|
+
* claims here is starting the daemon, which is already one of those two.
|
|
68
|
+
*
|
|
69
|
+
* Exported so both properties can be proved without a server, a credential or a
|
|
70
|
+
* second box — they are the contract, and nothing else in the file states them.
|
|
71
|
+
*/
|
|
72
|
+
/**
|
|
73
|
+
* AND A RESTART IS NOT A PERSON (2026-09-19, the review).
|
|
74
|
+
*
|
|
75
|
+
* `update.mjs` re-execs the daemon with `FLOWVIANT_REEXEC='1'` after an
|
|
76
|
+
* UNATTENDED auto-update, which is on by default. Born `false`, the new process
|
|
77
|
+
* would then spend a fresh ask and TAKE THE PROJECT'S MACHINE — so a standby
|
|
78
|
+
* sitting quietly on somebody's second computer would seize the machine off the
|
|
79
|
+
* live holder in the middle of the night, settling its running turns as moved,
|
|
80
|
+
* because npm published a patch. Nobody was at either keyboard, and nothing on
|
|
81
|
+
* any surface would say why.
|
|
82
|
+
*
|
|
83
|
+
* The ask belongs to the GESTURE, not to the process: what claims a machine is
|
|
84
|
+
* a person typing `npx flowviant`, and a re-exec is the same start continuing.
|
|
85
|
+
* So a re-executed daemon is born with the ask already spent and behaves
|
|
86
|
+
* exactly as it did before the update — it keeps the machine if it had it (arm
|
|
87
|
+
* (a) stamps it), and stands by if it did not.
|
|
88
|
+
*/
|
|
89
|
+
let machineAskSpent = process.env.FLOWVIANT_REEXEC === '1';
|
|
90
|
+
export function machineAskPending() {
|
|
91
|
+
return !machineAskSpent;
|
|
92
|
+
}
|
|
93
|
+
export function spendMachineAsk() {
|
|
94
|
+
machineAskSpent = true;
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
export async function fetchRoster(
|
|
99
|
+
livePreviewSessionIds = [],
|
|
100
|
+
heldSessionIds = [],
|
|
101
|
+
/** The churn ADMISSION verdict, taken by the caller from the same `admit`
|
|
102
|
+
* every unattended lane asks — see the `pr` param below for why it is the
|
|
103
|
+
* admission and not the pressure reading alone. Undefined where the caller
|
|
104
|
+
* has no admission to offer, which reads exactly like an older daemon. */
|
|
105
|
+
churnHold = undefined,
|
|
106
|
+
/** The checkout this daemon serves, for the `cp` report below. Passed in
|
|
107
|
+
* rather than re-derived: `repoRootOrDie` is resolved once at startup and
|
|
108
|
+
* running git on every poll to re-learn a constant would be a syscall for
|
|
109
|
+
* a readout. */
|
|
110
|
+
repoRoot = null,
|
|
111
|
+
baseRef = null
|
|
112
|
+
) {
|
|
113
|
+
const url = new URL(FLEET_URL);
|
|
114
|
+
// No `have`: it named the dispatch-era lanes' held tokens, and nothing has
|
|
115
|
+
// held one since 2026-08-19 — the param was built empty, so never sent.
|
|
116
|
+
// What this machine will run at once. The server grows lanes to meet waiting
|
|
117
|
+
// work beneath this, instead of the user pre-sizing a pool by hand — only the
|
|
118
|
+
// machine knows its cores, its RAM and whose Claude quota is being spent.
|
|
119
|
+
// Older servers ignore the param, so sending it is always safe.
|
|
120
|
+
url.searchParams.set('capacity', String(MAX_CONCURRENT));
|
|
121
|
+
// WHICH DAEMON this machine runs, so the server can gate version-dependent
|
|
122
|
+
// work — codex Workbench tabs are only created for machines whose daemon can
|
|
123
|
+
// actually serve them (dv >= 0.46.0). The same source the self-update check
|
|
124
|
+
// compares against the roster's daemon.latest (config.mjs VERSION, read off
|
|
125
|
+
// our own package.json). Older servers ignore unknown params, so sending it
|
|
126
|
+
// unconditionally is always safe.
|
|
127
|
+
url.searchParams.set('dv', VERSION);
|
|
128
|
+
// The permission posture this machine runs turns under — '1' when
|
|
129
|
+
// FLOWVIANT_SAFE narrows the toolset, '0' when everything is granted. A
|
|
130
|
+
// statement of configuration, not a request: the app SHOWS it in Settings
|
|
131
|
+
// so a team can see whether the shared box runs wide open, and enforces
|
|
132
|
+
// nothing (membership is the consent boundary). Older servers ignore it.
|
|
133
|
+
url.searchParams.set('safe', SAFE ? '1' : '0');
|
|
134
|
+
// WHICH PROCESS, so the server can lease preview work to exactly one of two
|
|
135
|
+
// daemons on one credential. Older servers ignore unknown params.
|
|
136
|
+
url.searchParams.set('di', DAEMON_INSTANCE);
|
|
137
|
+
// WHICH BOX, by name, so the app can say "your machine is mac-mini" instead
|
|
138
|
+
// of naming a public key. Display only: arbitration is keyed on `envpub`,
|
|
139
|
+
// which is durable per box, while a hostname is neither unique nor stable.
|
|
140
|
+
// Absent when the host has no readable name — an older daemon looks the same,
|
|
141
|
+
// and both mean "nobody said", which is what the nameless fallback renders.
|
|
142
|
+
if (MACHINE_HOST) url.searchParams.set('mh', MACHINE_HOST);
|
|
143
|
+
/**
|
|
144
|
+
* WHICH CHECKOUT, WHICH PROCESS, AND SINCE WHEN (0.91.0) — the three facts
|
|
145
|
+
* that turn "a box polled" into a row somebody can act on.
|
|
146
|
+
*
|
|
147
|
+
* The owner could not answer a plain question about his own machines: "im not
|
|
148
|
+
* sure if i have any duplicate or redundant daemons running". The hostname
|
|
149
|
+
* alone cannot answer it either, because ONE box legitimately runs several
|
|
150
|
+
* daemons — "i can have 1 daemon in one directory, 1 in another as a
|
|
151
|
+
* differentiator of projects" — so `cp` is what makes two rows on one box
|
|
152
|
+
* legible, and `pid` + `st` are what let somebody standing at that box find
|
|
153
|
+
* the process and tell a long-lived daemon from one that has restarted nine
|
|
154
|
+
* times since they last looked.
|
|
155
|
+
*
|
|
156
|
+
* DAEMON→SERVER REPORTS, so no floor: an older daemon sends none of these and
|
|
157
|
+
* the server stores null, which reads as "it did not say" rather than as a
|
|
158
|
+
* box with no checkout. Bounded here as well as at the boundary — a query
|
|
159
|
+
* string is not a log, and this one is rendered into a terminal.
|
|
160
|
+
*/
|
|
161
|
+
if (repoRoot) url.searchParams.set('cp', String(repoRoot).slice(0, 256));
|
|
162
|
+
url.searchParams.set('pid', String(process.pid));
|
|
163
|
+
url.searchParams.set('st', PROCESS_STARTED_AT);
|
|
164
|
+
/**
|
|
165
|
+
* THE FIRST POLL OF THIS PROCESS ASKS FOR THE MACHINE (`claim`, 0.91.0).
|
|
166
|
+
*
|
|
167
|
+
* The owner, on two boxes running one project: "it should kill the first one
|
|
168
|
+
* and take over." Starting the daemon on the computer in front of you IS the
|
|
169
|
+
* gesture — there is no flag, no env var and no third command, so the ruling
|
|
170
|
+
* that deleted the old machine-moving flag ("i dont intend to run or do
|
|
171
|
+
* anything in the terminal besides npx flowviant or npx flowviant login")
|
|
172
|
+
* stands untouched.
|
|
173
|
+
*
|
|
174
|
+
* SENT ONCE PER PROCESS, and that is the safety rather than a politeness: see
|
|
175
|
+
* `askForMachineOnce`. The server takes holdership there and then, the box it
|
|
176
|
+
* displaces learns through the existing named-and-windowed `displaced` signal
|
|
177
|
+
* and exits 0, and nothing restarts to ask again.
|
|
178
|
+
*
|
|
179
|
+
* AN OLDER SERVER IGNORES IT and the old standby behaviour stands, which is
|
|
180
|
+
* why this needs no floor of its own — the server applies one (it will not
|
|
181
|
+
* take a machine from a box too old to be told it lost it).
|
|
182
|
+
*/
|
|
183
|
+
if (machineAskPending()) url.searchParams.set('claim', '1');
|
|
184
|
+
// A poll reports what this box IS. Moving the project's machine onto it, when
|
|
185
|
+
// this process has already spent its one ask above, is a gesture a person
|
|
186
|
+
// makes in the app — and the daemon learns that outcome on its next poll like
|
|
187
|
+
// every other holder fact.
|
|
188
|
+
// Which shares this machine is still serving. It rides the poll rather than
|
|
189
|
+
// taking an endpoint of its own: one beat, no floor, and the stale window is
|
|
190
|
+
// the reconcile interval instead of minutes — which matters, because a share
|
|
191
|
+
// the server still calls live is a 530 on somebody's phone. Always set, even
|
|
192
|
+
// empty: '' means "serving none", absent would mean "an older daemon".
|
|
193
|
+
url.searchParams.set('pv', livePreviewSessionIds.join(','));
|
|
194
|
+
// The sessions this daemon holds a worktree for. Its LEASE on each renews
|
|
195
|
+
// here — one beat, no extra endpoint, and the server can tell "this daemon is
|
|
196
|
+
// still serving that tab" from "it went away" within a reconcile interval
|
|
197
|
+
// instead of minutes. Always set, even empty: '' means "holding none".
|
|
198
|
+
url.searchParams.set('ws', heldSessionIds.join(','));
|
|
199
|
+
// WHICH CLIs this machine actually has, so the app can stop guessing.
|
|
200
|
+
//
|
|
201
|
+
// Until now every surface that listed Gemini or Codex said "not wired up yet"
|
|
202
|
+
// and meant it literally: nothing had ever looked. That was the honest answer
|
|
203
|
+
// while it was true, and it stops being honest the moment a second runtime can
|
|
204
|
+
// run — an app that cannot tell "Codex is not installed" from "we never
|
|
205
|
+
// checked" will confidently tell you the wrong one.
|
|
206
|
+
//
|
|
207
|
+
// A statement about this MACHINE and nothing else: no account, no quota, no
|
|
208
|
+
// entitlement. Detection is cached after the first poll (one version probe per
|
|
209
|
+
// CLI), so this costs a query param thereafter. Older servers ignore an
|
|
210
|
+
// unknown param, so sending it is always safe.
|
|
211
|
+
//
|
|
212
|
+
// ALWAYS SENT once detection ran, even EMPTY: '' is "looked and can drive
|
|
213
|
+
// none", which is how an uninstalled last CLI stops being advertised; only a
|
|
214
|
+
// detection that threw leaves the param absent (see `runtimesReport`).
|
|
215
|
+
try {
|
|
216
|
+
url.searchParams.set('runtimes', runtimesReport(detectRuntimes()));
|
|
217
|
+
} catch {
|
|
218
|
+
/* detection is best-effort — a probe must never fail the poll */
|
|
219
|
+
}
|
|
220
|
+
// WHAT THE CLI CAN BE ASKED FOR BY NAME, so the composer can autocomplete a
|
|
221
|
+
// `/` the way the terminal does. Learned from the init event of a turn we
|
|
222
|
+
// already ran (runtimeCapabilities.mjs) — never probed, because spawning a CLI to fill a
|
|
223
|
+
// dropdown would spend the operator's quota on an affordance.
|
|
224
|
+
//
|
|
225
|
+
// NOT SENT until a turn has taught us: absent means "no turn has run here
|
|
226
|
+
// yet", and the app renders no menu rather than asserting this machine has no
|
|
227
|
+
// skills. An empty report, though, IS a fact and is sent as such — hence the
|
|
228
|
+
// null check rather than a truthiness check on the array.
|
|
229
|
+
try {
|
|
230
|
+
const skills = knownSkills();
|
|
231
|
+
if (skills !== null) url.searchParams.set('skills', skills.join(','));
|
|
232
|
+
} catch {
|
|
233
|
+
/* best-effort — the poll must never fail on a readout */
|
|
234
|
+
}
|
|
235
|
+
// WHICH MCP SERVERS AND CONNECTORS THE CLI MOUNTED, AND HOW EACH STANDS
|
|
236
|
+
// (0.97.0) — `[{n, s}]`, learned off the same init event as the skills, this
|
|
237
|
+
// daemon's own `flowviant` server excluded AND every `connected` one with it:
|
|
238
|
+
// only what needs something leaves the box (runtimeCapabilities.mjs, recordMcpServers —
|
|
239
|
+
// a teammate has no business reading which services the operator signed
|
|
240
|
+
// into). The same three states: NOT SENT
|
|
241
|
+
// until a turn (or the one probe) has taught us, `[]` sent as the fact it is.
|
|
242
|
+
// The app names every server that is not connected — a connector that needs
|
|
243
|
+
// a sign-in AT THIS BOX above all — under the serving machine's row. A
|
|
244
|
+
// daemon→server report: an older server ignores the unknown param.
|
|
245
|
+
try {
|
|
246
|
+
const mcp = knownMcpServers();
|
|
247
|
+
if (mcp !== null) url.searchParams.set('mcp', JSON.stringify(mcp));
|
|
248
|
+
} catch {
|
|
249
|
+
/* best-effort — the poll must never fail on a readout */
|
|
250
|
+
}
|
|
251
|
+
if (repoRoot && baseRef) {
|
|
252
|
+
try {
|
|
253
|
+
const offerings = detectRuntimes().some((rt) => rt.id === 'codex' && rt.installed)
|
|
254
|
+
? runtimeOfferings(repoRoot, baseRef) : {};
|
|
255
|
+
for (const [param, report] of Object.entries(offerings)) {
|
|
256
|
+
if (Object.keys(report).length) {
|
|
257
|
+
const payload = JSON.stringify(report);
|
|
258
|
+
if (payload.length <= 8_000 && url.toString().length + encodeURIComponent(payload).length <= 14_000) url.searchParams.set(param, payload);
|
|
259
|
+
}
|
|
260
|
+
}
|
|
261
|
+
} catch { /* local readouts never fail the poll */ }
|
|
262
|
+
}
|
|
263
|
+
// THIS BOX'S IDENTITY — `envpub`, and since 2026-09-21 nothing else.
|
|
264
|
+
//
|
|
265
|
+
// It carried two more params while the secrets vault existed: `envv` (the
|
|
266
|
+
// materialized bundle version) and `envskip` (the target files the
|
|
267
|
+
// materializer refused to write, where the EMPTY STRING was a real report and
|
|
268
|
+
// a truthiness filter here silently dropped it). Both died with the vault and
|
|
269
|
+
// the server no longer reads either, so the loop's `!= null` has nothing left
|
|
270
|
+
// to defend — it stays anyway, because it is the correct general shape for
|
|
271
|
+
// forwarding a param map and re-deriving that lesson is how it was lost the
|
|
272
|
+
// first time.
|
|
273
|
+
//
|
|
274
|
+
// `envpub` ITSELF IS UNCHANGED: same key, same base64, same keypair file. A
|
|
275
|
+
// box upgrading into this release must stay the SAME box to holdership
|
|
276
|
+
// arbitration and to the machine registry.
|
|
277
|
+
try {
|
|
278
|
+
for (const [k, v] of Object.entries(await envQueryParams())) {
|
|
279
|
+
if (v != null) url.searchParams.set(k, v);
|
|
280
|
+
}
|
|
281
|
+
} catch (e) {
|
|
282
|
+
/* env identity is best-effort — the poll must never fail on it */
|
|
283
|
+
/**
|
|
284
|
+
* …BUT IT IS NOT SILENT, because since 2026-09-14 losing it costs
|
|
285
|
+
* something visible. `envpub` is how the server tells two computers apart,
|
|
286
|
+
* so a poll without one is EXEMPT from arbitration — it is served in full,
|
|
287
|
+
* which is right — and nothing stamps `holder_heard_at`, which is the
|
|
288
|
+
* column presence is now read from. The app therefore says the machine is
|
|
289
|
+
* offline while this daemon is sitting here answering turns, and before
|
|
290
|
+
* this line the only evidence anywhere was a keypair file nobody looks at.
|
|
291
|
+
*
|
|
292
|
+
* Once per process: it is the same failure on every poll, and a reason
|
|
293
|
+
* repeated every few seconds is a reason nobody reads.
|
|
294
|
+
*/
|
|
295
|
+
if (!warnedEnvIdentity) {
|
|
296
|
+
warnedEnvIdentity = true;
|
|
297
|
+
console.warn(
|
|
298
|
+
`[flowviant] could not read ${'~/.flowviant/env-keypair.json'} (${e?.message ?? e}).\n` +
|
|
299
|
+
' This machine cannot identify itself, so the app may show it as offline while it works.\n' +
|
|
300
|
+
' It has NOT been replaced — that file is what the project secrets are sealed to.'
|
|
301
|
+
);
|
|
302
|
+
}
|
|
303
|
+
}
|
|
304
|
+
/**
|
|
305
|
+
* WHY THIS MACHINE IS NOT TAKING NEW WORK, in its own measured words.
|
|
306
|
+
*
|
|
307
|
+
* Three states, and the third is why this is a param and not a header:
|
|
308
|
+
* · a reason — the churn ADMISSION refused; new unattended work is being
|
|
309
|
+
* deferred, and the board can say so AT the agent that is
|
|
310
|
+
* waiting instead of leaving it looking like a slow model.
|
|
311
|
+
* · `-` — asked, and nothing is holding anything back. A POSITIVE
|
|
312
|
+
* fact, which is what lets the server clear a stale reason
|
|
313
|
+
* rather than letting one sit there being true-looking
|
|
314
|
+
* forever.
|
|
315
|
+
* · absent — an older daemon, or a poll with no admission to ask. The
|
|
316
|
+
* reserved meaning, and the reason nothing is sent in that
|
|
317
|
+
* case: silence must not be readable as "fine".
|
|
318
|
+
*
|
|
319
|
+
* `FLOWVIANT_NO_PRESSURE_GUARD` no longer silences the param, and should not:
|
|
320
|
+
* it turns off the MEMORY AND LOAD half, and the concurrency half it does not
|
|
321
|
+
* touch is still a true account of why nothing is starting. What the operator
|
|
322
|
+
* asked for is a box that is not second-guessed about its own memory, not an
|
|
323
|
+
* agent that sits still with no explanation.
|
|
324
|
+
*
|
|
325
|
+
* IT IS THE WHOLE ADMISSION, AND IT USED TO BE THE PRESSURE HALF ONLY. The
|
|
326
|
+
* argument for narrowing it was that "four turns are running" is a capacity
|
|
327
|
+
* statement — but the effect was worse than the thing it avoided: a machine
|
|
328
|
+
* refusing every agent turn at its ceiling sent `pr=-`, which says MEASURED
|
|
329
|
+
* AND FINE, so the server cleared any stored reason and the board fell
|
|
330
|
+
* through to "nothing has polled this turn for 12m" over a daemon that was
|
|
331
|
+
* polling every ten seconds and declining on purpose. The machine positively
|
|
332
|
+
* asserted health at the one moment it was refusing.
|
|
333
|
+
*
|
|
334
|
+
* And CLAUDE.md already carves this exact shape out: queueing is said "AT THE
|
|
335
|
+
* THING THAT IS WAITING, in the moment, never budgeted for in advance on a
|
|
336
|
+
* global chip". The relayed sentence is `admission.mjs`'s, and it names
|
|
337
|
+
* ACTIVITY — "the machine is already running 3 CLI turns" — never the
|
|
338
|
+
* ceiling, never headroom, and never anywhere but on the row that is stalled.
|
|
339
|
+
* That is the same shape as a CLI relaying that it hit its own limit.
|
|
340
|
+
*
|
|
341
|
+
* `URLSearchParams` does its own encoding; the slice is the belt against a
|
|
342
|
+
* pathological reason.
|
|
343
|
+
*/
|
|
344
|
+
try {
|
|
345
|
+
if (churnHold !== undefined) {
|
|
346
|
+
url.searchParams.set('pr', churnHold ? String(churnHold.reason).slice(0, 160) : '-');
|
|
347
|
+
/**
|
|
348
|
+
* …AND THE BOUND THE REFUSAL IS ABOUT (2026-09-17, `mt`).
|
|
349
|
+
*
|
|
350
|
+
* THIS AMENDS THE PARAGRAPH DIRECTLY ABOVE, which says the relayed
|
|
351
|
+
* sentence "names ACTIVITY … never the ceiling, never headroom". That
|
|
352
|
+
* rule was written to stop a capacity meter, and it succeeded so
|
|
353
|
+
* completely that a machine which could only ever run ONE turn had no way
|
|
354
|
+
* to say so — the owner, verbatim: "we should have ui indicating this on
|
|
355
|
+
* the board view and a ui indicating or showing the agent as waiting or
|
|
356
|
+
* queued as a result of that resource constraint. thats why i was
|
|
357
|
+
* immediately confused. theres nothing telling me that i could only have
|
|
358
|
+
* one agent on the board."
|
|
359
|
+
*
|
|
360
|
+
* So the ceiling crosses the wire, and the narrow shape that survives is
|
|
361
|
+
* the same carve-out the sentence beside it already lives under: it is
|
|
362
|
+
* shown in SETTINGS (beside the dial that sets it — a control with no
|
|
363
|
+
* readout is not a control) and on the BOARD only while a real agent is
|
|
364
|
+
* actually being deferred. Never a resting chip, and never headroom:
|
|
365
|
+
* nothing anywhere subtracts this from the live count to advertise room.
|
|
366
|
+
*
|
|
367
|
+
* It rides the `pr` gate deliberately — this is the bound that admission
|
|
368
|
+
* measured against, so a poll with no admission to ask has no effective
|
|
369
|
+
* ceiling to report either, and absence keeps meaning "an older daemon".
|
|
370
|
+
* `capacity` above is the DERIVED number and predates the dial; it is a
|
|
371
|
+
* dispatch-era fossil the server ignores, and the two are not the same
|
|
372
|
+
* fact.
|
|
373
|
+
*/
|
|
374
|
+
url.searchParams.set('mt', String(effectiveMaxTurns()));
|
|
375
|
+
}
|
|
376
|
+
} catch {
|
|
377
|
+
/* a readout — the poll must never fail on one */
|
|
378
|
+
}
|
|
379
|
+
// An explicit User-Agent is required: Node's default ("node"/empty) trips
|
|
380
|
+
// Cloudflare Bot Fight Mode (403). A descriptive product UA passes.
|
|
381
|
+
const res = await fetch(url, {
|
|
382
|
+
headers: { Authorization: `Bearer ${FLEET_TOKEN}`, 'User-Agent': USER_AGENT },
|
|
383
|
+
signal: AbortSignal.timeout(30_000), // a black-holed poll must not stall the loop
|
|
384
|
+
});
|
|
385
|
+
if (res.status === 401 || res.status === 403) {
|
|
386
|
+
// Revoked or expired — retrying can't recover, so signal exit. But ONLY on
|
|
387
|
+
// the API's own refusal: an edge 403 (Cloudflare's bot checks, which this
|
|
388
|
+
// very client class trips) is a retryable failure, and exiting on one took
|
|
389
|
+
// a machine offline for good over a credential that was still valid.
|
|
390
|
+
if (await credentialRejected(res)) {
|
|
391
|
+
const e = new Error(`fleet credential rejected (${res.status})`);
|
|
392
|
+
e.auth = true;
|
|
393
|
+
throw e;
|
|
394
|
+
}
|
|
395
|
+
throw new Error(`fleet poll refused by something in front of the API (${res.status})`);
|
|
396
|
+
}
|
|
397
|
+
if (!res.ok) throw new Error(`fleet poll failed (${res.status})`);
|
|
398
|
+
// THE ASK IS SPENT HERE AND NOWHERE ELSE — on a poll the server actually
|
|
399
|
+
// answered. Spending it where the param is SET would lose the claim of a
|
|
400
|
+
// daemon that started while the wire was down: it would retry, silently
|
|
401
|
+
// without `claim`, and the box somebody walked over to and started would
|
|
402
|
+
// never take the machine.
|
|
403
|
+
spendMachineAsk();
|
|
404
|
+
const body = await res.json();
|
|
405
|
+
// Validate the shape here so a malformed 200 (deploy hiccup, error envelope)
|
|
406
|
+
// throws a NORMAL retryable error inside the loop's try/catch, instead of a
|
|
407
|
+
// `roster.agents.map` TypeError escaping to top-level and killing the daemon.
|
|
408
|
+
const data = body?.data;
|
|
409
|
+
if (!data || !Array.isArray(data.agents)) {
|
|
410
|
+
throw new Error('fleet poll returned an unexpected shape');
|
|
411
|
+
}
|
|
412
|
+
// Drop roster agents with an unsafe id BEFORE they're used as a path segment.
|
|
413
|
+
data.agents = data.agents.filter((a) => {
|
|
414
|
+
if (isSafePathSegment(a?.agentId)) return true;
|
|
415
|
+
warn(`ignoring roster agent with an invalid id: ${JSON.stringify(a?.agentId)}`);
|
|
416
|
+
return false;
|
|
417
|
+
});
|
|
418
|
+
return data; // { mcpUrl, leaseTtlSeconds, agents: [] (a wire fossil), …the lanes' keys }
|
|
419
|
+
}
|
|
420
|
+
|
|
421
|
+
/**
|
|
422
|
+
* THE FIRST ANSWERED POLL, said to the terminal once — which project this
|
|
423
|
+
* daemon serves, and what else is serving it. The loop calls this exactly once
|
|
424
|
+
* per process, on the first roster that lands.
|
|
425
|
+
*/
|
|
426
|
+
export function announceFirstPoll(roster) {
|
|
427
|
+
// Name the scoped project so a mismatch (this daemon serves project A, but
|
|
428
|
+
// you're viewing project B's wiki) is obvious instead of a silent no-op.
|
|
429
|
+
if (roster.project) {
|
|
430
|
+
note(
|
|
431
|
+
`${c.cyan('project')} · ${c.bold(projectLabel({ name: roster.project.name, projectId: roster.project.id }))} ${c.dim(`(${safeName(roster.project.id) ?? ''})`)}`
|
|
432
|
+
);
|
|
433
|
+
note(c.dim(' wiki + agents stream to THIS project — view its Code canvas in Flowviant.'));
|
|
434
|
+
// Remember the NAME beside the stored credential, so the picker and
|
|
435
|
+
// `flowviant projects` can say "skadooble" instead of an id — a
|
|
436
|
+
// credential saved before the server sent names backfills here. No-op
|
|
437
|
+
// for a --fleet/env token (nothing stored to annotate).
|
|
438
|
+
setStoredProjectName(roster.project.id, roster.project.name);
|
|
439
|
+
}
|
|
440
|
+
/**
|
|
441
|
+
* WHAT ELSE IS SERVING THIS PROJECT — said ONCE, right after the first
|
|
442
|
+
* poll lands, and never again (0.91.0).
|
|
443
|
+
*
|
|
444
|
+
* The owner could not answer a plain question about his own setup: "im
|
|
445
|
+
* not sure if i have any duplicate or redundant daemons running". This is
|
|
446
|
+
* the half of the answer that belongs in the terminal you just typed
|
|
447
|
+
* into, because that is where you are standing when the question occurs
|
|
448
|
+
* to you — the app's Home is where the whole account's answer lives.
|
|
449
|
+
*
|
|
450
|
+
* A RELAY, and it withholds nothing on failure: an older server has no
|
|
451
|
+
* boxes route and 404s, which prints NOTHING rather than a complaint
|
|
452
|
+
* about a feature nobody asked for. Fire-and-forget and never awaited on
|
|
453
|
+
* the poll path — a listing must never delay a roster tick.
|
|
454
|
+
*/
|
|
455
|
+
void (async () => {
|
|
456
|
+
try {
|
|
457
|
+
const { boxesUrlFrom, fetchBoxesFor } = await import('./machines.mjs');
|
|
458
|
+
const { otherBoxesLine } = await import('./machineListing.mjs');
|
|
459
|
+
const res = await fetchBoxesFor(
|
|
460
|
+
{ fleetToken: FLEET_TOKEN },
|
|
461
|
+
{ url: boxesUrlFrom(FLEET_URL), envpub: myPubB64() ?? undefined }
|
|
462
|
+
);
|
|
463
|
+
if (!res.boxes) return;
|
|
464
|
+
const line = otherBoxesLine(res.boxes, res.me);
|
|
465
|
+
if (line) note(line);
|
|
466
|
+
} catch {
|
|
467
|
+
/* a readout — it must never disturb the daemon that produced it */
|
|
468
|
+
}
|
|
469
|
+
})();
|
|
470
|
+
}
|
|
@@ -0,0 +1,196 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* THE DAEMON'S START, BEFORE THE FIRST POLL — the single-instance lock (and
|
|
3
|
+
* its refusal in words), the per-repo worktree home with its disk line and its
|
|
4
|
+
* fortnight reap, and the process signal handlers that run the teardown.
|
|
5
|
+
*
|
|
6
|
+
* Split out of fleet.mjs (SOLID audit 2026-09-26, F038). These run once per
|
|
7
|
+
* process, in the order `runFleetDaemon` calls them, and change for local
|
|
8
|
+
* reasons (how two daemons on one box are told apart, where worktrees live,
|
|
9
|
+
* how long an idle one is kept) that are nothing to do with the roster loop.
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
import { mkdirSync, readdirSync, statSync } from 'node:fs';
|
|
13
|
+
import { execFileSync } from 'node:child_process';
|
|
14
|
+
import { createHash } from 'node:crypto';
|
|
15
|
+
import { homedir } from 'node:os';
|
|
16
|
+
import { join, basename } from 'node:path';
|
|
17
|
+
import { FLEET_TOKEN } from './config.mjs';
|
|
18
|
+
import { git } from './git.mjs';
|
|
19
|
+
import { info, note, warn, fail } from './ui.mjs';
|
|
20
|
+
import { acquireInstanceLock } from './instance.mjs';
|
|
21
|
+
import { emitMachineEvent } from './daemonLogging.mjs';
|
|
22
|
+
|
|
23
|
+
/** Takes the lock, or says why not and exits 1. Returns only on success. */
|
|
24
|
+
export function takeInstanceLockOrExit(repoRoot) {
|
|
25
|
+
// ONE DAEMON PER CREDENTIAL. Before preflight, before the preview reap,
|
|
26
|
+
// before anything with a side effect — a second daemon must not so much as
|
|
27
|
+
// install a CLI or clear a registry on its way to being refused. Keyed on the
|
|
28
|
+
// credential rather than the repo, because two checkouts on one credential is
|
|
29
|
+
// the SAME project served twice, and the worst version of this: their session
|
|
30
|
+
// worktrees are in different directories, so the per-turn lock cannot even see
|
|
31
|
+
// across them. See instance.mjs for why that lock is not enough on its own.
|
|
32
|
+
// Same repo -> this run replaces whatever was serving it. Different repo ->
|
|
33
|
+
// refused, and nothing is signalled. See instance.mjs's header for the rule.
|
|
34
|
+
//
|
|
35
|
+
// `--takeover` ARBITRATES PROCESSES ON THIS BOX and nothing more — which
|
|
36
|
+
// daemon serves this repo. It says nothing about which BOX serves the
|
|
37
|
+
// project: that is holdership, the server decides it because only the server
|
|
38
|
+
// can see both boxes, and a person moves it from the app.
|
|
39
|
+
const instance = acquireInstanceLock(FLEET_TOKEN, repoRoot, {
|
|
40
|
+
takeover:
|
|
41
|
+
process.argv.includes('--takeover') || process.argv.includes('--takeover-downgrade'),
|
|
42
|
+
noTakeover:
|
|
43
|
+
process.argv.includes('--no-takeover') || process.env.FLOWVIANT_NO_TAKEOVER === '1',
|
|
44
|
+
allowDowngrade: process.argv.includes('--takeover-downgrade'),
|
|
45
|
+
log: (m) => info(m),
|
|
46
|
+
});
|
|
47
|
+
if (!instance.ok) {
|
|
48
|
+
const h = instance.holder;
|
|
49
|
+
console.log('');
|
|
50
|
+
// Two different refusals, because they are two different mistakes and the
|
|
51
|
+
// fix is not the same. Same CREDENTIAL: one project is being served twice.
|
|
52
|
+
// Same REPO under another credential: two daemons in one working tree,
|
|
53
|
+
// which the credential-keyed lock cannot see on its own.
|
|
54
|
+
if (instance.takeoverFailed) {
|
|
55
|
+
fail(`could not replace the running daemon: ${instance.takeoverFailed}`);
|
|
56
|
+
} else if (instance.sameRepo) {
|
|
57
|
+
fail('a flowviant daemon is already running in this repo.');
|
|
58
|
+
} else {
|
|
59
|
+
fail('a flowviant daemon is already running for this credential.');
|
|
60
|
+
}
|
|
61
|
+
if (h?.pid) info(`holder · pid ${h.pid}${h.repoRoot ? ` in ${h.repoRoot}` : ''}`);
|
|
62
|
+
// The two-checkouts case is the one nobody spots on their own: both tabs
|
|
63
|
+
// look healthy, and the damage is doubled cards and doubled edits in a repo
|
|
64
|
+
// you are not looking at. Name the other repo when it is a different one.
|
|
65
|
+
if (!instance.sameRepo && h?.repoRoot && h.repoRoot !== repoRoot) {
|
|
66
|
+
warn('that is a DIFFERENT checkout — one credential serves one project, so both would answer the same tabs.');
|
|
67
|
+
// Not offered lightly: that daemon is serving other work, and this
|
|
68
|
+
// command was run somewhere else. Replacing it is a decision, not a
|
|
69
|
+
// restart, so it takes a word.
|
|
70
|
+
note('run with --takeover to stop it and serve this repo instead.');
|
|
71
|
+
}
|
|
72
|
+
// WITHHELD when we could not identify the holder. ALLOW_MULTI runs this
|
|
73
|
+
// daemon unguarded beside one we just admitted we cannot see, and in the
|
|
74
|
+
// same repo that is two `git fetch`, two worktree sweeps, and one
|
|
75
|
+
// `retireWorkSessions` deleting directories the other is serving. Offering
|
|
76
|
+
// it as the way out of "I don't know what that process is" would be handing
|
|
77
|
+
// someone the worst option at the moment they have the least information.
|
|
78
|
+
if (!instance.unidentified) {
|
|
79
|
+
note('or run this one with FLOWVIANT_ALLOW_MULTI=1 if you know what you are doing.');
|
|
80
|
+
}
|
|
81
|
+
console.log('');
|
|
82
|
+
process.exit(1);
|
|
83
|
+
}
|
|
84
|
+
if (instance.unguarded)
|
|
85
|
+
warn('could not take the single-instance lock (unwritable ~/.flowviant) — running unguarded');
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/**
|
|
89
|
+
* The persistent worktree home for this checkout, created if missing.
|
|
90
|
+
*/
|
|
91
|
+
export function worktreeHome(repoRoot) {
|
|
92
|
+
// Persistent worktree home (0.9.0) — survives daemon restarts AND reboots,
|
|
93
|
+
// so Ctrl+C mid-task never loses local work. Keyed per repo path.
|
|
94
|
+
const repoKey = `${basename(repoRoot)}-${createHash('sha256').update(repoRoot).digest('hex').slice(0, 8)}`;
|
|
95
|
+
const baseDir = join(homedir(), '.flowviant', 'worktrees', repoKey);
|
|
96
|
+
mkdirSync(baseDir, { recursive: true });
|
|
97
|
+
return { repoKey, baseDir };
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
/** The disk line and the startup reap of long-idle task checkouts. */
|
|
101
|
+
export function tidyWorktreeHome(baseDir, repoRoot) {
|
|
102
|
+
// ONE CHECKOUT PER TASK, named after the task. Worktrees used to be
|
|
103
|
+
// `agent-<agentId>` — a long-lived tree per lane, reset to base between
|
|
104
|
+
// tasks — and that was the last thing a lane owned. Now a lane is a
|
|
105
|
+
// credential and nothing more, which is what makes it disposable: the server
|
|
106
|
+
// can hand any lane any task, and two tasks can never be in each other's
|
|
107
|
+
// files even when one is mid-edit.
|
|
108
|
+
try {
|
|
109
|
+
const kb = Number(execFileSync('du', ['-sk', baseDir], { encoding: 'utf8' }).split('\t')[0]);
|
|
110
|
+
if (kb > 1024)
|
|
111
|
+
info(
|
|
112
|
+
`disk · worktrees ${(kb / 1024 / 1024).toFixed(1)} GB at ~/.flowviant/worktrees — \`flowviant clean\` reclaims`
|
|
113
|
+
);
|
|
114
|
+
} catch {
|
|
115
|
+
/* du unavailable (Windows) — skip the disk line */
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
// Reap long-dead task checkouts. Per-lane trees were self-limiting — N lanes,
|
|
119
|
+
// N directories, reused forever. Per-task trees are not: every task ever
|
|
120
|
+
// built leaves one behind, so without this the disk grows without bound and
|
|
121
|
+
// `flowviant clean` becomes a chore rather than a convenience.
|
|
122
|
+
//
|
|
123
|
+
// Age, not state, is the test. The daemon has no list of which intents are
|
|
124
|
+
// still open, and asking the server for one would put a delete behind a
|
|
125
|
+
// network call that can fail — so anything untouched for a fortnight goes,
|
|
126
|
+
// which is far beyond how long a task stays reviewable and far beyond any
|
|
127
|
+
// pause a human takes mid-build. Runs at startup only: mid-run this would
|
|
128
|
+
// race a worker that is quietly parked on a blocker.
|
|
129
|
+
try {
|
|
130
|
+
const cutoff = Date.now() - 14 * 24 * 60 * 60 * 1000;
|
|
131
|
+
let reaped = 0;
|
|
132
|
+
for (const name of readdirSync(baseDir)) {
|
|
133
|
+
if (!name.startsWith('task-')) continue;
|
|
134
|
+
const p = join(baseDir, name);
|
|
135
|
+
try {
|
|
136
|
+
if (statSync(p).mtimeMs > cutoff) continue;
|
|
137
|
+
// Through git, so the worktree REGISTRATION goes too — an rm -rf leaves
|
|
138
|
+
// a stale entry that blocks re-adding the same path later.
|
|
139
|
+
git(['worktree', 'remove', '--force', p], repoRoot);
|
|
140
|
+
reaped++;
|
|
141
|
+
} catch {
|
|
142
|
+
/* held, gone, or not ours — leave it for `flowviant clean` */
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
if (reaped) info(`disk · reclaimed ${reaped} task worktree${reaped === 1 ? '' : 's'} idle > 14d`);
|
|
146
|
+
} catch {
|
|
147
|
+
/* the worktree home may not exist yet on a first run */
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
/**
|
|
152
|
+
* Ctrl+C, a service manager's SIGTERM, and a stray rejection.
|
|
153
|
+
*
|
|
154
|
+
* `leave` is standDownExit.mjs's: it exits at once when no deploy is in
|
|
155
|
+
* flight, and otherwise waits for the deploys to finish and report (ruling
|
|
156
|
+
* 2026-09-26). The teardown runs once. A signal arriving while it waits goes
|
|
157
|
+
* to `leave` with its NAME, because only a person at the terminal can insist:
|
|
158
|
+
* a second Ctrl+C leaves now, a SIGTERM asking again changes nothing.
|
|
159
|
+
*/
|
|
160
|
+
export function installSignalHandlers(teardown, leave) {
|
|
161
|
+
// REQUIRED, with no `process.exit` fallback: a caller that forgot it would
|
|
162
|
+
// get the exit that skips the drain, and nothing would say so.
|
|
163
|
+
if (typeof leave !== 'function') throw new TypeError('installSignalHandlers needs standDownExit.mjs\'s `leave`');
|
|
164
|
+
const onSignal = (code, words, signal) => {
|
|
165
|
+
if (leave.leaving?.()) {
|
|
166
|
+
void leave(code, { signal });
|
|
167
|
+
return;
|
|
168
|
+
}
|
|
169
|
+
emitMachineEvent({ event: 'stopped', reason: 'signal' });
|
|
170
|
+
console.log('');
|
|
171
|
+
note(words);
|
|
172
|
+
teardown();
|
|
173
|
+
void leave(code, { signal });
|
|
174
|
+
};
|
|
175
|
+
process.on('SIGINT', () =>
|
|
176
|
+
onSignal(130, 'shutting down — stopping workers. Worktrees are kept: in-flight work resumes next run.', 'SIGINT')
|
|
177
|
+
);
|
|
178
|
+
// A service manager stops the daemon with SIGTERM, not Ctrl+C. Without this
|
|
179
|
+
// handler every child survived a `systemctl stop` — the exact orphaning the
|
|
180
|
+
// teardown exists to prevent.
|
|
181
|
+
process.on('SIGTERM', () =>
|
|
182
|
+
onSignal(
|
|
183
|
+
143,
|
|
184
|
+
'shutting down (SIGTERM) — stopping workers. Worktrees are kept: in-flight work resumes next run.',
|
|
185
|
+
'SIGTERM'
|
|
186
|
+
)
|
|
187
|
+
);
|
|
188
|
+
// Keep the daemon alive on a stray rejection. Many loops here are fire-and-
|
|
189
|
+
// forget (`void drainWiki()`, dispatch, sync) and rely on their callees never
|
|
190
|
+
// rejecting; Node ≥15 terminates the process on an unhandled rejection, which
|
|
191
|
+
// would kill every in-flight agent worker over one transient error. Log and
|
|
192
|
+
// survive instead — a wedged sub-task self-heals on the next poll.
|
|
193
|
+
process.on('unhandledRejection', (reason) => {
|
|
194
|
+
warn(`unhandled rejection (daemon kept alive): ${reason?.stack || reason}`);
|
|
195
|
+
});
|
|
196
|
+
}
|