Skip to content

Commit 6e2a5b0

Browse files
committed
feat: enhance OpenCodeAdapter with session management and cleanup logic
1 parent 5bfb224 commit 6e2a5b0

5 files changed

Lines changed: 201 additions & 38 deletions

File tree

apps/server/src/provider/Layers/OpenCodeAdapter.test.ts

Lines changed: 19 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -480,7 +480,25 @@ it.layer(OpenCodeAdapterTestLayer)("OpenCodeAdapterLive", (it) => {
480480

481481
assert.equal(sessions.length, 1);
482482
assert.equal(sessions[0]?.threadId, "thread-native-log-failure");
483-
assert.deepEqual(runtimeMock.state.closeCalls, []);
483+
assert.deepEqual(runtimeMock.state.closeCalls, ["http://127.0.0.1:9999"]);
484484
}),
485485
);
486486
});
487+
488+
it.effect("OpenCodeAdapterLive stops active sessions when the adapter layer is released", () =>
489+
Effect.gen(function* () {
490+
yield* Effect.scoped(
491+
Effect.gen(function* () {
492+
const adapter = yield* OpenCodeAdapter;
493+
yield* adapter.startSession({
494+
provider: "opencode",
495+
threadId: asThreadId("thread-finalizer"),
496+
runtimeMode: "full-access",
497+
});
498+
}).pipe(Effect.provide(OpenCodeAdapterTestLayer)),
499+
);
500+
501+
assert.deepEqual(runtimeMock.state.abortCalls, ["http://127.0.0.1:9999/session"]);
502+
assert.deepEqual(runtimeMock.state.closeCalls, ["http://127.0.0.1:9999"]);
503+
}),
504+
);

apps/server/src/provider/Layers/OpenCodeAdapter.ts

Lines changed: 49 additions & 36 deletions
Original file line numberDiff line numberDiff line change
@@ -40,11 +40,7 @@ import {
4040

4141
const PROVIDER = "opencode" as const;
4242
const OPEN_CODE_STALL_WARNING_MS = 15_000;
43-
44-
interface OpenCodeTurnSnapshot {
45-
readonly id: TurnId;
46-
readonly items: Array<unknown>;
47-
}
43+
const EXTERNAL_OPENCODE_ABORT_GRACE_MS = 2_000;
4844

4945
interface OpenCodeSessionContext {
5046
session: ProviderSession;
@@ -58,7 +54,6 @@ interface OpenCodeSessionContext {
5854
readonly partById: Map<string, Part>;
5955
readonly emittedTextByPartId: Map<string, string>;
6056
readonly completedAssistantPartIds: Set<string>;
61-
readonly turns: Array<OpenCodeTurnSnapshot>;
6257
activeTurnId: TurnId | undefined;
6358
activeAgent: string | undefined;
6459
activeVariant: string | undefined;
@@ -177,29 +172,22 @@ function mapPermissionDecision(reply: "once" | "always" | "reject"): string {
177172
}
178173
}
179174

180-
function resolveTurnSnapshot(
181-
context: OpenCodeSessionContext,
182-
turnId: TurnId,
183-
): OpenCodeTurnSnapshot {
184-
const existing = context.turns.find((turn) => turn.id === turnId);
185-
if (existing) {
186-
return existing;
187-
}
188-
189-
const created: OpenCodeTurnSnapshot = { id: turnId, items: [] };
190-
context.turns.push(created);
191-
return created;
192-
}
193-
194-
function appendTurnItem(
195-
context: OpenCodeSessionContext,
196-
turnId: TurnId | undefined,
197-
item: unknown,
198-
): void {
199-
if (!turnId) {
200-
return;
201-
}
202-
resolveTurnSnapshot(context, turnId).items.push(item);
175+
/**
176+
* Drop per-turn streaming bookkeeping after a turn ends. Long sessions otherwise
177+
* accumulate full assistant/reasoning text and tool-call state in `partById` /
178+
* `emittedTextByPartId` for every turn ever processed, which can grow into the
179+
* multi-GB range for sustained use.
180+
*
181+
* We intentionally drop *all* parts: events that arrive after the turn ends for
182+
* a previously-seen part are no-ops in the existing handlers (they guard on
183+
* `partById.get(...)` returning `undefined`), and assistant text for a closed
184+
* turn has already been emitted as `item.completed`.
185+
*/
186+
function pruneCompletedTurnState(context: OpenCodeSessionContext): void {
187+
context.partById.clear();
188+
context.emittedTextByPartId.clear();
189+
context.completedAssistantPartIds.clear();
190+
context.messageRoleById.clear();
203191
}
204192

205193
function ensureSessionContext(
@@ -509,12 +497,20 @@ async function stopOpenCodeContext(context: OpenCodeSessionContext): Promise<voi
509497
context.stallWarningTimer = undefined;
510498
}
511499
context.eventsAbortController.abort();
512-
try {
513-
await context.client.session
514-
.abort({ sessionID: context.openCodeSessionId })
515-
.catch(() => undefined);
516-
} catch {}
500+
if (context.server.external) {
501+
// External servers outlive us: try to abort the remote session politely,
502+
// but do not block teardown if the RPC is wedged.
503+
await Promise.race([
504+
context.client.session.abort({ sessionID: context.openCodeSessionId }).catch(() => undefined),
505+
new Promise<void>((resolve) => setTimeout(resolve, EXTERNAL_OPENCODE_ABORT_GRACE_MS)),
506+
]);
507+
context.server.close();
508+
return;
509+
}
510+
// Local server: skip the abort RPC entirely — we own the process and are
511+
// about to terminate it, so the HTTP call would just race a dropped socket.
517512
context.server.close();
513+
pruneCompletedTurnState(context);
518514
}
519515

520516
export function makeOpenCodeAdapterLive(_options?: OpenCodeAdapterLiveOptions) {
@@ -531,6 +527,8 @@ export function makeOpenCodeAdapterLive(_options?: OpenCodeAdapterLiveOptions) {
531527
stream: "native",
532528
})
533529
: undefined);
530+
const managedNativeEventLogger =
531+
_options?.nativeEventLogger === undefined ? nativeEventLogger : undefined;
534532
const runtimeEvents = yield* Queue.unbounded<ProviderRuntimeEvent>();
535533
const sessions = new Map<ThreadId, OpenCodeSessionContext>();
536534

@@ -593,7 +591,9 @@ export function makeOpenCodeAdapterLive(_options?: OpenCodeAdapterLiveOptions) {
593591
context.stopped = true;
594592
clearTurnStallWarning(context);
595593
sessions.delete(context.session.threadId);
594+
context.eventsAbortController.abort();
596595
context.server.close();
596+
pruneCompletedTurnState(context);
597597
void logWarning("opencode.session.unexpected-exit", {
598598
message,
599599
...turnObservabilitySnapshot(context),
@@ -861,7 +861,6 @@ export function makeOpenCodeAdapterLive(_options?: OpenCodeAdapterLiveOptions) {
861861
: "item.updated",
862862
payload,
863863
};
864-
appendTurnItem(context, turnId, part);
865864
await emitPromise(runtimeEvent);
866865
}
867866
break;
@@ -1022,6 +1021,7 @@ export function makeOpenCodeAdapterLive(_options?: OpenCodeAdapterLiveOptions) {
10221021
clearTurnStallWarning(context);
10231022
context.activeTurnId = undefined;
10241023
context.activeTurnStartedAt = undefined;
1024+
pruneCompletedTurnState(context);
10251025
updateProviderSession(
10261026
context,
10271027
{ status: "ready" },
@@ -1044,6 +1044,7 @@ export function makeOpenCodeAdapterLive(_options?: OpenCodeAdapterLiveOptions) {
10441044
clearTurnStallWarning(context);
10451045
context.activeTurnId = undefined;
10461046
context.activeTurnStartedAt = undefined;
1047+
pruneCompletedTurnState(context);
10471048
updateProviderSession(
10481049
context,
10491050
{
@@ -1238,7 +1239,6 @@ export function makeOpenCodeAdapterLive(_options?: OpenCodeAdapterLiveOptions) {
12381239
emittedTextByPartId: new Map(),
12391240
messageRoleById: new Map(),
12401241
completedAssistantPartIds: new Set(),
1241-
turns: [],
12421242
activeTurnId: undefined,
12431243
activeAgent: undefined,
12441244
activeVariant: undefined,
@@ -1397,6 +1397,7 @@ export function makeOpenCodeAdapterLive(_options?: OpenCodeAdapterLiveOptions) {
13971397
context.activeAgent = undefined;
13981398
context.activeVariant = undefined;
13991399
context.activeTurnStartedAt = undefined;
1400+
pruneCompletedTurnState(context);
14001401
updateProviderSession(
14011402
context,
14021403
{
@@ -1671,6 +1672,18 @@ export function makeOpenCodeAdapterLive(_options?: OpenCodeAdapterLiveOptions) {
16711672
}),
16721673
});
16731674

1675+
yield* Effect.addFinalizer(() =>
1676+
stopAll().pipe(
1677+
Effect.catchCause((cause) =>
1678+
Effect.logWarning("opencode.session.stop-all-finalizer-failed", {
1679+
cause: Cause.pretty(cause),
1680+
}),
1681+
),
1682+
Effect.tap(() => Queue.shutdown(runtimeEvents)),
1683+
Effect.tap(() => managedNativeEventLogger?.close() ?? Effect.void),
1684+
),
1685+
);
1686+
16741687
return {
16751688
provider: PROVIDER,
16761689
capabilities: {

apps/server/src/provider/opencodeRuntime.ts

Lines changed: 16 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -378,7 +378,16 @@ export async function startOpenCodeServerProcess(input: {
378378
return;
379379
}
380380
closed = true;
381-
child.kill();
381+
const forceKillTimer = setTimeout(() => {
382+
if (child.exitCode === null && child.signalCode === null) {
383+
child.kill("SIGKILL");
384+
}
385+
}, 1_000);
386+
forceKillTimer.unref();
387+
child.once("exit", () => clearTimeout(forceKillTimer));
388+
child.stdout?.destroy();
389+
child.stderr?.destroy();
390+
child.kill("SIGTERM");
382391
};
383392

384393
const url = await new Promise<string>((resolve, reject) => {
@@ -441,6 +450,12 @@ export async function startOpenCodeServerProcess(input: {
441450
child.once("close", onClose);
442451
});
443452

453+
// After startup, keep draining the child's stdio so its OS pipe buffers
454+
// never fill (which would block the OpenCode server on its next write).
455+
// We don't retain the data \u2014 only listeners that count as consumers.
456+
child.stdout.on("data", () => {});
457+
child.stderr.on("data", () => {});
458+
444459
return {
445460
url,
446461
process: child,

package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -53,6 +53,7 @@
5353
"dist:desktop:win": "node scripts/build-desktop-artifact.ts --platform win --target nsis",
5454
"dist:desktop:win:arm64": "node scripts/build-desktop-artifact.ts --platform win --target nsis --arch arm64",
5555
"dist:desktop:win:x64": "node scripts/build-desktop-artifact.ts --platform win --target nsis --arch x64",
56+
"install:desktop:local": "bash scripts/install-desktop-local.sh",
5657
"release:smoke": "node scripts/release-smoke.ts",
5758
"clean": "rm -rf node_modules apps/*/node_modules packages/*/node_modules apps/*/dist apps/*/dist-electron packages/*/dist .turbo apps/*/.turbo packages/*/.turbo",
5859
"sync:vscode-icons": "node scripts/sync-vscode-icons.mjs"

scripts/install-desktop-local.sh

Lines changed: 116 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,116 @@
1+
#!/usr/bin/env bash
2+
#
3+
# Build the arm64 macOS DMG and install it into /Applications, replacing any
4+
# previous installation. Quits the running app first, clears quarantine, and
5+
# launches the freshly installed build.
6+
#
7+
# Usage:
8+
# scripts/install-desktop-local.sh # build + install + launch
9+
# scripts/install-desktop-local.sh --no-build # reuse the existing DMG
10+
# scripts/install-desktop-local.sh --no-launch # skip the open at the end
11+
#
12+
set -euo pipefail
13+
14+
REPO_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
15+
APP_NAME="T3 Code (Alpha)"
16+
APP_BUNDLE="${APP_NAME}.app"
17+
INSTALL_DEST="/Applications/${APP_BUNDLE}"
18+
RELEASE_DIR="${REPO_ROOT}/release"
19+
20+
DO_BUILD=1
21+
DO_LAUNCH=1
22+
for arg in "$@"; do
23+
case "$arg" in
24+
--no-build) DO_BUILD=0 ;;
25+
--no-launch) DO_LAUNCH=0 ;;
26+
-h|--help)
27+
sed -n '2,12p' "$0"
28+
exit 0
29+
;;
30+
*)
31+
echo "Unknown argument: $arg" >&2
32+
exit 2
33+
;;
34+
esac
35+
done
36+
37+
if [[ "$(uname)" != "Darwin" ]]; then
38+
echo "This script only runs on macOS." >&2
39+
exit 1
40+
fi
41+
42+
HOST_ARCH="$(uname -m)"
43+
if [[ "$HOST_ARCH" != "arm64" ]]; then
44+
echo "Warning: host arch is ${HOST_ARCH}; this script builds an arm64 DMG." >&2
45+
fi
46+
47+
log() { printf '\n[install-desktop] %s\n' "$*"; }
48+
49+
log "Quitting any running ${APP_NAME} instance..."
50+
osascript -e "tell application \"${APP_NAME}\" to quit" >/dev/null 2>&1 || true
51+
# Give the app a moment to exit cleanly, then force-kill any stragglers.
52+
sleep 1
53+
pkill -f "${APP_BUNDLE}/Contents/MacOS/" >/dev/null 2>&1 || true
54+
55+
if [[ "$DO_BUILD" -eq 1 ]]; then
56+
log "Building arm64 DMG (this takes ~1 minute)..."
57+
rm -f "${RELEASE_DIR}"/T3-Code-*-arm64.dmg \
58+
"${RELEASE_DIR}"/T3-Code-*-arm64.dmg.blockmap \
59+
"${RELEASE_DIR}"/T3-Code-*-arm64.zip \
60+
"${RELEASE_DIR}"/T3-Code-*-arm64.zip.blockmap
61+
( cd "$REPO_ROOT" && bun run dist:desktop:dmg:arm64 )
62+
fi
63+
64+
DMG_PATH="$(ls -t "${RELEASE_DIR}"/T3-Code-*-arm64.dmg 2>/dev/null | head -n 1 || true)"
65+
if [[ -z "$DMG_PATH" || ! -f "$DMG_PATH" ]]; then
66+
echo "No arm64 DMG found in ${RELEASE_DIR}." >&2
67+
echo "Re-run without --no-build to produce one." >&2
68+
exit 1
69+
fi
70+
log "Using DMG: ${DMG_PATH}"
71+
72+
# Mount the DMG into a temporary mount point and ensure we always detach it,
73+
# even if ditto/cp fails.
74+
MOUNT_POINT=""
75+
cleanup() {
76+
if [[ -n "$MOUNT_POINT" && -d "$MOUNT_POINT" ]]; then
77+
hdiutil detach "$MOUNT_POINT" -quiet || hdiutil detach "$MOUNT_POINT" -force -quiet || true
78+
fi
79+
}
80+
trap cleanup EXIT
81+
82+
log "Mounting DMG..."
83+
ATTACH_OUTPUT="$(hdiutil attach -nobrowse -readonly -plist "$DMG_PATH")"
84+
# Pull the first /Volumes/* mount point out of the plist.
85+
MOUNT_POINT="$(printf '%s' "$ATTACH_OUTPUT" \
86+
| /usr/bin/awk '/<string>\/Volumes\//{ sub(/.*<string>/,""); sub(/<\/string>.*/,""); print; exit }')"
87+
if [[ -z "$MOUNT_POINT" || ! -d "$MOUNT_POINT" ]]; then
88+
echo "Failed to determine DMG mount point." >&2
89+
exit 1
90+
fi
91+
log "Mounted at: ${MOUNT_POINT}"
92+
93+
SRC_APP="${MOUNT_POINT}/${APP_BUNDLE}"
94+
if [[ ! -d "$SRC_APP" ]]; then
95+
echo "Source app not found at ${SRC_APP}." >&2
96+
ls -la "$MOUNT_POINT" >&2
97+
exit 1
98+
fi
99+
100+
log "Replacing ${INSTALL_DEST}..."
101+
rm -rf "$INSTALL_DEST"
102+
ditto "$SRC_APP" "$INSTALL_DEST"
103+
104+
log "Clearing quarantine attributes..."
105+
xattr -dr com.apple.quarantine "$INSTALL_DEST" 2>/dev/null || true
106+
107+
INSTALLED_VERSION="$(/usr/libexec/PlistBuddy -c 'Print :CFBundleShortVersionString' \
108+
"${INSTALL_DEST}/Contents/Info.plist" 2>/dev/null || echo 'unknown')"
109+
log "Installed ${APP_NAME} v${INSTALLED_VERSION}"
110+
111+
if [[ "$DO_LAUNCH" -eq 1 ]]; then
112+
log "Launching..."
113+
open -a "$INSTALL_DEST"
114+
fi
115+
116+
log "Done."

0 commit comments

Comments
 (0)