omp-conductor 0.15.9 → 0.15.11
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +273 -2543
- package/REFERENCE.md +2638 -0
- package/package.json +3 -2
- package/schema/config.schema.json +8 -23
- package/src/arm-challenge.ts +112 -0
- package/src/ask.ts +434 -0
- package/src/board.ts +81 -15
- package/src/brief-upgrade.ts +114 -8
- package/src/briefs/orchestrator.md +55 -29
- package/src/briefs/policy.md +14 -5
- package/src/briefs/worker.md +7 -1
- package/src/chain-check.ts +1 -1
- package/src/check-trailing-newlines.ts +82 -0
- package/src/cli.ts +190 -1391
- package/src/commands/arm.ts +21 -0
- package/src/commands/board.ts +23 -0
- package/src/commands/brief-upgrade.ts +186 -0
- package/src/commands/context.ts +49 -0
- package/src/commands/daemon.ts +71 -0
- package/src/commands/dashboard.ts +74 -0
- package/src/commands/decision.ts +103 -0
- package/src/commands/disarm.ts +21 -0
- package/src/commands/doctor.ts +98 -0
- package/src/commands/event.ts +62 -0
- package/src/commands/extend.ts +64 -0
- package/src/commands/friction.ts +56 -0
- package/src/commands/help.ts +9 -0
- package/src/commands/hold.ts +26 -0
- package/src/commands/intake.ts +134 -0
- package/src/commands/ledger.ts +69 -0
- package/src/commands/message.ts +48 -0
- package/src/commands/report.ts +170 -0
- package/src/commands/restart.ts +76 -0
- package/src/commands/resume.ts +58 -0
- package/src/commands/setup.ts +93 -0
- package/src/commands/start.ts +23 -0
- package/src/commands/stats.ts +131 -0
- package/src/commands/status.ts +48 -0
- package/src/commands/stop.ts +51 -0
- package/src/commands/tail.ts +109 -0
- package/src/commands/unblock.ts +39 -0
- package/src/commands/upgrade-install.ts +31 -0
- package/src/commands/upgrade-rollback.ts +23 -0
- package/src/commands/upgrade.ts +25 -0
- package/src/commands/verb.ts +83 -0
- package/src/commands/version.ts +30 -0
- package/src/commands/worker.ts +100 -0
- package/src/config-schema.ts +38 -1
- package/src/config.ts +10 -3
- package/src/daemon.ts +613 -94
- package/src/dashboard/app.js +120 -0
- package/src/dashboard/index.html +34 -0
- package/src/dashboard/server.ts +267 -0
- package/src/dashboard/style.css +180 -0
- package/src/decisions.ts +39 -14
- package/src/diff-flags.ts +131 -241
- package/src/doctor.ts +795 -0
- package/src/escalate.ts +60 -19
- package/src/failure-class.ts +29 -3
- package/src/fleet.ts +58 -1
- package/src/graph-health.ts +1 -1
- package/src/label-projection.ts +1 -1
- package/src/lifecycle.ts +198 -2
- package/src/notices.ts +9 -0
- package/src/omp.ts +2 -0
- package/src/orchestrator-tick.ts +315 -17
- package/src/release-policy.ts +135 -23
- package/src/reports.ts +19 -5
- package/src/setup-host.ts +420 -8
- package/src/setup-install.ts +69 -14
- package/src/setup-wizard.ts +199 -61
- package/src/setup.ts +131 -35
- package/src/stats.ts +331 -0
- package/src/store.ts +206 -21
- package/src/tracker/github.ts +27 -4
- package/src/types.ts +144 -31
- package/src/unblock.ts +55 -11
- package/src/upgrade-journal.ts +220 -0
- package/src/upgrade-verify.ts +506 -0
- package/src/upgrade.ts +295 -26
- package/src/verbs/actions.ts +73 -1
- package/src/verbs/protocol.ts +29 -4
- package/src/verbs/server.ts +183 -20
- package/systemd/omp-conductor-recover.sh +433 -0
- package/systemd/omp-conductor.service.example +7 -0
- package/systemd/recover-unit-test.sh +428 -0
package/src/escalate.ts
CHANGED
|
@@ -20,12 +20,12 @@
|
|
|
20
20
|
* those strings end up in daemon logs and, on the fallback path, in a public
|
|
21
21
|
* issue comment.
|
|
22
22
|
*/
|
|
23
|
-
import { createHash } from "node:crypto";
|
|
24
23
|
import { readFileSync } from "node:fs";
|
|
25
24
|
import { homedir } from "node:os";
|
|
26
25
|
import { join } from "node:path";
|
|
27
26
|
|
|
28
27
|
import { availabilityOpen, interruptDisposition, type InterruptDisposition } from "./availability.ts";
|
|
28
|
+
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
|
|
|
@@ -240,7 +240,7 @@ export function createEscalator(
|
|
|
240
240
|
}
|
|
241
241
|
if (disposition !== "interrupt") {
|
|
242
242
|
store.addHeldNotice({
|
|
243
|
-
id:
|
|
243
|
+
id: heldNoticeId(key),
|
|
244
244
|
project: p.name,
|
|
245
245
|
category,
|
|
246
246
|
summary: e.summary,
|
|
@@ -350,6 +350,15 @@ export function readTelegramToken(): string | undefined {
|
|
|
350
350
|
return undefined;
|
|
351
351
|
}
|
|
352
352
|
|
|
353
|
+
/** One live omp-telegram topic claim, as much of it as conductor reads. */
|
|
354
|
+
export interface ClaimedTopic {
|
|
355
|
+
threadId: number;
|
|
356
|
+
/** The bridge's own title for the claim. */
|
|
357
|
+
name: string;
|
|
358
|
+
/** The herdr space the claiming pane sits in, when the bridge captured one. */
|
|
359
|
+
workspaceLabel?: string;
|
|
360
|
+
}
|
|
361
|
+
|
|
353
362
|
/**
|
|
354
363
|
* The forum topic a project's Telegram actually lives in *now*.
|
|
355
364
|
*
|
|
@@ -362,12 +371,19 @@ export function readTelegramToken(): string | undefined {
|
|
|
362
371
|
*
|
|
363
372
|
* The operator's pin still wins whenever it is live. Only a pin that is
|
|
364
373
|
* *provably* absent from the current claims is replaced, and only by the claim
|
|
365
|
-
*
|
|
366
|
-
* never hijacked by the pane's own thread. Unreadable bridge state changes
|
|
374
|
+
* this project can be identified with — so a deliberately separate alerts topic
|
|
375
|
+
* is never hijacked by the pane's own thread. Unreadable bridge state changes
|
|
367
376
|
* nothing, and #318's stale-topic retry remains the last line of defence.
|
|
368
377
|
*
|
|
369
|
-
*
|
|
370
|
-
*
|
|
378
|
+
* Identity is read from the herdr space first, and only then from the claim's
|
|
379
|
+
* title. The bridge titles a topic `ownAgentName ?? basename(cwd)`, and both
|
|
380
|
+
* halves of that fall down on a real multi-project host: the agent-name lookup
|
|
381
|
+
* is best-effort, and a fleet whose panes live under one state directory gives
|
|
382
|
+
* every pane the same basename. `workspaceLabel` is the herdr space, which is
|
|
383
|
+
* one-to-one with the project, so it is the identity that survives a re-claim.
|
|
384
|
+
*
|
|
385
|
+
* The substitution is logged, naming the project and which identity answered.
|
|
386
|
+
* Never an id: a log line is a place these leak from.
|
|
371
387
|
*/
|
|
372
388
|
export function resolveProjectTopicId(project: ProjectConfig): number | undefined {
|
|
373
389
|
const pinned = project.escalation.telegramTopicId;
|
|
@@ -375,24 +391,42 @@ export function resolveProjectTopicId(project: ProjectConfig): number | undefine
|
|
|
375
391
|
const claims = claimedTelegramTopics();
|
|
376
392
|
if (claims.length === 0) return pinned;
|
|
377
393
|
if (claims.some((claim) => claim.threadId === pinned)) return pinned;
|
|
378
|
-
const
|
|
379
|
-
|
|
394
|
+
const bySpace = soleClaim(claims, (claim) => claim.workspaceLabel === project.name);
|
|
395
|
+
const match = bySpace ?? soleClaim(claims, (claim) => claim.name === project.name);
|
|
396
|
+
if (match === undefined) return pinned;
|
|
380
397
|
warn(
|
|
381
398
|
`escalation.telegramTopicId for ${project.name} is no longer a claimed topic; ` +
|
|
382
|
-
`following omp-telegram's current "${
|
|
399
|
+
`following omp-telegram's current "${project.name}" ` +
|
|
400
|
+
`${bySpace === undefined ? "claim" : "herdr space claim"} instead`,
|
|
383
401
|
);
|
|
384
|
-
return
|
|
402
|
+
return match.threadId;
|
|
403
|
+
}
|
|
404
|
+
|
|
405
|
+
/**
|
|
406
|
+
* The one claim answering to `predicate`, or nothing when none or several do.
|
|
407
|
+
*
|
|
408
|
+
* Ambiguity is not a coin toss: two claims wearing the same identity means the
|
|
409
|
+
* bridge cannot say which pane belongs to this project, and paging the wrong
|
|
410
|
+
* project's topic is worse than the flat-chat degrade #318 already handles.
|
|
411
|
+
*/
|
|
412
|
+
function soleClaim(
|
|
413
|
+
claims: readonly ClaimedTopic[],
|
|
414
|
+
predicate: (claim: ClaimedTopic) => boolean,
|
|
415
|
+
): ClaimedTopic | undefined {
|
|
416
|
+
const hits = claims.filter(predicate);
|
|
417
|
+
return hits.length === 1 ? hits[0] : undefined;
|
|
385
418
|
}
|
|
386
419
|
|
|
387
420
|
/**
|
|
388
|
-
* omp-telegram's live topic claims
|
|
389
|
-
*
|
|
390
|
-
*
|
|
421
|
+
* omp-telegram's live topic claims, from
|
|
422
|
+
* `{ threads: { "<threadId>": { name, workspaceLabel } } }`. Borrowed exactly as
|
|
423
|
+
* the token is, and tolerant of every shape it might not be — a bridge that has
|
|
424
|
+
* never run a forum pane has no file at all.
|
|
391
425
|
*
|
|
392
426
|
* `stateDir` is passed by the setup wizard, which has already probed for the
|
|
393
427
|
* bridge; send-time callers let it resolve the same way the token does.
|
|
394
428
|
*/
|
|
395
|
-
export function claimedTelegramTopics(stateDir?: string):
|
|
429
|
+
export function claimedTelegramTopics(stateDir?: string): ClaimedTopic[] {
|
|
396
430
|
const override = stateDir?.trim() ?? process.env.OMP_TELEGRAM_STATE_DIR?.trim();
|
|
397
431
|
const dir = override ? override : join(homedir(), ".omp", "agent", "telegram");
|
|
398
432
|
let raw: unknown;
|
|
@@ -404,16 +438,23 @@ export function claimedTelegramTopics(stateDir?: string): Array<{ threadId: numb
|
|
|
404
438
|
if (typeof raw !== "object" || raw === null || Array.isArray(raw) || !("threads" in raw)) return [];
|
|
405
439
|
const threads = raw.threads;
|
|
406
440
|
if (typeof threads !== "object" || threads === null || Array.isArray(threads)) return [];
|
|
407
|
-
const out:
|
|
441
|
+
const out: ClaimedTopic[] = [];
|
|
408
442
|
for (const [id, entry] of Object.entries(threads)) {
|
|
409
443
|
const threadId = Number(id);
|
|
410
444
|
if (!Number.isFinite(threadId) || !Number.isSafeInteger(threadId)) continue;
|
|
411
445
|
let name = id;
|
|
412
|
-
|
|
413
|
-
|
|
414
|
-
if (
|
|
446
|
+
let workspaceLabel: string | undefined;
|
|
447
|
+
if (typeof entry === "object" && entry !== null) {
|
|
448
|
+
if ("name" in entry) {
|
|
449
|
+
const candidate = entry.name;
|
|
450
|
+
if (typeof candidate === "string" && candidate.trim() !== "") name = candidate.trim();
|
|
451
|
+
}
|
|
452
|
+
if ("workspaceLabel" in entry) {
|
|
453
|
+
const candidate = entry.workspaceLabel;
|
|
454
|
+
if (typeof candidate === "string" && candidate.trim() !== "") workspaceLabel = candidate.trim();
|
|
455
|
+
}
|
|
415
456
|
}
|
|
416
|
-
out.push({ threadId, name });
|
|
457
|
+
out.push(workspaceLabel === undefined ? { threadId, name } : { threadId, name, workspaceLabel });
|
|
417
458
|
}
|
|
418
459
|
return out;
|
|
419
460
|
}
|
package/src/failure-class.ts
CHANGED
|
@@ -15,7 +15,7 @@
|
|
|
15
15
|
* green test per class" possible without a daemon, a tracker or a network.
|
|
16
16
|
*/
|
|
17
17
|
|
|
18
|
-
import type { FailureClass, RecoveryAction, RunRecord } from "./types.ts";
|
|
18
|
+
import type { Caps, FailureClass, RecoveryAction, RunRecord } from "./types.ts";
|
|
19
19
|
|
|
20
20
|
/** Facts the caller fetched, each only for the rows that need it. */
|
|
21
21
|
export interface ClassifyFacts {
|
|
@@ -222,7 +222,11 @@ export function dispatchInfra(
|
|
|
222
222
|
// front of the thrown message, so a bare /^git / would never match a real row.
|
|
223
223
|
const DISPATCH_GIT_ERROR = /^(?:Error: )?git .+ exited \d+/s;
|
|
224
224
|
|
|
225
|
-
export function classifyRun(
|
|
225
|
+
export function classifyRun(
|
|
226
|
+
run: RunRecord,
|
|
227
|
+
facts: ClassifyFacts,
|
|
228
|
+
caps?: Pick<Caps, "workerWallClockMs">,
|
|
229
|
+
): Classification {
|
|
226
230
|
const hasArtifacts = run.prUrl !== undefined || run.headSha !== undefined || run.salvageSha !== undefined;
|
|
227
231
|
const providerError =
|
|
228
232
|
facts.sessionError ?? (run.lastError === undefined ? undefined : { message: run.lastError });
|
|
@@ -337,7 +341,29 @@ export function classifyRun(run: RunRecord, facts: ClassifyFacts): Classificatio
|
|
|
337
341
|
evidence: `turns ${run.turns}/${run.maxTurns}, no PR, no commits — $${run.spendUsd.toFixed(2)} spent`,
|
|
338
342
|
};
|
|
339
343
|
}
|
|
340
|
-
//
|
|
344
|
+
// A run killed at the wall-clock cap is a cap kill too, even though its
|
|
345
|
+
// turns ceiling never tripped — the harness would have kept burning a
|
|
346
|
+
// worker slot until the ticks ran out (#490). The same split as the turns
|
|
347
|
+
// path: artifacts mean the next attempt resumes from real work; nothing to
|
|
348
|
+
// show means a human re-scopes, naming the time and spend consumed.
|
|
349
|
+
const wallClockCap = caps?.workerWallClockMs;
|
|
350
|
+
const wallClockElapsed =
|
|
351
|
+
run.endedAt === undefined ? undefined : run.endedAt - run.startedAt;
|
|
352
|
+
if (wallClockCap !== undefined && wallClockElapsed !== undefined && wallClockElapsed >= wallClockCap) {
|
|
353
|
+
const clock = `${Math.round(wallClockElapsed / 60_000)}m of ${Math.round(wallClockCap / 60_000)}m`;
|
|
354
|
+
return hasArtifacts
|
|
355
|
+
? {
|
|
356
|
+
cls: "wall-clock-cap-progress",
|
|
357
|
+
recovery: "continue",
|
|
358
|
+
evidence: `wall clock ${clock} with work to continue from (${run.prUrl ?? run.salvageSha ?? run.headSha})`,
|
|
359
|
+
}
|
|
360
|
+
: {
|
|
361
|
+
cls: "wall-clock-cap-spinning",
|
|
362
|
+
recovery: "escalate",
|
|
363
|
+
evidence: `wall clock ${clock}, no PR, no commits — $${run.spendUsd.toFixed(2)} spent`,
|
|
364
|
+
};
|
|
365
|
+
}
|
|
366
|
+
// Below its own ceilings, so nothing this worker did ended it: a daemon
|
|
341
367
|
// restart, a drain, an operator. Costs no budget and needs no human.
|
|
342
368
|
return {
|
|
343
369
|
cls: "admin-kill",
|
package/src/fleet.ts
CHANGED
|
@@ -29,6 +29,7 @@ import { homedir } from "node:os";
|
|
|
29
29
|
import { dirname, join } from "node:path";
|
|
30
30
|
import { formatZonedMinute } from "./availability.ts";
|
|
31
31
|
import { findProject, loadConfig, stateDir } from "./config.ts";
|
|
32
|
+
import { clearArmChallenge, recordArmChallenge } from "./arm-challenge.ts";
|
|
32
33
|
import { resolveProjectTopicId, sendTelegram } from "./escalate.ts";
|
|
33
34
|
import { planUsageLine, readPlanUsage, sharedUsageSource } from "./usage.ts";
|
|
34
35
|
import { readApprovalSurface, readDaemonProfile } from "./approval-surface.ts";
|
|
@@ -250,6 +251,18 @@ function armGate(projectName?: string): { path: string; named?: string } {
|
|
|
250
251
|
return named === undefined ? { path } : { path, named };
|
|
251
252
|
}
|
|
252
253
|
|
|
254
|
+
/**
|
|
255
|
+
* This project's current tick-arm verdict — the marker the heartbeat reads,
|
|
256
|
+
* returned with the path that carries it so a verb can report what actually
|
|
257
|
+
* remains. Read-only: the same decision `disarm` takes, so control and status
|
|
258
|
+
* about one fleet cannot disagree (a verb telling the operator to re-arm a
|
|
259
|
+
* fleet that is already armed is the defect #375 removes).
|
|
260
|
+
*/
|
|
261
|
+
export function armState(projectName?: string): { path: string; armed: boolean } {
|
|
262
|
+
const { path, named } = armGate(projectName);
|
|
263
|
+
return { path, armed: resolveArmState(path, named).armed };
|
|
264
|
+
}
|
|
265
|
+
|
|
253
266
|
/**
|
|
254
267
|
* Clears the arm gate for this project — and `wasArmed` is the gate the
|
|
255
268
|
* heartbeat reads, not merely one file's presence.
|
|
@@ -362,23 +375,34 @@ export async function armTicks(projectName?: string, deps: ArmDeps = {}): Promis
|
|
|
362
375
|
}
|
|
363
376
|
|
|
364
377
|
const send = deps.sendChallenge ?? sendTelegramMessage;
|
|
378
|
+
const timeoutMs = deps.timeoutMs ?? ARM_CHALLENGE_TIMEOUT_MS;
|
|
365
379
|
// Read before the send, not after: a transcript untouched since this instant
|
|
366
380
|
// cannot contain the reply, and that is what the waiter filters on.
|
|
367
381
|
const sentAt = (deps.now ?? Date.now)();
|
|
382
|
+
// The orchestrator must be able to classify the reply as an *active* arming
|
|
383
|
+
// proof for as long as this handshake is live, so the authenticated record
|
|
384
|
+
// (hash + expiry, never the code) is written before the challenge goes out
|
|
385
|
+
// and cleared the moment this end settles (#415).
|
|
386
|
+
recordArmChallenge(named, code, sentAt + timeoutMs);
|
|
368
387
|
try {
|
|
369
388
|
await send(token, channel.owner, text, topicId);
|
|
370
389
|
} catch (err) {
|
|
390
|
+
// The challenge never went out, so it must not linger as a classifiable
|
|
391
|
+
// proof either.
|
|
392
|
+
clearArmChallenge(named);
|
|
371
393
|
throw new Error(
|
|
372
394
|
`arm: outbound sendMessage failed — NOT armed: ${err instanceof Error ? err.message : String(err)}`,
|
|
373
395
|
);
|
|
374
396
|
}
|
|
375
397
|
|
|
376
|
-
const timeoutMs = deps.timeoutMs ?? ARM_CHALLENGE_TIMEOUT_MS;
|
|
377
398
|
const injected = deps.waitForUserTurn;
|
|
378
399
|
const scan: SessionScan = injected
|
|
379
400
|
? { seen: await injected(dir, code, sentAt, timeoutMs), scanned: [], ignored: [] }
|
|
380
401
|
: await waitForChallengeInSessions(dir, code, sentAt, timeoutMs, deps);
|
|
381
402
|
if (!scan.seen) {
|
|
403
|
+
// The proof missed its window: a lookalike reply must not stay classifiable
|
|
404
|
+
// after arming gave up, so the record is cleared before the failure lands.
|
|
405
|
+
clearArmChallenge(named);
|
|
382
406
|
const listing = [
|
|
383
407
|
`session dir: ${dir}`,
|
|
384
408
|
...scan.scanned.map((f) => ` watched: ${f}`),
|
|
@@ -402,6 +426,10 @@ export async function armTicks(projectName?: string, deps: ArmDeps = {}): Promis
|
|
|
402
426
|
// marker that re-arms the fleet, and turn into a meaningless legacy warning
|
|
403
427
|
// the moment a second project is configured.
|
|
404
428
|
if (arm.legacy === "honoured") rmSync(legacyArmedMarkerPath(), { force: true });
|
|
429
|
+
// The transcript proof landed and this project is armed: the record has done
|
|
430
|
+
// its job. A stale record would also keep a later unsolicited lookalike alive
|
|
431
|
+
// longer than the fresh challenge it was cut for.
|
|
432
|
+
clearArmChallenge(named);
|
|
405
433
|
return { path, alreadyArmed, owner: channel.owner, challenge: code };
|
|
406
434
|
}
|
|
407
435
|
|
|
@@ -1393,6 +1421,7 @@ export function formatFleetStatus(
|
|
|
1393
1421
|
decisions: string | undefined = undefined,
|
|
1394
1422
|
failureClasses: string | undefined = undefined,
|
|
1395
1423
|
workerPhases: ReadonlyMap<number, WorkerPausePhase> = new Map(),
|
|
1424
|
+
intake: string | undefined = undefined,
|
|
1396
1425
|
): string {
|
|
1397
1426
|
const tickLine =
|
|
1398
1427
|
layers.ticksDetail === undefined
|
|
@@ -1473,6 +1502,7 @@ export function formatFleetStatus(
|
|
|
1473
1502
|
...(brief === undefined ? [] : [brief]),
|
|
1474
1503
|
...(decisions === undefined ? [] : [decisions]),
|
|
1475
1504
|
...(failureClasses === undefined ? [] : [failureClasses]),
|
|
1505
|
+
...(intake === undefined ? [] : [intake]),
|
|
1476
1506
|
...(graphBlock === undefined ? [] : [graphBlock]),
|
|
1477
1507
|
daemonBlock,
|
|
1478
1508
|
"",
|
|
@@ -1656,6 +1686,7 @@ export async function renderStatus(projectName?: string): Promise<string> {
|
|
|
1656
1686
|
decisionStatusLine(project.name),
|
|
1657
1687
|
failureClassBlock(project.name),
|
|
1658
1688
|
workerPhases,
|
|
1689
|
+
intakeStatusLine(project.name),
|
|
1659
1690
|
);
|
|
1660
1691
|
}
|
|
1661
1692
|
|
|
@@ -1734,6 +1765,32 @@ function decisionStatusLine(projectName: string): string | undefined {
|
|
|
1734
1765
|
}
|
|
1735
1766
|
}
|
|
1736
1767
|
|
|
1768
|
+
/**
|
|
1769
|
+
* One line naming how many raw ideas are still pending (#299), or nothing when
|
|
1770
|
+
* there are none.
|
|
1771
|
+
*
|
|
1772
|
+
* Deliberately absent at zero, unlike the decision line: an empty intake is the
|
|
1773
|
+
* common state of a healthy fleet, and a row that prints "0 pending" every
|
|
1774
|
+
* status would be permanent noise. Only a nonzero backlog is news, exactly like
|
|
1775
|
+
* {@link failureClassBlock}. Omitted on any throw, like the other optional rows.
|
|
1776
|
+
* Exported so the conditional is testable against a real store.
|
|
1777
|
+
*/
|
|
1778
|
+
export function intakeStatusLine(projectName: string): string | undefined {
|
|
1779
|
+
const path = dbPath();
|
|
1780
|
+
if (!existsSync(path)) return undefined;
|
|
1781
|
+
let store: Store | undefined;
|
|
1782
|
+
try {
|
|
1783
|
+
store = openStore(path);
|
|
1784
|
+
const pending = store.pendingIntake(projectName);
|
|
1785
|
+
if (pending.length === 0) return undefined;
|
|
1786
|
+
return `intake ${pending.length} pending idea(s) — omp-conductor intake list`;
|
|
1787
|
+
} catch {
|
|
1788
|
+
return undefined;
|
|
1789
|
+
} finally {
|
|
1790
|
+
store?.close();
|
|
1791
|
+
}
|
|
1792
|
+
}
|
|
1793
|
+
|
|
1737
1794
|
// ---------------------------------------------------------------------------
|
|
1738
1795
|
// arm proof helpers
|
|
1739
1796
|
// ---------------------------------------------------------------------------
|
package/src/graph-health.ts
CHANGED
|
@@ -264,7 +264,7 @@ export async function probeCodeGraph(
|
|
|
264
264
|
const repos: CodeGraphRepoHealth[] = configured.map((repo) => {
|
|
265
265
|
const clone: CheckState = deps.exists(repo.graphProject) ? "present" : "missing";
|
|
266
266
|
const index: CheckState = roots === undefined ? "unknown" : roots.has(repo.graphProject) ? "present" : "missing";
|
|
267
|
-
if (clone === "missing") reasons.push(`${repo.name}:
|
|
267
|
+
if (clone === "missing") reasons.push(`${repo.name}: no code-graph clone at ${repo.graphProject}; run \`omp-conductor setup graph\``);
|
|
268
268
|
if (index === "missing") reasons.push(`${repo.name}: no indexed project exactly matches ${repo.graphProject}`);
|
|
269
269
|
return { name: repo.name, path: repo.graphProject, clone, index };
|
|
270
270
|
});
|
package/src/label-projection.ts
CHANGED
package/src/lifecycle.ts
CHANGED
|
@@ -66,6 +66,18 @@ const LOG_TAIL_BYTES = 4_096;
|
|
|
66
66
|
export function runtimeDir(): string {
|
|
67
67
|
const override = process.env["OMP_CONDUCTOR_RUNTIME_DIR"];
|
|
68
68
|
if (override !== undefined && override.length > 0) return override;
|
|
69
|
+
if (process.env["NODE_ENV"] === "test") {
|
|
70
|
+
// A test process must never read the host-global runtime dir: doing so
|
|
71
|
+
// names the real installed daemon's pid, and a test `stop` that inherits
|
|
72
|
+
// that record would signal — and stop — production's own unit (#399).
|
|
73
|
+
// Fail closed: a lifecycle test that omitted OMP_CONDUCTOR_RUNTIME_DIR
|
|
74
|
+
// (the runtime half of the test lifecycle seam) gets a loud error rather
|
|
75
|
+
// than a silent read of the operator's pidfile.
|
|
76
|
+
throw new Error(
|
|
77
|
+
"OMP_CONDUCTOR_RUNTIME_DIR must be set under NODE_ENV=test — " +
|
|
78
|
+
"refusing to read the host runtime directory (~/.omp/run/daemons/omp-conductor) from a test process",
|
|
79
|
+
);
|
|
80
|
+
}
|
|
69
81
|
return join(homedir(), ".omp", "run", "daemons", "omp-conductor");
|
|
70
82
|
}
|
|
71
83
|
|
|
@@ -178,6 +190,152 @@ export function livingDaemon(): DaemonRecord | undefined {
|
|
|
178
190
|
return undefined;
|
|
179
191
|
}
|
|
180
192
|
|
|
193
|
+
/**
|
|
194
|
+
* The `daemon --once` lease file, one per process. Created `O_EXCL` so two
|
|
195
|
+
* simultaneous `--once` runs cannot both win ownership, and it names the
|
|
196
|
+
* holder's pid so a stale file (holder died) is removable rather than a
|
|
197
|
+
* permanent lock.
|
|
198
|
+
*
|
|
199
|
+
* The pidfile (`daemon.json`) is what makes this process *visible* as the owner
|
|
200
|
+
* for the duration of the tick (`status`, `worker stop`, a concurrent `daemon`
|
|
201
|
+
* start, orphan reconciliation all probe it); the lease file is what makes
|
|
202
|
+
* ownership *exclusive* — it is the thing that lets two simultaneous `--once`
|
|
203
|
+
* processes serialise, which the pidfile alone cannot (each would just
|
|
204
|
+
* overwrite it). They are deliberately two files: the lease is never the
|
|
205
|
+
* daemon record, so a short-lived drill can never impersonate or clear the
|
|
206
|
+
* managed daemon's record by virtue of holding a lock.
|
|
207
|
+
*/
|
|
208
|
+
function onceLeasePath(): string {
|
|
209
|
+
return join(runtimeDir(), "once.lock");
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
interface OnceLeaseHolder {
|
|
213
|
+
pid: number;
|
|
214
|
+
startedAt: number;
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
/** Parses the lease file; garbage is the same answer as absence — "not held". */
|
|
218
|
+
function readOnceLeaseHolder(): OnceLeaseHolder | undefined {
|
|
219
|
+
let parsed: unknown;
|
|
220
|
+
try {
|
|
221
|
+
parsed = JSON.parse(readFileSync(onceLeasePath(), "utf8"));
|
|
222
|
+
} catch {
|
|
223
|
+
return undefined;
|
|
224
|
+
}
|
|
225
|
+
if (parsed === null || typeof parsed !== "object") return undefined;
|
|
226
|
+
const pid = Reflect.get(parsed, "pid");
|
|
227
|
+
const startedAt = Reflect.get(parsed, "startedAt");
|
|
228
|
+
if (typeof pid !== "number" || !Number.isInteger(pid) || pid <= 1) return undefined;
|
|
229
|
+
if (typeof startedAt !== "number" || !Number.isFinite(startedAt)) return undefined;
|
|
230
|
+
return { pid, startedAt };
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
/** Bounded attempts to clear a stale lease and re-acquire it. */
|
|
234
|
+
const ONCE_LEASE_RETRIES = 3;
|
|
235
|
+
|
|
236
|
+
/** A once tick has no log file of its own; the record's field is informational. */
|
|
237
|
+
const ONCE_RECORD_LOG = "<once tick — no daemon log>";
|
|
238
|
+
|
|
239
|
+
export interface OnceLease {
|
|
240
|
+
/** The owned record that makes this process the visible daemon for the tick. */
|
|
241
|
+
record: DaemonRecord;
|
|
242
|
+
/**
|
|
243
|
+
* Drops exclusivity — but only while it is still ours. A manager that
|
|
244
|
+
* claimed the record mid-tick must be left alone: clearing it would clear
|
|
245
|
+
* somebody else's daemon.
|
|
246
|
+
*/
|
|
247
|
+
release(): void;
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
export type OnceLeaseAcquire = { ok: true; lease: OnceLease } | { ok: false; pid: number };
|
|
251
|
+
|
|
252
|
+
/**
|
|
253
|
+
* Acquires the exclusive daemon/state ownership a `--once` tick must hold for
|
|
254
|
+
* its whole lifecycle (#431). A drill is still a dispatcher — it settles rows,
|
|
255
|
+
* projects labels, admits and launches workers — so it takes the same
|
|
256
|
+
* ownership the long-running dispatcher holds via the pidfile, and refuses
|
|
257
|
+
* (having done nothing) while a live daemon owns the record or another live
|
|
258
|
+
* `--once` holds the lease.
|
|
259
|
+
*
|
|
260
|
+
* Returns `{ ok: false, pid }` — having performed no mutation — when another
|
|
261
|
+
* live process owns the project/state. A stale lease and a stale record whose
|
|
262
|
+
* holder died are cleared and retried. On success the process both holds the
|
|
263
|
+
* exclusive lease file and has published itself as the daemon owner, so every
|
|
264
|
+
* ownership probe (`livingDaemon`, a `daemon` start, a second `--once`) sees
|
|
265
|
+
* this process for the whole tick.
|
|
266
|
+
*/
|
|
267
|
+
export function acquireOnceLease(
|
|
268
|
+
o: { port?: number; project?: string; logFile?: string } = {},
|
|
269
|
+
): OnceLeaseAcquire {
|
|
270
|
+
// A live dispatcher already owns the shared record. Never run beside it.
|
|
271
|
+
const daemon = livingDaemon();
|
|
272
|
+
if (daemon !== undefined && daemon.pid !== process.pid) {
|
|
273
|
+
return { ok: false, pid: daemon.pid };
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
const dir = ensureRuntimeDir();
|
|
277
|
+
const path = onceLeasePath();
|
|
278
|
+
|
|
279
|
+
for (let attempt = 0; attempt < ONCE_LEASE_RETRIES; attempt++) {
|
|
280
|
+
let created = false;
|
|
281
|
+
try {
|
|
282
|
+
const fd = openSync(path, "wx", 0o600);
|
|
283
|
+
writeFileSync(fd, `${JSON.stringify({ pid: process.pid, startedAt: Date.now() })}\n`);
|
|
284
|
+
closeSync(fd);
|
|
285
|
+
created = true;
|
|
286
|
+
} catch (err) {
|
|
287
|
+
if ((err as NodeJS.ErrnoException).code !== "EEXIST") throw err;
|
|
288
|
+
const holder = readOnceLeaseHolder();
|
|
289
|
+
if (holder !== undefined && isAlive(holder.pid)) {
|
|
290
|
+
// A live holder — another `--once` (or a process still finishing one).
|
|
291
|
+
return { ok: false, pid: holder.pid };
|
|
292
|
+
}
|
|
293
|
+
// Stale or unreadable holder: clear it and try once more.
|
|
294
|
+
rmSync(path, { force: true });
|
|
295
|
+
continue;
|
|
296
|
+
}
|
|
297
|
+
if (!created) continue;
|
|
298
|
+
|
|
299
|
+
const record: DaemonRecord = {
|
|
300
|
+
pid: process.pid,
|
|
301
|
+
port: o.port ?? DEFAULT_PORT,
|
|
302
|
+
startedAt: Date.now(),
|
|
303
|
+
logFile: o.logFile ?? ONCE_RECORD_LOG,
|
|
304
|
+
...(o.project === undefined ? {} : { project: o.project }),
|
|
305
|
+
};
|
|
306
|
+
try {
|
|
307
|
+
writeRecord(record);
|
|
308
|
+
} catch (err) {
|
|
309
|
+
rmSync(path, { force: true });
|
|
310
|
+
throw err;
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
// Confirm we still own the shared record: a manager may have claimed it in
|
|
314
|
+
// the instant between our lease create and our record write.
|
|
315
|
+
const current = readRecord();
|
|
316
|
+
if (current !== undefined && current.pid === process.pid) {
|
|
317
|
+
return {
|
|
318
|
+
ok: true,
|
|
319
|
+
lease: {
|
|
320
|
+
record,
|
|
321
|
+
release: () => {
|
|
322
|
+
const owned = readRecord();
|
|
323
|
+
if (owned !== undefined && owned.pid === process.pid) clearRecord();
|
|
324
|
+
const holder = readOnceLeaseHolder();
|
|
325
|
+
if (holder !== undefined && holder.pid === process.pid) {
|
|
326
|
+
rmSync(path, { force: true });
|
|
327
|
+
}
|
|
328
|
+
},
|
|
329
|
+
},
|
|
330
|
+
};
|
|
331
|
+
}
|
|
332
|
+
rmSync(path, { force: true });
|
|
333
|
+
return { ok: false, pid: current?.pid ?? process.pid };
|
|
334
|
+
}
|
|
335
|
+
|
|
336
|
+
throw new Error("could not acquire the daemon lease — a stale holder kept reappearing");
|
|
337
|
+
}
|
|
338
|
+
|
|
181
339
|
/**
|
|
182
340
|
* Probes the daemon's own health endpoint. Never throws: a refused connection,
|
|
183
341
|
* a DNS-less host, a hung socket and a 500 are all just "not healthy", and the
|
|
@@ -892,8 +1050,17 @@ async function terminate(pid: number, timeoutMs: number): Promise<boolean> {
|
|
|
892
1050
|
return !isAlive(pid);
|
|
893
1051
|
}
|
|
894
1052
|
|
|
895
|
-
/**
|
|
896
|
-
|
|
1053
|
+
/** Whether a raw signal should be delivered to a pid. */
|
|
1054
|
+
export type SignalFn = (pid: number, sig: NodeJS.Signals) => boolean;
|
|
1055
|
+
|
|
1056
|
+
/**
|
|
1057
|
+
* The default signal sender. Production reaches the real `kill`. Under
|
|
1058
|
+
* NODE_ENV=test it dispatches to the fail-closed refusal instead: a test
|
|
1059
|
+
* process must never signal a pid it did not explicitly authorise, and the
|
|
1060
|
+
* default authorises nothing (#399).
|
|
1061
|
+
*/
|
|
1062
|
+
function defaultSignal(pid: number, sig: NodeJS.Signals): boolean {
|
|
1063
|
+
if (process.env["NODE_ENV"] === "test") return testSignal(pid, sig);
|
|
897
1064
|
try {
|
|
898
1065
|
process.kill(pid, sig);
|
|
899
1066
|
return true;
|
|
@@ -902,6 +1069,35 @@ function signal(pid: number, sig: NodeJS.Signals): boolean {
|
|
|
902
1069
|
}
|
|
903
1070
|
}
|
|
904
1071
|
|
|
1072
|
+
/**
|
|
1073
|
+
* Child-process CLI tests cannot inject {@link setSignalForTest}. Under
|
|
1074
|
+
* NODE_ENV=test they therefore get a refusal: a test process may only signal
|
|
1075
|
+
* a pid its own code explicitly authorised, and a child has authorised none.
|
|
1076
|
+
* Without this, a child `stop` that reads the operator's real daemon.json
|
|
1077
|
+
* (an *inherited* runtime record) would SIGTERM the installed daemon — the
|
|
1078
|
+
* #399 incident.
|
|
1079
|
+
*/
|
|
1080
|
+
function testSignal(_pid: number, _sig: NodeJS.Signals): boolean {
|
|
1081
|
+
return false;
|
|
1082
|
+
}
|
|
1083
|
+
|
|
1084
|
+
let signalFn: SignalFn = defaultSignal;
|
|
1085
|
+
|
|
1086
|
+
/**
|
|
1087
|
+
* Test-only: replace the raw signal sender. Pass `undefined` to restore.
|
|
1088
|
+
* A test that intentionally spawned a helper may authorise signals to exactly
|
|
1089
|
+
* that helper pid; every other pid is refused. Ownership is never inferred
|
|
1090
|
+
* from writing a record — a pidfile fixture authorises nothing (#399).
|
|
1091
|
+
*/
|
|
1092
|
+
export function setSignalForTest(fn: SignalFn | undefined): void {
|
|
1093
|
+
signalFn = fn ?? defaultSignal;
|
|
1094
|
+
}
|
|
1095
|
+
|
|
1096
|
+
/** Sends a signal, treating "already gone" as success. Returns false if the send failed. */
|
|
1097
|
+
function signal(pid: number, sig: NodeJS.Signals): boolean {
|
|
1098
|
+
return signalFn(pid, sig);
|
|
1099
|
+
}
|
|
1100
|
+
|
|
905
1101
|
/**
|
|
906
1102
|
* The tail of the daemon log, formatted for an error message. Reads the last
|
|
907
1103
|
* few KB by offset rather than the whole file, because the log is append-only
|
package/src/notices.ts
ADDED
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
import { createHash } from "node:crypto";
|
|
2
|
+
|
|
3
|
+
/** The deterministic primary id of a held notice derived from its escalation
|
|
4
|
+
* key. Sha256 of the key — 64 hex characters, a shape the digest handoff
|
|
5
|
+
* accepts verbatim — keeps the re-derived notice from a later poll idempotent
|
|
6
|
+
* against `ON CONFLICT(id) DO NOTHING` while remaining a real ledger row id. */
|
|
7
|
+
export function heldNoticeId(key: string): string {
|
|
8
|
+
return createHash("sha256").update(`held-notice\0${key}`).digest("hex");
|
|
9
|
+
}
|
package/src/omp.ts
CHANGED
|
@@ -739,6 +739,8 @@ export async function createSession(opts: CreateSessionOptions): Promise<AgentSe
|
|
|
739
739
|
tool: message.tool,
|
|
740
740
|
reason: message.reason,
|
|
741
741
|
args: message.args,
|
|
742
|
+
...(message.matched === undefined ? {} : { matched: message.matched }),
|
|
743
|
+
...(message.invocation === undefined ? {} : { invocation: message.invocation }),
|
|
742
744
|
});
|
|
743
745
|
break;
|
|
744
746
|
}
|