omp-conductor 0.16.2 → 0.17.1

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.
Files changed (52) hide show
  1. package/README.md +38 -4
  2. package/REFERENCE.md +18 -12
  3. package/package.json +2 -1
  4. package/schema/config.schema.json +16 -0
  5. package/src/admission.ts +159 -43
  6. package/src/availability.ts +27 -1
  7. package/src/briefs/worker.md +2 -0
  8. package/src/clack-ui.ts +83 -0
  9. package/src/command-manifest.ts +16 -7
  10. package/src/commands/arm.ts +11 -3
  11. package/src/commands/decision.ts +17 -7
  12. package/src/commands/doctor.ts +18 -1
  13. package/src/commands/hold.ts +9 -7
  14. package/src/commands/ledger.ts +25 -4
  15. package/src/commands/message.ts +32 -4
  16. package/src/commands/setup.ts +61 -10
  17. package/src/commands/stats.ts +9 -5
  18. package/src/commands/status.ts +32 -5
  19. package/src/commands/tail.ts +13 -1
  20. package/src/commands/watch.ts +16 -7
  21. package/src/config-schema.ts +20 -0
  22. package/src/config.ts +37 -0
  23. package/src/daemon.ts +1240 -18
  24. package/src/doctor.ts +310 -22
  25. package/src/escalate.ts +560 -57
  26. package/src/failure-class.ts +56 -13
  27. package/src/fleet.ts +224 -47
  28. package/src/gitops.ts +103 -24
  29. package/src/lifecycle.ts +7 -2
  30. package/src/orchestrator-tick.ts +372 -157
  31. package/src/privileged.ts +3 -0
  32. package/src/release-policy.ts +177 -5
  33. package/src/setup-answers.ts +135 -0
  34. package/src/setup-host.ts +193 -4
  35. package/src/setup-install.ts +2 -0
  36. package/src/setup-probe.ts +1 -0
  37. package/src/setup-wizard.ts +1296 -101
  38. package/src/setup.ts +60 -3
  39. package/src/status-render.ts +11 -1
  40. package/src/store.ts +333 -12
  41. package/src/tracker/github.ts +562 -13
  42. package/src/types.ts +204 -2
  43. package/src/ui/progress.ts +32 -0
  44. package/src/ui/style.ts +11 -0
  45. package/src/upgrade.ts +50 -19
  46. package/src/verbs/actions.ts +66 -18
  47. package/src/verbs/protocol.ts +45 -0
  48. package/src/verbs/server.ts +212 -11
  49. package/src/wizard-ui.ts +14 -5
  50. package/src/worker.ts +26 -0
  51. package/systemd/omp-conductor-recover.sh +73 -0
  52. package/systemd/recover-unit-test.sh +61 -0
@@ -61,8 +61,14 @@ const SUCCESS_CHECK_STATES: Record<string, true> = { success: true, neutral: tru
61
61
  * Substrings in a failed check's log that prove the failure was infrastructure,
62
62
  * not the diff (#177). Each is a registry/docker/runner fault a worker cannot
63
63
  * have introduced: a rate limit, an image-manifest resolution failure, a runner
64
- * being torn down under the job, or a DNS failure. Matched lowercased against
65
- * the log tail.
64
+ * a runner being torn down under the job, or a DNS failure. Matched lowercased against
65
+ * the log tail. GitHub writes the rate-limit sentence "Response status code does
66
+ * not indicate success: 429 (Too Many Requests)" where Docker writes "429 Too
67
+ * Many Requests"; the full sentence is its own exact entry rather than a blanket
68
+ * paren-strip of the log — stripping every parenthesis would let a deterministic
69
+ * log like "failed to resolve source (metadata for fixture)" slip past the
70
+ * `failed to resolve source metadata for` signature and waive an attempt it
71
+ * genuinely spent (#177).
66
72
  *
67
73
  * Deliberately *not* matching bare `failed to solve:` — a docker build failure
68
74
  * often prints it with a real resolution error, so the closing words carry the
@@ -70,12 +76,46 @@ const SUCCESS_CHECK_STATES: Record<string, true> = { success: true, neutral: tru
70
76
  */
71
77
  const INFRA_LOG_SIGNATURES = [
72
78
  "429 too many requests",
79
+ // GitHub's setup/action-download sentence for the same rate limit — the full
80
+ // lowercased phrase, never the bare parenthesized status: an application log
81
+ // like "expected 200, got 429 (Too Many Requests)" is a product verdict, not
82
+ // the runner's, and must not be waived (#637, #639).
83
+ "response status code does not indicate success: 429 (too many requests)",
73
84
  "failed to resolve source metadata for",
74
85
  "the runner has received a shutdown signal",
75
86
  "could not resolve host",
76
87
  ];
77
88
 
78
- function normalise(state: string): string {
89
+ /**
90
+ * The closed infra signature a failed check log must contain for the failure
91
+ * to be infrastructure rather than the diff, or `undefined` when the log
92
+ * carries none (#177). Exported so the historical reconciliation recognises
93
+ * exactly what the forward classifier does — one definition of "the log
94
+ * proves infra", or a repaired row and a fresh row would diverge (#638).
95
+ */
96
+ export function infraLogSignature(log: string): string | undefined {
97
+ const lower = log.toLowerCase();
98
+ return INFRA_LOG_SIGNATURES.find((signature) => lower.includes(signature));
99
+ }
100
+
101
+ /**
102
+ * A stable fingerprint of the infrastructure signature list (#638). The
103
+ * historical reconciliation persists a per-project review cursor stamped with
104
+ * this version so a bounded pass resumes where the last one stopped; when the
105
+ * classifier learns a new signature, the fingerprint changes, the stored
106
+ * cursor is stale, and the pass restarts from the newest row so the newly
107
+ * recognised evidence is never skipped past. Two values are equal exactly when
108
+ * the signature set is — a join cannot collide, because each entry is a
109
+ * distinct delimiter-free token sequence.
110
+ */
111
+ export function infraSignatureVersion(): string {
112
+ return INFRA_LOG_SIGNATURES.join("|");
113
+ }
114
+
115
+ /** Lowercased check state — `gh pr checks` has emitted both `failure` and
116
+ * `FAILURE` across versions, and the classifier's callers must agree on one
117
+ * spelling so log selection and classification see the same set of checks. */
118
+ export function normalise(state: string): string {
79
119
  return state.trim().toLowerCase();
80
120
  }
81
121
 
@@ -466,16 +506,19 @@ export function classifyRun(
466
506
  // beats ci-deterministic because charging an implementation attempt for a
467
507
  // rate limit is exactly the waste that class exists to prevent.
468
508
  if (facts.failingLog !== undefined) {
469
- const lower = facts.failingLog.toLowerCase();
470
- for (const signature of INFRA_LOG_SIGNATURES) {
471
- if (lower.includes(signature)) {
472
- const check = checks.find((c) => normalise(c.state) === "failure");
473
- return {
474
- cls: "ci-infra",
475
- recovery: "rerun-checks",
476
- evidence: `infrastructure signature in ${check?.name === undefined ? "failed check" : check.name} log: "${signature}"`,
477
- };
478
- }
509
+ // Matched exactly, without paren-stripping: GitHub's full setup/codeload
510
+ // sentence is its own closed entry above, and a generic paren-strip would
511
+ // broaden the other signatures into waiving real deterministic failures
512
+ // (#177). A bare "429" or "too many requests" (parenthesized or not)
513
+ // still never matches without that sentence.
514
+ const signature = infraLogSignature(facts.failingLog);
515
+ if (signature !== undefined) {
516
+ const check = checks.find((c) => normalise(c.state) === "failure");
517
+ return {
518
+ cls: "ci-infra",
519
+ recovery: "rerun-checks",
520
+ evidence: `infrastructure signature in ${check?.name === undefined ? "failed check" : check.name} log: "${signature}"`,
521
+ };
479
522
  }
480
523
  }
481
524
  const failing = unresolved.filter((c) => normalise(c.state) === "failure");
package/src/fleet.ts CHANGED
@@ -27,17 +27,31 @@ import {
27
27
  import { createInterface } from "node:readline";
28
28
  import { homedir } from "node:os";
29
29
  import { dirname, join, sep } from "node:path";
30
- import { findProject, loadConfig, stateDir } from "./config.ts";
30
+ import { findProject, loadConfig, resolveArmProof, stateDir } from "./config.ts";
31
31
  import { clearArmChallenge, recordArmChallenge } from "./arm-challenge.ts";
32
- import { resolveClaimedSessionFile, resolveProjectTopicId, sendTelegram } from "./escalate.ts";
32
+ import {
33
+ claimedTelegramTopics,
34
+ lockPidAlive,
35
+ pidAlive,
36
+ readTelegramDmOwner,
37
+ readTelegramPollState,
38
+ resolveClaimedSessionFile,
39
+ resolveProjectTopicId,
40
+ sendTelegram,
41
+ telegramPlumbingVerdict,
42
+ TELEGRAM_LOCK_FRESH_MS,
43
+ type TelegramPlumbingFailureReason,
44
+ type TelegramPlumbingProbe,
45
+ type TelegramPlumbingVerdict,
46
+ } from "./escalate.ts";
33
47
  import { readPlanUsage, sharedUsageSource } from "./usage.ts";
34
48
  import { readApprovalSurface, readDaemonProfile } from "./approval-surface.ts";
35
49
  import { inspectBriefLayout } from "./brief-upgrade.ts";
36
50
  import { dbPath, openStore } from "./store.ts";
37
51
  import { renderBriefForProject } from "./setup.ts";
38
- import type { DaemonStop, ProjectConfig, Store } from "./types.ts";
52
+ import { DEFAULT_ARM_PROOF, type ArmProof, type DaemonStop, type ProjectConfig, type Store } from "./types.ts";
39
53
  import { probeCodeGraph, type CodeGraphHealth } from "./graph-health.ts";
40
- import { isPaused, setPaused, statusSnapshot } from "./daemon.ts";
54
+ import { isPaused, setPaused, statusSnapshot, type StatusSnapshot } from "./daemon.ts";
41
55
  import {
42
56
  healthCheck,
43
57
  isAlive,
@@ -68,6 +82,7 @@ import {
68
82
  resolveArmState,
69
83
  TICK_CONFIG_FILE,
70
84
  tickConfigMatchesProject,
85
+ type ArmState,
71
86
  type TickConfig,
72
87
  type TickConfigResult,
73
88
  } from "./orchestrator-tick.ts";
@@ -281,7 +296,13 @@ export interface ArmResult {
281
296
  path: string;
282
297
  alreadyArmed: boolean;
283
298
  owner: string;
284
- challenge: string;
299
+ /** The challenge code that proved arming, present only for `challenge` proof. */
300
+ challenge?: string;
301
+ /**
302
+ * Which proof armed the fleet (#613): `challenge` for the authenticated
303
+ * round-trip, `claim-only` for the live-plumbing verdict with no send.
304
+ */
305
+ proof: ArmProof;
285
306
  }
286
307
 
287
308
  export interface ArmDeps {
@@ -306,6 +327,17 @@ export interface ArmDeps {
306
327
  now?: () => number;
307
328
  sleep?: (ms: number) => Promise<void>;
308
329
  timeoutMs?: number;
330
+ /**
331
+ * Liveness seams for the claim-only verdict (#613), with omp-telegram's own
332
+ * rules (#612): `pidAlive` judges a claim or dm-owner (EPERM is dead),
333
+ * `lockPidAlive` judges a bot.lock owner (EPERM is live), and `lockFresh`
334
+ * applies the heartbeat window. Tests inject deterministic answers without
335
+ * owning another uid's process; the verdict itself and every state file it
336
+ * reads run for real.
337
+ */
338
+ pidAlive?: (pid: number) => boolean;
339
+ lockPidAlive?: (pid: number) => boolean;
340
+ lockFresh?: (mtimeMs: number) => boolean;
309
341
  }
310
342
 
311
343
  export async function armTicks(projectName?: string, deps: ArmDeps = {}): Promise<ArmResult> {
@@ -346,14 +378,81 @@ export async function armTicks(projectName?: string, deps: ArmDeps = {}): Promis
346
378
  // answering a question they cannot attribute.
347
379
  const named = tick.config.project ?? projectName;
348
380
 
381
+ // The arming proof is a declared per-project policy (#613). A config that
382
+ // cannot name the project fails safe to `challenge` — today's authenticated
383
+ // round-trip — so a missing or unreadable config never silently weakens the
384
+ // gate.
385
+ let proof: ArmProof = DEFAULT_ARM_PROOF;
386
+ try {
387
+ proof = resolveArmProof(findProject(loadConfig(), named));
388
+ } catch {
389
+ /* no project config — keep today's challenge behaviour */
390
+ }
391
+
349
392
  // Resolve the orchestrator's live session file *before* the challenge goes
350
393
  // out — the same claim the send follows (#600). A pane resumed from a
351
394
  // session created elsewhere (herdr pins it to the original transcript)
352
395
  // writes a session file outside the directory the tick cwd implies, and a
353
396
  // cwd-derived scan would poll the one place the reply is guaranteed not to
354
397
  // be. A claim outside the session tree arm scans can never be answered, so
355
- // that is a stop, not five minutes of polling.
398
+ // that is a stop, not five minutes of polling. (Claim-only resolves the
399
+ // claim too — the verdict checks the same session identity — but names the
400
+ // refusal itself rather than throwing the transcript wording.)
356
401
  const claimed = deps.claimedSessionFile !== undefined ? deps.claimedSessionFile() : claimedOrchestratorSessionFile(named);
402
+
403
+ // Prefer the project's live forum topic so arm challenges land where
404
+ // escalations already do (#318), following the bridge's current claim when the
405
+ // pinned id has gone stale (#407). Missing project config keeps flat-chat 0.13.
406
+ let sendTopic: number | undefined;
407
+ if (named !== undefined) {
408
+ try {
409
+ sendTopic = resolveProjectTopicId(findProject(loadConfig(), named));
410
+ } catch {
411
+ /* no project config */
412
+ }
413
+ }
414
+
415
+ const path = tick.config.armedFile;
416
+ // The gate as the heartbeat reads it, so "replaced previous marker" is not a
417
+ // lie about a fleet the shared marker was arming, and so the write below knows
418
+ // whether it is superseding that marker.
419
+ const arm = resolveArmState(path, named);
420
+ const alreadyArmed = arm.armed;
421
+
422
+ if (proof === "claim-only") {
423
+ // The human-intent gate is declared satisfied by policy, so #612's shared
424
+ // verdict is the whole proof: the same state reads and the same liveness
425
+ // rules the doctor's "telegram-plumbing" finding applies, on the route a
426
+ // challenge would have ridden. No Telegram send, no transcript wait, no
427
+ // pending-challenge record. A failed fact refuses arming by name — never
428
+ // a silent pass from file existence, and never a marker.
429
+ const scan = armVerdictScanDirs(tick.cwd, claimed);
430
+ const probe: TelegramPlumbingProbe = {
431
+ // `channel` is up here — the paired-channel block above already threw on
432
+ // down — but the verdict re-reads it from the same state the challenge
433
+ // would send over, so the two proofs cannot disagree about the transport.
434
+ channel,
435
+ registry: claimedTelegramTopics(),
436
+ poll: readTelegramPollState(),
437
+ dmOwner: readTelegramDmOwner(),
438
+ alive: deps.pidAlive ?? pidAlive,
439
+ lockAlive: deps.lockPidAlive ?? lockPidAlive,
440
+ fresh: deps.lockFresh ?? ((mtimeMs) => (deps.now ?? Date.now)() - mtimeMs < TELEGRAM_LOCK_FRESH_MS),
441
+ };
442
+ const verdict = telegramPlumbingVerdict(sendTopic, { dirs: scan }, probe);
443
+ if (!verdict.ok) {
444
+ throw new Error(
445
+ `arm: claim-only proof refused — ${claimOnlyFailureText(verdict.reason)}. ` +
446
+ `NOT armed; no marker was written`,
447
+ );
448
+ }
449
+ writeArmedMarker(path, channel.owner, arm);
450
+ return { path, alreadyArmed, owner: channel.owner, proof };
451
+ }
452
+
453
+ // The transcript proof needs a session tree to poll. The claim-only verdict
454
+ // needs no such thing — it reads session identity from omp-telegram's own
455
+ // state — so this stop stays on the challenge path only.
357
456
  const dirs = armSessionScanDirs(tick.cwd, claimed);
358
457
  if (dirs.every((d) => !existsSync(d))) {
359
458
  throw new Error(
@@ -362,30 +461,12 @@ export async function armTicks(projectName?: string, deps: ArmDeps = {}): Promis
362
461
  );
363
462
  }
364
463
 
365
- const path = tick.config.armedFile;
366
- // The gate as the heartbeat reads it, so "replaced previous marker" is not a
367
- // lie about a fleet the shared marker was arming, and so the write below knows
368
- // whether it is superseding that marker.
369
- const arm = resolveArmState(path, tick.config.project ?? projectName);
370
- const alreadyArmed = arm.armed;
371
464
  const code = makeChallengeCode();
372
465
  const text =
373
466
  `Fleet arming check${named === undefined ? "" : ` — project ${named}`}. ` +
374
467
  `Reply to this chat with exactly:\n${code}\n` +
375
468
  `Nothing will be dispatched until that reply is seen in the orchestrator session.`;
376
469
 
377
- // Prefer the project's live forum topic so arm challenges land where
378
- // escalations already do (#318), following the bridge's current claim when the
379
- // pinned id has gone stale (#407). Missing project config keeps flat-chat 0.13.
380
- let topicId: number | undefined;
381
- if (named !== undefined) {
382
- try {
383
- topicId = resolveProjectTopicId(findProject(loadConfig(), named));
384
- } catch {
385
- /* no project config */
386
- }
387
- }
388
-
389
470
  const send = deps.sendChallenge ?? sendTelegramMessage;
390
471
  const timeoutMs = deps.timeoutMs ?? ARM_CHALLENGE_TIMEOUT_MS;
391
472
  // Read before the send, not after: a transcript untouched since this instant
@@ -397,7 +478,7 @@ export async function armTicks(projectName?: string, deps: ArmDeps = {}): Promis
397
478
  // and cleared the moment this end settles (#415).
398
479
  recordArmChallenge(named, code, sentAt + timeoutMs);
399
480
  try {
400
- await send(token, channel.owner, text, topicId);
481
+ await send(token, channel.owner, text, sendTopic);
401
482
  } catch (err) {
402
483
  // The challenge never went out, so it must not linger as a classifiable
403
484
  // proof either.
@@ -443,18 +524,12 @@ export async function armTicks(projectName?: string, deps: ArmDeps = {}): Promis
443
524
  );
444
525
  }
445
526
 
446
- mkdirSync(dirname(path), { recursive: true });
447
- writeFileSync(path, `armed ${new Date().toISOString()} owner=${channel.owner}\n`, { mode: 0o600 });
448
- // This project now has its own marker, so the shared one it was borrowing has
449
- // done its last job. Left in place it would survive the next `disarm` as a
450
- // marker that re-arms the fleet, and turn into a meaningless legacy warning
451
- // the moment a second project is configured.
452
- if (arm.legacy === "honoured") rmSync(legacyArmedMarkerPath(), { force: true });
527
+ writeArmedMarker(path, channel.owner, arm);
453
528
  // The transcript proof landed and this project is armed: the record has done
454
529
  // its job. A stale record would also keep a later unsolicited lookalike alive
455
530
  // longer than the fresh challenge it was cut for.
456
531
  clearArmChallenge(named);
457
- return { path, alreadyArmed, owner: channel.owner, challenge: code };
532
+ return { path, alreadyArmed, owner: channel.owner, challenge: code, proof };
458
533
  }
459
534
 
460
535
  export interface HoldResult {
@@ -1311,8 +1386,24 @@ function servedProjectName(payload: object): string | undefined {
1311
1386
  return undefined;
1312
1387
  }
1313
1388
 
1314
- export async function renderStatus(projectName?: string): Promise<string> {
1315
- const s = statusSnapshot(projectName);
1389
+ export type FleetStatusReport = StatusSnapshot & {
1390
+ observedAt: number;
1391
+ layers: FleetLayers;
1392
+ daemon: FleetDaemonProbe | undefined;
1393
+ telegram: TelegramHealth;
1394
+ codeGraph: CodeGraphHealth;
1395
+ brief: string | undefined;
1396
+ decisions: string | undefined;
1397
+ failureClasses: string | undefined;
1398
+ workerPhases: { issue: number; phase: WorkerPausePhase }[];
1399
+ intake: string | undefined;
1400
+ lastStop: DaemonStop | undefined;
1401
+ siblings: { project: string; live: number }[];
1402
+ };
1403
+
1404
+ /** Collects the complete status payload once for both text and JSON renderers. */
1405
+ export async function collectFleetStatus(projectName?: string): Promise<FleetStatusReport> {
1406
+ const snapshot = statusSnapshot(projectName);
1316
1407
  const layers = fleetLayers(projectName);
1317
1408
  const project = findProject(loadConfig(), projectName);
1318
1409
  const rec = livingDaemon();
@@ -1325,7 +1416,7 @@ export async function renderStatus(projectName?: string): Promise<string> {
1325
1416
  // Read here rather than in `statusSnapshot`, which is synchronous and used
1326
1417
  // by callers that must not shell out. An unmetered project never spawns
1327
1418
  // the provider at all.
1328
- readPlanUsage(s.caps.planUsage, sharedUsageSource()),
1419
+ readPlanUsage(snapshot.caps.planUsage, sharedUsageSource()),
1329
1420
  // Same reasoning as `planUsage`: the snapshot is synchronous, this read is
1330
1421
  // a shell-out, and undefined on any failure — one missing row, never a
1331
1422
  // broken report (#188).
@@ -1347,7 +1438,9 @@ export async function renderStatus(projectName?: string): Promise<string> {
1347
1438
  const healthBody = projectHealth.kind === "ok" ? rawHealth?.body : undefined;
1348
1439
  const cached = codeGraphFromHealthz(healthBody, project.name);
1349
1440
  const codeGraph = cached ?? (await probeCodeGraph(project));
1350
- const workerPhases = workerPhasesFromHealthz(healthBody, project.name);
1441
+ const workerPhases = [...workerPhasesFromHealthz(healthBody, project.name)].map(
1442
+ ([issue, phase]) => ({ issue, phase }),
1443
+ );
1351
1444
  // The newest host-wide stop/restart provenance (#378). Read here — not in
1352
1445
  // `statusSnapshot`, which is synchronous and belongs to the daemon module —
1353
1446
  // and rendered identically from either project: the daemon_stops table is
@@ -1359,32 +1452,54 @@ export async function renderStatus(projectName?: string): Promise<string> {
1359
1452
  try {
1360
1453
  lastStop = store.latestDaemonStop();
1361
1454
  // Shared-daemon visibility (#545): every configured project other than the
1362
- // one being viewed, with its live-run count. Read from the same open store
1363
- // as the provenance above, so a status reader can tell "my fleet is idle"
1364
- // from "the process I am about to stop is busy".
1455
+ // one being viewed, with its live-run count.
1365
1456
  siblings = loadConfig()
1366
1457
  .projects.filter((p) => p.name !== project.name)
1367
1458
  .map((p) => ({ project: p.name, live: store.liveRuns(p.name).length }));
1368
1459
  } finally {
1369
1460
  store.close();
1370
1461
  }
1371
- return formatFleetStatus(
1372
- { ...s, planUsage, github },
1462
+ return {
1463
+ ...snapshot,
1464
+ planUsage,
1465
+ github,
1466
+ observedAt: Date.now(),
1373
1467
  layers,
1374
1468
  daemon,
1375
1469
  telegram,
1376
- Date.now(),
1377
1470
  codeGraph,
1378
- briefStatusLine(project),
1379
- decisionStatusLine(project.name),
1380
- failureClassBlock(project.name),
1471
+ brief: briefStatusLine(project),
1472
+ decisions: decisionStatusLine(project.name),
1473
+ failureClasses: failureClassBlock(project.name),
1381
1474
  workerPhases,
1382
- intakeStatusLine(project.name),
1475
+ intake: intakeStatusLine(project.name),
1383
1476
  lastStop,
1384
1477
  siblings,
1478
+ };
1479
+ }
1480
+
1481
+ export function renderFleetStatusReport(report: FleetStatusReport): string {
1482
+ return formatFleetStatus(
1483
+ report,
1484
+ report.layers,
1485
+ report.daemon,
1486
+ report.telegram,
1487
+ report.observedAt,
1488
+ report.codeGraph,
1489
+ report.brief,
1490
+ report.decisions,
1491
+ report.failureClasses,
1492
+ new Map(report.workerPhases.map(({ issue, phase }) => [issue, phase])),
1493
+ report.intake,
1494
+ report.lastStop,
1495
+ report.siblings,
1385
1496
  );
1386
1497
  }
1387
1498
 
1499
+ export async function renderStatus(projectName?: string): Promise<string> {
1500
+ return renderFleetStatusReport(await collectFleetStatus(projectName));
1501
+ }
1502
+
1388
1503
  /**
1389
1504
  * One line naming the brief layout, or nothing when it cannot be read.
1390
1505
  *
@@ -1692,6 +1807,68 @@ function makeChallengeCode(): string {
1692
1807
  return `FLEET-${hex}`;
1693
1808
  }
1694
1809
 
1810
+ /**
1811
+ * The one armed-marker write both proofs share: same content, same mode, and
1812
+ * the same restamp of the pre-per-project shared marker the heartbeat still
1813
+ * honours — a project that just armed must not leave the bare marker around to
1814
+ * re-arm future fleets through `disarm` (#316).
1815
+ */
1816
+ function writeArmedMarker(path: string, owner: string, arm: ArmState): void {
1817
+ mkdirSync(dirname(path), { recursive: true });
1818
+ writeFileSync(path, `armed ${new Date().toISOString()} owner=${owner}\n`, { mode: 0o600 });
1819
+ if (arm.legacy === "honoured") rmSync(legacyArmedMarkerPath(), { force: true });
1820
+ }
1821
+
1822
+ /**
1823
+ * The verdict's view of the session surface: the same dirs a challenge would
1824
+ * watch, minus the transcript throw. An outside-tree claim is not a reason to
1825
+ * stop here — the claim-only verdict names that failure itself
1826
+ * (`claim-session-outside` / `dm-owner-unrelated`) with the same session
1827
+ * identity rules, so the refusal carries the fact, not an early transcript
1828
+ * wording.
1829
+ */
1830
+ function armVerdictScanDirs(cwd: string, claimed: string | undefined): string[] {
1831
+ const cwdDir = sessionDirForCwd(cwd);
1832
+ if (claimed === undefined) return [cwdDir];
1833
+ const root = sessionsRoot();
1834
+ const claimDir = dirname(claimed);
1835
+ if (claimDir === cwdDir) return [cwdDir];
1836
+ const insideTree = claimDir === root || claimDir.startsWith(join(root, sep));
1837
+ return insideTree ? [cwdDir, claimDir] : [cwdDir];
1838
+ }
1839
+
1840
+ /**
1841
+ * What each claim-only refusal names, in the operator's words (#613). The
1842
+ * verdict's reason enum is the fact; this is the sentence that says it — the
1843
+ * same facts the doctor's `telegram-plumbing` finding spells out, without the
1844
+ * finding's remediation (the refusal is one line an unattended recovery can
1845
+ * escalate verbatim).
1846
+ */
1847
+ function claimOnlyFailureText(reason: TelegramPlumbingFailureReason): string {
1848
+ switch (reason) {
1849
+ case "channel-down":
1850
+ return "the paired inbound channel is not up";
1851
+ case "registry-unreadable":
1852
+ return "omp-telegram's threads.json is absent, unreadable or malformed — the topic's claim liveness cannot be verified";
1853
+ case "no-topic-claim":
1854
+ return "no live omp-telegram claim carries the topic arming would send into";
1855
+ case "claim-dead":
1856
+ return "the topic's omp-telegram claim records no live pid — the pane behind it is gone";
1857
+ case "claim-session-outside":
1858
+ return "the topic's claim names a session file outside the session directories arm scans — the reply could never be seen";
1859
+ case "no-dm-owner":
1860
+ return "no flat-chat reply recipient: no dm-owner.json, and bot.lock names no session inside the arm scan surface";
1861
+ case "dm-owner-dead":
1862
+ return "omp-telegram's dm-owner.json records no live pid — the flat-chat recipient is gone";
1863
+ case "dm-owner-unrelated":
1864
+ return "the dm-owner.json session sits outside the arm scan surface — a flat reply would land where no challenge can read it";
1865
+ case "no-poller-state":
1866
+ return "omp-telegram's bot.lock is absent, unreadable or malformed — nothing owns the poll, so nothing is receiving inbound";
1867
+ case "poller-dead":
1868
+ return "omp-telegram's bot.lock records no live owner and no fresh heartbeat — Telegram isn't being polled";
1869
+ }
1870
+ }
1871
+
1695
1872
  async function sendTelegramMessage(
1696
1873
  token: string,
1697
1874
  owner: string,
package/src/gitops.ts CHANGED
@@ -127,62 +127,141 @@ export interface RunLaneInput {
127
127
  }
128
128
 
129
129
  /**
130
- * The set of file paths one active run has touched relative to base, deduped
131
- * and sorted. Persistable but advisory: the admitting gate treats an unreadable
132
- * or absent lane as "no overlap" (fail open), so no probe bug can refuse a
133
- * well-formed issue.
130
+ * One file a run's lane covers, tagged with the probe read that produced it.
131
+ * The source is what lets a `file-lane` hold say *which* occupancy it is — the
132
+ * run's live worktree (`"worktree"`: uncommitted authored changes, from the
133
+ * porcelain read) or its committed work (`"branch"`: the base-relative diff,
134
+ * worktree or mirror) — so "held by run #N" can be told apart from the
135
+ * base-reconciliation noise #684 filters out entirely.
134
136
  */
135
- export type RunLaneProbe = (input: RunLaneInput) => Promise<string[]>;
137
+ export interface LaneFile {
138
+ file: string;
139
+ source: LaneSource;
140
+ }
141
+
142
+ /** Which of the probe's reads produced one occupied file. */
143
+ export type LaneSource = "worktree" | "branch";
144
+
145
+ /**
146
+ * The set of files one active run has touched relative to base, deduped and
147
+ * sorted, each tagged with the read that produced it. Persistable but
148
+ * advisory: the admitting gate treats an unreadable or absent lane as "no
149
+ * overlap" (fail open), so no probe bug can refuse a well-formed issue.
150
+ */
151
+ export type RunLaneProbe = (input: RunLaneInput) => Promise<LaneFile[]>;
136
152
 
137
153
  /** The real one, reading the run's own worktree and/or the mirror branch. */
138
154
  export async function probeRunLane(
139
155
  input: RunLaneInput,
140
156
  exec: Exec = spawnCaptured,
141
- ): Promise<string[]> {
142
- const files = new Set<string>();
143
- const add = (raw: string): void => {
144
- for (const line of raw.split("\n")) {
145
- const path = line.trim();
146
- if (path !== "") files.add(path);
147
- }
157
+ ): Promise<LaneFile[]> {
158
+ const files = new Map<string, LaneSource>();
159
+ const add = (path: string, source: LaneSource): void => {
160
+ if (path !== "" && !files.has(path)) files.set(path, source);
161
+ };
162
+ const addDiff = (stdout: string, source: LaneSource): void => {
163
+ for (const line of stdout.split("\n")) add(line.trim(), source);
148
164
  };
149
165
  if (input.worktree !== "") {
150
166
  const status = await exec(["git", "-C", input.worktree, "status", "--porcelain"], {});
151
167
  if (status.code === 0) {
152
- for (const path of parsePorcelain(status.stdout)) files.add(path);
168
+ const { untracked, tracked } = parsePorcelain(status.stdout);
169
+ // Untracked files are authored by construction: a merge stages the files
170
+ // it brings in, it never leaves them untracked.
171
+ for (const path of untracked) add(path, "worktree");
172
+ if (tracked.length > 0) {
173
+ // While a merge of the base is in progress — the reconciliation every
174
+ // continuation brief requires — the porcelain read reports every file
175
+ // the merge staged, which is every file the base changed since the
176
+ // branch's merge-base, none of them the run's own work (#684). The
177
+ // three-dot diff is immune (merge-base relative), so the porcelain
178
+ // half is gated on a live MERGE_HEAD and filtered to files whose
179
+ // worktree content has actually diverged from base: reconciliation
180
+ // brings base content forward (worktree == base), authored work does
181
+ // not. Unresolved conflicts were already excluded by
182
+ // {@link parsePorcelain}: a conflicted file is not the run's
183
+ // resolution. An unreadable divergence read fails open — no tracked
184
+ // occupancy is claimed while the merge makes the read ambiguous.
185
+ const merge = await exec(
186
+ ["git", "-C", input.worktree, "rev-parse", "-q", "--verify", "MERGE_HEAD"],
187
+ {},
188
+ );
189
+ if (merge.code === 0) {
190
+ const diverged = await exec(
191
+ ["git", "-C", input.worktree, "diff", "--name-only", input.baseRef],
192
+ {},
193
+ );
194
+ if (diverged.code === 0) {
195
+ const set = new Set(
196
+ diverged.stdout.split("\n").map((line) => line.trim()).filter((line) => line !== ""),
197
+ );
198
+ for (const path of tracked) if (set.has(path)) add(path, "worktree");
199
+ }
200
+ } else {
201
+ for (const path of tracked) add(path, "worktree");
202
+ }
203
+ }
153
204
  }
154
205
  const diff = await exec(
155
206
  ["git", "-C", input.worktree, "diff", "--name-only", `${input.baseRef}...HEAD`],
156
207
  {},
157
208
  );
158
- if (diff.code === 0) add(diff.stdout);
209
+ if (diff.code === 0) addDiff(diff.stdout, "branch");
159
210
  }
160
211
  if (input.branchRef !== undefined && input.mirror !== undefined && input.mirror !== "") {
161
212
  const diff = await exec(
162
213
  ["git", "--git-dir", input.mirror, "diff", "--name-only", `${input.baseRef}...${input.branchRef}`],
163
214
  {},
164
215
  );
165
- if (diff.code === 0) add(diff.stdout);
216
+ if (diff.code === 0) addDiff(diff.stdout, "branch");
166
217
  }
167
- return [...files].sort();
218
+ return [...files.entries()]
219
+ .map(([file, source]) => ({ file, source }))
220
+ .sort((a, b) => (a.file < b.file ? -1 : a.file > b.file ? 1 : 0));
168
221
  }
169
222
 
170
223
  /**
171
- * Parse `git status --porcelain` output into the paths it names. A rename or
172
- * copy is reported as `XY old -> new`, and the *new* path is the one that
173
- * occupies the lane (it is the path the other run's future writes would
174
- * collide with), so the arrow form keeps only its target.
224
+ * The porcelain codes `git status --porcelain` emits for an unmerged index
225
+ * entry a merge/rebase/cherry-pick conflict. The X/Y letters are then the
226
+ * stage-2/stage-3 states and only ever appear in this exact set (git-status(1)
227
+ * lists them all), so membership is unambiguous.
228
+ */
229
+ const UNMERGED_PORCELAIN: Record<string, true> = {
230
+ UU: true,
231
+ AA: true,
232
+ DD: true,
233
+ AU: true,
234
+ UA: true,
235
+ DU: true,
236
+ UD: true,
237
+ };
238
+
239
+ /**
240
+ * Parse `git status --porcelain` output into the paths it names, split by how
241
+ * they may occupy a lane. A rename or copy is reported as `XY old -> new`, and
242
+ * the *new* path is the one that occupies the lane (it is the path the other
243
+ * run's future writes would collide with), so the arrow form keeps only its
244
+ * target. Untracked (`??`) paths are always the run's own work. Unmerged
245
+ * (`UU`-class) paths are dropped: a conflicted file is not the run's
246
+ * resolution, so it neither occupies a lane nor proves authorship.
175
247
  */
176
- function parsePorcelain(stdout: string): string[] {
177
- const out = new Set<string>();
248
+ function parsePorcelain(stdout: string): { untracked: string[]; tracked: string[] } {
249
+ const untracked: string[] = [];
250
+ const tracked: string[] = [];
178
251
  for (const raw of stdout.split("\n")) {
179
252
  if (raw.length < 3) continue;
253
+ const code = raw.slice(0, 2);
254
+ if (code === "??") {
255
+ untracked.push(raw.slice(2).trim());
256
+ continue;
257
+ }
258
+ if (UNMERGED_PORCELAIN[code] === true) continue;
180
259
  let rest = raw.slice(2).trim();
181
260
  const arrow = rest.indexOf(" -> ");
182
261
  if (arrow !== -1) rest = rest.slice(arrow + 4).trim();
183
- if (rest !== "") out.add(rest);
262
+ if (rest !== "") tracked.push(rest);
184
263
  }
185
- return [...out];
264
+ return { untracked, tracked };
186
265
  }
187
266
 
188
267
  /** GitHub slug parsed off the clone URL, falling back to the routing name. */