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/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"] = { fallbackToIssueComment: a.fallbackToIssueComment };
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 and the chosen scope are substituted: the policy text is
419
- * left exactly as shipped, because from here on the file is the operator's to
420
- * edit and nothing in this package reads it back.
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
  },
@@ -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 six Tracker methods above it stay untouched.
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, because the homelab only has 3 CI runners and
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
- /** How a stuck run reaches a human, and what to do when it cannot. */
95
- escalation: { telegramChatId?: string; fallbackToIssueComment: boolean };
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