Skip to content

Commit 63889c2

Browse files
fix: use tmux metadata handle for reliable active session status (#449)
Treat persisted tmuxName as metadata-derived runtime identity when runtimeHandle is missing so active sessions are not misclassified as exited/unknown across agents.
1 parent dc85cdb commit 63889c2

File tree

2 files changed

+94
-1
lines changed

2 files changed

+94
-1
lines changed

packages/core/src/__tests__/session-manager.test.ts

Lines changed: 84 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -34,6 +34,7 @@ import {
3434
type Tracker,
3535
type SCM,
3636
type RuntimeHandle,
37+
type Session,
3738
} from "../types.js";
3839

3940
let tmpDir: string;
@@ -1387,6 +1388,89 @@ describe("list", () => {
13871388
expect(sessions[0].activity).toBe("active");
13881389
});
13891390

1391+
it.each(["claude-code", "codex", "aider", "opencode"])(
1392+
"uses tmuxName fallback handle for %s activity detection when runtimeHandle is missing",
1393+
async (agentName: string) => {
1394+
const expectedTmuxName = "hash-app-1";
1395+
const selectedAgent: Agent = {
1396+
...mockAgent,
1397+
name: agentName,
1398+
getActivityState: vi.fn().mockImplementation(async (session: Session) => {
1399+
return {
1400+
state: session.runtimeHandle?.id === expectedTmuxName ? "active" : "exited",
1401+
};
1402+
}),
1403+
};
1404+
const registryWithNamedAgents: PluginRegistry = {
1405+
...mockRegistry,
1406+
get: vi.fn().mockImplementation((slot: string, name: string) => {
1407+
if (slot === "runtime") return mockRuntime;
1408+
if (slot === "agent" && name === agentName) return selectedAgent;
1409+
if (slot === "workspace") return mockWorkspace;
1410+
return null;
1411+
}),
1412+
};
1413+
1414+
writeMetadata(sessionsDir, "app-1", {
1415+
worktree: "/tmp",
1416+
branch: "a",
1417+
status: "working",
1418+
project: "my-app",
1419+
agent: agentName,
1420+
tmuxName: expectedTmuxName,
1421+
...(agentName === "opencode" ? { opencodeSessionId: "ses_existing_mapping" } : {}),
1422+
});
1423+
1424+
const sm = createSessionManager({ config, registry: registryWithNamedAgents });
1425+
const sessions = await sm.list("my-app");
1426+
1427+
expect(sessions).toHaveLength(1);
1428+
expect(sessions[0].runtimeHandle?.id).toBe(expectedTmuxName);
1429+
expect(sessions[0].activity).toBe("active");
1430+
expect(selectedAgent.getActivityState).toHaveBeenCalled();
1431+
},
1432+
);
1433+
1434+
it("uses tmuxName fallback handle for runtime liveness checks when runtimeHandle is missing", async () => {
1435+
const expectedTmuxName = "hash-app-1";
1436+
const deadRuntime: Runtime = {
1437+
...mockRuntime,
1438+
isAlive: vi
1439+
.fn()
1440+
.mockImplementation(async (handle: RuntimeHandle) => handle.id !== expectedTmuxName),
1441+
};
1442+
const agentWithSpy: Agent = {
1443+
...mockAgent,
1444+
getActivityState: vi.fn().mockResolvedValue({ state: "active" }),
1445+
};
1446+
const registryWithDeadRuntime: PluginRegistry = {
1447+
...mockRegistry,
1448+
get: vi.fn().mockImplementation((slot: string) => {
1449+
if (slot === "runtime") return deadRuntime;
1450+
if (slot === "agent") return agentWithSpy;
1451+
if (slot === "workspace") return mockWorkspace;
1452+
return null;
1453+
}),
1454+
};
1455+
1456+
writeMetadata(sessionsDir, "app-1", {
1457+
worktree: "/tmp",
1458+
branch: "a",
1459+
status: "working",
1460+
project: "my-app",
1461+
tmuxName: expectedTmuxName,
1462+
});
1463+
1464+
const sm = createSessionManager({ config, registry: registryWithDeadRuntime });
1465+
const sessions = await sm.list("my-app");
1466+
1467+
expect(sessions).toHaveLength(1);
1468+
expect(sessions[0].runtimeHandle?.id).toBe(expectedTmuxName);
1469+
expect(sessions[0].status).toBe("killed");
1470+
expect(sessions[0].activity).toBe("exited");
1471+
expect(agentWithSpy.getActivityState).not.toHaveBeenCalled();
1472+
});
1473+
13901474
it("keeps existing activity when getActivityState throws", async () => {
13911475
const agentWithError: Agent = {
13921476
...mockAgent,

packages/core/src/session-manager.ts

Lines changed: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -794,13 +794,22 @@ export function createSessionManager(deps: SessionManagerDeps): OpenCodeSessionM
794794
sessionListPromise,
795795
);
796796

797-
const handleFromMetadata = session.runtimeHandle !== null;
797+
const tmuxNameFromMetadata = session.metadata["tmuxName"]?.trim();
798+
const hasTmuxNameFromMetadata =
799+
typeof tmuxNameFromMetadata === "string" && tmuxNameFromMetadata.length > 0;
800+
const handleFromMetadata = session.runtimeHandle !== null || hasTmuxNameFromMetadata;
798801
if (!handleFromMetadata) {
799802
session.runtimeHandle = {
800803
id: sessionName,
801804
runtimeName: project.runtime ?? config.defaults.runtime,
802805
data: {},
803806
};
807+
} else if (!session.runtimeHandle && hasTmuxNameFromMetadata) {
808+
session.runtimeHandle = {
809+
id: tmuxNameFromMetadata,
810+
runtimeName: project.runtime ?? config.defaults.runtime,
811+
data: {},
812+
};
804813
}
805814
await enrichSessionWithRuntimeState(session, plugins, handleFromMetadata);
806815
}

0 commit comments

Comments
 (0)