Skip to content
Merged
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
48 changes: 48 additions & 0 deletions .github/releases/v1.0.39.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,48 @@
## opencode {VERSION}

{Prerelease/Stable} release from `{branch}` branch. A follow-up hardening train closing the v1.0.38 review leftovers: a credential-leak dependency fix, the platform group-kill primitive converged to a single source, and two test-robustness deliveries (a load-tolerant stall bound, real-component cleanup coverage), synced from dev back to main.

---

### 🏗️ Architecture / Refactor

- **Platform group-kill converged into one primitive, #515**: the win32 `taskkill /T /F` versus negative-pid `process.kill` branching existed as diverging copies in `util/process.stop` and the hook `execShell` kill group, with different error handling. `killGroupPid` is now the single source (POSIX signals the group; win32 awaits the taskkill exit code and throws, leaving fallback and logging to callers), reused by both call sites with zero behavior change: same signals, ordering, and grace constants, and the #500 process-group regression suite with real grandchildren passes. The published SDK keeps its documented synchronous copy (#503).

---

### ⚙️ CI / Engineering

- **Memory stall assertion made load-tolerant, #512**: the dead-connection test bounded a 40ms connectTimeout with a 200ms wall-clock assertion and flaked at 288ms on a loaded linux runner (raw setTimeout; TestClock cannot apply). The bound is now 2000ms, still separating fail-fast from hang, with the Stalled error-class assertion as the semantic check.
- **TUI route cleanup pinned against the real components, #514**: the #502 seam test mirrored the production subscription shape, so removing an onCleanup wrapper from the real routes left it green. The real Session and Prompt components now mount inside the production provider stack, and the SDK event bus must return to its baseline listener count after unmount; both wrappers were negative-verified (removal turns the test red).

---

### 📦 Dependencies / Tooling

- **mysql2 3.14.4 to 3.22.0, #513**: closes the open high-severity dependabot advisory (alert 110) where an auth-plugin downgrade to mysql_clear_password leaks plaintext credentials.

---

### 🧪 Test Summary

```
unit tests (linux): 4447 tests, 4423 pass, 0 fail (364 files)
tui suites: 48 pass, 0 fail in test/cli/tui (incl. 4 real-component cleanup tests)
targeted suites: memory + process + hook 215 pass, 0 fail (linux and macos)
typecheck: 29/29 packages green
lint: 4839 warnings, 0 errors (ratchet budget 4850)
```

---

### 🔍 Verification

- Negative verification for #514: removing either production onCleanup wrapper turns the new real-component test red (Expected 0, Received 1); restoring turns it green, with an empty diff against production files afterwards.
- Zero-behavior-change argument for #515 walked per branch in the delivery commit: stop's win32 fallback path (taskkill failure to single-process kill) is preserved through the throw/catch reshape.
- The companion review finding on the workflow worker_type catalog's native-primary filter was adjudicated intentional (catalog lists spawn-resolvable worker shapes; native build/plan appear only because block defaults compile to them) and recorded on #507 without code change.
- Issues #512-#515 were closed manually with merge evidence: GitHub fires closing references only on PRs targeting the default branch, and this delivery merged through dev (PR #516) first.
- specgit finish exited 0 (accepted) for the dev delivery; this sync PR carries the same content to main under its own verdict.

---

**Full changelog:** [`{previous_tag}`...`{current_tag}`](https://github.com/LeXwDeX/OpenCode-GraphAgent/compare/{previous_tag}...{current_tag})
12 changes: 6 additions & 6 deletions .specgit.yaml
Original file line number Diff line number Diff line change
@@ -1,11 +1,11 @@
version: 1
delivery: release-notes-v1-0-38
delivery: sync-v1-0-39
context:
kind: branch
branch: docs/510-release-notes-v1-0-38
branch: chore/517-sync-v1-0-39
issues:
- 510
- 517
issueKinds:
- issue: 510
kind: kind::docs
pr: 511
- issue: 517
kind: kind::chore
pr: 518
8 changes: 3 additions & 5 deletions bun.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion packages/console/core/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -45,7 +45,7 @@
"@types/bun": "catalog:",
"@types/node": "catalog:",
"drizzle-kit": "catalog:",
"mysql2": "3.14.4",
"mysql2": "3.22.0",
"typescript": "catalog:",
"@typescript/native-preview": "catalog:"
}
Expand Down
14 changes: 3 additions & 11 deletions packages/opencode/src/hook/settings.ts
Original file line number Diff line number Diff line change
Expand Up @@ -51,6 +51,7 @@ import { generateObject, generateText, type ModelMessage } from "ai"
import { FSUtil } from "@opencode-ai/core/fs-util"
import { CrossSpawnSpawner } from "@opencode-ai/core/cross-spawn-spawner"
import * as Log from "@/util/log"
import { Process } from "@/util/process"
import { Global } from "@opencode-ai/core/global"
import { InstanceState } from "@/effect/instance-state"
import { MCP } from "@/mcp"
Expand Down Expand Up @@ -1143,18 +1144,9 @@ function execShell(
const killGroup = () => {
if (killSent || child.pid === undefined) return
killSent = true
if (process.platform === "win32") {
spawn("taskkill", ["/pid", String(child.pid), "/T", "/F"], { stdio: "ignore", windowsHide: true }).on(
"error",
(err) => log.warn("hook taskkill failed", { command, error: err.message }),
)
return
}
try {
process.kill(-child.pid, "SIGKILL")
} catch (err) {
void Process.killGroupPid(child.pid, "SIGKILL").catch((err) => {
log.warn("hook process-group kill failed", { command, error: String(err) })
}
})
}
const afterKill = () => {
killGroup()
Expand Down
25 changes: 19 additions & 6 deletions packages/opencode/src/util/process.ts
Original file line number Diff line number Diff line change
Expand Up @@ -150,6 +150,20 @@ export async function run(cmd: string[], opts: RunOptions = {}): Promise<Result>
export const STOP_TERM_GRACE_MS = 3_000
export const STOP_KILL_GRACE_MS = 2_000

// Platform group-kill primitive: POSIX signals the process group led by `pid`
// (the child must be a detached group leader); win32 has no group semantics,
// so `taskkill /T /F` tree-kills instead and `signal` is ignored. Resolves
// once the kill is delivered — on win32 that means awaiting the taskkill exit
// code — and throws when delivery fails, leaving fallback and logging to the
// caller.
export async function killGroupPid(pid: number, signal: NodeJS.Signals = "SIGKILL") {
if (process.platform !== "win32") {
process.kill(-pid, signal)
return
}
await run(["taskkill", "/pid", String(pid), "/T", "/F"])
}

// Duplicated in `packages/sdk/js/src/process.ts` because the SDK cannot import
// `opencode` without creating a cycle. Keep both copies in sync.
export async function stop(proc: ChildProcess) {
Expand All @@ -163,12 +177,11 @@ export async function stop(proc: ChildProcess) {
return
}

const out = await run(["taskkill", "/pid", String(proc.pid), "/T", "/F"], {
nothrow: true,
})

if (out.code === 0) return
proc.kill()
try {
await killGroupPid(proc.pid)
} catch {
proc.kill()
}
}

function exitedWithin(proc: ChildProcess, timeoutMs: number) {
Expand Down
6 changes: 5 additions & 1 deletion packages/opencode/test/memory/memory.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1716,7 +1716,11 @@ describe("memory hidden model", () => {
catch: (cause) => cause,
}).pipe(Effect.flip)
expect(error instanceof MemoryModel.Stalled).toBe(true)
expect(Date.now() - started).toBeLessThan(200)
// Fail-fast bound, not a scheduler bound: drainWithLiveness arms raw
// setTimeout (no TestClock), and a loaded linux runner was observed
// firing the 40ms connectTimeout at 288ms. 2000ms still separates
// fail-fast from hang (a hang trips the test timeout instead).
expect(Date.now() - started).toBeLessThan(2000)
}),
)

Expand Down
Loading
Loading