flowviant 0.40.1 → 0.43.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/claude.mjs +203 -3
- package/bin/lib/fleet.mjs +557 -68
- package/bin/lib/live.mjs +39 -5
- package/bin/lib/patch.mjs +73 -5
- package/bin/lib/resources.mjs +2 -2
- package/bin/lib/runtimes.mjs +48 -7
- package/package.json +1 -1
package/bin/lib/fleet.mjs
CHANGED
|
@@ -57,8 +57,10 @@ import {
|
|
|
57
57
|
SYSTEM_PLAN_CHECK,
|
|
58
58
|
PLAN_CHECK_KICKOFF,
|
|
59
59
|
REGROUND_KICKOFF,
|
|
60
|
-
|
|
61
|
-
|
|
60
|
+
SYSTEM_PLAN,
|
|
61
|
+
PLAN_TURN_KICKOFF,
|
|
62
|
+
SYSTEM_WORK,
|
|
63
|
+
WORK_TURN_KICKOFF,
|
|
62
64
|
SYSTEM_QUICK_EDIT,
|
|
63
65
|
QUICK_EDIT_KICKOFF,
|
|
64
66
|
} from './claude.mjs';
|
|
@@ -191,7 +193,7 @@ function sampleDiffstat(cwd, baseRef, intentId, agentId) {
|
|
|
191
193
|
signal: AbortSignal.timeout(15_000),
|
|
192
194
|
// The lane, not just the task: the server matches the run on both, so a
|
|
193
195
|
// sample can only ever overwrite the diffstat of THIS lane's own run.
|
|
194
|
-
body: JSON.stringify({ intentId, agentId, diffstat: stat }),
|
|
196
|
+
body: JSON.stringify({ taskId: intentId, agentId, diffstat: stat }),
|
|
195
197
|
});
|
|
196
198
|
// Only a sample the server ACCEPTED counts as sent. Marking it delivered
|
|
197
199
|
// before the round-trip meant a dropped request suppressed every retry
|
|
@@ -575,7 +577,7 @@ export async function runFleetDaemon() {
|
|
|
575
577
|
// roster re-serves the job every poll, and each pass reverts the
|
|
576
578
|
// revert — the change flapping in and out of the owner's tree forever.
|
|
577
579
|
await reportMergeOutcome(PATCH_REVERT_DONE_URL, {
|
|
578
|
-
|
|
580
|
+
taskId: job.id,
|
|
579
581
|
ok: res.ok,
|
|
580
582
|
error: res.ok ? undefined : String(res.error ?? 'revert failed'),
|
|
581
583
|
});
|
|
@@ -693,9 +695,12 @@ export async function runFleetDaemon() {
|
|
|
693
695
|
const checkingPlans = new Set();
|
|
694
696
|
const processPlanCheckJobs = (jobs) => {
|
|
695
697
|
for (const job of jobs ?? []) {
|
|
696
|
-
|
|
698
|
+
// New name first; the roster mirrors `intents` off `tasks` for exactly
|
|
699
|
+
// this fallback window.
|
|
700
|
+
const planTasks = Array.isArray(job?.tasks) ? job.tasks : job?.intents;
|
|
701
|
+
if (!job || typeof job.id !== 'string' || !Array.isArray(planTasks)) continue;
|
|
697
702
|
if (checkingPlans.has(job.id)) continue;
|
|
698
|
-
if (
|
|
703
|
+
if (planTasks.length === 0) continue;
|
|
699
704
|
checkingPlans.add(job.id);
|
|
700
705
|
(async () => {
|
|
701
706
|
try {
|
|
@@ -711,7 +716,7 @@ export async function runFleetDaemon() {
|
|
|
711
716
|
const out = await withWikiLock(async () => {
|
|
712
717
|
ensureWikiWorktree();
|
|
713
718
|
return runTurn({
|
|
714
|
-
prompt: PLAN_CHECK_KICKOFF({ title: job.title, intents:
|
|
719
|
+
prompt: PLAN_CHECK_KICKOFF({ title: job.title, intents: planTasks }),
|
|
715
720
|
resume: false,
|
|
716
721
|
system: SYSTEM_PLAN_CHECK,
|
|
717
722
|
cwd: wikiWt,
|
|
@@ -721,12 +726,12 @@ export async function runFleetDaemon() {
|
|
|
721
726
|
label: c.cyan('[plan]'),
|
|
722
727
|
});
|
|
723
728
|
});
|
|
724
|
-
const checks = parsePlanChecks(out,
|
|
729
|
+
const checks = parsePlanChecks(out, planTasks);
|
|
725
730
|
if (checks === null) {
|
|
726
731
|
warn(`plan check for "${job.title}": no usable JSON — leaving the plan as drafted`);
|
|
727
732
|
}
|
|
728
733
|
await reportMergeOutcome(PLAN_CHECK_DONE_URL, {
|
|
729
|
-
|
|
734
|
+
taskId: job.id,
|
|
730
735
|
checks: checks ?? [],
|
|
731
736
|
});
|
|
732
737
|
if (checks?.length) {
|
|
@@ -737,7 +742,7 @@ export async function runFleetDaemon() {
|
|
|
737
742
|
} catch (e) {
|
|
738
743
|
warn(`plan check failed for "${job.title}": ${e?.message ?? e}`);
|
|
739
744
|
// Clear the flag anyway — a stuck job would re-run every poll forever.
|
|
740
|
-
await reportMergeOutcome(PLAN_CHECK_DONE_URL, {
|
|
745
|
+
await reportMergeOutcome(PLAN_CHECK_DONE_URL, { taskId: job.id, checks: [] });
|
|
741
746
|
} finally {
|
|
742
747
|
checkingPlans.delete(job.id);
|
|
743
748
|
}
|
|
@@ -745,21 +750,155 @@ export async function runFleetDaemon() {
|
|
|
745
750
|
}
|
|
746
751
|
};
|
|
747
752
|
|
|
748
|
-
//
|
|
749
|
-
//
|
|
750
|
-
//
|
|
753
|
+
// ── Planning sessions ────────────────────────────────────────────────────
|
|
754
|
+
//
|
|
755
|
+
// A turn in a plan thread, answered inside a HELD session. This was the
|
|
756
|
+
// consult, which answered one question in prose and kept nothing: it existed
|
|
757
|
+
// because the planner was a different, weaker brain and this turn's only job
|
|
758
|
+
// was to correct it from the real code. That planner is gone, so the session
|
|
759
|
+
// reads the repo AND writes the plan, over many turns, in one context.
|
|
760
|
+
//
|
|
761
|
+
// Two things changed shape as a result.
|
|
762
|
+
//
|
|
763
|
+
// ONE WORKTREE PER PLAN, not the shared `wikiWt`. Every CLI here resumes with
|
|
764
|
+
// "continue the last session in this directory" (`--continue`, `resume
|
|
765
|
+
// --last`) rather than by session id, so the WORKING DIRECTORY *is* the
|
|
766
|
+
// session handle. A shared directory would have made two plans on one machine
|
|
767
|
+
// take turns wearing each other's context — and the wiki queue hard-resets
|
|
768
|
+
// that directory between tasks, which would pull the files out from under a
|
|
769
|
+
// session mid-argument. A private detached checkout per plan also means plan
|
|
770
|
+
// turns no longer queue behind the wiki lock.
|
|
771
|
+
//
|
|
772
|
+
// IT CARRIES MCP. A consult passed none — nothing to write. A session spawns
|
|
773
|
+
// slices, re-shapes them, drops them and maintains the spec, all of which are
|
|
774
|
+
// control-plane calls. The token is the fleet's PLAN principal, whose entire
|
|
775
|
+
// tool set is those five: it cannot claim, cannot open a worktree, cannot
|
|
776
|
+
// commit. That absence is the product rule, not a hardening measure — it is
|
|
777
|
+
// what makes "add a dark mode toggle" typed at a plan add a slice instead of
|
|
778
|
+
// building one, with nothing reading the sentence to decide.
|
|
751
779
|
const CONSULT_DONE_URL = FLEET_URL.replace(/\/agents\/?$/, '/consult-done');
|
|
780
|
+
const PLAN_TOKEN_URL = FLEET_URL.replace(/\/agents\/?$/, '/plan-token');
|
|
752
781
|
const answering = new Set();
|
|
753
|
-
const consultAttempts = new Map(); //
|
|
754
|
-
/** Give up after this many turns on one
|
|
782
|
+
const consultAttempts = new Map(); // turn id -> tries
|
|
783
|
+
/** Give up after this many turns on one message. A /consult-done that never
|
|
755
784
|
* reaches the server (offline, 500) would otherwise re-run the whole Claude
|
|
756
785
|
* turn every poll, forever, on the owner's quota. */
|
|
757
786
|
const MAX_CONSULT_TRIES = 3;
|
|
758
|
-
/** ONE
|
|
759
|
-
*
|
|
760
|
-
* concurrent
|
|
787
|
+
/** ONE planning turn at a time on this machine. Sessions are per-plan so they
|
|
788
|
+
* no longer collide on a directory, but the roster can hand back a batch, and
|
|
789
|
+
* un-awaited spawns would put N concurrent CLI processes on someone's laptop
|
|
790
|
+
* for what is, on the human's side, a chat. */
|
|
761
791
|
let consultChain = Promise.resolve();
|
|
762
792
|
|
|
793
|
+
/**
|
|
794
|
+
* The plan credential, cached until it stops working.
|
|
795
|
+
*
|
|
796
|
+
* Minted lazily rather than at startup: most daemons never host a planning
|
|
797
|
+
* session, and a token nobody uses is a credential sitting on disk for no
|
|
798
|
+
* reason. Rotated by the server on every mint, so a re-mint after a 401 is the
|
|
799
|
+
* recovery path.
|
|
800
|
+
*/
|
|
801
|
+
let planToken = null;
|
|
802
|
+
const mintPlanToken = async (force = false) => {
|
|
803
|
+
if (planToken && !force) return planToken;
|
|
804
|
+
try {
|
|
805
|
+
const res = await fetch(PLAN_TOKEN_URL, {
|
|
806
|
+
method: 'POST',
|
|
807
|
+
headers: { Authorization: `Bearer ${FLEET_TOKEN}`, 'User-Agent': USER_AGENT },
|
|
808
|
+
});
|
|
809
|
+
if (!res.ok) return null;
|
|
810
|
+
const data = await res.json().catch(() => null);
|
|
811
|
+
planToken = data?.data?.token ?? null;
|
|
812
|
+
return planToken;
|
|
813
|
+
} catch {
|
|
814
|
+
return null;
|
|
815
|
+
}
|
|
816
|
+
};
|
|
817
|
+
|
|
818
|
+
/**
|
|
819
|
+
* This plan's session directory — its context, expressed as a place.
|
|
820
|
+
*
|
|
821
|
+
* A detached checkout at base, like a consult's, but PRIVATE and PERSISTENT:
|
|
822
|
+
* private so `--continue` resumes this argument rather than whichever ran last
|
|
823
|
+
* on the box, persistent so it survives the daemon restarting or updating
|
|
824
|
+
* under it. Re-pointed at the current base each turn, because "reads your
|
|
825
|
+
* code" has to mean the code as it is now — a plan that runs for days would
|
|
826
|
+
* otherwise keep answering from the commit it was opened at.
|
|
827
|
+
*
|
|
828
|
+
* Returns null when the id is not a safe path segment: it comes off the wire.
|
|
829
|
+
*/
|
|
830
|
+
const planWtFor = (planId) => {
|
|
831
|
+
if (!isSafePathSegment(planId)) return null;
|
|
832
|
+
const wt = join(baseDir, 'plans', planId);
|
|
833
|
+
const fresh = !existsSync(wt);
|
|
834
|
+
if (fresh) {
|
|
835
|
+
try {
|
|
836
|
+
git(['worktree', 'add', '--detach', wt, baseRef], repoRoot);
|
|
837
|
+
} catch {
|
|
838
|
+
git(['worktree', 'prune'], repoRoot);
|
|
839
|
+
try {
|
|
840
|
+
git(['worktree', 'add', '--detach', wt, baseRef], repoRoot);
|
|
841
|
+
} catch {
|
|
842
|
+
return null;
|
|
843
|
+
}
|
|
844
|
+
}
|
|
845
|
+
} else {
|
|
846
|
+
try {
|
|
847
|
+
git(['fetch', 'origin', '--quiet'], repoRoot);
|
|
848
|
+
git(['checkout', '--detach', baseRef], wt);
|
|
849
|
+
git(['reset', '--hard', baseRef], wt);
|
|
850
|
+
git(['clean', '-fd'], wt);
|
|
851
|
+
} catch {
|
|
852
|
+
/* offline, or a turn left it dirty — read what we have */
|
|
853
|
+
}
|
|
854
|
+
}
|
|
855
|
+
return { wt, fresh };
|
|
856
|
+
};
|
|
857
|
+
|
|
858
|
+
/**
|
|
859
|
+
* Retire the least-recently-touched session directories.
|
|
860
|
+
*
|
|
861
|
+
* The bound belongs HERE, in the machine, and never in the interface: ten
|
|
862
|
+
* plans open across a team is ten checkouts on one box, which is a resource
|
|
863
|
+
* question. Announcing a session limit in the app would be advertising
|
|
864
|
+
* capacity, which this product does not do. A retired session simply rebuilds
|
|
865
|
+
* from the spec next time it is asked for — the fallback the server already
|
|
866
|
+
* expects, and which the thread says out loud when it happens.
|
|
867
|
+
*/
|
|
868
|
+
const MAX_PLAN_SESSIONS = 8;
|
|
869
|
+
const planTouched = new Map(); // planId -> ms
|
|
870
|
+
const retireIdlePlanSessions = () => {
|
|
871
|
+
const dir = join(baseDir, 'plans');
|
|
872
|
+
if (!existsSync(dir)) return;
|
|
873
|
+
let ids;
|
|
874
|
+
try {
|
|
875
|
+
ids = readdirSync(dir);
|
|
876
|
+
} catch {
|
|
877
|
+
return;
|
|
878
|
+
}
|
|
879
|
+
if (ids.length <= MAX_PLAN_SESSIONS) return;
|
|
880
|
+
const oldestFirst = ids.sort(
|
|
881
|
+
(a, b) => (planTouched.get(a) ?? 0) - (planTouched.get(b) ?? 0)
|
|
882
|
+
);
|
|
883
|
+
for (const id of oldestFirst.slice(0, ids.length - MAX_PLAN_SESSIONS)) {
|
|
884
|
+
try {
|
|
885
|
+
git(['worktree', 'remove', '--force', join(dir, id)], repoRoot);
|
|
886
|
+
} catch {
|
|
887
|
+
try {
|
|
888
|
+
rmSync(join(dir, id), { recursive: true, force: true });
|
|
889
|
+
} catch {
|
|
890
|
+
/* it is a directory we will overwrite next time; not worth failing a turn */
|
|
891
|
+
}
|
|
892
|
+
}
|
|
893
|
+
planTouched.delete(id);
|
|
894
|
+
}
|
|
895
|
+
try {
|
|
896
|
+
git(['worktree', 'prune'], repoRoot);
|
|
897
|
+
} catch {
|
|
898
|
+
/* best effort */
|
|
899
|
+
}
|
|
900
|
+
};
|
|
901
|
+
|
|
763
902
|
// Quick edits — a SECOND Claude alongside a task this machine is already
|
|
764
903
|
// building. Unlike every other roster job it does not get a worktree of its
|
|
765
904
|
// own: the whole point is to work in the one the running task opened, on that
|
|
@@ -797,7 +936,7 @@ export async function runFleetDaemon() {
|
|
|
797
936
|
joinChain = joinChain.then(async () => {
|
|
798
937
|
let settled = false;
|
|
799
938
|
try {
|
|
800
|
-
const target = worktreeBuilding(job.intentId);
|
|
939
|
+
const target = worktreeBuilding(job.taskId ?? job.intentId);
|
|
801
940
|
if (!target) {
|
|
802
941
|
// The run ended (or moved) between the human pressing ⚡ and this
|
|
803
942
|
// poll. Settle rather than retry: there is no worktree to join, and
|
|
@@ -818,11 +957,11 @@ export async function runFleetDaemon() {
|
|
|
818
957
|
const claim = await postForData(JOIN_TAKE_URL, { joinId: job.id });
|
|
819
958
|
if (!claim?.taken) return;
|
|
820
959
|
note(
|
|
821
|
-
`${c.cyan('quick')} ${c.dim(`— ${job.askedByName || 'someone'} on "${job.intentTitle || 'a task'}"`)}`
|
|
960
|
+
`${c.cyan('quick')} ${c.dim(`— ${job.askedByName || 'someone'} on "${job.taskTitle || job.intentTitle || 'a task'}"`)}`
|
|
822
961
|
);
|
|
823
962
|
const out = await runTurn({
|
|
824
963
|
prompt: QUICK_EDIT_KICKOFF({
|
|
825
|
-
intentTitle: job.intentTitle,
|
|
964
|
+
intentTitle: job.taskTitle ?? job.intentTitle,
|
|
826
965
|
instruction: job.instruction,
|
|
827
966
|
askedByName: job.askedByName,
|
|
828
967
|
}),
|
|
@@ -872,57 +1011,89 @@ export async function runFleetDaemon() {
|
|
|
872
1011
|
answering.add(job.id);
|
|
873
1012
|
consultChain = consultChain.then(async () => {
|
|
874
1013
|
try {
|
|
875
|
-
note(`${c.cyan('
|
|
876
|
-
//
|
|
877
|
-
//
|
|
878
|
-
//
|
|
879
|
-
//
|
|
880
|
-
//
|
|
881
|
-
|
|
882
|
-
|
|
883
|
-
|
|
884
|
-
if (!consultRt) {
|
|
885
|
-
warn('a consult is waiting, but no installed CLI can run a read-only turn');
|
|
1014
|
+
note(`${c.cyan('plan')} ${c.dim(`— ${job.askedByName || 'someone'} on "${job.planTitle || 'a plan'}"`)}`);
|
|
1015
|
+
// The profile is the enforcement, not the prompt: this turn is steered
|
|
1016
|
+
// by anything a project editor can type, and it holds write tools. A
|
|
1017
|
+
// runtime that cannot express `plan` does not get the job rather than
|
|
1018
|
+
// getting it with guarantees nobody wrote down — which today excludes
|
|
1019
|
+
// Antigravity, whose mediated shape fits a build and not an argument.
|
|
1020
|
+
const planRt = pickRuntimeFor('plan');
|
|
1021
|
+
if (!planRt) {
|
|
1022
|
+
warn('a planning turn is waiting, but no installed CLI can run a planning session');
|
|
886
1023
|
return;
|
|
887
1024
|
}
|
|
888
|
-
await
|
|
889
|
-
|
|
890
|
-
|
|
891
|
-
|
|
1025
|
+
const token = await mintPlanToken();
|
|
1026
|
+
if (!token) {
|
|
1027
|
+
warn('a planning turn is waiting, but the plan credential could not be minted');
|
|
1028
|
+
return;
|
|
1029
|
+
}
|
|
1030
|
+
const dir = planWtFor(job.taskId);
|
|
1031
|
+
if (!dir) {
|
|
1032
|
+
warn(`a planning turn is waiting, but its session directory could not be opened`);
|
|
1033
|
+
return;
|
|
1034
|
+
}
|
|
1035
|
+
planTouched.set(job.taskId, Date.now());
|
|
1036
|
+
// Resume only when this plan already HAS a session here. A fresh
|
|
1037
|
+
// directory means either the first turn or a session we retired, and
|
|
1038
|
+
// both want the same thing: start over from the spec, which the
|
|
1039
|
+
// kickoff carries. `--continue` against an empty directory is not an
|
|
1040
|
+
// error on every CLI, so asking `fresh` is what keeps it honest.
|
|
1041
|
+
const resume = !dir.fresh && Boolean(job.sessionRef);
|
|
1042
|
+
const mcp = mcpFor(planRt, token, mcpUrl);
|
|
1043
|
+
let out;
|
|
1044
|
+
try {
|
|
1045
|
+
out = await runTurn({
|
|
1046
|
+
prompt: PLAN_TURN_KICKOFF({
|
|
1047
|
+
planId: job.taskId,
|
|
892
1048
|
planTitle: job.planTitle,
|
|
893
1049
|
question: job.question,
|
|
894
1050
|
askedByName: job.askedByName,
|
|
1051
|
+
// Sent only when we are NOT resuming: a live session already has
|
|
1052
|
+
// the argument in its context, and re-stating the spec every
|
|
1053
|
+
// turn would spend tokens telling it what it just wrote. On a
|
|
1054
|
+
// rebuild it is the whole inheritance.
|
|
1055
|
+
spec: resume ? null : job.spec,
|
|
895
1056
|
}),
|
|
896
|
-
resume
|
|
897
|
-
system:
|
|
898
|
-
cwd:
|
|
899
|
-
//
|
|
900
|
-
//
|
|
901
|
-
//
|
|
902
|
-
|
|
903
|
-
|
|
904
|
-
|
|
905
|
-
|
|
906
|
-
|
|
907
|
-
const posted = await reportMergeOutcome(CONSULT_DONE_URL, {
|
|
908
|
-
consultId: job.id,
|
|
909
|
-
ok: answer.length > 0,
|
|
910
|
-
// Scrub: an answer can quote config or env-adjacent code.
|
|
911
|
-
answer: envScrub(answer).slice(0, 8000),
|
|
1057
|
+
resume,
|
|
1058
|
+
system: SYSTEM_PLAN,
|
|
1059
|
+
cwd: dir.wt,
|
|
1060
|
+
// Read the repo, write the PLAN. No Edit/Write/commit anywhere in
|
|
1061
|
+
// the toolset — the prompt says so too, but the prompt is what an
|
|
1062
|
+
// injected message competes with.
|
|
1063
|
+
planPerm: true,
|
|
1064
|
+
mcpArgs: mcp.args,
|
|
1065
|
+
mcpEnv: mcp.env,
|
|
1066
|
+
runtime: planRt,
|
|
1067
|
+
label: c.cyan('[plan]'),
|
|
912
1068
|
});
|
|
913
|
-
|
|
914
|
-
|
|
1069
|
+
} finally {
|
|
1070
|
+
if (mcp.dir) rmSync(mcp.dir, { recursive: true, force: true });
|
|
1071
|
+
}
|
|
1072
|
+
const answer = (out || '').trim();
|
|
1073
|
+
const posted = await reportMergeOutcome(CONSULT_DONE_URL, {
|
|
1074
|
+
consultId: job.id,
|
|
1075
|
+
ok: answer.length > 0,
|
|
1076
|
+
// Scrub: a reply can quote config or env-adjacent code.
|
|
1077
|
+
answer: envScrub(answer).slice(0, 8000),
|
|
1078
|
+
// The handle the server stores, reported on EVERY turn: a session we
|
|
1079
|
+
// had to rebuild comes back under a new directory state, and a
|
|
1080
|
+
// stored handle that does not follow it leaves later turns trying to
|
|
1081
|
+
// resume something that is gone.
|
|
1082
|
+
sessionRef: dir.wt,
|
|
915
1083
|
});
|
|
1084
|
+
if (posted) consultAttempts.delete(job.id);
|
|
1085
|
+
ok(`${c.cyan('plan')} ${c.dim('— replied in the plan thread')}`);
|
|
1086
|
+
retireIdlePlanSessions();
|
|
916
1087
|
} catch (e) {
|
|
917
|
-
// Settle it. A
|
|
918
|
-
//
|
|
919
|
-
//
|
|
1088
|
+
// Settle it. A turn that cannot be answered must not re-burn quota
|
|
1089
|
+
// every poll, and silence would leave the human waiting on a machine
|
|
1090
|
+
// that already gave up.
|
|
920
1091
|
await reportMergeOutcome(CONSULT_DONE_URL, {
|
|
921
1092
|
consultId: job.id,
|
|
922
1093
|
ok: false,
|
|
923
|
-
answer: e?.message ?? 'the
|
|
1094
|
+
answer: e?.message ?? 'the planning turn failed',
|
|
924
1095
|
});
|
|
925
|
-
warn(`
|
|
1096
|
+
warn(`planning turn failed: ${e?.message ?? e}`);
|
|
926
1097
|
} finally {
|
|
927
1098
|
answering.delete(job.id);
|
|
928
1099
|
}
|
|
@@ -930,6 +1101,315 @@ export async function runFleetDaemon() {
|
|
|
930
1101
|
}
|
|
931
1102
|
};
|
|
932
1103
|
|
|
1104
|
+
// ── Work sessions — the Workbench tabs ─────────────────────────────────────
|
|
1105
|
+
//
|
|
1106
|
+
// A tab is a held Claude session with BUILD permissions in a PERSISTENT
|
|
1107
|
+
// worktree on its own branch. The opposite of a plan directory on both
|
|
1108
|
+
// counts: nothing here is detached and nothing is ever reset — uncommitted
|
|
1109
|
+
// state between turns IS the session, and blowing it away would be closing
|
|
1110
|
+
// the human's editor mid-thought.
|
|
1111
|
+
const WORK_TOKEN_URL = FLEET_URL.replace(/\/agents\/?$/, '/work-token');
|
|
1112
|
+
const WORK_DONE_URL = FLEET_URL.replace(/\/agents\/?$/, '/work-turn-done');
|
|
1113
|
+
const workAnswering = new Set();
|
|
1114
|
+
const workAttempts = new Map(); // turn id -> tries
|
|
1115
|
+
const MAX_WORK_TRIES = 3;
|
|
1116
|
+
/** Per-SESSION serialization, parallel ACROSS sessions: turns within one tab
|
|
1117
|
+
* must land in order (they share a directory and a context), but two tabs
|
|
1118
|
+
* are two terminals — the human opened both on purpose. */
|
|
1119
|
+
const workChains = new Map(); // sessionId -> Promise
|
|
1120
|
+
|
|
1121
|
+
let workToken = null;
|
|
1122
|
+
const mintWorkToken = async (force = false) => {
|
|
1123
|
+
if (workToken && !force) return workToken;
|
|
1124
|
+
try {
|
|
1125
|
+
const res = await fetch(WORK_TOKEN_URL, {
|
|
1126
|
+
method: 'POST',
|
|
1127
|
+
headers: { Authorization: `Bearer ${FLEET_TOKEN}`, 'User-Agent': USER_AGENT },
|
|
1128
|
+
});
|
|
1129
|
+
if (!res.ok) return null;
|
|
1130
|
+
const data = await res.json().catch(() => null);
|
|
1131
|
+
workToken = data?.data?.token ?? null;
|
|
1132
|
+
return workToken;
|
|
1133
|
+
} catch {
|
|
1134
|
+
return null;
|
|
1135
|
+
}
|
|
1136
|
+
};
|
|
1137
|
+
|
|
1138
|
+
/**
|
|
1139
|
+
* This tab's worktree — its held context, expressed as a place, ON A BRANCH.
|
|
1140
|
+
*
|
|
1141
|
+
* Fresh: branch `session/<id>` off the current base. Existing: touched not at
|
|
1142
|
+
* all — no fetch-reset-clean like a plan directory, because the dirty state
|
|
1143
|
+
* is the point. If the directory was retired but the branch survives, the
|
|
1144
|
+
* worktree re-attaches to the branch and the committed work is still there.
|
|
1145
|
+
*/
|
|
1146
|
+
const sessionWtFor = (sessionId) => {
|
|
1147
|
+
if (!isSafePathSegment(sessionId)) return null;
|
|
1148
|
+
const wt = join(baseDir, 'sessions', sessionId);
|
|
1149
|
+
const fresh = !existsSync(wt);
|
|
1150
|
+
if (fresh) {
|
|
1151
|
+
const branch = `session/${sessionId}`;
|
|
1152
|
+
try {
|
|
1153
|
+
git(['worktree', 'add', '-b', branch, wt, baseRef], repoRoot);
|
|
1154
|
+
} catch {
|
|
1155
|
+
git(['worktree', 'prune'], repoRoot);
|
|
1156
|
+
try {
|
|
1157
|
+
// The branch may already exist (a retired directory's work) — attach.
|
|
1158
|
+
git(['worktree', 'add', wt, branch], repoRoot);
|
|
1159
|
+
} catch {
|
|
1160
|
+
try {
|
|
1161
|
+
git(['worktree', 'add', '-b', branch, wt, baseRef], repoRoot);
|
|
1162
|
+
} catch {
|
|
1163
|
+
return null;
|
|
1164
|
+
}
|
|
1165
|
+
}
|
|
1166
|
+
}
|
|
1167
|
+
}
|
|
1168
|
+
return { wt, fresh };
|
|
1169
|
+
};
|
|
1170
|
+
|
|
1171
|
+
/**
|
|
1172
|
+
* Retire the least-recently-touched CLEAN session directories past the cap.
|
|
1173
|
+
* A dirty worktree is never touched — uncommitted work is the human's, and a
|
|
1174
|
+
* resource bound does not outrank it. Committed work survives retirement on
|
|
1175
|
+
* the session branch either way.
|
|
1176
|
+
*/
|
|
1177
|
+
const MAX_WORK_DIRS = 12;
|
|
1178
|
+
const workTouched = new Map(); // sessionId -> ms
|
|
1179
|
+
const retireIdleWorkSessions = () => {
|
|
1180
|
+
const dir = join(baseDir, 'sessions');
|
|
1181
|
+
if (!existsSync(dir)) return;
|
|
1182
|
+
let ids;
|
|
1183
|
+
try {
|
|
1184
|
+
ids = readdirSync(dir);
|
|
1185
|
+
} catch {
|
|
1186
|
+
return;
|
|
1187
|
+
}
|
|
1188
|
+
if (ids.length <= MAX_WORK_DIRS) return;
|
|
1189
|
+
const oldestFirst = ids.sort(
|
|
1190
|
+
(a, b) => (workTouched.get(a) ?? 0) - (workTouched.get(b) ?? 0)
|
|
1191
|
+
);
|
|
1192
|
+
let excess = ids.length - MAX_WORK_DIRS;
|
|
1193
|
+
for (const id of oldestFirst) {
|
|
1194
|
+
if (excess <= 0) break;
|
|
1195
|
+
const wt = join(dir, id);
|
|
1196
|
+
try {
|
|
1197
|
+
if (git(['status', '--porcelain'], wt).trim() !== '') continue; // dirty — skip
|
|
1198
|
+
git(['worktree', 'remove', wt], repoRoot);
|
|
1199
|
+
workTouched.delete(id);
|
|
1200
|
+
excess--;
|
|
1201
|
+
} catch {
|
|
1202
|
+
/* leave it; a directory we can't cleanly remove is not worth a turn */
|
|
1203
|
+
}
|
|
1204
|
+
}
|
|
1205
|
+
try {
|
|
1206
|
+
git(['worktree', 'prune'], repoRoot);
|
|
1207
|
+
} catch {
|
|
1208
|
+
/* best effort */
|
|
1209
|
+
}
|
|
1210
|
+
};
|
|
1211
|
+
|
|
1212
|
+
const processWorkTurns = (jobs) => {
|
|
1213
|
+
for (const job of jobs ?? []) {
|
|
1214
|
+
if (!job || typeof job.id !== 'string' || !job.body || !job.sessionId) continue;
|
|
1215
|
+
if (workAnswering.has(job.id)) continue;
|
|
1216
|
+
const tries = (workAttempts.get(job.id) ?? 0) + 1;
|
|
1217
|
+
if (tries > MAX_WORK_TRIES) continue;
|
|
1218
|
+
workAttempts.set(job.id, tries);
|
|
1219
|
+
workAnswering.add(job.id);
|
|
1220
|
+
const chain = workChains.get(job.sessionId) ?? Promise.resolve();
|
|
1221
|
+
workChains.set(
|
|
1222
|
+
job.sessionId,
|
|
1223
|
+
chain.then(async () => {
|
|
1224
|
+
try {
|
|
1225
|
+
note(
|
|
1226
|
+
`${c.cyan('tab')} ${c.dim(`— ${job.askedByName || 'the owner'} in "${job.sessionName || 'a session'}"`)}`
|
|
1227
|
+
);
|
|
1228
|
+
const workRt = pickRuntimeFor('build');
|
|
1229
|
+
if (!workRt) {
|
|
1230
|
+
warn('a session turn is waiting, but no installed CLI can build here');
|
|
1231
|
+
return;
|
|
1232
|
+
}
|
|
1233
|
+
const token = await mintWorkToken();
|
|
1234
|
+
if (!token) {
|
|
1235
|
+
warn('a session turn is waiting, but the work credential could not be minted');
|
|
1236
|
+
return;
|
|
1237
|
+
}
|
|
1238
|
+
const dir = sessionWtFor(job.sessionId);
|
|
1239
|
+
if (!dir) {
|
|
1240
|
+
warn('a session turn is waiting, but its worktree could not be opened');
|
|
1241
|
+
return;
|
|
1242
|
+
}
|
|
1243
|
+
workTouched.set(job.sessionId, Date.now());
|
|
1244
|
+
const resume = !dir.fresh && Boolean(job.sessionRef);
|
|
1245
|
+
const mcp = mcpFor(workRt, token, mcpUrl);
|
|
1246
|
+
let out;
|
|
1247
|
+
try {
|
|
1248
|
+
out = await runTurn({
|
|
1249
|
+
prompt: WORK_TURN_KICKOFF({
|
|
1250
|
+
sessionId: job.sessionId,
|
|
1251
|
+
sessionName: job.sessionName,
|
|
1252
|
+
message: job.body,
|
|
1253
|
+
askedByName: job.askedByName,
|
|
1254
|
+
}),
|
|
1255
|
+
resume,
|
|
1256
|
+
system: SYSTEM_WORK,
|
|
1257
|
+
cwd: dir.wt,
|
|
1258
|
+
mcpArgs: mcp.args,
|
|
1259
|
+
mcpEnv: mcp.env,
|
|
1260
|
+
runtime: workRt,
|
|
1261
|
+
label: c.cyan('[tab]'),
|
|
1262
|
+
});
|
|
1263
|
+
} finally {
|
|
1264
|
+
if (mcp.dir) rmSync(mcp.dir, { recursive: true, force: true });
|
|
1265
|
+
}
|
|
1266
|
+
const answer = (out || '').trim();
|
|
1267
|
+
const posted = await reportMergeOutcome(WORK_DONE_URL, {
|
|
1268
|
+
turnId: job.id,
|
|
1269
|
+
ok: answer.length > 0,
|
|
1270
|
+
// Scrub: a reply can quote config or env-adjacent code.
|
|
1271
|
+
answer: envScrub(answer).slice(0, 16000),
|
|
1272
|
+
sessionRef: dir.wt,
|
|
1273
|
+
});
|
|
1274
|
+
if (posted) workAttempts.delete(job.id);
|
|
1275
|
+
ok(`${c.cyan('tab')} ${c.dim('— replied in the session')}`);
|
|
1276
|
+
retireIdleWorkSessions();
|
|
1277
|
+
} catch (e) {
|
|
1278
|
+
await reportMergeOutcome(WORK_DONE_URL, {
|
|
1279
|
+
turnId: job.id,
|
|
1280
|
+
ok: false,
|
|
1281
|
+
answer: e?.message ?? 'the session turn failed',
|
|
1282
|
+
});
|
|
1283
|
+
warn(`session turn failed: ${e?.message ?? e}`);
|
|
1284
|
+
} finally {
|
|
1285
|
+
workAnswering.delete(job.id);
|
|
1286
|
+
}
|
|
1287
|
+
})
|
|
1288
|
+
);
|
|
1289
|
+
}
|
|
1290
|
+
};
|
|
1291
|
+
|
|
1292
|
+
// Ship — a session's branch merging to main, on the human's word.
|
|
1293
|
+
//
|
|
1294
|
+
// --no-ff, NEVER squash: every delivered card carries commit shas as its
|
|
1295
|
+
// receipts, and a squash would point them all at commits that no longer
|
|
1296
|
+
// exist on main. Sequence: refuse a dirty worktree (auto-committing someone's
|
|
1297
|
+
// mid-thought state is not shipping, it is guessing), fold main INTO the
|
|
1298
|
+
// branch first so conflicts surface in the worktree where the session can
|
|
1299
|
+
// resolve them, collect the branch's own commits (the server's
|
|
1300
|
+
// reconciliation input), then merge outward through a throwaway worktree so
|
|
1301
|
+
// nobody's checkout moves. Failures report INTO the tab — a ship that failed
|
|
1302
|
+
// silently leaves the human believing their work is on main.
|
|
1303
|
+
const SHIP_DONE_URL = FLEET_URL.replace(/\/agents\/?$/, '/ship-done');
|
|
1304
|
+
const shipping = new Set();
|
|
1305
|
+
let shipChain = Promise.resolve();
|
|
1306
|
+
|
|
1307
|
+
const processShipJobs = (jobs) => {
|
|
1308
|
+
for (const job of jobs ?? []) {
|
|
1309
|
+
if (!job || typeof job.sessionId !== 'string') continue;
|
|
1310
|
+
if (shipping.has(job.sessionId)) continue;
|
|
1311
|
+
shipping.add(job.sessionId);
|
|
1312
|
+
shipChain = shipChain.then(async () => {
|
|
1313
|
+
const done = (payload) =>
|
|
1314
|
+
reportMergeOutcome(SHIP_DONE_URL, { sessionId: job.sessionId, ...payload }).catch(
|
|
1315
|
+
() => {}
|
|
1316
|
+
);
|
|
1317
|
+
try {
|
|
1318
|
+
if (!isSafePathSegment(job.sessionId)) {
|
|
1319
|
+
await done({ ok: false, error: 'invalid session id' });
|
|
1320
|
+
return;
|
|
1321
|
+
}
|
|
1322
|
+
note(`${c.cyan('ship')} ${c.dim(`— "${job.sessionName || job.sessionId}"`)}`);
|
|
1323
|
+
const wt = join(baseDir, 'sessions', job.sessionId);
|
|
1324
|
+
if (!existsSync(wt)) {
|
|
1325
|
+
await done({ ok: false, error: 'no session worktree on this machine' });
|
|
1326
|
+
return;
|
|
1327
|
+
}
|
|
1328
|
+
if (git(['status', '--porcelain'], wt) !== '') {
|
|
1329
|
+
await done({
|
|
1330
|
+
ok: false,
|
|
1331
|
+
error:
|
|
1332
|
+
'the session has uncommitted changes — ask it to commit or discard them first',
|
|
1333
|
+
});
|
|
1334
|
+
return;
|
|
1335
|
+
}
|
|
1336
|
+
try {
|
|
1337
|
+
git(['fetch', 'origin', '--quiet'], repoRoot);
|
|
1338
|
+
} catch {
|
|
1339
|
+
/* offline fetch — merge against what we have */
|
|
1340
|
+
}
|
|
1341
|
+
// Fold main into the branch FIRST: conflicts land here, in the
|
|
1342
|
+
// session's own worktree, where the next turn can resolve them.
|
|
1343
|
+
try {
|
|
1344
|
+
git(['merge', '--no-edit', baseRef], wt);
|
|
1345
|
+
} catch {
|
|
1346
|
+
try {
|
|
1347
|
+
git(['merge', '--abort'], wt);
|
|
1348
|
+
} catch {
|
|
1349
|
+
/* nothing in progress */
|
|
1350
|
+
}
|
|
1351
|
+
await done({
|
|
1352
|
+
ok: false,
|
|
1353
|
+
error: 'conflicts with main — ask the session to resolve them, then ship again',
|
|
1354
|
+
});
|
|
1355
|
+
return;
|
|
1356
|
+
}
|
|
1357
|
+
// The branch's own commits — the server's reconciliation input.
|
|
1358
|
+
// --no-merges: fold-commits describe plumbing, not work.
|
|
1359
|
+
const commits = git([
|
|
1360
|
+
'log',
|
|
1361
|
+
`${baseRef}..HEAD`,
|
|
1362
|
+
'--no-merges',
|
|
1363
|
+
'--format=%H%x09%s',
|
|
1364
|
+
], wt)
|
|
1365
|
+
.split('\n')
|
|
1366
|
+
.filter(Boolean)
|
|
1367
|
+
.map((l) => {
|
|
1368
|
+
const [sha, ...rest] = l.split('\t');
|
|
1369
|
+
return { sha, subject: envScrub(rest.join('\t')).slice(0, 200) };
|
|
1370
|
+
});
|
|
1371
|
+
if (commits.length === 0) {
|
|
1372
|
+
await done({ ok: false, error: 'nothing to ship — no commits on the session branch' });
|
|
1373
|
+
return;
|
|
1374
|
+
}
|
|
1375
|
+
// Merge outward through a throwaway worktree so no checkout moves.
|
|
1376
|
+
const branch = `session/${job.sessionId}`;
|
|
1377
|
+
const tmp = join(baseDir, 'ship', job.sessionId);
|
|
1378
|
+
try {
|
|
1379
|
+
try {
|
|
1380
|
+
git(['worktree', 'remove', '--force', tmp], repoRoot);
|
|
1381
|
+
} catch {
|
|
1382
|
+
/* not there — fine */
|
|
1383
|
+
}
|
|
1384
|
+
git(['worktree', 'add', '--detach', tmp, baseRef], repoRoot);
|
|
1385
|
+
git([
|
|
1386
|
+
'merge',
|
|
1387
|
+
'--no-ff',
|
|
1388
|
+
branch,
|
|
1389
|
+
'-m',
|
|
1390
|
+
`ship(${job.sessionName || job.sessionId.slice(0, 8)}): ${commits.length} commit${commits.length === 1 ? '' : 's'}`,
|
|
1391
|
+
], tmp);
|
|
1392
|
+
git(['push', 'origin', `HEAD:${baseBranchName(baseRef)}`], tmp);
|
|
1393
|
+
} finally {
|
|
1394
|
+
try {
|
|
1395
|
+
git(['worktree', 'remove', '--force', tmp], repoRoot);
|
|
1396
|
+
git(['worktree', 'prune'], repoRoot);
|
|
1397
|
+
} catch {
|
|
1398
|
+
/* best effort */
|
|
1399
|
+
}
|
|
1400
|
+
}
|
|
1401
|
+
await done({ ok: true, commits });
|
|
1402
|
+
ok(`${c.cyan('ship')} ${c.dim(`— ${commits.length} commit${commits.length === 1 ? '' : 's'} on main`)}`);
|
|
1403
|
+
} catch (e) {
|
|
1404
|
+
await done({ ok: false, error: envScrub(e?.message ?? 'the merge failed').slice(0, 500) });
|
|
1405
|
+
warn(`ship failed: ${e?.message ?? e}`);
|
|
1406
|
+
} finally {
|
|
1407
|
+
shipping.delete(job.sessionId);
|
|
1408
|
+
}
|
|
1409
|
+
});
|
|
1410
|
+
}
|
|
1411
|
+
};
|
|
1412
|
+
|
|
933
1413
|
const processMergeJobs = (jobs) => {
|
|
934
1414
|
for (const job of jobs ?? []) {
|
|
935
1415
|
if (!job || typeof job.id !== 'string') continue; // a null element would wedge the loop
|
|
@@ -946,7 +1426,7 @@ export async function runFleetDaemon() {
|
|
|
946
1426
|
if (!isValidPrUrl(job.prUrl, originSlug(repoRoot))) {
|
|
947
1427
|
mergeAttempts.delete(job.id);
|
|
948
1428
|
await reportMergeOutcome(MERGE_FAILED_URL, {
|
|
949
|
-
|
|
1429
|
+
taskId: job.id,
|
|
950
1430
|
message: 'refused: PR URL is not a pull request in this repository',
|
|
951
1431
|
});
|
|
952
1432
|
warn(`merge REFUSED for "${job.title}": untrusted PR URL ${String(job.prUrl)}`);
|
|
@@ -973,7 +1453,7 @@ export async function runFleetDaemon() {
|
|
|
973
1453
|
if (!/no changes|already/i.test(err)) {
|
|
974
1454
|
mergeAttempts.delete(job.id);
|
|
975
1455
|
await reportMergeOutcome(MERGE_FAILED_URL, {
|
|
976
|
-
|
|
1456
|
+
taskId: job.id,
|
|
977
1457
|
message: `could not retarget the stacked PR onto ${baseBranchName(baseRef)} — merging it now would land in the branch below it, not ${baseBranchName(baseRef)}`,
|
|
978
1458
|
});
|
|
979
1459
|
warn(`merge held for "${job.title}": retarget failed — ${err.split('\n')[0]}`);
|
|
@@ -1010,7 +1490,7 @@ export async function runFleetDaemon() {
|
|
|
1010
1490
|
}
|
|
1011
1491
|
if (merged) {
|
|
1012
1492
|
mergeAttempts.delete(job.id);
|
|
1013
|
-
await reportMergeOutcome(MERGE_DONE_URL, {
|
|
1493
|
+
await reportMergeOutcome(MERGE_DONE_URL, { taskId: job.id });
|
|
1014
1494
|
ok(`${c.cyan('merged')} ${c.dim(`— ${job.title} → ${baseRef}`)}`);
|
|
1015
1495
|
// The code just landed — re-ground the living wiki for what shipped
|
|
1016
1496
|
// (touched nodes re-read + a persistent feature-history node).
|
|
@@ -1023,7 +1503,7 @@ export async function runFleetDaemon() {
|
|
|
1023
1503
|
// button + notifies) — the job disappears from the roster.
|
|
1024
1504
|
mergeAttempts.delete(job.id);
|
|
1025
1505
|
await reportMergeOutcome(MERGE_FAILED_URL, {
|
|
1026
|
-
|
|
1506
|
+
taskId: job.id,
|
|
1027
1507
|
message: failedReason,
|
|
1028
1508
|
});
|
|
1029
1509
|
warn(`merge failed for "${job.title}": ${failedReason} — reported to the thread`);
|
|
@@ -1083,7 +1563,7 @@ export async function runFleetDaemon() {
|
|
|
1083
1563
|
} else if (job.prUrl || job.branch) {
|
|
1084
1564
|
warn(`cleanup REFUSED for "${job.title}": untrusted PR/branch value`);
|
|
1085
1565
|
}
|
|
1086
|
-
await reportMergeOutcome(CLEANUP_DONE_URL, {
|
|
1566
|
+
await reportMergeOutcome(CLEANUP_DONE_URL, { taskId: job.id });
|
|
1087
1567
|
ok(`${c.cyan('cleaned')} ${c.dim(`— ${job.title}`)}`);
|
|
1088
1568
|
} finally {
|
|
1089
1569
|
cleaning.delete(job.id);
|
|
@@ -1397,7 +1877,7 @@ export async function runFleetDaemon() {
|
|
|
1397
1877
|
// sweep), so a failing re-ground can't loop-burn quota. Only a
|
|
1398
1878
|
// crash BEFORE this line leaves the job listed for a retry.
|
|
1399
1879
|
regroundAttempts.delete(task.intentId);
|
|
1400
|
-
await reportMergeOutcome(REGROUND_DONE_URL, {
|
|
1880
|
+
await reportMergeOutcome(REGROUND_DONE_URL, { taskId: task.intentId });
|
|
1401
1881
|
}
|
|
1402
1882
|
} catch (e) {
|
|
1403
1883
|
warn(`wiki ${task.type} failed: ${e.message}`);
|
|
@@ -1526,6 +2006,8 @@ export async function runFleetDaemon() {
|
|
|
1526
2006
|
processPatchRevertJobs(roster.patchRevertJobs);
|
|
1527
2007
|
processPlanCheckJobs(roster.planCheckJobs);
|
|
1528
2008
|
processConsultJobs(roster.consultJobs);
|
|
2009
|
+
processWorkTurns(roster.workTurnJobs);
|
|
2010
|
+
processShipJobs(roster.shipJobs);
|
|
1529
2011
|
processJoinJobs(roster.joinJobs);
|
|
1530
2012
|
processCleanupJobs(roster.cleanupJobs);
|
|
1531
2013
|
const rosterIds = new Set(roster.agents.map((a) => a.agentId));
|
|
@@ -1553,7 +2035,13 @@ export async function runFleetDaemon() {
|
|
|
1553
2035
|
mintedAt.set(a.agentId, Date.now());
|
|
1554
2036
|
}
|
|
1555
2037
|
hasWorkByAgent.set(a.agentId, !!a.hasWork);
|
|
1556
|
-
|
|
2038
|
+
// The hint's task id, new name first. Normalized ONTO `intentId` here so
|
|
2039
|
+
// every downstream read (poll worker, kickoff, diffstat attribution)
|
|
2040
|
+
// keeps its one spelling — intent is still the daemon's internal word,
|
|
2041
|
+
// taskId is the wire's.
|
|
2042
|
+
const nextId = a.next && (a.next.taskId ?? a.next.intentId);
|
|
2043
|
+
if (a.next && typeof nextId === 'string')
|
|
2044
|
+
nextByAgent.set(a.agentId, { ...a.next, intentId: nextId });
|
|
1557
2045
|
else nextByAgent.delete(a.agentId);
|
|
1558
2046
|
if (!workers.has(a.agentId)) {
|
|
1559
2047
|
// Local ceiling, enforced and not merely requested. The roster can carry
|
|
@@ -1647,8 +2135,9 @@ export async function runFleetDaemon() {
|
|
|
1647
2135
|
// whose earlier mint failed.
|
|
1648
2136
|
enqueueSweep(roster.codeMapJob);
|
|
1649
2137
|
for (const j of roster.regroundJobs ?? []) {
|
|
1650
|
-
|
|
1651
|
-
|
|
2138
|
+
const rid = j && (j.taskId ?? j.intentId); // new name first, old as fallback
|
|
2139
|
+
if (!j || typeof rid !== 'string') continue; // a null element would throw + wedge the loop
|
|
2140
|
+
enqueueReground(rid, j.prUrl, j.title, j.dirtiesPages);
|
|
1652
2141
|
}
|
|
1653
2142
|
void drainWiki();
|
|
1654
2143
|
|