flowviant 0.77.5 → 0.78.1
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/authproxy.mjs +101 -32
- package/bin/lib/claude.mjs +12 -19
- package/bin/lib/deploy.mjs +150 -15
- package/bin/lib/env.mjs +37 -24
- package/bin/lib/fleet.mjs +136 -46
- package/bin/lib/git.mjs +0 -263
- package/bin/lib/grant.mjs +24 -3
- package/bin/lib/landed.mjs +69 -22
- package/bin/lib/localSessions.mjs +85 -3
- package/bin/lib/preflight.mjs +1 -1
- package/bin/lib/preview.mjs +35 -16
- package/bin/lib/prompts.mjs +40 -23
- package/bin/lib/shipSweep.mjs +16 -5
- package/bin/lib/work.mjs +364 -55
- package/bin/lib/worktreeDiff.mjs +55 -20
- package/package.json +1 -1
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
|
|
@@ -543,8 +574,11 @@ export function createWorkManager({
|
|
|
543
574
|
// path), but a server bug or compromise sending `../../etc` would
|
|
544
575
|
// otherwise point a session's measured directory anywhere on the box and
|
|
545
576
|
// defeat the port attribution. `sessionWorktreeReport` and `placeWtFor`
|
|
546
|
-
// already reject an unsafe segment;
|
|
547
|
-
// (the preview-claim `placeDir` did not re-check).
|
|
577
|
+
// already reject an unsafe segment; checking at the intake covers the
|
|
578
|
+
// consumers that do not (the preview-claim `placeDir` did not re-check).
|
|
579
|
+
// NOT the only writer: `processWorkTurns` stores a turn job's place too,
|
|
580
|
+
// and keeps the same check — validating one intake and not the other is
|
|
581
|
+
// one deploy away from validating neither. REPO_PLACE resolves to
|
|
548
582
|
// repoRoot, so it is allowed through despite not being a path segment.
|
|
549
583
|
if (typeof place === 'string' && place && (place === REPO_PLACE || isSafePathSegment(place)))
|
|
550
584
|
sessionPlaces.set(sid, place);
|
|
@@ -1309,6 +1343,23 @@ export function createWorkManager({
|
|
|
1309
1343
|
const pid = Number(job.pid);
|
|
1310
1344
|
const signal = job.signal === 'KILL' ? 'SIGKILL' : 'SIGTERM';
|
|
1311
1345
|
|
|
1346
|
+
/**
|
|
1347
|
+
* CLAIM FIRST, EVEN FOR THE ANSWERS THAT SIGNAL NOTHING.
|
|
1348
|
+
*
|
|
1349
|
+
* `unsupported` and `not_found` cost nothing to produce, which is exactly
|
|
1350
|
+
* why they must be leased: two daemons on one credential are handed the
|
|
1351
|
+
* same job, and the one holding NOTHING reaches these branches without
|
|
1352
|
+
* measuring anything or making a round trip — so it would answer first,
|
|
1353
|
+
* settle the row, and the machine that could actually have signalled would
|
|
1354
|
+
* find the job already closed and never touch the process. The person reads
|
|
1355
|
+
* "the pid is no longer one of this tab's", which is a real sentence, over a
|
|
1356
|
+
* watcher that is still running.
|
|
1357
|
+
*
|
|
1358
|
+
* The server enforces holder-only settles again (it briefly accepted these
|
|
1359
|
+
* two unclaimed, which is the bug above), so an unclaimed post here would
|
|
1360
|
+
* simply be dropped. One extra round trip on a path nobody is waiting on.
|
|
1361
|
+
*/
|
|
1362
|
+
if (!(await claimKill(id))) return;
|
|
1312
1363
|
if (!processesSupported()) {
|
|
1313
1364
|
await postKill({ id, outcome: 'unsupported' });
|
|
1314
1365
|
return;
|
|
@@ -1324,7 +1375,6 @@ export function createWorkManager({
|
|
|
1324
1375
|
await remeasureAfterKill(sessionId);
|
|
1325
1376
|
return;
|
|
1326
1377
|
}
|
|
1327
|
-
if (!(await claimKill(id))) return;
|
|
1328
1378
|
try {
|
|
1329
1379
|
process.kill(pid, signal);
|
|
1330
1380
|
// WHAT HAPPENED, not what we did. "We sent a signal" is a fact about us;
|
|
@@ -1439,7 +1489,18 @@ export function createWorkManager({
|
|
|
1439
1489
|
// outcome because "the machine cannot do this at all" and "GitHub said
|
|
1440
1490
|
// no" read differently to the person who asked.
|
|
1441
1491
|
try {
|
|
1442
|
-
|
|
1492
|
+
// TIMED OUT, like every other `gh` call. `execFileSync` blocks the whole
|
|
1493
|
+
// event loop, so a hung `gh` — an expired token whose refresh hits a
|
|
1494
|
+
// black hole, a credential helper waiting on a keyring prompt that has
|
|
1495
|
+
// no terminal — stops the roster poll, every in-flight settle, the
|
|
1496
|
+
// worktree sweep and the deploy heartbeat (whose 3-minute staleness
|
|
1497
|
+
// window then re-queues a deploy this daemon is still running). The
|
|
1498
|
+
// AGENT merge path was given exactly these timeouts in 0.77.1; this
|
|
1499
|
+
// copy, forty lines of the same logic, was missed.
|
|
1500
|
+
execFileSync('gh', ['auth', 'status'], {
|
|
1501
|
+
stdio: ['ignore', 'pipe', 'pipe'],
|
|
1502
|
+
timeout: 20_000,
|
|
1503
|
+
});
|
|
1443
1504
|
} catch (e) {
|
|
1444
1505
|
const missing = e?.code === 'ENOENT';
|
|
1445
1506
|
await settlePr({
|
|
@@ -1499,6 +1560,7 @@ export function createWorkManager({
|
|
|
1499
1560
|
execFileSync('gh', ['pr', 'view', branch, '--json', 'url,state'], {
|
|
1500
1561
|
cwd: repoRoot,
|
|
1501
1562
|
stdio: ['ignore', 'pipe', 'pipe'],
|
|
1563
|
+
timeout: 30_000,
|
|
1502
1564
|
}).toString()
|
|
1503
1565
|
);
|
|
1504
1566
|
return j?.state === 'OPEN' && typeof j?.url === 'string' ? j.url.trim() : null;
|
|
@@ -1524,7 +1586,7 @@ export function createWorkManager({
|
|
|
1524
1586
|
// call, nothing invented. baseBranchName, not baseRef: gh 422s on
|
|
1525
1587
|
// a remote-tracking name like origin/main.
|
|
1526
1588
|
['pr', 'create', '--head', branch, '--base', baseName, '--fill'],
|
|
1527
|
-
{ cwd: repoRoot, stdio: ['ignore', 'pipe', 'pipe'] }
|
|
1589
|
+
{ cwd: repoRoot, stdio: ['ignore', 'pipe', 'pipe'], timeout: 60_000 }
|
|
1528
1590
|
)
|
|
1529
1591
|
.toString()
|
|
1530
1592
|
.trim();
|
|
@@ -1559,6 +1621,7 @@ export function createWorkManager({
|
|
|
1559
1621
|
execFileSync('gh', ['pr', 'merge', branch, '--merge'], {
|
|
1560
1622
|
cwd: repoRoot,
|
|
1561
1623
|
stdio: ['ignore', 'pipe', 'pipe'],
|
|
1624
|
+
timeout: 120_000,
|
|
1562
1625
|
});
|
|
1563
1626
|
} catch (e) {
|
|
1564
1627
|
const line = ghFirstLine(e);
|
|
@@ -2247,15 +2310,41 @@ export function createWorkManager({
|
|
|
2247
2310
|
* the pid lives and clears the lock once it is dead.
|
|
2248
2311
|
*/
|
|
2249
2312
|
const workChildren = new Map(); // child process -> lockPath | null
|
|
2313
|
+
/**
|
|
2314
|
+
* Children whose whole PROCESS GROUP must go, not just the child.
|
|
2315
|
+
*
|
|
2316
|
+
* The standing rule is the opposite — teardown SIGTERMs the CLI child and
|
|
2317
|
+
* never its group, precisely so an unattended auto-update does not kill the
|
|
2318
|
+
* driver's dev server, which a turn started INTO the CLI's group. That rule
|
|
2319
|
+
* is about the CLI's group and stays.
|
|
2320
|
+
*
|
|
2321
|
+
* A project CHECK is a different group entirely: the daemon spawns it itself,
|
|
2322
|
+
* `detached` with `shell: true`, so its group holds the check command and
|
|
2323
|
+
* nothing else — no dev server of anybody's. And `shell: true` is exactly
|
|
2324
|
+
* what makes signalling only the child useless: a compound command like
|
|
2325
|
+
* `npm run lint && npm test` leaves `/bin/sh` as the child, so SIGTERM killed
|
|
2326
|
+
* the shell and the test runner underneath it carried on holding the
|
|
2327
|
+
* worktree — and that place's WRITER lock — through every stop, takeover and
|
|
2328
|
+
* auto-update. The check's own ten-minute timer already kills `-child.pid`
|
|
2329
|
+
* for this reason; teardown simply did not.
|
|
2330
|
+
*/
|
|
2331
|
+
const groupKillChildren = new Set();
|
|
2250
2332
|
const shutdownWork = () => {
|
|
2251
2333
|
for (const [ch] of workChildren) {
|
|
2252
2334
|
try {
|
|
2253
|
-
ch.kill('SIGTERM');
|
|
2335
|
+
if (groupKillChildren.has(ch) && ch.pid) process.kill(-ch.pid, 'SIGTERM');
|
|
2336
|
+
else ch.kill('SIGTERM');
|
|
2254
2337
|
} catch {
|
|
2255
|
-
|
|
2338
|
+
// A group that has already gone, or a pid that is no longer a leader.
|
|
2339
|
+
try {
|
|
2340
|
+
ch.kill('SIGTERM');
|
|
2341
|
+
} catch {
|
|
2342
|
+
/* best-effort */
|
|
2343
|
+
}
|
|
2256
2344
|
}
|
|
2257
2345
|
}
|
|
2258
2346
|
workChildren.clear();
|
|
2347
|
+
groupKillChildren.clear();
|
|
2259
2348
|
};
|
|
2260
2349
|
|
|
2261
2350
|
/**
|
|
@@ -2394,9 +2483,26 @@ export function createWorkManager({
|
|
|
2394
2483
|
// run it again while the report is merely undelivered.
|
|
2395
2484
|
if (pendingWorkReports.has(job.id)) continue;
|
|
2396
2485
|
workAnswering.add(job.id);
|
|
2397
|
-
// Serialized by PLACE: two tabs sharing a worktree take turns in it
|
|
2398
|
-
// rather than editing the same files at the same time.
|
|
2399
2486
|
const place = job.place || job.sessionId;
|
|
2487
|
+
/**
|
|
2488
|
+
* VALIDATED AT THE TRUST BOUNDARY, exactly as `learnPlaces` validates
|
|
2489
|
+
* the roster's map — this is the OTHER writer of `sessionPlaces`, in the
|
|
2490
|
+
* same reconcile tick, BEFORE the preview jobs and the sweep read it. An
|
|
2491
|
+
* unchecked value stored here reaches every `placeDir` consumer: the
|
|
2492
|
+
* turn is spawned in it, `burstListeners` measures it, and a share would
|
|
2493
|
+
* publicly tunnel a directory OUTSIDE the checkout — the exact traversal
|
|
2494
|
+
* the preview feature's port attribution exists to prevent. Settled out
|
|
2495
|
+
* loud rather than skipped, because a silently dropped turn strands the
|
|
2496
|
+
* tab for the server's whole expiry window.
|
|
2497
|
+
*/
|
|
2498
|
+
if (place !== REPO_PLACE && !isSafePathSegment(place)) {
|
|
2499
|
+
void settleWorkTurn(job.id, {
|
|
2500
|
+
ok: false,
|
|
2501
|
+
answer:
|
|
2502
|
+
'the server named a working directory this machine refuses to use — close and reopen the tab, then send the message again',
|
|
2503
|
+
}).finally(() => workAnswering.delete(job.id));
|
|
2504
|
+
continue;
|
|
2505
|
+
}
|
|
2400
2506
|
// Remembered for every other beat — the sweep, ship, the preview
|
|
2401
2507
|
// re-check — so they all ask the same directory this turn runs in.
|
|
2402
2508
|
sessionPlaces.set(job.sessionId, place);
|
|
@@ -2816,6 +2922,7 @@ export function createWorkManager({
|
|
|
2816
2922
|
toolLog.ev.length > 0 ? toolLog : undefined
|
|
2817
2923
|
);
|
|
2818
2924
|
|
|
2925
|
+
|
|
2819
2926
|
// THE COMMAND AUDIT — every `$ …` the CLI's stream reports, batched
|
|
2820
2927
|
// to the server verbatim so an admin can read what actually ran on
|
|
2821
2928
|
// this box. Same events the narrator renders and forgets; this is
|
|
@@ -2973,8 +3080,28 @@ export function createWorkManager({
|
|
|
2973
3080
|
// loud): a fresh conversation would silently discard the adoption
|
|
2974
3081
|
// and answer as a new session wearing its name — the empty adopt
|
|
2975
3082
|
// turn settles failed below instead.
|
|
2976
|
-
|
|
3083
|
+
/**
|
|
3084
|
+
* …OR PRODUCED ONLY THE CLI SAYING THE CONVERSATION IS GONE.
|
|
3085
|
+
*
|
|
3086
|
+
* "Produced nothing" was the whole test, and it is not the shape
|
|
3087
|
+
* these failures take: Claude Code answers a dead `--resume` id
|
|
3088
|
+
* with a result event carrying `errors: ['No conversation found
|
|
3089
|
+
* with session ID: …']` and writes the same line to stderr, so
|
|
3090
|
+
* `out` is non-empty. The backstop never fired, the turn settled
|
|
3091
|
+
* SUCCESSFULLY with that error as its answer, and — because the
|
|
3092
|
+
* marker is only rewritten when an init event is seen, and there
|
|
3093
|
+
* was none — the dead id stayed pinned. Every later message in
|
|
3094
|
+
* that tab replied identically, with nothing on any surface able
|
|
3095
|
+
* to clear it. A tab bricked forever by its own CLI pruning its
|
|
3096
|
+
* history, which it does on its own schedule.
|
|
3097
|
+
*/
|
|
3098
|
+
if (
|
|
3099
|
+
!adopting &&
|
|
3100
|
+
resume &&
|
|
3101
|
+
(!(out || '').trim() || resumeConversationLost(out))
|
|
3102
|
+
) {
|
|
2977
3103
|
out = await runTurn({ ...turnArgs, resume: false });
|
|
3104
|
+
}
|
|
2978
3105
|
} finally {
|
|
2979
3106
|
// The CLI has stopped printing, so stop relaying. The LINE itself
|
|
2980
3107
|
// is cleared server-side at settle — clearing it here would race
|
|
@@ -3239,7 +3366,7 @@ export function createWorkManager({
|
|
|
3239
3366
|
};
|
|
3240
3367
|
// The machine may have no git identity, and a merge COMMIT needs
|
|
3241
3368
|
// one. Prefer the user's own config; fall back to the daemon's (the
|
|
3242
|
-
// same fallback
|
|
3369
|
+
// same fallback ship's merge keeps) so a bare machine doesn't fail
|
|
3243
3370
|
// the fold with "Please tell me who you are".
|
|
3244
3371
|
let idEnv = null;
|
|
3245
3372
|
try {
|
|
@@ -3551,7 +3678,28 @@ export function createWorkManager({
|
|
|
3551
3678
|
['diff', '--name-only', `${baseRef()}...HEAD`],
|
|
3552
3679
|
['ls-files', '--others', '--exclude-standard'],
|
|
3553
3680
|
]) {
|
|
3554
|
-
|
|
3681
|
+
/**
|
|
3682
|
+
* `git()` THROWS; it does not return a non-string.
|
|
3683
|
+
*
|
|
3684
|
+
* The guard below was `if (typeof r !== 'string') continue`, which is a
|
|
3685
|
+
* value `execFileSync` can never produce — so every one of these calls
|
|
3686
|
+
* was effectively unguarded. And this runs over the LIVE AGENTS a plan
|
|
3687
|
+
* job carries, which include agents still in `planning`: they have no
|
|
3688
|
+
* worktree yet, so `git diff` in a directory that does not exist exits
|
|
3689
|
+
* non-zero and throws straight out of `runAgentPlan` — after the press
|
|
3690
|
+
* has been claimed and before the try/finally below. The press then sat
|
|
3691
|
+
* `planning` until its expiry, having spent nothing and explained
|
|
3692
|
+
* nothing, on what would be an ordinary second Deploy.
|
|
3693
|
+
*
|
|
3694
|
+
* An agent whose files cannot be read contributes none. That is the
|
|
3695
|
+
* honest answer and it is what the planner should be told.
|
|
3696
|
+
*/
|
|
3697
|
+
let r;
|
|
3698
|
+
try {
|
|
3699
|
+
r = git(args, wt);
|
|
3700
|
+
} catch {
|
|
3701
|
+
continue;
|
|
3702
|
+
}
|
|
3555
3703
|
if (typeof r !== 'string') continue;
|
|
3556
3704
|
for (const line of r.split('\n')) {
|
|
3557
3705
|
const f = line.trim();
|
|
@@ -3599,7 +3747,15 @@ export function createWorkManager({
|
|
|
3599
3747
|
id: String(a?.id ?? ''),
|
|
3600
3748
|
name: String(a?.name ?? ''),
|
|
3601
3749
|
status: String(a?.status ?? ''),
|
|
3602
|
-
|
|
3750
|
+
// Belt: `agentChangedFiles` is defensive internally, but it resolves a
|
|
3751
|
+
// place first and this whole block sits outside the try below.
|
|
3752
|
+
changedFiles: (() => {
|
|
3753
|
+
try {
|
|
3754
|
+
return agentChangedFiles(a?.placeId);
|
|
3755
|
+
} catch {
|
|
3756
|
+
return [];
|
|
3757
|
+
}
|
|
3758
|
+
})(),
|
|
3603
3759
|
}));
|
|
3604
3760
|
|
|
3605
3761
|
let out = '';
|
|
@@ -3680,8 +3836,13 @@ export function createWorkManager({
|
|
|
3680
3836
|
// UNLEASED, unlike a plan or a kill. The server hands out at most one turn
|
|
3681
3837
|
// per agent per poll and its settle is conditional on the row still being
|
|
3682
3838
|
// pending, so a second daemon cannot advance the queue twice. What it could
|
|
3683
|
-
// do is run a CLI twice in one worktree
|
|
3684
|
-
//
|
|
3839
|
+
// do is run a CLI twice in one worktree. The in-flight set below cannot
|
|
3840
|
+
// prevent that alone: it is keyed by TURN id, so it never sees the
|
|
3841
|
+
// DIFFERENT turn the server's TTL-skip hands out while an expired turn's
|
|
3842
|
+
// CLI is still running — and a reader lock would run the two side by side.
|
|
3843
|
+
// So an agent turn takes its place's lock as a WRITER: an agent is ONE
|
|
3844
|
+
// process by definition, and its `a-<id>` place is its own — no tab ever
|
|
3845
|
+
// stands there, so the tabs' turns-run-concurrently law is untouched.
|
|
3685
3846
|
const agentTurns = new Set(); // turn ids in flight on this tick
|
|
3686
3847
|
/**
|
|
3687
3848
|
* The CLI a live agent turn is running in, by PLACE.
|
|
@@ -3698,14 +3859,29 @@ export function createWorkManager({
|
|
|
3698
3859
|
* emptied, so run the project's own check in the worktree we are already
|
|
3699
3860
|
* standing in. A job lane for that would need a claim, a floor and a settle
|
|
3700
3861
|
* to say something this reply already can. */
|
|
3701
|
-
/**
|
|
3702
|
-
*
|
|
3703
|
-
*
|
|
3704
|
-
*
|
|
3705
|
-
|
|
3862
|
+
/**
|
|
3863
|
+
* Turns whose work is DONE but whose settle has not landed, keyed to the
|
|
3864
|
+
* finished BODY. The delivery half of what `pendingWorkReports` is for a
|
|
3865
|
+
* tab: a settle that fails to POST must not re-run the turn — that is a
|
|
3866
|
+
* second CLI, a second set of commits, and the operator's quota spent again
|
|
3867
|
+
* — but a guard that only SKIPPED left the other half undone. One failed
|
|
3868
|
+
* POST parked the agent for the server's whole six-hour expiry, holding a
|
|
3869
|
+
* cap slot the entire time, and then expired into "nobody ran it" — a false
|
|
3870
|
+
* sentence about a turn this machine finished. The server's settle is
|
|
3871
|
+
* idempotent (conditional on the row still being pending), so a re-offer of
|
|
3872
|
+
* a held turn re-POSTs the stored body instead: safe, and it lands the
|
|
3873
|
+
* moment the network heals rather than six hours later.
|
|
3874
|
+
*
|
|
3875
|
+
* BOUNDED by the roster itself: a held body's clock is refreshed while the
|
|
3876
|
+
* server keeps offering its turn, and once offering stops — settled by the
|
|
3877
|
+
* re-POST, or expired server-side — the grace below is all that keeps it.
|
|
3878
|
+
*/
|
|
3879
|
+
const agentReported = new Map(); // turnId -> { body, at }
|
|
3880
|
+
const AGENT_REPORT_GRACE_MS = 30 * 60_000;
|
|
3706
3881
|
|
|
3707
3882
|
const postAgentTurn = async (body) => {
|
|
3708
|
-
|
|
3883
|
+
const turnId = String(body.turnId);
|
|
3884
|
+
agentReported.set(turnId, { body, at: Date.now() });
|
|
3709
3885
|
try {
|
|
3710
3886
|
const res = await fetch(AGENT_TURN_DONE_URL, {
|
|
3711
3887
|
method: 'POST',
|
|
@@ -3718,14 +3894,19 @@ export function createWorkManager({
|
|
|
3718
3894
|
body: JSON.stringify(body),
|
|
3719
3895
|
});
|
|
3720
3896
|
const j = await res.json().catch(() => null);
|
|
3721
|
-
// Landed
|
|
3722
|
-
//
|
|
3723
|
-
|
|
3897
|
+
// Landed, or REFUSED: a 4xx is the server saying this settle will never
|
|
3898
|
+
// be accepted (expired, already settled, unknown turn), and re-POSTing a
|
|
3899
|
+
// refusal forever is the wedge wearing a retry's clothes. 408/429 stay
|
|
3900
|
+
// retryable, the same split `postSettle` makes for a tab.
|
|
3901
|
+
if (
|
|
3902
|
+
res.ok ||
|
|
3903
|
+
(res.status >= 400 && res.status < 500 && res.status !== 408 && res.status !== 429)
|
|
3904
|
+
)
|
|
3905
|
+
agentReported.delete(turnId);
|
|
3724
3906
|
return j?.data ?? null;
|
|
3725
3907
|
} catch {
|
|
3726
|
-
// Unsettled
|
|
3727
|
-
//
|
|
3728
|
-
// than running it again for six hours.
|
|
3908
|
+
// Unsettled — a network error, so the body STAYS held and the next
|
|
3909
|
+
// re-offer retries the POST rather than the CLI.
|
|
3729
3910
|
return null;
|
|
3730
3911
|
}
|
|
3731
3912
|
};
|
|
@@ -3846,7 +4027,10 @@ export function createWorkManager({
|
|
|
3846
4027
|
return;
|
|
3847
4028
|
}
|
|
3848
4029
|
|
|
3849
|
-
|
|
4030
|
+
// A WRITER on the agent's own place — see the lane header. Only `a-<id>`
|
|
4031
|
+
// places: those are agents' by construction, and anything else here would
|
|
4032
|
+
// be a tab's directory, where a turn is a reader by the product's own law.
|
|
4033
|
+
await inPlace(place, place.startsWith('a-'), async () => {
|
|
3850
4034
|
const dir = placeWtFor(place);
|
|
3851
4035
|
if (!dir) {
|
|
3852
4036
|
// No worktree and none could be cut. `nothing` rather than an invented
|
|
@@ -4041,13 +4225,46 @@ export function createWorkManager({
|
|
|
4041
4225
|
};
|
|
4042
4226
|
|
|
4043
4227
|
const processAgentTurnJobs = (jobs) => {
|
|
4044
|
-
|
|
4045
|
-
|
|
4228
|
+
const list = Array.isArray(jobs) ? jobs : [];
|
|
4229
|
+
if (agentReported.size) {
|
|
4230
|
+
// The roster is the held bodies' clock: an offered turn is still pending
|
|
4231
|
+
// server-side and worth retrying; one the roster stopped naming was
|
|
4232
|
+
// settled or expired, and holding its body past a generous grace would
|
|
4233
|
+
// grow this map for the life of the process. The server's own expiry is
|
|
4234
|
+
// the true bound — the grace only covers its POST racing a final offer.
|
|
4235
|
+
const offered = new Set(list.map((j) => String(j?.id || '')));
|
|
4236
|
+
const now = Date.now();
|
|
4237
|
+
for (const [id, held] of agentReported) {
|
|
4238
|
+
if (offered.has(id)) held.at = now;
|
|
4239
|
+
else if (now - held.at > AGENT_REPORT_GRACE_MS) agentReported.delete(id);
|
|
4240
|
+
}
|
|
4241
|
+
}
|
|
4242
|
+
for (const job of list.slice(0, 4)) {
|
|
4046
4243
|
const id = String(job?.id || '');
|
|
4047
4244
|
if (!id || agentTurns.has(id)) continue;
|
|
4048
|
-
|
|
4049
|
-
|
|
4050
|
-
|
|
4245
|
+
const held = agentReported.get(id);
|
|
4246
|
+
if (held) {
|
|
4247
|
+
// Already RAN here; only the settle is outstanding. Re-POST the held
|
|
4248
|
+
// body — never the CLI, which would spend the operator's quota again
|
|
4249
|
+
// and write a second set of commits. The reply can still carry the one
|
|
4250
|
+
// instruction a settle can (`review: true`, the queue just emptied),
|
|
4251
|
+
// so the project's check runs from here too, under the same writer
|
|
4252
|
+
// lock the turn itself would have held.
|
|
4253
|
+
agentTurns.add(id);
|
|
4254
|
+
void (async () => {
|
|
4255
|
+
const reply = await postAgentTurn(held.body);
|
|
4256
|
+
const place = String(job.placeId || '');
|
|
4257
|
+
const wt = typeof held.body.worktree === 'string' ? held.body.worktree : null;
|
|
4258
|
+
if (reply?.review === true && isSafePathSegment(place) && wt && existsSync(wt)) {
|
|
4259
|
+
await inPlace(place, place.startsWith('a-'), () =>
|
|
4260
|
+
runCheck(String(job.agentId || ''), wt)
|
|
4261
|
+
);
|
|
4262
|
+
}
|
|
4263
|
+
})()
|
|
4264
|
+
.catch(() => {})
|
|
4265
|
+
.finally(() => agentTurns.delete(id));
|
|
4266
|
+
continue;
|
|
4267
|
+
}
|
|
4051
4268
|
if (!job.agentId || !job.placeId) continue;
|
|
4052
4269
|
agentTurns.add(id);
|
|
4053
4270
|
void runAgentTurn(job).finally(() => agentTurns.delete(id));
|
|
@@ -4139,8 +4356,15 @@ export function createWorkManager({
|
|
|
4139
4356
|
* orphaned a running test suite inside a worktree the sweep may then
|
|
4140
4357
|
* try to remove. The ten-minute timer would eventually kill it, but by
|
|
4141
4358
|
* then it belongs to no daemon and nothing on any surface names it.
|
|
4359
|
+
*
|
|
4360
|
+
* Registered for a GROUP kill: with `shell: true` the child is
|
|
4361
|
+
* `/bin/sh`, and signalling it leaves the runner it started behind —
|
|
4362
|
+
* which is the process actually holding the worktree. See
|
|
4363
|
+
* `groupKillChildren` for why this one is exempt from the
|
|
4364
|
+
* never-signal-the-group rule.
|
|
4142
4365
|
*/
|
|
4143
4366
|
workChildren.set(child, null);
|
|
4367
|
+
groupKillChildren.add(child);
|
|
4144
4368
|
} catch (e) {
|
|
4145
4369
|
// TEXT BEFORE FINISH: `finish` captures `text` by value into the
|
|
4146
4370
|
// resolved object, so assigning afterwards threw the spawn error away
|
|
@@ -4268,8 +4492,8 @@ export function createWorkManager({
|
|
|
4268
4492
|
/**
|
|
4269
4493
|
* A merge COMMIT needs a git identity and the machine may have none. Prefer
|
|
4270
4494
|
* the operator's own config; fall back to the daemon's, the same fallback
|
|
4271
|
-
*
|
|
4272
|
-
*
|
|
4495
|
+
* ship's merge keeps, so a bare machine does not fail the fold with
|
|
4496
|
+
* "Please tell me who you are".
|
|
4273
4497
|
*/
|
|
4274
4498
|
const gitMerge = (args, cwd) => {
|
|
4275
4499
|
let idEnv = null;
|
|
@@ -4295,18 +4519,37 @@ export function createWorkManager({
|
|
|
4295
4519
|
const agentId = String(job.agentId);
|
|
4296
4520
|
const place = String(job.placeId || '');
|
|
4297
4521
|
if (!isSafePathSegment(place)) {
|
|
4522
|
+
// Before the claim, and before `report` exists — this one is not covered
|
|
4523
|
+
// by the belt below because there is nothing yet to be un-reported.
|
|
4298
4524
|
await postAgentMerge({ agentId, ok: false, detail: 'the agent has no worktree here' });
|
|
4299
4525
|
return;
|
|
4300
4526
|
}
|
|
4301
4527
|
if (!(await claimAgentMerge(agentId))) return;
|
|
4302
4528
|
|
|
4529
|
+
/**
|
|
4530
|
+
* NO EXIT PATH MAY LEAVE A CLAIMED MERGE UNREPORTED — the same belt the
|
|
4531
|
+
* ship path carries, and this function did not.
|
|
4532
|
+
*
|
|
4533
|
+
* Every branch below reports, but an UNEXPECTED throw reports nothing:
|
|
4534
|
+
* several `git()` calls in here are bare, and `git()` is `execFileSync`,
|
|
4535
|
+
* which throws on any non-zero exit. The agent then sits in `merging`,
|
|
4536
|
+
* which refuses Stop and refuses an answer, until an expiry fires — with
|
|
4537
|
+
* nothing anywhere saying what went wrong.
|
|
4538
|
+
*/
|
|
4539
|
+
let reported = false;
|
|
4540
|
+
const report = async (body) => {
|
|
4541
|
+
reported = true;
|
|
4542
|
+
await postAgentMerge(body);
|
|
4543
|
+
};
|
|
4544
|
+
|
|
4303
4545
|
// A WRITER on the place, exactly as a ship is. It folds base in and pushes
|
|
4304
4546
|
// with git in that directory, and no CLI can coordinate with something it
|
|
4305
4547
|
// does not know exists.
|
|
4306
|
-
|
|
4548
|
+
try {
|
|
4549
|
+
await inPlace(place, true, async () => {
|
|
4307
4550
|
const wt = join(baseDir, 'sessions', place);
|
|
4308
4551
|
if (!existsSync(wt)) {
|
|
4309
|
-
await
|
|
4552
|
+
await report({ agentId, ok: false, detail: 'the worktree is gone' });
|
|
4310
4553
|
return;
|
|
4311
4554
|
}
|
|
4312
4555
|
try {
|
|
@@ -4323,7 +4566,7 @@ export function createWorkManager({
|
|
|
4323
4566
|
try {
|
|
4324
4567
|
gitMerge(['merge', '--no-edit', baseRef()], wt);
|
|
4325
4568
|
} catch (e) {
|
|
4326
|
-
await
|
|
4569
|
+
await report({
|
|
4327
4570
|
agentId,
|
|
4328
4571
|
ok: false,
|
|
4329
4572
|
detail: envScrub(String(e?.message || e)).slice(0, 2000),
|
|
@@ -4346,7 +4589,7 @@ export function createWorkManager({
|
|
|
4346
4589
|
if (!branch) {
|
|
4347
4590
|
// A detached HEAD names no branch, so there is nothing to merge and
|
|
4348
4591
|
// nothing to record. An ambiguity in git, not a rule of ours.
|
|
4349
|
-
await
|
|
4592
|
+
await report({ agentId, ok: false, detail: 'this worktree is on a detached HEAD' });
|
|
4350
4593
|
return;
|
|
4351
4594
|
}
|
|
4352
4595
|
const tip = (git(['rev-parse', 'HEAD'], wt) || '').trim();
|
|
@@ -4356,7 +4599,7 @@ export function createWorkManager({
|
|
|
4356
4599
|
// Already on base — an idempotent re-offer, or an agent that changed
|
|
4357
4600
|
// nothing. Reported as a SUCCESS: the branch's work is on base, which
|
|
4358
4601
|
// is what the caller is asking about.
|
|
4359
|
-
await
|
|
4602
|
+
await report({ agentId, ok: true, sha: tip || undefined });
|
|
4360
4603
|
return;
|
|
4361
4604
|
}
|
|
4362
4605
|
/**
|
|
@@ -4390,7 +4633,7 @@ export function createWorkManager({
|
|
|
4390
4633
|
timeout: 20_000,
|
|
4391
4634
|
});
|
|
4392
4635
|
} catch (e) {
|
|
4393
|
-
await
|
|
4636
|
+
await report({
|
|
4394
4637
|
agentId,
|
|
4395
4638
|
ok: false,
|
|
4396
4639
|
detail:
|
|
@@ -4402,7 +4645,7 @@ export function createWorkManager({
|
|
|
4402
4645
|
}
|
|
4403
4646
|
const prBase = baseBranchName(baseRef());
|
|
4404
4647
|
if (branch === prBase) {
|
|
4405
|
-
await
|
|
4648
|
+
await report({
|
|
4406
4649
|
agentId,
|
|
4407
4650
|
ok: false,
|
|
4408
4651
|
detail: `this agent is on the base branch (${prBase}) — there is nothing to open a pull request from`,
|
|
@@ -4417,19 +4660,34 @@ export function createWorkManager({
|
|
|
4417
4660
|
// token in its userinfo — so this one line is the only place on the
|
|
4418
4661
|
// agent merge path that can leak a credential into a stored,
|
|
4419
4662
|
// team-visible `mergeError`.
|
|
4420
|
-
await
|
|
4663
|
+
await report({ agentId, ok: false, detail: envScrub(ghFirstLine(e)) });
|
|
4421
4664
|
return;
|
|
4422
4665
|
}
|
|
4423
4666
|
let prUrl = null;
|
|
4424
4667
|
try {
|
|
4425
4668
|
const j = JSON.parse(
|
|
4426
|
-
execFileSync('gh', ['pr', 'view', branch, '--json', 'url,state'], {
|
|
4669
|
+
execFileSync('gh', ['pr', 'view', branch, '--json', 'url,state,baseRefName'], {
|
|
4427
4670
|
cwd: repoRoot,
|
|
4428
4671
|
stdio: ['ignore', 'pipe', 'pipe'],
|
|
4429
4672
|
timeout: 30_000,
|
|
4430
4673
|
}).toString()
|
|
4431
4674
|
);
|
|
4432
|
-
if (j?.state === 'OPEN' && typeof j?.url === 'string')
|
|
4675
|
+
if (j?.state === 'OPEN' && typeof j?.url === 'string') {
|
|
4676
|
+
// Adoptable only when it points at the project's own base. A PR
|
|
4677
|
+
// somebody opened by hand against another branch would otherwise
|
|
4678
|
+
// be merged INTO that branch, and the ok settle would claim work
|
|
4679
|
+
// reached base that landed somewhere else entirely. Refused only
|
|
4680
|
+
// on a MEASURED mismatch — an absent field adopts as before.
|
|
4681
|
+
if (typeof j?.baseRefName === 'string' && j.baseRefName !== prBase) {
|
|
4682
|
+
await report({
|
|
4683
|
+
agentId,
|
|
4684
|
+
ok: false,
|
|
4685
|
+
detail: `the open pull request for ${branch} targets ${j.baseRefName}, not ${prBase} — retarget or close it, then approve again`,
|
|
4686
|
+
});
|
|
4687
|
+
return;
|
|
4688
|
+
}
|
|
4689
|
+
prUrl = j.url.trim();
|
|
4690
|
+
}
|
|
4433
4691
|
} catch {
|
|
4434
4692
|
/* no PR for this branch at all — created below */
|
|
4435
4693
|
}
|
|
@@ -4445,7 +4703,7 @@ export function createWorkManager({
|
|
|
4445
4703
|
.trim();
|
|
4446
4704
|
prUrl = out.split('\n').filter(Boolean).pop() ?? null;
|
|
4447
4705
|
} catch (e) {
|
|
4448
|
-
await
|
|
4706
|
+
await report({ agentId, ok: false, detail: ghFirstLine(e) });
|
|
4449
4707
|
return;
|
|
4450
4708
|
}
|
|
4451
4709
|
}
|
|
@@ -4460,7 +4718,7 @@ export function createWorkManager({
|
|
|
4460
4718
|
// Already merged is a SUCCESS: a re-offered job, or somebody merged
|
|
4461
4719
|
// it in the browser. The observer closes the cards either way.
|
|
4462
4720
|
if (!/already merged/i.test(line)) {
|
|
4463
|
-
await
|
|
4721
|
+
await report({
|
|
4464
4722
|
agentId,
|
|
4465
4723
|
ok: false,
|
|
4466
4724
|
detail:
|
|
@@ -4469,11 +4727,46 @@ export function createWorkManager({
|
|
|
4469
4727
|
return;
|
|
4470
4728
|
}
|
|
4471
4729
|
}
|
|
4472
|
-
//
|
|
4473
|
-
//
|
|
4474
|
-
//
|
|
4475
|
-
//
|
|
4476
|
-
|
|
4730
|
+
// VERIFY before reporting ok: modern gh exits 0 on an already-MERGED
|
|
4731
|
+
// PR, and on a repo with a merge queue or auto-merge it exits 0 after
|
|
4732
|
+
// ENQUEUEING — in both, "merged" is a claim about the future. The tip
|
|
4733
|
+
// being an ancestor of base is the fact `ok` asserts, and the server
|
|
4734
|
+
// closes every delivered card on this sha the moment it hears it — so
|
|
4735
|
+
// measure it, with one short retry for the fetch racing GitHub's
|
|
4736
|
+
// merge commit. The same guard the session PR path carries.
|
|
4737
|
+
const tipOnBase = () => {
|
|
4738
|
+
try {
|
|
4739
|
+
git(['merge-base', '--is-ancestor', tip, baseRef()], repoRoot);
|
|
4740
|
+
return true;
|
|
4741
|
+
} catch {
|
|
4742
|
+
return false;
|
|
4743
|
+
}
|
|
4744
|
+
};
|
|
4745
|
+
let landedOnBase = false;
|
|
4746
|
+
for (let attempt = 0; attempt < 2 && !landedOnBase; attempt++) {
|
|
4747
|
+
if (attempt > 0) await new Promise((r) => setTimeout(r, 2000));
|
|
4748
|
+
try {
|
|
4749
|
+
git(['fetch', 'origin', '--quiet'], repoRoot);
|
|
4750
|
+
} catch {
|
|
4751
|
+
/* offline — the check below answers from what we have */
|
|
4752
|
+
}
|
|
4753
|
+
landedOnBase = tipOnBase();
|
|
4754
|
+
}
|
|
4755
|
+
if (!landedOnBase) {
|
|
4756
|
+
await report({
|
|
4757
|
+
agentId,
|
|
4758
|
+
ok: false,
|
|
4759
|
+
detail:
|
|
4760
|
+
"GitHub accepted the merge, but this branch's tip is not on the base branch — a merge queue may still be running it, or the PR that merged was an older one. Approve again once it lands." +
|
|
4761
|
+
(prUrl && PR_URL_RE.test(prUrl) ? ` The pull request is at ${prUrl}.` : ''),
|
|
4762
|
+
});
|
|
4763
|
+
return;
|
|
4764
|
+
}
|
|
4765
|
+
// THE TIP, not the merge commit GitHub made: the tip identifies the
|
|
4766
|
+
// state we asked to be merged, which is what the receipt is for — the
|
|
4767
|
+
// cards themselves close when the landed observer sees the commits
|
|
4768
|
+
// arrive on base.
|
|
4769
|
+
await report({ agentId, ok: true, sha: tip });
|
|
4477
4770
|
onRepoChanged();
|
|
4478
4771
|
landed.observe();
|
|
4479
4772
|
return;
|
|
@@ -4493,17 +4786,29 @@ export function createWorkManager({
|
|
|
4493
4786
|
warn,
|
|
4494
4787
|
});
|
|
4495
4788
|
} catch (e) {
|
|
4496
|
-
await
|
|
4789
|
+
await report({
|
|
4497
4790
|
agentId,
|
|
4498
4791
|
ok: false,
|
|
4499
4792
|
detail: envScrub(String(e?.message || e)).slice(0, 2000),
|
|
4500
4793
|
});
|
|
4501
4794
|
return;
|
|
4502
4795
|
}
|
|
4503
|
-
await
|
|
4796
|
+
await report({ agentId, ok: true, sha: tip });
|
|
4504
4797
|
onRepoChanged();
|
|
4505
4798
|
landed.observe();
|
|
4506
|
-
|
|
4799
|
+
});
|
|
4800
|
+
} finally {
|
|
4801
|
+
if (!reported) {
|
|
4802
|
+
// Belt over braces. A merge this daemon claimed and cannot account for
|
|
4803
|
+
// is a FAILURE, said out loud, so the agent goes back to Review with a
|
|
4804
|
+
// reason instead of waiting out an expiry that blames nobody.
|
|
4805
|
+
await postAgentMerge({
|
|
4806
|
+
agentId,
|
|
4807
|
+
ok: false,
|
|
4808
|
+
detail: 'the merge did not complete — check the daemon log',
|
|
4809
|
+
}).catch(() => {});
|
|
4810
|
+
}
|
|
4811
|
+
}
|
|
4507
4812
|
};
|
|
4508
4813
|
|
|
4509
4814
|
const processAgentMergeJobs = (jobs) => {
|
|
@@ -4573,6 +4878,10 @@ export function createWorkManager({
|
|
|
4573
4878
|
// into a card nobody can explain.
|
|
4574
4879
|
agentTurns.size > 0 ||
|
|
4575
4880
|
agentMerges.size > 0 ||
|
|
4881
|
+
// A finished agent turn whose settle has not landed lives only in this
|
|
4882
|
+
// process; a restart here is the six-hour park the held body exists to
|
|
4883
|
+
// prevent. Same reason the tab's report queues are below.
|
|
4884
|
+
agentReported.size > 0 ||
|
|
4576
4885
|
shipping.size > 0 ||
|
|
4577
4886
|
workChildren.size > 0 ||
|
|
4578
4887
|
workAnswering.size > 0 ||
|