flowviant 0.77.0 → 0.77.3
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/work.mjs +136 -7
- package/package.json +1 -1
package/bin/lib/work.mjs
CHANGED
|
@@ -567,6 +567,21 @@ export function createWorkManager({
|
|
|
567
567
|
const worktreeSeen = new Set();
|
|
568
568
|
let lastWorktreeFetch = 0;
|
|
569
569
|
let sweepingWorktrees = false;
|
|
570
|
+
/**
|
|
571
|
+
* WHERE THE NEXT SWEEP STARTS.
|
|
572
|
+
*
|
|
573
|
+
* A safety valve with a rotation, and the rotation is the load-bearing half.
|
|
574
|
+
* The sweep used to take `activeIds.slice(0, 20)` — a silent truncation of a
|
|
575
|
+
* list the server builds tabs-first and agent places LAST, so on a project
|
|
576
|
+
* with twenty live tabs no agent was EVER measured: no branch diff, no head
|
|
577
|
+
* sha, and none of their trailered commits ever reached a card. Permanently,
|
|
578
|
+
* because the same twenty won every pass.
|
|
579
|
+
*
|
|
580
|
+
* Chunking removes the cut for any realistic project (see below). The cursor
|
|
581
|
+
* is what makes the residual cap fair rather than arbitrary: past it, the
|
|
582
|
+
* places that missed one sweep are the ones that lead the next.
|
|
583
|
+
*/
|
|
584
|
+
let worktreeCursor = 0;
|
|
570
585
|
const postWorktrees = async (reports) => {
|
|
571
586
|
if (!reports.length) return;
|
|
572
587
|
try {
|
|
@@ -800,12 +815,46 @@ export function createWorkManager({
|
|
|
800
815
|
// landed. Best-effort like everything in this sweep.
|
|
801
816
|
void landed.observe().catch(() => {});
|
|
802
817
|
}
|
|
818
|
+
/**
|
|
819
|
+
* MEASURED IN CHUNKS, not truncated to one.
|
|
820
|
+
*
|
|
821
|
+
* The server's endpoint takes twenty entries per request, and this read
|
|
822
|
+
* that bound as "measure twenty places" — so everything past the
|
|
823
|
+
* twentieth was silently never measured, and the server builds that
|
|
824
|
+
* list with agent places at the END. A project with twenty live tabs
|
|
825
|
+
* therefore measured no agent at all: their review pane showed no
|
|
826
|
+
* branch diff, `checkIsStale` had no head to compare against, and their
|
|
827
|
+
* commits never reached the cards they name.
|
|
828
|
+
*
|
|
829
|
+
* The cap on a REQUEST is not a cap on the WORK. Several requests of
|
|
830
|
+
* twenty cost several round trips and each is validated per entry
|
|
831
|
+
* exactly as before, so no contract changes and no floor is needed.
|
|
832
|
+
*
|
|
833
|
+
* `SWEEP_MAX_PLACES` is a bound on the MACHINE — each place costs a
|
|
834
|
+
* `git diff`, a listener scan and a process scan — and the cursor
|
|
835
|
+
* rotates so a project past it still measures everything, just across
|
|
836
|
+
* successive sweeps instead of one.
|
|
837
|
+
*/
|
|
838
|
+
const SWEEP_MAX_PLACES = 60;
|
|
839
|
+
const CHUNK = 20;
|
|
840
|
+
const total = activeIds.length;
|
|
841
|
+
const start = total > SWEEP_MAX_PLACES ? worktreeCursor % total : 0;
|
|
842
|
+
const take = Math.min(total, SWEEP_MAX_PLACES);
|
|
843
|
+
// Rotated slice, so the tail of a long list leads the next sweep rather
|
|
844
|
+
// than never being reached.
|
|
845
|
+
const order = Array.from({ length: take }, (_, i) => activeIds[(start + i) % total]);
|
|
846
|
+
worktreeCursor = total > SWEEP_MAX_PLACES ? (start + take) % total : 0;
|
|
803
847
|
const reports = [];
|
|
804
|
-
for (const id of
|
|
848
|
+
for (const id of order) {
|
|
805
849
|
const r = sessionWorktreeReport(id);
|
|
806
850
|
if (r) reports.push(r);
|
|
807
851
|
}
|
|
808
|
-
|
|
852
|
+
// One POST per chunk. Awaited in sequence rather than fired together:
|
|
853
|
+
// this runs on the machine's own poll beat and a burst of parallel
|
|
854
|
+
// writes to the same rows buys nothing.
|
|
855
|
+
for (let i = 0; i < reports.length; i += CHUNK) {
|
|
856
|
+
await postWorktrees(reports.slice(i, i + CHUNK));
|
|
857
|
+
}
|
|
809
858
|
} finally {
|
|
810
859
|
sweepingWorktrees = false;
|
|
811
860
|
}
|
|
@@ -2275,6 +2324,36 @@ export function createWorkManager({
|
|
|
2275
2324
|
for (const id of ids) {
|
|
2276
2325
|
if (live.has(id)) continue;
|
|
2277
2326
|
if (peers.has(id)) continue; // another daemon's tab — not ours to retire
|
|
2327
|
+
/**
|
|
2328
|
+
* A HARD STOP REACHES THE CLI HERE, and this is the whole of it.
|
|
2329
|
+
*
|
|
2330
|
+
* `stopAgent` marked the agent abandoned in D1 and nothing on the wire
|
|
2331
|
+
* told the machine, so the CLI went on working, editing the worktree and
|
|
2332
|
+
* spending the operator's quota — while the confirm dialog said the agent
|
|
2333
|
+
* was stopped. It also never got cleaned up, because the check below
|
|
2334
|
+
* skips any place whose lock is held and a running turn holds it: a
|
|
2335
|
+
* stopped agent mid-turn kept its directory forever.
|
|
2336
|
+
*
|
|
2337
|
+
* No new wire field and no version floor: an abandoned agent simply drops
|
|
2338
|
+
* out of `activeWorkSessions` (that list is built from LIVE statuses), so
|
|
2339
|
+
* the server ALREADY says everything needed. Absence means "nothing here
|
|
2340
|
+
* is live any more", and the honest response to that is to stop what we
|
|
2341
|
+
* are running in it and then take the directory.
|
|
2342
|
+
*
|
|
2343
|
+
* SIGTERM the CHILD, never its group — the rule teardown keeps, so an
|
|
2344
|
+
* unattended sweep cannot take the driver's dev server with it. The lock
|
|
2345
|
+
* is released when the child exits, so the removal happens on the next
|
|
2346
|
+
* pass rather than this one.
|
|
2347
|
+
*/
|
|
2348
|
+
const running = agentChildren.get(id);
|
|
2349
|
+
if (running) {
|
|
2350
|
+
try {
|
|
2351
|
+
running.kill('SIGTERM');
|
|
2352
|
+
} catch {
|
|
2353
|
+
/* already gone */
|
|
2354
|
+
}
|
|
2355
|
+
agentChildren.delete(id);
|
|
2356
|
+
}
|
|
2278
2357
|
// Asked of the PLACE, not the session: the lock is keyed by directory,
|
|
2279
2358
|
// and a session sharing one with a busy peer is not ours to retire
|
|
2280
2359
|
// either — its worktree is the peer's working directory.
|
|
@@ -3604,6 +3683,15 @@ export function createWorkManager({
|
|
|
3604
3683
|
// do is run a CLI twice in one worktree, which is what this in-flight set and
|
|
3605
3684
|
// the place lock prevent — the same discipline `processWorkTurns` keeps.
|
|
3606
3685
|
const agentTurns = new Set(); // turn ids in flight on this tick
|
|
3686
|
+
/**
|
|
3687
|
+
* The CLI a live agent turn is running in, by PLACE.
|
|
3688
|
+
*
|
|
3689
|
+
* `workChildren` is keyed by the child itself, which answers "kill everything
|
|
3690
|
+
* at teardown" and cannot answer "kill THIS agent's". Keyed by place rather
|
|
3691
|
+
* than by agent id because the one consumer — the retire sweep — walks
|
|
3692
|
+
* directory names, and those are places.
|
|
3693
|
+
*/
|
|
3694
|
+
const agentChildren = new Map(); // placeId -> child process
|
|
3607
3695
|
|
|
3608
3696
|
/** Returns the server's reply, because it carries ONE instruction the machine
|
|
3609
3697
|
* can act on immediately: `review: true` means the agent's queue just
|
|
@@ -3747,10 +3835,31 @@ export function createWorkManager({
|
|
|
3747
3835
|
return;
|
|
3748
3836
|
}
|
|
3749
3837
|
const wt = dir.wt;
|
|
3838
|
+
/**
|
|
3839
|
+
* NEITHER OF THESE MAY THROW. `git()` throws on a non-zero exit, and both
|
|
3840
|
+
* of these exit non-zero in states a real worktree reaches: `symbolic-ref`
|
|
3841
|
+
* on a DETACHED HEAD (an agent that ran `git checkout <sha>`, a rebase
|
|
3842
|
+
* left half-done), and `rev-parse HEAD` on a branch with no commits yet.
|
|
3843
|
+
* An escape here happens BEFORE any settle, so the turn stays pending
|
|
3844
|
+
* until the six-hour expiry while the agent sits in Working with nothing
|
|
3845
|
+
* behind it — the wedge this whole lane exists to avoid.
|
|
3846
|
+
*
|
|
3847
|
+
* `runAgentMerge` learned this two commits ago and says so in its own
|
|
3848
|
+
* comment; this is the same call, in the same file, left unguarded. Both
|
|
3849
|
+
* facts are OPTIONAL to a turn — they describe the branch, they do not
|
|
3850
|
+
* gate the work — so failing to read them costs a null, not the turn.
|
|
3851
|
+
*/
|
|
3852
|
+
const readGit = (args) => {
|
|
3853
|
+
try {
|
|
3854
|
+
return (git(args, wt) || '').trim() || null;
|
|
3855
|
+
} catch {
|
|
3856
|
+
return null;
|
|
3857
|
+
}
|
|
3858
|
+
};
|
|
3750
3859
|
// WHERE THE BRANCH WAS BEFORE THIS TURN, so the commits reported are the
|
|
3751
3860
|
// ones this turn actually made.
|
|
3752
|
-
const before = (
|
|
3753
|
-
const branch = (
|
|
3861
|
+
const before = readGit(['rev-parse', 'HEAD']);
|
|
3862
|
+
const branch = readGit(['symbolic-ref', '--quiet', '--short', 'HEAD']);
|
|
3754
3863
|
|
|
3755
3864
|
const rt = job.runtime || 'claude';
|
|
3756
3865
|
if (!canRun(RUNTIMES[rt], 'build')) {
|
|
@@ -3822,10 +3931,15 @@ export function createWorkManager({
|
|
|
3822
3931
|
child = ch;
|
|
3823
3932
|
workChildren.set(ch, null);
|
|
3824
3933
|
noteSessionGroup(agentId, ch.pid);
|
|
3934
|
+
// Keyed by PLACE, because the retire sweep iterates directory names
|
|
3935
|
+
// and a place id IS one. It is what lets a hard stop actually reach
|
|
3936
|
+
// the CLI — see `retireWorkSessions`.
|
|
3937
|
+
agentChildren.set(place, ch);
|
|
3825
3938
|
},
|
|
3826
3939
|
});
|
|
3827
3940
|
} finally {
|
|
3828
3941
|
if (child) workChildren.delete(child);
|
|
3942
|
+
if (agentChildren.get(place) === child) agentChildren.delete(place);
|
|
3829
3943
|
if (ranMarker) {
|
|
3830
3944
|
try {
|
|
3831
3945
|
writeFileSync(ranMarker, '1');
|
|
@@ -4209,7 +4323,15 @@ export function createWorkManager({
|
|
|
4209
4323
|
*/
|
|
4210
4324
|
if (job.prMode) {
|
|
4211
4325
|
try {
|
|
4212
|
-
|
|
4326
|
+
// EVERY `gh` CALL IS TIMED OUT. `execFileSync` blocks the daemon's
|
|
4327
|
+
// whole event loop, and this one runs INSIDE the place writer lock —
|
|
4328
|
+
// so a `gh` that hangs (an expired token prompting, a network black
|
|
4329
|
+
// hole, a hung credential helper) stops every turn on the machine,
|
|
4330
|
+
// not merely this merge, and holds the lock while it does.
|
|
4331
|
+
execFileSync('gh', ['auth', 'status'], {
|
|
4332
|
+
stdio: ['ignore', 'pipe', 'pipe'],
|
|
4333
|
+
timeout: 20_000,
|
|
4334
|
+
});
|
|
4213
4335
|
} catch (e) {
|
|
4214
4336
|
await postAgentMerge({
|
|
4215
4337
|
agentId,
|
|
@@ -4233,7 +4355,12 @@ export function createWorkManager({
|
|
|
4233
4355
|
try {
|
|
4234
4356
|
git(['push', '-u', 'origin', branch], wt);
|
|
4235
4357
|
} catch (e) {
|
|
4236
|
-
|
|
4358
|
+
// SCRUBBED. Every other failure on this path relays `gh`'s own words,
|
|
4359
|
+
// but a push writes the REMOTE URL to stderr and a remote can carry a
|
|
4360
|
+
// token in its userinfo — so this one line is the only place on the
|
|
4361
|
+
// agent merge path that can leak a credential into a stored,
|
|
4362
|
+
// team-visible `mergeError`.
|
|
4363
|
+
await postAgentMerge({ agentId, ok: false, detail: envScrub(ghFirstLine(e)) });
|
|
4237
4364
|
return;
|
|
4238
4365
|
}
|
|
4239
4366
|
let prUrl = null;
|
|
@@ -4242,6 +4369,7 @@ export function createWorkManager({
|
|
|
4242
4369
|
execFileSync('gh', ['pr', 'view', branch, '--json', 'url,state'], {
|
|
4243
4370
|
cwd: repoRoot,
|
|
4244
4371
|
stdio: ['ignore', 'pipe', 'pipe'],
|
|
4372
|
+
timeout: 30_000,
|
|
4245
4373
|
}).toString()
|
|
4246
4374
|
);
|
|
4247
4375
|
if (j?.state === 'OPEN' && typeof j?.url === 'string') prUrl = j.url.trim();
|
|
@@ -4254,7 +4382,7 @@ export function createWorkManager({
|
|
|
4254
4382
|
'gh',
|
|
4255
4383
|
// baseBranchName, not baseRef: gh 422s on a remote-tracking name.
|
|
4256
4384
|
['pr', 'create', '--head', branch, '--base', prBase, '--fill'],
|
|
4257
|
-
{ cwd: repoRoot, stdio: ['ignore', 'pipe', 'pipe'] }
|
|
4385
|
+
{ cwd: repoRoot, stdio: ['ignore', 'pipe', 'pipe'], timeout: 60_000 }
|
|
4258
4386
|
)
|
|
4259
4387
|
.toString()
|
|
4260
4388
|
.trim();
|
|
@@ -4268,6 +4396,7 @@ export function createWorkManager({
|
|
|
4268
4396
|
execFileSync('gh', ['pr', 'merge', branch, '--merge'], {
|
|
4269
4397
|
cwd: repoRoot,
|
|
4270
4398
|
stdio: ['ignore', 'pipe', 'pipe'],
|
|
4399
|
+
timeout: 120_000,
|
|
4271
4400
|
});
|
|
4272
4401
|
} catch (e) {
|
|
4273
4402
|
const line = ghFirstLine(e);
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "flowviant",
|
|
3
|
-
"version": "0.77.
|
|
3
|
+
"version": "0.77.3",
|
|
4
4
|
"description": "Run your own coding CLIs as build agents for Flowviant \u2014 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": {
|