Skip to content
Open
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
33 changes: 33 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
name: CI

on:
pull_request:
branches:
- beta
push:
branches:
- beta

permissions:
contents: read

jobs:
verify:
runs-on: ubuntu-latest
timeout-minutes: 20
steps:
- uses: actions/checkout@v4

- uses: oven-sh/setup-bun@v2
with:
bun-version: 1.3.14

- uses: actions/setup-node@v4
with:
node-version: 22

- name: Install exact dependencies
run: bun install --frozen-lockfile

- name: Verify V2 beta package
run: npm run verify
142 changes: 48 additions & 94 deletions .github/workflows/release.yml
Original file line number Diff line number Diff line change
@@ -1,136 +1,90 @@
name: Release
name: Release V2 Beta

on:
workflow_dispatch:
inputs:
version:
description: 'Explicit version (e.g., 0.3.0). If empty, bump type is used.'
required: false
type: string
bump:
description: 'Version bump type (used if version is empty)'
required: false
default: 'minor'
type: choice
options:
- minor
- patch
- major
dry_run:
description: 'Dry run — skip npm publish, create draft release'
required: false
default: false
description: Verify and pack without publishing
required: true
default: true
type: boolean

env:
NODE_VERSION: '22'
concurrency:
group: opencode-cursor-v2-release
cancel-in-progress: false

permissions:
contents: write
id-token: write

jobs:
release:
if: github.ref_name == 'beta'
runs-on: ubuntu-latest
timeout-minutes: 25
steps:
- uses: actions/checkout@v4

- uses: oven-sh/setup-bun@v2
with:
fetch-depth: 0
fetch-tags: true
bun-version: 1.3.14

- name: Get current version from package.json
id: current_version
run: echo "version=$(node -p "require('./package.json').version")" >> "$GITHUB_OUTPUT"
- uses: actions/setup-node@v4
with:
node-version: 22
registry-url: https://registry.npmjs.org

- name: Calculate new version
- name: Validate prerelease version
id: version
env:
CURRENT_VERSION: ${{ steps.current_version.outputs.version }}
INPUT_VERSION: ${{ github.event.inputs.version }}
BUMP_TYPE: ${{ github.event.inputs.bump }}
shell: bash
run: |
set -euo pipefail
if [[ -n "$INPUT_VERSION" ]]; then
# Validate semver
if ! echo "$INPUT_VERSION" | grep -qE '^[0-9]+\.[0-9]+\.[0-9]+$'; then
echo "Error: version must be in semver format X.Y.Z"
exit 1
fi
echo "version=$INPUT_VERSION" >> "$GITHUB_OUTPUT"
else
IFS='.' read -r major minor patch <<< "$CURRENT_VERSION"
case "$BUMP_TYPE" in
major) echo "version=$((major+1)).0.0" >> "$GITHUB_OUTPUT" ;;
minor) echo "version=$major.$((minor+1)).0" >> "$GITHUB_OUTPUT" ;;
patch) echo "version=$major.$minor.$((patch+1))" >> "$GITHUB_OUTPUT" ;;
*) echo "Error: unknown bump type $BUMP_TYPE"; exit 1 ;;
esac
version="$(node -p "require('./package.json').version")"
if [[ ! "$version" =~ ^[0-9]+\.[0-9]+\.[0-9]+-beta\.[0-9]+$ ]]; then
echo "Expected package version X.Y.Z-beta.N, got: $version" >&2
exit 1
fi
echo "version=$version" >> "$GITHUB_OUTPUT"

- name: Show version info
run: |
echo "Current version: ${{ steps.current_version.outputs.version }}"
echo "New version: ${{ steps.version.outputs.version }}"
echo "Dry run: ${{ github.event.inputs.dry_run == 'true' && 'yes' || 'no' }}"

- name: Update package.json version
run: |
node -e "
const pkg = require('./package.json');
pkg.version = '${{ steps.version.outputs.version }}';
require('fs').writeFileSync('./package.json', JSON.stringify(pkg, null, 2) + '\n');
"

- name: Setup Bun
uses: oven-sh/setup-bun@v2

- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version: ${{ env.NODE_VERSION }}
registry-url: 'https://registry.npmjs.org'

- name: Install dependencies
- name: Install exact dependencies
run: bun install --frozen-lockfile

- name: Run tests
run: bun run test
- name: Verify source and installed artifact
run: npm run verify

- name: Build package
run: bun run build

- name: Commit and tag
env:
VERSION: ${{ steps.version.outputs.version }}
- name: Pack exact release artifact
id: pack
shell: bash
run: |
git config user.name "github-actions[bot]"
git config user.email "github-actions[bot]@users.noreply.github.com"
git add package.json
if git ls-files --modified --others | grep -q bun.lock; then
git add bun.lock
fi
git commit -m "chore: release v${VERSION}"
git tag "v${VERSION}"
git push origin "v${VERSION}"
git push origin HEAD:main
set -euo pipefail
output="$(npm pack --ignore-scripts --json --pack-destination "$RUNNER_TEMP")"
filename="$(node -e 'const fs=require("fs"); const x=JSON.parse(fs.readFileSync(0,"utf8")); process.stdout.write(x[0].filename)' <<<"$output")"
echo "path=$RUNNER_TEMP/$filename" >> "$GITHUB_OUTPUT"

- name: Publish to npm
if: ${{ github.event.inputs.dry_run != 'true' }}
run: npm publish --access public
- name: Upload verified artifact
uses: actions/upload-artifact@v4
with:
name: opencode-cursor-v2-${{ steps.version.outputs.version }}
path: ${{ steps.pack.outputs.path }}

- name: Publish verified artifact
if: inputs.dry_run == false
run: npm publish "${{ steps.pack.outputs.path }}" --tag beta --provenance --access public
env:
NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }}

- name: Create GitHub Release
- name: Create GitHub prerelease
if: inputs.dry_run == false
uses: softprops/action-gh-release@v2
with:
tag_name: v${{ steps.version.outputs.version }}
target_commitish: ${{ github.sha }}
generate_release_notes: true
draft: ${{ github.event.inputs.dry_run == 'true' }}
prerelease: true
name: v${{ steps.version.outputs.version }}
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}

- name: Send Discord notification
if: ${{ github.event.inputs.dry_run != 'true' }}
if: inputs.dry_run == false
env:
DISCORD_WEBHOOK_URL: ${{ secrets.DISCORD_WEBHOOK_URL }}
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
Expand Down
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -25,7 +25,7 @@
"scripts": {
"build": "node scripts/clean.mjs && tsc -p tsconfig.json && node scripts/copy-runtime.mjs",
"test": "bun test/smoke.ts",
"test:v2": "bun test test/v2-*.test.ts test/node-runtime.test.ts test/model-normalizer.test.ts",
"test:v2": "bun test test/v2-*.test.ts test/bridge-pool.test.ts test/node-runtime.test.ts test/model-normalizer.test.ts test/shared-constants.test.ts",
"test:package": "node scripts/check-package.mjs",
"test:v2-loader": "node scripts/smoke-opencode-v2.mjs",
"typecheck": "tsc -p tsconfig.json --noEmit",
Expand Down
71 changes: 40 additions & 31 deletions src/bridge-pool.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@
*/
import { fileURLToPath } from "node:url";
import { resolveNodeExecutable } from "./node-runtime.js";
import { log } from "./shared/log.js";

const PERSISTENT_BRIDGE_PATH = fileURLToPath(
new URL("./h2-bridge-persistent.mjs", import.meta.url),
Expand Down Expand Up @@ -176,6 +177,13 @@ export interface BridgeAcquireOptions {
unary?: boolean;
}

export class BridgePoolCapacityError extends Error {
constructor(maxSize: number) {
super(`BridgePool capacity reached (${maxSize} active workers)`);
this.name = "BridgePoolCapacityError";
}
}

export class BridgePool {
private idle: PersistentWorker[] = [];
private allWorkers = new Set<PersistentWorker>();
Expand All @@ -184,15 +192,21 @@ export class BridgePool {
private shuttingDown = false;

constructor(options: BridgePoolOptions = {}) {
this.minSize = options.minSize ?? 2;
this.maxSize = options.maxSize ?? 4;
const minSize = options.minSize ?? 2;
const maxSize = options.maxSize ?? 4;
if (!Number.isSafeInteger(minSize) || minSize < 0) {
throw new Error(`BridgePool minSize must be a non-negative integer, got ${minSize}`);
}
if (!Number.isSafeInteger(maxSize) || maxSize < 1) {
throw new Error(`BridgePool maxSize must be a positive integer, got ${maxSize}`);
}
this.minSize = Math.min(minSize, maxSize);
this.maxSize = maxSize;
}

/** Pre-warm the pool with minSize idle workers. */
warmup(): void {
for (let i = 0; i < this.minSize; i++) {
this.addWorker();
}
this.replenish();
}

/** Acquire a bridge handle for a new request. */
Expand All @@ -217,9 +231,7 @@ export class BridgePool {
worker = this.addWorker();
this.idle.pop();
} else {
// Pool full — spawn an ephemeral worker not tracked by pool
worker = spawnWorker();
// Don't add to allWorkers — it won't be returned to pool
throw new BridgePoolCapacityError(this.maxSize);
}
}

Expand Down Expand Up @@ -305,7 +317,6 @@ export class BridgePool {
/** Exit code recorded when STREAM_DONE/process-death completes before callers attach onClose. */
let recordedExitCode = 0;
const pool = this;
const isPooled = this.allWorkers.has(worker);
/** Buffer OUTPUT_DATA until the caller registers onData (stdout can beat ReadableStream wiring). */
const pendingData: Buffer[] = [];
let userDataCb: ((chunk: Buffer) => void) | null = null;
Expand All @@ -318,23 +329,25 @@ export class BridgePool {

// When stream completes (bridge sends STREAM_DONE), fire onClose and return to pool
let closeCb: ((code: number) => void) | null = null;
const notifyClose = (callback: ((code: number) => void) | null, code: number) => {
if (!callback) return;
queueMicrotask(() => {
try {
callback(code);
} catch (error) {
log.error("[bridge-pool] onClose callback failed", error);
}
});
};

worker.cbs.streamDone = (code: number) => {
if (done) return;
done = true;
recordedExitCode = code;
const cbNow = closeCb;
closeCb = null;
cbNow?.(code);
if (isPooled) {
pool.release(worker);
} else {
// Ephemeral overflow worker (pool was saturated at acquire time): it is
// not tracked by the pool and will never be reused, so shut it down
// instead of leaking the child process.
workerSendShutdown(worker);
workerKill(worker);
}
pool.release(worker);
notifyClose(cbNow, code);
};

// Handle unexpected process death
Expand All @@ -344,12 +357,8 @@ export class BridgePool {
recordedExitCode = 1;
const cbNow = closeCb;
closeCb = null;
cbNow?.(1);
if (isPooled) {
pool.remove(worker);
} else {
workerKill(worker);
}
pool.remove(worker);
notifyClose(cbNow, 1);
};

return {
Expand All @@ -366,11 +375,11 @@ export class BridgePool {
kill() {
if (done) return;
done = true;
if (isPooled) {
pool.remove(worker);
} else {
workerKill(worker);
}
recordedExitCode = 1;
const cbNow = closeCb;
closeCb = null;
pool.remove(worker);
notifyClose(cbNow, 1);
},
onData(cb: (chunk: Buffer) => void) {
const flushed = pendingData.splice(0, pendingData.length);
Expand All @@ -379,7 +388,7 @@ export class BridgePool {
},
onClose(cb: (code: number) => void) {
if (done) {
queueMicrotask(() => cb(recordedExitCode));
notifyClose(cb, recordedExitCode);
} else {
closeCb = cb;
}
Expand Down
Loading
Loading