Skip to content

fix(core): keep locations alive while sessions are executing (24h cap) - #57274

Merged
kitlangton merged 2 commits into
betafrom
fix/location-activity-busy
Oct 20, 2026
Merged

kitlangton merged 2 commits into
betafrom
fix/location-activity-busy

Conversation

@kitlangton

@kitlangton kitlangton commented Oct 20, 2026 •

Copy link
Copy Markdown
Contributor

Issue for this PR

No issue filed. Found on Kit's Studio server running OpenCode Beta.

Type of change

  • Bug fix
  • New feature
  • Refactor / code improvement
  • Documentation

What does this PR do?

Symptom: subagents get cancelled after about 60 minutes when they're blocked in a single shell call (for example, waiting on a long build). The parent session gets no notice. The execution ends as Interrupted { reason: "inactivity" }, and the server log shows the location being torn down:

level=INFO message="location services evicted" directory=<dir> workspaceID=undefined role=server

One server logged 270 of these lines in a single day. At least 11 subagents were killed mid-work this way.

Root cause: LocationActivity only refreshes a location's deadline on durable SessionEvents. A session that's blocked in one long tool call, or waiting on an unanswered question, emits none. After the 60 minute TTL, the sweep sees the location as expired. It calls execution.interrupt(id, { reason: "inactivity", awaitSettlement: true }) for every active session there and then invalidates the location. That ordering came from #47629, which fixed the detached-graph bug from #47626 by interrupting.

Fix: count active execution as activity, up to a safety cap. On each sweep, refresh the deadline of every cached location that has an active session (execution.active → sessions.get → location key). Locations with no active session expire after the usual 60 minutes.

Safety cap (maxActiveHold, default 24 hours): each entry tracks its last durable session event (activeAt) separately from its keep-alive deadline (expiresAt). Keep-alive never pushes the deadline past activeAt + maxActiveHold. A location that active sessions have held for 24 hours with no durable event falls back to #47629's behaviour: interrupt its sessions (reason: "inactivity", awaitSettlement), wait for them to settle, re-check for work admitted during cleanup, then evict. Any durable event resets the clock, so a long session that's still emitting events never hits the cap.

  • A long shell or a pending question keeps its location (and its MCP and browser services) alive for as long as it runs.
  • Questions answered hours later still reach the same Form.Service the waiting tool holds. That's the case fix(core): retain locations while sessions are executing #47626 was about, now handled by retention instead of interruption.
  • Once the session settles, the location gets a full TTL from its last active sweep or terminal event, and is then evicted as before.
  • Idle locations, such as another workspace in the same directory, still expire on their own schedule.
  • The interrupt, settle, re-check and evict path from fix(core): interrupt sessions before inactivity eviction #47629 is unchanged. It only runs now for locations that hit the cap.

Tradeoff: before this change, any session silent for 60 minutes was interrupted. Now a silent session keeps its location (and its MCP, browser and form services) for up to 24 hours. That's more memory and processes held for stuck sessions, in exchange for no longer killing ordinary long tool calls and questions left unanswered. The cap keeps #47629's guarantee that nothing lives forever. A truly hung session is still interrupted and its location evicted, just after 24 silent hours instead of 1.

Other idle sweepers checked (none changed):

  • Workspace idle suspension (workspace.ts, 20 min) already skips connections with active > 0 (each spawn holds a ref), so it isn't affected by this bug.
  • model-transport IDLE_TIMEOUT (30 min) is a provider WebSocket read timeout, not a sweep over executions.
  • Form cache keeps pending forms with an infinite TTL.
  • Project.sweep archives projects with no recent activity. It doesn't touch executions.
  • Shell output and tool-output cleanup (7-day file retention), the PTY ticket cache, and the pairing cache don't interrupt work.
  • Browser attachments and MCP processes live in the location graph and were only torn down through this sweep (see browser-idle.test.ts). They're now retained while a session is active.

How did you verify your code works?

packages/core/test/location-activity.test.ts uses real execution ownership, location caching and forms, on TestClock:

  • Long tool call: the fixture runner sleeps for 3 hours with no events. At 63 and 119 minutes the session is still active, nothing is interrupted, and the location is still cached, while an idle workspace in the same directory is evicted. At 3 hours the tool returns and the session succeeds. The location is still cached 55 minutes later and evicted after the TTL.
  • Unanswered questions (1 and 2 sessions): after more than 3 hours there are no interruptions, the cached Form.Service is the same instance the tools hold, and answering completes each session. After the TTL the location is evicted.
  • 24 hour cap: this is fix(core): interrupt sessions before inactivity eviction #47629's test restored, with 1/2 sessions plus new work admitted during cleanup in another or the same session. A silent session survives 23 hours with its question pending and no interruptions. Just after 24 hours it's interrupted with reason: "inactivity", its forms are cancelled, slow cleanup still owns the graph, and then it's evicted. Work admitted during cleanup keeps the location, as before.
  • Reset: a durable event at hour 20 means the session survives past hour 24 and up to hour 43. It's interrupted and evicted 24 silent hours after that event.
  • The new retention tests fail on the base revision. The suite passed 3 repeated runs together with browser-idle and location-layer.
packages/core: bun run test test/location-activity.test.ts   9 pass, 0 fail
packages/core: bun run test   6654 pass, 43 skip, 7 fail
packages/core: bun run typecheck   clean
oxlint on the changed files: 0 warnings, 0 errors
pre-push `bun run check` (lint: 0 errors; turbo typecheck: 35/35 tasks pass)

The 7 failures are existing ShellTool / real-shell compound-syntax tests (tool-shell.test.ts, shell-scan/*). They fail the same way on the unmodified base on this macOS machine and are unrelated to this change.

Screenshots / recordings

Not a UI change.

Checklist

  • I have tested my changes locally
  • I have not included unrelated changes in this PR

LocationActivity only refreshed a location's deadline on durable session
events. A session blocked in one long tool call (a shell waiting on a build)
or on an unanswered question emits none, so after the 60 minute TTL the sweep
interrupted it with reason "inactivity" and evicted its services.

Count active execution as activity instead: every sweep refreshes the deadline
of each location an active session belongs to, and only locations without
active owners expire. The deadline restarts when the session settles.
Active executions keep their location alive, but only while a durable session
event has arrived within maxActiveHold (default 24 hours). A location held
that long in silence falls back to #47629's behaviour: interrupt its sessions
with reason "inactivity", await settlement, then evict.
@kitlangton kitlangton changed the title fix(core): keep locations alive while sessions are executing fix(core): keep locations alive while sessions are executing (24h cap) Oct 20, 2026
@kitlangton
kitlangton merged commit 23d7969 into beta Oct 20, 2026
10 of 12 checks passed
@kitlangton
kitlangton deleted the fix/location-activity-busy branch October 20, 2026 04:35
@amrkmn amrkmn mentioned this pull request Oct 27, 2026
1 task
pascalandr added a commit to NeuralNomadsAI/CodeNomad that referenced this pull request Oct 29, 2026
## Summary

- Qualify OpenCode **2.2.1** (latest stable, 29 October) with the
existing locked **2.1.0 client/plugin** dependencies, and **recommend**
it.
- Keep the technical **minimum at 2.1.2**: no consumed contract changed,
so the minimum has no new reason to move.
- 2.1.2, 2.1.4 and 2.2.0 daemons stay ready and get an optional upgrade
offer. 2.2.1 is the `tested` version. Newer and custom labels keep
authenticated contract recognition.
- The reviewed-contract fast path lists the 2.1.2 minimum and the 2.2.1
target.
- The recommended version is updated in the help (all ten locales), both
READMEs, `CONTRIBUTING.md`, `dev-docs/OPENCODE_COMPATIBILITY.md` and the
architecture skill.
## Contract review (v2.1.4 → v2.2.0 → v2.2.1; 661 + 134 commits)

Reviewed from the exact tags `v2.1.4` (`68bc26b7da`), `v2.2.0`
(`1097e2668b`) and `v2.2.1` (`3ebcf9a32c`), the published npm packages,
and an authenticated OpenAPI dump from each official CLI running in its
own sandbox. The [changelog](https://opencode.ai/changelog) covers
2.2.0. It had no 2.2.1 entry at review time (29 October), so 2.2.1 was
reviewed from its commit log.

- **Authenticated OpenAPI:** 124 → 130 → **134 paths**, 149 → 156 →
**162 operations**. Each dump matches the tagged
`packages/protocol/openapi.json`.
- **2.2.0 adds** `GET /api/plugin/gui`; `POST
/api/experimental/generate/{text,image,decisions}`; `POST`/`DELETE
/api/experimental/fs/watch`; `GET /api/experimental/media/{hash}`; and
`GET /api/experimental/usage/limits`.
- **2.2.0 removes** `POST
/api/experimental/voice/session/{sessionID}/instructions`.
- **2.2.1 adds** six `/api/experimental/schedule` operations (list,
create, get, delete, pause, resume), gated by `experimental.schedules`.
  - **Changed operations:**
- `GET /api/model` gains an optional `types` filter. Without it the
route lists chat models only. Image and decision models are new in
2.2.0, so the default listing matches 2.1.4.
    - `GET /api/event` gains an optional `types` filter.
    - `GET /api/vcs/diff` gains an optional `untracked` parameter.
- `POST /api/experimental/generate` becomes the deprecated alias of
`/generate/text`.
- Nine session, project and saved-permission routes drop
`LocationNotFoundError` from their 404 responses: they no longer boot a
Location ([#58997](anomalyco/opencode#58997)).
- `GET /api/agent` can now answer 503 while plugins settle
([#59082](anomalyco/opencode#59082)).
- In 2.2.1, the session log and persistent-PTY operation IDs move under
`experimental.*`. Their paths are unchanged.
- **Schemas:** all 103 consumed-family schemas are byte-identical from
2.1.4 to 2.2.1. These are `V2EventEncoded`, `Session.*` (messages,
parts, inbox, info), `Form.*`, `Permission.*`, `Prompt*`, `Event.*`,
`Location.*` and PTY/Shell. The session prompt, environment, message,
form/permission reply and RPC operations are unchanged too. The only
`Config.InfoEncoded` changes are additive opt-ins:
`experimental.media_blob`, `experimental.image_generate`,
`experimental.schedules` and `timezone`. Model `type`, prompt-cache
`cache` settings, `FileDiff.Info.more`, `Mcp.Server.title` and
`ServerInfo.hostname` are additive fields.
- **Admission:** every predicate CodeNomad's
`compatibility/negotiate.ts` checks holds on 2.2.0 and 2.2.1. That
covers the canonical API, `session_environment` and the 2.1 replay shape
(`native` without `state`, compaction `metadata` without `recent`).
- **Instance proxy:** none of the new routes is on the allowlist. That
includes the GUI plugin bundle, generation, file watch, media blobs,
usage limits and schedules. The removed voice route was never exposed.
The routes CodeNomad does proxy kept their paths.
- **Published declarations** (2.1.0 pins → 2.1.4 → 2.2.1):
- **Client:** 143 → 147 → 185 methods. 2.2.1 mirrors the experimental
methods under `client.experimental.*`. The only removal is
`voice.instructions`, which CodeNomad doesn't use. 2.2.1 doesn't send
the `experimental-media-uri` capability, so media reads stay inline.
- **Schema:** 2.2.0 adds media refs (`MediaRef`,
`Session.Message.MediaUser`, inbox `MediaUserPayload`), `generate`,
`usage-limits` and `Model.Type`. 2.2.1 adds `goal` and `schedule`. The
one removal since 2.1.0 is `SessionEvent.Source` (2.1.4).
- **Plugin:** 2.2.0 adds the `gui/*` SDK (excluded from the host
runtime) and `Host.packageID`. 2.2.1 adds
`SessionIdle`/`SessionIdleBackground`. The event `subscribe` domain
gains an optional `types` filter; CodeNomad's plugins don't subscribe
through it.
- **Typecheck:** the server and UI typecheck with **0 errors** against
the 2.1.4 and 2.2.1 client/schema/plugin declarations, through scratch
`paths` overrides. The pinned 2.1.0 control is also clean.
- **Host modules:** CodeNomad's three daemon-loaded bundles resolve
`@opencode/plugin` (`Plugin`), `/effect` (`Plugin`, `Rpc`, `Location`),
`/promise/adapter` (`fromPromise`), `/rpc` (`Rpc`), `effect` and
`effect/encoding` (`Base64Url`) from the host. `@opencode/schema` and
`@opencode/client` are bundled from the 2.1.0 pins, and
`@opencode/schema/location` imports are type-only. A probe plugin loaded
in an isolated **2.2.1** daemon resolves every one of these modules and
bindings, as on 2.1.4. The host Effect stays **4.0.1**, with the same
lockfile hash.
- **Private native services:** the pending-request reader and Missions
recurrence use keyed native services: `LocationServiceMap`, `Form`,
`Permission`, `Location`, `Job`, `Database`, `Session` and `Bus`. Their
keys are unchanged at v2.2.1, and the members CodeNomad calls are
unchanged. The changes are additive: `Permission.assertNotDenied`,
`Session.*WithRefs`, an `image` Job kind, and
`LocationServiceMap.canonical` normalizing paths.
- **Storage** (migrations from 2.1.4 to 2.2.1): `blob`/`blob_ref`
(always created; written only with `experimental.media_blob`) and
`schedule` are new. `session_pending` is dropped; CodeNomad never reads
it. A `session_v2.allowance` column is added and dropped within 2.2.0.
- The tables CodeNomad reads directly (`session_message`, `session_v2`,
`session_inbox`, `event`, `event_sequence`, `kv`) keep their shape.
- The pruning plugin's `media-blob.ts` already projects `media:<sha256>`
refs.
- The pruning fixture enables `media_blob` explicitly, so the
always-present `blob` table doesn't change its expectation.
- Missions reads `session_inbox` only by count and ID in production
code.
- **Behavior changes checked:**
- **Code Mode:** 2.2.1 moves programs onto one job queue
([#57976](anomalyco/opencode#57976)). The
run-to-completion regression behind the 2.1.2 minimum passes on 2.1.4,
2.2.0 and 2.2.1: `{ total: 2000, beforeAwait: 1000, count: 1000 }`.
- **Session form polling** returns `[]` without booting a cold Location
([#59083](anomalyco/opencode#59083)). Forms are
in memory, so nothing pending is hidden. This matches CodeNomad's
existing-only pending reader.
- **Location keys and `session.create` directories** are canonical on
POSIX too: trailing slash removed, `posix.normalize`
([#59327](anomalyco/opencode#59327)). Windows
already normalized. This is low risk; it was reviewed in source only,
since the location and ownership fixtures run on Windows and the POSIX
certification covers migration, Code Mode and Git-degraded.
- **Location keep-alive:** the changelog line "keep project services
alive … with a 24-hour inactivity cap"
([#57274](anomalyco/opencode#57274)) doesn't
match the shipped tree. That commit was superseded before 2.1.4, and no
cap exists at v2.2.0 or v2.2.1. From 2.1.4 to 2.2.1,
`location-activity.ts` only canonicalizes paths and generalizes Job
kinds. Long-waiting Missions sessions keep their Location as on 2.1.4.
- **Plugin readiness:** each runner step and tool call now waits for
plugin activation
([#51133](anomalyco/opencode#51133)), and
agent/provider lists wait for plugins. Presence leases stay outside the
discovery root, so this adds no reload.
- **Service lifecycle:** registration and config renames are retried on
Windows. The managed service ignores a saved `XDG_STATE_HOME`
([#58815](anomalyco/opencode#58815)); CodeNomad
never sets it. The `OPENCODE_SERVER_USERNAME` change was added and
reverted within 2.2.0. Detached POSIX children are killed when OpenCode
exits; service start uses its own spawn path.
  - **2.2.1 opt-ins:**
- schedules, both the API and the agent tool, need
`experimental.schedules`;
    - `image_generate` needs `experimental.image_generate`;
    - `/goal` runs only when invoked;
- the subagent tool description now invites follow-ups to *running*
subagents, with the same mechanics as 2.1.4.
- **Other changes:** compaction sizing from the provider's count, MCP
malformed-tool tolerance, OAuth refresh coordination
([#54023](anomalyco/opencode#54023)) and
webfetch redirect rules. They touch no consumed contract and were
reviewed in source; of these, the native suites exercise compaction
only.
- **Upstream #52922** (Bus routing before Location fanout) is still
open: keep the discovery and event-relay mitigations.

**Verdict:** no consumed contract changed from 2.1.4 to 2.2.1.

- The **2.1.0 client/plugin pins can stay**.
- The **2.1.2 minimum has no reason to move**.
- 2.2.1 is admitted through the existing contract recognition, and
nothing found blocks recommending it once the native suites pass on it.
- No CodeNomad bug was found.

Bumping the pins is optional. It would bring the 2.2 client's
discard-tool-result-payload option and its "register running sessions
missed while disconnected" fix
([#53012](anomalyco/opencode#53012)). It would
also move the replay reducer, so it belongs in its own PR. The UI and
server already typecheck against 2.2.1.

**Not verified here:**
- the full native suites against 2.2.0/2.2.1 (only the Code Mode
regression and the host-module probe ran);
- the TUI pruning companion under OpenTUI 0.7. It uses only
`Plugin.define`, `ctx.data.session` and `ctx.data.listen`, so the 0.7
renderable changes don't apply, but this wasn't run;
- POSIX canonicalization on a real Linux/macOS runtime;
- the 2.2.1 release notes, which were unpublished at review time.
## Isolated Windows qualification

Base: dev `157766206`; Node **25.2.1** (local; CI runs the pinned
24.20.0); exact official CLI **2.2.1**; unchanged locked client/plugin
**2.1.0**.

- **Build and checks:** production server and native plugin builds
(pruning, automation, Missions), root typecheck.
- **Native runtime:** Code Mode job ordering (`{ total: 2000,
beforeAwait: 1000, count: 1000 }` on 2.2.1), plugin runtime, automation;
complete pruning, history, proxy, navigation and media; Location,
worktree families/management and event relay; permission receipts; Git
init/degraded.
- **Missions:** contract, family control, native and trajectory; **all
11 recurring Mission journeys** (`ABCDGELFWQN`).
- **Sessions:** environment, fork, aside, interrupt, lifecycle, blank
cleanup (`--conditions=browser`, as in CI), compaction isolation, prompt
skills, provider usage/accounts/selection, service usage, web search,
plugin packages, output limit, Promise-tool failure, subagent depth,
temporary workspaces.
- **Migrations:** history from **2.1.0, 2.1.2, 2.1.4 and 2.2.0** to
2.2.1.
- **Installation:** fresh install, native upgrade and legacy replacement
fixtures.
- **Units after the recommendation change:**
compatibility/transport/update **35/35**; full server **2,628 passed, 8
skipped, 1 cancelled** (`index.test.ts` shutdown test timed out at 5 s
under full-suite load and passes **4/4** alone); ten-locale help checks
pass.

In CI, `latest` resolves to 2.2.1: the compatibility job runs the native
suites against the minimum and latest on Windows, and the migration and
Git-degraded certification runs them on Ubuntu, Windows and macOS. WSL,
packaged desktop, installers/updater end-to-end and real mobile devices
keep their separate release gates.
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.

1 participant