feat(opencode): open a fresh session on clear (#1300)

* test(opencode): specify fresh-session clear

* feat(opencode): open a fresh session on clear

* fix(opencode): release clear latch on cancel

* fix(opencode): retry transient clear handoffs

* fix(opencode): confirm clear archive delivery

* fix(web): preserve superseded session access

* fix(clear): invalidate transferred schedules

* fix(runner): restore live spawn dedupe

* fix(clear): preserve latched scheduled prompts

* fix(runner): quarantine unverified children

* fix(clear): retain handoff retry ownership

* fix(runner): release recovered spawn dedupe

* fix(clear): retain archive retry ownership

* fix(clear): settle rejected immediate prompts

* fix(clear): block reopening replaced sources

* fix(clear): settle prompts when clear is cancelled

* fix(clear): make fresh-session handoff durable

* fix(clear): finalize only after native cleanup

* fix(clear): abort failed native handoffs

* fix(clear): gate recovery on cleanup proof

* fix(clear): retry metadata persistence failures

* fix(clear): preserve handoff ownership through teardown

* fix(clear): abort incomplete cleanup reservations

* fix(clear): require explicit exit before abort

* fix(clear): verify owner exit before recovery

* fix(clear): guard recovery handoff races

* fix(clear): serialize cleanup callbacks

* fix(clear): make callback retries idempotent

* fix(clear): bind callbacks to reservations

* fix(clear): recover pending spawns

* fix(clear): deduplicate held prompts

* fix(clear): validate redirect ownership

* fix(clear): replay prompts in FIFO order

* fix(clear): gate replacement delivery
This commit is contained in:
Junmo Kim
2026-08-03 18:06:39 +08:00
committed by GitHub
parent 1b8cc334ea
commit f44c9ff3e6
41 changed files with 3209 additions and 73 deletions
+55 -9
View File
@@ -32,6 +32,12 @@ type OpencodeRemoteLauncherOptions = {
// call (and summary lookup) settles, so a cancelled request's result
// doesn't surface for an action the user no longer expects a reply from.
isLocalIdCancelled?: (localId: string) => boolean;
// Called only after /clear reaches its FIFO position *and* this
// launcher has disconnected its OpenCode backend. The caller then performs
// the source lifecycle cleanup before requesting the fresh process.
onClearRequested?: () => Promise<void>;
onClearCleanupComplete?: () => Promise<void>;
onClearCleanupFailed?: () => Promise<void>;
};
export type AbortStatusDecision = {
@@ -77,6 +83,10 @@ class OpencodeRemoteLauncher extends RemoteLauncherBase {
private baseUrl: string | null = null;
private permissionHandler: OpencodePermissionHandler | null = null;
private happyServer: { stop: () => void } | null = null;
// Becomes true when the FIFO loop reaches /clear. Its callback is deferred
// until cleanup() completes so a failed OpenCode disconnect cannot create a
// replacement while the source backend may still be live.
private clearRequested = false;
private abortController = new AbortController();
// Set by the dequeue loop as soon as a batch is identified as a
// `operation:'compact'` one — deliberately *before* that batch's inline
@@ -303,6 +313,18 @@ class OpencodeRemoteLauncher extends RemoteLauncherBase {
break;
}
// /clear is deliberately a queue operation rather than a direct
// slash side effect: every prompt and /compact ahead of it has
// completed before this point. In particular, do not route this
// through handleAbort(true): that method exists to interrupt an
// in-flight compact, while clear can only run after one finishes.
if (batch.mode.operation === 'clear') {
await this.options.onClearRequested?.();
this.clearRequested = true;
await this.requestExit('exit', async () => {})
break;
}
// Created here — before the model/effort switch below — rather
// than inside runCompactOperation(), so it already exists for
// handleAbort() to act on during that switch. backend.setModel()/
@@ -601,21 +623,45 @@ class OpencodeRemoteLauncher extends RemoteLauncherBase {
protected async cleanup(): Promise<void> {
this.clearAbortHandlers(this.session.client.rpcHandlerManager);
const failures: unknown[] = [];
if (this.permissionHandler) {
await this.permissionHandler.cancelAll('Session ended');
this.permissionHandler = null;
try {
await this.permissionHandler.cancelAll('Session ended');
} catch (error) {
failures.push(error);
} finally {
this.permissionHandler = null;
}
}
if (this.backend) {
await this.backend.disconnect();
this.backend = null;
try {
await this.backend.disconnect();
} catch (error) {
failures.push(error);
} finally {
this.backend = null;
}
}
if (this.happyServer) {
this.happyServer.stop();
this.happyServer = null;
try {
this.happyServer.stop();
} catch (error) {
failures.push(error);
} finally {
this.happyServer = null;
}
}
if (failures.length > 0) {
if (this.clearRequested) await this.options.onClearCleanupFailed?.();
throw failures.length === 1 ? failures[0] : new AggregateError(failures, 'OpenCode cleanup failed');
}
if (this.clearRequested) await this.options.onClearCleanupComplete?.();
// Signal the runner only after the native backend is gone. If an
// awaited teardown above fails, RemoteLauncherBase propagates that
// failure and this callback never runs; runOpencode then archives the
// source as an error rather than spawning a potentially concurrent
// replacement.
}
private rollbackReasoningEffort(batch: { mode: OpencodeMode }, effort: string | null): void {