flowviant 0.75.0 → 0.76.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/agentPlan.mjs +187 -0
- package/bin/lib/fleet.mjs +19 -0
- package/bin/lib/prompts.mjs +251 -0
- package/bin/lib/work.mjs +887 -2
- package/package.json +1 -1
package/bin/lib/work.mjs
CHANGED
|
@@ -28,7 +28,7 @@ import {
|
|
|
28
28
|
mkdirSync,
|
|
29
29
|
cpSync,
|
|
30
30
|
} from 'node:fs';
|
|
31
|
-
import { execFileSync } from 'node:child_process';
|
|
31
|
+
import { execFileSync, spawn } from 'node:child_process';
|
|
32
32
|
import { join, dirname } from 'node:path';
|
|
33
33
|
import {
|
|
34
34
|
FLEET_URL,
|
|
@@ -44,6 +44,7 @@ import { listenersIn, measureListeners, listenersSupported } from './listeners.m
|
|
|
44
44
|
import { measureProcesses, liveGroups, processesSupported } from './processes.mjs';
|
|
45
45
|
import { mutateRegistry, processAlive, readRegistry } from './procRegistry.mjs';
|
|
46
46
|
import { createPlaceLock } from './placeLock.mjs';
|
|
47
|
+
import { parseProposal, parseTurnResult } from './agentPlan.mjs';
|
|
47
48
|
import { sweepMergedBranch } from './shipSweep.mjs';
|
|
48
49
|
import { mergeOutward as shipMergeOutward } from './shipMerge.mjs';
|
|
49
50
|
import { openTunnel } from './preview.mjs';
|
|
@@ -54,9 +55,21 @@ import {
|
|
|
54
55
|
WORK_TURN_KICKOFF,
|
|
55
56
|
SYSTEM_WORK_PLAIN,
|
|
56
57
|
WORK_TURN_KICKOFF_PLAIN,
|
|
58
|
+
SYSTEM_PLAN,
|
|
59
|
+
AGENT_PLAN_KICKOFF,
|
|
60
|
+
SYSTEM_AGENT,
|
|
61
|
+
AGENT_TASK_KICKOFF,
|
|
62
|
+
AGENT_HUMAN_KICKOFF,
|
|
57
63
|
} from './prompts.mjs';
|
|
58
64
|
import { materializeInto, hasMaterialized, excludeInWorktree, scrub as envScrub } from './env.mjs';
|
|
59
|
-
import {
|
|
65
|
+
import {
|
|
66
|
+
detectRuntimes,
|
|
67
|
+
canRun,
|
|
68
|
+
pickRuntimeFor,
|
|
69
|
+
recordSkills,
|
|
70
|
+
toolEventOf,
|
|
71
|
+
RUNTIMES,
|
|
72
|
+
} from './runtimes.mjs';
|
|
60
73
|
|
|
61
74
|
/** The place id meaning "the checkout", not a worktree. Must match the
|
|
62
75
|
* server's REPO_PLACE — it is a wire value, not a local convention. */
|
|
@@ -142,6 +155,14 @@ export function createWorkManager({
|
|
|
142
155
|
const ATTACHMENT_URL = FLEET_URL.replace(/\/agents\/?$/, '/attachment');
|
|
143
156
|
const PR_CLAIM_URL = FLEET_URL.replace(/\/agents\/?$/, '/pr-claim');
|
|
144
157
|
const PR_DONE_URL = FLEET_URL.replace(/\/agents\/?$/, '/pr-done');
|
|
158
|
+
const AGENT_PLAN_CLAIM_URL = FLEET_URL.replace(/\/agents\/?$/, '/agent-plan-claim');
|
|
159
|
+
const AGENT_PLAN_DONE_URL = FLEET_URL.replace(/\/agents\/?$/, '/agent-plan-done');
|
|
160
|
+
const AGENT_TURN_DONE_URL = FLEET_URL.replace(/\/agents\/?$/, '/agent-turn-done');
|
|
161
|
+
const AGENT_ACTIVITY_URL = FLEET_URL.replace(/\/agents\/?$/, '/agent-activity');
|
|
162
|
+
const AGENT_PARKED_URL = FLEET_URL.replace(/\/agents\/?$/, '/agent-parked');
|
|
163
|
+
const AGENT_CHECK_DONE_URL = FLEET_URL.replace(/\/agents\/?$/, '/agent-check-done');
|
|
164
|
+
const AGENT_MERGE_CLAIM_URL = FLEET_URL.replace(/\/agents\/?$/, '/agent-merge-claim');
|
|
165
|
+
const AGENT_MERGE_DONE_URL = FLEET_URL.replace(/\/agents\/?$/, '/agent-merge-done');
|
|
145
166
|
// What arrived on base, whichever road it took — observed after every beat
|
|
146
167
|
// that can move origin/<base>: the sweep's fetch, a ship's push, a PR merge
|
|
147
168
|
// this daemon performed. See landed.mjs for the seeding and delivery rules.
|
|
@@ -3375,8 +3396,868 @@ export function createWorkManager({
|
|
|
3375
3396
|
* self-delete when a place goes quiet); the other collections are belt over
|
|
3376
3397
|
* braces for the windows around it.
|
|
3377
3398
|
*/
|
|
3399
|
+
// ── AGENT PLAN JOBS: the Deploy press ──────────────────────────────────────
|
|
3400
|
+
//
|
|
3401
|
+
// Somebody selected cards and pressed Deploy. This turn works out HOW THE
|
|
3402
|
+
// WORK SHOULD BE SPLIT across agents and stops — a person edits what it
|
|
3403
|
+
// proposes on the board, and ACCEPTING is what spawns anything. Nothing here
|
|
3404
|
+
// creates a worktree, a branch or a card.
|
|
3405
|
+
//
|
|
3406
|
+
// READ-ONLY IN THE CHECKOUT. `readOnly: true` selects CONSULT_PERM (Read,
|
|
3407
|
+
// Grep, Glob and a few `git` reads — no Write, no Edit, no mkdir, no rm) and
|
|
3408
|
+
// NO MCP is passed at all, so this turn has no control plane to reach even if
|
|
3409
|
+
// the repository it reads tries to steer it. The proposal comes back as the
|
|
3410
|
+
// turn's final message rather than through a tool, which is exactly what lets
|
|
3411
|
+
// that permission set be this narrow.
|
|
3412
|
+
//
|
|
3413
|
+
// It takes the checkout's place lock as a READER, beside the operator's own
|
|
3414
|
+
// tabs. It writes nothing, so a writer lock would only starve real work.
|
|
3415
|
+
const planning = new Set(); // press ids in flight on this tick
|
|
3416
|
+
|
|
3417
|
+
const postAgentPlan = async (body) => {
|
|
3418
|
+
try {
|
|
3419
|
+
await fetch(AGENT_PLAN_DONE_URL, {
|
|
3420
|
+
method: 'POST',
|
|
3421
|
+
headers: {
|
|
3422
|
+
Authorization: `Bearer ${FLEET_TOKEN}`,
|
|
3423
|
+
'User-Agent': USER_AGENT,
|
|
3424
|
+
'Content-Type': 'application/json',
|
|
3425
|
+
},
|
|
3426
|
+
signal: AbortSignal.timeout(30_000),
|
|
3427
|
+
body: JSON.stringify({ ...body, instance: DAEMON_INSTANCE }),
|
|
3428
|
+
});
|
|
3429
|
+
} catch {
|
|
3430
|
+
/* unsettled, and the server expires it — the asker is told, never spun */
|
|
3431
|
+
}
|
|
3432
|
+
};
|
|
3433
|
+
|
|
3434
|
+
const claimAgentPlan = async (id) => {
|
|
3435
|
+
try {
|
|
3436
|
+
const res = await fetch(AGENT_PLAN_CLAIM_URL, {
|
|
3437
|
+
method: 'POST',
|
|
3438
|
+
headers: {
|
|
3439
|
+
Authorization: `Bearer ${FLEET_TOKEN}`,
|
|
3440
|
+
'User-Agent': USER_AGENT,
|
|
3441
|
+
'Content-Type': 'application/json',
|
|
3442
|
+
},
|
|
3443
|
+
signal: AbortSignal.timeout(15_000),
|
|
3444
|
+
body: JSON.stringify({ id, instance: DAEMON_INSTANCE }),
|
|
3445
|
+
});
|
|
3446
|
+
const j = await res.json().catch(() => null);
|
|
3447
|
+
return j?.data?.claimed === true;
|
|
3448
|
+
} catch {
|
|
3449
|
+
return false; // the peer may hold it; doing nothing is the safe answer
|
|
3450
|
+
}
|
|
3451
|
+
};
|
|
3452
|
+
|
|
3453
|
+
/**
|
|
3454
|
+
* What a live agent has already TOUCHED, measured here rather than sent.
|
|
3455
|
+
*
|
|
3456
|
+
* The server could have shipped a copy of this, and deliberately does not:
|
|
3457
|
+
* these are directories on THIS machine, and a server-side copy would be a
|
|
3458
|
+
* second, staler source of truth for a fact the daemon is standing on top of.
|
|
3459
|
+
*
|
|
3460
|
+
* Uncommitted work AND commits against base, because a planner asking "will
|
|
3461
|
+
* these collide" cares about both — a file this agent has already rewritten
|
|
3462
|
+
* and merged into its own branch collides exactly as hard as one it is
|
|
3463
|
+
* editing now.
|
|
3464
|
+
*/
|
|
3465
|
+
const agentChangedFiles = (placeId) => {
|
|
3466
|
+
if (!placeId || !isSafePathSegment(placeId)) return [];
|
|
3467
|
+
const wt = join(baseDir, 'sessions', placeId);
|
|
3468
|
+
if (!existsSync(wt)) return [];
|
|
3469
|
+
const out = new Set();
|
|
3470
|
+
for (const args of [
|
|
3471
|
+
['diff', '--name-only', 'HEAD'],
|
|
3472
|
+
['diff', '--name-only', `${baseRef()}...HEAD`],
|
|
3473
|
+
['ls-files', '--others', '--exclude-standard'],
|
|
3474
|
+
]) {
|
|
3475
|
+
const r = git(args, wt);
|
|
3476
|
+
if (typeof r !== 'string') continue;
|
|
3477
|
+
for (const line of r.split('\n')) {
|
|
3478
|
+
const f = line.trim();
|
|
3479
|
+
if (f) out.add(f);
|
|
3480
|
+
if (out.size >= 60) return [...out];
|
|
3481
|
+
}
|
|
3482
|
+
}
|
|
3483
|
+
return [...out];
|
|
3484
|
+
};
|
|
3485
|
+
|
|
3486
|
+
const runAgentPlan = async (job) => {
|
|
3487
|
+
const id = String(job.id);
|
|
3488
|
+
const tasks = Array.isArray(job.tasks) ? job.tasks : [];
|
|
3489
|
+
// CLAIM BEFORE ANYTHING — including before the cheap refusal below.
|
|
3490
|
+
//
|
|
3491
|
+
// The settle is rejected unless the caller HOLDS the lease, so posting an
|
|
3492
|
+
// error first meant the server discarded it and the press sat open forever,
|
|
3493
|
+
// holding its cards out of Deploy. That is the opposite of the ordering a
|
|
3494
|
+
// kill keeps (refuse cheaply, then claim) and it is because these two
|
|
3495
|
+
// lanes settle differently: a kill's settle does not check a lease for the
|
|
3496
|
+
// not-found case, and a plan's always does.
|
|
3497
|
+
if (!(await claimAgentPlan(id))) return;
|
|
3498
|
+
if (tasks.length === 0) {
|
|
3499
|
+
// The press named cards the doc no longer has. A real answer, and one the
|
|
3500
|
+
// board can explain, rather than a turn that plans nothing.
|
|
3501
|
+
await postAgentPlan({ id, error: 'those cards are no longer on the board' });
|
|
3502
|
+
return;
|
|
3503
|
+
}
|
|
3504
|
+
|
|
3505
|
+
/**
|
|
3506
|
+
* WHICH CLI PLANS. `pickRuntimeFor('consult')`, the same picker every other
|
|
3507
|
+
* turn nobody @mentioned already uses — Claude when it is here (the prompts
|
|
3508
|
+
* were written against it), otherwise whatever can express the profile.
|
|
3509
|
+
*
|
|
3510
|
+
* Deliberately NOT the project's runtime order: that order is about AGENTS,
|
|
3511
|
+
* which hold a conversation across many turns and whose value is that held
|
|
3512
|
+
* context. A planner runs once and reads; it takes what the machine has.
|
|
3513
|
+
*/
|
|
3514
|
+
const rt = pickRuntimeFor('consult');
|
|
3515
|
+
if (!rt) {
|
|
3516
|
+
await postAgentPlan({ id, error: 'no CLI on this machine can run a read-only turn' });
|
|
3517
|
+
return;
|
|
3518
|
+
}
|
|
3519
|
+
const liveAgents = (Array.isArray(job.liveAgents) ? job.liveAgents : []).map((a) => ({
|
|
3520
|
+
id: String(a?.id ?? ''),
|
|
3521
|
+
name: String(a?.name ?? ''),
|
|
3522
|
+
status: String(a?.status ?? ''),
|
|
3523
|
+
changedFiles: agentChangedFiles(a?.placeId),
|
|
3524
|
+
}));
|
|
3525
|
+
|
|
3526
|
+
let out = '';
|
|
3527
|
+
/** REMOVED IN A `finally`. `workChildren` is what `workBusy()` counts, and
|
|
3528
|
+
* a leaked entry keeps the daemon permanently "busy" — which blocks every
|
|
3529
|
+
* auto-update from that moment on, silently, until a restart. */
|
|
3530
|
+
let planChild = null;
|
|
3531
|
+
try {
|
|
3532
|
+
await inPlace(REPO_PLACE, false, async () => {
|
|
3533
|
+
out = await runTurn({
|
|
3534
|
+
prompt: AGENT_PLAN_KICKOFF({
|
|
3535
|
+
tasks,
|
|
3536
|
+
liveAgents,
|
|
3537
|
+
agentCap: Number.isInteger(job.agentCap) && job.agentCap > 0 ? job.agentCap : 3,
|
|
3538
|
+
}),
|
|
3539
|
+
system: SYSTEM_PLAN,
|
|
3540
|
+
// READ-ONLY, and no MCP: `mcpArgs` is omitted entirely rather than
|
|
3541
|
+
// passed empty, so there is no control plane on this turn at all.
|
|
3542
|
+
readOnly: true,
|
|
3543
|
+
cwd: repoRoot,
|
|
3544
|
+
runtime: rt,
|
|
3545
|
+
streamJson: true,
|
|
3546
|
+
answerFromResult: true,
|
|
3547
|
+
label: c.cyan('[plan]'),
|
|
3548
|
+
onSpawn: (ch) => {
|
|
3549
|
+
planChild = ch;
|
|
3550
|
+
workChildren.set(ch, null);
|
|
3551
|
+
},
|
|
3552
|
+
});
|
|
3553
|
+
});
|
|
3554
|
+
} catch (e) {
|
|
3555
|
+
await postAgentPlan({ id, error: envScrub(String(e?.message || e)).slice(0, 500) });
|
|
3556
|
+
return;
|
|
3557
|
+
} finally {
|
|
3558
|
+
if (planChild) workChildren.delete(planChild);
|
|
3559
|
+
}
|
|
3560
|
+
|
|
3561
|
+
const proposal = parseProposal(out);
|
|
3562
|
+
if (!proposal) {
|
|
3563
|
+
await postAgentPlan({
|
|
3564
|
+
id,
|
|
3565
|
+
// The CLI's own words when it has any — a turn that explained why it
|
|
3566
|
+
// could not plan is far more use than "planning failed".
|
|
3567
|
+
error: out.trim()
|
|
3568
|
+
? `the plan did not come back as JSON: ${envScrub(out).slice(0, 300)}`
|
|
3569
|
+
: 'the planning turn produced no output — the CLI may be signed out',
|
|
3570
|
+
});
|
|
3571
|
+
return;
|
|
3572
|
+
}
|
|
3573
|
+
await postAgentPlan({ id, proposal, sessionRef: repoRoot });
|
|
3574
|
+
};
|
|
3575
|
+
|
|
3576
|
+
const processAgentPlanJobs = (jobs) => {
|
|
3577
|
+
if (!Array.isArray(jobs) || jobs.length === 0) return;
|
|
3578
|
+
// ONE AT A TIME, and the cap is 1 rather than 5: a planning turn is a real
|
|
3579
|
+
// model call, and a machine handed three at once would run three CLIs to
|
|
3580
|
+
// answer questions nobody asked in that order.
|
|
3581
|
+
for (const job of jobs.slice(0, 1)) {
|
|
3582
|
+
const id = String(job?.id || '');
|
|
3583
|
+
if (!id || planning.has(id)) continue;
|
|
3584
|
+
planning.add(id);
|
|
3585
|
+
void runAgentPlan(job).finally(() => planning.delete(id));
|
|
3586
|
+
}
|
|
3587
|
+
};
|
|
3588
|
+
|
|
3589
|
+
// ── AGENT TURNS: one task per prompt ───────────────────────────────────────
|
|
3590
|
+
//
|
|
3591
|
+
// An agent is one CLI in one worktree on one branch, working its cards ONE AT
|
|
3592
|
+
// A TIME. The server types the next prompt when this one lands; this side
|
|
3593
|
+
// does the work and reports what happened.
|
|
3594
|
+
//
|
|
3595
|
+
// NO MCP ON THIS TURN AT ALL. Everything the agent needs to say fits in its
|
|
3596
|
+
// final JSON object, and everything it needs to PROVE is measured from git
|
|
3597
|
+
// here afterwards — an agent naming its own commit shas would be a receipt
|
|
3598
|
+
// pointing at whatever it liked. That is also what keeps this feature from
|
|
3599
|
+
// adding a token kind to a scope map that has silently shipped empty twice.
|
|
3600
|
+
//
|
|
3601
|
+
// UNLEASED, unlike a plan or a kill. The server hands out at most one turn
|
|
3602
|
+
// per agent per poll and its settle is conditional on the row still being
|
|
3603
|
+
// pending, so a second daemon cannot advance the queue twice. What it could
|
|
3604
|
+
// do is run a CLI twice in one worktree, which is what this in-flight set and
|
|
3605
|
+
// the place lock prevent — the same discipline `processWorkTurns` keeps.
|
|
3606
|
+
const agentTurns = new Set(); // turn ids in flight on this tick
|
|
3607
|
+
|
|
3608
|
+
/** Returns the server's reply, because it carries ONE instruction the machine
|
|
3609
|
+
* can act on immediately: `review: true` means the agent's queue just
|
|
3610
|
+
* emptied, so run the project's own check in the worktree we are already
|
|
3611
|
+
* standing in. A job lane for that would need a claim, a floor and a settle
|
|
3612
|
+
* to say something this reply already can. */
|
|
3613
|
+
/** Turn ids whose work is DONE but whose report has not landed. The same
|
|
3614
|
+
* skip-guard `pendingWorkReports` is for a tab, and for the same reason: a
|
|
3615
|
+
* settle that fails to POST must not re-run the turn — that is a second CLI,
|
|
3616
|
+
* a second set of commits, and the operator's quota spent again. */
|
|
3617
|
+
const agentReported = new Set();
|
|
3618
|
+
|
|
3619
|
+
const postAgentTurn = async (body) => {
|
|
3620
|
+
agentReported.add(String(body.turnId));
|
|
3621
|
+
try {
|
|
3622
|
+
const res = await fetch(AGENT_TURN_DONE_URL, {
|
|
3623
|
+
method: 'POST',
|
|
3624
|
+
headers: {
|
|
3625
|
+
Authorization: `Bearer ${FLEET_TOKEN}`,
|
|
3626
|
+
'User-Agent': USER_AGENT,
|
|
3627
|
+
'Content-Type': 'application/json',
|
|
3628
|
+
},
|
|
3629
|
+
signal: AbortSignal.timeout(30_000),
|
|
3630
|
+
body: JSON.stringify(body),
|
|
3631
|
+
});
|
|
3632
|
+
const j = await res.json().catch(() => null);
|
|
3633
|
+
// Landed. Forgetting it keeps the guard from growing without bound; a
|
|
3634
|
+
// re-offer of a settled turn is refused server-side anyway.
|
|
3635
|
+
if (res.ok) agentReported.delete(String(body.turnId));
|
|
3636
|
+
return j?.data ?? null;
|
|
3637
|
+
} catch {
|
|
3638
|
+
// Unsettled, and the id STAYS in the guard: the server expires the turn
|
|
3639
|
+
// and the agent lands in Stuck saying nobody ran it, which is far better
|
|
3640
|
+
// than running it again for six hours.
|
|
3641
|
+
return null;
|
|
3642
|
+
}
|
|
3643
|
+
};
|
|
3644
|
+
|
|
3645
|
+
const postAgentActivity = async (agentId, text) => {
|
|
3646
|
+
try {
|
|
3647
|
+
await fetch(AGENT_ACTIVITY_URL, {
|
|
3648
|
+
method: 'POST',
|
|
3649
|
+
headers: {
|
|
3650
|
+
Authorization: `Bearer ${FLEET_TOKEN}`,
|
|
3651
|
+
'User-Agent': USER_AGENT,
|
|
3652
|
+
'Content-Type': 'application/json',
|
|
3653
|
+
},
|
|
3654
|
+
signal: AbortSignal.timeout(15_000),
|
|
3655
|
+
body: JSON.stringify({ agentId, text }),
|
|
3656
|
+
});
|
|
3657
|
+
} catch {
|
|
3658
|
+
/* narration is a readout; losing a line costs nothing */
|
|
3659
|
+
}
|
|
3660
|
+
};
|
|
3661
|
+
|
|
3662
|
+
/**
|
|
3663
|
+
* DID THE ACCOUNT HIT A LIMIT?
|
|
3664
|
+
*
|
|
3665
|
+
* Deliberately a LITERAL MATCH on the few sentences the CLIs actually print,
|
|
3666
|
+
* and the matched line is relayed VERBATIM. It is a trigger, not a
|
|
3667
|
+
* classifier: nothing here decides what an error "means" or writes a sentence
|
|
3668
|
+
* of its own, because the product's own rule is that it relays and never
|
|
3669
|
+
* infers. The honest limit is that a phrasing nobody listed reads as an
|
|
3670
|
+
* ordinary failed turn — which lands the agent in Stuck with the CLI's words
|
|
3671
|
+
* attached, and is a perfectly survivable second-best.
|
|
3672
|
+
*/
|
|
3673
|
+
const LIMIT_PHRASES = [
|
|
3674
|
+
/usage limit reached/i,
|
|
3675
|
+
/rate limit/i,
|
|
3676
|
+
/you've reached your .* limit/i,
|
|
3677
|
+
/quota exceeded/i,
|
|
3678
|
+
/insufficient_quota/i,
|
|
3679
|
+
];
|
|
3680
|
+
const limitLine = (text) => {
|
|
3681
|
+
for (const line of String(text ?? '').split('\n')) {
|
|
3682
|
+
const t = line.trim();
|
|
3683
|
+
if (t && LIMIT_PHRASES.some((re) => re.test(t))) return envScrub(t).slice(0, 300);
|
|
3684
|
+
}
|
|
3685
|
+
return null;
|
|
3686
|
+
};
|
|
3687
|
+
|
|
3688
|
+
/** Every sha that landed on this branch between two points. Measured, never
|
|
3689
|
+
* asserted — this is the whole reason an agent is not asked for its own. */
|
|
3690
|
+
const commitsBetween = (wt, from) => {
|
|
3691
|
+
if (!from) return [];
|
|
3692
|
+
/**
|
|
3693
|
+
* `--not <base>` and `--no-merges`, because `from..HEAD` alone reports
|
|
3694
|
+
* BASE'S commits as this turn's receipts the moment anything folds base in
|
|
3695
|
+
* — which the stale path does on purpose before a merge. A receipt naming
|
|
3696
|
+
* somebody else's commit is worse than a missing one.
|
|
3697
|
+
*/
|
|
3698
|
+
const out = git(
|
|
3699
|
+
['log', '--format=%H', '--no-merges', `${from}..HEAD`, '--not', baseRef()],
|
|
3700
|
+
wt
|
|
3701
|
+
);
|
|
3702
|
+
return typeof out === 'string'
|
|
3703
|
+
? out.split('\n').map((x) => x.trim()).filter(Boolean).slice(0, 50)
|
|
3704
|
+
: [];
|
|
3705
|
+
};
|
|
3706
|
+
|
|
3707
|
+
const runAgentTurn = async (job) => {
|
|
3708
|
+
const turnId = String(job.id);
|
|
3709
|
+
const agentId = String(job.agentId || '');
|
|
3710
|
+
const place = String(job.placeId || '');
|
|
3711
|
+
if (!isSafePathSegment(place)) {
|
|
3712
|
+
await postAgentTurn({ turnId, outcome: 'nothing' });
|
|
3713
|
+
return;
|
|
3714
|
+
}
|
|
3715
|
+
|
|
3716
|
+
await inPlace(place, false, async () => {
|
|
3717
|
+
const dir = placeWtFor(place);
|
|
3718
|
+
if (!dir) {
|
|
3719
|
+
// No worktree and none could be cut. `nothing` rather than an invented
|
|
3720
|
+
// error: the board says the machine went quiet, which is true.
|
|
3721
|
+
await postAgentTurn({ turnId, outcome: 'nothing' });
|
|
3722
|
+
return;
|
|
3723
|
+
}
|
|
3724
|
+
const wt = dir.wt;
|
|
3725
|
+
// WHERE THE BRANCH WAS BEFORE THIS TURN, so the commits reported are the
|
|
3726
|
+
// ones this turn actually made.
|
|
3727
|
+
const before = (git(['rev-parse', 'HEAD'], wt) || '').trim() || null;
|
|
3728
|
+
const branch = (git(['symbolic-ref', '--quiet', '--short', 'HEAD'], wt) || '').trim() || null;
|
|
3729
|
+
|
|
3730
|
+
const rt = job.runtime || 'claude';
|
|
3731
|
+
if (!canRun(RUNTIMES[rt], 'build')) {
|
|
3732
|
+
await postAgentTurn({
|
|
3733
|
+
turnId,
|
|
3734
|
+
outcome: 'nothing',
|
|
3735
|
+
answer: `this machine cannot run ${rt}`,
|
|
3736
|
+
branch,
|
|
3737
|
+
worktree: wt,
|
|
3738
|
+
});
|
|
3739
|
+
return;
|
|
3740
|
+
}
|
|
3741
|
+
|
|
3742
|
+
// ONE AGENT IS ONE DIRECTORY, so the CLI's own cwd-keyed resume is exactly
|
|
3743
|
+
// right here — the ambiguity that forced per-tab session pinning in the
|
|
3744
|
+
// Workbench (many tabs, one place) cannot arise. The marker is what
|
|
3745
|
+
// distinguishes the first turn from every later one across restarts.
|
|
3746
|
+
const ranMarker = sessionMetaPath(wt, 'flowviant-agent-ran');
|
|
3747
|
+
/**
|
|
3748
|
+
* RESUME IS CLAUDE-ONLY, and that is a correctness rule rather than a
|
|
3749
|
+
* preference. Claude's `--continue` is CWD-keyed and one agent is one
|
|
3750
|
+
* directory, so it resumes exactly this agent. Codex's `resume --last` is
|
|
3751
|
+
* MACHINE-GLOBAL: it would cross-resume whichever conversation spoke most
|
|
3752
|
+
* recently anywhere on the box, which is the bug the Workbench fixed in
|
|
3753
|
+
* 0.69.0 by pinning per-tab ids. Until an agent pins its own thread id,
|
|
3754
|
+
* a codex agent starts fresh each turn — a worse turn, not a wrong one.
|
|
3755
|
+
*/
|
|
3756
|
+
const resume = rt === 'claude' && Boolean(ranMarker && existsSync(ranMarker));
|
|
3757
|
+
|
|
3758
|
+
let out = '';
|
|
3759
|
+
let child = null;
|
|
3760
|
+
try {
|
|
3761
|
+
out = await runTurn({
|
|
3762
|
+
prompt:
|
|
3763
|
+
job.kind === 'task' && job.task
|
|
3764
|
+
? AGENT_TASK_KICKOFF({
|
|
3765
|
+
agentName: job.agentName,
|
|
3766
|
+
task: job.task,
|
|
3767
|
+
position: job.position ?? 1,
|
|
3768
|
+
total: job.total ?? 1,
|
|
3769
|
+
})
|
|
3770
|
+
: AGENT_HUMAN_KICKOFF({
|
|
3771
|
+
agentName: job.agentName,
|
|
3772
|
+
message: job.body ?? '',
|
|
3773
|
+
askedByName: job.askedByName,
|
|
3774
|
+
task: job.task,
|
|
3775
|
+
position: job.position ?? 1,
|
|
3776
|
+
total: job.total ?? 1,
|
|
3777
|
+
}),
|
|
3778
|
+
system: SYSTEM_AGENT,
|
|
3779
|
+
cwd: wt,
|
|
3780
|
+
runtime: rt,
|
|
3781
|
+
resume,
|
|
3782
|
+
streamJson: true,
|
|
3783
|
+
answerFromResult: true,
|
|
3784
|
+
label: c.cyan('[agent]'),
|
|
3785
|
+
// The CLI's own tail, relayed. Throttled by the same rule the tab's
|
|
3786
|
+
// narrator keeps: overwritten, never appended, and ignored by the
|
|
3787
|
+
// board past ~90 seconds.
|
|
3788
|
+
onActivity: (a) => {
|
|
3789
|
+
const line = a?.label;
|
|
3790
|
+
if (!line) return;
|
|
3791
|
+
const now = Date.now();
|
|
3792
|
+
if (now - (lastAgentBeat.get(agentId) ?? 0) < 2_000) return;
|
|
3793
|
+
lastAgentBeat.set(agentId, now);
|
|
3794
|
+
void postAgentActivity(agentId, envScrub(String(line)).slice(0, 400));
|
|
3795
|
+
},
|
|
3796
|
+
onSpawn: (ch) => {
|
|
3797
|
+
child = ch;
|
|
3798
|
+
workChildren.set(ch, null);
|
|
3799
|
+
noteSessionGroup(agentId, ch.pid);
|
|
3800
|
+
},
|
|
3801
|
+
});
|
|
3802
|
+
} finally {
|
|
3803
|
+
if (child) workChildren.delete(child);
|
|
3804
|
+
if (ranMarker) {
|
|
3805
|
+
try {
|
|
3806
|
+
writeFileSync(ranMarker, '1');
|
|
3807
|
+
} catch {
|
|
3808
|
+
/* a missing marker only costs one un-resumed turn */
|
|
3809
|
+
}
|
|
3810
|
+
}
|
|
3811
|
+
}
|
|
3812
|
+
|
|
3813
|
+
const commits = commitsBetween(wt, before);
|
|
3814
|
+
const res = parseTurnResult(out);
|
|
3815
|
+
/**
|
|
3816
|
+
* A LIMIT IS ONLY A LIMIT WHEN THE TURN PRODUCED NOTHING.
|
|
3817
|
+
*
|
|
3818
|
+
* `limitLine` is a literal phrase match over the CLI's output, and the
|
|
3819
|
+
* output of a successful turn contains whatever the agent wrote — so a
|
|
3820
|
+
* card about rate limiting, or a summary mentioning one, parked every
|
|
3821
|
+
* agent on the project. Gating on "the turn declared no outcome" is what
|
|
3822
|
+
* makes the match mean what it says: the CLI failed and this is the
|
|
3823
|
+
* sentence it failed with.
|
|
3824
|
+
*/
|
|
3825
|
+
const limit = res ? null : limitLine(out);
|
|
3826
|
+
if (limit) {
|
|
3827
|
+
// EVERY agent parks, because the account is shared: one hitting the
|
|
3828
|
+
// limit means all of them have. The turn itself is reported as
|
|
3829
|
+
// `nothing` — it did not deliver and it did not ask.
|
|
3830
|
+
await postAgentParked(limit);
|
|
3831
|
+
await postAgentTurn({ turnId, outcome: 'nothing', answer: limit, branch, worktree: wt });
|
|
3832
|
+
return;
|
|
3833
|
+
}
|
|
3834
|
+
|
|
3835
|
+
if (!res) {
|
|
3836
|
+
await postAgentTurn({
|
|
3837
|
+
turnId,
|
|
3838
|
+
outcome: 'nothing',
|
|
3839
|
+
// The CLI's own words when it produced any. A turn that explained why
|
|
3840
|
+
// it stopped is far more use than "the agent stopped".
|
|
3841
|
+
answer: out.trim()
|
|
3842
|
+
? envScrub(out).slice(-1500)
|
|
3843
|
+
: 'the turn produced no output on the machine — its CLI may be signed out',
|
|
3844
|
+
...(commits.length ? { commits } : {}),
|
|
3845
|
+
branch,
|
|
3846
|
+
worktree: wt,
|
|
3847
|
+
});
|
|
3848
|
+
return;
|
|
3849
|
+
}
|
|
3850
|
+
const reply = await postAgentTurn({
|
|
3851
|
+
turnId,
|
|
3852
|
+
outcome: res.outcome,
|
|
3853
|
+
answer: envScrub(res.answer ?? '').slice(0, 8000),
|
|
3854
|
+
...(commits.length ? { commits } : {}),
|
|
3855
|
+
...(res.raised?.length ? { raised: res.raised } : {}),
|
|
3856
|
+
branch,
|
|
3857
|
+
worktree: wt,
|
|
3858
|
+
});
|
|
3859
|
+
// The queue just emptied. Run the project's own check HERE, in the
|
|
3860
|
+
// worktree we are already standing in and still hold the lock on.
|
|
3861
|
+
if (reply?.review === true) await runCheck(agentId, wt);
|
|
3862
|
+
});
|
|
3863
|
+
};
|
|
3864
|
+
|
|
3865
|
+
const lastAgentBeat = new Map(); // agentId -> last activity POST, ms
|
|
3866
|
+
|
|
3867
|
+
const postAgentParked = async (reason) => {
|
|
3868
|
+
try {
|
|
3869
|
+
await fetch(AGENT_PARKED_URL, {
|
|
3870
|
+
method: 'POST',
|
|
3871
|
+
headers: {
|
|
3872
|
+
Authorization: `Bearer ${FLEET_TOKEN}`,
|
|
3873
|
+
'User-Agent': USER_AGENT,
|
|
3874
|
+
'Content-Type': 'application/json',
|
|
3875
|
+
},
|
|
3876
|
+
signal: AbortSignal.timeout(15_000),
|
|
3877
|
+
body: JSON.stringify({ reason }),
|
|
3878
|
+
});
|
|
3879
|
+
} catch {
|
|
3880
|
+
/* the next turn will hit the same limit and try again */
|
|
3881
|
+
}
|
|
3882
|
+
};
|
|
3883
|
+
|
|
3884
|
+
const processAgentTurnJobs = (jobs) => {
|
|
3885
|
+
if (!Array.isArray(jobs) || jobs.length === 0) return;
|
|
3886
|
+
for (const job of jobs.slice(0, 4)) {
|
|
3887
|
+
const id = String(job?.id || '');
|
|
3888
|
+
if (!id || agentTurns.has(id)) continue;
|
|
3889
|
+
// Already RAN here; only the report is outstanding. Re-running it would
|
|
3890
|
+
// spend the operator's quota again and write a second set of commits.
|
|
3891
|
+
if (agentReported.has(id)) continue;
|
|
3892
|
+
if (!job.agentId || !job.placeId) continue;
|
|
3893
|
+
agentTurns.add(id);
|
|
3894
|
+
void runAgentTurn(job).finally(() => agentTurns.delete(id));
|
|
3895
|
+
}
|
|
3896
|
+
};
|
|
3897
|
+
|
|
3898
|
+
// ── THE PROJECT'S OWN CHECK, and the MERGE ─────────────────────────────────
|
|
3899
|
+
//
|
|
3900
|
+
// The check runs in the agent's own worktree the moment its queue empties, so
|
|
3901
|
+
// a reviewer knows before they start reading whether they are reviewing
|
|
3902
|
+
// working code. It LABELS the review row; it never blocks it.
|
|
3903
|
+
//
|
|
3904
|
+
// IT IS THE REPO'S COMMAND, DECLARED IN THE REPO. `.flowviant/check.json`,
|
|
3905
|
+
// beside `deploy.json`, because a check travels with the code and changes
|
|
3906
|
+
// with it — a setting in the app would go stale the first time somebody
|
|
3907
|
+
// renamed a script. An absent file is a MEASURED answer ('none'), not a nag:
|
|
3908
|
+
// plenty of projects have no single command that means "is this alright".
|
|
3909
|
+
//
|
|
3910
|
+
// It runs through a shell, and that is no wider than what already happens in
|
|
3911
|
+
// that directory: every turn in this worktree spawns a CLI with build
|
|
3912
|
+
// permissions, so a repository that can run arbitrary code during a turn can
|
|
3913
|
+
// run it here too. What this is NOT is the deleted dev-run supervisor —
|
|
3914
|
+
// nothing here resolves a command, guesses a stack, or starts a server.
|
|
3915
|
+
const CHECK_TIMEOUT_MS = 10 * 60_000;
|
|
3916
|
+
const CHECK_OUTPUT_CAP = 4000;
|
|
3917
|
+
|
|
3918
|
+
const readCheckCommand = () => {
|
|
3919
|
+
try {
|
|
3920
|
+
const raw = readFileSync(join(repoRoot, '.flowviant', 'check.json'), 'utf8');
|
|
3921
|
+
const cfg = JSON.parse(raw);
|
|
3922
|
+
const cmd = typeof cfg?.command === 'string' ? cfg.command.trim() : '';
|
|
3923
|
+
return cmd ? cmd.slice(0, 500) : null;
|
|
3924
|
+
} catch {
|
|
3925
|
+
return null; // absent, unreadable or not JSON — all mean "no check"
|
|
3926
|
+
}
|
|
3927
|
+
};
|
|
3928
|
+
|
|
3929
|
+
const postCheck = async (body) => {
|
|
3930
|
+
try {
|
|
3931
|
+
await fetch(AGENT_CHECK_DONE_URL, {
|
|
3932
|
+
method: 'POST',
|
|
3933
|
+
headers: {
|
|
3934
|
+
Authorization: `Bearer ${FLEET_TOKEN}`,
|
|
3935
|
+
'User-Agent': USER_AGENT,
|
|
3936
|
+
'Content-Type': 'application/json',
|
|
3937
|
+
},
|
|
3938
|
+
signal: AbortSignal.timeout(30_000),
|
|
3939
|
+
body: JSON.stringify(body),
|
|
3940
|
+
});
|
|
3941
|
+
} catch {
|
|
3942
|
+
/* the row simply keeps its previous answer, which is null the first time */
|
|
3943
|
+
}
|
|
3944
|
+
};
|
|
3945
|
+
|
|
3946
|
+
const runCheck = async (agentId, wt) => {
|
|
3947
|
+
const cmd = readCheckCommand();
|
|
3948
|
+
const headSha = (git(['rev-parse', 'HEAD'], wt) || '').trim() || undefined;
|
|
3949
|
+
if (!cmd) {
|
|
3950
|
+
await postCheck({ agentId, status: 'none', ...(headSha ? { headSha } : {}) });
|
|
3951
|
+
return;
|
|
3952
|
+
}
|
|
3953
|
+
const out = await new Promise((resolve) => {
|
|
3954
|
+
let text = '';
|
|
3955
|
+
let done = false;
|
|
3956
|
+
const finish = (status) => {
|
|
3957
|
+
if (done) return;
|
|
3958
|
+
done = true;
|
|
3959
|
+
resolve({ status, text });
|
|
3960
|
+
};
|
|
3961
|
+
let child;
|
|
3962
|
+
try {
|
|
3963
|
+
// DETACHED, so the child's pid is its PROCESS GROUP. A check is almost
|
|
3964
|
+
// always a shell that spawns the real runner, and signalling the shell
|
|
3965
|
+
// alone leaves the runner holding the worktree — and this place's
|
|
3966
|
+
// WRITER lock — for as long as it likes.
|
|
3967
|
+
child = spawn(cmd, {
|
|
3968
|
+
cwd: wt,
|
|
3969
|
+
shell: true,
|
|
3970
|
+
detached: true,
|
|
3971
|
+
stdio: ['ignore', 'pipe', 'pipe'],
|
|
3972
|
+
});
|
|
3973
|
+
} catch (e) {
|
|
3974
|
+
// TEXT BEFORE FINISH: `finish` captures `text` by value into the
|
|
3975
|
+
// resolved object, so assigning afterwards threw the spawn error away
|
|
3976
|
+
// and the surface showed an empty failure.
|
|
3977
|
+
text = String(e?.message || e);
|
|
3978
|
+
finish('failed');
|
|
3979
|
+
return;
|
|
3980
|
+
}
|
|
3981
|
+
// The TAIL, not the head: a failing check says why at the end.
|
|
3982
|
+
const keep = (buf) => {
|
|
3983
|
+
text = (text + buf.toString()).slice(-CHECK_OUTPUT_CAP);
|
|
3984
|
+
};
|
|
3985
|
+
child.stdout?.on('data', keep);
|
|
3986
|
+
child.stderr?.on('data', keep);
|
|
3987
|
+
const timer = setTimeout(() => {
|
|
3988
|
+
try {
|
|
3989
|
+
// The GROUP, not the child: killing the shell leaves whatever it
|
|
3990
|
+
// started running, which is the thing actually taking ten minutes.
|
|
3991
|
+
process.kill(-child.pid, 'SIGKILL');
|
|
3992
|
+
} catch {
|
|
3993
|
+
try {
|
|
3994
|
+
child.kill('SIGKILL');
|
|
3995
|
+
} catch {
|
|
3996
|
+
/* already gone */
|
|
3997
|
+
}
|
|
3998
|
+
}
|
|
3999
|
+
text += '\n[flowviant] the check ran past ten minutes and was stopped';
|
|
4000
|
+
// RESOLVE HERE TOO. Waiting for 'close' after a kill is the shape that
|
|
4001
|
+
// hangs: if the group is already gone the event never arrives, and this
|
|
4002
|
+
// promise holds the place's writer lock forever.
|
|
4003
|
+
finish('failed');
|
|
4004
|
+
}, CHECK_TIMEOUT_MS);
|
|
4005
|
+
child.on('error', (e) => {
|
|
4006
|
+
clearTimeout(timer);
|
|
4007
|
+
text += String(e?.message || e);
|
|
4008
|
+
finish('failed');
|
|
4009
|
+
});
|
|
4010
|
+
child.on('close', (code) => {
|
|
4011
|
+
clearTimeout(timer);
|
|
4012
|
+
finish(code === 0 ? 'passed' : 'failed');
|
|
4013
|
+
});
|
|
4014
|
+
});
|
|
4015
|
+
await postCheck({
|
|
4016
|
+
agentId,
|
|
4017
|
+
status: out.status,
|
|
4018
|
+
output: envScrub(out.text).slice(-CHECK_OUTPUT_CAP),
|
|
4019
|
+
...(headSha ? { headSha } : {}),
|
|
4020
|
+
});
|
|
4021
|
+
};
|
|
4022
|
+
|
|
4023
|
+
// ── THE MERGE ──────────────────────────────────────────────────────────────
|
|
4024
|
+
//
|
|
4025
|
+
// LEASED, because two `git merge --no-ff` and two pushes over one branch is
|
|
4026
|
+
// the loudest duplicate this system can produce. It reuses `mergeOutward`
|
|
4027
|
+
// verbatim — the same throwaway-worktree merge, the same once-only retry when
|
|
4028
|
+
// two people land at the same moment — because an agent's branch is not
|
|
4029
|
+
// special: it is a branch, and this repo already knows how to land one.
|
|
4030
|
+
//
|
|
4031
|
+
// A SUCCESS CLOSES NOTHING. Done stays OBSERVED: the merge reaches base, the
|
|
4032
|
+
// landed observer's own fetch sees it, and the cards close there.
|
|
4033
|
+
const agentMerges = new Set(); // agent ids in flight on this tick
|
|
4034
|
+
|
|
4035
|
+
const claimAgentMerge = async (agentId) => {
|
|
4036
|
+
try {
|
|
4037
|
+
const res = await fetch(AGENT_MERGE_CLAIM_URL, {
|
|
4038
|
+
method: 'POST',
|
|
4039
|
+
headers: {
|
|
4040
|
+
Authorization: `Bearer ${FLEET_TOKEN}`,
|
|
4041
|
+
'User-Agent': USER_AGENT,
|
|
4042
|
+
'Content-Type': 'application/json',
|
|
4043
|
+
},
|
|
4044
|
+
signal: AbortSignal.timeout(15_000),
|
|
4045
|
+
body: JSON.stringify({ agentId, instance: DAEMON_INSTANCE }),
|
|
4046
|
+
});
|
|
4047
|
+
const j = await res.json().catch(() => null);
|
|
4048
|
+
return j?.data?.claimed === true;
|
|
4049
|
+
} catch {
|
|
4050
|
+
return false; // the peer may hold it; doing nothing is the safe answer
|
|
4051
|
+
}
|
|
4052
|
+
};
|
|
4053
|
+
|
|
4054
|
+
const postAgentMerge = async (body) => {
|
|
4055
|
+
try {
|
|
4056
|
+
await fetch(AGENT_MERGE_DONE_URL, {
|
|
4057
|
+
method: 'POST',
|
|
4058
|
+
headers: {
|
|
4059
|
+
Authorization: `Bearer ${FLEET_TOKEN}`,
|
|
4060
|
+
'User-Agent': USER_AGENT,
|
|
4061
|
+
'Content-Type': 'application/json',
|
|
4062
|
+
},
|
|
4063
|
+
signal: AbortSignal.timeout(30_000),
|
|
4064
|
+
body: JSON.stringify({ ...body, instance: DAEMON_INSTANCE }),
|
|
4065
|
+
});
|
|
4066
|
+
} catch {
|
|
4067
|
+
/* the lease lapses and the job is re-offered — a merge is idempotent
|
|
4068
|
+
against an already-merged branch, which mergeOutward detects */
|
|
4069
|
+
}
|
|
4070
|
+
};
|
|
4071
|
+
|
|
4072
|
+
/**
|
|
4073
|
+
* A merge COMMIT needs a git identity and the machine may have none. Prefer
|
|
4074
|
+
* the operator's own config; fall back to the daemon's, the same fallback
|
|
4075
|
+
* `checkpointWip` and ship both use, so a bare machine does not fail the fold
|
|
4076
|
+
* with "Please tell me who you are".
|
|
4077
|
+
*/
|
|
4078
|
+
const gitMerge = (args, cwd) => {
|
|
4079
|
+
let idEnv = null;
|
|
4080
|
+
try {
|
|
4081
|
+
git(['config', 'user.email'], repoRoot);
|
|
4082
|
+
} catch {
|
|
4083
|
+
idEnv = {
|
|
4084
|
+
GIT_AUTHOR_NAME: 'Flowviant',
|
|
4085
|
+
GIT_AUTHOR_EMAIL: 'daemon@flowviant.com',
|
|
4086
|
+
GIT_COMMITTER_NAME: 'Flowviant',
|
|
4087
|
+
GIT_COMMITTER_EMAIL: 'daemon@flowviant.com',
|
|
4088
|
+
};
|
|
4089
|
+
}
|
|
4090
|
+
return execFileSync('git', args, {
|
|
4091
|
+
cwd,
|
|
4092
|
+
encoding: 'utf8',
|
|
4093
|
+
stdio: ['ignore', 'pipe', 'pipe'],
|
|
4094
|
+
...(idEnv ? { env: { ...process.env, ...idEnv } } : {}),
|
|
4095
|
+
});
|
|
4096
|
+
};
|
|
4097
|
+
|
|
4098
|
+
const runAgentMerge = async (job) => {
|
|
4099
|
+
const agentId = String(job.agentId);
|
|
4100
|
+
const place = String(job.placeId || '');
|
|
4101
|
+
if (!isSafePathSegment(place)) {
|
|
4102
|
+
await postAgentMerge({ agentId, ok: false, detail: 'the agent has no worktree here' });
|
|
4103
|
+
return;
|
|
4104
|
+
}
|
|
4105
|
+
if (!(await claimAgentMerge(agentId))) return;
|
|
4106
|
+
|
|
4107
|
+
// A WRITER on the place, exactly as a ship is. It folds base in and pushes
|
|
4108
|
+
// with git in that directory, and no CLI can coordinate with something it
|
|
4109
|
+
// does not know exists.
|
|
4110
|
+
await inPlace(place, true, async () => {
|
|
4111
|
+
const wt = join(baseDir, 'sessions', place);
|
|
4112
|
+
if (!existsSync(wt)) {
|
|
4113
|
+
await postAgentMerge({ agentId, ok: false, detail: 'the worktree is gone' });
|
|
4114
|
+
return;
|
|
4115
|
+
}
|
|
4116
|
+
try {
|
|
4117
|
+
git(['fetch', 'origin', '--quiet'], repoRoot);
|
|
4118
|
+
} catch {
|
|
4119
|
+
/* offline — the merge fails honestly below */
|
|
4120
|
+
}
|
|
4121
|
+
// STALE means base moved under this branch while it sat in review. Fold
|
|
4122
|
+
// base IN first so the merge that follows is against what is actually
|
|
4123
|
+
// there; a conflict here is the same conflict the merge would hit, found
|
|
4124
|
+
// one step earlier and in the agent's own directory where it can be
|
|
4125
|
+
// resolved.
|
|
4126
|
+
if (job.stale) {
|
|
4127
|
+
try {
|
|
4128
|
+
gitMerge(['merge', '--no-edit', baseRef()], wt);
|
|
4129
|
+
} catch (e) {
|
|
4130
|
+
await postAgentMerge({
|
|
4131
|
+
agentId,
|
|
4132
|
+
ok: false,
|
|
4133
|
+
detail: envScrub(String(e?.message || e)).slice(0, 2000),
|
|
4134
|
+
});
|
|
4135
|
+
return;
|
|
4136
|
+
}
|
|
4137
|
+
// The branch changed, so the previous check answered about a different
|
|
4138
|
+
// tree. Re-run it before anything merges.
|
|
4139
|
+
await runCheck(agentId, wt);
|
|
4140
|
+
}
|
|
4141
|
+
// `git()` THROWS on a non-zero exit, and `symbolic-ref` exits non-zero on
|
|
4142
|
+
// a detached HEAD — so the guard below was unreachable and the throw
|
|
4143
|
+
// escaped the claimed merge, leaving it unsettled until its lease lapsed.
|
|
4144
|
+
let branch = '';
|
|
4145
|
+
try {
|
|
4146
|
+
branch = (git(['symbolic-ref', '--quiet', '--short', 'HEAD'], wt) || '').trim();
|
|
4147
|
+
} catch {
|
|
4148
|
+
branch = '';
|
|
4149
|
+
}
|
|
4150
|
+
if (!branch) {
|
|
4151
|
+
// A detached HEAD names no branch, so there is nothing to merge and
|
|
4152
|
+
// nothing to record. An ambiguity in git, not a rule of ours.
|
|
4153
|
+
await postAgentMerge({ agentId, ok: false, detail: 'this worktree is on a detached HEAD' });
|
|
4154
|
+
return;
|
|
4155
|
+
}
|
|
4156
|
+
const tip = (git(['rev-parse', 'HEAD'], wt) || '').trim();
|
|
4157
|
+
const countOut = git(['rev-list', '--count', `${baseRef()}..HEAD`], wt);
|
|
4158
|
+
const count = Number((countOut || '0').trim()) || 0;
|
|
4159
|
+
if (count === 0) {
|
|
4160
|
+
// Already on base — an idempotent re-offer, or an agent that changed
|
|
4161
|
+
// nothing. Reported as a SUCCESS: the branch's work is on base, which
|
|
4162
|
+
// is what the caller is asking about.
|
|
4163
|
+
await postAgentMerge({ agentId, ok: true, sha: tip || undefined });
|
|
4164
|
+
return;
|
|
4165
|
+
}
|
|
4166
|
+
try {
|
|
4167
|
+
shipMergeOutward({
|
|
4168
|
+
tip,
|
|
4169
|
+
count,
|
|
4170
|
+
branch,
|
|
4171
|
+
label: job.agentName || place.slice(0, 12),
|
|
4172
|
+
git,
|
|
4173
|
+
gitMerge,
|
|
4174
|
+
repoRoot,
|
|
4175
|
+
tmpDir: join(baseDir, 'ship', place),
|
|
4176
|
+
baseRef,
|
|
4177
|
+
workingTree: wt,
|
|
4178
|
+
warn,
|
|
4179
|
+
});
|
|
4180
|
+
} catch (e) {
|
|
4181
|
+
await postAgentMerge({
|
|
4182
|
+
agentId,
|
|
4183
|
+
ok: false,
|
|
4184
|
+
detail: envScrub(String(e?.message || e)).slice(0, 2000),
|
|
4185
|
+
});
|
|
4186
|
+
return;
|
|
4187
|
+
}
|
|
4188
|
+
await postAgentMerge({ agentId, ok: true, sha: tip });
|
|
4189
|
+
onRepoChanged();
|
|
4190
|
+
landed.observe();
|
|
4191
|
+
});
|
|
4192
|
+
};
|
|
4193
|
+
|
|
4194
|
+
const processAgentMergeJobs = (jobs) => {
|
|
4195
|
+
if (!Array.isArray(jobs) || jobs.length === 0) return;
|
|
4196
|
+
for (const job of jobs.slice(0, 2)) {
|
|
4197
|
+
const id = String(job?.agentId || '');
|
|
4198
|
+
if (!id || agentMerges.has(id)) continue;
|
|
4199
|
+
agentMerges.add(id);
|
|
4200
|
+
void runAgentMerge(job).finally(() => agentMerges.delete(id));
|
|
4201
|
+
}
|
|
4202
|
+
};
|
|
4203
|
+
|
|
4204
|
+
/**
|
|
4205
|
+
* KEEP EACH PERSON'S MANUAL WORKTREE FRESH.
|
|
4206
|
+
*
|
|
4207
|
+
* Every teammate's Workbench tabs share one directory of their own on a
|
|
4208
|
+
* branch of their own — which is not a preference, it is what git allows:
|
|
4209
|
+
* two worktrees cannot have the same branch checked out, so "everyone works
|
|
4210
|
+
* on main" is only true for the machine's OPERATOR, whose place is the
|
|
4211
|
+
* checkout itself. Everyone else needs a branch, and a branch left alone
|
|
4212
|
+
* drifts behind main until the first thing they do in a new tab is a merge
|
|
4213
|
+
* they did not ask for.
|
|
4214
|
+
*
|
|
4215
|
+
* FAST-FORWARD ONLY, and that is the whole safety argument. If their branch
|
|
4216
|
+
* has no commits of its own it simply catches up, which is the ordinary case
|
|
4217
|
+
* and the one worth automating. The moment it HAS diverged, this stops and
|
|
4218
|
+
* leaves it exactly as it is: their commits are theirs, a rebase would
|
|
4219
|
+
* rewrite them under somebody who is not looking, and a merge would put a
|
|
4220
|
+
* commit in their history that they did not make. Their own Claude can fold
|
|
4221
|
+
* base in whenever they ask it to.
|
|
4222
|
+
*
|
|
4223
|
+
* Guarded three ways: a DIRTY tree is left alone (uncommitted work outranks
|
|
4224
|
+
* freshness), a place with a live lock is skipped (a turn is standing in it),
|
|
4225
|
+
* and the whole thing is silent — nothing here reports, warns or blocks.
|
|
4226
|
+
*/
|
|
4227
|
+
const freshenManualPlaces = () => {
|
|
4228
|
+
let dir;
|
|
4229
|
+
try {
|
|
4230
|
+
dir = readdirSync(join(baseDir, 'sessions'));
|
|
4231
|
+
} catch {
|
|
4232
|
+
return; // no worktrees yet
|
|
4233
|
+
}
|
|
4234
|
+
for (const place of dir) {
|
|
4235
|
+
// Only a PERSON's manual place. An agent's own worktree (`a-<id>`) is
|
|
4236
|
+
// deliberately not touched: its branch is the reviewable unit, and
|
|
4237
|
+
// moving it under a review would change what somebody is deciding about.
|
|
4238
|
+
if (!place.startsWith('u-') || !isSafePathSegment(place)) continue;
|
|
4239
|
+
if (placeLocks.has(place)) continue;
|
|
4240
|
+
const wt = join(baseDir, 'sessions', place);
|
|
4241
|
+
try {
|
|
4242
|
+
if (git(['status', '--porcelain'], wt).trim() !== '') continue;
|
|
4243
|
+
git(['merge', '--ff-only', baseRef()], wt);
|
|
4244
|
+
} catch {
|
|
4245
|
+
/* diverged, or something else is going on in there. Leave it. */
|
|
4246
|
+
}
|
|
4247
|
+
}
|
|
4248
|
+
};
|
|
4249
|
+
|
|
3378
4250
|
const workBusy = () =>
|
|
3379
4251
|
placeLocks.size > 0 ||
|
|
4252
|
+
// A planning turn is a live CLI child of ours, and an auto-update that
|
|
4253
|
+
// SIGTERMs it mid-flight would leave a press claimed, unsettled and
|
|
4254
|
+
// waiting out its lease while somebody watches a spinner.
|
|
4255
|
+
planning.size > 0 ||
|
|
4256
|
+
// An agent turn is real work in a real worktree. Killed mid-flight it
|
|
4257
|
+
// leaves uncommitted edits and a turn the server will eventually expire
|
|
4258
|
+
// into a card nobody can explain.
|
|
4259
|
+
agentTurns.size > 0 ||
|
|
4260
|
+
agentMerges.size > 0 ||
|
|
3380
4261
|
shipping.size > 0 ||
|
|
3381
4262
|
workChildren.size > 0 ||
|
|
3382
4263
|
workAnswering.size > 0 ||
|
|
@@ -3400,5 +4281,9 @@ export function createWorkManager({
|
|
|
3400
4281
|
reportWorktrees,
|
|
3401
4282
|
shutdownWork,
|
|
3402
4283
|
workBusy,
|
|
4284
|
+
processAgentPlanJobs,
|
|
4285
|
+
processAgentTurnJobs,
|
|
4286
|
+
processAgentMergeJobs,
|
|
4287
|
+
freshenManualPlaces,
|
|
3403
4288
|
};
|
|
3404
4289
|
}
|