omp-conductor 0.15.6 → 0.15.7

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/config.ts CHANGED
@@ -45,6 +45,7 @@ import {
45
45
  type MergePreconditions,
46
46
  type PlanUsageCap,
47
47
  type ProjectConfig,
48
+ type RecoveryMergeAuthorization,
48
49
  type ProjectPolicy,
49
50
  type ReleasePreconditions,
50
51
  type ReleaseRequirement,
@@ -1044,9 +1045,33 @@ function finalizeProject(
1044
1045
  const escalation = finalizeEscalation(p["escalation"] as Raw | undefined);
1045
1046
  const authority = finalizeAuthority(p["authority"] as Raw | undefined);
1046
1047
  const releasePolicy = finalizeReleasePolicy(p["releasePolicy"], label, problems);
1048
+ const strandedTagRepos =
1049
+ releasePolicy["git-tag"] === "orchestrator" && releasePolicy["version-bump-pr"] !== "orchestrator"
1050
+ ? Object.values(repos).filter((repo) => repo.release !== undefined)
1051
+ : [];
1052
+ if (strandedTagRepos.length > 0) {
1053
+ problems.push(
1054
+ `${label}: releasePolicy delegates git-tag, but ${strandedTagRepos
1055
+ .map((repo) => `${repo.name}.release.versionFile`)
1056
+ .join(", ")} requires a version-change PR and version-bump-pr is not delegated — ` +
1057
+ `grant "version-bump-pr": "orchestrator" or keep git-tag with the human`,
1058
+ );
1059
+ }
1047
1060
  const policy = finalizePolicy(p["policy"], label, problems);
1048
1061
  const caps = reconcileCaps(p["caps"], `${label}: caps`, problems, legacyCaps);
1049
1062
  const reporting = finalizeReporting(p["reporting"], label, problems);
1063
+ const recoveryMerges = (p["recoveryMerges"] as RecoveryMergeAuthorization[] | undefined)?.map(
1064
+ (entry) => ({ ...entry }),
1065
+ );
1066
+ if (recoveryMerges !== undefined) {
1067
+ const seen = new Set<string>();
1068
+ for (const entry of recoveryMerges) {
1069
+ if (seen.has(entry.prUrl)) {
1070
+ problems.push(`${label}: recoveryMerges contains duplicate prUrl ${JSON.stringify(entry.prUrl)}`);
1071
+ }
1072
+ seen.add(entry.prUrl);
1073
+ }
1074
+ }
1050
1075
  const rawWorkerModel = p["workerModel"];
1051
1076
  const workerModel =
1052
1077
  typeof rawWorkerModel === "string" && rawWorkerModel.trim() !== "" ? rawWorkerModel : undefined;
@@ -1070,6 +1095,7 @@ function finalizeProject(
1070
1095
  authority,
1071
1096
  releasePolicy,
1072
1097
  policy,
1098
+ ...(recoveryMerges === undefined ? {} : { recoveryMerges }),
1073
1099
  reporting,
1074
1100
  workspaceRoot: expandHome(pickString(p["workspaceRoot"], defaultWorkspaceRoot())),
1075
1101
  mirrorRoot: expandHome(pickString(p["mirrorRoot"], defaultMirrorRoot())),
@@ -1223,6 +1249,8 @@ function finalizeRepos(parsed: unknown, label: string, problems: string[]): Reco
1223
1249
  if (graph !== undefined) target.graphProject = graph;
1224
1250
  const migrations = finalizeMigrationsDir(value?.["migrations"], `${label}: routing.repos.${key}`, problems);
1225
1251
  if (migrations !== undefined) target.migrations = { dir: migrations };
1252
+ const versionFile = finalizeVersionFile(value?.["release"], `${label}: routing.repos.${key}`, problems);
1253
+ if (versionFile !== undefined) target.release = { versionFile };
1226
1254
  repos[key] = target;
1227
1255
  }
1228
1256
  return repos;
@@ -1262,6 +1290,21 @@ function finalizeMigrationsDir(parsed: unknown, label: string, problems: string[
1262
1290
  return dir;
1263
1291
  }
1264
1292
 
1293
+ function finalizeVersionFile(parsed: unknown, label: string, problems: string[]): string | undefined {
1294
+ if (parsed === undefined) return undefined;
1295
+ const raw = typeof parsed === "object" && parsed !== null && !Array.isArray(parsed) ? (parsed as Raw) : undefined;
1296
+ const file = raw?.["versionFile"];
1297
+ if (typeof file !== "string" || file.trim() === "") {
1298
+ problems.push(`${label}.release.versionFile must be a non-empty string`);
1299
+ return undefined;
1300
+ }
1301
+ if (file.startsWith("/") || file.split("/").includes("..")) {
1302
+ problems.push(`${label}.release.versionFile must be a repo-relative file`);
1303
+ return undefined;
1304
+ }
1305
+ return file;
1306
+ }
1307
+
1265
1308
  function finalizeGraphProject(parsed: unknown, label: string, problems: string[]): string | undefined {
1266
1309
  if (parsed === undefined) return undefined;
1267
1310
  if (typeof parsed !== "string" || parsed.trim() === "") {
package/src/fleet.ts CHANGED
@@ -56,6 +56,7 @@ import {
56
56
  isAlive,
57
57
  livingDaemon,
58
58
  probeUnit,
59
+ runSystemctl,
59
60
  stopDaemon,
60
61
  type StopResult,
61
62
  type UnitOwnership,
@@ -566,21 +567,7 @@ export interface HerdrStartDeps {
566
567
  * systemd or without that optional unit keep the standalone daemon behaviour.
567
568
  */
568
569
  export function startHerdrFleet(projectName?: string, deps: HerdrStartDeps = {}): HerdrStartResult {
569
- const run: NonNullable<HerdrStartDeps["systemctl"]> =
570
- deps.systemctl ??
571
- ((args: string[]) => {
572
- const res = spawnSync("systemctl", args, { encoding: "utf8", timeout: 15_000, env: process.env });
573
- if (res.error) {
574
- const err = res.error as NodeJS.ErrnoException;
575
- return {
576
- ok: false,
577
- stdout: "",
578
- stderr: err.message,
579
- ...(err.code === "ENOENT" ? { missing: true } : {}),
580
- };
581
- }
582
- return { ok: res.status === 0, stdout: res.stdout ?? "", stderr: res.stderr ?? "" };
583
- });
570
+ const run = deps.systemctl ?? runSystemctl;
584
571
 
585
572
  const shown = run(["show", DEFAULT_HERDR_UNIT, "--property=LoadState", "--value"]);
586
573
  if (shown.missing) return { kind: "unmanaged", unit: DEFAULT_HERDR_UNIT, reason: "no systemctl" };
@@ -2000,26 +1987,18 @@ export async function transcriptHasUserCode(path: string, code: string): Promise
2000
1987
  // ---------------------------------------------------------------------------
2001
1988
 
2002
1989
  function probeHerdrUnit(unit = DEFAULT_HERDR_UNIT): { kind: HerdrLayer; detail?: string } {
2003
- try {
2004
- const res = spawnSync("systemctl", ["is-active", unit], {
2005
- encoding: "utf8",
2006
- timeout: 5_000,
2007
- env: process.env,
2008
- });
2009
- if (res.error) {
2010
- const err = res.error as NodeJS.ErrnoException;
2011
- if (err.code === "ENOENT") return { kind: "unknown", detail: "no systemctl" };
2012
- return { kind: "unknown", detail: err.message };
2013
- }
2014
- const out = (res.stdout ?? "").trim();
2015
- if (out === "active") return { kind: "active", detail: unit };
2016
- if (out === "inactive" || out === "failed" || out === "dead") {
2017
- return { kind: "inactive", detail: `${unit} ${out}` };
2018
- }
2019
- return { kind: "unknown", detail: `${unit} ${out || `exit ${String(res.status)}`}` };
2020
- } catch (err) {
2021
- return { kind: "unknown", detail: err instanceof Error ? err.message : String(err) };
2022
- }
1990
+ const res = runSystemctl(["is-active", unit]);
1991
+ if (res.missing) return { kind: "unknown", detail: "no systemctl" };
1992
+ const out = res.stdout.trim();
1993
+ if (out === "active") return { kind: "active", detail: unit };
1994
+ if (out === "inactive" || out === "failed" || out === "dead") {
1995
+ return { kind: "inactive", detail: `${unit} ${out}` };
1996
+ }
1997
+ const error = res.stderr.trim();
1998
+ return {
1999
+ kind: "unknown",
2000
+ detail: `${unit} ${out || error || "systemctl is-active failed"}`,
2001
+ };
2023
2002
  }
2024
2003
 
2025
2004
  export function paneLayerFromAgents(
package/src/lifecycle.ts CHANGED
@@ -195,18 +195,34 @@ export async function healthCheck(port: number): Promise<{ ok: boolean; body?: s
195
195
  }
196
196
  }
197
197
 
198
+ function healthServesProject(body: string | undefined, project: string | undefined): boolean {
199
+ if (project === undefined) return true;
200
+ if (body === undefined) return false;
201
+ try {
202
+ const parsed = JSON.parse(body) as unknown;
203
+ if (parsed === null || typeof parsed !== "object") return false;
204
+ const projects = Reflect.get(parsed, "projects");
205
+ if (!Array.isArray(projects)) return false;
206
+ return projects.some((entry) => {
207
+ if (typeof entry === "string") return entry === project;
208
+ return entry !== null && typeof entry === "object" && Reflect.get(entry, "project") === project;
209
+ });
210
+ } catch {
211
+ return false;
212
+ }
213
+ }
214
+
198
215
  /**
199
- * Starts the daemon in the background and does not return until it answers
200
- * `/healthz`.
216
+ * Starts the daemon and does not return until it answers `/healthz`.
201
217
  *
202
- * Spawning is not starting. A daemon whose config is broken, whose port is
203
- * taken or whose database is locked exits within a second of being spawned,
204
- * and a `start` that printed "started" for it hands the operator a lie they
205
- * only discover when work silently fails to be picked up. So: spawn, watch
206
- * both the pid and the endpoint, and fail loudly with the log if either says
207
- * no.
218
+ * An installed unit owns the host-wide daemon lifecycle. `start --project X`
219
+ * may select the project whose health is proved, but it must not turn the unit
220
+ * into a detached single-project competitor (#400). Only a host proven not to
221
+ * have the unit keeps the standalone spawn path.
208
222
  */
209
- export async function startDaemon(o: { port?: number; project?: string } = {}): Promise<DaemonRecord> {
223
+ export async function startDaemon(
224
+ o: { port?: number; project?: string; timeoutMs?: number } = {},
225
+ ): Promise<DaemonRecord> {
210
226
  const running = livingDaemon();
211
227
  if (running !== undefined) {
212
228
  throw new Error(
@@ -215,6 +231,28 @@ export async function startDaemon(o: { port?: number; project?: string } = {}):
215
231
  );
216
232
  }
217
233
 
234
+ const installation = probeUnitInstallation();
235
+ if (installation.kind === "unknown") {
236
+ throw new Error(
237
+ `cannot determine whether ${SYSTEMD_UNIT} is installed (${installation.reason}); ` +
238
+ "refusing to launch a detached daemon that could compete with the service manager",
239
+ );
240
+ }
241
+ if (installation.kind === "installed") {
242
+ const ownership = probeUnit();
243
+ if (ownership.kind === "unknown") {
244
+ throw new Error(ownershipUnknown("start", ownership.reason));
245
+ }
246
+ if (ownership.kind === "failed") {
247
+ return await restoreFailedUnit(o.timeoutMs, o.project);
248
+ }
249
+ const started = systemctl(["start", SYSTEMD_UNIT]);
250
+ if (!started.ok) {
251
+ throw new Error(systemctlFailure("start", started));
252
+ }
253
+ return await waitForOwnedDaemon("start", o.timeoutMs, o.project);
254
+ }
255
+
218
256
  const port = o.port ?? DEFAULT_PORT;
219
257
  const logFile = join(ensureRuntimeDir(), "daemon.log");
220
258
 
@@ -260,7 +298,7 @@ export async function startDaemon(o: { port?: number; project?: string } = {}):
260
298
  // rather than nothing at all.
261
299
  writeRecord(record);
262
300
 
263
- const deadline = Date.now() + READY_TIMEOUT_MS;
301
+ const deadline = Date.now() + (o.timeoutMs ?? READY_TIMEOUT_MS);
264
302
  for (;;) {
265
303
  // Liveness first. If the child is gone, a healthy answer on that port came
266
304
  // from somebody else's server, and reporting it as ours would be worse
@@ -270,7 +308,7 @@ export async function startDaemon(o: { port?: number; project?: string } = {}):
270
308
  throw new Error(`daemon exited during startup${tailLog(logFile)}`);
271
309
  }
272
310
  const health = await healthCheck(port);
273
- if (health.ok) return record;
311
+ if (health.ok && healthServesProject(health.body, o.project)) return record;
274
312
  if (Date.now() >= deadline) break;
275
313
  await sleep(READY_POLL_MS);
276
314
  }
@@ -280,7 +318,7 @@ export async function startDaemon(o: { port?: number; project?: string } = {}):
280
318
  await terminate(pid, STOP_TIMEOUT_MS);
281
319
  clearRecord();
282
320
  throw new Error(
283
- `daemon did not answer http://127.0.0.1:${port}/healthz within ${Math.round(READY_TIMEOUT_MS / 1000)}s${tailLog(logFile)}`,
321
+ `daemon did not answer http://127.0.0.1:${port}/healthz for the requested project within ${Math.round((o.timeoutMs ?? READY_TIMEOUT_MS) / 1000)}s${tailLog(logFile)}`,
284
322
  );
285
323
  }
286
324
 
@@ -395,7 +433,7 @@ export async function restartDaemon(
395
433
  // startDaemon() — that leaves the unit failed while handing the operator
396
434
  // a healthy-looking but unmanaged pid (the 12:17Z incident in #376).
397
435
  // Any manager refusal is terminal; so is unproven ownership afterwards.
398
- const record = await restoreFailedUnit(o.timeoutMs);
436
+ const record = await restoreFailedUnit(o.timeoutMs, o.project);
399
437
  return { previous, record, via: "systemctl" };
400
438
  }
401
439
 
@@ -413,7 +451,7 @@ export async function restartDaemon(
413
451
  // systemctl restart returns once the new MainPID is up; the pidfile is
414
452
  // written by the daemon itself on boot, so wait for that rather than
415
453
  // inventing a record from the unit alone.
416
- const record = await waitForOwnedDaemon("restart", o.timeoutMs);
454
+ const record = await waitForOwnedDaemon("restart", o.timeoutMs, o.project);
417
455
  return { previous, record, via: "systemctl" };
418
456
  }
419
457
 
@@ -430,7 +468,7 @@ export async function restartDaemon(
430
468
  * (see {@link waitForOwnedDaemon}). Refusal or unproven ownership is terminal
431
469
  * — never a fallthrough to the detached CLI daemon.
432
470
  */
433
- async function restoreFailedUnit(timeoutMs?: number): Promise<DaemonRecord> {
471
+ async function restoreFailedUnit(timeoutMs?: number, project?: string): Promise<DaemonRecord> {
434
472
  const reset = systemctl(["reset-failed", SYSTEMD_UNIT]);
435
473
  if (!reset.ok) {
436
474
  throw new Error(systemctlFailure("reset-failed", reset));
@@ -439,7 +477,7 @@ async function restoreFailedUnit(timeoutMs?: number): Promise<DaemonRecord> {
439
477
  if (!started.ok) {
440
478
  throw new Error(systemctlFailure("start", started));
441
479
  }
442
- return await waitForOwnedDaemon("start", timeoutMs);
480
+ return await waitForOwnedDaemon("start", timeoutMs, project);
443
481
  }
444
482
 
445
483
  /**
@@ -450,7 +488,11 @@ async function restoreFailedUnit(timeoutMs?: number): Promise<DaemonRecord> {
450
488
  * cannot un-fail it. Any other unproven state fails at the deadline with a
451
489
  * diagnostic that names the mismatch, never a bare "not ready".
452
490
  */
453
- async function waitForOwnedDaemon(verb: "restart" | "start", timeoutMs?: number): Promise<DaemonRecord> {
491
+ async function waitForOwnedDaemon(
492
+ verb: "restart" | "start",
493
+ timeoutMs?: number,
494
+ project?: string,
495
+ ): Promise<DaemonRecord> {
454
496
  const via = `systemctl ${verb} ${SYSTEMD_UNIT} returned`;
455
497
  const inspect = `systemctl status ${SYSTEMD_UNIT}`;
456
498
  const deadline = Date.now() + (timeoutMs ?? READY_TIMEOUT_MS);
@@ -459,7 +501,7 @@ async function waitForOwnedDaemon(verb: "restart" | "start", timeoutMs?: number)
459
501
  const ownership = probeUnit();
460
502
  if (rec !== undefined && ownership.kind === "active" && ownership.pid === rec.pid) {
461
503
  const health = await healthCheck(rec.port);
462
- if (health.ok) return rec;
504
+ if (health.ok && healthServesProject(health.body, project)) return rec;
463
505
  } else if (ownership.kind === "failed") {
464
506
  // A confirmed negative: the start did not take and the unit is failed
465
507
  // again. Waiting longer cannot un-fail it.
@@ -482,9 +524,10 @@ async function waitForOwnedDaemon(verb: "restart" | "start", timeoutMs?: number)
482
524
  }
483
525
  if (ownership.kind === "active") {
484
526
  if (ownership.pid === rec.pid) {
527
+ const projectDetail = project === undefined ? "" : ` for project ${project}`;
485
528
  throw new Error(
486
- `${via}, but the daemon never answered /healthz on :${rec.port} — ` +
487
- `the service manager owns pid ${rec.pid}, but it is not serving; ` +
529
+ `${via}, but the daemon never answered /healthz${projectDetail} on :${rec.port} — ` +
530
+ `the service manager owns pid ${rec.pid}, but it is not serving the requested project; ` +
488
531
  `check \`${inspect}\` and ${rec.logFile}`,
489
532
  );
490
533
  }
@@ -579,6 +622,31 @@ export function probeUnit(unit = SYSTEMD_UNIT): UnitOwnership {
579
622
  return { kind: "inactive" };
580
623
  }
581
624
 
625
+ type UnitInstallation =
626
+ | { kind: "installed" }
627
+ | { kind: "absent" }
628
+ | { kind: "unknown"; reason: string };
629
+
630
+ /**
631
+ * Installation is separate from ownership: an inactive installed unit and an
632
+ * absent unit both have MainPID 0, but only the latter permits a detached
633
+ * fallback.
634
+ */
635
+ function probeUnitInstallation(unit = SYSTEMD_UNIT): UnitInstallation {
636
+ const ran = systemctl(["show", unit, "--property=LoadState", "--value"]);
637
+ if (!ran.ok) {
638
+ if (ran.missing) return { kind: "absent" };
639
+ const detail = (ran.stderr.trim() || ran.stdout.trim() || "systemctl show failed").split("\n")[0]!;
640
+ return { kind: "unknown", reason: detail };
641
+ }
642
+ const loadState = ran.stdout.trim();
643
+ if (loadState === "not-found") return { kind: "absent" };
644
+ if (loadState.length === 0) return { kind: "unknown", reason: "systemctl returned no LoadState" };
645
+ // loaded, masked, error and bad-setting all prove a manager-known unit. Let
646
+ // `systemctl start` provide the actionable refusal for a broken definition.
647
+ return { kind: "installed" };
648
+ }
649
+
582
650
  /**
583
651
  * The MainPID of an *active* {@link SYSTEMD_UNIT}, or `undefined` when the
584
652
  * unit is confirmed inactive, failed, or absent, or when ownership could not
@@ -662,7 +730,14 @@ function systemctlFailure(
662
730
  );
663
731
  }
664
732
 
665
- function ownershipUnknown(verb: "stop" | "restart", reason: string): string {
733
+ function ownershipUnknown(verb: "start" | "stop" | "restart", reason: string): string {
734
+ if (verb === "start") {
735
+ return (
736
+ `cannot determine whether ${SYSTEMD_UNIT} owns the daemon (${reason}) — ` +
737
+ `refusing to start a detached competitor while ownership is unknown; ` +
738
+ `retry when systemctl answers, or run \`systemctl start ${SYSTEMD_UNIT}\` yourself`
739
+ );
740
+ }
666
741
  return (
667
742
  `cannot determine whether ${SYSTEMD_UNIT} owns the daemon (${reason}) — ` +
668
743
  `refusing to ${verb} via signal while ownership is unknown ` +
@@ -689,7 +764,69 @@ export type SystemctlResult = {
689
764
 
690
765
  export type SystemctlFn = (args: string[]) => SystemctlResult;
691
766
 
767
+ interface TestSystemctlState {
768
+ installed?: boolean;
769
+ mainPid?: number;
770
+ activeState?: string;
771
+ calls?: string[][];
772
+ failures?: Record<string, string>;
773
+ }
774
+
775
+ /**
776
+ * Child-process CLI tests cannot inject {@link setSystemctlForTest}. Under
777
+ * NODE_ENV=test they therefore get a file-backed fake manager, or a refusal
778
+ * when no fixture was supplied. The real binary is never reached from a test
779
+ * process (#399).
780
+ */
781
+ function testSystemctl(args: string[]): SystemctlResult {
782
+ const path = process.env["OMP_CONDUCTOR_TEST_SYSTEMCTL_STATE"];
783
+ if (path === undefined) {
784
+ return {
785
+ ok: false,
786
+ stdout: "",
787
+ stderr: "real systemctl is disabled under NODE_ENV=test; no fake manager state was supplied",
788
+ };
789
+ }
790
+ try {
791
+ const state = JSON.parse(readFileSync(path, "utf8")) as TestSystemctlState;
792
+ const calls = Array.isArray(state.calls) ? state.calls : [];
793
+ calls.push(args);
794
+ state.calls = calls;
795
+ const verb = args[0] ?? "";
796
+ const failure = state.failures?.[verb];
797
+ if (failure !== undefined) {
798
+ writeFileSync(path, JSON.stringify(state));
799
+ return { ok: false, stdout: "", stderr: failure };
800
+ }
801
+ let stdout = "";
802
+ if (verb === "show") {
803
+ stdout = args.includes("--property=LoadState")
804
+ ? `${state.installed === false ? "not-found" : "loaded"}\n`
805
+ : `${state.mainPid ?? 0}\n${state.activeState ?? "inactive"}\n`;
806
+ } else if (verb === "is-active") {
807
+ const activeState = state.activeState ?? "inactive";
808
+ stdout = `${activeState}\n`;
809
+ writeFileSync(path, JSON.stringify(state));
810
+ return { ok: activeState === "active", stdout, stderr: "" };
811
+ } else if (verb === "stop") {
812
+ state.mainPid = 0;
813
+ state.activeState = "inactive";
814
+ } else if (verb === "reset-failed") {
815
+ state.activeState = "inactive";
816
+ }
817
+ writeFileSync(path, JSON.stringify(state));
818
+ return { ok: true, stdout, stderr: "" };
819
+ } catch (err) {
820
+ return {
821
+ ok: false,
822
+ stdout: "",
823
+ stderr: `fake systemctl state failed: ${err instanceof Error ? err.message : String(err)}`,
824
+ };
825
+ }
826
+ }
827
+
692
828
  function defaultSystemctl(args: string[]): SystemctlResult {
829
+ if (process.env["NODE_ENV"] === "test") return testSystemctl(args);
693
830
  try {
694
831
  const res = spawnSync("systemctl", args, {
695
832
  encoding: "utf8",
@@ -719,6 +856,12 @@ function defaultSystemctl(args: string[]): SystemctlResult {
719
856
 
720
857
  let systemctl: SystemctlFn = defaultSystemctl;
721
858
 
859
+ /** Shared manager runner. Production reaches the hardcoded binary; test
860
+ * processes reach only the explicit fake or the fail-closed refusal. */
861
+ export function runSystemctl(args: string[]): SystemctlResult {
862
+ return systemctl(args);
863
+ }
864
+
722
865
  /** Test-only: replace the `systemctl` runner. Pass `undefined` to restore. */
723
866
  export function setSystemctlForTest(fn: SystemctlFn | undefined): void {
724
867
  systemctl = fn ?? defaultSystemctl;
@@ -617,9 +617,14 @@ export { TELEGRAM_APPROVAL_TOOL };
617
617
  * Fails closed in the only sense that helps a fleet: the duties still run, but
618
618
  * the turn is told the approval primitive is missing *before* it can reach the
619
619
  * step that needs one. The fallback named here is the one the floor already
620
- * documents for a `telegram_ask` that never delivered — re-deliver with
621
- * `telegram_send` — so a session has one answer to "the ask did not happen",
622
- * not two. The last clause is the actual hazard #114 exposed: a turn that knows
620
+ * documents for a `telegram_ask` that never delivered — re-deliver the same
621
+ * question — so a session has one answer to "the ask did not happen", not two.
622
+ * It names `omp-conductor message` rather than `telegram_send` because this
623
+ * rule only ever reaches a locally injected turn: there is no inbound message
624
+ * to inherit a chat and topic from, and a bare send would answer a forum fleet
625
+ * in its main chat (#366).
626
+ *
627
+ * The last clause is the actual hazard #114 exposed: a turn that knows
623
628
  * it must ask, and cannot, is one inference away from recording an approval
624
629
  * nobody gave.
625
630
  *
@@ -630,7 +635,7 @@ export { TELEGRAM_APPROVAL_TOOL };
630
635
  */
631
636
  export const TICK_APPROVAL_UNAVAILABLE_RULE =
632
637
  `The ${TELEGRAM_APPROVAL_TOOL} tool is NOT mounted on this tick, so the package floor's yes/no amendment approval cannot be asked here. ` +
633
- `If you have an amendment to propose, deliver the question with telegram_send, prefix its text with "QUESTION:", and wait for your operator's reply on a later turn. ` +
638
+ `If you have an amendment to propose, deliver the question with \`omp-conductor message --text "QUESTION: <the question>"\` — it resolves this project's own Telegram chat and topic — and wait for your operator's reply on a later turn. ` +
634
639
  `A returned telegram_ask answer proves an answer, not Telegram delivery. ` +
635
640
  `Never apply an amendment, or record one as approved, without an explicit answer you actually received.`;
636
641
 
@@ -1639,17 +1644,25 @@ function serialiseTelegramPayload(value: unknown): string {
1639
1644
  }
1640
1645
  }
1641
1646
 
1642
- function telegramInterruptFromTool(
1647
+ /**
1648
+ * A direct Telegram call, whatever surface it arrived on: the mounted tool, or
1649
+ * the same device written through `xd://`. Both spellings reach the same bot,
1650
+ * so every gate below reads them the same way.
1651
+ */
1652
+ function directTelegramCall(
1643
1653
  toolName: string,
1644
1654
  input: Record<string, unknown>,
1645
- ): TelegramInterrupt | undefined {
1655
+ ): { kind: TelegramInterrupt["kind"]; payload: Record<string, unknown>; reaction: boolean } | undefined {
1646
1656
  let kind: TelegramInterrupt["kind"];
1657
+ let reaction: boolean;
1647
1658
  let payload: Record<string, unknown> = input;
1648
1659
 
1649
1660
  if (toolName === TELEGRAM_APPROVAL_TOOL) {
1650
1661
  kind = "question";
1662
+ reaction = false;
1651
1663
  } else if (toolName === "telegram_send" || toolName === "telegram_react") {
1652
1664
  kind = "message";
1665
+ reaction = toolName === "telegram_react";
1653
1666
  } else if (toolName === "write") {
1654
1667
  const path = input["path"];
1655
1668
  if (
@@ -1660,6 +1673,7 @@ function telegramInterruptFromTool(
1660
1673
  return undefined;
1661
1674
  }
1662
1675
  kind = path === "xd://telegram_ask" ? "question" : "message";
1676
+ reaction = path === "xd://telegram_react";
1663
1677
  const content = input["content"];
1664
1678
  if (typeof content === "string") {
1665
1679
  try {
@@ -1672,6 +1686,46 @@ function telegramInterruptFromTool(
1672
1686
  } else {
1673
1687
  return undefined;
1674
1688
  }
1689
+ return { kind, payload, reaction };
1690
+ }
1691
+
1692
+ /** A named target that is really named: an empty string targets nothing. */
1693
+ function targetNamed(value: unknown): boolean {
1694
+ if (typeof value === "number") return Number.isFinite(value);
1695
+ return typeof value === "string" && value.trim() !== "";
1696
+ }
1697
+
1698
+ /**
1699
+ * The one `telegram_send` shape that silently leaves a forum topic: `thread_id`
1700
+ * falls back to the active topic *only while `chat_id` is omitted*, so naming
1701
+ * the chat alone delivers into the main chat instead of the thread the operator
1702
+ * wrote in (#366). This package cannot rewrite another plugin's arguments, so
1703
+ * the unsafe shape is refused with both safe ones spelled out — a reply the
1704
+ * operator never sees in the thread they asked in is indistinguishable from
1705
+ * silence, and it leaks the answer to everyone reading the main chat.
1706
+ *
1707
+ * Returns `undefined` for every other shape, including a call that names both.
1708
+ */
1709
+ export function telegramTopicRoutingRefusal(payload: Record<string, unknown>): string | undefined {
1710
+ if (!targetNamed(payload["chat_id"])) return undefined;
1711
+ if (targetNamed(payload["thread_id"]) || targetNamed(payload["message_thread_id"])) return undefined;
1712
+ return (
1713
+ "Blocked: this project's Telegram is topic-routed, and thread_id defaults to the active topic only while chat_id is omitted — " +
1714
+ "naming chat_id alone delivers into the main chat instead of the topic the message arrived in (#366). " +
1715
+ "Nothing was sent. Answer in the active message's own topic by omitting BOTH chat_id and thread_id, or pass both together. " +
1716
+ 'On a locally injected tick there is no active topic to keep: run `omp-conductor message --text "<the message>"`, ' +
1717
+ "which resolves this project's own chat and topic from config."
1718
+ );
1719
+ }
1720
+
1721
+ function telegramInterruptFromTool(
1722
+ toolName: string,
1723
+ input: Record<string, unknown>,
1724
+ ): TelegramInterrupt | undefined {
1725
+ const call = directTelegramCall(toolName, input);
1726
+ if (call === undefined) return undefined;
1727
+ let kind = call.kind;
1728
+ const payload = call.payload;
1675
1729
  if (
1676
1730
  kind === "message" &&
1677
1731
  typeof payload["text"] === "string" &&
@@ -1725,6 +1779,34 @@ function telegramInterruptFromTool(
1725
1779
 
1726
1780
  type TelegramInterruptBlock = { block: true; reason: string };
1727
1781
 
1782
+ /**
1783
+ * Topic routing is checked for **every** direct Telegram *delivery* this
1784
+ * session makes, autonomous or not: the incident was a reply to a waiting
1785
+ * human, which the autonomous gate below deliberately exempts (#366). A
1786
+ * flat-chat project has no thread to lose and is left exactly as it was, and a
1787
+ * reaction addresses an exact `message_id` — it carries no thread argument at
1788
+ * all, so there is nothing there to get wrong.
1789
+ */
1790
+ function telegramRoutingBlock(
1791
+ configuredProject: string | undefined,
1792
+ event: { toolName: string; input: Record<string, unknown> },
1793
+ ): TelegramInterruptBlock | undefined {
1794
+ const call = directTelegramCall(event.toolName, event.input);
1795
+ if (call === undefined || call.reaction) return undefined;
1796
+ let topicRouted: boolean;
1797
+ try {
1798
+ topicRouted = findProject(loadConfig(), configuredProject).escalation.telegramTopicId !== undefined;
1799
+ } catch {
1800
+ // An unreadable or ambiguous config says nothing about topics. The
1801
+ // autonomous gate already fails closed on it for locally injected ticks,
1802
+ // and a human-waiting reply must not become unanswerable because of it.
1803
+ return undefined;
1804
+ }
1805
+ if (!topicRouted) return undefined;
1806
+ const reason = telegramTopicRoutingRefusal(call.payload);
1807
+ return reason === undefined ? undefined : { block: true, reason };
1808
+ }
1809
+
1728
1810
  /**
1729
1811
  * A locally injected tick is an autonomous actor. Its direct Telegram calls
1730
1812
  * therefore pass through the same category and availability decision as daemon
@@ -2447,7 +2529,14 @@ export default function orchestratorTickExtension(pi: TickApi): void {
2447
2529
  ctx: unknown,
2448
2530
  ) => TelegramInterruptBlock | undefined,
2449
2531
  ): void;
2450
- }).on("tool_call", (event) => autonomousTelegramInterruptBlock(pi, session, event));
2532
+ }).on(
2533
+ "tool_call",
2534
+ (event) =>
2535
+ // Routing first: a call that cannot reach the right thread is refused
2536
+ // whether or not the availability policy would have let it through.
2537
+ telegramRoutingBlock(configuredProject, event) ??
2538
+ autonomousTelegramInterruptBlock(pi, session, event),
2539
+ );
2451
2540
  };
2452
2541
 
2453
2542
  pi.on("session_start", (_event, ctx) => {
package/src/reports.ts CHANGED
@@ -288,6 +288,53 @@ export function telegramReportSend(p: ProjectConfig): ReportSend {
288
288
  };
289
289
  }
290
290
 
291
+ /**
292
+ * One direct message to the operator, addressed from config rather than from
293
+ * whichever chat last spoke to the session (#366).
294
+ *
295
+ * `telegram_send` keeps the active forum topic only while it names no chat, and
296
+ * a locally injected tick has no active message to inherit one from — so a
297
+ * fleet whose Telegram is a topic had no path from an autonomous turn into its
298
+ * own thread, and answers surfaced in the main chat instead. This addresses the
299
+ * project's configured chat and topic, and it is deliberately *not* a way past
300
+ * the operator's interrupt policy: the same availability decision an autonomous
301
+ * Telegram tool call gets is applied here, and a deferred message is durably
302
+ * held for the digest or the working-hours catch-up rather than sent anyway.
303
+ *
304
+ * The `QUESTION:` marker is the floor's own spelling for "this needs an
305
+ * answer", so one prefix means one category whichever surface it leaves by.
306
+ */
307
+ export type OperatorMessageOutcome =
308
+ | { kind: "sent"; category: InterruptCategory }
309
+ | { kind: "held"; category: InterruptCategory; noticeId: string; reason: "availability" | "digest" };
310
+
311
+ export function operatorMessageCategory(text: string): InterruptCategory {
312
+ return /^\s*QUESTION:\s/i.test(text) ? "decision-needed" : "material";
313
+ }
314
+
315
+ export async function deliverOperatorMessage(
316
+ project: ProjectConfig,
317
+ text: string,
318
+ deps: { store: Store; at: number; noticeId: string; send?: ReportSend },
319
+ ): Promise<OperatorMessageOutcome> {
320
+ const category = operatorMessageCategory(text);
321
+ const disposition = interruptDisposition(project.reporting, category, deps.at);
322
+ if (disposition !== "interrupt") {
323
+ deps.store.addHeldNotice({
324
+ id: deps.noticeId,
325
+ project: project.name,
326
+ category,
327
+ summary: text.split("\n", 1)[0]!.slice(0, 240),
328
+ detail: text,
329
+ createdAt: deps.at,
330
+ ...(disposition === "availability" ? { releaseOnAvailable: true } : {}),
331
+ });
332
+ return { kind: "held", category, noticeId: deps.noticeId, reason: disposition };
333
+ }
334
+ await (deps.send ?? telegramReportSend(project))(text);
335
+ return { kind: "sent", category };
336
+ }
337
+
291
338
  /** Keep the mechanical catch-up comfortably inside Telegram's report wrapper. */
292
339
  const AVAILABILITY_REPORT_BODY_LIMIT = 3_200;
293
340
  const AVAILABILITY_REPORT_KEY_PREFIX = "availability/";