kimi-code/packages/node-sdk/test
7Sageer 3079e9515f
feat(agent-core-v2): add cross-process session occupancy lock (#4246)
* feat(agent-core-v2): add cross-process session occupancy lock

Multiple clients (TUI / kap-server / VS Code / ACP) running core in
separate processes could open the same session and corrupt each
other's wire.jsonl and metadata. Each session now holds a cross-process
occupancy lock ({homeDir}/locks/sessions/{id}.lock) taken only while
writing: runs acquire it on submit / compact / undo / cron / delete /
fork / cold-archive, the append log holds it before flushing, and a
stale check compares the main agent wire's on-disk length with the
length this process knows.

Collisions surface as session.occupied (holder in details); writes by
another client detected after acquire surface as session.stale and the
caller reloads before continuing. kap-server maps them to
SESSION_OCCUPIED (40903) / SESSION_STALE (40930), reloads the session
on stale, and re-attaches WS subscriptions; the TUI shows an occupancy
dialog (retry/cancel) or reloads and replays history keeping the input;
print exits with code 4 on occupied and reloads automatically on stale;
VS Code / ACP get minimal occupancy notices. Locks are released a few
seconds after the session goes idle, on close, and on process exit;
locking I/O failures degrade to unlocked operation with a warning.

* docs: document session occupancy error codes and lock directory

* fix: address codex review on session occupancy lock

- metadata: re-read state.json on the first write of each lock-hold
  epoch (holdToken), so pure-metadata changes made by another client
  (title, archive) are not clobbered by turn-end lastPrompt writes
- lifecycle: hold an explicit lease across live archive(), matching
  delete(), so the idle release timer cannot drop the lock mid-teardown
- tui: route the occupied dialog's Cancel selection through the same
  cleanup as Esc (restore input, release recalled media)
- acp: tell stale-session users to close and reopen the session
- append-log: warn when retirement drops records kept in memory because
  the session lock was unavailable

* fix: always run the staleness check in beginSessionRun

The prompt channels (AgentPromptChannel.submit/submitSteer, kap
/prompts, promptWithSkills) write prompt metadata before calling
loop.submit, and the metadata write acquires the session lock first.
beginSessionRun then short-circuited on 'already held' and skipped the
wire-length staleness check, so the main prompt path never detected
updates written by another client. Drop the already-held shortcut: the
check is a single stat and always passes while the lock is continuously
held, so every acquisition path now runs it regardless of order.

* fix(print): preserve occupied handling on the stale retry

If another client acquires the session between the reload and the
retry, the retried turn now goes through the same conflict handler:
session.occupied reports to stderr / stream-json and exits with code 4
instead of bubbling out as exit code 1.

* fix: do not mistake own in-flight writes for external modification

storage.append grows the file (writeFile) before fsync/close complete,
and knownLength only catches up afterwards; a beginSessionRun landing
in that window (steer, queued prompts, cron fires during a turn) was
misjudged as session.stale and deleted the lock file mid-turn. Track
in-flight bytes per log and compare disk against known + in-flight.
Cover the analogous rewrite window (post-rename syncDir) with an
expected-final-length marker.

* fix: bound the staleness comparison and check once per lock hold

The equality check disk !== known + inFlight moved the false-positive
window to before the append lands (mkdir/open) and into chunked
writes, so beginSessionRun during a turn (steer, cron, queued prompts)
could misjudge, delete the lock file mid-turn, and later append behind
another client. Compare known <= disk <= known + inFlight instead, and
evaluate the check only on a fresh hold (holdToken epoch): re-entrant
runs pass through without a stat, matching the RFC's 'already held
passes through' while every acquisition path still triggers the check
exactly once per hold.

Also unify the occupancy prompt copy across clients: the engine error
message is now the single user-facing text ('该会话正在被其他 Kimi
Code 进程使用;请停止其他进程中的会话或退出该进程后重试'), and the
TUI dialog, /compact /undo errors, print output, and the VS Code
fallback all display it instead of maintaining their own wordings.

* fix: address third-round codex review

- metadata: hold an explicit lease across each state.json write so the
  idle release timer cannot unlink the lock mid-write
- print: preflight the session lock before goal creation; make goal
  creation idempotent and emit the goal summary only for attempts that
  did not fail with session.stale, so a stale reload + retry neither
  duplicates the goal nor the summary
- tui: route every prompt-launch path (plain, goal steer, plugin
  command, inline skill, batch steer) through one conflict handler
  with the occupied dialog and stale reload + input restore
- tui: snapshot and restore the attachment store across stale reloads
  so pasted media placeholders still resolve after history replay

* fix: move session occupancy to a fresh error code, use English copy, release locks on exit

SESSION_OCCUPIED reused 40903, which belonged to PROMPT_ALREADY_COMPLETED until #3747 retired it, and web clients still allowlist 40903 for that old meaning; reusing it conflated the two semantics. Move session occupancy to the never-used 40931 and update the server API reference in both locales.

The occupied error message was hardcoded in Chinese while every other engine error is English, so the TUI, print mode, VS Code, and ACP clients all rendered mixed-language output; replace it with an English equivalent.

The lock release hook ran on 'beforeExit', which never fires when the process leaves through process.exit (SIGHUP in the TUI goes through emergencyTerminalExit), leaving stale lock files that could keep a session looking occupied forever after a hostname change or pid reuse. releaseAll is fully synchronous, so hook 'exit' instead.

* fix(agent-core-v2): decide prompt-derived titles after re-reading session metadata

applyPromptMetadataUpdate decided whether to derive a title from the
prompt from the in-memory metadata cache, which is only refreshed when
the session lock is re-acquired. If another client named the session in
the meantime, the stale cache still looked untitled, so the next prompt
from this client overwrote the other client's title — both on the stale
path, where the prompt is rejected afterwards, and on the fresh path,
where the other client only changed the title on disk.

Move the untitled check into SessionMetadata.applyPromptMetadata, which
runs after ensureWriteOwnership() has re-read state.json, so the
decision is based on the latest persisted metadata. The published
SessionMetaUpdated event now carries the derived title only when it was
actually written.

* fix: address fourth-round codex review

- vscode: classify session.occupied and session.stale as preflight errors so the webview toasts them instead of silently swallowing the send, and give session.occupied the same English copy the engine emits (the toast text comes from getUserMessage in the extension host).
- engine: beginSessionRun now takes the session context and agent lifecycle service, and on the first check of each lock hold it runs externallyModified over every loaded agent's wire (scope agents/<agentId>, key wire.jsonl) instead of only the main agent's, so a fork that only touched a subagent journal is detected as stale. All four call sites (loop submit, manual compaction begin, undo, print preflight) pass the extra dependency.
- tui: keep the request in flight across a stale reload instead of dropping to idle, so input submitted during the reload queues rather than racing the closing session; park the queue around switchToSession (resetSessionRuntime would clear it) and drain it into the reloaded session afterwards; when restoring the rejected text, prepend it before content the user typed during the reload instead of overwriting it.
- kap-server: clear the in-flight turn tracker and subagent roster in detachSession so a reattached session no longer reports stale in-flight turns or running subagents in REST snapshots and WS resyncs.

* fix: harden session conflict recovery

Detect external appends that race journal reads so a later run cannot miss session.stale. Keep deferred wire migrations pending and finish them before appending, while rejecting the append if migration still cannot complete. Restore the complete plugin command after an occupied cancellation, including commands without arguments.

* fix: scope session staleness cache to session context

Replace the process-global session-id map with a WeakMap keyed by ISessionContext so closed and reloaded sessions do not retain cache entries for the lifetime of the process.

* fix: document session conflicts for metadata routes

Declare SESSION_OCCUPIED and SESSION_STALE in the response schemas for profile updates, title generation, and child creation. Cover the OpenAPI declarations and the existing HTTP 200 conflict envelopes with regression tests.

* refactor: centralize session conflict handling

Move session error mapping and stale reloads into one shared route helper, reuse shared conflict response declarations, and cover skill activation conflicts. Remove the duplicate prompt, session, and skill error mappers while preserving their existing envelopes.

* fix(sdk): restate session conflict errors

* fix(tui): keep the reloaded session open and resend after a stale conflict

* fix(agent-core-v2): lazily rebuild registered agents after a session reload

* refactor: simplify session conflict handling

* refactor: hygiene fixes from session-lock review

- kap-server: move SESSION_STALE to 40932 (collided with
  DEFAULT_WORKSPACE_UNAVAILABLE at 40930) and pin ErrorCode value
  uniqueness with a test
- node-sdk: fold six catch-and-restate copies into a restate() helper
- acp-server: sync mapPromptLaunchError docstring with the
  occupied/stale branches
- replace hardcoded session.stale/session.occupied literals with
  ErrorCodes constants
- inline the single-use sessionStaleError
- restore the strict timer-leak assertion with a warmup baseline
  instead of a blanket +1 allowance
- narrow SessionState.detached to ?: true

* refactor: introduce session-level run gate

- replace the beginSessionRun free function with ISessionRunGate, a
  Session-scoped service; loop/compaction/undo now inject one
  dependency instead of four, and sessionLock.ts no longer imports
  session-layer modules
- tryHold() absorbs the duplicated try/catch hold copies in
  wireService and sessionMetadataService
- resolve IAgentLifecycleService lazily inside the gate to avoid a
  construction-time cycle with agentLifecycleService

* refactor: tighten session lock and conflict recovery structure

- Narrow ISessionLockService to hold/tryHold/release/lease/held and share
  the hold bookkeeping between the memory and node-fs backends; drop
  holdToken and the module-level exit hook state.
- Session metadata refreshes on a new hold via onDidHold and skips agent
  registration through tryHold instead of catching session.occupied.
- AppendLogStore reads sizes through IFileSystemStorageService.sizeSync and
  exposes a generic write guard instead of a session-lock binding.
- kap: reload stale sessions only at the call sites that can raise
  session.stale and log reload failures; error mapping stays side-effect free.
- TUI: recover from a stale conflict through the in-place session reload with
  history replay, removing the same-session switch special case, the media
  store snapshot, and the queue parking.
- print: bound stale reloads with a simple retry loop.

* refactor(agent-core-v2): merge session lock release into run gate

* test(agent-core-v2): reuse append log fixtures

* refactor(agent-core-v2): break run gate and agent lifecycle dependency cycle

* refactor: reload stale sessions in core instead of each host

Stale detection already lived in core, but every host re-implemented the
recovery: TUI, print, and kap each closed and resumed the session, and TUI
and web resent the rejected input themselves.

Core now exposes reloadOnStale: on session.stale it closes and resumes the
session, lets the caller refresh, then retries the call once (undo reloads
and still rejects, since its count depends on what the user saw).

- kap: prompt submit, skill activation, and compact retry in place instead
  of returning 40932; undo still returns 40932 after reloading
- print: retries through reloadOnStale on the same session id and keeps the
  original permission mode to restore
- SDK: run-starting calls reload and retry internally; Session gains
  setStaleReloadHandler so hosts refresh before the retry
- TUI: drops its own reload/resend/queue-merge path; the stale handler
  replays history and keeps in-flight messages

* docs(server-api): note that only undo returns 40932 after a stale reload

* refactor(agent-core-v2): ask each agent's wire whether it was modified externally

* refactor(agent-core-v2): clarify agent restoration helper name

* Delete .changeset/ensure-agent-lazy-rebuild.md

Signed-off-by: 7Sageer <sag77r@hotmail.com>

* fix(tui): wrap session occupied notice

---------

Signed-off-by: 7Sageer <sag77r@hotmail.com>
2026-10-20 17:21:18 +08:00
..
catalog.test.ts refactor: remove the legacy agent-core v1 package (#3542) 2026-09-07 11:17:51 +08:00
config.test.ts feat: support model migration metadata (#4171) 2026-10-14 14:24:01 +08:00
kimi-code-model-provider.test.ts fix: classify OAuth token refresh errors by cause (#838) 2026-06-17 18:34:42 +08:00
list-sessions.test.ts feat: graduate remote-control, minidb read model and search worker from experimental flags (#3552) 2026-09-07 11:28:35 +08:00
mcp-auth-status-server.ts feat(agent-core): unify the v1 MCP management plane (#2858) 2026-08-17 13:19:51 +08:00
replay.test.ts feat(tui): unify skill prompts and steering (#4165) 2026-10-13 12:17:14 +08:00
sdk-rpc-client-v2.test.ts feat(agent-core-v2): add cross-process session occupancy lock (#4246) 2026-10-20 17:21:18 +08:00
session-approval-handler.test.ts refactor: split SDK RPC client (#339) 2026-06-03 15:30:38 +08:00
session-event-types.test.ts perf(agent-core-v2): evict completed subagent scopes behind an LRU cache (#3778) 2026-09-15 20:33:24 +08:00
session-event-wiring.test.ts refactor(agent-core-v2): replace interaction DI services with a global human singleton (#3644) 2026-09-08 17:39:16 +08:00
session-goal.test.ts feat(vscode): migrate extension to Node SDK (#1769) 2026-07-16 17:27:21 +08:00
session-prompt-events.test.ts feat: notify the agent of external changes after /reload (#4019) 2026-10-16 15:15:57 +08:00
session-prompt-input.test.ts refactor: remove the legacy agent-core v1 package (#3542) 2026-09-07 11:17:51 +08:00
session-question-handler.test.ts refactor: split SDK RPC client (#339) 2026-06-03 15:30:38 +08:00
session-runtime-helpers.ts Kimi For Coding 2026-05-22 15:54:50 +08:00
session-skills.test.ts feat(tui): unify skill prompts and steering (#4165) 2026-10-13 12:17:14 +08:00
telemetry.ts Kimi For Coding 2026-05-22 15:54:50 +08:00
test-identity.ts feat: unify the host identity across OAuth, telemetry, and kap-server (#2382) 2026-07-30 13:45:41 +08:00