omp-conductor 0.2.2 → 0.3.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/README.md +205 -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 +462 -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/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
|
package/src/worktree.ts
CHANGED
|
@@ -268,6 +268,125 @@ export async function addWorktree(
|
|
|
268
268
|
return worktreePath;
|
|
269
269
|
}
|
|
270
270
|
|
|
271
|
+
/**
|
|
272
|
+
* What one salvage attempt did. A failure is a *value* rather than a throw
|
|
273
|
+
* because the only caller is a run that is already ending badly: it has to log
|
|
274
|
+
* the outcome, word it into the escalation and still close its run record, and
|
|
275
|
+
* none of that may be skipped by an exception from the last-ditch step.
|
|
276
|
+
*/
|
|
277
|
+
export type SalvageOutcome =
|
|
278
|
+
| { kind: "salvaged"; sha: string; branch: string; pushed: boolean; pushError?: string }
|
|
279
|
+
/** Nothing uncommitted was there to save — a clean tree, or no tree at all. */
|
|
280
|
+
| { kind: "nothing" }
|
|
281
|
+
/** There was work and git would not commit it. This is the loud one. */
|
|
282
|
+
| { kind: "failed"; error: string };
|
|
283
|
+
|
|
284
|
+
/**
|
|
285
|
+
* The salvage commit is the daemon's own, made unattended in a worktree whose
|
|
286
|
+
* config it does not control. A machine with no global git identity, a global
|
|
287
|
+
* `commit.gpgsign=true` whose key needs a passphrase nobody can type, or a repo
|
|
288
|
+
* pre-commit hook that rejects half-finished code are all ordinary states — and
|
|
289
|
+
* every one of them would turn "save the work" into "lose the work". So the
|
|
290
|
+
* commit brings its own identity, signs nothing, and skips hooks: it is a
|
|
291
|
+
* snapshot for a human to sort out, never something anyone merges.
|
|
292
|
+
*/
|
|
293
|
+
const SALVAGE_COMMIT_CONFIG = [
|
|
294
|
+
"-c",
|
|
295
|
+
"user.name=conductor",
|
|
296
|
+
"-c",
|
|
297
|
+
"user.email=conductor@invalid",
|
|
298
|
+
"-c",
|
|
299
|
+
"commit.gpgsign=false",
|
|
300
|
+
];
|
|
301
|
+
|
|
302
|
+
/**
|
|
303
|
+
* Commits a dead run's uncommitted work to the run's own branch and pushes it,
|
|
304
|
+
* so that the tree the next attempt destroys is no longer the only copy.
|
|
305
|
+
*
|
|
306
|
+
* This closes a deliberate asymmetry. `addWorktree` preserves the run branch
|
|
307
|
+
* precisely because "that branch can hold the only copy of work attempt 1
|
|
308
|
+
* committed but never pushed", while `removeWorktree` runs `worktree remove
|
|
309
|
+
* --force` and `addWorktree` refuses to reuse a tree that "may hold a previous
|
|
310
|
+
* attempt's uncommitted work" — committed work is kept by design, uncommitted
|
|
311
|
+
* work is discarded by design. That trade is fair for a worker that *stops*:
|
|
312
|
+
* blocking is a decision it makes with turns left to commit first. It is not
|
|
313
|
+
* fair for one killed by the turns cap or the wall clock, or one that crashes:
|
|
314
|
+
* that end is external, unannounced, mid-sentence, and it lands hardest on the
|
|
315
|
+
* long refactors carrying the most unsaved work. So: non-graceful ends only.
|
|
316
|
+
*
|
|
317
|
+
* Never throws. Every outcome, including its own failure, comes back as a value
|
|
318
|
+
* for the caller to log and to put in front of a human.
|
|
319
|
+
*
|
|
320
|
+
* The push is best-effort and deliberately last, after the sha exists: a
|
|
321
|
+
* refused push (diverged branch, no credentials, no network) still leaves the
|
|
322
|
+
* commit in this host's mirror, which is strictly better than nothing. It is a
|
|
323
|
+
* plain fast-forward push — never a force — and if the run already had a PR
|
|
324
|
+
* open, that PR gains the WIP commit and re-runs its checks. That is the price
|
|
325
|
+
* of work outliving its host, and only a run that already failed ever pays it.
|
|
326
|
+
*/
|
|
327
|
+
export async function salvageWip(
|
|
328
|
+
worktree: string,
|
|
329
|
+
issue: number,
|
|
330
|
+
attempt: number,
|
|
331
|
+
reason: string,
|
|
332
|
+
): Promise<SalvageOutcome> {
|
|
333
|
+
try {
|
|
334
|
+
// A tree that is not there cannot be holding work. Checked before spawning
|
|
335
|
+
// git, because a missing cwd fails at spawn time rather than as an exit
|
|
336
|
+
// code, and "no tree" is not a salvage failure worth alarming anyone with.
|
|
337
|
+
if (!existsSync(worktree)) return { kind: "nothing" };
|
|
338
|
+
|
|
339
|
+
if ((await git(["status", "--porcelain"], worktree)) === "") return { kind: "nothing" };
|
|
340
|
+
|
|
341
|
+
// The tree's own branch, not one the caller believes it should be on: this
|
|
342
|
+
// string ends up in an escalation as the place to go looking.
|
|
343
|
+
const branch = await git(["rev-parse", "--abbrev-ref", "HEAD"], worktree);
|
|
344
|
+
|
|
345
|
+
// `-A` on purpose: the losses this exists for were mostly *new* files.
|
|
346
|
+
await git(["add", "-A"], worktree);
|
|
347
|
+
await git(
|
|
348
|
+
[
|
|
349
|
+
...SALVAGE_COMMIT_CONFIG,
|
|
350
|
+
"commit",
|
|
351
|
+
"--no-verify",
|
|
352
|
+
"-m",
|
|
353
|
+
`wip(#${issue}): attempt ${attempt} killed by ${reason} — auto-salvaged`,
|
|
354
|
+
],
|
|
355
|
+
worktree,
|
|
356
|
+
);
|
|
357
|
+
const sha = await git(["rev-parse", "HEAD"], worktree);
|
|
358
|
+
|
|
359
|
+
if (branch === "HEAD") {
|
|
360
|
+
// Detached: the commit is real but reachable only by sha, and pushing
|
|
361
|
+
// `HEAD` from here would publish a branch literally named HEAD.
|
|
362
|
+
return {
|
|
363
|
+
kind: "salvaged",
|
|
364
|
+
sha,
|
|
365
|
+
branch,
|
|
366
|
+
pushed: false,
|
|
367
|
+
pushError: "detached HEAD — no branch to push",
|
|
368
|
+
};
|
|
369
|
+
}
|
|
370
|
+
|
|
371
|
+
const push = await runGit(["push", "origin", `HEAD:refs/heads/${branch}`], worktree);
|
|
372
|
+
if (push.code === 0) return { kind: "salvaged", sha, branch, pushed: true };
|
|
373
|
+
|
|
374
|
+
return {
|
|
375
|
+
kind: "salvaged",
|
|
376
|
+
sha,
|
|
377
|
+
branch,
|
|
378
|
+
pushed: false,
|
|
379
|
+
pushError: (
|
|
380
|
+
push.stderr.trim() ||
|
|
381
|
+
push.stdout.trim() ||
|
|
382
|
+
`git push exited ${push.code}`
|
|
383
|
+
).replace(URL_USERINFO, "$1***@"),
|
|
384
|
+
};
|
|
385
|
+
} catch (err) {
|
|
386
|
+
return { kind: "failed", error: err instanceof Error ? err.message : String(err) };
|
|
387
|
+
}
|
|
388
|
+
}
|
|
389
|
+
|
|
271
390
|
/**
|
|
272
391
|
* Removes a run's worktree and its registration in the mirror. Idempotent: a
|
|
273
392
|
* path that is already gone resolves, so cleanup can be retried and can run on
|