flowviant 0.84.0 → 0.86.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/README.md CHANGED
@@ -53,6 +53,8 @@ Nothing starts a session except you opening a tab and typing in it.
53
53
 
54
54
  Press **Deploy** on the board and this machine runs a read-only scratch turn that proposes how the selected cards should be split across agents; accepting the proposal is what cuts worktrees and starts work. From **0.79.0** that planning turn is relayed the same way a session turn is — the reads, greps and thoughts the daemon was already printing behind `[plan]` now reach the press itself, along with the two facts only this side can see: the CLI actually starting, and the press waiting for the checkout while a ship or another turn holds it. A planning CLI that wedges is stopped after fifteen minutes and the press is reported failed in the machine's own words, rather than sitting silent until the server expires it half an hour later.
55
55
 
56
+ From **0.86.0**, a project whose owner turns on **Publish agent branches** has each agent's branch pushed to your `origin` under `flowviant/<name>-<id>` as it works — the same commits that are already on the agent's local `session/a-<id>`, under a name you can see in your host. The push is tail work after the turn settles: it never blocks or fails a turn, and a push that fails is reported back in git's own words. The app composes the name and this machine only ever pushes to the one it was given. Two things follow from it: work an agent did on a box that has gone away can be fetched and continued on another one (the commits, not the CLI's conversation — the next turn re-reads the card), and the remote branch is deleted once its work lands on your base branch — on a project that merges through pull requests it is also the branch the PR is opened from, so there is one branch per agent rather than two. Each push leases against the sha this daemon last saw at that ref, so a second machine pushing to the same name is reported back to you rather than overwritten. With the setting off, nothing is pushed anywhere.
57
+
56
58
  ## Sharing a preview
57
59
 
58
60
  You run your dev server yourself, in the session's own worktree, exactly as you would in any terminal. The daemon NOTICES the listening port; ask for a share in the app and it puts a [cloudflared](https://developers.cloudflare.com/cloudflare-one/connections/connect-networks/) quick tunnel in front of it (auto-fetched if missing, pinned and checksummed) behind a **mandatory password gate**. Flowviant stores only the tunnel URL; your browser talks to it directly. Since 0.72.0 the gate also lets the Workbench embed the share in a frame: it rewrites the response's frame policy to permit exactly the app's origin, and a framed sign-in gets a partitioned cookie so it works where third-party cookies are blocked.
@@ -0,0 +1,164 @@
1
+ /**
2
+ * AN AGENT'S BRANCH ON THE REMOTE — the argv this machine builds, and nothing
3
+ * else (daemon 0.86.0).
4
+ *
5
+ * The owner's ask: "instead of guessing or having no control over what branch
6
+ * the agents or flowviant is working on … flowviant should create a flowviant
7
+ * branch", settled as one isolated branch per agent under one shared prefix.
8
+ * Nothing about the agent's LOCAL branch changed — it is still
9
+ * `session/a-<agentId>`, cut in its own worktree. What is new is that this
10
+ * machine pushes it to `origin` under `flowviant/`, so the work is visible
11
+ * where the team already looks and survives the box that cut it.
12
+ *
13
+ * ── WHY THIS IS A MODULE AND NOT FOUR STRING LITERALS IN work.mjs ──
14
+ *
15
+ * Every function here composes a REFSPEC out of a value the SERVER named, and
16
+ * a refspec is the one place in this feature where a wrong string is
17
+ * destructive rather than merely useless: `:refs/heads/main` is how you delete
18
+ * somebody's base branch, and the delete argv is built from a ref the server
19
+ * read back out of a machine's own report. So the shape check and the argv
20
+ * construction live together, pure, and are tested for what they REFUSE as
21
+ * much as for what they build. The server validates the same shape at the door
22
+ * the value comes IN through (`PUBLISH_REF_RE`, agentPublish.ts); this is the
23
+ * door it goes OUT by, and one place doing a check is one deploy away from
24
+ * being zero places.
25
+ *
26
+ * A server-named ref is trusted exactly as `placeId` is trusted: a validated
27
+ * SHAPE, never a path, never a value that reaches argv unread.
28
+ */
29
+
30
+ import { isSafePathSegment } from './git.mjs';
31
+
32
+ /**
33
+ * THE SHAPE A PUBLISHED REF MAY HAVE — the server's own `PUBLISH_REF_RE`,
34
+ * spelled identically on purpose.
35
+ *
36
+ * The PREFIX is part of the shape rather than a convention. It is what keeps a
37
+ * report claiming `published.ref = 'main'` from coming back later as
38
+ * `git push origin :refs/heads/main` on the project's base branch. The tail is
39
+ * a whitelist, so the characters git refuses in a ref (`~ ^ : ? * [ \`, a
40
+ * space) cannot appear however the name was composed.
41
+ *
42
+ * Bounded at 80 rather than left open: the server cannot compose a longer one
43
+ * (a 40-char slug, a hyphen and six hex), so a longer value did not come from
44
+ * `publishTargetFor` and there is nothing to be gained by acting on it.
45
+ */
46
+ export const PUBLISH_REF_RE = /^flowviant\/[A-Za-z0-9._-]{1,80}$/;
47
+
48
+ /** True for a ref this machine may put in argv. Anything else is dropped in
49
+ * silence by the callers — a refusal here is a feature not happening, never a
50
+ * turn failing. */
51
+ export function isPublishRef(ref) {
52
+ return typeof ref === 'string' && PUBLISH_REF_RE.test(ref);
53
+ }
54
+
55
+ /** The local branch an agent works on. One definition, so a push, a fetch and
56
+ * the begun-guard's own `rev-parse` can never name three different branches. */
57
+ export function agentBranchRef(place) {
58
+ return isSafePathSegment(place) ? `refs/heads/session/${place}` : null;
59
+ }
60
+
61
+ /** A sha this machine MEASURED, in the one form `rev-parse` prints. The lease
62
+ * below is built from nothing else: an unrecognised value is treated as no
63
+ * observation at all rather than interpolated into argv. */
64
+ const SHA_RE = /^[0-9a-f]{7,40}$/;
65
+ export function isSha(v) {
66
+ return typeof v === 'string' && SHA_RE.test(v);
67
+ }
68
+
69
+ /**
70
+ * PUSH THIS AGENT'S BRANCH TO ITS PUBLISHED NAME.
71
+ *
72
+ * THE LEASE IS EXPLICIT, AND THE BARE FORM IS A TRAP THIS DAEMON WALKS INTO
73
+ * BY ITSELF. `--force-with-lease` with no value expects the REMOTE-TRACKING
74
+ * ref (`refs/remotes/origin/flowviant/…`) — and the worktree sweep three
75
+ * hundred lines away runs `git fetch origin --quiet` on its own beat, which
76
+ * refreshes exactly that ref. So the bare form's expectation is refreshed to
77
+ * whatever a rival box pushed moments ago, the "lease" passes, and the push
78
+ * overwrites the rival's commits in silence: proven in a sandbox, where the
79
+ * same push is `! [rejected] (stale info)` before the sweep's fetch and
80
+ * `(forced update)` after it. `--force-with-lease=<ref>:<sha>` names what THIS
81
+ * process last saw at that ref, which no background fetch can move.
82
+ *
83
+ * NO EXPECTATION MEANS NO FORCE AT ALL. A process that has neither pushed nor
84
+ * fetched this ref has observed nothing, and a force flag with nothing behind
85
+ * it is a bare `--force` wearing a safer name. Unforced, git creates the ref or
86
+ * fast-forwards it — every ordinary case, including the stale-merge fold, which
87
+ * MERGES base in and therefore leaves a descendant — and REFUSES a genuine
88
+ * divergence, which is the failure the caller reports rather than work it
89
+ * silently discards.
90
+ *
91
+ * Explicit `refs/heads/…` on BOTH sides: a short name lets git guess, and its
92
+ * guess for an unqualified destination that does not exist yet depends on the
93
+ * remote's own refs. A destination is being CREATED here most of the time.
94
+ */
95
+ export function publishPushArgs(place, ref, expectedSha = null) {
96
+ const src = agentBranchRef(place);
97
+ if (!src || !isPublishRef(ref)) return null;
98
+ const spec = `${src}:refs/heads/${ref}`;
99
+ return isSha(expectedSha)
100
+ ? ['push', `--force-with-lease=refs/heads/${ref}:${expectedSha}`, 'origin', spec]
101
+ : ['push', 'origin', spec];
102
+ }
103
+
104
+ /**
105
+ * FETCH A PUBLISHED BRANCH BACK INTO THE LOCAL NAME THIS MACHINE CONTINUES ON.
106
+ *
107
+ * NO LEADING `+`, and that is the whole safety of this call. The plus is git's
108
+ * force flag for a refspec, and a forced fetch onto `refs/heads/session/<place>`
109
+ * would overwrite a local branch holding commits this box has and the remote
110
+ * does not — the unpushed tail of a turn that died before its publish. Without
111
+ * it git refuses any non-fast-forward update, so the call can create the branch
112
+ * (the only case its caller is in) and can fast-forward one, and can destroy
113
+ * nothing.
114
+ */
115
+ export function publishFetchArgs(ref, place) {
116
+ const dst = agentBranchRef(place);
117
+ if (!dst || !isPublishRef(ref)) return null;
118
+ return ['fetch', 'origin', `refs/heads/${ref}:${dst}`];
119
+ }
120
+
121
+ /**
122
+ * RETIRE A PUBLISHED REF once its work is on base.
123
+ *
124
+ * The colon-prefixed refspec is a delete, and it is the most destructive argv
125
+ * this daemon can build — which is why it is composed only from a ref that
126
+ * passed `isPublishRef`, and why the prefix is part of that test. Nothing here
127
+ * decides WHEN: the server sends the ref on a merge job only for an agent whose
128
+ * push it actually heard about, and the caller runs this after the merge landed.
129
+ */
130
+ export function publishDeleteArgs(ref) {
131
+ return isPublishRef(ref) ? ['push', 'origin', `:refs/heads/${ref}`] : null;
132
+ }
133
+
134
+ /**
135
+ * WHY A PUSH DID NOT LAND, in git's own words — the DIAGNOSIS, not the head and
136
+ * not the last thing printed.
137
+ *
138
+ * Neither end of git's stderr is the answer. The HEAD is `To <url>`, so a
139
+ * first-line reader relays the remote's address and never the failure. The TAIL
140
+ * is usually advice ("Please make sure you have the correct access rights / and
141
+ * the repository exists."), which is true of every auth failure there has ever
142
+ * been and says nothing about this one. What a person needs is the two lines
143
+ * carrying a DIAGNOSTIC marker — `fatal:`, `error:`, `remote:`, a `!` rejection,
144
+ * a denial — which is where git puts the reason before it starts advising.
145
+ *
146
+ * The tail is the fallback for output that carries no marker at all, because a
147
+ * relay with nothing to relay must still say something.
148
+ *
149
+ * USERINFO IS STRIPPED. A remote URL can carry a token in `//user:token@host`,
150
+ * and this string is stored server-side and rendered to the whole project. The
151
+ * caller scrubs the machine's env values before this; neither check knows about
152
+ * the other's, which is why both run.
153
+ */
154
+ const DIAGNOSTIC_RE = /^(fatal:|error:|remote:|!)|\b(denied|rejected|refused)\b/i;
155
+ export function publishErrorText(raw) {
156
+ const lines = String(raw ?? '')
157
+ .replace(/\/\/[^/@\s]+@/g, '//')
158
+ .split('\n')
159
+ .map((l) => l.trim())
160
+ .filter(Boolean);
161
+ const said = lines.filter((l) => DIAGNOSTIC_RE.test(l));
162
+ const pick = (said.length ? said : lines).slice(-2).join(' ');
163
+ return (pick || 'the push failed').slice(0, 300);
164
+ }
package/bin/lib/work.mjs CHANGED
@@ -40,6 +40,13 @@ import {
40
40
  MODEL,
41
41
  } from './config.mjs';
42
42
  import { git, gitRaw, splitNul, baseBranchName, isSafePathSegment } from './git.mjs';
43
+ import {
44
+ isPublishRef,
45
+ publishPushArgs,
46
+ publishFetchArgs,
47
+ publishDeleteArgs,
48
+ publishErrorText,
49
+ } from './agentPublish.mjs';
43
50
  import { createLandedObserver } from './landed.mjs';
44
51
  import { listenersIn, measureListeners, listenersSupported } from './listeners.mjs';
45
52
  import { measureProcesses, liveGroups, processesSupported } from './processes.mjs';
@@ -619,6 +626,182 @@ export function createWorkManager({
619
626
  return place === REPO_PLACE ? repoRoot : join(baseDir, 'sessions', place);
620
627
  };
621
628
 
629
+ // ── PUBLISHING AN AGENT'S BRANCH ───────────────────────────────────────────
630
+ //
631
+ // The server names the target (`agentTurnJobs[].publishTo`) and this machine
632
+ // pushes to it. Two laws hold the whole lane up:
633
+ //
634
+ // A PUSH NEVER BLOCKS OR FAILS A TURN. It is tail work after the settle, it
635
+ // is try/catch'd like every sweep, and a failure is REPORTED in git's own
636
+ // words rather than thrown. An agent whose remote push fails has still done
637
+ // its work, still committed it, and still settled.
638
+ //
639
+ // THE SERVER LEARNS OF A PUSH ONLY BY BEING TOLD. Nothing here composes a
640
+ // name: an absent `publishTo` is a project that has publishing off, or a
641
+ // server older than this daemon, and in both the honest behaviour is to push
642
+ // nothing and report nothing. Absence keeps one meaning.
643
+ /**
644
+ * WHAT THIS MACHINE LAST DID WITH EACH AGENT'S BRANCH, by place —
645
+ * `{ ref, sha }` for a push that landed, `{ ref, error, at, tried }` for one
646
+ * that did not. Mutually exclusive by construction, which is what makes the
647
+ * report's two keys mutually exclusive without a second rule.
648
+ *
649
+ * Process-local on purpose: it is a record of what THIS daemon pushed, so a
650
+ * restart re-pushes once and re-reports — a push of the same sha to the same
651
+ * ref is a no-op at the remote, and re-learning beats trusting a file about
652
+ * something a rebase can invalidate.
653
+ */
654
+ const agentPublished = new Map();
655
+ /**
656
+ * WHAT THIS PROCESS HAS SEEN AT EACH AGENT'S REMOTE REF — `{ ref, sha }`, and
657
+ * the ONLY input to the push's lease.
658
+ *
659
+ * It is deliberately NOT `agentPublished`: that map is the REPORT record (what
660
+ * this machine pushed, and what the server may be told), while this one is an
661
+ * observation of the REMOTE's own position, which a box also gets by FETCHING
662
+ * a ref it never pushed. A box that fetch-continues an agent has seen the ref
663
+ * and must be able to lease against it; a box that has seen nothing pushes
664
+ * with no force flag at all.
665
+ *
666
+ * Process-local for the same reason the record beside it is: a restart has
667
+ * observed nothing, and an unforced push is the honest thing to do about that
668
+ * — it lands, or it refuses and says so.
669
+ */
670
+ const agentRemoteAt = new Map();
671
+ /** A failed push retries on the next sweep, but not FOREVER at sweep cadence:
672
+ * a remote that refuses (no credentials on this box, a protected prefix)
673
+ * would otherwise cost a blocking network call per agent per minute for the
674
+ * life of the daemon. A moved branch always retries immediately — the
675
+ * throttle is on repeating the SAME attempt, never on new work. */
676
+ const PUBLISH_RETRY_MS = 5 * 60_000;
677
+ /**
678
+ * A NETWORK GIT CALL, TIMED AND NON-INTERACTIVE.
679
+ *
680
+ * `execFileSync` blocks the daemon's whole event loop — the reason every `gh`
681
+ * call on the merge path carries a timeout — and a push is the call most
682
+ * likely to hang: a credential helper with nothing to answer it, a remote
683
+ * black hole. Unattended tail work nobody asked for must not be able to stop
684
+ * every turn on the machine, so it is bounded here and `GIT_TERMINAL_PROMPT=0`
685
+ * turns a prompt into an immediate, reportable failure.
686
+ */
687
+ const gitNet = (args, ms) =>
688
+ execFileSync('git', args, {
689
+ cwd: repoRoot,
690
+ encoding: 'utf8',
691
+ stdio: ['ignore', 'pipe', 'pipe'],
692
+ timeout: ms,
693
+ env: { ...process.env, GIT_TERMINAL_PROMPT: '0' },
694
+ });
695
+ /** The tip of an agent's local branch, or null when this box does not hold it
696
+ * — which is a perfectly ordinary state (the begun-guard's whole subject) and
697
+ * means there is nothing to publish, never that a push failed. */
698
+ const agentBranchSha = (place) => {
699
+ try {
700
+ return (
701
+ git(['rev-parse', '--verify', '--quiet', `refs/heads/session/${place}`], repoRoot) || null
702
+ );
703
+ } catch {
704
+ return null;
705
+ }
706
+ };
707
+ /**
708
+ * PUSH ONE AGENT'S BRANCH TO THE NAME THE SERVER GAVE IT.
709
+ *
710
+ * Returns TRUE when the recorded state CHANGED, because the caller's next act
711
+ * is a worktree report and a report that says what the last one said is a
712
+ * write per minute restating a fact. The rule this serves is the one every
713
+ * settle keeps: an action that changes what the machine would measure must
714
+ * cause a new measurement — and only then.
715
+ *
716
+ * Never throws.
717
+ */
718
+ const publishAgentBranch = async (place, target) => {
719
+ const sha = agentBranchSha(place);
720
+ if (!sha) return false;
721
+ const prev = agentPublished.get(place);
722
+ if (prev?.ref === target && prev.sha === sha) return false; // already there
723
+ if (
724
+ prev?.ref === target &&
725
+ prev.error &&
726
+ prev.tried === sha &&
727
+ Date.now() - (prev.at ?? 0) < PUBLISH_RETRY_MS
728
+ )
729
+ return false; // the same attempt failed moments ago
730
+ // THE LEASE IS THIS PROCESS'S OWN LAST SIGHTING of that ref, and nothing
731
+ // else — never git's remote-tracking ref, which this daemon's own sweep
732
+ // fetch refreshes (the argument is in `publishPushArgs`). An expectation
733
+ // recorded against a DIFFERENT ref is no expectation for this one.
734
+ const seen = agentRemoteAt.get(place);
735
+ // A ref that is not the server's shape never reaches argv. Silent, because
736
+ // a refusal here is a feature not happening on this turn, not a failure of
737
+ // it — and a `publishError` about a value we declined to use would be this
738
+ // machine reporting on a push it never attempted.
739
+ const args = publishPushArgs(place, target, seen?.ref === target ? seen.sha : null);
740
+ if (!args) return false;
741
+ try {
742
+ gitNet(args, 60_000);
743
+ agentPublished.set(place, { ref: target, sha });
744
+ agentRemoteAt.set(place, { ref: target, sha });
745
+ return true;
746
+ } catch (e) {
747
+ const error = publishErrorText(envScrub(e?.stderr?.toString?.() || e?.message || ''));
748
+ agentPublished.set(place, { ref: target, error, at: Date.now(), tried: sha });
749
+ // A repeat of a failure already reported is not news; the server's stored
750
+ // sentence is already this one.
751
+ return !(prev?.error === error && prev.ref === target);
752
+ }
753
+ };
754
+ /**
755
+ * BRING A PUBLISHED BRANCH BACK DOWN — the other half of the durability
756
+ * promise, and the only thing that lets an agent's work outlive its box.
757
+ *
758
+ * Reached from ONE place: the begun-guard's refusal arm, where this machine
759
+ * has just MEASURED that it holds neither the agent's worktree nor its branch.
760
+ * The server only sends `publishedRef` when it heard about a real push, so
761
+ * this is not a guess at a remote branch — it is a fetch of one a machine
762
+ * reported writing.
763
+ *
764
+ * ── WHAT IT RECOVERS, AND WHAT IT CANNOT ──
765
+ *
766
+ * COMMITS COME BACK. The CONVERSATION DOES NOT: the CLI's held context lives
767
+ * in the box that ran it and nothing here transports it. The turn kickoff
768
+ * re-prompts from the card, which is the honest continuation — an agent that
769
+ * picks up its own commits and re-reads its own card, never one that remembers
770
+ * the argument. Nothing this returns may be phrased as if it did.
771
+ *
772
+ * THREE ANSWERS, because two would lie. `null` is "there was nothing to try"
773
+ * (no ref, or one whose shape this machine will not put in argv), and it must
774
+ * leave the existing refusal EXACTLY as it was — a sentence about a fetch
775
+ * nobody attempted is worse than the plain refusal. `{ ok: false, why }` is a
776
+ * measured failure, relayed in git's own words. `{ ok: true }` is only ever
777
+ * returned after re-reading the ref: a fetch that exits 0 having created
778
+ * nothing would otherwise walk straight into `placeWtFor` cutting a fresh
779
+ * branch off base — the context-free redo the guard above exists to prevent,
780
+ * wearing this feature's name.
781
+ */
782
+ const fetchPublishedBranch = (place, ref) => {
783
+ const args = publishFetchArgs(ref, place);
784
+ if (!args) return null;
785
+ try {
786
+ gitNet(args, 120_000);
787
+ } catch (e) {
788
+ return {
789
+ ok: false,
790
+ why: publishErrorText(envScrub(e?.stderr?.toString?.() || e?.message || '')),
791
+ };
792
+ }
793
+ const sha = agentBranchSha(place);
794
+ if (!sha) return { ok: false, why: 'the fetch reported nothing and left no local branch' };
795
+ // WHAT THIS PROCESS HAS NOW SEEN AT THAT REMOTE REF. A fetch is an
796
+ // observation of the remote's own position, and for a box that continues an
797
+ // agent it never started it is the ONLY one it will ever have — without it
798
+ // this box's first push would carry no lease and would have to go unforced.
799
+ // It is not a `publishAgentBranch` record: this machine pushed nothing, so
800
+ // the server is told nothing.
801
+ agentRemoteAt.set(place, { ref, sha });
802
+ return { ok: true };
803
+ };
804
+
622
805
  let lastWorktreeSweep = 0;
623
806
  /** Sessions this process has already tried to measure. See `reportWorktrees`. */
624
807
  const worktreeSeen = new Set();
@@ -735,10 +918,29 @@ export function createWorkManager({
735
918
  * human's own directory; attributing one would be a fact with no reader.
736
919
  */
737
920
  const pub = sessionId.startsWith('a-') && sessionId.length > 2 ? myPubB64() : null;
921
+ /**
922
+ * …AND WHAT THIS MACHINE PUSHED OF IT (0.86.0).
923
+ *
924
+ * The ONE road by which the server learns a push happened: it composes the
925
+ * target name and sends it, and stores nothing until a machine reports
926
+ * back. So an unreported push renders nothing and no surface can name a ref
927
+ * nobody can pull — the same "never assert what you did not observe" rule
928
+ * the box id beside it keeps.
929
+ *
930
+ * NO KEY AT ALL until a target arrives, which is what lets absence keep its
931
+ * one meaning: an older server, or a project with publishing off, leaves
932
+ * the agent reading as never published rather than as failed.
933
+ *
934
+ * Mutually exclusive without a rule of its own, because the state it reads
935
+ * holds a sha or an error and never both.
936
+ */
937
+ const pushed = sessionId.startsWith('a-') ? agentPublished.get(sessionId) : null;
738
938
  return {
739
939
  sessionId,
740
940
  ...d,
741
941
  ...(pub ? { box: { id: pub, name: MACHINE_HOST } } : {}),
942
+ ...(pushed?.sha ? { published: { ref: pushed.ref, sha: pushed.sha } } : {}),
943
+ ...(pushed?.error ? { publishError: pushed.error } : {}),
742
944
  listening: lis.rows,
743
945
  listeningTotal: lis.total,
744
946
  listeningSupported: listenersSupported(),
@@ -876,6 +1078,12 @@ export function createWorkManager({
876
1078
  const live = new Set(activeIds);
877
1079
  for (const id of worktreeSeen) if (!live.has(id)) worktreeSeen.delete(id);
878
1080
  for (const id of activeIds) worktreeSeen.add(id);
1081
+ // The publish record is bounded the same way and for the same reason: an
1082
+ // agent the roster has stopped naming is done, its ref is the server's
1083
+ // business now (the merge lane deletes it when the work lands), and a
1084
+ // long-running daemon must not accumulate a row per agent that ever ran.
1085
+ for (const id of agentPublished.keys()) if (!live.has(id)) agentPublished.delete(id);
1086
+ for (const id of agentRemoteAt.keys()) if (!live.has(id)) agentRemoteAt.delete(id);
879
1087
  sweepingWorktrees = true;
880
1088
  lastWorktreeSweep = Date.now();
881
1089
  void (async () => {
@@ -925,6 +1133,23 @@ export function createWorkManager({
925
1133
  worktreeCursor = total > SWEEP_MAX_PLACES ? (start + take) % total : 0;
926
1134
  const reports = [];
927
1135
  for (const id of order) {
1136
+ /**
1137
+ * KEEP A PUBLISHED BRANCH CURRENT, not merely born.
1138
+ *
1139
+ * A settle publishes what the turn just wrote, which covers almost
1140
+ * everything — but a branch also moves without a turn: the stale
1141
+ * path folds base in before a merge, and an operator can commit in
1142
+ * the agent's worktree by hand. Without this the remote ref would sit
1143
+ * at whatever the last turn left and the durability claim would be
1144
+ * quietly false for exactly the branches somebody is working on.
1145
+ *
1146
+ * ONLY where a target is already known. Nothing here composes a name,
1147
+ * so an agent this process has never been told to publish is
1148
+ * untouched — and the sha compare inside makes the resting cost of
1149
+ * this loop one `rev-parse` per agent.
1150
+ */
1151
+ const known = agentPublished.get(id);
1152
+ if (known?.ref) await publishAgentBranch(id, known.ref);
928
1153
  const r = sessionWorktreeReport(id);
929
1154
  if (r) reports.push(r);
930
1155
  }
@@ -4486,17 +4711,41 @@ export function createWorkManager({
4486
4711
  else branchMeasured = false;
4487
4712
  }
4488
4713
  if (branchMeasured && !existsSync(wtDir) && !hasBranch) {
4489
- const on = typeof job.begunOn === 'string' && job.begunOn.trim()
4490
- ? job.begunOn.trim().slice(0, 64)
4491
- : null;
4492
- await postAgentTurn({
4493
- turnId,
4494
- outcome: 'nothing',
4495
- answer:
4496
- `This machine does not hold this agent's worktree or branch${on ? ` — its work is on ${on}` : ''}. ` +
4497
- 'Stop the agent to re-plan it here.',
4498
- });
4499
- return;
4714
+ /**
4715
+ * …UNLESS THE WORK IS ON THE REMOTE (0.86.0).
4716
+ *
4717
+ * The guard's premise was "nothing pushes an agent's branch before
4718
+ * approve", and a project that publishes has changed exactly that
4719
+ * third of it: the COMMITS are on `origin` under `flowviant/`, so a
4720
+ * box that holds neither the directory nor the branch can fetch them
4721
+ * and continue the work instead of redoing it. The other two thirds
4722
+ * are untouched — the conversation still does not move, which is why
4723
+ * the kickoff re-prompts from the card either way.
4724
+ *
4725
+ * The refusal below is still the fallback, and it is the fallback for
4726
+ * BOTH shapes of failure: a project that does not publish (no ref,
4727
+ * sentence unchanged) and a fetch that could not land (sentence
4728
+ * extended with git's own reason, because "this machine does not hold
4729
+ * it" alone would hide that we tried and how it went).
4730
+ */
4731
+ const fetched = fetchPublishedBranch(place, job.publishedRef);
4732
+ if (!fetched?.ok) {
4733
+ const on = typeof job.begunOn === 'string' && job.begunOn.trim()
4734
+ ? job.begunOn.trim().slice(0, 64)
4735
+ : null;
4736
+ await postAgentTurn({
4737
+ turnId,
4738
+ outcome: 'nothing',
4739
+ answer:
4740
+ `This machine does not hold this agent's worktree or branch${on ? ` — its work is on ${on}` : ''}. ` +
4741
+ 'Stop the agent to re-plan it here.' +
4742
+ (fetched ? ` Its published branch could not be fetched (${fetched.why}).` : ''),
4743
+ });
4744
+ return;
4745
+ }
4746
+ // The branch is here and measured. `placeWtFor`'s attach fallback
4747
+ // opens a worktree on it below — the same path a directory somebody
4748
+ // cleaned up already takes, on commits this box now genuinely holds.
4500
4749
  }
4501
4750
  }
4502
4751
  const dir = placeWtFor(place);
@@ -4585,6 +4834,21 @@ export function createWorkManager({
4585
4834
  */
4586
4835
  const doubledKinds = RUNTIMES[rt]?.parse ? null : CLAUDE_TOOL_PROSE_KINDS;
4587
4836
 
4837
+ /**
4838
+ * WHICH BRAIN THIS CONTAINER WAS PINNED TO — the same `brainFor` the tab
4839
+ * lane runs, on the same two job keys, because an agent turn and a
4840
+ * session turn differ in who is watching and in nothing else that a model
4841
+ * name touches. Every guard lives in `brainFor`: a second copy here would
4842
+ * be a second answer to "is this a model we can spell", and the two would
4843
+ * drift the first time one of them learned a new effort.
4844
+ *
4845
+ * Absent stays genuinely absent — an agent nobody pinned produces the
4846
+ * byte-identical argv it produced yesterday, on the machine's own
4847
+ * default. That is also what an OLDER server yields, since it sends
4848
+ * neither key.
4849
+ */
4850
+ const brain = brainFor(job);
4851
+
4588
4852
  let out = '';
4589
4853
  let child = null;
4590
4854
  try {
@@ -4609,6 +4873,8 @@ export function createWorkManager({
4609
4873
  cwd: wt,
4610
4874
  runtime: rt,
4611
4875
  resume,
4876
+ // Present only when the container named one — see brainFor.
4877
+ ...brain,
4612
4878
  streamJson: true,
4613
4879
  answerFromResult: true,
4614
4880
  label: c.cyan('[agent]'),
@@ -4738,6 +5004,48 @@ export function createWorkManager({
4738
5004
  // worktree we are already standing in and still hold the lock on.
4739
5005
  if (reply?.review === true) await runCheck(agentId, wt);
4740
5006
  });
5007
+ /**
5008
+ * …AND THEN PUBLISH, IF THE PROJECT PUBLISHES.
5009
+ *
5010
+ * AFTER the settle and OUTSIDE the place lock, both deliberately. A push is
5011
+ * a network call that can hang for its whole timeout, and it is worth
5012
+ * exactly nothing compared with the turn's answer: holding the settle
5013
+ * behind it would put a remote's bad day in front of the board, and holding
5014
+ * the writer lock through it would put the same delay in front of the next
5015
+ * turn.
5016
+ *
5017
+ * OUT HERE rather than beside any one settle, because `runAgentTurn` has
5018
+ * many ways to end and every one of them leaves a branch worth publishing —
5019
+ * including the refusals, where what is worth publishing is whatever an
5020
+ * earlier turn already committed. The paths with nothing to push say so by
5021
+ * having no local branch, which `publishAgentBranch` reads and skips.
5022
+ *
5023
+ * A REPORT ONLY WHEN SOMETHING CHANGED: an action that changes what the
5024
+ * machine would measure must cause a new measurement, and one that changed
5025
+ * nothing must not cost a write per turn restating it.
5026
+ *
5027
+ * …AND AN ABSENT TARGET IS AN INSTRUCTION TO FORGET. The sweep republishes
5028
+ * from `agentPublished`, which is this PROCESS's memory — so without the
5029
+ * else arm, an owner turning the switch off left every already-publishing
5030
+ * agent still pushing every commit it made for the life of the daemon, and
5031
+ * the settings copy promising "publishes nothing further" was false. A
5032
+ * 0.86.0 daemon only ever sees the key dropped because the project stopped
5033
+ * asking (`publishTargetForJob`), so forgetting is exactly what absence
5034
+ * means here; an agent nobody asked about has no entry, and the delete is a
5035
+ * no-op. It bounds the leak to the one sweep window between the switch and
5036
+ * the next settle.
5037
+ */
5038
+ try {
5039
+ if (!job.publishTo) {
5040
+ agentPublished.delete(place);
5041
+ agentRemoteAt.delete(place);
5042
+ } else if (await publishAgentBranch(place, job.publishTo)) {
5043
+ void reportSessionWorktree(place).catch(() => {});
5044
+ }
5045
+ } catch {
5046
+ /* publishing is tail work — it may never fail a turn that is already
5047
+ settled, whatever went wrong down there */
5048
+ }
4741
5049
  };
4742
5050
 
4743
5051
  const lastAgentBeat = new Map(); // agentId -> last activity POST, ms
@@ -5141,8 +5449,28 @@ export function createWorkManager({
5141
5449
  * nothing anywhere saying what went wrong.
5142
5450
  */
5143
5451
  let reported = false;
5452
+ /**
5453
+ * THE PUBLISHED REF DIES WHEN THE WORK LANDS (0.86.0) — recorded here,
5454
+ * retired in the tail below.
5455
+ *
5456
+ * A `flowviant/*` branch exists so the work survives the box that cut it.
5457
+ * Once the commits are on base that job is done, and one ref per agent
5458
+ * forever is a branch list nobody wants to read. The server sends the ref
5459
+ * only when it heard about a real push, so this never deletes a name we
5460
+ * merely composed — and `publishDeleteArgs` refuses anything outside the
5461
+ * prefix, because `:refs/heads/<x>` is the most destructive argv in this
5462
+ * file.
5463
+ *
5464
+ * ONLY ON SUCCESS. A failed merge keeps its branch — that is the whole
5465
+ * point of the branch — and stopping or declining an agent deletes nothing
5466
+ * anywhere: durability is what this feature is, and a ref whose work never
5467
+ * landed is the case it exists for. It is recorded in `report` rather than
5468
+ * at the three success sites so a fourth one cannot forget it.
5469
+ */
5470
+ let landedRef = null;
5144
5471
  const report = async (body) => {
5145
5472
  reported = true;
5473
+ if (body?.ok === true) landedRef = job.publishedRef ?? null;
5146
5474
  await postAgentMerge(body);
5147
5475
  };
5148
5476
 
@@ -5256,8 +5584,42 @@ export function createWorkManager({
5256
5584
  });
5257
5585
  return;
5258
5586
  }
5587
+ /**
5588
+ * THE PULL REQUEST'S HEAD IS THE PUBLISHED REF, when there is one
5589
+ * (0.86.0).
5590
+ *
5591
+ * Pushing `session/a-<uuid>` here and reviewing THAT would defeat the
5592
+ * whole feature on exactly the projects it is most for: the owner asked
5593
+ * for "control over what branch the agents are working on", and a PR
5594
+ * mode project is one where people read branches in a host UI. Worse, it
5595
+ * leaves TWO refs per agent — the uuid one nothing ever deletes, and the
5596
+ * readable one the cleanup below retires — so the survivor is the opaque
5597
+ * name this feature exists to replace.
5598
+ *
5599
+ * ONLY when the local branch really is this agent's own. If somebody
5600
+ * checked something else out in the worktree, `session/<place>` is not
5601
+ * what is being merged and pushing it under the published name would put
5602
+ * work on that ref that nobody approved; the plain push of the checked
5603
+ * out branch is the honest fallback.
5604
+ */
5605
+ const ownBranch = branch === `session/${place}`;
5606
+ const head =
5607
+ ownBranch && isPublishRef(job.publishedRef) ? job.publishedRef : branch;
5259
5608
  try {
5260
- git(['push', '-u', 'origin', branch], wt);
5609
+ if (head === branch) {
5610
+ git(['push', '-u', 'origin', branch], wt);
5611
+ } else {
5612
+ // The same lease discipline the publish lane keeps, and TIMED like
5613
+ // every other network call on this path: `git()` has no timeout, and
5614
+ // this one runs inside the place writer lock.
5615
+ const seen = agentRemoteAt.get(place);
5616
+ gitNet(
5617
+ publishPushArgs(place, head, seen?.ref === head ? seen.sha : null),
5618
+ 120_000
5619
+ );
5620
+ agentPublished.set(place, { ref: head, sha: tip });
5621
+ agentRemoteAt.set(place, { ref: head, sha: tip });
5622
+ }
5261
5623
  } catch (e) {
5262
5624
  // SCRUBBED. Every other failure on this path relays `gh`'s own words,
5263
5625
  // but a push writes the REMOTE URL to stderr and a remote can carry a
@@ -5270,7 +5632,7 @@ export function createWorkManager({
5270
5632
  let prUrl = null;
5271
5633
  try {
5272
5634
  const j = JSON.parse(
5273
- execFileSync('gh', ['pr', 'view', branch, '--json', 'url,state,baseRefName'], {
5635
+ execFileSync('gh', ['pr', 'view', head, '--json', 'url,state,baseRefName'], {
5274
5636
  cwd: repoRoot,
5275
5637
  stdio: ['ignore', 'pipe', 'pipe'],
5276
5638
  timeout: 30_000,
@@ -5286,7 +5648,7 @@ export function createWorkManager({
5286
5648
  await report({
5287
5649
  agentId,
5288
5650
  ok: false,
5289
- detail: `the open pull request for ${branch} targets ${j.baseRefName}, not ${prBase} — retarget or close it, then approve again`,
5651
+ detail: `the open pull request for ${head} targets ${j.baseRefName}, not ${prBase} — retarget or close it, then approve again`,
5290
5652
  });
5291
5653
  return;
5292
5654
  }
@@ -5300,7 +5662,7 @@ export function createWorkManager({
5300
5662
  const out = execFileSync(
5301
5663
  'gh',
5302
5664
  // baseBranchName, not baseRef: gh 422s on a remote-tracking name.
5303
- ['pr', 'create', '--head', branch, '--base', prBase, '--fill'],
5665
+ ['pr', 'create', '--head', head, '--base', prBase, '--fill'],
5304
5666
  { cwd: repoRoot, stdio: ['ignore', 'pipe', 'pipe'], timeout: 60_000 }
5305
5667
  )
5306
5668
  .toString()
@@ -5312,7 +5674,7 @@ export function createWorkManager({
5312
5674
  }
5313
5675
  }
5314
5676
  try {
5315
- execFileSync('gh', ['pr', 'merge', branch, '--merge'], {
5677
+ execFileSync('gh', ['pr', 'merge', head, '--merge'], {
5316
5678
  cwd: repoRoot,
5317
5679
  stdio: ['ignore', 'pipe', 'pipe'],
5318
5680
  timeout: 120_000,
@@ -5412,6 +5774,42 @@ export function createWorkManager({
5412
5774
  detail: 'the merge did not complete — check the daemon log',
5413
5775
  }).catch(() => {});
5414
5776
  }
5777
+ /**
5778
+ * …AND THE RETIREMENT IS TAIL WORK, OUTSIDE THE PLACE LOCK — the same
5779
+ * placement the turn's publish argues for, for the same reason.
5780
+ *
5781
+ * `gitNet` is `execFileSync`: it blocks the whole event loop for up to its
5782
+ * timeout, and inside `inPlace(place, true, …)` it would hold this
5783
+ * agent's WRITER lock through a remote's bad day, with the merge already
5784
+ * settled and nothing left that the delay serves. This block is PAST the
5785
+ * lock: `inPlace` has returned (or thrown) before a `finally` runs.
5786
+ *
5787
+ * IN THE `finally` rather than after it, so a throw between the ok settle
5788
+ * and the end of the locked block cannot strand the ref. `landedRef` is
5789
+ * set only by a reported success, so there is nothing here to run on any
5790
+ * other path — and a remote that refuses the delete only warns: the merge
5791
+ * is the thing that matters, and a landed branch must not become a failed
5792
+ * approval.
5793
+ *
5794
+ * The record goes with the ref. `agentPublished` is what the SWEEP
5795
+ * republishes from, so leaving the entry behind would let the next sweep
5796
+ * push the ref straight back — an orphan no later merge job can ever
5797
+ * carry, and therefore one nothing can delete.
5798
+ */
5799
+ if (landedRef) {
5800
+ agentPublished.delete(place);
5801
+ agentRemoteAt.delete(place);
5802
+ const args = publishDeleteArgs(landedRef);
5803
+ if (args) {
5804
+ try {
5805
+ gitNet(args, 60_000);
5806
+ } catch (e) {
5807
+ warn(
5808
+ `agent ${agentId}: the published branch ${landedRef} could not be deleted — ${publishErrorText(envScrub(e?.stderr?.toString?.() || e?.message || ''))}`
5809
+ );
5810
+ }
5811
+ }
5812
+ }
5415
5813
  }
5416
5814
  };
5417
5815
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "flowviant",
3
- "version": "0.84.0",
3
+ "version": "0.86.0",
4
4
  "description": "Run your own coding CLIs as build agents for Flowviant \u2014 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": {