omp-conductor 0.2.2 → 0.3.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 +212 -35
- package/package.json +1 -1
- package/skills/conductor-onboarding/SKILL.md +33 -22
- package/src/briefs/orchestrator.md +46 -14
- package/src/briefs/worker.md +9 -2
- package/src/cli.ts +291 -13
- package/src/config.ts +127 -16
- package/src/daemon.ts +474 -50
- package/src/lifecycle.ts +6 -4
- package/src/orchestrator-tick.ts +209 -45
- package/src/plugin.ts +60 -0
- package/src/setup.ts +98 -4
- package/src/store.ts +20 -1
- package/src/tracker/github.ts +94 -2
- package/src/types.ts +56 -3
- package/src/unblock.ts +113 -0
- package/src/worker.ts +14 -0
- package/src/worktree.ts +119 -0
package/src/setup.ts
CHANGED
|
@@ -26,10 +26,12 @@ import { dirname, join } from "node:path";
|
|
|
26
26
|
import { configPath, resolveCaps, stateDir } from "./config.ts";
|
|
27
27
|
import {
|
|
28
28
|
CONFIG_VERSION,
|
|
29
|
+
DEFAULT_AUTHORITY,
|
|
29
30
|
DEFAULT_CAPS,
|
|
30
31
|
DEFAULT_REPORT_SCOPE,
|
|
31
32
|
type Caps,
|
|
32
33
|
type ConductorConfig,
|
|
34
|
+
type OrchestratorMode,
|
|
33
35
|
type ProjectConfig,
|
|
34
36
|
type ReportScope,
|
|
35
37
|
type RepoTarget,
|
|
@@ -67,6 +69,20 @@ export interface SetupAnswers {
|
|
|
67
69
|
* prompt that asked it to the step that acts on it.
|
|
68
70
|
*/
|
|
69
71
|
writeOrchestratorBrief: boolean;
|
|
72
|
+
/**
|
|
73
|
+
* Who lands green PRs and who cuts releases. Both default to the human — see
|
|
74
|
+
* {@link DEFAULT_AUTHORITY} — and the answer is what the rendered brief's
|
|
75
|
+
* Releases paragraph states, so the session can never read a delegation the
|
|
76
|
+
* config does not grant.
|
|
77
|
+
*/
|
|
78
|
+
authority: ProjectConfig["authority"];
|
|
79
|
+
/**
|
|
80
|
+
* Whether the daemon runs its own triage session, or an operator already runs
|
|
81
|
+
* one elsewhere. `external` on a host where the orchestrator is a visible TUI
|
|
82
|
+
* session: the daemon then posts tier-1 escalations as issue comments for
|
|
83
|
+
* that session to drain, rather than starting a second brain.
|
|
84
|
+
*/
|
|
85
|
+
orchestratorMode: OrchestratorMode;
|
|
70
86
|
}
|
|
71
87
|
|
|
72
88
|
/** What `gh auth status` says the active token may do. */
|
|
@@ -93,6 +109,10 @@ export const SETUP_DEFAULTS = {
|
|
|
93
109
|
stateLabels: { inProgress: "agent:in-progress", blocked: "agent:blocked", failed: "agent:failed" },
|
|
94
110
|
routingLabelPrefix: "repo:",
|
|
95
111
|
defaultBranch: "main",
|
|
112
|
+
/** Both authorities start with the human; the wizard asks to move each one. */
|
|
113
|
+
authority: DEFAULT_AUTHORITY,
|
|
114
|
+
/** The daemon runs its own triage session unless an operator already runs one. */
|
|
115
|
+
orchestratorMode: "embedded",
|
|
96
116
|
} as const;
|
|
97
117
|
|
|
98
118
|
/**
|
|
@@ -114,6 +134,57 @@ export const REPORT_SCOPE_CHOICES: readonly { scope: ReportScope; label: string;
|
|
|
114
134
|
},
|
|
115
135
|
];
|
|
116
136
|
|
|
137
|
+
/**
|
|
138
|
+
* The Releases paragraph the brief opens with, one per authority combination.
|
|
139
|
+
*
|
|
140
|
+
* Rendered rather than written by hand for the reason the standing orders are
|
|
141
|
+
* worded from the same config: an operator who delegated merging in setup and a
|
|
142
|
+
* brief that still says "you do not merge" is a session that has been given two
|
|
143
|
+
* answers and will act on whichever it read last.
|
|
144
|
+
*
|
|
145
|
+
* A mapped type over both holders, so adding a third holder fails to compile
|
|
146
|
+
* here instead of rendering `undefined` into somebody's standing prompt.
|
|
147
|
+
*/
|
|
148
|
+
export const RELEASES_DEFAULTS: {
|
|
149
|
+
readonly [K in `${ProjectConfig["authority"]["merge"]}/${ProjectConfig["authority"]["release"]}`]: string;
|
|
150
|
+
} = {
|
|
151
|
+
"human/human":
|
|
152
|
+
'**Default: humans release, and you do not merge.** Work ends at a green PR;\n' +
|
|
153
|
+
"merging is a separate human action, and releasing is a separate human action after\n" +
|
|
154
|
+
'that. "This needs releasing" is something you report, never something you take on.',
|
|
155
|
+
"orchestrator/orchestrator":
|
|
156
|
+
"**Delegated in setup: you merge, and you release.** Merge green PRs one at a\n" +
|
|
157
|
+
"time, re-checked against the base branch first. Cut releases per the procedure\n" +
|
|
158
|
+
"your operator writes below — do not cut one before the seven specifics are\n" +
|
|
159
|
+
"filled in.",
|
|
160
|
+
"orchestrator/human":
|
|
161
|
+
"**Delegated in setup: you merge; humans release.** Merge green PRs one at a\n" +
|
|
162
|
+
'time, re-checked against the base branch first. "This needs releasing" is\n' +
|
|
163
|
+
"something you report, never something you take on.",
|
|
164
|
+
"human/orchestrator":
|
|
165
|
+
"**Delegated in setup: humans merge; you release.** You cut releases from work a\n" +
|
|
166
|
+
"human has already merged, per the procedure your operator writes below.",
|
|
167
|
+
};
|
|
168
|
+
|
|
169
|
+
/**
|
|
170
|
+
* Duty 1's "the PR is green" branch, worded from `authority.merge`.
|
|
171
|
+
*
|
|
172
|
+
* The duty has to name an action, and which action depends on the grant. A
|
|
173
|
+
* session told "merging is yours" in its standing orders and told "you do not
|
|
174
|
+
* merge it" three sections into its own brief will do whichever it read last,
|
|
175
|
+
* which is the failure this whole key exists to prevent. The bullet marker is
|
|
176
|
+
* part of the value so the template line is nothing but the placeholder.
|
|
177
|
+
*/
|
|
178
|
+
export const MERGE_DUTY: { readonly [K in ProjectConfig["authority"]["merge"]]: string } = {
|
|
179
|
+
human:
|
|
180
|
+
"- **It is already done.** The PR is green and waiting on a human merge. Note it,\n" +
|
|
181
|
+
" with the link, and move on. You do not merge it.",
|
|
182
|
+
orchestrator:
|
|
183
|
+
"- **It is already done.** The PR is green, and merging is yours. Re-check it\n" +
|
|
184
|
+
" against the base branch, merge it, and note the link. One PR at a time — that\n" +
|
|
185
|
+
" one is a hard boundary, not a preference.",
|
|
186
|
+
};
|
|
187
|
+
|
|
117
188
|
/** The operator's own brief, rendered into the project's workspace root. */
|
|
118
189
|
export const ORCHESTRATOR_BRIEF_NAME = "ORCHESTRATOR.md";
|
|
119
190
|
|
|
@@ -336,7 +407,10 @@ function buildProject(a: SetupAnswers): ProjectConfig {
|
|
|
336
407
|
};
|
|
337
408
|
}
|
|
338
409
|
|
|
339
|
-
const escalation: ProjectConfig["escalation"] = {
|
|
410
|
+
const escalation: ProjectConfig["escalation"] = {
|
|
411
|
+
fallbackToIssueComment: a.fallbackToIssueComment,
|
|
412
|
+
orchestrator: a.orchestratorMode,
|
|
413
|
+
};
|
|
340
414
|
if (a.telegramChatId !== undefined && a.telegramChatId.trim().length > 0) {
|
|
341
415
|
escalation.telegramChatId = a.telegramChatId.trim();
|
|
342
416
|
}
|
|
@@ -359,6 +433,7 @@ function buildProject(a: SetupAnswers): ProjectConfig {
|
|
|
359
433
|
? { workerModel: a.workerModel.trim() }
|
|
360
434
|
: {}),
|
|
361
435
|
escalation,
|
|
436
|
+
authority: { ...a.authority },
|
|
362
437
|
reporting: { scope: a.reportScope },
|
|
363
438
|
// Both under the state dir so one `rm -rf ~/.omp/conductor` is a complete
|
|
364
439
|
// uninstall, and neither can land in a repo the daemon then tries to commit.
|
|
@@ -415,9 +490,10 @@ export function shippedBriefTemplate(): string {
|
|
|
415
490
|
/**
|
|
416
491
|
* The shipped template with a configured project's real values in it.
|
|
417
492
|
*
|
|
418
|
-
* Only the coordinates
|
|
419
|
-
*
|
|
420
|
-
* edit and nothing in this package
|
|
493
|
+
* Only the coordinates, the chosen scope and the authority paragraph are
|
|
494
|
+
* substituted: the rest of the policy text is left exactly as shipped, because
|
|
495
|
+
* from here on the file is the operator's to edit and nothing in this package
|
|
496
|
+
* reads it back.
|
|
421
497
|
*
|
|
422
498
|
* Takes a `ProjectConfig` rather than answers so that a *later* upgrade check can
|
|
423
499
|
* reproduce the same render from what is on disk, months after the wizard's
|
|
@@ -428,6 +504,8 @@ export function renderBriefForProject(p: ProjectConfig): string {
|
|
|
428
504
|
PROJECT: p.name,
|
|
429
505
|
TRACKER_REPO: p.tracker.repo,
|
|
430
506
|
QUEUE_LABEL: p.queueLabel,
|
|
507
|
+
RELEASES_DEFAULT: RELEASES_DEFAULTS[`${p.authority.merge}/${p.authority.release}`],
|
|
508
|
+
MERGE_DUTY: MERGE_DUTY[p.authority.merge],
|
|
431
509
|
REPORT_SCOPE: p.reporting?.scope ?? DEFAULT_REPORT_SCOPE,
|
|
432
510
|
});
|
|
433
511
|
}
|
|
@@ -641,6 +719,22 @@ export function summarisePlan(
|
|
|
641
719
|
);
|
|
642
720
|
}
|
|
643
721
|
lines.push(` fallback ${a.fallbackToIssueComment ? "comment on the issue as well" : "disabled"}`);
|
|
722
|
+
lines.push(
|
|
723
|
+
` triage ${
|
|
724
|
+
a.orchestratorMode === "external"
|
|
725
|
+
? "external — the daemon starts no session; an operator's own drains tier 1 off the tracker"
|
|
726
|
+
: "embedded — the daemon runs its own orchestrator session"
|
|
727
|
+
}`,
|
|
728
|
+
);
|
|
729
|
+
|
|
730
|
+
const delegated = a.authority.merge === "orchestrator" || a.authority.release === "orchestrator";
|
|
731
|
+
lines.push(
|
|
732
|
+
"",
|
|
733
|
+
`authority merge=${a.authority.merge} release=${a.authority.release}`,
|
|
734
|
+
delegated
|
|
735
|
+
? " the brief tells that session so, and it must spell the procedure out before acting"
|
|
736
|
+
: " humans do both; workers and the conductor stop at a green PR",
|
|
737
|
+
);
|
|
644
738
|
|
|
645
739
|
const chosen = REPORT_SCOPE_CHOICES.find((c) => c.scope === a.reportScope);
|
|
646
740
|
const briefPath = orchestratorBriefPath(a);
|
package/src/store.ts
CHANGED
|
@@ -18,8 +18,12 @@ import type { RunRecord, RunState, Store } from "./types.ts";
|
|
|
18
18
|
/**
|
|
19
19
|
* States backed by a worker process. These are what worker capacity counts:
|
|
20
20
|
* a slot is a process, and only a claimed or running attempt has one.
|
|
21
|
+
*
|
|
22
|
+
* Exported because it is also the answer to "is anything still writing to this
|
|
23
|
+
* run's transcript?" — `omp-conductor tail` needs that and must not re-derive
|
|
24
|
+
* it, or the two definitions drift the first time a state is added.
|
|
21
25
|
*/
|
|
22
|
-
const LIVE_STATES: readonly RunState[] = ["claimed", "running"];
|
|
26
|
+
export const LIVE_STATES: readonly RunState[] = ["claimed", "running"];
|
|
23
27
|
|
|
24
28
|
/**
|
|
25
29
|
* States that keep an *issue* occupied. `pushed-green` belongs here but not in
|
|
@@ -182,6 +186,16 @@ export function openStore(dbPath: string): Store {
|
|
|
182
186
|
const countAttempts = db.query<{ n: number }, [string, number]>(
|
|
183
187
|
`SELECT COUNT(*) AS n FROM runs WHERE project = ? AND issue = ?`,
|
|
184
188
|
);
|
|
189
|
+
// Newest attempt for one issue. `startedAt` is millisecond-resolution and two
|
|
190
|
+
// attempts could in principle share one, so rowid breaks the tie by insertion
|
|
191
|
+
// order — a `tail` that attached to the older of two same-millisecond attempts
|
|
192
|
+
// would follow a transcript nobody is writing to any more.
|
|
193
|
+
const selectLatestRun = db.query<RunRow, [string, number]>(
|
|
194
|
+
`SELECT * FROM runs
|
|
195
|
+
WHERE project = ? AND issue = ?
|
|
196
|
+
ORDER BY startedAt DESC, rowid DESC
|
|
197
|
+
LIMIT 1`,
|
|
198
|
+
);
|
|
185
199
|
const countStartedSince = db.query<{ n: number }, [string, number]>(
|
|
186
200
|
`SELECT COUNT(*) AS n FROM runs WHERE project = ? AND startedAt >= ?`,
|
|
187
201
|
);
|
|
@@ -255,6 +269,11 @@ export function openStore(dbPath: string): Store {
|
|
|
255
269
|
return countAttempts.get(project, issue)?.n ?? 0;
|
|
256
270
|
},
|
|
257
271
|
|
|
272
|
+
latestRun(project: string, issue: number): RunRecord | undefined {
|
|
273
|
+
const row = selectLatestRun.get(project, issue);
|
|
274
|
+
return row ? toRecord(row) : undefined;
|
|
275
|
+
},
|
|
276
|
+
|
|
258
277
|
runsStartedSince(project: string, sinceEpochMs: number): number {
|
|
259
278
|
return countStartedSince.get(project, sinceEpochMs)?.n ?? 0;
|
|
260
279
|
},
|
package/src/tracker/github.ts
CHANGED
|
@@ -10,7 +10,7 @@
|
|
|
10
10
|
* ~200-400ms) and failure classification by matching human-readable stderr
|
|
11
11
|
* instead of reading a status code. Upgrade path when either bites: replace the
|
|
12
12
|
* body of `gh()` with `fetch("https://api.github.com/...")` using a token from
|
|
13
|
-
* `gh auth token`; the
|
|
13
|
+
* `gh auth token`; the seven Tracker methods above it stay untouched.
|
|
14
14
|
*/
|
|
15
15
|
|
|
16
16
|
import type { ProjectConfig, ReadyIssue, Tracker } from "../types.ts";
|
|
@@ -26,6 +26,47 @@ interface GhIssue {
|
|
|
26
26
|
updatedAt: string;
|
|
27
27
|
}
|
|
28
28
|
|
|
29
|
+
/**
|
|
30
|
+
* Pull requests that would close an issue, with the one field that decides it.
|
|
31
|
+
*
|
|
32
|
+
* `gh issue view <n> --json closedByPullRequestsReferences` returns `id`,
|
|
33
|
+
* `number`, `repository` and `url` and no state, so a guard built on it holds an
|
|
34
|
+
* issue forever on a reference that is merged or closed-unmerged — which is
|
|
35
|
+
* exactly what a reopened issue looks like. GraphQL is the only spelling that
|
|
36
|
+
* yields `state`, so it is the spelling used.
|
|
37
|
+
*
|
|
38
|
+
* `includeClosedPrs:false` is deliberately not passed: it is not the filter it
|
|
39
|
+
* sounds like. Measured on gh 2.86.0 (2026-08-06), `veltro#260` returned a
|
|
40
|
+
* MERGED reference with the argument both true and false. The state is filtered
|
|
41
|
+
* in this consumer instead.
|
|
42
|
+
*
|
|
43
|
+
* ponytail: one page of ten. An issue closed by more than ten PRs is not a
|
|
44
|
+
* dispatch problem, and the first OPEN one already answers the question.
|
|
45
|
+
*/
|
|
46
|
+
const CLOSERS_QUERY = `query($owner:String!,$repo:String!,$n:Int!){
|
|
47
|
+
repository(owner:$owner,name:$repo){
|
|
48
|
+
issue(number:$n){
|
|
49
|
+
closedByPullRequestsReferences(first:10){
|
|
50
|
+
nodes{ number state isDraft url repository{ nameWithOwner } }
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
}`;
|
|
55
|
+
|
|
56
|
+
/** The `gh api graphql` envelope for {@link CLOSERS_QUERY}. Every level is
|
|
57
|
+
* nullable: a deleted or wrong-numbered issue answers `null`, not an error. */
|
|
58
|
+
interface ClosersResponse {
|
|
59
|
+
data?: {
|
|
60
|
+
repository?: {
|
|
61
|
+
issue?: {
|
|
62
|
+
closedByPullRequestsReferences?: {
|
|
63
|
+
nodes?: ({ state: string; isDraft: boolean; url: string } | null)[] | null;
|
|
64
|
+
} | null;
|
|
65
|
+
} | null;
|
|
66
|
+
} | null;
|
|
67
|
+
} | null;
|
|
68
|
+
}
|
|
69
|
+
|
|
29
70
|
/** Carries the captured stderr so callers can classify a failure without
|
|
30
71
|
* re-running the command or parsing the message text of a plain Error. */
|
|
31
72
|
class GhError extends Error {
|
|
@@ -74,17 +115,45 @@ async function gh(argv: string[], stdin?: string): Promise<string> {
|
|
|
74
115
|
* True when a label edit failed only because the requested end state already
|
|
75
116
|
* holds. Adding a label is idempotent server-side, but removing one that is not
|
|
76
117
|
* present is a 404, and a concurrent daemon restart can easily race into both.
|
|
118
|
+
*
|
|
119
|
+
* The quoted form is gh's own, and it is why the two operations are classified
|
|
120
|
+
* separately: `gh issue edit --remove-label x` on a label the *repository* does
|
|
121
|
+
* not define exits 1 with `'x' not found` (2.97.0), which for a removal is the
|
|
122
|
+
* end state already holding — a label nobody defined cannot be on an issue —
|
|
123
|
+
* while for an add it is a genuine failure. Removing a label the repo defines
|
|
124
|
+
* but the issue does not carry is already a silent success, so this path is
|
|
125
|
+
* reached only by the undefined-label case, most often a state label an
|
|
126
|
+
* operator declined to create at setup.
|
|
77
127
|
*/
|
|
78
128
|
function isLabelNoop(err: unknown, op: "add" | "remove"): boolean {
|
|
79
129
|
if (!(err instanceof GhError)) return false;
|
|
80
130
|
const stderr = err.stderr;
|
|
81
131
|
return op === "add"
|
|
82
132
|
? /already (?:has|had|exists|applied|added)|label .* already/i.test(stderr)
|
|
83
|
-
: /label does not exist|not labeled|does not have (?:that|the|this) label|label .* not found|not found on (?:this )?issue/i.test(
|
|
133
|
+
: /label does not exist|not labeled|does not have (?:that|the|this) label|label .* not found|not found on (?:this )?issue|'[^']+' not found/i.test(
|
|
84
134
|
stderr,
|
|
85
135
|
);
|
|
86
136
|
}
|
|
87
137
|
|
|
138
|
+
/**
|
|
139
|
+
* The URL of the first OPEN closer in a `gh api graphql` reply, if any.
|
|
140
|
+
*
|
|
141
|
+
* Split from the call so the state filter — the only real logic in this file —
|
|
142
|
+
* is pinned against recorded payloads instead of a live repo.
|
|
143
|
+
*
|
|
144
|
+
* A draft counts. `isDraft` is selected because the API offers it, not because
|
|
145
|
+
* it changes the answer: draft means "not ready to review", not "not pushed",
|
|
146
|
+
* and the branch behind a draft still holds the only copy of the work. Sending
|
|
147
|
+
* a second worker at it duplicates that work exactly as much as a ready PR
|
|
148
|
+
* would, so OPEN is the whole test.
|
|
149
|
+
*/
|
|
150
|
+
export function firstOpenCloser(raw: string): string | undefined {
|
|
151
|
+
const nodes =
|
|
152
|
+
(JSON.parse(raw) as ClosersResponse).data?.repository?.issue?.closedByPullRequestsReferences
|
|
153
|
+
?.nodes ?? [];
|
|
154
|
+
return nodes.find((n) => n !== null && n.state === "OPEN")?.url;
|
|
155
|
+
}
|
|
156
|
+
|
|
88
157
|
export function makeTracker(p: ProjectConfig): Tracker {
|
|
89
158
|
const repo = p.tracker.repo;
|
|
90
159
|
|
|
@@ -156,5 +225,28 @@ export function makeTracker(p: ProjectConfig): Tracker {
|
|
|
156
225
|
// maintaining a second index of it.
|
|
157
226
|
await gh(["issue", "edit", String(parent), "--repo", repo, "--add-sub-issue", String(child)]);
|
|
158
227
|
},
|
|
228
|
+
|
|
229
|
+
async openCloserFor(issue: number): Promise<string | undefined> {
|
|
230
|
+
// GraphQL wants the halves of `owner/repo` separately. Config validates
|
|
231
|
+
// that spelling, so an empty half means a hand-edited config: `gh` then
|
|
232
|
+
// errors and the caller holds the candidate rather than guessing.
|
|
233
|
+
const [owner = "", name = ""] = repo.split("/");
|
|
234
|
+
const raw = await gh([
|
|
235
|
+
"api",
|
|
236
|
+
"graphql",
|
|
237
|
+
"-f",
|
|
238
|
+
`query=${CLOSERS_QUERY}`,
|
|
239
|
+
"-F",
|
|
240
|
+
`owner=${owner}`,
|
|
241
|
+
"-F",
|
|
242
|
+
`repo=${name}`,
|
|
243
|
+
// -F, not -f: the query declares $n as Int! and a string would be a
|
|
244
|
+
// type error rather than a coerced number.
|
|
245
|
+
"-F",
|
|
246
|
+
`n=${issue}`,
|
|
247
|
+
]);
|
|
248
|
+
|
|
249
|
+
return firstOpenCloser(raw);
|
|
250
|
+
},
|
|
159
251
|
};
|
|
160
252
|
}
|
package/src/types.ts
CHANGED
|
@@ -13,7 +13,7 @@
|
|
|
13
13
|
* counts turns, wall clock and dollars itself and kills anything over the line.
|
|
14
14
|
*/
|
|
15
15
|
export interface Caps {
|
|
16
|
-
/** Parallel omp sessions. Two
|
|
16
|
+
/** Parallel omp sessions. Two by default: on a small self-hosted runner pool
|
|
17
17
|
* a third worker would starve its own PR checks. */
|
|
18
18
|
maxConcurrentWorkers: number;
|
|
19
19
|
/** Rolling-day spend ceiling; the loop stops claiming work once it is hit. */
|
|
@@ -65,6 +65,33 @@ export type ReportScope = (typeof REPORT_SCOPES)[number];
|
|
|
65
65
|
*/
|
|
66
66
|
export const DEFAULT_REPORT_SCOPE: ReportScope = "material";
|
|
67
67
|
|
|
68
|
+
/**
|
|
69
|
+
* Who holds an authority the daemon itself never exercises. Declared as data
|
|
70
|
+
* for the same reason as {@link REPORT_SCOPES}: the validator, the wizard and
|
|
71
|
+
* the brief renderer all enumerate the same two holders, so a third one cannot
|
|
72
|
+
* be added while any of them still knows only two.
|
|
73
|
+
*/
|
|
74
|
+
export const AUTHORITY_HOLDERS = ["human", "orchestrator"] as const;
|
|
75
|
+
|
|
76
|
+
export type AuthorityHolder = (typeof AUTHORITY_HOLDERS)[number];
|
|
77
|
+
|
|
78
|
+
/**
|
|
79
|
+
* What a project that never answered the question gets: humans keep both. A
|
|
80
|
+
* default that delegated merging would hand a fresh fleet write access to its
|
|
81
|
+
* own main branch on the strength of an unread config file.
|
|
82
|
+
*/
|
|
83
|
+
export const DEFAULT_AUTHORITY: ProjectConfig["authority"] = { merge: "human", release: "human" };
|
|
84
|
+
|
|
85
|
+
/**
|
|
86
|
+
* Where the session that triages escalations lives. `embedded` is the daemon's
|
|
87
|
+
* own child session; `external` means an operator already runs one — a visible
|
|
88
|
+
* TUI session, say — and the daemon must not start a second brain that would
|
|
89
|
+
* re-triage the same issues from a different transcript.
|
|
90
|
+
*/
|
|
91
|
+
export const ORCHESTRATOR_MODES = ["embedded", "external"] as const;
|
|
92
|
+
|
|
93
|
+
export type OrchestratorMode = (typeof ORCHESTRATOR_MODES)[number];
|
|
94
|
+
|
|
68
95
|
/**
|
|
69
96
|
* Everything the dispatcher needs to service one product: where work comes
|
|
70
97
|
* from, where code goes, and what it may spend doing it. Config is per project
|
|
@@ -91,8 +118,18 @@ export interface ProjectConfig {
|
|
|
91
118
|
* answered the question wants.
|
|
92
119
|
*/
|
|
93
120
|
workerModel?: string;
|
|
94
|
-
/**
|
|
95
|
-
|
|
121
|
+
/**
|
|
122
|
+
* How a stuck run reaches a human, what to do when it cannot, and who runs
|
|
123
|
+
* the session that triages it. See {@link ORCHESTRATOR_MODES}.
|
|
124
|
+
*/
|
|
125
|
+
escalation: { telegramChatId?: string; fallbackToIssueComment: boolean; orchestrator: OrchestratorMode };
|
|
126
|
+
/**
|
|
127
|
+
* Who lands green PRs and who cuts releases. The daemon never acts on this
|
|
128
|
+
* itself — it words the orchestrator's standing orders and the rendered brief
|
|
129
|
+
* scaffold with it, so config and prompt can never disagree about which of
|
|
130
|
+
* them is holding the merge button.
|
|
131
|
+
*/
|
|
132
|
+
authority: { merge: AuthorityHolder; release: AuthorityHolder };
|
|
96
133
|
/**
|
|
97
134
|
* How loud the orchestrator is. Optional on disk — a config written before
|
|
98
135
|
* this key existed loads as {@link DEFAULT_REPORT_SCOPE} — so read it through
|
|
@@ -156,6 +193,18 @@ export interface Tracker {
|
|
|
156
193
|
/** Records that a worker split its issue, so follow-up work stays traceable
|
|
157
194
|
* to the request that spawned it. */
|
|
158
195
|
linkParent(child: number, parent: number): Promise<void>;
|
|
196
|
+
/**
|
|
197
|
+
* The URL of an OPEN pull request that already closes `issue`, or undefined
|
|
198
|
+
* when none does.
|
|
199
|
+
*
|
|
200
|
+
* Admission has to ask the tracker because the store cannot answer. The busy
|
|
201
|
+
* set is built from run rows, so it only knows work *this* database recorded:
|
|
202
|
+
* a migration onto the daemon, a wiped or relocated state directory, a
|
|
203
|
+
* restore onto a new host, or simply a database younger than the PRs all
|
|
204
|
+
* present pushed-and-open work as an untouched queue item. The tracker is the
|
|
205
|
+
* only party that remembers across all of those.
|
|
206
|
+
*/
|
|
207
|
+
openCloserFor(issue: number): Promise<string | undefined>;
|
|
159
208
|
}
|
|
160
209
|
|
|
161
210
|
/**
|
|
@@ -214,6 +263,10 @@ export interface Store {
|
|
|
214
263
|
/** Runs backed by a worker process — what capacity counts. Subset of {@link Store.activeRuns}. */
|
|
215
264
|
liveRuns(project: string): RunRecord[];
|
|
216
265
|
attemptsFor(project: string, issue: number): number;
|
|
266
|
+
/** Newest attempt for one issue, whatever state it reached. `omp-conductor
|
|
267
|
+
* tail` resolves an issue number to a transcript through this; the number is
|
|
268
|
+
* what an operator has, the run id is not. */
|
|
269
|
+
latestRun(project: string, issue: number): RunRecord | undefined;
|
|
217
270
|
runsStartedSince(project: string, sinceEpochMs: number): number;
|
|
218
271
|
spendSince(project: string, sinceEpochMs: number): number;
|
|
219
272
|
/** Idempotence guard so a retry loop cannot page a human repeatedly for the
|
package/src/unblock.ts
ADDED
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Giving an answered block a way back into the queue.
|
|
3
|
+
*
|
|
4
|
+
* A blocked or failed run leaves its state label on the issue, and eligibility
|
|
5
|
+
* treats any state label as disqualifying (`routing.isEligible`). That is the
|
|
6
|
+
* right interlock — it is what stops a second worker landing on live work
|
|
7
|
+
* across a daemon restart — but until this verb existed it had no exit: the
|
|
8
|
+
* orchestrator's brief tells it never to hand-edit a state label, so an issue
|
|
9
|
+
* whose question it had just answered could only be left labelled, which means
|
|
10
|
+
* never re-claimed and the answer inert. Nothing surfaces that either: the
|
|
11
|
+
* issue does not fail, it simply stops existing as far as dispatch is
|
|
12
|
+
* concerned.
|
|
13
|
+
*
|
|
14
|
+
* So the clearing is a daemon-owned act, through the same tracker port the
|
|
15
|
+
* dispatcher writes labels with, and the brief's rule stays absolute. That
|
|
16
|
+
* absoluteness is worth more than the exception it replaces: orphan detection
|
|
17
|
+
* is only trustworthy while every state label on the tracker was written by
|
|
18
|
+
* this package.
|
|
19
|
+
*
|
|
20
|
+
* Nothing here writes to the store, and that is a decision rather than an
|
|
21
|
+
* omission. `RunState` describes what a worker process did; an answer is the
|
|
22
|
+
* one event that happens outside every run, so no member fits it — folding it
|
|
23
|
+
* into `merged` or `killed` would make `status` describe a run that never
|
|
24
|
+
* reached either. Eligibility is read off the tracker's labels and never off a
|
|
25
|
+
* run row, so the store has nothing to say here. Leaving the history alone is
|
|
26
|
+
* also what keeps `maxAttemptsPerIssue` honest: an answered block still spent a
|
|
27
|
+
* worker's whole budget, and the same question answered twice is a loop the cap
|
|
28
|
+
* exists to stop.
|
|
29
|
+
*/
|
|
30
|
+
|
|
31
|
+
import { LIVE_STATES } from "./store.ts";
|
|
32
|
+
import type { Caps, ProjectConfig, RunRecord, Store, Tracker } from "./types.ts";
|
|
33
|
+
|
|
34
|
+
/** What one `unblock` did, and the state it found around it. */
|
|
35
|
+
export interface UnblockOutcome {
|
|
36
|
+
/** State labels the tracker was asked to drop. */
|
|
37
|
+
cleared: string[];
|
|
38
|
+
/** Attempts this issue has already spent. Unchanged by the unblock. */
|
|
39
|
+
attemptsUsed: number;
|
|
40
|
+
/** Newest attempt, when the store has one for this issue at all. */
|
|
41
|
+
latest?: RunRecord;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* Drop both terminal state labels, whichever the issue is actually carrying.
|
|
46
|
+
*
|
|
47
|
+
* Both unconditionally, because the tracker is the only source of truth for
|
|
48
|
+
* which one is set and this process cannot read that back through the Tracker
|
|
49
|
+
* port — inferring it from the newest run row would be a guess that goes wrong
|
|
50
|
+
* exactly when a human has relabelled something by hand. Removing a label an
|
|
51
|
+
* issue does not carry is a no-op: `gh issue edit --remove-label` exits 0 on an
|
|
52
|
+
* absent label (verified against gh 2.97.0), and the adapter swallows the 404
|
|
53
|
+
* older paths return for one.
|
|
54
|
+
*
|
|
55
|
+
* `agent:in-progress` is deliberately not in the set. It means a worker process
|
|
56
|
+
* exists, which is not something an operator can answer away, and clearing it
|
|
57
|
+
* from under a live run is how two workers end up on one issue.
|
|
58
|
+
*/
|
|
59
|
+
export async function unblockIssue(
|
|
60
|
+
project: ProjectConfig,
|
|
61
|
+
tracker: Tracker,
|
|
62
|
+
store: Store,
|
|
63
|
+
issue: number,
|
|
64
|
+
): Promise<UnblockOutcome> {
|
|
65
|
+
const cleared: string[] = [];
|
|
66
|
+
for (const label of new Set([project.stateLabels.blocked, project.stateLabels.failed])) {
|
|
67
|
+
await tracker.removeLabel(issue, label);
|
|
68
|
+
cleared.push(label);
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
const latest = store.latestRun(project.name, issue);
|
|
72
|
+
return {
|
|
73
|
+
cleared,
|
|
74
|
+
attemptsUsed: store.attemptsFor(project.name, issue),
|
|
75
|
+
...(latest === undefined ? {} : { latest }),
|
|
76
|
+
};
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/**
|
|
80
|
+
* What the operator reads back. It promises a re-claim only when one can
|
|
81
|
+
* actually happen: a live run still owns the issue through `agent:in-progress`,
|
|
82
|
+
* and a spent attempt budget makes the next tick escalate rather than dispatch.
|
|
83
|
+
* Either promised blindly would send someone away believing work had resumed.
|
|
84
|
+
*/
|
|
85
|
+
export function formatUnblock(issue: number, o: UnblockOutcome, project: ProjectConfig, caps: Caps): string {
|
|
86
|
+
const latest = o.latest;
|
|
87
|
+
const lines = [`#${issue}: cleared ${o.cleared.join(", ")}`];
|
|
88
|
+
|
|
89
|
+
if (latest === undefined) {
|
|
90
|
+
lines.push(" attempts none recorded — the labels were cleared anyway; eligibility is read off the tracker");
|
|
91
|
+
} else {
|
|
92
|
+
lines.push(
|
|
93
|
+
` attempts ${o.attemptsUsed} of ${caps.maxAttemptsPerIssue} used, newest ${latest.state} — ` +
|
|
94
|
+
"unchanged, an answered block still spent a worker",
|
|
95
|
+
);
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
if (latest !== undefined && LIVE_STATES.includes(latest.state)) {
|
|
99
|
+
lines.push(
|
|
100
|
+
` in flight attempt ${latest.attempt} is ${latest.state}, so the issue keeps ` +
|
|
101
|
+
`"${project.stateLabels.inProgress}" until it ends — nothing is re-claimed before then`,
|
|
102
|
+
);
|
|
103
|
+
} else if (o.attemptsUsed >= caps.maxAttemptsPerIssue) {
|
|
104
|
+
lines.push(
|
|
105
|
+
` next tick not eligible: all ${caps.maxAttemptsPerIssue} attempts are spent, so the next tick escalates ` +
|
|
106
|
+
"instead of re-claiming. Raise maxAttemptsPerIssue with /conductor setup, or rewrite the issue.",
|
|
107
|
+
);
|
|
108
|
+
} else {
|
|
109
|
+
lines.push(` next tick eligible again, as long as the issue still carries "${project.queueLabel}"`);
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
return lines.join("\n");
|
|
113
|
+
}
|
package/src/worker.ts
CHANGED
|
@@ -43,6 +43,15 @@ export interface WorkerOpts {
|
|
|
43
43
|
*/
|
|
44
44
|
model?: string;
|
|
45
45
|
onTurn?: (n: number) => void;
|
|
46
|
+
/**
|
|
47
|
+
* The transcript path, handed over the moment the session opens it rather
|
|
48
|
+
* than at the end with {@link WorkerResult.sessionFile}. Both report the same
|
|
49
|
+
* path; only this one arrives while there is still something to watch, which
|
|
50
|
+
* is what `omp-conductor tail` attaches to. Never called for a session that
|
|
51
|
+
* opened no transcript — there is no path to report, and an empty string
|
|
52
|
+
* would be a path that fails to open rather than an absence.
|
|
53
|
+
*/
|
|
54
|
+
onSessionFile?: (path: string) => void;
|
|
46
55
|
}
|
|
47
56
|
|
|
48
57
|
/**
|
|
@@ -145,6 +154,11 @@ export async function runWorker(
|
|
|
145
154
|
...(o.model === undefined ? {} : { model: o.model }),
|
|
146
155
|
});
|
|
147
156
|
|
|
157
|
+
// Before the first turn, not after the last: a caller that only learns the
|
|
158
|
+
// transcript path from the result learns it once the run it wanted to watch
|
|
159
|
+
// is already over.
|
|
160
|
+
if (session.sessionFile !== undefined) o.onSessionFile?.(session.sessionFile);
|
|
161
|
+
|
|
148
162
|
// Every exit below reports the session's own facts the same way: the
|
|
149
163
|
// transcript it actually opened, and any model downgrade it announced. Read at
|
|
150
164
|
// return time so a session that materialises either late is still reported
|