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.
@@ -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
- // (a) close a focus card once its session has been OFFLINE past FOCUS_OFFLINE_MS (not the old 6h peer TTL).
87
- // (b) move an OFFLINE-owner doing card to "stale" once it's untouched past REAP_GRACE_MS.
88
- // Testing is waiting for the operator's verdict and is therefore outside the reaper's authority.
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
- // ONE derivation, shared by GET /contracts and the reaper below. Two copies of this is how you get
138
- // an endpoint and a sweeper that disagree about what is open, which is the same "two names for one
139
- // intent" mistake the spawn guards made.
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). `wake:false` is the sender saying
162
- // "context, not a contract" — the send result even prints "(batched — no turn)" — and a `receipt` or
163
- // `status` is a report, not a request. None of these ever buys the recipient a turn, so none can be
164
- // answered; counted as contracts they age into `stalled` and block the dispatcher's stop hook over
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: it can never be answered (that seat's replies all carry `re` for other
231
- // contracts, so the loose-reply fallback above never claims this one) and it can never be
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
- // A row can be unanswerable by a seat that is perfectly healthy: its later replies all carry `re`
252
- // for NEWER contracts (a re-dispatch, or an "ack by reference" threaded to the newer id), so
253
- // neither byRe nor the loose fallback ever claims the old row — the two matchers above only ever
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 (in-memory-only state is
289
- // exactly why the escalation backlog re-fires on every restart) and shows up once in the FEED.
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()) {
@@ -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
- // A contract is a DIRECT message from you to one peer. It closes when that peer sends you an
46
- // outcome: strictly by `re`, or, for seats that predate it, oldest-open-first. Broadcasts are
47
- // never contracts. Each open one carries the assignee's presence, because the actionable half
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` entirely and ride in their own key.
77
- //
78
- // Not cosmetic. A session's hooks are PINNED at session start, so an older stop hook iterates
79
- // `contracts` with its own predicate and knows nothing about `disposition` — it kept blocking on
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) {
@@ -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 "classified success because exit 0 with CLI output";
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
 
@@ -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
+ }
@@ -95,8 +95,9 @@ export function hasImperative(text) {
95
95
  return IMPERATIVE_RE.test(String(text || ""));
96
96
  }
97
97
 
98
- /// The safety net for a sender that never set `wake`: a direct message with no card and no
99
- /// imperative is an ack, an FYI or a queue note, and it batches into the next turn's context.
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
  }