omp-conductor 0.2.2 → 0.3.1

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/src/store.ts CHANGED
@@ -18,8 +18,12 @@ import type { RunRecord, RunState, Store } from "./types.ts";
18
18
  /**
19
19
  * States backed by a worker process. These are what worker capacity counts:
20
20
  * a slot is a process, and only a claimed or running attempt has one.
21
+ *
22
+ * Exported because it is also the answer to "is anything still writing to this
23
+ * run's transcript?" — `omp-conductor tail` needs that and must not re-derive
24
+ * it, or the two definitions drift the first time a state is added.
21
25
  */
22
- const LIVE_STATES: readonly RunState[] = ["claimed", "running"];
26
+ export const LIVE_STATES: readonly RunState[] = ["claimed", "running"];
23
27
 
24
28
  /**
25
29
  * States that keep an *issue* occupied. `pushed-green` belongs here but not in
@@ -182,6 +186,16 @@ export function openStore(dbPath: string): Store {
182
186
  const countAttempts = db.query<{ n: number }, [string, number]>(
183
187
  `SELECT COUNT(*) AS n FROM runs WHERE project = ? AND issue = ?`,
184
188
  );
189
+ // Newest attempt for one issue. `startedAt` is millisecond-resolution and two
190
+ // attempts could in principle share one, so rowid breaks the tie by insertion
191
+ // order — a `tail` that attached to the older of two same-millisecond attempts
192
+ // would follow a transcript nobody is writing to any more.
193
+ const selectLatestRun = db.query<RunRow, [string, number]>(
194
+ `SELECT * FROM runs
195
+ WHERE project = ? AND issue = ?
196
+ ORDER BY startedAt DESC, rowid DESC
197
+ LIMIT 1`,
198
+ );
185
199
  const countStartedSince = db.query<{ n: number }, [string, number]>(
186
200
  `SELECT COUNT(*) AS n FROM runs WHERE project = ? AND startedAt >= ?`,
187
201
  );
@@ -255,6 +269,11 @@ export function openStore(dbPath: string): Store {
255
269
  return countAttempts.get(project, issue)?.n ?? 0;
256
270
  },
257
271
 
272
+ latestRun(project: string, issue: number): RunRecord | undefined {
273
+ const row = selectLatestRun.get(project, issue);
274
+ return row ? toRecord(row) : undefined;
275
+ },
276
+
258
277
  runsStartedSince(project: string, sinceEpochMs: number): number {
259
278
  return countStartedSince.get(project, sinceEpochMs)?.n ?? 0;
260
279
  },
@@ -10,7 +10,7 @@
10
10
  * ~200-400ms) and failure classification by matching human-readable stderr
11
11
  * instead of reading a status code. Upgrade path when either bites: replace the
12
12
  * body of `gh()` with `fetch("https://api.github.com/...")` using a token from
13
- * `gh auth token`; the six Tracker methods above it stay untouched.
13
+ * `gh auth token`; the seven Tracker methods above it stay untouched.
14
14
  */
15
15
 
16
16
  import type { ProjectConfig, ReadyIssue, Tracker } from "../types.ts";
@@ -26,6 +26,47 @@ interface GhIssue {
26
26
  updatedAt: string;
27
27
  }
28
28
 
29
+ /**
30
+ * Pull requests that would close an issue, with the one field that decides it.
31
+ *
32
+ * `gh issue view <n> --json closedByPullRequestsReferences` returns `id`,
33
+ * `number`, `repository` and `url` and no state, so a guard built on it holds an
34
+ * issue forever on a reference that is merged or closed-unmerged — which is
35
+ * exactly what a reopened issue looks like. GraphQL is the only spelling that
36
+ * yields `state`, so it is the spelling used.
37
+ *
38
+ * `includeClosedPrs:false` is deliberately not passed: it is not the filter it
39
+ * sounds like. Measured on gh 2.86.0 (2026-08-06), `veltro#260` returned a
40
+ * MERGED reference with the argument both true and false. The state is filtered
41
+ * in this consumer instead.
42
+ *
43
+ * ponytail: one page of ten. An issue closed by more than ten PRs is not a
44
+ * dispatch problem, and the first OPEN one already answers the question.
45
+ */
46
+ const CLOSERS_QUERY = `query($owner:String!,$repo:String!,$n:Int!){
47
+ repository(owner:$owner,name:$repo){
48
+ issue(number:$n){
49
+ closedByPullRequestsReferences(first:10){
50
+ nodes{ number state isDraft url repository{ nameWithOwner } }
51
+ }
52
+ }
53
+ }
54
+ }`;
55
+
56
+ /** The `gh api graphql` envelope for {@link CLOSERS_QUERY}. Every level is
57
+ * nullable: a deleted or wrong-numbered issue answers `null`, not an error. */
58
+ interface ClosersResponse {
59
+ data?: {
60
+ repository?: {
61
+ issue?: {
62
+ closedByPullRequestsReferences?: {
63
+ nodes?: ({ state: string; isDraft: boolean; url: string } | null)[] | null;
64
+ } | null;
65
+ } | null;
66
+ } | null;
67
+ } | null;
68
+ }
69
+
29
70
  /** Carries the captured stderr so callers can classify a failure without
30
71
  * re-running the command or parsing the message text of a plain Error. */
31
72
  class GhError extends Error {
@@ -74,17 +115,45 @@ async function gh(argv: string[], stdin?: string): Promise<string> {
74
115
  * True when a label edit failed only because the requested end state already
75
116
  * holds. Adding a label is idempotent server-side, but removing one that is not
76
117
  * present is a 404, and a concurrent daemon restart can easily race into both.
118
+ *
119
+ * The quoted form is gh's own, and it is why the two operations are classified
120
+ * separately: `gh issue edit --remove-label x` on a label the *repository* does
121
+ * not define exits 1 with `'x' not found` (2.97.0), which for a removal is the
122
+ * end state already holding — a label nobody defined cannot be on an issue —
123
+ * while for an add it is a genuine failure. Removing a label the repo defines
124
+ * but the issue does not carry is already a silent success, so this path is
125
+ * reached only by the undefined-label case, most often a state label an
126
+ * operator declined to create at setup.
77
127
  */
78
128
  function isLabelNoop(err: unknown, op: "add" | "remove"): boolean {
79
129
  if (!(err instanceof GhError)) return false;
80
130
  const stderr = err.stderr;
81
131
  return op === "add"
82
132
  ? /already (?:has|had|exists|applied|added)|label .* already/i.test(stderr)
83
- : /label does not exist|not labeled|does not have (?:that|the|this) label|label .* not found|not found on (?:this )?issue/i.test(
133
+ : /label does not exist|not labeled|does not have (?:that|the|this) label|label .* not found|not found on (?:this )?issue|'[^']+' not found/i.test(
84
134
  stderr,
85
135
  );
86
136
  }
87
137
 
138
+ /**
139
+ * The URL of the first OPEN closer in a `gh api graphql` reply, if any.
140
+ *
141
+ * Split from the call so the state filter — the only real logic in this file —
142
+ * is pinned against recorded payloads instead of a live repo.
143
+ *
144
+ * A draft counts. `isDraft` is selected because the API offers it, not because
145
+ * it changes the answer: draft means "not ready to review", not "not pushed",
146
+ * and the branch behind a draft still holds the only copy of the work. Sending
147
+ * a second worker at it duplicates that work exactly as much as a ready PR
148
+ * would, so OPEN is the whole test.
149
+ */
150
+ export function firstOpenCloser(raw: string): string | undefined {
151
+ const nodes =
152
+ (JSON.parse(raw) as ClosersResponse).data?.repository?.issue?.closedByPullRequestsReferences
153
+ ?.nodes ?? [];
154
+ return nodes.find((n) => n !== null && n.state === "OPEN")?.url;
155
+ }
156
+
88
157
  export function makeTracker(p: ProjectConfig): Tracker {
89
158
  const repo = p.tracker.repo;
90
159
 
@@ -156,5 +225,28 @@ export function makeTracker(p: ProjectConfig): Tracker {
156
225
  // maintaining a second index of it.
157
226
  await gh(["issue", "edit", String(parent), "--repo", repo, "--add-sub-issue", String(child)]);
158
227
  },
228
+
229
+ async openCloserFor(issue: number): Promise<string | undefined> {
230
+ // GraphQL wants the halves of `owner/repo` separately. Config validates
231
+ // that spelling, so an empty half means a hand-edited config: `gh` then
232
+ // errors and the caller holds the candidate rather than guessing.
233
+ const [owner = "", name = ""] = repo.split("/");
234
+ const raw = await gh([
235
+ "api",
236
+ "graphql",
237
+ "-f",
238
+ `query=${CLOSERS_QUERY}`,
239
+ "-F",
240
+ `owner=${owner}`,
241
+ "-F",
242
+ `repo=${name}`,
243
+ // -F, not -f: the query declares $n as Int! and a string would be a
244
+ // type error rather than a coerced number.
245
+ "-F",
246
+ `n=${issue}`,
247
+ ]);
248
+
249
+ return firstOpenCloser(raw);
250
+ },
159
251
  };
160
252
  }
package/src/types.ts CHANGED
@@ -13,7 +13,7 @@
13
13
  * counts turns, wall clock and dollars itself and kills anything over the line.
14
14
  */
15
15
  export interface Caps {
16
- /** Parallel omp sessions. Two, because the homelab only has 3 CI runners and
16
+ /** Parallel omp sessions. Two by default: on a small self-hosted runner pool
17
17
  * a third worker would starve its own PR checks. */
18
18
  maxConcurrentWorkers: number;
19
19
  /** Rolling-day spend ceiling; the loop stops claiming work once it is hit. */
@@ -65,6 +65,33 @@ export type ReportScope = (typeof REPORT_SCOPES)[number];
65
65
  */
66
66
  export const DEFAULT_REPORT_SCOPE: ReportScope = "material";
67
67
 
68
+ /**
69
+ * Who holds an authority the daemon itself never exercises. Declared as data
70
+ * for the same reason as {@link REPORT_SCOPES}: the validator, the wizard and
71
+ * the brief renderer all enumerate the same two holders, so a third one cannot
72
+ * be added while any of them still knows only two.
73
+ */
74
+ export const AUTHORITY_HOLDERS = ["human", "orchestrator"] as const;
75
+
76
+ export type AuthorityHolder = (typeof AUTHORITY_HOLDERS)[number];
77
+
78
+ /**
79
+ * What a project that never answered the question gets: humans keep both. A
80
+ * default that delegated merging would hand a fresh fleet write access to its
81
+ * own main branch on the strength of an unread config file.
82
+ */
83
+ export const DEFAULT_AUTHORITY: ProjectConfig["authority"] = { merge: "human", release: "human" };
84
+
85
+ /**
86
+ * Where the session that triages escalations lives. `embedded` is the daemon's
87
+ * own child session; `external` means an operator already runs one — a visible
88
+ * TUI session, say — and the daemon must not start a second brain that would
89
+ * re-triage the same issues from a different transcript.
90
+ */
91
+ export const ORCHESTRATOR_MODES = ["embedded", "external"] as const;
92
+
93
+ export type OrchestratorMode = (typeof ORCHESTRATOR_MODES)[number];
94
+
68
95
  /**
69
96
  * Everything the dispatcher needs to service one product: where work comes
70
97
  * from, where code goes, and what it may spend doing it. Config is per project
@@ -91,8 +118,18 @@ export interface ProjectConfig {
91
118
  * answered the question wants.
92
119
  */
93
120
  workerModel?: string;
94
- /** How a stuck run reaches a human, and what to do when it cannot. */
95
- escalation: { telegramChatId?: string; fallbackToIssueComment: boolean };
121
+ /**
122
+ * How a stuck run reaches a human, what to do when it cannot, and who runs
123
+ * the session that triages it. See {@link ORCHESTRATOR_MODES}.
124
+ */
125
+ escalation: { telegramChatId?: string; fallbackToIssueComment: boolean; orchestrator: OrchestratorMode };
126
+ /**
127
+ * Who lands green PRs and who cuts releases. The daemon never acts on this
128
+ * itself — it words the orchestrator's standing orders and the rendered brief
129
+ * scaffold with it, so config and prompt can never disagree about which of
130
+ * them is holding the merge button.
131
+ */
132
+ authority: { merge: AuthorityHolder; release: AuthorityHolder };
96
133
  /**
97
134
  * How loud the orchestrator is. Optional on disk — a config written before
98
135
  * this key existed loads as {@link DEFAULT_REPORT_SCOPE} — so read it through
@@ -156,6 +193,18 @@ export interface Tracker {
156
193
  /** Records that a worker split its issue, so follow-up work stays traceable
157
194
  * to the request that spawned it. */
158
195
  linkParent(child: number, parent: number): Promise<void>;
196
+ /**
197
+ * The URL of an OPEN pull request that already closes `issue`, or undefined
198
+ * when none does.
199
+ *
200
+ * Admission has to ask the tracker because the store cannot answer. The busy
201
+ * set is built from run rows, so it only knows work *this* database recorded:
202
+ * a migration onto the daemon, a wiped or relocated state directory, a
203
+ * restore onto a new host, or simply a database younger than the PRs all
204
+ * present pushed-and-open work as an untouched queue item. The tracker is the
205
+ * only party that remembers across all of those.
206
+ */
207
+ openCloserFor(issue: number): Promise<string | undefined>;
159
208
  }
160
209
 
161
210
  /**
@@ -214,6 +263,10 @@ export interface Store {
214
263
  /** Runs backed by a worker process — what capacity counts. Subset of {@link Store.activeRuns}. */
215
264
  liveRuns(project: string): RunRecord[];
216
265
  attemptsFor(project: string, issue: number): number;
266
+ /** Newest attempt for one issue, whatever state it reached. `omp-conductor
267
+ * tail` resolves an issue number to a transcript through this; the number is
268
+ * what an operator has, the run id is not. */
269
+ latestRun(project: string, issue: number): RunRecord | undefined;
217
270
  runsStartedSince(project: string, sinceEpochMs: number): number;
218
271
  spendSince(project: string, sinceEpochMs: number): number;
219
272
  /** Idempotence guard so a retry loop cannot page a human repeatedly for the
package/src/unblock.ts ADDED
@@ -0,0 +1,113 @@
1
+ /**
2
+ * Giving an answered block a way back into the queue.
3
+ *
4
+ * A blocked or failed run leaves its state label on the issue, and eligibility
5
+ * treats any state label as disqualifying (`routing.isEligible`). That is the
6
+ * right interlock — it is what stops a second worker landing on live work
7
+ * across a daemon restart — but until this verb existed it had no exit: the
8
+ * orchestrator's brief tells it never to hand-edit a state label, so an issue
9
+ * whose question it had just answered could only be left labelled, which means
10
+ * never re-claimed and the answer inert. Nothing surfaces that either: the
11
+ * issue does not fail, it simply stops existing as far as dispatch is
12
+ * concerned.
13
+ *
14
+ * So the clearing is a daemon-owned act, through the same tracker port the
15
+ * dispatcher writes labels with, and the brief's rule stays absolute. That
16
+ * absoluteness is worth more than the exception it replaces: orphan detection
17
+ * is only trustworthy while every state label on the tracker was written by
18
+ * this package.
19
+ *
20
+ * Nothing here writes to the store, and that is a decision rather than an
21
+ * omission. `RunState` describes what a worker process did; an answer is the
22
+ * one event that happens outside every run, so no member fits it — folding it
23
+ * into `merged` or `killed` would make `status` describe a run that never
24
+ * reached either. Eligibility is read off the tracker's labels and never off a
25
+ * run row, so the store has nothing to say here. Leaving the history alone is
26
+ * also what keeps `maxAttemptsPerIssue` honest: an answered block still spent a
27
+ * worker's whole budget, and the same question answered twice is a loop the cap
28
+ * exists to stop.
29
+ */
30
+
31
+ import { LIVE_STATES } from "./store.ts";
32
+ import type { Caps, ProjectConfig, RunRecord, Store, Tracker } from "./types.ts";
33
+
34
+ /** What one `unblock` did, and the state it found around it. */
35
+ export interface UnblockOutcome {
36
+ /** State labels the tracker was asked to drop. */
37
+ cleared: string[];
38
+ /** Attempts this issue has already spent. Unchanged by the unblock. */
39
+ attemptsUsed: number;
40
+ /** Newest attempt, when the store has one for this issue at all. */
41
+ latest?: RunRecord;
42
+ }
43
+
44
+ /**
45
+ * Drop both terminal state labels, whichever the issue is actually carrying.
46
+ *
47
+ * Both unconditionally, because the tracker is the only source of truth for
48
+ * which one is set and this process cannot read that back through the Tracker
49
+ * port — inferring it from the newest run row would be a guess that goes wrong
50
+ * exactly when a human has relabelled something by hand. Removing a label an
51
+ * issue does not carry is a no-op: `gh issue edit --remove-label` exits 0 on an
52
+ * absent label (verified against gh 2.97.0), and the adapter swallows the 404
53
+ * older paths return for one.
54
+ *
55
+ * `agent:in-progress` is deliberately not in the set. It means a worker process
56
+ * exists, which is not something an operator can answer away, and clearing it
57
+ * from under a live run is how two workers end up on one issue.
58
+ */
59
+ export async function unblockIssue(
60
+ project: ProjectConfig,
61
+ tracker: Tracker,
62
+ store: Store,
63
+ issue: number,
64
+ ): Promise<UnblockOutcome> {
65
+ const cleared: string[] = [];
66
+ for (const label of new Set([project.stateLabels.blocked, project.stateLabels.failed])) {
67
+ await tracker.removeLabel(issue, label);
68
+ cleared.push(label);
69
+ }
70
+
71
+ const latest = store.latestRun(project.name, issue);
72
+ return {
73
+ cleared,
74
+ attemptsUsed: store.attemptsFor(project.name, issue),
75
+ ...(latest === undefined ? {} : { latest }),
76
+ };
77
+ }
78
+
79
+ /**
80
+ * What the operator reads back. It promises a re-claim only when one can
81
+ * actually happen: a live run still owns the issue through `agent:in-progress`,
82
+ * and a spent attempt budget makes the next tick escalate rather than dispatch.
83
+ * Either promised blindly would send someone away believing work had resumed.
84
+ */
85
+ export function formatUnblock(issue: number, o: UnblockOutcome, project: ProjectConfig, caps: Caps): string {
86
+ const latest = o.latest;
87
+ const lines = [`#${issue}: cleared ${o.cleared.join(", ")}`];
88
+
89
+ if (latest === undefined) {
90
+ lines.push(" attempts none recorded — the labels were cleared anyway; eligibility is read off the tracker");
91
+ } else {
92
+ lines.push(
93
+ ` attempts ${o.attemptsUsed} of ${caps.maxAttemptsPerIssue} used, newest ${latest.state} — ` +
94
+ "unchanged, an answered block still spent a worker",
95
+ );
96
+ }
97
+
98
+ if (latest !== undefined && LIVE_STATES.includes(latest.state)) {
99
+ lines.push(
100
+ ` in flight attempt ${latest.attempt} is ${latest.state}, so the issue keeps ` +
101
+ `"${project.stateLabels.inProgress}" until it ends — nothing is re-claimed before then`,
102
+ );
103
+ } else if (o.attemptsUsed >= caps.maxAttemptsPerIssue) {
104
+ lines.push(
105
+ ` next tick not eligible: all ${caps.maxAttemptsPerIssue} attempts are spent, so the next tick escalates ` +
106
+ "instead of re-claiming. Raise maxAttemptsPerIssue with /conductor setup, or rewrite the issue.",
107
+ );
108
+ } else {
109
+ lines.push(` next tick eligible again, as long as the issue still carries "${project.queueLabel}"`);
110
+ }
111
+
112
+ return lines.join("\n");
113
+ }
package/src/worker.ts CHANGED
@@ -43,6 +43,15 @@ export interface WorkerOpts {
43
43
  */
44
44
  model?: string;
45
45
  onTurn?: (n: number) => void;
46
+ /**
47
+ * The transcript path, handed over the moment the session opens it rather
48
+ * than at the end with {@link WorkerResult.sessionFile}. Both report the same
49
+ * path; only this one arrives while there is still something to watch, which
50
+ * is what `omp-conductor tail` attaches to. Never called for a session that
51
+ * opened no transcript — there is no path to report, and an empty string
52
+ * would be a path that fails to open rather than an absence.
53
+ */
54
+ onSessionFile?: (path: string) => void;
46
55
  }
47
56
 
48
57
  /**
@@ -145,6 +154,11 @@ export async function runWorker(
145
154
  ...(o.model === undefined ? {} : { model: o.model }),
146
155
  });
147
156
 
157
+ // Before the first turn, not after the last: a caller that only learns the
158
+ // transcript path from the result learns it once the run it wanted to watch
159
+ // is already over.
160
+ if (session.sessionFile !== undefined) o.onSessionFile?.(session.sessionFile);
161
+
148
162
  // Every exit below reports the session's own facts the same way: the
149
163
  // transcript it actually opened, and any model downgrade it announced. Read at
150
164
  // return time so a session that materialises either late is still reported
package/src/worktree.ts CHANGED
@@ -268,6 +268,125 @@ export async function addWorktree(
268
268
  return worktreePath;
269
269
  }
270
270
 
271
+ /**
272
+ * What one salvage attempt did. A failure is a *value* rather than a throw
273
+ * because the only caller is a run that is already ending badly: it has to log
274
+ * the outcome, word it into the escalation and still close its run record, and
275
+ * none of that may be skipped by an exception from the last-ditch step.
276
+ */
277
+ export type SalvageOutcome =
278
+ | { kind: "salvaged"; sha: string; branch: string; pushed: boolean; pushError?: string }
279
+ /** Nothing uncommitted was there to save — a clean tree, or no tree at all. */
280
+ | { kind: "nothing" }
281
+ /** There was work and git would not commit it. This is the loud one. */
282
+ | { kind: "failed"; error: string };
283
+
284
+ /**
285
+ * The salvage commit is the daemon's own, made unattended in a worktree whose
286
+ * config it does not control. A machine with no global git identity, a global
287
+ * `commit.gpgsign=true` whose key needs a passphrase nobody can type, or a repo
288
+ * pre-commit hook that rejects half-finished code are all ordinary states — and
289
+ * every one of them would turn "save the work" into "lose the work". So the
290
+ * commit brings its own identity, signs nothing, and skips hooks: it is a
291
+ * snapshot for a human to sort out, never something anyone merges.
292
+ */
293
+ const SALVAGE_COMMIT_CONFIG = [
294
+ "-c",
295
+ "user.name=conductor",
296
+ "-c",
297
+ "user.email=conductor@invalid",
298
+ "-c",
299
+ "commit.gpgsign=false",
300
+ ];
301
+
302
+ /**
303
+ * Commits a dead run's uncommitted work to the run's own branch and pushes it,
304
+ * so that the tree the next attempt destroys is no longer the only copy.
305
+ *
306
+ * This closes a deliberate asymmetry. `addWorktree` preserves the run branch
307
+ * precisely because "that branch can hold the only copy of work attempt 1
308
+ * committed but never pushed", while `removeWorktree` runs `worktree remove
309
+ * --force` and `addWorktree` refuses to reuse a tree that "may hold a previous
310
+ * attempt's uncommitted work" — committed work is kept by design, uncommitted
311
+ * work is discarded by design. That trade is fair for a worker that *stops*:
312
+ * blocking is a decision it makes with turns left to commit first. It is not
313
+ * fair for one killed by the turns cap or the wall clock, or one that crashes:
314
+ * that end is external, unannounced, mid-sentence, and it lands hardest on the
315
+ * long refactors carrying the most unsaved work. So: non-graceful ends only.
316
+ *
317
+ * Never throws. Every outcome, including its own failure, comes back as a value
318
+ * for the caller to log and to put in front of a human.
319
+ *
320
+ * The push is best-effort and deliberately last, after the sha exists: a
321
+ * refused push (diverged branch, no credentials, no network) still leaves the
322
+ * commit in this host's mirror, which is strictly better than nothing. It is a
323
+ * plain fast-forward push — never a force — and if the run already had a PR
324
+ * open, that PR gains the WIP commit and re-runs its checks. That is the price
325
+ * of work outliving its host, and only a run that already failed ever pays it.
326
+ */
327
+ export async function salvageWip(
328
+ worktree: string,
329
+ issue: number,
330
+ attempt: number,
331
+ reason: string,
332
+ ): Promise<SalvageOutcome> {
333
+ try {
334
+ // A tree that is not there cannot be holding work. Checked before spawning
335
+ // git, because a missing cwd fails at spawn time rather than as an exit
336
+ // code, and "no tree" is not a salvage failure worth alarming anyone with.
337
+ if (!existsSync(worktree)) return { kind: "nothing" };
338
+
339
+ if ((await git(["status", "--porcelain"], worktree)) === "") return { kind: "nothing" };
340
+
341
+ // The tree's own branch, not one the caller believes it should be on: this
342
+ // string ends up in an escalation as the place to go looking.
343
+ const branch = await git(["rev-parse", "--abbrev-ref", "HEAD"], worktree);
344
+
345
+ // `-A` on purpose: the losses this exists for were mostly *new* files.
346
+ await git(["add", "-A"], worktree);
347
+ await git(
348
+ [
349
+ ...SALVAGE_COMMIT_CONFIG,
350
+ "commit",
351
+ "--no-verify",
352
+ "-m",
353
+ `wip(#${issue}): attempt ${attempt} killed by ${reason} — auto-salvaged`,
354
+ ],
355
+ worktree,
356
+ );
357
+ const sha = await git(["rev-parse", "HEAD"], worktree);
358
+
359
+ if (branch === "HEAD") {
360
+ // Detached: the commit is real but reachable only by sha, and pushing
361
+ // `HEAD` from here would publish a branch literally named HEAD.
362
+ return {
363
+ kind: "salvaged",
364
+ sha,
365
+ branch,
366
+ pushed: false,
367
+ pushError: "detached HEAD — no branch to push",
368
+ };
369
+ }
370
+
371
+ const push = await runGit(["push", "origin", `HEAD:refs/heads/${branch}`], worktree);
372
+ if (push.code === 0) return { kind: "salvaged", sha, branch, pushed: true };
373
+
374
+ return {
375
+ kind: "salvaged",
376
+ sha,
377
+ branch,
378
+ pushed: false,
379
+ pushError: (
380
+ push.stderr.trim() ||
381
+ push.stdout.trim() ||
382
+ `git push exited ${push.code}`
383
+ ).replace(URL_USERINFO, "$1***@"),
384
+ };
385
+ } catch (err) {
386
+ return { kind: "failed", error: err instanceof Error ? err.message : String(err) };
387
+ }
388
+ }
389
+
271
390
  /**
272
391
  * Removes a run's worktree and its registration in the mirror. Idempotent: a
273
392
  * path that is already gone resolves, so cleanup can be retried and can run on