Skip to content

fix: [AI-7743] Altimate Code chat — MCP servers not getting added (question hang)#1007

Open
saravmajestic wants to merge 2 commits into
mainfrom
fix/AI-7743-mcp-add-question-hang
Open

fix: [AI-7743] Altimate Code chat — MCP servers not getting added (question hang)#1007
saravmajestic wants to merge 2 commits into
mainfrom
fix/AI-7743-mcp-add-question-hang

Conversation

@saravmajestic

@saravmajestic saravmajestic commented Jul 16, 2026

Copy link
Copy Markdown
Contributor

Summary

Fixes AI-7743 — in Altimate Code chat, /discover-and-add-mcps showed the "which MCP / what scope" question, but after the user answered it the chat sat on "Thinking…" forever and the MCP server was never added.

Root cause

After the v1.17.9 upstream merge (effect@4), src/question/index.ts is bundled as two separate module instances. Proven: a module-scoped id logged from the question tool's ask() differs from the one logged in the HTTP reply() route; normalizing every importer to @/question did not make Bun dedupe it. Each copy ran its own makeRuntime runtime, which broke the flow in two independent ways:

  1. State split. The question tool registered the pending Deferred in one copy's InstanceState pending-map, while POST /question/:id/reply looked it up in the other copy's empty map. The reply returned NotFoundError (HTTP 500) and the Deferred never resolved, so the agent loop blocked forever → "Thinking…". (GET /question returned [] for the same reason.)
  2. Event never reached the IDE webview. question.asked was published only via EventV2BridgeGlobalBus, but the /event SSE stream the webview subscribes to is fed by the Bus wildcard PubSub (Bus.publish) — a different channel. So the webview's pendingQuestions stayed empty, the answer card's Submit resolved requestId: undefined, and submitting did nothing.

Either half alone produces the reported "answer the question → nothing happens".

Fix (all in question/index.ts + import cleanup)

  • State: anchor the pending-question registry on globalThis (keyed by instance directory) so all module copies share one map. Restore per-instance cleanup via registerDisposer so entries don't leak across instances/tests.
  • Events: publish question.asked / replied / rejected via Bus.publish (added BusEvent mirrors) so they reach /event like every other webview-visible event.
  • Normalize the remaining relative ../question imports to @/question.

Verification

End-to-end in Docker code-server (VS Code extension + real UI):

  • /discover-and-add-mcps → question card renders → answer Yes / Project✅ datamate added to .altimate-code/altimate-code.json (enabled: true) and the chat shows the success summary.
  • Confirmed the webview now receives the event (pendingQuestions populated, was []) and the reply resolves (HTTP 200, message=replied).
  • 55 question unit tests pass (test/question, test/tool/question, test/release-validation/question-937*, test/cli/run/question.shared).

No changes were needed in the vscode-altimate-mcp-server extension.

🤖 Generated with Claude Code


Summary by cubic

Fixes AI-7743: answering the MCP add question in /discover-and-add-mcps no longer hangs. Question state is shared across bundled module copies, and events reach the webview without blocking resolution so the MCP server is added.

  • Bug Fixes
    • Share the pending-question registry on globalThis (keyed by instance directory) with per-instance cleanup via registerDisposer, so ask() and HTTP reply() resolve the same request; cleanup failures are logged.
    • Mirror question.asked / replied / rejected to /event via Bus.publish using BusEvent with zod schemas; mirrors are best-effort and kept off the Deferred critical path—reply()/reject() settle first, then publish.

Written for commit 0b5ecd1. Summary will update on new commits.

Review in cubic

Summary by CodeRabbit

  • New Features

    • Forwarded question lifecycle events (asked, replied, rejected) to the IDE webview event stream.
    • Standardized question handling so it’s shared consistently across modules within the same running application instance.
  • Bug Fixes

    • Pending questions are now properly cleaned up when an instance is disposed.
    • Any still-pending questions are automatically rejected instead of remaining unresolved.

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

Claude Code Review

This repository is configured for manual code reviews. Comment @claude review for a one-time review, or @claude review always to subscribe this PR to a review on every future push.

Tip: disable this comment in your organization's Code Review settings.

@github-actions

Copy link
Copy Markdown

This PR doesn't fully meet our contributing guidelines and PR template.

What needs to be fixed:

  • PR description is missing required template sections. Please use the PR template.

Please edit this PR description to address the above within 2 hours, or it will be automatically closed.

If you believe this was flagged incorrectly, please let a maintainer know.

@github-actions

Copy link
Copy Markdown

Thanks for your contribution!

This PR doesn't have a linked issue. All PRs must reference an existing issue.

Please:

  1. Open an issue describing the bug/feature (if one doesn't exist)
  2. Add Fixes #<number> or Closes #<number> to this PR description

See CONTRIBUTING.md for details.

@coderabbitai

coderabbitai Bot commented Jul 16, 2026

Copy link
Copy Markdown

Review Change Stack

📝 Walkthrough

Walkthrough

Question pending state now uses a process-global, directory-keyed registry with disposal cleanup. Question ask, reply, and reject operations also publish corresponding wildcard Bus events for SSE subscribers.

Changes

Question flow

Layer / File(s) Summary
Shared question state and disposal
packages/opencode/src/question/index.ts
Pending questions move to a global directory-keyed registry; reply, reject, and list use it, while disposal rejects remaining requests and removes the directory entry.
Wildcard question events
packages/opencode/src/question/index.ts
Bus event schemas and publications are added for question.asked, question.replied, and question.rejected, with publish failures logged without interrupting question settlement.

Estimated code review effort: 3 (Moderate) | ~20 minutes

Sequence Diagram(s)

sequenceDiagram
  participant Question
  participant Bus
  participant EventSSE
  Question->>Bus: publish question.asked
  Bus->>EventSSE: deliver wildcard event
  Question->>Bus: publish question.replied or question.rejected
  Bus->>EventSSE: deliver wildcard event
Loading

Poem

I’m a rabbit with questions, hopping in line,
A global burrow keeps each thread fine.
Ask, reply, reject—events now fly,
Through Bus to the webview sky.
When paths close, pending hopes say goodbye.

🚥 Pre-merge checks | ✅ 5
✅ Passed checks (5 passed)
Check name Status Explanation
Docstring Coverage ✅ Passed No functions found in the changed files to evaluate docstring coverage. Skipping docstring coverage check.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
Title check ✅ Passed The title clearly names the AI-7743 bug and the question-hang/MCP add failure, matching the main change.
Description check ✅ Passed The PR description covers the issue, root cause, fix, and verification in detail, but omits the template's explicit type, checklist, and screenshot sections.
✨ Finishing Touches
📝 Generate docstrings
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch fix/AI-7743-mcp-add-question-hang

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@github-actions

Copy link
Copy Markdown

👋 This PR was automatically closed by our quality checks.

Common reasons:

  • New GitHub account with limited contribution history
  • PR description doesn't meet our guidelines
  • Contribution appears to be AI-generated without meaningful review

If you believe this was a mistake, please open an issue explaining your intended contribution and a maintainer will help you.

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

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (1)
packages/opencode/src/question/index.ts (1)

228-240: 🩺 Stability & Availability | 🟠 Major | ⚡ Quick win

Make lifecycle finalization independent of event publication.

A failed publication can leak an asked request or strand a replied/rejected deferred.

  • packages/opencode/src/question/index.ts#L228-L240: wrap publication and awaiting with cleanup that begins immediately after pending.set.
  • packages/opencode/src/question/index.ts#L254-L269: guarantee Deferred.succeed with Effect.ensuring.
  • packages/opencode/src/question/index.ts#L279-L289: guarantee Deferred.fail with Effect.ensuring.
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@packages/opencode/src/question/index.ts` around lines 228 - 240, Make
question lifecycle cleanup independent of publication and deferred completion:
in packages/opencode/src/question/index.ts lines 228-240, wrap publication and
Deferred.await immediately after pending.set with Effect.ensuring that always
deletes the pending request; in lines 254-269, wrap Deferred.succeed with
Effect.ensuring; and in lines 279-289, wrap Deferred.fail with Effect.ensuring
so cleanup runs even when publication or completion fails.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Outside diff comments:
In `@packages/opencode/src/question/index.ts`:
- Around line 228-240: Make question lifecycle cleanup independent of
publication and deferred completion: in packages/opencode/src/question/index.ts
lines 228-240, wrap publication and Deferred.await immediately after pending.set
with Effect.ensuring that always deletes the pending request; in lines 254-269,
wrap Deferred.succeed with Effect.ensuring; and in lines 279-289, wrap
Deferred.fail with Effect.ensuring so cleanup runs even when publication or
completion fails.

ℹ️ Review info
⚙️ Run configuration

Configuration used: Repository UI

Review profile: CHILL

Plan: Pro

Run ID: 22566e2a-fd12-4a52-86af-c5cc09552b64

📥 Commits

Reviewing files that changed from the base of the PR and between 0dc8850 and ab2bd9f.

📒 Files selected for processing (4)
  • packages/opencode/src/question/index.ts
  • packages/opencode/src/server/routes/question.ts
  • packages/opencode/src/tool/plan.ts
  • packages/opencode/src/tool/question.ts

@cubic-dev-ai cubic-dev-ai 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.

No issues found across 4 files

Re-trigger cubic

@kilo-code-bot

kilo-code-bot Bot commented Jul 16, 2026

Copy link
Copy Markdown

Code Review Summary

Status: 1 Issue Found | Recommendation: Merge — 1 optional suggestion

Overview

Severity Count
CRITICAL 0
WARNING 0
SUGGESTION 1

Incremental review of commit 0b5ecd1 — the review-feedback follow-up that resolves all 9 prior findings. The Bus.publish mirror is now best-effort via a mirror() helper (Effect.promise(...).pipe(Effect.catchCause(...))), the Deferred is settled first in reply()/reject() before mirroring, the loose z.any() schemas were replaced with the structured zod() adapter, and the silent .catch(() => {}) now logs a warning.

Verified against the shipped surface:

  • zod(Request) is safezod(Question.Request) already ships in the /question route (server/routes/question.ts:24, :61), so the branded QuestionID/SessionID/MessageID fields don't crash the AST walker.
  • Effect.catchCause is a valid, idiomatic Effect v4 API (40+ in-repo usages, e.g. global-lifecycle.ts:21, lifecycle.ts:51).
  • Disposer safety — the recovered effect can no longer reject, so removing the outer .catch() is safe; the disposer stays idempotent across module copies.
Issue Details (click to expand)

SUGGESTION

File Line Issue
packages/opencode/src/question/index.ts 281 mirror(BusReplied) payload duplicates the adjacent events.publish(Event.Replied) payload; same in reject(). Hoist a shared const.
Files Reviewed (1 file)
  • packages/opencode/src/question/index.ts - 1 suggestion

Fix these issues in Kilo Cloud

Previous Review Summaries (2 snapshots, latest commit cd96fea)

Current summary above is authoritative. Previous snapshots are kept for context only.

Previous review (commit cd96fea)

Status: No Issues Found | Recommendation: Merge

Re-verified after the branch was force-pushed (previous review SHA no longer reachable). The change is now a single self-contained commit on question/index.ts; the previously-reviewed import normalizations were folded in. The AI-7743 fix is correct and verified against the shipped wiring:

  • State sharing: pendingByDir is anchored on globalThis (keyed by instance directory) with ??=, so all bundled copies of question/index.ts share one map; pendingFor(directory) keeps list() per-instance.
  • Disposal hygiene: registerDisposer + Effect.addFinalizer(() => Effect.sync(off)) mirrors the exact pattern already used by InstanceState.make (instance-state.ts:38-39). The disposer runs on Instance.dispose/reload (project/instance.ts:144,135), is idempotent across module copies (shared map; the second copy sees the directory already deleted), and each Deferred.fail is run via Effect.runPromise(...).catch(() => {}).
  • Event delivery: the shipped /event SSE route reads only Bus.subscribeAll (server/routes/event.ts:64), which Bus.publish populates and events.publish/EventV2Bridge does not — so question.asked/replied/rejected reach the IDE webview exactly once. events.publish is still needed (it persists to EventTable and emits sync events that Bus.publish does not), so it is not redundant.
  • No double-delivery: Bus.publish also emits to GlobalBus, but every active question consumer dedupes by id — TUI notifications.ts:36 and sync.tsx:290 both key on the question/request id.
  • Effect correctness: Effect.promise(() => Bus.publish(...)) is yield*-run, never bare-awaited.
Files Reviewed (1 file)
  • packages/opencode/src/question/index.ts

Previous review (commit ab2bd9f)

Status: No Issues Found | Recommendation: Merge

The fix correctly addresses the AI-7743 hang from two angles, both verified against the actual shipped wiring:

  • State sharing: anchoring the pending-question registry on globalThis (keyed by instance directory) makes all bundled module copies of question/index.ts share one map. ??= makes init idempotent across copies, and disposeInstance(directory) is called on instance dispose/reload (project/instance.ts:135,144), so the registerDisposer cleanup runs and the disposer is idempotent if both copies register.
  • Event delivery: the shipped /event SSE route is fed by Bus.subscribeAll (wildcard PubSub), which Bus.publish populates — so BusAsked/Replied/Rejected now reach the IDE webview. events.publish/EventV2Bridge does not feed that PubSub, so there's no double-delivery.
  • Effect/Promise correctness: Effect.promise(() => Bus.publish(...)) is properly yield*-run (not bare-awaited), and Deferred.fail in the disposer is executed via Effect.runPromise.

Import normalizations (../question@/question) are mechanical and consistent.

Files Reviewed (4 files)
  • packages/opencode/src/question/index.ts
  • packages/opencode/src/server/routes/question.ts
  • packages/opencode/src/tool/plan.ts
  • packages/opencode/src/tool/question.ts

Reviewed by glm-5.2 · Input: 76.1K · Output: 29.6K · Cached: 806.8K

Review guidance: REVIEW.md from base branch main

…bus across bundled module copies

`/discover-and-add-mcps` rendered the "which MCP / what scope" question but,
after answering, the chat sat on "Thinking…" and the server was never added.

Root cause: after the v1.17.9 upstream merge, `src/question/index.ts` is bundled
as TWO separate module instances (a module-scoped id differs between the tool's
`ask()` and the HTTP route's `reply()`; Bun does not reliably dedupe it, and
normalizing imports does NOT change that). Each copy ran its own `makeRuntime`
runtime, which broke the flow two independent ways:

1. State split — the `question` tool registered the pending `Deferred` in one
   copy's `InstanceState` map, while `POST /question/:id/reply` looked it up in
   the OTHER copy's empty map. The reply 404'd and the `Deferred` never resolved,
   so the agent loop blocked forever ("Thinking…").
2. Event never reached the IDE webview — `question.asked` was published only via
   `EventV2Bridge` -> `GlobalBus`, but the `/event` SSE stream the webview reads
   is fed by the Bus *wildcard PubSub* (`Bus.publish`). So `pendingQuestions`
   stayed empty and the answer card's Submit had no request id -> submitting did
   nothing.

Fix (self-contained in `question/index.ts`):
- Anchor the pending-question registry on `globalThis` (keyed by instance
  directory) so every bundled module copy shares one map. Restore per-instance
  cleanup via `registerDisposer` so entries don't leak across instances/tests.
- Publish `question.asked`/`replied`/`rejected` via `Bus.publish` (added
  `BusEvent` mirrors) so they reach `/event` like every other webview-visible
  event.

Every divergence from upstream is wrapped in `altimate_change start/end`
markers (14/14 balanced) so future upstream merges are handled correctly;
`--require-markers --strict` and the marker-integrity tests pass.

Verified end-to-end in code-server: discover -> answer (Yes / Project) ->
datamate written to `.altimate-code/altimate-code.json` (enabled) and the chat
shows the success summary. Marker-integrity + question tests pass (134).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
@saravmajestic
saravmajestic force-pushed the fix/AI-7743-mcp-add-question-hang branch from ab2bd9f to cd96fea Compare July 16, 2026 05:28
@github-actions

Copy link
Copy Markdown

👋 This PR was automatically closed by our quality checks.

Common reasons:

  • New GitHub account with limited contribution history
  • PR description doesn't meet our guidelines
  • Contribution appears to be AI-generated without meaningful review

If you believe this was a mistake, please open an issue explaining your intended contribution and a maintainer will help you.

@saravmajestic saravmajestic self-assigned this Jul 16, 2026

@dev-punia-altimate dev-punia-altimate 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.

🤖 Code Review — OpenCodeReview (Gemini) — 4 finding(s)

  • 4 anchored to a line (posted inline when the comment stream is on)
  • 0 without a line anchor
All findings (full text)

1. packages/opencode/src/question/index.ts (L266-L274)

[🔴 HIGH] If Bus.publish throws or rejects (e.g., due to bus errors), this Effect will terminate early, and Deferred.succeed will never be reached. This will cause the original ask operation that awaits this deferred to hang indefinitely.

Consider decoupling side-effects like event mirroring by using a non-blocking fork (e.g., Effect.fork(Effect.promise(...))), or placing it after Deferred.succeed, or applying explicit error handling (e.g., Effect.ignoreLogged / Effect.catchAllDefect) so it doesn't block the core business logic.

Suggested change:

      // altimate_change start — mirror on the Bus wildcard for /event (webview) clients.
      yield* Effect.fork(Effect.promise(() =>
        Bus.publish(BusReplied, {
          sessionID: existing.info.sessionID,
          requestID: existing.info.id,
          answers: input.answers.map((a) => [...a]),
        }),
      ))
      // altimate_change end

2. packages/opencode/src/question/index.ts (L293-L297)

[🔴 HIGH] Similarly, if Bus.publish throws or rejects here, the Effect will terminate early, preventing Deferred.fail from being executed and leaving the pending ask operation hanging indefinitely.

Consider placing this in a non-blocking fork or adding explicit error handling to avoid blocking the rejection resolution.

Suggested change:

      // altimate_change start — mirror on the Bus wildcard for /event (webview) clients.
      yield* Effect.fork(Effect.promise(() =>
        Bus.publish(BusRejected, { sessionID: existing.info.sessionID, requestID: existing.info.id }),
      ))
      // altimate_change end

3. packages/opencode/src/question/index.ts (L110-L118)

[🟠 MEDIUM] The use of any type (z.any()) is strictly prohibited by our review checklist unless explicitly justified. Using any bypasses Zod's validation and type inference, potentially allowing unexpected data structures to propagate.

Please use z.unknown() or define a stricter Zod schema that matches the expected structure of Question.Info to maintain type safety.

Suggested change:

const BusAsked = BusEvent.define(
  "question.asked",
  z.object({
    id: QuestionID.zod,
    sessionID: SessionID.zod,
    questions: z.array(z.unknown()),
    tool: z.object({ messageID: MessageID.zod, callID: z.string() }).optional(),
  }),
)

4. packages/opencode/src/question/index.ts (L204-L206)

[🟠 MEDIUM] This promise resolution contains an empty catch block: .catch(() => {}). This silently swallows any unexpected errors that might occur when Effect.runPromise executes, which violates error-handling best practices and can obscure underlying bugs during instance cleanup.

Consider logging the error explicitly instead of silently ignoring it, e.g., .catch((e) => log.error(e)).

Suggested change:

      for (const { deferred } of map.values()) {
        await Effect.runPromise(Deferred.fail(deferred, new RejectedError())).catch(console.error)
      }

Comment thread packages/opencode/src/question/index.ts Outdated
Comment on lines +266 to +274
// altimate_change start — mirror on the Bus wildcard for /event (webview) clients.
yield* Effect.promise(() =>
Bus.publish(BusReplied, {
sessionID: existing.info.sessionID,
requestID: existing.info.id,
answers: input.answers.map((a) => [...a]),
}),
)
// altimate_change end

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.

[🔴 HIGH] If Bus.publish throws or rejects (e.g., due to bus errors), this Effect will terminate early, and Deferred.succeed will never be reached. This will cause the original ask operation that awaits this deferred to hang indefinitely.

Consider decoupling side-effects like event mirroring by using a non-blocking fork (e.g., Effect.fork(Effect.promise(...))), or placing it after Deferred.succeed, or applying explicit error handling (e.g., Effect.ignoreLogged / Effect.catchAllDefect) so it doesn't block the core business logic.

Suggested change:

Suggested change
// altimate_change start — mirror on the Bus wildcard for /event (webview) clients.
yield* Effect.promise(() =>
Bus.publish(BusReplied, {
sessionID: existing.info.sessionID,
requestID: existing.info.id,
answers: input.answers.map((a) => [...a]),
}),
)
// altimate_change end
// altimate_change start — mirror on the Bus wildcard for /event (webview) clients.
yield* Effect.fork(Effect.promise(() =>
Bus.publish(BusReplied, {
sessionID: existing.info.sessionID,
requestID: existing.info.id,
answers: input.answers.map((a) => [...a]),
}),
))
// altimate_change end

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Fixed in 0b5ecd12f. Added a best-effort mirror() helper — Effect.promise(() => Bus.publish(...)) recovered with Effect.catchCause to a logged warning — so a publish failure can never abort the fiber. The Deferred is now settled first, then the mirror fires, so settlement can't be skipped. Applied to ask(), reply(), and reject().

Comment thread packages/opencode/src/question/index.ts Outdated
Comment on lines +293 to +297
// altimate_change start — mirror on the Bus wildcard for /event (webview) clients.
yield* Effect.promise(() =>
Bus.publish(BusRejected, { sessionID: existing.info.sessionID, requestID: existing.info.id }),
)
// altimate_change end

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.

[🔴 HIGH] Similarly, if Bus.publish throws or rejects here, the Effect will terminate early, preventing Deferred.fail from being executed and leaving the pending ask operation hanging indefinitely.

Consider placing this in a non-blocking fork or adding explicit error handling to avoid blocking the rejection resolution.

Suggested change:

Suggested change
// altimate_change start — mirror on the Bus wildcard for /event (webview) clients.
yield* Effect.promise(() =>
Bus.publish(BusRejected, { sessionID: existing.info.sessionID, requestID: existing.info.id }),
)
// altimate_change end
// altimate_change start — mirror on the Bus wildcard for /event (webview) clients.
yield* Effect.fork(Effect.promise(() =>
Bus.publish(BusRejected, { sessionID: existing.info.sessionID, requestID: existing.info.id }),
))
// altimate_change end

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Fixed in 0b5ecd12f. Added a best-effort mirror() helper — Effect.promise(() => Bus.publish(...)) recovered with Effect.catchCause to a logged warning — so a publish failure can never abort the fiber. The Deferred is now settled first, then the mirror fires, so settlement can't be skipped. Applied to ask(), reply(), and reject().

Comment thread packages/opencode/src/question/index.ts Outdated
Comment on lines +110 to +118
const BusAsked = BusEvent.define(
"question.asked",
z.object({
id: QuestionID.zod,
sessionID: SessionID.zod,
questions: z.array(z.any()),
tool: z.object({ messageID: MessageID.zod, callID: z.string() }).optional(),
}),
)

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.

[🟠 MEDIUM] The use of any type (z.any()) is strictly prohibited by our review checklist unless explicitly justified. Using any bypasses Zod's validation and type inference, potentially allowing unexpected data structures to propagate.

Please use z.unknown() or define a stricter Zod schema that matches the expected structure of Question.Info to maintain type safety.

Suggested change:

Suggested change
const BusAsked = BusEvent.define(
"question.asked",
z.object({
id: QuestionID.zod,
sessionID: SessionID.zod,
questions: z.array(z.any()),
tool: z.object({ messageID: MessageID.zod, callID: z.string() }).optional(),
}),
)
const BusAsked = BusEvent.define(
"question.asked",
z.object({
id: QuestionID.zod,
sessionID: SessionID.zod,
questions: z.array(z.unknown()),
tool: z.object({ messageID: MessageID.zod, callID: z.string() }).optional(),
}),
)

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Fixed in 0b5ecd12f. Went further than z.unknown() per @sahrizvi's related note — the Bus schemas now reuse the structured Effect schemas via the zod adapter (zod(Request)), so the generated /event payload matches the SDK's typed shape.

Comment on lines +204 to +206
for (const { deferred } of map.values()) {
await Effect.runPromise(Deferred.fail(deferred, new RejectedError())).catch(() => {})
}

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.

[🟠 MEDIUM] This promise resolution contains an empty catch block: .catch(() => {}). This silently swallows any unexpected errors that might occur when Effect.runPromise executes, which violates error-handling best practices and can obscure underlying bugs during instance cleanup.

Consider logging the error explicitly instead of silently ignoring it, e.g., .catch((e) => log.error(e)).

Suggested change:

Suggested change
for (const { deferred } of map.values()) {
await Effect.runPromise(Deferred.fail(deferred, new RejectedError())).catch(() => {})
}
for (const { deferred } of map.values()) {
await Effect.runPromise(Deferred.fail(deferred, new RejectedError())).catch(console.error)
}

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Fixed in 0b5ecd12f. The dispose cleanup now recovers the cause with Effect.catchCause and logs Effect.logWarning("question cleanup failed on dispose", { cause }) instead of swallowing it — kept in Effect-land rather than console.error.

@dev-punia-altimate

Copy link
Copy Markdown
Contributor

🤖 Code Review — OpenCodeReview (Gemini) — No Issues Found

No supported files changed.

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

7-model consensus review — verdict: REQUEST CHANGES

Panel: Claude + GPT Codex, Kimi K2.5, Qwen 3.6, MiniMax M2.7, GLM-5.1, MiMo V2.5 (Gemini excluded — auth failure). Quorum 6/6 met.
Tests: bun test test/question/question.test.ts14/14 pass in the PR worktree.

Root-cause diagnosis and the two-part fix (globalThis pending registry + Bus.publish mirrors) are correct and unanimously praised. Architecture is sound.

One must-fix (MAJOR #1, unanimous across all 7 models) is inline. Other MINOR findings are inline where a diff line applies; the remainder are below.


MINOR #4 — No regression test for the actual Bus-mirror fix

waitForPending in test/question/question.test.ts listens on the EventV2 path, not Bus. Nothing asserts Bus.publish/Bus.subscribeAll delivery — deleting the three mirror lines would leave every test green. Add a test that subscribes via Bus.subscribeAll (or Bus.subscribe(BusAsked)) and asserts question.asked/replied/rejected arrive after ask/reply/reject. Ideally also: a test that reply/reject still settle the Deferred when Bus.publish fails (guards MAJOR #1).

MINOR #6 — Shutdown/disposal semantics + globalThis anchor asymmetry

The old InstanceState finalizer failed all pending Deferreds when the layer scope closed; the new layer finalizer only calls off() (unregister) and relies on registerDisposer firing per-directory. Closing a Question.layer scope without going through disposeInstance(directory) now leaves pending fibers unresolved and entries in the global map. Negligible on full-process shutdown, but a behaviour delta.

Related: state is anchored on globalThis to survive module duplication, but the disposer registers into the module-scoped disposers Set in instance-registry.ts — if that module is ever duplicated the same way, cleanup silently no-ops. This is an inherited InstanceState assumption, but the PR's own thesis is that the assumption isn't safe. Consider a per-layer ownership token that drains only its own entries, and/or anchoring disposers on globalThis too.

NITs

  • (globalThis as Record<string, unknown>)[...] as PendingByDir — a non-Map value at that key would crash; a declare global augmentation or an instanceof Map guard is safer.
  • Effect.runPromise(Deferred.fail(...)).catch(() => {}) in the disposer spins a fresh runtime per deferred and swallows any future requirement; Effect.runFork(...) is lighter. Deferred.fail is currently R = never, so the .catch guards a non-event.

Adjudicated / rejected claims (checked against source)

  • Kimi — "Deferred.fail throws if the Deferred is already completed": false. In Effect, completing an already-settled Deferred returns false; it does not throw. The disposer loop is safe.
  • Qwen — "race condition in pendingFor on concurrent asks": false. pendingFor is fully synchronous (no yield), so two fibers cannot interleave its get-or-create in single-threaded JS.
  • MiniMax — CRITICAL "reply()/reject() miss the directory lookup": false and self-refuting — both call pendingFor(yield* InstanceState.directory), identical to ask(). Not an issue.

Positive observations (consensus)

  • Root cause precisely diagnosed and documented inline; altimate_change markers balanced; change is minimal and confined to one file.
  • The globalThis registry keyed by instance directory is the correct, robust fix for the split-module pending map and preserves per-instance list() semantics.
  • Both delivery channels (EventV2 and Bus wildcard) are genuinely needed; the PR correctly identifies they are separate.
  • Existing dispose/reload tests pass, validating the registerDisposer cleanup path.

Convergence: all 7 reviewers independently surfaced finding #1 without prompting — no second round needed. Disagreements on severity (#2, #6) and three false-positive claims were adjudicated by reading the source, not by vote.

Comment thread packages/opencode/src/question/index.ts Outdated
Comment on lines +234 to +236
yield* Effect.promise(() =>
Bus.publish(BusAsked, { id, sessionID: input.sessionID, questions: [...input.questions], tool: input.tool }),
)

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.

[MAJOR] Bus mirror on the Deferred critical path — can reintroduce the exact hang this PR fixes.

Effect.promise converts a promise rejection into an unrecoverable defect that aborts the fiber. This mirror sits before the Effect.ensuring(Deferred.await, pending.delete) block at lines 239-244 that registers cleanup, so if Bus.publish ever rejects:

  • the awaiting Question.ask() fiber is aborted before it registers the cleanup finalizer;
  • the pending entry stays in the global registry until instance disposal (MINOR chore(deps): Bump @gitlab/gitlab-ai-provider from 3.6.0 to 4.1.0 #5 — same root cause, folded here);
  • and any dependent Deferred is never settled → tool hangs on "Thinking…" — the exact failure class this PR was written to fix.

A fire-and-forget notification must never be able to abort core question settlement.

Required fix — make the mirror best-effort (also apply at reply()/reject()):

const mirror = <D extends BusEvent.Definition>(def: D, props: z.output<D["properties"]>) =>
  Effect.promise(() => Bus.publish(def, props)).pipe(
    Effect.catchAllCause((cause) =>
      Effect.logWarning("question bus mirror failed", { type: def.type, cause }),
    ),
  )
// ...
yield* mirror(BusAsked, { ... })

.pipe(Effect.ignore) is the minimal form. Prefer settling the Deferred first, then mirroring.

Flagged by all 7 reviewers. Probability of a Bus.publish rejection is low, but the impact is a permanent hang and the fix is trivial — every model rated this a blocker.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Fixed in 0b5ecd12f. Added a best-effort mirror() helper — Effect.promise(() => Bus.publish(...)) recovered with Effect.catchCause to a logged warning — so a publish failure can never abort the fiber. The Deferred is now settled first, then the mirror fires, so settlement can't be skipped. Applied to ask(), reply(), and reject().

Comment thread packages/opencode/src/question/index.ts Outdated
Comment on lines +267 to +273
yield* Effect.promise(() =>
Bus.publish(BusReplied, {
sessionID: existing.info.sessionID,
requestID: existing.info.id,
answers: input.answers.map((a) => [...a]),
}),
)

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.

[MAJOR] Same Bus-mirror-on-critical-path issue as ask() — this one is worse because the mirror runs after pending.delete(input.requestID) on line 259 but before Deferred.succeed on line 275.

If Bus.publish rejects:

  • Effect.promise converts the rejection to an unrecoverable fiber defect;
  • the Deferred is never succeeded → the awaiter of Question.ask() hangs forever;
  • the pending entry is already deleted, so a retried reply returns NotFoundError — unrecoverable.

Required fix — settle the Deferred first, then fire the mirror best-effort:

yield* Deferred.succeed(existing.deferred, input.answers)
yield* mirror(BusReplied, { ... })  // Effect.promise + Effect.catchAllCause

See the MAJOR comment on ask() for the mirror helper.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Fixed in 0b5ecd12f. Added a best-effort mirror() helper — Effect.promise(() => Bus.publish(...)) recovered with Effect.catchCause to a logged warning — so a publish failure can never abort the fiber. The Deferred is now settled first, then the mirror fires, so settlement can't be skipped. Applied to ask(), reply(), and reject().

Comment thread packages/opencode/src/question/index.ts Outdated
Comment on lines +294 to +296
yield* Effect.promise(() =>
Bus.publish(BusRejected, { sessionID: existing.info.sessionID, requestID: existing.info.id }),
)

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.

[MAJOR] Same critical-path Bus mirror as ask()/reply(). Mirror runs after pending.delete(requestID) on line 287 but before Deferred.fail on line 298.

On Bus.publish rejection: fiber aborts before Deferred.fail, awaiter hangs, pending entry already gone → unrecoverable.

Required fix — settle first, then fire the mirror best-effort:

yield* Deferred.fail(existing.deferred, new RejectedError())
yield* mirror(BusRejected, { ... })

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Fixed in 0b5ecd12f. Added a best-effort mirror() helper — Effect.promise(() => Bus.publish(...)) recovered with Effect.catchCause to a logged warning — so a publish failure can never abort the fiber. The Deferred is now settled first, then the mirror fires, so settlement can't be skipped. Applied to ask(), reply(), and reject().

Comment thread packages/opencode/src/question/index.ts Outdated
z.object({
id: QuestionID.zod,
sessionID: SessionID.zod,
questions: z.array(z.any()),

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.

[MINOR] z.any() weakens the published contract.

/event's OpenAPI is generated from BusEvent.payloads(), so the documented question.asked payload becomes questions: any — diverging from the SDK's typed Array<QuestionInfo>. Apply the same to BusReplied.answers (z.array(z.array(z.string()))) which is looser than Array<Answer>.

Reuse the structured schema instead. The zod adapter from util/effect-zod is already used by the question route:

const BusAsked = BusEvent.define("question.asked", zod(Request))
const BusReplied = BusEvent.define("question.replied", zod(Replied))
const BusRejected = BusEvent.define("question.rejected", zod(Rejected))

Flagged by Kimi, Qwen, GPT, MiniMax, GLM-5, MiMo.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Fixed in 0b5ecd12f. Replaced the hand-written loose schemas with zod(Request) / zod(Replied) / zod(Rejected) from util/effect-zod, so the /event OpenAPI payloads now match Array<QuestionInfo> / Array<Answer> instead of any.

Comment thread packages/opencode/src/question/index.ts Outdated
Comment on lines +100 to +113
// altimate_change start — BusEvent mirrors of the question events.
//
// The EventV2 `Event` defs above publish to GlobalBus/EventV2 consumers only.
// The IDE webview subscribes to the `/event` SSE route, which is fed by the Bus
// *wildcard PubSub* (`Bus.publish`), a different channel. So `question.asked`
// never reached the webview → `pendingQuestions` stayed empty → the mcp-add
// question card had no request id to reply with → "submit does nothing".
// Publish these via `Bus.publish` too so they reach /event like every other
// webview-visible event. (Schemas are loose — Bus.publish does not re-validate;
// they exist for the `type` string and typing.)
const BusAsked = BusEvent.define(
"question.asked",
z.object({
id: QuestionID.zod,

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.

[MINOR] Question events are now double-emitted on GlobalBus.

EventV2Bridge.listen already forwards every EventV2 event (incl. question.asked/replied/rejected) to GlobalBus, and Bus.publish here also emits to GlobalBus. So every GlobalBus//global/event consumer now receives each question event twice (with different top-level ids).

Verified impact — not the harm GLM-5 suspected: the TUI trace consumer (trace-consumer.ts handleEvent) only handles message.*/session.* and ignores question events entirely, so there is no double trace recording. TUI sync.tsx reconciles by request.id and notifications.ts dedupes via a Set<id> — both idempotent. No in-repo consumer breaks today.

Residual risk is only for external /global/event subscribers / side-effecting plugins that don't dedupe. Making the mirror best-effort (MAJOR #1) plus a one-line comment acknowledging the double is sufficient here; a cleaner long-term option is a local-only Bus publish that doesn't re-emit to GlobalBus.

Flagged by Claude, GPT, MiniMax, GLM-5, MiMo.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Acknowledged in 0b5ecd12f. Kept the mirror (best-effort now) and added a comment documenting the intentional GlobalBus double-emit and your verification that no in-repo consumer is affected (TUI reconciles by request id, notifications dedupe by id, trace consumer ignores question events). Left the local-only-publish refactor as a future option.

@github-actions

Copy link
Copy Markdown

👋 This PR was automatically closed by our quality checks.

Common reasons:

  • New GitHub account with limited contribution history
  • PR description doesn't meet our guidelines
  • Contribution appears to be AI-generated without meaningful review

If you believe this was a mistake, please open an issue explaining your intended contribution and a maintainer will help you.

Address review feedback (flagged by all reviewers): the `Bus.publish` mirrors
added for /event (webview) clients sat on the Deferred critical path. Since
`Effect.promise` converts a promise rejection into an unrecoverable fiber
defect, a `Bus.publish` rejection could abort the fiber before
`Deferred.succeed`/`Deferred.fail` ran — leaving the awaiting `Question.ask()`
hung on "Thinking…", the exact failure this PR fixes.

- Add a best-effort `mirror()` helper: `Effect.promise(() => Bus.publish(...))`
  recovered with `Effect.catchCause` to a logged warning, so a publish failure
  can never abort core question settlement.
- Settle the Deferred FIRST, then mirror, in `reply()` and `reject()`.
- `ask()` uses `mirror()` too, so a publish failure can't abort it before the
  cleanup finalizer is registered.
- Define the Bus mirror schemas from the structured Effect schemas via the
  `zod` adapter (`zod(Request)` / `zod(Replied)` / `zod(Rejected)`) so the
  generated /event OpenAPI payloads match the SDK types (no `any`/loose shapes).
- Log (not silently swallow) failures in the instance-dispose cleanup.
- Document the intentional GlobalBus double-emit (verified harmless in-repo).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
@saravmajestic
saravmajestic force-pushed the fix/AI-7743-mcp-add-question-hang branch from 903c510 to 0b5ecd1 Compare July 23, 2026 05:16
@github-actions

Copy link
Copy Markdown

👋 This PR was automatically closed by our quality checks.

Common reasons:

  • New GitHub account with limited contribution history
  • PR description doesn't meet our guidelines
  • Contribution appears to be AI-generated without meaningful review

If you believe this was a mistake, please open an issue explaining your intended contribution and a maintainer will help you.

1 similar comment
@github-actions

Copy link
Copy Markdown

👋 This PR was automatically closed by our quality checks.

Common reasons:

  • New GitHub account with limited contribution history
  • PR description doesn't meet our guidelines
  • Contribution appears to be AI-generated without meaningful review

If you believe this was a mistake, please open an issue explaining your intended contribution and a maintainer will help you.

yield* Deferred.succeed(existing.deferred, input.answers)
// altimate_change start — mirror on the Bus wildcard for /event (webview) clients,
// AFTER settling the Deferred and best-effort so a publish failure can't re-hang ask().
yield* mirror(BusReplied, {

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

SUGGESTION: The mirror(BusReplied, ...) object literal duplicates the events.publish(Event.Replied, ...) payload directly above it -- identical sessionID, requestID, and answers: input.answers.map((a) => [...a]). Hoisting a shared const replied = { ... } and passing it to both keeps the two channels (EventTable/GlobalBus vs /event SSE) from silently drifting. The same pattern repeats in reject() (lines 300-303 vs 307).


Reply with @kilocode-bot fix it to have Kilo Code address this issue.

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

Approving — the question-hang fix is solid. The Bus mirror is now correctly kept off the Deferred settlement path (best-effort recovery via catchCause, and reply()/reject() settle before mirroring), the event schemas are structured via zod(Request/Replied/Rejected) instead of z.any(), and the duplicate emission on GlobalBus is documented and harmless for current consumers. Typecheck, the question suite, and the /event OpenAPI generation all pass.

Two non-blocking follow-ups for a separate PR:

1. Add a regression test for the Bus mirror. The fix's core invariant — a publish failure can't re-hang the tool — currently has no automated guard (deleting the three mirror lines leaves every test green). Suggested coverage:

  • subscribe via Bus.subscribeAll and assert question.asked / question.replied / question.rejected are delivered after ask / reply / reject; and
  • inject a Bus.publish failure and assert reply() / reject() still settle the Deferred.

2. Same pattern exists (pre-existing) at session/session.ts:924. updatePartDelta publishes via the identical unguarded call:

yield* Effect.promise(() => Bus.publish(MessageV2.Event.PartDelta, input))

Effect.promise turns a promise rejection into an unrecoverable fiber defect, so a Bus.publish rejection here would abort the streaming fiber rather than no-op — the same class this PR fixes for questions (lower impact: a streaming error rather than a stranded Deferred). Suggest routing it through the same best-effort recovery, ideally by extracting the mirror helper into a shared Bus utility so every Effect.promise(() => Bus.publish(...)) site shares one hardened path.

Minor: mirror uses Effect.catchCause, which also catches interrupts. Consider catchCauseIf(cause => !Cause.hasInterrupts(cause), …) so an interrupted fiber isn't logged as "mirror failed" and swallowed — matches the existing observe() pattern in event.ts.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants