@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.
- package/CHANGELOG.md +157 -0
- package/dist/types/commands/session.d.ts +7 -0
- package/dist/types/config/telegram-autostart.d.ts +9 -1
- package/dist/types/modes/components/pet-capability.d.ts +8 -7
- package/dist/types/modes/components/pet-selector.d.ts +1 -1
- package/dist/types/modes/components/sayknow-pet-widget.d.ts +1 -1
- package/dist/types/modes/shared/agent-wire/unattended-session.d.ts +7 -0
- package/dist/types/modes/shared/agent-wire/workflow-gate-broker.d.ts +2 -0
- package/dist/types/session/agent-session.d.ts +1 -0
- package/dist/types/skc-runtime/boot-generation.d.ts +59 -0
- package/dist/types/skc-runtime/launch-tmux.d.ts +10 -2
- package/dist/types/skc-runtime/session-restore-runtime.d.ts +41 -0
- package/dist/types/skc-runtime/session-restore.d.ts +99 -0
- package/dist/types/skc-runtime/tmux-owner-isolation.d.ts +160 -0
- package/dist/types/skc-runtime/tmux-sessions.d.ts +26 -1
- package/dist/types/tools/ask.d.ts +164 -4
- package/package.json +10 -7
- package/src/commands/session.ts +88 -2
- package/src/config/model-registry.ts +12 -0
- package/src/config/telegram-autostart.ts +11 -4
- package/src/defaults/skc/skills/deep-interview/SKILL.md +29 -3
- package/src/internal-urls/docs-index.generated.ts +1 -1
- package/src/main.ts +1 -1
- package/src/modes/components/pet-capability.ts +22 -13
- package/src/modes/components/pet-selector.ts +1 -1
- package/src/modes/components/sayknow-pet-widget.ts +41 -7
- package/src/modes/controllers/event-controller.ts +1 -1
- package/src/modes/shared/agent-wire/unattended-session.ts +40 -9
- package/src/modes/shared/agent-wire/workflow-gate-broker.ts +2 -0
- package/src/notifications/lifecycle-control-runtime.ts +258 -179
- package/src/prompts/system/eager-todo.md +2 -0
- package/src/prompts/system/plan-mode-approved.md +1 -1
- package/src/prompts/system/system-prompt.md +4 -2
- package/src/sdk/bus/lifecycle-control-runtime.ts +189 -110
- package/src/session/agent-session.ts +31 -11
- package/src/skc-runtime/boot-generation.ts +172 -0
- package/src/skc-runtime/launch-tmux.ts +219 -41
- package/src/skc-runtime/session-restore-runtime.ts +120 -0
- package/src/skc-runtime/session-restore.ts +296 -0
- package/src/skc-runtime/session-state-sidecar.ts +41 -0
- package/src/skc-runtime/tmux-owner-isolation.ts +665 -0
- package/src/skc-runtime/tmux-sessions.ts +284 -108
- package/src/slash-commands/builtin-registry.ts +9 -4
- package/src/tools/ask.ts +183 -10
- 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",
|