Skip to content

fix(plugin): lifecycle correctness — dispose hook, claim release, startup races - #62

Merged
ZeR020 merged 6 commits into
mainfrom
fix/audit-lifecycle
Sep 8, 2026
Merged

fix(plugin): lifecycle correctness — dispose hook, claim release, startup races#62
ZeR020 merged 6 commits into
mainfrom
fix/audit-lifecycle

Conversation

@ZeR020

@ZeR020 ZeR020 commented Sep 8, 2026

Copy link
Copy Markdown
Owner

Summary

Full codebase audit batch 1/3 (plugin lifecycle). Every fix ships with a regression test that fails without it. Gates green: format:check + typecheck + 827 tests passed (baseline 821) + build.

Commit Fix
fix(auto-capture) Prompt claims are released on early return — prompts were stranded in captured=2 forever when capture skipped after claiming (the finally reset was dead code: claimedPromptId was only assigned from a function that returns null on both skip and success)
fix(plugin) Implements the opencode dispose hook. Cleanup previously lived only in a globalThis stash the host never calls; timers, scoring, lifecycle jobs, the web server and sqlite connections survived reload
fix(plugin) Warmup Promise.race timer cleared in finally — a late-losing timer rejected an orphan promise
fix(plugin) Auto-capture / profile learning / conflict checks await ensureProviderState() instead of racing the fire-and-forget provider-state init at startup
fix(tools) The memory.forget tool reports actual delete failures instead of always "Memory removed"
perf(plugin) Startup no longer blocks the host: warmup() and initial score recalculation are fire-and-forget; the plugin object returns immediately (config init stays synchronous)

Verification

  • bun run format:check && bun run typecheck && bun run test && bun run build — all green (827 passed / 1 skipped)
  • New regression tests: claim release, dispose clears idle timers, no unhandled warmup rejection, capture waits for provider state, forget failure message, factory returns without awaiting warmup

Notes

  • shutdownHandler globalThis stash retained for CLI consumers — dispose wraps the same handler.

Devin Review

Copilot AI lite review requested due to automatic review settings September 8, 2026 14:25
@sonarqubecloud

sonarqubecloud Bot commented Sep 8, 2026

Copy link
Copy Markdown

@devin-ai-integration devin-ai-integration Bot 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.

Devin Review found 3 potential issues.

3 flags not posted on this PR by your GitHub settings — view them in Devin Review. (Configure)

Devin Review

Comment thread src/index.ts
await handleSessionCompacted(event, ctx, directory);
}
},
dispose: shutdownHandler,

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.

🔴 Late web server survives disposal

When disposal precedes startWebServer completion, shutdownHandler sees no server to stop. The completion later installs a live server owned by the disposed plugin.

Prompt for agents
Coordinate asynchronous web-server startup with plugin disposal in src/index.ts. Track a disposed state or the startup promise. If disposal happens before startWebServer resolves, stop the returned server immediately and suppress its callbacks and toasts. Ensure shutdown also waits for or safely settles pending startup without allowing a disposed instance to become owner.
Devin Review

Was this helpful? React with 👍 or 👎 to provide feedback.

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

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

Verified and fixed in #66 (f20a844): the startWebServer().then handler now checks the disposed flag — a server resolving after disposal is stopped immediately and never assigned. Regression-tested in tests/disposal-lifecycle.test.ts.

Comment thread src/index.ts
Comment on lines +287 to +290
for (const timer of sessionIdleTimers.values()) {
clearTimeout(timer);
}
sessionIdleTimers.clear();

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.

🔴 Running idle work survives disposal

Once an idle callback starts, clearTimeout cannot cancel it. It continues captures and maintenance after memoryClient.close(), allowing disposed instances to write or reopen databases.

Prompt for agents
Add lifecycle cancellation for in-flight idle processing in src/index.ts, not only pending timeout handles. Track a disposed flag or AbortController per plugin instance. Check cancellation between awaited idle stages and before every post-disposal database operation. Make shutdown wait for active idle work to settle before closing memoryClient, or abort that work and then close connections.
Devin Review

Was this helpful? React with 👍 or 👎 to provide feedback.

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

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

Partially addressed in #66 (f20a844): the event handler gains an entry checkpoint and the capture/learning pipelines check again after their first await, so no NEW work starts post-dispose. Fully cancelling in-flight work mid-await (AbortController plumbing through capture) was left out — entry checkpoints plus the bounded provider-state wait cover the wedge scenarios; a full cancellation system is the follow-up if disposal-during-capture proves hot.

Comment thread src/index.ts
Comment on lines +262 to +264
void Promise.resolve().then(() => {
try {
recalculateAllScores(true);

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.

🔴 Score recalculation still blocks startup

Promise.resolve().then runs synchronous recalculateAllScores before callers resume from plugin initialization. Large stores still block the host during startup.

Prompt for agents
Move initial score recalculation off the plugin-initialization microtask path. Schedule it on a later event-loop turn or use an asynchronous or worker-based implementation so the host can receive and use the plugin first. Integrate the scheduled job with disposal so it cannot start or continue after shutdown.
Devin Review

Was this helpful? React with 👍 or 👎 to provide feedback.

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

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

Verified and fixed in #66 (f20a844): the scan now runs on a setTimeout(0) macrotask (cleared on dispose), so the host's await of the factory resolves before the shard scan executes — the DX comment was right that the microtask ran first.

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🟡 Changes recommended

ensureProviderState() is awaited unconditionally in paths that can use the external provider, which can deadlock idle processing/auto-capture if provider-state init hangs and leave global capture state stuck.

Once you've addressed the issues Copilot identified, you can request another Copilot review.

Pull request overview

This PR improves plugin lifecycle correctness and startup behavior by adding a proper dispose hook, fixing auto-capture claim handling, preventing warmup race rejections, and coordinating opencode provider-state initialization to avoid startup races.

Changes:

  • Add provider-state initialization gating (setProviderStateInit / ensureProviderState) and update lifecycle paths to wait for provider state before using getStatePath().
  • Fix auto-capture prompt-claim release on early returns and add tests to prevent regressions.
  • Make warmup and initial score recalculation non-blocking, ensure warmup timeout timer is cleared, and implement dispose to clean up timers/jobs/server/sqlite.
File summaries
File Description
tests/user-memory-learning.test.ts Updates provider mock to include ensureProviderState.
tests/transcript-idle-wiring.test.ts Adds coverage for dispose clearing idle timers and shutdown behavior.
tests/tool-scope.test.ts Adds regression test ensuring memory.forget reports real deletion failures.
tests/plugin-error-handling.test.ts Adds tests for warmup timeout race cleanup and non-blocking factory return.
tests/memory-conflicts.test.ts Updates provider mock to include ensureProviderState.
tests/chat-message-mode.test.ts Updates provider mock to include provider-state init hooks.
tests/auto-capture.test.ts Adds regression tests for claim release and provider-state gating.
src/services/user-memory-learning.ts Awaits provider-state initialization before profile learning run.
src/services/memory-conflicts.ts Awaits provider-state initialization before opencode contradiction checks.
src/services/auto-capture.ts Awaits provider-state initialization and fixes prompt-claim handling.
src/services/ai/opencode-provider.ts Introduces provider-state init promise + ensureProviderState API.
src/index.ts Implements dispose, makes warmup/scoring non-blocking, wires provider-state init, improves forget failure messaging.
docs/CHANGELOG.md Documents the lifecycle/startup fixes in Unreleased.
Review details
  • Files reviewed: 13/13 changed files
  • Comments generated: 2
  • Review effort level: Lite

💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.

Comment on lines +134 to +135
const { ensureProviderState } = await import("./ai/opencode-provider.js");
await ensureProviderState();

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

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

Verified and fixed in #66 (f20a844): ensureProviderState() is now bounded (10s warn-and-proceed), so a hung host bootstrap can no longer wedge the capture mutex; a disposed-plugin checkpoint also backs out before claiming a prompt.

Comment on lines +104 to +105
const { ensureProviderState } = await import("./ai/opencode-provider.js");
await ensureProviderState();

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

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

Verified and fixed in #66 (f20a844) — the bounded ensureProviderState() covers this path (10s warn-and-proceed), plus a post-await disposed check before profile learning proceeds.

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: dcfe834b54

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment thread src/index.ts
let timeoutId: ReturnType<typeof setTimeout> | undefined;
try {
await Promise.race([
memoryClient.warmup(),

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P1 Badge Prevent the first chat hook from waiting on warmup

On a cold local-embedding startup, the factory can now expose chat.message while this warmup is still pending. That hook calls searchMemories(), whose embedWithTimeout() awaits the in-progress embeddingService.warmup() before reaching signal-aware work, so its AbortController cannot interrupt model initialization. The first user prompt can therefore stall until the model load eventually finishes—potentially beyond warmupTimeoutMs—rather than using the prior bounded startup degradation path.

Useful? React with 👍 / 👎.

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

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

Verified and fixed in #66 (c78740b): the warmup wait inside embed() is now raced against warmupTimeoutMs and the caller's AbortSignal; it rejects AbortError-shaped so an already-running model load isn't re-triggered and the service isn't permanently disabled. searchMemories degrades to text-only for that prompt instead of stalling it. Tests: tests/warmup-bound.test.ts.

Comment on lines +134 to +135
const { ensureProviderState } = await import("./ai/opencode-provider.js");
await ensureProviderState();

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P1 Badge Do not gate manual capture on host provider state

If ctx.client.path.get() or provider.list() stalls during host bootstrap, providerStateReady never settles because its initialization has no timeout. A configuration using only memoryModel/memoryApiUrl does not need OpenCode state, but this unconditional await leaves isCapturing true forever; every later idle cycle then returns at the mutex check and automatic capture stops for the rest of the process. Limit this wait to the OpenCode-provider path (and apply the same distinction to profile learning) or bound the state initialization.

Useful? React with 👍 / 👎.

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

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

Fixed in #66 (f20a844) — bound applied to the OpenCode-provider path only where needed (all paths, via the shared 10s race), plus the disposed guard before claiming.

Comment thread src/index.ts
Comment on lines +262 to +264
void Promise.resolve().then(() => {
try {
recalculateAllScores(true);

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 Badge Defer the initial score scan beyond factory resolution

recalculateAllScores() is synchronous and can scan every memory shard. Queueing it in a promise microtask does not make startup non-blocking: that microtask is already queued before the async plugin factory resolves to its awaiting caller, so it runs before the host can continue after await OpenCodeMemPlugin(...). With scoring enabled, large stores therefore still block plugin startup despite the new background-startup behavior and changelog entry.

Useful? React with 👍 / 👎.

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

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

Fixed in #66 (f20a844): setTimeout(0) macrotask instead of the microtask — the host resumes first, changelog claim now true.

@ZeR020
ZeR020 merged commit b76d759 into main Sep 8, 2026
14 checks passed
@ZeR020
ZeR020 deleted the fix/audit-lifecycle branch September 8, 2026 14:32
ZeR020 added a commit that referenced this pull request Sep 8, 2026
…score scan

Code-review findings on #62 (Devin, Copilot, Codex):
- ensureProviderState() had no timeout: a hung host bootstrap wedged
  auto-capture (isCapturing stuck true) and profile learning forever.
  Now bounded at 10s with a warn-and-proceed fallback.
- startWebServer().then assigned webServer with no disposal guard:
  a server finishing after dispose ran orphaned with live toasts.
  Now stopped immediately on late resolution.
- Idle events arriving after dispose scheduled new capture work; the
  event handler and capture/learning pipelines now check disposed state.
- Initial recalculateAllScores ran on a Promise.resolve() microtask,
  which executes before the host resumes from awaiting the plugin
  factory — the startup-blocking claim was wrong. Now a setTimeout(0)
  macrotask, cleared on dispose.
ZeR020 added a commit that referenced this pull request Sep 8, 2026
Codex P1 on #62: embed() awaited warmup() with no bound — the
AbortController in embedWithTimeout only guarded the API-fetch path,
so a slow model download stalled the first chat.message hook past any
configured timeout (and an abort during warmup left the service
permanently disabled via embeddingAvailable=false).

- warmup wait is raced against warmupTimeoutMs and the caller's
  AbortSignal; on either it rejects AbortError-shaped, which embed()'s
  catch rethrows without disabling the service.
- searchMemories treats a warmup-pending AbortError as a degraded
  text-only search instead of propagating the failure to the prompt.
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