flowviant 0.74.2 → 0.76.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/bin/lib/agentPlan.mjs +187 -0
- package/bin/lib/deploy.mjs +4 -0
- package/bin/lib/fleet.mjs +25 -0
- package/bin/lib/landed.mjs +175 -0
- package/bin/lib/prompts.mjs +277 -10
- package/bin/lib/work.mjs +1149 -2
- package/bin/lib/worktreeDiff.mjs +1 -1
- package/package.json +1 -1
|
@@ -0,0 +1,187 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* READING A PLANNER'S ANSWER.
|
|
3
|
+
*
|
|
4
|
+
* The scratch agent behind a Deploy press is asked for one JSON object and
|
|
5
|
+
* nothing else. This is what turns its final message into a proposal, and it is
|
|
6
|
+
* its own module because it is the one piece of that turn that can quietly LIE:
|
|
7
|
+
* everything else either works or throws, while a half-parsed proposal renders
|
|
8
|
+
* as a plausible-looking board somebody accepts.
|
|
9
|
+
*
|
|
10
|
+
* LENIENT ON PACKAGING, STRICT ON SHAPE.
|
|
11
|
+
*
|
|
12
|
+
* A model asked for one object will sometimes fence it and sometimes not, and
|
|
13
|
+
* sometimes say "Here you go:" first. Refusing the whole plan over that spends
|
|
14
|
+
* the operator's own model quota to produce nothing, so the packaging is
|
|
15
|
+
* forgiven. What is NOT forgiven is the shape: an agent holding no cards, a
|
|
16
|
+
* `taskIds` that is not an array of strings, or no agents at all is a proposal
|
|
17
|
+
* nobody can accept, and `null` — which the caller reports in the machine's own
|
|
18
|
+
* words — beats rendering an empty board with an Accept button on it.
|
|
19
|
+
*
|
|
20
|
+
* Every string is CAPPED here rather than downstream. This text is model
|
|
21
|
+
* output about untrusted card content, and it becomes a row.
|
|
22
|
+
*
|
|
23
|
+
* Run: node --test bin/lib/agentPlan.test.mjs
|
|
24
|
+
*/
|
|
25
|
+
|
|
26
|
+
/** The biggest a single proposal may be. Bounds on a machine, not a policy:
|
|
27
|
+
* the server caps these again at its own boundary. */
|
|
28
|
+
const MAX_AGENTS = 20;
|
|
29
|
+
const MAX_TASKS_PER_AGENT = 60;
|
|
30
|
+
const MAX_NAME = 80;
|
|
31
|
+
const MAX_NOTE = 1000;
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* Find the object.
|
|
35
|
+
*
|
|
36
|
+
* A fenced block first, because that is what was asked for. Otherwise every `{`
|
|
37
|
+
* in the text is tried as a start, and its BALANCED end is found by counting
|
|
38
|
+
* braces while skipping string literals — the first candidate that parses into
|
|
39
|
+
* something with an `agents` array wins.
|
|
40
|
+
*
|
|
41
|
+
* The obvious cheap version — first `{` to last `}` — is wrong in a way a test
|
|
42
|
+
* caught: a planner that writes "I looked at {the auth module} first" before its
|
|
43
|
+
* JSON produces a span starting at the wrong brace, and the whole plan is lost
|
|
44
|
+
* to a sentence. Scanning candidates costs nothing at this size and cannot be
|
|
45
|
+
* defeated by prose.
|
|
46
|
+
*/
|
|
47
|
+
function extract(raw) {
|
|
48
|
+
const fenced = raw.match(/```(?:json)?\s*([\s\S]*?)```/);
|
|
49
|
+
const candidates = [];
|
|
50
|
+
if (fenced) candidates.push(fenced[1]);
|
|
51
|
+
for (let i = 0; i < raw.length; i++) {
|
|
52
|
+
if (raw[i] !== '{') continue;
|
|
53
|
+
const end = balanced(raw, i);
|
|
54
|
+
if (end > i) candidates.push(raw.slice(i, end + 1));
|
|
55
|
+
}
|
|
56
|
+
for (const body of candidates) {
|
|
57
|
+
if (!body.trim()) continue;
|
|
58
|
+
try {
|
|
59
|
+
const v = JSON.parse(body);
|
|
60
|
+
if (v && typeof v === 'object' && Array.isArray(v.agents)) return v;
|
|
61
|
+
} catch {
|
|
62
|
+
/* the next candidate may be the object */
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
return null;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/** The index of the `}` that closes the `{` at `from`, or -1. Skips string
|
|
69
|
+
* literals so a brace inside a card title cannot end the object early. */
|
|
70
|
+
function balanced(s, from) {
|
|
71
|
+
let depth = 0;
|
|
72
|
+
let inStr = false;
|
|
73
|
+
let esc = false;
|
|
74
|
+
for (let i = from; i < s.length; i++) {
|
|
75
|
+
const ch = s[i];
|
|
76
|
+
if (inStr) {
|
|
77
|
+
if (esc) esc = false;
|
|
78
|
+
else if (ch === '\\') esc = true;
|
|
79
|
+
else if (ch === '"') inStr = false;
|
|
80
|
+
continue;
|
|
81
|
+
}
|
|
82
|
+
if (ch === '"') inStr = true;
|
|
83
|
+
else if (ch === '{') depth++;
|
|
84
|
+
else if (ch === '}' && --depth === 0) return i;
|
|
85
|
+
}
|
|
86
|
+
return -1;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
export function parseProposal(text) {
|
|
90
|
+
const raw = String(text ?? '');
|
|
91
|
+
const parsed = extract(raw);
|
|
92
|
+
if (!parsed) return null;
|
|
93
|
+
|
|
94
|
+
const agents = [];
|
|
95
|
+
for (const [i, g] of parsed.agents.slice(0, MAX_AGENTS).entries()) {
|
|
96
|
+
if (!g || typeof g !== 'object') continue;
|
|
97
|
+
const taskIds = Array.isArray(g.taskIds)
|
|
98
|
+
? g.taskIds.filter((t) => typeof t === 'string' && t.trim()).slice(0, MAX_TASKS_PER_AGENT)
|
|
99
|
+
: [];
|
|
100
|
+
// AN AGENT WITH NO CARDS IS NOT AN AGENT. Dropping it is right rather than
|
|
101
|
+
// merely tidy: accepting one would create a worktree and a branch for
|
|
102
|
+
// nothing, and it would sit in Working forever with an empty queue.
|
|
103
|
+
if (taskIds.length === 0) continue;
|
|
104
|
+
agents.push({
|
|
105
|
+
tempId: String(g.tempId || `a${i + 1}`).slice(0, 64),
|
|
106
|
+
name: String(g.name ?? '').slice(0, MAX_NAME),
|
|
107
|
+
taskIds,
|
|
108
|
+
...(Number.isFinite(g.pointsBudget) && g.pointsBudget > 0
|
|
109
|
+
? { pointsBudget: Math.min(Math.round(g.pointsBudget), 100_000) }
|
|
110
|
+
: {}),
|
|
111
|
+
...(Array.isArray(g.waitsOn)
|
|
112
|
+
? { waitsOn: g.waitsOn.filter((w) => typeof w === 'string' && w).slice(0, 20) }
|
|
113
|
+
: {}),
|
|
114
|
+
...(typeof g.intoAgentId === 'string' && g.intoAgentId
|
|
115
|
+
? { intoAgentId: g.intoAgentId.slice(0, 64) }
|
|
116
|
+
: {}),
|
|
117
|
+
});
|
|
118
|
+
}
|
|
119
|
+
if (agents.length === 0) return null;
|
|
120
|
+
return {
|
|
121
|
+
agents,
|
|
122
|
+
...(typeof parsed.note === 'string' && parsed.note.trim()
|
|
123
|
+
? { note: parsed.note.slice(0, MAX_NOTE) }
|
|
124
|
+
: {}),
|
|
125
|
+
};
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
/**
|
|
129
|
+
* READING AN AGENT'S ANSWER at the end of a turn.
|
|
130
|
+
*
|
|
131
|
+
* Same lenient-packaging / strict-shape rule as `parseProposal`, and the same
|
|
132
|
+
* reason: the turn already ran and the operator already paid for it, so
|
|
133
|
+
* refusing the whole thing over a stray "Here you go:" throws away real work.
|
|
134
|
+
*
|
|
135
|
+
* NULL IS A REAL ANSWER AND THE MOST IMPORTANT ONE. It means the turn declared
|
|
136
|
+
* NEITHER delivered nor blocked — a signed-out CLI, a crash, an exhausted
|
|
137
|
+
* quota, or a model that simply stopped — and the caller reports it as
|
|
138
|
+
* `nothing`, which sends the agent to Stuck. Optimistic status from a machine
|
|
139
|
+
* that quit is the one lie this board cannot afford, so anything ambiguous ends
|
|
140
|
+
* up here rather than being read as success.
|
|
141
|
+
*/
|
|
142
|
+
export function parseTurnResult(text) {
|
|
143
|
+
const parsedRaw = String(text ?? '');
|
|
144
|
+
const fenced = parsedRaw.match(/```(?:json)?\s*([\s\S]*?)```/);
|
|
145
|
+
const candidates = [];
|
|
146
|
+
if (fenced) candidates.push(fenced[1]);
|
|
147
|
+
for (let i = 0; i < parsedRaw.length; i++) {
|
|
148
|
+
if (parsedRaw[i] !== '{') continue;
|
|
149
|
+
const end = balanced(parsedRaw, i);
|
|
150
|
+
if (end > i) candidates.push(parsedRaw.slice(i, end + 1));
|
|
151
|
+
}
|
|
152
|
+
for (const body of candidates) {
|
|
153
|
+
let v;
|
|
154
|
+
try {
|
|
155
|
+
v = JSON.parse(body);
|
|
156
|
+
} catch {
|
|
157
|
+
continue;
|
|
158
|
+
}
|
|
159
|
+
if (!v || typeof v !== 'object') continue;
|
|
160
|
+
if (v.status === 'blocked') {
|
|
161
|
+
const question = typeof v.question === 'string' ? v.question.trim() : '';
|
|
162
|
+
// A "blocked" with no question is not an answer anybody can act on — it
|
|
163
|
+
// parks an agent with nothing to reply to. Treated as `nothing`, which
|
|
164
|
+
// at least says truthfully that the machine went quiet.
|
|
165
|
+
if (!question) continue;
|
|
166
|
+
return { outcome: 'question', answer: question.slice(0, 8000) };
|
|
167
|
+
}
|
|
168
|
+
if (v.status === 'delivered') {
|
|
169
|
+
return {
|
|
170
|
+
outcome: 'delivered',
|
|
171
|
+
answer: (typeof v.summary === 'string' ? v.summary : '').slice(0, 8000),
|
|
172
|
+
raised: Array.isArray(v.raised)
|
|
173
|
+
? v.raised
|
|
174
|
+
.filter((r) => r && typeof r.title === 'string' && r.title.trim())
|
|
175
|
+
.slice(0, 10)
|
|
176
|
+
.map((r) => ({
|
|
177
|
+
title: r.title.trim().slice(0, 300),
|
|
178
|
+
...(typeof r.brief === 'string' && r.brief.trim()
|
|
179
|
+
? { brief: r.brief.trim().slice(0, 2000) }
|
|
180
|
+
: {}),
|
|
181
|
+
}))
|
|
182
|
+
: [],
|
|
183
|
+
};
|
|
184
|
+
}
|
|
185
|
+
}
|
|
186
|
+
return null;
|
|
187
|
+
}
|
package/bin/lib/deploy.mjs
CHANGED
|
@@ -77,6 +77,10 @@ export async function reportDeployConfig(repoRoot) {
|
|
|
77
77
|
healthcheck: t.healthcheck,
|
|
78
78
|
healthStatus: t.healthStatus,
|
|
79
79
|
pushSecrets: t.pushSecrets,
|
|
80
|
+
// Deploy-on-merge: the env this target auto-deploys to when commits land
|
|
81
|
+
// on base. MUST ride this map — a field forgotten here never reaches the
|
|
82
|
+
// server, and the server is what turns a landed report into the job.
|
|
83
|
+
...(typeof t.onMerge === 'string' ? { onMerge: t.onMerge } : {}),
|
|
80
84
|
}));
|
|
81
85
|
try {
|
|
82
86
|
await post('deploy-config', { pubkey: myPubB64(), targets: meta });
|
package/bin/lib/fleet.mjs
CHANGED
|
@@ -816,6 +816,11 @@ export async function runFleetDaemon() {
|
|
|
816
816
|
processShipJobs,
|
|
817
817
|
processDiffJobs,
|
|
818
818
|
processKillJobs,
|
|
819
|
+
processPrJobs,
|
|
820
|
+
processAgentPlanJobs,
|
|
821
|
+
processAgentTurnJobs,
|
|
822
|
+
processAgentMergeJobs,
|
|
823
|
+
freshenManualPlaces,
|
|
819
824
|
heldSessionIds,
|
|
820
825
|
processPreviewJobs,
|
|
821
826
|
livePreviewIds,
|
|
@@ -1610,6 +1615,26 @@ export async function runFleetDaemon() {
|
|
|
1610
1615
|
// the pid on this job is a request, never an authority, because pids are
|
|
1611
1616
|
// recycled and the row the browser clicked is up to a sweep old.
|
|
1612
1617
|
processKillJobs(roster.killJobs);
|
|
1618
|
+
// PR-mode work (push + open, or merge) — leased like a kill: two daemons
|
|
1619
|
+
// pushing one branch would open two PRs. Runs under the operator's own
|
|
1620
|
+
// `gh` credential; a settle never closes a card (done is observed by the
|
|
1621
|
+
// landed walk when the merge reaches base).
|
|
1622
|
+
processPrJobs(roster.prJobs);
|
|
1623
|
+
// A Deploy press waiting for a plan. AFTER the job lanes above and before
|
|
1624
|
+
// the worktree report, for no reason other than that it reads directories
|
|
1625
|
+
// those lanes may still be writing — it measures, so a stale read is a
|
|
1626
|
+
// slightly worse hint and never a wrong action.
|
|
1627
|
+
processAgentPlanJobs(roster.agentPlanJobs);
|
|
1628
|
+
// …and an agent's next card. After the plan jobs because a press becoming
|
|
1629
|
+
// agents is the thing that produces these.
|
|
1630
|
+
processAgentTurnJobs(roster.agentTurnJobs);
|
|
1631
|
+
// …and a branch somebody approved. After the turns: a merge takes the
|
|
1632
|
+
// place's WRITER lock, and writer preference means it goes ahead of any
|
|
1633
|
+
// reader queued behind it anyway.
|
|
1634
|
+
processAgentMergeJobs(roster.agentMergeJobs);
|
|
1635
|
+
// Catch each person's manual worktree up to base while it is clean. Silent,
|
|
1636
|
+
// fast-forward only, and it never touches an agent's branch.
|
|
1637
|
+
freshenManualPlaces();
|
|
1613
1638
|
// …and what the SURVIVING ones hold: branch, ahead-of-base, diffstat.
|
|
1614
1639
|
// Throttled inside, never awaited — a `git status` the human cannot run
|
|
1615
1640
|
// themselves from a browser, relayed. After retirement so a directory that
|
|
@@ -0,0 +1,175 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* THE LANDED OBSERVER — what arrived on base, whichever road it took.
|
|
3
|
+
*
|
|
4
|
+
* The daemon already fetches origin on a throttled beat (the worktree sweep)
|
|
5
|
+
* and moves the local base ref itself on a ship push. This module watches the
|
|
6
|
+
* base tip across those moments and, when it moves, walks the NEW commits and
|
|
7
|
+
* reports them to /fleet/base-landed: sha, subject, and any `Flowviant-Task:`
|
|
8
|
+
* trailer ids. The server closes what those commits name (a trailer from any
|
|
9
|
+
* live status, a delivered card's receipt sha) — done is OBSERVED, and this is
|
|
10
|
+
* the observation that covers a hand push, a PR merged on GitHub, and a
|
|
11
|
+
* teammate's ship, none of which pass through /fleet/ship-done.
|
|
12
|
+
*
|
|
13
|
+
* A daemon→server REPORT, so there is no version floor and the delivery
|
|
14
|
+
* discipline is repo-state's: a 404 (older server) goes quiet until restart,
|
|
15
|
+
* and the observed tip is persisted ONLY when the server accepted the report —
|
|
16
|
+
* a failed POST re-walks the same range on the next beat, which is free
|
|
17
|
+
* because the server skips done cards.
|
|
18
|
+
*
|
|
19
|
+
* FIRST SIGHT SEEDS, NEVER WALKS. A fresh install (or a base-ref change) has
|
|
20
|
+
* no honest "since when", and walking history would close every trailered card
|
|
21
|
+
* ever merged. The tip is recorded and observation starts from there. The same
|
|
22
|
+
* rule covers a range the repo can no longer answer (force-push, gc): reseed,
|
|
23
|
+
* report nothing — ignorance is never turned into a state.
|
|
24
|
+
*/
|
|
25
|
+
|
|
26
|
+
import { readFileSync, writeFileSync, mkdirSync } from 'node:fs';
|
|
27
|
+
import { join } from 'node:path';
|
|
28
|
+
import { homedir } from 'node:os';
|
|
29
|
+
import { createHash } from 'node:crypto';
|
|
30
|
+
import { git, baseBranchName } from './git.mjs';
|
|
31
|
+
import { taskIdsFromMessage } from './worktreeDiff.mjs';
|
|
32
|
+
import { warn } from './ui.mjs';
|
|
33
|
+
import { FLEET_URL, FLEET_TOKEN, USER_AGENT } from './config.mjs';
|
|
34
|
+
|
|
35
|
+
const LANDED_URL = FLEET_URL.replace(/\/agents\/?$/, '/base-landed');
|
|
36
|
+
/** The server accepts 50 per report. A bigger range walks OLDEST-FIRST in
|
|
37
|
+
* batches: the persisted tip advances to the last commit actually reported,
|
|
38
|
+
* so the remainder is picked up on the next beat rather than skipped forever
|
|
39
|
+
* — a trailered card in commit 51 of a big catch-up still closes. */
|
|
40
|
+
const MAX_COMMITS = 50;
|
|
41
|
+
const SHA_RE = /^[0-9a-f]{7,64}$/i;
|
|
42
|
+
|
|
43
|
+
export function createLandedObserver({ repoRoot, baseRef }) {
|
|
44
|
+
// Keyed like the worktree base dir: one state file per checkout, so two
|
|
45
|
+
// repos on one box never share a tip.
|
|
46
|
+
const key = createHash('sha256').update(String(repoRoot)).digest('hex').slice(0, 8);
|
|
47
|
+
const stateFile = join(homedir(), '.flowviant', `landed-${key}.json`);
|
|
48
|
+
let unsupported = false; // 404 once → an older server; quiet until restart
|
|
49
|
+
let inFlight = false;
|
|
50
|
+
|
|
51
|
+
const readState = () => {
|
|
52
|
+
try {
|
|
53
|
+
const s = JSON.parse(readFileSync(stateFile, 'utf8'));
|
|
54
|
+
return s && typeof s.ref === 'string' && typeof s.tip === 'string' ? s : null;
|
|
55
|
+
} catch {
|
|
56
|
+
return null;
|
|
57
|
+
}
|
|
58
|
+
};
|
|
59
|
+
const writeState = (s) => {
|
|
60
|
+
try {
|
|
61
|
+
mkdirSync(join(homedir(), '.flowviant'), { recursive: true });
|
|
62
|
+
writeFileSync(stateFile, JSON.stringify(s));
|
|
63
|
+
} catch {
|
|
64
|
+
/* a box that cannot persist just re-observes from the next seed */
|
|
65
|
+
}
|
|
66
|
+
};
|
|
67
|
+
|
|
68
|
+
const tipOf = (ref) => {
|
|
69
|
+
try {
|
|
70
|
+
const t = git(['rev-parse', ref], repoRoot);
|
|
71
|
+
return SHA_RE.test(t) ? t : null;
|
|
72
|
+
} catch {
|
|
73
|
+
return null;
|
|
74
|
+
}
|
|
75
|
+
};
|
|
76
|
+
|
|
77
|
+
/** New non-merge commits in from..to, OLDEST FIRST. `--no-merges` for the
|
|
78
|
+
* same reason branchCommits keeps it: a merge commit describes a range
|
|
79
|
+
* rather than doing work, and its constituents are walked as themselves. */
|
|
80
|
+
const walk = (from, to) => {
|
|
81
|
+
const raw = git(
|
|
82
|
+
['log', '--reverse', '--no-merges', '--format=%H%x1f%s%x1f%B%x1e', `${from}..${to}`],
|
|
83
|
+
repoRoot
|
|
84
|
+
);
|
|
85
|
+
const out = [];
|
|
86
|
+
for (const rec of raw.split('\x1e')) {
|
|
87
|
+
const line = rec.replace(/^\n+/, '');
|
|
88
|
+
if (!line.trim()) continue;
|
|
89
|
+
const [sha, subject, body] = line.split('\x1f');
|
|
90
|
+
if (!SHA_RE.test(sha || '')) continue;
|
|
91
|
+
out.push({
|
|
92
|
+
sha,
|
|
93
|
+
subject: String(subject || '').slice(0, 200),
|
|
94
|
+
taskIds: taskIdsFromMessage(body).slice(0, 8),
|
|
95
|
+
});
|
|
96
|
+
}
|
|
97
|
+
return out;
|
|
98
|
+
};
|
|
99
|
+
|
|
100
|
+
/** Look at the base tip; if it moved, report the range. Call after anything
|
|
101
|
+
* that may have moved origin/<base> — the sweep's fetch, a ship's push, a
|
|
102
|
+
* PR merge this daemon performed. Never throws, never awaited by a turn. */
|
|
103
|
+
const observe = async () => {
|
|
104
|
+
if (unsupported || inFlight) return;
|
|
105
|
+
const ref = baseRef();
|
|
106
|
+
if (!ref) return;
|
|
107
|
+
const tip = tipOf(ref);
|
|
108
|
+
if (!tip) return;
|
|
109
|
+
const st = readState();
|
|
110
|
+
if (!st || st.ref !== ref) {
|
|
111
|
+
writeState({ ref, tip });
|
|
112
|
+
return;
|
|
113
|
+
}
|
|
114
|
+
if (st.tip === tip) return;
|
|
115
|
+
let all;
|
|
116
|
+
try {
|
|
117
|
+
all = walk(st.tip, ref);
|
|
118
|
+
} catch {
|
|
119
|
+
// The old tip is no longer answerable (force-push, gc) — reseed and
|
|
120
|
+
// report nothing rather than guess at a range.
|
|
121
|
+
writeState({ ref, tip });
|
|
122
|
+
return;
|
|
123
|
+
}
|
|
124
|
+
// Oldest-first BATCH: a range past the server's cap advances the tip only
|
|
125
|
+
// to the last commit reported, so the remainder rides the next beat —
|
|
126
|
+
// nothing is skipped forever. (A range of nothing but merge commits still
|
|
127
|
+
// reports, tip-only: the tip moving is the fact deploy-on-merge rides.)
|
|
128
|
+
const commits = all.slice(0, MAX_COMMITS);
|
|
129
|
+
const reportedTip = all.length > MAX_COMMITS ? commits[commits.length - 1].sha : tip;
|
|
130
|
+
inFlight = true;
|
|
131
|
+
try {
|
|
132
|
+
const res = await fetch(LANDED_URL, {
|
|
133
|
+
method: 'POST',
|
|
134
|
+
headers: {
|
|
135
|
+
Authorization: `Bearer ${FLEET_TOKEN}`,
|
|
136
|
+
'User-Agent': USER_AGENT,
|
|
137
|
+
'Content-Type': 'application/json',
|
|
138
|
+
},
|
|
139
|
+
signal: AbortSignal.timeout(20_000),
|
|
140
|
+
body: JSON.stringify({ base: baseBranchName(ref), tip: reportedTip, commits }),
|
|
141
|
+
});
|
|
142
|
+
if (res.status === 404) {
|
|
143
|
+
unsupported = true;
|
|
144
|
+
return;
|
|
145
|
+
}
|
|
146
|
+
if (res.ok) {
|
|
147
|
+
// Persist ONLY an accepted report — a 5xx (the server could not close
|
|
148
|
+
// the cards) or a network failure leaves the tip where it was, so the
|
|
149
|
+
// next beat re-walks the same range and the close re-runs, idempotently.
|
|
150
|
+
writeState({ ref, tip: reportedTip });
|
|
151
|
+
// Deploy-on-merge refusals are computed server-side and would
|
|
152
|
+
// otherwise vanish — an onMerge:'prod' (or a target with no
|
|
153
|
+
// commands[env]) must not be quietly inert.
|
|
154
|
+
const j = await res.json().catch(() => null);
|
|
155
|
+
for (const r of j?.data?.deployRefused ?? []) {
|
|
156
|
+
warn(`deploy-on-merge refused for target "${r?.targetId}": ${r?.reason}`);
|
|
157
|
+
}
|
|
158
|
+
} else if (res.status >= 400 && res.status < 500) {
|
|
159
|
+
// A persistent 4xx (deploy skew, a payload this server refuses) would
|
|
160
|
+
// otherwise re-send the same poison range on every beat forever.
|
|
161
|
+
// Drop the range — the closes it carried re-run at the next REAL tip
|
|
162
|
+
// move only if their cards are still open, which is the idempotent
|
|
163
|
+
// half; the honest cost is stated out loud.
|
|
164
|
+
writeState({ ref, tip });
|
|
165
|
+
warn(`base-landed report refused (${res.status}) — skipped ${commits.length} commit(s)`);
|
|
166
|
+
}
|
|
167
|
+
} catch {
|
|
168
|
+
/* offline — the next fetch beat retries */
|
|
169
|
+
} finally {
|
|
170
|
+
inFlight = false;
|
|
171
|
+
}
|
|
172
|
+
};
|
|
173
|
+
|
|
174
|
+
return { observe };
|
|
175
|
+
}
|