omp-conductor 0.2.2 → 0.3.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +212 -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 +474 -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/lifecycle.ts
CHANGED
|
@@ -22,11 +22,13 @@ import { join } from "node:path";
|
|
|
22
22
|
|
|
23
23
|
/**
|
|
24
24
|
* Mirrors `DEFAULT_PORT` in ./daemon.ts. Duplicated rather than imported so
|
|
25
|
-
* this module stays free of the dispatcher's dependency tree
|
|
26
|
-
*
|
|
27
|
-
*
|
|
25
|
+
* this module stays free of the dispatcher's dependency tree; exported so the
|
|
26
|
+
* CLI can name the port a foreground daemon will serve on without adding a
|
|
27
|
+
* third literal.
|
|
28
|
+
* // ponytail: two constants that must agree. If a third *definition* ever
|
|
29
|
+
* // appears, move it into ./types.ts and import it everywhere.
|
|
28
30
|
*/
|
|
29
|
-
const DEFAULT_PORT = 8787;
|
|
31
|
+
export const DEFAULT_PORT = 8787;
|
|
30
32
|
|
|
31
33
|
/** How long `startDaemon` waits for the first successful `/healthz`. */
|
|
32
34
|
const READY_TIMEOUT_MS = 15_000;
|
package/src/orchestrator-tick.ts
CHANGED
|
@@ -6,12 +6,9 @@
|
|
|
6
6
|
* loop, so this extension is the heartbeat: every `intervalSeconds` it injects
|
|
7
7
|
* one message that starts a turn.
|
|
8
8
|
*
|
|
9
|
-
*
|
|
9
|
+
* Three properties are worth protecting, and each one is a branch in
|
|
10
10
|
* `tickDecision()`:
|
|
11
11
|
*
|
|
12
|
-
* - **Pause is honoured.** `isPaused()` is imported from ./daemon.ts — the exact
|
|
13
|
-
* function `/conductor pause` writes for and the dispatch loop reads. A second
|
|
14
|
-
* spelling of "is it paused" here is how a paused fleet keeps working.
|
|
15
12
|
* - **A disarmed fleet is not woken.** The arm marker is a file the operator
|
|
16
13
|
* controls; missing means "not armed", and a tick then does nothing.
|
|
17
14
|
* - **The human channel must still be there.** Autonomous dispatch is only
|
|
@@ -22,21 +19,25 @@
|
|
|
22
19
|
* channel that makes running unattended safe.
|
|
23
20
|
* - **Ticks coalesce.** A tick that lands while an earlier one is still queued
|
|
24
21
|
* would stack prompts on a session that is already behind. `hasPendingMessages()`
|
|
25
|
-
* makes the tick idempotent under slow turns
|
|
22
|
+
* makes the tick idempotent under slow turns — and, because it is the only
|
|
23
|
+
* signal in the process that says whether the last prompt was ever consumed,
|
|
24
|
+
* it doubles as the wedge detector: see {@link STALL_MARKER_FILE}.
|
|
26
25
|
*
|
|
27
|
-
* Beyond those
|
|
26
|
+
* Beyond those three gates, every tick carries the project's `reporting.scope`
|
|
28
27
|
* as one explicit constraint line, re-read from the conductor config on each
|
|
29
28
|
* tick so a `/conductor setup` change binds the next heartbeat rather than
|
|
30
|
-
* waiting for a session restart
|
|
29
|
+
* waiting for a session restart — and one delivery rule
|
|
30
|
+
* ({@link TICK_DELIVERY_RULE}), because a tick is injected locally and a report
|
|
31
|
+
* written as end-of-turn text on such a turn reaches nobody. An operator's own
|
|
32
|
+
* `message` replaces both, and is re-read per tick for the same reason.
|
|
31
33
|
*
|
|
32
34
|
* The extension is inert unless `<cwd>/.conductor-tick.json` exists, so shipping
|
|
33
35
|
* it inside `omp-conductor` costs an ordinary session nothing.
|
|
34
36
|
*/
|
|
35
37
|
|
|
36
|
-
import { existsSync, readFileSync } from "node:fs";
|
|
38
|
+
import { existsSync, readFileSync, rmSync, writeFileSync } from "node:fs";
|
|
37
39
|
import { isAbsolute, join, resolve } from "node:path";
|
|
38
40
|
import { findProject, loadConfig } from "./config.ts";
|
|
39
|
-
import { isPaused } from "./daemon.ts";
|
|
40
41
|
import { DEFAULT_REPORT_SCOPE, type ReportScope } from "./types.ts";
|
|
41
42
|
|
|
42
43
|
/** The activation file. Absent means "this is not an orchestrator session". */
|
|
@@ -52,6 +53,43 @@ export const TICK_CUSTOM_TYPE = "omp-conductor.tick";
|
|
|
52
53
|
*/
|
|
53
54
|
export const MIN_INTERVAL_SECONDS = 60;
|
|
54
55
|
|
|
56
|
+
/**
|
|
57
|
+
* The stall marker — written beside the activation file, in the session cwd —
|
|
58
|
+
* and the number of consecutive coalesced ticks that earn it.
|
|
59
|
+
*
|
|
60
|
+
* This detects a failure that happened. 2026-08-07: the dogfood fleet's
|
|
61
|
+
* orchestrator finished a turn, logged `ui.loop-blocked` immediately after an
|
|
62
|
+
* auto-compaction threshold decision, and never started another. The process
|
|
63
|
+
* stayed alive, so `herdr-conductor`'s recovery — agent listed AND a
|
|
64
|
+
* non-shell foreground process — read healthy, and the dispatch daemon is a
|
|
65
|
+
* different process entirely, so `/healthz` stayed green too. A tick injected
|
|
66
|
+
* two minutes later and an operator's Telegram message five minutes after that
|
|
67
|
+
* both sat unconsumed for 23 minutes, until a manual SIGTERM. This extension
|
|
68
|
+
* had the evidence and threw it away: coalescing logged `tick skipped: tick
|
|
69
|
+
* already pending`, which reads exactly like healthy backpressure.
|
|
70
|
+
*
|
|
71
|
+
* Two in a row is a full hour at the reference 1800s interval — generous by
|
|
72
|
+
* construction, because a turn that legitimately runs an hour belongs to a
|
|
73
|
+
* fleet with larger problems than one spurious marker. The escalation has to
|
|
74
|
+
* leave the session, since a wedged loop cannot report on itself: a file any
|
|
75
|
+
* out-of-process watchdog can stat, plus an error-level line a log scraper can
|
|
76
|
+
* match.
|
|
77
|
+
*/
|
|
78
|
+
export const STALL_MARKER_FILE = ".conductor-stalled";
|
|
79
|
+
export const STALL_TICKS = 2;
|
|
80
|
+
|
|
81
|
+
/**
|
|
82
|
+
* The marker's one line after its ISO timestamp, and the middle of the error
|
|
83
|
+
* log. Shared so the file and the log can never describe different failures.
|
|
84
|
+
*/
|
|
85
|
+
const STALL_DIAGNOSIS = `${STALL_TICKS} ticks queued unconsumed — the agent loop is not draining`;
|
|
86
|
+
|
|
87
|
+
/**
|
|
88
|
+
* The one skip reason the stall counter reacts to, shared with the decision so
|
|
89
|
+
* a reworded log line cannot silently disarm the detector.
|
|
90
|
+
*/
|
|
91
|
+
const PENDING_REASON = "tick already pending";
|
|
92
|
+
|
|
55
93
|
/**
|
|
56
94
|
* The slice of the omp extension API this entry touches, mirroring
|
|
57
95
|
* `ExtensionAPI` / `ExtensionContext` from `@oh-my-pi/pi-coding-agent`.
|
|
@@ -146,13 +184,20 @@ export type TickConfigResult =
|
|
|
146
184
|
* The prompt when the config names none. The timestamp is what makes two
|
|
147
185
|
* consecutive ticks distinguishable in the session log.
|
|
148
186
|
*
|
|
187
|
+
* "Re-read … from disk" is an order, not colour. A 24/7 session holds a copy of
|
|
188
|
+
* the brief from its own start (or its last compaction), and a tick that merely
|
|
189
|
+
* says "run your loop" was observed acting on that cached copy first — which
|
|
190
|
+
* means an operator's amendment, or a dated standing task added between ticks,
|
|
191
|
+
* may never bind. The whole point of a file the operator can edit is that the
|
|
192
|
+
* next tick obeys the file, not the memory of it.
|
|
193
|
+
*
|
|
149
194
|
* Deliberately silent about reporting volume: that clause is
|
|
150
195
|
* {@link TICK_SCOPE_CONSTRAINTS}, appended per tick from the configured scope.
|
|
151
196
|
* A second spelling of it here would contradict the first inside one prompt the
|
|
152
197
|
* moment a fleet chose `escalations`.
|
|
153
198
|
*/
|
|
154
|
-
export function defaultTickMessage(now: Date): string {
|
|
155
|
-
return `Tick ${now.toISOString()}: run your standing loop from
|
|
199
|
+
export function defaultTickMessage(now: Date, briefPath = "ORCHESTRATOR.md"): string {
|
|
200
|
+
return `Tick ${now.toISOString()}: re-read ${briefPath} from disk, then run your standing loop from it.`;
|
|
156
201
|
}
|
|
157
202
|
|
|
158
203
|
/**
|
|
@@ -174,21 +219,49 @@ export const TICK_SCOPE_CONSTRAINTS: { readonly [K in ReportScope]: string } = {
|
|
|
174
219
|
};
|
|
175
220
|
|
|
176
221
|
/**
|
|
177
|
-
* The
|
|
222
|
+
* The delivery clause, appended to every default tick prompt.
|
|
223
|
+
*
|
|
224
|
+
* End-of-turn text streams to the operator's Telegram only on a turn that
|
|
225
|
+
* *began* as an inbound Telegram message. A heartbeat tick is injected locally,
|
|
226
|
+
* so it is never such a turn, and a session that believes otherwise reports
|
|
227
|
+
* into a void: on 2026-08-06 the fleet this extension runs produced a release
|
|
228
|
+
* report and two tier-2 escalations as end-of-turn text, and not one of the
|
|
229
|
+
* three reached anybody. What makes a report real is a tool call the session
|
|
230
|
+
* watched succeed, so the prompt says so on every tick rather than trusting a
|
|
231
|
+
* brief that can drift, be edited, or be compacted away.
|
|
232
|
+
*/
|
|
233
|
+
export const TICK_DELIVERY_RULE =
|
|
234
|
+
"This tick was injected locally, not sent from Telegram, so your end-of-turn text does NOT reach your operator. Deliver anything reportable this turn by calling the telegram_send tool and confirming success; never claim a report was sent otherwise.";
|
|
235
|
+
|
|
236
|
+
/**
|
|
237
|
+
* The scope this tick carries, where the brief actually lives, and — when the
|
|
238
|
+
* config could not answer — why.
|
|
178
239
|
*
|
|
179
240
|
* Read on every tick rather than cached at session start, for the reason the
|
|
180
241
|
* channel gate is: the operator re-runs `/conductor setup` while this session
|
|
181
242
|
* lives, and a heartbeat holding a startup snapshot would keep injecting the
|
|
182
243
|
* old contract until somebody restarted it.
|
|
183
244
|
*
|
|
184
|
-
*
|
|
185
|
-
*
|
|
186
|
-
*
|
|
187
|
-
*
|
|
245
|
+
* `briefPath` exists because the prompt orders a re-read, and an order must
|
|
246
|
+
* name a file that is really there: the brief lives at
|
|
247
|
+
* `<workspaceRoot>/ORCHESTRATOR.md`, not in the session cwd — the cwd usually
|
|
248
|
+
* holds only an `AGENTS.md` symlink to it. The name is spelled here rather than
|
|
249
|
+
* imported from setup.ts, whose import graph drags the session SDK into an
|
|
250
|
+
* extension that must stay cheap to load.
|
|
251
|
+
*
|
|
252
|
+
* Every fault collapses to {@link DEFAULT_REPORT_SCOPE} and a pathless prompt:
|
|
253
|
+
* no config written yet, an unreadable or invalid one, or several projects with
|
|
254
|
+
* none named — the same ambiguity `findProject` refuses to guess through for
|
|
255
|
+
* `status`. Stopping the heartbeat over either preference would be the worse
|
|
256
|
+
* trade.
|
|
188
257
|
*/
|
|
189
|
-
export function resolveTickScope(): { scope: ReportScope; fallback?: string } {
|
|
258
|
+
export function resolveTickScope(): { scope: ReportScope; briefPath?: string; fallback?: string } {
|
|
190
259
|
try {
|
|
191
|
-
|
|
260
|
+
const project = findProject(loadConfig());
|
|
261
|
+
return {
|
|
262
|
+
scope: project.reporting?.scope ?? DEFAULT_REPORT_SCOPE,
|
|
263
|
+
briefPath: join(project.workspaceRoot, "ORCHESTRATOR.md"),
|
|
264
|
+
};
|
|
192
265
|
} catch (err) {
|
|
193
266
|
return { scope: DEFAULT_REPORT_SCOPE, fallback: err instanceof Error ? err.message : String(err) };
|
|
194
267
|
}
|
|
@@ -245,7 +318,7 @@ export function readTickConfig(cwd: string): TickConfigResult {
|
|
|
245
318
|
}
|
|
246
319
|
|
|
247
320
|
// Relative paths resolve against the session cwd, so the files can sit beside
|
|
248
|
-
// the config that names them (`state/armed`) without hard-coding
|
|
321
|
+
// the config that names them (`state/armed`) without hard-coding a deploy path.
|
|
249
322
|
const armedFile = optionalPath(raw["armedFile"], "armedFile", cwd, problems);
|
|
250
323
|
const accessFile = optionalPath(raw["accessFile"], "accessFile", cwd, problems);
|
|
251
324
|
|
|
@@ -276,8 +349,18 @@ export function readTickConfig(cwd: string): TickConfigResult {
|
|
|
276
349
|
/**
|
|
277
350
|
* Whether this tick sends, and why — the whole decision, with no clock, no
|
|
278
351
|
* filesystem and no session in it. The interesting part of a heartbeat is the
|
|
279
|
-
* precedence between "
|
|
280
|
-
*
|
|
352
|
+
* precedence between "not armed", "channel down" and "already pending", and
|
|
353
|
+
* that is worth being able to test without a session at all.
|
|
354
|
+
*
|
|
355
|
+
* The pause sentinel is deliberately NOT consulted. `pause` is the dispatch
|
|
356
|
+
* daemon's flag — "stop claiming; in-flight work finishes" — and this heartbeat
|
|
357
|
+
* drives a different brain: the supervising session whose duties (groom the
|
|
358
|
+
* queue, drain escalations, report) are exactly the ones that stay useful while
|
|
359
|
+
* dispatch is stopped. Honouring it here shipped once, when the tick-driven
|
|
360
|
+
* session WAS the dispatcher; the day dispatch moved into the daemon, one flag
|
|
361
|
+
* silencing both brains became a starvation bug: a paused fleet's orchestrator
|
|
362
|
+
* could neither groom nor even say it was paused. The operator's lever for this
|
|
363
|
+
* session is the arm marker — `disarm` stops ticks, and only the operator arms.
|
|
281
364
|
*
|
|
282
365
|
* `armed` and `channelOk` are the *satisfied* gates, not the files behind them:
|
|
283
366
|
* a config with no `armedFile` passes the first, and one with no `accessFile`
|
|
@@ -286,7 +369,6 @@ export function readTickConfig(cwd: string): TickConfigResult {
|
|
|
286
369
|
* channel gate means "this session is not the fleet", not "the check is off".
|
|
287
370
|
*/
|
|
288
371
|
export function tickDecision(input: {
|
|
289
|
-
paused: boolean;
|
|
290
372
|
armed: boolean;
|
|
291
373
|
channelOk: boolean;
|
|
292
374
|
hasPending: boolean;
|
|
@@ -294,10 +376,9 @@ export function tickDecision(input: {
|
|
|
294
376
|
send: boolean;
|
|
295
377
|
reason: string;
|
|
296
378
|
} {
|
|
297
|
-
if (input.paused) return { send: false, reason: "paused" };
|
|
298
379
|
if (!input.armed) return { send: false, reason: "not armed" };
|
|
299
380
|
if (!input.channelOk) return { send: false, reason: "escalation channel down" };
|
|
300
|
-
if (input.hasPending) return { send: false, reason:
|
|
381
|
+
if (input.hasPending) return { send: false, reason: PENDING_REASON };
|
|
301
382
|
return { send: true, reason: "armed, nothing pending" };
|
|
302
383
|
}
|
|
303
384
|
|
|
@@ -331,18 +412,81 @@ function channelIsUp(path: string): boolean {
|
|
|
331
412
|
}
|
|
332
413
|
|
|
333
414
|
/**
|
|
334
|
-
*
|
|
335
|
-
*
|
|
336
|
-
*
|
|
415
|
+
* The operator's own prompt for this tick, re-read from the session cwd rather
|
|
416
|
+
* than taken from the startup snapshot — for the reason {@link resolveTickScope}
|
|
417
|
+
* and the channel gate re-read: the operator of a 24/7 session reconfigures it
|
|
418
|
+
* out-of-band, and a heartbeat holding a startup copy would keep injecting last
|
|
419
|
+
* week's prompt until somebody restarted the session.
|
|
420
|
+
*
|
|
421
|
+
* A re-read that succeeds owns the answer, "no `message` key any more" included:
|
|
422
|
+
* deleting the override hands the prompt back to the shipped default. A re-read
|
|
423
|
+
* that fails keeps the startup value — a mid-edit truncation, a file moved away
|
|
424
|
+
* or a fault the validator collects must not stop the heartbeat, and must not
|
|
425
|
+
* silently swap the operator's prompt for ours over a transient bad read.
|
|
337
426
|
*
|
|
338
|
-
* `
|
|
339
|
-
*
|
|
340
|
-
|
|
341
|
-
|
|
427
|
+
* `intervalSeconds` is deliberately *not* re-read here: rescheduling a live
|
|
428
|
+
* managed timer is a different change, so a period edit still needs a restart.
|
|
429
|
+
*/
|
|
430
|
+
function currentMessage(cwd: string, startup: TickConfig): string | undefined {
|
|
431
|
+
const reread = readTickConfig(cwd);
|
|
432
|
+
return reread.kind === "ok" ? reread.config.message : startup.message;
|
|
433
|
+
}
|
|
434
|
+
|
|
435
|
+
/**
|
|
436
|
+
* Marker writes are best-effort by construction. This runs inside the loop
|
|
437
|
+
* whose whole job is to keep prompting a session; a read-only filesystem, a
|
|
438
|
+
* full disk, or a cwd deleted out from under a long-lived process is not a
|
|
439
|
+
* reason to stop doing that. Both directions log their own failure and carry
|
|
440
|
+
* on — the error line stands as the record either way, and the marker is only
|
|
441
|
+
* the copy an out-of-process watchdog can see.
|
|
342
442
|
*/
|
|
343
|
-
function
|
|
443
|
+
function writeStallMarker(pi: TickApi, cwd: string): void {
|
|
444
|
+
const path = join(cwd, STALL_MARKER_FILE);
|
|
445
|
+
try {
|
|
446
|
+
writeFileSync(path, `${new Date().toISOString()} ${STALL_DIAGNOSIS}\n`);
|
|
447
|
+
} catch (err) {
|
|
448
|
+
pi.logger.error(`[omp-conductor] could not write ${path}: ${err instanceof Error ? err.message : String(err)}`);
|
|
449
|
+
}
|
|
450
|
+
}
|
|
451
|
+
|
|
452
|
+
/**
|
|
453
|
+
* Cleared by the first consumed tick, whether or not this process wrote it.
|
|
454
|
+
* Recovery normally arrives as a *new* session — the wedged one was SIGTERM'd
|
|
455
|
+
* and its transcript resumed — which starts with a zero counter and its
|
|
456
|
+
* predecessor's marker on disk. Gating removal on this session's own count
|
|
457
|
+
* would strand that file, and a stall marker nobody clears is a stall marker
|
|
458
|
+
* nobody believes.
|
|
459
|
+
*/
|
|
460
|
+
function clearStallMarker(pi: TickApi, cwd: string): void {
|
|
461
|
+
const path = join(cwd, STALL_MARKER_FILE);
|
|
462
|
+
if (!existsSync(path)) return;
|
|
463
|
+
try {
|
|
464
|
+
rmSync(path, { force: true });
|
|
465
|
+
pi.logger.info("[omp-conductor] orchestrator stall cleared: a tick was consumed");
|
|
466
|
+
} catch (err) {
|
|
467
|
+
pi.logger.error(`[omp-conductor] could not remove ${path}: ${err instanceof Error ? err.message : String(err)}`);
|
|
468
|
+
}
|
|
469
|
+
}
|
|
470
|
+
|
|
471
|
+
/** Everything one tick remembers for the next. */
|
|
472
|
+
interface TickSession {
|
|
473
|
+
/**
|
|
474
|
+
* Whether the reporting-scope fallback has been logged. Without it, a host
|
|
475
|
+
* with no conductor config would repeat the same line about the same missing
|
|
476
|
+
* file every interval, for as long as the session lives.
|
|
477
|
+
*/
|
|
478
|
+
scopeFallbackLogged: boolean;
|
|
479
|
+
/** Consecutive {@link PENDING_REASON} skips — see {@link STALL_MARKER_FILE}. */
|
|
480
|
+
pendingSkips: number;
|
|
481
|
+
}
|
|
482
|
+
|
|
483
|
+
/**
|
|
484
|
+
* One tick: gather the three facts, ask `tickDecision`, log the reason either
|
|
485
|
+
* way. Skips are deliberately silent in the UI — a disarmed fleet would
|
|
486
|
+
* otherwise emit a notification every interval, forever.
|
|
487
|
+
*/
|
|
488
|
+
function tick(pi: TickApi, ctx: TickContext, config: TickConfig, session: TickSession): void {
|
|
344
489
|
const decision = tickDecision({
|
|
345
|
-
paused: isPaused(),
|
|
346
490
|
armed: config.armedFile === undefined || existsSync(config.armedFile),
|
|
347
491
|
channelOk: config.accessFile === undefined || channelIsUp(config.accessFile),
|
|
348
492
|
hasPending: ctx.hasPendingMessages(),
|
|
@@ -350,19 +494,35 @@ function tick(pi: TickApi, ctx: TickContext, config: TickConfig, session: { scop
|
|
|
350
494
|
|
|
351
495
|
if (!decision.send) {
|
|
352
496
|
pi.logger.info(`[omp-conductor] tick skipped: ${decision.reason}`, { reason: decision.reason });
|
|
497
|
+
// Only a coalesced skip counts. "Not armed" and "channel down" say nothing
|
|
498
|
+
// about the queue — they are gates the operator or the bridge closed, and
|
|
499
|
+
// the session behind them may be perfectly awake.
|
|
500
|
+
if (decision.reason !== PENDING_REASON) return;
|
|
501
|
+
session.pendingSkips += 1;
|
|
502
|
+
// Only the crossing writes. A later skip refreshing the timestamp would
|
|
503
|
+
// keep moving "stalled since" forward, and how long it has been wedged is
|
|
504
|
+
// the fact the file is read for.
|
|
505
|
+
if (session.pendingSkips === STALL_TICKS) {
|
|
506
|
+
pi.logger.error(`[omp-conductor] orchestrator stalled: ${STALL_DIAGNOSIS}; see ${STALL_MARKER_FILE}`, {
|
|
507
|
+
reason: decision.reason,
|
|
508
|
+
pendingSkips: session.pendingSkips,
|
|
509
|
+
});
|
|
510
|
+
writeStallMarker(pi, ctx.cwd);
|
|
511
|
+
}
|
|
353
512
|
return;
|
|
354
513
|
}
|
|
355
514
|
|
|
356
|
-
// A configured message owns the whole contract, reporting
|
|
357
|
-
// operator who wrote their own prompt did not ask for ours
|
|
358
|
-
|
|
515
|
+
// A configured message owns the whole contract, reporting and delivery clauses
|
|
516
|
+
// included: an operator who wrote their own prompt did not ask for ours
|
|
517
|
+
// appended to it.
|
|
518
|
+
let content = currentMessage(ctx.cwd, config);
|
|
359
519
|
if (content === undefined) {
|
|
360
520
|
const scope = resolveTickScope();
|
|
361
521
|
if (scope.fallback !== undefined && !session.scopeFallbackLogged) {
|
|
362
522
|
session.scopeFallbackLogged = true;
|
|
363
523
|
pi.logger.info(`[omp-conductor] tick reporting scope: using ${DEFAULT_REPORT_SCOPE} — ${scope.fallback}`);
|
|
364
524
|
}
|
|
365
|
-
content = `${defaultTickMessage(new Date())}\n${TICK_SCOPE_CONSTRAINTS[scope.scope]}`;
|
|
525
|
+
content = `${defaultTickMessage(new Date(), scope.briefPath)}\n${TICK_SCOPE_CONSTRAINTS[scope.scope]}\n${TICK_DELIVERY_RULE}`;
|
|
366
526
|
}
|
|
367
527
|
|
|
368
528
|
pi.sendMessage(
|
|
@@ -370,6 +530,10 @@ function tick(pi: TickApi, ctx: TickContext, config: TickConfig, session: { scop
|
|
|
370
530
|
{ triggerTurn: true, deliverAs: "followUp" },
|
|
371
531
|
);
|
|
372
532
|
pi.logger.info(`[omp-conductor] tick sent: ${decision.reason}`, { reason: decision.reason });
|
|
533
|
+
// An empty queue at send time is the proof the previous tick was consumed, so
|
|
534
|
+
// this is the only place either the counter or the marker is cleared.
|
|
535
|
+
session.pendingSkips = 0;
|
|
536
|
+
clearStallMarker(pi, ctx.cwd);
|
|
373
537
|
}
|
|
374
538
|
|
|
375
539
|
export default function orchestratorTickExtension(pi: TickApi): void {
|
|
@@ -377,9 +541,10 @@ export default function orchestratorTickExtension(pi: TickApi): void {
|
|
|
377
541
|
// `session_start` cannot install a second heartbeat on the same session.
|
|
378
542
|
let armed = false;
|
|
379
543
|
// Held per registration for the same reason: the "using the default reporting
|
|
380
|
-
// scope, because ..." line is logged once for this heartbeat, and
|
|
381
|
-
// session
|
|
382
|
-
|
|
544
|
+
// scope, because ..." line is logged once for this heartbeat, and the stall
|
|
545
|
+
// counter is about this session's own queue. A second session in the same
|
|
546
|
+
// process starts with both at zero.
|
|
547
|
+
const session: TickSession = { scopeFallbackLogged: false, pendingSkips: 0 };
|
|
383
548
|
|
|
384
549
|
pi.on("session_start", (_event, ctx) => {
|
|
385
550
|
if (armed) return;
|
|
@@ -387,11 +552,10 @@ export default function orchestratorTickExtension(pi: TickApi): void {
|
|
|
387
552
|
// A subagent inherits the orchestrator's cwd, so it finds the same
|
|
388
553
|
// activation file and would arm a heartbeat of its own — one extra tick
|
|
389
554
|
// per worker, each prompting a session whose whole contract is to finish
|
|
390
|
-
// and yield. The discriminator is omp-telegram
|
|
391
|
-
//
|
|
392
|
-
//
|
|
393
|
-
//
|
|
394
|
-
// alone — a headless root session (print/RPC mode) has no `yield`, and an
|
|
555
|
+
// and yield. The discriminator is the one omp-telegram has been running in
|
|
556
|
+
// production: task sessions are headless *and* always carry the `yield`
|
|
557
|
+
// tool. Neither half suffices alone — a headless root session (print/RPC
|
|
558
|
+
// mode) has no `yield`, and an
|
|
395
559
|
// interactive session may well have one. Checked before the config read
|
|
396
560
|
// so the overwhelmingly common case never touches the filesystem.
|
|
397
561
|
if (!ctx.hasUI && pi.getActiveTools().includes("yield")) {
|
package/src/plugin.ts
CHANGED
|
@@ -43,6 +43,7 @@ import {
|
|
|
43
43
|
DEFAULT_REPORT_SCOPE,
|
|
44
44
|
type Caps,
|
|
45
45
|
type ConductorConfig,
|
|
46
|
+
type OrchestratorMode,
|
|
46
47
|
type ProjectConfig,
|
|
47
48
|
type ReportScope,
|
|
48
49
|
} from "./types.ts";
|
|
@@ -239,6 +240,53 @@ async function askReportScope(ctx: CommandContext, current: ReportScope): Promis
|
|
|
239
240
|
return choice.scope;
|
|
240
241
|
}
|
|
241
242
|
|
|
243
|
+
/**
|
|
244
|
+
* Who merges and who releases. Two confirms rather than one four-way list:
|
|
245
|
+
* these are independent grants — delegating merges is routine, delegating
|
|
246
|
+
* releases is not — and a menu of four combinations frames them as equally
|
|
247
|
+
* ordinary choices, which is exactly the framing a release grant must not get.
|
|
248
|
+
*
|
|
249
|
+
* Neither confirm can start on "yes", so a re-run that Enters through the
|
|
250
|
+
* wizard revokes rather than renews. That is the safe direction, and the
|
|
251
|
+
* current grant is named in the question so the revoke is never a surprise.
|
|
252
|
+
*/
|
|
253
|
+
async function askAuthority(
|
|
254
|
+
ctx: CommandContext,
|
|
255
|
+
prior: ProjectConfig["authority"],
|
|
256
|
+
): Promise<ProjectConfig["authority"]> {
|
|
257
|
+
const merge = await ctx.ui.confirm(
|
|
258
|
+
"Merge authority",
|
|
259
|
+
"Delegate PR merging to the orchestrator session? It would land green PRs one at a time, each " +
|
|
260
|
+
"re-checked against the base branch first. Default: humans merge" +
|
|
261
|
+
`${prior.merge === "orchestrator" ? " — currently delegated, answer no to take it back" : ""}.`,
|
|
262
|
+
);
|
|
263
|
+
const release = await ctx.ui.confirm(
|
|
264
|
+
"Release authority",
|
|
265
|
+
"Delegate release cutting to the orchestrator session? It would tag, pin and publish by the " +
|
|
266
|
+
"procedure you write into its brief — and its brief forbids cutting one before you have. " +
|
|
267
|
+
"Default: humans release" +
|
|
268
|
+
`${prior.release === "orchestrator" ? " — currently delegated, answer no to take it back" : ""}.`,
|
|
269
|
+
);
|
|
270
|
+
return { merge: merge ? "orchestrator" : "human", release: release ? "orchestrator" : "human" };
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
/**
|
|
274
|
+
* Where the session that triages escalations lives. Phrased as a fact about the
|
|
275
|
+
* host rather than a preference, because that is what it is: answering yes when
|
|
276
|
+
* no such session exists leaves tier-1 escalations sitting in issue comments
|
|
277
|
+
* that nobody drains.
|
|
278
|
+
*/
|
|
279
|
+
async function askOrchestratorMode(ctx: CommandContext, prior: OrchestratorMode): Promise<OrchestratorMode> {
|
|
280
|
+
const external = await ctx.ui.confirm(
|
|
281
|
+
"Orchestrator session",
|
|
282
|
+
"Do you already run your own orchestrator session for this project — a visible TUI session, say? " +
|
|
283
|
+
"Then the daemon starts none of its own, and posts tier-1 escalations as issue comments for yours " +
|
|
284
|
+
"to drain. Default: no, the daemon runs one" +
|
|
285
|
+
`${prior === "external" ? " — currently external" : ""}.`,
|
|
286
|
+
);
|
|
287
|
+
return external ? "external" : "embedded";
|
|
288
|
+
}
|
|
289
|
+
|
|
242
290
|
/**
|
|
243
291
|
* Whether to render the operator's own brief, and — separately — whether an
|
|
244
292
|
* existing one may be replaced. Two questions on purpose: that file is where a
|
|
@@ -382,6 +430,11 @@ async function collectAnswers(
|
|
|
382
430
|
);
|
|
383
431
|
}
|
|
384
432
|
|
|
433
|
+
// Straight after the caps, and for the same reason they sit together: these
|
|
434
|
+
// are the two questions that decide what an unattended fleet may do without
|
|
435
|
+
// asking anybody.
|
|
436
|
+
const authority = await askAuthority(ctx, prior?.authority ?? SETUP_DEFAULTS.authority);
|
|
437
|
+
|
|
385
438
|
// Outside the caps block: a model is not a ceiling, and an operator who left
|
|
386
439
|
// the caps alone may still want workers on a cheaper model.
|
|
387
440
|
const answeredModel = await ask(
|
|
@@ -417,6 +470,11 @@ async function collectAnswers(
|
|
|
417
470
|
"Also comment on the issue when a run escalates? Recommended: a chat message you miss is a run nobody sees.",
|
|
418
471
|
);
|
|
419
472
|
|
|
473
|
+
const orchestratorMode = await askOrchestratorMode(
|
|
474
|
+
ctx,
|
|
475
|
+
prior?.escalation.orchestrator ?? SETUP_DEFAULTS.orchestratorMode,
|
|
476
|
+
);
|
|
477
|
+
|
|
420
478
|
const reportScope = await askReportScope(ctx, prior?.reporting?.scope ?? DEFAULT_REPORT_SCOPE);
|
|
421
479
|
|
|
422
480
|
const answers: SetupAnswers = {
|
|
@@ -428,6 +486,8 @@ async function collectAnswers(
|
|
|
428
486
|
targetRepos,
|
|
429
487
|
caps,
|
|
430
488
|
fallbackToIssueComment,
|
|
489
|
+
authority,
|
|
490
|
+
orchestratorMode,
|
|
431
491
|
reportScope,
|
|
432
492
|
// Asked last, and asked with the real path in the question — which needs the
|
|
433
493
|
// rest of the answers to derive, so the decision is folded in below.
|