omp-conductor 0.16.0 → 0.16.1
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/package.json +1 -1
- package/schema/config.schema.json +1 -0
- package/src/admission.ts +111 -7
- 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/transcript.ts +1 -1
- package/src/worker.ts +196 -0
- package/src/worktree.ts +13 -1
package/src/fleet.ts
CHANGED
|
@@ -26,10 +26,10 @@ import {
|
|
|
26
26
|
} from "node:fs";
|
|
27
27
|
import { createInterface } from "node:readline";
|
|
28
28
|
import { homedir } from "node:os";
|
|
29
|
-
import { dirname, join } from "node:path";
|
|
29
|
+
import { dirname, join, sep } from "node:path";
|
|
30
30
|
import { findProject, loadConfig, stateDir } from "./config.ts";
|
|
31
31
|
import { clearArmChallenge, recordArmChallenge } from "./arm-challenge.ts";
|
|
32
|
-
import { resolveProjectTopicId, sendTelegram } from "./escalate.ts";
|
|
32
|
+
import { resolveClaimedSessionFile, resolveProjectTopicId, sendTelegram } from "./escalate.ts";
|
|
33
33
|
import { readPlanUsage, sharedUsageSource } from "./usage.ts";
|
|
34
34
|
import { readApprovalSurface, readDaemonProfile } from "./approval-surface.ts";
|
|
35
35
|
import { inspectBriefLayout } from "./brief-upgrade.ts";
|
|
@@ -287,13 +287,22 @@ export interface ArmResult {
|
|
|
287
287
|
export interface ArmDeps {
|
|
288
288
|
sendChallenge?: (token: string, owner: string, text: string, topicId?: number) => Promise<void>;
|
|
289
289
|
/**
|
|
290
|
-
*
|
|
291
|
-
*
|
|
290
|
+
* The orchestrator session file the live omp-telegram claim names for this
|
|
291
|
+
* project, or undefined when there is no (readable) claim. The default
|
|
292
|
+
* resolves the claim the same way the send does; tests inject a fixture.
|
|
293
|
+
* Absent → arm watches the cwd-derived session directory, the pre-claim
|
|
294
|
+
* behaviour.
|
|
295
|
+
*/
|
|
296
|
+
claimedSessionFile?: () => string | undefined;
|
|
297
|
+
/**
|
|
298
|
+
* Waits for the challenge to appear as a user turn somewhere under the scan
|
|
299
|
+
* directories. The waiter owns transcript discovery — not the caller —
|
|
292
300
|
* because the reply may land in a session that starts *after* the send, so a
|
|
293
301
|
* path resolved before the challenge went out can be the wrong file by the
|
|
294
|
-
* time the operator answers (#142)
|
|
302
|
+
* time the operator answers (#142), and because the claimed session file can
|
|
303
|
+
* sit in a different directory than the tick cwd implies (#600).
|
|
295
304
|
*/
|
|
296
|
-
waitForUserTurn?: (
|
|
305
|
+
waitForUserTurn?: (dirs: readonly string[], code: string, sentAt: number, timeoutMs: number) => Promise<boolean>;
|
|
297
306
|
now?: () => number;
|
|
298
307
|
sleep?: (ms: number) => Promise<void>;
|
|
299
308
|
timeoutMs?: number;
|
|
@@ -332,10 +341,23 @@ export async function armTicks(projectName?: string, deps: ArmDeps = {}): Promis
|
|
|
332
341
|
);
|
|
333
342
|
}
|
|
334
343
|
|
|
335
|
-
|
|
336
|
-
|
|
344
|
+
// One bot, one chat, and — once a host runs more than one fleet — more than
|
|
345
|
+
// one pane that can ask. The challenge names which one, or the operator is
|
|
346
|
+
// answering a question they cannot attribute.
|
|
347
|
+
const named = tick.config.project ?? projectName;
|
|
348
|
+
|
|
349
|
+
// Resolve the orchestrator's live session file *before* the challenge goes
|
|
350
|
+
// out — the same claim the send follows (#600). A pane resumed from a
|
|
351
|
+
// session created elsewhere (herdr pins it to the original transcript)
|
|
352
|
+
// writes a session file outside the directory the tick cwd implies, and a
|
|
353
|
+
// cwd-derived scan would poll the one place the reply is guaranteed not to
|
|
354
|
+
// be. A claim outside the session tree arm scans can never be answered, so
|
|
355
|
+
// that is a stop, not five minutes of polling.
|
|
356
|
+
const claimed = deps.claimedSessionFile !== undefined ? deps.claimedSessionFile() : claimedOrchestratorSessionFile(named);
|
|
357
|
+
const dirs = armSessionScanDirs(tick.cwd, claimed);
|
|
358
|
+
if (dirs.every((d) => !existsSync(d))) {
|
|
337
359
|
throw new Error(
|
|
338
|
-
`no orchestrator session directory
|
|
360
|
+
`no orchestrator session directory under ${dirs.join(" or ")} — ` +
|
|
339
361
|
`the inbound proof is read from a user turn in a transcript there. Start the pane orchestrator, let it settle, then arm again`,
|
|
340
362
|
);
|
|
341
363
|
}
|
|
@@ -347,10 +369,6 @@ export async function armTicks(projectName?: string, deps: ArmDeps = {}): Promis
|
|
|
347
369
|
const arm = resolveArmState(path, tick.config.project ?? projectName);
|
|
348
370
|
const alreadyArmed = arm.armed;
|
|
349
371
|
const code = makeChallengeCode();
|
|
350
|
-
// One bot, one chat, and — once a host runs more than one fleet — more than
|
|
351
|
-
// one pane that can ask. The challenge names which one, or the operator is
|
|
352
|
-
// answering a question they cannot attribute.
|
|
353
|
-
const named = tick.config.project ?? projectName;
|
|
354
372
|
const text =
|
|
355
373
|
`Fleet arming check${named === undefined ? "" : ` — project ${named}`}. ` +
|
|
356
374
|
`Reply to this chat with exactly:\n${code}\n` +
|
|
@@ -391,25 +409,37 @@ export async function armTicks(projectName?: string, deps: ArmDeps = {}): Promis
|
|
|
391
409
|
|
|
392
410
|
const injected = deps.waitForUserTurn;
|
|
393
411
|
const scan: SessionScan = injected
|
|
394
|
-
? { seen: await injected(
|
|
395
|
-
: await waitForChallengeInSessions(
|
|
412
|
+
? { seen: await injected(dirs, code, sentAt, timeoutMs), scanned: [], ignored: [] }
|
|
413
|
+
: await waitForChallengeInSessions(dirs, code, sentAt, timeoutMs, deps);
|
|
396
414
|
if (!scan.seen) {
|
|
397
415
|
// The proof missed its window: a lookalike reply must not stay classifiable
|
|
398
416
|
// after arming gave up, so the record is cleared before the failure lands.
|
|
399
417
|
clearArmChallenge(named);
|
|
418
|
+
// The claimed session file is named before the bridge/token/chat block: a
|
|
419
|
+
// rotation under the window is visible from this error alone, and on the
|
|
420
|
+
// host this guard exists for the claimed file is the one that held the
|
|
421
|
+
// answer (#600).
|
|
400
422
|
const listing = [
|
|
401
|
-
|
|
423
|
+
...(claimed === undefined
|
|
424
|
+
? []
|
|
425
|
+
: [
|
|
426
|
+
`claimed orchestrator session file: ${claimed}` +
|
|
427
|
+
(dirs.includes(dirname(claimed))
|
|
428
|
+
? " (watched)"
|
|
429
|
+
: ` (NOT under any watched dir — a reply there can never be seen)`),
|
|
430
|
+
]),
|
|
431
|
+
`session dir: ${dirs.join(", ")}`,
|
|
402
432
|
...scan.scanned.map((f) => ` watched: ${f}`),
|
|
403
433
|
...scan.ignored.map((f) => ` ignored (stale, last written before the challenge): ${f}`),
|
|
404
434
|
].join("\n");
|
|
405
435
|
throw new Error(
|
|
406
436
|
`arm: the challenge never arrived as a user turn in time — NOT armed.\n` +
|
|
407
|
-
|
|
437
|
+
listing +
|
|
438
|
+
`\nInbound Telegram is not reaching the omp session. Check, in order:\n` +
|
|
408
439
|
` * is the bridge polling? attach and run: /telegram status\n` +
|
|
409
440
|
` * is another process holding this bot token? Telegram allows exactly one\n` +
|
|
410
441
|
` getUpdates consumer and rejects the second with HTTP 409.\n` +
|
|
411
|
-
` * did you reply in the DM with the bot, not another chat?\n
|
|
412
|
-
listing,
|
|
442
|
+
` * did you reply in the DM with the bot, not another chat?\n`,
|
|
413
443
|
);
|
|
414
444
|
}
|
|
415
445
|
|
|
@@ -1605,6 +1635,56 @@ export function sessionDirForCwd(cwd: string): string {
|
|
|
1605
1635
|
return join(home, ".omp", "agent", "sessions", slug.replaceAll("/", "-"));
|
|
1606
1636
|
}
|
|
1607
1637
|
|
|
1638
|
+
/** The session tree arm scans: every transcript under it, whatever the cwd slug. */
|
|
1639
|
+
export function sessionsRoot(): string {
|
|
1640
|
+
return join(homedir(), ".omp", "agent", "sessions");
|
|
1641
|
+
}
|
|
1642
|
+
|
|
1643
|
+
/**
|
|
1644
|
+
* The orchestrator session file omp-telegram's live claim names for this
|
|
1645
|
+
* project — the transcript a resumed/restored pane actually writes, which can
|
|
1646
|
+
* live in a different session directory than the tick cwd implies (#600).
|
|
1647
|
+
* Never throws: no claim, or an unreadable config, means undefined and arm
|
|
1648
|
+
* falls back to the cwd-derived directory.
|
|
1649
|
+
*/
|
|
1650
|
+
function claimedOrchestratorSessionFile(named: string | undefined): string | undefined {
|
|
1651
|
+
if (named === undefined) return undefined;
|
|
1652
|
+
try {
|
|
1653
|
+
return resolveClaimedSessionFile(findProject(loadConfig(), named));
|
|
1654
|
+
} catch {
|
|
1655
|
+
return undefined;
|
|
1656
|
+
}
|
|
1657
|
+
}
|
|
1658
|
+
|
|
1659
|
+
/**
|
|
1660
|
+
* The session directories `arm` watches for the reply — the cwd-derived one
|
|
1661
|
+
* plus, when the live claim's file lives elsewhere, that file's directory.
|
|
1662
|
+
*
|
|
1663
|
+
* The cwd-derived directory is where a *fresh* session started from the tick
|
|
1664
|
+
* cwd writes; the claimed file's directory is where the *restored* pane keeps
|
|
1665
|
+
* writing (herdr pins it to the original transcript). Watching both covers a
|
|
1666
|
+
* resumed session, a rotated one, and a session that starts after the send.
|
|
1667
|
+
*
|
|
1668
|
+
* A claim outside the session tree is a stop, not a slower failure: arm polls
|
|
1669
|
+
* only transcripts under {@link sessionsRoot}, so a claimed file elsewhere can
|
|
1670
|
+
* never satisfy the challenge, and five minutes of polling cannot discover a
|
|
1671
|
+
* file that is not in the search set. The throw names both paths.
|
|
1672
|
+
*/
|
|
1673
|
+
function armSessionScanDirs(cwd: string, claimed: string | undefined): string[] {
|
|
1674
|
+
const cwdDir = sessionDirForCwd(cwd);
|
|
1675
|
+
if (claimed === undefined) return [cwdDir];
|
|
1676
|
+
const root = sessionsRoot();
|
|
1677
|
+
const claimDir = dirname(claimed);
|
|
1678
|
+
if (claimDir !== root && !claimDir.startsWith(join(root, sep))) {
|
|
1679
|
+
throw new Error(
|
|
1680
|
+
`arm: the orchestrator's claimed session file ${claimed} is outside the session tree arm scans (${root}) — ` +
|
|
1681
|
+
`no transcript there can satisfy the challenge. Resume the pane under ${root}, or start it from ${cwd}; ` +
|
|
1682
|
+
`arm would otherwise poll ${cwdDir} until the timer runs out`,
|
|
1683
|
+
);
|
|
1684
|
+
}
|
|
1685
|
+
return claimDir === cwdDir ? [cwdDir] : [cwdDir, claimDir];
|
|
1686
|
+
}
|
|
1687
|
+
|
|
1608
1688
|
function makeChallengeCode(): string {
|
|
1609
1689
|
const bytes = new Uint8Array(4);
|
|
1610
1690
|
crypto.getRandomValues(bytes);
|
|
@@ -1631,20 +1711,23 @@ interface SessionScan {
|
|
|
1631
1711
|
}
|
|
1632
1712
|
|
|
1633
1713
|
/**
|
|
1634
|
-
* Polls the session
|
|
1714
|
+
* Polls the session directories — re-read on every pass, never snapshotted —
|
|
1635
1715
|
* until the challenge shows up as a user turn or the deadline passes.
|
|
1636
1716
|
*
|
|
1637
1717
|
* Discovery lives here because the reply can land in a transcript that does not
|
|
1638
1718
|
* exist yet when the challenge is sent: a rotated session, or the first one of
|
|
1639
|
-
* a pane started right after arming (#142).
|
|
1640
|
-
*
|
|
1719
|
+
* a pane started right after arming (#142). And the scan set can be two
|
|
1720
|
+
* directories when the live claim's session file lives outside the tick-cwd
|
|
1721
|
+
* directory: the restored pane keeps writing the claimed file, so its
|
|
1722
|
+
* directory is watched alongside the cwd-derived one (#600).
|
|
1641
1723
|
*
|
|
1642
1724
|
* Files untouched since just before the send are named, not parsed: an append
|
|
1643
1725
|
* bumps mtime, so a transcript older than the challenge cannot hold the reply,
|
|
1644
|
-
* and skipping it keeps a large stale session out of every 5 s pass.
|
|
1726
|
+
* and skipping it keeps a large stale session out of every 5 s pass. A missing
|
|
1727
|
+
* directory is not an error — it can vanish under a rotation and reappear.
|
|
1645
1728
|
*/
|
|
1646
1729
|
async function waitForChallengeInSessions(
|
|
1647
|
-
|
|
1730
|
+
dirs: readonly string[],
|
|
1648
1731
|
code: string,
|
|
1649
1732
|
sentAt: number,
|
|
1650
1733
|
timeoutMs: number,
|
|
@@ -1656,28 +1739,30 @@ async function waitForChallengeInSessions(
|
|
|
1656
1739
|
for (;;) {
|
|
1657
1740
|
const scanned: string[] = [];
|
|
1658
1741
|
const ignored: string[] = [];
|
|
1659
|
-
|
|
1660
|
-
|
|
1661
|
-
names = readdirSync(dir);
|
|
1662
|
-
} catch {
|
|
1663
|
-
/* the directory can go away under a rotation; the next pass re-reads it */
|
|
1664
|
-
}
|
|
1665
|
-
for (const name of names.sort()) {
|
|
1666
|
-
if (!name.endsWith(".jsonl")) continue;
|
|
1667
|
-
const path = join(dir, name);
|
|
1668
|
-
let mtimeMs: number;
|
|
1742
|
+
for (const dir of dirs) {
|
|
1743
|
+
let names: string[] = [];
|
|
1669
1744
|
try {
|
|
1670
|
-
|
|
1745
|
+
names = readdirSync(dir);
|
|
1671
1746
|
} catch {
|
|
1672
|
-
|
|
1747
|
+
/* the directory can go away under a rotation; the next pass re-reads it */
|
|
1673
1748
|
}
|
|
1674
|
-
const
|
|
1675
|
-
|
|
1676
|
-
|
|
1677
|
-
|
|
1749
|
+
for (const name of names.sort()) {
|
|
1750
|
+
if (!name.endsWith(".jsonl")) continue;
|
|
1751
|
+
const path = join(dir, name);
|
|
1752
|
+
let mtimeMs: number;
|
|
1753
|
+
try {
|
|
1754
|
+
mtimeMs = statSync(path).mtimeMs;
|
|
1755
|
+
} catch {
|
|
1756
|
+
continue; /* race */
|
|
1757
|
+
}
|
|
1758
|
+
const label = `${name} (mtime ${new Date(mtimeMs).toISOString()})`;
|
|
1759
|
+
if (mtimeMs < sentAt - 1_000) {
|
|
1760
|
+
ignored.push(label);
|
|
1761
|
+
continue;
|
|
1762
|
+
}
|
|
1763
|
+
scanned.push(label);
|
|
1764
|
+
if (await transcriptHasUserCode(path, code)) return { seen: true, scanned, ignored };
|
|
1678
1765
|
}
|
|
1679
|
-
scanned.push(label);
|
|
1680
|
-
if (await transcriptHasUserCode(path, code)) return { seen: true, scanned, ignored };
|
|
1681
1766
|
}
|
|
1682
1767
|
if (now() >= deadline) return { seen: false, scanned, ignored };
|
|
1683
1768
|
await sleep(5_000);
|
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/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.
|