flowviant 0.63.0 → 0.65.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 +19 -0
- package/bin/lib/fleet.mjs +2 -39
- package/bin/lib/placeLock.mjs +106 -0
- package/bin/lib/processes.mjs +202 -0
- package/bin/lib/work.mjs +102 -436
- package/package.json +1 -1
- package/bin/lib/devResolve.mjs +0 -167
- package/bin/lib/devServer.mjs +0 -306
package/bin/lib/work.mjs
CHANGED
|
@@ -40,9 +40,9 @@ import {
|
|
|
40
40
|
} from './config.mjs';
|
|
41
41
|
import { git, gitRaw, splitNul, baseBranchName, isSafePathSegment } from './git.mjs';
|
|
42
42
|
import { listenersIn, listenersSupported } from './listeners.mjs';
|
|
43
|
+
import { processesInGroups, liveGroups, processesSupported } from './processes.mjs';
|
|
44
|
+
import { createPlaceLock } from './placeLock.mjs';
|
|
43
45
|
import { openTunnel } from './preview.mjs';
|
|
44
|
-
import { startDevServer, reapOrphanDevRuns, killDevRunEntry } from './devServer.mjs';
|
|
45
|
-
import { resolveDevCommandOnMachine } from './devResolve.mjs';
|
|
46
46
|
import { c, note, ok, warn } from './ui.mjs';
|
|
47
47
|
import { mcpFor, runTurn } from './claude.mjs';
|
|
48
48
|
import {
|
|
@@ -109,56 +109,48 @@ export function createWorkManager({ repoRoot, baseDir, baseRef, getMcpUrl, getLe
|
|
|
109
109
|
const DIFF_DONE_URL = FLEET_URL.replace(/\/agents\/?$/, '/diff-done');
|
|
110
110
|
const PREVIEW_CLAIM_URL = FLEET_URL.replace(/\/agents\/?$/, '/preview-claim');
|
|
111
111
|
const PREVIEW_DONE_URL = FLEET_URL.replace(/\/agents\/?$/, '/preview-done');
|
|
112
|
-
const DEV_RUN_CLAIM_URL = FLEET_URL.replace(/\/agents\/?$/, '/dev-run-claim');
|
|
113
|
-
const DEV_RUN_DONE_URL = FLEET_URL.replace(/\/agents\/?$/, '/dev-run-done');
|
|
114
|
-
const DEV_RUN_RESOLVED_URL = FLEET_URL.replace(/\/agents\/?$/, '/dev-run-resolved');
|
|
115
|
-
const DEV_RUN_PROGRESS_URL = FLEET_URL.replace(/\/agents\/?$/, '/dev-run-progress');
|
|
116
112
|
const SESSION_COMMANDS_URL = FLEET_URL.replace(/\/agents\/?$/, '/session-commands');
|
|
117
113
|
const ATTACHMENT_URL = FLEET_URL.replace(/\/agents\/?$/, '/attachment');
|
|
118
114
|
const workAnswering = new Set(); // turn ids currently queued/running here
|
|
119
115
|
const workAttempts = new Map(); // turn id -> completed runTurn attempts
|
|
120
116
|
const MAX_WORK_TRIES = 3;
|
|
121
117
|
const shipping = new Set(); // sessionIds with a ship queued/running here
|
|
118
|
+
const { placeLocks, inPlace } = createPlaceLock();
|
|
119
|
+
|
|
122
120
|
/**
|
|
123
|
-
*
|
|
124
|
-
* must land in order (they share a directory and a context), but two tabs
|
|
125
|
-
* are two terminals — the human opened both on purpose. Ship jobs ride the
|
|
126
|
-
* SAME chain, never a separate one: a ship must not run git in a worktree
|
|
127
|
-
* while that session's turn has a live CLI in it.
|
|
128
|
-
*/
|
|
129
|
-
const workChains = new Map(); // sessionId -> settled-safe tail promise
|
|
130
|
-
/**
|
|
131
|
-
* Serialize work by PLACE — the directory — not by session.
|
|
121
|
+
* WHICH PROCESS GROUPS EACH TAB HAS STARTED.
|
|
132
122
|
*
|
|
133
|
-
*
|
|
134
|
-
*
|
|
135
|
-
*
|
|
136
|
-
*
|
|
137
|
-
*
|
|
138
|
-
* they take turns.
|
|
123
|
+
* A turn's CLI is spawned `detached`, so its pid is a process-group id and
|
|
124
|
+
* everything the agent starts inherits it — through `nohup` and `setsid`,
|
|
125
|
+
* which is precisely where attribution by ppid falls apart. Kept per SESSION
|
|
126
|
+
* and not per turn: the point of the feature is the watcher that outlives the
|
|
127
|
+
* turn that started it.
|
|
139
128
|
*
|
|
140
|
-
*
|
|
141
|
-
*
|
|
142
|
-
*
|
|
129
|
+
* PRUNED ON EVERY READ against the kernel, which is not housekeeping. A pgid
|
|
130
|
+
* is a pid and pids are recycled, so an un-pruned set would eventually
|
|
131
|
+
* attribute a stranger's process to a tab that has been closed for a week.
|
|
143
132
|
*/
|
|
144
|
-
const
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
const
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
133
|
+
const sessionGroups = new Map(); // sessionId -> Set<pgid>
|
|
134
|
+
|
|
135
|
+
const noteSessionGroup = (sessionId, pgid) => {
|
|
136
|
+
if (!sessionId || !pgid) return;
|
|
137
|
+
const set = sessionGroups.get(sessionId) ?? new Set();
|
|
138
|
+
set.add(pgid);
|
|
139
|
+
sessionGroups.set(sessionId, set);
|
|
140
|
+
};
|
|
141
|
+
|
|
142
|
+
/** This tab's live processes, or null where the machine cannot look. */
|
|
143
|
+
const sessionProcesses = (sessionId) => {
|
|
144
|
+
if (!processesSupported()) return null;
|
|
145
|
+
const known = sessionGroups.get(sessionId);
|
|
146
|
+
if (!known || known.size === 0) return [];
|
|
147
|
+
const alive = liveGroups(known);
|
|
148
|
+
if (alive.size === 0) sessionGroups.delete(sessionId);
|
|
149
|
+
else sessionGroups.set(sessionId, alive);
|
|
150
|
+
return processesInGroups(alive, { scrub: envScrub });
|
|
160
151
|
};
|
|
161
152
|
|
|
153
|
+
|
|
162
154
|
/**
|
|
163
155
|
* EVERY turn settles — the work loop's prime contract. A pending turn nobody
|
|
164
156
|
* answers holds one of the tab's slots until the server expires it (24h);
|
|
@@ -372,10 +364,29 @@ export function createWorkManager({ repoRoot, baseDir, baseRef, getMcpUrl, getLe
|
|
|
372
364
|
* the server does not name keeps whatever a turn taught, and failing that its
|
|
373
365
|
* own id, which is the pre-places default.
|
|
374
366
|
*/
|
|
367
|
+
/**
|
|
368
|
+
* WHERE EACH TAB WORKS — and it has to be able to UNLEARN.
|
|
369
|
+
*
|
|
370
|
+
* This only ever set. Combined with a server that omitted null places, a tab
|
|
371
|
+
* moved from the checkout back to its own worktree simply stopped being
|
|
372
|
+
* mentioned, and this map kept the old value forever. `placeDir` decides
|
|
373
|
+
* where a turn is SPAWNED and where the worktree is measured, so the browser
|
|
374
|
+
* said "its own worktree" while the CLI went on working in the checkout, and
|
|
375
|
+
* the tab reported the checkout's listeners as its own. That is the exact
|
|
376
|
+
* confusion the whole places feature exists to prevent.
|
|
377
|
+
*
|
|
378
|
+
* An explicit `null` now means "its own worktree" and DELETES the entry.
|
|
379
|
+
* Absence of the whole map still means "an older server said nothing", which
|
|
380
|
+
* is the only thing absence can safely mean.
|
|
381
|
+
*/
|
|
375
382
|
const learnPlaces = (map) => {
|
|
376
383
|
if (!map || typeof map !== 'object') return;
|
|
377
384
|
for (const [sid, place] of Object.entries(map)) {
|
|
378
385
|
if (typeof place === 'string' && place) sessionPlaces.set(sid, place);
|
|
386
|
+
// null / '' / anything else: the server is telling us this tab is in its
|
|
387
|
+
// OWN worktree. Falling back to the default requires forgetting, not
|
|
388
|
+
// ignoring.
|
|
389
|
+
else sessionPlaces.delete(sid);
|
|
379
390
|
}
|
|
380
391
|
};
|
|
381
392
|
/** The DIRECTORY a session works in. Every path that used to build
|
|
@@ -428,11 +439,21 @@ export function createWorkManager({ repoRoot, baseDir, baseRef, getMcpUrl, getLe
|
|
|
428
439
|
// failed scan reports nothing, and both were indistinguishable from an idle
|
|
429
440
|
// worktree — harmless while the only consumer needed a NON-empty array, and
|
|
430
441
|
// a permanent `Starting…` the moment a Run dev offer hangs off an empty one.
|
|
442
|
+
// …AND WHAT IT IS RUNNING, attributed by PROCESS GROUP rather than by cwd.
|
|
443
|
+
// A watcher (`rbxtsc -w`, `tsc --watch`) holds no socket and touches no
|
|
444
|
+
// file for minutes, so it was invisible from a browser in a way it never is
|
|
445
|
+
// in a terminal. Same rules as `listening` beside it: a daemon→server
|
|
446
|
+
// report on an endpoint that already exists, so NO version floor, and
|
|
447
|
+
// `processesSupported` keeps "cannot look" (Windows) apart from "looked and
|
|
448
|
+
// found none", which renders differently.
|
|
449
|
+
const processes = sessionProcesses(sessionId);
|
|
431
450
|
return {
|
|
432
451
|
sessionId,
|
|
433
452
|
...d,
|
|
434
453
|
listening: listenersIn(wt),
|
|
435
454
|
listeningSupported: listenersSupported(),
|
|
455
|
+
...(processes === null ? {} : { processes }),
|
|
456
|
+
processesSupported: processesSupported(),
|
|
436
457
|
};
|
|
437
458
|
};
|
|
438
459
|
/** One session, now — called after its turn settles. */
|
|
@@ -734,387 +755,24 @@ export function createWorkManager({ repoRoot, baseDir, baseRef, getMcpUrl, getLe
|
|
|
734
755
|
argv.every((a) => typeof a === 'string' && a.length > 0 && a.length <= 200) &&
|
|
735
756
|
!argv.some((a) => /[&|;<>`$(){}*?~\\]/.test(a));
|
|
736
757
|
|
|
737
|
-
// ── DEV RUNS
|
|
758
|
+
// ── DEV RUNS ARE DELETED (2026-08-26) ─────────────────────────────────
|
|
738
759
|
//
|
|
739
|
-
// The
|
|
740
|
-
//
|
|
741
|
-
//
|
|
742
|
-
|
|
743
|
-
|
|
744
|
-
|
|
745
|
-
|
|
746
|
-
|
|
747
|
-
|
|
748
|
-
|
|
749
|
-
|
|
750
|
-
|
|
751
|
-
|
|
752
|
-
|
|
753
|
-
|
|
754
|
-
|
|
755
|
-
body: JSON.stringify({ ...body, instance: DAEMON_INSTANCE }),
|
|
756
|
-
});
|
|
757
|
-
} catch {
|
|
758
|
-
/* the row stops being confirmed and reads as stopped — which is true */
|
|
759
|
-
}
|
|
760
|
-
};
|
|
761
|
-
|
|
762
|
-
const claimDevRun = async (sessionId) => {
|
|
763
|
-
try {
|
|
764
|
-
const res = await fetch(DEV_RUN_CLAIM_URL, {
|
|
765
|
-
method: 'POST',
|
|
766
|
-
headers: {
|
|
767
|
-
Authorization: `Bearer ${FLEET_TOKEN}`,
|
|
768
|
-
'User-Agent': USER_AGENT,
|
|
769
|
-
'Content-Type': 'application/json',
|
|
770
|
-
},
|
|
771
|
-
signal: AbortSignal.timeout(30_000),
|
|
772
|
-
body: JSON.stringify({ sessionId, instance: DAEMON_INSTANCE }),
|
|
773
|
-
});
|
|
774
|
-
const j = await res.json().catch(() => ({}));
|
|
775
|
-
return Boolean(j?.data?.claimed);
|
|
776
|
-
} catch {
|
|
777
|
-
return false;
|
|
778
|
-
}
|
|
779
|
-
};
|
|
780
|
-
|
|
781
|
-
/**
|
|
782
|
-
* The answer to a `resolve`, handed up as a STRING for the server to parse.
|
|
783
|
-
*
|
|
784
|
-
* Never argv: `parseDevCommand` is the single owner of what may execute, and
|
|
785
|
-
* a second implementation of that policy inside the one component a deploy
|
|
786
|
-
* cannot upgrade is exactly the drift this product keeps closing.
|
|
787
|
-
*/
|
|
788
|
-
/**
|
|
789
|
-
* THE RESOLVE TURN'S OUTPUT, streamed while it happens.
|
|
790
|
-
*
|
|
791
|
-
* A headless turn is the one place in this product where a Claude works and
|
|
792
|
-
* nobody can see it — deliberately, since it must not reach the transcript —
|
|
793
|
-
* and the answer to that is transparency rather than a promise: "we could
|
|
794
|
-
* have it stream the output for transparency on the menu."
|
|
795
|
-
*
|
|
796
|
-
* THROTTLED, and the tail is BOUNDED at the machine. This is a per-line hook
|
|
797
|
-
* on a turn that may run for minutes; posting each line would be hundreds of
|
|
798
|
-
* requests, and sending the whole transcript would grow without limit. Same
|
|
799
|
-
* shape as the session activity relay, for the same reasons.
|
|
800
|
-
*/
|
|
801
|
-
const devProgress = new Map(); // sessionId → { lines, at, timer }
|
|
802
|
-
const DEV_PROGRESS_MS = 2_000;
|
|
803
|
-
const DEV_PROGRESS_LINES = 40;
|
|
804
|
-
|
|
805
|
-
const flushDevProgress = async (sessionId) => {
|
|
806
|
-
const st = devProgress.get(sessionId);
|
|
807
|
-
if (!st || !st.lines.length) return;
|
|
808
|
-
st.at = Date.now();
|
|
809
|
-
const logTail = st.lines.join('\n').slice(-4000);
|
|
810
|
-
try {
|
|
811
|
-
await fetch(DEV_RUN_PROGRESS_URL, {
|
|
812
|
-
method: 'POST',
|
|
813
|
-
headers: {
|
|
814
|
-
Authorization: `Bearer ${FLEET_TOKEN}`,
|
|
815
|
-
'User-Agent': USER_AGENT,
|
|
816
|
-
'Content-Type': 'application/json',
|
|
817
|
-
},
|
|
818
|
-
signal: AbortSignal.timeout(15_000),
|
|
819
|
-
body: JSON.stringify({ sessionId, instance: DAEMON_INSTANCE, logTail }),
|
|
820
|
-
});
|
|
821
|
-
} catch {
|
|
822
|
-
/* progress is best-effort — the row's own state is the truth */
|
|
823
|
-
}
|
|
824
|
-
};
|
|
825
|
-
|
|
826
|
-
const noteDevProgress = (sessionId, label) => {
|
|
827
|
-
if (!label) return;
|
|
828
|
-
const st = devProgress.get(sessionId) ?? { lines: [], at: 0, timer: null };
|
|
829
|
-
st.lines.push(String(label).slice(0, 300));
|
|
830
|
-
if (st.lines.length > DEV_PROGRESS_LINES) st.lines.splice(0, st.lines.length - DEV_PROGRESS_LINES);
|
|
831
|
-
devProgress.set(sessionId, st);
|
|
832
|
-
// Leading-edge post, then a trailing one — so the FIRST line appears at
|
|
833
|
-
// once (an empty panel for two seconds reads as nothing happening) and the
|
|
834
|
-
// last line is never left unsent.
|
|
835
|
-
if (Date.now() - st.at > DEV_PROGRESS_MS) {
|
|
836
|
-
void flushDevProgress(sessionId);
|
|
837
|
-
return;
|
|
838
|
-
}
|
|
839
|
-
if (st.timer) return;
|
|
840
|
-
st.timer = setTimeout(() => {
|
|
841
|
-
st.timer = null;
|
|
842
|
-
void flushDevProgress(sessionId);
|
|
843
|
-
}, DEV_PROGRESS_MS);
|
|
844
|
-
st.timer.unref?.();
|
|
845
|
-
};
|
|
846
|
-
|
|
847
|
-
const postDevResolved = async (body) => {
|
|
848
|
-
try {
|
|
849
|
-
await fetch(DEV_RUN_RESOLVED_URL, {
|
|
850
|
-
method: 'POST',
|
|
851
|
-
headers: {
|
|
852
|
-
Authorization: `Bearer ${FLEET_TOKEN}`,
|
|
853
|
-
'User-Agent': USER_AGENT,
|
|
854
|
-
'Content-Type': 'application/json',
|
|
855
|
-
},
|
|
856
|
-
signal: AbortSignal.timeout(30_000),
|
|
857
|
-
body: JSON.stringify({ ...body, instance: DAEMON_INSTANCE }),
|
|
858
|
-
});
|
|
859
|
-
} catch {
|
|
860
|
-
/* the row's own TTL is the backstop */
|
|
861
|
-
}
|
|
862
|
-
};
|
|
863
|
-
|
|
864
|
-
const stopDevRun = async (sessionId, reason) => {
|
|
865
|
-
const live = liveDevRuns.get(sessionId);
|
|
866
|
-
liveDevRuns.delete(sessionId);
|
|
867
|
-
try {
|
|
868
|
-
live?.stop?.();
|
|
869
|
-
} catch {
|
|
870
|
-
/* best-effort */
|
|
871
|
-
}
|
|
872
|
-
await postDevRun({ sessionId, ended: true, endedReason: reason });
|
|
873
|
-
};
|
|
874
|
-
|
|
875
|
-
const processDevRunJobs = (jobs) => {
|
|
876
|
-
if (!Array.isArray(jobs) || jobs.length === 0) return;
|
|
877
|
-
for (const job of jobs.slice(0, 5)) {
|
|
878
|
-
const sessionId = String(job?.sessionId || '');
|
|
879
|
-
if (!isSafePathSegment(sessionId)) continue;
|
|
880
|
-
|
|
881
|
-
if (job?.action === 'stop') {
|
|
882
|
-
if (devRunClaiming.has(sessionId)) continue;
|
|
883
|
-
devRunClaiming.add(sessionId);
|
|
884
|
-
void stopDevRun(sessionId, 'stopped').finally(() => devRunClaiming.delete(sessionId));
|
|
885
|
-
continue;
|
|
886
|
-
}
|
|
887
|
-
|
|
888
|
-
/**
|
|
889
|
-
* RESOLVE — ask a Claude what starts this project, and say so. Starts
|
|
890
|
-
* NOTHING.
|
|
891
|
-
*
|
|
892
|
-
* It exists because the sheet used to open with a text field prefilled
|
|
893
|
-
* `npm run dev`, which presumes a stack. The turn is headless and reaches
|
|
894
|
-
* no transcript: "i still want claude to start the server for me but i
|
|
895
|
-
* dont want it to literally open a chat. have it do it in the
|
|
896
|
-
* background."
|
|
897
|
-
*
|
|
898
|
-
* IT TAKES THE PLACE LOCK, through the same `chainFor` every turn goes
|
|
899
|
-
* through, and that is deliberate rather than incidental. The turn reads
|
|
900
|
-
* the repo and may `npm install` into this worktree; running it beside a
|
|
901
|
-
* tab turn editing the same directory is the exact collision the chain
|
|
902
|
-
* exists to prevent. The cost is that a resolve makes the tab wait, which
|
|
903
|
-
* is honest — you cannot usefully build while an install is running
|
|
904
|
-
* anyway — and it is bounded, because this turn ENDS. That is the whole
|
|
905
|
-
* reason it answers with a command instead of running one: a foreground
|
|
906
|
-
* `npm run dev` would never return, and the lock would be held for as
|
|
907
|
-
* long as the server lived.
|
|
908
|
-
*/
|
|
909
|
-
if (job?.action === 'resolve') {
|
|
910
|
-
if (liveDevRuns.has(sessionId)) continue;
|
|
911
|
-
if (devRunClaiming.has(sessionId)) continue;
|
|
912
|
-
devRunClaiming.add(sessionId);
|
|
913
|
-
void (async () => {
|
|
914
|
-
try {
|
|
915
|
-
if (!(await claimDevRun(sessionId))) return; // somebody else has it
|
|
916
|
-
const wt = placeDir(sessionId);
|
|
917
|
-
const out = await chainFor(placeOf(sessionId), () =>
|
|
918
|
-
resolveDevCommandOnMachine({
|
|
919
|
-
cwd: wt,
|
|
920
|
-
// The machine's own pin, exactly as a tab turn gets — never
|
|
921
|
-
// the user's global default, which for Claude may be a
|
|
922
|
-
// long-context tier their subscription cannot bill autonomous
|
|
923
|
-
// work on. This turn is autonomous by definition.
|
|
924
|
-
model: MODEL,
|
|
925
|
-
log: (m) => note(`${sessionId.slice(0, 8)}: ${m}`),
|
|
926
|
-
})
|
|
927
|
-
);
|
|
928
|
-
// The last lines, before the row leaves the resolving state.
|
|
929
|
-
await flushDevProgress(sessionId);
|
|
930
|
-
await postDevResolved({
|
|
931
|
-
sessionId,
|
|
932
|
-
command: out?.command ?? null,
|
|
933
|
-
error: out?.error ?? null,
|
|
934
|
-
});
|
|
935
|
-
} catch (e) {
|
|
936
|
-
await postDevResolved({
|
|
937
|
-
sessionId,
|
|
938
|
-
command: null,
|
|
939
|
-
error: `the machine could not run that turn: ${e?.message ?? 'unknown error'}`,
|
|
940
|
-
});
|
|
941
|
-
} finally {
|
|
942
|
-
const st = devProgress.get(sessionId);
|
|
943
|
-
if (st?.timer) clearTimeout(st.timer);
|
|
944
|
-
devProgress.delete(sessionId);
|
|
945
|
-
devRunClaiming.delete(sessionId);
|
|
946
|
-
}
|
|
947
|
-
})();
|
|
948
|
-
continue;
|
|
949
|
-
}
|
|
950
|
-
|
|
951
|
-
// RE-VALIDATE THE SHAPE at this boundary. The server parsed the string
|
|
952
|
-
// and owns the policy; the machine owns the refusal to execute something
|
|
953
|
-
// malformed, because one place doing a check is one deploy away from
|
|
954
|
-
// being zero places.
|
|
955
|
-
const argv = Array.isArray(job?.argv) ? job.argv.map(String) : [];
|
|
956
|
-
if (!isPlausibleDevArgv(argv)) {
|
|
957
|
-
void postDevRun({
|
|
958
|
-
sessionId,
|
|
959
|
-
started: false,
|
|
960
|
-
endedReason: 'refused',
|
|
961
|
-
error: 'the machine did not recognise that command',
|
|
962
|
-
});
|
|
963
|
-
continue;
|
|
964
|
-
}
|
|
965
|
-
// Already serving this tab. Re-starting would kill a server somebody is
|
|
966
|
-
// looking at right now.
|
|
967
|
-
if (liveDevRuns.has(sessionId)) continue;
|
|
968
|
-
if (devRunClaiming.has(sessionId)) continue;
|
|
969
|
-
devRunClaiming.add(sessionId);
|
|
970
|
-
|
|
971
|
-
void (async () => {
|
|
972
|
-
try {
|
|
973
|
-
if (!(await claimDevRun(sessionId))) return; // somebody else has it
|
|
974
|
-
const wt = placeDir(sessionId);
|
|
975
|
-
const r = await startDevServer({
|
|
976
|
-
sessionId,
|
|
977
|
-
worktree: wt,
|
|
978
|
-
argv,
|
|
979
|
-
log: (m) => note(`dev ${sessionId.slice(0, 8)}: ${m}`),
|
|
980
|
-
onState: (st) => {
|
|
981
|
-
void postDevRun({ sessionId, ...st });
|
|
982
|
-
// Re-report the worktree at once so the chip flips within a
|
|
983
|
-
// second instead of waiting up to 60s for the sweep.
|
|
984
|
-
void reportSessionWorktree(sessionId).catch(() => undefined);
|
|
985
|
-
},
|
|
986
|
-
onExit: (ex) => {
|
|
987
|
-
liveDevRuns.delete(sessionId);
|
|
988
|
-
void postDevRun({ sessionId, ended: true, ...ex });
|
|
989
|
-
},
|
|
990
|
-
});
|
|
991
|
-
if (!r.ok) {
|
|
992
|
-
await postDevRun({
|
|
993
|
-
sessionId,
|
|
994
|
-
started: false,
|
|
995
|
-
endedReason: r.endedReason ?? 'spawn_failed',
|
|
996
|
-
error: r.error ?? 'the machine could not start it',
|
|
997
|
-
});
|
|
998
|
-
return;
|
|
999
|
-
}
|
|
1000
|
-
liveDevRuns.set(sessionId, { stop: r.stop, pid: r.pid });
|
|
1001
|
-
// THE AUDIT ROW. Without it a daemon-spawned dev server would be the
|
|
1002
|
-
// ONLY execution on this box with no entry in the very surface built
|
|
1003
|
-
// so an admin can answer "what ran on this machine" — and it would be
|
|
1004
|
-
// missing precisely the execution whose provenance is most worth
|
|
1005
|
-
// checking. `runtime: null` because no CLI ran it: the daemon did.
|
|
1006
|
-
void fetch(SESSION_COMMANDS_URL, {
|
|
1007
|
-
method: 'POST',
|
|
1008
|
-
headers: {
|
|
1009
|
-
Authorization: `Bearer ${FLEET_TOKEN}`,
|
|
1010
|
-
'User-Agent': USER_AGENT,
|
|
1011
|
-
'Content-Type': 'application/json',
|
|
1012
|
-
},
|
|
1013
|
-
signal: AbortSignal.timeout(30_000),
|
|
1014
|
-
body: JSON.stringify({
|
|
1015
|
-
sessionId,
|
|
1016
|
-
cwd: wt,
|
|
1017
|
-
commands: [{ command: argv.join(' '), at: new Date().toISOString() }],
|
|
1018
|
-
}),
|
|
1019
|
-
}).catch(() => {
|
|
1020
|
-
/* best-effort — the audit records what reached it */
|
|
1021
|
-
});
|
|
1022
|
-
} finally {
|
|
1023
|
-
devRunClaiming.delete(sessionId);
|
|
1024
|
-
}
|
|
1025
|
-
})();
|
|
1026
|
-
}
|
|
1027
|
-
};
|
|
1028
|
-
|
|
1029
|
-
/**
|
|
1030
|
-
* ADOPT what the previous process left behind.
|
|
1031
|
-
*
|
|
1032
|
-
* This is the half of "consistently running" that actually delivers it. A
|
|
1033
|
-
* self-update re-execs, and a same-repo takeover replaces this process — both
|
|
1034
|
-
* leave dev servers alive on purpose (see `shutdownDevRuns`), and without
|
|
1035
|
-
* adoption the successor would neither supervise them nor be able to stop
|
|
1036
|
-
* them, so the row would say running while nothing owned the process.
|
|
1037
|
-
*
|
|
1038
|
-
* Identity is the CWD plus liveness, never the command string: `npm run dev`
|
|
1039
|
-
* is identical across two tabs, two worktrees, and the driver's own
|
|
1040
|
-
* hand-started server, so a cmdline match would be indistinguishable from a
|
|
1041
|
-
* coincidence. A could-not-measure is NOT a match and kills nothing.
|
|
1042
|
-
*/
|
|
1043
|
-
const adoptDevRuns = (activeIds) => {
|
|
1044
|
-
let adopted = 0;
|
|
1045
|
-
for (const entry of reapOrphanDevRuns(activeIds, note)) {
|
|
1046
|
-
const wt = placeDir(entry.sessionId);
|
|
1047
|
-
// The recorded cwd must still be this session's worktree. A recycled pid
|
|
1048
|
-
// pointing anywhere else is somebody else's process.
|
|
1049
|
-
if (entry.cwd !== wt) continue;
|
|
1050
|
-
if (liveDevRuns.has(entry.sessionId)) continue;
|
|
1051
|
-
liveDevRuns.set(entry.sessionId, {
|
|
1052
|
-
pid: entry.pid,
|
|
1053
|
-
stop: () => killDevRunEntry(entry),
|
|
1054
|
-
});
|
|
1055
|
-
adopted += 1;
|
|
1056
|
-
// The output ring is EMPTY for an adopted run and the row says so rather
|
|
1057
|
-
// than pretending to a tail it does not have.
|
|
1058
|
-
void postDevRun({
|
|
1059
|
-
sessionId: entry.sessionId,
|
|
1060
|
-
started: true,
|
|
1061
|
-
pid: entry.pid,
|
|
1062
|
-
port: listenersIn(wt)[0]?.port ?? null,
|
|
1063
|
-
logTail: '[reattached after the machine restarted — earlier output is not kept]',
|
|
1064
|
-
});
|
|
1065
|
-
}
|
|
1066
|
-
if (adopted > 0) note(`re-attached ${adopted} dev server${adopted === 1 ? '' : 's'}`);
|
|
1067
|
-
};
|
|
1068
|
-
|
|
1069
|
-
/** The sessionIds this machine is still running, sent on the poll so the
|
|
1070
|
-
* server can tell a live run from one whose machine went away. */
|
|
1071
|
-
const liveDevRunIds = () => [...liveDevRuns.keys()];
|
|
1072
|
-
|
|
1073
|
-
/** A tab closed. ORDER MATTERS and the caller keeps it: the tunnel is retired
|
|
1074
|
-
* BEFORE the process it points at, so a viewer sees a dead link rather than
|
|
1075
|
-
* a 502 from a gate whose origin vanished — and both happen before
|
|
1076
|
-
* `retireWorkSessions` can `git worktree remove` the directory out from
|
|
1077
|
-
* under a running node process, which it would do without complaint because
|
|
1078
|
-
* its dirty check inspects only TRACKED files and node_modules is not one. */
|
|
1079
|
-
const retireDevRuns = (activeIds) => {
|
|
1080
|
-
if (!Array.isArray(activeIds)) return; // an older server, not a close
|
|
1081
|
-
const live = new Set(activeIds);
|
|
1082
|
-
for (const sessionId of [...liveDevRuns.keys()]) {
|
|
1083
|
-
if (live.has(sessionId)) continue;
|
|
1084
|
-
if (devRunClaiming.has(sessionId)) continue;
|
|
1085
|
-
devRunClaiming.add(sessionId);
|
|
1086
|
-
void stopDevRun(sessionId, 'tab_closed').finally(() => devRunClaiming.delete(sessionId));
|
|
1087
|
-
}
|
|
1088
|
-
};
|
|
1089
|
-
|
|
1090
|
-
/**
|
|
1091
|
-
* Daemon shutdown, and the SPLIT is what delivers "consistently running".
|
|
1092
|
-
*
|
|
1093
|
-
* `kill: true` — a human hit Ctrl-C, the credential was revoked, or a stop was
|
|
1094
|
-
* commanded. All three mean THIS MACHINE IS STANDING DOWN, and leaving dev
|
|
1095
|
-
* servers behind would strand them.
|
|
1096
|
-
*
|
|
1097
|
-
* `kill: false` — a same-repo takeover, or a self-update re-exec. Both mean
|
|
1098
|
-
* ANOTHER DAEMON IS ABOUT TO SERVE THIS REPO IN ONE SECOND, and killing would
|
|
1099
|
-
* mean `flowviant` re-run in your own directory restarts the app you were
|
|
1100
|
-
* watching, and a routine auto-update silently kills every dev server with
|
|
1101
|
-
* nothing to bring them back — precisely the goal defeated. The registry rows
|
|
1102
|
-
* are left for the successor to adopt.
|
|
1103
|
-
*/
|
|
1104
|
-
const shutdownDevRuns = (kill) => {
|
|
1105
|
-
if (!kill) {
|
|
1106
|
-
liveDevRuns.clear();
|
|
1107
|
-
return;
|
|
1108
|
-
}
|
|
1109
|
-
for (const [, live] of liveDevRuns) {
|
|
1110
|
-
try {
|
|
1111
|
-
live.stop?.();
|
|
1112
|
-
} catch {
|
|
1113
|
-
/* best-effort */
|
|
1114
|
-
}
|
|
1115
|
-
}
|
|
1116
|
-
liveDevRuns.clear();
|
|
1117
|
-
};
|
|
760
|
+
// The machine no longer starts application processes. It never learned to
|
|
761
|
+
// decide what "run dev" means for a stack nobody enumerated — `rojo serve`
|
|
762
|
+
// and `rbxtsc -w` are both correct for one Roblox repo and neither could
|
|
763
|
+
// clear the server's argv0 allowlist, and widening that list is a code change
|
|
764
|
+
// per ecosystem forever.
|
|
765
|
+
//
|
|
766
|
+
// Nothing downstream is lost, because SHARING NEVER DEPENDED ON US STARTING
|
|
767
|
+
// IT. `listenersIn` attributes a listening socket to a place by the cwd of
|
|
768
|
+
// the process holding it, so a server the driver's own agent started in the
|
|
769
|
+
// tab is measured exactly like one this file used to spawn. The web renders
|
|
770
|
+
// that measured list and a person picks which port to share.
|
|
771
|
+
//
|
|
772
|
+
// Gone with it: `devServer.mjs`, `devResolve.mjs`, the four `/fleet/dev-run-*`
|
|
773
|
+
// endpoints, the claim lease, the orphan registry at ~/.flowviant/devruns.json
|
|
774
|
+
// and its adopt-across-re-exec dance. If supervision is ever wanted back it
|
|
775
|
+
// returns as "supervise this process", never as "run dev".
|
|
1118
776
|
|
|
1119
777
|
const livePreviewIds = () => [...livePreviews.keys()];
|
|
1120
778
|
|
|
@@ -1811,7 +1469,10 @@ export function createWorkManager({ repoRoot, baseDir, baseRef, getMcpUrl, getLe
|
|
|
1811
1469
|
for (const id of ids) {
|
|
1812
1470
|
if (live.has(id)) continue;
|
|
1813
1471
|
if (peers.has(id)) continue; // another daemon's tab — not ours to retire
|
|
1814
|
-
|
|
1472
|
+
// Asked of the PLACE, not the session: the lock is keyed by directory,
|
|
1473
|
+
// and a session sharing one with a busy peer is not ours to retire
|
|
1474
|
+
// either — its worktree is the peer's working directory.
|
|
1475
|
+
if (placeLocks.has(placeOf(id)) || shipping.has(id)) continue; // still draining here
|
|
1815
1476
|
const wt = join(dir, id);
|
|
1816
1477
|
try {
|
|
1817
1478
|
// Uncommitted work is the human's — a resource sweep does not outrank
|
|
@@ -1848,7 +1509,8 @@ export function createWorkManager({ repoRoot, baseDir, baseRef, getMcpUrl, getLe
|
|
|
1848
1509
|
// Remembered for every other beat — the sweep, ship, the preview
|
|
1849
1510
|
// re-check — so they all ask the same directory this turn runs in.
|
|
1850
1511
|
sessionPlaces.set(job.sessionId, place);
|
|
1851
|
-
|
|
1512
|
+
// A READER: other turns in this place run alongside it. See `inPlace`.
|
|
1513
|
+
inPlace(place, false, async () => {
|
|
1852
1514
|
try {
|
|
1853
1515
|
const tries = workAttempts.get(job.id) ?? 0;
|
|
1854
1516
|
if (tries >= MAX_WORK_TRIES) {
|
|
@@ -2268,6 +1930,12 @@ export function createWorkManager({ repoRoot, baseDir, baseRef, getMcpUrl, getLe
|
|
|
2268
1930
|
if (!ch) return;
|
|
2269
1931
|
spawned.push(ch);
|
|
2270
1932
|
workChildren.set(ch, lockPath ?? null);
|
|
1933
|
+
// The CLI is spawned `detached`, so its pid IS its process
|
|
1934
|
+
// group id — and every process it starts inherits that, through
|
|
1935
|
+
// `nohup` and `setsid` alike. Remembered per SESSION rather
|
|
1936
|
+
// than per turn, because the whole point is the watcher that
|
|
1937
|
+
// outlives the turn that started it.
|
|
1938
|
+
if (ch.pid) noteSessionGroup(job.sessionId, ch.pid);
|
|
2271
1939
|
if (lockPath && ch.pid) {
|
|
2272
1940
|
try {
|
|
2273
1941
|
writeFileSync(lockPath, String(ch.pid));
|
|
@@ -2431,10 +2099,13 @@ export function createWorkManager({ repoRoot, baseDir, baseRef, getMcpUrl, getLe
|
|
|
2431
2099
|
// delivers it; re-running the ship would misread its own success.
|
|
2432
2100
|
if (pendingShipReports.has(job.sessionId)) continue;
|
|
2433
2101
|
shipping.add(job.sessionId);
|
|
2434
|
-
//
|
|
2435
|
-
//
|
|
2436
|
-
//
|
|
2437
|
-
|
|
2102
|
+
// A WRITER, keyed by PLACE: ship folds and merges with git in this
|
|
2103
|
+
// directory, and no CLI running here can coordinate with that. Keyed by
|
|
2104
|
+
// place rather than by session id, which is what this used to do while
|
|
2105
|
+
// turns keyed on the place — so the two never shared a lock and the
|
|
2106
|
+
// guarantee in this comment was not actually held. `shipping` (above)
|
|
2107
|
+
// keeps overlapping polls from queueing the same job twice.
|
|
2108
|
+
inPlace(placeOf(job.sessionId), true, async () => {
|
|
2438
2109
|
let settled = false;
|
|
2439
2110
|
let deferred = false;
|
|
2440
2111
|
const done = async (payload) => {
|
|
@@ -2771,13 +2442,13 @@ export function createWorkManager({ repoRoot, baseDir, baseRef, getMcpUrl, getLe
|
|
|
2771
2442
|
* half-finished answer settled as the tab's reply, and a queued-but-
|
|
2772
2443
|
* undelivered settle report would die in memory — after which the skip-
|
|
2773
2444
|
* guard's protection is gone and the re-exec'd daemon re-runs a turn whose
|
|
2774
|
-
* side effects (edits, commits, cards) already happened. `
|
|
2775
|
-
* an entry for every
|
|
2776
|
-
* when a
|
|
2777
|
-
* windows around it.
|
|
2445
|
+
* side effects (edits, commits, cards) already happened. `placeLocks` holds
|
|
2446
|
+
* an entry for every place with a running or waiting turn or ship (entries
|
|
2447
|
+
* self-delete when a place goes quiet); the other collections are belt over
|
|
2448
|
+
* braces for the windows around it.
|
|
2778
2449
|
*/
|
|
2779
2450
|
const workBusy = () =>
|
|
2780
|
-
|
|
2451
|
+
placeLocks.size > 0 ||
|
|
2781
2452
|
shipping.size > 0 ||
|
|
2782
2453
|
workChildren.size > 0 ||
|
|
2783
2454
|
workAnswering.size > 0 ||
|
|
@@ -2795,11 +2466,6 @@ export function createWorkManager({ repoRoot, baseDir, baseRef, getMcpUrl, getLe
|
|
|
2795
2466
|
livePreviewIds,
|
|
2796
2467
|
retirePreviews,
|
|
2797
2468
|
shutdownPreviews,
|
|
2798
|
-
processDevRunJobs,
|
|
2799
|
-
liveDevRunIds,
|
|
2800
|
-
retireDevRuns,
|
|
2801
|
-
shutdownDevRuns,
|
|
2802
|
-
adoptDevRuns,
|
|
2803
2469
|
retireWorkSessions,
|
|
2804
2470
|
reportWorktrees,
|
|
2805
2471
|
shutdownWork,
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "flowviant",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.65.0",
|
|
4
4
|
"description": "Run your own coding CLIs as build agents for Flowviant — Claude Code, Codex or Antigravity, on your own credentials. Holds your sessions, keeps a worktree per tab, and ships branches on your word.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"bin": {
|