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-context-consumer.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@sapiom/harness": patch
---

Share the existing Assistant context owner across explicit lifecycle consumers.
8 changes: 8 additions & 0 deletions packages/harness/docs/assistant-history.md
Original file line number Diff line number Diff line change
Expand Up @@ -109,3 +109,11 @@ an operation UUID; Continue includes the displayed record revision. Responses
project public session/lifecycle/provenance fields only. Continue retry storage
uses an opaque server-projected scope stable across restarts and separated by
authorized account, workspace, project, harness and native binding.

An explicitly supplied verified context runtime activates one delivery consumer
for Send, recovery, Resume and Continue. It stores accepted sources beside the
native engine under the exact host scope; removing the engine does not remove
accepted context. Every later explicit child Send includes the frozen recorded
brief with freshly resolved current guidance. With no activation supplied,
ordinary legacy delivery remains available, legacy inline history can preflight,
and accepted-context Continue fails before reserving or allocating a child.
90 changes: 87 additions & 3 deletions packages/harness/src/core/opencode-host.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -56,6 +56,7 @@ const getBrowserState = vi.fn(() => ({
let root: string;
let cwd: string;
let host: OpenCodeHost;
let hostOptions: ConstructorParameters<typeof OpenCodeHost>[0];
let grant: AssistantGrant | null;
let changed: () => void;
let expectShutdownFailure = false;
Expand Down Expand Up @@ -122,7 +123,7 @@ beforeEach(async () => {
close,
});
prepareSkills.mockReset().mockResolvedValue([]);
host = new OpenCodeHost({
hostOptions = {
createObserver,
access: {
get: () => grant,
Expand All @@ -140,7 +141,8 @@ beforeEach(async () => {
authorize,
prepareSkills,
start,
});
};
host = new OpenCodeHost(hostOptions);
});
afterEach(async () => {
vi.useRealTimers();
Expand All @@ -151,6 +153,82 @@ afterEach(async () => {
});

describe("Studio-owned OpenCode lifecycle", () => {
it("uses only the activated owner's verified launcher and exact context scope", async () => {
await host.close();
const legacy = vi.fn(),
assertAvailable = vi.fn(async () => {});
host = new OpenCodeHost({
...hostOptions,
start: legacy,
contextRuntime: { start, assertAvailable },
});
const hosted = await host.ensure("studio-a");
expect(assertAvailable).toHaveBeenCalledOnce();
expect(legacy).not.toHaveBeenCalled();
const options = start.mock.calls[0]![0];
expect(options.assistantContext).toEqual({
authorityScope: hosted.contextAuthorityScope,
});
expect(options.stateRoot).toBe(join(hosted.stateRoot, "engine"));
expect(options.beforeLaunch).toBeTypeOf("function");
expect(options.signal).toBe(hosted.signal);
expect(assertAvailable.mock.invocationCallOrder[0]).toBeLessThan(
issue.mock.invocationCallOrder[0]!,
);
});
it("refuses inactive context ownership before issuing credentials or launching native", async () => {
await host.close();
const assertAvailable = vi.fn(async () => {
throw new OpenCodeTransportError(
openCodeTransportFailure("context_unavailable"),
);
});
host = new OpenCodeHost({
...hostOptions,
contextRuntime: { start, assertAvailable },
});
await expect(host.ensure("studio-a")).rejects.toMatchObject({
failure: { code: "context_unavailable" },
});
expect(start).not.toHaveBeenCalled();
expect(issue).not.toHaveBeenCalled();
});
it("End settles held context availability and its late result cannot launch over a replacement", async () => {
await host.close();
let release!: () => void;
const assertAvailable = vi
.fn(async () => {})
.mockImplementationOnce(
() =>
new Promise<void>((resolve) => {
release = resolve;
}),
);
host = new OpenCodeHost({
...hostOptions,
contextRuntime: { start, assertAvailable },
});
const pending = host.ensure("studio-a");
const rejected = expect(pending).rejects.toMatchObject({
failure: { code: "transport_unavailable" },
});
await vi.waitFor(() => expect(assertAvailable).toHaveBeenCalledOnce());
try {
expect(await host.retireWithResult("studio-a", 500)).toEqual({
state: "confirmed",
});
await rejected;
expect(start).not.toHaveBeenCalled();
expect(issue).not.toHaveBeenCalled();
const replacement = await host.ensure("studio-a");
release();
await Promise.resolve();
expect(host.current("studio-a")).toBe(replacement);
expect(start).toHaveBeenCalledOnce();
} finally {
release();
}
});
it("keeps accepted scope stable across credential rotation and runtime restart", async () => {
const original = await host.ensure("studio-a");
expect(original.contextAuthorityScope).toMatch(/^[a-f0-9]{64}$/);
Expand All @@ -160,6 +238,7 @@ describe("Studio-owned OpenCode lifecycle", () => {
});
expect(Object.isFrozen(original.model)).toBe(true);
expect(Object.isFrozen(original)).toBe(true);
expect(start.mock.calls[0]![0]).not.toHaveProperty("assistantContext");
grant = {
...grant!,
identityRevision: "new-revision",
Expand Down Expand Up @@ -615,7 +694,12 @@ describe("Studio-owned OpenCode lifecycle", () => {

it("a stale workspace authorization rejection cannot retire a replacement after End", async () => {
let reject!: (reason: unknown) => void;
authorize.mockImplementationOnce(() => new Promise((_resolve, fail) => { reject = fail; }));
authorize.mockImplementationOnce(
() =>
new Promise((_resolve, fail) => {
reject = fail;
}),
);
const stale = host.ensure("studio-a");
const rejected = expect(stale).rejects.toThrow("old authorization failed");
await host.retireWithResult("studio-a");
Expand Down
41 changes: 40 additions & 1 deletion packages/harness/src/core/opencode-host.ts
Original file line number Diff line number Diff line change
Expand Up @@ -66,6 +66,12 @@ export interface HostedOpenCode extends OpenCodeWorkspace {
export interface OpenCodeRetirement {
state: "confirmed" | "absent" | "unconfirmed";
}
/** The context owner verifies its selected runtime/plugin before activation.
* Availability is read-only; the launcher retains normal process-guard ownership. */
export interface OpenCodeContextRuntime {
readonly start: typeof startOpenCodeServer;
assertAvailable(): Promise<void>;
}
interface Managed {
workspace: OpenCodeWorkspace;
authority: string;
Expand All @@ -92,6 +98,8 @@ interface Options {
stateRoot: string,
) => Promise<string[]>;
start?: typeof startOpenCodeServer;
/** Explicit server capability; absent leaves the current production launcher unchanged. */
contextRuntime?: OpenCodeContextRuntime;
createObserver: (
hosted: HostedOpenCode,
id: string,
Expand Down Expand Up @@ -581,6 +589,30 @@ export class OpenCodeHost {
let server: OpenCodeServer | undefined;
let startupAttempted = false;
try {
const contextRuntime = this.options.contextRuntime;
if (contextRuntime) {
if (
typeof contextRuntime.start !== "function" ||
typeof contextRuntime.assertAvailable !== "function"
)
throw new OpenCodeTransportError(
openCodeTransportFailure("context_unavailable"),
);
const signal = entry.abort.signal;
let abort!: () => void;
const cancelled = new Promise<never>((_resolve, reject) => {
abort = () => reject(signal.reason);
signal.addEventListener("abort", abort, { once: true });
if (signal.aborted) abort();
});
try {
// Cancellation can race this read-only capability check; it cannot launch native work.
await Promise.race([contextRuntime.assertAvailable(), cancelled]);
} finally {
signal.removeEventListener("abort", abort);
}
await this.validate(entry);
}
const release = await new DurableFileLock(join(stateRoot, "runtime"), {
timeoutMs: 1000,
processGuard: "required",
Expand Down Expand Up @@ -631,12 +663,19 @@ export class OpenCodeHost {
...(skillPaths.length ? { skills: { paths: skillPaths } } : {}),
};
startupAttempted = true;
server = await (this.options.start ?? startOpenCodeServer)({
server = await (
contextRuntime?.start ??
this.options.start ??
startOpenCodeServer
)({
cwd: entry.workspace.cwd,
stateRoot: join(stateRoot, "engine"),
config,
signal: entry.abort.signal,
beforeLaunch: (identity) => release.protectProcess(identity),
...(contextRuntime
? { assistantContext: { authorityScope: contextScope } }
: {}),
});
void server.exited.then(() => this.retireExited(entry)).catch(() => {});
await this.validate(entry);
Expand Down
Loading
Loading