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