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,482 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* WHETHER THIS BOX IS (STILL) THE PROJECT'S MACHINE — the holder watch, the
|
|
3
|
+
* three roster signals that end a daemon (a commanded stop, a displacement, a
|
|
4
|
+
* removal), and the stand-down choreography they share.
|
|
5
|
+
*
|
|
6
|
+
* Split out of fleet.mjs (SOLID audit 2026-09-26, F038). Everything here is a
|
|
7
|
+
* decision the SERVER made and the daemon relays: the server arbitrates
|
|
8
|
+
* holdership because it is the only party that can see both boxes, and every
|
|
9
|
+
* signal's PRESENCE is the command. What the loop keeps is the ordering —
|
|
10
|
+
* `obeyRosterCommands` runs before the holder observation and before the
|
|
11
|
+
* version signal, for the reason each block below states.
|
|
12
|
+
*
|
|
13
|
+
* Pure or dependency-injected throughout, so every property can be proved
|
|
14
|
+
* without a credential, a server or a second box (holder.test.mjs).
|
|
15
|
+
*/
|
|
16
|
+
|
|
17
|
+
import { safeName } from './credentials.mjs';
|
|
18
|
+
import { emitMachineEvent } from './daemonLogging.mjs';
|
|
19
|
+
import { note, warn } from './ui.mjs';
|
|
20
|
+
import { sleep } from './claude.mjs';
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* A STOP COMMANDED BY FLOWVIANT, read off the roster poll.
|
|
24
|
+
*
|
|
25
|
+
* The daemon is a PULL client — the /fleet/stream socket is a one-way wake
|
|
26
|
+
* nudge with no server→daemon request path — so "stop this machine" can never
|
|
27
|
+
* be a request the server makes of us. It rides the roster RESPONSE instead, on
|
|
28
|
+
* the same `daemon` object the version signal already travels on, which is why
|
|
29
|
+
* it needs no new endpoint and no version floor: an older daemon reads an
|
|
30
|
+
* unknown key as nothing and keeps running, and fail-open is the safe direction
|
|
31
|
+
* for a switch whose failure mode is "your machine went dark".
|
|
32
|
+
*
|
|
33
|
+
* The server decides whether a stop is LIVE — it stamps the credential and only
|
|
34
|
+
* sends the key inside a short honor window — and the daemon does NOT re-derive
|
|
35
|
+
* that. The key's PRESENCE is the command. Evaluating the same TTL on both
|
|
36
|
+
* sides would make clock skew the arbiter of whether a machine may run, and get
|
|
37
|
+
* it wrong in the direction that bricks the box: a relaunch that re-reads an
|
|
38
|
+
* old timestamp and stops itself again, forever.
|
|
39
|
+
*
|
|
40
|
+
* Pure and exported so the decision can be proved without a credential or a
|
|
41
|
+
* live server. `null` means keep running.
|
|
42
|
+
*/
|
|
43
|
+
export function shouldStop(rosterDaemon) {
|
|
44
|
+
const stop = rosterDaemon?.stop;
|
|
45
|
+
// An OBJECT, and not an array: `typeof [] === 'object'`, so the plain typeof
|
|
46
|
+
// guard let `stop: []` — an empty list, which is how this codebase spells "no
|
|
47
|
+
// jobs" on every other roster key — read as a live stop with no reason. A
|
|
48
|
+
// switch that kills a machine gets the narrow test.
|
|
49
|
+
if (!stop || typeof stop !== 'object' || Array.isArray(stop)) return null;
|
|
50
|
+
// Re-sanitized HERE even though the server wrote it: this string is operator
|
|
51
|
+
// prose typed into a SQL UPDATE and then printed straight to a terminal, so
|
|
52
|
+
// control bytes would let a stop reason repaint the console it is being read
|
|
53
|
+
// on, and an unbounded one would bury the line that matters. The WORDING is
|
|
54
|
+
// untouched — the operator's own sentence is the whole point of the field,
|
|
55
|
+
// and paraphrasing it would leave the person at the keyboard guessing.
|
|
56
|
+
const reason = String(stop.reason ?? '')
|
|
57
|
+
.replace(/[\u0000-\u001f\u007f]/g, ' ')
|
|
58
|
+
.replace(/\s+/g, ' ')
|
|
59
|
+
.trim()
|
|
60
|
+
.slice(0, 300);
|
|
61
|
+
return { stop: true, reason };
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/**
|
|
65
|
+
* HOW LONG AGO, in the words the standby sentence needs. Milliseconds in, one
|
|
66
|
+
* short label out; anything that is not a finite, non-negative number renders
|
|
67
|
+
* NOTHING and the caller drops the clause rather than printing "heard NaN ago".
|
|
68
|
+
* The three-state rule applied to a duration: measured, or say nothing.
|
|
69
|
+
*/
|
|
70
|
+
export function agoLabel(ms) {
|
|
71
|
+
// `typeof`, not `Number()`: `Number(null)` is 0, so a coercing guard turns
|
|
72
|
+
// "the server said nothing" into "heard 0s ago" — a measurement nobody took,
|
|
73
|
+
// printed at the one moment the person is deciding whether to wait.
|
|
74
|
+
if (typeof ms !== 'number' || !Number.isFinite(ms) || ms < 0) return null;
|
|
75
|
+
const s = Math.round(ms / 1000);
|
|
76
|
+
if (s < 60) return `${s}s`;
|
|
77
|
+
const m = Math.round(s / 60);
|
|
78
|
+
if (m < 60) return `${m}m`;
|
|
79
|
+
const h = Math.round(m / 60);
|
|
80
|
+
if (h < 48) return `${h}h`;
|
|
81
|
+
return `${Math.round(h / 24)}d`;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/**
|
|
85
|
+
* WHOSE MACHINE THIS IS — read off the roster, said once, and never a refusal.
|
|
86
|
+
*
|
|
87
|
+
* A project has ONE machine credential and `device/approve` hands every device
|
|
88
|
+
* the same raw token, so two boxes running `npx flowviant` are two daemons that
|
|
89
|
+
* both believe they are the machine. The old behaviour was a silent race: the
|
|
90
|
+
* loser could not see the winner at all, and the moment the winner went quiet
|
|
91
|
+
* long enough for a lease to lapse it cut a FRESH branch for an agent whose work
|
|
92
|
+
* exists only on the other box's disk, then ran a CLI with no conversation
|
|
93
|
+
* behind it. A confident, context-free redo of work somebody was mid-way
|
|
94
|
+
* through.
|
|
95
|
+
*
|
|
96
|
+
* The server arbitrates (it is the only party that can see both boxes) and the
|
|
97
|
+
* answer rides the poll RESPONSE as `holder`. This turns that answer into what
|
|
98
|
+
* the person at the keyboard needs to know, and nothing more:
|
|
99
|
+
*
|
|
100
|
+
* · ABSENT -> this server does not arbitrate machines. Behave exactly
|
|
101
|
+
* as every daemon before 0.84.0 did — zero new paths.
|
|
102
|
+
* · mine: true -> we are the machine. Announce it only if we have been
|
|
103
|
+
* inactive, so an ordinary daemon prints nothing new.
|
|
104
|
+
* · mine: false -> GO INACTIVE. Keep polling quietly; the restricted roster
|
|
105
|
+
* serves us nothing, the auto-handover makes this box the
|
|
106
|
+
* machine when the holder dies, and the app is where a
|
|
107
|
+
* person moves it sooner. Never exit: an inactive box that
|
|
108
|
+
* quits is one somebody has to go and restart by hand.
|
|
109
|
+
*
|
|
110
|
+
* THE WORD A PERSON READS IS `inactive` (2026-09-21) — the owner replaced
|
|
111
|
+
* "standing by" outright: *"no, it can [be] inactive instead"*. The server's
|
|
112
|
+
* role enum says `inactive`, `flowviant machines` prints it, and so do the two
|
|
113
|
+
* sentences below. The STATE NAME returned here is still `'standby'`, and that
|
|
114
|
+
* is deliberate: it names the server's arbitration arm, it is what every gate
|
|
115
|
+
* in this file branches on, and renaming an identifier to match a copy change
|
|
116
|
+
* is how a rename becomes a behaviour change nobody reviewed.
|
|
117
|
+
*
|
|
118
|
+
* Printed ONCE PER DISTINCT HOLDER rather than per poll — a true sentence
|
|
119
|
+
* restated every ten seconds is a scrolling console nobody reads, and the fact
|
|
120
|
+
* only CHANGES when the holder does.
|
|
121
|
+
*
|
|
122
|
+
* Pure except for the injected `say`, so the whole decision can be proved
|
|
123
|
+
* without a credential, a server or a second box.
|
|
124
|
+
*/
|
|
125
|
+
export function createHolderWatch({ say = () => {} } = {}) {
|
|
126
|
+
let standbyKey = null; // the holder we last announced, or null while we serve
|
|
127
|
+
return {
|
|
128
|
+
/** Returns 'absent' | 'mine' | 'standby' — the state, for the caller's
|
|
129
|
+
* own gating and for tests that must not read the console. */
|
|
130
|
+
observe(holder) {
|
|
131
|
+
if (!holder || typeof holder !== 'object' || Array.isArray(holder)) {
|
|
132
|
+
// An older server, or a poll it did not arbitrate. Silence, and not one
|
|
133
|
+
// new path: this is the 0.83.0 daemon.
|
|
134
|
+
return 'absent';
|
|
135
|
+
}
|
|
136
|
+
if (holder.mine === true) {
|
|
137
|
+
/**
|
|
138
|
+
* WE TOOK IT, AND THE SERVER SAID SO (0.91.0).
|
|
139
|
+
*
|
|
140
|
+
* Sent on the one poll that moved holdership and never again, so this
|
|
141
|
+
* is news by construction — no key to remember, no window to judge.
|
|
142
|
+
* RELAYED AND NEVER DERIVED: the box we displaced is named because the
|
|
143
|
+
* server named it, and an unnamed one gets the nameless fallback the
|
|
144
|
+
* rest of this file uses rather than a guess. The turns clause DROPS
|
|
145
|
+
* WHOLE when the count is absent — "0 turns were running" is a
|
|
146
|
+
* measurement nobody took, printed at the one moment somebody is
|
|
147
|
+
* deciding whether they have just interrupted their own work.
|
|
148
|
+
*/
|
|
149
|
+
const took = holder.took;
|
|
150
|
+
if (took && typeof took === 'object' && !Array.isArray(took)) {
|
|
151
|
+
standbyKey = null;
|
|
152
|
+
// scrubbed before it reaches a terminal (2026-09-24, the audit) —
|
|
153
|
+
// see printable.mjs.
|
|
154
|
+
const from = safeName(took.from)?.slice(0, 64) ?? 'another machine';
|
|
155
|
+
const turns = Number.isInteger(took.turns) && took.turns > 0 ? took.turns : null;
|
|
156
|
+
say(
|
|
157
|
+
`took this project's machine from ${from}` +
|
|
158
|
+
(turns
|
|
159
|
+
? ` — ${turns} turn${turns === 1 ? '' : 's'} ${turns === 1 ? 'was' : 'were'} running there and ${turns === 1 ? 'was' : 'were'} settled as moved.`
|
|
160
|
+
: '.')
|
|
161
|
+
);
|
|
162
|
+
return 'mine';
|
|
163
|
+
}
|
|
164
|
+
if (standbyKey !== null) {
|
|
165
|
+
standbyKey = null;
|
|
166
|
+
say('this machine now serves the project.');
|
|
167
|
+
}
|
|
168
|
+
return 'mine';
|
|
169
|
+
}
|
|
170
|
+
// The NAME is all the response carries about the other box, so it is also
|
|
171
|
+
// the only thing "a distinct holder" can be keyed on. An unnamed holder
|
|
172
|
+
// keys on the empty string, which is stable — one announcement, not one
|
|
173
|
+
// per poll. Scrubbed before it reaches a terminal (2026-09-24, the
|
|
174
|
+
// audit) — see printable.mjs.
|
|
175
|
+
const name = safeName(holder.name)?.slice(0, 64) ?? null;
|
|
176
|
+
const key = name ?? '';
|
|
177
|
+
if (key !== standbyKey) {
|
|
178
|
+
standbyKey = key;
|
|
179
|
+
const ago = agoLabel(holder.heardAgo);
|
|
180
|
+
// THE SENTENCE POINTS AT THE APP, NEVER AT A COMMAND. The owner's
|
|
181
|
+
// ruling, verbatim: "i dont intend to run or do anything in the
|
|
182
|
+
// terminal besides npx flowviant or npx flowviant login." So the
|
|
183
|
+
// terminal surface is those two commands, full stop — this box waits
|
|
184
|
+
// or a person moves the machine from project settings, and there is no
|
|
185
|
+
// third thing to type here. An earlier cut of this sentence ended by
|
|
186
|
+
// telling the person to re-run this daemon with a claim flag; that flag
|
|
187
|
+
// is deleted, and the gesture is the app's — where the server can see
|
|
188
|
+
// both boxes and every daemon learns the outcome on its next poll.
|
|
189
|
+
//
|
|
190
|
+
// The ago clause DROPS whole when unmeasured — agoLabel returns null
|
|
191
|
+
// rather than a zero — because "heard 0s ago" is a measurement nobody
|
|
192
|
+
// took, printed at the one moment the person is deciding whether to
|
|
193
|
+
// wait.
|
|
194
|
+
//
|
|
195
|
+
// THE PROMISE IS GONE (0.91.0), and its absence is the honest half.
|
|
196
|
+
// This sentence used to end "It moves here automatically once that
|
|
197
|
+
// machine has been quiet 10 minutes", which was true while starting a
|
|
198
|
+
// daemon did nothing but wait. Since 0.91.0 a process START asks for
|
|
199
|
+
// the machine on its first poll — the owner's ruling, "it should kill
|
|
200
|
+
// the first one and take over" — so reaching this branch at all means
|
|
201
|
+
// the ask was REFUSED or never spent. Four ways: an older server that
|
|
202
|
+
// does not honour it; a daemon this server will not hand a machine to;
|
|
203
|
+
// an UNATTENDED RE-EXEC, which is born with the ask spent because a
|
|
204
|
+
// restart is not a person; and — the one worth naming — a box this
|
|
205
|
+
// credential DISPLACED inside the last ten minutes, which the server
|
|
206
|
+
// refuses so that two supervised daemons cannot trade a machine back
|
|
207
|
+
// and forth forever (see `takeMachineHolderNow`). In that last case the
|
|
208
|
+
// right next move is a decision, not a wait, which is exactly what this
|
|
209
|
+
// sentence already points at. Restating the staleness clock instead
|
|
210
|
+
// would promise a handover on the one path where it is least likely to
|
|
211
|
+
// be what happens. So the line relays the fact and points at the door,
|
|
212
|
+
// which is the same door it always pointed at.
|
|
213
|
+
//
|
|
214
|
+
// THE WORD IS `inactive` (2026-09-21). It was "standing by" until the
|
|
215
|
+
// owner replaced it — asked whether a box should keep saying that, he
|
|
216
|
+
// answered *"no, it can [be] inactive instead"* — and the server's role
|
|
217
|
+
// enum moved in the same pass (`serving` / `inactive`, which is why
|
|
218
|
+
// `machineListing.mjs` no longer respells anything). A terminal saying one
|
|
219
|
+
// word about this box while the app says another about the same box is
|
|
220
|
+
// the confusion these readouts exist to end, so the two ends say the
|
|
221
|
+
// same thing.
|
|
222
|
+
//
|
|
223
|
+
// THE POINTER SURVIVES THE REWORD. The sentence still ends at the app's
|
|
224
|
+
// project settings rather than at "another machine is serving this
|
|
225
|
+
// project", which the line's first half has already said — restating it
|
|
226
|
+
// would spend the only clause the person can act on repeating a fact.
|
|
227
|
+
say(
|
|
228
|
+
`This project's machine is ${name ?? 'another machine'}${ago ? ` (heard ${ago} ago)` : ''}. ` +
|
|
229
|
+
"This one is inactive — move it here from the app's project settings."
|
|
230
|
+
);
|
|
231
|
+
}
|
|
232
|
+
return 'standby';
|
|
233
|
+
},
|
|
234
|
+
};
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
/** The sentence an in-flight agent turn is settled with when the machine moves
|
|
238
|
+
* out from under it. MEASURED, not inferred: the server named the box that
|
|
239
|
+
* took over, and an unnamed one says so rather than guessing. Scrubbed
|
|
240
|
+
* before it is relayed anywhere (2026-09-24, the audit) — see printable.mjs. */
|
|
241
|
+
export function displacedTurnSentence(by) {
|
|
242
|
+
const name = safeName(by)?.slice(0, 64) ?? 'another machine';
|
|
243
|
+
return `The project's machine moved to ${name} while this turn was running.`;
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
/**
|
|
247
|
+
* …AND THE SENTENCE FOR THE OTHER WAY A BOX STOPS BEING THIS PROJECT'S
|
|
248
|
+
* MACHINE (2026-09-21): it was REMOVED in the app.
|
|
249
|
+
*
|
|
250
|
+
* Kept apart from the displaced one rather than generalised into "the machine
|
|
251
|
+
* is no longer this box", because the two are different facts and the person
|
|
252
|
+
* reading a stuck turn needs the one that happened. A move names where the work
|
|
253
|
+
* went and implies somebody will pick it up there; a removal names nothing,
|
|
254
|
+
* because there is nowhere for it to have gone. Guessing between them — or
|
|
255
|
+
* blurring them into one sentence that is true of both — is the product
|
|
256
|
+
* inventing a state.
|
|
257
|
+
*
|
|
258
|
+
* It takes NO name: there is no box that took over. The PROJECT is named on the
|
|
259
|
+
* console line instead, where it answers "removed from what".
|
|
260
|
+
*/
|
|
261
|
+
export function removedTurnSentence() {
|
|
262
|
+
return 'This machine was removed from the project in the app while this turn was running.';
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
/**
|
|
266
|
+
* THE MACHINE MOVED. STAND DOWN.
|
|
267
|
+
*
|
|
268
|
+
* The server sends `displaced` only when it is aimed at THIS box (it matches the
|
|
269
|
+
* poll's own `envpub`) and only inside a short window, so there is nothing to
|
|
270
|
+
* re-derive here and nothing to honour from last week — the same shape, and the
|
|
271
|
+
* same reasoning, as the commanded stop above.
|
|
272
|
+
*
|
|
273
|
+
* Unlike the signal handlers this path is poll-response-driven, so it CAN await:
|
|
274
|
+
* every in-flight agent turn is settled first, because a turn this daemon
|
|
275
|
+
* abandons silently sits pending until the server's six-hour expiry while the
|
|
276
|
+
* board shows an agent working on a machine that has gone. `nothing` is the
|
|
277
|
+
* honest outcome and the sentence says what happened.
|
|
278
|
+
*
|
|
279
|
+
* EXIT 0, for the reason the two existing terminal paths (the commanded stop,
|
|
280
|
+
* and the revoked credential above it) both document: under `Restart=on-failure`
|
|
281
|
+
* a nonzero code has systemd relaunch this daemon immediately, where it would
|
|
282
|
+
* poll, be told again that it is not the machine, and stand down again — a
|
|
283
|
+
* restart loop fighting a decision somebody made on purpose.
|
|
284
|
+
*
|
|
285
|
+
* Every dependency is injected so the whole stand-down can be proved without a
|
|
286
|
+
* server, a repo or a process to kill.
|
|
287
|
+
*/
|
|
288
|
+
export async function standDownDisplaced({
|
|
289
|
+
by,
|
|
290
|
+
kind = 'moved',
|
|
291
|
+
project,
|
|
292
|
+
settleAgentTurns,
|
|
293
|
+
flushReports,
|
|
294
|
+
teardown,
|
|
295
|
+
exit,
|
|
296
|
+
log = { warn: () => {}, note: () => {} },
|
|
297
|
+
}) {
|
|
298
|
+
/** A bound on a wedged uplink, and never a reason to hang: the timer is
|
|
299
|
+
* unref'd, so the only thing that keeps this process alive is the work. */
|
|
300
|
+
const bounded = (p, seconds) =>
|
|
301
|
+
Promise.race([
|
|
302
|
+
p,
|
|
303
|
+
new Promise((resolve) => {
|
|
304
|
+
const t = setTimeout(resolve, seconds * 1000);
|
|
305
|
+
t.unref?.();
|
|
306
|
+
}),
|
|
307
|
+
]);
|
|
308
|
+
// Scrubbed before it reaches this box's own console (2026-09-24, the
|
|
309
|
+
// audit) — see printable.mjs.
|
|
310
|
+
const name = safeName(by)?.slice(0, 64) ?? null;
|
|
311
|
+
/**
|
|
312
|
+
* TWO KINDS, ONE CHOREOGRAPHY (2026-09-21).
|
|
313
|
+
*
|
|
314
|
+
* `moved` is the original: another box took the machine, and it is the
|
|
315
|
+
* DEFAULT so the displaced call site reads exactly as it always did.
|
|
316
|
+
* `removed` is the project saying this box is not its machine at all any
|
|
317
|
+
* more — somebody pressed a button in the app — and the only thing that
|
|
318
|
+
* differs is the WORDS. Everything below this point is the same, and it must
|
|
319
|
+
* be: settle the turns first, flush the reports, tear down the detached
|
|
320
|
+
* children, keep the worktrees, exit 0.
|
|
321
|
+
*
|
|
322
|
+
* The sentences are picked HERE rather than passed in, so the two cannot
|
|
323
|
+
* drift and neither call site can invent a third.
|
|
324
|
+
*/
|
|
325
|
+
const removed = kind === 'removed';
|
|
326
|
+
const projectName = safeName(project)?.slice(0, 64) ?? 'this project';
|
|
327
|
+
log.warn(
|
|
328
|
+
removed
|
|
329
|
+
? `removed from ${projectName} in the app — stopping.`
|
|
330
|
+
: name
|
|
331
|
+
? `this project's machine moved to ${name} — standing down.`
|
|
332
|
+
: "this project's machine moved to another box — standing down."
|
|
333
|
+
);
|
|
334
|
+
if (removed) emitMachineEvent({ event: 'stopped', reason: 'removed' });
|
|
335
|
+
else emitMachineEvent({ event: 'displaced', message: displacedTurnSentence(by) });
|
|
336
|
+
// FIRST, and awaited: an unsettled turn is the one thing here that no later
|
|
337
|
+
// poll from anybody can fix — this process holds the only copy of the fact
|
|
338
|
+
// that it was running.
|
|
339
|
+
try {
|
|
340
|
+
await bounded(
|
|
341
|
+
settleAgentTurns(removed ? removedTurnSentence() : displacedTurnSentence(by)),
|
|
342
|
+
10
|
|
343
|
+
);
|
|
344
|
+
} catch {
|
|
345
|
+
/* an unsettled turn expires server-side with words of its own */
|
|
346
|
+
}
|
|
347
|
+
// THEN the queued settles, bounded exactly as the commanded stop bounds them:
|
|
348
|
+
// a queued report is a COMPLETED turn whose side effects already happened, and
|
|
349
|
+
// dropping it re-runs the whole turn somewhere else.
|
|
350
|
+
try {
|
|
351
|
+
await bounded(flushReports(), 5);
|
|
352
|
+
} catch {
|
|
353
|
+
/* undelivered reports re-run; delivering them was best-effort */
|
|
354
|
+
}
|
|
355
|
+
// NOT optional, for the reason the commanded stop states: detached preview
|
|
356
|
+
// tunnels survive this process by design, and a public hostname pointed into a
|
|
357
|
+
// worktree on a box that no longer serves the project is the worst thing this
|
|
358
|
+
// path can leave behind.
|
|
359
|
+
teardown();
|
|
360
|
+
log.note('worktrees are kept — the branches here are the only copy of this box\'s work.');
|
|
361
|
+
exit(0);
|
|
362
|
+
}
|
|
363
|
+
|
|
364
|
+
/**
|
|
365
|
+
* THE THREE ROSTER SIGNALS THAT END THIS DAEMON, in their one order: a
|
|
366
|
+
* commanded stop, then a displacement, then a removal. Returns true when the
|
|
367
|
+
* daemon is going away (the loop returns), false when none was sent.
|
|
368
|
+
*
|
|
369
|
+
* Every dependency is injected so the choreography can be driven without a
|
|
370
|
+
* process to kill; the loop passes standDownExit.mjs's `leave` as `exit`, so a
|
|
371
|
+
* deploy in flight finishes and reports before the process goes.
|
|
372
|
+
*/
|
|
373
|
+
export async function obeyRosterCommands(
|
|
374
|
+
roster,
|
|
375
|
+
{ settleAgentTurns, flushWorkReports, teardown, exit, log = { warn, note } }
|
|
376
|
+
) {
|
|
377
|
+
// A COMMANDED STOP OUTRANKS AN UPDATE, and that ordering is the whole reason
|
|
378
|
+
// this sits ABOVE the version signal rather than inside it. Both read the
|
|
379
|
+
// same `roster.daemon` object, but `handleVersionSignal` can re-exec this
|
|
380
|
+
// process into a newer build — so checked second, a machine somebody just
|
|
381
|
+
// told to stop would come back up wearing a different version instead of
|
|
382
|
+
// going away.
|
|
383
|
+
const stopSignal = shouldStop(roster.daemon);
|
|
384
|
+
if (stopSignal) {
|
|
385
|
+
emitMachineEvent({ event: 'stopped', reason: stopSignal.reason ?? null });
|
|
386
|
+
log.warn(
|
|
387
|
+
stopSignal.reason
|
|
388
|
+
? `stopped by Flowviant — ${stopSignal.reason}`
|
|
389
|
+
: 'stopped by Flowviant — no reason given.'
|
|
390
|
+
);
|
|
391
|
+
log.note('shutting down — stopping workers. Worktrees are kept: in-flight work resumes next run.');
|
|
392
|
+
// FLUSH the settle queue first, bounded: a queued-but-undelivered report
|
|
393
|
+
// is a COMPLETED turn whose side effects already happened, and dropping
|
|
394
|
+
// it re-runs the whole turn on the next start — quota spent twice and
|
|
395
|
+
// every card write doubled. This path is async (unlike the signal
|
|
396
|
+
// handlers, which cannot await), so the stop can afford five seconds of
|
|
397
|
+
// delivery before it obeys.
|
|
398
|
+
try {
|
|
399
|
+
await Promise.race([flushWorkReports(), sleep(5)]);
|
|
400
|
+
} catch {
|
|
401
|
+
/* undelivered reports re-run; delivering them was best-effort */
|
|
402
|
+
}
|
|
403
|
+
// teardown() is NOT optional on this path. Detached preview tunnels
|
|
404
|
+
// survive this process BY DESIGN, so exiting without it strands a public
|
|
405
|
+
// hostname pointed into a worktree until somebody reboots the box — which
|
|
406
|
+
// is precisely the state a remote stop is usually being used to end. It
|
|
407
|
+
// also kills the session CLIs and the wiki Claude, which would otherwise
|
|
408
|
+
// keep editing worktrees and burning quota for a machine nobody is
|
|
409
|
+
// watching any more.
|
|
410
|
+
teardown();
|
|
411
|
+
// EXIT 0, and this is load-bearing: the stop was ASKED FOR, so it is not
|
|
412
|
+
// a failure. Under `Restart=on-failure` a nonzero code has systemd
|
|
413
|
+
// relaunch the daemon immediately, fighting the very command that stopped
|
|
414
|
+
// it; exit 0 reads as "the job is done" and leaves it down. The server's
|
|
415
|
+
// honor window is what makes the other half work — a deliberate relaunch
|
|
416
|
+
// minutes later comes up clean instead of stopping itself forever.
|
|
417
|
+
exit(0);
|
|
418
|
+
return true;
|
|
419
|
+
}
|
|
420
|
+
/**
|
|
421
|
+
* THE MACHINE MOVED OUT FROM UNDER US — checked BEFORE the version signal
|
|
422
|
+
* for the reason the stop above is: `handleVersionSignal` can re-exec this
|
|
423
|
+
* process, and a box that has just been displaced coming back up wearing a
|
|
424
|
+
* newer version is the one outcome nobody asked for.
|
|
425
|
+
*
|
|
426
|
+
* The key's PRESENCE is the command, exactly as it is for a stop: the server
|
|
427
|
+
* sends it only when it names THIS box's `envpub` and only inside its own
|
|
428
|
+
* window, so there is no TTL to re-evaluate here and no way for a relaunch
|
|
429
|
+
* to obey a displacement aimed at somebody else.
|
|
430
|
+
*/
|
|
431
|
+
if (roster.displaced && typeof roster.displaced === 'object' && !Array.isArray(roster.displaced)) {
|
|
432
|
+
await standDownDisplaced({
|
|
433
|
+
by: roster.displaced.by,
|
|
434
|
+
settleAgentTurns,
|
|
435
|
+
flushReports: flushWorkReports,
|
|
436
|
+
teardown,
|
|
437
|
+
exit,
|
|
438
|
+
log,
|
|
439
|
+
});
|
|
440
|
+
return true;
|
|
441
|
+
}
|
|
442
|
+
/**
|
|
443
|
+
* REMOVED FROM THE PROJECT IN THE APP — the second way this box stops being
|
|
444
|
+
* this project's machine, and it goes through the same door for the same
|
|
445
|
+
* reason (2026-09-21).
|
|
446
|
+
*
|
|
447
|
+
* IMMEDIATELY AFTER THE DISPLACEMENT, AND BEFORE THE VERSION SIGNAL, which
|
|
448
|
+
* is the ordering the stop and the displacement above both document:
|
|
449
|
+
* `handleVersionSignal` can RE-EXEC this process, and a box that was just
|
|
450
|
+
* removed coming back up wearing a newer version is the one outcome nobody
|
|
451
|
+
* asked for. The two removal-shaped signals sit together so a reader cannot
|
|
452
|
+
* find one without the other.
|
|
453
|
+
*
|
|
454
|
+
* THE KEY'S PRESENCE IS THE COMMAND, exactly as it is for a stop and a
|
|
455
|
+
* displacement: the server sends it only when it names THIS box and only
|
|
456
|
+
* inside its own window, so there is no TTL to re-evaluate here and no way
|
|
457
|
+
* for a relaunch to obey a removal aimed at somebody else.
|
|
458
|
+
*
|
|
459
|
+
* The choreography is identical — settle every in-flight turn, flush the
|
|
460
|
+
* queued reports, tear down the detached children, keep the worktrees,
|
|
461
|
+
* exit 0 — and only the sentences differ. Exit 0 for the reason every
|
|
462
|
+
* terminal path here states: this was ASKED FOR, so under
|
|
463
|
+
* `Restart=on-failure` a nonzero code would relaunch the daemon straight
|
|
464
|
+
* into being removed again.
|
|
465
|
+
*/
|
|
466
|
+
if (roster.standDown && typeof roster.standDown === 'object' && !Array.isArray(roster.standDown)) {
|
|
467
|
+
await standDownDisplaced({
|
|
468
|
+
kind: 'removed',
|
|
469
|
+
// The project's NAME rather than the box that took over, because no box
|
|
470
|
+
// took over. Falls back to "this project" inside — an unnamed project
|
|
471
|
+
// says so rather than being guessed at.
|
|
472
|
+
project: safeName(roster.standDown.project) ?? safeName(roster.project?.name) ?? undefined,
|
|
473
|
+
settleAgentTurns,
|
|
474
|
+
flushReports: flushWorkReports,
|
|
475
|
+
teardown,
|
|
476
|
+
exit,
|
|
477
|
+
log,
|
|
478
|
+
});
|
|
479
|
+
return true;
|
|
480
|
+
}
|
|
481
|
+
return false;
|
|
482
|
+
}
|