diff --git a/.changeset/assistant-pending-child-visibility.md b/.changeset/assistant-pending-child-visibility.md new file mode 100644 index 000000000..3e0504096 --- /dev/null +++ b/.changeset/assistant-pending-child-visibility.md @@ -0,0 +1,5 @@ +--- +"@sapiom/harness": patch +--- + +Keep unfinished Assistant continuation children out of all public session lists and block direct actions until preparation completes. diff --git a/packages/harness/docs/assistant-history.md b/packages/harness/docs/assistant-history.md index f5cdf85ee..20f11be42 100644 --- a/packages/harness/docs/assistant-history.md +++ b/packages/harness/docs/assistant-history.md @@ -172,3 +172,8 @@ A continued session shows a distinct Terminal-not-started pane. Start Terminal launches its coding agent in that same Studio workspace only after an explicit click. Failed starts retain the session for retry, immediate exits show the real exit state, and stale responses cannot override End or a newer selected session. + +Unfinished continuation children remain internal to allocation recovery. General +session lists, history, runtime/lifecycle events and direct session actions use +the same preparation visibility boundary; direct Terminal start cannot bypass it. +Restored pending children are hidden before the server starts accepting requests. diff --git a/packages/harness/src/core/assistant-state-projection.test.ts b/packages/harness/src/core/assistant-state-projection.test.ts index 9b728d793..93053a03f 100644 --- a/packages/harness/src/core/assistant-state-projection.test.ts +++ b/packages/harness/src/core/assistant-state-projection.test.ts @@ -71,6 +71,52 @@ it("orders lifecycle-only updates with observations and clears disabled projecti expect(stopHost).toHaveBeenCalledOnce(); expect(stopLifecycle).toHaveBeenCalledOnce(); }); +it("keeps private continuation runtimes and lifecycle headers out of every snapshot", () => { + let visible = false; + let changed!: () => void; + const publish = vi.fn(); + const projection = createAssistantStateProjection( + { + getAssistantState: () => ({ + ...runtime, + sessions: [ + { + harnessSessionId: state.harnessSessionId, + conversationId: "ses_private", + activity: "idle", + pendingPermissions: 0, + pendingQuestions: 0, + freshness: "current", + }, + ], + }), + subscribeAssistantState: () => () => {}, + }, + { + snapshot: () => [state], + subscribe: (fn) => { + changed = fn; + return () => {}; + }, + }, + publish, + () => visible, + ); + expect(projection.get()).toMatchObject({ sessions: [], lifecycles: [] }); + changed(); + expect(publish.mock.lastCall?.[0]).toMatchObject({ + sessions: [], + lifecycles: [], + }); + visible = true; + changed(); + expect(publish.mock.lastCall?.[0]).toMatchObject({ + sessions: [{ harnessSessionId: state.harnessSessionId }], + lifecycles: [state], + }); + projection.dispose(); +}); + it.each([ [{ ...state, lease: "private" }], [{ ...state, execution: "enabled" }], diff --git a/packages/harness/src/core/assistant-state-projection.ts b/packages/harness/src/core/assistant-state-projection.ts index b1ed247aa..66f7ce6b1 100644 --- a/packages/harness/src/core/assistant-state-projection.ts +++ b/packages/harness/src/core/assistant-state-projection.ts @@ -7,6 +7,7 @@ export function createAssistantStateProjection( host: Pick, lifecycle: Pick, publish: (snapshot: AssistantStateSnapshot) => void, + isVisible: (id: string) => boolean = () => true, ) { let revision = 0; const get = (): AssistantStateSnapshot => { @@ -15,11 +16,16 @@ export function createAssistantStateProjection( return { ...runtime, revision, + sessions: runtime.sessions.filter((state) => + isVisible(state.harnessSessionId), + ), lifecycles: runtime.enabled ? lifecycle .snapshot() .filter( - (state) => state.revision > 0 || state.lifecycle === "ending", + (state) => + isVisible(state.harnessSessionId) && + (state.revision > 0 || state.lifecycle === "ending"), ) : [], }; diff --git a/packages/harness/src/server/assistant-lifecycle-wiring.test.ts b/packages/harness/src/server/assistant-lifecycle-wiring.test.ts index 9d9b7cfdd..e48cd6780 100644 --- a/packages/harness/src/server/assistant-lifecycle-wiring.test.ts +++ b/packages/harness/src/server/assistant-lifecycle-wiring.test.ts @@ -614,6 +614,12 @@ it("shares retained context across Continue, later Send and Resume while Termina input, ); const childId = continued.session.id as string; + expect((await json("/api/state")).sessions).toContainEqual( + expect.objectContaining({ id: childId }), + ); + expect(await json("/api/sessions")).toContainEqual( + expect.objectContaining({ id: childId }), + ); await vi.waitFor(() => expect(events).toContainEqual( expect.objectContaining({ @@ -815,6 +821,35 @@ it("hides an allocated but unprepared child across reboot and blocks attach befo expect(failed.status).toBe(503); const receipt = (await stores.continuations.read(binding, operationId))!; expect(receipt.phase).toBe("associated"); + const assertPrivate = async () => { + const state = await json("/api/state"); + expect(JSON.stringify(state)).not.toContain(receipt.childStudioId); + expect(await json("/api/sessions")).not.toContainEqual( + expect.objectContaining({ id: receipt.childStudioId }), + ); + for (const [suffix, method, body] of [ + ["/terminal/start", "POST", {}], + ["/resume", "POST", {}], + ["/restart-mcp", "POST", {}], + ["/workflow", "PATCH", { workflowPath: null }], + ["/input", "POST", { text: "must not run" }], + ["/record", "GET", undefined], + ["/assistant/record", "GET", undefined], + ["", "DELETE", undefined], + ] as const) + expect( + ( + await request( + `/api/sessions/${receipt.childStudioId}${suffix}`, + method, + body, + ) + ).status, + `${method} ${suffix}`, + ).toBe(404); + expect(JSON.stringify(events)).not.toContain(receipt.childStudioId); + }; + await assertPrivate(); expect(events).not.toContainEqual( expect.objectContaining({ type: "session.status", @@ -828,6 +863,7 @@ it("hides an allocated but unprepared child across reboot and blocks attach befo studio = undefined; const before = launches.length; await boot(activated); + await assertPrivate(); expect( (await entries()).map(({ harnessSessionId }) => harnessSessionId), ).toEqual([parentId]); diff --git a/packages/harness/src/server/index.ts b/packages/harness/src/server/index.ts index b639eff5d..8a13846b2 100644 --- a/packages/harness/src/server/index.ts +++ b/packages/harness/src/server/index.ts @@ -907,6 +907,9 @@ export const startServer = async ( } satisfies Partial>); const bus = new EventBus(); + // Synchronous event projections consult this cache; all public admission reads + // refresh it from durable receipts, and allocation marks it before publication. + const hiddenAssistantSessions = new Set(); const canvasWatcher = new CanvasWatcherManager({ onChange: (harnessSessionId) => bus.publish({ type: "canvas.reload", harnessSessionId }), @@ -2013,7 +2016,7 @@ export const startServer = async ( const rescannedOnStart = new Set(); sessionManager.onStatusChange((session) => { - bus.publish({ type: "session.status", session }); + if (!hiddenAssistantSessions.has(session.id)) bus.publish({ type: "session.status", session }); if (session.status === "running") { canvasWatcher.start(session.id, session.cwd); workspaceWatcher.start(session.id, session.cwd); @@ -3555,6 +3558,15 @@ export const startServer = async ( const assistantSessions = new AssistantSessionStore(statePaths.root); const assistantRecords = new AssistantRecordStore(statePaths.root); const assistantContinuations = new AssistantContinuationStore(statePaths.root); + const isAssistantSessionVisible = async (id: string): Promise => { + const visible = await assistantContinuations.canAttach(id); + if (visible) hiddenAssistantSessions.delete(id); + else hiddenAssistantSessions.add(id); + return visible; + }; + const restoredSessionIds = sessionManager.list().map((session) => session.id); + for (let offset = 0; offset < restoredSessionIds.length; offset += 8) + await Promise.all(restoredSessionIds.slice(offset, offset + 8).map(isAssistantSessionVisible)); const recordCaptures = new WeakMap(); const authorizeAssistantWorkspace = async (id: string) => { const session = sessionManager.get(id); @@ -3594,13 +3606,14 @@ export const startServer = async ( authorize: authorizeAssistantWorkspace, }); const openCodeAssociations = new OpenCodeAssociations(assistantSessions); - const assistantLifecycle = new AssistantLifecycleCoordinator({ store: assistantSessions, host: openCodeHost, associations: openCodeAssociations, canAttach: (id) => assistantContinuations.canAttach(id) }); + const assistantLifecycle = new AssistantLifecycleCoordinator({ store: assistantSessions, host: openCodeHost, associations: openCodeAssociations, canAttach: isAssistantSessionVisible }); const assistantEnd = new AssistantEndCoordinator({ store: assistantSessions, lifecycle: assistantLifecycle, sessionManager }); // Hydrate lifecycle headers without launching runtimes. A corrupt entry fails // closed when selected rather than preventing unrelated sessions from loading. await Promise.allSettled(sessionManager.list().map((session) => assistantLifecycle.describe(session.id))); const assistantProjection = createAssistantStateProjection(openCodeHost, assistantLifecycle, (snapshot) => bus.publish({ type: "assistant.state", snapshot }), + (id) => !hiddenAssistantSessions.has(id), ); const getAssistantState = assistantProjection.get; const assistantContext = createAssistantContextRuntime({ @@ -3623,7 +3636,13 @@ export const startServer = async ( const assistantContinue = new AssistantContinuation({ store: assistantContinuations, records: assistantRecords, - sessions: sessionManager, + sessions: { + get: (id) => sessionManager.get(id), + allocateDormant: async (id, input) => { + await isAssistantSessionVisible(input.childSessionId); + return sessionManager.allocateDormant(id, input); + }, + }, associations: assistantSessions, lifecycle: assistantLifecycle, native: new AssistantContinuationNative((hosted) => openCodeHost.assertCurrent(hosted)), @@ -3673,7 +3692,7 @@ export const startServer = async ( res.setHeader("Cache-Control", "no-store"); res.json(assistantAccess.getBrowserState()); }); - const assistantHistory = new AssistantHistory({ sessions: sessionManager, authorize: authorizeAssistantHistory, records: assistantRecords, lifecycle: assistantLifecycle, isVisible: (id) => assistantContinuations.canAttach(id) }); + const assistantHistory = new AssistantHistory({ sessions: sessionManager, authorize: authorizeAssistantHistory, records: assistantRecords, lifecycle: assistantLifecycle, isVisible: isAssistantSessionVisible }); app.use("/api", createAssistantLifecycleRouter({ bootToken: options.bootToken, history: assistantHistory, @@ -3690,12 +3709,13 @@ export const startServer = async ( app.use("/api", createAssistantRecordsRouter({ bootToken: options.bootToken, store: assistantRecords, - authorize: authorizeAssistantHistory, + authorize: async (id) => (await isAssistantSessionVisible(id)) ? authorizeAssistantHistory(id) : null, history: assistantHistory, })); app.use( "/api", createRestRouter({ + isSessionVisible: isAssistantSessionVisible, getAssistantState, endSession: (id) => assistantEnd.end(id), sessionManager, diff --git a/packages/harness/src/server/rest.ts b/packages/harness/src/server/rest.ts index 3b9cbea3c..4d931b075 100644 --- a/packages/harness/src/server/rest.ts +++ b/packages/harness/src/server/rest.ts @@ -198,6 +198,8 @@ async function agentHoldsConversation( } export interface RestRouterOptions { + /** Keep unfinished continuation allocations private without removing internal registry rows. */ + isSessionVisible?: (id: string) => Promise; getAssistantState?: () => AssistantStateSnapshot; /** Complete selected-session End, including native and Terminal cleanup. * Uses the route's boot authorization independently of Assistant grant expiry. */ @@ -355,6 +357,27 @@ export function createRestRouter(options: RestRouterOptions): Router { router.post("/sessions", express.json({ limit: CREATE_SESSION_JSON_LIMIT_BYTES })); router.use(express.json({ limit: JSON_BODY_LIMIT_BYTES })); + const listVisibleSessions = async (): Promise => { + const sessions = sessionManager.list(); + if (!options.isSessionVisible) return sessions; + const visible: HarnessSession[] = []; + for (let offset = 0; offset < sessions.length; offset += 8) { + const page = sessions.slice(offset, offset + 8); + const accepted = await Promise.all(page.map((session) => options.isSessionVisible!(session.id))); + visible.push(...page.filter((_session, index) => accepted[index])); + } + return visible; + }; + router.use("/sessions/:id", async (req, res, next) => { + try { + if (options.isSessionVisible && sessionManager.get(req.params.id) && !(await options.isSessionVisible(req.params.id))) { + res.status(404).json({ error: "session not found" }); + return; + } + next(); + } catch (error) { next(error); } + }); + router.get("/state", async (_req, res, next) => { try { const settings = await loadSettings(options.settingsPath); @@ -367,7 +390,7 @@ export function createRestRouter(options: RestRouterOptions): Router { telemetryOptIn: settings.telemetryOptIn, // Absent === opted-in: light product analytics is on by default. productAnalyticsOptIn: settings.productAnalyticsOptIn !== false, - sessions: sessionManager.list(), + sessions: await listVisibleSessions(), workflows: await listWorkflows(), ...(options.listWorkspaceScopes ? { workspaceScopes: await options.listWorkspaceScopes() } @@ -593,8 +616,9 @@ export function createRestRouter(options: RestRouterOptions): Router { } }); - router.get("/sessions", (_req, res) => { - res.json(sessionManager.list()); + router.get("/sessions", async (_req, res, next) => { + try { res.json(await listVisibleSessions()); } + catch (error) { next(error); } }); router.get("/sessions/history", async (req, res, next) => { @@ -630,8 +654,7 @@ export function createRestRouter(options: RestRouterOptions): Router { // Registry entries win over transcript-scanned history for the same // agent session — they carry live status the transcript can't know. - const registryRows = sessionManager - .list() + const registryRows = (await listVisibleSessions()) .filter( (session) => session.cwd === cwd && session.agentSessionId != null, );