trantor 0.18.57 → 0.18.59
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/.claude-plugin/plugin.json +1 -1
- package/bin/cli.mjs +4 -0
- package/bin/crew-runner.mjs +198 -29
- package/bin/harvest.mjs +42 -0
- package/bin/integrate.mjs +9 -2
- package/bin/sync.mjs +41 -0
- package/hooks/lib/api.mjs +16 -2
- package/hooks/lib/hollow-move.mjs +53 -0
- package/hub/reaper.mjs +23 -56
- package/hub/routes/messages.mjs +24 -25
- package/hub.mjs +30 -0
- package/lib/classify-failure.mjs +28 -4
- package/lib/harvest.mjs +97 -0
- package/lib/turn-policy.mjs +3 -2
- package/mcp.mjs +98 -74
- package/package.json +1 -1
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
// #7750 — seat-side hollow-move check: the hub is remote and cannot see a seat's worktree, so on
|
|
2
|
+
// a move to testing/done mcp.mjs diffs the worktree against the sha recorded at `doing`, reads
|
|
3
|
+
// checklist ticks and scans the note for a test command. All missing + no declared no-code
|
|
4
|
+
// outcome → the move still lands, note prefixed HOLLOW:, assigner told on the bus.
|
|
5
|
+
import { execFileSync } from "node:child_process";
|
|
6
|
+
import { readFileSync, writeFileSync } from "node:fs";
|
|
7
|
+
import { join } from "node:path";
|
|
8
|
+
|
|
9
|
+
// A "test command" is a command shape or a pass count — the note contract already demands both.
|
|
10
|
+
const TEST_CMD_RE = /(node\s+test\/|npm\s+(run\s+)?test|pnpm\s+test|yarn\s+test|vitest|pytest|go\s+test|cargo\s+test|make\s+test|\b\d+\s*\/\s*\d+\b|\b\d+\s+passed\b)/i;
|
|
11
|
+
// Declared no-code outcomes (#7750: docs, investigation, refusal, answered on the bus) — a card
|
|
12
|
+
// that says one of these is NEVER flagged, however empty its worktree is.
|
|
13
|
+
const NOCODE_RE = /\b(no[- ]code|docs?[- ]only|investigat\w*|read[- ]only|research[- ]only|analysis[- ]only|answered (on|in) the bus|refus\w+|declin\w+|won'?t fix)\b/i;
|
|
14
|
+
|
|
15
|
+
export const hasTestCommand = (note) => TEST_CMD_RE.test(String(note || ""));
|
|
16
|
+
export const declaresNoCode = (note) => NOCODE_RE.test(String(note || ""));
|
|
17
|
+
|
|
18
|
+
function git(cwd, args) {
|
|
19
|
+
return execFileSync("git", args, { cwd, encoding: "utf8", stdio: ["ignore", "pipe", "ignore"] }).trim();
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
// Per-worktree, keyed by card id: <git-dir> is the worktree's OWN dir under a linked worktree
|
|
23
|
+
// (…/.git/worktrees/<seat>), so seats never share each other's bases.
|
|
24
|
+
const basePath = (cwd) => join(git(cwd, ["rev-parse", "--git-dir"]), "hollow-base.json");
|
|
25
|
+
|
|
26
|
+
// Snapshot HEAD when a card is taken (doing); the testing/done check diffs against this sha.
|
|
27
|
+
export function markDoing(cwd, id) {
|
|
28
|
+
try {
|
|
29
|
+
const p = basePath(cwd);
|
|
30
|
+
let map = {};
|
|
31
|
+
try { map = JSON.parse(readFileSync(p, "utf8")); } catch { /* first card in this worktree */ }
|
|
32
|
+
map[id] = git(cwd, ["rev-parse", "HEAD"]);
|
|
33
|
+
writeFileSync(p, JSON.stringify(map));
|
|
34
|
+
} catch { /* not a git worktree — no base recorded, and hollowVerdict fails open */ }
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
// Verdict for a testing/done move. checked:false = no usable base (pre-#7750 card, non-git cwd,
|
|
38
|
+
// base sha lost to a rebase) — fail open rather than flag on a base we cannot trust.
|
|
39
|
+
export function hollowVerdict(cwd, id, note, checklist) {
|
|
40
|
+
let base;
|
|
41
|
+
try { base = JSON.parse(readFileSync(basePath(cwd), "utf8"))[id]; } catch { return { checked: false, hollow: false, missing: [] }; }
|
|
42
|
+
if (!base) return { checked: false, hollow: false, missing: [] };
|
|
43
|
+
const missing = [];
|
|
44
|
+
try {
|
|
45
|
+
// `git diff <base>` covers committed AND uncommitted tracked changes vs the taken-sha.
|
|
46
|
+
if (!git(cwd, ["diff", "--name-only", base, "--"])) missing.push("diff");
|
|
47
|
+
if (!git(cwd, ["ls-files", "--others", "--exclude-standard"])) missing.push("new files");
|
|
48
|
+
} catch { return { checked: false, hollow: false, missing: [] }; }
|
|
49
|
+
if (!(checklist || []).some((c) => c.done)) missing.push("ticked checklist items");
|
|
50
|
+
if (!hasTestCommand(note)) missing.push("test command in the note");
|
|
51
|
+
const hollow = missing.length === 4 && !declaresNoCode(note);
|
|
52
|
+
return { checked: true, hollow, missing };
|
|
53
|
+
}
|
package/hub/reaper.mjs
CHANGED
|
@@ -82,12 +82,10 @@ function appendReaperStaleLog(t, reason, ts) {
|
|
|
82
82
|
const lastSeen = seen ? `${humanMs(ts - seen)} ago` : "never";
|
|
83
83
|
appendTaskLog(t, "reaper", `${reason}; owner last seen ${lastSeen}`, ts);
|
|
84
84
|
}
|
|
85
|
-
// The general stale-card reaper prunePeers never was. Every 60s:
|
|
86
|
-
//
|
|
87
|
-
//
|
|
88
|
-
//
|
|
89
|
-
// It NEVER touches a card whose owner is still online, so a live long task is safe; the owner-alive-but-idle
|
|
90
|
-
// "forgot its card" case is left to the explicit /sweep path (preview + confirm).
|
|
85
|
+
// The general stale-card reaper prunePeers never was. Every 60s: (a) close a focus card whose
|
|
86
|
+
// session is OFFLINE past FOCUS_OFFLINE_MS; (b) stale an offline-owner doing card past REAP_GRACE_MS.
|
|
87
|
+
// Testing (the operator's verdict) and online owners are NEVER touched — the owner-alive-but-idle
|
|
88
|
+
// case belongs to the explicit /sweep path (preview + confirm).
|
|
91
89
|
function reapStaleCards() {
|
|
92
90
|
const onCut = now() - ONLINE_MS;
|
|
93
91
|
const focusCut = now() - FOCUS_OFFLINE_MS;
|
|
@@ -133,20 +131,10 @@ function reapStaleCards() {
|
|
|
133
131
|
}
|
|
134
132
|
setInterval(reapStaleCards, REAP_INTERVAL_MS).unref?.();
|
|
135
133
|
|
|
136
|
-
// ---- the contract ledger
|
|
137
|
-
//
|
|
138
|
-
//
|
|
139
|
-
//
|
|
140
|
-
//
|
|
141
|
-
// A contract is a DIRECT message from `session` to one peer. It closes when that peer answers:
|
|
142
|
-
// strictly by `re`, or, for seats that predate that column, oldest-open-first. Excluded outright,
|
|
143
|
-
// because none of these can ever be answered and so would hang open forever:
|
|
144
|
-
// - broadcasts (`to === "all"`)
|
|
145
|
-
// - self-dispatch (`from === to`) — a reply from yourself is never counted as an answer
|
|
146
|
-
// - the hub's own pseudo-identities (`hub:*`) and hub-authored mail — nothing polls them (0.17.87)
|
|
147
|
-
// Durations an agent READS and acts on, so they must not round to nonsense. Minutes are the useful
|
|
148
|
-
// unit in production (the abandon window is an hour), but the drills run in seconds and "past the 0m
|
|
149
|
-
// abandon window" is a sentence that tells the reader nothing.
|
|
134
|
+
// ---- the contract ledger: ONE derivation shared by GET /contracts and the reaper below ---------
|
|
135
|
+
// A contract is a DIRECT message from `session` to one peer, closed when that peer answers:
|
|
136
|
+
// strictly by `re`, or, for seats that predate that column, oldest-open-first. Broadcasts,
|
|
137
|
+
// self-dispatch and hub:* identities are excluded — none can ever be answered (0.17.87).
|
|
150
138
|
function humanMs(ms) {
|
|
151
139
|
const n = Math.max(0, Number(ms) || 0);
|
|
152
140
|
if (n < 60000) return `${Math.max(1, Math.round(n / 1000))}s`;
|
|
@@ -158,11 +146,10 @@ function contractRecipientIsAnswerable(m) {
|
|
|
158
146
|
return !!m.to && m.to !== "all" && m.from !== m.to && !m.to.startsWith("hub:") && !m.from.startsWith("hub:");
|
|
159
147
|
}
|
|
160
148
|
|
|
161
|
-
// A direct message the SENDER declared owes nothing back (#7079)
|
|
162
|
-
// "
|
|
163
|
-
//
|
|
164
|
-
//
|
|
165
|
-
// work that was never owed (four times in one day, every row an ack to an idle seat).
|
|
149
|
+
// A direct message the SENDER declared owes nothing back (#7079): `wake:false` ("context, not a
|
|
150
|
+
// contract"), a `receipt`, or a `status` never buys the recipient a turn, so none can be answered —
|
|
151
|
+
// counted as contracts they age into `stalled` and block the dispatcher's stop hook over work that
|
|
152
|
+
// was never owed.
|
|
166
153
|
function contractIsAck(m) {
|
|
167
154
|
return m.wake === false || m.kind === "receipt" || m.kind === "status";
|
|
168
155
|
}
|
|
@@ -175,10 +162,12 @@ function contractsFor(session, { project = "", windowMs = CONTRACT_WINDOW_MS, ov
|
|
|
175
162
|
const mine = state.messages.filter(m =>
|
|
176
163
|
m.from === session && m.ts >= cutoff && contractRecipientIsAnswerable(m) && (!project || m.project === project));
|
|
177
164
|
const replies = state.messages.filter(m => m.to === session && m.from !== session && m.ts >= cutoff);
|
|
165
|
+
// #7756: an ask (kind:"ask") rides `re` to name the contract it QUESTIONS — it is not the answer.
|
|
166
|
+
// Counted as one, the contract would read answered while the seat sits blocked waiting.
|
|
178
167
|
const byRe = new Map();
|
|
179
|
-
for (const r of replies) if (r.re) byRe.set(Number(r.re), r);
|
|
168
|
+
for (const r of replies) if (r.re && r.kind !== "ask") byRe.set(Number(r.re), r);
|
|
180
169
|
const looseByPeer = new Map();
|
|
181
|
-
for (const r of replies) if (!r.re) { if (!looseByPeer.has(r.from)) looseByPeer.set(r.from, []); looseByPeer.get(r.from).push(r); }
|
|
170
|
+
for (const r of replies) if (!r.re && r.kind !== "ask") { if (!looseByPeer.has(r.from)) looseByPeer.set(r.from, []); looseByPeer.get(r.from).push(r); }
|
|
182
171
|
for (const arr of looseByPeer.values()) arr.sort((a, b) => a.ts - b.ts);
|
|
183
172
|
|
|
184
173
|
const out = [];
|
|
@@ -227,17 +216,8 @@ function contractsFor(session, { project = "", windowMs = CONTRACT_WINDOW_MS, ov
|
|
|
227
216
|
|
|
228
217
|
// ---- superseded: the terminal state for a row nobody will ever answer ------------------------
|
|
229
218
|
// `abandoned` keys on the ASSIGNEE being gone, so a permanently HEALTHY seat could strand a
|
|
230
|
-
// contract forever
|
|
231
|
-
//
|
|
232
|
-
// abandoned (the seat is alive). The row then blocks its dispatcher's stop hook every single
|
|
233
|
-
// turn, for days — observed on #10573 across two consecutive sessions.
|
|
234
|
-
//
|
|
235
|
-
// TWO signals must agree, because either alone is wrong. "The peer answered something newer" on
|
|
236
|
-
// its own would punish honest out-of-order completion — a seat handed three jobs may finish the
|
|
237
|
-
// third first and still be working the second, which is a real pattern this suite already drills.
|
|
238
|
-
// Age on its own would punish a seat legitimately grinding one long job. Together they are only
|
|
239
|
-
// true when the assignee is alive, has moved on to later work, AND the row has sat unanswered
|
|
240
|
-
// past the window in which any genuine in-flight job would have reported.
|
|
219
|
+
// contract forever — unanswerable, yet never abandoned — blocking the stop hook for days (#10573).
|
|
220
|
+
// Two signals must agree (a newer answer AND age past the window) so out-of-order completion is safe.
|
|
241
221
|
const newestAnswered = new Map();
|
|
242
222
|
for (const c of out) {
|
|
243
223
|
if (c.answered && c.ts > (newestAnswered.get(c.to) || 0)) newestAnswered.set(c.to, c.ts);
|
|
@@ -248,20 +228,9 @@ function contractsFor(session, { project = "", windowMs = CONTRACT_WINDOW_MS, ov
|
|
|
248
228
|
if (c.ts < (newestAnswered.get(c.to) || 0)) c.disposition = "superseded";
|
|
249
229
|
}
|
|
250
230
|
// ---- superseded by a later DIRECT reply: the morning case (#11047/#11048) --------------------
|
|
251
|
-
//
|
|
252
|
-
//
|
|
253
|
-
//
|
|
254
|
-
// see replies aimed at the NEWER work. The row then sits WAITING forever: it can never be
|
|
255
|
-
// answered, it can never be abandoned (the seat is alive), and the newestAnswered rule above
|
|
256
|
-
// misses it whenever the newer work was dispatched under a peer identity the old row never shares.
|
|
257
|
-
//
|
|
258
|
-
// The signal both matchers ignored is the DIRECT reply itself: if the assignee has sent this
|
|
259
|
-
// session ANY message after the row was dispatched, the assignee is alive, reachable, and has
|
|
260
|
-
// demonstrably moved on to later work — an older row that has then sat unanswered past the
|
|
261
|
-
// abandon window is dead weight, not in flight. Age still gates it, so honest out-of-order
|
|
262
|
-
// completion (a seat that answered a newer job while still working an older one) is never
|
|
263
|
-
// punished — the older row stays open until the window that any genuine in-flight job would have
|
|
264
|
-
// reported within has passed.
|
|
231
|
+
// Both matchers above only see replies aimed at NEWER work, so a healthy seat whose replies are
|
|
232
|
+
// all `re`-threaded to newer contracts strands the old row even when newestAnswered misses it.
|
|
233
|
+
// ANY later direct reply + past the abandon window = moved on; age gates out-of-order completion.
|
|
265
234
|
const latestDirectReply = new Map();
|
|
266
235
|
for (const r of replies) {
|
|
267
236
|
if (r.ts > (latestDirectReply.get(r.from) || 0)) latestDirectReply.set(r.from, r.ts);
|
|
@@ -285,10 +254,8 @@ function contractDispatchers(windowMs = CONTRACT_WINDOW_MS) {
|
|
|
285
254
|
}
|
|
286
255
|
|
|
287
256
|
// The contract reaper. Records — never invents an answer for — a contract whose assignee has been
|
|
288
|
-
// quiet past CONTRACT_ABANDON_MS, so the abandonment survives a hub restart
|
|
289
|
-
//
|
|
290
|
-
// After this the contract stops counting as open, so it stops nagging every future session; it stays
|
|
291
|
-
// listed with its evidence, so `relay_contracts` can still show what died.
|
|
257
|
+
// quiet past CONTRACT_ABANDON_MS, persisted so the abandonment survives a hub restart and shows once
|
|
258
|
+
// in the FEED. It stops counting as open but stays listed with its evidence for `relay_contracts`.
|
|
292
259
|
function reapAbandonedContracts() {
|
|
293
260
|
let changed = false;
|
|
294
261
|
for (const session of contractDispatchers()) {
|
package/hub/routes/messages.mjs
CHANGED
|
@@ -4,7 +4,7 @@ import { setTimeout, setInterval, clearInterval } from "node:timers";
|
|
|
4
4
|
export async function routeMessages({ req, res, q, P, auth, ctx }) {
|
|
5
5
|
const {
|
|
6
6
|
state, body, json, stripNulText, crossProjectGuard, touch, pushToStreams,
|
|
7
|
-
appendEvent, markDelivered, contractsFor, canUseInboxSession, inboxWindow,
|
|
7
|
+
appendEvent, appendCardEvent, appendTaskLog, markDelivered, contractsFor, canUseInboxSession, inboxWindow,
|
|
8
8
|
deliverable, inboxReadable, inboxResponse, filterReadable, streams, UI,
|
|
9
9
|
AUTH_MODE, persistHealth, duty, now, markDirty, assertNoSecrets,
|
|
10
10
|
CONTRACT_WINDOW_MS,
|
|
@@ -39,20 +39,28 @@ export async function routeMessages({ req, res, q, P, auth, ctx }) {
|
|
|
39
39
|
// that field is the card-event key, and /card must keep counting card events only.
|
|
40
40
|
const refs = [...new Set((msg.text.match(/#(\d{1,7})(?![0-9])/g) || []).map(s => Number(s.slice(1))))].slice(0, 8);
|
|
41
41
|
appendEvent("message", msg.project, msg.from, { msgId: msg.id, toSession: msg.to, text: msg.text.slice(0, 2000), refs });
|
|
42
|
+
// #7756: the ask primitive's board half — a kind:ask blocks the card it cites (the question is
|
|
43
|
+
// the note, so the board reads why nothing moves); the answer (re = the ask's id) unblocks it.
|
|
44
|
+
const askCardMove = (cardId, to, noteText) => {
|
|
45
|
+
const t = cardId && state.tasks.find(x => x.id === cardId && x.project === msg.project);
|
|
46
|
+
if (!t || t.status === to || (to === "doing" && t.status !== "blocked")) return;
|
|
47
|
+
const from = t.status;
|
|
48
|
+
(t.history ||= []).push({ from, to, by: msg.from, ts: now() });
|
|
49
|
+
if (t.history.length > 40) t.history.splice(0, 10);
|
|
50
|
+
t.status = to; t.updated = now();
|
|
51
|
+
appendTaskLog(t, msg.from, noteText); appendCardEvent("moved", t, msg.from, from, to); markDirty();
|
|
52
|
+
};
|
|
53
|
+
if (kind === "ask") askCardMove(refs[0] || 0, "blocked", msg.text);
|
|
54
|
+
else if (re) {
|
|
55
|
+
const opened = state.messages.find(m => m.id === re && m.kind === "ask");
|
|
56
|
+
if (opened) askCardMove(Number((String(opened.text).match(/#(\d{1,7})(?![0-9])/) || [])[1] || 0), "doing", `answered: ${text}`);
|
|
57
|
+
}
|
|
42
58
|
return json(res, 200, { ok: true, id: msg.id });
|
|
43
59
|
}
|
|
44
60
|
// ---- /contracts: what this session dispatched and has not been answered on ----------------
|
|
45
|
-
//
|
|
46
|
-
//
|
|
47
|
-
//
|
|
48
|
-
// of "still waiting" is whether anyone is still on the other end.
|
|
49
|
-
// ---- /delivered: an endpoint that has actually READ its mail says so ----------------------
|
|
50
|
-
// The desktop app lists with peek=1 on purpose, so it never steals a message from a session's
|
|
51
|
-
// delivery hooks. For a HUMAN endpoint there are no hooks — the app is the only reader — so
|
|
52
|
-
// sasha@mac's deliveredUpTo sat at 0 forever while mail piled up. dutyTick then escalated every
|
|
53
|
-
// message the human had already read, told the duty seat about it, the seat messaged the human,
|
|
54
|
-
// and that was undelivered too: about six escalations a minute, all about mail already read.
|
|
55
|
-
// Peeking stays the default; this lets a reader record delivery explicitly instead.
|
|
61
|
+
// Open contracts carry the assignee's presence — the actionable half of "still waiting" is
|
|
62
|
+
// whether anyone is on the other end. /delivered lets a HUMAN endpoint (the app peeks, never
|
|
63
|
+
// steals) record delivery explicitly, so duty stops escalating mail the human already read.
|
|
56
64
|
if (req.method === "POST" && P === "/delivered") {
|
|
57
65
|
const b = await body(req);
|
|
58
66
|
const session = String(b.session || "");
|
|
@@ -73,19 +81,10 @@ export async function routeMessages({ req, res, q, P, auth, ctx }) {
|
|
|
73
81
|
const overdueMs = Number.isFinite(rawOverdue) ? Math.max(0, rawOverdue) : null;
|
|
74
82
|
const all = contractsFor(session, { project: String(q.project || ""), windowMs, overdueMs });
|
|
75
83
|
const by = (d) => all.filter(c => c.disposition === d).length;
|
|
76
|
-
// Abandoned contracts leave `contracts`
|
|
77
|
-
//
|
|
78
|
-
//
|
|
79
|
-
//
|
|
80
|
-
// ghosts no matter what the hub called them. Keeping them in the array meant the fix only
|
|
81
|
-
// reached sessions that restarted, and a live one nagged its operator every single turn.
|
|
82
|
-
// Splitting them out fixes every running session the moment the hub redeploys, and the ledger
|
|
83
|
-
// still shows what died via `abandonedContracts`.
|
|
84
|
-
// `superseded` leaves `contracts` for exactly the reason `abandoned` does: a session's hooks
|
|
85
|
-
// are PINNED at session start, so an older stop hook filters this array with its own
|
|
86
|
-
// predicate and would keep blocking on a row the hub has already settled.
|
|
87
|
-
// `ack` leaves `contracts` too (#7079): a `wake:false` send, a `receipt` or a `status` is the
|
|
88
|
-
// sender declaring nothing is owed, so an old pinned hook must never see it as a row to block on.
|
|
84
|
+
// Abandoned/superseded/ack contracts leave `contracts` and ride in their own keys (#7079).
|
|
85
|
+
// Not cosmetic: a session's hooks are PINNED at session start, so an older stop hook filters
|
|
86
|
+
// this array with its own predicate and would keep blocking on rows the hub already settled —
|
|
87
|
+
// splitting them out fixes every running session the moment the hub redeploys.
|
|
89
88
|
const out = all.filter(c => c.disposition !== "abandoned" && c.disposition !== "superseded" && c.disposition !== "ack");
|
|
90
89
|
return json(res, 200, {
|
|
91
90
|
session, contracts: out, abandonedContracts: all.filter(c => c.disposition === "abandoned"),
|
package/hub.mjs
CHANGED
|
@@ -88,6 +88,28 @@ const overseer = createOverseer({
|
|
|
88
88
|
});
|
|
89
89
|
store.startChangeSubscription(() => events.pushEventToStreams({ ts: Date.now(), type: "hub.reload", project: "", by: "" }));
|
|
90
90
|
|
|
91
|
+
// #7755: a write carrying `_op` replays its stored outcome on a retry, and GET /_op answers
|
|
92
|
+
// committed / rejected / unknown, so a client timeout never has to guess whether the write landed.
|
|
93
|
+
const opOutcomes = new Map();
|
|
94
|
+
const OP_OUTCOME_WINDOW_MS = 3600000;
|
|
95
|
+
setInterval(() => {
|
|
96
|
+
const cutoff = Date.now() - OP_OUTCOME_WINDOW_MS;
|
|
97
|
+
for (const [id, o] of opOutcomes) { if (o.ts < cutoff) opOutcomes.delete(id); }
|
|
98
|
+
}, 60000).unref?.();
|
|
99
|
+
const opIdOf = (b) => String(b?._op || "").slice(0, 80) || null;
|
|
100
|
+
// Only a definite answer is stored: 2xx is committed, 4xx is rejected, a 5xx stays unknown.
|
|
101
|
+
function recordOpOutcome(res, op) {
|
|
102
|
+
const end = res.end.bind(res);
|
|
103
|
+
res.end = (chunk, ...rest) => {
|
|
104
|
+
const code = res.statusCode;
|
|
105
|
+
if (code < 500 && !opOutcomes.has(op)) {
|
|
106
|
+
let body = null; try { body = chunk ? JSON.parse(String(chunk)) : null; } catch {}
|
|
107
|
+
opOutcomes.set(op, { ts: Date.now(), code, status: code < 400 ? "committed" : "rejected", body });
|
|
108
|
+
}
|
|
109
|
+
return end(chunk, ...rest);
|
|
110
|
+
};
|
|
111
|
+
}
|
|
112
|
+
|
|
91
113
|
let UI = "";
|
|
92
114
|
try { UI = readFileSync(new URL("./ui.html", import.meta.url), "utf8"); } catch {}
|
|
93
115
|
const context = {
|
|
@@ -111,6 +133,14 @@ const server = http.createServer(async (req, res) => {
|
|
|
111
133
|
if (!auth.ok) return authRuntime.json(res, auth.code || 401, { error: auth.error || "unauthorized" });
|
|
112
134
|
const authorization = authRuntime.authorize(auth, req.method, P, authRuntime.projectFromRequest(P, q, b0));
|
|
113
135
|
if (!authorization.ok) return authRuntime.json(res, authorization.code || 403, { error: authorization.error || "forbidden" });
|
|
136
|
+
if (req.method === "GET" && P === "/_op") {
|
|
137
|
+
const o = opOutcomes.get(String(q.id || "").slice(0, 80));
|
|
138
|
+
return authRuntime.json(res, 200, o ? { status: o.status, code: o.code, body: o.body } : { status: "unknown" });
|
|
139
|
+
}
|
|
140
|
+
const op = req.method === "POST" ? opIdOf(b0) : null;
|
|
141
|
+
const seen = op ? opOutcomes.get(op) : null;
|
|
142
|
+
if (seen) return authRuntime.json(res, seen.code, seen.body);
|
|
143
|
+
if (op) recordOpOutcome(res, op);
|
|
114
144
|
for (const route of routes) if (await route({ req, res, q, P, auth, ctx: context })) return;
|
|
115
145
|
authRuntime.json(res, 404, { error: "not found" });
|
|
116
146
|
} catch (error) {
|
package/lib/classify-failure.mjs
CHANGED
|
@@ -37,6 +37,24 @@ export function stripPromptEcho(errText, promptText) {
|
|
|
37
37
|
* printed its whole failure in under a hundred characters and produced nothing else. */
|
|
38
38
|
export const OWN_OUTPUT_ANSWER_MIN = 400;
|
|
39
39
|
|
|
40
|
+
// ---- the hollow turn (#7759): exit 0, no worktree change, no substantive output ---------------
|
|
41
|
+
// A CLI banner is bytes on a stream, not work. ANSI stripped, a line is CHROME when it is a pure
|
|
42
|
+
// separator, decorative box/block art, or a label a CLI prints about itself (session, model,
|
|
43
|
+
// provider, workdir, version, tokens) — metadata about the run, never the run's answer.
|
|
44
|
+
const BANNER_RE = /[\u2500-\u257F\u2580-\u259F\u25A0-\u25FF]/;
|
|
45
|
+
const SEPARATOR_RE = /^[-=*_~.#]{3,}$/;
|
|
46
|
+
const CHROME_LABEL_RE = /^(?:session|model|provider|workdir|directory|version|tokens|thinking|truncating|truncated|hint|tip|ctrl|openai|opencode|codex|claude|gemini|kimi|deepseek|glm|qwen|dsh)\b/i;
|
|
47
|
+
export const SUBSTANTIVE_MIN = 120;
|
|
48
|
+
|
|
49
|
+
/** Whether the CLI's echo-stripped output carries anything beyond banner and hook noise: the
|
|
50
|
+
* surviving signal lines must total at least SUBSTANTIVE_MIN chars — a sentence or two. A real
|
|
51
|
+
* answer clears this easily; a banner contributes nothing once its chrome is dropped (#7759). */
|
|
52
|
+
export function substantiveOutput(ownText) {
|
|
53
|
+
const lines = String(ownText || "").replace(/\x1b\[[0-9;]*[A-Za-z]/g, "").split("\n").map(l => l.trim()).filter(Boolean);
|
|
54
|
+
const signal = lines.filter(l => !BANNER_RE.test(l) && !SEPARATOR_RE.test(l) && !CHROME_LABEL_RE.test(l));
|
|
55
|
+
return signal.join("\n").length >= SUBSTANTIVE_MIN;
|
|
56
|
+
}
|
|
57
|
+
|
|
40
58
|
/** The #5405 rule, refined twice by #5868: exit 0 + an auth-shaped marker means FAILED only when
|
|
41
59
|
* the CLI's own output is short enough to be JUST the error — and NEVER when the turn did real
|
|
42
60
|
* work (newCommit, checked by the runner via git): a shipped commit is a live turn, whatever the
|
|
@@ -49,20 +67,22 @@ export function looksLikeAuthDeath(ownText, realWork = false) {
|
|
|
49
67
|
/** The one-line verdict the seat's jsonl carries (#5868): why the runner judged the turn the way
|
|
50
68
|
* it did, phrased exactly like the runner's own "classified X because Y" log — so a pane that
|
|
51
69
|
* scrolls away loses nothing that the telemetry row needs to say. */
|
|
52
|
-
export function verdictFor(realExit, effExit, emptyOutput, ownText) {
|
|
53
|
-
if (realExit === 0 && effExit === 0) return
|
|
70
|
+
export function verdictFor(realExit, effExit, emptyOutput, ownText, emptyTurn = false) {
|
|
71
|
+
if (realExit === 0 && effExit === 0) return emptyTurn
|
|
72
|
+
? "classified empty-turn because exit 0 with no worktree change, no substantive output, no bus activity"
|
|
73
|
+
: "classified success because exit 0 with CLI output";
|
|
54
74
|
if (realExit === 0 && effExit === 1) {
|
|
55
75
|
if (emptyOutput) return "classified empty-output because exit 0 with no output on either stream";
|
|
56
76
|
const m = AUTH_MARKER_RE.exec(String(ownText || ""));
|
|
57
77
|
return `classified auth because ${m ? m[0] : "auth marker"} in the CLI's own short output`;
|
|
58
78
|
}
|
|
59
|
-
const { reason, matched } = classifyFailure(realExit, String(ownText || ""), emptyOutput);
|
|
79
|
+
const { reason, matched } = classifyFailure(realExit, String(ownText || ""), emptyOutput, emptyTurn);
|
|
60
80
|
return `classified ${reason} because ${matched}`;
|
|
61
81
|
}
|
|
62
82
|
|
|
63
83
|
/** Classify one failed turn. Returns { reason, matched } — matched is the evidence excerpt the
|
|
64
84
|
* runner logs, so the next misclassification is diagnosable from the seat log alone. */
|
|
65
|
-
export function classifyFailure(exit, errText, emptyOutput = false) {
|
|
85
|
+
export function classifyFailure(exit, errText, emptyOutput = false, emptyTurn = false) {
|
|
66
86
|
// #5481: silence with a clean exit is a failure shape, not success — see lastEmptyOutput.
|
|
67
87
|
if (emptyOutput) return { reason: "empty-output", matched: "exit 0 with no output on either stream" };
|
|
68
88
|
const t = String(errText || "").toLowerCase();
|
|
@@ -79,6 +99,10 @@ export function classifyFailure(exit, errText, emptyOutput = false) {
|
|
|
79
99
|
if (m) return { reason: "exhausted", matched: m[0] };
|
|
80
100
|
m = t.match(/unauthor|401|invalid[ _-]?api[ _-]?key|forbidden|403|token expired|expired/);
|
|
81
101
|
if (m) return { reason: "auth", matched: m[0] };
|
|
102
|
+
// #7759: exit 0 with bytes on the stream but neither a changed worktree nor substantive output
|
|
103
|
+
// is a HOLLOW turn — the CLI printed its banner and quit. It ranks BELOW every real failure
|
|
104
|
+
// shape above: an auth-shaped or exhausted exit is that failure, never an empty turn.
|
|
105
|
+
if (emptyTurn) return { reason: "empty-turn", matched: "exit 0 with no worktree change, no substantive output, no bus activity" };
|
|
82
106
|
return { reason: "crashed", matched: `exit ${exit} with no known failure pattern` };
|
|
83
107
|
}
|
|
84
108
|
|
package/lib/harvest.mjs
ADDED
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
// Harvest receipts (#7748): a seat commit landed on main under a NEW sha (cherry-pick, squash)
|
|
2
|
+
// diverges the seat branch for good. The receipt records seat sha -> main sha -> card so
|
|
3
|
+
// `trantor sync` can realign the seat without anyone re-proving patch-equivalence.
|
|
4
|
+
import { spawnSync } from "node:child_process";
|
|
5
|
+
import { existsSync, readFileSync, writeFileSync, renameSync, mkdirSync } from "node:fs";
|
|
6
|
+
import { join, dirname } from "node:path";
|
|
7
|
+
import { busDir } from "./project.mjs";
|
|
8
|
+
|
|
9
|
+
const SHA_RE = /^[0-9a-f]{7,40}$/;
|
|
10
|
+
|
|
11
|
+
export function receiptsPath(project, root = busDir()) {
|
|
12
|
+
return join(root, `harvest-${project}.json`);
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export function readReceipts(project, root = busDir()) {
|
|
16
|
+
const p = receiptsPath(project, root);
|
|
17
|
+
if (!existsSync(p)) return [];
|
|
18
|
+
try {
|
|
19
|
+
const j = JSON.parse(readFileSync(p, "utf8"));
|
|
20
|
+
return Array.isArray(j?.receipts) ? j.receipts : [];
|
|
21
|
+
} catch { return []; }
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
// A hand-typed receipt carries a short sha, so either side may be a prefix of the other.
|
|
25
|
+
export function shaMatches(a, b) {
|
|
26
|
+
const x = String(a || "").toLowerCase(), y = String(b || "").toLowerCase();
|
|
27
|
+
return x.length >= 7 && y.length >= 7 && (x.startsWith(y) || y.startsWith(x));
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export function recordHarvest(project, { seat, main, card = 0, branch = "", by = "" }, root = busDir()) {
|
|
31
|
+
const seatSha = String(seat || "").trim().toLowerCase();
|
|
32
|
+
const mainSha = String(main || "").trim().toLowerCase();
|
|
33
|
+
if (!SHA_RE.test(seatSha) || !SHA_RE.test(mainSha)) throw new Error("harvest: a sha is 7 to 40 hex characters");
|
|
34
|
+
const list = readReceipts(project, root).filter(r => !shaMatches(r.seat, seatSha));
|
|
35
|
+
const receipt = { seat: seatSha, main: mainSha, card: Number(card) || 0, branch: String(branch || ""), by: String(by || ""), ts: Date.now() };
|
|
36
|
+
list.push(receipt);
|
|
37
|
+
const p = receiptsPath(project, root);
|
|
38
|
+
mkdirSync(dirname(p), { recursive: true });
|
|
39
|
+
const tmp = `${p}.${process.pid}.tmp`;
|
|
40
|
+
writeFileSync(tmp, JSON.stringify({ project, receipts: list }, null, 2) + "\n");
|
|
41
|
+
renameSync(tmp, p);
|
|
42
|
+
return receipt;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
export function run(cwd, args, { timeout = 15000 } = {}) {
|
|
46
|
+
const r = spawnSync("git", args, { cwd, encoding: "utf8", stdio: ["ignore", "pipe", "pipe"], timeout });
|
|
47
|
+
return { ok: r.status === 0, out: String(r.stdout || "").trim(), err: String(r.stderr || "").trim() };
|
|
48
|
+
}
|
|
49
|
+
const out = (cwd, args) => { const r = run(cwd, args); return r.ok ? r.out : ""; };
|
|
50
|
+
|
|
51
|
+
// The runner persists branch.<seat>.base at worktree creation; a worktree made by hand means main.
|
|
52
|
+
export function seatBase(dir, branch) {
|
|
53
|
+
return out(dir, ["config", "--get", `branch.${branch}.base`]) || "main";
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
// Realignment target: the remote base when origin carries it, else the local base branch.
|
|
57
|
+
export function syncTarget(dir, base) {
|
|
58
|
+
const remote = out(dir, ["rev-parse", "--verify", "-q", `refs/remotes/origin/${base}`]);
|
|
59
|
+
if (remote) return { ref: `origin/${base}`, sha: remote };
|
|
60
|
+
const local = out(dir, ["rev-parse", "--verify", "-q", `refs/heads/${base}`]);
|
|
61
|
+
return { ref: base, sha: local };
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
// Every commit the seat branch carries that the target does not, split by whether a receipt covers it.
|
|
65
|
+
export function auditSeat(dir, { branch, targetRef, receipts }) {
|
|
66
|
+
const log = out(dir, ["log", "--reverse", "--format=%H%x09%s", `${targetRef}..${branch}`]);
|
|
67
|
+
const harvested = [], unharvested = [];
|
|
68
|
+
for (const line of log.split("\n").filter(Boolean)) {
|
|
69
|
+
const [sha, ...rest] = line.split("\t");
|
|
70
|
+
const subject = rest.join("\t");
|
|
71
|
+
const receipt = receipts.find(r => shaMatches(r.seat, sha));
|
|
72
|
+
(receipt ? harvested : unharvested).push({ sha, subject, receipt });
|
|
73
|
+
}
|
|
74
|
+
return { harvested, unharvested };
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
// Refuses rather than loses: an unharvested commit stops the sync and is named. `reset --keep`
|
|
78
|
+
// carries uncommitted edits across and refuses on its own when one touches a file main changed.
|
|
79
|
+
export function syncSeat(dir, { branch = "", dryRun = false, fetch = true, receipts, root = busDir(), project = "" } = {}) {
|
|
80
|
+
const current = out(dir, ["rev-parse", "--abbrev-ref", "HEAD"]);
|
|
81
|
+
if (!current || current === "HEAD") return { ok: false, reason: `${dir} has no branch checked out (detached HEAD)` };
|
|
82
|
+
if (branch && branch !== current) return { ok: false, reason: `${dir} is on ${current}, not ${branch}` };
|
|
83
|
+
branch = current;
|
|
84
|
+
const base = seatBase(dir, branch);
|
|
85
|
+
if (fetch) run(dir, ["fetch", "-q", "origin", base], { timeout: 20000 });
|
|
86
|
+
const target = syncTarget(dir, base);
|
|
87
|
+
if (!target.sha) return { ok: false, reason: `no ${base} to sync to: neither origin/${base} nor a local ${base} exists` };
|
|
88
|
+
const audit = auditSeat(dir, { branch, targetRef: target.sha, receipts: receipts || readReceipts(project, root) });
|
|
89
|
+
const head = out(dir, ["rev-parse", "HEAD"]);
|
|
90
|
+
const result = { ok: true, branch, base, target, head, ...audit };
|
|
91
|
+
if (audit.unharvested.length) return { ...result, ok: false, refused: true, reason: `${audit.unharvested.length} unharvested commit(s) on ${branch} would be lost` };
|
|
92
|
+
if (head === target.sha) return { ...result, noop: true };
|
|
93
|
+
if (dryRun) return { ...result, dry: true };
|
|
94
|
+
const r = run(dir, ["reset", "--keep", target.sha]);
|
|
95
|
+
if (!r.ok) return { ...result, ok: false, reason: `git reset --keep refused: ${r.err || r.out}`.trim() };
|
|
96
|
+
return { ...result, from: head, to: target.sha };
|
|
97
|
+
}
|
package/lib/turn-policy.mjs
CHANGED
|
@@ -95,8 +95,9 @@ export function hasImperative(text) {
|
|
|
95
95
|
return IMPERATIVE_RE.test(String(text || ""));
|
|
96
96
|
}
|
|
97
97
|
|
|
98
|
-
///
|
|
99
|
-
///
|
|
98
|
+
/// #7766: no longer a wake gate — on a direct message the sender's wake flag decides alone. What
|
|
99
|
+
/// keeps this alive is the receipt side of #7079: a reply threaded onto a seat's own outcome is an
|
|
100
|
+
/// ack only while it carries no work of its own.
|
|
100
101
|
export function carriesWork(text) {
|
|
101
102
|
return cardRef(text) > 0 || hasImperative(text);
|
|
102
103
|
}
|