omp-conductor 0.16.0 → 0.16.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 +25 -0
- package/REFERENCE.md +3 -0
- package/package.json +2 -1
- package/schema/config.schema.json +1 -0
- package/src/admission.ts +111 -7
- package/src/briefs/orchestrator.md +21 -6
- package/src/briefs/policy.md +13 -5
- package/src/cli.ts +110 -381
- package/src/command-help.ts +220 -0
- package/src/command-manifest.ts +471 -0
- package/src/commands/complete.ts +93 -0
- package/src/commands/context.ts +1 -0
- package/src/commands/setup.ts +68 -2
- package/src/commands/tail.ts +204 -44
- package/src/config-schema.ts +4 -0
- package/src/config.ts +31 -1
- package/src/daemon.ts +16 -0
- package/src/depends-on.ts +77 -28
- package/src/doctor.ts +120 -1
- package/src/escalate.ts +77 -4
- package/src/fleet.ts +127 -42
- package/src/setup-host.ts +29 -11
- package/src/setup-wizard.ts +10 -9
- package/src/transcript.ts +1 -1
- package/src/upgrade-verify.ts +25 -3
- package/src/upgrade.ts +61 -2
- package/src/worker.ts +196 -0
- package/src/worktree.ts +13 -1
- package/systemd/omp-conductor-recover.sh +1 -1
- package/systemd/recover-unit-test.sh +2 -2
package/src/setup-wizard.ts
CHANGED
|
@@ -2005,7 +2005,7 @@ export async function setup(
|
|
|
2005
2005
|
areaArg?: AmendAreaId,
|
|
2006
2006
|
probes: SetupProbes = DEFAULT_PROBES,
|
|
2007
2007
|
apply: SetupApplyDeps = DEFAULT_APPLY,
|
|
2008
|
-
): Promise<
|
|
2008
|
+
): Promise<boolean> {
|
|
2009
2009
|
const path = configPath();
|
|
2010
2010
|
// A config that exists but does not parse is a fault to report, never
|
|
2011
2011
|
// something to quietly replace: overwriting it would delete every project it
|
|
@@ -2021,7 +2021,7 @@ export async function setup(
|
|
|
2021
2021
|
} catch (err) {
|
|
2022
2022
|
if (!(err instanceof Cancelled)) throw err;
|
|
2023
2023
|
ui.notify("Setup cancelled — nothing was changed.", "info");
|
|
2024
|
-
return;
|
|
2024
|
+
return false;
|
|
2025
2025
|
}
|
|
2026
2026
|
// `answers` is mutable below: the review loop re-asks one area at a time and
|
|
2027
2027
|
// replaces only the fields that area owns, so everything else carries across.
|
|
@@ -2035,7 +2035,7 @@ export async function setup(
|
|
|
2035
2035
|
`Run \`gh auth refresh -s repo,project\`, then run setup again.`,
|
|
2036
2036
|
"error",
|
|
2037
2037
|
);
|
|
2038
|
-
return;
|
|
2038
|
+
return false;
|
|
2039
2039
|
}
|
|
2040
2040
|
|
|
2041
2041
|
// Drafted here — after the interview, before the plan — because the plan's single
|
|
@@ -2183,7 +2183,7 @@ export async function setup(
|
|
|
2183
2183
|
.join(" ");
|
|
2184
2184
|
|
|
2185
2185
|
let plan = await derive();
|
|
2186
|
-
if (plan === undefined) return;
|
|
2186
|
+
if (plan === undefined) return false;
|
|
2187
2187
|
ui.notify(planBlock(plan), "info");
|
|
2188
2188
|
// -------- the consent gate (#417): a review loop, not a yes/no --------
|
|
2189
2189
|
//
|
|
@@ -2206,12 +2206,12 @@ export async function setup(
|
|
|
2206
2206
|
);
|
|
2207
2207
|
if (choice === undefined) {
|
|
2208
2208
|
ui.notify("Setup cancelled — nothing was changed, and the answers were discarded.", "info");
|
|
2209
|
-
return;
|
|
2209
|
+
return false;
|
|
2210
2210
|
}
|
|
2211
2211
|
if (choice === REVIEW_APPLY) break;
|
|
2212
2212
|
if (choice === REVIEW_EXIT) {
|
|
2213
2213
|
ui.notify("Setup exited without applying — the answers were discarded and nothing was changed.", "info");
|
|
2214
|
-
return;
|
|
2214
|
+
return false;
|
|
2215
2215
|
}
|
|
2216
2216
|
if (choice === REVIEW_REVIEW) {
|
|
2217
2217
|
ui.notify(planBlock(plan), "info");
|
|
@@ -2259,7 +2259,7 @@ export async function setup(
|
|
|
2259
2259
|
proseDrafted = true;
|
|
2260
2260
|
}
|
|
2261
2261
|
const redrafted = await derive();
|
|
2262
|
-
if (redrafted === undefined) return;
|
|
2262
|
+
if (redrafted === undefined) return false;
|
|
2263
2263
|
plan = redrafted;
|
|
2264
2264
|
ui.notify(`Edited "${area.label}" — the plan below is recomputed from the new answers.`, "info");
|
|
2265
2265
|
ui.notify(planBlock(plan), "info");
|
|
@@ -2302,7 +2302,7 @@ export async function setup(
|
|
|
2302
2302
|
// enables the timer, which does not touch the running daemon.
|
|
2303
2303
|
await offerCodeGraph(plan.project, ui, apply.graphInstall);
|
|
2304
2304
|
ui.notify(formatHerdrHandoff(plan.project, plan.nextConfig), "info");
|
|
2305
|
-
return;
|
|
2305
|
+
return false;
|
|
2306
2306
|
}
|
|
2307
2307
|
// No live workers: still prefer an explicit reload when this run *added* a
|
|
2308
2308
|
// neighbour — auto-restart would bounce every other project's heartbeat for
|
|
@@ -2342,7 +2342,7 @@ export async function setup(
|
|
|
2342
2342
|
"warning",
|
|
2343
2343
|
);
|
|
2344
2344
|
ui.notify(formatHerdrHandoff(plan.project, plan.nextConfig), "info");
|
|
2345
|
-
return;
|
|
2345
|
+
return false;
|
|
2346
2346
|
}
|
|
2347
2347
|
}
|
|
2348
2348
|
apply.resume(plan.project.name);
|
|
@@ -2409,4 +2409,5 @@ export async function setup(
|
|
|
2409
2409
|
// to create/verify the herdr pane. Added projects especially — the CLI wrote
|
|
2410
2410
|
// tick + config but cannot start a herdr agent (#319).
|
|
2411
2411
|
ui.notify(formatHerdrHandoff(plan.project, plan.nextConfig), "info");
|
|
2412
|
+
return true;
|
|
2412
2413
|
}
|
package/src/transcript.ts
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
/** Read one property off an unvalidated transcript entry. */
|
|
2
|
-
function prop(source: unknown, key: string): unknown {
|
|
2
|
+
export function prop(source: unknown, key: string): unknown {
|
|
3
3
|
if (source === null || typeof source !== "object") return undefined;
|
|
4
4
|
return Reflect.get(source, key);
|
|
5
5
|
}
|
package/src/upgrade-verify.ts
CHANGED
|
@@ -382,6 +382,19 @@ function resumeUpgradePause(deps: UpgradeVerifyDeps, request: PendingUpgradeRequ
|
|
|
382
382
|
}
|
|
383
383
|
}
|
|
384
384
|
|
|
385
|
+
/**
|
|
386
|
+
* Doctor findings that only a privileged `setup host` can clear — the systemd
|
|
387
|
+
* units (and the recovery pair they name), and the herdr pane-shell key. A
|
|
388
|
+
* release that re-renders any of those templates fails them on every host
|
|
389
|
+
* whose `setup host` has not been re-run, so they are not rollback-worthy
|
|
390
|
+
* regression evidence: the upgrade is deliberately unable to perform the
|
|
391
|
+
* privileged install, and rolling back over drift it cannot clear would
|
|
392
|
+
* strand the fleet on the previous release with the same outstanding `setup
|
|
393
|
+
* host` (#598). The verifier escalates them instead — the doctor check's
|
|
394
|
+
* detail and the verify report name the drift and its fix.
|
|
395
|
+
*/
|
|
396
|
+
const PRIVILEGED_INSTALL_DRIFT = new Set(["systemd-unit", "systemd-recovery", "herdr-resume"]);
|
|
397
|
+
|
|
385
398
|
/**
|
|
386
399
|
* The independent checks the returning process runs. Each compares a live
|
|
387
400
|
* fact with the journal's record of the world the install began in:
|
|
@@ -392,7 +405,9 @@ function resumeUpgradePause(deps: UpgradeVerifyDeps, request: PendingUpgradeRequ
|
|
|
392
405
|
* same predicate the in-process upgrade used, so a fleet that was armed
|
|
393
406
|
* stays armed and a live pane stays live;
|
|
394
407
|
* - `health` — the daemon that came back answers `/healthz`;
|
|
395
|
-
* - `doctor` — no failing finding
|
|
408
|
+
* - `doctor` — no failing finding beyond the privileged-install drift
|
|
409
|
+
* ({@link PRIVILEGED_INSTALL_DRIFT}) only `setup host` can clear, which is
|
|
410
|
+
* escalated rather than rolled back.
|
|
396
411
|
*/
|
|
397
412
|
export async function runUpgradeChecks(
|
|
398
413
|
deps: UpgradeVerifyDeps,
|
|
@@ -423,10 +438,17 @@ export async function runUpgradeChecks(
|
|
|
423
438
|
|
|
424
439
|
const doctor = await deps.doctor(deps.projectName);
|
|
425
440
|
const failing = doctor.findings.filter((finding) => finding.status === "fail");
|
|
441
|
+
const drift = failing.filter((finding) => PRIVILEGED_INSTALL_DRIFT.has(finding.id));
|
|
442
|
+
const regression = failing.filter((finding) => !PRIVILEGED_INSTALL_DRIFT.has(finding.id));
|
|
426
443
|
checks.push({
|
|
427
444
|
name: "doctor",
|
|
428
|
-
ok:
|
|
429
|
-
detail:
|
|
445
|
+
ok: regression.length === 0,
|
|
446
|
+
detail:
|
|
447
|
+
regression.length > 0
|
|
448
|
+
? `${regression[0]!.id}: ${regression[0]!.summary}`
|
|
449
|
+
: drift.length > 0
|
|
450
|
+
? `status ${doctor.status}; ${drift.map((f) => f.id).join(", ")} — privileged-install drift, run \`omp-conductor setup host\` from the fleet account`
|
|
451
|
+
: `status ${doctor.status}`,
|
|
430
452
|
});
|
|
431
453
|
}
|
|
432
454
|
|
package/src/upgrade.ts
CHANGED
|
@@ -14,9 +14,9 @@ import { livingDaemon, restartDaemon } from "./lifecycle.ts";
|
|
|
14
14
|
import { configBackupDir, configPath, findProject, loadConfig, resolveCaps, stateDir, writeConfigRaw } from "./config.ts";
|
|
15
15
|
import { renderBriefForProject } from "./setup.ts";
|
|
16
16
|
import {
|
|
17
|
-
STAGED_SERVICE_NAME,
|
|
18
17
|
planHostRuntime,
|
|
19
|
-
|
|
18
|
+
totalConfiguredWorkers,
|
|
19
|
+
type HostRuntimePlan,
|
|
20
20
|
} from "./setup-host.ts";
|
|
21
21
|
import {
|
|
22
22
|
appendJournal,
|
|
@@ -95,6 +95,17 @@ export interface UpgradeDeps {
|
|
|
95
95
|
sleep(ms: number): Promise<void>;
|
|
96
96
|
env: NodeJS.ProcessEnv;
|
|
97
97
|
log(message: string): void;
|
|
98
|
+
/**
|
|
99
|
+
* The host runtime this version's package renders, compared against what is
|
|
100
|
+
* actually installed: the daemon and herdr units, the recovery unit and its
|
|
101
|
+
* playbook at {@link RECOVER_SCRIPT_INSTALL_PATH}, the herdr pane-shell
|
|
102
|
+
* config and the herdr-conductor `config.env`. `upgrade` prints
|
|
103
|
+
* {@link HostRuntimePlan.drift} after a release that re-rendered those
|
|
104
|
+
* files, so the operator is told that `setup host` is owed before a later
|
|
105
|
+
* `doctor` has to discover it. Read-only: the plan renders and compares,
|
|
106
|
+
* it never writes a host file.
|
|
107
|
+
*/
|
|
108
|
+
hostRuntime(): HostRuntimePlan;
|
|
98
109
|
/**
|
|
99
110
|
* The durable journal sink for the detached fleet installer (#486). Wired
|
|
100
111
|
* to the state-dir journal by `upgrade-install`/`upgrade-rollback`; absent
|
|
@@ -135,6 +146,13 @@ export const DEFAULT_DEPS: UpgradeDeps = {
|
|
|
135
146
|
restartDaemon: async () => {
|
|
136
147
|
await restartDaemon({});
|
|
137
148
|
},
|
|
149
|
+
// The bare host-global plan — the same render the advisory's `setup host`
|
|
150
|
+
// command would install: no per-project tail, the recovery unit encoding no
|
|
151
|
+
// one project's name, and FLEET_CWDS derived from every configured project.
|
|
152
|
+
hostRuntime: () => {
|
|
153
|
+
const cfg = loadConfig();
|
|
154
|
+
return planHostRuntime(undefined, cfg.defaults, telegramStateDir(), undefined, totalConfiguredWorkers(cfg));
|
|
155
|
+
},
|
|
138
156
|
sleep: Bun.sleep,
|
|
139
157
|
env: process.env,
|
|
140
158
|
log: (message) => process.stdout.write(`${message}\n`),
|
|
@@ -821,6 +839,37 @@ export async function rollbackUpgrade(
|
|
|
821
839
|
if (failures.length > 0) throw new Error(failures.join("; "));
|
|
822
840
|
}
|
|
823
841
|
|
|
842
|
+
/**
|
|
843
|
+
* Name the host-runtime destinations this version's render no longer matches,
|
|
844
|
+
* after a successful upgrade — the installed units, the recovery playbook at
|
|
845
|
+
* {@link RECOVER_SCRIPT_INSTALL_PATH}, the herdr pane-shell config and the
|
|
846
|
+
* herdr-conductor `config.env`. A release that re-renders any of those files
|
|
847
|
+
* silently invalidates the installed copies; telling the operator here — with
|
|
848
|
+
* the exact command — is what stops the drift from surviving until a later
|
|
849
|
+
* `doctor` finds it (#598).
|
|
850
|
+
*
|
|
851
|
+
* The plan *is* the comparison: {@link UpgradeDeps.hostRuntime} renders every
|
|
852
|
+
* destination against disk, so a current host prints nothing and a drifted one
|
|
853
|
+
* names exactly the files only a privileged `setup host` can refresh. This
|
|
854
|
+
* never mutates a host file and never fails the upgrade — an unreadable host
|
|
855
|
+
* state is a warning line, not a reason to roll a successful install back.
|
|
856
|
+
*/
|
|
857
|
+
function logHostRuntimeDrift(deps: UpgradeDeps): void {
|
|
858
|
+
let plan: HostRuntimePlan;
|
|
859
|
+
try {
|
|
860
|
+
plan = deps.hostRuntime();
|
|
861
|
+
} catch (err) {
|
|
862
|
+
deps.log(
|
|
863
|
+
`host runtime: could not compare against this version's render — ${err instanceof Error ? err.message : String(err)}`,
|
|
864
|
+
);
|
|
865
|
+
return;
|
|
866
|
+
}
|
|
867
|
+
if (plan.drift.length === 0) return;
|
|
868
|
+
deps.log("host runtime:");
|
|
869
|
+
for (const path of plan.drift) deps.log(` ${path} differs from this version's render`);
|
|
870
|
+
deps.log("fix: run `omp-conductor setup host` from the fleet account to re-install the host units");
|
|
871
|
+
}
|
|
872
|
+
|
|
824
873
|
export async function upgradeConductor(
|
|
825
874
|
options: UpgradeOptions = {},
|
|
826
875
|
overrides: Partial<UpgradeDeps> = {},
|
|
@@ -871,6 +920,11 @@ export async function upgradeConductor(
|
|
|
871
920
|
detail: "all three surfaces and every brief were already pinned to the target release",
|
|
872
921
|
});
|
|
873
922
|
}
|
|
923
|
+
// Packages are current, but the host units they render may be behind: the
|
|
924
|
+
// same drift this advisory names after an install applies when a release
|
|
925
|
+
// re-rendered a template since the operator's last `setup host`. Silent on
|
|
926
|
+
// a host whose runtime matches (#598).
|
|
927
|
+
logHostRuntimeDrift(deps);
|
|
874
928
|
return {
|
|
875
929
|
previousVersion: surfaces.cliVersion,
|
|
876
930
|
...release,
|
|
@@ -1118,6 +1172,11 @@ export async function upgradeConductor(
|
|
|
1118
1172
|
throw new Error(`upgrade failed: ${failure}; previous installation restored; dispatch remains paused`);
|
|
1119
1173
|
}
|
|
1120
1174
|
|
|
1175
|
+
// The install landed; name any host-runtime destination this version no
|
|
1176
|
+
// longer renders identically, so the operator knows `setup host` is owed
|
|
1177
|
+
// before a later `doctor` reports it as drift (#598).
|
|
1178
|
+
logHostRuntimeDrift(deps);
|
|
1179
|
+
|
|
1121
1180
|
return {
|
|
1122
1181
|
previousVersion: surfaces.cliVersion,
|
|
1123
1182
|
...release,
|
package/src/worker.ts
CHANGED
|
@@ -10,6 +10,8 @@
|
|
|
10
10
|
* sliding into a merge queue.
|
|
11
11
|
*/
|
|
12
12
|
|
|
13
|
+
import { mkdirSync, readFileSync, readdirSync, writeFileSync } from "node:fs";
|
|
14
|
+
import { join } from "node:path";
|
|
13
15
|
import { createSession, disposeSession, SessionAdmissionClosedError, type AgentSessionLike } from "./omp.ts";
|
|
14
16
|
import type { GateShape, ReleaseBlockContext } from "./release-policy.ts";
|
|
15
17
|
import type { Caps, ResolvedGrants, RunState } from "./types.ts";
|
|
@@ -321,6 +323,17 @@ export async function runWorker(
|
|
|
321
323
|
const schedule = deps.schedule ?? scheduleWallClock;
|
|
322
324
|
|
|
323
325
|
let session: AgentSessionLike;
|
|
326
|
+
// The per-issue reviewer brief (#542): when the staged omp settings turn the
|
|
327
|
+
// advisor on, drop a WATCHDOG.md rendered from this brief's own acceptance
|
|
328
|
+
// criteria into the worktree before the session exists — the advisor's
|
|
329
|
+
// watchdog discovery runs at session start, rooted at cwd, and a file that
|
|
330
|
+
// appears afterwards is a reviewer flying blind. The managed exclude block
|
|
331
|
+
// ignores that exact path (never `.omp/`), so it cannot reach the PR diff or
|
|
332
|
+
// a salvage commit. Best-effort: a reviewer brief is advisory, and a staging
|
|
333
|
+
// failure must not cost the run.
|
|
334
|
+
if (o.resume === undefined && o.ompSettingsFile !== undefined) {
|
|
335
|
+
stageIssueWatchdog(o.cwd, o.brief, o.ompSettingsFile);
|
|
336
|
+
}
|
|
324
337
|
try {
|
|
325
338
|
session = await deps.createSession({
|
|
326
339
|
cwd: o.cwd,
|
|
@@ -384,12 +397,33 @@ export async function runWorker(
|
|
|
384
397
|
| "modelRecoveries"
|
|
385
398
|
| "autoRetryCount"
|
|
386
399
|
| "autoCompactionCount";
|
|
400
|
+
// Folded once, on whichever exit path actually runs: every return below
|
|
401
|
+
// passes through `withSessionFacts`, so the advisor's separately-recorded
|
|
402
|
+
// spend (#542) is added to exactly one result and reported to the daemon
|
|
403
|
+
// exactly once.
|
|
404
|
+
let advisorSpendFolded = false;
|
|
387
405
|
const withSessionFacts = (
|
|
388
406
|
result: Omit<WorkerResult, ReliabilityKeys>,
|
|
389
407
|
): Omit<WorkerResult, ReliabilityKeys> => {
|
|
390
408
|
const { sessionFile, modelFallbackMessage } = session;
|
|
409
|
+
if (!advisorSpendFolded) {
|
|
410
|
+
advisorSpendFolded = true;
|
|
411
|
+
// Advisor turns live in their own `__advisor*.jsonl` and never surface
|
|
412
|
+
// as primary `message_end`s, so without this read the run's spend — and
|
|
413
|
+
// therefore `caps.dailySpendUsd` — silently under-counts real provider
|
|
414
|
+
// consumption (the #46 shape). Read at the very end: the advisor's
|
|
415
|
+
// review of the final turn may still be landing.
|
|
416
|
+
const extra = advisorSpendUsd(sessionFile);
|
|
417
|
+
if (extra > 0) {
|
|
418
|
+
spendUsd += extra;
|
|
419
|
+
o.onSpend?.(spendUsd);
|
|
420
|
+
}
|
|
421
|
+
}
|
|
391
422
|
return {
|
|
392
423
|
...result,
|
|
424
|
+
// Override whatever the exit path spelled: its literal captured the
|
|
425
|
+
// pre-fold `spendUsd`, and the advisor fold happened after.
|
|
426
|
+
spendUsd,
|
|
393
427
|
...(sessionFile === undefined ? {} : { sessionFile }),
|
|
394
428
|
...(modelFallbackMessage === undefined ? {} : { modelFallbackMessage }),
|
|
395
429
|
};
|
|
@@ -807,6 +841,168 @@ function field(source: unknown, key: string): unknown {
|
|
|
807
841
|
return Reflect.get(source, key);
|
|
808
842
|
}
|
|
809
843
|
|
|
844
|
+
/** Advisor transcripts are recorded under this reserved stem beside the session's. */
|
|
845
|
+
const ADVISOR_TRANSCRIPT_PREFIX = "__advisor";
|
|
846
|
+
|
|
847
|
+
/**
|
|
848
|
+
* Whether a staged omp-settings overlay (the file `materializeOmpSettings`
|
|
849
|
+
* writes under the run's session directory) turns the omp advisor on.
|
|
850
|
+
*
|
|
851
|
+
* The overlay is conductor's own YAML — written by `yaml.stringify` over a map
|
|
852
|
+
* this package controls — so this is deliberately not a general YAML parser:
|
|
853
|
+
* it scans for an `advisor:` mapping and asks whether one of its direct
|
|
854
|
+
* `enabled:` keys reads the literal `true`. Handles both the block form the
|
|
855
|
+
* overlay is written in (`advisor:\n enabled: true`) and an inline flow map
|
|
856
|
+
* (`advisor: { enabled: true }`); everything else — `enabled: false`, no
|
|
857
|
+
* `advisor` key, an absent overlay — answers false. The staged settings are
|
|
858
|
+
* what the harness itself resolves, so this is the same truth the session
|
|
859
|
+
* acts on, not a parallel decode. Exported so the staging decision in
|
|
860
|
+
* {@link runWorker} is pinned by a unit test.
|
|
861
|
+
*/
|
|
862
|
+
export function overlayEnablesAdvisor(overlayText: string): boolean {
|
|
863
|
+
const lines = overlayText.split(/\r?\n/);
|
|
864
|
+
for (let i = 0; i < lines.length; i++) {
|
|
865
|
+
const match = /^(\s*)advisor\s*:\s*(.*)$/.exec(lines[i] ?? "");
|
|
866
|
+
if (match === null) continue;
|
|
867
|
+
if (/\benabled\s*:\s*true\b/.test(match[2] ?? "")) return true;
|
|
868
|
+
const indent = match[1] ?? "";
|
|
869
|
+
for (let j = i + 1; j < lines.length; j++) {
|
|
870
|
+
const line = lines[j];
|
|
871
|
+
if (line === undefined || line.trim() === "") continue;
|
|
872
|
+
// A line at or shallower than `advisor`'s own indent ends its block.
|
|
873
|
+
if (!line.startsWith(`${indent} `) && !line.startsWith(`${indent}\t`)) break;
|
|
874
|
+
if (/^\s*enabled\s*:\s*true\s*$/.test(line)) return true;
|
|
875
|
+
}
|
|
876
|
+
}
|
|
877
|
+
return false;
|
|
878
|
+
}
|
|
879
|
+
|
|
880
|
+
/** A heading that begins a brief's acceptance section, whatever the brief's casing. */
|
|
881
|
+
const ACCEPTANCE_HEADING = /^#{1,4}\s+acceptance\s+criteri(?:a|on)\s*$/im;
|
|
882
|
+
|
|
883
|
+
/**
|
|
884
|
+
* Render the per-issue reviewer brief (#542): the acceptance section of one
|
|
885
|
+
* worker brief, wrapped so the omp advisor checks the primary against *this
|
|
886
|
+
* run's own* criteria rather than generic taste. Returns `undefined` when the
|
|
887
|
+
* brief carries no acceptance section — nothing useful to review against.
|
|
888
|
+
*
|
|
889
|
+
* The section runs from the first heading mentioning acceptance criteria to
|
|
890
|
+
* the next heading of any level (or the end of the brief). Exported so a unit
|
|
891
|
+
* test can pin the rendering without standing up a session.
|
|
892
|
+
*/
|
|
893
|
+
export function renderIssueWatchdog(brief: string): string | undefined {
|
|
894
|
+
const lines = brief.split(/\r?\n/);
|
|
895
|
+
let start = -1;
|
|
896
|
+
for (let i = 0; i < lines.length; i++) {
|
|
897
|
+
if (ACCEPTANCE_HEADING.test(lines[i] ?? "")) {
|
|
898
|
+
start = i + 1;
|
|
899
|
+
break;
|
|
900
|
+
}
|
|
901
|
+
}
|
|
902
|
+
if (start === -1) return undefined;
|
|
903
|
+
const body: string[] = [];
|
|
904
|
+
for (let i = start; i < lines.length; i++) {
|
|
905
|
+
const line = lines[i];
|
|
906
|
+
if (line === undefined || /^#{1,4}\s/.test(line)) break;
|
|
907
|
+
body.push(line);
|
|
908
|
+
}
|
|
909
|
+
const trimmed = body.join("\n").replace(/\n{3,}/g, "\n\n").trim();
|
|
910
|
+
if (trimmed === "") return undefined;
|
|
911
|
+
return (
|
|
912
|
+
"# Issue watchdog (rendered from this run's brief)\n\n" +
|
|
913
|
+
"This run's own acceptance criteria. Review the worker against these — not generic taste: " +
|
|
914
|
+
"a criterion is met only when the transcript or workspace shows it actually verified, and a " +
|
|
915
|
+
"worker claiming completion without evidence is a concern at least.\n\n" +
|
|
916
|
+
"## Acceptance criteria\n\n" +
|
|
917
|
+
`${trimmed}\n`
|
|
918
|
+
);
|
|
919
|
+
}
|
|
920
|
+
|
|
921
|
+
/**
|
|
922
|
+
* Stage the per-issue `WATCHDOG.md` into the worktree when the staged omp
|
|
923
|
+
* settings turn the advisor on (#542).
|
|
924
|
+
*
|
|
925
|
+
* The omp advisor's watchdog discovery is cwd-rooted, so per-issue guidance
|
|
926
|
+
* cannot ride the out-of-tree settings overlay: it must live inside the
|
|
927
|
+
* worktree, at `<cwd>/.omp/WATCHDOG.md`, which the managed exclude block
|
|
928
|
+
* ignores by that exact path (never the `.omp/` directory) so it can never
|
|
929
|
+
* reach the diff a worker ships or a salvage commit. Best-effort on purpose:
|
|
930
|
+
* a reviewer brief is advisory, and a worktree too broken to take it will fail
|
|
931
|
+
* the run on its own terms. Exported so the runWorker staging decision is
|
|
932
|
+
* testable without a session.
|
|
933
|
+
*/
|
|
934
|
+
export function stageIssueWatchdog(cwd: string, brief: string, overlayFile: string): boolean {
|
|
935
|
+
let overlayText: string;
|
|
936
|
+
try {
|
|
937
|
+
overlayText = readFileSync(overlayFile, "utf8");
|
|
938
|
+
} catch {
|
|
939
|
+
return false;
|
|
940
|
+
}
|
|
941
|
+
if (!overlayEnablesAdvisor(overlayText)) return false;
|
|
942
|
+
const watchdog = renderIssueWatchdog(brief);
|
|
943
|
+
if (watchdog === undefined) return false;
|
|
944
|
+
try {
|
|
945
|
+
const dir = join(cwd, ".omp");
|
|
946
|
+
mkdirSync(dir, { recursive: true });
|
|
947
|
+
writeFileSync(join(dir, "WATCHDOG.md"), watchdog);
|
|
948
|
+
return true;
|
|
949
|
+
} catch {
|
|
950
|
+
return false;
|
|
951
|
+
}
|
|
952
|
+
}
|
|
953
|
+
|
|
954
|
+
/**
|
|
955
|
+
* Total USD cost of one run's advisor turns (#542).
|
|
956
|
+
*
|
|
957
|
+
* Advisor turns are recorded to a separate `__advisor*.jsonl` beside the
|
|
958
|
+
* session transcript — the primary session never sees them as `message_end`s —
|
|
959
|
+
* so without this a run with an advisor under-counts `spendUsd` and the daily
|
|
960
|
+
* cap is theater, the #46 failure mode. Reads every advisor transcript in the
|
|
961
|
+
* session's advisor directory and sums the same `usage.cost` blocks
|
|
962
|
+
* {@link costUsdFromMessage} reads off primary messages: one spelling, so the
|
|
963
|
+
* two cannot drift. Unreadable or absent transcripts bill nothing — the run
|
|
964
|
+
* with no advisor stays a zero, and a corrupt advisor log must not crash the
|
|
965
|
+
* settlement.
|
|
966
|
+
*/
|
|
967
|
+
export function advisorSpendUsd(sessionFile: string | undefined): number {
|
|
968
|
+
if (sessionFile === undefined || !sessionFile.endsWith(".jsonl")) return 0;
|
|
969
|
+
// The harness records advisor transcripts in the directory named after the
|
|
970
|
+
// primary transcript stem (`<dir>/<stem>/__advisor*.jsonl`); slicing the
|
|
971
|
+
// suffix is exactly what the harness's own cost loader does.
|
|
972
|
+
const dir = sessionFile.slice(0, -".jsonl".length);
|
|
973
|
+
let names: string[];
|
|
974
|
+
try {
|
|
975
|
+
names = readdirSync(dir);
|
|
976
|
+
} catch {
|
|
977
|
+
return 0;
|
|
978
|
+
}
|
|
979
|
+
let total = 0;
|
|
980
|
+
for (const name of names) {
|
|
981
|
+
if (!name.startsWith(ADVISOR_TRANSCRIPT_PREFIX) || !name.endsWith(".jsonl")) continue;
|
|
982
|
+
let text: string;
|
|
983
|
+
try {
|
|
984
|
+
text = readFileSync(join(dir, name), "utf8");
|
|
985
|
+
} catch {
|
|
986
|
+
continue;
|
|
987
|
+
}
|
|
988
|
+
for (const line of text.split("\n")) {
|
|
989
|
+
if (line.trim() === "") continue;
|
|
990
|
+
let entry: unknown;
|
|
991
|
+
try {
|
|
992
|
+
entry = JSON.parse(line);
|
|
993
|
+
} catch {
|
|
994
|
+
continue;
|
|
995
|
+
}
|
|
996
|
+
if (field(entry, "type") !== "message") continue;
|
|
997
|
+
const message = field(entry, "message");
|
|
998
|
+
if (field(message, "role") !== "assistant") continue;
|
|
999
|
+
const cost = costUsdFromMessage(message);
|
|
1000
|
+
if (cost !== undefined) total += cost;
|
|
1001
|
+
}
|
|
1002
|
+
}
|
|
1003
|
+
return total;
|
|
1004
|
+
}
|
|
1005
|
+
|
|
810
1006
|
/** Flatten an assistant message's content blocks to their plain text. */
|
|
811
1007
|
/**
|
|
812
1008
|
* The newest assistant text, flattened out of whatever block shape the harness
|
package/src/worktree.ts
CHANGED
|
@@ -156,8 +156,20 @@ const EXCLUDE_END = "# <<< omp-conductor";
|
|
|
156
156
|
* the 2026-08-07 incident's exact shape (`.scratch82/env.sh`). Broader
|
|
157
157
|
* conventions belong in a repo's own `.gitignore`, where its operator chooses
|
|
158
158
|
* them, rather than being imposed by whatever dispatcher happens to be driving.
|
|
159
|
+
*
|
|
160
|
+
* The advisor watchdog (`#542`) is the deliberate exception, and the exact-path
|
|
161
|
+
* spelling is the whole point: with the advisor on, the dispatcher writes a
|
|
162
|
+
* per-issue `<worktree>/.omp/WATCHDOG.md` from the brief's acceptance criteria
|
|
163
|
+
* so the mid-run reviewer checks the worker against its own issue — and that
|
|
164
|
+
* file must never reach the diff a worker ships or the salvage commit. The
|
|
165
|
+
* standing rule says an ignored *new* file is invisible to salvage, so a name
|
|
166
|
+
* that could plausibly be a deliverable must never appear; `.omp/WATCHDOG.md`
|
|
167
|
+
* is the narrowest name that can. Only this exact file is excluded, never the
|
|
168
|
+
* `.omp/` directory — a directory-wide ignore would hide any future
|
|
169
|
+
* deliverable an operator legitimately places under `.omp/` (the very trap the
|
|
170
|
+
* `.scratch*` history describes), and the blind spot stays one path wide.
|
|
159
171
|
*/
|
|
160
|
-
const LOCAL_EXCLUDE = [".scratch*/"];
|
|
172
|
+
const LOCAL_EXCLUDE = [".scratch*/", ".omp/WATCHDOG.md"];
|
|
161
173
|
|
|
162
174
|
/**
|
|
163
175
|
* Adds the managed block to an `info/exclude`, preserving everything else.
|
|
@@ -273,7 +273,7 @@ fresh_rollback_snapshot() {
|
|
|
273
273
|
newest=$(ls -t "$BACKUP_ROOT"/config.json.pre-upgrade-* 2>/dev/null | head -n 1 || true)
|
|
274
274
|
[[ -n $newest ]] || return 1
|
|
275
275
|
now=$(epoch_now)
|
|
276
|
-
ts=$(stat -c %Y "$newest" 2>/dev/null || true)
|
|
276
|
+
ts=$(stat -c %Y "$newest" 2>/dev/null || stat -f %m "$newest" 2>/dev/null || true)
|
|
277
277
|
[[ -n $ts && $((now - ts)) -le $ROLLBACK_AGE_S ]] || return 1
|
|
278
278
|
printf '%s\n' "$newest"
|
|
279
279
|
}
|
|
@@ -126,14 +126,14 @@ unit_state() { # <case-dir> <unit> <state|absent>
|
|
|
126
126
|
}
|
|
127
127
|
|
|
128
128
|
# A pre-upgrade snapshot `upgrade` durably keeps (configBackupDir()):
|
|
129
|
-
# config.json.pre-upgrade-<ts>. The test
|
|
129
|
+
# config.json.pre-upgrade-<ts>. The test passes the age in seconds.
|
|
130
130
|
write_snapshot() { # <case-dir> <age-seconds>
|
|
131
131
|
local d="$1" age="${2:-0}" now
|
|
132
132
|
now=$(date +%s)
|
|
133
133
|
local snap="$d/state/backups/config/config.json.pre-upgrade-$now"
|
|
134
134
|
printf '%s\n' '{"preUpgrade":true}' >"$snap"
|
|
135
135
|
if (( age > 0 )); then
|
|
136
|
-
|
|
136
|
+
perl -e 'utime $ARGV[1], $ARGV[1], $ARGV[0] or die "utime: $!"' "$snap" "$(( now - age ))"
|
|
137
137
|
fi
|
|
138
138
|
printf '%s\n' "$snap"
|
|
139
139
|
}
|