omp-conductor 0.2.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/LICENSE +21 -0
- package/README.md +732 -0
- package/package.json +40 -0
- package/skills/conductor-onboarding/SKILL.md +626 -0
- package/src/briefs/orchestrator.md +213 -0
- package/src/briefs/worker.md +146 -0
- package/src/cli.ts +179 -0
- package/src/config.ts +446 -0
- package/src/daemon.ts +689 -0
- package/src/escalate.ts +265 -0
- package/src/lifecycle.ts +367 -0
- package/src/omp.ts +273 -0
- package/src/orchestrator-tick.ts +432 -0
- package/src/orchestrator.ts +267 -0
- package/src/plugin.ts +605 -0
- package/src/routing.ts +160 -0
- package/src/setup.ts +644 -0
- package/src/store.ts +263 -0
- package/src/tracker/github.ts +160 -0
- package/src/types.ts +250 -0
- package/src/worker.ts +292 -0
- package/src/worktree.ts +303 -0
|
@@ -0,0 +1,432 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Self-tick for the fleet orchestrator session.
|
|
3
|
+
*
|
|
4
|
+
* The orchestrator is a 24/7 omp session with a standing brief (ORCHESTRATOR.md)
|
|
5
|
+
* and no user typing into it. A session that is never prompted never runs its
|
|
6
|
+
* loop, so this extension is the heartbeat: every `intervalSeconds` it injects
|
|
7
|
+
* one message that starts a turn.
|
|
8
|
+
*
|
|
9
|
+
* Four properties are worth protecting, and each one is a branch in
|
|
10
|
+
* `tickDecision()`:
|
|
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
|
+
* - **A disarmed fleet is not woken.** The arm marker is a file the operator
|
|
16
|
+
* controls; missing means "not armed", and a tick then does nothing.
|
|
17
|
+
* - **The human channel must still be there.** Autonomous dispatch is only
|
|
18
|
+
* defensible while a tier-2 escalation can reach a person, so every tick
|
|
19
|
+
* re-reads the Telegram bridge's access file and requires it enabled with
|
|
20
|
+
* exactly one paired owner. This is fail-closed: an unreadable, unparseable or
|
|
21
|
+
* ambiguous access file stops ticking. A stale arm marker must not outlive the
|
|
22
|
+
* channel that makes running unattended safe.
|
|
23
|
+
* - **Ticks coalesce.** A tick that lands while an earlier one is still queued
|
|
24
|
+
* would stack prompts on a session that is already behind. `hasPendingMessages()`
|
|
25
|
+
* makes the tick idempotent under slow turns.
|
|
26
|
+
*
|
|
27
|
+
* Beyond those four gates, every tick carries the project's `reporting.scope`
|
|
28
|
+
* as one explicit constraint line, re-read from the conductor config on each
|
|
29
|
+
* tick so a `/conductor setup` change binds the next heartbeat rather than
|
|
30
|
+
* waiting for a session restart.
|
|
31
|
+
*
|
|
32
|
+
* The extension is inert unless `<cwd>/.conductor-tick.json` exists, so shipping
|
|
33
|
+
* it inside `omp-conductor` costs an ordinary session nothing.
|
|
34
|
+
*/
|
|
35
|
+
|
|
36
|
+
import { existsSync, readFileSync } from "node:fs";
|
|
37
|
+
import { isAbsolute, join, resolve } from "node:path";
|
|
38
|
+
import { findProject, loadConfig } from "./config.ts";
|
|
39
|
+
import { isPaused } from "./daemon.ts";
|
|
40
|
+
import { DEFAULT_REPORT_SCOPE, type ReportScope } from "./types.ts";
|
|
41
|
+
|
|
42
|
+
/** The activation file. Absent means "this is not an orchestrator session". */
|
|
43
|
+
export const TICK_CONFIG_FILE = ".conductor-tick.json";
|
|
44
|
+
|
|
45
|
+
/** Namespaced so a renderer or a session-log reader can pick ticks out. */
|
|
46
|
+
export const TICK_CUSTOM_TYPE = "omp-conductor.tick";
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* A tick costs a whole turn of a frontier model, and the orchestrator's loop is
|
|
50
|
+
* about minutes of latency, not seconds. Anything under a minute is a
|
|
51
|
+
* misconfiguration worth refusing rather than obeying.
|
|
52
|
+
*/
|
|
53
|
+
export const MIN_INTERVAL_SECONDS = 60;
|
|
54
|
+
|
|
55
|
+
/**
|
|
56
|
+
* The slice of the omp extension API this entry touches, mirroring
|
|
57
|
+
* `ExtensionAPI` / `ExtensionContext` from `@oh-my-pi/pi-coding-agent`.
|
|
58
|
+
*
|
|
59
|
+
* Declared here rather than imported for the reason ./plugin.ts declares its
|
|
60
|
+
* own: the harness is a peer dependency and the package has to type-check
|
|
61
|
+
* without it installed. Structural typing means the real objects satisfy these
|
|
62
|
+
* on the way in.
|
|
63
|
+
*/
|
|
64
|
+
interface TickLogger {
|
|
65
|
+
info(message: string, context?: Record<string, unknown>): void;
|
|
66
|
+
error(message: string, context?: Record<string, unknown>): void;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
interface TickContext {
|
|
70
|
+
/** Session cwd — where the activation file is looked for. */
|
|
71
|
+
cwd: string;
|
|
72
|
+
/**
|
|
73
|
+
* Whether a UI is attached — false in print/RPC mode, and false for every
|
|
74
|
+
* subagent. Typed as the SDK types it: `ExtensionContext.hasUI: boolean`,
|
|
75
|
+
* `@oh-my-pi/pi-coding-agent/src/extensibility/extensions/types.ts:424-425`.
|
|
76
|
+
*/
|
|
77
|
+
hasUI: boolean;
|
|
78
|
+
ui: {
|
|
79
|
+
notify(message: string, type?: "info" | "warning" | "error"): void;
|
|
80
|
+
};
|
|
81
|
+
/** True while steering, follow-up or next-turn messages are still queued. */
|
|
82
|
+
hasPendingMessages(): boolean;
|
|
83
|
+
/**
|
|
84
|
+
* Managed timer: throws inside `callback` are contained and surfaced on the
|
|
85
|
+
* extension error channel, the handle is `unref`'d, and it is cleared on
|
|
86
|
+
* `session_shutdown`. Raw `setInterval` has none of that and a throwing tick
|
|
87
|
+
* would take the session down, so it is never used here.
|
|
88
|
+
*/
|
|
89
|
+
setInterval(callback: () => void, ms?: number): unknown;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
interface TickApi {
|
|
93
|
+
logger: TickLogger;
|
|
94
|
+
/**
|
|
95
|
+
* The currently active tool names. Typed as the SDK types it:
|
|
96
|
+
* `ExtensionAPI.getActiveTools(): string[]`,
|
|
97
|
+
* `@oh-my-pi/pi-coding-agent/src/extensibility/extensions/types.ts:1267-1268`.
|
|
98
|
+
*/
|
|
99
|
+
getActiveTools(): string[];
|
|
100
|
+
on(event: "session_start", handler: (event: { type: "session_start" }, ctx: TickContext) => void): void;
|
|
101
|
+
/**
|
|
102
|
+
* `deliverAs: "followUp"` + `triggerTurn: true`, verified against
|
|
103
|
+
* `AgentSession.sendCustomMessage` rather than assumed:
|
|
104
|
+
*
|
|
105
|
+
* - idle: the `nextTurn` branch is skipped, `triggerTurn` prompts immediately,
|
|
106
|
+
* so the tick starts a turn now;
|
|
107
|
+
* - streaming: the message is queued as a follow-up and drained when the
|
|
108
|
+
* current turn ends, which is exactly the intended "tick after this
|
|
109
|
+
* finishes" and is also what `hasPendingMessages()` sees, so the next tick
|
|
110
|
+
* coalesces instead of stacking.
|
|
111
|
+
*
|
|
112
|
+
* `attribution: "user"` bills the turn as operator-initiated work, which is
|
|
113
|
+
* what a heartbeat prompt is.
|
|
114
|
+
*/
|
|
115
|
+
sendMessage(
|
|
116
|
+
message: {
|
|
117
|
+
customType: string;
|
|
118
|
+
content: string;
|
|
119
|
+
display: boolean;
|
|
120
|
+
attribution: "user" | "agent";
|
|
121
|
+
},
|
|
122
|
+
options: { triggerTurn?: boolean; deliverAs?: "steer" | "followUp" | "nextTurn" },
|
|
123
|
+
): void;
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
/** Validated activation config. `armedFile` and `accessFile` are absolute once
|
|
127
|
+
* they get here. */
|
|
128
|
+
export interface TickConfig {
|
|
129
|
+
intervalSeconds: number;
|
|
130
|
+
armedFile?: string;
|
|
131
|
+
accessFile?: string;
|
|
132
|
+
message?: string;
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
/**
|
|
136
|
+
* Three outcomes, none of them an exception: an absent file is the normal case
|
|
137
|
+
* for every other session, and a broken one must not stop the rest of the
|
|
138
|
+
* extension host from loading.
|
|
139
|
+
*/
|
|
140
|
+
export type TickConfigResult =
|
|
141
|
+
| { kind: "absent"; path: string }
|
|
142
|
+
| { kind: "invalid"; path: string; problem: string }
|
|
143
|
+
| { kind: "ok"; path: string; config: TickConfig };
|
|
144
|
+
|
|
145
|
+
/**
|
|
146
|
+
* The prompt when the config names none. The timestamp is what makes two
|
|
147
|
+
* consecutive ticks distinguishable in the session log.
|
|
148
|
+
*
|
|
149
|
+
* Deliberately silent about reporting volume: that clause is
|
|
150
|
+
* {@link TICK_SCOPE_CONSTRAINTS}, appended per tick from the configured scope.
|
|
151
|
+
* A second spelling of it here would contradict the first inside one prompt the
|
|
152
|
+
* moment a fleet chose `escalations`.
|
|
153
|
+
*/
|
|
154
|
+
export function defaultTickMessage(now: Date): string {
|
|
155
|
+
return `Tick ${now.toISOString()}: run your standing loop from ORCHESTRATOR.md now.`;
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
/**
|
|
159
|
+
* The reporting contract, one line per scope, appended to the tick prompt.
|
|
160
|
+
*
|
|
161
|
+
* This is the whole of what `reporting.scope` does at tick time: it constrains
|
|
162
|
+
* what the turn is allowed to say, in the session that reads it. It is not an
|
|
163
|
+
* outbound filter — nothing downstream drops a report the orchestrator decides
|
|
164
|
+
* to send anyway.
|
|
165
|
+
*
|
|
166
|
+
* A mapped type rather than a plain object, so adding a member to
|
|
167
|
+
* `REPORT_SCOPES` fails to compile here instead of resolving to `undefined` at
|
|
168
|
+
* the point of use.
|
|
169
|
+
*/
|
|
170
|
+
export const TICK_SCOPE_CONSTRAINTS: { readonly [K in ReportScope]: string } = {
|
|
171
|
+
material: "Report material events per your brief.",
|
|
172
|
+
escalations:
|
|
173
|
+
"Report NOTHING this turn except a Tier 1 or Tier 2 escalation; everything else -- releases included -- waits for the daily digest.",
|
|
174
|
+
};
|
|
175
|
+
|
|
176
|
+
/**
|
|
177
|
+
* The scope this tick carries, and — when it had to fall back — why.
|
|
178
|
+
*
|
|
179
|
+
* Read on every tick rather than cached at session start, for the reason the
|
|
180
|
+
* channel gate is: the operator re-runs `/conductor setup` while this session
|
|
181
|
+
* lives, and a heartbeat holding a startup snapshot would keep injecting the
|
|
182
|
+
* old contract until somebody restarted it.
|
|
183
|
+
*
|
|
184
|
+
* Every fault collapses to {@link DEFAULT_REPORT_SCOPE}: no config written yet,
|
|
185
|
+
* an unreadable or invalid one, or several projects with none named — the same
|
|
186
|
+
* ambiguity `findProject` refuses to guess through for `status`. Stopping the
|
|
187
|
+
* heartbeat over a reporting preference would be the worse trade.
|
|
188
|
+
*/
|
|
189
|
+
export function resolveTickScope(): { scope: ReportScope; fallback?: string } {
|
|
190
|
+
try {
|
|
191
|
+
return { scope: findProject(loadConfig()).reporting?.scope ?? DEFAULT_REPORT_SCOPE };
|
|
192
|
+
} catch (err) {
|
|
193
|
+
return { scope: DEFAULT_REPORT_SCOPE, fallback: err instanceof Error ? err.message : String(err) };
|
|
194
|
+
}
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
/**
|
|
198
|
+
* An optional file-path field. Relative paths resolve against the session cwd
|
|
199
|
+
* so `state/armed` means what it looks like; a present-but-unusable value is a
|
|
200
|
+
* config error rather than something to ignore, because both paths this reads
|
|
201
|
+
* are safety gates and a silently dropped gate is an open one.
|
|
202
|
+
*/
|
|
203
|
+
function optionalPath(raw: unknown, key: string, cwd: string, problems: string[]): string | undefined {
|
|
204
|
+
if (raw === undefined) return undefined;
|
|
205
|
+
if (typeof raw !== "string" || raw.trim().length === 0) {
|
|
206
|
+
problems.push(`${key} must be a non-empty string when present`);
|
|
207
|
+
return undefined;
|
|
208
|
+
}
|
|
209
|
+
return isAbsolute(raw) ? raw : resolve(cwd, raw);
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
/**
|
|
213
|
+
* Reads and validates `<cwd>/.conductor-tick.json`.
|
|
214
|
+
*
|
|
215
|
+
* Every fault is collected and reported in one message, the way ./config.ts
|
|
216
|
+
* does it: an operator fixing a config wants the whole list, not the first
|
|
217
|
+
* complaint followed by another edit-and-retry cycle.
|
|
218
|
+
*/
|
|
219
|
+
export function readTickConfig(cwd: string): TickConfigResult {
|
|
220
|
+
const path = join(cwd, TICK_CONFIG_FILE);
|
|
221
|
+
if (!existsSync(path)) return { kind: "absent", path };
|
|
222
|
+
|
|
223
|
+
let parsed: unknown;
|
|
224
|
+
try {
|
|
225
|
+
parsed = JSON.parse(readFileSync(path, "utf8"));
|
|
226
|
+
} catch (err) {
|
|
227
|
+
return { kind: "invalid", path, problem: `not valid JSON (${err instanceof Error ? err.message : String(err)})` };
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
if (parsed === null || typeof parsed !== "object" || Array.isArray(parsed)) {
|
|
231
|
+
return { kind: "invalid", path, problem: "must be a JSON object" };
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
const raw = parsed as { readonly [key: string]: unknown };
|
|
235
|
+
const problems: string[] = [];
|
|
236
|
+
|
|
237
|
+
let intervalSeconds = 0;
|
|
238
|
+
const interval = raw["intervalSeconds"];
|
|
239
|
+
if (typeof interval !== "number" || !Number.isFinite(interval) || !Number.isInteger(interval)) {
|
|
240
|
+
problems.push("intervalSeconds must be a whole number of seconds");
|
|
241
|
+
} else if (interval < MIN_INTERVAL_SECONDS) {
|
|
242
|
+
problems.push(`intervalSeconds must be at least ${MIN_INTERVAL_SECONDS}`);
|
|
243
|
+
} else {
|
|
244
|
+
intervalSeconds = interval;
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
// Relative paths resolve against the session cwd, so the files can sit beside
|
|
248
|
+
// the config that names them (`state/armed`) without hard-coding /root/fleet.
|
|
249
|
+
const armedFile = optionalPath(raw["armedFile"], "armedFile", cwd, problems);
|
|
250
|
+
const accessFile = optionalPath(raw["accessFile"], "accessFile", cwd, problems);
|
|
251
|
+
|
|
252
|
+
const messageRaw = raw["message"];
|
|
253
|
+
let message: string | undefined;
|
|
254
|
+
if (messageRaw !== undefined) {
|
|
255
|
+
if (typeof messageRaw !== "string" || messageRaw.trim().length === 0) {
|
|
256
|
+
problems.push("message must be a non-empty string when present");
|
|
257
|
+
} else {
|
|
258
|
+
message = messageRaw;
|
|
259
|
+
}
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
if (problems.length > 0) return { kind: "invalid", path, problem: problems.join("; ") };
|
|
263
|
+
|
|
264
|
+
return {
|
|
265
|
+
kind: "ok",
|
|
266
|
+
path,
|
|
267
|
+
config: {
|
|
268
|
+
intervalSeconds,
|
|
269
|
+
...(armedFile === undefined ? {} : { armedFile }),
|
|
270
|
+
...(accessFile === undefined ? {} : { accessFile }),
|
|
271
|
+
...(message === undefined ? {} : { message }),
|
|
272
|
+
},
|
|
273
|
+
};
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
/**
|
|
277
|
+
* Whether this tick sends, and why — the whole decision, with no clock, no
|
|
278
|
+
* filesystem and no session in it. The interesting part of a heartbeat is the
|
|
279
|
+
* precedence between "paused", "not armed", "channel down" and "already
|
|
280
|
+
* pending", and that is worth being able to test without a session at all.
|
|
281
|
+
*
|
|
282
|
+
* `armed` and `channelOk` are the *satisfied* gates, not the files behind them:
|
|
283
|
+
* a config with no `armedFile` passes the first, and one with no `accessFile`
|
|
284
|
+
* passes the second. The fleet deploy always configures `accessFile` — an
|
|
285
|
+
* orchestrator that can page nobody must not dispatch — so an unconfigured
|
|
286
|
+
* channel gate means "this session is not the fleet", not "the check is off".
|
|
287
|
+
*/
|
|
288
|
+
export function tickDecision(input: {
|
|
289
|
+
paused: boolean;
|
|
290
|
+
armed: boolean;
|
|
291
|
+
channelOk: boolean;
|
|
292
|
+
hasPending: boolean;
|
|
293
|
+
}): {
|
|
294
|
+
send: boolean;
|
|
295
|
+
reason: string;
|
|
296
|
+
} {
|
|
297
|
+
if (input.paused) return { send: false, reason: "paused" };
|
|
298
|
+
if (!input.armed) return { send: false, reason: "not armed" };
|
|
299
|
+
if (!input.channelOk) return { send: false, reason: "escalation channel down" };
|
|
300
|
+
if (input.hasPending) return { send: false, reason: "tick already pending" };
|
|
301
|
+
return { send: true, reason: "armed, nothing pending" };
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
/**
|
|
305
|
+
* Whether the Telegram bridge can still reach a person: enabled, with exactly
|
|
306
|
+
* one paired owner.
|
|
307
|
+
*
|
|
308
|
+
* Fail-closed, and every failure mode collapses to the same answer on purpose —
|
|
309
|
+
* missing file, truncated write, hand-edit that dropped `enabled`, a second
|
|
310
|
+
* chat id pasted in, or the pairing revoked. Distinguishing them would only
|
|
311
|
+
* tempt a future reader into treating one of them as benign, and none of them
|
|
312
|
+
* are: each one means a tier-2 escalation lands nowhere.
|
|
313
|
+
*
|
|
314
|
+
* Re-read on every tick rather than cached at session start, because the bridge
|
|
315
|
+
* is reconfigured by a long-lived operator out-of-band and a heartbeat that
|
|
316
|
+
* trusted a startup snapshot would keep dispatching for days after the channel
|
|
317
|
+
* went away.
|
|
318
|
+
*/
|
|
319
|
+
function channelIsUp(path: string): boolean {
|
|
320
|
+
let parsed: unknown;
|
|
321
|
+
try {
|
|
322
|
+
parsed = JSON.parse(readFileSync(path, "utf8"));
|
|
323
|
+
} catch {
|
|
324
|
+
return false;
|
|
325
|
+
}
|
|
326
|
+
if (parsed === null || typeof parsed !== "object" || Array.isArray(parsed)) return false;
|
|
327
|
+
const access = parsed as { readonly [key: string]: unknown };
|
|
328
|
+
if (access["enabled"] !== true) return false;
|
|
329
|
+
const allowFrom = access["allowFrom"];
|
|
330
|
+
return Array.isArray(allowFrom) && allowFrom.length === 1;
|
|
331
|
+
}
|
|
332
|
+
|
|
333
|
+
/**
|
|
334
|
+
* One tick: gather the four facts, ask `tickDecision`, log the reason either
|
|
335
|
+
* way. Skips are deliberately silent in the UI — a paused fleet would otherwise
|
|
336
|
+
* emit a notification every interval, forever.
|
|
337
|
+
*
|
|
338
|
+
* `session` holds the only thing one tick remembers for the next: whether the
|
|
339
|
+
* scope fallback has been logged. Without it, a host with no conductor config
|
|
340
|
+
* would repeat the same line about the same missing file every interval, for as
|
|
341
|
+
* long as the session lives.
|
|
342
|
+
*/
|
|
343
|
+
function tick(pi: TickApi, ctx: TickContext, config: TickConfig, session: { scopeFallbackLogged: boolean }): void {
|
|
344
|
+
const decision = tickDecision({
|
|
345
|
+
paused: isPaused(),
|
|
346
|
+
armed: config.armedFile === undefined || existsSync(config.armedFile),
|
|
347
|
+
channelOk: config.accessFile === undefined || channelIsUp(config.accessFile),
|
|
348
|
+
hasPending: ctx.hasPendingMessages(),
|
|
349
|
+
});
|
|
350
|
+
|
|
351
|
+
if (!decision.send) {
|
|
352
|
+
pi.logger.info(`[omp-conductor] tick skipped: ${decision.reason}`, { reason: decision.reason });
|
|
353
|
+
return;
|
|
354
|
+
}
|
|
355
|
+
|
|
356
|
+
// A configured message owns the whole contract, reporting clause included: an
|
|
357
|
+
// operator who wrote their own prompt did not ask for ours appended to it.
|
|
358
|
+
let content = config.message;
|
|
359
|
+
if (content === undefined) {
|
|
360
|
+
const scope = resolveTickScope();
|
|
361
|
+
if (scope.fallback !== undefined && !session.scopeFallbackLogged) {
|
|
362
|
+
session.scopeFallbackLogged = true;
|
|
363
|
+
pi.logger.info(`[omp-conductor] tick reporting scope: using ${DEFAULT_REPORT_SCOPE} — ${scope.fallback}`);
|
|
364
|
+
}
|
|
365
|
+
content = `${defaultTickMessage(new Date())}\n${TICK_SCOPE_CONSTRAINTS[scope.scope]}`;
|
|
366
|
+
}
|
|
367
|
+
|
|
368
|
+
pi.sendMessage(
|
|
369
|
+
{ customType: TICK_CUSTOM_TYPE, content, display: true, attribution: "user" },
|
|
370
|
+
{ triggerTurn: true, deliverAs: "followUp" },
|
|
371
|
+
);
|
|
372
|
+
pi.logger.info(`[omp-conductor] tick sent: ${decision.reason}`, { reason: decision.reason });
|
|
373
|
+
}
|
|
374
|
+
|
|
375
|
+
export default function orchestratorTickExtension(pi: TickApi): void {
|
|
376
|
+
// Scoped to this registration rather than the module, so a second
|
|
377
|
+
// `session_start` cannot install a second heartbeat on the same session.
|
|
378
|
+
let armed = false;
|
|
379
|
+
// Held per registration for the same reason: the "using the default reporting
|
|
380
|
+
// scope, because ..." line is logged once for this heartbeat, and a second
|
|
381
|
+
// session in the same process starts with its own count.
|
|
382
|
+
const session = { scopeFallbackLogged: false };
|
|
383
|
+
|
|
384
|
+
pi.on("session_start", (_event, ctx) => {
|
|
385
|
+
if (armed) return;
|
|
386
|
+
|
|
387
|
+
// A subagent inherits the orchestrator's cwd, so it finds the same
|
|
388
|
+
// activation file and would arm a heartbeat of its own — one extra tick
|
|
389
|
+
// per worker, each prompting a session whose whole contract is to finish
|
|
390
|
+
// and yield. The discriminator is omp-telegram's, which has been running
|
|
391
|
+
// it in production (`isTaskSubagent`, ~/VSCode/omp/plugins/telegram/src/
|
|
392
|
+
// index.ts:87-90, applied at index.ts:1951-1952): task sessions are
|
|
393
|
+
// headless *and* always carry the `yield` tool. Neither half suffices
|
|
394
|
+
// alone — a headless root session (print/RPC mode) has no `yield`, and an
|
|
395
|
+
// interactive session may well have one. Checked before the config read
|
|
396
|
+
// so the overwhelmingly common case never touches the filesystem.
|
|
397
|
+
if (!ctx.hasUI && pi.getActiveTools().includes("yield")) {
|
|
398
|
+
pi.logger.info("[omp-conductor] tick inert: subagent session");
|
|
399
|
+
return;
|
|
400
|
+
}
|
|
401
|
+
|
|
402
|
+
const result = readTickConfig(ctx.cwd);
|
|
403
|
+
|
|
404
|
+
if (result.kind === "absent") {
|
|
405
|
+
pi.logger.info(`[omp-conductor] orchestrator tick inactive: no ${TICK_CONFIG_FILE} in ${ctx.cwd}`);
|
|
406
|
+
return;
|
|
407
|
+
}
|
|
408
|
+
|
|
409
|
+
if (result.kind === "invalid") {
|
|
410
|
+
const detail = `${result.path}: ${result.problem}`;
|
|
411
|
+
pi.logger.error(`[omp-conductor] orchestrator tick disabled — ${detail}`);
|
|
412
|
+
// The one notification this extension ever raises: a broken heartbeat
|
|
413
|
+
// config is silent failure otherwise, and silence is what it is for.
|
|
414
|
+
ctx.ui.notify(`omp-conductor: orchestrator tick disabled — ${detail}`, "error");
|
|
415
|
+
return;
|
|
416
|
+
}
|
|
417
|
+
|
|
418
|
+
const config = result.config;
|
|
419
|
+
ctx.setInterval(() => tick(pi, ctx, config, session), config.intervalSeconds * 1000);
|
|
420
|
+
armed = true;
|
|
421
|
+
// Both gates are named at startup: "why is it not ticking?" is answered by
|
|
422
|
+
// looking at the files this line lists, and an unset channel gate on a fleet
|
|
423
|
+
// host is visible here rather than only in its absence.
|
|
424
|
+
const gates = [
|
|
425
|
+
config.armedFile === undefined ? undefined : `armed marker ${config.armedFile}`,
|
|
426
|
+
config.accessFile === undefined ? "no escalation-channel gate" : `escalation channel ${config.accessFile}`,
|
|
427
|
+
].filter((g) => g !== undefined);
|
|
428
|
+
pi.logger.info(
|
|
429
|
+
`[omp-conductor] orchestrator tick active: every ${config.intervalSeconds}s, gated on ${gates.join(" + ")}`,
|
|
430
|
+
);
|
|
431
|
+
});
|
|
432
|
+
}
|