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.
- package/README.md +74 -32
- package/package.json +1 -1
- package/src/approval-surface.ts +253 -0
- package/src/board.ts +6 -1
- package/src/briefs/orchestrator.md +8 -0
- package/src/cli.ts +9 -2
- package/src/daemon.ts +258 -70
- package/src/fleet.ts +16 -56
- package/src/orchestrator-tick.ts +117 -40
- package/src/store.ts +51 -2
- package/src/types.ts +16 -0
- package/src/unblock.ts +53 -3
- package/src/worktree.ts +26 -12
package/src/orchestrator-tick.ts
CHANGED
|
@@ -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
|
-
*
|
|
345
|
-
|
|
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,
|
|
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,
|
|
926
|
-
* tempt a future reader into treating one of them
|
|
927
|
-
* are: each one means a tier-2 escalation lands
|
|
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
|
-
*
|
|
930
|
-
*
|
|
931
|
-
*
|
|
932
|
-
*
|
|
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,
|
|
1119
|
-
//
|
|
1120
|
-
//
|
|
1121
|
-
//
|
|
1122
|
-
//
|
|
1123
|
-
//
|
|
1124
|
-
|
|
1150
|
+
// The floor's approval primitive, decided from configuration rather than from
|
|
1151
|
+
// the live mounted set — and 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
|
-
|
|
1134
|
-
|
|
1135
|
-
|
|
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,
|
|
332
|
-
|
|
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
|
-
|
|
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
|
-
|
|
430
|
+
ending: string,
|
|
425
431
|
files: string[],
|
|
426
432
|
newPaths: string[],
|
|
427
433
|
): { subject: string; body: string } {
|
|
428
|
-
const subject = `wip(#${issue}): attempt ${attempt}
|
|
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
|
|
463
|
-
*
|
|
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
|
|
471
|
-
*
|
|
472
|
-
*
|
|
473
|
-
*
|
|
474
|
-
*
|
|
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
|
|
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
|
-
|
|
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,
|
|
549
|
+
const msg = salvageCommitMessage(issue, attempt, ending, files, newPaths);
|
|
536
550
|
await git(
|
|
537
551
|
[
|
|
538
552
|
...SALVAGE_COMMIT_CONFIG,
|