fix: harden OpenCode session id handling and title reuse selection
This commit is contained in:
parent
0057cdda1c
commit
6255df4db6
|
|
@ -1450,6 +1450,27 @@ describe("kill", () => {
|
|||
const deleteLog = readFileSync(deleteLogPath, "utf-8");
|
||||
expect(deleteLog).toContain("session delete ses_purge");
|
||||
});
|
||||
|
||||
it("skips purge when mapped OpenCode session id is invalid", async () => {
|
||||
const deleteLogPath = join(tmpDir, "opencode-delete-kill-invalid.log");
|
||||
const mockBin = installMockOpencode("[]", deleteLogPath);
|
||||
process.env.PATH = `${mockBin}:${originalPath ?? ""}`;
|
||||
|
||||
writeMetadata(sessionsDir, "app-1", {
|
||||
worktree: "/tmp/ws",
|
||||
branch: "main",
|
||||
status: "working",
|
||||
project: "my-app",
|
||||
agent: "opencode",
|
||||
opencodeSessionId: "ses bad id",
|
||||
runtimeHandle: JSON.stringify(makeHandle("rt-1")),
|
||||
});
|
||||
|
||||
const sm = createSessionManager({ config, registry: mockRegistry });
|
||||
await sm.kill("app-1", { purgeOpenCode: true });
|
||||
|
||||
expect(existsSync(deleteLogPath)).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("cleanup", () => {
|
||||
|
|
|
|||
|
|
@ -64,6 +64,14 @@ import {
|
|||
const execFileAsync = promisify(execFile);
|
||||
const OPENCODE_DISCOVERY_TIMEOUT_MS = 2_000;
|
||||
const OPENCODE_INTERACTIVE_DISCOVERY_TIMEOUT_MS = 10_000;
|
||||
const OPENCODE_SESSION_ID_RE = /^ses_[A-Za-z0-9_-]+$/;
|
||||
|
||||
function asValidOpenCodeSessionId(value: unknown): string | undefined {
|
||||
if (typeof value !== "string") return undefined;
|
||||
const trimmed = value.trim();
|
||||
if (trimmed.length === 0) return undefined;
|
||||
return OPENCODE_SESSION_ID_RE.test(trimmed) ? trimmed : undefined;
|
||||
}
|
||||
|
||||
function errorIncludesSessionNotFound(err: unknown): boolean {
|
||||
if (!(err instanceof Error)) return false;
|
||||
|
|
@ -73,6 +81,8 @@ function errorIncludesSessionNotFound(err: unknown): boolean {
|
|||
}
|
||||
|
||||
async function deleteOpenCodeSession(sessionId: string): Promise<void> {
|
||||
const validatedSessionId = asValidOpenCodeSessionId(sessionId);
|
||||
if (!validatedSessionId) return;
|
||||
const retryDelaysMs = [0, 200, 600];
|
||||
let lastError: unknown;
|
||||
for (const delayMs of retryDelaysMs) {
|
||||
|
|
@ -80,7 +90,9 @@ async function deleteOpenCodeSession(sessionId: string): Promise<void> {
|
|||
await new Promise((resolve) => setTimeout(resolve, delayMs));
|
||||
}
|
||||
try {
|
||||
await execFileAsync("opencode", ["session", "delete", sessionId], { timeout: 30_000 });
|
||||
await execFileAsync("opencode", ["session", "delete", validatedSessionId], {
|
||||
timeout: 30_000,
|
||||
});
|
||||
return;
|
||||
} catch (err) {
|
||||
if (errorIncludesSessionNotFound(err)) {
|
||||
|
|
@ -118,8 +130,8 @@ async function discoverOpenCodeSessionIdsByTitle(
|
|||
});
|
||||
|
||||
return candidates
|
||||
.map((entry) => entry["id"])
|
||||
.filter((id): id is string => typeof id === "string" && id.length > 0);
|
||||
.map((entry) => asValidOpenCodeSessionId(entry["id"]))
|
||||
.filter((id): id is string => typeof id === "string");
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
|
|
@ -377,8 +389,8 @@ export function createSessionManager(deps: SessionManagerDeps): SessionManager {
|
|||
const ids: string[] = [];
|
||||
const maybeAdd = (id: string, raw: Record<string, string> | null) => {
|
||||
if (!matchesCriteria(id, raw)) return;
|
||||
const mapped = raw?.["opencodeSessionId"];
|
||||
if (typeof mapped !== "string" || mapped.length === 0) return;
|
||||
const mapped = asValidOpenCodeSessionId(raw?.["opencodeSessionId"]);
|
||||
if (!mapped) return;
|
||||
ids.push(mapped);
|
||||
};
|
||||
|
||||
|
|
|
|||
|
|
@ -485,6 +485,33 @@ describe("detectActivity", () => {
|
|||
});
|
||||
});
|
||||
|
||||
describe("getActivityState", () => {
|
||||
const agent = create();
|
||||
|
||||
it("returns null when opencode session list output is malformed JSON", async () => {
|
||||
mockExecFileAsync.mockImplementation((cmd: string) => {
|
||||
if (cmd === "tmux") return Promise.resolve({ stdout: "/dev/ttys003\n", stderr: "" });
|
||||
if (cmd === "ps") {
|
||||
return Promise.resolve({
|
||||
stdout: " PID TT ARGS\n 789 ttys003 opencode\n",
|
||||
stderr: "",
|
||||
});
|
||||
}
|
||||
if (cmd === "opencode") return Promise.resolve({ stdout: "not json", stderr: "" });
|
||||
return Promise.reject(new Error("unexpected"));
|
||||
});
|
||||
|
||||
const state = await agent.getActivityState(
|
||||
makeSession({
|
||||
runtimeHandle: makeTmuxHandle(),
|
||||
metadata: { opencodeSessionId: "ses_abc123" },
|
||||
}),
|
||||
);
|
||||
|
||||
expect(state).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
// =========================================================================
|
||||
// getSessionInfo
|
||||
// =========================================================================
|
||||
|
|
|
|||
|
|
@ -20,13 +20,27 @@ interface OpenCodeSessionListEntry {
|
|||
updated?: string;
|
||||
}
|
||||
|
||||
const OPENCODE_SESSION_ID_RE = /^ses_[A-Za-z0-9_-]+$/;
|
||||
|
||||
function asValidOpenCodeSessionId(value: unknown): string | undefined {
|
||||
if (typeof value !== "string") return undefined;
|
||||
const trimmed = value.trim();
|
||||
if (trimmed.length === 0) return undefined;
|
||||
return OPENCODE_SESSION_ID_RE.test(trimmed) ? trimmed : undefined;
|
||||
}
|
||||
|
||||
function parseSessionList(raw: string): OpenCodeSessionListEntry[] {
|
||||
const parsed: unknown = JSON.parse(raw);
|
||||
let parsed: unknown;
|
||||
try {
|
||||
parsed = JSON.parse(raw);
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
if (!Array.isArray(parsed)) return [];
|
||||
return parsed.filter((item): item is OpenCodeSessionListEntry => {
|
||||
if (!item || typeof item !== "object") return false;
|
||||
const record = item as Record<string, unknown>;
|
||||
return typeof record["id"] === "string";
|
||||
return asValidOpenCodeSessionId(record["id"]) !== undefined;
|
||||
});
|
||||
}
|
||||
|
||||
|
|
@ -99,7 +113,7 @@ function createOpenCodeAgent(): Agent {
|
|||
const runCommand = promptValue
|
||||
? ["opencode", "run", ...runOptions, promptValue].join(" ")
|
||||
: ["opencode", "run", ...runOptions, "--command", "true"].join(" ");
|
||||
const continueSession = `"$(opencode session list --format json | node -e ${shellEscape("let input='';process.stdin.on('data',c=>input+=c).on('end',()=>{const title=process.argv[1];const rows=JSON.parse(input);if(!Array.isArray(rows))process.exit(1);const matches=rows.filter((r)=>r&&r.title===title&&typeof r.id==='string');if(matches.length===0)process.exit(1);process.stdout.write(matches[0].id);});")} ${shellEscape(`AO:${config.sessionId}`)})"`;
|
||||
const continueSession = `"$(opencode session list --format json | node -e ${shellEscape("let input='';process.stdin.on('data',c=>input+=c).on('end',()=>{const title=process.argv[1];const rows=JSON.parse(input);if(!Array.isArray(rows))process.exit(1);const matches=rows.filter((r)=>r&&r.title===title&&typeof r.id==='string').sort((a,b)=>{const aUpdated=Date.parse(typeof a.updated==='string'?a.updated:'');const bUpdated=Date.parse(typeof b.updated==='string'?b.updated:'');const aScore=Number.isNaN(aUpdated)?0:aUpdated;const bScore=Number.isNaN(bUpdated)?0:bUpdated;return bScore-aScore;});if(matches.length===0)process.exit(1);process.stdout.write(matches[0].id);});")} ${shellEscape(`AO:${config.sessionId}`)})"`;
|
||||
const continueCommand = ["opencode", "--session", continueSession, ...sharedOptions].join(
|
||||
" ",
|
||||
);
|
||||
|
|
|
|||
Loading…
Reference in New Issue