omp-conductor 0.4.2 → 0.4.3
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 +344 -3
- 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 +220 -4
- package/src/tracker/github.ts +167 -1
- package/src/types.ts +179 -13
- package/src/upgrade.ts +27 -3
|
@@ -0,0 +1,172 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Why a run ended badly, and what to do about it (#132).
|
|
3
|
+
*
|
|
4
|
+
* Half of this fleet's spend produced no merged PR, and every one of those runs
|
|
5
|
+
* ended at a human who then re-derived the same triage by hand: read the row,
|
|
6
|
+
* read the PR's checks, decide whether to requeue, re-run, settle or escalate —
|
|
7
|
+
* and then threw the conclusion away. The daemon already knew *that* a run ended
|
|
8
|
+
* badly; it never recorded *why*, so a mechanical recovery cost human attention
|
|
9
|
+
* and an implementation budget it had not spent.
|
|
10
|
+
*
|
|
11
|
+
* This module is the "why", and nothing else: pure, synchronous, and given the
|
|
12
|
+
* facts rather than fetching them. The caller gathers what the row needs (a PR
|
|
13
|
+
* state, a mergeability, a check list), classifies, then performs the one
|
|
14
|
+
* recovery the class names. Keeping the decision table here is what makes "one
|
|
15
|
+
* green test per class" possible without a daemon, a tracker or a network.
|
|
16
|
+
*/
|
|
17
|
+
|
|
18
|
+
import type { FailureClass, RecoveryAction, RunRecord } from "./types.ts";
|
|
19
|
+
|
|
20
|
+
/** Facts the caller fetched, each only for the rows that need it. */
|
|
21
|
+
export interface ClassifyFacts {
|
|
22
|
+
pr?: "open" | "merged" | "closed";
|
|
23
|
+
mergeable?: "conflicting" | "clean" | "unknown";
|
|
24
|
+
checks?: { name: string; state: string; link?: string }[];
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export interface Classification {
|
|
28
|
+
cls: FailureClass;
|
|
29
|
+
recovery: RecoveryAction;
|
|
30
|
+
/** One line naming the signals that matched, carried into the escalation. */
|
|
31
|
+
evidence: string;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* Check states GitHub reports for a run that never produced a verdict.
|
|
36
|
+
*
|
|
37
|
+
* Infrastructure, not code: a cancelled or timed-out check says nothing about
|
|
38
|
+
* the diff, so re-running it is free information and charging an implementation
|
|
39
|
+
* attempt for it is simply wrong. Compared lowercased because `gh pr checks
|
|
40
|
+
* --json state` has emitted both spellings across versions.
|
|
41
|
+
*/
|
|
42
|
+
const INFRA_CHECK_STATES: Record<string, true> = {
|
|
43
|
+
cancelled: true,
|
|
44
|
+
timed_out: true,
|
|
45
|
+
startup_failure: true,
|
|
46
|
+
stale: true,
|
|
47
|
+
skipped: true,
|
|
48
|
+
};
|
|
49
|
+
|
|
50
|
+
/** States that mean "this check has a verdict and it is good". */
|
|
51
|
+
const SUCCESS_CHECK_STATES: Record<string, true> = { success: true, neutral: true };
|
|
52
|
+
|
|
53
|
+
function normalise(state: string): string {
|
|
54
|
+
return state.trim().toLowerCase();
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* Classify one terminal run.
|
|
59
|
+
*
|
|
60
|
+
* First match wins, and the order is the contract: the earlier a row matches,
|
|
61
|
+
* the less its raw state matters. `settlement-stuck` leads because a merged PR
|
|
62
|
+
* makes every other reading of the row wrong — that is the #362 case, where a
|
|
63
|
+
* `pushed-green` row outlived its own PR and held the issue out of dispatch.
|
|
64
|
+
*/
|
|
65
|
+
export function classifyRun(run: RunRecord, facts: ClassifyFacts): Classification {
|
|
66
|
+
const hasArtifacts = run.prUrl !== undefined || run.headSha !== undefined || run.salvageSha !== undefined;
|
|
67
|
+
|
|
68
|
+
// The PR landed while the row says otherwise. Whatever else is true about this
|
|
69
|
+
// run, it succeeded, and the recovery is bookkeeping.
|
|
70
|
+
if (run.prUrl !== undefined && facts.pr === "merged") {
|
|
71
|
+
return {
|
|
72
|
+
cls: "settlement-stuck",
|
|
73
|
+
recovery: "settle",
|
|
74
|
+
evidence: `row state ${run.state} but ${run.prUrl} is merged`,
|
|
75
|
+
};
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
// A green PR whose base moved under it. Mechanical to fix and it never spent
|
|
79
|
+
// an implementation attempt: veltro#365 lost a continuation to exactly this.
|
|
80
|
+
if (run.state === "pushed-green" && facts.pr === "open" && facts.mergeable === "conflicting") {
|
|
81
|
+
return {
|
|
82
|
+
cls: "merge-conflict",
|
|
83
|
+
recovery: "continue",
|
|
84
|
+
evidence: `${run.prUrl ?? "the PR"} is open and conflicting — the base moved under a green PR`,
|
|
85
|
+
};
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
if (run.state === "blocked") {
|
|
89
|
+
return {
|
|
90
|
+
cls: "question",
|
|
91
|
+
recovery: "escalate",
|
|
92
|
+
evidence: run.lastError ?? "the worker stopped to ask a question and left no report",
|
|
93
|
+
};
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
if (run.state === "orphaned") {
|
|
97
|
+
// Dirty means the worktree holds the only copy: re-claiming would delete it,
|
|
98
|
+
// which is the #319 data-loss shape. The existing unsalvaged-WIP admission
|
|
99
|
+
// hold already fails dispatch closed, so recording the class is the whole
|
|
100
|
+
// recovery — an operator has to look either way.
|
|
101
|
+
if (run.salvageError !== undefined && run.salvageAckAt === undefined) {
|
|
102
|
+
return {
|
|
103
|
+
cls: "orphan-dirty",
|
|
104
|
+
recovery: "hold",
|
|
105
|
+
evidence: `uncommitted work in ${run.worktree} is the only copy: ${run.salvageError}`,
|
|
106
|
+
};
|
|
107
|
+
}
|
|
108
|
+
return {
|
|
109
|
+
cls: "orphan-clean",
|
|
110
|
+
recovery: "requeue",
|
|
111
|
+
evidence: `daemon died holding the claim; nothing uncommitted (${run.branch})`,
|
|
112
|
+
};
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
if (run.state === "killed") {
|
|
116
|
+
if (run.turns >= run.maxTurns) {
|
|
117
|
+
return hasArtifacts
|
|
118
|
+
? {
|
|
119
|
+
cls: "turn-cap-progress",
|
|
120
|
+
recovery: "continue",
|
|
121
|
+
evidence: `turns ${run.turns}/${run.maxTurns} with work to continue from (${run.prUrl ?? run.salvageSha ?? run.headSha})`,
|
|
122
|
+
}
|
|
123
|
+
: {
|
|
124
|
+
cls: "turn-cap-spinning",
|
|
125
|
+
recovery: "escalate",
|
|
126
|
+
evidence: `turns ${run.turns}/${run.maxTurns}, no PR, no commits — $${run.spendUsd.toFixed(2)} spent`,
|
|
127
|
+
};
|
|
128
|
+
}
|
|
129
|
+
// Below its own ceiling, so nothing this worker did ended it: a daemon
|
|
130
|
+
// restart, a drain, an operator. Costs no budget and needs no human.
|
|
131
|
+
return {
|
|
132
|
+
cls: "admin-kill",
|
|
133
|
+
recovery: "requeue",
|
|
134
|
+
evidence: `killed at ${run.turns}/${run.maxTurns} turns — under its own ceiling, so not a cap kill`,
|
|
135
|
+
};
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
if (run.state === "failed" && facts.pr === "open") {
|
|
139
|
+
const checks = facts.checks ?? [];
|
|
140
|
+
const unresolved = checks.filter((c) => !SUCCESS_CHECK_STATES[normalise(c.state)] === true);
|
|
141
|
+
if (checks.length > 0 && unresolved.length > 0) {
|
|
142
|
+
const failing = unresolved.filter((c) => normalise(c.state) === "failure");
|
|
143
|
+
if (failing.length > 0) {
|
|
144
|
+
return {
|
|
145
|
+
cls: "ci-deterministic",
|
|
146
|
+
recovery: "escalate",
|
|
147
|
+
evidence: `checks failed: ${failing
|
|
148
|
+
.map((c) => `${c.name}${c.link === undefined ? "" : ` (${c.link})`}`)
|
|
149
|
+
.join(", ")}`,
|
|
150
|
+
};
|
|
151
|
+
}
|
|
152
|
+
if (unresolved.every((c) => INFRA_CHECK_STATES[normalise(c.state)] === true)) {
|
|
153
|
+
return {
|
|
154
|
+
cls: "ci-infra",
|
|
155
|
+
recovery: "rerun-checks",
|
|
156
|
+
evidence: `no check reached a verdict: ${unresolved.map((c) => `${c.name} ${normalise(c.state)}`).join(", ")}`,
|
|
157
|
+
};
|
|
158
|
+
}
|
|
159
|
+
}
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
// Deliberately escalate rather than retry. An unrecognised shape is a gap in
|
|
163
|
+
// this table, and a silent requeue would spend a budget on a cause nobody has
|
|
164
|
+
// named — the exact behaviour #132 exists to end.
|
|
165
|
+
return {
|
|
166
|
+
cls: "unknown",
|
|
167
|
+
recovery: "escalate",
|
|
168
|
+
evidence: `state ${run.state}, turns ${run.turns}/${run.maxTurns}, pr ${facts.pr ?? "unknown"}${
|
|
169
|
+
run.lastError === undefined ? "" : `, last error: ${run.lastError}`
|
|
170
|
+
}`,
|
|
171
|
+
};
|
|
172
|
+
}
|
package/src/fleet.ts
CHANGED
|
@@ -31,7 +31,10 @@ import { findProject, loadConfig, resolveCredentials, stateDir } from "./config.
|
|
|
31
31
|
import { describeBoundary, probeHost } from "./credentials.ts";
|
|
32
32
|
import { planUsageLine, readPlanUsage, sharedUsageSource } from "./usage.ts";
|
|
33
33
|
import { readApprovalSurface } from "./approval-surface.ts";
|
|
34
|
-
import {
|
|
34
|
+
import { inspectBriefLayout } from "./brief-upgrade.ts";
|
|
35
|
+
import { dbPath, openStore } from "./store.ts";
|
|
36
|
+
import { renderBriefForProject } from "./setup.ts";
|
|
37
|
+
import type { ProjectConfig, Store } from "./types.ts";
|
|
35
38
|
import { settlementFlagSummary } from "./diff-flags.ts";
|
|
36
39
|
import { probeCodeGraph, type CodeGraphHealth } from "./graph-health.ts";
|
|
37
40
|
import { formatOpenReports } from "./reports.ts";
|
|
@@ -128,7 +131,14 @@ export interface ArmResult {
|
|
|
128
131
|
|
|
129
132
|
export interface ArmDeps {
|
|
130
133
|
sendChallenge?: (token: string, owner: string, text: string) => Promise<void>;
|
|
131
|
-
|
|
134
|
+
/**
|
|
135
|
+
* Waits for the challenge to appear as a user turn somewhere under the
|
|
136
|
+
* session directory. The waiter owns transcript discovery — not the caller —
|
|
137
|
+
* because the reply may land in a session that starts *after* the send, so a
|
|
138
|
+
* path resolved before the challenge went out can be the wrong file by the
|
|
139
|
+
* time the operator answers (#142).
|
|
140
|
+
*/
|
|
141
|
+
waitForUserTurn?: (dir: string, code: string, sentAt: number, timeoutMs: number) => Promise<boolean>;
|
|
132
142
|
now?: () => number;
|
|
133
143
|
sleep?: (ms: number) => Promise<void>;
|
|
134
144
|
timeoutMs?: number;
|
|
@@ -167,11 +177,11 @@ export async function armTicks(projectName?: string, deps: ArmDeps = {}): Promis
|
|
|
167
177
|
);
|
|
168
178
|
}
|
|
169
179
|
|
|
170
|
-
const
|
|
171
|
-
if (
|
|
180
|
+
const dir = sessionDirForCwd(tick.cwd);
|
|
181
|
+
if (!existsSync(dir)) {
|
|
172
182
|
throw new Error(
|
|
173
|
-
`no orchestrator session
|
|
174
|
-
`the inbound proof is read from a user turn there. Start the pane orchestrator, let it settle, then arm again`,
|
|
183
|
+
`no orchestrator session directory at ${dir} — ` +
|
|
184
|
+
`the inbound proof is read from a user turn in a transcript there. Start the pane orchestrator, let it settle, then arm again`,
|
|
175
185
|
);
|
|
176
186
|
}
|
|
177
187
|
|
|
@@ -183,6 +193,9 @@ export async function armTicks(projectName?: string, deps: ArmDeps = {}): Promis
|
|
|
183
193
|
`Nothing will be dispatched until that reply is seen in the orchestrator session.`;
|
|
184
194
|
|
|
185
195
|
const send = deps.sendChallenge ?? sendTelegramMessage;
|
|
196
|
+
// Read before the send, not after: a transcript untouched since this instant
|
|
197
|
+
// cannot contain the reply, and that is what the waiter filters on.
|
|
198
|
+
const sentAt = (deps.now ?? Date.now)();
|
|
186
199
|
try {
|
|
187
200
|
await send(token, channel.owner, text);
|
|
188
201
|
} catch (err) {
|
|
@@ -192,9 +205,16 @@ export async function armTicks(projectName?: string, deps: ArmDeps = {}): Promis
|
|
|
192
205
|
}
|
|
193
206
|
|
|
194
207
|
const timeoutMs = deps.timeoutMs ?? ARM_CHALLENGE_TIMEOUT_MS;
|
|
195
|
-
const
|
|
196
|
-
const
|
|
197
|
-
|
|
208
|
+
const injected = deps.waitForUserTurn;
|
|
209
|
+
const scan: SessionScan = injected
|
|
210
|
+
? { seen: await injected(dir, code, sentAt, timeoutMs), scanned: [], ignored: [] }
|
|
211
|
+
: await waitForChallengeInSessions(dir, code, sentAt, timeoutMs, deps);
|
|
212
|
+
if (!scan.seen) {
|
|
213
|
+
const listing = [
|
|
214
|
+
`session dir: ${dir}`,
|
|
215
|
+
...scan.scanned.map((f) => ` watched: ${f}`),
|
|
216
|
+
...scan.ignored.map((f) => ` ignored (stale, last written before the challenge): ${f}`),
|
|
217
|
+
].join("\n");
|
|
198
218
|
throw new Error(
|
|
199
219
|
`arm: the challenge never arrived as a user turn in time — NOT armed.\n` +
|
|
200
220
|
`Inbound Telegram is not reaching the omp session. Check, in order:\n` +
|
|
@@ -202,7 +222,7 @@ export async function armTicks(projectName?: string, deps: ArmDeps = {}): Promis
|
|
|
202
222
|
` * is another process holding this bot token? Telegram allows exactly one\n` +
|
|
203
223
|
` getUpdates consumer and rejects the second with HTTP 409.\n` +
|
|
204
224
|
` * did you reply in the DM with the bot, not another chat?\n` +
|
|
205
|
-
|
|
225
|
+
listing,
|
|
206
226
|
);
|
|
207
227
|
}
|
|
208
228
|
|
|
@@ -953,8 +973,10 @@ export function formatFleetStatus(
|
|
|
953
973
|
telegram: TelegramHealth = { kind: "unprobed" },
|
|
954
974
|
now = Date.now(),
|
|
955
975
|
codeGraph: CodeGraphHealth = { configured: false },
|
|
956
|
-
refusals: ConfinementRefusalSummary | undefined = undefined,
|
|
957
976
|
boundary: BoundaryStatus | undefined = undefined,
|
|
977
|
+
brief: string | undefined = undefined,
|
|
978
|
+
decisions: string | undefined = undefined,
|
|
979
|
+
failureClasses: string | undefined = undefined,
|
|
958
980
|
): string {
|
|
959
981
|
const tickLine =
|
|
960
982
|
layers.ticksDetail === undefined
|
|
@@ -1007,14 +1029,6 @@ export function formatFleetStatus(
|
|
|
1007
1029
|
}
|
|
1008
1030
|
|
|
1009
1031
|
const graphBlock = formatCodeGraphHealth(codeGraph, now);
|
|
1010
|
-
// Silent when there were none: a line reading "0" every day is one nobody
|
|
1011
|
-
// reads on the day it says 40. A repeatedly-refused orchestrator is
|
|
1012
|
-
// misbriefed, and that is the operator's problem to see (#127).
|
|
1013
|
-
const confinementLine =
|
|
1014
|
-
refusals === undefined
|
|
1015
|
-
? undefined
|
|
1016
|
-
: `confine ${refusals.count} orchestrator refusal(s) today ` +
|
|
1017
|
-
`(latest: ${refusals.latest.tool} ${refusals.latest.path} — ${refusals.latest.kind})`;
|
|
1018
1032
|
// Reported every time, never only when it is bad. An operator reading this
|
|
1019
1033
|
// has to be able to see "unprotected" on the day they assumed otherwise, and
|
|
1020
1034
|
// a line that appears only in the failure case is one whose absence means
|
|
@@ -1034,7 +1048,9 @@ export function formatFleetStatus(
|
|
|
1034
1048
|
herdrLine,
|
|
1035
1049
|
telegramLine,
|
|
1036
1050
|
...boundaryLines,
|
|
1037
|
-
...(
|
|
1051
|
+
...(brief === undefined ? [] : [brief]),
|
|
1052
|
+
...(decisions === undefined ? [] : [decisions]),
|
|
1053
|
+
...(failureClasses === undefined ? [] : [failureClasses]),
|
|
1038
1054
|
...(graphBlock === undefined ? [] : [graphBlock]),
|
|
1039
1055
|
daemonBlock,
|
|
1040
1056
|
"",
|
|
@@ -1133,10 +1149,17 @@ export async function renderStatus(projectName?: string): Promise<string> {
|
|
|
1133
1149
|
const boundary =
|
|
1134
1150
|
live ?? {
|
|
1135
1151
|
...describeBoundary(credentials.isolation, await probeHost({ slots: s.caps.maxConcurrentWorkers })),
|
|
1136
|
-
detail:
|
|
1137
|
-
|
|
1138
|
-
|
|
1139
|
-
|
|
1152
|
+
detail:
|
|
1153
|
+
rec === undefined
|
|
1154
|
+
? [
|
|
1155
|
+
"No daemon is running, so this is THIS SHELL's view, not the fleet's — an interactive shell",
|
|
1156
|
+
"holds none of the unit's capabilities. Start the daemon and re-read before concluding anything.",
|
|
1157
|
+
]
|
|
1158
|
+
: [
|
|
1159
|
+
"The daemon did not report a boundary (it predates the field), so this is THIS SHELL's view,",
|
|
1160
|
+
"not the fleet's — an interactive shell holds none of the unit's capabilities. Upgrade and",
|
|
1161
|
+
"restart the daemon before concluding anything.",
|
|
1162
|
+
],
|
|
1140
1163
|
};
|
|
1141
1164
|
return formatFleetStatus(
|
|
1142
1165
|
{ ...s, planUsage },
|
|
@@ -1145,11 +1168,88 @@ export async function renderStatus(projectName?: string): Promise<string> {
|
|
|
1145
1168
|
telegram,
|
|
1146
1169
|
Date.now(),
|
|
1147
1170
|
codeGraph,
|
|
1148
|
-
confinementRefusalsToday(),
|
|
1149
1171
|
boundary,
|
|
1172
|
+
briefStatusLine(project),
|
|
1173
|
+
decisionStatusLine(project.name),
|
|
1174
|
+
failureClassBlock(project.name),
|
|
1150
1175
|
);
|
|
1151
1176
|
}
|
|
1152
1177
|
|
|
1178
|
+
/**
|
|
1179
|
+
* One line naming the brief layout, or nothing when it cannot be read.
|
|
1180
|
+
*
|
|
1181
|
+
* `brief-upgrade` stays the verb that migrates a legacy layout — this is the
|
|
1182
|
+
* surface that tells an operator they still have one, in the place they already
|
|
1183
|
+
* look (#131). Silent on any throw: a status that cannot render because the
|
|
1184
|
+
* brief moved is worse than a status missing one row.
|
|
1185
|
+
*/
|
|
1186
|
+
function briefStatusLine(project: ProjectConfig): string | undefined {
|
|
1187
|
+
try {
|
|
1188
|
+
const layout = inspectBriefLayout(project.workspaceRoot, renderBriefForProject(project));
|
|
1189
|
+
return layout.kind === "overlay"
|
|
1190
|
+
? "brief overlay (package floor + POLICY.md)"
|
|
1191
|
+
: `brief ${layout.kind} — run omp-conductor brief-upgrade`;
|
|
1192
|
+
} catch {
|
|
1193
|
+
return undefined;
|
|
1194
|
+
}
|
|
1195
|
+
}
|
|
1196
|
+
|
|
1197
|
+
/**
|
|
1198
|
+
* Unrecovered runs grouped by failure class (#132), or nothing when there are
|
|
1199
|
+
* none.
|
|
1200
|
+
*
|
|
1201
|
+
* Reported as classes rather than as row states because a row state is not an
|
|
1202
|
+
* issue state: the FAILED column counted four completed issues on this fleet
|
|
1203
|
+
* while the genuinely stuck ones were invisible (#109). A class says which of
|
|
1204
|
+
* those it is, and `recoveredAt` is what keeps a recovered row out of the list.
|
|
1205
|
+
*/
|
|
1206
|
+
function failureClassBlock(projectName: string): string | undefined {
|
|
1207
|
+
const path = dbPath();
|
|
1208
|
+
if (!existsSync(path)) return undefined;
|
|
1209
|
+
let store: Store | undefined;
|
|
1210
|
+
try {
|
|
1211
|
+
store = openStore(path);
|
|
1212
|
+
const counts = store.failureClassCounts(projectName);
|
|
1213
|
+
if (counts.length === 0) return undefined;
|
|
1214
|
+
return [
|
|
1215
|
+
"failure classes (unrecovered)",
|
|
1216
|
+
...counts.map((c) => ` ${c.cls.padEnd(18)}${c.n}`),
|
|
1217
|
+
].join("\n");
|
|
1218
|
+
} catch {
|
|
1219
|
+
return undefined;
|
|
1220
|
+
} finally {
|
|
1221
|
+
store?.close();
|
|
1222
|
+
}
|
|
1223
|
+
}
|
|
1224
|
+
|
|
1225
|
+
/**
|
|
1226
|
+
* One line naming what the orchestrator still owes its operator (#136).
|
|
1227
|
+
*
|
|
1228
|
+
* Reported whenever the store exists, `none open` included: a row that appears
|
|
1229
|
+
* only when something is outstanding is a row whose absence means nothing, and
|
|
1230
|
+
* "did it forget to record the question, or is there genuinely none?" is exactly
|
|
1231
|
+
* the ambiguity this ledger exists to remove. Omitted on any throw — a status
|
|
1232
|
+
* that fails to render because the store is mid-migration is worse than one
|
|
1233
|
+
* missing a row.
|
|
1234
|
+
*/
|
|
1235
|
+
function decisionStatusLine(projectName: string): string | undefined {
|
|
1236
|
+
const path = dbPath();
|
|
1237
|
+
if (!existsSync(path)) return undefined;
|
|
1238
|
+
let store: Store | undefined;
|
|
1239
|
+
try {
|
|
1240
|
+
store = openStore(path);
|
|
1241
|
+
const open = store.openDecisions(projectName);
|
|
1242
|
+
if (open.length === 0) return "decisions none open";
|
|
1243
|
+
const oldest = open[0]!;
|
|
1244
|
+
const hours = Math.max(0, Math.round((Date.now() - oldest.askedAt) / 3_600_000));
|
|
1245
|
+
return `decisions ${open.length} open (oldest ${hours}h)`;
|
|
1246
|
+
} catch {
|
|
1247
|
+
return undefined;
|
|
1248
|
+
} finally {
|
|
1249
|
+
store?.close();
|
|
1250
|
+
}
|
|
1251
|
+
}
|
|
1252
|
+
|
|
1153
1253
|
// ---------------------------------------------------------------------------
|
|
1154
1254
|
// arm proof helpers
|
|
1155
1255
|
// ---------------------------------------------------------------------------
|
|
@@ -1267,23 +1367,6 @@ export function sessionDirForCwd(cwd: string): string {
|
|
|
1267
1367
|
return join(home, ".omp", "agent", "sessions", slug.replaceAll("/", "-"));
|
|
1268
1368
|
}
|
|
1269
1369
|
|
|
1270
|
-
function newestSessionTranscript(cwd: string): string | undefined {
|
|
1271
|
-
const dir = sessionDirForCwd(cwd);
|
|
1272
|
-
if (!existsSync(dir)) return undefined;
|
|
1273
|
-
let best: { path: string; mtime: number } | undefined;
|
|
1274
|
-
for (const name of readdirSync(dir)) {
|
|
1275
|
-
if (!name.endsWith(".jsonl")) continue;
|
|
1276
|
-
const path = join(dir, name);
|
|
1277
|
-
try {
|
|
1278
|
-
const mtime = statSync(path).mtimeMs;
|
|
1279
|
-
if (best === undefined || mtime > best.mtime) best = { path, mtime };
|
|
1280
|
-
} catch {
|
|
1281
|
-
/* race */
|
|
1282
|
-
}
|
|
1283
|
-
}
|
|
1284
|
-
return best?.path;
|
|
1285
|
-
}
|
|
1286
|
-
|
|
1287
1370
|
function makeChallengeCode(): string {
|
|
1288
1371
|
const bytes = new Uint8Array(4);
|
|
1289
1372
|
crypto.getRandomValues(bytes);
|
|
@@ -1301,20 +1384,66 @@ async function sendTelegramMessage(token: string, owner: string, text: string):
|
|
|
1301
1384
|
}
|
|
1302
1385
|
}
|
|
1303
1386
|
|
|
1304
|
-
|
|
1305
|
-
|
|
1387
|
+
interface SessionScan {
|
|
1388
|
+
seen: boolean;
|
|
1389
|
+
/** `name (mtime <iso>)` for every transcript the last pass actually parsed. */
|
|
1390
|
+
scanned: string[];
|
|
1391
|
+
/** Same shape, for the ones skipped as written before the challenge. */
|
|
1392
|
+
ignored: string[];
|
|
1393
|
+
}
|
|
1394
|
+
|
|
1395
|
+
/**
|
|
1396
|
+
* Polls the session directory — re-read on every pass, never snapshotted —
|
|
1397
|
+
* until the challenge shows up as a user turn or the deadline passes.
|
|
1398
|
+
*
|
|
1399
|
+
* Discovery lives here because the reply can land in a transcript that does not
|
|
1400
|
+
* exist yet when the challenge is sent: a rotated session, or the first one of
|
|
1401
|
+
* a pane started right after arming (#142). A waiter handed one path polls a
|
|
1402
|
+
* file the answer will never be written to, and arming becomes impossible.
|
|
1403
|
+
*
|
|
1404
|
+
* Files untouched since just before the send are named, not parsed: an append
|
|
1405
|
+
* bumps mtime, so a transcript older than the challenge cannot hold the reply,
|
|
1406
|
+
* and skipping it keeps a large stale session out of every 5 s pass.
|
|
1407
|
+
*/
|
|
1408
|
+
async function waitForChallengeInSessions(
|
|
1409
|
+
dir: string,
|
|
1306
1410
|
code: string,
|
|
1411
|
+
sentAt: number,
|
|
1307
1412
|
timeoutMs: number,
|
|
1308
1413
|
deps: ArmDeps,
|
|
1309
|
-
): Promise<
|
|
1414
|
+
): Promise<SessionScan> {
|
|
1310
1415
|
const now = deps.now ?? Date.now;
|
|
1311
1416
|
const sleep = deps.sleep ?? ((ms: number) => new Promise<void>((r) => setTimeout(r, ms)));
|
|
1312
1417
|
const deadline = now() + timeoutMs;
|
|
1313
|
-
|
|
1314
|
-
|
|
1418
|
+
for (;;) {
|
|
1419
|
+
const scanned: string[] = [];
|
|
1420
|
+
const ignored: string[] = [];
|
|
1421
|
+
let names: string[] = [];
|
|
1422
|
+
try {
|
|
1423
|
+
names = readdirSync(dir);
|
|
1424
|
+
} catch {
|
|
1425
|
+
/* the directory can go away under a rotation; the next pass re-reads it */
|
|
1426
|
+
}
|
|
1427
|
+
for (const name of names.sort()) {
|
|
1428
|
+
if (!name.endsWith(".jsonl")) continue;
|
|
1429
|
+
const path = join(dir, name);
|
|
1430
|
+
let mtimeMs: number;
|
|
1431
|
+
try {
|
|
1432
|
+
mtimeMs = statSync(path).mtimeMs;
|
|
1433
|
+
} catch {
|
|
1434
|
+
continue; /* race */
|
|
1435
|
+
}
|
|
1436
|
+
const label = `${name} (mtime ${new Date(mtimeMs).toISOString()})`;
|
|
1437
|
+
if (mtimeMs < sentAt - 1_000) {
|
|
1438
|
+
ignored.push(label);
|
|
1439
|
+
continue;
|
|
1440
|
+
}
|
|
1441
|
+
scanned.push(label);
|
|
1442
|
+
if (await transcriptHasUserCode(path, code)) return { seen: true, scanned, ignored };
|
|
1443
|
+
}
|
|
1444
|
+
if (now() >= deadline) return { seen: false, scanned, ignored };
|
|
1315
1445
|
await sleep(5_000);
|
|
1316
1446
|
}
|
|
1317
|
-
return false;
|
|
1318
1447
|
}
|
|
1319
1448
|
|
|
1320
1449
|
export async function transcriptHasUserCode(path: string, code: string): Promise<boolean> {
|
|
@@ -1449,10 +1578,22 @@ function probeOmpPane(
|
|
|
1449
1578
|
* that difference stated rather than papered over.
|
|
1450
1579
|
*/
|
|
1451
1580
|
export function boundaryFromHealthz(
|
|
1452
|
-
body:
|
|
1581
|
+
body: string | undefined,
|
|
1453
1582
|
): { headline: string; detail: string[] } | undefined {
|
|
1454
|
-
|
|
1455
|
-
|
|
1583
|
+
// `healthCheck` hands back the raw response TEXT, not a parsed object — the
|
|
1584
|
+
// same shape `codeGraphFromHealthz` takes. Type-guarding this on `object`
|
|
1585
|
+
// meant it never matched, so `status` silently fell back to probing the
|
|
1586
|
+
// operator's own shell and printed "No daemon is running" at a fleet whose
|
|
1587
|
+
// daemon was up. Unit tests fed it objects and agreed with themselves.
|
|
1588
|
+
if (body === undefined || body.length === 0) return undefined;
|
|
1589
|
+
let parsed: unknown;
|
|
1590
|
+
try {
|
|
1591
|
+
parsed = JSON.parse(body);
|
|
1592
|
+
} catch {
|
|
1593
|
+
return undefined;
|
|
1594
|
+
}
|
|
1595
|
+
if (typeof parsed !== "object" || parsed === null) return undefined;
|
|
1596
|
+
const boundary = (parsed as { boundary?: unknown }).boundary;
|
|
1456
1597
|
if (typeof boundary !== "object" || boundary === null) return undefined;
|
|
1457
1598
|
const headline = (boundary as { headline?: unknown }).headline;
|
|
1458
1599
|
if (typeof headline !== "string" || headline.length === 0) return undefined;
|
package/src/omp.ts
CHANGED
|
@@ -18,14 +18,7 @@ import { createServer, type Server, type Socket } from "node:net";
|
|
|
18
18
|
import { tmpdir } from "node:os";
|
|
19
19
|
import { dirname, join } from "node:path";
|
|
20
20
|
|
|
21
|
-
import {
|
|
22
|
-
orchestratorConfinement,
|
|
23
|
-
orchestratorJailFromConfig,
|
|
24
|
-
worktreeConfinement,
|
|
25
|
-
type OrchestratorJail,
|
|
26
|
-
type OrchestratorRefusal,
|
|
27
|
-
} from "./confinement.ts";
|
|
28
|
-
import { recordConfinementRefusal } from "./confinement.ts";
|
|
21
|
+
import { worktreeConfinement } from "./confinement.ts";
|
|
29
22
|
import type { SessionBoundary } from "./credentials.ts";
|
|
30
23
|
import { releasePolicyTripwire } from "./release-policy.ts";
|
|
31
24
|
import type {
|
|
@@ -156,22 +149,6 @@ export async function createLocalSession(opts: {
|
|
|
156
149
|
releaseGrants?: ResolvedGrants;
|
|
157
150
|
/** Durable audit callback invoked only when that gate rejects a call. */
|
|
158
151
|
onReleaseBlocked?: (shape: ReleaseShape) => void;
|
|
159
|
-
/**
|
|
160
|
-
* The allowlist an orchestrator session is held to (#127). Omitted, it is
|
|
161
|
-
* derived from the config on disk — which is the only honest default, since
|
|
162
|
-
* a jail that silently resolved to "no jail" would leave the roots this gate
|
|
163
|
-
* exists to close wide open on the day a caller forgot the option.
|
|
164
|
-
*/
|
|
165
|
-
orchestratorJail?: OrchestratorJail;
|
|
166
|
-
/**
|
|
167
|
-
* Where an orchestrator confinement refusal is recorded. Defaults to the
|
|
168
|
-
* durable audit in the daemon's state directory — which is exactly why it is
|
|
169
|
-
* injectable: under `uid-pool` this function runs as a principal that cannot
|
|
170
|
-
* write that directory, so {@link runSessionHost} substitutes a forwarder and
|
|
171
|
-
* the *parent* performs the write. A refusal the operator never learns about
|
|
172
|
-
* is indistinguishable from a session that behaved (#127).
|
|
173
|
-
*/
|
|
174
|
-
onConfinementRefusal?: (refusal: OrchestratorRefusal) => void;
|
|
175
152
|
/**
|
|
176
153
|
* The conductor verb socket this session's mutation tools call (#126).
|
|
177
154
|
*
|
|
@@ -218,18 +195,12 @@ export async function createLocalSession(opts: {
|
|
|
218
195
|
// transcript is the only record of what the worker actually did.
|
|
219
196
|
const sessionManager = await openSessionManager(mod, opts);
|
|
220
197
|
const extensions = [
|
|
221
|
-
//
|
|
222
|
-
//
|
|
223
|
-
//
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
orchestratorConfinement(
|
|
228
|
-
opts.orchestratorJail ?? orchestratorJailFromConfig(),
|
|
229
|
-
opts.cwd,
|
|
230
|
-
opts.onConfinementRefusal,
|
|
231
|
-
),
|
|
232
|
-
]),
|
|
198
|
+
// Workers only. The orchestrator runs unconfined by operator ruling
|
|
199
|
+
// (#143): the gate could only ever be installed in sessions this daemon
|
|
200
|
+
// spawns, so an external orchestrator — the supported shape — never had it,
|
|
201
|
+
// and a boundary that exists in one deployment out of two is not a
|
|
202
|
+
// boundary. What holds the orchestrator is its brief and the verb ledger.
|
|
203
|
+
...(opts.role === "worker" ? [worktreeConfinement(opts.cwd)] : []),
|
|
233
204
|
...(opts.releaseGrants === undefined
|
|
234
205
|
? []
|
|
235
206
|
: [releasePolicyTripwire(opts.releaseGrants, opts.role, opts.onReleaseBlocked)]),
|
|
@@ -408,8 +379,7 @@ export interface CreateSessionOptions {
|
|
|
408
379
|
role: SessionRole;
|
|
409
380
|
releaseGrants?: ResolvedGrants;
|
|
410
381
|
onReleaseBlocked?: (shape: ReleaseShape) => void;
|
|
411
|
-
|
|
412
|
-
onConfinementRefusal?: (refusal: OrchestratorRefusal) => void;
|
|
382
|
+
|
|
413
383
|
/**
|
|
414
384
|
* The OS principal and environment this session runs behind (#125). Omitted,
|
|
415
385
|
* the child runs as the daemon's own user with the daemon's environment —
|
|
@@ -513,12 +483,6 @@ export async function createSession(opts: CreateSessionOptions): Promise<AgentSe
|
|
|
513
483
|
...(opts.model === undefined ? {} : { model: opts.model }),
|
|
514
484
|
...(opts.resume === undefined ? {} : { resume: opts.resume }),
|
|
515
485
|
...(opts.releaseGrants === undefined ? {} : { releaseGrants: opts.releaseGrants }),
|
|
516
|
-
// Resolved here rather than in the child: the child may not be able to read
|
|
517
|
-
// the config the jail is derived from, and a jail that fell back to
|
|
518
|
-
// defaults would silently widen the gate #127 closed.
|
|
519
|
-
...(opts.role === "orchestrator"
|
|
520
|
-
? { orchestratorJail: opts.orchestratorJail ?? orchestratorJailFromConfig() }
|
|
521
|
-
: {}),
|
|
522
486
|
...(opts.verbSocketPath === undefined ? {} : { verbSocketPath: opts.verbSocketPath }),
|
|
523
487
|
};
|
|
524
488
|
|
|
@@ -665,16 +629,6 @@ export async function createSession(opts: CreateSessionOptions): Promise<AgentSe
|
|
|
665
629
|
case "release-blocked":
|
|
666
630
|
opts.onReleaseBlocked?.(message.shape);
|
|
667
631
|
break;
|
|
668
|
-
case "confinement-refusal":
|
|
669
|
-
// Performed on this side because the child may not be able to write
|
|
670
|
-
// the state directory the audit lives in. Wrapped for the same reason
|
|
671
|
-
// the in-process version is: the audit is evidence, not the gate.
|
|
672
|
-
try {
|
|
673
|
-
(opts.onConfinementRefusal ?? recordConfinementRefusal)(message.refusal);
|
|
674
|
-
} catch {
|
|
675
|
-
// A full disk must not turn a deny into an allow.
|
|
676
|
-
}
|
|
677
|
-
break;
|
|
678
632
|
}
|
|
679
633
|
}
|
|
680
634
|
});
|