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.
Files changed (141) hide show
  1. package/bin/cli.mjs +87 -474
  2. package/bin/lib/admission.mjs +19 -3
  3. package/bin/lib/agentPlan.mjs +2 -2
  4. package/bin/lib/agentTaskKinds.mjs +93 -0
  5. package/bin/lib/agySessions.mjs +158 -0
  6. package/bin/lib/artifactPolicy.mjs +44 -0
  7. package/bin/lib/artifactPreview.mjs +52 -0
  8. package/bin/lib/artifactReporter.mjs +163 -0
  9. package/bin/lib/artifacts.mjs +47 -227
  10. package/bin/lib/authReject.mjs +16 -4
  11. package/bin/lib/authproxy.mjs +47 -304
  12. package/bin/lib/boxIdentity.mjs +245 -0
  13. package/bin/lib/childEnv.mjs +11 -9
  14. package/bin/lib/claude.mjs +14 -851
  15. package/bin/lib/claudeAuth.mjs +3 -3
  16. package/bin/lib/claudePosture.mjs +367 -0
  17. package/bin/lib/claudeSessions.mjs +411 -0
  18. package/bin/lib/claudeStream.mjs +207 -0
  19. package/bin/lib/cliLimit.mjs +34 -0
  20. package/bin/lib/commandSpecs.mjs +173 -0
  21. package/bin/lib/config.mjs +11 -42
  22. package/bin/lib/credentialDate.mjs +22 -0
  23. package/bin/lib/credentials.mjs +21 -26
  24. package/bin/lib/daemonLogging.mjs +55 -0
  25. package/bin/lib/daemonState.mjs +64 -0
  26. package/bin/lib/deploy.mjs +148 -647
  27. package/bin/lib/deployCheckout.mjs +265 -0
  28. package/bin/lib/deployConfig.mjs +142 -0
  29. package/bin/lib/deployRunner.mjs +311 -0
  30. package/bin/lib/deployWire.mjs +41 -0
  31. package/bin/lib/desktopContract.mjs +56 -105
  32. package/bin/lib/env.mjs +49 -484
  33. package/bin/lib/fleet.mjs +184 -2396
  34. package/bin/lib/fleetJobs.mjs +120 -0
  35. package/bin/lib/fleetPost.mjs +40 -0
  36. package/bin/lib/fleetReports.mjs +392 -0
  37. package/bin/lib/fleetRoster.mjs +470 -0
  38. package/bin/lib/fleetStartup.mjs +196 -0
  39. package/bin/lib/fleetWake.mjs +45 -0
  40. package/bin/lib/fleetWire.mjs +46 -0
  41. package/bin/lib/git.mjs +34 -3
  42. package/bin/lib/gitMergeIdentity.mjs +51 -0
  43. package/bin/lib/grant.mjs +5 -3
  44. package/bin/lib/help.mjs +3 -125
  45. package/bin/lib/holder.mjs +482 -0
  46. package/bin/lib/instance.mjs +124 -46
  47. package/bin/lib/knowledge.mjs +16 -799
  48. package/bin/lib/knowledgeFetch.mjs +38 -0
  49. package/bin/lib/knowledgeFileSync.mjs +112 -0
  50. package/bin/lib/knowledgeFiles.mjs +487 -0
  51. package/bin/lib/knowledgeLibrary.mjs +249 -0
  52. package/bin/lib/landed.mjs +2 -1
  53. package/bin/lib/listeners.mjs +99 -141
  54. package/bin/lib/localSessions.mjs +30 -555
  55. package/bin/lib/login.mjs +3 -2
  56. package/bin/lib/loginPath.mjs +3 -5
  57. package/bin/lib/machineDisconnect.mjs +203 -0
  58. package/bin/lib/machineEnv.mjs +22 -0
  59. package/bin/lib/machineLimits.mjs +61 -0
  60. package/bin/lib/machineListing.mjs +338 -0
  61. package/bin/lib/machines.mjs +78 -455
  62. package/bin/lib/machinesCommand.mjs +214 -0
  63. package/bin/lib/mcp-cli.mjs +2 -1
  64. package/bin/lib/patchRevert.mjs +60 -0
  65. package/bin/lib/prWorkflow.mjs +157 -0
  66. package/bin/lib/preflight.mjs +2 -1
  67. package/bin/lib/pressurePolicy.mjs +104 -0
  68. package/bin/lib/preview.mjs +2 -272
  69. package/bin/lib/previewFramePolicy.mjs +112 -0
  70. package/bin/lib/previewGatePolicy.mjs +236 -0
  71. package/bin/lib/previewRegistry.mjs +235 -0
  72. package/bin/lib/printable.mjs +2 -2
  73. package/bin/lib/procRegistry.mjs +28 -18
  74. package/bin/lib/procScan.mjs +146 -0
  75. package/bin/lib/processes.mjs +29 -37
  76. package/bin/lib/projectToolRuntimes.mjs +138 -0
  77. package/bin/lib/projectTools.mjs +13 -88
  78. package/bin/lib/prompts.mjs +28 -42
  79. package/bin/lib/repoPath.mjs +26 -0
  80. package/bin/lib/resources.mjs +7 -69
  81. package/bin/lib/resumeLost.mjs +107 -0
  82. package/bin/lib/runTurn.mjs +324 -0
  83. package/bin/lib/runtimeAntigravity.mjs +230 -0
  84. package/bin/lib/runtimeCapabilities.mjs +334 -0
  85. package/bin/lib/runtimeClaude.mjs +126 -0
  86. package/bin/lib/runtimeCodex.mjs +217 -0
  87. package/bin/lib/runtimeDetection.mjs +121 -0
  88. package/bin/lib/runtimeEvents.mjs +482 -0
  89. package/bin/lib/runtimeOfferings.mjs +4 -1
  90. package/bin/lib/runtimes.mjs +42 -1402
  91. package/bin/lib/safeFileName.mjs +74 -0
  92. package/bin/lib/shipMerge.mjs +33 -2
  93. package/bin/lib/standDownExit.mjs +181 -0
  94. package/bin/lib/standDownGate.mjs +48 -0
  95. package/bin/lib/startProject.mjs +279 -0
  96. package/bin/lib/trace.mjs +3 -3
  97. package/bin/lib/turnLock.mjs +68 -0
  98. package/bin/lib/turnProfile.mjs +77 -0
  99. package/bin/lib/uninstall.mjs +55 -45
  100. package/bin/lib/uninstallCopies.mjs +112 -0
  101. package/bin/lib/update.mjs +30 -17
  102. package/bin/lib/uplinkScrub.mjs +265 -0
  103. package/bin/lib/vault.mjs +22 -151
  104. package/bin/lib/vaultDiff.mjs +183 -0
  105. package/bin/lib/versionOrder.mjs +42 -0
  106. package/bin/lib/views.mjs +35 -12
  107. package/bin/lib/wikiRunner.mjs +630 -0
  108. package/bin/lib/work.mjs +188 -3037
  109. package/bin/lib/workAdoptCarry.mjs +94 -0
  110. package/bin/lib/workAgentCheck.mjs +278 -0
  111. package/bin/lib/workAgentMerges.mjs +54 -142
  112. package/bin/lib/workAgentPlans.mjs +14 -12
  113. package/bin/lib/workAgentPrecheck.mjs +393 -0
  114. package/bin/lib/workAgentPublish.mjs +177 -0
  115. package/bin/lib/workAgentReview.mjs +8 -666
  116. package/bin/lib/workAgentTurnBegun.mjs +109 -0
  117. package/bin/lib/workAgentTurnExecution.mjs +604 -0
  118. package/bin/lib/workAgentTurnOutcome.mjs +207 -0
  119. package/bin/lib/workAgentTurnReports.mjs +189 -0
  120. package/bin/lib/workAgentTurns.mjs +92 -897
  121. package/bin/lib/workAttachments.mjs +93 -0
  122. package/bin/lib/workBrain.mjs +53 -0
  123. package/bin/lib/workChildren.mjs +110 -0
  124. package/bin/lib/workCommandAudit.mjs +62 -0
  125. package/bin/lib/workDiffs.mjs +2 -1
  126. package/bin/lib/workNarration.mjs +152 -0
  127. package/bin/lib/workPlaceReports.mjs +426 -0
  128. package/bin/lib/workPlaces.mjs +228 -0
  129. package/bin/lib/workPreviews.mjs +31 -9
  130. package/bin/lib/workProcesses.mjs +4 -5
  131. package/bin/lib/workPullRequests.mjs +52 -126
  132. package/bin/lib/workReportQueue.mjs +254 -0
  133. package/bin/lib/workRetire.mjs +185 -0
  134. package/bin/lib/workSessionGroups.mjs +166 -0
  135. package/bin/lib/workSessionRuntime.mjs +164 -0
  136. package/bin/lib/workSessionTokens.mjs +68 -0
  137. package/bin/lib/workSessionTurns.mjs +892 -0
  138. package/bin/lib/workShip.mjs +9 -29
  139. package/bin/lib/workToolLog.mjs +76 -0
  140. package/package.json +3 -2
  141. package/bin/lib/patch.mjs +0 -363
@@ -1,26 +1,33 @@
1
- /** Agent turns, trace delivery, admission, and held settlement reports. */
2
- import { limitLine } from './workAgentReview.mjs';
3
- import { existsSync, readFileSync, writeFileSync } from 'node:fs';
4
- import { join } from 'node:path';
5
- import { FLEET_URL, FLEET_TOKEN, USER_AGENT, PROJECT_ID } from './config.mjs';
6
- import { setRuntimeLimit } from './desktopContract.mjs';
7
- import { git, isSafePathSegment } from './git.mjs';
8
- import { parseTurnResult } from './agentPlan.mjs';
9
- import { stashCard } from './agentCards.mjs';
1
+ /**
2
+ * THE AGENT-TURN LANE: which offered turns run on this machine, and that each
3
+ * one is settled exactly once.
4
+ *
5
+ * Since the 2026-09-26 SOLID pass (F036) this file is the coordinator and
6
+ * nothing else. It admits a roster's turns against the machine's pressure,
7
+ * re-POSTs a finished turn's held body instead of re-running its CLI, refuses
8
+ * the turns no box should run (a traversal place, a card that is gone), takes
9
+ * the agent's place lock as a WRITER, publishes the branch after the settle,
10
+ * and settles everything in flight when the machine moves away. What it hands
11
+ * off, each to the module named for it:
12
+ * - every POST and the held-body map: workAgentTurnReports.mjs
13
+ * - running one turn in its worktree: workAgentTurnExecution.mjs
14
+ * - the begun-guard: workAgentTurnBegun.mjs
15
+ * - what a finished (or failed) run reports: workAgentTurnOutcome.mjs
16
+ *
17
+ * The split exposed the defect it fixes: a run that THREW left its turn
18
+ * unsettled, so the next offer ran it again. The lane now catches, measures
19
+ * what the run had learned, and settles `nothing` once — unless the run had
20
+ * already sent a settle, which always outranks the crash.
21
+ */
22
+ import { existsSync } from 'node:fs';
23
+ import { isSafePathSegment } from './git.mjs';
10
24
  import { c, note } from './ui.mjs';
11
- import { runTurn } from './claude.mjs';
12
- import { SYSTEM_AGENT_FOR, agentTaskKindOf, unknownAgentTaskKind, AGENT_TASK_KICKOFF, AGENT_TASK_SPEC, AGENT_HUMAN_KICKOFF, withProjectContext } from './prompts.mjs';
13
- import { knowledgeDirFor } from './knowledge.mjs';
14
- import { changedArtifacts, scanArtifacts } from './artifacts.mjs';
15
- import { scrub as envScrub } from './env.mjs';
16
- import { canRun, recordSkills, recordMcpServers, toolEventOf, CLAUDE_TOOL_PROSE_KINDS, RUNTIMES } from './runtimes.mjs';
17
- import { makeTraceRelay } from './trace.mjs';
18
- import { readBaseTools, prepareAgentTools } from './projectTools.mjs';
25
+ import { createAgentTurnReports } from './workAgentTurnReports.mjs';
26
+ import { createAgentTurnExecution } from './workAgentTurnExecution.mjs';
27
+ import { failedTurnSettlement } from './workAgentTurnOutcome.mjs';
19
28
 
20
29
  export function createWorkAgentTurns({
21
30
  REJECT_RETRY_MS,
22
- CODEX_THREAD_RE,
23
- resumeConversationLost,
24
31
  baseRef,
25
32
  inPlace,
26
33
  baseDir,
@@ -28,7 +35,6 @@ export function createWorkAgentTurns({
28
35
  fetchPublishedBranch,
29
36
  placeWtFor,
30
37
  sessionMetaPath,
31
- brainFor,
32
38
  beforeArtifacts,
33
39
  getArtifactsAccepted,
34
40
  noteSessionGroup,
@@ -37,14 +43,19 @@ export function createWorkAgentTurns({
37
43
  runReviewEntry,
38
44
  publishAgentBranch,
39
45
  admit,
40
- state,
46
+ workChildren,
47
+ agentPublished,
48
+ agentRemoteAt,
41
49
  }) {
42
- const AGENT_TURN_DONE_URL = FLEET_URL.replace(/\/agents\/?$/, '/agent-turn-done');
43
- const AGENT_TRACE_URL = FLEET_URL.replace(/\/agents\/?$/, '/agent-trace');
44
- const AGENT_ACTIVITY_URL = FLEET_URL.replace(/\/agents\/?$/, '/agent-activity');
45
- const AGENT_PARKED_URL = FLEET_URL.replace(/\/agents\/?$/, '/agent-parked');
46
-
47
- const { workChildren, agentPublished, agentRemoteAt } = state;
50
+ const {
51
+ postAgentTurn,
52
+ postAgentTrace,
53
+ postAgentActivity,
54
+ postAgentParked,
55
+ pruneHeldReports,
56
+ agentReported,
57
+ agentRejectedUntil,
58
+ } = createAgentTurnReports({ REJECT_RETRY_MS });
48
59
 
49
60
  // ── AGENT TURNS: one task per prompt ───────────────────────────────────────
50
61
  //
@@ -79,164 +90,25 @@ export function createWorkAgentTurns({
79
90
  */
80
91
  const agentChildren = new Map(); // placeId -> child process
81
92
 
82
- /** Returns the server's reply, because it carries ONE instruction the machine
83
- * can act on immediately: `review: true` means the agent's queue just
84
- * emptied, so run the project's own check in the worktree we are already
85
- * standing in. A job lane for that would need a claim, a floor and a settle
86
- * to say something this reply already can. */
87
- /**
88
- * Turns whose work is DONE but whose settle has not landed, keyed to the
89
- * finished BODY. The delivery half of what `pendingWorkReports` is for a
90
- * tab: a settle that fails to POST must not re-run the turn — that is a
91
- * second CLI, a second set of commits, and the operator's quota spent again
92
- * — but a guard that only SKIPPED left the other half undone. One failed
93
- * POST parked the agent for the server's whole six-hour expiry, holding a
94
- * cap slot the entire time, and then expired into "nobody ran it" — a false
95
- * sentence about a turn this machine finished. The server's settle is
96
- * idempotent (conditional on the row still being pending), so a re-offer of
97
- * a held turn re-POSTs the stored body instead: safe, and it lands the
98
- * moment the network heals rather than six hours later.
99
- *
100
- * BOUNDED by the roster itself: a held body's clock is refreshed while the
101
- * server keeps offering its turn, and once offering stops — settled by the
102
- * re-POST, or expired server-side — the grace below is all that keeps it.
103
- */
104
- const agentReported = new Map(); // turnId -> { body, at }
105
- const agentRejectedUntil = new Map(); // turnId -> earliest re-POST of a refused body
106
- const AGENT_REPORT_GRACE_MS = 30 * 60_000;
107
-
108
- const postAgentTurn = async (body) => {
109
- const turnId = String(body.turnId);
110
- agentReported.set(turnId, { body, at: Date.now() });
111
- try {
112
- const res = await fetch(AGENT_TURN_DONE_URL, {
113
- method: 'POST',
114
- headers: {
115
- Authorization: `Bearer ${FLEET_TOKEN}`,
116
- 'User-Agent': USER_AGENT,
117
- 'Content-Type': 'application/json',
118
- },
119
- signal: AbortSignal.timeout(30_000),
120
- body: JSON.stringify(body),
121
- });
122
- const j = await res.json().catch(() => null);
123
- /**
124
- * ONLY A 2xx IS DELIVERED. The server answers an already-settled,
125
- * expired or unknown turn with 200 `{ settled: false }` — its only 4xx
126
- * are a body it could not parse (deploy skew) and auth, and an edge WAF
127
- * rule tripped by a summary that quotes an exploit string is a 403 from
128
- * in front of it. Each of those left the turn row PENDING, and dropping
129
- * the held body on them meant the next offer found nothing held and ran
130
- * the CLI again: a second set of commits and the operator's quota spent,
131
- * every poll, for six hours. The tab lane learned this as its 'reject'
132
- * class. So a refused body stays HELD — it is the skip-guard — and its
133
- * re-POST backs off instead of hammering a body the server just refused.
134
- */
135
- if (res.ok) {
136
- agentReported.delete(turnId);
137
- agentRejectedUntil.delete(turnId);
138
- } else if (res.status >= 400 && res.status < 500 && res.status !== 408 && res.status !== 429) {
139
- agentRejectedUntil.set(turnId, Date.now() + REJECT_RETRY_MS);
140
- }
141
- return j?.data ?? null;
142
- } catch {
143
- // Unsettled — a network error, so the body STAYS held and the next
144
- // re-offer retries the POST rather than the CLI.
145
- return null;
146
- }
147
- };
148
-
149
- /**
150
- * ONE BATCH OF A TURN'S TRACE. See trace.mjs for the whole contract.
151
- *
152
- * Resolves TRUE for a permanent refusal as well as a success, and that is
153
- * deliberate: a server with no such route 404s every batch, and a relay that
154
- * held them would fill its buffer, shed the turn's real steps and retry the
155
- * same rejected body for the life of the turn. There is no version floor here
156
- * — this is a daemon→server report, so an older server simply never learns
157
- * the trace and the board renders what it always did.
158
- */
159
- const postAgentTrace = async (body) => {
160
- try {
161
- const res = await fetch(AGENT_TRACE_URL, {
162
- method: 'POST',
163
- headers: {
164
- Authorization: `Bearer ${FLEET_TOKEN}`,
165
- 'User-Agent': USER_AGENT,
166
- 'Content-Type': 'application/json',
167
- },
168
- signal: AbortSignal.timeout(15_000),
169
- body: JSON.stringify(body),
170
- });
171
- return (
172
- res.ok ||
173
- (res.status >= 400 && res.status < 500 && res.status !== 408 && res.status !== 429)
174
- );
175
- } catch {
176
- return false; // a blip — the same entries go again at the same seq
177
- }
178
- };
179
-
180
- /** How long the final flush may hold the settle. Bounded because the settle
181
- * is the turn's contract and the trace is a readout: a wedged uplink costs
182
- * the tail of a trace, never the answer behind it. */
183
- const TRACE_FINAL_FLUSH_MS = 8_000;
184
-
185
- const postAgentActivity = async (agentId, text) => {
186
- try {
187
- await fetch(AGENT_ACTIVITY_URL, {
188
- method: 'POST',
189
- headers: {
190
- Authorization: `Bearer ${FLEET_TOKEN}`,
191
- 'User-Agent': USER_AGENT,
192
- 'Content-Type': 'application/json',
193
- },
194
- signal: AbortSignal.timeout(15_000),
195
- body: JSON.stringify({ agentId, text }),
196
- });
197
- } catch {
198
- /* narration is a readout; losing a line costs nothing */
199
- }
200
- };
201
-
202
- /** Every sha that landed on this branch between two points. Measured, never
203
- * asserted — this is the whole reason an agent is not asked for its own. */
204
- const commitsBetween = (wt, from) => {
205
- if (!from) return [];
206
- /**
207
- * `--not <base>` and `--no-merges`, because `from..HEAD` alone reports
208
- * BASE'S commits as this turn's receipts the moment anything folds base in
209
- * — which the stale path does on purpose before a merge. A receipt naming
210
- * somebody else's commit is worse than a missing one.
211
- */
212
- /**
213
- * IT CANNOT THROW, and that guard is the whole point of it being here.
214
- *
215
- * `git()` throws on a non-zero exit, and `baseRef()` is built from the
216
- * project's Base branch setting — free text an owner types, never verified
217
- * against the remote. Point it at a branch with no tracking ref (`develop`
218
- * on a repo whose remote branch is `dev`) and this exits "fatal: ambiguous
219
- * argument". The throw escaped `runAgentTurn` AFTER the CLI had already run
220
- * the card, so `postAgentTurn` was never reached, the server never settled
221
- * the turn, and the next poll handed back the identical turn — the same
222
- * card re-run every poll for six hours, on the operator's shared account,
223
- * piling commits onto the review branch, silently.
224
- *
225
- * An unreadable range means we cannot MEASURE the receipts, which is a
226
- * smaller failure than not settling: the turn still reports, and ship-time
227
- * reconciliation books whatever no card claimed. Missing beats fabricated
228
- * and both beat a stall.
229
- */
230
- let out;
231
- try {
232
- out = git(['log', '--format=%H', '--no-merges', `${from}..HEAD`, '--not', baseRef()], wt);
233
- } catch {
234
- return [];
235
- }
236
- return typeof out === 'string'
237
- ? out.split('\n').map((x) => x.trim()).filter(Boolean).slice(0, 50)
238
- : [];
239
- };
93
+ const { runAgentTurnInPlace, commitsBetween } = createAgentTurnExecution({
94
+ baseRef,
95
+ baseDir,
96
+ repoRoot,
97
+ fetchPublishedBranch,
98
+ placeWtFor,
99
+ sessionMetaPath,
100
+ beforeArtifacts,
101
+ getArtifactsAccepted,
102
+ noteSessionGroup,
103
+ reportSessionWorktree,
104
+ artifacts,
105
+ runReviewEntry,
106
+ workChildren,
107
+ agentChildren,
108
+ postAgentTrace,
109
+ postAgentActivity,
110
+ postAgentParked,
111
+ });
240
112
 
241
113
  /** `releaseSlot` hands back the admission reservation the caller took on this
242
114
  * turn's behalf, at the moment the CLI actually exists. Idempotent and
@@ -246,13 +118,13 @@ export function createWorkAgentTurns({
246
118
  const agentId = String(job.agentId || '');
247
119
  const place = String(job.placeId || '');
248
120
  if (!isSafePathSegment(place)) {
249
- await postAgentTurn({ turnId, outcome: 'nothing' });
121
+ await postAgentTurn({ turnId, outcome: 'nothing', answer: 'the machine was handed a turn for a place it will not use as a folder name' });
250
122
  return;
251
123
  }
252
124
  /**
253
125
  * A TASK TURN WITH NO CARD IS REFUSED, NOT IMPROVISED.
254
126
  *
255
- * The kickoff below branches on `job.kind === 'task' && job.task` and falls
127
+ * The kickoff (workAgentTurnExecution.mjs) branches on `job.kind === 'task' && job.task` and falls
256
128
  * through to the HUMAN kickoff otherwise — and a task turn's body is empty
257
129
  * by design, because the daemon composes the prompt from the card's own
258
130
  * spec. So a card deleted while its agent held it spawned a real CLI on a
@@ -275,686 +147,45 @@ export function createWorkAgentTurns({
275
147
  return;
276
148
  }
277
149
 
150
+ /**
151
+ * A RUN THAT THROWS IS STILL SETTLED — ONCE (SOLID F036).
152
+ *
153
+ * The run posts its own settle on every path it knows about. `settled`
154
+ * records that one was SENT (delivered or held for re-POST, either way the
155
+ * turn is answered), so a throw after it — the review-entry beat, say —
156
+ * never posts a second, contradicting `nothing`. A throw before it is
157
+ * settled here, with what the run had measured by then: the commits on the
158
+ * branch, the spend, the branch and worktree. Through the same held-body
159
+ * wire, so a failed POST is re-sent on the next offer and the CLI is not
160
+ * run again.
161
+ */
162
+ const measured = { wt: null, before: null, branch: null, usage: null };
163
+ let settled = false;
164
+ const settleOnce = (body) => {
165
+ settled = true;
166
+ return postAgentTurn(body);
167
+ };
168
+
278
169
  // A WRITER on the agent's own place — see the lane header. Only `a-<id>`
279
170
  // places: those are agents' by construction, and anything else here would
280
171
  // be a tab's directory, where a turn is a reader by the product's own law.
281
172
  await inPlace(place, place.startsWith('a-'), async () => {
282
- /**
283
- * WORK THAT HAS BEGUN LIVES ON EXACTLY ONE BOX, AND THIS MAY NOT BE IT.
284
- *
285
- * A project has ONE machine credential and every device is handed the same
286
- * raw token, so two boxes can both be polling for the same agents. Nothing
287
- * pushes an agent's branch before approve, so a turn that has already run
288
- * somewhere has its worktree, its branch and its CONVERSATION on that box's
289
- * disk and nowhere else. `placeWtFor` cannot tell the difference: it finds
290
- * no directory, cuts a fresh `session/a-<id>` off base, and the CLI starts
291
- * with no memory of the card — a confident, context-free redo of work
292
- * somebody is in the middle of, on the operator's shared account, landing
293
- * on a rival branch of the same name.
294
- *
295
- * So: if the server says this agent has BEGUN and this box holds neither
296
- * its directory nor its branch, refuse before anything is cut. `nothing` is
297
- * the honest outcome — this machine did not run the turn — and the sentence
298
- * says what was measured (two absences here, and the box name only when the
299
- * server recorded one; inferring where the work is would be invention).
300
- *
301
- * THE BRANCH ALONE IS ENOUGH TO CONTINUE. `placeWtFor`'s attach fallback
302
- * re-attaches a worktree to a surviving branch, so a directory somebody
303
- * cleaned up on THIS box is same-box recovery of real committed work and
304
- * behaves exactly as it did before this guard existed.
305
- *
306
- * The remedy is the stop path and nothing else. "Reconnect the other
307
- * machine" is not reachable from here once holdership has moved, and a
308
- * remedy somebody cannot carry out is worse than none.
309
- */
310
- if (job.begun) {
311
- const wtDir = join(baseDir, 'sessions', place);
312
- let hasBranch = false;
313
- /**
314
- * THREE STATES, AND THE MIDDLE ONE IS WHY THIS IS NOT A BARE CATCH.
315
- *
316
- * `rev-parse --verify --quiet` exits 1 and prints nothing for a ref that
317
- * is not there — that exit code IS the measurement, and it is the one
318
- * this guard acts on. Any OTHER failure (128 for "not a repository",
319
- * ENOENT for no git at all, a momentary index lock) measured nothing;
320
- * collapsing it onto "the branch is absent" would make the daemon assert
321
- * "this machine does not hold this agent's branch" off a repo it could
322
- * not read — the guard inventing the very fact it exists to relay.
323
- *
324
- * So an unmeasured branch stands the guard DOWN. That re-enters the path
325
- * this guard is a belt for, which is the fail-open direction it wants;
326
- * `placeWtFor` is about to fail on the same unreadable repo and say so
327
- * in its own words, which is the honest sentence.
328
- */
329
- let branchMeasured = true;
330
- try {
331
- hasBranch = Boolean(
332
- git(['rev-parse', '--verify', '--quiet', `refs/heads/session/${place}`], repoRoot)
333
- );
334
- } catch (e) {
335
- if (e?.status === 1) hasBranch = false;
336
- else branchMeasured = false;
337
- }
338
- if (branchMeasured && !existsSync(wtDir) && !hasBranch) {
339
- /**
340
- * …UNLESS THE WORK IS ON THE REMOTE (0.86.0).
341
- *
342
- * The guard's premise was "nothing pushes an agent's branch before
343
- * approve", and a project that publishes has changed exactly that
344
- * third of it: the COMMITS are on `origin` under `flowviant/`, so a
345
- * box that holds neither the directory nor the branch can fetch them
346
- * and continue the work instead of redoing it. The other two thirds
347
- * are untouched — the conversation still does not move, which is why
348
- * the kickoff re-prompts from the card either way.
349
- *
350
- * The refusal below is still the fallback, and it is the fallback for
351
- * BOTH shapes of failure: a project that does not publish (no ref,
352
- * sentence unchanged) and a fetch that could not land (sentence
353
- * extended with git's own reason, because "this machine does not hold
354
- * it" alone would hide that we tried and how it went).
355
- */
356
- const fetched = fetchPublishedBranch(place, job.publishedRef);
357
- if (!fetched?.ok) {
358
- const on = typeof job.begunOn === 'string' && job.begunOn.trim()
359
- ? job.begunOn.trim().slice(0, 64)
360
- : null;
361
- await postAgentTurn({
362
- turnId,
363
- outcome: 'nothing',
364
- answer:
365
- `This machine does not hold this agent's worktree or branch${on ? ` — its work is on ${on}` : ''}. ` +
366
- 'Stop the agent to re-plan it here.' +
367
- (fetched ? ` Its published branch could not be fetched (${fetched.why}).` : ''),
368
- });
369
- return;
370
- }
371
- // The branch is here and measured. `placeWtFor`'s attach fallback
372
- // opens a worktree on it below — the same path a directory somebody
373
- // cleaned up already takes, on commits this box now genuinely holds.
374
- }
375
- }
376
- const dir = placeWtFor(place);
377
- if (!dir) {
378
- // No worktree and none could be cut. `nothing` rather than an invented
379
- // error: the board says the machine went quiet, which is true.
380
- await postAgentTurn({ turnId, outcome: 'nothing' });
381
- return;
382
- }
383
- const wt = dir.wt;
384
- /**
385
- * NEITHER OF THESE MAY THROW. `git()` throws on a non-zero exit, and both
386
- * of these exit non-zero in states a real worktree reaches: `symbolic-ref`
387
- * on a DETACHED HEAD (an agent that ran `git checkout <sha>`, a rebase
388
- * left half-done), and `rev-parse HEAD` on a branch with no commits yet.
389
- * An escape here happens BEFORE any settle, so the turn stays pending
390
- * until the six-hour expiry while the agent sits in Working with nothing
391
- * behind it — the wedge this whole lane exists to avoid.
392
- *
393
- * `runAgentMerge` learned this two commits ago and says so in its own
394
- * comment; this is the same call, in the same file, left unguarded. Both
395
- * facts are OPTIONAL to a turn — they describe the branch, they do not
396
- * gate the work — so failing to read them costs a null, not the turn.
397
- */
398
- const readGit = (args) => {
399
- try {
400
- return (git(args, wt) || '').trim() || null;
401
- } catch {
402
- return null;
403
- }
404
- };
405
- // WHERE THE BRANCH WAS BEFORE THIS TURN, so the commits reported are the
406
- // ones this turn actually made.
407
- const before = readGit(['rev-parse', 'HEAD']);
408
- const branch = readGit(['symbolic-ref', '--quiet', '--short', 'HEAD']);
409
-
410
- const rt = job.runtime || 'claude';
411
- /**
412
- * WHAT THIS CARD HANDS BACK, AND THE POSTURE THAT FOLLOWS (0.97.0).
413
- *
414
- * `code` (the default — absent on the job IS code) is the build turn,
415
- * byte-for-byte what every agent turn was. `design` and `research` run
416
- * their own contract (SYSTEM_AGENT_DESIGN / _RESEARCH) under their own
417
- * posture, which can write ONLY `.flowviant/artifacts/` (claude.mjs).
418
- *
419
- * A RUNTIME THAT CANNOT EXPRESS THE POSTURE IS REFUSED, NOT IMPROVISED.
420
- * Codex and antigravity declare neither (runtimes.mjs), and the only
421
- * alternative to refusing is running the card as a BUILD turn with
422
- * permissions skipped — an agent writing code for an ask that was a
423
- * mockup, reporting success. `nothing` puts the agent in Stuck saying so.
424
- *
425
- * THE JOB'S OWN KIND FIRST (2026-09-23). A `human` turn — a send-back
426
- * from the review deck, an answer — usually names NO card: the queue has
427
- * emptied, or the card it is about was just re-queued behind it. Read off
428
- * `job.task` alone, a send-back on a design agent ran as a BUILD turn
429
- * under the code contract with permissions skipped — the one turn in the
430
- * owner's loop ("iterate in the review column, the agent opened") that
431
- * could edit code on an ask that was a mockup. So the server projects the
432
- * kind of the card the turn is ABOUT onto the job itself (`taskKind`,
433
- * sent only for design and research; the same `DAEMON_TASK_KIND_MIN`
434
- * floor as the card's own key, no new one), and the card's key stays the
435
- * fallback for a server that sends only that. Absent on both is code.
436
- */
437
- /**
438
- * A KIND THIS DAEMON DOES NOT KNOW IS REFUSED, NEVER BUILT (2026-09-23).
439
- * `agentTaskKindOf` reads anything unrecognised as code — right for a
440
- * printed label, wrong here, where code means the build posture with
441
- * permissions skipped. A server that learned a fourth kind ahead of
442
- * this daemon is exactly the case the version floor cannot cover (the
443
- * handout does not re-check it), and the answer is an update, said in
444
- * the word the server used. `nothing` puts the agent in Stuck with it.
445
- */
446
- const strangeKind = unknownAgentTaskKind(job.taskKind ?? job.task?.taskKind);
447
- if (strangeKind !== null) {
448
- await postAgentTurn({
449
- turnId,
450
- outcome: 'nothing',
451
- answer: `this daemon does not know the card kind '${strangeKind}' — update it`,
452
- branch,
453
- worktree: wt,
454
- });
455
- return;
456
- }
457
- const taskKind = agentTaskKindOf(job.taskKind ?? job.task?.taskKind);
458
- const posture = taskKind === 'code' ? 'build' : taskKind;
459
- if (!canRun(RUNTIMES[rt], posture)) {
460
- await postAgentTurn({
461
- turnId,
462
- outcome: 'nothing',
463
- answer:
464
- posture === 'build'
465
- ? `this machine cannot run ${rt}`
466
- : 'design and research cards run on Claude on this machine',
467
- branch,
468
- worktree: wt,
469
- });
470
- return;
471
- }
472
-
473
- // ONE AGENT IS ONE DIRECTORY, so the CLI's own cwd-keyed resume is exactly
474
- // right here — the ambiguity that forced per-tab session pinning in the
475
- // Workbench (many tabs, one place) cannot arise. The marker is what
476
- // distinguishes the first turn from every later one across restarts.
477
- //
478
- // IT MEANS "CLAUDE HAS SPOKEN HERE", and since 2026-09-24 only a Claude
479
- // turn writes it: an agent can be switched between CLIs, and a marker a
480
- // codex turn left behind would send the first Claude turn to
481
- // `--continue` a directory holding no Claude conversation at all.
482
- const ranMarker = sessionMetaPath(wt, 'flowviant-agent-ran');
483
- /**
484
- * CODEX RESUMES BY ITS OWN THREAD ID, PINNED PER AGENT (2026-09-24).
485
- *
486
- * Resume was Claude-only, because codex's `resume --last` is
487
- * MACHINE-GLOBAL and would cross-resume whichever conversation spoke
488
- * last anywhere on the box. The cost was that every codex card started
489
- * a stranger: no memory of the card before it, and — worse — an answer
490
- * to the agent's own question arrived at a codex that never asked it.
491
- * The Workbench solved the same problem in 0.69.0 by pinning the id
492
- * `thread.started` announces; this is that, keyed by the AGENT rather
493
- * than a tab. The id is shape-checked before it rides argv.
494
- */
495
- const codexThreadMarker =
496
- rt === 'codex' ? sessionMetaPath(wt, 'flowviant-agent-codex-thread', agentId) : null;
497
- let codexResumeId = null;
498
- if (codexThreadMarker && existsSync(codexThreadMarker)) {
499
- try {
500
- const v = readFileSync(codexThreadMarker, 'utf8').trim();
501
- if (CODEX_THREAD_RE.test(v)) codexResumeId = v;
502
- } catch {
503
- /* unreadable marker — run fresh */
504
- }
505
- }
506
- let seenThreadId = null;
507
- const resume =
508
- rt === 'codex'
509
- ? Boolean(codexResumeId)
510
- : rt === 'claude' && Boolean(ranMarker && existsSync(ranMarker));
511
-
512
- /**
513
- * WRITE THE CARD DOWN BEFORE HANDING IT OVER — the material the AI
514
- * pre-review reads at review entry (see agentCards.mjs).
515
- *
516
- * HERE rather than at review entry because here is the ONLY moment this
517
- * machine holds the card at all: the server feeds an agent one card per
518
- * prompt and keeps no copy on this disk, so by the time the queue empties
519
- * card one exists locally as nothing but its own commit messages — which
520
- * are a claim about the work, not the specification it was judged against.
521
- *
522
- * BEFORE the CLI runs rather than after, so a turn that crashes still
523
- * leaves the spec behind: the card really was given to the agent, the
524
- * commits it made are on the branch, and a reviewer is entitled to read
525
- * what was asked for either way.
526
- *
527
- * ONE SPEC BUILDER (`AGENT_TASK_SPEC`) shared with the kickoff below, so
528
- * what the reviewer reads is byte-identical to what the agent read.
529
- * Failure is swallowed inside `stashCard`: a note about a turn may never
530
- * cost the turn.
531
- */
532
- if (job.kind === 'task' && job.task) {
533
- stashCard(
534
- sessionMetaPath(wt, 'flowviant-agent-cards', agentId),
535
- job.task.id,
536
- AGENT_TASK_SPEC(job.task)
537
- );
538
- }
539
-
540
- /**
541
- * THE WHOLE STREAM, not just its latest line — see trace.mjs.
542
- *
543
- * The pulse below is untouched and still sent: it carries staleness (how
544
- * long the machine has been quiet), which an append-only list of steps
545
- * cannot say, because a list that stopped growing looks exactly like a
546
- * list that is finished.
547
- */
548
- const trace = makeTraceRelay({
549
- agentId,
550
- turnId,
551
- post: postAgentTrace,
552
- scrub: envScrub,
553
- });
554
- /**
555
- * Prose the structured event will carry anyway, dropped so a read does
556
- * not render twice — but ONLY on a runtime whose stream reaches
557
- * `onToolEvent` at all. Codex and agy have their own parsers and never
558
- * call it, so dropping their tool prose would blank their agents' traces.
559
- * `parse: null` is exactly the claude.mjs stream path. See
560
- * CLAUDE_TOOL_PROSE_KINDS.
561
- */
562
- const doubledKinds = RUNTIMES[rt]?.parse ? null : CLAUDE_TOOL_PROSE_KINDS;
563
-
564
- /**
565
- * WHICH BRAIN THIS CONTAINER WAS PINNED TO — the same `brainFor` the tab
566
- * lane runs, on the same two job keys, because an agent turn and a
567
- * session turn differ in who is watching and in nothing else that a model
568
- * name touches. Every guard lives in `brainFor`: a second copy here would
569
- * be a second answer to "is this a model we can spell", and the two would
570
- * drift the first time one of them learned a new effort.
571
- *
572
- * Absent stays genuinely absent — an agent nobody pinned produces the
573
- * byte-identical argv it produced yesterday, on the machine's own
574
- * default. That is also what an OLDER server yields, since it sends
575
- * neither key.
576
- */
577
- const brain = brainFor(job);
578
-
579
- let out = '';
580
- let child = null;
581
- /**
582
- * WHAT THIS TURN SPENT, AS THE CLI COUNTED IT (2026-09-19).
583
- *
584
- * Held across the whole turn so every settle below can carry it — a turn
585
- * that hit a limit, produced no parseable outcome or delivered properly
586
- * all spent real tokens, and a readout that only charged the happy path
587
- * would under-report the runs somebody opens the number to understand.
588
- *
589
- * The three settles it is spread into are the ones that follow a CLI
590
- * actually running. The pre-spawn refusals above (a bad place, a missing
591
- * card, the begun-guard) and the teardown sweep below deliberately do NOT
592
- * take it: nothing ran there, and `usage` stays null anyway, which is
593
- * what makes `...(usage ? … : {})` the whole guard.
594
- */
595
- let usage = null;
596
- /**
597
- * THE AGENT'S LAST MESSAGE, ALONE — where the final JSON object lives.
598
- * Codex's `out` is every message plus stderr plus error text, and
599
- * `parseTurnResult` takes the first object that fits, so an early
600
- * message quoting the format could be read as the outcome. Claude never
601
- * calls this (its `out` is already the result under `answerFromResult`).
602
- */
603
- let lastAnswer = null;
604
- /** The agent's artifact directory as it stood before this turn — the
605
- * tab lane's rule, in the agent's own worktree (2026-09-22). */
606
- const artifactsBefore = beforeArtifacts(wt);
607
- let projectTools = null;
608
173
  try {
609
- // A turn reads the base ref afresh. Changes the agent has made to its
610
- // own worktree cannot silently change the next turn's tools.
611
- projectTools = prepareAgentTools(readBaseTools(repoRoot, baseRef()), rt, process.env, wt);
612
- const agentTurnArgs = {
613
- prompt:
614
- job.kind === 'task' && job.task
615
- ? AGENT_TASK_KICKOFF({
616
- agentName: job.agentName,
617
- task: job.task,
618
- position: job.position ?? 1,
619
- total: job.total ?? 1,
620
- })
621
- : AGENT_HUMAN_KICKOFF({
622
- agentName: job.agentName,
623
- message: job.body ?? '',
624
- askedByName: job.askedByName,
625
- task: job.task,
626
- position: job.position ?? 1,
627
- total: job.total ?? 1,
628
- }),
629
- // The knowledge paragraph, when this box holds a library — the same
630
- // composer the tabs use, so an agent and a tab can never be told two
631
- // different things about the same directory.
632
- system: withProjectContext(SYSTEM_AGENT_FOR(taskKind), {
633
- knowledgeDir: knowledgeDirFor(repoRoot),
634
- // ARTIFACTS (0.94.0), while the server can show one — an agent's
635
- // land on its page, under the facts row.
636
- artifacts: getArtifactsAccepted(),
637
- }) + (projectTools.instructions ? `\n\n${projectTools.instructions}` : ''),
638
- agentTools: projectTools,
639
- ...(projectTools.codexHome ? { mcpEnv: { CODEX_HOME: projectTools.codexHome } } : {}),
640
- ...(posture === 'build' && rt === 'claude'
641
- ? { mcpArgs: ['--strict-mcp-config', '--mcp-config', projectTools.mcpPath] }
642
- : posture === 'build' && rt === 'codex'
643
- ? { mcpArgs: projectTools.codexArgs }
644
- : {}),
645
- knowledgeDir: knowledgeDirFor(repoRoot),
646
- // Named only for the two non-code kinds; a code turn passes nothing
647
- // and keeps the build branch it always had.
648
- ...(posture !== 'build' ? { posture } : {}),
649
- cwd: wt,
650
- runtime: rt,
651
- resume,
652
- resumeThreadId: codexResumeId || undefined,
653
- onThreadId: (id) => {
654
- seenThreadId = String(id ?? '').trim() || seenThreadId;
655
- },
656
- onAnswer: (t) => {
657
- lastAnswer = t;
658
- },
659
- // Present only when the container named one — see brainFor.
660
- ...brain,
661
- streamJson: true,
662
- answerFromResult: true,
663
- label: c.cyan('[agent]'),
664
- // The CLI's own tail, relayed. Throttled by the same rule the tab's
665
- // narrator keeps: overwritten, never appended, and ignored by the
666
- // board past ~90 seconds.
667
- onActivity: (a) => {
668
- const line = a?.label;
669
- if (!line) return;
670
- // THE TRACE TAKES EVERY LINE; the pulse takes one every two
671
- // seconds. Two channels, one stream, and the drop-sampler stays a
672
- // drop-sampler — buffering the pulse would make a stale line look
673
- // fresh, which is the one thing it exists to answer.
674
- //
675
- // …AND THE TRACE TAKES THE WHOLE LINE. `label` is a 160-char
676
- // console readout; `full` is what the CLI actually said, when the
677
- // parser had more than the label could hold (see runtimes.mjs).
678
- // The fallback is not a degradation — it is what every activity
679
- // without a fuller form carries, and what an entire pre-0.87.0
680
- // daemon carried for all of them. The PULSE below is deliberately
681
- // still `line`: it is one overwritten line on a board, and a
682
- // paragraph there would be a paragraph nobody can read.
683
- if (!doubledKinds || !doubledKinds.has(a.kind)) trace.prose(a.kind, a.full ?? line);
684
- const now = Date.now();
685
- if (now - (lastAgentBeat.get(agentId) ?? 0) < 2_000) return;
686
- lastAgentBeat.set(agentId, now);
687
- void postAgentActivity(agentId, envScrub(String(line)).slice(0, 400));
688
- },
689
- // The structured twin of the line above — the same `tool_use` the
690
- // Workbench's tool cards are built from, scrubbed at collection by
691
- // the builder itself (bounded window BEFORE its caps; see
692
- // toolEventOf). A tool it does not know pushes nothing.
693
- onToolEvent: (name, input) => {
694
- trace.tool(toolEventOf(name, input, wt, envScrub));
695
- },
696
- // The CLI's own per-turn token counts, off the `result` event. One
697
- // result per turn, so this is a set and not an accumulate — the
698
- // adding-up happens SERVER-side, behind the settle's idempotent win,
699
- // because a retried settle must not charge the same turn twice.
700
- // …tagged with WHICH CLI counted it (2026-09-24), so the server can
701
- // split an agent's spend per CLI rather than labelling a codex
702
- // agent's total with whatever the pre-review spent.
703
- onUsage: (u) => {
704
- usage = { ...u, runtime: rt };
705
- },
706
- // WHAT THE CLI SAYS IT CAN BE ASKED FOR, AND WHICH CONNECTORS NEED A
707
- // LOGIN — the same free fact off the same init event the tab lane
708
- // records (2026-09-23). Without it a box that only ever runs agents
709
- // taught the app its skills and connectors ONCE, from the startup
710
- // probe, and never again: a connector signed into at the box kept
711
- // reading "needs authorization" for the life of the process.
712
- onInit: (i) => {
713
- recordSkills(i.skills);
714
- recordMcpServers(i.mcpServers);
715
- },
716
- onSpawn: (ch) => {
717
- child = ch;
718
- // The AGENT it serves — what the machine snapshot charges this
719
- // child's memory to.
720
- workChildren.set(ch, agentId);
721
- // …and the registry now counts what the reservation was standing
722
- // in for.
723
- releaseSlot();
724
- // Under the PLACE, the id every reader asks with — the worktree
725
- // sweep and `killTargetOk` both key on `a-<agentId>`. Recorded under
726
- // the bare agent id it was never read, reported `processes: []`
727
- // over a running watcher, and left one dead entry per turn.
728
- noteSessionGroup(place, ch.pid);
729
- // Keyed by PLACE, because the retire sweep iterates directory names
730
- // and a place id IS one. It is what lets a hard stop actually reach
731
- // the CLI — see `retireWorkSessions`.
732
- agentChildren.set(place, ch);
733
- },
734
- };
735
- out = await runTurn(agentTurnArgs);
736
- /**
737
- * A RESUME THAT CAME BACK EMPTY, OR WITH ONLY THE CLI SAYING THE
738
- * CONVERSATION IS GONE, RUNS ONCE MORE FRESH — the tab lane's rule,
739
- * for the tab lane's reason: a pinned id the CLI has since pruned
740
- * (or a `--continue` over a directory Claude never spoke in, which an
741
- * agent switched between CLIs can reach) would otherwise settle
742
- * `nothing` on every turn forever. Same worktree, never a reset.
743
- */
744
- if (resume && (!(out || '').trim() || resumeConversationLost(out))) {
745
- seenThreadId = null;
746
- lastAnswer = null;
747
- out = await runTurn({ ...agentTurnArgs, resume: false, resumeThreadId: undefined });
748
- }
749
- } finally {
750
- projectTools?.cleanup();
751
- /**
752
- * THE TAIL, BEFORE THE SETTLE — so the last thing the agent did is on
753
- * the record by the time the board is told the turn is over.
754
- *
755
- * The server deliberately does NOT require a pending turn to accept a
756
- * trace batch (a late tail is still that turn's record), so a race here
757
- * is survivable rather than lossy; flushing first simply means it
758
- * almost never happens. Bounded, and the settle is what matters: an
759
- * uplink that will not answer costs the tail and nothing else.
760
- */
761
- trace.stop();
762
- await trace.flush(TRACE_FINAL_FLUSH_MS);
763
- if (child) workChildren.delete(child);
764
- if (agentChildren.get(place) === child) agentChildren.delete(place);
765
- if (ranMarker && rt === 'claude') {
766
- try {
767
- writeFileSync(ranMarker, '1');
768
- } catch {
769
- /* a missing marker only costs one un-resumed turn */
770
- }
771
- }
772
- // The thread THIS turn spoke under, pinned so the next card, answer
773
- // or merge-resolve resumes it. After the turn, like the tab lane: an
774
- // id learned mid-turn is only true once the turn that learned it ends.
775
- if (codexThreadMarker && seenThreadId && CODEX_THREAD_RE.test(seenThreadId)) {
776
- try {
777
- writeFileSync(codexThreadMarker, seenThreadId);
778
- } catch {
779
- /* best-effort — the next turn runs fresh */
780
- }
781
- }
782
- /**
783
- * AN ACTION THAT CHANGES WHAT THE MACHINE WOULD MEASURE MUST CAUSE A
784
- * NEW MEASUREMENT — the rule every session settle keeps, and the one
785
- * lane that did not. An agent's branch diff, head sha and trailered
786
- * commits only refreshed on the ≤60s sweep, so review opened right
787
- * after a turn described the branch as it was BEFORE the work.
788
- *
789
- * Here rather than after the settle POST because the CLI has exited,
790
- * so the tree is final — and because a queue that just emptied runs
791
- * the project's check next, which may hold this function for ten
792
- * minutes. Fire-and-forget on an endpoint that already exists, so no
793
- * floor: it must never delay the settle behind it.
794
- */
795
- void reportSessionWorktree(place).catch(() => {});
796
- // …and what it drew to SHOW the person (2026-09-22): uploaded against
797
- // the AGENT, so it lands on the agent's page whoever is looking.
798
- // Fire-and-forget for the same reason — never delay the settle.
799
- void artifacts
800
- .report({ placeDir: wt, before: artifactsBefore, agentId, turnId })
801
- .catch(() => {});
802
- }
803
-
804
- const commits = commitsBetween(wt, before);
805
- const res = (lastAnswer && parseTurnResult(lastAnswer)) || parseTurnResult(out);
806
- /**
807
- * A LIMIT IS ONLY A LIMIT WHEN THE TURN PRODUCED NOTHING.
808
- *
809
- * `limitLine` is a literal phrase match over the CLI's output, and the
810
- * output of a successful turn contains whatever the agent wrote — so a
811
- * card about rate limiting, or a summary mentioning one, parked every
812
- * agent on the project. Gating on "the turn declared no outcome" is what
813
- * makes the match mean what it says: the CLI failed and this is the
814
- * sentence it failed with.
815
- */
816
- const limit = res ? null : limitLine(out);
817
- if (res) setRuntimeLimit(PROJECT_ID, rt, null);
818
- if (limit) {
819
- setRuntimeLimit(PROJECT_ID, rt, limit);
820
- // Every agent ON THIS CLI parks, because that account is shared: one
821
- // hitting the limit means all of them have. Named since 2026-09-24 —
822
- // a machine can hold a Claude login and a Codex login, and a Codex
823
- // limit is no reason to stop the Claude agents. The turn itself is
824
- // reported as `nothing` — it did not deliver and it did not ask.
825
- await postAgentParked(limit, rt);
826
- await postAgentTurn({
827
- turnId,
828
- outcome: 'nothing',
829
- answer: limit,
830
- ...(usage ? { usage } : {}),
831
- branch,
832
- worktree: wt,
833
- });
834
- return;
835
- }
836
-
837
- if (!res) {
838
- await postAgentTurn({
839
- turnId,
840
- outcome: 'nothing',
841
- // The CLI's own words when it produced any. A turn that explained why
842
- // it stopped is far more use than "the agent stopped".
843
- answer: out.trim()
844
- ? envScrub(out).slice(-1500)
845
- : 'the turn produced no output on the machine — its CLI may be signed out',
846
- ...(commits.length ? { commits } : {}),
847
- ...(usage ? { usage } : {}),
848
- branch,
849
- worktree: wt,
850
- });
851
- return;
852
- }
853
- /**
854
- * A DESIGN OR RESEARCH CARD IS NOT DELIVERED UNTIL ITS ARTIFACT EXISTS
855
- * (0.97.0) — measured, never taken on the agent's word.
856
- *
857
- * Its whole product is one file under `.flowviant/artifacts/`: a `.html`
858
- * mockup for design, a `.md` write-up for research. A turn that says
859
- * "delivered" without having written one would land the agent in Review
860
- * with nothing to look at and a summary describing a page that does not
861
- * exist — so it settles `nothing` with the measured sentence, and the
862
- * agent lands in Stuck with a true reason instead.
863
- *
864
- * WHAT COUNTS: on a TASK turn, only what the scan found NEW OR CHANGED
865
- * this turn (`changedArtifacts` against the snapshot taken before the
866
- * spawn) — a second design card in the same agent must not pass on the
867
- * first card's page. On a HUMAN turn (an answer, a send-back) a matching
868
- * file already standing in the directory also counts: "keep it as it
869
- * is" is a legitimate answer to a question the agent asked after
870
- * drawing, and the turn did not have to rewrite the page to deliver it.
871
- *
872
- * …AND ON A REDO (2026-09-23): a TASK turn re-running a card this agent
873
- * already delivered, which is what a send-back's "Needs work" queues —
874
- * the human turn in front of it usually rewrote the mockup already, so
875
- * the task turn that follows truthfully says "revised last turn" with
876
- * nothing new to write, and the task rule sent it to Stuck with a false
877
- * sentence. The server marks such a job `redo: true` (a link delivered
878
- * before, or carrying a `needs_work` verdict), and then a standing match
879
- * counts, as it does for a human turn. No floor: an older server never
880
- * sends the key, and absent keeps the stricter task rule.
881
- *
882
- * Commits are still reported if any exist — the posture prevents them,
883
- * and a report never lies by omission about what is on the branch.
884
- */
885
- if (res.outcome === 'delivered' && taskKind !== 'code') {
886
- const want = taskKind === 'design' ? /\.html?$/i : /\.md$/i;
887
- const standing = scanArtifacts(wt);
888
- const wrote = changedArtifacts(artifactsBefore, standing).some((e) => want.test(e.name));
889
- const present =
890
- wrote || ((job.kind !== 'task' || job.redo === true) && standing.some((e) => want.test(e.name)));
891
- if (!present) {
892
- await postAgentTurn({
174
+ await runAgentTurnInPlace(job, { turnId, agentId, place, releaseSlot, postAgentTurn: settleOnce, measured });
175
+ } catch (error) {
176
+ if (settled) return;
177
+ const commits = measured.wt ? commitsBetween(measured.wt, measured.before) : [];
178
+ await postAgentTurn(
179
+ failedTurnSettlement({
893
180
  turnId,
894
- outcome: 'nothing',
895
- answer:
896
- taskKind === 'design'
897
- ? 'the turn ended without writing a mockup under .flowviant/artifacts/'
898
- : 'the turn ended without writing a write-up under .flowviant/artifacts/',
899
- ...(commits.length ? { commits } : {}),
900
- ...(usage ? { usage } : {}),
901
- branch,
902
- worktree: wt,
903
- });
904
- return;
905
- }
181
+ error,
182
+ commits,
183
+ usage: measured.usage,
184
+ branch: measured.branch,
185
+ wt: measured.wt,
186
+ })
187
+ );
906
188
  }
907
- const reply = await postAgentTurn({
908
- turnId,
909
- outcome: res.outcome,
910
- answer: envScrub(res.answer ?? '').slice(0, 8000),
911
- ...(commits.length ? { commits } : {}),
912
- // SCRUBBED like the answer beside it: a raised card lands in the
913
- // project doc every member reads, and a brief quoting the `.env` the
914
- // agent just read ("value sk_live_… is logged in pay.ts") went out
915
- // verbatim while the same value in the summary was redacted.
916
- ...(res.raised?.length
917
- ? {
918
- raised: res.raised.map((r) => ({
919
- title: envScrub(r.title).slice(0, 300),
920
- ...(r.brief ? { brief: envScrub(r.brief).slice(0, 2000) } : {}),
921
- })),
922
- }
923
- : {}),
924
- ...(usage ? { usage } : {}),
925
- /**
926
- * THE AGENT'S RUNNING ACCOUNT OF THIS BRANCH (2026-09-22).
927
- *
928
- * The owner: "we should add a brief summary of what the agent has done
929
- * overall at the top that updates." A RELAY — the agent wrote it in its
930
- * own final JSON object and nothing here composes, narrows or infers
931
- * one. It rides only this settle, which is the one that follows a
932
- * PARSED result: the two `nothing` settles above it come from a turn
933
- * that declared no outcome at all, so there is no account to carry and
934
- * inventing one would be the machine speaking for the agent.
935
- *
936
- * SCRUBBED BEFORE IT IS CUT, the order that matters and the one the
937
- * check's output lane learned the expensive way: `envScrub` replaces
938
- * EXACT values, so a paragraph capped first hands the scrub a
939
- * credential already cut in half — it matches nothing and the surviving
940
- * prefix ships. `parseTurnResult` trims it and bounds it for absurdity
941
- * at 8000, DELIBERATELY above the 1000 below, so this slice is the
942
- * first one a value of any interest meets and the scrub has already
943
- * run when it does. The first cut of this feature bounded the parser at
944
- * 1000 as well, which read identically and quietly put the cap first.
945
- *
946
- * OMITTED WHEN THE TURN DID NOT WRITE ONE, never sent empty. Absence is
947
- * the server's signal to KEEP the last account that was true; an empty
948
- * string would blank the head because a model dropped a key.
949
- */
950
- ...(res.progress ? { progress: envScrub(res.progress).slice(0, 1000) } : {}),
951
- branch,
952
- worktree: wt,
953
- });
954
- // The queue just emptied. Run the project's own check and the AI
955
- // pre-review HERE, in the worktree we are already standing in and still
956
- // hold the lock on — see `runReviewEntry`.
957
- if (reply?.review === true) await runReviewEntry(agentId, wt, job.agentName);
958
189
  });
959
190
  /**
960
191
  * …AND THEN PUBLISH, IF THE PROJECT PUBLISHES.
@@ -1000,45 +231,9 @@ export function createWorkAgentTurns({
1000
231
  }
1001
232
  };
1002
233
 
1003
- const lastAgentBeat = new Map(); // agentId -> last activity POST, ms
1004
-
1005
- const postAgentParked = async (reason, runtime) => {
1006
- try {
1007
- await fetch(AGENT_PARKED_URL, {
1008
- method: 'POST',
1009
- headers: {
1010
- Authorization: `Bearer ${FLEET_TOKEN}`,
1011
- 'User-Agent': USER_AGENT,
1012
- 'Content-Type': 'application/json',
1013
- },
1014
- signal: AbortSignal.timeout(15_000),
1015
- // `runtime` scopes the park to that CLI's agents; a server that does
1016
- // not know the key parks every agent, which is what it always did.
1017
- body: JSON.stringify({ reason, ...(runtime ? { runtime } : {}) }),
1018
- });
1019
- } catch {
1020
- /* the next turn will hit the same limit and try again */
1021
- }
1022
- };
1023
-
1024
234
  const processAgentTurnJobs = (jobs) => {
1025
235
  const list = Array.isArray(jobs) ? jobs : [];
1026
- if (agentReported.size) {
1027
- // The roster is the held bodies' clock: an offered turn is still pending
1028
- // server-side and worth retrying; one the roster stopped naming was
1029
- // settled or expired, and holding its body past a generous grace would
1030
- // grow this map for the life of the process. The server's own expiry is
1031
- // the true bound — the grace only covers its POST racing a final offer.
1032
- const offered = new Set(list.map((j) => String(j?.id || '')));
1033
- const now = Date.now();
1034
- for (const [id, held] of agentReported) {
1035
- if (offered.has(id)) held.at = now;
1036
- else if (now - held.at > AGENT_REPORT_GRACE_MS) {
1037
- agentReported.delete(id);
1038
- agentRejectedUntil.delete(id);
1039
- }
1040
- }
1041
- }
236
+ pruneHeldReports(list);
1042
237
  /** One deferral line per tick, however many turns were offered. */
1043
238
  let saidPressure = false;
1044
239
  for (const job of list.slice(0, 4)) {