Skip to content

fix(workspace-server): cascade-delete race + ACTIVITY_LOGGED body fidelity - #2188

Merged
hongmingwang-moleculeai merged 2 commits into
stagingfrom
fix/cascade-stop-removal-in-progress
Apr 27, 2026
Merged

fix(workspace-server): cascade-delete race + ACTIVITY_LOGGED body fidelity#2188
hongmingwang-moleculeai merged 2 commits into
stagingfrom
fix/cascade-stop-removal-in-progress

Conversation

@HongmingWang-Rabbit

Copy link
Copy Markdown
Contributor

Summary

Cascade-deleting a 7-workspace org returned 500 with `workspace marked removed, but 2 stop call(s) failed — please retry: stop X: force-remove ws-X: Error response from daemon: removal of container ws-X is already in progress` even though the DB post-condition succeeded (`removed_count=7`) and the containers were in fact gone shortly after.

The platform fans out `Stop()` on every workspace in parallel, and the orphan sweeper happened to reap two of them at the same instant. Docker rejects the second `ContainerRemove` for the same container with `removal already in progress` — a race-condition ack, not a real failure. Retrying just races the same in-flight removal.

The post-condition (container WILL be gone) is identical to a successful removal, so `Stop()` should treat the race-ack the same way it already treats `No such container`: a no-op `return nil` that lets the caller proceed with volume cleanup. Real daemon failures (timeout, EOF, context cancel) still surface as errors.

Changes

  • New `isRemovalInProgress()` predicate (string-match, same approach as `isContainerNotFound` — docker/docker has no typed errdef; the CLI itself relies on the message text).
  • `Stop()` treats the predicate as success with its own log line so debug can tell the two races apart.
  • 7 new test cases pin the truth table — including the both-substrings-required rule (`removal of container` + `already in progress`) so we don't false-positive on unrelated `image pull is already in progress`-style messages.

Test plan

  • All 8 existing `TestIsContainerNotFound` cases still pass
  • 7 new `TestIsRemovalInProgress` cases pass
  • Platform rebuilt and serving locally on http://localhost:8080
  • Manual verification: cascade-delete a multi-workspace org, expect `200 OK` with `removed_count=N` and no `stop_failures`
  • CI green

🤖 Generated with Claude Code

Cascade-deleting a 7-workspace org returned 500 with

  "workspace marked removed, but 2 stop call(s) failed — please retry:
   stop eeb99b5d-...: force-remove ws-eeb99b5d-607: Error response
   from daemon: removal of container ws-eeb99b5d-607 is already in
   progress"

even though the DB-side post-condition succeeded (removed_count=7) and
the containers WERE removed shortly after. The fanout fired Stop() on
every workspace concurrently and the orphan sweeper happened to reap
two of them at the same instant, so Docker rejected the second
ContainerRemove with "removal already in progress" — a race-condition
ack, not a real failure. Retrying just races the same in-flight
removal.

The post-condition we care about (the container WILL be gone) is
identical to a successful removal, so Stop() should treat it the
same way it already treats "No such container" — a no-op return nil
that lets the caller proceed with volume cleanup. Real daemon
failures (timeout, EOF, ctx cancel) still surface as errors.

Two pieces:

  - New isRemovalInProgress() predicate using the same string-match
    approach as isContainerNotFound (docker/docker has no typed
    errdef for this; the CLI itself relies on the message).

  - Stop() now treats the predicate as success, with a log line
    distinct from the not-found path so debugging can tell which
    race fired.

Both substrings ("removal of container" + "already in progress") must
match — "already in progress" alone would false-positive on unrelated
operations like image pulls. Truth table pinned in 7 new test cases.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…adcast

Canvas Agent Comms bubbles for outbound delegation showed only
"Delegating to <peer>" boilerplate during the live update window —
the actual task text only surfaced after a refresh re-fetched the row
from /workspaces/:id/activity. Symptom flagged today during a fresh
delegation manual test where the bubble said "Delegating to Perf
Auditor" instead of the user's "audit moleculesai.app for
performance" prompt.

Root cause: LogActivity's broadcast payload at activity.go:510-518
deliberately omitted request_body and response_body, so the canvas's
live-update path (AgentCommsPanel.tsx:271-289) saw `p.request_body =
undefined` and toCommMessage fell back to the
`Delegating to ${peerName}` template string. The DB row stored the
real task / reply, which is why GET-on-mount worked.

Fix: include both bodies in the broadcast as json.RawMessage values
(no re-marshal cost — they were already encoded for the DB insert
above). Same pattern as tool_trace, which has been included since #1814.

Each side is bounded by the workspace-side caller's own caps: the
runtime's report_activity helper caps error_detail at 4096 chars and
summary at 256; request/response are constrained by the runtime's
own limits — typical delegate_task payload is hundreds of chars to a
few KB. If a much-larger broadcast becomes a concern later, a soft
cap can be added at this site without breaking the contract.

Two regression tests pin the broadcast shape:
- request_body present → canvas renders the actual task text
- response_body present → canvas renders the actual reply text
- response_body nil → omitted from payload (no empty-bubble flicker)

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
@HongmingWang-Rabbit HongmingWang-Rabbit changed the title fix(provisioner): treat "removal already in progress" as no-op success fix(workspace-server): cascade-delete race + ACTIVITY_LOGGED body fidelity Apr 27, 2026
Merged via the queue into staging with commit b5441b8 Apr 27, 2026
16 of 17 checks passed
HongmingWang-Rabbit pushed a commit that referenced this pull request Jun 12, 2026
…provider catalog — platform bucket + correct llm_provider for moonshot/kimi-k2.6' (#2188) from fix/create-dialog-registry-provider-catalog into main
HongmingWang-Rabbit pushed a commit that referenced this pull request Jun 12, 2026
…t/kimi NOT_CONFIGURED

The moonshot/kimi incident: a canvas-created claude-code workspace with
provider=Platform + model=moonshot/kimi-k2.6 booted NOT_CONFIGURED in prod
because the generated config.yaml lacked the manifest-derived `provider:`
key, so the adapter slash-split "moonshot/..." -> unregistered provider.
Fixed by #2187 (ensureDefaultConfig stamps DeriveProvider->provider:platform)
+ #2188 (canvas). Unit tests passed; the REAL boot path was the gap.

This adds comprehensive regression coverage so the CLASS cannot reship:

Deterministic (no live infra, runs in the normal unit suite):
  workspace-server/internal/handlers/workspace_provision_platform_boot_test.go
  - TestEnsureDefaultConfig_StampsProviderForEverySSOTPlatformModel:
    enumerates the claude-code `platform` arm from the providers SSOT
    (providers.LoadManifest) and asserts ensureDefaultConfig stamps
    provider:platform (top-level AND runtime_config) for EVERY offered
    platform model — not just the single moonshot/kimi pin #2187 shipped.
    A newly-offered platform model gets a case for free and only passes if
    actually stamped (closes the offered-but-not-stamped divergence the bug
    rode in on). Mutation-verified: disabling the stamp fails the test.
  - TestPlatformModelDeriveProvider_SSOTConsistency: the upstream half —
    DeriveProvider maps every SSOT platform model to provider Name "platform".

Real-boot (staging; I will run it):
  Extends the existing staging harness (no new harness) with a
  platform-managed path: E2E_LLM_PATH=platform pin-selects moonshot/kimi-k2.6,
  sends NO tenant key, and reuses the harness's online-wait + completion
  assertions to prove the workspace reaches status=online (not
  not_configured) and a completion returns 200. The BYOK branches never
  exercised the platform arm — the exact arm the bug shipped on.
  - tests/e2e/lib/model_slug.sh: platform path + override semantics
  - tests/e2e/test_model_slug.sh: 4 new pinned cases (16/16 green)
  - tests/e2e/test_staging_full_saas.sh: empty-secrets platform branch
  - .gitea/workflows/e2e-staging-saas.yml: new `E2E Staging Platform Boot`
    job (continue-on-error during de-flake; bp-required: pending #2187),
    + providers.yaml/model_slug.sh added to the path triggers.

Coverage-audit theme: mc#1982 (continue-on-error masks; de-flake-then-gate).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
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.

1 participant