Skip to content

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

Open
GoldJohnKing wants to merge 11 commits into
prevalentWare:mainfrom
GoldJohnKing:feat/v2-adaptation
Open

feat: full OpenCode V2 adaptation (beta-19425 contract, compaction context, restart task recovery)#49
GoldJohnKing wants to merge 11 commits into
prevalentWare:mainfrom
GoldJohnKing:feat/v2-adaptation

Conversation

@GoldJohnKing

Copy link
Copy Markdown

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
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).

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