omp-conductor 0.15.13 → 0.16.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/REFERENCE.md +72 -2
- package/package.json +2 -1
- package/schema/config.schema.json +7 -0
- package/src/admission.ts +849 -0
- package/src/ask.ts +47 -0
- package/src/backups.ts +19 -7
- package/src/board.ts +1 -2
- package/src/briefs/orchestrator.md +62 -4
- package/src/cli.ts +26 -0
- package/src/commands/context.ts +3 -0
- package/src/commands/decision.ts +10 -1
- package/src/commands/doctor.ts +2 -0
- package/src/commands/message.ts +8 -1
- package/src/commands/restart.ts +15 -3
- package/src/commands/restore-db.ts +146 -0
- package/src/commands/stop.ts +24 -15
- package/src/commands/tail.ts +204 -44
- package/src/commands/unfreeze.ts +56 -0
- package/src/commands/watch.ts +77 -0
- package/src/config-schema.ts +13 -0
- package/src/config.ts +54 -0
- package/src/daemon.ts +255 -530
- package/src/dashboard/server.ts +2 -1
- package/src/decisions.ts +32 -7
- package/src/depends-on.ts +122 -0
- package/src/doctor.ts +297 -5
- package/src/escalate.ts +191 -19
- package/src/failure-class.ts +47 -0
- package/src/fleet.ts +168 -452
- package/src/gitops.ts +86 -1
- package/src/log.ts +40 -0
- package/src/model-fallback.ts +3 -2
- package/src/omp-settings.ts +114 -0
- package/src/omp.ts +39 -0
- package/src/orchestrator-tick.ts +7 -1
- package/src/reports.ts +124 -12
- package/src/session-host.ts +6 -0
- package/src/setup-wizard.ts +36 -0
- package/src/setup.ts +58 -1
- package/src/status-render.ts +445 -0
- package/src/stop-provenance.ts +53 -0
- package/src/store.ts +352 -11
- package/src/transcript.ts +1 -1
- package/src/types.ts +187 -4
- package/src/unblock.ts +1 -1
- package/src/upgrade-verify.ts +1 -1
- package/src/upgrade.ts +1 -2
- package/src/verbs/server.ts +25 -0
- package/src/worker.ts +358 -10
- package/src/worktree.ts +13 -1
package/src/escalate.ts
CHANGED
|
@@ -29,8 +29,79 @@ import { heldNoticeId } from "./notices.ts";
|
|
|
29
29
|
import type { OrchestratorHandle } from "./orchestrator.ts";
|
|
30
30
|
import type { Escalation, ProjectConfig, Store, Tracker } from "./types.ts";
|
|
31
31
|
|
|
32
|
-
/**
|
|
33
|
-
|
|
32
|
+
/**
|
|
33
|
+
* Telegram rejects `sendMessage` over 4096 characters. Longer text is split
|
|
34
|
+
* into labelled parts ({@link telegramTextParts}) instead of truncated, so the
|
|
35
|
+
* tail of a digest or a tier-2 escalation arrives instead of being silently
|
|
36
|
+
* dropped (#566).
|
|
37
|
+
*/
|
|
38
|
+
export const TELEGRAM_TEXT_LIMIT = 4096;
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* Width reserved for the `(i/n)` label prepended to every part of a split
|
|
42
|
+
* message, ported from omp-telegram's `PART_LABEL_RESERVE`. Parts are re-split
|
|
43
|
+
* against `TELEGRAM_TEXT_LIMIT - TELEGRAM_PART_LABEL_RESERVE` so the label can
|
|
44
|
+
* never push a part past the wire limit.
|
|
45
|
+
*/
|
|
46
|
+
export const TELEGRAM_PART_LABEL_RESERVE = 16;
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* Split text into Telegram-sized parts. Ports omp-telegram 0.12.1's
|
|
50
|
+
* `chunkLabeled` semantics (the plugin this transport bypasses, #566) so a
|
|
51
|
+
* digest copies the same readability contract that plugin ships:
|
|
52
|
+
*
|
|
53
|
+
* - newline-preferred boundaries: a cut prefers the last paragraph break
|
|
54
|
+
* (`\n\n`), then line break, then the last space, each only when it lands in
|
|
55
|
+
* the second half of the window so parts stay large; an unsplittable run is
|
|
56
|
+
* hard-cut rather than dropped.
|
|
57
|
+
* - numbered labels: when a message takes more than one part, every part is
|
|
58
|
+
* prefixed `(i/N)\n` so a reader sees the message continues and knows how
|
|
59
|
+
* many parts to expect. Under the label budget, so no part exceeds the limit.
|
|
60
|
+
* - lossless: a split message reassembles byte-for-byte to its input
|
|
61
|
+
* (strip the labels and concatenate). Deliberately *not* ported from
|
|
62
|
+
* upstream: fence repair and leading-newline stripping are rendering
|
|
63
|
+
* flourishes for markdown-mode sends, and this transport sends plain text
|
|
64
|
+
* (`parse_mode` is never set) — both would rewrite bytes the operator never
|
|
65
|
+
* wrote.
|
|
66
|
+
*
|
|
67
|
+
* Text at or under the limit is returned whole and untouched, so the
|
|
68
|
+
* single-message path keeps sending byte-identical payloads.
|
|
69
|
+
*/
|
|
70
|
+
export function telegramTextParts(text: string, limit: number = TELEGRAM_TEXT_LIMIT): string[] {
|
|
71
|
+
if (text.length === 0 || text.length <= limit) return [text];
|
|
72
|
+
// `splitToLimit` returns at least two pieces here (text is over the limit);
|
|
73
|
+
// the guard is for future-proofing against a limit rewrite.
|
|
74
|
+
const parts = splitToLimit(text, Math.max(1, limit));
|
|
75
|
+
if (parts.length === 1) return parts;
|
|
76
|
+
// Re-split under the label budget so `(i/N)\n` never pushes a part past the
|
|
77
|
+
// wire limit.
|
|
78
|
+
const labelled = splitToLimit(text, Math.max(1, limit - TELEGRAM_PART_LABEL_RESERVE));
|
|
79
|
+
return labelled.map((part, index) => `(${index + 1}/${labelled.length})\n${part}`);
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/**
|
|
83
|
+
* The base splitter, ported from omp-telegram's `splitToLimit`. Prefers a
|
|
84
|
+
* paragraph break, then a line break, then a space — each only when it sits in
|
|
85
|
+
* the second half of the window, so a digest splits on natural boundaries
|
|
86
|
+
* rather than mid-word. Never drops a byte: the fallback is a hard cut at
|
|
87
|
+
* `limit`, which split a run with no whitespace in the window in the middle,
|
|
88
|
+
* and the parts still reassemble to the input.
|
|
89
|
+
*/
|
|
90
|
+
function splitToLimit(text: string, limit: number): string[] {
|
|
91
|
+
if (text.length <= limit) return [text];
|
|
92
|
+
const out: string[] = [];
|
|
93
|
+
let rest = text;
|
|
94
|
+
while (rest.length > limit) {
|
|
95
|
+
const para = rest.lastIndexOf("\n\n", limit);
|
|
96
|
+
const line = rest.lastIndexOf("\n", limit);
|
|
97
|
+
const space = rest.lastIndexOf(" ", limit);
|
|
98
|
+
const cut = para > limit / 2 ? para : line > limit / 2 ? line : space > 0 ? space : limit;
|
|
99
|
+
out.push(rest.slice(0, cut));
|
|
100
|
+
rest = rest.slice(cut);
|
|
101
|
+
}
|
|
102
|
+
out.push(rest);
|
|
103
|
+
return out;
|
|
104
|
+
}
|
|
34
105
|
|
|
35
106
|
/**
|
|
36
107
|
* Whether a failed send settles the question of delivery. This distinction is
|
|
@@ -365,6 +436,13 @@ export interface ClaimedTopic {
|
|
|
365
436
|
name: string;
|
|
366
437
|
/** The herdr space the claiming pane sits in, when the bridge captured one. */
|
|
367
438
|
workspaceLabel?: string;
|
|
439
|
+
/**
|
|
440
|
+
* The transcript the claiming pane actually writes, when the bridge captured
|
|
441
|
+
* one. This is the *live* session file — herdr pins a restored pane to it
|
|
442
|
+
* (`omp --resume=<that path>`) — so it can sit in a different session
|
|
443
|
+
* directory than the pane's cwd implies (#600).
|
|
444
|
+
*/
|
|
445
|
+
sessionFile?: string;
|
|
368
446
|
}
|
|
369
447
|
|
|
370
448
|
/**
|
|
@@ -399,17 +477,47 @@ export function resolveProjectTopicId(project: ProjectConfig): number | undefine
|
|
|
399
477
|
const claims = claimedTelegramTopics();
|
|
400
478
|
if (claims.length === 0) return pinned;
|
|
401
479
|
if (claims.some((claim) => claim.threadId === pinned)) return pinned;
|
|
402
|
-
const
|
|
403
|
-
const match = bySpace ?? soleClaim(claims, (claim) => claim.name === project.name);
|
|
480
|
+
const match = claimForProject(claims, project.name);
|
|
404
481
|
if (match === undefined) return pinned;
|
|
405
482
|
warn(
|
|
406
483
|
`escalation.telegramTopicId for ${project.name} is no longer a claimed topic; ` +
|
|
407
484
|
`following omp-telegram's current "${project.name}" ` +
|
|
408
|
-
`${
|
|
485
|
+
`${match.workspaceLabel === project.name ? "herdr space claim" : "claim"} instead`,
|
|
409
486
|
);
|
|
410
487
|
return match.threadId;
|
|
411
488
|
}
|
|
412
489
|
|
|
490
|
+
/**
|
|
491
|
+
* The one claim answering to a project's identity — herdr space first, title
|
|
492
|
+
* second — or nothing when none or several do (#412).
|
|
493
|
+
*/
|
|
494
|
+
export function claimForProject(
|
|
495
|
+
claims: readonly ClaimedTopic[],
|
|
496
|
+
projectName: string,
|
|
497
|
+
): ClaimedTopic | undefined {
|
|
498
|
+
const bySpace = soleClaim(claims, (claim) => claim.workspaceLabel === projectName);
|
|
499
|
+
return bySpace ?? soleClaim(claims, (claim) => claim.name === projectName);
|
|
500
|
+
}
|
|
501
|
+
|
|
502
|
+
/**
|
|
503
|
+
* The transcript this project's live omp-telegram claim names — where the
|
|
504
|
+
* orchestrator pane's reply actually lands.
|
|
505
|
+
*
|
|
506
|
+
* `arm` derives its scan directory from the tick cwd, but a pane resumed from
|
|
507
|
+
* a session created elsewhere keeps the original transcript path (herdr pins
|
|
508
|
+
* the pane to it), so the cwd-derived directory is the one place the reply is
|
|
509
|
+
* guaranteed *not* to be (#600). The claim's `sessionFile` is the live answer;
|
|
510
|
+
* identity resolves exactly as {@link resolveProjectTopicId}'s substitution
|
|
511
|
+
* does, via {@link claimForProject}. Absent when the bridge has no claim or
|
|
512
|
+
* the claim names no file — the caller then falls back to the cwd-derived
|
|
513
|
+
* directory, which is the honest answer for a host that predates claims.
|
|
514
|
+
*/
|
|
515
|
+
export function resolveClaimedSessionFile(project: ProjectConfig): string | undefined {
|
|
516
|
+
const claims = claimedTelegramTopics();
|
|
517
|
+
if (claims.length === 0) return undefined;
|
|
518
|
+
return claimForProject(claims, project.name)?.sessionFile;
|
|
519
|
+
}
|
|
520
|
+
|
|
413
521
|
/**
|
|
414
522
|
* The one claim answering to `predicate`, or nothing when none or several do.
|
|
415
523
|
*
|
|
@@ -452,6 +560,7 @@ export function claimedTelegramTopics(stateDir?: string): ClaimedTopic[] {
|
|
|
452
560
|
if (!Number.isFinite(threadId) || !Number.isSafeInteger(threadId)) continue;
|
|
453
561
|
let name = id;
|
|
454
562
|
let workspaceLabel: string | undefined;
|
|
563
|
+
let sessionFile: string | undefined;
|
|
455
564
|
if (typeof entry === "object" && entry !== null) {
|
|
456
565
|
if ("name" in entry) {
|
|
457
566
|
const candidate = entry.name;
|
|
@@ -461,36 +570,84 @@ export function claimedTelegramTopics(stateDir?: string): ClaimedTopic[] {
|
|
|
461
570
|
const candidate = entry.workspaceLabel;
|
|
462
571
|
if (typeof candidate === "string" && candidate.trim() !== "") workspaceLabel = candidate.trim();
|
|
463
572
|
}
|
|
573
|
+
if ("sessionFile" in entry) {
|
|
574
|
+
const candidate = entry.sessionFile;
|
|
575
|
+
if (typeof candidate === "string" && candidate.trim() !== "") sessionFile = candidate.trim();
|
|
576
|
+
}
|
|
464
577
|
}
|
|
465
|
-
out.push(
|
|
578
|
+
out.push(
|
|
579
|
+
workspaceLabel === undefined && sessionFile === undefined
|
|
580
|
+
? { threadId, name }
|
|
581
|
+
: workspaceLabel === undefined
|
|
582
|
+
? { threadId, name, sessionFile }
|
|
583
|
+
: sessionFile === undefined
|
|
584
|
+
? { threadId, name, workspaceLabel }
|
|
585
|
+
: { threadId, name, workspaceLabel, sessionFile },
|
|
586
|
+
);
|
|
466
587
|
}
|
|
467
588
|
return out;
|
|
468
589
|
}
|
|
469
590
|
|
|
591
|
+
/**
|
|
592
|
+
* Whether the paired bridge runs with per-session topic tidy on (access.json
|
|
593
|
+
* `topicsTidy: true` — `/telegram topics tidy on`).
|
|
594
|
+
*
|
|
595
|
+
* On a tidy host every pane exit closes (forum) or deletes (DM) its own topic,
|
|
596
|
+
* so a pinned `escalation.telegramTopicId` is structurally stale: it can never
|
|
597
|
+
* be among the live claims again, and treating it as a live pin is the false
|
|
598
|
+
* belief #600 names. The bridge's own setting is read here so `doctor` can say
|
|
599
|
+
* "vestigial" rather than "moved once".
|
|
600
|
+
*/
|
|
601
|
+
export function telegramTopicsTidy(stateDirPath?: string): boolean {
|
|
602
|
+
const override = stateDirPath?.trim() ?? process.env.OMP_TELEGRAM_STATE_DIR?.trim();
|
|
603
|
+
const dir = override ? override : join(homedir(), ".omp", "agent", "telegram");
|
|
604
|
+
try {
|
|
605
|
+
const parsed = JSON.parse(readFileSync(join(dir, "access.json"), "utf8")) as {
|
|
606
|
+
readonly topicsTidy?: unknown;
|
|
607
|
+
};
|
|
608
|
+
return parsed.topicsTidy === true;
|
|
609
|
+
} catch {
|
|
610
|
+
return false;
|
|
611
|
+
}
|
|
612
|
+
}
|
|
613
|
+
|
|
470
614
|
/**
|
|
471
615
|
* The one Telegram send in this package. Exported so the report outbox (#123)
|
|
472
616
|
* reuses it rather than forking it: the response handling below is load-bearing
|
|
473
617
|
* and was paid for once already (see the comment on the parse). Resolves with
|
|
474
|
-
* Telegram's own message id
|
|
475
|
-
*
|
|
476
|
-
*
|
|
618
|
+
* Telegram's own message id for every accepted part, in order — one id for a
|
|
619
|
+
* message under the limit, one per message once a split is needed. Throws on
|
|
620
|
+
* every *known* failure — connection refused, HTTP error, `{"ok":false}` —
|
|
621
|
+
* which is what lets a caller treat a throw as "nobody has this" and a crash
|
|
622
|
+
* as "nobody knows".
|
|
623
|
+
*
|
|
624
|
+
* Splitting happens here, at the transport, so every message class that rides
|
|
625
|
+
* this seam — tier-2 escalations, report parts, fleet pages, operator
|
|
626
|
+
* messages — is delivered whole: a report's tail is the fleet state and open
|
|
627
|
+
* items, and a tier-2 escalation's tail may be the question itself (#566).
|
|
628
|
+
* Parts are sent strictly in order, one `sendMessage` at a time, so they never
|
|
629
|
+
* interleave with each other.
|
|
477
630
|
*
|
|
478
631
|
* Optional `topicId` pins the message to a forum topic (`message_thread_id`).
|
|
479
|
-
* A definitive missing-thread reject retries once as a flat chat and warns, so
|
|
480
|
-
* deleted topic degrades instead of silently losing the page (#318).
|
|
632
|
+
* A definitive missing-thread reject retries once as a flat chat and warns, so
|
|
633
|
+
* a deleted topic degrades instead of silently losing the page (#318). The
|
|
634
|
+
* flat retry re-sends the whole split: parts already accepted went into a
|
|
635
|
+
* thread Telegram has just told us is gone, so nothing is readable there that
|
|
636
|
+
* a flat resend would duplicate.
|
|
481
637
|
*/
|
|
482
638
|
export async function sendTelegram(
|
|
483
639
|
token: string,
|
|
484
640
|
chatId: string,
|
|
485
641
|
text: string,
|
|
486
642
|
opts?: { topicId?: number },
|
|
487
|
-
): Promise<number
|
|
643
|
+
): Promise<number[]> {
|
|
488
644
|
const topicId =
|
|
489
645
|
opts?.topicId !== undefined && Number.isFinite(opts.topicId) && Number.isSafeInteger(opts.topicId)
|
|
490
646
|
? opts.topicId
|
|
491
647
|
: undefined;
|
|
648
|
+
const parts = telegramTextParts(text);
|
|
492
649
|
try {
|
|
493
|
-
return await
|
|
650
|
+
return await postTelegramParts(token, chatId, parts, topicId);
|
|
494
651
|
} catch (err) {
|
|
495
652
|
if (
|
|
496
653
|
topicId === undefined ||
|
|
@@ -503,8 +660,23 @@ export async function sendTelegram(
|
|
|
503
660
|
warn(
|
|
504
661
|
`escalation.telegramTopicId=${topicId} is stale (${err.message}); retrying flat chat`,
|
|
505
662
|
);
|
|
506
|
-
return await
|
|
663
|
+
return await postTelegramParts(token, chatId, parts, undefined);
|
|
664
|
+
}
|
|
665
|
+
}
|
|
666
|
+
|
|
667
|
+
/** One part per `sendMessage`, in order, collecting the ids Telegram returns. */
|
|
668
|
+
async function postTelegramParts(
|
|
669
|
+
token: string,
|
|
670
|
+
chatId: string,
|
|
671
|
+
parts: readonly string[],
|
|
672
|
+
topicId: number | undefined,
|
|
673
|
+
): Promise<number[]> {
|
|
674
|
+
const ids: number[] = [];
|
|
675
|
+
for (const part of parts) {
|
|
676
|
+
const id = await postTelegramMessage(token, chatId, part, topicId);
|
|
677
|
+
if (id !== undefined) ids.push(id);
|
|
507
678
|
}
|
|
679
|
+
return ids;
|
|
508
680
|
}
|
|
509
681
|
|
|
510
682
|
/** Telegram's definitive "that forum topic is gone" answers. */
|
|
@@ -521,11 +693,11 @@ async function postTelegramMessage(
|
|
|
521
693
|
const url = `https://api.telegram.org/bot${token}/sendMessage`;
|
|
522
694
|
const payload: Record<string, unknown> = {
|
|
523
695
|
chat_id: chatId,
|
|
524
|
-
//
|
|
525
|
-
//
|
|
526
|
-
//
|
|
527
|
-
|
|
528
|
-
|
|
696
|
+
// `sendTelegram` splits long text into labelled parts above this call, so
|
|
697
|
+
// the text handed here is always a whole message: a part under the wire
|
|
698
|
+
// limit, or a message that never needed splitting. Nothing is truncated —
|
|
699
|
+
// a hard slice here is how the digest lost its tail (#566).
|
|
700
|
+
text,
|
|
529
701
|
disable_web_page_preview: true,
|
|
530
702
|
};
|
|
531
703
|
if (topicId !== undefined) payload.message_thread_id = topicId;
|
package/src/failure-class.ts
CHANGED
|
@@ -157,6 +157,35 @@ export function providerTransientFault(error: { status?: number; message: string
|
|
|
157
157
|
return error.message.split("\n")[0]?.trim() ?? error.message;
|
|
158
158
|
}
|
|
159
159
|
|
|
160
|
+
/**
|
|
161
|
+
* How many in-session provider 429s read as *sustained* rate limiting rather
|
|
162
|
+
* than a retried blip (#573).
|
|
163
|
+
*
|
|
164
|
+
* A single 429 the harness retried and recovered from is noise — counting it
|
|
165
|
+
* would reclassify every run that ever hit one throttle as a provider fault
|
|
166
|
+
* and let real failures escape the failed-attempt budget. Only a run that kept
|
|
167
|
+
* hitting the throttle never gets clear of it, and that is the signature the
|
|
168
|
+
* classifier needs. Three matches the "three strikes" motif this file already
|
|
169
|
+
* uses (`DISPATCH_INFRA_MAX_STRIKES`, `PROVIDER_TRANSIENT_MAX_STRIKES`).
|
|
170
|
+
*/
|
|
171
|
+
const SUSTAINED_RATE_LIMIT_COUNT = 3;
|
|
172
|
+
|
|
173
|
+
/**
|
|
174
|
+
* Evidence that this run drowned in in-session provider rate limiting, or
|
|
175
|
+
* `undefined` when it did not.
|
|
176
|
+
*
|
|
177
|
+
* Reads the count the worker recorded while the run was live — never a string
|
|
178
|
+
* match over the transcript, and never `lastError`: omp retried the 429s
|
|
179
|
+
* internally, so they never surfaced one `lastError`, which is exactly why the
|
|
180
|
+
* run was previously unclassifiable (#573). 429 is deliberately its own class:
|
|
181
|
+
* a generic 429 is rate limiting, a 402 is credit, a stream abort is
|
|
182
|
+
* transient — three failures with three remedies (#220).
|
|
183
|
+
*/
|
|
184
|
+
export function providerCapacityFault(count: number | undefined): string | undefined {
|
|
185
|
+
if (count === undefined || count < SUSTAINED_RATE_LIMIT_COUNT) return undefined;
|
|
186
|
+
return `${count} in-session provider rate limits (HTTP 429) — the harness retried and was exhausted`;
|
|
187
|
+
}
|
|
188
|
+
|
|
160
189
|
/**
|
|
161
190
|
* Evidence that this run never started, or `undefined` when something did happen.
|
|
162
191
|
*
|
|
@@ -300,6 +329,24 @@ export function classifyRun(
|
|
|
300
329
|
}
|
|
301
330
|
}
|
|
302
331
|
|
|
332
|
+
// Sustained in-session provider rate limiting: the run drowned in HTTP 429s
|
|
333
|
+
// the harness retried and never surfaced one as `lastError`, so neither the
|
|
334
|
+
// credit nor the transient branch sees it and it used to fall through to
|
|
335
|
+
// `unknown`, spending an implementation attempt on the provider's capacity
|
|
336
|
+
// (#573). Only the worker-recorded count — above a *sustained* threshold, so
|
|
337
|
+
// a single retried 429 on an otherwise healthy run stays untouched — reads as
|
|
338
|
+
// this. Distinct from the two provider classes on purpose and requeued free,
|
|
339
|
+
// bounded by its own strike cap in the daemon so a permanently throttled
|
|
340
|
+
// provider escalates instead of looping. Ahead of `neverStarted` for the same
|
|
341
|
+
// reason as the credit branch: a turn-0 run that immediately drowned in 429s
|
|
342
|
+
// would otherwise be absorbed by `env-start-failure` and lose its cause.
|
|
343
|
+
if (run.state === "failed" || run.state === "killed") {
|
|
344
|
+
const capacity = providerCapacityFault(run.provider429Count);
|
|
345
|
+
if (capacity !== undefined) {
|
|
346
|
+
return { cls: "provider-capacity", recovery: "requeue", evidence: capacity };
|
|
347
|
+
}
|
|
348
|
+
}
|
|
349
|
+
|
|
303
350
|
// A run that never started is the most classifiable failure there is, and the
|
|
304
351
|
// least deserving of an implementation attempt: the session did not get as far
|
|
305
352
|
// as reading the issue. See {@link neverStarted} for the two shapes and why the
|