Unresolved git merge conflict markers (<<<<<<<, =======, >>>>>>>) in
TypeScript source files break parsing and can cause the plugin to fail
at runtime or tests to hang with cryptic errors. This guard scans all
.ts/.tsx/.json files under src/ and fails the test suite if any
conflict markers are found.
Closes #debugging-hang-investigation
- Replace the simple scheduledDelays array with an activeTimers Map so
that clearTimeout removes timers from the tracked set.
- This prevents false positives when internal withDispatchTimeout calls
setTimeout for safety timeouts that are immediately cancelled.
- Keeps the test intent unchanged: only genuinely scheduled retries are
counted as delayed duplicate retries.
- Wrap isSessionActive in withDispatchTimeout (capped at 5s) so a
stuck OpenCode SDK status() call cannot block internal prompts forever.
- Catch the timeout and treat session as inactive so the prompt can
proceed rather than hanging indefinitely.
- Add regression test: session.status that never resolves now times out
and allows dispatch instead of hanging the test (and production).
Refs: AGENTS.md internal-message-injection safety note
The rule scan cache stored a path[] keyed by (projectRoot|startDir|
skipClaudeUserRules), so two issues stacked up on every tracked tool
call:
- Cache hits still ran safeRealpathSync(realpathSync) and re-derived
isGlobal / distance / isSingleFile for every cached path. That is a
per-candidate sync syscall plus repeated string-prefix walks.
- Sibling files in the same project landed under different startDir
keys, so the entire walk-and-recursive-scan chain repeated even
though every ancestor rule directory was identical.
Store the full RuleFileCandidate[] in the per-call cache so a cache hit
returns immediately with no realpath syscall. Add a separate per-
directory scan cache (getDirScan/setDirScan) keyed by absolute rule
directory path, so two sibling files reuse the same readdir + realpath
work for every shared ancestor.
Microbench (200 files / 20 modules / cached session):
- single sweep: 41.8ms -> 2.5ms (16x)
- 3-pass replay: 88.6ms -> 3.2ms (28x)
Pin the new invariants with two new tests:
- 'does not re-resolve symlinked rule path on cache hit' via a
retargeted directory symlink.
- 'reuses ancestor directory scan for sibling files in the same
project' by deleting the source rule file between the two calls.
findProjectRoot was keyed by exact startPath, so sibling files in the
same project repeated the entire upward marker walk. The walk does one
existsSync per marker per ancestor directory, which adds up on every
read/write/edit/multiedit tool call.
Track every directory visited during the walk and seed the cache with
the resolved root for each of them. Subsequent lookups for any
descendant short-circuit to the cached ancestor without re-running
marker probes. Cache invalidation still happens on session.deleted /
session.compacted, so production semantics are unchanged.
Pin the new contract via a sibling-startpath test, and make the
existing finder.test.ts beforeEach explicit about cache state so the
more aggressive cache does not leak between tests.
When the first prompt fails before any durable user message persists,
runtime fallback retry was rebuilding the request from parts alone and
losing the delegated agent system prompt and tool gates. Now it threads
bootstrap.system and bootstrap.tools into the retry body alongside the
captured retry parts, so the retried prompt keeps the same scope as the
initial delegate launch.
- script/run-ci-tests.ts: CI test sharding and isolation logic
- script/run-ci-tests.test.ts: tests for CI test target selection
- src/features/background-agent/session-route.ts: session prompt routing for background agents
- src/hooks/interactive-bash-session/parser.ts: interactive bash output parser
- src/hooks/ralph-loop/completion-promise-detector-test-input.ts: test fixture for completion promise detection
Preserve delegated child prompt/bootstrap metadata for early runtime fallback before OpenCode has persisted the first user turn. Bind prompt gate calls to the SDK session receiver and keep completed background task lookup visible across plugin manager instances.
Lines 79/142/428 of prompt-async-gate.test.ts used timer-based synchronization, violating .sisyphus/rules/test-discipline.md which forbids time-based test waits. Replace them with explicit dispatch awaits and mocked-time expiry so the assertions do not depend on CI machine speeds.
Closes BLOCKER-3 (Wave 2 cleanup)
Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent)
Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
Test-discipline.md forbids setTimeout(resolve, N) and sleep(N) in test bodies. Replace the 3 microtask and expiry sleeps with explicit microtask yields and deterministic clock advancement, preserving the prompt gate invariants without real-time waits.
Closes BLOCKER-3
Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent)
Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
Adds regression coverage for BLOCKER-1 (dispatch timeout releases
reservation for next caller after stalled upstream) and BLOCKER-2
(post-dispatch error preserves the post-dispatch hold so an immediate
second caller observes the reservation and is gated).
Both tests subscribe-first on the promptAsync call count and assert
status transitions without sleep-based synchronization. dispatchTimeoutMs
is the system under test, so passing it explicitly as 1ms in those tests
is the SUT, not a sleep-as-synchronization (per test-discipline.md).
Closes BLOCKER-3 (dispatch timeout + post-dispatch coverage)
Co-authored-by: gate-tests (deep / gpt-5.3-codex high)
Treat parsed variant as part of runtime-fallback model equivalence so variant-only fallback hops remain distinct while preserving the existing Claude-family alias handling.
Constraint: Oracle verification flagged unresolved PR #3322 review concerns about variant equivalence and remote state
Rejected: Preserve provider identity in equivalence | contradicted the original live-loop fix for equivalent Claude aliases
Confidence: medium
Scope-risk: narrow
Directive: Any future equivalence broadening must prove both live retry-loop behavior and variant/provider semantics with targeted tests before merging
Tested: bun run typecheck
Tested: bun test src/hooks/runtime-fallback/index.test.ts src/hooks/runtime-fallback/error-classifier.test.ts src/plugin/event.model-fallback.test.ts
Not-tested: Full live end-to-end repro across all provider redundancy policies
Prevent runtime fallback from cycling through provider aliases that resolve to the same underlying Claude family model. This keeps retry handling moving toward a genuinely distinct fallback model instead of appearing to fallback while staying on the same effective model.
Constraint: Live retry/fallback bug is in /Users/ravi/Code/personal/oh-my-opencode, while oh-my-openagent contribution work remains isolated to /Users/ravi/Code/forks/oh-my-openagent
Rejected: Change fallback chain precedence (category vs agent) first | lower-confidence root cause than equivalent-model retry
Confidence: high
Scope-risk: narrow
Directive: Keep alias-equivalence logic limited to model families that are intentionally interchangeable for runtime failover, and expand with targeted tests before broadening provider-family collapsing
Tested: bun run typecheck
Tested: bun test src/hooks/runtime-fallback/index.test.ts src/hooks/runtime-fallback/error-classifier.test.ts src/plugin/event.model-fallback.test.ts
Not-tested: Full live end-to-end session repro against external provider outages
Activity signals (tool calls like compress, grep, bash) were treated as
'progress' by the stagnation detector, resetting the stagnation counter
every cycle. This prevented MAX_STAGNATION_COUNT from being reached,
causing infinite continuation loops when models degrade to minimal
responses in long sessions (e.g. GLM-5.1 at ~100K tokens).
Stagnation now only tracks actual todo state changes: incomplete count
decrease, completed count increase, or todo snapshot change. Tool-level
activity no longer resets the stagnation counter.
Addresses two issues identified by cubic on PR #3952.
1. Watchdog cancellation was too narrow — only `text`/`reasoning` parts
counted as progress, so a subagent that immediately ran tools
(Read/Bash/Edit) emitted `tool`/`tool_use`/`tool_result`/`tool-call`/
`step-start` parts that the watchdog ignored, risking a false fire
on actively-working subagents. Broaden to: any assistant part of any
known type counts as progress (the model has started responding,
whether or not visible text has arrived yet). `info.error` and
`info.finish` continue to cancel.
2. Test timing margins were tight (15ms pre-cancel against a 40ms
timer), risking CI flakiness on loaded runners. Bumped to a 100ms
threshold with a 40ms pre-cancel window and a 250ms post-fire wait,
giving a 60ms margin before the timer fires and ~2.5x the threshold
after — robust against scheduler delay.
Refactor for testability: extracted the OpenCode-event→watchdog-signal
translation out of `hook.ts` into an exported `observeEventForWatchdog`
helper on the watchdog module. This let me add direct unit tests for
every part-type case (text, reasoning, tool, tool_use, tool_result,
tool-call, step-start, file) plus the error/finish/empty-parts branches
without spinning up the full hook. Net diff: hook.ts shrinks, watchdog
module gains a small pure function with parametrised coverage.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
When a subagent is dispatched to a provider and the underlying SDK
enters a silent internal retry loop on a 429/quota error, no error
event is ever emitted back to OpenCode. The runtime-fallback hook —
which is fully reactive (listens to message.updated/session.error/
session.status) — has nothing to react to and never dispatches the
configured fallback. The subagent sits in `retry` status until the
parent's 30-minute poll timeout (DEFAULT_POLL_TIMEOUT_MS) gives up,
during which the parent's pending task tool call shows "waiting for
subagent" with no indication of failure.
This change adds a first-prompt watchdog that synthesises the missing
error-event trigger:
- Armed when a user message lands in a subagent session
(membership check via `subagentSessions`).
- Cancelled on the first sign of progress: any assistant message
with text/reasoning content, finish field, or an error field (any
of which is something the existing handlers will deal with).
- Cancelled on session terminal events (idle/stop/deleted/error).
- On fire (90s default): aborts the in-flight request and routes
into the existing dispatchFallbackRetry path — the same code that
runs when a session.error arrives. No new fallback mechanism.
Design choices:
- Dispatch fallback, do not abort the subagent outright. Network
loss looks identical to a stuck retry from the hook's vantage
point; with fallback-dispatch behaviour, network loss degrades
to today's baseline (both attempts fail, 30-min outer timeout
still ends things) rather than destructively aborting work.
- Scope strictly to subagents. Parent/user sessions can legitimately
take 90s+ to produce the first token; subagent dispatches in
practice produce first content much faster, so a 90s ceiling is
safe.
- Threshold is tunable via the third arg to createFirstPromptWatchdog;
DEFAULT_FIRST_PROMPT_WATCHDOG_MS = 90_000 in constants.ts.
Also adds a diagnostic log in session-status-handler when a
`session.status: retry` event arrives whose message does not match
RETRYABLE_ERROR_PATTERNS. This is the hook's other silent-return
spot for retry events; logging the raw retry message will let us
extend the patterns next time we hit a provider whose phrasing
we don't yet match.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>