omp-conductor 0.3.7 → 0.3.11
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/README.md +71 -24
- package/package.json +1 -1
- package/src/briefs/orchestrator.md +16 -7
- package/src/briefs/worker.md +1 -1
- package/src/cli.ts +28 -14
- package/src/config.ts +16 -5
- package/src/daemon.ts +147 -48
- package/src/escalate.ts +26 -4
- package/src/lifecycle.ts +203 -12
- package/src/omp.ts +5 -0
- package/src/orchestrator-tick.ts +42 -2
- package/src/plugin.ts +32 -2
- package/src/setup.ts +3 -1
- package/src/types.ts +5 -2
- package/src/worker.ts +37 -5
- package/src/worktree.ts +109 -12
package/src/orchestrator-tick.ts
CHANGED
|
@@ -94,6 +94,14 @@ const RETRY_OWNERSHIP_MS = 60_000;
|
|
|
94
94
|
export const STALL_MARKER_FILE = ".conductor-stalled";
|
|
95
95
|
export const STALL_TICKS = 2;
|
|
96
96
|
|
|
97
|
+
/**
|
|
98
|
+
* Written by herdr-conductor `recover.sh` *before* `agent start`, so a resumed
|
|
99
|
+
* fleet can reconcile orphans without waiting a full `intervalSeconds`. Cleared
|
|
100
|
+
* only after a tick is actually sent — a disarmed or channel-down fleet keeps
|
|
101
|
+
* the request until gates pass (or a human removes the file).
|
|
102
|
+
*/
|
|
103
|
+
export const TICK_REQUESTED_FILE = ".conductor-tick-requested";
|
|
104
|
+
|
|
97
105
|
/**
|
|
98
106
|
* The marker's one line after its ISO timestamp, and the middle of the error
|
|
99
107
|
* log. Shared so the file and the log can never describe different failures.
|
|
@@ -834,6 +842,23 @@ function clearStallMarker(pi: TickApi, cwd: string): void {
|
|
|
834
842
|
}
|
|
835
843
|
}
|
|
836
844
|
|
|
845
|
+
/**
|
|
846
|
+
* Best-effort, same posture as {@link clearStallMarker}. Leaving the file on a
|
|
847
|
+
* failed unlink means the next successful send retries the clear; that is
|
|
848
|
+
* preferable to treating a recover poke as fire-and-forget when the tick did
|
|
849
|
+
* land.
|
|
850
|
+
*/
|
|
851
|
+
function clearTickRequest(pi: TickApi, cwd: string): void {
|
|
852
|
+
const path = join(cwd, TICK_REQUESTED_FILE);
|
|
853
|
+
if (!existsSync(path)) return;
|
|
854
|
+
try {
|
|
855
|
+
rmSync(path, { force: true });
|
|
856
|
+
pi.logger.info("[omp-conductor] recover tick request cleared: a tick was sent");
|
|
857
|
+
} catch (err) {
|
|
858
|
+
pi.logger.error(`[omp-conductor] could not remove ${path}: ${err instanceof Error ? err.message : String(err)}`);
|
|
859
|
+
}
|
|
860
|
+
}
|
|
861
|
+
|
|
837
862
|
/** Everything one tick remembers for the next. */
|
|
838
863
|
interface TickSession {
|
|
839
864
|
/**
|
|
@@ -900,6 +925,21 @@ function tick(pi: TickApi, ctx: TickContext, config: TickConfig, session: TickSe
|
|
|
900
925
|
// this is the only place either the counter or the marker is cleared.
|
|
901
926
|
session.pendingSkips = 0;
|
|
902
927
|
clearStallMarker(pi, ctx.cwd);
|
|
928
|
+
// Recover poke is consumed only on a real send — gates still apply above.
|
|
929
|
+
clearTickRequest(pi, ctx.cwd);
|
|
930
|
+
}
|
|
931
|
+
|
|
932
|
+
/**
|
|
933
|
+
* Arm the interval heartbeat, then honour a recover poke if one is waiting.
|
|
934
|
+
* Extracted so the ownership-retry path and the immediate-accept path cannot
|
|
935
|
+
* drift: both must fire the same "do not wait a full interval after resume"
|
|
936
|
+
* behaviour.
|
|
937
|
+
*/
|
|
938
|
+
function armTickHeartbeat(pi: TickApi, ctx: TickContext, config: TickConfig, session: TickSession): void {
|
|
939
|
+
ctx.setInterval(() => tick(pi, ctx, config, session), config.intervalSeconds * 1000);
|
|
940
|
+
if (!existsSync(join(ctx.cwd, TICK_REQUESTED_FILE))) return;
|
|
941
|
+
pi.logger.info("[omp-conductor] tick requested by recover — firing without waiting for the interval");
|
|
942
|
+
tick(pi, ctx, config, session);
|
|
903
943
|
}
|
|
904
944
|
|
|
905
945
|
export default function orchestratorTickExtension(pi: TickApi): void {
|
|
@@ -995,7 +1035,7 @@ export default function orchestratorTickExtension(pi: TickApi): void {
|
|
|
995
1035
|
return;
|
|
996
1036
|
}
|
|
997
1037
|
if (next.note !== undefined) pi.logger.error(`[omp-conductor] tick ownership: ${next.note}`);
|
|
998
|
-
|
|
1038
|
+
armTickHeartbeat(pi, ctx, config, session);
|
|
999
1039
|
pi.logger.info(`[omp-conductor] orchestrator tick active: ownership resolved on retry`, { agentName });
|
|
1000
1040
|
}, retryMs);
|
|
1001
1041
|
decided = true;
|
|
@@ -1008,7 +1048,7 @@ export default function orchestratorTickExtension(pi: TickApi): void {
|
|
|
1008
1048
|
return;
|
|
1009
1049
|
}
|
|
1010
1050
|
if (ownership.note !== undefined) pi.logger.error(`[omp-conductor] tick ownership: ${ownership.note}`);
|
|
1011
|
-
|
|
1051
|
+
armTickHeartbeat(pi, ctx, config, session);
|
|
1012
1052
|
decided = true;
|
|
1013
1053
|
// Both gates are named at startup: "why is it not ticking?" is answered by
|
|
1014
1054
|
// looking at the files this line lists, and an unset channel gate on a fleet
|
package/src/plugin.ts
CHANGED
|
@@ -193,6 +193,30 @@ async function askNumber(ctx: CommandContext, title: string, fallback: number):
|
|
|
193
193
|
return value;
|
|
194
194
|
}
|
|
195
195
|
|
|
196
|
+
/**
|
|
197
|
+
* Daily spend ceiling. Blank / "none" / "off" → null (no gate). Unparseable
|
|
198
|
+
* non-empty input keeps the current value. Distinct from askNumber so operators
|
|
199
|
+
* can turn the money brake off without writing a magic 0 (which is a hard stop).
|
|
200
|
+
*/
|
|
201
|
+
async function askSpendCap(
|
|
202
|
+
ctx: CommandContext,
|
|
203
|
+
title: string,
|
|
204
|
+
fallback: number | null,
|
|
205
|
+
): Promise<number | null> {
|
|
206
|
+
const seed = fallback === null ? "" : String(fallback);
|
|
207
|
+
const raw = (await ask(ctx, title, seed)).trim().toLowerCase();
|
|
208
|
+
if (raw === "" || raw === "none" || raw === "off" || raw === "null") return null;
|
|
209
|
+
const value = Number(raw);
|
|
210
|
+
if (!Number.isFinite(value) || value < 0) {
|
|
211
|
+
ctx.ui.notify(
|
|
212
|
+
`"${raw}" is not a non-negative number or blank — keeping ${fallback === null ? "no cap" : fallback}.`,
|
|
213
|
+
"warning",
|
|
214
|
+
);
|
|
215
|
+
return fallback;
|
|
216
|
+
}
|
|
217
|
+
return value;
|
|
218
|
+
}
|
|
219
|
+
|
|
196
220
|
/**
|
|
197
221
|
* Pre-push gates as one comma-separated line, `cmd @ cwd` for a subdirectory:
|
|
198
222
|
* `bun run check, bun test @ server`. Shown through `formatGates`, the same
|
|
@@ -500,10 +524,12 @@ const askGraph: AreaAsker = async (ctx, a) => {
|
|
|
500
524
|
* answer for anyone who has not measured their own runners. */
|
|
501
525
|
const askCaps: AreaAsker = async (ctx, a) => {
|
|
502
526
|
const caps: Partial<Caps> = { ...a.caps };
|
|
527
|
+
const spendLabel =
|
|
528
|
+
DEFAULT_CAPS.dailySpendUsd === null ? "no spend cap" : `$${DEFAULT_CAPS.dailySpendUsd}/day`;
|
|
503
529
|
const tuneCaps = await ctx.ui.confirm(
|
|
504
530
|
"Caps",
|
|
505
531
|
`Defaults: ${DEFAULT_CAPS.maxConcurrentWorkers} workers, ` +
|
|
506
|
-
|
|
532
|
+
`${spendLabel}, ${DEFAULT_CAPS.workerMaxTurns} turns and ` +
|
|
507
533
|
`${Math.round(DEFAULT_CAPS.workerWallClockMs / 60000)} min per worker, ` +
|
|
508
534
|
`${DEFAULT_CAPS.maxAttemptsPerIssue} attempts per issue. Change them?`,
|
|
509
535
|
);
|
|
@@ -516,7 +542,11 @@ const askCaps: AreaAsker = async (ctx, a) => {
|
|
|
516
542
|
"Max concurrent workers",
|
|
517
543
|
caps.maxConcurrentWorkers ?? DEFAULT_CAPS.maxConcurrentWorkers,
|
|
518
544
|
);
|
|
519
|
-
caps.dailySpendUsd = await
|
|
545
|
+
caps.dailySpendUsd = await askSpendCap(
|
|
546
|
+
ctx,
|
|
547
|
+
"Spend ceiling per rolling day (USD) — blank = no spend cap",
|
|
548
|
+
caps.dailySpendUsd !== undefined ? caps.dailySpendUsd : DEFAULT_CAPS.dailySpendUsd,
|
|
549
|
+
);
|
|
520
550
|
caps.workerMaxTurns = await askNumber(ctx, "Turn ceiling per worker", caps.workerMaxTurns ?? DEFAULT_CAPS.workerMaxTurns);
|
|
521
551
|
caps.workerWallClockMs = await askNumber(
|
|
522
552
|
ctx,
|
package/src/setup.ts
CHANGED
|
@@ -954,9 +954,11 @@ export const AMEND_AREAS: {
|
|
|
954
954
|
describe: (p) => {
|
|
955
955
|
const c = resolveCaps(p, DEFAULT_CAPS);
|
|
956
956
|
const answered = Object.keys(p.caps).length > 0;
|
|
957
|
+
const spend =
|
|
958
|
+
c.dailySpendUsd === null ? "no spend cap" : `$${c.dailySpendUsd}/day`;
|
|
957
959
|
return (
|
|
958
960
|
`${c.maxConcurrentWorkers} workers, ${c.workerMaxTurns} turns, ` +
|
|
959
|
-
`${Math.round(c.workerWallClockMs / 60000)}m,
|
|
961
|
+
`${Math.round(c.workerWallClockMs / 60000)}m, ${spend}, ` +
|
|
960
962
|
`${c.maxAttemptsPerIssue} attempts${answered ? "" : " (all defaults)"} — ` +
|
|
961
963
|
`${p.workerModel === undefined ? "harness default model" : `model ${p.workerModel}`}`
|
|
962
964
|
);
|
package/src/types.ts
CHANGED
|
@@ -16,8 +16,11 @@ export interface Caps {
|
|
|
16
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
|
-
/**
|
|
20
|
-
|
|
19
|
+
/**
|
|
20
|
+
* Rolling-day spend ceiling. `null` means no spend gate (turns + wall-clock
|
|
21
|
+
* still apply). `0` is a hard stop — deliberate, not "unset".
|
|
22
|
+
*/
|
|
23
|
+
dailySpendUsd: number | null;
|
|
21
24
|
/** Turn ceiling for one worker — catches loops that are burning tokens
|
|
22
25
|
* without converging. */
|
|
23
26
|
workerMaxTurns: number;
|
package/src/worker.ts
CHANGED
|
@@ -216,15 +216,22 @@ export async function runWorker(
|
|
|
216
216
|
// is its report, whether it finished cleanly or was cut off.
|
|
217
217
|
const text = reportText(field(message, "content"));
|
|
218
218
|
if (text !== "") report = text;
|
|
219
|
+
|
|
220
|
+
// Real cost lives on assistant messages as `usage.cost.total` (live hermes
|
|
221
|
+
// transcripts, 2026-08-07). The earlier agent_end.telemetry path never
|
|
222
|
+
// fired, so every run recorded $0 and the daily cap was theater (#46).
|
|
223
|
+
const cost = costUsdFromMessage(message);
|
|
224
|
+
if (cost !== undefined) spendUsd += cost;
|
|
219
225
|
});
|
|
220
226
|
|
|
221
227
|
session.on("agent_end", (event) => {
|
|
222
|
-
//
|
|
223
|
-
// spend can legitimately read 0 and the daily-spend cap then leans on the
|
|
224
|
-
// turn and wall-clock ceilings. Upgrade path: pass a telemetry config
|
|
225
|
-
// through `createSession` once the harness exposes it on the SDK options.
|
|
228
|
+
// Fallback for harnesses that only attach cost on the terminal event.
|
|
226
229
|
const estimated = field(field(field(event, "telemetry"), "cost"), "estimatedUsd");
|
|
227
|
-
if (typeof estimated === "number" && Number.isFinite(estimated)
|
|
230
|
+
if (typeof estimated === "number" && Number.isFinite(estimated) && estimated > 0) {
|
|
231
|
+
// Prefer message totals when both exist — do not double-count a run that
|
|
232
|
+
// already accumulated per-message costs.
|
|
233
|
+
if (spendUsd === 0) spendUsd += estimated;
|
|
234
|
+
}
|
|
228
235
|
|
|
229
236
|
// Anything that is not literally `false` — including garbage or nothing at
|
|
230
237
|
// all — is a finished run.
|
|
@@ -282,6 +289,31 @@ export async function runWorker(
|
|
|
282
289
|
);
|
|
283
290
|
}
|
|
284
291
|
|
|
292
|
+
/**
|
|
293
|
+
* USD cost from one assistant message's `usage.cost` block.
|
|
294
|
+
*
|
|
295
|
+
* Prefer `total` when present; otherwise sum the component fields the live
|
|
296
|
+
* harness emits (input/output/cacheRead/cacheWrite). Exported so a unit test
|
|
297
|
+
* can pin the shape without standing up a session.
|
|
298
|
+
*/
|
|
299
|
+
export function costUsdFromMessage(message: unknown): number | undefined {
|
|
300
|
+
const usage = field(message, "usage");
|
|
301
|
+
const cost = field(usage, "cost");
|
|
302
|
+
if (cost === null || typeof cost !== "object") return undefined;
|
|
303
|
+
const total = field(cost, "total");
|
|
304
|
+
if (typeof total === "number" && Number.isFinite(total) && total >= 0) return total;
|
|
305
|
+
let sum = 0;
|
|
306
|
+
let any = false;
|
|
307
|
+
for (const key of ["input", "output", "cacheRead", "cacheWrite"] as const) {
|
|
308
|
+
const v = field(cost, key);
|
|
309
|
+
if (typeof v === "number" && Number.isFinite(v) && v >= 0) {
|
|
310
|
+
sum += v;
|
|
311
|
+
any = true;
|
|
312
|
+
}
|
|
313
|
+
}
|
|
314
|
+
return any ? sum : undefined;
|
|
315
|
+
}
|
|
316
|
+
|
|
285
317
|
/**
|
|
286
318
|
* Read one property off an unvalidated harness event. The event union lives in
|
|
287
319
|
* the peer dependency, so the worker narrows the handful of fields it reads
|
package/src/worktree.ts
CHANGED
|
@@ -194,6 +194,40 @@ async function configureMirror(mirrorPath: string): Promise<void> {
|
|
|
194
194
|
writeFileSync(exclude, mergeExclude(existsSync(exclude) ? readFileSync(exclude, "utf8") : ""));
|
|
195
195
|
}
|
|
196
196
|
|
|
197
|
+
/**
|
|
198
|
+
* Rewrites this worktree's common `info/exclude` managed block to the package's
|
|
199
|
+
* current list. Salvage must do this itself: it is the path that runs after a
|
|
200
|
+
* package swap, when the mirror may still carry a previous release's patterns,
|
|
201
|
+
* and `git add -A` would otherwise silently skip legitimate new files the old
|
|
202
|
+
* list happened to name (dogfood 2026-08-07 / #44).
|
|
203
|
+
*
|
|
204
|
+
* Failures are swallowed — a missing common dir is "no exclude to heal", and
|
|
205
|
+
* the salvage dirty check still runs. Never throws into the salvage outcome.
|
|
206
|
+
*/
|
|
207
|
+
function refreshManagedExclude(worktree: string): void {
|
|
208
|
+
try {
|
|
209
|
+
// `--git-common-dir` is relative for linked worktrees; resolve against the
|
|
210
|
+
// tree so bare-mirror layouts and plain clones both land on info/exclude.
|
|
211
|
+
const common = Bun.spawnSync(["git", "rev-parse", "--git-common-dir"], {
|
|
212
|
+
cwd: worktree,
|
|
213
|
+
stdin: "ignore",
|
|
214
|
+
stdout: "pipe",
|
|
215
|
+
stderr: "pipe",
|
|
216
|
+
env: { ...process.env, GIT_TERMINAL_PROMPT: "0" },
|
|
217
|
+
});
|
|
218
|
+
if (common.exitCode !== 0) return;
|
|
219
|
+
const raw = common.stdout.toString().trim();
|
|
220
|
+
if (raw === "") return;
|
|
221
|
+
const commonDir = raw.startsWith("/") ? raw : join(worktree, raw);
|
|
222
|
+
const exclude = join(commonDir, "info", "exclude");
|
|
223
|
+
mkdirSync(dirname(exclude), { recursive: true });
|
|
224
|
+
writeFileSync(exclude, mergeExclude(existsSync(exclude) ? readFileSync(exclude, "utf8") : ""));
|
|
225
|
+
} catch {
|
|
226
|
+
// ponytail: exclude heal is best-effort; salvage still prefers a commit of
|
|
227
|
+
// whatever git can see over failing the whole orphan path.
|
|
228
|
+
}
|
|
229
|
+
}
|
|
230
|
+
|
|
197
231
|
/**
|
|
198
232
|
* Returns the path of the bare mirror for `repo`, cloning it on first use and
|
|
199
233
|
* refreshing it otherwise.
|
|
@@ -251,7 +285,7 @@ export async function addWorktree(
|
|
|
251
285
|
workspaceRoot: string,
|
|
252
286
|
issue: number,
|
|
253
287
|
branch: string,
|
|
254
|
-
): Promise<string> {
|
|
288
|
+
): Promise<{ path: string; reattached: boolean }> {
|
|
255
289
|
const mirrorPath = await ensureMirror(repo, mirrorRoot);
|
|
256
290
|
mkdirSync(workspaceRoot, { recursive: true });
|
|
257
291
|
|
|
@@ -335,7 +369,7 @@ export async function addWorktree(
|
|
|
335
369
|
}
|
|
336
370
|
}
|
|
337
371
|
|
|
338
|
-
return worktreePath;
|
|
372
|
+
return { path: worktreePath, reattached: branchExists };
|
|
339
373
|
}
|
|
340
374
|
|
|
341
375
|
/**
|
|
@@ -345,7 +379,17 @@ export async function addWorktree(
|
|
|
345
379
|
* none of that may be skipped by an exception from the last-ditch step.
|
|
346
380
|
*/
|
|
347
381
|
export type SalvageOutcome =
|
|
348
|
-
| {
|
|
382
|
+
| {
|
|
383
|
+
kind: "salvaged";
|
|
384
|
+
sha: string;
|
|
385
|
+
branch: string;
|
|
386
|
+
pushed: boolean;
|
|
387
|
+
pushError?: string;
|
|
388
|
+
/** Paths in the salvage commit (count = length). */
|
|
389
|
+
files: string[];
|
|
390
|
+
/** Subset that were untracked before salvage (`A` in the commit). */
|
|
391
|
+
newPaths: string[];
|
|
392
|
+
}
|
|
349
393
|
/** Nothing uncommitted was there to save — a clean tree, or no tree at all. */
|
|
350
394
|
| { kind: "nothing" }
|
|
351
395
|
/** There was work and git would not commit it. This is the loud one. */
|
|
@@ -369,6 +413,51 @@ const SALVAGE_COMMIT_CONFIG = [
|
|
|
369
413
|
"commit.gpgsign=false",
|
|
370
414
|
];
|
|
371
415
|
|
|
416
|
+
/**
|
|
417
|
+
* Subject stays the historical one-liner so status greps keep working; the
|
|
418
|
+
* body is the manifest (#38). Cap the new-path list so a runaway tree cannot
|
|
419
|
+
* push a multi-kilobyte commit message into every escalation.
|
|
420
|
+
*/
|
|
421
|
+
export function salvageCommitMessage(
|
|
422
|
+
issue: number,
|
|
423
|
+
attempt: number,
|
|
424
|
+
reason: string,
|
|
425
|
+
files: string[],
|
|
426
|
+
newPaths: string[],
|
|
427
|
+
): { subject: string; body: string } {
|
|
428
|
+
const subject = `wip(#${issue}): attempt ${attempt} killed by ${reason} — auto-salvaged`;
|
|
429
|
+
const n = files.length;
|
|
430
|
+
const count = `${n} file${n === 1 ? "" : "s"}`;
|
|
431
|
+
if (newPaths.length === 0) {
|
|
432
|
+
return { subject, body: `${count} (all modifications to tracked paths).` };
|
|
433
|
+
}
|
|
434
|
+
const cap = 30;
|
|
435
|
+
const shown = newPaths.slice(0, cap);
|
|
436
|
+
const more = newPaths.length - shown.length;
|
|
437
|
+
const list = shown.map((f) => ` ${f}`).join("\n");
|
|
438
|
+
const suffix = more > 0 ? `\n … and ${more} more` : "";
|
|
439
|
+
return {
|
|
440
|
+
subject,
|
|
441
|
+
body: `${count}. New (untracked before salvage):\n${list}${suffix}`,
|
|
442
|
+
};
|
|
443
|
+
}
|
|
444
|
+
|
|
445
|
+
function parseCachedNameStatus(raw: string): { files: string[]; newPaths: string[] } {
|
|
446
|
+
const files: string[] = [];
|
|
447
|
+
const newPaths: string[] = [];
|
|
448
|
+
for (const line of raw.split("\n")) {
|
|
449
|
+
if (line === "") continue;
|
|
450
|
+
// name-status: "<status>\t<path>" or rename "R100\t<old>\t<new>"
|
|
451
|
+
const parts = line.split("\t");
|
|
452
|
+
const status = parts[0] ?? "";
|
|
453
|
+
const path = parts.length >= 3 ? (parts[2] ?? parts[1] ?? "") : (parts[1] ?? "");
|
|
454
|
+
if (path === "") continue;
|
|
455
|
+
files.push(path);
|
|
456
|
+
if (status.startsWith("A")) newPaths.push(path);
|
|
457
|
+
}
|
|
458
|
+
return { files, newPaths };
|
|
459
|
+
}
|
|
460
|
+
|
|
372
461
|
/**
|
|
373
462
|
* Commits a dead run's uncommitted work to the run's own branch and pushes it,
|
|
374
463
|
* so that the tree the next attempt destroys is no longer the only copy.
|
|
@@ -406,6 +495,12 @@ export async function salvageWip(
|
|
|
406
495
|
// code, and "no tree" is not a salvage failure worth alarming anyone with.
|
|
407
496
|
if (!existsSync(worktree)) return { kind: "nothing" };
|
|
408
497
|
|
|
498
|
+
// Heal the managed ignore *before* status/add. A mirror left by an older
|
|
499
|
+
// build may still list patterns this release dropped; without this, an
|
|
500
|
+
// untracked deliverable matching the stale list is invisible to salvage
|
|
501
|
+
// and dies with the next `worktree remove --force` (#44).
|
|
502
|
+
refreshManagedExclude(worktree);
|
|
503
|
+
|
|
409
504
|
if ((await git(["status", "--porcelain"], worktree)) === "") return { kind: "nothing" };
|
|
410
505
|
|
|
411
506
|
// The tree's own branch, not one the caller believes it should be on: this
|
|
@@ -432,40 +527,42 @@ export async function salvageWip(
|
|
|
432
527
|
// only changes were ignored scratch had work by that test and none by this.
|
|
433
528
|
// Without this, `commit` exits non-zero on an empty index and a tree
|
|
434
529
|
// holding nothing worth keeping gets reported as a salvage *failure*.
|
|
435
|
-
|
|
530
|
+
const cached = await git(["diff", "--cached", "--name-status"], worktree);
|
|
531
|
+
if (cached === "") {
|
|
436
532
|
return { kind: "nothing" };
|
|
437
533
|
}
|
|
534
|
+
const { files, newPaths } = parseCachedNameStatus(cached);
|
|
535
|
+
const msg = salvageCommitMessage(issue, attempt, reason, files, newPaths);
|
|
438
536
|
await git(
|
|
439
537
|
[
|
|
440
538
|
...SALVAGE_COMMIT_CONFIG,
|
|
441
539
|
"commit",
|
|
442
540
|
"--no-verify",
|
|
443
541
|
"-m",
|
|
444
|
-
|
|
542
|
+
msg.subject,
|
|
543
|
+
"-m",
|
|
544
|
+
msg.body,
|
|
445
545
|
],
|
|
446
546
|
worktree,
|
|
447
547
|
);
|
|
448
548
|
const sha = await git(["rev-parse", "HEAD"], worktree);
|
|
549
|
+
const salvaged = { kind: "salvaged" as const, sha, branch, files, newPaths };
|
|
449
550
|
|
|
450
551
|
if (branch === "HEAD") {
|
|
451
552
|
// Detached: the commit is real but reachable only by sha, and pushing
|
|
452
553
|
// `HEAD` from here would publish a branch literally named HEAD.
|
|
453
554
|
return {
|
|
454
|
-
|
|
455
|
-
sha,
|
|
456
|
-
branch,
|
|
555
|
+
...salvaged,
|
|
457
556
|
pushed: false,
|
|
458
557
|
pushError: "detached HEAD — no branch to push",
|
|
459
558
|
};
|
|
460
559
|
}
|
|
461
560
|
|
|
462
561
|
const push = await runGit(["push", "origin", `HEAD:refs/heads/${branch}`], worktree);
|
|
463
|
-
if (push.code === 0) return {
|
|
562
|
+
if (push.code === 0) return { ...salvaged, pushed: true };
|
|
464
563
|
|
|
465
564
|
return {
|
|
466
|
-
|
|
467
|
-
sha,
|
|
468
|
-
branch,
|
|
565
|
+
...salvaged,
|
|
469
566
|
pushed: false,
|
|
470
567
|
pushError: (
|
|
471
568
|
push.stderr.trim() ||
|