flowviant 0.85.0 → 0.87.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 +2 -0
- package/bin/lib/agentPlan.mjs +21 -3
- package/bin/lib/agentPublish.mjs +164 -0
- package/bin/lib/claude.mjs +32 -5
- package/bin/lib/fleet.mjs +8 -1
- package/bin/lib/prompts.mjs +37 -4
- package/bin/lib/runtimes.mjs +91 -10
- package/bin/lib/trace.mjs +72 -9
- package/bin/lib/work.mjs +407 -17
- package/package.json +1 -1
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.
|
package/bin/lib/agentPlan.mjs
CHANGED
|
@@ -108,9 +108,27 @@ export function parseProposal(text) {
|
|
|
108
108
|
...(Number.isFinite(g.pointsBudget) && g.pointsBudget > 0
|
|
109
109
|
? { pointsBudget: Math.min(Math.round(g.pointsBudget), 100_000) }
|
|
110
110
|
: {}),
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
111
|
+
// NO `waitsOn`, AND ITS ABSENCE IS THE FEATURE (2026-09-16).
|
|
112
|
+
//
|
|
113
|
+
// The planner's schema used to carry it — tempIds of agents that had to
|
|
114
|
+
// MERGE first — and the owner deleted the idea: "whats the point of
|
|
115
|
+
// dividing up the agents if one of the agents rely on waiting for one to
|
|
116
|
+
// finish? if thats the case have it be in the same agent." An agent
|
|
117
|
+
// already works its cards in order, so a chain split across two agents
|
|
118
|
+
// buys a second worktree, a second branch and a second review and then
|
|
119
|
+
// idles one of them; the only thing a split buys is SIMULTANEOUS work.
|
|
120
|
+
// SYSTEM_PLAN no longer mentions the key at all.
|
|
121
|
+
//
|
|
122
|
+
// So a model that emits it anyway is answering a schema it was not given
|
|
123
|
+
// — an older prompt cached in a resumed conversation, or invention — and
|
|
124
|
+
// reading it would create a silently-waiting agent through the exact door
|
|
125
|
+
// the prompt just closed. DROPPED, not REFUSED: this file's own law is
|
|
126
|
+
// lenient packaging, strict shape, and a stray key is packaging. The
|
|
127
|
+
// proposal is otherwise good work the operator already paid for.
|
|
128
|
+
//
|
|
129
|
+
// The SERVER still accepts and honours `waitsOn` on the wire — 0.86.0
|
|
130
|
+
// daemons are still proposing it and existing agents still carry it. This
|
|
131
|
+
// is the end of PROPOSING one, not the end of reading one.
|
|
114
132
|
...(typeof g.intoAgentId === 'string' && g.intoAgentId
|
|
115
133
|
? { intoAgentId: g.intoAgentId.slice(0, 64) }
|
|
116
134
|
: {}),
|
|
@@ -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/claude.mjs
CHANGED
|
@@ -13,7 +13,7 @@
|
|
|
13
13
|
|
|
14
14
|
import { spawn } from 'node:child_process';
|
|
15
15
|
import { SAFE } from './config.mjs';
|
|
16
|
-
import { runtimeById, humanizeClaudeTool } from './runtimes.mjs';
|
|
16
|
+
import { runtimeById, humanizeClaudeTool, THINK_MARKER } from './runtimes.mjs';
|
|
17
17
|
|
|
18
18
|
// Every prompt/kickoff constant lives in prompts.mjs and is re-exported here:
|
|
19
19
|
// a dozen call sites import them from claude.mjs, and none of them care where
|
|
@@ -197,12 +197,39 @@ function handleStreamLine(line, { cwd, emit, onActivity, onToolEvent, appendText
|
|
|
197
197
|
if (ev.type === 'assistant' && Array.isArray(ev.message?.content)) {
|
|
198
198
|
for (const b of ev.message.content) {
|
|
199
199
|
if (b.type === 'thinking' || b.type === 'redacted_thinking') {
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
200
|
+
/**
|
|
201
|
+
* NO THINKING TEXT ARRIVES TODAY, AND THAT IS MEASURED (2026-09-16).
|
|
202
|
+
*
|
|
203
|
+
* This used to say the text is "usually redacted", which was a guess
|
|
204
|
+
* doing the work of a fact. The fact: across three real transcripts, 93
|
|
205
|
+
* thinking blocks, EVERY ONE of them carried an empty `thinking` — and
|
|
206
|
+
* a live probe with MAX_THINKING_TOKENS set and
|
|
207
|
+
* `--include-partial-messages` on returned an empty `thinking_delta`
|
|
208
|
+
* and a complete block of length zero (signature only). So the CLI
|
|
209
|
+
* emits the FACT that it reasoned and not a word of the reasoning, and
|
|
210
|
+
* "show the full thinking" cannot be conjured from this stream.
|
|
211
|
+
*
|
|
212
|
+
* THE BRANCH BELOW EXISTS ANYWAY, and deliberately. The shape is the
|
|
213
|
+
* whole point: when text is present it rides `full` UNCLIPPED, so the
|
|
214
|
+
* day a CLI release starts emitting it, the trace carries the thought
|
|
215
|
+
* whole with no daemon change and no version floor — the report's own
|
|
216
|
+
* presence is the capability. Until then every block takes the marker
|
|
217
|
+
* arm, and `trace.mjs` collapses a run of identical markers so the
|
|
218
|
+
* absence reads as one quiet step rather than forty.
|
|
219
|
+
*/
|
|
220
|
+
push(
|
|
221
|
+
b.thinking
|
|
222
|
+
? { kind: 'think', label: `thinking: ${oneLine(b.thinking)}`, full: b.thinking }
|
|
223
|
+
: { kind: 'think', label: THINK_MARKER }
|
|
224
|
+
);
|
|
203
225
|
} else if (b.type === 'text' && b.text?.trim()) {
|
|
204
226
|
if (!answerFromResult) appendText(b.text + '\n');
|
|
205
|
-
|
|
227
|
+
// `label` for the console and the pulse — one collapsed 160-char line,
|
|
228
|
+
// byte-identical to what it has always printed. `full` for the trace:
|
|
229
|
+
// a `say` is the agent NARRATING, several sentences at a time, and the
|
|
230
|
+
// clip at 160 was landing mid-sentence on the one thing a person opens
|
|
231
|
+
// the page to read.
|
|
232
|
+
push({ kind: 'say', label: oneLine(b.text), full: b.text });
|
|
206
233
|
} else if (b.type === 'tool_use') {
|
|
207
234
|
push(humanizeToolUse(b.name, b.input || {}, cwd));
|
|
208
235
|
// The STRUCTURED form of the same event, for the transcript's tool
|
package/bin/lib/fleet.mjs
CHANGED
|
@@ -82,6 +82,7 @@ import {
|
|
|
82
82
|
probeSkillsOnce,
|
|
83
83
|
recordSkills,
|
|
84
84
|
RUNTIMES,
|
|
85
|
+
THINK_MARKER,
|
|
85
86
|
} from './runtimes.mjs';
|
|
86
87
|
import { createWorkManager } from './work.mjs';
|
|
87
88
|
import { scanLocalSessions, ourConversationIds } from './localSessions.mjs';
|
|
@@ -1598,7 +1599,13 @@ export async function runFleetDaemon() {
|
|
|
1598
1599
|
pagesSeen.add(a.path || a.label);
|
|
1599
1600
|
}
|
|
1600
1601
|
// Collapse runs of bare "thinking…" so the feed doesn't fill with it.
|
|
1601
|
-
|
|
1602
|
+
// Keyed on the SHARED constant (runtimes.mjs), not on the literal: the
|
|
1603
|
+
// labels being compared here are the ones claude.mjs now builds from
|
|
1604
|
+
// that constant, so a reworded marker would leave this comparison
|
|
1605
|
+
// matching nothing and the 48-slot feed filling with the repeat — the
|
|
1606
|
+
// exact noise this line exists to stop, and silent, because a collapse
|
|
1607
|
+
// that stops collapsing fails no test.
|
|
1608
|
+
if (!(a.label === THINK_MARKER && feed[feed.length - 1] === THINK_MARKER)) {
|
|
1602
1609
|
feed.push(a.label);
|
|
1603
1610
|
if (feed.length > 48) feed.shift();
|
|
1604
1611
|
}
|
package/bin/lib/prompts.mjs
CHANGED
|
@@ -588,6 +588,35 @@ export const REGROUND_KICKOFF = ({ sha, title, files, vaultDir, predictedPages =
|
|
|
588
588
|
* TOUCHED. Everything else — how long something will take, who should own it,
|
|
589
589
|
* whether it is a good idea — is not a question a planner can answer from a
|
|
590
590
|
* repository, and asking for it produces confident invention.
|
|
591
|
+
*
|
|
592
|
+
* ── THERE IS NO WAY TO SAY "THIS ONE WAITS" (2026-09-16) ──
|
|
593
|
+
*
|
|
594
|
+
* The answer schema used to carry `waitsOn`, a list of tempIds an agent had to
|
|
595
|
+
* see MERGE before it could start, and the owner deleted the idea outright:
|
|
596
|
+
*
|
|
597
|
+
* "for the planning agent, whats the point of dividing up the agents if one
|
|
598
|
+
* of the agents rely on waiting for one to finish? if thats the case have it
|
|
599
|
+
* be in the same agent. the point of having it divide into various agent is
|
|
600
|
+
* so that its capable of parallel and simultaneous work."
|
|
601
|
+
*
|
|
602
|
+
* That is not a preference about a field, it is what an agent IS. An agent is
|
|
603
|
+
* one CLI in one worktree working its cards IN ORDER — ordering is the thing it
|
|
604
|
+
* already does, for free, with no branch to merge in between. So a planner that
|
|
605
|
+
* splits a chain across two agents has bought nothing and paid twice: a second
|
|
606
|
+
* worktree, a second branch, a second review, and a second agent sitting idle
|
|
607
|
+
* until the first one lands. The ONLY thing a split buys is two CLIs typing at
|
|
608
|
+
* the same time, and work that waits cannot do that by definition.
|
|
609
|
+
*
|
|
610
|
+
* So the vocabulary is gone rather than discouraged. A rule the model can still
|
|
611
|
+
* express a violation of is a rule it will sometimes express a violation of;
|
|
612
|
+
* removing the key removes the move. Rule 1 below carries the reasoning in the
|
|
613
|
+
* planner's own terms, and `agentPlan.mjs` drops the key if a model on an older
|
|
614
|
+
* prompt sends it anyway.
|
|
615
|
+
*
|
|
616
|
+
* WHAT DID NOT CHANGE, on purpose: the SERVER still accepts `waitsOn` on the
|
|
617
|
+
* wire and still honours it on stored proposals, because 0.86.0 daemons are
|
|
618
|
+
* still running and agents created under the old prompt still exist. This is a
|
|
619
|
+
* change to what is PROPOSED, not to what can be read.
|
|
591
620
|
*/
|
|
592
621
|
export const SYSTEM_PLAN = `You are the human's own Claude, planning a batch of work in their repository.
|
|
593
622
|
|
|
@@ -602,8 +631,13 @@ as a single reviewable branch.
|
|
|
602
631
|
THE RULES THAT MATTER:
|
|
603
632
|
|
|
604
633
|
1. SEQUENTIAL WORK BELONGS IN ONE AGENT. If B needs A's code to exist, put them
|
|
605
|
-
in the same agent, A first
|
|
606
|
-
|
|
634
|
+
in the same agent, A first — an agent works its cards in the order you give,
|
|
635
|
+
so ordering is free and costs no merge in between. THE ONLY REASON TO SPLIT
|
|
636
|
+
IS WORK THAT CAN RUN AT THE SAME TIME. There is no way to say that one agent
|
|
637
|
+
waits for another, and that is deliberate: an agent that has to wait is a
|
|
638
|
+
split done wrong. Splitting a chain buys a second worktree, a second branch
|
|
639
|
+
and a second review, and the second agent sits idle until the first one
|
|
640
|
+
merges — slower than one agent doing both in order.
|
|
607
641
|
|
|
608
642
|
2. SPLIT ONLY WHAT CAN GENUINELY RUN AT THE SAME TIME. Two agents editing the
|
|
609
643
|
same files land two branches that conflict, and somebody resolves it by hand.
|
|
@@ -641,7 +675,6 @@ after it. Wrap it in a \`\`\`json fence:
|
|
|
641
675
|
"name": "auth",
|
|
642
676
|
"taskIds": ["<card id>", "<card id>"],
|
|
643
677
|
"pointsBudget": 8,
|
|
644
|
-
"waitsOn": [],
|
|
645
678
|
"intoAgentId": null
|
|
646
679
|
}
|
|
647
680
|
]
|
|
@@ -649,7 +682,7 @@ after it. Wrap it in a \`\`\`json fence:
|
|
|
649
682
|
\`\`\`
|
|
650
683
|
|
|
651
684
|
Every selected card id must appear EXACTLY ONCE across all agents. Use the ids
|
|
652
|
-
exactly as given
|
|
685
|
+
exactly as given.`;
|
|
653
686
|
|
|
654
687
|
/**
|
|
655
688
|
* The planner's turn.
|
package/bin/lib/runtimes.mjs
CHANGED
|
@@ -46,6 +46,41 @@ import { SAFE, MODEL, USER_AGENT } from './config.mjs';
|
|
|
46
46
|
const oneLine = (s, n = 140) =>
|
|
47
47
|
String(s ?? '').replace(/\s+/g, ' ').trim().slice(0, n);
|
|
48
48
|
|
|
49
|
+
/**
|
|
50
|
+
* `label` IS THE READOUT; `full` IS THE RELAY (2026-09-16) — the convention
|
|
51
|
+
* every prose activity in this daemon follows, stated once here because three
|
|
52
|
+
* parsers produce one and three consumers read them.
|
|
53
|
+
*
|
|
54
|
+
* `label` is one collapsed, clamped line, because its first two consumers are a
|
|
55
|
+
* terminal console and a one-line pulse that is overwritten every two seconds,
|
|
56
|
+
* and neither can show a paragraph. The turn TRACE is a third consumer with the
|
|
57
|
+
* opposite need: it is scrollback, and clipping a sentence at 140 characters
|
|
58
|
+
* there is the product summarizing its own agent.
|
|
59
|
+
*
|
|
60
|
+
* So a prose activity may ALSO carry `full`: the text EXACTLY as the CLI
|
|
61
|
+
* emitted it, uncollapsed and unclipped. Nothing derives it and nothing but the
|
|
62
|
+
* trace reads it — `label` is untouched, so the console and the pulse are
|
|
63
|
+
* byte-identical to before. Absent means there was no fuller text than the
|
|
64
|
+
* label (a tool line, a bare thinking marker), and the trace falls back to the
|
|
65
|
+
* label — which is what every pre-0.87.0 daemon does for everything.
|
|
66
|
+
*
|
|
67
|
+
* THE MARKER BELOW IS THE label-ONLY CASE, and one definition with four
|
|
68
|
+
* readers: claude.mjs writes it, this file's codex parser falls back to it, and
|
|
69
|
+
* TWO collapses key on it — `trace.mjs` for the turn trace, `fleet.mjs` for the
|
|
70
|
+
* wiki sweep's feed. A collapse keyed on a string typed out four times would
|
|
71
|
+
* silently stop collapsing the first time somebody reworded one of them, and a
|
|
72
|
+
* collapse that stops collapsing fails no test — it just fills a feed.
|
|
73
|
+
*
|
|
74
|
+
* It exists at all because the CLIs do not hand over the thinking itself:
|
|
75
|
+
* measured 2026-09-16 across three real transcripts, 93 thinking blocks, every
|
|
76
|
+
* one of them empty (signature only), and a live probe with MAX_THINKING_TOKENS
|
|
77
|
+
* and `--include-partial-messages` returned an empty `thinking_delta` and a
|
|
78
|
+
* zero-length complete block. So the marker is the honest whole of what is
|
|
79
|
+
* known — "it is reasoning, not hung" — and the day text does arrive it rides
|
|
80
|
+
* `full` and this marker is simply not used.
|
|
81
|
+
*/
|
|
82
|
+
export const THINK_MARKER = 'thinking…';
|
|
83
|
+
|
|
49
84
|
const shortPath = (p, cwd) => {
|
|
50
85
|
const s = String(p ?? '');
|
|
51
86
|
return cwd && s.startsWith(cwd) ? s.slice(cwd.length).replace(/^\//, '') : s;
|
|
@@ -239,10 +274,22 @@ export const CLAUDE_TOOL_PROSE_KINDS = new Set([
|
|
|
239
274
|
*/
|
|
240
275
|
function humanizeCodexItem(item = {}, cwd = '') {
|
|
241
276
|
switch (item.item_type ?? item.type) {
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
277
|
+
// `full` rides beside the label wherever the item carries more than the
|
|
278
|
+
// label can hold — see the `label`/`full` note above. Codex is the runtime
|
|
279
|
+
// that actually SENDS reasoning text today, so its `think` is the one place
|
|
280
|
+
// in this daemon where a real thought reaches the trace whole.
|
|
281
|
+
case 'agent_message': {
|
|
282
|
+
const text = String(item.text ?? item.message ?? '');
|
|
283
|
+
return { kind: 'say', label: oneLine(text), ...(text ? { full: text } : {}) };
|
|
284
|
+
}
|
|
285
|
+
case 'reasoning': {
|
|
286
|
+
const text = String(item.text ?? '');
|
|
287
|
+
return {
|
|
288
|
+
kind: 'think',
|
|
289
|
+
label: oneLine(text) || THINK_MARKER,
|
|
290
|
+
...(text.trim() ? { full: text } : {}),
|
|
291
|
+
};
|
|
292
|
+
}
|
|
246
293
|
case 'command_execution':
|
|
247
294
|
return {
|
|
248
295
|
kind: 'bash',
|
|
@@ -301,11 +348,28 @@ function parseCodexLine(line, cwd) {
|
|
|
301
348
|
: '';
|
|
302
349
|
return { activity, text };
|
|
303
350
|
}
|
|
304
|
-
case 'turn.failed':
|
|
351
|
+
case 'turn.failed': {
|
|
352
|
+
// An error is prose too, and the sentence that explains a failed turn is
|
|
353
|
+
// routinely longer than a 140-char label — a stack-shaped message loses
|
|
354
|
+
// its cause exactly where somebody is reading to find it.
|
|
355
|
+
//
|
|
356
|
+
// THE LABEL KEEPS ITS OWN `??`, not `msg || …`, and the difference is one
|
|
357
|
+
// input: an EXPLICITLY EMPTY message. `??` lets `''` through as an empty
|
|
358
|
+
// label, which every consumer swallows (`if (!label)`); `||` would print
|
|
359
|
+
// "turn failed" and fire a pulse where this daemon printed nothing. That
|
|
360
|
+
// may well be the better readout, but it is a change to the console and
|
|
361
|
+
// the pulse, and `full` shipped on the promise that neither moved — so it
|
|
362
|
+
// is argued for on its own day, not smuggled in beside a relay field.
|
|
363
|
+
const msg = String(ev.error?.message ?? '');
|
|
305
364
|
return {
|
|
306
|
-
activity: {
|
|
365
|
+
activity: {
|
|
366
|
+
kind: 'error',
|
|
367
|
+
label: oneLine(ev.error?.message ?? 'turn failed'),
|
|
368
|
+
...(msg.trim() ? { full: msg } : {}),
|
|
369
|
+
},
|
|
307
370
|
text: '',
|
|
308
371
|
};
|
|
372
|
+
}
|
|
309
373
|
// A bare `error` event — the shape an auth failure arrives in ("401
|
|
310
374
|
// Unauthorized: Missing bearer…", observed against 0.147.0 with no
|
|
311
375
|
// credentials). It used to fall through to `default` and be dropped, which
|
|
@@ -314,11 +378,19 @@ function parseCodexLine(line, cwd) {
|
|
|
314
378
|
// gave up rather than that the CLI is not signed in. The message goes into
|
|
315
379
|
// `text` so it reaches the operator's console AND the usage-limit
|
|
316
380
|
// classifier, which reads exactly this stream.
|
|
317
|
-
case 'error':
|
|
381
|
+
case 'error': {
|
|
382
|
+
// Same split as the arm above: `??` for the label (untouched behaviour),
|
|
383
|
+
// `msg` for the relay field only.
|
|
384
|
+
const msg = String(ev.message ?? '');
|
|
318
385
|
return {
|
|
319
|
-
activity: {
|
|
386
|
+
activity: {
|
|
387
|
+
kind: 'error',
|
|
388
|
+
label: oneLine(ev.message ?? 'error'),
|
|
389
|
+
...(msg.trim() ? { full: msg } : {}),
|
|
390
|
+
},
|
|
320
391
|
text: `${ev.message ?? ''}\n`,
|
|
321
392
|
};
|
|
393
|
+
}
|
|
322
394
|
default:
|
|
323
395
|
return null; // turn.started / item.started / item.updated
|
|
324
396
|
}
|
|
@@ -376,7 +448,14 @@ function parseAgyLine(line, cwd) {
|
|
|
376
448
|
const ti = su.tool_info;
|
|
377
449
|
if (!ti) return null;
|
|
378
450
|
const err = ti.error?.message;
|
|
379
|
-
|
|
451
|
+
// Same rule as codex's error lines: the label is the console's, `full` is
|
|
452
|
+
// the trace's, and a tool error's message is exactly the kind of sentence a
|
|
453
|
+
// 140-char clamp cuts the cause out of.
|
|
454
|
+
if (err)
|
|
455
|
+
return {
|
|
456
|
+
activity: { kind: 'error', label: oneLine(err), full: String(err) },
|
|
457
|
+
text: '',
|
|
458
|
+
};
|
|
380
459
|
// Each tool is reported twice — once ACTIVE, once DONE — so only the
|
|
381
460
|
// terminal state emits, otherwise every action appears in the thread twice.
|
|
382
461
|
if (su.state && su.state !== 'DONE') return null;
|
|
@@ -387,7 +466,9 @@ function parseAgyLine(line, cwd) {
|
|
|
387
466
|
// The final answer is the ONLY sentinel-bearing text: agy has no incremental
|
|
388
467
|
// assistant-message event, so a turn's whole verdict arrives here at once.
|
|
389
468
|
return {
|
|
390
|
-
activity: r.error
|
|
469
|
+
activity: r.error
|
|
470
|
+
? { kind: 'error', label: oneLine(r.error), full: String(r.error) }
|
|
471
|
+
: null,
|
|
391
472
|
text: `${r.response ?? ''}${r.error ? `\n${r.error}` : ''}\n`,
|
|
392
473
|
};
|
|
393
474
|
}
|
package/bin/lib/trace.mjs
CHANGED
|
@@ -54,6 +54,8 @@
|
|
|
54
54
|
* tail of a trace, never the settle behind it.
|
|
55
55
|
*/
|
|
56
56
|
|
|
57
|
+
import { THINK_MARKER } from './runtimes.mjs';
|
|
58
|
+
|
|
57
59
|
/** One batch every two seconds — the discipline the tab's narrator keeps, for
|
|
58
60
|
* the same reason: a turn emits hundreds of entries and nobody is reading them
|
|
59
61
|
* faster than that. */
|
|
@@ -64,9 +66,22 @@ export const TRACE_BATCH = 40;
|
|
|
64
66
|
* scrollback, and the newest steps are the ones somebody watching wants. The
|
|
65
67
|
* drop is not silent — see the seq rule above. */
|
|
66
68
|
export const TRACE_BUFFER = 120;
|
|
67
|
-
/**
|
|
68
|
-
*
|
|
69
|
-
|
|
69
|
+
/**
|
|
70
|
+
* Longest prose entry. The server clamps to the same number
|
|
71
|
+
* (`agentTrace.ts.TRACE_PROSE_CAP`); doing it here too means a pathological line
|
|
72
|
+
* never becomes the POST. The two must move together — daemon at or below the
|
|
73
|
+
* server's, or the server silently does the cutting and this file's caps stop
|
|
74
|
+
* describing what ships.
|
|
75
|
+
*
|
|
76
|
+
* 300 UNTIL 2026-09-16, when it was the thing clipping sentences. A trace entry
|
|
77
|
+
* used to be the humanized 160-char label the console prints, so a 300 cap could
|
|
78
|
+
* not bite; it now carries the FULL text of what the CLI said or thought, and at
|
|
79
|
+
* 300 a paragraph of narration was cut mid-sentence. The real bound is the
|
|
80
|
+
* TURN's (400 entries / 96KB server-side, oldest shed with the count said out
|
|
81
|
+
* loud) — a long thought spends budget older steps would have held, and an
|
|
82
|
+
* admitted shed beats a silent clip.
|
|
83
|
+
*/
|
|
84
|
+
export const TRACE_PROSE_CAP = 4_000;
|
|
70
85
|
|
|
71
86
|
/**
|
|
72
87
|
* ONE ATTEMPT AT ONE TURN, named.
|
|
@@ -93,6 +108,11 @@ export function makeTraceRelay({ agentId, turnId, post, scrub = (s) => s, run =
|
|
|
93
108
|
* server's business: it rebases each run onto its own high-water mark. */
|
|
94
109
|
const queue = [];
|
|
95
110
|
let base = 0;
|
|
111
|
+
/** The last entry this relay ACCEPTED, kept across flushes and across a shed
|
|
112
|
+
* buffer — `queue[queue.length - 1]` is not the same thing, because a flush
|
|
113
|
+
* empties the queue and a batch boundary is not a change in the stream. Read
|
|
114
|
+
* by the bare-marker collapse in `prose()` and nothing else. */
|
|
115
|
+
let lastEntry = null;
|
|
96
116
|
let dirty = false;
|
|
97
117
|
let timer = null;
|
|
98
118
|
let stopped = false;
|
|
@@ -111,6 +131,7 @@ export function makeTraceRelay({ agentId, turnId, post, scrub = (s) => s, run =
|
|
|
111
131
|
|
|
112
132
|
const push = (entry) => {
|
|
113
133
|
if (stopped || !entry) return;
|
|
134
|
+
lastEntry = entry;
|
|
114
135
|
queue.push(entry);
|
|
115
136
|
while (queue.length > TRACE_BUFFER) {
|
|
116
137
|
queue.shift();
|
|
@@ -157,17 +178,59 @@ export function makeTraceRelay({ agentId, turnId, post, scrub = (s) => s, run =
|
|
|
157
178
|
};
|
|
158
179
|
|
|
159
180
|
return {
|
|
160
|
-
/**
|
|
161
|
-
*
|
|
162
|
-
*
|
|
163
|
-
*
|
|
181
|
+
/**
|
|
182
|
+
* A prose line from the stream. `kind` is the daemon's activity vocabulary
|
|
183
|
+
* (runtimes.mjs); anything that is not thinking or the model speaking is a
|
|
184
|
+
* `note` — the honest bucket for a codex error line or an agy tool name,
|
|
185
|
+
* rather than a wire value invented per runtime.
|
|
186
|
+
*
|
|
187
|
+
* THE NEWLINES SURVIVE (2026-09-16). This used to be `\s+ → ' '`, which was
|
|
188
|
+
* right while an entry WAS a one-line label and wrong the moment the caller
|
|
189
|
+
* started handing over the whole of what the CLI said: a model writes in
|
|
190
|
+
* paragraphs and lists, and flattening them here is the relay deciding how
|
|
191
|
+
* the agent's own words should be shaped. So only HORIZONTAL runs collapse,
|
|
192
|
+
* and a wall of blank lines becomes one — that second rule is not cosmetic,
|
|
193
|
+
* it stops padding eating the cap that the real sentences need.
|
|
194
|
+
*
|
|
195
|
+
* The scrub still runs over the WHOLE text, before the cap: this is the
|
|
196
|
+
* CLI's own stdout, and a secret in a thought must not ride further than it
|
|
197
|
+
* did when a thought was 300 characters.
|
|
198
|
+
*/
|
|
164
199
|
prose(kind, text) {
|
|
165
200
|
const t = scrub(String(text ?? ''))
|
|
166
|
-
|
|
201
|
+
// One line ending, whatever the CLI printed.
|
|
202
|
+
.replace(/\r\n?/g, '\n')
|
|
203
|
+
// Spaces and tabs collapse; `\n` is deliberately excluded from the class.
|
|
204
|
+
.replace(/[^\S\n]+/g, ' ')
|
|
205
|
+
// Three or more breaks in a row — two or more blank lines — become one.
|
|
206
|
+
.replace(/\n{3,}/g, '\n\n')
|
|
167
207
|
.trim()
|
|
168
208
|
.slice(0, TRACE_PROSE_CAP);
|
|
169
209
|
if (!t) return;
|
|
170
|
-
|
|
210
|
+
const k = kind === 'think' ? 'think' : kind === 'say' ? 'say' : 'note';
|
|
211
|
+
/**
|
|
212
|
+
* A RUN OF BARE "thinking…" IS ONE STEP, not forty.
|
|
213
|
+
*
|
|
214
|
+
* Claude emits a thinking block per burst and (measured 2026-09-16, three
|
|
215
|
+
* real transcripts: 93 blocks, all of them empty) carries no text in any
|
|
216
|
+
* of them — so the trace filled with the identical marker repeated, which
|
|
217
|
+
* is noise standing exactly where the thought would have been. The wiki
|
|
218
|
+
* feed collapses the same run for the same reason (fleet.mjs: "Collapse
|
|
219
|
+
* runs of bare 'thinking…' so the feed doesn't fill with it").
|
|
220
|
+
*
|
|
221
|
+
* ONLY THE BARE MARKER, and that is the whole safety of it: a think WITH
|
|
222
|
+
* text is never equal to it, so no real thought is ever eaten — the day
|
|
223
|
+
* the CLI starts emitting thinking text, every one of those blocks lands
|
|
224
|
+
* whole beside the others.
|
|
225
|
+
*
|
|
226
|
+
* A collapsed marker is NOT a drop: it never becomes an entry, so it
|
|
227
|
+
* never takes a seq, exactly like the empty line above it. The "N earlier
|
|
228
|
+
* steps are not shown" count stays a count of steps that existed.
|
|
229
|
+
*/
|
|
230
|
+
if (k === 'think' && t === THINK_MARKER && lastEntry?.k === 'think' && lastEntry.t === t) {
|
|
231
|
+
return;
|
|
232
|
+
}
|
|
233
|
+
push({ k, t });
|
|
171
234
|
},
|
|
172
235
|
/** One structured tool event, exactly as `toolEventOf` built it. A tool this
|
|
173
236
|
* builder does not know returns null there and nothing is pushed here — a
|
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
|
-
|
|
4490
|
-
|
|
4491
|
-
|
|
4492
|
-
|
|
4493
|
-
|
|
4494
|
-
|
|
4495
|
-
|
|
4496
|
-
|
|
4497
|
-
|
|
4498
|
-
|
|
4499
|
-
|
|
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);
|
|
@@ -4639,7 +4888,16 @@ export function createWorkManager({
|
|
|
4639
4888
|
// seconds. Two channels, one stream, and the drop-sampler stays a
|
|
4640
4889
|
// drop-sampler — buffering the pulse would make a stale line look
|
|
4641
4890
|
// fresh, which is the one thing it exists to answer.
|
|
4642
|
-
|
|
4891
|
+
//
|
|
4892
|
+
// …AND THE TRACE TAKES THE WHOLE LINE. `label` is a 160-char
|
|
4893
|
+
// console readout; `full` is what the CLI actually said, when the
|
|
4894
|
+
// parser had more than the label could hold (see runtimes.mjs).
|
|
4895
|
+
// The fallback is not a degradation — it is what every activity
|
|
4896
|
+
// without a fuller form carries, and what an entire pre-0.87.0
|
|
4897
|
+
// daemon carried for all of them. The PULSE below is deliberately
|
|
4898
|
+
// still `line`: it is one overwritten line on a board, and a
|
|
4899
|
+
// paragraph there would be a paragraph nobody can read.
|
|
4900
|
+
if (!doubledKinds || !doubledKinds.has(a.kind)) trace.prose(a.kind, a.full ?? line);
|
|
4643
4901
|
const now = Date.now();
|
|
4644
4902
|
if (now - (lastAgentBeat.get(agentId) ?? 0) < 2_000) return;
|
|
4645
4903
|
lastAgentBeat.set(agentId, now);
|
|
@@ -4755,6 +5013,48 @@ export function createWorkManager({
|
|
|
4755
5013
|
// worktree we are already standing in and still hold the lock on.
|
|
4756
5014
|
if (reply?.review === true) await runCheck(agentId, wt);
|
|
4757
5015
|
});
|
|
5016
|
+
/**
|
|
5017
|
+
* …AND THEN PUBLISH, IF THE PROJECT PUBLISHES.
|
|
5018
|
+
*
|
|
5019
|
+
* AFTER the settle and OUTSIDE the place lock, both deliberately. A push is
|
|
5020
|
+
* a network call that can hang for its whole timeout, and it is worth
|
|
5021
|
+
* exactly nothing compared with the turn's answer: holding the settle
|
|
5022
|
+
* behind it would put a remote's bad day in front of the board, and holding
|
|
5023
|
+
* the writer lock through it would put the same delay in front of the next
|
|
5024
|
+
* turn.
|
|
5025
|
+
*
|
|
5026
|
+
* OUT HERE rather than beside any one settle, because `runAgentTurn` has
|
|
5027
|
+
* many ways to end and every one of them leaves a branch worth publishing —
|
|
5028
|
+
* including the refusals, where what is worth publishing is whatever an
|
|
5029
|
+
* earlier turn already committed. The paths with nothing to push say so by
|
|
5030
|
+
* having no local branch, which `publishAgentBranch` reads and skips.
|
|
5031
|
+
*
|
|
5032
|
+
* A REPORT ONLY WHEN SOMETHING CHANGED: an action that changes what the
|
|
5033
|
+
* machine would measure must cause a new measurement, and one that changed
|
|
5034
|
+
* nothing must not cost a write per turn restating it.
|
|
5035
|
+
*
|
|
5036
|
+
* …AND AN ABSENT TARGET IS AN INSTRUCTION TO FORGET. The sweep republishes
|
|
5037
|
+
* from `agentPublished`, which is this PROCESS's memory — so without the
|
|
5038
|
+
* else arm, an owner turning the switch off left every already-publishing
|
|
5039
|
+
* agent still pushing every commit it made for the life of the daemon, and
|
|
5040
|
+
* the settings copy promising "publishes nothing further" was false. A
|
|
5041
|
+
* 0.86.0 daemon only ever sees the key dropped because the project stopped
|
|
5042
|
+
* asking (`publishTargetForJob`), so forgetting is exactly what absence
|
|
5043
|
+
* means here; an agent nobody asked about has no entry, and the delete is a
|
|
5044
|
+
* no-op. It bounds the leak to the one sweep window between the switch and
|
|
5045
|
+
* the next settle.
|
|
5046
|
+
*/
|
|
5047
|
+
try {
|
|
5048
|
+
if (!job.publishTo) {
|
|
5049
|
+
agentPublished.delete(place);
|
|
5050
|
+
agentRemoteAt.delete(place);
|
|
5051
|
+
} else if (await publishAgentBranch(place, job.publishTo)) {
|
|
5052
|
+
void reportSessionWorktree(place).catch(() => {});
|
|
5053
|
+
}
|
|
5054
|
+
} catch {
|
|
5055
|
+
/* publishing is tail work — it may never fail a turn that is already
|
|
5056
|
+
settled, whatever went wrong down there */
|
|
5057
|
+
}
|
|
4758
5058
|
};
|
|
4759
5059
|
|
|
4760
5060
|
const lastAgentBeat = new Map(); // agentId -> last activity POST, ms
|
|
@@ -5158,8 +5458,28 @@ export function createWorkManager({
|
|
|
5158
5458
|
* nothing anywhere saying what went wrong.
|
|
5159
5459
|
*/
|
|
5160
5460
|
let reported = false;
|
|
5461
|
+
/**
|
|
5462
|
+
* THE PUBLISHED REF DIES WHEN THE WORK LANDS (0.86.0) — recorded here,
|
|
5463
|
+
* retired in the tail below.
|
|
5464
|
+
*
|
|
5465
|
+
* A `flowviant/*` branch exists so the work survives the box that cut it.
|
|
5466
|
+
* Once the commits are on base that job is done, and one ref per agent
|
|
5467
|
+
* forever is a branch list nobody wants to read. The server sends the ref
|
|
5468
|
+
* only when it heard about a real push, so this never deletes a name we
|
|
5469
|
+
* merely composed — and `publishDeleteArgs` refuses anything outside the
|
|
5470
|
+
* prefix, because `:refs/heads/<x>` is the most destructive argv in this
|
|
5471
|
+
* file.
|
|
5472
|
+
*
|
|
5473
|
+
* ONLY ON SUCCESS. A failed merge keeps its branch — that is the whole
|
|
5474
|
+
* point of the branch — and stopping or declining an agent deletes nothing
|
|
5475
|
+
* anywhere: durability is what this feature is, and a ref whose work never
|
|
5476
|
+
* landed is the case it exists for. It is recorded in `report` rather than
|
|
5477
|
+
* at the three success sites so a fourth one cannot forget it.
|
|
5478
|
+
*/
|
|
5479
|
+
let landedRef = null;
|
|
5161
5480
|
const report = async (body) => {
|
|
5162
5481
|
reported = true;
|
|
5482
|
+
if (body?.ok === true) landedRef = job.publishedRef ?? null;
|
|
5163
5483
|
await postAgentMerge(body);
|
|
5164
5484
|
};
|
|
5165
5485
|
|
|
@@ -5273,8 +5593,42 @@ export function createWorkManager({
|
|
|
5273
5593
|
});
|
|
5274
5594
|
return;
|
|
5275
5595
|
}
|
|
5596
|
+
/**
|
|
5597
|
+
* THE PULL REQUEST'S HEAD IS THE PUBLISHED REF, when there is one
|
|
5598
|
+
* (0.86.0).
|
|
5599
|
+
*
|
|
5600
|
+
* Pushing `session/a-<uuid>` here and reviewing THAT would defeat the
|
|
5601
|
+
* whole feature on exactly the projects it is most for: the owner asked
|
|
5602
|
+
* for "control over what branch the agents are working on", and a PR
|
|
5603
|
+
* mode project is one where people read branches in a host UI. Worse, it
|
|
5604
|
+
* leaves TWO refs per agent — the uuid one nothing ever deletes, and the
|
|
5605
|
+
* readable one the cleanup below retires — so the survivor is the opaque
|
|
5606
|
+
* name this feature exists to replace.
|
|
5607
|
+
*
|
|
5608
|
+
* ONLY when the local branch really is this agent's own. If somebody
|
|
5609
|
+
* checked something else out in the worktree, `session/<place>` is not
|
|
5610
|
+
* what is being merged and pushing it under the published name would put
|
|
5611
|
+
* work on that ref that nobody approved; the plain push of the checked
|
|
5612
|
+
* out branch is the honest fallback.
|
|
5613
|
+
*/
|
|
5614
|
+
const ownBranch = branch === `session/${place}`;
|
|
5615
|
+
const head =
|
|
5616
|
+
ownBranch && isPublishRef(job.publishedRef) ? job.publishedRef : branch;
|
|
5276
5617
|
try {
|
|
5277
|
-
|
|
5618
|
+
if (head === branch) {
|
|
5619
|
+
git(['push', '-u', 'origin', branch], wt);
|
|
5620
|
+
} else {
|
|
5621
|
+
// The same lease discipline the publish lane keeps, and TIMED like
|
|
5622
|
+
// every other network call on this path: `git()` has no timeout, and
|
|
5623
|
+
// this one runs inside the place writer lock.
|
|
5624
|
+
const seen = agentRemoteAt.get(place);
|
|
5625
|
+
gitNet(
|
|
5626
|
+
publishPushArgs(place, head, seen?.ref === head ? seen.sha : null),
|
|
5627
|
+
120_000
|
|
5628
|
+
);
|
|
5629
|
+
agentPublished.set(place, { ref: head, sha: tip });
|
|
5630
|
+
agentRemoteAt.set(place, { ref: head, sha: tip });
|
|
5631
|
+
}
|
|
5278
5632
|
} catch (e) {
|
|
5279
5633
|
// SCRUBBED. Every other failure on this path relays `gh`'s own words,
|
|
5280
5634
|
// but a push writes the REMOTE URL to stderr and a remote can carry a
|
|
@@ -5287,7 +5641,7 @@ export function createWorkManager({
|
|
|
5287
5641
|
let prUrl = null;
|
|
5288
5642
|
try {
|
|
5289
5643
|
const j = JSON.parse(
|
|
5290
|
-
execFileSync('gh', ['pr', 'view',
|
|
5644
|
+
execFileSync('gh', ['pr', 'view', head, '--json', 'url,state,baseRefName'], {
|
|
5291
5645
|
cwd: repoRoot,
|
|
5292
5646
|
stdio: ['ignore', 'pipe', 'pipe'],
|
|
5293
5647
|
timeout: 30_000,
|
|
@@ -5303,7 +5657,7 @@ export function createWorkManager({
|
|
|
5303
5657
|
await report({
|
|
5304
5658
|
agentId,
|
|
5305
5659
|
ok: false,
|
|
5306
|
-
detail: `the open pull request for ${
|
|
5660
|
+
detail: `the open pull request for ${head} targets ${j.baseRefName}, not ${prBase} — retarget or close it, then approve again`,
|
|
5307
5661
|
});
|
|
5308
5662
|
return;
|
|
5309
5663
|
}
|
|
@@ -5317,7 +5671,7 @@ export function createWorkManager({
|
|
|
5317
5671
|
const out = execFileSync(
|
|
5318
5672
|
'gh',
|
|
5319
5673
|
// baseBranchName, not baseRef: gh 422s on a remote-tracking name.
|
|
5320
|
-
['pr', 'create', '--head',
|
|
5674
|
+
['pr', 'create', '--head', head, '--base', prBase, '--fill'],
|
|
5321
5675
|
{ cwd: repoRoot, stdio: ['ignore', 'pipe', 'pipe'], timeout: 60_000 }
|
|
5322
5676
|
)
|
|
5323
5677
|
.toString()
|
|
@@ -5329,7 +5683,7 @@ export function createWorkManager({
|
|
|
5329
5683
|
}
|
|
5330
5684
|
}
|
|
5331
5685
|
try {
|
|
5332
|
-
execFileSync('gh', ['pr', 'merge',
|
|
5686
|
+
execFileSync('gh', ['pr', 'merge', head, '--merge'], {
|
|
5333
5687
|
cwd: repoRoot,
|
|
5334
5688
|
stdio: ['ignore', 'pipe', 'pipe'],
|
|
5335
5689
|
timeout: 120_000,
|
|
@@ -5429,6 +5783,42 @@ export function createWorkManager({
|
|
|
5429
5783
|
detail: 'the merge did not complete — check the daemon log',
|
|
5430
5784
|
}).catch(() => {});
|
|
5431
5785
|
}
|
|
5786
|
+
/**
|
|
5787
|
+
* …AND THE RETIREMENT IS TAIL WORK, OUTSIDE THE PLACE LOCK — the same
|
|
5788
|
+
* placement the turn's publish argues for, for the same reason.
|
|
5789
|
+
*
|
|
5790
|
+
* `gitNet` is `execFileSync`: it blocks the whole event loop for up to its
|
|
5791
|
+
* timeout, and inside `inPlace(place, true, …)` it would hold this
|
|
5792
|
+
* agent's WRITER lock through a remote's bad day, with the merge already
|
|
5793
|
+
* settled and nothing left that the delay serves. This block is PAST the
|
|
5794
|
+
* lock: `inPlace` has returned (or thrown) before a `finally` runs.
|
|
5795
|
+
*
|
|
5796
|
+
* IN THE `finally` rather than after it, so a throw between the ok settle
|
|
5797
|
+
* and the end of the locked block cannot strand the ref. `landedRef` is
|
|
5798
|
+
* set only by a reported success, so there is nothing here to run on any
|
|
5799
|
+
* other path — and a remote that refuses the delete only warns: the merge
|
|
5800
|
+
* is the thing that matters, and a landed branch must not become a failed
|
|
5801
|
+
* approval.
|
|
5802
|
+
*
|
|
5803
|
+
* The record goes with the ref. `agentPublished` is what the SWEEP
|
|
5804
|
+
* republishes from, so leaving the entry behind would let the next sweep
|
|
5805
|
+
* push the ref straight back — an orphan no later merge job can ever
|
|
5806
|
+
* carry, and therefore one nothing can delete.
|
|
5807
|
+
*/
|
|
5808
|
+
if (landedRef) {
|
|
5809
|
+
agentPublished.delete(place);
|
|
5810
|
+
agentRemoteAt.delete(place);
|
|
5811
|
+
const args = publishDeleteArgs(landedRef);
|
|
5812
|
+
if (args) {
|
|
5813
|
+
try {
|
|
5814
|
+
gitNet(args, 60_000);
|
|
5815
|
+
} catch (e) {
|
|
5816
|
+
warn(
|
|
5817
|
+
`agent ${agentId}: the published branch ${landedRef} could not be deleted — ${publishErrorText(envScrub(e?.stderr?.toString?.() || e?.message || ''))}`
|
|
5818
|
+
);
|
|
5819
|
+
}
|
|
5820
|
+
}
|
|
5821
|
+
}
|
|
5432
5822
|
}
|
|
5433
5823
|
};
|
|
5434
5824
|
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "flowviant",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.87.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": {
|