Compare commits

...
Author SHA1 Message Date
v-openrig-build 9d6cfbf5d2 test: model canonical lookup and rig metadata in worker entry fixtures 2026-10-20 10:45:59 +00:00
v-openrig-build db04d7dbd8 fix: require guard target to agree with current logical routing 2026-10-20 10:44:59 +00:00
v-openrig-build d3da20b48a test: cover unrelated exact bindings beside attached logical addresses 2026-10-20 10:42:39 +00:00
v-openrig-build 9854eb03d8 fix: retain binding collision fences and registration read-model compatibility 2026-10-20 10:02:19 +00:00
v-openrig-build b45a9e8d08 Merge remote-tracking branch 'origin/main' into fix/attach-logical-address 2026-10-20 09:05:16 +00:00
v-openrig-build 0e1ff99d84 fix: use current address routing for the successor delivery guard 2026-10-20 09:05:16 +00:00
v-openrig-build 420c66c99f test: bind successor replies to its own delivery guard 2026-10-20 09:03:34 +00:00
v-openrig-build c6dc2bfc19 fix: preserve saved session reads and same-seat replacement addresses 2026-10-20 08:43:54 +00:00
v-openrig-build 870707e64f test: keep same-seat replacement addresses routable 2026-10-20 08:39:51 +00:00
v-openrig-build 7a21b12280 test: restore ordinary registration for replacement handover 2026-10-20 08:38:34 +00:00
v-openrig-build 1ccb086168 test: preserve saved context and rig inference after attach reconnect 2026-10-20 08:35:22 +00:00
v-openrig-build 222dd81de2 style: align address conflict route cases 2026-10-20 08:08:02 +00:00
v-openrig-build 45e7f54349 test: seed retained handover occupants without a new historical-name join 2026-10-20 08:04:54 +00:00
v-openrig-build 4e9d22b0e4 fix: keep saved exact addresses ahead of derived occupant lookups 2026-10-20 08:01:29 +00:00
v-openrig-build a3e89cc26c Merge remote-tracking branch 'origin/main' into fix/attach-logical-address 2026-10-20 07:59:34 +00:00
v-openrig-build 46b570b708 fix: reserve saved logical addresses and preserve handover precedence 2026-10-20 07:59:20 +00:00
v-openrig-build b530b8f4d1 test: reserve saved team addresses before attach effects 2026-10-20 07:56:04 +00:00
v-openrig-build 579f702a19 test: separate attach CLI JSON output from origin diagnostics 2026-10-20 06:01:43 +00:00
v-openrig-build 233a9e9461 test: expose historical raw-address shadow boundary 2026-10-20 05:56:47 +00:00
v-openrig-build 2deb793b70 fix: keep archived physical history from shadowing a current team address 2026-10-20 05:52:39 +00:00
v-openrig-build e5922d987b fix: report address conflicts consistently on adoption and handover routes 2026-10-20 05:49:58 +00:00
v-openrig-build addcd26d62 fix: use the parked diagnosis repository handle for address reads 2026-10-20 05:49:15 +00:00
v-openrig-build 5831d30dd5 test: bind hook fixture to the existing constructor 2026-10-20 05:46:39 +00:00
v-openrig-build 55205fdb1f test: verify real CLI address round trip and custody compatibility 2026-10-20 05:46:18 +00:00
v-openrig-build 6f8283bcb7 fix: carry logical addresses through current identity and read consumers 2026-10-20 05:43:23 +00:00
v-openrig-build c528b2a889 feat: resolve attached team addresses through current physical bindings 2026-10-20 05:38:05 +00:00
v-openrig-build 551d6171b9 test: cover attached logical reply addresses and pre-effect clashes 2026-10-20 05:35:17 +00:00
44 changed files with 884 additions and 87 deletions

No files matched your search

+5 -4
View File
@@ -57,7 +57,7 @@ export function attachCommand(depsOverride?: StatusDeps): Command {
.option("--member <name>", "Member name for pod attach mode")
.option("--runtime <runtime>", "Runtime for pod attach mode; optional guard for node mode")
.option("--cwd <path>", "Working directory to record", process.cwd())
.option("--display-name <name>", "External session/display name to record")
.option("--display-name <name>", "Display label inside tmux; external session name outside tmux")
.option("--print-env", "Print shell exports for OPENRIG_NODE_ID and OPENRIG_SESSION_NAME")
.option("--json", "JSON output")
.action(async (opts: {
@@ -119,9 +119,8 @@ export function attachCommand(depsOverride?: StatusDeps): Command {
body["tmuxSession"] = attachContext.tmuxSession;
if (attachContext.tmuxWindow) body["tmuxWindow"] = attachContext.tmuxWindow;
body["tmuxPane"] = attachContext.tmuxPane;
} else if (opts.displayName) {
body["displayName"] = opts.displayName;
}
if (opts.displayName) body["displayName"] = opts.displayName;
if (hasNode) {
body["logicalId"] = opts.node;
@@ -160,7 +159,9 @@ export function attachCommand(depsOverride?: StatusDeps): Command {
} else {
console.log(`Created node ${logicalId} and attached this shell in rig ${opts.rig}`);
}
console.log(`Session: ${sessionName}`);
console.log(`Sender: ${sessionName}`);
const physicalSession = res.data["sessionName"];
if (typeof physicalSession === "string" && physicalSession !== sessionName) console.log(`Tmux: ${physicalSession}`);
console.log(`Transport: ${
res.data["attachmentType"] === "tmux"
? "tmux"
+2
View File
@@ -94,6 +94,7 @@ function projectCompactWhoami(data: Record<string, unknown>, bounded?: BoundedPe
podNamespace: id["podNamespace"],
memberId: id["memberId"],
sessionName: id["sessionName"],
senderAddress: id["senderAddress"],
runtime: id["runtime"],
},
peers: peers.map((p) => {
@@ -408,6 +409,7 @@ left out. --all-peers lists every one (today's compact output).`)
console.log(`Logical ID: ${id.logicalId}`);
console.log(`Pod: ${(id.podNamespace ?? id.podId) ?? "—"} / ${id.memberId}`);
console.log(`Session: ${id.sessionName ?? "—"}`);
if (id.senderAddress && id.senderAddress !== id.sessionName) console.log(`Sender: ${id.senderAddress}`);
console.log(`Runtime: ${id.runtime}`);
console.log(`Transport: ${id.attachmentType === "external_cli" ? "external_cli (outbound only)" : id.attachmentType}`);
console.log(`Resolved: via ${data.resolvedBy.replace(/_/g, " ")}`);
@@ -0,0 +1,99 @@
// Real Commander commands, DaemonClient, loopback HTTP, daemon routes and SQLite.
// Only the person's tmux metadata and pane effects are inert.
import { describe, expect, it, vi } from "vitest";
import { Command } from "commander";
import { once } from "node:events";
import { mkdtempSync, rmSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { Hono } from "hono";
import { serve } from "@hono/node-server";
import { attachCommand } from "../src/commands/attach.js";
import { sendCommand } from "../src/commands/send.js";
import { whoamiCommand } from "../src/commands/whoami.js";
import { DaemonClient } from "../src/client.js";
import { STATE_FILE, type LifecycleDeps } from "../src/daemon-lifecycle.js";
import { createDb } from "../../daemon/src/db/connection.js";
import { migrate } from "../../daemon/src/db/migrate.js";
import { ALL_MIGRATIONS } from "../../daemon/src/db/all-migrations.js";
import { RigRepository } from "../../daemon/src/domain/rig-repository.js";
import { PodRepository } from "../../daemon/src/domain/pod-repository.js";
import { SessionRegistry } from "../../daemon/src/domain/session-registry.js";
import { EventBus } from "../../daemon/src/domain/event-bus.js";
import { SelfAttachService } from "../../daemon/src/domain/self-attach-service.js";
import { SessionTransport } from "../../daemon/src/domain/session-transport.js";
import { WhoamiService } from "../../daemon/src/domain/whoami-service.js";
import { TranscriptStore } from "../../daemon/src/domain/transcript-store.js";
import { rigsRoutes } from "../../daemon/src/routes/rigs.js";
import { transportRoutes } from "../../daemon/src/routes/transport.js";
import { whoamiRoutes } from "../../daemon/src/routes/whoami.js";
import type { TmuxAdapter } from "../../daemon/src/adapters/tmux.js";
vi.mock("node:child_process", async (actual) => ({ ...await actual<typeof import("node:child_process")>(), execSync: (cmd: string) => {
if (!cmd.startsWith("tmux display-message ")) throw new Error(`Unexpected shell command in inert fixture: ${cmd}`);
return `${process.env.TEST_PHYSICAL_SESSION}\n@12\n%42\n`;
} }));
it.each(["0", "person-terminal"])("real attach -> returned env -> actual From -> reply to original %s pane", async (physical) => {
const home = mkdtempSync(join(tmpdir(), "attach-address-cli-"));
const db = createDb(); migrate(db, ALL_MIGRATIONS);
const rigs = new RigRepository(db), pods = new PodRepository(db), sessions = new SessionRegistry(db), bus = new EventBus(db);
const rig = rigs.createRig("sample-team"), person = rigs.addNode(rig.id, "person.agent", { runtime: "terminal" });
const operator = rigs.addNode(rig.id, "operator.agent", { runtime: "terminal" });
sessions.updateBinding(operator.id, { tmuxSession: "operator-physical", tmuxPane: "%43" }); sessions.registerClaimedSession(operator.id, "operator-physical");
const writes: Array<{ target: string; pane: string; text: string }> = [];
const tmux = { hasSession: async () => true, probeSession: async () => ({ state: "present" }),
capturePaneContent: async () => "plain terminal", getPaneCommand: async () => "sh",
sendText: async (target: string, text: string) => { writes.push({ target, pane: target === physical ? "%42" : "%43", text }); return { ok: true }; },
sendKeys: async () => ({ ok: true }),
} as unknown as TmuxAdapter;
const attach = new SelfAttachService({ db, rigRepo: rigs, podRepo: pods, sessionRegistry: sessions, eventBus: bus, tmuxAdapter: tmux });
const transport = new SessionTransport({ db, rigRepo: rigs, sessionRegistry: sessions, tmuxAdapter: tmux });
const whoami = new WhoamiService({ db, rigRepo: rigs, sessionRegistry: sessions, transcriptStore: new TranscriptStore({ transcriptsRoot: join(home, "transcripts"), enabled: false }) });
const app = new Hono(); app.use("*", async (c, next) => {
c.set("selfAttachService" as never, attach); c.set("sessionTransport" as never, transport); c.set("whoamiService" as never, whoami); await next();
});
app.get("/healthz", c => c.json({ ok: true }));
app.route("/api/rigs", rigsRoutes); app.route("/api/transport", transportRoutes()); app.route("/api/whoami", whoamiRoutes());
const server = serve({ fetch: app.fetch, hostname: "127.0.0.1", port: 0 });
if (!server.listening) await once(server, "listening");
const address = server.address(); if (!address || typeof address === "string") throw new Error("missing fixture port");
const url = `http://127.0.0.1:${address.port}`;
const lifecycleDeps = { spawn: vi.fn(), fetch: async () => ({ ok: true }), kill: vi.fn(),
readFile: (p: string) => p === STATE_FILE ? JSON.stringify({ pid: process.pid, port: address.port, db: "fixture", startedAt: "2026-10-20T00:00:00Z" }) : null,
exists: (p: string) => p === STATE_FILE, writeFile: vi.fn(), removeFile: vi.fn(), mkdirp: vi.fn(), openForAppend: () => 3, isProcessAlive: () => true,
} as LifecycleDeps;
const deps = { lifecycleDeps, clientFactory: () => new DaemonClient(url) };
const previousExit = process.exitCode;
const run = async (command: Command, args: string[]) => {
const logs: string[] = [], diagnostics: string[] = [], capture = vi.spyOn(console, "log").mockImplementation((...parts) => { logs.push(parts.join(" ")); });
const errors = vi.spyOn(console, "error").mockImplementation((...parts) => { diagnostics.push(parts.join(" ")); });
process.exitCode = undefined;
try { await new Command().addCommand(command).parseAsync(["node", "rig", ...args]); expect(process.exitCode).toBeUndefined(); return logs.join("\n"); }
finally { capture.mockRestore(); errors.mockRestore(); if (diagnostics.length) console.error(diagnostics.join("\n")); }
};
try {
vi.stubEnv("OPENRIG_HOME", home); vi.stubEnv("OPENRIG_URL", url); vi.stubEnv("TMUX_PANE", "%42"); vi.stubEnv("TEST_PHYSICAL_SESSION", physical);
const attached = JSON.parse(await run(attachCommand(deps), ["attach", "--self", "--rig", rig.id, "--node", person.logicalId, "--display-name", "Taylor", "--json"]));
expect(attached).toMatchObject({ sessionName: physical, senderAddress: "person-agent@sample-team", env: { OPENRIG_SESSION_NAME: "person-agent@sample-team" } });
vi.stubEnv("OPENRIG_SESSION_NAME", attached.env.OPENRIG_SESSION_NAME); vi.stubEnv("OPENRIG_NODE_ID", attached.nodeId);
const identity = JSON.parse(await run(whoamiCommand(deps), ["whoami", "--json"]));
expect(identity.identity).toMatchObject({ sessionName: physical, senderAddress: attached.senderAddress });
expect(Buffer.byteLength(JSON.stringify(identity))).toBeLessThan(6000);
await run(sendCommand(deps), ["send", "operator-agent@sample-team", "Hello from the attached person.", "--json"]);
const outbound = writes[0]!;
expect(outbound.target).toBe("operator-physical");
const from = outbound.text.split("\n")[0]!.slice("From: ".length);
expect(from).toBe(attached.senderAddress);
expect(outbound.text).toContain(`rig send ${from}`);
vi.stubEnv("OPENRIG_SESSION_NAME", "operator-agent@sample-team"); vi.stubEnv("OPENRIG_NODE_ID", operator.id);
await run(sendCommand(deps), ["send", from, "A reply to the actual From address.", "--json"]);
expect(writes[1]).toMatchObject({ target: physical, pane: "%42" });
expect(writes[1]?.text).toContain("A reply to the actual From address.");
expect(sessions.getBindingForNode(person.id)?.tmuxSession).toBe(physical);
expect(sessions.getSessionsForRig(rig.id).filter(s => s.nodeId === person.id).map(s => s.sessionName)).toEqual([physical]);
expect(rigs.getRig(rig.id)?.nodes.find(n => n.id === person.id)?.label).toBe("Taylor");
} finally {
vi.unstubAllEnvs(); process.exitCode = previousExit; server.closeAllConnections?.(); await new Promise<void>(resolve => server.close(() => resolve())); db.close(); rmSync(home, { recursive: true, force: true });
}
});
+10 -4
View File
@@ -43,7 +43,10 @@ function fixture() {
heartbeat(id:string,session:string) { return leases.requireActiveHolder(session,id); },
};
const db = {prepare(sql:string) {
return {all(arg:string) {
return {
// This fake models canonical sessions only, without an alias schema.
get() { if (sql.includes("sqlite_master")) return undefined; throw Error("unexpected SQL"); },
all(arg:string) {
if (sql.includes("o.generation_uuid AS generation")) return arg === "classifier@fixture" ? [{nodeId:"node-1",rigId:"rig-1",session:arg,generation}] : [];
if (sql.includes("ORDER BY session")) return [{session:"classifier@fixture"}];
throw Error("unexpected SQL");
@@ -182,12 +185,15 @@ describe("supported occupant wake entry",()=>{
});
it("current occupant query rejects a historical running row behind a stopped latest session",()=>{
const db=new Database(":memory:");
// Four tiny fixture tables only. No production migration or DB file is opened.
// Tiny in-memory tables include the rig metadata used by address resolution.
// No production migration or DB file is opened.
try {
db.exec(`CREATE TABLE nodes(id TEXT,rig_id TEXT); CREATE TABLE bindings(node_id TEXT,tmux_session TEXT);
db.exec(`CREATE TABLE rigs(id TEXT,name TEXT,archived_at TEXT);
CREATE TABLE nodes(id TEXT,rig_id TEXT,logical_id TEXT); CREATE TABLE bindings(node_id TEXT,tmux_session TEXT);
CREATE TABLE sessions(id TEXT,node_id TEXT,session_name TEXT,status TEXT);
CREATE TABLE occupant_tenures(node_id TEXT,generation_ordinal INTEGER,generation_uuid TEXT);
INSERT INTO nodes VALUES('n','r'); INSERT INTO bindings VALUES('n','seat@rig');
INSERT INTO rigs VALUES('r','rig',NULL);
INSERT INTO nodes VALUES('n','r','seat'); INSERT INTO bindings VALUES('n','seat@rig');
INSERT INTO sessions VALUES('01','n','seat@rig','running');
INSERT INTO occupant_tenures VALUES('n',1,'old'),('n',2,'current');`);
expect(classifierOccupant(db,"seat@rig")).toEqual({nodeId:"n",rigId:"r",session:"seat@rig",generation:"current"});
@@ -1,3 +1,4 @@
import { physicalSessionName } from "./session-registry.js";
import type Database from "better-sqlite3";
import type { EventBus } from "./event-bus.js";
import type { AgentActivity, PersistedEvent } from "./types.js";
@@ -129,6 +130,7 @@ export class AgentActivityStore {
sessionName?: string | null;
now?: Date;
}): AgentActivity | null {
if (input.sessionName) input = { ...input, sessionName: physicalSessionName(this.db, input.sessionName) };
const nodeId = input.nodeId ?? (input.sessionName ? this._resolveSession({ sessionName: input.sessionName })?.node_id : null);
if (!nodeId) return null;
@@ -310,6 +312,7 @@ export class AgentActivityStore {
}
if (!input.sessionName) return null;
const sessionName = physicalSessionName(this.db, input.sessionName);
const row = this.db.prepare(`
SELECT n.rig_id, n.id AS node_id, s.session_name, n.runtime
FROM sessions s
@@ -317,7 +320,7 @@ export class AgentActivityStore {
WHERE s.session_name = ?
ORDER BY s.id DESC
LIMIT 1
`).get(input.sessionName) as SessionLookupRow | undefined;
`).get(sessionName) as SessionLookupRow | undefined;
return row ?? null;
}
}
@@ -1,3 +1,4 @@
import { savedSessionName } from "../session-registry.js";
// Fork Primitive + Starter Agent Images v0 (PL-016) — runtime-specific
// resume-token discovery.
//
@@ -35,6 +36,7 @@ export interface DiscoveryFailure {
export type DiscoveryOutcome = { ok: true; result: DiscoveryResult } | { ok: false; failure: DiscoveryFailure };
export function discoverResumeToken(db: Database.Database, sourceSession: string): DiscoveryOutcome {
sourceSession = savedSessionName(db, sourceSession);
const sessionRow = db
.prepare("SELECT id, node_id, resume_token FROM sessions WHERE session_name = ? ORDER BY id DESC LIMIT 1")
.get(sourceSession) as { id: string; node_id: string; resume_token: string | null } | undefined;
+4 -1
View File
@@ -1,7 +1,7 @@
import { OutboxHandler } from "./outbox-handler.js";
import type Database from "better-sqlite3";
import type { RigRepository } from "./rig-repository.js";
import type { SessionRegistry } from "./session-registry.js";
import { SessionAddressConflict, type SessionRegistry } from "./session-registry.js";
import type { DiscoveryRepository } from "./discovery-repository.js";
import type { EventBus } from "./event-bus.js";
import type { TmuxAdapter } from "../adapters/tmux.js";
@@ -403,6 +403,7 @@ export class ClaimService {
return { ok: true, nodeId, sessionId };
} catch (err) {
if (err instanceof SessionAddressConflict) return { ok: false, code: "address_conflict", error: err.message };
return { ok: false, code: "claim_error", error: (err as Error).message };
}
}
@@ -618,6 +619,7 @@ export class ClaimService {
},
};
} catch (err) {
if (err instanceof SessionAddressConflict) return { ok: false, code: "address_conflict", message: err.message };
return { ok: false, code: "reconcile_error", message: (err as Error).message };
}
}
@@ -832,6 +834,7 @@ export class ClaimService {
return { ok: true, nodeId, sessionId };
} catch (err) {
if (err instanceof SessionAddressConflict) return { ok: false, code: "address_conflict", error: err.message };
return { ok: false, code: "claim_error", error: (err as Error).message };
}
}
@@ -1,3 +1,4 @@
import { physicalSessionName } from "./session-registry.js";
import type Database from "better-sqlite3";
import fs from "node:fs";
import path from "node:path";
@@ -12,6 +13,7 @@ export interface ClassifierOccupant { nodeId: string; rigId: string; session: st
// Same real running-session/tenure sources as startup liveness. No daemon pseudo-session.
export function classifierOccupant(db: Database.Database, session: string): ClassifierOccupant | null {
session = physicalSessionName(db, session);
const rows = db.prepare(`SELECT DISTINCT n.id AS nodeId, n.rig_id AS rigId, b.tmux_session AS session,
o.generation_uuid AS generation FROM nodes n JOIN bindings b ON b.node_id=n.id
JOIN sessions s ON s.node_id=n.id AND s.session_name=b.tmux_session AND s.status='running'
@@ -1,3 +1,4 @@
import { physicalSessionName } from "./session-registry.js";
import Database from "better-sqlite3";
import { join } from "node:path";
import os from "node:os";
@@ -317,6 +318,7 @@ export class ContextUsageStore {
/** Get context usage for a single node. Session-aware: mismatched session returns unknown. */
getForNode(nodeId: string, currentSessionName: string | null): ContextUsage {
if (!currentSessionName) return this.unknownUsage("not_managed");
currentSessionName = physicalSessionName(this.db, currentSessionName);
const row = this.db.prepare("SELECT * FROM context_usage WHERE node_id = ?").get(nodeId) as ContextUsageRow | undefined;
if (!row) return this.unknownUsage("no_data");
@@ -341,6 +343,7 @@ export class ContextUsageStore {
const result = new Map<string, ContextUsage>();
if (entries.length === 0) return result;
entries = entries.map(entry => ({ ...entry, currentSessionName: entry.currentSessionName ? physicalSessionName(this.db, entry.currentSessionName) : null }));
const nodeIds = entries.map((e) => e.nodeId);
const sessionMap = new Map(entries.map((e) => [e.nodeId, e.currentSessionName]));
+2 -1
View File
@@ -1,3 +1,4 @@
import { physicalSessionName } from "./session-registry.js";
import { resolveNodeFile } from "./scope/node-file.js";
import { belongsToProject } from "./workspace/project-catalog.js";
import { inspectGraph } from "./workflow-reconciliation.js";
@@ -1218,7 +1219,7 @@ export async function buildExecutionView(deps: ExecutionViewDeps, opts?: { missi
});
// ---- Q6 parallelism health ----
const inProgressSeats = new Set(rows.filter((r) => r.state === "in-progress").map((r) => r.destination_session));
const inProgressSeats = new Set(rows.filter((r) => r.state === "in-progress").map((r) => physicalSessionName(deps.db, r.destination_session)));
// Idle capacity: the ROSTER comes from sessions (names only); idleness comes
// from the ACTIVITY ORACLE — sessions.status is not consulted (it labeled
// live working lanes `superseded` on the real board).
+8 -5
View File
@@ -1,3 +1,4 @@
import { physicalSessionName } from "./session-registry.js";
import fs from "node:fs";
import type { CaptureObserver, ObservedBinding } from "./capture-observer.js";
import type Database from "better-sqlite3";
@@ -1303,11 +1304,13 @@ export function readAssignedWorkBySession(db: Database.Database): Map<string, As
}>;
const out = new Map<string, AssignedWorkCounts>();
for (const r of rows) {
out.set(r.destination_session, {
assignedWorkCount: r.assigned_count,
pendingWorkCount: r.pending_count,
inProgressWorkCount: r.in_progress_count,
blockedWorkCount: r.blocked_count,
const address = physicalSessionName(db, r.destination_session);
const prior = out.get(address);
out.set(address, {
assignedWorkCount: (prior?.assignedWorkCount ?? 0) + r.assigned_count,
pendingWorkCount: (prior?.pendingWorkCount ?? 0) + r.pending_count,
inProgressWorkCount: (prior?.inProgressWorkCount ?? 0) + r.in_progress_count,
blockedWorkCount: (prior?.blockedWorkCount ?? 0) + r.blocked_count,
});
}
return out;
+7 -1
View File
@@ -1,6 +1,6 @@
import type Database from "better-sqlite3";
import type { RigRepository } from "./rig-repository.js";
import type { SessionRegistry } from "./session-registry.js";
import { SessionAddressConflict, type SessionRegistry } from "./session-registry.js";
import type { EventBus } from "./event-bus.js";
import type { TmuxAdapter } from "../adapters/tmux.js";
import type { TranscriptStore } from "./transcript-store.js";
@@ -145,6 +145,12 @@ export class NodeLauncher {
};
}
try { this.sessionRegistry.assertAddressAvailable(node.id, sessionName); }
catch (error) {
if (!(error instanceof SessionAddressConflict)) throw error;
return { ok: false, code: "address_conflict", message: error.message };
}
// 3. Reserve the source-bound occupant generation before the process starts. Reservation is
// side-effect-free and fail-open: an unavailable ledger yields null and the launch remains valid.
const occupantGeneration = this.sessionRegistry.reserveOccupantGeneration();
+3 -3
View File
@@ -336,12 +336,12 @@ export class OutboxHandler {
return row ? this.rowToEntry(row) : null;
}
listForSender(senderSession: string, limit = 100): OutboxEntry[] {
listForSender(senderSession: string, limit = 100, addresses = [senderSession]): OutboxEntry[] {
const rows = this.db
.prepare(
`SELECT * FROM outbox_entries WHERE sender_session = ? ORDER BY ts_dispatched DESC, rowid DESC LIMIT ?`
`SELECT * FROM outbox_entries WHERE sender_session IN (${addresses.map(() => "?").join(",")}) ORDER BY ts_dispatched DESC, rowid DESC LIMIT ?`
)
.all(senderSession, limit) as OutboxEntryRow[];
.all(...addresses, limit) as OutboxEntryRow[];
return rows.map((r) => this.rowToEntry(r));
}
+2 -1
View File
@@ -1,3 +1,4 @@
import { resolveCurrentSessionAddress } from "./session-registry.js";
import type Database from "better-sqlite3";
/** The durable session→node binding (the session-registry precedent: latest sessions row
@@ -8,7 +9,7 @@ export function resolveSessionNodeId(db: Database.Database, session: string): st
const row = db
.prepare("SELECT node_id FROM sessions WHERE session_name = ? ORDER BY id DESC LIMIT 1")
.get(session) as { node_id: string } | undefined;
return row?.node_id ?? null;
return row?.node_id ?? resolveCurrentSessionAddress(db, session)?.nodeId ?? null;
}
/** Default orchestrator derivation: the destination's BOUND node → the source of its
+36 -21
View File
@@ -804,6 +804,7 @@ export class QueueRepository {
* fallback real code rather than a promise in a comment). */
private outbox: OutboxHandler | undefined;
private resolveOccupantGeneration?: (sessionName: string) => string | null;
private readonly resolveSessionAddresses: (name: string) => string[];
private readonly wakeRepo: QueueWakeRepository;
private watchdogJobsRepo: WatchdogJobsRepository | undefined;
/** PL-007 Workspace Primitive — true when migration 038 has applied the
@@ -861,6 +862,7 @@ export class QueueRepository {
* is omitted (never forged).
*/
resolveOccupantGeneration?: (sessionName: string) => string | null;
resolveSessionAddresses?: (name: string) => string[];
loadHumanRegistry?: () => LoadResult;
}
) {
@@ -873,6 +875,7 @@ export class QueueRepository {
if (opts?.transport) this.attachTransport(opts.transport);
this.workflowFrontierPredicate = opts?.workflowFrontierPredicate;
this.resolveOccupantGeneration = opts?.resolveOccupantGeneration;
this.resolveSessionAddresses = opts?.resolveSessionAddresses ?? ((name) => [name]);
this.loadHumanRegistryFn = opts?.loadHumanRegistry ?? (() => loadHumanRegistry());
this.hasTargetRepoColumn = detectQueueColumn(db, "target_repo");
this.hasSummaryColumn = detectQueueColumn(db, "summary");
@@ -2270,27 +2273,29 @@ export class QueueRepository {
asDestination: { pending: number; inProgress: number; blocked: number; recent: QueueItem[] };
asSource: { total: number };
} {
const addresses = this.resolveSessionAddresses(session);
const slots = addresses.map(() => "?").join(",");
const limit = Math.max(1, Math.min(opts?.recentLimit ?? 25, 200));
const countByState = (state: string): number => {
const row = this.db
.prepare(
`SELECT COUNT(*) AS n FROM queue_items WHERE destination_session = ? AND state = ?`
`SELECT COUNT(*) AS n FROM queue_items WHERE destination_session IN (${slots}) AND state = ?`
)
.get(session, state) as { n: number };
.get(...addresses, state) as { n: number };
return row.n;
};
const recent = this.db
.prepare(
`SELECT * FROM queue_items
WHERE destination_session = ?
WHERE destination_session IN (${slots})
AND state IN ('pending','in-progress','blocked')
ORDER BY ts_updated DESC
LIMIT ?`
)
.all(session, limit) as QueueItemRow[];
.all(...addresses, limit) as QueueItemRow[];
const sourceTotalRow = this.db
.prepare(`SELECT COUNT(*) AS n FROM queue_items WHERE source_session = ?`)
.get(session) as { n: number };
.prepare(`SELECT COUNT(*) AS n FROM queue_items WHERE source_session IN (${slots})`)
.get(...addresses) as { n: number };
return {
session,
asDestination: {
@@ -2313,11 +2318,13 @@ export class QueueRepository {
* silently degrades into a confident answer. This is that authoritative input.
*/
listInProgressForDestination(session: string): QueueItem[] {
const addresses = this.resolveSessionAddresses(session);
const slots = addresses.map(() => "?").join(",");
const rows = this.db
.prepare(
`SELECT * FROM queue_items WHERE destination_session = ? AND state = 'in-progress'`
`SELECT * FROM queue_items WHERE destination_session IN (${slots}) AND state = 'in-progress'`
)
.all(session) as QueueItemRow[];
.all(...addresses) as QueueItemRow[];
return rows.map((r) => this.rowToItem(r));
}
@@ -2332,7 +2339,7 @@ export class QueueRepository {
`qitem ${input.qitemId} not found`
);
}
if (qitem.destinationSession !== input.destinationSession) {
if (!this.resolveSessionAddresses(input.destinationSession).includes(qitem.destinationSession)) {
throw new QueueRepositoryError(
"claim_destination_mismatch",
`qitem ${input.qitemId} destination is ${qitem.destinationSession}, not ${input.destinationSession}`
@@ -3466,11 +3473,13 @@ export class QueueRepository {
/** The parked query's obligation face: open rows to one destination, newest first, without the per-row
* projections list() derives (pickup, waiting view, delivery ledger), which this read never uses. */
listOpenObligations(destinationSession: string, limit: number): Array<{ qitemId: string; state: "pending" | "in-progress" | "blocked"; summary: string | null }> {
const addresses = this.resolveSessionAddresses(destinationSession);
const slots = addresses.map(() => "?").join(",");
const rows = this.db.prepare(
`SELECT qitem_id, state${this.hasSummaryColumn ? ", summary" : ""} FROM queue_items
WHERE destination_session = ? AND state IN ('pending', 'in-progress', 'blocked')
WHERE destination_session IN (${slots}) AND state IN ('pending', 'in-progress', 'blocked')
ORDER BY ts_created DESC LIMIT ?`,
).all(destinationSession, limit) as Array<{ qitem_id: string; state: "pending" | "in-progress" | "blocked"; summary?: string | null }>;
).all(...addresses, limit) as Array<{ qitem_id: string; state: "pending" | "in-progress" | "blocked"; summary?: string | null }>;
return rows.map((r) => ({ qitemId: r.qitem_id, state: r.state, summary: r.summary ?? null }));
}
@@ -3492,19 +3501,23 @@ export class QueueRepository {
params.push(`%@${escaped}`, `%@${escaped}`);
}
if (opts?.asSession) {
conditions.push("(destination_session = ? OR source_session = ?)");
params.push(opts.asSession, opts.asSession);
const addresses = this.resolveSessionAddresses(opts.asSession);
const slots = addresses.map(() => "?").join(",");
conditions.push(`(destination_session IN (${slots}) OR source_session IN (${slots}))`);
params.push(...addresses, ...addresses);
}
if (opts?.activeOnly && !opts?.state) {
conditions.push("state IN ('pending', 'in-progress', 'blocked')");
}
if (opts?.destinationSession) {
conditions.push("destination_session = ?");
params.push(opts.destinationSession);
const addresses = this.resolveSessionAddresses(opts.destinationSession);
conditions.push(`destination_session IN (${addresses.map(() => "?").join(",")})`);
params.push(...addresses);
}
if (opts?.sourceSession) {
conditions.push("source_session = ?");
params.push(opts.sourceSession);
const addresses = this.resolveSessionAddresses(opts.sourceSession);
conditions.push(`source_session IN (${addresses.map(() => "?").join(",")})`);
params.push(...addresses);
}
if (opts?.state) {
const states = Array.isArray(opts.state) ? opts.state : [opts.state];
@@ -3644,12 +3657,14 @@ export class QueueRepository {
if (this.hasHumanIntentColumn) conditions.push("COALESCE(human_intent, 'decision') <> 'update'");
const params: unknown[] = [...states];
if (opts?.destinationSession) {
conditions.push("destination_session = ?");
params.push(opts.destinationSession);
const addresses = this.resolveSessionAddresses(opts.destinationSession);
conditions.push(`destination_session IN (${addresses.map(() => "?").join(",")})`);
params.push(...addresses);
}
if (opts?.sourceSession) {
conditions.push("source_session = ?");
params.push(opts.sourceSession);
const addresses = this.resolveSessionAddresses(opts.sourceSession);
conditions.push(`source_session IN (${addresses.map(() => "?").join(",")})`);
params.push(...addresses);
}
if (opts?.targetRepo && this.hasTargetRepoColumn) {
conditions.push("target_repo = ?");
+8 -5
View File
@@ -1,3 +1,4 @@
import { physicalSessionName } from "../session-registry.js";
// Living Notes Packet 2 — the input gatherer (OPR.0.4.4.20).
//
// The impure shell around the pure composer: reads slice docs + proof
@@ -577,10 +578,11 @@ export class ReviewGatherer {
!(missionName !== null && scopes.missions.has(missionName))
)
continue;
const entry = bySession.get(r.destination_session) ?? { rows: [], slices: new Set<string>() };
const owner = physicalSessionName(this.db, r.destination_session);
const entry = bySession.get(owner) ?? { rows: [], slices: new Set<string>() };
entry.rows.push(r);
for (const s of rowSlices) entry.slices.add(s);
bySession.set(r.destination_session, entry);
bySession.set(owner, entry);
}
return [...bySession.entries()]
.sort(([a], [b]) => a.localeCompare(b))
@@ -630,7 +632,8 @@ export class ReviewGatherer {
const recent = new Map<string, { latest: QitemRow; slices: Set<string> }>();
for (const r of rows) {
if (isHumanSeatSession(r.destination_session)) continue;
if (known.has(r.destination_session)) continue;
const owner = physicalSessionName(this.db, r.destination_session);
if (known.has(owner)) continue;
let tags: string[] = [];
try {
tags = (JSON.parse(r.tags ?? "[]") as string[]) ?? [];
@@ -639,10 +642,10 @@ export class ReviewGatherer {
}
const rowSlices = tags.filter((t) => t.startsWith("slice:")).map((t) => t.slice("slice:".length));
if (rowSlices.length === 0) continue;
const entry = recent.get(r.destination_session) ?? { latest: r, slices: new Set<string>() };
const entry = recent.get(owner) ?? { latest: r, slices: new Set<string>() };
if (r.ts_updated > entry.latest.ts_updated) entry.latest = r;
for (const s of rowSlices) entry.slices.add(s);
recent.set(r.destination_session, entry);
recent.set(owner, entry);
}
const recentInputs: AgentInput[] = [...recent.entries()]
.sort(([a], [b]) => a.localeCompare(b))
@@ -1,3 +1,4 @@
import { physicalSessionName } from "../session-registry.js";
import { readFileSync, realpathSync } from "node:fs";
import { dirname, isAbsolute, join, relative, resolve } from "node:path";
import { parse as parseYaml } from "yaml";
@@ -194,8 +195,9 @@ export class OperatingPostureService {
* rig-scoped read the rig's name alone. */
private inferenceSignals(ctx: OperatingContext, destination: string | null): { rigName: string | null; cwd: string | null } {
if (destination) {
const physical = physicalSessionName(this.db, destination);
const seat = this.db.prepare("SELECT n.cwd FROM sessions s JOIN nodes n ON n.id = s.node_id WHERE s.session_name = ? ORDER BY s.created_at DESC, s.rowid DESC LIMIT 1")
.get(destination) as { cwd: string | null } | undefined;
.get(physical) as { cwd: string | null } | undefined;
return { rigName: rigFromSession(destination), cwd: seat?.cwd ?? null };
}
const rig = ctx.rigId ? this.db.prepare("SELECT name FROM rigs WHERE id = ?").get(ctx.rigId) as { name: string } | undefined : undefined;
@@ -1,3 +1,4 @@
import { hasOtherSeatHistory, isStoppedSeatPredecessor, resolveCurrentSessionAddress } from "./session-registry.js";
import { AsyncLocalStorage } from "node:async_hooks";
import { randomUUID } from "node:crypto";
import type Database from "better-sqlite3";
@@ -449,14 +450,29 @@ export function resolveGuardTarget(db: Database.Database, name: string): GuardTa
coalesce(b.tmux_session, replace(n.logical_id,'.','-') || '@' || r.name) AS session,
b.tmux_pane AS pane,
(SELECT generation_uuid FROM occupant_tenures t WHERE t.node_id=n.id ORDER BY generation_ordinal DESC LIMIT 1) AS occupant,
b.tmux_session AS physicalName, n.logical_id AS logicalId, r.name AS rigName,
(SELECT status FROM sessions WHERE node_id=n.id ORDER BY id DESC LIMIT 1) AS latestStatus,
r.archived_at AS archivedAt
FROM nodes n JOIN rigs r ON r.id=n.rig_id LEFT JOIN bindings b ON b.node_id=n.id
WHERE n.id=? OR b.tmux_session=? OR b.tmux_pane=? OR n.logical_id=?
OR (b.tmux_session IS NULL AND replace(n.logical_id,'.','-') || '@' || r.name=?)`)
.all(name, name, name, name, name) as Array<GuardTarget & { archivedAt: string | null }>;
const unarchived = rows.filter((row) => row.archivedAt === null);
const pool = unarchived.length > 0 ? unarchived : rows;
if (pool.length !== 1) return null;
const { archivedAt: _archivedAt, ...target } = pool[0]!;
OR (replace(n.logical_id,'.','-') || '@' || r.name=?
AND (b.tmux_session IS NULL OR b.tmux_session=(SELECT session_name FROM sessions WHERE node_id=n.id ORDER BY id DESC LIMIT 1))
)`)
.all(name, name, name, name, name) as Array<GuardTarget & { archivedAt: string | null; physicalName: string | null; logicalId: string; rigName: string; latestStatus: string | null }>;
const eligible = rows.filter(row => row.nodeId === name || row.physicalName === name || row.pane === name || row.logicalId === name
|| !hasOtherSeatHistory(db, name, row.nodeId));
// A stopped same-seat predecessor may retain the exact physical spelling.
// Omit only a proven same-seat predecessor. Unrelated/mismatched bindings
// still compete, including owners without a current matching session row.
// Explicit node/pane lookup and saved-history access remain separate.
const current = name.includes("@") ? resolveCurrentSessionAddress(db, name) : null;
const named = current ? eligible.filter(row => row.nodeId === current.nodeId || !isStoppedSeatPredecessor(row, current)) : eligible;
const unarchived = named.filter((row) => row.archivedAt === null);
const active = unarchived.length > 0 ? unarchived : named;
const exact = active.filter(row => row.physicalName === name);
const pool = exact.length ? exact : active;
// An exact physical spelling cannot displace the routed logical recipient.
if (pool.length !== 1 || (current && pool[0]!.nodeId !== current.nodeId)) return null;
const { archivedAt: _archivedAt, physicalName: _physicalName, logicalId: _logicalId, rigName: _rigName, latestStatus: _latestStatus, ...target } = pool[0]!;
return target;
}
@@ -3,7 +3,7 @@ import { readSeatEffort } from "./seat-effort.js";
import { NativePermissionStore } from "./native-permission-store.js";
import { ulid } from "ulid";
import type { RigRepository } from "./rig-repository.js";
import type { SessionRegistry } from "./session-registry.js";
import { SessionAddressConflict, type SessionRegistry } from "./session-registry.js";
import type { DiscoveryRepository } from "./discovery-repository.js";
import type { EventBus } from "./event-bus.js";
import type { TmuxAdapter } from "../adapters/tmux.js";
@@ -88,7 +88,7 @@ export type SeatHandoverResult =
| { ok: true; plan: SeatHandoverPlan }
| { ok: true; result: SeatHandoverMutationResult }
| { ok: false; code: "missing_reason" | "invalid_source" | "successor_creation_not_implemented" | "source_not_supported" | "resume_token_unavailable" | "fork_source_not_found"; message: string; guidance: string }
| { ok: false; code: "current_occupant_required" | "discovered_not_active" | "successor_tmux_absent" | "successor_already_managed" | "successor_is_current" | "runtime_mismatch"; message: string; guidance: string }
| { ok: false; code: "address_conflict" | "current_occupant_required" | "discovered_not_active" | "successor_tmux_absent" | "successor_already_managed" | "successor_is_current" | "runtime_mismatch"; message: string; guidance: string }
| { ok: false; code: "discovered_not_found"; message: string; guidance: string }
| { ok: false; code: "tmux_probe_failed" | "handover_commit_failed" | "successor_create_failed" | "context_delivery_failed"; message: string; guidance: string }
| Extract<SeatStatusResult, { ok: false }>;
@@ -360,6 +360,19 @@ export class SeatHandoverService {
};
}
if (parsed.source.mode !== "discovered") {
// Preserve the existing exact-owner refusal before testing the new address.
const owner = this.lookupManagedOwner(latestSession.session_name, node.id, true);
if (owner) return { ok: false, code: "successor_already_managed",
message: `Successor tmux session "${latestSession.session_name}" is already managed by ${owner.logical_id}@${owner.rig_name}.`,
guidance: "Use an unclaimed discovered successor session." };
try { this.sessionRegistry.assertAddressAvailable(node.id, latestSession.session_name); }
catch (error) {
if (!(error instanceof SessionAddressConflict)) throw error;
return { ok: false, code: "address_conflict", message: error.message, guidance: "Choose another member name; no successor was started." };
}
}
const operator = input.operator?.trim() || null;
// OPR.0.5.5.5 fork: resolve the native conversation id BEFORE any mutation
@@ -710,6 +723,12 @@ export class SeatHandoverService {
});
}
try { this.sessionRegistry.assertAddressAvailable(input.node.id, discovered.tmuxSession); }
catch (error) {
if (!(error instanceof SessionAddressConflict)) throw error;
return fail({ ok: false, code: "address_conflict", message: error.message, guidance: "Choose another member name; this discovered session was not bound." });
}
let tmuxPresent: boolean;
try {
tmuxPresent = await this.tmuxAdapter.hasSession(discovered.tmuxSession);
@@ -1,7 +1,7 @@
import type Database from "better-sqlite3";
import type { RigRepository } from "./rig-repository.js";
import type { PodRepository } from "./pod-repository.js";
import type { SessionRegistry } from "./session-registry.js";
import { SessionAddressConflict, type SessionRegistry } from "./session-registry.js";
import type { EventBus } from "./event-bus.js";
import type { PersistedEvent } from "./types.js";
import type { TmuxAdapter } from "../adapters/tmux.js";
@@ -14,6 +14,7 @@ export type SelfAttachSuccess = {
logicalId: string;
sessionId: string;
sessionName: string;
senderAddress: string;
attachmentType: "tmux" | "external_cli";
env: {
OPENRIG_NODE_ID: string;
@@ -33,7 +34,8 @@ export type SelfAttachFailureCode =
| "duplicate_logical_id"
| "invalid_member_name"
| "runtime_required"
| "runtime_mismatch";
| "runtime_mismatch"
| "address_conflict";
export type SelfAttachResult =
| SelfAttachSuccess
@@ -148,6 +150,7 @@ export class SelfAttachService {
logicalId: node.logicalId,
runtime: node.runtime ?? opts.runtime,
cwd: node.cwd ?? opts.cwd,
displayName: opts.displayName,
context: this.resolveContext(node.logicalId, rig.rig.name, opts.displayName, opts.context),
});
}
@@ -188,6 +191,7 @@ export class SelfAttachService {
rigId: opts.rigId,
nodeId: node.id,
logicalId,
displayName: opts.displayName,
context: this.resolveContext(logicalId, rig.rig.name, opts.displayName, opts.context),
});
return attached;
@@ -200,6 +204,7 @@ export class SelfAttachService {
this.eventBus.notifySubscribers(result.event);
return this.toSuccess(result.nodeId, result.logicalId, result.sessionId, result.sessionName, result.attachmentType, runtime);
} catch (error) {
if (error instanceof SessionAddressConflict) return { ok: false, code: "address_conflict", error: error.message };
return { ok: false, code: "duplicate_logical_id", error: (error as Error).message };
}
}
@@ -211,6 +216,7 @@ export class SelfAttachService {
logicalId: string;
runtime?: string | null;
cwd?: string | null;
displayName?: string;
context: SelfAttachContext;
}): Promise<SelfAttachResult> {
const attachTx = this.db.transaction(() =>
@@ -218,6 +224,7 @@ export class SelfAttachService {
rigId: args.rigId,
nodeId: args.nodeId,
logicalId: args.logicalId,
displayName: args.displayName,
context: args.context,
})
);
@@ -226,6 +233,7 @@ export class SelfAttachService {
const result = attachTx();
return this.finalizeAttach(args.rigName, result, args.runtime, args.cwd);
} catch (error) {
if (error instanceof SessionAddressConflict) return { ok: false, code: "address_conflict", error: error.message };
return { ok: false, code: "already_bound", error: (error as Error).message };
}
}
@@ -249,6 +257,7 @@ export class SelfAttachService {
this.eventBus.notifySubscribers(result.event);
return this.toSuccess(result.nodeId, result.logicalId, result.sessionId, result.sessionName, result.attachmentType, runtime);
} catch (error) {
if (error instanceof SessionAddressConflict) return { ok: false, code: "address_conflict", error: error.message };
return { ok: false, code: "already_bound", error: (error as Error).message };
}
}
@@ -257,6 +266,7 @@ export class SelfAttachService {
rigId: string;
nodeId: string;
logicalId: string;
displayName?: string;
context: SelfAttachContext;
}): {
nodeId: string;
@@ -270,6 +280,10 @@ export class SelfAttachService {
? args.context.tmuxSession
: args.context.sessionName ?? `unknown-${args.nodeId.slice(-6)}`;
this.sessionRegistry.assertAddressAvailable(args.nodeId, sessionName, args.context.attachmentType === "tmux");
if (args.context.attachmentType === "tmux" && args.displayName?.trim()) {
this.db.prepare("UPDATE nodes SET label=? WHERE id=?").run(args.displayName.trim(), args.nodeId);
}
this.sessionRegistry.updateBinding(args.nodeId, args.context.attachmentType === "tmux"
? {
attachmentType: "tmux",
@@ -350,9 +364,11 @@ export class SelfAttachService {
// agent it starts) produces activity signal: OPENRIG_RUNTIME is required by
// the relay to build a payload; url+token let it reach the ingest endpoint
// directly (file-discovery is the fallback when they are absent).
const senderAddress = attachmentType === "tmux"
? this.sessionRegistry.resolveAddress(sessionName)?.senderAddress ?? sessionName : sessionName;
const env: SelfAttachSuccess["env"] = {
OPENRIG_NODE_ID: nodeId,
OPENRIG_SESSION_NAME: sessionName,
OPENRIG_SESSION_NAME: senderAddress,
};
if (runtime) env.OPENRIG_RUNTIME = runtime;
if (this.activityEnv.url) env.OPENRIG_URL = this.activityEnv.url;
@@ -363,6 +379,7 @@ export class SelfAttachService {
logicalId,
sessionId,
sessionName,
senderAddress,
attachmentType,
env,
};
@@ -113,6 +113,81 @@ export interface WatchdogRegistrationObserver {
reconcileHandover?(nodeId: string, sessionName: string): unknown;
}
export interface CurrentSessionAddress {
nodeId: string; sessionId: string; sessionName: string; senderAddress: string;
logicalId: string; rigName: string;
}
/** Explicit node/binding join: never reverse-parse a flattened team address.
* Physical names win over derived names. Archived/historical bindings cannot
* advertise a new alias, and two current matches never pick an arbitrary seat. */
export function resolveCurrentSessionAddress(db: Database.Database, address: string): CurrentSessionAddress | null {
const rows = db.prepare(`SELECT r.*, n.id AS nodeId, n.logical_id AS logicalId,
s.id AS sessionId, s.session_name AS sessionName, s.status,
replace(n.logical_id, '.', '-') || '@' || r.name AS senderAddress
FROM nodes n JOIN rigs r ON r.id=n.rig_id
JOIN bindings b ON b.node_id=n.id AND b.tmux_session IS NOT NULL
JOIN sessions s ON s.node_id=n.id AND s.session_name=b.tmux_session
AND s.id=(SELECT id FROM sessions WHERE node_id=n.id ORDER BY id DESC LIMIT 1)
WHERE s.session_name=? OR replace(n.logical_id, '.', '-') || '@' || r.name=?`)
.all(address, address) as Array<CurrentSessionAddress & { name: string; archived_at?: string | null; status: string }>;
const active = rows.filter(row => row.archived_at == null && !['superseded', 'detached', 'exited'].includes(row.status));
const exact = active.filter(row => row.sessionName === address);
// A derived address cannot take another seat's saved spelling. The same seat
// can continue after the old rig generation stopped or was archived by replacement.
const pool = exact.length ? exact : active.filter(row => !hasOtherSeatHistory(db, address, row.nodeId));
if (pool.length !== 1) return null;
const row = pool[0]!;
return { nodeId: row.nodeId, sessionId: row.sessionId, sessionName: row.sessionName,
senderAddress: row.senderAddress, logicalId: row.logicalId, rigName: row.name };
}
/** Resolve live routing to the current physical tmux session. Historical readers
* use savedSessionName instead so reconnecting does not redirect saved context. */
export function physicalSessionName(db: Database.Database, address: string): string {
if (!address.includes("@")) return address;
// A curated/legacy read model with no binding table has no aliases to resolve.
if (!db.prepare("SELECT 1 FROM sqlite_master WHERE type='table' AND name='bindings'").get()) return address;
return resolveCurrentSessionAddress(db, address)?.sessionName ?? address;
}
/** Only this predecessor may be omitted when selecting a same-seat successor.
* Unknown state and another logical seat remain competing owners. */
export function isStoppedSeatPredecessor(
previous: { rigName: string; logicalId: string; archivedAt?: string | null; latestStatus?: string | null },
current: { rigName: string; logicalId: string },
): boolean {
return previous.rigName === current.rigName && previous.logicalId === current.logicalId
&& (previous.archivedAt != null || ["exited", "detached", "superseded"].includes(previous.latestStatus ?? ""));
}
/** Stopped/archived generations of the same rig/logical seat are its own history;
* every other saved owner still reserves the spelling, including archived ones. */
export function hasOtherSeatHistory(db: Database.Database, address: string, nodeId: string): boolean {
const owners = db.prepare(`SELECT r.*, n.logical_id AS logicalId,
target.logical_id AS targetLogicalId, target_rig.name AS targetRigName,
(SELECT status FROM sessions WHERE node_id=n.id ORDER BY id DESC LIMIT 1) AS latestStatus
FROM sessions s JOIN nodes n ON n.id=s.node_id JOIN rigs r ON r.id=n.rig_id
JOIN nodes target ON target.id=? JOIN rigs target_rig ON target_rig.id=target.rig_id
WHERE s.session_name=? AND s.node_id<>?`)
.all(nodeId, address, nodeId) as Array<{ name: string; archived_at?: string | null; logicalId: string; targetLogicalId: string; targetRigName: string; latestStatus: string }>;
return owners.some(owner => !isStoppedSeatPredecessor(
{ rigName: owner.name, logicalId: owner.logicalId, archivedAt: owner.archived_at, latestStatus: owner.latestStatus },
{ rigName: owner.targetRigName, logicalId: owner.targetLogicalId }));
}
/** Saved transcript/native context keeps an exact session spelling, even when
* the same node now advertises that spelling from a different physical session. */
export function savedSessionName(db: Database.Database, address: string): string {
if (db.prepare("SELECT 1 FROM sessions WHERE session_name=? LIMIT 1").get(address)) return address;
return physicalSessionName(db, address);
}
export class SessionAddressConflict extends Error {
readonly code = "address_conflict";
constructor(address: string) { super(`Team address '${address}' conflicts with another member. Choose another member name.`); }
}
export class SessionRegistry {
readonly db: Database.Database;
private watchdogRegistrationObserver?: WatchdogRegistrationObserver;
@@ -121,6 +196,63 @@ export class SessionRegistry {
this.watchdogRegistrationObserver = watchdogRegistrationObserver;
}
resolveAddress(address: string): CurrentSessionAddress | null {
return resolveCurrentSessionAddress(this.db, address);
}
/** Current queue equivalence, not a rewrite of recorded identity. A raw name
* reused by another node has ambiguous history: do not newly attribute its
* old rows through the logical spelling. Exact raw access stays unchanged. */
sessionAddresses(address: string): string[] {
const target = this.resolveAddress(address);
if (!target) return [address];
const names = new Set([address, target.senderAddress]);
const history = this.db.prepare("SELECT DISTINCT node_id FROM sessions WHERE session_name=?").all(target.sessionName) as Array<{ node_id: string }>;
if (address === target.sessionName || history.every(row => row.node_id === target.nodeId)) names.add(target.sessionName);
return [...names].filter(name => name === address || this.resolveAddress(name)?.nodeId === target.nodeId);
}
/** Run before effects as well as at registration. Declared logical collisions
* cannot race two launches into advertising the same address. Reconnecting
* this same node is not a clash. No binding or record is renamed here. */
assertAddressAvailable(nodeId: string, sessionName: string, tmux = true): void {
const node = this.db.prepare(`SELECT n.logical_id, r.name FROM nodes n JOIN rigs r ON r.id=n.rig_id WHERE n.id=?`)
.get(nodeId) as { logical_id: string; name: string } | undefined;
if (!node) return; // the existing FK/registration error owns an unknown node
const logical = `${node.logical_id.replace(/\./g, '-')}@${node.name}`;
const continuing = this.db.prepare("SELECT 1 FROM sessions WHERE node_id=? LIMIT 1").get(nodeId) !== undefined;
// An established exact/raw session can reconnect as before. A newly advertised
// logical name cannot take another seat's saved spelling, even after unbind/archive.
if (tmux && !(continuing && sessionName === logical)
&& hasOtherSeatHistory(this.db, logical, nodeId)) {
throw new SessionAddressConflict(logical);
}
const proposed = tmux ? [sessionName, logical] : [sessionName];
// b.* keeps registration valid before the optional external-CLI binding columns exist.
const other = this.db.prepare(`SELECT r.*, n.id AS nodeId, n.logical_id AS logicalId,
replace(n.logical_id, '.', '-') || '@' || r.name AS logicalAddress,
b.*,
(SELECT session_name FROM sessions WHERE node_id=n.id ORDER BY id DESC LIMIT 1) AS latestName,
(SELECT status FROM sessions WHERE node_id=n.id ORDER BY id DESC LIMIT 1) AS latestStatus
FROM nodes n JOIN rigs r ON r.id=n.rig_id LEFT JOIN bindings b ON b.node_id=n.id WHERE n.id<>?`)
.all(nodeId) as Array<{ nodeId: string; name: string; logicalId: string; latestStatus: string | null; archived_at?: string | null; logicalAddress: string; tmux_session: string | null; external_session_name?: string | null; latestName: string | null }>;
for (const row of other) {
if (row.archived_at != null) continue;
// The supported no-probe replacement also keeps a stopped same-seat generation.
if (isStoppedSeatPredecessor(
{ rigName: row.name, logicalId: row.logicalId, archivedAt: row.archived_at, latestStatus: row.latestStatus },
{ rigName: node.name, logicalId: node.logical_id })) continue;
const physical = row.tmux_session ?? row.external_session_name;
for (const address of proposed) {
const currentOther = physical && (!row.latestName || row.latestName === physical);
if ((address === row.logicalAddress && !(continuing && address === logical && !currentOther))
|| (currentOther && address === physical)) {
throw new SessionAddressConflict(address);
}
}
}
}
setWatchdogRegistrationObserver(observer: WatchdogRegistrationObserver): void {
this.watchdogRegistrationObserver = observer;
}
@@ -137,6 +269,7 @@ export class SessionRegistry {
);
}
this.assertAddressAvailable(nodeId, sessionName);
const id = ulid();
this.db
.prepare(
@@ -157,6 +290,7 @@ export class SessionRegistry {
kind: OccupantKind = "adopt",
reservedGeneration?: string | null,
): Session {
this.assertAddressAvailable(nodeId, sessionName, this.getBindingForNode(nodeId)?.attachmentType !== "external_cli");
const id = ulid();
this.db
.prepare(
@@ -307,6 +441,7 @@ export class SessionRegistry {
* enforcer's gen-scoped stage gate consumes. Never throws (a bad lookup returns null). */
currentOccupantGenerationForSession(sessionName: string): string | null {
try {
sessionName = physicalSessionName(this.db, sessionName);
const row = this.db
.prepare("SELECT node_id FROM sessions WHERE session_name = ? ORDER BY id DESC LIMIT 1")
.get(sessionName) as { node_id: string } | undefined;
@@ -538,6 +673,7 @@ export class SessionRegistry {
runtime: string | null;
currentProvenance: string | null;
} | null {
sessionName = savedSessionName(this.db, sessionName);
const row = this.db.prepare(
`SELECT s.id as session_id, s.node_id, n.rig_id, n.runtime, s.resume_provenance
FROM sessions s
@@ -1,3 +1,4 @@
import { physicalSessionName } from "./session-registry.js";
import { currentClaudeMcpMenu } from "./claude-mcp-menu.js";
import stringWidth from "string-width";
import { CODEX_TURN_STATUS_PATTERN } from "./codex-pane-patterns.js";
@@ -1827,6 +1828,7 @@ export class SessionTransport {
/** Freeze the registration and binding before asking tmux about this name. */
private absenceProbeTarget(sessionName: string): AbsenceProbeTarget | undefined {
sessionName = physicalSessionName(this.db, sessionName);
return this.db
.prepare(`
SELECT s.id AS session_id, n.id AS node_id, s.session_name AS session_name, b.tmux_pane AS tmux_pane
@@ -1862,6 +1864,7 @@ export class SessionTransport {
runtime: string | null; attachmentType: string | null; nodeId: string | null; pane: string | null; occupant: string | null; resumeToken: string | null;
rotation: ClaudeResumeRotation | null;
} {
sessionName = physicalSessionName(this.db, sessionName);
// One existing statement; P2 reads the binding columns it already joins plus the
// same current-occupant subselect the delivery guard uses. No extra query.
const row = this.db.prepare(`
@@ -1933,6 +1936,8 @@ export class SessionTransport {
}
private resolveBySessionName(sessionName: string): ResolveResult {
const current = this.sessionRegistry.resolveAddress(sessionName);
if (current) return { ok: true, sessions: [{ sessionName, rigName: current.rigName, nodeLogicalId: current.logicalId }] };
const sessionRows = this.db
.prepare("SELECT node_id, session_name FROM sessions WHERE session_name = ? ORDER BY id DESC")
.all(sessionName) as SessionRow[];
@@ -1993,8 +1998,9 @@ export class SessionTransport {
const sessions: ResolvedTarget[] = [];
const seen = new Set<string>();
for (const name of sessionNames) {
if (seen.has(name)) continue;
seen.add(name);
const identity = this.sessionRegistry.resolveAddress(name)?.nodeId ?? name;
if (seen.has(identity)) continue;
seen.add(identity);
const resolved = this.resolveBySessionName(name);
if (!resolved.ok) return resolved;
sessions.push(...resolved.sessions);
@@ -2337,6 +2343,7 @@ export class SessionTransport {
if (prePref.effective || prePref.desired || prePref.desiredMode === "draft-aware" || prePref.effectiveMode === "draft-aware") return null;
meta = this.getSessionMeta(sessionName);
bound = guard.target(sessionName);
sessionName = bound.session;
} catch {
return null;
}
@@ -2438,6 +2445,7 @@ export class SessionTransport {
}
private async sendUnguarded(sessionName: string, text: string, opts?: SendOpts): Promise<SendResult> {
sessionName = physicalSessionName(this.db, sessionName);
let preVerifyContent: string | null = null;
const sessionMeta = this.getSessionMeta(sessionName);
const runtime = sessionMeta.runtime;
@@ -3539,6 +3547,7 @@ export class SessionTransport {
* use it: the capture route asks when its request does (`rig capture`, the MCP tool). `inputBoxBudget` is the time
* those reads may still take, shared across a fan-out; a single capture gets its own CAPTURE_INPUT_BOX_DEADLINE_MS. */
async capture(sessionName: string, opts?: { lines?: number; inputBox?: boolean; inputBoxBudget?: InputBoxBudget }): Promise<CaptureResult> {
sessionName = physicalSessionName(this.db, sessionName);
const sessionMeta = this.getSessionMeta(sessionName);
if (sessionMeta.attachmentType === "external_cli") {
return {
@@ -3717,6 +3726,7 @@ export class SessionTransport {
* re-check readiness via the runtime adapter with currentness verification and atomic commit.
*/
async reconcileStartupReadiness(sessionName: string): Promise<boolean> {
sessionName = physicalSessionName(this.db, sessionName);
const existing = this.inFlightReconciliations.get(sessionName);
if (existing) return existing;
+13 -3
View File
@@ -1,3 +1,4 @@
import { resolveCurrentSessionAddress } from "./session-registry.js";
import type Database from "better-sqlite3";
import type { RigRepository } from "./rig-repository.js";
import type { SessionRegistry } from "./session-registry.js";
@@ -18,6 +19,7 @@ export interface WhoamiResult {
memberId: string;
memberLabel: string | null;
sessionName: string | null;
senderAddress: string | null;
runtime: string;
cwd: string | null;
agentRef: string | null;
@@ -154,6 +156,12 @@ export class WhoamiService {
this.contextUsageStore = deps.contextUsageStore ?? null;
}
private senderAddress(sessionName: string, nodeId: string): string {
const current = resolveCurrentSessionAddress(this.db, sessionName);
return current && resolveCurrentSessionAddress(this.db, current.senderAddress)?.nodeId === nodeId
? current.senderAddress : sessionName;
}
resolve(query: { nodeId?: string; sessionName?: string; targetRepoOverride?: string; compact?: boolean }): WhoamiResult | null {
let nodeRow: NodeRow | undefined;
let resolvedBy: "node_id" | "session_name";
@@ -166,9 +174,10 @@ export class WhoamiService {
currentSessionName = this.getCurrentSessionName(nodeRow.id, nodeRow.rig_id);
} else if (query.sessionName) {
// Find sessions matching this name — check for ambiguity across rigs
const sessionRows = this.db
.prepare("SELECT * FROM sessions WHERE session_name = ? ORDER BY id DESC")
.all(query.sessionName) as SessionRow[];
const current = resolveCurrentSessionAddress(this.db, query.sessionName);
const sessionRows = current && current.sessionName !== query.sessionName
? this.db.prepare("SELECT * FROM sessions WHERE id=?").all(current.sessionId) as SessionRow[]
: this.db.prepare("SELECT * FROM sessions WHERE session_name = ? ORDER BY id DESC").all(query.sessionName) as SessionRow[];
if (sessionRows.length === 0) return null;
@@ -242,6 +251,7 @@ export class WhoamiService {
memberId,
memberLabel: nodeRow.label,
sessionName: currentSessionName,
senderAddress: currentSessionName ? this.senderAddress(currentSessionName, nodeRow.id) : null,
runtime: nodeRow.runtime ?? "unknown",
cwd: nodeRow.cwd,
agentRef: nodeRow.agent_ref,
@@ -1,3 +1,4 @@
import { physicalSessionName } from "./session-registry.js";
import { readWorkflowGuidance, type GuidanceInput } from "./workflow-guidance.js";
// PL-004 Phase D: workflow projector — transactional-scribe contract.
//
@@ -133,6 +134,7 @@ export function workflowWaitWakeMessage(input: {
* facade (instantiate-time static pin checks).
*/
export function nodeRuntimeOf(db: Database.Database, session: string): string | null {
session = physicalSessionName(db, session);
const row = db
.prepare(
`SELECT n.runtime FROM nodes n
@@ -1,3 +1,4 @@
import { physicalSessionName } from "./session-registry.js";
import { readWorkflowGuidance, type GuidanceInput } from "./workflow-guidance.js";
import { exceptionConfigurationSource, inspectExceptionReadiness, type ExceptionReadiness } from "./workflow-exception-readiness.js";
import { inspectGraph, recoverGraphOperation, reviseGraph } from "./workflow-reconciliation.js";
@@ -345,6 +346,7 @@ export class WorkflowRuntime {
// EITHER way — report alive so no warning fires, and never let an advisory
// path fail validate/instantiate.
try {
sessionRef = physicalSessionName(this.db, sessionRef);
const row = this.db
.prepare(
`SELECT s.status AS status FROM sessions s WHERE s.session_name = ? ORDER BY s.created_at DESC LIMIT 1`,
+5 -1
View File
@@ -1,3 +1,4 @@
import { physicalSessionName } from "../domain/session-registry.js";
import { existsSync } from "node:fs";
import { Hono, type Context } from "hono";
import { streamSSE } from "hono/streaming";
@@ -183,6 +184,8 @@ activityRoutes.post("/hooks", async (c) => {
return c.json({ ok: false, code: "invalid_json", error: "Request body must be JSON." }, 400);
}
if (typeof body.sessionName === "string") body = { ...body, sessionName: physicalSessionName(store.db, body.sessionName) };
if (body.eventFamily === "session_identity") {
const sessionId = typeof body.sessionId === "string" ? body.sessionId : null;
const sessionName = stringOrNull(body.sessionName);
@@ -566,7 +569,8 @@ activityRoutes.get("/parked", (c) => {
`).all(scope.rig) as Array<{ node_id: string; session_name: string }>;
if (seatParam) {
const match = seats.find((s) => s.node_id === seatParam || s.session_name === seatParam);
const physical = physicalSessionName(rigRepo.db, seatParam);
const match = seats.find((s) => s.node_id === seatParam || s.session_name === physical);
if (!match) {
return c.json({
ok: false,
@@ -1,3 +1,4 @@
import { physicalSessionName } from "../domain/session-registry.js";
// Fork Primitive + Starter Agent Images v0 (PL-016) — daemon HTTP
// routes.
//
@@ -80,6 +81,7 @@ interface ForkSourceShape {
}
function resolveForkSourceNode(db: Database.Database, sourceSession: string): ForkSourceShape | null {
sourceSession = physicalSessionName(db, sourceSession);
// SELECT n.* so an older DB missing codex_config_profile simply yields
// undefined for that key rather than throwing.
const row = db
+2 -1
View File
@@ -1,3 +1,4 @@
import { physicalSessionName } from "../domain/session-registry.js";
import { Hono } from "hono";
import { shellQuote } from "../adapters/shell-quote.js";
import type Database from "better-sqlite3";
@@ -41,7 +42,7 @@ export function compactionRoutes(opts?: { bearerToken?: string | null }): Hono {
return c.json({ ok: false, error: "Missing required field: session" }, 400);
}
if (body.skipMap !== undefined && typeof body.skipMap !== "boolean") return c.json({ ok: false, error: "skipMap must be boolean" }, 400);
const sessionName = body.session;
const sessionName = physicalSessionName(db, body.session);
// Ambiguity / existence check — mirror /send (409 ambiguous, 404 not found).
const resolved = await transport.resolveSessions({ session: sessionName });
+3
View File
@@ -123,6 +123,7 @@ discoveryRoutes.post("/:id/bind", async (c) => {
case "node_not_found":
case "pod_not_found":
return c.json(result, 404);
case "address_conflict":
case "not_active":
case "already_bound":
case "runtime_mismatch":
@@ -166,6 +167,7 @@ discoveryRoutes.post("/:id/adopt", async (c) => {
case "rig_not_found":
case "node_not_found":
return c.json(result, 404);
case "address_conflict":
case "not_active":
case "already_bound":
case "runtime_mismatch":
@@ -205,6 +207,7 @@ discoveryRoutes.post("/:id/adopt", async (c) => {
case "rig_not_found":
case "pod_not_found":
return c.json(result, 404);
case "address_conflict":
case "not_active":
case "duplicate_logical_id":
case "invalid_member_name":
+3 -1
View File
@@ -1,3 +1,4 @@
import type { SessionRegistry } from "../domain/session-registry.js";
import { Hono } from "hono";
import { streamSSE } from "hono/streaming";
import type { EventBus } from "../domain/event-bus.js";
@@ -1286,7 +1287,8 @@ export function queueRoutes(): Hono {
if (!senderSession) return c.json({ error: "senderSession is required" }, 400);
const parsed = queryLimit(c);
if (!parsed.ok) return parsed.response;
return c.json(getOutbox(c).listForSender(senderSession, parsed.limit));
const sessions = c.get("sessionRegistry" as never) as SessionRegistry | undefined;
return c.json(getOutbox(c).listForSender(senderSession, parsed.limit, sessions?.sessionAddresses(senderSession)));
});
return app;
+1
View File
@@ -624,6 +624,7 @@ rigsRoutes.post("/:id/attach-self", async (c) => {
return c.json(result, 404);
case "runtime_required":
return c.json(result, 400);
case "address_conflict":
case "already_bound":
case "duplicate_logical_id":
case "invalid_member_name":
+1 -1
View File
@@ -220,7 +220,7 @@ seatRoutes.post("/handover/:seatRef", async (c) => {
if (result.code === "tmux_probe_failed") {
return c.json(result, 502);
}
if (result.code === "current_occupant_required" ||
if (result.code === "address_conflict" || result.code === "current_occupant_required" ||
result.code === "discovered_not_active" ||
result.code === "successor_tmux_absent" ||
result.code === "successor_already_managed" ||
+7 -3
View File
@@ -1,3 +1,4 @@
import { savedSessionName } from "../domain/session-registry.js";
import { Hono } from "hono";
import type Database from "better-sqlite3";
import type { RigRepository } from "../domain/rig-repository.js";
@@ -159,7 +160,7 @@ export function transcriptRoutes(): Hono {
const db = c.get("db" as never) as Database.Database;
const rigRepo = c.get("rigRepo" as never) as RigRepository;
const tmuxAdapter = c.get("tmuxAdapter" as never) as TmuxAdapter | undefined;
const sessionName = c.req.param("session");
let sessionName = c.req.param("session");
const rawLines = parseInt(c.req.query("lines") ?? "50", 10);
const lines = isNaN(rawLines) || rawLines < 1 ? 50 : rawLines;
@@ -170,6 +171,7 @@ export function transcriptRoutes(): Hono {
);
}
sessionName = savedSessionName(db, sessionName);
const resolution = resolveSessionToRig(db, rigRepo, sessionName);
if ("error" in resolution) {
return c.json({ error: resolution.error }, resolution.status as 404);
@@ -207,7 +209,7 @@ export function transcriptRoutes(): Hono {
const db = c.get("db" as never) as Database.Database;
const rigRepo = c.get("rigRepo" as never) as RigRepository;
const tmuxAdapter = c.get("tmuxAdapter" as never) as TmuxAdapter | undefined;
const sessionName = c.req.param("session");
let sessionName = c.req.param("session");
const pattern = c.req.query("pattern");
if (!pattern) {
@@ -231,6 +233,7 @@ export function transcriptRoutes(): Hono {
);
}
sessionName = savedSessionName(db, sessionName);
const resolution = resolveSessionToRig(db, rigRepo, sessionName);
if ("error" in resolution) {
return c.json({ error: resolution.error }, resolution.status as 404);
@@ -273,7 +276,7 @@ export function transcriptRoutes(): Hono {
const db = c.get("db" as never) as Database.Database;
const rigRepo = c.get("rigRepo" as never) as RigRepository;
const tmuxAdapter = c.get("tmuxAdapter" as never) as TmuxAdapter | undefined;
const sessionName = c.req.param("session");
let sessionName = c.req.param("session");
if (!transcriptStore?.enabled) {
return c.json(
@@ -282,6 +285,7 @@ export function transcriptRoutes(): Hono {
);
}
sessionName = savedSessionName(db, sessionName);
const resolution = resolveSessionToRig(db, rigRepo, sessionName);
if ("error" in resolution) {
return c.json({ error: resolution.error }, resolution.status as 404);
+4
View File
@@ -1,3 +1,4 @@
import { physicalSessionName } from "./domain/session-registry.js";
import { makeWatchdogDeliver, occurrenceAttempted, priorDigestAttempt } from "./domain/watchdog-delivery.js";
import { fileURLToPath } from "node:url";
import { discoverTailscaleSelfNames } from "./middleware/browser-boundary.js";
@@ -452,6 +453,7 @@ export async function createDaemon(opts?: DaemonOptions): Promise<DaemonResult>
// GHOST-STAGE (h): resolve the source seat's atom-B generation for the handoff-nudge Sent: line
// (same injected-predicate layering — the queue never imports the session domain).
resolveOccupantGeneration: (sessionName) => sessionRegistry.currentOccupantGenerationForSession(sessionName),
resolveSessionAddresses: (sessionName) => sessionRegistry.sessionAddresses(sessionName),
});
// W1 (transactional closure): ONE OutboxHandler shared between the deps slot
// (sender-side audit surface) and the queue repo's durable wake-intent
@@ -1400,6 +1402,7 @@ export async function createDaemon(opts?: DaemonOptions): Promise<DaemonResult>
// status == 'running'.
classifierLeaseManagerInstance.attachIsAlive((classifierSession: string): boolean => {
try {
classifierSession = physicalSessionName(db, classifierSession);
const row = db
.prepare(
`SELECT 1 FROM sessions WHERE session_name = ? AND status = 'running' LIMIT 1`,
@@ -2095,6 +2098,7 @@ export async function createDaemon(opts?: DaemonOptions): Promise<DaemonResult>
const sessionTransport = deps.sessionTransport;
if (sessionTransport) {
queueRepoInstance.attachActivityReader((session) => {
session = physicalSessionName(db, session);
const node = db.prepare("SELECT node_id FROM sessions WHERE session_name = ? ORDER BY id DESC LIMIT 1").get(session) as { node_id: string } | undefined;
return node ? seatActivityService.getSeatState(node.node_id) : null;
});
@@ -30,10 +30,16 @@ describe("#174 archived duplicate rig vs the live seat with the same name", () =
let stale: { rigId: string; nodeId: string };
let live: { rigId: string; nodeId: string; sessionId: string };
function seat(rigName: string, status: "running" | "exited", logicalId = "lead.planner", name = SEAT) {
function seat(rigName: string, status: "running" | "exited", logicalId = "lead.planner", name = SEAT, retainedDuplicate = false) {
const rig = rigRepo.createRig(rigName);
const node = rigRepo.addNode(rig.id, logicalId, { role: "planner", runtime: "claude-code" });
const session = sessionRegistry.registerSession(node.id, name);
// New duplicate joins refuse; these two ambiguity controls model retained
// pre-admission state while ordinary stopped/archived replacement stays real.
const session = retainedDuplicate ? { id: node.id } : sessionRegistry.registerSession(node.id, name);
if (retainedDuplicate) {
db.prepare("INSERT INTO sessions(id,node_id,session_name) VALUES (?,?,?)").run(session.id, node.id, name);
sessionRegistry.mintOccupantTenure(node.id, "initial");
}
sessionRegistry.updateStatus(session.id, status);
sessionRegistry.updateBinding(node.id, { tmuxSession: name });
return { rigId: rig.id, nodeId: node.id, sessionId: session.id };
@@ -103,7 +109,7 @@ describe("#174 archived duplicate rig vs the live seat with the same name", () =
});
it("(guard) control: two unarchived bindings to the same name still resolve to nothing", () => {
seat(RIG, "running");
seat(RIG, "running", "lead.planner", SEAT, true);
expect(resolveGuardTarget(db, SEAT)).toBeNull();
});
@@ -132,7 +138,7 @@ describe("#174 archived duplicate rig vs the live seat with the same name", () =
});
it("control: an unarchived duplicate is still reported as ambiguous", () => {
seat(RIG, "running", "lead.planner");
seat(RIG, "running", "lead.planner", SEAT, true);
const status = new SeatStatusService({ rigRepo }).getStatus(SEAT);
expect(status.ok).toBe(false);
@@ -120,6 +120,13 @@ describe("#141 shrink target members sharing a session name", () => {
let lifecycle: RigLifecycleService;
let target: { rigId: string; podId: string };
// Retained pre-admission state: new joins now refuse a physical name owned by
// another member. These controls exercise shrink/queue handling of old ambiguity.
function seedLegacySharedSession(nodeId: string) {
db.prepare("INSERT INTO sessions(id,node_id,session_name,status) VALUES (?,?,?,'running')").run(nodeId, nodeId, SHARED);
sessionRegistry.mintOccupantTenure(nodeId, "initial");
}
beforeEach(() => {
db = createFullTestDb();
rigRepo = new RigRepository(db);
@@ -135,7 +142,7 @@ describe("#141 shrink target members sharing a session name", () => {
const pod = new PodRepository(db).createPod(rig.id, "crew", "Crew");
for (const id of ["crew.a", "crew.b"]) {
const node = rigRepo.addNode(rig.id, id, { runtime: "claude-code", podId: pod.id });
sessionRegistry.updateStatus(sessionRegistry.registerSession(node.id, SHARED).id, "running");
seedLegacySharedSession(node.id);
sessionRegistry.updateBinding(node.id, { tmuxSession: SHARED });
}
target = { rigId: rig.id, podId: pod.id };
@@ -174,7 +181,7 @@ describe("#141 shrink target members sharing a session name", () => {
// The target members' bindings exist first, so a lookup that returned one of them before excluding it would miss this owner.
const other = rigRepo.addNode(rigRepo.createRig("other-rig").id, "dev.other", { runtime: "claude-code" });
if (via === "binding") sessionRegistry.updateBinding(other.id, { tmuxSession: SHARED });
else sessionRegistry.updateStatus(sessionRegistry.registerSession(other.id, SHARED).id, "running");
else seedLegacySharedSession(other.id);
const work = await openWork();
const result = await lifecycle.shrinkPod(target.rigId, target.podId);
@@ -5,6 +5,7 @@
import { readFileSync } from "node:fs";
import { join } from "node:path";
import { Hono } from "hono";
import { createDb } from "../src/db/connection.js";
import { afterEach, describe, expect, it, vi } from "vitest";
import { annotateInputBox } from "../src/domain/session-transport.js";
import { transportRoutes } from "../src/routes/transport.js";
@@ -211,7 +212,11 @@ describe("annotateInputBox: Codex", () => {
});
describe("rig capture reports the input box for agent seats only, leaving the pane text as it was", () => {
afterEach(() => { vi.useRealTimers(); });
const readModels: Array<ReturnType<typeof createDb>> = [];
afterEach(() => { vi.useRealTimers(); for (const db of readModels.splice(0)) db.close(); });
// A canonical-only read model has no alias bindings; use real sqlite_master
// rather than leaving the transport's required database dependency absent.
const readModel = () => { const db = createDb(); readModels.push(db); return db; };
async function makeTransport(getPaneCursorPosition: (promptRow: number) => Promise<unknown>, screenOverride?: string) {
const { SessionTransport } = await import("../src/domain/session-transport.js");
@@ -224,7 +229,7 @@ describe("rig capture reports the input box for agent seats only, leaving the pa
const make = (runtime: string) => {
const transport = Object.create(SessionTransport.prototype) as InstanceType<typeof SessionTransport>;
Object.assign(transport, {
tmuxAdapter: tmux,
db: readModel(), tmuxAdapter: tmux,
getSessionMeta: () => ({ runtime, attachmentType: "tmux", nodeId: "n1", pane: "%1", occupant: null, resumeToken: null }),
absenceProbeTarget: () => null,
now: () => new Date(),
@@ -323,7 +328,7 @@ describe("rig capture reports the input box for agent seats only, leaving the pa
const seats = ["s1@r", "s2@r", "s3@r", "s4@r", "s5@r"];
const transport = Object.create(SessionTransport.prototype) as InstanceType<typeof SessionTransport>;
Object.assign(transport, {
tmuxAdapter: tmux,
db: readModel(), tmuxAdapter: tmux,
getSessionMeta: () => ({ runtime: "claude-code", attachmentType: "tmux", nodeId: "n1", pane: "%1", occupant: null, resumeToken: null }),
absenceProbeTarget: () => null,
now: () => new Date(),
@@ -247,7 +247,8 @@ it('a failed manual trigger reports the /compact result too', async () => {
c.set('compactionEnforcer' as never, f.e);
c.set('sessionTransport' as never, f.transport);
c.set('contextUsageStore' as never, {getForNode: () => ({availability: 'known', usedPercentage: 40})});
c.set('db' as never, {prepare: () => ({get: () => ({node_id: 'node-one', runtime: 'claude-code', cwd: null})})});
// Canonical-only fixture: no bindings table exists for alias normalization.
c.set('db' as never, {prepare: (sql: string) => ({get: () => sql.includes('sqlite_master') ? undefined : ({node_id: 'node-one', runtime: 'claude-code', cwd: null})})});
await next();
});
app.route('/api/compaction', compactionRoutes());
@@ -267,7 +267,8 @@ for(const expiry of ['missing map','idle after completed map'])it(`route reports
c.set('compactionEnforcer' as never,f.e);
c.set('sessionTransport' as never,f.transport);
c.set('contextUsageStore' as never,{getForNode:()=>({availability:'known',usedPercentage:4})});
c.set('db' as never,{prepare:()=>({get:()=>({node_id:'node-one',runtime:'claude-code'})})});
// Canonical-only fixture: no bindings table exists for alias normalization.
c.set('db' as never,{prepare:(sql:string)=>({get:()=>sql.includes('sqlite_master')?undefined:({node_id:'node-one',runtime:'claude-code'})})});
await next();
});
app.route('/api/compaction',compactionRoutes());
@@ -291,7 +292,8 @@ function routeFixture(f:ReturnType<typeof fixture>, cwd?:string){
c.set('compactionEnforcer' as never,f.e);
c.set('sessionTransport' as never,f.transport);
c.set('contextUsageStore' as never,{getForNode:()=>({availability:'known',usedPercentage:4})});
c.set('db' as never,{prepare:()=>({get:()=>({node_id:'node-one',runtime:'claude-code',cwd})})});
// Canonical-only fixture: no bindings table exists for alias normalization.
c.set('db' as never,{prepare:(sql:string)=>({get:()=>sql.includes('sqlite_master')?undefined:({node_id:'node-one',runtime:'claude-code',cwd})})});
await next();
});
app.route('/api/compaction',compactionRoutes());
@@ -310,6 +310,20 @@ it("picks the project the bundle's rig claims for a row that names only its miss
expect(result.context!.sources).toContain(join(t.home, "workspace.yaml") + "#selected-by=rig:bundle-rig");
});
it.each(["0", "person-terminal", "custom@other-rig"])("keeps rig project inference with physical binding %s", async physical => {
const t = await inferenceSetup();
const address = t.seat("bundle-rig", join(t.home, "mine", "missions"));
const sessions = new SessionRegistry(t.db);
const nodeId = (t.db.prepare("SELECT node_id FROM sessions WHERE session_name=?").get(address) as { node_id: string }).node_id;
sessions.markSuperseded((t.db.prepare("SELECT id FROM sessions WHERE node_id=? ORDER BY id DESC LIMIT 1").get(nodeId) as { id: string }).id);
sessions.registerClaimedSession(nodeId, physical);
sessions.updateBinding(nodeId, { tmuxSession: physical, tmuxPane: "%42" });
await t.row("claimed", address, ["mission:release"]);
const result = t.service.resolve({ qitemId: "claimed" });
expect(result).toMatchObject({ posture: "human-led", source: "product-default", context: { projectId: "bundle", missionId: "release", paths: { project: join(t.home, "bundle") } } });
expect(result.context!.sources).toContain(join(t.home, "workspace.yaml") + "#selected-by=rig:bundle-rig");
});
it("picks the user's own project for its unclaimed rig by working folder, then as the only unclaimed entry", async () => {
const t = await inferenceSetup();
await t.row("by-folder", t.seat("user-rig", join(t.home, "mine", "missions")), ["mission:release"]);
@@ -0,0 +1,370 @@
// Real repositories/services/routes; tmux and runtime effects are inert recorders.
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { Hono } from "hono";
import { mkdtempSync, rmSync, writeFileSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { transcriptRoutes } from "../src/routes/transcripts.js";
import { discoverResumeToken } from "../src/domain/agent-images/resume-token-discovery.js";
import { createDb } from "../src/db/connection.js";
import { migrate } from "../src/db/migrate.js";
import { ALL_MIGRATIONS } from "../src/db/all-migrations.js";
import { RigRepository } from "../src/domain/rig-repository.js";
import { PodRepository } from "../src/domain/pod-repository.js";
import { SessionRegistry, physicalSessionName } from "../src/domain/session-registry.js";
import { EventBus } from "../src/domain/event-bus.js";
import { SelfAttachService } from "../src/domain/self-attach-service.js";
import { SessionTransport } from "../src/domain/session-transport.js";
import { QueueRepository } from "../src/domain/queue-repository.js";
import { SeatDeliveryGuard, resolveGuardTarget } from "../src/domain/seat-delivery-guard.js";
import { WhoamiService } from "../src/domain/whoami-service.js";
import { TranscriptStore } from "../src/domain/transcript-store.js";
import { OutboxHandler } from "../src/domain/outbox-handler.js";
import { AgentActivityStore } from "../src/domain/agent-activity-store.js";
import { NodeLauncher } from "../src/domain/node-launcher.js";
import type { TmuxAdapter } from "../src/adapters/tmux.js";
import { rigsRoutes } from "../src/routes/rigs.js";
import { transportRoutes } from "../src/routes/transport.js";
describe("attached team address preserves physical custody", () => {
let db: ReturnType<typeof createDb>, rigs: RigRepository, pods: PodRepository, sessions: SessionRegistry, bus: EventBus;
let attach: SelfAttachService, transport: SessionTransport, tmux: TmuxAdapter, app: Hono;
let transcriptRoot: string;
let writes: Array<{ target: string; text: string }>;
beforeEach(() => {
db = createDb(); migrate(db, ALL_MIGRATIONS);
rigs = new RigRepository(db); pods = new PodRepository(db); sessions = new SessionRegistry(db); bus = new EventBus(db);
transcriptRoot = mkdtempSync(join(tmpdir(), "attach-history-"));
writes = [];
tmux = { hasSession: async () => true, probeSession: async () => ({ state: "present" }),
capturePaneContent: async () => "plain terminal", getPaneCommand: async () => "sh",
sendText: async (target: string, text: string) => { writes.push({ target, text }); return { ok: true }; },
sendKeys: async () => ({ ok: true }), createSession: vi.fn(async () => ({ ok: false, code: "session_exists", message: "inert" })),
} as unknown as TmuxAdapter;
attach = new SelfAttachService({ db, rigRepo: rigs, podRepo: pods, sessionRegistry: sessions, eventBus: bus, tmuxAdapter: tmux });
transport = new SessionTransport({ db, rigRepo: rigs, sessionRegistry: sessions, tmuxAdapter: tmux, sleep: async () => {} });
app = new Hono(); app.use("*", async (c, next) => { c.set("selfAttachService" as never, attach); c.set("sessionTransport" as never, transport); await next(); });
app.route("/api/rigs", rigsRoutes); app.route("/api/transport", transportRoutes());
});
afterEach(() => { db.close(); rmSync(transcriptRoot, { recursive: true, force: true }); });
const context = (tmuxSession: string) => ({ attachmentType: "tmux" as const, tmuxSession, tmuxWindow: "@12", tmuxPane: "%42" });
it.each(["0", "person-terminal"])("returns a routable sender and records the display label without renaming %s", async (physical) => {
const rig = rigs.createRig("sample-team"), node = rigs.addNode(rig.id, "person.agent", { runtime: "terminal" });
const result = await attach.attachToNode({ rigId: rig.id, logicalId: node.logicalId, displayName: "Taylor", context: context(physical) });
expect(result).toMatchObject({ ok: true, sessionName: physical, senderAddress: "person-agent@sample-team", env: { OPENRIG_SESSION_NAME: "person-agent@sample-team" } });
expect(rigs.getRig(rig.id)?.nodes[0]?.label).toBe("Taylor");
expect(sessions.getBindingForNode(node.id)).toMatchObject({ tmuxSession: physical, tmuxPane: "%42" });
expect(sessions.getSessionsForRig(rig.id)[0]?.sessionName).toBe(physical);
if (!result.ok) throw new Error("attach failed");
const response = await app.request("/api/transport/send", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ session: result.env.OPENRIG_SESSION_NAME, text: "harmless reply" }) });
expect(response.status).toBe(200); expect((await response.json()).ok).toBe(true);
expect(writes).toEqual([{ target: physical, text: "harmless reply" }]);
const raw = await transport.send(physical, "raw reply"); expect(raw.ok).toBe(true);
expect(writes.at(-1)).toEqual({ target: physical, text: "raw reply" });
});
it("refuses an ambiguous flattened member before binding or provisioning", async () => {
const rig = rigs.createRig("team"), first = rigs.addNode(rig.id, "p.a-b", { runtime: "terminal" });
expect((await attach.attachToNode({ rigId: rig.id, logicalId: first.logicalId, context: context("first") })).ok).toBe(true);
const second = rigs.addNode(rig.id, "p-a.b", { runtime: "terminal" });
const before = db.prepare("SELECT * FROM sessions ORDER BY id").all();
const result = await attach.attachToNode({ rigId: rig.id, logicalId: second.logicalId, context: context("second") });
expect(result).toMatchObject({ ok: false, code: "address_conflict" });
if (!result.ok) expect(result.error).toMatch(/another member name/i);
expect(sessions.getBindingForNode(second.id)).toBeNull();
expect(db.prepare("SELECT * FROM sessions ORDER BY id").all()).toEqual(before);
});
it("refuses a new physical name that would steal another member's advertised address", async () => {
const rig = rigs.createRig("team"), first = rigs.addNode(rig.id, "person.agent", { runtime: "terminal" });
await attach.attachToNode({ rigId: rig.id, logicalId: first.logicalId, context: context("0") });
const second = rigs.addNode(rig.id, "other.agent", { runtime: "terminal" });
expect(await attach.attachToNode({ rigId: rig.id, logicalId: second.logicalId, context: context("person-agent@team") })).toMatchObject({ ok: false, code: "address_conflict" });
expect(sessions.getBindingForNode(first.id)?.tmuxSession).toBe("0"); expect(sessions.getBindingForNode(second.id)).toBeNull();
});
it("refuses a new join sharing another current member's raw physical name", async () => {
const rig = rigs.createRig("team"), first = rigs.addNode(rig.id, "first.agent", { runtime: "terminal" });
expect((await attach.attachToNode({ rigId: rig.id, logicalId: first.logicalId, context: context("0") })).ok).toBe(true);
const other = rigs.addNode(rig.id, "other.agent", { runtime: "terminal" });
const before = db.prepare("SELECT * FROM sessions ORDER BY id").all();
expect(await attach.attachToNode({ rigId: rig.id, logicalId: other.logicalId, context: context("0") }))
.toMatchObject({ ok: false, code: "address_conflict" });
expect(sessions.getBindingForNode(other.id)).toBeNull();
expect(db.prepare("SELECT * FROM sessions ORDER BY id").all()).toEqual(before);
expect(writes).toHaveLength(0); expect(tmux.createSession).not.toHaveBeenCalled();
});
it("later launch refuses the same address before creating any tmux session", async () => {
const rig = rigs.createRig("team"), first = rigs.addNode(rig.id, "person.agent", { runtime: "terminal" });
await attach.attachToNode({ rigId: rig.id, logicalId: first.logicalId, context: context("0") });
const second = rigs.addNode(rig.id, "other.agent", { runtime: "terminal" });
const launcher = new NodeLauncher({ db, rigRepo: rigs, sessionRegistry: sessions, eventBus: bus, tmuxAdapter: tmux });
expect(await launcher.launchNode(rig.id, second.logicalId, { sessionName: "person-agent@team" })).toMatchObject({ ok: false, code: "address_conflict" });
expect(tmux.createSession).not.toHaveBeenCalled();
});
it("logical and raw queue owners see and claim the same current rows without rewriting them", async () => {
const rig = rigs.createRig("team"), node = rigs.addNode(rig.id, "person.agent", { runtime: "terminal" });
await attach.attachToNode({ rigId: rig.id, logicalId: node.logicalId, context: context("0") });
const queue = new QueueRepository(db, bus, { resolveSessionAddresses: (name: string) => sessions.sessionAddresses(name) });
const item = await queue.create({ sourceSession: "operator@team", destinationSession: "0", body: "work", nudge: false });
expect(queue.list({ destinationSession: "person-agent@team" }).map((q) => q.qitemId)).toContain(item.qitemId);
expect(queue.claim({ qitemId: item.qitemId, destinationSession: "person-agent@team" }).destinationSession).toBe("0");
expect(queue.whoami("person-agent@team").asDestination.inProgress).toBe(1);
});
it("pod and multi-dot addresses use the same rule, label and physical transcript path", async () => {
const rig = rigs.createRig("team"); pods.createPod(rig.id, "people", "People");
const result = await attach.attachToPod({ rigId: rig.id, podNamespace: "people", memberName: "person.agent", runtime: "terminal", displayName: "Taylor", context: context("0") });
expect(result).toMatchObject({ ok: true, senderAddress: "people-person-agent@team" });
if (!result.ok) throw new Error("attach failed");
const store = new TranscriptStore({ transcriptsRoot: "/tmp/inert-attach-proof", enabled: true });
const who = new WhoamiService({ db, rigRepo: rigs, sessionRegistry: sessions, transcriptStore: store }).resolve({ sessionName: result.senderAddress, compact: true });
expect(who?.identity).toMatchObject({ sessionName: "0", senderAddress: result.senderAddress, memberLabel: "Taylor" });
expect(who?.transcript.path).toBe(store.getTranscriptPath("team", "0"));
const resolved = await transport.resolveSessions({ sessions: [result.senderAddress, "0"] });
expect(resolved.ok && resolved.sessions).toHaveLength(1);
expect((await transport.capture(result.senderAddress)).ok).toBe(true);
});
it("a second attach to the same member retains the existing already-bound result, not a clash", async () => {
const rig = rigs.createRig("team"), node = rigs.addNode(rig.id, "person.agent", { runtime: "terminal" });
await attach.attachToNode({ rigId: rig.id, logicalId: node.logicalId, context: context("0") });
expect(await attach.attachToNode({ rigId: rig.id, logicalId: node.logicalId, context: context("0") })).toMatchObject({ ok: false, code: "already_bound" });
expect(() => sessions.assertAddressAvailable(node.id, "0")).not.toThrow();
expect(sessions.getSessionsForRig(rig.id)).toHaveLength(1);
});
it("archived namesakes do not own a new live member's address", async () => {
const old = rigs.createRig("team"), former = rigs.addNode(old.id, "person.agent", { runtime: "terminal" });
await attach.attachToNode({ rigId: old.id, logicalId: former.logicalId, context: context("old") });
db.prepare("UPDATE rigs SET archived_at='2026-10-01T00:00:00Z' WHERE id=?").run(old.id);
const rig = rigs.createRig("team"), current = rigs.addNode(rig.id, "person.agent", { runtime: "terminal" });
expect(await attach.attachToNode({ rigId: rig.id, logicalId: current.logicalId, context: context("0") })).toMatchObject({ ok: true, senderAddress: "person-agent@team" });
expect(resolveGuardTarget(db, "person-agent@team")?.nodeId).toBe(current.id);
expect((await transport.send("person-agent@team", "current reply")).ok).toBe(true);
expect(writes.at(-1)?.target).toBe("0");
});
it.each(["unbound", "archived", "older session"])("reserves an exact historical address before any join effect (%s)", async state => {
const rig = rigs.createRig("team"), former = rigs.addNode(rig.id, "former.agent", { runtime: "terminal" });
await attach.attachToNode({ rigId: rig.id, logicalId: former.logicalId, context: context("person-agent@team") });
const oldSession = sessions.getSessionsForRig(rig.id)[0]!;
sessions.clearBinding(former.id); sessions.markSuperseded(oldSession.id);
if (state === "older session") {
sessions.registerClaimedSession(former.id, "different-physical");
sessions.updateBinding(former.id, { tmuxSession: "different-physical", tmuxPane: "%77" });
}
let currentRig = rig;
if (state === "archived") {
db.prepare("UPDATE rigs SET archived_at='2026-10-01T00:00:00Z' WHERE id=?").run(rig.id);
currentRig = rigs.createRig("team");
}
const current = rigs.addNode(currentRig.id, "person.agent", { runtime: "terminal", label: "before" });
const stored = () => Object.fromEntries((db.prepare("SELECT name FROM sqlite_master WHERE type='table' AND name NOT LIKE 'sqlite_%' ORDER BY name").all() as Array<{name:string}>)
.map(({name}) => [name, db.prepare(`SELECT * FROM "${name}"`).all()]));
const before = stored();
const result = await attach.attachToNode({ rigId: currentRig.id, logicalId: current.logicalId, displayName: "must not apply", context: context("0") });
expect(result).toMatchObject({ ok: false, code: "address_conflict", error: expect.stringContaining("Choose another member name") });
expect(stored()).toEqual(before);
expect(writes).toHaveLength(0);expect(tmux.createSession).not.toHaveBeenCalled();
expect(physicalSessionName(db, "person-agent@team")).toBe("person-agent@team");
});
it.each([
["archived", "person-agent@team"], ["archived", "0"],
["detached", "0"], ["exited", "0"], ["superseded", "0"],
])("the same seat's successor owns guarded replies after %s on %s", async (state, physical) => {
const address = "person-agent@team";
const oldRig = rigs.createRig("team"), oldNode = rigs.addNode(oldRig.id, "person.agent", { runtime: "terminal" });
expect((await attach.attachToNode({ rigId: oldRig.id, logicalId: oldNode.logicalId, context: { ...context(address), tmuxPane: "%41" } })).ok).toBe(true);
const oldSession = sessions.getSessionsForRig(oldRig.id)[0]!;
if (state === "archived") { sessions.updateStatus(oldSession.id, "exited"); rigs.archiveRig(oldRig.id); }
else db.prepare("UPDATE sessions SET status=? WHERE id=?").run(state, oldSession.id);
const rig = rigs.createRig("team"), node = rigs.addNode(rig.id, "person.agent", { runtime: "terminal" });
expect(await attach.attachToNode({ rigId: rig.id, logicalId: node.logicalId, context: context(physical) }))
.toMatchObject({ ok: true, sessionName: physical, senderAddress: address });
expect(physicalSessionName(db, address)).toBe(physical);
const guard = new SeatDeliveryGuard(db, name => resolveGuardTarget(db, name));
Object.defineProperty(tmux, "deliveryGuard", { value: guard });
const guarded = new SessionTransport({ db, rigRepo: rigs, sessionRegistry: sessions, tmuxAdapter: tmux, sleep: async () => {} });
const target = { nodeId: node.id, session: physical, pane: "%42", occupant: sessions.currentOccupantTenure(node.id)!.generationUuid };
expect(guard.target(address)).toEqual(target);
expect(guard.target(oldNode.id)).toEqual({ nodeId: oldNode.id, session: address, pane: "%41", occupant: sessions.currentOccupantTenure(oldNode.id)!.generationUuid });
if (physical === "0") expect(sessions.findResumeContextByName(address)?.sessionId).toBe(oldSession.id);
await guard.set(oldNode.id, true, "operator@team", "old preference");
await guard.set(node.id, false, "operator@team", "successor is off");
expect((await guarded.send(address, "replacement reply")).ok).toBe(true);
expect(writes).toEqual([{ target: physical, text: "replacement reply" }]);
await guard.set(oldNode.id, false, "operator@team", "old is off");
await guard.set(node.id, true, "operator@team", "successor holds");
expect(await guarded.send(address, "held reply", { deliveryId: "successor-held", actorSession: "operator@team" }))
.toMatchObject({ ok: true, sent: false, outcome: "retained" });
expect(new OutboxHandler(db).getById("successor-held")?.guardBinding).toEqual(target);
expect(writes).toHaveLength(1);
expect(sessions.getSessionsForRig(oldRig.id)[0]?.sessionName).toBe(address);
});
it.each(["0", "person-terminal"].flatMap(physical => [
{ physical, competitorHeld: true }, { physical, competitorHeld: false },
]))("keeps an unrelated exact binding ambiguous for $physical (competitor held=$competitorHeld)", async ({ physical, competitorHeld }) => {
const address = "person-agent@team", rig = rigs.createRig("team");
const node = rigs.addNode(rig.id, "person.agent", { runtime: "terminal" });
expect((await attach.attachToNode({ rigId: rig.id, logicalId: node.logicalId, context: context(physical) })).ok).toBe(true);
const other = rigs.addNode(rig.id, "other.agent", { runtime: "terminal" });
// A later competing binding has no matching session; routing still selects
// the attached person, but the guard must not prefer the unrelated owner.
sessions.updateBinding(other.id, { tmuxSession: address, tmuxPane: "%77" });
expect(sessions.resolveAddress(address)?.nodeId).toBe(node.id);
expect(physicalSessionName(db, address)).toBe(physical);
const guard = new SeatDeliveryGuard(db, name => resolveGuardTarget(db, name));
Object.defineProperty(tmux, "deliveryGuard", { value: guard });
const guarded = new SessionTransport({ db, rigRepo: rigs, sessionRegistry: sessions, tmuxAdapter: tmux, sleep: async () => {} });
await guard.set(node.id, !competitorHeld, "operator@team", "person preference");
await guard.set(other.id, competitorHeld, "operator@team", "opposing preference");
expect(await guarded.send(address, "must not send or retain", { deliveryId: "ambiguous-reply", actorSession: "operator@team" }))
.toMatchObject({ ok: false, sent: false, reason: "guard_target_unknown" });
expect(resolveGuardTarget(db, address)).toBeNull();
expect(writes).toEqual([]);
expect(new OutboxHandler(db).getById("ambiguous-reply")).toBeNull();
expect(guard.target(node.id).nodeId).toBe(node.id);
expect(guard.target("%77").nodeId).toBe(other.id);
});
it.each(["running", "pending", "unknown"])("does not treat a %s same-seat predecessor as stopped", async status => {
const oldRig = rigs.createRig("team"), oldNode = rigs.addNode(oldRig.id, "person.agent", { runtime: "terminal" });
await attach.attachToNode({ rigId: oldRig.id, logicalId: oldNode.logicalId, context: context("person-agent@team") });
db.prepare("UPDATE sessions SET status=? WHERE node_id=?").run(status, oldNode.id);
const rig = rigs.createRig("team"), node = rigs.addNode(rig.id, "person.agent", { runtime: "terminal" });
expect(await attach.attachToNode({ rigId: rig.id, logicalId: node.logicalId, context: context("0") }))
.toMatchObject({ ok: false, code: "address_conflict" });
expect(sessions.getBindingForNode(node.id)).toBeNull(); expect(writes).toHaveLength(0);
});
it("retained legacy collisions keep exact history and do not select a different occupant", async () => {
const rig = rigs.createRig("team"), former = rigs.addNode(rig.id, "former.agent", { runtime: "terminal" });
await attach.attachToNode({ rigId: rig.id, logicalId: former.logicalId, context: context("person-agent@team") });
sessions.clearBinding(former.id);sessions.markSuperseded(sessions.getSessionsForRig(rig.id)[0]!.id);
const current = rigs.addNode(rig.id, "person.agent", { runtime: "terminal" });
// Pre-existing data is not a new join permitted by the admission check.
db.prepare("INSERT INTO sessions(id,node_id,session_name,status,origin) VALUES ('legacy-current',?,'0','running','claimed')").run(current.id);
sessions.updateBinding(current.id, { tmuxSession: "0", tmuxPane: "%99" });
expect(physicalSessionName(db, "person-agent@team")).toBe("person-agent@team");
expect(resolveGuardTarget(db, "person-agent@team")).toBeNull();
expect(resolveGuardTarget(db, "0")?.nodeId).toBe(current.id);
const whoami = new WhoamiService({ db, rigRepo: rigs, sessionRegistry: sessions, transcriptStore: new TranscriptStore({ enabled: false }) });
expect(whoami.resolve({ sessionName: "person-agent@team" })?.identity.nodeId).toBe(former.id);
expect(whoami.resolve({ nodeId: current.id })?.identity.senderAddress).toBe("0");
});
it("the historical owner can reconnect under its own logical address", async () => {
const rig = rigs.createRig("team"), node = rigs.addNode(rig.id, "person.agent", { runtime: "terminal" });
const original = await attach.attachToNode({ rigId: rig.id, logicalId: node.logicalId, context: context("person-agent@team") });
expect(original.ok).toBe(true);
sessions.clearBinding(node.id);sessions.markSuperseded(sessions.getSessionsForRig(rig.id)[0]!.id);
expect(await attach.attachToNode({ rigId: rig.id, logicalId: node.logicalId, context: context("0") }))
.toMatchObject({ ok: true, senderAddress: "person-agent@team", sessionName: "0" });
});
async function reconnect() {
const rig = rigs.createRig("team"), node = rigs.addNode(rig.id, "person.agent", { runtime: "terminal" });
expect((await attach.attachToNode({ rigId: rig.id, logicalId: node.logicalId, context: context("person-agent@team") })).ok).toBe(true);
const old = sessions.getSessionsForRig(rig.id)[0]!;
sessions.clearBinding(node.id); sessions.updateStatus(old.id, "exited");
expect(await attach.attachToNode({ rigId: rig.id, logicalId: node.logicalId, context: context("0") }))
.toMatchObject({ ok: true, senderAddress: "person-agent@team", sessionName: "0" });
const current = sessions.getSessionsForRig(rig.id).find(s => s.sessionName === "0")!;
expect((await transport.send("person-agent@team", "current reply")).ok).toBe(true);
expect(writes).toEqual([{ target: "0", text: "current reply" }]);
return { node, old, current };
}
it.each(["tail", "grep?pattern=output", "full"])("keeps exact saved transcript reads after same-node reconnect (%s)", async route => {
await reconnect();
const store = new TranscriptStore({ transcriptsRoot: transcriptRoot, enabled: true });
store.ensureTranscriptDir("team");
writeFileSync(store.getTranscriptPath("team", "person-agent@team"), "old output\n");
writeFileSync(store.getTranscriptPath("team", "0"), "new output\n");
const history = new Hono();
history.use("*", async (c, next) => {
c.set("db" as never, db); c.set("rigRepo" as never, rigs); c.set("transcriptStore" as never, store); await next();
});
history.route("/api/transcripts", transcriptRoutes());
for (const [name, expected, absent] of [["person-agent@team", "old output", "new output"], ["0", "new output", "old output"]]) {
const response = await history.request(`/api/transcripts/${name}/${route}`);
expect(response.status).toBe(200);
const body = await response.json();
expect(body.session).toBe(name);
expect(JSON.stringify(body)).toContain(expected); expect(JSON.stringify(body)).not.toContain(absent);
}
});
it.each(["claude-code", "codex"])("keeps exact saved native context after same-node reconnect (%s)", async runtime => {
const { node, old, current } = await reconnect();
db.prepare("UPDATE nodes SET runtime=? WHERE id=?").run(runtime, node.id);
db.prepare("UPDATE sessions SET resume_token=? WHERE id=?").run("old-native-id", old.id);
db.prepare("UPDATE sessions SET resume_token=? WHERE id=?").run("new-native-id", current.id);
expect(discoverResumeToken(db, "person-agent@team")).toMatchObject({ ok: true, result: { runtime, nativeId: "old-native-id" } });
expect(discoverResumeToken(db, "0")).toMatchObject({ ok: true, result: { runtime, nativeId: "new-native-id" } });
expect(sessions.findResumeContextByName("person-agent@team")?.sessionId).toBe(old.id);
expect(sessions.findResumeContextByName("0")?.sessionId).toBe(current.id);
});
it("a reused physical name does not newly transfer old raw queue ownership through the alias", async () => {
const rig = rigs.createRig("team"), former = rigs.addNode(rig.id, "former.agent", { runtime: "terminal" });
await attach.attachToNode({ rigId: rig.id, logicalId: former.logicalId, context: context("0") });
const queue = new QueueRepository(db, bus, { resolveSessionAddresses: name => sessions.sessionAddresses(name) });
const old = await queue.create({ sourceSession: "operator@team", destinationSession: "0", body: "former raw record", nudge: false });
sessions.clearBinding(former.id); sessions.markSuperseded(sessions.getSessionsForRig(rig.id)[0]!.id);
const current = rigs.addNode(rig.id, "person.agent", { runtime: "terminal" });
await attach.attachToNode({ rigId: rig.id, logicalId: current.logicalId, context: context("0") });
expect(queue.list({ destinationSession: "person-agent@team" })).toHaveLength(0);
expect(() => queue.claim({ qitemId: old.qitemId, destinationSession: "person-agent@team" })).toThrow(/destination/);
expect(queue.getById(old.qitemId)?.destinationSession).toBe("0");
expect(queue.list({ destinationSession: "0" })).toHaveLength(1); // raw compatibility is unchanged
});
it("both spellings take the same lease, and a waiting old occupant cannot acquire its replacement", async () => {
const rig = rigs.createRig("team"), node = rigs.addNode(rig.id, "person.agent", { runtime: "terminal" });
await attach.attachToNode({ rigId: rig.id, logicalId: node.logicalId, context: context("0") });
const guard = new SeatDeliveryGuard(db, name => resolveGuardTarget(db, name));
expect(guard.target("person-agent@team")).toEqual(guard.target("0"));
let release!: () => void, entered!: () => void;
const started = new Promise<void>(resolve => { entered = resolve; });
const held = guard.operation("0", async () => { entered(); await new Promise<void>(resolve => { release = resolve; }); });
await started;
const effect = vi.fn(); const next = guard.operation("person-agent@team", async () => effect());
const rejection = expect(next).rejects.toMatchObject({ code: "guard_target_changed" });
sessions.updateBinding(node.id, { tmuxPane: "%99" }); release(); await held; await rejection;
expect(effect).not.toHaveBeenCalled();
});
it("retained IDs preserve the requested spelling and frozen pane across an alias and occupant change", async () => {
const rig = rigs.createRig("team"), node = rigs.addNode(rig.id, "person.agent", { runtime: "terminal" });
await attach.attachToNode({ rigId: rig.id, logicalId: node.logicalId, context: context("0") });
const guard = new SeatDeliveryGuard(db, name => resolveGuardTarget(db, name));
Object.defineProperty(tmux, "deliveryGuard", { value: guard });
const guarded = new SessionTransport({ db, rigRepo: rigs, sessionRegistry: sessions, tmuxAdapter: tmux });
await guard.set(node.id, true, "operator@team", "fixture hold");
const first = await guarded.send("person-agent@team", "kept text", { deliveryId: "stable-id", actorSession: "operator@team" });
expect(first).toMatchObject({ ok: true, sent: false, outcome: "retained" });
const outbox = new OutboxHandler(db), before = outbox.getById("stable-id")!;
expect(before.destinationSession).toBe("person-agent@team"); expect(before.guardBinding?.session).toBe("0"); expect(before.guardBinding?.pane).toBe("%42");
expect(await guarded.send("0", "kept text", { deliveryId: "stable-id", actorSession: "operator@team" })).toMatchObject({ ok: false, reason: "delivery_identity_conflict" });
sessions.updateBinding(node.id, { tmuxPane: "%99" });
await guarded.send("person-agent@team", "kept text", { deliveryId: "stable-id", actorSession: "operator@team" });
expect(outbox.getById("stable-id")).toEqual(before); expect(writes).toHaveLength(0);
});
it("hook resolution uses the current physical identity and retains old-session mismatch checks", async () => {
const rig = rigs.createRig("team"), node = rigs.addNode(rig.id, "person.agent", { runtime: "terminal" });
await attach.attachToNode({ rigId: rig.id, logicalId: node.logicalId, context: context("0") });
const activity = new AgentActivityStore({ db, eventBus: bus });
expect(activity.resolveSession({ sessionName: "person-agent@team" })).toMatchObject({ nodeId: node.id, sessionName: "0" });
expect(sessions.currentOccupantGenerationForSession("person-agent@team")).toBe(sessions.currentOccupantGenerationForSession("0"));
expect(sessions.findResumeContextByName("person-agent@team")?.nodeId).toBe(node.id);
});
});
+2 -1
View File
@@ -299,7 +299,8 @@ describe("tmuxControlExec", () => {
it("spawns load-buffer, alone or in a chain: the server would open the file itself", async () => {
const client = fakeClient();
const { spawned, fallback } = fakeSpawned();
const exec = tmuxControlExec(fallback, { spawnClient: () => client.child, replyTimeoutMs: 50 });
// Dispatch semantics, not a 50ms reply race against waitFor's 50ms poll.
const exec = tmuxControlExec(fallback, { spawnClient: () => client.child, now: () => 0 });
const read = exec(["tmux", "display-message", "-p", "-t", "=seat@rig:", "x"]);
await vi.waitFor(() => expect(client.written).toHaveLength(1));
@@ -160,7 +160,9 @@ describe("transcript capture stops when a single session ends", () => {
const owner = seedSeat("dev.impl");
const stale = setup.rigRepo.createRig("stale-rig");
const staleNode = setup.rigRepo.addNode(stale.id, "dev.impl", { runtime: "claude-code" });
setup.sessionRegistry.registerSession(staleNode.id, owner.sessionName);
// Retained pre-admission duplicate, not a new shared join (which now refuses).
db.prepare("INSERT INTO sessions(id,node_id,session_name) VALUES (?,?,?)").run(staleNode.id, staleNode.id, owner.sessionName);
setup.sessionRegistry.mintOccupantTenure(staleNode.id, "initial");
rotate(owner.sessionName);
const res = await setup.app.request(`/api/rigs/${stale.id}/nodes/dev.impl`, { method: "DELETE" });
@@ -277,7 +277,9 @@ describe("transcript routes", () => {
const rig2 = rigRepo.createRig("other-rig");
const node2 = rigRepo.addNode(rig2.id, "dev-impl2", { role: "worker", runtime: "claude-code" });
sessionRegistry.registerSession(node2.id, "dev-impl@my-rig");
// Legacy ambiguity is retained data, not a new registration that admission permits.
db.prepare("INSERT INTO sessions(id,node_id,session_name) VALUES (?,?,?)")
.run("legacy-ambiguous-session", node2.id, "dev-impl@my-rig");
const store = new TranscriptStore({ transcriptsRoot: tmpDir, enabled: true });
const app = createApp({ db, rigRepo, sessionRegistry, transcriptStore: store });
@@ -408,7 +410,9 @@ describe("transcript routes", () => {
const rig2 = rigRepo.createRig("other-rig");
const node2 = rigRepo.addNode(rig2.id, "dev-impl2", { role: "worker", runtime: "claude-code" });
sessionRegistry.registerSession(node2.id, "dev-impl@my-rig");
// Legacy ambiguity is retained data, not a new registration that admission permits.
db.prepare("INSERT INTO sessions(id,node_id,session_name) VALUES (?,?,?)")
.run("legacy-ambiguous-session", node2.id, "dev-impl@my-rig");
const store = new TranscriptStore({ transcriptsRoot: tmpDir, enabled: true });
const app = createApp({ db, rigRepo, sessionRegistry, transcriptStore: store });