mirror of
https://github.com/mvschwarz/openrig.git
synced 2026-11-09 11:51:37 -10:00
Compare commits
27
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
9d6cfbf5d2 | ||
|
|
db04d7dbd8 | ||
|
|
d3da20b48a | ||
|
|
9854eb03d8 | ||
|
|
b45a9e8d08 | ||
|
|
0e1ff99d84 | ||
|
|
420c66c99f | ||
|
|
c6dc2bfc19 | ||
|
|
870707e64f | ||
|
|
7a21b12280 | ||
|
|
1ccb086168 | ||
|
|
222dd81de2 | ||
|
|
45e7f54349 | ||
|
|
4e9d22b0e4 | ||
|
|
a3e89cc26c | ||
|
|
46b570b708 | ||
|
|
b530b8f4d1 | ||
|
|
579f702a19 | ||
|
|
233a9e9461 | ||
|
|
2deb793b70 | ||
|
|
e5922d987b | ||
|
|
addcd26d62 | ||
|
|
5831d30dd5 | ||
|
|
55205fdb1f | ||
|
|
6f8283bcb7 | ||
|
|
c528b2a889 | ||
|
|
551d6171b9 |
No files matched your search
@@ -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"
|
||||
|
||||
@@ -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 });
|
||||
}
|
||||
});
|
||||
@@ -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;
|
||||
|
||||
@@ -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]));
|
||||
|
||||
|
||||
@@ -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).
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 = ?");
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -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`,
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 });
|
||||
|
||||
@@ -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":
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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":
|
||||
|
||||
@@ -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" ||
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
|
||||
});
|
||||
@@ -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 });
|
||||
|
||||
Reference in new issue
Block a user