omp-conductor 0.2.2 → 0.3.2

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/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