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
package/bin/lib/work.mjs
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* Work sessions — the Workbench tabs, daemon side
|
|
2
|
+
* Work sessions — the Workbench tabs and the agent lanes, daemon side: THE
|
|
3
|
+
* MANAGER that builds every lane and hands each one exactly what it uses.
|
|
3
4
|
*
|
|
4
5
|
* A tab is a held coding-CLI session (Claude or codex — the server names the
|
|
5
6
|
* brain per tab, and the pin holds it) with BUILD permissions in a PERSISTENT
|
|
@@ -8,272 +9,83 @@
|
|
|
8
9
|
* blowing it away would be closing the human's editor mid-thought. (Plan
|
|
9
10
|
* worktrees are the deliberate opposite: reset at base every turn.)
|
|
10
11
|
*
|
|
11
|
-
*
|
|
12
|
-
*
|
|
13
|
-
*
|
|
14
|
-
*
|
|
15
|
-
*
|
|
16
|
-
*
|
|
12
|
+
* The daemon's reconcile loop (fleet.mjs) constructs one manager per run and
|
|
13
|
+
* feeds it roster jobs; the only state it borrows from the loop is read
|
|
14
|
+
* through the getters (the MCP URL, the lease TTL and the base can change with
|
|
15
|
+
* any poll).
|
|
16
|
+
*
|
|
17
|
+
* THIS FILE RUNS NO LANE OF ITS OWN (2026-09-26, SOLID F037). It was the
|
|
18
|
+
* implementation site of every workflow the loop guarantees — process-group
|
|
19
|
+
* bookkeeping, the settle queue, publishing, the worktree readout,
|
|
20
|
+
* attachments, the tab's whole turn, retirement — at 3.2k lines, so a change
|
|
21
|
+
* to any one of them was a change to all of their file. Each now has a
|
|
22
|
+
* sibling named for what it does, and this file owns only what is genuinely
|
|
23
|
+
* the manager's: the shared maps (named at every factory that reads them,
|
|
24
|
+
* F164), the artifact reporter and the landed observer that several lanes
|
|
25
|
+
* share, the network-git bound, the construction ORDER (a factory is built
|
|
26
|
+
* after everything it is handed), and the two answers the loop asks of the
|
|
27
|
+
* whole — `workBusy` and the returned surface. A pure rule or constant
|
|
28
|
+
* (REPO_PLACE, the resume-id guards, brainFor, the turn lock) is IMPORTED by
|
|
29
|
+
* every lane that reads it, never carried through here.
|
|
30
|
+
*
|
|
31
|
+
* workPlaces.mjs where a session works, its worktree, its markers
|
|
32
|
+
* workSessionGroups.mjs which process groups each tab started
|
|
33
|
+
* workChildren.mjs the live CLI children, teardown, the admission
|
|
34
|
+
* workAgentPublish.mjs pushing and fetching an agent's branch
|
|
35
|
+
* workPlaceReports.mjs the worktree readout: settle, burst, sweep
|
|
36
|
+
* workReportQueue.mjs every settle, held and retried until delivered
|
|
37
|
+
* workSessionTokens.mjs the per-session work credential
|
|
38
|
+
* workNarration.mjs the tab's live line and the deferral sentence
|
|
39
|
+
* workAttachments.mjs files the human attached, brought to disk
|
|
40
|
+
* workSessionRuntime.mjs which CLI a tab speaks, which conversation resumes
|
|
41
|
+
* workAdoptCarry.mjs adoption's dirty carry
|
|
42
|
+
* workSessionTurns.mjs a tab's turn, start to settle
|
|
43
|
+
* workRetire.mjs held sessions, retirement, manual-place freshening
|
|
44
|
+
* workToolLog.mjs one turn's tool cards, folded and capped (pure)
|
|
45
|
+
* workCommandAudit.mjs one turn's `$ …` audit, batched to the server
|
|
46
|
+
* workBrain.mjs the model and effort a turn may put on argv (pure)
|
|
47
|
+
* turnLock.mjs whether a live CLI of ours holds a directory's turn
|
|
48
|
+
* (and, split earlier: workDiffs, workPreviews, workPullRequests, workShip,
|
|
49
|
+
* workAgentPlans, workAgentCheck, workAgentPrecheck, workAgentReview,
|
|
50
|
+
* workAgentTurns, workAgentMerges, workProcesses; and out of workAgentTurns,
|
|
51
|
+
* SOLID F036: workAgentTurnReports, workAgentTurnExecution,
|
|
52
|
+
* workAgentTurnBegun, workAgentTurnOutcome)
|
|
17
53
|
*/
|
|
18
54
|
|
|
19
55
|
import { createWorkProcesses } from './workProcesses.mjs';
|
|
20
56
|
import { createWorkAgentMerges } from './workAgentMerges.mjs';
|
|
21
57
|
import { createWorkAgentTurns } from './workAgentTurns.mjs';
|
|
22
|
-
|
|
58
|
+
import { createWorkAgentCheck } from './workAgentCheck.mjs';
|
|
59
|
+
import { createWorkAgentPrecheck } from './workAgentPrecheck.mjs';
|
|
23
60
|
import { createWorkAgentReview } from './workAgentReview.mjs';
|
|
24
61
|
import { createWorkAgentPlans } from './workAgentPlans.mjs';
|
|
25
62
|
import { createWorkShipper } from './workShip.mjs';
|
|
26
63
|
import { createWorkPullRequests } from './workPullRequests.mjs';
|
|
27
64
|
import { createWorkPreviews } from './workPreviews.mjs';
|
|
28
65
|
import { createWorkDiffs } from './workDiffs.mjs';
|
|
29
|
-
import {
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
} from '
|
|
41
|
-
import {
|
|
42
|
-
import {
|
|
43
|
-
import {
|
|
44
|
-
FLEET_URL,
|
|
45
|
-
FLEET_TOKEN,
|
|
46
|
-
USER_AGENT,
|
|
47
|
-
REFRESH_BEFORE_SECONDS,
|
|
48
|
-
DAEMON_INSTANCE,
|
|
49
|
-
MACHINE_HOST,
|
|
50
|
-
} from './config.mjs';
|
|
51
|
-
import {
|
|
52
|
-
git,
|
|
53
|
-
gitRaw,
|
|
54
|
-
gitNet as gitNetIn,
|
|
55
|
-
gitNetAsync,
|
|
56
|
-
splitNul,
|
|
57
|
-
isSafePathSegment,
|
|
58
|
-
excludeInWorktree,
|
|
59
|
-
} from './git.mjs';
|
|
60
|
-
import {
|
|
61
|
-
publishPushArgs,
|
|
62
|
-
publishFetchArgs,
|
|
63
|
-
publishErrorText,
|
|
64
|
-
} from './agentPublish.mjs';
|
|
66
|
+
import { createWorkPlaces } from './workPlaces.mjs';
|
|
67
|
+
import { createWorkSessionGroups } from './workSessionGroups.mjs';
|
|
68
|
+
import { createWorkChildren } from './workChildren.mjs';
|
|
69
|
+
import { createWorkAgentPublish } from './workAgentPublish.mjs';
|
|
70
|
+
import { createWorkPlaceReports } from './workPlaceReports.mjs';
|
|
71
|
+
import { createWorkReportQueue } from './workReportQueue.mjs';
|
|
72
|
+
import { createWorkSessionTokens } from './workSessionTokens.mjs';
|
|
73
|
+
import { createWorkNarration } from './workNarration.mjs';
|
|
74
|
+
import { createWorkAttachments } from './workAttachments.mjs';
|
|
75
|
+
import { createWorkSessionRuntime } from './workSessionRuntime.mjs';
|
|
76
|
+
import { createAdoptCarry } from './workAdoptCarry.mjs';
|
|
77
|
+
import { createWorkSessionTurns } from './workSessionTurns.mjs';
|
|
78
|
+
import { createWorkRetire } from './workRetire.mjs';
|
|
79
|
+
import { FLEET_URL, FLEET_TOKEN, USER_AGENT } from './config.mjs';
|
|
80
|
+
import { gitNet as gitNetIn, excludeInWorktree } from './git.mjs';
|
|
65
81
|
import { createLandedObserver } from './landed.mjs';
|
|
66
|
-
import { measureListeners, listenersSupported } from './listeners.mjs';
|
|
67
|
-
import { measureProcesses, liveGroups, processesSupported } from './processes.mjs';
|
|
68
|
-
import {
|
|
69
|
-
bootMark,
|
|
70
|
-
mutateRegistry,
|
|
71
|
-
processStartTime,
|
|
72
|
-
readRegistry,
|
|
73
|
-
sameBoot,
|
|
74
|
-
} from './procRegistry.mjs';
|
|
75
82
|
import { createPlaceLock } from './placeLock.mjs';
|
|
76
|
-
import {
|
|
77
|
-
import {
|
|
78
|
-
import {
|
|
79
|
-
import {
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
WORK_TURN_KICKOFF,
|
|
83
|
-
CAPTURE_TURN_KICKOFF,
|
|
84
|
-
SYSTEM_WORK_PLAIN,
|
|
85
|
-
WORK_TURN_KICKOFF_PLAIN,
|
|
86
|
-
withProjectContext,
|
|
87
|
-
} from './prompts.mjs';
|
|
88
|
-
import { knowledgeDirFor, FLOWVIANT_OWN_PATHS } from './knowledge.mjs';
|
|
89
|
-
import {
|
|
90
|
-
createArtifactReporter,
|
|
91
|
-
snapshotArtifacts,
|
|
92
|
-
} from './artifacts.mjs';
|
|
93
|
-
import { myPubB64, scrub as envScrub, secretIn as envSecretIn } from './env.mjs';
|
|
94
|
-
import {
|
|
95
|
-
detectRuntimes,
|
|
96
|
-
canRun,
|
|
97
|
-
recordSkills,
|
|
98
|
-
recordMcpServers,
|
|
99
|
-
toolEventOf,
|
|
100
|
-
RUNTIMES,
|
|
101
|
-
} from './runtimes.mjs';
|
|
102
|
-
import { createAdmission } from './admission.mjs';
|
|
103
|
-
|
|
104
|
-
/** The place id meaning "the checkout", not a worktree. Must match the
|
|
105
|
-
* server's REPO_PLACE — it is a wire value, not a local convention. */
|
|
106
|
-
const REPO_PLACE = 'repo';
|
|
107
|
-
import {
|
|
108
|
-
isTerminalSessionLive,
|
|
109
|
-
isAgyConversationLive,
|
|
110
|
-
titleForSession,
|
|
111
|
-
} from './localSessions.mjs';
|
|
112
|
-
import { worktreeDiff } from './worktreeDiff.mjs';
|
|
113
|
-
import { homedir } from 'node:os';
|
|
114
|
-
import { createHash } from 'node:crypto';
|
|
115
|
-
|
|
116
|
-
/**
|
|
117
|
-
* The spawn lock: the pid of the CLI currently live in this worktree. A
|
|
118
|
-
* restarted daemon must not put a second Claude into a directory the orphan
|
|
119
|
-
* of its previous life is still editing — two CLIs appending to one held
|
|
120
|
-
* conversation is exactly the incoherence workChains prevents in-process,
|
|
121
|
-
* and the lock extends that guarantee across a restart. A dead pid is a
|
|
122
|
-
* stale lock (removed here); a live one means "come back next poll".
|
|
123
|
-
*
|
|
124
|
-
* …AND A PID IS ONLY THE HOLDER WHILE IT IS STILL THE SAME PROCESS.
|
|
125
|
-
*
|
|
126
|
-
* The lock outlives a reboot or a daemon killed mid-turn, and pids are
|
|
127
|
-
* recycled. Two readings treated a stranger as the holder and wedged every
|
|
128
|
-
* turn and ship in the place until somebody deleted the file by hand: EPERM
|
|
129
|
-
* was read as "alive, just not ours" — but the lock only ever holds a CLI this
|
|
130
|
-
* daemon spawned under its own uid, so a pid we may not signal is by
|
|
131
|
-
* definition not ours, i.e. stale — and a recycled pid of our OWN uid
|
|
132
|
-
* answered signal 0 like the CLI it replaced. The lock now records the
|
|
133
|
-
* process's start time beside the pid (`pid:start`); a live pid whose start
|
|
134
|
-
* time differs is a different process. A lock with no start time (written by
|
|
135
|
-
* an older daemon) keeps the old signal-0 reading, minus EPERM.
|
|
136
|
-
*/
|
|
137
|
-
export const turnLockedByLivePid = (lockPath) => {
|
|
138
|
-
if (!lockPath || !existsSync(lockPath)) return false;
|
|
139
|
-
let pid = 0;
|
|
140
|
-
let start = null;
|
|
141
|
-
try {
|
|
142
|
-
const raw = readFileSync(lockPath, 'utf8').trim();
|
|
143
|
-
const cut = raw.indexOf(':');
|
|
144
|
-
pid = Number(cut < 0 ? raw : raw.slice(0, cut));
|
|
145
|
-
start = cut < 0 ? null : raw.slice(cut + 1) || null;
|
|
146
|
-
} catch {
|
|
147
|
-
/* unreadable — treat as stale */
|
|
148
|
-
}
|
|
149
|
-
if (Number.isInteger(pid) && pid > 0) {
|
|
150
|
-
let alive = false;
|
|
151
|
-
try {
|
|
152
|
-
process.kill(pid, 0);
|
|
153
|
-
alive = true; // signal 0 delivered — a process of OUR uid holds the pid
|
|
154
|
-
} catch {
|
|
155
|
-
/* ESRCH: gone. EPERM: another uid's process — never our CLI. Stale. */
|
|
156
|
-
}
|
|
157
|
-
if (alive) {
|
|
158
|
-
const now = start ? processStartTime(pid) : null;
|
|
159
|
-
// Unmeasurable start time is "cannot tell", which keeps the lock.
|
|
160
|
-
if (!start || !now || now === start) return true;
|
|
161
|
-
}
|
|
162
|
-
}
|
|
163
|
-
try {
|
|
164
|
-
rmSync(lockPath, { force: true }); // dead holder — clear the stale lock
|
|
165
|
-
} catch {
|
|
166
|
-
/* best-effort */
|
|
167
|
-
}
|
|
168
|
-
return false;
|
|
169
|
-
};
|
|
170
|
-
|
|
171
|
-
/**
|
|
172
|
-
* A RESUME THAT FOUND NO CONVERSATION, in the CLI's own words.
|
|
173
|
-
*
|
|
174
|
-
* "Produced nothing" was the only signal the retry backstop had, and it is not
|
|
175
|
-
* the signal these failures give: Claude Code answers a dead `--resume` id with
|
|
176
|
-
* a result event carrying `errors: ['No conversation found with session ID: …']`
|
|
177
|
-
* AND writes the same line to stderr, so `out` is non-empty, the backstop never
|
|
178
|
-
* fired, and the turn SETTLED SUCCESSFULLY with the error as its answer. Every
|
|
179
|
-
* later message in that tab replied the same way — the marker file still held
|
|
180
|
-
* the dead id, nothing rewrote it (that only happens when an init event is
|
|
181
|
-
* seen, and there is none), and no surface offered a way to clear it. A tab
|
|
182
|
-
* bricked forever by its own CLI pruning its history, which it does on its own
|
|
183
|
-
* schedule.
|
|
184
|
-
*
|
|
185
|
-
* Matched on the CLI's phrasing rather than a code, because neither runtime
|
|
186
|
-
* gives one. Deliberately narrow: it must not swallow a rate limit or a
|
|
187
|
-
* permission refusal, both of which are real answers that should stand.
|
|
188
|
-
*/
|
|
189
|
-
const RESUME_LOST = [
|
|
190
|
-
/no conversation found/i,
|
|
191
|
-
/no session found/i,
|
|
192
|
-
/session .{0,80}not found/i,
|
|
193
|
-
/conversation .{0,80}not found/i,
|
|
194
|
-
/thread .{0,80}not found/i,
|
|
195
|
-
/trajectory not found/i,
|
|
196
|
-
];
|
|
197
|
-
/**
|
|
198
|
-
* …BUT ONLY WHEN THE CLI SAID IT, NOT WHEN THE REPLY DID (2026-09-24).
|
|
199
|
-
*
|
|
200
|
-
* Under `answerFromResult` a SUCCESSFUL turn's `out` is Claude's reply, and the
|
|
201
|
-
* patterns above were tested against all of it — so "the session cookie was not
|
|
202
|
-
* found because SameSite dropped it" threw away a real answer and re-ran the
|
|
203
|
-
* same message in a fresh, context-free conversation, whose init id then
|
|
204
|
-
* re-pinned the tab: the first run's edits and commits had already happened,
|
|
205
|
-
* the second repeated or misread them, and the tab's history was gone for good.
|
|
206
|
-
* Web debugging talk says "session … not found" all day.
|
|
207
|
-
*
|
|
208
|
-
* So the text alone is never the evidence. Claude Code emits `system.init` on
|
|
209
|
-
* EVERY turn that reached a conversation, and a dead `--resume` id fails before
|
|
210
|
-
* one is emitted — so for Claude, a lost conversation is the phrase AND no init
|
|
211
|
-
* event. Codex and agy give no such marker to this caller, so for them the
|
|
212
|
-
* phrase must BE the reply: a short line, the shape a CLI error takes, never a
|
|
213
|
-
* paragraph that happens to contain it.
|
|
214
|
-
*/
|
|
215
|
-
export const RESUME_LOST_MAX_CHARS = 400;
|
|
216
|
-
export const resumeConversationLost = (text, { runtime = 'claude', sawInit = false } = {}) => {
|
|
217
|
-
const t = String(text || '').trim();
|
|
218
|
-
if (!t || !RESUME_LOST.some((re) => re.test(t))) return false;
|
|
219
|
-
if (runtime === 'claude') return !sawInit;
|
|
220
|
-
return t.length <= RESUME_LOST_MAX_CHARS;
|
|
221
|
-
};
|
|
222
|
-
|
|
223
|
-
/**
|
|
224
|
-
* The shape a per-tab model name must have before it rides argv as
|
|
225
|
-
* `--model <name>`. Conservative for the same reason the codex thread id is
|
|
226
|
-
* (below): it comes off the wire and lands in a child process's arguments —
|
|
227
|
-
* alphanumerics plus dot/dash/underscore, at most 40 characters, and NEVER a
|
|
228
|
-
* leading dash, which is an argv that parses as a flag.
|
|
229
|
-
*/
|
|
230
|
-
const WORK_MODEL_RE = /^[a-zA-Z0-9._][a-zA-Z0-9._-]{0,39}$/;
|
|
231
|
-
|
|
232
|
-
/** The five efforts the CLIs actually accept. A literal set rather than a
|
|
233
|
-
* pattern: there is no such thing as an effort we haven't heard of, and the
|
|
234
|
-
* server's own union is exactly this list. */
|
|
235
|
-
const WORK_EFFORTS = new Set(['low', 'medium', 'high', 'xhigh', 'max']);
|
|
236
|
-
|
|
237
|
-
/**
|
|
238
|
-
* WHICH BRAIN, AT WHICH EFFORT — the tab's own pick, off the roster.
|
|
239
|
-
*
|
|
240
|
-
* Absent is the resting state and it must stay genuinely absent: every tab ran
|
|
241
|
-
* with no `--model` and no `--effort` until now, so a job that names neither
|
|
242
|
-
* has to produce the byte-identical argv it produced yesterday — Claude falling
|
|
243
|
-
* back to the machine's MODEL pin, codex and agy to their own defaults. Hence
|
|
244
|
-
* an object with the key MISSING rather than one holding null: a null would
|
|
245
|
-
* reach the builders as a value and Claude's `model || MODEL` is the only one
|
|
246
|
-
* that would survive it.
|
|
247
|
-
*
|
|
248
|
-
* A value that fails its guard is DROPPED, not passed through and not an error.
|
|
249
|
-
* The honest outcome of "the server named a model this machine can't spell" is
|
|
250
|
-
* the machine's own default — a turn that runs — rather than a flag no CLI
|
|
251
|
-
* understands and a tab that fails every message.
|
|
252
|
-
*/
|
|
253
|
-
/**
|
|
254
|
-
* THE ONE SENTENCE A PLAN TURN'S SYSTEM PROMPT GAINS (0.97.0), composed here
|
|
255
|
-
* rather than in prompts.mjs because it is a MODIFIER on whichever contract
|
|
256
|
-
* the tab runs, not a contract of its own.
|
|
257
|
-
*
|
|
258
|
-
* The enforcement is not this sentence — it is `--permission-mode plan`, under
|
|
259
|
-
* which the CLI itself refuses every write (claude.mjs, PLAN_MODE_PERM). What
|
|
260
|
-
* the sentence buys is the ANSWER: the plain contract above it says "edit
|
|
261
|
-
* freely, commit", and a turn told only that would spend itself discovering
|
|
262
|
-
* refusals. It also says not to reach for `ExitPlanMode` (measured: disabled
|
|
263
|
-
* under `-p`, and the probe turn then asked the person to leave plan mode
|
|
264
|
-
* themselves) — leaving plan mode is the person's switch in the tab.
|
|
265
|
-
*/
|
|
266
|
-
export const PLAN_TURN_SENTENCE =
|
|
267
|
-
'THIS IS A PLANNING TURN: the person switched this tab to plan mode, so read what you need, decide, and answer with the plan itself as your reply — change nothing (the CLI refuses every write this turn, whatever the mechanics above say about editing, committing or artifacts), and do not try to leave plan mode; they switch it off in the tab when they want the plan carried out.';
|
|
268
|
-
|
|
269
|
-
function brainFor(job) {
|
|
270
|
-
const out = {};
|
|
271
|
-
const model = typeof job?.model === 'string' ? job.model.trim() : '';
|
|
272
|
-
if (model && WORK_MODEL_RE.test(model)) out.model = model;
|
|
273
|
-
const effort = typeof job?.effort === 'string' ? job.effort.trim() : '';
|
|
274
|
-
if (effort && WORK_EFFORTS.has(effort)) out.effort = effort;
|
|
275
|
-
return out;
|
|
276
|
-
}
|
|
83
|
+
import { warn } from './ui.mjs';
|
|
84
|
+
import { FLOWVIANT_OWN_PATHS } from './knowledgeLibrary.mjs';
|
|
85
|
+
import { buildArtifactUpload, changedSince, snapshotArtifacts } from './artifacts.mjs';
|
|
86
|
+
import { createArtifactReporter } from './artifactReporter.mjs';
|
|
87
|
+
import { renderDesignPreview } from './artifactPreview.mjs';
|
|
88
|
+
import { scrub as envScrub, secretIn as envSecretIn } from './uplinkScrub.mjs';
|
|
277
89
|
|
|
278
90
|
export function createWorkManager({
|
|
279
91
|
repoRoot,
|
|
@@ -285,7 +97,7 @@ export function createWorkManager({
|
|
|
285
97
|
onRepoChanged = () => {},
|
|
286
98
|
/**
|
|
287
99
|
* CLI turns this manager did not spawn — today exactly one, the wiki
|
|
288
|
-
* cartographer, which lives in
|
|
100
|
+
* cartographer, which lives in wikiRunner.mjs and is wired by fleet.mjs. A callback for the
|
|
289
101
|
* same reason `onRepoChanged` is one: work.mjs is imported BY fleet.mjs and
|
|
290
102
|
* cannot import back. It exists because the concurrency bound is a bound on
|
|
291
103
|
* the MACHINE: a count that can see three lanes out of four is a ceiling with
|
|
@@ -313,13 +125,6 @@ export function createWorkManager({
|
|
|
313
125
|
* `origin/<branch you happened to be on>` for the life of the daemon.
|
|
314
126
|
*/
|
|
315
127
|
const baseRef = () => getBaseRef();
|
|
316
|
-
const WORK_TOKEN_URL = FLEET_URL.replace(/\/agents\/?$/, '/work-token');
|
|
317
|
-
const WORK_DONE_URL = FLEET_URL.replace(/\/agents\/?$/, '/work-turn-done');
|
|
318
|
-
const SHIP_DONE_URL = FLEET_URL.replace(/\/agents\/?$/, '/ship-done');
|
|
319
|
-
const ACTIVITY_URL = FLEET_URL.replace(/\/agents\/?$/, '/session-activity');
|
|
320
|
-
const WORKTREES_URL = FLEET_URL.replace(/\/agents\/?$/, '/session-worktrees');
|
|
321
|
-
const SESSION_COMMANDS_URL = FLEET_URL.replace(/\/agents\/?$/, '/session-commands');
|
|
322
|
-
const ATTACHMENT_URL = FLEET_URL.replace(/\/agents\/?$/, '/attachment');
|
|
323
128
|
// What arrived on base, whichever road it took — observed after every beat
|
|
324
129
|
// that can move origin/<base>: the sweep's fetch, a ship's push, a PR merge
|
|
325
130
|
// this daemon performed. See landed.mjs for the seeding and delivery rules.
|
|
@@ -328,7 +133,7 @@ export function createWorkManager({
|
|
|
328
133
|
* THE ARTIFACT RELAY (2026-09-22, 0.94.0) — what a turn wrote under
|
|
329
134
|
* `.flowviant/artifacts/`, uploaded after it. One reporter for the life of
|
|
330
135
|
* the manager so its held bodies survive from one beat to the next; see
|
|
331
|
-
* artifacts.mjs for the snapshot rule
|
|
136
|
+
* artifacts.mjs for the snapshot rule and the bounds, artifactReporter.mjs for the delivery shape.
|
|
332
137
|
*/
|
|
333
138
|
const artifacts = createArtifactReporter({
|
|
334
139
|
fleetUrl: FLEET_URL,
|
|
@@ -339,6 +144,10 @@ export function createWorkManager({
|
|
|
339
144
|
// a hit is withheld, never uploaded rewritten (artifacts.mjs).
|
|
340
145
|
secretIn: envSecretIn,
|
|
341
146
|
log: (line) => warn(line),
|
|
147
|
+
// The disk half and the browser half, handed in (artifactReporter.mjs).
|
|
148
|
+
listChanged: changedSince,
|
|
149
|
+
buildUpload: buildArtifactUpload,
|
|
150
|
+
renderPreview: renderDesignPreview,
|
|
342
151
|
});
|
|
343
152
|
/**
|
|
344
153
|
* Snapshot a place's artifact directory before a CLI spawns in it — and make
|
|
@@ -358,561 +167,10 @@ export function createWorkManager({
|
|
|
358
167
|
}
|
|
359
168
|
return snapshotArtifacts(placeDir);
|
|
360
169
|
};
|
|
361
|
-
const workAnswering = new Set(); // turn ids currently queued/running here
|
|
362
170
|
const workAttempts = new Map(); // turn id -> completed runTurn attempts
|
|
363
|
-
const MAX_WORK_TRIES = 3;
|
|
364
171
|
const shipping = new Set(); // sessionIds with a ship queued/running here
|
|
365
172
|
const { placeLocks, inPlace } = createPlaceLock();
|
|
366
173
|
|
|
367
|
-
/**
|
|
368
|
-
* WHICH PROCESS GROUPS EACH TAB HAS STARTED.
|
|
369
|
-
*
|
|
370
|
-
* A turn's CLI is spawned `detached`, so its pid is a process-group id and
|
|
371
|
-
* everything the agent starts inherits it — through `nohup` and `setsid`,
|
|
372
|
-
* which is precisely where attribution by ppid falls apart. Kept per SESSION
|
|
373
|
-
* and not per turn: the point of the feature is the watcher that outlives the
|
|
374
|
-
* turn that started it.
|
|
375
|
-
*
|
|
376
|
-
* PRUNED ON EVERY READ against the kernel, which is not housekeeping. A pgid
|
|
377
|
-
* is a pid and pids are recycled, so an un-pruned set would eventually
|
|
378
|
-
* attribute a stranger's process to a tab that has been closed for a week.
|
|
379
|
-
*/
|
|
380
|
-
const sessionGroups = new Map(); // sessionId -> Set<pgid>
|
|
381
|
-
|
|
382
|
-
/**
|
|
383
|
-
* …AND IT SURVIVES A RESTART, which it did not until 2026-08-27.
|
|
384
|
-
*
|
|
385
|
-
* This map used to live only in this closure. The processes it tracks
|
|
386
|
-
* OUTLIVE the daemon on purpose — `shutdownWork` SIGTERMs the CLI child and
|
|
387
|
-
* never the group, precisely so an unattended auto-update does not kill the
|
|
388
|
-
* driver's dev server — so every daemon restart left a live watcher running
|
|
389
|
-
* with nothing left that knew whose it was. The tab reported `[]`, the web
|
|
390
|
-
* read that as "looked, found none", and the Running section went dark until
|
|
391
|
-
* some later turn happened to open a new group.
|
|
392
|
-
*
|
|
393
|
-
* That is not a small bug: AUTO_UPDATE is on by default, so it fired on every
|
|
394
|
-
* release, on every machine. And it broke the three-state rule this file
|
|
395
|
-
* states in its own header — the honest answer after a restart was "we have
|
|
396
|
-
* forgotten", and `[]` is not that. Persisting is what makes `[]` true again,
|
|
397
|
-
* which is why the fix is a disk write and not a fourth state.
|
|
398
|
-
*
|
|
399
|
-
* `procRegistry` is the right home and was sitting unused: it was built for
|
|
400
|
-
* exactly this ("the daemon spawns things that outlive it… the successor has
|
|
401
|
-
* to find them"), was orphaned when the dev-run system was deleted, and
|
|
402
|
-
* already does the atomic write, the stale-lock recovery, the entry cap and
|
|
403
|
-
* the dead-pid TTL. Its prune is deliberately LOOSE here — it keeps an entry
|
|
404
|
-
* whose leader is gone, because the leader is the CLI and it exits at the end
|
|
405
|
-
* of every turn while the watcher it started keeps running. `liveGroups` is
|
|
406
|
-
* the real prune, on every read, against the kernel.
|
|
407
|
-
*/
|
|
408
|
-
/*
|
|
409
|
-
* THREE RULES THE FIRST CUT BROKE (2026-09-24), each a way the file named a
|
|
410
|
-
* group that was not this tab's, or forgot one that was:
|
|
411
|
-
*
|
|
412
|
-
* - ONE FILE PER REPO, not per OS user. Each daemon rewrote the shared file
|
|
413
|
-
* with only its own groups, so two daemons on one box (project A in one
|
|
414
|
-
* checkout, B in another) erased each other's entries and A's watcher
|
|
415
|
-
* was forgotten at A's next restart. The instance lock already makes a
|
|
416
|
-
* repo one daemon, so the repo is the right owner. The pre-2026-09-24
|
|
417
|
-
* shared file is not read: its entries carry no boot, so no rule below
|
|
418
|
-
* could believe them anyway.
|
|
419
|
-
* - AN ENTRY IS BELIEVED ONLY IN THE BOOT THAT WROTE IT. After a reboot the
|
|
420
|
-
* remembered pgid belongs to whatever the kernel handed it to next — the
|
|
421
|
-
* operator's shell, another project's children — and the Running list
|
|
422
|
-
* relayed that group's command lines as this tab's and `killTargetOk`
|
|
423
|
-
* accepted a Stop on them.
|
|
424
|
-
* - ONLY LIVE GROUPS ARE WRITTEN, each with the time it was FIRST seen.
|
|
425
|
-
* Every persist used to restamp every entry with `Date.now()`, so the
|
|
426
|
-
* registry's 7-day TTL never applied to anything, and dead entries (one per
|
|
427
|
-
* agent turn, recorded under a key nothing read) filled the 32-entry cap
|
|
428
|
-
* and pushed a tab's live watcher off the end.
|
|
429
|
-
*/
|
|
430
|
-
const GROUPS_DIR = join(homedir(), '.flowviant');
|
|
431
|
-
const GROUPS_KEY = createHash('sha256').update(String(repoRoot)).digest('hex').slice(0, 16);
|
|
432
|
-
const GROUPS_FILE = join(GROUPS_DIR, `session-groups-${GROUPS_KEY}.json`);
|
|
433
|
-
const GROUPS_LOCK = join(GROUPS_DIR, `session-groups-${GROUPS_KEY}.lock`);
|
|
434
|
-
const groupFirstSeen = new Map(); // pgid -> ms first recorded
|
|
435
|
-
const BOOT = bootMark();
|
|
436
|
-
|
|
437
|
-
const persistGroups = () => {
|
|
438
|
-
const flat = [];
|
|
439
|
-
for (const [sid, set] of sessionGroups) {
|
|
440
|
-
for (const pgid of liveGroups(set)) {
|
|
441
|
-
if (!groupFirstSeen.has(pgid)) groupFirstSeen.set(pgid, Date.now());
|
|
442
|
-
flat.push({ sessionId: sid, pid: pgid, startedAt: groupFirstSeen.get(pgid), boot: BOOT });
|
|
443
|
-
}
|
|
444
|
-
}
|
|
445
|
-
const kept = new Set(flat.map((e) => e.pid));
|
|
446
|
-
for (const g of groupFirstSeen.keys()) if (!kept.has(g)) groupFirstSeen.delete(g);
|
|
447
|
-
try {
|
|
448
|
-
mutateRegistry(GROUPS_DIR, GROUPS_FILE, GROUPS_LOCK, () => flat);
|
|
449
|
-
} catch {
|
|
450
|
-
/* best-effort: losing the file costs a restart's visibility, never a turn */
|
|
451
|
-
}
|
|
452
|
-
};
|
|
453
|
-
|
|
454
|
-
try {
|
|
455
|
-
for (const e of readRegistry(GROUPS_FILE)) {
|
|
456
|
-
if (!e?.sessionId || !Number.isInteger(e?.pid)) continue;
|
|
457
|
-
if (!sameBoot(e.boot, BOOT)) continue; // another boot's pgid names a stranger now
|
|
458
|
-
const set = sessionGroups.get(e.sessionId) ?? new Set();
|
|
459
|
-
set.add(e.pid);
|
|
460
|
-
sessionGroups.set(e.sessionId, set);
|
|
461
|
-
if (Number(e.startedAt) > 0) groupFirstSeen.set(e.pid, Number(e.startedAt));
|
|
462
|
-
}
|
|
463
|
-
} catch {
|
|
464
|
-
/* no registry yet — the ordinary first run */
|
|
465
|
-
}
|
|
466
|
-
|
|
467
|
-
/** Forget the groups of every id the roster no longer names — a closed tab,
|
|
468
|
-
* a finished agent. Nothing reports or stops a group for an id that is not
|
|
469
|
-
* live, so holding it only grows the map for the life of the process. */
|
|
470
|
-
const pruneSessionGroups = (activeIds) => {
|
|
471
|
-
const live = new Set(activeIds);
|
|
472
|
-
let changed = false;
|
|
473
|
-
for (const id of [...sessionGroups.keys()]) {
|
|
474
|
-
if (live.has(id)) continue;
|
|
475
|
-
sessionGroups.delete(id);
|
|
476
|
-
changed = true;
|
|
477
|
-
}
|
|
478
|
-
if (changed) persistGroups();
|
|
479
|
-
};
|
|
480
|
-
|
|
481
|
-
const noteSessionGroup = (sessionId, pgid) => {
|
|
482
|
-
if (!sessionId || !pgid) return;
|
|
483
|
-
const set = sessionGroups.get(sessionId) ?? new Set();
|
|
484
|
-
if (set.has(pgid)) return;
|
|
485
|
-
set.add(pgid);
|
|
486
|
-
sessionGroups.set(sessionId, set);
|
|
487
|
-
persistGroups();
|
|
488
|
-
};
|
|
489
|
-
|
|
490
|
-
/** This tab's live processes, or null where the machine cannot look. */
|
|
491
|
-
const sessionProcesses = (sessionId) => {
|
|
492
|
-
if (!processesSupported()) return null;
|
|
493
|
-
const known = sessionGroups.get(sessionId);
|
|
494
|
-
// The SHAPE `measureProcesses` returns — a bare `[]` here has no `.rows`,
|
|
495
|
-
// so the report's `processes` key came out undefined and was dropped from
|
|
496
|
-
// the JSON: "looked and found none" arrived as "never looked".
|
|
497
|
-
if (!known || known.size === 0) return { rows: [], total: 0 };
|
|
498
|
-
const alive = liveGroups(known);
|
|
499
|
-
// Only touch the disk when the set actually MOVED. This runs on every
|
|
500
|
-
// sweep, for every live tab, forever; an unconditional write would be a
|
|
501
|
-
// file rewrite a minute for the life of the daemon to restate what is
|
|
502
|
-
// already there — the same reasoning the auto-name relay uses for its
|
|
503
|
-
// unchanged-title check.
|
|
504
|
-
const changed = alive.size !== known.size;
|
|
505
|
-
if (alive.size === 0) sessionGroups.delete(sessionId);
|
|
506
|
-
else sessionGroups.set(sessionId, alive);
|
|
507
|
-
if (changed) persistGroups();
|
|
508
|
-
return measureProcesses(alive, { scrub: envScrub });
|
|
509
|
-
};
|
|
510
|
-
|
|
511
|
-
/** Every process group this machine is tracking, across every tab — what a
|
|
512
|
-
* kill request is checked against before anything is signalled. */
|
|
513
|
-
const allKnownGroups = () => {
|
|
514
|
-
const all = new Set();
|
|
515
|
-
for (const set of sessionGroups.values()) for (const g of set) all.add(g);
|
|
516
|
-
return all;
|
|
517
|
-
};
|
|
518
|
-
|
|
519
|
-
|
|
520
|
-
/**
|
|
521
|
-
* EVERY turn settles — the work loop's prime contract. A pending turn nobody
|
|
522
|
-
* answers holds one of the tab's slots until the server expires it (24h);
|
|
523
|
-
* silence is the worst outcome. So a report that cannot be DELIVERED right
|
|
524
|
-
* now is queued in memory and retried at the top of every poll, and a turn
|
|
525
|
-
* whose finished answer sits in that queue is never re-run — a session turn
|
|
526
|
-
* has side effects (edits, commits, cards), and a dropped 200 must not apply
|
|
527
|
-
* them twice.
|
|
528
|
-
*/
|
|
529
|
-
const pendingWorkReports = new Map(); // turnId -> work-turn-done body
|
|
530
|
-
const pendingShipReports = new Map(); // sessionId -> ship-done body
|
|
531
|
-
/** POST a settle body. Four outcomes, and the split between the last two is
|
|
532
|
-
* load-bearing:
|
|
533
|
-
* - 'ok' — delivered.
|
|
534
|
-
* - 'terminal' — the EXPLICIT per-endpoint statuses under which the server
|
|
535
|
-
* will never re-offer the job (403 not this fleet's session, 404 unknown
|
|
536
|
-
* turn, 409 ship already settled). Only these may drop the report AND
|
|
537
|
-
* the attempts counter: they are the statuses where forgetting is safe
|
|
538
|
-
* because the job is gone server-side too.
|
|
539
|
-
* - 'reject' — any OTHER 4xx (a 400 from deploy skew, an edge/WAF rule):
|
|
540
|
-
* the server refused this BODY, but the job row may still be pending and
|
|
541
|
-
* riding every poll. The report must stay QUEUED — it is the skip-guard
|
|
542
|
-
* that stops the turn being re-run with all its side effects — but
|
|
543
|
-
* re-POSTing a body the server just refused every poll is spam, so
|
|
544
|
-
* delivery backs off. Treating this as terminal once re-ran whole
|
|
545
|
-
* non-idempotent CLI turns in a loop; treating it as plain retry
|
|
546
|
-
* hammered a refused body forever.
|
|
547
|
-
* - 'retry' — network errors, 408, 429 and 5xx: nothing was decided. */
|
|
548
|
-
const postSettle = async (url, body, terminalStatuses) => {
|
|
549
|
-
try {
|
|
550
|
-
const res = await fetch(url, {
|
|
551
|
-
method: 'POST',
|
|
552
|
-
headers: {
|
|
553
|
-
Authorization: `Bearer ${FLEET_TOKEN}`,
|
|
554
|
-
'User-Agent': USER_AGENT,
|
|
555
|
-
'Content-Type': 'application/json',
|
|
556
|
-
},
|
|
557
|
-
signal: AbortSignal.timeout(30_000),
|
|
558
|
-
body: JSON.stringify(body),
|
|
559
|
-
});
|
|
560
|
-
if (res.ok) return 'ok';
|
|
561
|
-
if (terminalStatuses.includes(res.status)) return 'terminal';
|
|
562
|
-
if (res.status >= 400 && res.status < 500 && res.status !== 408 && res.status !== 429)
|
|
563
|
-
return 'reject';
|
|
564
|
-
return 'retry';
|
|
565
|
-
} catch {
|
|
566
|
-
return 'retry';
|
|
567
|
-
}
|
|
568
|
-
};
|
|
569
|
-
|
|
570
|
-
/** Retry-After, in ms — seconds or an HTTP-date — capped so one bad or
|
|
571
|
-
* hostile header cannot stall a caller for an hour. Falls back to the
|
|
572
|
-
* caller's own backoff when the header is absent or unparseable. */
|
|
573
|
-
const retryAfterMs = (res, fallbackMs) => {
|
|
574
|
-
const h = res?.headers?.get?.('retry-after');
|
|
575
|
-
if (h == null) return fallbackMs;
|
|
576
|
-
const secs = Number(h);
|
|
577
|
-
if (Number.isFinite(secs)) return Math.max(0, Math.min(secs * 1000, 60_000));
|
|
578
|
-
const at = Date.parse(h);
|
|
579
|
-
return Number.isFinite(at) ? Math.max(0, Math.min(at - Date.now(), 60_000)) : fallbackMs;
|
|
580
|
-
};
|
|
581
|
-
|
|
582
|
-
/**
|
|
583
|
-
* A best-effort settle POST, AWAITED INLINE by a caller with no per-poll
|
|
584
|
-
* queue of its own to retry from — unlike `postSettle` above, whose callers
|
|
585
|
-
* requeue a 'retry'/'reject' outcome themselves. Bounded attempts: 408/429
|
|
586
|
-
* retry honouring Retry-After, 5xx retries on a short fixed backoff, and
|
|
587
|
-
* any OTHER 4xx is the server's considered answer — retrying it would just
|
|
588
|
-
* spend the same refusal again, so it counts as delivered, the existing
|
|
589
|
-
* trace convention. A network error retries the same way and is then
|
|
590
|
-
* swallowed: unsettled, and the server expires the job so the asker is
|
|
591
|
-
* told, never spun.
|
|
592
|
-
*/
|
|
593
|
-
const postBestEffort = async (url, body, { attempts = 4, timeoutMs = 30_000 } = {}) => {
|
|
594
|
-
for (let i = 0; i < attempts; i += 1) {
|
|
595
|
-
let res;
|
|
596
|
-
try {
|
|
597
|
-
res = await fetch(url, {
|
|
598
|
-
method: 'POST',
|
|
599
|
-
headers: {
|
|
600
|
-
Authorization: `Bearer ${FLEET_TOKEN}`,
|
|
601
|
-
'User-Agent': USER_AGENT,
|
|
602
|
-
'Content-Type': 'application/json',
|
|
603
|
-
},
|
|
604
|
-
signal: AbortSignal.timeout(timeoutMs),
|
|
605
|
-
body: JSON.stringify(body),
|
|
606
|
-
});
|
|
607
|
-
} catch {
|
|
608
|
-
if (i === attempts - 1) return false;
|
|
609
|
-
await new Promise((r) => setTimeout(r, 2_000 * (i + 1)));
|
|
610
|
-
continue;
|
|
611
|
-
}
|
|
612
|
-
if (res.ok) return true;
|
|
613
|
-
if (res.status === 408 || res.status === 429) {
|
|
614
|
-
if (i === attempts - 1) return false;
|
|
615
|
-
await new Promise((r) => setTimeout(r, retryAfterMs(res, 2_000 * (i + 1))));
|
|
616
|
-
continue;
|
|
617
|
-
}
|
|
618
|
-
if (res.status >= 500) {
|
|
619
|
-
if (i === attempts - 1) return false;
|
|
620
|
-
await new Promise((r) => setTimeout(r, 2_000 * (i + 1)));
|
|
621
|
-
continue;
|
|
622
|
-
}
|
|
623
|
-
return true; // any other 4xx — the server's considered answer
|
|
624
|
-
}
|
|
625
|
-
return false;
|
|
626
|
-
};
|
|
627
|
-
|
|
628
|
-
/** How long a REJECTED report sits out before re-offering its body — long
|
|
629
|
-
* enough that a deploy-skew 400 costs a handful of POSTs a day, short
|
|
630
|
-
* enough that a server fix picks the report up the same morning. */
|
|
631
|
-
const REJECT_RETRY_MS = 10 * 60 * 1000;
|
|
632
|
-
const reportBackoff = new Map(); // turnId|sessionId -> earliest next attempt
|
|
633
|
-
const settleWorkTurn = async (turnId, payload) => {
|
|
634
|
-
const body = { turnId, ...payload };
|
|
635
|
-
const r = await postSettle(WORK_DONE_URL, body, [403, 404]);
|
|
636
|
-
if (r === 'retry' || r === 'reject') {
|
|
637
|
-
pendingWorkReports.set(turnId, body);
|
|
638
|
-
if (r === 'reject') reportBackoff.set(turnId, Date.now() + REJECT_RETRY_MS);
|
|
639
|
-
} else {
|
|
640
|
-
pendingWorkReports.delete(turnId);
|
|
641
|
-
workAttempts.delete(turnId);
|
|
642
|
-
reportBackoff.delete(turnId);
|
|
643
|
-
}
|
|
644
|
-
return r;
|
|
645
|
-
};
|
|
646
|
-
const settleShip = async (sessionId, payload) => {
|
|
647
|
-
const body = { sessionId, ...payload };
|
|
648
|
-
const r = await postSettle(SHIP_DONE_URL, body, [403, 409]);
|
|
649
|
-
if (r === 'retry' || r === 'reject') {
|
|
650
|
-
pendingShipReports.set(sessionId, body);
|
|
651
|
-
if (r === 'reject') reportBackoff.set(sessionId, Date.now() + REJECT_RETRY_MS);
|
|
652
|
-
} else {
|
|
653
|
-
pendingShipReports.delete(sessionId);
|
|
654
|
-
reportBackoff.delete(sessionId);
|
|
655
|
-
// The report has landed, so the idempotency path no longer needs the
|
|
656
|
-
// branch to exist. See `sweepMergedSessionBranch`.
|
|
657
|
-
sweepMergedSessionBranch(sessionId);
|
|
658
|
-
// AND THE DIFFSTAT IS NOW WRONG BY DEFINITION. A ship folds base in,
|
|
659
|
-
// merges the tip out and deletes the branch, so a fresh measurement reads
|
|
660
|
-
// `ahead: 0` with an empty diffstat — and without this the rail keeps
|
|
661
|
-
// rendering the ENTIRE pre-ship diff while the transcript two hundred
|
|
662
|
-
// pixels away says "Shipped to main". The ship button re-arms over a
|
|
663
|
-
// branch that is already merged. Same rule the kill path just learned:
|
|
664
|
-
// an action that changes what the machine would measure must cause a new
|
|
665
|
-
// measurement, and the 60s sweep is not that.
|
|
666
|
-
void reportPlaceWorktrees(sessionId).catch(() => {});
|
|
667
|
-
burstListeners(sessionId);
|
|
668
|
-
// …and the REPO picture changed too: the session branch is gone and base
|
|
669
|
-
// moved. Without this the Repository block keeps counting a branch the
|
|
670
|
-
// ship just deleted.
|
|
671
|
-
onRepoChanged();
|
|
672
|
-
// The ship's push moved the local origin/<base> ref — observe now, so
|
|
673
|
-
// the landed report (and anything trailered a ship carried) lands on
|
|
674
|
-
// this beat rather than the next 3-minute fetch.
|
|
675
|
-
void landed.observe().catch(() => {});
|
|
676
|
-
}
|
|
677
|
-
return r;
|
|
678
|
-
};
|
|
679
|
-
/**
|
|
680
|
-
* THE TAB'S LIVE NARRATION — the terminal's own stdout, relayed.
|
|
681
|
-
*
|
|
682
|
-
* A turn used to be a spinner: the tab said "working…" for minutes and the
|
|
683
|
-
* only thing that ever appeared was the finished reply. The CLI is printing
|
|
684
|
-
* the whole time (thinking, reads, greps, commands), so the honest fix is to
|
|
685
|
-
* FORWARD that, not to invent a progress model on the server. Flowviant
|
|
686
|
-
* relays; it does not narrate on its own behalf.
|
|
687
|
-
*
|
|
688
|
-
* Best-effort by construction: throttled to one POST per window (a turn can
|
|
689
|
-
* emit hundreds of lines), never awaited by the turn, and every failure is
|
|
690
|
-
* swallowed. A spinner must never be able to fail a build. The server clears
|
|
691
|
-
* the line at settle, so a daemon killed mid-turn cannot leave one stuck.
|
|
692
|
-
*/
|
|
693
|
-
const ACTIVITY_MIN_MS = 1_500;
|
|
694
|
-
const ACTIVITY_KEEP = 4; // the last few lines — a tail, not a log
|
|
695
|
-
/** `turnId` scopes the narration to the turn that produced it: a POST
|
|
696
|
-
* already on the wire when the turn settles must not re-stamp a "working…"
|
|
697
|
-
* line over the finished reply — the server drops narration for a turn
|
|
698
|
-
* that is no longer pending. (A session-level pending count can't tell the
|
|
699
|
-
* settled turn's stale line from the queued NEXT turn's fresh one.) */
|
|
700
|
-
const makeNarrator = (sessionId, turnId, getTools) => {
|
|
701
|
-
const recent = [];
|
|
702
|
-
let lastSent = 0;
|
|
703
|
-
let dirty = false;
|
|
704
|
-
let timer = null;
|
|
705
|
-
let sending = false;
|
|
706
|
-
let stopped = false;
|
|
707
|
-
const send = async () => {
|
|
708
|
-
if (sending || stopped) return;
|
|
709
|
-
sending = true;
|
|
710
|
-
dirty = false;
|
|
711
|
-
lastSent = Date.now();
|
|
712
|
-
const lines = recent.slice(-ACTIVITY_KEEP);
|
|
713
|
-
try {
|
|
714
|
-
await fetch(ACTIVITY_URL, {
|
|
715
|
-
method: 'POST',
|
|
716
|
-
headers: {
|
|
717
|
-
Authorization: `Bearer ${FLEET_TOKEN}`,
|
|
718
|
-
'User-Agent': USER_AGENT,
|
|
719
|
-
'Content-Type': 'application/json',
|
|
720
|
-
},
|
|
721
|
-
signal: AbortSignal.timeout(10_000),
|
|
722
|
-
body: JSON.stringify({
|
|
723
|
-
sessionId,
|
|
724
|
-
turnId,
|
|
725
|
-
lines,
|
|
726
|
-
// The structured tool log so far, riding the same throttled beat.
|
|
727
|
-
// Same lifecycle as the lines: overwritten as the turn moves,
|
|
728
|
-
// cleared server-side at settle. Absent until something ran.
|
|
729
|
-
...(getTools ? { tools: getTools() } : {}),
|
|
730
|
-
}),
|
|
731
|
-
});
|
|
732
|
-
} catch {
|
|
733
|
-
/* narration is decoration — a dropped line is not an incident */
|
|
734
|
-
}
|
|
735
|
-
sending = false;
|
|
736
|
-
if (dirty && !stopped) schedule();
|
|
737
|
-
};
|
|
738
|
-
const schedule = () => {
|
|
739
|
-
if (timer || stopped) return;
|
|
740
|
-
const wait = Math.max(0, ACTIVITY_MIN_MS - (Date.now() - lastSent));
|
|
741
|
-
timer = setTimeout(() => {
|
|
742
|
-
timer = null;
|
|
743
|
-
void send();
|
|
744
|
-
}, wait);
|
|
745
|
-
timer.unref?.(); // never hold the process open for a spinner
|
|
746
|
-
};
|
|
747
|
-
return {
|
|
748
|
-
line(label) {
|
|
749
|
-
// Scrub, like every string that leaves this machine: a narration line
|
|
750
|
-
// is the CLI's own stdout — a command echoing an env var, a read of a
|
|
751
|
-
// config file — and it rides the same uplink the final answer does.
|
|
752
|
-
const s = envScrub(String(label ?? ''))
|
|
753
|
-
.replace(/\s+/g, ' ')
|
|
754
|
-
.trim()
|
|
755
|
-
.slice(0, 200);
|
|
756
|
-
if (!s || stopped) return;
|
|
757
|
-
recent.push(s);
|
|
758
|
-
if (recent.length > ACTIVITY_KEEP * 2) recent.shift();
|
|
759
|
-
dirty = true;
|
|
760
|
-
schedule();
|
|
761
|
-
},
|
|
762
|
-
stop() {
|
|
763
|
-
stopped = true;
|
|
764
|
-
if (timer) {
|
|
765
|
-
clearTimeout(timer);
|
|
766
|
-
timer = null;
|
|
767
|
-
}
|
|
768
|
-
},
|
|
769
|
-
};
|
|
770
|
-
};
|
|
771
|
-
|
|
772
|
-
/**
|
|
773
|
-
* WHERE EACH TAB IS STANDING, and what it holds — the readout a human would
|
|
774
|
-
* get by running `git status` in the session's directory, which is the one
|
|
775
|
-
* thing they cannot do from a browser.
|
|
776
|
-
*
|
|
777
|
-
* Two triggers, both cheap: right after a turn settles (the moment the diff
|
|
778
|
-
* changed) and a throttled sweep over every live session (a human editing in
|
|
779
|
-
* the worktree, a build writing files, a ship landing). Best-effort like the
|
|
780
|
-
* narrator: never awaited by a turn, every failure swallowed.
|
|
781
|
-
*/
|
|
782
|
-
const WORKTREE_SWEEP_MS = 60_000;
|
|
783
|
-
/** How often the sweep refreshes `origin/<base>` before measuring. The
|
|
784
|
-
* behind-count is the whole point of the readout — "someone pushed while you
|
|
785
|
-
* were working" — and without a fetch it would only ever count what this
|
|
786
|
-
* machine already happened to have. Rarer than the sweep because a fetch is
|
|
787
|
-
* network, and a teammate's push being visible within three minutes is the
|
|
788
|
-
* same promise the rest of the product makes. */
|
|
789
|
-
const WORKTREE_FETCH_MS = 3 * 60_000;
|
|
790
|
-
/**
|
|
791
|
-
* WHERE EACH SESSION WORKS, learned from the turns we are handed.
|
|
792
|
-
*
|
|
793
|
-
* Every other beat — the worktree sweep, ship, the preview re-check — has to
|
|
794
|
-
* ask the SAME directory the turn ran in, and only the turn job carries
|
|
795
|
-
* `place`. Caching it here is what keeps them agreeing without a second
|
|
796
|
-
* server→daemon field: a session absent from this map has never run a turn,
|
|
797
|
-
* and its own id is the right answer for that case anyway (it is the default
|
|
798
|
-
* place, and a session with no turn has no worktree either).
|
|
799
|
-
*
|
|
800
|
-
* A tab standing in the CHECKOUT is the case this exists for: its directory
|
|
801
|
-
* is not `sessions/<id>` and never will be, so a sweep that assumed the
|
|
802
|
-
* default would measure a directory that does not exist and report nothing —
|
|
803
|
-
* which is exactly why "I still do not see a preview URL" was true of a tab
|
|
804
|
-
* opened in the checkout.
|
|
805
|
-
*/
|
|
806
|
-
const sessionPlaces = new Map();
|
|
807
|
-
const placeOf = (sessionId) => sessionPlaces.get(sessionId) ?? sessionId;
|
|
808
|
-
/**
|
|
809
|
-
* Places straight off the roster, so a tab is measured the moment it EXISTS
|
|
810
|
-
* rather than after somebody types into it.
|
|
811
|
-
*
|
|
812
|
-
* Learning only from turn jobs meant a tab nobody had spoken to yet was
|
|
813
|
-
* measured at `sessions/<id>` — a directory that does not exist for a tab
|
|
814
|
-
* working in the checkout — so it reported no branch, no diffstat and no
|
|
815
|
-
* ports, and every control reading those had nothing to render.
|
|
816
|
-
*
|
|
817
|
-
* The roster is authoritative and turn jobs still agree with it; a session
|
|
818
|
-
* the server does not name keeps whatever a turn taught, and failing that its
|
|
819
|
-
* own id, which is the pre-places default.
|
|
820
|
-
*/
|
|
821
|
-
/**
|
|
822
|
-
* WHERE EACH TAB WORKS — and it has to be able to UNLEARN.
|
|
823
|
-
*
|
|
824
|
-
* This only ever set. Combined with a server that omitted null places, a tab
|
|
825
|
-
* moved from the checkout back to its own worktree simply stopped being
|
|
826
|
-
* mentioned, and this map kept the old value forever. `placeDir` decides
|
|
827
|
-
* where a turn is SPAWNED and where the worktree is measured, so the browser
|
|
828
|
-
* said "its own worktree" while the CLI went on working in the checkout, and
|
|
829
|
-
* the tab reported the checkout's listeners as its own. That is the exact
|
|
830
|
-
* confusion the whole places feature exists to prevent.
|
|
831
|
-
*
|
|
832
|
-
* An explicit `null` now means "its own worktree" and DELETES the entry.
|
|
833
|
-
* Absence of the whole map still means "an older server said nothing", which
|
|
834
|
-
* is the only thing absence can safely mean.
|
|
835
|
-
*/
|
|
836
|
-
const learnPlaces = (map) => {
|
|
837
|
-
if (!map || typeof map !== 'object') return;
|
|
838
|
-
for (const [sid, place] of Object.entries(map)) {
|
|
839
|
-
// VALIDATE AT THE TRUST BOUNDARY. `placeDir` joins this value straight
|
|
840
|
-
// into `sessions/<place>` and it is the directory a turn is SPAWNED in
|
|
841
|
-
// and a preview port is measured against — the security boundary for the
|
|
842
|
-
// whole preview feature. The server resolves place to an enum (never a
|
|
843
|
-
// path), but a server bug or compromise sending `../../etc` would
|
|
844
|
-
// otherwise point a session's measured directory anywhere on the box and
|
|
845
|
-
// defeat the port attribution. `sessionWorktreeReport` and `placeWtFor`
|
|
846
|
-
// already reject an unsafe segment; checking at the intake covers the
|
|
847
|
-
// consumers that do not (the preview-claim `placeDir` did not re-check).
|
|
848
|
-
// NOT the only writer: `processWorkTurns` stores a turn job's place too,
|
|
849
|
-
// and keeps the same check — validating one intake and not the other is
|
|
850
|
-
// one deploy away from validating neither. REPO_PLACE resolves to
|
|
851
|
-
// repoRoot, so it is allowed through despite not being a path segment.
|
|
852
|
-
if (typeof place === 'string' && place && (place === REPO_PLACE || isSafePathSegment(place)))
|
|
853
|
-
sessionPlaces.set(sid, place);
|
|
854
|
-
// null / '' / an unsafe value: the server is telling us this tab is in its
|
|
855
|
-
// OWN worktree (or is malformed). Falling back to the default requires
|
|
856
|
-
// forgetting, not ignoring.
|
|
857
|
-
else sessionPlaces.delete(sid);
|
|
858
|
-
}
|
|
859
|
-
};
|
|
860
|
-
/** The DIRECTORY a session works in. Every path that used to build
|
|
861
|
-
* `sessions/<id>` by hand goes through here, or a tab in the checkout gets
|
|
862
|
-
* measured against a directory that does not exist. */
|
|
863
|
-
const placeDir = (sessionId) => {
|
|
864
|
-
const place = placeOf(sessionId);
|
|
865
|
-
return place === REPO_PLACE ? repoRoot : join(baseDir, 'sessions', place);
|
|
866
|
-
};
|
|
867
|
-
|
|
868
|
-
// ── PUBLISHING AN AGENT'S BRANCH ───────────────────────────────────────────
|
|
869
|
-
//
|
|
870
|
-
// The server names the target (`agentTurnJobs[].publishTo`) and this machine
|
|
871
|
-
// pushes to it. Two laws hold the whole lane up:
|
|
872
|
-
//
|
|
873
|
-
// A PUSH NEVER BLOCKS OR FAILS A TURN. It is tail work after the settle, it
|
|
874
|
-
// is try/catch'd like every sweep, and a failure is REPORTED in git's own
|
|
875
|
-
// words rather than thrown. An agent whose remote push fails has still done
|
|
876
|
-
// its work, still committed it, and still settled.
|
|
877
|
-
//
|
|
878
|
-
// THE SERVER LEARNS OF A PUSH ONLY BY BEING TOLD. Nothing here composes a
|
|
879
|
-
// name: an absent `publishTo` is a project that has publishing off, or a
|
|
880
|
-
// server older than this daemon, and in both the honest behaviour is to push
|
|
881
|
-
// nothing and report nothing. Absence keeps one meaning.
|
|
882
|
-
/**
|
|
883
|
-
* WHAT THIS MACHINE LAST DID WITH EACH AGENT'S BRANCH, by place —
|
|
884
|
-
* `{ ref, sha }` for a push that landed, `{ ref, error, at, tried }` for one
|
|
885
|
-
* that did not. Mutually exclusive by construction, which is what makes the
|
|
886
|
-
* report's two keys mutually exclusive without a second rule.
|
|
887
|
-
*
|
|
888
|
-
* Process-local on purpose: it is a record of what THIS daemon pushed, so a
|
|
889
|
-
* restart re-pushes once and re-reports — a push of the same sha to the same
|
|
890
|
-
* ref is a no-op at the remote, and re-learning beats trusting a file about
|
|
891
|
-
* something a rebase can invalidate.
|
|
892
|
-
*/
|
|
893
|
-
const agentPublished = new Map();
|
|
894
|
-
/**
|
|
895
|
-
* WHAT THIS PROCESS HAS SEEN AT EACH AGENT'S REMOTE REF — `{ ref, sha }`, and
|
|
896
|
-
* the ONLY input to the push's lease.
|
|
897
|
-
*
|
|
898
|
-
* It is deliberately NOT `agentPublished`: that map is the REPORT record (what
|
|
899
|
-
* this machine pushed, and what the server may be told), while this one is an
|
|
900
|
-
* observation of the REMOTE's own position, which a box also gets by FETCHING
|
|
901
|
-
* a ref it never pushed. A box that fetch-continues an agent has seen the ref
|
|
902
|
-
* and must be able to lease against it; a box that has seen nothing pushes
|
|
903
|
-
* with no force flag at all.
|
|
904
|
-
*
|
|
905
|
-
* Process-local for the same reason the record beside it is: a restart has
|
|
906
|
-
* observed nothing, and an unforced push is the honest thing to do about that
|
|
907
|
-
* — it lands, or it refuses and says so.
|
|
908
|
-
*/
|
|
909
|
-
const agentRemoteAt = new Map();
|
|
910
|
-
/** A failed push retries on the next sweep, but not FOREVER at sweep cadence:
|
|
911
|
-
* a remote that refuses (no credentials on this box, a protected prefix)
|
|
912
|
-
* would otherwise cost a blocking network call per agent per minute for the
|
|
913
|
-
* life of the daemon. A moved branch always retries immediately — the
|
|
914
|
-
* throttle is on repeating the SAME attempt, never on new work. */
|
|
915
|
-
const PUBLISH_RETRY_MS = 5 * 60_000;
|
|
916
174
|
/**
|
|
917
175
|
* A NETWORK GIT CALL, TIMED AND NON-INTERACTIVE.
|
|
918
176
|
*
|
|
@@ -924,2219 +182,93 @@ export function createWorkManager({
|
|
|
924
182
|
* turns a prompt into an immediate, reportable failure.
|
|
925
183
|
*/
|
|
926
184
|
const gitNet = (args, ms) => gitNetIn(args, repoRoot, ms);
|
|
927
|
-
/** The tip of an agent's local branch, or null when this box does not hold it
|
|
928
|
-
* — which is a perfectly ordinary state (the begun-guard's whole subject) and
|
|
929
|
-
* means there is nothing to publish, never that a push failed. */
|
|
930
|
-
const agentBranchSha = (place) => {
|
|
931
|
-
try {
|
|
932
|
-
return (
|
|
933
|
-
git(['rev-parse', '--verify', '--quiet', `refs/heads/session/${place}`], repoRoot) || null
|
|
934
|
-
);
|
|
935
|
-
} catch {
|
|
936
|
-
return null;
|
|
937
|
-
}
|
|
938
|
-
};
|
|
939
|
-
/**
|
|
940
|
-
* PUSH ONE AGENT'S BRANCH TO THE NAME THE SERVER GAVE IT.
|
|
941
|
-
*
|
|
942
|
-
* Returns TRUE when the recorded state CHANGED, because the caller's next act
|
|
943
|
-
* is a worktree report and a report that says what the last one said is a
|
|
944
|
-
* write per minute restating a fact. The rule this serves is the one every
|
|
945
|
-
* settle keeps: an action that changes what the machine would measure must
|
|
946
|
-
* cause a new measurement — and only then.
|
|
947
|
-
*
|
|
948
|
-
* Never throws.
|
|
949
|
-
*/
|
|
950
|
-
const publishAgentBranch = async (place, target) => {
|
|
951
|
-
const sha = agentBranchSha(place);
|
|
952
|
-
if (!sha) return false;
|
|
953
|
-
const prev = agentPublished.get(place);
|
|
954
|
-
if (prev?.ref === target && prev.sha === sha) return false; // already there
|
|
955
|
-
if (
|
|
956
|
-
prev?.ref === target &&
|
|
957
|
-
prev.error &&
|
|
958
|
-
prev.tried === sha &&
|
|
959
|
-
Date.now() - (prev.at ?? 0) < PUBLISH_RETRY_MS
|
|
960
|
-
)
|
|
961
|
-
return false; // the same attempt failed moments ago
|
|
962
|
-
// THE LEASE IS THIS PROCESS'S OWN LAST SIGHTING of that ref, and nothing
|
|
963
|
-
// else — never git's remote-tracking ref, which this daemon's own sweep
|
|
964
|
-
// fetch refreshes (the argument is in `publishPushArgs`). An expectation
|
|
965
|
-
// recorded against a DIFFERENT ref is no expectation for this one.
|
|
966
|
-
const seen = agentRemoteAt.get(place);
|
|
967
|
-
// A ref that is not the server's shape never reaches argv. Silent, because
|
|
968
|
-
// a refusal here is a feature not happening on this turn, not a failure of
|
|
969
|
-
// it — and a `publishError` about a value we declined to use would be this
|
|
970
|
-
// machine reporting on a push it never attempted.
|
|
971
|
-
const args = publishPushArgs(place, target, seen?.ref === target ? seen.sha : null);
|
|
972
|
-
if (!args) return false;
|
|
973
|
-
try {
|
|
974
|
-
gitNet(args, 60_000);
|
|
975
|
-
agentPublished.set(place, { ref: target, sha });
|
|
976
|
-
agentRemoteAt.set(place, { ref: target, sha });
|
|
977
|
-
return true;
|
|
978
|
-
} catch (e) {
|
|
979
|
-
const error = publishErrorText(envScrub(e?.stderr?.toString?.() || e?.message || ''));
|
|
980
|
-
agentPublished.set(place, { ref: target, error, at: Date.now(), tried: sha });
|
|
981
|
-
// A repeat of a failure already reported is not news; the server's stored
|
|
982
|
-
// sentence is already this one.
|
|
983
|
-
return !(prev?.error === error && prev.ref === target);
|
|
984
|
-
}
|
|
985
|
-
};
|
|
986
|
-
/**
|
|
987
|
-
* BRING A PUBLISHED BRANCH BACK DOWN — the other half of the durability
|
|
988
|
-
* promise, and the only thing that lets an agent's work outlive its box.
|
|
989
|
-
*
|
|
990
|
-
* Reached from ONE place: the begun-guard's refusal arm, where this machine
|
|
991
|
-
* has just MEASURED that it holds neither the agent's worktree nor its branch.
|
|
992
|
-
* The server only sends `publishedRef` when it heard about a real push, so
|
|
993
|
-
* this is not a guess at a remote branch — it is a fetch of one a machine
|
|
994
|
-
* reported writing.
|
|
995
|
-
*
|
|
996
|
-
* ── WHAT IT RECOVERS, AND WHAT IT CANNOT ──
|
|
997
|
-
*
|
|
998
|
-
* COMMITS COME BACK. The CONVERSATION DOES NOT: the CLI's held context lives
|
|
999
|
-
* in the box that ran it and nothing here transports it. The turn kickoff
|
|
1000
|
-
* re-prompts from the card, which is the honest continuation — an agent that
|
|
1001
|
-
* picks up its own commits and re-reads its own card, never one that remembers
|
|
1002
|
-
* the argument. Nothing this returns may be phrased as if it did.
|
|
1003
|
-
*
|
|
1004
|
-
* THREE ANSWERS, because two would lie. `null` is "there was nothing to try"
|
|
1005
|
-
* (no ref, or one whose shape this machine will not put in argv), and it must
|
|
1006
|
-
* leave the existing refusal EXACTLY as it was — a sentence about a fetch
|
|
1007
|
-
* nobody attempted is worse than the plain refusal. `{ ok: false, why }` is a
|
|
1008
|
-
* measured failure, relayed in git's own words. `{ ok: true }` is only ever
|
|
1009
|
-
* returned after re-reading the ref: a fetch that exits 0 having created
|
|
1010
|
-
* nothing would otherwise walk straight into `placeWtFor` cutting a fresh
|
|
1011
|
-
* branch off base — the context-free redo the guard above exists to prevent,
|
|
1012
|
-
* wearing this feature's name.
|
|
1013
|
-
*/
|
|
1014
|
-
const fetchPublishedBranch = (place, ref) => {
|
|
1015
|
-
const args = publishFetchArgs(ref, place);
|
|
1016
|
-
if (!args) return null;
|
|
1017
|
-
try {
|
|
1018
|
-
gitNet(args, 120_000);
|
|
1019
|
-
} catch (e) {
|
|
1020
|
-
return {
|
|
1021
|
-
ok: false,
|
|
1022
|
-
why: publishErrorText(envScrub(e?.stderr?.toString?.() || e?.message || '')),
|
|
1023
|
-
};
|
|
1024
|
-
}
|
|
1025
|
-
const sha = agentBranchSha(place);
|
|
1026
|
-
if (!sha) return { ok: false, why: 'the fetch reported nothing and left no local branch' };
|
|
1027
|
-
// WHAT THIS PROCESS HAS NOW SEEN AT THAT REMOTE REF. A fetch is an
|
|
1028
|
-
// observation of the remote's own position, and for a box that continues an
|
|
1029
|
-
// agent it never started it is the ONLY one it will ever have — without it
|
|
1030
|
-
// this box's first push would carry no lease and would have to go unforced.
|
|
1031
|
-
// It is not a `publishAgentBranch` record: this machine pushed nothing, so
|
|
1032
|
-
// the server is told nothing.
|
|
1033
|
-
agentRemoteAt.set(place, { ref, sha });
|
|
1034
|
-
return { ok: true };
|
|
1035
|
-
};
|
|
1036
185
|
|
|
1037
|
-
|
|
1038
|
-
|
|
1039
|
-
|
|
1040
|
-
|
|
1041
|
-
|
|
1042
|
-
|
|
1043
|
-
|
|
1044
|
-
|
|
1045
|
-
|
|
1046
|
-
|
|
1047
|
-
|
|
1048
|
-
* with twenty live tabs no agent was EVER measured: no branch diff, no head
|
|
1049
|
-
* sha, and none of their trailered commits ever reached a card. Permanently,
|
|
1050
|
-
* because the same twenty won every pass.
|
|
1051
|
-
*
|
|
1052
|
-
* Chunking removes the cut for any realistic project (see below). The cursor
|
|
1053
|
-
* is what makes the residual cap fair rather than arbitrary: past it, the
|
|
1054
|
-
* places that missed one sweep are the ones that lead the next.
|
|
1055
|
-
*/
|
|
1056
|
-
let worktreeCursor = 0;
|
|
1057
|
-
const postWorktrees = async (reports) => {
|
|
1058
|
-
if (!reports.length) return;
|
|
1059
|
-
try {
|
|
1060
|
-
await fetch(WORKTREES_URL, {
|
|
1061
|
-
method: 'POST',
|
|
1062
|
-
headers: {
|
|
1063
|
-
Authorization: `Bearer ${FLEET_TOKEN}`,
|
|
1064
|
-
'User-Agent': USER_AGENT,
|
|
1065
|
-
'Content-Type': 'application/json',
|
|
1066
|
-
},
|
|
1067
|
-
signal: AbortSignal.timeout(20_000),
|
|
1068
|
-
body: JSON.stringify({ reports }),
|
|
1069
|
-
});
|
|
1070
|
-
} catch {
|
|
1071
|
-
/* a readout — the next sweep carries it */
|
|
1072
|
-
}
|
|
1073
|
-
};
|
|
1074
|
-
const sessionWorktreeReport = (sessionId) => {
|
|
1075
|
-
// The session's PLACE, not its name: a tab in the checkout is measured in
|
|
1076
|
-
// the checkout, and a tab sharing another tab's worktree is measured there.
|
|
1077
|
-
const place = placeOf(sessionId);
|
|
1078
|
-
if (place !== REPO_PLACE && !isSafePathSegment(place)) return null;
|
|
1079
|
-
const wt = placeDir(sessionId);
|
|
1080
|
-
const d = worktreeDiff(wt, baseRef());
|
|
1081
|
-
if (!d) return null;
|
|
1082
|
-
// WHAT IS LISTENING in this worktree, attributed by the CWD of the process
|
|
1083
|
-
// holding the socket. It rides the sweep the daemon already makes rather
|
|
1084
|
-
// than taking a beat of its own, exactly as the commit trailers do — and
|
|
1085
|
-
// like them it needs no version floor, because it is a daemon→server report
|
|
1086
|
-
// on an endpoint that already exists. An older server ignores the key.
|
|
1087
|
-
//
|
|
1088
|
-
// The browser NEVER names a directory and never names a port this did not
|
|
1089
|
-
// report: ports are global to a box and a worktree is not, so this
|
|
1090
|
-
// measurement is the security boundary for the whole preview feature.
|
|
1091
|
-
// `listeningSupported` says whether this machine can measure AT ALL, which
|
|
1092
|
-
// is a different fact from finding nothing. Windows reports nothing and a
|
|
1093
|
-
// failed scan reports nothing, and both were indistinguishable from an idle
|
|
1094
|
-
// worktree — harmless while the only consumer needed a NON-empty array, and
|
|
1095
|
-
// a permanent `Starting…` the moment a Run dev offer hangs off an empty one.
|
|
1096
|
-
// …AND WHAT IT IS RUNNING, attributed by PROCESS GROUP rather than by cwd.
|
|
1097
|
-
// A watcher (`rbxtsc -w`, `tsc --watch`) holds no socket and touches no
|
|
1098
|
-
// file for minutes, so it was invisible from a browser in a way it never is
|
|
1099
|
-
// in a terminal. Same rules as `listening` beside it: a daemon→server
|
|
1100
|
-
// report on an endpoint that already exists, so NO version floor, and
|
|
1101
|
-
// `processesSupported` keeps "cannot look" (Windows) apart from "looked and
|
|
1102
|
-
// found none", which renders differently.
|
|
1103
|
-
const proc = sessionProcesses(sessionId);
|
|
1104
|
-
// THE NAME CLAUDE ALREADY GAVE THIS CONVERSATION, relayed.
|
|
1105
|
-
//
|
|
1106
|
-
// Claude Code titles its own sessions; a Flowviant tab was born "session 3"
|
|
1107
|
-
// and stayed that way unless somebody renamed it by hand, so a strip of
|
|
1108
|
-
// eight tabs said nothing about any of them. The title is not ours to
|
|
1109
|
-
// invent — reading it is the same relay this whole file does, and asking
|
|
1110
|
-
// a model for one would be a second brain, which the product forbids.
|
|
1111
|
-
//
|
|
1112
|
-
// Only CLAUDE tabs have one here: the id is the one the CLI reported at
|
|
1113
|
-
// `system.init` and pinned per tab, so a codex or agy tab simply has no
|
|
1114
|
-
// marker and reports no title. No runtime check is needed for that — the
|
|
1115
|
-
// absent marker IS the check.
|
|
1116
|
-
//
|
|
1117
|
-
// No version floor: a daemon→server report on an endpoint that already
|
|
1118
|
-
// exists, so an older daemon sends no key and the server leaves the name
|
|
1119
|
-
// exactly as it found it.
|
|
1120
|
-
let title = null;
|
|
1121
|
-
try {
|
|
1122
|
-
const marker = sessionMetaPath(wt, 'flowviant-claude-session', sessionId);
|
|
1123
|
-
if (marker) title = titleForSession(wt, readFileSync(marker, 'utf8').trim());
|
|
1124
|
-
} catch {
|
|
1125
|
-
/* no marker yet — this tab has not spoken, or is not Claude */
|
|
1126
|
-
}
|
|
1127
|
-
// The TOTAL rides beside the capped rows, because a list silently cut at
|
|
1128
|
-
// twelve answers "what is running in here" with a number that is not true.
|
|
1129
|
-
// `wrangler dev` alone opens nine, so the old cap of eight was already
|
|
1130
|
-
// dropping a row on an ordinary stack with nothing on the wire to say so.
|
|
1131
|
-
const lis = measureListeners(wt);
|
|
1132
|
-
/**
|
|
1133
|
-
* WHICH BOX MEASURED THIS — on an agent's report only.
|
|
1134
|
-
*
|
|
1135
|
-
* The server stores it on the agent row so a LATER turn can be checked
|
|
1136
|
-
* against the box that actually holds the work: an agent's branch and its
|
|
1137
|
-
* conversation exist on one machine's disk until somebody approves it, and
|
|
1138
|
-
* two boxes on one credential can both be offered its turns. The daemon
|
|
1139
|
-
* reports what it is; the server does the comparing.
|
|
1140
|
-
*
|
|
1141
|
-
* A daemon→server report on an endpoint that already exists, so no floor —
|
|
1142
|
-
* an older daemon sends no key and the agent is left UNATTRIBUTED, which is
|
|
1143
|
-
* a third state the server reads as "nobody said" rather than as "not this
|
|
1144
|
-
* box". `envpub` is the identity for the same reason the poll uses it: it is
|
|
1145
|
-
* durable per box, and the hostname beside it is only a label for a person
|
|
1146
|
-
* to read. Absent when the keypair is unreadable — that machine is exempt
|
|
1147
|
-
* from arbitration entirely, which is the fail-open direction.
|
|
1148
|
-
*
|
|
1149
|
-
* TABS GET NOTHING. A tab's place is shared by design and its work is a
|
|
1150
|
-
* human's own directory; attributing one would be a fact with no reader.
|
|
1151
|
-
*/
|
|
1152
|
-
const pub = sessionId.startsWith('a-') && sessionId.length > 2 ? myPubB64() : null;
|
|
1153
|
-
/**
|
|
1154
|
-
* …AND WHAT THIS MACHINE PUSHED OF IT (0.86.0).
|
|
1155
|
-
*
|
|
1156
|
-
* The ONE road by which the server learns a push happened: it composes the
|
|
1157
|
-
* target name and sends it, and stores nothing until a machine reports
|
|
1158
|
-
* back. So an unreported push renders nothing and no surface can name a ref
|
|
1159
|
-
* nobody can pull — the same "never assert what you did not observe" rule
|
|
1160
|
-
* the box id beside it keeps.
|
|
1161
|
-
*
|
|
1162
|
-
* NO KEY AT ALL until a target arrives, which is what lets absence keep its
|
|
1163
|
-
* one meaning: an older server, or a project with publishing off, leaves
|
|
1164
|
-
* the agent reading as never published rather than as failed.
|
|
1165
|
-
*
|
|
1166
|
-
* Mutually exclusive without a rule of its own, because the state it reads
|
|
1167
|
-
* holds a sha or an error and never both.
|
|
1168
|
-
*/
|
|
1169
|
-
const pushed = sessionId.startsWith('a-') ? agentPublished.get(sessionId) : null;
|
|
1170
|
-
return {
|
|
1171
|
-
sessionId,
|
|
1172
|
-
...d,
|
|
1173
|
-
...(pub ? { box: { id: pub, name: MACHINE_HOST } } : {}),
|
|
1174
|
-
...(pushed?.sha ? { published: { ref: pushed.ref, sha: pushed.sha } } : {}),
|
|
1175
|
-
...(pushed?.error ? { publishError: pushed.error } : {}),
|
|
1176
|
-
listening: lis.rows,
|
|
1177
|
-
listeningTotal: lis.total,
|
|
1178
|
-
listeningSupported: listenersSupported(),
|
|
1179
|
-
...(proc === null ? {} : { processes: proc.rows, processesTotal: proc.total }),
|
|
1180
|
-
processesSupported: processesSupported(),
|
|
1181
|
-
...(title ? { title } : {}),
|
|
1182
|
-
};
|
|
1183
|
-
};
|
|
1184
|
-
/** One session, now. */
|
|
1185
|
-
const reportSessionWorktree = async (sessionId) => {
|
|
1186
|
-
const r = sessionWorktreeReport(sessionId);
|
|
1187
|
-
if (r) await postWorktrees([r]);
|
|
1188
|
-
};
|
|
1189
|
-
|
|
1190
|
-
/**
|
|
1191
|
-
* …AND EVERY OTHER TAB STANDING IN THE SAME DIRECTORY.
|
|
1192
|
-
*
|
|
1193
|
-
* One tab is not one directory any more. Since tabs moved into the driver's
|
|
1194
|
-
* own folder, every tab a person owns resolves to the SAME place — so a turn
|
|
1195
|
-
* in tab A changed the directory tab B is also describing, and only tab A was
|
|
1196
|
-
* re-measured. Tab B went on rendering its pre-turn `+A −D` for up to a
|
|
1197
|
-
* minute, which makes the tab strip visibly disagree with itself about one
|
|
1198
|
-
* directory. That is the "why are two tabs showing one dev server" confusion
|
|
1199
|
-
* the places readout exists to END, arriving through the diffstat instead.
|
|
1200
|
-
*
|
|
1201
|
-
* It fires on EVERY turn, every ship and every stop, which is what made this
|
|
1202
|
-
* the most-hit instance of the rule and the least visible: nothing is wrong
|
|
1203
|
-
* on the tab you are looking at.
|
|
1204
|
-
*
|
|
1205
|
-
* Bounded by the live set the roster last handed us, and the reports go in
|
|
1206
|
-
* ONE post — the endpoint is already batched, and a tab per request would
|
|
1207
|
-
* turn a five-tab place into five round trips on every settle.
|
|
1208
|
-
*/
|
|
1209
|
-
const reportPlaceWorktrees = async (sessionId) => {
|
|
1210
|
-
const place = placeOf(sessionId);
|
|
1211
|
-
const ids = [sessionId];
|
|
1212
|
-
// `worktreeSeen` is the roster's own live set, pruned to `activeWorkSessions`
|
|
1213
|
-
// on every sweep — so this can never report a tab that has closed, and it
|
|
1214
|
-
// needs no second source of truth about which tabs exist.
|
|
1215
|
-
for (const id of worktreeSeen) {
|
|
1216
|
-
if (id !== sessionId && placeOf(id) === place) ids.push(id);
|
|
1217
|
-
}
|
|
1218
|
-
const reports = ids.map(sessionWorktreeReport).filter(Boolean);
|
|
1219
|
-
if (reports.length) await postWorktrees(reports);
|
|
1220
|
-
};
|
|
1221
|
-
|
|
1222
|
-
/**
|
|
1223
|
-
* THE FIRST MINUTE AFTER A SETTLE — when "run the dev server" actually binds.
|
|
1224
|
-
*
|
|
1225
|
-
* The settle-time report fires the moment the reply lands, but a dev server
|
|
1226
|
-
* the agent just started usually takes a few more seconds to open its socket
|
|
1227
|
-
* (vite boots, next compiles). It therefore missed the settle measurement and
|
|
1228
|
-
* waited the full 60s sweep — up to a minute of "nothing is running here"
|
|
1229
|
-
* over a server that was already up, which is the slowest link in the whole
|
|
1230
|
-
* "ask for dev → see the preview" chain. Asked directly: "how do we make it
|
|
1231
|
-
* more responsive when the user prompts claude to run dev to waiting for it
|
|
1232
|
-
* to appear on the preview?"
|
|
1233
|
-
*
|
|
1234
|
-
* A DECAYING BURST, and it re-CHECKS before it re-REPORTS: each beat walks
|
|
1235
|
-
* /proc for the place's listeners (purely local, no git, no network) and only
|
|
1236
|
-
* when the PORT SET actually changed does the full place report run and post.
|
|
1237
|
-
* A settle where nothing ever binds costs five /proc walks and zero posts; a
|
|
1238
|
-
* dev server that binds at +7s is on the wire at +9 instead of +60. The burst
|
|
1239
|
-
* for a place restarts on its next settle, so overlapping turns cannot stack
|
|
1240
|
-
* timers, and every timer is unref'd — a readout must never hold the process
|
|
1241
|
-
* open.
|
|
1242
|
-
*
|
|
1243
|
-
* This also serves the OPPOSITE transition for free: a stopped dev server
|
|
1244
|
-
* (the panel's Stop, a ctrl-C in a terminal) vanishes from the port set the
|
|
1245
|
-
* same way it appeared, so the preview's "origin gone" story starts in
|
|
1246
|
-
* seconds too.
|
|
1247
|
-
*/
|
|
1248
|
-
const LISTEN_BURST_DELAYS_MS = [4_000, 9_000, 16_000, 30_000, 55_000];
|
|
1249
|
-
const listenBursts = new Map(); // place -> timers[]
|
|
1250
|
-
const listenSignature = (wt) => {
|
|
1251
|
-
try {
|
|
1252
|
-
const l = measureListeners(wt);
|
|
1253
|
-
return l.rows.map((r) => r.port).sort((a, b) => a - b).join(',');
|
|
1254
|
-
} catch {
|
|
1255
|
-
return '';
|
|
1256
|
-
}
|
|
1257
|
-
};
|
|
1258
|
-
const burstListeners = (sessionId) => {
|
|
1259
|
-
try {
|
|
1260
|
-
const place = placeOf(sessionId);
|
|
1261
|
-
for (const t of listenBursts.get(place) ?? []) clearTimeout(t);
|
|
1262
|
-
const wt = placeDir(sessionId);
|
|
1263
|
-
// Captured alongside the settle report, so only a CHANGE after this
|
|
1264
|
-
// moment triggers a post — the settle report already said the rest.
|
|
1265
|
-
let last = listenSignature(wt);
|
|
1266
|
-
const timers = LISTEN_BURST_DELAYS_MS.map((d) =>
|
|
1267
|
-
setTimeout(() => {
|
|
1268
|
-
try {
|
|
1269
|
-
const sig = listenSignature(wt);
|
|
1270
|
-
if (sig === last) return;
|
|
1271
|
-
last = sig;
|
|
1272
|
-
void reportPlaceWorktrees(sessionId).catch(() => {});
|
|
1273
|
-
} catch {
|
|
1274
|
-
/* a readout — the sweep still carries it */
|
|
1275
|
-
}
|
|
1276
|
-
}, d)
|
|
1277
|
-
);
|
|
1278
|
-
for (const t of timers) t.unref?.();
|
|
1279
|
-
listenBursts.set(place, timers);
|
|
1280
|
-
} catch {
|
|
1281
|
-
/* never let the burst break a settle */
|
|
1282
|
-
}
|
|
1283
|
-
};
|
|
1284
|
-
/** Every live session, throttled — called from the reconcile loop. */
|
|
1285
|
-
/**
|
|
1286
|
-
* A SESSION NOBODY HAS MEASURED YET JUMPS THE SWEEP (2026-08-26).
|
|
1287
|
-
*
|
|
1288
|
-
* The throttle is GLOBAL, not per-session, so a tab opened one second after a
|
|
1289
|
-
* sweep waited the remaining fifty-nine for its first measurement — and until
|
|
1290
|
-
* it lands there is no branch, no directory and no listeners anywhere in the
|
|
1291
|
-
* product, because every one of those readouts is gated on a measurement and
|
|
1292
|
-
* renders nothing rather than inventing a state. Asked directly: "how come it
|
|
1293
|
-
* takes a while for a new session to show branch and worktree and listeners
|
|
1294
|
-
* after i create a new tab."
|
|
1295
|
-
*
|
|
1296
|
-
* ATTEMPTED, never MEASURED, is what is remembered. A session whose directory
|
|
1297
|
-
* cannot be read yet — a pre-places daemon that has not cut one, a worktree
|
|
1298
|
-
* mid-creation — would otherwise be "unmeasured" on every poll and force a
|
|
1299
|
-
* full sweep each time. Recording the attempt bounds it at exactly one extra
|
|
1300
|
-
* sweep per session, ever, after which the normal cadence carries it.
|
|
1301
|
-
*/
|
|
1302
|
-
const reportWorktrees = (activeIds) => {
|
|
1303
|
-
if (!Array.isArray(activeIds) || activeIds.length === 0) return;
|
|
1304
|
-
if (sweepingWorktrees) return;
|
|
1305
|
-
const firstSight = activeIds.some((id) => !worktreeSeen.has(id));
|
|
1306
|
-
if (!firstSight && Date.now() - lastWorktreeSweep < WORKTREE_SWEEP_MS) return;
|
|
1307
|
-
// Bounded to LIVE sessions: a long-running daemon must not accumulate a
|
|
1308
|
-
// uuid per tab anyone has ever opened. Pruning also means a reopened tab is
|
|
1309
|
-
// measured immediately again, which is the same answer for the same reason.
|
|
1310
|
-
const live = new Set(activeIds);
|
|
1311
|
-
for (const id of worktreeSeen) if (!live.has(id)) worktreeSeen.delete(id);
|
|
1312
|
-
for (const id of activeIds) worktreeSeen.add(id);
|
|
1313
|
-
// The publish record is bounded the same way and for the same reason: an
|
|
1314
|
-
// agent the roster has stopped naming is done, its ref is the server's
|
|
1315
|
-
// business now (the merge lane deletes it when the work lands), and a
|
|
1316
|
-
// long-running daemon must not accumulate a row per agent that ever ran.
|
|
1317
|
-
for (const id of agentPublished.keys()) if (!live.has(id)) agentPublished.delete(id);
|
|
1318
|
-
for (const id of agentRemoteAt.keys()) if (!live.has(id)) agentRemoteAt.delete(id);
|
|
1319
|
-
pruneSessionGroups(activeIds);
|
|
1320
|
-
sweepingWorktrees = true;
|
|
1321
|
-
lastWorktreeSweep = Date.now();
|
|
1322
|
-
void (async () => {
|
|
1323
|
-
try {
|
|
1324
|
-
// Refresh the base before measuring, so "3 new on main" means what a
|
|
1325
|
-
// person thinks it means. Throttled, best-effort, and never fatal: an
|
|
1326
|
-
// offline machine reports the counts it can still compute.
|
|
1327
|
-
if (Date.now() - lastWorktreeFetch >= WORKTREE_FETCH_MS) {
|
|
1328
|
-
lastWorktreeFetch = Date.now();
|
|
1329
|
-
try {
|
|
1330
|
-
// ASYNC and TIMED: this runs every three minutes with nobody
|
|
1331
|
-
// watching, and a synchronous fetch against a remote that prompts
|
|
1332
|
-
// on /dev/tty or a connection gone half-open froze the whole
|
|
1333
|
-
// daemon — no polls, no settles, no lease renewals — until it
|
|
1334
|
-
// returned. See `gitNetAsync`.
|
|
1335
|
-
await gitNetAsync(['fetch', 'origin', '--quiet'], repoRoot);
|
|
1336
|
-
} catch {
|
|
1337
|
-
/* offline, no remote, or timed out — the numbers just age */
|
|
1338
|
-
}
|
|
1339
|
-
// The fetch may have moved the base tip — walk and report what
|
|
1340
|
-
// landed. Best-effort like everything in this sweep.
|
|
1341
|
-
void landed.observe().catch(() => {});
|
|
1342
|
-
}
|
|
1343
|
-
/**
|
|
1344
|
-
* MEASURED IN CHUNKS, not truncated to one.
|
|
1345
|
-
*
|
|
1346
|
-
* The server's endpoint takes twenty entries per request, and this read
|
|
1347
|
-
* that bound as "measure twenty places" — so everything past the
|
|
1348
|
-
* twentieth was silently never measured, and the server builds that
|
|
1349
|
-
* list with agent places at the END. A project with twenty live tabs
|
|
1350
|
-
* therefore measured no agent at all: their review pane showed no
|
|
1351
|
-
* branch diff, `checkIsStale` had no head to compare against, and their
|
|
1352
|
-
* commits never reached the cards they name.
|
|
1353
|
-
*
|
|
1354
|
-
* The cap on a REQUEST is not a cap on the WORK. Several requests of
|
|
1355
|
-
* twenty cost several round trips and each is validated per entry
|
|
1356
|
-
* exactly as before, so no contract changes and no floor is needed.
|
|
1357
|
-
*
|
|
1358
|
-
* `SWEEP_MAX_PLACES` is a bound on the MACHINE — each place costs a
|
|
1359
|
-
* `git diff`, a listener scan and a process scan — and the cursor
|
|
1360
|
-
* rotates so a project past it still measures everything, just across
|
|
1361
|
-
* successive sweeps instead of one.
|
|
1362
|
-
*/
|
|
1363
|
-
const SWEEP_MAX_PLACES = 60;
|
|
1364
|
-
const CHUNK = 20;
|
|
1365
|
-
const total = activeIds.length;
|
|
1366
|
-
const start = total > SWEEP_MAX_PLACES ? worktreeCursor % total : 0;
|
|
1367
|
-
const take = Math.min(total, SWEEP_MAX_PLACES);
|
|
1368
|
-
// Rotated slice, so the tail of a long list leads the next sweep rather
|
|
1369
|
-
// than never being reached.
|
|
1370
|
-
const order = Array.from({ length: take }, (_, i) => activeIds[(start + i) % total]);
|
|
1371
|
-
worktreeCursor = total > SWEEP_MAX_PLACES ? (start + take) % total : 0;
|
|
1372
|
-
const reports = [];
|
|
1373
|
-
for (const id of order) {
|
|
1374
|
-
/**
|
|
1375
|
-
* KEEP A PUBLISHED BRANCH CURRENT, not merely born.
|
|
1376
|
-
*
|
|
1377
|
-
* A settle publishes what the turn just wrote, which covers almost
|
|
1378
|
-
* everything — but a branch also moves without a turn: the stale
|
|
1379
|
-
* path folds base in before a merge, and an operator can commit in
|
|
1380
|
-
* the agent's worktree by hand. Without this the remote ref would sit
|
|
1381
|
-
* at whatever the last turn left and the durability claim would be
|
|
1382
|
-
* quietly false for exactly the branches somebody is working on.
|
|
1383
|
-
*
|
|
1384
|
-
* ONLY where a target is already known. Nothing here composes a name,
|
|
1385
|
-
* so an agent this process has never been told to publish is
|
|
1386
|
-
* untouched — and the sha compare inside makes the resting cost of
|
|
1387
|
-
* this loop one `rev-parse` per agent.
|
|
1388
|
-
*/
|
|
1389
|
-
const known = agentPublished.get(id);
|
|
1390
|
-
if (known?.ref) await publishAgentBranch(id, known.ref);
|
|
1391
|
-
const r = sessionWorktreeReport(id);
|
|
1392
|
-
if (r) reports.push(r);
|
|
1393
|
-
}
|
|
1394
|
-
// One POST per chunk. Awaited in sequence rather than fired together:
|
|
1395
|
-
// this runs on the machine's own poll beat and a burst of parallel
|
|
1396
|
-
// writes to the same rows buys nothing.
|
|
1397
|
-
for (let i = 0; i < reports.length; i += CHUNK) {
|
|
1398
|
-
await postWorktrees(reports.slice(i, i + CHUNK));
|
|
1399
|
-
}
|
|
1400
|
-
} finally {
|
|
1401
|
-
sweepingWorktrees = false;
|
|
1402
|
-
}
|
|
1403
|
-
})();
|
|
1404
|
-
};
|
|
1405
|
-
|
|
1406
|
-
/**
|
|
1407
|
-
* FILES THE HUMAN ATTACHED, brought to where a CLI can read them.
|
|
1408
|
-
*
|
|
1409
|
-
* A screenshot in a chat bubble is useless to an agent; a path is not. So the
|
|
1410
|
-
* turn's attachments are downloaded into `.flowviant/uploads/` inside the
|
|
1411
|
-
* session's own worktree and the prompt is handed the relative paths.
|
|
1412
|
-
*
|
|
1413
|
-
* `.flowviant/` rather than the repo proper, and gitignored-or-not it is
|
|
1414
|
-
* never committed by us: these are the human's inputs to a conversation, not
|
|
1415
|
-
* project files. The name is re-sanitized HERE even though the server already
|
|
1416
|
-
* did it — this string becomes a path on someone's machine, and one place
|
|
1417
|
-
* doing that check is one deploy away from being zero places.
|
|
1418
|
-
*/
|
|
1419
|
-
const UPLOAD_DIR = '.flowviant/uploads';
|
|
1420
|
-
const ATTACHMENT_MAX_BYTES = 10 * 1024 * 1024;
|
|
1421
|
-
// `safeFileName`, verbatim (apps/api/src/routes/sessionsAttachments.routes.ts):
|
|
1422
|
-
// THE EXTENSION SURVIVES A CUT (2026-09-24). A bare `slice(0, 80)` used to
|
|
1423
|
-
// truncate an over-long name mid-extension; an over-long stem is now cut
|
|
1424
|
-
// and an 8-hex FNV-1a hash of the WHOLE sanitised name is appended before
|
|
1425
|
-
// the extension — stable, at most 80 characters, and idempotent over its
|
|
1426
|
-
// own output, so re-applying it to what the server already sanitized is a
|
|
1427
|
-
// no-op and the two sides never disagree about the cut.
|
|
1428
|
-
const safeUploadFnv1a8 = (s) => {
|
|
1429
|
-
let h = 0x811c9dc5;
|
|
1430
|
-
for (let i = 0; i < s.length; i++) {
|
|
1431
|
-
h ^= s.charCodeAt(i);
|
|
1432
|
-
h = Math.imul(h, 0x01000193);
|
|
1433
|
-
}
|
|
1434
|
-
return (h >>> 0).toString(16).padStart(8, '0');
|
|
1435
|
-
};
|
|
1436
|
-
const SAFE_UPLOAD_EXT_RE = /^[A-Za-z0-9]{1,10}$/;
|
|
1437
|
-
const safeUploadName = (raw) => {
|
|
1438
|
-
const clean = String(raw ?? '')
|
|
1439
|
-
.split(/[\\/]/)
|
|
1440
|
-
.pop()
|
|
1441
|
-
.replace(/[^A-Za-z0-9._-]/g, '_')
|
|
1442
|
-
.replace(/^[.-]+/, '');
|
|
1443
|
-
if (!clean) return 'attachment';
|
|
1444
|
-
if (clean.length <= 80) return clean;
|
|
1445
|
-
const dot = clean.lastIndexOf('.');
|
|
1446
|
-
const ext = dot > 0 && SAFE_UPLOAD_EXT_RE.test(clean.slice(dot + 1)) ? clean.slice(dot + 1) : '';
|
|
1447
|
-
const stem = ext ? clean.slice(0, dot) : clean;
|
|
1448
|
-
const tag = `-${safeUploadFnv1a8(clean)}`;
|
|
1449
|
-
const room = 80 - tag.length - (ext ? ext.length + 1 : 0);
|
|
1450
|
-
return `${stem.slice(0, room)}${tag}${ext ? `.${ext}` : ''}`;
|
|
1451
|
-
};
|
|
1452
|
-
/** @returns relative paths written, in the order the human attached them. */
|
|
1453
|
-
const fetchAttachments = async (wt, attachments) => {
|
|
1454
|
-
if (!Array.isArray(attachments) || attachments.length === 0) return [];
|
|
1455
|
-
const dir = join(wt, UPLOAD_DIR);
|
|
1456
|
-
try {
|
|
1457
|
-
mkdirSync(dir, { recursive: true });
|
|
1458
|
-
} catch {
|
|
1459
|
-
return [];
|
|
1460
|
-
}
|
|
1461
|
-
// "Never committed by us" has to be true for GIT, not just for this code:
|
|
1462
|
-
// an untracked `.flowviant/` makes the whole worktree dirty, which refuses
|
|
1463
|
-
// every ship, exempts the tree from closed-tab retirement forever, and
|
|
1464
|
-
// shows the human's own uploads in the rail as session changes. Same
|
|
1465
|
-
// mechanism the materialized env files used until the vault was deleted —
|
|
1466
|
-
// the exclude file git actually reads (git.mjs, where the helper moved when
|
|
1467
|
-
// env.mjs shrank), which already skips lines it has written before, so
|
|
1468
|
-
// calling it per fetch is idempotent. NARROWED 2026-09-23 to the paths
|
|
1469
|
-
// this daemon writes (`FLOWVIANT_OWN_PATHS`, knowledge.mjs says why): the
|
|
1470
|
-
// whole-directory line also hid a repo's own new `.flowviant/check.json`
|
|
1471
|
-
// from its agent's `git add -A`.
|
|
1472
|
-
excludeInWorktree(wt, FLOWVIANT_OWN_PATHS);
|
|
1473
|
-
const written = [];
|
|
1474
|
-
for (const a of attachments.slice(0, 8)) {
|
|
1475
|
-
if (!a?.id || typeof a.id !== 'string' || !/^[0-9a-f-]{8,64}$/i.test(a.id)) continue;
|
|
1476
|
-
if (Number(a.size) > ATTACHMENT_MAX_BYTES) continue;
|
|
1477
|
-
try {
|
|
1478
|
-
const res = await fetch(`${ATTACHMENT_URL}/${a.id}`, {
|
|
1479
|
-
headers: { Authorization: `Bearer ${FLEET_TOKEN}`, 'User-Agent': USER_AGENT },
|
|
1480
|
-
signal: AbortSignal.timeout(60_000),
|
|
1481
|
-
});
|
|
1482
|
-
if (!res.ok) continue;
|
|
1483
|
-
const buf = Buffer.from(await res.arrayBuffer());
|
|
1484
|
-
if (buf.byteLength === 0 || buf.byteLength > ATTACHMENT_MAX_BYTES) continue;
|
|
1485
|
-
// Collisions are real (two screenshots both named Screenshot.png), and
|
|
1486
|
-
// silently overwriting one with the other loses a file the human sent.
|
|
1487
|
-
let name = safeUploadName(a.name);
|
|
1488
|
-
if (existsSync(join(dir, name))) {
|
|
1489
|
-
const dot = name.lastIndexOf('.');
|
|
1490
|
-
const stem = dot > 0 ? name.slice(0, dot) : name;
|
|
1491
|
-
const ext = dot > 0 ? name.slice(dot) : '';
|
|
1492
|
-
name = `${stem}-${String(a.id).slice(0, 6)}${ext}`;
|
|
1493
|
-
}
|
|
1494
|
-
writeFileSync(join(dir, name), buf);
|
|
1495
|
-
written.push(`${UPLOAD_DIR}/${name}`);
|
|
1496
|
-
} catch {
|
|
1497
|
-
/* one file failing must not fail the turn — the prompt lists what
|
|
1498
|
-
actually arrived, so the agent never chases a path that isn't there */
|
|
1499
|
-
}
|
|
1500
|
-
}
|
|
1501
|
-
return written;
|
|
1502
|
-
};
|
|
1503
|
-
|
|
1504
|
-
let flushingReports = false;
|
|
1505
|
-
const flushWorkReports = async () => {
|
|
1506
|
-
// Held artifact uploads ride this beat too — the settle retry's own shape —
|
|
1507
|
-
// and are never awaited: a readout's retry must not hold a settle's.
|
|
1508
|
-
void artifacts.retryPending().catch(() => {});
|
|
1509
|
-
if (flushingReports) return;
|
|
1510
|
-
if (pendingWorkReports.size === 0 && pendingShipReports.size === 0) return;
|
|
1511
|
-
flushingReports = true;
|
|
1512
|
-
try {
|
|
1513
|
-
for (const [id, body] of [...pendingWorkReports]) {
|
|
1514
|
-
// A rejected body sits out its backoff; the queued entry itself stays
|
|
1515
|
-
// — it is the skip-guard against re-running a turn whose side effects
|
|
1516
|
-
// already happened.
|
|
1517
|
-
if ((reportBackoff.get(id) ?? 0) > Date.now()) continue;
|
|
1518
|
-
const r = await postSettle(WORK_DONE_URL, body, [403, 404]);
|
|
1519
|
-
if (r === 'reject') reportBackoff.set(id, Date.now() + REJECT_RETRY_MS);
|
|
1520
|
-
else if (r !== 'retry') {
|
|
1521
|
-
pendingWorkReports.delete(id);
|
|
1522
|
-
workAttempts.delete(id);
|
|
1523
|
-
reportBackoff.delete(id);
|
|
1524
|
-
}
|
|
1525
|
-
}
|
|
1526
|
-
for (const [id, body] of [...pendingShipReports]) {
|
|
1527
|
-
if ((reportBackoff.get(id) ?? 0) > Date.now()) continue;
|
|
1528
|
-
const r = await postSettle(SHIP_DONE_URL, body, [403, 409]);
|
|
1529
|
-
if (r === 'reject') reportBackoff.set(id, Date.now() + REJECT_RETRY_MS);
|
|
1530
|
-
else if (r !== 'retry') {
|
|
1531
|
-
pendingShipReports.delete(id);
|
|
1532
|
-
reportBackoff.delete(id);
|
|
1533
|
-
// Delivered late is still delivered — same sweep as the immediate
|
|
1534
|
-
// path, and it must be here too or a report that needed a retry
|
|
1535
|
-
// would leave its branch behind forever.
|
|
1536
|
-
sweepMergedSessionBranch(id);
|
|
1537
|
-
}
|
|
1538
|
-
}
|
|
1539
|
-
} finally {
|
|
1540
|
-
flushingReports = false;
|
|
1541
|
-
}
|
|
1542
|
-
};
|
|
1543
|
-
|
|
1544
|
-
/**
|
|
1545
|
-
* The work credential, ONE PER SESSION. The server binds each minted token
|
|
1546
|
-
* to the sessionId in the mint body and the MCP layer refuses it for any
|
|
1547
|
-
* other session, so a process-wide token would fail every tab but the one
|
|
1548
|
-
* that minted it. Cached per session, re-minted near expiry (the endpoint
|
|
1549
|
-
* rotates on every mint; per-session chaining means no turn is in flight
|
|
1550
|
-
* for the session when its next turn mints). 404 means the server no longer
|
|
1551
|
-
* holds that session for this fleet — a fact for the turn to settle with,
|
|
1552
|
-
* not a retry.
|
|
1553
|
-
*/
|
|
1554
|
-
const workTokens = new Map(); // sessionId -> { token, mintedAt }
|
|
1555
|
-
const mintWorkToken = async (sessionId, force = false) => {
|
|
1556
|
-
const cached = workTokens.get(sessionId);
|
|
1557
|
-
const freshEnoughS = getLeaseTtl() - REFRESH_BEFORE_SECONDS;
|
|
1558
|
-
if (cached && !force && (Date.now() - cached.mintedAt) / 1000 < freshEnoughS)
|
|
1559
|
-
return { token: cached.token };
|
|
1560
|
-
try {
|
|
1561
|
-
const res = await fetch(WORK_TOKEN_URL, {
|
|
1562
|
-
method: 'POST',
|
|
1563
|
-
headers: {
|
|
1564
|
-
Authorization: `Bearer ${FLEET_TOKEN}`,
|
|
1565
|
-
'User-Agent': USER_AGENT,
|
|
1566
|
-
'Content-Type': 'application/json',
|
|
1567
|
-
},
|
|
1568
|
-
signal: AbortSignal.timeout(30_000),
|
|
1569
|
-
// The instance is what CLAIMS the session lease server-side. Two
|
|
1570
|
-
// daemons share one fleet credential, so the token cannot say which of
|
|
1571
|
-
// us is serving this tab — and the mint is the moment that matters:
|
|
1572
|
-
// there is one work-token row per session and minting ROTATES it, so a
|
|
1573
|
-
// second mint revokes the first daemon's live secret mid-turn.
|
|
1574
|
-
body: JSON.stringify({ sessionId, instance: DAEMON_INSTANCE }),
|
|
1575
|
-
});
|
|
1576
|
-
if (res.status === 404) return { gone: true };
|
|
1577
|
-
// 409 — another daemon on this credential holds the session. Not ours to
|
|
1578
|
-
// serve and not a retry: stand down and let the holder answer.
|
|
1579
|
-
if (res.status === 409) return { heldElsewhere: true };
|
|
1580
|
-
if (!res.ok) return null;
|
|
1581
|
-
const token = (await res.json().catch(() => null))?.data?.token ?? null;
|
|
1582
|
-
if (!token) return null;
|
|
1583
|
-
workTokens.set(sessionId, { token, mintedAt: Date.now() });
|
|
1584
|
-
return { token };
|
|
1585
|
-
} catch {
|
|
1586
|
-
return null;
|
|
1587
|
-
}
|
|
1588
|
-
};
|
|
1589
|
-
|
|
1590
|
-
/**
|
|
1591
|
-
* This tab's worktree — its held context, expressed as a place, ON A BRANCH.
|
|
1592
|
-
*
|
|
1593
|
-
* Fresh: branch `session/<id>` off the current base. Existing: touched not at
|
|
1594
|
-
* all — no fetch-reset-clean like a plan directory, because the dirty state
|
|
1595
|
-
* is the point. If the directory was retired but the branch survives, the
|
|
1596
|
-
* worktree re-attaches to the branch and the committed work is still there.
|
|
1597
|
-
*/
|
|
1598
|
-
/**
|
|
1599
|
-
* WHERE A SESSION WORKS — its PLACE, which is a directory on a branch.
|
|
1600
|
-
*
|
|
1601
|
-
* A session used to BE a worktree: one tab, one directory, cut at birth and
|
|
1602
|
-
* retired at close. That binding was never an isolation guarantee — a turn
|
|
1603
|
-
* runs with permissions skipped, so the worktree is a starting directory and
|
|
1604
|
-
* not a fence, and any agent could always `cd` into another one. The product
|
|
1605
|
-
* was asserting an invariant it did not have.
|
|
1606
|
-
*
|
|
1607
|
-
* So a session now REFERENCES a place rather than being one. Many sessions
|
|
1608
|
-
* may name the same place; a session may name the repo checkout itself; and
|
|
1609
|
-
* `session/<own-id>` is simply the DEFAULT place, cut fresh at first turn,
|
|
1610
|
-
* which is why an absent `place` behaves exactly as every existing tab does.
|
|
1611
|
-
*
|
|
1612
|
-
* `'repo'` IS NOT A DIRECTORY NAME AND MUST NOT BECOME ONE. It resolves to
|
|
1613
|
-
* the checkout the daemon already serves — never created, never retired,
|
|
1614
|
-
* because it is not ours to remove. The value reaching here is a server-side
|
|
1615
|
-
* enum, never a browser-supplied path: `sessions.routes.ts` resolves it the
|
|
1616
|
-
* same way adoption resolves a cwd, and for the same reason.
|
|
1617
|
-
*/
|
|
1618
|
-
const placeWtFor = (placeId, baseAt) => {
|
|
1619
|
-
if (placeId === REPO_PLACE) {
|
|
1620
|
-
// The checkout. `fresh: false` on purpose — nothing was opened, so no
|
|
1621
|
-
// caller may treat this as a newly-cut branch.
|
|
1622
|
-
return { wt: repoRoot, fresh: false };
|
|
1623
|
-
}
|
|
1624
|
-
if (!isSafePathSegment(placeId)) return null;
|
|
1625
|
-
const sessionId = placeId;
|
|
1626
|
-
const wt = join(baseDir, 'sessions', sessionId);
|
|
1627
|
-
const fresh = !existsSync(wt);
|
|
1628
|
-
if (fresh) {
|
|
1629
|
-
const branch = `session/${sessionId}`;
|
|
1630
|
-
// `baseAt` is the adoption override: a tab born from a terminal session
|
|
1631
|
-
// branches from THAT checkout's HEAD, because the conversation being
|
|
1632
|
-
// resumed was had against those commits — putting it on the project base
|
|
1633
|
-
// would hand it a repo state it has never seen. Everything else is
|
|
1634
|
-
// unchanged, the attach fallback included: a surviving branch already
|
|
1635
|
-
// chose its base, and re-basing it here would move committed work.
|
|
1636
|
-
const at = baseAt || baseRef();
|
|
1637
|
-
try {
|
|
1638
|
-
git(['worktree', 'add', '-b', branch, wt, at], repoRoot);
|
|
1639
|
-
} catch {
|
|
1640
|
-
git(['worktree', 'prune'], repoRoot);
|
|
1641
|
-
// A directory and a branch just stopped existing. The Repository block
|
|
1642
|
-
// would otherwise keep listing both until its own 60s scan came round.
|
|
1643
|
-
onRepoChanged();
|
|
1644
|
-
try {
|
|
1645
|
-
// The branch may already exist (a retired directory's work) — attach.
|
|
1646
|
-
git(['worktree', 'add', wt, branch], repoRoot);
|
|
1647
|
-
} catch {
|
|
1648
|
-
try {
|
|
1649
|
-
git(['worktree', 'add', '-b', branch, wt, at], repoRoot);
|
|
1650
|
-
} catch {
|
|
1651
|
-
return null;
|
|
1652
|
-
}
|
|
1653
|
-
}
|
|
1654
|
-
}
|
|
1655
|
-
// NOTHING IS MATERIALIZED INTO A FRESH WORKTREE ANY MORE (2026-09-21),
|
|
1656
|
-
// and nothing replaced it.
|
|
1657
|
-
//
|
|
1658
|
-
// Two blocks stood here: one that wrote the decrypted vault bundle into a
|
|
1659
|
-
// newly-created worktree, and a second that retried on the next turn for
|
|
1660
|
-
// the restart window where the bundle had not warmed yet. The vault is
|
|
1661
|
-
// deleted — the owner: "no i dont want it" — so Flowviant holds no secret
|
|
1662
|
-
// for this project and has nothing to write.
|
|
1663
|
-
//
|
|
1664
|
-
// WHAT THE SOURCE OF A WORKTREE'S ENV IS NOW: the repo's own `.env`
|
|
1665
|
-
// files, wherever the repo puts them. A worktree branches from the
|
|
1666
|
-
// checkout, and a gitignored `.env` is by definition not in a fresh one —
|
|
1667
|
-
// which is the ordinary behaviour of `git worktree add`, the same thing a
|
|
1668
|
-
// person gets typing it themselves, and the operator's problem to solve
|
|
1669
|
-
// the way they already solve it (a symlink, a `direnv`, a copy in a
|
|
1670
|
-
// setup script). This product no longer claims otherwise, and the claim
|
|
1671
|
-
// is what was worth deleting: a promise that every session tab comes up
|
|
1672
|
-
// with the team's secrets is only kept while a vault exists to keep it.
|
|
1673
|
-
//
|
|
1674
|
-
// The CLI child still inherits the daemon's own environment exactly as it
|
|
1675
|
-
// always did (claude.mjs spawns with `{...process.env}`), so anything
|
|
1676
|
-
// exported in the shell the daemon was started from is present here.
|
|
1677
|
-
}
|
|
1678
|
-
return { wt, fresh };
|
|
1679
|
-
};
|
|
1680
|
-
|
|
1681
|
-
/**
|
|
1682
|
-
* A file in the worktree's PRIVATE git dir (…/.git/worktrees/<name>). It
|
|
1683
|
-
* travels with the worktree, dies with `git worktree remove`, and is
|
|
1684
|
-
* invisible to `git status` — so nothing stored here can ever make the
|
|
1685
|
-
* session look dirty (a dirty tree refuses ships). A marker file in the
|
|
1686
|
-
* working tree itself would show up as an untracked path and block every
|
|
1687
|
-
* ship of an otherwise-clean session.
|
|
1688
|
-
*/
|
|
1689
|
-
/**
|
|
1690
|
-
* A file beside the worktree's git dir, holding something about ONE TAB.
|
|
1691
|
-
*
|
|
1692
|
-
* `scope` is the session id and is NOT optional for anything per-tab. These
|
|
1693
|
-
* markers were named bare — `flowviant-codex-thread`, `flowviant-agy-
|
|
1694
|
-
* conversation` — which was unambiguous while one directory meant one tab.
|
|
1695
|
-
* The day tabs moved into their driver's project folder, every tab there
|
|
1696
|
-
* started reading and writing ONE marker: tab B would resume tab A's codex
|
|
1697
|
-
* thread, and the last turn to finish would overwrite the id for both.
|
|
1698
|
-
*
|
|
1699
|
-
* The turn LOCK is deliberately still un-scoped — it guards the directory
|
|
1700
|
-
* against a second CLI, which is a property of the place and not of a tab.
|
|
1701
|
-
*/
|
|
1702
|
-
const sessionMetaPath = (wt, name, scope) => {
|
|
1703
|
-
try {
|
|
1704
|
-
const safe = scope && /^[A-Za-z0-9_-]{1,64}$/.test(String(scope)) ? `-${scope}` : '';
|
|
1705
|
-
return join(git(['rev-parse', '--absolute-git-dir'], wt), `${name}${safe}`);
|
|
1706
|
-
} catch {
|
|
1707
|
-
return null;
|
|
1708
|
-
}
|
|
1709
|
-
};
|
|
1710
|
-
|
|
1711
|
-
/**
|
|
1712
|
-
* Carry a terminal checkout's DIRTY state into a fresh adopt worktree. The
|
|
1713
|
-
* source is strictly READ-ONLY — nothing here writes to it, because it is
|
|
1714
|
-
* the human's own checkout and adoption promises to leave it exactly as the
|
|
1715
|
-
* closed terminal did. Tracked changes travel as one binary patch staged
|
|
1716
|
-
* through the worktree's PRIVATE git dir (invisible to status, dies with the
|
|
1717
|
-
* tree); untracked files are copied one by one, skipping anything over 5MB.
|
|
1718
|
-
*
|
|
1719
|
-
* Returns '' or ONE bracketed line for the turn's prompt: a carry problem is
|
|
1720
|
-
* the AGENT's to explain to the user, never a reason to fail the adoption —
|
|
1721
|
-
* the conversation is the thing being adopted, and it resumes either way.
|
|
1722
|
-
*/
|
|
1723
|
-
const carryDirtyState = (srcCwd, wt, sessionId) => {
|
|
1724
|
-
const problems = [];
|
|
1725
|
-
try {
|
|
1726
|
-
// A Buffer, not utf8: a `--binary` patch (and a hunk from a non-UTF-8
|
|
1727
|
-
// text file) must round-trip byte-exact or the apply corrupts what it
|
|
1728
|
-
// carries. 64MB of headroom — a dirtier tree than that fails the read
|
|
1729
|
-
// here and is SAID, below, rather than half-applied.
|
|
1730
|
-
const patch = execFileSync('git', ['diff', 'HEAD', '--binary'], {
|
|
1731
|
-
cwd: srcCwd,
|
|
1732
|
-
stdio: ['ignore', 'pipe', 'pipe'],
|
|
1733
|
-
maxBuffer: 64 * 1024 * 1024,
|
|
1734
|
-
});
|
|
1735
|
-
if (patch.length) {
|
|
1736
|
-
const patchPath = sessionMetaPath(wt, 'flowviant-adopt.patch', sessionId);
|
|
1737
|
-
if (!patchPath) throw new Error('no private git dir to stage the patch in');
|
|
1738
|
-
try {
|
|
1739
|
-
writeFileSync(patchPath, patch);
|
|
1740
|
-
git(['apply', '--whitespace=nowarn', patchPath], wt);
|
|
1741
|
-
} finally {
|
|
1742
|
-
try {
|
|
1743
|
-
rmSync(patchPath, { force: true });
|
|
1744
|
-
} catch {
|
|
1745
|
-
/* best-effort — the private git dir dies with the worktree anyway */
|
|
1746
|
-
}
|
|
1747
|
-
}
|
|
1748
|
-
}
|
|
1749
|
-
} catch {
|
|
1750
|
-
problems.push(
|
|
1751
|
-
'their uncommitted TRACKED changes did not carry over (they are still in the terminal checkout, untouched)'
|
|
1752
|
-
);
|
|
1753
|
-
}
|
|
1754
|
-
try {
|
|
1755
|
-
const skipped = [];
|
|
1756
|
-
for (const rel of splitNul(
|
|
1757
|
-
gitRaw(['ls-files', '--others', '--exclude-standard', '-z'], srcCwd)
|
|
1758
|
-
)) {
|
|
1759
|
-
try {
|
|
1760
|
-
const from = join(srcCwd, rel);
|
|
1761
|
-
// lstat, not stat: a symlink is carried as itself, and its own size
|
|
1762
|
-
// is what the 5MB budget judges — never the file it points at.
|
|
1763
|
-
if (lstatSync(from).size > 5 * 1024 * 1024) {
|
|
1764
|
-
skipped.push(rel);
|
|
1765
|
-
continue;
|
|
1766
|
-
}
|
|
1767
|
-
const to = join(wt, rel);
|
|
1768
|
-
mkdirSync(dirname(to), { recursive: true });
|
|
1769
|
-
cpSync(from, to);
|
|
1770
|
-
} catch {
|
|
1771
|
-
skipped.push(rel);
|
|
1772
|
-
}
|
|
1773
|
-
}
|
|
1774
|
-
if (skipped.length) {
|
|
1775
|
-
problems.push(
|
|
1776
|
-
`${skipped.length} untracked file${skipped.length === 1 ? '' : 's'} did not carry (over 5MB or unreadable): ${skipped.slice(0, 5).join(', ')}${skipped.length > 5 ? ', …' : ''}`
|
|
1777
|
-
);
|
|
1778
|
-
}
|
|
1779
|
-
} catch {
|
|
1780
|
-
problems.push(
|
|
1781
|
-
'untracked files could not be listed in the terminal checkout, so none were carried'
|
|
1782
|
-
);
|
|
1783
|
-
}
|
|
1784
|
-
return problems.length
|
|
1785
|
-
? `[ADOPTION NOTE from the daemon — tell the user plainly at the start of your reply: ${problems.join('; ')}.]`
|
|
1786
|
-
: '';
|
|
1787
|
-
};
|
|
1788
|
-
|
|
1789
|
-
/**
|
|
1790
|
-
* WHICH CLI drives this session — picked ONCE, on the first turn, and pinned
|
|
1791
|
-
* in the worktree's meta dir. The held context belongs to the CLI that made
|
|
1792
|
-
* it: `--continue` under a different binary is a different brain wearing the
|
|
1793
|
-
* session's half-finished state (the dispatch path pins heldRuntime for the
|
|
1794
|
-
* same reason). If the pinned CLI has left the machine, the turn settles
|
|
1795
|
-
* honestly instead of substituting. A retired-and-reattached directory has
|
|
1796
|
-
* no marker and no held context either, so re-picking there is correct.
|
|
1797
|
-
*
|
|
1798
|
-
* THE SERVER'S WORD COMES FIRST. A tab is created AS a runtime's tab
|
|
1799
|
-
* (`job.runtime`; null/absent = Claude, which is what every tab ran on until
|
|
1800
|
-
* now), so on the first turn a named runtime IS the pick — never a
|
|
1801
|
-
* preference the machine may override. And a named runtime that DISAGREES
|
|
1802
|
-
* with an existing pin is an identity change mid-life: something upstream
|
|
1803
|
-
* now calls this tab a different brain's, and the only honest move is to
|
|
1804
|
-
* settle the turn and say so ({ mismatch }), because a held context must
|
|
1805
|
-
* never be answered by a different brain.
|
|
1806
|
-
*
|
|
1807
|
-
* Returns { id } | { id: null } (nothing installed) | { missing: label } |
|
|
1808
|
-
* { unsupported: label } (a runtime no session can run on) |
|
|
1809
|
-
* { mismatch: { pin, runtime } } (labels, for the caller's sentence).
|
|
1810
|
-
*
|
|
1811
|
-
* SESSION-CAPABLE means rt.mcp is truthy — the session tools ride a real
|
|
1812
|
-
* per-invocation MCP config — OR the runtime runs tabs PLAIN (Antigravity):
|
|
1813
|
-
* no MCP at all, no cards, no streaming; the final answer is delivered by
|
|
1814
|
-
* the daemon's own report and ship-time reconciliation keeps the ledger
|
|
1815
|
-
* whole. `pickRuntimeFor('build')` is still the WRONG question here — it
|
|
1816
|
-
* says yes to the mediated DISPATCH path without saying how a tab would
|
|
1817
|
-
* speak, and a session pinned by it once threw in mcpFor on every turn.
|
|
1818
|
-
*/
|
|
1819
|
-
const sessionCapable = (rid) =>
|
|
1820
|
-
(Boolean(RUNTIMES[rid]?.mcp) || rid === 'antigravity') && canRun(RUNTIMES[rid], 'build');
|
|
1821
|
-
const sessionRuntime = (wt, jobRuntime, sessionId) => {
|
|
1822
|
-
// SCOPED: two tabs standing in one directory may run different CLIs, and an
|
|
1823
|
-
// unscoped pin would hand the second one the first one's runtime.
|
|
1824
|
-
const marker = sessionMetaPath(wt, 'flowviant-runtime', sessionId);
|
|
1825
|
-
let pinned = null;
|
|
1826
|
-
if (marker && existsSync(marker)) {
|
|
1827
|
-
try {
|
|
1828
|
-
pinned = readFileSync(marker, 'utf8').trim() || null;
|
|
1829
|
-
} catch {
|
|
1830
|
-
/* unreadable marker — re-pin below */
|
|
1831
|
-
}
|
|
1832
|
-
}
|
|
1833
|
-
if (pinned && RUNTIMES[pinned]) {
|
|
1834
|
-
if (jobRuntime && jobRuntime !== pinned) {
|
|
1835
|
-
return {
|
|
1836
|
-
mismatch: {
|
|
1837
|
-
pin: RUNTIMES[pinned].label || pinned,
|
|
1838
|
-
runtime: RUNTIMES[jobRuntime]?.label || jobRuntime,
|
|
1839
|
-
},
|
|
1840
|
-
};
|
|
1841
|
-
}
|
|
1842
|
-
// A pin that names a non-session-capable runtime is settled honestly by
|
|
1843
|
-
// the caller, not silently re-picked: re-picking would hand the held
|
|
1844
|
-
// context to a different brain, which is the exact substitution the pin
|
|
1845
|
-
// exists to prevent.
|
|
1846
|
-
if (!sessionCapable(pinned)) return { unsupported: RUNTIMES[pinned].label || pinned };
|
|
1847
|
-
const installed = detectRuntimes().find((r) => r.id === pinned)?.installed;
|
|
1848
|
-
return installed ? { id: pinned } : { missing: RUNTIMES[pinned].label || pinned };
|
|
1849
|
-
}
|
|
1850
|
-
// First turn, and the server named the brain: that IS the pick, gated the
|
|
1851
|
-
// same two ways as a pin — not session-capable and not installed both
|
|
1852
|
-
// settle honestly via the caller's existing paths, never substituted.
|
|
1853
|
-
if (jobRuntime) {
|
|
1854
|
-
if (!sessionCapable(jobRuntime))
|
|
1855
|
-
return { unsupported: RUNTIMES[jobRuntime]?.label || jobRuntime };
|
|
1856
|
-
const installed = detectRuntimes().find((r) => r.id === jobRuntime)?.installed;
|
|
1857
|
-
if (!installed) return { missing: RUNTIMES[jobRuntime]?.label || jobRuntime };
|
|
1858
|
-
if (marker) {
|
|
1859
|
-
try {
|
|
1860
|
-
writeFileSync(marker, jobRuntime);
|
|
1861
|
-
} catch {
|
|
1862
|
-
/* best-effort — an unpinnable session just re-picks next turn */
|
|
1863
|
-
}
|
|
1864
|
-
}
|
|
1865
|
-
return { id: jobRuntime };
|
|
1866
|
-
}
|
|
1867
|
-
// The fresh pick — Claude first when it qualifies, for the reason
|
|
1868
|
-
// pickRuntimeFor gives: the prompts were tuned against it. DELIBERATELY
|
|
1869
|
-
// NARROWER than sessionCapable: a PLAIN tab (Antigravity — no cards, no
|
|
1870
|
-
// streaming) is a degraded mode someone CHOOSES, so it is honored only
|
|
1871
|
-
// when the server names it, never handed out as a default.
|
|
1872
|
-
const rows = detectRuntimes();
|
|
1873
|
-
const okFor = (rid) =>
|
|
1874
|
-
Boolean(RUNTIMES[rid]?.mcp) &&
|
|
1875
|
-
sessionCapable(rid) &&
|
|
1876
|
-
Boolean(rows.find((r) => r.id === rid)?.installed);
|
|
1877
|
-
const id = okFor('claude') ? 'claude' : (Object.keys(RUNTIMES).find(okFor) ?? null);
|
|
1878
|
-
if (!id) return { id: null };
|
|
1879
|
-
if (marker) {
|
|
1880
|
-
try {
|
|
1881
|
-
writeFileSync(marker, id);
|
|
1882
|
-
} catch {
|
|
1883
|
-
/* best-effort — an unpinnable session just re-picks next turn */
|
|
1884
|
-
}
|
|
1885
|
-
}
|
|
1886
|
-
return { id };
|
|
1887
|
-
};
|
|
1888
|
-
|
|
1889
|
-
/**
|
|
1890
|
-
* The shape a codex thread id must have before it is written to disk or —
|
|
1891
|
-
* decisive — pushed into argv as `resume <id>`. Conservative on purpose:
|
|
1892
|
-
* alphanumeric plus dash/underscore, never a leading dash (an argv that
|
|
1893
|
-
* parses as a flag), never whitespace. Anything else is dropped and the
|
|
1894
|
-
* session simply runs fresh in its own worktree.
|
|
1895
|
-
*/
|
|
1896
|
-
const CODEX_THREAD_RE = /^[0-9a-zA-Z][0-9a-zA-Z_-]{7,63}$/;
|
|
1897
|
-
|
|
1898
|
-
/** agy conversation ids are plain UUIDs (the db filename IS the identity —
|
|
1899
|
-
* measured: a renamed copy fails "trajectory not found"). Guarded the same
|
|
1900
|
-
* way as the codex id: it rides in argv as `--conversation <id>`. */
|
|
1901
|
-
const AGY_CONV_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
|
|
1902
|
-
|
|
1903
|
-
/** agy's own cwd registry — {cwd → the conversation that ran there LAST}.
|
|
1904
|
-
* Read once, right after a fresh agy turn, to learn the id the turn just
|
|
1905
|
-
* created; from then on the tab's marker is the identity and this registry
|
|
1906
|
-
* is never consulted again (a dispatch sharing the machine may overwrite
|
|
1907
|
-
* the cwd's entry between turns). */
|
|
1908
|
-
const agyRegistryLookup = (cwd) => {
|
|
1909
|
-
try {
|
|
1910
|
-
const raw = readFileSync(
|
|
1911
|
-
join(homedir(), '.gemini', 'antigravity-cli', 'cache', 'last_conversations.json'),
|
|
1912
|
-
'utf8'
|
|
1913
|
-
);
|
|
1914
|
-
const map = JSON.parse(raw);
|
|
1915
|
-
if (!map || typeof map !== 'object') return null;
|
|
1916
|
-
// agy keys by the cwd as IT resolved it — try our literal path and its
|
|
1917
|
-
// realpath, so a symlinked home doesn't orphan the lookup.
|
|
1918
|
-
let keys = [cwd];
|
|
1919
|
-
try {
|
|
1920
|
-
keys.push(realpathSync(cwd));
|
|
1921
|
-
} catch {
|
|
1922
|
-
/* the literal alone, then */
|
|
1923
|
-
}
|
|
1924
|
-
for (const k of keys) {
|
|
1925
|
-
const id = map[k];
|
|
1926
|
-
if (typeof id === 'string' && AGY_CONV_RE.test(id)) return id;
|
|
1927
|
-
}
|
|
1928
|
-
return null;
|
|
1929
|
-
} catch {
|
|
1930
|
-
return null;
|
|
1931
|
-
}
|
|
1932
|
-
};
|
|
1933
|
-
|
|
1934
|
-
/**
|
|
1935
|
-
* Live session-turn CLI children. The daemon's teardown SIGTERMs them: an
|
|
1936
|
-
* orphaned CLI keeps editing the session worktree and burning quota after
|
|
1937
|
-
* the daemon is gone. Each child's pid-lock is deliberately LEFT IN PLACE —
|
|
1938
|
-
* a CLI can trap SIGTERM to finish an in-flight request and outlive this
|
|
1939
|
-
* loop by seconds, and removing the lock in the same tick handed the
|
|
1940
|
-
* restarted daemon a green light to spawn a second CLI into the same held
|
|
1941
|
-
* context. turnLockedByLivePid already covers both outcomes: it waits while
|
|
1942
|
-
* the pid lives and clears the lock once it is dead.
|
|
1943
|
-
*
|
|
1944
|
-
* THE VALUE IS THE ID THE CHILD SERVES, and it used to be the pid-lock path —
|
|
1945
|
-
* which nothing in this file has ever read. A write-only value is not free:
|
|
1946
|
-
* the machine snapshot's per-task RSS (`/fleet/machine`) is the one readout
|
|
1947
|
-
* that answers "which task is holding nine gigabytes", and it was being built
|
|
1948
|
-
* from the dispatch-era `workers` map, which nothing has `.set()` since that
|
|
1949
|
-
* lane was deleted — so the column its own server handler calls the
|
|
1950
|
-
* load-bearing half of the report had never been populated once. This map is
|
|
1951
|
-
* the only place that knows both the pid and whose work it is, so it carries
|
|
1952
|
-
* both. `null` where there is no id to name (a Deploy press is not a task).
|
|
1953
|
-
*/
|
|
1954
|
-
const workChildren = new Map(); // child process -> sessionId | agentId | null
|
|
1955
|
-
/**
|
|
1956
|
-
* Children whose whole PROCESS GROUP must go, not just the child.
|
|
1957
|
-
*
|
|
1958
|
-
* The standing rule is the opposite — teardown SIGTERMs the CLI child and
|
|
1959
|
-
* never its group, precisely so an unattended auto-update does not kill the
|
|
1960
|
-
* driver's dev server, which a turn started INTO the CLI's group. That rule
|
|
1961
|
-
* is about the CLI's group and stays.
|
|
1962
|
-
*
|
|
1963
|
-
* A project CHECK is a different group entirely: the daemon spawns it itself,
|
|
1964
|
-
* `detached` with `shell: true`, so its group holds the check command and
|
|
1965
|
-
* nothing else — no dev server of anybody's. And `shell: true` is exactly
|
|
1966
|
-
* what makes signalling only the child useless: a compound command like
|
|
1967
|
-
* `npm run lint && npm test` leaves `/bin/sh` as the child, so SIGTERM killed
|
|
1968
|
-
* the shell and the test runner underneath it carried on holding the
|
|
1969
|
-
* worktree — and that place's WRITER lock — through every stop, takeover and
|
|
1970
|
-
* auto-update. The check's own ten-minute timer already kills `-child.pid`
|
|
1971
|
-
* for this reason; teardown simply did not.
|
|
1972
|
-
*/
|
|
1973
|
-
const groupKillChildren = new Set();
|
|
1974
|
-
const shutdownWork = () => {
|
|
1975
|
-
for (const [ch] of workChildren) {
|
|
1976
|
-
try {
|
|
1977
|
-
if (groupKillChildren.has(ch) && ch.pid) process.kill(-ch.pid, 'SIGTERM');
|
|
1978
|
-
else ch.kill('SIGTERM');
|
|
1979
|
-
} catch {
|
|
1980
|
-
// A group that has already gone, or a pid that is no longer a leader.
|
|
1981
|
-
try {
|
|
1982
|
-
ch.kill('SIGTERM');
|
|
1983
|
-
} catch {
|
|
1984
|
-
/* best-effort */
|
|
1985
|
-
}
|
|
1986
|
-
}
|
|
1987
|
-
}
|
|
1988
|
-
workChildren.clear();
|
|
1989
|
-
groupKillChildren.clear();
|
|
1990
|
-
};
|
|
1991
|
-
|
|
1992
|
-
/**
|
|
1993
|
-
* HOW MANY CLIs THIS MACHINE IS RUNNING RIGHT NOW — the number
|
|
1994
|
-
* `MAX_CONCURRENT` is a ceiling on, and the thing that had no counter.
|
|
1995
|
-
*
|
|
1996
|
-
* Every lane, because the bound is on the BOX and not on a lane: session
|
|
1997
|
-
* turns, an agent's turn, a Deploy press's planner, a project check — all of
|
|
1998
|
-
* them land in `workChildren` — plus whatever the caller reports on top of it
|
|
1999
|
-
* (the wiki cartographer, which fleet.mjs owns).
|
|
2000
|
-
*
|
|
2001
|
-
* THE PROJECT CHECK COUNTS, deliberately. It is not a model turn, but it is a
|
|
2002
|
-
* full test or build run in a worktree, which is exactly the kind of process
|
|
2003
|
-
* this ceiling exists to stop stacking. Nothing gates a check, so counting it
|
|
2004
|
-
* cannot deadlock: it only ever delays the NEXT spawn.
|
|
2005
|
-
*/
|
|
2006
|
-
const liveTurnCount = () => {
|
|
2007
|
-
const extra = Number(extraLiveTurns() ?? 0);
|
|
2008
|
-
return workChildren.size + (Number.isFinite(extra) && extra > 0 ? extra : 0);
|
|
2009
|
-
};
|
|
2010
|
-
|
|
2011
|
-
/** The live turn children with the id each one serves — what the machine
|
|
2012
|
-
* snapshot charges its per-task RSS to. Children with no id (the planner)
|
|
2013
|
-
* are still counted above; they just have nothing to be charged TO. */
|
|
2014
|
-
const liveTurns = () => {
|
|
2015
|
-
const out = [];
|
|
2016
|
-
for (const [ch, id] of workChildren) if (ch?.pid) out.push({ id: id ?? null, pid: ch.pid });
|
|
2017
|
-
return out;
|
|
2018
|
-
};
|
|
2019
|
-
|
|
2020
|
-
/**
|
|
2021
|
-
* WHETHER TO START ONE MORE. See admission.mjs for the whole argument: a
|
|
2022
|
-
* runaway bound on a machine, read at the spawn, surfaced only as the
|
|
2023
|
-
* machine's own measured sentence at the thing that is waiting, and never a
|
|
2024
|
-
* reason to settle a job — a deferred job is re-offered next poll.
|
|
2025
|
-
*/
|
|
2026
|
-
const admit = createAdmission({ liveTurnCount });
|
|
2027
|
-
|
|
2028
|
-
/**
|
|
2029
|
-
* Retire the worktrees of sessions the server says are CLOSED.
|
|
2030
|
-
*
|
|
2031
|
-
* `activeWorkSessions` on the roster is the list of this fleet's LIVE
|
|
2032
|
-
* sessions; a directory whose id is absent belongs to a tab its owner
|
|
2033
|
-
* closed, and the directory — never the branch: committed work survives on
|
|
2034
|
-
* `session/<id>`, and ship re-attaches to it — is returned to disk. NEVER
|
|
2035
|
-
* by count: the old cap-12 retirement destroyed live sessions on shared
|
|
2036
|
-
* machines. When the roster omits the field entirely (older server),
|
|
2037
|
-
* absence of signal is not a close — retire nothing.
|
|
2038
|
-
*/
|
|
2039
|
-
/** Every session this daemon currently has a worktree for — what renews our
|
|
2040
|
-
* lease on the poll. Read off the directory rather than a map, so it is the
|
|
2041
|
-
* same fact retirement acts on. */
|
|
2042
|
-
const heldSessionIds = () => {
|
|
2043
|
-
const dir = join(baseDir, 'sessions');
|
|
2044
|
-
try {
|
|
2045
|
-
return readdirSync(dir).filter(isSafePathSegment).slice(0, 50);
|
|
2046
|
-
} catch {
|
|
2047
|
-
return [];
|
|
2048
|
-
}
|
|
2049
|
-
};
|
|
2050
|
-
|
|
2051
|
-
/** The pre-places name, kept so every existing caller reads unchanged: a
|
|
2052
|
-
* session's own id IS its default place. */
|
|
2053
|
-
const sessionWtFor = (sessionId, baseAt) => placeWtFor(sessionId, baseAt);
|
|
2054
|
-
|
|
2055
|
-
/** See `shipSweep.mjs`. Bound to this manager's repo, base and report queue. */
|
|
2056
|
-
const sweepMergedSessionBranch = (sessionId) =>
|
|
2057
|
-
sweepMergedBranch(sessionId, {
|
|
2058
|
-
git,
|
|
186
|
+
// ── THE SESSION'S OWN HALF — built first, because the lanes below are handed it.
|
|
187
|
+
const { sessionPlaces, placeOf, learnPlaces, placeDir, placeWtFor, sessionMetaPath } =
|
|
188
|
+
createWorkPlaces({ repoRoot, baseDir, baseRef, onRepoChanged });
|
|
189
|
+
const { sessionGroups, noteSessionGroup, pruneSessionGroups, sessionProcesses } =
|
|
190
|
+
createWorkSessionGroups({ repoRoot });
|
|
191
|
+
const { workChildren, groupKillChildren, shutdownWork, liveTurnCount, liveTurns, admit } =
|
|
192
|
+
createWorkChildren({ extraLiveTurns });
|
|
193
|
+
const { agentPublished, agentRemoteAt, publishAgentBranch, fetchPublishedBranch } =
|
|
194
|
+
createWorkAgentPublish({ repoRoot, gitNet });
|
|
195
|
+
const { reportSessionWorktree, reportPlaceWorktrees, burstListeners, reportWorktrees } =
|
|
196
|
+
createWorkPlaceReports({
|
|
2059
197
|
repoRoot,
|
|
2060
|
-
baseRef
|
|
2061
|
-
|
|
2062
|
-
|
|
2063
|
-
|
|
2064
|
-
|
|
2065
|
-
|
|
2066
|
-
|
|
2067
|
-
|
|
2068
|
-
|
|
2069
|
-
|
|
2070
|
-
// from activeWorkSessions for us and present for them, and removing their
|
|
2071
|
-
// worktree would pull the directory out from under a running turn. Absence
|
|
2072
|
-
// means "the tab closed"; this is the one other thing it can mean.
|
|
2073
|
-
const peers = new Set(Array.isArray(heldElsewhere) ? heldElsewhere : []);
|
|
2074
|
-
// A peer-held session's CACHED work token is a claim-bypass: the mint is
|
|
2075
|
-
// the one place the session lease 409s a non-holder, and a token younger
|
|
2076
|
-
// than ~23h skips the mint entirely — so a daemon that lost a lease would
|
|
2077
|
-
// run the next turn anyway, editing the worktree while every MCP call
|
|
2078
|
-
// 401s (the peer's mint rotated the secret). Dropping the cache forces
|
|
2079
|
-
// the next turn through the mint, where the 409 stands it down.
|
|
2080
|
-
for (const id of peers) workTokens.delete(id);
|
|
2081
|
-
const dir = join(baseDir, 'sessions');
|
|
2082
|
-
if (!existsSync(dir)) return;
|
|
2083
|
-
let ids;
|
|
2084
|
-
try {
|
|
2085
|
-
ids = readdirSync(dir);
|
|
2086
|
-
} catch {
|
|
2087
|
-
return;
|
|
2088
|
-
}
|
|
2089
|
-
const live = new Set(activeIds);
|
|
2090
|
-
let removed = 0;
|
|
2091
|
-
for (const id of ids) {
|
|
2092
|
-
if (live.has(id)) continue;
|
|
2093
|
-
if (peers.has(id)) continue; // another daemon's tab — not ours to retire
|
|
2094
|
-
/**
|
|
2095
|
-
* A HARD STOP REACHES THE CLI HERE, and this is the whole of it.
|
|
2096
|
-
*
|
|
2097
|
-
* `stopAgent` marked the agent abandoned in D1 and nothing on the wire
|
|
2098
|
-
* told the machine, so the CLI went on working, editing the worktree and
|
|
2099
|
-
* spending the operator's quota — while the confirm dialog said the agent
|
|
2100
|
-
* was stopped. It also never got cleaned up, because the check below
|
|
2101
|
-
* skips any place whose lock is held and a running turn holds it: a
|
|
2102
|
-
* stopped agent mid-turn kept its directory forever.
|
|
2103
|
-
*
|
|
2104
|
-
* No new wire field and no version floor: an abandoned agent simply drops
|
|
2105
|
-
* out of `activeWorkSessions` (that list is built from LIVE statuses), so
|
|
2106
|
-
* the server ALREADY says everything needed. Absence means "nothing here
|
|
2107
|
-
* is live any more", and the honest response to that is to stop what we
|
|
2108
|
-
* are running in it and then take the directory.
|
|
2109
|
-
*
|
|
2110
|
-
* SIGTERM the CHILD, never its group — the rule teardown keeps, so an
|
|
2111
|
-
* unattended sweep cannot take the driver's dev server with it. The lock
|
|
2112
|
-
* is released when the child exits, so the removal happens on the next
|
|
2113
|
-
* pass rather than this one.
|
|
2114
|
-
*/
|
|
2115
|
-
const running = agentChildren.get(id);
|
|
2116
|
-
if (running) {
|
|
2117
|
-
try {
|
|
2118
|
-
running.kill('SIGTERM');
|
|
2119
|
-
} catch {
|
|
2120
|
-
/* already gone */
|
|
2121
|
-
}
|
|
2122
|
-
agentChildren.delete(id);
|
|
2123
|
-
}
|
|
2124
|
-
// Asked of the PLACE, not the session: the lock is keyed by directory,
|
|
2125
|
-
// and a session sharing one with a busy peer is not ours to retire
|
|
2126
|
-
// either — its worktree is the peer's working directory.
|
|
2127
|
-
if (placeLocks.has(placeOf(id)) || shipping.has(id)) continue; // still draining here
|
|
2128
|
-
const wt = join(dir, id);
|
|
2129
|
-
try {
|
|
2130
|
-
// Uncommitted work is the human's — a resource sweep does not outrank
|
|
2131
|
-
// it, closed tab or not. (The non-force remove would refuse anyway;
|
|
2132
|
-
// the explicit check keeps the intent legible.)
|
|
2133
|
-
if (git(['status', '--porcelain'], wt) !== '') continue;
|
|
2134
|
-
git(['worktree', 'remove', wt], repoRoot); // non-force
|
|
2135
|
-
workTokens.delete(id);
|
|
2136
|
-
removed++;
|
|
2137
|
-
// NOW the branch can be judged. While this worktree existed the branch
|
|
2138
|
-
// was checked out in it, so `git branch -d` refused on every earlier
|
|
2139
|
-
// attempt — a tab that shipped and then closed would otherwise leave
|
|
2140
|
-
// its merged branch behind forever, which is the common case.
|
|
2141
|
-
// Unshipped work still refuses here: `-d` is what decides.
|
|
2142
|
-
sweepMergedSessionBranch(id);
|
|
2143
|
-
} catch {
|
|
2144
|
-
/* not cleanly removable — leave it */
|
|
2145
|
-
}
|
|
2146
|
-
}
|
|
2147
|
-
if (removed) {
|
|
2148
|
-
try {
|
|
2149
|
-
git(['worktree', 'prune'], repoRoot);
|
|
2150
|
-
} catch {
|
|
2151
|
-
/* best effort */
|
|
2152
|
-
}
|
|
2153
|
-
}
|
|
2154
|
-
};
|
|
2155
|
-
|
|
2156
|
-
/**
|
|
2157
|
-
* TELL THE TAB IT IS WAITING ON THE BOX, not on its Claude.
|
|
2158
|
-
*
|
|
2159
|
-
* A deferred turn is invisible from a browser: the composer says "working…"
|
|
2160
|
-
* and the machine simply does not spawn, which looks exactly like a slow
|
|
2161
|
-
* model. So the deferral rides the narration channel the turn would have used
|
|
2162
|
-
* anyway — the turn is still pending, so the server accepts the line — and
|
|
2163
|
-
* says the measured reason and what happens next. The machine's own voice,
|
|
2164
|
-
* for a moment only this side can see; the same shape the planner's "waiting
|
|
2165
|
-
* for the checkout" already keeps.
|
|
2166
|
-
*
|
|
2167
|
-
* ONCE PER SESSION PER WINDOW, because the roster re-offers the same turn on
|
|
2168
|
-
* every poll and restating an unchanged sentence every ten seconds is a POST
|
|
2169
|
-
* loop, not a readout. The clock is cleared the moment a turn for that session
|
|
2170
|
-
* actually starts, so the next stall speaks immediately rather than inheriting
|
|
2171
|
-
* a window from an unrelated one.
|
|
2172
|
-
*/
|
|
2173
|
-
const DEFER_SAY_MS = 30_000;
|
|
2174
|
-
const lastDeferSaid = new Map(); // sessionId -> ms
|
|
2175
|
-
const sayTurnDeferred = (sessionId, turnId, reason) => {
|
|
2176
|
-
const now = Date.now();
|
|
2177
|
-
if (now - (lastDeferSaid.get(sessionId) ?? 0) < DEFER_SAY_MS) return;
|
|
2178
|
-
lastDeferSaid.set(sessionId, now);
|
|
2179
|
-
void fetch(ACTIVITY_URL, {
|
|
2180
|
-
method: 'POST',
|
|
2181
|
-
headers: {
|
|
2182
|
-
Authorization: `Bearer ${FLEET_TOKEN}`,
|
|
2183
|
-
'User-Agent': USER_AGENT,
|
|
2184
|
-
'Content-Type': 'application/json',
|
|
2185
|
-
},
|
|
2186
|
-
signal: AbortSignal.timeout(10_000),
|
|
2187
|
-
body: JSON.stringify({
|
|
2188
|
-
sessionId,
|
|
2189
|
-
turnId,
|
|
2190
|
-
lines: [`Deferred — ${reason}. The machine retries on its next poll.`],
|
|
2191
|
-
}),
|
|
2192
|
-
}).catch(() => {
|
|
2193
|
-
/* a readout — a dropped line is not an incident */
|
|
198
|
+
baseRef,
|
|
199
|
+
placeOf,
|
|
200
|
+
placeDir,
|
|
201
|
+
sessionMetaPath,
|
|
202
|
+
sessionProcesses,
|
|
203
|
+
pruneSessionGroups,
|
|
204
|
+
agentPublished,
|
|
205
|
+
agentRemoteAt,
|
|
206
|
+
publishAgentBranch,
|
|
207
|
+
landed,
|
|
2194
208
|
});
|
|
2195
|
-
|
|
2196
|
-
|
|
2197
|
-
const processWorkTurns = (jobs) => {
|
|
2198
|
-
for (const job of jobs ?? []) {
|
|
2199
|
-
if (!job || typeof job.id !== 'string' || !job.body || !job.sessionId) continue;
|
|
2200
|
-
if (workAnswering.has(job.id)) continue;
|
|
2201
|
-
// The turn already RAN and its answer sits in the delivery queue — never
|
|
2202
|
-
// run it again while the report is merely undelivered.
|
|
2203
|
-
if (pendingWorkReports.has(job.id)) continue;
|
|
2204
|
-
/**
|
|
2205
|
-
* THE BOX IS ABOUT TO FALL OVER, OR THIS MACHINE IS ALREADY AT ITS
|
|
2206
|
-
* CEILING. Defer: return without settling and without consuming an
|
|
2207
|
-
* attempt — the shape the live-CLI lock below uses, for the same reason.
|
|
2208
|
-
* The job stays pending and the server re-offers it next poll; settling
|
|
2209
|
-
* it would tell the human their message failed when nothing ran.
|
|
2210
|
-
*
|
|
2211
|
-
* `interactive`, not `churn`: somebody is watching a composer they just
|
|
2212
|
-
* pressed enter in, so this holds out until the box is genuinely about to
|
|
2213
|
-
* die rather than yielding early the way the unattended lanes do.
|
|
2214
|
-
*
|
|
2215
|
-
* AND THE SLOT IS RESERVED BEFORE THE NEXT ITERATION ASKS. This loop is
|
|
2216
|
-
* synchronous and every spawn under it is not — `inPlace` resolves its
|
|
2217
|
-
* callback in a later microtask — so `liveTurnCount()` could not move
|
|
2218
|
-
* between jobs and a roster offering eight turns admitted all eight
|
|
2219
|
-
* against a ceiling of one. See admission.mjs.
|
|
2220
|
-
*/
|
|
2221
|
-
const hold = admit('interactive');
|
|
2222
|
-
if (hold) {
|
|
2223
|
-
sayTurnDeferred(job.sessionId, job.id, hold.reason);
|
|
2224
|
-
continue;
|
|
2225
|
-
}
|
|
2226
|
-
const releaseSlot = admit.reserve();
|
|
2227
|
-
lastDeferSaid.delete(job.sessionId);
|
|
2228
|
-
workAnswering.add(job.id);
|
|
2229
|
-
const place = job.place || job.sessionId;
|
|
2230
|
-
/**
|
|
2231
|
-
* VALIDATED AT THE TRUST BOUNDARY, exactly as `learnPlaces` validates
|
|
2232
|
-
* the roster's map — this is the OTHER writer of `sessionPlaces`, in the
|
|
2233
|
-
* same reconcile tick, BEFORE the preview jobs and the sweep read it. An
|
|
2234
|
-
* unchecked value stored here reaches every `placeDir` consumer: the
|
|
2235
|
-
* turn is spawned in it, `burstListeners` measures it, and a share would
|
|
2236
|
-
* publicly tunnel a directory OUTSIDE the checkout — the exact traversal
|
|
2237
|
-
* the preview feature's port attribution exists to prevent. Settled out
|
|
2238
|
-
* loud rather than skipped, because a silently dropped turn strands the
|
|
2239
|
-
* tab for the server's whole expiry window.
|
|
2240
|
-
*/
|
|
2241
|
-
if (place !== REPO_PLACE && !isSafePathSegment(place)) {
|
|
2242
|
-
void settleWorkTurn(job.id, {
|
|
2243
|
-
ok: false,
|
|
2244
|
-
answer:
|
|
2245
|
-
'the server named a working directory this machine refuses to use — close and reopen the tab, then send the message again',
|
|
2246
|
-
}).finally(() => workAnswering.delete(job.id));
|
|
2247
|
-
releaseSlot();
|
|
2248
|
-
continue;
|
|
2249
|
-
}
|
|
2250
|
-
// Remembered for every other beat — the sweep, ship, the preview
|
|
2251
|
-
// re-check — so they all ask the same directory this turn runs in.
|
|
2252
|
-
sessionPlaces.set(job.sessionId, place);
|
|
2253
|
-
// A READER: other turns in this place run alongside it. See `inPlace`.
|
|
2254
|
-
inPlace(place, false, async () => {
|
|
2255
|
-
/** This turn's artifact snapshot — set once the place is resolved and
|
|
2256
|
-
* a CLI is about to spawn there; read in the `finally`. Null on every
|
|
2257
|
-
* path that never ran a CLI, which is exactly when nothing is new. */
|
|
2258
|
-
let artifactScan = null;
|
|
2259
|
-
try {
|
|
2260
|
-
const tries = workAttempts.get(job.id) ?? 0;
|
|
2261
|
-
if (tries >= MAX_WORK_TRIES) {
|
|
2262
|
-
// Out of local tries: SETTLE, don't skip — a silently skipped turn
|
|
2263
|
-
// strands the tab for the server's whole 24h expiry window.
|
|
2264
|
-
await settleWorkTurn(job.id, {
|
|
2265
|
-
ok: false,
|
|
2266
|
-
answer: `the turn failed ${tries} times on this machine — check the daemon log, then send the message again`,
|
|
2267
|
-
});
|
|
2268
|
-
return;
|
|
2269
|
-
}
|
|
2270
|
-
note(
|
|
2271
|
-
`${c.cyan('tab')} ${c.dim(`— ${job.askedByName || 'the owner'} in "${job.sessionName || 'a session'}"`)}`
|
|
2272
|
-
);
|
|
2273
|
-
// ── ADOPTION: a tab born from a TERMINAL session ────────────────
|
|
2274
|
-
// The server sends `adopt {id, cwd}` only while the session has no
|
|
2275
|
-
// sessionRef — no turn has ever spoken from a worktree here — and
|
|
2276
|
-
// the first turn resumes the terminal conversation by forking it
|
|
2277
|
-
// into the tab's own worktree. Everything the server asserts is
|
|
2278
|
-
// re-validated MACHINE-side: the id shape, the source directory,
|
|
2279
|
-
// and — decisive — that the terminal is actually closed, because
|
|
2280
|
-
// forking a session someone is still typing into puts two Claudes
|
|
2281
|
-
// on one conversation.
|
|
2282
|
-
const adopting = Boolean(job.adopt) && !job.sessionRef;
|
|
2283
|
-
let srcHead = null;
|
|
2284
|
-
let adoptSrc = null; // the validated, realpath'd source checkout
|
|
2285
|
-
if (adopting) {
|
|
2286
|
-
if (
|
|
2287
|
-
typeof job.adopt.id !== 'string' ||
|
|
2288
|
-
!/^[0-9a-f][0-9a-f-]{6,62}$/i.test(job.adopt.id)
|
|
2289
|
-
) {
|
|
2290
|
-
await settleWorkTurn(job.id, {
|
|
2291
|
-
ok: false,
|
|
2292
|
-
answer: 'that terminal session id is not one this machine can resume',
|
|
2293
|
-
});
|
|
2294
|
-
return;
|
|
2295
|
-
}
|
|
2296
|
-
let srcCwd = null;
|
|
2297
|
-
try {
|
|
2298
|
-
srcCwd = realpathSync(String(job.adopt.cwd ?? ''));
|
|
2299
|
-
if (!statSync(srcCwd).isDirectory()) srcCwd = null;
|
|
2300
|
-
} catch {
|
|
2301
|
-
srcCwd = null;
|
|
2302
|
-
}
|
|
2303
|
-
if (!srcCwd) {
|
|
2304
|
-
await settleWorkTurn(job.id, {
|
|
2305
|
-
ok: false,
|
|
2306
|
-
answer: "the terminal session's directory no longer exists on the machine",
|
|
2307
|
-
});
|
|
2308
|
-
return;
|
|
2309
|
-
}
|
|
2310
|
-
// Inside the repo, outside the daemon's own worktrees: an adopt
|
|
2311
|
-
// source is a HUMAN's checkout, and one of our directories showing
|
|
2312
|
-
// up here means a stale or confused offer, not a session to fork.
|
|
2313
|
-
const under = (p, root) =>
|
|
2314
|
-
p === root || p.startsWith(root.endsWith('/') ? root : `${root}/`);
|
|
2315
|
-
let realRoot = repoRoot;
|
|
2316
|
-
let realBase = baseDir;
|
|
2317
|
-
try {
|
|
2318
|
-
realRoot = realpathSync(repoRoot);
|
|
2319
|
-
} catch {
|
|
2320
|
-
/* keep the literal path */
|
|
2321
|
-
}
|
|
2322
|
-
try {
|
|
2323
|
-
realBase = realpathSync(baseDir);
|
|
2324
|
-
} catch {
|
|
2325
|
-
/* keep the literal path */
|
|
2326
|
-
}
|
|
2327
|
-
if (!under(srcCwd, realRoot)) {
|
|
2328
|
-
await settleWorkTurn(job.id, {
|
|
2329
|
-
ok: false,
|
|
2330
|
-
answer: "the terminal session's directory is outside this project's repository",
|
|
2331
|
-
});
|
|
2332
|
-
return;
|
|
2333
|
-
}
|
|
2334
|
-
if (under(srcCwd, realBase)) {
|
|
2335
|
-
await settleWorkTurn(job.id, {
|
|
2336
|
-
ok: false,
|
|
2337
|
-
answer:
|
|
2338
|
-
"that directory is one of the daemon's own worktrees — its session is already a tab, not something to adopt",
|
|
2339
|
-
});
|
|
2340
|
-
return;
|
|
2341
|
-
}
|
|
2342
|
-
try {
|
|
2343
|
-
srcHead = git(['rev-parse', 'HEAD'], srcCwd);
|
|
2344
|
-
} catch {
|
|
2345
|
-
await settleWorkTurn(job.id, {
|
|
2346
|
-
ok: false,
|
|
2347
|
-
answer:
|
|
2348
|
-
"the terminal session's directory is not a usable git checkout (no HEAD to branch from)",
|
|
2349
|
-
});
|
|
2350
|
-
return;
|
|
2351
|
-
}
|
|
2352
|
-
// Liveness by the SESSION's own runtime: Claude has a real pid
|
|
2353
|
-
// registry; agy only leaves store-write recency + a process check,
|
|
2354
|
-
// and adoption there is a MOVE (no fork exists), so the composite
|
|
2355
|
-
// errs toward refusing — a false "live" costs a retry in minutes,
|
|
2356
|
-
// a false "ended" puts two drivers on one conversation store.
|
|
2357
|
-
const adoptLive =
|
|
2358
|
-
job.runtime === 'antigravity'
|
|
2359
|
-
? isAgyConversationLive(job.adopt.id)
|
|
2360
|
-
: isTerminalSessionLive(job.adopt.id);
|
|
2361
|
-
if (adoptLive) {
|
|
2362
|
-
await settleWorkTurn(job.id, {
|
|
2363
|
-
ok: false,
|
|
2364
|
-
answer:
|
|
2365
|
-
'That terminal session is still open on the machine — close it there first, then adopt.',
|
|
2366
|
-
});
|
|
2367
|
-
return;
|
|
2368
|
-
}
|
|
2369
|
-
adoptSrc = srcCwd;
|
|
2370
|
-
}
|
|
2371
|
-
// Based at the SOURCE's HEAD when adopting — the resumed
|
|
2372
|
-
// conversation was had against those commits, not the project base.
|
|
2373
|
-
// The PLACE this tab works in — its own worktree unless the server
|
|
2374
|
-
// named another. An older server sends no `place` and the default is
|
|
2375
|
-
// the session's own id, which is what every tab has always done.
|
|
2376
|
-
const dir = placeWtFor(place, adopting ? srcHead : undefined);
|
|
2377
|
-
if (!dir) {
|
|
2378
|
-
await settleWorkTurn(job.id, {
|
|
2379
|
-
ok: false,
|
|
2380
|
-
answer:
|
|
2381
|
-
'the session worktree could not be opened on the machine — check the daemon log',
|
|
2382
|
-
});
|
|
2383
|
-
return;
|
|
2384
|
-
}
|
|
2385
|
-
// A live CLI is ALREADY in this worktree — this daemon's previous
|
|
2386
|
-
// life, most likely; the lock outlives a restart. Leave the job
|
|
2387
|
-
// pending and look again next poll; spawning a second CLI would put
|
|
2388
|
-
// two Claudes in one held context. Costs no attempt: nothing ran.
|
|
2389
|
-
const lockPath = sessionMetaPath(dir.wt, 'flowviant-turn.lock');
|
|
2390
|
-
if (turnLockedByLivePid(lockPath)) {
|
|
2391
|
-
warn(
|
|
2392
|
-
`a turn is already running in "${job.sessionName || job.sessionId}" — waiting for it to finish`
|
|
2393
|
-
);
|
|
2394
|
-
return;
|
|
2395
|
-
}
|
|
2396
|
-
// WHICH BRAIN the roster says this tab speaks (null/absent = Claude,
|
|
2397
|
-
// which is what every tab ran on until now) — honored by
|
|
2398
|
-
// sessionRuntime: on a first turn a named runtime IS the pick, and a
|
|
2399
|
-
// named runtime that disagrees with the pin settles below.
|
|
2400
|
-
// What the artifact directory held BEFORE this turn — the capture chat
|
|
2401
|
-
// excepted: it runs read-only, is never told about artifacts, and
|
|
2402
|
-
// whatever sibling tabs wrote in this shared place is theirs.
|
|
2403
|
-
if (job.capture !== true) artifactScan = { dir: dir.wt, before: beforeArtifacts(dir.wt) };
|
|
2404
|
-
const rt = sessionRuntime(dir.wt, job.runtime || null, job.sessionId);
|
|
2405
|
-
if (rt.mismatch) {
|
|
2406
|
-
// Something upstream changed this tab's identity mid-life. A held
|
|
2407
|
-
// context must never be answered by a different brain — say so.
|
|
2408
|
-
await settleWorkTurn(job.id, {
|
|
2409
|
-
ok: false,
|
|
2410
|
-
answer: `this tab is pinned to ${rt.mismatch.pin} but the server says it is a ${rt.mismatch.runtime} tab — reopen a new tab`,
|
|
2411
|
-
});
|
|
2412
|
-
return;
|
|
2413
|
-
}
|
|
2414
|
-
if (rt.missing) {
|
|
2415
|
-
await settleWorkTurn(job.id, {
|
|
2416
|
-
ok: false,
|
|
2417
|
-
answer: `this session runs on ${rt.missing}, which is no longer installed on the machine — reinstall it, or open a new tab`,
|
|
2418
|
-
});
|
|
2419
|
-
return;
|
|
2420
|
-
}
|
|
2421
|
-
if (rt.unsupported) {
|
|
2422
|
-
// A pin from before the session-capable gate existed — or a
|
|
2423
|
-
// first-turn tab the server named for one — can carry a runtime no
|
|
2424
|
-
// tab can run on (Antigravity has no MCP config, and the session's
|
|
2425
|
-
// whole control plane rides one). An honest sentence beats the
|
|
2426
|
-
// mcpFor throw this used to crash into every turn.
|
|
2427
|
-
await settleWorkTurn(job.id, {
|
|
2428
|
-
ok: false,
|
|
2429
|
-
answer: `this session runs on ${rt.unsupported}, which cannot drive a Workbench tab on this machine — open a new tab`,
|
|
2430
|
-
});
|
|
2431
|
-
return;
|
|
2432
|
-
}
|
|
2433
|
-
if (!rt.id) {
|
|
2434
|
-
await settleWorkTurn(job.id, {
|
|
2435
|
-
ok: false,
|
|
2436
|
-
answer:
|
|
2437
|
-
'No coding CLI is installed on the machine — install Claude Code (or another supported CLI), then send the message again',
|
|
2438
|
-
});
|
|
2439
|
-
return;
|
|
2440
|
-
}
|
|
2441
|
-
if (adopting && rt.id !== 'claude' && rt.id !== 'antigravity') {
|
|
2442
|
-
// An adopt id names a conversation in ITS OWN CLI's store: claude
|
|
2443
|
-
// forks it (--resume --fork-session), agy moves it
|
|
2444
|
-
// (--conversation). Codex has no adoptable store yet, and its
|
|
2445
|
-
// args builder backstops this with a loud throw — but a sentence
|
|
2446
|
-
// here beats a stack there.
|
|
2447
|
-
await settleWorkTurn(job.id, {
|
|
2448
|
-
ok: false,
|
|
2449
|
-
answer:
|
|
2450
|
-
'adopting this terminal session needs its own CLI on the machine — install it, then try again',
|
|
2451
|
-
});
|
|
2452
|
-
return;
|
|
2453
|
-
}
|
|
2454
|
-
/**
|
|
2455
|
-
* A PLAN TURN (0.97.0): the tab's switch, off the job. CLAUDE ONLY —
|
|
2456
|
-
* `--permission-mode plan` is Claude Code's, and a codex or agy turn
|
|
2457
|
-
* has no way to spell it, so it is refused HERE in words rather than
|
|
2458
|
-
* run as a build turn wearing the word. The server never sends the
|
|
2459
|
-
* key for a non-Claude tab; this is the machine's own half. Never on
|
|
2460
|
-
* a capture chat, which is read-only by its own profile already.
|
|
2461
|
-
*/
|
|
2462
|
-
const planTurn = job.planMode === true && job.capture !== true;
|
|
2463
|
-
if (planTurn && rt.id !== 'claude') {
|
|
2464
|
-
await settleWorkTurn(job.id, {
|
|
2465
|
-
ok: false,
|
|
2466
|
-
answer: `plan mode runs on Claude Code only, and this tab is ${rt.id} — turn plan off in the tab, then send the message again`,
|
|
2467
|
-
});
|
|
2468
|
-
return;
|
|
2469
|
-
}
|
|
2470
|
-
// A PLAIN tab (agy) mounts no MCP: no credential to mint, no config
|
|
2471
|
-
// to write. The trade is stated in SYSTEM_WORK_PLAIN — no cards, no
|
|
2472
|
-
// streaming — and the honesty survives on the existing rails: the
|
|
2473
|
-
// answer lands via work-turn-done, the rail says "no card yet", and
|
|
2474
|
-
// ship-time reconciliation books every branch commit.
|
|
2475
|
-
const plainTab = rt.id === 'antigravity';
|
|
2476
|
-
let mint = null;
|
|
2477
|
-
if (!plainTab) {
|
|
2478
|
-
mint = await mintWorkToken(job.sessionId);
|
|
2479
|
-
if (!mint) mint = await mintWorkToken(job.sessionId, true); // one transient blip ≠ a dead turn
|
|
2480
|
-
// Another daemon on this credential holds the session. Return
|
|
2481
|
-
// WITHOUT settling: the holder is answering this same turn, and
|
|
2482
|
-
// settling it here — even as a failure — would race the real
|
|
2483
|
-
// answer and could win. Dropping it means the turn stays pending
|
|
2484
|
-
// and the holder's answer lands, which is the whole point.
|
|
2485
|
-
if (mint?.heldElsewhere) {
|
|
2486
|
-
workAnswering.delete(job.id);
|
|
2487
|
-
return;
|
|
2488
|
-
}
|
|
2489
|
-
if (mint?.gone) {
|
|
2490
|
-
await settleWorkTurn(job.id, {
|
|
2491
|
-
ok: false,
|
|
2492
|
-
answer:
|
|
2493
|
-
'Flowviant no longer offers this session to this machine — the tab may have been closed or moved',
|
|
2494
|
-
});
|
|
2495
|
-
return;
|
|
2496
|
-
}
|
|
2497
|
-
if (!mint?.token) {
|
|
2498
|
-
await settleWorkTurn(job.id, {
|
|
2499
|
-
ok: false,
|
|
2500
|
-
answer:
|
|
2501
|
-
'the machine could not mint a session credential from Flowviant — check its connection, then send the message again',
|
|
2502
|
-
});
|
|
2503
|
-
return;
|
|
2504
|
-
}
|
|
2505
|
-
}
|
|
2506
|
-
// CODEX RESUMES BY THREAD ID, never by `--last`: `resume --last` is
|
|
2507
|
-
// the MACHINE's most recent codex conversation, and two codex tabs —
|
|
2508
|
-
// or a tab plus a codex dispatch — would cross-resume each other's
|
|
2509
|
-
// context. The id was captured off thread.started (runtimes.mjs) and
|
|
2510
|
-
// persisted below, beside the runtime pin; absent, the turn runs
|
|
2511
|
-
// FRESH in the same worktree — the dirty state is most of the held
|
|
2512
|
-
// context, and a machine-global guess is someone else's conversation.
|
|
2513
|
-
/**
|
|
2514
|
-
* CLAUDE RESUMES BY THE CONVERSATION ID THIS TAB SPOKE UNDER.
|
|
2515
|
-
*
|
|
2516
|
-
* `--continue` is CWD-KEYED. That was unambiguous while one directory
|
|
2517
|
-
* meant one tab, and it stopped being true the day tabs moved into
|
|
2518
|
-
* their driver's project folder: every tab there said `--continue`
|
|
2519
|
-
* and every one of them resumed whichever conversation had spoken
|
|
2520
|
-
* most recently in that directory. Tab B inherited tab A's entire
|
|
2521
|
-
* context, and each turn afterwards ping-ponged between them — the
|
|
2522
|
-
* exact failure the codex note two blocks down warns about for
|
|
2523
|
-
* `resume --last`, arriving for Claude by a different route.
|
|
2524
|
-
*
|
|
2525
|
-
* The id comes from the CLI's own `system.init` event, which the
|
|
2526
|
-
* stream parser already surfaces, and is pinned per session so it is
|
|
2527
|
-
* unambiguous wherever the tab is standing.
|
|
2528
|
-
*
|
|
2529
|
-
* NO ID, NO `--continue`: a tab whose place is shared starts FRESH
|
|
2530
|
-
* rather than guessing, because in a shared directory the guess is
|
|
2531
|
-
* someone else's conversation. `--continue` survives only where the
|
|
2532
|
-
* directory belongs to this tab alone, which is the one case it was
|
|
2533
|
-
* ever right for.
|
|
2534
|
-
*/
|
|
2535
|
-
let claudeResumeId = null;
|
|
2536
|
-
if (rt.id === 'claude') {
|
|
2537
|
-
const convMarker = sessionMetaPath(dir.wt, 'flowviant-claude-session', job.sessionId);
|
|
2538
|
-
if (convMarker && existsSync(convMarker)) {
|
|
2539
|
-
try {
|
|
2540
|
-
const v = readFileSync(convMarker, 'utf8').trim();
|
|
2541
|
-
if (/^[A-Za-z0-9_-]{8,64}$/.test(v)) claudeResumeId = v;
|
|
2542
|
-
} catch {
|
|
2543
|
-
/* unreadable marker — run fresh */
|
|
2544
|
-
}
|
|
2545
|
-
}
|
|
2546
|
-
}
|
|
2547
|
-
let codexResumeId = null;
|
|
2548
|
-
if (rt.id === 'codex') {
|
|
2549
|
-
const threadMarker = sessionMetaPath(dir.wt, 'flowviant-codex-thread', job.sessionId);
|
|
2550
|
-
if (threadMarker && existsSync(threadMarker)) {
|
|
2551
|
-
try {
|
|
2552
|
-
const v = readFileSync(threadMarker, 'utf8').trim();
|
|
2553
|
-
if (CODEX_THREAD_RE.test(v)) codexResumeId = v;
|
|
2554
|
-
} catch {
|
|
2555
|
-
/* unreadable marker — run fresh */
|
|
2556
|
-
}
|
|
2557
|
-
}
|
|
2558
|
-
}
|
|
2559
|
-
// AGY RESUMES BY CONVERSATION ID, learned once and pinned beside the
|
|
2560
|
-
// runtime marker: an adopted tab knows it from the adopt hint; a new
|
|
2561
|
-
// tab learns it from agy's own cwd registry after its first turn.
|
|
2562
|
-
// The marker beats `--continue` because it is the tab's OWN identity
|
|
2563
|
-
// — the registry maps a cwd to whatever ran there LAST, and a
|
|
2564
|
-
// dispatch sharing the machine could overwrite that between turns.
|
|
2565
|
-
let agyConvId = null;
|
|
2566
|
-
if (rt.id === 'antigravity' && !adopting) {
|
|
2567
|
-
const convMarker = sessionMetaPath(dir.wt, 'flowviant-agy-conversation', job.sessionId);
|
|
2568
|
-
if (convMarker && existsSync(convMarker)) {
|
|
2569
|
-
try {
|
|
2570
|
-
const v = readFileSync(convMarker, 'utf8').trim();
|
|
2571
|
-
if (AGY_CONV_RE.test(v)) agyConvId = v;
|
|
2572
|
-
} catch {
|
|
2573
|
-
/* unreadable marker — run fresh */
|
|
2574
|
-
}
|
|
2575
|
-
}
|
|
2576
|
-
}
|
|
2577
|
-
// Resume iff a conversation is known to live in THIS directory. For
|
|
2578
|
-
// Claude that proof is the server's sessionRef — only ever a path
|
|
2579
|
-
// some turn actually SPOKE from (see the settle below), and it must
|
|
2580
|
-
// match the directory we just opened. For codex it is the stored
|
|
2581
|
-
// thread id, which lives IN the directory and is stronger. Anything
|
|
2582
|
-
// else starts fresh IN the existing worktree — never a reset; the
|
|
2583
|
-
// dirty state is the session.
|
|
2584
|
-
// agy layers its two resumes: the pinned conversation id when the
|
|
2585
|
-
// marker exists (deterministic, registry-proof), else the Claude
|
|
2586
|
-
// rule — a tab that has SPOKEN from this directory may `--continue`
|
|
2587
|
-
// it (cwd-keyed; measured safe), so a lost marker degrades to the
|
|
2588
|
-
// weaker resume instead of silently starting over.
|
|
2589
|
-
const spokeHere = !dir.fresh && Boolean(job.sessionRef) && job.sessionRef === dir.wt;
|
|
2590
|
-
// A place this tab does NOT have to itself: `--continue` there is a
|
|
2591
|
-
// guess at somebody else's conversation, so it is withheld and only
|
|
2592
|
-
// a pinned id may resume.
|
|
2593
|
-
const placeIsMine = !placeOf(job.sessionId) || placeOf(job.sessionId) === job.sessionId;
|
|
2594
|
-
const resume =
|
|
2595
|
-
rt.id === 'codex'
|
|
2596
|
-
? Boolean(codexResumeId)
|
|
2597
|
-
: rt.id === 'antigravity'
|
|
2598
|
-
? Boolean(agyConvId) || (placeIsMine && spokeHere)
|
|
2599
|
-
: Boolean(claudeResumeId) || (placeIsMine && spokeHere);
|
|
2600
|
-
// The dirty carry, on the adopt worktree's FIRST life only: a
|
|
2601
|
-
// re-attempted adoption (the directory already exists) carried what
|
|
2602
|
-
// it could the first time, and re-applying would double it. A carry
|
|
2603
|
-
// problem never fails the adoption — it becomes one bracketed line
|
|
2604
|
-
// in the prompt, so the AGENT tells the user what stayed behind.
|
|
2605
|
-
let carryNote = '';
|
|
2606
|
-
if (adopting && dir.fresh && adoptSrc) carryNote = carryDirtyState(adoptSrc, dir.wt, job.sessionId);
|
|
2607
|
-
// The tab's transcript starts EMPTY on adoption (scrollback is
|
|
2608
|
-
// disposable, the held context is the brain — never import an
|
|
2609
|
-
// archive), so the first reply opens with a recap: the human sees
|
|
2610
|
-
// the thread they are picking up without asking for it.
|
|
2611
|
-
const adoptNote = adopting
|
|
2612
|
-
? '[ADOPTED SESSION — this conversation was brought in from a terminal. Begin your reply with a 2-3 sentence recap of where it left off and what state carried over, then answer the message.]'
|
|
2613
|
-
: '';
|
|
2614
|
-
// A PLAN TURN RUNS PLAIN TOO (0.97.0): measured on 2.1.281, plan mode
|
|
2615
|
-
// refuses every `mcp__flowviant` call ("Cannot call … while in plan
|
|
2616
|
-
// mode") unless the tool is annotated read-only, and none of the
|
|
2617
|
-
// session tools are — so mounting the control plane would hand the
|
|
2618
|
-
// turn a list of tools it can only fail at. The credential is still
|
|
2619
|
-
// minted above: its lease answers (held elsewhere, gone) are about
|
|
2620
|
-
// the SESSION, and a plan turn needs them as much as any other.
|
|
2621
|
-
const mcp =
|
|
2622
|
-
plainTab || planTurn
|
|
2623
|
-
? { args: [], env: null, dir: null }
|
|
2624
|
-
: mcpFor(rt.id, mint.token, getMcpUrl());
|
|
2625
|
-
// The tab's model/effort, if it named any. Spread into turnArgs so
|
|
2626
|
-
// BOTH runTurn calls below carry it — the retry is the same turn on
|
|
2627
|
-
// the same brain, not a quieter second opinion.
|
|
2628
|
-
const brain = brainFor(job);
|
|
2629
|
-
// Attempts count RUNS: the infra refusals above consumed nothing and
|
|
2630
|
-
// settled on their own terms.
|
|
2631
|
-
workAttempts.set(job.id, tries + 1);
|
|
2632
|
-
let out;
|
|
2633
|
-
let seenThreadId = null; // codex's conversation id, off thread.started
|
|
2634
|
-
let seenClaudeSession = null; // claude's own conversation id, off system.init
|
|
2635
|
-
const spawned = []; // this turn's children, for the teardown registry
|
|
2636
|
-
/**
|
|
2637
|
-
* THE TURN'S TOOL LOG — the structured relay behind the transcript's
|
|
2638
|
-
* tool cards. Same source as the narrator (the CLI's own tool_use
|
|
2639
|
-
* events), zero inference; scrubbed AT COLLECTION so every copy that
|
|
2640
|
-
* leaves the machine — live beat and settle alike — is already clean.
|
|
2641
|
-
*
|
|
2642
|
-
* Shape rules, applied here because the collector is the one writer:
|
|
2643
|
-
* · consecutive identical read/grep/glob/bash/task events collapse
|
|
2644
|
-
* into one row with a count (n);
|
|
2645
|
-
* · consecutive edits of ONE file merge, summing counts, keeping
|
|
2646
|
-
* the newest preview;
|
|
2647
|
-
* · the PLAN is a single event — a new TodoWrite replaces the old
|
|
2648
|
-
* plan at the current position, so the log shows the latest plan
|
|
2649
|
-
* where it last changed rather than five stale copies;
|
|
2650
|
-
* · capped at the newest 60 NON-PLAN rows, with the shed counted
|
|
2651
|
-
* call-for-call (`dropped += n`) — scrollback semantics, the
|
|
2652
|
-
* same trade the transcript itself makes; the plan is exempt,
|
|
2653
|
-
* because it is current state rather than scrollback.
|
|
2654
|
-
*/
|
|
2655
|
-
const toolLog = { ev: [], dropped: 0 };
|
|
2656
|
-
const pushToolEvent = (name, input) => {
|
|
2657
|
-
// envScrub rides INTO the builder, which scrubs over a bounded
|
|
2658
|
-
// window BEFORE its caps — scrubbing after the cut both leaked a
|
|
2659
|
-
// boundary-straddling secret's prefix and grew a capped field
|
|
2660
|
-
// past the server's limits (review, 2026-09-01).
|
|
2661
|
-
const e = toolEventOf(name, input, dir.wt, envScrub);
|
|
2662
|
-
if (!e) return;
|
|
2663
|
-
if (e.t === 'plan') {
|
|
2664
|
-
const i = toolLog.ev.findIndex((x) => x.t === 'plan');
|
|
2665
|
-
if (i >= 0) toolLog.ev.splice(i, 1);
|
|
2666
|
-
toolLog.ev.push(e);
|
|
2667
|
-
} else {
|
|
2668
|
-
const last = toolLog.ev[toolLog.ev.length - 1];
|
|
2669
|
-
const sameKey =
|
|
2670
|
-
last &&
|
|
2671
|
-
last.t === e.t &&
|
|
2672
|
-
last.p === e.p &&
|
|
2673
|
-
last.q === e.q &&
|
|
2674
|
-
last.c === e.c;
|
|
2675
|
-
if (sameKey && (e.t === 'edit' || e.t === 'write')) {
|
|
2676
|
-
last.n = (last.n ?? 1) + 1;
|
|
2677
|
-
last.a = (last.a ?? 0) + (e.a ?? 0);
|
|
2678
|
-
last.d = (last.d ?? 0) + (e.d ?? 0);
|
|
2679
|
-
if (e.dl) last.dl = e.dl;
|
|
2680
|
-
} else if (sameKey) {
|
|
2681
|
-
last.n = (last.n ?? 1) + 1;
|
|
2682
|
-
} else {
|
|
2683
|
-
toolLog.ev.push(e);
|
|
2684
|
-
}
|
|
2685
|
-
}
|
|
2686
|
-
// The cap evicts the oldest NON-plan row: the plan is current
|
|
2687
|
-
// state, not scrollback — the one card the fold keeps out — and a
|
|
2688
|
-
// shed collapsed row counts its repeats, so "N steps" never
|
|
2689
|
-
// understates what the cut removed.
|
|
2690
|
-
while (toolLog.ev.length > 60) {
|
|
2691
|
-
const i = toolLog.ev.findIndex((x) => x.t !== 'plan');
|
|
2692
|
-
if (i < 0) break; // only the plan left; it stays
|
|
2693
|
-
const [shed] = toolLog.ev.splice(i, 1);
|
|
2694
|
-
toolLog.dropped = Math.min(1_000_000, toolLog.dropped + (shed?.n ?? 1));
|
|
2695
|
-
}
|
|
2696
|
-
};
|
|
2697
|
-
const narrator = makeNarrator(job.sessionId, job.id, () =>
|
|
2698
|
-
toolLog.ev.length > 0 ? toolLog : undefined
|
|
2699
|
-
);
|
|
2700
|
-
|
|
2701
|
-
|
|
2702
|
-
// THE COMMAND AUDIT — every `$ …` the CLI's stream reports, batched
|
|
2703
|
-
// to the server verbatim so an admin can read what actually ran on
|
|
2704
|
-
// this box. Same events the narrator renders and forgets; this is
|
|
2705
|
-
// the durable copy, and it carries ONLY commands — no prose, no
|
|
2706
|
-
// thinking, no file reads (the session stays private; what executed
|
|
2707
|
-
// on the shared machine is the machine's own fact to relay).
|
|
2708
|
-
// Flushed mid-turn every 25 so a long turn is not one giant loss on
|
|
2709
|
-
// a kill, and again at settle. Best-effort: a failed post drops the
|
|
2710
|
-
// batch rather than blocking the turn — the surface says it is the
|
|
2711
|
-
// machine's report, not a syscall trace.
|
|
2712
|
-
const auditBatch = [];
|
|
2713
|
-
const flushAudit = () => {
|
|
2714
|
-
if (auditBatch.length === 0) return;
|
|
2715
|
-
const commands = auditBatch.splice(0, auditBatch.length);
|
|
2716
|
-
void fetch(SESSION_COMMANDS_URL, {
|
|
2717
|
-
method: 'POST',
|
|
2718
|
-
headers: {
|
|
2719
|
-
Authorization: `Bearer ${FLEET_TOKEN}`,
|
|
2720
|
-
'User-Agent': USER_AGENT,
|
|
2721
|
-
'Content-Type': 'application/json',
|
|
2722
|
-
},
|
|
2723
|
-
signal: AbortSignal.timeout(30_000),
|
|
2724
|
-
body: JSON.stringify({
|
|
2725
|
-
sessionId: job.sessionId,
|
|
2726
|
-
turnId: job.id,
|
|
2727
|
-
runtime: rt.id,
|
|
2728
|
-
cwd: dir.wt,
|
|
2729
|
-
commands,
|
|
2730
|
-
}),
|
|
2731
|
-
}).catch(() => {
|
|
2732
|
-
/* best-effort — the audit records what reached it */
|
|
2733
|
-
});
|
|
2734
|
-
};
|
|
2735
|
-
const auditCommand = (a) => {
|
|
2736
|
-
if (a?.kind !== 'bash' || !a.command) return;
|
|
2737
|
-
// Scrubbed like every other string that leaves this box (the
|
|
2738
|
-
// narrator label, the settle answer, commit subjects, ship/merge
|
|
2739
|
-
// lines, the per-tab process report). A command line is exactly
|
|
2740
|
-
// where a secret leaks — `curl -H "authorization: <token>"`,
|
|
2741
|
-
// `PGPASSWORD=… psql` — and the audit is stored 30 days and rendered
|
|
2742
|
-
// in the admin view, so the one uplink that omitted scrub was the
|
|
2743
|
-
// one most likely to carry a plaintext secret.
|
|
2744
|
-
auditBatch.push({ command: envScrub(a.command), at: new Date().toISOString() });
|
|
2745
|
-
if (auditBatch.length >= 25) flushAudit();
|
|
2746
|
-
};
|
|
2747
|
-
try {
|
|
2748
|
-
// Files first, then the message that references them: the agent
|
|
2749
|
-
// must be able to open what it is being told about. Only the ones
|
|
2750
|
-
// that actually landed are named.
|
|
2751
|
-
const files = await fetchAttachments(dir.wt, job.attachments);
|
|
2752
|
-
const filesNote = files.length
|
|
2753
|
-
? `[FILES THE HUMAN ATTACHED TO THIS MESSAGE — already on disk in this worktree]\n${files
|
|
2754
|
-
.map((f) => `- ${f}`)
|
|
2755
|
-
.join('\n')}`
|
|
2756
|
-
: '';
|
|
2757
|
-
const message = [job.body, filesNote, adoptNote, carryNote]
|
|
2758
|
-
.filter(Boolean)
|
|
2759
|
-
.join('\n\n');
|
|
2760
|
-
// A CAPTURE chat (the board's New task conversation): its own
|
|
2761
|
-
// system prompt, its own kickoff, and the scratch planner's
|
|
2762
|
-
// READ-ONLY permission profile — the prompt says stage-never-file
|
|
2763
|
-
// and the profile is what makes "read-only" true rather than
|
|
2764
|
-
// asserted. Server-flagged per job; a server too old to flag it
|
|
2765
|
-
// simply runs an ordinary tab, which the web's version floor
|
|
2766
|
-
// prevents ever being offered.
|
|
2767
|
-
const captureTab = job.capture === true;
|
|
2768
|
-
const turnArgs = {
|
|
2769
|
-
// A plain tab has no tools to name and no session id to pass —
|
|
2770
|
-
// its kickoff asks for one complete report instead of a stream.
|
|
2771
|
-
prompt: plainTab || planTurn
|
|
2772
|
-
? WORK_TURN_KICKOFF_PLAIN({
|
|
2773
|
-
sessionName: job.sessionName,
|
|
2774
|
-
message,
|
|
2775
|
-
askedByName: job.askedByName,
|
|
2776
|
-
})
|
|
2777
|
-
: captureTab
|
|
2778
|
-
? CAPTURE_TURN_KICKOFF({
|
|
2779
|
-
sessionId: job.sessionId,
|
|
2780
|
-
sessionName: job.sessionName,
|
|
2781
|
-
message,
|
|
2782
|
-
askedByName: job.askedByName,
|
|
2783
|
-
})
|
|
2784
|
-
: WORK_TURN_KICKOFF({
|
|
2785
|
-
sessionId: job.sessionId,
|
|
2786
|
-
sessionName: job.sessionName,
|
|
2787
|
-
message,
|
|
2788
|
-
askedByName: job.askedByName,
|
|
2789
|
-
}),
|
|
2790
|
-
planPerm: captureTab,
|
|
2791
|
-
// `--permission-mode plan` INSTEAD of the build posture — never
|
|
2792
|
-
// beside `--dangerously-skip-permissions`, which silently wins
|
|
2793
|
-
// (claude.mjs, PLAN_MODE_PERM). Present only on a plan turn.
|
|
2794
|
-
...(planTurn ? { planMode: true } : {}),
|
|
2795
|
-
// The adopt turn resumes the TERMINAL conversation by forking it
|
|
2796
|
-
// into this cwd (claude: --resume <id> --fork-session). After it
|
|
2797
|
-
// speaks once, the fork lives natively here and turn 2+ is the
|
|
2798
|
-
// ordinary --continue resume path, unchanged.
|
|
2799
|
-
...(adopting ? { adoptResumeId: job.adopt.id } : {}),
|
|
2800
|
-
// THE PROJECT'S KNOWLEDGE LIBRARY (0.94.0) rides as one appended
|
|
2801
|
-
// paragraph, and only while this box holds one — see
|
|
2802
|
-
// `withProjectContext`. Resolved at spawn, not at startup, so a
|
|
2803
|
-
// library that synced a second ago is in THIS turn.
|
|
2804
|
-
...(() => {
|
|
2805
|
-
const knowledgeDir = knowledgeDirFor(repoRoot);
|
|
2806
|
-
return {
|
|
2807
|
-
// A PLAN TURN (0.97.0) runs the PLAIN contract — it has no
|
|
2808
|
-
// Flowviant tools, see `mcp` above — with no artifacts
|
|
2809
|
-
// paragraph (plan mode cannot write one) and the planning
|
|
2810
|
-
// sentence appended. Every other tab is untouched.
|
|
2811
|
-
system: planTurn
|
|
2812
|
-
? `${withProjectContext(SYSTEM_WORK_PLAIN, { knowledgeDir, artifacts: false })}\n\n${PLAN_TURN_SENTENCE}`
|
|
2813
|
-
: withProjectContext(
|
|
2814
|
-
plainTab ? SYSTEM_WORK_PLAIN : captureTab ? SYSTEM_CAPTURE : SYSTEM_WORK,
|
|
2815
|
-
// ARTIFACTS (0.94.0): every tab but the read-only capture
|
|
2816
|
-
// chat, and only while the server can show one.
|
|
2817
|
-
{ knowledgeDir, artifacts: !captureTab && getArtifactsAccepted() }
|
|
2818
|
-
),
|
|
2819
|
-
// The directory is OUTSIDE a worktree's cwd; Claude Code is
|
|
2820
|
-
// told it may read there (`--add-dir`) rather than left to
|
|
2821
|
-
// refuse a read-only capture turn a path it was just handed.
|
|
2822
|
-
knowledgeDir,
|
|
2823
|
-
};
|
|
2824
|
-
})(),
|
|
2825
|
-
// Present only when the tab named one — see brainFor.
|
|
2826
|
-
...brain,
|
|
2827
|
-
// The tab watches the CLI work. Claude needs the flag to speak
|
|
2828
|
-
// events at all (codex and agy always do); `answerFromResult`
|
|
2829
|
-
// keeps `out` — which IS the reply posted to the transcript — to
|
|
2830
|
-
// the final result, so streamed prose is narrated once and
|
|
2831
|
-
// posted once. Every line goes to the narrator above, throttled.
|
|
2832
|
-
streamJson: true,
|
|
2833
|
-
answerFromResult: true,
|
|
2834
|
-
onActivity: (a) => {
|
|
2835
|
-
narrator.line(a?.label);
|
|
2836
|
-
auditCommand(a);
|
|
2837
|
-
},
|
|
2838
|
-
// The structured twin of the line above — see toolLog.
|
|
2839
|
-
onToolEvent: pushToolEvent,
|
|
2840
|
-
// What this CLI says it can be asked for by name. Harvested off
|
|
2841
|
-
// the init event the stream already carries — no probe, no scan,
|
|
2842
|
-
// no extra spawn — and reported on the next roster poll so the
|
|
2843
|
-
// composer can autocomplete a `/`. See runtimes.mjs for why it is
|
|
2844
|
-
// learned from a turn rather than looked up.
|
|
2845
|
-
onInit: (i) => {
|
|
2846
|
-
recordSkills(i.skills);
|
|
2847
|
-
// The CLI's mounted MCP servers and connectors (0.97.0) — the
|
|
2848
|
-
// same free fact off the same event; see recordMcpServers.
|
|
2849
|
-
recordMcpServers(i.mcpServers);
|
|
2850
|
-
// The conversation this turn is actually speaking under. Held
|
|
2851
|
-
// and persisted after the turn ends, so the NEXT one resumes
|
|
2852
|
-
// this exact thread rather than whatever the directory saw
|
|
2853
|
-
// last. Last write wins on purpose: a resume that fell back to
|
|
2854
|
-
// fresh reports the fresh id, healing the marker.
|
|
2855
|
-
if (typeof i.sessionId === 'string' && i.sessionId.trim())
|
|
2856
|
-
seenClaudeSession = i.sessionId.trim();
|
|
2857
|
-
},
|
|
2858
|
-
cwd: dir.wt,
|
|
2859
|
-
mcpArgs: mcp.args,
|
|
2860
|
-
mcpEnv: mcp.env,
|
|
2861
|
-
runtime: rt.id,
|
|
2862
|
-
label: c.cyan('[tab]'),
|
|
2863
|
-
// Only codex announces one (thread.started); held here so the id
|
|
2864
|
-
// this turn actually SPOKE under is what gets persisted after it
|
|
2865
|
-
// ends. Last write wins on purpose: a failed resume that fell
|
|
2866
|
-
// back to fresh reports the fresh run's id, healing the marker.
|
|
2867
|
-
onThreadId: (id) => {
|
|
2868
|
-
seenThreadId = String(id ?? '').trim() || seenThreadId;
|
|
2869
|
-
},
|
|
2870
|
-
onSpawn: (ch) => {
|
|
2871
|
-
if (!ch) return;
|
|
2872
|
-
spawned.push(ch);
|
|
2873
|
-
// Keyed to the SESSION it serves — that id is what the machine
|
|
2874
|
-
// snapshot charges this child's memory to.
|
|
2875
|
-
workChildren.set(ch, job.sessionId);
|
|
2876
|
-
// The process exists, so the reserved slot is now counted by
|
|
2877
|
-
// the registry itself. Idempotent — the `finally` below releases
|
|
2878
|
-
// it again for every path that never got here.
|
|
2879
|
-
releaseSlot();
|
|
2880
|
-
// The CLI is spawned `detached`, so its pid IS its process
|
|
2881
|
-
// group id — and every process it starts inherits that, through
|
|
2882
|
-
// `nohup` and `setsid` alike. Remembered per SESSION rather
|
|
2883
|
-
// than per turn, because the whole point is the watcher that
|
|
2884
|
-
// outlives the turn that started it.
|
|
2885
|
-
if (ch.pid) noteSessionGroup(job.sessionId, ch.pid);
|
|
2886
|
-
if (lockPath && ch.pid) {
|
|
2887
|
-
try {
|
|
2888
|
-
const start = processStartTime(ch.pid);
|
|
2889
|
-
writeFileSync(lockPath, start ? `${ch.pid}:${start}` : String(ch.pid));
|
|
2890
|
-
} catch {
|
|
2891
|
-
/* best-effort */
|
|
2892
|
-
}
|
|
2893
|
-
}
|
|
2894
|
-
},
|
|
2895
|
-
};
|
|
2896
|
-
out = await runTurn({
|
|
2897
|
-
...turnArgs,
|
|
2898
|
-
resume,
|
|
2899
|
-
resumeThreadId: codexResumeId || claudeResumeId || undefined,
|
|
2900
|
-
resumeConversationId: agyConvId || undefined,
|
|
2901
|
-
});
|
|
2902
|
-
// A resume that produced NOTHING usually means the held
|
|
2903
|
-
// conversation is gone (a first turn that crashed before writing
|
|
2904
|
-
// state, a wiped CLI dir — or, on codex, a deleted thread). Retry
|
|
2905
|
-
// once fresh in the SAME worktree — never reset — instead of
|
|
2906
|
-
// bricking the tab forever; the retry carries no resumeThreadId,
|
|
2907
|
-
// so codex genuinely starts over rather than re-asking for the
|
|
2908
|
-
// thread that just came back empty. NEVER on an adopt turn
|
|
2909
|
-
// (`resume` is structurally false there, and the guard says so out
|
|
2910
|
-
// loud): a fresh conversation would silently discard the adoption
|
|
2911
|
-
// and answer as a new session wearing its name — the empty adopt
|
|
2912
|
-
// turn settles failed below instead.
|
|
2913
|
-
/**
|
|
2914
|
-
* …OR PRODUCED ONLY THE CLI SAYING THE CONVERSATION IS GONE.
|
|
2915
|
-
*
|
|
2916
|
-
* "Produced nothing" was the whole test, and it is not the shape
|
|
2917
|
-
* these failures take: Claude Code answers a dead `--resume` id
|
|
2918
|
-
* with a result event carrying `errors: ['No conversation found
|
|
2919
|
-
* with session ID: …']` and writes the same line to stderr, so
|
|
2920
|
-
* `out` is non-empty. The backstop never fired, the turn settled
|
|
2921
|
-
* SUCCESSFULLY with that error as its answer, and — because the
|
|
2922
|
-
* marker is only rewritten when an init event is seen, and there
|
|
2923
|
-
* was none — the dead id stayed pinned. Every later message in
|
|
2924
|
-
* that tab replied identically, with nothing on any surface able
|
|
2925
|
-
* to clear it. A tab bricked forever by its own CLI pruning its
|
|
2926
|
-
* history, which it does on its own schedule.
|
|
2927
|
-
*/
|
|
2928
|
-
if (
|
|
2929
|
-
!adopting &&
|
|
2930
|
-
resume &&
|
|
2931
|
-
(!(out || '').trim() ||
|
|
2932
|
-
resumeConversationLost(out, { runtime: rt.id, sawInit: Boolean(seenClaudeSession) }))
|
|
2933
|
-
) {
|
|
2934
|
-
out = await runTurn({ ...turnArgs, resume: false });
|
|
2935
|
-
}
|
|
2936
|
-
} finally {
|
|
2937
|
-
// The CLI has stopped printing, so stop relaying. The LINE itself
|
|
2938
|
-
// is cleared server-side at settle — clearing it here would race
|
|
2939
|
-
// the settle and blank the tab a beat before the reply lands.
|
|
2940
|
-
narrator.stop();
|
|
2941
|
-
flushAudit();
|
|
2942
|
-
for (const ch of spawned) workChildren.delete(ch);
|
|
2943
|
-
if (lockPath) {
|
|
2944
|
-
try {
|
|
2945
|
-
rmSync(lockPath, { force: true });
|
|
2946
|
-
} catch {
|
|
2947
|
-
/* best-effort */
|
|
2948
|
-
}
|
|
2949
|
-
}
|
|
2950
|
-
if (mcp.dir) rmSync(mcp.dir, { recursive: true, force: true });
|
|
2951
|
-
}
|
|
2952
|
-
// Persist the codex thread id AFTER the turn ends, so the next turn
|
|
2953
|
-
// resumes exactly the conversation that just spoke. Shape-guarded
|
|
2954
|
-
// before it ever touches disk — it later rides in argv as
|
|
2955
|
-
// `resume <id>` — and best-effort, like the runtime pin: an
|
|
2956
|
-
// unwritable marker just means the tab runs fresh next turn.
|
|
2957
|
-
// The conversation THIS TAB just spoke under, pinned so the next
|
|
2958
|
-
// turn resumes it by id rather than asking the directory. Written
|
|
2959
|
-
// after the turn for the same reason codex's is: an id learned
|
|
2960
|
-
// mid-turn is only true once the turn that learned it finished.
|
|
2961
|
-
if (
|
|
2962
|
-
rt.id === 'claude' &&
|
|
2963
|
-
seenClaudeSession &&
|
|
2964
|
-
/^[A-Za-z0-9_-]{8,64}$/.test(seenClaudeSession)
|
|
2965
|
-
) {
|
|
2966
|
-
const convMarker = sessionMetaPath(dir.wt, 'flowviant-claude-session', job.sessionId);
|
|
2967
|
-
if (convMarker) {
|
|
2968
|
-
try {
|
|
2969
|
-
writeFileSync(convMarker, seenClaudeSession);
|
|
2970
|
-
} catch {
|
|
2971
|
-
/* best-effort — the next turn re-learns it */
|
|
2972
|
-
}
|
|
2973
|
-
}
|
|
2974
|
-
}
|
|
2975
|
-
if (rt.id === 'codex' && seenThreadId && CODEX_THREAD_RE.test(seenThreadId)) {
|
|
2976
|
-
const threadMarker = sessionMetaPath(dir.wt, 'flowviant-codex-thread', job.sessionId);
|
|
2977
|
-
if (threadMarker) {
|
|
2978
|
-
try {
|
|
2979
|
-
writeFileSync(threadMarker, seenThreadId);
|
|
2980
|
-
} catch {
|
|
2981
|
-
/* best-effort */
|
|
2982
|
-
}
|
|
2983
|
-
}
|
|
2984
|
-
}
|
|
2985
|
-
const answer = (out || '').trim();
|
|
2986
|
-
// No output at all smells like a dead MCP credential (the lane
|
|
2987
|
-
// workers' no-sentinel case) — drop the cached token so the next
|
|
2988
|
-
// turn re-mints instead of failing the same way forever.
|
|
2989
|
-
if (!answer) workTokens.delete(job.sessionId);
|
|
2990
|
-
if (adopting && !answer) {
|
|
2991
|
-
// The fork came back with nothing — the terminal session's
|
|
2992
|
-
// transcript is most likely gone (cleaned, expired, deleted). Say
|
|
2993
|
-
// exactly that; no sessionRef is recorded, so the server keeps
|
|
2994
|
-
// offering the adoption and a retry after the user checks is cheap.
|
|
2995
|
-
await settleWorkTurn(job.id, {
|
|
2996
|
-
ok: false,
|
|
2997
|
-
answer: "Couldn't resume the terminal session — it may have been removed.",
|
|
2998
|
-
// Whatever it DID before coming back empty is exactly the
|
|
2999
|
-
// question a failed turn's log answers.
|
|
3000
|
-
...(toolLog.ev.length > 0 ? { tools: toolLog } : {}),
|
|
3001
|
-
});
|
|
3002
|
-
warn('adopt turn produced no output — settled as failed');
|
|
3003
|
-
return;
|
|
3004
|
-
}
|
|
3005
|
-
// Persist the agy conversation id once the turn actually SPOKE — an
|
|
3006
|
-
// adopted tab pins the id it moved in (the adopt hint); a new tab
|
|
3007
|
-
// learns the one its first fresh turn just created, from agy's own
|
|
3008
|
-
// cwd registry. From here on the marker is the tab's identity and
|
|
3009
|
-
// the registry is never trusted again.
|
|
3010
|
-
if (rt.id === 'antigravity' && answer.length > 0) {
|
|
3011
|
-
const convMarker = sessionMetaPath(dir.wt, 'flowviant-agy-conversation', job.sessionId);
|
|
3012
|
-
if (convMarker && !existsSync(convMarker)) {
|
|
3013
|
-
const learned = adopting ? job.adopt.id : agyRegistryLookup(dir.wt);
|
|
3014
|
-
if (learned && AGY_CONV_RE.test(learned)) {
|
|
3015
|
-
try {
|
|
3016
|
-
writeFileSync(convMarker, learned);
|
|
3017
|
-
} catch {
|
|
3018
|
-
/* best-effort — an unpinned tab resumes via --continue's cwd key */
|
|
3019
|
-
}
|
|
3020
|
-
}
|
|
3021
|
-
}
|
|
3022
|
-
}
|
|
3023
|
-
await settleWorkTurn(job.id, {
|
|
3024
|
-
ok: answer.length > 0,
|
|
3025
|
-
answer:
|
|
3026
|
-
answer.length > 0
|
|
3027
|
-
? // Scrub: a reply can quote config or env-adjacent code.
|
|
3028
|
-
envScrub(answer).slice(0, 16000)
|
|
3029
|
-
: 'the turn produced no output on the machine — its CLI may be signed out; try again',
|
|
3030
|
-
// Only a turn that actually SPOKE proves a conversation lives
|
|
3031
|
-
// here. Recording the path unconditionally is how a crashed first
|
|
3032
|
-
// turn used to brick resume for the session's whole life.
|
|
3033
|
-
...(answer.length > 0 ? { sessionRef: dir.wt } : {}),
|
|
3034
|
-
// The turn's tool log, in final form — the durable copy that lands
|
|
3035
|
-
// on the settled message (the live copy on the record is cleared
|
|
3036
|
-
// at settle). Already scrubbed at collection.
|
|
3037
|
-
...(toolLog.ev.length > 0 ? { tools: toolLog } : {}),
|
|
3038
|
-
});
|
|
3039
|
-
if (answer.length > 0) ok(`${c.cyan('tab')} ${c.dim('— replied in the session')}`);
|
|
3040
|
-
else warn('session turn produced no output — settled as failed');
|
|
3041
|
-
} catch (e) {
|
|
3042
|
-
await settleWorkTurn(job.id, {
|
|
3043
|
-
ok: false,
|
|
3044
|
-
// Scrub, like every string that leaves this machine: an exception
|
|
3045
|
-
// routinely quotes command output, and command output can quote a
|
|
3046
|
-
// synced secret.
|
|
3047
|
-
answer: envScrub(String(e?.message ?? 'the session turn failed')).slice(0, 2000),
|
|
3048
|
-
// "What did it do before it failed" is exactly the question a
|
|
3049
|
-
// crashed turn's log answers — same spread as the main settle.
|
|
3050
|
-
...(toolLog.ev.length > 0 ? { tools: toolLog } : {}),
|
|
3051
|
-
});
|
|
3052
|
-
warn(`session turn failed: ${e?.message ?? e}`);
|
|
3053
|
-
} finally {
|
|
3054
|
-
workAnswering.delete(job.id);
|
|
3055
|
-
// Nothing spawned, or everything already has: releasing twice is the
|
|
3056
|
-
// normal case and costs nothing. A reservation that leaked would
|
|
3057
|
-
// shrink this machine's ceiling for the life of the process.
|
|
3058
|
-
releaseSlot();
|
|
3059
|
-
// The turn just changed the directory — say what it looks like now,
|
|
3060
|
-
// whether it succeeded or blew up (a failed turn can still have
|
|
3061
|
-
// written half a file, and the tab should show that honestly). NOT
|
|
3062
|
-
// awaited: this runs inside the session's chain, and a slow POST
|
|
3063
|
-
// would delay the next turn of that tab behind a readout.
|
|
3064
|
-
void reportPlaceWorktrees(job.sessionId).catch(() => {});
|
|
3065
|
-
burstListeners(job.sessionId);
|
|
3066
|
-
// …and relay what it wrote to SHOW its owner (2026-09-22) — the same
|
|
3067
|
-
// beat, the same reason it is not awaited. A failed turn's half-drawn
|
|
3068
|
-
// page is still what the turn left, and the tab should see it.
|
|
3069
|
-
if (artifactScan) {
|
|
3070
|
-
void artifacts
|
|
3071
|
-
.report({
|
|
3072
|
-
placeDir: artifactScan.dir,
|
|
3073
|
-
before: artifactScan.before,
|
|
3074
|
-
sessionId: job.sessionId,
|
|
3075
|
-
turnId: job.id,
|
|
3076
|
-
})
|
|
3077
|
-
.catch(() => {});
|
|
3078
|
-
}
|
|
3079
|
-
}
|
|
3080
|
-
});
|
|
3081
|
-
}
|
|
3082
|
-
};
|
|
3083
|
-
|
|
3084
|
-
/**
|
|
3085
|
-
* KEEP EACH PERSON'S MANUAL WORKTREE FRESH.
|
|
3086
|
-
*
|
|
3087
|
-
* Every teammate's Workbench tabs share one directory of their own on a
|
|
3088
|
-
* branch of their own — which is not a preference, it is what git allows:
|
|
3089
|
-
* two worktrees cannot have the same branch checked out, so "everyone works
|
|
3090
|
-
* on main" is only true for the machine's OPERATOR, whose place is the
|
|
3091
|
-
* checkout itself. Everyone else needs a branch, and a branch left alone
|
|
3092
|
-
* drifts behind main until the first thing they do in a new tab is a merge
|
|
3093
|
-
* they did not ask for.
|
|
3094
|
-
*
|
|
3095
|
-
* FAST-FORWARD ONLY, and that is the whole safety argument. If their branch
|
|
3096
|
-
* has no commits of its own it simply catches up, which is the ordinary case
|
|
3097
|
-
* and the one worth automating. The moment it HAS diverged, this stops and
|
|
3098
|
-
* leaves it exactly as it is: their commits are theirs, a rebase would
|
|
3099
|
-
* rewrite them under somebody who is not looking, and a merge would put a
|
|
3100
|
-
* commit in their history that they did not make. Their own Claude can fold
|
|
3101
|
-
* base in whenever they ask it to.
|
|
3102
|
-
*
|
|
3103
|
-
* Guarded three ways: a DIRTY tree is left alone (uncommitted work outranks
|
|
3104
|
-
* freshness), a place with a live lock is skipped (a turn is standing in it),
|
|
3105
|
-
* and the whole thing is silent — nothing here reports, warns or blocks.
|
|
3106
|
-
*/
|
|
3107
|
-
const freshenManualPlaces = () => {
|
|
3108
|
-
let dir;
|
|
3109
|
-
try {
|
|
3110
|
-
dir = readdirSync(join(baseDir, 'sessions'));
|
|
3111
|
-
} catch {
|
|
3112
|
-
return; // no worktrees yet
|
|
3113
|
-
}
|
|
3114
|
-
for (const place of dir) {
|
|
3115
|
-
// Only a PERSON's manual place. An agent's own worktree (`a-<id>`) is
|
|
3116
|
-
// deliberately not touched: its branch is the reviewable unit, and
|
|
3117
|
-
// moving it under a review would change what somebody is deciding about.
|
|
3118
|
-
if (!place.startsWith('u-') || !isSafePathSegment(place)) continue;
|
|
3119
|
-
if (placeLocks.has(place)) continue;
|
|
3120
|
-
const wt = join(baseDir, 'sessions', place);
|
|
3121
|
-
try {
|
|
3122
|
-
if (git(['status', '--porcelain'], wt).trim() !== '') continue;
|
|
3123
|
-
git(['merge', '--ff-only', baseRef()], wt);
|
|
3124
|
-
} catch {
|
|
3125
|
-
/* diverged, or something else is going on in there. Leave it. */
|
|
3126
|
-
}
|
|
3127
|
-
}
|
|
3128
|
-
};
|
|
3129
|
-
|
|
3130
|
-
const state = {
|
|
3131
|
-
shipping,
|
|
209
|
+
const {
|
|
210
|
+
pendingWorkReports,
|
|
3132
211
|
pendingShipReports,
|
|
212
|
+
postBestEffort,
|
|
213
|
+
REJECT_RETRY_MS,
|
|
214
|
+
settleWorkTurn,
|
|
215
|
+
settleShip,
|
|
216
|
+
flushWorkReports,
|
|
217
|
+
sweepMergedSessionBranch,
|
|
218
|
+
} = createWorkReportQueue({
|
|
219
|
+
repoRoot,
|
|
220
|
+
baseRef,
|
|
221
|
+
workAttempts,
|
|
222
|
+
artifacts,
|
|
223
|
+
landed,
|
|
224
|
+
onRepoChanged,
|
|
225
|
+
reportPlaceWorktrees,
|
|
226
|
+
burstListeners,
|
|
227
|
+
});
|
|
228
|
+
const { workTokens, mintWorkToken } = createWorkSessionTokens({ getLeaseTtl });
|
|
229
|
+
const { makeNarrator, sayTurnDeferred, lastDeferSaid } = createWorkNarration();
|
|
230
|
+
const { fetchAttachments } = createWorkAttachments();
|
|
231
|
+
const { sessionRuntime, agyRegistryLookup } = createWorkSessionRuntime({ sessionMetaPath });
|
|
232
|
+
const { carryDirtyState } = createAdoptCarry({ sessionMetaPath });
|
|
233
|
+
const { processWorkTurns, workAnswering } = createWorkSessionTurns({
|
|
234
|
+
repoRoot,
|
|
235
|
+
baseDir,
|
|
236
|
+
getMcpUrl,
|
|
237
|
+
getArtifactsAccepted,
|
|
238
|
+
admit,
|
|
239
|
+
inPlace,
|
|
3133
240
|
workChildren,
|
|
3134
|
-
|
|
3135
|
-
|
|
3136
|
-
|
|
3137
|
-
|
|
3138
|
-
|
|
3139
|
-
|
|
241
|
+
workAttempts,
|
|
242
|
+
sessionPlaces,
|
|
243
|
+
placeOf,
|
|
244
|
+
placeWtFor,
|
|
245
|
+
sessionMetaPath,
|
|
246
|
+
beforeArtifacts,
|
|
247
|
+
artifacts,
|
|
248
|
+
settleWorkTurn,
|
|
249
|
+
pendingWorkReports,
|
|
250
|
+
workTokens,
|
|
251
|
+
mintWorkToken,
|
|
252
|
+
sessionRuntime,
|
|
253
|
+
agyRegistryLookup,
|
|
254
|
+
carryDirtyState,
|
|
255
|
+
fetchAttachments,
|
|
256
|
+
makeNarrator,
|
|
257
|
+
sayTurnDeferred,
|
|
258
|
+
lastDeferSaid,
|
|
259
|
+
noteSessionGroup,
|
|
260
|
+
reportPlaceWorktrees,
|
|
261
|
+
burstListeners,
|
|
262
|
+
});
|
|
263
|
+
|
|
264
|
+
/*
|
|
265
|
+
* THE SHARED MAPS GO BY NAME (2026-09-26, SOLID F164). They were one
|
|
266
|
+
* eight-field `state` bag handed to six factories that each read one to
|
|
267
|
+
* three of it, which hid every worker's real dependencies. Each factory now
|
|
268
|
+
* names exactly the maps it reads or writes, so a reader sees at the call
|
|
269
|
+
* which lanes share which map (workChildren: plans, review, turns; the
|
|
270
|
+
* publication maps: turns and merges).
|
|
271
|
+
*/
|
|
3140
272
|
|
|
3141
273
|
const { processDiffJobs } = createWorkDiffs({
|
|
3142
274
|
repoRoot,
|
|
@@ -3148,7 +280,6 @@ export function createWorkManager({
|
|
|
3148
280
|
|
|
3149
281
|
const { processPrJobs } = createWorkPullRequests({
|
|
3150
282
|
placeOf,
|
|
3151
|
-
REPO_PLACE,
|
|
3152
283
|
repoRoot,
|
|
3153
284
|
baseDir,
|
|
3154
285
|
baseRef,
|
|
@@ -3161,41 +292,45 @@ export function createWorkManager({
|
|
|
3161
292
|
inPlace,
|
|
3162
293
|
placeOf,
|
|
3163
294
|
settleShip,
|
|
3164
|
-
REPO_PLACE,
|
|
3165
295
|
repoRoot,
|
|
3166
296
|
baseDir,
|
|
3167
297
|
gitNet,
|
|
3168
298
|
baseRef,
|
|
3169
299
|
placeWtFor,
|
|
3170
|
-
turnLockedByLivePid,
|
|
3171
300
|
sessionMetaPath,
|
|
3172
|
-
|
|
301
|
+
shipping,
|
|
302
|
+
pendingShipReports,
|
|
3173
303
|
});
|
|
3174
304
|
|
|
3175
305
|
const { processAgentPlanJobs, planning } = createWorkAgentPlans({
|
|
3176
306
|
postBestEffort,
|
|
3177
307
|
baseDir,
|
|
3178
308
|
baseRef,
|
|
3179
|
-
REPO_PLACE,
|
|
3180
309
|
inPlace,
|
|
3181
310
|
repoRoot,
|
|
3182
311
|
admit,
|
|
3183
|
-
|
|
312
|
+
placeLocks,
|
|
313
|
+
workChildren,
|
|
3184
314
|
});
|
|
3185
315
|
|
|
3186
|
-
|
|
316
|
+
// The check and the pre-review are built with only what each one uses; the
|
|
317
|
+
// review entry beat is handed the two and owns only their order.
|
|
318
|
+
const { runCheck } = createWorkAgentCheck({
|
|
3187
319
|
repoRoot,
|
|
3188
320
|
postBestEffort,
|
|
321
|
+
workChildren,
|
|
322
|
+
groupKillChildren,
|
|
323
|
+
});
|
|
324
|
+
const { runPrecheck } = createWorkAgentPrecheck({
|
|
3189
325
|
baseRef,
|
|
3190
326
|
admit,
|
|
3191
327
|
sessionMetaPath,
|
|
3192
|
-
|
|
328
|
+
workChildren,
|
|
3193
329
|
});
|
|
330
|
+
const { runReviewEntry } = createWorkAgentReview({ runCheck, runPrecheck });
|
|
3194
331
|
|
|
3195
332
|
const { processAgentTurnJobs, settleAgentTurns, agentTurns, agentChildren, agentReported } = createWorkAgentTurns({
|
|
3196
333
|
REJECT_RETRY_MS,
|
|
3197
|
-
CODEX_THREAD_RE,
|
|
3198
|
-
resumeConversationLost,
|
|
3199
334
|
baseRef,
|
|
3200
335
|
inPlace,
|
|
3201
336
|
baseDir,
|
|
@@ -3203,7 +338,6 @@ export function createWorkManager({
|
|
|
3203
338
|
fetchPublishedBranch,
|
|
3204
339
|
placeWtFor,
|
|
3205
340
|
sessionMetaPath,
|
|
3206
|
-
brainFor,
|
|
3207
341
|
beforeArtifacts,
|
|
3208
342
|
getArtifactsAccepted,
|
|
3209
343
|
noteSessionGroup,
|
|
@@ -3212,7 +346,9 @@ export function createWorkManager({
|
|
|
3212
346
|
runReviewEntry,
|
|
3213
347
|
publishAgentBranch,
|
|
3214
348
|
admit,
|
|
3215
|
-
|
|
349
|
+
workChildren,
|
|
350
|
+
agentPublished,
|
|
351
|
+
agentRemoteAt,
|
|
3216
352
|
});
|
|
3217
353
|
|
|
3218
354
|
const { processAgentMergeJobs, agentMerges } = createWorkAgentMerges({
|
|
@@ -3224,13 +360,28 @@ export function createWorkManager({
|
|
|
3224
360
|
runReviewEntry,
|
|
3225
361
|
onRepoChanged,
|
|
3226
362
|
landed,
|
|
3227
|
-
|
|
363
|
+
agentRemoteAt,
|
|
364
|
+
agentPublished,
|
|
365
|
+
});
|
|
366
|
+
|
|
367
|
+
// Built after the agent lane: retirement stops a stopped agent's CLI, and
|
|
368
|
+
// only the agent lane knows which child that is.
|
|
369
|
+
const { heldSessionIds, retireWorkSessions, freshenManualPlaces } = createWorkRetire({
|
|
370
|
+
repoRoot,
|
|
371
|
+
baseDir,
|
|
372
|
+
baseRef,
|
|
373
|
+
placeOf,
|
|
374
|
+
placeLocks,
|
|
375
|
+
shipping,
|
|
376
|
+
workTokens,
|
|
377
|
+
agentChildren,
|
|
378
|
+
sweepMergedSessionBranch,
|
|
3228
379
|
});
|
|
3229
380
|
|
|
3230
381
|
const { processKillJobs } = createWorkProcesses({
|
|
3231
382
|
placeDir,
|
|
3232
383
|
reportPlaceWorktrees,
|
|
3233
|
-
|
|
384
|
+
sessionGroups,
|
|
3234
385
|
});
|
|
3235
386
|
|
|
3236
387
|
/**
|
|
@@ -3283,7 +434,7 @@ export function createWorkManager({
|
|
|
3283
434
|
shutdownWork,
|
|
3284
435
|
workBusy,
|
|
3285
436
|
// The machine's own admission answer, and what it is counting. Handed to
|
|
3286
|
-
// the loop so the lanes
|
|
437
|
+
// the loop so the lanes it owns — the wiki cartographer (wikiRunner.mjs) — ask the
|
|
3287
438
|
// same question, and so the machine snapshot can charge memory to the work
|
|
3288
439
|
// holding it.
|
|
3289
440
|
admit,
|