Archived
fix: treat missing subsession files as unavailable
This commit is contained in:
@@ -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();
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
Reference in New Issue
Block a user