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,447 @@
|
|
|
1
|
+
/** Agent plan claims, read-only planning turns, and proposal reports. */
|
|
2
|
+
import { existsSync } from 'node:fs';
|
|
3
|
+
import { join } from 'node:path';
|
|
4
|
+
import { FLEET_URL, FLEET_TOKEN, USER_AGENT, DAEMON_INSTANCE } from './config.mjs';
|
|
5
|
+
import { git, isSafePathSegment } from './git.mjs';
|
|
6
|
+
import { parseProposal } from './agentPlan.mjs';
|
|
7
|
+
import { c, note } from './ui.mjs';
|
|
8
|
+
import { runTurn } from './claude.mjs';
|
|
9
|
+
import { SYSTEM_PLAN, AGENT_PLAN_KICKOFF } from './prompts.mjs';
|
|
10
|
+
import { scrub as envScrub } from './env.mjs';
|
|
11
|
+
import { pickRuntimeFor, removeProbeTranscript, RUNTIMES } from './runtimes.mjs';
|
|
12
|
+
|
|
13
|
+
export function createWorkAgentPlans({
|
|
14
|
+
postBestEffort,
|
|
15
|
+
baseDir,
|
|
16
|
+
baseRef,
|
|
17
|
+
REPO_PLACE,
|
|
18
|
+
inPlace,
|
|
19
|
+
repoRoot,
|
|
20
|
+
admit,
|
|
21
|
+
state,
|
|
22
|
+
}) {
|
|
23
|
+
const AGENT_PLAN_DONE_URL = FLEET_URL.replace(/\/agents\/?$/, '/agent-plan-done');
|
|
24
|
+
const AGENT_PLAN_ACTIVITY_URL = FLEET_URL.replace(/\/agents\/?$/, '/agent-plan-activity');
|
|
25
|
+
const AGENT_PLAN_CLAIM_URL = FLEET_URL.replace(/\/agents\/?$/, '/agent-plan-claim');
|
|
26
|
+
|
|
27
|
+
const { placeLocks, workChildren } = state;
|
|
28
|
+
|
|
29
|
+
// ── AGENT PLAN JOBS: the Deploy press ──────────────────────────────────────
|
|
30
|
+
//
|
|
31
|
+
// Somebody selected cards and pressed Deploy. This turn works out HOW THE
|
|
32
|
+
// WORK SHOULD BE SPLIT across agents and stops — a person edits what it
|
|
33
|
+
// proposes on the board, and ACCEPTING is what spawns anything. Nothing here
|
|
34
|
+
// creates a worktree, a branch or a card.
|
|
35
|
+
//
|
|
36
|
+
// READ-ONLY IN THE CHECKOUT. `readOnly: true` selects `consultPermFor` (the
|
|
37
|
+
// fenced Read/Glob rules plus a few path-validated `git`/`ls`/`cat` reads —
|
|
38
|
+
// no Write, no Edit, no mkdir, no rm) and NO MCP is passed at all, so this
|
|
39
|
+
// turn has no control plane to reach even if the repository it reads tries
|
|
40
|
+
// to steer it. The proposal comes back as the turn's final message rather
|
|
41
|
+
// than through a tool, which is exactly what lets that permission set be
|
|
42
|
+
// this narrow.
|
|
43
|
+
//
|
|
44
|
+
// It takes the checkout's place lock as a READER, beside the operator's own
|
|
45
|
+
// tabs. It writes nothing, so a writer lock would only starve real work.
|
|
46
|
+
const planning = new Set(); // press ids in flight on this tick
|
|
47
|
+
|
|
48
|
+
const postAgentPlan = async (body) => {
|
|
49
|
+
// Bounded retry on 408/429/5xx, an other-4xx counted as delivered — see
|
|
50
|
+
// postBestEffort's own docblock. Previously this swallowed every
|
|
51
|
+
// response including ordinary 5xx and rate limits, so a transient
|
|
52
|
+
// failure looked identical to a hostile refusal: nothing retried, and
|
|
53
|
+
// the proposal sat unsettled until the server's own expiry.
|
|
54
|
+
await postBestEffort(AGENT_PLAN_DONE_URL, { ...body, instance: DAEMON_INSTANCE });
|
|
55
|
+
};
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* WHAT THE PLANNER IS DOING, RELAYED — the agent turn's narration channel,
|
|
59
|
+
* on a PRESS instead of an agent.
|
|
60
|
+
*
|
|
61
|
+
* This turn was already streaming and the daemon was already reading it:
|
|
62
|
+
* `streamJson` humanizes every read, grep, command and thought and prints
|
|
63
|
+
* it behind `[plan]` on the operator's terminal. Every one of those lines
|
|
64
|
+
* was then thrown away, so a Deploy press said "planning" on the board and
|
|
65
|
+
* nothing else for as long as it took — which on an overnight queue is the
|
|
66
|
+
* whole night. Forwarding the CLI's own tail costs nothing that is not
|
|
67
|
+
* already being computed.
|
|
68
|
+
*
|
|
69
|
+
* IT IS THE CLI'S WORDS, OR THE MACHINE'S — never a stage, a percentage or
|
|
70
|
+
* an estimate of how far along a model is. Flowviant relays.
|
|
71
|
+
*
|
|
72
|
+
* SCRUBBED AND CAPPED HERE rather than at each call site, so a phase marker
|
|
73
|
+
* added later cannot skip either. Fire-and-forget with every failure
|
|
74
|
+
* swallowed: narration is a readout, and it must never fail or delay the
|
|
75
|
+
* turn it describes. A daemon that never calls this is a machine that has
|
|
76
|
+
* not narrated, which is the only thing an absent line can mean.
|
|
77
|
+
*/
|
|
78
|
+
const postAgentPlanActivity = async (planId, line) => {
|
|
79
|
+
const text = envScrub(String(line ?? '')).slice(0, 400);
|
|
80
|
+
if (!text) return;
|
|
81
|
+
try {
|
|
82
|
+
await fetch(AGENT_PLAN_ACTIVITY_URL, {
|
|
83
|
+
method: 'POST',
|
|
84
|
+
headers: {
|
|
85
|
+
Authorization: `Bearer ${FLEET_TOKEN}`,
|
|
86
|
+
'User-Agent': USER_AGENT,
|
|
87
|
+
'Content-Type': 'application/json',
|
|
88
|
+
},
|
|
89
|
+
signal: AbortSignal.timeout(15_000),
|
|
90
|
+
body: JSON.stringify({ planId, line: text }),
|
|
91
|
+
});
|
|
92
|
+
} catch {
|
|
93
|
+
/* a readout — losing a line costs nothing */
|
|
94
|
+
}
|
|
95
|
+
};
|
|
96
|
+
|
|
97
|
+
/**
|
|
98
|
+
* A WEDGED PLANNING CLI IS STOPPED AT FIFTEEN MINUTES.
|
|
99
|
+
*
|
|
100
|
+
* `runTurn` has no timer of its own. A CLI that hangs — a login prompt
|
|
101
|
+
* nobody is there to answer, a model client stalled on a socket — therefore
|
|
102
|
+
* runs until something else kills it, holding `planning`, which is what
|
|
103
|
+
* blocks every auto-update on this machine, and spending whatever the
|
|
104
|
+
* operator's account is charged for a live session.
|
|
105
|
+
*
|
|
106
|
+
* FIFTEEN because the server fails a claimed press at THIRTY (its
|
|
107
|
+
* `PLAN_JOB_TTL_MS`) past thirty minutes with no renewal: half of that
|
|
108
|
+
* leaves this side — the only side that knows the CLI is still running —
|
|
109
|
+
* time to stop it and have its settle land, instead of the press expiring
|
|
110
|
+
* into a sentence that blames a machine which never spoke. Nothing
|
|
111
|
+
* legitimate is cut off either way: this turn reads a card selection and
|
|
112
|
+
* writes nothing, and the shape of it is minutes.
|
|
113
|
+
*
|
|
114
|
+
* `claimedAt` IS NO LONGER A ONE-SHOT STAMP (2026-09-24): the narration
|
|
115
|
+
* relay below (`postAgentPlanActivity`, `/fleet/agent-plan-activity`)
|
|
116
|
+
* renews it server-side as its own heartbeat, so a genuinely WORKING plan
|
|
117
|
+
* turn keeps its claim alive well past thirty minutes on its own. This
|
|
118
|
+
* timer exists for the one case narration cannot cover — a WEDGED CLI (a
|
|
119
|
+
* login prompt nobody answers, a stalled socket) emits nothing, renews
|
|
120
|
+
* nothing, and is exactly the silence the server's thirty-minute clock was
|
|
121
|
+
* always measuring.
|
|
122
|
+
*/
|
|
123
|
+
const PLAN_TURN_TIMEOUT_MS = 15 * 60_000;
|
|
124
|
+
|
|
125
|
+
const claimAgentPlan = async (id) => {
|
|
126
|
+
try {
|
|
127
|
+
const res = await fetch(AGENT_PLAN_CLAIM_URL, {
|
|
128
|
+
method: 'POST',
|
|
129
|
+
headers: {
|
|
130
|
+
Authorization: `Bearer ${FLEET_TOKEN}`,
|
|
131
|
+
'User-Agent': USER_AGENT,
|
|
132
|
+
'Content-Type': 'application/json',
|
|
133
|
+
},
|
|
134
|
+
signal: AbortSignal.timeout(15_000),
|
|
135
|
+
body: JSON.stringify({ id, instance: DAEMON_INSTANCE }),
|
|
136
|
+
});
|
|
137
|
+
const j = await res.json().catch(() => null);
|
|
138
|
+
return j?.data?.claimed === true;
|
|
139
|
+
} catch {
|
|
140
|
+
return false; // the peer may hold it; doing nothing is the safe answer
|
|
141
|
+
}
|
|
142
|
+
};
|
|
143
|
+
|
|
144
|
+
/**
|
|
145
|
+
* What a live agent has already TOUCHED, measured here rather than sent.
|
|
146
|
+
*
|
|
147
|
+
* The server could have shipped a copy of this, and deliberately does not:
|
|
148
|
+
* these are directories on THIS machine, and a server-side copy would be a
|
|
149
|
+
* second, staler source of truth for a fact the daemon is standing on top of.
|
|
150
|
+
*
|
|
151
|
+
* Uncommitted work AND commits against base, because a planner asking "will
|
|
152
|
+
* these collide" cares about both — a file this agent has already rewritten
|
|
153
|
+
* and merged into its own branch collides exactly as hard as one it is
|
|
154
|
+
* editing now.
|
|
155
|
+
*/
|
|
156
|
+
const agentChangedFiles = (placeId) => {
|
|
157
|
+
if (!placeId || !isSafePathSegment(placeId)) return [];
|
|
158
|
+
const wt = join(baseDir, 'sessions', placeId);
|
|
159
|
+
if (!existsSync(wt)) return [];
|
|
160
|
+
const out = new Set();
|
|
161
|
+
for (const args of [
|
|
162
|
+
['diff', '--name-only', 'HEAD'],
|
|
163
|
+
['diff', '--name-only', `${baseRef()}...HEAD`],
|
|
164
|
+
['ls-files', '--others', '--exclude-standard'],
|
|
165
|
+
]) {
|
|
166
|
+
/**
|
|
167
|
+
* `git()` THROWS; it does not return a non-string.
|
|
168
|
+
*
|
|
169
|
+
* The guard below was `if (typeof r !== 'string') continue`, which is a
|
|
170
|
+
* value `execFileSync` can never produce — so every one of these calls
|
|
171
|
+
* was effectively unguarded. And this runs over the LIVE AGENTS a plan
|
|
172
|
+
* job carries, which include agents still in `planning`: they have no
|
|
173
|
+
* worktree yet, so `git diff` in a directory that does not exist exits
|
|
174
|
+
* non-zero and throws straight out of `runAgentPlan` — after the press
|
|
175
|
+
* has been claimed and before the try/finally below. The press then sat
|
|
176
|
+
* `planning` until its expiry, having spent nothing and explained
|
|
177
|
+
* nothing, on what would be an ordinary second Deploy.
|
|
178
|
+
*
|
|
179
|
+
* An agent whose files cannot be read contributes none. That is the
|
|
180
|
+
* honest answer and it is what the planner should be told.
|
|
181
|
+
*/
|
|
182
|
+
let r;
|
|
183
|
+
try {
|
|
184
|
+
r = git(args, wt);
|
|
185
|
+
} catch {
|
|
186
|
+
continue;
|
|
187
|
+
}
|
|
188
|
+
if (typeof r !== 'string') continue;
|
|
189
|
+
for (const line of r.split('\n')) {
|
|
190
|
+
const f = line.trim();
|
|
191
|
+
if (f) out.add(f);
|
|
192
|
+
if (out.size >= 60) return [...out];
|
|
193
|
+
}
|
|
194
|
+
}
|
|
195
|
+
return [...out];
|
|
196
|
+
};
|
|
197
|
+
|
|
198
|
+
/** See `runAgentTurn` — the admission reservation, released the moment the
|
|
199
|
+
* planner's CLI exists. */
|
|
200
|
+
const runAgentPlan = async (job, releaseSlot = () => {}) => {
|
|
201
|
+
const id = String(job.id);
|
|
202
|
+
const tasks = Array.isArray(job.tasks) ? job.tasks : [];
|
|
203
|
+
// CLAIM BEFORE ANYTHING — including before the cheap refusal below.
|
|
204
|
+
//
|
|
205
|
+
// The settle is rejected unless the caller HOLDS the lease, so posting an
|
|
206
|
+
// error first meant the server discarded it and the press sat open forever,
|
|
207
|
+
// holding its cards out of Deploy. That is the opposite of the ordering a
|
|
208
|
+
// kill keeps (refuse cheaply, then claim) and it is because these two
|
|
209
|
+
// lanes settle differently: a kill's settle does not check a lease for the
|
|
210
|
+
// not-found case, and a plan's always does.
|
|
211
|
+
if (!(await claimAgentPlan(id))) return;
|
|
212
|
+
if (tasks.length === 0) {
|
|
213
|
+
// The press named cards the doc no longer has. A real answer, and one the
|
|
214
|
+
// board can explain, rather than a turn that plans nothing.
|
|
215
|
+
await postAgentPlan({ id, error: 'those cards are no longer on the board' });
|
|
216
|
+
return;
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
/**
|
|
220
|
+
* WHICH CLI PLANS. `pickRuntimeFor('consult')`, the same picker every other
|
|
221
|
+
* turn nobody @mentioned already uses — Claude when it is here (the prompts
|
|
222
|
+
* were written against it), otherwise whatever can express the profile.
|
|
223
|
+
*
|
|
224
|
+
* Deliberately NOT the project's runtime order: that order is about AGENTS,
|
|
225
|
+
* which hold a conversation across many turns and whose value is that held
|
|
226
|
+
* context. A planner runs once and reads; it takes what the machine has.
|
|
227
|
+
*/
|
|
228
|
+
const rt = pickRuntimeFor('consult');
|
|
229
|
+
if (!rt) {
|
|
230
|
+
await postAgentPlan({ id, error: 'no CLI on this machine can run a read-only turn' });
|
|
231
|
+
return;
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
/**
|
|
235
|
+
* ONE LINE AT A TIME, AT MOST ONE EVERY TWO SECONDS — the throttle the
|
|
236
|
+
* agent turn's narration keeps, for the same reason: a turn emits hundreds
|
|
237
|
+
* of lines and only the latest is ever rendered. Held per RUN rather than
|
|
238
|
+
* in a map keyed by press, because one press is one turn and there is
|
|
239
|
+
* nothing for the clock to outlive.
|
|
240
|
+
*/
|
|
241
|
+
let lastPlanBeat = 0;
|
|
242
|
+
const narrate = (line) => {
|
|
243
|
+
const now = Date.now();
|
|
244
|
+
if (now - lastPlanBeat < 2_000) return;
|
|
245
|
+
lastPlanBeat = now;
|
|
246
|
+
void postAgentPlanActivity(id, line);
|
|
247
|
+
};
|
|
248
|
+
/**
|
|
249
|
+
* THE MACHINE'S OWN VOICE, for the moments only this side can see — the
|
|
250
|
+
* CLI process actually starting, and this press waiting its turn for the
|
|
251
|
+
* checkout. Both are FACTS measured here, not a reading of the model's
|
|
252
|
+
* progress, and both are invisible from a browser: a press held behind a
|
|
253
|
+
* ship's writer lock looks exactly like a press whose CLI is thinking.
|
|
254
|
+
*
|
|
255
|
+
* Never dropped by the throttle above — these are moments, not a stream —
|
|
256
|
+
* and they stamp its clock so the next model line does not overwrite one
|
|
257
|
+
* the instant it lands.
|
|
258
|
+
*/
|
|
259
|
+
const say = (line) => {
|
|
260
|
+
lastPlanBeat = Date.now();
|
|
261
|
+
void postAgentPlanActivity(id, line);
|
|
262
|
+
};
|
|
263
|
+
const liveAgents = (Array.isArray(job.liveAgents) ? job.liveAgents : []).map((a) => ({
|
|
264
|
+
id: String(a?.id ?? ''),
|
|
265
|
+
name: String(a?.name ?? ''),
|
|
266
|
+
status: String(a?.status ?? ''),
|
|
267
|
+
// Belt: `agentChangedFiles` is defensive internally, but it resolves a
|
|
268
|
+
// place first and this whole block sits outside the try below.
|
|
269
|
+
changedFiles: (() => {
|
|
270
|
+
try {
|
|
271
|
+
return agentChangedFiles(a?.placeId);
|
|
272
|
+
} catch {
|
|
273
|
+
return [];
|
|
274
|
+
}
|
|
275
|
+
})(),
|
|
276
|
+
}));
|
|
277
|
+
|
|
278
|
+
let out = '';
|
|
279
|
+
/** REMOVED IN A `finally`. `workChildren` is what `workBusy()` counts, and
|
|
280
|
+
* a leaked entry keeps the daemon permanently "busy" — which blocks every
|
|
281
|
+
* auto-update from that moment on, silently, until a restart. */
|
|
282
|
+
let planChild = null;
|
|
283
|
+
let planTimer = null;
|
|
284
|
+
/**
|
|
285
|
+
* THE PLANNER'S OWN CONVERSATION, so its transcript can be removed.
|
|
286
|
+
*
|
|
287
|
+
* `claude -p` in the CHECKOUT writes `~/.claude/projects/<checkout>/<id>.jsonl`,
|
|
288
|
+
* and the checkout is exactly where the operator runs their own terminal
|
|
289
|
+
* Claude. Left behind, every Deploy press became the newest ENDED session
|
|
290
|
+
* there: it displaced the operator's real conversation from the `+` adopt
|
|
291
|
+
* menu (one row per directory), offered to fork a read-only planner into a
|
|
292
|
+
* build tab, and was what their own `claude --continue` resumed. The
|
|
293
|
+
* pre-review and the skills probe delete theirs for the same reason.
|
|
294
|
+
*/
|
|
295
|
+
let planSession = null;
|
|
296
|
+
/** The cap fired: the CLI was still running when this machine stopped it. */
|
|
297
|
+
let wedged = false;
|
|
298
|
+
// Said BEFORE the lock is asked for, because a reader only waits when a
|
|
299
|
+
// writer holds the checkout or is queued ahead of it — which is exactly
|
|
300
|
+
// this condition, read one line before we join the queue.
|
|
301
|
+
const busy = placeLocks.get(REPO_PLACE);
|
|
302
|
+
if (busy && (busy.writing || busy.waiters.some((w) => w.write)))
|
|
303
|
+
say('waiting for the checkout — a ship or another turn on this machine is holding it');
|
|
304
|
+
try {
|
|
305
|
+
await inPlace(REPO_PLACE, false, async () => {
|
|
306
|
+
/**
|
|
307
|
+
* THE CAP RESOLVES THE WAIT ITSELF rather than waiting for `close`
|
|
308
|
+
* after the kill — the shape the project check's timeout already
|
|
309
|
+
* keeps. A SIGKILLed process whose stdio a grandchild still holds can
|
|
310
|
+
* be slow to emit `close`, or never emit it, and this promise is what
|
|
311
|
+
* holds the claimed press open.
|
|
312
|
+
*/
|
|
313
|
+
let stopWaiting = () => {};
|
|
314
|
+
const capped = new Promise((r) => {
|
|
315
|
+
stopWaiting = r;
|
|
316
|
+
});
|
|
317
|
+
const turn = runTurn({
|
|
318
|
+
prompt: AGENT_PLAN_KICKOFF({
|
|
319
|
+
tasks,
|
|
320
|
+
liveAgents,
|
|
321
|
+
agentCap: Number.isInteger(job.agentCap) && job.agentCap > 0 ? job.agentCap : 3,
|
|
322
|
+
}),
|
|
323
|
+
system: SYSTEM_PLAN,
|
|
324
|
+
// READ-ONLY, and no MCP: `mcpArgs` is omitted entirely rather than
|
|
325
|
+
// passed empty, so there is no control plane on this turn at all.
|
|
326
|
+
readOnly: true,
|
|
327
|
+
cwd: repoRoot,
|
|
328
|
+
runtime: rt,
|
|
329
|
+
streamJson: true,
|
|
330
|
+
answerFromResult: true,
|
|
331
|
+
label: c.cyan('[plan]'),
|
|
332
|
+
// The CLI's own tail, forwarded to the press. Same rule as an agent
|
|
333
|
+
// turn's: throttled, overwritten rather than appended, and never
|
|
334
|
+
// awaited by the turn.
|
|
335
|
+
onActivity: (a) => {
|
|
336
|
+
if (a?.label) narrate(String(a.label));
|
|
337
|
+
},
|
|
338
|
+
onInit: (i) => {
|
|
339
|
+
if (typeof i.sessionId === 'string' && i.sessionId.trim())
|
|
340
|
+
planSession = i.sessionId.trim();
|
|
341
|
+
},
|
|
342
|
+
onSpawn: (ch) => {
|
|
343
|
+
planChild = ch;
|
|
344
|
+
// No id: a Deploy press is not a task, and the snapshot's per-task
|
|
345
|
+
// rows must not invent one. It still COUNTS against the machine's
|
|
346
|
+
// ceiling — see liveTurnCount.
|
|
347
|
+
workChildren.set(ch, null);
|
|
348
|
+
releaseSlot();
|
|
349
|
+
say(`${RUNTIMES[rt]?.label ?? rt} started on this machine`);
|
|
350
|
+
/**
|
|
351
|
+
* ARMED AT THE SPAWN, not at the claim: time spent waiting for the
|
|
352
|
+
* checkout is not a wedged CLI, and a press that never got to run
|
|
353
|
+
* is what the server's own clock is for.
|
|
354
|
+
*
|
|
355
|
+
* The CHILD, never its group — the rule teardown keeps. A
|
|
356
|
+
* read-only planning turn starts no dev server, so there is
|
|
357
|
+
* nothing behind it worth signalling and everything to lose by
|
|
358
|
+
* signalling somebody else's.
|
|
359
|
+
*/
|
|
360
|
+
planTimer = setTimeout(() => {
|
|
361
|
+
wedged = true;
|
|
362
|
+
try {
|
|
363
|
+
ch.kill('SIGKILL');
|
|
364
|
+
} catch {
|
|
365
|
+
/* already gone */
|
|
366
|
+
}
|
|
367
|
+
stopWaiting('');
|
|
368
|
+
}, PLAN_TURN_TIMEOUT_MS);
|
|
369
|
+
planTimer.unref?.();
|
|
370
|
+
},
|
|
371
|
+
});
|
|
372
|
+
out = await Promise.race([turn, capped]);
|
|
373
|
+
});
|
|
374
|
+
} catch (e) {
|
|
375
|
+
await postAgentPlan({ id, error: envScrub(String(e?.message || e)).slice(0, 500) });
|
|
376
|
+
return;
|
|
377
|
+
} finally {
|
|
378
|
+
if (planTimer) clearTimeout(planTimer);
|
|
379
|
+
if (planChild) workChildren.delete(planChild);
|
|
380
|
+
// On EVERY exit, after the kill and on a delay — the transcript is the
|
|
381
|
+
// child's file, and removing it while the child is still dying races a
|
|
382
|
+
// recreate. The pre-review's own shape.
|
|
383
|
+
if (planSession) setTimeout(() => removeProbeTranscript(repoRoot, planSession), 750).unref?.();
|
|
384
|
+
}
|
|
385
|
+
|
|
386
|
+
if (wedged) {
|
|
387
|
+
// NEVER LEAVE A CLAIMED PRESS UNREPORTED — the belt the merge lane wears
|
|
388
|
+
// beneath this one. The machine's own words, because the machine is the
|
|
389
|
+
// only side that knows: the server would have expired this press in
|
|
390
|
+
// another fifteen minutes with a sentence blaming a daemon that had in
|
|
391
|
+
// fact answered.
|
|
392
|
+
await postAgentPlan({
|
|
393
|
+
id,
|
|
394
|
+
error: 'the planning turn ran past fifteen minutes on this machine and was stopped',
|
|
395
|
+
});
|
|
396
|
+
return;
|
|
397
|
+
}
|
|
398
|
+
|
|
399
|
+
const proposal = parseProposal(out);
|
|
400
|
+
if (!proposal) {
|
|
401
|
+
await postAgentPlan({
|
|
402
|
+
id,
|
|
403
|
+
// The CLI's own words when it has any — a turn that explained why it
|
|
404
|
+
// could not plan is far more use than "planning failed".
|
|
405
|
+
error: out.trim()
|
|
406
|
+
? `the plan did not come back as JSON: ${envScrub(out).slice(0, 300)}`
|
|
407
|
+
: 'the planning turn produced no output — the CLI may be signed out',
|
|
408
|
+
});
|
|
409
|
+
return;
|
|
410
|
+
}
|
|
411
|
+
await postAgentPlan({ id, proposal, sessionRef: repoRoot });
|
|
412
|
+
};
|
|
413
|
+
|
|
414
|
+
const processAgentPlanJobs = (jobs) => {
|
|
415
|
+
if (!Array.isArray(jobs) || jobs.length === 0) return;
|
|
416
|
+
// ONE AT A TIME, and the cap is 1 rather than 5: a planning turn is a real
|
|
417
|
+
// model call, and a machine handed three at once would run three CLIs to
|
|
418
|
+
// answer questions nobody asked in that order.
|
|
419
|
+
for (const job of jobs.slice(0, 1)) {
|
|
420
|
+
const id = String(job?.id || '');
|
|
421
|
+
if (!id || planning.has(id)) continue;
|
|
422
|
+
/**
|
|
423
|
+
* BEFORE THE CLAIM, and that ordering is the whole point: `runAgentPlan`
|
|
424
|
+
* claims the press as its first act, and a claimed press must be settled
|
|
425
|
+
* or it sits open holding its cards out of Deploy. NOT claiming is how
|
|
426
|
+
* this lane declines — the press stays queued, the server offers it
|
|
427
|
+
* again next poll, and nobody is told their Deploy failed.
|
|
428
|
+
*/
|
|
429
|
+
const hold = admit('churn');
|
|
430
|
+
if (hold) {
|
|
431
|
+
note(`${c.cyan('plan')} ${c.dim(`— holding off: ${hold.reason}`)}`);
|
|
432
|
+
continue;
|
|
433
|
+
}
|
|
434
|
+
// One press a tick, so this lane cannot burst on its own — but the slot
|
|
435
|
+
// it is about to take has to be visible to the agent-turn lane that runs
|
|
436
|
+
// moments later in the same reconcile. See admission.mjs.
|
|
437
|
+
const releaseSlot = admit.reserve();
|
|
438
|
+
planning.add(id);
|
|
439
|
+
void runAgentPlan(job, releaseSlot).finally(() => {
|
|
440
|
+
releaseSlot();
|
|
441
|
+
planning.delete(id);
|
|
442
|
+
});
|
|
443
|
+
}
|
|
444
|
+
};
|
|
445
|
+
|
|
446
|
+
return { processAgentPlanJobs, planning };
|
|
447
|
+
}
|