omp-conductor 0.15.13 → 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/REFERENCE.md +72 -2
- package/package.json +2 -1
- package/schema/config.schema.json +7 -0
- package/src/admission.ts +849 -0
- package/src/ask.ts +47 -0
- package/src/backups.ts +19 -7
- package/src/board.ts +1 -2
- package/src/briefs/orchestrator.md +62 -4
- package/src/cli.ts +26 -0
- package/src/commands/context.ts +3 -0
- package/src/commands/decision.ts +10 -1
- package/src/commands/doctor.ts +2 -0
- package/src/commands/message.ts +8 -1
- package/src/commands/restart.ts +15 -3
- package/src/commands/restore-db.ts +146 -0
- package/src/commands/stop.ts +24 -15
- package/src/commands/tail.ts +204 -44
- package/src/commands/unfreeze.ts +56 -0
- package/src/commands/watch.ts +77 -0
- package/src/config-schema.ts +13 -0
- package/src/config.ts +54 -0
- package/src/daemon.ts +255 -530
- package/src/dashboard/server.ts +2 -1
- package/src/decisions.ts +32 -7
- package/src/depends-on.ts +122 -0
- package/src/doctor.ts +297 -5
- package/src/escalate.ts +191 -19
- package/src/failure-class.ts +47 -0
- package/src/fleet.ts +168 -452
- package/src/gitops.ts +86 -1
- package/src/log.ts +40 -0
- package/src/model-fallback.ts +3 -2
- package/src/omp-settings.ts +114 -0
- package/src/omp.ts +39 -0
- package/src/orchestrator-tick.ts +7 -1
- package/src/reports.ts +124 -12
- package/src/session-host.ts +6 -0
- package/src/setup-wizard.ts +36 -0
- package/src/setup.ts +58 -1
- package/src/status-render.ts +445 -0
- package/src/stop-provenance.ts +53 -0
- package/src/store.ts +352 -11
- package/src/transcript.ts +1 -1
- package/src/types.ts +187 -4
- package/src/unblock.ts +1 -1
- package/src/upgrade-verify.ts +1 -1
- package/src/upgrade.ts +1 -2
- package/src/verbs/server.ts +25 -0
- package/src/worker.ts +358 -10
- package/src/worktree.ts +13 -1
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";
|
|
@@ -99,6 +101,15 @@ export interface WorkerOpts {
|
|
|
99
101
|
* the harness to pick, which is what an unconfigured project wants.
|
|
100
102
|
*/
|
|
101
103
|
model?: string;
|
|
104
|
+
/**
|
|
105
|
+
* Absolute path to the fleet-owned omp settings overlay (#537): the YAML the
|
|
106
|
+
* daemon materialised from the project's `ompSettings` map (plus the retry
|
|
107
|
+
* keys derived from `modelFallbacks`, which is where #539's staging lives)
|
|
108
|
+
* under the run's session directory. Forwarded to `createSession`, which
|
|
109
|
+
* loads it through `Settings.init({ configFiles: [<path>] })`. Absent, no
|
|
110
|
+
* settings are staged and dispatch is byte-for-byte what it is today.
|
|
111
|
+
*/
|
|
112
|
+
ompSettingsFile?: string;
|
|
102
113
|
/**
|
|
103
114
|
* Effective per-shape release grants for this session. A worker is refused
|
|
104
115
|
* every shape whatever they say — see {@link SessionRole} — so this is passed
|
|
@@ -169,6 +180,37 @@ export interface WorkerResult {
|
|
|
169
180
|
turns: number;
|
|
170
181
|
spendUsd: number;
|
|
171
182
|
report: string;
|
|
183
|
+
/** In-session HTTP 429 responses the session recorded (stopReason "error",
|
|
184
|
+
* errorStatus 429), counted as the messages streamed in. A healthy run
|
|
185
|
+
* reports 0; a run the harness retried through a barrel of rate limits
|
|
186
|
+
* carries the number, which is what distinguishes provider-capacity (the
|
|
187
|
+
* provider was throttling all along) from an ordinary failure (#573). */
|
|
188
|
+
provider429Count: number;
|
|
189
|
+
/**
|
|
190
|
+
* The model that actually wrote this run's messages, read from
|
|
191
|
+
* `AssistantMessage.model` on the newest assistant `message_end`. Present
|
|
192
|
+
* even for a run that never failed over — it is the durable answer to "which
|
|
193
|
+
* model wrote this" (#535 slice 1, from the message field rather than the
|
|
194
|
+
* payload-free `model_changed` event). Absent only when no assistant message
|
|
195
|
+
* carried a model.
|
|
196
|
+
*/
|
|
197
|
+
model?: string;
|
|
198
|
+
/** The provider that wrote them, read from the same `AssistantMessage.provider`. */
|
|
199
|
+
provider?: string;
|
|
200
|
+
/** Every within-run model fallback the harness applied
|
|
201
|
+
* (`retry_fallback_applied`), newest first. The `to` target is what a
|
|
202
|
+
* settlement report names when a run swapped providers mid-run. */
|
|
203
|
+
retryFallbacks: { from: string; to: string }[];
|
|
204
|
+
/** `retry_fallback_succeeded` events: within-run fallbacks the harness
|
|
205
|
+
* confirmed recovered on. */
|
|
206
|
+
retryFallbackSucceeded: number;
|
|
207
|
+
/** Assistant messages whose `retryRecovery.recovery === "model"` — the durable
|
|
208
|
+
* transcript record of a within-run model swap. */
|
|
209
|
+
modelRecoveries: number;
|
|
210
|
+
/** `auto_retry_start` events: in-session provider retries the harness ran. */
|
|
211
|
+
autoRetryCount: number;
|
|
212
|
+
/** `auto_compaction_start` events: in-session context compactions. */
|
|
213
|
+
autoCompactionCount: number;
|
|
172
214
|
killedBy?: KilledBy;
|
|
173
215
|
/** Present only when an operator terminally stopped this run. */
|
|
174
216
|
stoppedReason?: string;
|
|
@@ -281,12 +323,27 @@ export async function runWorker(
|
|
|
281
323
|
const schedule = deps.schedule ?? scheduleWallClock;
|
|
282
324
|
|
|
283
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
|
+
}
|
|
284
337
|
try {
|
|
285
338
|
session = await deps.createSession({
|
|
286
339
|
cwd: o.cwd,
|
|
287
340
|
...(o.sessionDir === undefined ? {} : { sessionDir: o.sessionDir }),
|
|
288
341
|
...(o.model === undefined ? {} : { model: o.model }),
|
|
289
342
|
...(o.resume === undefined ? {} : { resume: o.resume }),
|
|
343
|
+
// The fleet-owned omp settings overlay (#537): carry the staged overlay
|
|
344
|
+
// path to the session so it loads the project's omp settings. Absent,
|
|
345
|
+
// nothing is staged and the harness discovers settings as it does today.
|
|
346
|
+
...(o.ompSettingsFile === undefined ? {} : { ompSettingsFile: o.ompSettingsFile }),
|
|
290
347
|
// Prevention half of #24: as a worker, structured file tools cannot leave
|
|
291
348
|
// this worktree, and no release grant can ever reach this session (#122).
|
|
292
349
|
role: "worker",
|
|
@@ -308,6 +365,12 @@ export async function runWorker(
|
|
|
308
365
|
state: "stopped",
|
|
309
366
|
turns: 0,
|
|
310
367
|
spendUsd: 0,
|
|
368
|
+
provider429Count: 0,
|
|
369
|
+
retryFallbacks: [],
|
|
370
|
+
retryFallbackSucceeded: 0,
|
|
371
|
+
modelRecoveries: 0,
|
|
372
|
+
autoRetryCount: 0,
|
|
373
|
+
autoCompactionCount: 0,
|
|
311
374
|
report: "",
|
|
312
375
|
stoppedReason: "daemon shutdown began before the worker session started",
|
|
313
376
|
};
|
|
@@ -324,18 +387,80 @@ export async function runWorker(
|
|
|
324
387
|
// transcript it actually opened, and any model downgrade it announced. Read at
|
|
325
388
|
// return time so a session that materialises either late is still reported
|
|
326
389
|
// honestly.
|
|
327
|
-
|
|
390
|
+
// The within-run reliability surface this worker now records (#539): the
|
|
391
|
+
// resolved model/provider plus the fallback/retry/compaction events. Layered
|
|
392
|
+
// last, at return time, so every exit path reports the same shape without
|
|
393
|
+
// each spelling the metrics out by hand.
|
|
394
|
+
type ReliabilityKeys =
|
|
395
|
+
| "retryFallbacks"
|
|
396
|
+
| "retryFallbackSucceeded"
|
|
397
|
+
| "modelRecoveries"
|
|
398
|
+
| "autoRetryCount"
|
|
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;
|
|
405
|
+
const withSessionFacts = (
|
|
406
|
+
result: Omit<WorkerResult, ReliabilityKeys>,
|
|
407
|
+
): Omit<WorkerResult, ReliabilityKeys> => {
|
|
328
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
|
+
}
|
|
329
422
|
return {
|
|
330
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,
|
|
331
427
|
...(sessionFile === undefined ? {} : { sessionFile }),
|
|
332
428
|
...(modelFallbackMessage === undefined ? {} : { modelFallbackMessage }),
|
|
333
429
|
};
|
|
334
430
|
};
|
|
335
431
|
|
|
432
|
+
// The count fields always travel (0 for a clean run, so an absent field can
|
|
433
|
+
// never be misread); the resolved model/provider only when some assistant
|
|
434
|
+
// message actually carried them.
|
|
435
|
+
const withMetrics = (result: Omit<WorkerResult, ReliabilityKeys>): WorkerResult => ({
|
|
436
|
+
...result,
|
|
437
|
+
...(resolvedModel === undefined ? {} : { model: resolvedModel }),
|
|
438
|
+
...(resolvedProvider === undefined ? {} : { provider: resolvedProvider }),
|
|
439
|
+
retryFallbacks,
|
|
440
|
+
retryFallbackSucceeded,
|
|
441
|
+
modelRecoveries,
|
|
442
|
+
autoRetryCount,
|
|
443
|
+
autoCompactionCount,
|
|
444
|
+
});
|
|
445
|
+
|
|
336
446
|
let turns = 0;
|
|
337
447
|
let spendUsd = 0;
|
|
448
|
+
let provider429Count = 0;
|
|
338
449
|
let report = "";
|
|
450
|
+
// Which model/provider actually wrote the newest assistant message. Last
|
|
451
|
+
// assistant message wins: that is the durable answer even for a run that
|
|
452
|
+
// never failed over (#535 slice 1, read off the message field which is where
|
|
453
|
+
// the resolved model actually lives).
|
|
454
|
+
let resolvedModel: string | undefined;
|
|
455
|
+
let resolvedProvider: string | undefined;
|
|
456
|
+
// Harness reliability surface (#539): within-run provider failover and the
|
|
457
|
+
// retry/compaction activity that surrounds it, all of it events this worker
|
|
458
|
+
// does not yet subscribe to but that the run row and settlement report want.
|
|
459
|
+
let retryFallbacks: { from: string; to: string }[] = [];
|
|
460
|
+
let retryFallbackSucceeded = 0;
|
|
461
|
+
let modelRecoveries = 0;
|
|
462
|
+
let autoRetryCount = 0;
|
|
463
|
+
let autoCompactionCount = 0;
|
|
339
464
|
// The newest COMPLETE `pushed-green` verdict this session emitted. Tracked
|
|
340
465
|
// apart from `report` because `report` is deliberately the newest non-empty
|
|
341
466
|
// text — a run cut off mid-sentence must still report what it said last —
|
|
@@ -497,6 +622,48 @@ export async function runWorker(
|
|
|
497
622
|
spendUsd += cost;
|
|
498
623
|
o.onSpend?.(spendUsd);
|
|
499
624
|
}
|
|
625
|
+
|
|
626
|
+
// Count the provider rate limits the harness retried in-session (#573). A
|
|
627
|
+
// run that drowns in 429s records `stopReason:"error", errorStatus:429`
|
|
628
|
+
// dozens of times and never surfaces one as `lastError` — omp swallowed
|
|
629
|
+
// every retry — so `unknown` and the provider failover chain (#286) never
|
|
630
|
+
// see them. Counted here, alongside spend, so the classifier can tell
|
|
631
|
+
// "the provider was throttling the whole run" from an ordinary failure.
|
|
632
|
+
if (provider429FromMessage(message)) provider429Count += 1;
|
|
633
|
+
|
|
634
|
+
// The resolved model and provider live on the message, not on any event
|
|
635
|
+
// (#539). Last assistant message wins, which is the run's durable answer
|
|
636
|
+
// even when it never failed over.
|
|
637
|
+
const model = field(message, "model");
|
|
638
|
+
if (typeof model === "string" && model !== "") resolvedModel = model;
|
|
639
|
+
const provider = field(message, "provider");
|
|
640
|
+
if (typeof provider === "string" && provider !== "") resolvedProvider = provider;
|
|
641
|
+
// A within-run model swap the harness persisted into the transcript rather
|
|
642
|
+
// than only emitting as a transient event. Recovery kind "model" is the
|
|
643
|
+
// durable spelling of the same thing `retry_fallback_applied` says.
|
|
644
|
+
if (field(field(message, "retryRecovery"), "recovery") === "model") modelRecoveries += 1;
|
|
645
|
+
});
|
|
646
|
+
|
|
647
|
+
// The harness reliability events the run row and settlement report now want
|
|
648
|
+
// (#539): within-run model fallback, and the retry/compaction activity that
|
|
649
|
+
// surrounds a throttled provider. Each is a session event (AgentSessionEvent)
|
|
650
|
+
// carrying the fields this worker reads — a fallback's from→to pair, the
|
|
651
|
+
// confirmed recoveries, and the auto-retry/compaction attempt counts.
|
|
652
|
+
session.on("retry_fallback_applied", (event) => {
|
|
653
|
+
const from = field(event, "from");
|
|
654
|
+
const to = field(event, "to");
|
|
655
|
+
if (typeof from === "string" && typeof to === "string") {
|
|
656
|
+
retryFallbacks = [{ from, to }, ...retryFallbacks];
|
|
657
|
+
}
|
|
658
|
+
});
|
|
659
|
+
session.on("retry_fallback_succeeded", () => {
|
|
660
|
+
retryFallbackSucceeded += 1;
|
|
661
|
+
});
|
|
662
|
+
session.on("auto_retry_start", () => {
|
|
663
|
+
autoRetryCount += 1;
|
|
664
|
+
});
|
|
665
|
+
session.on("auto_compaction_start", () => {
|
|
666
|
+
autoCompactionCount += 1;
|
|
500
667
|
});
|
|
501
668
|
|
|
502
669
|
session.on("agent_end", (event) => {
|
|
@@ -561,12 +728,13 @@ export async function runWorker(
|
|
|
561
728
|
// Our own abort surfaces here on some paths; that is a kill, not a crash.
|
|
562
729
|
if (killedBy === undefined && stoppedReason === undefined) {
|
|
563
730
|
const detail = cause instanceof Error ? cause.message : String(cause);
|
|
564
|
-
return withSessionFacts({
|
|
731
|
+
return withMetrics(withSessionFacts({
|
|
565
732
|
state: "failed",
|
|
566
733
|
turns,
|
|
567
734
|
spendUsd,
|
|
735
|
+
provider429Count,
|
|
568
736
|
report: report === "" ? detail : report,
|
|
569
|
-
});
|
|
737
|
+
}));
|
|
570
738
|
}
|
|
571
739
|
} finally {
|
|
572
740
|
done = true;
|
|
@@ -581,14 +749,15 @@ export async function runWorker(
|
|
|
581
749
|
}
|
|
582
750
|
|
|
583
751
|
if (stoppedReason !== undefined) {
|
|
584
|
-
return withSessionFacts({
|
|
752
|
+
return withMetrics(withSessionFacts({
|
|
585
753
|
state: "stopped",
|
|
586
754
|
turns,
|
|
587
755
|
spendUsd,
|
|
756
|
+
provider429Count,
|
|
588
757
|
report,
|
|
589
758
|
stoppedReason,
|
|
590
759
|
...(claim === undefined ? {} : { prUrl: claim.prUrl, headSha: claim.headSha }),
|
|
591
|
-
});
|
|
760
|
+
}));
|
|
592
761
|
}
|
|
593
762
|
|
|
594
763
|
if (killedBy !== undefined) {
|
|
@@ -596,28 +765,30 @@ export async function runWorker(
|
|
|
596
765
|
// survive the kill. Without them `shouldContinueAfterTurnsCap` sees no
|
|
597
766
|
// artifacts and charges an implementation attempt for a cap kill that had
|
|
598
767
|
// real work to continue from.
|
|
599
|
-
return withSessionFacts({
|
|
768
|
+
return withMetrics(withSessionFacts({
|
|
600
769
|
state: "killed",
|
|
601
770
|
turns,
|
|
602
771
|
spendUsd,
|
|
772
|
+
provider429Count,
|
|
603
773
|
report,
|
|
604
774
|
killedBy,
|
|
605
775
|
...(claim === undefined ? {} : { prUrl: claim.prUrl, headSha: claim.headSha }),
|
|
606
|
-
});
|
|
776
|
+
}));
|
|
607
777
|
}
|
|
608
778
|
|
|
609
779
|
// An explicit later verdict always wins: a worker that pushed green and then
|
|
610
780
|
// stopped to ask a question means the question. The earlier claim is only
|
|
611
781
|
// restored when the last thing said was not a verdict at all.
|
|
612
782
|
if (claim !== undefined && !hasVerdictLine(report)) {
|
|
613
|
-
return withSessionFacts({ state: "pushed-green", ...claim, turns, spendUsd, report });
|
|
783
|
+
return withMetrics(withSessionFacts({ state: "pushed-green", ...claim, turns, spendUsd, provider429Count, report }));
|
|
614
784
|
}
|
|
615
|
-
return withSessionFacts({
|
|
785
|
+
return withMetrics(withSessionFacts({
|
|
616
786
|
...deriveResult(report, o.repoSlug),
|
|
617
787
|
turns,
|
|
618
788
|
spendUsd,
|
|
789
|
+
provider429Count,
|
|
619
790
|
report,
|
|
620
|
-
});
|
|
791
|
+
}));
|
|
621
792
|
}
|
|
622
793
|
|
|
623
794
|
/**
|
|
@@ -645,6 +816,21 @@ export function costUsdFromMessage(message: unknown): number | undefined {
|
|
|
645
816
|
return any ? sum : undefined;
|
|
646
817
|
}
|
|
647
818
|
|
|
819
|
+
/**
|
|
820
|
+
* Is this assistant message a provider HTTP 429 the harness recorded mid-run?
|
|
821
|
+
*
|
|
822
|
+
* Live transcripts mark a rate-limited turn with `stopReason:"error"`,
|
|
823
|
+
* `errorStatus:429` and a message like `429 Provider returned error`, and the
|
|
824
|
+
* harness's in-session auto-retry usually swallows it — the run carries on and
|
|
825
|
+
* the 429 never surfaces as `lastError` (#573). Exported so a unit test can pin
|
|
826
|
+
* the signature without standing up a session; the count itself distinguishes a
|
|
827
|
+
* run that drowned in them from a run that hit one and recovered.
|
|
828
|
+
*/
|
|
829
|
+
export function provider429FromMessage(message: unknown): boolean {
|
|
830
|
+
if (field(message, "stopReason") !== "error") return false;
|
|
831
|
+
return field(message, "errorStatus") === 429;
|
|
832
|
+
}
|
|
833
|
+
|
|
648
834
|
/**
|
|
649
835
|
* Read one property off an unvalidated harness event. The event union lives in
|
|
650
836
|
* the peer dependency, so the worker narrows the handful of fields it reads
|
|
@@ -655,6 +841,168 @@ function field(source: unknown, key: string): unknown {
|
|
|
655
841
|
return Reflect.get(source, key);
|
|
656
842
|
}
|
|
657
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
|
+
|
|
658
1006
|
/** Flatten an assistant message's content blocks to their plain text. */
|
|
659
1007
|
/**
|
|
660
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.
|