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
6 changes: 3 additions & 3 deletions .github/release-state.json
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
{
"pullRequest": 7,
"sourceSHA": "0607dc761f2bff2bafca5c820f1427109cc13fde",
"version": "0.6.3"
"pullRequest": 9,
"sourceSHA": "a5804b907dc37d653bb9b339300dc3865159ec84",
"version": "0.6.4"
}
13 changes: 13 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,19 @@ include the full version, for example `## 0.7.0-beta.1`.

## Unreleased

## 0.6.4

### Fixed

- Prevent hourly owner eviction using durable activity on one empty maintenance
session. The previous plugin-list heartbeat did not refresh OpenCode's session
inactivity timer. Keepalive never prompts a model or modifies a work session.
- Bound SDK waits independently of adapters that ignore AbortSignal, allowing
shutdown to release locks without interrupting a healthy worktree session.
A replacement dispatcher reconciles the saved session before publication.
- Wait briefly for a retiring owner's locks, bound RPC disposal, and reject
queued checkpoint writes after shutdown to prevent failed plugin reloads.

## 0.6.3

### Fixed
Expand Down
6 changes: 3 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -26,12 +26,12 @@ Nothing needs to be published to npm. `$HOME` expands to your home directory.
Run this command on the machine running OpenCode 2:

<!-- latest-release:start -->
Latest stable release: **[v0.6.3](https://github.com/d3cker/opencode2-github-automation/releases/tag/v0.6.3)**.
Latest stable release: **[v0.6.4](https://github.com/d3cker/opencode2-github-automation/releases/tag/v0.6.4)**.

[Download the .tgz package](https://github.com/d3cker/opencode2-github-automation/releases/download/v0.6.3/opencode2-automation-0.6.3.tgz) · [SHA-256 checksum](https://github.com/d3cker/opencode2-github-automation/releases/download/v0.6.3/opencode2-automation-0.6.3.tgz.sha256)
[Download the .tgz package](https://github.com/d3cker/opencode2-github-automation/releases/download/v0.6.4/opencode2-automation-0.6.4.tgz) · [SHA-256 checksum](https://github.com/d3cker/opencode2-github-automation/releases/download/v0.6.4/opencode2-automation-0.6.4.tgz.sha256)

```bash
npm install --global --prefix "$HOME/.local" "https://github.com/d3cker/opencode2-github-automation/releases/download/v0.6.3/opencode2-automation-0.6.3.tgz"
npm install --global --prefix "$HOME/.local" "https://github.com/d3cker/opencode2-github-automation/releases/download/v0.6.4/opencode2-automation-0.6.4.tgz"
```
<!-- latest-release:end -->

Expand Down
30 changes: 17 additions & 13 deletions docs/advanced.md
Original file line number Diff line number Diff line change
Expand Up @@ -101,19 +101,23 @@ uncertain network results. Uncertain initial prompt delivery is not automaticall
resent. Issue replies and helper prompts use deterministic IDs for admission retries.
Merge requests pin the verified head SHA and reconcile an already-merged PR.

The shared service can evict an idle owner location even while an agent works in
a different worktree. Scheduler and dispatcher components therefore refresh the
owner every 30 seconds through the public plugin-list API, after confirming the
service PID matches their own process. Transient heartbeat errors are logged and
retried; requests do not overlap. A standalone server without a matching service
registration skips this mechanism. Keep its owner location in use or use the
shared background service for unattended automation.

Cleanup aborts and settles work before releasing ownership. All cleanup steps
are attempted even when RPC disposal fails; locks are not forcibly removed or
stolen from another owner. A held-lock startup error should be investigated via
plugin details and server logs. Back up the queue and worktree before recovery;
do not assume a failed session means its edits were lost.
The shared service evicts owner locations after roughly an hour without durable
session activity, even if plugin RPC or HTTP requests continue. Components use one
deterministically identified, empty maintenance session per owner directory and
rename it at startup and every ten minutes. Creation is idempotent, metadata and
location are checked before renaming, and no model is prompted. Requests have a
15-second deadline, never overlap, and require a matching service PID. Standalone
servers without a matching service registration skip this mechanism; use the
shared service for unattended automation.

SDK adapters may ignore AbortSignal. The plugin therefore bounds its own SDK waits,
preserves healthy worker execution on owner disposal, and settles local state writes
before releasing ownership. A replacement waits up to 15 seconds for the retiring
owner's locks. RPC disposal has a five-second deadline per component; cleanup still
attempts every remaining step. No live lock is forcibly removed. A held-lock startup
error should be investigated via plugin details and server logs. Back up the queue,
worktree, and session database before recovery. Reconcile an already-published PR
and saved session instead of restarting implementation or deleting the worktree.

Only one issue executes at a time. Checks must succeed before publication. Push
uses the exact verified commit without force. Worktrees remain available for
Expand Down
14 changes: 8 additions & 6 deletions docs/architecture.md
Original file line number Diff line number Diff line change
Expand Up @@ -42,12 +42,14 @@ The primary checkout owns scheduling. Worker worktrees do not start additional
schedulers. A shared Git-directory state folder holds the queue and locks; separate
machines require separate test repositories to avoid duplicate execution.

In the shared background service, each scheduler/dispatcher component sends a
periodic request back to its owner location to prevent idle eviction while work
runs elsewhere. It checks the service PID before touching the location, so a
standalone instance cannot activate another owner in a different service.
Shutdown settles work and attempts all cleanup steps, including lock release,
even if an SDK registration fails to dispose after location eviction.
In the shared background service, each scheduler/dispatcher component renews one
empty maintenance session in the owner location at startup and every ten minutes.
OpenCode counts durable session events as activity; polling plugin APIs alone
does not prevent its hourly inactivity eviction. No model is prompted by keepalive.
The service PID must match the plugin process before any maintenance session is
created. Shutdown releases local SDK waits independently of adapter cancellation,
settles state writes, bounds RPC disposal, and releases locks. A healthy worktree
session continues and the replacement owner reconciles its saved identity.

See [advanced configuration](advanced.md) for retry commands, limits, and RPC
settings, and the [README](../README.md) for installation and user-facing behavior.
22 changes: 12 additions & 10 deletions docs/bot-workflow.md
Original file line number Diff line number Diff line change
Expand Up @@ -35,20 +35,22 @@ flowchart TD
cancel accepted work or active sessions.
- A scan cannot overlap another scan in the same dispatcher. Only one worker
invocation runs at a time; one scheduler job cannot overlap itself.
- Each component refreshes the owner through the matching background service's
plugin-list API every 30 seconds. A PID check prevents activating a second
owner in another service. Requests do not overlap and have a 15-second deadline.
This heartbeat keeps the owner loaded while execution happens in worktrees;
pausing issue scans does not pause it. Standalone servers without a matching
registered service skip the refresh.
- Each component renews one empty owner maintenance session at startup and every
ten minutes. Durable session events refresh OpenCode's inactivity timer; listing
plugins does not. No model is prompted. A PID check prevents touching a different
service. Requests do not overlap and have a 15-second deadline. Pausing issue
scans does not pause keepalive. Standalone servers without a matching registered
service skip it.
- State is schema-validated and saved through a temporary file, file sync, and
rename. A corrupt state file fails to load rather than resetting the queue.
Local locks prevent duplicate owners sharing this state directory; independent
machines do not share ownership.
- Shutdown clears timers, aborts operations, waits for in-flight work, disposes
registrations, and releases locks. Cleanup attempts the remaining steps even
if an earlier step fails, so an evicted RPC registration cannot skip lock
release. Executor shutdown interrupts its task session.
- Shutdown clears timers and aborts local SDK waits even if the adapter ignores
cancellation. It settles local writes, bounds RPC disposal to five seconds per
component, and attempts all cleanup steps before releasing ownership. A replacement
waits up to 15 seconds for locks without removing them. Healthy worktree sessions
continue; the next owner reconciles the saved session before verifying and publishing.


Sources: [index.ts](../src/index.ts), [easy.ts](../src/easy.ts),
[GitHub plugin](../src/plugins/github.ts),
Expand Down
17 changes: 12 additions & 5 deletions docs/runtime.md
Original file line number Diff line number Diff line change
Expand Up @@ -46,11 +46,18 @@ After restarting the service, load the owner project again to resume polling.
No terminal UI is needed to answer in GitHub.

The automation owner must remain loaded while its worktree sessions run. In the
shared background service, the plugin refreshes the owner location every 30
seconds, independently of issue polling and its pause setting. This prevents
idle owner eviction from disconnecting worker hooks and publication. The refresh
only targets a service whose PID matches the plugin process; standalone servers
without a matching registered service do not receive this heartbeat.
shared background service, keepalive runs at startup and every ten minutes,
independently of issue polling and its pause setting. It reuses one empty session
named `Automation owner keepalive` in the primary checkout. The session has no
messages, consumes no model tokens, and is not an issue task. Its durable rename
event refreshes OpenCode's activity timer; requests to list plugins do not.
Keepalive only targets a service whose PID matches the plugin process. Standalone
servers without a matching registered service do not receive this protection.

On owner reload, the dispatcher stops waiting locally and releases its lock without
interrupting a healthy worker. The next owner reconnects to the saved session ID,
checks its outcome, verifies the changes, and resumes publication. Explicit task
cancellation, unanswered questions, and session deadlines still interrupt workers.

If the plugin reports a held lock or a worker reports unavailable automation RPC,
inspect plugin details, logs, and queue state before retrying. Preserve the
Expand Down
4 changes: 2 additions & 2 deletions package-lock.json

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

2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "opencode2-automation",
"version": "0.6.3",
"version": "0.6.4",
"description": "Issue-to-PR automation for OpenCode 2 with a scheduler and GitHub dispatcher",
"main": "./dist/index.js",
"files": [
Expand Down
1 change: 1 addition & 0 deletions src/dispatcher.ts
Original file line number Diff line number Diff line change
Expand Up @@ -77,6 +77,7 @@ export class Dispatcher {
this.signal.throwIfAborted();
let announce = false;
await this.serial.run(async () => {
this.signal.throwIfAborted();
announce = Boolean(patch.sessionReady && !task.sessionReady) || Boolean(patch.status && patch.status !== task.status && ["done", "blocked", "failed", "waiting"].includes(patch.status));
announce ||= patch.pr?.state === "closed" && task.pr?.state !== "closed";
if (patch.sessionID) task.sessionIDs = [...new Set([...task.sessionIDs ?? [], ...[task.previousSessionID, task.sessionID, patch.sessionID].filter((id): id is string => Boolean(id))])];
Expand Down
10 changes: 5 additions & 5 deletions src/executor.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import { analysisDecision } from "./analysis.js";
import { baseChoice, type BranchInput } from "./branch.js";
import { installWorkerPlugin } from "./worker.js";
import { Blocked, WaitingForAnswer, type Executor, type Task } from "./dispatcher.js";
import { cancellable } from "./lifecycle.js";

export type CommandRunner = (cwd: string, argv: string[]) => Promise<string>;
export function commandRunner(signal: AbortSignal, timeout: number, secretEnv: string): CommandRunner {
Expand Down Expand Up @@ -104,6 +105,7 @@ export class GitWorkspace {
export class OpenCodeExecutor implements Executor {
private git: GitWorkspace;
constructor(private ctx: Plugin.Context, private options: GithubOptions, private signal: AbortSignal, private runtimeInstaller = installWorkerPlugin) {
this.ctx = { ...ctx, ...(ctx.session ? { session: cancellable(ctx.session) } : {}), ...(ctx.generate ? { generate: cancellable(ctx.generate) } : {}) };
this.git = new GitWorkspace(options.stateDirectory, commandRunner(signal, options.commandTimeoutSeconds * 1000, options.tokenEnv));
}
async title(task: Task) {
Expand Down Expand Up @@ -156,11 +158,9 @@ export class OpenCodeExecutor implements Executor {
return this.runtimeInstaller(directory, this.options, commandRunner(this.signal, this.options.commandTimeoutSeconds * 1000, this.options.tokenEnv));
}
async run(task: Task, checkpoint: (patch: Partial<Task>) => Promise<void>) {
try { await this.runSession(task, checkpoint); }
catch (error) {
if (this.signal.aborted) await this.cancel(task);
throw error;
}
// Owner disposal must not interrupt a healthy worker in another location.
// The replacement owner resumes waiting on the saved session ID.
await this.runSession(task, checkpoint);
}
private async runSession(task: Task, checkpoint: (patch: Partial<Task>) => Promise<void>) {
if (!task.worktree || !task.route) throw new Blocked("Missing execution configuration");
Expand Down
49 changes: 44 additions & 5 deletions src/lifecycle.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import { OpenCode } from "@opencode/client";
import { Service } from "@opencode/client/service";
import { createHash } from "node:crypto";

// Cleanup must reach the lock release even when an evicted SDK scope can no
// longer dispose its RPC registration. Keep the lock until work has settled.
Expand All @@ -13,7 +14,10 @@ export async function cleanup(...steps: (() => void | Promise<void>)[]) {

export interface OwnerClient {
health: { get(options: { signal: AbortSignal }): Promise<{ pid: number }> };
plugin: { list(input: { location: { directory: string } }, options: { signal: AbortSignal }): Promise<unknown> };
session: {
create(input: { id: string; title: string; location: { directory: string }; metadata: Record<string, string> }, options: { signal: AbortSignal }): Promise<{ id: string; location: { directory: string }; metadata?: Record<string, unknown> }>;
rename(input: { sessionID: string; title: string }, options: { signal: AbortSignal }): Promise<unknown>;
};
}

export async function touchOwner(directory: string, signal: AbortSignal, connect: () => Promise<OwnerClient | undefined> = async () => {
Expand All @@ -25,22 +29,57 @@ export async function touchOwner(directory: string, signal: AbortSignal, connect
// A standalone server must never activate a second owner in a different
// background service. The public request must return to this exact process.
if ((await client.health.get({ signal })).pid !== process.pid) return false;
await client.plugin.list({ location: { directory } }, { signal });
// OpenCode's inactivity sweep observes durable session events, NOT HTTP
// requests or plugin RPC. Reuse one empty maintenance session; never prompt
// a model, touch a user's session, or create a new session on every tick.
const id = `ses_${createHash("sha256").update(`opencode2-automation-owner:${directory}`).digest("hex").slice(0, 32)}`;
const title = "Automation owner keepalive";
const session = await client.session.create({ id, title, location: { directory }, metadata: { automation: "owner-keepalive" } }, { signal });
if (session.location.directory !== directory || session.metadata?.automation !== "owner-keepalive") throw new Error("Automation keepalive session identity mismatch");
// Rename emits Session.Renamed even when the title is unchanged.
await client.session.rename({ sessionID: session.id, title }, { signal });
return true;
}

export function heartbeat(touch: (signal: AbortSignal) => Promise<unknown>, report: (error: unknown) => void, everyMs = 30_000) {
export function heartbeat(touch: (signal: AbortSignal) => Promise<unknown>, report: (error: unknown) => void, everyMs = 10 * 60_000) {
const controller = new AbortController();
let active: Promise<void> | undefined;
const timer = setInterval(() => {
const tick = () => {
if (active || controller.signal.aborted) return;
active = Promise.resolve().then(() => touch(AbortSignal.any([controller.signal, AbortSignal.timeout(15_000)])))
.then(() => {}, error => { if (!controller.signal.aborted) report(error); })
.finally(() => { active = undefined; });
}, everyMs);
};
const timer = setInterval(tick, everyMs);
tick();
return async () => {
clearInterval(timer);
controller.abort();
await active;
};
}

// Some OpenCode Promise plugin adapters ignore the request's AbortSignal.
// Bound our wait independently. Late SDK results are observed but cannot resume
// the retired dispatcher; durable session identity allows the next owner to
// reconcile work that continues in the server.
export function abortable<T>(action: () => Promise<T>, signal?: AbortSignal): Promise<T> {
if (!signal) return action();
return new Promise<T>((resolve, reject) => {
if (signal.aborted) { reject(signal.reason); return; }
const abort = () => reject(signal.reason);
signal.addEventListener("abort", abort, { once: true });
Promise.resolve().then(() => { signal.throwIfAborted(); return action(); })
.then(resolve, reject).finally(() => signal.removeEventListener("abort", abort));
});
}

export function cancellable<T extends object>(api: T): T {
return new Proxy(api, {
get(target, property, receiver) {
const method: unknown = Reflect.get(target, property, receiver);
if (typeof method !== "function") return method;
return (input: unknown, request?: { signal?: AbortSignal }) => abortable(() => Reflect.apply(method, target, [input, request]), request?.signal);
},
});
}
6 changes: 3 additions & 3 deletions src/plugins/github.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@ import { GithubRpc } from "../rpc.js";
import { acquire, JsonStore, redact } from "../state.js";
import { githubToken } from "../easy.js";
import type { Activity } from "../activity.js";
import { cleanup, heartbeat, touchOwner } from "../lifecycle.js";
import { abortable, cleanup, heartbeat, touchOwner } from "../lifecycle.js";

export default Plugin.define({
id: "automation.github",
Expand All @@ -19,7 +19,7 @@ export default Plugin.define({
if (await realpath(ctx.location.directory) !== await realpath(options.ownerDirectory)) return;
const token = await githubToken(options.tokenEnv);
const controller = new AbortController();
const release = await acquire(options.stateDirectory, "github", error => controller.abort(error));
const release = await acquire(options.stateDirectory, "github", error => controller.abort(error), true);
const executor = new OpenCodeExecutor(ctx, options, controller.signal);
let publish: (activity: Activity) => Promise<void> = async () => {};
const dispatcher = new Dispatcher(options, new JsonStore(join(options.stateDirectory, "queue.json"), Queue, () => ({ version: 1, tasks: [] })), new Github(token, controller.signal, fetch, options.signature), executor, controller.signal, [token], Date.now, activity => publish(activity));
Expand All @@ -31,7 +31,7 @@ export default Plugin.define({
() => { clearInterval(timer); controller.abort(); },
() => stopHeartbeat?.(),
() => dispatcher.settle(),
() => registration?.dispose(),
() => abortable(async () => { await registration?.dispose(); }, AbortSignal.timeout(5_000)),
() => releaseBridge?.(),
release,
);
Expand Down
Loading