omp-conductor 0.18.0 → 0.18.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/README.md +35 -1
- package/REFERENCE.md +61 -11
- package/agents/to-spec.md +94 -0
- package/package.json +2 -1
- package/schema/config.schema.json +35 -1
- package/src/admission.ts +204 -75
- package/src/arm-challenge.ts +250 -57
- package/src/ask.ts +268 -7
- package/src/board.ts +17 -3
- package/src/briefs/orchestrator.md +62 -21
- package/src/briefs/to-spec.md +88 -0
- package/src/briefs/worker.md +2 -1
- package/src/cli.ts +124 -1
- package/src/command-help.ts +11 -0
- package/src/command-manifest.ts +38 -5
- package/src/commands/arm.ts +1 -1
- package/src/commands/context.ts +1 -0
- package/src/commands/drain.ts +176 -0
- package/src/commands/extend.ts +6 -10
- package/src/commands/intake.ts +4 -19
- package/src/commands/status.ts +5 -1
- package/src/commands/watch.ts +51 -16
- package/src/commands/worker.ts +9 -10
- package/src/config-schema.ts +43 -6
- package/src/config.ts +65 -9
- package/src/daemon.ts +879 -41
- package/src/dashboard/app.js +4 -1
- package/src/dashboard/server.ts +5 -2
- package/src/decisions.ts +243 -17
- package/src/diff-flags.ts +75 -1
- package/src/doctor.ts +60 -82
- package/src/escalate.ts +31 -14
- package/src/failure-class.ts +28 -2
- package/src/fleet.ts +239 -240
- package/src/gitops.ts +188 -81
- package/src/graph-health.ts +35 -1
- package/src/graph.ts +66 -1
- package/src/harness-loader.ts +59 -0
- package/src/host.ts +242 -2
- package/src/lifecycle.ts +122 -1
- package/src/omp-settings.ts +19 -0
- package/src/omp.ts +183 -21
- package/src/orchestrator-tick.ts +1591 -32
- package/src/orchestrator.ts +12 -0
- package/src/privileged.ts +1 -4
- package/src/release-policy.ts +503 -9
- package/src/session-host.ts +65 -6
- package/src/settlement.ts +69 -17
- package/src/setup-host.ts +1225 -9
- package/src/setup-install.ts +28 -0
- package/src/setup-wizard.ts +154 -3
- package/src/setup.ts +83 -17
- package/src/shell.ts +15 -0
- package/src/status-render.ts +216 -12
- package/src/store.ts +443 -42
- package/src/to-spec.ts +408 -0
- package/src/tracker/github.ts +104 -14
- package/src/types.ts +405 -19
- package/src/upgrade-verify.ts +209 -2
- package/src/upgrade.ts +175 -1
- package/src/verbs/protocol.ts +39 -0
- package/src/verbs/server.ts +765 -56
- package/src/verbs/socket.ts +24 -5
- package/src/worker.ts +12 -2
- package/src/worktree.ts +29 -12
package/src/doctor.ts
CHANGED
|
@@ -46,9 +46,11 @@ import {
|
|
|
46
46
|
dbBackupDirFor,
|
|
47
47
|
findProject,
|
|
48
48
|
loadConfig,
|
|
49
|
+
resolveArmProof,
|
|
49
50
|
resolveCaps,
|
|
50
51
|
stateDir,
|
|
51
52
|
} from "./config.ts";
|
|
53
|
+
import { observeArmChallenge } from "./arm-challenge.ts";
|
|
52
54
|
import { ompSettingsOverlay, sessionRootDir } from "./omp-settings.ts";
|
|
53
55
|
import {
|
|
54
56
|
DEFAULT_HERDR_UNIT,
|
|
@@ -98,7 +100,7 @@ import {
|
|
|
98
100
|
type HerdrAgentList,
|
|
99
101
|
} from "./orchestrator-tick.ts";
|
|
100
102
|
import type { ConductorConfig, ProjectConfig, RepoTarget, RunState } from "./types.ts";
|
|
101
|
-
|
|
103
|
+
import { DEFAULT_ARM_PROOF, type ArmProof } from "./types.ts";
|
|
102
104
|
/**
|
|
103
105
|
* `doctor`'s finding vocabulary. `pass`/`warn`/`fail` — a warning renders the
|
|
104
106
|
* same but never flips the exit code; exit is nonzero iff any finding fails.
|
|
@@ -142,6 +144,10 @@ const INCIDENTS = {
|
|
|
142
144
|
spend:
|
|
143
145
|
"spend telemetry was once absent, so the USD cap never fired — $0.00 spend is not proof of no spend",
|
|
144
146
|
ghauth: "gh auth expired under a live daemon and every tracker call failed silently",
|
|
147
|
+
workerAcl:
|
|
148
|
+
"the #835 incident: a setup host granted the worker's path ACLs before restarting the fleet, an OMP startup chmod'd " +
|
|
149
|
+
"the agent config dir back to 0700 and rewrote the ACL mask, and the next two admitted workers died on EACCES " +
|
|
150
|
+
"before connecting — the named ACL entry was still there, only its effective permissions were gone",
|
|
145
151
|
} as const;
|
|
146
152
|
|
|
147
153
|
// ------------------------------------------------------------------ dependencies
|
|
@@ -249,8 +255,10 @@ export interface DoctorDeps {
|
|
|
249
255
|
/** The topic id an arm challenge would send into for this project —
|
|
250
256
|
* resolveProjectTopicId's pin-or-live-claim; undefined means flat chat. */
|
|
251
257
|
armSendTopic?: (project: ProjectConfig) => number | undefined;
|
|
252
|
-
/** The session
|
|
253
|
-
* project — tick-cwd-derived plus the live claim's own directory.
|
|
258
|
+
/** The session surface the claim-only plumbing verdict judges for this
|
|
259
|
+
* project — tick-cwd-derived plus the live claim's own directory. The
|
|
260
|
+
* challenge proof reads conductor state instead (#614), so this feeds
|
|
261
|
+
* claim-only verdict checks only. */
|
|
254
262
|
armScanDirs?: (project: ProjectConfig) => readonly string[];
|
|
255
263
|
/** Whether a recorded claim or dm-owner pid is live, with omp-telegram's
|
|
256
264
|
* topics.ts semantics (EPERM is dead). */
|
|
@@ -748,6 +756,12 @@ function unresolvableShell(unit: string): boolean {
|
|
|
748
756
|
* state resolved at staging time, so a rendered value doctor cannot reproduce
|
|
749
757
|
* is excluded from both sides rather than reported as a difference — an
|
|
750
758
|
* unresolvable canonical is "cannot compare", never drift (#511).
|
|
759
|
+
*
|
|
760
|
+
* The service `PATH=` line has no such exemption: since #879 it is canonical
|
|
761
|
+
* on both sides (`defaultServiceRuntime` composes it from the resolved fleet
|
|
762
|
+
* binaries plus the standard system directories, never from the rendering
|
|
763
|
+
* process's environment), so a differing installed value is genuine drift and
|
|
764
|
+
* must fail here with the value named.
|
|
751
765
|
*/
|
|
752
766
|
export function unitDrift(installed: string, canonical: string): { differences: string[] } {
|
|
753
767
|
const want = canonical
|
|
@@ -902,6 +916,7 @@ function ownershipProbe(probes: Probes): Finding {
|
|
|
902
916
|
return failFinding("systemd-ownership", summary, fix);
|
|
903
917
|
}
|
|
904
918
|
|
|
919
|
+
|
|
905
920
|
/** IANA timezone in reporting config — an invalid zone silently mis-schedules
|
|
906
921
|
* the availability window and the daily digest (#273). */
|
|
907
922
|
function timezoneProbe(project: ProjectConfig | undefined): Finding {
|
|
@@ -1128,91 +1143,43 @@ function herdrAgentNameProbe(probes: Probes, p: ProjectConfig): Finding {
|
|
|
1128
1143
|
}
|
|
1129
1144
|
|
|
1130
1145
|
/**
|
|
1131
|
-
* #
|
|
1132
|
-
* scans.
|
|
1146
|
+
* #614 check — the state of this project's arming acknowledgement transaction.
|
|
1133
1147
|
*
|
|
1134
|
-
*
|
|
1135
|
-
*
|
|
1136
|
-
*
|
|
1137
|
-
*
|
|
1138
|
-
*
|
|
1139
|
-
*
|
|
1140
|
-
* is
|
|
1141
|
-
*
|
|
1148
|
+
* The challenge proof is conductor-owned state now: the host records a pending
|
|
1149
|
+
* challenge and waits for the acknowledgement the orchestrator's inbound
|
|
1150
|
+
* adapter writes. No transcript location is diagnosed any more — where (or
|
|
1151
|
+
* whether) a session file lives is not part of arming — so what is worth
|
|
1152
|
+
* reporting is a transaction left open: a challenge still pending and whether
|
|
1153
|
+
* it has been acknowledged, or an expired record nothing settled. Every shape
|
|
1154
|
+
* is inert by construction (a reply past expiry, or for a replaced id, is
|
|
1155
|
+
* refused), so this informs; only the stale leftover warns.
|
|
1142
1156
|
*/
|
|
1143
|
-
function
|
|
1144
|
-
const
|
|
1145
|
-
|
|
1146
|
-
|
|
1147
|
-
|
|
1148
|
-
|
|
1149
|
-
|
|
1150
|
-
|
|
1151
|
-
|
|
1157
|
+
function armAckProbe(probes: Probes, p: ProjectConfig): Finding {
|
|
1158
|
+
const now = probes.now();
|
|
1159
|
+
const sighting = observeArmChallenge(p.name);
|
|
1160
|
+
if (sighting === undefined) {
|
|
1161
|
+
return passFinding("arm-ack", `[${p.name}] no pending arm challenge — the acknowledgement handshake is idle`);
|
|
1162
|
+
}
|
|
1163
|
+
const window =
|
|
1164
|
+
sighting.sentAt === undefined || sighting.expiresAt === undefined
|
|
1165
|
+
? "an unreadable record window"
|
|
1166
|
+
: `sent ${new Date(sighting.sentAt).toISOString()}, expires ${new Date(sighting.expiresAt).toISOString()}`;
|
|
1167
|
+
if (sighting.expiresAt !== undefined && now >= sighting.expiresAt) {
|
|
1152
1168
|
return warnFinding(
|
|
1153
|
-
"arm-
|
|
1154
|
-
`[${p.name}]
|
|
1155
|
-
|
|
1156
|
-
? `omp-telegram has no claim registry (threads.json in its state dir) — arm scans the tick-cwd session directory, but doctor cannot tell whether the claim names a session file elsewhere`
|
|
1157
|
-
: `omp-telegram's claim registry is unreadable (${result.problem}) — arm scans the tick-cwd session directory, but doctor cannot tell whether the claim names a session file elsewhere`),
|
|
1158
|
-
`check omp-telegram's claim registry (threads.json in its state dir) is readable and the bridge is running, then re-run doctor`,
|
|
1169
|
+
"arm-ack",
|
|
1170
|
+
`[${p.name}] an arming challenge from ${window} expired without being settled — it is inert (replies past expiry are refused), but it lingers until this project's next arm replaces it`,
|
|
1171
|
+
`no action needed; the next \`arm\` for this project settles it — re-run doctor afterwards to confirm`,
|
|
1159
1172
|
);
|
|
1160
1173
|
}
|
|
1161
|
-
if (
|
|
1174
|
+
if (sighting.acknowledgedAt !== undefined) {
|
|
1162
1175
|
return passFinding(
|
|
1163
|
-
"arm-
|
|
1164
|
-
`[${p.name}]
|
|
1165
|
-
);
|
|
1166
|
-
}
|
|
1167
|
-
const claims = result.claims;
|
|
1168
|
-
const match = resolveProjectClaim(claims, p.name);
|
|
1169
|
-
const scanDir = sessionDirForCwd(tickCwdForProject(p));
|
|
1170
|
-
if (match.kind === "ambiguous") {
|
|
1171
|
-
// Several live claims answer to the project, so the claim's session file
|
|
1172
|
-
// cannot be told from a sibling pane's. Arm scans the tick-cwd directory
|
|
1173
|
-
// either way, but certifying that fallback as the scan is the miss this
|
|
1174
|
-
// probe exists to surface: the resumed transcript may live under another
|
|
1175
|
-
// claim while doctor reads the fallback as the whole story (#626).
|
|
1176
|
-
return warnFinding(
|
|
1177
|
-
"arm-session-dir",
|
|
1178
|
-
`[${p.name}] several live claims answer to this project (topics ${match.claimants.map((c) => c.threadId).join(", ")}), so the claim's session file is a coin toss — arm scans the tick-cwd session directory, but the resumed transcript may live elsewhere`,
|
|
1179
|
-
`make exactly one live claim answer to this project: rename the other panes' herdr spaces or re-claim them one at a time, then re-run doctor — or start the orchestrator pane's session from the tick cwd ${scanDir}`,
|
|
1180
|
-
);
|
|
1181
|
-
}
|
|
1182
|
-
const claim = match.kind === "match" ? match.claim : undefined;
|
|
1183
|
-
if (claim === undefined) {
|
|
1184
|
-
return passFinding(
|
|
1185
|
-
"arm-session-dir",
|
|
1186
|
-
`[${p.name}] no live omp-telegram claim answers to this project — arm scans the tick-cwd session directory`,
|
|
1187
|
-
);
|
|
1188
|
-
}
|
|
1189
|
-
if (claim.sessionFile === undefined) {
|
|
1190
|
-
return passFinding(
|
|
1191
|
-
"arm-session-dir",
|
|
1192
|
-
`[${p.name}] the live claim names no session file — arm scans the tick-cwd session directory`,
|
|
1193
|
-
);
|
|
1194
|
-
}
|
|
1195
|
-
const root = sessionsRoot();
|
|
1196
|
-
const claimDir = dirname(claim.sessionFile);
|
|
1197
|
-
if (claimDir !== root && !claimDir.startsWith(join(root, sep))) {
|
|
1198
|
-
return failFinding(
|
|
1199
|
-
"arm-session-dir",
|
|
1200
|
-
`[${p.name}] the orchestrator's claimed session file ${claim.sessionFile} is outside the session tree arm scans (${root}) — ` +
|
|
1201
|
-
`arm cannot arm while the reply can only land there`,
|
|
1202
|
-
`start the orchestrator pane's session under ${root}, or from the tick cwd ${scanDir}`,
|
|
1203
|
-
);
|
|
1204
|
-
}
|
|
1205
|
-
if (claimDir !== scanDir) {
|
|
1206
|
-
return warnFinding(
|
|
1207
|
-
"arm-session-dir",
|
|
1208
|
-
`[${p.name}] the orchestrator's claimed session file ${claim.sessionFile} lives in ${claimDir}, not the tick-cwd session directory ${scanDir} — ` +
|
|
1209
|
-
`arm follows the claim, so arming works, but this is the resumed-pane shape #600 names`,
|
|
1210
|
-
`optional tidy-up: start the pane's session from the tick cwd so transcript and cwd agree again`,
|
|
1176
|
+
"arm-ack",
|
|
1177
|
+
`[${p.name}] an arm challenge is pending (${window}) and was acknowledged ${new Date(sighting.acknowledgedAt).toISOString()} — the host consumes it on settle`,
|
|
1211
1178
|
);
|
|
1212
1179
|
}
|
|
1213
1180
|
return passFinding(
|
|
1214
|
-
"arm-
|
|
1215
|
-
`[${p.name}]
|
|
1181
|
+
"arm-ack",
|
|
1182
|
+
`[${p.name}] an arm challenge is pending (${window}), not yet acknowledged — live if \`arm\` is running right now, otherwise its window simply runs out`,
|
|
1216
1183
|
);
|
|
1217
1184
|
}
|
|
1218
1185
|
|
|
@@ -1334,9 +1301,20 @@ function telegramPlumbingProbe(probes: Probes, p: ProjectConfig): Finding {
|
|
|
1334
1301
|
`[${p.name}] outbound-only paging — no tick accessFile to arm, so no live claim, DM owner or poll ownership is required`,
|
|
1335
1302
|
);
|
|
1336
1303
|
}
|
|
1304
|
+
// Transcript-surface identity checks belong to the claim-only proof alone:
|
|
1305
|
+
// since #614 the challenge proof reads conductor acknowledgement state, so
|
|
1306
|
+
// judging it by where a transcript lives could report arming impossible
|
|
1307
|
+
// while `arm` succeeds. An unreadable config keeps the default proof —
|
|
1308
|
+
// fail-safe to the stricter verdict, exactly like armTicks.
|
|
1309
|
+
let proof: ArmProof = DEFAULT_ARM_PROOF;
|
|
1310
|
+
try {
|
|
1311
|
+
proof = resolveArmProof(p);
|
|
1312
|
+
} catch {
|
|
1313
|
+
/* unreadable config — keep today's stricter default */
|
|
1314
|
+
}
|
|
1337
1315
|
const verdict = telegramPlumbingVerdict(
|
|
1338
1316
|
probes.armSendTopic(p),
|
|
1339
|
-
{ dirs: probes.armScanDirs(p) },
|
|
1317
|
+
proof === "claim-only" ? { dirs: probes.armScanDirs(p) } : undefined,
|
|
1340
1318
|
{
|
|
1341
1319
|
channel: probes.channelState(p),
|
|
1342
1320
|
registry: probes.claimedTopics(),
|
|
@@ -1618,7 +1596,7 @@ export async function runDoctor(projectName: string | undefined, opts: DoctorDep
|
|
|
1618
1596
|
passFinding("herdr-agent-name", projectProblem === undefined ? "no project resolved — nothing to check" : `agent name uncheckable: ${projectProblem}`),
|
|
1619
1597
|
);
|
|
1620
1598
|
findings.push(
|
|
1621
|
-
passFinding("arm-
|
|
1599
|
+
passFinding("arm-ack", projectProblem === undefined ? "no project resolved — nothing to check" : `arm acknowledgement uncheckable: ${projectProblem}`),
|
|
1622
1600
|
);
|
|
1623
1601
|
findings.push(
|
|
1624
1602
|
passFinding("topic-pin", projectProblem === undefined ? "no project resolved — nothing to check" : `topic pin uncheckable: ${projectProblem}`),
|
|
@@ -1633,7 +1611,7 @@ export async function runDoctor(projectName: string | undefined, opts: DoctorDep
|
|
|
1633
1611
|
for (const p of projects) {
|
|
1634
1612
|
findings.push(await labelProbe(probes, p));
|
|
1635
1613
|
findings.push(herdrAgentNameProbe(probes, p));
|
|
1636
|
-
findings.push(
|
|
1614
|
+
findings.push(armAckProbe(probes, p));
|
|
1637
1615
|
findings.push(topicPinProbe(probes, p));
|
|
1638
1616
|
findings.push(telegramPlumbingProbe(probes, p));
|
|
1639
1617
|
findings.push(ompSettingsProbe(p, sessionRootState));
|
package/src/escalate.ts
CHANGED
|
@@ -403,11 +403,17 @@ function errText(e: unknown): string {
|
|
|
403
403
|
*
|
|
404
404
|
* Exported for the report outbox (#123), which pages over the same bot and must
|
|
405
405
|
* resolve the token the same way. Two readers of one `.env` is fine; two
|
|
406
|
-
* *implementations* of the parse below is how they drift.
|
|
406
|
+
* *implementations* of the parse below is how they drift. The state dir is the
|
|
407
|
+
* shared resolution both the token read and the interactive ask surface (#722)
|
|
408
|
+
* need, so it is one function rather than a second copy of the override.
|
|
407
409
|
*/
|
|
408
|
-
export function
|
|
410
|
+
export function telegramStateDir(): string {
|
|
409
411
|
const override = process.env.OMP_TELEGRAM_STATE_DIR?.trim();
|
|
410
|
-
|
|
412
|
+
return override ? override : join(homedir(), ".omp", "agent", "telegram");
|
|
413
|
+
}
|
|
414
|
+
|
|
415
|
+
export function readTelegramToken(): string | undefined {
|
|
416
|
+
const dir = telegramStateDir();
|
|
411
417
|
let raw: string;
|
|
412
418
|
try {
|
|
413
419
|
raw = readFileSync(join(dir, ".env"), "utf8");
|
|
@@ -1034,13 +1040,14 @@ export interface TelegramPlumbingProbe {
|
|
|
1034
1040
|
}
|
|
1035
1041
|
|
|
1036
1042
|
/**
|
|
1037
|
-
* The
|
|
1038
|
-
*
|
|
1039
|
-
*
|
|
1040
|
-
*
|
|
1041
|
-
*
|
|
1042
|
-
*
|
|
1043
|
-
*
|
|
1043
|
+
* The transcript-surface input for {@link telegramPlumbingVerdict}'s session
|
|
1044
|
+
* identity checks: the directories the claim-only proof judges recipients
|
|
1045
|
+
* against (a claim's, the DM owner's, or the local poll owner's session file
|
|
1046
|
+
* must land inside, or a flat reply could land where no challenge would have
|
|
1047
|
+
* looked). Since #614 the *challenge* proof reads conductor acknowledgement
|
|
1048
|
+
* state and never opens a transcript, so callers judging challenge-mode
|
|
1049
|
+
* plumbing omit this — passing it there could report arming impossible while
|
|
1050
|
+
* `arm` succeeds.
|
|
1044
1051
|
*/
|
|
1045
1052
|
export type TelegramPlumbingScan = { dirs: readonly string[] };
|
|
1046
1053
|
|
|
@@ -1068,7 +1075,7 @@ export type TelegramPlumbingScan = { dirs: readonly string[] };
|
|
|
1068
1075
|
*/
|
|
1069
1076
|
export function telegramPlumbingVerdict(
|
|
1070
1077
|
sendTopic: number | undefined,
|
|
1071
|
-
scan: TelegramPlumbingScan,
|
|
1078
|
+
scan: TelegramPlumbingScan | undefined,
|
|
1072
1079
|
probe: TelegramPlumbingProbe,
|
|
1073
1080
|
): TelegramPlumbingVerdict {
|
|
1074
1081
|
const { registry, poll, dmOwner, channel, alive, lockAlive, fresh } = probe;
|
|
@@ -1079,14 +1086,21 @@ export function telegramPlumbingVerdict(
|
|
|
1079
1086
|
const claim = registry.claims.find((c) => c.threadId === sendTopic);
|
|
1080
1087
|
if (claim === undefined) return { ok: false, reason: "no-topic-claim" };
|
|
1081
1088
|
if (claim.pid === undefined || !alive(claim.pid)) return { ok: false, reason: "claim-dead" };
|
|
1082
|
-
if (
|
|
1089
|
+
if (
|
|
1090
|
+
scan !== undefined &&
|
|
1091
|
+
claim.sessionFile !== undefined &&
|
|
1092
|
+
!sessionWithinScan(claim.sessionFile, scan.dirs)
|
|
1093
|
+
) {
|
|
1083
1094
|
return { ok: false, reason: "claim-session-outside" };
|
|
1084
1095
|
}
|
|
1085
1096
|
recipient = "claim";
|
|
1086
1097
|
} else {
|
|
1087
1098
|
if (dmOwner.kind === "ok") {
|
|
1088
1099
|
if (dmOwner.owner.pid === undefined || !alive(dmOwner.owner.pid)) return { ok: false, reason: "dm-owner-dead" };
|
|
1089
|
-
if (
|
|
1100
|
+
if (
|
|
1101
|
+
scan !== undefined &&
|
|
1102
|
+
(dmOwner.owner.sessionFile === undefined || !sessionWithinScan(dmOwner.owner.sessionFile, scan.dirs))
|
|
1103
|
+
) {
|
|
1090
1104
|
return { ok: false, reason: "dm-owner-unrelated" };
|
|
1091
1105
|
}
|
|
1092
1106
|
recipient = "dm-owner";
|
|
@@ -1096,7 +1110,10 @@ export function telegramPlumbingVerdict(
|
|
|
1096
1110
|
// record must name that session. A daemon-only or unrelated owner is no
|
|
1097
1111
|
// recipient: the reply would land in a transcript arm never scans.
|
|
1098
1112
|
if (poll.kind !== "ok") return { ok: false, reason: "no-dm-owner" };
|
|
1099
|
-
if (
|
|
1113
|
+
if (
|
|
1114
|
+
scan !== undefined &&
|
|
1115
|
+
(poll.owner.sessionFile === undefined || !sessionWithinScan(poll.owner.sessionFile, scan.dirs))
|
|
1116
|
+
) {
|
|
1100
1117
|
return { ok: false, reason: "no-dm-owner" };
|
|
1101
1118
|
}
|
|
1102
1119
|
recipient = "poller";
|
package/src/failure-class.ts
CHANGED
|
@@ -17,9 +17,14 @@
|
|
|
17
17
|
|
|
18
18
|
import type { Caps, FailureClass, RecoveryAction, RunRecord } from "./types.ts";
|
|
19
19
|
|
|
20
|
-
/** Facts the caller fetched, each only for the rows that need it.
|
|
20
|
+
/** Facts the caller fetched, each only for the rows that need it.
|
|
21
|
+
*
|
|
22
|
+
* `pr` can also be `"missing"`: the tracker has proven the claimed PR
|
|
23
|
+
* definitively does not exist (#779). It is a fact, the same way `"closed"`
|
|
24
|
+
* is — a REST 404 the adapter corroborated with a same-repository pulls-list
|
|
25
|
+
* read — never a transient "could not tell", which stays `undefined`. */
|
|
21
26
|
export interface ClassifyFacts {
|
|
22
|
-
pr?: "open" | "merged" | "closed";
|
|
27
|
+
pr?: "open" | "merged" | "closed" | "missing";
|
|
23
28
|
mergeable?: "conflicting" | "clean" | "unknown";
|
|
24
29
|
checks?: { name: string; state: string; link?: string }[];
|
|
25
30
|
/** Full session error recovered from the transcript. Kept as a fact so a
|
|
@@ -142,6 +147,12 @@ const START_FAILURE_SIGNATURES = [
|
|
|
142
147
|
"invalid api key",
|
|
143
148
|
"authentication failed",
|
|
144
149
|
"could not load its peer dependency",
|
|
150
|
+
// The pre-launch identity gate (#798/#828): the account is missing, or the
|
|
151
|
+
// harness binding a worker resolves through is not established. The daemon
|
|
152
|
+
// refuses before it spawns anything, so no attempt was spent — and the
|
|
153
|
+
// symptom this replaced (the child dying on the peer import) already read as
|
|
154
|
+
// a start failure, so charging one here would be a regression dressed as a fix.
|
|
155
|
+
"worker identity unavailable",
|
|
145
156
|
] as const;
|
|
146
157
|
|
|
147
158
|
/**
|
|
@@ -512,6 +523,21 @@ export function classifyRun(
|
|
|
512
523
|
if (run.state === "failed" && facts.pr === "open") {
|
|
513
524
|
const checks = facts.checks ?? [];
|
|
514
525
|
const unresolved = checks.filter((c) => !SUCCESS_CHECK_STATES[normalise(c.state)] === true);
|
|
526
|
+
if (checks.length > 0 && unresolved.length === 0) {
|
|
527
|
+
// The PR is open and every check is green: the strongest mechanical
|
|
528
|
+
// success evidence there is short of merge. The row's `failed` state is
|
|
529
|
+
// stale — whatever the worker recorded, its work is passing — so this is
|
|
530
|
+
// not a failure of any class. `none` recovery restores it to
|
|
531
|
+
// `pushed-green` in classifyAndRecover instead of escalating an
|
|
532
|
+
// `[unknown]` that charges an attempt (#766). An empty check list is
|
|
533
|
+
// "could not tell", not all-green, and still falls through to `unknown`
|
|
534
|
+
// below.
|
|
535
|
+
return {
|
|
536
|
+
cls: "unknown",
|
|
537
|
+
recovery: "none",
|
|
538
|
+
evidence: `${run.prUrl ?? "the PR"} is open and every check is green`,
|
|
539
|
+
};
|
|
540
|
+
}
|
|
515
541
|
if (checks.length > 0 && unresolved.length > 0) {
|
|
516
542
|
// A failed check whose *log* smells like infrastructure — a registry 429,
|
|
517
543
|
// a runner shutdown, a DNS failure (#177). The check has a verdict, so
|