flowviant 0.77.3 → 0.78.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/lib/claude.mjs +11 -1
- package/bin/lib/deploy.mjs +138 -15
- package/bin/lib/fleet.mjs +110 -9
- package/bin/lib/localSessions.mjs +85 -3
- package/bin/lib/preflight.mjs +1 -1
- package/bin/lib/preview.mjs +57 -11
- package/bin/lib/prompts.mjs +20 -2
- package/bin/lib/work.mjs +183 -12
- package/package.json +2 -2
package/bin/lib/claude.mjs
CHANGED
|
@@ -259,7 +259,17 @@ function handleStreamLine(line, { cwd, emit, onActivity, onToolEvent, appendText
|
|
|
259
259
|
// permission, an aborted run). Under `answerFromResult` this is the only
|
|
260
260
|
// stdout that would have said so, and a caller whose `out` is the answer
|
|
261
261
|
// must not report "no output" for a turn that explained itself.
|
|
262
|
-
|
|
262
|
+
// `errors[]` FIRST, because it is where the real sentence is. Claude
|
|
263
|
+
// Code reports a dead `--resume` id as
|
|
264
|
+
// `{subtype:'error_during_execution', errors:['No conversation found
|
|
265
|
+
// with session ID: …']}` — reading only `error`/`subtype` dropped that
|
|
266
|
+
// and appended the literal string `error_during_execution`, which told
|
|
267
|
+
// the driver nothing and hid the one phrase the caller needs to
|
|
268
|
+
// recognise a lost conversation.
|
|
269
|
+
const listed = Array.isArray(ev.errors)
|
|
270
|
+
? ev.errors.filter((e) => typeof e === 'string' && e.trim()).join('; ')
|
|
271
|
+
: '';
|
|
272
|
+
const msg = listed || ev.error?.message || ev.error || ev.subtype;
|
|
263
273
|
appendText(`${typeof msg === 'string' ? msg : JSON.stringify(msg)}\n`);
|
|
264
274
|
}
|
|
265
275
|
}
|
package/bin/lib/deploy.mjs
CHANGED
|
@@ -10,13 +10,14 @@
|
|
|
10
10
|
* wrangler output routinely echoes secrets.
|
|
11
11
|
*/
|
|
12
12
|
|
|
13
|
-
|
|
13
|
+
|
|
14
14
|
import { spawn } from 'node:child_process';
|
|
15
15
|
import { join } from 'node:path';
|
|
16
16
|
import { FLEET_URL, FLEET_TOKEN, USER_AGENT, DAEMON_INSTANCE } from './config.mjs';
|
|
17
17
|
import { c, note, ok, warn } from './ui.mjs';
|
|
18
18
|
import { deployCreds, appSecretsFor, scrub, myPubB64 } from './env.mjs';
|
|
19
19
|
import { childEnv } from './childEnv.mjs';
|
|
20
|
+
import { git } from './git.mjs';
|
|
20
21
|
|
|
21
22
|
const deployUrl = (tail) => FLEET_URL.replace(/\/agents\/?$/, `/${tail}`);
|
|
22
23
|
|
|
@@ -38,27 +39,64 @@ async function post(tail, body) {
|
|
|
38
39
|
return json?.data;
|
|
39
40
|
}
|
|
40
41
|
|
|
41
|
-
/**
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
42
|
+
/**
|
|
43
|
+
* Read + parse `.flowviant/deploy.json` AS IT IS ON THE BASE BRANCH. Returns []
|
|
44
|
+
* if the branch has none.
|
|
45
|
+
*
|
|
46
|
+
* IT USED TO READ THE WORKING TREE, which made the feature's one stated bound
|
|
47
|
+
* false. `deploy_target`'s own description says "only ids already declared in
|
|
48
|
+
* `.flowviant/deploy.json` on MAIN can be named (the daemon reads and runs from
|
|
49
|
+
* the repo ROOT, never a session worktree, so an agent cannot author the command
|
|
50
|
+
* it triggers without shipping it first)" — and the repo root's WORKING TREE is
|
|
51
|
+
* exactly where the machine operator's tabs stand (their place is the checkout).
|
|
52
|
+
* So an agent that had read an injected instruction could write an uncommitted
|
|
53
|
+
* `.flowviant/deploy.json` naming any shell command, call `deploy_target`, and
|
|
54
|
+
* have the daemon run it from the repo root with `CLOUDFLARE_API_TOKEN` and
|
|
55
|
+
* every other deploy-scope credential in its environment. Nothing about that
|
|
56
|
+
* needed a commit, a review, or an owner.
|
|
57
|
+
*
|
|
58
|
+
* Reading the COMMITTED tree is what makes the sentence true: authoring the
|
|
59
|
+
* command now requires landing it on base, which is a reviewed act. The file is
|
|
60
|
+
* read through git rather than the filesystem, so an uncommitted edit is simply
|
|
61
|
+
* not there.
|
|
62
|
+
*
|
|
63
|
+
* A base ref that does not resolve yields NO TARGETS, and says so once. That is
|
|
64
|
+
* the withholding direction and it is the right one here — a deploy is
|
|
65
|
+
* irreversible and running the wrong file is worse than running nothing.
|
|
66
|
+
*/
|
|
67
|
+
export function readDeployConfig(repoRoot, baseRef) {
|
|
68
|
+
let raw;
|
|
69
|
+
if (baseRef) {
|
|
70
|
+
try {
|
|
71
|
+
raw = git(['show', `${baseRef}:.flowviant/deploy.json`], repoRoot);
|
|
72
|
+
} catch {
|
|
73
|
+
// No such file on base, or a base ref that does not resolve. Both mean
|
|
74
|
+
// "this branch declares no targets", which is a real answer.
|
|
75
|
+
return [];
|
|
76
|
+
}
|
|
77
|
+
} else {
|
|
78
|
+
// No base ref in hand (a caller that has not been updated). Refuse rather
|
|
79
|
+
// than silently falling back to the working tree — that fallback IS the bug.
|
|
80
|
+
warn('deploy: no base branch resolved, so no deploy targets were read.');
|
|
81
|
+
return [];
|
|
82
|
+
}
|
|
45
83
|
try {
|
|
46
|
-
const parsed = JSON.parse(
|
|
84
|
+
const parsed = JSON.parse(raw);
|
|
47
85
|
const targets = Array.isArray(parsed?.targets) ? parsed.targets : [];
|
|
48
86
|
// Keep only fields the server + runner need; the daemon holds the commands.
|
|
49
87
|
return targets
|
|
50
88
|
.filter((t) => t && typeof t.id === 'string' && typeof t.command === 'string')
|
|
51
89
|
.slice(0, 20);
|
|
52
90
|
} catch (e) {
|
|
53
|
-
warn(`deploy: .flowviant/deploy.json is not valid JSON — ${e.message}`);
|
|
91
|
+
warn(`deploy: .flowviant/deploy.json on the base branch is not valid JSON — ${e.message}`);
|
|
54
92
|
return [];
|
|
55
93
|
}
|
|
56
94
|
}
|
|
57
95
|
|
|
58
96
|
/** Report the parsed config to the server (only when it changed). */
|
|
59
97
|
let lastConfigJson = null;
|
|
60
|
-
export async function reportDeployConfig(repoRoot) {
|
|
61
|
-
const targets = readDeployConfig(repoRoot);
|
|
98
|
+
export async function reportDeployConfig(repoRoot, baseRef) {
|
|
99
|
+
const targets = readDeployConfig(repoRoot, baseRef);
|
|
62
100
|
const json = JSON.stringify(targets);
|
|
63
101
|
if (json === lastConfigJson) return;
|
|
64
102
|
// Scrub command strings before the server sees them — a command line can embed
|
|
@@ -154,6 +192,23 @@ async function verifyHealth(url, status) {
|
|
|
154
192
|
}
|
|
155
193
|
|
|
156
194
|
const claiming = new Set(); // in-flight guard (single-flight per daemon process)
|
|
195
|
+
/**
|
|
196
|
+
* JOBS THIS PROCESS HAS ALREADY RUN, whatever the server thinks.
|
|
197
|
+
*
|
|
198
|
+
* A deploy is IRREVERSIBLE and the report is not: a transient 5xx, a DNS blip
|
|
199
|
+
* or the 30s timeout meant the outcome never landed, the heartbeat stopped,
|
|
200
|
+
* and three minutes later the server requeued the job and this same daemon ran
|
|
201
|
+
* `wrangler rollback` — or a full prod deploy with every pushSecret re-pushed —
|
|
202
|
+
* a SECOND time, leaving production two versions behind the intended one with
|
|
203
|
+
* nothing recording that it happened twice.
|
|
204
|
+
*
|
|
205
|
+
* So the process remembers. Not a substitute for the report (see the retry
|
|
206
|
+
* below, which is the real fix); a floor under it, for the case where the
|
|
207
|
+
* report never lands at all. It does not survive a restart — nothing local
|
|
208
|
+
* could be trusted to — which is why the retry has to keep the heartbeat alive
|
|
209
|
+
* while it runs.
|
|
210
|
+
*/
|
|
211
|
+
const ran = new Set();
|
|
157
212
|
|
|
158
213
|
/**
|
|
159
214
|
* Process queued deploy jobs from the roster. `ctx` = { repoRoot, baseRef,
|
|
@@ -168,6 +223,7 @@ export function processDeployJobs(jobs, ctx) {
|
|
|
168
223
|
// reconcile loop, since this runs unguarded from the fleet tick.
|
|
169
224
|
if (!job || typeof job.id !== 'string') continue;
|
|
170
225
|
if (claiming.has(job.id)) continue;
|
|
226
|
+
if (ran.has(job.id)) continue; // already executed here — never twice
|
|
171
227
|
claiming.add(job.id);
|
|
172
228
|
void (async () => {
|
|
173
229
|
let beat = null;
|
|
@@ -185,10 +241,35 @@ export function processDeployJobs(jobs, ctx) {
|
|
|
185
241
|
// Keep the claim fresh while we run — a long deploy must never be
|
|
186
242
|
// re-queued out from under us (that would double-deploy). The async
|
|
187
243
|
// run() below keeps the event loop free so this fires.
|
|
244
|
+
/**
|
|
245
|
+
* …AND IT CAN DIE, which is what makes the `stillBeating` predicate
|
|
246
|
+
* below mean anything.
|
|
247
|
+
*
|
|
248
|
+
* `report` is handed `() => beat != null` so it stops retrying once the
|
|
249
|
+
* claim is certainly stale — but `beat` only ever held a timer handle
|
|
250
|
+
* and was never nulled, so that predicate could not return false and
|
|
251
|
+
* `report` retried into a job another daemon may already own.
|
|
252
|
+
*
|
|
253
|
+
* The server re-queues a deploy whose heartbeat is older than three
|
|
254
|
+
* minutes, and this fires every sixty seconds — so three consecutive
|
|
255
|
+
* failures is exactly the point past which the claim cannot be assumed.
|
|
256
|
+
* A single blip does not count: only an unbroken run does.
|
|
257
|
+
*/
|
|
258
|
+
let missed = 0;
|
|
188
259
|
beat = setInterval(() => {
|
|
189
|
-
void post('deploy-heartbeat', { jobId: job.id, pubkey: ctx.myPubB64() })
|
|
260
|
+
void post('deploy-heartbeat', { jobId: job.id, pubkey: ctx.myPubB64() })
|
|
261
|
+
.then(() => {
|
|
262
|
+
missed = 0;
|
|
263
|
+
})
|
|
264
|
+
.catch(() => {
|
|
265
|
+
missed += 1;
|
|
266
|
+
if (missed >= 3 && beat) {
|
|
267
|
+
clearInterval(beat);
|
|
268
|
+
beat = null;
|
|
269
|
+
}
|
|
270
|
+
});
|
|
190
271
|
}, 60_000);
|
|
191
|
-
const targets = readDeployConfig(ctx.repoRoot);
|
|
272
|
+
const targets = readDeployConfig(ctx.repoRoot, ctx.baseRef);
|
|
192
273
|
const target = targets.find((t) => t.id === job.targetId);
|
|
193
274
|
if (!target) {
|
|
194
275
|
await report(job, ctx, { ok: false, message: `target "${job.targetId}" not in .flowviant/deploy.json` });
|
|
@@ -196,13 +277,19 @@ export function processDeployJobs(jobs, ctx) {
|
|
|
196
277
|
}
|
|
197
278
|
note(`${c.cyan('deploy')} ${c.dim(`— ${job.kind} ${job.targetId} → ${job.env}…`)}`);
|
|
198
279
|
const outcome = await runDeploy(job, target, ctx);
|
|
199
|
-
|
|
280
|
+
// From here the work is DONE. Whatever the report does, this job must
|
|
281
|
+
// never run again in this process.
|
|
282
|
+
ran.add(job.id);
|
|
283
|
+
await report(job, ctx, outcome, () => beat != null);
|
|
200
284
|
if (outcome.ok) ok(`${c.cyan('deploy')} ${c.dim(`— ${job.targetId} → ${job.env} done${outcome.healthOk === false ? ' (health failed)' : ''}`)}`);
|
|
201
285
|
else warn(`deploy: ${job.targetId} → ${job.env} failed — ${outcome.message}`);
|
|
202
286
|
} catch (e) {
|
|
203
287
|
warn(`deploy job ${job.id} errored: ${e.message}`);
|
|
204
288
|
await report(job, ctx, { ok: false, message: e.message }).catch(() => {});
|
|
205
289
|
} finally {
|
|
290
|
+
// Stopped only AFTER the report has landed or given up — the requeue is
|
|
291
|
+
// gated on heartbeat staleness, so beating through the retries is what
|
|
292
|
+
// stops the server handing this job out again mid-retry.
|
|
206
293
|
if (beat) clearInterval(beat);
|
|
207
294
|
claiming.delete(job.id);
|
|
208
295
|
}
|
|
@@ -278,13 +365,49 @@ async function runDeploy(job, target, ctx) {
|
|
|
278
365
|
};
|
|
279
366
|
}
|
|
280
367
|
|
|
281
|
-
|
|
282
|
-
|
|
368
|
+
/**
|
|
369
|
+
* THE OUTCOME IS RETRIED, because losing it re-runs the deploy.
|
|
370
|
+
*
|
|
371
|
+
* One `post` with a `.catch(warn)` was the whole of this: a transient 5xx, a
|
|
372
|
+
* DNS blip or the 30s timeout dropped the outcome, the `finally` stopped the
|
|
373
|
+
* heartbeat, and the server — which requeues a running job after three minutes
|
|
374
|
+
* without one — handed the SAME job back to the SAME daemon, which ran it
|
|
375
|
+
* again. For a rollback that is production two versions behind the intended
|
|
376
|
+
* one; for a prod deploy it is every pushSecret pushed twice. Nothing recorded
|
|
377
|
+
* that it had happened at all.
|
|
378
|
+
*
|
|
379
|
+
* The heartbeat keeps running throughout (the caller's `finally` is what stops
|
|
380
|
+
* it), so the requeue window stays shut for as long as we are still trying.
|
|
381
|
+
* Bounded: six attempts over roughly a minute, then a warning and the local
|
|
382
|
+
* `ran` guard as the floor.
|
|
383
|
+
*/
|
|
384
|
+
async function report(job, ctx, outcome, stillBeating = () => true) {
|
|
385
|
+
const body = {
|
|
283
386
|
jobId: job.id,
|
|
284
387
|
pubkey: ctx.myPubB64(),
|
|
285
388
|
ok: !!outcome.ok,
|
|
286
389
|
deploymentId: outcome.deploymentId ?? null,
|
|
287
390
|
healthOk: outcome.healthOk ?? null,
|
|
288
391
|
message: scrub(outcome.message || ''),
|
|
289
|
-
}
|
|
392
|
+
};
|
|
393
|
+
for (let attempt = 0; attempt < 6; attempt++) {
|
|
394
|
+
try {
|
|
395
|
+
await post('deploy-report', body);
|
|
396
|
+
return;
|
|
397
|
+
} catch (e) {
|
|
398
|
+
// The last attempt says so; the ones before it are noise on a path that
|
|
399
|
+
// usually recovers.
|
|
400
|
+
if (attempt === 5) {
|
|
401
|
+
warn(`deploy: could not report outcome after 6 tries — ${e.message}`);
|
|
402
|
+
return;
|
|
403
|
+
}
|
|
404
|
+
// If the heartbeat is already gone the requeue window is open and
|
|
405
|
+
// retrying buys nothing — the job may have been handed to somebody else.
|
|
406
|
+
if (!stillBeating()) {
|
|
407
|
+
warn(`deploy: could not report outcome — ${e.message}`);
|
|
408
|
+
return;
|
|
409
|
+
}
|
|
410
|
+
await new Promise((r) => setTimeout(r, 2000 * (attempt + 1)));
|
|
411
|
+
}
|
|
412
|
+
}
|
|
290
413
|
}
|
package/bin/lib/fleet.mjs
CHANGED
|
@@ -85,7 +85,7 @@ import {
|
|
|
85
85
|
RUNTIMES,
|
|
86
86
|
} from './runtimes.mjs';
|
|
87
87
|
import { createWorkManager } from './work.mjs';
|
|
88
|
-
import { scanLocalSessions } from './localSessions.mjs';
|
|
88
|
+
import { scanLocalSessions, ourConversationIds } from './localSessions.mjs';
|
|
89
89
|
import { repoState } from './repoState.mjs';
|
|
90
90
|
|
|
91
91
|
async function fetchRoster(
|
|
@@ -253,7 +253,7 @@ let localSessionsUnsupported = false; // the server 404'd — quiet until restar
|
|
|
253
253
|
let localSessionsScanAt = 0;
|
|
254
254
|
let localSessionsSent = null; // last payload the server ACCEPTED, stringified
|
|
255
255
|
let localSessionsSentAt = 0;
|
|
256
|
-
async function maybeReportLocalSessions({ repoRoot, excludeDirs }) {
|
|
256
|
+
async function maybeReportLocalSessions({ repoRoot, excludeDirs, excludeIds }) {
|
|
257
257
|
if (localSessionsUnsupported) return;
|
|
258
258
|
if (Date.now() - localSessionsScanAt < LOCAL_SESSIONS_SCAN_MS) return;
|
|
259
259
|
localSessionsScanAt = Date.now();
|
|
@@ -261,7 +261,9 @@ async function maybeReportLocalSessions({ repoRoot, excludeDirs }) {
|
|
|
261
261
|
try {
|
|
262
262
|
// scanLocalSessions orders deterministically, so this string only changes
|
|
263
263
|
// when the facts on disk do — the dedup below compares whole payloads.
|
|
264
|
-
payload = JSON.stringify({
|
|
264
|
+
payload = JSON.stringify({
|
|
265
|
+
sessions: scanLocalSessions({ repoRoot, excludeDirs, excludeIds }),
|
|
266
|
+
});
|
|
265
267
|
} catch {
|
|
266
268
|
return; // presence must never throw into the poll loop
|
|
267
269
|
}
|
|
@@ -892,6 +894,7 @@ export async function runFleetDaemon() {
|
|
|
892
894
|
execFileSync('gh', ['pr', 'edit', job.prUrl, '--base', baseBranchName(baseRef)], {
|
|
893
895
|
cwd: repoRoot,
|
|
894
896
|
stdio: ['ignore', 'pipe', 'pipe'],
|
|
897
|
+
timeout: 30_000,
|
|
895
898
|
});
|
|
896
899
|
} catch (e) {
|
|
897
900
|
// Already targeting base is the common no-op; anything else is
|
|
@@ -912,6 +915,7 @@ export async function runFleetDaemon() {
|
|
|
912
915
|
execFileSync('gh', ['pr', 'merge', job.prUrl, '--squash', '--delete-branch'], {
|
|
913
916
|
cwd: repoRoot,
|
|
914
917
|
stdio: ['ignore', 'pipe', 'pipe'],
|
|
918
|
+
timeout: 120_000,
|
|
915
919
|
});
|
|
916
920
|
merged = true;
|
|
917
921
|
} catch (e) {
|
|
@@ -989,7 +993,7 @@ export async function runFleetDaemon() {
|
|
|
989
993
|
'Task restarted in Flowviant — this attempt was discarded.',
|
|
990
994
|
'--delete-branch',
|
|
991
995
|
],
|
|
992
|
-
{ cwd: repoRoot, stdio: ['ignore', 'pipe', 'pipe'] }
|
|
996
|
+
{ cwd: repoRoot, stdio: ['ignore', 'pipe', 'pipe'], timeout: 60_000 }
|
|
993
997
|
);
|
|
994
998
|
} catch (e) {
|
|
995
999
|
// Already closed/merged/missing = fine; anything else we still
|
|
@@ -1031,10 +1035,22 @@ export async function runFleetDaemon() {
|
|
|
1031
1035
|
const REGROUND_DONE_URL = FLEET_URL.replace(/\/agents\/?$/, '/reground-done');
|
|
1032
1036
|
const WIKI_VAULT_URL = FLEET_URL.replace(/\/agents\/?$/, '/wiki-vault');
|
|
1033
1037
|
const WIKI_PROGRESS_URL = FLEET_URL.replace(/\/agents\/?$/, '/wiki-progress');
|
|
1038
|
+
const WIKI_ABANDONED_URL = FLEET_URL.replace(/\/agents\/?$/, '/wiki-abandoned');
|
|
1034
1039
|
const wikiQueue = [];
|
|
1035
1040
|
let wikiBusy = false;
|
|
1036
1041
|
let wikiChild = null; // the wiki turn's Claude process — tracked so teardown can kill it
|
|
1037
1042
|
let lastSweepAt = null; // dedup: run each Regenerate request once
|
|
1043
|
+
// …UNLESS IT FAILED. A sweep that ends without WIKI_DONE never finalizes, so
|
|
1044
|
+
// the server's `regen_requested_at` stays set and the roster keeps offering
|
|
1045
|
+
// the same `requestedAt` — which this dedup then swallowed forever. The
|
|
1046
|
+
// console said "retry from the app", to a console nobody reads. Bounded the
|
|
1047
|
+
// same way the re-ground path is: a full sweep is an expensive model turn, so
|
|
1048
|
+
// a repo that fails one every time must not be able to loop-burn quota.
|
|
1049
|
+
let sweepAttempts = 0;
|
|
1050
|
+
// Which request the counter belongs to, so a NEW Regenerate click starts with
|
|
1051
|
+
// a full budget rather than inheriting an exhausted one.
|
|
1052
|
+
let sweepAttemptsFor = null;
|
|
1053
|
+
const MAX_SWEEP_ATTEMPTS = 3;
|
|
1038
1054
|
const groundedIntents = new Set(); // dedup: re-ground each delivery once
|
|
1039
1055
|
// The vault is keyed by the server project this fleet credential serves
|
|
1040
1056
|
// (learned from the roster); until the first poll names it, fall back to a
|
|
@@ -1077,8 +1093,38 @@ export async function runFleetDaemon() {
|
|
|
1077
1093
|
}
|
|
1078
1094
|
};
|
|
1079
1095
|
|
|
1096
|
+
/**
|
|
1097
|
+
* Tell the server this daemon has stopped retrying the pending sweep.
|
|
1098
|
+
*
|
|
1099
|
+
* The retry budget is a `let` in this process; `regen_requested_at` is a
|
|
1100
|
+
* durable column with a 24-hour TTL. Without this the two disagreed — the
|
|
1101
|
+
* daemon had permanently given up while every surface went on calling the
|
|
1102
|
+
* sweep queued, for the rest of the day. Best-effort: an older server 404s
|
|
1103
|
+
* once and the TTL is still the backstop it always was.
|
|
1104
|
+
*/
|
|
1105
|
+
const postWikiAbandoned = async () => {
|
|
1106
|
+
try {
|
|
1107
|
+
await fetch(WIKI_ABANDONED_URL, {
|
|
1108
|
+
method: 'POST',
|
|
1109
|
+
headers: {
|
|
1110
|
+
Authorization: `Bearer ${FLEET_TOKEN}`,
|
|
1111
|
+
'User-Agent': USER_AGENT,
|
|
1112
|
+
'Content-Type': 'application/json',
|
|
1113
|
+
},
|
|
1114
|
+
signal: AbortSignal.timeout(15_000),
|
|
1115
|
+
body: '{}',
|
|
1116
|
+
});
|
|
1117
|
+
} catch {
|
|
1118
|
+
/* best-effort — the request's own TTL still expires it */
|
|
1119
|
+
}
|
|
1120
|
+
};
|
|
1121
|
+
|
|
1080
1122
|
const enqueueSweep = (job) => {
|
|
1081
1123
|
if (!job || job.requestedAt === lastSweepAt) return;
|
|
1124
|
+
if (job.requestedAt !== sweepAttemptsFor) {
|
|
1125
|
+
sweepAttemptsFor = job.requestedAt;
|
|
1126
|
+
sweepAttempts = 0;
|
|
1127
|
+
}
|
|
1082
1128
|
lastSweepAt = job.requestedAt;
|
|
1083
1129
|
// A full sweep is expensive — never stack two. One queued sweep already
|
|
1084
1130
|
// covers any newer Regenerate click (it reads the repo fresh when it runs).
|
|
@@ -1224,6 +1270,18 @@ export async function runFleetDaemon() {
|
|
|
1224
1270
|
// thinking block or slow tool (which emit nothing until they finish) —
|
|
1225
1271
|
// otherwise the cover would flap back to the empty state mid-sweep.
|
|
1226
1272
|
let heartbeat = null;
|
|
1273
|
+
/**
|
|
1274
|
+
* Did THIS sweep finish? Read by the `finally` below, which owns the
|
|
1275
|
+
* retry decision for every way out of this block.
|
|
1276
|
+
*
|
|
1277
|
+
* It used to be decided inline on the one branch where the turn
|
|
1278
|
+
* returned without its sentinel — so the two OTHER ways a sweep fails,
|
|
1279
|
+
* `pickRuntimeFor` finding no CLI and the catch around the whole turn,
|
|
1280
|
+
* left the request pinned and never retried at all. Those are the
|
|
1281
|
+
* failures most worth retrying: a missing runtime is fixed by
|
|
1282
|
+
* installing one, and a thrown turn is exactly the transient case.
|
|
1283
|
+
*/
|
|
1284
|
+
let sweepCompleted = false;
|
|
1227
1285
|
try {
|
|
1228
1286
|
// Immediate frame so the cover shows the daemon feed right away (the
|
|
1229
1287
|
// "reading your code" phase), not a static message, while Claude warms up.
|
|
@@ -1283,6 +1341,7 @@ export async function runFleetDaemon() {
|
|
|
1283
1341
|
}
|
|
1284
1342
|
const wikiLabel = RUNTIMES[wikiRt].label;
|
|
1285
1343
|
if (task.type === 'sweep') {
|
|
1344
|
+
sweepAttempts++;
|
|
1286
1345
|
note(`${c.cyan('wiki')} ${c.dim(`— regenerating: your ${wikiLabel} is reading the repo…`)}`);
|
|
1287
1346
|
const out = await runTurn({
|
|
1288
1347
|
prompt: WIKI_KICKOFF(sha, vaultDir),
|
|
@@ -1307,9 +1366,12 @@ export async function runFleetDaemon() {
|
|
|
1307
1366
|
},
|
|
1308
1367
|
});
|
|
1309
1368
|
const complete = sawSentinel(out, 'WIKI_DONE');
|
|
1310
|
-
if (complete)
|
|
1311
|
-
|
|
1312
|
-
|
|
1369
|
+
if (complete) {
|
|
1370
|
+
sweepCompleted = true;
|
|
1371
|
+
ok(`${c.cyan('wiki')} ${c.dim('— vault regenerated from your code.')}`);
|
|
1372
|
+
} else {
|
|
1373
|
+
warn('wiki sweep ended without WIKI_DONE — partial pages synced');
|
|
1374
|
+
}
|
|
1313
1375
|
await runSync(complete);
|
|
1314
1376
|
} else {
|
|
1315
1377
|
const files = changedFilesForShas(task.shas);
|
|
@@ -1376,6 +1438,34 @@ export async function runFleetDaemon() {
|
|
|
1376
1438
|
} finally {
|
|
1377
1439
|
wikiChild = null;
|
|
1378
1440
|
if (heartbeat) clearInterval(heartbeat);
|
|
1441
|
+
/**
|
|
1442
|
+
* THE RETRY DECISION, IN ONE PLACE, FOR EVERY WAY OUT OF THIS BLOCK.
|
|
1443
|
+
*
|
|
1444
|
+
* Deciding it inline on the no-sentinel branch covered one of the
|
|
1445
|
+
* three ways a sweep fails and silently declined the other two. Here
|
|
1446
|
+
* it covers the thrown turn and the no-runtime return as well, which
|
|
1447
|
+
* are the two most worth retrying.
|
|
1448
|
+
*
|
|
1449
|
+
* A successful sweep resets the budget. A failed one with budget left
|
|
1450
|
+
* clears `lastSweepAt` so the roster's next offer of the SAME request
|
|
1451
|
+
* is accepted — partial pages are synced without pruning either way,
|
|
1452
|
+
* so a retry resumes rather than starting over. A failed one with the
|
|
1453
|
+
* budget spent tells the SERVER, because the counter is process-local
|
|
1454
|
+
* and the request it bounds is durable for 24 hours.
|
|
1455
|
+
*/
|
|
1456
|
+
if (task.type === 'sweep') {
|
|
1457
|
+
if (sweepCompleted) {
|
|
1458
|
+
sweepAttempts = 0;
|
|
1459
|
+
} else if (sweepAttempts < MAX_SWEEP_ATTEMPTS) {
|
|
1460
|
+
lastSweepAt = null;
|
|
1461
|
+
warn(`wiki sweep failed — retrying (${sweepAttempts}/${MAX_SWEEP_ATTEMPTS})`);
|
|
1462
|
+
} else {
|
|
1463
|
+
warn(
|
|
1464
|
+
`wiki sweep failed ${sweepAttempts} times — giving up on this request; press Regenerate to try again.`
|
|
1465
|
+
);
|
|
1466
|
+
await postWikiAbandoned();
|
|
1467
|
+
}
|
|
1468
|
+
}
|
|
1379
1469
|
// Terminal frame so the app cover clears promptly (don't wait for the
|
|
1380
1470
|
// freshness window to lapse). force-sent past the throttle.
|
|
1381
1471
|
await postWikiProgress(frame({ done: true }), true);
|
|
@@ -1643,7 +1733,15 @@ export async function runFleetDaemon() {
|
|
|
1643
1733
|
// Terminal-session presence, throttled + dedup'd inside; never awaited —
|
|
1644
1734
|
// the daemon's own worktrees are carved out (a session the daemon spawned
|
|
1645
1735
|
// is already a tab, not something to offer adopting).
|
|
1646
|
-
void maybeReportLocalSessions({
|
|
1736
|
+
void maybeReportLocalSessions({
|
|
1737
|
+
repoRoot,
|
|
1738
|
+
excludeDirs: [baseDir],
|
|
1739
|
+
// …and our OWN tabs' conversations. Only the CHECKOUT needs this: every
|
|
1740
|
+
// other place is under `baseDir` and already fenced by directory, while
|
|
1741
|
+
// the operator's tabs share the checkout with real terminal sessions and
|
|
1742
|
+
// cannot be. See `ourConversationIds`.
|
|
1743
|
+
excludeIds: ourConversationIds(repoRoot),
|
|
1744
|
+
});
|
|
1647
1745
|
// …and the repo itself: every worktree and every branch, ours and not.
|
|
1648
1746
|
// Never awaited, throttled inside, and silent on an older server.
|
|
1649
1747
|
void maybeReportRepoState({ repoRoot, baseRef });
|
|
@@ -1736,7 +1834,10 @@ export async function runFleetDaemon() {
|
|
|
1736
1834
|
// runs queued deploy jobs (the server only sends deployJobs to authorized
|
|
1737
1835
|
// machines). Config report is cheap + dedup'd; jobs are single-flight.
|
|
1738
1836
|
if (roster.env?.deployAuthorized) {
|
|
1739
|
-
|
|
1837
|
+
// The BASE branch's copy, not the working tree's — see readDeployConfig.
|
|
1838
|
+
// Reporting the working tree would advertise targets the runner will not
|
|
1839
|
+
// find, which is the same lie in the other direction.
|
|
1840
|
+
void reportDeployConfig(repoRoot, getBaseRef());
|
|
1740
1841
|
processDeployJobs(roster.deployJobs, { repoRoot, baseRef: getBaseRef(), myPubB64 });
|
|
1741
1842
|
}
|
|
1742
1843
|
|
|
@@ -234,6 +234,72 @@ function firstCwdRecord(file) {
|
|
|
234
234
|
}
|
|
235
235
|
}
|
|
236
236
|
|
|
237
|
+
/**
|
|
238
|
+
* THE MARKER NAMES THE DAEMON PINS A TAB'S CONVERSATION UNDER.
|
|
239
|
+
*
|
|
240
|
+
* One owner, because two things read them and a drift here is silent: `work.mjs`
|
|
241
|
+
* WRITES them (`sessionMetaPath(wt, <name>, sessionId)`) and this file READS
|
|
242
|
+
* them to know which conversations are its own.
|
|
243
|
+
*/
|
|
244
|
+
export const SESSION_MARKERS = [
|
|
245
|
+
'flowviant-claude-session',
|
|
246
|
+
'flowviant-codex-thread',
|
|
247
|
+
'flowviant-agy-conversation',
|
|
248
|
+
];
|
|
249
|
+
|
|
250
|
+
/**
|
|
251
|
+
* EVERY CONVERSATION THIS DAEMON STARTED, so the adopt strip never offers you
|
|
252
|
+
* your own reflection.
|
|
253
|
+
*
|
|
254
|
+
* The machine OPERATOR's tabs work in the checkout itself (their place is
|
|
255
|
+
* `'repo'`), so their CLI transcripts land in `~/.claude/projects/<munge(repoRoot)>/`
|
|
256
|
+
* — the very directory this scan reads. The `excludeDirs` fence cannot help:
|
|
257
|
+
* repoRoot is the scan ROOT, not something under it. So the daemon's own tabs
|
|
258
|
+
* were reported as adoptable "terminal sessions", and two things followed:
|
|
259
|
+
* the `+` menu offered to adopt a tab you already have open (accepting FORKS
|
|
260
|
+
* that conversation and copies the checkout's uncommitted and untracked files
|
|
261
|
+
* into a new worktree), and — because the ended walk keeps only the newest row
|
|
262
|
+
* per directory, and a live tab's transcript is always the freshest thing in
|
|
263
|
+
* the checkout — a REAL terminal session started in the repo root could never
|
|
264
|
+
* be offered at all.
|
|
265
|
+
*
|
|
266
|
+
* Excluded by conversation ID rather than by directory, because the directory
|
|
267
|
+
* is shared with exactly the sessions we want to keep offering.
|
|
268
|
+
*/
|
|
269
|
+
export function ourConversationIds(repoRoot, gitDirs = []) {
|
|
270
|
+
const ids = new Set();
|
|
271
|
+
const dirs = new Set(gitDirs.filter(Boolean));
|
|
272
|
+
try {
|
|
273
|
+
dirs.add(
|
|
274
|
+
execFileSync('git', ['rev-parse', '--absolute-git-dir'], {
|
|
275
|
+
cwd: repoRoot,
|
|
276
|
+
encoding: 'utf8',
|
|
277
|
+
timeout: 5_000,
|
|
278
|
+
}).trim()
|
|
279
|
+
);
|
|
280
|
+
} catch {
|
|
281
|
+
/* not a repo, or git unavailable — the fence is simply empty */
|
|
282
|
+
}
|
|
283
|
+
for (const dir of dirs) {
|
|
284
|
+
let names = [];
|
|
285
|
+
try {
|
|
286
|
+
names = readdirSync(dir);
|
|
287
|
+
} catch {
|
|
288
|
+
continue;
|
|
289
|
+
}
|
|
290
|
+
for (const n of names) {
|
|
291
|
+
if (!SESSION_MARKERS.some((m) => n === m || n.startsWith(`${m}-`))) continue;
|
|
292
|
+
try {
|
|
293
|
+
const v = readFileSync(join(dir, n), 'utf8').trim();
|
|
294
|
+
if (v) ids.add(v);
|
|
295
|
+
} catch {
|
|
296
|
+
/* unreadable marker: nothing to fence */
|
|
297
|
+
}
|
|
298
|
+
}
|
|
299
|
+
}
|
|
300
|
+
return ids;
|
|
301
|
+
}
|
|
302
|
+
|
|
237
303
|
/**
|
|
238
304
|
* Every Claude terminal session belonging to this repo: LIVE ones from the
|
|
239
305
|
* liveness registry, ENDED ones from the transcript store. Returns
|
|
@@ -245,7 +311,11 @@ function firstCwdRecord(file) {
|
|
|
245
311
|
* itself spawned are tabs already, and offering to adopt one would be the
|
|
246
312
|
* product offering the user their own reflection.
|
|
247
313
|
*/
|
|
248
|
-
export function scanLocalSessions({ repoRoot, excludeDirs = [] }) {
|
|
314
|
+
export function scanLocalSessions({ repoRoot, excludeDirs = [], excludeIds }) {
|
|
315
|
+
/** Conversations this daemon started — never offered for adoption. See
|
|
316
|
+
* `ourConversationIds`: the operator's tabs share the checkout with real
|
|
317
|
+
* terminal sessions, so the fence has to be by id, not by directory. */
|
|
318
|
+
const mine = excludeIds instanceof Set ? excludeIds : new Set(excludeIds ?? []);
|
|
249
319
|
const live = [];
|
|
250
320
|
const ended = [];
|
|
251
321
|
try {
|
|
@@ -311,6 +381,7 @@ export function scanLocalSessions({ repoRoot, excludeDirs = [] }) {
|
|
|
311
381
|
/* no transcript yet */
|
|
312
382
|
}
|
|
313
383
|
if (!liveTitle && typeof rec.name === 'string' && rec.name.trim()) liveTitle = rec.name.trim();
|
|
384
|
+
if (mine.has(rec.sessionId)) continue; // our own tab, not a terminal session
|
|
314
385
|
live.push({
|
|
315
386
|
id: rec.sessionId,
|
|
316
387
|
cwd,
|
|
@@ -387,6 +458,13 @@ export function scanLocalSessions({ repoRoot, excludeDirs = [] }) {
|
|
|
387
458
|
continue;
|
|
388
459
|
}
|
|
389
460
|
if (!ours(cwd)) continue;
|
|
461
|
+
/**
|
|
462
|
+
* OUR OWN TAB IS NOT A CANDIDATE, and it is skipped BEFORE `seenCwds`
|
|
463
|
+
* claims the directory — otherwise the daemon's transcript (always the
|
|
464
|
+
* freshest thing in the checkout) took the one slot that directory gets
|
|
465
|
+
* and a genuine terminal session there could never be offered at all.
|
|
466
|
+
*/
|
|
467
|
+
if (mine.has(cand.id)) continue;
|
|
390
468
|
if (seenCwds.has(cwd)) continue; // newest per directory; a live one owns its cwd
|
|
391
469
|
seenCwds.add(cwd);
|
|
392
470
|
const title = transcriptTitle(cand.file, cand.mtimeMs);
|
|
@@ -405,7 +483,7 @@ export function scanLocalSessions({ repoRoot, excludeDirs = [] }) {
|
|
|
405
483
|
const claude = [...live.slice(0, REPORT_CAP), ...ended];
|
|
406
484
|
// agy rides in whatever room the cap leaves — Claude sessions first, they
|
|
407
485
|
// are the ones adoption serves best (fork, never move).
|
|
408
|
-
const agy = scanAgyConversations({ repoRoot, excludeDirs }).slice(
|
|
486
|
+
const agy = scanAgyConversations({ repoRoot, excludeDirs, excludeIds: mine }).slice(
|
|
409
487
|
0,
|
|
410
488
|
Math.max(0, REPORT_CAP - claude.length)
|
|
411
489
|
);
|
|
@@ -511,7 +589,8 @@ export function isAgyConversationLive(id) {
|
|
|
511
589
|
|
|
512
590
|
/** The repo's agy conversations, via the cwd registry — see the section
|
|
513
591
|
* comment for why this is deliberately a subset. */
|
|
514
|
-
function scanAgyConversations({ repoRoot, excludeDirs = [] }) {
|
|
592
|
+
function scanAgyConversations({ repoRoot, excludeDirs = [], excludeIds }) {
|
|
593
|
+
const mine = excludeIds instanceof Set ? excludeIds : new Set(excludeIds ?? []);
|
|
515
594
|
const out = [];
|
|
516
595
|
try {
|
|
517
596
|
let realRoot;
|
|
@@ -537,6 +616,9 @@ function scanAgyConversations({ repoRoot, excludeDirs = [] }) {
|
|
|
537
616
|
const processUp = agyProcessAlive();
|
|
538
617
|
for (const [cwd, id] of Object.entries(map)) {
|
|
539
618
|
if (typeof id !== 'string' || !AGY_UUID_RE.test(id)) continue;
|
|
619
|
+
// The operator's own agy TAB, whose conversation is registered against
|
|
620
|
+
// the checkout exactly as a terminal one would be.
|
|
621
|
+
if (mine.has(id)) continue;
|
|
540
622
|
let real;
|
|
541
623
|
try {
|
|
542
624
|
real = realpathSync(cwd);
|
package/bin/lib/preflight.mjs
CHANGED
package/bin/lib/preview.mjs
CHANGED
|
@@ -254,18 +254,53 @@ function forgetPreviewPid(pid) {
|
|
|
254
254
|
mutateRegistry((list) => list.filter((e) => e.pid !== pid));
|
|
255
255
|
}
|
|
256
256
|
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
257
|
+
/**
|
|
258
|
+
* Only kill a pid we can VERIFY is still one of ours — its command line must
|
|
259
|
+
* still contain the signature we stored. A reused pid belonging to something
|
|
260
|
+
* unrelated won't match, so we never kill a stranger.
|
|
261
|
+
*
|
|
262
|
+
* MACOS READS IT THROUGH `ps`, because "Linux-only, elsewhere just clear the
|
|
263
|
+
* registry" was the worst of both. A macOS daemon killed ungracefully leaves
|
|
264
|
+
* cloudflared running — it is spawned detached — so the public hostname keeps
|
|
265
|
+
* resolving to this machine while the gate dies with the daemon. The reaper
|
|
266
|
+
* then verified nothing, killed nothing, and DELETED the entry, so no later run
|
|
267
|
+
* could ever find that process. The tunnel served 502 until anything else on
|
|
268
|
+
* the box bound the gate's old port, which `startAuthProxy` obtained with
|
|
269
|
+
* `listen(0)` and is therefore squarely inside the kernel's ephemeral reuse
|
|
270
|
+
* pool — at which point a live public hostname forwarded straight to an
|
|
271
|
+
* unrelated local service with no gate, no password and no grant check. Exactly
|
|
272
|
+
* what this file's header says the reap exists to prevent.
|
|
273
|
+
*
|
|
274
|
+
* Three states, not two: `true` (ours), `false` (verified NOT ours, or gone),
|
|
275
|
+
* and `null` (we could not look — the caller keeps the record rather than
|
|
276
|
+
* dropping it).
|
|
277
|
+
*/
|
|
261
278
|
function stillOurs(pid, sig) {
|
|
262
|
-
if (
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
279
|
+
if (typeof sig !== 'string' || sig.length === 0) return false;
|
|
280
|
+
if (platform() === 'linux') {
|
|
281
|
+
try {
|
|
282
|
+
const cmd = readFileSync(`/proc/${pid}/cmdline`, 'utf8').replace(/\0/g, ' ');
|
|
283
|
+
return cmd.includes(sig);
|
|
284
|
+
} catch {
|
|
285
|
+
return false; // process gone / unreadable
|
|
286
|
+
}
|
|
268
287
|
}
|
|
288
|
+
if (platform() === 'darwin') {
|
|
289
|
+
try {
|
|
290
|
+
const cmd = execFileSync('ps', ['-p', String(pid), '-o', 'command='], {
|
|
291
|
+
encoding: 'utf8',
|
|
292
|
+
timeout: 5_000,
|
|
293
|
+
});
|
|
294
|
+
return cmd.includes(sig);
|
|
295
|
+
} catch {
|
|
296
|
+
// `ps` exits non-zero when the pid is gone — which is a real answer.
|
|
297
|
+
return false;
|
|
298
|
+
}
|
|
299
|
+
}
|
|
300
|
+
// Windows: no way to check from here. UNKNOWN, never "not ours" — see the
|
|
301
|
+
// caller, which keeps the record so a later run on a platform that can look
|
|
302
|
+
// is still able to.
|
|
303
|
+
return null;
|
|
269
304
|
}
|
|
270
305
|
|
|
271
306
|
/** Reap tunnel process groups left behind by a previously-crashed daemon.
|
|
@@ -283,8 +318,19 @@ export function reapOrphanPreviews(log) {
|
|
|
283
318
|
const handled = new Set();
|
|
284
319
|
for (const { pid, sig, owner } of list) {
|
|
285
320
|
if (Number.isInteger(owner) && owner !== process.pid && processAlive(owner)) continue;
|
|
321
|
+
const ours = stillOurs(pid, sig);
|
|
322
|
+
/**
|
|
323
|
+
* THE RECORD OUTLIVES A REAP THAT COULD NOT LOOK. `handled.add` ran BEFORE
|
|
324
|
+
* this check, so an entry was dropped whether or not anything was killed —
|
|
325
|
+
* and on any platform `stillOurs` could not read, that deleted the only
|
|
326
|
+
* trace of a tunnel still serving the public internet. Kept on `null`
|
|
327
|
+
* (unknown); removed on `true` (we killed it) and on `false` (the process
|
|
328
|
+
* is gone, or the pid now belongs to somebody else and the entry is stale
|
|
329
|
+
* either way).
|
|
330
|
+
*/
|
|
331
|
+
if (ours === null) continue;
|
|
286
332
|
handled.add(pid);
|
|
287
|
-
if (!
|
|
333
|
+
if (!ours) continue;
|
|
288
334
|
try {
|
|
289
335
|
process.kill(-pid, 'SIGKILL'); // whole group
|
|
290
336
|
killed++;
|
package/bin/lib/prompts.mjs
CHANGED
|
@@ -482,11 +482,29 @@ export const WORK_TURN_KICKOFF_PLAIN = ({ sessionName, message, askedByName }) =
|
|
|
482
482
|
});
|
|
483
483
|
|
|
484
484
|
|
|
485
|
+
/**
|
|
486
|
+
* THE FEATURE NAME AND THE FILE LIST ARE FENCED, and they were the only two
|
|
487
|
+
* unfenced strings left in this file.
|
|
488
|
+
*
|
|
489
|
+
* A card title is member-authored, and worse: ticket triage falls back to the
|
|
490
|
+
* REPORTER's ticket title verbatim, so a stranger can put words in it. That
|
|
491
|
+
* string rides the ship report into `code_map_reground_jobs`, comes back on the
|
|
492
|
+
* roster, and landed here as a bare `Feature: <text>` line — no delimiter, no
|
|
493
|
+
* instruction not to obey it. This turn runs UNATTENDED under `WIKI_PERM`,
|
|
494
|
+
* which grants Write, Edit, `Bash(mkdir:*)` and `Bash(rm:*)`, and whose own
|
|
495
|
+
* comment concedes that Write/Edit cannot be path-scoped here — the worktree
|
|
496
|
+
* reset is the backstop, and it only cleans the wiki worktree. Anything written
|
|
497
|
+
* outside it survives.
|
|
498
|
+
*
|
|
499
|
+
* Every other kickoff in this file already fences its untrusted input; this one
|
|
500
|
+
* was simply missed. The file list is fenced for the same reason at lower
|
|
501
|
+
* stakes — a path is attacker-influenceable too, and there is no cost to it.
|
|
502
|
+
*/
|
|
485
503
|
export const REGROUND_KICKOFF = ({ sha, title, files, vaultDir, predictedPages = [] }) =>
|
|
486
504
|
`A feature just merged. Re-ground the knowledge vault (${vaultDir}) for it.\n\n` +
|
|
487
|
-
`Feature
|
|
505
|
+
`Feature:\n${fence('FEATURE NAME', title)}\n` +
|
|
488
506
|
`Grounded commit: ${sha}\n` +
|
|
489
|
-
`Changed files:\n${files.map((f) => `- ${f}`).join('\n')}\n\n` +
|
|
507
|
+
`Changed files:\n${fence('CHANGED FILES', files.map((f) => `- ${f}`).join('\n'))}\n\n` +
|
|
490
508
|
// The plan's own prediction, made when this work was drafted. Overlapping
|
|
491
509
|
// changed files against each page's frontmatter finds most of what moved, but
|
|
492
510
|
// misses a page whose file list has drifted or that documents a CONCEPT rather
|
package/bin/lib/work.mjs
CHANGED
|
@@ -82,6 +82,37 @@ import {
|
|
|
82
82
|
import { worktreeDiff } from './worktreeDiff.mjs';
|
|
83
83
|
import { homedir } from 'node:os';
|
|
84
84
|
|
|
85
|
+
/**
|
|
86
|
+
* A RESUME THAT FOUND NO CONVERSATION, in the CLI's own words.
|
|
87
|
+
*
|
|
88
|
+
* "Produced nothing" was the only signal the retry backstop had, and it is not
|
|
89
|
+
* the signal these failures give: Claude Code answers a dead `--resume` id with
|
|
90
|
+
* a result event carrying `errors: ['No conversation found with session ID: …']`
|
|
91
|
+
* AND writes the same line to stderr, so `out` is non-empty, the backstop never
|
|
92
|
+
* fired, and the turn SETTLED SUCCESSFULLY with the error as its answer. Every
|
|
93
|
+
* later message in that tab replied the same way — the marker file still held
|
|
94
|
+
* the dead id, nothing rewrote it (that only happens when an init event is
|
|
95
|
+
* seen, and there is none), and no surface offered a way to clear it. A tab
|
|
96
|
+
* bricked forever by its own CLI pruning its history, which it does on its own
|
|
97
|
+
* schedule.
|
|
98
|
+
*
|
|
99
|
+
* Matched on the CLI's phrasing rather than a code, because neither runtime
|
|
100
|
+
* gives one. Deliberately narrow: it must not swallow a rate limit or a
|
|
101
|
+
* permission refusal, both of which are real answers that should stand.
|
|
102
|
+
*/
|
|
103
|
+
const RESUME_LOST = [
|
|
104
|
+
/no conversation found/i,
|
|
105
|
+
/no session found/i,
|
|
106
|
+
/session .{0,80}not found/i,
|
|
107
|
+
/conversation .{0,80}not found/i,
|
|
108
|
+
/thread .{0,80}not found/i,
|
|
109
|
+
/trajectory not found/i,
|
|
110
|
+
];
|
|
111
|
+
const resumeConversationLost = (text) => {
|
|
112
|
+
const t = String(text || '');
|
|
113
|
+
return t.length > 0 && RESUME_LOST.some((re) => re.test(t));
|
|
114
|
+
};
|
|
115
|
+
|
|
85
116
|
/**
|
|
86
117
|
* The shape a per-tab model name must have before it rides argv as
|
|
87
118
|
* `--model <name>`. Conservative for the same reason the codex thread id is
|
|
@@ -1309,6 +1340,23 @@ export function createWorkManager({
|
|
|
1309
1340
|
const pid = Number(job.pid);
|
|
1310
1341
|
const signal = job.signal === 'KILL' ? 'SIGKILL' : 'SIGTERM';
|
|
1311
1342
|
|
|
1343
|
+
/**
|
|
1344
|
+
* CLAIM FIRST, EVEN FOR THE ANSWERS THAT SIGNAL NOTHING.
|
|
1345
|
+
*
|
|
1346
|
+
* `unsupported` and `not_found` cost nothing to produce, which is exactly
|
|
1347
|
+
* why they must be leased: two daemons on one credential are handed the
|
|
1348
|
+
* same job, and the one holding NOTHING reaches these branches without
|
|
1349
|
+
* measuring anything or making a round trip — so it would answer first,
|
|
1350
|
+
* settle the row, and the machine that could actually have signalled would
|
|
1351
|
+
* find the job already closed and never touch the process. The person reads
|
|
1352
|
+
* "the pid is no longer one of this tab's", which is a real sentence, over a
|
|
1353
|
+
* watcher that is still running.
|
|
1354
|
+
*
|
|
1355
|
+
* The server enforces holder-only settles again (it briefly accepted these
|
|
1356
|
+
* two unclaimed, which is the bug above), so an unclaimed post here would
|
|
1357
|
+
* simply be dropped. One extra round trip on a path nobody is waiting on.
|
|
1358
|
+
*/
|
|
1359
|
+
if (!(await claimKill(id))) return;
|
|
1312
1360
|
if (!processesSupported()) {
|
|
1313
1361
|
await postKill({ id, outcome: 'unsupported' });
|
|
1314
1362
|
return;
|
|
@@ -1324,7 +1372,6 @@ export function createWorkManager({
|
|
|
1324
1372
|
await remeasureAfterKill(sessionId);
|
|
1325
1373
|
return;
|
|
1326
1374
|
}
|
|
1327
|
-
if (!(await claimKill(id))) return;
|
|
1328
1375
|
try {
|
|
1329
1376
|
process.kill(pid, signal);
|
|
1330
1377
|
// WHAT HAPPENED, not what we did. "We sent a signal" is a fact about us;
|
|
@@ -1439,7 +1486,18 @@ export function createWorkManager({
|
|
|
1439
1486
|
// outcome because "the machine cannot do this at all" and "GitHub said
|
|
1440
1487
|
// no" read differently to the person who asked.
|
|
1441
1488
|
try {
|
|
1442
|
-
|
|
1489
|
+
// TIMED OUT, like every other `gh` call. `execFileSync` blocks the whole
|
|
1490
|
+
// event loop, so a hung `gh` — an expired token whose refresh hits a
|
|
1491
|
+
// black hole, a credential helper waiting on a keyring prompt that has
|
|
1492
|
+
// no terminal — stops the roster poll, every in-flight settle, the
|
|
1493
|
+
// worktree sweep and the deploy heartbeat (whose 3-minute staleness
|
|
1494
|
+
// window then re-queues a deploy this daemon is still running). The
|
|
1495
|
+
// AGENT merge path was given exactly these timeouts in 0.77.1; this
|
|
1496
|
+
// copy, forty lines of the same logic, was missed.
|
|
1497
|
+
execFileSync('gh', ['auth', 'status'], {
|
|
1498
|
+
stdio: ['ignore', 'pipe', 'pipe'],
|
|
1499
|
+
timeout: 20_000,
|
|
1500
|
+
});
|
|
1443
1501
|
} catch (e) {
|
|
1444
1502
|
const missing = e?.code === 'ENOENT';
|
|
1445
1503
|
await settlePr({
|
|
@@ -1499,6 +1557,7 @@ export function createWorkManager({
|
|
|
1499
1557
|
execFileSync('gh', ['pr', 'view', branch, '--json', 'url,state'], {
|
|
1500
1558
|
cwd: repoRoot,
|
|
1501
1559
|
stdio: ['ignore', 'pipe', 'pipe'],
|
|
1560
|
+
timeout: 30_000,
|
|
1502
1561
|
}).toString()
|
|
1503
1562
|
);
|
|
1504
1563
|
return j?.state === 'OPEN' && typeof j?.url === 'string' ? j.url.trim() : null;
|
|
@@ -1524,7 +1583,7 @@ export function createWorkManager({
|
|
|
1524
1583
|
// call, nothing invented. baseBranchName, not baseRef: gh 422s on
|
|
1525
1584
|
// a remote-tracking name like origin/main.
|
|
1526
1585
|
['pr', 'create', '--head', branch, '--base', baseName, '--fill'],
|
|
1527
|
-
{ cwd: repoRoot, stdio: ['ignore', 'pipe', 'pipe'] }
|
|
1586
|
+
{ cwd: repoRoot, stdio: ['ignore', 'pipe', 'pipe'], timeout: 60_000 }
|
|
1528
1587
|
)
|
|
1529
1588
|
.toString()
|
|
1530
1589
|
.trim();
|
|
@@ -1559,6 +1618,7 @@ export function createWorkManager({
|
|
|
1559
1618
|
execFileSync('gh', ['pr', 'merge', branch, '--merge'], {
|
|
1560
1619
|
cwd: repoRoot,
|
|
1561
1620
|
stdio: ['ignore', 'pipe', 'pipe'],
|
|
1621
|
+
timeout: 120_000,
|
|
1562
1622
|
});
|
|
1563
1623
|
} catch (e) {
|
|
1564
1624
|
const line = ghFirstLine(e);
|
|
@@ -2247,15 +2307,41 @@ export function createWorkManager({
|
|
|
2247
2307
|
* the pid lives and clears the lock once it is dead.
|
|
2248
2308
|
*/
|
|
2249
2309
|
const workChildren = new Map(); // child process -> lockPath | null
|
|
2310
|
+
/**
|
|
2311
|
+
* Children whose whole PROCESS GROUP must go, not just the child.
|
|
2312
|
+
*
|
|
2313
|
+
* The standing rule is the opposite — teardown SIGTERMs the CLI child and
|
|
2314
|
+
* never its group, precisely so an unattended auto-update does not kill the
|
|
2315
|
+
* driver's dev server, which a turn started INTO the CLI's group. That rule
|
|
2316
|
+
* is about the CLI's group and stays.
|
|
2317
|
+
*
|
|
2318
|
+
* A project CHECK is a different group entirely: the daemon spawns it itself,
|
|
2319
|
+
* `detached` with `shell: true`, so its group holds the check command and
|
|
2320
|
+
* nothing else — no dev server of anybody's. And `shell: true` is exactly
|
|
2321
|
+
* what makes signalling only the child useless: a compound command like
|
|
2322
|
+
* `npm run lint && npm test` leaves `/bin/sh` as the child, so SIGTERM killed
|
|
2323
|
+
* the shell and the test runner underneath it carried on holding the
|
|
2324
|
+
* worktree — and that place's WRITER lock — through every stop, takeover and
|
|
2325
|
+
* auto-update. The check's own ten-minute timer already kills `-child.pid`
|
|
2326
|
+
* for this reason; teardown simply did not.
|
|
2327
|
+
*/
|
|
2328
|
+
const groupKillChildren = new Set();
|
|
2250
2329
|
const shutdownWork = () => {
|
|
2251
2330
|
for (const [ch] of workChildren) {
|
|
2252
2331
|
try {
|
|
2253
|
-
ch.kill('SIGTERM');
|
|
2332
|
+
if (groupKillChildren.has(ch) && ch.pid) process.kill(-ch.pid, 'SIGTERM');
|
|
2333
|
+
else ch.kill('SIGTERM');
|
|
2254
2334
|
} catch {
|
|
2255
|
-
|
|
2335
|
+
// A group that has already gone, or a pid that is no longer a leader.
|
|
2336
|
+
try {
|
|
2337
|
+
ch.kill('SIGTERM');
|
|
2338
|
+
} catch {
|
|
2339
|
+
/* best-effort */
|
|
2340
|
+
}
|
|
2256
2341
|
}
|
|
2257
2342
|
}
|
|
2258
2343
|
workChildren.clear();
|
|
2344
|
+
groupKillChildren.clear();
|
|
2259
2345
|
};
|
|
2260
2346
|
|
|
2261
2347
|
/**
|
|
@@ -2816,6 +2902,7 @@ export function createWorkManager({
|
|
|
2816
2902
|
toolLog.ev.length > 0 ? toolLog : undefined
|
|
2817
2903
|
);
|
|
2818
2904
|
|
|
2905
|
+
|
|
2819
2906
|
// THE COMMAND AUDIT — every `$ …` the CLI's stream reports, batched
|
|
2820
2907
|
// to the server verbatim so an admin can read what actually ran on
|
|
2821
2908
|
// this box. Same events the narrator renders and forgets; this is
|
|
@@ -2973,8 +3060,28 @@ export function createWorkManager({
|
|
|
2973
3060
|
// loud): a fresh conversation would silently discard the adoption
|
|
2974
3061
|
// and answer as a new session wearing its name — the empty adopt
|
|
2975
3062
|
// turn settles failed below instead.
|
|
2976
|
-
|
|
3063
|
+
/**
|
|
3064
|
+
* …OR PRODUCED ONLY THE CLI SAYING THE CONVERSATION IS GONE.
|
|
3065
|
+
*
|
|
3066
|
+
* "Produced nothing" was the whole test, and it is not the shape
|
|
3067
|
+
* these failures take: Claude Code answers a dead `--resume` id
|
|
3068
|
+
* with a result event carrying `errors: ['No conversation found
|
|
3069
|
+
* with session ID: …']` and writes the same line to stderr, so
|
|
3070
|
+
* `out` is non-empty. The backstop never fired, the turn settled
|
|
3071
|
+
* SUCCESSFULLY with that error as its answer, and — because the
|
|
3072
|
+
* marker is only rewritten when an init event is seen, and there
|
|
3073
|
+
* was none — the dead id stayed pinned. Every later message in
|
|
3074
|
+
* that tab replied identically, with nothing on any surface able
|
|
3075
|
+
* to clear it. A tab bricked forever by its own CLI pruning its
|
|
3076
|
+
* history, which it does on its own schedule.
|
|
3077
|
+
*/
|
|
3078
|
+
if (
|
|
3079
|
+
!adopting &&
|
|
3080
|
+
resume &&
|
|
3081
|
+
(!(out || '').trim() || resumeConversationLost(out))
|
|
3082
|
+
) {
|
|
2977
3083
|
out = await runTurn({ ...turnArgs, resume: false });
|
|
3084
|
+
}
|
|
2978
3085
|
} finally {
|
|
2979
3086
|
// The CLI has stopped printing, so stop relaying. The LINE itself
|
|
2980
3087
|
// is cleared server-side at settle — clearing it here would race
|
|
@@ -3783,10 +3890,30 @@ export function createWorkManager({
|
|
|
3783
3890
|
* — which the stale path does on purpose before a merge. A receipt naming
|
|
3784
3891
|
* somebody else's commit is worse than a missing one.
|
|
3785
3892
|
*/
|
|
3786
|
-
|
|
3787
|
-
|
|
3788
|
-
|
|
3789
|
-
|
|
3893
|
+
/**
|
|
3894
|
+
* IT CANNOT THROW, and that guard is the whole point of it being here.
|
|
3895
|
+
*
|
|
3896
|
+
* `git()` throws on a non-zero exit, and `baseRef()` is built from the
|
|
3897
|
+
* project's Base branch setting — free text an owner types, never verified
|
|
3898
|
+
* against the remote. Point it at a branch with no tracking ref (`develop`
|
|
3899
|
+
* on a repo whose remote branch is `dev`) and this exits "fatal: ambiguous
|
|
3900
|
+
* argument". The throw escaped `runAgentTurn` AFTER the CLI had already run
|
|
3901
|
+
* the card, so `postAgentTurn` was never reached, the server never settled
|
|
3902
|
+
* the turn, and the next poll handed back the identical turn — the same
|
|
3903
|
+
* card re-run every poll for six hours, on the operator's shared account,
|
|
3904
|
+
* piling commits onto the review branch, silently.
|
|
3905
|
+
*
|
|
3906
|
+
* An unreadable range means we cannot MEASURE the receipts, which is a
|
|
3907
|
+
* smaller failure than not settling: the turn still reports, and ship-time
|
|
3908
|
+
* reconciliation books whatever no card claimed. Missing beats fabricated
|
|
3909
|
+
* and both beat a stall.
|
|
3910
|
+
*/
|
|
3911
|
+
let out;
|
|
3912
|
+
try {
|
|
3913
|
+
out = git(['log', '--format=%H', '--no-merges', `${from}..HEAD`, '--not', baseRef()], wt);
|
|
3914
|
+
} catch {
|
|
3915
|
+
return [];
|
|
3916
|
+
}
|
|
3790
3917
|
return typeof out === 'string'
|
|
3791
3918
|
? out.split('\n').map((x) => x.trim()).filter(Boolean).slice(0, 50)
|
|
3792
3919
|
: [];
|
|
@@ -4109,6 +4236,25 @@ export function createWorkManager({
|
|
|
4109
4236
|
detached: true,
|
|
4110
4237
|
stdio: ['ignore', 'pipe', 'pipe'],
|
|
4111
4238
|
});
|
|
4239
|
+
/**
|
|
4240
|
+
* …AND IT IS TRACKED, so a stop or a takeover takes it with them.
|
|
4241
|
+
*
|
|
4242
|
+
* A check is a full test or build run in the agent's worktree, and it
|
|
4243
|
+
* was the one long-lived child the daemon spawned without telling its
|
|
4244
|
+
* own teardown about it. `shutdownWork` signalled the CLI children and
|
|
4245
|
+
* left this one — so restarting the daemon, or a same-repo takeover,
|
|
4246
|
+
* orphaned a running test suite inside a worktree the sweep may then
|
|
4247
|
+
* try to remove. The ten-minute timer would eventually kill it, but by
|
|
4248
|
+
* then it belongs to no daemon and nothing on any surface names it.
|
|
4249
|
+
*
|
|
4250
|
+
* Registered for a GROUP kill: with `shell: true` the child is
|
|
4251
|
+
* `/bin/sh`, and signalling it leaves the runner it started behind —
|
|
4252
|
+
* which is the process actually holding the worktree. See
|
|
4253
|
+
* `groupKillChildren` for why this one is exempt from the
|
|
4254
|
+
* never-signal-the-group rule.
|
|
4255
|
+
*/
|
|
4256
|
+
workChildren.set(child, null);
|
|
4257
|
+
groupKillChildren.add(child);
|
|
4112
4258
|
} catch (e) {
|
|
4113
4259
|
// TEXT BEFORE FINISH: `finish` captures `text` by value into the
|
|
4114
4260
|
// resolved object, so assigning afterwards threw the spawn error away
|
|
@@ -4117,9 +4263,32 @@ export function createWorkManager({
|
|
|
4117
4263
|
finish('failed');
|
|
4118
4264
|
return;
|
|
4119
4265
|
}
|
|
4120
|
-
|
|
4266
|
+
/**
|
|
4267
|
+
* The TAIL, not the head: a failing check says why at the end. And
|
|
4268
|
+
* SCRUBBED BEFORE IT IS CUT, which is the order that matters.
|
|
4269
|
+
*
|
|
4270
|
+
* It used to slice first: `text = (text + buf).slice(-CAP)`, with a
|
|
4271
|
+
* single `envScrub` at the very end. `envScrub` replaces EXACT full
|
|
4272
|
+
* values, so any credential straddling either boundary — the rolling
|
|
4273
|
+
* window's, or a chunk's — was already cut in half by the time it was
|
|
4274
|
+
* looked at, matched nothing, and the surviving tail was written to
|
|
4275
|
+
* `agent.checkOutput` and shown to every member of the project. A failing
|
|
4276
|
+
* integration test that dumps its environment is an ordinary way to reach
|
|
4277
|
+
* that, and the partial is enough where the prefix of the key is a
|
|
4278
|
+
* publicly known constant.
|
|
4279
|
+
*
|
|
4280
|
+
* Scrubbing on every chunk fixes both straddles at once: the accumulated
|
|
4281
|
+
* text always holds the previous kept tail plus the whole new chunk, so a
|
|
4282
|
+
* value split across chunks is whole here, and a value near the window
|
|
4283
|
+
* edge is redacted before anything is discarded. Bounded work — the
|
|
4284
|
+
* string is never longer than the cap plus one chunk.
|
|
4285
|
+
*
|
|
4286
|
+
* The one case it cannot cover is a secret LONGER than the cap itself,
|
|
4287
|
+
* which can never sit in the window whole. The final scrub below stays as
|
|
4288
|
+
* the second pass over what actually ships.
|
|
4289
|
+
*/
|
|
4121
4290
|
const keep = (buf) => {
|
|
4122
|
-
text = (text + buf.toString()).slice(-CHECK_OUTPUT_CAP);
|
|
4291
|
+
text = envScrub(text + buf.toString()).slice(-CHECK_OUTPUT_CAP);
|
|
4123
4292
|
};
|
|
4124
4293
|
child.stdout?.on('data', keep);
|
|
4125
4294
|
child.stderr?.on('data', keep);
|
|
@@ -4143,11 +4312,13 @@ export function createWorkManager({
|
|
|
4143
4312
|
}, CHECK_TIMEOUT_MS);
|
|
4144
4313
|
child.on('error', (e) => {
|
|
4145
4314
|
clearTimeout(timer);
|
|
4315
|
+
workChildren.delete(child);
|
|
4146
4316
|
text += String(e?.message || e);
|
|
4147
4317
|
finish('failed');
|
|
4148
4318
|
});
|
|
4149
4319
|
child.on('close', (code) => {
|
|
4150
4320
|
clearTimeout(timer);
|
|
4321
|
+
workChildren.delete(child);
|
|
4151
4322
|
finish(code === 0 ? 'passed' : 'failed');
|
|
4152
4323
|
});
|
|
4153
4324
|
});
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "flowviant",
|
|
3
|
-
"version": "0.
|
|
4
|
-
"description": "Run your own coding CLIs as build agents for Flowviant
|
|
3
|
+
"version": "0.78.0",
|
|
4
|
+
"description": "Run your own coding CLIs as build agents for Flowviant — Claude Code, Codex or Antigravity, on your own credentials. Holds your sessions, keeps a worktree per tab, and ships branches on your word.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"bin": {
|
|
7
7
|
"flowviant": "bin/cli.mjs"
|