fix: preserve title-only OpenCode reuse with fallback mapping persistence
This commit is contained in:
parent
80e96db16c
commit
11ea174a81
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
" ",
|
||||
);
|
||||
|
|
|
|||
Loading…
Reference in New Issue