Skip to content

Make the automatic continuation deterministic - #99

Open
ggrabarz wants to merge 6 commits into
prevalentWare:mainfrom
ggrabarz:pr/03-continuation-timing
Open

ggrabarz wants to merge 6 commits into
prevalentWare:mainfrom
ggrabarz:pr/03-continuation-timing

Conversation

@ggrabarz

@ggrabarz ggrabarz commented Oct 21, 2026 •

Copy link
Copy Markdown

Fixes in this pull request

  • fix/v2-continuation-timing-flake - A continuation failure was not correlated with the attempt it belongs to, so a failure of an earlier attempt could be reported against a later one.
  • fix/min-continuation-interval-precision - The minimum interval between two automatic continuations was not honoured to the millisecond, so the plugin could start another continuation before the interval had passed.

Why

Both showed up as a continuation that either did not fire or fired twice. The first was a reporting bug: the diagnostics log named the wrong attempt, which made the failure impossible to attribute. The second was a real behaviour bug in the arbitration of the next turn.

Verification

  • The full local gate passes on this branch.
  • The continuation-sensitive test file passes 111 of 111 in three isolated runs.
  • The V2 lifecycle smoke test reports PASS with the automatic continuation delivered and the goal complete.

This is the third 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 6 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.
@ggrabarz
ggrabarz force-pushed the pr/03-continuation-timing branch from f687c00 to 3697d38 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 reservation now uses lastContinuationAtMs, but continuationDelayFromSnapshot still schedules from the rounded public lastContinuationAt and deliberately adds another whole second. A configured minimum interval can therefore be exceeded by nearly one second on every fast V2 turn. Please carry the private millisecond anchor through the scheduler and assert a tight elapsed-time boundary; the public seconds timestamp can remain unchanged. The full stack still needs green CI and the model/harness or manual-work disclosure required by CONTRIBUTING.md.

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