Skip to content

Report plugin failures with the cause behind them - #100

Open
ggrabarz wants to merge 9 commits into
prevalentWare:mainfrom
ggrabarz:pr/04-failure-diagnostics
Open

ggrabarz wants to merge 9 commits into
prevalentWare:mainfrom
ggrabarz:pr/04-failure-diagnostics

Conversation

@ggrabarz

@ggrabarz ggrabarz commented Oct 21, 2026 •

Copy link
Copy Markdown

Fixes in this pull request

  • fix/plugin-diagnostics-log - A plugin failure was handed to the host, but nothing was written where a monitor could see it. Failures are now recorded in the plugin's own diagnostics log.
  • fix/diagnostics-error-cause - Only the message of a failure was reported, not the cause behind it.
  • fix/diagnostics-effect-cause - An Effect fiber failure hid the real error inside the fiber; the cause is now read out of it.

Why

Without a log of its own, a failure of the plugin was only visible as a line in the host log, if at all, and without the cause it did not say what went wrong. These three fixes are what makes the rest of the fixes in this series findable: every later fix was diagnosed from this log.

Verification

  • The full local gate passes on this branch.
  • Tests cover the reported cause, the cause of a fiber failure, and the written log entry.

This is the fourth pull request of a stack. Merge the earlier ones first; once they are merged this diff is only the fixes above.


View with [code]smith Autofix with [code]smith
Need help on this PR? Tag @codesmith-bot with what you need.

Grzegorz Grabarz and others added 9 commits October 21, 2026 11:29
The repository stores every text file with LF endings, but nothing said so.
A commit made on a machine whose core.autocrlf is false therefore stored
whatever the editor happened to write, and a file saved with CRLF endings
landed in the commit as CRLF. The next edit to that file then rewrote every
line of it, which turned a small fix into a whole-file diff and hid the real
change.

A .gitattributes file pins the convention, so the line endings of a file no
longer depend on the settings of the machine that makes the commit.
The plugin declared @opencode-ai/plugin as a runtime dependency and the
host-provided @opentui/solid and solid-js peers as required peers. npm
auto-installs required peers, so every host that installed this plugin
also installed @opentui/core, typescript, web-tree-sitter, @babel and a
second Effect runtime. The OpenCode log reported the resulting install as
a stalled plugin preparation step:

  message="plugin operation stalled" component=plugin stage=prepare
  elapsedMs=5002 target=@prevalentware/opencode-goal-plugin install=true

@opencode-ai/plugin is only imported with `import type`, so it never
reaches the built bundle: dist/server.js imports zod, effect-goal-state
and node builtins only. The TUI peers are supplied by the OpenCode host at
runtime, which is why @opencode-ai/plugin itself declares the equivalent
peers as optional.

Measured on this repository by packing the published files and installing
each variant into an isolated project:

  before: 10.20s, 157.81 MB, 11928 files
  after:   5.94s,  33.48 MB,  4589 files

Adds test/dependencies.test.ts to keep the runtime imports and the declared
dependencies in sync, and to assert that the host-provided peers stay
optional so the install footprint cannot silently grow back.
The host resolves the plugin entrypoint through the exports map of the
installed package, and it falls back to the main entry when the subpath does
not resolve. The subpaths only answered to the "import" condition and the
package had no main entry at all, so a host that resolved them under another
condition found no entrypoint: the host reports that as "Plugin entrypoint not
found", leaves the location without a fully activated plugin set, and fails
the commands that need one with "Location plugins did not finish
activating".

Both subpaths now carry a default condition and the package has a main entry
that points at the bundle, so the entrypoint resolves whichever condition the
host resolves it under.
The goal plugin replaces its state file through a same-directory temp file
and a rename. On Windows that rename fails with EPERM/EACCES/EBUSY while
an antivirus scanner, a search indexer, or a concurrent reader still holds
the target open, and those locks routinely outlive a couple of hundred
milliseconds.

The retry budget was three attempts with a 60ms total backoff, so a
transient lock surfaced as a failed goal state write and the goal tools
threw EPERM to the caller. The budget is now ten attempts with a capped
exponential backoff (~2.6s worst case, still one rename when uncontended).

Also fixes two Windows-only test failures that the upstream suite carried:
the state-recovery log assertion compared a raw path against a JSON
stringified payload (JSON escaping doubles the path separators), and the
TUI test helpers replaced the state file with a bare rename that has no
retry.
A transport failure was recorded with only requirePending, so a failure that
belonged to a previous continuation could clear the attempt that happened to
be pending when the write landed, and could count a phantom failure toward
the ceiling that pauses the goal. recordToolProgress and recordAssistantProgress
already correlate by attempt id or reservedAt; the continuation-result path
did not.

The failure is now recorded with the goal id and the attempt id, so a late
failure can only affect the attempt it belongs to. When the attempt has
already moved on, the failure is ignored instead of clearing the newer
attempt.

The two V2 continuation tests also get a deadline that covers the plugin's
documented minimum-interval window: a configured interval of N seconds
anchors on a whole second, so a continuation can legitimately be held for up
to N + 1 seconds, which the previous 2500ms budget did not cover. Their
per-test timeout is raised above Bun's 5 second default, which is shorter
than the timeline those tests legitimately wait for. This is the same class
of change as the repository's own fix/ci-timing-flakes branch.

Adds state-level regression tests that pin the attempt-correlation contract
for both the late success and the late failure case.
The minimum interval between two automatic continuations was compared against
whole-second timestamps, so the gate was sub-second sensitive: as soon as the
wall clock crossed into the next second a follow-up continuation was allowed,
even when a fraction of the configured minimum had elapsed. A one second
minimum interval could therefore be honoured with 200ms.

The interval is now measured from a durable millisecond anchor
(lastContinuationAtMs) that is persisted with the goal and restored when a
reserved attempt is rolled back, so the configured interval is the real
interval.

The interval anchor alone was not enough. A fast execution that settles before
the interval expires takes the "not yet" branch, and that branch only
retained a timed wake-up when no attempt was outstanding. With an attempt
still outstanding the wake-up was dropped, so the goal had neither a pending
continuation nor a queued wake-up and was stranded until an unrelated event
arrived. The wake-up is now retained in that case too, which is what the
branch comment already claimed it did.

The result is that the V2 continuation tests stop failing intermittently:
"bun test test/server-v2.test.ts -t 'V2 fast execution success'" failed 6-8
times in 20 runs before and now fails 0 times in 20, and the sibling
"timed continuation waits for a live run boundary" test goes from the same
intermittent failure to 0 in 20.

Adds state-level regression tests for the interval gate, for the follow-up
continuation that must wait for the full interval, and for the anchor that a
rolled-back attempt has to restore.
OpenCode's V2 plugin API has no logger: the context exposes the app metadata,
the location and the domain APIs, but nothing that writes to the server log.
Plugin console output does not reach the OpenCode log either, so every
failure inside the plugin - a failed auto-continue, a turn watchdog retry that
could not be delivered, a goal state that could not be quarantined - was
invisible to anything that only watches the host log.

The plugin now keeps its own diagnostics log next to the goal state file
(goal-plugin.log) and writes its error reports there in addition to the
console output. The log is rotated at 1 MB so a failure loop cannot fill the
disk, and the writer never throws: a failing diagnostics writer must not turn
a recoverable plugin error into a crash.

The state recovery diagnostics are recorded the same way, because those are
the failures that leave the goal state in an unexpected shape.
The state layer reports failures through Effect, so the error that reaches a
caller is often a FiberFailure whose own message is just "An error has
occurred". The real reason - a failed rename, a full disk, a state file that
changed underneath - only lives in the cause chain, so the diagnostics log
recorded a failure without saying what failed.

The diagnostics now unwrap the cause chain, including the rendering Effect
keeps on an internal Cause, so a line names the actual error. The first
entry the new log captured was exactly this case: "Turn watchdog retry failed
| (FiberFailure) StateWriteError: An error has occurred", which said nothing
about the write that had failed.
The cause unwrapping followed the public `cause` property, but Effect keeps
the cause of a failed fiber behind a symbol and the public getter does not
return it. A recorded failure therefore stopped at the wrapper and still said
"An error has occurred" instead of naming the error that failed.

The diagnostics now read Effect's cause symbol and unwrap the cause itself: a
failure carries the error, a defect carries the thrown value, and a composite
cause carries a set of both.

The regression test builds a real FiberFailure with Effect, so the shape the
state layer produces is the shape that is covered.
@ggrabarz
ggrabarz force-pushed the pr/04-failure-diagnostics branch from 2626a6a to 5b9e026 Compare October 21, 2026 09:47

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

The new diagnostics file persists raw error cause chains. appendFileSync creates it with the process umask (commonly readable by other local users), and mkdirSync likewise uses default directory permissions. State files use owner-only permissions. Please create the diagnostics directory/file with owner-only permissions and add a permissions regression test. Also, rotateIfNeeded unlinks the prior archive before renaming; if rename fails, the active file continues growing without a cap despite the comment. Keep a bounded fallback on rotation failure. Please include the model/harness or manual-work disclosure required by CONTRIBUTING.md in this PR body.

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