@gajae-code/coding-agent 0.12.5 → 0.12.6

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 (127) hide show
  1. package/CHANGELOG.md +32 -0
  2. package/README.md +4 -1
  3. package/dist/types/eval/backend.d.ts +2 -2
  4. package/dist/types/exec/bash-executor.d.ts +10 -8
  5. package/dist/types/extensibility/extensions/runner.d.ts +9 -7
  6. package/dist/types/gjc-runtime/launch-tmux.d.ts +22 -3
  7. package/dist/types/gjc-runtime/psmux-detect.d.ts +3 -0
  8. package/dist/types/gjc-runtime/team-launch.d.ts +3 -1
  9. package/dist/types/gjc-runtime/team-runtime.d.ts +14 -2
  10. package/dist/types/gjc-runtime/team-store.d.ts +0 -4
  11. package/dist/types/gjc-runtime/team-workers.d.ts +5 -0
  12. package/dist/types/gjc-runtime/tmux-common.d.ts +1 -0
  13. package/dist/types/gjc-runtime/tmux-provider-context.d.ts +57 -0
  14. package/dist/types/gjc-runtime/tmux-sessions.d.ts +8 -1
  15. package/dist/types/gjc-runtime/ultragoal-change-set.d.ts +3 -0
  16. package/dist/types/gjc-runtime/ultragoal-review-source.d.ts +83 -0
  17. package/dist/types/gjc-runtime/ultragoal-runtime.d.ts +57 -4
  18. package/dist/types/internal-urls/registry-helpers.d.ts +18 -0
  19. package/dist/types/modes/components/gjc-bundle-settings.d.ts +1 -0
  20. package/dist/types/modes/components/plugin-settings.d.ts +2 -0
  21. package/dist/types/modes/components/settings-selector.d.ts +2 -0
  22. package/dist/types/sdk/broker/broker.d.ts +18 -0
  23. package/dist/types/sdk/broker/lifecycle-ledger.d.ts +13 -0
  24. package/dist/types/sdk/broker/lifecycle.d.ts +1 -0
  25. package/dist/types/sdk/bus/chat-daemon-control.d.ts +4 -3
  26. package/dist/types/sdk/bus/index.d.ts +4 -0
  27. package/dist/types/sdk/bus/telegram-daemon-contract.d.ts +4 -2
  28. package/dist/types/sdk/bus/telegram-daemon.d.ts +25 -3
  29. package/dist/types/sdk/bus/topic-registry.d.ts +63 -3
  30. package/dist/types/sdk/host/host.d.ts +23 -2
  31. package/dist/types/sdk/host/reverse-leases.d.ts +7 -4
  32. package/dist/types/session/agent-session.d.ts +5 -4
  33. package/dist/types/session/artifacts.d.ts +8 -5
  34. package/dist/types/session/attempt-record-store.d.ts +19 -0
  35. package/dist/types/session/client-bridge.d.ts +14 -0
  36. package/dist/types/session/internal/managed-session-storage.d.ts +5 -2
  37. package/dist/types/session/messages.d.ts +4 -1
  38. package/dist/types/session/session-manager.d.ts +6 -4
  39. package/dist/types/session/session-storage.d.ts +8 -0
  40. package/dist/types/session/streaming-output.d.ts +32 -0
  41. package/dist/types/task/receipt.d.ts +2 -0
  42. package/dist/types/task/types.d.ts +178 -0
  43. package/dist/types/tools/bash.d.ts +1 -1
  44. package/dist/types/tools/fetch.d.ts +3 -0
  45. package/dist/types/tools/output-meta.d.ts +37 -5
  46. package/dist/types/tools/tool-result.d.ts +1 -0
  47. package/dist/types/utils/artifact-id.d.ts +2 -0
  48. package/package.json +8 -8
  49. package/scripts/capture-sticky-viewport-showcase.ts +131 -5
  50. package/scripts/verify-gjc-sdk-canonicalization.ts +128 -183
  51. package/scripts/verify-sticky-viewport-showcase.ts +620 -93
  52. package/src/config/model-profiles.ts +8 -0
  53. package/src/config/settings-schema.ts +17 -3
  54. package/src/cursor.ts +20 -13
  55. package/src/defaults/gjc/skills/ultragoal/SKILL.md +5 -2
  56. package/src/eval/backend.ts +2 -2
  57. package/src/exec/bash-executor.ts +425 -151
  58. package/src/extensibility/extensions/runner.ts +80 -6
  59. package/src/extensibility/extensions/wrapper.ts +22 -15
  60. package/src/extensibility/runtime-skill-discovery.ts +4 -6
  61. package/src/gjc-runtime/launch-tmux.ts +309 -293
  62. package/src/gjc-runtime/managed-owner-supervisor.ts +6 -2
  63. package/src/gjc-runtime/psmux-detect.ts +52 -6
  64. package/src/gjc-runtime/team-launch.ts +50 -2
  65. package/src/gjc-runtime/team-runtime.ts +716 -251
  66. package/src/gjc-runtime/team-store.ts +25 -7
  67. package/src/gjc-runtime/team-workers.ts +161 -1
  68. package/src/gjc-runtime/tmux-common.ts +16 -4
  69. package/src/gjc-runtime/tmux-gc.ts +37 -5
  70. package/src/gjc-runtime/tmux-owner-isolation.ts +2 -0
  71. package/src/gjc-runtime/tmux-provider-context.ts +451 -0
  72. package/src/gjc-runtime/tmux-sessions.ts +409 -156
  73. package/src/gjc-runtime/ultragoal-change-set.ts +90 -44
  74. package/src/gjc-runtime/ultragoal-receipt-freshness.ts +7 -0
  75. package/src/gjc-runtime/ultragoal-review-source.ts +378 -0
  76. package/src/gjc-runtime/ultragoal-runtime.ts +640 -159
  77. package/src/gjc-runtime/workflow-manifest.generated.json +40 -1
  78. package/src/gjc-runtime/workflow-manifest.ts +12 -0
  79. package/src/internal-urls/agent-protocol.ts +98 -38
  80. package/src/internal-urls/artifact-protocol.ts +28 -17
  81. package/src/internal-urls/docs-index.generated.ts +5 -5
  82. package/src/internal-urls/local-protocol.ts +39 -18
  83. package/src/internal-urls/registry-helpers.ts +62 -0
  84. package/src/modes/acp/acp-agent.ts +51 -13
  85. package/src/modes/acp/acp-client-bridge.ts +154 -11
  86. package/src/modes/components/gjc-bundle-settings.ts +2 -0
  87. package/src/modes/components/plugin-settings.ts +5 -0
  88. package/src/modes/components/settings-selector.ts +4 -0
  89. package/src/modes/controllers/selector-controller.ts +1 -0
  90. package/src/prompts/tools/bash.md +2 -2
  91. package/src/prompts/tools/monitor.md +4 -4
  92. package/src/sdk/acp/adapter.ts +200 -9
  93. package/src/sdk/broker/broker.ts +93 -10
  94. package/src/sdk/broker/lifecycle-ledger.ts +227 -11
  95. package/src/sdk/broker/lifecycle.ts +691 -92
  96. package/src/sdk/bus/chat-daemon-control.ts +32 -6
  97. package/src/sdk/bus/index.ts +347 -14
  98. package/src/sdk/bus/telegram-daemon-contract.ts +4 -2
  99. package/src/sdk/bus/telegram-daemon.ts +283 -44
  100. package/src/sdk/bus/topic-registry.ts +133 -10
  101. package/src/sdk/host/host.ts +187 -9
  102. package/src/sdk/host/reverse-leases.ts +249 -42
  103. package/src/sdk/session.ts +7 -6
  104. package/src/session/agent-session.ts +930 -354
  105. package/src/session/artifacts.ts +211 -74
  106. package/src/session/attempt-record-store.ts +106 -0
  107. package/src/session/client-bridge.ts +15 -0
  108. package/src/session/internal/managed-session-scope.ts +252 -67
  109. package/src/session/internal/managed-session-storage.ts +105 -5
  110. package/src/session/messages.ts +32 -7
  111. package/src/session/session-manager.ts +19 -21
  112. package/src/session/session-storage.ts +146 -24
  113. package/src/session/streaming-output.ts +198 -40
  114. package/src/task/index.ts +45 -0
  115. package/src/task/provider-retry-status.ts +4 -1
  116. package/src/task/receipt.ts +4 -0
  117. package/src/task/render.ts +30 -0
  118. package/src/task/types.ts +34 -0
  119. package/src/tools/bash.ts +818 -239
  120. package/src/tools/eval.ts +99 -4
  121. package/src/tools/fetch.ts +77 -15
  122. package/src/tools/find.ts +7 -3
  123. package/src/tools/monitor.ts +7 -1
  124. package/src/tools/output-meta.ts +706 -116
  125. package/src/tools/search.ts +9 -4
  126. package/src/tools/tool-result.ts +1 -0
  127. package/src/utils/artifact-id.ts +6 -0
@@ -160,11 +160,17 @@ export interface TelegramDaemonFs {
160
160
  size?: number;
161
161
  dev?: number;
162
162
  ino?: number;
163
+ /** Hard-link count; required to prove a staging temp has no second name. */
164
+ nlink?: number;
163
165
  ctimeMs?: number;
164
166
  isDirectory?: () => boolean;
165
167
  }>;
166
168
  readEndpointFile?(path: string): Promise<NotificationEndpointFile>;
167
- exactUnlink?(path: string, identity: NotificationEndpointFileIdentity): Promise<NotificationExactUnlinkResult>;
169
+ exactUnlink?(
170
+ path: string,
171
+ identity: NotificationEndpointFileIdentity,
172
+ quarantineName?: string,
173
+ ): Promise<NotificationExactUnlinkResult>;
168
174
  }
169
175
 
170
176
  export interface SpawnResult {
@@ -228,8 +234,12 @@ function negotiateToolActivityCapability(
228
234
  const nodeFs: TelegramDaemonFs = {
229
235
  ...(fs.promises as unknown as TelegramDaemonFs),
230
236
  readEndpointFile: readNotificationEndpointFile,
231
- exactUnlink: async (file, identity) =>
232
- exactUnlinkNotificationFile(file, identity, `.gjc-delete-daemon-transition-${crypto.randomUUID()}.json`),
237
+ exactUnlink: async (file, identity, quarantineName) =>
238
+ exactUnlinkNotificationFile(
239
+ file,
240
+ identity,
241
+ quarantineName ?? `.gjc-delete-daemon-transition-${crypto.randomUUID()}.json`,
242
+ ),
233
243
  };
234
244
 
235
245
  /**
@@ -935,8 +945,9 @@ async function exactUnlinkAcceptedWithRetainedEvidence(
935
945
  fsImpl: TelegramDaemonFs,
936
946
  file: string,
937
947
  identity: NotificationEndpointFileIdentity,
948
+ quarantineName?: string,
938
949
  ): Promise<boolean> {
939
- const removed = await fsImpl.exactUnlink!(file, identity);
950
+ const removed = await fsImpl.exactUnlink!(file, identity, quarantineName);
940
951
  if (removed.ok) return true;
941
952
  return (
942
953
  removed.code === "cleanup_pending" &&
@@ -946,6 +957,27 @@ async function exactUnlinkAcceptedWithRetainedEvidence(
946
957
  );
947
958
  }
948
959
 
960
+ type NotificationArtifactReapOutcome = "removed" | "retained" | "unchanged";
961
+
962
+ /** Distinguish terminal removal from a safely detached artifact that still needs authority-preserving cleanup. */
963
+ async function reapNotificationArtifactExactly(
964
+ fsImpl: TelegramDaemonFs,
965
+ file: string,
966
+ identity: NotificationEndpointFileIdentity,
967
+ quarantineName: string,
968
+ ): Promise<NotificationArtifactReapOutcome> {
969
+ const removed = await fsImpl.exactUnlink!(file, identity, quarantineName);
970
+ if (removed.ok) return "removed";
971
+ if (
972
+ removed.code === "cleanup_pending" &&
973
+ typeof removed.detachedPath === "string" &&
974
+ removed.detachedPath.length > 0 &&
975
+ (await fsImpl.readEndpointFile!(file).catch(() => undefined)) === undefined
976
+ )
977
+ return "retained";
978
+ return "unchanged";
979
+ }
980
+
949
981
  async function unlinkOwnershipLockExactly(
950
982
  fsImpl: TelegramDaemonFs,
951
983
  file: string,
@@ -1287,6 +1319,7 @@ export const NOTIFICATION_LEAK_ARTIFACT_PREFIXES = [
1287
1319
  ".gjc-delete-daemon-transition-",
1288
1320
  ".gjc-exact-unlink-placeholder-",
1289
1321
  ".gjc-delete-notification-endpoint-",
1322
+ ".gjc-delete-notification-staging-temp-",
1290
1323
  ] as const;
1291
1324
 
1292
1325
  /** Grace window before a leak artifact is reaped (covers in-flight unlinks). */
@@ -1298,6 +1331,80 @@ export function isPermanentMissingPathError(error: unknown): boolean {
1298
1331
  return code === "ENOENT" || code === "ENOTDIR";
1299
1332
  }
1300
1333
 
1334
+ /**
1335
+ * Shape of an abandoned `writeJsonAtomic` staging file:
1336
+ * `<published-name>.<pid>.<epoch-ms>.<base36-suffix>.tmp`.
1337
+ *
1338
+ * Publication stages a sibling temp and then renames it over the destination.
1339
+ * If the staging write or the rename fails, or the process dies between the
1340
+ * two, that temp is never published and never read again — no prefix in
1341
+ * {@link NOTIFICATION_LEAK_ARTIFACT_PREFIXES} claimed it, so it accumulated in
1342
+ * the agent notifications directory permanently, once per failed attempt.
1343
+ *
1344
+ * Reaping it here (rather than only unwinding in the writer) also reclaims
1345
+ * temps orphaned by a crash or power loss, which no writer-side cleanup can
1346
+ * reach. Age alone is not proof of abandonment, though: a slow or blocked
1347
+ * publisher (a stalled network write, a rename fenced by an antivirus handle)
1348
+ * can hold a live staged temp well past any grace window. The staged name
1349
+ * therefore carries its publisher's PID, and the reaper only removes a temp
1350
+ * whose publisher is provably dead — see {@link parseNotificationStagingTemp}.
1351
+ */
1352
+ const NOTIFICATION_STAGING_TEMP_PATTERN = /^(?<destination>.+)\.(?<pid>\d+)\.(?<stagedAt>\d+)\.[0-9a-z]+\.tmp$/;
1353
+
1354
+ /** A publication staging claim recovered from an abandoned temp's name. */
1355
+ export interface NotificationStagingTempClaim {
1356
+ /** Published sibling this temp was staged for. */
1357
+ destination: string;
1358
+ /** PID of the process that staged it. */
1359
+ pid: number;
1360
+ /** Wall-clock ms the publisher recorded when it staged the temp. */
1361
+ stagedAtMs: number;
1362
+ }
1363
+
1364
+ /**
1365
+ * Recover the publication claim encoded in a staging temp's name, or
1366
+ * `undefined` when `name` is not a staging temp or its PID/timestamp fields are
1367
+ * not usable integers. An unparseable claim is never reaped.
1368
+ */
1369
+ export function parseNotificationStagingTemp(name: string): NotificationStagingTempClaim | undefined {
1370
+ const groups = NOTIFICATION_STAGING_TEMP_PATTERN.exec(name)?.groups;
1371
+ if (!groups) return undefined;
1372
+ const pid = Number(groups.pid);
1373
+ const stagedAtMs = Number(groups.stagedAt);
1374
+ if (!validDaemonPid(pid) || !Number.isSafeInteger(stagedAtMs) || stagedAtMs < 0) return undefined;
1375
+ return { destination: groups.destination as string, pid, stagedAtMs };
1376
+ }
1377
+
1378
+ /** Liveness verdict for a staging temp's publisher; `unknown` fails closed. */
1379
+ type NotificationPublisherLiveness = "alive" | "dead" | "unknown";
1380
+
1381
+ /**
1382
+ * Classify a staging temp publisher against the daemon's liveness seam. A
1383
+ * throwing probe (an unreadable process table, a denied query) is
1384
+ * indeterminate, not dead, so the temp is retained.
1385
+ */
1386
+ function classifyNotificationStagingPublisher(
1387
+ claim: NotificationStagingTempClaim,
1388
+ pidAlive: (pid: number) => boolean,
1389
+ ): NotificationPublisherLiveness {
1390
+ try {
1391
+ return pidAlive(claim.pid) ? "alive" : "dead";
1392
+ } catch {
1393
+ return "unknown";
1394
+ }
1395
+ }
1396
+
1397
+ /**
1398
+ * True when `file` is a regular file reachable under exactly one name. A
1399
+ * multi-link file shares its inode with another pathname, so unlinking this one
1400
+ * would not reclaim the data and may be another owner's live hardlink. Fails
1401
+ * closed when the `stat` seam cannot report a link count.
1402
+ */
1403
+ async function isSingleLinkRegularFile(fsImpl: TelegramDaemonFs, file: string): Promise<boolean> {
1404
+ if (!fsImpl.stat) return false;
1405
+ const stat = await fsImpl.stat(file);
1406
+ return stat.nlink === 1;
1407
+ }
1301
1408
  export function isNotificationLeakArtifactName(name: string): boolean {
1302
1409
  return NOTIFICATION_LEAK_ARTIFACT_PREFIXES.some(prefix => name.startsWith(prefix));
1303
1410
  }
@@ -1408,21 +1515,89 @@ export async function pruneMissingNotificationRoots(input: {
1408
1515
  return { pruned, remaining };
1409
1516
  }
1410
1517
 
1518
+ /**
1519
+ * Remove one abandoned publication staging temp under a liveness fence and an
1520
+ * identity-bound delete. Returns whether the temp was removed; `false` means it
1521
+ * was deliberately retained (live/indeterminate publisher, not a single-link
1522
+ * regular file, still inside the grace window, or an identity change between
1523
+ * capture and delete). Filesystem faults propagate to the caller's best-effort
1524
+ * handler.
1525
+ */
1526
+ async function reapAbandonedNotificationStagingTemp(input: {
1527
+ fs: TelegramDaemonFs;
1528
+ file: string;
1529
+ claim: NotificationStagingTempClaim;
1530
+ now: number;
1531
+ graceMs: number;
1532
+ pidAlive: (pid: number) => boolean;
1533
+ }): Promise<NotificationArtifactReapOutcome> {
1534
+ // A live or blocked publisher can hold a staged temp far past any grace
1535
+ // window; only a provably dead publisher's claim is abandoned. `unknown`
1536
+ // (throwing probe) fails closed.
1537
+ if (classifyNotificationStagingPublisher(input.claim, input.pidAlive) !== "dead") return "unchanged";
1538
+ const readEndpointFile = input.fs.readEndpointFile;
1539
+ // Both seams are optional; without them there is no no-follow capture and no
1540
+ // identity-bound delete, so retain rather than unlink unfenced.
1541
+ if (!readEndpointFile || !input.fs.exactUnlink) return "unchanged";
1542
+ // No-follow capture: rejects symlinks, directories, and anything that changes
1543
+ // while it is read. A reparse point or dangling link is therefore retained.
1544
+ const endpoint = await readEndpointFile(input.file);
1545
+ if (!(await isSingleLinkRegularFile(input.fs, input.file))) return "unchanged";
1546
+ // Age from the captured (no-follow) mtime rather than a second path-following
1547
+ // stat, so the grace decision and the delete bind the same inode. Integer ns
1548
+ // truncation only ever ages the file, never rejuvenates it.
1549
+ const age = input.now - Number(endpoint.identity.mtimeNs / 1_000_000n);
1550
+ if (age < input.graceMs) return "unchanged";
1551
+ // The native verifies dev+ino+size+mtimeNs+sha256 before unlinking, so a
1552
+ // readdir/capture -> replacement ABA cannot delete the fresh generation.
1553
+ return await reapNotificationArtifactExactly(
1554
+ input.fs,
1555
+ input.file,
1556
+ endpoint.identity,
1557
+ `.gjc-delete-notification-staging-temp-${crypto.randomUUID()}.json`,
1558
+ );
1559
+ }
1560
+
1561
+ /** Reap one retained delete quarantine without following or unlinking a successor. */
1562
+ async function reapNotificationLeakArtifact(input: {
1563
+ fs: TelegramDaemonFs;
1564
+ file: string;
1565
+ now: number;
1566
+ graceMs: number;
1567
+ }): Promise<NotificationArtifactReapOutcome> {
1568
+ if (path.basename(input.file).startsWith(".gjc-exact-unlink-placeholder-")) return "retained";
1569
+ if (!input.fs.readEndpointFile || !input.fs.exactUnlink) return "unchanged";
1570
+ const endpoint = await input.fs.readEndpointFile(input.file);
1571
+ if (!(await isSingleLinkRegularFile(input.fs, input.file))) return "unchanged";
1572
+ const age = input.now - Number(endpoint.identity.mtimeNs / 1_000_000n);
1573
+ if (age < input.graceMs) return "unchanged";
1574
+ return await reapNotificationArtifactExactly(
1575
+ input.fs,
1576
+ input.file,
1577
+ endpoint.identity,
1578
+ `.gjc-exact-unlink-placeholder-${crypto.randomUUID()}.json`,
1579
+ );
1580
+ }
1581
+
1411
1582
  /**
1412
1583
  * Reap retained exact-unlink / ownership-transition quarantine files older than
1413
- * the grace window from the notifications directory.
1584
+ * the grace window from the notifications directory, plus publication staging
1585
+ * temps whose publisher is provably dead.
1414
1586
  */
1415
1587
  export async function reapStaleNotificationArtifacts(input: {
1416
1588
  settings: Settings;
1417
1589
  fs?: TelegramDaemonFs;
1418
1590
  now?: () => number;
1419
1591
  graceMs?: number;
1592
+ /** Liveness seam used to prove a staging temp's publisher is dead. */
1593
+ pidAlive?: (pid: number) => boolean;
1420
1594
  }): Promise<{ removed: string[]; skipped: number }> {
1421
1595
  const fsImpl = input.fs ?? nodeFs;
1422
1596
  const paths = daemonPaths(input.settings.getAgentDir());
1423
1597
  await ensureDir(fsImpl, paths.dir);
1424
1598
  const now = input.now?.() ?? Date.now();
1425
1599
  const graceMs = input.graceMs ?? NOTIFICATION_LEAK_ARTIFACT_GRACE_MS;
1600
+ const pidAlive = input.pidAlive ?? defaultPidAlive;
1426
1601
  const removed: string[] = [];
1427
1602
  let skipped = 0;
1428
1603
  let names: string[];
@@ -1433,17 +1608,33 @@ export async function reapStaleNotificationArtifacts(input: {
1433
1608
  throw error;
1434
1609
  }
1435
1610
  for (const name of names) {
1436
- if (!isNotificationLeakArtifactName(name)) continue;
1611
+ const stagingTemp = NOTIFICATION_STAGING_TEMP_PATTERN.test(name);
1612
+ if (!isNotificationLeakArtifactName(name) && !stagingTemp) continue;
1437
1613
  const file = path.join(paths.dir, name);
1438
1614
  try {
1439
- const stat = fsImpl.stat ? await fsImpl.stat(file) : undefined;
1440
- const age = stat ? now - stat.mtimeMs : Number.POSITIVE_INFINITY;
1441
- if (Number.isFinite(age) && age < graceMs) {
1442
- skipped += 1;
1615
+ if (stagingTemp) {
1616
+ const claim = parseNotificationStagingTemp(name);
1617
+ // A staging-temp shape whose PID/timestamp will not parse carries no
1618
+ // provable claim, so it is retained.
1619
+ if (!claim) {
1620
+ skipped += 1;
1621
+ continue;
1622
+ }
1623
+ const outcome = await reapAbandonedNotificationStagingTemp({
1624
+ fs: fsImpl,
1625
+ file,
1626
+ claim,
1627
+ now,
1628
+ graceMs,
1629
+ pidAlive,
1630
+ });
1631
+ if (outcome === "removed") removed.push(file);
1632
+ else skipped += 1;
1443
1633
  continue;
1444
1634
  }
1445
- await fsImpl.unlink(file);
1446
- removed.push(file);
1635
+ const outcome = await reapNotificationLeakArtifact({ fs: fsImpl, file, now, graceMs });
1636
+ if (outcome === "removed") removed.push(file);
1637
+ else skipped += 1;
1447
1638
  } catch (error) {
1448
1639
  if (isPermanentMissingPathError(error)) continue;
1449
1640
  // Best-effort: a busy file must not fail daemon ownership.
@@ -1462,6 +1653,8 @@ export async function healTelegramDaemonNotificationState(input: {
1462
1653
  fs?: TelegramDaemonFs;
1463
1654
  now?: () => number;
1464
1655
  graceMs?: number;
1656
+ /** Liveness seam used to prove a staging temp's publisher is dead. */
1657
+ pidAlive?: (pid: number) => boolean;
1465
1658
  }): Promise<{ prunedRoots: string[]; removedArtifacts: string[] }> {
1466
1659
  const prune = await pruneMissingNotificationRoots(input);
1467
1660
  const reap = await reapStaleNotificationArtifacts(input);
@@ -5263,6 +5456,7 @@ export class TelegramNotificationDaemon {
5263
5456
  settings: this.opts.settings,
5264
5457
  fs: this.fsImpl,
5265
5458
  now: this.opts.now,
5459
+ pidAlive: this.opts.pidAlive,
5266
5460
  });
5267
5461
  } catch (error) {
5268
5462
  logger.warn(`notifications: leak-artifact reap failed: ${sanitizeDiagnostic(String(error))}`);
@@ -6814,17 +7008,18 @@ export class TelegramNotificationDaemon {
6814
7008
  acceptedTopicId = String(tid);
6815
7009
  this.#malformedTopicCreateEndpoints.delete(sessionId);
6816
7010
  if (capturedCreationLease && !(await this.#awaitCreationLeaseAuthority(capturedCreationLease))) {
6817
- if (
6818
- !this.topics.fenceAcceptedCreateForLease(
6819
- sessionId,
6820
- acceptedTopicId,
6821
- creationLeaseEpoch,
6822
- this.opts.now,
6823
- name,
6824
- creationBinding,
6825
- )
6826
- )
6827
- throw new Error("topic authority was revoked during creation");
7011
+ const fencedCreate = this.topics.fenceAcceptedCreateForLease(
7012
+ sessionId,
7013
+ acceptedTopicId,
7014
+ creationLeaseEpoch,
7015
+ this.opts.now,
7016
+ name,
7017
+ creationBinding,
7018
+ );
7019
+ if (!fencedCreate) throw new Error("topic authority was revoked during creation");
7020
+ // Epoch held when the compensating delete is dispatched below; a
7021
+ // concurrent re-fence must not be settled by this delete's result.
7022
+ const fencedCreateEpoch = fencedCreate.authorityEpoch ?? 0;
6828
7023
  try {
6829
7024
  await this.persistTopics();
6830
7025
  } finally {
@@ -6833,11 +7028,27 @@ export class TelegramNotificationDaemon {
6833
7028
  chat_id: this.opts.chatId,
6834
7029
  message_thread_id: tid,
6835
7030
  });
6836
- acceptedTopicCompensated = topicDeleteSettled(deletion);
6837
-
7031
+ // Remote compensation succeeded, but the transaction is not complete
7032
+ // until the registry clear is durable. `acceptedTopicCompensated` is
7033
+ // what tells outer recovery to stop supervising the fence, so it is
7034
+ // set only after the phase-2 commit: a failed clear persist leaves
7035
+ // the fence supervised instead of stranding a cleared memory state
7036
+ // against a `delete_pending` disk state.
6838
7037
  if (topicDeleteSettled(deletion)) {
6839
- this.topics.settleDelete(sessionId, acceptedTopicId);
6840
- await this.persistTopics();
7038
+ const settled = this.topics.settleDelete(sessionId, acceptedTopicId, fencedCreateEpoch);
7039
+ if (!settled) {
7040
+ this.#superviseCompensationFence(sessionId);
7041
+ await this.#persistTopicsWithRetry().catch(() => undefined);
7042
+ } else
7043
+ try {
7044
+ await this.persistTopics();
7045
+ this.topics.commitSettledDelete(settled);
7046
+ acceptedTopicCompensated = true;
7047
+ } catch {
7048
+ this.topics.rollbackSettledDelete(settled);
7049
+ this.#superviseCompensationFence(sessionId);
7050
+ await this.#persistTopicsWithRetry().catch(() => undefined);
7051
+ }
6841
7052
  } else {
6842
7053
  this.#superviseCompensationFence(sessionId);
6843
7054
  await this.#persistTopicsWithRetry().catch(() => undefined);
@@ -6916,16 +7127,18 @@ export class TelegramNotificationDaemon {
6916
7127
  ) {
6917
7128
  // A failed initial commit must never make compensation conditional on
6918
7129
  // successfully publishing its fence.
6919
- if (
6920
- this.topics.fenceAcceptedCreateForLease(
6921
- sessionId,
6922
- acceptedTopicId,
6923
- creationLeaseEpoch,
6924
- this.opts.now,
6925
- name,
6926
- creationBinding,
6927
- )
6928
- ) {
7130
+ const fencedCompensation = this.topics.fenceAcceptedCreateForLease(
7131
+ sessionId,
7132
+ acceptedTopicId,
7133
+ creationLeaseEpoch,
7134
+ this.opts.now,
7135
+ name,
7136
+ creationBinding,
7137
+ );
7138
+ if (fencedCompensation) {
7139
+ // Epoch held when the compensating delete is dispatched below; a
7140
+ // concurrent re-fence must not be settled by this delete's result.
7141
+ const fencedCompensationEpoch = fencedCompensation.authorityEpoch ?? 0;
6929
7142
  try {
6930
7143
  await this.#persistTopicsWithRetry();
6931
7144
  } catch {
@@ -6938,8 +7151,19 @@ export class TelegramNotificationDaemon {
6938
7151
  message_thread_id: Number(acceptedTopicId),
6939
7152
  });
6940
7153
  if (topicDeleteSettled(deletion)) {
6941
- this.topics.settleDelete(sessionId, acceptedTopicId);
6942
- await this.persistTopics();
7154
+ const settled = this.topics.settleDelete(sessionId, acceptedTopicId, fencedCompensationEpoch);
7155
+ if (!settled) {
7156
+ this.#superviseCompensationFence(sessionId);
7157
+ await this.#persistTopicsWithRetry().catch(() => undefined);
7158
+ } else
7159
+ try {
7160
+ await this.persistTopics();
7161
+ this.topics.commitSettledDelete(settled);
7162
+ } catch {
7163
+ this.topics.rollbackSettledDelete(settled);
7164
+ this.#superviseCompensationFence(sessionId);
7165
+ await this.#persistTopicsWithRetry().catch(() => undefined);
7166
+ }
6943
7167
  } else {
6944
7168
  this.#superviseCompensationFence(sessionId);
6945
7169
  await this.#persistTopicsWithRetry().catch(() => undefined);
@@ -6989,8 +7213,11 @@ export class TelegramNotificationDaemon {
6989
7213
  socketLease?: { session: SessionSocket; token: number; logicalSessionId: string },
6990
7214
  deleteFenceAlreadyPublished = false,
6991
7215
  ): Promise<"pre_dispatch_cancelled" | "post_dispatch_pending" | "settled"> {
6992
- const deleteSnapshot = this.topics.captureDeleteAuthority(sessionId);
6993
7216
  let record = deleteFenceAlreadyPublished ? this.topics.get(sessionId) : this.topics.beginDelete(sessionId);
7217
+ // Authority epoch held for this delete. Captured before any dispatch so a
7218
+ // concurrent scan/close re-fence of the same session cannot be settled by
7219
+ // this delete's definite result.
7220
+ const dispatchedAuthorityEpoch = this.topics.authorityEpoch(sessionId);
6994
7221
  if (socketLease && !this.#deleteLeaseAllows(socketLease)) return "pre_dispatch_cancelled";
6995
7222
  await this.persistTopics();
6996
7223
  if (socketLease && !this.#deleteLeaseAllows(socketLease)) return "pre_dispatch_cancelled";
@@ -7013,7 +7240,13 @@ export class TelegramNotificationDaemon {
7013
7240
  await this.flushPool();
7014
7241
  if (socketLease && !this.#deleteLeaseAllows(socketLease)) return "pre_dispatch_cancelled";
7015
7242
  if (record.topicOrigin === "user_created") {
7016
- this.topics.settleDelete(sessionId, record.topicId);
7243
+ // Phase 1: drop the record but keep the topic id quarantined. Routes are
7244
+ // only republished by `commitSettledDelete` once the clear is durable.
7245
+ const settled = this.topics.settleDelete(sessionId, record.topicId, dispatchedAuthorityEpoch);
7246
+ if (!settled) {
7247
+ await this.#persistTopicsWithRetry().catch(() => undefined);
7248
+ return "post_dispatch_pending";
7249
+ }
7017
7250
  for (const k of [...this.liveMessages.keys()])
7018
7251
  if (k.startsWith(`${sessionId}:`)) {
7019
7252
  this.liveMessages.delete(k);
@@ -7025,9 +7258,10 @@ export class TelegramNotificationDaemon {
7025
7258
  this.pendingThreadedFrames.delete(sessionId);
7026
7259
  try {
7027
7260
  await this.persistTopics();
7261
+ this.topics.commitSettledDelete(settled);
7028
7262
  return "settled";
7029
7263
  } catch {
7030
- this.topics.restoreDeleteFence(deleteSnapshot);
7264
+ this.topics.rollbackSettledDelete(settled);
7031
7265
  await this.#persistTopicsWithRetry().catch(() => undefined);
7032
7266
  return "post_dispatch_pending";
7033
7267
  }
@@ -7037,7 +7271,11 @@ export class TelegramNotificationDaemon {
7037
7271
  message_thread_id: Number(record.topicId),
7038
7272
  })) as { ok?: boolean };
7039
7273
  if (!topicDeleteSettled(res)) return "post_dispatch_pending";
7040
- this.topics.settleDelete(sessionId, record.topicId);
7274
+ const settled = this.topics.settleDelete(sessionId, record.topicId, dispatchedAuthorityEpoch);
7275
+ if (!settled) {
7276
+ await this.#persistTopicsWithRetry().catch(() => undefined);
7277
+ return "post_dispatch_pending";
7278
+ }
7041
7279
  for (const k of [...this.liveMessages.keys()])
7042
7280
  if (k.startsWith(`${sessionId}:`)) {
7043
7281
  this.liveMessages.delete(k);
@@ -7049,9 +7287,10 @@ export class TelegramNotificationDaemon {
7049
7287
  this.pendingThreadedFrames.delete(sessionId);
7050
7288
  try {
7051
7289
  await this.persistTopics();
7290
+ this.topics.commitSettledDelete(settled);
7052
7291
  return "settled";
7053
7292
  } catch {
7054
- this.topics.restoreDeleteFence(deleteSnapshot);
7293
+ this.topics.rollbackSettledDelete(settled);
7055
7294
  await this.#persistTopicsWithRetry().catch(() => undefined);
7056
7295
  return "post_dispatch_pending";
7057
7296
  }