omp-conductor 0.18.1 → 0.18.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/src/fleet.ts CHANGED
@@ -15,20 +15,16 @@
15
15
 
16
16
  import { spawnSync } from "node:child_process";
17
17
  import {
18
- createReadStream,
19
18
  existsSync,
20
19
  mkdirSync,
21
- readdirSync,
22
20
  readFileSync,
23
21
  rmSync,
24
- statSync,
25
22
  writeFileSync,
26
23
  } from "node:fs";
27
- import { createInterface } from "node:readline";
28
24
  import { homedir } from "node:os";
29
25
  import { dirname, join, sep } from "node:path";
30
26
  import { findProject, loadConfig, resolveArmProof, stateDir } from "./config.ts";
31
- import { clearArmChallenge, recordArmChallenge } from "./arm-challenge.ts";
27
+ import { clearArmTransaction, readArmAcknowledgement, recordArmChallenge } from "./arm-challenge.ts";
32
28
  import {
33
29
  claimedTelegramTopics,
34
30
  lockPidAlive,
@@ -81,6 +77,7 @@ import {
81
77
  import type { WorkerPausePhase } from "./worker.ts";
82
78
  import {
83
79
  formatFleetStatus,
80
+ formatGroomingStatus,
84
81
  type DaemonProjectHealth,
85
82
  type DispatchLayer,
86
83
  type FleetDaemonProbe,
@@ -328,19 +325,10 @@ export interface ArmDeps {
328
325
  * The orchestrator session file the live omp-telegram claim names for this
329
326
  * project, or undefined when there is no (readable) claim. The default
330
327
  * resolves the claim the same way the send does; tests inject a fixture.
331
- * Absent arm watches the cwd-derived session directory, the pre-claim
332
- * behaviour.
328
+ * Claim-only feeds it to the plumbing verdict's session-identity checks;
329
+ * the challenge proof never reads a transcript, so it ignores the claim.
333
330
  */
334
331
  claimedSessionFile?: () => string | undefined;
335
- /**
336
- * Waits for the challenge to appear as a user turn somewhere under the scan
337
- * directories. The waiter owns transcript discovery — not the caller —
338
- * because the reply may land in a session that starts *after* the send, so a
339
- * path resolved before the challenge went out can be the wrong file by the
340
- * time the operator answers (#142), and because the claimed session file can
341
- * sit in a different directory than the tick cwd implies (#600).
342
- */
343
- waitForUserTurn?: (dirs: readonly string[], code: string, sentAt: number, timeoutMs: number) => Promise<boolean>;
344
332
  now?: () => number;
345
333
  sleep?: (ms: number) => Promise<void>;
346
334
  timeoutMs?: number;
@@ -394,6 +382,13 @@ export async function armTicks(projectName?: string, deps: ArmDeps = {}): Promis
394
382
  // one pane that can ask. The challenge names which one, or the operator is
395
383
  // answering a question they cannot attribute.
396
384
  const named = tick.config.project ?? projectName;
385
+ // The handshake state key must be exactly what the orchestrator's inbound
386
+ // adapter computes: TickConfig.project, undefined for a legacy unstamped
387
+ // config. `named` may fall back to the CLI argument for the challenge text
388
+ // and config lookups; the state key must not — the adapter has no CLI
389
+ // argument to fall back to, and a mismatched key would make arming wait on
390
+ // an acknowledgement that can never be written.
391
+ const stateKey = tick.config.project;
397
392
 
398
393
  // The arming proof is a declared per-project policy (#613). A config that
399
394
  // cannot name the project fails safe to `challenge` — today's authenticated
@@ -406,15 +401,11 @@ export async function armTicks(projectName?: string, deps: ArmDeps = {}): Promis
406
401
  /* no project config — keep today's challenge behaviour */
407
402
  }
408
403
 
409
- // Resolve the orchestrator's live session file *before* the challenge goes
410
- // out the same claim the send follows (#600). A pane resumed from a
411
- // session created elsewhere (herdr pins it to the original transcript)
412
- // writes a session file outside the directory the tick cwd implies, and a
413
- // cwd-derived scan would poll the one place the reply is guaranteed not to
414
- // be. A claim outside the session tree arm scans can never be answered, so
415
- // that is a stop, not five minutes of polling. (Claim-only resolves the
416
- // claim too — the verdict checks the same session identity — but names the
417
- // refusal itself rather than throwing the transcript wording.)
404
+ // The orchestrator's live session file per omp-telegram's claim (#600) —
405
+ // input to the claim-only verdict's session-identity checks below. The
406
+ // challenge proof never reads it: its acknowledgement is conductor state,
407
+ // so where (or whether) a transcript lives is no longer part of arming
408
+ // (#614).
418
409
  const claimed = deps.claimedSessionFile !== undefined ? deps.claimedSessionFile() : claimedOrchestratorSessionFile(named);
419
410
 
420
411
  // Prefer the project's live forum topic so arm challenges land where
@@ -467,17 +458,6 @@ export async function armTicks(projectName?: string, deps: ArmDeps = {}): Promis
467
458
  return { path, alreadyArmed, owner: channel.owner, proof };
468
459
  }
469
460
 
470
- // The transcript proof needs a session tree to poll. The claim-only verdict
471
- // needs no such thing — it reads session identity from omp-telegram's own
472
- // state — so this stop stays on the challenge path only.
473
- const dirs = armSessionScanDirs(tick.cwd, claimed);
474
- if (dirs.every((d) => !existsSync(d))) {
475
- throw new Error(
476
- `no orchestrator session directory under ${dirs.join(" or ")} — ` +
477
- `the inbound proof is read from a user turn in a transcript there. Start the pane orchestrator, let it settle, then arm again`,
478
- );
479
- }
480
-
481
461
  const code = makeChallengeCode();
482
462
  const text =
483
463
  `Fleet arming check${named === undefined ? "" : ` — project ${named}`}. ` +
@@ -486,54 +466,38 @@ export async function armTicks(projectName?: string, deps: ArmDeps = {}): Promis
486
466
 
487
467
  const send = deps.sendChallenge ?? sendTelegramMessage;
488
468
  const timeoutMs = deps.timeoutMs ?? ARM_CHALLENGE_TIMEOUT_MS;
489
- // Read before the send, not after: a transcript untouched since this instant
490
- // cannot contain the reply, and that is what the waiter filters on.
491
469
  const sentAt = (deps.now ?? Date.now)();
492
- // The orchestrator must be able to classify the reply as an *active* arming
493
- // proof for as long as this handshake is live, so the authenticated record
494
- // (hash + expiry, never the code) is written before the challenge goes out
495
- // and cleared the moment this end settles (#415).
496
- recordArmChallenge(named, code, sentAt + timeoutMs);
470
+ // The orchestrator's inbound adapter can only acknowledge an *active*
471
+ // challenge, so the authenticated pending record (hash + expiry, never the
472
+ // code) is written before the challenge goes out and settled the moment
473
+ // this end finishes (#415). The returned id pins the wait below: an
474
+ // acknowledgement can only ever name the currently-pending id, so replacing
475
+ // a challenge makes every prior acknowledgement inert.
476
+ const challengeId = recordArmChallenge(stateKey, code, sentAt, sentAt + timeoutMs);
497
477
  try {
498
478
  await send(token, channel.owner, text, sendTopic);
499
479
  } catch (err) {
500
- // The challenge never went out, so it must not linger as a classifiable
501
- // proof either.
502
- clearArmChallenge(named);
480
+ // The challenge never went out, so its transaction must not linger as a
481
+ // classifiable proof either.
482
+ clearArmTransaction(stateKey, challengeId);
503
483
  throw new Error(
504
484
  `arm: outbound sendMessage failed — NOT armed: ${err instanceof Error ? err.message : String(err)}`,
505
485
  );
506
486
  }
507
487
 
508
- const injected = deps.waitForUserTurn;
509
- const scan: SessionScan = injected
510
- ? { seen: await injected(dirs, code, sentAt, timeoutMs), scanned: [], ignored: [] }
511
- : await waitForChallengeInSessions(dirs, code, sentAt, timeoutMs, deps);
512
- if (!scan.seen) {
513
- // The proof missed its window: a lookalike reply must not stay classifiable
514
- // after arming gave up, so the record is cleared before the failure lands.
515
- clearArmChallenge(named);
516
- // The claimed session file is named before the bridge/token/chat block: a
517
- // rotation under the window is visible from this error alone, and on the
518
- // host this guard exists for the claimed file is the one that held the
519
- // answer (#600).
520
- const listing = [
521
- ...(claimed === undefined
522
- ? []
523
- : [
524
- `claimed orchestrator session file: ${claimed}` +
525
- (dirs.includes(dirname(claimed))
526
- ? " (watched)"
527
- : ` (NOT under any watched dir — a reply there can never be seen)`),
528
- ]),
529
- `session dir: ${dirs.join(", ")}`,
530
- ...scan.scanned.map((f) => ` watched: ${f}`),
531
- ...scan.ignored.map((f) => ` ignored (stale, last written before the challenge): ${f}`),
532
- ].join("\n");
488
+ // Wait for the orchestrator's own acknowledgement — conductor state written
489
+ // by the inbound user-turn adapter when the real reply lands (#614). No
490
+ // transcript is read: the proof no longer depends on where (or whether) a
491
+ // session file lives, which is exactly the discovery that mis-fired on the
492
+ // host three times (#614). A wrong-project or lookalike reply writes no
493
+ // acknowledgement, so the window simply runs out fail-closed.
494
+ if (!(await waitForArmAcknowledgement(challengeId, timeoutMs, deps))) {
495
+ clearArmTransaction(stateKey, challengeId);
533
496
  throw new Error(
534
- `arm: the challenge never arrived as a user turn in time — NOT armed.\n` +
535
- listing +
536
- `\nInbound Telegram is not reaching the omp session. Check, in order:\n` +
497
+ `arm: the challenge was never acknowledged in time — NOT armed.\n` +
498
+ `The orchestrator's inbound adapter acknowledges the reply when it lands as a user turn; ` +
499
+ `no acknowledgement for challenge ${challengeId} arrived.\n` +
500
+ `Inbound Telegram is not reaching the omp session. Check, in order:\n` +
537
501
  ` * is the bridge polling? attach and run: /telegram status\n` +
538
502
  ` * is another process holding this bot token? Telegram allows exactly one\n` +
539
503
  ` getUpdates consumer and rejects the second with HTTP 409.\n` +
@@ -542,10 +506,11 @@ export async function armTicks(projectName?: string, deps: ArmDeps = {}): Promis
542
506
  }
543
507
 
544
508
  writeArmedMarker(path, channel.owner, arm);
545
- // The transcript proof landed and this project is armed: the record has done
546
- // its job. A stale record would also keep a later unsolicited lookalike alive
547
- // longer than the fresh challenge it was cut for.
548
- clearArmChallenge(named);
509
+ // The acknowledgement landed and this project is armed: settling clears this
510
+ // transaction's pending record and acknowledgement never a newer
511
+ // replacement's so a later unsolicited lookalike stays inert past this
512
+ // handshake.
513
+ clearArmTransaction(stateKey, challengeId);
549
514
  return { path, alreadyArmed, owner: channel.owner, challenge: code, proof };
550
515
  }
551
516
 
@@ -1528,6 +1493,9 @@ export type FleetStatusReport = StatusSnapshot & {
1528
1493
  failureClasses: string | undefined;
1529
1494
  workerPhases: { issue: number; phase: WorkerPausePhase }[];
1530
1495
  intake: string | undefined;
1496
+ /** The project-scoped durable to-spec grooming lifecycle as status lines
1497
+ * (#809), or nothing when there is nothing to report. */
1498
+ grooming: string | undefined;
1531
1499
  lastStop: DaemonStop | undefined;
1532
1500
  siblings: { project: string; live: number }[];
1533
1501
  };
@@ -1585,6 +1553,7 @@ export async function collectFleetStatus(projectName?: string): Promise<FleetSta
1585
1553
  let codeGraph: CodeGraphHealth;
1586
1554
  let lastStop: DaemonStop | undefined;
1587
1555
  let siblings: { project: string; live: number }[] = [];
1556
+ let grooming: string | undefined;
1588
1557
  try {
1589
1558
  codeGraph =
1590
1559
  cached ??
@@ -1598,6 +1567,15 @@ export async function collectFleetStatus(projectName?: string): Promise<FleetSta
1598
1567
  siblings = loadConfig()
1599
1568
  .projects.filter((p) => p.name !== project.name)
1600
1569
  .map((p) => ({ project: p.name, live: store.liveRuns(p.name).length }));
1570
+ // The durable to-spec grooming lifecycle (#809): project-scoped verdict
1571
+ // rows from the store, plus the dispatch snapshot's routed/parked counts —
1572
+ // no tracker read, no second cache, no evidence parsing. Rendered only
1573
+ // when it has something to say.
1574
+ grooming = formatGroomingStatus({
1575
+ records: store.groomingVerdicts(project.name),
1576
+ routed: snapshot.dispatch?.routed ?? 0,
1577
+ parked: snapshot.dispatch?.parked ?? 0,
1578
+ });
1601
1579
  } finally {
1602
1580
  store.close();
1603
1581
  }
@@ -1615,6 +1593,7 @@ export async function collectFleetStatus(projectName?: string): Promise<FleetSta
1615
1593
  failureClasses: failureClassBlock(project.name),
1616
1594
  workerPhases,
1617
1595
  intake: intakeStatusLine(project.name),
1596
+ grooming,
1618
1597
  lastStop,
1619
1598
  siblings,
1620
1599
  };
@@ -1635,6 +1614,7 @@ export function renderFleetStatusReport(report: FleetStatusReport): string {
1635
1614
  report.intake,
1636
1615
  report.lastStop,
1637
1616
  report.siblings,
1617
+ report.grooming,
1638
1618
  );
1639
1619
  }
1640
1620
 
@@ -1892,7 +1872,7 @@ export function sessionDirForCwd(cwd: string): string {
1892
1872
  return join(home, ".omp", "agent", "sessions", slug.replaceAll("/", "-"));
1893
1873
  }
1894
1874
 
1895
- /** The session tree arm scans: every transcript under it, whatever the cwd slug. */
1875
+ /** The omp session tree the claim-only verdict judges session identity against. */
1896
1876
  export function sessionsRoot(): string {
1897
1877
  return join(homedir(), ".omp", "agent", "sessions");
1898
1878
  }
@@ -1901,8 +1881,9 @@ export function sessionsRoot(): string {
1901
1881
  * The orchestrator session file omp-telegram's live claim names for this
1902
1882
  * project — the transcript a resumed/restored pane actually writes, which can
1903
1883
  * live in a different session directory than the tick cwd implies (#600).
1904
- * Never throws: no claim, or an unreadable config, means undefined and arm
1905
- * falls back to the cwd-derived directory.
1884
+ * Never throws: no claim, or an unreadable config, means undefined. Only the
1885
+ * claim-only verdict consumes this — the challenge proof reads conductor
1886
+ * state, not transcripts (#614).
1906
1887
  */
1907
1888
  function claimedOrchestratorSessionFile(named: string | undefined): string | undefined {
1908
1889
  if (named === undefined) return undefined;
@@ -1913,35 +1894,6 @@ function claimedOrchestratorSessionFile(named: string | undefined): string | und
1913
1894
  }
1914
1895
  }
1915
1896
 
1916
- /**
1917
- * The session directories `arm` watches for the reply — the cwd-derived one
1918
- * plus, when the live claim's file lives elsewhere, that file's directory.
1919
- *
1920
- * The cwd-derived directory is where a *fresh* session started from the tick
1921
- * cwd writes; the claimed file's directory is where the *restored* pane keeps
1922
- * writing (herdr pins it to the original transcript). Watching both covers a
1923
- * resumed session, a rotated one, and a session that starts after the send.
1924
- *
1925
- * A claim outside the session tree is a stop, not a slower failure: arm polls
1926
- * only transcripts under {@link sessionsRoot}, so a claimed file elsewhere can
1927
- * never satisfy the challenge, and five minutes of polling cannot discover a
1928
- * file that is not in the search set. The throw names both paths.
1929
- */
1930
- function armSessionScanDirs(cwd: string, claimed: string | undefined): string[] {
1931
- const cwdDir = sessionDirForCwd(cwd);
1932
- if (claimed === undefined) return [cwdDir];
1933
- const root = sessionsRoot();
1934
- const claimDir = dirname(claimed);
1935
- if (claimDir !== root && !claimDir.startsWith(join(root, sep))) {
1936
- throw new Error(
1937
- `arm: the orchestrator's claimed session file ${claimed} is outside the session tree arm scans (${root}) — ` +
1938
- `no transcript there can satisfy the challenge. Resume the pane under ${root}, or start it from ${cwd}; ` +
1939
- `arm would otherwise poll ${cwdDir} until the timer runs out`,
1940
- );
1941
- }
1942
- return claimDir === cwdDir ? [cwdDir] : [cwdDir, claimDir];
1943
- }
1944
-
1945
1897
  function makeChallengeCode(): string {
1946
1898
  const bytes = new Uint8Array(4);
1947
1899
  crypto.getRandomValues(bytes);
@@ -1949,6 +1901,31 @@ function makeChallengeCode(): string {
1949
1901
  return `FLEET-${hex}`;
1950
1902
  }
1951
1903
 
1904
+ /**
1905
+ * Polls the acknowledgement record for one exact challenge id until the
1906
+ * orchestrator's inbound adapter writes it or the deadline passes (#614).
1907
+ *
1908
+ * The state is a small JSON file re-read every pass, never snapshotted: the
1909
+ * acknowledgement may land at any point in the window, written by the live
1910
+ * orchestrator process. Only a record naming this exact id satisfies the
1911
+ * wait — an acknowledgement cut for a replaced challenge is inert here by
1912
+ * construction, and no transcript anywhere is opened.
1913
+ */
1914
+ async function waitForArmAcknowledgement(
1915
+ challengeId: string,
1916
+ timeoutMs: number,
1917
+ deps: ArmDeps,
1918
+ ): Promise<boolean> {
1919
+ const now = deps.now ?? Date.now;
1920
+ const sleep = deps.sleep ?? ((ms: number) => new Promise<void>((r) => setTimeout(r, ms)));
1921
+ const deadline = now() + timeoutMs;
1922
+ for (;;) {
1923
+ if (readArmAcknowledgement(challengeId) !== undefined) return true;
1924
+ if (now() >= deadline) return false;
1925
+ await sleep(5_000);
1926
+ }
1927
+ }
1928
+
1952
1929
  /**
1953
1930
  * The one armed-marker write both proofs share: same content, same mode, and
1954
1931
  * the same restamp of the pre-per-project shared marker the heartbeat still
@@ -1962,12 +1939,12 @@ function writeArmedMarker(path: string, owner: string, arm: ArmState): void {
1962
1939
  }
1963
1940
 
1964
1941
  /**
1965
- * The verdict's view of the session surface: the same dirs a challenge would
1966
- * watch, minus the transcript throw. An outside-tree claim is not a reason to
1967
- * stop here the claim-only verdict names that failure itself
1968
- * (`claim-session-outside` / `dm-owner-unrelated`) with the same session
1969
- * identity rules, so the refusal carries the fact, not an early transcript
1970
- * wording.
1942
+ * The session surface the claim-only verdict judges: the tick-cwd-derived
1943
+ * session directory plus, when the live claim's file lives elsewhere in the
1944
+ * tree, that file's directory (#600). An outside-tree claim is not a reason
1945
+ * to stop here the verdict names that failure itself
1946
+ * (`claim-session-outside` / `dm-owner-unrelated`), so the refusal carries
1947
+ * the fact.
1971
1948
  */
1972
1949
  function armVerdictScanDirs(cwd: string, claimed: string | undefined): string[] {
1973
1950
  const cwdDir = sessionDirForCwd(cwd);
@@ -2021,108 +1998,6 @@ async function sendTelegramMessage(
2021
1998
  await sendTelegram(token, owner, text, { topicId });
2022
1999
  }
2023
2000
 
2024
- interface SessionScan {
2025
- seen: boolean;
2026
- /** `name (mtime <iso>)` for every transcript the last pass actually parsed. */
2027
- scanned: string[];
2028
- /** Same shape, for the ones skipped as written before the challenge. */
2029
- ignored: string[];
2030
- }
2031
-
2032
- /**
2033
- * Polls the session directories — re-read on every pass, never snapshotted —
2034
- * until the challenge shows up as a user turn or the deadline passes.
2035
- *
2036
- * Discovery lives here because the reply can land in a transcript that does not
2037
- * exist yet when the challenge is sent: a rotated session, or the first one of
2038
- * a pane started right after arming (#142). And the scan set can be two
2039
- * directories when the live claim's session file lives outside the tick-cwd
2040
- * directory: the restored pane keeps writing the claimed file, so its
2041
- * directory is watched alongside the cwd-derived one (#600).
2042
- *
2043
- * Files untouched since just before the send are named, not parsed: an append
2044
- * bumps mtime, so a transcript older than the challenge cannot hold the reply,
2045
- * and skipping it keeps a large stale session out of every 5 s pass. A missing
2046
- * directory is not an error — it can vanish under a rotation and reappear.
2047
- */
2048
- async function waitForChallengeInSessions(
2049
- dirs: readonly string[],
2050
- code: string,
2051
- sentAt: number,
2052
- timeoutMs: number,
2053
- deps: ArmDeps,
2054
- ): Promise<SessionScan> {
2055
- const now = deps.now ?? Date.now;
2056
- const sleep = deps.sleep ?? ((ms: number) => new Promise<void>((r) => setTimeout(r, ms)));
2057
- const deadline = now() + timeoutMs;
2058
- for (;;) {
2059
- const scanned: string[] = [];
2060
- const ignored: string[] = [];
2061
- for (const dir of dirs) {
2062
- let names: string[] = [];
2063
- try {
2064
- names = readdirSync(dir);
2065
- } catch {
2066
- /* the directory can go away under a rotation; the next pass re-reads it */
2067
- }
2068
- for (const name of names.sort()) {
2069
- if (!name.endsWith(".jsonl")) continue;
2070
- const path = join(dir, name);
2071
- let mtimeMs: number;
2072
- try {
2073
- mtimeMs = statSync(path).mtimeMs;
2074
- } catch {
2075
- continue; /* race */
2076
- }
2077
- const label = `${name} (mtime ${new Date(mtimeMs).toISOString()})`;
2078
- if (mtimeMs < sentAt - 1_000) {
2079
- ignored.push(label);
2080
- continue;
2081
- }
2082
- scanned.push(label);
2083
- if (await transcriptHasUserCode(path, code)) return { seen: true, scanned, ignored };
2084
- }
2085
- }
2086
- if (now() >= deadline) return { seen: false, scanned, ignored };
2087
- await sleep(5_000);
2088
- }
2089
- }
2090
-
2091
- export async function transcriptHasUserCode(path: string, code: string): Promise<boolean> {
2092
- if (!existsSync(path)) return false;
2093
- const rl = createInterface({ input: createReadStream(path, { encoding: "utf8" }), crlfDelay: Infinity });
2094
- try {
2095
- for await (const line of rl) {
2096
- if (line.length === 0) continue;
2097
- let row: unknown;
2098
- try {
2099
- row = JSON.parse(line);
2100
- } catch {
2101
- continue;
2102
- }
2103
- if (row === null || typeof row !== "object") continue;
2104
- const rec = row as { readonly [key: string]: unknown };
2105
- if (rec["type"] !== "message") continue;
2106
- const message = rec["message"];
2107
- if (message === null || typeof message !== "object") continue;
2108
- const msg = message as { readonly [key: string]: unknown };
2109
- if (msg["role"] !== "user") continue;
2110
- const content = msg["content"];
2111
- if (!Array.isArray(content)) continue;
2112
- for (const part of content) {
2113
- if (part === null || typeof part !== "object") continue;
2114
- const p = part as { readonly [key: string]: unknown };
2115
- if (p["type"] === "text" && typeof p["text"] === "string" && p["text"].includes(code)) {
2116
- return true;
2117
- }
2118
- }
2119
- }
2120
- } finally {
2121
- rl.close();
2122
- }
2123
- return false;
2124
- }
2125
-
2126
2001
  // ---------------------------------------------------------------------------
2127
2002
  // probes
2128
2003
  // ---------------------------------------------------------------------------