omp-conductor 0.3.23 → 0.3.25

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.
@@ -46,8 +46,14 @@
46
46
 
47
47
  import { spawnSync } from "node:child_process";
48
48
  import { existsSync, readFileSync, renameSync, rmSync, writeFileSync } from "node:fs";
49
- import { isAbsolute, join, resolve } from "node:path";
49
+ import { dirname, isAbsolute, join, resolve } from "node:path";
50
50
  import { findProject, loadConfig, resolveReleasePolicy } from "./config.ts";
51
+ import {
52
+ bridgeTokenBound,
53
+ hasBotToken,
54
+ readApprovalSurface,
55
+ TELEGRAM_APPROVAL_TOOL,
56
+ } from "./approval-surface.ts";
51
57
  import {
52
58
  briefPathForProject,
53
59
  policyPathForProject,
@@ -340,15 +346,9 @@ export const TICK_SCOPE_CONSTRAINTS: { readonly [K in ReportScope]: string } = {
340
346
  export const TICK_DELIVERY_RULE =
341
347
  "This tick was injected locally, not sent from Telegram, so your end-of-turn text does NOT reach your operator. Deliver anything reportable this turn by calling the telegram_send tool and confirming success; never claim a report was sent otherwise.";
342
348
 
343
- /**
344
- * The tool the package floor names for the Learning-loop yes/no amendment
345
- * approval (`## Learning loop`, step 2, in `briefs/orchestrator.md`).
346
- *
347
- * Held as a constant because two things must agree on it: the prose that tells
348
- * the turn to call it, and the preflight that checks whether it is callable at
349
- * all.
350
- */
351
- export const TELEGRAM_APPROVAL_TOOL = "telegram_ask";
349
+ /** Re-exported so the tick's own contract stays readable from one file: the
350
+ * constant itself lives beside the check that decides whether it is callable. */
351
+ export { TELEGRAM_APPROVAL_TOOL };
352
352
 
353
353
  /**
354
354
  * Appended to every tick — the shipped prompt or the operator's own — composed
@@ -917,21 +917,44 @@ export function tickDecision(input: {
917
917
  }
918
918
 
919
919
  /**
920
- * Whether the Telegram bridge can still reach a person: enabled, with exactly
921
- * one paired owner.
920
+ * Whether the Telegram bridge can still reach a person: a bot token, enabled,
921
+ * with exactly one paired owner.
922
922
  *
923
923
  * Fail-closed, and every failure mode collapses to the same answer on purpose —
924
924
  * missing file, truncated write, hand-edit that dropped `enabled`, a second
925
- * chat id pasted in, or the pairing revoked. Distinguishing them would only
926
- * tempt a future reader into treating one of them as benign, and none of them
927
- * are: each one means a tier-2 escalation lands nowhere.
925
+ * chat id pasted in, the pairing revoked, or no token for the bot to send with.
926
+ * Distinguishing them would only tempt a future reader into treating one of them
927
+ * as benign, and none of them are: each one means a tier-2 escalation lands
928
+ * nowhere.
929
+ *
930
+ * The token belongs in this gate rather than further down. Without one nothing
931
+ * outbound works, so a tick would carry a delivery rule ordering a
932
+ * `telegram_send` that cannot happen and an approval fallback naming the same
933
+ * unusable tool — an unattended fleet dispatching with no way to page anybody,
934
+ * which is the one thing the arm handshake exists to prevent. A missing token
935
+ * fails the whole channel; the narrower approval preflight is reserved for a
936
+ * bridge that can send but cannot ask.
937
+ *
938
+ * Re-read on every tick, and specifically *not* snapshotted at session start,
939
+ * because of what this gate is actually about. Tier 2 is paged by conductor
940
+ * itself: `escalate.ts` reads the same `.env` and calls `api.telegram.org`
941
+ * directly, never through omp-telegram's bridge. So the question "can an
942
+ * escalation still reach a person" is answered by the file as it stands now, and
943
+ * an operator who fixes a missing token has fixed paging immediately — a startup
944
+ * snapshot would keep a working fleet silent until somebody restarted the
945
+ * session, which is a worse failure than the one it would prevent. Re-reading
946
+ * also catches the reverse: a channel that goes away mid-session stops the
947
+ * heartbeat on the next tick rather than days later.
928
948
  *
929
- * Re-read on every tick rather than cached at session start, because the bridge
930
- * is reconfigured by a long-lived operator out-of-band and a heartbeat that
931
- * trusted a startup snapshot would keep dispatching for days after the channel
932
- * went away.
949
+ * What the file cannot answer is whether *omp-telegram* holds a token. It binds
950
+ * one in `startBot()` at session start and rebinds only on `/telegram token` and
951
+ * `/telegram on`, so a token added out-of-band leaves the bridge's own tools
952
+ * `telegram_ask`, `telegram_send` — dead until one of those runs. That is real,
953
+ * and it is deliberately not modelled here: this gate protects paging, the
954
+ * README says to reload the bridge, and `omp-conductor status` shows the row.
933
955
  */
934
956
  function channelIsUp(path: string): boolean {
957
+ if (!hasBotToken(dirname(path))) return false;
935
958
  let parsed: unknown;
936
959
  try {
937
960
  parsed = JSON.parse(readFileSync(path, "utf8"));
@@ -1036,6 +1059,15 @@ interface TickSession {
1036
1059
  * background noise.
1037
1060
  */
1038
1061
  approvalToolMissingLogged: boolean;
1062
+ /**
1063
+ * Whether omp-telegram could have bound a bot token when this session started.
1064
+ * False means its `telegram_ask` / `telegram_send` are dead for this session
1065
+ * however good `access.json` looks now, because the bridge resolves its token
1066
+ * once in `startBot()`. Defaults true so a session that never reaches
1067
+ * `session_start` — every unit test that calls `tick()` directly — keeps the
1068
+ * behaviour the access file describes.
1069
+ */
1070
+ bridgeTokenAtStart: boolean;
1039
1071
  /** Consecutive {@link PENDING_REASON} skips — see {@link STALL_MARKER_FILE}. */
1040
1072
  pendingSkips: number;
1041
1073
  }
@@ -1115,13 +1147,49 @@ function tick(pi: TickApi, ctx: TickContext, config: TickConfig, session: TickSe
1115
1147
  }
1116
1148
  }
1117
1149
 
1118
- // The floor's approval primitive, checked against the live mounted set at the
1119
- // moment this tick is composed not once at session start, because the
1120
- // mounted set is exactly what differed between two consecutive turns of the
1121
- // same session on 2026-08-09 (#114). Appended last, after the friction
1122
- // digest: the digest is what provokes an amendment, so the sentence that says
1123
- // the amendment cannot be approved here is the one that should read last.
1124
- if (!pi.getActiveTools().includes(TELEGRAM_APPROVAL_TOOL)) {
1150
+ // The floor's approval primitive, decided from configuration rather than from
1151
+ // the live mounted setand that is a correction, not a shortcut. The first
1152
+ // attempt at this read `pi.getActiveTools()` here, which is wrong at exactly
1153
+ // this point in the lifecycle: omp-telegram mounts `telegram_ask` in
1154
+ // `before_agent_start` and takes it away again in `agent_end`
1155
+ // (`restorePromptTools`), so it exists only *during* a turn. This runs between
1156
+ // turns, composing the prompt that is about to start one, so the live set
1157
+ // never contains the tool — a correctly configured fleet would have been told
1158
+ // the approval was unavailable on every single tick, and the error line meant
1159
+ // to flag a real fault would have fired 144 times a day saying nothing.
1160
+ //
1161
+ // What decides the mounting is knowable in advance and is shared with the
1162
+ // status row, so the two cannot disagree: a locally injected tick has no
1163
+ // `<telegram-message>` wrapper, so omp-telegram can only resolve a target
1164
+ // through `notifyTarget()` — `notifyMode` plus a destination, both in the
1165
+ // access file. Appended last, after the friction digest: the digest is what
1166
+ // provokes an amendment, so the sentence saying the amendment cannot be
1167
+ // approved here is the one that should read last.
1168
+ //
1169
+ // No access file configured means no fleet channel to judge, so nothing is
1170
+ // claimed: the channel gate above already treats that as "not the fleet".
1171
+ //
1172
+ // Two facts have to hold and the access file carries only one. It says whether
1173
+ // a destination would resolve; it cannot say whether the bridge holds a token
1174
+ // to resolve it with, because omp-telegram binds that once in `startBot()`. A
1175
+ // token written out-of-band after this session started opens the channel gate
1176
+ // — conductor pages tier 2 itself, so that much is honest — while leaving
1177
+ // `telegram_ask` and `telegram_send` dead until `/telegram on`. Trusting the
1178
+ // file alone there puts the tick straight back to mandating a call its surface
1179
+ // cannot make, which is #114 exactly.
1180
+ const approval =
1181
+ config.accessFile === undefined
1182
+ ? undefined
1183
+ : session.bridgeTokenAtStart
1184
+ ? readApprovalSurface(config.accessFile)
1185
+ : ({
1186
+ kind: "missing",
1187
+ reason:
1188
+ `${TELEGRAM_APPROVAL_TOOL} unavailable on local ticks: omp-telegram had no bot token when this ` +
1189
+ "session started, so it bound none and its tools stay dead however complete the access file looks " +
1190
+ "now — run `/telegram on` in this session, or restart it, to rebind the bridge",
1191
+ } as const);
1192
+ if (approval?.kind === "missing") {
1125
1193
  content = `${content}\n${TICK_APPROVAL_UNAVAILABLE_RULE}`;
1126
1194
  if (!session.approvalToolMissingLogged) {
1127
1195
  session.approvalToolMissingLogged = true;
@@ -1129,20 +1197,10 @@ function tick(pi: TickApi, ctx: TickContext, config: TickConfig, session: TickSe
1129
1197
  // inbound configured)` throughout the incident, so nothing else told the
1130
1198
  // operator the approval contract was unsatisfiable. A line buried at
1131
1199
  // info, beside one "tick sent" per interval, would not be found.
1132
- //
1133
- // It names the cause, not just the symptom, because the remedy is a file
1134
- // the operator owns: omp-telegram mounts the tool only for a turn whose
1135
- // prompt resolves a notify target, and a locally injected tick resolves
1136
- // one only through `notifyTarget()` — `notifyMode` "away" or "always",
1137
- // plus a destination.
1138
- pi.logger.error(
1139
- `[omp-conductor] ${TELEGRAM_APPROVAL_TOOL} is not mounted on this tick surface: omp-telegram mounts it ` +
1140
- "only for a turn that resolves a notify target, and a locally injected tick resolves one only when " +
1141
- `notifyMode is "away" or "always" with a destination (notifyChat, or topicsChat for a forum) in ` +
1142
- `${config.accessFile ?? "the omp-telegram access.json"} — until then ticks instruct the orchestrator ` +
1143
- "to deliver amendment questions with telegram_send and never to assume an answer",
1144
- { tool: TELEGRAM_APPROVAL_TOOL, ...(config.accessFile === undefined ? {} : { accessFile: config.accessFile }) },
1145
- );
1200
+ pi.logger.error(`[omp-conductor] ${approval.reason}`, {
1201
+ tool: TELEGRAM_APPROVAL_TOOL,
1202
+ accessFile: config.accessFile,
1203
+ });
1146
1204
  }
1147
1205
  }
1148
1206
 
@@ -1226,6 +1284,7 @@ export default function orchestratorTickExtension(pi: TickApi): void {
1226
1284
  const session: TickSession = {
1227
1285
  scopeFallbackLogged: false,
1228
1286
  approvalToolMissingLogged: false,
1287
+ bridgeTokenAtStart: true,
1229
1288
  pendingSkips: 0,
1230
1289
  };
1231
1290
  let releaseGateArmed = false;
@@ -1325,6 +1384,24 @@ export default function orchestratorTickExtension(pi: TickApi): void {
1325
1384
 
1326
1385
  const config = result.config;
1327
1386
 
1387
+ // Whether omp-telegram could have bound a token when this session started,
1388
+ // which is a different question from whether one exists now and is the only
1389
+ // one the approval preflight can honestly ask. The bridge resolves its token
1390
+ // once, in `startBot()`, and rebinds only on `/telegram token` or
1391
+ // `/telegram on` — so a token written into `.env` out-of-band leaves
1392
+ // `telegram_ask` and `telegram_send` dead for the life of this session even
1393
+ // though the file now looks perfect. Sampling here, next to the same
1394
+ // `session_start` omp-telegram binds on, is as close as another package can
1395
+ // get to that fact.
1396
+ //
1397
+ // Conservative on the other transition: an operator who ran `/telegram on`
1398
+ // after start really does have a working bridge, and this snapshot will keep
1399
+ // saying otherwise until the session restarts. That costs a fallback
1400
+ // instruction the orchestrator can follow, where guessing the other way
1401
+ // costs an amendment recorded as approved that nobody ever answered.
1402
+ session.bridgeTokenAtStart =
1403
+ config.accessFile === undefined ? true : bridgeTokenBound(config.accessFile);
1404
+
1328
1405
  // Activation is a property of the directory, so every omp session started in
1329
1406
  // the fleet's cwd used to become a ticker — and with merge and release
1330
1407
  // delegated in config, a shell opened beside the orchestrator believed it
package/src/store.ts CHANGED
@@ -85,6 +85,9 @@ const UPDATABLE_COLUMNS: Record<string, true> = {
85
85
  sessionFile: true,
86
86
  prUrl: true,
87
87
  headSha: true,
88
+ salvageSha: true,
89
+ salvageError: true,
90
+ salvageAckAt: true,
88
91
  startedAt: true,
89
92
  endedAt: true,
90
93
  lastError: true,
@@ -109,6 +112,9 @@ interface RunRow {
109
112
  sessionFile: string | null;
110
113
  prUrl: string | null;
111
114
  headSha: string | null;
115
+ salvageSha: string | null;
116
+ salvageError: string | null;
117
+ salvageAckAt: number | null;
112
118
  startedAt: number;
113
119
  endedAt: number | null;
114
120
  lastError: string | null;
@@ -146,6 +152,9 @@ CREATE TABLE IF NOT EXISTS runs (
146
152
  sessionFile TEXT,
147
153
  prUrl TEXT,
148
154
  headSha TEXT,
155
+ salvageSha TEXT,
156
+ salvageError TEXT,
157
+ salvageAckAt INTEGER,
149
158
  startedAt INTEGER NOT NULL,
150
159
  endedAt INTEGER,
151
160
  lastError TEXT
@@ -217,6 +226,9 @@ function toRecord(row: RunRow): RunRecord {
217
226
  if (row.sessionFile !== null) record.sessionFile = row.sessionFile;
218
227
  if (row.prUrl !== null) record.prUrl = row.prUrl;
219
228
  if (row.headSha !== null) record.headSha = row.headSha;
229
+ if (row.salvageSha !== null) record.salvageSha = row.salvageSha;
230
+ if (row.salvageError !== null) record.salvageError = row.salvageError;
231
+ if (row.salvageAckAt !== null) record.salvageAckAt = row.salvageAckAt;
220
232
  if (row.endedAt !== null) record.endedAt = row.endedAt;
221
233
  if (row.lastError !== null) record.lastError = row.lastError;
222
234
  return record;
@@ -324,12 +336,27 @@ export function openStore(dbPath: string): Store {
324
336
  if (!columns.some((column) => column.name === "headSha")) {
325
337
  db.exec("ALTER TABLE runs ADD COLUMN headSha TEXT");
326
338
  }
339
+ // v0.3.22 and earlier removed a blocked run's dirty tree without saving it
340
+ // (#118), so no row before this release has anywhere to record where the
341
+ // work went. A NULL `salvageSha` on a historical row therefore means "never
342
+ // asked", which reads the same as "clean tree" — the honest reading for a
343
+ // release that never salvaged one.
344
+ for (const [name, type] of [
345
+ ["salvageSha", "TEXT"],
346
+ ["salvageError", "TEXT"],
347
+ ["salvageAckAt", "INTEGER"],
348
+ ] as const) {
349
+ if (!columns.some((column) => column.name === name)) {
350
+ db.exec(`ALTER TABLE runs ADD COLUMN ${name} ${type}`);
351
+ }
352
+ }
327
353
 
328
354
  const insertRun = db.query<unknown, SqlValue[]>(
329
355
  `INSERT INTO runs (
330
356
  id, project, issue, repo, branch, worktree, state, attempt, turns,
331
- maxTurns, spendUsd, sessionFile, prUrl, headSha, startedAt, endedAt, lastError
332
- ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
357
+ maxTurns, spendUsd, sessionFile, prUrl, headSha, salvageSha, salvageError,
358
+ salvageAckAt, startedAt, endedAt, lastError
359
+ ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
333
360
  );
334
361
  const selectRun = db.query<RunRow, [string]>(`SELECT * FROM runs WHERE id = ?`);
335
362
  const selectActive = db.query<RunRow, SqlValue[]>(
@@ -370,6 +397,21 @@ export function openStore(dbPath: string): Store {
370
397
  `SELECT COUNT(*) AS n FROM runs
371
398
  WHERE project = ? AND issue = ? AND state IN ('killed', 'orphaned', 'blocked')`,
372
399
  );
400
+ // Salvage state that still describes an issue's present, so an operator is
401
+ // shown a preserved WIP tip exactly while it is the thing a re-claim would
402
+ // build on — and an unsalvaged tree for as long as it is the only copy.
403
+ // Newest-attempt-only for the same reason the board is: a later attempt has
404
+ // already consumed or superseded whatever an older one left behind.
405
+ const selectSalvaged = db.query<RunRow, [string]>(
406
+ `SELECT * FROM runs
407
+ WHERE rowid IN (
408
+ SELECT MAX(rowid) FROM runs
409
+ WHERE project = ?
410
+ GROUP BY issue
411
+ )
412
+ AND (salvageSha IS NOT NULL OR salvageError IS NOT NULL)
413
+ ORDER BY issue ASC`,
414
+ );
373
415
  // Newest attempt for one issue. `startedAt` is millisecond-resolution and two
374
416
  // attempts could in principle share one, so rowid breaks the tie by insertion
375
417
  // order — a `tail` that attached to the older of two same-millisecond attempts
@@ -487,6 +529,9 @@ export function openStore(dbPath: string): Store {
487
529
  toSql(record.sessionFile),
488
530
  toSql(record.prUrl),
489
531
  toSql(record.headSha),
532
+ toSql(record.salvageSha),
533
+ toSql(record.salvageError),
534
+ toSql(record.salvageAckAt),
490
535
  record.startedAt,
491
536
  toSql(record.endedAt),
492
537
  toSql(record.lastError),
@@ -532,6 +577,10 @@ export function openStore(dbPath: string): Store {
532
577
  return selectRecentRuns.all(project, mergedSinceEpochMs).map(toRecord);
533
578
  },
534
579
 
580
+ salvagedRuns(project: string): RunRecord[] {
581
+ return selectSalvaged.all(project).map(toRecord);
582
+ },
583
+
535
584
  attemptsFor(project: string, issue: number): number {
536
585
  return countAttempts.get(project, issue)?.n ?? 0;
537
586
  },
package/src/types.ts CHANGED
@@ -373,6 +373,17 @@ export interface RunRecord {
373
373
  prUrl?: string;
374
374
  /** Pull request head the worker observed after its deterministic CI watcher exited. */
375
375
  headSha?: string;
376
+ /** Commit this run's uncommitted work was preserved as before its worktree
377
+ * was removed, on the run's own branch. Absent means the daemon found
378
+ * nothing to save, or never looked — see {@link RunRecord.salvageError}. */
379
+ salvageSha?: string;
380
+ /** Why the salvage failed. Present means the worktree still holds the only
381
+ * copy of real work, so the tree was kept and the issue is held out of
382
+ * dispatch until an operator acknowledges it. */
383
+ salvageError?: string;
384
+ /** When an operator accepted the loss or recovered the tree by hand
385
+ * (`unblock --force`). Clears the hold without erasing what happened. */
386
+ salvageAckAt?: number;
376
387
  startedAt: number;
377
388
  endedAt?: number;
378
389
  /** Last failure text, surfaced verbatim in escalations. */
@@ -388,6 +399,7 @@ export type AdmissionHoldReason =
388
399
  | "sibling-active"
389
400
  | "open-pr-lookup-error"
390
401
  | "open-pr"
402
+ | "unsalvaged-wip"
391
403
  | "daily-spend-cap"
392
404
  | "unroutable:no-repo-label"
393
405
  | "unroutable:multiple-repo-labels"
@@ -470,6 +482,10 @@ export interface Store {
470
482
  /** Newest attempt per issue for the live board. Non-merged work remains
471
483
  * visible; merged rows are bounded by the supplied recent-history cutoff. */
472
484
  recentRuns(project: string, mergedSinceEpochMs: number): RunRecord[];
485
+ /** Newest attempt per issue that preserved work or failed to, so `status`
486
+ * can name every WIP tip a re-claim would build on and every tree that is
487
+ * still the only copy. */
488
+ salvagedRuns(project: string): RunRecord[];
473
489
  /** Total run segments, used only for the monotonically increasing run number. */
474
490
  attemptsFor(project: string, issue: number): number;
475
491
  /** Terminal implementation failures that consume `maxAttemptsPerIssue`. */
package/src/unblock.ts CHANGED
@@ -40,6 +40,11 @@ export interface UnblockOutcome {
40
40
  continuationsUsed: number;
41
41
  /** Newest attempt, when the store has one for this issue at all. */
42
42
  latest?: RunRecord;
43
+ /** Set when nothing was cleared because the newest attempt's work exists
44
+ * only in its worktree. Carries the salvage failure verbatim. */
45
+ refused?: string;
46
+ /** Set when `--force` recorded an operator's acceptance of that loss. */
47
+ forced?: true;
43
48
  }
44
49
 
45
50
  /**
@@ -81,11 +86,35 @@ export async function unblockIssue(
81
86
  tracker: Tracker,
82
87
  store: Store,
83
88
  issue: number,
89
+ opts: { force?: boolean } = {},
84
90
  ): Promise<UnblockOutcome> {
85
91
  // Read before any label is touched: terminality is the whole of the argument
86
92
  // for clearing in-progress, so the row that carries it decides the set.
87
93
  const latest = store.latestRun(project.name, issue);
88
94
  const terminal = latest !== undefined && !LIVE_STATES.includes(latest.state);
95
+ const counts = {
96
+ attemptsUsed: store.attemptsFor(project.name, issue),
97
+ failuresUsed: store.failuresFor(project.name, issue),
98
+ continuationsUsed: store.continuationsFor(project.name, issue),
99
+ };
100
+
101
+ // The one case where this verb refuses. Clearing the labels here re-queues an
102
+ // issue whose next claim starts by force-removing the worktree that holds the
103
+ // only copy of the last attempt's work (#118) — and unlike every other state
104
+ // this function reasons about, that is unrecoverable. `--force` is the way
105
+ // through, and it is deliberately a separate keystroke rather than a prompt
106
+ // the operator can wave past: it records, on the row, that a human accepted
107
+ // the loss or recovered the tree themselves. Narrowed to the row rather than
108
+ // a boolean so the reason printed and the acknowledgement written are
109
+ // provably about the same run.
110
+ const held =
111
+ latest !== undefined && latest.salvageError !== undefined && latest.salvageAckAt === undefined
112
+ ? latest
113
+ : undefined;
114
+ if (held !== undefined && opts.force !== true) {
115
+ return { cleared: [], ...counts, latest: held, refused: held.salvageError };
116
+ }
117
+ if (held !== undefined) store.updateRun(held.id, { salvageAckAt: Date.now() });
89
118
 
90
119
  const cleared: string[] = [];
91
120
  for (const label of new Set([
@@ -99,10 +128,9 @@ export async function unblockIssue(
99
128
 
100
129
  return {
101
130
  cleared,
102
- attemptsUsed: store.attemptsFor(project.name, issue),
103
- failuresUsed: store.failuresFor(project.name, issue),
104
- continuationsUsed: store.continuationsFor(project.name, issue),
131
+ ...counts,
105
132
  ...(latest === undefined ? {} : { latest }),
133
+ ...(held === undefined ? {} : { forced: true as const }),
106
134
  };
107
135
  }
108
136
 
@@ -116,6 +144,11 @@ export async function unblockIssue(
116
144
  * run row at all, where that label may still be sitting there unread. #18 was
117
145
  * filed against this function saying `next tick eligible again` in a case where
118
146
  * it was not, so the wording is a contract rather than prose.
147
+ *
148
+ * A refusal is the loudest thing this verb prints, and it prints instead of
149
+ * everything else: the operator asked to re-queue an issue whose only copy of
150
+ * real work is a directory, and the next line they read has to be the one that
151
+ * stops them typing the same command again with `--force` on the end.
119
152
  */
120
153
  export function formatUnblock(
121
154
  issue: number,
@@ -124,7 +157,24 @@ export function formatUnblock(
124
157
  caps: Caps,
125
158
  ): string {
126
159
  const latest = o.latest;
160
+
161
+ if (o.refused !== undefined) {
162
+ return [
163
+ `#${issue}: REFUSED — nothing was cleared`,
164
+ ` reason attempt ${latest?.attempt ?? "?"} could not commit its work: ${o.refused}`,
165
+ ` only copy ${latest?.worktree === undefined || latest.worktree === "" ? "(worktree path not recorded)" : latest.worktree}`,
166
+ " why re-claiming this issue removes that worktree, and the work is not on any ref",
167
+ ` recover inspect the tree and commit or copy what matters, then re-run with --force`,
168
+ ].join("\n");
169
+ }
170
+
127
171
  const lines = [`#${issue}: cleared ${o.cleared.join(", ")}`];
172
+ if (o.forced === true) {
173
+ lines.push(
174
+ ` forced the unsalvaged worktree was accepted as lost or already recovered by hand — ` +
175
+ "the next claim removes it",
176
+ );
177
+ }
128
178
 
129
179
  if (latest === undefined) {
130
180
  lines.push(" runs none recorded — the terminal labels were cleared anyway; eligibility is read off the tracker");
package/src/worktree.ts CHANGED
@@ -417,15 +417,21 @@ const SALVAGE_COMMIT_CONFIG = [
417
417
  * Subject stays the historical one-liner so status greps keep working; the
418
418
  * body is the manifest (#38). Cap the new-path list so a runaway tree cannot
419
419
  * push a multi-kilobyte commit message into every escalation.
420
+ *
421
+ * `ending` is the whole clause rather than a bare reason because not every
422
+ * salvaged run was killed. A run that blocked for a decision stopped on
423
+ * purpose (#118), and a commit message telling the operator reading it during
424
+ * recovery that the attempt was "killed by a blocked run" is a lie about the
425
+ * one artefact they are using to reconstruct what happened.
420
426
  */
421
427
  export function salvageCommitMessage(
422
428
  issue: number,
423
429
  attempt: number,
424
- reason: string,
430
+ ending: string,
425
431
  files: string[],
426
432
  newPaths: string[],
427
433
  ): { subject: string; body: string } {
428
- const subject = `wip(#${issue}): attempt ${attempt} killed by ${reason} — auto-salvaged`;
434
+ const subject = `wip(#${issue}): attempt ${attempt} ${ending} — auto-salvaged`;
429
435
  const n = files.length;
430
436
  const count = `${n} file${n === 1 ? "" : "s"}`;
431
437
  if (newPaths.length === 0) {
@@ -459,19 +465,27 @@ function parseCachedNameStatus(raw: string): { files: string[]; newPaths: string
459
465
  }
460
466
 
461
467
  /**
462
- * Commits a dead run's uncommitted work to the run's own branch and pushes it,
463
- * so that the tree the next attempt destroys is no longer the only copy.
468
+ * Commits a run's uncommitted work to the run's own branch and pushes it, so
469
+ * that the tree the next attempt destroys is no longer the only copy.
464
470
  *
465
471
  * This closes a deliberate asymmetry. `addWorktree` preserves the run branch
466
472
  * precisely because "that branch can hold the only copy of work attempt 1
467
473
  * committed but never pushed", while `removeWorktree` runs `worktree remove
468
474
  * --force` and `addWorktree` refuses to reuse a tree that "may hold a previous
469
475
  * attempt's uncommitted work" — committed work is kept by design, uncommitted
470
- * work is discarded by design. That trade is fair for a worker that *stops*:
471
- * blocking is a decision it makes with turns left to commit first. It is not
472
- * fair for one killed by the turns cap or the wall clock, or one that crashes:
473
- * that end is external, unannounced, mid-sentence, and it lands hardest on the
474
- * long refactors carrying the most unsaved work. So: non-graceful ends only.
476
+ * work was discarded by design.
477
+ *
478
+ * That trade was first taken only for non-graceful ends, on the argument that
479
+ * a worker which *stops* had turns left to commit for itself. #118 is what
480
+ * that argument cost. veltro#349 blocked on a real decision whether a
481
+ * failing test was obsolete — after editing 34 files across a request/auth
482
+ * boundary, and the daemon force-removed the tree seconds later; the branch
483
+ * and `origin/main` still pointed at the same commit, and the session
484
+ * transcript was the only surviving evidence the work had ever existed. A
485
+ * worker asking permission is precisely a worker declining to commit a
486
+ * half-migrated tree, so "had turns to commit" and "should have committed"
487
+ * were never the same claim. Every end whose tree is about to be removed
488
+ * salvages: killed, crashed, orphaned, and blocked alike.
475
489
  *
476
490
  * Never throws. Every outcome, including its own failure, comes back as a value
477
491
  * for the caller to log and to put in front of a human.
@@ -481,13 +495,13 @@ function parseCachedNameStatus(raw: string): { files: string[]; newPaths: string
481
495
  * commit in this host's mirror, which is strictly better than nothing. It is a
482
496
  * plain fast-forward push — never a force — and if the run already had a PR
483
497
  * open, that PR gains the WIP commit and re-runs its checks. That is the price
484
- * of work outliving its host, and only a run that already failed ever pays it.
498
+ * of work outliving its host.
485
499
  */
486
500
  export async function salvageWip(
487
501
  worktree: string,
488
502
  issue: number,
489
503
  attempt: number,
490
- reason: string,
504
+ ending: string,
491
505
  ): Promise<SalvageOutcome> {
492
506
  try {
493
507
  // A tree that is not there cannot be holding work. Checked before spawning
@@ -532,7 +546,7 @@ export async function salvageWip(
532
546
  return { kind: "nothing" };
533
547
  }
534
548
  const { files, newPaths } = parseCachedNameStatus(cached);
535
- const msg = salvageCommitMessage(issue, attempt, reason, files, newPaths);
549
+ const msg = salvageCommitMessage(issue, attempt, ending, files, newPaths);
536
550
  await git(
537
551
  [
538
552
  ...SALVAGE_COMMIT_CONFIG,