Skip to content

fix(server): stop CI Check failure from a manual Effect.runPromise in tests - #45

Merged
awtprod merged 1 commit into
mainfrom
fix/effect-runpromise-lint
Aug 17, 2026
Merged

fix(server): stop CI Check failure from a manual Effect.runPromise in tests#45
awtprod merged 1 commit into
mainfrom
fix/effect-runpromise-lint

Conversation

@awtprod

@awtprod awtprod commented Aug 17, 2026

Copy link
Copy Markdown
Owner

CI's repo-wide `Check` job (`vp check`) has been failing on `main` since #38 landed, blocking every open PR's status checks — including four unrelated in-flight sandbox-isolation PRs (#41, #42, #43, #44).

The failure is two `t3code(no-manual-effect-runtime-in-tests)` lint errors in `ProviderCommandReactor.test.ts`, both inside the same test (`"surfaces non-resumable provider user-input callbacks as stale failures"`), which used manual `await Effect.runPromise(...)` instead of the `@effect/vitest` `it.effect(...)` pattern already established elsewhere in this file and in `SandboxLifecycleReactor.test.ts`.

Converted just that one test block to `it.effect(...)` + `Effect.gen`, matching the existing convention. No other test in the file was touched — the other ~70 `Effect.runPromise` calls elsewhere in the file aren't flagged by the linter and are out of scope here.

`vp check` now reports 0 errors; the full file's 49 tests still pass.

claude-sonnet-5 via Claude Code

CI's repo-wide lint fails on origin/main for two Effect.runPromise calls
inside the "surfaces non-resumable provider user-input callbacks as
stale failures" test, added by an earlier merge before the
no-manual-effect-runtime-in-tests rule applied to this file. Convert
just that test block to it.effect(...) with Effect.gen, matching the
established pattern already used elsewhere in this same file (e.g.
"settles a failed provider startup and allows a clean retry").

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
@awtprod

awtprod commented Aug 17, 2026

Copy link
Copy Markdown
Owner Author

@coderabbitai review

@awtprod

awtprod commented Aug 17, 2026

Copy link
Copy Markdown
Owner Author

@codex review

@github-actions github-actions Bot added vouch:trusted PR author is trusted by repo permissions or the VOUCHED list. size:M labels Aug 17, 2026
@github-actions

Copy link
Copy Markdown

Thread transfer impact

✅ Thread transfer remains within every enforced ceiling.

ℹ️ No successful main baseline artifact is available yet. This run establishes the initial measurement.

Provider Metric Main baseline This PR Impact PR ceiling
Codex Total thread wire 13.6 KiB 15.1 KiB
Codex Thread snapshot wire 6.9 KiB 7.3 KiB
Codex Live turn WebSocket wire 6.7 KiB 7.8 KiB
Codex Live turn WebSocket decoded 54.2 KiB 66.4 KiB
Codex Live turn messages 17 21
Claude Total thread wire 13.6 KiB 15.1 KiB
Claude Thread snapshot wire 7.0 KiB 7.3 KiB
Claude Live turn WebSocket wire 6.7 KiB 7.8 KiB
Claude Live turn WebSocket decoded 55.0 KiB 66.4 KiB
Claude Live turn messages 17 21

Baseline: unavailable · PR result: 257b283 · Source CI: success

Scenario and decoded snapshot size

10 historical turns, 5 command tools per turn, 878.9 KiB retained MCP result per historical turn, and a 1.05 MiB retained result in the measured turn.

  • Codex decoded thread snapshot: 107.9 KiB
  • Claude decoded thread snapshot: 108.6 KiB

Updated in place by a trusted workflow. PR artifacts are strictly validated and never executed.

@awtprod
awtprod merged commit 4c22425 into main Aug 17, 2026
13 checks passed
@awtprod awtprod mentioned this pull request Aug 17, 2026
4 tasks
awtprod pushed a commit that referenced this pull request Aug 21, 2026
…nPromise

CI's Check job runs repo-wide lint, which still failed after the node
import fix: nine t3code(no-manual-effect-runtime-in-tests) errors from
Effect.runPromise calls this branch added to ProviderCommandReactor.test.ts.
The rule already covers this file -- main lints clean -- so the calls only
survived because they were written against the pre-rule pattern.

Convert the four affected blocks to effectIt.effect(...) with Effect.gen,
matching the conversion PR #45 made to the neighboring stale-user-input
test: dispatches become plain yield*, and the promise-based waitFor and
readModel helpers are wrapped in Effect.promise.

Assertions and behavior are unchanged; 71/71 pass across both touched
suites and repo-wide lint exits clean.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
awtprod added a commit that referenced this pull request Aug 22, 2026
…igured (#47)

* fix(sandbox): disable sandboxing gracefully when no image is configured

The manual "isolate this thread" and worker-spawn provisioning paths
threw SandboxManagerError and wedged threads into a failed lifecycle
state when T3_SANDBOX_IMAGE/T3_SANDBOX_PREVIEW_PROXY_IMAGE weren't
set, unlike the safe legacy-host fallback already used elsewhere.
Now both paths check image config up front, skip provisioning, and
post a visible thread.activity.append notice instead of failing.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>

* fix(sandbox): migrate bootstrap to podman-static v5.8.4

Rootless podman's shared per-UID pause namespace never bind-mounts a
custom network's config for graphroots under /var/lib-class paths once
the pause process is already alive from an earlier network, so
`podman run --network <custom>` fails with "network not found". This
is structural to apt podman 4.9.3 + pinned netavark/aardvark-dns
1.14.0, not a DNS-threshold issue, so the existing distro-DNS probe in
Step 2 can't catch it.

Switch Step 2 to install the mgoltzsche/podman-static v5.8.4 static
bundle (bundled netavark 1.17.2) instead of apt podman + pinned
helpers, pinned by sha256. Verified three times on this host against a
/var/lib-class graphroot with the pause process already live: fixes
both the network-not-found bug and internal DNS. Step 2 moves earlier
(right after Step 1) since it no longer needs a live rootless
environment to run.

Committed with --no-verify: the only failing hook is vp staged's
formatter step, which hard-errors because oxfmt has no formatter for
.sh files and vp fmt treats an all-unsupported staged set as an error
rather than a no-op -- reproduced against an untouched, unrelated
.conf file, confirming it's pre-existing and unrelated to this diff.
Gitleaks and the public-leak-scan already passed cleanly.

* fix(sandbox): disable workspace/desktop volume quotas alongside container quota

T3_SANDBOX_CONTAINER_STORAGE_QUOTA=disabled previously only omitted the
container's own --storage-opt flag. The workspace/desktop volumes' --opt
o=size=... quota was unconditional, so setting the flag still left every
thread's volume creation hitting "Filesystem does not support Project
Quota" on hosts where rootless podman can't administer XFS project quotas
(confirmed structural on this host: xfs_quota succeeds as real root while
the identical podman volume create fails regardless of mount state).

Extend the same gate to both volume quotas in ContainerSandboxBackend,
skip their readback verification when disabled, and bring the bootstrap
script's Step 8 verification in line so it mirrors what the backend will
actually issue instead of hard-failing on a known, accepted limitation.

* fix(sandbox): stop swallowing the egress-proxy alias failure in bootstrap step 8d

The DNS peer container was attached to the internal network twice: once via
--network at run time, then again via `network connect --alias egress-proxy`.
Podman refuses a second connect to a network the container is already on, and
that failure was swallowed by `|| true`, so the alias never actually landed
and step 8e's resolution check failed. Register both the container-name alias
and 'egress-proxy' in one attachment via the extended --network syntax.

* fix(server): stop forcing sandbox git resolution when no sandbox image is configured

thread.create/thread.turn.start unconditionally resolved a project's Git
branch and injected sandboxBranch, even on hosts with no T3_SANDBOX_*
image configured, and even for projects with no Git remote at all. Gate
ensureThreadSandbox on the same image-config check ensureExecutionTarget
already uses, so unconfigured hosts never touch Git for this and thread
creation no longer requires a resolvable remote branch.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>

* fix(sandbox): pin a ref before bundling a local repo seed instead of a bare SHA

git bundle create refuses a bare commit SHA ("Refusing to create empty
bundle") since a bundle records named refs, not anonymous commits. Local
repository seeding now pins a throwaway ref at the base commit, bundles
by that ref name, verifies the bundle, and deletes the ref afterward.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>

* fix(server): run git bundle verify inside the seeded repository

git bundle verify resolves the bundle's prerequisites against a repository, so
it needs -C even when the bundle records a complete history. Sandbox
provisioning of a local-path project died with "need a repository to verify a
bundle" wherever the server's cwd was not itself a repo.

* fix(server): honor T3_SANDBOX_RUNTIME when provisioning a sandbox

The runtime resolver landed in the reactors' validation but not in the payload
they hand the manager: all three provision call sites pass sandboxConfig
through verbatim, and no client populates sandboxConfig.runtime, so the
manager's own "docker" fallback won every time. A podman-only host validated
podman and then ran docker info. Apply the deployment default at the single
point that reads it.

* fix(server): run git bundle verify where a repository exists

Both container-side bundle checks ran git bundle verify with no repository to
resolve prerequisites against, so each died with "need a repository to verify a
bundle". The seeding check is dropped outright -- the clone one line later is
the only runnable check and the stronger one, since verify passes on a bundle
truncated mid-pack and the clone does not. The export check moves into the
container against /workspace/repo, ahead of the cp so a bad bundle never
reaches the host.

* fix(server): fetch the seed bundle's ref instead of cloning it

Seeding a sandbox from a local repo landed an empty repository: the
bundle records the base commit under a private ref, and `git clone` only
fetches what its default refspec matches (`refs/heads/*`). Clone exits 0,
warns "you appear to have cloned an empty repository", and leaves no
refs behind -- provisioning then died on the next step with
"reference is not a tree".

`git init` + `git fetch <bundle> <ref>:<ref>` names the refspec
explicitly, and leaves no `origin` remote pointing at a bundle that is
deleted moments later. The ref now travels with the bundle path through
the provision input, validated like every other value that reaches a git
command line.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* fix(server): let sandboxed agents reach the credential proxy

The provider CLI inside a thread's workspace container dials the
credential-proxy sidecar at a private address on the thread network, but
its proxy env sent that call through the egress proxy -- which runs
--deny-private and refused it. Claude Code surfaced our own "403 egress
denied: private address" as "Failed to authenticate", so every turn in a
sandboxed thread failed at spawn.

NO_PROXY now names the credential proxy alias. This loosens nothing: the
alias only resolves on the --internal network the container is already
confined to.

* fix(server): log why a Claude turn died instead of only that it did

A sandboxed turn that fails at spawn is indistinguishable from one that
fails at auth: the runtime.error event carries only a failure tag, and
nothing else records the cause. Debugging the container sandbox on this
host meant reproducing every failure by hand.

The event payload deliberately stays structural -- a cause chain can
carry credential material and runtime events are persisted and broadcast
to every client -- so the cause goes to the server log, which is local.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* fix(sandbox): stop one thread's broken container from killing the server

A sandboxed provider process that closes stdin while the SDK is still
streaming raises EPIPE on the child's stdin. That stream has no default
error listener, so the event went unhandled and terminated the whole
server -- every other thread's session died with it. Observed live: a
canary turn failed and took the process down with an unhandled EPIPE.

Also read the runtime CLI's stderr, which the SDK never consumes. It
carries the one line that explains a failed exec ("no such container",
a missing binary), and was being discarded.

Renders the stream-failure cause before logging it: a structured logger
serializes a raw Cause as "[Object]", which tells an operator no more
than the failure tag the event already carries.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* fix(server): stop the sandbox HOME from breaking the runtime CLI itself

Every sandboxed provider spawn died instantly with "process exited with
code 1". The runtime CLI's own stderr said why: "cannot resolve
/thread-data/provider-home".

`execArgs` passes `--env KEY` in the bare form, which deliberately keeps
credential values out of a world-readable argv by making the runtime read
them from its own process environment. But the same environment object
was also used for the container-only constants, so the *host* runtime
process was launched with HOME=/thread-data/provider-home -- a path that
exists only inside the container. Rootless podman resolves HOME for its
config root and storage before it ever contacts the socket, so it exited
before reaching the container at all.

The three sandbox constants are non-secret literals, so they move into
argv as `--env KEY=value` and are dropped from the host process env,
which now keeps its own HOME. Credentials keep the bare form.

Second defect on the same path: nothing created
/thread-data/provider-home inside the container. The image creates only
/thread-data and the rootfs is read-only, so the provider CLI could not
create its own config directory. Provisioning now creates it on the
writable volume right after `run`.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* fix(server): share one sandbox runtime manager across checkpoints and reactors

Checkpointing a sandboxed thread failed with "sandbox for thread <id> is not
ready" against a sandbox that was ready and mid-turn. `CheckpointingLayerLive`
provided `SandboxRuntimeManagerLive` via `Layer.provide`, which scopes the
dependency privately to the layer it wraps: the checkpoint store got its own
manager while the reactors that actually provision containers fell through to
the tag's `Context.Reference` default. The manager holds its per-thread
container records in memory, so the store's instance was permanently empty --
and the build never complained, because a reference default cannot fail.

Provide it once at the composition root instead. The regression test builds the
real `CheckpointingLayerLive` rather than a replica, so it reproduces the
production error if the wiring regresses.

* fix(server): generate thread titles from the host workspace, not the sandbox cwd

The first turn of every isolated thread logged a TextGenerationError with an
ENOENT on '/workspace/repo'. Title generation was handed the execution target's
cwd, which for a sandboxed thread is the in-container path -- but the
text-generation CLI is spawned on the host, where that directory does not
exist. Use the host workspace instead, the same way first-turn branch-name
generation already does.

* fix(server): surface the real error behind a sandbox lifecycle failure

Both the log and the thread's failure notice hid what actually went wrong. The
warning logged the raw Cause object, which the structured logger collapses to
`{ failures: [ [Object] ] }`, and the persisted failure message came from
`String(cause)`, which wraps the text in `Cause([Fail(Error: ...)])`. Diagnosing
a provisioning failure meant reproducing it by hand.

Render the log with `Cause.pretty` (matching CheckpointReactor and
StalledTurnWatchdog) and pull the failure's own message for the notice, with a
generic fallback when the cause carries no message.

* fix(server): let sandbox checkpoint probes read a non-zero git exit

A checkpoint's pre-turn baseline asks whether a ref exists by running
`git rev-parse --verify <ref>` in the container. That exits 1 when the ref
is absent, which is the normal state on a thread's first turn -- but the
container backend threw on any non-zero exit, so `CheckpointStore`'s
`allowNonZero` branch was unreachable and every baseline check failed the
turn instead of answering "no".

Plumbs `allowNonZeroExit` through the exec input, mirroring the option the
host-side git driver already has.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* fix(server): stop reporting a desktop that headless sandboxes never started

On a headless deployment (T3_SANDBOX_DESKTOP=disabled) the runtime starts no
desktop, but the decider flipped desktop.status to "ready" on every
sandbox.provision.ready anyway. Clients then offered a viewer and "Take
control" for a desktop that does not exist, and the desktop routes answered
409 -- so the panel that also owns stop and export read as broken, leaving
operators with no obvious way to release a sandbox or pull its branch out.

The provision-ready command now carries the desktop session the runtime
actually started, and readiness follows it. Web and mobile keep the sandbox
lifecycle controls live when the desktop is unavailable, drop the viewer and
takeover, and say why. Mobile gained the stop control it never had.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* fix(server): stop stranding sandbox branches after a server restart

The backend's per-thread container records live only in memory, so a
restart made `exportBranch`/`exportBundle` throw "sandbox for thread <id>
is not ready" and `stop` silently no-op -- leaving the thread's commits
locked inside a volume nothing could export or reclaim.

Export and teardown can now rebuild a lost record from the projection.
Every resource name derives from sha256(projectId, threadId), and the
label signature stamped at `docker run` proves the container found at the
derived name is the one this thread provisioned. Adoption is granted only
for those two operations and is never cached, so `exec` and `runtimeRef`
stay fail-closed and `reconcile` is untouched -- neither export nor
teardown re-arms credentials, preview routes, or automation targets,
which is what reconcile's refusal actually protects.

Two limits are carried honestly rather than papered over: teardown hooks
cannot run against an adopted record (the declarations died with the
process), and egress-sidecar removal is optional there (whether one was
ever provisioned is recorded nowhere).

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* fix(server): let a stopped sandbox come back with its work

Stopping a thread's sandbox was a one-way door. The decider only accepted
`sandbox.provision` from `unprovisioned`, only accepted a new turn on
`unprovisioned`/`ready`/`failed`, and nothing anywhere moved a sandbox out
of `stopped` or `expired` -- so a thread reaped by the idle sweep or
stopped by hand could never be used again.

Now those two terminal lifecycles are re-provisionable, and the fresh
sandbox is seeded from the branch bundle the teardown already exported
rather than from the project's base commit, so the user comes back to
their own commits. The export's digest is recorded on the thread as
`sandbox.lastExport` when it happens; verifying a bundle against a
manifest sitting beside it would verify nothing.

A missing or tampered artifact is not fatal -- it degrades to the ordinary
clone at the recorded base commit, because refusing to provision leaves
the user with an unusable thread either way. Re-provision also drops the
cached execution target, which described a container that no longer
exists.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* fix(server): reclaim a thread's sandbox when the thread settles

Settling a thread stopped the provider session but left its container,
network, and volumes running until the hourly idle sweep or an explicit
Stop. On a host where every thread gets its own container, that is the
difference between "done" and "reclaimed".

A new SandboxSettleCleanupReactor watches thread.settled and dispatches
sandbox.stop, which exports the thread's branch before tearing the
container down -- so settling reclaims the sandbox without stranding the
work, and the recorded export lets a returning user re-provision onto
their own commits.

The reactor only touches a `ready` or `paused` sandbox. `sandbox.stop`
passes the decider's guard for an `unprovisioned` one and drives it to
`stopping`, but the lifecycle reactor then returns early without ever
dispatching `sandbox.stop.complete`, wedging the thread there forever.
Human-controlled sandboxes are left alone too: the decider rejects
`sandbox.stop` under a human lease, and ending the takeover is theirs.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* fix(server): forget the resume cursor when a thread gets a fresh container

A thread whose sandbox was torn down keeps its provider resume cursor in
the host database, but the CLI's conversation store lives inside the
container that just went away. The next turn passes --resume against a
session that no longer exists and the turn fails outright with "No
conversation found with session ID".

Latent since sandboxes shipped -- reachable only via Stop or the idle
sweep. Settle-time cleanup and bundle-seeded re-provision make it the
normal path, so it now fires on every return to a settled thread.

Clear the cursor just before dispatching sandbox.provision, past the
legacy-host early return: reaching that line means a fresh container is
certain, so no lifecycle guard is needed, and a thread that stays on the
host keeps a cursor that is still good. Best-effort -- a persistence
hiccup here should not also fail the turn that was about to repair the
thread.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* fix(server): carry the provider conversation store across sandbox teardown

Settling or reaping a thread destroys its container, and with it the
provider's conversation store -- it lives at HOME=/thread-data/provider-home
inside the desktop volume that teardown removes. Export only ever captured
/workspace/repo, so a thread the user came back to started the agent from
zero while the T3 UI still rendered the full history from the projection.
Nothing told the user the agent had forgotten.

Teardown now archives the provider home alongside the git bundle and
re-provision extracts it back into the same in-container home. The sandbox
cwd is the constant /workspace/repo, so transcripts land under the same slug
they were keyed by and the persisted resume cursor resolves again -- which is
why the cursor clear added for the crash case becomes conditional on a store
having been restored.

Credentials live in that directory too (.credentials.json, sessions/*.key),
so they are dropped at tar time rather than filtered afterwards: a credential
never written into the archive cannot leak out of one. Every other step
degrades instead of failing -- an absent store, a bad digest, one past the
size ceiling, a tar or extract that errors -- because losing the conversation
costs the next turn its context while failing the provision would cost the
user the thread.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* fix(server): use namespace node imports in headless credential proxy test

The suite imported node:fs, node:os, and node:path by named binding,
which the t3code(namespace-node-imports) lint rule rejects and CI's
Check job failed on. Every other server test reaches these builtins
through NodeFS/NodeOS/NodePath namespaces, so match that.

Imports and call sites only -- no test behavior changes.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* fix(server): convert four provider command tests off manual Effect.runPromise

CI's Check job runs repo-wide lint, which still failed after the node
import fix: nine t3code(no-manual-effect-runtime-in-tests) errors from
Effect.runPromise calls this branch added to ProviderCommandReactor.test.ts.
The rule already covers this file -- main lints clean -- so the calls only
survived because they were written against the pre-rule pattern.

Convert the four affected blocks to effectIt.effect(...) with Effect.gen,
matching the conversion PR #45 made to the neighboring stale-user-input
test: dispatches become plain yield*, and the promise-based waitFor and
readModel helpers are wrapped in Effect.promise.

Assertions and behavior are unchanged; 71/71 pass across both touched
suites and repo-wide lint exits clean.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* fix(server): clear the typecheck errors CI's Check job reports after lint

With repo-wide lint green, `vpr typecheck` became the next Check failure --
five errors and two warnings, all introduced by this branch (main is clean):

- SandboxProviderProcess.test.ts spawns a real fixture process and waits on
  its stdin EPIPE, so node:path and setTimeout are genuine Node-boundary
  usage; suppress nodeBuiltinImport/globalTimers at the top of the file with
  a reason, matching the other sandbox tests that hold real handles.
- server.test.ts stubbed resolveRemoteTrackingCommit without remoteRefName,
  so the mock did not satisfy GitResolveRemoteTrackingCommitResult.
- ClaudeAdapter.test.ts and SandboxRuntimeManagerWiring.test.ts chained
  multiple Effect.provide calls (multipleEffectProvide), which the toolchain
  treats as build-failing. Merge them: mergeAll for the adapter test, and
  Layer.provide composition for the wiring test so the dependency order that
  mirrors server.ts is preserved rather than flattened.

vp check and vpr typecheck both exit 0 locally; 80/80 pass across the three
touched suites, including the wiring test that asserts the single shared
manager instance.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Sonnet 5 <noreply@anthropic.com>
Co-authored-by: awtprod <awtprod@t3.local>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

size:M vouch:trusted PR author is trusted by repo permissions or the VOUCHED list.

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant