omp-conductor 0.3.9 → 0.3.11
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/src/briefs/orchestrator.md +11 -5
- package/src/daemon.ts +17 -8
- package/src/escalate.ts +26 -4
- package/src/orchestrator-tick.ts +42 -2
- package/src/worktree.ts +67 -10
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "omp-conductor",
|
|
3
|
-
"version": "0.3.
|
|
3
|
+
"version": "0.3.11",
|
|
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.",
|
|
@@ -18,9 +18,11 @@ written out as the one you chose — ask an omp session to read
|
|
|
18
18
|
|
|
19
19
|
---
|
|
20
20
|
|
|
21
|
-
You are the orchestrator for **{{PROJECT}}**. You do not write product code
|
|
22
|
-
|
|
23
|
-
|
|
21
|
+
You are the orchestrator for **{{PROJECT}}**. You do not write product code —
|
|
22
|
+
**Hard boundaries** below names the exact acts that are out (checkout / commit /
|
|
23
|
+
push inside a worker's worktree; inventing a commit identity) and the one that
|
|
24
|
+
is in (`gh pr update-branch` for a green PR that fell behind). You keep the
|
|
25
|
+
queue moving, and you are the first responder when a worker gets stuck.
|
|
24
26
|
|
|
25
27
|
You are prompted on a timer. Each tick: do the three duties below, then stop.
|
|
26
28
|
|
|
@@ -225,8 +227,12 @@ The protocol, in order:
|
|
|
225
227
|
big for that, send the one-sentence version of each change and say the
|
|
226
228
|
full text lands in the file on yes — the diff stays in your transcript for
|
|
227
229
|
anyone who wants it verbatim.
|
|
228
|
-
3. **On yes, apply it** by editing this file yourself. On no,
|
|
229
|
-
|
|
230
|
+
3. **On yes, apply it** by editing this file yourself. **On explicit no, drop
|
|
231
|
+
it** forever and do not re-ask that amendment. **On cancel, timeout, or no
|
|
232
|
+
answer**, park it — that means "not now", not "never": mention it once in the
|
|
233
|
+
next report as `pending amendment: <one-liner> — say 'apply it' or 'drop it'`,
|
|
234
|
+
never re-open the yes/no dialog, and drop it if still unanswered after 7 days.
|
|
235
|
+
A cancelled dialog is not a permanent rejection.
|
|
230
236
|
4. **Log it.** Append one line to **Amendments** at the bottom of this file: the
|
|
231
237
|
date, what triggered it, a one-sentence summary.
|
|
232
238
|
5. **Offer general fixes upstream.** Ask one question of the amendment you just
|
package/src/daemon.ts
CHANGED
|
@@ -11,7 +11,7 @@ import { createHash } from "node:crypto";
|
|
|
11
11
|
import { existsSync, mkdirSync, readdirSync, readFileSync, rmSync, writeFileSync } from "node:fs";
|
|
12
12
|
import { dirname, join, relative } from "node:path";
|
|
13
13
|
import { configPath, findProject, loadConfig, resolveCaps, stateDir } from "./config.ts";
|
|
14
|
-
import { createEscalator } from "./escalate.ts";
|
|
14
|
+
import { createEscalator, escalationIssueRef } from "./escalate.ts";
|
|
15
15
|
import { graphHint } from "./graph.ts";
|
|
16
16
|
import { livingDaemon } from "./lifecycle.ts";
|
|
17
17
|
import { STALL_MARKER_FILE } from "./orchestrator-tick.ts";
|
|
@@ -369,7 +369,7 @@ async function safeEscalate(d: Pick<Deps, "escalate">, e: Escalation): Promise<b
|
|
|
369
369
|
await d.escalate(e);
|
|
370
370
|
return true;
|
|
371
371
|
} catch (err) {
|
|
372
|
-
log(`escalation for
|
|
372
|
+
log(`escalation for ${escalationIssueRef(e.issue)} could not be delivered: ${errText(err)}`);
|
|
373
373
|
return false;
|
|
374
374
|
}
|
|
375
375
|
}
|
|
@@ -393,13 +393,22 @@ export function salvageLines(outcome: SalvageOutcome, worktree: string): string[
|
|
|
393
393
|
];
|
|
394
394
|
}
|
|
395
395
|
|
|
396
|
-
|
|
396
|
+
const where =
|
|
397
397
|
`WIP committed to ${outcome.branch} @ ${outcome.sha}` +
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
|
|
398
|
+
(outcome.pushed
|
|
399
|
+
? " and pushed — the work outlives this worktree"
|
|
400
|
+
: ` but NOT pushed (${outcome.pushError ?? "no reason given"}) — it lives only in this host's mirror`);
|
|
401
|
+
// Manifest belongs in the escalation too: opening the commit is how the
|
|
402
|
+
// orchestrator talked itself into scrubbing a worker tree (#38).
|
|
403
|
+
const n = outcome.files.length;
|
|
404
|
+
const count = `${n} file${n === 1 ? "" : "s"}`;
|
|
405
|
+
const manifest =
|
|
406
|
+
outcome.newPaths.length === 0
|
|
407
|
+
? `${count} (all modifications to tracked paths)`
|
|
408
|
+
: `${count}; new: ${outcome.newPaths.slice(0, 12).join(", ")}${
|
|
409
|
+
outcome.newPaths.length > 12 ? `, … +${outcome.newPaths.length - 12} more` : ""
|
|
410
|
+
}`;
|
|
411
|
+
return [where, manifest, kept];
|
|
403
412
|
}
|
|
404
413
|
|
|
405
414
|
/**
|
package/src/escalate.ts
CHANGED
|
@@ -45,11 +45,21 @@ export interface Escalator {
|
|
|
45
45
|
* send, turning a cosmetic problem into a lost escalation. Plain text is also
|
|
46
46
|
* valid Markdown, so the same string renders fine as an issue comment.
|
|
47
47
|
*/
|
|
48
|
+
/**
|
|
49
|
+
* Fleet-scoped pages (integrity tripwire, spend-cap halt, stall) use issue `0`
|
|
50
|
+
* as a sentinel — there is no tracker issue. Rendering that as `#0` made
|
|
51
|
+
* Telegram pages and daemon logs look like a bug (#52). Keep the sentinel in
|
|
52
|
+
* the typed field; only the human-facing label changes here.
|
|
53
|
+
*/
|
|
54
|
+
export function escalationIssueRef(issue: number): string {
|
|
55
|
+
return issue === 0 ? "fleet" : `#${issue}`;
|
|
56
|
+
}
|
|
57
|
+
|
|
48
58
|
export function formatEscalation(e: Escalation, project: string): string {
|
|
49
59
|
const lines = [
|
|
50
60
|
`omp-conductor · tier ${e.tier} escalation`,
|
|
51
61
|
`project: ${project}`,
|
|
52
|
-
`issue:
|
|
62
|
+
`issue: ${escalationIssueRef(e.issue)}`,
|
|
53
63
|
`summary: ${e.summary}`,
|
|
54
64
|
];
|
|
55
65
|
if (e.detail) lines.push(`detail: ${e.detail}`);
|
|
@@ -99,18 +109,21 @@ export function createEscalator(
|
|
|
99
109
|
const onTurnFailed = async (cause: unknown): Promise<void> => {
|
|
100
110
|
if (!p.escalation.fallbackToIssueComment) {
|
|
101
111
|
warn(
|
|
102
|
-
`tier 1 escalation on
|
|
112
|
+
`tier 1 escalation on ${escalationIssueRef(e.issue)} was accepted by the orchestrator but its ` +
|
|
103
113
|
`turn failed (${errText(cause)}), and no fallback is configured — left unmarked ` +
|
|
104
114
|
`so the next tick retries`,
|
|
105
115
|
);
|
|
106
116
|
return;
|
|
107
117
|
}
|
|
108
118
|
try {
|
|
119
|
+
if (e.issue === 0) {
|
|
120
|
+
throw new Error("fleet-scoped escalation has no issue to comment on");
|
|
121
|
+
}
|
|
109
122
|
await tracker.comment(e.issue, text);
|
|
110
123
|
store.markNotified(key);
|
|
111
124
|
} catch (err) {
|
|
112
125
|
warn(
|
|
113
|
-
`tier 1 escalation on
|
|
126
|
+
`tier 1 escalation on ${escalationIssueRef(e.issue)} failed after acceptance ` +
|
|
114
127
|
`(${errText(cause)}) and its issue-comment fallback failed too ` +
|
|
115
128
|
`(${errText(err)}) — left unmarked so the next tick retries`,
|
|
116
129
|
);
|
|
@@ -154,10 +167,19 @@ export function createEscalator(
|
|
|
154
167
|
if (!p.escalation.fallbackToIssueComment) {
|
|
155
168
|
throw new Error(
|
|
156
169
|
`no escalation transport configured for project "${p.name}": tier ${e.tier} ` +
|
|
157
|
-
`escalation on
|
|
170
|
+
`escalation on ${escalationIssueRef(e.issue)} (${e.summary}) could not be delivered — ` +
|
|
158
171
|
`set escalation.telegramChatId or escalation.fallbackToIssueComment`,
|
|
159
172
|
);
|
|
160
173
|
}
|
|
174
|
+
// Fleet pages (issue 0) have nowhere to comment. Falling through here used
|
|
175
|
+
// to call `issues/0/comments` and surface as a confusing "#0" delivery
|
|
176
|
+
// failure after Telegram had already been tried (#52).
|
|
177
|
+
if (e.issue === 0) {
|
|
178
|
+
throw new Error(
|
|
179
|
+
`fleet-scoped tier ${e.tier} escalation (${e.summary}) has no issue to comment on — ` +
|
|
180
|
+
`configure escalation.telegramChatId so integrity/spend/stall pages can reach an operator`,
|
|
181
|
+
);
|
|
182
|
+
}
|
|
161
183
|
await tracker.comment(e.issue, text);
|
|
162
184
|
store.markNotified(key);
|
|
163
185
|
},
|
package/src/orchestrator-tick.ts
CHANGED
|
@@ -94,6 +94,14 @@ const RETRY_OWNERSHIP_MS = 60_000;
|
|
|
94
94
|
export const STALL_MARKER_FILE = ".conductor-stalled";
|
|
95
95
|
export const STALL_TICKS = 2;
|
|
96
96
|
|
|
97
|
+
/**
|
|
98
|
+
* Written by herdr-conductor `recover.sh` *before* `agent start`, so a resumed
|
|
99
|
+
* fleet can reconcile orphans without waiting a full `intervalSeconds`. Cleared
|
|
100
|
+
* only after a tick is actually sent — a disarmed or channel-down fleet keeps
|
|
101
|
+
* the request until gates pass (or a human removes the file).
|
|
102
|
+
*/
|
|
103
|
+
export const TICK_REQUESTED_FILE = ".conductor-tick-requested";
|
|
104
|
+
|
|
97
105
|
/**
|
|
98
106
|
* The marker's one line after its ISO timestamp, and the middle of the error
|
|
99
107
|
* log. Shared so the file and the log can never describe different failures.
|
|
@@ -834,6 +842,23 @@ function clearStallMarker(pi: TickApi, cwd: string): void {
|
|
|
834
842
|
}
|
|
835
843
|
}
|
|
836
844
|
|
|
845
|
+
/**
|
|
846
|
+
* Best-effort, same posture as {@link clearStallMarker}. Leaving the file on a
|
|
847
|
+
* failed unlink means the next successful send retries the clear; that is
|
|
848
|
+
* preferable to treating a recover poke as fire-and-forget when the tick did
|
|
849
|
+
* land.
|
|
850
|
+
*/
|
|
851
|
+
function clearTickRequest(pi: TickApi, cwd: string): void {
|
|
852
|
+
const path = join(cwd, TICK_REQUESTED_FILE);
|
|
853
|
+
if (!existsSync(path)) return;
|
|
854
|
+
try {
|
|
855
|
+
rmSync(path, { force: true });
|
|
856
|
+
pi.logger.info("[omp-conductor] recover tick request cleared: a tick was sent");
|
|
857
|
+
} catch (err) {
|
|
858
|
+
pi.logger.error(`[omp-conductor] could not remove ${path}: ${err instanceof Error ? err.message : String(err)}`);
|
|
859
|
+
}
|
|
860
|
+
}
|
|
861
|
+
|
|
837
862
|
/** Everything one tick remembers for the next. */
|
|
838
863
|
interface TickSession {
|
|
839
864
|
/**
|
|
@@ -900,6 +925,21 @@ function tick(pi: TickApi, ctx: TickContext, config: TickConfig, session: TickSe
|
|
|
900
925
|
// this is the only place either the counter or the marker is cleared.
|
|
901
926
|
session.pendingSkips = 0;
|
|
902
927
|
clearStallMarker(pi, ctx.cwd);
|
|
928
|
+
// Recover poke is consumed only on a real send — gates still apply above.
|
|
929
|
+
clearTickRequest(pi, ctx.cwd);
|
|
930
|
+
}
|
|
931
|
+
|
|
932
|
+
/**
|
|
933
|
+
* Arm the interval heartbeat, then honour a recover poke if one is waiting.
|
|
934
|
+
* Extracted so the ownership-retry path and the immediate-accept path cannot
|
|
935
|
+
* drift: both must fire the same "do not wait a full interval after resume"
|
|
936
|
+
* behaviour.
|
|
937
|
+
*/
|
|
938
|
+
function armTickHeartbeat(pi: TickApi, ctx: TickContext, config: TickConfig, session: TickSession): void {
|
|
939
|
+
ctx.setInterval(() => tick(pi, ctx, config, session), config.intervalSeconds * 1000);
|
|
940
|
+
if (!existsSync(join(ctx.cwd, TICK_REQUESTED_FILE))) return;
|
|
941
|
+
pi.logger.info("[omp-conductor] tick requested by recover — firing without waiting for the interval");
|
|
942
|
+
tick(pi, ctx, config, session);
|
|
903
943
|
}
|
|
904
944
|
|
|
905
945
|
export default function orchestratorTickExtension(pi: TickApi): void {
|
|
@@ -995,7 +1035,7 @@ export default function orchestratorTickExtension(pi: TickApi): void {
|
|
|
995
1035
|
return;
|
|
996
1036
|
}
|
|
997
1037
|
if (next.note !== undefined) pi.logger.error(`[omp-conductor] tick ownership: ${next.note}`);
|
|
998
|
-
|
|
1038
|
+
armTickHeartbeat(pi, ctx, config, session);
|
|
999
1039
|
pi.logger.info(`[omp-conductor] orchestrator tick active: ownership resolved on retry`, { agentName });
|
|
1000
1040
|
}, retryMs);
|
|
1001
1041
|
decided = true;
|
|
@@ -1008,7 +1048,7 @@ export default function orchestratorTickExtension(pi: TickApi): void {
|
|
|
1008
1048
|
return;
|
|
1009
1049
|
}
|
|
1010
1050
|
if (ownership.note !== undefined) pi.logger.error(`[omp-conductor] tick ownership: ${ownership.note}`);
|
|
1011
|
-
|
|
1051
|
+
armTickHeartbeat(pi, ctx, config, session);
|
|
1012
1052
|
decided = true;
|
|
1013
1053
|
// Both gates are named at startup: "why is it not ticking?" is answered by
|
|
1014
1054
|
// looking at the files this line lists, and an unset channel gate on a fleet
|
package/src/worktree.ts
CHANGED
|
@@ -379,7 +379,17 @@ export async function addWorktree(
|
|
|
379
379
|
* none of that may be skipped by an exception from the last-ditch step.
|
|
380
380
|
*/
|
|
381
381
|
export type SalvageOutcome =
|
|
382
|
-
| {
|
|
382
|
+
| {
|
|
383
|
+
kind: "salvaged";
|
|
384
|
+
sha: string;
|
|
385
|
+
branch: string;
|
|
386
|
+
pushed: boolean;
|
|
387
|
+
pushError?: string;
|
|
388
|
+
/** Paths in the salvage commit (count = length). */
|
|
389
|
+
files: string[];
|
|
390
|
+
/** Subset that were untracked before salvage (`A` in the commit). */
|
|
391
|
+
newPaths: string[];
|
|
392
|
+
}
|
|
383
393
|
/** Nothing uncommitted was there to save — a clean tree, or no tree at all. */
|
|
384
394
|
| { kind: "nothing" }
|
|
385
395
|
/** There was work and git would not commit it. This is the loud one. */
|
|
@@ -403,6 +413,51 @@ const SALVAGE_COMMIT_CONFIG = [
|
|
|
403
413
|
"commit.gpgsign=false",
|
|
404
414
|
];
|
|
405
415
|
|
|
416
|
+
/**
|
|
417
|
+
* Subject stays the historical one-liner so status greps keep working; the
|
|
418
|
+
* body is the manifest (#38). Cap the new-path list so a runaway tree cannot
|
|
419
|
+
* push a multi-kilobyte commit message into every escalation.
|
|
420
|
+
*/
|
|
421
|
+
export function salvageCommitMessage(
|
|
422
|
+
issue: number,
|
|
423
|
+
attempt: number,
|
|
424
|
+
reason: string,
|
|
425
|
+
files: string[],
|
|
426
|
+
newPaths: string[],
|
|
427
|
+
): { subject: string; body: string } {
|
|
428
|
+
const subject = `wip(#${issue}): attempt ${attempt} killed by ${reason} — auto-salvaged`;
|
|
429
|
+
const n = files.length;
|
|
430
|
+
const count = `${n} file${n === 1 ? "" : "s"}`;
|
|
431
|
+
if (newPaths.length === 0) {
|
|
432
|
+
return { subject, body: `${count} (all modifications to tracked paths).` };
|
|
433
|
+
}
|
|
434
|
+
const cap = 30;
|
|
435
|
+
const shown = newPaths.slice(0, cap);
|
|
436
|
+
const more = newPaths.length - shown.length;
|
|
437
|
+
const list = shown.map((f) => ` ${f}`).join("\n");
|
|
438
|
+
const suffix = more > 0 ? `\n … and ${more} more` : "";
|
|
439
|
+
return {
|
|
440
|
+
subject,
|
|
441
|
+
body: `${count}. New (untracked before salvage):\n${list}${suffix}`,
|
|
442
|
+
};
|
|
443
|
+
}
|
|
444
|
+
|
|
445
|
+
function parseCachedNameStatus(raw: string): { files: string[]; newPaths: string[] } {
|
|
446
|
+
const files: string[] = [];
|
|
447
|
+
const newPaths: string[] = [];
|
|
448
|
+
for (const line of raw.split("\n")) {
|
|
449
|
+
if (line === "") continue;
|
|
450
|
+
// name-status: "<status>\t<path>" or rename "R100\t<old>\t<new>"
|
|
451
|
+
const parts = line.split("\t");
|
|
452
|
+
const status = parts[0] ?? "";
|
|
453
|
+
const path = parts.length >= 3 ? (parts[2] ?? parts[1] ?? "") : (parts[1] ?? "");
|
|
454
|
+
if (path === "") continue;
|
|
455
|
+
files.push(path);
|
|
456
|
+
if (status.startsWith("A")) newPaths.push(path);
|
|
457
|
+
}
|
|
458
|
+
return { files, newPaths };
|
|
459
|
+
}
|
|
460
|
+
|
|
406
461
|
/**
|
|
407
462
|
* Commits a dead run's uncommitted work to the run's own branch and pushes it,
|
|
408
463
|
* so that the tree the next attempt destroys is no longer the only copy.
|
|
@@ -472,40 +527,42 @@ export async function salvageWip(
|
|
|
472
527
|
// only changes were ignored scratch had work by that test and none by this.
|
|
473
528
|
// Without this, `commit` exits non-zero on an empty index and a tree
|
|
474
529
|
// holding nothing worth keeping gets reported as a salvage *failure*.
|
|
475
|
-
|
|
530
|
+
const cached = await git(["diff", "--cached", "--name-status"], worktree);
|
|
531
|
+
if (cached === "") {
|
|
476
532
|
return { kind: "nothing" };
|
|
477
533
|
}
|
|
534
|
+
const { files, newPaths } = parseCachedNameStatus(cached);
|
|
535
|
+
const msg = salvageCommitMessage(issue, attempt, reason, files, newPaths);
|
|
478
536
|
await git(
|
|
479
537
|
[
|
|
480
538
|
...SALVAGE_COMMIT_CONFIG,
|
|
481
539
|
"commit",
|
|
482
540
|
"--no-verify",
|
|
483
541
|
"-m",
|
|
484
|
-
|
|
542
|
+
msg.subject,
|
|
543
|
+
"-m",
|
|
544
|
+
msg.body,
|
|
485
545
|
],
|
|
486
546
|
worktree,
|
|
487
547
|
);
|
|
488
548
|
const sha = await git(["rev-parse", "HEAD"], worktree);
|
|
549
|
+
const salvaged = { kind: "salvaged" as const, sha, branch, files, newPaths };
|
|
489
550
|
|
|
490
551
|
if (branch === "HEAD") {
|
|
491
552
|
// Detached: the commit is real but reachable only by sha, and pushing
|
|
492
553
|
// `HEAD` from here would publish a branch literally named HEAD.
|
|
493
554
|
return {
|
|
494
|
-
|
|
495
|
-
sha,
|
|
496
|
-
branch,
|
|
555
|
+
...salvaged,
|
|
497
556
|
pushed: false,
|
|
498
557
|
pushError: "detached HEAD — no branch to push",
|
|
499
558
|
};
|
|
500
559
|
}
|
|
501
560
|
|
|
502
561
|
const push = await runGit(["push", "origin", `HEAD:refs/heads/${branch}`], worktree);
|
|
503
|
-
if (push.code === 0) return {
|
|
562
|
+
if (push.code === 0) return { ...salvaged, pushed: true };
|
|
504
563
|
|
|
505
564
|
return {
|
|
506
|
-
|
|
507
|
-
sha,
|
|
508
|
-
branch,
|
|
565
|
+
...salvaged,
|
|
509
566
|
pushed: false,
|
|
510
567
|
pushError: (
|
|
511
568
|
push.stderr.trim() ||
|