Skip to content

feat: full OpenCode V2 adaptation (beta-19425 contract, compaction context, restart task recovery) - #49

Merged
danyel117 merged 15 commits into
prevalentWare:mainfrom
GoldJohnKing:feat/v2-adaptation
Sep 14, 2026
Merged

danyel117 merged 15 commits into
prevalentWare:mainfrom
GoldJohnKing:feat/v2-adaptation

Conversation

@GoldJohnKing

Copy link
Copy Markdown
Contributor

Summary

Full OpenCode V2 adaptation. The package now targets the current published V2 contract (@opencode/plugin@0.0.0-beta-19425, the @opencode/* npm scope) and closes the remaining V2 gaps: goal context is preserved across V2 session compaction, Task-subagent deferral state is rebuilt from persisted transcripts after a plugin/server restart, the legacy session.error branch is removed, TUI runtime dependencies are shared with the host via peerDependencies, and both CI and publish are gated on a real V2 lifecycle smoke test. V1 support is unchanged.

Related issue

None open. This closes the gap analysis against the current V2 beta: pinned SDK contract, missing V2 compaction context, missing V2 restart task recovery, stale session.error handling, non-conforming TUI packaging, and absent V2 CI verification.

Changes

  • chore Migrate the V2 development contract to @opencode/plugin@0.0.0-beta-19425 (new @opencode/* scope for plugin/schema/client/theme; V1 runtime dep untouched).
  • feat Preserve goal context across V2 session compaction: registers the session.compaction hook and injects the goal snapshot (compactionContext) into the request system parts, idempotently. Because SessionHooks.compaction landed upstream only after beta-19425 (opencode PR #48212), the registration is defensive — a no-op on beta-19425 hosts, active on newer hosts.
  • feat Rebuild task-deferral state after restart: TaskTracker.recoverFromTranscript replays each non-closed goal session's persisted transcript (finalized task/subagent tool entries) at plugin startup, compensating for the V2 plugin context having no live child-session query.
  • refactor Drop the V2 session.error case; session.execution.failed is the only terminal-failure event in the current public manifest. The native-retry interplay test was rewritten to current semantics.
  • fix Move @opentui/solid and solid-js to peerDependencies (host instances are shared; no duplicate Solid/OpenTUI runtimes), keep dev copies; engines.opencode now covers 0.0.0-beta-* hosts.
  • ci Add a V2 Lifecycle Smoke job to ci.yml and gate publish on it (@opencode/cli@beta + bun run smoke:v2).
  • docs README/AGENTS.md sync: pinned beta version, compaction + transcript-recovery support claims, remove the outdated "V1-only" statement.
  • test New V2 tests: compaction hook (injection, idempotency, no-goal no-op) and transcript-based restart recovery (deferral rebuilt from a running child in history; control continues without deferral). Extended the pre-existing flaky watchdog test's waitFor deadlines (it also fails on main, ~1/6 in isolation on slow filesystems).

Verification

Local gate (all fresh runs on this branch):

bun run lint        # 0 problems
bun run typecheck   # tsc --noEmit, clean
bun run test        # 264 pass, 0 fail  (watchdog flake documented above; fails on main too)
bun run build       # dist/server.js 156.76 KB
bun run pack:dry-run# 5 files, 45.4 kB

Real V2 lifecycle smokebun run smoke:v2 against a private opencode2 v0.0.0-beta-19425 server with an isolated home/config/db/state and a deterministic local fixture model (no provider credentials):

{
  "result": "PASS",
  "sessionID": "ses_f754c0b91ffevDUswBtoBDXJxC",
  "modelCalls": 6,
  "continuationCalls": 3,
  "status": "complete",
  "autoTurns": 2
}

A second loaded location was activated during the run and did not duplicate continuation delivery.

E2E transcript (exported from the smoke run's durable session history) — timeline of ses_f754c0b91ffevDUswBtoBDXJxC:

# Role Content
0 user V2 command executor rewrote /goal into the goal-mode prompt: <goal_command_arguments>Create a goal for the fixture milestone. Keep it active until the automatic continuation arrives.</goal_command_arguments>
1 assistant create_goal(completed)goal.status: "active", objective Verify native V2 goal continuation with the local fixture model
2 assistant text milestone (turn 1 ends; session.execution.succeeded)
3 user plugin auto-continuation #1: Continue working toward the active session goal. + objective + budget block
4 assistant text milestone (turn 2 ends; session.execution.succeeded)
5 user plugin auto-continuation #2: same continuation prompt, updated budgets
6 assistant update_goal(completed)goal.status: "complete" with evidence
7 assistant closing text

Final persisted goal state:

{
  "ses_f754c0b91ffevDUswBtoBDXJxC": {
    "status": "complete",
    "autoTurns": 2,
    "tokensUsed": 1000,
    "timeUsedSeconds": 4,
    "completionEvidence": "A native V2 execution settled and the plugin automatically sent the next goal prompt."
  }
}
Raw tool outputs from the exported transcript

create_goal output:

{
  "goal": {
    "sessionID": "ses_f754c0b91ffevDUswBtoBDXJxC",
    "status": "active",
    "objective": "Verify native V2 goal continuation with the local fixture model",
    "maxAutoTurns": 3,
    ...
  }
}

update_goal output:

{
  "goal": {
    "status": "complete",
    "completionEvidence": "A native V2 execution settled and the plugin automatically sent the next goal prompt.",
    "autoTurns": 2
  },
  "completion_report": "Goal achieved. Time used: 4 seconds. Token usage: 1000/200000. Evidence: A native V2 execution settled and the plugin automatically sent the next goal prompt."
}

Package entrypoint resolution — the packed tarball resolves through the host's own Host.resolve (@opencode/plugin/host), confirming the exports map loads correctly without a root . export:

{
  "server": "…/node_modules/@prevalentware/opencode-goal-plugin/dist/server.js",
  "tui": "…/node_modules/@prevalentware/opencode-goal-plugin/src/tui.ts"
}

Default export keys after Host.load(...): [ "id", "server", "setup" ] (dual V1/V2 shape).

Checklist

  • bun run test passes (new behavior has regression coverage)
  • bun run lint passes
  • bun run typecheck passes
  • bun run build passes and dist/server.js is committed if server code changed
  • README/docs updated if behavior or options changed

@danyel117 danyel117 left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks for the thorough V2 work. The documented local gate and the real beta-19425 lifecycle smoke both pass here, but restart recovery still has two reproducible correctness gaps that can violate task deferral:

  1. Recovery races the first lifecycle event. recoverTrackedTasks() is launched with void after the event consumer starts, so session.execution.succeeded/session.idle can reach runAutoContinue() before session.context() returns. I reproduced this by deferring context(), emitting session.execution.succeeded, and then resolving a transcript containing task_id: T_race / state: running; one continuation had already been sent. Please await recovery before events can continue goals, or gate each session's continuation on its recovery promise. Add a regression where the settled event arrives while transcript loading is still pending.

  2. Historical terminal tasks are never reconciled by later transcript messages. recoverFromTranscript() replays task/subagent tool entries, but it never replays assistant markers. A completed task is therefore restored as terminalUnreconciled at the current time even when a later assistant message already integrated its result, blocking continuation until the task-block ceiling. I reproduced this with a completed task entry followed by a later assistant message; the expected continuation never arrived. Please replay transcript messages in order (including assistant markers) and cover completed/failed task output followed by a later assistant turn, while retaining the running-task case.

There is also a compatibility-metadata issue: changing engines.opencode from >=1.17.1 to >=0.0.0-beta-0 accepts stable releases 0.0.1 through 1.17.0, despite the PR stating that V1 support is unchanged. Please use a disjoint range that preserves the V1 floor while including the intended 0.0.0-beta-* builds (for example, >=0.0.0-beta-0 <0.0.0 || >=1.17.1) and add/adjust the package assertion.

Validation performed on f2af84d: bun run lint, bun run typecheck, all 264 existing tests, bun run build, bun run pack:dry-run, and bun run smoke:v2 against opencode2 v0.0.0-beta-19425 all passed. The two targeted negative regressions above both failed, confirming the recovery defects.

@danyel117

Copy link
Copy Markdown
Contributor

CI follow-up: the first approved run of the new V2 Lifecycle Smoke timed out after 120s with modelCalls=7 and continuationCalls=3; rerunning the failed job passed in 24s with the same commit. The rest of the matrix passed both times. This is therefore a demonstrated flake in the new release gate, not a source/test failure. Please make the timeout path preserve enough state to identify which final condition is stuck (and ideally upload server.log plus the smoke artifacts on failure) so a future publish is not blocked by an opaque 120-second timeout. This is separate from the two restart-recovery blockers in the review.

@GoldJohnKing

Copy link
Copy Markdown
Contributor Author

All four items were verified against the code and reproduced before fixing. Pushed as f2af84d..366e02f (3 commits).

1. Recovery races the first lifecycle event — verified: recoverTrackedTasks() was launched with void after the event consumer started, so session.execution.succeeded could reach runAutoContinue() before session.context() resolved; hasBlockingTasks() saw no records and a continuation was delivered while the transcript still contained a running child. Fixed in 70fa53c: the recovery promise (taskRecoveryComplete, .catch-guarded so it can never reject — the per-item try/catch did not cover a getAllGoals() rejection) is now created before the consumer starts, and both continuation decision paths (runAutoContinue and the turn watchdog) await it before consulting task state, re-checking the disposed/stopped/busy guards after the await. Regression V2 defers continuation until transcript recovery completes when a settled event races the recovery pushes the settled event while the transcript promise is still pending, then resolves it with task_id: T_race / state: running — no continuation is sent and the task-block re-arm fires. Confirmed the test fails on the pre-fix code.

2. Historical terminal tasks never reconciled — verified: recoverFromTranscript() replayed only tool entries, so a terminal task came back terminalUnreconciled with terminalAt = Date.now() and an empty assistant-marker table; neither clause of assistantReconcilesTask() could fire for historical messages, deferring until the max_task_block_seconds ceiling. Fixed in 70fa53c: the replay now processes messages in order — each assistant message's marker is observed first (a terminal task is stamped with its own message's marker, mirroring live V2 semantics where the marker is observed at session.step.started), then its tool entries are replayed with the message's historical time.completed as terminalAt. A terminal child is therefore reconciled by any later assistant turn in the transcript, and both the reconciliation timestamp clause and the block ceiling measure from the historical terminal moment. runningSince intentionally stays at restart time so an old-but-alive child cannot expire the ceiling immediately after a restart. Regressions V2 reconciles a transcript-terminal task via a later assistant message and continues (completed) and the failed (state: error) variant both fail on the pre-fix code; the original running-task recovery test is retained unchanged.

3. engines.opencode range — verified: >=0.0.0-beta-0 admitted stable 0.0.1–1.17.0. Changed to the disjoint range >=0.0.0-beta-0 <0.0.0 || >=1.17.1 in ff684f2, with a package assertion using satisfies from semver (added as a devDependency, previously only transitive): covers 0.0.0-beta-0, 0.0.0-beta-19425, 1.17.1, 1.17.2, 2.0.0; rejects 0.0.0, 0.0.1, 0.5.0, 1.0.0, 1.17.0.

4. Smoke timeout opacity (CI follow-up) — addressed in 366e02f: every wait stage is labeled (server-ready, goal-complete, arrays-goal-registered) in the timeout error; the goal-complete poll records the last-observed final conditions (goal status, autoTurns, session outcome, still-active membership) into that stage's error so the stuck condition is identifiable; on failure the script writes failure-summary.json (stage, model/continuation call counts, deadline) and copies the entire smoke root — including server.log — to OPENCODE_SMOKE_ARTIFACTS_DIR, which the v2-smoke job now uploads via an if: failure() artifact step.

Validation on 366e02f: bun run lint, bun run typecheck, bun run test (268 pass / 0 fail, incl. 4 new tests), bun run build, and bun run pack:dry-run all pass; each new recovery regression was confirmed to fail when src/server.ts alone is reverted to the pre-fix state. The new CI run on this push currently needs workflow approval to start (fork-PR gating).

@danyel117 danyel117 left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks — the three requested follow-ups now satisfy the previous review. I revalidated the fixed diff and the functional/spec axis is clean; locally, lint, typecheck, 268 tests, build, pack dry-run, and the V2 smoke against exact beta-19425 all pass. Two new blockers remain on 366e02f:

  1. [Medium] Remove the undeclared registry mirror from bun.lock. The new lockfile now pins 581 tarballs to https://repo.nju.edu.cn/repository/npm/... (starting at bun.lock:35), while both the reviewed parent and main had zero such references. bun install --frozen-lockfile therefore routes CI/release dependency retrieval through an unrelated third-party mirror. Please regenerate or normalize the lockfile with the official registry and verify rg -c "repo\.nju\.edu\.cn" bun.lock returns no matches.

  2. The required V2 smoke is still nondeterministic on Linux. After approving the fork workflow, run 34604475252 failed twice at scripts/smoke-v2-lifecycle.ts:174: first autoTurns was 0, then 1, while the uploaded artifacts showed status: complete, continuationCalls: 3, and the expected value was 2. This is not the prior opaque timeout; the new diagnostics made the failure clear. The same head passes locally on macOS with Bun 1.4.2 and both beta-19425/current beta, so please isolate the Linux accounting/synchronization race and make the release gate deterministic rather than weakening the assertion.

Minor standards cleanup while touching the smoke: current lines 118, 173, and 206 exceed the documented 130-column limit; deadLineMs at line 206 is also an absolute timestamp and would read more accurately as deadlineAtMs.

CI run: https://github.com/prevalentWare/opencode-goal-plugin/actions/runs/34604475252

The previous lockfile rewrite resolved all 581 tarballs through a local
mirror (repo.nju.edu.cn) because the authoring machine's global bunfig
overrides the install registry. Restore official registry URLs; integrity
hashes are unchanged, and bun install --frozen-lockfile verifies cleanly.
event.subscribe() delivers every session event to each loaded location's
plugin instance, and session events carry no envelope location (routing
metadata stays inside the host bus). The previous guard keyed on
event.location, so envelope-less events passed as owned in sibling
instances: both instances accounted usage, recorded progress, and ran
continuations through separate in-process mutation queues, and the
interleaved read-modify-write on the shared goal state lost updates
(autoTurns undercounted, reservations and completion writes clobbered).
This made the V2 lifecycle smoke nondeterministic on CI.

Ownership is now resolved per session: an event envelope wins when
present, session.created uses its data.location, and every other session
event resolves the session's actual location via session.get() compared
against context.location (positive and negative answers cached, lookups
deduplicated; transient lookup failures are not cached). Local
execution paths (prompt hook, tool hooks, command execution, delivered
continuations) mark ownership directly because they only fire in the
owning instance. Sibling instances keep observing child lifecycles for
cross-location task deferral but never mutate shared goal state.

Adds a dual-instance regression covering broadcast envelope-less events;
it fails when the guard treats them as owned.
Wrap the three over-130-column diagnostics lines from the previous
push (plus the equally long PASS summary) and rename the absolute
timestamp field deadLineMs to deadlineAtMs.
@GoldJohnKing

Copy link
Copy Markdown
Contributor Author

All three items were verified before fixing. Pushed as 366e02f..59e5d2c (3 commits).

1. Undeclared registry mirror in bun.lock — verified: all 581 resolved URLs pointed at repo.nju.edu.cn; the rewrite happened because the authoring machine's global ~/.bunfig.toml overrides the install registry, so the semver addition in ff684f2 re-resolved the whole lockfile through the mirror. Fixed in 4065614 by normalizing every URL back to registry.npmjs.org (two URL shapes were present: /repository/npm/ and /npm/). Integrity hashes are content hashes and unchanged; rg -c "repo\\.nju\\.edu\\.cn" bun.lock now returns no matches, and bun install --frozen-lockfile verifies cleanly against the official registry (564 installs, no changes).

2. V2 smoke nondeterminism on Linux — root-caused, fixed, and reproduced before/after on the same machine.

Root cause (from the uploaded artifacts plus a temporary instrumentation trail): context.event.subscribe() delivers every session event to each loaded location's plugin instance (the stream is not location-filtered for plugin hosts), and session events carry no envelope location — routing metadata stays inside the host bus, so the payload the plugin sees has event.location === undefined. The foreign-event guard keyed on event.location, so envelope-less events passed as owned in every instance. The smoke activates two locations, so both instances accounted usage, recorded assistant progress, and ran continuation decisions for the same session through separate in-process mutation queues; the interleaved read-modify-write on the shared goals.json lost updates. That matches the artifacts exactly: two continuation prompts were delivered and update_goal ran once, yet the final state had autoTurns: 0 (no reservation history at all) or autoTurns: 1 with a pendingAttempt still in its committed:true, started:false, delivered:false reserved shape — i.e. the completion write was derived from a snapshot taken before the delivery confirmation, which is impossible under a single serialized queue. It also explains the second failure mode (goal stuck active, one continuation lost) and why macOS hid it.

Local reproduction: pinned to 2 CPUs to approximate the CI runner, the head (366e02f) failed 11 of 20 runs — both the autoTurns assertion and a goal-complete timeout with status: active.

Fix (37d539c): ownership is now resolved per session instead of inferred from the event envelope — an explicit envelope wins when present; session.created uses its data.location; every other session event resolves the session's actual location via session.get() compared against context.location, with positive and negative answers cached and concurrent lookups deduplicated (transient lookup failures are not cached, so the owning instance recovers). Local execution paths that only fire in the owning instance (prompt hook, tool execute.before, command execution, delivered continuations) mark ownership directly. Sibling instances still observe child lifecycles for cross-location task deferral but never mutate shared goal state. The assertion at line 174 is unchanged.

Regression: V2 envelope-less session events stay foreign to sibling location instances builds two plugin instances over one shared state file, broadcasts the same envelope-less events to both, and asserts the sibling neither delivers continuations nor reserves turns nor adds accounting (it also verifies the sibling consults session.get). Confirmed it fails on the pre-fix guard behavior.

Verification on the fixed build: the same pinned 20-run loop passes 20 of 20; full gate passes (lint, typecheck, 269 tests, build, pack:dry-run). One note: V2 watchdog no-response counts a failure on idle even with auto_continue false is timing-sensitive and flaked ~1–3/10 locally on both 366e02f and the fixed head when the machine is loaded (it passed on both prior CI runs, including yours); no behavior change from this push affects it — flagging in case a future Tests run hiccups on a slow runner.

3. Minor standards cleanup — done in 59e5d2c: the three flagged lines (now 118/174/230 after the wrap) are within the 130-column limit, the equally over-long new PASS summary line is wrapped as well, and deadLineMs is renamed to deadlineAtMs.

Heads-up: this push will again need workflow approval to start (fork-PR gating).

@danyel117 danyel117 left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks — the previous registry, Linux race, and smoke-cleanup requests are materially addressed, and the full local gate passes on 59e5d2c (frozen install, lint, typecheck, 269 tests, build, pack dry-run, and V2 smoke with autoTurns=2). Two new-diff items remain:

  1. [Medium] Clear foreign ownership entries when a session is deleted. In handleV2Event, a sibling instance resolves a foreign session and caches sessionOwnership.set(sessionID, false). Its session.deleted path only calls taskTracker.observeSessionDeleted(sessionID) and returns at src/server.ts:2255-2268, so the cleanup at src/server.ts:2393 is owner-only. A long-lived shared server therefore retains one negative cache entry per deleted session per sibling location indefinitely. Please delete the ownership entry in the foreign deletion path as well (and add a regression proving the cache is cleared/re-resolved after deletion).

  2. [Low] Finish the requested 130-column cleanup in the new regression. test/server-v2.test.ts:1111 and :1119-1122 are 132-180 columns, while CONTRIBUTING.md requires 130-column lines. Please reflow those five expressions.

I did not find additional high/medium functional or spec issues in 366e02f...59e5d2c.

The foreign branch of handleV2Event handled session.deleted with only
taskTracker.observeSessionDeleted, so the sessionOwnership cache entry
was freed only by the owning instance. A long-lived shared server would
retain one negative entry per deleted session per sibling location
indefinitely, and a re-created session could never be re-resolved. The
foreign deletion path now clears the entry too, and the dual-instance
regression proves the cache is re-resolved (a second session.get lookup)
after deletion. Also reflows the five over-130-column expressions the
review flagged in the new regression.
@GoldJohnKing

Copy link
Copy Markdown
Contributor Author

Both items were verified against the code before fixing. Pushed as 59e5d2c..d958246 (1 commit).

1. Foreign ownership entries on session deletion — verified: the foreign branch's session.deleted path called only taskTracker.observeSessionDeleted and returned, so sessionOwnership.delete ran only in the owner's switch case; every sibling location retained one negative cache entry per deleted session for the life of the server. Fixed: the foreign deletion path now clears the entry as well (with a comment noting why both instances must free it). The dual-instance regression now drives deletion through the sibling and asserts re-resolution — after session.deleted, the next envelope-less event triggers a second session.get lookup for the same session ID instead of inheriting the stale negative entry (asserted via the mock's sessionGetCalls going from 1 to 2, with still zero sibling prompts). Confirmed the new assertions fail when the delete is removed.

2. 130-column cleanup in the regression — the five flagged expressions (the create_goal execute and the four multi-field event pushes) are reflowed; awk 'length > 130' test/server-v2.test.ts now reports nothing inside the new test. The three remaining over-130 lines in the file belong to the pre-existing fast execution success test from an earlier reviewed push, so I left them untouched to keep this diff scoped.

Validation on d958246: bun run lint, bun run typecheck, bun run test (269 pass / 0 fail), bun run build, bun run pack:dry-run, and the pinned 2-CPU smoke loop 5/5 PASS with autoTurns=2. (One suite run mid-verification hit the known timing-sensitive watchdog no-response test noted in my previous comment; it passes on re-run and is unrelated to this change.)

Workflow approval will be needed again for the fork CI run.

@danyel117 danyel117 left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Re-reviewed the incremental fix from 59e5d2c through d958246. Both requested items are resolved: foreign session deletion clears the ownership cache and the regression proves re-resolution without sibling prompts; the five newly flagged overlong expressions are reflowed. No new high/medium findings. Local validation passed (frozen install, lint, typecheck, 269 tests, build, pack dry-run, V2 lifecycle smoke with status=complete and autoTurns=2). CI run 34744999936 is fully green.

@danyel117
danyel117 merged commit cb70a39 into prevalentWare:main Sep 14, 2026
5 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants