@sayknow-cli/coding-agent 0.5.0 → 0.5.2

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 (45) hide show
  1. package/CHANGELOG.md +157 -0
  2. package/dist/types/commands/session.d.ts +7 -0
  3. package/dist/types/config/telegram-autostart.d.ts +9 -1
  4. package/dist/types/modes/components/pet-capability.d.ts +8 -7
  5. package/dist/types/modes/components/pet-selector.d.ts +1 -1
  6. package/dist/types/modes/components/sayknow-pet-widget.d.ts +1 -1
  7. package/dist/types/modes/shared/agent-wire/unattended-session.d.ts +7 -0
  8. package/dist/types/modes/shared/agent-wire/workflow-gate-broker.d.ts +2 -0
  9. package/dist/types/session/agent-session.d.ts +1 -0
  10. package/dist/types/skc-runtime/boot-generation.d.ts +59 -0
  11. package/dist/types/skc-runtime/launch-tmux.d.ts +10 -2
  12. package/dist/types/skc-runtime/session-restore-runtime.d.ts +41 -0
  13. package/dist/types/skc-runtime/session-restore.d.ts +99 -0
  14. package/dist/types/skc-runtime/tmux-owner-isolation.d.ts +160 -0
  15. package/dist/types/skc-runtime/tmux-sessions.d.ts +26 -1
  16. package/dist/types/tools/ask.d.ts +164 -4
  17. package/package.json +10 -7
  18. package/src/commands/session.ts +88 -2
  19. package/src/config/model-registry.ts +12 -0
  20. package/src/config/telegram-autostart.ts +11 -4
  21. package/src/defaults/skc/skills/deep-interview/SKILL.md +29 -3
  22. package/src/internal-urls/docs-index.generated.ts +1 -1
  23. package/src/main.ts +1 -1
  24. package/src/modes/components/pet-capability.ts +22 -13
  25. package/src/modes/components/pet-selector.ts +1 -1
  26. package/src/modes/components/sayknow-pet-widget.ts +41 -7
  27. package/src/modes/controllers/event-controller.ts +1 -1
  28. package/src/modes/shared/agent-wire/unattended-session.ts +40 -9
  29. package/src/modes/shared/agent-wire/workflow-gate-broker.ts +2 -0
  30. package/src/notifications/lifecycle-control-runtime.ts +258 -179
  31. package/src/prompts/system/eager-todo.md +2 -0
  32. package/src/prompts/system/plan-mode-approved.md +1 -1
  33. package/src/prompts/system/system-prompt.md +4 -2
  34. package/src/sdk/bus/lifecycle-control-runtime.ts +189 -110
  35. package/src/session/agent-session.ts +31 -11
  36. package/src/skc-runtime/boot-generation.ts +172 -0
  37. package/src/skc-runtime/launch-tmux.ts +219 -41
  38. package/src/skc-runtime/session-restore-runtime.ts +120 -0
  39. package/src/skc-runtime/session-restore.ts +296 -0
  40. package/src/skc-runtime/session-state-sidecar.ts +41 -0
  41. package/src/skc-runtime/tmux-owner-isolation.ts +665 -0
  42. package/src/skc-runtime/tmux-sessions.ts +284 -108
  43. package/src/slash-commands/builtin-registry.ts +9 -4
  44. package/src/tools/ask.ts +183 -10
  45. package/src/tools/eval.ts +2 -2
@@ -12,6 +12,7 @@ import * as fs from "node:fs/promises";
12
12
 
13
13
  import * as path from "node:path";
14
14
  import { openRecoveryFsRoot } from "@sayknow-cli/natives";
15
+ import { getAgentDir } from "@sayknow-cli/utils/dirs";
15
16
  import { isCompiledBinary } from "@sayknow-cli/utils/env";
16
17
  import { parseLinuxProcStartTime } from "./linux-proc";
17
18
 
@@ -1453,6 +1454,670 @@ async function releaseVerdictLock(token: string): Promise<void> {
1453
1454
  }
1454
1455
  }
1455
1456
 
1457
+ /**
1458
+ * Shared identity create fence (frozen contract F1'-F9''', architect-approved).
1459
+ *
1460
+ * Concurrent creators of the SAME canonical `(stateDir, sessionId)` identity must
1461
+ * not both spawn a child: `SessionManager` opens transcripts with append flags and
1462
+ * has no inter-process single-writer lock, so two owners on one transcript is a
1463
+ * data-integrity failure rather than a recoverable skip.
1464
+ *
1465
+ * The fence deliberately does NOT hold the SQLite writer transaction across the
1466
+ * spawn. `bootstrapTmuxOwnerIsolation()` runs in a separate process and acquires
1467
+ * this very database twice itself, and the synchronous creator blocks on that
1468
+ * helper through `Bun.spawnSync`, so a held transaction would deadlock the helper
1469
+ * against its own parent. A long hold would also starve the 250 ms verdict path
1470
+ * that shares this database. Instead each transition takes a short (target 50 ms)
1471
+ * transaction that only reads and writes a durable reservation row; mutual
1472
+ * exclusion outlives the transaction as row state, not as a held lock.
1473
+ */
1474
+ export type IdentityCreatePhase = "reserved" | "helper_invoked" | "spawned" | "tagged" | "published";
1475
+
1476
+ const IDENTITY_CREATE_PHASE_ORDER: readonly IdentityCreatePhase[] = [
1477
+ "reserved",
1478
+ "helper_invoked",
1479
+ "spawned",
1480
+ "tagged",
1481
+ "published",
1482
+ ];
1483
+
1484
+ /** Phases at or beyond which an untagged child may already exist on the server. */
1485
+ export function identityCreatePhaseMayHaveChild(phase: IdentityCreatePhase): boolean {
1486
+ return IDENTITY_CREATE_PHASE_ORDER.indexOf(phase) >= IDENTITY_CREATE_PHASE_ORDER.indexOf("helper_invoked");
1487
+ }
1488
+
1489
+ export interface IdentityCreateKey {
1490
+ stateDir: string;
1491
+ sessionId: string;
1492
+ stateFile: string;
1493
+ }
1494
+
1495
+ export interface IdentityCreateReservation {
1496
+ reservationId: string;
1497
+ stateDir: string;
1498
+ sessionId: string;
1499
+ stateFile: string;
1500
+ ownerPid: number;
1501
+ ownerIncarnation: string;
1502
+ phase: IdentityCreatePhase;
1503
+ attemptSessionName: string | null;
1504
+ nativeSessionId: string | null;
1505
+ serverPid: number | null;
1506
+ serverStartTime: string | null;
1507
+ claimedAt: string;
1508
+ updatedAt: string;
1509
+ leaseDeadline: string;
1510
+ }
1511
+
1512
+ export type IdentityCreateReservationResult =
1513
+ | { ok: true; reservation: IdentityCreateReservation; recovered: IdentityCreateReservation | null }
1514
+ | {
1515
+ ok: false;
1516
+ code:
1517
+ | "identity_reserved_live"
1518
+ | "identity_reserved_unknown"
1519
+ | "identity_fence_contended"
1520
+ | "identity_incarnation_unavailable"
1521
+ | "identity_orphan_unresolved"
1522
+ | "identity_existing_owner";
1523
+ existing: IdentityCreateReservation | null;
1524
+ diagnostic: string;
1525
+ };
1526
+
1527
+ /** Owner liveness is tri-state: only a proven-dead owner may be displaced. */
1528
+ export type OwnerLiveness = "alive" | "dead" | "unknown";
1529
+
1530
+ /**
1531
+ * `processIncarnation()` returns `undefined` both for a vanished process and for a
1532
+ * failed probe, so existence is resolved first. Signal 0 distinguishes them:
1533
+ * `ESRCH` proves absence, `EPERM` proves a live process owned by another user, and
1534
+ * anything else is unknown. Only then does the incarnation discriminate PID reuse.
1535
+ */
1536
+ export function probeOwnerLiveness(
1537
+ pid: number,
1538
+ recordedIncarnation: string,
1539
+ deps: { readIncarnation?: (pid: number) => string | undefined; signal?: (pid: number) => void } = {},
1540
+ ): OwnerLiveness {
1541
+ if (!Number.isSafeInteger(pid) || pid <= 0) return "unknown";
1542
+ const signal = deps.signal ?? ((target: number) => process.kill(target, 0));
1543
+ try {
1544
+ signal(pid);
1545
+ } catch (error) {
1546
+ const code = (error as { code?: unknown }).code;
1547
+ if (code === "ESRCH") return "dead";
1548
+ if (code !== "EPERM") return "unknown";
1549
+ }
1550
+ const readIncarnation = deps.readIncarnation ?? defaultOwnerIncarnationReader;
1551
+ const current = readIncarnation(pid);
1552
+ if (current === undefined) return "unknown";
1553
+ return current === recordedIncarnation ? "alive" : "dead";
1554
+ }
1555
+
1556
+ let ownerIncarnationReader: ((pid: number) => string | undefined) | null = null;
1557
+
1558
+ function defaultOwnerIncarnationReader(pid: number): string | undefined {
1559
+ if (!ownerIncarnationReader) {
1560
+ // Imported lazily: the Darwin reader opens a FFI handle, which must not be
1561
+ // paid by callers that never touch the fence.
1562
+ const module = require("../sdk/broker/process-incarnation") as {
1563
+ processIncarnation: (pid: number) => string | undefined;
1564
+ };
1565
+ ownerIncarnationReader = module.processIncarnation;
1566
+ }
1567
+ return ownerIncarnationReader(pid);
1568
+ }
1569
+
1570
+ /** @internal Test seam for the incarnation reader. */
1571
+ export function __setOwnerIncarnationReaderForTests(reader: ((pid: number) => string | undefined) | null): void {
1572
+ ownerIncarnationReader = reader;
1573
+ }
1574
+
1575
+ /** Raw paths may be relative or alias the same inode; the fence key must not. */
1576
+ function canonicalIdentityPath(value: string): string {
1577
+ const resolved = path.resolve(value);
1578
+ try {
1579
+ return fsSync.realpathSync.native(resolved);
1580
+ } catch {
1581
+ return resolved;
1582
+ }
1583
+ }
1584
+
1585
+ export function canonicalIdentityCreateKey(key: IdentityCreateKey): IdentityCreateKey {
1586
+ return {
1587
+ stateDir: canonicalIdentityPath(key.stateDir),
1588
+ sessionId: key.sessionId,
1589
+ stateFile: canonicalIdentityPath(key.stateFile),
1590
+ };
1591
+ }
1592
+
1593
+ const IDENTITY_FENCE_WINDOW_MS = 250;
1594
+ const IDENTITY_RESERVATION_DEFAULT_TTL_MS = 30_000;
1595
+
1596
+ const IDENTITY_RESERVATION_TABLE = `CREATE TABLE IF NOT EXISTS identity_create_reservation (
1597
+ state_dir TEXT NOT NULL,
1598
+ session_id TEXT NOT NULL,
1599
+ state_file TEXT NOT NULL,
1600
+ reservation_id TEXT NOT NULL,
1601
+ owner_pid INTEGER NOT NULL,
1602
+ owner_incarnation TEXT NOT NULL,
1603
+ phase TEXT NOT NULL,
1604
+ attempt_session_name TEXT,
1605
+ native_session_id TEXT,
1606
+ server_pid INTEGER,
1607
+ server_start_time TEXT,
1608
+ claimed_at TEXT NOT NULL,
1609
+ updated_at TEXT NOT NULL,
1610
+ lease_deadline TEXT NOT NULL,
1611
+ PRIMARY KEY (state_dir, session_id)
1612
+ )`;
1613
+
1614
+ interface ReservationRow {
1615
+ state_dir: string;
1616
+ session_id: string;
1617
+ state_file: string;
1618
+ reservation_id: string;
1619
+ owner_pid: number;
1620
+ owner_incarnation: string;
1621
+ phase: string;
1622
+ attempt_session_name: string | null;
1623
+ native_session_id: string | null;
1624
+ server_pid: number | null;
1625
+ server_start_time: string | null;
1626
+ claimed_at: string;
1627
+ updated_at: string;
1628
+ lease_deadline: string;
1629
+ }
1630
+
1631
+ function reservationFromRow(row: ReservationRow): IdentityCreateReservation {
1632
+ return {
1633
+ reservationId: row.reservation_id,
1634
+ stateDir: row.state_dir,
1635
+ sessionId: row.session_id,
1636
+ stateFile: row.state_file,
1637
+ ownerPid: row.owner_pid,
1638
+ ownerIncarnation: row.owner_incarnation,
1639
+ phase: (IDENTITY_CREATE_PHASE_ORDER as readonly string[]).includes(row.phase)
1640
+ ? (row.phase as IdentityCreatePhase)
1641
+ : "reserved",
1642
+ attemptSessionName: row.attempt_session_name,
1643
+ nativeSessionId: row.native_session_id,
1644
+ serverPid: row.server_pid,
1645
+ serverStartTime: row.server_start_time,
1646
+ claimedAt: row.claimed_at,
1647
+ updatedAt: row.updated_at,
1648
+ leaseDeadline: row.lease_deadline,
1649
+ };
1650
+ }
1651
+
1652
+ /**
1653
+ * Runs `operation` inside one short write transaction on the identity's lock
1654
+ * database. The transaction is committed (or rolled back) before returning, so it
1655
+ * is never held across a spawn, a helper process, or a tag.
1656
+ */
1657
+ /**
1658
+ * The single fence database, under SKC's own agent directory.
1659
+ *
1660
+ * The lifecycle root (`<stateDir>/<sessionId>/owner-lifecycle`) cannot host it: a
1661
+ * create that is about to be REFUSED must leave no filesystem trace, and that
1662
+ * directory may not exist yet. SKC's agent directory always exists, so the fence
1663
+ * is always reachable and creators can fail closed instead of proceeding unfenced.
1664
+ *
1665
+ * One shared file rather than one per identity. Reservations are rows keyed by
1666
+ * `(state_dir, session_id)`, so a single database keeps this state bounded
1667
+ * forever instead of accreting a file per session that nothing ever collects.
1668
+ * Deleting per-identity files on release was the alternative and is unsafe: on
1669
+ * POSIX an unlink while another process holds the database open leaves that
1670
+ * process working against an orphaned inode while a third creates a fresh file,
1671
+ * which would silently split the fence in two. Cross-identity contention is the
1672
+ * accepted cost, bounded by a window that only reads and writes one row.
1673
+ */
1674
+ function identityFenceDatabaseFile(): string {
1675
+ return path.join(getAgentDir(), "identity-create-fence.sqlite");
1676
+ }
1677
+
1678
+ function withIdentityFenceWindow<T>(operation: (db: Database) => T): T | null {
1679
+ const lockDatabaseFile = identityFenceDatabaseFile();
1680
+ let db: Database;
1681
+ try {
1682
+ fsSync.mkdirSync(path.dirname(lockDatabaseFile), { recursive: true, mode: 0o700 });
1683
+ db = new Database(lockDatabaseFile);
1684
+ } catch {
1685
+ return null;
1686
+ }
1687
+ try {
1688
+ try {
1689
+ fsSync.chmodSync(lockDatabaseFile, 0o600);
1690
+ } catch {}
1691
+ db.exec(`PRAGMA busy_timeout = ${IDENTITY_FENCE_WINDOW_MS}`);
1692
+ db.exec("BEGIN IMMEDIATE");
1693
+ } catch {
1694
+ db.close();
1695
+ return null;
1696
+ }
1697
+ try {
1698
+ db.exec(IDENTITY_RESERVATION_TABLE);
1699
+ const result = operation(db);
1700
+ db.exec("COMMIT");
1701
+ return result;
1702
+ } catch (error) {
1703
+ try {
1704
+ db.exec("ROLLBACK");
1705
+ } catch {}
1706
+ throw error;
1707
+ } finally {
1708
+ db.close();
1709
+ }
1710
+ }
1711
+
1712
+ /**
1713
+ * Reservation ids whose creating attempt is still executing in THIS process.
1714
+ *
1715
+ * A row owned by our own live process is ambiguous: it is either a concurrent
1716
+ * in-flight attempt (which must still block us) or our own abandoned attempt
1717
+ * from an earlier failure that deliberately kept its evidence. Only the second
1718
+ * may be recovered, and only this set can tell them apart — process liveness
1719
+ * cannot, because both cases are "the owner is alive".
1720
+ */
1721
+ const inFlightReservations = new Set<string>();
1722
+
1723
+ /**
1724
+ * Ends the attempt while KEEPING the durable row.
1725
+ *
1726
+ * Used when cleanup after a spawn was uncertain: a child may survive that we
1727
+ * could not remove, so the evidence must outlive the attempt. Unlike
1728
+ * `releaseIdentityCreate` this leaves the row for authority-first recovery.
1729
+ */
1730
+ export function abandonIdentityCreate(reservation: IdentityCreateReservation): void {
1731
+ inFlightReservations.delete(reservation.reservationId);
1732
+ }
1733
+
1734
+ export interface ReserveIdentityCreateOptions {
1735
+ ttlMs?: number;
1736
+ ownerPid?: number;
1737
+ ownerIncarnation?: string;
1738
+ now?: () => Date;
1739
+ probeLiveness?: (pid: number, incarnation: string) => OwnerLiveness;
1740
+ }
1741
+
1742
+ function reservationLease(now: Date, ttlMs: number): string {
1743
+ return new Date(now.getTime() + Math.max(0, ttlMs)).toISOString();
1744
+ }
1745
+
1746
+ function selectReservation(db: Database, key: IdentityCreateKey): IdentityCreateReservation | null {
1747
+ const row = db
1748
+ .query("SELECT * FROM identity_create_reservation WHERE state_dir = ? AND session_id = ?")
1749
+ .get(key.stateDir, key.sessionId) as ReservationRow | null;
1750
+ return row ? reservationFromRow(row) : null;
1751
+ }
1752
+
1753
+ function writeReservation(db: Database, reservation: IdentityCreateReservation): void {
1754
+ db.query(
1755
+ `INSERT OR REPLACE INTO identity_create_reservation (
1756
+ state_dir, session_id, state_file, reservation_id, owner_pid, owner_incarnation, phase,
1757
+ attempt_session_name, native_session_id, server_pid, server_start_time,
1758
+ claimed_at, updated_at, lease_deadline
1759
+ ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
1760
+ ).run(
1761
+ reservation.stateDir,
1762
+ reservation.sessionId,
1763
+ reservation.stateFile,
1764
+ reservation.reservationId,
1765
+ reservation.ownerPid,
1766
+ reservation.ownerIncarnation,
1767
+ reservation.phase,
1768
+ reservation.attemptSessionName,
1769
+ reservation.nativeSessionId,
1770
+ reservation.serverPid,
1771
+ reservation.serverStartTime,
1772
+ reservation.claimedAt,
1773
+ reservation.updatedAt,
1774
+ reservation.leaseDeadline,
1775
+ );
1776
+ }
1777
+
1778
+ function freshReservation(
1779
+ key: IdentityCreateKey,
1780
+ options: ReserveIdentityCreateOptions,
1781
+ incarnation: string,
1782
+ ): IdentityCreateReservation {
1783
+ const now = (options.now ?? (() => new Date()))();
1784
+ const stamp = now.toISOString();
1785
+ return {
1786
+ reservationId: crypto.randomUUID(),
1787
+ stateDir: key.stateDir,
1788
+ sessionId: key.sessionId,
1789
+ stateFile: key.stateFile,
1790
+ ownerPid: options.ownerPid ?? process.pid,
1791
+ ownerIncarnation: incarnation,
1792
+ phase: "reserved",
1793
+ attemptSessionName: null,
1794
+ nativeSessionId: null,
1795
+ serverPid: null,
1796
+ serverStartTime: null,
1797
+ claimedAt: stamp,
1798
+ updatedAt: stamp,
1799
+ leaseDeadline: reservationLease(now, options.ttlMs ?? IDENTITY_RESERVATION_DEFAULT_TTL_MS),
1800
+ };
1801
+ }
1802
+
1803
+ /**
1804
+ * Claims the identity when it is free, and otherwise reports why not.
1805
+ *
1806
+ * A dead previous owner is deliberately NOT displaced here: authority-first
1807
+ * recovery has to census live tmux, which shells out and must never run inside
1808
+ * the fence window. The caller performs that census and then calls
1809
+ * {@link reclaimIdentityCreate}.
1810
+ *
1811
+ * The lease deadline is diagnostic only. Expiry never authorizes takeover; only a
1812
+ * proven-dead owner does, because real creator paths block in unbounded
1813
+ * `Bun.spawnSync` and cannot heartbeat while blocked.
1814
+ */
1815
+ export function reserveIdentityCreate(
1816
+ rawKey: IdentityCreateKey,
1817
+ options: ReserveIdentityCreateOptions = {},
1818
+ ): IdentityCreateReservationResult {
1819
+ const key = canonicalIdentityCreateKey(rawKey);
1820
+ const ownerPid = options.ownerPid ?? process.pid;
1821
+ const incarnation = options.ownerIncarnation ?? defaultOwnerIncarnationReader(ownerPid);
1822
+ if (incarnation === undefined) {
1823
+ return {
1824
+ ok: false,
1825
+ code: "identity_incarnation_unavailable",
1826
+ existing: null,
1827
+ diagnostic: "own_process_incarnation_unavailable",
1828
+ };
1829
+ }
1830
+ const probeLiveness = options.probeLiveness ?? ((pid, recorded) => probeOwnerLiveness(pid, recorded));
1831
+ const outcome = withIdentityFenceWindow<IdentityCreateReservationResult>(db => {
1832
+ const existing = selectReservation(db, key);
1833
+ if (!existing) {
1834
+ const reservation = freshReservation(key, options, incarnation);
1835
+ writeReservation(db, reservation);
1836
+ inFlightReservations.add(reservation.reservationId);
1837
+ return { ok: true, reservation, recovered: null };
1838
+ }
1839
+ if (existing.stateFile !== key.stateFile) {
1840
+ return {
1841
+ ok: false,
1842
+ code: "identity_reserved_unknown",
1843
+ existing,
1844
+ diagnostic: "state_file_mismatch",
1845
+ };
1846
+ }
1847
+ const liveness = probeLiveness(existing.ownerPid, existing.ownerIncarnation);
1848
+ if (liveness === "alive") {
1849
+ // Our own row from a finished attempt that kept its evidence: we are the
1850
+ // only party that can safely resolve it, and refusing forever would brick
1851
+ // the identity for the rest of this process's life.
1852
+ const ourOwnAbandonedRow =
1853
+ existing.ownerPid === ownerPid &&
1854
+ existing.ownerIncarnation === incarnation &&
1855
+ !inFlightReservations.has(existing.reservationId);
1856
+ if (!ourOwnAbandonedRow) {
1857
+ return { ok: false, code: "identity_reserved_live", existing, diagnostic: `phase=${existing.phase}` };
1858
+ }
1859
+ if (!identityCreatePhaseMayHaveChild(existing.phase)) {
1860
+ const reservation = freshReservation(key, options, incarnation);
1861
+ writeReservation(db, reservation);
1862
+ inFlightReservations.add(reservation.reservationId);
1863
+ return { ok: true, reservation, recovered: existing };
1864
+ }
1865
+ return {
1866
+ ok: false,
1867
+ code: "identity_orphan_unresolved",
1868
+ existing,
1869
+ diagnostic: `self_abandoned phase=${existing.phase} census_required`,
1870
+ };
1871
+ }
1872
+ if (liveness === "unknown") {
1873
+ return {
1874
+ ok: false,
1875
+ code: "identity_reserved_unknown",
1876
+ existing,
1877
+ diagnostic: `owner_liveness_unknown phase=${existing.phase}`,
1878
+ };
1879
+ }
1880
+ // Authority-first recovery, branch 1: a dead owner that never reached the
1881
+ // helper cannot have produced a child, so no census is needed. Reclaiming
1882
+ // here is what stops a crashed or force-killed creator from bricking the
1883
+ // identity forever — without it every later create aborts on a tombstone.
1884
+ if (!identityCreatePhaseMayHaveChild(existing.phase)) {
1885
+ const reservation = freshReservation(key, options, incarnation);
1886
+ writeReservation(db, reservation);
1887
+ inFlightReservations.add(reservation.reservationId);
1888
+ return { ok: true, reservation, recovered: existing };
1889
+ }
1890
+ // Branches 2-5 need a live tmux census (canonical tags plus the current
1891
+ // published generation) that shells out and must not run inside this
1892
+ // window. The caller performs it and then calls reclaimIdentityCreate().
1893
+ return {
1894
+ ok: false,
1895
+ code: "identity_orphan_unresolved",
1896
+ existing,
1897
+ diagnostic: `owner_dead phase=${existing.phase} census_required`,
1898
+ };
1899
+ });
1900
+ if (outcome === null) {
1901
+ return {
1902
+ ok: false,
1903
+ code: "identity_fence_contended",
1904
+ existing: null,
1905
+ diagnostic: "fence_window_unavailable",
1906
+ };
1907
+ }
1908
+ return outcome;
1909
+ }
1910
+
1911
+ /**
1912
+ * Completes authority-first recovery after the caller proved, outside the fence
1913
+ * window, that the abandoned reservation left no authoritative child behind.
1914
+ *
1915
+ * `expectedReservationId` pins the exact abandoned row: if another process already
1916
+ * recovered it, the row no longer matches and this fails closed rather than
1917
+ * producing a second child.
1918
+ */
1919
+ export function reclaimIdentityCreate(
1920
+ rawKey: IdentityCreateKey,
1921
+ expectedReservationId: string,
1922
+ options: ReserveIdentityCreateOptions = {},
1923
+ ): IdentityCreateReservationResult {
1924
+ const key = canonicalIdentityCreateKey(rawKey);
1925
+ const ownerPid = options.ownerPid ?? process.pid;
1926
+ const incarnation = options.ownerIncarnation ?? defaultOwnerIncarnationReader(ownerPid);
1927
+ if (incarnation === undefined) {
1928
+ return {
1929
+ ok: false,
1930
+ code: "identity_incarnation_unavailable",
1931
+ existing: null,
1932
+ diagnostic: "own_process_incarnation_unavailable",
1933
+ };
1934
+ }
1935
+ const probeLiveness = options.probeLiveness ?? ((pid, recorded) => probeOwnerLiveness(pid, recorded));
1936
+ const outcome = withIdentityFenceWindow<IdentityCreateReservationResult>(db => {
1937
+ const existing = selectReservation(db, key);
1938
+ if (!existing || existing.reservationId !== expectedReservationId) {
1939
+ return {
1940
+ ok: false,
1941
+ code: "identity_orphan_unresolved",
1942
+ existing,
1943
+ diagnostic: "reservation_changed_during_recovery",
1944
+ };
1945
+ }
1946
+ const liveness = probeLiveness(existing.ownerPid, existing.ownerIncarnation);
1947
+ const ourOwnAbandonedRow =
1948
+ existing.ownerPid === ownerPid &&
1949
+ existing.ownerIncarnation === incarnation &&
1950
+ !inFlightReservations.has(existing.reservationId);
1951
+ if (liveness !== "dead" && !ourOwnAbandonedRow) {
1952
+ return {
1953
+ ok: false,
1954
+ code: liveness === "alive" ? "identity_reserved_live" : "identity_reserved_unknown",
1955
+ existing,
1956
+ diagnostic: `owner_resurrected liveness=${liveness}`,
1957
+ };
1958
+ }
1959
+ const reservation = freshReservation(key, options, incarnation);
1960
+ writeReservation(db, reservation);
1961
+ inFlightReservations.add(reservation.reservationId);
1962
+ return { ok: true, reservation, recovered: existing };
1963
+ });
1964
+ if (outcome === null) {
1965
+ return {
1966
+ ok: false,
1967
+ code: "identity_fence_contended",
1968
+ existing: null,
1969
+ diagnostic: "fence_window_unavailable",
1970
+ };
1971
+ }
1972
+ return outcome;
1973
+ }
1974
+
1975
+ /**
1976
+ * What a live-tmux census concluded about the child an abandoned reservation may
1977
+ * have left behind. `unknown` is not a soft failure: it is the fail-closed case.
1978
+ */
1979
+ export type AbandonedIdentityVerdict =
1980
+ | { kind: "authoritative"; nativeSessionId: string }
1981
+ | { kind: "orphan"; nativeSessionId: string }
1982
+ | { kind: "absent" }
1983
+ | { kind: "unknown"; reason: string };
1984
+
1985
+ /**
1986
+ * Injected so this module never imports the tmux session helpers (which already
1987
+ * depend on it). The census shells out and therefore runs OUTSIDE the fence
1988
+ * window, by construction.
1989
+ */
1990
+ export interface AbandonedIdentityCensus {
1991
+ inspect(evidence: {
1992
+ stateDir: string;
1993
+ sessionId: string;
1994
+ attemptSessionName: string | null;
1995
+ nativeSessionId: string | null;
1996
+ }): AbandonedIdentityVerdict;
1997
+ cleanupOrphan(nativeSessionId: string, attemptSessionName: string | null): void;
1998
+ }
1999
+
2000
+ /**
2001
+ * Authority-first recovery for a reservation whose owner is proven dead and that
2002
+ * reached at least `helper_invoked`, so an untagged child may exist.
2003
+ *
2004
+ * The reservation's own `phase` is a hint, never the authority: a creator can
2005
+ * publish its generation and die before recording `published`. The census reads
2006
+ * the live canonical tags and the current published generation instead, so a
2007
+ * valid child is preserved rather than killed — which is what keeps a
2008
+ * tag-before-report crash from producing a successor.
2009
+ */
2010
+ export function recoverAbandonedIdentityCreate(
2011
+ key: IdentityCreateKey,
2012
+ existing: IdentityCreateReservation,
2013
+ census: AbandonedIdentityCensus,
2014
+ options: ReserveIdentityCreateOptions = {},
2015
+ ): IdentityCreateReservationResult {
2016
+ const canonical = canonicalIdentityCreateKey(key);
2017
+ const verdict = census.inspect({
2018
+ stateDir: canonical.stateDir,
2019
+ sessionId: canonical.sessionId,
2020
+ attemptSessionName: existing.attemptSessionName,
2021
+ nativeSessionId: existing.nativeSessionId,
2022
+ });
2023
+ if (verdict.kind === "unknown") {
2024
+ return {
2025
+ ok: false,
2026
+ code: "identity_orphan_unresolved",
2027
+ existing,
2028
+ diagnostic: `census_unknown:${verdict.reason}`,
2029
+ };
2030
+ }
2031
+ if (verdict.kind === "authoritative") {
2032
+ // A valid published child is still serving this identity. Preserve it and
2033
+ // tell the caller an owner exists; creating a second one is never correct.
2034
+ return {
2035
+ ok: false,
2036
+ code: "identity_existing_owner",
2037
+ existing,
2038
+ diagnostic: `authoritative_child:${verdict.nativeSessionId}`,
2039
+ };
2040
+ }
2041
+ if (verdict.kind === "orphan") {
2042
+ try {
2043
+ census.cleanupOrphan(verdict.nativeSessionId, existing.attemptSessionName);
2044
+ } catch (error) {
2045
+ return {
2046
+ ok: false,
2047
+ code: "identity_orphan_unresolved",
2048
+ existing,
2049
+ diagnostic: `orphan_cleanup_failed:${error instanceof Error ? error.message : String(error)}`,
2050
+ };
2051
+ }
2052
+ }
2053
+ return reclaimIdentityCreate(canonical, existing.reservationId, options);
2054
+ }
2055
+
2056
+ export interface IdentityCreatePhasePatch {
2057
+ attemptSessionName?: string | null;
2058
+ nativeSessionId?: string | null;
2059
+ serverPid?: number | null;
2060
+ serverStartTime?: string | null;
2061
+ }
2062
+
2063
+ /**
2064
+ * Records a create-progress transition and renews the lease.
2065
+ *
2066
+ * Returns `null` when the reservation is no longer ours. The caller MUST treat
2067
+ * that as a lost fence and perform no tmux mutation: another process has already
2068
+ * recovered this identity.
2069
+ */
2070
+ export function advanceIdentityCreatePhase(
2071
+ reservation: IdentityCreateReservation,
2072
+ phase: IdentityCreatePhase,
2073
+ patch: IdentityCreatePhasePatch = {},
2074
+ options: Pick<ReserveIdentityCreateOptions, "ttlMs" | "now"> = {},
2075
+ ): IdentityCreateReservation | null {
2076
+ const key: IdentityCreateKey = {
2077
+ stateDir: reservation.stateDir,
2078
+ sessionId: reservation.sessionId,
2079
+ stateFile: reservation.stateFile,
2080
+ };
2081
+ const now = (options.now ?? (() => new Date()))();
2082
+ return withIdentityFenceWindow<IdentityCreateReservation | null>(db => {
2083
+ const current = selectReservation(db, key);
2084
+ if (!current || current.reservationId !== reservation.reservationId) return null;
2085
+ const updated: IdentityCreateReservation = {
2086
+ ...current,
2087
+ phase,
2088
+ attemptSessionName:
2089
+ patch.attemptSessionName !== undefined ? patch.attemptSessionName : current.attemptSessionName,
2090
+ nativeSessionId: patch.nativeSessionId !== undefined ? patch.nativeSessionId : current.nativeSessionId,
2091
+ serverPid: patch.serverPid !== undefined ? patch.serverPid : current.serverPid,
2092
+ serverStartTime: patch.serverStartTime !== undefined ? patch.serverStartTime : current.serverStartTime,
2093
+ updatedAt: now.toISOString(),
2094
+ leaseDeadline: reservationLease(now, options.ttlMs ?? IDENTITY_RESERVATION_DEFAULT_TTL_MS),
2095
+ };
2096
+ writeReservation(db, updated);
2097
+ return updated;
2098
+ });
2099
+ }
2100
+
2101
+ /** Releases the reservation. Idempotent, and never deletes a successor's row. */
2102
+ export function releaseIdentityCreate(reservation: IdentityCreateReservation): void {
2103
+ inFlightReservations.delete(reservation.reservationId);
2104
+ const key: IdentityCreateKey = {
2105
+ stateDir: reservation.stateDir,
2106
+ sessionId: reservation.sessionId,
2107
+ stateFile: reservation.stateFile,
2108
+ };
2109
+ try {
2110
+ withIdentityFenceWindow<void>(db => {
2111
+ db.query(
2112
+ "DELETE FROM identity_create_reservation WHERE state_dir = ? AND session_id = ? AND reservation_id = ?",
2113
+ ).run(key.stateDir, key.sessionId, reservation.reservationId);
2114
+ });
2115
+ } catch {
2116
+ // A failed release leaves a row whose owner is provably dead once this
2117
+ // process exits, which the authority-first recovery path resolves.
2118
+ }
2119
+ }
2120
+
1456
2121
  const ALLOWED_TERMINAL_EXIT_KINDS = new Set(["owner_lost", "cleanup", "process_postmortem", "exit"]);
1457
2122
  const ALLOWED_TERMINAL_REASONS = new Set([
1458
2123
  "tmux_session_missing",