flowviant 0.41.0 → 0.44.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/claude.mjs +43 -437
- package/bin/lib/fleet.mjs +274 -49
- package/bin/lib/live.mjs +29 -1
- package/bin/lib/patch.mjs +73 -5
- package/bin/lib/prompts.mjs +610 -0
- package/bin/lib/runtimes.mjs +48 -7
- package/bin/lib/work.mjs +875 -0
- package/package.json +1 -1
package/bin/lib/fleet.mjs
CHANGED
|
@@ -5,7 +5,13 @@
|
|
|
5
5
|
* MCP token, and only spawns Claude when the server says an agent has work.
|
|
6
6
|
*/
|
|
7
7
|
|
|
8
|
-
import {
|
|
8
|
+
import {
|
|
9
|
+
mkdirSync,
|
|
10
|
+
existsSync,
|
|
11
|
+
rmSync,
|
|
12
|
+
readdirSync,
|
|
13
|
+
statSync,
|
|
14
|
+
} from 'node:fs';
|
|
9
15
|
import { execFileSync } from 'node:child_process';
|
|
10
16
|
import { createHash } from 'node:crypto';
|
|
11
17
|
import { homedir } from 'node:os';
|
|
@@ -57,8 +63,8 @@ import {
|
|
|
57
63
|
SYSTEM_PLAN_CHECK,
|
|
58
64
|
PLAN_CHECK_KICKOFF,
|
|
59
65
|
REGROUND_KICKOFF,
|
|
60
|
-
|
|
61
|
-
|
|
66
|
+
SYSTEM_PLAN,
|
|
67
|
+
PLAN_TURN_KICKOFF,
|
|
62
68
|
SYSTEM_QUICK_EDIT,
|
|
63
69
|
QUICK_EDIT_KICKOFF,
|
|
64
70
|
} from './claude.mjs';
|
|
@@ -77,6 +83,7 @@ import {
|
|
|
77
83
|
import { processDeployJobs, reportDeployConfig } from './deploy.mjs';
|
|
78
84
|
import { machineSnapshot } from './resources.mjs';
|
|
79
85
|
import { detectRuntimes, pickRuntimeFor, RUNTIMES } from './runtimes.mjs';
|
|
86
|
+
import { createWorkManager } from './work.mjs';
|
|
80
87
|
|
|
81
88
|
async function fetchRoster(haveIds) {
|
|
82
89
|
const url = new URL(FLEET_URL);
|
|
@@ -451,6 +458,7 @@ export async function runFleetDaemon() {
|
|
|
451
458
|
const workers = new Map(); // agentId -> { state, promise, wt, label }
|
|
452
459
|
let daemonAlive = true; // flipped false on shutdown so the stream stops reconnecting
|
|
453
460
|
let stream = null; // push channel handle (set once the loop is set up)
|
|
461
|
+
let workShutdown = null; // kills live session-turn CLIs (set with the work manager below)
|
|
454
462
|
|
|
455
463
|
// Shutdown KEEPS the worktrees: in-flight local work survives Ctrl+C and
|
|
456
464
|
// resumes in place on the next run (the task marker matches). Worktrees are
|
|
@@ -471,6 +479,14 @@ export async function runFleetDaemon() {
|
|
|
471
479
|
} catch {
|
|
472
480
|
/* best-effort */
|
|
473
481
|
}
|
|
482
|
+
// Session-turn CLIs die with the daemon too: an orphan keeps editing the
|
|
483
|
+
// session worktree and burning quota, and its live-pid lock would make the
|
|
484
|
+
// restarted daemon skip that tab's turns for as long as it survived.
|
|
485
|
+
try {
|
|
486
|
+
workShutdown?.();
|
|
487
|
+
} catch {
|
|
488
|
+
/* best-effort */
|
|
489
|
+
}
|
|
474
490
|
for (const [, w] of workers) {
|
|
475
491
|
w.state.alive = false;
|
|
476
492
|
try {
|
|
@@ -494,6 +510,15 @@ export async function runFleetDaemon() {
|
|
|
494
510
|
teardown();
|
|
495
511
|
process.exit(130);
|
|
496
512
|
});
|
|
513
|
+
// A service manager stops the daemon with SIGTERM, not Ctrl+C. Without this
|
|
514
|
+
// handler every child survived a `systemctl stop` — the exact orphaning the
|
|
515
|
+
// teardown exists to prevent.
|
|
516
|
+
process.on('SIGTERM', () => {
|
|
517
|
+
console.log('');
|
|
518
|
+
note('shutting down (SIGTERM) — stopping workers. Worktrees are kept: in-flight work resumes next run.');
|
|
519
|
+
teardown();
|
|
520
|
+
process.exit(143);
|
|
521
|
+
});
|
|
497
522
|
// Keep the daemon alive on a stray rejection. Many loops here are fire-and-
|
|
498
523
|
// forget (`void drainWiki()`, dispatch, sync) and rely on their callees never
|
|
499
524
|
// rejecting; Node ≥15 terminates the process on an unhandled rejection, which
|
|
@@ -748,21 +773,155 @@ export async function runFleetDaemon() {
|
|
|
748
773
|
}
|
|
749
774
|
};
|
|
750
775
|
|
|
751
|
-
//
|
|
752
|
-
//
|
|
753
|
-
//
|
|
776
|
+
// ── Planning sessions ────────────────────────────────────────────────────
|
|
777
|
+
//
|
|
778
|
+
// A turn in a plan thread, answered inside a HELD session. This was the
|
|
779
|
+
// consult, which answered one question in prose and kept nothing: it existed
|
|
780
|
+
// because the planner was a different, weaker brain and this turn's only job
|
|
781
|
+
// was to correct it from the real code. That planner is gone, so the session
|
|
782
|
+
// reads the repo AND writes the plan, over many turns, in one context.
|
|
783
|
+
//
|
|
784
|
+
// Two things changed shape as a result.
|
|
785
|
+
//
|
|
786
|
+
// ONE WORKTREE PER PLAN, not the shared `wikiWt`. Every CLI here resumes with
|
|
787
|
+
// "continue the last session in this directory" (`--continue`, `resume
|
|
788
|
+
// --last`) rather than by session id, so the WORKING DIRECTORY *is* the
|
|
789
|
+
// session handle. A shared directory would have made two plans on one machine
|
|
790
|
+
// take turns wearing each other's context — and the wiki queue hard-resets
|
|
791
|
+
// that directory between tasks, which would pull the files out from under a
|
|
792
|
+
// session mid-argument. A private detached checkout per plan also means plan
|
|
793
|
+
// turns no longer queue behind the wiki lock.
|
|
794
|
+
//
|
|
795
|
+
// IT CARRIES MCP. A consult passed none — nothing to write. A session spawns
|
|
796
|
+
// slices, re-shapes them, drops them and maintains the spec, all of which are
|
|
797
|
+
// control-plane calls. The token is the fleet's PLAN principal, whose entire
|
|
798
|
+
// tool set is those five: it cannot claim, cannot open a worktree, cannot
|
|
799
|
+
// commit. That absence is the product rule, not a hardening measure — it is
|
|
800
|
+
// what makes "add a dark mode toggle" typed at a plan add a slice instead of
|
|
801
|
+
// building one, with nothing reading the sentence to decide.
|
|
754
802
|
const CONSULT_DONE_URL = FLEET_URL.replace(/\/agents\/?$/, '/consult-done');
|
|
803
|
+
const PLAN_TOKEN_URL = FLEET_URL.replace(/\/agents\/?$/, '/plan-token');
|
|
755
804
|
const answering = new Set();
|
|
756
|
-
const consultAttempts = new Map(); //
|
|
757
|
-
/** Give up after this many turns on one
|
|
805
|
+
const consultAttempts = new Map(); // turn id -> tries
|
|
806
|
+
/** Give up after this many turns on one message. A /consult-done that never
|
|
758
807
|
* reaches the server (offline, 500) would otherwise re-run the whole Claude
|
|
759
808
|
* turn every poll, forever, on the owner's quota. */
|
|
760
809
|
const MAX_CONSULT_TRIES = 3;
|
|
761
|
-
/** ONE
|
|
762
|
-
*
|
|
763
|
-
* concurrent
|
|
810
|
+
/** ONE planning turn at a time on this machine. Sessions are per-plan so they
|
|
811
|
+
* no longer collide on a directory, but the roster can hand back a batch, and
|
|
812
|
+
* un-awaited spawns would put N concurrent CLI processes on someone's laptop
|
|
813
|
+
* for what is, on the human's side, a chat. */
|
|
764
814
|
let consultChain = Promise.resolve();
|
|
765
815
|
|
|
816
|
+
/**
|
|
817
|
+
* The plan credential, cached until it stops working.
|
|
818
|
+
*
|
|
819
|
+
* Minted lazily rather than at startup: most daemons never host a planning
|
|
820
|
+
* session, and a token nobody uses is a credential sitting on disk for no
|
|
821
|
+
* reason. Rotated by the server on every mint, so a re-mint after a 401 is the
|
|
822
|
+
* recovery path.
|
|
823
|
+
*/
|
|
824
|
+
let planToken = null;
|
|
825
|
+
const mintPlanToken = async (force = false) => {
|
|
826
|
+
if (planToken && !force) return planToken;
|
|
827
|
+
try {
|
|
828
|
+
const res = await fetch(PLAN_TOKEN_URL, {
|
|
829
|
+
method: 'POST',
|
|
830
|
+
headers: { Authorization: `Bearer ${FLEET_TOKEN}`, 'User-Agent': USER_AGENT },
|
|
831
|
+
});
|
|
832
|
+
if (!res.ok) return null;
|
|
833
|
+
const data = await res.json().catch(() => null);
|
|
834
|
+
planToken = data?.data?.token ?? null;
|
|
835
|
+
return planToken;
|
|
836
|
+
} catch {
|
|
837
|
+
return null;
|
|
838
|
+
}
|
|
839
|
+
};
|
|
840
|
+
|
|
841
|
+
/**
|
|
842
|
+
* This plan's session directory — its context, expressed as a place.
|
|
843
|
+
*
|
|
844
|
+
* A detached checkout at base, like a consult's, but PRIVATE and PERSISTENT:
|
|
845
|
+
* private so `--continue` resumes this argument rather than whichever ran last
|
|
846
|
+
* on the box, persistent so it survives the daemon restarting or updating
|
|
847
|
+
* under it. Re-pointed at the current base each turn, because "reads your
|
|
848
|
+
* code" has to mean the code as it is now — a plan that runs for days would
|
|
849
|
+
* otherwise keep answering from the commit it was opened at.
|
|
850
|
+
*
|
|
851
|
+
* Returns null when the id is not a safe path segment: it comes off the wire.
|
|
852
|
+
*/
|
|
853
|
+
const planWtFor = (planId) => {
|
|
854
|
+
if (!isSafePathSegment(planId)) return null;
|
|
855
|
+
const wt = join(baseDir, 'plans', planId);
|
|
856
|
+
const fresh = !existsSync(wt);
|
|
857
|
+
if (fresh) {
|
|
858
|
+
try {
|
|
859
|
+
git(['worktree', 'add', '--detach', wt, baseRef], repoRoot);
|
|
860
|
+
} catch {
|
|
861
|
+
git(['worktree', 'prune'], repoRoot);
|
|
862
|
+
try {
|
|
863
|
+
git(['worktree', 'add', '--detach', wt, baseRef], repoRoot);
|
|
864
|
+
} catch {
|
|
865
|
+
return null;
|
|
866
|
+
}
|
|
867
|
+
}
|
|
868
|
+
} else {
|
|
869
|
+
try {
|
|
870
|
+
git(['fetch', 'origin', '--quiet'], repoRoot);
|
|
871
|
+
git(['checkout', '--detach', baseRef], wt);
|
|
872
|
+
git(['reset', '--hard', baseRef], wt);
|
|
873
|
+
git(['clean', '-fd'], wt);
|
|
874
|
+
} catch {
|
|
875
|
+
/* offline, or a turn left it dirty — read what we have */
|
|
876
|
+
}
|
|
877
|
+
}
|
|
878
|
+
return { wt, fresh };
|
|
879
|
+
};
|
|
880
|
+
|
|
881
|
+
/**
|
|
882
|
+
* Retire the least-recently-touched session directories.
|
|
883
|
+
*
|
|
884
|
+
* The bound belongs HERE, in the machine, and never in the interface: ten
|
|
885
|
+
* plans open across a team is ten checkouts on one box, which is a resource
|
|
886
|
+
* question. Announcing a session limit in the app would be advertising
|
|
887
|
+
* capacity, which this product does not do. A retired session simply rebuilds
|
|
888
|
+
* from the spec next time it is asked for — the fallback the server already
|
|
889
|
+
* expects, and which the thread says out loud when it happens.
|
|
890
|
+
*/
|
|
891
|
+
const MAX_PLAN_SESSIONS = 8;
|
|
892
|
+
const planTouched = new Map(); // planId -> ms
|
|
893
|
+
const retireIdlePlanSessions = () => {
|
|
894
|
+
const dir = join(baseDir, 'plans');
|
|
895
|
+
if (!existsSync(dir)) return;
|
|
896
|
+
let ids;
|
|
897
|
+
try {
|
|
898
|
+
ids = readdirSync(dir);
|
|
899
|
+
} catch {
|
|
900
|
+
return;
|
|
901
|
+
}
|
|
902
|
+
if (ids.length <= MAX_PLAN_SESSIONS) return;
|
|
903
|
+
const oldestFirst = ids.sort(
|
|
904
|
+
(a, b) => (planTouched.get(a) ?? 0) - (planTouched.get(b) ?? 0)
|
|
905
|
+
);
|
|
906
|
+
for (const id of oldestFirst.slice(0, ids.length - MAX_PLAN_SESSIONS)) {
|
|
907
|
+
try {
|
|
908
|
+
git(['worktree', 'remove', '--force', join(dir, id)], repoRoot);
|
|
909
|
+
} catch {
|
|
910
|
+
try {
|
|
911
|
+
rmSync(join(dir, id), { recursive: true, force: true });
|
|
912
|
+
} catch {
|
|
913
|
+
/* it is a directory we will overwrite next time; not worth failing a turn */
|
|
914
|
+
}
|
|
915
|
+
}
|
|
916
|
+
planTouched.delete(id);
|
|
917
|
+
}
|
|
918
|
+
try {
|
|
919
|
+
git(['worktree', 'prune'], repoRoot);
|
|
920
|
+
} catch {
|
|
921
|
+
/* best effort */
|
|
922
|
+
}
|
|
923
|
+
};
|
|
924
|
+
|
|
766
925
|
// Quick edits — a SECOND Claude alongside a task this machine is already
|
|
767
926
|
// building. Unlike every other roster job it does not get a worktree of its
|
|
768
927
|
// own: the whole point is to work in the one the running task opened, on that
|
|
@@ -875,57 +1034,91 @@ export async function runFleetDaemon() {
|
|
|
875
1034
|
answering.add(job.id);
|
|
876
1035
|
consultChain = consultChain.then(async () => {
|
|
877
1036
|
try {
|
|
878
|
-
note(`${c.cyan('
|
|
879
|
-
//
|
|
880
|
-
//
|
|
881
|
-
//
|
|
882
|
-
//
|
|
883
|
-
//
|
|
884
|
-
|
|
885
|
-
|
|
886
|
-
|
|
887
|
-
if (!consultRt) {
|
|
888
|
-
warn('a consult is waiting, but no installed CLI can run a read-only turn');
|
|
1037
|
+
note(`${c.cyan('plan')} ${c.dim(`— ${job.askedByName || 'someone'} on "${job.planTitle || 'a plan'}"`)}`);
|
|
1038
|
+
// The profile is the enforcement, not the prompt: this turn is steered
|
|
1039
|
+
// by anything a project editor can type, and it holds write tools. A
|
|
1040
|
+
// runtime that cannot express `plan` does not get the job rather than
|
|
1041
|
+
// getting it with guarantees nobody wrote down — which today excludes
|
|
1042
|
+
// Antigravity, whose mediated shape fits a build and not an argument.
|
|
1043
|
+
const planRt = pickRuntimeFor('plan');
|
|
1044
|
+
if (!planRt) {
|
|
1045
|
+
warn('a planning turn is waiting, but no installed CLI can run a planning session');
|
|
889
1046
|
return;
|
|
890
1047
|
}
|
|
891
|
-
await
|
|
892
|
-
|
|
893
|
-
|
|
894
|
-
|
|
1048
|
+
const token = await mintPlanToken();
|
|
1049
|
+
if (!token) {
|
|
1050
|
+
warn('a planning turn is waiting, but the plan credential could not be minted');
|
|
1051
|
+
return;
|
|
1052
|
+
}
|
|
1053
|
+
const dir = planWtFor(job.taskId);
|
|
1054
|
+
if (!dir) {
|
|
1055
|
+
warn(`a planning turn is waiting, but its session directory could not be opened`);
|
|
1056
|
+
return;
|
|
1057
|
+
}
|
|
1058
|
+
planTouched.set(job.taskId, Date.now());
|
|
1059
|
+
// Resume only when this plan already HAS a session here. A fresh
|
|
1060
|
+
// directory means either the first turn or a session we retired, and
|
|
1061
|
+
// both want the same thing: start over from the spec, which the
|
|
1062
|
+
// kickoff carries. `--continue` against an empty directory is not an
|
|
1063
|
+
// error on every CLI, so asking `fresh` is what keeps it honest.
|
|
1064
|
+
const resume = !dir.fresh && Boolean(job.sessionRef);
|
|
1065
|
+
const mcp = mcpFor(planRt, token, mcpUrl);
|
|
1066
|
+
let out;
|
|
1067
|
+
try {
|
|
1068
|
+
out = await runTurn({
|
|
1069
|
+
prompt: PLAN_TURN_KICKOFF({
|
|
1070
|
+
planId: job.taskId,
|
|
895
1071
|
planTitle: job.planTitle,
|
|
896
1072
|
question: job.question,
|
|
897
1073
|
askedByName: job.askedByName,
|
|
1074
|
+
// Sent only when we are NOT resuming: a live session already has
|
|
1075
|
+
// the argument in its context, and re-stating the spec every
|
|
1076
|
+
// turn would spend tokens telling it what it just wrote. On a
|
|
1077
|
+
// rebuild it is the whole inheritance.
|
|
1078
|
+
spec: resume ? null : job.spec,
|
|
898
1079
|
}),
|
|
899
|
-
resume
|
|
900
|
-
system:
|
|
901
|
-
cwd:
|
|
902
|
-
//
|
|
903
|
-
//
|
|
904
|
-
//
|
|
905
|
-
|
|
906
|
-
|
|
907
|
-
|
|
908
|
-
|
|
909
|
-
|
|
910
|
-
const posted = await reportMergeOutcome(CONSULT_DONE_URL, {
|
|
911
|
-
consultId: job.id,
|
|
912
|
-
ok: answer.length > 0,
|
|
913
|
-
// Scrub: an answer can quote config or env-adjacent code.
|
|
914
|
-
answer: envScrub(answer).slice(0, 8000),
|
|
1080
|
+
resume,
|
|
1081
|
+
system: SYSTEM_PLAN,
|
|
1082
|
+
cwd: dir.wt,
|
|
1083
|
+
// Read the repo, write the PLAN. No Edit/Write/commit anywhere in
|
|
1084
|
+
// the toolset — the prompt says so too, but the prompt is what an
|
|
1085
|
+
// injected message competes with.
|
|
1086
|
+
planPerm: true,
|
|
1087
|
+
mcpArgs: mcp.args,
|
|
1088
|
+
mcpEnv: mcp.env,
|
|
1089
|
+
runtime: planRt,
|
|
1090
|
+
label: c.cyan('[plan]'),
|
|
915
1091
|
});
|
|
916
|
-
|
|
917
|
-
|
|
1092
|
+
} finally {
|
|
1093
|
+
if (mcp.dir) rmSync(mcp.dir, { recursive: true, force: true });
|
|
1094
|
+
}
|
|
1095
|
+
const answer = (out || '').trim();
|
|
1096
|
+
const posted = await reportMergeOutcome(CONSULT_DONE_URL, {
|
|
1097
|
+
consultId: job.id,
|
|
1098
|
+
ok: answer.length > 0,
|
|
1099
|
+
// Scrub: a reply can quote config or env-adjacent code.
|
|
1100
|
+
answer: envScrub(answer).slice(0, 8000),
|
|
1101
|
+
// The handle the server stores, reported on EVERY turn: a session we
|
|
1102
|
+
// had to rebuild comes back under a new directory state, and a
|
|
1103
|
+
// stored handle that does not follow it leaves later turns trying to
|
|
1104
|
+
// resume something that is gone.
|
|
1105
|
+
sessionRef: dir.wt,
|
|
918
1106
|
});
|
|
1107
|
+
if (posted) consultAttempts.delete(job.id);
|
|
1108
|
+
ok(`${c.cyan('plan')} ${c.dim('— replied in the plan thread')}`);
|
|
1109
|
+
retireIdlePlanSessions();
|
|
919
1110
|
} catch (e) {
|
|
920
|
-
// Settle it. A
|
|
921
|
-
//
|
|
922
|
-
//
|
|
1111
|
+
// Settle it. A turn that cannot be answered must not re-burn quota
|
|
1112
|
+
// every poll, and silence would leave the human waiting on a machine
|
|
1113
|
+
// that already gave up.
|
|
923
1114
|
await reportMergeOutcome(CONSULT_DONE_URL, {
|
|
924
1115
|
consultId: job.id,
|
|
925
1116
|
ok: false,
|
|
926
|
-
|
|
1117
|
+
// Scrub, like the success path: an exception routinely quotes
|
|
1118
|
+
// command output, and command output can quote a synced secret.
|
|
1119
|
+
answer: envScrub(String(e?.message ?? 'the planning turn failed')).slice(0, 2000),
|
|
927
1120
|
});
|
|
928
|
-
warn(`
|
|
1121
|
+
warn(`planning turn failed: ${e?.message ?? e}`);
|
|
929
1122
|
} finally {
|
|
930
1123
|
answering.delete(job.id);
|
|
931
1124
|
}
|
|
@@ -933,6 +1126,26 @@ export async function runFleetDaemon() {
|
|
|
933
1126
|
}
|
|
934
1127
|
};
|
|
935
1128
|
|
|
1129
|
+
// ── Work sessions — the Workbench tabs ─────────────────────────────────────
|
|
1130
|
+
//
|
|
1131
|
+
// The whole machinery — per-session turn/ship chains, per-session work
|
|
1132
|
+
// tokens, the settle-every-turn contract, the ship executor, worktree
|
|
1133
|
+
// retirement — lives in work.mjs; this hands it the loop's mutable state.
|
|
1134
|
+
const {
|
|
1135
|
+
flushWorkReports,
|
|
1136
|
+
processWorkTurns,
|
|
1137
|
+
processShipJobs,
|
|
1138
|
+
retireWorkSessions,
|
|
1139
|
+
shutdownWork,
|
|
1140
|
+
} = createWorkManager({
|
|
1141
|
+
repoRoot,
|
|
1142
|
+
baseDir,
|
|
1143
|
+
baseRef,
|
|
1144
|
+
getMcpUrl: () => mcpUrl,
|
|
1145
|
+
getLeaseTtl: () => leaseTtlSeconds,
|
|
1146
|
+
});
|
|
1147
|
+
workShutdown = shutdownWork; // teardown can now reach the live session CLIs
|
|
1148
|
+
|
|
936
1149
|
const processMergeJobs = (jobs) => {
|
|
937
1150
|
for (const job of jobs ?? []) {
|
|
938
1151
|
if (!job || typeof job.id !== 'string') continue; // a null element would wedge the loop
|
|
@@ -1525,10 +1738,22 @@ export async function runFleetDaemon() {
|
|
|
1525
1738
|
});
|
|
1526
1739
|
if (updating) return;
|
|
1527
1740
|
}
|
|
1741
|
+
// Settle any turn/ship answers whose earlier report POST failed BEFORE
|
|
1742
|
+
// taking new work — the skip-if-pending guards make the ordering safe, but
|
|
1743
|
+
// delivering first keeps the tab honest a poll sooner.
|
|
1744
|
+
void flushWorkReports();
|
|
1528
1745
|
processMergeJobs(roster.mergeJobs);
|
|
1529
1746
|
processPatchRevertJobs(roster.patchRevertJobs);
|
|
1530
1747
|
processPlanCheckJobs(roster.planCheckJobs);
|
|
1531
1748
|
processConsultJobs(roster.consultJobs);
|
|
1749
|
+
processWorkTurns(roster.workTurnJobs);
|
|
1750
|
+
// The roster's live-session list rides along: an ENDED session's ship
|
|
1751
|
+
// must not be refused by checks whose remedies need a live tab.
|
|
1752
|
+
processShipJobs(roster.shipJobs, roster.activeWorkSessions);
|
|
1753
|
+
// AFTER the work/ship intake: retirement is the server saying which
|
|
1754
|
+
// sessions are LIVE, and the guards above (chains, shipping) are populated
|
|
1755
|
+
// by the intake this same tick.
|
|
1756
|
+
retireWorkSessions(roster.activeWorkSessions);
|
|
1532
1757
|
processJoinJobs(roster.joinJobs);
|
|
1533
1758
|
processCleanupJobs(roster.cleanupJobs);
|
|
1534
1759
|
const rosterIds = new Set(roster.agents.map((a) => a.agentId));
|
package/bin/lib/live.mjs
CHANGED
|
@@ -43,7 +43,7 @@ import {
|
|
|
43
43
|
restoreWip,
|
|
44
44
|
clearWip,
|
|
45
45
|
} from './git.mjs';
|
|
46
|
-
import { applyPatch, fileDiffs, ownerCurrentBranch, withPatchLock } from './patch.mjs';
|
|
46
|
+
import { applyPatch, commitHistory, fileDiffs, ownerCurrentBranch, withPatchLock } from './patch.mjs';
|
|
47
47
|
import { RUNTIMES, runtimeById, drivableHere, mediated } from './runtimes.mjs';
|
|
48
48
|
import { loadPreviewConfig, startPreview } from './preview.mjs';
|
|
49
49
|
import { materializeInto, scrub as envScrub } from './env.mjs';
|
|
@@ -316,6 +316,33 @@ function seedPrompt(runId, brief, transcript, resumedInPlace) {
|
|
|
316
316
|
// The flowviant MCP endpoint handles tools/call statelessly with a bearer
|
|
317
317
|
// worker token — no handshake — so this is all the daemon needs.
|
|
318
318
|
let rpcId = 0;
|
|
319
|
+
/**
|
|
320
|
+
* Push this task's commits + real diffs to the control plane.
|
|
321
|
+
*
|
|
322
|
+
* The server used to fetch exactly this from github.com with a GitHub App
|
|
323
|
+
* installation token — the app existed largely for it. We are standing in the
|
|
324
|
+
* worktree that produced these commits, so we send them: the thread's diff
|
|
325
|
+
* timeline, the review quiz and the merge gate's approved-head pin all read
|
|
326
|
+
* what lands here.
|
|
327
|
+
*
|
|
328
|
+
* Best-effort by design. A failure here must never fail the run — the work is
|
|
329
|
+
* committed and the PR is open either way, and the next push reports again.
|
|
330
|
+
* What it costs when it does fail is visible rather than silent: the thread
|
|
331
|
+
* shows no diffs, which is the same thing it showed when GitHub was unreachable.
|
|
332
|
+
*/
|
|
333
|
+
async function reportCommits({ mcpUrl, token, runId, cwd, baseRef }) {
|
|
334
|
+
try {
|
|
335
|
+
const base = baseRef ?? 'HEAD';
|
|
336
|
+
const commits = commitHistory(cwd, base);
|
|
337
|
+
if (commits.length === 0) return;
|
|
338
|
+
const headSha = commits[commits.length - 1].sha;
|
|
339
|
+
const res = await mcpCall(mcpUrl, token, 'report_commits', { runId, headSha, commits });
|
|
340
|
+
if (res?.ok === false) warn(`report_commits rejected: ${res.reason ?? 'unknown'}`);
|
|
341
|
+
} catch (e) {
|
|
342
|
+
warn(`report_commits skipped: ${e?.message ?? String(e)}`);
|
|
343
|
+
}
|
|
344
|
+
}
|
|
345
|
+
|
|
319
346
|
async function mcpCall(mcpUrl, token, name, args) {
|
|
320
347
|
const res = await fetch(mcpUrl, {
|
|
321
348
|
method: 'POST',
|
|
@@ -1023,6 +1050,7 @@ async function driveMediated({
|
|
|
1023
1050
|
...(result.branch ? { branch: String(result.branch) } : {}),
|
|
1024
1051
|
}).catch((e) => ({ ok: false, reason: e?.message ?? String(e) }));
|
|
1025
1052
|
if (attached?.ok === false) warn(`attach_pr rejected: ${attached.reason ?? 'unknown'}`);
|
|
1053
|
+
else await reportCommits({ mcpUrl, token, runId, cwd, baseRef });
|
|
1026
1054
|
}
|
|
1027
1055
|
}
|
|
1028
1056
|
clearTaskMarker(cwd);
|
package/bin/lib/patch.mjs
CHANGED
|
@@ -125,12 +125,15 @@ const DIFF_STATUS = { A: 'added', D: 'removed', M: 'modified' };
|
|
|
125
125
|
* pass back to `git diff -- <path>`. A rename showing up as a delete plus an add
|
|
126
126
|
* is a slightly longer diff and a correct one.
|
|
127
127
|
*/
|
|
128
|
-
export function fileDiffs(cwd, base) {
|
|
128
|
+
export function fileDiffs(cwd, base, { range, maxFiles = MAX_DIFF_FILES } = {}) {
|
|
129
|
+
// `range` lets the per-COMMIT walk reuse this (`sha^..sha`); without it the
|
|
130
|
+
// original meaning holds — everything the agent did since `base`.
|
|
131
|
+
const rev = range ?? `${base}..HEAD`;
|
|
129
132
|
let numstat = '';
|
|
130
133
|
let names = '';
|
|
131
134
|
try {
|
|
132
|
-
numstat = git(['diff', '--numstat', '--no-renames',
|
|
133
|
-
names = git(['diff', '--name-status', '--no-renames',
|
|
135
|
+
numstat = git(['diff', '--numstat', '--no-renames', rev], cwd);
|
|
136
|
+
names = git(['diff', '--name-status', '--no-renames', rev], cwd);
|
|
134
137
|
} catch {
|
|
135
138
|
return [];
|
|
136
139
|
}
|
|
@@ -144,7 +147,7 @@ export function fileDiffs(cwd, base) {
|
|
|
144
147
|
|
|
145
148
|
const out = [];
|
|
146
149
|
for (const line of numstat.split('\n')) {
|
|
147
|
-
if (out.length >=
|
|
150
|
+
if (out.length >= maxFiles) break;
|
|
148
151
|
const m = /^(\d+|-)\t(\d+|-)\t(.+)$/.exec(line.replace(/\n$/, ''));
|
|
149
152
|
if (!m) continue;
|
|
150
153
|
const path = m[3].trim();
|
|
@@ -154,7 +157,7 @@ export function fileDiffs(cwd, base) {
|
|
|
154
157
|
let patch = null;
|
|
155
158
|
if (!binary) {
|
|
156
159
|
try {
|
|
157
|
-
const full = git(['diff',
|
|
160
|
+
const full = git(['diff', rev, '--', path], cwd);
|
|
158
161
|
// Drop git's own "diff --git a/… b/…" preamble; the card shows the path.
|
|
159
162
|
const at = full.indexOf('@@');
|
|
160
163
|
const hunks = at === -1 ? full : full.slice(at);
|
|
@@ -293,3 +296,68 @@ export function revertPatch({ repoRoot, shas }) {
|
|
|
293
296
|
return { ok: false, error: e?.message ?? String(e) };
|
|
294
297
|
}
|
|
295
298
|
}
|
|
299
|
+
|
|
300
|
+
|
|
301
|
+
// How many commits of a task's branch we carry across. The server used to read
|
|
302
|
+
// this from GitHub and capped at 50 for the same reason: each commit costs a
|
|
303
|
+
// diff, and a runaway branch must not fan out unbounded work or produce a row
|
|
304
|
+
// too big to read on every card render. Truncation keeps the MOST RECENT
|
|
305
|
+
// commits — the tail is what a reviewer is looking at.
|
|
306
|
+
const MAX_COMMITS = 50;
|
|
307
|
+
|
|
308
|
+
/**
|
|
309
|
+
* A task branch's commits with their real per-file diffs, in the exact shape
|
|
310
|
+
* the server's GitHub read used to return (`TaskCommit[]`).
|
|
311
|
+
*
|
|
312
|
+
* This is the function that let the GitHub App die. The server used to resolve
|
|
313
|
+
* the project's linked repo, mint an installation token, fetch
|
|
314
|
+
* `GET /pulls/{n}/commits` and then run an N+1 of `GET /commits/{sha}` for the
|
|
315
|
+
* per-file patches — up to ~52 API calls to describe work THIS process had just
|
|
316
|
+
* performed, in a checkout it is standing in. Now the daemon reports it through
|
|
317
|
+
* `report_commits` and the server reads a row.
|
|
318
|
+
*
|
|
319
|
+
* Oldest → newest, because the thread appends chronologically.
|
|
320
|
+
*/
|
|
321
|
+
export function commitHistory(cwd, base) {
|
|
322
|
+
let log = '';
|
|
323
|
+
try {
|
|
324
|
+
// %x1f/%x1e are unit/record separators: a commit subject can contain
|
|
325
|
+
// anything, tabs and pipes included, so the delimiters have to be bytes a
|
|
326
|
+
// human will never type.
|
|
327
|
+
log = git(
|
|
328
|
+
['log', '--reverse', `--max-count=${MAX_COMMITS}`, '--format=%H%x1f%s%x1f%an%x1f%aI%x1e', `${base}..HEAD`],
|
|
329
|
+
cwd,
|
|
330
|
+
);
|
|
331
|
+
} catch {
|
|
332
|
+
return [];
|
|
333
|
+
}
|
|
334
|
+
|
|
335
|
+
const out = [];
|
|
336
|
+
for (const record of log.split('\x1e')) {
|
|
337
|
+
const line = record.trim();
|
|
338
|
+
if (!line) continue;
|
|
339
|
+
const [sha, message, authorName, committedAt] = line.split('\x1f');
|
|
340
|
+
if (!sha) continue;
|
|
341
|
+
// First-parent range for the commit itself. A root commit has no `^`, in
|
|
342
|
+
// which case git's empty-tree hash gives us the whole thing as an add.
|
|
343
|
+
let range = `${sha}^..${sha}`;
|
|
344
|
+
try {
|
|
345
|
+
git(['rev-parse', `${sha}^`], cwd);
|
|
346
|
+
} catch {
|
|
347
|
+
range = `4b825dc642cb6eb9a060e54bf8d69288fbee4904..${sha}`;
|
|
348
|
+
}
|
|
349
|
+
const files = fileDiffs(cwd, null, { range });
|
|
350
|
+
out.push({
|
|
351
|
+
sha,
|
|
352
|
+
message: (message ?? '').slice(0, 500),
|
|
353
|
+
authorName: (authorName ?? '').slice(0, 200),
|
|
354
|
+
authorLogin: null,
|
|
355
|
+
committedAt: committedAt ?? new Date().toISOString(),
|
|
356
|
+
url: null,
|
|
357
|
+
additions: files.reduce((n, f) => n + f.additions, 0),
|
|
358
|
+
deletions: files.reduce((n, f) => n + f.deletions, 0),
|
|
359
|
+
files,
|
|
360
|
+
});
|
|
361
|
+
}
|
|
362
|
+
return out;
|
|
363
|
+
}
|