omp-conductor 0.4.2 → 0.4.4
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 +189 -50
- package/package.json +1 -1
- package/src/board.ts +6 -1
- package/src/brief-upgrade.ts +42 -105
- package/src/briefs/orchestrator.md +51 -13
- package/src/briefs/policy.md +6 -2
- package/src/cli.ts +147 -30
- package/src/config.ts +23 -52
- package/src/confinement.ts +22 -472
- package/src/daemon.ts +391 -9
- package/src/decisions.ts +164 -0
- package/src/failure-class.ts +172 -0
- package/src/fleet.ts +192 -51
- package/src/omp.ts +8 -54
- package/src/orchestrator-tick.ts +33 -0
- package/src/plugin.ts +9 -20
- package/src/session-host.ts +10 -22
- package/src/setup.ts +22 -4
- package/src/store.ts +230 -4
- package/src/tracker/github.ts +188 -5
- package/src/types.ts +183 -13
- package/src/upgrade.ts +27 -3
package/src/orchestrator-tick.ts
CHANGED
|
@@ -74,6 +74,8 @@ import {
|
|
|
74
74
|
type ResolvedGrants,
|
|
75
75
|
type Store,
|
|
76
76
|
} from "./types.ts";
|
|
77
|
+
import { formatDecisionDigest } from "./decisions.ts";
|
|
78
|
+
import type { RunRecord } from "./types.ts";
|
|
77
79
|
import { dbPath, openStore } from "./store.ts";
|
|
78
80
|
|
|
79
81
|
/** The activation file. Absent means "this is not an orchestrator session". */
|
|
@@ -333,10 +335,29 @@ export function defaultTickMessage(
|
|
|
333
335
|
* `REPORT_SCOPES` fails to compile here instead of resolving to `undefined` at
|
|
334
336
|
* the point of use.
|
|
335
337
|
*/
|
|
338
|
+
/**
|
|
339
|
+
* One line naming what the daemon recovered without asking (#132).
|
|
340
|
+
*
|
|
341
|
+
* The orchestrator used to write this paragraph by re-deriving it from the run
|
|
342
|
+
* rows every tick. Undefined when nothing was recovered: a line reading "0" is
|
|
343
|
+
* one nobody reads on the day it says 4.
|
|
344
|
+
*/
|
|
345
|
+
export function recoveryDigestLine(recovered: readonly RunRecord[]): string | undefined {
|
|
346
|
+
if (recovered.length === 0) return undefined;
|
|
347
|
+
const named = recovered
|
|
348
|
+
.slice(0, 5)
|
|
349
|
+
.map((r) => `${r.failureClass ?? "unknown"} #${r.issue}`)
|
|
350
|
+
.join(", ");
|
|
351
|
+
const rest = recovered.length > 5 ? `, +${recovered.length - 5} more` : "";
|
|
352
|
+
return `Auto-recovered since last tick: ${recovered.length} (${named}${rest}) — already handled, do not re-triage these.`;
|
|
353
|
+
}
|
|
354
|
+
|
|
336
355
|
export const TICK_SCOPE_CONSTRAINTS: { readonly [K in ReportScope]: string } = {
|
|
337
356
|
material: "Report material events per your brief.",
|
|
338
357
|
escalations:
|
|
339
358
|
"Report NOTHING this turn except a Tier 1 or Tier 2 escalation; everything else -- releases included -- waits for the daily digest.",
|
|
359
|
+
decisions:
|
|
360
|
+
"Reporting scope decisions: interrupt only for a decision you need (tier-2) or a condition that stops the fleet. Every other material event accumulates and ships as ONE message with this tick's report via omp-conductor report -- a merge, a green PR, a pulled issue wait for the tick; nothing between ticks.",
|
|
340
361
|
};
|
|
341
362
|
|
|
342
363
|
/**
|
|
@@ -1154,6 +1175,18 @@ function tick(pi: TickApi, ctx: TickContext, config: TickConfig, session: TickSe
|
|
|
1154
1175
|
now - FRICTION_COOLDOWN_MS,
|
|
1155
1176
|
);
|
|
1156
1177
|
if (frictionSignals.length > 0) content = `${content}\n${formatFrictionDigest(frictionSignals)}`;
|
|
1178
|
+
// What the session still owes its operator, read from the ledger rather
|
|
1179
|
+
// than from what it remembers asking (#136). Appended every tick,
|
|
1180
|
+
// because the whole failure was a question surviving in context only.
|
|
1181
|
+
const decisions = formatDecisionDigest(frictionStore.openDecisions(scope.projectName), now);
|
|
1182
|
+
if (decisions.length > 0) content = `${content}\n${decisions}`;
|
|
1183
|
+
// What the daemon already fixed, so the session stops re-deriving that
|
|
1184
|
+
// paragraph on every tick (#132). Two intervals wide rather than one: a
|
|
1185
|
+
// tick that ran long must not drop the window it was meant to report.
|
|
1186
|
+
const recovered = recoveryDigestLine(
|
|
1187
|
+
frictionStore.recoveredSince(scope.projectName, now - 2 * config.intervalSeconds * 1_000),
|
|
1188
|
+
);
|
|
1189
|
+
if (recovered !== undefined) content = `${content}\n${recovered}`;
|
|
1157
1190
|
} catch (err) {
|
|
1158
1191
|
frictionStore?.close();
|
|
1159
1192
|
frictionStore = undefined;
|
package/src/plugin.ts
CHANGED
|
@@ -13,13 +13,11 @@
|
|
|
13
13
|
import { existsSync, readFileSync } from "node:fs";
|
|
14
14
|
import { dirname, isAbsolute } from "node:path";
|
|
15
15
|
import {
|
|
16
|
-
|
|
17
|
-
formatBriefStatus,
|
|
16
|
+
formatBriefReport,
|
|
18
17
|
formatMigrateResult,
|
|
19
18
|
inspectBriefLayout,
|
|
20
19
|
migrateToPolicy,
|
|
21
20
|
repairPolicyBannerCrumbs,
|
|
22
|
-
writeMergedBrief,
|
|
23
21
|
} from "./brief-upgrade.ts";
|
|
24
22
|
import { configPath, expandHome, findProject, loadConfig, resolveCaps, saveConfig } from "./config.ts";
|
|
25
23
|
import { mechanismSatisfies, probeHost } from "./credentials.ts";
|
|
@@ -1424,11 +1422,7 @@ export default function conductorPlugin(pi: PluginApi): void {
|
|
|
1424
1422
|
}
|
|
1425
1423
|
if (layout.kind === "overlay") {
|
|
1426
1424
|
ctx.ui.notify(
|
|
1427
|
-
|
|
1428
|
-
kind: "overlay",
|
|
1429
|
-
policyPath: layout.policyPath,
|
|
1430
|
-
orchestratorPath: layout.orchestratorPath,
|
|
1431
|
-
}),
|
|
1425
|
+
formatBriefReport(path, layout, []),
|
|
1432
1426
|
"info",
|
|
1433
1427
|
);
|
|
1434
1428
|
const repair = await ctx.ui.confirm(
|
|
@@ -1473,18 +1467,13 @@ export default function conductorPlugin(pi: PluginApi): void {
|
|
|
1473
1467
|
}
|
|
1474
1468
|
break;
|
|
1475
1469
|
}
|
|
1476
|
-
|
|
1477
|
-
|
|
1478
|
-
|
|
1479
|
-
|
|
1480
|
-
|
|
1481
|
-
|
|
1482
|
-
|
|
1483
|
-
if (apply) {
|
|
1484
|
-
const backup = writeMergedBrief(path, status.merged);
|
|
1485
|
-
ctx.ui.notify(`Brief upgraded. Previous version kept at ${backup}.`, "info");
|
|
1486
|
-
}
|
|
1487
|
-
}
|
|
1470
|
+
// Hand-written: the plugin no longer merges single-file briefs
|
|
1471
|
+
// (#131). There is no banner, so nothing here can tell which lines
|
|
1472
|
+
// are the operator's — a retrofit has to name the cut first.
|
|
1473
|
+
ctx.ui.notify(
|
|
1474
|
+
`Hand-written brief at ${path} — run: omp-conductor brief-upgrade --retrofit (then --migrate). The plugin no longer merges single-file briefs.`,
|
|
1475
|
+
"warning",
|
|
1476
|
+
);
|
|
1488
1477
|
break;
|
|
1489
1478
|
}
|
|
1490
1479
|
|
package/src/session-host.ts
CHANGED
|
@@ -23,14 +23,13 @@
|
|
|
23
23
|
import { connect } from "node:net";
|
|
24
24
|
|
|
25
25
|
import { createLocalSession, disposeSession, type AgentSessionLike } from "./omp.ts";
|
|
26
|
-
|
|
26
|
+
|
|
27
27
|
import type { ReleaseShape, ResolvedGrants, SessionRole } from "./types.ts";
|
|
28
28
|
|
|
29
29
|
/**
|
|
30
30
|
* Everything the child needs to build the session. Plain JSON on purpose:
|
|
31
|
-
* callbacks (`onReleaseBlocked
|
|
32
|
-
*
|
|
33
|
-
* {@link HostToParent}.
|
|
31
|
+
* callbacks (`onReleaseBlocked`) cannot cross a process boundary, so they
|
|
32
|
+
* become messages instead — see {@link HostToParent}.
|
|
34
33
|
*/
|
|
35
34
|
export interface SessionHostSpec {
|
|
36
35
|
socket: string;
|
|
@@ -40,19 +39,13 @@ export interface SessionHostSpec {
|
|
|
40
39
|
model?: string;
|
|
41
40
|
resume?: boolean;
|
|
42
41
|
releaseGrants?: ResolvedGrants;
|
|
43
|
-
/**
|
|
44
|
-
* Resolved by the *parent*, never by this process. `orchestratorJailFromConfig`
|
|
45
|
-
* reads the conductor config, and an isolated child cannot — a jail that
|
|
46
|
-
* silently fell back to defaults here would widen the very gate #127 closed.
|
|
47
|
-
*/
|
|
48
|
-
orchestratorJail?: OrchestratorJail;
|
|
49
42
|
/**
|
|
50
43
|
* The conductor verb socket this session may call mutations on (#126).
|
|
51
44
|
*
|
|
52
45
|
* Carried in the spec rather than read from the environment by the child,
|
|
53
|
-
* for the same reason
|
|
54
|
-
* child deciding
|
|
55
|
-
*
|
|
46
|
+
* for the same reason the grants are: the child deciding *which* socket it
|
|
47
|
+
* owns is the child deciding which run it is, and that is the question the
|
|
48
|
+
* transport exists to stop it answering.
|
|
56
49
|
* Absent, the verbs are registered and every one of them fails closed.
|
|
57
50
|
*/
|
|
58
51
|
verbSocketPath?: string;
|
|
@@ -71,8 +64,7 @@ export type HostToParent =
|
|
|
71
64
|
| { t: "event"; event: unknown }
|
|
72
65
|
| { t: "session-file"; path: string }
|
|
73
66
|
| { t: "prompt-result"; id: number; ok: boolean; error?: string }
|
|
74
|
-
| { t: "release-blocked"; shape: ReleaseShape }
|
|
75
|
-
| { t: "confinement-refusal"; refusal: OrchestratorRefusal };
|
|
67
|
+
| { t: "release-blocked"; shape: ReleaseShape };
|
|
76
68
|
|
|
77
69
|
/**
|
|
78
70
|
* Depth at which a harness event stops being copied for the wire.
|
|
@@ -195,17 +187,13 @@ export async function runSessionHost(
|
|
|
195
187
|
...(spec.model === undefined ? {} : { model: spec.model }),
|
|
196
188
|
...(spec.resume === undefined ? {} : { resume: spec.resume }),
|
|
197
189
|
...(spec.releaseGrants === undefined ? {} : { releaseGrants: spec.releaseGrants }),
|
|
198
|
-
...(spec.orchestratorJail === undefined ? {} : { orchestratorJail: spec.orchestratorJail }),
|
|
199
190
|
...(spec.verbSocketPath === undefined ? {} : { verbSocketPath: spec.verbSocketPath }),
|
|
200
|
-
//
|
|
201
|
-
// process may not be able to write and must not be trusted to.
|
|
202
|
-
//
|
|
191
|
+
// The release audit lives in the daemon's state directory, which this
|
|
192
|
+
// process may not be able to write and must not be trusted to. It
|
|
193
|
+
// becomes a message; the parent performs the durable write.
|
|
203
194
|
onReleaseBlocked: (shape) => {
|
|
204
195
|
send({ t: "release-blocked", shape });
|
|
205
196
|
},
|
|
206
|
-
onConfinementRefusal: (refusal) => {
|
|
207
|
-
send({ t: "confinement-refusal", refusal });
|
|
208
|
-
},
|
|
209
197
|
});
|
|
210
198
|
} catch (err) {
|
|
211
199
|
send({ t: "start-error", message: err instanceof Error ? err.message : String(err) });
|
package/src/setup.ts
CHANGED
|
@@ -186,12 +186,18 @@ export const SETUP_DEFAULTS = {
|
|
|
186
186
|
} as const;
|
|
187
187
|
|
|
188
188
|
/**
|
|
189
|
-
* The
|
|
189
|
+
* The three report scopes as the operator meets them, described once. The wizard
|
|
190
190
|
* shows these labels, the plan summary quotes the description, and the rendered
|
|
191
|
-
* brief spells the same
|
|
192
|
-
* thing in the dialog and another in the session that has to honour it.
|
|
191
|
+
* brief spells the same three options out — so "material" cannot come to mean
|
|
192
|
+
* one thing in the dialog and another in the session that has to honour it.
|
|
193
193
|
*/
|
|
194
194
|
export const REPORT_SCOPE_CHOICES: readonly { scope: ReportScope; label: string; description: string }[] = [
|
|
195
|
+
{
|
|
196
|
+
scope: "decisions",
|
|
197
|
+
label: "Decisions interrupt, rest batches",
|
|
198
|
+
description:
|
|
199
|
+
"tier-2 decisions and fleet-stopping conditions immediately; every other material event ships with the tick report",
|
|
200
|
+
},
|
|
195
201
|
{
|
|
196
202
|
scope: "material",
|
|
197
203
|
label: "Material events",
|
|
@@ -204,6 +210,18 @@ export const REPORT_SCOPE_CHOICES: readonly { scope: ReportScope; label: string;
|
|
|
204
210
|
},
|
|
205
211
|
];
|
|
206
212
|
|
|
213
|
+
/**
|
|
214
|
+
* What a *fresh* interview opens on: the first choice above, by construction, so
|
|
215
|
+
* reordering that list moves the wizard's cursor with it.
|
|
216
|
+
*
|
|
217
|
+
* Deliberately not {@link DEFAULT_REPORT_SCOPE}. That one answers "a config on
|
|
218
|
+
* disk with no `reporting` key" and must stay `material` forever, because
|
|
219
|
+
* changing it would turn a running fleet's volume down on upgrade. This one
|
|
220
|
+
* answers "an operator being asked the question for the first time", where the
|
|
221
|
+
* recommended answer is the useful one — and they see it, and confirm it.
|
|
222
|
+
*/
|
|
223
|
+
export const SETUP_DEFAULT_REPORT_SCOPE: ReportScope = REPORT_SCOPE_CHOICES[0]!.scope;
|
|
224
|
+
|
|
207
225
|
/**
|
|
208
226
|
* What each precondition value means to the operator being asked about it, in
|
|
209
227
|
* their words rather than the gate's.
|
|
@@ -625,7 +643,7 @@ export function defaultAnswers(projectName: string): SetupAnswers {
|
|
|
625
643
|
policy: clonePolicy(SETUP_DEFAULTS.policy),
|
|
626
644
|
credentials: { ...SETUP_DEFAULTS.credentials },
|
|
627
645
|
orchestratorMode: SETUP_DEFAULTS.orchestratorMode,
|
|
628
|
-
reportScope:
|
|
646
|
+
reportScope: SETUP_DEFAULT_REPORT_SCOPE,
|
|
629
647
|
writeOrchestratorBrief: false,
|
|
630
648
|
};
|
|
631
649
|
}
|
package/src/store.ts
CHANGED
|
@@ -14,8 +14,12 @@ import { mkdirSync } from "node:fs";
|
|
|
14
14
|
import { dirname, join } from "node:path";
|
|
15
15
|
|
|
16
16
|
import { stateDir } from "./config.ts";
|
|
17
|
-
import { DEFAULT_CAPS } from "./types.ts";
|
|
17
|
+
import { DECISION_TTL_MS, DEFAULT_CAPS } from "./types.ts";
|
|
18
18
|
import type {
|
|
19
|
+
DecisionDraft,
|
|
20
|
+
FailureClass,
|
|
21
|
+
DecisionRecord,
|
|
22
|
+
DecisionState,
|
|
19
23
|
DispatchSummary,
|
|
20
24
|
FrictionAdmissionReason,
|
|
21
25
|
FrictionKind,
|
|
@@ -27,6 +31,7 @@ import type {
|
|
|
27
31
|
ReportEnqueue,
|
|
28
32
|
ReportKind,
|
|
29
33
|
ReportRecord,
|
|
34
|
+
RecoveryAction,
|
|
30
35
|
RunRecord,
|
|
31
36
|
RunState,
|
|
32
37
|
SessionRole,
|
|
@@ -105,6 +110,9 @@ const UPDATABLE_COLUMNS: Record<string, true> = {
|
|
|
105
110
|
endedAt: true,
|
|
106
111
|
lastError: true,
|
|
107
112
|
settlementFlags: true,
|
|
113
|
+
failureClass: true,
|
|
114
|
+
recoveryAction: true,
|
|
115
|
+
recoveredAt: true,
|
|
108
116
|
};
|
|
109
117
|
|
|
110
118
|
/** Everything SQLite will accept from us. */
|
|
@@ -133,6 +141,9 @@ interface RunRow {
|
|
|
133
141
|
endedAt: number | null;
|
|
134
142
|
lastError: string | null;
|
|
135
143
|
settlementFlags: string | null;
|
|
144
|
+
failureClass: string | null;
|
|
145
|
+
recoveryAction: string | null;
|
|
146
|
+
recoveredAt: number | null;
|
|
136
147
|
}
|
|
137
148
|
|
|
138
149
|
interface FrictionRollupRow {
|
|
@@ -216,7 +227,10 @@ CREATE TABLE IF NOT EXISTS runs (
|
|
|
216
227
|
startedAt INTEGER NOT NULL,
|
|
217
228
|
endedAt INTEGER,
|
|
218
229
|
lastError TEXT,
|
|
219
|
-
settlementFlags TEXT
|
|
230
|
+
settlementFlags TEXT,
|
|
231
|
+
failureClass TEXT,
|
|
232
|
+
recoveryAction TEXT,
|
|
233
|
+
recoveredAt INTEGER
|
|
220
234
|
);
|
|
221
235
|
CREATE INDEX IF NOT EXISTS runs_project_issue ON runs (project, issue);
|
|
222
236
|
CREATE INDEX IF NOT EXISTS runs_project_state ON runs (project, state);
|
|
@@ -317,6 +331,34 @@ CREATE TABLE IF NOT EXISTS merge_locks (
|
|
|
317
331
|
prUrl TEXT NOT NULL,
|
|
318
332
|
at INTEGER NOT NULL
|
|
319
333
|
);
|
|
334
|
+
|
|
335
|
+
-- Questions the orchestrator has put to its operator, and their answers (#136).
|
|
336
|
+
--
|
|
337
|
+
-- The reason this is a table and not the session's memory: a question lived only
|
|
338
|
+
-- in the model's context, so a compaction, a restart or a tick that ran long
|
|
339
|
+
-- lost both the question and the fact that it was owed one. The session then
|
|
340
|
+
-- either re-asked (the operator answers twice) or silently dropped it (the
|
|
341
|
+
-- decision never lands, and nothing anywhere says a decision is outstanding).
|
|
342
|
+
--
|
|
343
|
+
-- The 'condition' column is a machine-checkable precondition for the answer
|
|
344
|
+
-- becoming actionable -- a PR merging, an issue closing, a version on npm --
|
|
345
|
+
-- stored as the raw string an operator or session wrote, parsed on read. Once
|
|
346
|
+
-- met, 'conditionMetAt' is what turns a parked question into one the tick digest
|
|
347
|
+
-- pushes at the session.
|
|
348
|
+
CREATE TABLE IF NOT EXISTS decisions (
|
|
349
|
+
id TEXT PRIMARY KEY,
|
|
350
|
+
project TEXT NOT NULL,
|
|
351
|
+
question TEXT NOT NULL,
|
|
352
|
+
blocks TEXT,
|
|
353
|
+
askedAt INTEGER NOT NULL,
|
|
354
|
+
expiresAt INTEGER NOT NULL,
|
|
355
|
+
condition TEXT,
|
|
356
|
+
conditionMetAt INTEGER,
|
|
357
|
+
state TEXT NOT NULL,
|
|
358
|
+
resolvedAt INTEGER,
|
|
359
|
+
resolution TEXT
|
|
360
|
+
);
|
|
361
|
+
CREATE INDEX IF NOT EXISTS decisions_project_state ON decisions (project, state);
|
|
320
362
|
`;
|
|
321
363
|
|
|
322
364
|
/**
|
|
@@ -392,6 +434,9 @@ function toRecord(row: RunRow): RunRecord {
|
|
|
392
434
|
const flags = toSettlementFlags(row.settlementFlags);
|
|
393
435
|
if (flags !== undefined) record.settlementFlags = flags;
|
|
394
436
|
}
|
|
437
|
+
if (row.failureClass !== null) record.failureClass = row.failureClass as FailureClass;
|
|
438
|
+
if (row.recoveryAction !== null) record.recoveryAction = row.recoveryAction as RecoveryAction;
|
|
439
|
+
if (row.recoveredAt !== null) record.recoveredAt = row.recoveredAt;
|
|
395
440
|
return record;
|
|
396
441
|
}
|
|
397
442
|
|
|
@@ -420,6 +465,38 @@ function toReport(row: ReportRow): ReportRecord {
|
|
|
420
465
|
return record;
|
|
421
466
|
}
|
|
422
467
|
|
|
468
|
+
interface DecisionRow {
|
|
469
|
+
id: string;
|
|
470
|
+
project: string;
|
|
471
|
+
question: string;
|
|
472
|
+
blocks: string | null;
|
|
473
|
+
askedAt: number;
|
|
474
|
+
expiresAt: number;
|
|
475
|
+
condition: string | null;
|
|
476
|
+
conditionMetAt: number | null;
|
|
477
|
+
state: string;
|
|
478
|
+
resolvedAt: number | null;
|
|
479
|
+
resolution: string | null;
|
|
480
|
+
}
|
|
481
|
+
|
|
482
|
+
/** Same NULL-to-absent contract as {@link toReport}. */
|
|
483
|
+
function toDecision(row: DecisionRow): DecisionRecord {
|
|
484
|
+
const record: DecisionRecord = {
|
|
485
|
+
id: row.id,
|
|
486
|
+
project: row.project,
|
|
487
|
+
question: row.question,
|
|
488
|
+
askedAt: row.askedAt,
|
|
489
|
+
expiresAt: row.expiresAt,
|
|
490
|
+
state: row.state as DecisionState,
|
|
491
|
+
};
|
|
492
|
+
if (row.blocks !== null) record.blocks = row.blocks;
|
|
493
|
+
if (row.condition !== null) record.condition = row.condition;
|
|
494
|
+
if (row.conditionMetAt !== null) record.conditionMetAt = row.conditionMetAt;
|
|
495
|
+
if (row.resolvedAt !== null) record.resolvedAt = row.resolvedAt;
|
|
496
|
+
if (row.resolution !== null) record.resolution = row.resolution;
|
|
497
|
+
return record;
|
|
498
|
+
}
|
|
499
|
+
|
|
423
500
|
/**
|
|
424
501
|
* Long enough that a collision is not a thing anyone has to handle, short
|
|
425
502
|
* enough that an operator can compare it against the id printed in a Telegram
|
|
@@ -587,6 +664,19 @@ export function openStore(dbPath: string): Store {
|
|
|
587
664
|
if (!columns.some((column) => column.name === "settlementFlags")) {
|
|
588
665
|
db.exec("ALTER TABLE runs ADD COLUMN settlementFlags TEXT");
|
|
589
666
|
}
|
|
667
|
+
// Every row written before #132 is unclassified, and NULL is the honest
|
|
668
|
+
// reading of that: the budget counters below deliberately still count an
|
|
669
|
+
// unclassified terminal row exactly as this release's predecessor did, so an
|
|
670
|
+
// upgrade never silently re-opens a budget an operator had already spent.
|
|
671
|
+
for (const [name, type] of [
|
|
672
|
+
["failureClass", "TEXT"],
|
|
673
|
+
["recoveryAction", "TEXT"],
|
|
674
|
+
["recoveredAt", "INTEGER"],
|
|
675
|
+
] as const) {
|
|
676
|
+
if (!columns.some((column) => column.name === name)) {
|
|
677
|
+
db.exec(`ALTER TABLE runs ADD COLUMN ${name} ${type}`);
|
|
678
|
+
}
|
|
679
|
+
}
|
|
590
680
|
|
|
591
681
|
const insertRun = db.query<unknown, SqlValue[]>(
|
|
592
682
|
`INSERT INTO runs (
|
|
@@ -626,13 +716,53 @@ export function openStore(dbPath: string): Store {
|
|
|
626
716
|
const countAttempts = db.query<{ n: number }, [string, number]>(
|
|
627
717
|
`SELECT COUNT(*) AS n FROM runs WHERE project = ? AND issue = ?`,
|
|
628
718
|
);
|
|
719
|
+
// The classes excluded here are the ones whose cause is not the work (#132): a
|
|
720
|
+
// runner that never produced a verdict, a row whose PR had already merged, a
|
|
721
|
+
// kill that came from a daemon restart rather than from a turn ceiling. On this
|
|
722
|
+
// fleet #350 spent its whole continuation budget on causes like those and then
|
|
723
|
+
// sat in admission hold for six dispatch cycles. An unclassified row (NULL) is
|
|
724
|
+
// counted exactly as before, so upgrading changes no existing budget.
|
|
629
725
|
const countFailures = db.query<{ n: number }, [string, number]>(
|
|
630
726
|
`SELECT COUNT(*) AS n FROM runs
|
|
631
|
-
WHERE project = ? AND issue = ? AND state = 'failed'
|
|
727
|
+
WHERE project = ? AND issue = ? AND state = 'failed'
|
|
728
|
+
AND (failureClass IS NULL OR failureClass NOT IN ('ci-infra', 'settlement-stuck'))`,
|
|
632
729
|
);
|
|
633
730
|
const countContinuations = db.query<{ n: number }, [string, number]>(
|
|
634
731
|
`SELECT COUNT(*) AS n FROM runs
|
|
635
|
-
WHERE project = ? AND issue = ? AND state IN ('killed', 'orphaned', 'blocked')
|
|
732
|
+
WHERE project = ? AND issue = ? AND state IN ('killed', 'orphaned', 'blocked')
|
|
733
|
+
AND (failureClass IS NULL OR failureClass NOT IN ('admin-kill', 'settlement-stuck'))`,
|
|
734
|
+
);
|
|
735
|
+
// Newest first, and bounded: every row this returns costs `gh` calls to gather
|
|
736
|
+
// facts for, so a fleet with a long unclassified history classifies over
|
|
737
|
+
// several ticks rather than spending one tick's budget on all of it.
|
|
738
|
+
//
|
|
739
|
+
// Classified-but-unrecovered rows are included, not just unclassified ones: a
|
|
740
|
+
// recovery whose tracker write failed has to come back, or the log line saying
|
|
741
|
+
// "retrying next tick" is a lie and the row is stranded with a class and no
|
|
742
|
+
// action. `hold` is excluded because it is *recorded only* by design — its
|
|
743
|
+
// `recoveredAt` stays NULL forever, and re-offering it would spin the sweep.
|
|
744
|
+
const selectUnclassified = db.query<RunRow, [string, number]>(
|
|
745
|
+
`SELECT * FROM runs
|
|
746
|
+
WHERE project = ?
|
|
747
|
+
AND state IN ('blocked', 'killed', 'failed', 'orphaned', 'pushed-green')
|
|
748
|
+
AND (
|
|
749
|
+
failureClass IS NULL
|
|
750
|
+
OR (recoveredAt IS NULL AND recoveryAction IN ('settle', 'continue', 'requeue', 'rerun-checks'))
|
|
751
|
+
)
|
|
752
|
+
ORDER BY startedAt DESC, rowid DESC
|
|
753
|
+
LIMIT ?`,
|
|
754
|
+
);
|
|
755
|
+
const selectFailureClassCounts = db.query<{ cls: string; n: number }, [string]>(
|
|
756
|
+
`SELECT failureClass AS cls, COUNT(*) AS n FROM runs
|
|
757
|
+
WHERE project = ? AND failureClass IS NOT NULL AND recoveredAt IS NULL
|
|
758
|
+
AND state IN ('blocked', 'killed', 'failed', 'orphaned', 'pushed-green')
|
|
759
|
+
GROUP BY failureClass
|
|
760
|
+
ORDER BY n DESC, failureClass ASC`,
|
|
761
|
+
);
|
|
762
|
+
const selectRecoveredSince = db.query<RunRow, [string, number]>(
|
|
763
|
+
`SELECT * FROM runs
|
|
764
|
+
WHERE project = ? AND recoveredAt IS NOT NULL AND recoveredAt >= ?
|
|
765
|
+
ORDER BY recoveredAt DESC, rowid DESC`,
|
|
636
766
|
);
|
|
637
767
|
// Salvage state that still describes an issue's present, so an operator is
|
|
638
768
|
// shown a preserved WIP tip exactly while it is the thing a re-claim would
|
|
@@ -777,6 +907,39 @@ export function openStore(dbPath: string): Store {
|
|
|
777
907
|
ORDER BY createdAt ASC, rowid ASC`,
|
|
778
908
|
);
|
|
779
909
|
|
|
910
|
+
const insertDecision = db.query<never, [string, string, string, SqlValue, number, number, SqlValue, string]>(
|
|
911
|
+
`INSERT INTO decisions
|
|
912
|
+
(id, project, question, blocks, askedAt, expiresAt, condition, state)
|
|
913
|
+
VALUES (?, ?, ?, ?, ?, ?, ?, ?)`,
|
|
914
|
+
);
|
|
915
|
+
const selectDecision = db.query<DecisionRow, [string]>(`SELECT * FROM decisions WHERE id = ?`);
|
|
916
|
+
const selectOpenDecisions = db.query<DecisionRow, [string]>(
|
|
917
|
+
`SELECT * FROM decisions
|
|
918
|
+
WHERE project = ? AND state = 'open'
|
|
919
|
+
ORDER BY askedAt ASC, rowid ASC`,
|
|
920
|
+
);
|
|
921
|
+
const selectDueDecisions = db.query<DecisionRow, [string, number]>(
|
|
922
|
+
`SELECT * FROM decisions
|
|
923
|
+
WHERE project = ? AND state = 'open' AND expiresAt <= ?
|
|
924
|
+
ORDER BY askedAt ASC, rowid ASC`,
|
|
925
|
+
);
|
|
926
|
+
// `state = 'open'` in the WHERE is the double-resolve guard: the second call
|
|
927
|
+
// changes no rows and returns false rather than overwriting the first answer.
|
|
928
|
+
const resolveDecisionRow = db.query<never, [string, string, number, string]>(
|
|
929
|
+
`UPDATE decisions SET state = ?, resolution = ?, resolvedAt = ?
|
|
930
|
+
WHERE id = ? AND state = 'open'`,
|
|
931
|
+
);
|
|
932
|
+
const expireDecisionRows = db.query<never, [number, string, number]>(
|
|
933
|
+
`UPDATE decisions SET state = 'expired', resolvedAt = ?, resolution = 'expired unanswered'
|
|
934
|
+
WHERE project = ? AND state = 'open' AND expiresAt <= ?`,
|
|
935
|
+
);
|
|
936
|
+
// Set once: a condition that flickers must not keep moving its own timestamp,
|
|
937
|
+
// or the digest's "act on this now" would reset to looking new every tick.
|
|
938
|
+
const markConditionMetRow = db.query<never, [number, string]>(
|
|
939
|
+
`UPDATE decisions SET conditionMetAt = ?
|
|
940
|
+
WHERE id = ? AND state = 'open' AND conditionMetAt IS NULL`,
|
|
941
|
+
);
|
|
942
|
+
|
|
780
943
|
const insertVerbLedger = db.query<unknown, SqlValue[]>(
|
|
781
944
|
`INSERT INTO verb_ledger
|
|
782
945
|
(id, project, runId, issue, verb, role, args, decision, refusal, detail, sha, at)
|
|
@@ -1152,6 +1315,69 @@ export function openStore(dbPath: string): Store {
|
|
|
1152
1315
|
return selectOpenReports.all(project).map(toReport);
|
|
1153
1316
|
},
|
|
1154
1317
|
|
|
1318
|
+
runsNeedingClassification(project: string, limit = 20): RunRecord[] {
|
|
1319
|
+
return selectUnclassified.all(project, limit).map(toRecord);
|
|
1320
|
+
},
|
|
1321
|
+
|
|
1322
|
+
failureClassCounts(project: string): { cls: FailureClass; n: number }[] {
|
|
1323
|
+
return selectFailureClassCounts
|
|
1324
|
+
.all(project)
|
|
1325
|
+
.map((row) => ({ cls: row.cls as FailureClass, n: row.n }));
|
|
1326
|
+
},
|
|
1327
|
+
|
|
1328
|
+
recoveredSince(project: string, since: number): RunRecord[] {
|
|
1329
|
+
return selectRecoveredSince.all(project, since).map(toRecord);
|
|
1330
|
+
},
|
|
1331
|
+
|
|
1332
|
+
createDecision(draft: DecisionDraft): DecisionRecord {
|
|
1333
|
+
const record: DecisionRecord = {
|
|
1334
|
+
id: crypto.randomUUID(),
|
|
1335
|
+
project: draft.project,
|
|
1336
|
+
question: draft.question,
|
|
1337
|
+
askedAt: draft.at,
|
|
1338
|
+
expiresAt: draft.at + DECISION_TTL_MS,
|
|
1339
|
+
state: "open",
|
|
1340
|
+
};
|
|
1341
|
+
if (draft.blocks !== undefined) record.blocks = draft.blocks;
|
|
1342
|
+
if (draft.condition !== undefined) record.condition = draft.condition;
|
|
1343
|
+
insertDecision.run(
|
|
1344
|
+
record.id,
|
|
1345
|
+
record.project,
|
|
1346
|
+
record.question,
|
|
1347
|
+
toSql(record.blocks),
|
|
1348
|
+
record.askedAt,
|
|
1349
|
+
record.expiresAt,
|
|
1350
|
+
toSql(record.condition),
|
|
1351
|
+
record.state,
|
|
1352
|
+
);
|
|
1353
|
+
return record;
|
|
1354
|
+
},
|
|
1355
|
+
|
|
1356
|
+
openDecisions(project: string): DecisionRecord[] {
|
|
1357
|
+
return selectOpenDecisions.all(project).map(toDecision);
|
|
1358
|
+
},
|
|
1359
|
+
|
|
1360
|
+
resolveDecision(id: string, state: "answered" | "withdrawn", resolution: string, at: number): boolean {
|
|
1361
|
+
return resolveDecisionRow.run(state, resolution, at, id).changes > 0;
|
|
1362
|
+
},
|
|
1363
|
+
|
|
1364
|
+
markDecisionConditionMet(id: string, at: number): boolean {
|
|
1365
|
+
return markConditionMetRow.run(at, id).changes > 0;
|
|
1366
|
+
},
|
|
1367
|
+
|
|
1368
|
+
expireDueDecisions(project: string, now: number): DecisionRecord[] {
|
|
1369
|
+
const due = selectDueDecisions.all(project, now);
|
|
1370
|
+
if (due.length === 0) return [];
|
|
1371
|
+
expireDecisionRows.run(now, project, now);
|
|
1372
|
+
// Re-read for the same reason `recoverSendingReports` does: the caller
|
|
1373
|
+
// renders these, and a row that still says `open` while the ledger says
|
|
1374
|
+
// `expired` is the disagreement this table exists to remove.
|
|
1375
|
+
return due
|
|
1376
|
+
.map((row) => selectDecision.get(row.id))
|
|
1377
|
+
.filter((row): row is DecisionRow => row !== null)
|
|
1378
|
+
.map(toDecision);
|
|
1379
|
+
},
|
|
1380
|
+
|
|
1155
1381
|
appendVerbLedger(draft: VerbLedgerDraft): VerbLedgerEntry {
|
|
1156
1382
|
const entry: VerbLedgerEntry = { ...draft, id: crypto.randomUUID(), at: Date.now() };
|
|
1157
1383
|
insertVerbLedger.run(
|