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/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
- * Per-SESSION serialization, parallel ACROSS sessions: turns within one tab
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
- * It was keyed by session id, which was the same thing right up until a place
134
- * could be shared: two sessions pointed at one worktree had independent
135
- * chains, so their turns would run at the same time in the same directory and
136
- * edit each other's files mid-edit. Keying on the place is what makes "two
137
- * tabs in one repo" behave the way two terminal tabs in one repo behave —
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
- * The cross-PROCESS half was already right and needed no change: the turn
141
- * lock is a file inside the worktree (`flowviant-turn.lock`), so two sessions
142
- * sharing a place already share the lock by construction.
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 chainFor = (placeId, fn) => {
145
- const prev = workChains.get(placeId) ?? Promise.resolve();
146
- // `.then(fn, fn)`, like withWikiLock: one rejected link must never wedge
147
- // every later turn of the tab.
148
- const run = prev.then(fn, fn);
149
- const stored = run.then(
150
- () => {},
151
- () => {}
152
- );
153
- workChains.set(placeId, stored);
154
- // Release the entry when the chain drains, so the map cannot grow for the
155
- // process lifetime and `workChains.has()` means "busy right now".
156
- stored.then(() => {
157
- if (workChains.get(placeId) === stored) workChains.delete(placeId);
158
- });
159
- return run;
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 web asks for the PROJECT'S STORED command to run in a tab's worktree.
740
- // The argv arrives on the job, already parsed from a string a human approved;
741
- // nothing here reads a repo file to decide what executes. See devServer.mjs.
742
- const liveDevRuns = new Map(); // sessionId -> { stop, pid }
743
- const devRunClaiming = new Set();
744
-
745
- const postDevRun = async (body) => {
746
- try {
747
- await fetch(DEV_RUN_DONE_URL, {
748
- method: 'POST',
749
- headers: {
750
- Authorization: `Bearer ${FLEET_TOKEN}`,
751
- 'User-Agent': USER_AGENT,
752
- 'Content-Type': 'application/json',
753
- },
754
- signal: AbortSignal.timeout(30_000),
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
- if (workChains.has(id) || shipping.has(id)) continue; // still draining here
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
- chainFor(place, async () => {
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
- // The SESSION's own chain, never a ship-wide one: a ship must not run
2435
- // git in this worktree while a turn's CLI is live in it. `shipping`
2436
- // (above) keeps overlapping polls from queueing the same job twice.
2437
- chainFor(job.sessionId, async () => {
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. `workChains` holds
2775
- * an entry for every queued-or-running turn and ship (entries self-delete
2776
- * when a chain drains); the other collections are belt over braces for the
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
- workChains.size > 0 ||
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.63.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": {