flowviant 0.78.0 → 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 +1 -18
- package/bin/lib/deploy.mjs +13 -1
- package/bin/lib/env.mjs +37 -24
- package/bin/lib/fleet.mjs +26 -37
- package/bin/lib/git.mjs +0 -263
- package/bin/lib/grant.mjs +24 -3
- package/bin/lib/landed.mjs +69 -22
- package/bin/lib/preview.mjs +35 -16
- package/bin/lib/prompts.mjs +25 -26
- package/bin/lib/shipSweep.mjs +16 -5
- package/bin/lib/work.mjs +244 -49
- package/bin/lib/worktreeDiff.mjs +55 -20
- package/package.json +1 -1
package/bin/lib/shipSweep.mjs
CHANGED
|
@@ -19,11 +19,15 @@ import { baseBranchName } from './git.mjs';
|
|
|
19
19
|
*
|
|
20
20
|
* `git branch -d` IS THE GUARD, deliberately, rather than a stack of checks
|
|
21
21
|
* of our own. It refuses an UNMERGED branch and it refuses one CHECKED OUT in
|
|
22
|
-
* any worktree —
|
|
23
|
-
*
|
|
24
|
-
*
|
|
25
|
-
*
|
|
26
|
-
*
|
|
22
|
+
* any worktree — enforced by the tool that owns the truth instead of by our
|
|
23
|
+
* reading of it. Never `-D`: if git objects, git is right and we stop. Two
|
|
24
|
+
* conditions are ours on top of it. The NAME: only `session/<id>` exactly, so
|
|
25
|
+
* a branch the agent cut is never in scope no matter what it was merged into.
|
|
26
|
+
* And ANCESTRY OF BASE: `-d` with no upstream judges "merged" against HEAD,
|
|
27
|
+
* so an operator parked on an experimental branch that merged the session
|
|
28
|
+
* work would get the delete while the narration below claimed "merged into
|
|
29
|
+
* <base>" for commits base has never seen — the claim is checked against the
|
|
30
|
+
* thing it names before git is asked at all.
|
|
27
31
|
*
|
|
28
32
|
* ORDERING IS LOAD-BEARING. This must not run while a ship report is still
|
|
29
33
|
* undelivered. Ship's idempotency path recovers from a lost report by asking
|
|
@@ -45,6 +49,13 @@ export function sweepMergedBranch(sessionId, { git, repoRoot, baseRef, note, isR
|
|
|
45
49
|
} catch {
|
|
46
50
|
return false; // already gone, or never existed
|
|
47
51
|
}
|
|
52
|
+
try {
|
|
53
|
+
git(['merge-base', '--is-ancestor', `refs/heads/${name}`, baseRef], repoRoot);
|
|
54
|
+
} catch {
|
|
55
|
+
// Not on base — merged into something, perhaps, but not into the thing the
|
|
56
|
+
// narration names, and not ours to retire on HEAD's say-so.
|
|
57
|
+
return false;
|
|
58
|
+
}
|
|
48
59
|
try {
|
|
49
60
|
git(['branch', '-d', name], repoRoot);
|
|
50
61
|
} catch {
|
package/bin/lib/work.mjs
CHANGED
|
@@ -574,8 +574,11 @@ export function createWorkManager({
|
|
|
574
574
|
// path), but a server bug or compromise sending `../../etc` would
|
|
575
575
|
// otherwise point a session's measured directory anywhere on the box and
|
|
576
576
|
// defeat the port attribution. `sessionWorktreeReport` and `placeWtFor`
|
|
577
|
-
// already reject an unsafe segment;
|
|
578
|
-
// (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
|
|
579
582
|
// repoRoot, so it is allowed through despite not being a path segment.
|
|
580
583
|
if (typeof place === 'string' && place && (place === REPO_PLACE || isSafePathSegment(place)))
|
|
581
584
|
sessionPlaces.set(sid, place);
|
|
@@ -2480,9 +2483,26 @@ export function createWorkManager({
|
|
|
2480
2483
|
// run it again while the report is merely undelivered.
|
|
2481
2484
|
if (pendingWorkReports.has(job.id)) continue;
|
|
2482
2485
|
workAnswering.add(job.id);
|
|
2483
|
-
// Serialized by PLACE: two tabs sharing a worktree take turns in it
|
|
2484
|
-
// rather than editing the same files at the same time.
|
|
2485
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
|
+
}
|
|
2486
2506
|
// Remembered for every other beat — the sweep, ship, the preview
|
|
2487
2507
|
// re-check — so they all ask the same directory this turn runs in.
|
|
2488
2508
|
sessionPlaces.set(job.sessionId, place);
|
|
@@ -3346,7 +3366,7 @@ export function createWorkManager({
|
|
|
3346
3366
|
};
|
|
3347
3367
|
// The machine may have no git identity, and a merge COMMIT needs
|
|
3348
3368
|
// one. Prefer the user's own config; fall back to the daemon's (the
|
|
3349
|
-
// same fallback
|
|
3369
|
+
// same fallback ship's merge keeps) so a bare machine doesn't fail
|
|
3350
3370
|
// the fold with "Please tell me who you are".
|
|
3351
3371
|
let idEnv = null;
|
|
3352
3372
|
try {
|
|
@@ -3658,7 +3678,28 @@ export function createWorkManager({
|
|
|
3658
3678
|
['diff', '--name-only', `${baseRef()}...HEAD`],
|
|
3659
3679
|
['ls-files', '--others', '--exclude-standard'],
|
|
3660
3680
|
]) {
|
|
3661
|
-
|
|
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
|
+
}
|
|
3662
3703
|
if (typeof r !== 'string') continue;
|
|
3663
3704
|
for (const line of r.split('\n')) {
|
|
3664
3705
|
const f = line.trim();
|
|
@@ -3706,7 +3747,15 @@ export function createWorkManager({
|
|
|
3706
3747
|
id: String(a?.id ?? ''),
|
|
3707
3748
|
name: String(a?.name ?? ''),
|
|
3708
3749
|
status: String(a?.status ?? ''),
|
|
3709
|
-
|
|
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
|
+
})(),
|
|
3710
3759
|
}));
|
|
3711
3760
|
|
|
3712
3761
|
let out = '';
|
|
@@ -3787,8 +3836,13 @@ export function createWorkManager({
|
|
|
3787
3836
|
// UNLEASED, unlike a plan or a kill. The server hands out at most one turn
|
|
3788
3837
|
// per agent per poll and its settle is conditional on the row still being
|
|
3789
3838
|
// pending, so a second daemon cannot advance the queue twice. What it could
|
|
3790
|
-
// do is run a CLI twice in one worktree
|
|
3791
|
-
//
|
|
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.
|
|
3792
3846
|
const agentTurns = new Set(); // turn ids in flight on this tick
|
|
3793
3847
|
/**
|
|
3794
3848
|
* The CLI a live agent turn is running in, by PLACE.
|
|
@@ -3805,14 +3859,29 @@ export function createWorkManager({
|
|
|
3805
3859
|
* emptied, so run the project's own check in the worktree we are already
|
|
3806
3860
|
* standing in. A job lane for that would need a claim, a floor and a settle
|
|
3807
3861
|
* to say something this reply already can. */
|
|
3808
|
-
/**
|
|
3809
|
-
*
|
|
3810
|
-
*
|
|
3811
|
-
*
|
|
3812
|
-
|
|
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;
|
|
3813
3881
|
|
|
3814
3882
|
const postAgentTurn = async (body) => {
|
|
3815
|
-
|
|
3883
|
+
const turnId = String(body.turnId);
|
|
3884
|
+
agentReported.set(turnId, { body, at: Date.now() });
|
|
3816
3885
|
try {
|
|
3817
3886
|
const res = await fetch(AGENT_TURN_DONE_URL, {
|
|
3818
3887
|
method: 'POST',
|
|
@@ -3825,14 +3894,19 @@ export function createWorkManager({
|
|
|
3825
3894
|
body: JSON.stringify(body),
|
|
3826
3895
|
});
|
|
3827
3896
|
const j = await res.json().catch(() => null);
|
|
3828
|
-
// Landed
|
|
3829
|
-
//
|
|
3830
|
-
|
|
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);
|
|
3831
3906
|
return j?.data ?? null;
|
|
3832
3907
|
} catch {
|
|
3833
|
-
// Unsettled
|
|
3834
|
-
//
|
|
3835
|
-
// 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.
|
|
3836
3910
|
return null;
|
|
3837
3911
|
}
|
|
3838
3912
|
};
|
|
@@ -3953,7 +4027,10 @@ export function createWorkManager({
|
|
|
3953
4027
|
return;
|
|
3954
4028
|
}
|
|
3955
4029
|
|
|
3956
|
-
|
|
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 () => {
|
|
3957
4034
|
const dir = placeWtFor(place);
|
|
3958
4035
|
if (!dir) {
|
|
3959
4036
|
// No worktree and none could be cut. `nothing` rather than an invented
|
|
@@ -4148,13 +4225,46 @@ export function createWorkManager({
|
|
|
4148
4225
|
};
|
|
4149
4226
|
|
|
4150
4227
|
const processAgentTurnJobs = (jobs) => {
|
|
4151
|
-
|
|
4152
|
-
|
|
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)) {
|
|
4153
4243
|
const id = String(job?.id || '');
|
|
4154
4244
|
if (!id || agentTurns.has(id)) continue;
|
|
4155
|
-
|
|
4156
|
-
|
|
4157
|
-
|
|
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
|
+
}
|
|
4158
4268
|
if (!job.agentId || !job.placeId) continue;
|
|
4159
4269
|
agentTurns.add(id);
|
|
4160
4270
|
void runAgentTurn(job).finally(() => agentTurns.delete(id));
|
|
@@ -4382,8 +4492,8 @@ export function createWorkManager({
|
|
|
4382
4492
|
/**
|
|
4383
4493
|
* A merge COMMIT needs a git identity and the machine may have none. Prefer
|
|
4384
4494
|
* the operator's own config; fall back to the daemon's, the same fallback
|
|
4385
|
-
*
|
|
4386
|
-
*
|
|
4495
|
+
* ship's merge keeps, so a bare machine does not fail the fold with
|
|
4496
|
+
* "Please tell me who you are".
|
|
4387
4497
|
*/
|
|
4388
4498
|
const gitMerge = (args, cwd) => {
|
|
4389
4499
|
let idEnv = null;
|
|
@@ -4409,18 +4519,37 @@ export function createWorkManager({
|
|
|
4409
4519
|
const agentId = String(job.agentId);
|
|
4410
4520
|
const place = String(job.placeId || '');
|
|
4411
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.
|
|
4412
4524
|
await postAgentMerge({ agentId, ok: false, detail: 'the agent has no worktree here' });
|
|
4413
4525
|
return;
|
|
4414
4526
|
}
|
|
4415
4527
|
if (!(await claimAgentMerge(agentId))) return;
|
|
4416
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
|
+
|
|
4417
4545
|
// A WRITER on the place, exactly as a ship is. It folds base in and pushes
|
|
4418
4546
|
// with git in that directory, and no CLI can coordinate with something it
|
|
4419
4547
|
// does not know exists.
|
|
4420
|
-
|
|
4548
|
+
try {
|
|
4549
|
+
await inPlace(place, true, async () => {
|
|
4421
4550
|
const wt = join(baseDir, 'sessions', place);
|
|
4422
4551
|
if (!existsSync(wt)) {
|
|
4423
|
-
await
|
|
4552
|
+
await report({ agentId, ok: false, detail: 'the worktree is gone' });
|
|
4424
4553
|
return;
|
|
4425
4554
|
}
|
|
4426
4555
|
try {
|
|
@@ -4437,7 +4566,7 @@ export function createWorkManager({
|
|
|
4437
4566
|
try {
|
|
4438
4567
|
gitMerge(['merge', '--no-edit', baseRef()], wt);
|
|
4439
4568
|
} catch (e) {
|
|
4440
|
-
await
|
|
4569
|
+
await report({
|
|
4441
4570
|
agentId,
|
|
4442
4571
|
ok: false,
|
|
4443
4572
|
detail: envScrub(String(e?.message || e)).slice(0, 2000),
|
|
@@ -4460,7 +4589,7 @@ export function createWorkManager({
|
|
|
4460
4589
|
if (!branch) {
|
|
4461
4590
|
// A detached HEAD names no branch, so there is nothing to merge and
|
|
4462
4591
|
// nothing to record. An ambiguity in git, not a rule of ours.
|
|
4463
|
-
await
|
|
4592
|
+
await report({ agentId, ok: false, detail: 'this worktree is on a detached HEAD' });
|
|
4464
4593
|
return;
|
|
4465
4594
|
}
|
|
4466
4595
|
const tip = (git(['rev-parse', 'HEAD'], wt) || '').trim();
|
|
@@ -4470,7 +4599,7 @@ export function createWorkManager({
|
|
|
4470
4599
|
// Already on base — an idempotent re-offer, or an agent that changed
|
|
4471
4600
|
// nothing. Reported as a SUCCESS: the branch's work is on base, which
|
|
4472
4601
|
// is what the caller is asking about.
|
|
4473
|
-
await
|
|
4602
|
+
await report({ agentId, ok: true, sha: tip || undefined });
|
|
4474
4603
|
return;
|
|
4475
4604
|
}
|
|
4476
4605
|
/**
|
|
@@ -4504,7 +4633,7 @@ export function createWorkManager({
|
|
|
4504
4633
|
timeout: 20_000,
|
|
4505
4634
|
});
|
|
4506
4635
|
} catch (e) {
|
|
4507
|
-
await
|
|
4636
|
+
await report({
|
|
4508
4637
|
agentId,
|
|
4509
4638
|
ok: false,
|
|
4510
4639
|
detail:
|
|
@@ -4516,7 +4645,7 @@ export function createWorkManager({
|
|
|
4516
4645
|
}
|
|
4517
4646
|
const prBase = baseBranchName(baseRef());
|
|
4518
4647
|
if (branch === prBase) {
|
|
4519
|
-
await
|
|
4648
|
+
await report({
|
|
4520
4649
|
agentId,
|
|
4521
4650
|
ok: false,
|
|
4522
4651
|
detail: `this agent is on the base branch (${prBase}) — there is nothing to open a pull request from`,
|
|
@@ -4531,19 +4660,34 @@ export function createWorkManager({
|
|
|
4531
4660
|
// token in its userinfo — so this one line is the only place on the
|
|
4532
4661
|
// agent merge path that can leak a credential into a stored,
|
|
4533
4662
|
// team-visible `mergeError`.
|
|
4534
|
-
await
|
|
4663
|
+
await report({ agentId, ok: false, detail: envScrub(ghFirstLine(e)) });
|
|
4535
4664
|
return;
|
|
4536
4665
|
}
|
|
4537
4666
|
let prUrl = null;
|
|
4538
4667
|
try {
|
|
4539
4668
|
const j = JSON.parse(
|
|
4540
|
-
execFileSync('gh', ['pr', 'view', branch, '--json', 'url,state'], {
|
|
4669
|
+
execFileSync('gh', ['pr', 'view', branch, '--json', 'url,state,baseRefName'], {
|
|
4541
4670
|
cwd: repoRoot,
|
|
4542
4671
|
stdio: ['ignore', 'pipe', 'pipe'],
|
|
4543
4672
|
timeout: 30_000,
|
|
4544
4673
|
}).toString()
|
|
4545
4674
|
);
|
|
4546
|
-
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
|
+
}
|
|
4547
4691
|
} catch {
|
|
4548
4692
|
/* no PR for this branch at all — created below */
|
|
4549
4693
|
}
|
|
@@ -4559,7 +4703,7 @@ export function createWorkManager({
|
|
|
4559
4703
|
.trim();
|
|
4560
4704
|
prUrl = out.split('\n').filter(Boolean).pop() ?? null;
|
|
4561
4705
|
} catch (e) {
|
|
4562
|
-
await
|
|
4706
|
+
await report({ agentId, ok: false, detail: ghFirstLine(e) });
|
|
4563
4707
|
return;
|
|
4564
4708
|
}
|
|
4565
4709
|
}
|
|
@@ -4574,7 +4718,7 @@ export function createWorkManager({
|
|
|
4574
4718
|
// Already merged is a SUCCESS: a re-offered job, or somebody merged
|
|
4575
4719
|
// it in the browser. The observer closes the cards either way.
|
|
4576
4720
|
if (!/already merged/i.test(line)) {
|
|
4577
|
-
await
|
|
4721
|
+
await report({
|
|
4578
4722
|
agentId,
|
|
4579
4723
|
ok: false,
|
|
4580
4724
|
detail:
|
|
@@ -4583,11 +4727,46 @@ export function createWorkManager({
|
|
|
4583
4727
|
return;
|
|
4584
4728
|
}
|
|
4585
4729
|
}
|
|
4586
|
-
//
|
|
4587
|
-
//
|
|
4588
|
-
//
|
|
4589
|
-
//
|
|
4590
|
-
|
|
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 });
|
|
4591
4770
|
onRepoChanged();
|
|
4592
4771
|
landed.observe();
|
|
4593
4772
|
return;
|
|
@@ -4607,17 +4786,29 @@ export function createWorkManager({
|
|
|
4607
4786
|
warn,
|
|
4608
4787
|
});
|
|
4609
4788
|
} catch (e) {
|
|
4610
|
-
await
|
|
4789
|
+
await report({
|
|
4611
4790
|
agentId,
|
|
4612
4791
|
ok: false,
|
|
4613
4792
|
detail: envScrub(String(e?.message || e)).slice(0, 2000),
|
|
4614
4793
|
});
|
|
4615
4794
|
return;
|
|
4616
4795
|
}
|
|
4617
|
-
await
|
|
4796
|
+
await report({ agentId, ok: true, sha: tip });
|
|
4618
4797
|
onRepoChanged();
|
|
4619
4798
|
landed.observe();
|
|
4620
|
-
|
|
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
|
+
}
|
|
4621
4812
|
};
|
|
4622
4813
|
|
|
4623
4814
|
const processAgentMergeJobs = (jobs) => {
|
|
@@ -4687,6 +4878,10 @@ export function createWorkManager({
|
|
|
4687
4878
|
// into a card nobody can explain.
|
|
4688
4879
|
agentTurns.size > 0 ||
|
|
4689
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 ||
|
|
4690
4885
|
shipping.size > 0 ||
|
|
4691
4886
|
workChildren.size > 0 ||
|
|
4692
4887
|
workAnswering.size > 0 ||
|
package/bin/lib/worktreeDiff.mjs
CHANGED
|
@@ -27,7 +27,7 @@
|
|
|
27
27
|
|
|
28
28
|
import { existsSync, statSync, readFileSync } from 'node:fs';
|
|
29
29
|
import { join } from 'node:path';
|
|
30
|
-
import { git } from './git.mjs';
|
|
30
|
+
import { git, gitRaw, splitNul } from './git.mjs';
|
|
31
31
|
|
|
32
32
|
/** Rows reported. The rail shows a handful; the totals below cover the rest. */
|
|
33
33
|
const MAX_FILES = 20;
|
|
@@ -70,6 +70,14 @@ export function taskIdsFromMessage(body) {
|
|
|
70
70
|
return ids;
|
|
71
71
|
}
|
|
72
72
|
|
|
73
|
+
/**
|
|
74
|
+
* The %x1e/%x1f record delimiters, removed from any field that survives
|
|
75
|
+
* parsing. Splitting alone is not enough: git PRESERVES both bytes in a commit
|
|
76
|
+
* body, so a surviving field can still carry a fragment of a forged record,
|
|
77
|
+
* and a stripped field can never re-assemble one downstream.
|
|
78
|
+
*/
|
|
79
|
+
export const stripDelims = (s) => String(s ?? '').replace(/[\x1e\x1f]/g, '');
|
|
80
|
+
|
|
73
81
|
/**
|
|
74
82
|
* The commits this branch has that base does not, with the cards they name.
|
|
75
83
|
*
|
|
@@ -81,14 +89,27 @@ export function taskIdsFromMessage(body) {
|
|
|
81
89
|
* `--no-merges`, because a merge commit describes a range rather than doing
|
|
82
90
|
* work, and its trailer (if it has one) would double-book the range's own
|
|
83
91
|
* commits.
|
|
92
|
+
*
|
|
93
|
+
* THE SHA LIST COMES FROM REV-LIST, NEVER FROM THE FORMATTED LOG. The %x1e and
|
|
94
|
+
* %x1f delimiters are formatting, not a fence: git preserves both bytes inside
|
|
95
|
+
* a commit BODY (verified empirically), so a crafted message can fabricate
|
|
96
|
+
* whole records — a self-named sha carrying task ids, exactly the receipt this
|
|
97
|
+
* product refuses to let an agent write about itself. rev-list prints nothing
|
|
98
|
+
* an author controls, so its output is the set of commits that exist: a parsed
|
|
99
|
+
* record whose sha is not in that set is a forgery and is dropped, a repeated
|
|
100
|
+
* sha is the same forgery wearing a real commit's name, and the delimiter
|
|
101
|
+
* bytes are stripped from every surviving field.
|
|
84
102
|
*/
|
|
85
103
|
function branchCommits(wt, base) {
|
|
86
104
|
if (!base) return [];
|
|
87
105
|
const out = [];
|
|
88
106
|
try {
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
107
|
+
const real = new Set(
|
|
108
|
+
git(['rev-list', '--no-merges', '-n', String(MAX_COMMITS), `${base}..HEAD`], wt)
|
|
109
|
+
.split('\n')
|
|
110
|
+
.filter(Boolean)
|
|
111
|
+
);
|
|
112
|
+
if (real.size === 0) return [];
|
|
92
113
|
const raw = git(
|
|
93
114
|
[
|
|
94
115
|
'log',
|
|
@@ -103,9 +124,10 @@ function branchCommits(wt, base) {
|
|
|
103
124
|
for (const rec of raw.split('\x1e')) {
|
|
104
125
|
const line = rec.replace(/^\n+/, '');
|
|
105
126
|
if (!line.trim()) continue;
|
|
106
|
-
const [sha, subject, author, at,
|
|
107
|
-
if (!sha) continue;
|
|
108
|
-
|
|
127
|
+
const [sha, subject, author, at, ...bodyParts] = line.split('\x1f');
|
|
128
|
+
if (!real.has(sha)) continue;
|
|
129
|
+
real.delete(sha);
|
|
130
|
+
const taskIds = taskIdsFromMessage(stripDelims(bodyParts.join('\n')));
|
|
109
131
|
if (taskIds.length === 0) continue;
|
|
110
132
|
let additions = 0;
|
|
111
133
|
let deletions = 0;
|
|
@@ -125,9 +147,9 @@ function branchCommits(wt, base) {
|
|
|
125
147
|
// Clamped to the server's zod caps, same rule as everything else in
|
|
126
148
|
// this file: one over-cap string 400s the whole batch.
|
|
127
149
|
sha: sha.slice(0, 64),
|
|
128
|
-
subject: (subject
|
|
129
|
-
author: (author
|
|
130
|
-
at: (at
|
|
150
|
+
subject: stripDelims(subject).slice(0, 200),
|
|
151
|
+
author: stripDelims(author).slice(0, 80),
|
|
152
|
+
at: stripDelims(at).slice(0, 40),
|
|
131
153
|
additions,
|
|
132
154
|
deletions,
|
|
133
155
|
taskIds: taskIds.slice(0, 8),
|
|
@@ -202,13 +224,25 @@ export function worktreeDiff(wt, baseRef) {
|
|
|
202
224
|
};
|
|
203
225
|
|
|
204
226
|
// Tracked: working tree vs base. `git diff <base>` (no --cached, no second
|
|
205
|
-
// ref) is exactly "everything this session did", committed or not.
|
|
227
|
+
// ref) is exactly "everything this session did", committed or not. `-z`, for
|
|
228
|
+
// the reason gitRaw exists: git's LINE-based output C-quotes any path that
|
|
229
|
+
// is not plain ASCII, so an accented filename arrives as "n\303\251w.txt" —
|
|
230
|
+
// a string that is not the path and reads as escaped garbage in the rail.
|
|
206
231
|
try {
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
232
|
+
// `--numstat -z` frames a normal change as one field, "added\tdeleted\tpath",
|
|
233
|
+
// but a RENAME as three: "added\tdeleted\t" (empty path), then the old path,
|
|
234
|
+
// then the new one. An empty path is therefore the rename marker and the
|
|
235
|
+
// next two fields belong to it — read line-wise, a rename would report a
|
|
236
|
+
// file literally named "old => new".
|
|
237
|
+
const fields = splitNul(gitRaw(['diff', '--numstat', '-z', base || 'HEAD', '--'], wt));
|
|
238
|
+
for (let i = 0; i < fields.length; i++) {
|
|
239
|
+
const [a, d, ...rest] = fields[i].split('\t');
|
|
240
|
+
let path = rest.join('\t');
|
|
241
|
+
if (!path) {
|
|
242
|
+
path = fields[i + 2] ?? fields[i + 1]; // the post-rename name is what exists now
|
|
243
|
+
i += 2;
|
|
244
|
+
if (!path) continue;
|
|
245
|
+
}
|
|
212
246
|
const binary = a === '-' || d === '-';
|
|
213
247
|
push(path, binary ? 0 : Number(a) || 0, binary ? 0 : Number(d) || 0, binary);
|
|
214
248
|
}
|
|
@@ -217,11 +251,12 @@ export function worktreeDiff(wt, baseRef) {
|
|
|
217
251
|
}
|
|
218
252
|
|
|
219
253
|
// Untracked, minus everything gitignored — new files are the most visible
|
|
220
|
-
// work a session does and they would otherwise show as nothing at all.
|
|
254
|
+
// work a session does and they would otherwise show as nothing at all. `-z`
|
|
255
|
+
// here is not cosmetic: a C-quoted untracked name fails the statSync below,
|
|
256
|
+
// and the catch swallowed the row — a session's brand-new `café.txt` simply
|
|
257
|
+
// vanished from the rail.
|
|
221
258
|
try {
|
|
222
|
-
const others =
|
|
223
|
-
.split('\n')
|
|
224
|
-
.filter(Boolean);
|
|
259
|
+
const others = splitNul(gitRaw(['ls-files', '--others', '--exclude-standard', '-z'], wt));
|
|
225
260
|
for (const path of others.slice(0, MAX_UNTRACKED_SCAN)) {
|
|
226
261
|
try {
|
|
227
262
|
const full = join(wt, path);
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "flowviant",
|
|
3
|
-
"version": "0.78.
|
|
3
|
+
"version": "0.78.1",
|
|
4
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": {
|