omp-conductor 0.15.7 → 0.15.8

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -1497,7 +1497,10 @@ omitted — so an orchestrator that helpfully supplied `chat_id` (and nothing
1497
1497
  else) answered three topic messages in the main chat instead (#366). The
1498
1498
  orchestrator floor now says to name **neither** target or **both**, and the
1499
1499
  dispatcher refuses `chat_id` without `thread_id` for a project that configured
1500
- `escalation.telegramTopicId`. A flat-chat project is unaffected.
1500
+ `escalation.telegramTopicId`. A flat-chat project is unaffected. When a pinned
1501
+ topic id has gone stale — the bridge re-claims pane topics across restarts — the
1502
+ live claim named for the project is used instead, so a restart does not quietly
1503
+ move every page into the main chat (#407).
1501
1504
 
1502
1505
  A locally injected tick has no inbound message to inherit a topic from, so a
1503
1506
  bare `telegram_send` there has nothing to preserve. That turn addresses the
@@ -1924,6 +1927,7 @@ Field notes:
1924
1927
  | `migrations` | Optional, per repo: `{ "dir": "backend/alembic/versions" }`. Names the repo-relative directory of an Alembic-style ordered migration chain (`revision` / `down_revision` in `*.py`). When set, `conductor_pr_merge` **refuses** a merge that would corrupt the chain at the base tip: reusing a revision id another file already declares, deleting a published migration, or a merge that would leave the combined graph with more than one head (so a stale parent is refused, and a fork-repair merge migration that unifies the heads passes). Absent means the repo opts out of the chain check entirely. Repo-relative only: a leading `/` or `..` is an error. |
1925
1928
  | `caps` | Per-project overrides; omit it or pin only the fields you want to change. |
1926
1929
  | `escalation.fallbackToIssueComment` | Defaults to `true`. Absent means "yes, still tell me". |
1930
+ | `escalation.telegramTopicId` | Optional forum topic for everything conductor sends: tier-2 pages, reports, digests, arm challenges, `omp-conductor message`. Setup offers the topics omp-telegram has claimed. The bridge re-claims a pane's topic across restarts — including the restarts `upgrade` and `restart` perform — so a pinned id that is no longer claimed is replaced at send time by the live claim whose name matches the project, logged without ids (#407). A pin that is still claimed always wins, so a deliberately separate topic is never hijacked. Absent keeps flat-chat behaviour. |
1927
1931
  | `escalation.orchestrator` | Optional; `"embedded"` (default) or `"external"`. `external` means an orchestrator session already runs elsewhere: the daemon starts none, and tier-1 escalations post as issue comments for that session to drain. Any other value is an error. |
1928
1932
  | `authority` | Optional; `{ "merge": …, "release": … }`, each `"human"` (default) or `"orchestrator"`. It grants nothing to the daemon — it words the orchestrator's standing orders and the Releases paragraph of the rendered brief, so the config and the prompt cannot disagree about who holds the merge button. Unknown keys and any other value are errors, never folded to the default. |
1929
1933
  | `releasePolicy` | Optional; a per-shape map whose values are `"human"` (default) or `"orchestrator"`. Shapes are `version-bump-pr`, `git-tag`, `git-push-tags`, `package-publish`, `github-release`, and `deploy`. The legacy `"none"` denies every shape; legacy `"operator-brief"` grants the artifact-producing shapes, including reviewed version preparation, but keeps deploy human-owned. The in-session tripwire blocks recognised raw release/deploy calls before execution. Every rejection is written to `release-policy-blocks.jsonl`; the heartbeat carries that day's count into the daily digest. This is the mechanical gate; `authority.release` still says who owns the decision. |
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "omp-conductor",
3
- "version": "0.15.7",
3
+ "version": "0.15.8",
4
4
  "type": "module",
5
5
  "license": "MIT",
6
6
  "description": "A 24/7 dispatcher that takes ready GitHub issues to green, mergeable PRs using omp coding sessions, with tiered escalation first to an orchestrator session and then to a human.",
package/src/cli.ts CHANGED
@@ -1367,8 +1367,9 @@ try {
1367
1367
  }
1368
1368
  process.stdout.write(
1369
1369
  outcome.kind === "sent"
1370
- ? `message delivered to ${project.name}'s configured Telegram ` +
1371
- `${project.escalation.telegramTopicId === undefined ? "chat" : "project topic"} (${outcome.category})\n`
1370
+ ? // Not "into the topic": a stale topic degrades to the flat chat with
1371
+ // its own warning on stderr, and this line must not contradict it.
1372
+ `message delivered to ${project.name}'s configured Telegram target (${outcome.category})\n`
1372
1373
  : `held notice ${outcome.noticeId} queued for ${project.name} (${outcome.category}; ` +
1373
1374
  `${outcome.reason === "availability" ? "outside the availability window" : "a digest-only category"})\n` +
1374
1375
  "nothing was sent; the daemon releases it with the next digest or working-hours catch-up\n",
package/src/escalate.ts CHANGED
@@ -269,7 +269,7 @@ export function createEscalator(
269
269
  // the tick re-raises it. A *report* has no such source — it exists
270
270
  // once, in the model's head, and nothing regenerates it — which is
271
271
  // why that one needed a ledger and this one does not.
272
- await sendTelegram(token, chatId, text, { topicId: p.escalation.telegramTopicId });
272
+ await sendTelegram(token, chatId, text, { topicId: resolveProjectTopicId(p) });
273
273
  store.markNotified(key);
274
274
  return;
275
275
  }
@@ -350,6 +350,74 @@ export function readTelegramToken(): string | undefined {
350
350
  return undefined;
351
351
  }
352
352
 
353
+ /**
354
+ * The forum topic a project's Telegram actually lives in *now*.
355
+ *
356
+ * `escalation.telegramTopicId` is pinned by the wizard from the topics
357
+ * omp-telegram had claimed at setup time — but the bridge re-claims a pane's
358
+ * topic across restarts, and conductor restarts that service itself during
359
+ * `upgrade` and `restart`. The pinned id therefore goes stale on the fleet's
360
+ * own maintenance, and every topic-addressed send silently degrades to the main
361
+ * chat: tier-2 pages, reports, digests, arm challenges, direct messages (#407).
362
+ *
363
+ * The operator's pin still wins whenever it is live. Only a pin that is
364
+ * *provably* absent from the current claims is replaced, and only by the claim
365
+ * whose name is this project's — so a deliberately separate alerts topic is
366
+ * never hijacked by the pane's own thread. Unreadable bridge state changes
367
+ * nothing, and #318's stale-topic retry remains the last line of defence.
368
+ *
369
+ * The substitution is logged, naming the project and the claim's name. Never an
370
+ * id: a log line is a place these leak from.
371
+ */
372
+ export function resolveProjectTopicId(project: ProjectConfig): number | undefined {
373
+ const pinned = project.escalation.telegramTopicId;
374
+ if (pinned === undefined) return undefined;
375
+ const claims = claimedTelegramTopics();
376
+ if (claims.length === 0) return pinned;
377
+ if (claims.some((claim) => claim.threadId === pinned)) return pinned;
378
+ const named = claims.find((claim) => claim.name === project.name);
379
+ if (named === undefined) return pinned;
380
+ warn(
381
+ `escalation.telegramTopicId for ${project.name} is no longer a claimed topic; ` +
382
+ `following omp-telegram's current "${named.name}" claim instead`,
383
+ );
384
+ return named.threadId;
385
+ }
386
+
387
+ /**
388
+ * omp-telegram's live topic claims: `{ threads: { "<threadId>": { name } } }`.
389
+ * Borrowed exactly as the token is, and tolerant of every shape it might not
390
+ * be — a bridge that has never run a forum pane has no file at all.
391
+ *
392
+ * `stateDir` is passed by the setup wizard, which has already probed for the
393
+ * bridge; send-time callers let it resolve the same way the token does.
394
+ */
395
+ export function claimedTelegramTopics(stateDir?: string): Array<{ threadId: number; name: string }> {
396
+ const override = stateDir?.trim() ?? process.env.OMP_TELEGRAM_STATE_DIR?.trim();
397
+ const dir = override ? override : join(homedir(), ".omp", "agent", "telegram");
398
+ let raw: unknown;
399
+ try {
400
+ raw = JSON.parse(readFileSync(join(dir, "threads.json"), "utf8"));
401
+ } catch {
402
+ return [];
403
+ }
404
+ if (typeof raw !== "object" || raw === null || Array.isArray(raw) || !("threads" in raw)) return [];
405
+ const threads = raw.threads;
406
+ if (typeof threads !== "object" || threads === null || Array.isArray(threads)) return [];
407
+ const out: Array<{ threadId: number; name: string }> = [];
408
+ for (const [id, entry] of Object.entries(threads)) {
409
+ const threadId = Number(id);
410
+ if (!Number.isFinite(threadId) || !Number.isSafeInteger(threadId)) continue;
411
+ let name = id;
412
+ if (typeof entry === "object" && entry !== null && "name" in entry) {
413
+ const candidate = entry.name;
414
+ if (typeof candidate === "string" && candidate.trim() !== "") name = candidate.trim();
415
+ }
416
+ out.push({ threadId, name });
417
+ }
418
+ return out;
419
+ }
420
+
353
421
  /**
354
422
  * The one Telegram send in this package. Exported so the report outbox (#123)
355
423
  * reuses it rather than forking it: the response handling below is load-bearing
package/src/fleet.ts CHANGED
@@ -29,7 +29,7 @@ import { homedir } from "node:os";
29
29
  import { dirname, join } from "node:path";
30
30
  import { formatZonedMinute } from "./availability.ts";
31
31
  import { findProject, loadConfig, stateDir } from "./config.ts";
32
- import { sendTelegram } from "./escalate.ts";
32
+ import { resolveProjectTopicId, sendTelegram } from "./escalate.ts";
33
33
  import { planUsageLine, readPlanUsage, sharedUsageSource } from "./usage.ts";
34
34
  import { readApprovalSurface, readDaemonProfile } from "./approval-surface.ts";
35
35
  import { inspectBriefLayout } from "./brief-upgrade.ts";
@@ -349,12 +349,13 @@ export async function armTicks(projectName?: string, deps: ArmDeps = {}): Promis
349
349
  `Reply to this chat with exactly:\n${code}\n` +
350
350
  `Nothing will be dispatched until that reply is seen in the orchestrator session.`;
351
351
 
352
- // Prefer the project's configured forum topic so arm challenges land where
353
- // escalations already do (#318). Missing project config keeps flat-chat 0.13.
352
+ // Prefer the project's live forum topic so arm challenges land where
353
+ // escalations already do (#318), following the bridge's current claim when the
354
+ // pinned id has gone stale (#407). Missing project config keeps flat-chat 0.13.
354
355
  let topicId: number | undefined;
355
356
  if (named !== undefined) {
356
357
  try {
357
- topicId = findProject(loadConfig(), named).escalation.telegramTopicId;
358
+ topicId = resolveProjectTopicId(findProject(loadConfig(), named));
358
359
  } catch {
359
360
  /* no project config */
360
361
  }
package/src/reports.ts CHANGED
@@ -37,7 +37,7 @@
37
37
  */
38
38
 
39
39
  import { availabilityDisposition, interruptDisposition } from "./availability.ts";
40
- import { readTelegramToken, sendTelegram, TelegramSendError } from "./escalate.ts";
40
+ import { readTelegramToken, resolveProjectTopicId, sendTelegram, TelegramSendError } from "./escalate.ts";
41
41
  import { localDayKey } from "./digest-schedule.ts";
42
42
  import {
43
43
  DIGEST_BACKLOG_LIMIT,
@@ -284,7 +284,7 @@ export function telegramReportSend(p: ProjectConfig): ReportSend {
284
284
  "definitive",
285
285
  );
286
286
  }
287
- return await sendTelegram(token, chatId, text, { topicId: p.escalation.telegramTopicId });
287
+ return await sendTelegram(token, chatId, text, { topicId: resolveProjectTopicId(p) });
288
288
  };
289
289
  }
290
290
 
@@ -21,6 +21,7 @@ import {
21
21
  resolveCaps,
22
22
  saveConfig,
23
23
  } from "./config.ts";
24
+ import { claimedTelegramTopics } from "./escalate.ts";
24
25
  import { hostRamBytes, recommendedMaxWorkers, workerOvercommit } from "./host.ts";
25
26
  import {
26
27
  prepareConductor,
@@ -1208,7 +1209,7 @@ async function askTelegramTopicId(
1208
1209
  stateDir: string,
1209
1210
  prior: number | undefined,
1210
1211
  ): Promise<number | undefined> {
1211
- const claimed = readClaimedTelegramTopics(stateDir);
1212
+ const claimed = claimedTelegramTopics(stateDir);
1212
1213
  const manual = "Enter thread id manually";
1213
1214
  const none = "None — flat chat (0.13 behaviour)";
1214
1215
  if (claimed.length > 0) {
@@ -1246,29 +1247,6 @@ async function askTelegramTopicId(
1246
1247
  return n;
1247
1248
  }
1248
1249
 
1249
- function readClaimedTelegramTopics(stateDir: string): Array<{ threadId: number; name: string }> {
1250
- try {
1251
- const raw: unknown = JSON.parse(readFileSync(join(stateDir, "threads.json"), "utf8"));
1252
- if (typeof raw !== "object" || raw === null || Array.isArray(raw)) return [];
1253
- if (!("threads" in raw)) return [];
1254
- const threads = raw.threads;
1255
- if (typeof threads !== "object" || threads === null || Array.isArray(threads)) return [];
1256
- const out: Array<{ threadId: number; name: string }> = [];
1257
- for (const [id, entry] of Object.entries(threads)) {
1258
- const threadId = Number(id);
1259
- if (!Number.isFinite(threadId) || !Number.isSafeInteger(threadId)) continue;
1260
- let name = id;
1261
- if (typeof entry === "object" && entry !== null && "name" in entry) {
1262
- const candidate = entry.name;
1263
- if (typeof candidate === "string" && candidate.trim() !== "") name = candidate.trim();
1264
- }
1265
- out.push({ threadId, name });
1266
- }
1267
- return out;
1268
- } catch {
1269
- return [];
1270
- }
1271
- }
1272
1250
 
1273
1251
  /** How loud the orchestrator is and when the operator permits interruptions. */
1274
1252
  const askReporting: AreaAsker = async (ui, a) => {