fix: treat missing subsession files as unavailable

This commit is contained in:
Federico Jaramillo Martinez
2026-06-25 11:08:26 +02:00
parent b0b497d493
commit 56c1c1714e
3 changed files with 15 additions and 44 deletions
+7 -17
View File
@@ -1032,7 +1032,7 @@ describe("PiSessionService", () => {
} }
}); });
it("hydrates persisted links to archived children without scanning unrelated child headers", async () => { it("does not hydrate persisted links when the exact child file is unavailable", async () => {
const parentFile = "/sessions/parent-1.jsonl"; const parentFile = "/sessions/parent-1.jsonl";
const parent = fakeRuntime("parent-1", { const parent = fakeRuntime("parent-1", {
sessionFile: parentFile, sessionFile: parentFile,
@@ -1043,24 +1043,17 @@ describe("PiSessionService", () => {
const service = new PiSessionService(new CapturingSessionEventHub(), { const service = new PiSessionService(new CapturingSessionEventHub(), {
createAgentRuntime: runtimeCreator(parent.runtime), createAgentRuntime: runtimeCreator(parent.runtime),
sessionManager: { create: () => parent.session.sessionManager, list: () => Promise.resolve([]), listAll: () => Promise.resolve([]), open: () => fakeSessionManager() }, sessionManager: { create: () => parent.session.sessionManager, list: () => Promise.resolve([]), listAll: () => Promise.resolve([]), open: () => fakeSessionManager() },
archiveStore: { archiveStore: emptyArchiveStore(),
...emptyArchiveStore(),
list: () => Promise.resolve([]),
get: (sessionId) => Promise.resolve(sessionId === "child-1" ? { sessionId: "child-1", cwd: "/workspace-feature", archivedAt: "2026-01-01T00:00:00.000Z", parentSessionPath: parentFile } : undefined),
isArchived: (sessionId) => Promise.resolve(sessionId === "child-1"),
},
heartbeatIntervalMs: 60_000, heartbeatIntervalMs: 60_000,
}); });
await service.start("/workspace"); await service.start("/workspace");
await expect(service.listSubsessions("parent-1")).resolves.toEqual([ await expect(service.listSubsessions("parent-1")).resolves.toEqual([]);
{ sessionId: "child-1", cwd: "/workspace-feature", status: "archived" },
]);
await service.dispose(); await service.dispose();
}); });
it("does not hydrate parent links without a child file or exact archived child validation", async () => { it("does not hydrate parent links without a child file", async () => {
const parentFile = "/sessions/parent-1.jsonl"; const parentFile = "/sessions/parent-1.jsonl";
const parent = fakeRuntime("parent-1", { const parent = fakeRuntime("parent-1", {
sessionFile: parentFile, sessionFile: parentFile,
@@ -1071,10 +1064,7 @@ describe("PiSessionService", () => {
const service = new PiSessionService(new CapturingSessionEventHub(), { const service = new PiSessionService(new CapturingSessionEventHub(), {
createAgentRuntime: runtimeCreator(parent.runtime), createAgentRuntime: runtimeCreator(parent.runtime),
sessionManager: { create: () => parent.session.sessionManager, list: () => Promise.resolve([]), listAll: () => Promise.resolve([]), open: () => fakeSessionManager() }, sessionManager: { create: () => parent.session.sessionManager, list: () => Promise.resolve([]), listAll: () => Promise.resolve([]), open: () => fakeSessionManager() },
archiveStore: { archiveStore: emptyArchiveStore(),
...emptyArchiveStore(),
get: (sessionId) => Promise.resolve(sessionId === "child" ? { sessionId: "child-fork", cwd: "/workspace-feature", archivedAt: "2026-01-01T00:00:00.000Z", parentSessionPath: parentFile } : undefined),
},
heartbeatIntervalMs: 60_000, heartbeatIntervalMs: 60_000,
}); });
@@ -1633,7 +1623,7 @@ describe("PiSessionService", () => {
await service.dispose(); await service.dispose();
}); });
it("reports an archived child's status in the subsession list", async () => { it("reports a missing tracked child file as unknown in the subsession list", async () => {
const { service } = subsessionService({ allowed: true, cwd: "/workspace-feature" }); const { service } = subsessionService({ allowed: true, cwd: "/workspace-feature" });
await service.start("/workspace"); await service.start("/workspace");
await service.spawnSubsession({ spawningCwd: "/workspace", parentSessionId: "parent-1", parentSessionFile: "/tmp/parent-1.jsonl", prompt: "go", cwd: "/workspace-feature" }); await service.spawnSubsession({ spawningCwd: "/workspace", parentSessionId: "parent-1", parentSessionFile: "/tmp/parent-1.jsonl", prompt: "go", cwd: "/workspace-feature" });
@@ -1641,7 +1631,7 @@ describe("PiSessionService", () => {
await service.archive("child-1"); await service.archive("child-1");
await expect(service.listSubsessions("parent-1")).resolves.toEqual([ await expect(service.listSubsessions("parent-1")).resolves.toEqual([
{ sessionId: "child-1", cwd: "/workspace-feature", status: "archived" }, { sessionId: "child-1", cwd: "/workspace-feature", status: "unknown" },
]); ]);
await service.dispose(); await service.dispose();
}); });
+7 -26
View File
@@ -503,7 +503,7 @@ export class PiSessionService {
return { return {
sessionId, sessionId,
cwd: session.sessionManager.getCwd(), cwd: session.sessionManager.getCwd(),
status: await this.subsessionStatus(session), status: this.subsessionStatus(session),
finalText: finalAssistantText(messages), finalText: finalAssistantText(messages),
messageCount: messages.length, messageCount: messages.length,
}; };
@@ -516,7 +516,7 @@ export class PiSessionService {
return { return {
sessionId, sessionId,
cwd: session.sessionManager.getCwd(), cwd: session.sessionManager.getCwd(),
status: await this.subsessionStatus(session), status: this.subsessionStatus(session),
...view, ...view,
}; };
} }
@@ -645,7 +645,7 @@ export class PiSessionService {
private async registerPersistedSubsessionLinks(parentSessionId: string, parentManager: PiSessionManager, parentSessionFile: string | undefined): Promise<void> { private async registerPersistedSubsessionLinks(parentSessionId: string, parentManager: PiSessionManager, parentSessionFile: string | undefined): Promise<void> {
// Parent custom links are the authoritative recovery record: verify the // Parent custom links are the authoritative recovery record: verify the
// exact live child file/header or an exact archived child before tracking. // exact live child file/header before tracking.
const entries = parentManager.getEntries?.() ?? parentManager.getBranch(); const entries = parentManager.getEntries?.() ?? parentManager.getBranch();
for (const entry of entries) { for (const entry of entries) {
const link = parsePersistedParentSubsessionLink(entry); const link = parsePersistedParentSubsessionLink(entry);
@@ -664,16 +664,8 @@ export class PiSessionService {
} }
private async parentLinkHasValidChildTarget(parentSessionFile: string, link: PersistedParentSubsessionLink): Promise<boolean> { private async parentLinkHasValidChildTarget(parentSessionFile: string, link: PersistedParentSubsessionLink): Promise<boolean> {
if (link.spawnedSessionFile !== undefined && (await sessionFileHeaderMatches(link.spawnedSessionFile, { sessionId: link.spawnedSessionId, parentSessionFile }))) return true; return link.spawnedSessionFile !== undefined
return this.archivedSubsessionLinkMatchesParent(parentSessionFile, link); && await sessionFileHeaderMatches(link.spawnedSessionFile, { sessionId: link.spawnedSessionId, parentSessionFile });
}
private async archivedSubsessionLinkMatchesParent(parentSessionFile: string, link: PersistedParentSubsessionLink): Promise<boolean> {
const archived = await this.getArchivedExact(link.spawnedSessionId);
if (archived?.parentSessionPath === undefined) return false;
if (!sessionPathsEqual(archived.parentSessionPath, parentSessionFile)) return false;
if (archived.originalPath !== undefined && link.spawnedSessionFile !== undefined && !sessionPathsEqual(archived.originalPath, link.spawnedSessionFile)) return false;
return true;
} }
private async recoverSubsessionTrackingForOpenedSession(session: PiAgentSession): Promise<void> { private async recoverSubsessionTrackingForOpenedSession(session: PiAgentSession): Promise<void> {
@@ -738,9 +730,6 @@ export class PiSessionService {
const active = this.activeChildForSubsessionLink(link); const active = this.activeChildForSubsessionLink(link);
if (active !== undefined) return active.runtime.session; if (active !== undefined) return active.runtime.session;
const archived = await this.getArchivedExact(sessionId);
if (archived?.archivePath !== undefined) return (await this.create(this.sessionManager.open(archived.archivePath), archived.cwd)).runtime.session;
if (link.childSessionFile !== undefined) { if (link.childSessionFile !== undefined) {
if (!(await sessionFileHeaderMatches(link.childSessionFile, { sessionId, parentSessionFile: link.parentSessionFile }))) throw new Error("Session not found"); if (!(await sessionFileHeaderMatches(link.childSessionFile, { sessionId, parentSessionFile: link.parentSessionFile }))) throw new Error("Session not found");
const sessionManager = this.sessionManager.open(link.childSessionFile); const sessionManager = this.sessionManager.open(link.childSessionFile);
@@ -754,10 +743,8 @@ export class PiSessionService {
const link = this.subsessionLinks.get(childSessionId); const link = this.subsessionLinks.get(childSessionId);
const active = link === undefined ? undefined : this.activeChildForSubsessionLink(link); const active = link === undefined ? undefined : this.activeChildForSubsessionLink(link);
if (active !== undefined) { if (active !== undefined) {
return { cwd: active.runtime.cwd, status: await this.subsessionStatus(active.runtime.session) }; return { cwd: active.runtime.cwd, status: this.subsessionStatus(active.runtime.session) };
} }
const archived = await this.getArchivedExact(childSessionId);
if (archived !== undefined) return { cwd: archived.cwd, status: "archived" };
if (link?.childSessionFile !== undefined && (await sessionFileHeaderMatches(link.childSessionFile, { sessionId: childSessionId, parentSessionFile: link.parentSessionFile }))) { if (link?.childSessionFile !== undefined && (await sessionFileHeaderMatches(link.childSessionFile, { sessionId: childSessionId, parentSessionFile: link.parentSessionFile }))) {
return { cwd: link.cwd ?? "", status: "idle" }; return { cwd: link.cwd ?? "", status: "idle" };
} }
@@ -765,8 +752,7 @@ export class PiSessionService {
return { cwd: "", status: "unknown" }; return { cwd: "", status: "unknown" };
} }
private async subsessionStatus(session: PiAgentSession): Promise<SubsessionStatus> { private subsessionStatus(session: PiAgentSession): SubsessionStatus {
if (await this.getArchivedExact(session.sessionId) !== undefined) return "archived";
if (this.hasActiveWork(session)) return "working"; if (this.hasActiveWork(session)) return "working";
if (this.activities.get(session.sessionId)?.phase === "error") return "error"; if (this.activities.get(session.sessionId)?.phase === "error") return "error";
return "idle"; return "idle";
@@ -1238,11 +1224,6 @@ export class PiSessionService {
return archived; return archived;
} }
private async getArchivedExact(sessionId: string): Promise<ArchivedSessionRecord | undefined> {
const archived = await this.archiveStore.get(sessionId);
return archived?.sessionId === sessionId ? archived : undefined;
}
private activeForLookup(ref: PiSessionLookup): ActiveSession<PiSessionRuntime> | undefined { private activeForLookup(ref: PiSessionLookup): ActiveSession<PiSessionRuntime> | undefined {
const sessionId = sessionIdFromLookup(ref); const sessionId = sessionIdFromLookup(ref);
const exact = this.active.get(sessionId); const exact = this.active.get(sessionId);
+1 -1
View File
@@ -3,7 +3,7 @@ import { defineTool } from "@earendil-works/pi-coding-agent";
import type { TranscriptContentKind, TranscriptEntry, TranscriptRole, TranscriptView } from "./subsessionTranscript.js"; import type { TranscriptContentKind, TranscriptEntry, TranscriptRole, TranscriptView } from "./subsessionTranscript.js";
/** Lifecycle phase of a tracked subsession as seen by its parent. */ /** Lifecycle phase of a tracked subsession as seen by its parent. */
export type SubsessionStatus = "working" | "idle" | "error" | "archived" | "unknown"; export type SubsessionStatus = "working" | "idle" | "error" | "unknown";
export interface SpawnSubsessionResult { export interface SpawnSubsessionResult {
sessionId: string; sessionId: string;