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/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "omp-conductor",
|
|
3
|
-
"version": "0.16.
|
|
3
|
+
"version": "0.16.1",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"description": "A 24/7 dispatcher that takes ready GitHub issues to green, mergeable PRs using omp coding sessions, with tiered escalation first to an orchestrator session and then to a human.",
|
package/src/admission.ts
CHANGED
|
@@ -29,6 +29,7 @@ import type {
|
|
|
29
29
|
} from "./types.ts";
|
|
30
30
|
import { readPlanUsage, type PlanUsageStatus, type UsageSource } from "./usage.ts";
|
|
31
31
|
import type { CriticalBaseProbe, CriticalBaseVerdict, RunLaneProbe } from "./gitops.ts";
|
|
32
|
+
import { repoSlugFor } from "./gitops.ts";
|
|
32
33
|
import { branchName, type Routed } from "./routing.ts";
|
|
33
34
|
import { parseDependsOn } from "./depends-on.ts";
|
|
34
35
|
|
|
@@ -51,6 +52,17 @@ export interface AdmissionDeps {
|
|
|
51
52
|
escalate(e: Escalation): Promise<void>;
|
|
52
53
|
probeCriticalBase?: CriticalBaseProbe;
|
|
53
54
|
probeWorktreeLane?: RunLaneProbe;
|
|
55
|
+
/**
|
|
56
|
+
* Reads one issue's tracker state in a repository the admission tracker is
|
|
57
|
+
* NOT bound to — the cross-repo half of the Depends-on interlock (#420).
|
|
58
|
+
* The tracker bound to `project.tracker.repo` can only answer same-repo
|
|
59
|
+
* references; a `owner/repo#n` prerequisite naming a different routed repo
|
|
60
|
+
* goes through this. Undefined means "could not tell" (same posture as
|
|
61
|
+
* {@link Tracker.issueSnapshot}); a routed prerequisite it cannot answer
|
|
62
|
+
* fails that candidate closed. Unset, admission fails a routed cross-repo
|
|
63
|
+
* prerequisite closed too — the daemon always wires it.
|
|
64
|
+
*/
|
|
65
|
+
probeIssueIn?: (repo: string, issue: number) => Promise<IssueSnapshot | undefined>;
|
|
54
66
|
}
|
|
55
67
|
/** `stops` are the operational ends that each require one resume. */
|
|
56
68
|
export function hasContinuationBudget(stops: number, maxContinuations: number): boolean {
|
|
@@ -190,6 +202,22 @@ function isPathLike(token: string): boolean {
|
|
|
190
202
|
return token !== "" && !/\s/.test(token) && (token.includes("/") || /\.[A-Za-z0-9]{1,10}$/.test(token));
|
|
191
203
|
}
|
|
192
204
|
|
|
205
|
+
/**
|
|
206
|
+
* Where a `owner/repo` cross-repo reference can be read. Only repositories
|
|
207
|
+
* routed in THIS fleet are resolvable (#420): the issue source repo
|
|
208
|
+
* (`project.tracker.repo`) is answered by the existing tracker, and any routed
|
|
209
|
+
* work repo by {@link AdmissionDeps.probeIssueIn}. Anything else is unresolved
|
|
210
|
+
* — the caller fails closed rather than guess which repo was meant, and never
|
|
211
|
+
* silently resolves the reference against the candidate's own repo.
|
|
212
|
+
*/
|
|
213
|
+
function crossRepoTarget(project: ProjectConfig, ownerRepo: string): "tracker" | "routed" | undefined {
|
|
214
|
+
if (ownerRepo === project.tracker.repo) return "tracker";
|
|
215
|
+
for (const routed of Object.values(project.routing.repos)) {
|
|
216
|
+
if (repoSlugFor(routed) === ownerRepo) return "routed";
|
|
217
|
+
}
|
|
218
|
+
return undefined;
|
|
219
|
+
}
|
|
220
|
+
|
|
193
221
|
/**
|
|
194
222
|
* Which routed candidates get a worker this tick — in queue order, never more
|
|
195
223
|
* than `slots` of them. Every non-admission receives a stable reason code.
|
|
@@ -623,13 +651,14 @@ export async function admitCandidates(
|
|
|
623
651
|
continue;
|
|
624
652
|
}
|
|
625
653
|
|
|
626
|
-
// The Depends-on interlock (#419): a candidate declares the
|
|
627
|
-
//
|
|
628
|
-
//
|
|
629
|
-
//
|
|
630
|
-
//
|
|
631
|
-
//
|
|
632
|
-
// references
|
|
654
|
+
// The Depends-on interlock (#419/#420): a candidate declares the issues it
|
|
655
|
+
// must not be dispatched before — same-repo (`#123`) or cross-repo
|
|
656
|
+
// (`owner/repo#123`) — and any *open* prerequisite holds it until every
|
|
657
|
+
// referenced issue is closed. Prerequisite state is read fresh from the
|
|
658
|
+
// tracker at claim time, every pass — never cached across ticks — so a
|
|
659
|
+
// prerequisite that reopens re-holds on the next tick. Cross-repo
|
|
660
|
+
// references resolve only against repositories routed in this fleet;
|
|
661
|
+
// graph cycles are a later slice (#421).
|
|
633
662
|
const dependsOn = parseDependsOn(r.issue.body);
|
|
634
663
|
if (dependsOn.malformed.length > 0) {
|
|
635
664
|
// A marker line with no strict `#<n>` reference (e.g. `Depends-on:
|
|
@@ -686,6 +715,81 @@ export async function admitCandidates(
|
|
|
686
715
|
continue;
|
|
687
716
|
}
|
|
688
717
|
}
|
|
718
|
+
if (dependsOn.crossRefs.length > 0) {
|
|
719
|
+
// The cross-repo half (#420): each `owner/repo#n` is read from the repo
|
|
720
|
+
// it names. The fully qualified form travels in the `detail` string, so
|
|
721
|
+
// status/digest render it as `blocked by owner/repo#123` while same-repo
|
|
722
|
+
// refs stay compact `blocked by #5`.
|
|
723
|
+
let blocking: string | undefined;
|
|
724
|
+
let unreadable: string | undefined;
|
|
725
|
+
let unresolved: string | undefined;
|
|
726
|
+
for (const ref of dependsOn.crossRefs) {
|
|
727
|
+
const where = crossRepoTarget(project, ref.repo);
|
|
728
|
+
const full = `${ref.repo}#${ref.issue}`;
|
|
729
|
+
if (where === undefined) {
|
|
730
|
+
unresolved = full;
|
|
731
|
+
break;
|
|
732
|
+
}
|
|
733
|
+
let state: IssueSnapshot | undefined;
|
|
734
|
+
try {
|
|
735
|
+
if (where === "tracker") {
|
|
736
|
+
// The qualified form of the candidate's own tracker repo: the
|
|
737
|
+
// same state read the same-repo interlock uses.
|
|
738
|
+
state = await tracker.issueSnapshot(ref.issue);
|
|
739
|
+
} else if (d.probeIssueIn === undefined) {
|
|
740
|
+
// No resolver wired — fail closed, never guess the issue's state.
|
|
741
|
+
state = undefined;
|
|
742
|
+
} else {
|
|
743
|
+
state = await d.probeIssueIn(ref.repo, ref.issue);
|
|
744
|
+
}
|
|
745
|
+
} catch {
|
|
746
|
+
state = undefined;
|
|
747
|
+
}
|
|
748
|
+
if (state?.state === "open") {
|
|
749
|
+
blocking = full;
|
|
750
|
+
break;
|
|
751
|
+
}
|
|
752
|
+
if (state === undefined) {
|
|
753
|
+
unreadable = full;
|
|
754
|
+
break;
|
|
755
|
+
}
|
|
756
|
+
}
|
|
757
|
+
// Same fail-closed posture as same-repo refs: an open prerequisite
|
|
758
|
+
// holds, an unreadable one holds (the next tick rereads it fresh), and
|
|
759
|
+
// an unresolved repo holds because the dispatcher cannot verify the
|
|
760
|
+
// dependency — it is never silently treated as closed. Only the
|
|
761
|
+
// unresolved repo also surfaces one material event for grooming, and it
|
|
762
|
+
// follows the malformed-declaration escalation's stable-summary
|
|
763
|
+
// dedupe, so repeated ticks page once, not per tick.
|
|
764
|
+
if (blocking !== undefined) {
|
|
765
|
+
hold(issue, "depends-on", `blocked by ${blocking}`);
|
|
766
|
+
log(`#${issue} held (depends-on): cross-repo prerequisite ${blocking} is open`);
|
|
767
|
+
continue;
|
|
768
|
+
}
|
|
769
|
+
if (unreadable !== undefined) {
|
|
770
|
+
hold(issue, "depends-on", `prerequisite ${unreadable} state unreadable`);
|
|
771
|
+
log(`#${issue} held (depends-on): cross-repo prerequisite ${unreadable} state unreadable`);
|
|
772
|
+
continue;
|
|
773
|
+
}
|
|
774
|
+
if (unresolved !== undefined) {
|
|
775
|
+
hold(issue, "depends-on", `blocked by ${unresolved} — repo not routed in this fleet`);
|
|
776
|
+
log(`#${issue} held (depends-on): cross-repo prerequisite ${unresolved} is not a routed repo`);
|
|
777
|
+
await safeEscalate(d, {
|
|
778
|
+
tier: 1,
|
|
779
|
+
project: project.name,
|
|
780
|
+
issue,
|
|
781
|
+
summary: `#${issue} depends on ${unresolved}, a repo not routed in this fleet — held until groomed`,
|
|
782
|
+
detail: [
|
|
783
|
+
r.issue.title,
|
|
784
|
+
r.issue.url,
|
|
785
|
+
`Unroutable prerequisite: ${unresolved}`,
|
|
786
|
+
"Depends-on cross-repo references must name a repository routed in this project",
|
|
787
|
+
"(`routing.repos` or the tracker repo). Groom the body or route the repo.",
|
|
788
|
+
].join("\n"),
|
|
789
|
+
});
|
|
790
|
+
continue;
|
|
791
|
+
}
|
|
792
|
+
}
|
|
689
793
|
|
|
690
794
|
// The file-lane interlock (#555): a candidate whose declared lane overlaps
|
|
691
795
|
// a live run's *actual* lane is held until that run's work has merged, so
|
package/src/commands/tail.ts
CHANGED
|
@@ -7,24 +7,146 @@
|
|
|
7
7
|
*/
|
|
8
8
|
|
|
9
9
|
import type { CommandContext } from "./context.ts";
|
|
10
|
-
import { closeSync, openSync, readSync, statSync } from "node:fs";
|
|
10
|
+
import { closeSync, openSync, readdirSync, readSync, statSync } from "node:fs";
|
|
11
|
+
import { join } from "node:path";
|
|
11
12
|
import { findProject, loadConfig } from "../config.ts";
|
|
12
13
|
import { dbPath, LIVE_STATES, openStore } from "../store.ts";
|
|
13
|
-
import { formatTranscriptLine } from "../transcript.ts";
|
|
14
|
+
import { formatTranscriptLine, prop } from "../transcript.ts";
|
|
15
|
+
import type { Store } from "../types.ts";
|
|
14
16
|
|
|
15
|
-
/** How often `tail` re-stats the
|
|
17
|
+
/** How often `tail` re-stats the transcripts it is following. */
|
|
16
18
|
const TAIL_POLL_MS = 1_000;
|
|
17
19
|
|
|
18
20
|
/**
|
|
19
|
-
* How long the
|
|
21
|
+
* How long the transcripts must stay unchanged, after its run has left the live
|
|
20
22
|
* states, before `tail` calls it over. The state flips from the daemon's thread
|
|
21
23
|
* while the harness may still be flushing its last message, so exiting on the
|
|
22
24
|
* state alone truncates the ending an operator ran this command to watch.
|
|
23
25
|
*/
|
|
24
26
|
const TAIL_QUIET_MS = 5_000;
|
|
25
27
|
|
|
28
|
+
/** The harness records advisor turns under this reserved stem. */
|
|
29
|
+
const ADVISOR_TRANSCRIPT_PREFIX = "__advisor";
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* One followed file: its open descriptor, how many bytes have been rendered,
|
|
33
|
+
* and whatever byte tail did not end on a newline yet. Holding the partial line
|
|
34
|
+
* across polls is what stops a UTF-8 sequence straddling a read boundary (or
|
|
35
|
+
* the newline itself) from being mangled by decoding each chunk on its own.
|
|
36
|
+
*/
|
|
37
|
+
interface FollowedStream {
|
|
38
|
+
fd: number;
|
|
39
|
+
offset: number;
|
|
40
|
+
pending: Buffer;
|
|
41
|
+
}
|
|
42
|
+
|
|
26
43
|
/**
|
|
27
|
-
*
|
|
44
|
+
* Return the complete lines appended to `file` since the last poll, plus
|
|
45
|
+
* whether any bytes changed at all (the quiet-timer needs the latter even when
|
|
46
|
+
* a delta rendered no displayable line). A file that shrank (truncated or
|
|
47
|
+
* replaced) restarts from zero; a final line without a newline stays in
|
|
48
|
+
* `pending` until its newline lands.
|
|
49
|
+
*/
|
|
50
|
+
function followLines(
|
|
51
|
+
state: FollowedStream,
|
|
52
|
+
file: string,
|
|
53
|
+
): { lines: Buffer[]; changed: boolean } {
|
|
54
|
+
const lines: Buffer[] = [];
|
|
55
|
+
let changed = false;
|
|
56
|
+
let size = 0;
|
|
57
|
+
try {
|
|
58
|
+
size = statSync(file).size;
|
|
59
|
+
} catch {
|
|
60
|
+
// A transcript that vanishes mid-follow is not worth crashing over.
|
|
61
|
+
return { lines, changed };
|
|
62
|
+
}
|
|
63
|
+
// Shorter than what we have already read means truncated or replaced;
|
|
64
|
+
// resuming from the old offset would read the middle of another file.
|
|
65
|
+
if (size < state.offset) {
|
|
66
|
+
state.offset = 0;
|
|
67
|
+
state.pending = Buffer.alloc(0);
|
|
68
|
+
}
|
|
69
|
+
if (size > state.offset) {
|
|
70
|
+
const chunk = Buffer.allocUnsafe(size - state.offset);
|
|
71
|
+
const read = readSync(state.fd, chunk, 0, chunk.length, state.offset);
|
|
72
|
+
state.offset += read;
|
|
73
|
+
state.pending = Buffer.concat([state.pending, chunk.subarray(0, read)]);
|
|
74
|
+
for (;;) {
|
|
75
|
+
const nl = state.pending.indexOf(0x0a);
|
|
76
|
+
if (nl < 0) break;
|
|
77
|
+
lines.push(state.pending.subarray(0, nl));
|
|
78
|
+
state.pending = state.pending.subarray(nl + 1);
|
|
79
|
+
}
|
|
80
|
+
changed = read > 0;
|
|
81
|
+
}
|
|
82
|
+
return { lines, changed };
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/**
|
|
86
|
+
* One advisor-transcript line rendered for the watcher, or `undefined` for the
|
|
87
|
+
* lines not worth a row. An advisor speaks in two forms, and an operator
|
|
88
|
+
* watching a run wants both:
|
|
89
|
+
*
|
|
90
|
+
* - plain assistant text, rendered exactly like the primary transcript's
|
|
91
|
+
* (one spelling, so the two cannot drift), and
|
|
92
|
+
* - `advise` tool calls — the reviewer's actual advisory, whose note and
|
|
93
|
+
* severity live in the call arguments and would otherwise be invisible.
|
|
94
|
+
*
|
|
95
|
+
* The reviewer's other tool calls (`glob`/`read` probes into the workspace)
|
|
96
|
+
* render nothing: they are the investigation, not the advice. Every rendered
|
|
97
|
+
* line is marked so the watcher can tell which reviewer produced it. Exported
|
|
98
|
+
* so a unit test pins the surface without a store.
|
|
99
|
+
*/
|
|
100
|
+
export function renderAdvisorLine(line: string): string | undefined {
|
|
101
|
+
let entry: unknown;
|
|
102
|
+
try {
|
|
103
|
+
entry = JSON.parse(line);
|
|
104
|
+
} catch {
|
|
105
|
+
return undefined;
|
|
106
|
+
}
|
|
107
|
+
if (prop(entry, "type") !== "message") return undefined;
|
|
108
|
+
const message = prop(entry, "message");
|
|
109
|
+
if (prop(message, "role") !== "assistant") return undefined;
|
|
110
|
+
|
|
111
|
+
const content = prop(message, "content");
|
|
112
|
+
// A string-typed message is one plain text block — the same shape the
|
|
113
|
+
// primary transcript's formatter accepts, so an advisor advisory written as
|
|
114
|
+
// a bare string is not silently dropped.
|
|
115
|
+
if (typeof content === "string") {
|
|
116
|
+
return content.trim() === "" ? undefined : `[advisor] assistant: ${content.trim()}`;
|
|
117
|
+
}
|
|
118
|
+
const blocks: readonly unknown[] = Array.isArray(content) ? content : [];
|
|
119
|
+
const out: string[] = [];
|
|
120
|
+
for (const block of blocks) {
|
|
121
|
+
const type = prop(block, "type");
|
|
122
|
+
if (type === "text") {
|
|
123
|
+
const text = prop(block, "text");
|
|
124
|
+
if (typeof text === "string" && text.trim() !== "") out.push(`assistant: ${text.trim()}`);
|
|
125
|
+
} else if (type === "toolCall" && prop(block, "name") === "advise") {
|
|
126
|
+
let args = prop(block, "arguments");
|
|
127
|
+
if (typeof args === "string") {
|
|
128
|
+
try {
|
|
129
|
+
args = JSON.parse(args);
|
|
130
|
+
} catch {
|
|
131
|
+
continue;
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
const note = prop(args, "note");
|
|
135
|
+
if (typeof note === "string" && note.trim() !== "") {
|
|
136
|
+
const severity = prop(args, "severity");
|
|
137
|
+
const label = typeof severity === "string" && severity !== "" ? severity : "nit";
|
|
138
|
+
out.push(`${label}: ${note.trim()}`);
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
return out.length === 0 ? undefined : `[advisor] ${out.join("\n")}`;
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
/**
|
|
146
|
+
* Follow one run's transcripts the way `tail -f` follows a log: the worker's
|
|
147
|
+
* own transcript plus any `__advisor*.jsonl` sitting beside it (the mid-run
|
|
148
|
+
* reviewer's turns, #542), so an advisory the reviewer raised is visible in
|
|
149
|
+
* the same stream as the work it reacted to.
|
|
28
150
|
*
|
|
29
151
|
* Reads from byte zero rather than from the end: attaching to a worker that is
|
|
30
152
|
* already ten turns in and then showing nothing until turn eleven is not
|
|
@@ -36,10 +158,24 @@ const TAIL_QUIET_MS = 5_000;
|
|
|
36
158
|
* here is buffered, and a handler could only add a poll interval of latency to
|
|
37
159
|
* every Ctrl-C.
|
|
38
160
|
*/
|
|
39
|
-
async function tailRun(
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
161
|
+
export async function tailRun(
|
|
162
|
+
project: string,
|
|
163
|
+
issue: number,
|
|
164
|
+
deps: {
|
|
165
|
+
/** Read-only in practice: opened WAL with a busy timeout. Tests inject one. */
|
|
166
|
+
store?: Store;
|
|
167
|
+
/** How often the transcripts are re-statted (tests shrink it). */
|
|
168
|
+
pollMs?: number;
|
|
169
|
+
/** How long a terminal run must stay quiet before the watcher exits. */
|
|
170
|
+
quietMs?: number;
|
|
171
|
+
/** Where rendered lines go; the CLI writes to stdout, tests capture. */
|
|
172
|
+
write?: (line: string) => void;
|
|
173
|
+
} = {},
|
|
174
|
+
): Promise<void> {
|
|
175
|
+
const store = deps.store ?? openStore(dbPath());
|
|
176
|
+
const write = deps.write ?? ((line: string) => process.stdout.write(`${line}\n`));
|
|
177
|
+
const pollMs = deps.pollMs ?? TAIL_POLL_MS;
|
|
178
|
+
const quietMs = deps.quietMs ?? TAIL_QUIET_MS;
|
|
43
179
|
try {
|
|
44
180
|
const run = store.latestRun(project, issue);
|
|
45
181
|
if (run === undefined) throw new Error(`no run recorded for #${issue}`);
|
|
@@ -47,56 +183,80 @@ async function tailRun(project: string, issue: number): Promise<void> {
|
|
|
47
183
|
// Claimed but not yet started, or an attempt whose session never opened one.
|
|
48
184
|
if (path === undefined) throw new Error(`no transcript yet (state: ${run.state})`);
|
|
49
185
|
|
|
50
|
-
const
|
|
186
|
+
const primary: FollowedStream = { fd: openSync(path, "r"), offset: 0, pending: Buffer.alloc(0) };
|
|
187
|
+
// Advisor transcripts live in the directory named after the primary
|
|
188
|
+
// transcript stem; the harness writes them there beside the session file.
|
|
189
|
+
const advisorDir = path.endsWith(".jsonl") ? path.slice(0, -".jsonl".length) : undefined;
|
|
190
|
+
const advisors = new Map<string, FollowedStream>();
|
|
51
191
|
try {
|
|
52
|
-
let offset = 0;
|
|
53
|
-
let pending = Buffer.alloc(0);
|
|
54
192
|
let lastChange = Date.now();
|
|
55
193
|
|
|
56
194
|
for (;;) {
|
|
57
|
-
let
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
195
|
+
let changed = false;
|
|
196
|
+
const primaryDelta = followLines(primary, path);
|
|
197
|
+
changed = primaryDelta.changed || changed;
|
|
198
|
+
for (const raw of primaryDelta.lines) {
|
|
199
|
+
const rendered = formatTranscriptLine(raw.toString("utf8"));
|
|
200
|
+
if (rendered !== undefined) write(rendered);
|
|
63
201
|
}
|
|
64
|
-
|
|
65
|
-
//
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
const
|
|
81
|
-
|
|
82
|
-
if (
|
|
202
|
+
|
|
203
|
+
// Follow every advisor transcript that appears beside the primary one.
|
|
204
|
+
// Files land mid-run (the reviewer's first turn), so each poll re-lists
|
|
205
|
+
// the directory and opens whatever is new.
|
|
206
|
+
if (advisorDir !== undefined) {
|
|
207
|
+
let names: string[] = [];
|
|
208
|
+
try {
|
|
209
|
+
names = readdirSync(advisorDir).filter(
|
|
210
|
+
(name) =>
|
|
211
|
+
name.startsWith(ADVISOR_TRANSCRIPT_PREFIX) &&
|
|
212
|
+
name.endsWith(".jsonl"),
|
|
213
|
+
);
|
|
214
|
+
} catch {
|
|
215
|
+
// No advisor directory — nothing to surface.
|
|
216
|
+
}
|
|
217
|
+
for (const name of names) {
|
|
218
|
+
const file = join(advisorDir, name);
|
|
219
|
+
let state = advisors.get(file);
|
|
220
|
+
if (state === undefined) {
|
|
221
|
+
try {
|
|
222
|
+
state = { fd: openSync(file, "r"), offset: 0, pending: Buffer.alloc(0) };
|
|
223
|
+
advisors.set(file, state);
|
|
224
|
+
} catch {
|
|
225
|
+
continue;
|
|
226
|
+
}
|
|
227
|
+
}
|
|
228
|
+
const advisorDelta = followLines(state, file);
|
|
229
|
+
changed = advisorDelta.changed || changed;
|
|
230
|
+
for (const raw of advisorDelta.lines) {
|
|
231
|
+
const rendered = renderAdvisorLine(raw.toString("utf8"));
|
|
232
|
+
if (rendered !== undefined) write(rendered);
|
|
233
|
+
}
|
|
234
|
+
}
|
|
235
|
+
// Drop watched files that vanished (mid-run rotation is rare, but a
|
|
236
|
+
// stale descriptor would otherwise pin the old inode forever).
|
|
237
|
+
for (const [file, state] of advisors) {
|
|
238
|
+
if (!names.includes(file.slice(advisorDir.length + 1))) {
|
|
239
|
+
closeSync(state.fd);
|
|
240
|
+
advisors.delete(file);
|
|
241
|
+
}
|
|
83
242
|
}
|
|
84
|
-
if (read > 0) lastChange = Date.now();
|
|
85
243
|
}
|
|
244
|
+
if (changed) lastChange = Date.now();
|
|
86
245
|
|
|
87
246
|
// Re-read this exact run every poll — not `latestRun`, which would jump
|
|
88
247
|
// to a retry started meanwhile and report its state against the wrong
|
|
89
248
|
// transcript. The daemon writes the row from another process, so looking
|
|
90
249
|
// is the only way to notice the run finished.
|
|
91
250
|
const state = store.getRun(run.id)?.state ?? run.state;
|
|
92
|
-
if (!LIVE_STATES.includes(state) && Date.now() - lastChange >=
|
|
93
|
-
|
|
251
|
+
if (!LIVE_STATES.includes(state) && Date.now() - lastChange >= quietMs) {
|
|
252
|
+
write(`run ended: ${state}`);
|
|
94
253
|
return;
|
|
95
254
|
}
|
|
96
|
-
await new Promise<void>((resolve) => setTimeout(resolve,
|
|
255
|
+
await new Promise<void>((resolve) => setTimeout(resolve, pollMs));
|
|
97
256
|
}
|
|
98
257
|
} finally {
|
|
99
|
-
closeSync(fd);
|
|
258
|
+
closeSync(primary.fd);
|
|
259
|
+
for (const state of advisors.values()) closeSync(state.fd);
|
|
100
260
|
}
|
|
101
261
|
} finally {
|
|
102
262
|
store.close();
|
|
@@ -104,6 +264,6 @@ async function tailRun(project: string, issue: number): Promise<void> {
|
|
|
104
264
|
}
|
|
105
265
|
|
|
106
266
|
export async function tailCommand(ctx: CommandContext): Promise<void> {
|
|
107
|
-
const issue = ctx.issueArg("tail", ctx.argv[1]);
|
|
108
|
-
await tailRun(findProject(loadConfig(), ctx.projectFlag).name, issue);
|
|
267
|
+
const issue = ctx.issueArg("tail", ctx.argv[1]);
|
|
268
|
+
await tailRun(findProject(loadConfig(), ctx.projectFlag).name, issue);
|
|
109
269
|
}
|
package/src/config-schema.ts
CHANGED
|
@@ -325,6 +325,10 @@ const projectSchema = z
|
|
|
325
325
|
// schema owns. Conductor validates YAML shape only — the loader keeps it
|
|
326
326
|
// when it is a mapping and drops anything else, like `modelFallbacks`.
|
|
327
327
|
ompSettings: z.unknown().optional(),
|
|
328
|
+
// Opt-in omp advisor on workers (#542). Only the literal boolean `true`
|
|
329
|
+
// opts in (it stages `advisor.enabled: true` into the omp settings
|
|
330
|
+
// overlay); anything else is dropped by the loader, like `workerModel`.
|
|
331
|
+
workerAdvisor: z.unknown().optional(),
|
|
328
332
|
escalation: escalationSchema.optional(),
|
|
329
333
|
authority: authoritySchema.optional(),
|
|
330
334
|
releasePolicy: releasePolicySchema.optional(),
|
package/src/config.ts
CHANGED
|
@@ -1122,6 +1122,36 @@ function finalizeProject(
|
|
|
1122
1122
|
? (rawOmpSettings as Record<string, unknown>)
|
|
1123
1123
|
: undefined;
|
|
1124
1124
|
|
|
1125
|
+
// Opt-in omp advisor on this project's workers (#542). `workerAdvisor: true`
|
|
1126
|
+
// stages `advisor.enabled: true` into the omp settings overlay — the same
|
|
1127
|
+
// #537 channel everything else uses — so the advisor's own behaviour (roster,
|
|
1128
|
+
// model, tools) stays omp's to resolve from the staged or global config, and
|
|
1129
|
+
// conductor never names a model or grants a tool. Only the literal boolean
|
|
1130
|
+
// `true` opts in; anything else (absent, false, a string) is dropped like an
|
|
1131
|
+
// unusable `modelFallbacks` entry, keeping today's dispatch byte for byte.
|
|
1132
|
+
const workerAdvisor = p["workerAdvisor"] === true;
|
|
1133
|
+
const effectiveOmpSettings =
|
|
1134
|
+
workerAdvisor || ompSettings !== undefined
|
|
1135
|
+
? {
|
|
1136
|
+
...(ompSettings === undefined ? {} : ompSettings),
|
|
1137
|
+
...(workerAdvisor
|
|
1138
|
+
? {
|
|
1139
|
+
// Merge, not replace: an operator's own `ompSettings.advisor`
|
|
1140
|
+
// block (roster or model overrides) survives; only `enabled` is
|
|
1141
|
+
// forced on, because that is the point of the opt-in.
|
|
1142
|
+
advisor: {
|
|
1143
|
+
...(typeof ompSettings?.advisor === "object" &&
|
|
1144
|
+
ompSettings.advisor !== null &&
|
|
1145
|
+
!Array.isArray(ompSettings.advisor)
|
|
1146
|
+
? (ompSettings.advisor as Record<string, unknown>)
|
|
1147
|
+
: {}),
|
|
1148
|
+
enabled: true,
|
|
1149
|
+
},
|
|
1150
|
+
}
|
|
1151
|
+
: {}),
|
|
1152
|
+
}
|
|
1153
|
+
: undefined;
|
|
1154
|
+
|
|
1125
1155
|
// Marked critical-base/safety markers (commit SHAs or refs). A continuation
|
|
1126
1156
|
// branch must contain every one before the dispatcher may reattach it;
|
|
1127
1157
|
// unusable entries are dropped like `modelFallbacks` and an absent or empty
|
|
@@ -1151,7 +1181,7 @@ function finalizeProject(
|
|
|
1151
1181
|
...(workerModel === undefined ? {} : { workerModel }),
|
|
1152
1182
|
...(modelFallbacks.length === 0 ? {} : { modelFallbacks }),
|
|
1153
1183
|
...(modelFallbackThreshold === undefined ? {} : { modelFallbackThreshold }),
|
|
1154
|
-
...(
|
|
1184
|
+
...(effectiveOmpSettings === undefined ? {} : { ompSettings: effectiveOmpSettings }),
|
|
1155
1185
|
escalation,
|
|
1156
1186
|
authority,
|
|
1157
1187
|
releasePolicy,
|
package/src/daemon.ts
CHANGED
|
@@ -296,6 +296,13 @@ interface Deps {
|
|
|
296
296
|
* probe the way `criticalBase` does.
|
|
297
297
|
*/
|
|
298
298
|
probeWorktreeLane?: RunLaneProbe;
|
|
299
|
+
/**
|
|
300
|
+
* Reads one issue's tracker state in a repository the admission tracker is
|
|
301
|
+
* not bound to — the cross-repo Depends-on interlock (#420). Wired by
|
|
302
|
+
* `runDaemon` to a repo-scoped tracker; a test injects a fake. Absent,
|
|
303
|
+
* admission fails a routed cross-repo prerequisite closed.
|
|
304
|
+
*/
|
|
305
|
+
probeIssueIn?: (repo: string, issue: number) => Promise<IssueSnapshot | undefined>;
|
|
299
306
|
}
|
|
300
307
|
|
|
301
308
|
/**
|
|
@@ -5584,6 +5591,15 @@ export async function runDaemon(o: DaemonOpts = {}): Promise<void> {
|
|
|
5584
5591
|
probeCriticalBase: (repo, markers, branch) =>
|
|
5585
5592
|
probeCriticalBase(project, repo, branch, markers),
|
|
5586
5593
|
probeWorktreeLane: (input) => probeRunLane(input),
|
|
5594
|
+
// A cross-repo Depends-on prerequisite reads through the same GitHub
|
|
5595
|
+
// credential/accounting seams as the project tracker — a fresh tracker
|
|
5596
|
+
// scoped to the referenced repo, reusing the daemon's gh hooks so API
|
|
5597
|
+
// spend and refusals are counted exactly as the main tracker's are.
|
|
5598
|
+
probeIssueIn: (ownerRepo, issue) =>
|
|
5599
|
+
makeTracker({ ...project, tracker: { kind: "github", repo: ownerRepo } }, undefined, {
|
|
5600
|
+
onCall: () => store.bumpGhCalls?.(utcDay(), "daemon"),
|
|
5601
|
+
onRefusal: (at) => store.recordGhRefusal?.(at),
|
|
5602
|
+
}).issueSnapshot(issue),
|
|
5587
5603
|
...(verbPeerReader === undefined ? {} : { verbPeerReader }),
|
|
5588
5604
|
verbActions,
|
|
5589
5605
|
};
|