omp-conductor 0.5.6 → 0.7.0
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 +22 -0
- package/package.json +1 -1
- package/src/approval-surface.ts +98 -4
- package/src/board.ts +134 -7
- package/src/briefs/orchestrator.md +30 -9
- package/src/briefs/policy.md +5 -4
- package/src/cli.ts +95 -2
- package/src/daemon.ts +117 -7
- package/src/failure-class.ts +62 -0
- package/src/fleet.ts +19 -1
- package/src/orchestrator-tick.ts +107 -19
- package/src/store.ts +14 -3
- package/src/tracker/github.ts +16 -0
- package/src/types.ts +12 -0
- package/src/unblock.ts +30 -1
- package/src/verbs/actions.ts +8 -0
- package/src/verbs/protocol.ts +29 -0
- package/src/verbs/server.ts +113 -10
- package/src/worktree.ts +24 -1
package/README.md
CHANGED
|
@@ -204,6 +204,26 @@ Also required on the host:
|
|
|
204
204
|
unconfigured rather than guessed at, on the grounds that a health row which
|
|
205
205
|
reads green over a broken contract is worse than one that overstates a fault.
|
|
206
206
|
|
|
207
|
+
- **A fleet that answers instead of narrating** needs one key, set once:
|
|
208
|
+
`/telegram set profile daemon` (omp-telegram 0.11.0 or newer). Without it the
|
|
209
|
+
bridge behaves as it does on a laptop: it finalizes a real Telegram message
|
|
210
|
+
per assistant turn for as long as a conversation is active — so one answer
|
|
211
|
+
arrives as several messages, and a message that lands mid-tick keeps relaying
|
|
212
|
+
that tick's internal turns — and it posts every local run's closing text to
|
|
213
|
+
`notifyChat`, which on a host whose runs are heartbeat ticks means each tick's
|
|
214
|
+
working prose. The profile switches all of it off at the transport: text
|
|
215
|
+
reaches Telegram only through `telegram_send` / `telegram_ask`, the idle post
|
|
216
|
+
is suppressed, and `telegram_ask` stays mounted and aimed at the paired owner
|
|
217
|
+
on every turn — including a locally injected tick, so it also removes the need
|
|
218
|
+
for `notifyMode` above. Approval and blocked-input pings still fire; those
|
|
219
|
+
mean a human is needed, which is the point of the channel.
|
|
220
|
+
|
|
221
|
+
`omp-conductor status` reports an interactive profile on the `telegram` row,
|
|
222
|
+
and every tick composed on one carries a prompt line saying so. Note the two
|
|
223
|
+
settings pull against each other before the profile exists: setting
|
|
224
|
+
`notifyMode` to make `telegram_ask` mountable is exactly what arms the idle
|
|
225
|
+
post, so the correctly askable fleet was also the loud one.
|
|
226
|
+
|
|
207
227
|
With neither, tier 2 degrades to a comment on the issue. Nothing is broken in
|
|
208
228
|
that configuration: it is supported, just slower to reach you.
|
|
209
229
|
|
|
@@ -1898,6 +1918,7 @@ omp-conductor release-pane [--project NAME]
|
|
|
1898
1918
|
omp-conductor tail <issue> [--project NAME]
|
|
1899
1919
|
omp-conductor extend <issue> --turns N [--project NAME]
|
|
1900
1920
|
omp-conductor unblock <issue> [--force] [--project NAME]
|
|
1921
|
+
omp-conductor verb <conductor_*> [--project NAME] [--arg k=v ...]
|
|
1901
1922
|
omp-conductor friction <escalation-digest|report-noise|report-surprise> --detail TEXT [--issue N] [--project NAME]
|
|
1902
1923
|
omp-conductor report --text TEXT [--kind material|digest] [--project NAME]
|
|
1903
1924
|
omp-conductor decision open --question TEXT [--blocks TEXT] [--resolves-when COND] [--project NAME]
|
|
@@ -1929,6 +1950,7 @@ omp-conductor help
|
|
|
1929
1950
|
| `tail <issue>` | Follow the newest run for that issue: the worker's assistant text as `assistant: …` and each tool it calls as `tool: <name>`, printed as they land. Workers are omp sessions inside the daemon rather than terminals, so this is the only way to watch one live — a herdr pane running it becomes an observation window. Starts from the top of the transcript, not the end, so attaching to a run that is already ten turns in shows those ten turns. Exits `1` with `no run recorded for #N` when the issue has never been dispatched, or `no transcript yet (state: …)` when the attempt has not opened one. Otherwise it runs until `Ctrl-C`, or until the run has finished and its transcript has been silent for five seconds, and prints `run ended: <state>`. |
|
|
1930
1951
|
| `extend <issue> --turns N [--project NAME]` | Monotonically raise that live worker's effective turn ceiling through its owning daemon. The current omp session keeps running; no restart or continuation is created. The daemon persists the new ceiling for `status` and rejects missing, settled, cap-killed, equal, or lower requests instead of implying that an immutable session changed. |
|
|
1931
1952
|
| `unblock <issue> [--force]` | Remove that issue's `blocked` and `failed` labels so an answered escalation can be claimed again. `agent:in-progress` comes off too, but only when the newest recorded run is terminal — that row is the proof no worker still owns the issue, so a live run keeps the label, and so does an issue with no run row at all. Run history remains intact: blocks consume the independent continuation budget, not failed implementation attempts. The output reports both budgets and warns when either will make the next tick escalate instead of dispatch. **Refuses, clearing nothing and exiting `3`, when the newest attempt's work could not be committed and its worktree is the only copy** — re-claiming removes that tree. `--force` records the operator's acceptance on the run row and then clears; the salvage failure stays in history. Exits `2` when the issue number is missing or malformed. |
|
|
1953
|
+
| `verb <conductor_*> [--arg k=v ...]` | Run one [mediated verb](#the-mediated-verbs-126) as the orchestrator, from the CLI — the external-orchestrator half of the verb surface. Every argument goes in as a `--arg k=v` string; an orchestrator can merge (`conductor_pr_merge`), label (`conductor_label`), release (`conductor_release`), update a branch (`conductor_pr_update_branch`) or title/body (`conductor_pr_update`), or read PR state (`conductor_pr_status`). The daemon applies the same checks and writes the same ledger rows a session's call would; a missing `--arg` is refused exactly as a missing tool argument is, worker-only verbs (`conductor_push`, `conductor_pr_create`) are refused with `role-not-allowed`, and a refusal exits `3`. An unknown verb exits `2`. |
|
|
1932
1954
|
| `friction <kind> --detail TEXT [--issue N]` | Record one bounded judgment the daemon cannot infer: an escalation belonged in a digest, or a tick report was noise/surprising. The detail is limited to 160 characters. One event never changes policy; three observations inside seven days make the aggregate eligible for one Learning-loop prompt, followed by a seven-day cooldown. |
|
|
1933
1955
|
| `report --text TEXT [--kind material|digest]` | Hand a rendered report to the daemon's durable outbox. The text is persisted **before** anything is sent and the command prints a report id; the daemon then owns delivery, retries on a bounded backoff, and records the Telegram message id it actually got back. Delivery is [at-least-once](#report-delivery-the-outbox) — a crash mid-send is retried and the retry says it may be a repeat — so `delivered` is never proof of exactly one message. `--kind digest` is accepted at most once per local day, decided from the ledger rather than from what the session remembers sending; an unknown `--kind` exits `2` rather than quietly becoming a material report. Anything undelivered shows in `status` with its age. |
|
|
1934
1956
|
| `decision open --question TEXT [--blocks TEXT] [--resolves-when COND]` | Record a question the orchestrator has put to you, and print its id. A question that lives only in a session's context is lost at the next compaction — after which it is either asked twice or dropped silently. `--resolves-when` attaches a machine-checkable condition: `pr-merged:<https url>`, `issue-closed:<n>`, or `npm-version:<pkg>@<version>`; anything else exits `2` listing the three forms. See [The decision ledger](#the-decision-ledger-136). |
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "omp-conductor",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.7.0",
|
|
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.",
|
package/src/approval-surface.ts
CHANGED
|
@@ -33,6 +33,17 @@
|
|
|
33
33
|
* The legacy `away: true` boolean counts: `loadAccess()` migrates it to
|
|
34
34
|
* `notifyMode: "away"` on read, so a fleet still carrying it resolves a target
|
|
35
35
|
* and must not be reported as broken.
|
|
36
|
+
*
|
|
37
|
+
* Since omp-telegram 0.11.0 there is a second way for a locally injected turn to
|
|
38
|
+
* resolve a target, and it counts here for the same reason the legacy boolean
|
|
39
|
+
* does: `profile: "daemon"` makes `notifyTarget()` resolve without `notifyMode`
|
|
40
|
+
* at all, because the profile *is* the headless contract — explicit-only
|
|
41
|
+
* outbound, no idle notify post, and `telegram_ask` targeted at the paired owner
|
|
42
|
+
* on every turn including a cron tick. A fleet carrying it has the surface #114
|
|
43
|
+
* was about, so demanding `notifyMode` beside it would be the false alarm this
|
|
44
|
+
* file exists to avoid. The `notifyMode` paths are all still live: an older
|
|
45
|
+
* plugin ignores the `profile` key entirely, and a host running one keeps
|
|
46
|
+
* working unchanged.
|
|
36
47
|
*/
|
|
37
48
|
|
|
38
49
|
import { readFileSync } from "node:fs";
|
|
@@ -44,7 +55,22 @@ import { dirname, join } from "node:path";
|
|
|
44
55
|
* says whether it can be called must name the same thing. */
|
|
45
56
|
export const TELEGRAM_APPROVAL_TOOL = "telegram_ask";
|
|
46
57
|
|
|
47
|
-
export type ApprovalSurface =
|
|
58
|
+
export type ApprovalSurface =
|
|
59
|
+
| { kind: "ready"; /** `notifyMode` from access.json, when the surface read it
|
|
60
|
+
(#169): whether end-of-turn text reaches the operator's
|
|
61
|
+
chat, so the tick's delivery rule can say so truthfully. */
|
|
62
|
+
notifyMode?: string }
|
|
63
|
+
| { kind: "missing"; reason: string };
|
|
64
|
+
|
|
65
|
+
/** Whether the Telegram bridge is running the headless contract, or merely
|
|
66
|
+
* configured well enough to answer a question.
|
|
67
|
+
*
|
|
68
|
+
* Two questions, not one, which is why this is a separate answer from
|
|
69
|
+
* {@link ApprovalSurface}: a fleet can be perfectly *askable* and still be
|
|
70
|
+
* narrating every turn into its operator's chat. `interactive` carries the
|
|
71
|
+
* reason because both readers of it — a tick prompt and a status row — have to
|
|
72
|
+
* name the remedy, and one spelling of it keeps them in agreement. */
|
|
73
|
+
export type DaemonProfileSurface = { kind: "daemon" } | { kind: "interactive"; reason: string };
|
|
48
74
|
|
|
49
75
|
/** One checked read of a JSON property, so nothing below asserts a shape the
|
|
50
76
|
* parse never proved. Anything that is not a plain object, or a key that is
|
|
@@ -156,7 +182,20 @@ export function readApprovalSurface(path: string): ApprovalSurface {
|
|
|
156
182
|
};
|
|
157
183
|
}
|
|
158
184
|
const mode = field(access, "notifyMode");
|
|
159
|
-
|
|
185
|
+
// Three spellings of the same fact, and none of them is redundant.
|
|
186
|
+
// `notifyMode` is what an interactive host sets; `away: true` is the retired
|
|
187
|
+
// boolean `loadAccess()` migrates on read; `profile: "daemon"` is the headless
|
|
188
|
+
// contract, which omp-telegram ≥ 0.11.0 treats as its own reason to resolve a
|
|
189
|
+
// notify target (`notifyTarget()` gates on `notifyMode || profile === "daemon"`
|
|
190
|
+
// and `before_agent_start` mounts `telegram_ask` on the same disjunction). A
|
|
191
|
+
// daemon-profile fleet with no `notifyMode` is therefore *more* answerable than
|
|
192
|
+
// the configuration #114 asked for, not less, and reporting it broken would be
|
|
193
|
+
// the every-interval false alarm this file was written to prevent.
|
|
194
|
+
const active =
|
|
195
|
+
mode === "away" ||
|
|
196
|
+
mode === "always" ||
|
|
197
|
+
field(access, "away") === true ||
|
|
198
|
+
field(access, "profile") === "daemon";
|
|
160
199
|
if (!active) {
|
|
161
200
|
return {
|
|
162
201
|
kind: "missing",
|
|
@@ -223,7 +262,7 @@ export function readApprovalSurface(path: string): ApprovalSurface {
|
|
|
223
262
|
"set notifyChat to the paired owner id",
|
|
224
263
|
};
|
|
225
264
|
}
|
|
226
|
-
return { kind: "ready" };
|
|
265
|
+
return { kind: "ready", notifyMode: mode === undefined ? undefined : String(mode) };
|
|
227
266
|
}
|
|
228
267
|
|
|
229
268
|
// A group destination needs a policy entry, and that policy must admit the
|
|
@@ -249,5 +288,60 @@ export function readApprovalSurface(path: string): ApprovalSurface {
|
|
|
249
288
|
`paired owner (${owner}) in its allowFrom, so their answer would be rejected`,
|
|
250
289
|
};
|
|
251
290
|
}
|
|
252
|
-
return { kind: "ready" };
|
|
291
|
+
return { kind: "ready", notifyMode: mode === undefined ? undefined : String(mode) };
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
/**
|
|
295
|
+
* Reads the same access file and answers the other question: does visible
|
|
296
|
+
* assistant text stay out of the operator's chat?
|
|
297
|
+
*
|
|
298
|
+
* A separate read rather than a field on {@link ApprovalSurface}, because the
|
|
299
|
+
* two faults have different remedies and a caller may care about only one. This
|
|
300
|
+
* one is about *noise*: without the profile, omp-telegram relays a real Telegram
|
|
301
|
+
* message per assistant turn for as long as a conversation is marked active
|
|
302
|
+
* (`outbound.ts` `onTurnEnd`), and — because the fleet sets `notifyMode` for the
|
|
303
|
+
* approval surface above — posts every local run's closing text to the notify
|
|
304
|
+
* chat from its `agent_end` handler. So a correctly *askable* fleet is exactly
|
|
305
|
+
* the one that narrates: the two settings pull against each other, which is why
|
|
306
|
+
* both are checked and only the profile silences the second.
|
|
307
|
+
*
|
|
308
|
+
* One key decides it, deliberately. `profile: "daemon"` is omp-telegram ≥ 0.11.0's
|
|
309
|
+
* whole headless contract — explicit-only outbound, no idle/final notify post,
|
|
310
|
+
* `telegram_ask` targeted at the paired owner on every turn — so verifying it is
|
|
311
|
+
* one comparison rather than three inferences about `streaming`, `notifyMode` and
|
|
312
|
+
* turn origin that would drift the moment the plugin changes. Anything other
|
|
313
|
+
* than the literal string is `interactive`: `loadAccess()` drops every other
|
|
314
|
+
* value to undefined, so `"DAEMON"` and `5` are not the contract on the host
|
|
315
|
+
* either, and reporting them as one would promise a suppression nothing applied.
|
|
316
|
+
*
|
|
317
|
+
* Never throws, and never writes: conductor reads this file, the operator's
|
|
318
|
+
* `/telegram set profile daemon` is what changes it. An unreadable or corrupt
|
|
319
|
+
* file answers `interactive` for the reason the whole module fails closed — a
|
|
320
|
+
* green signal over an unproven contract is what #114 was.
|
|
321
|
+
*/
|
|
322
|
+
export function readDaemonProfile(accessPath: string): DaemonProfileSurface {
|
|
323
|
+
const remedy = "assistant text can auto-relay to Telegram — run `/telegram set profile daemon`";
|
|
324
|
+
let access: unknown;
|
|
325
|
+
try {
|
|
326
|
+
access = JSON.parse(readFileSync(accessPath, "utf8"));
|
|
327
|
+
} catch {
|
|
328
|
+
return {
|
|
329
|
+
kind: "interactive",
|
|
330
|
+
reason: `cannot read or parse ${accessPath}, so no telegram profile is proven; ${remedy}`,
|
|
331
|
+
};
|
|
332
|
+
}
|
|
333
|
+
if (access === null || typeof access !== "object" || Array.isArray(access)) {
|
|
334
|
+
return {
|
|
335
|
+
kind: "interactive",
|
|
336
|
+
reason: `${accessPath} is not an object, so no telegram profile is proven; ${remedy}`,
|
|
337
|
+
};
|
|
338
|
+
}
|
|
339
|
+
const profile = field(access, "profile");
|
|
340
|
+
if (profile === "daemon") return { kind: "daemon" };
|
|
341
|
+
// An absent key reads as "default" rather than "undefined": that is the word
|
|
342
|
+
// the plugin's own `/telegram status` line and its `profile: daemon | default`
|
|
343
|
+
// help text use, and an operator matching a status row against this reason
|
|
344
|
+
// should not have to translate.
|
|
345
|
+
const observed = profile === undefined ? "default" : JSON.stringify(profile);
|
|
346
|
+
return { kind: "interactive", reason: `telegram profile is ${observed}; ${remedy}` };
|
|
253
347
|
}
|
package/src/board.ts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { closeSync, openSync, readSync, statSync } from "node:fs";
|
|
1
|
+
import { closeSync, existsSync, openSync, readSync, statSync } from "node:fs";
|
|
2
2
|
import { join } from "node:path";
|
|
3
3
|
import { emitKeypressEvents } from "node:readline";
|
|
4
4
|
import { findProject, loadConfig, resolveCaps } from "./config.ts";
|
|
@@ -16,10 +16,21 @@ import { dbPath, openStore } from "./store.ts";
|
|
|
16
16
|
import { formatTranscriptLine } from "./transcript.ts";
|
|
17
17
|
import { makeTracker } from "./tracker/github.ts";
|
|
18
18
|
import { planUsageBadge, readPlanUsage, sharedUsageSource } from "./usage.ts";
|
|
19
|
-
import type {
|
|
19
|
+
import type {
|
|
20
|
+
AdmissionHoldReason,
|
|
21
|
+
PrVerification,
|
|
22
|
+
ProjectConfig,
|
|
23
|
+
RunRecord,
|
|
24
|
+
RunState,
|
|
25
|
+
Store,
|
|
26
|
+
} from "./types.ts";
|
|
20
27
|
|
|
21
28
|
const REFRESH_MS = 1_000;
|
|
22
29
|
const HEALTH_REFRESH_MS = 10_000;
|
|
30
|
+
/** Cadence for re-verifying pushed rows against GitHub — deliberately slower
|
|
31
|
+
* than the board's health/label probe: it is one `gh` call per pushed row, and
|
|
32
|
+
* pull-request state does not move at 1 Hz. #173. */
|
|
33
|
+
const PR_REFRESH_MS = 60_000;
|
|
23
34
|
const MERGED_HISTORY_MS = 24 * 60 * 60_000;
|
|
24
35
|
const TRANSCRIPT_BYTES = 64 * 1024;
|
|
25
36
|
const MIN_WIDTH = 50;
|
|
@@ -53,6 +64,7 @@ const COLUMN_DEFS = [
|
|
|
53
64
|
{ key: "blocked", title: "BLOCKED" },
|
|
54
65
|
{ key: "failed", title: "FAILED" },
|
|
55
66
|
{ key: "orphaned", title: "ORPHANED" },
|
|
67
|
+
{ key: "parked", title: "PARKED" },
|
|
56
68
|
{ key: "merged", title: "MERGED" },
|
|
57
69
|
{ key: "history", title: "HISTORY" },
|
|
58
70
|
] as const satisfies readonly { key: string; title: string }[];
|
|
@@ -69,6 +81,23 @@ const LIVE_LANES: Partial<Record<RunState, BoardLane>> = {
|
|
|
69
81
|
"pushed-green": "green",
|
|
70
82
|
};
|
|
71
83
|
|
|
84
|
+
/** Terminal run states that park an issue nobody has acted on: a human cleared
|
|
85
|
+
* the state labels but the issue is still open, so its last run is the story
|
|
86
|
+
* that has to stay visible — #173. Differs from {@link LIVE_LANES} in that
|
|
87
|
+
* these are rows, never current work, and from MERGED in that they are not a
|
|
88
|
+
* happy resolution. */
|
|
89
|
+
const PARKED_STATES = new Set<RunState>(["blocked", "failed", "killed", "orphaned"]);
|
|
90
|
+
|
|
91
|
+
/** Whether a terminal blocked/failed run is wearing its own state label. When
|
|
92
|
+
* the label is absent the run row is the only record of what happened, so the
|
|
93
|
+
* card and detail header say "last run: <state>" rather than presenting it as
|
|
94
|
+
* current — #173, the same distinction that parks the lane. */
|
|
95
|
+
function isLastRun(run: RunRecord, labels: BoardLabels): boolean {
|
|
96
|
+
if (run.state === "blocked") return !labels.blocked.has(run.issue);
|
|
97
|
+
if (run.state === "failed") return !labels.failed.has(run.issue);
|
|
98
|
+
return false;
|
|
99
|
+
}
|
|
100
|
+
|
|
72
101
|
type DaemonBoardState = "stopped" | "ok" | "unreachable" | "other-project";
|
|
73
102
|
|
|
74
103
|
/**
|
|
@@ -114,6 +143,11 @@ export interface BoardLabels {
|
|
|
114
143
|
inProgress: ReadonlySet<number>;
|
|
115
144
|
blocked: ReadonlySet<number>;
|
|
116
145
|
failed: ReadonlySet<number>;
|
|
146
|
+
/** Issues whose newest run is terminal and unlabelled but that are still open
|
|
147
|
+
* on the tracker — the PARKED lane (#173), probed on the same 10s cadence as
|
|
148
|
+
* the label sets. An issue missing from this set (closed, or the probe could
|
|
149
|
+
* not tell) keeps today's HISTORY/undefined behaviour rather than parking. */
|
|
150
|
+
open: ReadonlySet<number>;
|
|
117
151
|
/** Epoch ms of the last read that succeeded; 0 when none has. */
|
|
118
152
|
readAt: number;
|
|
119
153
|
/** Message from the most recent failed read. The sets above are then the last
|
|
@@ -129,6 +163,7 @@ const UNREAD_LABELS: BoardLabels = {
|
|
|
129
163
|
inProgress: new Set<number>(),
|
|
130
164
|
blocked: new Set<number>(),
|
|
131
165
|
failed: new Set<number>(),
|
|
166
|
+
open: new Set<number>(),
|
|
132
167
|
readAt: 0,
|
|
133
168
|
};
|
|
134
169
|
|
|
@@ -137,10 +172,18 @@ export interface BoardSnapshot {
|
|
|
137
172
|
status: StatusSnapshot;
|
|
138
173
|
health: BoardHealth;
|
|
139
174
|
labels: BoardLabels;
|
|
175
|
+
/** Live re-verification of pushed rows (#173): run id → what the tracker says
|
|
176
|
+
* the PR looks like now. Rendered as a `now:` suffix on the card. */
|
|
177
|
+
pr: ReadonlyMap<string, { status: PrVerification["status"]; reason: string }>;
|
|
140
178
|
runs: RunRecord[];
|
|
141
179
|
now: number;
|
|
142
180
|
}
|
|
143
181
|
|
|
182
|
+
/** The card state a {@link PrVerification.status} reads as in the UI. */
|
|
183
|
+
function prLabel(status: PrVerification["status"]): string {
|
|
184
|
+
return status === "green" ? "green" : status === "failed" ? "red" : "pending";
|
|
185
|
+
}
|
|
186
|
+
|
|
144
187
|
export interface BoardCursor {
|
|
145
188
|
column: number;
|
|
146
189
|
card: number;
|
|
@@ -166,6 +209,7 @@ function ansiColor(key: BoardLane): string {
|
|
|
166
209
|
case "claimed":
|
|
167
210
|
case "blocked":
|
|
168
211
|
case "orphaned":
|
|
212
|
+
case "parked":
|
|
169
213
|
return YELLOW;
|
|
170
214
|
case "failed":
|
|
171
215
|
return RED;
|
|
@@ -266,6 +310,13 @@ function laneOf(
|
|
|
266
310
|
if (queued.has(issue)) return "queue";
|
|
267
311
|
if (run?.state === "merged") return "merged";
|
|
268
312
|
if (run === undefined) return undefined;
|
|
313
|
+
// PARKED (#173): a terminal run row with no state or queue label left on it,
|
|
314
|
+
// on an issue the tracker says is still open. A human cleared the labels but
|
|
315
|
+
// has not acted — the blank lane the four labels used to imply is a lie, and
|
|
316
|
+
// closed or unreadable issues keep the HISTORY/undefined fallthrough below.
|
|
317
|
+
// Deliberately not subject to MERGED_HISTORY_MS: parked work does not stop
|
|
318
|
+
// being the issue's last word after 24 hours.
|
|
319
|
+
if (PARKED_STATES.has(run.state) && labels.open.has(issue)) return "parked";
|
|
269
320
|
// What is left is a terminal run row with no current claim on it — a closed
|
|
270
321
|
// issue, or one a human already cleared. Recent rows stay readable under
|
|
271
322
|
// HISTORY for the same 24 hours MERGED uses; older ones are not news.
|
|
@@ -424,6 +475,21 @@ function runCardLines(run: RunRecord, snapshot: BoardSnapshot): string[] {
|
|
|
424
475
|
`attempt ${run.attempt} · ${run.turns}/${run.maxTurns}t`,
|
|
425
476
|
`$${run.spendUsd.toFixed(2)} · ${duration}`,
|
|
426
477
|
];
|
|
478
|
+
// #173: a live re-verification of a pushed row, when the 60s probe has one.
|
|
479
|
+
// Guarded on the pushed states: a stale entry must not follow a row that
|
|
480
|
+
// settled into another lane and keep claiming "now: green" about a PR that
|
|
481
|
+
// already merged or failed.
|
|
482
|
+
const live =
|
|
483
|
+
run.state === "pushed-green" || run.state === "pushed-pending" ? snapshot.pr.get(run.id) : undefined;
|
|
484
|
+
if (live !== undefined) {
|
|
485
|
+
const reason = live.reason.split(/\s+/, 1)[0] ?? "";
|
|
486
|
+
lines.push(
|
|
487
|
+
live.status === "failed" ? `now: red — ${reason === "" ? "failed" : reason}` : `now: ${prLabel(live.status)}`,
|
|
488
|
+
);
|
|
489
|
+
}
|
|
490
|
+
// #173: a terminal blocked/failed run whose state label is gone reads as the
|
|
491
|
+
// lane it sits in — which the label being absent made PARKED, not the truth.
|
|
492
|
+
if (isLastRun(run, snapshot.labels)) lines.push(`last run: ${run.state}`);
|
|
427
493
|
// Ordered by what needs a human first. An unsalvaged tree outranks even a
|
|
428
494
|
// last error: the error describes a run that is over, the tree is work that
|
|
429
495
|
// is still at risk and an issue that will not dispatch (#118).
|
|
@@ -567,6 +633,9 @@ function renderBoardColumns(snapshot: BoardSnapshot, cursor: BoardCursor, width:
|
|
|
567
633
|
|
|
568
634
|
function readTranscript(run: RunRecord): string[] {
|
|
569
635
|
if (run.sessionFile === undefined) return [`No transcript yet (state: ${run.state}).`];
|
|
636
|
+
// #172: a row name that never materialised is "not yet", not a read error —
|
|
637
|
+
// rendering it as `Transcript unavailable` would blame the file for existing.
|
|
638
|
+
if (!existsSync(run.sessionFile)) return [`No transcript yet (state: ${run.state}).`];
|
|
570
639
|
let fd: number | undefined;
|
|
571
640
|
try {
|
|
572
641
|
const size = statSync(run.sessionFile).size;
|
|
@@ -617,8 +686,11 @@ function renderDetail(snapshot: BoardSnapshot, cursor: BoardCursor, width: numbe
|
|
|
617
686
|
}
|
|
618
687
|
|
|
619
688
|
const run = card.run;
|
|
689
|
+
// #173: a blocked/failed run whose label is gone is the reason this card is
|
|
690
|
+
// parked; the header says so rather than presenting the state as current.
|
|
691
|
+
const stateShown = isLastRun(run, snapshot.labels) ? `last run: ${run.state}` : run.state;
|
|
620
692
|
const metadata = [
|
|
621
|
-
styledCell(` RUN #${run.issue} ${run.repo} ${
|
|
693
|
+
styledCell(` RUN #${run.issue} ${run.repo} ${stateShown} `, width, `${BOLD}${REVERSE}`),
|
|
622
694
|
styledCell(`attempt ${run.attempt} · ${run.turns}/${run.maxTurns} turns · $${run.spendUsd.toFixed(2)} · ${humanDuration((run.endedAt ?? snapshot.now) - run.startedAt)}`, width),
|
|
623
695
|
styledCell(`branch ${run.branch}`, width, DIM),
|
|
624
696
|
styledCell(`worktree ${run.worktree || "removed"}`, width, DIM),
|
|
@@ -739,7 +811,11 @@ async function probeBoardHealth(project: ProjectConfig): Promise<BoardHealth> {
|
|
|
739
811
|
* why, because blanking QUEUE and FAILED is a louder lie than showing them
|
|
740
812
|
* stale — {@link labelFact} puts the staleness in the header.
|
|
741
813
|
*/
|
|
742
|
-
async function probeBoardLabels(
|
|
814
|
+
async function probeBoardLabels(
|
|
815
|
+
project: ProjectConfig,
|
|
816
|
+
store: Store,
|
|
817
|
+
previous?: BoardLabels,
|
|
818
|
+
): Promise<BoardLabels> {
|
|
743
819
|
const openIssuesLabelled = async (label: string): Promise<Set<number>> =>
|
|
744
820
|
new Set((await makeTracker({ ...project, queueLabel: label }).listReady()).map((issue) => issue.number));
|
|
745
821
|
try {
|
|
@@ -749,7 +825,26 @@ async function probeBoardLabels(project: ProjectConfig, previous?: BoardLabels):
|
|
|
749
825
|
openIssuesLabelled(project.stateLabels.blocked),
|
|
750
826
|
openIssuesLabelled(project.stateLabels.failed),
|
|
751
827
|
]);
|
|
752
|
-
|
|
828
|
+
// PARKED candidates: the newest run per issue, terminal, non-merged, and
|
|
829
|
+
// wearing no state or queue label. Exactly the rows {@link laneOf} would
|
|
830
|
+
// otherwise fall through on. Bounded by `recentRuns`' own recentJobs limit,
|
|
831
|
+
// issued in parallel, and cached: a failed read keeps the last set.
|
|
832
|
+
const labelled = new Set([...queued, ...inProgress, ...blocked, ...failed]);
|
|
833
|
+
const newestRun = new Map<number, RunRecord>();
|
|
834
|
+
for (const run of store.recentRuns(project.name, Date.now() - MERGED_HISTORY_MS)) {
|
|
835
|
+
if (!newestRun.has(run.issue)) newestRun.set(run.issue, run);
|
|
836
|
+
}
|
|
837
|
+
const parkedCandidates = [...newestRun.values()].filter(
|
|
838
|
+
(run) => PARKED_STATES.has(run.state) && !labelled.has(run.issue),
|
|
839
|
+
);
|
|
840
|
+
const open = new Set<number>();
|
|
841
|
+
await Promise.all(
|
|
842
|
+
parkedCandidates.map(async (run) => {
|
|
843
|
+
const state = await makeTracker(project).issueState(run.issue);
|
|
844
|
+
if (state === "open") open.add(run.issue);
|
|
845
|
+
}),
|
|
846
|
+
);
|
|
847
|
+
return { queued, inProgress, blocked, failed, open, readAt: Date.now() };
|
|
753
848
|
} catch (err) {
|
|
754
849
|
return {
|
|
755
850
|
...(previous ?? UNREAD_LABELS),
|
|
@@ -827,11 +922,17 @@ export async function runBoard(projectName?: string): Promise<void> {
|
|
|
827
922
|
let notice = "";
|
|
828
923
|
let [health, labels, planUsage] = await Promise.all([
|
|
829
924
|
probeBoardHealth(project),
|
|
830
|
-
probeBoardLabels(project),
|
|
925
|
+
probeBoardLabels(project, store),
|
|
831
926
|
readPlanUsage(caps.planUsage, sharedUsageSource()),
|
|
832
927
|
]);
|
|
833
928
|
let healthAt = Date.now();
|
|
834
929
|
let healthRefresh: Promise<void> | undefined;
|
|
930
|
+
// Live re-verification of pushed rows (#173): cached per run id, refreshed on
|
|
931
|
+
// the slow PR cadence so a `gh` call per pushed row does not ride the 1 Hz
|
|
932
|
+
// repaint or the 10 s health gate.
|
|
933
|
+
const prProbe = new Map<string, { status: PrVerification["status"]; reason: string }>();
|
|
934
|
+
let prProbeAt = 0;
|
|
935
|
+
let prProbeRun: Promise<void> | undefined;
|
|
835
936
|
|
|
836
937
|
emitKeypressEvents(process.stdin);
|
|
837
938
|
const onKey = (_text: string, key: KeyInput): void => enqueue(queue, key, wake);
|
|
@@ -855,7 +956,7 @@ export async function runBoard(projectName?: string): Promise<void> {
|
|
|
855
956
|
healthAt = now;
|
|
856
957
|
healthRefresh = Promise.all([
|
|
857
958
|
probeBoardHealth(project),
|
|
858
|
-
probeBoardLabels(project, labels),
|
|
959
|
+
probeBoardLabels(project, store, labels),
|
|
859
960
|
// On the health cadence, not the 1s repaint: the provider read is a
|
|
860
961
|
// subprocess, and an allowance does not move at 1 Hz.
|
|
861
962
|
readPlanUsage(caps.planUsage, sharedUsageSource()),
|
|
@@ -873,11 +974,37 @@ export async function runBoard(projectName?: string): Promise<void> {
|
|
|
873
974
|
healthRefresh = undefined;
|
|
874
975
|
});
|
|
875
976
|
}
|
|
977
|
+
// #173: re-verify pushed rows against the tracker on their own slow
|
|
978
|
+
// cadence, so red-shown-as-green and a moved head surface without paying
|
|
979
|
+
// for the read every second. A failed probe leaves the last verdicts in
|
|
980
|
+
// place (blanking a card's `now:` is a quieter lie than nothing).
|
|
981
|
+
if (now - prProbeAt >= PR_REFRESH_MS && prProbeRun === undefined) {
|
|
982
|
+
prProbeAt = now;
|
|
983
|
+
const rows = store.recentRuns(project.name, now - MERGED_HISTORY_MS).filter(
|
|
984
|
+
(run) => (run.state === "pushed-green" || run.state === "pushed-pending") && run.prUrl !== undefined && run.headSha !== undefined,
|
|
985
|
+
);
|
|
986
|
+
prProbeRun = (async () => {
|
|
987
|
+
const tracker = makeTracker(project);
|
|
988
|
+
await Promise.all(
|
|
989
|
+
rows.map(async (run) => {
|
|
990
|
+
const verified = await tracker.verifyPr(run.prUrl!, run.headSha!);
|
|
991
|
+
if (verified !== undefined) prProbe.set(run.id, { status: verified.status, reason: verified.reason });
|
|
992
|
+
}),
|
|
993
|
+
);
|
|
994
|
+
})()
|
|
995
|
+
.catch((err: unknown) => {
|
|
996
|
+
notice = `PR probe failed: ${err instanceof Error ? err.message : String(err)}`;
|
|
997
|
+
})
|
|
998
|
+
.finally(() => {
|
|
999
|
+
prProbeRun = undefined;
|
|
1000
|
+
});
|
|
1001
|
+
}
|
|
876
1002
|
const snapshot: BoardSnapshot = {
|
|
877
1003
|
project,
|
|
878
1004
|
status: statusSnapshotFromStore(project, caps, store, planUsage),
|
|
879
1005
|
health,
|
|
880
1006
|
labels,
|
|
1007
|
+
pr: prProbe,
|
|
881
1008
|
runs: store.recentRuns(project.name, now - MERGED_HISTORY_MS),
|
|
882
1009
|
now,
|
|
883
1010
|
};
|
|
@@ -208,18 +208,23 @@ sent that way is undetectable when it does not arrive.
|
|
|
208
208
|
|
|
209
209
|
## Human messages
|
|
210
210
|
|
|
211
|
-
A human writing to you between ticks is not a tick. Answer
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
211
|
+
A human writing to you between ticks is not a tick. Answer with a **single
|
|
212
|
+
`telegram_send` call** — one message, the answer only, from evidence you already
|
|
213
|
+
hold or go and fetch. Never answer as plain end-of-turn text: on this session,
|
|
214
|
+
text you merely write reaches nobody — if you do not call `telegram_send`, the
|
|
215
|
+
person gets silence. While handling any turn, produce no visible commentary
|
|
216
|
+
between tool calls — reasoning stays in thinking, actions stay in tools.
|
|
217
|
+
|
|
218
|
+
If the answer needs a decision from the operator (a choice, a yes/no, an
|
|
219
|
+
approval), ask it with `telegram_ask` — never a numbered-options message via
|
|
220
|
+
`telegram_send`, and never the generic `ask` UI. A cancelled or errored
|
|
221
|
+
`telegram_ask` is a delivery failure, not an answer.
|
|
216
222
|
|
|
217
223
|
A message may also reach you **mid-tick** (delivery is steering: it arrives
|
|
218
224
|
between two of your tool calls). Treat it as an interrupt, not a new tick:
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
person is waiting now.
|
|
225
|
+
`telegram_send` the answer immediately, then return to the duty you were in the
|
|
226
|
+
middle of and finish it. Never abandon or restart the tick because a message
|
|
227
|
+
arrived, and never batch the answer "for the report" — the person is waiting now.
|
|
223
228
|
|
|
224
229
|
## Escalation tiers
|
|
225
230
|
|
|
@@ -285,6 +290,22 @@ what you asked for and what it decided — readable with `omp-conductor ledger`,
|
|
|
285
290
|
including the refusals. That record is the point: a merge or a release the
|
|
286
291
|
dispatcher did not perform is one nobody can account for afterwards.
|
|
287
292
|
|
|
293
|
+
An external orchestrator (a session outside the daemon) runs the same verbs, with
|
|
294
|
+
the same checks and the same ledger rows, through the CLI:
|
|
295
|
+
|
|
296
|
+
```
|
|
297
|
+
omp-conductor verb conductor_pr_merge --arg prUrl=<url> --arg headSha=<sha> --arg reason=<reason>
|
|
298
|
+
omp-conductor verb conductor_label --arg issueUrl=<url> --arg label=<name> --arg action=add --arg reason=<reason>
|
|
299
|
+
omp-conductor verb conductor_release --arg shape=git-tag --arg repo=<name> --arg reason=<reason> --arg tag=<tag>
|
|
300
|
+
omp-conductor verb conductor_pr_update_branch --arg prUrl=<url>
|
|
301
|
+
omp-conductor verb conductor_pr_update --arg prUrl=<url> --arg title=<title>
|
|
302
|
+
```
|
|
303
|
+
|
|
304
|
+
Each `--arg k=v` is one argument, read as a string. Worker-only verbs
|
|
305
|
+
(`conductor_push`, `conductor_pr_create`) are refused with `role-not-allowed` —
|
|
306
|
+
an external orchestrator never pushes or opens a PR, exactly as this floor's
|
|
307
|
+
sessions do not.
|
|
308
|
+
|
|
288
309
|
| Tool | Yours when | What the dispatcher checks before acting |
|
|
289
310
|
| --- | --- | --- |
|
|
290
311
|
| `conductor_pr_status` | always | Nothing to gate: it reads. |
|
package/src/briefs/policy.md
CHANGED
|
@@ -74,10 +74,11 @@ Your report scope is **`{{REPORT_SCOPE}}`**. All three scopes, spelled out:
|
|
|
74
74
|
issue you pulled off the queue, a cap that stopped the fleet. A tick where
|
|
75
75
|
nothing changed still says nothing — "no change" is not an event.
|
|
76
76
|
|
|
77
|
-
**Delivery.**
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
77
|
+
**Delivery.** Never rely on end-of-turn text reaching anyone. The only delivery
|
|
78
|
+
paths are `omp-conductor report` (reports — persisted, daemon-retried),
|
|
79
|
+
`telegram_send` (a person who is waiting), and `telegram_ask` (a decision).
|
|
80
|
+
Everything else is noise or silence. Hand every reportable event to the
|
|
81
|
+
conductor's outbox:
|
|
81
82
|
|
|
82
83
|
```
|
|
83
84
|
omp-conductor report --text "<the whole report>" # a material event
|