Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/assistant-server-lifecycle.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@sapiom/harness": patch
---

Connect Assistant history, Resume and Continue to the shared server context owner and hide unfinished continuation children.
6 changes: 6 additions & 0 deletions packages/harness/docs/assistant-history.md
Original file line number Diff line number Diff line change
Expand Up @@ -148,3 +148,9 @@ Continue explicitly uses the record currently displayed in history review. Its
opaque request tuple survives reloads and uncertain responses; tabs serialize
creation before POST. A verified child opens Assistant paused with recorded
provenance. Account, navigation and newer bus state fence result adoption.

The assembled server shares one context delivery instance across Send, recovery,
Resume and Continue. Attach includes verified public continuation provenance and
rechecks its lease after receipt reads. Pending children stay out of history and
cannot attach, including after restart; only prepared children publish a session
event. Runtime activation remains an explicit capability from its existing owner.
11 changes: 11 additions & 0 deletions packages/harness/src/core/assistant-history.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,8 @@ interface Options {
authorize: (id: string) => Promise<AssistantAssociation | null>;
records: Pick<AssistantRecordStore, "read">;
lifecycle: Pick<AssistantLifecycleCoordinator, "describe">;
/** Pending continuation children are not discoverable until preparation commits. */
isVisible?: (id: string) => Promise<boolean>;
}

/** Metadata-only discovery. Native availability is checked separately for a selected entry. */
Expand All @@ -32,6 +34,8 @@ export class AssistantHistory {
} | null> {
const session = this.options.sessions.get(id);
if (!session) return null;
if (this.options.isVisible && !(await this.options.isVisible(id)))
return null;
const project = JSON.stringify([session.harness, session.agentMapIdentity]);
const binding = await this.options.authorize(id);
if (!binding) return null;
Expand Down Expand Up @@ -65,6 +69,8 @@ export class AssistantHistory {
)
throw new OpenCodeAccessError("Assistant binding changed");
const current = this.options.sessions.get(id);
if (this.options.isVisible && !(await this.options.isVisible(id)))
return null;
if (
!current ||
current.cwd !== session.cwd ||
Expand Down Expand Up @@ -139,6 +145,11 @@ export class AssistantHistory {
// Revalidate the complete result after IO for the other entries. No old
// authority's titles or existence may escape on a mid-list account change.
for (const { entry, binding: previous } of entries) {
if (
this.options.isVisible &&
!(await this.options.isVisible(entry.harnessSessionId))
)
throw new OpenCodeAccessError("Assistant preparation changed");
const binding = await this.options.authorize(entry.harnessSessionId);
if (!binding || JSON.stringify(binding) !== JSON.stringify(previous))
throw new OpenCodeAccessError("Assistant access changed");
Expand Down
86 changes: 73 additions & 13 deletions packages/harness/src/server/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -201,15 +201,21 @@ import { AssistantLifecycleCoordinator } from "../core/assistant-lifecycle.js";
import { AssistantEndCoordinator } from "../core/assistant-end.js";
import { createAssistantStateProjection } from "../core/assistant-state-projection.js";
import { OpenCodeAssociations } from "../core/opencode-association.js";
import { OpenCodeHost, type HostedOpenCode } from "../core/opencode-host.js";
import { OpenCodeHost, OpenCodeAccessError, type HostedOpenCode } from "../core/opencode-host.js";
import { AssistantRecordStore } from "../core/assistant-record-store.js";
import { AssistantRecordCapture } from "../core/assistant-record-capture.js";
import { assistantHistoryAccess } from "../core/assistant-history-access.js";
import { AssistantHistory } from "../core/assistant-history.js";
import { OpenCodeFinalResponse } from "../core/opencode-final-response.js";
import { createAssistantRecordsRouter } from "./assistant-records.js";
import { OpenCodeObserver } from "../core/opencode-observer.js";
import { createAssistantContextResolver } from "./studio-assistant.js";
import { createAssistantContextRuntime, type ActivatedAssistantContextRuntime } from "./assistant-context-runtime.js";
import { AssistantContinuationStore } from "../core/assistant-continuation-store.js";
import { AssistantContinuationNative } from "../core/assistant-continuation-native.js";
import { AssistantContinuation } from "../core/assistant-continuation.js";
import { AssistantNativeHistory } from "../core/assistant-native-history.js";
import { createAssistantLifecycleRouter, projectAssistantContinuation } from "./assistant-lifecycle.js";
import type { AssistantContinuationView } from "../shared/assistant-continuation.js";
import { OpenCodeBridge } from "./opencode-bridge.js";
import { createStaticRouter } from "./static.js";
import { createTerminalWebSocketHandler } from "./terminal-ws.js";
Expand Down Expand Up @@ -302,6 +308,8 @@ export interface HarnessServerOptions {
* bundled DEFAULT_SYSTEM_PROMPT on any failure. Tests and offline hosts inject a
* plain function instead of reaching the network. */
loadSystemPrompt?: () => Promise<string>;
/** Explicit verified capability supplied by the existing context runtime owner. */
assistantContextRuntime?: ActivatedAssistantContextRuntime;
Comment on lines +311 to +312

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔍 No shipping host activates Continue

Neither the CLI nor Electron supplies assistantContextRuntime, so Continue returns context_unavailable. Verify that the stack intentionally defers host activation.

Devin Review

Was this helpful? React with 👍 or 👎 to provide feedback.

/** Host-supplied launcher for the local sapiom-dev MCP server, replacing the
* default `npx @sapiom/mcp@latest`. The Electron host passes its own binary
* (GUI-subsystem — allocates no console window on Windows, where the npx
Expand Down Expand Up @@ -3546,6 +3554,7 @@ export const startServer = async (

const assistantSessions = new AssistantSessionStore(statePaths.root);
const assistantRecords = new AssistantRecordStore(statePaths.root);
const assistantContinuations = new AssistantContinuationStore(statePaths.root);
const recordCaptures = new WeakMap<HostedOpenCode, AssistantRecordCapture>();
const authorizeAssistantWorkspace = async (id: string) => {
const session = sessionManager.get(id);
Expand All @@ -3556,10 +3565,24 @@ export const startServer = async (
}))) return null;
return { harnessSessionId: id, cwd: session.cwd };
};
const authorizeAssistantHistory = assistantHistoryAccess({ access: assistantAccess, authorize: authorizeAssistantWorkspace, store: assistantSessions });
const continuationFor = async (id: string, conversationId: string): Promise<AssistantContinuationView | null> => {
const receipt = await assistantContinuations.readChild(id);
if (!receipt) return null;
const binding = await authorizeAssistantHistory(id);
if (!binding || !receipt.childBinding || receipt.childStudioId !== id ||
binding.conversationId !== conversationId ||
binding.conversationId !== receipt.childBinding.conversationId ||
binding.contextAuthorityScope !== receipt.childBinding.contextAuthorityScope ||
binding.cwd !== receipt.childBinding.cwd)
throw new OpenCodeAccessError("Assistant continuation binding changed");
return projectAssistantContinuation(receipt);
};
const openCodeHost = new OpenCodeHost({
contextRuntime: options.assistantContextRuntime,
access: assistantAccess,
createObserver: (hosted, id, update) => {
const capture = new AssistantRecordCapture(hosted, id, assistantRecords);
const capture = new AssistantRecordCapture(hosted, id, assistantRecords, () => continuationFor(hosted.harnessSessionId, id));
recordCaptures.set(hosted, capture);
const checkpoint = () => capture.checkpoint();
const observer = new OpenCodeObserver(hosted, id, update, capture.invalidate, () => { void checkpoint(); });
Expand All @@ -3571,7 +3594,7 @@ export const startServer = async (
authorize: authorizeAssistantWorkspace,
});
const openCodeAssociations = new OpenCodeAssociations(assistantSessions);
const assistantLifecycle = new AssistantLifecycleCoordinator({ store: assistantSessions, host: openCodeHost, associations: openCodeAssociations });
const assistantLifecycle = new AssistantLifecycleCoordinator({ store: assistantSessions, host: openCodeHost, associations: openCodeAssociations, canAttach: (id) => assistantContinuations.canAttach(id) });
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.
Expand All @@ -3580,6 +3603,35 @@ export const startServer = async (
bus.publish({ type: "assistant.state", snapshot }),
);
const getAssistantState = assistantProjection.get;
const assistantContext = createAssistantContextRuntime({
activated: options.assistantContextRuntime,
continuations: assistantContinuations,
assertCurrent: (hosted) => openCodeHost.assertCurrent(hosted),
contextOptions: {
getSession: (id) => sessionManager.get(id),
resolveProject: (id) => studioProjectCatalog.resolveIdentity(id),
getWorkflows: readPublicWorkflows,
getEnvironment: () => assistantAccess.get()?.environment ?? null,
loadSystemPrompt: options.loadSystemPrompt,
},
});
const assistantNativeHistory = new AssistantNativeHistory({
authorize: authorizeAssistantHistory,
lifecycle: assistantLifecycle,
preflight: assistantContext.delivery.recover,
});
const assistantContinue = new AssistantContinuation({
store: assistantContinuations,
records: assistantRecords,
sessions: sessionManager,
associations: assistantSessions,
lifecycle: assistantLifecycle,
native: new AssistantContinuationNative((hosted) => openCodeHost.assertCurrent(hosted)),
delivery: assistantContext.delivery,
authorize: authorizeAssistantHistory,
resolveCandidate: (hosted, _receipt, signal) => assistantContext.resolveCandidate(hosted, null, signal),
assertAvailable: assistantContext.assertAvailable,
});
const app: Express = express();
app.disable("x-powered-by");
app.use("/opencode-runtime", openCodeBridge.router);
Expand All @@ -3588,19 +3640,15 @@ export const startServer = async (
createOpenCodeRouter(
openCodeHost,
options.bootToken,
createAssistantContextResolver({
getSession: (id) => sessionManager.get(id),
resolveProject: (id) => studioProjectCatalog.resolveIdentity(id),
getWorkflows: readPublicWorkflows,
getEnvironment: () => assistantAccess.get()?.environment ?? null,
loadSystemPrompt: options.loadSystemPrompt,
}),
assistantContext.context,
openCodeAssociations,
new OpenCodeFinalResponse({
recoverPrompt: assistantContext.delivery.recover,
assertCurrent: (hosted) => assistantLifecycle.assertRuntime(hosted, true),
onPersisted: async (hosted) => { await recordCaptures.get(hosted)?.checkpoint(); },
}),
assistantLifecycle,
continuationFor,
),
);

Expand All @@ -3625,8 +3673,20 @@ export const startServer = async (
res.setHeader("Cache-Control", "no-store");
res.json(assistantAccess.getBrowserState());
});
const authorizeAssistantHistory = assistantHistoryAccess({ access: assistantAccess, authorize: authorizeAssistantWorkspace, store: assistantSessions });
const assistantHistory = new AssistantHistory({ sessions: sessionManager, authorize: authorizeAssistantHistory, records: assistantRecords, lifecycle: assistantLifecycle });
const assistantHistory = new AssistantHistory({ sessions: sessionManager, authorize: authorizeAssistantHistory, records: assistantRecords, lifecycle: assistantLifecycle, isVisible: (id) => assistantContinuations.canAttach(id) });

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 Pending continuation children remain visible

After Continue allocates an unfinished child, isVisible hides it only from Assistant history. Generic session projections still expose the exited child, allowing Studio to start its dormant Terminal.

Learn more

Continuation allocation persists an exited, dormant HarnessSession before native preparation completes. This filter removes that row only from AssistantHistory; it does not affect the session arrays returned by the boot state and session-list endpoints. The history menu treats an exited session absent from Assistant history as an ordinary Terminal row in pastRows. Its normal Start Terminal action reaches activateDormant without checking continuation preparation. A crash or failed Continue therefore exposes a child that must remain private until its receipt reaches prepared.

Example: Continue allocates child child-1, then native seeding fails. After reload, /api/state includes child-1 with status: "exited", while Assistant history filters it out. Studio renders an ordinary past session and can start its dormant Terminal, instead of hiding the child until retry completes preparation.

Recommended fix: Apply one continuation-visibility projection to every public session source, including /api/state, /api/sessions, and relevant history/session actions. Reject terminal/start and other direct child operations while assistantContinuations.canAttach(id) is false. Preserve internal SessionManager visibility so the continuation coordinator can recover the allocation.

Devin Review

Was this helpful? React with 👍 or 👎 to provide feedback.

app.use("/api", createAssistantLifecycleRouter({
bootToken: options.bootToken,
history: assistantHistory,
native: assistantNativeHistory,
getSession: (id) => sessionManager.get(id),
resume: (id, revision, operationId) => assistantNativeHistory.resume(id, revision, operationId),
continue: async (id, request) => {
const prepared = await assistantContinue.continue(id, request);
// Publish only the verified child. Pending receipt allocation is private.
bus.publish({ type: "session.status", session: prepared.session });
return prepared;
},
}));
Comment on lines 3673 to +3689

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔍 Server wiring lacks integration coverage

Tests exercise the router, not the new startServer composition. Add assembled-server coverage for Continue publication, restart visibility, Resume, and inactive activation.

(Refers to this code)

Devin Review

Was this helpful? React with 👍 or 👎 to provide feedback.

app.use("/api", createAssistantRecordsRouter({
bootToken: options.bootToken,
store: assistantRecords,
Expand Down
37 changes: 37 additions & 0 deletions packages/harness/src/server/opencode.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -743,6 +743,7 @@ it("binds an authorized association even when its browser disconnects during att

function installLifecycle(
context: Parameters<typeof createOpenCodeRouter>[2] = resolveContext,
continuationFor?: Parameters<typeof createOpenCodeRouter>[6],
) {
const host = {
ensure,
Expand Down Expand Up @@ -779,6 +780,7 @@ function installLifecycle(
associations,
undefined,
lifecycle,
continuationFor,
);
return lifecycle;
}
Expand Down Expand Up @@ -840,6 +842,41 @@ it("requires a revision for attach and an exact lease for every subsequent nativ
expect(other.status).toBe(409);
});

it("enriches exact attachments with authorized continuation provenance without prompting", async () => {
const continuation = { sourceSessionId: "source" } as Awaited<
ReturnType<NonNullable<Parameters<typeof createOpenCodeRouter>[6]>>
>;
const project = vi.fn(async () => continuation);
installLifecycle(resolveContext, project);
const attached = await attachLease();
expect(attached).toHaveProperty("continuation", continuation);
expect(project).toHaveBeenCalledExactlyOnceWith(
"studio-a",
attached.conversationId,
);
expect(
requests.filter((request) => request.path.endsWith("/prompt_async")),
).toHaveLength(0);
});

it("does not return provenance with a lease superseded during receipt IO", async () => {
const lifecycle = installLifecycle(resolveContext, async (id) => {
const ending = lifecycle.beginEnd(id);
await ending.persistence;
await ending.native;
await lifecycle.finishEnd(ending.fence);
return null;
});
const response = await request("studio-a/attach", {
method: "POST",
body: JSON.stringify({ expectedRevision: 0 }),
});
expect(response.status).toBe(409);
expect(await response.json()).toMatchObject({
error: { code: "lifecycle_changed" },
});
});

it("rejects paused recovery before native reads and enables only explicit prepared Send", async () => {
const lifecycle = installLifecycle();
const attached = await attachLease();
Expand Down
14 changes: 13 additions & 1 deletion packages/harness/src/server/opencode.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@ import {
} from "../core/studio-assistant-context.js";
import type { AssistantContextDelivery } from "../core/studio-assistant-delivery.js";
import type { AssistantLifecycleCoordinator } from "../core/assistant-lifecycle.js";
import type { AssistantContinuationView } from "../shared/assistant-continuation.js";

export function createOpenCodeRouter(
host: Pick<OpenCodeHost, "ensure" | "observe"> &
Expand All @@ -33,6 +34,10 @@ export function createOpenCodeRouter(
AssistantLifecycleCoordinator,
"describe" | "attach" | "use" | "enable" | "assertRuntime"
>,
continuationFor?: (
id: string,
conversationId: string,
) => Promise<AssistantContinuationView | null>,
): Router {
const router = express.Router();
const dispatch =
Expand Down Expand Up @@ -129,7 +134,14 @@ export function createOpenCodeRouter(
}
if (attach && lifecycle) {
const attached = await lifecycle.attach(id, req.body.expectedRevision);
if (!res.destroyed) res.json(attached);
const continuation = await continuationFor?.(
id,
attached.conversationId,
);
// Receipt IO cannot publish an old lease after End or an authority change.
if (continuationFor) await lifecycle.use(id, attached.lease);
if (!res.destroyed)
res.json({ ...attached, ...(continuation ? { continuation } : {}) });
return;
}
const bound = lifecycle
Expand Down
Loading