fix: preserve title-only OpenCode reuse with fallback mapping persistence

This commit is contained in:
Harsh 2026-03-06 11:34:29 +05:30
parent 80e96db16c
commit 11ea174a81
3 changed files with 157 additions and 19 deletions

View File

@ -2058,6 +2058,54 @@ describe("spawnOrchestrator", () => {
expect(meta?.["opencodeSessionId"]).toBeUndefined();
});
it("discovers and persists OpenCode session id by title when strategy is reuse", async () => {
const deleteLogPath = join(tmpDir, "opencode-delete-orchestrator-reuse-discovery.log");
const mockBin = installMockOpencode(
JSON.stringify([
{
id: "ses_discovered_orchestrator",
title: "AO:app-orchestrator",
updated: 1_772_777_000_000,
},
]),
deleteLogPath,
);
process.env.PATH = `${mockBin}:${originalPath ?? ""}`;
const opencodeAgent: Agent = {
...mockAgent,
name: "opencode",
};
const registryWithOpenCode: PluginRegistry = {
...mockRegistry,
get: vi.fn().mockImplementation((slot: string) => {
if (slot === "runtime") return mockRuntime;
if (slot === "agent") return opencodeAgent;
if (slot === "workspace") return mockWorkspace;
return null;
}),
};
const configWithReuse: OrchestratorConfig = {
...config,
defaults: { ...config.defaults, agent: "opencode" },
projects: {
...config.projects,
"my-app": {
...config.projects["my-app"],
agent: "opencode",
orchestratorSessionStrategy: "reuse",
},
},
};
const sm = createSessionManager({ config: configWithReuse, registry: registryWithOpenCode });
await sm.spawnOrchestrator({ projectId: "my-app" });
const meta = readMetadataRaw(sessionsDir, "app-orchestrator");
expect(meta?.["opencodeSessionId"]).toBe("ses_discovered_orchestrator");
});
it("reuses an existing orchestrator session when strategy is reuse", async () => {
const opencodeAgent: Agent = {
...mockAgent,
@ -2227,6 +2275,70 @@ describe("spawnOrchestrator", () => {
);
});
it("reuses OpenCode session by title when orchestrator mapping is missing", async () => {
const deleteLogPath = join(tmpDir, "opencode-delete-orchestrator-reuse-title.log");
const mockBin = installMockOpencode(
JSON.stringify([
{ id: "ses_title_match", title: "AO:app-orchestrator", updated: 1_772_777_000_000 },
]),
deleteLogPath,
);
process.env.PATH = `${mockBin}:${originalPath ?? ""}`;
const opencodeAgent: Agent = {
...mockAgent,
name: "opencode",
};
const registryWithOpenCode: PluginRegistry = {
...mockRegistry,
get: vi.fn().mockImplementation((slot: string) => {
if (slot === "runtime") return mockRuntime;
if (slot === "agent") return opencodeAgent;
if (slot === "workspace") return mockWorkspace;
return null;
}),
};
const configWithReuse: OrchestratorConfig = {
...config,
defaults: { ...config.defaults, agent: "opencode" },
projects: {
...config.projects,
"my-app": {
...config.projects["my-app"],
agent: "opencode",
orchestratorSessionStrategy: "reuse",
},
},
};
writeMetadata(sessionsDir, "app-orchestrator", {
worktree: join(tmpDir, "my-app"),
branch: "main",
status: "working",
role: "orchestrator",
project: "my-app",
agent: "opencode",
runtimeHandle: JSON.stringify(makeHandle("rt-existing")),
createdAt: new Date().toISOString(),
});
vi.mocked(mockRuntime.isAlive).mockResolvedValue(false);
const sm = createSessionManager({ config: configWithReuse, registry: registryWithOpenCode });
await sm.spawnOrchestrator({ projectId: "my-app" });
expect(opencodeAgent.getLaunchCommand).toHaveBeenCalledWith(
expect.objectContaining({
projectConfig: expect.objectContaining({
agentConfig: expect.objectContaining({ opencodeSessionId: "ses_title_match" }),
}),
}),
);
const meta = readMetadataRaw(sessionsDir, "app-orchestrator");
expect(meta?.["opencodeSessionId"]).toBe("ses_title_match");
});
it("starts fresh without deleting prior OpenCode sessions when strategy is ignore", async () => {
const deleteLogPath = join(tmpDir, "opencode-delete-ignore.log");
const mockBin = installMockOpencode(

View File

@ -115,19 +115,11 @@ async function discoverOpenCodeSessionIdsByTitle(
const parsed = safeJsonParse<Array<Record<string, unknown>>>(stdout);
if (!parsed) return [];
const title = `AO:${sessionId}`;
const candidates = parsed
.filter((entry) => {
const candidateTitle = typeof entry["title"] === "string" ? entry["title"] : "";
const candidateId = typeof entry["id"] === "string" ? entry["id"] : "";
return candidateTitle === title && candidateId.length > 0;
})
.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;
});
const candidates = parsed.filter((entry) => {
const candidateTitle = typeof entry["title"] === "string" ? entry["title"] : "";
const candidateId = typeof entry["id"] === "string" ? entry["id"] : "";
return candidateTitle === title && candidateId.length > 0;
});
return candidates
.map((entry) => asValidOpenCodeSessionId(entry["id"]))
@ -429,6 +421,10 @@ export function createSessionManager(deps: SessionManagerDeps): SessionManager {
return undefined;
}
if (candidateIds.length === 0 && criteria.sessionId) {
candidateIds = await discoverOpenCodeSessionIdsByTitle(criteria.sessionId);
}
return candidateIds[0];
}
@ -810,11 +806,25 @@ export function createSessionManager(deps: SessionManagerDeps): SessionManager {
if (plugins.agent.postLaunchSetup) {
await plugins.agent.postLaunchSetup(session);
}
if (Object.keys(session.metadata || {}).length > 0) {
updateMetadata(sessionsDir, sessionId, session.metadata);
if (
plugins.agent.name === "opencode" &&
opencodeIssueSessionStrategy === "reuse" &&
!session.metadata["opencodeSessionId"]
) {
const discovered = await discoverOpenCodeSessionIdByTitle(
sessionId,
OPENCODE_INTERACTIVE_DISCOVERY_TIMEOUT_MS,
);
if (discovered) {
session.metadata["opencodeSessionId"] = discovered;
}
}
if (Object.keys(session.metadata || {}).length > 0) {
updateMetadata(sessionsDir, sessionId, session.metadata);
}
} catch (err) {
// Clean up runtime and workspace on post-launch failure
try {
@ -988,7 +998,9 @@ export function createSessionManager(deps: SessionManagerDeps): SessionManager {
agentInfo: null,
createdAt: new Date(),
lastActivityAt: new Date(),
metadata: {},
metadata: {
...(reusableOpenCodeSessionId ? { opencodeSessionId: reusableOpenCodeSessionId } : {}),
},
};
try {
@ -1007,11 +1019,25 @@ export function createSessionManager(deps: SessionManagerDeps): SessionManager {
if (plugins.agent.postLaunchSetup) {
await plugins.agent.postLaunchSetup(session);
}
if (Object.keys(session.metadata || {}).length > 0) {
updateMetadata(sessionsDir, sessionId, session.metadata);
if (
plugins.agent.name === "opencode" &&
orchestratorSessionStrategy === "reuse" &&
!session.metadata["opencodeSessionId"]
) {
const discovered = await discoverOpenCodeSessionIdByTitle(
sessionId,
OPENCODE_INTERACTIVE_DISCOVERY_TIMEOUT_MS,
);
if (discovered) {
session.metadata["opencodeSessionId"] = discovered;
}
}
if (Object.keys(session.metadata || {}).length > 0) {
updateMetadata(sessionsDir, sessionId, session.metadata);
}
} catch (err) {
// Clean up runtime on post-launch failure
try {

View File

@ -113,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').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 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 continueCommand = ["opencode", "--session", continueSession, ...sharedOptions].join(
" ",
);