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
24 changes: 23 additions & 1 deletion packages/core/src/background-job.ts
Original file line number Diff line number Diff line change
Expand Up @@ -97,6 +97,19 @@ export interface Interface {

export class Service extends Context.Service<Service, Interface>()("@opencode/BackgroundJob") {}

/**
* The registry's own (instance-lifetime) scope, made available to each job's
* `run` effect. A job's scope is a CHILD fork of this (see `start`), and
* `cancel` closes that child — so anything that must OUTLIVE a single job's
* cancel (notably a model wake that is driving the very turn issuing the cancel,
* i.e. monitor re-arm) must fork into THIS scope, not the job scope, or it
* self-cancels and deadlocks. Defaults to `undefined` so a `run` invoked outside
* `start` simply falls back to its own job scope.
*/
export const WakeScope = Context.Reference<Scope.Scope | undefined>("@opencode/BackgroundJob/WakeScope", {
defaultValue: () => undefined,
})

function snapshot(job: Active): Info {
return {
...job.info,
Expand Down Expand Up @@ -245,7 +258,13 @@ export const make = Effect.gen(function* () {
id,
result.token,
0,
restore(input.run).pipe(Effect.ensuring(Deferred.succeed(tail, undefined))),
// Expose the registry scope to `run` as WakeScope: long-lived wakes
// (model re-arm turns) fork into it instead of the job scope, so a
// cancel of THIS job can't interrupt the turn that issued the cancel.
restore(input.run).pipe(
Effect.provideService(WakeScope, state.scope),
Effect.ensuring(Deferred.succeed(tail, undefined)),
),
)
return result.info
}),
Expand Down Expand Up @@ -280,6 +299,9 @@ export const make = Effect.gen(function* () {
result.sequence,
Deferred.await(result.previous).pipe(
Effect.andThen(restore(input.run)),
// Same as start(): an extended run forks its wakes into the registry scope,
// not the job scope, so it keeps the self-cancel protection.
Effect.provideService(WakeScope, state.scope),
Effect.ensuring(Deferred.succeed(result.tail, undefined)),
),
)
Expand Down
58 changes: 58 additions & 0 deletions packages/core/src/background-monitor.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,58 @@
export * as BackgroundMonitor from "./background-monitor"

// Process-local liveness tracking for the Monitor tool. The monitored process
// itself runs as a BackgroundJob entry; this module only tracks "is a monitor
// still running for this session" + its child PID, as PLAIN functions (no Effect
// service) so the non-Effect CLI loop in cli/cmd/run.ts can read the count / kill
// the PIDs SYNCHRONOUSLY — to defer process exit while a monitor is live and to
// clean up on SIGINT.

const _sessionCounts = new Map<string, number>()
const _sessionPids = new Map<string, Array<number>>()

/** Number of live monitor jobs for a session. */
export function getMonitorCount(sessionID: string): number {
return _sessionCounts.get(sessionID) ?? 0
}

/** Bump the live count for a session (call when a job is armed). */
export function monitorStarted(sessionID: string): void {
_sessionCounts.set(sessionID, (_sessionCounts.get(sessionID) ?? 0) + 1)
}

/** Drop the live count (and forget the PID) when a job ends. */
export function monitorStopped(sessionID: string, pid?: number): void {
const next = (_sessionCounts.get(sessionID) ?? 0) - 1
if (next <= 0) _sessionCounts.delete(sessionID)
else _sessionCounts.set(sessionID, next)
if (pid !== undefined) removePid(sessionID, pid)
}

/** Track a child PID so SIGINT can kill it synchronously. */
export function monitorPid(sessionID: string, pid: number): void {
const list = _sessionPids.get(sessionID) ?? []
list.push(pid)
_sessionPids.set(sessionID, list)
}

function removePid(sessionID: string, pid: number): void {
const list = _sessionPids.get(sessionID)
if (!list) return
const filtered = list.filter((p) => p !== pid)
if (filtered.length === 0) _sessionPids.delete(sessionID)
else _sessionPids.set(sessionID, filtered)
}

/** SIGTERM every tracked PID for a session (CLI SIGINT / shutdown path). */
export function stopAllForSessionSync(sessionID: string): void {
const pids = _sessionPids.get(sessionID)
if (!pids) return
for (const pid of pids) {
try {
process.kill(pid, "SIGTERM")
} catch {
// already-exited or permission errors are fine
}
}
_sessionPids.delete(sessionID)
}
14 changes: 13 additions & 1 deletion packages/opencode/src/cli/cmd/run.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import type { PermissionV1 } from "@opencode-ai/core/v1/permission"
import { FSUtil } from "@opencode-ai/core/fs-util"
import { getMonitorCount, stopAllForSessionSync } from "@opencode-ai/core/background-monitor"
// CLI entry point for `opencode run` and `opencode --mini`.
//
// Handles three modes:
Expand Down Expand Up @@ -773,7 +774,8 @@ export const RunCommand = effectCmd({
event.properties.sessionID === sessionID &&
event.properties.status.type === "idle"
) {
break
if (getMonitorCount(sessionID) === 0) break
continue
}

if (event.type === "permission.asked") {
Expand Down Expand Up @@ -814,10 +816,20 @@ export const RunCommand = effectCmd({
console.error(e)
process.exitCode = 1
})
process.on("SIGINT", () => {
stopAllForSessionSync(sessionID)
process.exit(1)
})

async function finish() {
if (args.attach) return
const error = await completed
if (error) process.exitCode = 1

// Keep the process alive while background monitors are active.
while (getMonitorCount(sessionID) > 0) {
await new Promise((resolve) => setTimeout(resolve, 100))
}
}

if (args.command) {
Expand Down
1 change: 1 addition & 0 deletions packages/opencode/src/effect/runtime-flags.ts
Original file line number Diff line number Diff line change
Expand Up @@ -52,6 +52,7 @@ export class Service extends ConfigService.Service<Service>()("@opencode/Runtime
bashDefaultTimeoutMs: positiveInteger("OPENCODE_EXPERIMENTAL_BASH_DEFAULT_TIMEOUT_MS"),
experimentalNativeLlm: bool("OPENCODE_EXPERIMENTAL_NATIVE_LLM"),
experimentalWebSockets: bool("OPENCODE_EXPERIMENTAL_WEBSOCKETS"),
experimentalMonitor: enabledByExperimental("OPENCODE_EXPERIMENTAL_MONITOR"),
client: Config.string("OPENCODE_CLIENT").pipe(Config.withDefault("cli")),
}) {}

Expand Down
Loading
Loading