omp-conductor 0.17.0 → 0.18.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (51) hide show
  1. package/REFERENCE.md +12 -8
  2. package/package.json +1 -1
  3. package/schema/config.schema.json +40 -1
  4. package/src/admission.ts +263 -44
  5. package/src/ask.ts +39 -3
  6. package/src/availability.ts +27 -1
  7. package/src/backups.ts +2 -2
  8. package/src/briefs/orchestrator.md +1 -0
  9. package/src/briefs/worker.md +38 -19
  10. package/src/command-help.ts +8 -1
  11. package/src/command-manifest.ts +5 -2
  12. package/src/commands/arm.ts +6 -3
  13. package/src/commands/message.ts +32 -4
  14. package/src/commands/watch.ts +62 -3
  15. package/src/config-schema.ts +53 -0
  16. package/src/config.ts +97 -1
  17. package/src/daemon.ts +1479 -1483
  18. package/src/decisions.ts +51 -6
  19. package/src/depends-on.ts +261 -1
  20. package/src/diff-flags.ts +350 -0
  21. package/src/digest-schedule.ts +37 -0
  22. package/src/doctor.ts +310 -22
  23. package/src/escalate.ts +560 -57
  24. package/src/failure-class.ts +71 -15
  25. package/src/fleet.ts +189 -34
  26. package/src/gitops.ts +103 -24
  27. package/src/graph-health.ts +20 -7
  28. package/src/graph.ts +313 -68
  29. package/src/lifecycle.ts +43 -7
  30. package/src/omp.ts +42 -0
  31. package/src/orchestrator-tick.ts +430 -162
  32. package/src/release-policy.ts +177 -5
  33. package/src/routing.ts +11 -3
  34. package/src/session-host.ts +16 -0
  35. package/src/settlement.ts +1728 -0
  36. package/src/setup-host.ts +193 -4
  37. package/src/setup-install.ts +91 -30
  38. package/src/setup-wizard.ts +1257 -78
  39. package/src/setup.ts +153 -6
  40. package/src/status-render.ts +36 -4
  41. package/src/store.ts +411 -17
  42. package/src/tracker/github.ts +607 -12
  43. package/src/types.ts +331 -5
  44. package/src/upgrade.ts +50 -19
  45. package/src/verbs/actions.ts +66 -18
  46. package/src/verbs/protocol.ts +45 -0
  47. package/src/verbs/server.ts +270 -13
  48. package/src/worker.ts +239 -6
  49. package/src/worktree.ts +115 -8
  50. package/systemd/omp-conductor-recover.sh +73 -0
  51. 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
 
@@ -148,10 +188,23 @@ export function providerCreditRefusal(error: {
148
188
  }
149
189
 
150
190
  /** Provider text that names a per-request stream fault. Deliberately narrow:
151
- * a 429 is rate limiting and a 402 is credit — different remedies (#220). */
191
+ * a 429 is rate limiting and a 402 is credit — different remedies (#220).
192
+ *
193
+ * The structural `kind` that `readSessionError` marks outranks this list: a
194
+ * transcript record the harness itself attributed to a provider abort is a
195
+ * per-request stream fault whatever the provider called it, so no vendor
196
+ * prose needs enumerating here (#743). The list remains for transcripts that
197
+ * only ever carried prose, like the original "stream stalled" stall. */
152
198
  const TRANSIENT_FAULT_SIGNATURES = ["stream stalled"] as const;
153
199
 
154
- export function providerTransientFault(error: { status?: number; message: string }): string | undefined {
200
+ export function providerTransientFault(error: {
201
+ status?: number;
202
+ message: string;
203
+ kind?: "provider-stream";
204
+ }): string | undefined {
205
+ if (error.kind === "provider-stream") {
206
+ return error.message.split("\n")[0]?.trim() ?? error.message;
207
+ }
155
208
  const text = error.message.toLowerCase();
156
209
  if (!TRANSIENT_FAULT_SIGNATURES.some((s) => text.includes(s))) return undefined;
157
210
  return error.message.split("\n")[0]?.trim() ?? error.message;
@@ -466,16 +519,19 @@ export function classifyRun(
466
519
  // beats ci-deterministic because charging an implementation attempt for a
467
520
  // rate limit is exactly the waste that class exists to prevent.
468
521
  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
- }
522
+ // Matched exactly, without paren-stripping: GitHub's full setup/codeload
523
+ // sentence is its own closed entry above, and a generic paren-strip would
524
+ // broaden the other signatures into waiving real deterministic failures
525
+ // (#177). A bare "429" or "too many requests" (parenthesized or not)
526
+ // still never matches without that sentence.
527
+ const signature = infraLogSignature(facts.failingLog);
528
+ if (signature !== undefined) {
529
+ const check = checks.find((c) => normalise(c.state) === "failure");
530
+ return {
531
+ cls: "ci-infra",
532
+ recovery: "rerun-checks",
533
+ evidence: `infrastructure signature in ${check?.name === undefined ? "failed check" : check.name} log: "${signature}"`,
534
+ };
479
535
  }
480
536
  }
481
537
  const failing = unresolved.filter((c) => normalise(c.state) === "failure");
package/src/fleet.ts CHANGED
@@ -27,15 +27,29 @@ 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
54
  import { isPaused, setPaused, statusSnapshot, type StatusSnapshot } from "./daemon.ts";
41
55
  import {
@@ -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 {
@@ -1068,8 +1143,26 @@ function bareReadProject(): string | undefined {
1068
1143
 
1069
1144
  export function fleetLayers(projectName?: string): FleetLayers {
1070
1145
  const rec = livingDaemon();
1146
+ // systemd is the authoritative liveness witness for a unit-owned daemon: a
1147
+ // pidfile that is missing, stale, or skewed against the unit's MainPID must
1148
+ // not declare the daemon dead (#716). Consulted only when the pidfile says
1149
+ // dead — a healthy record needs no shell-out, and a host without systemctl
1150
+ // answers `inactive`, so those hosts behave exactly as before. `failed`,
1151
+ // `unknown`, and a mid-restart unit (MainPID 0, process gone) all answer
1152
+ // undefined: a genuinely stopped daemon never reports running.
1153
+ let unitPid: number | undefined;
1154
+ if (rec === undefined) {
1155
+ const ownership = probeUnit();
1156
+ if (ownership.kind === "active" && isAlive(ownership.pid)) unitPid = ownership.pid;
1157
+ }
1158
+ const daemon: FleetLayers["daemon"] =
1159
+ rec !== undefined
1160
+ ? { running: true, pid: rec.pid, port: rec.port }
1161
+ : unitPid !== undefined
1162
+ ? { running: true, pid: unitPid }
1163
+ : { running: false };
1071
1164
  const paused = isPaused(projectName ?? bareReadProject());
1072
- const dispatch: DispatchLayer = rec === undefined ? "stopped" : paused ? "paused" : "running";
1165
+ const dispatch: DispatchLayer = daemon.running ? (paused ? "paused" : "running") : "stopped";
1073
1166
 
1074
1167
  const tick = resolveTickConfig(projectName);
1075
1168
  let ticks: TicksLayer;
@@ -1149,7 +1242,7 @@ export function fleetLayers(projectName?: string): FleetLayers {
1149
1242
  ...(tickConfigPath === undefined ? {} : { tickConfigPath }),
1150
1243
  ...(haltPath === undefined ? {} : { paneHaltPath: haltPath }),
1151
1244
  paused,
1152
- daemon: rec === undefined ? { running: false } : { running: true, pid: rec.pid, port: rec.port },
1245
+ daemon,
1153
1246
  };
1154
1247
  }
1155
1248
 
@@ -1732,6 +1825,68 @@ function makeChallengeCode(): string {
1732
1825
  return `FLEET-${hex}`;
1733
1826
  }
1734
1827
 
1828
+ /**
1829
+ * The one armed-marker write both proofs share: same content, same mode, and
1830
+ * the same restamp of the pre-per-project shared marker the heartbeat still
1831
+ * honours — a project that just armed must not leave the bare marker around to
1832
+ * re-arm future fleets through `disarm` (#316).
1833
+ */
1834
+ function writeArmedMarker(path: string, owner: string, arm: ArmState): void {
1835
+ mkdirSync(dirname(path), { recursive: true });
1836
+ writeFileSync(path, `armed ${new Date().toISOString()} owner=${owner}\n`, { mode: 0o600 });
1837
+ if (arm.legacy === "honoured") rmSync(legacyArmedMarkerPath(), { force: true });
1838
+ }
1839
+
1840
+ /**
1841
+ * The verdict's view of the session surface: the same dirs a challenge would
1842
+ * watch, minus the transcript throw. An outside-tree claim is not a reason to
1843
+ * stop here — the claim-only verdict names that failure itself
1844
+ * (`claim-session-outside` / `dm-owner-unrelated`) with the same session
1845
+ * identity rules, so the refusal carries the fact, not an early transcript
1846
+ * wording.
1847
+ */
1848
+ function armVerdictScanDirs(cwd: string, claimed: string | undefined): string[] {
1849
+ const cwdDir = sessionDirForCwd(cwd);
1850
+ if (claimed === undefined) return [cwdDir];
1851
+ const root = sessionsRoot();
1852
+ const claimDir = dirname(claimed);
1853
+ if (claimDir === cwdDir) return [cwdDir];
1854
+ const insideTree = claimDir === root || claimDir.startsWith(join(root, sep));
1855
+ return insideTree ? [cwdDir, claimDir] : [cwdDir];
1856
+ }
1857
+
1858
+ /**
1859
+ * What each claim-only refusal names, in the operator's words (#613). The
1860
+ * verdict's reason enum is the fact; this is the sentence that says it — the
1861
+ * same facts the doctor's `telegram-plumbing` finding spells out, without the
1862
+ * finding's remediation (the refusal is one line an unattended recovery can
1863
+ * escalate verbatim).
1864
+ */
1865
+ function claimOnlyFailureText(reason: TelegramPlumbingFailureReason): string {
1866
+ switch (reason) {
1867
+ case "channel-down":
1868
+ return "the paired inbound channel is not up";
1869
+ case "registry-unreadable":
1870
+ return "omp-telegram's threads.json is absent, unreadable or malformed — the topic's claim liveness cannot be verified";
1871
+ case "no-topic-claim":
1872
+ return "no live omp-telegram claim carries the topic arming would send into";
1873
+ case "claim-dead":
1874
+ return "the topic's omp-telegram claim records no live pid — the pane behind it is gone";
1875
+ case "claim-session-outside":
1876
+ return "the topic's claim names a session file outside the session directories arm scans — the reply could never be seen";
1877
+ case "no-dm-owner":
1878
+ return "no flat-chat reply recipient: no dm-owner.json, and bot.lock names no session inside the arm scan surface";
1879
+ case "dm-owner-dead":
1880
+ return "omp-telegram's dm-owner.json records no live pid — the flat-chat recipient is gone";
1881
+ case "dm-owner-unrelated":
1882
+ return "the dm-owner.json session sits outside the arm scan surface — a flat reply would land where no challenge can read it";
1883
+ case "no-poller-state":
1884
+ return "omp-telegram's bot.lock is absent, unreadable or malformed — nothing owns the poll, so nothing is receiving inbound";
1885
+ case "poller-dead":
1886
+ return "omp-telegram's bot.lock records no live owner and no fresh heartbeat — Telegram isn't being polled";
1887
+ }
1888
+ }
1889
+
1735
1890
  async function sendTelegramMessage(
1736
1891
  token: string,
1737
1892
  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. */