From 56f1a5a93d8a8d3c705491e87775c245b5ab43eb Mon Sep 17 00:00:00 2001 From: 7Sageer <7sageer@djwcb.cn> Date: Sun, 19 Jul 2026 19:11:27 +0800 Subject: [PATCH 01/22] feat: multi-instance session lease, write fencing, and fs/watch reliability Single-instance reliability: - Journal: preserve content on write failure with sticky JournalStorageError, per-batch fsync, last-header open, and zero-write cold reads - Broadcaster: writeFailure gate, deterministic dispose drain, replay failure now triggers resync_required; graceful close ordering in start.ts - fswatch: per-connection monotonic seq, pinned truncated semantics, and gitignore double-cache invalidation; __global__ session made volatile Cross-process lock unification: - agent-core-v2 CrossProcessLockService: parameterized lock modes (heartbeat-TTL implemented), token-guarded release/update, rename-isolated stale takeover, creation window, PID-reuse detection (conservative on macOS) - config.toml and workspace registry locked read-modify-write; mcp.json watch - kap-server lock protocol aligned (lock_id, process_started_at, three-layer stale detection); minidb release ownership fixed, legacy cleanup removed Multi-instance session ownership (gated by multi_server flag): - Session leases with write fencing; 40921 SESSION_HELD_BY_PEER with typed details (routable/creating/holder-unresponsive/held-by-local-instance) and unregistered-writer detection; write-authority registry hard gate in AppendLogStore, session metadata, and flush paths - kimi-web redirect to the holder with pending approvals/questions replay; klient transparent redirect with loop guard - session.list_changed cross-instance discovery via two-layer fs watch; ownership field on GET /sessions Stale-write fencing and skill hot reload: - Session file ledger + agent file fencing: hard gate with the flag on, advisory note with it off; dirty-tick signals from fs watch - Skill root watcher hot reload: catalog reload, turn-boundary listing reminder, and skill_catalog.changed volatile event to web clients --- .changeset/cross-process-lock-unification.md | 5 + .../journal-fswatch-reliability-fixes.md | 5 + .changeset/multi-instance-session-sync.md | 5 + .changeset/session-lease-write-fencing.md | 5 + .changeset/skill-hot-reload.md | 5 + .changeset/stale-write-fencing.md | 5 + apps/kimi-web/src/api/daemon/client.ts | 12 +- .../src/api/daemon/sessionOwnership.ts | 87 +++ apps/kimi-web/src/api/daemon/ws.ts | 33 +- apps/kimi-web/src/api/types.ts | 15 +- .../composables/client/useWorkspaceState.ts | 56 ++ .../src/composables/useKimiWebClient.ts | 208 +++++- apps/kimi-web/src/debug/trace.ts | 10 + apps/kimi-web/src/i18n/locales/en/warnings.ts | 12 + apps/kimi-web/src/i18n/locales/zh/warnings.ts | 12 + .../src/lib/sessionOwnershipDecision.ts | 160 +++++ apps/kimi-web/test/session-ownership.test.ts | 259 +++++++ apps/kimi-web/test/workspace-state.test.ts | 65 ++ apps/kimi-web/test/ws-lifecycle.test.ts | 146 ++++ .../scripts/check-domain-layers.mjs | 15 + .../src/agent/fileFencing/fileFencing.ts | 19 + .../agent/fileFencing/fileFencingService.ts | 167 +++++ .../profile/agentSkillListingReminder.ts | 22 + .../agentSkillListingReminderService.ts | 72 ++ .../src/app/config/configFileMutex.ts | 35 + .../src/app/config/configService.ts | 44 +- .../src/app/mcp/mcpConfigWatch.ts | 24 + .../src/app/mcp/mcpConfigWatchService.ts | 74 ++ .../agent-core-v2/src/app/multiServer/flag.ts | 28 + .../sessionLifecycleService.ts | 236 +++++- .../src/app/skillCatalog/skillRootWatcher.ts | 184 +++++ .../src/app/skillCatalog/skillRoots.ts | 32 +- .../app/skillCatalog/userFileSkillSource.ts | 18 +- .../agent-core-v2/src/app/telemetry/events.ts | 44 ++ .../fileWorkspacePersistence.ts | 46 +- .../workspaceRegistry/workspacePersistence.ts | 12 +- .../workspaceRegistryService.ts | 69 +- packages/agent-core-v2/src/errors.ts | 3 + packages/agent-core-v2/src/index.ts | 15 + .../node-local/crossProcessLockService.ts | 573 +++++++++++++++ .../backends/node-local/hostFsWatchService.ts | 16 +- .../os/backends/node-local/processProbe.ts | 71 ++ .../src/os/interface/crossProcessLock.ts | 226 ++++++ .../backends/node-fs/appendLogStore.ts | 44 +- .../backends/node-fs/fileStorageService.ts | 5 + .../node-fs/writeAuthorityRegistryService.ts | 52 ++ .../persistence/interface/writeAuthority.ts | 50 ++ .../agentLifecycle/agentLifecycleService.ts | 9 + packages/agent-core-v2/src/session/errors.ts | 2 + .../session/sessionFileLedger/fileLedger.ts | 57 ++ .../sessionFileLedger/fileLedgerService.ts | 131 ++++ .../src/session/sessionFs/fsService.ts | 32 +- .../src/session/sessionFs/fsWatch.ts | 40 ++ .../src/session/sessionFs/fsWatchService.ts | 201 +++++- .../src/session/sessionLease/sessionLease.ts | 145 ++++ .../sessionLeaseContactProvider.ts | 54 ++ .../sessionMetadata/sessionMetadataService.ts | 11 +- .../explicitFileSkillSource.ts | 34 +- .../extraFileSkillSource.ts | 31 +- .../workspaceFileSkillSource.ts | 20 +- .../agent/contextMemory/splice-replay.test.ts | 3 + .../agent/fileFencing/fileFencing.test.ts | 540 ++++++++++++++ .../fullCompaction/compactionOps.test.ts | 3 + .../test/agent/goal/goalOps.test.ts | 3 + .../permissionMode/permissionMode.test.ts | 4 + .../permissionRules/permissionRules.test.ts | 4 + .../test/agent/plan/planOps.test.ts | 3 + .../profile/agentSkillListingReminder.test.ts | 165 +++++ .../test/agent/profile/profileOps.test.ts | 3 + .../test/agent/swarm/swarm.test.ts | 4 + .../test/agent/task/taskOps.test.ts | 3 + .../test/agent/usage/usage.test.ts | 4 + .../test/agent/userTool/userTool.test.ts | 5 + .../test/app/config/config.test.ts | 11 + .../test/app/config/configFileMutex.test.ts | 173 +++++ .../agent-core-v2/test/app/flag/flag.test.ts | 3 + .../test/app/mcp/mcpConfigWatch.test.ts | 117 +++ .../sessionLifecycle/sessionLifecycle.test.ts | 350 +++++++++ .../app/skillCatalog/skillRootWatcher.test.ts | 139 ++++ .../workspaceRegistryService.test.ts | 111 ++- packages/agent-core-v2/test/harness/agent.ts | 3 + packages/agent-core-v2/test/index.test.ts | 4 + .../crossProcessLockService.test.ts | 602 ++++++++++++++++ .../node-local/hostFsWatchService.test.ts | 20 + packages/agent-core-v2/test/os/stubs.ts | 62 ++ .../backends/node-fs/appendLogStore.test.ts | 163 +++++ .../sessionFileLedger/fileLedger.test.ts | 295 ++++++++ .../test/session/sessionFs/fsService.test.ts | 94 ++- .../session/sessionFs/fsWatchService.test.ts | 222 ++++-- .../test/session/sessionFs/stubs.ts | 68 ++ .../session/sessionLease/sessionLease.test.ts | 161 +++++ .../test/session/sessionLease/stubs.ts | 20 + .../sessionMetadata/sessionMetadata.test.ts | 61 ++ .../sessionSkillCatalog/skillCatalog.test.ts | 17 + .../skillHotReload.test.ts | 340 +++++++++ .../test/wire/persistence.test.ts | 3 + .../test/wire/store-event.test.ts | 3 + .../test/wire/wire-compat.test.ts | 4 + .../test/wire/wireService.test.ts | 4 + packages/kap-server/src/error-handler.ts | 21 + packages/kap-server/src/instanceRegistry.ts | 19 +- packages/kap-server/src/protocol/envelope.ts | 6 +- .../kap-server/src/protocol/error-codes.ts | 2 + .../kap-server/src/protocol/rest-snapshot.ts | 9 +- packages/kap-server/src/protocol/session.ts | 17 + packages/kap-server/src/routes/fs.ts | 14 + packages/kap-server/src/routes/prompts.ts | 14 + packages/kap-server/src/routes/sessions.ts | 77 +- packages/kap-server/src/routes/snapshot.ts | 2 + packages/kap-server/src/routes/terminals.ts | 14 + .../sessionListWatchService.ts | 188 +++++ .../src/services/snapshot/snapshotReader.ts | 2 + packages/kap-server/src/start.ts | 88 ++- packages/kap-server/src/transport/errors.ts | 8 +- .../kap-server/src/transport/ws/eventMap.ts | 12 + .../kap-server/src/transport/ws/v1/events.ts | 47 ++ .../src/transport/ws/v1/fsWatchBridge.ts | 30 +- .../src/transport/ws/v1/registerWsV1.ts | 3 + .../ws/v1/sessionEventBroadcaster.ts | 196 ++++- .../transport/ws/v1/sessionEventJournal.ts | 225 ++++-- .../src/transport/ws/v1/skillCatalogBridge.ts | 137 ++++ .../src/transport/ws/v1/wsConnectionV1.ts | 28 +- .../src/transport/ws/wsConnection.ts | 4 +- .../kap-server/src/transport/ws/wsProtocol.ts | 2 + packages/kap-server/test/boot.test.ts | 81 ++- packages/kap-server/test/eventMap.test.ts | 34 +- packages/kap-server/test/fs-watch.e2e.test.ts | 150 +++- .../test/session-ownership.e2e.test.ts | 164 +++++ .../test/sessionEventBroadcaster.test.ts | 150 +++- .../test/sessionEventJournal.test.ts | 161 ++++- .../kap-server/test/sessionListWatch.test.ts | 193 +++++ packages/kap-server/test/sessions.test.ts | 21 + .../test/skillCatalogBridge.test.ts | 196 +++++ .../kap-server/test/transport-errors.test.ts | 29 + packages/kap-server/test/ws.test.ts | 129 +++- packages/kap-server/test/wsV1Resync.test.ts | 58 +- packages/klient/AGENTS.md | 56 +- packages/klient/src/index.ts | 18 + packages/klient/src/sessionRedirect.ts | 376 ++++++++++ packages/klient/src/transports/http/index.ts | 47 +- packages/klient/src/transports/ws/wsSocket.ts | 5 +- packages/klient/test/e2e/harness/index.ts | 8 + .../klient/test/e2e/harness/testing/index.ts | 8 + .../test/e2e/harness/testing/serverPair.ts | 197 +++++ .../test/e2e/harness/testing/serverProcess.ts | 324 +++++++++ .../e2e/harness/testing/serverProcessMain.ts | 79 +++ .../test/e2e/harness/testing/spawnContract.ts | 30 + .../test/e2e/legacy/dual-instance.test.ts | 260 +++++++ .../test/e2e/legacy/session-ownership.test.ts | 670 ++++++++++++++++++ .../test/e2e/v2/ownership-redirect.test.ts | 71 ++ packages/klient/test/sessionRedirect.test.ts | 465 ++++++++++++ packages/klient/test/wsSocket.test.ts | 37 +- packages/klient/tsconfig.dev.json | 16 + packages/minidb/README.md | 8 +- packages/minidb/src/lockfile.ts | 506 ++++++++----- packages/minidb/test/defense.test.ts | 31 +- .../minidb/test/e2e/helpers/lock-racer.ts | 24 +- packages/minidb/test/e2e/stress.test.ts | 5 + packages/minidb/test/lock.test.ts | 199 +++++- .../node-sdk/test/session-event-types.test.ts | 2 + .../protocol/src/__tests__/envelope.test.ts | 29 + .../protocol/src/__tests__/events.test.ts | 25 + .../src/__tests__/session-ownership.test.ts | 73 ++ .../protocol/src/__tests__/session.test.ts | 16 + packages/protocol/src/envelope.ts | 6 +- packages/protocol/src/error-codes.ts | 3 + packages/protocol/src/events.ts | 45 ++ packages/protocol/src/fs.ts | 21 + packages/protocol/src/index.ts | 1 + packages/protocol/src/rest/snapshot.ts | 9 +- packages/protocol/src/session-ownership.ts | 45 ++ packages/protocol/src/session.ts | 17 + packages/protocol/src/ws-control.ts | 7 + 173 files changed, 13597 insertions(+), 535 deletions(-) create mode 100644 .changeset/cross-process-lock-unification.md create mode 100644 .changeset/journal-fswatch-reliability-fixes.md create mode 100644 .changeset/multi-instance-session-sync.md create mode 100644 .changeset/session-lease-write-fencing.md create mode 100644 .changeset/skill-hot-reload.md create mode 100644 .changeset/stale-write-fencing.md create mode 100644 apps/kimi-web/src/api/daemon/sessionOwnership.ts create mode 100644 apps/kimi-web/src/lib/sessionOwnershipDecision.ts create mode 100644 apps/kimi-web/test/session-ownership.test.ts create mode 100644 packages/agent-core-v2/src/agent/fileFencing/fileFencing.ts create mode 100644 packages/agent-core-v2/src/agent/fileFencing/fileFencingService.ts create mode 100644 packages/agent-core-v2/src/agent/profile/agentSkillListingReminder.ts create mode 100644 packages/agent-core-v2/src/agent/profile/agentSkillListingReminderService.ts create mode 100644 packages/agent-core-v2/src/app/config/configFileMutex.ts create mode 100644 packages/agent-core-v2/src/app/mcp/mcpConfigWatch.ts create mode 100644 packages/agent-core-v2/src/app/mcp/mcpConfigWatchService.ts create mode 100644 packages/agent-core-v2/src/app/multiServer/flag.ts create mode 100644 packages/agent-core-v2/src/app/skillCatalog/skillRootWatcher.ts create mode 100644 packages/agent-core-v2/src/os/backends/node-local/crossProcessLockService.ts create mode 100644 packages/agent-core-v2/src/os/backends/node-local/processProbe.ts create mode 100644 packages/agent-core-v2/src/os/interface/crossProcessLock.ts create mode 100644 packages/agent-core-v2/src/persistence/backends/node-fs/writeAuthorityRegistryService.ts create mode 100644 packages/agent-core-v2/src/persistence/interface/writeAuthority.ts create mode 100644 packages/agent-core-v2/src/session/sessionFileLedger/fileLedger.ts create mode 100644 packages/agent-core-v2/src/session/sessionFileLedger/fileLedgerService.ts create mode 100644 packages/agent-core-v2/src/session/sessionLease/sessionLease.ts create mode 100644 packages/agent-core-v2/src/session/sessionLease/sessionLeaseContactProvider.ts create mode 100644 packages/agent-core-v2/test/agent/fileFencing/fileFencing.test.ts create mode 100644 packages/agent-core-v2/test/agent/profile/agentSkillListingReminder.test.ts create mode 100644 packages/agent-core-v2/test/app/config/configFileMutex.test.ts create mode 100644 packages/agent-core-v2/test/app/mcp/mcpConfigWatch.test.ts create mode 100644 packages/agent-core-v2/test/app/skillCatalog/skillRootWatcher.test.ts create mode 100644 packages/agent-core-v2/test/os/backends/node-local/crossProcessLockService.test.ts create mode 100644 packages/agent-core-v2/test/os/stubs.ts create mode 100644 packages/agent-core-v2/test/session/sessionFileLedger/fileLedger.test.ts create mode 100644 packages/agent-core-v2/test/session/sessionFs/stubs.ts create mode 100644 packages/agent-core-v2/test/session/sessionLease/sessionLease.test.ts create mode 100644 packages/agent-core-v2/test/session/sessionLease/stubs.ts create mode 100644 packages/agent-core-v2/test/session/sessionSkillCatalog/skillHotReload.test.ts create mode 100644 packages/kap-server/src/services/sessionListWatch/sessionListWatchService.ts create mode 100644 packages/kap-server/src/transport/ws/v1/skillCatalogBridge.ts create mode 100644 packages/kap-server/test/session-ownership.e2e.test.ts create mode 100644 packages/kap-server/test/sessionListWatch.test.ts create mode 100644 packages/kap-server/test/skillCatalogBridge.test.ts create mode 100644 packages/klient/src/sessionRedirect.ts create mode 100644 packages/klient/test/e2e/harness/testing/index.ts create mode 100644 packages/klient/test/e2e/harness/testing/serverPair.ts create mode 100644 packages/klient/test/e2e/harness/testing/serverProcess.ts create mode 100644 packages/klient/test/e2e/harness/testing/serverProcessMain.ts create mode 100644 packages/klient/test/e2e/harness/testing/spawnContract.ts create mode 100644 packages/klient/test/e2e/legacy/dual-instance.test.ts create mode 100644 packages/klient/test/e2e/legacy/session-ownership.test.ts create mode 100644 packages/klient/test/e2e/v2/ownership-redirect.test.ts create mode 100644 packages/klient/test/sessionRedirect.test.ts create mode 100644 packages/klient/tsconfig.dev.json create mode 100644 packages/protocol/src/__tests__/session-ownership.test.ts create mode 100644 packages/protocol/src/session-ownership.ts diff --git a/.changeset/cross-process-lock-unification.md b/.changeset/cross-process-lock-unification.md new file mode 100644 index 0000000000..9eaeade844 --- /dev/null +++ b/.changeset/cross-process-lock-unification.md @@ -0,0 +1,5 @@ +--- +"@moonshot-ai/kimi-code": patch +--- + +Fix cross-process state corruption from ad-hoc file locks: server and database locks now verify ownership before release, stale locks are taken over by a quarantine rename instead of deletion, and concurrent writes to the global config and workspace registry are serialized so updates are no longer lost. diff --git a/.changeset/journal-fswatch-reliability-fixes.md b/.changeset/journal-fswatch-reliability-fixes.md new file mode 100644 index 0000000000..1af84ab3a5 --- /dev/null +++ b/.changeset/journal-fswatch-reliability-fixes.md @@ -0,0 +1,5 @@ +--- +"@moonshot-ai/kimi-code": patch +--- + +Fix the server event journal silently dropping events on write failure and on shutdown, and make `.gitignore` edits take effect immediately for file listing and file watching. diff --git a/.changeset/multi-instance-session-sync.md b/.changeset/multi-instance-session-sync.md new file mode 100644 index 0000000000..4c4ecfb41a --- /dev/null +++ b/.changeset/multi-instance-session-sync.md @@ -0,0 +1,5 @@ +--- +"@moonshot-ai/kimi-code": patch +--- + +web: When several server instances share one home, opening a session held by another instance now redirects to that instance, and the session list refreshes automatically when a peer adds or removes sessions. CLI/SDK clients follow the same redirect transparently. diff --git a/.changeset/session-lease-write-fencing.md b/.changeset/session-lease-write-fencing.md new file mode 100644 index 0000000000..53d4898a0d --- /dev/null +++ b/.changeset/session-lease-write-fencing.md @@ -0,0 +1,5 @@ +--- +"@moonshot-ai/kimi-code": patch +--- + +Add per-session cross-process leases with write fencing: a second engine instance opening the same session now receives a structured session-ownership error instead of interleaving writes. diff --git a/.changeset/skill-hot-reload.md b/.changeset/skill-hot-reload.md new file mode 100644 index 0000000000..7777359bca --- /dev/null +++ b/.changeset/skill-hot-reload.md @@ -0,0 +1,5 @@ +--- +"@moonshot-ai/kimi-code": patch +--- + +Reload skill catalogs automatically when skill files change on disk, so newly added or updated skills show up in the next turn; the web UI refreshes its skill list on a server push. diff --git a/.changeset/stale-write-fencing.md b/.changeset/stale-write-fencing.md new file mode 100644 index 0000000000..638b58ed63 --- /dev/null +++ b/.changeset/stale-write-fencing.md @@ -0,0 +1,5 @@ +--- +"@moonshot-ai/kimi-code": patch +--- + +Track files read and written per session to detect conflicting edits across server instances: with multi-server mode enabled, stale or never-read file writes are rejected, otherwise they are flagged in the tool result. diff --git a/apps/kimi-web/src/api/daemon/client.ts b/apps/kimi-web/src/api/daemon/client.ts index d8b1833edf..9cff10fa04 100644 --- a/apps/kimi-web/src/api/daemon/client.ts +++ b/apps/kimi-web/src/api/daemon/client.ts @@ -1463,8 +1463,16 @@ export class DaemonKimiWebApi implements KimiWebApi { handlers.onConnectionChange(connected); }, - onError: (code: number, msg: string, fatal: boolean) => { - handlers.onError(code, msg, fatal); + onError: (code: number, msg: string, fatal: boolean, details?: unknown) => { + handlers.onError(code, msg, fatal, details); + }, + + onSessionListChanged: () => { + handlers.onSessionListChanged?.(); + }, + + onSkillCatalogChanged: (sessionId: string) => { + handlers.onSkillCatalogChanged?.(sessionId); }, onTerminalOutput: (sessionId, terminalId, data, seq) => { diff --git a/apps/kimi-web/src/api/daemon/sessionOwnership.ts b/apps/kimi-web/src/api/daemon/sessionOwnership.ts new file mode 100644 index 0000000000..4e429bc0f0 --- /dev/null +++ b/apps/kimi-web/src/api/daemon/sessionOwnership.ts @@ -0,0 +1,87 @@ +// apps/kimi-web/src/api/daemon/sessionOwnership.ts +// Session-ownership (multi-instance) error details — local re-implementation +// of the `details` payload carried under envelope code 40921 +// (`session.held_by_peer`). kimi-web must not depend on @moonshot-ai/agent-core +// or @moonshot-ai/protocol, so the zod schema in +// packages/protocol/src/session-ownership.ts is mirrored here as plain types +// plus structural narrowing. +// +// Wire semantics (from the protocol schema): +// - creating lease file observed mid-creation; retry shortly +// - routable holder is live and registered an address; redirect +// - holder-unresponsive holder pid alive but heartbeat stale; retry later +// - held-by-local-instance holder has no address (local/embedded); terminal +// - unregistered-writer session dir written by an unregistered process + +import { isDaemonApiError } from '../errors'; + +/** Envelope `code` for `session.held_by_peer` (see kap-server error-handler). */ +export const SESSION_HELD_BY_PEER_CODE = 40921; + +export type SessionOwnershipPhase = + | 'creating' + | 'routable' + | 'holder-unresponsive' + | 'held-by-local-instance'; + +export interface HeldByPeerDetails { + kind: 'held-by-peer'; + phase: SessionOwnershipPhase; + /** Present only when phase === 'routable'. */ + address?: string; + /** Retry hint (ms) for 'creating' / 'holder-unresponsive'. */ + retry_after_ms?: number; +} + +export interface UnregisteredWriterDetails { + kind: 'unregistered-writer'; +} + +export type SessionOwnershipDetails = HeldByPeerDetails | UnregisteredWriterDetails; + +const PHASES: ReadonlySet = new Set([ + 'creating', + 'routable', + 'holder-unresponsive', + 'held-by-local-instance', +]); + +function isSessionOwnershipPhase(value: string): value is SessionOwnershipPhase { + return PHASES.has(value as SessionOwnershipPhase); +} + +/** Structurally narrow an unknown envelope `details` payload. Returns undefined + * for anything that is not a well-formed ownership payload (defensive: the + * server contract is new, and a malformed payload must degrade to the generic + * error toast rather than a crash). */ +export function narrowSessionOwnershipDetails( + details: unknown, +): SessionOwnershipDetails | undefined { + if (typeof details !== 'object' || details === null) return undefined; + const record = details as Record; + if (record['kind'] === 'unregistered-writer') return { kind: 'unregistered-writer' }; + if (record['kind'] !== 'held-by-peer') return undefined; + const phase = record['phase']; + if (typeof phase !== 'string' || !isSessionOwnershipPhase(phase)) { + return undefined; + } + const address = record['address']; + const retryAfterMs = record['retry_after_ms']; + return { + kind: 'held-by-peer', + phase, + address: typeof address === 'string' && address.length > 0 ? address : undefined, + retry_after_ms: + typeof retryAfterMs === 'number' && Number.isFinite(retryAfterMs) && retryAfterMs >= 0 + ? retryAfterMs + : undefined, + }; +} + +/** Extract the ownership details from any thrown value: a DaemonApiError with + * code 40921 carrying a well-formed details payload. Anything else → undefined. */ +export function getSessionOwnershipDetails(err: unknown): SessionOwnershipDetails | undefined { + if (!isDaemonApiError(err)) return undefined; + if (err.code !== SESSION_HELD_BY_PEER_CODE) return undefined; + return narrowSessionOwnershipDetails(err.details); +} diff --git a/apps/kimi-web/src/api/daemon/ws.ts b/apps/kimi-web/src/api/daemon/ws.ts index 721677df8a..32088809f9 100644 --- a/apps/kimi-web/src/api/daemon/ws.ts +++ b/apps/kimi-web/src/api/daemon/ws.ts @@ -44,8 +44,16 @@ export interface DaemonEventSocketHandlers { onResync(sessionId: string, currentSeq: number, epoch?: string): void; /** Called when the WS connection opens or closes */ onConnectionState(connected: boolean): void; - /** Called on error frames or JSON parse failures */ - onError(code: number, msg: string, fatal: boolean): void; + /** Called on error frames or JSON parse failures. `details` carries the + * structured payload of connection-level error frames when the server + * sends one (e.g. SessionOwnershipDetails under 40921). */ + onError(code: number, msg: string, fatal: boolean, details?: unknown): void; + /** Volatile hint (no payload): the shared session list changed on some + * instance (multi-instance home). The listener re-pulls the list. */ + onSessionListChanged?(): void; + /** Volatile per-session hint: the session's skill catalog changed on the + * daemon. The listener re-pulls that session's skills. */ + onSkillCatalogChanged?(sessionId: string): void; onTerminalOutput?(sessionId: string, terminalId: string, data: string, seq: number): void; onTerminalExit?(sessionId: string, terminalId: string, exitCode: number | null): void; } @@ -373,11 +381,30 @@ export class DaemonEventSocket { payload: frame.payload, }); } else { - this.handlers.onError(frame.payload.code, frame.payload.msg, frame.payload.fatal); + this.handlers.onError(frame.payload.code, frame.payload.msg, frame.payload.fatal, frame.payload?.details); } break; } + // Multi-instance hint (volatile, no payload): some instance sharing this + // home created/archived/deleted a session. Consumed by name here because + // classifyFrame would route the unknown unprefixed type to the agent + // projector, which no-ops on it. Emitted with or without the "event." + // prefix — accept both. + case 'session.list_changed': + case 'event.session.list_changed': + this.handlers.onSessionListChanged?.(); + break; + + // Volatile per-session hint: the daemon's skill catalog for this session + // changed (e.g. a skill file edited on disk). Consumed by name here for + // the same reason as session.list_changed above; the frame carries its + // own per-connection seq, never the durable watermark. + case 'skill_catalog.changed': + case 'event.skill_catalog.changed': + this.handlers.onSkillCatalogChanged?.(frame.session_id as string); + break; + case 'ack': // ack frames are fire-and-forget for now (no request tracking) break; diff --git a/apps/kimi-web/src/api/types.ts b/apps/kimi-web/src/api/types.ts index 3f82db6a4b..ee4736f71b 100644 --- a/apps/kimi-web/src/api/types.ts +++ b/apps/kimi-web/src/api/types.ts @@ -520,7 +520,8 @@ export interface AppInFlightTurn { */ export interface AppSessionSnapshot { asOfSeq: number; - epoch: string; + /** Absent until the journal's first durable event: "no baseline" ≠ "baseline changed". */ + epoch?: string; session: AppSession; /** Most recent messages, chronological ascending. */ messages: AppMessage[]; @@ -535,8 +536,18 @@ export interface AppSessionSnapshot { export interface KimiEventHandlers { onEvent(event: AppEvent, meta: KimiEventMeta): void; onResync(sessionId: string, currentSeq: number, epoch?: string): void; - onError(code: number, msg: string, fatal: boolean): void; + /** Connection-level error frame. `details` carries the structured payload + * newer servers attach (e.g. SessionOwnershipDetails under 40921); absent + * on older servers. */ + onError(code: number, msg: string, fatal: boolean, details?: unknown): void; onConnectionChange(connected: boolean): void; + /** Volatile multi-instance hint: the shared session list changed on some + * instance — refresh the sidebar list. No payload; loss is harmless. */ + onSessionListChanged?(): void; + /** Volatile per-session hint: the session's skill catalog changed on the + * daemon — refresh that session's skills (slash menu). Carries only the + * session id; loss is harmless (the next hint or reload converges). */ + onSkillCatalogChanged?(sessionId: string): void; onTerminalOutput?(sessionId: string, terminalId: string, data: string, seq: number): void; onTerminalExit?(sessionId: string, terminalId: string, exitCode: number | null): void; } diff --git a/apps/kimi-web/src/composables/client/useWorkspaceState.ts b/apps/kimi-web/src/composables/client/useWorkspaceState.ts index 5f5cdbfa5b..1540c4c3bf 100644 --- a/apps/kimi-web/src/composables/client/useWorkspaceState.ts +++ b/apps/kimi-web/src/composables/client/useWorkspaceState.ts @@ -314,6 +314,9 @@ export function useWorkspaceState(rawState: ExtendedState, deps: UseWorkspaceSta fileDiffLoading, } = deps; let exportInFlight = false; + // Re-entrancy guard for volatile session.list_changed refreshes — a burst of + // events must not stack overlapping re-pulls. + let sessionsListRefreshInFlight = false; async function loadOlderMessages(sessionId: string): Promise { if (rawState.messagesLoadingMoreBySession[sessionId]) return; @@ -819,6 +822,57 @@ export function useWorkspaceState(rawState: ExtendedState, deps: UseWorkspaceSta rawState.sessionsHasMoreByWorkspace = cleared; } + // Volatile per-session `skill_catalog.changed` hint: coalesce a burst of + // frames for one session into a single skills re-pull. + const SKILLS_REFRESH_DEBOUNCE_MS = 400; + const skillRefreshTimers = new Map>(); + + /** Re-pull a session's skills after a volatile `skill_catalog.changed` WS + * hint (a skill file was added/edited/removed on the daemon). Failing + * silently is correct — the event is a bare hint, so a lost refresh is + * indistinguishable from a lost event and the next hint or session reload + * converges the slash menu. Same rationale as refreshSessionsList. */ + function scheduleSkillsRefresh(sessionId: string): void { + const existing = skillRefreshTimers.get(sessionId); + if (existing !== undefined) clearTimeout(existing); + skillRefreshTimers.set( + sessionId, + setTimeout(() => { + skillRefreshTimers.delete(sessionId); + void modelProvider.loadSkillsForSession(sessionId); + }, SKILLS_REFRESH_DEBOUNCE_MS), + ); + } + + /** Re-pull the first session pages after a volatile `session.list_changed` + * WS hint (multi-instance home: another instance created/archived/deleted a + * session). Failing silently (log only) is correct — the event carries no + * payload, so a lost refresh is indistinguishable from a lost event and the + * next hint or manual reload converges the list. */ + async function refreshSessionsList(): Promise { + if (sessionsListRefreshInFlight) return; + sessionsListRefreshInFlight = true; + try { + const sessions = await loadInitialSessionsByWorkspace(); + // undefined = every workspace request failed: keep the current list + // rather than committing a false empty one (same contract as loadAll). + if (sessions === undefined) return; + // Keep the active session even when it falls outside the re-fetched + // first pages (deep link into an old session): dropping it would cut its + // snapshot/status sync and sidebar highlight mid-use. + const activeId = rawState.activeSessionId; + if (activeId !== undefined && !sessions.some((s) => s.id === activeId)) { + const current = rawState.sessions.find((s) => s.id === activeId); + if (current) sessions.push(current); + } + setSessionsPreservingLiveUsage(sessions); + } catch (err) { + console.warn('[kimi-web] sessions list refresh failed', err); + } finally { + sessionsListRefreshInFlight = false; + } + } + /** * Re-read GET /meta and apply the server-self fields (version, open-in * apps, auth bypass, backend engine). Called on first load and on every WS @@ -2751,6 +2805,8 @@ export function useWorkspaceState(rawState: ExtendedState, deps: UseWorkspaceSta loadWorkspaces, loadMoreSessions, loadAllSessions, + refreshSessionsList, + scheduleSkillsRefresh, selectWorkspace, openWorkspace, upsertWorkspacePreserveOrder, diff --git a/apps/kimi-web/src/composables/useKimiWebClient.ts b/apps/kimi-web/src/composables/useKimiWebClient.ts index db5281071a..ff1c6ea618 100644 --- a/apps/kimi-web/src/composables/useKimiWebClient.ts +++ b/apps/kimi-web/src/composables/useKimiWebClient.ts @@ -7,6 +7,22 @@ import { i18n } from '../i18n'; import { traceClientEvent, traceKeyEvent } from '../debug/trace'; import { getKimiWebApi } from '../api'; import { isDaemonApiError, isDaemonNetworkError } from '../api/errors'; +import { + SESSION_HELD_BY_PEER_CODE, + getSessionOwnershipDetails, + narrowSessionOwnershipDetails, + type HeldByPeerDetails, + type SessionOwnershipDetails, +} from '../api/daemon/sessionOwnership'; +import { + bumpRedirectBudget, + decideSessionOwnershipAction, + readRedirectBudget, + serializeRedirectBudget, + type OwnershipDecisionContext, + type OwnershipNotifyKey, + type RedirectBudget, +} from '../lib/sessionOwnershipDecision'; import { reconcileWorkspaceOrder, sortByWorkspaceOrder, @@ -1122,12 +1138,21 @@ function connectEventsIfNeeded(): void { snapshotSyncRunner.request(sessionId); }, - onError(code: number, msg: string, fatal: boolean) { + onError(code: number, msg: string, fatal: boolean, details?: unknown) { traceKeyEvent('ws:error', { status: 'failed', errorCode: code, fatal, }); + // A connection-level 40921 frame (multi-instance surfaces) carries the + // same ownership details as the REST envelope — same decision path. + if (code === SESSION_HELD_BY_PEER_CODE) { + const ownership = narrowSessionOwnershipDetails(details); + if (ownership !== undefined) { + handleSessionOwnership(ownership, { operation: 'ws' }); + return; + } + } pushWarning({ severity: 'error', title: i18n.global.t('warnings.wsTitle'), @@ -1138,6 +1163,19 @@ function connectEventsIfNeeded(): void { }); }, + // Volatile multi-instance hint: some instance sharing this home changed + // the session list — re-pull it (debounced, bursts coalesce). + onSessionListChanged() { + scheduleSessionsListRefresh(); + }, + + // Volatile per-session hint: this session's skill catalog changed on the + // daemon (e.g. a skill file edited on disk) — re-pull the skills list so + // the composer's `/` menu picks it up (debounced, bursts coalesce). + onSkillCatalogChanged(sessionId: string) { + workspaceState.scheduleSkillsRefresh(sessionId); + }, + onConnectionChange(connected: boolean) { traceKeyEvent('ws:connection', { status: connected ? 'connected' : 'disconnected', @@ -1162,7 +1200,7 @@ function connectEventsIfNeeded(): void { // Journal epoch per session, learned from snapshots / resync frames. Not // reactive — only consulted when building the subscribe cursor. -const epochBySession: Record = {}; +const epochBySession: Record = {}; // onResync resets the event projector, so that path must apply a snapshot even // if a newer global event advances the local cursor while the GET is in flight. const sessionsRequiringSnapshot = new Set(); @@ -1361,6 +1399,10 @@ function pushOperationFailure( phase: network ? err.phase : undefined, httpStatus: network ? err.status : undefined, }); + // Session-ownership contention (40921) is an expected multi-instance outcome + // with its own UX — redirect / auto-retry / targeted message — so it never + // reaches the generic failure toast. + if (handleSessionOwnershipError(err, { operation })) return; pushWarning(operationFailureNotice(operation, err, opts)); } @@ -1381,6 +1423,159 @@ function goalErrorMessage(err: unknown): string | undefined { return key ? i18n.global.t(key) : undefined; } +// --------------------------------------------------------------------------- +// Session ownership (multi-instance, envelope code 40921) +// --------------------------------------------------------------------------- +// +// Several kap-server instances can share one home. Opening (or writing to) a +// session whose lease is held by a sibling instance is rejected with +// SESSION_HELD_BY_PEER + structured details. The client-facing decision +// (redirect to the holder / auto-retry while it is mid-creation / targeted +// terminal message) is pure and lives in lib/sessionOwnershipDecision; this +// cluster owns the side effects: sessionStorage loop guard, toasts, the +// full-page navigation, and the creating retry timer. + +/** Loop-guard persistence: redirects started inside this window count towards + * PEER_MAX_REDIRECT_ATTEMPTS; older attempts expire so legitimate future + * redirects are never blocked by ancient history. */ +const PEER_REDIRECT_STORAGE_KEY = 'kimi.peerRedirectBudget'; +const PEER_REDIRECT_WINDOW_MS = 5 * 60_000; +const PEER_MAX_REDIRECT_ATTEMPTS = 3; +const OWNERSHIP_MAX_CREATING_ATTEMPTS = 3; +const OWNERSHIP_DEFAULT_RETRY_DELAY_MS = 1_000; + +/** 'creating' retries already fired per session (counts towards the cap). */ +const ownershipCreatingAttempts = new Map(); + +function readPeerRedirectBudgetSafe(now: number): RedirectBudget { + if (typeof window === 'undefined') return { count: 0, windowStart: now }; + try { + return readRedirectBudget( + window.sessionStorage.getItem(PEER_REDIRECT_STORAGE_KEY), + now, + PEER_REDIRECT_WINDOW_MS, + ); + } catch { + return { count: 0, windowStart: now }; + } +} + +function persistPeerRedirectBudgetSafe(budget: RedirectBudget): void { + if (typeof window === 'undefined') return; + try { + window.sessionStorage.setItem(PEER_REDIRECT_STORAGE_KEY, serializeRedirectBudget(budget)); + } catch { + // Storage unavailable (private mode…): the loop guard degrades to + // per-page-load only, which is still safe. + } +} + +type OwnershipNoticeMessageKey = OwnershipNotifyKey | 'redirecting' | 'creating'; + +function ownershipNotice(key: OwnershipNoticeMessageKey, params?: Record): AppNotice { + return { + // redirecting/creating are transient progress, the rest are terminal states. + severity: key === 'redirecting' || key === 'creating' ? 'info' : 'error', + title: i18n.global.t('warnings.ownership.title'), + message: + params === undefined + ? i18n.global.t(`warnings.ownership.${key}`) + : i18n.global.t(`warnings.ownership.${key}`, params), + }; +} + +function ownershipDecisionContext(overrides?: Partial): OwnershipDecisionContext { + const loc = typeof window === 'undefined' ? undefined : window.location; + return { + currentHost: loc?.host ?? '', + currentPath: loc === undefined ? '/' : `${loc.pathname}${loc.search}${loc.hash}`, + redirectAttempts: readPeerRedirectBudgetSafe(Date.now()).count, + maxRedirectAttempts: PEER_MAX_REDIRECT_ATTEMPTS, + creatingAttempts: 0, + maxCreatingAttempts: OWNERSHIP_MAX_CREATING_ATTEMPTS, + defaultRetryDelayMs: OWNERSHIP_DEFAULT_RETRY_DELAY_MS, + ...overrides, + }; +} + +/** Run the ownership decision for an already-narrowed details payload and + * apply its side effects (redirect / notice). Auto-retry for 'creating' only + * exists on the snapshot open path (scheduleOwnershipCreatingRetry); at this + * blanket level a 'retry' decision degrades to the informational notice. */ +function handleSessionOwnership(details: SessionOwnershipDetails, ctx: { operation: string }): void { + const action = decideSessionOwnershipAction(details, ownershipDecisionContext()); + traceKeyEvent('session:ownership', { + operation: ctx.operation, + action: action.type, + kind: details.kind, + phase: details.kind === 'held-by-peer' ? details.phase : undefined, + }); + switch (action.type) { + case 'redirect': { + pushWarning(ownershipNotice('redirecting', { origin: action.origin })); + persistPeerRedirectBudgetSafe(bumpRedirectBudget(readPeerRedirectBudgetSafe(Date.now()))); + if (typeof window !== 'undefined') { + window.location.assign(action.url); + } + return; + } + case 'retry': + pushWarning(ownershipNotice('creating')); + return; + case 'notify': + pushWarning(ownershipNotice(action.key)); + return; + } +} + +/** Intercept an operation failure: when it carries ownership details (40921) + * run the ownership UX and report the error as handled — the caller must NOT + * additionally surface its generic failure toast. */ +function handleSessionOwnershipError(err: unknown, ctx: { operation: string }): boolean { + const details = getSessionOwnershipDetails(err); + if (details === undefined) return false; + handleSessionOwnership(details, ctx); + return true; +} + +/** Re-fire the snapshot sync after a 'creating' rejection, honouring the + * server-provided retry_after_ms and capping the attempts. The first attempt + * surfaces a transient notice; exhausting the cap surfaces the timeout one. */ +function scheduleOwnershipCreatingRetry(sessionId: string, details: HeldByPeerDetails): void { + const attempts = ownershipCreatingAttempts.get(sessionId) ?? 0; + const action = decideSessionOwnershipAction( + details, + ownershipDecisionContext({ creatingAttempts: attempts }), + ); + if (action.type === 'retry') { + ownershipCreatingAttempts.set(sessionId, attempts + 1); + if (attempts === 0) pushWarning(ownershipNotice('creating')); + traceKeyEvent('session:ownership-creating-retry', { + sessionId, + attempt: attempts + 1, + delayMs: action.delayMs, + }); + setTimeout(() => { + snapshotSyncRunner.request(sessionId); + }, action.delayMs); + return; + } + ownershipCreatingAttempts.delete(sessionId); + if (action.type === 'notify') pushWarning(ownershipNotice(action.key)); +} + +// The volatile session.list_changed event only says "something changed somewhere" +// — coalesce a burst into a single sidebar list re-pull. +const SESSIONS_LIST_REFRESH_DEBOUNCE_MS = 400; +let sessionsListRefreshTimer: ReturnType | null = null; +function scheduleSessionsListRefresh(): void { + if (sessionsListRefreshTimer !== null) clearTimeout(sessionsListRefreshTimer); + sessionsListRefreshTimer = setTimeout(() => { + sessionsListRefreshTimer = null; + void workspaceState.refreshSessionsList(); + }, SESSIONS_LIST_REFRESH_DEBOUNCE_MS); +} + async function handleSessionNotFound(sessionId: string): Promise { forgetSession(sessionId); @@ -1515,6 +1710,7 @@ async function syncSessionFromSnapshot(sessionId: string): Promise= ctx.maxCreatingAttempts) { + return { type: 'notify', key: 'creatingTimeout' }; + } + return { + type: 'retry', + delayMs: details.retry_after_ms ?? ctx.defaultRetryDelayMs, + }; + } + + case 'routable': { + const origin = details.address !== undefined ? normalizePeerOrigin(details.address) : undefined; + if (origin === undefined) return { type: 'notify', key: 'redirectUnavailable' }; + // Loop guard A: the "holder" resolves to ourselves — redirecting would + // reload the same page and fail the same way forever. + try { + if (new URL(origin).host === ctx.currentHost) { + return { type: 'notify', key: 'redirectSameHost' }; + } + } catch { + return { type: 'notify', key: 'redirectUnavailable' }; + } + // Loop guard B: A → B → A → … chains (stale registry, clock skew, two + // tabs fighting) must converge to a message instead of a reload storm. + if (ctx.redirectAttempts >= ctx.maxRedirectAttempts) { + return { type: 'notify', key: 'redirectLoopGuard' }; + } + return { type: 'redirect', url: buildPeerTargetUrl(origin, ctx.currentPath), origin }; + } + + case 'holder-unresponsive': + return { type: 'notify', key: 'holderUnresponsive' }; + + case 'held-by-local-instance': + return { type: 'notify', key: 'heldByLocalInstance' }; + } +} + +// --------------------------------------------------------------------------- +// Redirect-attempt budget (loop guard persistence) +// --------------------------------------------------------------------------- + +/** Redirects started within this window count towards the loop guard. */ +export interface RedirectBudget { + count: number; + windowStart: number; +} + +/** Parse the persisted budget. An expired or malformed record starts fresh + * (stale attempts must not block a legitimate future redirect forever). */ +export function readRedirectBudget( + raw: string | null, + now: number, + windowMs: number, +): RedirectBudget { + if (raw !== null) { + try { + const parsed = JSON.parse(raw) as { count?: unknown; windowStart?: unknown }; + if ( + typeof parsed.count === 'number' && + Number.isFinite(parsed.count) && + parsed.count >= 0 && + typeof parsed.windowStart === 'number' && + Number.isFinite(parsed.windowStart) && + now - parsed.windowStart < windowMs + ) { + return { count: parsed.count, windowStart: parsed.windowStart }; + } + } catch { + // malformed — fall through to a fresh budget + } + } + return { count: 0, windowStart: now }; +} + +/** Record one more redirect start within the budget's window. */ +export function bumpRedirectBudget(budget: RedirectBudget): RedirectBudget { + return { count: budget.count + 1, windowStart: budget.windowStart }; +} + +export function serializeRedirectBudget(budget: RedirectBudget): string { + return JSON.stringify({ count: budget.count, windowStart: budget.windowStart }); +} diff --git a/apps/kimi-web/test/session-ownership.test.ts b/apps/kimi-web/test/session-ownership.test.ts new file mode 100644 index 0000000000..c2cd008808 --- /dev/null +++ b/apps/kimi-web/test/session-ownership.test.ts @@ -0,0 +1,259 @@ +// apps/kimi-web/test/session-ownership.test.ts +// Session-ownership (40921) client handling: wire-details narrowing and the +// pure redirect / retry / notify decision. See +// src/api/daemon/sessionOwnership.ts and src/lib/sessionOwnershipDecision.ts. + +import { describe, expect, it } from 'vitest'; + +import { DaemonApiError } from '../src/api/errors'; +import { + SESSION_HELD_BY_PEER_CODE, + getSessionOwnershipDetails, + narrowSessionOwnershipDetails, + type SessionOwnershipDetails, +} from '../src/api/daemon/sessionOwnership'; +import { + buildPeerTargetUrl, + bumpRedirectBudget, + decideSessionOwnershipAction, + normalizePeerOrigin, + readRedirectBudget, + serializeRedirectBudget, + type OwnershipDecisionContext, +} from '../src/lib/sessionOwnershipDecision'; + +const ROUTABLE: SessionOwnershipDetails = { + kind: 'held-by-peer', + phase: 'routable', + address: 'http://127.0.0.1:58628', +}; + +function makeCtx(overrides?: Partial): OwnershipDecisionContext { + return { + currentHost: '127.0.0.1:58627', + currentPath: '/sessions/sess_1?debug=1#top', + redirectAttempts: 0, + maxRedirectAttempts: 3, + creatingAttempts: 0, + maxCreatingAttempts: 3, + defaultRetryDelayMs: 1_000, + ...overrides, + }; +} + +describe('narrowSessionOwnershipDetails', () => { + it('accepts a well-formed held-by-peer payload', () => { + expect( + narrowSessionOwnershipDetails({ + kind: 'held-by-peer', + phase: 'routable', + address: 'http://127.0.0.1:58628', + retry_after_ms: 500, + }), + ).toEqual({ + kind: 'held-by-peer', + phase: 'routable', + address: 'http://127.0.0.1:58628', + retry_after_ms: 500, + }); + }); + + it('accepts unregistered-writer', () => { + expect(narrowSessionOwnershipDetails({ kind: 'unregistered-writer' })).toEqual({ + kind: 'unregistered-writer', + }); + }); + + it('drops invalid optional fields instead of failing the whole payload', () => { + expect( + narrowSessionOwnershipDetails({ + kind: 'held-by-peer', + phase: 'creating', + address: 42, + retry_after_ms: -1, + }), + ).toEqual({ kind: 'held-by-peer', phase: 'creating', address: undefined, retry_after_ms: undefined }); + }); + + it.each([ + ['null', null], + ['a string', 'held-by-peer'], + ['an empty object', {}], + ['an unknown kind', { kind: 'held-by-something-else' }], + ['an unknown phase', { kind: 'held-by-peer', phase: 'mystery' }], + ['a non-string phase', { kind: 'held-by-peer', phase: 1 }], + ])('rejects %s', (_label, input) => { + expect(narrowSessionOwnershipDetails(input)).toBeUndefined(); + }); +}); + +describe('getSessionOwnershipDetails', () => { + function apiError(code: number, details: unknown): DaemonApiError { + return new DaemonApiError({ code, msg: 'held', requestId: 'req_1', details }); + } + + it('extracts details from a 40921 DaemonApiError', () => { + expect( + getSessionOwnershipDetails(apiError(SESSION_HELD_BY_PEER_CODE, { kind: 'unregistered-writer' })), + ).toEqual({ kind: 'unregistered-writer' }); + }); + + it('returns undefined for other codes, malformed details, and non-API errors', () => { + expect(getSessionOwnershipDetails(apiError(40401, { kind: 'unregistered-writer' }))).toBeUndefined(); + expect(getSessionOwnershipDetails(apiError(SESSION_HELD_BY_PEER_CODE, { nope: 1 }))).toBeUndefined(); + expect(getSessionOwnershipDetails(new Error('boom'))).toBeUndefined(); + expect(getSessionOwnershipDetails(undefined)).toBeUndefined(); + }); + + it('matches the structural DaemonApiError guard (cross-realm error)', () => { + const crossRealm = { + name: 'DaemonApiError', + code: SESSION_HELD_BY_PEER_CODE, + details: { kind: 'held-by-peer', phase: 'held-by-local-instance' }, + }; + expect(getSessionOwnershipDetails(crossRealm)).toEqual({ + kind: 'held-by-peer', + phase: 'held-by-local-instance', + address: undefined, + retry_after_ms: undefined, + }); + }); +}); + +describe('normalizePeerOrigin', () => { + it.each([ + ['http://127.0.0.1:58628', 'http://127.0.0.1:58628'], + ['127.0.0.1:58628', 'http://127.0.0.1:58628'], + ['http://127.0.0.1:58628/api/v1/sessions?x=1#y', 'http://127.0.0.1:58628'], + ['https://kimi.example.com/', 'https://kimi.example.com'], + [' localhost:58628 ', 'http://localhost:58628'], + ])('normalizes %s → %s', (input, expected) => { + expect(normalizePeerOrigin(input)).toBe(expected); + }); + + it.each([ + ['empty', ''], + ['whitespace', ' '], + ['non-http scheme', 'ftp://127.0.0.1:58628'], + ])('rejects %s', (_label, input) => { + expect(normalizePeerOrigin(input)).toBeUndefined(); + }); +}); + +describe('buildPeerTargetUrl', () => { + it('joins origin and path exactly once', () => { + expect(buildPeerTargetUrl('http://127.0.0.1:58628', '/sessions/s_1?a=1#h')).toBe( + 'http://127.0.0.1:58628/sessions/s_1?a=1#h', + ); + expect(buildPeerTargetUrl('http://127.0.0.1:58628', '')).toBe('http://127.0.0.1:58628/'); + }); +}); + +describe('decideSessionOwnershipAction', () => { + it('unregistered-writer → terminal notice', () => { + expect(decideSessionOwnershipAction({ kind: 'unregistered-writer' }, makeCtx())).toEqual({ + type: 'notify', + key: 'unregisteredWriter', + }); + }); + + it('routable with address → redirect carrying origin + current path', () => { + expect(decideSessionOwnershipAction(ROUTABLE, makeCtx())).toEqual({ + type: 'redirect', + origin: 'http://127.0.0.1:58628', + url: 'http://127.0.0.1:58628/sessions/sess_1?debug=1#top', + }); + }); + + it('routable with a bare host:port address → scheme defaults to http', () => { + const action = decideSessionOwnershipAction( + { kind: 'held-by-peer', phase: 'routable', address: '127.0.0.1:58628' }, + makeCtx(), + ); + expect(action).toMatchObject({ type: 'redirect', origin: 'http://127.0.0.1:58628' }); + }); + + it('routable resolving to the current host → same-host notice, no redirect loop', () => { + expect( + decideSessionOwnershipAction(ROUTABLE, makeCtx({ currentHost: '127.0.0.1:58628' })), + ).toEqual({ type: 'notify', key: 'redirectSameHost' }); + }); + + it('routable after too many redirects → loop-guard notice', () => { + expect(decideSessionOwnershipAction(ROUTABLE, makeCtx({ redirectAttempts: 3 }))).toEqual({ + type: 'notify', + key: 'redirectLoopGuard', + }); + }); + + it('routable without a usable address → unavailable notice', () => { + expect( + decideSessionOwnershipAction({ kind: 'held-by-peer', phase: 'routable' }, makeCtx()), + ).toEqual({ type: 'notify', key: 'redirectUnavailable' }); + expect( + decideSessionOwnershipAction( + { kind: 'held-by-peer', phase: 'routable', address: 'ftp://x' }, + makeCtx(), + ), + ).toEqual({ type: 'notify', key: 'redirectUnavailable' }); + }); + + it('creating → retry with the server hint, capped by attempts', () => { + expect( + decideSessionOwnershipAction( + { kind: 'held-by-peer', phase: 'creating', retry_after_ms: 250 }, + makeCtx(), + ), + ).toEqual({ type: 'retry', delayMs: 250 }); + // No hint → default delay. + expect(decideSessionOwnershipAction({ kind: 'held-by-peer', phase: 'creating' }, makeCtx())).toEqual({ + type: 'retry', + delayMs: 1_000, + }); + // Cap exhausted → timeout notice. + expect( + decideSessionOwnershipAction( + { kind: 'held-by-peer', phase: 'creating' }, + makeCtx({ creatingAttempts: 3 }), + ), + ).toEqual({ type: 'notify', key: 'creatingTimeout' }); + }); + + it('holder-unresponsive and held-by-local-instance → terminal notices', () => { + expect( + decideSessionOwnershipAction({ kind: 'held-by-peer', phase: 'holder-unresponsive' }, makeCtx()), + ).toEqual({ type: 'notify', key: 'holderUnresponsive' }); + expect( + decideSessionOwnershipAction({ kind: 'held-by-peer', phase: 'held-by-local-instance' }, makeCtx()), + ).toEqual({ type: 'notify', key: 'heldByLocalInstance' }); + }); +}); + +describe('redirect budget (loop guard persistence)', () => { + const WINDOW_MS = 5 * 60_000; + + it('starts fresh on null / malformed / expired records', () => { + expect(readRedirectBudget(null, 1_000, WINDOW_MS)).toEqual({ count: 0, windowStart: 1_000 }); + expect(readRedirectBudget('not-json', 1_000, WINDOW_MS)).toEqual({ count: 0, windowStart: 1_000 }); + expect(readRedirectBudget('{"count":5}', 1_000, WINDOW_MS)).toEqual({ count: 0, windowStart: 1_000 }); + const stale = serializeRedirectBudget({ count: 2, windowStart: 1_000 }); + expect(readRedirectBudget(stale, 1_000 + WINDOW_MS + 1, WINDOW_MS)).toEqual({ + count: 0, + windowStart: 1_000 + WINDOW_MS + 1, + }); + }); + + it('keeps counts inside the window and bumps without moving windowStart', () => { + const raw = serializeRedirectBudget({ count: 2, windowStart: 1_000 }); + const budget = readRedirectBudget(raw, 2_000, WINDOW_MS); + expect(budget).toEqual({ count: 2, windowStart: 1_000 }); + expect(bumpRedirectBudget(budget)).toEqual({ count: 3, windowStart: 1_000 }); + }); + + it('round-trips through serialize', () => { + const budget = { count: 1, windowStart: 123_456 }; + expect(readRedirectBudget(serializeRedirectBudget(budget), budget.windowStart + 10, WINDOW_MS)).toEqual( + budget, + ); + }); +}); diff --git a/apps/kimi-web/test/workspace-state.test.ts b/apps/kimi-web/test/workspace-state.test.ts index 5ea1a8615c..8231fcc476 100644 --- a/apps/kimi-web/test/workspace-state.test.ts +++ b/apps/kimi-web/test/workspace-state.test.ts @@ -2059,3 +2059,68 @@ describe('useWorkspaceState — upsertWorkspacePreserveOrder hidden roots', () = expect(state.hiddenWorkspaceRoots).toEqual(['/home/Foo']); }); }); + +// Volatile `skill_catalog.changed` hint: a burst of frames for one session +// must coalesce into a single skills re-pull (mirrors the +// session.list_changed sidebar refresh debounce). +describe('useWorkspaceState — scheduleSkillsRefresh', () => { + beforeEach(() => { + vi.useFakeTimers(); + }); + + afterEach(() => { + vi.useRealTimers(); + }); + + function skillsRefreshDeps(loadSkillsForSession: ReturnType): UseWorkspaceStateDeps { + return { + ...createDeps(), + modelProvider: { + draftModel: ref(null), + skillsBySession: ref({}), + loadSkillsForSession, + } as unknown as UseWorkspaceStateDeps['modelProvider'], + }; + } + + it('coalesces a burst of hints into one skills re-pull per session', () => { + const loadSkillsForSession = vi.fn().mockResolvedValue(undefined); + const ws = useWorkspaceState(createState(), skillsRefreshDeps(loadSkillsForSession)); + + ws.scheduleSkillsRefresh('sess_1'); + ws.scheduleSkillsRefresh('sess_1'); + ws.scheduleSkillsRefresh('sess_1'); + expect(loadSkillsForSession).not.toHaveBeenCalled(); + + vi.advanceTimersByTime(400); + expect(loadSkillsForSession).toHaveBeenCalledTimes(1); + expect(loadSkillsForSession).toHaveBeenCalledWith('sess_1'); + }); + + it('trailing-refresh: a hint inside the debounce window re-arms it', () => { + const loadSkillsForSession = vi.fn().mockResolvedValue(undefined); + const ws = useWorkspaceState(createState(), skillsRefreshDeps(loadSkillsForSession)); + + ws.scheduleSkillsRefresh('sess_1'); + vi.advanceTimersByTime(300); + ws.scheduleSkillsRefresh('sess_1'); + vi.advanceTimersByTime(300); + expect(loadSkillsForSession).not.toHaveBeenCalled(); + + vi.advanceTimersByTime(100); + expect(loadSkillsForSession).toHaveBeenCalledTimes(1); + }); + + it('debounces per session, not globally', () => { + const loadSkillsForSession = vi.fn().mockResolvedValue(undefined); + const ws = useWorkspaceState(createState(), skillsRefreshDeps(loadSkillsForSession)); + + ws.scheduleSkillsRefresh('sess_1'); + ws.scheduleSkillsRefresh('sess_2'); + vi.advanceTimersByTime(400); + + expect(loadSkillsForSession).toHaveBeenCalledTimes(2); + expect(loadSkillsForSession).toHaveBeenCalledWith('sess_1'); + expect(loadSkillsForSession).toHaveBeenCalledWith('sess_2'); + }); +}); diff --git a/apps/kimi-web/test/ws-lifecycle.test.ts b/apps/kimi-web/test/ws-lifecycle.test.ts index e70686a396..91d5edaa9d 100644 --- a/apps/kimi-web/test/ws-lifecycle.test.ts +++ b/apps/kimi-web/test/ws-lifecycle.test.ts @@ -144,3 +144,149 @@ describe('DaemonEventSocket reconnect + staleness', () => { expect(socket.health().open).toBe(false); }); }); + +describe('DaemonEventSocket frame dispatch (multi-instance surface)', () => { + let originalWebSocket: typeof globalThis.WebSocket; + + beforeEach(() => { + FakeWebSocket.instances = []; + originalWebSocket = globalThis.WebSocket; + globalThis.WebSocket = FakeWebSocket as unknown as typeof WebSocket; + }); + + afterEach(() => { + globalThis.WebSocket = originalWebSocket; + vi.useRealTimers(); + }); + + it('consumes session.list_changed (bare and event.-prefixed) via onSessionListChanged only', () => { + let wireEvents = 0; + let listChanged = 0; + const handlers: DaemonEventSocketHandlers = { + onWireEvent: () => { + wireEvents += 1; + }, + onResync: () => {}, + onConnectionState: () => {}, + onError: () => {}, + onSessionListChanged: () => { + listChanged += 1; + }, + }; + const socket = new DaemonEventSocket(WS_URL, CLIENT_ID, handlers); + socket.connect(); + const ws = FakeWebSocket.instances[0]!; + ws.emitMessage(SERVER_HELLO); + + ws.emitMessage({ type: 'session.list_changed', payload: {} }); + ws.emitMessage({ type: 'event.session.list_changed', payload: {} }); + + expect(listChanged).toBe(2); + expect(wireEvents).toBe(0); + }); + + it('consumes skill_catalog.changed (bare and event.-prefixed) via onSkillCatalogChanged only', () => { + let wireEvents = 0; + let rawAgentEvents = 0; + const changed: string[] = []; + const handlers: DaemonEventSocketHandlers = { + onWireEvent: () => { + wireEvents += 1; + }, + onRawAgentEvent: () => { + rawAgentEvents += 1; + }, + onResync: () => {}, + onConnectionState: () => {}, + onError: () => {}, + onSkillCatalogChanged: (sessionId) => { + changed.push(sessionId); + }, + }; + const socket = new DaemonEventSocket(WS_URL, CLIENT_ID, handlers); + socket.connect(); + const ws = FakeWebSocket.instances[0]!; + ws.emitMessage(SERVER_HELLO); + + ws.emitMessage({ + type: 'skill_catalog.changed', + seq: 1, + session_id: 'sess_1', + timestamp: '2026-01-01T00:00:00.000Z', + volatile: true, + payload: { type: 'skill_catalog.changed', sourceId: 'workspace-file' }, + }); + ws.emitMessage({ + type: 'event.skill_catalog.changed', + seq: 2, + session_id: 'sess_2', + timestamp: '2026-01-01T00:00:00.000Z', + volatile: true, + payload: { type: 'skill_catalog.changed', sourceId: 'plugin' }, + }); + + expect(changed).toEqual(['sess_1', 'sess_2']); + expect(wireEvents).toBe(0); + expect(rawAgentEvents).toBe(0); + }); + + it('passes connection-level error-frame details through to onError', () => { + const errors: Array<{ code: number; msg: string; fatal: boolean; details: unknown }> = []; + const handlers: DaemonEventSocketHandlers = { + onWireEvent: () => {}, + onResync: () => {}, + onConnectionState: () => {}, + onError: (code, msg, fatal, details) => { + errors.push({ code, msg, fatal, details }); + }, + }; + const socket = new DaemonEventSocket(WS_URL, CLIENT_ID, handlers); + socket.connect(); + const ws = FakeWebSocket.instances[0]!; + ws.emitMessage(SERVER_HELLO); + + const ownership = { kind: 'held-by-peer', phase: 'routable', address: 'http://127.0.0.1:58628' }; + ws.emitMessage({ type: 'error', payload: { code: 40921, msg: 'session held by peer', fatal: false, details: ownership } }); + + expect(errors).toEqual([{ code: 40921, msg: 'session held by peer', fatal: false, details: ownership }]); + }); + + it('keeps session-scoped error frames on the agent-event path (no details leak to onError)', () => { + const errors: unknown[] = []; + const raw: Array<{ type: string; session_id: string; payload: unknown }> = []; + const handlers: DaemonEventSocketHandlers = { + onWireEvent: () => {}, + onResync: () => {}, + onConnectionState: () => {}, + onError: (...args) => { + errors.push(args); + }, + onRawAgentEvent: (frame) => { + raw.push(frame); + }, + }; + const socket = new DaemonEventSocket(WS_URL, CLIENT_ID, handlers); + socket.connect(); + const ws = FakeWebSocket.instances[0]!; + ws.emitMessage(SERVER_HELLO); + + ws.emitMessage({ + type: 'error', + session_id: 'sess_1', + seq: 7, + timestamp: 't', + payload: { code: 40300, msg: 'provider denied' }, + }); + + expect(errors).toEqual([]); + expect(raw).toEqual([ + { + type: 'error', + seq: 7, + session_id: 'sess_1', + timestamp: 't', + payload: { code: 40300, msg: 'provider denied' }, + }, + ]); + }); +}); diff --git a/packages/agent-core-v2/scripts/check-domain-layers.mjs b/packages/agent-core-v2/scripts/check-domain-layers.mjs index 81f1731e43..4e497c97d3 100644 --- a/packages/agent-core-v2/scripts/check-domain-layers.mjs +++ b/packages/agent-core-v2/scripts/check-domain-layers.mjs @@ -103,6 +103,10 @@ const DOMAIN_LAYER = new Map([ ['config', 2], ['workspaceLocalConfig', 2], ['sessionFs', 2], + // `sessionFileLedger` is the Session-scope optimistic-concurrency ledger for + // the write path: it consumes the `sessionFs` dirty-tick state and the L1 + // hostFs stat, so it sits beside `sessionFs` at L2. + ['sessionFileLedger', 2], ['process', 2], ['workspaceRegistry', 2], ['hostFolderBrowser', 2], @@ -146,6 +150,10 @@ const DOMAIN_LAYER = new Map([ ['usage', 4], ['runtime', 4], ['toolDedupe', 4], + // `fileFencing` is the Agent-scope optimistic-concurrency gate: a + // tool-executor hook participant (L3) over the `sessionFileLedger` (L2) + // and flag state (L3), so it sits at L4 beside `toolDedupe`. + ['fileFencing', 4], ['toolSelect', 4], ['contextMemory', 4], ['contextInjector', 4], @@ -209,6 +217,13 @@ const DOMAIN_LAYER = new Map([ // through `profile` (L4). Its highest real dependency is `agentLifecycle`, // so it sits in L6 beside `workspaceCommand`. ['sessionInit', 6], + // `sessionLease` owns the per-session write lease (`SessionLease`, the + // Session-scope seeded `ISessionLeaseService` fencing capability, and the + // App-scope contact provider seed). It builds on the L1 cross-process lock + // and the L1 write-authority contract, and is consumed by `sessionLifecycle` + // and `sessionMetadata` (both L6); nothing it imports rises past L1, and it + // sits in L6 beside its consumers. + ['sessionLease', 6], // L7 — boundary ['approval', 7], ['question', 7], diff --git a/packages/agent-core-v2/src/agent/fileFencing/fileFencing.ts b/packages/agent-core-v2/src/agent/fileFencing/fileFencing.ts new file mode 100644 index 0000000000..c69163976c --- /dev/null +++ b/packages/agent-core-v2/src/agent/fileFencing/fileFencing.ts @@ -0,0 +1,19 @@ +/** + * `fileFencing` domain (L4) — `IAgentFileFencingService` contract. + * + * The Agent-scope tool-hook participant that gates `Write`/`Edit` calls on + * the `sessionFileLedger` optimistic-concurrency verdict and re-baselines + * the ledger after successful `Read`/`Write`/`Edit` executions. The service + * exists for its constructor side effects (it registers by name on the + * `toolExecutor` hook slots); nothing calls its methods. Bound at Agent + * scope. + */ + +import { createDecorator, type ServiceIdentifier } from '#/_base/di/instantiation'; + +export interface IAgentFileFencingService { + readonly _serviceBrand: undefined; +} + +export const IAgentFileFencingService: ServiceIdentifier = + createDecorator('agentFileFencingService'); diff --git a/packages/agent-core-v2/src/agent/fileFencing/fileFencingService.ts b/packages/agent-core-v2/src/agent/fileFencing/fileFencingService.ts new file mode 100644 index 0000000000..b65be3b672 --- /dev/null +++ b/packages/agent-core-v2/src/agent/fileFencing/fileFencingService.ts @@ -0,0 +1,167 @@ +/** + * `fileFencing` domain (L4) — `IAgentFileFencingService` implementation. + * + * Registers the `writeFencing` participant on the `toolExecutor` + * `onBeforeExecuteTool` / `onDidExecuteTool` hook slots, matching + * `Read`/`Write`/`Edit` by tool name and letting every other tool pass + * through. The target path comes from the resolved execution's file accesses + * — the exact canonical path the tool itself computed — so the ledger and + * the watcher key it identically. The before-hook records the target keyed + * by `toolCall.id` (cleared in the did-hook and swept on turn change) and, + * for `Write`/`Edit`, computes the ledger verdict: with the `multi_server` + * flag on, `stale` blocks with an outside-modification conflict and + * `no-baseline` blocks with a read-first reason (Edit-over-existing, or + * Write over an already existing file); with the flag off nothing ever + * blocks and the verdict is marked for the did-hook. The did-hook + * re-baselines the ledger after any successful fenced call (ranged Reads + * excepted — per the ledger contract they never count as full reads) and, + * for a flag-off stale mark, composes a `` advisory onto the result + * note; direct creation of a new file is verdict-`clean`, so it is never + * advisory'd. Watcher echos of the session's own writes are absorbed by the + * ledger's stat punch, so consecutive Edits stay clean. Checked after + * `permission` (ignition order is set by `agentLifecycle`). Bound at Agent + * scope. + */ + +import { InstantiationType } from '#/_base/di/extensions'; +import { Disposable } from '#/_base/di/lifecycle'; +import { LifecycleScope, registerScopedService } from '#/_base/di/scope'; +import { IAgentToolExecutorService } from '#/agent/toolExecutor/toolExecutor'; +import type { + ToolBeforeExecuteContext, + ToolDidExecuteContext, + ToolExecutionHookContext, +} from '#/agent/toolExecutor/toolHooks'; +import { IFlagService } from '#/app/flag/flag'; +import { MULTI_SERVER_FLAG_ID } from '#/app/multiServer/flag'; +import { + ISessionFileLedger, + type FileLedgerVerdict, +} from '#/session/sessionFileLedger/fileLedger'; +import type { ToolAccesses } from '#/tool/toolContract'; + +import { IAgentFileFencingService } from './fileFencing'; + +const READ_TOOL = 'Read'; +const WRITE_TOOLS = new Set(['Write', 'Edit']); + +interface FencingTarget { + readonly toolName: string; + readonly path: string; +} + +function isFenced(ctx: ToolExecutionHookContext): boolean { + return ctx.toolCall.name === READ_TOOL || WRITE_TOOLS.has(ctx.toolCall.name); +} + +function targetPathOf(ctx: ToolBeforeExecuteContext): string | undefined { + return fileAccessPath(ctx.execution.accesses); +} + +function fileAccessPath(accesses: ToolAccesses | undefined): string | undefined { + if (accesses === undefined) return undefined; + for (const access of accesses) { + if (access.kind === 'file') return access.path; + } + return undefined; +} + +function isRangedRead(args: unknown): boolean { + if (typeof args !== 'object' || args === null) return false; + const input = args as { readonly line_offset?: unknown; readonly n_lines?: unknown }; + return input.line_offset !== undefined || input.n_lines !== undefined; +} + +function blockReason(toolName: string, path: string, verdict: FileLedgerVerdict): string { + if (verdict === 'no-baseline') { + return toolName === 'Edit' + ? `Editing "${path}" is blocked: the file exists but has not been read in this session. Read it first, then retry the edit.` + : `Writing "${path}" is blocked: the file already exists but has not been read in this session. Read it first, then retry the write.`; + } + const verb = toolName === 'Edit' ? 'Editing' : 'Writing'; + return ( + `${verb} "${path}" is blocked: the file changed on disk since it was last read or written ` + + 'in this session. Read it again, then retry.' + ); +} + +function advisoryNote(target: FencingTarget, verdict: FileLedgerVerdict): string { + const body = + verdict === 'no-baseline' + ? `"${target.path}" already existed on disk and had not been read in this session; your change overwrote it anyway.` + : `"${target.path}" changed on disk since it was last read in this session; your change was applied anyway.`; + return `Warning: ${body} Read the file to verify the current content.`; +} + +function composeNote(existing: string | undefined, advisory: string): string { + return existing === undefined || existing.length === 0 + ? advisory + : `${existing}\n${advisory}`; +} + +export class AgentFileFencingService extends Disposable implements IAgentFileFencingService { + declare readonly _serviceBrand: undefined; + + private readonly targets = new Map(); + private readonly staleMarks = new Map(); + private markerTurnId: number | undefined; + + constructor( + @ISessionFileLedger private readonly ledger: ISessionFileLedger, + @IFlagService private readonly flags: IFlagService, + @IAgentToolExecutorService toolExecutor: IAgentToolExecutorService, + ) { + super(); + toolExecutor.hooks.onBeforeExecuteTool.register('writeFencing', async (ctx, next) => { + await this.onBefore(ctx); + if (ctx.decision?.block === true) return; + await next(); + }); + toolExecutor.hooks.onDidExecuteTool.register('writeFencing', async (ctx, next) => { + await this.onDid(ctx); + await next(); + }); + } + + private async onBefore(ctx: ToolBeforeExecuteContext): Promise { + if (!isFenced(ctx)) return; + const path = targetPathOf(ctx); + if (path === undefined) return; + if (this.markerTurnId !== ctx.turnId) { + this.markerTurnId = ctx.turnId; + this.targets.clear(); + this.staleMarks.clear(); + } + this.targets.set(ctx.toolCall.id, { toolName: ctx.toolCall.name, path }); + if (!WRITE_TOOLS.has(ctx.toolCall.name)) return; + const verdict = await this.ledger.compare(path); + if (verdict === 'clean') return; + if (this.flags.enabled(MULTI_SERVER_FLAG_ID)) { + ctx.decision = { block: true, reason: blockReason(ctx.toolCall.name, path, verdict) }; + return; + } + this.staleMarks.set(ctx.toolCall.id, verdict); + } + + private async onDid(ctx: ToolDidExecuteContext): Promise { + if (!isFenced(ctx)) return; + const target = this.targets.get(ctx.toolCall.id); + this.targets.delete(ctx.toolCall.id); + const mark = this.staleMarks.get(ctx.toolCall.id); + this.staleMarks.delete(ctx.toolCall.id); + if (target === undefined || ctx.result.isError === true) return; + if (target.toolName === READ_TOOL && isRangedRead(ctx.args)) return; + await this.ledger.recordBaseline(target.path); + if (mark !== undefined) { + ctx.result = { ...ctx.result, note: composeNote(ctx.result.note, advisoryNote(target, mark)) }; + } + } +} + +registerScopedService( + LifecycleScope.Agent, + IAgentFileFencingService, + AgentFileFencingService, + InstantiationType.Eager, + 'fileFencing', +); diff --git a/packages/agent-core-v2/src/agent/profile/agentSkillListingReminder.ts b/packages/agent-core-v2/src/agent/profile/agentSkillListingReminder.ts new file mode 100644 index 0000000000..852ef2fdf9 --- /dev/null +++ b/packages/agent-core-v2/src/agent/profile/agentSkillListingReminder.ts @@ -0,0 +1,22 @@ +/** + * `profile` domain (L4) — `IAgentSkillListingReminderService` contract. + * + * Turn-boundary model notification for skill hot reload: the skill listing is + * baked into the agent's system prompt at bind time, so after the session + * skill catalog merges a filesystem- or config-driven reload the provider + * registered by this service fires once at the agent's next new turn, appending + * a system reminder with the refreshed `catalog.getModelSkillListing()` (whose + * first line already supersedes earlier listings). Bound at Agent scope — each + * agent carries its own pending flag. + */ + +import { createDecorator, type ServiceIdentifier } from '#/_base/di/instantiation'; + +export const SKILL_CATALOG_CHANGED_INJECTION_VARIANT = 'skill_catalog_changed'; + +export interface IAgentSkillListingReminderService { + readonly _serviceBrand: undefined; +} + +export const IAgentSkillListingReminderService: ServiceIdentifier = + createDecorator('agentSkillListingReminderService'); diff --git a/packages/agent-core-v2/src/agent/profile/agentSkillListingReminderService.ts b/packages/agent-core-v2/src/agent/profile/agentSkillListingReminderService.ts new file mode 100644 index 0000000000..994e0202e2 --- /dev/null +++ b/packages/agent-core-v2/src/agent/profile/agentSkillListingReminderService.ts @@ -0,0 +1,72 @@ +/** + * `profile` domain (L4) — `IAgentSkillListingReminderService` implementation. + * + * Subscribes the session skill catalog's `onDidChange` and registers one + * context-injection provider into the agent's `contextInjector`; the provider + * stays silent until an injection pass runs at a new turn (`isNewTurn`), then + * clears the pending flag and returns the change note plus the current + * `sessionSkillCatalog` model listing, which the injector appends as a system + * reminder. Bound at Agent scope. + */ + +import { Disposable } from '#/_base/di/lifecycle'; +import { InstantiationType } from '#/_base/di/extensions'; +import { LifecycleScope, registerScopedService } from '#/_base/di/scope'; +import { IAgentContextInjectorService } from '#/agent/contextInjector/contextInjector'; +import { ISessionSkillCatalog } from '#/session/sessionSkillCatalog/skillCatalog'; + +import { + IAgentSkillListingReminderService, + SKILL_CATALOG_CHANGED_INJECTION_VARIANT, +} from './agentSkillListingReminder'; + +export class AgentSkillListingReminderService + extends Disposable + implements IAgentSkillListingReminderService +{ + declare readonly _serviceBrand: undefined; + + private pending = false; + + constructor( + @IAgentContextInjectorService injector: IAgentContextInjectorService, + @ISessionSkillCatalog private readonly skillCatalog: ISessionSkillCatalog, + ) { + super(); + this._register( + this.skillCatalog.onDidChange(() => { + this.pending = true; + }), + ); + this._register( + injector.register(SKILL_CATALOG_CHANGED_INJECTION_VARIANT, ({ isNewTurn }) => { + if (!isNewTurn || !this.pending) return undefined; + this.pending = false; + return this.renderReminder(); + }), + ); + } + + private renderReminder(): string { + const listing = this.skillCatalog.catalog.getModelSkillListing(); + if (listing === '') { + return ( + 'The skill catalog changed during this session; there are currently no invocable skills. ' + + 'This supersedes any earlier skill listing reminder in this session.' + ); + } + return ( + 'The skill catalog changed during this session; the listing below supersedes any earlier ' + + 'skill listing reminder in this session.' + + `\n\n${listing}` + ); + } +} + +registerScopedService( + LifecycleScope.Agent, + IAgentSkillListingReminderService, + AgentSkillListingReminderService, + InstantiationType.Eager, + 'profile', +); diff --git a/packages/agent-core-v2/src/app/config/configFileMutex.ts b/packages/agent-core-v2/src/app/config/configFileMutex.ts new file mode 100644 index 0000000000..1e8a7f9091 --- /dev/null +++ b/packages/agent-core-v2/src/app/config/configFileMutex.ts @@ -0,0 +1,35 @@ +/** + * `config` domain (L2) — cross-process lock target for the config.toml write path. + * + * Pure seam shared by `ConfigService.persist`: the lock file path derived from + * the resolved config file (`.lock` — `/config.toml.lock` + * in the default layout, so a custom `--config` path is protected at its real + * location) and the bounded-wait acquisition options for the lock-in + * read-modify-write critical section (design: `.tmp/refactor-watch-design-v2.md` + * §3.6). Owns no scoped state. + */ + +import type { + CrossProcessLockAcquireOptions, + CrossProcessLockWaitOptions, +} from '#/os/interface/crossProcessLock'; + +export interface ConfigFileMutexTarget { + readonly lockPath: string; + readonly options: CrossProcessLockAcquireOptions & { wait: CrossProcessLockWaitOptions }; +} + +export const CONFIG_FILE_LOCK_TIMEOUT_MS = 10_000; +export const CONFIG_FILE_LOCK_RETRY_INTERVAL_MS = 50; + +const DEFAULT_WAIT: CrossProcessLockWaitOptions = { + timeoutMs: CONFIG_FILE_LOCK_TIMEOUT_MS, + retryIntervalMs: CONFIG_FILE_LOCK_RETRY_INTERVAL_MS, +}; + +export function configFileMutexTarget( + configPath: string, + wait: CrossProcessLockWaitOptions = DEFAULT_WAIT, +): ConfigFileMutexTarget { + return { lockPath: `${configPath}.lock`, options: { wait } }; +} diff --git a/packages/agent-core-v2/src/app/config/configService.ts b/packages/agent-core-v2/src/app/config/configService.ts index f578f57357..2fe91defba 100644 --- a/packages/agent-core-v2/src/app/config/configService.ts +++ b/packages/agent-core-v2/src/app/config/configService.ts @@ -10,9 +10,13 @@ * — plus a `delivered` snapshot per domain used as the diff base for * `onDidSectionChange`. Reads config paths and the environment overlay through * `bootstrap`, persists the TOML document through the `storage` TOML - * atomic-document store (reloading when the document changes on disk), and logs - * through `log`. Late section / overlay registration re-validates the - * already-loaded raw value and re-runs overlays. Bound at App scope. + * atomic-document store (reloading when the document changes on disk), + * serializes every persist as a cross-process lock-in read-modify-write + * through `crossProcessLock` (re-reading the file inside the lock and applying + * only the touched section, so sections written by other processes survive — + * design: `.tmp/refactor-watch-design-v2.md` §3.6), and logs through `log`. + * Late section / overlay registration re-validates the already-loaded raw + * value and re-runs overlays. Bound at App scope. */ import { InstantiationType } from '#/_base/di/extensions'; @@ -25,7 +29,9 @@ import { IAtomicTomlDocumentStore, type IAtomicDocumentStore, } from '#/persistence/interface/atomicDocumentStore'; +import { ICrossProcessLockService } from '#/os/interface/crossProcessLock'; +import { configFileMutexTarget } from './configFileMutex'; import { type AnyEnvBindings, type ConfigChangedEvent, @@ -237,6 +243,7 @@ export class ConfigService extends Disposable implements IConfigService { @IBootstrapService private readonly bootstrap: IBootstrapService, @ILogService private readonly log: ILogService, @IAtomicTomlDocumentStore private readonly documentStore: IAtomicDocumentStore, + @ICrossProcessLockService private readonly lock: ICrossProcessLockService, ) { super(); this.configKey = this.bootstrap.configKey; @@ -323,8 +330,10 @@ export class ConfigService extends Disposable implements IConfigService { } else { this.raw[domain] = stripped; } + const previousRaw = this.raw; await this.persist(domain); this.rebuildEffective('set', [domain]); + this.commitAbsorbed(previousRaw); }); } @@ -350,8 +359,10 @@ export class ConfigService extends Disposable implements IConfigService { } else { this.raw[domain] = this.registry.validate(domain, stripped); } + const previousRaw = this.raw; await this.persist(domain); this.rebuildEffective('set', [domain]); + this.commitAbsorbed(previousRaw); }); } @@ -541,8 +552,31 @@ export class ConfigService extends Disposable implements IConfigService { } private async persist(domain: string): Promise { - applySectionToToml(this.rawSnake, domain, this.raw[domain], this.registry); - await this.documentStore.set(CONFIG_SCOPE, this.configKey, this.rawSnake); + const { lockPath, options } = configFileMutexTarget(this.bootstrap.configPath); + // Two exclusion layers: `enqueueStateTransition` serializes transitions + // inside this process; the cross-process lock makes the re-read → apply → + // atomic-write burst mutually exclusive with other processes sharing this + // config file. Lock-acquisition and write failures propagate to the set() + // caller as-is (no retry here). + await this.lock.withLock(lockPath, options, async () => { + const data = await this.documentStore.get(CONFIG_SCOPE, this.configKey); + const freshBase = data !== undefined && isPlainObject(data) ? cloneRecord(data) : {}; + applySectionToToml(freshBase, domain, this.raw[domain], this.registry); + await this.documentStore.set(CONFIG_SCOPE, this.configKey, freshBase); + this.rawSnake = freshBase; + // Re-derive the camelCase view too: the fresh base may carry sections + // written by other processes, and both views must match what we wrote + // (rawSnake equality is how the watch-triggered load() skips our own + // echo instead of double-reporting it). + this.raw = transformTomlData(freshBase, this.registry); + }); + } + + private commitAbsorbed(previousRaw: ResolvedConfig): void { + const absorbed = [...new Set([...Object.keys(previousRaw), ...Object.keys(this.raw)])].filter( + (domain) => !deepEqual(previousRaw[domain], this.raw[domain]), + ); + if (absorbed.length > 0) this.commit('reload', absorbed); } } diff --git a/packages/agent-core-v2/src/app/mcp/mcpConfigWatch.ts b/packages/agent-core-v2/src/app/mcp/mcpConfigWatch.ts new file mode 100644 index 0000000000..6ffff7b761 --- /dev/null +++ b/packages/agent-core-v2/src/app/mcp/mcpConfigWatch.ts @@ -0,0 +1,24 @@ +/** + * `mcp` domain (L5) — App-scope watch contract for the user-level mcp.json. + * + * Defines `IMcpConfigWatchService`: a typed `onDidChange` event that fires + * when the user-global `/mcp.json` changes on disk and its new + * content parses (blank content counts as valid, matching the loader). Only + * the user level is watched — project-root `.mcp.json` and project-local + * `.kimi-code/mcp.json` are per-workspace paths and stay unwatched this + * round. v2 has no mcp.json writer (edits are out-of-band direct writes) and + * no server-config cache yet, so the event exists for future hot-aware + * consumers; Session-scoped services may subscribe to this App-scope event, + * never the reverse. Bound at App scope. + */ + +import type { Event } from '#/_base/event'; +import { createDecorator, type ServiceIdentifier } from '#/_base/di/instantiation'; + +export interface IMcpConfigWatchService { + readonly _serviceBrand: undefined; + readonly onDidChange: Event; +} + +export const IMcpConfigWatchService: ServiceIdentifier = + createDecorator('mcpConfigWatchService'); diff --git a/packages/agent-core-v2/src/app/mcp/mcpConfigWatchService.ts b/packages/agent-core-v2/src/app/mcp/mcpConfigWatchService.ts new file mode 100644 index 0000000000..2dd08f3261 --- /dev/null +++ b/packages/agent-core-v2/src/app/mcp/mcpConfigWatchService.ts @@ -0,0 +1,74 @@ +/** + * `mcp` domain (L5) — `IMcpConfigWatchService` implementation. + * + * Watches the user-level mcp.json through the `storage` byte layer + * (`watch('', 'mcp.json')` — the same exact-key, debounced shape as the config + * watch, which filters out lock/tmp siblings in the same directory), then + * JSON-parse-probes the new content before emitting: unparseable content (a + * half-finished direct edit that slipped past the atomic-write and exact-key + * filters) logs a warning through `log` and suppresses the event; a missing + * or blank file counts as valid (the loader treats it as an empty config). + * Bound at App scope. + */ + +import { InstantiationType } from '#/_base/di/extensions'; +import { Disposable } from '#/_base/di/lifecycle'; +import { LifecycleScope, registerScopedService } from '#/_base/di/scope'; +import { Emitter, Event } from '#/_base/event'; +import { ILogService } from '#/_base/log/log'; +import { IFileSystemStorageService } from '#/persistence/interface/storage'; + +import { IMcpConfigWatchService } from './mcpConfigWatch'; + +const MCP_CONFIG_SCOPE = ''; +const MCP_CONFIG_KEY = 'mcp.json'; + +const textDecoder = new TextDecoder(); + +export class McpConfigWatchService extends Disposable implements IMcpConfigWatchService { + declare readonly _serviceBrand: undefined; + + private readonly _onDidChange = this._register(new Emitter()); + readonly onDidChange: Event = this._onDidChange.event; + + constructor( + @IFileSystemStorageService private readonly storage: IFileSystemStorageService, + @ILogService private readonly log: ILogService, + ) { + super(); + const change = this.storage.watch?.(MCP_CONFIG_SCOPE, MCP_CONFIG_KEY) ?? Event.None; + this._register(change(() => void this.probe())); + } + + private async probe(): Promise { + let text = ''; + try { + const bytes = await this.storage.read(MCP_CONFIG_SCOPE, MCP_CONFIG_KEY); + text = bytes === undefined ? '' : textDecoder.decode(bytes); + } catch (error) { + this.log.warn('mcp.json change probe failed to read the file', { + error: error instanceof Error ? error.message : String(error), + }); + return; + } + if (text.trim().length > 0) { + try { + JSON.parse(text); + } catch (error) { + this.log.warn('ignoring mcp.json change: invalid JSON', { + error: error instanceof Error ? error.message : String(error), + }); + return; + } + } + this._onDidChange.fire(); + } +} + +registerScopedService( + LifecycleScope.App, + IMcpConfigWatchService, + McpConfigWatchService, + InstantiationType.Eager, + 'mcp', +); diff --git a/packages/agent-core-v2/src/app/multiServer/flag.ts b/packages/agent-core-v2/src/app/multiServer/flag.ts new file mode 100644 index 0000000000..b3bdc70f6d --- /dev/null +++ b/packages/agent-core-v2/src/app/multiServer/flag.ts @@ -0,0 +1,28 @@ +/** + * `multi_server` experimental flag — gates the multi-server shared-homedir work. + * + * When enabled, a kap-server instance registers itself under + * `/server/instances/.json` instead of taking the legacy + * single-instance `/server/lock`, so multiple servers can share one home + * directory. Off by default; enable via `KIMI_CODE_EXPERIMENTAL_MULTI_SERVER`, + * the master `KIMI_CODE_EXPERIMENTAL_FLAG`, or the `[experimental]` config + * section. Imported for its side effect (registers the definition) from the + * package barrel. + */ + +import { type FlagDefinitionInput, registerFlagDefinition } from '#/app/flag/flagRegistry'; + +export const MULTI_SERVER_FLAG_ID = 'multi_server'; +export const MULTI_SERVER_FLAG_ENV = 'KIMI_CODE_EXPERIMENTAL_MULTI_SERVER'; + +export const multiServerFlag: FlagDefinitionInput = { + id: MULTI_SERVER_FLAG_ID, + title: 'multi-server shared home', + description: + 'Allow multiple kap-server instances to share one home directory by registering each instance under server/instances/ instead of taking a single homedir lock.', + env: MULTI_SERVER_FLAG_ENV, + default: false, + surface: 'core', +}; + +registerFlagDefinition(multiServerFlag); diff --git a/packages/agent-core-v2/src/app/sessionLifecycle/sessionLifecycleService.ts b/packages/agent-core-v2/src/app/sessionLifecycle/sessionLifecycleService.ts index 1922a81955..8ef3dd229f 100644 --- a/packages/agent-core-v2/src/app/sessionLifecycle/sessionLifecycleService.ts +++ b/packages/agent-core-v2/src/app/sessionLifecycle/sessionLifecycleService.ts @@ -22,6 +22,19 @@ * the session into a bucket v1 readers never look in. Fork flushes * live Agent wire journals, normalizes a missing protocol envelope, and * appends the fork boundary before restoring the target Agent. + * + * Every materialization (create/resume/fork-target) first takes the session's + * cross-process write lease under `session-leases/` and registers its + * `ISessionWriteAuthority` with the `writeAuthorityRegistry`, so the + * journal/state fencing gates have exactly one authority per live session + * (design: `.tmp/refactor-watch-design-v2.md` §3.4 — always on, no flag). + * Contended acquisitions are answered with `session.held_by_peer` carrying + * the structured ownership details; the multi_server flag gates only the + * address emission (`routable` phase) and the unregistered-writer freshness + * probe. Materialize failure arms unregister and release immediately, and + * close/archive finish the session tail (final journal flush) before + * unregistering the authority and releasing the lease. A lease lost under a + * live session (payload token mismatch) tears the whole session down. */ import { randomUUID } from 'node:crypto'; @@ -31,7 +44,7 @@ import { ulid } from 'ulid'; import { InstantiationType } from '#/_base/di/extensions'; import { IInstantiationService } from '#/_base/di/instantiation'; -import { Disposable } from '#/_base/di/lifecycle'; +import { Disposable, type IDisposable } from '#/_base/di/lifecycle'; import { createScopedChildHandle, type ISessionScopeHandle, @@ -40,6 +53,7 @@ import { } from '#/_base/di/scope'; import { unwrapErrorCause } from '#/_base/errors/errors'; import { Emitter, type Event } from '#/_base/event'; +import { ILogService } from '#/_base/log/log'; import { DEFAULT_PLAN_MODE_SECTION } from '#/agent/plan/configSection'; import { IAgentPlanService } from '#/agent/plan/plan'; import { IBootstrapService } from '#/app/bootstrap/bootstrap'; @@ -47,6 +61,8 @@ import { CRON_SESSION_TAG, type CronTask } from '#/app/cron/cronTask'; import { ICronTaskPersistence } from '#/app/cron/cronTaskPersistence'; import { IConfigService } from '#/app/config/config'; import { IEventService } from '#/app/event/event'; +import { IFlagService } from '#/app/flag/flag'; +import { MULTI_SERVER_FLAG_ID } from '#/app/multiServer/flag'; import { CHILD_SESSION_KIND, CHILD_SESSION_KIND_KEY, @@ -62,6 +78,12 @@ import { IHostEnvironment } from '#/os/interface/hostEnvironment'; import { IHostFileSystem, type HostDirEntry } from '#/os/interface/hostFileSystem'; import { IAppendLogStore } from '#/persistence/interface/appendLogStore'; import { IAtomicDocumentStore } from '#/persistence/interface/atomicDocumentStore'; +import { IWriteAuthorityRegistry } from '#/persistence/interface/writeAuthority'; +import { + type CrossProcessLockInspection, + ICrossProcessLockService, + OsLockErrors, +} from '#/os/interface/crossProcessLock'; import { IAgentLifecycleService, MAIN_AGENT_ID } from '#/session/agentLifecycle/agentLifecycle'; import { ensureMainAgent } from '#/session/agentLifecycle/mainAgent'; import { ISessionMcpService } from '#/session/mcp/sessionMcp'; @@ -69,6 +91,19 @@ import { labelsFromAgentMeta } from '#/session/agentLifecycle/subagentMetadata'; import { ISessionExternalHooksService } from '#/session/externalHooks/externalHooks'; import { ISessionContext, sessionContextSeed } from '#/session/sessionContext/sessionContext'; import { ISessionCronService } from '#/session/cron/sessionCronService'; +import { + type HeldByPeerDetails, + HOLDER_UNRESPONSIVE_RETRY_AFTER_MS, + LEASE_CREATING_RETRY_AFTER_MS, + SessionLease, + sessionLeasePath, + sessionLeaseSeed, + SESSION_LEASE_HEARTBEAT_INTERVAL_MS, + SESSION_LEASE_TTL_MS, + UNREGISTERED_WRITER_RECHECK_DELAY_MS, + UNREGISTERED_WRITER_WINDOW_MS, +} from '#/session/sessionLease/sessionLease'; +import { ISessionLeaseContactProvider } from '#/session/sessionLease/sessionLeaseContactProvider'; import { ISessionMetadata, type SessionMeta } from '#/session/sessionMetadata/sessionMetadata'; import { ISessionSkillCatalog } from '#/session/sessionSkillCatalog/skillCatalog'; import { ISessionWorkspaceContext } from '#/session/workspaceContext/workspaceContext'; @@ -113,6 +148,10 @@ export class SessionLifecycleService extends Disposable implements ISessionLifec 'onWillCloseSession', ]); private readonly resuming = new Map>(); + private readonly leases = new Map< + string, + { readonly lease: SessionLease; readonly registration: IDisposable } + >(); constructor( @IInstantiationService private readonly instantiation: IInstantiationService, @@ -129,6 +168,12 @@ export class SessionLifecycleService extends Disposable implements ISessionLifec private readonly workspaceLocalConfig: IWorkspaceLocalConfigService, @IEventService private readonly event: IEventService, @ITelemetryService private readonly telemetry: ITelemetryService, + @ILogService private readonly log: ILogService, + @IFlagService private readonly flags: IFlagService, + @ICrossProcessLockService private readonly locks: ICrossProcessLockService, + @IWriteAuthorityRegistry private readonly authorityRegistry: IWriteAuthorityRegistry, + @ISessionLeaseContactProvider + private readonly leaseContact: ISessionLeaseContactProvider, ) { super(); } @@ -175,26 +220,35 @@ export class SessionLifecycleService extends Disposable implements ISessionLifec ); const additionalDirs = [...localWorkspaceDirs.additionalDirs, ...callerAdditionalDirs]; await this.hostEnv.ready; - const handle = createScopedChildHandle( - this.instantiation, - LifecycleScope.Session, - opts.sessionId, - { - extra: [...sessionContextSeed(ctx)], - }, - ) as ISessionScopeHandle; - if (additionalDirs.length > 0) { - handle.accessor.get(ISessionWorkspaceContext).setAdditionalDirs(additionalDirs); + await this.assertNoActiveUnregisteredWriter(sessionDir, opts.sessionId); + const lease = this.acquireSessionLease(opts.sessionId); + const registration = this.authorityRegistry.register(lease); + this.leases.set(opts.sessionId, { lease, registration }); + try { + const handle = createScopedChildHandle( + this.instantiation, + LifecycleScope.Session, + opts.sessionId, + { + extra: [...sessionContextSeed(ctx), ...sessionLeaseSeed(lease)], + }, + ) as ISessionScopeHandle; + if (additionalDirs.length > 0) { + handle.accessor.get(ISessionWorkspaceContext).setAdditionalDirs(additionalDirs); + } + this.sessions.set(opts.sessionId, handle); + await handle.accessor.get(ISessionMetadata).ready; + void handle.accessor.get(ISessionSkillCatalog).ready; + await handle.accessor.get(ISessionMcpService).ensureMcpReady(opts.mcpServers); + // Force-instantiate the session-level eager services whose subscriptions + // must exist before the first agent / turn (external hooks, cron). + handle.accessor.get(ISessionExternalHooksService); + handle.accessor.get(ISessionCronService); + return handle; + } catch (error) { + this.teardownLease(opts.sessionId); + throw error; } - this.sessions.set(opts.sessionId, handle); - await handle.accessor.get(ISessionMetadata).ready; - void handle.accessor.get(ISessionSkillCatalog).ready; - await handle.accessor.get(ISessionMcpService).ensureMcpReady(opts.mcpServers); - // Force-instantiate the session-level eager services whose subscriptions - // must exist before the first agent / turn (external hooks, cron). - handle.accessor.get(ISessionExternalHooksService); - handle.accessor.get(ISessionCronService); - return handle; } /** @@ -287,6 +341,8 @@ export class SessionLifecycleService extends Disposable implements ISessionLifec this.sessions.delete(sessionId); await this.drainAgents(handle); handle.dispose(); + await this.flushSessionTail(sessionId); + this.teardownLease(sessionId); this._onDidCloseSession.fire({ sessionId }); } @@ -303,6 +359,8 @@ export class SessionLifecycleService extends Disposable implements ISessionLifec await this.announceWillClose({ sessionId, handle, reason: 'exit' }); this.sessions.delete(sessionId); handle.dispose(); + await this.flushSessionTail(sessionId); + this.teardownLease(sessionId); this._onDidArchiveSession.fire({ sessionId }); } @@ -431,6 +489,9 @@ export class SessionLifecycleService extends Disposable implements ISessionLifec } catch { } } + if (targetId !== undefined) { + this.teardownLease(targetId); + } if (targetSessionDir !== undefined) { await this.hostFs.remove(targetSessionDir).catch(() => {}); } @@ -568,6 +629,134 @@ export class SessionLifecycleService extends Disposable implements ISessionLifec } } + override dispose(): void { + for (const sessionId of this.leases.keys()) { + this.teardownLease(sessionId); + } + super.dispose(); + } + + private onLeaseLost(sessionId: string): void { + this.log.error('session lease lost; tearing the session down', { sessionId }); + void this.close(sessionId).catch(() => {}); + } + + private async assertNoActiveUnregisteredWriter( + sessionDir: string, + sessionId: string, + ): Promise { + if (!this.flags.enabled(MULTI_SERVER_FLAG_ID)) return; + const first = await this.hostFs.stat(sessionDir).catch(() => undefined); + if (first?.mtimeMs === undefined) return; + if (Date.now() - first.mtimeMs >= UNREGISTERED_WRITER_WINDOW_MS) return; + await sleep(UNREGISTERED_WRITER_RECHECK_DELAY_MS); + const second = await this.hostFs.stat(sessionDir).catch(() => undefined); + if (second?.mtimeMs === undefined) return; + if (second.mtimeMs > first.mtimeMs) { + throw new Error2( + ErrorCodes.SESSION_HELD_BY_PEER, + `session ${sessionId} directory is being written by an external writer; refusing to materialize it here`, + { details: { kind: 'unregistered-writer' } }, + ); + } + } + + private acquireSessionLease(sessionId: string): SessionLease { + const leasePath = sessionLeasePath(this.bootstrap.homeDir, sessionId); + const contact = this.leaseContact.contact(); + let lease: SessionLease | undefined; + try { + const prior = this.locks.inspect(leasePath); + const handle = this.locks.acquire(leasePath, { + heartbeat: { + intervalMs: SESSION_LEASE_HEARTBEAT_INTERVAL_MS, + ttlMs: SESSION_LEASE_TTL_MS, + }, + address: contact.type === 'address' ? contact.address : undefined, + onLost: () => { + lease?.markLost(); + }, + }); + lease = new SessionLease(sessionId, handle, (id) => { + this.onLeaseLost(id); + }); + this.telemetry.track2('session_lease_acquired', { session_id: sessionId }); + if (prior.state === 'stale') { + this.telemetry.track2('session_lease_takeover', { + session_id: sessionId, + previous: prior.staleReason ?? 'unknown', + }); + } + return lease; + } catch (error) { + if ( + isError2(error) && + (error.code === OsLockErrors.codes.OS_LOCK_HELD || + error.code === OsLockErrors.codes.OS_LOCK_WAIT_TIMEOUT) + ) { + this.throwHeldByPeer(sessionId, error); + } + throw error; + } + } + + private throwHeldByPeer(sessionId: string, cause: unknown): never { + const inspection = this.locks.inspect(sessionLeasePath(this.bootstrap.homeDir, sessionId)); + const details = this.heldByPeerDetails(inspection); + this.telemetry.track2('session_held_by_peer_returned', { + session_id: sessionId, + phase: details.phase, + }); + if (details.phase === 'holder-unresponsive') { + this.telemetry.track2('session_lease_holder_unresponsive', { session_id: sessionId }); + } + throw new Error2( + ErrorCodes.SESSION_HELD_BY_PEER, + `session ${sessionId} is held by another instance (${details.phase})`, + { details, cause }, + ); + } + + private heldByPeerDetails(inspection: CrossProcessLockInspection): HeldByPeerDetails { + if (inspection.state === 'held' && inspection.payload !== undefined) { + const payload = inspection.payload; + const heartbeatAt = payload.heartbeatAt; + if (heartbeatAt !== undefined && Date.now() - heartbeatAt > SESSION_LEASE_TTL_MS) { + return { + kind: 'held-by-peer', + phase: 'holder-unresponsive', + retry_after_ms: HOLDER_UNRESPONSIVE_RETRY_AFTER_MS, + }; + } + if (payload.address !== undefined && this.flags.enabled(MULTI_SERVER_FLAG_ID)) { + return { kind: 'held-by-peer', phase: 'routable', address: payload.address }; + } + return { kind: 'held-by-peer', phase: 'held-by-local-instance' }; + } + // 'creating' mid-creation, and races where the holder vanished between the + // failed acquire and this probe, both converge by retrying shortly. + return { kind: 'held-by-peer', phase: 'creating', retry_after_ms: LEASE_CREATING_RETRY_AFTER_MS }; + } + + private async flushSessionTail(sessionId: string): Promise { + try { + await this.appendLogStore.flush(); + } catch (error) { + this.log.warn('final journal flush failed while closing session', { + sessionId, + error: String(error), + }); + } + } + + private teardownLease(sessionId: string): void { + const entry = this.leases.get(sessionId); + if (entry === undefined) return; + this.leases.delete(sessionId); + entry.registration.dispose(); + entry.lease.release(); + } + private async readMetaFromDisk( workspaceId: string, sessionId: string, @@ -600,6 +789,13 @@ function isMissingFileError(error: unknown): boolean { return code === 'ENOENT'; } +function sleep(ms: number): Promise { + return new Promise((resolvePromise) => { + const timer = setTimeout(resolvePromise, ms); + timer.unref?.(); + }); +} + function createSessionId(): string { return `session_${randomUUID()}`; } diff --git a/packages/agent-core-v2/src/app/skillCatalog/skillRootWatcher.ts b/packages/agent-core-v2/src/app/skillCatalog/skillRootWatcher.ts new file mode 100644 index 0000000000..8efbdd6e0d --- /dev/null +++ b/packages/agent-core-v2/src/app/skillCatalog/skillRootWatcher.ts @@ -0,0 +1,184 @@ +/** + * `skillCatalog` domain (L3) — filesystem watcher for skill-root directories. + * + * Watches candidate skill roots through `IHostFsWatchService` and fires a + * 300 ms debounced change callback whenever any of them changes. Candidates + * may not exist yet (skill directories are opt-in). chokidar 4 (verified + * 4.0.3 on darwin): a recursive watch on a path whose immediate parent + * exists picks the path up when it is created, but a path with two or more + * missing leading segments reports NOTHING when the chain appears. So an + * absent root is tracked by a depth-0 sentinel on the nearest existing + * ancestor that re-anchors down the chain as segments appear — sentinel + * events only trigger an existence re-probe (a `mkdir -p` chain is never + * missed) — and once the root exists a recursive watch is armed in its + * place. Root deletion while armed falls back to sentinel mode on the next + * advance, so delete/recreate cycles stay live. Pure helper owned by the + * file-backed skill sources; not a DI service. + */ + +import { promises as fs } from 'node:fs'; +import { dirname } from 'pathe'; + +import { Disposable } from '#/_base/di/lifecycle'; +import type { HostFsChange, IHostFsWatchHandle, IHostFsWatchService } from '#/os/interface/hostFsWatch'; + +const SKILL_WATCH_DEBOUNCE_MS = 300; + +interface RootWatchState { + readonly root: string; + rootWatch: IHostFsWatchHandle | undefined; + sentinel: IHostFsWatchHandle | undefined; + sentinelDir: string | undefined; + advanceTail: Promise; +} + +export class SkillRootWatcher extends Disposable { + private readonly states = new Map(); + private armTail: Promise = Promise.resolve(); + private disposed = false; + private debounceTimer: ReturnType | undefined; + + readonly ready: Promise; + + constructor( + private readonly hostFsWatch: IHostFsWatchService, + private readonly resolveRoots: () => Promise, + private readonly onDidChange: () => void, + ) { + super(); + this.ready = this.rearm(); + } + + refresh(): Promise { + return this.rearm(); + } + + override dispose(): void { + if (this.disposed) return; + this.disposed = true; + if (this.debounceTimer !== undefined) { + clearTimeout(this.debounceTimer); + this.debounceTimer = undefined; + } + for (const state of this.states.values()) this.teardownState(state); + this.states.clear(); + super.dispose(); + } + + private rearm(): Promise { + const tail = this.armTail.then(() => this.rearmNow()); + this.armTail = tail.catch(() => undefined); + return tail; + } + + private async rearmNow(): Promise { + if (this.disposed) return; + for (const state of this.states.values()) this.teardownState(state); + this.states.clear(); + const roots = await this.resolveRoots(); + if (this.disposed) return; + const advances: Promise[] = []; + for (const root of new Set(roots)) { + const state: RootWatchState = { + root, + rootWatch: undefined, + sentinel: undefined, + sentinelDir: undefined, + advanceTail: Promise.resolve(), + }; + this.states.set(root, state); + this.advance(state); + advances.push(state.advanceTail); + } + await Promise.all(advances); + } + + private teardownState(state: RootWatchState): void { + state.rootWatch?.dispose(); + state.rootWatch = undefined; + state.sentinel?.dispose(); + state.sentinel = undefined; + state.sentinelDir = undefined; + } + + private advance(state: RootWatchState): void { + const tail = state.advanceTail.then(async () => { + if (this.disposed || this.states.get(state.root) !== state) return; + if (await isDir(state.root)) { + if (state.rootWatch !== undefined) return; + // A previously armed sentinel means the root just appeared (possibly + // with content already inside): the transition itself is a change. + const appeared = state.sentinel !== undefined; + state.sentinel?.dispose(); + state.sentinel = undefined; + state.sentinelDir = undefined; + if (this.disposed || this.states.get(state.root) !== state) return; + const handle = this.hostFsWatch.watch(state.root); + state.rootWatch = handle; + handle.onDidChange(() => { + this.scheduleFire(); + }); + if (appeared) this.scheduleFire(); + return; + } + state.rootWatch?.dispose(); + state.rootWatch = undefined; + const anchor = await nearestExistingDir(state.root); + if (this.disposed || this.states.get(state.root) !== state) return; + if (state.sentinel !== undefined && state.sentinelDir === anchor) return; + state.sentinel?.dispose(); + const sentinel = this.hostFsWatch.watch(anchor, { recursive: false }); + state.sentinel = sentinel; + state.sentinelDir = anchor; + sentinel.onDidChange((event) => { + this.onSentinelEvent(state, event); + }); + }); + state.advanceTail = tail.catch(() => undefined); + } + + private onSentinelEvent(state: RootWatchState, event: HostFsChange): void { + if (isOnRootChain(state.root, event.path)) this.advance(state); + } + + private scheduleFire(): void { + if (this.disposed) return; + if (this.debounceTimer !== undefined) clearTimeout(this.debounceTimer); + const timer = setTimeout(() => { + this.debounceTimer = undefined; + if (this.disposed) return; + this.onDidChange(); + // Re-probe every root: a deleted armed root falls back to sentinel mode + // here, and a missed sentinel transition is re-armed on the new root. + for (const state of this.states.values()) this.advance(state); + }, SKILL_WATCH_DEBOUNCE_MS); + timer.unref?.(); + this.debounceTimer = timer; + } +} + +function isOnRootChain(root: string, eventPath: string): boolean { + if (eventPath === root) return true; + return ( + root.startsWith(eventPath) && + (root[eventPath.length] === '/' || root[eventPath.length] === '\\') + ); +} + +async function nearestExistingDir(root: string): Promise { + let current = root; + while (true) { + if (await isDir(current)) return current; + const parent = dirname(current); + if (parent === current) return current; + current = parent; + } +} + +async function isDir(p: string): Promise { + try { + return (await fs.stat(p)).isDirectory(); + } catch { + return false; + } +} diff --git a/packages/agent-core-v2/src/app/skillCatalog/skillRoots.ts b/packages/agent-core-v2/src/app/skillCatalog/skillRoots.ts index c0a1c76330..505968488d 100644 --- a/packages/agent-core-v2/src/app/skillCatalog/skillRoots.ts +++ b/packages/agent-core-v2/src/app/skillCatalog/skillRoots.ts @@ -5,9 +5,11 @@ * user (home) and project (workspace) skill locations. Brand directories are * preferred over generic ones (`.kimi-code/skills` before `.agents/skills`), * and the project root is found by walking up to `.git`. Plugin roots are no - * longer folded in here — plugins are a separate `ISkillSource`. These helpers - * are exported so the edge can compose a workspace's skills without a Session. - * Pure path/fs probes; no scoped state. + * longer folded in here — plugins are a separate `ISkillSource`. The + * `*Candidates` helpers return the same locations WITHOUT the existence filter + * or realpath resolution, for file watchers that must observe roots appearing + * later. These helpers are exported so the edge can compose a workspace's + * skills without a Session. Pure path/fs probes; no scoped state. */ import { promises as fs } from 'node:fs'; @@ -62,6 +64,30 @@ export async function configuredRoots( return roots; } +export function userRootCandidates(homeDir: string, osHomeDir: string): readonly string[] { + return [ + ...USER_BRAND_DIRS.map((dir) => path.join(homeDir, dir)), + ...USER_GENERIC_DIRS.map((dir) => path.join(osHomeDir, dir)), + ]; +} + +export async function projectRootCandidates(workDir: string): Promise { + const projectRoot = await findProjectRoot(workDir); + return [ + ...PROJECT_BRAND_DIRS.map((dir) => path.join(projectRoot, dir)), + ...PROJECT_GENERIC_DIRS.map((dir) => path.join(projectRoot, dir)), + ]; +} + +export async function configuredRootCandidates( + dirs: readonly string[], + workDir: string, + osHomeDir: string, +): Promise { + const projectRoot = await findProjectRoot(workDir); + return dirs.map((dir) => resolveConfiguredDir(dir, projectRoot, osHomeDir)); +} + async function findProjectRoot(workDir: string): Promise { const start = path.resolve(workDir); let current = start; diff --git a/packages/agent-core-v2/src/app/skillCatalog/userFileSkillSource.ts b/packages/agent-core-v2/src/app/skillCatalog/userFileSkillSource.ts index fc7842a52b..6f8c317676 100644 --- a/packages/agent-core-v2/src/app/skillCatalog/userFileSkillSource.ts +++ b/packages/agent-core-v2/src/app/skillCatalog/userFileSkillSource.ts @@ -3,7 +3,9 @@ * * Discovers user skills from the bootstrap home directories through * `ISkillDiscovery`, contributing them at priority 20 (above extra / plugin / - * builtin, below workspace). Reads home paths from `bootstrap`. Bound at App scope. + * builtin, below workspace). Reads home paths from `bootstrap` and hot-reloads + * on both its config section and filesystem changes in the user skill roots + * (watched through `hostFsWatch` via `SkillRootWatcher`). Bound at App scope. */ import { createDecorator, type ServiceIdentifier } from '#/_base/di/instantiation'; @@ -13,6 +15,7 @@ import { Emitter, type Event } from '#/_base/event'; import { LifecycleScope, registerScopedService } from '#/_base/di/scope'; import { IBootstrapService } from '#/app/bootstrap/bootstrap'; import { IConfigService } from '#/app/config/config'; +import { IHostFsWatchService } from '#/os/interface/hostFsWatch'; import { MERGE_ALL_AVAILABLE_SKILLS_SECTION, @@ -20,7 +23,8 @@ import { } from './configSection'; import { ISkillCatalogRuntimeOptions } from './skillCatalogRuntimeOptions'; import { ISkillDiscovery } from './skillDiscovery'; -import { userRoots } from './skillRoots'; +import { SkillRootWatcher } from './skillRootWatcher'; +import { userRootCandidates, userRoots } from './skillRoots'; import { SKILL_SOURCE_PRIORITY, type ISkillSource, type SkillContribution } from './skillSource'; export interface IUserFileSkillSource extends ISkillSource { @@ -43,6 +47,7 @@ export class UserFileSkillSource extends Disposable implements IUserFileSkillSou @IBootstrapService private readonly bootstrap: IBootstrapService, @IConfigService private readonly config: IConfigService, @ISkillCatalogRuntimeOptions private readonly runtimeOptions: ISkillCatalogRuntimeOptions, + @IHostFsWatchService hostFsWatch: IHostFsWatchService, ) { super(); this._register( @@ -50,6 +55,15 @@ export class UserFileSkillSource extends Disposable implements IUserFileSkillSou if (event.domain === MERGE_ALL_AVAILABLE_SKILLS_SECTION) this.onDidChangeEmitter.fire(); }), ); + if ((this.runtimeOptions.explicitDirs?.length ?? 0) === 0) { + this._register( + new SkillRootWatcher( + hostFsWatch, + async () => userRootCandidates(this.bootstrap.homeDir, this.bootstrap.osHomeDir), + () => this.onDidChangeEmitter.fire(), + ), + ); + } } async load(): Promise { diff --git a/packages/agent-core-v2/src/app/telemetry/events.ts b/packages/agent-core-v2/src/app/telemetry/events.ts index 774fbf30b3..bd4bf4e2a9 100644 --- a/packages/agent-core-v2/src/app/telemetry/events.ts +++ b/packages/agent-core-v2/src/app/telemetry/events.ts @@ -395,6 +395,24 @@ export interface SessionLoadFailedEvent { reason: string; } +export interface SessionLeaseAcquiredEvent { + session_id: string; +} + +export interface SessionLeaseTakeoverEvent { + session_id: string; + previous: string; +} + +export interface SessionHeldByPeerReturnedEvent { + session_id: string; + phase: 'creating' | 'routable' | 'holder-unresponsive' | 'held-by-local-instance'; +} + +export interface SessionLeaseHolderUnresponsiveEvent { + session_id: string; +} + export interface FirstLaunchEvent {} export interface ExitEvent { @@ -851,6 +869,32 @@ export const telemetryEventDefinitions = { comment: 'A session resume fails.', properties: { reason: 'Error code, error name, or unknown' }, }), + session_lease_acquired: defineTelemetryEvent({ + owner: 'kimi-code', + comment: "This instance takes a session's write lease.", + properties: { session_id: 'Session the lease covers' }, + }), + session_lease_takeover: defineTelemetryEvent({ + owner: 'kimi-code', + comment: "A session's write lease is taken over from a stale (dead) holder.", + properties: { + session_id: 'Session the lease covers', + previous: 'Stale reason observed before takeover (holder-dead, pid-reused, …)', + }, + }), + session_held_by_peer_returned: defineTelemetryEvent({ + owner: 'kimi-code', + comment: 'A session materialization is refused because a peer instance holds the lease.', + properties: { + session_id: 'Session that was refused', + phase: 'Ownership phase reported to the client (routable, creating, …)', + }, + }), + session_lease_holder_unresponsive: defineTelemetryEvent({ + owner: 'kimi-code', + comment: "A session's lease holder is alive but its heartbeat is past TTL (frozen).", + properties: { session_id: 'Session whose holder is unresponsive' }, + }), first_launch: defineTelemetryEvent({ owner: 'kimi-code', comment: 'The CLI runs for the first time on this device.', diff --git a/packages/agent-core-v2/src/app/workspaceRegistry/fileWorkspacePersistence.ts b/packages/agent-core-v2/src/app/workspaceRegistry/fileWorkspacePersistence.ts index 7f04827d06..4d8b543669 100644 --- a/packages/agent-core-v2/src/app/workspaceRegistry/fileWorkspacePersistence.ts +++ b/packages/agent-core-v2/src/app/workspaceRegistry/fileWorkspacePersistence.ts @@ -1,13 +1,15 @@ /** - * `workspaceRegistry` domain (L1) — `FileWorkspacePersistence` implementation. + * `workspaceRegistry` domain (L2) — `FileWorkspacePersistence` implementation. * * File backend of `IWorkspacePersistence`. Persists the catalog as a single * v1-compatible `workspaces.json` document at the storage root * (`/workspaces.json`, via `scope = ''`) through the * `IAtomicDocumentStore` access-pattern Store. The `deleted_workspace_ids` * tombstone list round-trips with the catalog so soft deletions survive - * regardless of which engine (v1 or v2) last wrote the file. Bound at App - * scope. + * regardless of which engine (v1 or v2) last wrote the file, and the parsed + * document rides along in `WorkspaceCatalog.raw` so `save` re-applies the + * semantic view onto it — unknown top-level and entry fields written by other + * engine versions are preserved. Bound at App scope. */ import { InstantiationType } from '#/_base/di/extensions'; @@ -18,7 +20,6 @@ import type { Workspace } from './workspaceRegistry'; import { IWorkspacePersistence, type PersistedWorkspaceEntry, - type PersistedWorkspaceFile, type WorkspaceCatalog, } from './workspacePersistence'; @@ -32,22 +33,16 @@ export class FileWorkspacePersistence implements IWorkspacePersistence { constructor(@IAtomicDocumentStore private readonly docs: IAtomicDocumentStore) {} async load(): Promise { - const file = await this.docs.get( + const file = await this.docs.get>( WORKSPACE_REGISTRY_SCOPE, WORKSPACE_REGISTRY_KEY, ); - if (file === undefined) return undefined; - if ( - typeof file !== 'object' || - file === null || - typeof (file as { workspaces?: unknown }).workspaces !== 'object' || - (file as { workspaces?: unknown }).workspaces === null - ) { - return undefined; - } + if (!isRecord(file)) return undefined; + const rawWorkspaces = file['workspaces']; + if (!isRecord(rawWorkspaces)) return undefined; const now = Date.now(); const workspaces: Workspace[] = []; - for (const [id, raw] of Object.entries(file.workspaces)) { + for (const [id, raw] of Object.entries(rawWorkspaces)) { const entry = sanitizeEntry(raw); if (entry === null) continue; workspaces.push({ @@ -58,24 +53,29 @@ export class FileWorkspacePersistence implements IWorkspacePersistence { lastOpenedAt: parseTime(entry.last_opened_at, now), }); } - const rawDeleted = (file as { deleted_workspace_ids?: unknown }).deleted_workspace_ids; + const rawDeleted = file['deleted_workspace_ids']; const deletedIds = Array.isArray(rawDeleted) ? rawDeleted.filter((id): id is string => typeof id === 'string') : []; - return { workspaces, deletedIds }; + return { workspaces, deletedIds, raw: file }; } async save(catalog: WorkspaceCatalog): Promise { - const record: Record = {}; + const rawWorkspaces = catalog.raw['workspaces']; + const previousWorkspaces = isRecord(rawWorkspaces) ? rawWorkspaces : {}; + const record: Record = {}; for (const ws of catalog.workspaces) { + const previous = previousWorkspaces[ws.id]; record[ws.id] = { + ...(isPlainRecord(previous) ? previous : {}), root: ws.root, name: ws.name, created_at: new Date(ws.createdAt).toISOString(), last_opened_at: new Date(ws.lastOpenedAt).toISOString(), }; } - const file: PersistedWorkspaceFile = { + const file = { + ...catalog.raw, version: WORKSPACE_REGISTRY_VERSION, workspaces: record, deleted_workspace_ids: [...catalog.deletedIds], @@ -84,6 +84,14 @@ export class FileWorkspacePersistence implements IWorkspacePersistence { } } +function isRecord(value: unknown): value is Record { + return typeof value === 'object' && value !== null; +} + +function isPlainRecord(value: unknown): value is Record { + return isRecord(value) && !Array.isArray(value); +} + function sanitizeEntry(value: unknown): PersistedWorkspaceEntry | null { if (typeof value !== 'object' || value === null) return null; const v = value as Partial; diff --git a/packages/agent-core-v2/src/app/workspaceRegistry/workspacePersistence.ts b/packages/agent-core-v2/src/app/workspaceRegistry/workspacePersistence.ts index 46ae19c201..96d7690032 100644 --- a/packages/agent-core-v2/src/app/workspaceRegistry/workspacePersistence.ts +++ b/packages/agent-core-v2/src/app/workspaceRegistry/workspacePersistence.ts @@ -1,5 +1,5 @@ /** - * `workspaceRegistry` domain (L1) — `IWorkspacePersistence` contract. + * `workspaceRegistry` domain (L2) — `IWorkspacePersistence` contract. * * Domain-specific persistence Store for the known-workspaces catalog. It hides * the on-disk document layout (`/workspaces.json`, the v1-compatible @@ -17,6 +17,12 @@ * `load()` returns `undefined` to mean "no usable catalog" so the registry can * trigger a one-shot rebuild from the legacy session index; an empty catalog * is a valid, already-materialized state and must NOT trigger a rebuild. + * + * `WorkspaceCatalog.raw` carries the opaque document the catalog was loaded + * from; `save` re-applies the semantic view onto it so unknown top-level and + * entry fields written by other engine versions survive the round-trip — the + * read-modify-write contract of the shared file (design: + * `.tmp/refactor-watch-design-v2.md` §3.6). */ import { createDecorator, type ServiceIdentifier } from '#/_base/di/instantiation'; @@ -39,6 +45,10 @@ export interface PersistedWorkspaceFile { export interface WorkspaceCatalog { readonly workspaces: readonly Workspace[]; readonly deletedIds: readonly string[]; + /** Opaque snapshot of the document this catalog was loaded from (empty when + the file was absent or unusable). save() re-applies the semantic view + onto it, preserving fields this engine does not know. */ + readonly raw: Readonly>; } export interface IWorkspacePersistence { diff --git a/packages/agent-core-v2/src/app/workspaceRegistry/workspaceRegistryService.ts b/packages/agent-core-v2/src/app/workspaceRegistry/workspaceRegistryService.ts index 27ba9458d7..37671d8c59 100644 --- a/packages/agent-core-v2/src/app/workspaceRegistry/workspaceRegistryService.ts +++ b/packages/agent-core-v2/src/app/workspaceRegistry/workspaceRegistryService.ts @@ -1,18 +1,25 @@ /** - * `workspaceRegistry` domain (L1) — `IWorkspaceRegistry` implementation. + * `workspaceRegistry` domain (L2) — `IWorkspaceRegistry` implementation. * * Process-wide catalog of known workspaces, durable in * `/workspaces.json` (the v1-compatible file shared with * agent-core). The service keeps NO in-memory write cache: every operation - * is a fresh read-modify-write against the file, serialized through a - * promise-chain mutex. This is required, not just tidy — the same file is - * written concurrently by other processes (the v1 TUI registers session cwds - * via `touchWorkspaceRegistry`, which also re-reads the file on every call), - * so a write-through cache would clobber external additions and tombstones - * with stale state. Atomic renames at the persistence layer plus fresh - * read-modify-write on both engines shrink the lost-update window to a - * single read-modify-write, and the next session-index merge heals anything - * still lost there. + * is a fresh read-modify-write against the file. This is required, not just + * tidy — the same file is written concurrently by other processes (the v1 + * TUI registers session cwds via `touchWorkspaceRegistry`, which also + * re-reads the file on every call), so a write-through cache would clobber + * external additions and tombstones with stale state. + * + * Two exclusion layers make each read-modify-write safe (design: + * `.tmp/refactor-watch-design-v2.md` §3.6): the in-process promise chain + * serializes ops (entered first, so cross-process waiting stays minimal), + * and an `ICrossProcessLockService` file lock (`workspaces.json.lock`, from + * `crossProcessLock`, resolved against the `bootstrap` home dir) makes the + * load → mutate → save burst atomic against other lock-aware v2 processes. + * Unregistered writers (v1) stay best-effort: their lost updates are healed + * by the session-index merge. Tombstone logic, format and key names are + * unchanged, and unknown document fields round-trip verbatim via + * `WorkspaceCatalog.raw`. * * Once per process, the first operation triggers the startup sync with the * legacy `/session_index.jsonl`: @@ -57,13 +64,19 @@ * sibling buckets at once without rewriting any stored id. */ -import { basename, isAbsolute } from 'pathe'; +import { basename, isAbsolute, join } from 'pathe'; import { InstantiationType } from '#/_base/di/extensions'; import { LifecycleScope, registerScopedService } from '#/_base/di/scope'; import { encodeWorkDirKey, workspaceRootKey } from '#/_base/utils/workdir-slug'; import { ErrorCodes, Error2, unwrapErrorCause } from '#/errors'; +import { IBootstrapService } from '#/app/bootstrap/bootstrap'; import { IHostFileSystem } from '#/os/interface/hostFileSystem'; +import { + type CrossProcessLockAcquireOptions, + ICrossProcessLockService, + type CrossProcessLockWaitOptions, +} from '#/os/interface/crossProcessLock'; import { IFileSystemStorageService } from '#/persistence/interface/storage'; import { IWorkspaceRegistry, type Workspace, type WorkspaceUpdate } from './workspaceRegistry'; @@ -75,6 +88,10 @@ import { IWorkspacePersistence, type WorkspaceCatalog } from './workspacePersist const SESSION_INDEX_SCOPE = ''; const SESSION_INDEX_KEY = 'session_index.jsonl'; +const WORKSPACES_CATALOG_LOCK_OPTIONS: CrossProcessLockAcquireOptions & { + wait: CrossProcessLockWaitOptions; +} = { wait: { timeoutMs: 10_000 } }; + const textDecoder = new TextDecoder(); interface SessionIndexLine { @@ -89,12 +106,17 @@ export class WorkspaceRegistryService implements IWorkspaceRegistry { /** Whether the once-per-process session-index sync already ran. */ private merged = false; private opQueue: Promise = Promise.resolve(); + private readonly catalogLockPath: string; constructor( @IWorkspacePersistence private readonly store: IWorkspacePersistence, @IFileSystemStorageService private readonly storage: IFileSystemStorageService, @IHostFileSystem private readonly hostFs: IHostFileSystem, - ) {} + @IBootstrapService bootstrap: IBootstrapService, + @ICrossProcessLockService private readonly lock: ICrossProcessLockService, + ) { + this.catalogLockPath = join(bootstrap.homeDir, 'workspaces.json.lock'); + } list(): Promise { return this.runExclusive(async () => { @@ -206,7 +228,11 @@ export class WorkspaceRegistryService implements IWorkspaceRegistry { byId.set(ws.id, ws); // An explicit add clears any prior deletion tombstone. deletedIds.delete(ws.id); - await this.store.save({ workspaces: [...byId.values()], deletedIds: [...deletedIds] }); + await this.store.save({ + workspaces: [...byId.values()], + deletedIds: [...deletedIds], + raw: catalog.raw, + }); return ws; }); } @@ -224,6 +250,7 @@ export class WorkspaceRegistryService implements IWorkspaceRegistry { await this.store.save({ workspaces: catalog.workspaces.map((ws) => (ws.id === id ? updated : ws)), deletedIds: catalog.deletedIds, + raw: catalog.raw, }); return updated; }); @@ -250,6 +277,7 @@ export class WorkspaceRegistryService implements IWorkspaceRegistry { await this.store.save({ workspaces: catalog.workspaces.filter((ws) => ws.id !== id), deletedIds: [...new Set([...catalog.deletedIds, id])], + raw: catalog.raw, }); return; } @@ -258,6 +286,7 @@ export class WorkspaceRegistryService implements IWorkspaceRegistry { await this.store.save({ workspaces: catalog.workspaces.filter((ws) => workspaceRootKey(ws.root) !== rootKey), deletedIds: [...new Set([...catalog.deletedIds, ...aliasIds])], + raw: catalog.raw, }); }); } @@ -270,14 +299,18 @@ export class WorkspaceRegistryService implements IWorkspaceRegistry { const loaded = await this.store.load(); if (loaded === undefined) { const rebuilt = await this.rebuildFromSessionIndex(); - await this.store.save({ workspaces: [...rebuilt.values()], deletedIds: [] }); + await this.store.save({ workspaces: [...rebuilt.values()], deletedIds: [], raw: {} }); this.merged = true; return; } const byId = new Map(loaded.workspaces.map((ws) => [ws.id, ws])); const deletedIds = new Set(loaded.deletedIds); if (await this.mergeFromSessionIndex(byId, deletedIds)) { - await this.store.save({ workspaces: [...byId.values()], deletedIds: [...deletedIds] }); + await this.store.save({ + workspaces: [...byId.values()], + deletedIds: [...deletedIds], + raw: loaded.raw, + }); } this.merged = true; } @@ -285,7 +318,7 @@ export class WorkspaceRegistryService implements IWorkspaceRegistry { /** Read the current catalog; a missing or malformed file is an empty * catalog (mirrors v1's tolerant read). */ private async loadCatalog(): Promise { - return (await this.store.load()) ?? { workspaces: [], deletedIds: [] }; + return (await this.store.load()) ?? { workspaces: [], deletedIds: [], raw: {} }; } /** Add every distinct workDir from the legacy session index that the @@ -371,7 +404,9 @@ export class WorkspaceRegistryService implements IWorkspaceRegistry { } private runExclusive(op: () => Promise): Promise { - const next = this.opQueue.then(op, op); + const locked = (): Promise => + this.lock.withLock(this.catalogLockPath, WORKSPACES_CATALOG_LOCK_OPTIONS, op); + const next = this.opQueue.then(locked, locked); this.opQueue = next.then( () => {}, () => {}, diff --git a/packages/agent-core-v2/src/errors.ts b/packages/agent-core-v2/src/errors.ts index b6935173a1..496443b997 100644 --- a/packages/agent-core-v2/src/errors.ts +++ b/packages/agent-core-v2/src/errors.ts @@ -22,6 +22,7 @@ import { McpErrors } from '#/agent/mcp/errors'; import { MessageLegacyErrors } from '#/app/messageLegacy/errors'; import { ModelCatalogErrors } from '#/app/modelCatalog/errors'; import { OsFsErrors } from '#/os/interface/hostFsErrors'; +import { OsLockErrors } from '#/os/interface/crossProcessLock'; import { OsProcessErrors } from '#/os/interface/hostProcess'; import { PluginErrors } from '#/app/plugin/errors'; import { ProfileErrors } from '#/agent/profile/errors'; @@ -54,6 +55,7 @@ export { McpErrors } from '#/agent/mcp/errors'; export { MessageLegacyErrors } from '#/app/messageLegacy/errors'; export { ModelCatalogErrors } from '#/app/modelCatalog/errors'; export { OsFsErrors } from '#/os/interface/hostFsErrors'; +export { OsLockErrors } from '#/os/interface/crossProcessLock'; export { OsProcessErrors } from '#/os/interface/hostProcess'; export { PluginErrors } from '#/app/plugin/errors'; export { ProfileErrors } from '#/agent/profile/errors'; @@ -83,6 +85,7 @@ export const ErrorCodes = { ...MessageLegacyErrors.codes, ...ModelCatalogErrors.codes, ...OsFsErrors.codes, + ...OsLockErrors.codes, ...OsProcessErrors.codes, ...PluginErrors.codes, ...ProfileErrors.codes, diff --git a/packages/agent-core-v2/src/index.ts b/packages/agent-core-v2/src/index.ts index 66dbb44c60..28f4faec6e 100644 --- a/packages/agent-core-v2/src/index.ts +++ b/packages/agent-core-v2/src/index.ts @@ -33,12 +33,14 @@ export * from '#/app/telemetry/consoleAppender'; export * from '#/app/telemetry/cloudAppender'; export * from '#/app/bootstrap/bootstrap'; export * from '#/app/bootstrap/bootstrapService'; +export * from '#/os/interface/crossProcessLock'; export * from '#/os/interface/hostEnvironment'; export * from '#/os/interface/hostFileSystem'; export * from '#/os/interface/hostFsWatch'; export * from '#/os/interface/hostProcess'; export * from '#/os/interface/terminal'; export * from '#/os/interface/terminalErrors'; +export * from '#/os/backends/node-local/crossProcessLockService'; export * from '#/os/backends/node-local/hostEnvironmentService'; export * from '#/os/backends/node-local/hostFsService'; export * from '#/os/backends/node-local/hostFsWatchService'; @@ -75,6 +77,8 @@ export * from '#/app/sessionIndex/sessionIndex'; export * from '#/app/sessionIndex/sessionIndexService'; export * from '#/session/sessionMetadata/sessionMetadata'; export * from '#/session/sessionMetadata/sessionMetadataService'; +export * from '#/session/sessionLease/sessionLease'; +export * from '#/session/sessionLease/sessionLeaseContactProvider'; export * from '#/app/config/config'; export * from '#/app/config/configService'; import '#/app/provider/configSection'; @@ -144,6 +148,7 @@ export * from '#/app/skillCatalog/skillDiscovery'; export * from '#/app/skillCatalog/inMemorySkillDiscovery'; export * from '#/app/skillCatalog/skillSource'; export * from '#/app/skillCatalog/skillRoots'; +export * from '#/app/skillCatalog/skillRootWatcher'; export * from '#/app/skillCatalog/builtin/builtin'; export * from '#/app/skillCatalog/builtinSkillSource'; export * from '#/app/skillCatalog/userFileSkillSource'; @@ -155,6 +160,8 @@ export * from '#/session/sessionSkillCatalog/workspaceFileSkillSource'; export * from '#/session/sessionSkillCatalog/pluginSkillSource'; export * from '#/agent/permissionGate/permissionGate'; export * from '#/agent/permissionGate/permissionGateService'; +export * from '#/agent/fileFencing/fileFencing'; +export * from '#/agent/fileFencing/fileFencingService'; import '#/app/flag/flag'; import '#/app/flag/flagRegistry'; import '#/app/flag/flagRegistryService'; @@ -282,6 +289,8 @@ export * from '#/session/sessionFs/fsWatchService'; export * from '#/session/sessionFs/gitContext'; export * from '#/session/sessionFs/rgLocator'; export * from '#/session/sessionFs/runRg'; +export * from '#/session/sessionFileLedger/fileLedger'; +export * from '#/session/sessionFileLedger/fileLedgerService'; export * from '#/app/hostFolderBrowser/hostFolderBrowser'; export * from '#/app/hostFolderBrowser/hostFolderBrowserService'; export * from '#/persistence/interface/storage'; @@ -289,8 +298,10 @@ export * from '#/persistence/interface/appendLogStore'; export * from '#/persistence/interface/atomicDocumentStore'; export * from '#/persistence/interface/queryStore'; export * from '#/persistence/interface/blobStore'; +export * from '#/persistence/interface/writeAuthority'; export * from '#/persistence/backends/node-fs/fileStorageService'; export * from '#/persistence/backends/node-fs/appendLogStore'; +export * from '#/persistence/backends/node-fs/writeAuthorityRegistryService'; export * from '#/persistence/backends/node-fs/atomicDocumentStore'; export * from '#/persistence/backends/node-fs/blobStoreService'; export * from '#/persistence/backends/node-fs/workspaceLocalConfigService'; @@ -389,6 +400,8 @@ export * from '#/agent/loop/loop'; export * from '#/agent/loop/loopService'; export * from '#/agent/loop/loopContinuation'; export * from '#/agent/loop/loopContinuationService'; +export * from '#/app/mcp/mcpConfigWatch'; +import '#/app/mcp/mcpConfigWatchService'; export * from '#/agent/mcp/mcp'; export * from '#/agent/mcp/mcpService'; export * from '#/agent/mcp/mcpDiscoveryOps'; @@ -412,6 +425,8 @@ export * from '#/agent/permissionRules/permissionRulesService'; import '#/agent/profile/configSection'; export * from '#/agent/profile/profile'; export * from '#/agent/profile/profileService'; +export * from '#/agent/profile/agentSkillListingReminder'; +export * from '#/agent/profile/agentSkillListingReminderService'; export * from '#/agent/profile/context'; export * from '#/agent/prompt/prompt'; export * from '#/agent/prompt/promptService'; diff --git a/packages/agent-core-v2/src/os/backends/node-local/crossProcessLockService.ts b/packages/agent-core-v2/src/os/backends/node-local/crossProcessLockService.ts new file mode 100644 index 0000000000..278afffd2a --- /dev/null +++ b/packages/agent-core-v2/src/os/backends/node-local/crossProcessLockService.ts @@ -0,0 +1,573 @@ +/** + * `crossProcessLock` domain (L1) — `ICrossProcessLockService` implementation. + * + * Node-local backend for the cross-process exclusive file-lock protocol + * defined by `os/interface/crossProcessLock` (design: + * `.tmp/refactor-watch-design-v2.md` §3.3). Uses the synchronous `node:fs` + * API by design: every operation is a short burst (create / read / rename / + * beat) on low-frequency coordination paths — server lock, session lease, + * lock-in-RMW — and must never be called from turn/loop hot paths. Process + * probing goes through `createNodeProcessProbe`; every clock, pid, probe and + * token source is injectable for tests. + * + * Protocol invariants implemented here: + * + * - Token-guarded: acquire stamps a fresh ulid `lockId`; release, heartbeat + * and payload rewrites re-read the file and compare it before touching the + * lock, so a late operation never clobbers a newer holder. + * - Live PID is never taken over. Only pid death, or a live pid whose + * `processStartedAt` identity no longer matches (pid reused), makes a lock + * stale; a live identity-matching holder whose heartbeat is past ttl is + * `holder-unresponsive` — reported, never seized. An identity that either + * side cannot provide counts as matching (conservative). + * - Takeover is rename-isolated: the stale file is moved aside to + * `.stale.` before re-creating, and the freshly created + * payload is read back and confirmed against the new `lockId` — a creator + * frozen inside its create window cannot silently stomp the new lock. + * - Creation window: an empty/unparseable file younger than + * `creationWindowMs` (default 5s) is `creating` (treated as held); past the + * window it is stale. + * - Heartbeat is `write(position 0) + ftruncate + fsync` on the fd kept open + * from acquire — never tmp+rename, which would let a frozen old holder's + * next beat overwrite the lock that took it over. + * + * Bound at App scope. + */ + +import { + closeSync, + fsyncSync, + ftruncateSync, + mkdirSync, + openSync, + readFileSync, + renameSync, + statSync, + unlinkSync, + writeSync, +} from 'node:fs'; +import { dirname } from 'node:path'; + +import { ulid } from 'ulid'; + +import { InstantiationType } from '#/_base/di/extensions'; +import { LifecycleScope, registerScopedService } from '#/_base/di/scope'; +import { + CrossProcessLockError, + CrossProcessLockErrorCode, + type CrossProcessLockAcquireOptions, + type CrossProcessLockHeartbeatOptions, + type CrossProcessLockInspection, + type CrossProcessLockPayload, + type CrossProcessLockServiceDeps, + type CrossProcessLockUnavailableReason, + type CrossProcessLockWaitOptions, + type ICrossProcessLockHandle, + ICrossProcessLockService, + type ProcessProbe, +} from '#/os/interface/crossProcessLock'; + +import { createNodeProcessProbe } from './processProbe'; + +const DEFAULT_CREATION_WINDOW_MS = 5_000; +const DEFAULT_WAIT_RETRY_INTERVAL_MS = 50; +const MAX_ACQUIRE_ATTEMPTS = 3; + +function readErrno(error: unknown): string | undefined { + if (error === null || typeof error !== 'object' || !('code' in error)) return undefined; + const code = (error as { code: unknown }).code; + return typeof code === 'string' ? code : undefined; +} + +function errorMessage(error: unknown): string { + return error instanceof Error ? error.message : String(error); +} + +function toLockIoError(error: unknown, ctx: { path: string; op: string }): CrossProcessLockError { + if (error instanceof CrossProcessLockError) return error; + return new CrossProcessLockError( + CrossProcessLockErrorCode.Io, + `${ctx.op} failed on lock file: ${errorMessage(error)}`, + { details: { path: ctx.path, op: ctx.op, errno: readErrno(error) }, cause: error }, + ); +} + +function heldError( + lockPath: string, + reason: CrossProcessLockUnavailableReason, + holder: CrossProcessLockPayload | undefined, +): CrossProcessLockError { + const summary = holder + ? `pid=${holder.pid} instanceId=${holder.instanceId} address=${holder.address ?? '-'} heartbeatAt=${holder.heartbeatAt ?? '-'}` + : 'holder unknown'; + return new CrossProcessLockError( + CrossProcessLockErrorCode.Held, + `cross-process lock unavailable (${reason}): ${summary}`, + { details: { path: lockPath, reason, holder } }, + ); +} + +function lostError(lockPath: string, what: string): CrossProcessLockError { + return new CrossProcessLockError( + CrossProcessLockErrorCode.Lost, + `lock ownership lost while ${what}`, + { details: { path: lockPath } }, + ); +} + +interface DiskLockPayload { + lock_id?: string; + instance_id?: string; + pid?: number; + process_started_at?: string; + address?: string; + heartbeat_at?: number; + [extra: string]: unknown; +} + +function renderPayloadJson(payload: CrossProcessLockPayload): string { + const { lockId, instanceId, pid, processStartedAt, address, heartbeatAt, ...extras } = payload; + const disk: DiskLockPayload = { + ...extras, + lock_id: lockId, + instance_id: instanceId, + pid, + }; + if (processStartedAt !== undefined) disk.process_started_at = processStartedAt; + if (address !== undefined) disk.address = address; + if (heartbeatAt !== undefined) disk.heartbeat_at = heartbeatAt; + return JSON.stringify(disk); +} + +function parseDiskPayload(raw: string): CrossProcessLockPayload | undefined { + let parsed: unknown; + try { + parsed = JSON.parse(raw); + } catch { + return undefined; + } + if (parsed === null || typeof parsed !== 'object' || Array.isArray(parsed)) return undefined; + const disk = parsed as DiskLockPayload; + const hasLockId = typeof disk.lock_id === 'string'; + const hasPid = typeof disk.pid === 'number'; + if (!hasLockId && !hasPid) return undefined; + const { lock_id, instance_id, pid, process_started_at, address, heartbeat_at, ...extras } = disk; + const payload: CrossProcessLockPayload = { + ...extras, + lockId: lock_id ?? '', + instanceId: typeof instance_id === 'string' ? instance_id : '', + pid: typeof pid === 'number' ? pid : -1, + }; + if (typeof process_started_at === 'string') payload.processStartedAt = process_started_at; + if (typeof address === 'string') payload.address = address; + if (typeof heartbeat_at === 'number') payload.heartbeatAt = heartbeat_at; + return payload; +} + +function readPayloadFromPath(lockPath: string): CrossProcessLockPayload | undefined { + let raw: string; + try { + raw = readFileSync(lockPath, 'utf8'); + } catch { + return undefined; + } + return parseDiskPayload(raw); +} + +function extractExtras(payload: CrossProcessLockPayload): Record { + const { + lockId: _lockId, + instanceId: _instanceId, + pid: _pid, + processStartedAt: _processStartedAt, + address, + heartbeatAt: _heartbeatAt, + ...rest + } = payload; + const extras: DiskLockPayload = rest; + if (address !== undefined) extras.address = address; + return extras; +} + +function isProbingPid(pid: number): boolean { + return Number.isInteger(pid) && pid > 0; +} + +class NodeCrossProcessLockHandle implements ICrossProcessLockHandle { + private _released = false; + private _lostNotified = false; + private _timer: ReturnType | undefined; + private _fd: number; + private _extras: Record; + + constructor( + readonly lockPath: string, + readonly lockId: string, + private readonly now: () => number, + private readonly selfPid: number, + private readonly instanceId: string, + private readonly selfProcessStartedAt: string | undefined, + extras: Record, + private readonly heartbeat: CrossProcessLockHeartbeatOptions | undefined, + private readonly onLost: (() => void) | undefined, + fd: number, + ) { + this._extras = extras; + this._fd = fd; + } + + checkHeld(): boolean { + return readPayloadFromPath(this.lockPath)?.lockId === this.lockId; + } + + update(mutate: (payload: CrossProcessLockPayload) => Record): void { + const current = readPayloadFromPath(this.lockPath); + if (current?.lockId !== this.lockId) { + throw lostError(this.lockPath, 'updating the payload'); + } + const merged: CrossProcessLockPayload = { + ...current, + ...mutate(current), + lockId: this.lockId, + instanceId: this.instanceId, + pid: this.selfPid, + }; + this._extras = extractExtras(merged); + try { + this.writePayload(); + } catch (error) { + if (readErrno(error) === 'ENOENT') throw lostError(this.lockPath, 'updating the payload'); + throw toLockIoError(error, { path: this.lockPath, op: 'update' }); + } + } + + release(): void { + if (this._released) return; + this._released = true; + this.stopHeartbeat(); + this.closeFd(); + try { + if (readPayloadFromPath(this.lockPath)?.lockId === this.lockId) { + unlinkSync(this.lockPath); + } + } catch { + // best-effort: a release failure must never delete a foreign lock. + } + } + + startHeartbeat(): void { + if (this.heartbeat === undefined) return; + this._timer = setInterval(() => { + this.tick(); + }, this.heartbeat.intervalMs); + this._timer.unref(); + } + + writeInitialPayload(): void { + this.writePayload(); + } + + sealPidOnly(): void { + this.closeFd(); + } + + private tick(): void { + if (this._released || this._fd < 0) return; + try { + this.writePayload(); + } catch { + this.handleLost(); + return; + } + if (readPayloadFromPath(this.lockPath)?.lockId !== this.lockId) { + this.handleLost(); + } + } + + private handleLost(): void { + this.stopHeartbeat(); + this.closeFd(); + if (this._lostNotified) return; + this._lostNotified = true; + this.onLost?.(); + } + + private writePayload(): void { + const payload: CrossProcessLockPayload = { + ...this._extras, + lockId: this.lockId, + instanceId: this.instanceId, + pid: this.selfPid, + processStartedAt: this.selfProcessStartedAt, + heartbeatAt: this.heartbeat !== undefined ? this.now() : undefined, + }; + const data = Buffer.from(renderPayloadJson(payload), 'utf8'); + if (this._fd >= 0) { + writeSync(this._fd, data, 0, data.length, 0); + ftruncateSync(this._fd, data.length); + fsyncSync(this._fd); + return; + } + const fd = openSync(this.lockPath, 'r+'); + try { + writeSync(fd, data, 0, data.length, 0); + ftruncateSync(fd, data.length); + fsyncSync(fd); + } finally { + closeSync(fd); + } + } + + private stopHeartbeat(): void { + if (this._timer === undefined) return; + clearInterval(this._timer); + this._timer = undefined; + } + + private closeFd(): void { + if (this._fd < 0) return; + try { + closeSync(this._fd); + } catch { + // fd already closed elsewhere; nothing to do. + } + this._fd = -1; + } +} + +export class CrossProcessLockService implements ICrossProcessLockService { + declare readonly _serviceBrand: undefined; + + private readonly now: () => number; + private readonly selfPid: number; + private readonly probe: ProcessProbe; + private readonly newLockId: () => string; + private readonly instanceId: string; + private readonly sleep: (ms: number) => Promise; + + constructor(deps: CrossProcessLockServiceDeps = {}) { + this.now = deps.now ?? Date.now; + this.selfPid = deps.selfPid ?? process.pid; + this.probe = deps.probeProcess ?? createNodeProcessProbe(); + this.newLockId = deps.newLockId ?? ulid; + this.instanceId = deps.instanceId ?? ulid(); + this.sleep = + deps.sleep ?? + ((ms) => + new Promise((resolvePromise) => { + const timer = setTimeout(resolvePromise, ms); + timer.unref(); + })); + } + + acquire( + lockPath: string, + options: CrossProcessLockAcquireOptions = {}, + ): ICrossProcessLockHandle { + try { + mkdirSync(dirname(lockPath), { recursive: true }); + } catch (error) { + throw toLockIoError(error, { path: lockPath, op: 'mkdir' }); + } + const creationWindowMs = options.creationWindowMs ?? DEFAULT_CREATION_WINDOW_MS; + const observedTtlMs = options.heartbeat?.ttlMs ?? creationWindowMs; + let lastHolder: CrossProcessLockPayload | undefined; + for (let attempt = 0; attempt < MAX_ACQUIRE_ATTEMPTS; attempt++) { + let fd: number; + try { + fd = openSync(lockPath, 'wx', 0o600); + } catch (error) { + if (readErrno(error) !== 'EEXIST') { + throw toLockIoError(error, { path: lockPath, op: 'open' }); + } + const inspection = this.classify(lockPath, creationWindowMs); + lastHolder = inspection.payload; + switch (inspection.state) { + case 'free': + continue; + case 'creating': + throw heldError(lockPath, 'creating', undefined); + case 'held': + throw heldError(lockPath, this.reasonForHeld(inspection.payload, observedTtlMs), inspection.payload); + case 'stale': + this.isolateStale(lockPath, inspection); + continue; + } + } + return this.completeAcquire(lockPath, fd, options); + } + throw heldError(lockPath, 'held', lastHolder); + } + + async acquireWithWait( + lockPath: string, + options: CrossProcessLockAcquireOptions & { wait: CrossProcessLockWaitOptions }, + ): Promise { + const start = this.now(); + let lastError: CrossProcessLockError | undefined; + for (;;) { + try { + return this.acquire(lockPath, options); + } catch (error) { + if (!(error instanceof CrossProcessLockError) || error.code !== CrossProcessLockErrorCode.Held) { + throw error; + } + lastError = error; + if (this.now() - start >= options.wait.timeoutMs) { + throw new CrossProcessLockError( + CrossProcessLockErrorCode.WaitTimeout, + `timed out waiting for the cross-process lock (${options.wait.timeoutMs}ms): ${lastError.message}`, + { details: { path: lockPath, timeoutMs: options.wait.timeoutMs }, cause: lastError }, + ); + } + await this.sleep(options.wait.retryIntervalMs ?? DEFAULT_WAIT_RETRY_INTERVAL_MS); + } + } + } + + async withLock( + lockPath: string, + options: CrossProcessLockAcquireOptions & { wait: CrossProcessLockWaitOptions }, + fn: (handle: ICrossProcessLockHandle) => T | Promise, + ): Promise { + const handle = await this.acquireWithWait(lockPath, options); + try { + return await fn(handle); + } finally { + handle.release(); + } + } + + inspect( + lockPath: string, + options?: Pick, + ): CrossProcessLockInspection { + return this.classify(lockPath, options?.creationWindowMs ?? DEFAULT_CREATION_WINDOW_MS); + } + + private classify(lockPath: string, creationWindowMs: number): CrossProcessLockInspection { + let raw: string; + try { + raw = readFileSync(lockPath, 'utf8'); + } catch (error) { + if (readErrno(error) === 'ENOENT') return { state: 'free' }; + throw toLockIoError(error, { path: lockPath, op: 'read' }); + } + const payload = parseDiskPayload(raw); + if (payload === undefined) { + const mtimeMs = this.readMtimeMs(lockPath); + if (mtimeMs === undefined) return { state: 'free' }; + return this.now() - mtimeMs < creationWindowMs + ? { state: 'creating' } + : { state: 'stale', staleReason: 'creation-window-expired' }; + } + if (isProbingPid(payload.pid)) { + const probed = this.safeProbe(payload.pid); + if (!probed.alive) { + return { state: 'stale', payload, staleReason: 'holder-dead' }; + } + if ( + payload.processStartedAt !== undefined && + probed.processStartedAt !== undefined && + payload.processStartedAt !== probed.processStartedAt + ) { + return { state: 'stale', payload, staleReason: 'pid-reused' }; + } + } + return { state: 'held', payload, unavailableReason: 'held' }; + } + + private reasonForHeld( + payload: CrossProcessLockPayload | undefined, + observedTtlMs: number, + ): CrossProcessLockUnavailableReason { + const heartbeatAt = payload?.heartbeatAt; + if (heartbeatAt !== undefined && this.now() - heartbeatAt > observedTtlMs) { + return 'holder-unresponsive'; + } + return 'held'; + } + + private isolateStale(lockPath: string, inspection: CrossProcessLockInspection): void { + const rawLockId = inspection.payload?.lockId; + const staleLockId = rawLockId !== undefined && rawLockId !== '' ? rawLockId : 'unknown'; + try { + renameSync(lockPath, `${lockPath}.stale.${staleLockId}`); + } catch (error) { + if (readErrno(error) === 'ENOENT') return; + throw toLockIoError(error, { path: lockPath, op: 'rename-stale' }); + } + } + + private completeAcquire( + lockPath: string, + fd: number, + options: CrossProcessLockAcquireOptions, + ): ICrossProcessLockHandle { + const lockId = this.newLockId(); + const extras: DiskLockPayload = { ...options.extraPayload }; + if (options.address !== undefined) extras.address = options.address; + const handle = new NodeCrossProcessLockHandle( + lockPath, + lockId, + this.now, + this.selfPid, + this.instanceId, + this.safeProbe(this.selfPid).processStartedAt, + extras, + options.heartbeat, + options.onLost, + fd, + ); + try { + handle.writeInitialPayload(); + } catch (error) { + // We exclusively created this file via O_EXCL and its (partial) content + // is not a valid payload, so cleanup is safe and avoids creating-window + // litter; release() closes the fd without touching foreign payloads. + handle.release(); + try { + unlinkSync(lockPath); + } catch { + // best effort + } + throw toLockIoError(error, { path: lockPath, op: 'write' }); + } + // Read-back confirmation: a creator frozen inside its create window must + // honestly fail instead of believing it still owns the lock. + if (readPayloadFromPath(lockPath)?.lockId !== lockId) { + handle.release(); + throw lostError(lockPath, 'confirming the newly created payload'); + } + if (options.heartbeat !== undefined) { + handle.startHeartbeat(); + } else { + handle.sealPidOnly(); + } + return handle; + } + + private safeProbe(pid: number): { alive: boolean; processStartedAt?: string } { + try { + return this.probe(pid); + } catch { + return { alive: true }; + } + } + + private readMtimeMs(lockPath: string): number | undefined { + try { + return statSync(lockPath).mtimeMs; + } catch { + return undefined; + } + } +} + +registerScopedService( + LifecycleScope.App, + ICrossProcessLockService, + CrossProcessLockService, + InstantiationType.Eager, + 'crossProcessLock', +); diff --git a/packages/agent-core-v2/src/os/backends/node-local/hostFsWatchService.ts b/packages/agent-core-v2/src/os/backends/node-local/hostFsWatchService.ts index 1014d690d6..c7b1286353 100644 --- a/packages/agent-core-v2/src/os/backends/node-local/hostFsWatchService.ts +++ b/packages/agent-core-v2/src/os/backends/node-local/hostFsWatchService.ts @@ -6,6 +6,8 @@ * handle closes it. Bound at App scope. */ +import type { Stats } from 'node:fs'; + import { FSWatcher } from 'chokidar'; import { Emitter, type Event } from '#/_base/event'; @@ -24,6 +26,17 @@ import { const DEFAULT_IGNORED = (p: string): boolean => /(?:^|[/\\])\.git(?:$|[/\\])/.test(p); +/** + * chokidar attaches `fs.watch` to every scanned entry; special files (unix + * sockets, fifos, devices) make that call throw UNKNOWN — e.g. a depth-0 + * sentinel landing on a shared temp root that contains sockets, or a project + * tree with a socket in it. Filter them up front so one such entry can never + * poison the whole watcher. + */ +function isSpecialEntry(stats: Stats | undefined): boolean { + return stats !== undefined && !stats.isFile() && !stats.isDirectory() && !stats.isSymbolicLink(); +} + class HostFsWatchHandle implements IHostFsWatchHandle { readonly onDidChange: Event; @@ -39,7 +52,8 @@ class HostFsWatchHandle implements IHostFsWatchHandle { persistent: false, followSymlinks: false, depth: options?.recursive === false ? 0 : undefined, - ignored: options?.ignored ?? DEFAULT_IGNORED, + ignored: (path, stats) => + isSpecialEntry(stats) || (options?.ignored?.(path) ?? DEFAULT_IGNORED(path)), }); this.watcher.on('all', (eventName: string, absPath: string) => { const mapped = mapChokidarEvent(eventName, absPath); diff --git a/packages/agent-core-v2/src/os/backends/node-local/processProbe.ts b/packages/agent-core-v2/src/os/backends/node-local/processProbe.ts new file mode 100644 index 0000000000..885b6151c5 --- /dev/null +++ b/packages/agent-core-v2/src/os/backends/node-local/processProbe.ts @@ -0,0 +1,71 @@ +/** + * `crossProcessLock` domain (L1) — node-local `ProcessProbe` implementation. + * + * `alive` follows `process.kill(pid, 0)` semantics: ESRCH means the pid is + * gone; EPERM or any other failure is treated as conservatively alive so a + * probing error never causes a live lock to be seized. `processStartedAt` is + * the opaque pid-reuse identity token: darwin attempts `sysctl -n + * kern.proc.starttime` (modern macOS exposes no named per-pid starttime OID + * — the call fails and the token is absent, which the lock protocol treats + * as "identity unavailable → treat as matching"); linux reads + * `/proc//stat` field 22 (starttime in clock ticks); other platforms + * cannot provide one. The token is only ever compared for equality by + * callers — it is never parsed for meaning, and `ps -o lstart=` is + * deliberately not used (locale-dependent, unstable format). When the + * process is dead the probe returns `{alive: false}` without collecting a + * token; a token-collection failure on a live process degrades to + * `{alive: true}` with no token. + */ + +import { execFileSync } from 'node:child_process'; +import { readFileSync } from 'node:fs'; + +import type { ProcessProbe } from '#/os/interface/crossProcessLock'; + +export function createNodeProcessProbe(): ProcessProbe { + return (pid) => { + if (!pidAlive(pid)) return { alive: false }; + return { alive: true, processStartedAt: readProcessStartedAt(pid) }; + }; +} + +function pidAlive(pid: number): boolean { + try { + process.kill(pid, 0); + return true; + } catch (error) { + const code = (error as NodeJS.ErrnoException).code; + if (code === 'ESRCH') return false; + return true; + } +} + +function readProcessStartedAt(pid: number): string | undefined { + try { + if (process.platform === 'darwin') return darwinProcessStartedAt(pid); + if (process.platform === 'linux') return linuxProcessStartedAt(pid); + return undefined; + } catch { + return undefined; + } +} + +function darwinProcessStartedAt(pid: number): string | undefined { + const out = execFileSync('sysctl', ['-n', 'kern.proc.starttime', String(pid)], { + encoding: 'utf8', + timeout: 3000, + stdio: ['ignore', 'pipe', 'ignore'], + }); + const match = /sec = (\d+), usec = (\d+)/.exec(out); + if (match === null) return undefined; + return `${match[1]}.${match[2]}`; +} + +function linuxProcessStartedAt(pid: number): string | undefined { + const raw = readFileSync(`/proc/${pid}/stat`, 'utf8'); + const close = raw.lastIndexOf(')'); + if (close < 0) return undefined; + const rest = raw.slice(close + 1).trim().split(' '); + const starttime = rest[19]; + return starttime === undefined || starttime === '' ? undefined : starttime; +} diff --git a/packages/agent-core-v2/src/os/interface/crossProcessLock.ts b/packages/agent-core-v2/src/os/interface/crossProcessLock.ts new file mode 100644 index 0000000000..da35aa1b87 --- /dev/null +++ b/packages/agent-core-v2/src/os/interface/crossProcessLock.ts @@ -0,0 +1,226 @@ +/** + * `crossProcessLock` domain (L1) — cross-process exclusive file-lock contract. + * + * Defines `ICrossProcessLockService`, the single lock protocol that replaces the + * repo's ad-hoc lockfiles (design: `.tmp/refactor-watch-design-v2.md` §3.3). + * One JSON lock file per resource, created with `O_EXCL`. Protocol invariants: + * + * - Token-guarded: every acquire generates a fresh `lockId` (ulid); release, + * heartbeat and payload rewrites re-read the file and compare `lockId` before + * touching it, so a late operation never clobbers a newer holder's lock. + * - Live PID is never taken over: a lock whose owner pid is alive and whose + * `processStartedAt` identity matches is held, even when its heartbeat has + * gone silent (`alive-unresponsive` — alert, never seize). Pid death, or a + * pid whose identity no longer matches (pid reused by a new process), makes + * the lock stale. + * - Takeover is rename-isolated: the stale file is renamed aside to + * `.stale.` before re-creating, then the new payload is read + * back and confirmed — a creator frozen inside its create window (SIGSTOP) + * cannot silently stomp the new lock when it resumes. + * - Creation window: an empty or unparseable file younger than the creation + * window is "creating" (treated as held, no address yet); only past the + * window may it be treated as stale. + * - Heartbeat, for modes that use it, is `pwrite + ftruncate + fsync` on the + * fd kept open from acquire — never tmp+rename, which would let a frozen old + * holder's next beat overwrite the lock that took it over. + * + * The on-disk JSON is flat and snake_case, matching operator-facing lock + * conventions; the six known protocol keys map to the camelCase fields of + * `CrossProcessLockPayload`, and any additional adapter-owned keys pass + * through untouched. Bound at App scope; the Node implementation lives in + * `os/backends/node-local/crossProcessLockService.ts`. + */ + +import { registerErrorDomain, type ErrorDomain } from '#/_base/errors/codes'; +import { Error2, type Error2Options } from '#/_base/errors/errors'; +import { createDecorator, type ServiceIdentifier } from '#/_base/di/instantiation'; + +export interface CrossProcessLockPayload { + /** Unique token of this acquire; every mutation must be guarded by it. */ + lockId: string; + /** Identity of the acquiring instance (per-service ulid by default). */ + instanceId: string; + pid: number; + /** Opaque platform identity token for pid-reuse detection (macOS `sysctl + kern.proc.starttime`, Linux `/proc//stat` field 22); compared by + equality only, never parsed. Absent when the platform cannot provide it. */ + processStartedAt?: string; + /** Contact address for instances that run a network service. */ + address?: string; + /** Last heartbeat wall-clock ms; present only in heartbeat modes. */ + heartbeatAt?: number; + /** Adapter-owned extra fields, kept flat on disk (e.g. server lock `port`). */ + [extra: string]: unknown; +} + +export interface CrossProcessLockHeartbeatOptions { + /** Milliseconds between heartbeat writes. */ + readonly intervalMs: number; + /** Milliseconds of heartbeat silence after which a *dead-identity* holder is + observable as unresponsive; a live, identity-matching pid is still never + taken over — the ttl only feeds the `alive-unresponsive` verdict. */ + readonly ttlMs: number; +} + +export interface CrossProcessLockWaitOptions { + /** Give up and throw `OS_LOCK_WAIT_TIMEOUT` after this many ms. */ + readonly timeoutMs: number; + /** Delay between acquisition attempts; small default when omitted. */ + readonly retryIntervalMs?: number; +} + +export interface CrossProcessLockAcquireOptions { + /** Heartbeat mode. Omit for pid-only locks (no fd kept, no beats). */ + readonly heartbeat?: CrossProcessLockHeartbeatOptions; + /** Milliseconds an empty/unparseable lock file counts as "creating" rather + than stale. Default 5000 for heartbeat-less modes. */ + readonly creationWindowMs?: number; + /** Contact address recorded in the payload. */ + readonly address?: string; + /** Extra flat fields written into the lock JSON (adapter-owned, snake_case). */ + readonly extraPayload?: Record; + /** Called once when a heartbeat-mode lock detects it has lost ownership + (payload token no longer matches). Not called for pid-only locks. */ + readonly onLost?: () => void; +} + +export type CrossProcessLockUnavailableReason = + /** Live, identity-matching owner (or, heartbeat mode, silently frozen). */ + | 'held' + /** Empty/unparseable file still inside its creation window. */ + | 'creating' + /** Heartbeat-mode lock whose heartbeatAt is past ttl while the owner pid is + alive and identity-matching. Never taken over; surfaced for alerting. */ + | 'holder-unresponsive'; + +export type CrossProcessLockStaleReason = + /** Owner pid no longer exists. */ + | 'holder-dead' + /** Owner pid alive but `processStartedAt` differs — pid was reused. */ + | 'pid-reused' + /** Empty/unparseable file older than the creation window. */ + | 'creation-window-expired'; + +export interface CrossProcessLockInspection { + readonly state: 'free' | 'creating' | 'held' | 'stale'; + /** Present whenever the file existed and parsed. */ + readonly payload?: CrossProcessLockPayload; + readonly unavailableReason?: CrossProcessLockUnavailableReason; + readonly staleReason?: CrossProcessLockStaleReason; +} + +export interface ICrossProcessLockHandle { + readonly lockPath: string; + readonly lockId: string; + /** True while the on-disk payload still carries this handle's `lockId`. */ + checkHeld(): boolean; + /** Token-guarded payload rewrite (`port` updates and the like). Re-reads + and compares `lockId` first; protocol fields (`lockId`/`instanceId`/`pid`) + are re-stamped, mutator output cannot change them. Throws + `OS_LOCK_LOST` when the token no longer matches. */ + update(mutate: (payload: CrossProcessLockPayload) => Record): void; + /** Token-guarded unlink. Idempotent; a missing or foreign-owned file is + left untouched. Stops the heartbeat when present. */ + release(): void; +} + +export interface ICrossProcessLockService { + readonly _serviceBrand: undefined; + + /** Fail-fast acquisition. Throws `OS_LOCK_HELD` carrying the + `CrossProcessLockUnavailableReason` when a live owner stands in the way; + takes over stale locks per protocol. */ + acquire( + lockPath: string, + options?: CrossProcessLockAcquireOptions, + ): ICrossProcessLockHandle; + + /** Blocking acquisition for short critical sections (lock-in-RMW): retries + while the lock is held/creating until `wait.timeoutMs` elapses. */ + acquireWithWait( + lockPath: string, + options: CrossProcessLockAcquireOptions & { wait: CrossProcessLockWaitOptions }, + ): Promise; + + /** `acquireWithWait` + `fn` + guaranteed release, for read-modify-write + critical sections. */ + withLock( + lockPath: string, + options: CrossProcessLockAcquireOptions & { wait: CrossProcessLockWaitOptions }, + fn: (handle: ICrossProcessLockHandle) => T | Promise, + ): Promise; + + /** Read-only probe; never mutates the file. */ + inspect(lockPath: string, options?: Pick): CrossProcessLockInspection; +} + +export const ICrossProcessLockService: ServiceIdentifier = + createDecorator('crossProcessLockService'); + +/** Process probing seam, injectable for tests. `alive` follows `kill(pid,0)` + semantics (EPERM counts as alive); `processStartedAt` is the opaque identity + token. A probing failure must return the conservative `{alive: true}`. */ +export type ProcessProbe = (pid: number) => { + alive: boolean; + processStartedAt?: string; +}; + +/** Test seam: every clock, pid, probe and token source is replaceable. */ +export interface CrossProcessLockServiceDeps { + readonly now?: () => number; + readonly selfPid?: number; + readonly probeProcess?: ProcessProbe; + readonly newLockId?: () => string; + readonly instanceId?: string; + readonly sleep?: (ms: number) => Promise; +} + +export const OsLockErrors = { + codes: { + OS_LOCK_HELD: 'os.lock.held', + OS_LOCK_WAIT_TIMEOUT: 'os.lock.wait_timeout', + OS_LOCK_LOST: 'os.lock.lost', + OS_LOCK_IO: 'os.lock.io', + }, + info: { + 'os.lock.held': { + title: 'Lock is held by another process', + retryable: false, + public: true, + }, + 'os.lock.wait_timeout': { + title: 'Timed out waiting for a cross-process lock', + retryable: true, + public: true, + }, + 'os.lock.lost': { + title: 'Lock ownership was lost to another process', + retryable: false, + public: true, + }, + 'os.lock.io': { + title: 'Lock file I/O failed', + retryable: true, + public: false, + }, + }, +} as const satisfies ErrorDomain; + +registerErrorDomain(OsLockErrors); + +export const CrossProcessLockErrorCode = { + Held: OsLockErrors.codes.OS_LOCK_HELD, + WaitTimeout: OsLockErrors.codes.OS_LOCK_WAIT_TIMEOUT, + Lost: OsLockErrors.codes.OS_LOCK_LOST, + Io: OsLockErrors.codes.OS_LOCK_IO, +} as const; + +export type CrossProcessLockErrorCode = + (typeof CrossProcessLockErrorCode)[keyof typeof CrossProcessLockErrorCode]; + +export class CrossProcessLockError extends Error2 { + constructor(code: CrossProcessLockErrorCode, message: string, options?: Error2Options) { + super(code, message, options); + this.name = 'CrossProcessLockError'; + } +} diff --git a/packages/agent-core-v2/src/persistence/backends/node-fs/appendLogStore.ts b/packages/agent-core-v2/src/persistence/backends/node-fs/appendLogStore.ts index 074b98798e..4421f34a7e 100644 --- a/packages/agent-core-v2/src/persistence/backends/node-fs/appendLogStore.ts +++ b/packages/agent-core-v2/src/persistence/backends/node-fs/appendLogStore.ts @@ -11,14 +11,26 @@ * rewrite failures sticky, keeps the shared flush pending until the * post-rewrite drain is durable, waits every key before a global flush reports * an error, and preserves per-key storage ordering while acquired buffers - * retire and hand off to replacement owners. Bound at App scope. + * retire and hand off to replacement owners. Session-scoped writes (journal + * bytes under `sessions//`) are fenced: `drain` and `rewrite` + * re-verify the session's registered `ISessionWriteAuthority` through + * `IWriteAuthorityRegistry` immediately before bytes hit storage, a session + * scope with no registered authority fails closed, and a fencing failure + * sticks the buffer like any ambiguous storage failure (the session teardown + * follows). The root scope and scopes outside the sessions tree carry no + * authority and pass untouched. Bound at App scope. */ import { InstantiationType } from '#/_base/di/extensions'; import { toDisposable, type IDisposable } from '#/_base/di/lifecycle'; import { LifecycleScope, registerScopedService } from '#/_base/di/scope'; +import { Error2, ErrorCodes } from '#/errors'; import { IFileSystemStorageService } from '#/persistence/interface/storage'; +import { + sessionIdFromScope, + IWriteAuthorityRegistry, +} from '#/persistence/interface/writeAuthority'; import { AppendLogCorruptedError, IAppendLogStore, @@ -45,7 +57,10 @@ export class AppendLogStore implements IAppendLogStore { private readonly logs = new Map(); - constructor(@IFileSystemStorageService private readonly storage: IFileSystemStorageService) {} + constructor( + @IFileSystemStorageService private readonly storage: IFileSystemStorageService, + @IWriteAuthorityRegistry private readonly authorityRegistry: IWriteAuthorityRegistry, + ) {} append(scope: string, key: string, record: R, options?: AppendLogOptions): void { const state = this.state(scope, key); @@ -109,6 +124,7 @@ export class AppendLogStore implements IAppendLogStore { ); const rewrite = priorSettled.then(async () => { try { + this.assertScopeWritable(scope); await this.storage.write(scope, key, encoded, { atomic: true }); state.storageFailure = undefined; } catch (error) { @@ -120,10 +136,15 @@ export class AppendLogStore implements IAppendLogStore { } async flush(): Promise { - const inFlight = [...this.logs.entries()].map(([id, state]) => { + const inFlight: Promise[] = []; + for (const [id, state] of this.logs) { const { scope, key } = fromLogId(id); - return this.flushState(scope, key, state); - }); + inFlight.push(this.flushState(scope, key, state)); + // A retired buffer's fire-and-forget final flush must settle before a + // global flush reports, or the close path can return with the session + // tail still in flight. Its errors stay swallowed per the release path. + if (state.retirement !== undefined) inFlight.push(state.retirement); + } const settled = await Promise.allSettled(inFlight); for (const result of settled) { if (result.status === 'rejected') throw result.reason; @@ -248,6 +269,7 @@ export class AppendLogStore implements IAppendLogStore { while (state.pending.length > 0) { const batch = state.pending.slice(); try { + this.assertScopeWritable(scope); await this.storage.append(scope, key, encodeBatch(batch), { durable: true }); } catch (error) { const failure = (state.storageFailure ??= { error }); @@ -257,6 +279,18 @@ export class AppendLogStore implements IAppendLogStore { state.pending.splice(0, batch.length); } } + + private assertScopeWritable(scope: string): void { + const sessionId = sessionIdFromScope(scope); + if (sessionId === undefined) return; + const authority = this.authorityRegistry.resolve(sessionId); + if (authority === undefined) { + throw new Error2(ErrorCodes.SESSION_LEASE_LOST, 'session has no registered write authority', { + details: { sessionId }, + }); + } + authority.assertWritable(); + } } function logId(scope: string, key: string): string { diff --git a/packages/agent-core-v2/src/persistence/backends/node-fs/fileStorageService.ts b/packages/agent-core-v2/src/persistence/backends/node-fs/fileStorageService.ts index 4bb02fcbf0..c1e1286d05 100644 --- a/packages/agent-core-v2/src/persistence/backends/node-fs/fileStorageService.ts +++ b/packages/agent-core-v2/src/persistence/backends/node-fs/fileStorageService.ts @@ -173,6 +173,11 @@ export class FileStorageService implements IFileSystemStorageService { ignoreInitial: true, awaitWriteFinish: false, depth: 0, + // chokidar attaches fs.watch to every scanned entry; special files + // (unix sockets, fifos, devices — e.g. an ipc `klient.sock` sharing + // the home root) make that call throw UNKNOWN. Skip them up front. + ignored: (_path, stats) => + stats !== undefined && !stats.isFile() && !stats.isDirectory() && !stats.isSymbolicLink(), }); watcher.on('all', (_event, changedPath) => { if (normalize(changedPath) === normalizedTarget) schedule(); diff --git a/packages/agent-core-v2/src/persistence/backends/node-fs/writeAuthorityRegistryService.ts b/packages/agent-core-v2/src/persistence/backends/node-fs/writeAuthorityRegistryService.ts new file mode 100644 index 0000000000..0942307d88 --- /dev/null +++ b/packages/agent-core-v2/src/persistence/backends/node-fs/writeAuthorityRegistryService.ts @@ -0,0 +1,52 @@ +/** + * `storage` domain (L1) — `IWriteAuthorityRegistry` implementation. + * + * A plain `sessionId → ISessionWriteAuthority` map with no storage or + * filesystem dependencies: the session lifecycle registers an authority when + * the session's lease is acquired and disposes the registration when the + * lease is released, and the `AppendLogStore` resolves authorities at drain / + * rewrite time. Double registration for the same session is a bug (two live + * writers for one session must never coexist), so it throws a + * `BugIndicatingError` instead of replacing. Bound at App scope. + */ + +import { InstantiationType } from '#/_base/di/extensions'; +import { toDisposable, type IDisposable } from '#/_base/di/lifecycle'; +import { LifecycleScope, registerScopedService } from '#/_base/di/scope'; +import { BugIndicatingError } from '#/_base/errors/errors'; +import { + type ISessionWriteAuthority, + IWriteAuthorityRegistry, +} from '#/persistence/interface/writeAuthority'; + +export class WriteAuthorityRegistryService implements IWriteAuthorityRegistry { + declare readonly _serviceBrand: undefined; + + private readonly authorities = new Map(); + + register(authority: ISessionWriteAuthority): IDisposable { + if (this.authorities.get(authority.sessionId) !== undefined) { + throw new BugIndicatingError( + `write authority already registered for session ${authority.sessionId}`, + ); + } + this.authorities.set(authority.sessionId, authority); + return toDisposable(() => { + if (this.authorities.get(authority.sessionId) === authority) { + this.authorities.delete(authority.sessionId); + } + }); + } + + resolve(sessionId: string): ISessionWriteAuthority | undefined { + return this.authorities.get(sessionId); + } +} + +registerScopedService( + LifecycleScope.App, + IWriteAuthorityRegistry, + WriteAuthorityRegistryService, + InstantiationType.Eager, + 'storage', +); diff --git a/packages/agent-core-v2/src/persistence/interface/writeAuthority.ts b/packages/agent-core-v2/src/persistence/interface/writeAuthority.ts new file mode 100644 index 0000000000..a546399953 --- /dev/null +++ b/packages/agent-core-v2/src/persistence/interface/writeAuthority.ts @@ -0,0 +1,50 @@ +/** + * `persistence/interface` — session-scoped write fencing contract. + * + * Defines `ISessionWriteAuthority`, the per-session lease proof that a Store + * write must re-verify immediately before its bytes hit storage (design: + * `.tmp/refactor-watch-design-v2.md` §3.4.2/§3.4.5 — the pre-commit lease + * re-read is the hard gate and must fail closed), and the App-scoped + * `IWriteAuthorityRegistry` the `AppendLogStore` resolves authorities through. + * The registry never creates semantics of its own: it only maps `sessionId` + * to the authority the session lifecycle registered, so a write for a + * session with no registered authority is a bypass attempt and must be + * rejected. `sessionIdFromScope` keeps the filesystem-layout knowledge + * (`sessions//[/agents/]`) in exactly one place; + * the root scope (`''`, e.g. `session_index.jsonl`) and any scope outside + * the sessions tree deliberately carry no authority and pass untouched. + * The concrete registry lives in + * `persistence/backends/node-fs/writeAuthorityRegistryService.ts`. + */ + +import { createDecorator, type ServiceIdentifier } from '#/_base/di/instantiation'; +import type { IDisposable } from '#/_base/di/lifecycle'; + +export interface ISessionWriteAuthority { + readonly sessionId: string; + /** Re-reads the lease payload and compares the held lock token. Throws + `Error2(session.lease_lost)` when this instance no longer holds the + lease; must be called immediately before any durable write. */ + assertWritable(): void; +} + +export const IWriteAuthorityRegistry: ServiceIdentifier = + createDecorator('writeAuthorityRegistry'); + +export interface IWriteAuthorityRegistry { + readonly _serviceBrand: undefined; + + /** Registers the session's authority. Throws when one is already + registered for the sessionId — double registration is a bug, never a + takeover. Dispose the returned handle to unregister. */ + register(authority: ISessionWriteAuthority): IDisposable; + resolve(sessionId: string): ISessionWriteAuthority | undefined; +} + +export function sessionIdFromScope(scope: string): string | undefined { + if (scope === '') return undefined; + const parts = scope.split('/'); + if (parts.length < 3 || parts[0] !== 'sessions') return undefined; + const sessionId = parts[2]; + return parts[1] === '' || sessionId === undefined || sessionId === '' ? undefined : sessionId; +} diff --git a/packages/agent-core-v2/src/session/agentLifecycle/agentLifecycleService.ts b/packages/agent-core-v2/src/session/agentLifecycle/agentLifecycleService.ts index 24cf4fdea4..4a7163acfa 100644 --- a/packages/agent-core-v2/src/session/agentLifecycle/agentLifecycleService.ts +++ b/packages/agent-core-v2/src/session/agentLifecycle/agentLifecycleService.ts @@ -61,7 +61,9 @@ import { IAgentMediaToolsRegistrar } from '#/agent/media/mediaTools'; import { IImageConfigBridge } from '#/agent/media/imageConfigBridge'; import { IAgentMcpService } from '#/agent/mcp/mcp'; import { IAgentExternalHooksService } from '#/agent/externalHooks/externalHooks'; +import { IAgentFileFencingService } from '#/agent/fileFencing/fileFencing'; import { IAgentPluginService } from '#/agent/plugin/agentPlugin'; +import { IAgentSkillListingReminderService } from '#/agent/profile/agentSkillListingReminder'; import { ISessionInteractionService } from '#/session/interaction/interaction'; import { IWireService } from '#/wire/wire'; import { @@ -211,10 +213,17 @@ export class AgentLifecycleService extends Disposable implements IAgentLifecycle handle.accessor.get(IImageConfigBridge); handle.accessor.get(IAgentToolDedupeService); handle.accessor.get(IAgentExternalHooksService); + // File fencing hooks Read/Write/Edit on the shared Session file ledger: + // resolve after externalHooks so permission/external hook participants + // register first on the executor's hook slots. + handle.accessor.get(IAgentFileFencingService); handle.accessor.get(IAgentMcpService); // Agent plugin service: registers main-agent-only plugin session-start // guidance before the first turn (self-gates to a no-op for other agents). handle.accessor.get(IAgentPluginService); + // Skill listing reminder: arms the turn-boundary notification for skill + // catalog hot reload before the first turn. + handle.accessor.get(IAgentSkillListingReminderService); // Tool-select services: precompute tool selection and the announcements // derived from it before the first turn. handle.accessor.get(IAgentToolSelectService); diff --git a/packages/agent-core-v2/src/session/errors.ts b/packages/agent-core-v2/src/session/errors.ts index 5413a89c54..d9fc6d255c 100644 --- a/packages/agent-core-v2/src/session/errors.ts +++ b/packages/agent-core-v2/src/session/errors.ts @@ -14,6 +14,8 @@ export const SessionErrors = { SESSION_FORK_ACTIVE_TURN: 'session.fork_active_turn', SESSION_UNDO_UNAVAILABLE: 'session.undo_unavailable', SESSION_INIT_FAILED: 'session.init_failed', + SESSION_HELD_BY_PEER: 'session.held_by_peer', + SESSION_LEASE_LOST: 'session.lease_lost', }, retryable: ['session.fork_active_turn'], } as const satisfies ErrorDomain; diff --git a/packages/agent-core-v2/src/session/sessionFileLedger/fileLedger.ts b/packages/agent-core-v2/src/session/sessionFileLedger/fileLedger.ts new file mode 100644 index 0000000000..975b79a433 --- /dev/null +++ b/packages/agent-core-v2/src/session/sessionFileLedger/fileLedger.ts @@ -0,0 +1,57 @@ +/** + * `sessionFileLedger` domain (L2) — per-session optimistic-concurrency ledger. + * + * Defines the `ISessionFileLedger` that remembers, per normalized absolute + * path, the on-disk stat tuple (`ino`, `mtimeMs`, `size`, existence) this + * session last successfully read or wrote, together with the + * `sessionFsWatch` tick captured at that moment. Before a Write/Edit, + * `compare` decides from the ledger entry plus live dirty signals whether + * the target was modified outside this session, punching a single stat only + * when a dirty signal is newer than the baseline (an unchanged tuple means + * the signal was the session's own write echo). Targets outside every + * watched root degrade to the same comparison without dirty signals. + * + * The verdict drives the write-path policy: `clean` lets the call through, + * `stale` means the file diverged since the baseline (or a path-level dirty + * signal hit with no baseline at all), and `no-baseline` means the target + * exists on disk but this session never read or wrote it (the read-first + * case). New-file creation is always `clean`. + * + * The ledger is in-memory only: never persisted, never journaled, never part + * of diagnostics. `resume` and `fork` therefore start from an empty ledger + * and conservatively require a fresh read before editing existing files. + * Baselines refresh only on successful Read/Write/Edit executions, and only + * full reads of the target file count. Keys are lexical + * `normalizeFsWatchKey`s — no `realpath`, so symlink aliases to the same + * inode are an accepted residual gap. Session-scoped. + */ + +import { createDecorator, type ServiceIdentifier } from '#/_base/di/instantiation'; + +export type FileStatTuple = + | { readonly exists: false } + | { + readonly exists: true; + readonly ino?: number; + readonly mtimeMs?: number; + readonly size: number; + }; + +export function fileStatTuplesEqual(a: FileStatTuple, b: FileStatTuple): boolean { + if (a.exists !== b.exists) return false; + if (!a.exists || !b.exists) return true; + return a.ino === b.ino && a.mtimeMs === b.mtimeMs && a.size === b.size; +} + +export type FileLedgerVerdict = 'clean' | 'stale' | 'no-baseline'; + +export interface ISessionFileLedger { + readonly _serviceBrand: undefined; + + recordBaseline(path: string): Promise; + + compare(path: string): Promise; +} + +export const ISessionFileLedger: ServiceIdentifier = + createDecorator('sessionFileLedger'); diff --git a/packages/agent-core-v2/src/session/sessionFileLedger/fileLedgerService.ts b/packages/agent-core-v2/src/session/sessionFileLedger/fileLedgerService.ts new file mode 100644 index 0000000000..f0b12137f7 --- /dev/null +++ b/packages/agent-core-v2/src/session/sessionFileLedger/fileLedgerService.ts @@ -0,0 +1,131 @@ +/** + * `sessionFileLedger` domain (L2) — `ISessionFileLedger` implementation. + * + * In-memory per-session ledger of on-disk stat tuples keyed by + * `normalizeFsWatchKey`. `recordBaseline` re-stats the target through + * `IHostFileSystem` and stores the tuple with the watch service's current + * tick; `compare` consults only already-folded `sessionFsWatch` dirty state + * (it never awaits watcher frames) and degrades gracefully: stat failures + * other than not-found yield `clean` rather than blocking the write path, + * and paths outside every watched root get a stat-only comparison. The + * service also guarantees `ISessionFsWatchService` watches the session roots + * (`workDir` + `additionalDirs` from `ISessionWorkspaceContext`), adding an + * unwatched containing root additively whenever a Write/Edit target falls + * under one; writes outside all session roots proceed unwatched. Bound at + * Session scope. + */ + +import { resolve } from 'node:path'; + +import { InstantiationType } from '#/_base/di/extensions'; +import { LifecycleScope, registerScopedService } from '#/_base/di/scope'; +import { unwrapErrorCause } from '#/_base/errors/errors'; +import { IHostFileSystem } from '#/os/interface/hostFileSystem'; +import { + ISessionFsWatchService, + isFsWatchKeyWithin, + normalizeFsWatchKey, +} from '#/session/sessionFs/fsWatch'; +import { ISessionWorkspaceContext } from '#/session/workspaceContext/workspaceContext'; + +import { + ISessionFileLedger, + fileStatTuplesEqual, + type FileLedgerVerdict, + type FileStatTuple, +} from './fileLedger'; + +type FileLedgerEntry = FileStatTuple & { readonly tick: number }; + +export class SessionFileLedger implements ISessionFileLedger { + declare readonly _serviceBrand: undefined; + + private readonly entries = new Map(); + + constructor( + @ISessionFsWatchService private readonly watch: ISessionFsWatchService, + @ISessionWorkspaceContext private readonly workspace: ISessionWorkspaceContext, + @IHostFileSystem private readonly hostFs: IHostFileSystem, + ) { + this.watch.ensureWatchedRoots(this.sessionRoots()); + } + + async recordBaseline(path: string): Promise { + this.ensureWatchedRootFor(path); + const tuple = await this.tryStat(path); + if (tuple === undefined) return; + this.entries.set(normalizeFsWatchKey(path), { ...tuple, tick: this.watch.currentTick }); + } + + async compare(path: string): Promise { + this.ensureWatchedRootFor(path); + const key = normalizeFsWatchKey(path); + const entry = this.entries.get(key); + const root = this.containingWatchedRoot(key); + if (root === undefined) { + const current = await this.tryStat(path); + if (current === undefined) return 'clean'; + if (entry === undefined) return current.exists ? 'no-baseline' : 'clean'; + return fileStatTuplesEqual(entry, current) ? 'clean' : 'stale'; + } + if (entry === undefined) { + if ((this.watch.dirtyTickFor(path) ?? 0) > 0) return 'stale'; + const current = await this.tryStat(path); + if (current === undefined) return 'clean'; + return current.exists ? 'no-baseline' : 'clean'; + } + const dirty = Math.max( + this.watch.dirtyTickFor(path) ?? 0, + this.watch.rootDirtyTickFor(root) ?? 0, + ); + if (dirty <= entry.tick) return 'clean'; + const current = await this.tryStat(path); + if (current === undefined) return 'clean'; + if (fileStatTuplesEqual(entry, current)) { + this.entries.set(key, { ...entry, tick: dirty }); + return 'clean'; + } + return 'stale'; + } + + private sessionRoots(): readonly string[] { + return [this.workspace.workDir, ...this.workspace.additionalDirs].map((dir) => resolve(dir)); + } + + private ensureWatchedRootFor(path: string): void { + const root = this.longestContaining(normalizeFsWatchKey(path), this.sessionRoots()); + if (root !== undefined) this.watch.ensureWatchedRoots([root]); + } + + private containingWatchedRoot(key: string): string | undefined { + return this.longestContaining(key, this.watch.watchedRoots); + } + + private longestContaining(key: string, roots: readonly string[]): string | undefined { + let best: string | undefined; + for (const root of roots) { + if (!isFsWatchKeyWithin(key, normalizeFsWatchKey(root))) continue; + if (best === undefined || root.length > best.length) best = root; + } + return best; + } + + private async tryStat(path: string): Promise { + try { + const stat = await this.hostFs.stat(path); + return { exists: true, ino: stat.ino, mtimeMs: stat.mtimeMs, size: stat.size }; + } catch (error) { + const code = (unwrapErrorCause(error) as { code?: unknown } | null)?.code; + if (code === 'ENOENT' || code === 'ENOTDIR') return { exists: false }; + return undefined; + } + } +} + +registerScopedService( + LifecycleScope.Session, + ISessionFileLedger, + SessionFileLedger, + InstantiationType.Eager, + 'sessionFileLedger', +); diff --git a/packages/agent-core-v2/src/session/sessionFs/fsService.ts b/packages/agent-core-v2/src/session/sessionFs/fsService.ts index cf70e6e998..f08926d812 100644 --- a/packages/agent-core-v2/src/session/sessionFs/fsService.ts +++ b/packages/agent-core-v2/src/session/sessionFs/fsService.ts @@ -94,10 +94,19 @@ const FS_BINARY_NONPRINTABLE_FRACTION = 0.3; const HIDDEN_NAME_RE = /^\./; const MACOS_NOISE = new Set(['.DS_Store', '.AppleDouble', '.LSOverride']); +interface GitignoreCacheEntry { + readonly ig: Ignore; + /** + * Fingerprint of the workspace `.gitignore` the matcher was built from: + * its `mtimeMs`, or `undefined` when the file was absent at load time. + */ + readonly mtimeMs: number | undefined; +} + export class SessionFsService implements ISessionFsService { declare readonly _serviceBrand: undefined; - private readonly gitignoreCache = new Map(); + private readonly gitignoreCache = new Map(); private rgResolution: RgResolution | null | undefined = undefined; constructor( @@ -661,16 +670,25 @@ export class SessionFsService implements ISessionFsService { private async matcher(): Promise { const cwd = this.workspace.workDir; + const ignorePath = join(cwd, '.gitignore'); + // One stat per lookup keeps the cache honest: a changed or removed + // `.gitignore` (mtime mismatch / stat failure) rebuilds the entry. + // (The rg grep path never consults this cache — rg applies ignore rules + // itself.) + const st = await this.hostFs.stat(ignorePath).catch(() => undefined); + const mtimeMs = st === undefined ? undefined : (st.mtimeMs ?? -1); const cached = this.gitignoreCache.get(cwd); - if (cached !== undefined) return cached; + if (cached !== undefined && cached.mtimeMs === mtimeMs) return cached.ig; const ig = ignore(); ig.add('.git/'); - try { - const contents = await this.hostFs.readText(join(this.workspace.workDir, '.gitignore')); - ig.add(contents); - } catch { + if (st !== undefined) { + try { + ig.add(await this.hostFs.readText(ignorePath)); + } catch { + // The file raced away between stat and read — keep the base rules. + } } - this.gitignoreCache.set(cwd, ig); + this.gitignoreCache.set(cwd, { ig, mtimeMs }); return ig; } diff --git a/packages/agent-core-v2/src/session/sessionFs/fsWatch.ts b/packages/agent-core-v2/src/session/sessionFs/fsWatch.ts index 6e44c47780..ac3acdfe44 100644 --- a/packages/agent-core-v2/src/session/sessionFs/fsWatch.ts +++ b/packages/agent-core-v2/src/session/sessionFs/fsWatch.ts @@ -7,8 +7,25 @@ * workspace-relative paths they care about; events outside that subtree are * dropped. Session-scoped — the scope itself is the session, so no * `sessionId` is threaded through. + * + * Beyond the change feed, every confined change entry is folded into a + * per-session dirty state for optimistic-concurrency consumers + * (`sessionFileLedger`): a monotonic `currentTick` incremented per processed + * entry, per-path dirty ticks folded when a debounce window flushes, and + * per-root dirty ticks for truncated windows whose exact paths were dropped. + * Client subscriptions (`setWatchedPaths`, replace semantics) and + * optimistic-concurrency watch anchors (`ensureWatchedRoots`, additive, + * absolute) are independent sets so neither side can clobber the other; + * `watchedRoots` reports the union as absolute paths. + * + * Also owns the lexical key helpers shared with the ledger so both sides key + * paths identically: `normalizeFsWatchKey` (lexical normalize only, no + * `realpath`; case-folded on macOS/Windows) and `isFsWatchKeyWithin` + * (separator-bounded prefix containment on normalized keys). */ +import { normalize, sep } from 'node:path'; + import { createDecorator, type ServiceIdentifier } from '#/_base/di/instantiation'; import type { Event } from '#/_base/event'; @@ -31,6 +48,19 @@ export interface FsChangeEvent { count?: number | undefined; } +const FS_WATCH_KEY_CASE_FOLD = process.platform === 'darwin' || process.platform === 'win32'; + +export function normalizeFsWatchKey(path: string): string { + const normalized = normalize(path).split(sep).join('/'); + return FS_WATCH_KEY_CASE_FOLD ? normalized.toLowerCase() : normalized; +} + +export function isFsWatchKeyWithin(key: string, rootKey: string): boolean { + if (key === rootKey) return true; + const prefix = rootKey.endsWith('/') ? rootKey : `${rootKey}/`; + return key.startsWith(prefix); +} + export interface ISessionFsWatchService { readonly _serviceBrand: undefined; @@ -38,6 +68,16 @@ export interface ISessionFsWatchService { readonly watchedPaths: readonly string[]; + ensureWatchedRoots(roots: readonly string[]): void; + + readonly watchedRoots: readonly string[]; + + readonly currentTick: number; + + dirtyTickFor(path: string): number | undefined; + + rootDirtyTickFor(root: string): number | undefined; + readonly onDidChangeFiles: Event; } diff --git a/packages/agent-core-v2/src/session/sessionFs/fsWatchService.ts b/packages/agent-core-v2/src/session/sessionFs/fsWatchService.ts index a440595bd3..65e11fc588 100644 --- a/packages/agent-core-v2/src/session/sessionFs/fsWatchService.ts +++ b/packages/agent-core-v2/src/session/sessionFs/fsWatchService.ts @@ -4,13 +4,29 @@ * Subscribes to the os `IHostFsWatchService` on the workspace root, confines * events to the caller-declared subtree and to non-`.gitignore`d paths, * debounces them into fixed windows and re-exposes them as workspace-relative - * `FsChangeEvent`s. The os watcher is started lazily on the first non-empty - * subscription and stopped when the subscription set becomes empty. Path - * confinement is lexical (`ISessionWorkspaceContext.isWithin`), matching - * `sessionFs`. + * `FsChangeEvent`s. Path confinement is lexical (`ISessionWorkspaceContext.isWithin`), + * matching `sessionFs`. + * + * Two independent watch sets feed confinement: client subscriptions + * (`setWatchedPaths`, workspace-relative, replace semantics) and ensured + * roots (`ensureWatchedRoots`, absolute, additive — used by the + * optimistic-concurrency ledger). An ensured root inside the workDir is + * covered by the workDir watcher; one outside gets its own os handle whose + * events skip the workDir `.gitignore` filter and are never re-emitted as + * `FsChangeEvent`s (protocol paths are workspace-relative) — they only fold + * into dirty state. The os workDir watcher runs while either set is + * non-empty. + * + * Dirty state: every confined change entry gets the next monotonic tick and + * is buffered with it; at flush the entries fold into + * `dirtyTicks[normalizedAbs]`, and a truncated window (exact paths dropped) + * conservatively folds `dirtyRootTicks` for every watched root at the + * window's last tick. Clearing a window without flushing still folds the + * buffered entries so in-flight dirty signals are never silently dropped. + * Key normalization is the shared `normalizeFsWatchKey` from the contract. */ -import { isAbsolute, join, relative, sep } from 'node:path'; +import { isAbsolute, join, relative, resolve, sep } from 'node:path'; import ignore, { type Ignore } from 'ignore'; @@ -26,13 +42,21 @@ import { IHostFsWatchService, } from '#/os/interface/hostFsWatch'; import { ISessionWorkspaceContext } from '#/session/workspaceContext/workspaceContext'; -import type { FsChangeEntry, FsChangeEvent } from './fsWatch'; +import type { FsChangeEvent } from './fsWatch'; -import { ISessionFsWatchService } from './fsWatch'; +import { ISessionFsWatchService, isFsWatchKeyWithin, normalizeFsWatchKey } from './fsWatch'; const DEFAULT_DEBOUNCE_MS = 200; const DEFAULT_MAX_CHANGES_PER_WINDOW = 500; +interface PendingChange { + readonly abs: string; + readonly rel: string | undefined; + readonly tick: number; + readonly change: HostFsChange['action']; + readonly kind: HostFsChange['kind']; +} + function readPositiveIntEnv(name: string, fallback: number): number { const raw = process.env[name]; if (raw === undefined || raw === '') return fallback; @@ -47,14 +71,23 @@ export class SessionFsWatchService extends Disposable implements ISessionFsWatch readonly onDidChangeFiles: Event = this.emitter.event; private watched = new Set(); + private readonly ensured = new Map(); private handle: IHostFsWatchHandle | undefined; private handleSub: IDisposable | undefined; + private readonly extraHandles = new Map< + string, + { readonly handle: IHostFsWatchHandle; readonly sub: IDisposable } + >(); private debounceTimer: NodeJS.Timeout | undefined; - private pending: FsChangeEntry[] = []; + private pending: PendingChange[] = []; private rawCount = 0; private truncated = false; + private tick = 0; + private readonly dirtyTicks = new Map(); + private readonly dirtyRootTicks = new Map(); + private readonly debounceMs = readPositiveIntEnv( 'KIMI_CODE_FS_WATCH_DEBOUNCE_MS', DEFAULT_DEBOUNCE_MS, @@ -64,7 +97,11 @@ export class SessionFsWatchService extends Disposable implements ISessionFsWatch DEFAULT_MAX_CHANGES_PER_WINDOW, ); - private readonly matcher: Ignore = ignore().add('.git/'); + // The matcher is the entire filter state — a rules change swaps it in + // whole, nothing else is kept. Rules load asynchronously: until the + // initial load settles, filtering conservatively uses the base rules + // (only `.git/`), i.e. unknown-but-maybe-ignored paths still pass through. + private matcher: Ignore = ignore().add('.git/'); private gitignoreLoaded = false; constructor( @@ -79,6 +116,28 @@ export class SessionFsWatchService extends Disposable implements ISessionFsWatch return Array.from(this.watched); } + get watchedRoots(): readonly string[] { + const out = new Map(); + for (const rel of this.watched) { + const abs = this.workspace.resolve(rel); + out.set(normalizeFsWatchKey(abs), abs); + } + for (const [key, abs] of this.ensured) out.set(key, abs); + return Array.from(out.values()); + } + + get currentTick(): number { + return this.tick; + } + + dirtyTickFor(path: string): number | undefined { + return this.dirtyTicks.get(normalizeFsWatchKey(path)); + } + + rootDirtyTickFor(root: string): number | undefined { + return this.dirtyRootTicks.get(normalizeFsWatchKey(root)); + } + setWatchedPaths(paths: readonly string[]): void { const next = new Set(); for (const p of paths) { @@ -86,7 +145,7 @@ export class SessionFsWatchService extends Disposable implements ISessionFsWatch next.add(this.toRel(abs)); } this.watched = next; - if (next.size === 0) { + if (next.size === 0 && this.ensured.size === 0) { this.teardownHandle(); this.clearWindow(); return; @@ -94,8 +153,39 @@ export class SessionFsWatchService extends Disposable implements ISessionFsWatch this.ensureHandle(); } + ensureWatchedRoots(roots: readonly string[]): void { + let changed = false; + for (const root of roots) { + const abs = resolve(root); + if (this.isCovered(abs)) continue; + const key = normalizeFsWatchKey(abs); + this.ensured.set(key, abs); + changed = true; + if (!isFsWatchKeyWithin(key, normalizeFsWatchKey(this.workspace.workDir))) { + this.startExtraHandle(key, abs); + } + } + if (changed || this.watched.size > 0) this.ensureHandle(); + } + + private isCovered(abs: string): boolean { + const key = normalizeFsWatchKey(abs); + for (const root of this.watchedRoots) { + if (isFsWatchKeyWithin(key, normalizeFsWatchKey(root))) return true; + } + return false; + } + + private startExtraHandle(key: string, abs: string): void { + if (this.extraHandles.has(key)) return; + const handle = this.hostFsWatch.watch(abs, { recursive: true }); + const sub = handle.onDidChange((e) => this.onRawExtra(key, e)); + this.extraHandles.set(key, { handle, sub }); + } + private ensureHandle(): void { if (this.handle !== undefined) return; + if (this.watched.size === 0 && this.ensured.size === 0) return; this.loadGitignore(); const handle = this.hostFsWatch.watch(this.workspace.workDir, { recursive: true }); this.handle = handle; @@ -112,24 +202,52 @@ export class SessionFsWatchService extends Disposable implements ISessionFsWatch private loadGitignore(): void { if (this.gitignoreLoaded) return; this.gitignoreLoaded = true; - void this.hostFs - .readText(join(this.workspace.workDir, '.gitignore')) - .then( - (content) => { - this.matcher.add(content); - }, - () => undefined, - ); + void this.reloadGitignore(); + } + + private async reloadGitignore(): Promise { + const next = ignore().add('.git/'); + try { + next.add(await this.hostFs.readText(join(this.workspace.workDir, '.gitignore'))); + } catch { + // Missing or unreadable `.gitignore` — fall back to the base rules. + } + this.matcher = next; } private onRaw(e: HostFsChange): void { const rel = this.toRel(e.path); if (rel === '.') return; + // A change to the workspace-root `.gitignore` invalidates the filter + // rules: rebuild the matcher asynchronously (the recursive watcher already + // delivers this event, so no extra watcher is needed). Events arriving + // while the rebuild is in flight are filtered by the previous matcher. + if (rel === '.gitignore' && e.kind !== 'directory') { + void this.reloadGitignore(); + } const probe = e.kind === 'directory' ? `${rel}/` : rel; if (this.matcher.ignores(probe)) return; - if (!isUnderAny(rel, this.watched)) return; + if (isUnderAny(rel, this.watched)) { + this.record(e, rel); + return; + } + const key = normalizeFsWatchKey(e.path); + for (const rootKey of this.ensured.keys()) { + if (isFsWatchKeyWithin(key, rootKey)) { + this.record(e, rel); + return; + } + } + } - this.pending.push({ path: rel, change: e.action, kind: e.kind }); + private onRawExtra(rootKey: string, e: HostFsChange): void { + if (!isFsWatchKeyWithin(normalizeFsWatchKey(e.path), rootKey)) return; + this.record(e, undefined); + } + + private record(e: HostFsChange, rel: string | undefined): void { + this.tick += 1; + this.pending.push({ abs: e.path, rel, tick: this.tick, change: e.action, kind: e.kind }); this.rawCount += 1; if (this.pending.length > this.maxChangesPerWindow) { this.truncated = true; @@ -147,17 +265,38 @@ export class SessionFsWatchService extends Disposable implements ISessionFsWatch if (this.rawCount === 0) return; const truncated = this.truncated; const count = this.rawCount; - const changes = truncated ? [] : this.pending; + const pending = this.pending; this.pending = []; this.rawCount = 0; this.truncated = false; - const event: FsChangeEvent = { - changes, - coalesced_window_ms: this.debounceMs, - ...(truncated ? { truncated: true, count } : {}), - }; - this.emitter.fire(event); + if (truncated) { + for (const root of this.watchedRoots) { + this.dirtyRootTicks.set(normalizeFsWatchKey(root), this.tick); + } + } else { + for (const entry of pending) { + this.dirtyTicks.set(normalizeFsWatchKey(entry.abs), entry.tick); + } + } + + const changes = truncated + ? [] + : pending + .filter((entry) => entry.rel !== undefined) + .map((entry) => ({ + path: entry.rel!, + change: entry.change, + kind: entry.kind, + })); + if (truncated || changes.length > 0) { + const event: FsChangeEvent = { + changes, + coalesced_window_ms: this.debounceMs, + ...(truncated ? { truncated: true, count } : {}), + }; + this.emitter.fire(event); + } } private clearWindow(): void { @@ -165,6 +304,9 @@ export class SessionFsWatchService extends Disposable implements ISessionFsWatch clearTimeout(this.debounceTimer); this.debounceTimer = undefined; } + for (const entry of this.pending) { + this.dirtyTicks.set(normalizeFsWatchKey(entry.abs), entry.tick); + } this.pending = []; this.rawCount = 0; this.truncated = false; @@ -173,6 +315,11 @@ export class SessionFsWatchService extends Disposable implements ISessionFsWatch override dispose(): void { this.clearWindow(); this.teardownHandle(); + for (const { handle, sub } of this.extraHandles.values()) { + sub.dispose(); + handle.dispose(); + } + this.extraHandles.clear(); super.dispose(); } diff --git a/packages/agent-core-v2/src/session/sessionLease/sessionLease.ts b/packages/agent-core-v2/src/session/sessionLease/sessionLease.ts new file mode 100644 index 0000000000..e5fd6baeb0 --- /dev/null +++ b/packages/agent-core-v2/src/session/sessionLease/sessionLease.ts @@ -0,0 +1,145 @@ +/** + * `sessionLease` domain (L6) — the per-session write lease. + * + * Defines `ISessionLeaseService`, the Session-scope seeded capability that + * state writers use to re-verify they still own the session's durable state, + * and the `SessionLease` object that satisfies it: an App-owned wrapper + * (`SessionLifecycleService` builds it; it is deliberately not a DI service) + * around the cross-process lock handle at + * `/session-leases/.json`. `assertWritable` is the + * Quint-verified hard gate (design: `.tmp/refactor-watch-design-v2.md` + * §3.4.2/§3.4.5): it synchronously re-reads the on-disk lease payload and + * compares the held `lockId` — a mismatch fails closed with + * `session.lease_lost`, marks the lease lost, and fires the loss callback + * exactly once so the owning session tears itself down. Release order is the + * lifecycle's business; `release()` only forwards to the token-guarded lock + * release (a foreign payload is never unlinked) and is idempotent. + * + * No default is registered for `ISessionLeaseService`: every production + * session scope is seeded by `sessionLifecycle` via {@link sessionLeaseSeed}; + * resolving it unseeded (a session that bypassed materialization) is a bug + * and must fail loudly rather than silently disable the fencing gate. + */ + +import { join } from 'pathe'; + +import { createDecorator, type ServiceIdentifier } from '#/_base/di/instantiation'; +import type { ScopeSeed } from '#/_base/di/scope'; +import { Error2, ErrorCodes } from '#/errors'; +import type { ICrossProcessLockHandle } from '#/os/interface/crossProcessLock'; +import type { ISessionWriteAuthority } from '#/persistence/interface/writeAuthority'; + +export const SESSION_LEASE_HEARTBEAT_INTERVAL_MS = 2000; +export const SESSION_LEASE_TTL_MS = 6000; +export const LEASE_CREATING_RETRY_AFTER_MS = 1000; +export const HOLDER_UNRESPONSIVE_RETRY_AFTER_MS = 2000; +export const UNREGISTERED_WRITER_WINDOW_MS = 5000; +export const UNREGISTERED_WRITER_RECHECK_DELAY_MS = 1000; + +/** `details` payload of `session.held_by_peer` errors; the zod twin lives in + packages/protocol (`sessionOwnershipDetailsSchema`) and the shapes must + stay byte-identical. Declared as `type` (not `interface`) so the payload + stays assignable to `Error2Options.details`. */ +export type SessionOwnershipPhase = + | 'creating' + | 'routable' + | 'holder-unresponsive' + | 'held-by-local-instance'; + +export type HeldByPeerDetails = { + readonly kind: 'held-by-peer'; + readonly phase: SessionOwnershipPhase; + readonly address?: string; + readonly retry_after_ms?: number; +}; + +export type SessionOwnershipDetails = HeldByPeerDetails | { readonly kind: 'unregistered-writer' }; + +export interface ISessionLeaseInfo { + readonly sessionId: string; + readonly lockId: string; +} + +export interface ISessionLeaseService { + readonly _serviceBrand: undefined; + + /** The held lease identity; `undefined` once the lease is released. */ + readonly info: ISessionLeaseInfo | undefined; + /** Hard gate, synchronously re-reads the lease payload (see the file + header). Throws `Error2(session.lease_lost)` when this instance no + longer holds the lease — including after `release()`. */ + assertWritable(): void; +} + +export const ISessionLeaseService: ServiceIdentifier = + createDecorator('sessionLeaseService'); + +export class SessionLease implements ISessionWriteAuthority, ISessionLeaseService { + declare readonly _serviceBrand: undefined; + + readonly lockId: string; + private _released = false; + private _lost = false; + private _lossFired = false; + + constructor( + readonly sessionId: string, + private readonly handle: ICrossProcessLockHandle, + private readonly onLeaseLost: (sessionId: string) => void, + ) { + this.lockId = handle.lockId; + } + + get released(): boolean { + return this._released; + } + + get info(): ISessionLeaseInfo | undefined { + return this._released ? undefined : { sessionId: this.sessionId, lockId: this.lockId }; + } + + checkHeld(): boolean { + return !this._released && this.handle.checkHeld(); + } + + assertWritable(): void { + if (this._released) { + throw new Error2( + ErrorCodes.SESSION_LEASE_LOST, + `session ${this.sessionId} write lease was released`, + { details: { sessionId: this.sessionId } }, + ); + } + if (this._lost || !this.handle.checkHeld()) { + this.markLost(); + throw new Error2( + ErrorCodes.SESSION_LEASE_LOST, + `session ${this.sessionId} no longer holds its write lease`, + { details: { sessionId: this.sessionId } }, + ); + } + } + + /** Forwards the lock handle's heartbeat loss detection into the lease's + own once-only loss path; also driven directly by `assertWritable`. */ + markLost(): void { + this._lost = true; + if (this._lossFired) return; + this._lossFired = true; + this.onLeaseLost(this.sessionId); + } + + release(): void { + if (this._released) return; + this._released = true; + this.handle.release(); + } +} + +export function sessionLeasePath(homeDir: string, sessionId: string): string { + return join(homeDir, 'session-leases', `${sessionId}.json`); +} + +export function sessionLeaseSeed(lease: SessionLease): ScopeSeed { + return [[ISessionLeaseService as ServiceIdentifier, lease]]; +} diff --git a/packages/agent-core-v2/src/session/sessionLease/sessionLeaseContactProvider.ts b/packages/agent-core-v2/src/session/sessionLease/sessionLeaseContactProvider.ts new file mode 100644 index 0000000000..1523cc79bc --- /dev/null +++ b/packages/agent-core-v2/src/session/sessionLease/sessionLeaseContactProvider.ts @@ -0,0 +1,54 @@ +/** + * `sessionLease` domain (L6) — host-provided lease contact address. + * + * Holds what a session lease payload should advertise for this instance: + * `{type: 'address', address}` when the host runs a routable network service + * (kap-server seeds its listening address), `{type: 'local'}` otherwise — + * the value is recorded in the lease payload so a blocked peer can tell a + * routable holder from a local-only one (design: + * `.tmp/refactor-watch-design-v2.md` §3.4.1 — the discriminated + * `contact` union landing as the flat `address?` payload field). Read + * lazily at every lease acquisition through the `contact` thunk, so a host + * whose address is only known after listen can seed a provider that closes + * over it. Composition roots override the local-only default through + * {@link sessionLeaseContactSeed}; embedded engines pass nothing and keep + * the default. Bound at App scope. + */ + +import { createDecorator, type ServiceIdentifier } from '#/_base/di/instantiation'; +import { InstantiationType } from '#/_base/di/extensions'; +import { LifecycleScope, registerScopedService, type ScopeSeed } from '#/_base/di/scope'; + +export type SessionLeaseContact = { type: 'address'; address: string } | { type: 'local' }; + +export interface ISessionLeaseContactProvider { + readonly _serviceBrand: undefined; + + readonly contact: () => SessionLeaseContact; +} + +export const ISessionLeaseContactProvider: ServiceIdentifier = + createDecorator('sessionLeaseContactProvider'); + +export class SessionLeaseContactProvider implements ISessionLeaseContactProvider { + declare readonly _serviceBrand: undefined; + + constructor(readonly contact: () => SessionLeaseContact = () => ({ type: 'local' })) {} +} + +export function sessionLeaseContactSeed(contact: () => SessionLeaseContact): ScopeSeed { + return [ + [ + ISessionLeaseContactProvider as ServiceIdentifier, + new SessionLeaseContactProvider(contact), + ], + ]; +} + +registerScopedService( + LifecycleScope.App, + ISessionLeaseContactProvider, + SessionLeaseContactProvider, + InstantiationType.Eager, + 'sessionLease', +); diff --git a/packages/agent-core-v2/src/session/sessionMetadata/sessionMetadataService.ts b/packages/agent-core-v2/src/session/sessionMetadata/sessionMetadataService.ts index 239abe87b8..4d234fe9d4 100644 --- a/packages/agent-core-v2/src/session/sessionMetadata/sessionMetadataService.ts +++ b/packages/agent-core-v2/src/session/sessionMetadata/sessionMetadataService.ts @@ -13,7 +13,11 @@ * released v1 builds. Re-registering an agent whose metadata is unchanged is * a no-op (no write, no mirror, no event), so resuming a session — which * re-registers its agents as they materialize — never bumps `updatedAt` and - * never reorders session listings. Bound at Session scope. + * never reorders session listings. Every durable write passes the + * `sessionLease` hard gate first (`ISessionLeaseService.assertWritable`, + * synchronously re-reading the lease payload), so an instance that lost the + * session lease fails closed instead of overwriting a live peer's state + * (design: `.tmp/refactor-watch-design-v2.md` §3.4.5). Bound at Session scope. * * Read-model mirroring (flag `persistence_minidb_readmodel`): after a metadata * update is persisted, the fresh summary is mirrored into the `IQueryStore` @@ -33,6 +37,7 @@ import { IFlagService } from '#/app/flag/flag'; import { IAtomicDocumentStore } from '#/persistence/interface/atomicDocumentStore'; import { IQueryStore } from '#/persistence/interface/queryStore'; import { ISessionContext } from '#/session/sessionContext/sessionContext'; +import { ISessionLeaseService } from '#/session/sessionLease/sessionLease'; import { ISessionMetadata, @@ -65,6 +70,7 @@ export class SessionMetadata extends Disposable implements ISessionMetadata { @ILogService private readonly log: ILogService, @IQueryStore private readonly queryStore: IQueryStore, @IFlagService private readonly flags: IFlagService, + @ISessionLeaseService private readonly lease: ISessionLeaseService, ) { super(); this.scope = ctx.metaScope; @@ -84,6 +90,7 @@ export class SessionMetadata extends Disposable implements ISessionMetadata { private async applyUpdate(patch: SessionMetaPatch): Promise { await this.ready; this.data = { ...this.data, ...patch, updatedAt: Date.now() }; + this.lease.assertWritable(); await this.store.set(this.scope, META_KEY, this.data); await this.mirrorToReadModel(); this._onDidChangeMetadata.fire({ @@ -147,6 +154,7 @@ export class SessionMetadata extends Disposable implements ISessionMetadata { agents: this.data.agents ?? {}, custom: this.data.custom ?? {}, }; + this.lease.assertWritable(); await this.store.set(this.scope, META_KEY, this.data); } return; @@ -162,6 +170,7 @@ export class SessionMetadata extends Disposable implements ISessionMetadata { agents: {}, custom: {}, }; + this.lease.assertWritable(); await this.store.set(this.scope, META_KEY, this.data); this.log.debug('session metadata created', { sessionId: this.ctx.sessionId }); } diff --git a/packages/agent-core-v2/src/session/sessionSkillCatalog/explicitFileSkillSource.ts b/packages/agent-core-v2/src/session/sessionSkillCatalog/explicitFileSkillSource.ts index 2fea07d89f..486aaff827 100644 --- a/packages/agent-core-v2/src/session/sessionSkillCatalog/explicitFileSkillSource.ts +++ b/packages/agent-core-v2/src/session/sessionSkillCatalog/explicitFileSkillSource.ts @@ -3,19 +3,25 @@ * * Mirrors v1 SDK `skillDirs`: when runtime options provide `explicitDirs`, this * source contributes those directories as the user source, resolving relative - * paths against the session project root. When no explicit dirs are configured, - * it yields nothing so default user / project discovery remains active. Bound at - * Session scope so each session resolves paths against its own workDir. + * paths against the session project root, and hot-reloads on filesystem + * changes in them (watched through `hostFsWatch` via `SkillRootWatcher`). When + * no explicit dirs are configured, it yields nothing so default user / project + * discovery remains active. Bound at Session scope so each session resolves + * paths against its own workDir. */ import { createDecorator, type ServiceIdentifier } from '#/_base/di/instantiation'; import { InstantiationType } from '#/_base/di/extensions'; +import { Disposable } from '#/_base/di/lifecycle'; +import { Emitter, type Event } from '#/_base/event'; import { LifecycleScope, registerScopedService } from '#/_base/di/scope'; import { IBootstrapService } from '#/app/bootstrap/bootstrap'; -import { configuredRoots } from '#/app/skillCatalog/skillRoots'; +import { configuredRootCandidates, configuredRoots } from '#/app/skillCatalog/skillRoots'; import { ISkillCatalogRuntimeOptions } from '#/app/skillCatalog/skillCatalogRuntimeOptions'; import { ISkillDiscovery } from '#/app/skillCatalog/skillDiscovery'; +import { SkillRootWatcher } from '#/app/skillCatalog/skillRootWatcher'; import { SKILL_SOURCE_PRIORITY, type ISkillSource, type SkillContribution } from '#/app/skillCatalog/skillSource'; +import { IHostFsWatchService } from '#/os/interface/hostFsWatch'; import { ISessionWorkspaceContext } from '#/session/workspaceContext/workspaceContext'; export interface IExplicitFileSkillSource extends ISkillSource { @@ -25,18 +31,34 @@ export interface IExplicitFileSkillSource extends ISkillSource { export const IExplicitFileSkillSource: ServiceIdentifier = createDecorator('explicitFileSkillSource'); -export class ExplicitFileSkillSource implements IExplicitFileSkillSource { +export class ExplicitFileSkillSource extends Disposable implements IExplicitFileSkillSource { declare readonly _serviceBrand: undefined; readonly id = 'explicit'; readonly priority = SKILL_SOURCE_PRIORITY.user; + private readonly onDidChangeEmitter = this._register(new Emitter()); + readonly onDidChange: Event = this.onDidChangeEmitter.event; constructor( @ISkillDiscovery private readonly discovery: ISkillDiscovery, @ISkillCatalogRuntimeOptions private readonly runtimeOptions: ISkillCatalogRuntimeOptions, @ISessionWorkspaceContext private readonly workspace: ISessionWorkspaceContext, @IBootstrapService private readonly bootstrap: IBootstrapService, - ) {} + @IHostFsWatchService hostFsWatch: IHostFsWatchService, + ) { + super(); + const explicitDirs = this.runtimeOptions.explicitDirs ?? []; + if (explicitDirs.length > 0) { + this._register( + new SkillRootWatcher( + hostFsWatch, + async () => + configuredRootCandidates(explicitDirs, this.workspace.workDir, this.bootstrap.osHomeDir), + () => this.onDidChangeEmitter.fire(), + ), + ); + } + } async load(): Promise { const explicitDirs = this.runtimeOptions.explicitDirs ?? []; diff --git a/packages/agent-core-v2/src/session/sessionSkillCatalog/extraFileSkillSource.ts b/packages/agent-core-v2/src/session/sessionSkillCatalog/extraFileSkillSource.ts index 176c3492be..1094f8f0dc 100644 --- a/packages/agent-core-v2/src/session/sessionSkillCatalog/extraFileSkillSource.ts +++ b/packages/agent-core-v2/src/session/sessionSkillCatalog/extraFileSkillSource.ts @@ -4,8 +4,11 @@ * Discovers user-configured extra skill directories (`extraSkillDirs`) through * `ISkillDiscovery`, contributing them at priority 10 (above plugin / builtin, * below user / workspace). Relative paths resolve against the session project - * root; `~` and `~/...` resolve against the bootstrap home dir. Bound at Session - * scope so each session reads its own workspace root. + * root; `~` and `~/...` resolve against the bootstrap home dir. Hot-reloads on + * both its config section (re-resolving the watched directories) and + * filesystem changes in the configured roots (watched through `hostFsWatch` + * via `SkillRootWatcher`). Bound at Session scope so each session reads its + * own workspace root. */ import { createDecorator, type ServiceIdentifier } from '#/_base/di/instantiation'; @@ -19,9 +22,11 @@ import { EXTRA_SKILL_DIRS_SECTION, type ExtraSkillDirsConfig, } from '#/app/skillCatalog/configSection'; -import { configuredRoots } from '#/app/skillCatalog/skillRoots'; +import { configuredRootCandidates, configuredRoots } from '#/app/skillCatalog/skillRoots'; import { ISkillDiscovery } from '#/app/skillCatalog/skillDiscovery'; +import { SkillRootWatcher } from '#/app/skillCatalog/skillRootWatcher'; import { SKILL_SOURCE_PRIORITY, type ISkillSource, type SkillContribution } from '#/app/skillCatalog/skillSource'; +import { IHostFsWatchService } from '#/os/interface/hostFsWatch'; import { ISessionWorkspaceContext } from '#/session/workspaceContext/workspaceContext'; export interface IExtraFileSkillSource extends ISkillSource { @@ -38,19 +43,31 @@ export class ExtraFileSkillSource extends Disposable implements IExtraFileSkillS readonly priority = SKILL_SOURCE_PRIORITY.extra; private readonly onDidChangeEmitter = this._register(new Emitter()); readonly onDidChange: Event = this.onDidChangeEmitter.event; + private readonly watcher: SkillRootWatcher; constructor( @ISkillDiscovery private readonly discovery: ISkillDiscovery, @IConfigService private readonly config: IConfigService, @ISessionWorkspaceContext private readonly workspace: ISessionWorkspaceContext, @IBootstrapService private readonly bootstrap: IBootstrapService, + @IHostFsWatchService hostFsWatch: IHostFsWatchService, ) { super(); this._register( this.config.onDidSectionChange((event) => { - if (event.domain === EXTRA_SKILL_DIRS_SECTION) this.onDidChangeEmitter.fire(); + if (event.domain === EXTRA_SKILL_DIRS_SECTION) { + this.onDidChangeEmitter.fire(); + void this.watcher.refresh(); + } }), ); + this.watcher = this._register( + new SkillRootWatcher( + hostFsWatch, + () => this.watchCandidates(), + () => this.onDidChangeEmitter.fire(), + ), + ); } async load(): Promise { @@ -60,6 +77,12 @@ export class ExtraFileSkillSource extends Disposable implements IExtraFileSkillS await configuredRoots(extraSkillDirs, this.workspace.workDir, this.bootstrap.osHomeDir, 'extra'), ); } + + private async watchCandidates(): Promise { + await this.config.ready; + const extraSkillDirs = this.config.get(EXTRA_SKILL_DIRS_SECTION) ?? []; + return configuredRootCandidates(extraSkillDirs, this.workspace.workDir, this.bootstrap.osHomeDir); + } } registerScopedService( diff --git a/packages/agent-core-v2/src/session/sessionSkillCatalog/workspaceFileSkillSource.ts b/packages/agent-core-v2/src/session/sessionSkillCatalog/workspaceFileSkillSource.ts index 18fbc99f37..324a54bbc6 100644 --- a/packages/agent-core-v2/src/session/sessionSkillCatalog/workspaceFileSkillSource.ts +++ b/packages/agent-core-v2/src/session/sessionSkillCatalog/workspaceFileSkillSource.ts @@ -3,8 +3,10 @@ * * Discovers project skills from the session's current `workDir` * (`workspaceContext`) through `ISkillDiscovery`, contributing them at priority - * 30 (above user / extra / plugin / builtin). Bound at Session scope so each session reads - * its own workspace root. + * 30 (above user / extra / plugin / builtin). Hot-reloads on both its config + * section and filesystem changes in the project skill roots (watched through + * `hostFsWatch` via `SkillRootWatcher`). Bound at Session scope so each + * session reads its own workspace root. */ import { createDecorator, type ServiceIdentifier } from '#/_base/di/instantiation'; @@ -19,8 +21,10 @@ import { } from '#/app/skillCatalog/configSection'; import { ISkillCatalogRuntimeOptions } from '#/app/skillCatalog/skillCatalogRuntimeOptions'; import { ISkillDiscovery } from '#/app/skillCatalog/skillDiscovery'; -import { projectRoots } from '#/app/skillCatalog/skillRoots'; +import { SkillRootWatcher } from '#/app/skillCatalog/skillRootWatcher'; +import { projectRootCandidates, projectRoots } from '#/app/skillCatalog/skillRoots'; import { SKILL_SOURCE_PRIORITY, type ISkillSource, type SkillContribution } from '#/app/skillCatalog/skillSource'; +import { IHostFsWatchService } from '#/os/interface/hostFsWatch'; import { ISessionWorkspaceContext } from '#/session/workspaceContext/workspaceContext'; export interface IWorkspaceFileSkillSource extends ISkillSource { @@ -43,6 +47,7 @@ export class WorkspaceFileSkillSource extends Disposable implements IWorkspaceFi @ISessionWorkspaceContext private readonly workspace: ISessionWorkspaceContext, @IConfigService private readonly config: IConfigService, @ISkillCatalogRuntimeOptions private readonly runtimeOptions: ISkillCatalogRuntimeOptions, + @IHostFsWatchService hostFsWatch: IHostFsWatchService, ) { super(); this._register( @@ -50,6 +55,15 @@ export class WorkspaceFileSkillSource extends Disposable implements IWorkspaceFi if (event.domain === MERGE_ALL_AVAILABLE_SKILLS_SECTION) this.onDidChangeEmitter.fire(); }), ); + if ((this.runtimeOptions.explicitDirs?.length ?? 0) === 0) { + this._register( + new SkillRootWatcher( + hostFsWatch, + async () => projectRootCandidates(this.workspace.workDir), + () => this.onDidChangeEmitter.fire(), + ), + ); + } } async load(): Promise { diff --git a/packages/agent-core-v2/test/agent/contextMemory/splice-replay.test.ts b/packages/agent-core-v2/test/agent/contextMemory/splice-replay.test.ts index 676c58c5b7..e1aa9aaaa9 100644 --- a/packages/agent-core-v2/test/agent/contextMemory/splice-replay.test.ts +++ b/packages/agent-core-v2/test/agent/contextMemory/splice-replay.test.ts @@ -29,6 +29,8 @@ import { IEventBus } from '#/app/event/eventBus'; import { EventBusService } from '#/app/event/eventBusService'; import type { ContentPart } from '#/app/llmProtocol/message'; import { AppendLogStore } from '#/persistence/backends/node-fs/appendLogStore'; +import { WriteAuthorityRegistryService } from '#/persistence/backends/node-fs/writeAuthorityRegistryService'; +import { IWriteAuthorityRegistry } from '#/persistence/interface/writeAuthority'; import { InMemoryStorageService } from '#/persistence/backends/memory/inMemoryStorageService'; import { IAppendLogStore } from '#/persistence/interface/appendLogStore'; import { IFileSystemStorageService } from '#/persistence/interface/storage'; @@ -153,6 +155,7 @@ function buildHost(key: string): Host { const ix = disposables.add(new TestInstantiationService()); ix.stub(IFileSystemStorageService, new InMemoryStorageService()); ix.set(IAppendLogStore, new SyncDescriptor(AppendLogStore)); + ix.stub(IWriteAuthorityRegistry, new WriteAuthorityRegistryService()); ix.stub(IAgentBlobService, blob); ix.set(IEventBus, new SyncDescriptor(EventBusService)); ix.set(IAgentContextMemoryService, new SyncDescriptor(AgentContextMemoryService)); diff --git a/packages/agent-core-v2/test/agent/fileFencing/fileFencing.test.ts b/packages/agent-core-v2/test/agent/fileFencing/fileFencing.test.ts new file mode 100644 index 0000000000..c0e061e146 --- /dev/null +++ b/packages/agent-core-v2/test/agent/fileFencing/fileFencing.test.ts @@ -0,0 +1,540 @@ +/** + * `fileFencing` domain (L4) — verifies the write/read-first gate end to end + * through the real DI scope tree: a real tmpdir, the real `HostFileSystem`, + * the real watch service folding fake os-watcher events, and the registered + * `writeFencing` hook participant over a real `OrderedHookSlot`. Covers both + * flag postures (`multi_server` on = hard block, off = advisory note), the + * own-write echo / truncated-window stale checks, out-of-root stat fallback, + * watched-root ensuring for additional dirs, and ledger isolation between + * two Session scopes sharing one workspace (two-instance conflict). + */ + +import { mkdtempSync, rmSync, writeFileSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; + +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; + +import { LifecycleScope, type Scope } from '#/_base/di/scope'; +import { createScopedTestHost, stubPair, type ScopedTestHost } from '#/_base/di/test'; +import { IAgentFileFencingService } from '#/agent/fileFencing/fileFencing'; +import { AgentFileFencingService } from '#/agent/fileFencing/fileFencingService'; +import { IAgentToolExecutorService } from '#/agent/toolExecutor/toolExecutor'; +import type { + ToolBeforeExecuteContext, + ToolDidExecuteContext, +} from '#/agent/toolExecutor/toolHooks'; +import { IFlagService } from '#/app/flag/flag'; +import type { ToolCall } from '#/app/llmProtocol/message'; +import { HostFileSystem } from '#/os/backends/node-local/hostFsService'; +import { IHostFileSystem } from '#/os/interface/hostFileSystem'; +import { IHostFsWatchService } from '#/os/interface/hostFsWatch'; +import { ISessionContext, makeSessionContext } from '#/session/sessionContext/sessionContext'; +import { ISessionFileLedger } from '#/session/sessionFileLedger/fileLedger'; +import { SessionFileLedger } from '#/session/sessionFileLedger/fileLedgerService'; +import { ISessionFsWatchService } from '#/session/sessionFs/fsWatch'; +import { SessionFsWatchService } from '#/session/sessionFs/fsWatchService'; +import { ISessionWorkspaceContext } from '#/session/workspaceContext/workspaceContext'; +import { SessionWorkspaceContextService } from '#/session/workspaceContext/workspaceContextService'; +import { ToolAccesses, type ExecutableToolResult } from '#/tool/toolContract'; + +import { AgentToolExecutorService } from '#/agent/toolExecutor/toolExecutorService'; + +import { stubToolExecutor } from '../loop/stubs'; +import { fakeHostFsWatch, type FakeWatch } from '../../session/sessionFs/stubs'; + +void AgentFileFencingService; +void SessionFileLedger; +void SessionFsWatchService; +void SessionWorkspaceContextService; +void AgentToolExecutorService; + +let multiServer = false; + +function countingHostFs(): { fs: IHostFileSystem; statCalls: () => number } { + const real = new HostFileSystem(); + let count = 0; + const fs = new Proxy(real, { + get(target, prop, receiver) { + if (prop === 'stat') { + return async (path: string) => { + count += 1; + return target.stat(path); + }; + } + return Reflect.get(target, prop, receiver); + }, + }) as IHostFileSystem; + return { fs, statCalls: () => count }; +} + +interface Env { + readonly host: ScopedTestHost; + readonly fake: FakeWatch; + readonly workDir: string; + readonly outsideDir: string; + readonly statCalls: () => number; +} + +function makeEnv(): Env { + const workDir = mkdtempSync(join(tmpdir(), 'kimi-fencing-work-')); + const outsideDir = mkdtempSync(join(tmpdir(), 'kimi-fencing-out-')); + cleanupPaths.push(workDir, outsideDir); + const fake = fakeHostFsWatch(); + const { fs, statCalls } = countingHostFs(); + const host = createScopedTestHost([ + stubPair(IHostFileSystem, fs), + stubPair(IHostFsWatchService, fake.service), + stubPair(IFlagService, { + _serviceBrand: undefined, + enabled: () => multiServer, + } as unknown as IFlagService), + ]); + hosts.push(host); + return { host, fake, workDir, outsideDir, statCalls }; +} + +function makeSession(env: Env, sessionId: string, cwd: string): Scope { + return env.host.child(LifecycleScope.Session, sessionId, [ + stubPair( + ISessionContext, + makeSessionContext({ + sessionId, + workspaceId: 'ws', + sessionDir: join(cwd, '.session'), + sessionScope: `sessions/ws/${sessionId}`, + cwd, + }), + ), + ]); +} + +interface AgentWorld { + readonly env: Env; + readonly session: Scope; + readonly agent: Scope; + readonly executor: IAgentToolExecutorService; + readonly watch: ISessionFsWatchService; + readonly workspace: ISessionWorkspaceContext; +} + +function makeAgent(env: Env, session: Scope): AgentWorld { + const executor = stubToolExecutor(); + const agent = env.host.childOf(session, LifecycleScope.Agent, 'main', [ + stubPair(IAgentToolExecutorService, executor), + ]); + agent.accessor.get(IAgentFileFencingService); + session.accessor.get(ISessionFileLedger); + return { + env, + session, + agent, + executor, + watch: session.accessor.get(ISessionFsWatchService), + workspace: session.accessor.get(ISessionWorkspaceContext), + }; +} + +function setup(): AgentWorld { + const env = makeEnv(); + return makeAgent(env, makeSession(env, 's1', env.workDir)); +} + +let nextCallSeq = 0; + +function beforeCtx( + toolName: string, + path: string, + opts: { id?: string; turnId?: number; args?: Record } = {}, +): ToolBeforeExecuteContext { + const id = opts.id ?? `call-${++nextCallSeq}`; + const args = + opts.args ?? + (toolName === 'Edit' + ? { path, old_string: 'a', new_string: 'b' } + : toolName === 'Write' + ? { path, content: 'x' } + : { path }); + const toolCall: ToolCall = { + type: 'function', + id, + name: toolName, + arguments: JSON.stringify(args), + }; + const operation = toolName === 'Read' ? 'read' : toolName === 'Write' ? 'write' : 'readwrite'; + return { + turnId: opts.turnId ?? 1, + signal: new AbortController().signal, + toolCall, + toolCalls: [toolCall], + args, + execution: { + accesses: ToolAccesses.file(operation, path), + approvalRule: toolName, + execute: async () => ({ output: 'ok' }), + }, + }; +} + +async function runBefore( + world: AgentWorld, + ctx: ToolBeforeExecuteContext, +): Promise { + await world.executor.hooks.onBeforeExecuteTool.run(ctx); + return ctx; +} + +async function runDid( + world: AgentWorld, + ctx: ToolBeforeExecuteContext, + result?: ExecutableToolResult, +): Promise { + const did: ToolDidExecuteContext = { + turnId: ctx.turnId, + signal: ctx.signal, + toolCall: ctx.toolCall, + toolCalls: [ctx.toolCall], + args: ctx.args, + result: result ?? { output: 'done' }, + }; + await world.executor.hooks.onDidExecuteTool.run(did); + return did; +} + +async function runOk( + world: AgentWorld, + toolName: string, + path: string, + opts: { id?: string; turnId?: number; args?: Record } = {}, +): Promise { + const ctx = await runBefore(world, beforeCtx(toolName, path, opts)); + expect(ctx.decision?.block).not.toBe(true); + return runDid(world, ctx); +} + +function foldChange(world: AgentWorld, rel: string, action: 'created' | 'modified' | 'deleted'): void { + world.env.fake.fire(rel, action); + vi.advanceTimersByTime(200); +} + +function foldJunk(env: Env, count = 501): void { + for (let i = 0; i < count; i++) env.fake.fire(`junk-${i}.tmp`, 'created'); + vi.advanceTimersByTime(200); +} + +const hosts: ScopedTestHost[] = []; +const cleanupPaths: string[] = []; + +describe('AgentFileFencingService', () => { + beforeEach(() => { + multiServer = false; + vi.useFakeTimers(); + }); + afterEach(() => { + for (const host of hosts.splice(0)) host.dispose(); + for (const path of cleanupPaths.splice(0)) rmSync(path, { recursive: true, force: true }); + vi.useRealTimers(); + }); + + describe('with the multi_server flag on', () => { + it('blocks Edit on an existing file that was never read, read-first', async () => { + multiServer = true; + const world = setup(); + const file = join(world.env.workDir, 'a.txt'); + writeFileSync(file, 'hello'); + + const ctx = await runBefore(world, beforeCtx('Edit', file)); + expect(ctx.decision?.block).toBe(true); + expect(ctx.decision?.reason).toContain('has not been read in this session'); + expect(ctx.decision?.reason).toContain('Read it first'); + }); + + it('blocks Edit when the file changed on disk since the last read, and unblocks after re-read', async () => { + multiServer = true; + const world = setup(); + const file = join(world.env.workDir, 'a.txt'); + writeFileSync(file, 'hello'); + await runOk(world, 'Read', file); + + writeFileSync(file, 'hello world'); + foldChange(world, 'a.txt', 'modified'); + + const ctx = await runBefore(world, beforeCtx('Edit', file)); + expect(ctx.decision?.block).toBe(true); + expect(ctx.decision?.reason).toContain('changed on disk since'); + + await runOk(world, 'Read', file); + const retry = await runBefore(world, beforeCtx('Edit', file)); + expect(retry.decision?.block).not.toBe(true); + }); + + it('blocks Write over an existing file that was never read', async () => { + multiServer = true; + const world = setup(); + const file = join(world.env.workDir, 'a.txt'); + writeFileSync(file, 'hello'); + + const ctx = await runBefore(world, beforeCtx('Write', file)); + expect(ctx.decision?.block).toBe(true); + expect(ctx.decision?.reason).toContain('already exists'); + expect(ctx.decision?.reason).toContain('has not been read in this session'); + }); + + it('allows Write creating a new file and baselines it', async () => { + multiServer = true; + const world = setup(); + const file = join(world.env.workDir, 'new.txt'); + + await runOk(world, 'Write', file); + const ctx = await runBefore(world, beforeCtx('Edit', file)); + expect(ctx.decision?.block).not.toBe(true); + }); + + it('allows Edit right after a full Read', async () => { + multiServer = true; + const world = setup(); + const file = join(world.env.workDir, 'a.txt'); + writeFileSync(file, 'hello'); + + await runOk(world, 'Read', file); + const ctx = await runBefore(world, beforeCtx('Edit', file)); + expect(ctx.decision?.block).not.toBe(true); + }); + + it('allows consecutive Edits without watcher events', async () => { + multiServer = true; + const world = setup(); + const file = join(world.env.workDir, 'a.txt'); + writeFileSync(file, 'hello'); + + await runOk(world, 'Read', file); + await runOk(world, 'Edit', file); + const ctx = await runBefore(world, beforeCtx('Edit', file)); + expect(ctx.decision?.block).not.toBe(true); + }); + + it('keeps consecutive Edits clean through the own-write watcher echo and re-baselines', async () => { + multiServer = true; + const world = setup(); + const file = join(world.env.workDir, 'a.txt'); + writeFileSync(file, 'hello'); + await runOk(world, 'Read', file); + expect(world.env.statCalls()).toBe(1); + + foldChange(world, 'a.txt', 'modified'); + + const ctx = await runBefore(world, beforeCtx('Edit', file)); + expect(ctx.decision?.block).not.toBe(true); + expect(world.env.statCalls()).toBe(2); + + const again = await runBefore(world, beforeCtx('Edit', file)); + expect(again.decision?.block).not.toBe(true); + expect(world.env.statCalls()).toBe(2); + }); + + it('resolves a truncated window by stat punch: unchanged passes, changed blocks', async () => { + multiServer = true; + const world = setup(); + const file = join(world.env.workDir, 'a.txt'); + writeFileSync(file, 'hello'); + await runOk(world, 'Read', file); + + foldJunk(world.env); + + const unchanged = await runBefore(world, beforeCtx('Edit', file)); + expect(unchanged.decision?.block).not.toBe(true); + + writeFileSync(file, 'hello world'); + foldJunk(world.env); + + const changed = await runBefore(world, beforeCtx('Edit', file)); + expect(changed.decision?.block).toBe(true); + expect(changed.decision?.reason).toContain('changed on disk since'); + }); + + it('blocks ranged-Read followed by Edit because ranged reads never baseline', async () => { + multiServer = true; + const world = setup(); + const file = join(world.env.workDir, 'a.txt'); + writeFileSync(file, '1\n2\n3\n4\n5\n6\n'); + + await runOk(world, 'Read', file, { args: { path: file, line_offset: 5 } }); + const ctx = await runBefore(world, beforeCtx('Edit', file)); + expect(ctx.decision?.block).toBe(true); + expect(ctx.decision?.reason).toContain('has not been read in this session'); + }); + + it('blocks out-of-root writes through the stat-only fallback and allows them after Read', async () => { + multiServer = true; + const world = setup(); + const file = join(world.env.outsideDir, 'b.txt'); + writeFileSync(file, 'hello'); + + const first = await runBefore(world, beforeCtx('Edit', file)); + expect(first.decision?.block).toBe(true); + expect(first.decision?.reason).toContain('has not been read in this session'); + + await runOk(world, 'Read', file); + const afterRead = await runBefore(world, beforeCtx('Edit', file)); + expect(afterRead.decision?.block).not.toBe(true); + + writeFileSync(file, 'hello world'); + const changed = await runBefore(world, beforeCtx('Edit', file)); + expect(changed.decision?.block).toBe(true); + expect(changed.decision?.reason).toContain('changed on disk since'); + }); + + it('ensures an additional dir becomes watched when a write target falls under it', async () => { + multiServer = true; + const world = setup(); + world.workspace.addAdditionalDir(world.env.outsideDir); + const file = join(world.env.outsideDir, 'new.txt'); + + await runOk(world, 'Write', file); + expect(world.watch.watchedRoots).toContain(world.env.outsideDir); + expect(world.env.fake.watchCalls).toContain(world.env.outsideDir); + + writeFileSync(file, 'changed outside'); + world.env.fake.handles + .find((h) => h.root === world.env.outsideDir) + ?.fire('new.txt', 'modified'); + vi.advanceTimersByTime(200); + + const ctx = await runBefore(world, beforeCtx('Write', file)); + expect(ctx.decision?.block).toBe(true); + expect(ctx.decision?.reason).toContain('changed on disk since'); + }); + + it('keeps ledgers on two session scopes sharing one workspace independent and flags the peer change', async () => { + multiServer = true; + const env = makeEnv(); + const worldA = makeAgent(env, makeSession(env, 'sA', env.workDir)); + const worldB = makeAgent(env, makeSession(env, 'sB', env.workDir)); + const file = join(env.workDir, 'a.txt'); + writeFileSync(file, 'hello'); + + await runOk(worldA, 'Read', file); + + const neverRead = await runBefore(worldB, beforeCtx('Edit', file)); + expect(neverRead.decision?.block).toBe(true); + expect(neverRead.decision?.reason).toContain('has not been read in this session'); + + writeFileSync(file, 'hello world'); + env.fake.handles + .findLast((h) => h.root === env.workDir) + ?.fire('a.txt', 'modified'); + vi.advanceTimersByTime(200); + + const conflict = await runBefore(worldB, beforeCtx('Edit', file)); + expect(conflict.decision?.block).toBe(true); + expect(conflict.decision?.reason).toContain('changed on disk since'); + }); + }); + + describe('with the multi_server flag off', () => { + it('admits Edit on an unread existing file with an advisory, then re-baselines', async () => { + const world = setup(); + const file = join(world.env.workDir, 'a.txt'); + writeFileSync(file, 'hello'); + + const did = await runOk(world, 'Edit', file); + expect(did.result.note).toContain('Warning:'); + expect(did.result.note).toContain('had not been read in this session'); + + const second = await runOk(world, 'Edit', file); + expect(second.result.note).toBeUndefined(); + }); + + it('admits Write over an unread existing file with an advisory', async () => { + const world = setup(); + const file = join(world.env.workDir, 'a.txt'); + writeFileSync(file, 'hello'); + + const did = await runOk(world, 'Write', file); + expect(did.result.note).toContain('already existed on disk'); + }); + + it('admits Edit after an outside change with the applied-anyway advisory', async () => { + const world = setup(); + const file = join(world.env.workDir, 'a.txt'); + writeFileSync(file, 'hello'); + await runOk(world, 'Read', file); + + writeFileSync(file, 'hello world'); + foldChange(world, 'a.txt', 'modified'); + + const did = await runOk(world, 'Edit', file); + expect(did.result.note).toContain('changed on disk since it was last read in this session'); + expect(did.result.note).toContain('your change was applied anyway'); + + const second = await runOk(world, 'Edit', file); + expect(second.result.note).toBeUndefined(); + }); + + it('composes the advisory with an existing result note', async () => { + const world = setup(); + const file = join(world.env.workDir, 'a.txt'); + writeFileSync(file, 'hello'); + + const ctx = await runBefore(world, beforeCtx('Edit', file)); + const did = await runDid(world, ctx, { output: 'done', note: 'existing' }); + expect(did.result.note).toBe( + 'existing\n' + + `Warning: "${file}" already existed on disk and had not been read in this session; ` + + 'your change overwrote it anyway. Read the file to verify the current content.', + ); + }); + + it('never advisories direct creation of a new file', async () => { + const world = setup(); + const did = await runOk(world, 'Write', join(world.env.workDir, 'new.txt')); + expect(did.result.note).toBeUndefined(); + }); + + it('adds no advisory and no baseline when the tool result is an error', async () => { + const world = setup(); + const file = join(world.env.workDir, 'a.txt'); + writeFileSync(file, 'hello'); + await runOk(world, 'Read', file); + + writeFileSync(file, 'hello world'); + foldChange(world, 'a.txt', 'modified'); + + const failed = await runBefore(world, beforeCtx('Edit', file)); + const failedDid = await runDid(world, failed, { output: 'boom', isError: true }); + expect(failedDid.result.note).toBeUndefined(); + + const retry = await runOk(world, 'Edit', file); + expect(retry.result.note).toContain('changed on disk since it was last read in this session'); + }); + + it('does not leak a stale mark across turns', async () => { + const world = setup(); + const file = join(world.env.workDir, 'a.txt'); + writeFileSync(file, 'hello'); + + await runBefore(world, beforeCtx('Edit', file, { id: 'call-abandoned', turnId: 1 })); + await runBefore( + world, + beforeCtx('Edit', join(world.env.workDir, 'other.txt'), { turnId: 2 }), + ); + + const abandonedCtx = beforeCtx('Edit', file, { id: 'call-abandoned', turnId: 1 }); + const did = await runDid(world, abandonedCtx); + expect(did.result.note).toBeUndefined(); + }); + + it('ignores tools other than Read/Write/Edit entirely', async () => { + const world = setup(); + const ctx = await runBefore( + world, + beforeCtx('Bash', join(world.env.workDir, 'a.txt'), { args: { command: 'ls' } }), + ); + expect(ctx.decision).toBeUndefined(); + + const did = await runDid(world, ctx); + expect(did.result.note).toBeUndefined(); + expect(world.env.statCalls()).toBe(0); + }); + }); +}); diff --git a/packages/agent-core-v2/test/agent/fullCompaction/compactionOps.test.ts b/packages/agent-core-v2/test/agent/fullCompaction/compactionOps.test.ts index 167d5d656e..a9eef2e56c 100644 --- a/packages/agent-core-v2/test/agent/fullCompaction/compactionOps.test.ts +++ b/packages/agent-core-v2/test/agent/fullCompaction/compactionOps.test.ts @@ -12,6 +12,8 @@ import { fullCompactionComplete, } from '#/agent/fullCompaction/compactionOps'; import { AppendLogStore } from '#/persistence/backends/node-fs/appendLogStore'; +import { WriteAuthorityRegistryService } from '#/persistence/backends/node-fs/writeAuthorityRegistryService'; +import { IWriteAuthorityRegistry } from '#/persistence/interface/writeAuthority'; import { InMemoryStorageService } from '#/persistence/backends/memory/inMemoryStorageService'; import { IAppendLogStore } from '#/persistence/interface/appendLogStore'; import { IFileSystemStorageService } from '#/persistence/interface/storage'; @@ -31,6 +33,7 @@ function buildHost(key: string): { wire: IWireService; log: IAppendLogStore; eve const ix = disposables.add(new TestInstantiationService()); ix.stub(IFileSystemStorageService, new InMemoryStorageService()); ix.set(IAppendLogStore, new SyncDescriptor(AppendLogStore)); + ix.stub(IWriteAuthorityRegistry, new WriteAuthorityRegistryService()); ix.set(IEventBus, new SyncDescriptor(EventBusService)); const wire = registerTestAgentWire(ix, testWireScope(SCOPE, key), { log: ix.get(IAppendLogStore), diff --git a/packages/agent-core-v2/test/agent/goal/goalOps.test.ts b/packages/agent-core-v2/test/agent/goal/goalOps.test.ts index f5f6192ea9..1ba42f3bbd 100644 --- a/packages/agent-core-v2/test/agent/goal/goalOps.test.ts +++ b/packages/agent-core-v2/test/agent/goal/goalOps.test.ts @@ -28,6 +28,8 @@ import { IAgentToolExecutorService } from '#/agent/toolExecutor/toolExecutor'; import { IAgentUsageService } from '#/agent/usage/usage'; import { ITelemetryService } from '#/app/telemetry/telemetry'; import { AppendLogStore } from '#/persistence/backends/node-fs/appendLogStore'; +import { WriteAuthorityRegistryService } from '#/persistence/backends/node-fs/writeAuthorityRegistryService'; +import { IWriteAuthorityRegistry } from '#/persistence/interface/writeAuthority'; import { InMemoryStorageService } from '#/persistence/backends/memory/inMemoryStorageService'; import { IAppendLogStore } from '#/persistence/interface/appendLogStore'; import { IFileSystemStorageService } from '#/persistence/interface/storage'; @@ -113,6 +115,7 @@ function buildHost(key: string): { const ix = disposables.add(new TestInstantiationService()); ix.stub(IFileSystemStorageService, new InMemoryStorageService()); ix.set(IAppendLogStore, new SyncDescriptor(AppendLogStore)); + ix.stub(IWriteAuthorityRegistry, new WriteAuthorityRegistryService()); ix.set(IEventBus, new SyncDescriptor(EventBusService)); ix.stub(IAgentLoopService, createLoopStub()); ix.stub(IAgentUsageService, { diff --git a/packages/agent-core-v2/test/agent/permissionMode/permissionMode.test.ts b/packages/agent-core-v2/test/agent/permissionMode/permissionMode.test.ts index 05fbd52d94..d77fd513e1 100644 --- a/packages/agent-core-v2/test/agent/permissionMode/permissionMode.test.ts +++ b/packages/agent-core-v2/test/agent/permissionMode/permissionMode.test.ts @@ -13,6 +13,8 @@ import { AgentPermissionModeService } from '#/agent/permissionMode/permissionMod import { PermissionModeModel } from '#/agent/permissionMode/permissionModeOps'; import type { PermissionMode } from '#/agent/permissionPolicy/types'; import { AppendLogStore } from '#/persistence/backends/node-fs/appendLogStore'; +import { WriteAuthorityRegistryService } from '#/persistence/backends/node-fs/writeAuthorityRegistryService'; +import { IWriteAuthorityRegistry } from '#/persistence/interface/writeAuthority'; import { InMemoryStorageService } from '#/persistence/backends/memory/inMemoryStorageService'; import { IAppendLogStore } from '#/persistence/interface/appendLogStore'; import { IFileSystemStorageService } from '#/persistence/interface/storage'; @@ -57,6 +59,7 @@ beforeEach(() => { ix = disposables.add(new TestInstantiationService()); ix.stub(IFileSystemStorageService, new InMemoryStorageService()); ix.set(IAppendLogStore, new SyncDescriptor(AppendLogStore)); + ix.stub(IWriteAuthorityRegistry, new WriteAuthorityRegistryService()); ix.stub(IAgentContextInjectorService, injectorStub); ix.set(IAgentPermissionModeService, new SyncDescriptor(AgentPermissionModeService)); log = ix.get(IAppendLogStore); @@ -199,6 +202,7 @@ describe('AgentPermissionModeService (wire-backed)', () => { const ix2 = disposables.add(new TestInstantiationService()); ix2.stub(IFileSystemStorageService, new InMemoryStorageService()); ix2.set(IAppendLogStore, new SyncDescriptor(AppendLogStore)); + ix2.stub(IWriteAuthorityRegistry, new WriteAuthorityRegistryService()); const log2 = ix2.get(IAppendLogStore); const fresh = registerTestAgentWire(ix2, testWireScope(SCOPE, 'permission-mode-replay'), { log: log2, diff --git a/packages/agent-core-v2/test/agent/permissionRules/permissionRules.test.ts b/packages/agent-core-v2/test/agent/permissionRules/permissionRules.test.ts index 517edaa7c7..c5c7e266ff 100644 --- a/packages/agent-core-v2/test/agent/permissionRules/permissionRules.test.ts +++ b/packages/agent-core-v2/test/agent/permissionRules/permissionRules.test.ts @@ -7,6 +7,8 @@ import { IAgentPermissionRulesService, type PermissionApprovalResultRecord, type import { AgentPermissionRulesService } from '#/agent/permissionRules/permissionRulesService'; import { PermissionRulesModel } from '#/agent/permissionRules/permissionRulesOps'; import { AppendLogStore } from '#/persistence/backends/node-fs/appendLogStore'; +import { WriteAuthorityRegistryService } from '#/persistence/backends/node-fs/writeAuthorityRegistryService'; +import { IWriteAuthorityRegistry } from '#/persistence/interface/writeAuthority'; import { InMemoryStorageService } from '#/persistence/backends/memory/inMemoryStorageService'; import { IAppendLogStore } from '#/persistence/interface/appendLogStore'; import { IFileSystemStorageService } from '#/persistence/interface/storage'; @@ -42,6 +44,7 @@ beforeEach(() => { ix = disposables.add(new TestInstantiationService()); ix.stub(IFileSystemStorageService, new InMemoryStorageService()); ix.set(IAppendLogStore, new SyncDescriptor(AppendLogStore)); + ix.stub(IWriteAuthorityRegistry, new WriteAuthorityRegistryService()); ix.set(IAgentPermissionRulesService, new SyncDescriptor(AgentPermissionRulesService)); log = ix.get(IAppendLogStore); registerTestAgentWire(ix, testWireScope(SCOPE, KEY), { log }); @@ -122,6 +125,7 @@ describe('AgentPermissionRulesService (wire-backed)', () => { const ix2 = disposables.add(new TestInstantiationService()); ix2.stub(IFileSystemStorageService, new InMemoryStorageService()); ix2.set(IAppendLogStore, new SyncDescriptor(AppendLogStore)); + ix2.stub(IWriteAuthorityRegistry, new WriteAuthorityRegistryService()); const log2 = ix2.get(IAppendLogStore); const fresh = registerTestAgentWire(ix2, testWireScope(SCOPE, 'permission-rules-replay'), { log: log2, diff --git a/packages/agent-core-v2/test/agent/plan/planOps.test.ts b/packages/agent-core-v2/test/agent/plan/planOps.test.ts index 3c6e1c6109..b9c336e05f 100644 --- a/packages/agent-core-v2/test/agent/plan/planOps.test.ts +++ b/packages/agent-core-v2/test/agent/plan/planOps.test.ts @@ -12,6 +12,8 @@ import { planModeExit, } from '#/agent/plan/planOps'; import { AppendLogStore } from '#/persistence/backends/node-fs/appendLogStore'; +import { WriteAuthorityRegistryService } from '#/persistence/backends/node-fs/writeAuthorityRegistryService'; +import { IWriteAuthorityRegistry } from '#/persistence/interface/writeAuthority'; import { InMemoryStorageService } from '#/persistence/backends/memory/inMemoryStorageService'; import { IAppendLogStore } from '#/persistence/interface/appendLogStore'; import { IFileSystemStorageService } from '#/persistence/interface/storage'; @@ -31,6 +33,7 @@ function buildHost(key: string): { wire: IWireService; log: IAppendLogStore; eve const ix = disposables.add(new TestInstantiationService()); ix.stub(IFileSystemStorageService, new InMemoryStorageService()); ix.set(IAppendLogStore, new SyncDescriptor(AppendLogStore)); + ix.stub(IWriteAuthorityRegistry, new WriteAuthorityRegistryService()); ix.set(IEventBus, new SyncDescriptor(EventBusService)); const wire = registerTestAgentWire(ix, testWireScope(SCOPE, key), { log: ix.get(IAppendLogStore), diff --git a/packages/agent-core-v2/test/agent/profile/agentSkillListingReminder.test.ts b/packages/agent-core-v2/test/agent/profile/agentSkillListingReminder.test.ts new file mode 100644 index 0000000000..0cbde4a5de --- /dev/null +++ b/packages/agent-core-v2/test/agent/profile/agentSkillListingReminder.test.ts @@ -0,0 +1,165 @@ +/** + * `profile` domain (L4) — `AgentSkillListingReminderService` unit tests. + * + * Asserts the turn-boundary injection semantics of the skill-catalog change + * reminder: exactly one reminder per catalog change burst, only at new turns, + * carrying the change note, the DISREGARD line, and the refreshed listing. + * Run: `pnpm --filter @moonshot-ai/agent-core-v2 exec vitest run + * test/agent/profile/agentSkillListingReminder.test.ts`. + */ + +import { afterEach, beforeEach, describe, expect, it } from 'vitest'; + +import { DisposableStore, toDisposable } from '#/_base/di/lifecycle'; +import { createServices, type TestInstantiationService } from '#/_base/di/test'; +import { Emitter } from '#/_base/event'; +import { + IAgentContextInjectorService, + type ContextInjectionProvider, +} from '#/agent/contextInjector/contextInjector'; +import { AgentSkillListingReminderService } from '#/agent/profile/agentSkillListingReminderService'; +import { + IAgentSkillListingReminderService, + SKILL_CATALOG_CHANGED_INJECTION_VARIANT, +} from '#/agent/profile/agentSkillListingReminder'; +import { InMemorySkillCatalog } from '#/app/skillCatalog/registry'; +import type { SkillDefinition } from '#/app/skillCatalog/types'; +import { ISessionSkillCatalog } from '#/session/sessionSkillCatalog/skillCatalog'; + +import { stubSkill } from '../../app/skillCatalog/stubs'; + +function skillCatalogStub(skills: readonly SkillDefinition[]): { + readonly stub: ISessionSkillCatalog; + readonly emitter: Emitter; +} { + const catalog = new InMemorySkillCatalog(); + for (const skill of skills) catalog.register(skill, { replace: true }); + const emitter = new Emitter(); + return { + emitter, + stub: { + _serviceBrand: undefined, + catalog, + ready: Promise.resolve(), + onDidChange: emitter.event, + load: async () => {}, + reload: async () => {}, + }, + }; +} + +function injectorStub(): { + readonly stub: IAgentContextInjectorService; + readonly providers: Map; +} { + const providers = new Map(); + return { + providers, + stub: { + _serviceBrand: undefined, + register: (name: string, provider: ContextInjectionProvider) => { + providers.set(name, provider); + return toDisposable(() => { + if (providers.get(name) === provider) providers.delete(name); + }); + }, + injectAfterCompaction: async () => {}, + }, + }; +} + +function isNewTurn(): { injectedPositions: number[]; lastInjectedAt: null; isNewTurn: true } { + return { injectedPositions: [], lastInjectedAt: null, isNewTurn: true }; +} + +function notNewTurn(): { injectedPositions: number[]; lastInjectedAt: null; isNewTurn: false } { + return { injectedPositions: [], lastInjectedAt: null, isNewTurn: false }; +} + +describe('AgentSkillListingReminderService', () => { + let disposables: DisposableStore; + let ix: TestInstantiationService; + let catalog: ReturnType; + let injector: ReturnType; + + beforeEach(() => { + disposables = new DisposableStore(); + catalog = skillCatalogStub([stubSkill('hot-skill')]); + injector = injectorStub(); + ix = createServices(disposables, { + additionalServices: (reg) => { + reg.defineInstance(ISessionSkillCatalog, catalog.stub); + reg.defineInstance(IAgentContextInjectorService, injector.stub); + reg.define(IAgentSkillListingReminderService, AgentSkillListingReminderService); + }, + }); + }); + + afterEach(() => { + disposables.dispose(); + }); + + function provider(): ContextInjectionProvider { + const registered = injector.providers.get(SKILL_CATALOG_CHANGED_INJECTION_VARIANT); + expect(registered).toBeDefined(); + return registered!; + } + + it('registers the skill_catalog_changed provider into the agent injector', () => { + ix.get(IAgentSkillListingReminderService); + expect(injector.providers.has(SKILL_CATALOG_CHANGED_INJECTION_VARIANT)).toBe(true); + }); + + it('stays silent until the catalog changes at a new turn', async () => { + ix.get(IAgentSkillListingReminderService); + expect(await provider()(isNewTurn())).toBeUndefined(); + + catalog.emitter.fire('user'); + expect(await provider()(notNewTurn())).toBeUndefined(); + + const reminder = await provider()(isNewTurn()); + expect(reminder).toBeDefined(); + expect(reminder).toContain('The skill catalog changed during this session'); + expect(reminder).toContain('DISREGARD any earlier skill listings'); + expect(reminder).toContain('hot-skill'); + }); + + it('does not re-inject on a later new turn without a new change', async () => { + ix.get(IAgentSkillListingReminderService); + catalog.emitter.fire('user'); + expect(await provider()(isNewTurn())).toBeDefined(); + expect(await provider()(isNewTurn())).toBeUndefined(); + }); + + it('injects once per catalog change burst and again after the next change', async () => { + ix.get(IAgentSkillListingReminderService); + catalog.emitter.fire('user'); + catalog.emitter.fire('workspace'); + expect(await provider()(isNewTurn())).toBeDefined(); + expect(await provider()(isNewTurn())).toBeUndefined(); + + catalog.emitter.fire('extra'); + expect(await provider()(isNewTurn())).toBeDefined(); + }); + + it('reports when no invocable skills remain', async () => { + disposables.dispose(); + disposables = new DisposableStore(); + catalog = skillCatalogStub([]); + injector = injectorStub(); + ix = createServices(disposables, { + additionalServices: (reg) => { + reg.defineInstance(ISessionSkillCatalog, catalog.stub); + reg.defineInstance(IAgentContextInjectorService, injector.stub); + reg.define(IAgentSkillListingReminderService, AgentSkillListingReminderService); + }, + }); + ix.get(IAgentSkillListingReminderService); + + catalog.emitter.fire('user'); + const reminder = await provider()(isNewTurn()); + expect(reminder).toBeDefined(); + expect(reminder).toContain('The skill catalog changed during this session'); + expect(reminder).toContain('no invocable skills'); + }); +}); diff --git a/packages/agent-core-v2/test/agent/profile/profileOps.test.ts b/packages/agent-core-v2/test/agent/profile/profileOps.test.ts index ab552c51c8..c001512229 100644 --- a/packages/agent-core-v2/test/agent/profile/profileOps.test.ts +++ b/packages/agent-core-v2/test/agent/profile/profileOps.test.ts @@ -19,6 +19,8 @@ import { AgentTelemetryContextService } from '#/app/telemetry/agentTelemetryCont import { IHostEnvironment } from '#/os/interface/hostEnvironment'; import { IHostFileSystem } from '#/os/interface/hostFileSystem'; import { AppendLogStore } from '#/persistence/backends/node-fs/appendLogStore'; +import { WriteAuthorityRegistryService } from '#/persistence/backends/node-fs/writeAuthorityRegistryService'; +import { IWriteAuthorityRegistry } from '#/persistence/interface/writeAuthority'; import { InMemoryStorageService } from '#/persistence/backends/memory/inMemoryStorageService'; import { IAppendLogStore } from '#/persistence/interface/appendLogStore'; import { IFileSystemStorageService } from '#/persistence/interface/storage'; @@ -93,6 +95,7 @@ function buildHost(key: string): { const host = disposables.add(new TestInstantiationService()); host.stub(IFileSystemStorageService, new InMemoryStorageService()); host.set(IAppendLogStore, new SyncDescriptor(AppendLogStore)); + host.stub(IWriteAuthorityRegistry, new WriteAuthorityRegistryService()); host.stub(ITelemetryService, createTelemetryStub()); host.stub(IAgentTelemetryContextService, new AgentTelemetryContextService()); host.stub(IConfigService, createConfigStub()); diff --git a/packages/agent-core-v2/test/agent/swarm/swarm.test.ts b/packages/agent-core-v2/test/agent/swarm/swarm.test.ts index 30f8d385e1..7eb5aae9f2 100644 --- a/packages/agent-core-v2/test/agent/swarm/swarm.test.ts +++ b/packages/agent-core-v2/test/agent/swarm/swarm.test.ts @@ -20,6 +20,8 @@ import { AgentToolRegistryService } from '#/agent/toolRegistry/toolRegistryServi import { IAgentLoopService } from '#/agent/loop/loop'; import { IConfigService } from '#/app/config/config'; import { AppendLogStore } from '#/persistence/backends/node-fs/appendLogStore'; +import { WriteAuthorityRegistryService } from '#/persistence/backends/node-fs/writeAuthorityRegistryService'; +import { IWriteAuthorityRegistry } from '#/persistence/interface/writeAuthority'; import { InMemoryStorageService } from '#/persistence/backends/memory/inMemoryStorageService'; import { IAppendLogStore } from '#/persistence/interface/appendLogStore'; import { IFileSystemStorageService } from '#/persistence/interface/storage'; @@ -77,6 +79,7 @@ describe('AgentSwarmService', () => { ix.stub(IAgentContextMemoryService, stubContextMemory()); ix.stub(IFileSystemStorageService, new InMemoryStorageService()); ix.set(IAppendLogStore, new SyncDescriptor(AppendLogStore)); + ix.stub(IWriteAuthorityRegistry, new WriteAuthorityRegistryService()); ix.set(IEventBus, new SyncDescriptor(EventBusService)); ix.stub(IAgentLoopService, stubLoopWithHooks()); ix.set(IAgentToolRegistryService, new SyncDescriptor(AgentToolRegistryService)); @@ -132,6 +135,7 @@ describe('AgentSwarmService', () => { const ix2 = disposables.add(new TestInstantiationService()); ix2.stub(IFileSystemStorageService, new InMemoryStorageService()); ix2.set(IAppendLogStore, new SyncDescriptor(AppendLogStore)); + ix2.stub(IWriteAuthorityRegistry, new WriteAuthorityRegistryService()); const fresh = registerTestAgentWire(ix2, testWireScope('wire', 'swarm-replay'), { log: ix2.get(IAppendLogStore), }); diff --git a/packages/agent-core-v2/test/agent/task/taskOps.test.ts b/packages/agent-core-v2/test/agent/task/taskOps.test.ts index 828dc4b7b0..d17524d38d 100644 --- a/packages/agent-core-v2/test/agent/task/taskOps.test.ts +++ b/packages/agent-core-v2/test/agent/task/taskOps.test.ts @@ -8,6 +8,8 @@ import { EventBusService } from '#/app/event/eventBusService'; import type { AgentTaskInfo } from '#/agent/task/task'; import { TaskModel, taskStarted, taskTerminated } from '#/agent/task/taskOps'; import { AppendLogStore } from '#/persistence/backends/node-fs/appendLogStore'; +import { WriteAuthorityRegistryService } from '#/persistence/backends/node-fs/writeAuthorityRegistryService'; +import { IWriteAuthorityRegistry } from '#/persistence/interface/writeAuthority'; import { InMemoryStorageService } from '#/persistence/backends/memory/inMemoryStorageService'; import { IAppendLogStore } from '#/persistence/interface/appendLogStore'; import { IFileSystemStorageService } from '#/persistence/interface/storage'; @@ -27,6 +29,7 @@ function buildHost(key: string): { wire: IWireService; log: IAppendLogStore; eve const ix = disposables.add(new TestInstantiationService()); ix.stub(IFileSystemStorageService, new InMemoryStorageService()); ix.set(IAppendLogStore, new SyncDescriptor(AppendLogStore)); + ix.stub(IWriteAuthorityRegistry, new WriteAuthorityRegistryService()); ix.set(IEventBus, new SyncDescriptor(EventBusService)); const wire = registerTestAgentWire(ix, testWireScope(SCOPE, key), { log: ix.get(IAppendLogStore), diff --git a/packages/agent-core-v2/test/agent/usage/usage.test.ts b/packages/agent-core-v2/test/agent/usage/usage.test.ts index 8e086f440f..75ff62eea9 100644 --- a/packages/agent-core-v2/test/agent/usage/usage.test.ts +++ b/packages/agent-core-v2/test/agent/usage/usage.test.ts @@ -11,6 +11,8 @@ import { import { AgentUsageService } from '#/agent/usage/usageService'; import { UsageModel } from '#/agent/usage/usageOps'; import { AppendLogStore } from '#/persistence/backends/node-fs/appendLogStore'; +import { WriteAuthorityRegistryService } from '#/persistence/backends/node-fs/writeAuthorityRegistryService'; +import { IWriteAuthorityRegistry } from '#/persistence/interface/writeAuthority'; import { InMemoryStorageService } from '#/persistence/backends/memory/inMemoryStorageService'; import { IAppendLogStore } from '#/persistence/interface/appendLogStore'; import { IFileSystemStorageService } from '#/persistence/interface/storage'; @@ -34,6 +36,7 @@ beforeEach(() => { ix = disposables.add(new TestInstantiationService()); ix.stub(IFileSystemStorageService, new InMemoryStorageService()); ix.set(IAppendLogStore, new SyncDescriptor(AppendLogStore)); + ix.stub(IWriteAuthorityRegistry, new WriteAuthorityRegistryService()); ix.set(IEventBus, new SyncDescriptor(EventBusService)); ix.set(IAgentUsageService, new SyncDescriptor(AgentUsageService)); log = ix.get(IAppendLogStore); @@ -59,6 +62,7 @@ function createFreshWire(logKey: string): { readonly fresh: IWireService; readon const freshIx = disposables.add(new TestInstantiationService()); freshIx.stub(IFileSystemStorageService, new InMemoryStorageService()); freshIx.set(IAppendLogStore, new SyncDescriptor(AppendLogStore)); + freshIx.stub(IWriteAuthorityRegistry, new WriteAuthorityRegistryService()); const freshLog = freshIx.get(IAppendLogStore); const fresh = registerTestAgentWire(freshIx, testWireScope(SCOPE, logKey), { log: freshLog, diff --git a/packages/agent-core-v2/test/agent/userTool/userTool.test.ts b/packages/agent-core-v2/test/agent/userTool/userTool.test.ts index 314c7b3b80..eeedbf0831 100644 --- a/packages/agent-core-v2/test/agent/userTool/userTool.test.ts +++ b/packages/agent-core-v2/test/agent/userTool/userTool.test.ts @@ -10,6 +10,8 @@ import { IAgentUserToolService, type UserToolRegistration } from '#/agent/userTo import { AgentUserToolService } from '#/agent/userTool/userToolService'; import { UserToolModel } from '#/agent/userTool/userToolOps'; import { AppendLogStore } from '#/persistence/backends/node-fs/appendLogStore'; +import { WriteAuthorityRegistryService } from '#/persistence/backends/node-fs/writeAuthorityRegistryService'; +import { IWriteAuthorityRegistry } from '#/persistence/interface/writeAuthority'; import { InMemoryStorageService } from '#/persistence/backends/memory/inMemoryStorageService'; import { IAppendLogStore } from '#/persistence/interface/appendLogStore'; import { IFileSystemStorageService } from '#/persistence/interface/storage'; @@ -75,6 +77,7 @@ beforeEach(() => { ix = disposables.add(new TestInstantiationService()); ix.stub(IFileSystemStorageService, new InMemoryStorageService()); ix.set(IAppendLogStore, new SyncDescriptor(AppendLogStore)); + ix.stub(IWriteAuthorityRegistry, new WriteAuthorityRegistryService()); ix.set(IAgentToolRegistryService, new SyncDescriptor(AgentToolRegistryService)); profile = createProfileStub(); ix.stub(IAgentProfileService, profile); @@ -139,6 +142,7 @@ describe('AgentUserToolService (wire-backed)', () => { const ixChild = disposables.add(new TestInstantiationService()); ixChild.stub(IFileSystemStorageService, new InMemoryStorageService()); ixChild.set(IAppendLogStore, new SyncDescriptor(AppendLogStore)); + ixChild.stub(IWriteAuthorityRegistry, new WriteAuthorityRegistryService()); ixChild.set(IAgentToolRegistryService, new SyncDescriptor(AgentToolRegistryService)); const childProfile = createProfileStub(); ixChild.stub(IAgentProfileService, childProfile); @@ -185,6 +189,7 @@ describe('AgentUserToolService (wire-backed)', () => { const ix2 = disposables.add(new TestInstantiationService()); ix2.stub(IFileSystemStorageService, new InMemoryStorageService()); ix2.set(IAppendLogStore, new SyncDescriptor(AppendLogStore)); + ix2.stub(IWriteAuthorityRegistry, new WriteAuthorityRegistryService()); ix2.set(IAgentToolRegistryService, new SyncDescriptor(AgentToolRegistryService)); const profile2 = createProfileStub(); ix2.stub(IAgentProfileService, profile2); diff --git a/packages/agent-core-v2/test/app/config/config.test.ts b/packages/agent-core-v2/test/app/config/config.test.ts index 75ce0b91d3..8b9178970f 100644 --- a/packages/agent-core-v2/test/app/config/config.test.ts +++ b/packages/agent-core-v2/test/app/config/config.test.ts @@ -54,8 +54,10 @@ import { InMemoryStorageService } from '#/persistence/backends/memory/inMemorySt import { IFileSystemStorageService } from '#/persistence/interface/storage'; import { IAtomicTomlDocumentStore } from '#/persistence/interface/atomicDocumentStore'; import { TomlAtomicDocumentStore } from '#/persistence/backends/node-fs/atomicDocumentStore'; +import { ICrossProcessLockService } from '#/os/interface/crossProcessLock'; import { stubBootstrap } from '../bootstrap/stubs'; import { stubLog } from '../../_base/log/stubs'; +import { stubCrossProcessLock } from '../../os/stubs'; const TEST_OS_ENV = { osKind: 'Linux', @@ -368,6 +370,7 @@ describe('ConfigService env overlay (live)', () => { ix.stub(IFileSystemStorageService, new InMemoryStorageService()); ix.set(IAtomicTomlDocumentStore, new SyncDescriptor(TomlAtomicDocumentStore)); ix.set(IConfigRegistry, new SyncDescriptor(ConfigRegistry)); + ix.stub(ICrossProcessLockService, stubCrossProcessLock()); ix.set(IConfigService, new SyncDescriptor(ConfigService)); const config = ix.get(IConfigService); await config.ready; @@ -390,6 +393,7 @@ describe('ConfigService env overlay (live)', () => { ix.stub(IFileSystemStorageService, new InMemoryStorageService()); ix.set(IAtomicTomlDocumentStore, new SyncDescriptor(TomlAtomicDocumentStore)); ix.set(IConfigRegistry, new SyncDescriptor(ConfigRegistry)); + ix.stub(ICrossProcessLockService, stubCrossProcessLock()); ix.set(IConfigService, new SyncDescriptor(ConfigService)); const config = ix.get(IConfigService); await config.ready; @@ -411,6 +415,7 @@ describe('ConfigService env overlay (live)', () => { ix.stub(IFileSystemStorageService, new InMemoryStorageService()); ix.set(IAtomicTomlDocumentStore, new SyncDescriptor(TomlAtomicDocumentStore)); ix.set(IConfigRegistry, new SyncDescriptor(ConfigRegistry)); + ix.stub(ICrossProcessLockService, stubCrossProcessLock()); ix.set(IConfigService, new SyncDescriptor(ConfigService)); const config = ix.get(IConfigService); await config.ready; @@ -432,6 +437,7 @@ describe('ConfigService env overlay (live)', () => { const ix = disposables.add(new TestInstantiationService()); ix.stub(ILogService, stubLog()); ix.stub(IBootstrapService, stubBootstrap('/tmp/kimi-cfg')); + ix.stub(ICrossProcessLockService, stubCrossProcessLock()); ix.stub(IFileSystemStorageService, new InMemoryStorageService()); ix.set(IAtomicTomlDocumentStore, new SyncDescriptor(TomlAtomicDocumentStore)); ix.set(IConfigRegistry, new SyncDescriptor(ConfigRegistry)); @@ -501,6 +507,7 @@ describe('image config section', () => { ix.stub(IFileSystemStorageService, new InMemoryStorageService()); ix.set(IAtomicTomlDocumentStore, new SyncDescriptor(TomlAtomicDocumentStore)); ix.set(IConfigRegistry, new SyncDescriptor(ConfigRegistry)); + ix.stub(ICrossProcessLockService, stubCrossProcessLock()); ix.set(IConfigService, new SyncDescriptor(ConfigService)); const config = ix.get(IConfigService); await config.ready; @@ -535,6 +542,7 @@ describe('task config section', () => { ix.stub(IFileSystemStorageService, new InMemoryStorageService()); ix.set(IAtomicTomlDocumentStore, new SyncDescriptor(TomlAtomicDocumentStore)); ix.set(IConfigRegistry, new SyncDescriptor(ConfigRegistry)); + ix.stub(ICrossProcessLockService, stubCrossProcessLock()); ix.set(IConfigService, new SyncDescriptor(ConfigService)); const config = ix.get(IConfigService); await config.ready; @@ -569,6 +577,7 @@ describe('task config section', () => { ix.stub(IFileSystemStorageService, storage); ix.set(IAtomicTomlDocumentStore, new SyncDescriptor(TomlAtomicDocumentStore)); ix.set(IConfigRegistry, new SyncDescriptor(ConfigRegistry)); + ix.stub(ICrossProcessLockService, stubCrossProcessLock()); ix.set(IConfigService, new SyncDescriptor(ConfigService)); const config = ix.get(IConfigService); await config.ready; @@ -594,6 +603,7 @@ describe('task config section', () => { ix.stub(IFileSystemStorageService, storage); ix.set(IAtomicTomlDocumentStore, new SyncDescriptor(TomlAtomicDocumentStore)); ix.set(IConfigRegistry, new SyncDescriptor(ConfigRegistry)); + ix.stub(ICrossProcessLockService, stubCrossProcessLock()); ix.set(IConfigService, new SyncDescriptor(ConfigService)); const config = ix.get(IConfigService); await config.ready; @@ -665,6 +675,7 @@ describe('subagent config section', () => { ix.stub(IFileSystemStorageService, storage); ix.set(IAtomicTomlDocumentStore, new SyncDescriptor(TomlAtomicDocumentStore)); ix.set(IConfigRegistry, new SyncDescriptor(ConfigRegistry)); + ix.stub(ICrossProcessLockService, stubCrossProcessLock()); ix.set(IConfigService, new SyncDescriptor(ConfigService)); const config = ix.get(IConfigService); await config.ready; diff --git a/packages/agent-core-v2/test/app/config/configFileMutex.test.ts b/packages/agent-core-v2/test/app/config/configFileMutex.test.ts new file mode 100644 index 0000000000..799e7bbc81 --- /dev/null +++ b/packages/agent-core-v2/test/app/config/configFileMutex.test.ts @@ -0,0 +1,173 @@ +/** + * Scenario: config.toml cross-process lock-in-RMW (design: + * .tmp/refactor-watch-design-v2.md §3.6). + * + * Two independent `ConfigService` instances share one home dir on the real + * filesystem; interleaved writes must merge without lost updates, the lock + * file must be released after each critical section, and a held lock must + * surface OS_LOCK_WAIT_TIMEOUT while leaving config.toml intact. Watch-based + * reloads ride real chokidar (150ms debounce), so assertions poll with real + * timers. Run with `pnpm --filter @moonshot-ai/agent-core-v2 exec vitest run + * test/app/config/configFileMutex.test.ts`. + */ + +import { existsSync, readFileSync, readdirSync } from 'node:fs'; +import { mkdtemp, rm, writeFile } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; + +import { afterEach, beforeEach, describe, expect, it } from 'vitest'; + +import { SyncDescriptor } from '#/_base/di/descriptors'; +import { DisposableStore } from '#/_base/di/lifecycle'; +import { TestInstantiationService } from '#/_base/di/test'; +import { ILogService } from '#/_base/log/log'; +import { IBootstrapService } from '#/app/bootstrap/bootstrap'; +import { IConfigRegistry, IConfigService } from '#/app/config/config'; +import { ConfigRegistry, ConfigService } from '#/app/config/configService'; +import { CrossProcessLockService } from '#/os/backends/node-local/crossProcessLockService'; +import { + CrossProcessLockErrorCode, + ICrossProcessLockService, + type ICrossProcessLockHandle, +} from '#/os/interface/crossProcessLock'; +import { TomlAtomicDocumentStore } from '#/persistence/backends/node-fs/atomicDocumentStore'; +import { FileStorageService } from '#/persistence/backends/node-fs/fileStorageService'; +import { IAtomicTomlDocumentStore } from '#/persistence/interface/atomicDocumentStore'; +import { IFileSystemStorageService } from '#/persistence/interface/storage'; + +import { stubLog } from '../../_base/log/stubs'; +import { stubBootstrap } from '../bootstrap/stubs'; + +const realSleep = (ms: number): Promise => + new Promise((resolvePromise) => setTimeout(resolvePromise, ms)); + +async function waitFor(cond: () => boolean, timeoutMs = 5_000): Promise { + const start = Date.now(); + for (;;) { + if (cond()) return; + if (Date.now() - start > timeoutMs) throw new Error('waitFor timed out'); + await realSleep(25); + } +} + +describe('ConfigService config.toml lock-in-RMW', () => { + let homeDir: string; + let disposables: DisposableStore; + + beforeEach(async () => { + homeDir = await mkdtemp(join(tmpdir(), 'config-rmw-')); + disposables = new DisposableStore(); + }); + + afterEach(async () => { + disposables.dispose(); + await rm(homeDir, { recursive: true, force: true }); + }); + + function createContainer(lock?: ICrossProcessLockService): IConfigService { + const ix = disposables.add(new TestInstantiationService()); + ix.stub(ILogService, stubLog()); + ix.stub(IBootstrapService, stubBootstrap(homeDir, {})); + ix.stub(IFileSystemStorageService, new FileStorageService(homeDir)); + ix.set(IAtomicTomlDocumentStore, new SyncDescriptor(TomlAtomicDocumentStore)); + ix.stub(ICrossProcessLockService, lock ?? new CrossProcessLockService()); + ix.set(IConfigRegistry, new SyncDescriptor(ConfigRegistry)); + ix.set(IConfigService, new SyncDescriptor(ConfigService)); + return ix.get(IConfigService); + } + + it('merges repeated set()s of different sections within one container', async () => { + const config = createContainer(); + await config.set('alphaSection', { one: 1 }); + await config.set('betaSection', { two: 2 }); + + const toml = readFileSync(join(homeDir, 'config.toml'), 'utf8'); + expect(toml).toContain('[alpha_section]'); + expect(toml).toContain('[beta_section]'); + expect(config.get('alphaSection')).toEqual({ one: 1 }); + expect(config.get('betaSection')).toEqual({ two: 2 }); + + await config.reload(); + expect(config.get('alphaSection')).toEqual({ one: 1 }); + expect(config.get('betaSection')).toEqual({ two: 2 }); + }); + + it('two containers interleave set()s without losing section updates', async () => { + await writeFile(join(homeDir, 'config.toml'), '[hand_written]\nkeep = "me"\n'); + const a = createContainer(); + const b = createContainer(); + await a.ready; + await b.ready; + + const rounds = 4; + for (let i = 0; i < rounds; i++) { + await Promise.all([a.set(`alphaSide${i}`, { v: i }), b.set(`betaSide${i}`, { v: i })]); + } + + const toml = readFileSync(join(homeDir, 'config.toml'), 'utf8'); + expect(toml).toContain('[hand_written]'); + for (let i = 0; i < rounds; i++) { + expect(toml).toContain(`[alpha_side${i}]`); + expect(toml).toContain(`[beta_side${i}]`); + } + + // Each container's own watcher reloads the other side's sections. + await waitFor(() => { + for (let i = 0; i < rounds; i++) if (b.get(`alphaSide${i}`) === undefined) return false; + return true; + }); + await waitFor(() => { + for (let i = 0; i < rounds; i++) if (a.get(`betaSide${i}`) === undefined) return false; + return true; + }); + expect(b.get('alphaSide0')).toEqual({ v: 0 }); + expect(a.get('betaSide3')).toEqual({ v: 3 }); + }); + + it('releases config.toml.lock after the critical section and leaves no stale files', async () => { + const config = createContainer(); + await config.set('alphaSection', { one: 1 }); + await config.set('betaSection', { two: 2 }); + + expect(existsSync(join(homeDir, 'config.toml.lock'))).toBe(false); + expect(readdirSync(homeDir).filter((entry) => entry.includes('.stale.'))).toEqual([]); + }); + + it('fails set() with OS_LOCK_WAIT_TIMEOUT while another holder is stuck, leaving config.toml intact', async () => { + let nowValue = 1_000_000; + let lockSeq = 0; + const victim = new CrossProcessLockService({ + selfPid: 1001, + instanceId: 'victim', + probeProcess: () => ({ alive: true }), + now: () => nowValue, + newLockId: () => `victim-${++lockSeq}`, + sleep: (ms) => { + nowValue += ms; + return Promise.resolve(); + }, + }); + const config = createContainer(victim); + await config.set('seedSection', { ok: true }); + const before = readFileSync(join(homeDir, 'config.toml'), 'utf8'); + + const attacker = new CrossProcessLockService({ + selfPid: 2002, + instanceId: 'attacker', + probeProcess: () => ({ alive: true }), + newLockId: () => 'attacker-lock', + }); + const lockPath = join(homeDir, 'config.toml.lock'); + const handle: ICrossProcessLockHandle = attacker.acquire(lockPath); + try { + await expect(config.set('blockedSection', { no: true })).rejects.toMatchObject({ + code: CrossProcessLockErrorCode.WaitTimeout, + }); + expect(readFileSync(join(homeDir, 'config.toml'), 'utf8')).toBe(before); + } finally { + handle.release(); + } + expect(existsSync(lockPath)).toBe(false); + }); +}); diff --git a/packages/agent-core-v2/test/app/flag/flag.test.ts b/packages/agent-core-v2/test/app/flag/flag.test.ts index 2749afcb05..658b8de601 100644 --- a/packages/agent-core-v2/test/app/flag/flag.test.ts +++ b/packages/agent-core-v2/test/app/flag/flag.test.ts @@ -18,9 +18,11 @@ import { IAtomicTomlDocumentStore } from '#/persistence/interface/atomicDocument import { TomlAtomicDocumentStore } from '#/persistence/backends/node-fs/atomicDocumentStore'; import { InMemoryStorageService } from '#/persistence/backends/memory/inMemoryStorageService'; import { IFileSystemStorageService } from '#/persistence/interface/storage'; +import { ICrossProcessLockService } from '#/os/interface/crossProcessLock'; import { stubBootstrap } from '../bootstrap/stubs'; import { stubLog } from '../../_base/log/stubs'; +import { stubCrossProcessLock } from '../../os/stubs'; const exampleFlag: FlagDefinitionInput = { id: 'example_flag', @@ -75,6 +77,7 @@ describe('FlagService', () => { ix.stub(IFileSystemStorageService, new InMemoryStorageService()); ix.set(IAtomicTomlDocumentStore, new SyncDescriptor(TomlAtomicDocumentStore)); ix.set(IConfigRegistry, new SyncDescriptor(ConfigRegistry)); + ix.stub(ICrossProcessLockService, stubCrossProcessLock()); ix.set(IConfigService, new SyncDescriptor(ConfigService)); ix.set(IFlagRegistry, new SyncDescriptor(FlagRegistryService)); ix.set(IFlagService, new SyncDescriptor(FlagService)); diff --git a/packages/agent-core-v2/test/app/mcp/mcpConfigWatch.test.ts b/packages/agent-core-v2/test/app/mcp/mcpConfigWatch.test.ts new file mode 100644 index 0000000000..f12c6a97b0 --- /dev/null +++ b/packages/agent-core-v2/test/app/mcp/mcpConfigWatch.test.ts @@ -0,0 +1,117 @@ +/** + * Scenario: App-scope watch of the user-level mcp.json (design: + * .tmp/refactor-watch-design-v2.md §3.6). + * + * Valid content fires `onDidChange` (and a fresh `loadMcpServers` observes + * the new servers — v2 loads mcp.json per session creation and has no cache + * to invalidate); unparseable content logs a warning and suppresses the + * event; blank content counts as valid, matching the loader's tolerance. The + * watch rides real chokidar with a 150ms debounce, so assertions poll with + * real timers. Run with `pnpm --filter @moonshot-ai/agent-core-v2 exec + * vitest run test/app/mcp/mcpConfigWatch.test.ts`. + */ + +import { mkdtemp, rm, writeFile } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; + +import { afterEach, beforeEach, describe, expect, it } from 'vitest'; + +import { SyncDescriptor } from '#/_base/di/descriptors'; +import { DisposableStore } from '#/_base/di/lifecycle'; +import { TestInstantiationService } from '#/_base/di/test'; +import { ILogService } from '#/_base/log/log'; +import { loadMcpServers } from '#/agent/mcp/config-loader'; +import { IMcpConfigWatchService } from '#/app/mcp/mcpConfigWatch'; +import { McpConfigWatchService } from '#/app/mcp/mcpConfigWatchService'; +import { FileStorageService } from '#/persistence/backends/node-fs/fileStorageService'; +import { IFileSystemStorageService } from '#/persistence/interface/storage'; + +import { stubLog } from '../../_base/log/stubs'; + +const realSleep = (ms: number): Promise => + new Promise((resolvePromise) => setTimeout(resolvePromise, ms)); + +async function waitFor(cond: () => boolean, timeoutMs = 5_000): Promise { + const start = Date.now(); + for (;;) { + if (cond()) return; + if (Date.now() - start > timeoutMs) throw new Error('waitFor timed out'); + await realSleep(25); + } +} + +describe('McpConfigWatchService', () => { + let homeDir: string; + let disposables: DisposableStore; + let warnings: string[]; + + beforeEach(async () => { + homeDir = await mkdtemp(join(tmpdir(), 'mcp-watch-')); + disposables = new DisposableStore(); + warnings = []; + }); + + afterEach(async () => { + disposables.dispose(); + await rm(homeDir, { recursive: true, force: true }); + }); + + function createWatcher(): { events: number } { + const state = { events: 0 }; + const log = { + ...stubLog(), + warn: (message: string) => { + warnings.push(message); + }, + }; + const ix = disposables.add(new TestInstantiationService()); + ix.stub(ILogService, log); + ix.stub(IFileSystemStorageService, new FileStorageService(homeDir)); + ix.set(IMcpConfigWatchService, new SyncDescriptor(McpConfigWatchService)); + const watch = ix.get(IMcpConfigWatchService); + disposables.add(watch.onDidChange(() => { + state.events += 1; + })); + return state; + } + + it('fires onDidChange for a valid write and a fresh load sees the new servers', async () => { + const state = createWatcher(); + await realSleep(100); + + await writeFile( + join(homeDir, 'mcp.json'), + JSON.stringify({ mcpServers: { docs: { transport: 'http', url: 'https://docs.example.com' } } }), + 'utf8', + ); + + await waitFor(() => state.events === 1); + const servers = await loadMcpServers({ cwd: homeDir, homeDir }); + expect(Object.keys(servers)).toEqual(['docs']); + expect(warnings).toEqual([]); + }); + + it('warns and suppresses the event on invalid JSON', async () => { + const state = createWatcher(); + await realSleep(100); + + await writeFile(join(homeDir, 'mcp.json'), JSON.stringify({ mcpServers: {} }), 'utf8'); + await waitFor(() => state.events === 1); + + await writeFile(join(homeDir, 'mcp.json'), '{not json}', 'utf8'); + await realSleep(400); + expect(state.events).toBe(1); + expect(warnings.some((message) => message.includes('invalid JSON'))).toBe(true); + }); + + it('treats blank content as a valid empty config change', async () => { + const state = createWatcher(); + await realSleep(100); + + await writeFile(join(homeDir, 'mcp.json'), ' \n', 'utf8'); + + await waitFor(() => state.events === 1); + expect(warnings).toEqual([]); + }); +}); diff --git a/packages/agent-core-v2/test/app/sessionLifecycle/sessionLifecycle.test.ts b/packages/agent-core-v2/test/app/sessionLifecycle/sessionLifecycle.test.ts index 4ac8b047b4..1464b37c8e 100644 --- a/packages/agent-core-v2/test/app/sessionLifecycle/sessionLifecycle.test.ts +++ b/packages/agent-core-v2/test/app/sessionLifecycle/sessionLifecycle.test.ts @@ -1,5 +1,6 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { randomUUID } from 'node:crypto'; import { mkdtemp, mkdir, readFile, rm, stat, writeFile } from 'node:fs/promises'; import { tmpdir } from 'node:os'; import { isAbsolute, join, resolve } from 'node:path'; @@ -14,12 +15,14 @@ import { } from '#/_base/di/scope'; import { type ScopedTestHost, createScopedTestHost, stubPair } from '#/_base/di/test'; import { Event } from '#/_base/event'; +import { ILogService } from '#/_base/log/log'; import { IBootstrapService } from '#/app/bootstrap/bootstrap'; import { IConfigService } from '#/app/config/config'; import { IHostEnvironment } from '#/os/interface/hostEnvironment'; import { IHostFileSystem } from '#/os/interface/hostFileSystem'; import { HostFileSystem } from '#/os/backends/node-local/hostFsService'; import { IEventService } from '#/app/event/event'; +import { IFlagService } from '#/app/flag/flag'; import { IAgentLifecycleService, MAIN_AGENT_ID, @@ -38,14 +41,28 @@ import { ISessionSkillCatalog } from '#/session/sessionSkillCatalog/skillCatalog import { ISessionIndex, type SessionSummary } from '#/app/sessionIndex/sessionIndex'; import { IAppendLogStore } from '#/persistence/interface/appendLogStore'; import { IAtomicDocumentStore } from '#/persistence/interface/atomicDocumentStore'; +import { IWriteAuthorityRegistry } from '#/persistence/interface/writeAuthority'; +import { AppendLogStore } from '#/persistence/backends/node-fs/appendLogStore'; +import { FileStorageService } from '#/persistence/backends/node-fs/fileStorageService'; +import { WriteAuthorityRegistryService } from '#/persistence/backends/node-fs/writeAuthorityRegistryService'; import { IWorkspaceLocalConfigService } from '#/app/workspaceLocalConfig/workspaceLocalConfig'; import { ISessionWorkspaceContext } from '#/session/workspaceContext/workspaceContext'; import { SessionWorkspaceContextService } from '#/session/workspaceContext/workspaceContextService'; import { IWorkspaceRegistry, type Workspace } from '#/app/workspaceRegistry/workspaceRegistry'; import { encodeWorkDirKey } from '#/_base/utils/workdir-slug'; import { ISessionContext } from '#/session/sessionContext/sessionContext'; +import { ISessionLeaseService } from '#/session/sessionLease/sessionLease'; +import { + ISessionLeaseContactProvider, + SessionLeaseContactProvider, +} from '#/session/sessionLease/sessionLeaseContactProvider'; import { ITelemetryService } from '#/app/telemetry/telemetry'; import { Error2, ErrorCodes } from '#/errors'; +import { ICrossProcessLockService } from '#/os/interface/crossProcessLock'; +import { CrossProcessLockService } from '#/os/backends/node-local/crossProcessLockService'; +import { stubCrossProcessLock } from '../../os/stubs'; +import { stubFlag } from '../flag/stubs'; +import { stubLog } from '../../_base/log/stubs'; import { recordingTelemetry, type TelemetryRecord } from '../../app/telemetry/stubs'; function bootstrapStub(): IBootstrapService { @@ -361,6 +378,15 @@ function tick(): Promise { return new Promise((resolve) => setTimeout(resolve, 0)); } +async function waitFor(cond: () => boolean, timeoutMs = 5_000): Promise { + const start = Date.now(); + for (;;) { + if (cond()) return; + if (Date.now() - start > timeoutMs) throw new Error('waitFor timed out'); + await new Promise((r) => setTimeout(r, 10)); + } +} + class NoopSessionExternalHooksService implements ISessionExternalHooksService { declare readonly _serviceBrand: undefined; } @@ -447,6 +473,11 @@ describe('SessionLifecycleService', () => { stubPair(IWorkspaceLocalConfigService, workspaceLocalConfigStub()), stubPair(ITelemetryService, recordingTelemetry(telemetryRecords)), stubPair(ICronTaskPersistence, cronStoreStub()), + stubPair(ILogService, stubLog()), + stubPair(IFlagService, stubFlag(false)), + stubPair(ICrossProcessLockService, stubCrossProcessLock()), + stubPair(IWriteAuthorityRegistry, new WriteAuthorityRegistryService()), + stubPair(ISessionLeaseContactProvider, new SessionLeaseContactProvider()), ...extra, ]); return host.app.accessor.get(ISessionLifecycleService); @@ -1183,6 +1214,325 @@ describe('SessionLifecycleService', () => { }); }); + describe('session lease', () => { + function realInstanceSeeds( + root: string, + over: ReturnType[] = [], + ): ReturnType[] { + return [ + stubPair(IBootstrapService, tmpBootstrapStub(root)), + stubPair(ICrossProcessLockService, new CrossProcessLockService()), + stubPair(IWriteAuthorityRegistry, new WriteAuthorityRegistryService()), + ...over, + ]; + } + + function realAlsSeeds(root: string): { + seeds: ReturnType[]; + appendLog: AppendLogStore; + registry: WriteAuthorityRegistryService; + } { + const registry = new WriteAuthorityRegistryService(); + const appendLog = new AppendLogStore(new FileStorageService(root), registry); + return { + seeds: [ + stubPair(IBootstrapService, tmpBootstrapStub(root)), + stubPair(ICrossProcessLockService, new CrossProcessLockService()), + stubPair(IWriteAuthorityRegistry, registry), + stubPair(IAppendLogStore, appendLog), + ], + appendLog, + registry, + }; + } + + function leaseFile(root: string, sessionId: string): string { + return join(root, 'session-leases', `${sessionId}.json`); + } + + async function createError( + svc: ISessionLifecycleService, + sessionId: string, + ): Promise { + return svc + .create({ sessionId, workDir: '/tmp/proj' }) + .then(() => { + throw new Error('expected create to fail'); + }, (error: unknown) => error as Error2); + } + + it('acquires the lease on materialize and seeds it into the session scope', async () => { + const root = await makeTmpRoot(); + const svc = build(realInstanceSeeds(root)); + const h = await svc.create({ sessionId: 's1', workDir: '/tmp/proj' }); + + const payload = JSON.parse(await readFile(leaseFile(root, 's1'), 'utf8')); + const lease = h.accessor.get(ISessionLeaseService); + expect(lease.info).toEqual({ sessionId: 's1', lockId: payload.lock_id }); + expect(() => lease.assertWritable()).not.toThrow(); + expect(telemetryRecords).toContainEqual({ + event: 'session_lease_acquired', + properties: { session_id: 's1' }, + }); + }); + + it('refuses to materialize a session live-held by another instance', async () => { + const root = await makeTmpRoot(); + const first = build(realInstanceSeeds(root)); + const firstHost = host!; + await first.create({ sessionId: 's1', workDir: '/tmp/proj' }); + try { + const second = build(realInstanceSeeds(root)); + const error = await createError(second, 's1'); + expect(error.code).toBe(ErrorCodes.SESSION_HELD_BY_PEER); + expect(error.details).toEqual({ kind: 'held-by-peer', phase: 'held-by-local-instance' }); + expect(telemetryRecords).toContainEqual({ + event: 'session_held_by_peer_returned', + properties: { session_id: 's1', phase: 'held-by-local-instance' }, + }); + } finally { + firstHost.dispose(); + } + }); + + it('reports the routable phase with the holder address when multi_server is on', async () => { + const root = await makeTmpRoot(); + const first = build( + realInstanceSeeds(root, [ + stubPair(IFlagService, stubFlag(true)), + stubPair( + ISessionLeaseContactProvider, + new SessionLeaseContactProvider(() => ({ + type: 'address', + address: 'http://127.0.0.1:5555', + })), + ), + ]), + ); + const firstHost = host!; + await first.create({ sessionId: 's1', workDir: '/tmp/proj' }); + try { + const second = build( + realInstanceSeeds(root, [stubPair(IFlagService, stubFlag(true))]), + ); + const error = await createError(second, 's1'); + expect(error.code).toBe(ErrorCodes.SESSION_HELD_BY_PEER); + expect(error.details).toEqual({ + kind: 'held-by-peer', + phase: 'routable', + address: 'http://127.0.0.1:5555', + }); + } finally { + firstHost.dispose(); + } + }); + + it('never emits the holder address when multi_server is off', async () => { + const root = await makeTmpRoot(); + const first = build( + realInstanceSeeds(root, [ + stubPair( + ISessionLeaseContactProvider, + new SessionLeaseContactProvider(() => ({ + type: 'address', + address: 'http://127.0.0.1:5555', + })), + ), + ]), + ); + const firstHost = host!; + await first.create({ sessionId: 's1', workDir: '/tmp/proj' }); + try { + const second = build(realInstanceSeeds(root)); + const error = await createError(second, 's1'); + expect(error.code).toBe(ErrorCodes.SESSION_HELD_BY_PEER); + expect(error.details).toEqual({ kind: 'held-by-peer', phase: 'held-by-local-instance' }); + } finally { + firstHost.dispose(); + } + }); + + it('reports holder-unresponsive for a live holder with a stale heartbeat', async () => { + const root = await makeTmpRoot(); + await mkdir(join(root, 'session-leases'), { recursive: true }); + await writeFile( + leaseFile(root, 's1'), + JSON.stringify({ + lock_id: 'old-token', + instance_id: 'inst-old', + pid: process.pid, + heartbeat_at: Date.now() - 60_000, + }), + ); + const svc = build(realInstanceSeeds(root)); + const error = await createError(svc, 's1'); + expect(error.code).toBe(ErrorCodes.SESSION_HELD_BY_PEER); + expect(error.details).toEqual({ + kind: 'held-by-peer', + phase: 'holder-unresponsive', + retry_after_ms: 2000, + }); + expect(telemetryRecords).toContainEqual({ + event: 'session_lease_holder_unresponsive', + properties: { session_id: 's1' }, + }); + }); + + it('reports the creating phase for a lease file inside its creation window', async () => { + const root = await makeTmpRoot(); + await mkdir(join(root, 'session-leases'), { recursive: true }); + await writeFile(leaseFile(root, 's1'), ''); + const svc = build(realInstanceSeeds(root)); + const error = await createError(svc, 's1'); + expect(error.code).toBe(ErrorCodes.SESSION_HELD_BY_PEER); + expect(error.details).toEqual({ kind: 'held-by-peer', phase: 'creating', retry_after_ms: 1000 }); + }); + + it('takes over the lease of a dead holder and reports session_lease_takeover', async () => { + const root = await makeTmpRoot(); + await mkdir(join(root, 'session-leases'), { recursive: true }); + await writeFile( + leaseFile(root, 's1'), + JSON.stringify({ + lock_id: 'dead-token', + instance_id: 'inst-dead', + pid: 0x7fffffff, + heartbeat_at: 1, + }), + ); + const svc = build(realInstanceSeeds(root)); + const h = await svc.create({ sessionId: 's1', workDir: '/tmp/proj' }); + expect(h.id).toBe('s1'); + expect(telemetryRecords).toContainEqual({ + event: 'session_lease_takeover', + properties: { session_id: 's1', previous: 'holder-dead' }, + }); + const payload = JSON.parse(await readFile(leaseFile(root, 's1'), 'utf8')); + expect(payload.lock_id).not.toBe('dead-token'); + }); + + it('tears the session down when a peer takes over its lease', async () => { + const root = await makeTmpRoot(); + const { seeds, appendLog } = realAlsSeeds(root); + const svc = build(seeds); + await svc.create({ sessionId: 's1', workDir: '/tmp/proj' }); + + const leasePath = leaseFile(root, 's1'); + const payload = JSON.parse(await readFile(leasePath, 'utf8')); + await writeFile(leasePath, JSON.stringify({ ...payload, lock_id: 'peer-token' })); + + const agentScopeStr = 'sessions/wd_stub/s1/agents/main'; + appendLog.append(agentScopeStr, 'wire.jsonl', { type: 'metadata' }); + await expect(appendLog.flush()).rejects.toMatchObject({ + code: ErrorCodes.SESSION_LEASE_LOST, + }); + + await waitFor(() => svc.get('s1') === undefined); + + appendLog.append(agentScopeStr, 'wire.jsonl', { type: 'metadata' }); + await expect(appendLog.flush()).rejects.toMatchObject({ + code: ErrorCodes.SESSION_LEASE_LOST, + }); + await expect(stat(join(root, agentScopeStr))).rejects.toThrow(); + // The token-guarded release never unlinks the peer's lease file. + const remaining = JSON.parse(await readFile(leasePath, 'utf8')); + expect(remaining.lock_id).toBe('peer-token'); + }); + + it('refuses to materialize a session directory an unregistered writer keeps writing', async () => { + const root = await makeTmpRoot(); + const sessionDir = join(root, 'sessions', 'wd_stub', 's1'); + await mkdir(sessionDir, { recursive: true }); + await writeFile(join(sessionDir, 'marker'), '1'); + const writer = setInterval(() => { + void writeFile(join(sessionDir, `tick-${randomUUID()}`), 'x').catch(() => {}); + }, 150); + writer.unref(); + try { + const svc = build( + realInstanceSeeds(root, [stubPair(IFlagService, stubFlag(true))]), + ); + const error = await createError(svc, 's1'); + expect(error.code).toBe(ErrorCodes.SESSION_HELD_BY_PEER); + expect(error.details).toEqual({ kind: 'unregistered-writer' }); + await expect(stat(leaseFile(root, 's1'))).rejects.toThrow(); + } finally { + clearInterval(writer); + } + }); + + it('materializes when the session directory stops being written between the two checks', async () => { + const root = await makeTmpRoot(); + const sessionDir = join(root, 'sessions', 'wd_stub', 's1'); + await mkdir(sessionDir, { recursive: true }); + await writeFile(join(sessionDir, 'marker'), '1'); + const svc = build( + realInstanceSeeds(root, [stubPair(IFlagService, stubFlag(true))]), + ); + const h = await svc.create({ sessionId: 's1', workDir: '/tmp/proj' }); + expect(h.id).toBe('s1'); + }); + + it('fork acquires a distinct target lease; releasing the source does not fence the target', async () => { + const root = await makeTmpRoot(); + const { seeds, appendLog } = realAlsSeeds(root); + const svc = build([ + ...seeds, + stubPair(IWorkspaceRegistry, { + ...workspaceRegistryStub(), + get: () => + Promise.resolve({ + id: 'wd_stub', + root: '/tmp/proj', + name: 'stub', + createdAt: 0, + lastOpenedAt: 0, + }), + }), + ]); + await svc.create({ sessionId: 'src', workDir: '/tmp/proj' }); + const target = await svc.fork({ sourceSessionId: 'src', newSessionId: 'dst' }); + expect(target.id).toBe('dst'); + + const srcPayload = JSON.parse(await readFile(leaseFile(root, 'src'), 'utf8')); + const dstPayload = JSON.parse(await readFile(leaseFile(root, 'dst'), 'utf8')); + expect(dstPayload.lock_id).not.toBe(srcPayload.lock_id); + + appendLog.append('sessions/wd_stub/src/agents/main', 'wire.jsonl', { n: 1 }); + appendLog.append('sessions/wd_stub/dst/agents/main', 'wire.jsonl', { n: 2 }); + await appendLog.flush(); + + await svc.close('src'); + appendLog.append('sessions/wd_stub/dst/agents/main', 'wire.jsonl', { n: 3 }); + await appendLog.flush(); + + const disk = await readFile( + join(root, 'sessions', 'wd_stub', 'dst', 'agents', 'main', 'wire.jsonl'), + 'utf8', + ); + expect(disk).toBe('{"n":2}\n{"n":3}\n'); + }); + + it('close returns with the just-appended journal tail durable and the lease released', async () => { + const root = await makeTmpRoot(); + const { seeds, appendLog } = realAlsSeeds(root); + const svc = build(seeds); + await svc.create({ sessionId: 's1', workDir: '/tmp/proj' }); + const agentScopeStr = 'sessions/wd_stub/s1/agents/main'; + // Mirror the wire handle lifecycle: release retires the buffer and its + // final flush is fire-and-forget — close must still await it. + const released = appendLog.acquire(agentScopeStr, 'wire.jsonl'); + appendLog.append(agentScopeStr, 'wire.jsonl', { tail: true }); + released.dispose(); + + await svc.close('s1'); + + const disk = await readFile(join(root, agentScopeStr, 'wire.jsonl'), 'utf8'); + expect(disk).toBe('{"tail":true}\n'); + await expect(stat(leaseFile(root, 's1'))).rejects.toThrow(); + }); + }); + describe('defaultPlanMode bootstrap', () => { it('enters plan mode on a fresh session when config.defaultPlanMode is true', async () => { const { lifecycle, enter, create } = agentLifecycleCapturingPlanSpy(); diff --git a/packages/agent-core-v2/test/app/skillCatalog/skillRootWatcher.test.ts b/packages/agent-core-v2/test/app/skillCatalog/skillRootWatcher.test.ts new file mode 100644 index 0000000000..dfdd09917d --- /dev/null +++ b/packages/agent-core-v2/test/app/skillCatalog/skillRootWatcher.test.ts @@ -0,0 +1,139 @@ +/** + * `skillCatalog` domain (L3) — `SkillRootWatcher` integration test against the + * real chokidar watcher on temporary directories. + * + * Run: `pnpm --filter @moonshot-ai/agent-core-v2 exec vitest run + * test/app/skillCatalog/skillRootWatcher.test.ts`. + */ + +import { realpathSync } from 'node:fs'; +import { mkdir, mkdtemp, rm, writeFile } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; + +import { join } from 'pathe'; +import { afterEach, describe, expect, it } from 'vitest'; + +import { SkillRootWatcher } from '#/app/skillCatalog/skillRootWatcher'; +import { HostFsWatchService } from '#/os/backends/node-local/hostFsWatchService'; + +const wait = (ms: number): Promise => new Promise((r) => setTimeout(r, ms)); + +// chokidar arm latency slack; the watcher's own debounce is 300 ms. +const SETTLE_MS = 300; +const DEBOUNCE_WINDOW_MS = 1000; + +describe('SkillRootWatcher', () => { + let base: string; + let watcher: SkillRootWatcher | undefined; + + afterEach(async () => { + watcher?.dispose(); + watcher = undefined; + if (base) await rm(base, { recursive: true, force: true }); + }); + + async function makeBase(): Promise { + base = realpathSync(await mkdtemp(join(tmpdir(), 'skill-watch-'))); + return base; + } + + function start(roots: () => Promise): { fires: () => number } { + let fires = 0; + watcher = new SkillRootWatcher( + new HostFsWatchService(), + roots, + () => { + fires += 1; + }, + ); + return { fires: () => fires }; + } + + it('debounces a burst of writes under an existing root into a single fire', async () => { + const root = join(await makeBase(), 'skills'); + await mkdir(root, { recursive: true }); + const { fires } = start(async () => [root]); + await watcher!.ready; + await wait(SETTLE_MS); + + for (let i = 0; i < 5; i += 1) { + await writeFile(join(root, `f${i}.md`), 'x'); + await wait(30); + } + await wait(DEBOUNCE_WINDOW_MS); + + expect(fires()).toBe(1); + }, 20000); + + it('fires when a root with multiple missing leading segments is created', async () => { + const root = join(await makeBase(), '.agents', 'skills'); + const { fires } = start(async () => [root]); + await watcher!.ready; + await wait(SETTLE_MS); + expect(fires()).toBe(0); + + await mkdir(join(root, 'demo'), { recursive: true }); + await writeFile(join(root, 'demo', 'SKILL.md'), 'x'); + await wait(DEBOUNCE_WINDOW_MS); + + expect(fires()).toBe(1); + }, 20000); + + it('keeps firing after the root is deleted and recreated', async () => { + const root = join(await makeBase(), 'skills'); + await mkdir(join(root, 'demo'), { recursive: true }); + await writeFile(join(root, 'demo', 'SKILL.md'), 'x'); + const { fires } = start(async () => [root]); + await watcher!.ready; + await wait(SETTLE_MS); + + await rm(root, { recursive: true, force: true }); + await wait(DEBOUNCE_WINDOW_MS); + const afterDelete = fires(); + expect(afterDelete).toBeGreaterThanOrEqual(1); + + await mkdir(join(root, 'demo'), { recursive: true }); + await writeFile(join(root, 'demo', 'SKILL.md'), 'y'); + await wait(DEBOUNCE_WINDOW_MS); + + expect(fires()).toBeGreaterThan(afterDelete); + }, 20000); + + it('re-arms onto roots returned by the resolver after refresh()', async () => { + const baseDir = await makeBase(); + const rootA = join(baseDir, 'a'); + const rootB = join(baseDir, 'b'); + await mkdir(rootA, { recursive: true }); + await mkdir(rootB, { recursive: true }); + let current: readonly string[] = [rootA]; + const { fires } = start(async () => current); + await watcher!.ready; + await wait(SETTLE_MS); + + current = [rootB]; + await watcher!.refresh(); + await wait(SETTLE_MS); + + await writeFile(join(rootA, 'a.md'), 'x'); + await writeFile(join(rootB, 'b.md'), 'x'); + await wait(DEBOUNCE_WINDOW_MS); + + expect(fires()).toBe(1); + }, 20000); + + it('stops firing after dispose', async () => { + const root = join(await makeBase(), 'skills'); + await mkdir(root, { recursive: true }); + const { fires } = start(async () => [root]); + await watcher!.ready; + await wait(SETTLE_MS); + const owned = watcher!; + watcher = undefined; + owned.dispose(); + + await writeFile(join(root, 'x.md'), 'x'); + await wait(DEBOUNCE_WINDOW_MS); + + expect(fires()).toBe(0); + }, 20000); +}); diff --git a/packages/agent-core-v2/test/app/workspaceRegistry/workspaceRegistryService.test.ts b/packages/agent-core-v2/test/app/workspaceRegistry/workspaceRegistryService.test.ts index 8532e9b589..7cb41df09c 100644 --- a/packages/agent-core-v2/test/app/workspaceRegistry/workspaceRegistryService.test.ts +++ b/packages/agent-core-v2/test/app/workspaceRegistry/workspaceRegistryService.test.ts @@ -13,7 +13,10 @@ import { import { createScopedTestHost, stubPair } from '#/_base/di/test'; import { encodeWorkDirKey, workspaceRootKey } from '#/_base/utils/workdir-slug'; import { ErrorCodes, Error2 } from '#/errors'; +import { IBootstrapService } from '#/app/bootstrap/bootstrap'; +import { CrossProcessLockService } from '#/os/backends/node-local/crossProcessLockService'; import { HostFileSystem } from '#/os/backends/node-local/hostFsService'; +import { ICrossProcessLockService } from '#/os/interface/crossProcessLock'; import { IHostFileSystem } from '#/os/interface/hostFileSystem'; import { JsonAtomicDocumentStore } from '#/persistence/backends/node-fs/atomicDocumentStore'; import { FileStorageService } from '#/persistence/backends/node-fs/fileStorageService'; @@ -23,6 +26,7 @@ import { IWorkspaceRegistry } from '#/app/workspaceRegistry/workspaceRegistry'; import { WorkspaceRegistryService } from '#/app/workspaceRegistry/workspaceRegistryService'; import { FileWorkspacePersistence } from '#/app/workspaceRegistry/fileWorkspacePersistence'; import { IWorkspacePersistence, type PersistedWorkspaceEntry } from '#/app/workspaceRegistry/workspacePersistence'; +import { stubBootstrap } from '../bootstrap/stubs'; interface SessionIndexLine { readonly sessionId: string; @@ -32,7 +36,7 @@ interface SessionIndexLine { describe('WorkspaceRegistryService (file-backed)', () => { let homeDir: string; - let currentHost: ReturnType | undefined; + const hosts: ReturnType[] = []; beforeEach(async () => { _clearScopedRegistryForTests(); @@ -54,8 +58,7 @@ describe('WorkspaceRegistryService (file-backed)', () => { }); afterEach(async () => { - currentHost?.dispose(); - currentHost = undefined; + for (const host of hosts.splice(0)) host.dispose(); await fsp.rm(homeDir, { recursive: true, force: true }); }); @@ -65,14 +68,15 @@ describe('WorkspaceRegistryService (file-backed)', () => { stubPair(IFileSystemStorageService, fileStorage), stubPair(IAtomicDocumentStore, new JsonAtomicDocumentStore(fileStorage)), stubPair(IHostFileSystem, hostFs), + stubPair(IBootstrapService, stubBootstrap(homeDir)), + stubPair(ICrossProcessLockService, new CrossProcessLockService()), ]); - currentHost = host; + hosts.push(host); return host.app.accessor.get(IWorkspaceRegistry); } function restart(): IWorkspaceRegistry { - currentHost?.dispose(); - currentHost = undefined; + hosts.pop()?.dispose(); return build(); } @@ -637,6 +641,101 @@ describe('WorkspaceRegistryService (file-backed)', () => { const afterMerge = await readWorkspacesJson(); expect(Object.keys(afterMerge.workspaces)).toEqual([unrelatedId]); }); + + it('two registries interleave createOrTouch without losing entries', async () => { + const a = build(); + const b = build(); + const dirsA: string[] = []; + const dirsB: string[] = []; + for (let i = 0; i < 4; i++) { + const da = join(homeDir, `side-a-${i}`); + const db = join(homeDir, `side-b-${i}`); + await fsp.mkdir(da); + await fsp.mkdir(db); + dirsA.push(da); + dirsB.push(db); + } + for (let i = 0; i < 4; i++) { + await Promise.all([a.createOrTouch(dirsA[i]!), b.createOrTouch(dirsB[i]!)]); + } + + const onDisk = await readWorkspacesJson(); + const expectedIds = [...dirsA, ...dirsB].map((dir) => encodeWorkDirKey(dir)).toSorted(); + expect(Object.keys(onDisk.workspaces).toSorted()).toEqual(expectedIds); + expect(onDisk.deleted_workspace_ids).toEqual([]); + for (const entry of Object.values(onDisk.workspaces)) { + expect(Object.keys(entry).toSorted()).toEqual([ + 'created_at', + 'last_opened_at', + 'name', + 'root', + ]); + } + }); + + it('interleaved delete and createOrTouch keep both the new entry and the tombstone', async () => { + const dirKeep = join(homeDir, 'keep'); + const dirDrop = join(homeDir, 'drop'); + const dirNew = join(homeDir, 'new'); + await fsp.mkdir(dirKeep); + await fsp.mkdir(dirDrop); + await fsp.mkdir(dirNew); + const a = build(); + await a.createOrTouch(dirKeep); + const drop = await a.createOrTouch(dirDrop); + const b = build(); + + await Promise.all([a.delete(drop.id), b.createOrTouch(dirNew)]); + + const onDisk = await readWorkspacesJson(); + expect(Object.keys(onDisk.workspaces).toSorted()).toEqual( + [encodeWorkDirKey(dirKeep), encodeWorkDirKey(dirNew)].toSorted(), + ); + expect(onDisk.deleted_workspace_ids).toEqual([drop.id]); + }); + + it('preserves unknown top-level and entry fields of an older-format file', async () => { + const dirA = join(homeDir, 'dir-a'); + const dirB = join(homeDir, 'dir-b'); + await fsp.mkdir(dirA); + await fsp.mkdir(dirB); + const idA = encodeWorkDirKey(dirA); + await fsp.writeFile( + join(homeDir, 'workspaces.json'), + JSON.stringify({ + version: 1, + workspaces: { + [idA]: { + root: dirA, + name: 'a', + created_at: '2024-01-01T00:00:00.000Z', + last_opened_at: '2024-01-02T00:00:00.000Z', + legacy_extra: { nested: true }, + }, + }, + deleted_workspace_ids: ['wd_gone'], + future_top_level: { since: 2 }, + }), + 'utf8', + ); + + const registry = build(); + await registry.createOrTouch(dirB); + + const onDisk = JSON.parse(await fsp.readFile(join(homeDir, 'workspaces.json'), 'utf8')) as { + version: number; + workspaces: Record>; + deleted_workspace_ids: unknown; + future_top_level?: unknown; + }; + expect(onDisk.future_top_level).toEqual({ since: 2 }); + expect(onDisk.workspaces[idA]?.['legacy_extra']).toEqual({ nested: true }); + expect(onDisk.workspaces[idA]?.['name']).toBe('a'); + expect(onDisk.workspaces[idA]?.['last_opened_at']).toBe('2024-01-02T00:00:00.000Z'); + expect(onDisk.deleted_workspace_ids).toEqual(['wd_gone']); + expect(onDisk.version).toBe(1); + expect(onDisk.workspaces[encodeWorkDirKey(dirB)]).toBeDefined(); + }); }); describe('workspaceRootKey', () => { diff --git a/packages/agent-core-v2/test/harness/agent.ts b/packages/agent-core-v2/test/harness/agent.ts index 4508410e6d..b5d6104c91 100644 --- a/packages/agent-core-v2/test/harness/agent.ts +++ b/packages/agent-core-v2/test/harness/agent.ts @@ -83,6 +83,7 @@ import { IAgentPermissionRulesService, IHostFileSystem, ISessionContext, + ISessionLeaseService, ISessionProcessRunner, IAgentScopeContext, IAgentStepRetryService, @@ -140,6 +141,7 @@ import type { PathAccessOperation } from '#/session/workspaceContext/workspaceCo import { recordAgentEvents, type RecordedEventEntry } from '../snapshot/events'; import { createFakeHostFs, createFakeProcessRunner } from '../tools/fixtures/fake-exec'; +import { stubSessionLeaseService } from '../session/sessionLease/stubs'; import { createScriptedGenerate } from './scripted-generate'; import { DEFAULT_TEST_SYSTEM_PROMPT, @@ -991,6 +993,7 @@ export class AgentTestContext { scope: (subKey?: string): string => subKey === undefined || subKey === '' ? sessionScope : `${sessionScope}/${subKey}`, }); + reg.defineInstance(ISessionLeaseService, stubSessionLeaseService()); reg.defineInstance(ISessionInteractionService, this.createInteractionService()); reg.defineInstance(ISessionApprovalService, this.createApprovalService()); reg.defineInstance(ISessionQuestionService, this.createQuestionService()); diff --git a/packages/agent-core-v2/test/index.test.ts b/packages/agent-core-v2/test/index.test.ts index b30586ffa5..9c2f7f8401 100644 --- a/packages/agent-core-v2/test/index.test.ts +++ b/packages/agent-core-v2/test/index.test.ts @@ -18,6 +18,8 @@ import { SyncDescriptor } from '#/_base/di/descriptors'; import { DisposableStore } from '#/_base/di/lifecycle'; import { TestInstantiationService } from '#/_base/di/test'; import { AppendLogStore } from '#/persistence/backends/node-fs/appendLogStore'; +import { WriteAuthorityRegistryService } from '#/persistence/backends/node-fs/writeAuthorityRegistryService'; +import { IWriteAuthorityRegistry } from '#/persistence/interface/writeAuthority'; import { InMemoryStorageService } from '#/persistence/backends/memory/inMemoryStorageService'; import { IAppendLogStore } from '#/persistence/interface/appendLogStore'; import { IFileSystemStorageService } from '#/persistence/interface/storage'; @@ -75,6 +77,7 @@ describe('v1 wire vocabulary', () => { const ix = disposables.add(new TestInstantiationService()); ix.stub(IFileSystemStorageService, new InMemoryStorageService()); ix.set(IAppendLogStore, new SyncDescriptor(AppendLogStore)); + ix.stub(IWriteAuthorityRegistry, new WriteAuthorityRegistryService()); log = ix.get(IAppendLogStore); wire = registerTestAgentWire(ix, SCOPE, { log }); }); @@ -128,6 +131,7 @@ describe('v1 wire vocabulary', () => { const ix2 = store.add(new TestInstantiationService()); ix2.stub(IFileSystemStorageService, new InMemoryStorageService()); ix2.set(IAppendLogStore, new SyncDescriptor(AppendLogStore)); + ix2.stub(IWriteAuthorityRegistry, new WriteAuthorityRegistryService()); const log2 = ix2.get(IAppendLogStore); const fresh = registerTestAgentWire(ix2, SCOPE, { log: log2 }); diff --git a/packages/agent-core-v2/test/os/backends/node-local/crossProcessLockService.test.ts b/packages/agent-core-v2/test/os/backends/node-local/crossProcessLockService.test.ts new file mode 100644 index 0000000000..03e1854768 --- /dev/null +++ b/packages/agent-core-v2/test/os/backends/node-local/crossProcessLockService.test.ts @@ -0,0 +1,602 @@ +/** + * `crossProcessLock` domain — integration tests for the node-local lock + * service against a real temporary directory. + * + * Every test constructs the service with the full fake seam (clock, self pid, + * process probe, token source, sleep) and asserts the on-disk file names and + * snake_case payload keys, not just in-memory state. Dead-pid simulation for + * the real probe uses `0x7fffffff` (guaranteed ESRCH), mirroring kap-server's + * lock tests. Timed tests use real short sleep; the fake clock is advanced by + * hand where the protocol depends on wall-clock values. + */ + +import { + existsSync, + mkdtempSync, + readFileSync, + readdirSync, + rmSync, + utimesSync, + writeFileSync, +} from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; + +import { afterEach, beforeEach, describe, expect, it } from 'vitest'; + +import { CrossProcessLockService } from '#/os/backends/node-local/crossProcessLockService'; +import { createNodeProcessProbe } from '#/os/backends/node-local/processProbe'; +import { + CrossProcessLockErrorCode, + type ICrossProcessLockHandle, + type ProcessProbe, +} from '#/os/interface/crossProcessLock'; + +const SELF_PID = 1001; +const OTHER_PID = 2002; +/** Max signed-32 pid; the kernel never allocates it, so `kill(pid, 0)` → ESRCH. */ +const DEAD_PID = 0x7fffffff; + +const realSleep = (ms: number): Promise => + new Promise((resolvePromise) => setTimeout(resolvePromise, ms)); + +let tmpDir: string; +let lockPath: string; +let nowValue: number; +let lockSeq: number; +const handles: ICrossProcessLockHandle[] = []; + +function track(handle: T): T { + handles.push(handle); + return handle; +} + +function probeFor(live: ReadonlyMap): ProcessProbe { + return (pid) => { + if (!live.has(pid)) return { alive: false }; + const startedAt = live.get(pid); + return startedAt === undefined + ? { alive: true } + : { alive: true, processStartedAt: startedAt }; + }; +} + +interface FakeServiceOptions { + selfPid?: number; + instanceId?: string; + probe?: ProcessProbe; + now?: () => number; +} + +function makeService(options: FakeServiceOptions = {}): CrossProcessLockService { + const selfPid = options.selfPid ?? SELF_PID; + return new CrossProcessLockService({ + selfPid, + instanceId: options.instanceId ?? 'inst-self', + probeProcess: options.probe ?? probeFor(new Map([[selfPid, 'self-start']])), + now: options.now ?? (() => nowValue), + newLockId: () => `lockid-${++lockSeq}`, + sleep: realSleep, + }); +} + +function liveWorld(): Map { + return new Map([ + [SELF_PID, 'self-start'], + [OTHER_PID, 'other-start'], + ]); +} + +function writePayload(payload: Record): void { + writeFileSync(lockPath, JSON.stringify(payload)); +} + +interface DiskLockJson { + lock_id?: string; + instance_id?: string; + pid?: number; + process_started_at?: string; + address?: string; + heartbeat_at?: number; + port?: number; + [extra: string]: unknown; +} + +function readDisk(): DiskLockJson { + return JSON.parse(readFileSync(lockPath, 'utf8')) as DiskLockJson; +} + +function backdate(path: string, ageMs: number): void { + const t = (Date.now() - ageMs) / 1000; + utimesSync(path, t, t); +} + +async function waitFor(cond: () => boolean, timeoutMs = 3_000): Promise { + const start = Date.now(); + for (;;) { + if (cond()) return; + if (Date.now() - start > timeoutMs) throw new Error('waitFor timed out'); + await realSleep(10); + } +} + +beforeEach(() => { + tmpDir = mkdtempSync(join(tmpdir(), 'cplock-test-')); + lockPath = join(tmpDir, 'lock'); + nowValue = 1_000_000; + lockSeq = 0; +}); + +afterEach(() => { + for (const handle of handles.splice(0)) handle.release(); + rmSync(tmpDir, { recursive: true, force: true }); +}); + +describe('acquire / release', () => { + it('writes the snake_case payload with extras flat, and release removes the file', () => { + const svc = makeService(); + const handle = track( + svc.acquire(lockPath, { + address: '127.0.0.1:58627', + extraPayload: { port: 58627, role: 'primary' }, + }), + ); + expect(handle.lockId).toBe('lockid-1'); + expect(readDisk()).toEqual({ + lock_id: 'lockid-1', + instance_id: 'inst-self', + pid: SELF_PID, + process_started_at: 'self-start', + address: '127.0.0.1:58627', + port: 58627, + role: 'primary', + }); + + handle.release(); + expect(existsSync(lockPath)).toBe(false); + }); + + it('omits optional keys when the platform cannot provide them', () => { + const svc = makeService({ probe: () => ({ alive: true }) }); + const handle = track(svc.acquire(lockPath)); + expect(readDisk()).toEqual({ + lock_id: 'lockid-1', + instance_id: 'inst-self', + pid: SELF_PID, + }); + }); + + it('creates missing parent directories and release is idempotent', () => { + const nested = join(tmpDir, 'a', 'b', 'lock'); + const svc = makeService(); + const handle = track(svc.acquire(nested)); + expect(existsSync(nested)).toBe(true); + handle.release(); + handle.release(); + expect(existsSync(nested)).toBe(false); + }); + + it('release never unlinks a foreign lock', () => { + const svc = makeService(); + const handle = track(svc.acquire(lockPath)); + handle.release(); + writePayload({ lock_id: 'someone-else', instance_id: 'x', pid: OTHER_PID }); + handle.release(); + expect(existsSync(lockPath)).toBe(true); + expect(readDisk().lock_id).toBe('someone-else'); + }); +}); + +describe('held vs takeover', () => { + it('a live identity-matching holder blocks acquisition with OS_LOCK_HELD', () => { + writePayload({ + lock_id: 'old-id', + instance_id: 'inst-other', + pid: OTHER_PID, + process_started_at: 'other-start', + }); + const svc = makeService({ probe: probeFor(liveWorld()) }); + const before = readFileSync(lockPath, 'utf8'); + + let caught: unknown; + try { + svc.acquire(lockPath); + } catch (error) { + caught = error; + } + expect(caught).toMatchObject({ + code: CrossProcessLockErrorCode.Held, + details: { reason: 'held' }, + }); + expect(readFileSync(lockPath, 'utf8')).toBe(before); + expect(readdirSync(tmpDir)).toEqual(['lock']); + }); + + it('takes over a dead holder with rename isolation', () => { + const live = liveWorld(); + const probe = probeFor(live); + const oldHandle = track( + makeService({ selfPid: OTHER_PID, instanceId: 'inst-other', probe }).acquire( + lockPath, + { extraPayload: { port: 1 } }, + ), + ); + const oldDisk = JSON.parse(readFileSync(lockPath, 'utf8')) as Record; + + live.delete(OTHER_PID); + const handle = track(makeService({ probe }).acquire(lockPath)); + + expect(existsSync(`${lockPath}.stale.lockid-1`)).toBe(true); + expect(JSON.parse(readFileSync(`${lockPath}.stale.lockid-1`, 'utf8'))).toEqual(oldDisk); + expect(readDisk().lock_id).toBe('lockid-2'); + expect(handle.lockId).toBe('lockid-2'); + + expect(oldHandle.checkHeld()).toBe(false); + oldHandle.release(); + expect(existsSync(lockPath)).toBe(true); + expect(readDisk().lock_id).toBe('lockid-2'); + }); + + it('treats a live pid with mismatched identity as stale (pid reused)', () => { + writePayload({ + lock_id: 'old-id', + instance_id: 'inst-other', + pid: OTHER_PID, + process_started_at: 'original-start', + }); + const live = liveWorld(); + live.set(OTHER_PID, 'reused-start'); + const svc = makeService({ probe: probeFor(live) }); + + expect(svc.inspect(lockPath)).toMatchObject({ + state: 'stale', + staleReason: 'pid-reused', + }); + track(svc.acquire(lockPath)); + expect(existsSync(`${lockPath}.stale.old-id`)).toBe(true); + expect(readDisk().lock_id).toBe('lockid-1'); + }); + + it('refuses takeover when the holder identity is unavailable (conservative held)', () => { + writePayload({ + lock_id: 'old-id', + instance_id: 'inst-other', + pid: OTHER_PID, + process_started_at: 'original-start', + }); + const live = liveWorld(); + live.set(OTHER_PID, undefined); + const svc = makeService({ probe: probeFor(live) }); + + expect(svc.inspect(lockPath)).toMatchObject({ state: 'held', unavailableReason: 'held' }); + expect(() => svc.acquire(lockPath)).toThrowError( + expect.objectContaining({ code: CrossProcessLockErrorCode.Held }), + ); + expect(readDisk().lock_id).toBe('old-id'); + expect(readdirSync(tmpDir)).toEqual(['lock']); + }); + + it('takes over a legacy payload without lock_id, renamed aside as unknown', () => { + writePayload({ pid: OTHER_PID, started_at: '1', port: 58627 }); + const svc = makeService(); + + expect(svc.inspect(lockPath)).toMatchObject({ + state: 'stale', + staleReason: 'holder-dead', + payload: { lockId: '', pid: OTHER_PID, port: 58627 }, + }); + track(svc.acquire(lockPath)); + expect(existsSync(`${lockPath}.stale.unknown`)).toBe(true); + expect(readDisk().lock_id).toBe('lockid-1'); + }); +}); + +describe('creation window', () => { + it('an empty file inside the window is creating', () => { + writeFileSync(lockPath, ''); + const svc = makeService({ now: () => Date.now() }); + + expect(svc.inspect(lockPath)).toMatchObject({ state: 'creating' }); + let caught: unknown; + try { + svc.acquire(lockPath); + } catch (error) { + caught = error; + } + expect(caught).toMatchObject({ + code: CrossProcessLockErrorCode.Held, + details: { reason: 'creating' }, + }); + expect(readFileSync(lockPath, 'utf8')).toBe(''); + expect(readdirSync(tmpDir)).toEqual(['lock']); + }); + + it('an empty file past the window is taken over as stale', () => { + writeFileSync(lockPath, ''); + backdate(lockPath, 10_000); + const svc = makeService({ now: () => Date.now() }); + + expect(svc.inspect(lockPath)).toMatchObject({ + state: 'stale', + staleReason: 'creation-window-expired', + }); + track(svc.acquire(lockPath)); + expect(existsSync(`${lockPath}.stale.unknown`)).toBe(true); + expect(readDisk().lock_id).toBe('lockid-1'); + }); + + it('an unparseable file follows the same window', () => { + writeFileSync(lockPath, '{oops'); + const svc = makeService({ now: () => Date.now() }); + + expect(svc.inspect(lockPath)).toMatchObject({ state: 'creating' }); + backdate(lockPath, 10_000); + expect(svc.inspect(lockPath)).toMatchObject({ + state: 'stale', + staleReason: 'creation-window-expired', + }); + track(svc.acquire(lockPath)); + expect(existsSync(`${lockPath}.stale.unknown`)).toBe(true); + expect(readDisk().instance_id).toBe('inst-self'); + }); +}); + +describe('heartbeat mode', () => { + it('beats heartbeat_at into the disk payload through the kept fd', async () => { + const svc = makeService(); + const handle = track( + svc.acquire(lockPath, { heartbeat: { intervalMs: 20, ttlMs: 60_000 } }), + ); + expect(readDisk().heartbeat_at).toBe(1_000_000); + + nowValue = 1_000_500; + await waitFor(() => readDisk().heartbeat_at === 1_000_500); + handle.release(); + expect(existsSync(lockPath)).toBe(false); + }); + + it('a taken-over holder detects the loss on its next beat and fires onLost exactly once', async () => { + const live = liveWorld(); + const probe = probeFor(live); + let lostCount = 0; + const oldHandle = track( + makeService({ probe }).acquire(lockPath, { + heartbeat: { intervalMs: 20, ttlMs: 60_000 }, + onLost: () => { + lostCount += 1; + }, + }), + ); + + live.delete(SELF_PID); + track( + makeService({ selfPid: OTHER_PID, instanceId: 'inst-b', probe }).acquire(lockPath), + ); + + await waitFor(() => lostCount === 1); + await realSleep(100); + expect(lostCount).toBe(1); + expect(oldHandle.checkHeld()).toBe(false); + expect(readDisk().lock_id).toBe('lockid-2'); + }); + + it('a silent heartbeat with a live identity-matching pid is holder-unresponsive, never seized', () => { + writePayload({ + lock_id: 'old-id', + instance_id: 'inst-other', + pid: OTHER_PID, + process_started_at: 'other-start', + heartbeat_at: nowValue - 10_000, + }); + const svc = makeService({ probe: probeFor(liveWorld()) }); + const before = readFileSync(lockPath, 'utf8'); + + let caught: unknown; + try { + svc.acquire(lockPath, { heartbeat: { intervalMs: 100, ttlMs: 5_000 } }); + } catch (error) { + caught = error; + } + expect(caught).toMatchObject({ + code: CrossProcessLockErrorCode.Held, + details: { reason: 'holder-unresponsive' }, + }); + expect(readFileSync(lockPath, 'utf8')).toBe(before); + expect(readdirSync(tmpDir)).toEqual(['lock']); + }); + + it('a fresh heartbeat with a live holder is a plain held', () => { + writePayload({ + lock_id: 'old-id', + instance_id: 'inst-other', + pid: OTHER_PID, + process_started_at: 'other-start', + heartbeat_at: nowValue - 100, + }); + const svc = makeService({ probe: probeFor(liveWorld()) }); + + let caught: unknown; + try { + svc.acquire(lockPath, { heartbeat: { intervalMs: 100, ttlMs: 5_000 } }); + } catch (error) { + caught = error; + } + expect(caught).toMatchObject({ + code: CrossProcessLockErrorCode.Held, + details: { reason: 'held' }, + }); + expect(readDisk().lock_id).toBe('old-id'); + }); +}); + +describe('acquireWithWait / withLock', () => { + it('a waiting acquirer obtains the lock after the holder releases', async () => { + const live = liveWorld(); + const probe = probeFor(live); + const holder = track(makeService({ probe }).acquire(lockPath)); + + const waiter = makeService({ + selfPid: OTHER_PID, + instanceId: 'inst-b', + probe, + now: () => Date.now(), + }); + const acquired: string[] = []; + const pending = waiter.withLock( + lockPath, + { wait: { timeoutMs: 5_000, retryIntervalMs: 5 } }, + (handle) => { + acquired.push(handle.lockId); + return 'done'; + }, + ); + await realSleep(50); + holder.release(); + + await expect(pending).resolves.toBe('done'); + expect(acquired).toEqual(['lockid-2']); + expect(existsSync(lockPath)).toBe(false); + }); + + it('a waiting acquirer gives up with OS_LOCK_WAIT_TIMEOUT', async () => { + const live = liveWorld(); + const probe = probeFor(live); + track(makeService({ probe }).acquire(lockPath)); + + const waiter = makeService({ + selfPid: OTHER_PID, + instanceId: 'inst-b', + probe, + now: () => Date.now(), + }); + await expect( + waiter.acquireWithWait(lockPath, { wait: { timeoutMs: 100, retryIntervalMs: 5 } }), + ).rejects.toMatchObject({ code: CrossProcessLockErrorCode.WaitTimeout }); + expect(readDisk().lock_id).toBe('lockid-1'); + }); +}); + +describe('update', () => { + it('rewrites extras and re-stamps the protocol keys', () => { + const svc = makeService(); + const handle = track(svc.acquire(lockPath, { extraPayload: { port: 1 } })); + + handle.update((payload) => ({ + ...payload, + port: 58627, + lockId: 'evil', + instanceId: 'evil', + pid: 9999, + })); + expect(readDisk()).toEqual({ + lock_id: 'lockid-1', + instance_id: 'inst-self', + pid: SELF_PID, + process_started_at: 'self-start', + port: 58627, + }); + expect(handle.lockId).toBe('lockid-1'); + }); + + it('a later update keeps the extras the heartbeat rewrites', async () => { + const svc = makeService(); + const handle = track( + svc.acquire(lockPath, { + heartbeat: { intervalMs: 20, ttlMs: 60_000 }, + extraPayload: { port: 1 }, + }), + ); + handle.update((payload) => ({ ...payload, port: 58627 })); + nowValue = 1_000_700; + await waitFor( + () => readDisk().port === 58627 && readDisk().heartbeat_at === 1_000_700, + ); + expect(readDisk()).toMatchObject({ + lock_id: 'lockid-1', + port: 58627, + heartbeat_at: 1_000_700, + }); + handle.release(); + }); + + it('update after a takeover throws OS_LOCK_LOST', () => { + const live = liveWorld(); + const probe = probeFor(live); + const oldHandle = track( + makeService({ probe }).acquire(lockPath, { extraPayload: { port: 1 } }), + ); + live.delete(SELF_PID); + track( + makeService({ selfPid: OTHER_PID, instanceId: 'inst-b', probe }).acquire(lockPath), + ); + + expect(() => { + oldHandle.update((payload) => ({ ...payload, port: 2 })); + }).toThrowError(expect.objectContaining({ code: CrossProcessLockErrorCode.Lost })); + expect(readDisk().lock_id).toBe('lockid-2'); + expect(readDisk().port).toBeUndefined(); + }); +}); + +describe('inspect', () => { + it('free when the file is missing', () => { + const svc = makeService(); + expect(svc.inspect(lockPath)).toEqual({ state: 'free' }); + }); + + it('held with payload passthrough for a live holder', () => { + writePayload({ + lock_id: 'x', + instance_id: 'inst-other', + pid: OTHER_PID, + process_started_at: 'other-start', + address: '127.0.0.1:1', + port: 58627, + }); + const svc = makeService({ probe: probeFor(liveWorld()) }); + + expect(svc.inspect(lockPath)).toMatchObject({ + state: 'held', + unavailableReason: 'held', + payload: { + lockId: 'x', + instanceId: 'inst-other', + pid: OTHER_PID, + processStartedAt: 'other-start', + address: '127.0.0.1:1', + port: 58627, + }, + }); + }); + + it('stale holder-dead for a gone pid', () => { + writePayload({ lock_id: 'x', instance_id: 'inst-other', pid: OTHER_PID }); + const svc = makeService(); + expect(svc.inspect(lockPath)).toMatchObject({ + state: 'stale', + staleReason: 'holder-dead', + payload: { lockId: 'x', pid: OTHER_PID }, + }); + }); +}); + +describe('createNodeProcessProbe', () => { + it('reports the current process alive with a stable identity token', () => { + const probe = createNodeProcessProbe(); + const first = probe(process.pid); + expect(first.alive).toBe(true); + // Modern macOS exposes no named per-pid starttime OID, so the token is + // legitimately absent on darwin; linux always has one via /proc. + if (process.platform === 'linux') { + expect(first.processStartedAt).toBeDefined(); + } + if (first.processStartedAt !== undefined) { + expect(probe(process.pid).processStartedAt).toBe(first.processStartedAt); + } + }); + + it('reports a guaranteed-absent pid dead, without a token', () => { + const probe = createNodeProcessProbe(); + expect(probe(DEAD_PID)).toEqual({ alive: false }); + }); +}); diff --git a/packages/agent-core-v2/test/os/backends/node-local/hostFsWatchService.test.ts b/packages/agent-core-v2/test/os/backends/node-local/hostFsWatchService.test.ts index d4cc51e68e..5b3299bd6d 100644 --- a/packages/agent-core-v2/test/os/backends/node-local/hostFsWatchService.test.ts +++ b/packages/agent-core-v2/test/os/backends/node-local/hostFsWatchService.test.ts @@ -4,6 +4,7 @@ */ import { mkdtemp, mkdir, rm, writeFile } from 'node:fs/promises'; +import { createServer } from 'node:net'; import { tmpdir } from 'node:os'; import { join } from 'node:path'; @@ -86,4 +87,23 @@ describe('HostFsWatchService', () => { expect(events).toHaveLength(0); }); + + it('ignores unix sockets and other special files instead of erroring', async () => { + root = await mkdtemp(join(tmpdir(), 'hostfswatch-')); + // A unix socket inside a watched dir makes chokidar's per-entry fs.watch + // call throw UNKNOWN — special files must be filtered, never watched. + const sockPath = join(root, 'special.sock'); + const server = createServer(); + await new Promise((resolve) => server.listen(sockPath, resolve)); + + const events = await start(); + await writeFile(join(root, 'real.txt'), 'x'); + await wait(300); + server.close(); + + expect(events.some((e) => e.path === sockPath)).toBe(false); + expect(events.some((e) => e.path === join(root, 'real.txt') && e.action === 'created')).toBe( + true, + ); + }); }); diff --git a/packages/agent-core-v2/test/os/stubs.ts b/packages/agent-core-v2/test/os/stubs.ts new file mode 100644 index 0000000000..3d0cdb277a --- /dev/null +++ b/packages/agent-core-v2/test/os/stubs.ts @@ -0,0 +1,62 @@ +/** + * `os` test stubs — shared cross-process-lock pass-through for unit tests. + * + * `stubCrossProcessLock()` mirrors the real service's mutual-exclusion + * semantics (a held path rejects further acquisitions) without touching the + * filesystem, for tests whose suite is otherwise fully in-memory. Lives under + * `test/` (not `src/`) so test-support code stays out of the production tree. + * Import from a relative path (`./stubs` or `../os/stubs`). + */ + +import { + CrossProcessLockError, + CrossProcessLockErrorCode, + type CrossProcessLockInspection, + type ICrossProcessLockHandle, + type ICrossProcessLockService, +} from '#/os/interface/crossProcessLock'; + +export function stubCrossProcessLock(): ICrossProcessLockService { + const held = new Set(); + const acquire = (lockPath: string): ICrossProcessLockHandle => { + if (held.has(lockPath)) { + throw new CrossProcessLockError( + CrossProcessLockErrorCode.Held, + `cross-process lock unavailable (held): ${lockPath}`, + { details: { path: lockPath, reason: 'held' } }, + ); + } + held.add(lockPath); + let released = false; + return { + lockPath, + lockId: 'stub-lock', + checkHeld: () => !released, + update: () => {}, + release: () => { + if (released) return; + released = true; + held.delete(lockPath); + }, + }; + }; + return { + _serviceBrand: undefined, + acquire, + acquireWithWait: (lockPath) => Promise.resolve(acquire(lockPath)), + withLock: async ( + lockPath: string, + _options: Parameters[1], + fn: (handle: ICrossProcessLockHandle) => T | Promise, + ): Promise => { + const handle = acquire(lockPath); + try { + return await fn(handle); + } finally { + handle.release(); + } + }, + inspect: (lockPath): CrossProcessLockInspection => + held.has(lockPath) ? { state: 'held' } : { state: 'free' }, + }; +} diff --git a/packages/agent-core-v2/test/persistence/backends/node-fs/appendLogStore.test.ts b/packages/agent-core-v2/test/persistence/backends/node-fs/appendLogStore.test.ts index a6b24eb762..ce4a20ed7c 100644 --- a/packages/agent-core-v2/test/persistence/backends/node-fs/appendLogStore.test.ts +++ b/packages/agent-core-v2/test/persistence/backends/node-fs/appendLogStore.test.ts @@ -7,15 +7,27 @@ * test/persistence/backends/node-fs/appendLogStore.test.ts`. */ +import { mkdtempSync, rmSync, writeFileSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; + import { afterEach, beforeEach, describe, expect, it } from 'vitest'; import { SyncDescriptor } from '#/_base/di/descriptors'; import { DisposableStore } from '#/_base/di/lifecycle'; import { TestInstantiationService } from '#/_base/di/test'; +import { ErrorCodes } from '#/errors'; import { AppendLogCorruptedError, IAppendLogStore } from '#/persistence/interface/appendLogStore'; import { IFileSystemStorageService } from '#/persistence/interface/storage'; import { AppendLogStore } from '#/persistence/backends/node-fs/appendLogStore'; +import { WriteAuthorityRegistryService } from '#/persistence/backends/node-fs/writeAuthorityRegistryService'; +import { + sessionIdFromScope, + IWriteAuthorityRegistry, +} from '#/persistence/interface/writeAuthority'; import { InMemoryStorageService } from '#/persistence/backends/memory/inMemoryStorageService'; +import { CrossProcessLockService } from '#/os/backends/node-local/crossProcessLockService'; +import { SessionLease, sessionLeasePath } from '#/session/sessionLease/sessionLease'; const enc = new TextEncoder(); @@ -54,6 +66,7 @@ describe('AppendLogStore', () => { storage = new InMemoryStorageService(); ix.stub(IFileSystemStorageService, storage); ix.set(IAppendLogStore, new SyncDescriptor(AppendLogStore)); + ix.stub(IWriteAuthorityRegistry, new WriteAuthorityRegistryService()); record = ix.get(IAppendLogStore); }); @@ -612,6 +625,7 @@ describe('AppendLogStore', () => { const localIx = disposables.add(new TestInstantiationService()); localIx.stub(IFileSystemStorageService, chunkedStorage(chunks)); localIx.set(IAppendLogStore, new SyncDescriptor(AppendLogStore)); + localIx.stub(IWriteAuthorityRegistry, new WriteAuthorityRegistryService()); const log = localIx.get(IAppendLogStore); const out: Rec[] = []; @@ -628,6 +642,7 @@ describe('AppendLogStore', () => { const localIx = disposables.add(new TestInstantiationService()); localIx.stub(IFileSystemStorageService, chunkedStorage(chunks)); localIx.set(IAppendLogStore, new SyncDescriptor(AppendLogStore)); + localIx.stub(IWriteAuthorityRegistry, new WriteAuthorityRegistryService()); const log = localIx.get(IAppendLogStore); const first: Array<{ type: string }> = []; @@ -652,6 +667,7 @@ describe('AppendLogStore', () => { const localIx = disposables.add(new TestInstantiationService()); localIx.stub(IFileSystemStorageService, chunkedStorage(chunks)); localIx.set(IAppendLogStore, new SyncDescriptor(AppendLogStore)); + localIx.stub(IWriteAuthorityRegistry, new WriteAuthorityRegistryService()); const log = localIx.get(IAppendLogStore); const readAll = async (): Promise> => { @@ -671,6 +687,7 @@ describe('AppendLogStore', () => { const localIx = disposables.add(new TestInstantiationService()); localIx.stub(IFileSystemStorageService, chunkedStorage(chunks)); localIx.set(IAppendLogStore, new SyncDescriptor(AppendLogStore)); + localIx.stub(IWriteAuthorityRegistry, new WriteAuthorityRegistryService()); const log = localIx.get(IAppendLogStore); const out: Array = []; @@ -680,4 +697,150 @@ describe('AppendLogStore', () => { { n: 2, s: '日本語' }, ]); }); + + describe('write fencing', () => { + const SESSION_SCOPE = 'sessions/wd_test/s1'; + let tmpDir: string; + let locks: CrossProcessLockService; + let registry: WriteAuthorityRegistryService; + + beforeEach(() => { + tmpDir = mkdtempSync(join(tmpdir(), 'kimi-als-fence-')); + locks = new CrossProcessLockService(); + registry = new WriteAuthorityRegistryService(); + }); + + afterEach(() => { + rmSync(tmpDir, { recursive: true, force: true }); + }); + + function makeStore(): { store: IAppendLogStore; storage: InMemoryStorageService } { + const storage = new InMemoryStorageService(); + const localIx = disposables.add(new TestInstantiationService()); + localIx.stub(IFileSystemStorageService, storage); + localIx.stub(IWriteAuthorityRegistry, registry); + localIx.set(IAppendLogStore, new SyncDescriptor(AppendLogStore)); + return { store: localIx.get(IAppendLogStore), storage }; + } + + function leaseFor(sessionId: string): SessionLease { + return new SessionLease( + sessionId, + locks.acquire(sessionLeasePath(tmpDir, sessionId)), + () => {}, + ); + } + + function foreignPayload(tmp: string, sessionId: string): void { + writeFileSync( + sessionLeasePath(tmp, sessionId), + JSON.stringify({ lock_id: 'peer-token', pid: process.pid }), + ); + } + + it('parses the session id out of session and agent scopes only', () => { + expect(sessionIdFromScope('')).toBeUndefined(); + expect(sessionIdFromScope('sessions')).toBeUndefined(); + expect(sessionIdFromScope('sessions/wd_test')).toBeUndefined(); + expect(sessionIdFromScope('sessions//s1')).toBeUndefined(); + expect(sessionIdFromScope('wire')).toBeUndefined(); + expect(sessionIdFromScope('agents/main')).toBeUndefined(); + expect(sessionIdFromScope('credentials/x')).toBeUndefined(); + expect(sessionIdFromScope('sessions/wd_test/s1')).toBe('s1'); + expect(sessionIdFromScope('sessions/wd_test/s1/agents/main')).toBe('s1'); + }); + + it('flush rejects with session.lease_lost when the lease is gone and writes no bytes', async () => { + const lease = leaseFor('s1'); + registry.register(lease); + const { store, storage } = makeStore(); + let appendAttempts = 0; + const originalAppend = storage.append.bind(storage); + storage.append = async (...args) => { + appendAttempts++; + return originalAppend(...args); + }; + foreignPayload(tmpDir, 's1'); + + store.append(SESSION_SCOPE, KEY, { n: 1 }); + await expect(store.flush()).rejects.toMatchObject({ + code: ErrorCodes.SESSION_LEASE_LOST, + }); + // Sticky like any ambiguous storage failure: the buffer does not retry. + await expect(store.flush()).rejects.toMatchObject({ + code: ErrorCodes.SESSION_LEASE_LOST, + }); + expect(appendAttempts).toBe(0); + expect(await storage.read(SESSION_SCOPE, KEY)).toBeUndefined(); + }); + + it('rewrite is fenced by the same hard gate', async () => { + const lease = leaseFor('s1'); + registry.register(lease); + const { store, storage } = makeStore(); + let writeAttempts = 0; + const originalWrite = storage.write.bind(storage); + storage.write = async (...args) => { + writeAttempts++; + return originalWrite(...args); + }; + foreignPayload(tmpDir, 's1'); + + await expect(store.rewrite(SESSION_SCOPE, KEY, [{ n: 1 }])).rejects.toMatchObject({ + code: ErrorCodes.SESSION_LEASE_LOST, + }); + expect(writeAttempts).toBe(0); + expect(await storage.read(SESSION_SCOPE, KEY)).toBeUndefined(); + }); + + it('root-scope appends bypass the gate with zero authorities registered', async () => { + const { store, storage } = makeStore(); + store.append('', 'session_index.jsonl', { sessionId: 's1' }); + await store.flush(); + const bytes = await storage.read('', 'session_index.jsonl'); + expect(new TextDecoder().decode(bytes)).toBe('{"sessionId":"s1"}\n'); + }); + + it('session-scoped writes without a registered authority fail closed', async () => { + const { store, storage } = makeStore(); + store.append(SESSION_SCOPE, KEY, { n: 1 }); + await expect(store.flush()).rejects.toMatchObject({ + code: ErrorCodes.SESSION_LEASE_LOST, + message: 'session has no registered write authority', + details: { sessionId: 's1' }, + }); + expect(await storage.read(SESSION_SCOPE, KEY)).toBeUndefined(); + }); + + it('writes pass while the registered lease is held', async () => { + const lease = leaseFor('s1'); + registry.register(lease); + const { store, storage } = makeStore(); + store.append(SESSION_SCOPE, KEY, { n: 1 }); + await store.flush(); + const bytes = await storage.read(SESSION_SCOPE, KEY); + expect(new TextDecoder().decode(bytes)).toBe('{"n":1}\n'); + }); + + it('flush awaits the retired buffer final flush before returning', async () => { + const lease = leaseFor('s1'); + registry.register(lease); + const { store, storage } = makeStore(); + const handle = store.acquire(SESSION_SCOPE, KEY); + store.append(SESSION_SCOPE, KEY, { n: 1 }); + handle.dispose(); + await store.flush(); + const bytes = await storage.read(SESSION_SCOPE, KEY); + expect(new TextDecoder().decode(bytes)).toBe('{"n":1}\n'); + }); + + it('double registration for a session is rejected and dispose unregisters', () => { + const registration = registry.register({ sessionId: 's1', assertWritable: () => {} }); + expect(() => registry.register({ sessionId: 's1', assertWritable: () => {} })).toThrow( + /already registered/, + ); + registration.dispose(); + expect(registry.resolve('s1')).toBeUndefined(); + }); + }); }); diff --git a/packages/agent-core-v2/test/session/sessionFileLedger/fileLedger.test.ts b/packages/agent-core-v2/test/session/sessionFileLedger/fileLedger.test.ts new file mode 100644 index 0000000000..31489b7fe2 --- /dev/null +++ b/packages/agent-core-v2/test/session/sessionFileLedger/fileLedger.test.ts @@ -0,0 +1,295 @@ +/** + * `sessionFileLedger` domain (L2) — verifies the optimistic-concurrency + * verdict matrix (clean / stale / no-baseline) against a real tmpdir, a real + * `HostFileSystem` (stat-call counted) and a fake os watcher: baselines only + * refresh on success, dirty ticks come from the watch service's folded + * state, watcher echoes of the session's own writes punch a stat and + * re-baseline, truncated windows fall back to the per-root dirty tick, and + * out-of-root targets degrade to a stat-only comparison. + */ + +import { mkdtempSync, rmSync, writeFileSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; + +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; + +import { LifecycleScope } from '#/_base/di/scope'; +import { createScopedTestHost, stubPair, type ScopedTestHost } from '#/_base/di/test'; +import { HostFileSystem } from '#/os/backends/node-local/hostFsService'; +import { IHostFileSystem } from '#/os/interface/hostFileSystem'; +import { IHostFsWatchService } from '#/os/interface/hostFsWatch'; +import { ISessionContext, makeSessionContext } from '#/session/sessionContext/sessionContext'; +import { ISessionFileLedger } from '#/session/sessionFileLedger/fileLedger'; +import { SessionFileLedger } from '#/session/sessionFileLedger/fileLedgerService'; +import { ISessionFsWatchService } from '#/session/sessionFs/fsWatch'; +import { SessionFsWatchService } from '#/session/sessionFs/fsWatchService'; +import { ISessionWorkspaceContext } from '#/session/workspaceContext/workspaceContext'; +import { SessionWorkspaceContextService } from '#/session/workspaceContext/workspaceContextService'; + +import { fakeHostFsWatch, type FakeWatch } from '../sessionFs/stubs'; + +void SessionFileLedger; +void SessionFsWatchService; +void SessionWorkspaceContextService; + +const JUNK_EVENT_COUNT = 501; + +function countingHostFs(poisonedPaths: Set): { + fs: IHostFileSystem; + statCalls: () => number; +} { + const real = new HostFileSystem(); + let count = 0; + const fs = new Proxy(real, { + get(target, prop, receiver) { + if (prop === 'stat') { + return async (path: string) => { + count += 1; + if (poisonedPaths.has(path)) { + const err = new Error(`EACCES: permission denied, stat '${path}'`) as NodeJS.ErrnoException; + err.code = 'EACCES'; + throw err; + } + return target.stat(path); + }; + } + return Reflect.get(target, prop, receiver); + }, + }) as IHostFileSystem; + return { fs, statCalls: () => count }; +} + +interface World { + readonly workDir: string; + readonly outsideDir: string; + readonly ledger: ISessionFileLedger; + readonly watch: ISessionFsWatchService; + readonly workspace: ISessionWorkspaceContext; + readonly fake: FakeWatch; + readonly statCalls: () => number; + readonly poisonedPaths: Set; +} + +function makeWorld(): World { + const workDir = mkdtempSync(join(tmpdir(), 'kimi-ledger-work-')); + const outsideDir = mkdtempSync(join(tmpdir(), 'kimi-ledger-out-')); + cleanupPaths.push(workDir, outsideDir); + const fake = fakeHostFsWatch(); + const poisonedPaths = new Set(); + const { fs, statCalls } = countingHostFs(poisonedPaths); + const host = createScopedTestHost([ + stubPair(IHostFileSystem, fs), + stubPair(IHostFsWatchService, fake.service), + ]); + const session = host.child(LifecycleScope.Session, 's1', [ + stubPair( + ISessionContext, + makeSessionContext({ + sessionId: 's1', + workspaceId: 'ws', + sessionDir: join(workDir, '.session'), + sessionScope: 'sessions/ws/s1', + cwd: workDir, + }), + ), + ]); + hosts.push(host); + return { + workDir, + outsideDir, + ledger: session.accessor.get(ISessionFileLedger), + watch: session.accessor.get(ISessionFsWatchService), + workspace: session.accessor.get(ISessionWorkspaceContext), + fake, + statCalls, + poisonedPaths, + }; +} + +function foldJunkEvents(world: World, count: number = JUNK_EVENT_COUNT): void { + for (let i = 0; i < count; i++) world.fake.fire(`junk-${i}.tmp`, 'created'); + vi.advanceTimersByTime(200); +} + +const hosts: ScopedTestHost[] = []; +const cleanupPaths: string[] = []; + +describe('SessionFileLedger', () => { + beforeEach(() => { + vi.useFakeTimers(); + }); + afterEach(() => { + for (const host of hosts.splice(0)) host.dispose(); + for (const path of cleanupPaths.splice(0)) rmSync(path, { recursive: true, force: true }); + vi.useRealTimers(); + }); + + it('returns clean for a baselined file with no changes', async () => { + const world = makeWorld(); + const file = join(world.workDir, 'a.txt'); + writeFileSync(file, 'hello'); + + await world.ledger.recordBaseline(file); + expect(await world.ledger.compare(file)).toBe('clean'); + }); + + it('returns no-baseline for an existing file never read or written', async () => { + const world = makeWorld(); + const file = join(world.workDir, 'a.txt'); + writeFileSync(file, 'hello'); + + expect(await world.ledger.compare(file)).toBe('no-baseline'); + }); + + it('returns clean for a missing file (new-file creation is exempt)', async () => { + const world = makeWorld(); + expect(await world.ledger.compare(join(world.workDir, 'new.txt'))).toBe('clean'); + }); + + it('returns stale for a dirty path without a baseline', async () => { + const world = makeWorld(); + const file = join(world.workDir, 'a.txt'); + writeFileSync(file, 'hello'); + + world.fake.fire('a.txt', 'modified'); + vi.advanceTimersByTime(200); + + expect(await world.ledger.compare(file)).toBe('stale'); + }); + + it('returns stale when a baselined file is modified outside the session', async () => { + const world = makeWorld(); + const file = join(world.workDir, 'a.txt'); + writeFileSync(file, 'hello'); + await world.ledger.recordBaseline(file); + + writeFileSync(file, 'hello world'); + world.fake.fire('a.txt', 'modified'); + vi.advanceTimersByTime(200); + + expect(await world.ledger.compare(file)).toBe('stale'); + }); + + it('absorbs the watcher echo of the session own write and re-baselines the tick', async () => { + const world = makeWorld(); + const file = join(world.workDir, 'a.txt'); + writeFileSync(file, 'hello'); + await world.ledger.recordBaseline(file); + expect(world.statCalls()).toBe(1); + + world.fake.fire('a.txt', 'modified'); + vi.advanceTimersByTime(200); + + expect(await world.ledger.compare(file)).toBe('clean'); + expect(world.statCalls()).toBe(2); + + expect(await world.ledger.compare(file)).toBe('clean'); + expect(world.statCalls()).toBe(2); + }); + + it('keeps a baselined file clean through an untouched truncated window', async () => { + const world = makeWorld(); + const file = join(world.workDir, 'a.txt'); + writeFileSync(file, 'hello'); + await world.ledger.recordBaseline(file); + + foldJunkEvents(world); + expect(world.watch.rootDirtyTickFor(world.workDir)).toBeGreaterThan(0); + + expect(await world.ledger.compare(file)).toBe('clean'); + expect(world.statCalls()).toBe(2); + expect(await world.ledger.compare(file)).toBe('clean'); + expect(world.statCalls()).toBe(2); + }); + + it('detects an outside modification through a truncated window via the stat punch', async () => { + const world = makeWorld(); + const file = join(world.workDir, 'a.txt'); + writeFileSync(file, 'hello'); + await world.ledger.recordBaseline(file); + + writeFileSync(file, 'hello world'); + foldJunkEvents(world); + + expect(await world.ledger.compare(file)).toBe('stale'); + }); + + it('tracks a write-then-delete baseline as non-existence', async () => { + const world = makeWorld(); + const file = join(world.workDir, 'a.txt'); + writeFileSync(file, 'hello'); + await world.ledger.recordBaseline(file); + + rmSync(file); + world.fake.fire('a.txt', 'deleted'); + vi.advanceTimersByTime(200); + expect(await world.ledger.compare(file)).toBe('stale'); + + await world.ledger.recordBaseline(file); + expect(await world.ledger.compare(file)).toBe('clean'); + + writeFileSync(file, 'recreated'); + world.fake.fire('a.txt', 'created'); + vi.advanceTimersByTime(200); + expect(await world.ledger.compare(file)).toBe('stale'); + }); + + it('falls back to the stat-only comparison outside every watched root', async () => { + const world = makeWorld(); + const file = join(world.outsideDir, 'b.txt'); + writeFileSync(file, 'hello'); + + expect(await world.ledger.compare(file)).toBe('no-baseline'); + + await world.ledger.recordBaseline(file); + expect(await world.ledger.compare(file)).toBe('clean'); + + writeFileSync(file, 'hello world'); + expect(await world.ledger.compare(file)).toBe('stale'); + + await world.ledger.recordBaseline(file); + expect(await world.ledger.compare(file)).toBe('clean'); + + rmSync(file); + expect(await world.ledger.compare(file)).toBe('stale'); + }); + + it('adds a later additional dir as a watched root when a target falls under it', async () => { + const world = makeWorld(); + expect(world.fake.watchCalls).toEqual([world.workDir]); + + world.workspace.addAdditionalDir(world.outsideDir); + const file = join(world.outsideDir, 'b.txt'); + writeFileSync(file, 'hello'); + + expect(await world.ledger.compare(file)).toBe('no-baseline'); + expect(world.fake.watchCalls).toContain(world.outsideDir); + expect(world.watch.watchedRoots).toContain(world.outsideDir); + + world.fake.handles + .find((h) => h.root === world.outsideDir) + ?.fire('b.txt', 'modified'); + vi.advanceTimersByTime(200); + expect(await world.ledger.compare(file)).toBe('stale'); + }); + + it('degrades to clean when stat fails for reasons other than not-found', async () => { + const world = makeWorld(); + const file = join(world.workDir, 'a.txt'); + writeFileSync(file, 'hello'); + world.poisonedPaths.add(file); + + await world.ledger.recordBaseline(file); + expect(await world.ledger.compare(file)).toBe('clean'); + + world.poisonedPaths.clear(); + await world.ledger.recordBaseline(file); + world.poisonedPaths.add(file); + world.fake.fire('a.txt', 'modified'); + vi.advanceTimersByTime(200); + + expect(await world.ledger.compare(file)).toBe('clean'); + expect(world.statCalls()).toBeGreaterThan(0); + }); +}); diff --git a/packages/agent-core-v2/test/session/sessionFs/fsService.test.ts b/packages/agent-core-v2/test/session/sessionFs/fsService.test.ts index 32de5cef24..27182479bb 100644 --- a/packages/agent-core-v2/test/session/sessionFs/fsService.test.ts +++ b/packages/agent-core-v2/test/session/sessionFs/fsService.test.ts @@ -336,11 +336,12 @@ function makeSession( symlinks: readonly string[] = [], runner?: ISessionProcessRunner, symlinkTargets: Record = {}, + hostFs?: IHostFileSystem, ): ISessionFsService { host = createScopedTestHost(); const session = host.child(LifecycleScope.Session, 's1', [ stubPair(ISessionWorkspaceContext, stubWorkspace()), - stubPair(IHostFileSystem, fakeFs(files, symlinks, symlinkTargets)), + stubPair(IHostFileSystem, hostFs ?? fakeFs(files, symlinks, symlinkTargets)), stubPair(ISessionProcessRunner, runner ?? fakeRunner(handler)), stubPair(ITelemetryService, telemetryStub(events)), stubPair(IGitService, git), @@ -625,6 +626,97 @@ describe('SessionFsService.list', () => { }); }); +describe('SessionFsService gitignore cache invalidation', () => { + interface GitignoreState { + content: string | undefined; + mtimeMs: number; + } + + function gitignoreFs(state: GitignoreState, files: Record): IHostFileSystem { + const base = fakeFs(files); + const enoent = (p: string): NodeJS.ErrnoException => { + const err = new Error(`ENOENT: ${p}`) as NodeJS.ErrnoException; + err.code = 'ENOENT'; + return err; + }; + const gitignorePath = join(WORK_DIR, '.gitignore'); + return { + ...base, + readText: async (p) => { + if (p === gitignorePath) { + if (state.content === undefined) throw enoent(p); + return state.content; + } + return base.readText(p); + }, + stat: async (p) => { + if (p === gitignorePath) { + if (state.content === undefined) throw enoent(p); + return { + isFile: true, + isDirectory: false, + size: state.content.length, + mtimeMs: state.mtimeMs, + ino: 1, + }; + } + return base.stat(p); + }, + }; + } + + it('list() picks up `.gitignore` edits without rebuilding the service', async () => { + const state: GitignoreState = { content: 'dist/\n', mtimeMs: 1000 }; + const fs = makeSession( + {}, + emptyHandler, + [], + defaultGitStub(), + [], + undefined, + {}, + gitignoreFs(state, { 'src/keep.ts': '', 'dist/x.js': '' }), + ); + const baseReq = { + path: '.', + depth: 1, + limit: 200, + show_hidden: false, + follow_gitignore: true, + sort: 'name_asc' as const, + include_git_status: false, + }; + const before = await fs.list(baseReq); + expect(before.items.map((i) => i.name).sort()).toEqual(['src']); + + state.content = ''; + state.mtimeMs = 2000; + const after = await fs.list(baseReq); + expect(after.items.map((i) => i.name).sort()).toEqual(['dist', 'src']); + }); + + it('search() rebuilds the matcher when `.gitignore` is removed', async () => { + const state: GitignoreState = { content: 'dist/\n', mtimeMs: 1000 }; + const fs = makeSession( + {}, + emptyHandler, + [], + defaultGitStub(), + [], + undefined, + {}, + gitignoreFs(state, { 'dist/x.js': '' }), + ); + const before = await fs.search({ query: 'x.js', limit: 50, follow_gitignore: true }); + expect(before.items).toHaveLength(0); + + state.content = undefined; + state.mtimeMs = 2000; + const after = await fs.search({ query: 'x.js', limit: 50, follow_gitignore: true }); + expect(after.items.map((i) => i.path)).toEqual(['dist/x.js']); + }); +}); + describe('SessionFsService.read', () => { it('reads utf-8 content with metadata', async () => { const fs = makeSession({ 'src/a.ts': 'hello\nworld\n' }, emptyHandler); diff --git a/packages/agent-core-v2/test/session/sessionFs/fsWatchService.test.ts b/packages/agent-core-v2/test/session/sessionFs/fsWatchService.test.ts index 08868dd713..48e4239fc9 100644 --- a/packages/agent-core-v2/test/session/sessionFs/fsWatchService.test.ts +++ b/packages/agent-core-v2/test/session/sessionFs/fsWatchService.test.ts @@ -11,17 +11,15 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; import { LifecycleScope } from '#/_base/di/scope'; import { createScopedTestHost, stubPair } from '#/_base/di/test'; import { IHostFileSystem } from '#/os/interface/hostFileSystem'; -import { - type HostFsChange, - type IHostFsWatchHandle, - IHostFsWatchService, -} from '#/os/interface/hostFsWatch'; +import { IHostFsWatchService } from '#/os/interface/hostFsWatch'; import { ISessionWorkspaceContext } from '#/session/workspaceContext/workspaceContext'; import type { FsChangeEvent } from '#/session/sessionFs/fsWatch'; -import { ISessionFsWatchService } from '#/session/sessionFs/fsWatch'; +import { ISessionFsWatchService, isFsWatchKeyWithin, normalizeFsWatchKey } from '#/session/sessionFs/fsWatch'; import { SessionFsWatchService } from '#/session/sessionFs/fsWatchService'; +import { fakeHostFsWatch, type FakeWatch } from './stubs'; + const WORK_DIR = '/repo'; void SessionFsWatchService; @@ -44,44 +42,6 @@ function stubWorkspace(): ISessionWorkspaceContext { }; } -interface FakeWatch { - readonly service: IHostFsWatchService; - readonly watchCalls: string[]; - fire: (rel: string, action: HostFsChange['action'], kind?: HostFsChange['kind']) => void; - readonly disposed: () => boolean; -} - -function fakeHostFsWatch(): FakeWatch { - const watchCalls: string[] = []; - let listener: ((e: HostFsChange) => void) | undefined; - let disposed = false; - const handle: IHostFsWatchHandle = { - onDidChange: (l) => { - listener = l; - return { dispose: () => (listener = undefined) }; - }, - dispose: () => { - disposed = true; - listener = undefined; - }, - }; - const service: IHostFsWatchService = { - _serviceBrand: undefined, - watch: (path) => { - watchCalls.push(path); - disposed = false; - return handle; - }, - }; - return { - service, - watchCalls, - fire: (rel, action, kind = 'file') => - listener?.({ path: join(WORK_DIR, rel), action, kind }), - disposed: () => disposed, - }; -} - function fakeHostFs(gitignore?: string): IHostFileSystem { return { _serviceBrand: undefined, @@ -100,13 +60,13 @@ interface Harness { readonly events: FsChangeEvent[]; } -function makeSession(gitignore?: string): Harness { +function makeSession(gitignore?: string, hostFs?: IHostFileSystem): Harness { const watch = fakeHostFsWatch(); const host = createScopedTestHost(); const session = host.child(LifecycleScope.Session, 's1', [ stubPair(ISessionWorkspaceContext, stubWorkspace()), stubPair(IHostFsWatchService, watch.service), - stubPair(IHostFileSystem, fakeHostFs(gitignore)), + stubPair(IHostFileSystem, hostFs ?? fakeHostFs(gitignore)), ]); const svc = session.accessor.get(ISessionFsWatchService); const events: FsChangeEvent[] = []; @@ -186,6 +146,77 @@ describe('SessionFsWatchService', () => { expect(events[0]?.changes.map((c) => c.path)).toEqual(['src/keep.ts']); }); + it('lets events through while the initial `.gitignore` load is in flight', async () => { + let releaseLoad: ((content: string) => void) | undefined; + const pendingLoad = new Promise((res) => { + releaseLoad = res; + }); + const hostFs = { + _serviceBrand: undefined, + readText: async (p: string) => { + if (p === join(WORK_DIR, '.gitignore')) return pendingLoad; + const err = new Error(`ENOENT: ${p}`) as NodeJS.ErrnoException; + err.code = 'ENOENT'; + throw err; + }, + } as unknown as IHostFileSystem; + const { svc, watch, events } = makeSession(undefined, hostFs); + svc.setWatchedPaths(['.']); + + // The rules are not loaded yet: filtering is conservative (only `.git/`), + // so a path that will later turn out to be ignored still gets delivered. + watch.fire('dist/x.js', 'created'); + vi.advanceTimersByTime(200); + expect(events).toHaveLength(1); + expect(events[0]?.changes.map((c) => c.path)).toEqual(['dist/x.js']); + + releaseLoad!('dist/\n'); + await pendingLoad; + await Promise.resolve(); + await Promise.resolve(); + + watch.fire('dist/y.js', 'created'); + vi.advanceTimersByTime(200); + expect(events).toHaveLength(1); + }); + + it('rebuilds the matcher when the workspace `.gitignore` changes', async () => { + let gitignore = 'dist/\n'; + const hostFs = { + _serviceBrand: undefined, + readText: async (p: string) => { + if (p === join(WORK_DIR, '.gitignore')) return gitignore; + const err = new Error(`ENOENT: ${p}`) as NodeJS.ErrnoException; + err.code = 'ENOENT'; + throw err; + }, + } as unknown as IHostFileSystem; + const { svc, watch, events } = makeSession(undefined, hostFs); + svc.setWatchedPaths(['.']); + await Promise.resolve(); + await Promise.resolve(); + + watch.fire('dist/a.js', 'created'); + vi.advanceTimersByTime(200); + expect(events).toHaveLength(0); + + gitignore = 'build/\n'; + watch.fire('.gitignore', 'modified'); + await Promise.resolve(); + await Promise.resolve(); + vi.advanceTimersByTime(200); + + watch.fire('dist/b.js', 'created'); + watch.fire('build/c.js', 'created'); + vi.advanceTimersByTime(200); + + // The `.gitignore` change itself is a delivered event; afterwards + // `dist/` passes (no longer ignored) and `build/` is filtered. + expect(events).toHaveLength(2); + expect(events[0]?.changes.map((c) => c.path)).toEqual(['.gitignore']); + expect(events[1]?.changes.map((c) => c.path)).toEqual(['dist/b.js']); + }); + it('rejects paths that escape the workspace', () => { const { svc } = makeSession(); expect(() => svc.setWatchedPaths(['../x'])).toThrowError(/escapes workspace|rejected/); @@ -209,3 +240,102 @@ describe('SessionFsWatchService', () => { expect(events).toHaveLength(0); }); }); + +describe('fsWatch key helpers', () => { + it('normalizes keys lexically and folds case on macOS/Windows', () => { + const folded = process.platform === 'darwin' || process.platform === 'win32'; + expect(normalizeFsWatchKey('/A//B/../C')).toBe(folded ? '/a/c' : '/A/C'); + }); + + it('checks containment with a separator boundary', () => { + expect(isFsWatchKeyWithin('/a/b', '/a/b')).toBe(true); + expect(isFsWatchKeyWithin('/a/b/c', '/a/b')).toBe(true); + expect(isFsWatchKeyWithin('/a/bc', '/a/b')).toBe(false); + expect(isFsWatchKeyWithin('/a', '/a/b')).toBe(false); + }); +}); + +describe('SessionFsWatchService ensured roots and dirty ticks', () => { + beforeEach(() => { + vi.useFakeTimers(); + }); + afterEach(() => { + for (const d of disposers.splice(0)) d(); + vi.useRealTimers(); + }); + + it('starts the os watcher for an ensured root and reports it as a watched root', () => { + const { svc, watch } = makeSession(); + svc.ensureWatchedRoots([WORK_DIR]); + expect(watch.watchCalls).toEqual([WORK_DIR]); + expect(svc.watchedRoots).toEqual([WORK_DIR]); + expect(svc.watchedPaths).toEqual([]); + }); + + it('keeps the os watcher alive when client subscriptions empty but ensured roots remain', () => { + const { svc, watch } = makeSession(); + svc.ensureWatchedRoots([WORK_DIR]); + svc.setWatchedPaths(['src']); + svc.setWatchedPaths([]); + expect(watch.disposed()).toBe(false); + expect(svc.watchedRoots).toEqual([WORK_DIR]); + }); + + it('skips ensured roots already covered by an existing watched root', () => { + const { svc, watch } = makeSession(); + svc.ensureWatchedRoots([WORK_DIR]); + svc.ensureWatchedRoots([join(WORK_DIR, 'sub')]); + expect(watch.watchCalls).toEqual([WORK_DIR]); + }); + + it('watches an ensured root outside the workspace with a dedicated handle folded into dirty state only', () => { + const EXT = '/ext-root'; + const { svc, watch, events } = makeSession(); + svc.ensureWatchedRoots([EXT]); + expect(new Set(watch.watchCalls)).toEqual(new Set([WORK_DIR, EXT])); + const ext = watch.handles.find((h) => h.root === EXT); + expect(ext).toBeDefined(); + + ext!.fire('a.ts', 'modified'); + vi.advanceTimersByTime(200); + + expect(svc.dirtyTickFor(join(EXT, 'a.ts'))).toBe(1); + expect(events).toEqual([]); + }); + + it('increments the tick per confined change and folds per-path dirty ticks at flush', () => { + const { svc, watch } = makeSession(); + svc.ensureWatchedRoots([WORK_DIR]); + expect(svc.currentTick).toBe(0); + + const a = join(WORK_DIR, 'a.ts'); + watch.fire('a.ts', 'created'); + expect(svc.currentTick).toBe(1); + expect(svc.dirtyTickFor(a)).toBeUndefined(); + vi.advanceTimersByTime(200); + expect(svc.dirtyTickFor(a)).toBe(1); + + watch.fire('b.ts', 'modified'); + vi.advanceTimersByTime(200); + expect(svc.dirtyTickFor(join(WORK_DIR, 'b.ts'))).toBe(2); + expect(svc.dirtyTickFor(a)).toBe(1); + }); + + it('marks every watched root dirty for a truncated window', () => { + const { svc, watch } = makeSession(); + svc.ensureWatchedRoots([WORK_DIR]); + for (let i = 0; i < 501; i++) watch.fire(`f${i}.ts`, 'created'); + vi.advanceTimersByTime(200); + + expect(svc.dirtyTickFor(join(WORK_DIR, 'f0.ts'))).toBeUndefined(); + expect(svc.rootDirtyTickFor(WORK_DIR)).toBe(501); + }); + + it('folds buffered dirty signals when the window is cleared without flushing', () => { + const { svc, watch } = makeSession(); + svc.setWatchedPaths(['src']); + watch.fire('src/a.ts', 'modified'); + svc.setWatchedPaths([]); + expect(svc.dirtyTickFor(join(WORK_DIR, 'src/a.ts'))).toBe(1); + }); +}); diff --git a/packages/agent-core-v2/test/session/sessionFs/stubs.ts b/packages/agent-core-v2/test/session/sessionFs/stubs.ts new file mode 100644 index 0000000000..ede06c648f --- /dev/null +++ b/packages/agent-core-v2/test/session/sessionFs/stubs.ts @@ -0,0 +1,68 @@ +/** + * `sessionFs` test stubs — controllable multi-handle fake host watcher. + * + * `fakeHostFsWatch()` mirrors `IHostFsWatchService.watch()` semantics (one + * independent handle per call) without touching the real filesystem: tests + * fire synthetic changes at a chosen handle and advance the debounce window + * with fake timers. Import from a relative path (`./stubs` or + * `../sessionFs/stubs`). + */ + +import { join } from 'node:path'; + +import { + type HostFsChange, + type IHostFsWatchHandle, + IHostFsWatchService, +} from '#/os/interface/hostFsWatch'; + +export interface FakeWatchHandle { + readonly root: string; + fire: (rel: string, action: HostFsChange['action'], kind?: HostFsChange['kind']) => void; + readonly disposed: () => boolean; +} + +export interface FakeWatch { + readonly service: IHostFsWatchService; + readonly watchCalls: string[]; + readonly handles: FakeWatchHandle[]; + fire: (rel: string, action: HostFsChange['action'], kind?: HostFsChange['kind']) => void; + readonly disposed: () => boolean; +} + +export function fakeHostFsWatch(): FakeWatch { + const watchCalls: string[] = []; + const handles: FakeWatchHandle[] = []; + const service: IHostFsWatchService = { + _serviceBrand: undefined, + watch: (path) => { + watchCalls.push(path); + let listener: ((e: HostFsChange) => void) | undefined; + let disposed = false; + const handle: IHostFsWatchHandle = { + onDidChange: (l) => { + listener = l; + return { dispose: () => (listener = undefined) }; + }, + dispose: () => { + disposed = true; + listener = undefined; + }, + }; + handles.push({ + root: path, + fire: (rel, action, kind = 'file') => + listener?.({ path: join(path, rel), action, kind }), + disposed: () => disposed, + }); + return handle; + }, + }; + return { + service, + watchCalls, + handles, + fire: (rel, action, kind = 'file') => handles.at(-1)?.fire(rel, action, kind), + disposed: () => handles.every((h) => h.disposed()), + }; +} diff --git a/packages/agent-core-v2/test/session/sessionLease/sessionLease.test.ts b/packages/agent-core-v2/test/session/sessionLease/sessionLease.test.ts new file mode 100644 index 0000000000..51472b7d7d --- /dev/null +++ b/packages/agent-core-v2/test/session/sessionLease/sessionLease.test.ts @@ -0,0 +1,161 @@ +/** + * `sessionLease` domain — unit tests for the per-session write lease. + * + * Runs against the real node-local cross-process lock service (pid-only + * handles: no heartbeat timers) rooted at a mkdtemp home, asserting on-disk + * lease payload contents, the once-only loss notification, the idempotent + * token-guarded release, and the contact-provider seed semantics (default + * local, seed override wins — the exact production wiring). + */ + +import { existsSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; + +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; + +import { createScopedTestHost, type ScopedTestHost } from '#/_base/di/test'; +import { Error2, ErrorCodes } from '#/errors'; +import { CrossProcessLockService } from '#/os/backends/node-local/crossProcessLockService'; +import { + ISessionLeaseContactProvider, + sessionLeaseContactSeed, +} from '#/session/sessionLease/sessionLeaseContactProvider'; +import { + HOLDER_UNRESPONSIVE_RETRY_AFTER_MS, + LEASE_CREATING_RETRY_AFTER_MS, + SessionLease, + sessionLeasePath, + SESSION_LEASE_HEARTBEAT_INTERVAL_MS, + SESSION_LEASE_TTL_MS, + UNREGISTERED_WRITER_RECHECK_DELAY_MS, + UNREGISTERED_WRITER_WINDOW_MS, +} from '#/session/sessionLease/sessionLease'; + +let tmpDir: string; +let locks: CrossProcessLockService; +const hosts: ScopedTestHost[] = []; + +beforeEach(() => { + tmpDir = mkdtempSync(join(tmpdir(), 'kimi-session-lease-')); + locks = new CrossProcessLockService(); +}); + +afterEach(() => { + for (const host of hosts.splice(0)) host.dispose(); + rmSync(tmpDir, { recursive: true, force: true }); +}); + +function acquire(sessionId = 's1', onLost: (sessionId: string) => void = () => {}): SessionLease { + return new SessionLease(sessionId, locks.acquire(sessionLeasePath(tmpDir, sessionId)), onLost); +} + +function thrownError(fn: () => void): Error2 { + try { + fn(); + } catch (error) { + return error as Error2; + } + throw new Error('expected the call to throw'); +} + +function hostWith(seeds: Parameters[0] = []): ScopedTestHost { + const host = createScopedTestHost(seeds); + hosts.push(host); + return host; +} + +describe('SessionLease', () => { + it('reports its identity through info and passes the hard gate while held', () => { + const lease = acquire(); + expect(lease.checkHeld()).toBe(true); + expect(lease.info).toEqual({ sessionId: 's1', lockId: lease.lockId }); + expect(() => lease.assertWritable()).not.toThrow(); + lease.release(); + }); + + it('fails closed with session.lease_lost once the payload no longer carries its token', () => { + const onLost = vi.fn(); + const lease = acquire('s1', onLost); + writeFileSync( + sessionLeasePath(tmpDir, 's1'), + JSON.stringify({ lock_id: 'peer-token', pid: process.pid }), + ); + + expect(lease.checkHeld()).toBe(false); + expect(thrownError(() => lease.assertWritable()).code).toBe(ErrorCodes.SESSION_LEASE_LOST); + // Loss fires exactly once across every detection path. + expect(onLost).toHaveBeenCalledTimes(1); + expect(onLost).toHaveBeenCalledWith('s1'); + lease.markLost(); + expect(onLost).toHaveBeenCalledTimes(1); + expect(thrownError(() => lease.assertWritable()).code).toBe(ErrorCodes.SESSION_LEASE_LOST); + expect(onLost).toHaveBeenCalledTimes(1); + }); + + it('release is idempotent, unlinks the owned file, and later assertions throw', () => { + const lease = acquire(); + lease.release(); + lease.release(); + + expect(lease.released).toBe(true); + expect(lease.info).toBeUndefined(); + expect(existsSync(sessionLeasePath(tmpDir, 's1'))).toBe(false); + expect(thrownError(() => lease.assertWritable()).code).toBe(ErrorCodes.SESSION_LEASE_LOST); + }); + + it('release never unlinks a payload owned by a peer', () => { + const lease = acquire(); + writeFileSync( + sessionLeasePath(tmpDir, 's1'), + JSON.stringify({ lock_id: 'peer-token', pid: process.pid }), + ); + lease.release(); + + expect(lease.released).toBe(true); + const payload = JSON.parse(readFileSync(sessionLeasePath(tmpDir, 's1'), 'utf8')); + expect(payload.lock_id).toBe('peer-token'); + }); + + it('exported constants pin the documented protocol timings', () => { + expect(SESSION_LEASE_HEARTBEAT_INTERVAL_MS).toBe(2000); + expect(SESSION_LEASE_TTL_MS).toBe(6000); + expect(LEASE_CREATING_RETRY_AFTER_MS).toBe(1000); + expect(HOLDER_UNRESPONSIVE_RETRY_AFTER_MS).toBe(2000); + expect(UNREGISTERED_WRITER_WINDOW_MS).toBe(5000); + expect(UNREGISTERED_WRITER_RECHECK_DELAY_MS).toBe(1000); + }); + + it('sessionLeasePath lives under /session-leases/', () => { + expect(sessionLeasePath('/home/kimi', 'abc')).toBe( + join('/home/kimi', 'session-leases', 'abc.json'), + ); + }); +}); + +describe('session lease contact provider', () => { + it('resolves a local contact by default when the host seeds nothing', () => { + const host = hostWith(); + expect(host.app.accessor.get(ISessionLeaseContactProvider).contact()).toEqual({ + type: 'local', + }); + }); + + it('the seed overrides the registered default with the host address', () => { + const host = hostWith( + sessionLeaseContactSeed(() => ({ type: 'address', address: 'http://127.0.0.1:8080' })), + ); + expect(host.app.accessor.get(ISessionLeaseContactProvider).contact()).toEqual({ + type: 'address', + address: 'http://127.0.0.1:8080', + }); + }); + + it('evaluates the contact lazily at every lease acquisition', () => { + let contact: { type: 'address'; address: string } | { type: 'local' } = { type: 'local' }; + const host = hostWith(sessionLeaseContactSeed(() => contact)); + const provider = host.app.accessor.get(ISessionLeaseContactProvider); + contact = { type: 'address', address: 'http://127.0.0.1:9999' }; + expect(provider.contact()).toEqual({ type: 'address', address: 'http://127.0.0.1:9999' }); + }); +}); diff --git a/packages/agent-core-v2/test/session/sessionLease/stubs.ts b/packages/agent-core-v2/test/session/sessionLease/stubs.ts new file mode 100644 index 0000000000..c03a39bf6b --- /dev/null +++ b/packages/agent-core-v2/test/session/sessionLease/stubs.ts @@ -0,0 +1,20 @@ +/** + * `sessionLease` test stubs — no-op `ISessionLeaseService` for unit tests. + * + * Lives under `test/` (not `src/`). The default stub reports no lease info + * and passes every `assertWritable` gate; tests that exercise fencing pass an + * `assertWritable` override that throws. Import from a relative path. + */ + +import { ISessionLeaseService } from '#/session/sessionLease/sessionLease'; + +export function stubSessionLeaseService( + overrides: Partial = {}, +): ISessionLeaseService { + return { + _serviceBrand: undefined, + info: undefined, + assertWritable: () => {}, + ...overrides, + }; +} diff --git a/packages/agent-core-v2/test/session/sessionMetadata/sessionMetadata.test.ts b/packages/agent-core-v2/test/session/sessionMetadata/sessionMetadata.test.ts index b98e6b7c80..0a6f07e3d0 100644 --- a/packages/agent-core-v2/test/session/sessionMetadata/sessionMetadata.test.ts +++ b/packages/agent-core-v2/test/session/sessionMetadata/sessionMetadata.test.ts @@ -5,7 +5,9 @@ import { DisposableStore } from '#/_base/di/lifecycle'; import { TestInstantiationService } from '#/_base/di/test'; import { IFlagService } from '#/app/flag/flag'; import { ILogService } from '#/_base/log/log'; +import { Error2, ErrorCodes } from '#/errors'; import { ISessionContext, makeSessionContext } from '#/session/sessionContext/sessionContext'; +import { ISessionLeaseService } from '#/session/sessionLease/sessionLease'; import { ISessionMetadata } from '#/session/sessionMetadata/sessionMetadata'; import { SessionMetadata } from '#/session/sessionMetadata/sessionMetadataService'; import { JsonAtomicDocumentStore } from '#/persistence/backends/node-fs/atomicDocumentStore'; @@ -17,6 +19,7 @@ import { IQueryStore } from '#/persistence/interface/queryStore'; import { stubFlag } from '../../app/flag/stubs'; import { stubLog } from '../../_base/log/stubs'; import { stubQueryStore } from '../../persistence/interface/stubs'; +import { stubSessionLeaseService } from '../sessionLease/stubs'; const META_SCOPE = 'sessions/wd_test/s1/session-meta'; @@ -42,6 +45,7 @@ describe('SessionMetadata', () => { ix.stub(ISessionContext, makeContext()); ix.stub(IQueryStore, stubQueryStore()); ix.stub(IFlagService, stubFlag(false)); + ix.stub(ISessionLeaseService, stubSessionLeaseService()); ix.set(IFileSystemStorageService, new SyncDescriptor(InMemoryStorageService)); ix.set(IAtomicDocumentStore, new SyncDescriptor(JsonAtomicDocumentStore)); ix.set(ISessionMetadata, new SyncDescriptor(SessionMetadata)); @@ -247,4 +251,61 @@ describe('SessionMetadata', () => { expect(next.agents?.['main']?.labels).toEqual({ swarmItem: 'src/a.ts' }); expect(next.updatedAt).toBeGreaterThan(before); }); + + it('gates updates behind the lease and leaves state.json untouched when it fails', async () => { + let leaseLost = false; + ix.stub( + ISessionLeaseService, + stubSessionLeaseService({ + assertWritable: () => { + if (leaseLost) { + throw new Error2(ErrorCodes.SESSION_LEASE_LOST, 'lease lost', { + details: { sessionId: 's1' }, + }); + } + }, + }), + ); + const meta = ix.get(ISessionMetadata); + await meta.ready; + + leaseLost = true; + await expect(meta.update({ title: 'x' })).rejects.toMatchObject({ + code: ErrorCodes.SESSION_LEASE_LOST, + }); + + const raw = await ix + .get(IAtomicDocumentStore) + .get<{ title?: string }>(META_SCOPE, 'state.json'); + expect(raw?.title).toBeUndefined(); + }); + + it('gates the load-time heal write behind the lease', async () => { + const store = ix.get(IAtomicDocumentStore); + await store.set(META_SCOPE, 'state.json', { + id: 's1', + version: 2, + createdAt: 1700000000000, + updatedAt: 1700000000000, + archived: false, + }); + ix.stub( + ISessionLeaseService, + stubSessionLeaseService({ + assertWritable: () => { + throw new Error2(ErrorCodes.SESSION_LEASE_LOST, 'lease lost', { + details: { sessionId: 's1' }, + }); + }, + }), + ); + + const meta = ix.get(ISessionMetadata); + await expect(meta.ready).rejects.toMatchObject({ + code: ErrorCodes.SESSION_LEASE_LOST, + }); + + const raw = await store.get<{ agents?: unknown }>(META_SCOPE, 'state.json'); + expect(raw?.agents).toBeUndefined(); + }); }); diff --git a/packages/agent-core-v2/test/session/sessionSkillCatalog/skillCatalog.test.ts b/packages/agent-core-v2/test/session/sessionSkillCatalog/skillCatalog.test.ts index cd839d090e..d177101128 100644 --- a/packages/agent-core-v2/test/session/sessionSkillCatalog/skillCatalog.test.ts +++ b/packages/agent-core-v2/test/session/sessionSkillCatalog/skillCatalog.test.ts @@ -34,6 +34,7 @@ import { ISessionSkillCatalog } from '#/session/sessionSkillCatalog/skillCatalog import { IPluginSkillSource } from '#/session/sessionSkillCatalog/pluginSkillSource'; import { ISkillDiscovery } from '#/app/skillCatalog/skillDiscovery'; import type { SkillRoot } from '#/app/skillCatalog/types'; +import { IHostFsWatchService, type IHostFsWatchHandle } from '#/os/interface/hostFsWatch'; import { stubBootstrap } from '../../app/bootstrap/stubs'; import { stubSkill } from '../../app/skillCatalog/stubs'; @@ -41,6 +42,16 @@ import { stubProviderService } from '../../app/provider/stubs'; const bootstrapStub = stubBootstrap('/home'); +function hostFsWatchStub(): IHostFsWatchService { + return { + _serviceBrand: undefined, + watch: (): IHostFsWatchHandle => ({ + onDidChange: (): { dispose(): void } => ({ dispose: () => {} }), + dispose: () => {}, + }), + }; +} + function configStub(): IConfigService & { setExtraSkillDirs(dirs: readonly string[]): void; setMergeAllAvailableSkills(value: boolean): void; @@ -153,6 +164,7 @@ function makeHost( stubPair(IConfigService, config), stubPair(ISkillCatalogRuntimeOptions, runtimeOptions), stubPair(IPluginService, pluginStub(pluginRoots, pluginReloadEmitter)), + stubPair(IHostFsWatchService, hostFsWatchStub()), ]); const session = host.child(LifecycleScope.Session, 's1', [stubPair(ISessionWorkspaceContext, ws)]); return { host, session, config }; @@ -328,6 +340,7 @@ describe('SessionSkillCatalogService', () => { stubPair(IConfigService, config), stubPair(ISkillCatalogRuntimeOptions, runtimeOptions), stubPair(IPluginService, pluginStub()), + stubPair(IHostFsWatchService, hostFsWatchStub()), ]); const session = host.child(LifecycleScope.Session, 's1', [stubPair(ISessionWorkspaceContext, ws)]); @@ -367,6 +380,7 @@ describe('SessionSkillCatalogService', () => { stubPair(IConfigService, config), stubPair(ISkillCatalogRuntimeOptions, runtimeOptions), stubPair(IPluginService, pluginStub()), + stubPair(IHostFsWatchService, hostFsWatchStub()), ]); const session = host.child(LifecycleScope.Session, 's1', [stubPair(ISessionWorkspaceContext, ws)]); @@ -584,6 +598,7 @@ describe('SessionSkillCatalogService', () => { _serviceBrand: undefined, } as unknown as ISkillCatalogRuntimeOptions), stubPair(IPluginService, pluginStub()), + stubPair(IHostFsWatchService, hostFsWatchStub()), ]); const session = host.child(LifecycleScope.Session, 's1', [ stubPair(ISessionWorkspaceContext, ws), @@ -643,6 +658,7 @@ describe('SessionSkillCatalogService', () => { _serviceBrand: undefined, } as unknown as ISkillCatalogRuntimeOptions), stubPair(IPluginService, pluginService), + stubPair(IHostFsWatchService, hostFsWatchStub()), ]); const session = host.child(LifecycleScope.Session, 's1', [ stubPair(ISessionWorkspaceContext, ws), @@ -701,6 +717,7 @@ describe('SessionSkillCatalogService', () => { _serviceBrand: undefined, } as unknown as ISkillCatalogRuntimeOptions), stubPair(IProviderService, stubProviderService()), + stubPair(IHostFsWatchService, hostFsWatchStub()), ]); const { stub: ws } = workspaceStub('/work'); const session = host.child(LifecycleScope.Session, 's1', [ diff --git a/packages/agent-core-v2/test/session/sessionSkillCatalog/skillHotReload.test.ts b/packages/agent-core-v2/test/session/sessionSkillCatalog/skillHotReload.test.ts new file mode 100644 index 0000000000..42d2193ca4 --- /dev/null +++ b/packages/agent-core-v2/test/session/sessionSkillCatalog/skillHotReload.test.ts @@ -0,0 +1,340 @@ +/** + * Scenario: skill-catalog hot reload — filesystem changes under watched skill + * roots drive each file-backed source's `onDidChange`, the serialized catalog + * remerge, and expose the new skills through `ISessionSkillCatalog`. + * + * Exercises the real chokidar watcher (`HostFsWatchService`) and real + * `FileSkillDiscovery` over temporary directories, wired through the real + * DI scope tree. Run: `pnpm --filter @moonshot-ai/agent-core-v2 exec vitest + * run test/session/sessionSkillCatalog/skillHotReload.test.ts`. + */ + +import { realpathSync } from 'node:fs'; +import { mkdir, mkdtemp, rm, writeFile } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; + +import { join } from 'pathe'; +import { afterEach, describe, expect, it } from 'vitest'; + +import { createScopedTestHost, stubPair, type ScopedTestHost } from '#/_base/di/test'; +import { SyncDescriptor } from '#/_base/di/descriptors'; +import { LifecycleScope, type Scope } from '#/_base/di/scope'; +import '#/index'; +import { IBootstrapService } from '#/app/bootstrap/bootstrap'; +import { IConfigService } from '#/app/config/config'; +import { ILogService } from '#/_base/log/log'; +import { IPluginService } from '#/app/plugin/plugin'; +import { + EXTRA_SKILL_DIRS_SECTION, + MERGE_ALL_AVAILABLE_SKILLS_SECTION, +} from '#/app/skillCatalog/configSection'; +import { FileSkillDiscovery } from '#/app/skillCatalog/fileSkillDiscovery'; +import { ISkillDiscovery } from '#/app/skillCatalog/skillDiscovery'; +import { ISkillCatalogRuntimeOptions } from '#/app/skillCatalog/skillCatalogRuntimeOptions'; +import { IHostFsWatchService } from '#/os/interface/hostFsWatch'; +import { HostFsWatchService } from '#/os/backends/node-local/hostFsWatchService'; +import { ISessionSkillCatalog } from '#/session/sessionSkillCatalog/skillCatalog'; +import { ISessionWorkspaceContext } from '#/session/workspaceContext/workspaceContext'; + +import { stubLog } from '../../_base/log/stubs'; +import { stubBootstrap } from '../../app/bootstrap/stubs'; + +const wait = (ms: number): Promise => new Promise((r) => setTimeout(r, ms)); + +function configStub(): IConfigService & { + setExtraSkillDirs(dirs: readonly string[]): void; + fireSectionChange(domain: string): void; +} { + let extraSkillDirs: readonly string[] = []; + const sectionChangeListeners: Array<(event: unknown) => void> = []; + return { + _serviceBrand: undefined, + ready: Promise.resolve(), + onDidChangeConfiguration: () => ({ dispose: () => {} }), + onDidSectionChange: (listener: (event: unknown) => void) => { + sectionChangeListeners.push(listener); + return { dispose: () => {} }; + }, + get: (domain: string) => { + if (domain === EXTRA_SKILL_DIRS_SECTION) return [...extraSkillDirs]; + if (domain === MERGE_ALL_AVAILABLE_SKILLS_SECTION) return true; + return undefined; + }, + inspect: () => ({ value: undefined, defaultValue: undefined, userValue: undefined, memoryValue: undefined }), + getAll: () => ({}), + set: async () => {}, + replace: async () => {}, + reload: async () => {}, + diagnostics: () => [], + setExtraSkillDirs: (dirs: readonly string[]) => { + extraSkillDirs = [...dirs]; + }, + fireSectionChange: (domain: string) => { + for (const listener of sectionChangeListeners) { + listener({ domain, source: 'set', value: undefined, previousValue: undefined }); + } + }, + } as unknown as IConfigService & { + setExtraSkillDirs(dirs: readonly string[]): void; + fireSectionChange(domain: string): void; + }; +} + +function pluginStub(): IPluginService { + return { + _serviceBrand: undefined, + onDidReload: () => ({ dispose: () => {} }), + listPlugins: async () => [], + installPlugin: async () => ({ id: '' }) as never, + setPluginEnabled: async () => {}, + setPluginMcpServerEnabled: async () => {}, + removePlugin: async () => {}, + reloadPlugins: async () => ({ added: [], removed: [], errors: [] }), + getPluginInfo: async () => { + throw new Error('getPluginInfo is not used by these tests'); + }, + listPluginCommands: async () => [], + checkUpdates: async () => [], + pluginSkillRoots: async () => [], + enabledSessionStarts: async () => [], + enabledMcpServers: async () => ({}), + enabledHooks: async () => [], + }; +} + +function workspaceStub(workDir: string): ISessionWorkspaceContext { + return { + _serviceBrand: undefined, + workDir, + additionalDirs: [], + setWorkDir: () => {}, + setAdditionalDirs: () => {}, + resolve: (rel: string) => rel, + isWithin: () => true, + assertAllowed: (p: string) => p, + addAdditionalDir: () => {}, + removeAdditionalDir: () => {}, + } satisfies ISessionWorkspaceContext; +} + +interface HotReloadFixture { + readonly host: ScopedTestHost; + readonly session: Scope; + readonly catalog: ISessionSkillCatalog; + readonly config: ReturnType; + readonly changes: string[]; +} + +async function makeBase(): Promise { + return realpathSync(await mkdtemp(join(tmpdir(), 'skill-hot-reload-'))); +} + +async function writeSkill(root: string, name: string, description?: string): Promise { + const descriptionText = description ?? `desc for ${name}`; + await mkdir(join(root, name), { recursive: true }); + await writeFile(join(root, name, 'SKILL.md'), `---\nname: ${name}\ndescription: ${descriptionText}\n---\nbody`); +} + +function makeHost( + base: string, + opts: { readonly extraSkillDirs?: readonly string[]; readonly explicitDirs?: readonly string[] } = {}, +): HotReloadFixture { + const homeDir = join(base, 'home'); + const osHomeDir = join(base, 'os-home'); + const workDir = join(base, 'project'); + const bootstrap = { ...stubBootstrap(homeDir), osHomeDir }; + const config = configStub(); + const runtimeOptions = { + _serviceBrand: undefined, + explicitDirs: opts.explicitDirs, + } as unknown as ISkillCatalogRuntimeOptions; + const host = createScopedTestHost([ + [ISkillDiscovery, new SyncDescriptor(FileSkillDiscovery) as unknown], + stubPair(ILogService, stubLog()), + stubPair(IBootstrapService, bootstrap), + stubPair(IConfigService, config), + stubPair(ISkillCatalogRuntimeOptions, runtimeOptions), + stubPair(IPluginService, pluginStub()), + stubPair(IHostFsWatchService, new HostFsWatchService()), + ]); + const session = host.child(LifecycleScope.Session, 's1', [ + stubPair(ISessionWorkspaceContext, workspaceStub(workDir)), + stubPair(ILogService, stubLog()), + ]); + const catalog = session.accessor.get(ISessionSkillCatalog); + const changes: string[] = []; + catalog.onDidChange((sourceId) => changes.push(sourceId)); + if (opts.extraSkillDirs !== undefined) config.setExtraSkillDirs(opts.extraSkillDirs); + return { host, session, catalog, config, changes }; +} + +async function waitFor(cond: () => boolean, label: string, timeoutMs = 20000): Promise { + const start = Date.now(); + while (!cond()) { + if (Date.now() - start > timeoutMs) throw new Error(`timed out waiting for: ${label}`); + await wait(100); + } +} + +describe('skill hot reload', () => { + const tmpdirs: string[] = []; + const fixtures: HotReloadFixture[] = []; + + async function fixture( + opts: { readonly extraSkillDirs?: readonly string[]; readonly explicitDirs?: readonly string[] } = {}, + ): Promise { + const base = await makeBase(); + tmpdirs.push(base); + const f = makeHost(base, opts); + fixtures.push(f); + return { ...f, base }; + } + + afterEach(async () => { + for (const f of fixtures.splice(0)) f.host.dispose(); + for (const dir of tmpdirs.splice(0)) await rm(dir, { recursive: true, force: true }); + }); + + it('reloads the user source on add / modify / remove under /skills', async () => { + const { catalog, changes, base } = await fixture(); + const userRoot = join(base, 'home', 'skills'); + + await catalog.load(); + expect(catalog.catalog.getSkill('hot-one')).toBeUndefined(); + + await writeSkill(userRoot, 'hot-one'); + await waitFor(() => catalog.catalog.getSkill('hot-one') !== undefined, 'hot-one appears'); + + await writeSkill(userRoot, 'hot-one', 'updated description'); + await waitFor( + () => catalog.catalog.getSkill('hot-one')?.description === 'updated description', + 'hot-one description updates', + ); + + await rm(join(userRoot, 'hot-one'), { recursive: true, force: true }); + await waitFor(() => catalog.catalog.getSkill('hot-one') === undefined, 'hot-one disappears'); + + expect(changes.filter((id) => id === 'user').length).toBeGreaterThanOrEqual(3); + }, 30000); + + it('watches a user generic root that does not exist at load time', async () => { + const { catalog, base } = await fixture(); + const genericRoot = join(base, 'os-home', '.agents', 'skills'); + + await catalog.load(); + await wait(400); + expect(catalog.catalog.getSkill('generic-one')).toBeUndefined(); + + await writeSkill(genericRoot, 'generic-one'); + await waitFor(() => catalog.catalog.getSkill('generic-one') !== undefined, 'generic-one appears'); + }, 30000); + + it('a burst of writes collapses into a bounded number of catalog reloads', async () => { + const { catalog, changes, base } = await fixture(); + const userRoot = join(base, 'home', 'skills'); + + await catalog.load(); + await wait(400); + changes.length = 0; + + for (let i = 0; i < 4; i += 1) { + await writeSkill(userRoot, `burst-${i}`); + } + await waitFor(() => catalog.catalog.getSkill('burst-3') !== undefined, 'burst skills appear'); + await wait(600); + + expect(catalog.catalog.getSkill('burst-0')).toBeDefined(); + expect(changes.length).toBeGreaterThanOrEqual(1); + expect(changes.length).toBeLessThanOrEqual(2); + }, 30000); + + it('reloads the workspace source for both project brand and generic roots', async () => { + const { catalog, changes, base } = await fixture(); + const projectDir = join(base, 'project'); + await mkdir(join(projectDir, '.git'), { recursive: true }); + const brandRoot = join(projectDir, '.kimi-code', 'skills'); + + await catalog.load(); + await wait(400); + + await writeSkill(brandRoot, 'workspace-one'); + await waitFor(() => catalog.catalog.getSkill('workspace-one') !== undefined, 'workspace-one appears'); + + await rm(join(brandRoot, 'workspace-one'), { recursive: true, force: true }); + await waitFor(() => catalog.catalog.getSkill('workspace-one') === undefined, 'workspace-one disappears'); + + await writeSkill(join(projectDir, '.agents', 'skills'), 'workspace-generic'); + await waitFor( + () => catalog.catalog.getSkill('workspace-generic') !== undefined, + 'workspace-generic appears', + ); + + expect(changes).toContain('workspace'); + }, 30000); + + it('reloads the extra source under a configured directory created after load', async () => { + const probe = await makeBase(); + tmpdirs.push(probe); + const extraDir = join(probe, 'extra-skills'); + const { catalog, changes } = await fixture({ extraSkillDirs: [extraDir] }); + + await catalog.load(); + await wait(400); + expect(catalog.catalog.getSkill('extra-one')).toBeUndefined(); + + await writeSkill(extraDir, 'extra-one'); + await waitFor(() => catalog.catalog.getSkill('extra-one') !== undefined, 'extra-one appears'); + + expect(changes).toContain('extra'); + }, 30000); + + it('reloads the explicit source and keeps default user discovery replaced', async () => { + const probe = await makeBase(); + tmpdirs.push(probe); + const explicitDir = join(probe, 'explicit-skills'); + const { catalog, base } = await fixture({ explicitDirs: [explicitDir] }); + + await writeSkill(join(base, 'home', 'skills'), 'user-hidden'); + await catalog.load(); + await wait(400); + expect(catalog.catalog.getSkill('user-hidden')).toBeUndefined(); + + await writeSkill(explicitDir, 'explicit-one'); + await waitFor(() => catalog.catalog.getSkill('explicit-one') !== undefined, 'explicit-one appears'); + }, 30000); + + it('two independent containers on the same roots each see the change', async () => { + const base = await makeBase(); + tmpdirs.push(base); + const first = makeHost(base); + const second = makeHost(base); + fixtures.push(first, second); + + await first.catalog.load(); + await second.catalog.load(); + await wait(400); + + await writeSkill(join(base, 'home', 'skills'), 'shared-hot'); + await waitFor(() => first.catalog.catalog.getSkill('shared-hot') !== undefined, 'first sees shared-hot'); + await waitFor(() => second.catalog.catalog.getSkill('shared-hot') !== undefined, 'second sees shared-hot'); + + expect(first.catalog.catalog.getSkill('shared-hot')).toBeDefined(); + expect(second.catalog.catalog.getSkill('shared-hot')).toBeDefined(); + }, 30000); + + it('session dispose stops its watchers', async () => { + const { catalog, session, changes, base } = await fixture(); + await catalog.load(); + await wait(400); + + session.dispose(); + const firedBeforeWrites = changes.length; + + await writeSkill(join(base, 'home', 'skills'), 'after-dispose'); + await writeSkill(join(base, 'project', '.kimi-code', 'skills'), 'after-dispose-ws'); + await wait(1500); + + expect(changes.length).toBe(firedBeforeWrites); + expect(catalog.catalog.getSkill('after-dispose')).toBeUndefined(); + }, 30000); +}); diff --git a/packages/agent-core-v2/test/wire/persistence.test.ts b/packages/agent-core-v2/test/wire/persistence.test.ts index 6208a33819..ccfa772693 100644 --- a/packages/agent-core-v2/test/wire/persistence.test.ts +++ b/packages/agent-core-v2/test/wire/persistence.test.ts @@ -22,6 +22,8 @@ import { WIRE_PROTOCOL_VERSION, IFileSystemStorageService, IAppendLogStore, + IWriteAuthorityRegistry, + WriteAuthorityRegistryService, type WireRecord, } from '#/index'; import { IWireService } from '#/wire/wire'; @@ -63,6 +65,7 @@ function createAppendLogHarness(storage: IFileSystemStorageService): IAppendLogS const ix = disposable.add(new TestInstantiationService()); ix.stub(IFileSystemStorageService, storage); ix.set(IAppendLogStore, new SyncDescriptor(AppendLogStore)); + ix.stub(IWriteAuthorityRegistry, new WriteAuthorityRegistryService()); return ix.get(IAppendLogStore); } diff --git a/packages/agent-core-v2/test/wire/store-event.test.ts b/packages/agent-core-v2/test/wire/store-event.test.ts index 455e68ecf9..2950476cd3 100644 --- a/packages/agent-core-v2/test/wire/store-event.test.ts +++ b/packages/agent-core-v2/test/wire/store-event.test.ts @@ -7,6 +7,8 @@ import { TestInstantiationService } from '#/_base/di/test'; import { type DomainEvent, IEventBus } from '#/app/event/eventBus'; import { EventBusService } from '#/app/event/eventBusService'; import { AppendLogStore } from '#/persistence/backends/node-fs/appendLogStore'; +import { WriteAuthorityRegistryService } from '#/persistence/backends/node-fs/writeAuthorityRegistryService'; +import { IWriteAuthorityRegistry } from '#/persistence/interface/writeAuthority'; import { InMemoryStorageService } from '#/persistence/backends/memory/inMemoryStorageService'; import { IAppendLogStore } from '#/persistence/interface/appendLogStore'; import { IFileSystemStorageService } from '#/persistence/interface/storage'; @@ -63,6 +65,7 @@ function setup(logKey: string): { const ix = disposables.add(new TestInstantiationService()); ix.stub(IFileSystemStorageService, new InMemoryStorageService()); ix.set(IAppendLogStore, new SyncDescriptor(AppendLogStore)); + ix.stub(IWriteAuthorityRegistry, new WriteAuthorityRegistryService()); ix.set(IEventBus, new SyncDescriptor(EventBusService)); const log = ix.get(IAppendLogStore); const eventBus = ix.get(IEventBus); diff --git a/packages/agent-core-v2/test/wire/wire-compat.test.ts b/packages/agent-core-v2/test/wire/wire-compat.test.ts index a90ca1d02e..522806d256 100644 --- a/packages/agent-core-v2/test/wire/wire-compat.test.ts +++ b/packages/agent-core-v2/test/wire/wire-compat.test.ts @@ -11,6 +11,8 @@ import { DisposableStore } from '#/_base/di/lifecycle'; import { TestInstantiationService } from '#/_base/di/test'; import { resetUnexpectedErrorHandler, setUnexpectedErrorHandler } from '#/_base/errors/unexpectedError'; import { AppendLogStore } from '#/persistence/backends/node-fs/appendLogStore'; +import { WriteAuthorityRegistryService } from '#/persistence/backends/node-fs/writeAuthorityRegistryService'; +import { IWriteAuthorityRegistry } from '#/persistence/interface/writeAuthority'; import { FileStorageService } from '#/persistence/backends/node-fs/fileStorageService'; import { IAppendLogStore } from '#/persistence/interface/appendLogStore'; import { IFileSystemStorageService } from '#/persistence/interface/storage'; @@ -58,6 +60,7 @@ function makeContainer(storage: IFileSystemStorageService, logKey: string) { const ix = store.add(new TestInstantiationService()); ix.stub(IFileSystemStorageService, storage); ix.set(IAppendLogStore, new SyncDescriptor(AppendLogStore)); + ix.stub(IWriteAuthorityRegistry, new WriteAuthorityRegistryService()); const log = ix.get(IAppendLogStore); const wire = registerTestAgentWire(ix, testWireScope(SCOPE, logKey), { log }); return { ix, wire, log }; @@ -69,6 +72,7 @@ function makeReader(storage: IFileSystemStorageService): IAppendLogStore { const ix = store.add(new TestInstantiationService()); ix.stub(IFileSystemStorageService, storage); ix.set(IAppendLogStore, new SyncDescriptor(AppendLogStore)); + ix.stub(IWriteAuthorityRegistry, new WriteAuthorityRegistryService()); return ix.get(IAppendLogStore); } diff --git a/packages/agent-core-v2/test/wire/wireService.test.ts b/packages/agent-core-v2/test/wire/wireService.test.ts index 8e68877765..fff4433627 100644 --- a/packages/agent-core-v2/test/wire/wireService.test.ts +++ b/packages/agent-core-v2/test/wire/wireService.test.ts @@ -8,6 +8,8 @@ import { resetUnexpectedErrorHandler, setUnexpectedErrorHandler } from '#/_base/ import { IEventBus } from '#/app/event/eventBus'; import { EventBusService } from '#/app/event/eventBusService'; import { AppendLogStore } from '#/persistence/backends/node-fs/appendLogStore'; +import { WriteAuthorityRegistryService } from '#/persistence/backends/node-fs/writeAuthorityRegistryService'; +import { IWriteAuthorityRegistry } from '#/persistence/interface/writeAuthority'; import { InMemoryStorageService } from '#/persistence/backends/memory/inMemoryStorageService'; import { IAppendLogStore } from '#/persistence/interface/appendLogStore'; import { IFileSystemStorageService } from '#/persistence/interface/storage'; @@ -87,6 +89,7 @@ beforeEach(() => { ix = disposables.add(new TestInstantiationService()); ix.stub(IFileSystemStorageService, new InMemoryStorageService()); ix.set(IAppendLogStore, new SyncDescriptor(AppendLogStore)); + ix.stub(IWriteAuthorityRegistry, new WriteAuthorityRegistryService()); ix.set(IEventBus, new SyncDescriptor(EventBusService)); log = ix.get(IAppendLogStore); eventBus = ix.get(IEventBus); @@ -132,6 +135,7 @@ describe('WireService', () => { const ix2 = disposables.add(new TestInstantiationService()); ix2.stub(IFileSystemStorageService, new InMemoryStorageService()); ix2.set(IAppendLogStore, new SyncDescriptor(AppendLogStore)); + ix2.stub(IWriteAuthorityRegistry, new WriteAuthorityRegistryService()); ix2.set(IEventBus, new SyncDescriptor(EventBusService)); const log2 = ix2.get(IAppendLogStore); const replayEventBus = ix2.get(IEventBus); diff --git a/packages/kap-server/src/error-handler.ts b/packages/kap-server/src/error-handler.ts index 010eb6a05d..2fdb5af3a2 100644 --- a/packages/kap-server/src/error-handler.ts +++ b/packages/kap-server/src/error-handler.ts @@ -4,6 +4,11 @@ * Wraps unhandled exceptions in the Feishu-style envelope: * - HTTP status ALWAYS 200 (business outcome lives in `code`); * - `code: 50001` (`internal.error`) for unknown exceptions; + * - `code: 40921` (`session.held_by_peer`) when the session's write lease is + * held by a sibling instance — the structured ownership details ride the + * envelope so the client can redirect or retry (routes without a local + * error switch — approvals, questions, skills — resume sessions + * unguarded and land here); * - `request_id` echoes the inbound request id (set by Fastify's * `genReqId` via `resolveRequestId`); * - `data: null`. @@ -17,6 +22,7 @@ * we never bleed it into the JSON response. */ +import { ErrorCodes, isError2 } from '@moonshot-ai/agent-core-v2'; import { errEnvelope } from './envelope'; import { ErrorCode } from './protocol/error-codes'; import type { FastifyError } from 'fastify'; @@ -40,6 +46,21 @@ interface ErrorHandlerHost { export function installErrorHandler(app: ErrorHandlerHost): void { app.setErrorHandler((err, req, reply) => { const requestId = req.id; + // Session-ownership contention is an expected multi-server outcome, not a + // server failure: surface 40921 with the structured details (phase / + // redirect address) and keep the stack in the log only. + if (isError2(err) && err.code === ErrorCodes.SESSION_HELD_BY_PEER) { + reply.status(200).send( + errEnvelope( + ErrorCode.SESSION_HELD_BY_PEER, + err.message, + requestId, + undefined, + err.details, + ), + ); + return; + } req.log.error({ err, request_id: requestId }, 'unhandled error'); reply.status(200).send( errEnvelope( diff --git a/packages/kap-server/src/instanceRegistry.ts b/packages/kap-server/src/instanceRegistry.ts index 96c9305faa..233c2e1b17 100644 --- a/packages/kap-server/src/instanceRegistry.ts +++ b/packages/kap-server/src/instanceRegistry.ts @@ -16,7 +16,7 @@ import { randomBytes } from 'node:crypto'; import { mkdir, open, readdir, readFile, rename, unlink } from 'node:fs/promises'; -import { join } from 'node:path'; +import { dirname, join } from 'node:path'; import { resolveKimiHome } from '@moonshot-ai/agent-core-v2'; import { ulid } from 'ulid'; @@ -147,7 +147,18 @@ async function readInstanceFile(filePath: string): Promise { + if (process.platform === 'win32') return; + const dirFh = await open(dirPath, 'r'); + try { + await dirFh.sync(); + } finally { + await dirFh.close(); + } +} + +/** Atomic (rename-based), durable write. Single-writer per file, so no lock is needed. */ async function writeFileAtomic(filePath: string, content: string): Promise { const tmpPath = `${filePath}.tmp.${process.pid}.${randomBytes(4).toString('hex')}`; let renamed = false; @@ -155,11 +166,15 @@ async function writeFileAtomic(filePath: string, content: string): Promise const fh = await open(tmpPath, 'w'); try { await fh.writeFile(content); + // Flush the payload before the rename, so a crash can't resurrect a 0-byte entry. + await fh.sync(); } finally { await fh.close(); } await rename(tmpPath, filePath); renamed = true; + // The rename itself is not durable — fsync the directory so the new entry survives a crash. + await syncDir(dirname(filePath)); } finally { if (!renamed) { try { diff --git a/packages/kap-server/src/protocol/envelope.ts b/packages/kap-server/src/protocol/envelope.ts index 620a4ae62e..b28d2b09f8 100644 --- a/packages/kap-server/src/protocol/envelope.ts +++ b/packages/kap-server/src/protocol/envelope.ts @@ -35,12 +35,16 @@ export function okEnvelope(data: T, requestId: string): Envelope { * (or `undefined`) the field is absent and the wire shape stays byte-identical * to the original `{ code, msg, data: null, request_id }` — `JSON.stringify` * drops `undefined` properties, so callers that have no stack are unaffected. + * `details` behaves the same way: when provided it carries a structured, + * client-consumable payload (e.g. `SessionOwnershipDetails` under + * `SESSION_HELD_BY_PEER`); when omitted the field is absent from the wire. */ export function errEnvelope( code: number, msg: string, requestId: string, stack?: string, + details?: unknown, ): Envelope { - return { code, msg, data: null, request_id: requestId, stack }; + return { code, msg, data: null, request_id: requestId, stack, details }; } diff --git a/packages/kap-server/src/protocol/error-codes.ts b/packages/kap-server/src/protocol/error-codes.ts index 775f895925..164bb82fc7 100644 --- a/packages/kap-server/src/protocol/error-codes.ts +++ b/packages/kap-server/src/protocol/error-codes.ts @@ -104,6 +104,8 @@ export const ErrorCode = { FS_ALREADY_EXISTS: 40919, /** goal 只允许主 agent 使用 */ GOAL_UNSUPPORTED_AGENT: 40920, + /** session 的 lease 被其他实例持有;`details` 为 SessionOwnershipDetails */ + SESSION_HELD_BY_PEER: 40921, /** approval 60s 超时 */ APPROVAL_EXPIRED: 41001, diff --git a/packages/kap-server/src/protocol/rest-snapshot.ts b/packages/kap-server/src/protocol/rest-snapshot.ts index 9b445d6158..d3691561aa 100644 --- a/packages/kap-server/src/protocol/rest-snapshot.ts +++ b/packages/kap-server/src/protocol/rest-snapshot.ts @@ -71,8 +71,13 @@ export type SnapshotSubagent = z.infer; export const sessionSnapshotResponseSchema = z.object({ /** Durable event watermark this snapshot is consistent with. */ as_of_seq: z.number().int().nonnegative(), - /** Journal epoch — pass back via the WS cursor for invalidation detection. */ - epoch: z.string().min(1), + /** + * Journal epoch — pass back via the WS cursor for invalidation detection. + * Absent when the journal has no baseline yet (no durable event written): + * "no baseline" is not "baseline changed" — treat the cursor as fresh, + * not as invalidated. + */ + epoch: z.string().min(1).optional(), session: sessionSchema, /** Most recent messages (chronological ascending), bounded page. */ messages: z.object({ diff --git a/packages/kap-server/src/protocol/session.ts b/packages/kap-server/src/protocol/session.ts index 9fa454244a..b3126a5bcc 100644 --- a/packages/kap-server/src/protocol/session.ts +++ b/packages/kap-server/src/protocol/session.ts @@ -39,6 +39,22 @@ export function emptySessionUsage(): SessionUsage { export const sessionPendingInteractionSchema = z.enum(['none', 'approval', 'question']); export type SessionPendingInteraction = z.infer; +/** + * Per-row holder annotation joined onto `GET /sessions` items (multi-instance, + * design §3.8): `self` = this instance holds the write lease (the session is + * materialized here), `peer` = another instance holds it (`address` is its + * reachable base URL when the holder advertised one — the redirect target), + * `none` = no lease on disk (the session is materialized nowhere). Purely + * display/redirect metadata: the lease file stays the authority and is + * re-checked on every materialization. + */ +export const sessionOwnershipSchema = z.object({ + held_by: z.enum(['self', 'peer', 'none']), + address: z.string().min(1).optional(), +}); + +export type SessionOwnership = z.infer; + export const sessionSchema = z.object({ id: z.string().min(1), workspace_id: workspaceIdSchema, @@ -54,6 +70,7 @@ export const sessionSchema = z.object({ /** Outcome of the main agent's most recent turn. */ last_turn_reason: z.enum(['completed', 'cancelled', 'failed']).optional(), archived: z.boolean().optional(), + ownership: sessionOwnershipSchema.optional(), current_prompt_id: z.string().min(1).optional(), /** Text of the most recent user prompt, for search/preview. Absent for empty sessions. */ last_prompt: z.string().optional(), diff --git a/packages/kap-server/src/routes/fs.ts b/packages/kap-server/src/routes/fs.ts index bd3433217f..5eb10375ab 100644 --- a/packages/kap-server/src/routes/fs.ts +++ b/packages/kap-server/src/routes/fs.ts @@ -530,6 +530,20 @@ function sendMappedError(reply: Reply, req: { id: string }, err: unknown): void case ErrorCodes.SESSION_NOT_FOUND: reply.send(errEnvelope(ErrorCode.SESSION_NOT_FOUND, err.message, requestId, err.stack)); return; + case ErrorCodes.SESSION_HELD_BY_PEER: + // Ownership redirect: the details payload (`held-by-peer` phase / + // address) is the actionable part, so it rides the envelope and the + // stack stays server-side. + reply.send( + errEnvelope( + ErrorCode.SESSION_HELD_BY_PEER, + err.message, + requestId, + undefined, + err.details, + ), + ); + return; // hostFs errors that escaped the sessionFs layer keep their `os.fs.*` // code; map them onto the closest v1 wire code (ENOTDIR collapses into // path-not-found, matching `mapFsError`). diff --git a/packages/kap-server/src/routes/prompts.ts b/packages/kap-server/src/routes/prompts.ts index 1cddd845f4..ee80c53545 100644 --- a/packages/kap-server/src/routes/prompts.ts +++ b/packages/kap-server/src/routes/prompts.ts @@ -712,6 +712,20 @@ function sendMappedError( case 'session.busy': reply.send(errEnvelope(ErrorCode.SESSION_BUSY, err.message, requestId, err.stack)); return; + case 'session.held_by_peer': + // Ownership redirect: the details payload (`held-by-peer` phase / + // address) is the actionable part, so it rides the envelope and the + // stack stays server-side. + reply.send( + errEnvelope( + ErrorCode.SESSION_HELD_BY_PEER, + err.message, + requestId, + undefined, + err.details, + ), + ); + return; case 'prompt.already_completed': reply.send({ code: ErrorCode.PROMPT_ALREADY_COMPLETED, diff --git a/packages/kap-server/src/routes/sessions.ts b/packages/kap-server/src/routes/sessions.ts index e47a0f7637..c869dc216b 100644 --- a/packages/kap-server/src/routes/sessions.ts +++ b/packages/kap-server/src/routes/sessions.ts @@ -69,6 +69,13 @@ * their original cwd and stay listed / gettable (matching v1, which stores * `workDir` on the session). `IWorkspaceRegistry` is consulted only as a * back-compat fallback for sessions written before `cwd` was persisted. + * + * **Ownership join (multi-instance, design §3.8)**: `GET /sessions` rows also + * carry an additive `ownership` field joined from the shared + * `session-leases/` tree — `held_by` is 'self' when this instance holds the + * session's write lease, 'peer' (with the holder's `address`, when + * advertised) when another instance does, and 'none' when no instance does. + * See {@link resolveSessionOwnership}. */ import { @@ -81,10 +88,13 @@ import { IAgentRPCService, IAgentActivityView, IAuthSummaryService, + IBootstrapService, + ICrossProcessLockService, ISessionBtwService, ISessionContext, ISessionIndex, ISessionInteractionService, + ISessionLeaseService, ISessionLifecycleService, ISessionMetadata, ISessionLegacyService, @@ -92,6 +102,7 @@ import { IWorkspaceRegistry, isError2, Error2, + sessionLeasePath, toProtocolMessage, type ContextMessage, type IAgentScopeHandle, @@ -120,6 +131,7 @@ import { emptySessionUsage, sessionSchema, type Session, + type SessionOwnership, type SessionPendingInteraction, } from '../protocol/session'; import { workspaceIdSchema } from '../protocol/workspace'; @@ -442,11 +454,16 @@ export function registerSessionsRoutes(app: SessionRouteHost, core: Scope): void ? (pageSize ?? DEFAULT_SESSION_LIST_PAGE_SIZE) : (pageSize ?? visible.length); const hasMore = visible.length > limit; - const projected: Session[] = visible - .slice(0, limit) - .map(({ summary, cwd, facts }) => - toWireSession(summary, cwd, facts ?? resolveSessionFacts(core, summary.id)), - ); + // Owner annotation is joined from the shared `session-leases/` tree per + // row (design §3.8): 'self' when this instance materialized the session, + // the holder's address for a routable 'peer', 'none' for a session + // materialized nowhere. Read-only per row; a vanished holder races at + // worst a 'peer' that resolves to 'none' on the next list. + const homeDir = core.accessor.get(IBootstrapService).homeDir; + const projected: Session[] = visible.slice(0, limit).map(({ summary, cwd, facts }) => ({ + ...toWireSession(summary, cwd, facts ?? resolveSessionFacts(core, summary.id)), + ownership: resolveSessionOwnership(core, homeDir, summary.id), + })); // v1 filters ordinary lists by the busy fact post-page; `archived_only` // already applied it before pagination above so it can drain to a full page. const items = @@ -1123,6 +1140,42 @@ function resolvePendingInteraction( return 'none'; } +/** + * Join one session's holder annotation from the shared `session-leases/` tree + * (design §3.8 layer 2). Classification: + * + * - 'self': this instance materialized the session and still holds its write + * lease (`ISessionLeaseService.info` survives only while held). + * - 'peer': a lease is on disk and held (or mid-creation) by someone else; + * `address` rides along when the holder advertised one — the redirect + * target. An unresponsive holder still counts as 'peer' (it is never + * auto-taken over). + * - 'none': no lease on disk, or a stale husk a dead holder left behind — + * the session is materialized nowhere and any instance may acquire it. + * + * Advisory only: the sync `inspect` read can observe a holder that died the + * next millisecond; the lease's own acquisition protocol stays the authority. + */ +function resolveSessionOwnership( + core: Scope, + homeDir: string, + sessionId: string, +): SessionOwnership { + const handle = core.accessor.get(ISessionLifecycleService).get(sessionId); + if (handle !== undefined) { + const lease = handle.accessor.get(ISessionLeaseService); + if (lease.info !== undefined) return { held_by: 'self' }; + } + const inspection = core + .accessor.get(ICrossProcessLockService) + .inspect(sessionLeasePath(homeDir, sessionId)); + if (inspection.state === 'held' || inspection.state === 'creating') { + const address = inspection.payload?.address; + return address !== undefined ? { held_by: 'peer', address } : { held_by: 'peer' }; + } + return { held_by: 'none' }; +} + /** * Resume the session (cold-load if needed) and resolve its main agent, throwing * `session.not_found` when the session is unknown or its workspace is gone. @@ -1241,6 +1294,20 @@ function sendMappedError( stack: err.stack, }); return; + case ErrorCodes.SESSION_HELD_BY_PEER: + // Ownership redirect: the details payload (`held-by-peer` phase / + // address) is the actionable part, so it rides the envelope and the + // stack stays server-side. + reply.send( + errEnvelope( + ErrorCode.SESSION_HELD_BY_PEER, + err.message, + requestId, + undefined, + err.details, + ), + ); + return; case ErrorCodes.GOAL_ALREADY_EXISTS: reply.send(errEnvelope(ErrorCode.GOAL_ALREADY_EXISTS, err.message, requestId, err.stack)); return; diff --git a/packages/kap-server/src/routes/snapshot.ts b/packages/kap-server/src/routes/snapshot.ts index c3e7b3eb7c..ee3a507a02 100644 --- a/packages/kap-server/src/routes/snapshot.ts +++ b/packages/kap-server/src/routes/snapshot.ts @@ -196,6 +196,8 @@ async function readViaLegacyAssembly( return { as_of_seq: snapState.seq, + // A journal with no baseline yet has `epoch: undefined`; on the wire the + // field is simply absent (never fabricated). epoch: snapState.epoch, session, messages: { items, has_more: hasMore }, diff --git a/packages/kap-server/src/routes/terminals.ts b/packages/kap-server/src/routes/terminals.ts index 8a0ce8b5c1..f07b3f389f 100644 --- a/packages/kap-server/src/routes/terminals.ts +++ b/packages/kap-server/src/routes/terminals.ts @@ -241,6 +241,20 @@ function sendMappedError( case ErrorCodes.TERMINAL_NOT_FOUND: reply.send(errEnvelope(ErrorCode.TERMINAL_NOT_FOUND, err.message, requestId, err.stack)); return; + case ErrorCodes.SESSION_HELD_BY_PEER: + // Ownership redirect: the details payload (`held-by-peer` phase / + // address) is the actionable part, so it rides the envelope and the + // stack stays server-side. + reply.send( + errEnvelope( + ErrorCode.SESSION_HELD_BY_PEER, + err.message, + requestId, + undefined, + err.details, + ), + ); + return; } } // `ISessionWorkspaceContext.assertAllowed` throws a plain (uncoded) Error when a cwd diff --git a/packages/kap-server/src/services/sessionListWatch/sessionListWatchService.ts b/packages/kap-server/src/services/sessionListWatch/sessionListWatchService.ts new file mode 100644 index 0000000000..1e9a0a8f37 --- /dev/null +++ b/packages/kap-server/src/services/sessionListWatch/sessionListWatchService.ts @@ -0,0 +1,188 @@ +/** + * `SessionListWatchService` — the event plane of multi-instance session-list + * sync (design `.tmp/refactor-watch-design-v2.md` §3.8). + * + * Several kap-server instances can share one home directory (the + * `multi_server` experimental flag). The session list itself needs no + * synchronization: `ISessionIndex.list()` re-enumerates the shared + * `/sessions` tree on every request, so a peer's sessions are visible on + * the next pull. What a peer can never produce is the *event* — core events + * are process-local. This service closes that gap locally: it watches the + * shared sessions tree and, on any workspace/session directory appearing or + * disappearing, publishes ONE debounced `session.list_changed` hint on this + * instance's core `IEventService`, which the `SessionEventBroadcaster` fans + * out live (volatile, never journaled) to every connected WS client. Clients + * then re-pull the list — the directory scan stays the single authority, the + * hint is pure "go refetch" advice and deliberately carries no payload. + * + * Two-layer topology (a root recursive watch was rejected as an event flood): + * - root `/sessions` at depth 0: workspace directories appearing / + * disappearing — per-workspace watchers are added and removed here; + * - one depth-0 watcher per workspace directory: session directories + * appearing / disappearing. + * Existing workspaces are scanned at `start()`; every watcher runs with + * `ignoreInitial` so boot produces no hint flood. + * + * This is transport state (like `FsWatchBridge` / `SessionEventBroadcaster`): + * constructed in `start.ts` when `multi_server` is on — never DI-registered — + * and disposed during server close before the core scope goes down. + */ + +import { mkdirSync } from 'node:fs'; +import { readdir } from 'node:fs/promises'; +import { join, relative, sep } from 'node:path'; + +import type { + HostFsChange, + IEventService, + IHostFsWatchHandle, + IHostFsWatchService, +} from '@moonshot-ai/agent-core-v2'; + +import type { JournalLogger } from '../../transport/ws/v1/sessionEventJournal'; + +/** Same debounce window as the storage-byte watch layer (`fileStorageService`). */ +export const SESSION_LIST_WATCH_DEBOUNCE_MS = 150; + +export class SessionListWatchService { + private readonly sessionsDir: string; + private readonly fsWatch: IHostFsWatchService; + private readonly events: IEventService; + private readonly logger: JournalLogger | undefined; + private readonly debounceMs: number; + + private rootHandle: IHostFsWatchHandle | undefined; + /** workspaceId → handle, added/removed as workspace directories come and go. */ + private readonly workspaceHandles = new Map(); + private timer: ReturnType | undefined; + private started = false; + + constructor(opts: { + readonly sessionsDir: string; + readonly fsWatch: IHostFsWatchService; + readonly events: IEventService; + readonly logger?: JournalLogger; + /** Test seam: defaults to {@link SESSION_LIST_WATCH_DEBOUNCE_MS}. */ + readonly debounceMs?: number; + }) { + this.sessionsDir = opts.sessionsDir; + this.fsWatch = opts.fsWatch; + this.events = opts.events; + this.logger = opts.logger; + this.debounceMs = opts.debounceMs ?? SESSION_LIST_WATCH_DEBOUNCE_MS; + } + + /** Resolves when the initial workspace scan is done (never rejects). */ + start(): Promise { + if (this.started) return Promise.resolve(); + this.started = true; + // Watching a not-yet-existing root would leave later first-session + // creation (which mkdirs the whole chain) up to chokidar's nonexistent- + // path timing; creating the dir up front removes that race. The server + // owns this tree anyway — the first session write would create it. + try { + mkdirSync(this.sessionsDir, { recursive: true }); + } catch (error) { + this.logger?.warn( + { err: String(error) }, + 'session list watch: failed to ensure sessions dir; watching anyway', + ); + } + this.rootHandle = this.fsWatch.watch(this.sessionsDir, { recursive: false }); + this.rootHandle.onDidChange((change) => this.onRootChange(change)); + return this.scanWorkspaces(); + } + + dispose(): void { + if (this.timer !== undefined) { + clearTimeout(this.timer); + this.timer = undefined; + } + for (const handle of this.workspaceHandles.values()) handle.dispose(); + this.workspaceHandles.clear(); + this.rootHandle?.dispose(); + this.rootHandle = undefined; + this.started = false; + } + + private async scanWorkspaces(): Promise { + let entries; + try { + entries = await readdir(this.sessionsDir, { withFileTypes: true }); + } catch (error) { + if ((error as NodeJS.ErrnoException).code !== 'ENOENT') { + this.logger?.warn({ err: String(error) }, 'session list watch: workspace scan failed'); + } + return; + } + for (const entry of entries) { + if (!this.started) return; + if (entry.isDirectory()) this.addWorkspaceWatcher(entry.name); + } + } + + /** Root depth-0 events: only directories matter — workspace ids. */ + private onRootChange(change: HostFsChange): void { + if (change.kind !== 'directory') return; + // depth-0 watching reports direct children only; check defensively anyway. + const workspaceId = relative(this.sessionsDir, change.path); + if (workspaceId === '' || workspaceId.includes(sep)) return; + this.scheduleHint(); + if (change.action === 'created') { + this.addWorkspaceWatcher(workspaceId); + } else if (change.action === 'deleted') { + this.removeWorkspaceWatcher(workspaceId); + } + // The hint for the root event itself already covers a workspace that + // appeared WITH its first session inside (created between the fs event and + // our watcher attach): one debounced hint, no missed window. + } + + /** Per-workspace depth-0 events: only directories matter — session ids. */ + private onWorkspaceChange(change: HostFsChange): void { + if (change.kind !== 'directory') return; + if (change.action === 'modified') return; + this.scheduleHint(); + } + + private addWorkspaceWatcher(workspaceId: string): void { + if (this.workspaceHandles.has(workspaceId)) return; + let handle: IHostFsWatchHandle; + try { + handle = this.fsWatch.watch(join(this.sessionsDir, workspaceId), { + recursive: false, + }); + } catch (error) { + this.logger?.warn( + { workspaceId, err: String(error) }, + 'session list watch: failed to watch workspace dir', + ); + return; + } + this.workspaceHandles.set(workspaceId, handle); + handle.onDidChange((change) => this.onWorkspaceChange(change)); + } + + private removeWorkspaceWatcher(workspaceId: string): void { + const handle = this.workspaceHandles.get(workspaceId); + if (handle === undefined) return; + this.workspaceHandles.delete(workspaceId); + handle.dispose(); + } + + private scheduleHint(): void { + if (this.timer !== undefined) clearTimeout(this.timer); + this.timer = setTimeout(() => { + this.timer = undefined; + this.publishHint(); + }, this.debounceMs); + } + + private publishHint(): void { + try { + this.events.publish({ type: 'session.list_changed', payload: {} }); + } catch (error) { + this.logger?.warn({ err: String(error) }, 'session list watch: hint publish failed'); + } + } +} diff --git a/packages/kap-server/src/services/snapshot/snapshotReader.ts b/packages/kap-server/src/services/snapshot/snapshotReader.ts index 0b2f14758e..b3add7783f 100644 --- a/packages/kap-server/src/services/snapshot/snapshotReader.ts +++ b/packages/kap-server/src/services/snapshot/snapshotReader.ts @@ -138,6 +138,8 @@ export class SnapshotReader implements ISnapshotReader { return { as_of_seq: snapState.seq, + // A journal with no baseline yet has `epoch: undefined`; on the wire the + // field is simply absent (never fabricated). epoch: snapState.epoch, session, messages: { items, has_more: hasMore }, diff --git a/packages/kap-server/src/start.ts b/packages/kap-server/src/start.ts index d971d4eea6..b1a3925004 100644 --- a/packages/kap-server/src/start.ts +++ b/packages/kap-server/src/start.ts @@ -11,15 +11,20 @@ import { bootstrap, hostRequestHeadersSeed, IConfigService, + IEventService, + IHostFsWatchService, IModelCatalogService, + ISessionLifecycleService, IWorkspaceRegistry, logSeed, resolveConfigPath, resolveKimiHome, resolveLoggingConfig, + sessionLeaseContactSeed, skillCatalogRuntimeOptionsSeed, type Scope, type ScopeSeed, + type SessionLeaseContact, } from '@moonshot-ai/agent-core-v2'; import { createAsyncApiDocument } from './protocol/asyncapi'; import Fastify, { type FastifyInstance } from 'fastify'; @@ -50,6 +55,7 @@ import { registerWs, WS_PATH as WS_PATH_V2 } from './transport/ws/registerWs'; import { extractWsBearerToken } from './transport/ws/bearerProtocol'; import { SessionEventBroadcaster } from './transport/ws/v1/sessionEventBroadcaster'; import { FsWatchBridge } from './transport/ws/v1/fsWatchBridge'; +import { SkillCatalogBridge } from './transport/ws/v1/skillCatalogBridge'; import { registerWsV1, WS_PATH as WS_PATH_V1 } from './transport/ws/v1/registerWsV1'; import { getServerVersion } from './version'; import { classify } from './security/bindClassify'; @@ -62,6 +68,7 @@ import { createOriginHook, isOriginAllowed, parseCorsOrigins } from './middlewar import { createSecurityHeadersHook } from './middleware/securityHeaders'; import { createAuthHook } from './middleware/auth'; import { GuiStoreService } from './services/guiStore/guiStoreService'; +import { SessionListWatchService } from './services/sessionListWatch/sessionListWatchService'; import { loadSnapshotConfig, SnapshotReader } from './services/snapshot'; import { ModelCatalogRefreshScheduler } from './services/modelCatalog/modelCatalogRefreshScheduler'; import { createAuthFailureLimiter } from './middleware/rateLimit'; @@ -120,7 +127,7 @@ export interface ServerStartOptions { readonly webAssetsDir?: string; /** * Host product version, reported as `server_version` (GET /api/v1/meta), in - * the OpenAPI document, session exports, the lock / instance registry, and + * the OpenAPI document, session exports, the instance registry, and * the default User-Agent. Defaults to kap-server's own package version; * embedding hosts (the CLI) should pass their own version. */ @@ -140,6 +147,22 @@ export interface RunningServer { const DEFAULT_HOST = '127.0.0.1'; const DEFAULT_PORT = 58627; +/** + * Env gate for the multi-server session-list sync below + * (`KIMI_CODE_EXPERIMENTAL_MULTI_SERVER`). Resolved directly from the + * environment *before* bootstrap, and deliberately NOT via the flag service / + * master `KIMI_CODE_EXPERIMENTAL_FLAG`: that switch already enables the v2 + * engine itself, and coupling multi-instance behavior to it would change the + * watch surface of every v2 server before the feature is ready. Keeping the + * gate specific makes multi-server strictly opt-in. + */ +const MULTI_SERVER_FLAG_ENV = 'KIMI_CODE_EXPERIMENTAL_MULTI_SERVER'; + +function isMultiServerEnabled(env: NodeJS.ProcessEnv): boolean { + const raw = (env[MULTI_SERVER_FLAG_ENV] ?? '').trim().toLowerCase(); + return raw === '1' || raw === 'true' || raw === 'yes' || raw === 'on'; +} + export async function startServer(opts: ServerStartOptions = {}): Promise { const host = opts.host ?? DEFAULT_HOST; const port = opts.port ?? DEFAULT_PORT; @@ -199,6 +222,10 @@ export async function startServer(opts: ServerStartOptions = {}): Promise leaseContact.current), ...(opts.seeds ?? []), ]); @@ -289,7 +317,40 @@ export async function startServer(opts: ServerStartOptions = {}): Promise => { + // Shutdown is re-entrant at two points: the remote /api/v1/shutdown route + // and the CLI signal handler both converge on this `close`, and the boot + // failure path below uses it as cleanup. The in-flight promise guard makes + // it idempotent — concurrent and repeat callers share one execution. + let closePromise: Promise | undefined; + const close = (): Promise => { + closePromise ??= doClose(); + return closePromise; + }; + const doClose = async (): Promise => { + // Stop the sessions-tree watcher first: a debounced hint firing mid-close + // would publish into an event bus whose subscribers are already unwinding. + sessionListWatch?.dispose(); + // Release-order contract: sessions close FIRST (each runs the + // onWillCloseSession hooks and drains agents while the transport is still + // alive, so teardown events still fan out and land in the journal), the + // transport/app next (its onClose hook closes connections, stops the WS + // servers and flushes every journal — including the tail events queued + // during session teardown), and only then the core scope, the failure + // limiter/scheduler, and the registration handle. + // + // Before any session scope is disposed, settle the broadcaster's in-flight + // dispatches: an `ensureState` resumed after its session's scope died + // would abandon its event (e.g. `event.session.created` for a session + // created right before shutdown), and its journal entry with it. + await broadcaster.drainDispatches(); + const lifecycle = core.accessor.get(ISessionLifecycleService); + for (const handle of lifecycle.list()) { + try { + await lifecycle.close(handle.id); + } catch (error) { + logger.error({ err: error, sessionId: handle.id }, 'session close failed during shutdown'); + } + } await app.close(); authFailureLimiter?.dispose(); modelCatalogRefreshScheduler.dispose(); @@ -304,6 +365,24 @@ export async function startServer(opts: ServerStartOptions = {}): Promise + logger.error({ err: error }, 'session list watch failed to start'), + ); const snapshotReader = new SnapshotReader({ homeDir, @@ -378,6 +457,7 @@ export async function startServer(opts: ServerStartOptions = {}): Promise { logger.warn( diff --git a/packages/kap-server/src/transport/errors.ts b/packages/kap-server/src/transport/errors.ts index d63e5bd7a1..b37401f861 100644 --- a/packages/kap-server/src/transport/errors.ts +++ b/packages/kap-server/src/transport/errors.ts @@ -47,6 +47,7 @@ const KIMI_TO_PROTOCOL: Record = { [ErrorCodes.GOAL_OBJECTIVE_EMPTY]: ErrorCode.GOAL_OBJECTIVE_EMPTY, [ErrorCodes.GOAL_OBJECTIVE_TOO_LONG]: ErrorCode.GOAL_OBJECTIVE_TOO_LONG, [ErrorCodes.GOAL_UNSUPPORTED_AGENT]: ErrorCode.GOAL_UNSUPPORTED_AGENT, + [ErrorCodes.SESSION_HELD_BY_PEER]: ErrorCode.SESSION_HELD_BY_PEER, // hostFs / storage codes → closest v1 wire equivalent (ENOTDIR collapses // into path-not-found); codes without an equivalent fall back to 50001. [ErrorCodes.OS_FS_NOT_FOUND]: ErrorCode.FS_PATH_NOT_FOUND, @@ -61,12 +62,15 @@ const KIMI_TO_PROTOCOL: Record = { /** * Map an internal error to the project envelope. `Error2` keeps its coded * mapping; everything else becomes `50001`. Stack traces are intentionally not - * surfaced. + * surfaced. Structured `Error2.details` always ride the mapped envelope + * verbatim (omitted when absent), so newly-mapped codes such as + * `session.held_by_peer` carry their ownership payload without a per-code + * branch. */ export function mapError(err: unknown, requestId: string): ReturnType { if (err instanceof Error2) { const code = KIMI_TO_PROTOCOL[err.code] ?? ErrorCode.INTERNAL_ERROR; - return errEnvelope(code, err.message, requestId, err.stack); + return errEnvelope(code, err.message, requestId, err.stack, err.details); } if (err instanceof TimeoutError) { return errEnvelope(ErrorCode.INTERNAL_ERROR, err.message, requestId, err.stack); diff --git a/packages/kap-server/src/transport/ws/eventMap.ts b/packages/kap-server/src/transport/ws/eventMap.ts index d9259a63fe..bd534f5406 100644 --- a/packages/kap-server/src/transport/ws/eventMap.ts +++ b/packages/kap-server/src/transport/ws/eventMap.ts @@ -35,6 +35,18 @@ export const eventMap: Record> = { subscribe: (scope, listener) => scope.accessor.get(IEventService).subscribe(listener as (event: GlobalEvent) => void), }, + // Filtered view of the same bus for the multi-instance session-list hint + // (design §3.8): volatile and payload-free, published by + // `SessionListWatchService` — clients subscribe by name instead of + // draining the whole `events` stream. + 'session.list_changed': { + subscribe: (scope, listener) => + scope.accessor + .get(IEventService) + .subscribe((event) => { + if (event.type === 'session.list_changed') listener(event); + }), + }, }, session: { // Pushes the full pending interaction set whenever it changes. Payload is diff --git a/packages/kap-server/src/transport/ws/v1/events.ts b/packages/kap-server/src/transport/ws/v1/events.ts index 0ce5c16b6d..57349bd968 100644 --- a/packages/kap-server/src/transport/ws/v1/events.ts +++ b/packages/kap-server/src/transport/ws/v1/events.ts @@ -14,6 +14,10 @@ import type { DomainEvent } from '@moonshot-ai/agent-core-v2/app/event/eventBus' import type { MessageContent } from '@moonshot-ai/agent-core-v2/agent/contextMemory/protocolMessage'; import type { PermissionMode } from '@moonshot-ai/agent-core-v2/agent/permissionPolicy/types'; import type { UsageStatus } from '@moonshot-ai/agent-core-v2/agent/usage/usage'; +import type { + ProviderRefreshChange, + ProviderRefreshFailure, +} from '@moonshot-ai/agent-core-v2/app/modelCatalog/modelCatalog'; import type { AgentPhase } from '../../../services/legacyStatus/legacyStatus'; import type { ConfigResponse } from '../../../protocol/rest-config'; import type { Session, SessionPendingInteraction } from '../../../protocol/session'; @@ -44,6 +48,32 @@ export interface SessionCreatedEvent { readonly session: Session; } +/** + * Volatile, payload-less hint that the set of sessions on disk changed + * (design §3.8): a workspace or session directory appeared or vanished under + * the shared `/sessions` tree, possibly created by ANOTHER server + * instance sharing the home. Clients should re-pull `GET /sessions` instead + * of reading anything into the event itself — it is fanned out live only + * (never journaled, never replayed). + */ +export interface SessionListChangedEvent { + readonly type: 'session.list_changed'; +} + +/** + * Volatile per-session hint that the session's skill catalog changed: a skill + * file or directory under one of the watched sources appeared, changed, or + * vanished (mirrors the core `ISessionSkillCatalog.onDidChange` feed, whose + * payload is the changed source id). Fanned out live only (never journaled, + * never replayed) to connections subscribed to that session — clients should + * re-pull `GET /sessions/{sid}/skills` instead of reading anything into the + * hint itself. + */ +export interface SkillCatalogChangedEvent { + readonly type: 'skill_catalog.changed'; + readonly sourceId: string; +} + export interface WorkspaceCreatedEvent { readonly type: 'event.workspace.created'; readonly workspace: Workspace; @@ -88,6 +118,20 @@ export interface ConfigChangedEvent { readonly config: ConfigResponse; } +/** + * Published (core event bus) when the provider/model catalog refreshes (auth + * change, config edit, scheduled) and the effective catalog changed. Carries + * the per-provider diff so clients can both refresh their model/provider + * caches and surface a summary ("3 models added") without re-diffing the + * whole config. + */ +export interface ModelCatalogChangedEvent { + readonly type: 'event.model_catalog.changed'; + readonly changed: readonly ProviderRefreshChange[]; + readonly unchanged: readonly string[]; + readonly failed: readonly ProviderRefreshFailure[]; +} + export interface PromptSubmittedEvent { readonly type: 'prompt.submitted'; readonly promptId: string; @@ -162,12 +206,15 @@ export type AgentEvent = | AgentStatusUpdatedEvent | SessionMetaUpdatedEvent | SessionCreatedEvent + | SessionListChangedEvent + | SkillCatalogChangedEvent | WorkspaceCreatedEvent | WorkspaceUpdatedEvent | WorkspaceDeletedEvent | SessionWorkChangedEvent | SessionStatusChangedEvent | ConfigChangedEvent + | ModelCatalogChangedEvent | PromptSubmittedEvent | BackgroundTaskStartedEvent | BackgroundTaskTerminatedEvent; diff --git a/packages/kap-server/src/transport/ws/v1/fsWatchBridge.ts b/packages/kap-server/src/transport/ws/v1/fsWatchBridge.ts index 7a0d100903..44fd0e5077 100644 --- a/packages/kap-server/src/transport/ws/v1/fsWatchBridge.ts +++ b/packages/kap-server/src/transport/ws/v1/fsWatchBridge.ts @@ -14,9 +14,16 @@ * {@link SessionEventBroadcaster}); it is **not** DI-registered and carries no * `_serviceBrand`. It owns the per-`(connection, session)` subscription sets, * fans the core feed out to each connection filtered by that connection's - * paths, and assigns a per-session monotonic `seq`. Frames are sent straight - * to the socket — they never enter the broadcaster / journal (fs changes are - * volatile: on overflow the client sees `truncated` and re-syncs). + * paths, and assigns a **per-connection monotonic `seq`**: each connection + * numbers only the frames actually delivered to it — matched change frames and + * `truncated` frames alike — starting at 1 with no gaps; a frame the + * connection never receives consumes nothing. A `seq` gap, a `truncated: true` + * payload, or an epoch change each invalidate the incremental stream: the + * client must fall back to a full baseline re-pull (snapshot + incremental + + * resync), per the wire contract pinned on `fsChangeEventSchema` + * (`@moonshot-ai/protocol` `fs.ts`). Frames are sent straight to the socket — + * they never enter the broadcaster / journal (fs changes are volatile: on + * overflow the client sees `truncated` and re-syncs). * * The core `ISessionFsWatchService` keeps a single subscription set per * session; the bridge drives it with the **union** of every connection's @@ -70,6 +77,8 @@ export interface FsWatchAck { interface ConnEntry { readonly conn: FsWatchConnection; readonly paths: Set; + /** Per-connection monotonic frame counter; starts at 0, pre-incremented per delivery. */ + seq: number; } interface SessionWatch { @@ -79,7 +88,6 @@ interface SessionWatch { readonly workspace: ISessionWorkspaceContext; readonly conns: Map; union: Set; - seq: number; sub: IDisposable | undefined; } @@ -126,7 +134,7 @@ export class FsWatchBridge { } if (entry === undefined) { - entry = { conn, paths: new Set() }; + entry = { conn, paths: new Set(), seq: 0 }; sw.conns.set(conn.id, entry); } for (const rel of toAdd) entry.paths.add(rel); @@ -185,7 +193,6 @@ export class FsWatchBridge { workspace: session.accessor.get(ISessionWorkspaceContext), conns: new Map(), union: new Set(), - seq: 0, sub: undefined, }; this.bySession.set(sessionId, sw); @@ -214,18 +221,23 @@ export class FsWatchBridge { private onSessionEvent(sessionId: string, ev: FsChangeEvent): void { const sw = this.bySession.get(sessionId); if (sw === undefined) return; - for (const { conn, paths } of sw.conns.values()) { + for (const entry of sw.conns.values()) { + const { conn, paths } = entry; let changes: FsChangeEntry[]; if (ev.truncated === true) { + // A truncated window is broadcast to every connection of the session + // watch, even one whose path set matches nothing: after it, no + // incremental state can be trusted, so every client must resync. changes = []; } else { changes = ev.changes.filter((c) => isUnderAny(c.path, paths)); + // No frame for this connection → its seq does not advance. if (changes.length === 0) continue; } - sw.seq += 1; + entry.seq += 1; const frame: FsChangedFrame = { type: 'event.fs.changed', - seq: sw.seq, + seq: entry.seq, session_id: sessionId, timestamp: new Date().toISOString(), payload: { diff --git a/packages/kap-server/src/transport/ws/v1/registerWsV1.ts b/packages/kap-server/src/transport/ws/v1/registerWsV1.ts index 215062a566..81629324c1 100644 --- a/packages/kap-server/src/transport/ws/v1/registerWsV1.ts +++ b/packages/kap-server/src/transport/ws/v1/registerWsV1.ts @@ -15,6 +15,7 @@ import type { CredentialValidator } from '../../../services/auth/credentials'; import { type IConnectionRegistry } from '../connectionRegistry'; import type { SessionEventBroadcaster } from './sessionEventBroadcaster'; import type { FsWatchBridge } from './fsWatchBridge'; +import type { SkillCatalogBridge } from './skillCatalogBridge'; import type { JournalLogger } from './sessionEventJournal'; import { WsConnectionV1 } from './wsConnectionV1'; import { selectWsBearerProtocol } from '../bearerProtocol'; @@ -27,6 +28,7 @@ export interface RegisterWsV1Options { readonly registry: IConnectionRegistry; readonly broadcaster: SessionEventBroadcaster; readonly fsWatchBridge: FsWatchBridge; + readonly skillCatalogBridge: SkillCatalogBridge; readonly logger?: JournalLogger; readonly maxBufferSize?: number; readonly flushIntervalMs?: number; @@ -44,6 +46,7 @@ export function registerWsV1(core: Scope, opts: RegisterWsV1Options): WebSocketS socket, broadcaster, fsWatchBridge: opts.fsWatchBridge, + skillCatalogBridge: opts.skillCatalogBridge, connectionRegistry: registry, validateCredential: opts.validateCredential, remoteAddress: req.socket.remoteAddress ?? null, diff --git a/packages/kap-server/src/transport/ws/v1/sessionEventBroadcaster.ts b/packages/kap-server/src/transport/ws/v1/sessionEventBroadcaster.ts index 1fb4ff5e43..ebe153cb3c 100644 --- a/packages/kap-server/src/transport/ws/v1/sessionEventBroadcaster.ts +++ b/packages/kap-server/src/transport/ws/v1/sessionEventBroadcaster.ts @@ -52,7 +52,12 @@ import { MAIN_AGENT_ID, } from '@moonshot-ai/agent-core-v2'; import type { TurnEndReason } from '@moonshot-ai/agent-core-v2/agent/loop/turnEvents'; -import type { SessionCreatedEvent, SessionMetaUpdatedEvent, Event } from './events'; +import type { + ModelCatalogChangedEvent, + SessionCreatedEvent, + SessionMetaUpdatedEvent, + Event, +} from './events'; import { isVolatileEventType } from './events'; import type { SessionCursor } from '../../../protocol/ws-control'; import type { InFlightTurn, SnapshotSubagent } from '../../../protocol/rest-snapshot'; @@ -77,12 +82,17 @@ export interface BufferedSinceResult { /** When set, the client must rebuild from the snapshot and re-subscribe. */ resyncRequired: ResyncReason | false; currentSeq: number; - epoch: string; + /** + * Current journal epoch, if the journal has a baseline. `undefined` (absent + * on the wire) means "no baseline yet" — distinct from a changed baseline. + */ + epoch: string | undefined; } export interface SessionSnapshotState { seq: number; - epoch: string; + /** Current journal epoch, or `undefined` while the journal has no baseline. */ + epoch: string | undefined; inFlightTurn: InFlightTurn | null; subagents: SnapshotSubagent[]; } @@ -146,6 +156,9 @@ const GLOBAL_SESSION_ID = '__global__'; async function disposeSessionState(state: SessionState): Promise { for (const d of state.lifecycleDisposables) d.dispose(); for (const d of state.agentDisposables.values()) d.dispose(); + // Drain every queued dispatch (the journal append happens inside the queue) + // before the final flush — otherwise close() drops the dispatch tail. + await state.queue; await state.journal.close(); } @@ -165,7 +178,11 @@ export class SessionEventBroadcaster { private readonly pendingStates = new Map>(); private readonly maxBufferSize: number; private readonly coreEventSubscription: IDisposable; + /** Fire-and-forget dispatches still in flight (possibly inside `ensureState`). */ + private readonly inFlightDispatches = new Set>(); private closed = false; + /** Process-local monotonic seq for volatile global events (never journaled). */ + private globalSeq = 0; constructor( private readonly opts: { @@ -204,7 +221,7 @@ export class SessionEventBroadcaster { ): Promise { const state = await this.ensureState(sessionId); if (state === undefined) { - return { events: [], resyncRequired: 'session_recreated', currentSeq: 0, epoch: '' }; + return { events: [], resyncRequired: 'session_recreated', currentSeq: 0, epoch: undefined }; } // Drain so the cursor reflects everything dispatched so far. await state.queue; @@ -237,20 +254,27 @@ export class SessionEventBroadcaster { : entries.filter(({ envelope }) => matchesAgentFilter(envelope, filter)); // Serve from the memory tail when it fully covers the gap; else the journal. - const tailStart = tail[0]?.seq; - if (tailStart !== undefined && tailStart <= cursor.seq + 1) { - const events = applyFilter(tail.filter((e) => e.seq > cursor.seq)); - return { events, resyncRequired: false, currentSeq, epoch }; + // While the journal has unrecovered write failures, never serve from the + // tail: those events are not durable, and replaying them as if they were + // would resurrect the "fake durable" hole after a restart. `readSince` + // retries the pending flush and throws the sticky JournalStorageError + // instead — the replay edge maps that to a client-visible resync. + if (!journal.writeFailure) { + const tailStart = tail[0]?.seq; + if (tailStart !== undefined && tailStart <= cursor.seq + 1) { + const events = applyFilter(tail.filter((e) => e.seq > cursor.seq)); + return { events, resyncRequired: false, currentSeq, epoch }; + } } const fromDisk = await journal.readSince(cursor.seq, this.maxBufferSize); return { events: applyFilter(fromDisk), resyncRequired: false, currentSeq, epoch }; } - async getCursor(sessionId: string): Promise<{ seq: number; epoch: string }> { + async getCursor(sessionId: string): Promise<{ seq: number; epoch: string | undefined }> { const state = await this.ensureState(sessionId); if (state === undefined) { const cold = await this.readColdWatermark(sessionId); - return cold ?? { seq: 0, epoch: '' }; + return cold ?? { seq: 0, epoch: undefined }; } await state.queue; return { seq: state.journal.seq, epoch: state.journal.epoch }; @@ -263,7 +287,7 @@ export class SessionEventBroadcaster { const cold = await this.readColdWatermark(sessionId); return cold !== undefined ? { ...cold, inFlightTurn: null, subagents: [] } - : { seq: 0, epoch: '', inFlightTurn: null, subagents: [] }; + : { seq: 0, epoch: undefined, inFlightTurn: null, subagents: [] }; } await state.queue; return { @@ -279,11 +303,12 @@ export class SessionEventBroadcaster { * (carried over from a prior process, or created by v1). Opens the journal * transiently — no agent/interaction listeners and not cached in * `this.sessions` — so a later live activation still attaches subscriptions. + * The open is read-only (zero bytes written, no fabricated epoch). * Returns `undefined` when the session is unknown to the index (truly absent). */ private async readColdWatermark( sessionId: string, - ): Promise<{ seq: number; epoch: string } | undefined> { + ): Promise<{ seq: number; epoch: string | undefined } | undefined> { const summary = await this.opts.core.accessor.get(ISessionIndex).get(sessionId); if (summary === undefined) return undefined; const journal = await SessionEventJournal.open( @@ -297,6 +322,10 @@ export class SessionEventBroadcaster { async close(): Promise { if (this.closed) return; + // Settle dispatches already in flight before flagging closed — a dispatch + // still inside `ensureState` would otherwise see `closed` and abandon its + // event (and its journal state). + await this.drainDispatches(); this.closed = true; this.coreEventSubscription.dispose(); for (const state of this.sessions.values()) { @@ -305,6 +334,19 @@ export class SessionEventBroadcaster { this.sessions.clear(); } + /** + * Settle every fire-and-forget dispatch currently in flight. start.ts calls + * this BEFORE disposing session scopes (an `ensureState` resumed after its + * session's scope died would abandon the dispatch, losing e.g. the + * `event.session.created` journal entry for a short-lived session); close() + * calls it before flipping `closed` for the same reason. + */ + async drainDispatches(): Promise { + while (this.inFlightDispatches.size > 0) { + await Promise.all(this.inFlightDispatches); + } + } + private ensureState(sessionId: string): Promise { if (this.closed) return Promise.resolve(undefined); const existing = this.sessions.get(sessionId); @@ -415,6 +457,32 @@ export class SessionEventBroadcaster { } private onCoreEvent(event: GlobalEvent): void { + if (event.type === 'session.list_changed') { + // Published by `SessionListWatchService` when a workspace/session + // directory appears or disappears under the shared sessions tree + // (possibly by a peer instance). Payload-less go-refetch advice: fanned + // out volatile under the global watermark, never journaled. + this.dispatchGlobal({ + type: 'session.list_changed', + agentId: 'main', + sessionId: GLOBAL_SESSION_ID, + }); + return; + } + if (event.type === 'event.model_catalog.changed') { + const payload = modelCatalogChangedPayload(event.payload); + if (payload === undefined) return; + const modelEvent: ModelCatalogChangedEvent = { + type: 'event.model_catalog.changed', + ...payload, + }; + this.dispatchGlobal({ + ...modelEvent, + agentId: 'main', + sessionId: GLOBAL_SESSION_ID, + }); + return; + } if (event.type === 'event.session.created') { const payload = sessionCreatedPayload(event.payload); if (payload === undefined) return; @@ -426,13 +494,15 @@ export class SessionEventBroadcaster { // `sessionStatusChanged` reducer is a no-op for the unknown session and // kimi-web's Stop button (gated on session.status === 'running') never // renders. Mirrors v1's `isGlobalSessionEvent` broadcast of creation. - void this.dispatchSessionEvent(payload.sessionId, { - type: 'event.session.created', - session: payload.session, - agentId: 'main', - sessionId: payload.sessionId, - } as Event).catch((error: unknown) => - this.logDispatchError(payload.sessionId, 'event.session.created', error), + this.trackDispatch( + this.dispatchSessionEvent(payload.sessionId, { + type: 'event.session.created', + session: payload.session, + agentId: 'main', + sessionId: payload.sessionId, + } as Event), + payload.sessionId, + 'event.session.created', ); return; } @@ -449,22 +519,54 @@ export class SessionEventBroadcaster { // exactly like v1. const sessionId = sessionMetaUpdatedSessionId(event.payload); if (sessionId === undefined) return; - void this.dispatchSessionEvent(sessionId, { - type: 'session.meta.updated', - ...payload, - agentId: 'main', + this.trackDispatch( + this.dispatchSessionEvent(sessionId, { + type: 'session.meta.updated', + ...payload, + agentId: 'main', + sessionId, + } as Event), sessionId, - } as Event).catch((error: unknown) => - this.logDispatchError(sessionId, 'session.meta.updated', error), + 'session.meta.updated', ); } } - private async dispatchGlobal(event: Event): Promise { - const state = await this.ensureGlobalState(); - state.queue = state.queue - .then(() => this.dispatch(state, event, isVolatileEventType(event.type))) - .catch((error: unknown) => this.logDispatchDropped(state.sessionId, event.type, error)); + /** + * Fire a dispatch without awaiting it, but keep it drainable: errors are + * logged exactly once here, and `drainDispatches()` / `close()` can settle + * the still-running dispatch before session scopes are disposed. + */ + private trackDispatch(promise: Promise, sessionId: string, eventType: string): void { + const tracked = promise.catch((error: unknown) => + this.logDispatchError(sessionId, eventType, error), + ); + this.inFlightDispatches.add(tracked); + void tracked.finally(() => this.inFlightDispatches.delete(tracked)); + } + + /** + * Fan a global advisory event out to every live connection. Deliberately + * volatile: never journaled and never replayed — the old `__global__.jsonl` + * was a multi-writer artifact with no read path (pure disk garbage), and its + * only payload (`event.model_catalog.changed`) is cache-refresh advice. + * `seq` is a process-local monotonic counter, NOT a durable watermark, and + * the envelope carries no epoch. + */ + private dispatchGlobal(event: Event): void { + this.globalSeq += 1; + const envelope = this.buildEnvelope(this.globalSeq, GLOBAL_SESSION_ID, event, { + volatile: true, + }); + // Global events bypass the agent allowlist entirely (isGlobalEvent), same + // fan-out rule as `dispatch` — only transport is direct, not per-session. + for (const target of this.allTargets()) { + try { + target.send(envelope); + } catch { + // best-effort fan-out; a broken target is dropped, not fatal + } + } } /** @@ -751,13 +853,17 @@ export class SessionEventBroadcaster { } /** - * A queued dispatch rejected: the event is permanently lost (and, for durable - * events, the seq is skipped). Warn instead of swallowing it silently. + * A queued dispatch rejected: the event is permanently lost. For a durable + * event the seq is skipped, leaving an on-disk seq gap — that gap is itself + * the resync boundary: clients detect it and rebuild via resync, so the + * loss stays observable instead of silent. Journal storage failures also + * land here (`nextSeq()`/`append()` throw once the journal is sticky). + * Warn, never swallow. */ private logDispatchDropped(sessionId: string, eventType: string, error: unknown): void { this.opts.logger?.warn( { sessionId, eventType, err: error }, - 'session event dispatch failed; event dropped', + 'session event dispatch failed; event dropped (durable seq gap → client resyncs)', ); } @@ -1078,3 +1184,27 @@ function sessionCreatedPayload( if (sessionId === undefined || session === undefined) return undefined; return { sessionId, session }; } + +/** + * Validate the `event.model_catalog.changed` payload published on the core + * `IEventService` (`IModelCatalogService` refresh / auth change): the three + * per-provider diff arrays are all the v1 frame carries. + */ +function modelCatalogChangedPayload( + payload: unknown, +): Pick | undefined { + if (typeof payload !== 'object' || payload === null) return undefined; + const candidate = payload as Partial; + if ( + !Array.isArray(candidate.changed) || + !Array.isArray(candidate.unchanged) || + !Array.isArray(candidate.failed) + ) { + return undefined; + } + return { + changed: candidate.changed, + unchanged: candidate.unchanged, + failed: candidate.failed, + }; +} diff --git a/packages/kap-server/src/transport/ws/v1/sessionEventJournal.ts b/packages/kap-server/src/transport/ws/v1/sessionEventJournal.ts index a2a29ef5bf..8885e22e59 100644 --- a/packages/kap-server/src/transport/ws/v1/sessionEventJournal.ts +++ b/packages/kap-server/src/transport/ws/v1/sessionEventJournal.ts @@ -11,25 +11,64 @@ * Invariants: * - `seq` is assigned at append time, starts at 1, and is monotonic across * server restarts (recovered by scanning the file on open). - * - `epoch` identifies this journal incarnation. It changes only when the - * file is unreadable/corrupt at open (we start a fresh journal) — clients - * holding cursors from the old epoch get `resync_required(epoch_changed)`. + * - `epoch` identifies this journal incarnation. It is born lazily on the + * FIRST durable append (never on a cold read) and changes only when the + * file is unreadable/corrupt at open (the next append starts a fresh + * journal) — clients holding cursors from the old epoch get + * `resync_required(epoch_changed)`. A journal with no baseline yet reports + * `epoch: undefined`; absent on the wire means "no baseline", which is + * distinct from "baseline changed". + * - On open with several headers (crash after a rotation) the LAST header + * wins — only the newest incarnation is authoritative. * - Only durable events are written (volatile frames never touch the journal; * see `VOLATILE_EVENT_TYPES` in `./events`). * * Durability model: `append()` is synchronous (callers need the seq immediately - * for fan-out); bytes are flushed on a microtask-scheduled async batch. - * `readSince()` flushes first so replay never misses queued lines. A torn - * trailing line from a crash is tolerated and ignored on open. + * for fan-out); bytes are flushed on a microtask-scheduled async batch. Each + * batch uses a single `open(path, 'a')` → write → fsync → close cycle. Pending + * lines are dequeued only AFTER the batch is durable; a failed round keeps the + * whole batch (and the pending header) for the retry. After + * {@link STICKY_FAILURE_THRESHOLD} consecutive failures the journal goes + * sticky: `nextSeq()`/`append()` fail fast (pending can never grow unbounded) + * and `readSince()` throws a {@link JournalStorageError} instead of silently + * serving fewer events — "not served" must stay distinguishable from "nothing + * to serve". `readSince()` flushes first so replay never misses queued lines. + * A torn trailing line from a crash is tolerated and ignored on open, and a + * pure cold-read open → close writes zero bytes. */ import { createReadStream } from 'node:fs'; -import { appendFile, mkdir } from 'node:fs/promises'; +import { mkdir, open as openFile } from 'node:fs/promises'; import { dirname, join } from 'node:path'; import { ulid } from 'ulid'; const JOURNAL_VERSION = 1; +/** + * Consecutive write failures that move the journal into the sticky storage + * failure state. Bounded so a single transient error is retried by the next + * flush round, while a persistently failing disk fails fast instead of growing + * the pending queue forever. + */ +const STICKY_FAILURE_THRESHOLD = 2; + +/** + * Explicit journal storage failure. Thrown by `nextSeq()`/`append()` (sticky + * fail-fast) and by `readSince()` (never answer a replay from a journal whose + * writes failed). Distinguishable via `instanceof` so the replay edge can map + * it to a client-visible resync instead of an empty event page. + */ +export class JournalStorageError extends Error { + readonly filePath: string; + + constructor(filePath: string, cause: unknown) { + const causeMessage = cause instanceof Error ? cause.message : String(cause); + super(`event journal storage failed for ${filePath}: ${causeMessage}`, { cause }); + this.name = 'JournalStorageError'; + this.filePath = filePath; + } +} + /** * Wire event envelope — matches `wsEventEnvelopeSchema` / * `sessionEventMessageSchema` in the local `protocol/ws-control` catalog. Defined @@ -76,17 +115,19 @@ export class SessionEventJournal { private _seq: number; private pendingLines: string[] = []; private flushPromise: Promise | undefined; - private headerPending: boolean; + private headerPending = false; + private currentEpoch: string | undefined; + private consecutiveFailures = 0; + private stickyError: JournalStorageError | undefined; private constructor( private readonly filePath: string, private readonly logger: JournalLogger, - public readonly epoch: string, + epoch: string | undefined, lastSeq: number, - isFresh: boolean, ) { this._seq = lastSeq; - this.headerPending = isFresh; + this.currentEpoch = epoch; } /** Highest durable seq appended (0 if none). */ @@ -95,11 +136,36 @@ export class SessionEventJournal { } /** - * Open (or create) the journal for `filePath`. Scans an existing file to - * recover `{epoch, lastSeq}`. A missing file or an unreadable header starts - * a fresh journal with a new epoch. + * Current journal epoch. `undefined` until the first durable append stamps a + * header — a journal with no baseline must present "absent", not a random + * placeholder (repeated cold reads of the same journal used to yield + * different fabricated epochs and trigger fake `epoch_changed` resyncs). */ - static async open(filePath: string, logger: JournalLogger = noopLogger): Promise { + get epoch(): string | undefined { + return this.currentEpoch; + } + + /** + * Whether writes have failed and not yet recovered, i.e. pending lines are + * not durably on disk. While this holds, replay must consult the journal + * itself (`readSince`, which retries the flush and throws once sticky) + * rather than any in-memory copy of the events. + */ + get writeFailure(): boolean { + return this.stickyError !== undefined || this.consecutiveFailures > 0; + } + + /** + * Open (or create-on-first-append) the journal for `filePath`. Scans an + * existing file to recover `{epoch, lastSeq}`. This open is READ-ONLY: a + * missing file or an unreadable/missing header yields a journal with + * `epoch: undefined` and seq 0, writes nothing, and defers the fresh epoch + * to the first real `append()`. With several headers the last one wins. + */ + static async open( + filePath: string, + logger: JournalLogger = noopLogger, + ): Promise { let epoch: string | undefined; let lastSeq = 0; let sawAnyLine = false; @@ -110,7 +176,7 @@ export class SessionEventJournal { const parsed = parseJournalLine(raw); if (parsed === undefined) continue; // torn/corrupt line — skip if (parsed.kind === 'journal_header') { - if (epoch === undefined) epoch = parsed.epoch; + epoch = parsed.epoch; // last header wins continue; } if (parsed.seq > lastSeq) lastSeq = parsed.seq; @@ -120,30 +186,43 @@ export class SessionEventJournal { if (code !== 'ENOENT') { logger.warn( { filePath, err: String(error) }, - 'event journal unreadable; starting a fresh epoch', + 'event journal unreadable; starting a fresh epoch on next append', ); } } if (epoch === undefined) { if (sawAnyLine) { - // File exists but has no parseable header — treat as corrupt and - // start a fresh incarnation. Old cursors will epoch-mismatch. - logger.warn({ filePath }, 'event journal missing header; rotating to a fresh epoch'); + // File exists but has no parseable header — treat as corrupt and let + // the next append start a fresh incarnation. Old cursors will + // epoch-mismatch once the new header lands. + logger.warn({ filePath }, 'event journal missing header; rotating to a fresh epoch on next append'); } - return new SessionEventJournal(filePath, logger, `ep_${ulid()}`, 0, true); + return new SessionEventJournal(filePath, logger, undefined, 0); } - return new SessionEventJournal(filePath, logger, epoch, lastSeq, false); + return new SessionEventJournal(filePath, logger, epoch, lastSeq); } /** Reserve the next durable seq. The caller must follow with `append()`. */ nextSeq(): number { + this.throwIfSticky(); + if (this.currentEpoch === undefined) { + // First durable write of this incarnation: seq and epoch are born + // together, so every envelope can carry the epoch — including the very + // first one (the broadcaster stamps envelopes before `append()` runs). + // This is the ONLY place an epoch materializes: cold reads never call + // `nextSeq`, so they stay byte-free. The header latch alone writes + // nothing — `flushOnce` only runs when lines are pending (see `flush`). + this.currentEpoch = `ep_${ulid()}`; + this.headerPending = true; + } this._seq += 1; return this._seq; } /** Queue a durable event line for write-behind flush. */ append(seq: number, envelope: EventEnvelope): void { + this.throwIfSticky(); const line: JournalEventLine = { kind: 'event', seq, envelope }; this.pendingLines.push(JSON.stringify(line)); this.scheduleFlush(); @@ -152,6 +231,10 @@ export class SessionEventJournal { /** Read journal entries with `seq > fromSeqExclusive`, capped at `limit`. */ async readSince(fromSeqExclusive: number, limit: number): Promise { await this.flush(); + // Never answer a replay from a journal whose writes failed: a partial + // read would be a lie. Surface the sticky error so the edge can force a + // client-visible resync instead. + if (this.stickyError !== undefined) throw this.stickyError; const out: JournalEntry[] = []; try { for await (const raw of readLines(this.filePath)) { @@ -171,55 +254,101 @@ export class SessionEventJournal { async flush(): Promise { while (this.flushPromise !== undefined || this.pendingLines.length > 0) { if (this.flushPromise === undefined) { - this.flushPromise = this.flushOnce().finally(() => { + this.flushPromise = this.flushOnce().then(() => { this.flushPromise = undefined; }); } await this.flushPromise; + // Give up once sticky instead of hot-spinning on a persistently failing + // disk; the kept pending lines are retried by the next append-scheduled + // or read-triggered round. + if (this.stickyError !== undefined) return; } } + /** Flush whatever is pending; never throws (the read edge throws instead). */ async close(): Promise { await this.flush(); } private scheduleFlush(): void { if (this.flushPromise !== undefined) return; - this.flushPromise = this.flushOnce().finally(() => { + this.flushPromise = this.flushOnce().then((succeeded) => { this.flushPromise = undefined; // Appends that arrived while this flush was in flight are still pending: // chain the next round instead of parking them until a later append (or - // `close()`) happens to trigger one. - if (this.pendingLines.length > 0) this.scheduleFlush(); + // `close()`) happens to trigger one. A FAILED round must NOT chain — its + // kept lines are retried only by the next append-scheduled or + // read-triggered round (see `flush`), otherwise a persistently failing + // disk hot-spins open attempts forever, even past `close()`. + if (succeeded && this.pendingLines.length > 0) this.scheduleFlush(); }); } - private async flushOnce(): Promise { - // Snapshot the queue first so appends during the await are picked up by - // the next flush round, never lost. - const lines: string[] = []; - if (this.headerPending) { - const header: JournalHeaderLine = { - kind: 'journal_header', - version: JOURNAL_VERSION, - epoch: this.epoch, - created_at: Date.now(), - }; - lines.push(JSON.stringify(header)); - this.headerPending = false; - } - lines.push(...this.pendingLines); - this.pendingLines = []; - if (lines.length === 0) return; + private async flushOnce(): Promise { + // Snapshot the queue WITHOUT clearing it: lines are dequeued only after + // the batch is durably on disk (write + fsync succeeded). A failed round + // keeps every line — and a pending header — for the next retry. + const headerLine = this.headerPending ? this.buildHeaderLine() : undefined; + const pendingSnapshot = this.pendingLines.slice(); + if (headerLine === undefined && pendingSnapshot.length === 0) return true; + const lines = headerLine !== undefined ? [headerLine, ...pendingSnapshot] : pendingSnapshot; try { await mkdir(dirname(this.filePath), { recursive: true }); - await appendFile(this.filePath, lines.join('\n') + '\n', 'utf8'); + // One open per batch: write header+lines, fsync, close. + const handle = await openFile(this.filePath, 'a'); + try { + await handle.writeFile(lines.join('\n') + '\n', 'utf8'); + await handle.sync(); + } finally { + await handle.close(); + } } catch (error) { - this.logger.warn( - { filePath: this.filePath, err: String(error) }, - 'event journal write failed; events remain live-only this round', - ); + this.consecutiveFailures += 1; + if (this.consecutiveFailures >= STICKY_FAILURE_THRESHOLD && this.stickyError === undefined) { + // Sticky storage failure: durable events must never silently degrade + // to live-only. kap-server has no telemetry wiring today, so the + // sticky transition is an error-level log breadcrumb (the design's + // `session.journal_write_failed` event lands with the wiring). + const logError = this.logger.error?.bind(this.logger) ?? this.logger.warn.bind(this.logger); + logError( + { + filePath: this.filePath, + err: String(error), + consecutiveFailures: this.consecutiveFailures, + }, + 'event journal storage failed persistently; entering sticky failure state — appends fail fast and readSince throws', + ); + this.stickyError = new JournalStorageError(this.filePath, error); + } else { + this.logger.warn( + { filePath: this.filePath, err: String(error) }, + 'event journal write failed; batch kept pending for retry', + ); + } + return false; } + // Success: dequeue exactly the lines written above (appends during the + // await stay queued for the next round) and release the header latch. + this.pendingLines.splice(0, pendingSnapshot.length); + if (headerLine !== undefined) this.headerPending = false; + this.consecutiveFailures = 0; + return true; + } + + private buildHeaderLine(): string | undefined { + if (this.currentEpoch === undefined) return undefined; + const header: JournalHeaderLine = { + kind: 'journal_header', + version: JOURNAL_VERSION, + epoch: this.currentEpoch, + created_at: Date.now(), + }; + return JSON.stringify(header); + } + + private throwIfSticky(): void { + if (this.stickyError !== undefined) throw this.stickyError; } } diff --git a/packages/kap-server/src/transport/ws/v1/skillCatalogBridge.ts b/packages/kap-server/src/transport/ws/v1/skillCatalogBridge.ts new file mode 100644 index 0000000000..af7515e77b --- /dev/null +++ b/packages/kap-server/src/transport/ws/v1/skillCatalogBridge.ts @@ -0,0 +1,137 @@ +/** + * `SkillCatalogBridge` — volatile `/api/v1/ws` delivery for skill-catalog changes. + * + * Turns the core `ISessionSkillCatalog.onDidChange` feed (payload: the changed + * source id) into `skill_catalog.changed` frames on the v1 WebSocket: + * + * server → `{type:'skill_catalog.changed', seq, session_id, timestamp, volatile:true, payload}` + * + * Sibling of {@link FsWatchBridge}: the frame is a pure go-refetch hint + * (clients re-pull `GET /sessions/{sid}/skills`), so it is sent straight to the + * socket and never enters the broadcaster / journal — no durable `seq`, no + * replay after reconnect. Each connection numbers only the frames actually + * delivered to it (per-connection monotonic `seq`, starting at 1); unlike the + * fs channel, gaps carry no meaning because every hint triggers a full + * re-pull, and `volatile: true` keeps older clients from mistaking the + * connection-local `seq` for the durable watermark. + * + * Delivery set = connections subscribed to the session (the broadcaster's + * subscribe/unsubscribe lifecycle, driven by {@link WsConnectionV1}), NOT the + * fs-watch path sets: the bridge keeps one core subscription per session with + * at least one attached connection and releases it when the last detaches. + */ + +import { + type IDisposable, + ISessionSkillCatalog, + ISessionLifecycleService, + type Scope, +} from '@moonshot-ai/agent-core-v2'; +import type { SkillCatalogChangedEvent } from './events'; + +import type { EventEnvelope, JournalLogger } from './sessionEventJournal'; + +export interface SkillCatalogChangedFrame { + readonly type: 'skill_catalog.changed'; + readonly seq: number; + readonly session_id: string; + readonly timestamp: string; + readonly volatile: true; + readonly payload: SkillCatalogChangedEvent; +} + +/** Minimal connection surface the bridge needs (satisfied by `WsConnectionV1`). */ +export interface SkillCatalogConnection { + readonly id: string; + send(envelope: EventEnvelope): void; +} + +interface ConnEntry { + readonly conn: SkillCatalogConnection; + /** Per-connection monotonic frame counter; starts at 0, pre-incremented per delivery. */ + seq: number; +} + +interface SessionWatch { + readonly id: string; + readonly skillCatalog: ISessionSkillCatalog; + readonly conns: Map; + sub: IDisposable | undefined; +} + +export class SkillCatalogBridge { + private readonly core: Scope; + private readonly logger: JournalLogger | undefined; + private readonly bySession = new Map(); + + constructor(opts: { core: Scope; logger?: JournalLogger }) { + this.core = opts.core; + this.logger = opts.logger; + } + + /** Start delivering the session's catalog hints to `conn` (session subscribe). */ + attachSession(conn: SkillCatalogConnection, sessionId: string): void { + let sw = this.bySession.get(sessionId); + if (sw === undefined) { + const session = this.core.accessor.get(ISessionLifecycleService).get(sessionId); + if (session === undefined) return; + sw = { + id: sessionId, + skillCatalog: session.accessor.get(ISessionSkillCatalog), + conns: new Map(), + sub: undefined, + }; + this.bySession.set(sessionId, sw); + } + if (!sw.conns.has(conn.id)) { + sw.conns.set(conn.id, { conn, seq: 0 }); + } + sw.sub ??= sw.skillCatalog.onDidChange((sourceId) => { + this.onSessionEvent(sessionId, sourceId); + }); + } + + /** Stop delivering to `conn` (session unsubscribe); releases the core + * subscription when the session has no connections left. */ + detachSession(conn: SkillCatalogConnection, sessionId: string): void { + const sw = this.bySession.get(sessionId); + if (sw === undefined) return; + sw.conns.delete(conn.id); + if (sw.conns.size === 0) this.teardownSession(sw); + } + + /** Drop every subscription held by `conn` (called on socket close). */ + detachConnection(conn: SkillCatalogConnection): void { + for (const sw of Array.from(this.bySession.values())) { + if (!sw.conns.delete(conn.id)) continue; + if (sw.conns.size === 0) this.teardownSession(sw); + } + } + + private teardownSession(sw: SessionWatch): void { + sw.sub?.dispose(); + sw.sub = undefined; + this.bySession.delete(sw.id); + } + + private onSessionEvent(sessionId: string, sourceId: string): void { + const sw = this.bySession.get(sessionId); + if (sw === undefined) return; + for (const entry of sw.conns.values()) { + entry.seq += 1; + const frame: SkillCatalogChangedFrame = { + type: 'skill_catalog.changed', + seq: entry.seq, + session_id: sessionId, + timestamp: new Date().toISOString(), + volatile: true, + payload: { type: 'skill_catalog.changed', sourceId }, + }; + try { + entry.conn.send(frame as EventEnvelope); + } catch (error) { + this.logger?.warn({ sessionId, err: String(error) }, 'skill-catalog send failed'); + } + } + } +} diff --git a/packages/kap-server/src/transport/ws/v1/wsConnectionV1.ts b/packages/kap-server/src/transport/ws/v1/wsConnectionV1.ts index 8953acc0c2..5fb9a8c745 100644 --- a/packages/kap-server/src/transport/ws/v1/wsConnectionV1.ts +++ b/packages/kap-server/src/transport/ws/v1/wsConnectionV1.ts @@ -23,6 +23,7 @@ import type { CredentialValidator } from '../../../services/auth/credentials'; import type { IConnectionRegistry } from '../connectionRegistry'; import { type EventEnvelope, + JournalStorageError, type JournalLogger, } from './sessionEventJournal'; import { @@ -37,6 +38,7 @@ import { type SessionEventBroadcaster, } from './sessionEventBroadcaster'; import { FsWatchBridge } from './fsWatchBridge'; +import { SkillCatalogBridge } from './skillCatalogBridge'; const DEFAULT_MAX_BUFFER_SIZE = 1000; @@ -59,6 +61,7 @@ export interface WsConnectionV1Options { readonly socket: WebSocket; readonly broadcaster: SessionEventBroadcaster; readonly fsWatchBridge?: FsWatchBridge; + readonly skillCatalogBridge?: SkillCatalogBridge; readonly connectionRegistry: IConnectionRegistry; /** * Present-only credential check for the post-connect `client_hello` @@ -89,6 +92,7 @@ export class WsConnectionV1 implements BroadcastTarget { private readonly socket: WebSocket; private readonly broadcaster: SessionEventBroadcaster; private readonly fsWatchBridge?: FsWatchBridge; + private readonly skillCatalogBridge?: SkillCatalogBridge; private readonly validateCredential?: CredentialValidator; private readonly maxBufferSize: number; private readonly flushIntervalMs: number; @@ -116,6 +120,7 @@ export class WsConnectionV1 implements BroadcastTarget { this.socket = opts.socket; this.broadcaster = opts.broadcaster; this.fsWatchBridge = opts.fsWatchBridge; + this.skillCatalogBridge = opts.skillCatalogBridge; this.validateCredential = opts.validateCredential; this.logger = opts.logger; this.maxBufferSize = opts.maxBufferSize ?? DEFAULT_MAX_BUFFER_SIZE; @@ -236,6 +241,7 @@ export class WsConnectionV1 implements BroadcastTarget { continue; } this.subscriptions.set(sid, filter); + this.skillCatalogBridge?.attachSession(this, sid); accepted.push(sid); const cursor = cursors?.[sid]; if (cursor !== undefined) { @@ -262,6 +268,7 @@ export class WsConnectionV1 implements BroadcastTarget { for (const sid of sessionIds) { this.broadcaster.unsubscribe(sid, this); this.subscriptions.delete(sid); + this.skillCatalogBridge?.detachSession(this, sid); } this.sendFrame( buildAck(frame.id ?? '', 0, 'success', { @@ -316,6 +323,7 @@ export class WsConnectionV1 implements BroadcastTarget { return; } this.subscriptions.set(sid, filter); + this.skillCatalogBridge?.attachSession(this, sid); accepted.push(sid); if (cursor !== undefined) { await this.replay(sid, cursor, filter, resyncRequired, serverCursors); @@ -332,7 +340,24 @@ export class WsConnectionV1 implements BroadcastTarget { resyncRequired: string[], serverCursors: Record, ): Promise { - const result = await this.broadcaster.getBufferedSince(sid, cursor, filter); + let result; + try { + result = await this.broadcaster.getBufferedSince(sid, cursor, filter); + } catch (error) { + if (error instanceof JournalStorageError) { + // The journal cannot serve the gap (sticky storage failure): answering + // with an empty page would be a lie — "not served" must stay + // distinguishable from "nothing to serve". Force a resync so the + // client rebuilds from the snapshot and re-subscribes. The protocol + // reason enum has no storage-failure entry, so this rides the + // existing `epoch_changed` branch; the client behavior (rebuild from + // snapshot) is identical for every reason. + this.sendFrame(buildResyncRequired(sid, 'epoch_changed', cursor.seq, cursor.epoch)); + resyncRequired.push(sid); + return; + } + throw error; + } if (result.resyncRequired !== false) { this.sendFrame( buildResyncRequired(sid, result.resyncRequired as ResyncReason, result.currentSeq, result.epoch), @@ -461,6 +486,7 @@ export class WsConnectionV1 implements BroadcastTarget { this.outbound = []; for (const sid of this.subscriptions.keys()) this.broadcaster.unsubscribe(sid, this); this.fsWatchBridge?.detachConnection(this); + this.skillCatalogBridge?.detachConnection(this); // registry removal is handled by registerWsV1 on the socket 'close' event. } } diff --git a/packages/kap-server/src/transport/ws/wsConnection.ts b/packages/kap-server/src/transport/ws/wsConnection.ts index 445508c743..89206fd7e6 100644 --- a/packages/kap-server/src/transport/ws/wsConnection.ts +++ b/packages/kap-server/src/transport/ws/wsConnection.ts @@ -227,7 +227,7 @@ export class WsConnection { } else { this.logger.warn(ctx, 'ws rpc call failed'); } - this.send({ type: 'error', id: msg.id, code: env.code, msg: env.msg }); + this.send({ type: 'error', id: msg.id, code: env.code, msg: env.msg, details: env.details }); } } @@ -283,7 +283,7 @@ export class WsConnection { } else { this.logger.warn(ctx, 'ws listen failed'); } - this.send({ type: 'error', id: msg.id, code: env.code, msg: env.msg }); + this.send({ type: 'error', id: msg.id, code: env.code, msg: env.msg, details: env.details }); return; } diff --git a/packages/kap-server/src/transport/ws/wsProtocol.ts b/packages/kap-server/src/transport/ws/wsProtocol.ts index 7710cfa239..0a013aed2d 100644 --- a/packages/kap-server/src/transport/ws/wsProtocol.ts +++ b/packages/kap-server/src/transport/ws/wsProtocol.ts @@ -85,6 +85,8 @@ export interface ErrorMessage { readonly id: string; readonly code: number; readonly msg: string; + /** Structured error payload (e.g. `SessionOwnershipDetails` under 40921); absent when the mapped error carries none. */ + readonly details?: unknown; } export interface ListenResultMessage { diff --git a/packages/kap-server/test/boot.test.ts b/packages/kap-server/test/boot.test.ts index dba60db6fc..7cb9b129d3 100644 --- a/packages/kap-server/test/boot.test.ts +++ b/packages/kap-server/test/boot.test.ts @@ -1,4 +1,4 @@ -import { mkdtemp, rm } from 'node:fs/promises'; +import { mkdtemp, readFile, rm } from 'node:fs/promises'; import { createServer, type Server } from 'node:net'; import { tmpdir } from 'node:os'; import { join } from 'node:path'; @@ -6,7 +6,15 @@ import { join } from 'node:path'; import { pino } from 'pino'; import { afterEach, describe, expect, it } from 'vitest'; -import { hostRequestHeadersSeed, IHostRequestHeaders, ISkillCatalogRuntimeOptions } from '@moonshot-ai/agent-core-v2'; +import { + type DomainEvent, + hostRequestHeadersSeed, + IAgentLifecycleService, + IEventBus, + IHostRequestHeaders, + ISessionLifecycleService, + ISkillCatalogRuntimeOptions, +} from '@moonshot-ai/agent-core-v2'; import { listLiveServerInstances } from '../src/instanceRegistry'; import { listenWithPortRetry, type RunningServer, startServer } from '../src/start'; @@ -158,6 +166,75 @@ describe('server-v2 boot', () => { }); expect(server.core.accessor.get(ISkillCatalogRuntimeOptions).explicitDirs).toBeUndefined(); }); + + it('close() is idempotent under concurrent triggers and safe to re-invoke', async () => { + home = await mkdtemp(join(tmpdir(), 'kimi-server-v2-close-')); + server = await startServer({ + host: '127.0.0.1', + port: 0, + homeDir: home, + logLevel: 'silent', + }); + + // The remote /api/v1/shutdown path and the CLI signal handler converge on + // this same close(): double triggers must share ONE in-flight close + // rather than racing fastify.close / core.dispose re-entrantly. + const first = server.close(); + const second = server.close(); + expect(second).toBe(first); + await expect(first).resolves.toBeUndefined(); + + // A late third invocation resolves replays the completed close instead of + // re-running it (fastify would reject a second app.close()). + await expect(server.close()).resolves.toBeUndefined(); + server = undefined; + }); + + it('flushes the dispatch tail into the session journal before close() resolves', async () => { + home = await mkdtemp(join(tmpdir(), 'kimi-server-v2-close-tail-')); + server = await startServer({ + host: '127.0.0.1', + port: 0, + homeDir: home, + logLevel: 'silent', + }); + const base = `http://127.0.0.1:${server.port}`; + + // Create a session and arm its journal (the snapshot route activates the + // broadcaster state for the session without a WS connection). + const created = await authedFetch(server, base, '/api/v1/sessions', { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify({ metadata: { cwd: home } }), + }); + const createdBody = (await created.json()) as { code: number; data: { id: string } }; + expect(createdBody.code).toBe(0); + const sid = createdBody.data.id; + + const session = server.core.accessor.get(ISessionLifecycleService).get(sid); + expect(session).toBeDefined(); + const agents = session!.accessor.get(IAgentLifecycleService); + if (agents.get('main') === undefined) { + await agents.create({ agentId: 'main' }); + } + const snapshot = await authedFetch(server, base, `/api/v1/sessions/${sid}/snapshot`); + expect(snapshot.status).toBe(200); + + // Emit a durable event and shut down IMMEDIATELY — the dispatch is still + // sitting in the broadcaster queue when close() starts. It must be + // journaled (with fsync) before close() resolves: sessions close first, + // the journal flush comes last. + agents + .get('main')! + .accessor.get(IEventBus) + .publish({ type: 'turn.started', turnId: 1 } as unknown as DomainEvent); + await server.close(); + server = undefined; + + const journal = await readFile(join(home, 'server', 'events', `${sid}.jsonl`), 'utf8'); + expect(journal).toContain('journal_header'); + expect(journal).toContain('turn.started'); + }); }); function silentLogger() { diff --git a/packages/kap-server/test/eventMap.test.ts b/packages/kap-server/test/eventMap.test.ts index 01745a46c3..5ec1f6d1d7 100644 --- a/packages/kap-server/test/eventMap.test.ts +++ b/packages/kap-server/test/eventMap.test.ts @@ -1,6 +1,11 @@ import { describe, expect, it } from 'vitest'; -import { ISessionInteractionService, type IDisposable } from '@moonshot-ai/agent-core-v2'; +import { + IEventService, + ISessionInteractionService, + type GlobalEvent, + type IDisposable, +} from '@moonshot-ai/agent-core-v2'; import { resolveEventSource } from '../src/transport/ws/eventMap'; @@ -90,3 +95,30 @@ describe('session `interactions:resolved` event source', () => { expect(seen).toHaveLength(2); }); }); + +describe('core `session.list_changed` event source', () => { + it('forwards only session.list_changed domain events', () => { + const { event, fire } = manualEvent(); + const eventService = { subscribe: event }; + const scope = { + accessor: { + get: (id: unknown) => (id === IEventService ? eventService : undefined), + }, + }; + + const source = resolveEventSource('core', 'session.list_changed'); + expect(source).toBeDefined(); + + const seen: unknown[] = []; + const disposable = source!.subscribe(scope as never, (data) => seen.push(data)); + + fire({ type: 'event.session.created', payload: { id: 's1' } }); + fire({ type: 'session.list_changed', payload: {} }); + + expect(seen).toEqual([{ type: 'session.list_changed', payload: {} }]); + + disposable.dispose(); + fire({ type: 'session.list_changed', payload: {} }); + expect(seen).toHaveLength(1); + }); +}); diff --git a/packages/kap-server/test/fs-watch.e2e.test.ts b/packages/kap-server/test/fs-watch.e2e.test.ts index e674e625e3..e82785daa6 100644 --- a/packages/kap-server/test/fs-watch.e2e.test.ts +++ b/packages/kap-server/test/fs-watch.e2e.test.ts @@ -4,11 +4,16 @@ * Mirrors `packages/server/test/fs-watch.e2e.test.ts` (v1) so the wire contract * stays byte-compatible: * 1. subscribe `src` → create file → receive `event.fs.changed` - * 2. burst > 500 changes / 200ms → `truncated` event - * 3. two clients, disjoint paths → no cross-delivery + * 2. burst > 500 changes / 200ms → `truncated` event reaches every + * connection with its own next seq; the client then rebuilds its + * baseline via REST `fs:list` + * 3. two clients, disjoint paths → no cross-delivery; each connection + * numbers only the frames it actually receives (per-connection seq) * 4. > 100 paths per connection → `42902 fs.watch_limit_exceeded` * 5. idempotent add of the same path * 6. `watch_fs_remove` updates `watched_paths`; `..` → `41304` + * 7. two clients on the same path see the same event with the same + * per-connection seq, numbered from 1 without gaps * * Boots `startServer` in-process (loopback, auth disabled) against a tmp * workspace, drives `/api/v1/ws` clients with the raw `ws` library, and mutates @@ -224,6 +229,8 @@ describe('WS fs watch (kap-server)', () => { const sid = await createSession(r); const conn = await openConn(wsUrl(r)); await helloAndSubscribe(conn, 'A', sid); + const bystander = await openConn(wsUrl(r)); + await helloAndSubscribe(bystander, 'B', sid); conn.ws.send( JSON.stringify({ @@ -233,31 +240,66 @@ describe('WS fs watch (kap-server)', () => { }), ); await receiveType(conn, 'ack', 1000); + bystander.ws.send( + JSON.stringify({ + type: 'watch_fs_add', + id: 'wB', + payload: { session_id: sid, paths: ['docs'] }, + }), + ); + await receiveType(bystander, 'ack', 1000); await sleep(WATCH_SETTLE_MS); const burstDir = join(workspace, 'burst'); mkdirSync(burstDir, { recursive: true }); for (let i = 0; i < 600; i++) writeFileSync(join(burstDir, `f${i}.txt`), `x${i}`); - const deadline = Date.now() + 12000; - let sawTruncated = false; - while (Date.now() < deadline) { - let frame: WsFrame; - try { - frame = await receive(conn, deadline - Date.now()); - } catch { - break; - } - if (frame.type !== 'event.fs.changed') continue; - const payload = frame.payload as { truncated?: boolean; count?: number }; - if (payload.truncated === true) { - expect(payload.count).toBeGreaterThan(100); - sawTruncated = true; - break; + const waitForTruncated = async (c: Conn): Promise => { + const deadline = Date.now() + 12000; + while (Date.now() < deadline) { + let frame: WsFrame; + try { + frame = await receive(c, deadline - Date.now()); + } catch { + break; + } + if (frame.type !== 'event.fs.changed') continue; + const payload = frame.payload as { truncated?: boolean; count?: number }; + if (payload.truncated === true) { + expect(payload.count).toBeGreaterThan(100); + return frame; + } } - } - expect(sawTruncated).toBe(true); + throw new Error('no truncated frame received'); + }; + + const [truncatedA, truncatedB] = await Promise.all([ + waitForTruncated(conn), + waitForTruncated(bystander), + ]); + // A truncated window is broadcast to every connection of the session + // watch — even one whose paths match nothing — and each frame carries + // that connection's own next seq. The bystander received no matched + // frame before, so its truncated frame is its first (seq 1). + expect(typeof truncatedA.seq).toBe('number'); + expect(truncatedB.seq).toBe(1); + + // Client-side recovery path after `truncated`: rebuild the baseline + // with a full REST listing (snapshot + incremental + resync). + const listRes = await fetch(`${addressOf(r)}/api/v1/sessions/${sid}/fs:list`, { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify({ path: '.', depth: 1, limit: 100 }), + }); + const listEnv = (await listRes.json()) as { + code: number; + data: { items: Array<{ path: string }> } | null; + }; + expect(listEnv.code).toBe(0); + expect(listEnv.data?.items.map((i) => i.path)).toContain('burst'); + conn.ws.close(); + bystander.ws.close(); }, ); @@ -292,6 +334,76 @@ describe('WS fs watch (kap-server)', () => { expect(pathsB.some((p) => p.startsWith('docs/'))).toBe(true); expect(pathsB.some((p) => p.startsWith('src/'))).toBe(false); + // Per-connection monotonic seq: each connection numbers only the frames + // actually delivered to it, so the same logical window arrives as each + // connection's own seq 1 — not a shared per-session counter. + expect(evA.seq).toBe(1); + expect(evB.seq).toBe(1); + + writeFileSync(join(workspace, 'src', 'a2.ts'), 'a2'); + const evA2 = await receiveType(a, 'event.fs.changed', 2000); + expect(evA2.seq).toBe(2); + + writeFileSync(join(workspace, 'docs', 'b2.md'), 'b2'); + const evB2 = await receiveType(b, 'event.fs.changed', 2000); + // The src-only window never reached B, so it consumed none of B's seq. + expect(evB2.seq).toBe(2); + + a.ws.close(); + b.ws.close(); + }); + + it('two clients on the same path see the same event numbered identically from 1', async () => { + const r = await boot(); + const sid = await createSession(r); + const a = await openConn(wsUrl(r)); + const b = await openConn(wsUrl(r)); + await helloAndSubscribe(a, 'A', sid); + await helloAndSubscribe(b, 'B', sid); + + for (const [conn, id] of [[a, 'wA'], [b, 'wB']] as const) { + conn.ws.send( + JSON.stringify({ + type: 'watch_fs_add', + id, + payload: { session_id: sid, paths: ['src'] }, + }), + ); + await receiveType(conn, 'ack', 1000); + } + + await sleep(WATCH_SETTLE_MS); + writeFileSync(join(workspace, 'src', 'one.ts'), '1'); + // Keep the two writes in separate debounce windows so each yields one frame. + await sleep(500); + writeFileSync(join(workspace, 'src', 'two.ts'), '2'); + + const collectSeqs = async (conn: Conn, ms: number): Promise => { + const seqs: number[] = []; + const deadline = Date.now() + ms; + for (;;) { + const remaining = deadline - Date.now(); + if (remaining <= 0) break; + let frame: WsFrame; + try { + frame = await receive(conn, remaining); + } catch { + break; + } + if (frame.type === 'event.fs.changed' && typeof frame.seq === 'number') { + seqs.push(frame.seq); + } + } + return seqs; + }; + + const [seqsA, seqsB] = await Promise.all([collectSeqs(a, 2000), collectSeqs(b, 2000)]); + // Identical subscriptions receive identical streams: the same per-connection + // seqs, each numbered from 1 with no gaps. + expect(seqsA.length).toBeGreaterThanOrEqual(2); + expect(seqsA).toEqual(seqsB); + expect(seqsA).toEqual(seqsA.map((_, i) => i + 1)); + a.ws.close(); b.ws.close(); }); diff --git a/packages/kap-server/test/session-ownership.e2e.test.ts b/packages/kap-server/test/session-ownership.e2e.test.ts new file mode 100644 index 0000000000..e5e8930781 --- /dev/null +++ b/packages/kap-server/test/session-ownership.e2e.test.ts @@ -0,0 +1,164 @@ +/** + * Multi-server session ownership — two kap-servers on ONE kimi home (the + * `multi_server` experimental flag on). Instance A creates the session and + * holds its write lease; instance B's materializing routes for the same + * session are answered with `40921 session.held_by_peer` carrying the + * structured ownership details (phase `routable` + A's address), so clients + * can redirect to the holder. Closing A releases the lease and B takes over. + * Run: `pnpm --filter @moonshot-ai/kap-server exec vitest run test/session-ownership.e2e.test.ts`. + */ +import { mkdtemp, readFile, rm } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; + +import { sessionLeasePath } from '@moonshot-ai/agent-core-v2'; +import { ErrorCode } from '../src/protocol/error-codes'; +import { afterEach, beforeEach, describe, expect, it } from 'vitest'; + +import { type RunningServer, startServer } from '../src/start'; + +/** Same env gate start.ts checks locally (`KIMI_CODE_EXPERIMENTAL_MULTI_SERVER`). */ +const MULTI_SERVER_FLAG_ENV = 'KIMI_CODE_EXPERIMENTAL_MULTI_SERVER'; + +interface Envelope { + code: number; + msg: string; + data: T; + request_id: string; + details?: unknown; + stack?: string; +} + +interface SessionWire { + id: string; +} + +describe('multi-server session ownership (session.held_by_peer → 40921)', () => { + let home: string | undefined; + let serverA: RunningServer | undefined; + let serverB: RunningServer | undefined; + let previousFlag: string | undefined; + + beforeEach(() => { + previousFlag = process.env[MULTI_SERVER_FLAG_ENV]; + process.env[MULTI_SERVER_FLAG_ENV] = '1'; + }); + + afterEach(async () => { + if (previousFlag === undefined) delete process.env[MULTI_SERVER_FLAG_ENV]; + else process.env[MULTI_SERVER_FLAG_ENV] = previousFlag; + if (serverB !== undefined) { + await serverB.close(); + serverB = undefined; + } + if (serverA !== undefined) { + await serverA.close(); + serverA = undefined; + } + if (home !== undefined) { + await rm(home, { recursive: true, force: true, maxRetries: 5, retryDelay: 50 } as never); + home = undefined; + } + }); + + async function boot(): Promise { + return startServer({ + host: '127.0.0.1', + port: 0, + homeDir: home as string, + logLevel: 'silent', + disableAuth: true, + }); + } + + function base(server: RunningServer): string { + return `http://127.0.0.1:${server.port}`; + } + + async function getJson( + server: RunningServer, + path: string, + ): Promise<{ status: number; body: Envelope }> { + const res = await fetch(`${base(server)}${path}`); + return { status: res.status, body: (await res.json()) as Envelope }; + } + + /** Boot A + B on the shared home and create a session owned by A. */ + async function bootPairWithSession(): Promise { + home = await mkdtemp(join(tmpdir(), 'kimi-server-v2-ownership-')); + serverA = await boot(); + serverB = await boot(); + const res = await fetch(`${base(serverA)}/api/v1/sessions`, { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify({ metadata: { cwd: home } }), + }); + const body = (await res.json()) as Envelope; + expect(body.code).toBe(0); + return body.data.id; + } + + it( + 'B surfaces 40921 with routable ownership details, and A records its address in the lease file', + async () => { + const sessionId = await bootPairWithSession(); + const addressA = base(serverA as RunningServer); + + // Lease file: A advertises the URL it actually bound (post-listen swap + // of the contact ref), so a contended peer knows where to redirect. + const lease = JSON.parse( + await readFile(sessionLeasePath(home as string, sessionId), 'utf8'), + ) as Record; + expect(lease['address']).toBe(addressA); + expect(typeof lease['lock_id']).toBe('string'); + + // Per-route mapper path (prompts.ts `sendMappedError` switch). + const prompts = await getJson( + serverB as RunningServer, + `/api/v1/sessions/${sessionId}/prompts`, + ); + expect(prompts.status).toBe(200); + expect(prompts.body.code).toBe(ErrorCode.SESSION_HELD_BY_PEER); + expect(prompts.body.code).toBe(40921); + expect(prompts.body.details).toEqual({ + kind: 'held-by-peer', + phase: 'routable', + address: addressA, + }); + + // Global error-handler path (warnings resumes without a local switch). + const warnings = await getJson( + serverB as RunningServer, + `/api/v1/sessions/${sessionId}/warnings`, + ); + expect(warnings.status).toBe(200); + expect(warnings.body.code).toBe(ErrorCode.SESSION_HELD_BY_PEER); + expect(warnings.body.details).toEqual({ + kind: 'held-by-peer', + phase: 'routable', + address: addressA, + }); + }, + 30_000, + ); + + it( + 'closing the holder releases the lease so the peer materializes the session instead of 40921', + async () => { + const sessionId = await bootPairWithSession(); + + await (serverA as RunningServer).close(); + serverA = undefined; + + const warnings = await getJson<{ warnings: unknown[] }>( + serverB as RunningServer, + `/api/v1/sessions/${sessionId}/warnings`, + ); + expect(warnings.status).toBe(200); + expect(warnings.body.code).toBe(0); + expect(warnings.body.code).not.toBe(ErrorCode.SESSION_HELD_BY_PEER); + expect(warnings.body.data.warnings).toEqual([]); + }, + 30_000, + ); +}); diff --git a/packages/kap-server/test/sessionEventBroadcaster.test.ts b/packages/kap-server/test/sessionEventBroadcaster.test.ts index 0659546654..534b8066c3 100644 --- a/packages/kap-server/test/sessionEventBroadcaster.test.ts +++ b/packages/kap-server/test/sessionEventBroadcaster.test.ts @@ -2,7 +2,7 @@ * `SessionEventBroadcaster` — seq stamping, volatile vs durable, fan-out, replay. */ -import { mkdtemp, rm } from 'node:fs/promises'; +import { mkdtemp, rm, stat } from 'node:fs/promises'; import { tmpdir } from 'node:os'; import { join } from 'node:path'; @@ -28,7 +28,48 @@ import { type BroadcastTarget, SessionEventBroadcaster, } from '../src/transport/ws/v1/sessionEventBroadcaster'; -import type { EventEnvelope } from '../src/transport/ws/v1/sessionEventJournal'; +import { + type EventEnvelope, + JournalStorageError, +} from '../src/transport/ws/v1/sessionEventJournal'; + +/** + * Controllable deferred-failure injection for the journal under test. The + * journal opens its file via `fs/promises.open(path, 'a')` per flush batch; + * while `active` is set, opens of journal paths in this file's tmp dirs are + * parked until the test rejects them (precise per-round failure control). + * Everything else passes through to the real module. + */ +const journalFs = vi.hoisted(() => ({ + active: false, + rejecters: [] as Array<(error: Error) => void>, +})); + +vi.mock('node:fs/promises', async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + open: (path: Parameters[0], flags?: string, mode?: number) => { + if ( + journalFs.active && + typeof path === 'string' && + path.includes('kimi-broadcaster-test-') && + path.endsWith('.jsonl') + ) { + return new Promise((_resolve, reject) => { + journalFs.rejecters.push(reject); + }); + } + return actual.open(path, flags, mode); + }, + }; +}); + +function injectWriteFailure(): Error { + const error = new Error('injected journal write failure') as NodeJS.ErrnoException; + error.code = 'EACCES'; + return error; +} // --------------------------------------------------------------------------- // Fakes @@ -217,6 +258,8 @@ describe('SessionEventBroadcaster', () => { let bc: SessionEventBroadcaster; beforeEach(async () => { + journalFs.active = false; + journalFs.rejecters = []; dir = await mkdtemp(join(tmpdir(), 'kimi-broadcaster-test-')); sessions = new Map(); eventBus = new FakeEventBus(); @@ -228,6 +271,8 @@ describe('SessionEventBroadcaster', () => { }); afterEach(async () => { + journalFs.active = false; + journalFs.rejecters = []; await bc.close(); await rm(dir, { recursive: true, force: true }); }); @@ -261,7 +306,14 @@ describe('SessionEventBroadcaster', () => { type: 'event.session.work_changed', payload: { busy: false, last_turn_reason: 'completed' }, }); - expect(envelopes.every((e) => e.epoch === envelopes[0]!.epoch)).toBe(true); + // The epoch is born lazily with the first durable append (`nextSeq`), so + // the pre-baseline volatile phase frame carries none. Every envelope that + // HAS an epoch shares the journal's single incarnation — no mid-stream + // epoch flip-flop. + expect(durable[0]!.epoch).toMatch(/^ep_/); + expect(envelopes.every((e) => e.epoch === undefined || e.epoch === durable[0]!.epoch)).toBe( + true, + ); expect(durable[0]!.volatile).toBeUndefined(); }); @@ -499,6 +551,98 @@ describe('SessionEventBroadcaster', () => { expect(next.subagents).toEqual([]); }); + it('fans core model-catalog changes out live without journaling __global__', async () => { + const lc = new FakeLifecycle(); + lc.addAgent('main'); + sessions.set('s1', lc); + const { target, envelopes } = collectingTarget(); + await bc.subscribe('s1', target); + + eventBus.emit({ + type: 'event.model_catalog.changed', + payload: { + changed: [{ provider_id: 'managed:kimi-code', provider_name: 'Kimi Code', added: 1, removed: 0 }], + unchanged: [], + failed: [], + }, + }); + + await vi.waitFor(() => expect(envelopes).toHaveLength(1)); + expect(envelopes[0]).toMatchObject({ + type: 'event.model_catalog.changed', + session_id: '__global__', + volatile: true, + payload: { + type: 'event.model_catalog.changed', + agentId: 'main', + sessionId: '__global__', + }, + }); + // Advisory-only now: fanned out live with a process-local seq and never + // journaled — no __global__.jsonl garbage file appears under the events + // dir (the old durable file was multi-writer by nature and had no reader). + await expect(stat(join(dir, '__global__.jsonl'))).rejects.toMatchObject({ code: 'ENOENT' }); + }); + + it('fans session.list_changed out live as a volatile payload-less global hint', async () => { + // The multi-instance sessions-tree watcher publishes this core event when + // a workspace/session directory appears or disappears (possibly created + // by a peer instance). It must reach every connection as a volatile, + // payload-less, unjournaled go-refetch hint — same delivery class as + // event.model_catalog.changed, with nothing to replay. + const lc = new FakeLifecycle(); + lc.addAgent('main'); + sessions.set('s1', lc); + const { target, envelopes } = collectingTarget(); + await bc.subscribe('s1', target); + + eventBus.emit({ type: 'session.list_changed', payload: {} }); + + await vi.waitFor(() => expect(envelopes).toHaveLength(1)); + expect(envelopes[0]).toMatchObject({ + type: 'session.list_changed', + session_id: '__global__', + volatile: true, + payload: { + type: 'session.list_changed', + agentId: 'main', + sessionId: '__global__', + }, + }); + await expect(stat(join(dir, '__global__.jsonl'))).rejects.toMatchObject({ code: 'ENOENT' }); + }); + + it('never serves replay from the in-memory tail while the journal has write failures', async () => { + const lc = new FakeLifecycle(); + const main = lc.addAgent('main'); + sessions.set('s1', lc); + const { target } = collectingTarget(); + await bc.subscribe('s1', target); + + main.bus.emit(agentEvent('turn.started', { turnId: 1 })); + // Force batch 1 durable on disk (replay flushes first): the durable + // status_changed + turn.started. + const warm = await bc.getBufferedSince('s1', { seq: 0 }); + expect(warm.events).toHaveLength(2); + + // Storage starts failing: the next flush round parks on a deferred open. + journalFs.active = true; + main.bus.emit(agentEvent('turn.ended', { turnId: 1 })); + await bc.getCursor('s1'); // drain the queue — the append is queued to the journal + await vi.waitFor(() => expect(journalFs.rejecters).toHaveLength(1)); + journalFs.rejecters.shift()!(injectWriteFailure()); + await new Promise((resolve) => setImmediate(resolve)); // settle the failure state + + // The memory tail still holds the undurable turn.ended, but replay must + // not be served from it: the journal-side retry fails again → sticky → + // the explicit storage error surfaces to the replay edge. (Cursor seq 1 + // keeps the gap inside the maxBufferSize=3 cap.) + const replay = bc.getBufferedSince('s1', { seq: 1 }); + await vi.waitFor(() => expect(journalFs.rejecters).toHaveLength(1)); + journalFs.rejecters.shift()!(injectWriteFailure()); + await expect(replay).rejects.toBeInstanceOf(JournalStorageError); + }); + it('subscribe returns false for an unknown session', async () => { const { target } = collectingTarget(); expect(await bc.subscribe('nope', target)).toBe(false); diff --git a/packages/kap-server/test/sessionEventJournal.test.ts b/packages/kap-server/test/sessionEventJournal.test.ts index 33a62e099d..a13cb76692 100644 --- a/packages/kap-server/test/sessionEventJournal.test.ts +++ b/packages/kap-server/test/sessionEventJournal.test.ts @@ -1,18 +1,47 @@ /** - * `SessionEventJournal` — seq assignment, durability, recovery, epoch rotation. + * `SessionEventJournal` — seq assignment, durability, recovery, epoch rotation, + * write-failure semantics. */ -import { mkdtemp, readFile, rm, writeFile } from 'node:fs/promises'; +import { mkdtemp, readFile, rm, stat, writeFile } from 'node:fs/promises'; import { tmpdir } from 'node:os'; import { join } from 'node:path'; -import { afterEach, beforeEach, describe, expect, it } from 'vitest'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; import { type EventEnvelope, + JournalStorageError, SessionEventJournal, } from '../src/transport/ws/v1/sessionEventJournal'; +/** + * Controllable write-failure injection. The journal opens its journal file via + * `fs/promises.open(path, 'a')` per flush batch (single open → write → fsync → + * close), so failing `open` fails the whole batch. All other calls (including + * this test's own mkdtemp/readFile/...) pass through to the real module. + */ +const journalFs = vi.hoisted(() => ({ + failOpens: false, + openAttempts: 0, +})); + +vi.mock('node:fs/promises', async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + open: (path: Parameters[0], flags?: string, mode?: number) => { + journalFs.openAttempts += 1; + if (journalFs.failOpens) { + const error = new Error('injected journal write failure') as NodeJS.ErrnoException; + error.code = 'EACCES'; + return Promise.reject(error); + } + return actual.open(path, flags, mode); + }, + }; +}); + function envelope(seq: number): EventEnvelope { return { type: 'turn.started', @@ -27,20 +56,25 @@ describe('SessionEventJournal', () => { let filePath: string; beforeEach(async () => { + journalFs.failOpens = false; + journalFs.openAttempts = 0; dir = await mkdtemp(join(tmpdir(), 'kimi-journal-test-')); filePath = join(dir, 'sess_1.jsonl'); }); afterEach(async () => { + journalFs.failOpens = false; await rm(dir, { recursive: true, force: true }); }); it('assigns monotonic seq and reads back in order', async () => { const j = await SessionEventJournal.open(filePath); - expect(j.epoch).toMatch(/^ep_/); + // No baseline before the first durable append — a cold journal has no epoch. + expect(j.epoch).toBeUndefined(); expect(j.seq).toBe(0); j.append(j.nextSeq(), envelope(1)); + expect(j.epoch).toMatch(/^ep_/); // materialized on the first real append j.append(j.nextSeq(), envelope(2)); j.append(j.nextSeq(), envelope(3)); expect(j.seq).toBe(3); @@ -52,9 +86,10 @@ describe('SessionEventJournal', () => { it('recovers seq and epoch across reopen', async () => { const j1 = await SessionEventJournal.open(filePath); - const epoch = j1.epoch; j1.append(j1.nextSeq(), envelope(1)); j1.append(j1.nextSeq(), envelope(2)); + const epoch = j1.epoch; + expect(epoch).toMatch(/^ep_/); await j1.close(); const j2 = await SessionEventJournal.open(filePath); @@ -66,20 +101,73 @@ describe('SessionEventJournal', () => { it('rotates to a fresh epoch when the header is corrupt', async () => { const j1 = await SessionEventJournal.open(filePath); - const epoch = j1.epoch; j1.append(j1.nextSeq(), envelope(1)); + const epoch = j1.epoch; await j1.close(); // Corrupt the file: overwrite with a garbage first line (no header). await writeFile(filePath, 'this is not json\n', 'utf8'); const j2 = await SessionEventJournal.open(filePath); + // The baseline is lost, but the next incarnation (and its epoch) is born + // only on the next real append. + expect(j2.epoch).toBeUndefined(); + expect(j2.seq).toBe(0); + j2.append(j2.nextSeq(), envelope(1)); expect(j2.epoch).toMatch(/^ep_/); expect(j2.epoch).not.toBe(epoch); - expect(j2.seq).toBe(0); await j2.close(); }); + it('adopts the LAST header when a rotated journal carries several', async () => { + // A crash after a rotation can leave multiple headers in one file; only + // the newest incarnation is authoritative. + const lines = [ + JSON.stringify({ kind: 'journal_header', version: 1, epoch: 'ep_old', created_at: 1 }), + JSON.stringify({ kind: 'event', seq: 1, envelope: envelope(1) }), + JSON.stringify({ kind: 'journal_header', version: 1, epoch: 'ep_new', created_at: 2 }), + JSON.stringify({ kind: 'event', seq: 2, envelope: envelope(2) }), + ]; + await writeFile(filePath, lines.join('\n') + '\n', 'utf8'); + + const j = await SessionEventJournal.open(filePath); + expect(j.epoch).toBe('ep_new'); + expect(j.seq).toBe(2); + await j.close(); + }); + + it('cold reads are read-only: no epoch is fabricated and nothing is written', async () => { + // Missing file — open → close must not create it. + const j1 = await SessionEventJournal.open(filePath); + expect(j1.epoch).toBeUndefined(); + expect(j1.seq).toBe(0); + await j1.close(); + await expect(stat(filePath)).rejects.toMatchObject({ code: 'ENOENT' }); + + // Re-open of the same absent journal: the same absent baseline — no + // random-epoch flip-flop (which used to trigger fake `epoch_changed`). + const j2 = await SessionEventJournal.open(filePath); + expect(j2.epoch).toBeUndefined(); + await j2.close(); + await expect(stat(filePath)).rejects.toMatchObject({ code: 'ENOENT' }); + + // Pre-created empty file: stays zero bytes across open → close. + await writeFile(filePath, '', 'utf8'); + const j3 = await SessionEventJournal.open(filePath); + expect(j3.epoch).toBeUndefined(); + await j3.close(); + expect(await readFile(filePath, 'utf8')).toBe(''); + + // The epoch only materializes when the first durable event actually lands. + j3.append(j3.nextSeq(), envelope(1)); + expect(j3.epoch).toMatch(/^ep_/); + await j3.close(); + const j4 = await SessionEventJournal.open(filePath); + expect(j4.epoch).toBe(j3.epoch); + expect(j4.seq).toBe(1); + await j4.close(); + }); + it('readSince honors the exclusive lower bound and the limit', async () => { const j = await SessionEventJournal.open(filePath); for (let i = 1; i <= 5; i++) j.append(j.nextSeq(), envelope(i)); @@ -89,10 +177,67 @@ describe('SessionEventJournal', () => { await j.close(); }); - it('readSince on a missing file returns empty', async () => { + it('readSince on a missing file returns empty (and writes nothing)', async () => { const j = await SessionEventJournal.open(filePath); + expect(j.epoch).toBeUndefined(); const out = await j.readSince(0, 100); expect(out).toEqual([]); + // A pure read never fabricates an epoch or a header. + expect(j.epoch).toBeUndefined(); + await j.close(); + await expect(stat(filePath)).rejects.toMatchObject({ code: 'ENOENT' }); + }); + + it('keeps pending lines across a failed flush and replays them after a retry', async () => { + const j = await SessionEventJournal.open(filePath); + j.append(j.nextSeq(), envelope(1)); + await j.readSince(0, 100); // batch 1 durable (header + seq 1) + expect(journalFs.openAttempts).toBe(1); + + // Batch 2 fails: the lines must NOT be dropped — the file still holds seq 1. + journalFs.failOpens = true; + j.append(j.nextSeq(), envelope(2)); + j.append(j.nextSeq(), envelope(3)); + await vi.waitFor(() => expect(journalFs.openAttempts).toBe(2)); + journalFs.failOpens = false; + + const mid = await SessionEventJournal.open(filePath); + expect(mid.seq).toBe(1); + await mid.close(); + + // readSince retries the pending flush, then serves the full tail. + const all = await j.readSince(1, 100); + expect(all.map((e) => e.seq)).toEqual([2, 3]); + await j.close(); + + const reopened = await SessionEventJournal.open(filePath); + expect(reopened.seq).toBe(3); + expect(reopened.epoch).toBe(j.epoch); + await reopened.close(); + }); + + it('enters a sticky failure state after consecutive write failures', async () => { + const j = await SessionEventJournal.open(filePath); + journalFs.failOpens = true; + j.append(j.nextSeq(), envelope(1)); + + // The scheduled round fails (1); the flush inside readSince retries and + // fails again (2) → sticky — and the read fails loudly instead of + // silently serving fewer events. + await expect(j.readSince(0, 100)).rejects.toBeInstanceOf(JournalStorageError); + + // Sticky: subsequent writes fail fast (no unbounded pending growth), and + // reads keep failing explicitly. + expect(() => j.nextSeq()).toThrow(JournalStorageError); + expect(() => j.append(2, envelope(2))).toThrow(JournalStorageError); + await expect(j.readSince(0, 100)).rejects.toBeInstanceOf(JournalStorageError); + + // The sticky state is terminal for this instance — storage "recovering" + // does not silently un-stick it mid-flight. + journalFs.failOpens = false; + expect(() => j.append(2, envelope(2))).toThrow(JournalStorageError); + + // close() must neither throw nor hot-spin on the persistent failure. await j.close(); }); diff --git a/packages/kap-server/test/sessionListWatch.test.ts b/packages/kap-server/test/sessionListWatch.test.ts new file mode 100644 index 0000000000..431f953970 --- /dev/null +++ b/packages/kap-server/test/sessionListWatch.test.ts @@ -0,0 +1,193 @@ +/** + * `SessionListWatchService` — two-layer watcher over the shared sessions tree + * (design §3.8 event plane; run: + * `pnpm --filter @moonshot-ai/kap-server exec vitest run test/sessionListWatch.test.ts`). + * + * Real chokidar (`HostFsWatchService`) + real tmp dirs; only the core event + * bus is faked (a collect-only `publish`). Cases pin the §3.8 contract: + * existing workspace + new session dir → hint; new workspace appears → hint; + * session/workspace dirs deleted → hint; boot with pre-existing dirs is + * silent; a same-window burst collapses into ONE hint; nothing fires after + * dispose. + */ +import { mkdirSync, mkdtemp, rmSync } from 'node:fs'; +import { rm } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; + +import { + HostFsWatchService, + type DomainEvent, + type IEventService, +} from '@moonshot-ai/agent-core-v2'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; + +import { SessionListWatchService } from '../src/services/sessionListWatch/sessionListWatchService'; + +const DEBOUNCE_MS = 40; +/** Time given to chokidar to register newly-watched paths before mutating + (same settle constant as the fs-watch e2e suite). */ +const WATCH_SETTLE_MS = 150; +/** How long to wait for a hint before declaring it lost. */ +const HINT_TIMEOUT_MS = 8_000; + +const sleep = (ms: number): Promise => new Promise((r) => setTimeout(r, ms)); + +function collectingEvents(): { events: IEventService; published: DomainEvent[] } { + const published: DomainEvent[] = []; + return { + published, + events: { + publish: (event: DomainEvent) => published.push(event), + } as unknown as IEventService, + }; +} + +describe('SessionListWatchService', () => { + let root: string; + let sessionsDir: string; + let published: DomainEvent[]; + let service: SessionListWatchService | undefined; + + beforeEach(async () => { + root = await new Promise((resolve, reject) => + mkdtemp(join(tmpdir(), 'kimi-sessionlist-watch-'), (err, dir) => + err === null ? resolve(dir) : reject(err), + ), + ); + sessionsDir = join(root, 'sessions'); + ({ published } = collectingEvents()); + }); + + afterEach(async () => { + service?.dispose(); + service = undefined; + if (root !== '') { + await rm(root, { recursive: true, force: true, maxRetries: 5, retryDelay: 50 }); + } + }); + + async function boot(opts?: { debounceMs?: number }): Promise { + const events = collectingEvents(); + published = events.published; + service = new SessionListWatchService({ + sessionsDir, + fsWatch: new HostFsWatchService(), + events: events.events, + debounceMs: opts?.debounceMs ?? DEBOUNCE_MS, + }); + await service.start(); + await sleep(WATCH_SETTLE_MS); + } + + /** Wait until at least `count` hints were published; fails the test on timeout. */ + async function waitForHints(count: number): Promise { + await vi.waitFor(() => expect(published.length).toBeGreaterThanOrEqual(count), { + timeout: HINT_TIMEOUT_MS, + interval: 25, + }); + } + + function expectHintShape(event: DomainEvent | undefined): void { + expect(event).toEqual({ type: 'session.list_changed', payload: {} }); + } + + it( + 'emits a hint when a second session dir appears under an existing workspace, and boot stays silent', + { timeout: 20_000 }, + async () => { + mkdirSync(join(sessionsDir, 'ws1', 's1'), { recursive: true }); + await boot(); + + // ignoreInitial everywhere: pre-existing workspace/session trees must + // not produce a boot flood before anything actually changes. + expect(published).toHaveLength(0); + + mkdirSync(join(sessionsDir, 'ws1', 's2')); + await waitForHints(1); + expectHintShape(published[0]); + }, + ); + + it( + 'emits a hint when a new workspace appears, and again for a session created inside it', + { timeout: 20_000 }, + async () => { + await boot(); + expect(published).toHaveLength(0); + + // Root watcher: workspace dir appears. + mkdirSync(join(sessionsDir, 'wsA')); + await waitForHints(1); + expectHintShape(published[0]); + + // The dynamically added per-workspace watcher picks up sessions created + // inside the new workspace afterwards. + await sleep(WATCH_SETTLE_MS); + mkdirSync(join(sessionsDir, 'wsA', 's1')); + await waitForHints(2); + expectHintShape(published[1]); + }, + ); + + it( + 'emits a hint when a session dir is deleted', + { timeout: 20_000 }, + async () => { + mkdirSync(join(sessionsDir, 'ws1', 's1'), { recursive: true }); + mkdirSync(join(sessionsDir, 'ws1', 's2'), { recursive: true }); + await boot(); + + rmSync(join(sessionsDir, 'ws1', 's2'), { recursive: true, force: true }); + await waitForHints(1); + expectHintShape(published[0]); + }, + ); + + it( + 'emits a hint when a workspace dir is deleted', + { timeout: 20_000 }, + async () => { + mkdirSync(join(sessionsDir, 'ws1'), { recursive: true }); + await boot(); + + rmSync(join(sessionsDir, 'ws1'), { recursive: true, force: true }); + await waitForHints(1); + expectHintShape(published[0]); + }, + ); + + it( + 'debounces a same-window burst into a single hint', + { timeout: 20_000 }, + async () => { + mkdirSync(join(sessionsDir, 'ws1'), { recursive: true }); + await boot(); + + mkdirSync(join(sessionsDir, 'ws1', 's1')); + mkdirSync(join(sessionsDir, 'ws1', 's2')); + mkdirSync(join(sessionsDir, 'ws1', 's3')); + await waitForHints(1); + // Silence several debounce windows: nothing more may arrive — the burst + // was coalesced, and repeated hints would inflate the count. + await sleep(DEBOUNCE_MS * 6); + expect(published).toHaveLength(1); + }, + ); + + it( + 'stops emitting after dispose', + { timeout: 20_000 }, + async () => { + mkdirSync(join(sessionsDir, 'ws1'), { recursive: true }); + await boot(); + + service?.dispose(); + + mkdirSync(join(sessionsDir, 'ws1', 's1')); + mkdirSync(join(sessionsDir, 'ws2')); + await sleep(WATCH_SETTLE_MS + DEBOUNCE_MS * 5); + expect(published).toHaveLength(0); + }, + ); +}); diff --git a/packages/kap-server/test/sessions.test.ts b/packages/kap-server/test/sessions.test.ts index 2447ea611a..5b5c9db555 100644 --- a/packages/kap-server/test/sessions.test.ts +++ b/packages/kap-server/test/sessions.test.ts @@ -49,6 +49,7 @@ interface SessionWire { pending_interaction: 'none' | 'approval' | 'question'; last_turn_reason?: 'completed' | 'cancelled' | 'failed'; archived?: boolean; + ownership?: { held_by: 'self' | 'peer' | 'none'; address?: string }; metadata: { cwd: string } & Record; agent_config: { model: string }; usage: { input_tokens: number }; @@ -353,6 +354,26 @@ describe('server-v2 /api/v1/sessions', () => { expect(typeof body.data.has_more).toBe('boolean'); }); + it('joins the lease into ownership: self while materialized, none after close', async () => { + const cwd = home as string; + const created = await postJson('/api/v1/sessions', { metadata: { cwd } }); + const id = created.body.data.id; + + const listed = await getJson('/api/v1/sessions'); + expect(listed.body.data.items.find((s) => s.id === id)?.ownership).toEqual({ + held_by: 'self', + }); + + // Releasing the materialized scope drops the lease: the session is now + // owned by NO instance, and the join must show it. + await (server as RunningServer).core.accessor.get(ISessionLifecycleService).close(id); + + const after = await getJson('/api/v1/sessions'); + expect(after.body.data.items.find((s) => s.id === id)?.ownership).toEqual({ + held_by: 'none', + }); + }); + it('supports exclude_empty when listing sessions', async () => { const cwd = home as string; const created = await postJson('/api/v1/sessions', { metadata: { cwd } }); diff --git a/packages/kap-server/test/skillCatalogBridge.test.ts b/packages/kap-server/test/skillCatalogBridge.test.ts new file mode 100644 index 0000000000..3b3c52f8c4 --- /dev/null +++ b/packages/kap-server/test/skillCatalogBridge.test.ts @@ -0,0 +1,196 @@ +/** + * `SkillCatalogBridge` — volatile `skill_catalog.changed` delivery. + * + * Fake core accessor (per `wsConnectionV1.test.ts` fake-service pattern): the + * session-scoped `ISessionSkillCatalog.onDidChange` feed is hand-fired. Cases + * pin the bridge contract: + * - two connections subscribed to one session each receive ONE volatile + * frame per core change, numbered by their own per-connection `seq`; + * - the frame is transport-only: the bridge constructor takes `{core, logger}` + * and never touches `SessionEventJournal` (no durable seq / epoch to + * journal), and the envelope carries no `epoch`; + * - unsubscribe / last-detach stops delivery and releases the core + * subscription (`dispose` called); re-attach subscribes afresh. + */ +import { + type IDisposable, + ISessionSkillCatalog, + ISessionLifecycleService, + type ISessionScopeHandle, + type Scope, +} from '@moonshot-ai/agent-core-v2'; +import { describe, expect, it, vi } from 'vitest'; + +import { + SkillCatalogBridge, + type SkillCatalogChangedFrame, + type SkillCatalogConnection, +} from '../src/transport/ws/v1/skillCatalogBridge'; +import type { EventEnvelope } from '../src/transport/ws/v1/sessionEventJournal'; + +function makeConn(id: string): { conn: SkillCatalogConnection; frames: SkillCatalogChangedFrame[] } { + const frames: SkillCatalogChangedFrame[] = []; + return { + frames, + conn: { + id, + send: (envelope: EventEnvelope) => frames.push(envelope as unknown as SkillCatalogChangedFrame), + }, + }; +} + +interface FakeCatalog { + readonly service: ISessionSkillCatalog; + readonly onDidChange: ReturnType; + readonly dispose: ReturnType; + fire(sourceId: string): void; + listenerCount(): number; +} + +function makeCatalog(): FakeCatalog { + let listener: ((sourceId: string) => void) | undefined; + const dispose = vi.fn(() => { + listener = undefined; + }); + const onDidChange = vi.fn((cb: (sourceId: string) => void): IDisposable => { + listener = cb; + return { dispose }; + }); + return { + service: { onDidChange } as unknown as ISessionSkillCatalog, + onDidChange, + dispose, + fire: (sourceId: string) => listener?.(sourceId), + listenerCount: () => (listener === undefined ? 0 : 1), + }; +} + +function makeCore(sessions: Map): Scope { + const lifecycle = { get: (sid: string) => sessions.get(sid) }; + return { + accessor: { + get: (decorator: unknown) => { + if (decorator === ISessionLifecycleService) return lifecycle; + throw new Error('unexpected service lookup'); + }, + }, + } as unknown as Scope; +} + +function makeSession(catalog: ISessionSkillCatalog): ISessionScopeHandle { + return { + accessor: { + get: (decorator: unknown) => { + if (decorator === ISessionSkillCatalog) return catalog; + throw new Error('unexpected service lookup'); + }, + }, + } as unknown as ISessionScopeHandle; +} + +describe('SkillCatalogBridge', () => { + it('fans one volatile frame per core change out to every subscribed connection', () => { + const catalog = makeCatalog(); + const sessions = new Map([['sess_1', makeSession(catalog.service)]]); + const bridge = new SkillCatalogBridge({ core: makeCore(sessions) }); + const a = makeConn('conn_a'); + const b = makeConn('conn_b'); + + bridge.attachSession(a.conn, 'sess_1'); + bridge.attachSession(b.conn, 'sess_1'); + // One core subscription shared by both connections of the session. + expect(catalog.onDidChange).toHaveBeenCalledTimes(1); + expect(a.frames).toHaveLength(0); + expect(b.frames).toHaveLength(0); + + catalog.fire('workspace-file'); + + expect(a.frames).toHaveLength(1); + expect(b.frames).toHaveLength(1); + for (const frames of [a.frames, b.frames]) { + const frame = frames[0]!; + expect(frame).toMatchObject({ + type: 'skill_catalog.changed', + // Per-connection monotonic seq — not the durable session watermark. + seq: 1, + session_id: 'sess_1', + volatile: true, + payload: { type: 'skill_catalog.changed', sourceId: 'workspace-file' }, + }); + // No journalling surface: no journal epoch is attached to the frame. + expect('epoch' in frame).toBe(false); + } + + // A second change advances each connection's own seq only. + catalog.fire('plugin'); + expect(a.frames[1]).toMatchObject({ seq: 2, payload: { sourceId: 'plugin' } }); + expect(b.frames[1]).toMatchObject({ seq: 2, payload: { sourceId: 'plugin' } }); + }); + + it('attaches only to materialized sessions', () => { + const catalog = makeCatalog(); + const bridge = new SkillCatalogBridge({ core: makeCore(new Map()) }); + const a = makeConn('conn_a'); + + bridge.attachSession(a.conn, 'missing'); + catalog.fire('workspace-file'); + + expect(catalog.onDidChange).not.toHaveBeenCalled(); + expect(a.frames).toHaveLength(0); + }); + + it('stops delivering to an unsubscribed connection and releases the core subscription on last detach', () => { + const catalog = makeCatalog(); + const sessions = new Map([['sess_1', makeSession(catalog.service)]]); + const bridge = new SkillCatalogBridge({ core: makeCore(sessions) }); + const a = makeConn('conn_a'); + const b = makeConn('conn_b'); + + bridge.attachSession(a.conn, 'sess_1'); + bridge.attachSession(b.conn, 'sess_1'); + bridge.detachSession(a.conn, 'sess_1'); + catalog.fire('workspace-file'); + + expect(a.frames).toHaveLength(0); + expect(b.frames).toHaveLength(1); + // conn_b still holds the session: core subscription is NOT released. + expect(catalog.dispose).not.toHaveBeenCalled(); + + bridge.detachSession(b.conn, 'sess_1'); + expect(catalog.dispose).toHaveBeenCalledTimes(1); + expect(catalog.listenerCount()).toBe(0); + + catalog.fire('plugin'); + expect(a.frames).toHaveLength(0); + expect(b.frames).toHaveLength(1); + }); + + it('detachConnection drops the connection from every session and re-attach subscribes afresh', () => { + const catalog1 = makeCatalog(); + const catalog2 = makeCatalog(); + const sessions = new Map([ + ['sess_1', makeSession(catalog1.service)], + ['sess_2', makeSession(catalog2.service)], + ]); + const bridge = new SkillCatalogBridge({ core: makeCore(sessions) }); + const a = makeConn('conn_a'); + + bridge.attachSession(a.conn, 'sess_1'); + bridge.attachSession(a.conn, 'sess_2'); + bridge.detachConnection(a.conn); + + expect(catalog1.dispose).toHaveBeenCalledTimes(1); + expect(catalog2.dispose).toHaveBeenCalledTimes(1); + catalog1.fire('workspace-file'); + catalog2.fire('workspace-file'); + expect(a.frames).toHaveLength(0); + + // Re-attaching after a full teardown subscribes anew and restarts the + // per-connection seq from 1. + bridge.attachSession(a.conn, 'sess_1'); + expect(catalog1.onDidChange).toHaveBeenCalledTimes(2); + catalog1.fire('plugin'); + expect(a.frames).toHaveLength(1); + expect(a.frames[0]).toMatchObject({ seq: 1, payload: { sourceId: 'plugin' } }); + }); +}); diff --git a/packages/kap-server/test/transport-errors.test.ts b/packages/kap-server/test/transport-errors.test.ts index 07d46e497b..94a177056c 100644 --- a/packages/kap-server/test/transport-errors.test.ts +++ b/packages/kap-server/test/transport-errors.test.ts @@ -20,6 +20,7 @@ describe('/api/v2 transport mapError', () => { [ErrorCodes.STORAGE_IO_FAILED, ErrorCode.PERSISTENCE_FAILURE], [ErrorCodes.STORAGE_LOCKED, ErrorCode.PERSISTENCE_FAILURE], [ErrorCodes.GOAL_UNSUPPORTED_AGENT, ErrorCode.GOAL_UNSUPPORTED_AGENT], + [ErrorCodes.SESSION_HELD_BY_PEER, ErrorCode.SESSION_HELD_BY_PEER], ])('maps domain code %s to its wire equivalent', (code, wire) => { const env = mapError(new Error2(code, 'boom'), 'req-1'); expect(env.code).toBe(wire); @@ -29,4 +30,32 @@ describe('/api/v2 transport mapError', () => { const env = mapError(new Error2(ErrorCodes.OS_FS_UNKNOWN, 'boom'), 'req-1'); expect(env.code).toBe(ErrorCode.INTERNAL_ERROR); }); + + it('forwards Error2 details onto the mapped envelope verbatim', () => { + const details = { kind: 'held-by-peer', phase: 'routable', address: 'http://127.0.0.1:58628' }; + const env = mapError( + new Error2(ErrorCodes.SESSION_HELD_BY_PEER, 'session is owned by another instance', { + details, + }), + 'req-1', + ); + expect(env.code).toBe(ErrorCode.SESSION_HELD_BY_PEER); + expect(env.details).toEqual(details); + }); + + it('omits details when the error carries none — wire shape unchanged', () => { + const env = mapError(new Error2(ErrorCodes.SESSION_NOT_FOUND, 'boom'), 'req-1'); + expect(env.details).toBeUndefined(); + expect(JSON.stringify(env)).not.toContain('"details"'); + }); + + it('session.lease_lost falls through to the internal-error envelope', () => { + const env = mapError( + new Error2(ErrorCodes.SESSION_LEASE_LOST, 'write lease lost', { + details: { sessionId: 's1' }, + }), + 'req-1', + ); + expect(env.code).toBe(ErrorCode.INTERNAL_ERROR); + }); }); diff --git a/packages/kap-server/test/ws.test.ts b/packages/kap-server/test/ws.test.ts index 7b339fd090..4c5f00ea14 100644 --- a/packages/kap-server/test/ws.test.ts +++ b/packages/kap-server/test/ws.test.ts @@ -2,11 +2,22 @@ import { mkdtemp, rm } from 'node:fs/promises'; import { tmpdir } from 'node:os'; import { join } from 'node:path'; -import { ISessionIndex, ISessionMetadata } from '@moonshot-ai/agent-core-v2'; +import { + createDecorator, + Error2, + ErrorCodes, + ISessionIndex, + ISessionMetadata, + type Scope, + type ServiceIdentifier, +} from '@moonshot-ai/agent-core-v2'; +import { ErrorCode } from '../src/protocol/error-codes'; import { afterEach, beforeEach, describe, expect, it } from 'vitest'; import { type RunningServer, startServer } from '../src/start'; +import { registerChannel } from '../src/transport/channelRegistry'; import { RpcWsError, WsClient } from '../src/transport/ws/wsClient'; +import { WsConnection } from '../src/transport/ws/wsConnection'; import { authHeaders } from './helpers/auth'; interface Envelope { @@ -195,3 +206,119 @@ describe('server-v2 /api/v2/ws auth', () => { client.close(); }); }); + +/** + * `/api/v2/ws` error-frame construction, no server boot: drive a raw + * `WsConnection` against a stub socket + scope so the mapped `details` + * forwarding is asserted verbatim on the wire frame. + */ +describe('server-v2 /api/v2/ws error frames', () => { + it('error frame forwards mapped details verbatim', async () => { + const details = { kind: 'held-by-peer', phase: 'routable', address: 'http://127.0.0.1:58628' }; + const { connMessage, frames } = serveThrowingService( + ErrorCodes.SESSION_HELD_BY_PEER, + 'session 01JOWNERSHIP is held by another instance (routable)', + details, + ); + connMessage({ type: 'hello' }); + connMessage({ + type: 'call', + id: 'c1', + scope: 'core', + service: 'ownershipTestThrowing', + method: 'fail', + }); + await expect + .poll(() => frames.find((f) => f['type'] === 'error'), { timeout: 2000 }) + .toEqual({ + type: 'error', + id: 'c1', + code: ErrorCode.SESSION_HELD_BY_PEER, + msg: 'session 01JOWNERSHIP is held by another instance (routable)', + details, + }); + }); + + it('error frame omits details when the mapped error carries none', async () => { + const { connMessage, frames } = serveThrowingService( + ErrorCodes.SESSION_NOT_FOUND, + 'session s_missing not found', + undefined, + ); + connMessage({ type: 'hello' }); + connMessage({ + type: 'call', + id: 'c2', + scope: 'core', + service: 'ownershipTestThrowing', + method: 'fail', + }); + await expect.poll(() => frames.find((f) => f['type'] === 'error'), { timeout: 2000 }).toEqual({ + type: 'error', + id: 'c2', + code: ErrorCode.SESSION_NOT_FOUND, + msg: 'session s_missing not found', + }); + }); +}); + +interface ThrowingTestService { + fail(): never; +} + +/** + * Wire a one-off channel whose `fail` throws an `Error2` with `details`, and + * return a frame driver + frame sink for a `WsConnection` wrapped around a + * minimal stub socket. + */ +function serveThrowingService( + code: ConstructorParameters[0], + message: string, + details: Record | undefined, +): { + conn: WsConnection; + connMessage: (msg: unknown) => void; + frames: Record[]; +} { + const Throwing = createDecorator('ownershipTestThrowing'); + registerChannel(Throwing as ServiceIdentifier); + const service: ThrowingTestService = { + fail: () => { + throw new Error2(code, message, details === undefined ? undefined : { details }); + }, + }; + const frames: Record[] = []; + const handlers = new Map void>(); + const socket = { + readyState: 1, + OPEN: 1, + on(event: string, cb: (data: unknown) => void) { + handlers.set(event, cb); + }, + send(raw: string) { + frames.push(JSON.parse(raw) as Record); + }, + close() { + // no-op + }, + }; + const core = { + accessor: { + get: (id: unknown) => { + if (id === Throwing) return service; + throw new Error('unseeded service'); + }, + }, + } as unknown as Scope; + const conn = new WsConnection({ + socket: socket as never, + core, + remoteAddress: null, + userAgent: null, + }); + return { + conn, + connMessage: (msg) => handlers.get('message')?.(JSON.stringify(msg)), + frames, + }; +} diff --git a/packages/kap-server/test/wsV1Resync.test.ts b/packages/kap-server/test/wsV1Resync.test.ts index ff01d33851..b007ba63d6 100644 --- a/packages/kap-server/test/wsV1Resync.test.ts +++ b/packages/kap-server/test/wsV1Resync.test.ts @@ -4,7 +4,7 @@ * cursor-based replay. */ -import { mkdtemp, rm } from 'node:fs/promises'; +import { mkdir, mkdtemp, rm } from 'node:fs/promises'; import { tmpdir } from 'node:os'; import { join } from 'node:path'; @@ -192,7 +192,7 @@ describe('server-v2 /api/v1/ws resync', () => { c.send({ type: 'client_hello', id: 'h1', payload: withToken({ client_id: 'cli', subscriptions: [sid] }) }); await c.next((f) => f.type === 'ack' && f.id === 'h1'); - emitAgentEvent(sid, { type: 'turn.started', turnId: 1 } as unknown as DomainEvent); + emitAgentEvent(sid, { type: 'turn.started', turnId: 1, origin: { kind: 'user' } } as unknown as DomainEvent); const ev = await c.next((f) => f.type === 'turn.started'); expect(ev.seq).toBeGreaterThanOrEqual(1); @@ -212,7 +212,7 @@ describe('server-v2 /api/v1/ws resync', () => { await c1.next((f) => f.type === 'server_hello'); c1.send({ type: 'client_hello', id: 'h1', payload: withToken({ client_id: 'cli', subscriptions: [sid] }) }); await c1.next((f) => f.type === 'ack' && f.id === 'h1'); - emitAgentEvent(sid, { type: 'turn.started', turnId: 1 } as unknown as DomainEvent); + emitAgentEvent(sid, { type: 'turn.started', turnId: 1, origin: { kind: 'user' } } as unknown as DomainEvent); emitAgentEvent(sid, { type: 'turn.ended', turnId: 1 } as unknown as DomainEvent); await c1.next((f) => f.type === 'turn.ended'); c1.ws.close(); @@ -255,6 +255,58 @@ describe('server-v2 /api/v1/ws resync', () => { await c.closed; }); + it('sends resync_required when the journal cannot serve replay (storage failure)', async () => { + const sid = await createSession(); + await ensureMainAgent(sid); + const journalPath = join(home as string, 'server', 'events', `${sid}.jsonl`); + + // Subscribe (activates the journal) and land one durable event on disk. + const c1 = await openConn(wsUrl, server!.authTokenService.getToken()); + await c1.next((f) => f.type === 'server_hello'); + c1.send({ type: 'client_hello', id: 'h1', payload: withToken({ client_id: 'cli', subscriptions: [sid] }) }); + await c1.next((f) => f.type === 'ack' && f.id === 'h1'); + emitAgentEvent(sid, { type: 'turn.started', turnId: 1, origin: { kind: 'user' } } as unknown as DomainEvent); + await c1.next((f) => f.type === 'turn.started'); + + // Sabotage the journal: replace the file with a same-named directory so + // every subsequent open('a') fails. The next durable event's flush round + // drives the journal into its failure state. + await rm(journalPath, { force: true }); + await mkdir(journalPath); + emitAgentEvent(sid, { type: 'turn.ended', turnId: 1 } as unknown as DomainEvent); + await new Promise((resolve) => setTimeout(resolve, 200)); + + // Replaying from a stale cursor: the journal cannot serve the gap, and a + // "not served" must surface as resync_required — never as an empty page + // (or as the not-yet-persisted memory tail). The failure flush rounds + // settle asynchronously, so retry the subscribe a few times; each attempt + // retries the journal flush and converges to the sticky state. + const c2 = await openConn(wsUrl, server!.authTokenService.getToken()); + await c2.next((f) => f.type === 'server_hello'); + let resync: Frame | undefined; + for (let attempt = 0; attempt < 5 && resync === undefined; attempt++) { + c2.send({ + type: 'subscribe', + id: `s${attempt}`, + payload: withToken({ session_ids: [sid], cursors: { [sid]: { seq: 0 } } }), + }); + resync = await c2 + .next( + (f) => + f.type === 'resync_required' && + (f.payload as { session_id?: string } | undefined)?.session_id === sid, + 400, + ) + .catch(() => undefined); + } + expect(resync).toBeDefined(); + + c1.ws.close(); + c2.ws.close(); + await c1.closed; + await c2.closed; + }); + it('delivers only the allowlisted agent events via agent_filter', async () => { const sid = await createSession(); await ensureMainAgent(sid); diff --git a/packages/klient/AGENTS.md b/packages/klient/AGENTS.md index 3dff543f44..104fb0af15 100644 --- a/packages/klient/AGENTS.md +++ b/packages/klient/AGENTS.md @@ -50,13 +50,62 @@ terminal surface are v1-only and live in the legacy suites. suites (moved from server-e2e). They skip unless `KIMI_SERVER_URL` points at a running server and **must keep running unchanged**; the v1 surface has no in-memory equivalent, so these stay http-only — do not try to - dual-run them. + dual-run them. Exception: the dual-instance / session-ownership suites + (`legacy/dual-instance.test.ts`, `legacy/session-ownership.test.ts`, + `v2/ownership-redirect.test.ts`) boot their own kap-server instances via + the helpers below and run without `KIMI_SERVER_URL`. - The retired `scenarios/` scripts were rewritten as suites: prompt / approval / workspace / catalog / children / pending flows live in `test/e2e/dual/`; image-upload and terminal (v1-only surfaces) live in `test/e2e/legacy/`; refresh-replay was dropped as redundant with the legacy test of the same name. +## Dual-instance helpers + +Multi-server e2e cases boot two `kap-server` instances on ONE shared home via +`test/e2e/harness/testing/` (re-exported from `test/e2e/harness/index.js`). +Pick the helper by what the case needs: + +- **`startServerPair(options?)`** — default. Two in-process instances on one + shared `mkdtemp` home (or caller-provided `home`), each with `port: 0`, + `logLevel: 'silent'`, and `disableAuth: true` by default. Returns + `{ a, b, home, cwd, urlA, urlB, baseUrl(server), connectClient(server), dispose() }`; + `connectClient` returns an authed `HttpClient` (bearer from + `server.authTokenService.getToken()` when `disableAuth: false`). `cwd` is + the shared workspace cwd — pass it as `metadata.cwd` in `createSession` on + both instances ("same cwd" is session-level, never a server flag). + `dispose()` closes both instances (best-effort), restores env, and removes + the home only if the helper created it. +- **`spawnServerProcess(options?)` / `spawnServerProcessPair(options?)`** — + subprocess mode for signal-sensitive cases (SIGSTOP / SIGCONT / SIGKILL, + kill -9 lease takeover) that need real, distinct pids. Each child is + `node --import --import build/register-raw-text-loader.mjs + test/e2e/harness/testing/serverProcessMain.ts` with + `TSX_TSCONFIG_PATH=tsconfig.dev.json` (the dev tsconfig's `include` covers + every package's `src`, which tsx's per-file tsconfig mapping needs for + `experimentalDecorators` in the agent-core graph). Do NOT switch the + incantation to the tsx CLI: it is a hub/spoke wrapper that forks the server + as a grandchild, so `child.pid` — and every signal sent to it — would miss + the actual server. The helper asserts the pid the child reports in its + `{type:'ready'}` stdout line equals `child.pid` to catch exactly this. + `stop()` is SIGTERM + await exit, escalating to SIGKILL after ~10s. + +Hard rules: + +- Same-home dual boot requires `KIMI_CODE_EXPERIMENTAL_MULTI_SERVER=1` at + boot time or the second boot fails with `ServerLockedError`. The helpers + set/restore it themselves (child env for spawned pairs) — do not export it + globally. +- Always `port: 0`. A fixed busy port silently walks to `port + 1`, which + breaks registry/port assertions and cross-test isolation. +- One pair per test file/worker; never share a `RunningServer` or + `SpawnedServer` across files — vitest runs files in parallel workers. +- Readiness when NOT using these helpers: poll `GET /api/v1/healthz` + (auth-exempt) until 200 — not `/api/v1/meta` (token-gated). +- The helpers import `@moonshot-ai/kap-server` lazily at call time so the + harness barrel stays loadable under plain `tsx` without the raw-text + loader; keep that pattern when extending them. + ## Observability (inherited from server-e2e) - Keep observability inside each e2e case; every live case prints structured, @@ -74,6 +123,11 @@ terminal surface are v1-only and live in the legacy suites. conformance + e2e; live and model cases skip without their env). - `KIMI_SERVER_URL=http://127.0.0.1:58627 pnpm --filter @moonshot-ai/klient test` — include the live legacy/v2 cases against a running server. +- `pnpm --filter @moonshot-ai/klient exec vitest run test/e2e/legacy/dual-instance.test.ts` + — dual-instance helper self-tests (boot their own in-process + subprocess + servers; no external server needed). Same for + `test/e2e/legacy/session-ownership.test.ts` and + `test/e2e/v2/ownership-redirect.test.ts`. - `KIMI_E2E_MODEL=... KIMI_E2E_API_KEY=... [KIMI_E2E_BASE_URL=...] pnpm --filter @moonshot-ai/klient exec vitest run test/e2e/dual` — run the model-requiring dual suites against both backends. - `pnpm --filter @moonshot-ai/klient docker:e2e` — docker e2e; the run diff --git a/packages/klient/src/index.ts b/packages/klient/src/index.ts index ff3101461c..a813c7c1fd 100644 --- a/packages/klient/src/index.ts +++ b/packages/klient/src/index.ts @@ -126,3 +126,21 @@ export type { } from '@moonshot-ai/agent-core-v2/session/interaction/interaction'; export type { ContentPart } from '@moonshot-ai/agent-core-v2/app/llmProtocol/message'; export type { PermissionMode } from '@moonshot-ai/agent-core-v2/agent/permissionPolicy/types'; + +// Multi-instance session ownership (shared home): the http transport follows +// 40921 `session.held_by_peer` redirects onto the holder instance. +export { + KlientConnection, + normalizeInstanceOrigin, + readSessionOwnershipDetails, + SESSION_HELD_BY_PEER, + SessionRedirectChannel, + splitOrigin, + type HeldByPeerDetails, + type SessionOwnershipDetails, + type SessionOwnershipPhase, + type SessionRedirectChannelOptions, + type SessionRedirectInfo, + type SessionRedirectOptions, + type UnregisteredWriterDetails, +} from './sessionRedirect.js'; diff --git a/packages/klient/src/sessionRedirect.ts b/packages/klient/src/sessionRedirect.ts new file mode 100644 index 0000000000..55ab3e9ec9 --- /dev/null +++ b/packages/klient/src/sessionRedirect.ts @@ -0,0 +1,376 @@ +/** + * Multi-instance session-ownership handling — follow the holder, never corrupt. + * + * When several kap-server instances share one home, a session's write lease is + * held by exactly one instance. Any request that would materialize the session + * on a sibling instance is refused with envelope code 40921 + * (`session.held_by_peer`) plus a structured `details` payload (zod twin in + * `packages/protocol/src/session-ownership.ts`; the shapes here are the + * dependency-free client-side twin and must stay byte-identical). The + * `kind`/`phase` pair dictates the one correct reaction: + * + * - held-by-peer / routable follow `address`: rebase onto the + * holder origin and re-send the call + * - held-by-peer / creating lease file mid-creation: wait + * `retry_after_ms`, retry the SAME + * instance + * - held-by-peer / holder-unresponsive holder pid alive, heartbeat stale: + * terminal for auto-recovery + * - held-by-peer / held-by-local-instance holder has no address (embedded + * engine / CLI): terminal, never + * retried + * - unregistered-writer session looks actively written by + * an external / older process: + * terminal safety stop + * + * `KlientConnection` owns the per-client current origin plus the follow/retry + * policy and is shared by the `SessionRedirectChannel` decorator, so once one + * call redirects, all later calls — on every facade handle the klient handed + * out — land on the holder. Both policies are bounded per original call + * (`maxRedirects`, `maxCreatingRetries`) so redirect ping-pong cannot spin. + */ + +import type { + EventSourceRef, + IDisposable, + KlientChannel, + ScopeRef, +} from './core/channel.js'; +import { RPCError } from './core/errors.js'; +import { HttpChannel } from './transports/http/channel.js'; +import type { WsLikeCtor } from './transports/ws/wsSocket.js'; + +/** + * Envelope code of `session.held_by_peer` — the single branch key across the + * wire (mirrors `ErrorCode.SESSION_HELD_BY_PEER` in `@moonshot-ai/protocol`, + * kept literal here so klient stays dependency-free). + */ +export const SESSION_HELD_BY_PEER = 40921; + +export type SessionOwnershipPhase = + | 'creating' + | 'routable' + | 'holder-unresponsive' + | 'held-by-local-instance'; + +export interface HeldByPeerDetails { + readonly kind: 'held-by-peer'; + readonly phase: SessionOwnershipPhase; + /** Present only when phase === 'routable'. */ + readonly address?: string; + /** Retry hint (ms) for 'creating' / 'holder-unresponsive'. */ + readonly retry_after_ms?: number; +} + +export interface UnregisteredWriterDetails { + readonly kind: 'unregistered-writer'; +} + +export type SessionOwnershipDetails = HeldByPeerDetails | UnregisteredWriterDetails; + +const PHASES: ReadonlySet = new Set([ + 'creating', + 'routable', + 'holder-unresponsive', + 'held-by-local-instance', +]); + +/** + * Structural read of the 40921 `details` payload. Returns `undefined` for any + * other error or an unrecognized payload shape — unknown shapes are rethrown + * untouched so newer servers never break older clients (forward-compat rule). + */ +export function readSessionOwnershipDetails( + error: unknown, +): SessionOwnershipDetails | undefined { + if (!(error instanceof RPCError) || error.code !== SESSION_HELD_BY_PEER) return undefined; + const details = error.details; + if (typeof details !== 'object' || details === null) return undefined; + const kind = (details as { kind?: unknown }).kind; + if (kind === 'unregistered-writer') return { kind: 'unregistered-writer' }; + if (kind !== 'held-by-peer') return undefined; + const phase = (details as { phase?: unknown }).phase; + if (typeof phase !== 'string' || !PHASES.has(phase)) return undefined; + const address = (details as { address?: unknown }).address; + const retry = (details as { retry_after_ms?: unknown }).retry_after_ms; + return { + kind: 'held-by-peer', + phase: phase as SessionOwnershipPhase, + address: typeof address === 'string' && address.length > 0 ? address : undefined, + retry_after_ms: + typeof retry === 'number' && Number.isFinite(retry) && retry >= 0 ? retry : undefined, + }; +} + +/** + * Normalize a holder `address` to its bare origin (`http://host:port`). The + * server already maps wildcard binds onto `127.0.0.1`; a trailing slash or + * path suffix would otherwise poison URL composition. + */ +export function normalizeInstanceOrigin(address: string): string { + try { + const url = new URL(address); + if (url.protocol === 'http:' || url.protocol === 'https:') return url.origin; + } catch { + // not an absolute http(s) URL — fall through to the textual form + } + return address.replace(/\/+$/, ''); +} + +/** Split an absolute base URL into origin + path (no trailing slash on either). */ +export function splitOrigin(url: string): { origin: string; path: string } { + const parsed = new URL(url); + return { origin: parsed.origin, path: parsed.pathname.replace(/\/$/, '') }; +} + +/** One followed redirect, emitted via `KlientConnection.onRedirect`. */ +export interface SessionRedirectInfo { + /** Origin the request was sent to, e.g. `http://127.0.0.1:58627`. */ + readonly from: string; + /** + * Holder origin the client switched to; every later request lands there. + * This is the address to surface as "connected to the instance holding + * the session ()". + */ + readonly to: string; + /** 1-based follow count within the triggering call. */ + readonly follow: number; +} + +export interface SessionRedirectOptions { + /** + * Follow `routable` redirects automatically. Default `true`; set `false` to + * surface the raw 40921 to the caller instead. + */ + readonly follow?: boolean; + /** Redirect follows allowed per call (anti-loop bound). Default `1`. */ + readonly maxRedirects?: number; + /** `creating` retries allowed per call. Default `3`. */ + readonly maxCreatingRetries?: number; + /** Fallback pause (ms) between `creating` retries when the server omits `retry_after_ms`. Default `500`. */ + readonly creatingRetryDelayMs?: number; + /** Injectable clock for tests; defaults to a real `setTimeout` sleep. */ + readonly sleep?: (ms: number) => Promise; +} + +const defaultSleep = (ms: number): Promise => + new Promise((resolve) => setTimeout(resolve, ms)); + +/** + * Shared per-client redirect state: the current target origin (mutated by a + * followed redirect), the resolved policy, and the redirect listeners. The + * channel reads the origin from here on every attempt, so a redirect + * re-points the whole client, not one request. + */ +export class KlientConnection { + private url: string; + readonly follow: boolean; + readonly maxRedirects: number; + readonly maxCreatingRetries: number; + readonly creatingRetryDelayMs: number; + private readonly sleepImpl: (ms: number) => Promise; + private readonly redirectListeners = new Set<(info: SessionRedirectInfo) => void>(); + + constructor(opts: { url: string } & SessionRedirectOptions) { + this.url = opts.url.replace(/\/$/, ''); + this.follow = opts.follow ?? true; + this.maxRedirects = opts.maxRedirects ?? 1; + this.maxCreatingRetries = opts.maxCreatingRetries ?? 3; + this.creatingRetryDelayMs = opts.creatingRetryDelayMs ?? 500; + this.sleepImpl = opts.sleep ?? defaultSleep; + } + + get currentUrl(): string { + return this.url; + } + + onRedirect(listener: (info: SessionRedirectInfo) => void): IDisposable { + this.redirectListeners.add(listener); + return { dispose: () => this.redirectListeners.delete(listener) }; + } + + /** + * Switch the target origin after a `routable` answer. Returns the previous + * origin, or `undefined` when the holder address already equals the current + * origin (a self-redirect is a loop, handled as an error by the caller). + */ + applyRedirect(address: string): string | undefined { + const next = normalizeInstanceOrigin(address); + if (next === this.url) return undefined; + const previous = this.url; + this.url = next; + return previous; + } + + notifyRedirect(info: SessionRedirectInfo): void { + for (const listener of this.redirectListeners) listener(info); + } + + sleep(ms: number): Promise { + return this.sleepImpl(ms); + } +} + +export interface SessionRedirectChannelOptions { + /** Shared redirect state (origin + policy). */ + readonly connection: KlientConnection; + /** Optional bearer token (re-used against the holder origin). */ + readonly token?: string; + /** `fetch` implementation; defaults to the global `fetch`. */ + readonly fetch?: typeof fetch; + /** WebSocket implementation for the lazy event bridge of each inner channel. */ + readonly WebSocketImpl?: WsLikeCtor; +} + +/** + * `KlientChannel` decorator with the session-ownership follow/retry loop. + * The inner `HttpChannel` is rebuilt whenever a redirect moves the connection + * origin, so calls always target the current holder — facade handles obtained + * before a redirect keep working against the holder afterwards. + * + * Event subscriptions made after a redirect land on the holder. Subscriptions + * established before a redirect die with the stale channel's event bridge; + * consumers should re-subscribe when `KlientConnection.onRedirect` fires. + */ +export class SessionRedirectChannel implements KlientChannel { + private readonly connection: KlientConnection; + private readonly token?: string; + private readonly fetchImpl?: typeof fetch; + private readonly WebSocketImpl?: WsLikeCtor; + private inner: HttpChannel; + + constructor(opts: SessionRedirectChannelOptions) { + this.connection = opts.connection; + this.token = opts.token; + this.fetchImpl = opts.fetch; + this.WebSocketImpl = opts.WebSocketImpl; + this.inner = this.buildInner(); + } + + private buildInner(): HttpChannel { + return new HttpChannel({ + url: this.connection.currentUrl, + token: this.token, + fetch: this.fetchImpl, + WebSocketImpl: this.WebSocketImpl, + }); + } + + async call(scope: ScopeRef, service: string, method: string, args: unknown[]): Promise { + const { connection } = this; + let follows = 0; + let creatingRetries = 0; + const trace: string[] = []; + for (;;) { + try { + return await this.inner.call(scope, service, method, args); + } catch (error) { + const details = readSessionOwnershipDetails(error); + if (details === undefined) throw error; + if (details.kind === 'unregistered-writer') { + throw enrichOwnershipError( + error, + 'the session looks actively used by an external or older process (unregistered writer); ' + + 'opening it here is refused to protect its data — close that process, or wait until it goes idle, then retry', + ); + } + switch (details.phase) { + case 'routable': { + if (details.address === undefined) { + throw enrichOwnershipError( + error, + 'the session is held by a peer instance, but the refusal carried no holder address; ' + + 'retry shortly, or open it from the instance that created it', + ); + } + const target = normalizeInstanceOrigin(details.address); + if (!connection.follow) { + throw enrichOwnershipError( + error, + `the session is held by peer instance ${target}; connect to it directly ` + + '(automatic redirect is off: sessionRedirect.follow === false)', + ); + } + if (follows >= connection.maxRedirects) { + throw enrichOwnershipError( + error, + `followed ${follows} instance redirect(s) (${trace.join(' → ')}) and the session is ` + + 'still held elsewhere — giving up to avoid a redirect loop', + ); + } + const previous = connection.applyRedirect(target); + if (previous === undefined) { + throw enrichOwnershipError( + error, + `the holder address ${target} is this very instance, yet the request was refused; ` + + 'lease and server disagree — retry shortly, or force-unlock the session lease', + ); + } + follows += 1; + trace.push(`${previous} → ${target}`); + // Rebuild the inner channel onto the holder BEFORE notifying, so + // listeners that re-subscribe already land on the new origin. + const stale = this.inner; + this.inner = this.buildInner(); + void stale.close().catch(() => {}); + connection.notifyRedirect({ from: previous, to: target, follow: follows }); + continue; + } + case 'creating': { + if (creatingRetries >= connection.maxCreatingRetries) { + throw enrichOwnershipError( + error, + `the session lease is still mid-creation on a peer instance after ` + + `${creatingRetries} retries — retry shortly`, + ); + } + creatingRetries += 1; + await connection.sleep(details.retry_after_ms ?? connection.creatingRetryDelayMs); + continue; + } + case 'holder-unresponsive': { + const retryHint = + details.retry_after_ms !== undefined + ? ` (suggested retry after ${details.retry_after_ms}ms)` + : ''; + throw enrichOwnershipError( + error, + `the session is held by a peer instance that is not responding${retryHint}: its process ` + + 'is alive but its lease heartbeat is stale. Open the session from that instance, ' + + 'retry later, or stop the holder and force-unlock the lease to take over here', + ); + } + case 'held-by-local-instance': { + throw enrichOwnershipError( + error, + 'the session is held by a local instance without a network address (an embedded engine ' + + 'or CLI process); it cannot be reached from here — close the holding process (or ' + + 'force-unlock the lease) before opening the session elsewhere', + ); + } + } + } + } + } + + listen( + scope: ScopeRef, + source: EventSourceRef, + handler: (data: unknown) => void, + onError?: (error: Error) => void, + ): IDisposable { + return this.inner.listen(scope, source, handler, onError); + } + + close(): Promise { + return this.inner.close(); + } +} + +/** Re-throw a 40921 as `RPCError` with code + details intact and actionable handoff copy appended. */ +function enrichOwnershipError(error: unknown, guidance: string): RPCError { + if (error instanceof RPCError) { + return new RPCError(error.code, `${error.message} — ${guidance}`, error.details); + } + return new RPCError(SESSION_HELD_BY_PEER, String(error), undefined); +} diff --git a/packages/klient/src/transports/http/index.ts b/packages/klient/src/transports/http/index.ts index 92203a219b..0cce2d4ca1 100644 --- a/packages/klient/src/transports/http/index.ts +++ b/packages/klient/src/transports/http/index.ts @@ -3,13 +3,52 @@ * subscriptions transparently ride a lazily opened WebSocket — after * initialization the klient behaves exactly like its ipc/memory siblings. * Browser-safe: only `fetch` + an injectable `WebSocket` are required. + * + * Multi-instance shared home: when a call is refused with envelope code 40921 + * (`session.held_by_peer`) because a sibling instance holds the session's + * write lease, the transport follows the holder address automatically + * (`SessionRedirectChannel`); `currentUrl` tracks the live origin and + * `onSessionRedirect` fires once per followed redirect. */ import { createKlientFromChannel, type Klient, type KlientOptions } from '../../core/klient.js'; -import { HttpChannel, type HttpChannelOptions } from './channel.js'; +import { + KlientConnection, + SessionRedirectChannel, + type SessionRedirectInfo, + type SessionRedirectOptions, +} from '../../sessionRedirect.js'; +import type { HttpChannelOptions } from './channel.js'; -export interface HttpKlientOptions extends KlientOptions, HttpChannelOptions {} +export interface HttpKlientOptions + extends KlientOptions, + HttpChannelOptions, + SessionRedirectOptions { + /** Fires once per followed session-ownership redirect (re-subscribe events here). */ + readonly onSessionRedirect?: (info: SessionRedirectInfo) => void; +} + +export interface HttpKlient extends Klient { + /** Origin every later call targets; changes after a followed redirect. */ + readonly currentUrl: string; +} -export function createKlient(options: HttpKlientOptions): Klient { - return createKlientFromChannel(new HttpChannel(options), options); +export function createKlient(options: HttpKlientOptions): HttpKlient { + const connection = new KlientConnection(options); + if (options.onSessionRedirect !== undefined) { + connection.onRedirect(options.onSessionRedirect); + } + const channel = new SessionRedirectChannel({ + connection, + token: options.token, + fetch: options.fetch, + WebSocketImpl: options.WebSocketImpl, + }); + const klient = createKlientFromChannel(channel, options); + return { + ...klient, + get currentUrl() { + return connection.currentUrl; + }, + }; } diff --git a/packages/klient/src/transports/ws/wsSocket.ts b/packages/klient/src/transports/ws/wsSocket.ts index d345410dc4..340f854577 100644 --- a/packages/klient/src/transports/ws/wsSocket.ts +++ b/packages/klient/src/transports/ws/wsSocket.ts @@ -89,6 +89,7 @@ interface ServerFrame { readonly data?: unknown; readonly code?: number; readonly msg?: string; + readonly details?: unknown; readonly eventId?: string; } @@ -299,12 +300,12 @@ export class WsSocket { case 'error': { const p = this.take(frame.id); if (p !== undefined) { - p.reject(new RPCError(frame.code ?? 50001, frame.msg ?? 'error')); + p.reject(new RPCError(frame.code ?? 50001, frame.msg ?? 'error', frame.details)); } else { const sub = this.listens.get(frame.id ?? ''); if (sub !== undefined) { this.listens.delete(frame.id ?? ''); - const error = new RPCError(frame.code ?? 50001, frame.msg ?? 'error'); + const error = new RPCError(frame.code ?? 50001, frame.msg ?? 'error', frame.details); sub.onError?.(error); queueMicrotask(() => { for (const listener of this.listenErrorListeners) { diff --git a/packages/klient/test/e2e/harness/index.ts b/packages/klient/test/e2e/harness/index.ts index 30ddb91783..b20ed183e1 100644 --- a/packages/klient/test/e2e/harness/index.ts +++ b/packages/klient/test/e2e/harness/index.ts @@ -72,3 +72,11 @@ export { DEFAULT_FRAME_TIMEOUT_MS, waitForFrame, waitForSessionBusy } from './wa // A lark-style typed client for the `/api/v2` RPC + WS surface. Re-exported // here so consumers can `import { ServerClient } from '@moonshot-ai/server-e2e'` // alongside the legacy `DaemonClient`. Names are disjoint from the v1 surface. + +// ── Dual-instance test helpers (additive) ───────────────────────────────── +// Boot helpers for multi-server e2e cases: `startServerPair` (in-process) and +// `spawnServerProcess` / `spawnServerProcessPair` (subprocess, for +// signal-sensitive cases). The helpers import `@moonshot-ai/kap-server` +// lazily at call time, so this barrel stays loadable under plain `tsx` +// without the raw-text loader. +export * from './testing/index.js'; diff --git a/packages/klient/test/e2e/harness/testing/index.ts b/packages/klient/test/e2e/harness/testing/index.ts new file mode 100644 index 0000000000..4897d57842 --- /dev/null +++ b/packages/klient/test/e2e/harness/testing/index.ts @@ -0,0 +1,8 @@ +/** + * Dual-instance test helpers — see the "Dual-instance helpers" section of + * `packages/klient/AGENTS.md` for when to use the in-process pair vs the + * subprocess spawner. + */ +export * from './serverPair.js'; +export * from './serverProcess.js'; +export * from './spawnContract.js'; diff --git a/packages/klient/test/e2e/harness/testing/serverPair.ts b/packages/klient/test/e2e/harness/testing/serverPair.ts new file mode 100644 index 0000000000..98ff9ae84e --- /dev/null +++ b/packages/klient/test/e2e/harness/testing/serverPair.ts @@ -0,0 +1,197 @@ +/** + * In-process dual-instance boot: two `kap-server` instances sharing ONE home + * directory inside the current (test) process. + * + * Use this by default for multi-server e2e cases; reach for + * `spawnServerProcess` instead only when the case is signal-sensitive + * (SIGSTOP / SIGKILL need real, distinct pids). + * + * Two hard requirements this helper encapsulates: + * - `KIMI_CODE_EXPERIMENTAL_MULTI_SERVER=1` must be set when each instance + * boots (read by `startServer` at call time only), or the second boot on + * the same home fails with `ServerLockedError`. The previous env value is + * saved and restored after boot / on `dispose()`. + * - Both instances must bind `port: 0` (OS-assigned) — a fixed busy port + * silently walks to `port + 1`, which breaks assertions on the registry. + * + * `@moonshot-ai/kap-server` is imported lazily *inside* the function: its + * module graph contains `*.md?raw` imports that plain `tsx` (running without + * the raw-text loader) cannot resolve. Static imports would make the whole + * harness barrel unloadable there. Type-only imports are erased at compile + * time and stay safe. + */ +import { mkdtemp, rm } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; + +import type { RunningServer } from '@moonshot-ai/kap-server'; + +import { HttpClient } from '../http.js'; + +/** + * Literal copy of agent-core-v2's `MULTI_SERVER_FLAG_ENV`. Duplicated on + * purpose: importing the constant would pull the kap-server / agent-core-v2 + * module graph into every consumer of this barrel (see file header). + */ +export const MULTI_SERVER_FLAG_ENV = 'KIMI_CODE_EXPERIMENTAL_MULTI_SERVER' as const; + +// `recursive` rm can hit ENOTEMPTY on macOS while the closing server is still +// flushing/unlinking its own files — retry briefly (same trick as the v2 +// smoke test's home cleanup). +const RM_HOME_OPTIONS = { recursive: true, force: true, maxRetries: 5, retryDelay: 100 } as const; + +export interface ServerPairOptions { + /** Shared home for both instances. Created via `mkdtemp` when omitted. */ + readonly home?: string; + /** + * Boot both servers with `disableAuth` (no bearer-token hook). Default + * `true`; when `false`, `connectClient` attaches the per-instance token + * from `server.authTokenService.getToken()`. + */ + readonly disableAuth?: boolean; + /** Extra env vars patched around both boots (restored afterwards). */ + readonly env?: Record; + /** + * The shared workspace cwd for sessions created against the pair. "Same + * cwd" is a session-level concept — pass this as `metadata.cwd` in + * `createSession` on both instances. Defaults to the pair home. + */ + readonly cwd?: string; +} + +export interface ServerPair { + readonly a: RunningServer; + readonly b: RunningServer; + readonly home: string; + /** Shared workspace cwd — see `ServerPairOptions.cwd`. */ + readonly cwd: string; + /** Base URL of instance `a` (`http://host:port`). */ + readonly urlA: string; + /** Base URL of instance `b` (`http://host:port`). */ + readonly urlB: string; + baseUrl(server: RunningServer): string; + /** + * Authed REST client for one instance: bearer token attached unless the + * pair booted with `disableAuth`. + */ + connectClient(server: RunningServer): HttpClient; + /** + * Close both instances (idempotent, best-effort), restore the pre-boot env, + * and remove the home directory if this helper created it. + */ + dispose(): Promise; +} + +export async function startServerPair(options: ServerPairOptions = {}): Promise { + const home = options.home ?? (await mkdtemp(join(tmpdir(), 'kimi-e2e-pair-'))); + const ownsHome = options.home === undefined; + const disableAuth = options.disableAuth ?? true; + // The multi-server gate wins over `options.env`: without it the second boot + // below cannot succeed on a shared home. + const envPatch: Record = { ...options.env, [MULTI_SERVER_FLAG_ENV]: '1' }; + const savedEnv = saveEnv(envPatch); + let envRestored = false; + const restoreEnv = (): void => { + if (envRestored) return; + envRestored = true; + restoreSavedEnv(savedEnv); + }; + + try { + applyEnv(envPatch); + const { startServer } = await import('@moonshot-ai/kap-server'); + const boot = (): Promise => + startServer({ + host: '127.0.0.1', + port: 0, + homeDir: home, + logLevel: 'silent', + disableAuth, + }); + const a = await boot(); + let b: RunningServer; + try { + b = await boot(); + } catch (error) { + await a.close(); + throw error; + } + // The flag is also read at request time — `heldByPeerDetails` phase + // classification and the unregistered-writer check consult it on every + // ownership rejection — not just inside `startServer`. Keep the env + // patched for the pair's whole lifetime; dispose() restores it. + // (Restoring it here made request-time reads fall back to the registry + // default `false`, turning routable 40921s into held-by-local-instance + // on any environment without the master flag.) + + const baseUrl = (server: RunningServer): string => `http://${server.host}:${server.port}`; + let disposed = false; + return { + a, + b, + home, + cwd: options.cwd ?? home, + urlA: baseUrl(a), + urlB: baseUrl(b), + baseUrl, + connectClient: (server) => + new HttpClient({ + baseUrl: baseUrl(server), + apiPrefix: '/api/v1', + fetchImpl: fetch, + token: disableAuth ? undefined : server.authTokenService.getToken(), + }), + dispose: async () => { + if (disposed) return; + disposed = true; + restoreEnv(); + // Best-effort: a failed close must not mask the other instance's + // teardown, but it also must not disappear silently. + const results = await Promise.allSettled([a.close(), b.close()]); + for (const [label, result] of [ + ['a', results[0]], + ['b', results[1]], + ] as const) { + if (result?.status === 'rejected') { + process.stderr.write( + `[server-e2e] startServerPair dispose: instance ${label} close failed: ${String(result.reason)}\n`, + ); + } + } + if (ownsHome) { + await rm(home, RM_HOME_OPTIONS); + } + }, + }; + } catch (error) { + restoreEnv(); + if (ownsHome) { + await rm(home, RM_HOME_OPTIONS); + } + throw error; + } +} + +function saveEnv(patch: Record): Map { + const saved = new Map(); + for (const key of Object.keys(patch)) { + saved.set(key, process.env[key]); + } + return saved; +} + +function applyEnv(patch: Record): void { + for (const [key, value] of Object.entries(patch)) { + process.env[key] = value; + } +} + +function restoreSavedEnv(saved: Map): void { + for (const [key, value] of saved) { + if (value === undefined) { + delete process.env[key]; + } else { + process.env[key] = value; + } + } +} diff --git a/packages/klient/test/e2e/harness/testing/serverProcess.ts b/packages/klient/test/e2e/harness/testing/serverProcess.ts new file mode 100644 index 0000000000..cc887df18b --- /dev/null +++ b/packages/klient/test/e2e/harness/testing/serverProcess.ts @@ -0,0 +1,324 @@ +/** + * Subprocess-mode dual-instance boot: each server runs in its own OS process + * (`node --import tsx serverProcessMain.ts`), so signal-sensitive cases + * (SIGSTOP, SIGCONT, SIGKILL, kill -9 takeover) get real, distinct pids — + * in-process `startServerPair` cannot model those. + * + * Spawn incantation: + * node --import --import + * - `tsx` compiles the workspace's TypeScript module graph. It is attached + * as a `--import` loader — NOT via the tsx CLI, which is a hub/spoke + * wrapper: the CLI would fork the server as a GRANDCHILD, so + * `child.pid` (and every signal sent to it) would miss the actual + * server process. With the direct import the child IS the server, which + * the child additionally proves by reporting its own pid in the ready + * line. `tsx` is resolved through `createRequire` from the repo root's + * devDependencies — this package deliberately does not redeclare it. + * - `TSX_TSCONFIG_PATH` replaces the CLI's `--tsconfig` flag and points at + * `tsconfig.dev.json`, whose `include` covers every package's `src` — + * that is what tsx's per-file tsconfig mapping needs to apply + * `experimentalDecorators` for DI parameter decorators in the + * agent-core graph (mirrors `apps/kimi-code/tsconfig.dev.json`). + * - `build/register-raw-text-loader.mjs` makes `*.md?raw` prompt-template + * imports (kap-server → agent-core-v2) resolvable outside a bundler; + * plain `node` fails on those imports without it. + * - registers/lock state: same-home coexistence still requires + * `KIMI_CODE_EXPERIMENTAL_MULTI_SERVER=1`, passed through the child env. + * + * Readiness is the child's `{type:'ready'}` stdout line (printed after + * `startServer` resolved, i.e. the port is already listening). When driving + * an externally spawned server without that line, poll + * `waitForServerHealthy` instead — `/api/v1/healthz` is auth-exempt. + */ +import { spawn, type ChildProcess } from 'node:child_process'; +import { mkdtemp, rm } from 'node:fs/promises'; +import { createRequire } from 'node:module'; +import { tmpdir } from 'node:os'; +import { dirname, join, resolve } from 'node:path'; +import { createInterface } from 'node:readline'; +import { fileURLToPath, pathToFileURL } from 'node:url'; + +import { MULTI_SERVER_FLAG_ENV } from './serverPair.js'; +import { + SPAWN_SERVER_HOME_ENV, + type SpawnServerMessage, + type SpawnServerReadyMessage, +} from './spawnContract.js'; + +const TESTING_DIR = dirname(fileURLToPath(import.meta.url)); +// testing/ → harness/ → e2e/ → test/ → packages/klient +const PACKAGE_ROOT = resolve(TESTING_DIR, '../../../..'); +const REPO_ROOT = resolve(PACKAGE_ROOT, '../..'); +const ENTRY_PATH = join(TESTING_DIR, 'serverProcessMain.ts'); +const STOP_GRACE_MS = 10_000; +const STDERR_TAIL_LIMIT = 8_192; +// `recursive` rm can hit ENOTEMPTY on macOS while the closing server is still +// flushing/unlinking its own files — retry briefly. +const RM_HOME_OPTIONS = { recursive: true, force: true, maxRetries: 5, retryDelay: 100 } as const; + +export interface SpawnServerProcessOptions { + /** Home directory for the child server. Created via `mkdtemp` when omitted. */ + readonly home?: string; + /** Extra env for the child process, merged over `process.env`. */ + readonly env?: Record; + /** Working directory of the child process. Defaults to the parent's cwd. */ + readonly cwd?: string; + /** How long to wait for the child's ready line. Default 30s. */ + readonly startupTimeoutMs?: number; +} + +export interface SpawnedServer { + /** Pid of the child process — the handle signals are delivered to. */ + readonly pid: number; + readonly port: number; + readonly baseUrl: string; + readonly home: string; + /** + * Graceful stop: SIGTERM, await exit, escalate to SIGKILL after ~10s. + * Idempotent. Removes `home` when this helper created it. + */ + stop(): Promise; + /** Deliver an arbitrary signal to the child (e.g. SIGSTOP / SIGKILL). */ + kill(signal: NodeJS.Signals): void; + /** Captured stderr tail so far — child boot/close failures land here. */ + stderr(): string; +} + +export interface SpawnedServerPair { + readonly a: SpawnedServer; + readonly b: SpawnedServer; + readonly home: string; + /** Stop both children (idempotent, best-effort) and remove `home` if owned. */ + dispose(): Promise; +} + +export async function spawnServerProcess( + options: SpawnServerProcessOptions = {}, +): Promise { + const home = options.home ?? (await mkdtemp(join(tmpdir(), 'kimi-e2e-spawn-'))); + const ownsHome = options.home === undefined; + const startupTimeoutMs = options.startupTimeoutMs ?? 30_000; + + const require = createRequire(import.meta.url); + // The `.` export of the `tsx` package — attached via `--import` so the TS + // transpiler registers in the child process itself (no CLI wrapper). + const tsxLoaderHref = pathToFileURL(require.resolve('tsx')).href; + const rawLoaderHref = pathToFileURL( + join(REPO_ROOT, 'build/register-raw-text-loader.mjs'), + ).href; + + const child = spawn( + process.execPath, + ['--import', tsxLoaderHref, '--import', rawLoaderHref, ENTRY_PATH], + { + cwd: options.cwd, + env: { + ...process.env, + ...options.env, + // The CLI's `--tsconfig` flag, as a loader env var. + TSX_TSCONFIG_PATH: join(PACKAGE_ROOT, 'tsconfig.dev.json'), + [SPAWN_SERVER_HOME_ENV]: home, + }, + stdio: ['ignore', 'pipe', 'pipe'], + }, + ); + + let stderrTail = ''; + child.stderr?.on('data', (chunk: Buffer) => { + stderrTail = (stderrTail + chunk.toString('utf8')).slice(-STDERR_TAIL_LIMIT); + }); + const exited = new Promise((resolveExit) => { + child.once('exit', (code) => resolveExit(code)); + }); + + let ready: SpawnServerReadyMessage; + try { + ready = await waitForReady(child, startupTimeoutMs, () => stderrTail); + } catch (error) { + child.kill('SIGKILL'); + if (ownsHome) { + await rm(home, RM_HOME_OPTIONS); + } + throw error; + } + + const pid = child.pid; + if (pid === undefined) { + child.kill('SIGKILL'); + throw new Error('spawned server child has no pid'); + } + if (ready.pid !== pid) { + child.kill('SIGKILL'); + if (ownsHome) { + await rm(home, RM_HOME_OPTIONS); + } + throw new Error( + `spawned server reports pid ${ready.pid} but the child handle is ${pid} — a wrapper process slipped into the launch incantation and would misdirect signals`, + ); + } + const baseUrl = `http://127.0.0.1:${ready.port}`; + + let stopPromise: Promise | undefined; + const stop = (): Promise => { + stopPromise ??= (async () => { + if (child.exitCode === null && !child.killed) { + child.kill('SIGTERM'); + } + if (!(await raceExit(exited, STOP_GRACE_MS))) { + child.kill('SIGKILL'); + await raceExit(exited, STOP_GRACE_MS); + } + if (ownsHome) { + await rm(home, RM_HOME_OPTIONS); + } + })(); + return stopPromise; + }; + + return { + pid, + port: ready.port, + baseUrl, + home, + stop, + kill: (signal) => { + child.kill(signal); + }, + stderr: () => stderrTail, + }; +} + +/** + * Pair of spawned children sharing one home; the multi-server flag is pushed + * into both child envs — process-level patching like `startServerPair` does + * would not reach them. + */ +export async function spawnServerProcessPair( + options: SpawnServerProcessOptions = {}, +): Promise { + const home = options.home ?? (await mkdtemp(join(tmpdir(), 'kimi-e2e-spawn-pair-'))); + const ownsHome = options.home === undefined; + const childOptions: SpawnServerProcessOptions = { + ...options, + home, + env: { ...options.env, [MULTI_SERVER_FLAG_ENV]: '1' }, + }; + try { + const a = await spawnServerProcess(childOptions); + let b: SpawnedServer; + try { + b = await spawnServerProcess(childOptions); + } catch (error) { + await a.stop(); + throw error; + } + let disposed = false; + return { + a, + b, + home, + dispose: async () => { + if (disposed) return; + disposed = true; + const results = await Promise.allSettled([a.stop(), b.stop()]); + for (const [label, result] of [ + ['a', results[0]], + ['b', results[1]], + ] as const) { + if (result?.status === 'rejected') { + process.stderr.write( + `[server-e2e] spawnServerProcessPair dispose: child ${label} stop failed: ${String(result.reason)}\n`, + ); + } + } + if (ownsHome) { + await rm(home, RM_HOME_OPTIONS); + } + }, + }; + } catch (error) { + if (ownsHome) { + await rm(home, RM_HOME_OPTIONS); + } + throw error; + } +} + +/** Poll the auth-exempt health route until the server responds with 200. */ +export async function waitForServerHealthy(baseUrl: string, timeoutMs = 30_000): Promise { + const deadline = Date.now() + timeoutMs; + let lastError: unknown; + while (Date.now() < deadline) { + try { + const res = await fetch(`${baseUrl}/api/v1/healthz`); + if (res.status === 200) return; + lastError = new Error(`healthz returned HTTP ${res.status}`); + } catch (error) { + lastError = error; + } + await sleep(100); + } + throw new Error(`server at ${baseUrl} did not become healthy within ${timeoutMs}ms`, { + cause: lastError, + }); +} + +function waitForReady( + child: ChildProcess, + timeoutMs: number, + stderrTail: () => string, +): Promise { + return new Promise((resolvePromise, rejectPromise) => { + const rl = createInterface({ input: child.stdout! }); + const timer = setTimeout(() => { + fail(new Error('timed out waiting for the ready line')); + }, timeoutMs); + timer.unref(); + + let settled = false; + const fail = (error: Error): void => { + if (settled) return; + settled = true; + clearTimeout(timer); + rl.close(); + rejectPromise(withStderr(error, stderrTail())); + }; + rl.once('line', (line) => { + let message: SpawnServerMessage; + try { + message = JSON.parse(line) as SpawnServerMessage; + } catch { + fail(new Error(`unparseable first stdout line: ${line.slice(0, 200)}`)); + return; + } + if (message.type === 'error') { + fail(new Error(`child failed to boot: ${message.message}`)); + return; + } + if (settled) return; + settled = true; + clearTimeout(timer); + rl.close(); + resolvePromise(message); + }); + child.once('exit', (code, signal) => { + fail(new Error(`child exited before ready (code ${code ?? 'null'}, signal ${signal ?? 'null'})`)); + }); + child.once('error', (error) => { + fail(error); + }); + }); +} + +function withStderr(error: Error, stderrTail: string): Error { + if (stderrTail.length === 0) return error; + return new Error(`${error.message}\nchild stderr (tail):\n${stderrTail}`, { cause: error }); +} + +function raceExit(exited: Promise, timeoutMs: number): Promise { + return Promise.race([exited.then(() => true), sleep(timeoutMs).then(() => false)]); +} + +function sleep(ms: number): Promise { + return new Promise((resolvePromise) => setTimeout(resolvePromise, ms)); +} diff --git a/packages/klient/test/e2e/harness/testing/serverProcessMain.ts b/packages/klient/test/e2e/harness/testing/serverProcessMain.ts new file mode 100644 index 0000000000..7e48cb51b2 --- /dev/null +++ b/packages/klient/test/e2e/harness/testing/serverProcessMain.ts @@ -0,0 +1,79 @@ +/** + * Child-process entry for `spawnServerProcess` — boots ONE `kap-server` + * instance on an ephemeral port and reports it over stdout. + * + * Spawning cannot run plain `node` on this file: the surrounding module + * graph (kap-server → agent-core-v2) is TypeScript with `*.md?raw` imports. + * The parent spawns `tsx` plus `build/register-raw-text-loader.mjs` to cover + * both (see `serverProcess.ts` for the exact incantation), so a static + * import of `@moonshot-ai/kap-server` is safe HERE — unlike in the helpers + * that ship on the package barrel. + * + * Protocol (one JSON line each, see `spawnContract.ts`): + * - success: `{type:'ready', port, home}` on stdout, then serve until + * SIGTERM/SIGINT, which triggers `server.close()` and a clean exit. + * - failure: `{type:'error', message}` on stdout, exit code 1. + */ +import { startServer, type RunningServer } from '@moonshot-ai/kap-server'; + +import { + SPAWN_SERVER_HOME_ENV, + type SpawnServerMessage, +} from './spawnContract.js'; + +async function main(): Promise { + const home = process.env[SPAWN_SERVER_HOME_ENV]; + if (home === undefined || home.length === 0) { + emit({ type: 'error', message: `${SPAWN_SERVER_HOME_ENV} is not set` }); + process.exitCode = 1; + return; + } + + let server: RunningServer; + try { + server = await startServer({ + host: '127.0.0.1', + port: 0, + homeDir: home, + logLevel: 'silent', + // Signal tests must not juggle tokens; the parent drives this server + // over loopback only. + disableAuth: true, + }); + } catch (error) { + emit({ type: 'error', message: error instanceof Error ? error.message : String(error) }); + process.exitCode = 1; + return; + } + emit({ type: 'ready', port: server.port, home, pid: process.pid }); + + // Graceful shutdown: `stop()` sends SIGTERM first and escalates to SIGKILL + // on timeout, so exiting here only after `close()` settles keeps the + // instance-registry / journal teardown on the slow path observable. Close + // failures go to stderr (the parent captures the tail) but never block the + // exit — signal tests need the process to actually die. + let closing: Promise | undefined; + const onSignal = (): void => { + closing ??= server + .close() + .catch((error: unknown) => { + process.stderr.write( + `server.close() failed during signal shutdown: ${ + error instanceof Error ? error.message : String(error) + }\n`, + ); + }) + .finally(() => { + process.exit(0); + }); + void closing; + }; + process.on('SIGTERM', onSignal); + process.on('SIGINT', onSignal); +} + +function emit(message: SpawnServerMessage): void { + process.stdout.write(`${JSON.stringify(message)}\n`); +} + +void main(); diff --git a/packages/klient/test/e2e/harness/testing/spawnContract.ts b/packages/klient/test/e2e/harness/testing/spawnContract.ts new file mode 100644 index 0000000000..2222a62735 --- /dev/null +++ b/packages/klient/test/e2e/harness/testing/spawnContract.ts @@ -0,0 +1,30 @@ +/** + * Shared contract between `spawnServerProcess` (`serverProcess.ts`) and its + * child entry point (`serverProcessMain.ts`). Kept dependency-free so both + * sides can import it without pulling in kap-server. + */ + +/** Env var carrying the child server's homeDir into `serverProcessMain`. */ +export const SPAWN_SERVER_HOME_ENV = 'KIMI_E2E_SPAWN_SERVER_HOME'; + +/** One JSON line on the child's stdout once `startServer` resolved. */ +export interface SpawnServerReadyMessage { + readonly type: 'ready'; + readonly port: number; + readonly home: string; + /** + * The child reports its OWN pid, and the spawner asserts it equals + * `child.pid` — if the launch incantation ever reintroduces a wrapper + * process (e.g. the tsx CLI, which forks a grandchild), signals delivered + * via `kill()` would silently hit the wrong process. + */ + readonly pid: number; +} + +/** One JSON line on the child's stdout when booting failed. */ +export interface SpawnServerErrorMessage { + readonly type: 'error'; + readonly message: string; +} + +export type SpawnServerMessage = SpawnServerReadyMessage | SpawnServerErrorMessage; diff --git a/packages/klient/test/e2e/legacy/dual-instance.test.ts b/packages/klient/test/e2e/legacy/dual-instance.test.ts new file mode 100644 index 0000000000..3c6d1a93d0 --- /dev/null +++ b/packages/klient/test/e2e/legacy/dual-instance.test.ts @@ -0,0 +1,260 @@ +/** + * Self-tests for the dual-instance helpers (`test/e2e/harness/testing/`). + * + * These tests require no external server — they boot their own instances — + * and are deliberately the first consumers of the Phase-2 multi-server test + * infrastructure. What's asserted: + * + * In-process (`startServerPair`): + * 1. Two instances boot on ONE shared home; both ports are ephemeral (> 0) + * and distinct, and both land in `/server/instances/` (dir listing + * + `listLiveServerInstances` agree). + * 2. Closing instance `a` leaves instance `b` serving (healthz 200, registry + * down to one live entry) while `a`'s port refuses connections. + * 3. `dispose()` restores `KIMI_CODE_EXPERIMENTAL_MULTI_SERVER` to its + * pre-boot value and removes the helper-created home directory. + * (Upstream removed the single-instance server lock — multi-server is + * the always-on model now, so the former "no flag → ServerLockedError" + * case no longer exists.) + * + * Subprocess (`spawnServerProcess`): + * 4. A child boots on a real distinct pid, answers healthz, and serves + * token-gated routes WITHOUT a token (`disableAuth`); `stop()` (SIGTERM) + * exits the child and removes the helper-created home. + * 5. A spawned pair shares one home (flag reaches the children's env); a + * SIGKILLed child's pid actually dies and its registry entry is swept as + * stale on the next `listLiveServerInstances` read. + * + * KNOWN BRANCH GAP (refactor-fs-watch WIP): kap-server's `close()` currently + * throws `appendLogStore depends on writeAuthorityRegistry which is NOT + * registered` for a session-less server — the Phase-1/2 + * `writeAuthorityRegistryService` module exists but is not yet imported by + * anything, so its scoped DI registration never runs. Verified pre-existing + * with a single in-process server and no server-e2e code involved. Test 2 + * tolerates ONLY that exact error (see `isKnownCloseGap`) and log which + * path ran; once the wiring lands, drop the tolerance and assert close() + * resolves. + */ +import { existsSync } from 'node:fs'; +import { readdir } from 'node:fs/promises'; +import { join } from 'node:path'; + +import { listLiveServerInstances } from '@moonshot-ai/kap-server'; +import { describe, expect, it } from 'vitest'; + +import { HttpClient } from '../harness/http.js'; +import { + MULTI_SERVER_FLAG_ENV, + spawnServerProcess, + spawnServerProcessPair, + startServerPair, + waitForServerHealthy, +} from '../harness/testing/index.js'; +import { createCaseLogger, errorForLog } from './log.js'; + +describe('dual-instance helpers', () => { + describe('startServerPair (in-process)', () => { + it( + 'boots two instances on one shared home with distinct ephemeral ports', + { timeout: 30_000 }, + async () => { + const log = createCaseLogger('dual-instance/in-process-boot'); + const pair = await startServerPair(); + try { + expect(pair.a.port).toBeGreaterThan(0); + expect(pair.b.port).toBeGreaterThan(0); + expect(pair.a.port).not.toBe(pair.b.port); + + const instances = await listLiveServerInstances(pair.home); + const instanceFiles = (await readdir(join(pair.home, 'server', 'instances'))).filter( + (name) => name.endsWith('.json'), + ); + log('shared home registry', { + home: pair.home, + ports: [pair.a.port, pair.b.port], + liveInstances: instances, + instanceFiles, + }); + expect(instanceFiles).toHaveLength(2); + expect(instances).toHaveLength(2); + expect(sortedNumeric(instances.map((info) => info.port))).toEqual( + sortedNumeric([pair.a.port, pair.b.port]), + ); + + // Both instances answer authed REST traffic (disableAuth by default). + await pair.connectClient(pair.a).listSessions(); + await pair.connectClient(pair.b).listSessions(); + } finally { + await pair.dispose(); + } + }, + ); + + it( + 'closing instance a leaves instance b serving', + { timeout: 30_000 }, + async () => { + const log = createCaseLogger('dual-instance/close-left-serving'); + const pair = await startServerPair(); + try { + const closeError = await pair.a.close().then( + () => undefined, + (error: unknown) => error, + ); + log('a.close() outcome', { + closeError: closeError === undefined ? null : errorForLog(closeError), + }); + // Only the pre-existing branch gap is tolerated (see file header). + if (closeError !== undefined && !isKnownCloseGap(closeError)) { + throw closeError instanceof Error ? closeError : new Error(`close() rejected with a non-error of type ${typeof closeError}`); + } + + // The core contract either way: the peer instance is unaffected. + await waitForServerHealthy(pair.urlB, 10_000); + const fetchA = await fetch(`${pair.urlA}/api/v1/healthz`).then( + (res) => `up:${res.status}`, + () => 'down', + ); + const instances = await listLiveServerInstances(pair.home); + log('after a.close()', { fetchA, bHealthy: true, liveInstances: instances }); + + if (closeError === undefined) { + expect(fetchA).toBe('down'); + expect(instances).toHaveLength(1); + expect(instances[0]?.port).toBe(pair.b.port); + } else { + // Broken-close branch: a never got torn down, so it is still + // listening and registered; b must be healthy regardless. + expect(fetchA).toBe('up:200'); + expect(instances).toHaveLength(2); + } + } finally { + await pair.dispose(); + } + }, + ); + + it( + 'dispose() restores the multi-server env flag and removes the created home', + { timeout: 30_000 }, + async () => { + const log = createCaseLogger('dual-instance/dispose-cleanup'); + const ambientFlag = process.env[MULTI_SERVER_FLAG_ENV]; + const pair = await startServerPair(); + // The flag stays patched for the pair's whole lifetime: request-time + // readers (40921 phase classification, unregistered-writer checks) + // consult it on every ownership rejection, not just at boot. + expect(process.env[MULTI_SERVER_FLAG_ENV]).toBe('1'); + expect(existsSync(pair.home)).toBe(true); + + await pair.dispose(); + log('after dispose()', { + restoredFlag: process.env[MULTI_SERVER_FLAG_ENV] ?? null, + ambientFlag: ambientFlag ?? null, + homeExists: existsSync(pair.home), + }); + expect(process.env[MULTI_SERVER_FLAG_ENV]).toBe(ambientFlag); + expect(existsSync(pair.home)).toBe(false); + }, + ); + + it( + 'spawns a child server, serves without a token, and stops on SIGTERM', + { timeout: 60_000 }, + async () => { + const log = createCaseLogger('dual-instance/spawn-stop'); + const spawned = await spawnServerProcess(); + log('spawned child', { + pid: spawned.pid, + port: spawned.port, + baseUrl: spawned.baseUrl, + home: spawned.home, + }); + expect(spawned.pid).toBeGreaterThan(0); + expect(spawned.pid).not.toBe(process.pid); + + await waitForServerHealthy(spawned.baseUrl, 10_000); + const client = new HttpClient({ + baseUrl: spawned.baseUrl, + apiPrefix: '/api/v1', + fetchImpl: fetch, + }); + const page = await client.listSessions(); + log('GET /api/v1/sessions without token (disableAuth)', page); + expect(Array.isArray(page.items)).toBe(true); + + await spawned.stop(); + const alive = pidAlive(spawned.pid); + log('after stop()', { pid: spawned.pid, alive, homeExists: existsSync(spawned.home) }); + expect(alive).toBe(false); + expect(existsSync(spawned.home)).toBe(false); + }, + ); + + it( + 'spawned pair shares one home; a SIGKILLed child dies and is swept from the registry', + { timeout: 60_000 }, + async () => { + const log = createCaseLogger('dual-instance/spawn-pair-sigkill'); + const pair = await spawnServerProcessPair(); + try { + expect(pair.a.pid).not.toBe(pair.b.pid); + expect(pair.a.port).not.toBe(pair.b.port); + await Promise.all([ + waitForServerHealthy(pair.a.baseUrl, 10_000), + waitForServerHealthy(pair.b.baseUrl, 10_000), + ]); + + const before = await listLiveServerInstances(pair.home); + log('live instances before SIGKILL', { before, pidA: pair.a.pid, pidB: pair.b.pid }); + expect(sortedNumeric(before.map((info) => info.pid))).toEqual( + sortedNumeric([pair.a.pid, pair.b.pid]), + ); + + pair.a.kill('SIGKILL'); + expect(await waitForPidExit(pair.a.pid, 5_000)).toBe(true); + + // The dead instance cannot release its registration; the registry + // sweeps it on the next live read via the pid probe. + const after = await listLiveServerInstances(pair.home); + log('live instances after SIGKILL', { after }); + expect(after).toHaveLength(1); + expect(after[0]?.pid).toBe(pair.b.pid); + } finally { + await pair.dispose(); + } + expect(existsSync(pair.home)).toBe(false); + }, + ); + }); +}); + +function sortedNumeric(values: readonly number[]): number[] { + return [...values].sort((x, y) => x - y); +} + +// See the KNOWN BRANCH GAP note in the file header. Matches the exact DI +// wiring failure so any OTHER close error still fails the test. +const KNOWN_CLOSE_GAP = 'writeAuthorityRegistry'; + +function isKnownCloseGap(error: unknown): boolean { + return error instanceof Error && error.message.includes(KNOWN_CLOSE_GAP); +} + +function pidAlive(pid: number): boolean { + try { + process.kill(pid, 0); + return true; + } catch (error) { + return (error as NodeJS.ErrnoException).code === 'EPERM'; + } +} + +async function waitForPidExit(pid: number, timeoutMs: number): Promise { + const deadline = Date.now() + timeoutMs; + while (Date.now() < deadline) { + if (!pidAlive(pid)) return true; + await new Promise((resolvePromise) => setTimeout(resolvePromise, 50)); + } + return !pidAlive(pid); +} diff --git a/packages/klient/test/e2e/legacy/session-ownership.test.ts b/packages/klient/test/e2e/legacy/session-ownership.test.ts new file mode 100644 index 0000000000..aeedcc1259 --- /dev/null +++ b/packages/klient/test/e2e/legacy/session-ownership.test.ts @@ -0,0 +1,670 @@ +/** + * Phase-2 session-ownership verification matrix — multi-process e2e over real + * REST boundaries (design `.tmp/refactor-watch-design-v2.md` §3.10). + * + * One session write lease per session under `/session-leases/.json` + * (heartbeat 2000ms, TTL 6000ms — real-time constants, wait with polling). + * Materializing routes on a peer-held session answer HTTP 200 with envelope + * `code 40921 session.held_by_peer` + ownership details. kap-server's own e2e + * already pins the dual-open envelope schema and graceful-close takeover; the + * unique value here is cross-PROCESS behavior and byte-level file integrity: + * + * 1. Concurrent dual materialization race (in-process `startServerPair`): + * A creates the session, then `GET .../warnings` storms fire on A and B + * concurrently for several rounds — A always serves (code 0), B always + * loses with 40921 phase `routable` + A's address — followed by a + * `*.jsonl` byte-integrity sweep of the shared home (no torn records) and + * a single-lease assertion. + * 2. SIGSTOP → no takeover, SIGCONT → clean continuation (subprocess pair): + * a stopped holder keeps its lease past the heartbeat TTL; B is refused + * with phase `holder-unresponsive` (retry_after_ms 2000), the lease + * `lock_id` never changes and no `*.stale.*` sibling appears. After + * SIGCONT the holder serves the session again and B drops back to + * `routable` once the heartbeat refreshes. + * 3. kill -9 → dead-pid takeover with data intact (subprocess pair): after + * the holder dies and is reaped, B's resume poll succeeds (transient + * observations are schema-valid 40921s, never `routable` into the dead + * address), the lease is re-acquired with a NEW lock_id and B's + * pid/address, and A's payload is rename-isolated to + * `.json.stale.` next to it. `GET /sessions/{id}` on B + * returns the same session; the JSONL sweep stays clean. + * + * Every subprocess scenario ends with dispose() + an explicit pid-dead check + * (ESRCH) so no child server can linger across tests. + */ +import { mkdirSync, readdirSync, unlinkSync, writeFileSync } from 'node:fs'; +import { readdir, readFile } from 'node:fs/promises'; +import { join } from 'node:path'; + +import { SESSION_LEASE_TTL_MS, sessionLeasePath } from '@moonshot-ai/agent-core-v2'; +import { ErrorCode, sessionOwnershipDetailsSchema } from '@moonshot-ai/protocol'; +import { describe, expect, it } from 'vitest'; + +import { DaemonClient } from '../harness/index.js'; +import { + spawnServerProcessPair, + startServerPair, + waitForServerHealthy, +} from '../harness/testing/index.js'; +import { createCaseLogger } from './log.js'; + +const SESSION_OWNERSHIP_HELD_BY_PEER = 40921; +/** Pinned wire hint for `holder-unresponsive` (design §3.10 Phase-2 row). */ +const HOLDER_UNRESPONSIVE_RETRY_AFTER_MS = 2000; + +describe('session ownership: concurrent dual materialization race (in-process pair)', () => { + it( + 'holder serves, peer gets 40921 routable every round, no torn JSONL on disk', + { timeout: 90_000 }, + async () => { + const log = createCaseLogger('session-ownership/materialization-race'); + const pair = await startServerPair(); + try { + const sessionId = await createSession(pair.urlA, pair.cwd); + log('session created on A', { sessionId, urlA: pair.urlA, urlB: pair.urlB }); + + const lease = await readLease(pair.home, sessionId); + log('lease after create', lease); + expect(lease?.['address']).toBe(pair.urlA); + const lockId = lease?.['lock_id']; + expect(typeof lockId).toBe('string'); + + const ROUNDS = 5; + for (let round = 1; round <= ROUNDS; round += 1) { + const [a, b] = await Promise.all([ + getEnvelope(pair.urlA, warningsPath(sessionId)), + getEnvelope(pair.urlB, warningsPath(sessionId)), + ]); + expect(a.status).toBe(200); + expect(a.body.code).toBe(0); + expect(b.status).toBe(200); + expect(b.body.code).toBe(ErrorCode.SESSION_HELD_BY_PEER); + expect(b.body.code).toBe(SESSION_OWNERSHIP_HELD_BY_PEER); + const details = sessionOwnershipDetailsSchema.parse(b.body.details); + expect(details).toEqual({ kind: 'held-by-peer', phase: 'routable', address: pair.urlA }); + if (round === 1 || round === ROUNDS) { + log(`concurrent round ${round}/${ROUNDS}`, { + holder: { status: a.status, code: a.body.code }, + peer: { status: b.status, code: b.body.code, details }, + }); + } + } + + // Exactly one lease file for the session, still A's, lock id stable. + const leaseFilenames = await listLeaseFilenames(pair.home); + const related = leaseFilenames.filter((name) => name.startsWith(`${sessionId}.json`)); + expect(related).toEqual([`${sessionId}.json`]); + const after = await readLease(pair.home, sessionId); + expect(after?.['lock_id']).toBe(lockId); + expect(after?.['address']).toBe(pair.urlA); + + const sweep = await assertJsonlIntegrity(pair.home); + log('byte-integrity sweep', sweep); + expect(sweep.files).toBeGreaterThan(0); + } finally { + await pair.dispose(); + } + }, + ); +}); + +describe('session ownership: SIGSTOP/SIGCONT (subprocess pair)', () => { + it( + 'a stopped holder is never taken over past the TTL; after SIGCONT it resumes cleanly', + { timeout: 150_000 }, + async () => { + const log = createCaseLogger('session-ownership/sigstop-sigcont'); + const pair = await spawnServerProcessPair(); + // If the test fails mid-stop, un-freeze the child before teardown so + // dispose()'s SIGTERM can be acted on without the SIGKILL escalation. + let holderStopped = false; + try { + await Promise.all([ + waitForServerHealthy(pair.a.baseUrl, 15_000), + waitForServerHealthy(pair.b.baseUrl, 15_000), + ]); + const sessionId = await createSession(pair.a.baseUrl, pair.home); + const leaseA = await readLease(pair.home, sessionId); + expect(leaseA?.['address']).toBe(pair.a.baseUrl); + expect(leaseA?.['pid']).toBe(pair.a.pid); + const lockIdA = leaseA?.['lock_id']; + expect(typeof lockIdA).toBe('string'); + log('holder lease before SIGSTOP', { sessionId, lease: leaseA }); + + process.kill(pair.a.pid, 'SIGSTOP'); + holderStopped = true; + log('SIGSTOP sent', { pid: pair.a.pid }); + + // Wait (real time) until the frozen heartbeat is older than the TTL: + // from now on a peer inspecting the lease MUST see the holder as + // unresponsive — and MUST NOT take the lease over (pid still alive + // with matching identity). + const stale = await pollUntil(async () => { + const lease = await readLease(pair.home, sessionId); + const heartbeatAt = lease?.['heartbeat_at']; + if (typeof heartbeatAt !== 'number') return undefined; + const ageMs = Date.now() - heartbeatAt; + return ageMs > SESSION_LEASE_TTL_MS ? { heartbeatAt, ageMs } : undefined; + }, `lease heartbeat older than TTL (${SESSION_LEASE_TTL_MS}ms)`, 15_000, 250); + log('heartbeat now stale while A is stopped', stale); + + for (let attempt = 1; attempt <= 2; attempt += 1) { + const b = await getEnvelope(pair.b.baseUrl, warningsPath(sessionId)); + const details = sessionOwnershipDetailsSchema.parse(b.body.details); + log(`B resume attempt ${attempt}/2 while A is stopped`, { + status: b.status, + code: b.body.code, + details, + }); + expect(b.status).toBe(200); + expect(b.body.code).toBe(SESSION_OWNERSHIP_HELD_BY_PEER); + expect(details).toEqual({ + kind: 'held-by-peer', + phase: 'holder-unresponsive', + retry_after_ms: HOLDER_UNRESPONSIVE_RETRY_AFTER_MS, + }); + if (attempt === 1) await sleep(300); + } + + // No takeover happened: same lock id, no rename-isolated sibling. + expect((await readLease(pair.home, sessionId))?.['lock_id']).toBe(lockIdA); + const filenames = await listLeaseFilenames(pair.home); + expect(filenames.filter((name) => name.startsWith(`${sessionId}.json`))).toEqual([ + `${sessionId}.json`, + ]); + + process.kill(pair.a.pid, 'SIGCONT'); + holderStopped = false; + log('SIGCONT sent', { pid: pair.a.pid }); + await waitForServerHealthy(pair.a.baseUrl, 20_000); + + // The original holder still owns and serves the session. + const aResumed = await pollUntil(async () => { + const res = await getEnvelope(pair.a.baseUrl, warningsPath(sessionId)); + return res.body.code === 0 ? res : undefined; + }, 'A serving the session after SIGCONT', 10_000, 500); + log('A serving the session after SIGCONT', { + status: aResumed.status, + code: aResumed.body.code, + }); + + // B stays refused; once the resumed heartbeat lands it returns to + // `routable` (never code 0, never anything but schema-valid 40921). + const transcript: Array<{ code: number; details: unknown }> = []; + const routable = await pollUntil(async () => { + const res = await getEnvelope(pair.b.baseUrl, warningsPath(sessionId)); + transcript.push({ code: res.body.code, details: res.body.details }); + const details = sessionOwnershipDetailsSchema.parse(res.body.details); + return details.kind === 'held-by-peer' && details.phase === 'routable' + ? details + : undefined; + }, 'B back to 40921 routable after SIGCONT', 15_000, 500); + log('B observations after SIGCONT', { transcript, final: routable }); + for (const entry of transcript) { + expect(entry.code).toBe(SESSION_OWNERSHIP_HELD_BY_PEER); + } + expect(routable).toEqual({ + kind: 'held-by-peer', + phase: 'routable', + address: pair.a.baseUrl, + }); + + // Still the original lease; no stale sibling ever appeared. + expect((await readLease(pair.home, sessionId))?.['lock_id']).toBe(lockIdA); + const swept = await assertJsonlIntegrity(pair.home); + log('byte-integrity sweep', swept); + expect(swept.files).toBeGreaterThan(0); + } finally { + if (holderStopped) { + try { + process.kill(pair.a.pid, 'SIGCONT'); + } catch { + // child already dead — teardown proceeds regardless + } + } + await pair.dispose(); + } + expect(pidAlive(pair.a.pid)).toBe(false); + expect(pidAlive(pair.b.pid)).toBe(false); + log('exit hygiene: both child pids dead after dispose', { + pidA: pair.a.pid, + pidB: pair.b.pid, + }); + }, + ); +}); + +describe('session ownership: kill -9 dead-pid takeover (subprocess pair)', () => { + it( + 'B takes over via rename isolation with a new lock id and serves the intact session', + { timeout: 120_000 }, + async () => { + const log = createCaseLogger('session-ownership/sigkill-takeover'); + const pair = await spawnServerProcessPair(); + try { + await Promise.all([ + waitForServerHealthy(pair.a.baseUrl, 15_000), + waitForServerHealthy(pair.b.baseUrl, 15_000), + ]); + const sessionId = await createSession(pair.a.baseUrl, pair.home); + const leaseA = await readLease(pair.home, sessionId); + expect(leaseA?.['address']).toBe(pair.a.baseUrl); + expect(leaseA?.['pid']).toBe(pair.a.pid); + const lockIdA = leaseA?.['lock_id']; + expect(typeof lockIdA).toBe('string'); + log('holder lease before SIGKILL', { sessionId, lease: leaseA }); + + pair.a.kill('SIGKILL'); + // Await real death (zombie reaped): ESRCH is the takeover precondition. + const exited = await waitForPidExit(pair.a.pid, 10_000); + log('SIGKILL delivered', { pid: pair.a.pid, exited }); + expect(exited).toBe(true); + + // B's resume poll: may observe schema-valid 40921s transiently; must + // converge to success and must never be routed to the dead address. + const transcript: Array<{ elapsedMs: number; code: number; details: unknown }> = []; + const startedAt = Date.now(); + const success = await pollUntil(async () => { + const res = await getEnvelope(pair.b.baseUrl, warningsPath(sessionId)); + transcript.push({ + elapsedMs: Date.now() - startedAt, + code: res.body.code, + details: res.body.details, + }); + return res.body.code === 0 ? res : undefined; + }, 'B resume succeeds after dead-pid takeover', 20_000, 500); + log('B resume transcript after kill -9', transcript); + log('B resume success', { status: success.status, code: success.body.code }); + + for (const entry of transcript) { + if (entry.code === 0) continue; + expect(entry.code).toBe(SESSION_OWNERSHIP_HELD_BY_PEER); + const details = sessionOwnershipDetailsSchema.parse(entry.details); + expect(details.kind).toBe('held-by-peer'); + if (details.kind === 'held-by-peer') { + expect(details.phase).not.toBe('routable'); + } + } + + // Takeover evidence: new lock id, B's pid + address, and A's payload + // rename-isolated next to the live lease. + const leaseB = await readLease(pair.home, sessionId); + log('lease after takeover', leaseB); + expect(typeof leaseB?.['lock_id']).toBe('string'); + expect(leaseB?.['lock_id']).not.toBe(lockIdA); + expect(leaseB?.['pid']).toBe(pair.b.pid); + expect(leaseB?.['address']).toBe(pair.b.baseUrl); + + const staleName = `${sessionId}.json.stale.${String(lockIdA)}`; + const stale = JSON.parse( + await readFile(join(pair.home, 'session-leases', staleName), 'utf8'), + ) as Record; + log('rename-isolated stale lease', { staleName, stale }); + expect(stale['lock_id']).toBe(lockIdA); + expect(stale['pid']).toBe(pair.a.pid); + const related = (await listLeaseFilenames(pair.home)).filter((name) => + name.startsWith(`${sessionId}.json`), + ); + expect(related).toEqual([`${sessionId}.json`, staleName].sort()); + + // The session survived the handover intact. + const session = await getEnvelope(pair.b.baseUrl, `/sessions/${sessionId}`); + log('GET /sessions/{id} on B after takeover', session.body); + expect(session.status).toBe(200); + expect(session.body.code).toBe(0); + expect(session.body.data?.id).toBe(sessionId); + expect(session.body.data?.metadata?.cwd).toBe(pair.home); + + const swept = await assertJsonlIntegrity(pair.home); + log('byte-integrity sweep', swept); + expect(swept.files).toBeGreaterThan(0); + } finally { + await pair.dispose(); + } + expect(pidAlive(pair.a.pid)).toBe(false); + expect(pidAlive(pair.b.pid)).toBe(false); + log('exit hygiene: both child pids dead after dispose', { + pidA: pair.a.pid, + pidB: pair.b.pid, + }); + }, + ); +}); + +/** + * Multi-instance session-list sync (design `.tmp/refactor-watch-design-v2.md` + * §3.8): the event-plane hint plus the list-side ownership join. While the + * ownership matrix above cross-checks dual-open refusals, these cases track a + * session created on instance A as it surfaces on instance B: + * + * 1. A creates a session under a workspace B has never seen → B's root + * watcher fires → B's subscribed WS client receives a volatile, + * payload-less `session.list_changed` → B's re-pulled list shows the + * session with `ownership.held_by = 'peer'` + A's address, while B's own + * session reads 'self'. + * 2. A creates a SECOND session under a workspace B already watches → + * discovered all the same (the per-workspace watcher layer). + * + * Both run on the in-process pair (real shared home, real chokidar events); + * the hint is volatile — never journaled — so only live delivery counts. + * + * Test-environment note: two kap-server instances in ONE vitest process put + * enough concurrent fs/fsync load on macOS that Node `fs.watch` (libuv + * FSEvents — the only mechanism chokidar 4 has) coalesces directory + * notifications under the shared sessions tree and holds them until further + * write activity flushes the stream (observed: no delivery within 45s + * without activity, ~200ms once the tree is tickled). Both cases therefore + * run a {@link startSessionsTreeKicker} while waiting for the hint; it only + * touches FILES, which the watch service ignores, so it never produces + * hints of its own — every observed hint still corresponds to a real + * workspace/session directory change. + */ +describe('session list sync: session.list_changed hint + ownership join (in-process pair)', () => { + it( + 'a peer-created session under a NEW workspace surfaces via session.list_changed and lists as held_by=peer', + { timeout: 60_000 }, + async () => { + const pair = await startServerPair(); + const stopKicker = startSessionsTreeKicker(join(pair.home, 'sessions')); + const client = new DaemonClient({ baseUrl: pair.urlB }); + const hints: HintRecord[] = []; + try { + // B owns a session so its client has something to subscribe to (global + // volatile events fan out to subscribed connections only). + const ownSessionId = await createSession(pair.urlB, pair.cwd); + await client.connect(); + await client.subscribe(ownSessionId); + const off = client.onFrame((frame) => { + if (frame.type === 'session.list_changed') hints.push({ frame, at: Date.now() }); + }); + try { + // Any hint caused by B's OWN create lands before the baseline; only + // hints recorded after it count as "A's create reached B". + const baseline = hints.length; + const peerWorkspace = join(pair.home, 'peer-workspace'); + mkdirSync(peerWorkspace, { recursive: true }); + const peerSessionId = await createSession(pair.urlA, peerWorkspace); + + const hint = await pollUntil( + async () => (hints.length > baseline ? hints[hints.length - 1] : undefined), + 'session.list_changed reaching B for the new workspace', + 20_000, + 100, + ); + expect(hint.frame.session_id).toBe('__global__'); + expect((hint.frame as { volatile?: boolean }).volatile).toBe(true); + expect(hint.frame.payload).toMatchObject({ + type: 'session.list_changed', + agentId: 'main', + sessionId: '__global__', + }); + + // Data plane: the re-pull (as the client would do on the hint) + // shows A's session; ownership join marks it a routable peer, and + // B's own session stays 'self'. + const listed = await client.listSessions(); + const peerRow = listed.items.find((s) => s.id === peerSessionId); + expect(peerRow?.metadata.cwd).toBe(peerWorkspace); + expect(peerRow?.ownership).toEqual({ held_by: 'peer', address: pair.urlA }); + expect(listed.items.find((s) => s.id === ownSessionId)?.ownership).toEqual({ + held_by: 'self', + }); + } finally { + off(); + } + } finally { + stopKicker(); + await client.close(); + await pair.dispose(); + } + }, + ); + + it( + 'a second session under an ALREADY-WATCHED workspace surfaces via session.list_changed on the peer', + { timeout: 60_000 }, + async () => { + const pair = await startServerPair(); + const stopKicker = startSessionsTreeKicker(join(pair.home, 'sessions')); + const client = new DaemonClient({ baseUrl: pair.urlB }); + const hints: HintRecord[] = []; + try { + // B's own create establishes the workspace dir; B's watcher picks it up + // (its own write triggers the same fs events a peer's write would). + const ownSessionId = await createSession(pair.urlB, pair.cwd); + // Give B's root watcher time to attach the per-workspace watcher — + // otherwise A's write below could slip into the attach window and + // produce no hint (the list would still converge on re-pull; the hint + // is advisory). + await sleep(500); + await client.connect(); + await client.subscribe(ownSessionId); + const off = client.onFrame((frame) => { + if (frame.type === 'session.list_changed') hints.push({ frame, at: Date.now() }); + }); + try { + const baseline = hints.length; + const peerSessionId = await createSession(pair.urlA, pair.cwd); + + await pollUntil( + async () => (hints.length > baseline ? hints[hints.length - 1] : undefined), + 'session.list_changed reaching B for a second session in a known workspace', + 20_000, + 100, + ); + + const listed = await client.listSessions(); + expect(listed.items.find((s) => s.id === peerSessionId)?.ownership).toEqual({ + held_by: 'peer', + address: pair.urlA, + }); + expect(listed.items.find((s) => s.id === ownSessionId)?.ownership).toEqual({ + held_by: 'self', + }); + } finally { + off(); + } + } finally { + stopKicker(); + await client.close(); + await pair.dispose(); + } + }, + ); +}); + +interface HintRecord { + frame: { type: string; session_id?: string; payload?: unknown }; + at: number; +} + +// ── Local helpers ────────────────────────────────────────────────────────── +interface Envelope { + code: number; + msg: string; + data: T; + request_id?: string; + details?: unknown; +} + +interface SessionWire { + id: string; + metadata?: { cwd?: string }; +} + +function warningsPath(sessionId: string): string { + return `/sessions/${encodeURIComponent(sessionId)}/warnings`; +} + +async function getEnvelope( + baseUrl: string, + path: string, +): Promise<{ status: number; body: Envelope }> { + const res = await fetch(`${baseUrl}/api/v1${path}`); + return { status: res.status, body: (await res.json()) as Envelope }; +} + +async function createSession(baseUrl: string, cwd: string): Promise { + const res = await fetch(`${baseUrl}/api/v1/sessions`, { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify({ metadata: { cwd } }), + }); + const body = (await res.json()) as Envelope<{ id: string }>; + if (res.status !== 200 || body.code !== 0) { + throw new Error( + `createSession failed (HTTP ${res.status}, code ${body.code}): ${JSON.stringify(body)}`, + ); + } + return body.data.id; +} + +/** + * macOS FSEvents delivery workaround for the in-process pair (see the + * "session list sync" describe header). Two kap-server instances in one + * process generate enough concurrent fs/fsync load that Node `fs.watch` + * notifications under the shared sessions tree are coalesced and held until + * further write activity flushes the stream — without a kicker, a + * peer-created session dir never reaches the other instance's + * `SessionListWatchService` within the test window. + * + * Every `intervalMs`, touch+unlink a FILE at the sessions root and inside + * each workspace bucket (covering both watcher layers). File-kind events are + * ignored by the watch service, so the kicker never produces hints of its + * own; it only forces delivery of the pending directory events that real + * creates are waiting on. Returns a stop function — call it before + * `pair.dispose()` so no tick races the home teardown. + */ +function startSessionsTreeKicker(sessionsRoot: string, intervalMs = 250): () => void { + const timer = setInterval(() => { + let targets: string[]; + try { + targets = [ + sessionsRoot, + ...readdirSync(sessionsRoot, { withFileTypes: true }) + .filter((entry) => entry.isDirectory()) + .map((entry) => join(sessionsRoot, entry.name)), + ]; + } catch { + return; // sessions root gone (teardown in flight) — nothing to kick + } + for (const dir of targets) { + const sentinel = join(dir, '.fs-kick'); + try { + writeFileSync(sentinel, ''); + unlinkSync(sentinel); + } catch { + // best effort — a bucket can disappear mid-tick + } + } + }, intervalMs); + return () => clearInterval(timer); +} + +type LeasePayload = Record; + +/** Read `/session-leases/.json`; undefined when absent. */ +async function readLease(home: string, sessionId: string): Promise { + try { + return JSON.parse(await readFile(sessionLeasePath(home, sessionId), 'utf8')) as LeasePayload; + } catch (error) { + if ((error as NodeJS.ErrnoException).code === 'ENOENT') return undefined; + throw error; + } +} + +async function listLeaseFilenames(home: string): Promise { + try { + return (await readdir(join(home, 'session-leases'))).sort(); + } catch (error) { + if ((error as NodeJS.ErrnoException).code === 'ENOENT') return []; + throw error; + } +} + +/** + * Byte-level integrity sweep: every non-empty line of every `*.jsonl` under + * `root` must parse as one complete JSON record — a torn / interleaved write + * from a double-materialized session shows up here as a parse failure. + */ +async function assertJsonlIntegrity(root: string): Promise<{ files: number; records: number }> { + const files = await listJsonlFiles(root); + const violations: string[] = []; + let records = 0; + for (const file of files) { + const content = await readFile(file, 'utf8'); + content.split('\n').forEach((line, index) => { + if (line.trim().length === 0) return; + try { + JSON.parse(line); + records += 1; + } catch { + violations.push(`${file}:${index + 1} :: ${line.slice(0, 120)}`); + } + }); + } + expect(violations).toEqual([]); + return { files: files.length, records }; +} + +async function listJsonlFiles(root: string): Promise { + let entries; + try { + entries = await readdir(root, { recursive: true, withFileTypes: true }); + } catch (error) { + if ((error as NodeJS.ErrnoException).code === 'ENOENT') return []; + throw error; + } + return entries + .filter((entry) => entry.isFile() && entry.name.endsWith('.jsonl')) + .map((entry) => join(entry.parentPath, entry.name)) + .sort(); +} + +function pidAlive(pid: number): boolean { + try { + process.kill(pid, 0); + return true; + } catch (error) { + return (error as NodeJS.ErrnoException).code === 'EPERM'; + } +} + +/** True once `pid` is fully gone (ESRCH — zombies reaped), false on timeout. */ +async function waitForPidExit(pid: number, timeoutMs: number): Promise { + const deadline = Date.now() + timeoutMs; + while (Date.now() < deadline) { + if (!pidAlive(pid)) return true; + await sleep(50); + } + return !pidAlive(pid); +} + +/** Probe returning undefined ⇒ keep polling; any other value ends the wait. */ +async function pollUntil( + probe: () => Promise, + description: string, + timeoutMs: number, + intervalMs: number, +): Promise { + const deadline = Date.now() + timeoutMs; + let lastError: unknown; + for (;;) { + try { + const value = await probe(); + if (value !== undefined) return value; + } catch (error) { + lastError = error; + } + if (Date.now() >= deadline) { + throw new Error(`timed out (${timeoutMs}ms) waiting for: ${description}`, { + cause: lastError, + }); + } + await sleep(intervalMs); + } +} + +function sleep(ms: number): Promise { + return new Promise((resolvePromise) => setTimeout(resolvePromise, ms)); +} diff --git a/packages/klient/test/e2e/v2/ownership-redirect.test.ts b/packages/klient/test/e2e/v2/ownership-redirect.test.ts new file mode 100644 index 0000000000..f336af152b --- /dev/null +++ b/packages/klient/test/e2e/v2/ownership-redirect.test.ts @@ -0,0 +1,71 @@ +/** + * Klient multi-instance redirect e2e (phase 3, design §2.1): a klient pointed + * at the WRONG instance follows the `session.held_by_peer` (40921) `routable` + * answer onto the holder origin — and every later call lands on the holder. + * + * In-process `startServerPair` (shared home, `multi_server` flag, port 0). + * The session is created through `/api/v1` on instance A, so A owns the write + * lease registered with its own address; klient drives `/api/v2` starting + * from B. `SessionFacade.restore()` routes to + * `sessionLifecycleService.restore` → `resume`, the v2 open-session entry + * that materializes the session and refuses peer-held ones; scoped reads + * only `get()` and would answer 40401 on the wrong instance (dispatcher + * materialization is a separate workflow). + */ +import { describe, expect, it } from 'vitest'; + +import { createKlient } from '../../../src/transports/http/index.js'; +import type { SessionRedirectInfo } from '../../../src/sessionRedirect.js'; +import { startServerPair } from '../harness/testing/index.js'; +import { createCaseLogger } from '../legacy/log.js'; + +describe('klient: session-ownership redirect onto the holder instance', () => { + it( + 'opening a peer-held session from the wrong instance follows routable 40921 onto the holder', + { timeout: 60_000 }, + async () => { + const log = createCaseLogger('klient-ownership-redirect/follow-routable'); + const pair = await startServerPair(); + try { + const created = await pair + .connectClient(pair.a) + .createSession({ metadata: { cwd: pair.cwd } }); + const sessionId = created.id; + log('session created on A (A holds the lease)', { + sessionId, + urlA: pair.urlA, + urlB: pair.urlB, + }); + + const redirects: SessionRedirectInfo[] = []; + const klient = createKlient({ + url: pair.urlB, + onSessionRedirect: (info) => redirects.push(info), + }); + + // Open from the WRONG instance: B refuses 40921 routable(address=urlA); + // the transport switches origin and re-sends the same call. + const restored = await klient.session(sessionId).restore(); + log('restore via klient after redirect', { restored, redirects }); + expect(restored).toBe(true); + expect(redirects).toEqual([{ from: pair.urlB, to: pair.urlA, follow: 1 }]); + expect(klient.currentUrl).toBe(pair.urlA); + + // Every later request — session facade, global facade — now lands on + // the holder (B's dispatcher would answer 40401 for either call). + const meta = await klient.session(sessionId).get(); + const page = await klient.global.sessions.list({ limit: 20 }); + log('post-redirect reads land on the holder', { + currentUrl: klient.currentUrl, + meta: meta.id, + listed: page.items.map((item) => item.id), + }); + expect(meta.id).toBe(sessionId); + expect(page.items.some((item) => item.id === sessionId)).toBe(true); + await klient.close(); + } finally { + await pair.dispose(); + } + }, + ); +}); diff --git a/packages/klient/test/sessionRedirect.test.ts b/packages/klient/test/sessionRedirect.test.ts new file mode 100644 index 0000000000..f0c62ca8da --- /dev/null +++ b/packages/klient/test/sessionRedirect.test.ts @@ -0,0 +1,465 @@ +/** + * Unit coverage for the multi-instance session-ownership follow/retry loop + * (`KlientConnection` + `SessionRedirectChannel`): routable redirects rebase + * the shared origin and re-send, `creating` retries stay on the same + * instance, terminal phases surface structured handoff errors, and unknown + * payload shapes pass through untouched (forward compatibility). + */ + +import { describe, expect, it, vi } from 'vitest'; + +import { RPCError } from '../src/core/errors.js'; +import type { WsLike, WsLikeCtor } from '../src/transports/ws/wsSocket.js'; +import { + KlientConnection, + readSessionOwnershipDetails, + SessionRedirectChannel, + type SessionRedirectInfo, + type SessionRedirectOptions, +} from '../src/sessionRedirect.js'; + +const PEER = 'http://127.0.0.1:60002'; +const HOLDER = 'http://127.0.0.1:60001'; +const OWNED_MSG = 'session s1 is held by another instance (routable)'; + +const tick = (ms = 0): Promise => new Promise((resolve) => setTimeout(resolve, ms)); + +function jsonResponse(body: unknown): Response { + return new Response(JSON.stringify(body), { + status: 200, + headers: { 'content-type': 'application/json' }, + }); +} + +const okEnvelope = (data: unknown): Response => + jsonResponse({ code: 0, msg: 'ok', data, request_id: 'r' }); +const heldByPeer = (details: unknown, msg = OWNED_MSG): Response => + jsonResponse({ code: 40921, msg, data: null, request_id: 'r', details }); + +function urls(fetchMock: ReturnType>): string[] { + return fetchMock.mock.calls.map((call) => call[0] as string); +} + +interface Rig { + connection: KlientConnection; + channel: SessionRedirectChannel; + redirects: SessionRedirectInfo[]; +} + +function rig( + fetchMock: typeof fetch, + policy: SessionRedirectOptions & { token?: string; WebSocketImpl?: WsLikeCtor } = {}, +): Rig { + const { token, WebSocketImpl, ...redirect } = policy; + const connection = new KlientConnection({ url: PEER, ...redirect }); + const channel = new SessionRedirectChannel({ + connection, + token, + fetch: fetchMock, + WebSocketImpl, + }); + const redirects: SessionRedirectInfo[] = []; + connection.onRedirect((info) => redirects.push(info)); + return { connection, channel, redirects }; +} + +const readS1 = (channel: SessionRedirectChannel): Promise => + channel.call({ sessionId: 's1' }, 'sessionMetadata', 'read', []); + +describe('session ownership redirect (SESSION_HELD_BY_PEER)', () => { + it('follows a routable redirect: rebases onto the holder, re-sends the call, emits the signal', async () => { + const fetchMock = vi + .fn() + .mockResolvedValueOnce( + heldByPeer({ kind: 'held-by-peer', phase: 'routable', address: `${HOLDER}/` }), + ) + .mockResolvedValueOnce(okEnvelope({ id: 's1' })); + const { connection, channel, redirects } = rig(fetchMock); + + const data = await readS1(channel); + + expect(data).toEqual({ id: 's1' }); + expect(urls(fetchMock)).toEqual([ + `${PEER}/api/v2/session/s1/sessionMetadata/read`, + // address normalizes to its bare origin (trailing slash dropped) + `${HOLDER}/api/v2/session/s1/sessionMetadata/read`, + ]); + expect(connection.currentUrl).toBe(HOLDER); + expect(redirects).toEqual([{ from: PEER, to: HOLDER, follow: 1 }]); + }); + + it('lands every later request (core, session, and agent scopes) on the holder', async () => { + const fetchMock = vi + .fn() + .mockResolvedValueOnce( + heldByPeer({ kind: 'held-by-peer', phase: 'routable', address: HOLDER }), + ) + // a fresh Response per call — the body is consumed on every read + .mockImplementation(() => Promise.resolve(okEnvelope(null))); + const { channel } = rig(fetchMock); + + await readS1(channel); + await channel.call({}, 'sessionIndex', 'list', [{}]); + await readS1(channel); + await channel.call({ sessionId: 's1', agentId: 'a1' }, 'sessionMetadata', 'read', []); + + expect(urls(fetchMock)).toEqual([ + `${PEER}/api/v2/session/s1/sessionMetadata/read`, + `${HOLDER}/api/v2/session/s1/sessionMetadata/read`, + `${HOLDER}/api/v2/sessionIndex/list`, + `${HOLDER}/api/v2/session/s1/sessionMetadata/read`, + `${HOLDER}/api/v2/session/s1/agent/a1/sessionMetadata/read`, + ]); + }); + + it('keeps the bearer token and reuses it against the holder origin', async () => { + const fetchMock = vi + .fn() + .mockResolvedValueOnce( + heldByPeer({ kind: 'held-by-peer', phase: 'routable', address: HOLDER }), + ) + .mockResolvedValueOnce(okEnvelope(null)); + const { channel } = rig(fetchMock, { token: 'tok' }); + + await readS1(channel); + + for (const call of fetchMock.mock.calls) { + expect((call[1]?.headers as Record)['authorization']).toBe('Bearer tok'); + } + }); + + it('stops after the redirect limit (anti-loop) with a structured error', async () => { + const details = { kind: 'held-by-peer', phase: 'routable', address: HOLDER }; + const fetchMock = vi + .fn() + .mockImplementation(() => Promise.resolve(heldByPeer(details))); + const { connection, channel } = rig(fetchMock); + + const failure = await readS1(channel).then( + () => ({ error: undefined as unknown }), + (error: unknown) => ({ error }), + ); + + // default maxRedirects = 1: one follow, then the loop guard fires. + expect(fetchMock).toHaveBeenCalledTimes(2); + expect(connection.currentUrl).toBe(HOLDER); + expect(failure.error).toMatchObject({ name: 'RPCError', code: 40921, details }); + expect((failure.error as Error).message).toContain(OWNED_MSG); + expect((failure.error as Error).message).toMatch(/redirect loop/); + }); + + it('honors a raised maxRedirects before the loop guard fires (ping-pong redirect)', async () => { + const fetchMock = vi + .fn() + // holder ping-pongs the client back and forth: the loop guard, not the + // self-loop terminal, must fire after maxRedirects follows + .mockResolvedValueOnce( + heldByPeer({ kind: 'held-by-peer', phase: 'routable', address: HOLDER }), + ) + .mockResolvedValueOnce( + heldByPeer({ kind: 'held-by-peer', phase: 'routable', address: PEER }), + ) + .mockImplementation(() => + Promise.resolve(heldByPeer({ kind: 'held-by-peer', phase: 'routable', address: HOLDER })), + ); + const { channel } = rig(fetchMock, { maxRedirects: 2 }); + + await expect(readS1(channel)).rejects.toMatchObject({ + code: 40921, + message: expect.stringMatching(/redirect loop/) as unknown as string, + }); + expect(fetchMock).toHaveBeenCalledTimes(3); + }); + + it('retries creating on the SAME instance per retry_after_ms, honoring the fallback delay', async () => { + const delays: number[] = []; + const fetchMock = vi + .fn() + .mockResolvedValueOnce( + heldByPeer({ kind: 'held-by-peer', phase: 'creating', retry_after_ms: 120 }), + ) + .mockResolvedValueOnce(heldByPeer({ kind: 'held-by-peer', phase: 'creating' })) + .mockResolvedValueOnce(okEnvelope({ id: 's1' })); + const { connection, channel } = rig(fetchMock, { + sleep: (ms) => { + delays.push(ms); + return Promise.resolve(); + }, + }); + + await expect(readS1(channel)).resolves.toEqual({ id: 's1' }); + expect(delays).toEqual([120, 500]); + expect(urls(fetchMock)).toEqual([ + `${PEER}/api/v2/session/s1/sessionMetadata/read`, + `${PEER}/api/v2/session/s1/sessionMetadata/read`, + `${PEER}/api/v2/session/s1/sessionMetadata/read`, + ]); + expect(connection.currentUrl).toBe(PEER); + }); + + it('gives up after maxCreatingRetries creating answers with a structured error', async () => { + const details = { kind: 'held-by-peer', phase: 'creating', retry_after_ms: 10 }; + const fetchMock = vi + .fn() + .mockImplementation(() => Promise.resolve(heldByPeer(details))); + const { channel } = rig(fetchMock, { sleep: () => Promise.resolve() }); + + await expect(readS1(channel)).rejects.toMatchObject({ + code: 40921, + details, + message: expect.stringMatching(/mid-creation/) as unknown as string, + }); + expect(fetchMock).toHaveBeenCalledTimes(4); // 1 initial + maxCreatingRetries(3) retries + }); + + it.each([ + { + name: 'holder-unresponsive', + details: { kind: 'held-by-peer', phase: 'holder-unresponsive', retry_after_ms: 2000 }, + message: /not responding.*2000ms.*force-unlock/s, + }, + { + name: 'held-by-local-instance', + details: { kind: 'held-by-peer', phase: 'held-by-local-instance' }, + message: /without a network address.*force-unlock/s, + }, + { + name: 'unregistered-writer', + details: { kind: 'unregistered-writer' }, + message: /unregistered writer/, + }, + ])( + 'throws a terminal structured error for $name (no retry, no follow)', + async ({ details, message }) => { + const fetchMock = vi + .fn() + .mockImplementation(() => Promise.resolve(heldByPeer(details))); + const { connection, channel } = rig(fetchMock, { sleep: () => Promise.resolve() }); + + await expect(readS1(channel)).rejects.toMatchObject({ + name: 'RPCError', + code: 40921, + details, + message: expect.stringMatching(message) as unknown as string, + }); + expect(fetchMock).toHaveBeenCalledTimes(1); + expect(connection.currentUrl).toBe(PEER); + }, + ); + + it('surfaces the raw refusal (plus holder guidance) when follow is disabled', async () => { + const details = { kind: 'held-by-peer', phase: 'routable', address: HOLDER }; + const fetchMock = vi + .fn() + .mockImplementation(() => Promise.resolve(heldByPeer(details))); + const { connection, channel, redirects } = rig(fetchMock, { follow: false }); + + await expect(readS1(channel)).rejects.toMatchObject({ + code: 40921, + details, + message: expect.stringMatching(new RegExp(HOLDER.replace(/[.:]/g, '\\$&'))) as unknown as string, + }); + expect(fetchMock).toHaveBeenCalledTimes(1); + expect(redirects).toEqual([]); + expect(connection.currentUrl).toBe(PEER); + }); + + it('treats a routable address pointing back at this instance as terminal (self-loop)', async () => { + const details = { kind: 'held-by-peer', phase: 'routable', address: `${PEER}/` }; + const fetchMock = vi + .fn() + .mockImplementation(() => Promise.resolve(heldByPeer(details))); + const { channel, redirects } = rig(fetchMock); + + await expect(readS1(channel)).rejects.toMatchObject({ + code: 40921, + details, + message: expect.stringMatching(/this very instance/) as unknown as string, + }); + expect(fetchMock).toHaveBeenCalledTimes(1); + expect(redirects).toEqual([]); + }); + + it('rethrows unknown ownership payload shapes untouched (forward compatibility)', async () => { + const fetchMock = vi + .fn() + .mockResolvedValueOnce( + heldByPeer({ kind: 'held-by-peer', phase: 'future-phase', address: HOLDER }), + ); + const { channel } = rig(fetchMock); + + await expect(readS1(channel)).rejects.toMatchObject({ + code: 40921, + message: OWNED_MSG, // no guidance appended + details: { kind: 'held-by-peer', phase: 'future-phase', address: HOLDER }, + }); + expect(fetchMock).toHaveBeenCalledTimes(1); + }); + + it('does not touch non-40921 RPC errors', async () => { + const fetchMock = vi + .fn() + .mockResolvedValueOnce( + jsonResponse({ code: 40401, msg: 'session not found', data: null, request_id: 'r' }), + ); + const { channel } = rig(fetchMock); + + await expect(readS1(channel)).rejects.toMatchObject({ + code: 40401, + message: 'session not found', + }); + expect(fetchMock).toHaveBeenCalledTimes(1); + }); + + it('closes the stale event bridge on redirect; later listens target the holder origin', async () => { + const server = new FakeServer(); + const fetchMock = vi + .fn() + .mockResolvedValueOnce( + heldByPeer({ kind: 'held-by-peer', phase: 'routable', address: HOLDER }), + ) + .mockResolvedValueOnce(okEnvelope({ id: 's1' })); + const { channel } = rig(fetchMock, { WebSocketImpl: fakeCtor(server) }); + + channel.listen({}, { kind: 'stream', name: 'events' }, () => undefined); + await tick(5); + expect(server.lastUrl).toContain('127.0.0.1:60002'); + expect(server.sockets).toHaveLength(1); + + await readS1(channel); + + // The old bridge is dropped — its streams would keep talking to the wrong + // instance; the next listen lazily re-creates against the holder origin. + expect(server.sockets[0]!.readyState).toBe(3); + + const second = channel.listen({}, { kind: 'stream', name: 'events' }, () => undefined); + await tick(5); + expect(server.lastUrl).toContain('127.0.0.1:60001'); + expect(server.sockets).toHaveLength(2); + second.dispose(); + await channel.close(); + }); +}); + +describe('readSessionOwnershipDetails', () => { + it('parses the wire payload shapes and rejects everything else', () => { + expect( + readSessionOwnershipDetails( + new RPCError(40921, 'x', { kind: 'held-by-peer', phase: 'routable', address: 'h' }), + ), + ).toEqual({ + kind: 'held-by-peer', + phase: 'routable', + address: 'h', + retry_after_ms: undefined, + }); + expect( + readSessionOwnershipDetails(new RPCError(40921, 'x', { kind: 'unregistered-writer' })), + ).toEqual({ kind: 'unregistered-writer' }); + // unknown phase → undefined (forward compat: payload passes through untouched) + expect( + readSessionOwnershipDetails( + new RPCError(40921, 'x', { kind: 'held-by-peer', phase: 'future-phase' }), + ), + ).toBeUndefined(); + expect(readSessionOwnershipDetails(new RPCError(40401, 'x'))).toBeUndefined(); + expect(readSessionOwnershipDetails(new Error('boom'))).toBeUndefined(); + // garbage fields are dropped rather than trusted + expect( + readSessionOwnershipDetails( + new RPCError(40921, 'x', { + kind: 'held-by-peer', + phase: 'creating', + retry_after_ms: -5, + }), + ), + ).toEqual({ + kind: 'held-by-peer', + phase: 'creating', + address: undefined, + retry_after_ms: undefined, + }); + }); +}); + +// --- WS fake (event-bridge level, mirrors wsSocket.test.ts helpers) ---------- + +type Listener = (event: never) => void; + +class FakeServer { + readonly frames: Record[] = []; + readonly sockets: FakeClientSocket[] = []; + lastUrl = ''; + + attach(socket: FakeClientSocket): void { + this.sockets.push(socket); + queueMicrotask(() => { + socket.readyState = FakeClientSocket.OPEN; + socket.fire('open'); + this.deliver(socket, { type: 'ready', heartbeatMs: 30_000 }); + }); + } + + receive(raw: string): void { + const frame = JSON.parse(raw) as Record; + this.frames.push(frame); + if (frame['type'] === 'listen') { + const socket = this.sockets.at(-1); + if (socket !== undefined) this.deliver(socket, { type: 'listen_result', id: frame['id'] }); + } + } + + private deliver(socket: FakeClientSocket, frame: Record): void { + socket.deliver(frame); + } +} + +class FakeClientSocket implements WsLike { + static readonly OPEN = 1; + readyState = 0; + private readonly handlers = new Map>(); + + constructor( + private readonly server: FakeServer, + url: string, + ) { + server.lastUrl = url; + server.attach(this); + } + + addEventListener(type: string, listener: Listener): void { + const set = this.handlers.get(type) ?? new Set(); + set.add(listener); + this.handlers.set(type, set); + } + + send(data: string): void { + this.server.receive(data); + } + + close(): void { + this.readyState = 3; + this.fire('close'); + } + + fire(type: string): void { + for (const handler of this.handlers.get(type) ?? []) handler(undefined as never); + } + + deliver(frame: Record): void { + queueMicrotask(() => { + for (const handler of this.handlers.get('message') ?? []) { + handler({ data: JSON.stringify(frame) } as never); + } + }); + } +} + +function fakeCtor(server: FakeServer): WsLikeCtor { + class BoundFakeSocket extends FakeClientSocket { + constructor(url: string) { + super(server, url); + } + } + return BoundFakeSocket as unknown as WsLikeCtor; +} diff --git a/packages/klient/test/wsSocket.test.ts b/packages/klient/test/wsSocket.test.ts index 382074905c..7584f8c960 100644 --- a/packages/klient/test/wsSocket.test.ts +++ b/packages/klient/test/wsSocket.test.ts @@ -72,8 +72,8 @@ class FakeServer { this.send({ type: 'event', id, eventId, data }); } - pushError(id: string, msg: string): void { - this.send({ type: 'error', id, code: 40001, msg }); + pushError(id: string, msg: string, details?: unknown): void { + this.send({ type: 'error', id, code: 40001, msg, details }); } cancelEvent(id: string, eventId: string): void { @@ -241,13 +241,21 @@ describe('WsSocket', () => { const server = new FakeServer(); const socket = await openSocket(server); const errors: string[] = []; - socket.onDidListenError((event) => errors.push(event.error.message)); + const detailsList: unknown[] = []; + socket.onDidListenError((event) => { + errors.push(event.error.message); + detailsList.push((event.error as { details?: unknown }).details); + }); socket.listen('session', 'onDidChangeMetadata', { sessionId: 's1' }, () => undefined, 'sessionMetadata'); await tick(5); const id = [...server.listens][0]!; - server.pushError(id, 'payload is not serializable'); + // Server-side validation failures must carry the structured details + // payload through — 40921 session-ownership refusals branch on it. + const details = { kind: 'held-by-peer', phase: 'holder-unresponsive', retry_after_ms: 2000 }; + server.pushError(id, 'payload is not serializable', details); await tick(5); expect(errors).toEqual(['payload is not serializable']); + expect(detailsList).toEqual([details]); socket.close(); }); @@ -330,3 +338,24 @@ describe('WsSocket', () => { await expect(socket.call('core', 'sessionIndex', 'list', [{}])).rejects.toThrow('ws closed'); }); }); + +describe('session.list_changed (multi-instance discovery)', () => { + it('passes the session.list_changed core event stream through to listeners verbatim', async () => { + const server = new FakeServer(); + const socket = await openSocket(server); + const seen: unknown[] = []; + const sub = socket.listen('core', 'session.list_changed', {}, (data) => seen.push(data)); + await tick(5); + + const listenFrame = server.frames.find((frame) => frame['type'] === 'listen')!; + expect(listenFrame).toMatchObject({ scope: 'core', event: 'session.list_changed' }); + const listenId = listenFrame['id'] as string; + server.pushEvent(listenId, { type: 'session.list_changed' }); + await tick(5); + expect(seen).toEqual([{ type: 'session.list_changed' }]); + + sub.dispose(); + expect(server.listens.size).toBe(0); + socket.close(); + }); +}); diff --git a/packages/klient/tsconfig.dev.json b/packages/klient/tsconfig.dev.json new file mode 100644 index 0000000000..3127c1672e --- /dev/null +++ b/packages/klient/tsconfig.dev.json @@ -0,0 +1,16 @@ +{ + "extends": "./tsconfig.json", + // tsx (esbuild transform) applies a custom `--tsconfig` per file via its + // `include` globs. The `include` must therefore cover every workspace + // package the spawned server imports (mirrors + // `apps/kimi-code/tsconfig.dev.json`): otherwise files outside the globs + // get no tsconfig at all and DI parameter decorators in the agent-core + // graph fail with "Parameter decorators only work when experimental + // decorators are enabled". + "include": [ + "src", + "test", + "../../packages/*/src/**/*.ts", + "../../packages/*/src/**/*.tsx" + ] +} diff --git a/packages/minidb/README.md b/packages/minidb/README.md index 977003848a..301e123025 100644 --- a/packages/minidb/README.md +++ b/packages/minidb/README.md @@ -420,8 +420,12 @@ source-code study behind each choice. minidb is **single-writer**. Opening a directory for writing acquires an exclusive lock file (`db.lock`); a second writer is rejected with a `LockError`. A lock is -taken over only when its owner PID is dead (stale-lock recovery), never merely -because it is old. +taken over only when its owner PID is dead — or when the PID has been recycled by +an unrelated process (detected via the recorded process start time) — never +merely because it is old. Takeover renames the stale file aside +(`db.lock.stale.`) instead of deleting it, and every acquire/release +re-reads the file and compares its unique token, so a superseded owner that +wakes up late can never delete the new holder's lock. ```js // second process: throws LockError diff --git a/packages/minidb/src/lockfile.ts b/packages/minidb/src/lockfile.ts index 43727f1a41..f3521e4308 100644 --- a/packages/minidb/src/lockfile.ts +++ b/packages/minidb/src/lockfile.ts @@ -1,13 +1,42 @@ // src/lockfile.ts // -// A small exclusive file lock using O_EXCL creation. Used to prevent two -// processes from opening the same database directory for writing (which would -// corrupt it). A lock is considered stale and is taken over only when the -// recorded owner PID is no longer alive — never merely because it is old. +// A small exclusive file lock using atomic tmp+link creation, aligned with the +// repo's unified cross-process lock protocol (pid-only directory-lock mode — +// see agent-core-v2's `interface/crossProcessLock.ts`; the protocol is an +// on-disk contract, so this package inlines its own zero-dependency +// implementation). +// +// Invariants: +// - Token-guarded: every acquire writes a unique `lock_id`; the token is +// compared after every winning create (read-back), on every settle +// re-check, and before release/releaseSync unlink — a late operation never +// deletes or mistakes a newer holder's lock. +// - A lock whose owner PID is alive is never taken over. Stale = dead PID, a +// reused PID (processStartedAt identity mismatch), a payload without a +// usable PID, or an empty/unparseable file older than the creation window +// (a fresh one is "creating" → held; foreign publishers are not required to +// write atomically — our own publishes are atomic tmp+link). +// - Takeover quarantines the corpse via rename to `db.lock.stale.` +// (never delete+create, so a frozen creator resuming mid-window cannot be +// clobbered and the takeover stays auditable), then re-creates via the +// atomic link. A full re-inspect precedes every rename attempt so a live +// winner is never quarantined. +// - Exactly one holder is a construction, not a timing bet: every contender +// registers a liveness watch covering its whole attempt, and every creator +// — direct or takeover — settles (adaptive backoff) until no live foreign +// watch remains before claiming, so an in-flight co-racer always resolves +// first. +// +// The correctness backstop is dead-PID takeover plus the token guard against +// deleting someone else's lock. The beforeExit hook is only a safety net (it +// never fires on SIGKILL) — the README documents takeover on dead PID as the +// recovery path. -import fs from 'node:fs/promises'; import fsSync from 'node:fs'; +import fs from 'node:fs/promises'; import path from 'node:path'; +import { execFileSync } from 'node:child_process'; +import { randomUUID } from 'node:crypto'; import { renameReplace } from './rename-replace.js'; export class LockError extends Error { @@ -18,37 +47,42 @@ export class LockError extends Error { } } -function pidAlive(pid: unknown): boolean { - if (!pid || typeof pid !== 'number') return false; - try { - process.kill(pid, 0); - return true; - } catch (e) { - return (e as NodeJS.ErrnoException).code === 'EPERM'; - } +/** Milliseconds an empty/unparseable lock file counts as "creating" (held) + rather than stale. Covers foreign publishers whose create and payload + write are not atomic; our own publishes are atomic tmp+link. */ +export const LOCK_CREATION_WINDOW_MS = 5000; + +/** Test seam: every clock, pid, probe and token source is replaceable. */ +export interface LockFileDeps { + now?: () => number; + selfPid?: number; + probeProcess?: (pid: number) => { alive: boolean; processStartedAt?: string }; + /** Unique token of this acquire; compared on every guarded mutation. */ + newLockId?: () => string; } // Track held locks so we can release them on process exit as a safety net. const HELD = new Set(); // Distinct sidecar names per acquire attempt: two lock users in the same -// process (e.g. independent shard pools) must never share a tmp/bid/watch +// process (e.g. independent shard pools) must never share a tmp/watch // path, or one user's cleanup would delete the other's in-flight file. let sidecarSeq = 0; const nextSidecarSeq = (): number => ++sidecarSeq; let exitHooked = false; -// Co-bidders all replace the stale corpse within the same wave (they woke on -// the same event); the settle pause before the winner claims the lock must -// outlast that wave so the last bidder to land is unambiguous. A fixed value -// (even a generous one) loses on shared CI runners that deschedule a bidder -// for hundreds of milliseconds inside its own atomic-op sequence, so the -// settle is ADAPTIVE: it scales with how long our own takeover attempt took -// (4x the wall clock of inspect+writeBid+rename — a stalled machine stalls -// every bidder), floored at 60ms and capped at 2s so a healthy takeover stays -// fast. Residual (bounded-delay, inherent to file-based takeover): a bidder -// whose writeBid is delayed past the winner's final verify can still -// double-win — the bid-file sweep at verification shrinks this window to -// "competitor had not even started writing their bid yet", which requires a -// full-attempt+settle-sized skew and is effectively a process-level pause. +// Co-racers all move on the stale corpse within the same wave (they woke on +// the same event); the settle before any creator claims must outlast that +// wave so the last racer to land is unambiguous. A fixed value (even a +// generous one) loses on shared CI runners that deschedule a racer for +// hundreds of milliseconds inside its own atomic-op sequence, so the backoff +// is ADAPTIVE: it scales with how long our own takeover attempt took (a +// stalled machine stalls every racer), floored at 60ms and capped at 2s so a +// healthy takeover stays fast. Residual (bounded-delay, inherent to +// file-based takeover): a racer descheduled between its gate inspect and its +// quarantine rename can still rename-aside a fresh creator's lock — which is +// exactly why EVERY creator (direct or takeover) runs the watch settle +// before claiming: the thief is still inside its attempt, its watch keeps +// the victim settling, the victim sees its token gone from the file, and +// resolves without claiming. const TAKEOVER_SETTLE_BASE_MS = 60; const TAKEOVER_SETTLE_MAX_MS = 2_000; function hookExit(): void { @@ -59,12 +93,85 @@ function hookExit(): void { }); } +/** Opaque platform identity token for pid-reuse detection; compared by + equality only, never parsed. Undefined when the platform cannot provide it + or probing fails (conservative: degradation only *disables* takeover). */ +function processStartedAtOf(pid: number): string | undefined { + try { + if (process.platform === 'darwin') { + // e.g. `{ sec = 1759812345, usec = 123456 }` + const out = execFileSync('sysctl', ['-n', 'kern.proc.starttime', String(pid)], { + encoding: 'utf8', + timeout: 3000, + stdio: ['ignore', 'pipe', 'ignore'], + }); + const m = /sec = (\d+), usec = (\d+)/.exec(out); + return m ? `${m[1]}.${m[2]}` : undefined; + } + if (process.platform === 'linux') { + // comm (field 2) may contain spaces/parens; field 22 (starttime) is the + // 20th token (index 19) after the LAST ')'. + const raw = fsSync.readFileSync(`/proc/${pid}/stat`, 'utf8'); + const close = raw.lastIndexOf(')'); + if (close === -1) return undefined; + const rest = raw.slice(close + 1).trim().split(/\s+/); + return rest.length > 19 ? rest[19] : undefined; + } + } catch { + /* probing failure: no identity available */ + } + return undefined; +} + +function pidAlive(pid: number): boolean { + try { + process.kill(pid, 0); + return true; + } catch (e) { + // ESRCH: no such process (dead). EPERM: exists but another user (alive). + // Any other failure is inconclusive — treat conservatively as alive. + return (e as NodeJS.ErrnoException).code !== 'ESRCH'; + } +} + +function defaultProbeProcess(pid: number): { alive: boolean; processStartedAt?: string } { + const alive = pidAlive(pid); + return { alive, processStartedAt: alive ? processStartedAtOf(pid) : undefined }; +} + +interface LockPayload { + pid?: unknown; + ts?: unknown; + lock_id?: unknown; + process_started_at?: unknown; +} + +function tryParse(raw: string): LockPayload | undefined { + try { + const parsed = JSON.parse(raw) as unknown; + return typeof parsed === 'object' && parsed !== null ? (parsed as LockPayload) : undefined; + } catch { + return undefined; + } +} + export class LockFile { readonly path: string; held = false; - constructor(path: string) { + private lockId?: string; + private startedAt?: string; + private readonly now: () => number; + private readonly selfPid: number; + private readonly probeProcess: NonNullable; + private readonly newLockId: () => string; + + constructor(path: string, deps: LockFileDeps = {}) { this.path = path; + this.now = deps.now ?? Date.now; + this.selfPid = deps.selfPid ?? process.pid; + this.probeProcess = deps.probeProcess ?? defaultProbeProcess; + this.newLockId = deps.newLockId ?? randomUUID; } /** Try to acquire the lock exactly once. Returns true when this call created @@ -74,132 +181,103 @@ export class LockFile { * re-races: callers that want to wait retry acquire() at a higher level * (see the cluster lock pool). */ async acquire(): Promise { + const lockId = this.newLockId(); + this.startedAt ??= processStartedAtOf(this.selfPid); // Register a "watch" BEFORE touching the lock: every contender is visible // to every other for its whole attempt, regardless of where the scheduler - // stalls it. (Settle-window heuristics alone could not survive a bidder - // descheduled before its bid write on a shard-parallel CI runner — see the - // takeover loop below; a stalled contender is only in the way, not + // stalls it. (Settle-window heuristics alone could not survive a racer + // descheduled before its quarantine rename on a shard-parallel CI runner — + // see the takeover loop below; a stalled contender is only in the way, not // invisible.) const watch = `${this.path}.watch-${process.pid}-${nextSidecarSeq()}`; await fs.writeFile(watch, JSON.stringify({ pid: process.pid, ts: Date.now() })); try { await this.reapDeadWatches(); - if (await this.tryCreate()) return true; + if (await this.tryCreate(lockId)) { + // A direct creator must settle exactly like a takeover winner (see + // settleForForeignWatches): a co-racer that gate-inspected a corpse + // before it vanished can still be descheduled between that inspect + // and its quarantine rename, and its late rename would steal this + // fresh lock AFTER our confirm — the historical "two processes + // believe they hold the lock" failure the settle exists to prevent. + if (!(await this.settleForForeignWatches(lockId, watch, TAKEOVER_SETTLE_BASE_MS))) { + return false; + } + return await this.confirmCreated(lockId); + } - // The lock exists. Only a DEAD owner's lock may be taken over; everything - // else (a live owner, or a takeover bid made by another racer in the + // The lock exists. Only a STALE owner's lock may be taken over; + // everything else (a live owner, a takeover won by another racer in the // meantime) is respected. const seen = await this.inspect(); if (seen === null || seen.alive) return false; - // Takeover via atomic bid-replace, NOT unlink-then-create. Unlinking a + // Takeover via quarantine-rename, NOT unlink-then-create. Unlinking a // stale lock and then racing to re-create it left a window in which a // loser could delete the winner's just-linked file, after which several - // processes all believed they held the lock. Rename atomically replaces - // the corpse with our bid. + // processes all believed they held the lock. Renaming the corpse aside + // is atomic and auditable, and the subsequent tmp+link create admits + // exactly one winner. // - // Windows cannot rename over a destination while ANY process holds it - // open (co-racers reading/stat'ing the corpse make the rename EPERM), so - // the rename is retried with jitter. Crucially, each retry re-inspects - // the corpse first: a blind retry loop could land our bid seconds late, - // OVERWRITING an already-verified winner's lock line and double-holding - // (exactly the failure this loop is careful not to reintroduce). - const bid = `${this.path}.bid-${process.pid}-${nextSidecarSeq()}`; + // Windows cannot rename a file while ANY process holds it open + // (co-racers reading/stat'ing the corpse make the rename EPERM), so the + // rename is retried with jitter. Crucially, each retry re-inspects the + // file first: a blind retry loop could quarantine a live winner's lock + // seconds late (exactly the failure this loop is careful not to + // reintroduce). const attemptStart = Date.now(); - try { - await fs.writeFile(bid, JSON.stringify({ pid: process.pid, ts: Date.now() })); - for (let attempt = 0; ; attempt++) { - // The corpse must still be there and dead. A competitor who landed - // wins by being alive in the file now — back off instead of - // overwriting their lock. (Unconditional, not just win32: the same - // overwrite hazard exists on POSIX when a co-bidder is descheduled - // between its first inspect and its rename.) - const gate = await this.inspect(); - if (gate === null || gate.alive || gate.mine) { - await fs.unlink(bid).catch(() => {}); - return false; - } - try { - await fs.rename(bid, this.path); - break; - } catch (e) { - const code = (e as NodeJS.ErrnoException).code; - const epermRetryable = - code === 'EPERM' && process.platform === 'win32' && attempt < 50; - if (!epermRetryable) { - await fs.unlink(bid).catch(() => {}); - // EEXIST races another creator; a persistent EPERM (Windows - // retries exhausted) means some holder kept the path pinned — - // either way the corpse could not be displaced this round, so - // decline like a live lock and let callers retry higher up. - if (code === 'EEXIST' || code === 'EPERM') return false; - throw e; - } - await new Promise((r) => setTimeout(r, 20 + Math.floor(Math.random() * 30))); + const stalePath = `${this.path}.stale.${seen.lockId ?? 'unknown'}`; + for (let attempt = 0; ; attempt++) { + // The corpse must still be there and stale. A competitor who landed + // wins by being alive in the file now — back off instead of + // quarantining their lock. (Unconditional, not just win32: the same + // hazard exists on POSIX when a co-racer is descheduled between its + // first inspect and its rename.) + const gate = await this.inspect(); + if (gate === null || gate.alive || gate.mine) return false; + try { + await fs.rename(this.path, stalePath); + break; + } catch (e) { + const code = (e as NodeJS.ErrnoException).code; + if (code === 'ENOENT') break; // a co-racer quarantined it first + const epermRetryable = + code === 'EPERM' && process.platform === 'win32' && attempt < 50; + if (!epermRetryable) { + // A persistent EPERM (Windows retries exhausted) means some holder + // kept the path pinned — the corpse could not be displaced this + // round, so decline like a live lock and let callers retry higher + // up. + if (code === 'EPERM' || code === 'EEXIST') return false; + throw e; } + await new Promise((r) => setTimeout(r, 20 + Math.floor(Math.random() * 30))); } - } catch (e) { - await fs.unlink(bid).catch(() => {}); - throw e; } + if (!(await this.tryCreate(lockId))) return false; + // Adaptive settle: scale with how long our own attempt took (a stalled - // machine stalls every bidder), floored and capped (see the constants). + // machine stalls every racer), floored and capped (see the constants). const elapsedMs = Date.now() - attemptStart; - let settleMs = Math.min(TAKEOVER_SETTLE_MAX_MS, Math.max(TAKEOVER_SETTLE_BASE_MS, elapsedMs * 4)); - for (;;) { - await new Promise((resolve) => setTimeout(resolve, settleMs)); - const cur = await this.inspect(); - if (cur === null || !cur.mine) return false; - // Any live foreign watch means a contender is still in flight (its - // registration precedes its whole attempt): wait for its loop to - // finish instead of claiming on stale evidence. This is the check - // that makes exactly-one a construction, not a timing bet. - if (!(await this.hasLiveForeignWatch())) break; - settleMs = Math.min(TAKEOVER_SETTLE_MAX_MS, settleMs * 2); - } - this.markHeld(); - return true; + const initialSettleMs = Math.min( + TAKEOVER_SETTLE_MAX_MS, + Math.max(TAKEOVER_SETTLE_BASE_MS, elapsedMs * 4), + ); + if (!(await this.settleForForeignWatches(lockId, watch, initialSettleMs))) return false; + return await this.confirmCreated(lockId); } finally { await fs.unlink(watch).catch(() => {}); } } - /** Delete watch registrations whose owner pid is no longer alive. */ - private async reapDeadWatches(): Promise { - const dir = path.dirname(this.path); - const prefix = `${path.basename(this.path)}.watch-`; - for (const f of await fs.readdir(dir).catch(() => [] as string[])) { - if (!f.startsWith(prefix)) continue; - const pid = Number(f.slice(prefix.length).split('-')[0]); - if (Number.isInteger(pid) && pid !== process.pid && !pidAlive(pid)) { - await fs.unlink(path.join(dir, f)).catch(() => {}); - } - } - } - - /** True when any OTHER process's liveness watch exists (reaping dead ones on sight). */ - private async hasLiveForeignWatch(): Promise { - const dir = path.dirname(this.path); - const prefix = `${path.basename(this.path)}.watch-`; - for (const f of await fs.readdir(dir).catch(() => [] as string[])) { - if (!f.startsWith(prefix)) continue; - const pid = Number(f.slice(prefix.length).split('-')[0]); - if (!Number.isInteger(pid) || pid === process.pid) continue; - if (pidAlive(pid)) return true; - await fs.unlink(path.join(dir, f)).catch(() => {}); - } - return false; - } - /** Atomic create-if-absent publish: tmp write + hard link (EEXIST-safe). */ - private async tryCreate(): Promise { + private async tryCreate(lockId: string): Promise { const tmp = `${this.path}.tmp-${process.pid}-${nextSidecarSeq()}`; try { - await fs.writeFile(tmp, JSON.stringify({ pid: process.pid, ts: Date.now() })); + await fs.writeFile(tmp, this.payload(lockId)); await fs.link(tmp, this.path); - this.markHeld(); return true; } catch (e) { if ((e as NodeJS.ErrnoException).code !== 'EEXIST') throw e; @@ -209,85 +287,173 @@ export class LockFile { } } + /** Read-back confirm: the file we just linked must still carry our token — + * a co-racer resuming inside the takeover window may have replaced it. */ + private async confirmCreated(lockId: string): Promise { + const back = await this.readDiskText(); + if (back === undefined || tryParse(back)?.lock_id !== lockId) return false; + this.lockId = lockId; + this.held = true; + HELD.add(this); + hookExit(); + return true; + } + + private payload(lockId: string): string { + return JSON.stringify({ + pid: this.selfPid, + ts: this.now(), + lock_id: lockId, + // Legacy readers see only `pid`/`ts`; `lock_id`/`process_started_at` + // follow the unified protocol's snake_case disk keys. Absent when the + // platform cannot provide a start-time identity (e.g. macOS). + process_started_at: this.startedAt, + }); + } + /** Read the lock file and decide its state. null = the file vanished. */ - private async inspect(): Promise<{ ino: number | bigint; alive: boolean; mine: boolean } | null> { - let raw: string; - let st: { ino: number | bigint }; - try { - [raw, st] = await Promise.all([fs.readFile(this.path, 'utf8'), fs.stat(this.path)]); - } catch (e) { - if ((e as NodeJS.ErrnoException).code === 'ENOENT') return null; - throw e; + private async inspect(): Promise<{ alive: boolean; mine: boolean; lockId?: string } | null> { + const raw = await this.readDiskText(); + if (raw === undefined) return null; + const st = await fs.stat(this.path).catch(() => undefined); + if (st === undefined) return null; + const parsed = raw.trim() === '' ? undefined : tryParse(raw); + if (parsed === undefined) { + // Empty or unparseable: a foreign publisher may be mid-write. Only past + // the creation window is it definitively stale. + return { alive: this.now() - st.mtimeMs < LOCK_CREATION_WINDOW_MS, mine: false }; } - let pid: number | undefined; - try { - pid = (JSON.parse(raw) as { pid?: number }).pid; - } catch { - pid = undefined; // unparsable content looks abandoned, same as a dead PID + const pid = parsed.pid; + if (typeof pid !== 'number' || !Number.isInteger(pid) || pid <= 0) { + // A parsed payload without a usable PID cannot be owned by anyone. + return { alive: false, mine: false, lockId: stringOrUndefined(parsed.lock_id) }; + } + const probe = this.probeProcess(pid); + let alive = probe.alive; + const diskStartedAt = stringOrUndefined(parsed.process_started_at); + if ( + alive && + probe.processStartedAt !== undefined && + diskStartedAt !== undefined && + probe.processStartedAt !== diskStartedAt + ) { + // PID alive but identity differs: the PID was reused by a new process, + // the original holder is dead. Treated as dead. + alive = false; } - return { ino: st.ino, alive: pidAlive(pid), mine: pid === process.pid }; + // Live, identity matching or unavailable: never take over a live PID. + return { alive, mine: pid === process.pid, lockId: stringOrUndefined(parsed.lock_id) }; } - private inspectSync(): { ino: number | bigint; alive: boolean; mine: boolean } | null { - let raw: string; - let st: { ino: number | bigint }; + /** File contents, `undefined` only when the file is gone. */ + private async readDiskText(): Promise { try { - raw = fsSync.readFileSync(this.path, 'utf8'); - st = fsSync.statSync(this.path); + return await fs.readFile(this.path, 'utf8'); } catch (e) { - if ((e as NodeJS.ErrnoException).code === 'ENOENT') return null; + if ((e as NodeJS.ErrnoException).code === 'ENOENT') return undefined; throw e; } - let pid: number | undefined; - try { - pid = (JSON.parse(raw) as { pid?: number }).pid; - } catch { - pid = undefined; + } + + /** Delete watch registrations whose owner pid is no longer alive. */ + private async reapDeadWatches(): Promise { + const dir = path.dirname(this.path); + const prefix = `${path.basename(this.path)}.watch-`; + for (const f of await fs.readdir(dir).catch(() => [] as string[])) { + if (!f.startsWith(prefix)) continue; + const pid = Number(f.slice(prefix.length).split('-')[0]); + if (Number.isInteger(pid) && pid !== process.pid && !pidAlive(pid)) { + await fs.unlink(path.join(dir, f)).catch(() => {}); + } + } + } + + /** Exactly-one is a construction, not a timing bet: after winning the + * create, wait until no live foreign watch remains. A watch covers its + * owner's WHOLE attempt (registration precedes the first lock touch), so + * an in-flight co-racer — e.g. one descheduled between its gate inspect + * and its quarantine rename — always resolves before we claim; if its + * late rename quarantined our fresh lock, our token is gone from the file + * and we resolve WITHOUT claiming. Adaptive backoff: a stalled machine + * stalls every racer, so the pause doubles while contention persists. */ + private async settleForForeignWatches( + lockId: string, + ownWatch: string, + initialSettleMs: number, + ): Promise { + let settleMs = initialSettleMs; + for (;;) { + const cur = await this.inspect(); + if (cur === null || cur.lockId !== lockId) return false; + if (!(await this.hasLiveForeignWatch(ownWatch))) return true; + await new Promise((resolve) => setTimeout(resolve, settleMs)); + settleMs = Math.min(TAKEOVER_SETTLE_MAX_MS, settleMs * 2); } - return { ino: st.ino, alive: pidAlive(pid), mine: pid === process.pid }; + } + + /** True when any OTHER attempt's liveness watch exists (reaping dead ones + * on sight). Compared by watch NAME, not pid: two LockFile users in one + * process (e.g. independent shard pools) can contest the same lock and + * must see each other's watches despite sharing a pid. */ + private async hasLiveForeignWatch(ownWatch: string): Promise { + const dir = path.dirname(this.path); + const prefix = `${path.basename(this.path)}.watch-`; + const own = path.basename(ownWatch); + for (const f of await fs.readdir(dir).catch(() => [] as string[])) { + if (!f.startsWith(prefix) || f === own) continue; + const pid = Number(f.slice(prefix.length).split('-')[0]); + if (!Number.isInteger(pid)) continue; + if (pidAlive(pid)) return true; + await fs.unlink(path.join(dir, f)).catch(() => {}); + } + return false; } /** Refresh the lock timestamp (proves liveness to processes inspecting the * lock file). No-op when the lock is not held. Uses write-tmp-then-rename * so a crash mid-renew cannot leave a truncated, "stale-looking" lock file - * behind for a lock that is actually still owned. */ + * behind for a lock that is actually still owned. The payload keeps our + * token and start-time identity, so guards keep working after a renew. */ async renew(): Promise { - if (!this.held) return; + if (!this.held || this.lockId === undefined) return; const tmp = `${this.path}.tmp-${process.pid}-${nextSidecarSeq()}`; - await fs.writeFile(tmp, JSON.stringify({ pid: process.pid, ts: Date.now() })); + await fs.writeFile(tmp, this.payload(this.lockId)); // Windows: replacing our own lock can still clash with a co-process's // readFile/stat of it (EPERM) — the helper rides out such transients. await renameReplace(tmp, this.path, { retries: 20 }); } - private markHeld(): void { - this.held = true; - HELD.add(this); - hookExit(); - } - + /** Token-guarded release. Idempotent; a missing or foreign-owned file is + never unlinked. */ async release(): Promise { if (!this.held) return; - // Unlink ONLY the file this instance actually owns. The content at this - // path may have been replaced since we acquired it (a supervisor re-plant a - // dead-man's marker, a concurrent takeover…), and deleting such a file - // would drop a lock that no longer belongs to us. - const cur = await this.inspect(); - if (cur?.mine) await fs.unlink(this.path).catch(() => {}); this.held = false; - HELD.delete(this); + const lockId = this.lockId; + this.lockId = undefined; + try { + const raw = await this.readDiskText(); + if (raw !== undefined && tryParse(raw)?.lock_id === lockId) { + await fs.unlink(this.path).catch(() => {}); + } + } catch { + // Missing or unreadable: nothing of ours to remove (best-effort). + } } - /** Best-effort sync release for the exit hook. */ releaseSync(): void { if (!this.held) return; + this.held = false; + const lockId = this.lockId; + this.lockId = undefined; try { - const cur = this.inspectSync(); - if (cur?.mine) fsSync.unlinkSync(this.path); + const raw = fsSync.readFileSync(this.path, 'utf8'); + if (tryParse(raw)?.lock_id === lockId) fsSync.unlinkSync(this.path); } catch { - /* ignore */ + /* same policy as release(): never unlink on uncertainty */ } - this.held = false; - HELD.delete(this); } } + +function stringOrUndefined(v: unknown): string | undefined { + return typeof v === 'string' ? v : undefined; +} diff --git a/packages/minidb/test/defense.test.ts b/packages/minidb/test/defense.test.ts index 69712fdccd..95a1c76271 100644 --- a/packages/minidb/test/defense.test.ts +++ b/packages/minidb/test/defense.test.ts @@ -1,8 +1,8 @@ // Exercises defensive input/state-validation branches that are reachable // through the public/direct API but were not covered by the functional tests. // Fault-injection-only branches (writev short-write, fsync failure, >64MB -// RESP payload, cross-user EPERM, process-exit hook) are intentionally not -// covered here — see the coverage summary in the commit message. +// RESP payload, cross-user EPERM, process-exit hook) are intentionally not covered here — see +// the coverage summary in the commit message. import { expect, test } from 'vitest'; import assert from 'node:assert/strict'; import fs from 'node:fs/promises'; @@ -12,7 +12,7 @@ import net from 'node:net'; import { WAL } from '../src/wal.js'; import { encodeFrame, decodeBatchOps, TYPE_SET } from '../src/codec.js'; import { MiniDb } from '../src/index.js'; -import { LockFile } from '../src/lockfile.js'; +import { LockFile, LOCK_CREATION_WINDOW_MS } from '../src/lockfile.js'; import { startServer } from '../src/server.js'; async function tmpDir() { @@ -120,18 +120,35 @@ test('LockFile.acquire returns false when a live process holds the lock', async const p = path.join(dir, 'db.lock'); const a = new LockFile(p); assert.equal(await a.acquire(), true); - const b = new LockFile(p); - assert.equal(await b.acquire(), false); // same PID is alive -> not stale + // A live owner whose identity cannot be cross-checked is never taken over. + const b = new LockFile(p, { probeProcess: () => ({ alive: true, processStartedAt: undefined }) }); + assert.equal(await b.acquire(), false); await a.release(); await fs.rm(dir, { recursive: true, force: true }); }); -test('a corrupt lock file is treated as stale and taken over', async () => { +test('an unparseable lock file inside the creation window counts as held', async () => { + const dir = await tmpDir(); + const p = path.join(dir, 'db.lock'); + await fs.writeFile(p, 'not-json'); + // Fresh garbage may be a live creator mid-write: treated as held, so this + // second writer is refused rather than taking over. + const b = new LockFile(p); + assert.equal(await b.acquire(), false); + await fs.rm(dir, { recursive: true, force: true }); +}); + +test('an unparseable lock file past the creation window is taken over', async () => { const dir = await tmpDir(); - await fs.writeFile(path.join(dir, 'db.lock'), 'not-json'); + const p = path.join(dir, 'db.lock'); + await fs.writeFile(p, 'not-json'); + const past = new Date(Date.now() - LOCK_CREATION_WINDOW_MS - 1000); + await fs.utimes(p, past, past); const db = await MiniDb.open({ dir, valueCodec: 'string' }); await db.set('a', '1'); assert.equal(db.get('a'), '1'); + // The takeover quarantined the garbage file instead of deleting it. + assert.ok((await fs.readdir(dir)).includes('db.lock.stale.unknown')); await db.close(); await fs.rm(dir, { recursive: true, force: true }); }); diff --git a/packages/minidb/test/e2e/helpers/lock-racer.ts b/packages/minidb/test/e2e/helpers/lock-racer.ts index a2e77989f5..9fa38914ce 100644 --- a/packages/minidb/test/e2e/helpers/lock-racer.ts +++ b/packages/minidb/test/e2e/helpers/lock-racer.ts @@ -35,12 +35,24 @@ for (let r = 0; r < rounds; r++) { const got = await lf.acquire(); process.stdout.write(`R${r} ${process.pid} ${got ? 1 : 0}\n`); if (got) { - // Hold the lock briefly so every loser resolves its acquire() while the - // winner still holds (an immediate release makes sequential re-acquisters - // indistinguishable from simultaneous holders). Sized generously for - // heavily loaded machines, where a racer can be descheduled for tens of - // milliseconds inside its own call. - await sleep(50); + // Hold until the parent has collected every racer's line for this round + // (it plants release- once all RACERS lines are in). No fixed hold + // duration survives per-round wake-up stagger: on a loaded runner a racer + // can be descheduled past any hold+release window and then acquire + // SEQUENTIALLY, truthfully reporting 1 — which the parent would misread + // as two simultaneous holders. The gate makes every late racer see a + // live, held lock and resolve as a loser deterministically. The timeout + // is only a safety net for a dead parent. + const releaseGate = `${gateDir}/release-${r}`; + for (let waited = 0; ; waited += 1) { + try { + fs.statSync(releaseGate); + break; + } catch { + if (waited >= 30_000) break; + await sleep(1); + } + } await lf.release(); } } diff --git a/packages/minidb/test/e2e/stress.test.ts b/packages/minidb/test/e2e/stress.test.ts index 7a77126f2a..e17b32cb85 100644 --- a/packages/minidb/test/e2e/stress.test.ts +++ b/packages/minidb/test/e2e/stress.test.ts @@ -241,6 +241,11 @@ test( if (lines.length >= RACERS) { const holders = lines.filter((l) => l.endsWith(' 1')); if (holders.length !== 1) violations.push(`round ${r}: ${holders.length} holders (${holders.join(', ')})`); + // Every racer has resolved this round: let the winner release. + // Without the gate a racer descheduled past the winner's hold + // could acquire sequentially afterwards and report a truthful + // second "1" (see lock-racer.ts). + await fs.writeFile(`${dir}/release-${r}`, '1'); break; } await new Promise((res) => setTimeout(res, 1)); diff --git a/packages/minidb/test/lock.test.ts b/packages/minidb/test/lock.test.ts index 3e5c22b83c..5a81f6925d 100644 --- a/packages/minidb/test/lock.test.ts +++ b/packages/minidb/test/lock.test.ts @@ -1,16 +1,27 @@ // test/lock.test.js -import { test } from 'vitest'; +import { test, vi } from 'vitest'; import assert from 'node:assert/strict'; import fs from 'node:fs/promises'; import os from 'node:os'; import path from 'node:path'; import { MiniDb } from '../src/index.js'; -import { LockError } from '../src/lockfile.js'; +import { LockError, LockFile, LOCK_CREATION_WINDOW_MS } from '../src/lockfile.js'; async function tmpDir() { return fs.mkdtemp(path.join(os.tmpdir(), 'minidb-lock-')); } +async function cleanup(dir: string) { + await fs.rm(dir, { recursive: true, force: true }); +} + +const legacyPayload = (pid: number) => JSON.stringify({ pid, ts: Date.now() }); + +async function ageFile(p: string) { + const past = new Date(Date.now() - LOCK_CREATION_WINDOW_MS - 1000); + await fs.utimes(p, past, past); +} + test('a second writer on the same dir is rejected with LockError', async () => { const dir = await tmpDir(); const db1 = await MiniDb.open({ dir, valueCodec: 'string' }); @@ -18,7 +29,7 @@ test('a second writer on the same dir is rejected with LockError', async () => { await assert.rejects(() => MiniDb.open({ dir, valueCodec: 'string' }), LockError); } finally { await db1.close(); - await fs.rm(dir, { recursive: true, force: true }); + await cleanup(dir); } }); @@ -31,7 +42,7 @@ test('lock is released on close, allowing another writer', async () => { const db2 = await MiniDb.open({ dir, valueCodec: 'string' }); assert.equal(db2.get('a'), '1'); await db2.close(); - await fs.rm(dir, { recursive: true, force: true }); + await cleanup(dir); }); test('readOnly open succeeds alongside a writer and rejects writes', async () => { @@ -46,7 +57,7 @@ test('readOnly open succeeds alongside a writer and rejects writes', async () => await ro.close(); } finally { await db1.close(); - await fs.rm(dir, { recursive: true, force: true }); + await cleanup(dir); } }); @@ -59,17 +70,187 @@ test("onLockFail: 'readonly' degrades instead of throwing", async () => { await db2.close(); } finally { await db1.close(); - await fs.rm(dir, { recursive: true, force: true }); + await cleanup(dir); } }); -test('a stale lock (dead PID) is taken over', async () => { +// --- stale takeover ---------------------------------------------------------- + +test('a stale lock (dead PID) is taken over via rename isolation', async () => { const dir = await tmpDir(); - await fs.writeFile(path.join(dir, 'db.lock'), JSON.stringify({ pid: 999999, ts: Date.now() })); + // Legacy payload shape ({pid, ts} only): old minidb versions left these. + await fs.writeFile(path.join(dir, 'db.lock'), legacyPayload(999999)); const db = await MiniDb.open({ dir, valueCodec: 'string' }); assert.equal(db.readOnly, false); await db.set('a', '1'); assert.equal(db.get('a'), '1'); + // The old lock is renamed aside (no lock_id in legacy payload → "unknown"), + // never deleted, so the takeover stays auditable. + assert((await fs.readdir(dir)).includes('db.lock.stale.unknown')); await db.close(); - await fs.rm(dir, { recursive: true, force: true }); + await cleanup(dir); +}); + +test('a stale lock with a protocol payload keeps its lock_id in the stale file name', async () => { + const dir = await tmpDir(); + await fs.writeFile( + path.join(dir, 'db.lock'), + JSON.stringify({ pid: 999999, ts: Date.now(), lock_id: 'old-token', process_started_at: 'x' }), + ); + const lock = new LockFile(path.join(dir, 'db.lock'), { newLockId: () => 'new-token' }); + assert.equal(await lock.acquire(), true); + assert.equal(lock.held, true); + assert((await fs.readdir(dir)).includes('db.lock.stale.old-token')); + const onDisk = JSON.parse(await fs.readFile(path.join(dir, 'db.lock'), 'utf8')); + assert.equal(onDisk.lock_id, 'new-token'); + await lock.release(); + // Release removed only our own lock; the quarantined stale file stays. + const after = await fs.readdir(dir); + assert(!after.includes('db.lock')); + assert(after.includes('db.lock.stale.old-token')); + await cleanup(dir); +}); + +// --- creation window: fresh empty/garbage files are "creating", not stale ---- + +test('a fresh empty lock file counts as still being created (acquire refused)', async () => { + const dir = await tmpDir(); + const p = path.join(dir, 'db.lock'); + await fs.writeFile(p, ''); + const lock = new LockFile(p); + assert.equal(await lock.acquire(), false); + assert.equal(lock.held, false); + + // Once the file is older than the creation window it is stale and can be + // taken over — via rename isolation, not unlink+create. + await ageFile(p); + assert.equal(await lock.acquire(), true); + assert((await fs.readdir(dir)).includes('db.lock.stale.unknown')); + await lock.release(); + await cleanup(dir); +}); + +test('a fresh unparseable lock file counts as being created; an old one is stale', async () => { + const dir = await tmpDir(); + const p = path.join(dir, 'db.lock'); + await fs.writeFile(p, 'not-json'); + const lock = new LockFile(p); + assert.equal(await lock.acquire(), false); + + await ageFile(p); + assert.equal(await lock.acquire(), true); + await lock.release(); + await cleanup(dir); +}); + +// --- token guard ------------------------------------------------------------- + +test('release never unlinks a lock that was taken over meanwhile', async () => { + const dir = await tmpDir(); + const p = path.join(dir, 'db.lock'); + const a = new LockFile(p, { newLockId: () => 'a-token' }); + assert.equal(await a.acquire(), true); + + // A judges A's (simulated dead) entry stale and B's payload replaces it on + // disk — e.g. after a real takeover of a frozen A. + await fs.writeFile(p, JSON.stringify({ pid: process.pid, ts: Date.now(), lock_id: 'b-token' })); + + await a.release(); // must not touch B's lock + const onDisk = JSON.parse(await fs.readFile(p, 'utf8')); + assert.equal(onDisk.lock_id, 'b-token'); + + // Same for the sync variant (rebuild the "held but superseded" state). + Object.assign(a, { held: true, lockId: 'a-token' }); + a.releaseSync(); + const stillB = JSON.parse(await fs.readFile(p, 'utf8')); + assert.equal(stillB.lock_id, 'b-token'); + await cleanup(dir); +}); + +test('a read-back token mismatch rejects the acquire', async () => { + const dir = await tmpDir(); + const p = path.join(dir, 'db.lock'); + const lock = new LockFile(p, { newLockId: () => 'victim-token' }); + // A peer stomps the file between our O_EXCL create and our read-back. + const foreign = JSON.stringify({ pid: process.pid, ts: Date.now(), lock_id: 'other-token' }); + const spy = vi + .spyOn(LockFile.prototype as never, 'readDiskText') + .mockImplementation(async function (this: { path: string }) { + await fs.writeFile(this.path, foreign); + return fs.readFile(this.path, 'utf8'); + }); + try { + assert.equal(await lock.acquire(), false); + assert.equal(lock.held, false); + const onDisk = JSON.parse(await fs.readFile(p, 'utf8')); + assert.equal(onDisk.lock_id, 'other-token'); + } finally { + spy.mockRestore(); + await cleanup(dir); + } +}); + +// --- pid reuse (processStartedAt identity) ----------------------------------- + +test('a live pid whose processStartedAt differs is treated as dead (pid reused)', async () => { + const dir = await tmpDir(); + const p = path.join(dir, 'db.lock'); + await fs.writeFile( + p, + JSON.stringify({ pid: 12345, ts: Date.now(), lock_id: 'old-token', process_started_at: 'A' }), + ); + const lock = new LockFile(p, { + probeProcess: () => ({ alive: true, processStartedAt: 'B' }), + }); + assert.equal(await lock.acquire(), true); + assert((await fs.readdir(dir)).includes('db.lock.stale.old-token')); + await lock.release(); + await cleanup(dir); +}); + +test('identity unavailable for a live pid is conservative: no takeover', async () => { + const dir = await tmpDir(); + const p = path.join(dir, 'db.lock'); + await fs.writeFile( + p, + JSON.stringify({ pid: 12345, ts: Date.now(), lock_id: 'old-token', process_started_at: 'A' }), + ); + const lock = new LockFile(p, { + probeProcess: () => ({ alive: true, processStartedAt: undefined }), + }); + assert.equal(await lock.acquire(), false); + // Nothing was renamed aside either. + assert.deepEqual(await fs.readdir(dir), ['db.lock']); + await cleanup(dir); +}); + +test('matching identity for a live pid also refuses takeover', async () => { + const dir = await tmpDir(); + const p = path.join(dir, 'db.lock'); + await fs.writeFile( + p, + JSON.stringify({ pid: 12345, ts: Date.now(), lock_id: 'old-token', process_started_at: 'A' }), + ); + const lock = new LockFile(p, { + probeProcess: () => ({ alive: true, processStartedAt: 'A' }), + }); + assert.equal(await lock.acquire(), false); + await cleanup(dir); +}); + +// --- legacy payload compatibility --------------------------------------------- + +test('legacy payload ({pid, ts} only): dead pid is taken over, live pid is refused', async () => { + const dir = await tmpDir(); + const p = path.join(dir, 'db.lock'); + + await fs.writeFile(p, legacyPayload(999999)); + const dead = new LockFile(p); + assert.equal(await dead.acquire(), true); + await dead.release(); + + await fs.writeFile(p, legacyPayload(process.pid)); + const live = new LockFile(p); + assert.equal(await live.acquire(), false); + await cleanup(dir); }); diff --git a/packages/node-sdk/test/session-event-types.test.ts b/packages/node-sdk/test/session-event-types.test.ts index dabddd2510..4c3a952c29 100644 --- a/packages/node-sdk/test/session-event-types.test.ts +++ b/packages/node-sdk/test/session-event-types.test.ts @@ -68,6 +68,7 @@ describe('Event public types', () => { switch (event.type) { case 'agent.status.updated': case 'session.meta.updated': + case 'session.list_changed': case 'event.session.created': case 'event.session.status_changed': case 'event.session.work_changed': @@ -78,6 +79,7 @@ describe('Event public types', () => { case 'event.model_catalog.changed': case 'goal.updated': case 'skill.activated': + case 'skill_catalog.changed': case 'plugin_command.activated': case 'error': case 'warning': diff --git a/packages/protocol/src/__tests__/envelope.test.ts b/packages/protocol/src/__tests__/envelope.test.ts index cc72a134bb..008f2a76ad 100644 --- a/packages/protocol/src/__tests__/envelope.test.ts +++ b/packages/protocol/src/__tests__/envelope.test.ts @@ -77,6 +77,33 @@ describe('envelope', () => { '{"code":50001,"msg":"boom","data":null,"request_id":"req_s"}', ); }); + + it('errEnvelope carries structured details and omits them when absent', () => { + const details = { kind: 'held-by-peer', phase: 'routable', address: 'http://127.0.0.1:58628' }; + const withDetails = errEnvelope( + ErrorCode.SESSION_HELD_BY_PEER, + 'session is owned by another instance', + 'req_d', + undefined, + details, + ); + expect(withDetails.details).toEqual(details); + expect(JSON.stringify(withDetails)).toBe( + '{"code":40921,"msg":"session is owned by another instance","data":null,"request_id":"req_d","details":{"kind":"held-by-peer","phase":"routable","address":"http://127.0.0.1:58628"}}', + ); + expect(envelopeSchema(z.any()).parse(withDetails).details).toEqual(details); + + // Stack and details may coexist; neither position affects the other. + const withBoth = errEnvelope(50001, 'boom', 'req_e', 'trace', details); + expect(withBoth.stack).toBe('trace'); + expect(withBoth.details).toEqual(details); + + // No details → field is absent and the wire shape is byte-identical to before. + const without = errEnvelope(ErrorCode.SESSION_HELD_BY_PEER, 'owned by peer', 'req_f'); + expect(JSON.stringify(without)).toBe( + '{"code":40921,"msg":"owned by peer","data":null,"request_id":"req_f"}', + ); + }); }); describe('error-codes', () => { @@ -85,6 +112,7 @@ describe('error-codes', () => { expect(ErrorCode.VALIDATION_FAILED).toBe(40001); expect(ErrorCode.SESSION_NOT_FOUND).toBe(40401); expect(ErrorCode.GOAL_UNSUPPORTED_AGENT).toBe(40920); + expect(ErrorCode.SESSION_HELD_BY_PEER).toBe(40921); expect(ErrorCode.APPROVAL_EXPIRED).toBe(41001); expect(ErrorCode.FS_WATCH_LIMIT_EXCEEDED).toBe(42902); expect(ErrorCode.INTERNAL_ERROR).toBe(50001); @@ -98,6 +126,7 @@ describe('error-codes', () => { expect(ErrorCodeReason[ErrorCode.VALIDATION_FAILED]).toBe('validation.failed'); expect(ErrorCodeReason[ErrorCode.FS_WATCH_LIMIT_EXCEEDED]).toBe('fs.watch_limit_exceeded'); expect(ErrorCodeReason[ErrorCode.GOAL_UNSUPPORTED_AGENT]).toBe('goal.unsupported_agent'); + expect(ErrorCodeReason[ErrorCode.SESSION_HELD_BY_PEER]).toBe('session.held_by_peer'); }); it('reserved codes are not redefined (40101, 50002 absent)', () => { diff --git a/packages/protocol/src/__tests__/events.test.ts b/packages/protocol/src/__tests__/events.test.ts index b4ca42de84..eb996505a8 100644 --- a/packages/protocol/src/__tests__/events.test.ts +++ b/packages/protocol/src/__tests__/events.test.ts @@ -177,6 +177,31 @@ describe('events / display re-exports', () => { expect((parsed as { session: { id: string } }).session.id).toBe('sess_1'); }); + it('validates session.list_changed events (volatile, payload-less)', () => { + const parsed = eventSchema.parse({ + type: 'session.list_changed', + agentId: 'main', + sessionId: '__global__', + }); + expect(parsed.type).toBe('session.list_changed'); + expect( + agentEventSchema.safeParse({ type: 'session.list_changed', unexpected: 1 }).success, + ).toBe(true); + }); + + it('validates skill_catalog.changed events (volatile, source-id hint)', () => { + const parsed = eventSchema.parse({ + type: 'skill_catalog.changed', + sourceId: 'workspace-files', + agentId: 'main', + sessionId: 'sess_1', + }); + expect(parsed.type).toBe('skill_catalog.changed'); + expect( + agentEventSchema.safeParse({ type: 'skill_catalog.changed', unexpected: 1 }).success, + ).toBe(false); + }); + it('validates workspace lifecycle events', () => { const workspace = { id: 'wd_project_123456abcdef', diff --git a/packages/protocol/src/__tests__/session-ownership.test.ts b/packages/protocol/src/__tests__/session-ownership.test.ts new file mode 100644 index 0000000000..35e3c8ad01 --- /dev/null +++ b/packages/protocol/src/__tests__/session-ownership.test.ts @@ -0,0 +1,73 @@ +/** + * Scenario: session-ownership error details payloads (SESSION_HELD_BY_PEER). + * Responsibilities: verify the discriminated-union wire shape, phase enum, and + * retryhint validation. + * Wiring: pure protocol schemas; no external boundaries. + * Run: `pnpm --filter @moonshot-ai/protocol exec vitest run src/__tests__/session-ownership.test.ts`. + */ +import { describe, expect, it } from 'vitest'; + +import { + sessionOwnershipDetailsSchema, + sessionOwnershipPhaseSchema, + type SessionOwnershipDetails, +} from '../session-ownership'; + +describe('sessionOwnershipPhaseSchema', () => { + it('accepts every documented phase', () => { + for (const phase of ['creating', 'routable', 'holder-unresponsive', 'held-by-local-instance']) { + expect(sessionOwnershipPhaseSchema.parse(phase)).toBe(phase); + } + }); + + it('rejects unknown phases', () => { + expect(sessionOwnershipPhaseSchema.safeParse('redirect').success).toBe(false); + expect(sessionOwnershipPhaseSchema.safeParse('').success).toBe(false); + }); +}); + +describe('sessionOwnershipDetailsSchema', () => { + it('parses a routable held-by-peer payload carrying an address', () => { + const payload: SessionOwnershipDetails = { + kind: 'held-by-peer', + phase: 'routable', + address: 'http://127.0.0.1:58628', + }; + expect(sessionOwnershipDetailsSchema.parse(payload)).toEqual(payload); + }); + + it('parses a retryable payload carrying retry_after_ms', () => { + const payload = { kind: 'held-by-peer', phase: 'holder-unresponsive', retry_after_ms: 1500 }; + expect(sessionOwnershipDetailsSchema.parse(payload)).toEqual(payload); + }); + + it('parses a bare held-by-peer payload with no optional hints', () => { + const payload = { kind: 'held-by-peer', phase: 'held-by-local-instance' }; + expect(sessionOwnershipDetailsSchema.parse(payload)).toEqual(payload); + }); + + it('parses the unregistered-writer variant', () => { + expect(sessionOwnershipDetailsSchema.parse({ kind: 'unregistered-writer' })).toEqual({ + kind: 'unregistered-writer', + }); + }); + + it('rejects an unknown kind', () => { + expect(sessionOwnershipDetailsSchema.safeParse({ kind: 'stolen', phase: 'routable' }).success).toBe(false); + }); + + it('rejects a held-by-peer payload without phase', () => { + expect(sessionOwnershipDetailsSchema.safeParse({ kind: 'held-by-peer' }).success).toBe(false); + }); + + it('rejects a negative or fractional retry_after_ms', () => { + expect( + sessionOwnershipDetailsSchema.safeParse({ kind: 'held-by-peer', phase: 'creating', retry_after_ms: -1 }) + .success, + ).toBe(false); + expect( + sessionOwnershipDetailsSchema.safeParse({ kind: 'held-by-peer', phase: 'creating', retry_after_ms: 1.5 }) + .success, + ).toBe(false); + }); +}); diff --git a/packages/protocol/src/__tests__/session.test.ts b/packages/protocol/src/__tests__/session.test.ts index ef78b8f4bd..100d44f274 100644 --- a/packages/protocol/src/__tests__/session.test.ts +++ b/packages/protocol/src/__tests__/session.test.ts @@ -114,6 +114,22 @@ describe('sessionSchema', () => { const parsed = sessionSchema.parse(fullSession); expect(parsed.last_prompt).toBeUndefined(); }); + + it('accepts the optional ownership field and rejects unknown held_by', () => { + expect( + sessionSchema.parse({ ...fullSession, ownership: { held_by: 'self' } }).ownership, + ).toEqual({ held_by: 'self' }); + expect( + sessionSchema.parse({ + ...fullSession, + ownership: { held_by: 'peer', address: 'http://127.0.0.1:58627' }, + }).ownership, + ).toEqual({ held_by: 'peer', address: 'http://127.0.0.1:58627' }); + expect(sessionSchema.parse(fullSession).ownership).toBeUndefined(); + expect( + sessionSchema.safeParse({ ...fullSession, ownership: { held_by: 'unknown' } }).success, + ).toBe(false); + }); }); describe('sessionCreateSchema', () => { diff --git a/packages/protocol/src/envelope.ts b/packages/protocol/src/envelope.ts index 581087ddd3..56766662e7 100644 --- a/packages/protocol/src/envelope.ts +++ b/packages/protocol/src/envelope.ts @@ -29,12 +29,16 @@ export function okEnvelope(data: T, requestId: string): Envelope { * (or `undefined`) the field is absent and the wire shape stays byte-identical * to the original `{ code, msg, data: null, request_id }` — `JSON.stringify` * drops `undefined` properties, so callers that have no stack are unaffected. + * `details` behaves the same way: when provided it carries a structured, + * client-consumable payload (e.g. `SessionOwnershipDetails` under + * `SESSION_HELD_BY_PEER`); when omitted the field is absent from the wire. */ export function errEnvelope( code: number, msg: string, requestId: string, stack?: string, + details?: unknown, ): Envelope { - return { code, msg, data: null, request_id: requestId, stack }; + return { code, msg, data: null, request_id: requestId, stack, details }; } diff --git a/packages/protocol/src/error-codes.ts b/packages/protocol/src/error-codes.ts index 9c4eaccce1..f3c926ae28 100644 --- a/packages/protocol/src/error-codes.ts +++ b/packages/protocol/src/error-codes.ts @@ -99,6 +99,8 @@ export const ErrorCode = { FS_ALREADY_EXISTS: 40919, /** goal 只允许主 agent 使用 */ GOAL_UNSUPPORTED_AGENT: 40920, + /** session 的 lease 被其他实例持有;`details` 为 SessionOwnershipDetails */ + SESSION_HELD_BY_PEER: 40921, /** approval 60s 超时 */ APPROVAL_EXPIRED: 41001, @@ -194,6 +196,7 @@ export const ErrorCodeReason: Readonly> = { [ErrorCode.GOAL_OBJECTIVE_TOO_LONG]: 'goal.objective_too_long', [ErrorCode.FS_ALREADY_EXISTS]: 'fs.already_exists', [ErrorCode.GOAL_UNSUPPORTED_AGENT]: 'goal.unsupported_agent', + [ErrorCode.SESSION_HELD_BY_PEER]: 'session.held_by_peer', [ErrorCode.APPROVAL_EXPIRED]: 'approval.expired', [ErrorCode.QUESTION_EXPIRED]: 'question.expired', diff --git a/packages/protocol/src/events.ts b/packages/protocol/src/events.ts index 3b2eceaafb..ef6b638432 100644 --- a/packages/protocol/src/events.ts +++ b/packages/protocol/src/events.ts @@ -235,6 +235,8 @@ export type KimiErrorCode = | 'session.approval_handler_error' | 'session.question_handler_error' | 'session.init_failed' + | 'session.held_by_peer' + | 'session.lease_lost' | 'agent.not_found' | 'turn.agent_busy' | 'goal.already_exists' @@ -309,6 +311,10 @@ export type KimiErrorCode = | 'os.fs.unknown' | 'os.process.spawn_failed' | 'os.process.kill_failed' + | 'os.lock.held' + | 'os.lock.wait_timeout' + | 'os.lock.lost' + | 'os.lock.io' | 'storage.not_found' | 'storage.decode_failed' | 'storage.corrupted' @@ -502,6 +508,32 @@ export interface SessionCreatedEvent { readonly session: Session; } +/** + * Volatile, payload-less hint that the set of sessions on disk changed + * (design `.tmp/refactor-watch-design-v2.md` §3.8): a workspace or session + * directory appeared or vanished under the shared `/sessions` tree, + * possibly created by ANOTHER server instance sharing the home. Clients + * should re-pull `GET /sessions` instead of reading anything into the event + * itself — it is fanned out live only (never journaled, never replayed). + */ +export interface SessionListChangedEvent { + readonly type: 'session.list_changed'; +} + +/** + * Volatile per-session hint that the session's skill catalog changed: a skill + * file or directory under one of the watched sources appeared, changed, or + * vanished (mirrors the core `ISessionSkillCatalog.onDidChange` feed, whose + * payload is the changed source id). Fanned out live only (never journaled, + * never replayed) to connections subscribed to that session — clients should + * re-pull `GET /sessions/{sid}/skills` instead of reading anything into the + * hint itself. + */ +export interface SkillCatalogChangedEvent { + readonly type: 'skill_catalog.changed'; + readonly sourceId: string; +} + export interface WorkspaceCreatedEvent { readonly type: 'event.workspace.created'; readonly workspace: Workspace; @@ -890,6 +922,8 @@ export type AgentEvent = | AgentStatusUpdatedEvent | SessionMetaUpdatedEvent | SessionCreatedEvent + | SessionListChangedEvent + | SkillCatalogChangedEvent | WorkspaceCreatedEvent | WorkspaceUpdatedEvent | WorkspaceDeletedEvent @@ -1384,6 +1418,15 @@ export const sessionCreatedEventSchema = z.object({ session: sessionSchema, }) satisfies z.ZodType; +export const sessionListChangedEventSchema = z.object({ + type: z.literal('session.list_changed'), +}) satisfies z.ZodType; + +export const skillCatalogChangedEventSchema = z.object({ + type: z.literal('skill_catalog.changed'), + sourceId: z.string().min(1), +}) satisfies z.ZodType; + export const workspaceCreatedEventSchema = z.object({ type: z.literal('event.workspace.created'), workspace: workspaceSchema, @@ -1736,6 +1779,8 @@ export const agentEventSchema = z.discriminatedUnion('type', [ agentStatusUpdatedEventSchema, sessionMetaUpdatedEventSchema, sessionCreatedEventSchema, + sessionListChangedEventSchema, + skillCatalogChangedEventSchema, workspaceCreatedEventSchema, workspaceUpdatedEventSchema, workspaceDeletedEventSchema, diff --git a/packages/protocol/src/fs.ts b/packages/protocol/src/fs.ts index 39e2c9a1be..b54f18fa99 100644 --- a/packages/protocol/src/fs.ts +++ b/packages/protocol/src/fs.ts @@ -79,6 +79,27 @@ export const fsChangeEntrySchema = z.object({ }); export type FsChangeEntry = z.infer; +/** + * Payload of the volatile `event.fs.changed` frame on `/api/v1/ws`: + * + * { type: 'event.fs.changed', seq, session_id, timestamp, payload: FsChangeEvent } + * + * The frames never enter the journal — the stream is volatile and there is no + * catch-up cursor. `seq` is **per-connection monotonic**: each WebSocket + * connection numbers only the frames actually delivered to it (matched change + * frames and `truncated` frames alike), starting at 1 with no gaps; a frame + * dispatched only to other connections does not advance yours. + * + * `truncated: true` (with `count`; `changes` empty) means the coalescing + * window overflowed server-side and that window's entries were dropped + * wholesale — the incremental stream after the previously received `seq` is + * no longer trustworthy. On `truncated`, on a `seq` gap, or on an epoch + * change, the client MUST rebuild its baseline with a full REST listing + * (`POST /sessions/{sid}/fs:list`) and resume applying increments on top; + * partial continuation after a truncation is not defined. Truncated frames + * are broadcast to every connection watching the session, regardless of each + * connection's path set. + */ export const fsChangeEventSchema = z.object({ changes: z.array(fsChangeEntrySchema), coalesced_window_ms: z.number().int().positive(), diff --git a/packages/protocol/src/index.ts b/packages/protocol/src/index.ts index 16489267e7..1464498ef5 100644 --- a/packages/protocol/src/index.ts +++ b/packages/protocol/src/index.ts @@ -9,6 +9,7 @@ export * from './ws-control'; export * from './asyncapi'; export * from './session'; +export * from './session-ownership'; export * from './workspace'; export * from './message'; export * from './approval'; diff --git a/packages/protocol/src/rest/snapshot.ts b/packages/protocol/src/rest/snapshot.ts index 02278a69ef..2bac26268c 100644 --- a/packages/protocol/src/rest/snapshot.ts +++ b/packages/protocol/src/rest/snapshot.ts @@ -78,8 +78,13 @@ export type SnapshotSubagent = z.infer; export const sessionSnapshotResponseSchema = z.object({ /** Durable event watermark this snapshot is consistent with. */ as_of_seq: z.number().int().nonnegative(), - /** Journal epoch — pass back via the WS cursor for invalidation detection. */ - epoch: z.string().min(1), + /** + * Journal epoch — pass back via the WS cursor for invalidation detection. + * Absent when the journal has no baseline yet (no durable event written): + * "no baseline" is not "baseline changed" — treat the cursor as fresh, + * not as invalidated. + */ + epoch: z.string().min(1).optional(), session: sessionSchema, /** Most recent messages (chronological ascending), bounded page. */ messages: z.object({ diff --git a/packages/protocol/src/session-ownership.ts b/packages/protocol/src/session-ownership.ts new file mode 100644 index 0000000000..82234278f8 --- /dev/null +++ b/packages/protocol/src/session-ownership.ts @@ -0,0 +1,45 @@ +/** + * Session-ownership error details — the structured `details` payload carried + * under `SESSION_HELD_BY_PEER` (40921) when an instance receives a request for + * a session whose lease is held by another instance. + * + * Wire semantics: the payload rides the REST envelope `details` field (HTTP + * 200 envelope; the business outcome lives in `code`) and the v2 WS `error` + * frame `details` field, verbatim in both directions. Clients branch on + * `kind`; within `held-by-peer` the `phase` tells the client what to do next: + * + * - creating lease file observed mid-creation; retry shortly + * - routable holder is live and registered an address; client may redirect + * - holder-unresponsive holder pid alive but heartbeat stale; retry later + * - held-by-local-instance holder has no address (local/embedded engine); terminal, do not retry + */ +import { z } from 'zod'; + +export const sessionOwnershipPhaseSchema = z.enum([ + 'creating', + 'routable', + 'holder-unresponsive', + 'held-by-local-instance', +]); +export type SessionOwnershipPhase = z.infer; + +export const heldByPeerDetailsSchema = z.object({ + kind: z.literal('held-by-peer'), + phase: sessionOwnershipPhaseSchema, + /** Present only when phase === 'routable'. */ + address: z.string().optional(), + /** Retry hint (ms) for 'creating' / 'holder-unresponsive'. */ + retry_after_ms: z.number().int().nonnegative().optional(), +}); +export type HeldByPeerDetails = z.infer; + +export const unregisteredWriterDetailsSchema = z.object({ + kind: z.literal('unregistered-writer'), +}); +export type UnregisteredWriterDetails = z.infer; + +export const sessionOwnershipDetailsSchema = z.discriminatedUnion('kind', [ + heldByPeerDetailsSchema, + unregisteredWriterDetailsSchema, +]); +export type SessionOwnershipDetails = z.infer; diff --git a/packages/protocol/src/session.ts b/packages/protocol/src/session.ts index 8d10164e8c..5f9fc8348f 100644 --- a/packages/protocol/src/session.ts +++ b/packages/protocol/src/session.ts @@ -78,6 +78,22 @@ export type SessionMetadata = z.infer; export const sessionPendingInteractionSchema = z.enum(['none', 'approval', 'question']); export type SessionPendingInteraction = z.infer; +/** + * Per-session holder annotation joined from `session-leases/.json` by the + * session list route (multi-instance shared home). `self` = this instance + * holds the write lease (the session is materialized here), `peer` = another + * instance holds it (`address` is its reachable base URL when the holder + * advertised one — the redirect target), `none` = no lease on disk (the + * session is materialized nowhere). Purely display/redirect metadata: the + * lease file stays the authority and is re-checked on every materialization. + */ +export const sessionOwnershipSchema = z.object({ + held_by: z.enum(['self', 'peer', 'none']), + address: z.string().min(1).optional(), +}); + +export type SessionOwnership = z.infer; + export const sessionSchema = z.object({ id: z.string().min(1), workspace_id: workspaceIdSchema, @@ -102,6 +118,7 @@ export const sessionSchema = z.object({ * reason is cancelled/failed). */ last_turn_reason: z.enum(['completed', 'cancelled', 'failed']).optional(), archived: z.boolean().optional(), + ownership: sessionOwnershipSchema.optional(), current_prompt_id: z.string().min(1).optional(), /** Text of the most recent user prompt, for search/preview. Absent for empty sessions. */ last_prompt: z.string().optional(), diff --git a/packages/protocol/src/ws-control.ts b/packages/protocol/src/ws-control.ts index 19807c9f46..e29179e4ca 100644 --- a/packages/protocol/src/ws-control.ts +++ b/packages/protocol/src/ws-control.ts @@ -178,6 +178,13 @@ export const unsubscribeAckPayloadSchema = subscribeAckPayloadSchema; export const unsubscribeAckMessageSchema = wsAckEnvelopeSchema(unsubscribeAckPayloadSchema); +/** + * Filesystem watch (per connection): `watch_fs_add` / `watch_fs_remove` + * manage this connection's path set for a session; matching changes stream + * back as volatile `event.fs.changed` frames with a per-connection monotonic + * `seq`. See `fsChangeEventSchema` (`fs.ts`) for the full seq / truncated + * contract, including the client's rebuild-baseline obligation. + */ export const watchFsAddPayloadSchema = z.object({ session_id: z.string(), paths: z.array(z.string()), From 215ca73f87252d65c335d6e9c0b508b997b3a958 Mon Sep 17 00:00:00 2001 From: 7Sageer <7sageer@djwcb.cn> Date: Mon, 20 Jul 2026 12:04:39 +0800 Subject: [PATCH 02/22] fix: close lock and session lifecycle review gaps --- .../scripts/check-domain-layers.mjs | 1 + .../src/_base/di/instantiation.ts | 17 +- .../src/_base/di/instantiationService.ts | 12 +- .../agent/fileFencing/fileFencingService.ts | 32 +- .../src/agent/toolExecutor/toolExecutor.ts | 5 +- .../agent/toolExecutor/toolExecutorService.ts | 17 +- .../src/agent/toolExecutor/toolHooks.ts | 1 + .../src/app/bootstrap/bootstrap.ts | 4 +- .../src/app/bootstrap/bootstrapService.ts | 4 +- .../src/app/config/configFileMutex.ts | 35 -- .../src/app/config/configService.ts | 41 +- .../agent-core-v2/src/app/multiServer/flag.ts | 5 +- .../app/sessionLifecycle/sessionLifecycle.ts | 3 + .../sessionLifecycleService.ts | 408 ++++++++++++------ .../agent-core-v2/src/app/telemetry/events.ts | 13 + .../fileWorkspacePersistence.ts | 4 + .../workspaceRegistry/workspacePersistence.ts | 6 +- .../workspaceRegistryService.ts | 33 +- .../node-local/crossProcessLockService.ts | 349 ++++++++++++--- .../src/os/interface/crossProcessLock.ts | 23 +- .../backends/memory/inMemoryStorageService.ts | 15 + .../backends/node-fs/appendLogStore.ts | 29 +- .../backends/node-fs/atomicDocumentStore.ts | 30 ++ .../backends/node-fs/fileStorageService.ts | 43 +- .../persistence/interface/appendLogStore.ts | 8 +- .../interface/atomicDocumentStore.ts | 6 + .../src/persistence/interface/storage.ts | 3 + .../persistence/interface/writeAuthority.ts | 5 +- .../agentLifecycle/agentLifecycleService.ts | 6 +- packages/agent-core-v2/src/session/errors.ts | 1 + .../session/sessionFileLedger/fileLedger.ts | 11 +- .../sessionFileLedger/fileLedgerService.ts | 39 +- .../src/session/sessionFs/fsWatch.ts | 2 +- .../src/session/sessionFs/fsWatchService.ts | 25 +- .../src/session/sessionLease/sessionLease.ts | 7 +- .../sessionLeaseContactProvider.ts | 5 +- .../sessionMetadata/sessionMetadataService.ts | 4 +- .../test/_base/di/auto-inject.test.ts | 46 +- .../agent/fileFencing/fileFencing.test.ts | 49 ++- .../test/agent/goal/goalOps.test.ts | 5 +- .../agent/toolExecutor/toolExecutor.test.ts | 21 + .../app/bootstrap/bootstrapService.test.ts | 21 +- .../test/app/config/config.test.ts | 11 - .../test/app/config/configFileMutex.test.ts | 58 ++- .../externalHooksRunner/integration.test.ts | 1 + .../test/app/mcp/mcpConfigWatch.test.ts | 3 +- .../app/sessionExport/sessionExport.test.ts | 1 + .../sessionLifecycle/sessionLifecycle.test.ts | 213 +++++++-- .../workspaceRegistryService.test.ts | 5 +- .../crossProcessLockService.test.ts | 256 +++++++---- packages/agent-core-v2/test/os/stubs.ts | 8 +- .../backends/node-fs/appendLogStore.test.ts | 90 ++-- .../node-fs/atomicDocumentStore.test.ts | 36 ++ .../agentLifecycle/agentLifecycle.test.ts | 11 + .../sessionFileLedger/fileLedger.test.ts | 29 +- .../session/sessionFs/fsWatchService.test.ts | 6 +- .../session/sessionLease/sessionLease.test.ts | 31 +- .../sessionListWatchService.ts | 2 +- .../klient/src/contract/session/lifecycle.ts | 1 + packages/klient/src/core/facade/session.ts | 4 + packages/minidb/src/index.ts | 15 +- packages/minidb/src/lockfile.ts | 54 ++- packages/minidb/test/lock.test.ts | 20 + packages/minidb/test/review-fixes.test.ts | 24 ++ 64 files changed, 1637 insertions(+), 636 deletions(-) delete mode 100644 packages/agent-core-v2/src/app/config/configFileMutex.ts diff --git a/packages/agent-core-v2/scripts/check-domain-layers.mjs b/packages/agent-core-v2/scripts/check-domain-layers.mjs index 4e497c97d3..82e6d278cc 100644 --- a/packages/agent-core-v2/scripts/check-domain-layers.mjs +++ b/packages/agent-core-v2/scripts/check-domain-layers.mjs @@ -123,6 +123,7 @@ const DOMAIN_LAYER = new Map([ ['sessionSkillCatalog', 3], ['permissionGate', 3], ['flag', 3], + ['multiServer', 3], ['toolExecutor', 3], ['toolResultTruncation', 3], ['toolRegistry', 3], diff --git a/packages/agent-core-v2/src/_base/di/instantiation.ts b/packages/agent-core-v2/src/_base/di/instantiation.ts index 5642c087b4..c13d9091a8 100644 --- a/packages/agent-core-v2/src/_base/di/instantiation.ts +++ b/packages/agent-core-v2/src/_base/di/instantiation.ts @@ -16,7 +16,7 @@ export namespace _util { export function getServiceDependencies( ctor: DI_TARGET_OBJ, // eslint-disable-next-line @typescript-eslint/no-explicit-any - ): { id: ServiceIdentifier; index: number }[] { + ): { id: ServiceIdentifier; index: number; optional: boolean }[] { return ctor[DI_DEPENDENCIES] || []; } @@ -25,7 +25,7 @@ export namespace _util { // eslint-disable-next-line @typescript-eslint/no-unsafe-function-type [DI_TARGET]: Function; // eslint-disable-next-line @typescript-eslint/no-explicit-any - [DI_DEPENDENCIES]: { id: ServiceIdentifier; index: number }[]; + [DI_DEPENDENCIES]: { id: ServiceIdentifier; index: number; optional: boolean }[]; } } @@ -56,12 +56,13 @@ function storeServiceDependency( // eslint-disable-next-line @typescript-eslint/no-unsafe-function-type target: Function, index: number, + optional: boolean, ): void { const t = target as _util.DI_TARGET_OBJ; if (t[_util.DI_TARGET] === target) { - t[_util.DI_DEPENDENCIES].push({ id, index }); + t[_util.DI_DEPENDENCIES].push({ id, index, optional }); } else { - t[_util.DI_DEPENDENCIES] = [{ id, index }]; + t[_util.DI_DEPENDENCIES] = [{ id, index, optional }]; t[_util.DI_TARGET] = target; } } @@ -83,7 +84,7 @@ export function createDecorator(name: string): ServiceIdentifier { '@IServiceName-decorator can only be used to decorate a parameter', ); } - storeServiceDependency(id, target, index); + storeServiceDependency(id, target, index, false); } as unknown as ServiceIdentifier; Object.defineProperty(id, 'toString', { @@ -99,6 +100,12 @@ export function createDecorator(name: string): ServiceIdentifier { return id; } +export function optional(id: ServiceIdentifier): ParameterDecorator { + return (target, _key, index): void => { + storeServiceDependency(id, target as Function, index, true); + }; +} + export function refineServiceDecorator( serviceIdentifier: ServiceIdentifier, ): ServiceIdentifier { diff --git a/packages/agent-core-v2/src/_base/di/instantiationService.ts b/packages/agent-core-v2/src/_base/di/instantiationService.ts index 52084a85b7..625442b9d9 100644 --- a/packages/agent-core-v2/src/_base/di/instantiationService.ts +++ b/packages/agent-core-v2/src/_base/di/instantiationService.ts @@ -255,8 +255,15 @@ export class InstantiationService implements IInstantiationService { const serviceDependencies = _util.getServiceDependencies(ctor).toSorted((a, b) => a.index - b.index); const serviceArgs: unknown[] = []; for (const dependency of serviceDependencies) { - const service = this._getOrCreateServiceInstance(dependency.id, _trace); - if (!service) { + if ( + dependency.optional && + this._getServiceInstanceOrDescriptor(dependency.id) === undefined + ) { + serviceArgs.push(undefined); + continue; + } + const service = this._getOrCreateServiceInstance(dependency.id, _trace); + if (!service) { this._throwIfStrict( `[createInstance] ${ctor.name} depends on UNKNOWN service ${String(dependency.id)}.`, false, @@ -349,6 +356,7 @@ export class InstantiationService implements IInstantiationService { for (const dependency of _util.getServiceDependencies(item.desc.ctor)) { const instanceOrDesc = this._getServiceInstanceOrDescriptor(dependency.id); if (!instanceOrDesc) { + if (dependency.optional) continue; this._throwIfStrict( `[createInstance] ${String(item.id)} depends on ${String(dependency.id)} which is NOT registered.`, true, diff --git a/packages/agent-core-v2/src/agent/fileFencing/fileFencingService.ts b/packages/agent-core-v2/src/agent/fileFencing/fileFencingService.ts index b65be3b672..f3ef5d6d0b 100644 --- a/packages/agent-core-v2/src/agent/fileFencing/fileFencingService.ts +++ b/packages/agent-core-v2/src/agent/fileFencing/fileFencingService.ts @@ -4,7 +4,10 @@ * Registers the `writeFencing` participant on the `toolExecutor` * `onBeforeExecuteTool` / `onDidExecuteTool` hook slots, matching * `Read`/`Write`/`Edit` by tool name and letting every other tool pass - * through. The target path comes from the resolved execution's file accesses + * through. For Write/Edit the before hook injects an execution wrapper; the + * wrapper re-checks the ledger only after the scheduler grants the declared + * file access, so a queued write cannot rely on a stale preflight verdict. The + * target path comes from the resolved execution's file accesses * — the exact canonical path the tool itself computed — so the ledger and * the watcher key it identically. The before-hook records the target keyed * by `toolCall.id` (cleared in the did-hook and swept on turn change) and, @@ -113,7 +116,7 @@ export class AgentFileFencingService extends Disposable implements IAgentFileFen ) { super(); toolExecutor.hooks.onBeforeExecuteTool.register('writeFencing', async (ctx, next) => { - await this.onBefore(ctx); + this.onBefore(ctx); if (ctx.decision?.block === true) return; await next(); }); @@ -123,7 +126,7 @@ export class AgentFileFencingService extends Disposable implements IAgentFileFen }); } - private async onBefore(ctx: ToolBeforeExecuteContext): Promise { + private onBefore(ctx: ToolBeforeExecuteContext): void { if (!isFenced(ctx)) return; const path = targetPathOf(ctx); if (path === undefined) return; @@ -134,13 +137,22 @@ export class AgentFileFencingService extends Disposable implements IAgentFileFen } this.targets.set(ctx.toolCall.id, { toolName: ctx.toolCall.name, path }); if (!WRITE_TOOLS.has(ctx.toolCall.name)) return; - const verdict = await this.ledger.compare(path); - if (verdict === 'clean') return; - if (this.flags.enabled(MULTI_SERVER_FLAG_ID)) { - ctx.decision = { block: true, reason: blockReason(ctx.toolCall.name, path, verdict) }; - return; - } - this.staleMarks.set(ctx.toolCall.id, verdict); + const execute = ctx.decision?.execute ?? ctx.execution.execute; + ctx.decision = { + ...ctx.decision, + execute: async (executeCtx) => { + const verdict = await this.ledger.compare(path); + if (verdict !== 'clean') { + if (this.flags.enabled(MULTI_SERVER_FLAG_ID)) { + const reason = blockReason(ctx.toolCall.name, path, verdict); + ctx.decision = { ...ctx.decision, block: true, reason }; + return { output: reason, isError: true }; + } + this.staleMarks.set(ctx.toolCall.id, verdict); + } + return execute(executeCtx); + }, + }; } private async onDid(ctx: ToolDidExecuteContext): Promise { diff --git a/packages/agent-core-v2/src/agent/toolExecutor/toolExecutor.ts b/packages/agent-core-v2/src/agent/toolExecutor/toolExecutor.ts index d8ca83fb6f..3f2ef84a77 100644 --- a/packages/agent-core-v2/src/agent/toolExecutor/toolExecutor.ts +++ b/packages/agent-core-v2/src/agent/toolExecutor/toolExecutor.ts @@ -9,7 +9,10 @@ import { createDecorator } from '#/_base/di/instantiation'; import type { IDisposable } from '#/_base/di/lifecycle'; import type { ToolResult } from '#/tool/toolContract'; -import type { ToolDidExecuteContext, ToolBeforeExecuteContext } from '#/agent/toolExecutor/toolHooks'; +import type { + ToolBeforeExecuteContext, + ToolDidExecuteContext, +} from '#/agent/toolExecutor/toolHooks'; import type { ToolCall } from '#/app/llmProtocol/message'; import type { OrderedHookSlot } from '#/hooks'; import type { LLMRequestTrace } from '#/app/llmProtocol/requestTrace'; diff --git a/packages/agent-core-v2/src/agent/toolExecutor/toolExecutorService.ts b/packages/agent-core-v2/src/agent/toolExecutor/toolExecutorService.ts index c7e2f02726..a3d3ce8cfa 100644 --- a/packages/agent-core-v2/src/agent/toolExecutor/toolExecutorService.ts +++ b/packages/agent-core-v2/src/agent/toolExecutor/toolExecutorService.ts @@ -31,7 +31,10 @@ import { type ToolResult, type ToolUpdate, } from '#/tool/toolContract'; -import type { ToolDidExecuteContext, ToolBeforeExecuteContext } from '#/agent/toolExecutor/toolHooks'; +import type { + ToolBeforeExecuteContext, + ToolDidExecuteContext, +} from '#/agent/toolExecutor/toolHooks'; import { IAgentToolRegistryService } from '#/agent/toolRegistry/toolRegistry'; import { ILogService } from '#/_base/log/log'; import type { ToolCallEvent } from '#/app/telemetry/events'; @@ -365,14 +368,22 @@ export class AgentToolExecutorService implements IAgentToolExecutorService { } const executionMetadata = decision?.executionMetadata; + const runnableExecution = + decision?.execute === undefined ? execution : { ...execution, execute: decision.execute }; this.dispatchToolCall(call, call.args, options, displayFields); return { task: { - accesses: execution.accesses ?? ToolAccesses.all(), + accesses: runnableExecution.accesses ?? ToolAccesses.all(), execute: async (taskSignal) => - this.runSingleExecution(call, execution, executionMetadata, options, taskSignal), + this.runSingleExecution( + call, + runnableExecution, + executionMetadata, + options, + taskSignal, + ), }, stopBatchAfterThis: execution.stopBatchAfterThis, }; diff --git a/packages/agent-core-v2/src/agent/toolExecutor/toolHooks.ts b/packages/agent-core-v2/src/agent/toolExecutor/toolHooks.ts index 015ed3dd6f..ed7f72384d 100644 --- a/packages/agent-core-v2/src/agent/toolExecutor/toolHooks.ts +++ b/packages/agent-core-v2/src/agent/toolExecutor/toolHooks.ts @@ -33,6 +33,7 @@ export interface AuthorizeToolExecutionResult { readonly reason?: string | undefined; readonly syntheticResult?: ExecutableToolResult | undefined; readonly executionMetadata?: unknown; + readonly execute?: RunnableToolExecution['execute']; } export interface PrepareToolExecutionResult extends AuthorizeToolExecutionResult { diff --git a/packages/agent-core-v2/src/app/bootstrap/bootstrap.ts b/packages/agent-core-v2/src/app/bootstrap/bootstrap.ts index 9c54118cff..e4ce9db311 100644 --- a/packages/agent-core-v2/src/app/bootstrap/bootstrap.ts +++ b/packages/agent-core-v2/src/app/bootstrap/bootstrap.ts @@ -126,9 +126,7 @@ export function bootstrap(input: BootstrapInput = {}, extraSeeds: ScopeSeed = [] function storageSeed(options: IBootstrapOptions): ScopeSeed { const file = (): SyncDescriptor => new SyncDescriptor(FileStorageService, [options.homeDir, 0o700, 0o600], true); - return [ - [IFileSystemStorageService as ServiceIdentifier, file()], - ]; + return [[IFileSystemStorageService as ServiceIdentifier, file()]]; } function skillSeed(): ScopeSeed { diff --git a/packages/agent-core-v2/src/app/bootstrap/bootstrapService.ts b/packages/agent-core-v2/src/app/bootstrap/bootstrapService.ts index 381b00ddd8..2334fc2a98 100644 --- a/packages/agent-core-v2/src/app/bootstrap/bootstrapService.ts +++ b/packages/agent-core-v2/src/app/bootstrap/bootstrapService.ts @@ -13,7 +13,7 @@ * Bound at App scope. */ -import { basename, join, relative } from 'pathe'; +import { join, relative } from 'pathe'; import { InstantiationType } from '#/_base/di/extensions'; import { LifecycleScope, registerScopedService } from '#/_base/di/scope'; @@ -58,7 +58,7 @@ export class BootstrapService implements IBootstrapService { this.storeDir = join(options.homeDir, 'store'); this.cacheDir = join(options.homeDir, 'cache'); this.logsDir = join(options.homeDir, 'logs'); - this.configKey = basename(options.configPath); + this.configKey = relative(options.homeDir, options.configPath); this.scopes = { config: '', sessions: relative(options.homeDir, this.sessionsDir), diff --git a/packages/agent-core-v2/src/app/config/configFileMutex.ts b/packages/agent-core-v2/src/app/config/configFileMutex.ts deleted file mode 100644 index 1e8a7f9091..0000000000 --- a/packages/agent-core-v2/src/app/config/configFileMutex.ts +++ /dev/null @@ -1,35 +0,0 @@ -/** - * `config` domain (L2) — cross-process lock target for the config.toml write path. - * - * Pure seam shared by `ConfigService.persist`: the lock file path derived from - * the resolved config file (`.lock` — `/config.toml.lock` - * in the default layout, so a custom `--config` path is protected at its real - * location) and the bounded-wait acquisition options for the lock-in - * read-modify-write critical section (design: `.tmp/refactor-watch-design-v2.md` - * §3.6). Owns no scoped state. - */ - -import type { - CrossProcessLockAcquireOptions, - CrossProcessLockWaitOptions, -} from '#/os/interface/crossProcessLock'; - -export interface ConfigFileMutexTarget { - readonly lockPath: string; - readonly options: CrossProcessLockAcquireOptions & { wait: CrossProcessLockWaitOptions }; -} - -export const CONFIG_FILE_LOCK_TIMEOUT_MS = 10_000; -export const CONFIG_FILE_LOCK_RETRY_INTERVAL_MS = 50; - -const DEFAULT_WAIT: CrossProcessLockWaitOptions = { - timeoutMs: CONFIG_FILE_LOCK_TIMEOUT_MS, - retryIntervalMs: CONFIG_FILE_LOCK_RETRY_INTERVAL_MS, -}; - -export function configFileMutexTarget( - configPath: string, - wait: CrossProcessLockWaitOptions = DEFAULT_WAIT, -): ConfigFileMutexTarget { - return { lockPath: `${configPath}.lock`, options: { wait } }; -} diff --git a/packages/agent-core-v2/src/app/config/configService.ts b/packages/agent-core-v2/src/app/config/configService.ts index 2fe91defba..bd651fe6ef 100644 --- a/packages/agent-core-v2/src/app/config/configService.ts +++ b/packages/agent-core-v2/src/app/config/configService.ts @@ -11,10 +11,10 @@ * `onDidSectionChange`. Reads config paths and the environment overlay through * `bootstrap`, persists the TOML document through the `storage` TOML * atomic-document store (reloading when the document changes on disk), - * serializes every persist as a cross-process lock-in read-modify-write - * through `crossProcessLock` (re-reading the file inside the lock and applying - * only the touched section, so sections written by other processes survive — - * design: `.tmp/refactor-watch-design-v2.md` §3.6), and logs through `log`. + * serializes every persist through the atomic-document store's transactional + * update primitive (backed by a cross-process lock for file storage), re-reading + * inside the lock and applying only the touched section so writes from other + * processes survive, and logs through `log`. * Late section / overlay registration re-validates the already-loaded raw * value and re-runs overlays. Bound at App scope. */ @@ -29,9 +29,6 @@ import { IAtomicTomlDocumentStore, type IAtomicDocumentStore, } from '#/persistence/interface/atomicDocumentStore'; -import { ICrossProcessLockService } from '#/os/interface/crossProcessLock'; - -import { configFileMutexTarget } from './configFileMutex'; import { type AnyEnvBindings, type ConfigChangedEvent, @@ -243,7 +240,6 @@ export class ConfigService extends Disposable implements IConfigService { @IBootstrapService private readonly bootstrap: IBootstrapService, @ILogService private readonly log: ILogService, @IAtomicTomlDocumentStore private readonly documentStore: IAtomicDocumentStore, - @ICrossProcessLockService private readonly lock: ICrossProcessLockService, ) { super(); this.configKey = this.bootstrap.configKey; @@ -552,24 +548,17 @@ export class ConfigService extends Disposable implements IConfigService { } private async persist(domain: string): Promise { - const { lockPath, options } = configFileMutexTarget(this.bootstrap.configPath); - // Two exclusion layers: `enqueueStateTransition` serializes transitions - // inside this process; the cross-process lock makes the re-read → apply → - // atomic-write burst mutually exclusive with other processes sharing this - // config file. Lock-acquisition and write failures propagate to the set() - // caller as-is (no retry here). - await this.lock.withLock(lockPath, options, async () => { - const data = await this.documentStore.get(CONFIG_SCOPE, this.configKey); - const freshBase = data !== undefined && isPlainObject(data) ? cloneRecord(data) : {}; - applySectionToToml(freshBase, domain, this.raw[domain], this.registry); - await this.documentStore.set(CONFIG_SCOPE, this.configKey, freshBase); - this.rawSnake = freshBase; - // Re-derive the camelCase view too: the fresh base may carry sections - // written by other processes, and both views must match what we wrote - // (rawSnake equality is how the watch-triggered load() skips our own - // echo instead of double-reporting it). - this.raw = transformTomlData(freshBase, this.registry); - }); + const freshBase = await this.documentStore.update( + CONFIG_SCOPE, + this.configKey, + (data) => { + const next = data !== undefined && isPlainObject(data) ? cloneRecord(data) : {}; + applySectionToToml(next, domain, this.raw[domain], this.registry); + return next; + }, + ); + this.rawSnake = freshBase; + this.raw = transformTomlData(freshBase, this.registry); } private commitAbsorbed(previousRaw: ResolvedConfig): void { diff --git a/packages/agent-core-v2/src/app/multiServer/flag.ts b/packages/agent-core-v2/src/app/multiServer/flag.ts index b3bdc70f6d..22c7c1e6a1 100644 --- a/packages/agent-core-v2/src/app/multiServer/flag.ts +++ b/packages/agent-core-v2/src/app/multiServer/flag.ts @@ -1,5 +1,6 @@ /** - * `multi_server` experimental flag — gates the multi-server shared-homedir work. + * `multi_server` experimental flag — gates shared-home coordination among + * cooperating lease-aware server versions. * * When enabled, a kap-server instance registers itself under * `/server/instances/.json` instead of taking the legacy @@ -19,7 +20,7 @@ export const multiServerFlag: FlagDefinitionInput = { id: MULTI_SERVER_FLAG_ID, title: 'multi-server shared home', description: - 'Allow multiple kap-server instances to share one home directory by registering each instance under server/instances/ instead of taking a single homedir lock.', + 'Allow cooperating lease-aware kap-server instances to share one home directory by registering each instance under server/instances/ instead of taking a single homedir lock.', env: MULTI_SERVER_FLAG_ENV, default: false, surface: 'core', diff --git a/packages/agent-core-v2/src/app/sessionLifecycle/sessionLifecycle.ts b/packages/agent-core-v2/src/app/sessionLifecycle/sessionLifecycle.ts index 965cf96be9..9ac44c4f96 100644 --- a/packages/agent-core-v2/src/app/sessionLifecycle/sessionLifecycle.ts +++ b/packages/agent-core-v2/src/app/sessionLifecycle/sessionLifecycle.ts @@ -6,6 +6,8 @@ * `ISessionLifecycleService` used to create sessions (`create`), look up the * live ones (`get` / `list`), close them (`close`), archive/restore them, * fork them (`fork`), and fork-then-tag them as direct children (`createChild`). Announces + * an ambiguous durability failure through explicit `forceAbort`, which records a + * dirty-abort marker before releasing the session lease. * lifecycle transitions through ordered hook slots plus * `onDidCreateSession` / `onDidCloseSession` / `onDidArchiveSession` / * `onDidForkSession`. App-scoped — a single @@ -89,6 +91,7 @@ export interface ISessionLifecycleService { list(): readonly ISessionScopeHandle[]; resume(sessionId: string): Promise; close(sessionId: string): Promise; + forceAbort(sessionId: string): Promise; archive(sessionId: string): Promise; restore(sessionId: string): Promise; fork(opts: ForkSessionOptions): Promise; diff --git a/packages/agent-core-v2/src/app/sessionLifecycle/sessionLifecycleService.ts b/packages/agent-core-v2/src/app/sessionLifecycle/sessionLifecycleService.ts index 8ef3dd229f..6c9168d2b3 100644 --- a/packages/agent-core-v2/src/app/sessionLifecycle/sessionLifecycleService.ts +++ b/packages/agent-core-v2/src/app/sessionLifecycle/sessionLifecycleService.ts @@ -26,15 +26,13 @@ * Every materialization (create/resume/fork-target) first takes the session's * cross-process write lease under `session-leases/` and registers its * `ISessionWriteAuthority` with the `writeAuthorityRegistry`, so the - * journal/state fencing gates have exactly one authority per live session - * (design: `.tmp/refactor-watch-design-v2.md` §3.4 — always on, no flag). - * Contended acquisitions are answered with `session.held_by_peer` carrying - * the structured ownership details; the multi_server flag gates only the - * address emission (`routable` phase) and the unregistered-writer freshness - * probe. Materialize failure arms unregister and release immediately, and - * close/archive finish the session tail (final journal flush) before - * unregistering the authority and releasing the lease. A lease lost under a - * live session (payload token mismatch) tears the whole session down. + * journal/state fencing gates have exactly one authority per live session. + * A preparing scope is private until metadata, MCP, caller-specific setup and + * lifecycle hooks finish. Close/archive move the entry through a draining + * phase, dispose producers, durably flush only that session's append-log tail, + * and release authority only after the barrier succeeds. A failed barrier + * keeps the lease registered for a safe retry; lease loss takes the explicit + * dirty-abort path without claiming a clean handoff. */ import { randomUUID } from 'node:crypto'; @@ -100,8 +98,6 @@ import { sessionLeaseSeed, SESSION_LEASE_HEARTBEAT_INTERVAL_MS, SESSION_LEASE_TTL_MS, - UNREGISTERED_WRITER_RECHECK_DELAY_MS, - UNREGISTERED_WRITER_WINDOW_MS, } from '#/session/sessionLease/sessionLease'; import { ISessionLeaseContactProvider } from '#/session/sessionLease/sessionLeaseContactProvider'; import { ISessionMetadata, type SessionMeta } from '#/session/sessionMetadata/sessionMetadata'; @@ -132,9 +128,24 @@ type MaterializeSessionOptions = Omit & { readonly workspaceId?: string; }; +type SessionEntryPhase = 'preparing' | 'active' | 'draining' | 'flush-failed'; +type SessionCloseKind = 'close' | 'archive'; + +interface SessionEntry { + phase: SessionEntryPhase; + readonly handle: ISessionScopeHandle; + readonly lease: SessionLease; + readonly registration: IDisposable; + readonly scope: string; + disposed: boolean; + closeKind?: SessionCloseKind; + closeStep: number; + closePromise?: Promise; +} + export class SessionLifecycleService extends Disposable implements ISessionLifecycleService { declare readonly _serviceBrand: undefined; - private readonly sessions = new Map(); + private readonly entries = new Map(); private readonly _onDidCreateSession = this._register(new Emitter()); readonly onDidCreateSession: Event = this._onDidCreateSession.event; private readonly _onDidCloseSession = this._register(new Emitter()); @@ -148,10 +159,6 @@ export class SessionLifecycleService extends Disposable implements ISessionLifec 'onWillCloseSession', ]); private readonly resuming = new Map>(); - private readonly leases = new Map< - string, - { readonly lease: SessionLease; readonly registration: IDisposable } - >(); constructor( @IInstantiationService private readonly instantiation: IInstantiationService, @@ -180,24 +187,28 @@ export class SessionLifecycleService extends Disposable implements ISessionLifec async create(opts: CreateSessionOptions): Promise { const sessionId = opts.sessionId ?? createSessionId(); - const handle = await this.materializeSession({ ...opts, sessionId }); - // Index the session under the workspace id the registry actually resolved - // (the same one seeding the session's storage scope), not a recomputed - // `encodeWorkDirKey` — with root folding the two can diverge. - await this.appendSessionIndexEntry( - sessionId, - opts.workDir, - handle.accessor.get(ISessionContext).workspaceId, - ); - if (this.config.get(DEFAULT_PLAN_MODE_SECTION) === true) { - const main = await ensureMainAgent(handle); - await main.accessor.get(IAgentPlanService).enter(); + const entry = await this.materializeSession({ ...opts, sessionId }); + const handle = entry.handle; + try { + await this.appendSessionIndexEntry( + sessionId, + opts.workDir, + handle.accessor.get(ISessionContext).workspaceId, + ); + if (this.config.get(DEFAULT_PLAN_MODE_SECTION) === true) { + const main = await ensureMainAgent(handle); + await main.accessor.get(IAgentPlanService).enter(); + } + this.activateSession(entry); + await this.announceCreated({ sessionId, handle, source: 'startup' }); + return handle; + } catch (error) { + this.rollbackSession(entry); + throw error; } - await this.announceCreated({ sessionId, handle, source: 'startup' }); - return handle; } - private async materializeSession(opts: MaterializeSessionOptions): Promise { + private async materializeSession(opts: MaterializeSessionOptions): Promise { const workspace = await this.workspaceRegistry.createOrTouch(opts.workDir); const workspaceId = opts.workspaceId ?? workspace.id; const sessionScope = this.bootstrap.sessionScope(workspaceId, opts.sessionId); @@ -220,10 +231,15 @@ export class SessionLifecycleService extends Disposable implements ISessionLifec ); const additionalDirs = [...localWorkspaceDirs.additionalDirs, ...callerAdditionalDirs]; await this.hostEnv.ready; - await this.assertNoActiveUnregisteredWriter(sessionDir, opts.sessionId); - const lease = this.acquireSessionLease(opts.sessionId); - const registration = this.authorityRegistry.register(lease); - this.leases.set(opts.sessionId, { lease, registration }); + const lease = await this.acquireSessionLease(opts.sessionId); + let registration: IDisposable; + try { + registration = this.authorityRegistry.register(lease); + } catch (error) { + lease.release(); + throw error; + } + let entry: SessionEntry | undefined; try { const handle = createScopedChildHandle( this.instantiation, @@ -236,17 +252,29 @@ export class SessionLifecycleService extends Disposable implements ISessionLifec if (additionalDirs.length > 0) { handle.accessor.get(ISessionWorkspaceContext).setAdditionalDirs(additionalDirs); } - this.sessions.set(opts.sessionId, handle); + entry = { + phase: 'preparing', + handle, + lease, + registration, + scope: sessionScope, + disposed: false, + closeStep: 0, + }; + this.entries.set(opts.sessionId, entry); + handle.accessor.get(ISessionExternalHooksService); + handle.accessor.get(ISessionCronService); await handle.accessor.get(ISessionMetadata).ready; void handle.accessor.get(ISessionSkillCatalog).ready; await handle.accessor.get(ISessionMcpService).ensureMcpReady(opts.mcpServers); - // Force-instantiate the session-level eager services whose subscriptions - // must exist before the first agent / turn (external hooks, cron). - handle.accessor.get(ISessionExternalHooksService); - handle.accessor.get(ISessionCronService); - return handle; + return entry; } catch (error) { - this.teardownLease(opts.sessionId); + if (entry !== undefined) { + this.rollbackSession(entry); + } else { + registration.dispose(); + lease.release(); + } throw error; } } @@ -269,7 +297,7 @@ export class SessionLifecycleService extends Disposable implements ISessionLifec sessionDir, workDir, }); - await this.appendLogStore.flush(); + await this.appendLogStore.flush(''); } private async announceCreated(event: SessionCreatedEvent): Promise { @@ -280,14 +308,14 @@ export class SessionLifecycleService extends Disposable implements ISessionLifec } get(sessionId: string): ISessionScopeHandle | undefined { - if (this.resuming.has(sessionId)) return undefined; - return this.sessions.get(sessionId); + const entry = this.entries.get(sessionId); + return entry?.phase === 'active' ? entry.handle : undefined; } resume(sessionId: string): Promise { const inflight = this.resuming.get(sessionId); if (inflight !== undefined) return inflight; - const live = this.sessions.get(sessionId); + const live = this.get(sessionId); if (live !== undefined) return Promise.resolve(live); const promise = this.doResume(sessionId) .catch((error: unknown) => { @@ -303,7 +331,7 @@ export class SessionLifecycleService extends Disposable implements ISessionLifec } private async doResume(sessionId: string): Promise { - const live = this.sessions.get(sessionId); + const live = this.get(sessionId); if (live !== undefined) return live; const summary = await this.index.get(sessionId); @@ -313,55 +341,73 @@ export class SessionLifecycleService extends Disposable implements ISessionLifec const workDir = summary.cwd ?? workspace?.root; if (workDir === undefined) return undefined; - const handle = await this.materializeSession({ + const entry = await this.materializeSession({ sessionId, workDir, workspaceId: summary.workspaceId, }); - const agents = handle.accessor.get(IAgentLifecycleService); - if (agents.get(MAIN_AGENT_ID) === undefined) { - await agents.create({ agentId: MAIN_AGENT_ID }); + const handle = entry.handle; + try { + const agents = handle.accessor.get(IAgentLifecycleService); + if (agents.get(MAIN_AGENT_ID) === undefined) { + await agents.create({ agentId: MAIN_AGENT_ID }); + } + this.activateSession(entry); + await this.announceCreated({ sessionId, handle, source: 'resume' }); + return handle; + } catch (error) { + this.rollbackSession(entry); + throw error; } - await this.announceCreated({ sessionId, handle, source: 'resume' }); - return handle; } list(): readonly ISessionScopeHandle[] { const ready: ISessionScopeHandle[] = []; - for (const [id, handle] of this.sessions) { - if (!this.resuming.has(id)) ready.push(handle); + for (const entry of this.entries.values()) { + if (entry.phase === 'active') ready.push(entry.handle); } return ready; } async close(sessionId: string): Promise { - const handle = this.sessions.get(sessionId); - if (handle === undefined) return; - await this.announceWillClose({ sessionId, handle, reason: 'exit' }); - this.sessions.delete(sessionId); - await this.drainAgents(handle); - handle.dispose(); - await this.flushSessionTail(sessionId); - this.teardownLease(sessionId); - this._onDidCloseSession.fire({ sessionId }); + await this.closeSession(sessionId, 'close'); } - async archive(sessionId: string): Promise { - const handle = this.sessions.get(sessionId); - if (handle === undefined) return; - const meta = handle.accessor.get(ISessionMetadata); - await meta.setArchived(true); - await this.drainAgents(handle); - this.event.publish({ - type: 'event.session.archived', - payload: { sessionId }, + async forceAbort(sessionId: string): Promise { + const entry = this.entries.get(sessionId); + if (entry === undefined) return; + if (entry.phase !== 'flush-failed') { + throw new Error2( + ErrorCodes.SESSION_DURABILITY_FAILED, + `session ${sessionId} can only be force-aborted after a durability barrier failure`, + { details: { sessionId, phase: entry.phase } }, + ); + } + + entry.lease.assertWritable(); + await this.docs.update(entry.scope, 'state.json', (current) => { + if (current === undefined) { + throw new Error2( + ErrorCodes.SESSION_DURABILITY_FAILED, + `session ${sessionId} metadata is missing during force-abort`, + { details: { sessionId } }, + ); + } + return { + ...current, + custom: { + ...current.custom, + dirtyAbort: { reason: 'flush-failed', at: Date.now() }, + }, + }; }); - await this.announceWillClose({ sessionId, handle, reason: 'exit' }); - this.sessions.delete(sessionId); - handle.dispose(); - await this.flushSessionTail(sessionId); - this.teardownLease(sessionId); - this._onDidArchiveSession.fire({ sessionId }); + this.telemetry.track2('session_dirty_abort', { session_id: sessionId, reason: 'flush-failed' }); + this.log.warn('force-aborting session after an ambiguous durability failure', { sessionId }); + this.dirtyAbortSession(entry); + } + + async archive(sessionId: string): Promise { + await this.closeSession(sessionId, 'archive'); } async restore(sessionId: string): Promise { @@ -382,10 +428,132 @@ export class SessionLifecycleService extends Disposable implements ISessionLifec } } + private async closeSession(sessionId: string, kind: SessionCloseKind): Promise { + const entry = this.entries.get(sessionId); + if (entry === undefined) return; + if (entry.phase === 'preparing') { + this.rollbackSession(entry); + return; + } + if (entry.phase === 'active') { + entry.phase = 'draining'; + entry.closeKind = kind; + entry.closeStep = 0; + } + if (entry.closePromise !== undefined) { + await entry.closePromise; + return; + } + const closePromise = this.runSessionClose(sessionId, entry); + entry.closePromise = closePromise; + try { + await closePromise; + } finally { + if (this.entries.get(sessionId) === entry && entry.closePromise === closePromise) { + entry.closePromise = undefined; + } + } + } + + private async runSessionClose(sessionId: string, entry: SessionEntry): Promise { + const kind = entry.closeKind ?? 'close'; + const stepCount = kind === 'close' ? 3 : 5; + while (entry.closeStep < stepCount) { + if (kind === 'close') { + await this.runCloseStep(sessionId, entry); + } else { + await this.runArchiveStep(sessionId, entry); + } + entry.closeStep++; + } + try { + await this.flushSessionTail(sessionId, entry.scope); + } catch (error) { + entry.phase = 'flush-failed'; + throw error; + } + entry.registration.dispose(); + entry.lease.release(); + if (this.entries.get(sessionId) === entry) this.entries.delete(sessionId); + if (kind === 'archive') { + this._onDidArchiveSession.fire({ sessionId }); + } else { + this._onDidCloseSession.fire({ sessionId }); + } + } + + private async runCloseStep(sessionId: string, entry: SessionEntry): Promise { + switch (entry.closeStep) { + case 0: + await this.announceWillClose({ sessionId, handle: entry.handle, reason: 'exit' }); + return; + case 1: + await this.drainAgents(entry.handle); + return; + case 2: + this.disposeSessionHandle(entry); + return; + } + } + + private async runArchiveStep(sessionId: string, entry: SessionEntry): Promise { + switch (entry.closeStep) { + case 0: + await entry.handle.accessor.get(ISessionMetadata).setArchived(true); + return; + case 1: + await this.drainAgents(entry.handle); + return; + case 2: + this.event.publish({ + type: 'event.session.archived', + payload: { sessionId }, + }); + return; + case 3: + await this.announceWillClose({ sessionId, handle: entry.handle, reason: 'exit' }); + return; + case 4: + this.disposeSessionHandle(entry); + return; + } + } + + private activateSession(entry: SessionEntry): void { + if (this.entries.get(entry.handle.id) !== entry || entry.phase !== 'preparing') { + throw new Error2( + ErrorCodes.SESSION_LEASE_LOST, + `session ${entry.handle.id} was torn down before activation`, + { details: { sessionId: entry.handle.id } }, + ); + } + entry.lease.assertWritable(); + entry.phase = 'active'; + } + + private rollbackSession(entry: SessionEntry): void { + if (this.entries.get(entry.handle.id) === entry) this.entries.delete(entry.handle.id); + try { + this.disposeSessionHandle(entry); + } catch { + } + try { + entry.registration.dispose(); + } catch { + } + entry.lease.release(); + } + + private disposeSessionHandle(entry: SessionEntry): void { + if (entry.disposed) return; + entry.handle.dispose(); + entry.disposed = true; + } + async fork(opts: ForkSessionOptions): Promise { const sourceId = opts.sourceSessionId; - const sourceHandle = this.sessions.get(sourceId); + const sourceHandle = this.get(sourceId); const indexSummary = await this.index.get(sourceId); if (sourceHandle === undefined && indexSummary === undefined) { throw new Error2(ErrorCodes.SESSION_NOT_FOUND, `session ${sourceId} does not exist`); @@ -403,6 +571,7 @@ export class SessionLifecycleService extends Disposable implements ISessionLifec // quiesce: the only requirement is a durable copy point, which // `copyAgentWire`'s flush provides. let targetId: string | undefined; + let targetEntry: SessionEntry | undefined; let target: ISessionScopeHandle | undefined; let targetSessionDir: string | undefined; try { @@ -417,17 +586,18 @@ export class SessionLifecycleService extends Disposable implements ISessionLifec : await this.readMetaFromDisk(workspaceId, sourceId); targetId = opts.newSessionId ?? createSessionId(); - if (this.sessions.has(targetId) || (await this.index.get(targetId)) !== undefined) { + if (this.entries.has(targetId) || (await this.index.get(targetId)) !== undefined) { throw new Error2( ErrorCodes.SESSION_ALREADY_EXISTS, `Session "${targetId}" already exists`, ); } - target = await this.materializeSession({ + targetEntry = await this.materializeSession({ sessionId: targetId, workDir: workspace.root, }); + target = targetEntry.handle; const targetCtx = target.accessor.get(ISessionContext); targetSessionDir = targetCtx.sessionDir; const targetMeta = target.accessor.get(ISessionMetadata); @@ -472,6 +642,7 @@ export class SessionLifecycleService extends Disposable implements ISessionLifec } await this.appendSessionIndexEntry(targetId, workspace.root, targetCtx.workspaceId); + this.activateSession(targetEntry); this._onDidForkSession.fire({ sourceSessionId: sourceId, sessionId: targetId, @@ -480,18 +651,7 @@ export class SessionLifecycleService extends Disposable implements ISessionLifec await this.announceCreated({ sessionId: targetId, handle: target, source: 'fork' }); return target; } catch (error) { - if (targetId !== undefined) { - this.sessions.delete(targetId); - } - if (target !== undefined) { - try { - target.dispose(); - } catch { - } - } - if (targetId !== undefined) { - this.teardownLease(targetId); - } + if (targetEntry !== undefined) this.rollbackSession(targetEntry); if (targetSessionDir !== undefined) { await this.hostFs.remove(targetSessionDir).catch(() => {}); } @@ -517,7 +677,7 @@ export class SessionLifecycleService extends Disposable implements ISessionLifec } private async resolveSourceTitle(sourceId: string): Promise { - const live = this.sessions.get(sourceId); + const live = this.get(sourceId); if (live !== undefined) { return (await live.accessor.get(ISessionMetadata).read()).title; } @@ -630,44 +790,23 @@ export class SessionLifecycleService extends Disposable implements ISessionLifec } override dispose(): void { - for (const sessionId of this.leases.keys()) { - this.teardownLease(sessionId); - } + for (const entry of this.entries.values()) this.dirtyAbortSession(entry); super.dispose(); } private onLeaseLost(sessionId: string): void { this.log.error('session lease lost; tearing the session down', { sessionId }); - void this.close(sessionId).catch(() => {}); - } - - private async assertNoActiveUnregisteredWriter( - sessionDir: string, - sessionId: string, - ): Promise { - if (!this.flags.enabled(MULTI_SERVER_FLAG_ID)) return; - const first = await this.hostFs.stat(sessionDir).catch(() => undefined); - if (first?.mtimeMs === undefined) return; - if (Date.now() - first.mtimeMs >= UNREGISTERED_WRITER_WINDOW_MS) return; - await sleep(UNREGISTERED_WRITER_RECHECK_DELAY_MS); - const second = await this.hostFs.stat(sessionDir).catch(() => undefined); - if (second?.mtimeMs === undefined) return; - if (second.mtimeMs > first.mtimeMs) { - throw new Error2( - ErrorCodes.SESSION_HELD_BY_PEER, - `session ${sessionId} directory is being written by an external writer; refusing to materialize it here`, - { details: { kind: 'unregistered-writer' } }, - ); - } + const entry = this.entries.get(sessionId); + if (entry !== undefined) this.dirtyAbortSession(entry); } - private acquireSessionLease(sessionId: string): SessionLease { + private async acquireSessionLease(sessionId: string): Promise { const leasePath = sessionLeasePath(this.bootstrap.homeDir, sessionId); const contact = this.leaseContact.contact(); let lease: SessionLease | undefined; try { const prior = this.locks.inspect(leasePath); - const handle = this.locks.acquire(leasePath, { + const handle = await this.locks.acquire(leasePath, { heartbeat: { intervalMs: SESSION_LEASE_HEARTBEAT_INTERVAL_MS, ttlMs: SESSION_LEASE_TTL_MS, @@ -738,22 +877,28 @@ export class SessionLifecycleService extends Disposable implements ISessionLifec return { kind: 'held-by-peer', phase: 'creating', retry_after_ms: LEASE_CREATING_RETRY_AFTER_MS }; } - private async flushSessionTail(sessionId: string): Promise { + private async flushSessionTail(sessionId: string, scope: string): Promise { try { - await this.appendLogStore.flush(); + await this.appendLogStore.flush(scope); } catch (error) { this.log.warn('final journal flush failed while closing session', { sessionId, error: String(error), }); + throw error; } } - private teardownLease(sessionId: string): void { - const entry = this.leases.get(sessionId); - if (entry === undefined) return; - this.leases.delete(sessionId); - entry.registration.dispose(); + private dirtyAbortSession(entry: SessionEntry): void { + if (this.entries.get(entry.handle.id) === entry) this.entries.delete(entry.handle.id); + try { + this.disposeSessionHandle(entry); + } catch { + } + try { + entry.registration.dispose(); + } catch { + } entry.lease.release(); } @@ -789,13 +934,6 @@ function isMissingFileError(error: unknown): boolean { return code === 'ENOENT'; } -function sleep(ms: number): Promise { - return new Promise((resolvePromise) => { - const timer = setTimeout(resolvePromise, ms); - timer.unref?.(); - }); -} - function createSessionId(): string { return `session_${randomUUID()}`; } diff --git a/packages/agent-core-v2/src/app/telemetry/events.ts b/packages/agent-core-v2/src/app/telemetry/events.ts index bd4bf4e2a9..a245533854 100644 --- a/packages/agent-core-v2/src/app/telemetry/events.ts +++ b/packages/agent-core-v2/src/app/telemetry/events.ts @@ -413,6 +413,11 @@ export interface SessionLeaseHolderUnresponsiveEvent { session_id: string; } +export interface SessionDirtyAbortEvent { + session_id: string; + reason: 'flush-failed'; +} + export interface FirstLaunchEvent {} export interface ExitEvent { @@ -895,6 +900,14 @@ export const telemetryEventDefinitions = { comment: "A session's lease holder is alive but its heartbeat is past TTL (frozen).", properties: { session_id: 'Session whose holder is unresponsive' }, }), + session_dirty_abort: defineTelemetryEvent({ + owner: 'kimi-code', + comment: 'A session is force-aborted after its final durability barrier became ambiguous.', + properties: { + session_id: 'Session whose lease is being released after an ambiguous flush failure', + reason: 'Why the dirty abort was required', + }, + }), first_launch: defineTelemetryEvent({ owner: 'kimi-code', comment: 'The CLI runs for the first time on this device.', diff --git a/packages/agent-core-v2/src/app/workspaceRegistry/fileWorkspacePersistence.ts b/packages/agent-core-v2/src/app/workspaceRegistry/fileWorkspacePersistence.ts index 4d8b543669..c3851b5357 100644 --- a/packages/agent-core-v2/src/app/workspaceRegistry/fileWorkspacePersistence.ts +++ b/packages/agent-core-v2/src/app/workspaceRegistry/fileWorkspacePersistence.ts @@ -32,6 +32,10 @@ export class FileWorkspacePersistence implements IWorkspacePersistence { constructor(@IAtomicDocumentStore private readonly docs: IAtomicDocumentStore) {} + runExclusive(op: () => Promise): Promise { + return this.docs.runExclusive(WORKSPACE_REGISTRY_SCOPE, WORKSPACE_REGISTRY_KEY, op); + } + async load(): Promise { const file = await this.docs.get>( WORKSPACE_REGISTRY_SCOPE, diff --git a/packages/agent-core-v2/src/app/workspaceRegistry/workspacePersistence.ts b/packages/agent-core-v2/src/app/workspaceRegistry/workspacePersistence.ts index 96d7690032..3f0efd2bbe 100644 --- a/packages/agent-core-v2/src/app/workspaceRegistry/workspacePersistence.ts +++ b/packages/agent-core-v2/src/app/workspaceRegistry/workspacePersistence.ts @@ -20,9 +20,8 @@ * * `WorkspaceCatalog.raw` carries the opaque document the catalog was loaded * from; `save` re-applies the semantic view onto it so unknown top-level and - * entry fields written by other engine versions survive the round-trip — the - * read-modify-write contract of the shared file (design: - * `.tmp/refactor-watch-design-v2.md` §3.6). + * entry fields written by other engine versions survive the round-trip under + * the shared file's read-modify-write contract. */ import { createDecorator, type ServiceIdentifier } from '#/_base/di/instantiation'; @@ -54,6 +53,7 @@ export interface WorkspaceCatalog { export interface IWorkspacePersistence { readonly _serviceBrand: undefined; + runExclusive(op: () => Promise): Promise; load(): Promise; save(catalog: WorkspaceCatalog): Promise; } diff --git a/packages/agent-core-v2/src/app/workspaceRegistry/workspaceRegistryService.ts b/packages/agent-core-v2/src/app/workspaceRegistry/workspaceRegistryService.ts index 37671d8c59..917b0e89d9 100644 --- a/packages/agent-core-v2/src/app/workspaceRegistry/workspaceRegistryService.ts +++ b/packages/agent-core-v2/src/app/workspaceRegistry/workspaceRegistryService.ts @@ -10,12 +10,11 @@ * re-reads the file on every call), so a write-through cache would clobber * external additions and tombstones with stale state. * - * Two exclusion layers make each read-modify-write safe (design: - * `.tmp/refactor-watch-design-v2.md` §3.6): the in-process promise chain - * serializes ops (entered first, so cross-process waiting stays minimal), - * and an `ICrossProcessLockService` file lock (`workspaces.json.lock`, from - * `crossProcessLock`, resolved against the `bootstrap` home dir) makes the - * load → mutate → save burst atomic against other lock-aware v2 processes. + * Two exclusion layers make each read-modify-write safe: the in-process promise + * chain serializes ops (entered first, so cross-process waiting stays minimal), + * and the persistence store's transaction primitive locks the physical + * `workspaces.json` path, making the load → mutate → save burst atomic against + * other lock-aware v2 processes. * Unregistered writers (v1) stay best-effort: their lost updates are healed * by the session-index merge. Tombstone logic, format and key names are * unchanged, and unknown document fields round-trip verbatim via @@ -64,19 +63,13 @@ * sibling buckets at once without rewriting any stored id. */ -import { basename, isAbsolute, join } from 'pathe'; +import { basename, isAbsolute } from 'pathe'; import { InstantiationType } from '#/_base/di/extensions'; import { LifecycleScope, registerScopedService } from '#/_base/di/scope'; import { encodeWorkDirKey, workspaceRootKey } from '#/_base/utils/workdir-slug'; import { ErrorCodes, Error2, unwrapErrorCause } from '#/errors'; -import { IBootstrapService } from '#/app/bootstrap/bootstrap'; import { IHostFileSystem } from '#/os/interface/hostFileSystem'; -import { - type CrossProcessLockAcquireOptions, - ICrossProcessLockService, - type CrossProcessLockWaitOptions, -} from '#/os/interface/crossProcessLock'; import { IFileSystemStorageService } from '#/persistence/interface/storage'; import { IWorkspaceRegistry, type Workspace, type WorkspaceUpdate } from './workspaceRegistry'; @@ -88,10 +81,6 @@ import { IWorkspacePersistence, type WorkspaceCatalog } from './workspacePersist const SESSION_INDEX_SCOPE = ''; const SESSION_INDEX_KEY = 'session_index.jsonl'; -const WORKSPACES_CATALOG_LOCK_OPTIONS: CrossProcessLockAcquireOptions & { - wait: CrossProcessLockWaitOptions; -} = { wait: { timeoutMs: 10_000 } }; - const textDecoder = new TextDecoder(); interface SessionIndexLine { @@ -106,17 +95,12 @@ export class WorkspaceRegistryService implements IWorkspaceRegistry { /** Whether the once-per-process session-index sync already ran. */ private merged = false; private opQueue: Promise = Promise.resolve(); - private readonly catalogLockPath: string; constructor( @IWorkspacePersistence private readonly store: IWorkspacePersistence, @IFileSystemStorageService private readonly storage: IFileSystemStorageService, @IHostFileSystem private readonly hostFs: IHostFileSystem, - @IBootstrapService bootstrap: IBootstrapService, - @ICrossProcessLockService private readonly lock: ICrossProcessLockService, - ) { - this.catalogLockPath = join(bootstrap.homeDir, 'workspaces.json.lock'); - } + ) {} list(): Promise { return this.runExclusive(async () => { @@ -404,8 +388,7 @@ export class WorkspaceRegistryService implements IWorkspaceRegistry { } private runExclusive(op: () => Promise): Promise { - const locked = (): Promise => - this.lock.withLock(this.catalogLockPath, WORKSPACES_CATALOG_LOCK_OPTIONS, op); + const locked = (): Promise => this.store.runExclusive(op); const next = this.opQueue.then(locked, locked); this.opQueue = next.then( () => {}, diff --git a/packages/agent-core-v2/src/os/backends/node-local/crossProcessLockService.ts b/packages/agent-core-v2/src/os/backends/node-local/crossProcessLockService.ts index 278afffd2a..e0a6d39676 100644 --- a/packages/agent-core-v2/src/os/backends/node-local/crossProcessLockService.ts +++ b/packages/agent-core-v2/src/os/backends/node-local/crossProcessLockService.ts @@ -2,13 +2,11 @@ * `crossProcessLock` domain (L1) — `ICrossProcessLockService` implementation. * * Node-local backend for the cross-process exclusive file-lock protocol - * defined by `os/interface/crossProcessLock` (design: - * `.tmp/refactor-watch-design-v2.md` §3.3). Uses the synchronous `node:fs` - * API by design: every operation is a short burst (create / read / rename / - * beat) on low-frequency coordination paths — server lock, session lease, - * lock-in-RMW — and must never be called from turn/loop hot paths. Process - * probing goes through `createNodeProcessProbe`; every clock, pid, probe and - * token source is injectable for tests. + * defined by `os/interface/crossProcessLock`. Filesystem mutations are short + * synchronous bursts; contender settlement is asynchronous so a delayed + * process never forces the event loop to spin while another attempt exits. + * Process probing goes through `createNodeProcessProbe`; every clock, pid, + * probe and token source is injectable for tests. * * Protocol invariants implemented here: * @@ -20,16 +18,19 @@ * stale; a live identity-matching holder whose heartbeat is past ttl is * `holder-unresponsive` — reported, never seized. An identity that either * side cannot provide counts as matching (conservative). - * - Takeover is rename-isolated: the stale file is moved aside to - * `.stale.` before re-creating, and the freshly created - * payload is read back and confirmed against the new `lockId` — a creator - * frozen inside its create window cannot silently stomp the new lock. + * - Each attempt publishes a unique sidecar intent before inspecting the lock. + * A creator confirms its token, snapshots the foreign intents already + * present, and waits for that finite set to settle before returning. A + * delayed stale observer therefore either sees the new live generation and + * backs off, or steals it before settlement and causes the creator to fail + * rather than double-return; contenders arriving after the snapshot cannot + * starve the creator because they can only observe the new generation. * - Creation window: an empty/unparseable file younger than * `creationWindowMs` (default 5s) is `creating` (treated as held); past the * window it is stale. - * - Heartbeat is `write(position 0) + ftruncate + fsync` on the fd kept open - * from acquire — never tmp+rename, which would let a frozen old holder's - * next beat overwrite the lock that took it over. + * - The winning create fd stays open for every handle. Heartbeat and updates + * write only through that fd — never tmp+rename or path re-open — so a frozen + * old holder cannot overwrite a successor after losing the public path. * * Bound at App scope. */ @@ -40,13 +41,14 @@ import { ftruncateSync, mkdirSync, openSync, + readdirSync, readFileSync, renameSync, statSync, unlinkSync, writeSync, } from 'node:fs'; -import { dirname } from 'node:path'; +import { basename, dirname, join } from 'node:path'; import { ulid } from 'ulid'; @@ -71,6 +73,7 @@ import { createNodeProcessProbe } from './processProbe'; const DEFAULT_CREATION_WINDOW_MS = 5_000; const DEFAULT_WAIT_RETRY_INTERVAL_MS = 50; +const DEFAULT_SETTLE_RETRY_INTERVAL_MS = 10; const MAX_ACQUIRE_ATTEMPTS = 3; function readErrno(error: unknown): string | undefined { @@ -125,6 +128,19 @@ interface DiskLockPayload { [extra: string]: unknown; } +interface DiskIntentPayload { + intent_id?: string; + state?: 'active' | 'settled'; + pid?: number; + process_started_at?: string; +} + +interface RegisteredIntent { + readonly path: string; + readonly token: string; + readonly fd: number; +} + function renderPayloadJson(payload: CrossProcessLockPayload): string { const { lockId, instanceId, pid, processStartedAt, address, heartbeatAt, ...extras } = payload; const disk: DiskLockPayload = { @@ -239,6 +255,9 @@ class NodeCrossProcessLockHandle implements ICrossProcessLockHandle { if (readErrno(error) === 'ENOENT') throw lostError(this.lockPath, 'updating the payload'); throw toLockIoError(error, { path: this.lockPath, op: 'update' }); } + if (readPayloadFromPath(this.lockPath)?.lockId !== this.lockId) { + throw lostError(this.lockPath, 'confirming the updated payload'); + } } release(): void { @@ -267,10 +286,6 @@ class NodeCrossProcessLockHandle implements ICrossProcessLockHandle { this.writePayload(); } - sealPidOnly(): void { - this.closeFd(); - } - private tick(): void { if (this._released || this._fd < 0) return; try { @@ -308,14 +323,7 @@ class NodeCrossProcessLockHandle implements ICrossProcessLockHandle { fsyncSync(this._fd); return; } - const fd = openSync(this.lockPath, 'r+'); - try { - writeSync(fd, data, 0, data.length, 0); - ftruncateSync(fd, data.length); - fsyncSync(fd); - } finally { - closeSync(fd); - } + throw lostError(this.lockPath, 'writing the payload'); } private stopHeartbeat(): void { @@ -342,15 +350,19 @@ export class CrossProcessLockService implements ICrossProcessLockService { private readonly selfPid: number; private readonly probe: ProcessProbe; private readonly newLockId: () => string; + private readonly newAttemptId: () => string; private readonly instanceId: string; private readonly sleep: (ms: number) => Promise; + private readonly beforeStaleIsolation: (() => void | Promise) | undefined; constructor(deps: CrossProcessLockServiceDeps = {}) { this.now = deps.now ?? Date.now; this.selfPid = deps.selfPid ?? process.pid; this.probe = deps.probeProcess ?? createNodeProcessProbe(); this.newLockId = deps.newLockId ?? ulid; + this.newAttemptId = deps.newAttemptId ?? ulid; this.instanceId = deps.instanceId ?? ulid(); + this.beforeStaleIsolation = deps.beforeStaleIsolation; this.sleep = deps.sleep ?? ((ms) => @@ -360,10 +372,10 @@ export class CrossProcessLockService implements ICrossProcessLockService { })); } - acquire( + async acquire( lockPath: string, options: CrossProcessLockAcquireOptions = {}, - ): ICrossProcessLockHandle { + ): Promise { try { mkdirSync(dirname(lockPath), { recursive: true }); } catch (error) { @@ -371,32 +383,82 @@ export class CrossProcessLockService implements ICrossProcessLockService { } const creationWindowMs = options.creationWindowMs ?? DEFAULT_CREATION_WINDOW_MS; const observedTtlMs = options.heartbeat?.ttlMs ?? creationWindowMs; - let lastHolder: CrossProcessLockPayload | undefined; - for (let attempt = 0; attempt < MAX_ACQUIRE_ATTEMPTS; attempt++) { - let fd: number; + const intent = this.registerIntent(lockPath); + let acquiredHandle: ICrossProcessLockHandle | undefined; + let primaryError: unknown; + let hasPrimaryError = false; + try { + let lastHolder: CrossProcessLockPayload | undefined; + for (let attempt = 0; attempt < MAX_ACQUIRE_ATTEMPTS; attempt++) { + let fd: number; + try { + fd = openSync(lockPath, 'wx', 0o600); + } catch (error) { + if (readErrno(error) !== 'EEXIST') { + throw toLockIoError(error, { path: lockPath, op: 'open' }); + } + const inspection = this.classify(lockPath, creationWindowMs); + lastHolder = inspection.payload; + switch (inspection.state) { + case 'free': + continue; + case 'creating': + throw heldError(lockPath, 'creating', undefined); + case 'held': + throw heldError( + lockPath, + this.reasonForHeld(inspection.payload, observedTtlMs), + inspection.payload, + ); + case 'stale': + if (!(await this.isolateStale(lockPath, inspection, creationWindowMs, intent.path))) { + continue; + } + continue; + } + } + const handle = this.completeAcquire(lockPath, fd, options); + try { + await this.settleAcquire(handle, intent.path, creationWindowMs); + acquiredHandle = handle; + break; + } catch (error) { + handle.release(); + throw error; + } + } + if (acquiredHandle === undefined) throw heldError(lockPath, 'held', lastHolder); + } catch (error) { + hasPrimaryError = true; + primaryError = error; + } + let cleanupError: unknown; + let hasCleanupError = false; + try { try { - fd = openSync(lockPath, 'wx', 0o600); + this.markIntentSettled(intent); } catch (error) { - if (readErrno(error) !== 'EEXIST') { - throw toLockIoError(error, { path: lockPath, op: 'open' }); - } - const inspection = this.classify(lockPath, creationWindowMs); - lastHolder = inspection.payload; - switch (inspection.state) { - case 'free': - continue; - case 'creating': - throw heldError(lockPath, 'creating', undefined); - case 'held': - throw heldError(lockPath, this.reasonForHeld(inspection.payload, observedTtlMs), inspection.payload); - case 'stale': - this.isolateStale(lockPath, inspection); - continue; + hasCleanupError = true; + cleanupError = error; + } + try { + this.removeIntent(intent); + } catch (error) { + if (!hasCleanupError) { + hasCleanupError = true; + cleanupError = error; } } - return this.completeAcquire(lockPath, fd, options); + } finally { + this.closeIntent(intent); + } + if (hasCleanupError && acquiredHandle !== undefined) acquiredHandle.release(); + if (hasPrimaryError) throw primaryError; + if (hasCleanupError) throw cleanupError; + if (acquiredHandle === undefined) { + throw new Error('cross-process lock acquisition finished without a result'); } - throw heldError(lockPath, 'held', lastHolder); + return acquiredHandle; } async acquireWithWait( @@ -407,7 +469,7 @@ export class CrossProcessLockService implements ICrossProcessLockService { let lastError: CrossProcessLockError | undefined; for (;;) { try { - return this.acquire(lockPath, options); + return await this.acquire(lockPath, options); } catch (error) { if (!(error instanceof CrossProcessLockError) || error.code !== CrossProcessLockErrorCode.Held) { throw error; @@ -488,13 +550,23 @@ export class CrossProcessLockService implements ICrossProcessLockService { return 'held'; } - private isolateStale(lockPath: string, inspection: CrossProcessLockInspection): void { + private async isolateStale( + lockPath: string, + inspection: CrossProcessLockInspection, + creationWindowMs: number, + intentPath: string, + ): Promise { + const current = this.classify(lockPath, creationWindowMs); + if (current.state !== 'stale') return false; + if (inspection.payload?.lockId !== current.payload?.lockId) return false; + await this.beforeStaleIsolation?.(); const rawLockId = inspection.payload?.lockId; const staleLockId = rawLockId !== undefined && rawLockId !== '' ? rawLockId : 'unknown'; try { - renameSync(lockPath, `${lockPath}.stale.${staleLockId}`); + renameSync(lockPath, `${lockPath}.stale.${staleLockId}.${basename(intentPath)}`); + return true; } catch (error) { - if (readErrno(error) === 'ENOENT') return; + if (readErrno(error) === 'ENOENT') return false; throw toLockIoError(error, { path: lockPath, op: 'rename-stale' }); } } @@ -539,14 +611,173 @@ export class CrossProcessLockService implements ICrossProcessLockService { handle.release(); throw lostError(lockPath, 'confirming the newly created payload'); } - if (options.heartbeat !== undefined) { - handle.startHeartbeat(); - } else { - handle.sealPidOnly(); - } + if (options.heartbeat !== undefined) handle.startHeartbeat(); return handle; } + private registerIntent(lockPath: string): RegisteredIntent { + const token = this.newAttemptId(); + const intentPath = `${lockPath}.intent.${token}`; + const startedAt = this.safeProbe(this.selfPid).processStartedAt; + const payload: DiskIntentPayload = { intent_id: token, state: 'active', pid: this.selfPid }; + if (startedAt !== undefined) payload.process_started_at = startedAt; + let fd: number | undefined; + try { + fd = openSync(intentPath, 'wx+', 0o600); + this.writeIntent(fd, payload); + return { path: intentPath, token, fd }; + } catch (error) { + if (fd !== undefined) this.closeFd(fd); + try { + unlinkSync(intentPath); + } catch { + // best effort + } + throw toLockIoError(error, { path: intentPath, op: 'register-intent' }); + } + } + + private markIntentSettled(intent: RegisteredIntent): void { + const current = this.readIntent(intent.path); + if (current?.intent_id !== intent.token) return; + this.writeIntent(intent.fd, { ...current, intent_id: intent.token, state: 'settled' }); + } + + private removeIntent(intent: RegisteredIntent): void { + try { + const current = this.readIntent(intent.path); + if (current?.intent_id !== intent.token) return; + unlinkSync(intent.path); + } catch (error) { + if (readErrno(error) !== 'ENOENT') { + throw toLockIoError(error, { path: intent.path, op: 'remove-intent' }); + } + } + } + + private readIntent(intentPath: string): DiskIntentPayload | undefined { + let raw: string; + try { + raw = readFileSync(intentPath, 'utf8'); + } catch (error) { + if (readErrno(error) === 'ENOENT') return undefined; + throw toLockIoError(error, { path: intentPath, op: 'read-intent' }); + } + try { + const parsed: unknown = JSON.parse(raw); + return parsed !== null && typeof parsed === 'object' ? (parsed as DiskIntentPayload) : undefined; + } catch { + return undefined; + } + } + + private writeIntent(fd: number, payload: DiskIntentPayload): void { + const data = Buffer.from(JSON.stringify(payload), 'utf8'); + writeSync(fd, data, 0, data.length, 0); + ftruncateSync(fd, data.length); + fsyncSync(fd); + } + + private closeIntent(intent: RegisteredIntent): void { + this.closeFd(intent.fd); + } + + private closeFd(fd: number): void { + try { + closeSync(fd); + } catch { + // best effort + } + } + + private async settleAcquire( + handle: ICrossProcessLockHandle, + ownIntentPath: string, + timeoutMs: number, + ): Promise { + const startedAt = this.now(); + const contenders = this.snapshotForeignIntents(handle.lockPath, ownIntentPath); + for (;;) { + if (!handle.checkHeld()) { + throw lostError(handle.lockPath, 'settling concurrent contenders'); + } + if (!this.hasLiveIntent(contenders, timeoutMs)) return; + if (this.now() - startedAt >= timeoutMs) { + throw heldError(handle.lockPath, 'creating', readPayloadFromPath(handle.lockPath)); + } + await this.sleep(DEFAULT_SETTLE_RETRY_INTERVAL_MS); + } + } + + private snapshotForeignIntents( + lockPath: string, + ownIntentPath: string, + ): Set { + const dir = dirname(lockPath); + const prefix = `${basename(lockPath)}.intent.`; + const ownName = basename(ownIntentPath); + let names: string[]; + try { + names = readdirSync(dir); + } catch (error) { + throw toLockIoError(error, { path: dir, op: 'list-intents' }); + } + return new Set( + names + .filter((name) => name.startsWith(prefix) && name !== ownName) + .map((name) => join(dir, name)), + ); + } + + private hasLiveIntent(intentPaths: Set, creationWindowMs: number): boolean { + for (const path of intentPaths) { + let raw: string; + try { + raw = readFileSync(path, 'utf8'); + } catch (error) { + if (readErrno(error) === 'ENOENT') { + intentPaths.delete(path); + continue; + } + throw toLockIoError(error, { path, op: 'read-intent' }); + } + let payload: DiskIntentPayload | undefined; + try { + const parsed: unknown = JSON.parse(raw); + if (parsed !== null && typeof parsed === 'object') payload = parsed as DiskIntentPayload; + } catch { + // handled as a creation-window intent below + } + if (payload?.state === 'settled') { + this.removeIntent({ path, token: payload.intent_id ?? '', fd: -1 }); + intentPaths.delete(path); + continue; + } + const intentPid = payload?.pid; + if (!isProbingPid(intentPid ?? 0)) { + const mtimeMs = this.readMtimeMs(path); + if (mtimeMs !== undefined && this.now() - mtimeMs >= creationWindowMs) { + this.removeIntent({ path, token: payload?.intent_id ?? '', fd: -1 }); + intentPaths.delete(path); + continue; + } + return true; + } + const probed = this.safeProbe(intentPid as number); + const reused = + payload?.process_started_at !== undefined && + probed.processStartedAt !== undefined && + payload.process_started_at !== probed.processStartedAt; + if (!probed.alive || reused) { + this.removeIntent({ path, token: payload?.intent_id ?? '', fd: -1 }); + intentPaths.delete(path); + continue; + } + return true; + } + return false; + } + private safeProbe(pid: number): { alive: boolean; processStartedAt?: string } { try { return this.probe(pid); diff --git a/packages/agent-core-v2/src/os/interface/crossProcessLock.ts b/packages/agent-core-v2/src/os/interface/crossProcessLock.ts index da35aa1b87..598a62f208 100644 --- a/packages/agent-core-v2/src/os/interface/crossProcessLock.ts +++ b/packages/agent-core-v2/src/os/interface/crossProcessLock.ts @@ -2,7 +2,7 @@ * `crossProcessLock` domain (L1) — cross-process exclusive file-lock contract. * * Defines `ICrossProcessLockService`, the single lock protocol that replaces the - * repo's ad-hoc lockfiles (design: `.tmp/refactor-watch-design-v2.md` §3.3). + * repo's ad-hoc lockfiles. * One JSON lock file per resource, created with `O_EXCL`. Protocol invariants: * * - Token-guarded: every acquire generates a fresh `lockId` (ulid); release, @@ -13,16 +13,17 @@ * gone silent (`alive-unresponsive` — alert, never seize). Pid death, or a * pid whose identity no longer matches (pid reused by a new process), makes * the lock stale. - * - Takeover is rename-isolated: the stale file is renamed aside to - * `.stale.` before re-creating, then the new payload is read - * back and confirmed — a creator frozen inside its create window (SIGSTOP) - * cannot silently stomp the new lock when it resumes. + * - Every acquisition attempt registers a process-owned contender intent before + * inspecting the lock. A creator does not return until every older in-flight + * contender has either completed or disappeared. This closes the delayed + * stale-observer race where a contender could quarantine a newer generation + * after its creator had already returned. * - Creation window: an empty or unparseable file younger than the creation * window is "creating" (treated as held, no address yet); only past the * window may it be treated as stale. - * - Heartbeat, for modes that use it, is `pwrite + ftruncate + fsync` on the - * fd kept open from acquire — never tmp+rename, which would let a frozen old - * holder's next beat overwrite the lock that took it over. + * - The fd opened by the winning `O_EXCL` create stays open for the handle's + * lifetime. Heartbeat and payload updates write only through that fd, so a + * holder that lost the public path can never overwrite its successor. * * The on-disk JSON is flat and snake_case, matching operator-facing lock * conventions; the six known protocol keys map to the camelCase fields of @@ -70,7 +71,7 @@ export interface CrossProcessLockWaitOptions { } export interface CrossProcessLockAcquireOptions { - /** Heartbeat mode. Omit for pid-only locks (no fd kept, no beats). */ + /** Heartbeat mode. Omit for pid-only locks. */ readonly heartbeat?: CrossProcessLockHeartbeatOptions; /** Milliseconds an empty/unparseable lock file counts as "creating" rather than stale. Default 5000 for heartbeat-less modes. */ @@ -133,7 +134,7 @@ export interface ICrossProcessLockService { acquire( lockPath: string, options?: CrossProcessLockAcquireOptions, - ): ICrossProcessLockHandle; + ): Promise; /** Blocking acquisition for short critical sections (lock-in-RMW): retries while the lock is held/creating until `wait.timeoutMs` elapses. */ @@ -171,8 +172,10 @@ export interface CrossProcessLockServiceDeps { readonly selfPid?: number; readonly probeProcess?: ProcessProbe; readonly newLockId?: () => string; + readonly newAttemptId?: () => string; readonly instanceId?: string; readonly sleep?: (ms: number) => Promise; + readonly beforeStaleIsolation?: () => void | Promise; } export const OsLockErrors = { diff --git a/packages/agent-core-v2/src/persistence/backends/memory/inMemoryStorageService.ts b/packages/agent-core-v2/src/persistence/backends/memory/inMemoryStorageService.ts index cf5f716c0e..f82948a76f 100644 --- a/packages/agent-core-v2/src/persistence/backends/memory/inMemoryStorageService.ts +++ b/packages/agent-core-v2/src/persistence/backends/memory/inMemoryStorageService.ts @@ -38,6 +38,7 @@ export class InMemoryStorageService implements IFileSystemStorageService { private readonly scopes = new Map>(); private readonly watchers = new Map(); + private readonly operationQueues = new Map>(); async read(scope: string, key: string): Promise { return this.scopes.get(scope)?.get(key); @@ -131,6 +132,20 @@ export class InMemoryStorageService implements IFileSystemStorageService { }; } + runExclusive(scope: string, key: string, op: () => Promise): Promise { + const id = this.watchKey(scope, key); + const previous = this.operationQueues.get(id) ?? Promise.resolve(); + const result = previous.then(op); + const tail = result.then( + () => undefined, + () => undefined, + ); + this.operationQueues.set(id, tail); + return result.finally(() => { + if (this.operationQueues.get(id) === tail) this.operationQueues.delete(id); + }); + } + private notifyWatchers(scope: string, key: string): void { this.watchers.get(this.watchKey(scope, key))?.emitter.fire(); } diff --git a/packages/agent-core-v2/src/persistence/backends/node-fs/appendLogStore.ts b/packages/agent-core-v2/src/persistence/backends/node-fs/appendLogStore.ts index 4421f34a7e..4ca54db20d 100644 --- a/packages/agent-core-v2/src/persistence/backends/node-fs/appendLogStore.ts +++ b/packages/agent-core-v2/src/persistence/backends/node-fs/appendLogStore.ts @@ -9,9 +9,10 @@ * Serializes whole-log rewrites with live appends, preserves queued or * in-flight records across the atomic replacement, keeps ambiguous append and * rewrite failures sticky, keeps the shared flush pending until the - * post-rewrite drain is durable, waits every key before a global flush reports - * an error, and preserves per-key storage ordering while acquired buffers - * retire and hand off to replacement owners. Session-scoped writes (journal + * post-rewrite drain is durable, supports scoped durability barriers, waits + * every selected key before a flush reports an error, and preserves failed + * retired buffers so their in-memory tail remains observable while replacement + * owners wait for the prior generation. Session-scoped writes (journal * bytes under `sessions//`) are fenced: `drain` and `rewrite` * re-verify the session's registered `ISessionWriteAuthority` through * `IWriteAuthorityRegistry` immediately before bytes hit storage, a session @@ -135,14 +136,12 @@ export class AppendLogStore implements IAppendLogStore { await this.ownFlush(scope, key, state, rewrite); } - async flush(): Promise { + async flush(scopePrefix?: string): Promise { const inFlight: Promise[] = []; for (const [id, state] of this.logs) { const { scope, key } = fromLogId(id); + if (!matchesScope(scope, scopePrefix)) continue; inFlight.push(this.flushState(scope, key, state)); - // A retired buffer's fire-and-forget final flush must settle before a - // global flush reports, or the close path can return with the session - // tail still in flight. Its errors stay swallowed per the release path. if (state.retirement !== undefined) inFlight.push(state.retirement); } const settled = await Promise.allSettled(inFlight); @@ -208,16 +207,14 @@ export class AppendLogStore implements IAppendLogStore { state.refCount--; if (state.refCount > 0) return; state.retired = true; - state.retirement = this.settleRetiredState(scope, key, state).catch(() => undefined); + state.retirement = this.settleRetiredState(scope, key, state); + void state.retirement.catch((error) => state.onError?.(error)); } private async settleRetiredState(scope: string, key: string, state: LogState): Promise { - try { - await this.flushState(scope, key, state); - } finally { - const id = logId(scope, key); - if (this.logs.get(id) === state) this.logs.delete(id); - } + await this.flushState(scope, key, state); + const id = logId(scope, key); + if (this.logs.get(id) === state) this.logs.delete(id); } private ownFlush( @@ -302,6 +299,10 @@ function fromLogId(id: string): { scope: string; key: string } { return { scope: id.slice(0, index), key: id.slice(index + 1) }; } +function matchesScope(scope: string, scopePrefix: string | undefined): boolean { + return scopePrefix === undefined || scope === scopePrefix || scope.startsWith(`${scopePrefix}/`); +} + function encodeBatch(records: readonly unknown[]): Uint8Array { if (records.length === 0) return new Uint8Array(0); const content = records.map((record) => JSON.stringify(record) + '\n').join(''); diff --git a/packages/agent-core-v2/src/persistence/backends/node-fs/atomicDocumentStore.ts b/packages/agent-core-v2/src/persistence/backends/node-fs/atomicDocumentStore.ts index ca9532299b..7388b94ac9 100644 --- a/packages/agent-core-v2/src/persistence/backends/node-fs/atomicDocumentStore.ts +++ b/packages/agent-core-v2/src/persistence/backends/node-fs/atomicDocumentStore.ts @@ -48,6 +48,7 @@ export const tomlDocumentCodec: DocumentCodec = { class AtomicDocumentStoreBase implements IAtomicDocumentStore { declare readonly _serviceBrand: undefined; + private readonly operationQueues = new Map>(); constructor( private readonly storage: IFileSystemStorageService, @@ -75,6 +76,35 @@ class AtomicDocumentStoreBase implements IAtomicDocumentStore { await this.storage.write(scope, key, this.codec.encode(value), { atomic: true }); } + update( + scope: string, + key: string, + mutate: (current: T | undefined) => T | Promise, + ): Promise { + return this.runExclusive(scope, key, async () => { + const next = await mutate(await this.get(scope, key)); + await this.set(scope, key, next); + return next; + }); + } + + runExclusive(scope: string, key: string, op: () => Promise): Promise { + if (this.storage.runExclusive !== undefined) { + return this.storage.runExclusive(scope, key, op); + } + const id = `${scope}\0${key}`; + const previous = this.operationQueues.get(id) ?? Promise.resolve(); + const result = previous.then(op); + const tail = result.then( + () => undefined, + () => undefined, + ); + this.operationQueues.set(id, tail); + return result.finally(() => { + if (this.operationQueues.get(id) === tail) this.operationQueues.delete(id); + }); + } + async delete(scope: string, key: string): Promise { await this.storage.delete(scope, key); } diff --git a/packages/agent-core-v2/src/persistence/backends/node-fs/fileStorageService.ts b/packages/agent-core-v2/src/persistence/backends/node-fs/fileStorageService.ts index c1e1286d05..43bd55e6cb 100644 --- a/packages/agent-core-v2/src/persistence/backends/node-fs/fileStorageService.ts +++ b/packages/agent-core-v2/src/persistence/backends/node-fs/fileStorageService.ts @@ -10,6 +10,8 @@ * fsync, so the replacement is both atomic and durable. * - `append` → `open('a')` + write + `fh.sync()` (when `durable`), plus a * one-time directory fsync per scope. + * - `runExclusive` → the shared cross-process lock protocol on + * `.lock`, bounding lock waits to 10 seconds. * - `watch` → chokidar on the parent directory, filtered to the exact key and * debounced, so it survives atomic-replace renames and observes a * file that does not exist yet at subscription time. @@ -27,9 +29,15 @@ import { FSWatcher } from 'chokidar'; import { dirname, join, normalize } from 'pathe'; import { DisposableStore, combinedDisposable, toDisposable, type IDisposable } from '#/_base/di/lifecycle'; +import { optional } from '#/_base/di/instantiation'; import { Emitter, type Event } from '#/_base/event'; import { onUnexpectedError } from '#/_base/errors/unexpectedError'; import { atomicWrite, syncDir } from '#/_base/utils/fs'; +import { + CrossProcessLockError, + CrossProcessLockErrorCode, + ICrossProcessLockService, +} from '#/os/interface/crossProcessLock'; import type { IFileSystemStorageService, @@ -37,9 +45,10 @@ import type { StorageReadRange, StorageWriteOptions, } from '#/persistence/interface/storage'; -import { toStorageIoError } from '#/persistence/interface/storage'; +import { StorageError, StorageErrors, toStorageIoError } from '#/persistence/interface/storage'; const WATCH_DEBOUNCE_MS = 150; +const STORAGE_LOCK_WAIT_TIMEOUT_MS = 10_000; function isEnoent(error: unknown): boolean { return (error as NodeJS.ErrnoException).code === 'ENOENT'; @@ -54,6 +63,7 @@ export class FileStorageService implements IFileSystemStorageService { private readonly baseDir: string, private readonly dirMode?: number, private readonly fileMode?: number, + @optional(ICrossProcessLockService) private readonly locks?: ICrossProcessLockService, ) {} async read(scope: string, key: string): Promise { @@ -220,6 +230,37 @@ export class FileStorageService implements IFileSystemStorageService { }; } + async runExclusive(scope: string, key: string, op: () => Promise): Promise { + const filePath = this.path(scope, key); + const lockPath = `${filePath}.lock`; + if (this.locks === undefined) { + throw new StorageError( + StorageErrors.codes.STORAGE_IO_FAILED, + 'file storage transaction requires a cross-process lock service', + { details: { path: filePath, op: 'lock' } }, + ); + } + try { + return await this.locks.withLock( + lockPath, + { wait: { timeoutMs: STORAGE_LOCK_WAIT_TIMEOUT_MS } }, + op, + ); + } catch (error) { + if (!(error instanceof CrossProcessLockError)) throw error; + if ( + error.code === CrossProcessLockErrorCode.Held || + error.code === CrossProcessLockErrorCode.WaitTimeout + ) { + throw new StorageError(StorageErrors.codes.STORAGE_LOCKED, 'storage transaction is locked', { + details: { path: filePath, op: 'lock' }, + cause: error, + }); + } + throw toStorageIoError(error, { path: lockPath, op: 'lock' }); + } + } + async flush(): Promise { } diff --git a/packages/agent-core-v2/src/persistence/interface/appendLogStore.ts b/packages/agent-core-v2/src/persistence/interface/appendLogStore.ts index c241836493..82352d20cb 100644 --- a/packages/agent-core-v2/src/persistence/interface/appendLogStore.ts +++ b/packages/agent-core-v2/src/persistence/interface/appendLogStore.ts @@ -15,8 +15,10 @@ * later flush cannot duplicate data by guessing whether storage committed it. * A valid explicit `rewrite` is the recovery boundary: a successful atomic * replacement clears that failure before the preserved live tail drains. - * `flush` and `close` wait for every keyed buffer to settle before reporting - * the first failure in stable key insertion order. + * `flush(scopePrefix)` waits for the matching scope and descendants; omitting + * the prefix and `close` wait for every keyed buffer. Retired generations stay + * reachable after a failed final flush so later flushes report the same sticky + * failure instead of silently losing the in-memory tail. * * This file ships the interface, error class, and DI token only. * The concrete `AppendLogStore` implementation lives in @@ -52,7 +54,7 @@ export interface IAppendLogStore { append(scope: string, key: string, record: R, options?: AppendLogOptions): void; read(scope: string, key: string): AsyncIterable; rewrite(scope: string, key: string, records: readonly R[]): Promise; - flush(): Promise; + flush(scopePrefix?: string): Promise; close(): Promise; acquire(scope: string, key: string): IDisposable; } diff --git a/packages/agent-core-v2/src/persistence/interface/atomicDocumentStore.ts b/packages/agent-core-v2/src/persistence/interface/atomicDocumentStore.ts index 2d109d49b3..2a413f13ab 100644 --- a/packages/agent-core-v2/src/persistence/interface/atomicDocumentStore.ts +++ b/packages/agent-core-v2/src/persistence/interface/atomicDocumentStore.ts @@ -24,6 +24,12 @@ export interface IAtomicDocumentStore { get(scope: string, key: string): Promise; set(scope: string, key: string, value: T): Promise; + update( + scope: string, + key: string, + mutate: (current: T | undefined) => T | Promise, + ): Promise; + runExclusive(scope: string, key: string, op: () => Promise): Promise; delete(scope: string, key: string): Promise; list(scope: string, prefix?: string): Promise; watch(scope: string, key: string): Event; diff --git a/packages/agent-core-v2/src/persistence/interface/storage.ts b/packages/agent-core-v2/src/persistence/interface/storage.ts index 83c57ca17d..5bbe5a8499 100644 --- a/packages/agent-core-v2/src/persistence/interface/storage.ts +++ b/packages/agent-core-v2/src/persistence/interface/storage.ts @@ -7,6 +7,8 @@ * * - `write` — atomic whole-value replacement (the `Config` access pattern). * - `append` — ordered, durable byte extension (the `Record` access pattern). + * - `runExclusive` — keyed read-modify-write exclusion when the backend can + * coordinate independent processes. * * They are not interchangeable: building `append` on top of `write` is O(n) * per append, and building `write` on top of `append` yields awkward "read @@ -128,6 +130,7 @@ export interface IFileSystemStorageService { list(scope: string, prefix?: string): Promise; delete(scope: string, key: string): Promise; watch?(scope: string, key: string): Event; + runExclusive?(scope: string, key: string, op: () => Promise): Promise; flush(): Promise; close(): Promise; } diff --git a/packages/agent-core-v2/src/persistence/interface/writeAuthority.ts b/packages/agent-core-v2/src/persistence/interface/writeAuthority.ts index a546399953..f2ce8567d4 100644 --- a/packages/agent-core-v2/src/persistence/interface/writeAuthority.ts +++ b/packages/agent-core-v2/src/persistence/interface/writeAuthority.ts @@ -2,9 +2,8 @@ * `persistence/interface` — session-scoped write fencing contract. * * Defines `ISessionWriteAuthority`, the per-session lease proof that a Store - * write must re-verify immediately before its bytes hit storage (design: - * `.tmp/refactor-watch-design-v2.md` §3.4.2/§3.4.5 — the pre-commit lease - * re-read is the hard gate and must fail closed), and the App-scoped + * write must re-verify immediately before its bytes hit storage (the + * pre-commit lease re-read is the hard gate and must fail closed), and the App-scoped * `IWriteAuthorityRegistry` the `AppendLogStore` resolves authorities through. * The registry never creates semantics of its own: it only maps `sessionId` * to the authority the session lifecycle registered, so a write for a diff --git a/packages/agent-core-v2/src/session/agentLifecycle/agentLifecycleService.ts b/packages/agent-core-v2/src/session/agentLifecycle/agentLifecycleService.ts index 4a7163acfa..548bf4cdf9 100644 --- a/packages/agent-core-v2/src/session/agentLifecycle/agentLifecycleService.ts +++ b/packages/agent-core-v2/src/session/agentLifecycle/agentLifecycleService.ts @@ -213,9 +213,9 @@ export class AgentLifecycleService extends Disposable implements IAgentLifecycle handle.accessor.get(IImageConfigBridge); handle.accessor.get(IAgentToolDedupeService); handle.accessor.get(IAgentExternalHooksService); - // File fencing hooks Read/Write/Edit on the shared Session file ledger: - // resolve after externalHooks so permission/external hook participants - // register first on the executor's hook slots. + // File fencing wraps Read/Write/Edit execution on the shared Session file + // ledger. Resolve after externalHooks so permission and external preflight + // participants register first. handle.accessor.get(IAgentFileFencingService); handle.accessor.get(IAgentMcpService); // Agent plugin service: registers main-agent-only plugin session-start diff --git a/packages/agent-core-v2/src/session/errors.ts b/packages/agent-core-v2/src/session/errors.ts index d9fc6d255c..e1b74998f6 100644 --- a/packages/agent-core-v2/src/session/errors.ts +++ b/packages/agent-core-v2/src/session/errors.ts @@ -16,6 +16,7 @@ export const SessionErrors = { SESSION_INIT_FAILED: 'session.init_failed', SESSION_HELD_BY_PEER: 'session.held_by_peer', SESSION_LEASE_LOST: 'session.lease_lost', + SESSION_DURABILITY_FAILED: 'session.durability_failed', }, retryable: ['session.fork_active_turn'], } as const satisfies ErrorDomain; diff --git a/packages/agent-core-v2/src/session/sessionFileLedger/fileLedger.ts b/packages/agent-core-v2/src/session/sessionFileLedger/fileLedger.ts index 975b79a433..5674f74556 100644 --- a/packages/agent-core-v2/src/session/sessionFileLedger/fileLedger.ts +++ b/packages/agent-core-v2/src/session/sessionFileLedger/fileLedger.ts @@ -4,12 +4,11 @@ * Defines the `ISessionFileLedger` that remembers, per normalized absolute * path, the on-disk stat tuple (`ino`, `mtimeMs`, `size`, existence) this * session last successfully read or wrote, together with the - * `sessionFsWatch` tick captured at that moment. Before a Write/Edit, - * `compare` decides from the ledger entry plus live dirty signals whether - * the target was modified outside this session, punching a single stat only - * when a dirty signal is newer than the baseline (an unchanged tuple means - * the signal was the session's own write echo). Targets outside every - * watched root degrade to the same comparison without dirty signals. + * `sessionFsWatch` tick captured before that stat. Before every Write/Edit, + * `compare` stats the target again and compares the tuple directly; live dirty + * signals classify watcher echoes and truncated windows but are never the + * correctness gate. This avoids both debounce latency and delayed watcher + * delivery turning into a stale-write allowance. * * The verdict drives the write-path policy: `clean` lets the call through, * `stale` means the file diverged since the baseline (or a path-level dirty diff --git a/packages/agent-core-v2/src/session/sessionFileLedger/fileLedgerService.ts b/packages/agent-core-v2/src/session/sessionFileLedger/fileLedgerService.ts index f0b12137f7..fc76f809ba 100644 --- a/packages/agent-core-v2/src/session/sessionFileLedger/fileLedgerService.ts +++ b/packages/agent-core-v2/src/session/sessionFileLedger/fileLedgerService.ts @@ -2,12 +2,11 @@ * `sessionFileLedger` domain (L2) — `ISessionFileLedger` implementation. * * In-memory per-session ledger of on-disk stat tuples keyed by - * `normalizeFsWatchKey`. `recordBaseline` re-stats the target through - * `IHostFileSystem` and stores the tuple with the watch service's current - * tick; `compare` consults only already-folded `sessionFsWatch` dirty state - * (it never awaits watcher frames) and degrades gracefully: stat failures - * other than not-found yield `clean` rather than blocking the write path, - * and paths outside every watched root get a stat-only comparison. The + * `normalizeFsWatchKey`. `recordBaseline` captures the watch tick before it + * re-stats the target, so a concurrent event cannot be absorbed into an older + * tuple. `compare` always re-stats immediately before a write decision; + * watcher ticks classify the result but never replace the stat. An + * unverifiable stat fails closed as `stale`. The * service also guarantees `ISessionFsWatchService` watches the session roots * (`workDir` + `additionalDirs` from `ISessionWorkspaceContext`), adding an * unwatched containing root additively whenever a Write/Edit target falls @@ -52,9 +51,10 @@ export class SessionFileLedger implements ISessionFileLedger { async recordBaseline(path: string): Promise { this.ensureWatchedRootFor(path); + const tick = this.watch.currentTick; const tuple = await this.tryStat(path); if (tuple === undefined) return; - this.entries.set(normalizeFsWatchKey(path), { ...tuple, tick: this.watch.currentTick }); + this.entries.set(normalizeFsWatchKey(path), { ...tuple, tick }); } async compare(path: string): Promise { @@ -62,27 +62,18 @@ export class SessionFileLedger implements ISessionFileLedger { const key = normalizeFsWatchKey(path); const entry = this.entries.get(key); const root = this.containingWatchedRoot(key); - if (root === undefined) { - const current = await this.tryStat(path); - if (current === undefined) return 'clean'; - if (entry === undefined) return current.exists ? 'no-baseline' : 'clean'; - return fileStatTuplesEqual(entry, current) ? 'clean' : 'stale'; - } - if (entry === undefined) { - if ((this.watch.dirtyTickFor(path) ?? 0) > 0) return 'stale'; - const current = await this.tryStat(path); - if (current === undefined) return 'clean'; - return current.exists ? 'no-baseline' : 'clean'; - } + const current = await this.tryStat(path); + if (current === undefined) return 'stale'; const dirty = Math.max( this.watch.dirtyTickFor(path) ?? 0, - this.watch.rootDirtyTickFor(root) ?? 0, + root === undefined ? 0 : (this.watch.rootDirtyTickFor(root) ?? 0), ); - if (dirty <= entry.tick) return 'clean'; - const current = await this.tryStat(path); - if (current === undefined) return 'clean'; + if (entry === undefined) { + if (dirty > 0) return current.exists ? 'stale' : 'clean'; + return current.exists ? 'no-baseline' : 'clean'; + } if (fileStatTuplesEqual(entry, current)) { - this.entries.set(key, { ...entry, tick: dirty }); + if (dirty > entry.tick) this.entries.set(key, { ...current, tick: dirty }); return 'clean'; } return 'stale'; diff --git a/packages/agent-core-v2/src/session/sessionFs/fsWatch.ts b/packages/agent-core-v2/src/session/sessionFs/fsWatch.ts index ac3acdfe44..6b36821266 100644 --- a/packages/agent-core-v2/src/session/sessionFs/fsWatch.ts +++ b/packages/agent-core-v2/src/session/sessionFs/fsWatch.ts @@ -11,7 +11,7 @@ * Beyond the change feed, every confined change entry is folded into a * per-session dirty state for optimistic-concurrency consumers * (`sessionFileLedger`): a monotonic `currentTick` incremented per processed - * entry, per-path dirty ticks folded when a debounce window flushes, and + * entry, immediate per-path dirty ticks independent of debounce delivery, and * per-root dirty ticks for truncated windows whose exact paths were dropped. * Client subscriptions (`setWatchedPaths`, replace semantics) and * optimistic-concurrency watch anchors (`ensureWatchedRoots`, additive, diff --git a/packages/agent-core-v2/src/session/sessionFs/fsWatchService.ts b/packages/agent-core-v2/src/session/sessionFs/fsWatchService.ts index 65e11fc588..e0c882805e 100644 --- a/packages/agent-core-v2/src/session/sessionFs/fsWatchService.ts +++ b/packages/agent-core-v2/src/session/sessionFs/fsWatchService.ts @@ -17,12 +17,11 @@ * into dirty state. The os workDir watcher runs while either set is * non-empty. * - * Dirty state: every confined change entry gets the next monotonic tick and - * is buffered with it; at flush the entries fold into - * `dirtyTicks[normalizedAbs]`, and a truncated window (exact paths dropped) - * conservatively folds `dirtyRootTicks` for every watched root at the - * window's last tick. Clearing a window without flushing still folds the - * buffered entries so in-flight dirty signals are never silently dropped. + * Dirty state is independent from event delivery: every confined raw change + * immediately advances `dirtyTicks[normalizedAbs]`, while protocol events are + * still buffered and debounced. A truncated window additionally advances + * `dirtyRootTicks` for every watched root. This keeps the optimistic + * concurrency ledger from inheriting the UI debounce window. * Key normalization is the shared `normalizeFsWatchKey` from the contract. */ @@ -247,11 +246,15 @@ export class SessionFsWatchService extends Disposable implements ISessionFsWatch private record(e: HostFsChange, rel: string | undefined): void { this.tick += 1; + this.dirtyTicks.set(normalizeFsWatchKey(e.path), this.tick); this.pending.push({ abs: e.path, rel, tick: this.tick, change: e.action, kind: e.kind }); this.rawCount += 1; if (this.pending.length > this.maxChangesPerWindow) { this.truncated = true; this.pending = []; + for (const root of this.watchedRoots) { + this.dirtyRootTicks.set(normalizeFsWatchKey(root), this.tick); + } } if (this.debounceTimer === undefined) { const timer = setTimeout(() => this.flush(), this.debounceMs); @@ -274,10 +277,6 @@ export class SessionFsWatchService extends Disposable implements ISessionFsWatch for (const root of this.watchedRoots) { this.dirtyRootTicks.set(normalizeFsWatchKey(root), this.tick); } - } else { - for (const entry of pending) { - this.dirtyTicks.set(normalizeFsWatchKey(entry.abs), entry.tick); - } } const changes = truncated @@ -304,8 +303,10 @@ export class SessionFsWatchService extends Disposable implements ISessionFsWatch clearTimeout(this.debounceTimer); this.debounceTimer = undefined; } - for (const entry of this.pending) { - this.dirtyTicks.set(normalizeFsWatchKey(entry.abs), entry.tick); + if (this.truncated) { + for (const root of this.watchedRoots) { + this.dirtyRootTicks.set(normalizeFsWatchKey(root), this.tick); + } } this.pending = []; this.rawCount = 0; diff --git a/packages/agent-core-v2/src/session/sessionLease/sessionLease.ts b/packages/agent-core-v2/src/session/sessionLease/sessionLease.ts index e5fd6baeb0..d845e7d737 100644 --- a/packages/agent-core-v2/src/session/sessionLease/sessionLease.ts +++ b/packages/agent-core-v2/src/session/sessionLease/sessionLease.ts @@ -6,9 +6,8 @@ * and the `SessionLease` object that satisfies it: an App-owned wrapper * (`SessionLifecycleService` builds it; it is deliberately not a DI service) * around the cross-process lock handle at - * `/session-leases/.json`. `assertWritable` is the - * Quint-verified hard gate (design: `.tmp/refactor-watch-design-v2.md` - * §3.4.2/§3.4.5): it synchronously re-reads the on-disk lease payload and + * `/session-leases/.json`. `assertWritable` is the hard + * gate: it synchronously re-reads the on-disk lease payload and * compares the held `lockId` — a mismatch fails closed with * `session.lease_lost`, marks the lease lost, and fires the loss callback * exactly once so the owning session tears itself down. Release order is the @@ -33,8 +32,6 @@ export const SESSION_LEASE_HEARTBEAT_INTERVAL_MS = 2000; export const SESSION_LEASE_TTL_MS = 6000; export const LEASE_CREATING_RETRY_AFTER_MS = 1000; export const HOLDER_UNRESPONSIVE_RETRY_AFTER_MS = 2000; -export const UNREGISTERED_WRITER_WINDOW_MS = 5000; -export const UNREGISTERED_WRITER_RECHECK_DELAY_MS = 1000; /** `details` payload of `session.held_by_peer` errors; the zod twin lives in packages/protocol (`sessionOwnershipDetailsSchema`) and the shapes must diff --git a/packages/agent-core-v2/src/session/sessionLease/sessionLeaseContactProvider.ts b/packages/agent-core-v2/src/session/sessionLease/sessionLeaseContactProvider.ts index 1523cc79bc..c9374ed9c1 100644 --- a/packages/agent-core-v2/src/session/sessionLease/sessionLeaseContactProvider.ts +++ b/packages/agent-core-v2/src/session/sessionLease/sessionLeaseContactProvider.ts @@ -5,9 +5,8 @@ * `{type: 'address', address}` when the host runs a routable network service * (kap-server seeds its listening address), `{type: 'local'}` otherwise — * the value is recorded in the lease payload so a blocked peer can tell a - * routable holder from a local-only one (design: - * `.tmp/refactor-watch-design-v2.md` §3.4.1 — the discriminated - * `contact` union landing as the flat `address?` payload field). Read + * routable holder from a local-only one (the discriminated `contact` union + * lands as the flat `address?` payload field). Read * lazily at every lease acquisition through the `contact` thunk, so a host * whose address is only known after listen can seed a provider that closes * over it. Composition roots override the local-only default through diff --git a/packages/agent-core-v2/src/session/sessionMetadata/sessionMetadataService.ts b/packages/agent-core-v2/src/session/sessionMetadata/sessionMetadataService.ts index 4d234fe9d4..d483ea9db4 100644 --- a/packages/agent-core-v2/src/session/sessionMetadata/sessionMetadataService.ts +++ b/packages/agent-core-v2/src/session/sessionMetadata/sessionMetadataService.ts @@ -16,8 +16,8 @@ * never reorders session listings. Every durable write passes the * `sessionLease` hard gate first (`ISessionLeaseService.assertWritable`, * synchronously re-reading the lease payload), so an instance that lost the - * session lease fails closed instead of overwriting a live peer's state - * (design: `.tmp/refactor-watch-design-v2.md` §3.4.5). Bound at Session scope. + * session lease fails closed instead of overwriting a live peer's state. + * Bound at Session scope. * * Read-model mirroring (flag `persistence_minidb_readmodel`): after a metadata * update is persisted, the fresh summary is mirrored into the `IQueryStore` diff --git a/packages/agent-core-v2/test/_base/di/auto-inject.test.ts b/packages/agent-core-v2/test/_base/di/auto-inject.test.ts index c8f2eca181..f969257a62 100644 --- a/packages/agent-core-v2/test/_base/di/auto-inject.test.ts +++ b/packages/agent-core-v2/test/_base/di/auto-inject.test.ts @@ -2,7 +2,7 @@ import { describe, expect, it } from 'vitest'; import { SyncDescriptor } from '#/_base/di/descriptors'; import { CyclicDependencyError } from '#/_base/di/errors'; -import { IInstantiationService, createDecorator } from '#/_base/di/instantiation'; +import { IInstantiationService, createDecorator, optional } from '#/_base/di/instantiation'; import { InstantiationService } from '#/_base/di/instantiationService'; import { ServiceCollection } from '#/_base/di/serviceCollection'; @@ -66,6 +66,50 @@ describe('@IFoo auto-injection', () => { expect(bar.baz).toBeInstanceOf(Baz); }); + it('injects undefined for an unregistered optional service in strict mode', () => { + interface IDependency { + tag: 'dependency'; + } + const IDependency = createDecorator('p1.1-IDependency-optional-missing'); + class Consumer { + constructor(@optional(IDependency) readonly dependency?: IDependency) {} + } + const IConsumer = createDecorator('p1.1-IConsumer-optional-missing'); + const ix = new InstantiationService( + new ServiceCollection([IConsumer, new SyncDescriptor(Consumer)]), + true, + ); + + const consumer = ix.invokeFunction((accessor) => accessor.get(IConsumer)); + + expect(consumer.dependency).toBeUndefined(); + }); + + it('resolves a registered optional service normally', () => { + interface IDependency { + tag: 'dependency'; + } + const IDependency = createDecorator('p1.1-IDependency-optional-present'); + class Dependency implements IDependency { + tag = 'dependency' as const; + } + class Consumer { + constructor(@optional(IDependency) readonly dependency?: IDependency) {} + } + const IConsumer = createDecorator('p1.1-IConsumer-optional-present'); + const ix = new InstantiationService( + new ServiceCollection( + [IDependency, new SyncDescriptor(Dependency)], + [IConsumer, new SyncDescriptor(Consumer)], + ), + true, + ); + + const consumer = ix.invokeFunction((accessor) => accessor.get(IConsumer)); + + expect(consumer.dependency).toBeInstanceOf(Dependency); + }); + it('@IInstantiationService self-injection resolves to the OWNING container', () => { class Widget { constructor(public readonly label: string) {} diff --git a/packages/agent-core-v2/test/agent/fileFencing/fileFencing.test.ts b/packages/agent-core-v2/test/agent/fileFencing/fileFencing.test.ts index c0e061e146..eac937bf9f 100644 --- a/packages/agent-core-v2/test/agent/fileFencing/fileFencing.test.ts +++ b/packages/agent-core-v2/test/agent/fileFencing/fileFencing.test.ts @@ -181,9 +181,20 @@ async function runBefore( ctx: ToolBeforeExecuteContext, ): Promise { await world.executor.hooks.onBeforeExecuteTool.run(ctx); + await runPrepared(ctx); return ctx; } +async function runPrepared(ctx: ToolBeforeExecuteContext): Promise { + if (ctx.decision?.execute === undefined) return; + await ctx.decision.execute({ + turnId: ctx.turnId, + toolCallId: ctx.toolCall.id, + trace: ctx.trace, + signal: ctx.signal, + }); +} + async function runDid( world: AgentWorld, ctx: ToolBeforeExecuteContext, @@ -268,6 +279,42 @@ describe('AgentFileFencingService', () => { expect(retry.decision?.block).not.toBe(true); }); + it('checks the file at execution time rather than during preflight', async () => { + multiServer = true; + const world = setup(); + const file = join(world.env.workDir, 'a.txt'); + writeFileSync(file, 'hello'); + await runOk(world, 'Read', file); + + const ctx = beforeCtx('Edit', file); + await world.executor.hooks.onBeforeExecuteTool.run(ctx); + writeFileSync(file, 'changed while queued'); + + await runPrepared(ctx); + expect(ctx.decision?.block).toBe(true); + expect(ctx.decision?.reason).toContain('changed on disk since'); + }); + + it('wraps an execution override installed by an earlier hook', async () => { + const world = setup(); + const file = join(world.env.workDir, 'a.txt'); + writeFileSync(file, 'hello'); + await runOk(world, 'Read', file); + let overrideCalls = 0; + const ctx = beforeCtx('Edit', file); + ctx.decision = { + execute: async () => { + overrideCalls++; + return { output: 'overridden' }; + }, + }; + + await world.executor.hooks.onBeforeExecuteTool.run(ctx); + await runPrepared(ctx); + + expect(overrideCalls).toBe(1); + }); + it('blocks Write over an existing file that was never read', async () => { multiServer = true; const world = setup(); @@ -329,7 +376,7 @@ describe('AgentFileFencingService', () => { const again = await runBefore(world, beforeCtx('Edit', file)); expect(again.decision?.block).not.toBe(true); - expect(world.env.statCalls()).toBe(2); + expect(world.env.statCalls()).toBe(3); }); it('resolves a truncated window by stat punch: unchanged passes, changed blocks', async () => { diff --git a/packages/agent-core-v2/test/agent/goal/goalOps.test.ts b/packages/agent-core-v2/test/agent/goal/goalOps.test.ts index 1ba42f3bbd..de1d592bb9 100644 --- a/packages/agent-core-v2/test/agent/goal/goalOps.test.ts +++ b/packages/agent-core-v2/test/agent/goal/goalOps.test.ts @@ -89,7 +89,10 @@ function createTelemetryStub(): ITelemetryService { function createToolExecutorStub(): IAgentToolExecutorService { return { _serviceBrand: undefined, - hooks: { onBeforeExecuteTool: hookSlot(), onDidExecuteTool: hookSlot() }, + hooks: { + onBeforeExecuteTool: hookSlot(), + onDidExecuteTool: hookSlot(), + }, } as unknown as IAgentToolExecutorService; } diff --git a/packages/agent-core-v2/test/agent/toolExecutor/toolExecutor.test.ts b/packages/agent-core-v2/test/agent/toolExecutor/toolExecutor.test.ts index fbf9eba0ff..e8399f70da 100644 --- a/packages/agent-core-v2/test/agent/toolExecutor/toolExecutor.test.ts +++ b/packages/agent-core-v2/test/agent/toolExecutor/toolExecutor.test.ts @@ -409,6 +409,27 @@ describe('AgentToolExecutorService', () => { expect(second.calls).toHaveLength(1); }); + it('an execution override runs instead of the original tool after scheduling', async () => { + const tool = new TestTool('echo'); + registry.register(tool); + executor.hooks.onBeforeExecuteTool.register('replace-execute', async (ctx) => { + ctx.decision = { + ...ctx.decision, + execute: async () => ({ output: 'changed while queued', isError: true }), + }; + }); + + const results = await execute([toolCall('call_echo', 'echo', { text: 'hi' })]); + + expect(results).toEqual([ + expect.objectContaining({ + output: 'changed while queued', + isError: true, + }), + ]); + expect(tool.calls).toEqual([]); + }); + it('skips later tool calls after an execution requests stopBatchAfterThis', async () => { const first = new TestTool('first', { stopBatchAfterThis: true }); const second = new TestTool('second'); diff --git a/packages/agent-core-v2/test/app/bootstrap/bootstrapService.test.ts b/packages/agent-core-v2/test/app/bootstrap/bootstrapService.test.ts index d3272107e0..aa3e483f9e 100644 --- a/packages/agent-core-v2/test/app/bootstrap/bootstrapService.test.ts +++ b/packages/agent-core-v2/test/app/bootstrap/bootstrapService.test.ts @@ -3,8 +3,12 @@ import { beforeEach, describe, expect, it } from 'vitest'; import { InstantiationType } from '#/_base/di/extensions'; import { LifecycleScope, _clearScopedRegistryForTests, registerScopedService } from '#/_base/di/scope'; import { createScopedTestHost } from '#/_base/di/test'; -import { IBootstrapService, bootstrapSeed, resolveBootstrapOptions } from '#/app/bootstrap/bootstrap'; -import { bootstrap } from '#/app/bootstrap/bootstrap'; +import { + IBootstrapService, + bootstrap, + bootstrapSeed, + resolveBootstrapOptions, +} from '#/app/bootstrap/bootstrap'; import { BootstrapService } from '#/app/bootstrap/bootstrapService'; import { FileStorageService } from '#/persistence/backends/node-fs/fileStorageService'; import { IFileSystemStorageService } from '#/persistence/interface/storage'; @@ -26,10 +30,23 @@ describe('BootstrapService (scoped)', () => { const svc = host.app.accessor.get(IBootstrapService); expect(svc.homeDir).toBe('/tmp/kimi-home'); expect(svc.configPath).toBe('/tmp/kimi-home/config.toml'); + expect(svc.configKey).toBe('config.toml'); expect(svc.sessionsDir).toBe('/tmp/kimi-home/sessions'); host.dispose(); }); + it('addresses a custom config path relative to the storage root', () => { + const host = createScopedTestHost( + bootstrapSeed({ + homeDir: '/tmp/kimi-home', + configPath: '/tmp/custom/config.toml', + }), + ); + const svc = host.app.accessor.get(IBootstrapService); + expect(svc.configKey).toBe('../custom/config.toml'); + host.dispose(); + }); + it('getEnv reads from the seeded env bag', () => { const host = createScopedTestHost(bootstrapSeed({ env: { FOO: 'bar' } })); const svc = host.app.accessor.get(IBootstrapService); diff --git a/packages/agent-core-v2/test/app/config/config.test.ts b/packages/agent-core-v2/test/app/config/config.test.ts index 8b9178970f..75ce0b91d3 100644 --- a/packages/agent-core-v2/test/app/config/config.test.ts +++ b/packages/agent-core-v2/test/app/config/config.test.ts @@ -54,10 +54,8 @@ import { InMemoryStorageService } from '#/persistence/backends/memory/inMemorySt import { IFileSystemStorageService } from '#/persistence/interface/storage'; import { IAtomicTomlDocumentStore } from '#/persistence/interface/atomicDocumentStore'; import { TomlAtomicDocumentStore } from '#/persistence/backends/node-fs/atomicDocumentStore'; -import { ICrossProcessLockService } from '#/os/interface/crossProcessLock'; import { stubBootstrap } from '../bootstrap/stubs'; import { stubLog } from '../../_base/log/stubs'; -import { stubCrossProcessLock } from '../../os/stubs'; const TEST_OS_ENV = { osKind: 'Linux', @@ -370,7 +368,6 @@ describe('ConfigService env overlay (live)', () => { ix.stub(IFileSystemStorageService, new InMemoryStorageService()); ix.set(IAtomicTomlDocumentStore, new SyncDescriptor(TomlAtomicDocumentStore)); ix.set(IConfigRegistry, new SyncDescriptor(ConfigRegistry)); - ix.stub(ICrossProcessLockService, stubCrossProcessLock()); ix.set(IConfigService, new SyncDescriptor(ConfigService)); const config = ix.get(IConfigService); await config.ready; @@ -393,7 +390,6 @@ describe('ConfigService env overlay (live)', () => { ix.stub(IFileSystemStorageService, new InMemoryStorageService()); ix.set(IAtomicTomlDocumentStore, new SyncDescriptor(TomlAtomicDocumentStore)); ix.set(IConfigRegistry, new SyncDescriptor(ConfigRegistry)); - ix.stub(ICrossProcessLockService, stubCrossProcessLock()); ix.set(IConfigService, new SyncDescriptor(ConfigService)); const config = ix.get(IConfigService); await config.ready; @@ -415,7 +411,6 @@ describe('ConfigService env overlay (live)', () => { ix.stub(IFileSystemStorageService, new InMemoryStorageService()); ix.set(IAtomicTomlDocumentStore, new SyncDescriptor(TomlAtomicDocumentStore)); ix.set(IConfigRegistry, new SyncDescriptor(ConfigRegistry)); - ix.stub(ICrossProcessLockService, stubCrossProcessLock()); ix.set(IConfigService, new SyncDescriptor(ConfigService)); const config = ix.get(IConfigService); await config.ready; @@ -437,7 +432,6 @@ describe('ConfigService env overlay (live)', () => { const ix = disposables.add(new TestInstantiationService()); ix.stub(ILogService, stubLog()); ix.stub(IBootstrapService, stubBootstrap('/tmp/kimi-cfg')); - ix.stub(ICrossProcessLockService, stubCrossProcessLock()); ix.stub(IFileSystemStorageService, new InMemoryStorageService()); ix.set(IAtomicTomlDocumentStore, new SyncDescriptor(TomlAtomicDocumentStore)); ix.set(IConfigRegistry, new SyncDescriptor(ConfigRegistry)); @@ -507,7 +501,6 @@ describe('image config section', () => { ix.stub(IFileSystemStorageService, new InMemoryStorageService()); ix.set(IAtomicTomlDocumentStore, new SyncDescriptor(TomlAtomicDocumentStore)); ix.set(IConfigRegistry, new SyncDescriptor(ConfigRegistry)); - ix.stub(ICrossProcessLockService, stubCrossProcessLock()); ix.set(IConfigService, new SyncDescriptor(ConfigService)); const config = ix.get(IConfigService); await config.ready; @@ -542,7 +535,6 @@ describe('task config section', () => { ix.stub(IFileSystemStorageService, new InMemoryStorageService()); ix.set(IAtomicTomlDocumentStore, new SyncDescriptor(TomlAtomicDocumentStore)); ix.set(IConfigRegistry, new SyncDescriptor(ConfigRegistry)); - ix.stub(ICrossProcessLockService, stubCrossProcessLock()); ix.set(IConfigService, new SyncDescriptor(ConfigService)); const config = ix.get(IConfigService); await config.ready; @@ -577,7 +569,6 @@ describe('task config section', () => { ix.stub(IFileSystemStorageService, storage); ix.set(IAtomicTomlDocumentStore, new SyncDescriptor(TomlAtomicDocumentStore)); ix.set(IConfigRegistry, new SyncDescriptor(ConfigRegistry)); - ix.stub(ICrossProcessLockService, stubCrossProcessLock()); ix.set(IConfigService, new SyncDescriptor(ConfigService)); const config = ix.get(IConfigService); await config.ready; @@ -603,7 +594,6 @@ describe('task config section', () => { ix.stub(IFileSystemStorageService, storage); ix.set(IAtomicTomlDocumentStore, new SyncDescriptor(TomlAtomicDocumentStore)); ix.set(IConfigRegistry, new SyncDescriptor(ConfigRegistry)); - ix.stub(ICrossProcessLockService, stubCrossProcessLock()); ix.set(IConfigService, new SyncDescriptor(ConfigService)); const config = ix.get(IConfigService); await config.ready; @@ -675,7 +665,6 @@ describe('subagent config section', () => { ix.stub(IFileSystemStorageService, storage); ix.set(IAtomicTomlDocumentStore, new SyncDescriptor(TomlAtomicDocumentStore)); ix.set(IConfigRegistry, new SyncDescriptor(ConfigRegistry)); - ix.stub(ICrossProcessLockService, stubCrossProcessLock()); ix.set(IConfigService, new SyncDescriptor(ConfigService)); const config = ix.get(IConfigService); await config.ready; diff --git a/packages/agent-core-v2/test/app/config/configFileMutex.test.ts b/packages/agent-core-v2/test/app/config/configFileMutex.test.ts index 799e7bbc81..72dca42989 100644 --- a/packages/agent-core-v2/test/app/config/configFileMutex.test.ts +++ b/packages/agent-core-v2/test/app/config/configFileMutex.test.ts @@ -1,20 +1,17 @@ /** - * Scenario: config.toml cross-process lock-in-RMW (design: - * .tmp/refactor-watch-design-v2.md §3.6). + * Scenario: config.toml atomic read-modify-write. * - * Two independent `ConfigService` instances share one home dir on the real - * filesystem; interleaved writes must merge without lost updates, the lock - * file must be released after each critical section, and a held lock must - * surface OS_LOCK_WAIT_TIMEOUT while leaving config.toml intact. Watch-based - * reloads ride real chokidar (150ms debounce), so assertions poll with real - * timers. Run with `pnpm --filter @moonshot-ai/agent-core-v2 exec vitest run - * test/app/config/configFileMutex.test.ts`. + * Two independent `ConfigService` instances share one storage root on the real + * filesystem. The atomic-document Store owns cross-process exclusion, so + * interleaved writes merge without lost updates and `ConfigService` never + * handles lock paths or lock services itself. Watch-based reloads ride real + * chokidar (150ms debounce), so assertions poll with real timers. */ import { existsSync, readFileSync, readdirSync } from 'node:fs'; import { mkdtemp, rm, writeFile } from 'node:fs/promises'; import { tmpdir } from 'node:os'; -import { join } from 'node:path'; +import { join, relative } from 'node:path'; import { afterEach, beforeEach, describe, expect, it } from 'vitest'; @@ -28,13 +25,16 @@ import { ConfigRegistry, ConfigService } from '#/app/config/configService'; import { CrossProcessLockService } from '#/os/backends/node-local/crossProcessLockService'; import { CrossProcessLockErrorCode, - ICrossProcessLockService, + type ICrossProcessLockService, type ICrossProcessLockHandle, } from '#/os/interface/crossProcessLock'; import { TomlAtomicDocumentStore } from '#/persistence/backends/node-fs/atomicDocumentStore'; import { FileStorageService } from '#/persistence/backends/node-fs/fileStorageService'; import { IAtomicTomlDocumentStore } from '#/persistence/interface/atomicDocumentStore'; -import { IFileSystemStorageService } from '#/persistence/interface/storage'; +import { + IFileSystemStorageService, + StorageErrors, +} from '#/persistence/interface/storage'; import { stubLog } from '../../_base/log/stubs'; import { stubBootstrap } from '../bootstrap/stubs'; @@ -65,13 +65,22 @@ describe('ConfigService config.toml lock-in-RMW', () => { await rm(homeDir, { recursive: true, force: true }); }); - function createContainer(lock?: ICrossProcessLockService): IConfigService { + function createContainer( + lock: ICrossProcessLockService = new CrossProcessLockService(), + configPath = join(homeDir, 'config.toml'), + ): IConfigService { const ix = disposables.add(new TestInstantiationService()); ix.stub(ILogService, stubLog()); - ix.stub(IBootstrapService, stubBootstrap(homeDir, {})); - ix.stub(IFileSystemStorageService, new FileStorageService(homeDir)); + ix.stub(IBootstrapService, { + ...stubBootstrap(homeDir, {}), + configPath, + configKey: relative(homeDir, configPath), + }); + ix.stub( + IFileSystemStorageService, + new FileStorageService(homeDir, undefined, undefined, lock), + ); ix.set(IAtomicTomlDocumentStore, new SyncDescriptor(TomlAtomicDocumentStore)); - ix.stub(ICrossProcessLockService, lock ?? new CrossProcessLockService()); ix.set(IConfigRegistry, new SyncDescriptor(ConfigRegistry)); ix.set(IConfigService, new SyncDescriptor(ConfigService)); return ix.get(IConfigService); @@ -134,7 +143,17 @@ describe('ConfigService config.toml lock-in-RMW', () => { expect(readdirSync(homeDir).filter((entry) => entry.includes('.stale.'))).toEqual([]); }); - it('fails set() with OS_LOCK_WAIT_TIMEOUT while another holder is stuck, leaving config.toml intact', async () => { + it('writes and locks a custom config path at its actual location', async () => { + const configPath = join(homeDir, 'nested', 'custom.toml'); + const config = createContainer(new CrossProcessLockService(), configPath); + await config.set('alphaSection', { one: 1 }); + + expect(readFileSync(configPath, 'utf8')).toContain('[alpha_section]'); + expect(existsSync(join(homeDir, 'custom.toml'))).toBe(false); + expect(existsSync(`${configPath}.lock`)).toBe(false); + }); + + it('fails set() with storage.locked while another holder is stuck, leaving config.toml intact', async () => { let nowValue = 1_000_000; let lockSeq = 0; const victim = new CrossProcessLockService({ @@ -159,10 +178,11 @@ describe('ConfigService config.toml lock-in-RMW', () => { newLockId: () => 'attacker-lock', }); const lockPath = join(homeDir, 'config.toml.lock'); - const handle: ICrossProcessLockHandle = attacker.acquire(lockPath); + const handle: ICrossProcessLockHandle = await attacker.acquire(lockPath); try { await expect(config.set('blockedSection', { no: true })).rejects.toMatchObject({ - code: CrossProcessLockErrorCode.WaitTimeout, + code: StorageErrors.codes.STORAGE_LOCKED, + cause: { code: CrossProcessLockErrorCode.WaitTimeout }, }); expect(readFileSync(join(homeDir, 'config.toml'), 'utf8')).toBe(before); } finally { diff --git a/packages/agent-core-v2/test/app/externalHooksRunner/integration.test.ts b/packages/agent-core-v2/test/app/externalHooksRunner/integration.test.ts index df54d14ec2..7c9ba95386 100644 --- a/packages/agent-core-v2/test/app/externalHooksRunner/integration.test.ts +++ b/packages/agent-core-v2/test/app/externalHooksRunner/integration.test.ts @@ -199,6 +199,7 @@ function stubSessionLifecycle(): ISessionLifecycleService { list: () => [], resume: async () => undefined, close: async () => {}, + forceAbort: async () => {}, archive: async () => {}, restore: async () => undefined, fork: async () => { diff --git a/packages/agent-core-v2/test/app/mcp/mcpConfigWatch.test.ts b/packages/agent-core-v2/test/app/mcp/mcpConfigWatch.test.ts index f12c6a97b0..98acef57c2 100644 --- a/packages/agent-core-v2/test/app/mcp/mcpConfigWatch.test.ts +++ b/packages/agent-core-v2/test/app/mcp/mcpConfigWatch.test.ts @@ -1,6 +1,5 @@ /** - * Scenario: App-scope watch of the user-level mcp.json (design: - * .tmp/refactor-watch-design-v2.md §3.6). + * Scenario: App-scope watch of the user-level mcp.json. * * Valid content fires `onDidChange` (and a fresh `loadMcpServers` observes * the new servers — v2 loads mcp.json per session creation and has no cache diff --git a/packages/agent-core-v2/test/app/sessionExport/sessionExport.test.ts b/packages/agent-core-v2/test/app/sessionExport/sessionExport.test.ts index 0107e08ad4..598671139e 100644 --- a/packages/agent-core-v2/test/app/sessionExport/sessionExport.test.ts +++ b/packages/agent-core-v2/test/app/sessionExport/sessionExport.test.ts @@ -863,6 +863,7 @@ function registerSessionExportServices( list: () => (options.lifecycleHandle === undefined ? [] : [options.lifecycleHandle]), resume: async () => options.lifecycleHandle, close: async () => {}, + forceAbort: async () => {}, archive: async () => {}, restore: async () => options.lifecycleHandle, fork: async () => { diff --git a/packages/agent-core-v2/test/app/sessionLifecycle/sessionLifecycle.test.ts b/packages/agent-core-v2/test/app/sessionLifecycle/sessionLifecycle.test.ts index 1464b37c8e..011bc48a61 100644 --- a/packages/agent-core-v2/test/app/sessionLifecycle/sessionLifecycle.test.ts +++ b/packages/agent-core-v2/test/app/sessionLifecycle/sessionLifecycle.test.ts @@ -1,6 +1,5 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; -import { randomUUID } from 'node:crypto'; import { mkdtemp, mkdir, readFile, rm, stat, writeFile } from 'node:fs/promises'; import { tmpdir } from 'node:os'; import { isAbsolute, join, resolve } from 'node:path'; @@ -9,6 +8,7 @@ import { InstantiationType } from '#/_base/di/extensions'; import { Disposable } from '#/_base/di/lifecycle'; import { type IAgentScopeHandle, + type ISessionScopeHandle, LifecycleScope, _clearScopedRegistryForTests, registerScopedService, @@ -41,6 +41,7 @@ import { ISessionSkillCatalog } from '#/session/sessionSkillCatalog/skillCatalog import { ISessionIndex, type SessionSummary } from '#/app/sessionIndex/sessionIndex'; import { IAppendLogStore } from '#/persistence/interface/appendLogStore'; import { IAtomicDocumentStore } from '#/persistence/interface/atomicDocumentStore'; +import { JsonAtomicDocumentStore } from '#/persistence/backends/node-fs/atomicDocumentStore'; import { IWriteAuthorityRegistry } from '#/persistence/interface/writeAuthority'; import { AppendLogStore } from '#/persistence/backends/node-fs/appendLogStore'; import { FileStorageService } from '#/persistence/backends/node-fs/fileStorageService'; @@ -58,7 +59,10 @@ import { } from '#/session/sessionLease/sessionLeaseContactProvider'; import { ITelemetryService } from '#/app/telemetry/telemetry'; import { Error2, ErrorCodes } from '#/errors'; -import { ICrossProcessLockService } from '#/os/interface/crossProcessLock'; +import { + CrossProcessLockErrorCode, + ICrossProcessLockService, +} from '#/os/interface/crossProcessLock'; import { CrossProcessLockService } from '#/os/backends/node-local/crossProcessLockService'; import { stubCrossProcessLock } from '../../os/stubs'; import { stubFlag } from '../flag/stubs'; @@ -279,6 +283,8 @@ function atomicDocumentStoreStub(): IAtomicDocumentStore { _serviceBrand: undefined, get: () => Promise.resolve(undefined), set: () => Promise.resolve(), + update: (_scope, _key, mutate) => Promise.resolve(mutate(undefined)), + runExclusive: (_scope, _key, op) => op(), delete: () => Promise.resolve(), list: () => Promise.resolve([]), watch: () => (_listener) => ({ dispose: () => {} }), @@ -387,8 +393,18 @@ async function waitFor(cond: () => boolean, timeoutMs = 5_000): Promise { } } -class NoopSessionExternalHooksService implements ISessionExternalHooksService { +let disposedSessionServices = 0; + +class NoopSessionExternalHooksService + extends Disposable + implements ISessionExternalHooksService +{ declare readonly _serviceBrand: undefined; + + override dispose(): void { + disposedSessionServices++; + super.dispose(); + } } let recordedSessionHookEvents: string[] = []; @@ -422,6 +438,7 @@ describe('SessionLifecycleService', () => { let tmpRoots: string[]; beforeEach(() => { + disposedSessionServices = 0; recordedSessionHookEvents = []; telemetryRecords = []; tmpRoots = []; @@ -786,11 +803,41 @@ describe('SessionLifecycleService', () => { it('fires onDidCreateSession with the new handle', async () => { const svc = build(); let captured: { readonly sessionId: string } | undefined; + let visibleDuringEvent: ISessionScopeHandle | undefined; svc.onDidCreateSession((e) => { captured = e; + visibleDuringEvent = svc.get(e.sessionId); }); const h = await svc.create({ sessionId: 's1', workDir: '/tmp/proj' }); expect(captured).toMatchObject({ sessionId: 's1', handle: h, source: 'startup' }); + expect(visibleDuringEvent).toBe(h); + expect(svc.list()).toContain(h); + }); + + it('makes a fork visible before fork and create events fire', async () => { + const svc = build([ + stubPair(IWorkspaceRegistry, { + ...workspaceRegistryStub(), + get: () => + Promise.resolve({ + id: 'wd_stub', + root: '/tmp/proj', + name: 'stub', + createdAt: 0, + lastOpenedAt: 0, + }), + }), + ]); + await svc.create({ sessionId: 'src', workDir: '/tmp/proj' }); + let visibleDuringFork: ISessionScopeHandle | undefined; + svc.onDidForkSession((event) => { + visibleDuringFork = svc.get(event.sessionId); + }); + + const target = await svc.fork({ sourceSessionId: 'src', newSessionId: 'dst' }); + + expect(visibleDuringFork).toBe(target); + expect(svc.list()).toContain(target); }); it('emits session_started with resumed: false and the bound session id on create', async () => { @@ -1231,18 +1278,26 @@ describe('SessionLifecycleService', () => { seeds: ReturnType[]; appendLog: AppendLogStore; registry: WriteAuthorityRegistryService; + storage: FileStorageService; + docs: JsonAtomicDocumentStore; } { const registry = new WriteAuthorityRegistryService(); - const appendLog = new AppendLogStore(new FileStorageService(root), registry); + const locks = new CrossProcessLockService(); + const storage = new FileStorageService(root, undefined, undefined, locks); + const appendLog = new AppendLogStore(storage, registry); + const docs = new JsonAtomicDocumentStore(storage); return { seeds: [ stubPair(IBootstrapService, tmpBootstrapStub(root)), - stubPair(ICrossProcessLockService, new CrossProcessLockService()), + stubPair(ICrossProcessLockService, locks), stubPair(IWriteAuthorityRegistry, registry), stubPair(IAppendLogStore, appendLog), + stubPair(IAtomicDocumentStore, docs), ], appendLog, registry, + storage, + docs, }; } @@ -1276,6 +1331,25 @@ describe('SessionLifecycleService', () => { }); }); + it('rolls back the private scope, authority, and lease when MCP initialization fails', async () => { + const root = await makeTmpRoot(); + const registry = new WriteAuthorityRegistryService(); + const failure = new Error('mcp failed'); + const svc = build([ + stubPair(IBootstrapService, tmpBootstrapStub(root)), + stubPair(ICrossProcessLockService, new CrossProcessLockService()), + stubPair(IWriteAuthorityRegistry, registry), + stubPair(ISessionMcpService, sessionMcpServiceStub(() => Promise.reject(failure))), + ]); + + await expect(svc.create({ sessionId: 's1', workDir: '/tmp/proj' })).rejects.toBe(failure); + expect(svc.get('s1')).toBeUndefined(); + expect(svc.list()).toEqual([]); + expect(registry.resolve('s1')).toBeUndefined(); + await expect(stat(leaseFile(root, 's1'))).rejects.toThrow(); + expect(disposedSessionServices).toBeGreaterThan(0); + }); + it('refuses to materialize a session live-held by another instance', async () => { const root = await makeTmpRoot(); const first = build(realInstanceSeeds(root)); @@ -1439,40 +1513,6 @@ describe('SessionLifecycleService', () => { expect(remaining.lock_id).toBe('peer-token'); }); - it('refuses to materialize a session directory an unregistered writer keeps writing', async () => { - const root = await makeTmpRoot(); - const sessionDir = join(root, 'sessions', 'wd_stub', 's1'); - await mkdir(sessionDir, { recursive: true }); - await writeFile(join(sessionDir, 'marker'), '1'); - const writer = setInterval(() => { - void writeFile(join(sessionDir, `tick-${randomUUID()}`), 'x').catch(() => {}); - }, 150); - writer.unref(); - try { - const svc = build( - realInstanceSeeds(root, [stubPair(IFlagService, stubFlag(true))]), - ); - const error = await createError(svc, 's1'); - expect(error.code).toBe(ErrorCodes.SESSION_HELD_BY_PEER); - expect(error.details).toEqual({ kind: 'unregistered-writer' }); - await expect(stat(leaseFile(root, 's1'))).rejects.toThrow(); - } finally { - clearInterval(writer); - } - }); - - it('materializes when the session directory stops being written between the two checks', async () => { - const root = await makeTmpRoot(); - const sessionDir = join(root, 'sessions', 'wd_stub', 's1'); - await mkdir(sessionDir, { recursive: true }); - await writeFile(join(sessionDir, 'marker'), '1'); - const svc = build( - realInstanceSeeds(root, [stubPair(IFlagService, stubFlag(true))]), - ); - const h = await svc.create({ sessionId: 's1', workDir: '/tmp/proj' }); - expect(h.id).toBe('s1'); - }); - it('fork acquires a distinct target lease; releasing the source does not fence the target', async () => { const root = await makeTmpRoot(); const { seeds, appendLog } = realAlsSeeds(root); @@ -1531,6 +1571,101 @@ describe('SessionLifecycleService', () => { expect(disk).toBe('{"tail":true}\n'); await expect(stat(leaseFile(root, 's1'))).rejects.toThrow(); }); + + it('keeps authority and lease when the session durability barrier fails', async () => { + const root = await makeTmpRoot(); + const { seeds, appendLog, registry, storage } = realAlsSeeds(root); + const svc = build(seeds); + await svc.create({ sessionId: 's1', workDir: '/tmp/proj' }); + const failure = new Error('durable append failed'); + const originalAppend = storage.append.bind(storage); + storage.append = async (...args) => { + if (args[0].startsWith('sessions/wd_stub/s1')) throw failure; + return originalAppend(...args); + }; + appendLog.append('sessions/wd_stub/s1/agents/main', 'wire.jsonl', { tail: true }); + + await expect(svc.close('s1')).rejects.toBe(failure); + expect(svc.get('s1')).toBeUndefined(); + expect(registry.resolve('s1')).toBeDefined(); + await expect(stat(leaseFile(root, 's1'))).resolves.toBeDefined(); + await expect( + new CrossProcessLockService().acquire(leaseFile(root, 's1')), + ).rejects.toMatchObject({ code: CrossProcessLockErrorCode.Held }); + await expect(svc.close('s1')).rejects.toBe(failure); + expect(registry.resolve('s1')).toBeDefined(); + }); + + it('requires an explicit dirty abort before releasing a failed durability lease', async () => { + const root = await makeTmpRoot(); + const { seeds, appendLog, registry, storage, docs } = realAlsSeeds(root); + const svc = build(seeds); + await svc.create({ sessionId: 's1', workDir: '/tmp/proj' }); + await docs.set('sessions/wd_stub/s1', 'state.json', { + id: 's1', + version: 2, + cwd: '/tmp/proj', + createdAt: 1, + updatedAt: 1, + archived: false, + agents: {}, + custom: {}, + }); + const failure = new Error('durable append failed'); + const originalAppend = storage.append.bind(storage); + storage.append = async (...args) => { + if (args[0].startsWith('sessions/wd_stub/s1')) throw failure; + return originalAppend(...args); + }; + appendLog.append('sessions/wd_stub/s1/agents/main', 'wire.jsonl', { tail: true }); + + await expect(svc.close('s1')).rejects.toBe(failure); + await svc.forceAbort('s1'); + + expect(registry.resolve('s1')).toBeUndefined(); + await expect(stat(leaseFile(root, 's1'))).rejects.toThrow(); + const successor = await new CrossProcessLockService().acquire(leaseFile(root, 's1')); + successor.release(); + expect(telemetryRecords).toContainEqual({ + event: 'session_dirty_abort', + properties: { session_id: 's1', reason: 'flush-failed', sessionId: 's1' }, + }); + }); + + it('closing one session does not wait for another session append', async () => { + const root = await makeTmpRoot(); + const { seeds, appendLog, storage } = realAlsSeeds(root); + const svc = build(seeds); + await svc.create({ sessionId: 's1', workDir: '/tmp/proj' }); + await svc.create({ sessionId: 's10', workDir: '/tmp/proj' }); + let markBlockedStarted!: () => void; + const blockedStarted = new Promise((resolvePromise) => { + markBlockedStarted = resolvePromise; + }); + let releaseBlocked!: () => void; + const blockedGate = new Promise((resolvePromise) => { + releaseBlocked = resolvePromise; + }); + const originalAppend = storage.append.bind(storage); + storage.append = async (...args) => { + if (args[0].startsWith('sessions/wd_stub/s10')) { + markBlockedStarted(); + await blockedGate; + } + return originalAppend(...args); + }; + appendLog.append('sessions/wd_stub/s10/agents/main', 'wire.jsonl', { n: 2 }); + appendLog.append('sessions/wd_stub/s1/agents/main', 'wire.jsonl', { n: 1 }); + await blockedStarted; + + await svc.close('s1'); + await expect(stat(leaseFile(root, 's1'))).rejects.toThrow(); + await expect(stat(leaseFile(root, 's10'))).resolves.toBeDefined(); + + releaseBlocked(); + await appendLog.flush('sessions/wd_stub/s10'); + await svc.close('s10'); + }); }); describe('defaultPlanMode bootstrap', () => { diff --git a/packages/agent-core-v2/test/app/workspaceRegistry/workspaceRegistryService.test.ts b/packages/agent-core-v2/test/app/workspaceRegistry/workspaceRegistryService.test.ts index 7cb41df09c..b8110f8063 100644 --- a/packages/agent-core-v2/test/app/workspaceRegistry/workspaceRegistryService.test.ts +++ b/packages/agent-core-v2/test/app/workspaceRegistry/workspaceRegistryService.test.ts @@ -63,13 +63,14 @@ describe('WorkspaceRegistryService (file-backed)', () => { }); function build(hostFs: IHostFileSystem = new HostFileSystem()): IWorkspaceRegistry { - const fileStorage = new FileStorageService(homeDir); + const locks = new CrossProcessLockService(); + const fileStorage = new FileStorageService(homeDir, undefined, undefined, locks); const host = createScopedTestHost([ stubPair(IFileSystemStorageService, fileStorage), stubPair(IAtomicDocumentStore, new JsonAtomicDocumentStore(fileStorage)), stubPair(IHostFileSystem, hostFs), stubPair(IBootstrapService, stubBootstrap(homeDir)), - stubPair(ICrossProcessLockService, new CrossProcessLockService()), + stubPair(ICrossProcessLockService, locks), ]); hosts.push(host); return host.app.accessor.get(IWorkspaceRegistry); diff --git a/packages/agent-core-v2/test/os/backends/node-local/crossProcessLockService.test.ts b/packages/agent-core-v2/test/os/backends/node-local/crossProcessLockService.test.ts index 03e1854768..41d5777025 100644 --- a/packages/agent-core-v2/test/os/backends/node-local/crossProcessLockService.test.ts +++ b/packages/agent-core-v2/test/os/backends/node-local/crossProcessLockService.test.ts @@ -15,6 +15,7 @@ import { mkdtempSync, readFileSync, readdirSync, + renameSync, rmSync, utimesSync, writeFileSync, @@ -44,6 +45,7 @@ let tmpDir: string; let lockPath: string; let nowValue: number; let lockSeq: number; +let attemptSeq: number; const handles: ICrossProcessLockHandle[] = []; function track(handle: T): T { @@ -66,6 +68,8 @@ interface FakeServiceOptions { instanceId?: string; probe?: ProcessProbe; now?: () => number; + beforeStaleIsolation?: () => void | Promise; + sleep?: (ms: number) => Promise; } function makeService(options: FakeServiceOptions = {}): CrossProcessLockService { @@ -76,7 +80,9 @@ function makeService(options: FakeServiceOptions = {}): CrossProcessLockService probeProcess: options.probe ?? probeFor(new Map([[selfPid, 'self-start']])), now: options.now ?? (() => nowValue), newLockId: () => `lockid-${++lockSeq}`, - sleep: realSleep, + newAttemptId: () => `attempt-${++attemptSeq}`, + beforeStaleIsolation: options.beforeStaleIsolation, + sleep: options.sleep ?? realSleep, }); } @@ -111,6 +117,13 @@ function backdate(path: string, ageMs: number): void { utimesSync(path, t, t); } +function findStalePath(lockId: string): string { + const prefix = `lock.stale.${lockId}.`; + const name = readdirSync(tmpDir).find((entry) => entry.startsWith(prefix)); + expect(name).toBeDefined(); + return join(tmpDir, name!); +} + async function waitFor(cond: () => boolean, timeoutMs = 3_000): Promise { const start = Date.now(); for (;;) { @@ -125,6 +138,7 @@ beforeEach(() => { lockPath = join(tmpDir, 'lock'); nowValue = 1_000_000; lockSeq = 0; + attemptSeq = 0; }); afterEach(() => { @@ -133,10 +147,10 @@ afterEach(() => { }); describe('acquire / release', () => { - it('writes the snake_case payload with extras flat, and release removes the file', () => { + it('writes the snake_case payload with extras flat, and release removes the file', async () => { const svc = makeService(); const handle = track( - svc.acquire(lockPath, { + await svc.acquire(lockPath, { address: '127.0.0.1:58627', extraPayload: { port: 58627, role: 'primary' }, }), @@ -156,9 +170,9 @@ describe('acquire / release', () => { expect(existsSync(lockPath)).toBe(false); }); - it('omits optional keys when the platform cannot provide them', () => { + it('omits optional keys when the platform cannot provide them', async () => { const svc = makeService({ probe: () => ({ alive: true }) }); - const handle = track(svc.acquire(lockPath)); + const handle = track(await svc.acquire(lockPath)); expect(readDisk()).toEqual({ lock_id: 'lockid-1', instance_id: 'inst-self', @@ -166,19 +180,19 @@ describe('acquire / release', () => { }); }); - it('creates missing parent directories and release is idempotent', () => { + it('creates missing parent directories and release is idempotent', async () => { const nested = join(tmpDir, 'a', 'b', 'lock'); const svc = makeService(); - const handle = track(svc.acquire(nested)); + const handle = track(await svc.acquire(nested)); expect(existsSync(nested)).toBe(true); handle.release(); handle.release(); expect(existsSync(nested)).toBe(false); }); - it('release never unlinks a foreign lock', () => { + it('release never unlinks a foreign lock', async () => { const svc = makeService(); - const handle = track(svc.acquire(lockPath)); + const handle = track(await svc.acquire(lockPath)); handle.release(); writePayload({ lock_id: 'someone-else', instance_id: 'x', pid: OTHER_PID }); handle.release(); @@ -188,7 +202,7 @@ describe('acquire / release', () => { }); describe('held vs takeover', () => { - it('a live identity-matching holder blocks acquisition with OS_LOCK_HELD', () => { + it('a live identity-matching holder blocks acquisition with OS_LOCK_HELD', async () => { writePayload({ lock_id: 'old-id', instance_id: 'inst-other', @@ -198,13 +212,7 @@ describe('held vs takeover', () => { const svc = makeService({ probe: probeFor(liveWorld()) }); const before = readFileSync(lockPath, 'utf8'); - let caught: unknown; - try { - svc.acquire(lockPath); - } catch (error) { - caught = error; - } - expect(caught).toMatchObject({ + await expect(svc.acquire(lockPath)).rejects.toMatchObject({ code: CrossProcessLockErrorCode.Held, details: { reason: 'held' }, }); @@ -212,11 +220,11 @@ describe('held vs takeover', () => { expect(readdirSync(tmpDir)).toEqual(['lock']); }); - it('takes over a dead holder with rename isolation', () => { + it('takes over a dead holder with rename isolation', async () => { const live = liveWorld(); const probe = probeFor(live); const oldHandle = track( - makeService({ selfPid: OTHER_PID, instanceId: 'inst-other', probe }).acquire( + await makeService({ selfPid: OTHER_PID, instanceId: 'inst-other', probe }).acquire( lockPath, { extraPayload: { port: 1 } }, ), @@ -224,10 +232,10 @@ describe('held vs takeover', () => { const oldDisk = JSON.parse(readFileSync(lockPath, 'utf8')) as Record; live.delete(OTHER_PID); - const handle = track(makeService({ probe }).acquire(lockPath)); + const handle = track(await makeService({ probe }).acquire(lockPath)); - expect(existsSync(`${lockPath}.stale.lockid-1`)).toBe(true); - expect(JSON.parse(readFileSync(`${lockPath}.stale.lockid-1`, 'utf8'))).toEqual(oldDisk); + const stalePath = findStalePath('lockid-1'); + expect(JSON.parse(readFileSync(stalePath, 'utf8'))).toEqual(oldDisk); expect(readDisk().lock_id).toBe('lockid-2'); expect(handle.lockId).toBe('lockid-2'); @@ -237,7 +245,7 @@ describe('held vs takeover', () => { expect(readDisk().lock_id).toBe('lockid-2'); }); - it('treats a live pid with mismatched identity as stale (pid reused)', () => { + it('treats a live pid with mismatched identity as stale (pid reused)', async () => { writePayload({ lock_id: 'old-id', instance_id: 'inst-other', @@ -252,12 +260,12 @@ describe('held vs takeover', () => { state: 'stale', staleReason: 'pid-reused', }); - track(svc.acquire(lockPath)); - expect(existsSync(`${lockPath}.stale.old-id`)).toBe(true); + track(await svc.acquire(lockPath)); + expect(existsSync(findStalePath('old-id'))).toBe(true); expect(readDisk().lock_id).toBe('lockid-1'); }); - it('refuses takeover when the holder identity is unavailable (conservative held)', () => { + it('refuses takeover when the holder identity is unavailable (conservative held)', async () => { writePayload({ lock_id: 'old-id', instance_id: 'inst-other', @@ -269,14 +277,14 @@ describe('held vs takeover', () => { const svc = makeService({ probe: probeFor(live) }); expect(svc.inspect(lockPath)).toMatchObject({ state: 'held', unavailableReason: 'held' }); - expect(() => svc.acquire(lockPath)).toThrowError( - expect.objectContaining({ code: CrossProcessLockErrorCode.Held }), - ); + await expect(svc.acquire(lockPath)).rejects.toMatchObject({ + code: CrossProcessLockErrorCode.Held, + }); expect(readDisk().lock_id).toBe('old-id'); expect(readdirSync(tmpDir)).toEqual(['lock']); }); - it('takes over a legacy payload without lock_id, renamed aside as unknown', () => { + it('takes over a legacy payload without lock_id, renamed aside as unknown', async () => { writePayload({ pid: OTHER_PID, started_at: '1', port: 58627 }); const svc = makeService(); @@ -285,25 +293,113 @@ describe('held vs takeover', () => { staleReason: 'holder-dead', payload: { lockId: '', pid: OTHER_PID, port: 58627 }, }); - track(svc.acquire(lockPath)); - expect(existsSync(`${lockPath}.stale.unknown`)).toBe(true); + track(await svc.acquire(lockPath)); + expect(existsSync(findStalePath('unknown'))).toBe(true); expect(readDisk().lock_id).toBe('lockid-1'); }); + + it('never lets two delayed stale contenders both return ownership', async () => { + writePayload({ + lock_id: 'old-id', + instance_id: 'inst-dead', + pid: DEAD_PID, + }); + const probe = probeFor(liveWorld()); + let enterIsolation!: () => void; + const isolationEntered = new Promise((resolvePromise) => { + enterIsolation = resolvePromise; + }); + let resumeIsolation!: () => void; + const isolationPaused = new Promise((resolvePromise) => { + resumeIsolation = resolvePromise; + }); + let pauseCount = 0; + const contenderA = makeService({ + probe, + now: Date.now, + beforeStaleIsolation: async () => { + pauseCount += 1; + if (pauseCount !== 1) return; + enterIsolation(); + await isolationPaused; + }, + }); + const contenderB = makeService({ + selfPid: OTHER_PID, + instanceId: 'inst-b', + probe, + now: Date.now, + }); + + const acquireA = contenderA.acquire(lockPath, { creationWindowMs: 500 }); + await isolationEntered; + const acquireB = contenderB.acquire(lockPath, { creationWindowMs: 500 }); + await waitFor(() => readDisk().lock_id === 'lockid-1'); + resumeIsolation(); + + const outcomes = await Promise.allSettled([acquireA, acquireB]); + const fulfilled = outcomes.filter( + (outcome): outcome is PromiseFulfilledResult => + outcome.status === 'fulfilled', + ); + const rejected = outcomes.filter( + (outcome): outcome is PromiseRejectedResult => outcome.status === 'rejected', + ); + expect(fulfilled).toHaveLength(1); + expect(rejected).toHaveLength(1); + expect(rejected[0]?.reason).toMatchObject({ code: CrossProcessLockErrorCode.Lost }); + track(fulfilled[0]!.value); + expect(readDisk().lock_id).toBe(fulfilled[0]!.value.lockId); + expect(readdirSync(tmpDir).filter((entry) => entry.startsWith('lock.intent.'))).toEqual([]); + }); + + it('settles against the contender snapshot without waiting for later arrivals', async () => { + const olderIntent = `${lockPath}.intent.older`; + const laterIntent = `${lockPath}.intent.later`; + writeFileSync( + olderIntent, + JSON.stringify({ pid: OTHER_PID, process_started_at: 'other-start' }), + ); + let sleepCount = 0; + const svc = makeService({ + probe: probeFor(liveWorld()), + sleep: async () => { + sleepCount += 1; + writeFileSync( + laterIntent, + JSON.stringify({ pid: OTHER_PID, process_started_at: 'other-start' }), + ); + rmSync(olderIntent, { force: true }); + }, + }); + + const handle = track(await svc.acquire(lockPath, { creationWindowMs: 500 })); + + expect(sleepCount).toBe(1); + expect(handle.checkHeld()).toBe(true); + expect(existsSync(laterIntent)).toBe(true); + }); + + it('ignores a settled intent left behind after cleanup failure', async () => { + writeFileSync( + `${lockPath}.intent.orphan`, + JSON.stringify({ intent_id: 'orphan', state: 'settled', pid: OTHER_PID }), + ); + + const handle = track(await makeService({ probe: probeFor(liveWorld()) }).acquire(lockPath)); + + expect(handle.lockId).toBe('lockid-1'); + expect(handle.checkHeld()).toBe(true); + }); }); describe('creation window', () => { - it('an empty file inside the window is creating', () => { + it('an empty file inside the window is creating', async () => { writeFileSync(lockPath, ''); const svc = makeService({ now: () => Date.now() }); expect(svc.inspect(lockPath)).toMatchObject({ state: 'creating' }); - let caught: unknown; - try { - svc.acquire(lockPath); - } catch (error) { - caught = error; - } - expect(caught).toMatchObject({ + await expect(svc.acquire(lockPath)).rejects.toMatchObject({ code: CrossProcessLockErrorCode.Held, details: { reason: 'creating' }, }); @@ -311,7 +407,7 @@ describe('creation window', () => { expect(readdirSync(tmpDir)).toEqual(['lock']); }); - it('an empty file past the window is taken over as stale', () => { + it('an empty file past the window is taken over as stale', async () => { writeFileSync(lockPath, ''); backdate(lockPath, 10_000); const svc = makeService({ now: () => Date.now() }); @@ -320,12 +416,12 @@ describe('creation window', () => { state: 'stale', staleReason: 'creation-window-expired', }); - track(svc.acquire(lockPath)); - expect(existsSync(`${lockPath}.stale.unknown`)).toBe(true); + track(await svc.acquire(lockPath)); + expect(existsSync(findStalePath('unknown'))).toBe(true); expect(readDisk().lock_id).toBe('lockid-1'); }); - it('an unparseable file follows the same window', () => { + it('an unparseable file follows the same window', async () => { writeFileSync(lockPath, '{oops'); const svc = makeService({ now: () => Date.now() }); @@ -335,8 +431,8 @@ describe('creation window', () => { state: 'stale', staleReason: 'creation-window-expired', }); - track(svc.acquire(lockPath)); - expect(existsSync(`${lockPath}.stale.unknown`)).toBe(true); + track(await svc.acquire(lockPath)); + expect(existsSync(findStalePath('unknown'))).toBe(true); expect(readDisk().instance_id).toBe('inst-self'); }); }); @@ -345,7 +441,7 @@ describe('heartbeat mode', () => { it('beats heartbeat_at into the disk payload through the kept fd', async () => { const svc = makeService(); const handle = track( - svc.acquire(lockPath, { heartbeat: { intervalMs: 20, ttlMs: 60_000 } }), + await svc.acquire(lockPath, { heartbeat: { intervalMs: 20, ttlMs: 60_000 } }), ); expect(readDisk().heartbeat_at).toBe(1_000_000); @@ -360,7 +456,7 @@ describe('heartbeat mode', () => { const probe = probeFor(live); let lostCount = 0; const oldHandle = track( - makeService({ probe }).acquire(lockPath, { + await makeService({ probe }).acquire(lockPath, { heartbeat: { intervalMs: 20, ttlMs: 60_000 }, onLost: () => { lostCount += 1; @@ -370,7 +466,7 @@ describe('heartbeat mode', () => { live.delete(SELF_PID); track( - makeService({ selfPid: OTHER_PID, instanceId: 'inst-b', probe }).acquire(lockPath), + await makeService({ selfPid: OTHER_PID, instanceId: 'inst-b', probe }).acquire(lockPath), ); await waitFor(() => lostCount === 1); @@ -380,7 +476,7 @@ describe('heartbeat mode', () => { expect(readDisk().lock_id).toBe('lockid-2'); }); - it('a silent heartbeat with a live identity-matching pid is holder-unresponsive, never seized', () => { + it('a silent heartbeat with a live identity-matching pid is holder-unresponsive, never seized', async () => { writePayload({ lock_id: 'old-id', instance_id: 'inst-other', @@ -391,13 +487,9 @@ describe('heartbeat mode', () => { const svc = makeService({ probe: probeFor(liveWorld()) }); const before = readFileSync(lockPath, 'utf8'); - let caught: unknown; - try { - svc.acquire(lockPath, { heartbeat: { intervalMs: 100, ttlMs: 5_000 } }); - } catch (error) { - caught = error; - } - expect(caught).toMatchObject({ + await expect( + svc.acquire(lockPath, { heartbeat: { intervalMs: 100, ttlMs: 5_000 } }), + ).rejects.toMatchObject({ code: CrossProcessLockErrorCode.Held, details: { reason: 'holder-unresponsive' }, }); @@ -405,7 +497,7 @@ describe('heartbeat mode', () => { expect(readdirSync(tmpDir)).toEqual(['lock']); }); - it('a fresh heartbeat with a live holder is a plain held', () => { + it('a fresh heartbeat with a live holder is a plain held', async () => { writePayload({ lock_id: 'old-id', instance_id: 'inst-other', @@ -415,13 +507,9 @@ describe('heartbeat mode', () => { }); const svc = makeService({ probe: probeFor(liveWorld()) }); - let caught: unknown; - try { - svc.acquire(lockPath, { heartbeat: { intervalMs: 100, ttlMs: 5_000 } }); - } catch (error) { - caught = error; - } - expect(caught).toMatchObject({ + await expect( + svc.acquire(lockPath, { heartbeat: { intervalMs: 100, ttlMs: 5_000 } }), + ).rejects.toMatchObject({ code: CrossProcessLockErrorCode.Held, details: { reason: 'held' }, }); @@ -433,7 +521,7 @@ describe('acquireWithWait / withLock', () => { it('a waiting acquirer obtains the lock after the holder releases', async () => { const live = liveWorld(); const probe = probeFor(live); - const holder = track(makeService({ probe }).acquire(lockPath)); + const holder = track(await makeService({ probe }).acquire(lockPath)); const waiter = makeService({ selfPid: OTHER_PID, @@ -461,7 +549,7 @@ describe('acquireWithWait / withLock', () => { it('a waiting acquirer gives up with OS_LOCK_WAIT_TIMEOUT', async () => { const live = liveWorld(); const probe = probeFor(live); - track(makeService({ probe }).acquire(lockPath)); + track(await makeService({ probe }).acquire(lockPath)); const waiter = makeService({ selfPid: OTHER_PID, @@ -477,9 +565,9 @@ describe('acquireWithWait / withLock', () => { }); describe('update', () => { - it('rewrites extras and re-stamps the protocol keys', () => { + it('rewrites extras and re-stamps the protocol keys', async () => { const svc = makeService(); - const handle = track(svc.acquire(lockPath, { extraPayload: { port: 1 } })); + const handle = track(await svc.acquire(lockPath, { extraPayload: { port: 1 } })); handle.update((payload) => ({ ...payload, @@ -501,7 +589,7 @@ describe('update', () => { it('a later update keeps the extras the heartbeat rewrites', async () => { const svc = makeService(); const handle = track( - svc.acquire(lockPath, { + await svc.acquire(lockPath, { heartbeat: { intervalMs: 20, ttlMs: 60_000 }, extraPayload: { port: 1 }, }), @@ -519,15 +607,15 @@ describe('update', () => { handle.release(); }); - it('update after a takeover throws OS_LOCK_LOST', () => { + it('update after a takeover throws OS_LOCK_LOST', async () => { const live = liveWorld(); const probe = probeFor(live); const oldHandle = track( - makeService({ probe }).acquire(lockPath, { extraPayload: { port: 1 } }), + await makeService({ probe }).acquire(lockPath, { extraPayload: { port: 1 } }), ); live.delete(SELF_PID); track( - makeService({ selfPid: OTHER_PID, instanceId: 'inst-b', probe }).acquire(lockPath), + await makeService({ selfPid: OTHER_PID, instanceId: 'inst-b', probe }).acquire(lockPath), ); expect(() => { @@ -536,6 +624,28 @@ describe('update', () => { expect(readDisk().lock_id).toBe('lockid-2'); expect(readDisk().port).toBeUndefined(); }); + + it('update detects a takeover that happens after its initial token check', async () => { + const svc = makeService(); + const handle = track(await svc.acquire(lockPath, { extraPayload: { port: 1 } })); + const oldPath = `${lockPath}.old`; + + expect(() => { + handle.update((payload) => { + renameSync(lockPath, oldPath); + writeFileSync( + lockPath, + JSON.stringify({ + lock_id: 'foreign-lock', + instance_id: 'foreign-instance', + pid: OTHER_PID, + }), + ); + return { ...payload, port: 2 }; + }); + }).toThrowError(expect.objectContaining({ code: CrossProcessLockErrorCode.Lost })); + expect(readDisk().lock_id).toBe('foreign-lock'); + }); }); describe('inspect', () => { diff --git a/packages/agent-core-v2/test/os/stubs.ts b/packages/agent-core-v2/test/os/stubs.ts index 3d0cdb277a..d7d7a053d6 100644 --- a/packages/agent-core-v2/test/os/stubs.ts +++ b/packages/agent-core-v2/test/os/stubs.ts @@ -18,7 +18,7 @@ import { export function stubCrossProcessLock(): ICrossProcessLockService { const held = new Set(); - const acquire = (lockPath: string): ICrossProcessLockHandle => { + const acquireHandle = (lockPath: string): ICrossProcessLockHandle => { if (held.has(lockPath)) { throw new CrossProcessLockError( CrossProcessLockErrorCode.Held, @@ -42,14 +42,14 @@ export function stubCrossProcessLock(): ICrossProcessLockService { }; return { _serviceBrand: undefined, - acquire, - acquireWithWait: (lockPath) => Promise.resolve(acquire(lockPath)), + acquire: (lockPath) => Promise.resolve(acquireHandle(lockPath)), + acquireWithWait: (lockPath) => Promise.resolve(acquireHandle(lockPath)), withLock: async ( lockPath: string, _options: Parameters[1], fn: (handle: ICrossProcessLockHandle) => T | Promise, ): Promise => { - const handle = acquire(lockPath); + const handle = acquireHandle(lockPath); try { return await fn(handle); } finally { diff --git a/packages/agent-core-v2/test/persistence/backends/node-fs/appendLogStore.test.ts b/packages/agent-core-v2/test/persistence/backends/node-fs/appendLogStore.test.ts index ce4a20ed7c..b4d2ffa6be 100644 --- a/packages/agent-core-v2/test/persistence/backends/node-fs/appendLogStore.test.ts +++ b/packages/agent-core-v2/test/persistence/backends/node-fs/appendLogStore.test.ts @@ -143,7 +143,7 @@ describe('AppendLogStore', () => { ); }); - it('reacquire after final release waits for retiring storage before fresh I/O', async () => { + it('keeps a failed retired generation reachable and blocks replacement I/O', async () => { const failure = new Error('retiring append failed'); let markAppendStarted!: () => void; const appendStarted = new Promise((resolve) => { @@ -153,32 +153,16 @@ describe('AppendLogStore', () => { const appendGate = new Promise((resolve) => { releaseAppend = resolve; }); - let markReplacementAppendStarted!: () => void; - const replacementAppendStarted = new Promise((resolve) => { - markReplacementAppendStarted = resolve; - }); - let releaseReplacementAppend!: () => void; - const replacementAppendGate = new Promise((resolve) => { - releaseReplacementAppend = resolve; - }); let reportFailure!: (error: unknown) => void; const reportedFailure = new Promise((resolve) => { reportFailure = resolve; }); let appendAttempts = 0; - let replacementStarted = false; - const originalAppend = storage.append.bind(storage); storage.append = async (...args) => { appendAttempts++; - if (appendAttempts === 1) { - markAppendStarted(); - await appendGate; - throw failure; - } - replacementStarted = true; - markReplacementAppendStarted(); - await replacementAppendGate; - return originalAppend(...args); + markAppendStarted(); + await appendGate; + throw failure; }; const retiringOwner = record.acquire(SCOPE, KEY); @@ -187,28 +171,46 @@ describe('AppendLogStore', () => { retiringOwner.dispose(); const replacementOwner = record.acquire(SCOPE, KEY); record.append(SCOPE, KEY, { n: 2 }); - const orderedFlush = record.flush(); - await Promise.resolve(); - await Promise.resolve(); - expect(replacementStarted).toBe(false); releaseAppend(); expect(await reportedFailure).toBe(failure); - await replacementAppendStarted; + await expect(record.flush(SCOPE)).rejects.toBe(failure); + await expect(record.flush(SCOPE)).rejects.toBe(failure); + expect(appendAttempts).toBe(1); + expect(await storage.read(SCOPE, KEY)).toBeUndefined(); + replacementOwner.dispose(); + }); - const currentFlush = record.flush(); - let flushSettled = false; - void currentFlush.then(() => { - flushSettled = true; + it('scoped flush does not wait for an unrelated scope', async () => { + const selectedScope = 'agents/s1'; + const blockedScope = 'agents/s10'; + let markBlockedStarted!: () => void; + const blockedStarted = new Promise((resolve) => { + markBlockedStarted = resolve; }); - await Promise.resolve(); - await Promise.resolve(); - expect(flushSettled).toBe(false); + let releaseBlocked!: () => void; + const blockedGate = new Promise((resolve) => { + releaseBlocked = resolve; + }); + const originalAppend = storage.append.bind(storage); + storage.append = async (...args) => { + if (args[0] === blockedScope) { + markBlockedStarted(); + await blockedGate; + } + return originalAppend(...args); + }; - releaseReplacementAppend(); - await Promise.all([orderedFlush, currentFlush]); - expect(await collect(SCOPE, KEY)).toEqual([{ n: 2 }]); - replacementOwner.dispose(); + record.append(blockedScope, KEY, { n: 2 }); + record.append(selectedScope, KEY, { n: 1 }); + await blockedStarted; + + await record.flush(selectedScope); + expect(new TextDecoder().decode(await storage.read(selectedScope, KEY))).toBe('{"n":1}\n'); + + releaseBlocked(); + await record.flush(blockedScope); + expect(new TextDecoder().decode(await storage.read(blockedScope, KEY))).toBe('{"n":2}\n'); }); it('keeps a sticky failure until every acquired owner releases it', async () => { @@ -236,8 +238,8 @@ describe('AppendLogStore', () => { finalOwner.dispose(); const replacementOwner = record.acquire(SCOPE, KEY); record.append(SCOPE, KEY, { n: 2 }); - await record.flush(); - expect(await collect(SCOPE, KEY)).toEqual([{ n: 2 }]); + await expect(record.flush()).rejects.toBe(failure); + expect(appendAttempts).toBe(1); replacementOwner.dispose(); }); @@ -723,10 +725,10 @@ describe('AppendLogStore', () => { return { store: localIx.get(IAppendLogStore), storage }; } - function leaseFor(sessionId: string): SessionLease { + async function leaseFor(sessionId: string): Promise { return new SessionLease( sessionId, - locks.acquire(sessionLeasePath(tmpDir, sessionId)), + await locks.acquire(sessionLeasePath(tmpDir, sessionId)), () => {}, ); } @@ -751,7 +753,7 @@ describe('AppendLogStore', () => { }); it('flush rejects with session.lease_lost when the lease is gone and writes no bytes', async () => { - const lease = leaseFor('s1'); + const lease = await leaseFor('s1'); registry.register(lease); const { store, storage } = makeStore(); let appendAttempts = 0; @@ -775,7 +777,7 @@ describe('AppendLogStore', () => { }); it('rewrite is fenced by the same hard gate', async () => { - const lease = leaseFor('s1'); + const lease = await leaseFor('s1'); registry.register(lease); const { store, storage } = makeStore(); let writeAttempts = 0; @@ -813,7 +815,7 @@ describe('AppendLogStore', () => { }); it('writes pass while the registered lease is held', async () => { - const lease = leaseFor('s1'); + const lease = await leaseFor('s1'); registry.register(lease); const { store, storage } = makeStore(); store.append(SESSION_SCOPE, KEY, { n: 1 }); @@ -823,7 +825,7 @@ describe('AppendLogStore', () => { }); it('flush awaits the retired buffer final flush before returning', async () => { - const lease = leaseFor('s1'); + const lease = await leaseFor('s1'); registry.register(lease); const { store, storage } = makeStore(); const handle = store.acquire(SESSION_SCOPE, KEY); diff --git a/packages/agent-core-v2/test/persistence/backends/node-fs/atomicDocumentStore.test.ts b/packages/agent-core-v2/test/persistence/backends/node-fs/atomicDocumentStore.test.ts index ae427f496a..3d6cc36898 100644 --- a/packages/agent-core-v2/test/persistence/backends/node-fs/atomicDocumentStore.test.ts +++ b/packages/agent-core-v2/test/persistence/backends/node-fs/atomicDocumentStore.test.ts @@ -45,6 +45,42 @@ describe('JsonAtomicDocumentStore', () => { expect(await config.get('session', 'state.json')).toEqual({ title: 'new', count: 2 }); }); + it('serializes concurrent read-modify-write updates for one key', async () => { + await config.set('session', 'state.json', { count: 0 }); + let releaseFirst!: () => void; + const firstMayFinish = new Promise((resolve) => { + releaseFirst = resolve; + }); + let firstEntered!: () => void; + const firstDidEnter = new Promise((resolve) => { + firstEntered = resolve; + }); + + const first = config.update('session', 'state.json', async (current) => { + firstEntered(); + await firstMayFinish; + return { count: (current?.count ?? 0) + 1 }; + }); + await firstDidEnter; + const second = config.update('session', 'state.json', (current) => ({ + count: (current?.count ?? 0) + 1, + })); + releaseFirst(); + + await expect(Promise.all([first, second])).resolves.toEqual([{ count: 1 }, { count: 2 }]); + expect(await config.get('session', 'state.json')).toEqual({ count: 2 }); + }); + + it('does not replace the document when an update callback fails', async () => { + await config.set('session', 'state.json', { count: 1 }); + await expect( + config.update('session', 'state.json', () => { + throw new Error('mutate failed'); + }), + ).rejects.toThrow('mutate failed'); + expect(await config.get('session', 'state.json')).toEqual({ count: 1 }); + }); + it('keys are independent', async () => { await config.set('session', 'a.json', { title: 'A' }); await config.set('session', 'b.json', { title: 'B' }); diff --git a/packages/agent-core-v2/test/session/agentLifecycle/agentLifecycle.test.ts b/packages/agent-core-v2/test/session/agentLifecycle/agentLifecycle.test.ts index 6cced97a0b..4ff869a794 100644 --- a/packages/agent-core-v2/test/session/agentLifecycle/agentLifecycle.test.ts +++ b/packages/agent-core-v2/test/session/agentLifecycle/agentLifecycle.test.ts @@ -201,6 +201,17 @@ describe('AgentLifecycleService', () => { set: async (scope: string, key: string, value: T): Promise => { atomicDocs.set(`${scope}/${key}`, value); }, + update: async ( + scope: string, + key: string, + mutate: (current: T | undefined) => T | Promise, + ): Promise => { + const next = await mutate(atomicDocs.get(`${scope}/${key}`) as T | undefined); + atomicDocs.set(`${scope}/${key}`, next); + return next; + }, + runExclusive: async (_scope: string, _key: string, op: () => Promise): Promise => + op(), delete: async (scope: string, key: string): Promise => { atomicDocs.delete(`${scope}/${key}`); }, diff --git a/packages/agent-core-v2/test/session/sessionFileLedger/fileLedger.test.ts b/packages/agent-core-v2/test/session/sessionFileLedger/fileLedger.test.ts index 31489b7fe2..7cc1ac4ecd 100644 --- a/packages/agent-core-v2/test/session/sessionFileLedger/fileLedger.test.ts +++ b/packages/agent-core-v2/test/session/sessionFileLedger/fileLedger.test.ts @@ -2,10 +2,9 @@ * `sessionFileLedger` domain (L2) — verifies the optimistic-concurrency * verdict matrix (clean / stale / no-baseline) against a real tmpdir, a real * `HostFileSystem` (stat-call counted) and a fake os watcher: baselines only - * refresh on success, dirty ticks come from the watch service's folded - * state, watcher echoes of the session's own writes punch a stat and - * re-baseline, truncated windows fall back to the per-root dirty tick, and - * out-of-root targets degrade to a stat-only comparison. + * refresh on success, every write decision performs a fresh stat, dirty ticks + * arrive before debounced event delivery, watcher echoes of the session's own + * writes re-baseline, and truncated windows retain conservative root ticks. */ import { mkdtempSync, rmSync, writeFileSync } from 'node:fs'; @@ -171,6 +170,18 @@ describe('SessionFileLedger', () => { expect(await world.ledger.compare(file)).toBe('stale'); }); + it('detects an outside modification before the watch debounce flush', async () => { + const world = makeWorld(); + const file = join(world.workDir, 'a.txt'); + writeFileSync(file, 'hello'); + await world.ledger.recordBaseline(file); + + writeFileSync(file, 'hello world'); + world.fake.fire('a.txt', 'modified'); + + expect(await world.ledger.compare(file)).toBe('stale'); + }); + it('absorbs the watcher echo of the session own write and re-baselines the tick', async () => { const world = makeWorld(); const file = join(world.workDir, 'a.txt'); @@ -185,7 +196,7 @@ describe('SessionFileLedger', () => { expect(world.statCalls()).toBe(2); expect(await world.ledger.compare(file)).toBe('clean'); - expect(world.statCalls()).toBe(2); + expect(world.statCalls()).toBe(3); }); it('keeps a baselined file clean through an untouched truncated window', async () => { @@ -200,7 +211,7 @@ describe('SessionFileLedger', () => { expect(await world.ledger.compare(file)).toBe('clean'); expect(world.statCalls()).toBe(2); expect(await world.ledger.compare(file)).toBe('clean'); - expect(world.statCalls()).toBe(2); + expect(world.statCalls()).toBe(3); }); it('detects an outside modification through a truncated window via the stat punch', async () => { @@ -274,14 +285,14 @@ describe('SessionFileLedger', () => { expect(await world.ledger.compare(file)).toBe('stale'); }); - it('degrades to clean when stat fails for reasons other than not-found', async () => { + it('fails closed when stat fails for reasons other than not-found', async () => { const world = makeWorld(); const file = join(world.workDir, 'a.txt'); writeFileSync(file, 'hello'); world.poisonedPaths.add(file); await world.ledger.recordBaseline(file); - expect(await world.ledger.compare(file)).toBe('clean'); + expect(await world.ledger.compare(file)).toBe('stale'); world.poisonedPaths.clear(); await world.ledger.recordBaseline(file); @@ -289,7 +300,7 @@ describe('SessionFileLedger', () => { world.fake.fire('a.txt', 'modified'); vi.advanceTimersByTime(200); - expect(await world.ledger.compare(file)).toBe('clean'); + expect(await world.ledger.compare(file)).toBe('stale'); expect(world.statCalls()).toBeGreaterThan(0); }); }); diff --git a/packages/agent-core-v2/test/session/sessionFs/fsWatchService.test.ts b/packages/agent-core-v2/test/session/sessionFs/fsWatchService.test.ts index 48e4239fc9..5af13a0f35 100644 --- a/packages/agent-core-v2/test/session/sessionFs/fsWatchService.test.ts +++ b/packages/agent-core-v2/test/session/sessionFs/fsWatchService.test.ts @@ -303,7 +303,7 @@ describe('SessionFsWatchService ensured roots and dirty ticks', () => { expect(events).toEqual([]); }); - it('increments the tick per confined change and folds per-path dirty ticks at flush', () => { + it('increments and exposes per-path dirty ticks before the debounce flush', () => { const { svc, watch } = makeSession(); svc.ensureWatchedRoots([WORK_DIR]); expect(svc.currentTick).toBe(0); @@ -311,7 +311,7 @@ describe('SessionFsWatchService ensured roots and dirty ticks', () => { const a = join(WORK_DIR, 'a.ts'); watch.fire('a.ts', 'created'); expect(svc.currentTick).toBe(1); - expect(svc.dirtyTickFor(a)).toBeUndefined(); + expect(svc.dirtyTickFor(a)).toBe(1); vi.advanceTimersByTime(200); expect(svc.dirtyTickFor(a)).toBe(1); @@ -327,7 +327,7 @@ describe('SessionFsWatchService ensured roots and dirty ticks', () => { for (let i = 0; i < 501; i++) watch.fire(`f${i}.ts`, 'created'); vi.advanceTimersByTime(200); - expect(svc.dirtyTickFor(join(WORK_DIR, 'f0.ts'))).toBeUndefined(); + expect(svc.dirtyTickFor(join(WORK_DIR, 'f0.ts'))).toBe(1); expect(svc.rootDirtyTickFor(WORK_DIR)).toBe(501); }); diff --git a/packages/agent-core-v2/test/session/sessionLease/sessionLease.test.ts b/packages/agent-core-v2/test/session/sessionLease/sessionLease.test.ts index 51472b7d7d..fed97de1d4 100644 --- a/packages/agent-core-v2/test/session/sessionLease/sessionLease.test.ts +++ b/packages/agent-core-v2/test/session/sessionLease/sessionLease.test.ts @@ -28,8 +28,6 @@ import { sessionLeasePath, SESSION_LEASE_HEARTBEAT_INTERVAL_MS, SESSION_LEASE_TTL_MS, - UNREGISTERED_WRITER_RECHECK_DELAY_MS, - UNREGISTERED_WRITER_WINDOW_MS, } from '#/session/sessionLease/sessionLease'; let tmpDir: string; @@ -46,8 +44,15 @@ afterEach(() => { rmSync(tmpDir, { recursive: true, force: true }); }); -function acquire(sessionId = 's1', onLost: (sessionId: string) => void = () => {}): SessionLease { - return new SessionLease(sessionId, locks.acquire(sessionLeasePath(tmpDir, sessionId)), onLost); +async function acquire( + sessionId = 's1', + onLost: (sessionId: string) => void = () => {}, +): Promise { + return new SessionLease( + sessionId, + await locks.acquire(sessionLeasePath(tmpDir, sessionId)), + onLost, + ); } function thrownError(fn: () => void): Error2 { @@ -66,17 +71,17 @@ function hostWith(seeds: Parameters[0] = []): Scope } describe('SessionLease', () => { - it('reports its identity through info and passes the hard gate while held', () => { - const lease = acquire(); + it('reports its identity through info and passes the hard gate while held', async () => { + const lease = await acquire(); expect(lease.checkHeld()).toBe(true); expect(lease.info).toEqual({ sessionId: 's1', lockId: lease.lockId }); expect(() => lease.assertWritable()).not.toThrow(); lease.release(); }); - it('fails closed with session.lease_lost once the payload no longer carries its token', () => { + it('fails closed with session.lease_lost once the payload no longer carries its token', async () => { const onLost = vi.fn(); - const lease = acquire('s1', onLost); + const lease = await acquire('s1', onLost); writeFileSync( sessionLeasePath(tmpDir, 's1'), JSON.stringify({ lock_id: 'peer-token', pid: process.pid }), @@ -93,8 +98,8 @@ describe('SessionLease', () => { expect(onLost).toHaveBeenCalledTimes(1); }); - it('release is idempotent, unlinks the owned file, and later assertions throw', () => { - const lease = acquire(); + it('release is idempotent, unlinks the owned file, and later assertions throw', async () => { + const lease = await acquire(); lease.release(); lease.release(); @@ -104,8 +109,8 @@ describe('SessionLease', () => { expect(thrownError(() => lease.assertWritable()).code).toBe(ErrorCodes.SESSION_LEASE_LOST); }); - it('release never unlinks a payload owned by a peer', () => { - const lease = acquire(); + it('release never unlinks a payload owned by a peer', async () => { + const lease = await acquire(); writeFileSync( sessionLeasePath(tmpDir, 's1'), JSON.stringify({ lock_id: 'peer-token', pid: process.pid }), @@ -122,8 +127,6 @@ describe('SessionLease', () => { expect(SESSION_LEASE_TTL_MS).toBe(6000); expect(LEASE_CREATING_RETRY_AFTER_MS).toBe(1000); expect(HOLDER_UNRESPONSIVE_RETRY_AFTER_MS).toBe(2000); - expect(UNREGISTERED_WRITER_WINDOW_MS).toBe(5000); - expect(UNREGISTERED_WRITER_RECHECK_DELAY_MS).toBe(1000); }); it('sessionLeasePath lives under /session-leases/', () => { diff --git a/packages/kap-server/src/services/sessionListWatch/sessionListWatchService.ts b/packages/kap-server/src/services/sessionListWatch/sessionListWatchService.ts index 1e9a0a8f37..3ebfd7839b 100644 --- a/packages/kap-server/src/services/sessionListWatch/sessionListWatchService.ts +++ b/packages/kap-server/src/services/sessionListWatch/sessionListWatchService.ts @@ -1,6 +1,6 @@ /** * `SessionListWatchService` — the event plane of multi-instance session-list - * sync (design `.tmp/refactor-watch-design-v2.md` §3.8). + * sync. * * Several kap-server instances can share one home directory (the * `multi_server` experimental flag). The session list itself needs no diff --git a/packages/klient/src/contract/session/lifecycle.ts b/packages/klient/src/contract/session/lifecycle.ts index 355ceb401c..064e16cfed 100644 --- a/packages/klient/src/contract/session/lifecycle.ts +++ b/packages/klient/src/contract/session/lifecycle.ts @@ -79,6 +79,7 @@ export const sessionLifecycleContract = { create: { input: z.tuple([createSessionOptionsSchema]), output: handleWireSchema }, resume: { input: z.tuple([z.string()]), output: maybe(handleWireSchema) }, close: { input: z.tuple([z.string()]), output: noResult }, + forceAbort: { input: z.tuple([z.string()]), output: noResult }, archive: { input: z.tuple([z.string()]), output: noResult }, restore: { input: z.tuple([z.string()]), output: maybe(handleWireSchema) }, fork: { input: z.tuple([forkSessionOptionsSchema]), output: handleWireSchema }, diff --git a/packages/klient/src/core/facade/session.ts b/packages/klient/src/core/facade/session.ts index cce957aa60..1edb7e2eda 100644 --- a/packages/klient/src/core/facade/session.ts +++ b/packages/klient/src/core/facade/session.ts @@ -67,6 +67,8 @@ export interface SessionFacade { setArchived(archived: boolean): Promise; status(): Promise; close(): Promise; + /** Explicitly release a session lease after an ambiguous close flush failure. */ + forceAbort(): Promise; archive(): Promise; /** Re-materialize a closed session; `false` when it no longer exists. */ restore(): Promise; @@ -128,6 +130,8 @@ export function createSessionFacade(call: ScopedCaller, sessionId: string): Sess return 'idle'; }, close: () => call({}, 'sessionLifecycleService', 'close', [sessionId]) as Promise, + forceAbort: () => + call({}, 'sessionLifecycleService', 'forceAbort', [sessionId]) as Promise, archive: () => call({}, 'sessionLifecycleService', 'archive', [sessionId]) as Promise, restore: async () => { const handle = (await call({}, 'sessionLifecycleService', 'restore', [ diff --git a/packages/minidb/src/index.ts b/packages/minidb/src/index.ts index a6ea101928..e90af77d3c 100644 --- a/packages/minidb/src/index.ts +++ b/packages/minidb/src/index.ts @@ -226,6 +226,7 @@ export class MiniDb { private walTail: { dev: number; ino: number; size: number } | null = null; readOnly = false; private lock: LockFile | null = null; + private lockLossError: LockError | null = null; compactThresholdBytes = 64 * 1024 * 1024; autoCompact = true; @@ -286,7 +287,9 @@ export class MiniDb { db.readOnly = !!opts.readOnly; if (!db.readOnly) { - db.lock = new LockFile(path.join(db.dir, 'db.lock')); + db.lock = new LockFile(path.join(db.dir, 'db.lock'), { + onLost: () => db.markLockLost(), + }); const got = await db.lock.acquire(); if (!got) { if (opts.onLockFail === 'readonly') { @@ -1609,7 +1612,9 @@ export class MiniDb { * for a read-only instance. Exposed for lease-style holders such as the * cluster shard pool, which renew on a timer to prove liveness. */ async renewLock(): Promise { - await this.lock?.renew(); + if (this.lock === null) return; + await this.lock.renew(); + this.ensureWritable(); } /** Advanced/internal (read-replica owners such as the cluster shard pool): @@ -1664,5 +1669,11 @@ export class MiniDb { } private ensureWritable(): void { if (this.readOnly) throw new Error('MiniDb is open in read-only mode'); + if (this.lockLossError !== null) throw this.lockLossError; + } + + private markLockLost(): void { + if (this.lockLossError !== null) return; + this.lockLossError = new LockError(`database write lock was lost: ${this.dir}`); } } diff --git a/packages/minidb/src/lockfile.ts b/packages/minidb/src/lockfile.ts index f3521e4308..e434374580 100644 --- a/packages/minidb/src/lockfile.ts +++ b/packages/minidb/src/lockfile.ts @@ -37,7 +37,6 @@ import fs from 'node:fs/promises'; import path from 'node:path'; import { execFileSync } from 'node:child_process'; import { randomUUID } from 'node:crypto'; -import { renameReplace } from './rename-replace.js'; export class LockError extends Error { readonly code = 'ELOCKED'; @@ -59,6 +58,8 @@ export interface LockFileDeps { probeProcess?: (pid: number) => { alive: boolean; processStartedAt?: string }; /** Unique token of this acquire; compared on every guarded mutation. */ newLockId?: () => string; + /** Called exactly once when a held lock is replaced or disappears. */ + onLost?: () => void; } // Track held locks so we can release them on process exit as a safety net. @@ -165,6 +166,7 @@ export class LockFile { private readonly selfPid: number; private readonly probeProcess: NonNullable; private readonly newLockId: () => string; + private readonly onLost: (() => void) | undefined; constructor(path: string, deps: LockFileDeps = {}) { this.path = path; @@ -172,6 +174,7 @@ export class LockFile { this.selfPid = deps.selfPid ?? process.pid; this.probeProcess = deps.probeProcess ?? defaultProbeProcess; this.newLockId = deps.newLockId ?? randomUUID; + this.onLost = deps.onLost; } /** Try to acquire the lock exactly once. Returns true when this call created @@ -410,17 +413,38 @@ export class LockFile { } /** Refresh the lock timestamp (proves liveness to processes inspecting the - * lock file). No-op when the lock is not held. Uses write-tmp-then-rename - * so a crash mid-renew cannot leave a truncated, "stale-looking" lock file - * behind for a lock that is actually still owned. The payload keeps our - * token and start-time identity, so guards keep working after a renew. */ + * lock file). No-op when the lock is not held. The update is bound to the + * inode whose token was verified: replacing the public path after open can + * only make this holder lose ownership, never overwrite the replacement. */ async renew(): Promise { if (!this.held || this.lockId === undefined) return; - const tmp = `${this.path}.tmp-${process.pid}-${nextSidecarSeq()}`; - await fs.writeFile(tmp, this.payload(this.lockId)); - // Windows: replacing our own lock can still clash with a co-process's - // readFile/stat of it (EPERM) — the helper rides out such transients. - await renameReplace(tmp, this.path, { retries: 20 }); + const lockId = this.lockId; + let handle: fs.FileHandle; + try { + handle = await fs.open(this.path, 'r+'); + } catch (error) { + if ((error as NodeJS.ErrnoException).code === 'ENOENT') { + this.markLost(); + return; + } + throw error; + } + try { + const raw = await handle.readFile({ encoding: 'utf8' }); + if (tryParse(raw)?.lock_id !== lockId) { + this.markLost(); + return; + } + const data = Buffer.from(this.payload(lockId)); + await handle.write(data, 0, data.length, 0); + await handle.truncate(data.length); + await handle.sync(); + } finally { + await handle.close(); + } + if (tryParse((await this.readDiskText()) ?? '')?.lock_id !== lockId) { + this.markLost(); + } } /** Token-guarded release. Idempotent; a missing or foreign-owned file is @@ -428,6 +452,7 @@ export class LockFile { async release(): Promise { if (!this.held) return; this.held = false; + HELD.delete(this); const lockId = this.lockId; this.lockId = undefined; try { @@ -443,6 +468,7 @@ export class LockFile { releaseSync(): void { if (!this.held) return; this.held = false; + HELD.delete(this); const lockId = this.lockId; this.lockId = undefined; try { @@ -452,6 +478,14 @@ export class LockFile { /* same policy as release(): never unlink on uncertainty */ } } + + private markLost(): void { + if (!this.held) return; + this.held = false; + this.lockId = undefined; + HELD.delete(this); + this.onLost?.(); + } } function stringOrUndefined(v: unknown): string | undefined { diff --git a/packages/minidb/test/lock.test.ts b/packages/minidb/test/lock.test.ts index 5a81f6925d..4a675fc347 100644 --- a/packages/minidb/test/lock.test.ts +++ b/packages/minidb/test/lock.test.ts @@ -167,6 +167,26 @@ test('release never unlinks a lock that was taken over meanwhile', async () => { await cleanup(dir); }); +test('renew never overwrites a foreign lock generation', async () => { + const dir = await tmpDir(); + const p = path.join(dir, 'db.lock'); + const old = path.join(dir, 'db.lock.old'); + const lock = new LockFile(p, { newLockId: () => 'a-token' }); + assert.equal(await lock.acquire(), true); + + await fs.rename(p, old); + await fs.writeFile( + p, + JSON.stringify({ pid: process.pid, ts: Date.now(), lock_id: 'b-token' }), + ); + await lock.renew(); + + assert.equal(lock.held, false); + const onDisk = JSON.parse(await fs.readFile(p, 'utf8')); + assert.equal(onDisk.lock_id, 'b-token'); + await cleanup(dir); +}); + test('a read-back token mismatch rejects the acquire', async () => { const dir = await tmpDir(); const p = path.join(dir, 'db.lock'); diff --git a/packages/minidb/test/review-fixes.test.ts b/packages/minidb/test/review-fixes.test.ts index 0b761bab35..10af7b600a 100644 --- a/packages/minidb/test/review-fixes.test.ts +++ b/packages/minidb/test/review-fixes.test.ts @@ -84,6 +84,30 @@ test('expired keys are removed from secondary indexes', async () => { } }); +test('a writer that loses its lock cannot write into a successor generation', async () => { + const dir = await tmpDir(); + try { + const oldWriter = await MiniDb.open({ dir, valueCodec: 'string', autoCompact: false }); + await oldWriter.set('generation', 'old'); + + await fs.writeFile( + path.join(dir, 'db.lock'), + JSON.stringify({ pid: 0x7fffffff, ts: Date.now(), lock_id: 'successor-generation' }), + ); + await assert.rejects(oldWriter.renewLock(), /write lock was lost/); + + const newWriter = await MiniDb.open({ dir, valueCodec: 'string', autoCompact: false }); + await newWriter.set('generation', 'new'); + await assert.rejects(oldWriter.set('generation', 'old-again'), /write lock was lost/); + assert.equal(newWriter.get('generation'), 'new'); + + await newWriter.close(); + await oldWriter.close(); + } finally { + await fs.rm(dir, { recursive: true, force: true }); + } +}); + test('expired keys are removed from the full-text index', async () => { const dir = await tmpDir(); try { From 75473fc57a960b5965019806ec20a86b76d0fae1 Mon Sep 17 00:00:00 2001 From: 7Sageer <7sageer@djwcb.cn> Date: Mon, 20 Jul 2026 15:05:43 +0800 Subject: [PATCH 03/22] fix: isolate session redirects and fence task persistence --- .../agent-core-v2/src/agent/task/persist.ts | 20 +++- packages/agent-core-v2/src/agent/task/task.ts | 1 + .../src/agent/task/taskService.ts | 39 +++++++- .../sessionLifecycleService.ts | 23 ++++- .../agentLifecycle/agentLifecycleService.ts | 4 +- .../test/agent/task/persist.test.ts | 43 ++++++++- .../agent-core-v2/test/agent/task/stubs.ts | 8 ++ .../test/agent/task/taskService.test.ts | 66 +++++++++++++ .../test/agent/task/tools/task-tools.test.ts | 2 + packages/agent-core-v2/test/harness/agent.ts | 22 +++++ .../os/backends/node-local/tools/bash.test.ts | 1 + packages/klient/src/sessionRedirect.ts | 92 +++++++++++++------ packages/klient/src/transports/http/index.ts | 2 +- packages/klient/test/sessionRedirect.test.ts | 40 ++++++++ 14 files changed, 322 insertions(+), 41 deletions(-) diff --git a/packages/agent-core-v2/src/agent/task/persist.ts b/packages/agent-core-v2/src/agent/task/persist.ts index 9fc37d94fb..45e0bea002 100644 --- a/packages/agent-core-v2/src/agent/task/persist.ts +++ b/packages/agent-core-v2/src/agent/task/persist.ts @@ -20,8 +20,10 @@ import { join } from 'pathe'; +import { Error2, ErrorCodes } from '#/errors'; import type { IAtomicDocumentStore } from '#/persistence/interface/atomicDocumentStore'; import type { IFileSystemStorageService } from '#/persistence/interface/storage'; +import { IWriteAuthorityRegistry, sessionIdFromScope } from '#/persistence/interface/writeAuthority'; import type { AgentTaskInfo, AgentTaskStatus } from './types'; @@ -73,7 +75,8 @@ export class AgentTaskPersistence { private readonly agentScope: string, private readonly docs: IAtomicDocumentStore, private readonly bytes: IFileSystemStorageService, - private readonly fallbackRoot?: AgentTaskPersistenceRoot, + private readonly fallbackRoot: AgentTaskPersistenceRoot | undefined, + private readonly authorityRegistry: IWriteAuthorityRegistry, ) {} private primaryRoot(): AgentTaskPersistenceRoot { @@ -103,6 +106,7 @@ export class AgentTaskPersistence { async writeTask(task: PersistedTask): Promise { validateTaskId(task.taskId); + this.assertWritable(); await this.docs.set(this.tasksScope(), `${task.taskId}${JSON_SUFFIX}`, task); } @@ -122,9 +126,23 @@ export class AgentTaskPersistence { async appendTaskOutput(taskId: string, chunk: string): Promise { if (chunk.length === 0) return; + validateTaskId(taskId); + this.assertWritable(); await this.bytes.append(this.taskOutputScope(taskId), OUTPUT_LOG_KEY, textEncoder.encode(chunk)); } + private assertWritable(): void { + const sessionId = sessionIdFromScope(this.agentScope); + if (sessionId === undefined) return; + const authority = this.authorityRegistry.resolve(sessionId); + if (authority === undefined) { + throw new Error2(ErrorCodes.SESSION_LEASE_LOST, 'session has no registered write authority', { + details: { sessionId }, + }); + } + authority.assertWritable(); + } + async taskOutputSizeBytes(taskId: string): Promise { const output = await this.readTaskOutputData(taskId); return output?.data.byteLength ?? 0; diff --git a/packages/agent-core-v2/src/agent/task/task.ts b/packages/agent-core-v2/src/agent/task/task.ts index 9e091502fa..8e11832d99 100644 --- a/packages/agent-core-v2/src/agent/task/task.ts +++ b/packages/agent-core-v2/src/agent/task/task.ts @@ -84,6 +84,7 @@ export interface IAgentTaskService { getTask(taskId: string): AgentTaskInfo | undefined; list(activeOnly?: boolean, limit?: number): readonly AgentTaskInfo[]; persistOutput(taskId: string): void; + flushPersistence(): Promise; getOutputSnapshot( taskId: string, maxPreviewBytes: number, diff --git a/packages/agent-core-v2/src/agent/task/taskService.ts b/packages/agent-core-v2/src/agent/task/taskService.ts index 32bb4945f7..86d2fc7609 100644 --- a/packages/agent-core-v2/src/agent/task/taskService.ts +++ b/packages/agent-core-v2/src/agent/task/taskService.ts @@ -60,6 +60,7 @@ import { IConfigService } from '#/app/config/config'; import { ISessionContext } from '#/session/sessionContext/sessionContext'; import { IAtomicDocumentStore } from '#/persistence/interface/atomicDocumentStore'; import { IFileSystemStorageService } from '#/persistence/interface/storage'; +import { IWriteAuthorityRegistry } from '#/persistence/interface/writeAuthority'; import { ITelemetryService } from '#/app/telemetry/telemetry'; import { defineModel } from '#/wire/model'; import { IWireService } from '#/wire/wire'; @@ -225,6 +226,7 @@ export class AgentTaskService extends Disposable implements IAgentTaskService { @IConfigService private readonly config: IConfigService, @IAtomicDocumentStore atomicDocs: IAtomicDocumentStore, @IFileSystemStorageService byteStore: IFileSystemStorageService, + @IWriteAuthorityRegistry authorityRegistry: IWriteAuthorityRegistry, @ISessionContext session: ISessionContext, @IAgentScopeContext scopeContext: IAgentScopeContext, @ITaskService private readonly taskService: ITaskService, @@ -244,6 +246,7 @@ export class AgentTaskService extends Disposable implements IAgentTaskService { atomicDocs, byteStore, fallbackRoot, + authorityRegistry, ); this._register( this.wire.hooks.onDidRestore.register('task', async (_ctx, next) => { @@ -476,6 +479,34 @@ export class AgentTaskService extends Disposable implements IAgentTaskService { this.startOutputPersist(entry); } + async flushPersistence(): Promise { + let firstFailure: unknown; + for (;;) { + const entries = [...this.tasks.values()]; + for (const entry of entries) { + if (entry.pendingOutput.length > 0) this.startOutputPersist(entry); + } + const queues = entries.flatMap((entry) => [ + { entry, kind: 'state' as const, promise: entry.persistWriteQueue }, + { entry, kind: 'output' as const, promise: entry.outputWriteQueue }, + ]); + const results = await Promise.allSettled(queues.map(({ promise }) => promise)); + for (const result of results) { + if (result.status === 'rejected' && firstFailure === undefined) { + firstFailure = result.reason; + } + } + const changed = queues.some(({ entry, kind, promise }) => { + return kind === 'state' + ? entry.persistWriteQueue !== promise + : entry.outputWriteQueue !== promise; + }); + if (changed) continue; + if (firstFailure !== undefined) throw firstFailure; + return; + } + } + async loadFromDisk(options: AgentTaskLoadOptions = {}): Promise { const persistence = this.persistence; if (options.replace !== false) { @@ -872,8 +903,8 @@ export class AgentTaskService extends Disposable implements IAgentTaskService { const persistence = this.persistence; const info = this.toInfo(entry); entry.persistWriteQueue = entry.persistWriteQueue - .then(() => persistence.writeTask(info)) - .catch(() => { }); + .then(() => persistence.writeTask(info)); + void entry.persistWriteQueue.catch(() => {}); return entry.persistWriteQueue; } @@ -907,8 +938,8 @@ export class AgentTaskService extends Disposable implements IAgentTaskService { private appendTaskOutput(entry: ManagedTask, chunk: string): void { const persistence = this.persistence; entry.outputWriteQueue = entry.outputWriteQueue - .then(() => persistence.appendTaskOutput(entry.taskId, chunk)) - .catch(() => { }); + .then(() => persistence.appendTaskOutput(entry.taskId, chunk)); + void entry.outputWriteQueue.catch(() => {}); } private startOutputPersist(entry: ManagedTask): void { diff --git a/packages/agent-core-v2/src/app/sessionLifecycle/sessionLifecycleService.ts b/packages/agent-core-v2/src/app/sessionLifecycle/sessionLifecycleService.ts index 6c9168d2b3..5c321e4198 100644 --- a/packages/agent-core-v2/src/app/sessionLifecycle/sessionLifecycleService.ts +++ b/packages/agent-core-v2/src/app/sessionLifecycle/sessionLifecycleService.ts @@ -83,6 +83,7 @@ import { OsLockErrors, } from '#/os/interface/crossProcessLock'; import { IAgentLifecycleService, MAIN_AGENT_ID } from '#/session/agentLifecycle/agentLifecycle'; +import { IAgentTaskService } from '#/agent/task/task'; import { ensureMainAgent } from '#/session/agentLifecycle/mainAgent'; import { ISessionMcpService } from '#/session/mcp/sessionMcp'; import { labelsFromAgentMeta } from '#/session/agentLifecycle/subagentMetadata'; @@ -141,6 +142,7 @@ interface SessionEntry { closeKind?: SessionCloseKind; closeStep: number; closePromise?: Promise; + dirtyAbortPromise?: Promise; } export class SessionLifecycleService extends Disposable implements ISessionLifecycleService { @@ -890,16 +892,31 @@ export class SessionLifecycleService extends Disposable implements ISessionLifec } private dirtyAbortSession(entry: SessionEntry): void { + if (entry.dirtyAbortPromise !== undefined) return; if (this.entries.get(entry.handle.id) === entry) this.entries.delete(entry.handle.id); + + let taskServices: IAgentTaskService[] = []; try { - this.disposeSessionHandle(entry); + taskServices = entry.handle + .accessor.get(IAgentLifecycleService) + .list() + .map((agent) => agent.accessor.get(IAgentTaskService)); } catch { } try { - entry.registration.dispose(); + this.disposeSessionHandle(entry); } catch { } - entry.lease.release(); + entry.dirtyAbortPromise = Promise.allSettled( + taskServices.map((tasks) => tasks.flushPersistence()), + ).then(() => { + try { + entry.registration.dispose(); + } catch { + } + entry.lease.release(); + }); + void entry.dirtyAbortPromise.catch(() => {}); } private async readMetaFromDisk( diff --git a/packages/agent-core-v2/src/session/agentLifecycle/agentLifecycleService.ts b/packages/agent-core-v2/src/session/agentLifecycle/agentLifecycleService.ts index 548bf4cdf9..c700cc1e1b 100644 --- a/packages/agent-core-v2/src/session/agentLifecycle/agentLifecycleService.ts +++ b/packages/agent-core-v2/src/session/agentLifecycle/agentLifecycleService.ts @@ -312,7 +312,9 @@ export class AgentLifecycleService extends Disposable implements IAgentLifecycle const handle = this.handles.get(agentId); if (handle === undefined) return; this.handles.delete(agentId); - await handle.accessor.get(IAgentTaskService).stopAllOnExit('Session closed'); + const tasks = handle.accessor.get(IAgentTaskService); + await tasks.stopAllOnExit('Session closed'); + await tasks.flushPersistence?.(); const loop = handle.accessor.get(IAgentLoopService); const compaction = handle.accessor.get(IAgentFullCompactionService).compacting; const compactionSettled = compaction?.promise.catch(() => undefined) ?? Promise.resolve(); diff --git a/packages/agent-core-v2/test/agent/task/persist.test.ts b/packages/agent-core-v2/test/agent/task/persist.test.ts index 8054fe6876..10749d8a8b 100644 --- a/packages/agent-core-v2/test/agent/task/persist.test.ts +++ b/packages/agent-core-v2/test/agent/task/persist.test.ts @@ -16,14 +16,17 @@ import { afterEach, beforeEach, describe, expect, it } from 'vitest'; import { SyncDescriptor } from '#/_base/di/descriptors'; import { DisposableStore } from '#/_base/di/lifecycle'; import { TestInstantiationService } from '#/_base/di/test'; +import { ErrorCodes } from '#/errors'; import { AgentTaskPersistence, type AgentTaskInfo, } from '#/agent/task/task'; import { JsonAtomicDocumentStore } from '#/persistence/backends/node-fs/atomicDocumentStore'; import { FileStorageService } from '#/persistence/backends/node-fs/fileStorageService'; +import { WriteAuthorityRegistryService } from '#/persistence/backends/node-fs/writeAuthorityRegistryService'; import { IAtomicDocumentStore } from '#/persistence/interface/atomicDocumentStore'; import { IFileSystemStorageService } from '#/persistence/interface/storage'; +import type { IWriteAuthorityRegistry } from '#/persistence/interface/writeAuthority'; const SESSION_SCOPE = 'session'; const AGENT_SCOPE = `${SESSION_SCOPE}/agents/main`; @@ -32,6 +35,7 @@ let disposables: DisposableStore; let sessionDir: string; let docs: IAtomicDocumentStore; let bytes: IFileSystemStorageService; +let authorityRegistry: IWriteAuthorityRegistry; let persistence: AgentTaskPersistence; function sample(overrides: Partial> = {}): Extract { @@ -64,7 +68,15 @@ beforeEach(async () => { ix.set(IAtomicDocumentStore, new SyncDescriptor(JsonAtomicDocumentStore)); docs = ix.get(IAtomicDocumentStore); bytes = ix.get(IFileSystemStorageService); - persistence = new AgentTaskPersistence(sessionDir, SESSION_SCOPE, docs, bytes); + authorityRegistry = new WriteAuthorityRegistryService(); + persistence = new AgentTaskPersistence( + sessionDir, + SESSION_SCOPE, + docs, + bytes, + undefined, + authorityRegistry, + ); }); afterEach(async () => { @@ -77,7 +89,14 @@ describe('AgentTaskPersistence', () => { scope: string, fallbackRoot?: { readonly dir: string; readonly scope: string }, ): AgentTaskPersistence { - return new AgentTaskPersistence(join(sessionDir, scope), scope, docs, bytes, fallbackRoot); + return new AgentTaskPersistence( + join(sessionDir, scope), + scope, + docs, + bytes, + fallbackRoot, + authorityRegistry, + ); } function sessionRoot(): { readonly dir: string; readonly scope: string } { @@ -108,6 +127,26 @@ describe('AgentTaskPersistence', () => { }); }); + it('fails closed when the session write authority is unregistered', async () => { + const scope = 'sessions/workspace/test-session/agents/main'; + const fenced = rootedPersistence(scope); + const registration = authorityRegistry.register({ + sessionId: 'test-session', + assertWritable: () => {}, + }); + + await fenced.writeTask(sample()); + await fenced.appendTaskOutput(sample().taskId, 'before release'); + registration.dispose(); + + await expect(fenced.writeTask(sample({ status: 'completed', endedAt: 2 }))).rejects.toMatchObject({ + code: ErrorCodes.SESSION_LEASE_LOST, + }); + await expect(fenced.appendTaskOutput(sample().taskId, 'after release')).rejects.toMatchObject({ + code: ErrorCodes.SESSION_LEASE_LOST, + }); + }); + it('listTasks enumerates all persisted entries', async () => { await persistence.writeTask(sample({ taskId: 'bash-11111111' })); await persistence.writeTask(sample({ taskId: 'bash-22222222', command: 'pnpm test' })); diff --git a/packages/agent-core-v2/test/agent/task/stubs.ts b/packages/agent-core-v2/test/agent/task/stubs.ts index 66f59de320..b822521d77 100644 --- a/packages/agent-core-v2/test/agent/task/stubs.ts +++ b/packages/agent-core-v2/test/agent/task/stubs.ts @@ -14,6 +14,7 @@ import { } from '#/agent/task/task'; import { JsonAtomicDocumentStore } from '#/persistence/backends/node-fs/atomicDocumentStore'; import { FileStorageService } from '#/persistence/backends/node-fs/fileStorageService'; +import { WriteAuthorityRegistryService } from '#/persistence/backends/node-fs/writeAuthorityRegistryService'; export type TaskServiceTestManager = IAgentTaskService & { loadFromDisk(): Promise; @@ -26,10 +27,17 @@ export const TASK_TEST_AGENT_SCOPE = `${TASK_TEST_SESSION_SCOPE}/agents/main`; export function createAgentTaskPersistence(homedir: string): AgentTaskPersistence { const storage = new FileStorageService(homedir); + const authorityRegistry = new WriteAuthorityRegistryService(); + authorityRegistry.register({ + sessionId: 'test-session', + assertWritable: () => {}, + }); return new AgentTaskPersistence( join(homedir, TASK_TEST_AGENT_SCOPE), TASK_TEST_AGENT_SCOPE, new JsonAtomicDocumentStore(storage), storage, + undefined, + authorityRegistry, ); } diff --git a/packages/agent-core-v2/test/agent/task/taskService.test.ts b/packages/agent-core-v2/test/agent/task/taskService.test.ts index f873dfeb97..7c58d4adb6 100644 --- a/packages/agent-core-v2/test/agent/task/taskService.test.ts +++ b/packages/agent-core-v2/test/agent/task/taskService.test.ts @@ -36,6 +36,7 @@ import { IAgentScopeContext, makeAgentScopeContext } from '#/agent/scopeContext/ import { ISessionContext, makeSessionContext } from '#/session/sessionContext/sessionContext'; import { IAtomicDocumentStore } from '#/persistence/interface/atomicDocumentStore'; import { IFileSystemStorageService } from '#/persistence/interface/storage'; +import { IWriteAuthorityRegistry } from '#/persistence/interface/writeAuthority'; import { ITelemetryService } from '#/app/telemetry/telemetry'; import { IAgentToolRegistryService } from '#/agent/toolRegistry/toolRegistry'; import { createHooks } from '#/hooks'; @@ -148,6 +149,10 @@ describe('AgentTaskService', () => { flush: async () => {}, close: async () => {}, }); + ix.stub(IWriteAuthorityRegistry, { + resolve: () => ({ sessionId: 'test-session', assertWritable: () => {} }), + register: () => toDisposable(() => {}), + }); ix.set(IAgentTaskService, new SyncDescriptor(AgentTaskService)); }); afterEach(() => disposables.dispose()); @@ -251,6 +256,63 @@ describe('AgentTaskService', () => { await svc.stop(taskId); }); + it('flushPersistence waits for a delayed output append', async () => { + let releaseAppend!: () => void; + let markAppendStarted!: () => void; + const appendStarted = new Promise((resolve) => { + markAppendStarted = resolve; + }); + const appendReleased = new Promise((resolve) => { + releaseAppend = resolve; + }); + ix.stub(IFileSystemStorageService, { + read: async () => undefined, + readStream: async function* () {}, + write: async () => {}, + append: async () => { + markAppendStarted(); + await appendReleased; + }, + list: async () => [], + delete: async () => {}, + flush: async () => {}, + close: async () => {}, + }); + const svc = ix.get(IAgentTaskService); + let releaseTask!: () => void; + const taskRunning = new Promise((resolve) => { + releaseTask = resolve; + }); + const taskId = svc.registerTask({ + idPrefix: 'test', + kind: 'agent', + description: 'delayed output', + start: async (sink) => { + sink.appendOutput('delayed output'); + await taskRunning; + await sink.settle({ status: 'completed' }); + }, + toInfo: (base) => ({ ...base, kind: 'agent' }), + }); + await Promise.resolve(); + await Promise.resolve(); + svc.persistOutput(taskId); + await appendStarted; + + let flushed = false; + const flush = svc.flushPersistence().then(() => { + flushed = true; + }); + await Promise.resolve(); + expect(flushed).toBe(false); + + releaseAppend(); + await flush; + expect(flushed).toBe(true); + releaseTask(); + await svc.stop(taskId); + }); + it('dispose aborts live tasks as a last resort', async () => { const svc = ix.get(IAgentTaskService); let abortReason: unknown; @@ -431,6 +493,10 @@ describe('AgentTaskService', () => { ); ix.stub(IAtomicDocumentStore, docs); ix.stub(IFileSystemStorageService, bytes); + ix.stub(IWriteAuthorityRegistry, { + resolve: () => ({ sessionId: 'test-session', assertWritable: () => {} }), + register: () => toDisposable(() => {}), + }); ix.set(IAgentTaskService, new SyncDescriptor(AgentTaskService)); return ix; } diff --git a/packages/agent-core-v2/test/agent/task/tools/task-tools.test.ts b/packages/agent-core-v2/test/agent/task/tools/task-tools.test.ts index 97051a38b3..c6f2ec5808 100644 --- a/packages/agent-core-v2/test/agent/task/tools/task-tools.test.ts +++ b/packages/agent-core-v2/test/agent/task/tools/task-tools.test.ts @@ -172,6 +172,8 @@ class FakeTaskService implements IAgentTaskService { persistOutput(_taskId: string): void {} + async flushPersistence(): Promise {} + async getOutputSnapshot( taskId: string, _maxPreviewBytes: number, diff --git a/packages/agent-core-v2/test/harness/agent.ts b/packages/agent-core-v2/test/harness/agent.ts index b5d6104c91..fd9e73fb83 100644 --- a/packages/agent-core-v2/test/harness/agent.ts +++ b/packages/agent-core-v2/test/harness/agent.ts @@ -138,6 +138,12 @@ import { ISessionQuestionService, type QuestionResult } from '#/session/question import { ISessionSkillCatalog } from '#/session/sessionSkillCatalog/skillCatalog'; import { ISessionSwarmService } from '#/session/swarm/sessionSwarm'; import type { PathAccessOperation } from '#/session/workspaceContext/workspaceContext'; +import { WriteAuthorityRegistryService } from '#/persistence/backends/node-fs/writeAuthorityRegistryService'; +import { IWriteAuthorityRegistry } from '#/persistence/interface/writeAuthority'; +import { + IHostFsWatchService, + type HostFsChange, +} from '#/os/interface/hostFsWatch'; import { recordAgentEvents, type RecordedEventEntry } from '../snapshot/events'; import { createFakeHostFs, createFakeProcessRunner } from '../tools/fixtures/fake-exec'; @@ -444,6 +450,7 @@ export function homeDirServices(homeDir: string | undefined): TestAgentServiceOv if (homeDir !== undefined) { for (const [id, value] of bootstrapSeed({ homeDir, + osHomeDir: homeDir, cwd: process.cwd(), env: process.env, })) { @@ -566,6 +573,14 @@ const noopHookRunner: IExternalHooksRunnerService = { fireAndForgetTrigger: async () => [], }; +const noopHostFsWatchService: IHostFsWatchService = { + _serviceBrand: undefined, + watch: () => ({ + onDidChange: Event.None as Event, + dispose: () => {}, + }), +}; + export function permissionModeServices(mode: PermissionMode): TestAgentServiceOverride { return agentService(IAgentPermissionModeService, createPermissionModeService(mode)); } @@ -906,6 +921,13 @@ export class AgentTestContext { })) { reg.defineInstance(id, value); } + const authorityRegistry = new WriteAuthorityRegistryService(); + authorityRegistry.register({ + sessionId, + assertWritable: () => {}, + }); + reg.defineInstance(IWriteAuthorityRegistry, authorityRegistry); + reg.defineInstance(IHostFsWatchService, noopHostFsWatchService); const memoryStorage = (): SyncDescriptor => new SyncDescriptor(InMemoryStorageService, [], true); reg.defineDescriptor(IFileSystemStorageService, memoryStorage()); diff --git a/packages/agent-core-v2/test/os/backends/node-local/tools/bash.test.ts b/packages/agent-core-v2/test/os/backends/node-local/tools/bash.test.ts index 6994c01ccc..1a5190e9ee 100644 --- a/packages/agent-core-v2/test/os/backends/node-local/tools/bash.test.ts +++ b/packages/agent-core-v2/test/os/backends/node-local/tools/bash.test.ts @@ -474,6 +474,7 @@ function createFakeTaskService(options: { maxRunningTasks?: number } = {}): { const service: IAgentTaskService = { _serviceBrand: undefined, + flushPersistence: async () => {}, track(): never { throw new Error('fake IAgentTaskService.track is not implemented'); }, diff --git a/packages/klient/src/sessionRedirect.ts b/packages/klient/src/sessionRedirect.ts index 55ab3e9ec9..0135172416 100644 --- a/packages/klient/src/sessionRedirect.ts +++ b/packages/klient/src/sessionRedirect.ts @@ -23,11 +23,12 @@ * an external / older process: * terminal safety stop * - * `KlientConnection` owns the per-client current origin plus the follow/retry - * policy and is shared by the `SessionRedirectChannel` decorator, so once one - * call redirects, all later calls — on every facade handle the klient handed - * out — land on the holder. Both policies are bounded per original call - * (`maxRedirects`, `maxCreatingRetries`) so redirect ping-pong cannot spin. + * `KlientConnection` owns the redirect routes plus the follow/retry policy and + * is shared by the `SessionRedirectChannel` decorator. Session routes are + * isolated by `sessionId`, while the legacy `currentUrl` getter continues to + * expose the most recently selected origin. Both policies are bounded per + * original call (`maxRedirects`, `maxCreatingRetries`) so redirect ping-pong + * cannot spin. */ import type { @@ -128,7 +129,7 @@ export interface SessionRedirectInfo { /** Origin the request was sent to, e.g. `http://127.0.0.1:58627`. */ readonly from: string; /** - * Holder origin the client switched to; every later request lands there. + * Holder origin the client switched to; later requests for this route land there. * This is the address to surface as "connected to the instance holding * the session ()". */ @@ -157,13 +158,14 @@ const defaultSleep = (ms: number): Promise => new Promise((resolve) => setTimeout(resolve, ms)); /** - * Shared per-client redirect state: the current target origin (mutated by a - * followed redirect), the resolved policy, and the redirect listeners. The - * channel reads the origin from here on every attempt, so a redirect - * re-points the whole client, not one request. + * Shared per-client redirect state: the target origin for each session, the + * legacy most-recent target, the resolved policy, and redirect listeners. + * Session calls read their own route on every attempt, so one holder cannot + * overwrite another session's route. */ export class KlientConnection { private url: string; + private readonly sessionUrls = new Map(); readonly follow: boolean; readonly maxRedirects: number; readonly maxCreatingRetries: number; @@ -184,6 +186,10 @@ export class KlientConnection { return this.url; } + currentUrlFor(sessionId: string | undefined): string { + return sessionId === undefined ? this.url : (this.sessionUrls.get(sessionId) ?? this.url); + } + onRedirect(listener: (info: SessionRedirectInfo) => void): IDisposable { this.redirectListeners.add(listener); return { dispose: () => this.redirectListeners.delete(listener) }; @@ -194,11 +200,16 @@ export class KlientConnection { * origin, or `undefined` when the holder address already equals the current * origin (a self-redirect is a loop, handled as an error by the caller). */ - applyRedirect(address: string): string | undefined { + applyRedirect(address: string, sessionId?: string): string | undefined { const next = normalizeInstanceOrigin(address); - if (next === this.url) return undefined; - const previous = this.url; - this.url = next; + const previous = this.currentUrlFor(sessionId); + if (next === previous) return undefined; + if (sessionId === undefined) { + this.url = next; + } else { + this.sessionUrls.set(sessionId, next); + this.url = next; + } return previous; } @@ -237,23 +248,38 @@ export class SessionRedirectChannel implements KlientChannel { private readonly token?: string; private readonly fetchImpl?: typeof fetch; private readonly WebSocketImpl?: WsLikeCtor; - private inner: HttpChannel; + private readonly inners = new Map(); constructor(opts: SessionRedirectChannelOptions) { this.connection = opts.connection; this.token = opts.token; this.fetchImpl = opts.fetch; this.WebSocketImpl = opts.WebSocketImpl; - this.inner = this.buildInner(); } - private buildInner(): HttpChannel { - return new HttpChannel({ - url: this.connection.currentUrl, + private routeKey(sessionId: string | undefined): string { + return sessionId ?? ''; + } + + private innerFor(sessionId: string | undefined): HttpChannel { + const key = this.routeKey(sessionId); + const existing = this.inners.get(key); + if (existing !== undefined) return existing; + const inner = new HttpChannel({ + url: this.connection.currentUrlFor(sessionId), token: this.token, fetch: this.fetchImpl, WebSocketImpl: this.WebSocketImpl, }); + this.inners.set(key, inner); + return inner; + } + + private resetInner(sessionId: string | undefined): void { + const key = this.routeKey(sessionId); + const stale = this.inners.get(key); + this.inners.delete(key); + void stale?.close().catch(() => {}); } async call(scope: ScopeRef, service: string, method: string, args: unknown[]): Promise { @@ -262,8 +288,10 @@ export class SessionRedirectChannel implements KlientChannel { let creatingRetries = 0; const trace: string[] = []; for (;;) { + const attemptUrl = connection.currentUrlFor(scope.sessionId); + const inner = this.innerFor(scope.sessionId); try { - return await this.inner.call(scope, service, method, args); + return await inner.call(scope, service, method, args); } catch (error) { const details = readSessionOwnershipDetails(error); if (details === undefined) throw error; @@ -298,22 +326,26 @@ export class SessionRedirectChannel implements KlientChannel { 'still held elsewhere — giving up to avoid a redirect loop', ); } - const previous = connection.applyRedirect(target); - if (previous === undefined) { + if (target === attemptUrl) { throw enrichOwnershipError( error, `the holder address ${target} is this very instance, yet the request was refused; ` + 'lease and server disagree — retry shortly, or force-unlock the session lease', ); } + const previous = connection.applyRedirect(target, scope.sessionId); follows += 1; - trace.push(`${previous} → ${target}`); + trace.push(`${previous ?? attemptUrl} → ${target}`); // Rebuild the inner channel onto the holder BEFORE notifying, so // listeners that re-subscribe already land on the new origin. - const stale = this.inner; - this.inner = this.buildInner(); - void stale.close().catch(() => {}); - connection.notifyRedirect({ from: previous, to: target, follow: follows }); + if (previous !== undefined) { + this.resetInner(scope.sessionId); + // Global calls have no session route, so preserve the legacy + // behavior that a session redirect also invalidates global + // event subscriptions tied to the previous currentUrl. + if (scope.sessionId !== undefined) this.resetInner(undefined); + connection.notifyRedirect({ from: previous, to: target, follow: follows }); + } continue; } case 'creating': { @@ -359,11 +391,13 @@ export class SessionRedirectChannel implements KlientChannel { handler: (data: unknown) => void, onError?: (error: Error) => void, ): IDisposable { - return this.inner.listen(scope, source, handler, onError); + return this.innerFor(scope.sessionId).listen(scope, source, handler, onError); } close(): Promise { - return this.inner.close(); + const inners = [...this.inners.values()]; + this.inners.clear(); + return Promise.all(inners.map((inner) => inner.close())).then(() => undefined); } } diff --git a/packages/klient/src/transports/http/index.ts b/packages/klient/src/transports/http/index.ts index 0cce2d4ca1..4ed5279e51 100644 --- a/packages/klient/src/transports/http/index.ts +++ b/packages/klient/src/transports/http/index.ts @@ -29,7 +29,7 @@ export interface HttpKlientOptions } export interface HttpKlient extends Klient { - /** Origin every later call targets; changes after a followed redirect. */ + /** Most recently selected origin; session calls keep isolated routes internally. */ readonly currentUrl: string; } diff --git a/packages/klient/test/sessionRedirect.test.ts b/packages/klient/test/sessionRedirect.test.ts index f0c62ca8da..1fbf0dfa77 100644 --- a/packages/klient/test/sessionRedirect.test.ts +++ b/packages/klient/test/sessionRedirect.test.ts @@ -66,7 +66,47 @@ function rig( const readS1 = (channel: SessionRedirectChannel): Promise => channel.call({ sessionId: 's1' }, 'sessionMetadata', 'read', []); +const readSession = (channel: SessionRedirectChannel, sessionId: string): Promise => + channel.call({ sessionId }, 'sessionMetadata', 'read', []); + describe('session ownership redirect (SESSION_HELD_BY_PEER)', () => { + it('isolates concurrent session redirects to different holders', async () => { + const firstRequests: Array<{ url: string; resolve: (response: Response) => void }> = []; + const fetchMock = vi.fn((input) => { + const url = String(input); + if (firstRequests.length < 2) { + return new Promise((resolve) => { + firstRequests.push({ url, resolve }); + }); + } + return Promise.resolve(okEnvelope({ id: url.includes('/session/a/') ? 'a' : 'b' })); + }); + const { channel } = rig(fetchMock); + + const a = readSession(channel, 'a'); + const b = readSession(channel, 'b'); + await tick(); + expect(firstRequests.map(({ url }) => url)).toEqual([ + `${PEER}/api/v2/session/a/sessionMetadata/read`, + `${PEER}/api/v2/session/b/sessionMetadata/read`, + ]); + + firstRequests[1]!.resolve( + heldByPeer({ kind: 'held-by-peer', phase: 'routable', address: HOLDER }), + ); + firstRequests[0]!.resolve( + heldByPeer({ kind: 'held-by-peer', phase: 'routable', address: 'http://127.0.0.1:60003' }), + ); + + await expect(Promise.all([a, b])).resolves.toEqual([{ id: 'a' }, { id: 'b' }]); + expect(urls(fetchMock)).toEqual([ + `${PEER}/api/v2/session/a/sessionMetadata/read`, + `${PEER}/api/v2/session/b/sessionMetadata/read`, + `${HOLDER}/api/v2/session/b/sessionMetadata/read`, + `http://127.0.0.1:60003/api/v2/session/a/sessionMetadata/read`, + ]); + }); + it('follows a routable redirect: rebases onto the holder, re-sends the call, emits the signal', async () => { const fetchMock = vi .fn() From 304910ed482ca124253d6a7096e23399a9a05e09 Mon Sep 17 00:00:00 2001 From: 7Sageer <7sageer@djwcb.cn> Date: Mon, 20 Jul 2026 17:06:00 +0800 Subject: [PATCH 04/22] fix: harden fs watch and session lifecycle races --- .../harden-session-lifecycle-readiness.md | 5 + apps/kimi-web/src/api/daemon/ws.ts | 12 +- .../app/sessionLifecycle/sessionLifecycle.ts | 1 + .../sessionLifecycleService.ts | 37 +++++ .../backends/node-local/hostFsWatchService.ts | 30 +++- .../src/os/interface/hostFsWatch.ts | 1 + .../backends/node-fs/fileStorageService.ts | 25 ++- .../externalHooksRunner/integration.test.ts | 1 + .../app/sessionExport/sessionExport.test.ts | 1 + .../sessionLifecycle/sessionLifecycle.test.ts | 16 ++ packages/agent-core-v2/test/harness/agent.ts | 1 + .../test/session/sessionFs/stubs.ts | 1 + .../sessionSkillCatalog/skillCatalog.test.ts | 1 + .../sessionListWatchService.ts | 74 +++++++-- packages/kap-server/src/start.ts | 6 +- .../ws/v1/sessionEventBroadcaster.ts | 45 +++++- .../transport/ws/v1/sessionEventJournal.ts | 142 ++++++++++++++++-- .../src/transport/ws/v1/wsConnectionV1.ts | 26 +++- .../test/sessionEventBroadcaster.test.ts | 13 ++ .../test/sessionEventJournal.test.ts | 24 ++- packages/minidb/src/cluster/index.ts | 1 + packages/minidb/src/index.ts | 8 +- packages/minidb/src/lockfile.ts | 33 +++- packages/minidb/test/lock.test.ts | 20 +++ 24 files changed, 472 insertions(+), 52 deletions(-) create mode 100644 .changeset/harden-session-lifecycle-readiness.md diff --git a/.changeset/harden-session-lifecycle-readiness.md b/.changeset/harden-session-lifecycle-readiness.md new file mode 100644 index 0000000000..9106e3b510 --- /dev/null +++ b/.changeset/harden-session-lifecycle-readiness.md @@ -0,0 +1,5 @@ +--- +"@moonshot-ai/kimi-code": patch +--- + +Harden session startup and shutdown coordination so filesystem updates and lock acquisition remain bounded during races. diff --git a/apps/kimi-web/src/api/daemon/ws.ts b/apps/kimi-web/src/api/daemon/ws.ts index 32088809f9..eff16ce616 100644 --- a/apps/kimi-web/src/api/daemon/ws.ts +++ b/apps/kimi-web/src/api/daemon/ws.ts @@ -6,6 +6,7 @@ import { traceWsIn, traceWsLifecycle, traceWsOut } from '../../debug/trace'; import { classifyFrame } from './agentEventProjector'; import { getCredential } from './serverAuth'; +import { SESSION_HELD_BY_PEER_CODE } from './sessionOwnership'; import type { WireEvent, WireServerFrame } from './wire'; // Mirrors kap-server's WS_BEARER_PROTOCOL_PREFIX. The browser WebSocket API @@ -406,7 +407,16 @@ export class DaemonEventSocket { break; case 'ack': - // ack frames are fire-and-forget for now (no request tracking) + if (frame.code === SESSION_HELD_BY_PEER_CODE) { + const ownershipDetails = frame.payload?.ownership_details; + if (ownershipDetails && typeof ownershipDetails === 'object') { + for (const details of Object.values(ownershipDetails as Record)) { + this.handlers.onError(frame.code, frame.msg, false, details); + } + } else { + this.handlers.onError(frame.code, frame.msg, false, frame.payload?.details); + } + } break; case 'terminal_output': { diff --git a/packages/agent-core-v2/src/app/sessionLifecycle/sessionLifecycle.ts b/packages/agent-core-v2/src/app/sessionLifecycle/sessionLifecycle.ts index 9ac44c4f96..3dcb44d471 100644 --- a/packages/agent-core-v2/src/app/sessionLifecycle/sessionLifecycle.ts +++ b/packages/agent-core-v2/src/app/sessionLifecycle/sessionLifecycle.ts @@ -86,6 +86,7 @@ export interface ISessionLifecycleService { readonly onDidArchiveSession: Event; readonly onDidForkSession: Event; readonly hooks: Hooks; + beginClose(): Promise; create(opts: CreateSessionOptions): Promise; get(sessionId: string): ISessionScopeHandle | undefined; list(): readonly ISessionScopeHandle[]; diff --git a/packages/agent-core-v2/src/app/sessionLifecycle/sessionLifecycleService.ts b/packages/agent-core-v2/src/app/sessionLifecycle/sessionLifecycleService.ts index 5c321e4198..2dd6210951 100644 --- a/packages/agent-core-v2/src/app/sessionLifecycle/sessionLifecycleService.ts +++ b/packages/agent-core-v2/src/app/sessionLifecycle/sessionLifecycleService.ts @@ -161,6 +161,8 @@ export class SessionLifecycleService extends Disposable implements ISessionLifec 'onWillCloseSession', ]); private readonly resuming = new Map>(); + private readonly inFlightOperations = new Set>(); + private closing = false; constructor( @IInstantiationService private readonly instantiation: IInstantiationService, @@ -187,7 +189,17 @@ export class SessionLifecycleService extends Disposable implements ISessionLifec super(); } + beginClose(): Promise { + this.closing = true; + return Promise.allSettled([...this.inFlightOperations, ...this.resuming.values()]).then(() => undefined); + } + async create(opts: CreateSessionOptions): Promise { + this.assertOpen(); + return this.trackOperation(this.doCreate(opts)); + } + + private async doCreate(opts: CreateSessionOptions): Promise { const sessionId = opts.sessionId ?? createSessionId(); const entry = await this.materializeSession({ ...opts, sessionId }); const handle = entry.handle; @@ -315,6 +327,7 @@ export class SessionLifecycleService extends Disposable implements ISessionLifec } resume(sessionId: string): Promise { + if (this.closing) return Promise.reject(this.lifecycleClosingError()); const inflight = this.resuming.get(sessionId); if (inflight !== undefined) return inflight; const live = this.get(sessionId); @@ -553,6 +566,11 @@ export class SessionLifecycleService extends Disposable implements ISessionLifec } async fork(opts: ForkSessionOptions): Promise { + this.assertOpen(); + return this.trackOperation(this.doFork(opts)); + } + + private async doFork(opts: ForkSessionOptions): Promise { const sourceId = opts.sourceSessionId; const sourceHandle = this.get(sourceId); @@ -662,6 +680,7 @@ export class SessionLifecycleService extends Disposable implements ISessionLifec } async createChild(opts: CreateChildSessionOptions): Promise { + this.assertOpen(); const title = opts.title ?? `Child: ${(await this.resolveSourceTitle(opts.sourceSessionId)) ?? opts.sourceSessionId}`; @@ -792,10 +811,28 @@ export class SessionLifecycleService extends Disposable implements ISessionLifec } override dispose(): void { + this.closing = true; for (const entry of this.entries.values()) this.dirtyAbortSession(entry); super.dispose(); } + private assertOpen(): void { + if (this.closing) throw this.lifecycleClosingError(); + } + + private lifecycleClosingError(): Error2 { + return new Error2(ErrorCodes.SESSION_CLOSED, 'session lifecycle is closing'); + } + + private trackOperation(promise: Promise): Promise { + this.inFlightOperations.add(promise); + const remove = (): void => { + this.inFlightOperations.delete(promise); + }; + promise.then(remove, remove); + return promise; + } + private onLeaseLost(sessionId: string): void { this.log.error('session lease lost; tearing the session down', { sessionId }); const entry = this.entries.get(sessionId); diff --git a/packages/agent-core-v2/src/os/backends/node-local/hostFsWatchService.ts b/packages/agent-core-v2/src/os/backends/node-local/hostFsWatchService.ts index c7b1286353..c68b24eb51 100644 --- a/packages/agent-core-v2/src/os/backends/node-local/hostFsWatchService.ts +++ b/packages/agent-core-v2/src/os/backends/node-local/hostFsWatchService.ts @@ -26,25 +26,27 @@ import { const DEFAULT_IGNORED = (p: string): boolean => /(?:^|[/\\])\.git(?:$|[/\\])/.test(p); -/** - * chokidar attaches `fs.watch` to every scanned entry; special files (unix - * sockets, fifos, devices) make that call throw UNKNOWN — e.g. a depth-0 - * sentinel landing on a shared temp root that contains sockets, or a project - * tree with a socket in it. Filter them up front so one such entry can never - * poison the whole watcher. - */ function isSpecialEntry(stats: Stats | undefined): boolean { return stats !== undefined && !stats.isFile() && !stats.isDirectory() && !stats.isSymbolicLink(); } class HostFsWatchHandle implements IHostFsWatchHandle { + readonly ready: Promise; readonly onDidChange: Event; private readonly emitter: Emitter; private readonly watcher: FSWatcher; private disposed = false; + private readySettled = false; + private resolveReady!: () => void; + private rejectReady!: (error: unknown) => void; constructor(path: string, options: HostFsWatchOptions | undefined) { + this.ready = new Promise((resolve, reject) => { + this.resolveReady = resolve; + this.rejectReady = reject; + }); + void this.ready.catch(() => undefined); this.emitter = new Emitter(); this.onDidChange = this.emitter.event; this.watcher = new FSWatcher({ @@ -56,18 +58,32 @@ class HostFsWatchHandle implements IHostFsWatchHandle { isSpecialEntry(stats) || (options?.ignored?.(path) ?? DEFAULT_IGNORED(path)), }); this.watcher.on('all', (eventName: string, absPath: string) => { + if (this.disposed) return; const mapped = mapChokidarEvent(eventName, absPath); if (mapped !== undefined) this.emitter.fire(mapped); }); this.watcher.on('error', (error: unknown) => { + if (!this.readySettled) { + this.readySettled = true; + this.rejectReady(error); + } onUnexpectedError(error); }); + this.watcher.on('ready', () => { + if (this.readySettled) return; + this.readySettled = true; + this.resolveReady(); + }); this.watcher.add(path); } dispose(): void { if (this.disposed) return; this.disposed = true; + if (!this.readySettled) { + this.readySettled = true; + this.resolveReady(); + } void this.watcher.close().catch(() => undefined); this.emitter.dispose(); } diff --git a/packages/agent-core-v2/src/os/interface/hostFsWatch.ts b/packages/agent-core-v2/src/os/interface/hostFsWatch.ts index 890ce98c61..fff8589149 100644 --- a/packages/agent-core-v2/src/os/interface/hostFsWatch.ts +++ b/packages/agent-core-v2/src/os/interface/hostFsWatch.ts @@ -28,6 +28,7 @@ export interface HostFsWatchOptions { } export interface IHostFsWatchHandle extends IDisposable { + readonly ready: Promise; readonly onDidChange: Event; } diff --git a/packages/agent-core-v2/src/persistence/backends/node-fs/fileStorageService.ts b/packages/agent-core-v2/src/persistence/backends/node-fs/fileStorageService.ts index 43bd55e6cb..6919bf3d69 100644 --- a/packages/agent-core-v2/src/persistence/backends/node-fs/fileStorageService.ts +++ b/packages/agent-core-v2/src/persistence/backends/node-fs/fileStorageService.ts @@ -23,7 +23,7 @@ * this backend, never `node:fs` directly. */ -import { createReadStream, mkdirSync } from 'node:fs'; +import { createReadStream, mkdirSync, statSync } from 'node:fs'; import { mkdir, open, readFile, readdir, unlink } from 'node:fs/promises'; import { FSWatcher } from 'chokidar'; import { dirname, join, normalize } from 'pathe'; @@ -50,6 +50,25 @@ import { StorageError, StorageErrors, toStorageIoError } from '#/persistence/int const WATCH_DEBOUNCE_MS = 150; const STORAGE_LOCK_WAIT_TIMEOUT_MS = 10_000; +type WatchFingerprint = { readonly size: number; readonly mtimeMs: number; readonly ino: number } | undefined; + +function fingerprint(path: string): WatchFingerprint { + try { + const stats = statSync(path); + return { size: stats.size, mtimeMs: stats.mtimeMs, ino: stats.ino }; + } catch { + return undefined; + } +} + +function sameFingerprint(left: WatchFingerprint, right: WatchFingerprint): boolean { + return ( + left?.size === right?.size && + left?.mtimeMs === right?.mtimeMs && + left?.ino === right?.ino + ); +} + function isEnoent(error: unknown): boolean { return (error as NodeJS.ErrnoException).code === 'ENOENT'; } @@ -179,6 +198,7 @@ export class FileStorageService implements IFileSystemStorageService { const arm = (): void => { try { mkdirSync(dir, { recursive: true, mode: this.dirMode }); + const before = fingerprint(normalizedTarget); watcher = new FSWatcher({ ignoreInitial: true, awaitWriteFinish: false, @@ -193,6 +213,9 @@ export class FileStorageService implements IFileSystemStorageService { if (normalize(changedPath) === normalizedTarget) schedule(); }); watcher.on('error', (error: unknown) => onUnexpectedError(error)); + watcher.on('ready', () => { + if (!sameFingerprint(before, fingerprint(normalizedTarget))) schedule(); + }); watcher.add(dir); } catch (error) { onUnexpectedError(error); diff --git a/packages/agent-core-v2/test/app/externalHooksRunner/integration.test.ts b/packages/agent-core-v2/test/app/externalHooksRunner/integration.test.ts index 7c9ba95386..632504e46d 100644 --- a/packages/agent-core-v2/test/app/externalHooksRunner/integration.test.ts +++ b/packages/agent-core-v2/test/app/externalHooksRunner/integration.test.ts @@ -192,6 +192,7 @@ function stubSessionLifecycle(): ISessionLifecycleService { onDidCloseSession: Event.None as ISessionLifecycleService['onDidCloseSession'], onDidArchiveSession: Event.None as ISessionLifecycleService['onDidArchiveSession'], onDidForkSession: Event.None as ISessionLifecycleService['onDidForkSession'], + beginClose: async () => {}, create: async () => { throw new Error('not implemented'); }, diff --git a/packages/agent-core-v2/test/app/sessionExport/sessionExport.test.ts b/packages/agent-core-v2/test/app/sessionExport/sessionExport.test.ts index 598671139e..2020035dac 100644 --- a/packages/agent-core-v2/test/app/sessionExport/sessionExport.test.ts +++ b/packages/agent-core-v2/test/app/sessionExport/sessionExport.test.ts @@ -852,6 +852,7 @@ function registerSessionExportServices( onDidCloseSession: noopEvent, onDidArchiveSession: noopEvent, onDidForkSession: noopEvent, + beginClose: async () => {}, hooks: createHooks([ 'onDidCreateSession', 'onWillCloseSession', diff --git a/packages/agent-core-v2/test/app/sessionLifecycle/sessionLifecycle.test.ts b/packages/agent-core-v2/test/app/sessionLifecycle/sessionLifecycle.test.ts index 011bc48a61..06a28c80d6 100644 --- a/packages/agent-core-v2/test/app/sessionLifecycle/sessionLifecycle.test.ts +++ b/packages/agent-core-v2/test/app/sessionLifecycle/sessionLifecycle.test.ts @@ -517,6 +517,22 @@ describe('SessionLifecycleService', () => { expect(svc.get('s1')).toBeUndefined(); }); + it('rejects new session materialization after close admission begins', async () => { + const svc = build(); + await svc.beginClose(); + + await expect(svc.create({ sessionId: 's1', workDir: '/tmp/proj' })).rejects.toMatchObject({ + code: ErrorCodes.SESSION_CLOSED, + }); + await expect(svc.resume('s1')).rejects.toMatchObject({ code: ErrorCodes.SESSION_CLOSED }); + await expect( + svc.fork({ sourceSessionId: 's1', newSessionId: 's2' }), + ).rejects.toMatchObject({ code: ErrorCodes.SESSION_CLOSED }); + await expect( + svc.createChild({ sourceSessionId: 's1', newSessionId: 's3' }), + ).rejects.toMatchObject({ code: ErrorCodes.SESSION_CLOSED }); + }); + it('create seeds identity and materializes metadata', async () => { const svc = build(); const h = await svc.create({ sessionId: 's1', workDir: '/tmp/proj' }); diff --git a/packages/agent-core-v2/test/harness/agent.ts b/packages/agent-core-v2/test/harness/agent.ts index fd9e73fb83..b4e4994a5b 100644 --- a/packages/agent-core-v2/test/harness/agent.ts +++ b/packages/agent-core-v2/test/harness/agent.ts @@ -576,6 +576,7 @@ const noopHookRunner: IExternalHooksRunnerService = { const noopHostFsWatchService: IHostFsWatchService = { _serviceBrand: undefined, watch: () => ({ + ready: Promise.resolve(), onDidChange: Event.None as Event, dispose: () => {}, }), diff --git a/packages/agent-core-v2/test/session/sessionFs/stubs.ts b/packages/agent-core-v2/test/session/sessionFs/stubs.ts index ede06c648f..7d963e98c1 100644 --- a/packages/agent-core-v2/test/session/sessionFs/stubs.ts +++ b/packages/agent-core-v2/test/session/sessionFs/stubs.ts @@ -40,6 +40,7 @@ export function fakeHostFsWatch(): FakeWatch { let listener: ((e: HostFsChange) => void) | undefined; let disposed = false; const handle: IHostFsWatchHandle = { + ready: Promise.resolve(), onDidChange: (l) => { listener = l; return { dispose: () => (listener = undefined) }; diff --git a/packages/agent-core-v2/test/session/sessionSkillCatalog/skillCatalog.test.ts b/packages/agent-core-v2/test/session/sessionSkillCatalog/skillCatalog.test.ts index d177101128..9bb83b6ed1 100644 --- a/packages/agent-core-v2/test/session/sessionSkillCatalog/skillCatalog.test.ts +++ b/packages/agent-core-v2/test/session/sessionSkillCatalog/skillCatalog.test.ts @@ -46,6 +46,7 @@ function hostFsWatchStub(): IHostFsWatchService { return { _serviceBrand: undefined, watch: (): IHostFsWatchHandle => ({ + ready: Promise.resolve(), onDidChange: (): { dispose(): void } => ({ dispose: () => {} }), dispose: () => {}, }), diff --git a/packages/kap-server/src/services/sessionListWatch/sessionListWatchService.ts b/packages/kap-server/src/services/sessionListWatch/sessionListWatchService.ts index 3ebfd7839b..ca753dcf0f 100644 --- a/packages/kap-server/src/services/sessionListWatch/sessionListWatchService.ts +++ b/packages/kap-server/src/services/sessionListWatch/sessionListWatchService.ts @@ -54,6 +54,8 @@ export class SessionListWatchService { private rootHandle: IHostFsWatchHandle | undefined; /** workspaceId → handle, added/removed as workspace directories come and go. */ private readonly workspaceHandles = new Map(); + private readonly pendingWorkspaceWatchers = new Set(); + private readonly removedWorkspaces = new Set(); private timer: ReturnType | undefined; private started = false; @@ -90,7 +92,11 @@ export class SessionListWatchService { } this.rootHandle = this.fsWatch.watch(this.sessionsDir, { recursive: false }); this.rootHandle.onDidChange((change) => this.onRootChange(change)); - return this.scanWorkspaces(); + return this.rootHandle.ready + .catch((error: unknown) => { + this.logger?.warn({ err: String(error) }, 'session list watch: root watcher failed to ready'); + }) + .then(() => this.scanWorkspaces()); } dispose(): void { @@ -100,6 +106,8 @@ export class SessionListWatchService { } for (const handle of this.workspaceHandles.values()) handle.dispose(); this.workspaceHandles.clear(); + this.pendingWorkspaceWatchers.clear(); + this.removedWorkspaces.clear(); this.rootHandle?.dispose(); this.rootHandle = undefined; this.started = false; @@ -117,7 +125,7 @@ export class SessionListWatchService { } for (const entry of entries) { if (!this.started) return; - if (entry.isDirectory()) this.addWorkspaceWatcher(entry.name); + if (entry.isDirectory()) await this.addWorkspaceWatcher(entry.name); } } @@ -129,8 +137,10 @@ export class SessionListWatchService { if (workspaceId === '' || workspaceId.includes(sep)) return; this.scheduleHint(); if (change.action === 'created') { - this.addWorkspaceWatcher(workspaceId); + this.removedWorkspaces.delete(workspaceId); + void this.addWorkspaceWatcher(workspaceId); } else if (change.action === 'deleted') { + this.removedWorkspaces.add(workspaceId); this.removeWorkspaceWatcher(workspaceId); } // The hint for the root event itself already covers a workspace that @@ -145,22 +155,48 @@ export class SessionListWatchService { this.scheduleHint(); } - private addWorkspaceWatcher(workspaceId: string): void { - if (this.workspaceHandles.has(workspaceId)) return; - let handle: IHostFsWatchHandle; + private async addWorkspaceWatcher(workspaceId: string): Promise { + if (this.workspaceHandles.has(workspaceId) || this.pendingWorkspaceWatchers.has(workspaceId)) return; + this.pendingWorkspaceWatchers.add(workspaceId); try { - handle = this.fsWatch.watch(join(this.sessionsDir, workspaceId), { - recursive: false, + const workspacePath = join(this.sessionsDir, workspaceId); + const before = await this.readDirectoryNames(workspacePath); + let handle: IHostFsWatchHandle; + try { + handle = this.fsWatch.watch(workspacePath, { + recursive: false, + }); + } catch (error) { + this.logger?.warn( + { workspaceId, err: String(error) }, + 'session list watch: failed to watch workspace dir', + ); + return; + } + if (!this.started || this.removedWorkspaces.has(workspaceId)) { + handle.dispose(); + return; + } + this.workspaceHandles.set(workspaceId, handle); + handle.onDidChange((change) => this.onWorkspaceChange(change)); + await handle.ready.catch((error: unknown) => { + this.logger?.warn({ workspaceId, err: String(error) }, 'session list watch: workspace watcher failed to ready'); }); - } catch (error) { - this.logger?.warn( - { workspaceId, err: String(error) }, - 'session list watch: failed to watch workspace dir', - ); - return; + if (!this.started || this.workspaceHandles.get(workspaceId) !== handle) return; + const after = await this.readDirectoryNames(workspacePath); + if (!sameNames(before, after)) this.scheduleHint(); + } finally { + this.pendingWorkspaceWatchers.delete(workspaceId); + } + } + + private async readDirectoryNames(path: string): Promise> { + try { + const entries = await readdir(path, { withFileTypes: true }); + return new Set(entries.filter((entry) => entry.isDirectory()).map((entry) => entry.name)); + } catch { + return new Set(); } - this.workspaceHandles.set(workspaceId, handle); - handle.onDidChange((change) => this.onWorkspaceChange(change)); } private removeWorkspaceWatcher(workspaceId: string): void { @@ -186,3 +222,9 @@ export class SessionListWatchService { } } } + +function sameNames(left: Set, right: Set): boolean { + if (left.size !== right.size) return false; + for (const name of left) if (!right.has(name)) return false; + return true; +} diff --git a/packages/kap-server/src/start.ts b/packages/kap-server/src/start.ts index b1a3925004..a1ba7b9f47 100644 --- a/packages/kap-server/src/start.ts +++ b/packages/kap-server/src/start.ts @@ -327,6 +327,8 @@ export async function startServer(opts: ServerStartOptions = {}): Promise => { + const lifecycle = core.accessor.get(ISessionLifecycleService); + const lifecycleDrain = lifecycle.beginClose(); // Stop the sessions-tree watcher first: a debounced hint firing mid-close // would publish into an event bus whose subscribers are already unwinding. sessionListWatch?.dispose(); @@ -342,8 +344,7 @@ export async function startServer(opts: ServerStartOptions = {}): Promise { + const summary = await this.opts.core.accessor.get(ISessionIndex).get(sessionId); + if (summary === undefined) return undefined; + + const homeDir = this.opts.homeDir ?? dirname(dirname(this.opts.eventsDir)); + const inspection = this.opts.core + .accessor.get(ICrossProcessLockService) + .inspect(sessionLeasePath(homeDir, sessionId)); + if (inspection.state === 'creating') { + return { + kind: 'held-by-peer', + phase: 'creating', + retry_after_ms: LEASE_CREATING_RETRY_AFTER_MS, + }; + } + if (inspection.state !== 'held' || inspection.payload === undefined) return undefined; + + const heartbeatAt = inspection.payload.heartbeatAt; + if (heartbeatAt !== undefined && Date.now() - heartbeatAt > SESSION_LEASE_TTL_MS) { + return { + kind: 'held-by-peer', + phase: 'holder-unresponsive', + retry_after_ms: HOLDER_UNRESPONSIVE_RETRY_AFTER_MS, + }; + } + if (inspection.payload.address !== undefined) { + return { kind: 'held-by-peer', phase: 'routable', address: inspection.payload.address }; + } + return { kind: 'held-by-peer', phase: 'held-by-local-instance' }; + } + unsubscribe(sessionId: string, target: BroadcastTarget): void { this.sessions.get(sessionId)?.targets.delete(target); } @@ -226,6 +266,7 @@ export class SessionEventBroadcaster { // Drain so the cursor reflects everything dispatched so far. await state.queue; const { journal, tail } = state; + if (journal.flushInFlight || cursor.seq === 0) await journal.flush(); const currentSeq = journal.seq; const { epoch } = journal; @@ -928,9 +969,11 @@ export class SessionEventBroadcaster { } private *allTargets(): Iterable { + const targets = new Set(); for (const state of this.sessions.values()) { - for (const target of state.targets.keys()) yield target; + for (const target of state.targets.keys()) targets.add(target); } + yield* targets; } } diff --git a/packages/kap-server/src/transport/ws/v1/sessionEventJournal.ts b/packages/kap-server/src/transport/ws/v1/sessionEventJournal.ts index 8885e22e59..c0fd52eac1 100644 --- a/packages/kap-server/src/transport/ws/v1/sessionEventJournal.ts +++ b/packages/kap-server/src/transport/ws/v1/sessionEventJournal.ts @@ -38,7 +38,7 @@ */ import { createReadStream } from 'node:fs'; -import { mkdir, open as openFile } from 'node:fs/promises'; +import { mkdir, open as openFile, readFile, rename, truncate } from 'node:fs/promises'; import { dirname, join } from 'node:path'; import { ulid } from 'ulid'; @@ -155,6 +155,10 @@ export class SessionEventJournal { return this.stickyError !== undefined || this.consecutiveFailures > 0; } + get flushInFlight(): boolean { + return this.flushPromise !== undefined; + } + /** * Open (or create-on-first-append) the journal for `filePath`. Scans an * existing file to recover `{epoch, lastSeq}`. This open is READ-ONLY: a @@ -169,21 +173,36 @@ export class SessionEventJournal { let epoch: string | undefined; let lastSeq = 0; let sawAnyLine = false; + let corrupt = false; + let segmentSeq = 0; try { - for await (const raw of readLines(filePath)) { + for await (const line of readLines(filePath)) { + const raw = line.raw; + if (raw.trim().length === 0) continue; sawAnyLine = true; const parsed = parseJournalLine(raw); - if (parsed === undefined) continue; // torn/corrupt line — skip + if (parsed === undefined) { + if (!line.terminated) continue; + corrupt = true; + break; + } if (parsed.kind === 'journal_header') { epoch = parsed.epoch; // last header wins + segmentSeq = 0; continue; } - if (parsed.seq > lastSeq) lastSeq = parsed.seq; + if (epoch === undefined || parsed.seq !== segmentSeq + 1) { + corrupt = true; + break; + } + segmentSeq = parsed.seq; + lastSeq = segmentSeq; } } catch (error) { const code = (error as NodeJS.ErrnoException).code; if (code !== 'ENOENT') { + corrupt = true; logger.warn( { filePath, err: String(error) }, 'event journal unreadable; starting a fresh epoch on next append', @@ -191,6 +210,12 @@ export class SessionEventJournal { } } + if (corrupt || (sawAnyLine && epoch === undefined)) { + await quarantineCorruptJournal(filePath, logger); + epoch = undefined; + lastSeq = 0; + } + if (epoch === undefined) { if (sawAnyLine) { // File exists but has no parseable header — treat as corrupt and let @@ -237,9 +262,26 @@ export class SessionEventJournal { if (this.stickyError !== undefined) throw this.stickyError; const out: JournalEntry[] = []; try { - for await (const raw of readLines(this.filePath)) { + let activeEpoch: string | undefined; + let expectedSeq = 0; + for await (const line of readLines(this.filePath)) { + const raw = line.raw; + if (raw.trim().length === 0) continue; const parsed = parseJournalLine(raw); - if (parsed === undefined || parsed.kind !== 'event') continue; + if (parsed === undefined) { + if (!line.terminated) continue; + throw new JournalStorageError(this.filePath, new Error('corrupt journal line')); + } + if (parsed.kind === 'journal_header') { + activeEpoch = parsed.epoch; + expectedSeq = 0; + continue; + } + if (activeEpoch === undefined || parsed.seq !== expectedSeq + 1) { + throw new JournalStorageError(this.filePath, new Error('journal sequence gap')); + } + expectedSeq = parsed.seq; + if (activeEpoch !== this.currentEpoch) continue; if (parsed.seq <= fromSeqExclusive) continue; out.push({ seq: parsed.seq, envelope: parsed.envelope }); if (out.length >= limit) break; @@ -252,6 +294,7 @@ export class SessionEventJournal { } async flush(): Promise { + if (this.stickyError !== undefined) return; while (this.flushPromise !== undefined || this.pendingLines.length > 0) { if (this.flushPromise === undefined) { this.flushPromise = this.flushOnce().then(() => { @@ -292,9 +335,21 @@ export class SessionEventJournal { const headerLine = this.headerPending ? this.buildHeaderLine() : undefined; const pendingSnapshot = this.pendingLines.slice(); if (headerLine === undefined && pendingSnapshot.length === 0) return true; - const lines = headerLine !== undefined ? [headerLine, ...pendingSnapshot] : pendingSnapshot; + let lines = headerLine !== undefined ? [headerLine, ...pendingSnapshot] : pendingSnapshot; try { await mkdir(dirname(this.filePath), { recursive: true }); + if (this.consecutiveFailures > 0) { + const committed = await countCommittedPrefix(this.filePath, lines); + if (committed > 0) { + if (headerLine !== undefined) this.headerPending = false; + this.pendingLines.splice(0, Math.max(0, committed - (headerLine === undefined ? 0 : 1))); + lines = lines.slice(committed); + if (lines.length === 0) { + this.consecutiveFailures = 0; + return true; + } + } + } // One open per batch: write header+lines, fsync, close. const handle = await openFile(this.filePath, 'a'); try { @@ -304,6 +359,13 @@ export class SessionEventJournal { await handle.close(); } } catch (error) { + const committed = await countCommittedPrefix(this.filePath, lines); + if (committed > 0) { + if (headerLine !== undefined && committed > 0) this.headerPending = false; + this.pendingLines.splice(0, Math.max(0, committed - (headerLine === undefined ? 0 : 1))); + this.stickyError ??= new JournalStorageError(this.filePath, error); + return true; + } this.consecutiveFailures += 1; if (this.consecutiveFailures >= STICKY_FAILURE_THRESHOLD && this.stickyError === undefined) { // Sticky storage failure: durable events must never silently degrade @@ -370,30 +432,86 @@ function parseJournalLine(raw: string): JournalHeaderLine | JournalEventLine | u const kind = (value as { kind?: unknown }).kind; if (kind === 'journal_header') { const epoch = (value as { epoch?: unknown }).epoch; - if (typeof epoch !== 'string' || epoch.length === 0) return undefined; + const version = (value as { version?: unknown }).version; + if (typeof epoch !== 'string' || epoch.length === 0 || version !== JOURNAL_VERSION) return undefined; return value as JournalHeaderLine; } if (kind === 'event') { const seq = (value as { seq?: unknown }).seq; const envelope = (value as { envelope?: unknown }).envelope; if (typeof seq !== 'number' || !Number.isInteger(seq) || seq <= 0) return undefined; - if (typeof envelope !== 'object' || envelope === null) return undefined; + if (!isEventEnvelope(envelope) || envelope.seq !== seq) return undefined; return value as JournalEventLine; } return undefined; } -async function* readLines(filePath: string): AsyncIterable { +function isEventEnvelope(value: unknown): value is EventEnvelope { + if (typeof value !== 'object' || value === null) return false; + const envelope = value as Partial; + return ( + typeof envelope.type === 'string' && + typeof envelope.seq === 'number' && + Number.isInteger(envelope.seq) && + envelope.seq > 0 && + typeof envelope.timestamp === 'string' && + Object.prototype.hasOwnProperty.call(envelope, 'payload') + ); +} + +interface RawJournalLine { + raw: string; + terminated: boolean; +} + +async function* readLines(filePath: string): AsyncIterable { let buffered = ''; const stream = createReadStream(filePath, { encoding: 'utf8' }); for await (const chunk of stream) { buffered += chunk; let newlineIndex = buffered.indexOf('\n'); while (newlineIndex !== -1) { - yield buffered.slice(0, newlineIndex); + yield { raw: buffered.slice(0, newlineIndex), terminated: true }; buffered = buffered.slice(newlineIndex + 1); newlineIndex = buffered.indexOf('\n'); } } - if (buffered.length > 0) yield buffered; + if (buffered.length > 0) yield { raw: buffered, terminated: false }; +} + +async function quarantineCorruptJournal(filePath: string, logger: JournalLogger): Promise { + try { + await rename(filePath, `${filePath}.corrupt.${ulid()}`); + } catch (error) { + if ((error as NodeJS.ErrnoException).code !== 'ENOENT') { + logger.warn({ filePath, err: String(error) }, 'event journal quarantine failed; truncating file'); + await truncate(filePath, 0).catch(() => undefined); + } + } +} + +async function countCommittedPrefix(filePath: string, lines: readonly string[]): Promise { + if (lines.length === 0) return 0; + let raw: string; + try { + raw = await readFile(filePath, 'utf8'); + } catch { + return 0; + } + const existing = raw.split('\n'); + if (existing.at(-1) === '') existing.pop(); + let committed = 0; + for (let count = 1; count <= lines.length; count++) { + const start = existing.length - count; + if (start < 0) break; + let matches = true; + for (let index = 0; index < count; index++) { + if (existing[start + index] !== lines[index]) { + matches = false; + break; + } + } + if (matches) committed = count; + } + return committed; } diff --git a/packages/kap-server/src/transport/ws/v1/wsConnectionV1.ts b/packages/kap-server/src/transport/ws/v1/wsConnectionV1.ts index 5fb9a8c745..f101314765 100644 --- a/packages/kap-server/src/transport/ws/v1/wsConnectionV1.ts +++ b/packages/kap-server/src/transport/ws/v1/wsConnectionV1.ts @@ -15,6 +15,7 @@ * down. */ +import { ErrorCode } from '../../../protocol/error-codes'; import { WS_PROTOCOL_VERSION, type SessionCursor } from '../../../protocol/ws-control'; import { ulid } from 'ulid'; import type { RawData, WebSocket } from 'ws'; @@ -37,6 +38,7 @@ import { type ResyncReason, type SessionEventBroadcaster, } from './sessionEventBroadcaster'; +import type { SessionOwnershipDetails } from '@moonshot-ai/agent-core-v2'; import { FsWatchBridge } from './fsWatchBridge'; import { SkillCatalogBridge } from './skillCatalogBridge'; @@ -199,7 +201,9 @@ export class WsConnectionV1 implements BroadcastTarget { const agentFilter = parseAgentFilter(payload['agent_filter']); const accepted: string[] = []; + const notFound: string[] = []; const resyncRequired: string[] = []; + const ownershipDetails: Record = {}; const serverCursors: Record = {}; for (const sid of subscriptions) { @@ -208,15 +212,20 @@ export class WsConnectionV1 implements BroadcastTarget { cursors?.[sid], agentFilter?.[sid], accepted, + notFound, resyncRequired, + ownershipDetails, serverCursors, ); } + const hasOwnershipFailure = Object.keys(ownershipDetails).length > 0; this.sendFrame( - buildAck(frame.id ?? '', 0, 'success', { + buildAck(frame.id ?? '', hasOwnershipFailure ? ErrorCode.SESSION_HELD_BY_PEER : 0, hasOwnershipFailure ? 'session held by peer' : 'success', { accepted_subscriptions: accepted, + not_found: notFound, resync_required: resyncRequired, + ownership_details: ownershipDetails, cursors: serverCursors, }), ); @@ -231,13 +240,16 @@ export class WsConnectionV1 implements BroadcastTarget { const accepted: string[] = []; const notFound: string[] = []; const resyncRequired: string[] = []; + const ownershipDetails: Record = {}; const serverCursors: Record = {}; for (const sid of sessionIds) { const filter = agentFilter?.[sid]; const ok = await this.broadcaster.subscribe(sid, this, filter); if (!ok) { - notFound.push(sid); + const ownership = await this.broadcaster.getSubscriptionFailure?.(sid); + if (ownership !== undefined) ownershipDetails[sid] = ownership; + else notFound.push(sid); continue; } this.subscriptions.set(sid, filter); @@ -252,11 +264,13 @@ export class WsConnectionV1 implements BroadcastTarget { } } + const hasOwnershipFailure = Object.keys(ownershipDetails).length > 0; this.sendFrame( - buildAck(frame.id ?? '', 0, 'success', { + buildAck(frame.id ?? '', hasOwnershipFailure ? ErrorCode.SESSION_HELD_BY_PEER : 0, hasOwnershipFailure ? 'session held by peer' : 'success', { accepted, not_found: notFound, resync_required: resyncRequired, + ownership_details: ownershipDetails, cursors: serverCursors, }), ); @@ -314,12 +328,16 @@ export class WsConnectionV1 implements BroadcastTarget { cursor: SessionCursor | undefined, filter: AgentFilter | undefined, accepted: string[], + notFound: string[], resyncRequired: string[], + ownershipDetails: Record, serverCursors: Record, ): Promise { const ok = await this.broadcaster.subscribe(sid, this, filter); if (!ok) { - resyncRequired.push(sid); + const ownership = await this.broadcaster.getSubscriptionFailure?.(sid); + if (ownership !== undefined) ownershipDetails[sid] = ownership; + else notFound.push(sid); return; } this.subscriptions.set(sid, filter); diff --git a/packages/kap-server/test/sessionEventBroadcaster.test.ts b/packages/kap-server/test/sessionEventBroadcaster.test.ts index 534b8066c3..77e84bcf5d 100644 --- a/packages/kap-server/test/sessionEventBroadcaster.test.ts +++ b/packages/kap-server/test/sessionEventBroadcaster.test.ts @@ -612,6 +612,19 @@ describe('SessionEventBroadcaster', () => { await expect(stat(join(dir, '__global__.jsonl'))).rejects.toMatchObject({ code: 'ENOENT' }); }); + it('sends one global hint when a target subscribes to multiple sessions', async () => { + sessions.set('s1', new FakeLifecycle()); + sessions.set('s2', new FakeLifecycle()); + const { target, envelopes } = collectingTarget(); + await bc.subscribe('s1', target); + await bc.subscribe('s2', target); + + eventBus.emit({ type: 'session.list_changed', payload: {} }); + + await vi.waitFor(() => expect(envelopes).toHaveLength(1)); + expect(envelopes[0]).toMatchObject({ type: 'session.list_changed', session_id: '__global__' }); + }); + it('never serves replay from the in-memory tail while the journal has write failures', async () => { const lc = new FakeLifecycle(); const main = lc.addAgent('main'); diff --git a/packages/kap-server/test/sessionEventJournal.test.ts b/packages/kap-server/test/sessionEventJournal.test.ts index a13cb76692..a2feb8ca87 100644 --- a/packages/kap-server/test/sessionEventJournal.test.ts +++ b/packages/kap-server/test/sessionEventJournal.test.ts @@ -126,16 +126,36 @@ describe('SessionEventJournal', () => { JSON.stringify({ kind: 'journal_header', version: 1, epoch: 'ep_old', created_at: 1 }), JSON.stringify({ kind: 'event', seq: 1, envelope: envelope(1) }), JSON.stringify({ kind: 'journal_header', version: 1, epoch: 'ep_new', created_at: 2 }), - JSON.stringify({ kind: 'event', seq: 2, envelope: envelope(2) }), + JSON.stringify({ kind: 'event', seq: 1, envelope: envelope(1) }), ]; await writeFile(filePath, lines.join('\n') + '\n', 'utf8'); const j = await SessionEventJournal.open(filePath); expect(j.epoch).toBe('ep_new'); - expect(j.seq).toBe(2); + expect(j.seq).toBe(1); + expect((await j.readSince(0, 100)).map((entry) => entry.seq)).toEqual([1]); await j.close(); }); + it('ignores a torn trailing line but rejects a malformed middle line', async () => { + const j1 = await SessionEventJournal.open(filePath); + j1.append(j1.nextSeq(), envelope(1)); + await j1.close(); + + const durable = await readFile(filePath, 'utf8'); + await writeFile(filePath, `${durable}{"kind":"event"}`, 'utf8'); + const j2 = await SessionEventJournal.open(filePath); + expect(j2.seq).toBe(1); + + await writeFile( + filePath, + `${durable}not-json\n${durable.split('\n')[1]}\n`, + 'utf8', + ); + await expect(j2.readSince(0, 100)).rejects.toBeInstanceOf(JournalStorageError); + await j2.close(); + }); + it('cold reads are read-only: no epoch is fabricated and nothing is written', async () => { // Missing file — open → close must not create it. const j1 = await SessionEventJournal.open(filePath); diff --git a/packages/minidb/src/cluster/index.ts b/packages/minidb/src/cluster/index.ts index 9d0ff3f1fe..648b709443 100644 --- a/packages/minidb/src/cluster/index.ts +++ b/packages/minidb/src/cluster/index.ts @@ -100,6 +100,7 @@ export class ClusterDb { recovery: opts.recovery, maxMemoryBytes: opts.maxMemoryBytes, maxMemoryPolicy: opts.maxMemoryPolicy, + lockAcquireTimeoutMs: opts.lockAcquireTimeoutMs, }, readerOpts: { valueCodec: topology.meta.valueCodec, diff --git a/packages/minidb/src/index.ts b/packages/minidb/src/index.ts index e90af77d3c..40f6530833 100644 --- a/packages/minidb/src/index.ts +++ b/packages/minidb/src/index.ts @@ -146,6 +146,8 @@ export interface OpenOptions { recovery?: RecoveryMode; readOnly?: boolean; onLockFail?: 'readonly'; + /** Absolute bound for the underlying lock acquisition settle phase. */ + lockAcquireTimeoutMs?: number; /** Where to keep value bulk. 'memory' keeps values in RAM; 'disk' keeps only * value pointers in RAM and reads values from the snapshot/WAL on demand. */ valueMode?: ValueModeSetting; @@ -290,7 +292,11 @@ export class MiniDb { db.lock = new LockFile(path.join(db.dir, 'db.lock'), { onLost: () => db.markLockLost(), }); - const got = await db.lock.acquire(); + const deadline = + opts.lockAcquireTimeoutMs === undefined + ? undefined + : Date.now() + Math.max(0, opts.lockAcquireTimeoutMs); + const got = await db.lock.acquire(deadline); if (!got) { if (opts.onLockFail === 'readonly') { db.readOnly = true; diff --git a/packages/minidb/src/lockfile.ts b/packages/minidb/src/lockfile.ts index e434374580..39ff0d1e24 100644 --- a/packages/minidb/src/lockfile.ts +++ b/packages/minidb/src/lockfile.ts @@ -183,7 +183,7 @@ export class LockFile { * or by a competing takeover. After observing a held lock this call never * re-races: callers that want to wait retry acquire() at a higher level * (see the cluster lock pool). */ - async acquire(): Promise { + async acquire(deadline?: number): Promise { const lockId = this.newLockId(); this.startedAt ??= processStartedAtOf(this.selfPid); // Register a "watch" BEFORE touching the lock: every contender is visible @@ -204,7 +204,8 @@ export class LockFile { // and its quarantine rename, and its late rename would steal this // fresh lock AFTER our confirm — the historical "two processes // believe they hold the lock" failure the settle exists to prevent. - if (!(await this.settleForForeignWatches(lockId, watch, TAKEOVER_SETTLE_BASE_MS))) { + if (!(await this.settleForForeignWatches(lockId, watch, TAKEOVER_SETTLE_BASE_MS, deadline))) { + await this.abandonCandidate(lockId); return false; } return await this.confirmCreated(lockId); @@ -232,6 +233,7 @@ export class LockFile { const attemptStart = Date.now(); const stalePath = `${this.path}.stale.${seen.lockId ?? 'unknown'}`; for (let attempt = 0; ; attempt++) { + if (deadline !== undefined && this.now() >= deadline) return false; // The corpse must still be there and stale. A competitor who landed // wins by being alive in the file now — back off instead of // quarantining their lock. (Unconditional, not just win32: the same @@ -255,7 +257,13 @@ export class LockFile { if (code === 'EPERM' || code === 'EEXIST') return false; throw e; } - await new Promise((r) => setTimeout(r, 20 + Math.floor(Math.random() * 30))); + const delay = 20 + Math.floor(Math.random() * 30); + const remaining = deadline === undefined ? delay : deadline - this.now(); + if (remaining <= 0) return false; + const waitMs = Math.min(delay, remaining); + await new Promise((resolve) => { + setTimeout(resolve, waitMs); + }); } } @@ -268,7 +276,10 @@ export class LockFile { TAKEOVER_SETTLE_MAX_MS, Math.max(TAKEOVER_SETTLE_BASE_MS, elapsedMs * 4), ); - if (!(await this.settleForForeignWatches(lockId, watch, initialSettleMs))) return false; + if (!(await this.settleForForeignWatches(lockId, watch, initialSettleMs, deadline))) { + await this.abandonCandidate(lockId); + return false; + } return await this.confirmCreated(lockId); } finally { await fs.unlink(watch).catch(() => {}); @@ -302,6 +313,12 @@ export class LockFile { return true; } + private async abandonCandidate(lockId: string): Promise { + const raw = await this.readDiskText(); + if (tryParse(raw ?? '')?.lock_id !== lockId) return; + await fs.unlink(this.path).catch(() => {}); + } + private payload(lockId: string): string { return JSON.stringify({ pid: this.selfPid, @@ -383,13 +400,19 @@ export class LockFile { lockId: string, ownWatch: string, initialSettleMs: number, + deadline?: number, ): Promise { let settleMs = initialSettleMs; for (;;) { const cur = await this.inspect(); if (cur === null || cur.lockId !== lockId) return false; if (!(await this.hasLiveForeignWatch(ownWatch))) return true; - await new Promise((resolve) => setTimeout(resolve, settleMs)); + const remaining = deadline === undefined ? settleMs : deadline - this.now(); + if (remaining <= 0) return false; + const waitMs = Math.min(settleMs, remaining); + await new Promise((resolve) => { + setTimeout(resolve, waitMs); + }); settleMs = Math.min(TAKEOVER_SETTLE_MAX_MS, settleMs * 2); } } diff --git a/packages/minidb/test/lock.test.ts b/packages/minidb/test/lock.test.ts index 4a675fc347..08c12e7639 100644 --- a/packages/minidb/test/lock.test.ts +++ b/packages/minidb/test/lock.test.ts @@ -111,6 +111,26 @@ test('a stale lock with a protocol payload keeps its lock_id in the stale file n await cleanup(dir); }); +test('a live foreign watch cannot keep stale-lock acquire waiting past its deadline', async () => { + const dir = await tmpDir(); + const p = path.join(dir, 'db.lock'); + const foreignWatch = `${p}.watch-${process.pid}-foreign`; + await fs.writeFile( + p, + JSON.stringify({ pid: 999999, ts: Date.now(), lock_id: 'old-token' }), + ); + await fs.writeFile(foreignWatch, JSON.stringify({ pid: process.pid, ts: Date.now() })); + + const lock = new LockFile(p); + const started = performance.now(); + assert.equal(await lock.acquire(Date.now() + 100), false); + assert.ok(performance.now() - started < 1_000); + assert.equal(await fs.readFile(p, 'utf8').then(() => true, () => false), false); + + await fs.unlink(foreignWatch).catch(() => {}); + await cleanup(dir); +}); + // --- creation window: fresh empty/garbage files are "creating", not stale ---- test('a fresh empty lock file counts as still being created (acquire refused)', async () => { From d1ff8157b01b5443e15a79e6db9a007d33e449a3 Mon Sep 17 00:00:00 2001 From: 7Sageer <7sageer@djwcb.cn> Date: Mon, 20 Jul 2026 22:49:40 +0800 Subject: [PATCH 05/22] refactor: replace session lease heartbeat protocol with kernel-held flock Per-session flock is now held for the entire time a server hosts a session, making the kernel the single arbiter of ownership: process death releases the lock, so heartbeats, TTL-based stale takeover, and pid liveness probing are no longer needed. owner.json is reduced to a redirect hint (instance address), written atomically after acquire. - add @moonshot-ai/kernel-file-lock (fs-ext-extra-prebuilt; flock on unix, LockFileEx emulation on windows) - crossProcessLockService shrinks to try-acquire/checkHeld/release - minidb lockfile adopts the same flock-held semantics - delete processProbe --- .changeset/cross-process-lock-unification.md | 2 +- apps/kimi-code/.gitignore | 2 +- apps/kimi-code/package.json | 4 + .../kimi-code/scripts/native/check-bundle.mjs | 2 +- apps/kimi-code/scripts/native/native-deps.mjs | 16 + apps/kimi-code/src/main.ts | 2 + apps/kimi-code/src/native/kernel-file-lock.ts | 12 + apps/kimi-code/src/native/smoke.ts | 11 +- apps/kimi-code/tsdown.native.config.ts | 2 +- flake.nix | 2 + packages/agent-core-v2/package.json | 1 + .../sessionLifecycleService.ts | 31 +- .../agent-core-v2/src/app/telemetry/events.ts | 4 +- .../node-local/crossProcessLockService.ts | 812 ++++-------------- .../os/backends/node-local/processProbe.ts | 71 -- .../src/os/interface/crossProcessLock.ts | 126 +-- .../persistence/interface/writeAuthority.ts | 4 +- .../src/session/sessionLease/sessionLease.ts | 24 +- .../test/app/config/configFileMutex.test.ts | 22 +- .../sessionLifecycle/sessionLifecycle.test.ts | 117 +-- .../crossProcessLockService.test.ts | 746 +++------------- packages/agent-core-v2/test/os/stubs.ts | 1 - .../backends/node-fs/appendLogStore.test.ts | 13 +- .../session/sessionLease/sessionLease.test.ts | 48 +- .../ws/v1/sessionEventBroadcaster.ts | 10 - .../test/session-ownership.e2e.test.ts | 2 +- packages/kernel-file-lock/package.json | 26 + packages/kernel-file-lock/src/index.ts | 157 ++++ packages/kernel-file-lock/test/holder.ts | 13 + .../test/kernel-file-lock.test.ts | 169 ++++ packages/kernel-file-lock/tsconfig.json | 4 + packages/kernel-file-lock/tsdown.config.ts | 12 + packages/kernel-file-lock/vitest.config.ts | 8 + packages/klient/src/sessionRedirect.ts | 19 +- .../test/e2e/legacy/session-ownership.test.ts | 130 +-- packages/klient/test/sessionRedirect.test.ts | 4 +- packages/minidb/README.md | 24 +- packages/minidb/package.json | 3 + packages/minidb/src/cluster/index.ts | 6 +- packages/minidb/src/cluster/lock-pool.ts | 3 +- packages/minidb/src/cluster/shard.ts | 23 +- packages/minidb/src/cluster/types.ts | 9 - packages/minidb/src/index.ts | 13 +- packages/minidb/src/lockfile.ts | 495 +---------- packages/minidb/test/cluster/lock.test.ts | 23 +- packages/minidb/test/defense.test.ts | 32 +- .../minidb/test/e2e/helpers/lock-racer.ts | 2 +- packages/minidb/test/e2e/stress.test.ts | 17 +- packages/minidb/test/lock.test.ts | 242 +----- packages/minidb/test/review-fixes.test.ts | 19 +- packages/protocol/src/session-ownership.ts | 10 +- packages/protocol/src/session.ts | 13 +- pnpm-lock.yaml | 35 +- 53 files changed, 1001 insertions(+), 2597 deletions(-) create mode 100644 apps/kimi-code/src/native/kernel-file-lock.ts delete mode 100644 packages/agent-core-v2/src/os/backends/node-local/processProbe.ts create mode 100644 packages/kernel-file-lock/package.json create mode 100644 packages/kernel-file-lock/src/index.ts create mode 100644 packages/kernel-file-lock/test/holder.ts create mode 100644 packages/kernel-file-lock/test/kernel-file-lock.test.ts create mode 100644 packages/kernel-file-lock/tsconfig.json create mode 100644 packages/kernel-file-lock/tsdown.config.ts create mode 100644 packages/kernel-file-lock/vitest.config.ts diff --git a/.changeset/cross-process-lock-unification.md b/.changeset/cross-process-lock-unification.md index 9eaeade844..2e0c62aa57 100644 --- a/.changeset/cross-process-lock-unification.md +++ b/.changeset/cross-process-lock-unification.md @@ -2,4 +2,4 @@ "@moonshot-ai/kimi-code": patch --- -Fix cross-process state corruption from ad-hoc file locks: server and database locks now verify ownership before release, stale locks are taken over by a quarantine rename instead of deletion, and concurrent writes to the global config and workspace registry are serialized so updates are no longer lost. +Fix cross-process state corruption by replacing ad-hoc lockfiles with kernel-backed locks for session, server, and database coordination, with automatic release when a process exits. diff --git a/apps/kimi-code/.gitignore b/apps/kimi-code/.gitignore index 901b7a6d26..762220ab4c 100644 --- a/apps/kimi-code/.gitignore +++ b/apps/kimi-code/.gitignore @@ -8,4 +8,4 @@ agents/ src/generated/vis-web-asset.ts # Copied from packages/pi-tui/native at build time by scripts/copy-native-assets.mjs -native/ +/native/ diff --git a/apps/kimi-code/package.json b/apps/kimi-code/package.json index 9e865f8c5d..e5ade62901 100644 --- a/apps/kimi-code/package.json +++ b/apps/kimi-code/package.json @@ -77,6 +77,9 @@ "e2e:real": "pnpm -w run build:packages && KIMI_E2E_REAL=1 vitest run test/e2e/real-llm-smoke.e2e.test.ts", "postinstall": "node scripts/postinstall.mjs" }, + "dependencies": { + "fs-ext-extra-prebuilt": "2.2.9" + }, "optionalDependencies": { "@mariozechner/clipboard": "^0.3.9", "node-pty": "^1.1.0" @@ -85,6 +88,7 @@ "@moonshot-ai/acp-adapter": "workspace:^", "@moonshot-ai/agent-core-v2": "workspace:^", "@moonshot-ai/kap-server": "workspace:^", + "@moonshot-ai/kernel-file-lock": "workspace:^", "@moonshot-ai/kimi-code-oauth": "workspace:^", "@moonshot-ai/kimi-code-sdk": "workspace:^", "@moonshot-ai/kimi-telemetry": "workspace:^", diff --git a/apps/kimi-code/scripts/native/check-bundle.mjs b/apps/kimi-code/scripts/native/check-bundle.mjs index 3cd10c278d..0145e4e391 100644 --- a/apps/kimi-code/scripts/native/check-bundle.mjs +++ b/apps/kimi-code/scripts/native/check-bundle.mjs @@ -23,7 +23,7 @@ const optionalRuntimeRequires = new Set([ 'utf-8-validate', ]); const optionalRelativeRuntimeRequires = new Set(['./crypto/build/Release/sshcrypto.node']); -const handledNativeRuntimeRequires = new Set(); +const handledNativeRuntimeRequires = new Set(['fs-ext-extra-prebuilt']); function isAllowedSpecifier(specifier) { if (builtins.has(specifier) || specifier.startsWith('node:')) return true; diff --git a/apps/kimi-code/scripts/native/native-deps.mjs b/apps/kimi-code/scripts/native/native-deps.mjs index 8e26d9229d..ae91ec6734 100644 --- a/apps/kimi-code/scripts/native/native-deps.mjs +++ b/apps/kimi-code/scripts/native/native-deps.mjs @@ -39,6 +39,15 @@ const piTuiNativeFileByTarget = Object.freeze({ 'win32-x64': ['native/win32/prebuilds/win32-x64/win32-console-mode.node'], }); +const fsExtNativeFileByTarget = Object.freeze( + Object.fromEntries( + SUPPORTED_TARGETS.map((target) => [ + target, + [`binaries/fs-ext-${target}-node-24.0.0.node`], + ]), + ), +); + export function isSupportedTarget(target) { return SUPPORTED_TARGETS.includes(target); } @@ -84,6 +93,13 @@ export const nativeDeps = Object.freeze([ parent: null, nativeFileRelatives: (target) => piTuiNativeFileByTarget[target] ?? [], }, + { + id: 'fs-ext', + name: () => 'fs-ext-extra-prebuilt', + collect: 'js-and-native-file', + parent: null, + nativeFileRelatives: (target) => fsExtNativeFileByTarget[target] ?? [], + }, ]); /** diff --git a/apps/kimi-code/src/main.ts b/apps/kimi-code/src/main.ts index 860c4423d8..0e82697f36 100644 --- a/apps/kimi-code/src/main.ts +++ b/apps/kimi-code/src/main.ts @@ -36,6 +36,7 @@ import { runUpdatePreflight } from './cli/update/preflight'; import { createKimiCodeHostIdentity, getVersion } from './cli/version'; import { CLI_SHUTDOWN_TIMEOUT_MS, CLI_UI_MODE, PROCESS_NAME } from './constant/app'; import { cleanupStaleNativeCacheForCurrent } from './native/native-assets'; +import { installKernelFileLockNativeBinding } from './native/kernel-file-lock'; import { installNativeModuleHook } from './native/module-hook'; import { runNativeAssetSmokeIfRequested } from './native/smoke'; @@ -140,6 +141,7 @@ export function main(): void { // invalid proxy URL is reported and ignored rather than aborting startup. installGlobalProxyDispatcher(); installNativeModuleHook(); + installKernelFileLockNativeBinding(); if (runNativeAssetSmokeIfRequested()) return; // Start the background cleanup of stale native cache. Fire-and-forget; must not block startup or throw. diff --git a/apps/kimi-code/src/native/kernel-file-lock.ts b/apps/kimi-code/src/native/kernel-file-lock.ts new file mode 100644 index 0000000000..89a57140ec --- /dev/null +++ b/apps/kimi-code/src/native/kernel-file-lock.ts @@ -0,0 +1,12 @@ +import { + setKernelFileLockBindingLoader, + type KernelFileLockBinding, +} from '@moonshot-ai/kernel-file-lock'; + +import { loadNativePackage } from './native-require'; + +export function installKernelFileLockNativeBinding(): void { + setKernelFileLockBindingLoader(() => + loadNativePackage('fs-ext-extra-prebuilt') ?? undefined, + ); +} diff --git a/apps/kimi-code/src/native/smoke.ts b/apps/kimi-code/src/native/smoke.ts index c77f1419d0..c39b4286a4 100644 --- a/apps/kimi-code/src/native/smoke.ts +++ b/apps/kimi-code/src/native/smoke.ts @@ -2,8 +2,9 @@ import { createRequire } from 'node:module'; import { dirname, join } from 'node:path'; import { getEmbeddedNativeAssetManifest, getNativePackageRoot } from './native-assets'; +import { loadNativePackage } from './native-require'; -const smokePackages = ['@mariozechner/clipboard', '@moonshot-ai/pi-tui']; +const smokePackages = ['@mariozechner/clipboard', '@moonshot-ai/pi-tui', 'fs-ext-extra-prebuilt']; // Verify pi-tui's native helper can actually be loaded through the module hook. // pi-tui computes native helper paths from process.execPath and require()s them; @@ -34,6 +35,13 @@ function smokePiTuiNativeLoad(): void { } } +function smokeKernelFileLockNativeLoad(): void { + const binding = loadNativePackage<{ flockSync?: unknown }>('fs-ext-extra-prebuilt'); + if (binding === null || typeof binding.flockSync !== 'function') { + throw new Error('fs-ext-extra-prebuilt loaded but flockSync is unavailable.'); + } +} + export function runNativeAssetSmokeIfRequested(): boolean { if (process.env['KIMI_CODE_NATIVE_ASSET_SMOKE'] !== '1') return false; @@ -49,6 +57,7 @@ export function runNativeAssetSmokeIfRequested(): boolean { } } smokePiTuiNativeLoad(); + smokeKernelFileLockNativeLoad(); process.stdout.write(`Native asset smoke passed: ${manifest.target}\n`); process.exit(0); } catch (error) { diff --git a/apps/kimi-code/tsdown.native.config.ts b/apps/kimi-code/tsdown.native.config.ts index c1008cb616..bcc66236d5 100644 --- a/apps/kimi-code/tsdown.native.config.ts +++ b/apps/kimi-code/tsdown.native.config.ts @@ -16,7 +16,7 @@ const builtins = new Set([ ...builtinModules, ...builtinModules.map((name) => `node:${name}`), ]); -const optionalNativeDependencies = new Set(['cpu-features']); +const optionalNativeDependencies = new Set(['cpu-features', 'fs-ext-extra-prebuilt']); function shouldAlwaysBundle(id: string): boolean { if (builtins.has(id) || id.startsWith('node:')) return false; diff --git a/flake.nix b/flake.nix index 83419b3415..e7d1451706 100644 --- a/flake.nix +++ b/flake.nix @@ -67,6 +67,7 @@ ./packages/agent-core-v2 ./packages/kap-server ./packages/kaos + ./packages/kernel-file-lock ./packages/klient ./packages/kosong ./packages/migration-legacy @@ -92,6 +93,7 @@ "@moonshot-ai/agent-core-v2" "@moonshot-ai/kap-server" "@moonshot-ai/kaos" + "@moonshot-ai/kernel-file-lock" "@moonshot-ai/kosong" "@moonshot-ai/migration-legacy" "@moonshot-ai/minidb" diff --git a/packages/agent-core-v2/package.json b/packages/agent-core-v2/package.json index 0f469f2f1f..acbd224e4c 100644 --- a/packages/agent-core-v2/package.json +++ b/packages/agent-core-v2/package.json @@ -60,6 +60,7 @@ "@google/genai": "^1.49.0", "@jsquash/webp": "^1.5.0", "@modelcontextprotocol/sdk": "^1.29.0", + "@moonshot-ai/kernel-file-lock": "workspace:^", "@moonshot-ai/kimi-code-oauth": "workspace:^", "@moonshot-ai/minidb": "workspace:^", "@moonshot-ai/protocol": "workspace:^", diff --git a/packages/agent-core-v2/src/app/sessionLifecycle/sessionLifecycleService.ts b/packages/agent-core-v2/src/app/sessionLifecycle/sessionLifecycleService.ts index 2dd6210951..85f70f8711 100644 --- a/packages/agent-core-v2/src/app/sessionLifecycle/sessionLifecycleService.ts +++ b/packages/agent-core-v2/src/app/sessionLifecycle/sessionLifecycleService.ts @@ -92,13 +92,10 @@ import { ISessionContext, sessionContextSeed } from '#/session/sessionContext/se import { ISessionCronService } from '#/session/cron/sessionCronService'; import { type HeldByPeerDetails, - HOLDER_UNRESPONSIVE_RETRY_AFTER_MS, LEASE_CREATING_RETRY_AFTER_MS, SessionLease, sessionLeasePath, sessionLeaseSeed, - SESSION_LEASE_HEARTBEAT_INTERVAL_MS, - SESSION_LEASE_TTL_MS, } from '#/session/sessionLease/sessionLease'; import { ISessionLeaseContactProvider } from '#/session/sessionLease/sessionLeaseContactProvider'; import { ISessionMetadata, type SessionMeta } from '#/session/sessionMetadata/sessionMetadata'; @@ -842,29 +839,14 @@ export class SessionLifecycleService extends Disposable implements ISessionLifec private async acquireSessionLease(sessionId: string): Promise { const leasePath = sessionLeasePath(this.bootstrap.homeDir, sessionId); const contact = this.leaseContact.contact(); - let lease: SessionLease | undefined; try { - const prior = this.locks.inspect(leasePath); const handle = await this.locks.acquire(leasePath, { - heartbeat: { - intervalMs: SESSION_LEASE_HEARTBEAT_INTERVAL_MS, - ttlMs: SESSION_LEASE_TTL_MS, - }, address: contact.type === 'address' ? contact.address : undefined, - onLost: () => { - lease?.markLost(); - }, }); - lease = new SessionLease(sessionId, handle, (id) => { + const lease = new SessionLease(sessionId, handle, (id) => { this.onLeaseLost(id); }); this.telemetry.track2('session_lease_acquired', { session_id: sessionId }); - if (prior.state === 'stale') { - this.telemetry.track2('session_lease_takeover', { - session_id: sessionId, - previous: prior.staleReason ?? 'unknown', - }); - } return lease; } catch (error) { if ( @@ -885,9 +867,6 @@ export class SessionLifecycleService extends Disposable implements ISessionLifec session_id: sessionId, phase: details.phase, }); - if (details.phase === 'holder-unresponsive') { - this.telemetry.track2('session_lease_holder_unresponsive', { session_id: sessionId }); - } throw new Error2( ErrorCodes.SESSION_HELD_BY_PEER, `session ${sessionId} is held by another instance (${details.phase})`, @@ -898,14 +877,6 @@ export class SessionLifecycleService extends Disposable implements ISessionLifec private heldByPeerDetails(inspection: CrossProcessLockInspection): HeldByPeerDetails { if (inspection.state === 'held' && inspection.payload !== undefined) { const payload = inspection.payload; - const heartbeatAt = payload.heartbeatAt; - if (heartbeatAt !== undefined && Date.now() - heartbeatAt > SESSION_LEASE_TTL_MS) { - return { - kind: 'held-by-peer', - phase: 'holder-unresponsive', - retry_after_ms: HOLDER_UNRESPONSIVE_RETRY_AFTER_MS, - }; - } if (payload.address !== undefined && this.flags.enabled(MULTI_SERVER_FLAG_ID)) { return { kind: 'held-by-peer', phase: 'routable', address: payload.address }; } diff --git a/packages/agent-core-v2/src/app/telemetry/events.ts b/packages/agent-core-v2/src/app/telemetry/events.ts index a245533854..28e1b127d4 100644 --- a/packages/agent-core-v2/src/app/telemetry/events.ts +++ b/packages/agent-core-v2/src/app/telemetry/events.ts @@ -881,7 +881,7 @@ export const telemetryEventDefinitions = { }), session_lease_takeover: defineTelemetryEvent({ owner: 'kimi-code', - comment: "A session's write lease is taken over from a stale (dead) holder.", + comment: 'Legacy stale-lease takeover event retained for telemetry schema stability.', properties: { session_id: 'Session the lease covers', previous: 'Stale reason observed before takeover (holder-dead, pid-reused, …)', @@ -897,7 +897,7 @@ export const telemetryEventDefinitions = { }), session_lease_holder_unresponsive: defineTelemetryEvent({ owner: 'kimi-code', - comment: "A session's lease holder is alive but its heartbeat is past TTL (frozen).", + comment: 'Legacy heartbeat-liveness event retained for telemetry schema stability.', properties: { session_id: 'Session whose holder is unresponsive' }, }), session_dirty_abort: defineTelemetryEvent({ diff --git a/packages/agent-core-v2/src/os/backends/node-local/crossProcessLockService.ts b/packages/agent-core-v2/src/os/backends/node-local/crossProcessLockService.ts index e0a6d39676..2dc79d04ff 100644 --- a/packages/agent-core-v2/src/os/backends/node-local/crossProcessLockService.ts +++ b/packages/agent-core-v2/src/os/backends/node-local/crossProcessLockService.ts @@ -1,55 +1,19 @@ /** * `crossProcessLock` domain (L1) — `ICrossProcessLockService` implementation. * - * Node-local backend for the cross-process exclusive file-lock protocol - * defined by `os/interface/crossProcessLock`. Filesystem mutations are short - * synchronous bursts; contender settlement is asynchronous so a delayed - * process never forces the event loop to spin while another attempt exits. - * Process probing goes through `createNodeProcessProbe`; every clock, pid, - * probe and token source is injectable for tests. - * - * Protocol invariants implemented here: - * - * - Token-guarded: acquire stamps a fresh ulid `lockId`; release, heartbeat - * and payload rewrites re-read the file and compare it before touching the - * lock, so a late operation never clobbers a newer holder. - * - Live PID is never taken over. Only pid death, or a live pid whose - * `processStartedAt` identity no longer matches (pid reused), makes a lock - * stale; a live identity-matching holder whose heartbeat is past ttl is - * `holder-unresponsive` — reported, never seized. An identity that either - * side cannot provide counts as matching (conservative). - * - Each attempt publishes a unique sidecar intent before inspecting the lock. - * A creator confirms its token, snapshots the foreign intents already - * present, and waits for that finite set to settle before returning. A - * delayed stale observer therefore either sees the new live generation and - * backs off, or steals it before settlement and causes the creator to fail - * rather than double-return; contenders arriving after the snapshot cannot - * starve the creator because they can only observe the new generation. - * - Creation window: an empty/unparseable file younger than - * `creationWindowMs` (default 5s) is `creating` (treated as held); past the - * window it is stale. - * - The winning create fd stays open for every handle. Heartbeat and updates - * write only through that fd — never tmp+rename or path re-open — so a frozen - * old holder cannot overwrite a successor after losing the public path. - * + * Uses `kernel-file-lock` to hold an operating-system advisory lock on a + * permanent sentinel file. Owner metadata is stored in a sibling JSON document + * for diagnostics and routing only; it is not part of the exclusion protocol. * Bound at App scope. */ -import { - closeSync, - fsyncSync, - ftruncateSync, - mkdirSync, - openSync, - readdirSync, - readFileSync, - renameSync, - statSync, - unlinkSync, - writeSync, -} from 'node:fs'; -import { basename, dirname, join } from 'node:path'; +import { randomUUID } from 'node:crypto'; +import { readFileSync, renameSync, rmSync, writeFileSync } from 'node:fs'; +import { + type KernelFileLockHandle, + tryAcquireKernelFileLock, +} from '@moonshot-ai/kernel-file-lock'; import { ulid } from 'ulid'; import { InstantiationType } from '#/_base/di/extensions'; @@ -58,23 +22,22 @@ import { CrossProcessLockError, CrossProcessLockErrorCode, type CrossProcessLockAcquireOptions, - type CrossProcessLockHeartbeatOptions, type CrossProcessLockInspection, type CrossProcessLockPayload, type CrossProcessLockServiceDeps, - type CrossProcessLockUnavailableReason, type CrossProcessLockWaitOptions, type ICrossProcessLockHandle, ICrossProcessLockService, - type ProcessProbe, } from '#/os/interface/crossProcessLock'; -import { createNodeProcessProbe } from './processProbe'; - -const DEFAULT_CREATION_WINDOW_MS = 5_000; const DEFAULT_WAIT_RETRY_INTERVAL_MS = 50; -const DEFAULT_SETTLE_RETRY_INTERVAL_MS = 10; -const MAX_ACQUIRE_ATTEMPTS = 3; + +interface DiskLockPayload { + lock_id?: string; + instance_id?: string; + pid?: number; + address?: string; +} function readErrno(error: unknown): string | undefined { if (error === null || typeof error !== 'object' || !('code' in error)) return undefined; @@ -86,403 +49,199 @@ function errorMessage(error: unknown): string { return error instanceof Error ? error.message : String(error); } -function toLockIoError(error: unknown, ctx: { path: string; op: string }): CrossProcessLockError { - if (error instanceof CrossProcessLockError) return error; - return new CrossProcessLockError( - CrossProcessLockErrorCode.Io, - `${ctx.op} failed on lock file: ${errorMessage(error)}`, - { details: { path: ctx.path, op: ctx.op, errno: readErrno(error) }, cause: error }, - ); -} - -function heldError( - lockPath: string, - reason: CrossProcessLockUnavailableReason, - holder: CrossProcessLockPayload | undefined, -): CrossProcessLockError { - const summary = holder - ? `pid=${holder.pid} instanceId=${holder.instanceId} address=${holder.address ?? '-'} heartbeatAt=${holder.heartbeatAt ?? '-'}` - : 'holder unknown'; - return new CrossProcessLockError( - CrossProcessLockErrorCode.Held, - `cross-process lock unavailable (${reason}): ${summary}`, - { details: { path: lockPath, reason, holder } }, - ); -} - -function lostError(lockPath: string, what: string): CrossProcessLockError { - return new CrossProcessLockError( - CrossProcessLockErrorCode.Lost, - `lock ownership lost while ${what}`, - { details: { path: lockPath } }, - ); -} - -interface DiskLockPayload { - lock_id?: string; - instance_id?: string; - pid?: number; - process_started_at?: string; - address?: string; - heartbeat_at?: number; - [extra: string]: unknown; +function ownerPath(lockPath: string): string { + return `${lockPath}.owner.json`; } -interface DiskIntentPayload { - intent_id?: string; - state?: 'active' | 'settled'; - pid?: number; - process_started_at?: string; -} - -interface RegisteredIntent { - readonly path: string; - readonly token: string; - readonly fd: number; +function toDiskPayload(payload: CrossProcessLockPayload): DiskLockPayload { + return { + lock_id: payload.lockId, + instance_id: payload.instanceId, + pid: payload.pid, + address: payload.address, + }; } -function renderPayloadJson(payload: CrossProcessLockPayload): string { - const { lockId, instanceId, pid, processStartedAt, address, heartbeatAt, ...extras } = payload; - const disk: DiskLockPayload = { - ...extras, - lock_id: lockId, - instance_id: instanceId, - pid, +function fromDiskPayload(payload: DiskLockPayload): CrossProcessLockPayload | undefined { + if ( + typeof payload.lock_id !== 'string' || + typeof payload.instance_id !== 'string' || + typeof payload.pid !== 'number' + ) { + return undefined; + } + return { + lockId: payload.lock_id, + instanceId: payload.instance_id, + pid: payload.pid, + address: typeof payload.address === 'string' ? payload.address : undefined, }; - if (processStartedAt !== undefined) disk.process_started_at = processStartedAt; - if (address !== undefined) disk.address = address; - if (heartbeatAt !== undefined) disk.heartbeat_at = heartbeatAt; - return JSON.stringify(disk); } -function parseDiskPayload(raw: string): CrossProcessLockPayload | undefined { - let parsed: unknown; +function readPayload(lockPath: string): CrossProcessLockPayload | undefined { try { - parsed = JSON.parse(raw); - } catch { - return undefined; + const parsed: unknown = JSON.parse(readFileSync(ownerPath(lockPath), 'utf8')); + return parsed !== null && typeof parsed === 'object' + ? fromDiskPayload(parsed as DiskLockPayload) + : undefined; + } catch (error) { + if (readErrno(error) === 'ENOENT' || error instanceof SyntaxError) return undefined; + throw error; } - if (parsed === null || typeof parsed !== 'object' || Array.isArray(parsed)) return undefined; - const disk = parsed as DiskLockPayload; - const hasLockId = typeof disk.lock_id === 'string'; - const hasPid = typeof disk.pid === 'number'; - if (!hasLockId && !hasPid) return undefined; - const { lock_id, instance_id, pid, process_started_at, address, heartbeat_at, ...extras } = disk; - const payload: CrossProcessLockPayload = { - ...extras, - lockId: lock_id ?? '', - instanceId: typeof instance_id === 'string' ? instance_id : '', - pid: typeof pid === 'number' ? pid : -1, - }; - if (typeof process_started_at === 'string') payload.processStartedAt = process_started_at; - if (typeof address === 'string') payload.address = address; - if (typeof heartbeat_at === 'number') payload.heartbeatAt = heartbeat_at; - return payload; } -function readPayloadFromPath(lockPath: string): CrossProcessLockPayload | undefined { - let raw: string; +function writePayload(lockPath: string, payload: CrossProcessLockPayload): void { + const path = ownerPath(lockPath); + const tempPath = `${path}.${process.pid}.${randomUUID()}.tmp`; try { - raw = readFileSync(lockPath, 'utf8'); - } catch { - return undefined; + writeFileSync(tempPath, JSON.stringify(toDiskPayload(payload)), { mode: 0o600 }); + renameSync(tempPath, path); + } catch (error) { + rmSync(tempPath, { force: true }); + throw error; } - return parseDiskPayload(raw); } -function extractExtras(payload: CrossProcessLockPayload): Record { - const { - lockId: _lockId, - instanceId: _instanceId, - pid: _pid, - processStartedAt: _processStartedAt, - address, - heartbeatAt: _heartbeatAt, - ...rest - } = payload; - const extras: DiskLockPayload = rest; - if (address !== undefined) extras.address = address; - return extras; +function toLockIoError(error: unknown, path: string, op: string): CrossProcessLockError { + if (error instanceof CrossProcessLockError) return error; + return new CrossProcessLockError( + CrossProcessLockErrorCode.Io, + `${op} failed on lock ${path}: ${errorMessage(error)}`, + { details: { path, op, errno: readErrno(error) }, cause: error }, + ); +} + +function heldError( + lockPath: string, + inspection: CrossProcessLockInspection, +): CrossProcessLockError { + return new CrossProcessLockError( + CrossProcessLockErrorCode.Held, + `cross-process lock unavailable (${inspection.state})`, + { details: { path: lockPath, reason: inspection.state, holder: inspection.payload } }, + ); } -function isProbingPid(pid: number): boolean { - return Number.isInteger(pid) && pid > 0; +function waitTimeoutError( + lockPath: string, + timeoutMs: number, + cause: unknown, +): CrossProcessLockError { + return new CrossProcessLockError( + CrossProcessLockErrorCode.WaitTimeout, + `timed out waiting for the cross-process lock (${timeoutMs}ms)`, + { details: { path: lockPath, timeoutMs }, cause }, + ); } -class NodeCrossProcessLockHandle implements ICrossProcessLockHandle { - private _released = false; - private _lostNotified = false; - private _timer: ReturnType | undefined; - private _fd: number; - private _extras: Record; +class CrossProcessLockHandle implements ICrossProcessLockHandle { + private released = false; constructor( readonly lockPath: string, readonly lockId: string, - private readonly now: () => number, - private readonly selfPid: number, - private readonly instanceId: string, - private readonly selfProcessStartedAt: string | undefined, - extras: Record, - private readonly heartbeat: CrossProcessLockHeartbeatOptions | undefined, - private readonly onLost: (() => void) | undefined, - fd: number, - ) { - this._extras = extras; - this._fd = fd; - } + private readonly kernelHandle: KernelFileLockHandle, + ) {} checkHeld(): boolean { - return readPayloadFromPath(this.lockPath)?.lockId === this.lockId; - } - - update(mutate: (payload: CrossProcessLockPayload) => Record): void { - const current = readPayloadFromPath(this.lockPath); - if (current?.lockId !== this.lockId) { - throw lostError(this.lockPath, 'updating the payload'); - } - const merged: CrossProcessLockPayload = { - ...current, - ...mutate(current), - lockId: this.lockId, - instanceId: this.instanceId, - pid: this.selfPid, - }; - this._extras = extractExtras(merged); - try { - this.writePayload(); - } catch (error) { - if (readErrno(error) === 'ENOENT') throw lostError(this.lockPath, 'updating the payload'); - throw toLockIoError(error, { path: this.lockPath, op: 'update' }); - } - if (readPayloadFromPath(this.lockPath)?.lockId !== this.lockId) { - throw lostError(this.lockPath, 'confirming the updated payload'); - } + return !this.released && this.kernelHandle.checkHeld(); } release(): void { - if (this._released) return; - this._released = true; - this.stopHeartbeat(); - this.closeFd(); + if (this.released) return; + this.released = true; try { - if (readPayloadFromPath(this.lockPath)?.lockId === this.lockId) { - unlinkSync(this.lockPath); + if (this.kernelHandle.checkHeld()) { + try { + rmSync(ownerPath(this.lockPath), { force: true }); + } catch {} } - } catch { - // best-effort: a release failure must never delete a foreign lock. - } - } - - startHeartbeat(): void { - if (this.heartbeat === undefined) return; - this._timer = setInterval(() => { - this.tick(); - }, this.heartbeat.intervalMs); - this._timer.unref(); - } - - writeInitialPayload(): void { - this.writePayload(); - } - - private tick(): void { - if (this._released || this._fd < 0) return; - try { - this.writePayload(); - } catch { - this.handleLost(); - return; - } - if (readPayloadFromPath(this.lockPath)?.lockId !== this.lockId) { - this.handleLost(); - } - } - - private handleLost(): void { - this.stopHeartbeat(); - this.closeFd(); - if (this._lostNotified) return; - this._lostNotified = true; - this.onLost?.(); - } - - private writePayload(): void { - const payload: CrossProcessLockPayload = { - ...this._extras, - lockId: this.lockId, - instanceId: this.instanceId, - pid: this.selfPid, - processStartedAt: this.selfProcessStartedAt, - heartbeatAt: this.heartbeat !== undefined ? this.now() : undefined, - }; - const data = Buffer.from(renderPayloadJson(payload), 'utf8'); - if (this._fd >= 0) { - writeSync(this._fd, data, 0, data.length, 0); - ftruncateSync(this._fd, data.length); - fsyncSync(this._fd); - return; - } - throw lostError(this.lockPath, 'writing the payload'); - } - - private stopHeartbeat(): void { - if (this._timer === undefined) return; - clearInterval(this._timer); - this._timer = undefined; - } - - private closeFd(): void { - if (this._fd < 0) return; - try { - closeSync(this._fd); - } catch { - // fd already closed elsewhere; nothing to do. + } finally { + this.kernelHandle.release(); } - this._fd = -1; } } export class CrossProcessLockService implements ICrossProcessLockService { declare readonly _serviceBrand: undefined; - private readonly now: () => number; private readonly selfPid: number; - private readonly probe: ProcessProbe; + private readonly now: () => number; private readonly newLockId: () => string; - private readonly newAttemptId: () => string; private readonly instanceId: string; private readonly sleep: (ms: number) => Promise; - private readonly beforeStaleIsolation: (() => void | Promise) | undefined; constructor(deps: CrossProcessLockServiceDeps = {}) { this.now = deps.now ?? Date.now; this.selfPid = deps.selfPid ?? process.pid; - this.probe = deps.probeProcess ?? createNodeProcessProbe(); this.newLockId = deps.newLockId ?? ulid; - this.newAttemptId = deps.newAttemptId ?? ulid; this.instanceId = deps.instanceId ?? ulid(); - this.beforeStaleIsolation = deps.beforeStaleIsolation; - this.sleep = - deps.sleep ?? - ((ms) => - new Promise((resolvePromise) => { - const timer = setTimeout(resolvePromise, ms); - timer.unref(); - })); + this.sleep = deps.sleep ?? ((ms) => new Promise((resolve) => setTimeout(resolve, ms))); } async acquire( lockPath: string, options: CrossProcessLockAcquireOptions = {}, ): Promise { + let kernelHandle: KernelFileLockHandle | undefined; try { - mkdirSync(dirname(lockPath), { recursive: true }); + kernelHandle = tryAcquireKernelFileLock(lockPath); } catch (error) { - throw toLockIoError(error, { path: lockPath, op: 'mkdir' }); + throw toLockIoError(error, lockPath, 'acquire'); } - const creationWindowMs = options.creationWindowMs ?? DEFAULT_CREATION_WINDOW_MS; - const observedTtlMs = options.heartbeat?.ttlMs ?? creationWindowMs; - const intent = this.registerIntent(lockPath); - let acquiredHandle: ICrossProcessLockHandle | undefined; - let primaryError: unknown; - let hasPrimaryError = false; + if (kernelHandle === undefined) throw heldError(lockPath, this.inspectHeld(lockPath)); + + const lockId = this.newLockId(); + const payload: CrossProcessLockPayload = { + lockId, + instanceId: this.instanceId, + pid: this.selfPid, + address: options.address, + }; try { - let lastHolder: CrossProcessLockPayload | undefined; - for (let attempt = 0; attempt < MAX_ACQUIRE_ATTEMPTS; attempt++) { - let fd: number; - try { - fd = openSync(lockPath, 'wx', 0o600); - } catch (error) { - if (readErrno(error) !== 'EEXIST') { - throw toLockIoError(error, { path: lockPath, op: 'open' }); - } - const inspection = this.classify(lockPath, creationWindowMs); - lastHolder = inspection.payload; - switch (inspection.state) { - case 'free': - continue; - case 'creating': - throw heldError(lockPath, 'creating', undefined); - case 'held': - throw heldError( - lockPath, - this.reasonForHeld(inspection.payload, observedTtlMs), - inspection.payload, - ); - case 'stale': - if (!(await this.isolateStale(lockPath, inspection, creationWindowMs, intent.path))) { - continue; - } - continue; - } - } - const handle = this.completeAcquire(lockPath, fd, options); - try { - await this.settleAcquire(handle, intent.path, creationWindowMs); - acquiredHandle = handle; - break; - } catch (error) { - handle.release(); - throw error; - } - } - if (acquiredHandle === undefined) throw heldError(lockPath, 'held', lastHolder); + rmSync(ownerPath(lockPath), { force: true }); + writePayload(lockPath, payload); + return new CrossProcessLockHandle(lockPath, lockId, kernelHandle); } catch (error) { - hasPrimaryError = true; - primaryError = error; + kernelHandle.release(); + throw toLockIoError(error, lockPath, 'write-owner'); } - let cleanupError: unknown; - let hasCleanupError = false; - try { - try { - this.markIntentSettled(intent); - } catch (error) { - hasCleanupError = true; - cleanupError = error; - } - try { - this.removeIntent(intent); - } catch (error) { - if (!hasCleanupError) { - hasCleanupError = true; - cleanupError = error; - } - } - } finally { - this.closeIntent(intent); - } - if (hasCleanupError && acquiredHandle !== undefined) acquiredHandle.release(); - if (hasPrimaryError) throw primaryError; - if (hasCleanupError) throw cleanupError; - if (acquiredHandle === undefined) { - throw new Error('cross-process lock acquisition finished without a result'); - } - return acquiredHandle; } async acquireWithWait( lockPath: string, options: CrossProcessLockAcquireOptions & { wait: CrossProcessLockWaitOptions }, ): Promise { - const start = this.now(); - let lastError: CrossProcessLockError | undefined; + const deadline = this.now() + options.wait.timeoutMs; + const retryIntervalMs = options.wait.retryIntervalMs ?? DEFAULT_WAIT_RETRY_INTERVAL_MS; + let firstAttempt = true; + let lastHeldError: CrossProcessLockError | undefined; for (;;) { + const isFirstAttempt = firstAttempt; + if (!isFirstAttempt && this.now() >= deadline) { + throw waitTimeoutError(lockPath, options.wait.timeoutMs, lastHeldError); + } + firstAttempt = false; try { - return await this.acquire(lockPath, options); + const handle = await this.acquire(lockPath, options); + if (!isFirstAttempt && this.now() >= deadline) { + handle.release(); + throw waitTimeoutError(lockPath, options.wait.timeoutMs, lastHeldError); + } + return handle; } catch (error) { - if (!(error instanceof CrossProcessLockError) || error.code !== CrossProcessLockErrorCode.Held) { + if ( + !(error instanceof CrossProcessLockError) || + error.code !== CrossProcessLockErrorCode.Held + ) { throw error; } - lastError = error; - if (this.now() - start >= options.wait.timeoutMs) { - throw new CrossProcessLockError( - CrossProcessLockErrorCode.WaitTimeout, - `timed out waiting for the cross-process lock (${options.wait.timeoutMs}ms): ${lastError.message}`, - { details: { path: lockPath, timeoutMs: options.wait.timeoutMs }, cause: lastError }, - ); + lastHeldError = error; + const remainingMs = deadline - this.now(); + if (remainingMs <= 0) { + throw waitTimeoutError(lockPath, options.wait.timeoutMs, error); } - await this.sleep(options.wait.retryIntervalMs ?? DEFAULT_WAIT_RETRY_INTERVAL_MS); + await this.sleep(Math.min(retryIntervalMs, remainingMs)); } } } @@ -500,297 +259,26 @@ export class CrossProcessLockService implements ICrossProcessLockService { } } - inspect( - lockPath: string, - options?: Pick, - ): CrossProcessLockInspection { - return this.classify(lockPath, options?.creationWindowMs ?? DEFAULT_CREATION_WINDOW_MS); - } - - private classify(lockPath: string, creationWindowMs: number): CrossProcessLockInspection { - let raw: string; - try { - raw = readFileSync(lockPath, 'utf8'); - } catch (error) { - if (readErrno(error) === 'ENOENT') return { state: 'free' }; - throw toLockIoError(error, { path: lockPath, op: 'read' }); - } - const payload = parseDiskPayload(raw); - if (payload === undefined) { - const mtimeMs = this.readMtimeMs(lockPath); - if (mtimeMs === undefined) return { state: 'free' }; - return this.now() - mtimeMs < creationWindowMs - ? { state: 'creating' } - : { state: 'stale', staleReason: 'creation-window-expired' }; - } - if (isProbingPid(payload.pid)) { - const probed = this.safeProbe(payload.pid); - if (!probed.alive) { - return { state: 'stale', payload, staleReason: 'holder-dead' }; - } - if ( - payload.processStartedAt !== undefined && - probed.processStartedAt !== undefined && - payload.processStartedAt !== probed.processStartedAt - ) { - return { state: 'stale', payload, staleReason: 'pid-reused' }; - } - } - return { state: 'held', payload, unavailableReason: 'held' }; - } - - private reasonForHeld( - payload: CrossProcessLockPayload | undefined, - observedTtlMs: number, - ): CrossProcessLockUnavailableReason { - const heartbeatAt = payload?.heartbeatAt; - if (heartbeatAt !== undefined && this.now() - heartbeatAt > observedTtlMs) { - return 'holder-unresponsive'; - } - return 'held'; - } - - private async isolateStale( - lockPath: string, - inspection: CrossProcessLockInspection, - creationWindowMs: number, - intentPath: string, - ): Promise { - const current = this.classify(lockPath, creationWindowMs); - if (current.state !== 'stale') return false; - if (inspection.payload?.lockId !== current.payload?.lockId) return false; - await this.beforeStaleIsolation?.(); - const rawLockId = inspection.payload?.lockId; - const staleLockId = rawLockId !== undefined && rawLockId !== '' ? rawLockId : 'unknown'; - try { - renameSync(lockPath, `${lockPath}.stale.${staleLockId}.${basename(intentPath)}`); - return true; - } catch (error) { - if (readErrno(error) === 'ENOENT') return false; - throw toLockIoError(error, { path: lockPath, op: 'rename-stale' }); - } - } - - private completeAcquire( - lockPath: string, - fd: number, - options: CrossProcessLockAcquireOptions, - ): ICrossProcessLockHandle { - const lockId = this.newLockId(); - const extras: DiskLockPayload = { ...options.extraPayload }; - if (options.address !== undefined) extras.address = options.address; - const handle = new NodeCrossProcessLockHandle( - lockPath, - lockId, - this.now, - this.selfPid, - this.instanceId, - this.safeProbe(this.selfPid).processStartedAt, - extras, - options.heartbeat, - options.onLost, - fd, - ); - try { - handle.writeInitialPayload(); - } catch (error) { - // We exclusively created this file via O_EXCL and its (partial) content - // is not a valid payload, so cleanup is safe and avoids creating-window - // litter; release() closes the fd without touching foreign payloads. - handle.release(); - try { - unlinkSync(lockPath); - } catch { - // best effort - } - throw toLockIoError(error, { path: lockPath, op: 'write' }); - } - // Read-back confirmation: a creator frozen inside its create window must - // honestly fail instead of believing it still owns the lock. - if (readPayloadFromPath(lockPath)?.lockId !== lockId) { - handle.release(); - throw lostError(lockPath, 'confirming the newly created payload'); - } - if (options.heartbeat !== undefined) handle.startHeartbeat(); - return handle; - } - - private registerIntent(lockPath: string): RegisteredIntent { - const token = this.newAttemptId(); - const intentPath = `${lockPath}.intent.${token}`; - const startedAt = this.safeProbe(this.selfPid).processStartedAt; - const payload: DiskIntentPayload = { intent_id: token, state: 'active', pid: this.selfPid }; - if (startedAt !== undefined) payload.process_started_at = startedAt; - let fd: number | undefined; - try { - fd = openSync(intentPath, 'wx+', 0o600); - this.writeIntent(fd, payload); - return { path: intentPath, token, fd }; - } catch (error) { - if (fd !== undefined) this.closeFd(fd); - try { - unlinkSync(intentPath); - } catch { - // best effort - } - throw toLockIoError(error, { path: intentPath, op: 'register-intent' }); - } - } - - private markIntentSettled(intent: RegisteredIntent): void { - const current = this.readIntent(intent.path); - if (current?.intent_id !== intent.token) return; - this.writeIntent(intent.fd, { ...current, intent_id: intent.token, state: 'settled' }); - } - - private removeIntent(intent: RegisteredIntent): void { + inspect(lockPath: string): CrossProcessLockInspection { + let probe: KernelFileLockHandle | undefined; try { - const current = this.readIntent(intent.path); - if (current?.intent_id !== intent.token) return; - unlinkSync(intent.path); + probe = tryAcquireKernelFileLock(lockPath); } catch (error) { - if (readErrno(error) !== 'ENOENT') { - throw toLockIoError(error, { path: intent.path, op: 'remove-intent' }); - } - } - } - - private readIntent(intentPath: string): DiskIntentPayload | undefined { - let raw: string; - try { - raw = readFileSync(intentPath, 'utf8'); - } catch (error) { - if (readErrno(error) === 'ENOENT') return undefined; - throw toLockIoError(error, { path: intentPath, op: 'read-intent' }); - } - try { - const parsed: unknown = JSON.parse(raw); - return parsed !== null && typeof parsed === 'object' ? (parsed as DiskIntentPayload) : undefined; - } catch { - return undefined; - } - } - - private writeIntent(fd: number, payload: DiskIntentPayload): void { - const data = Buffer.from(JSON.stringify(payload), 'utf8'); - writeSync(fd, data, 0, data.length, 0); - ftruncateSync(fd, data.length); - fsyncSync(fd); - } - - private closeIntent(intent: RegisteredIntent): void { - this.closeFd(intent.fd); - } - - private closeFd(fd: number): void { - try { - closeSync(fd); - } catch { - // best effort + throw toLockIoError(error, lockPath, 'inspect'); } - } - - private async settleAcquire( - handle: ICrossProcessLockHandle, - ownIntentPath: string, - timeoutMs: number, - ): Promise { - const startedAt = this.now(); - const contenders = this.snapshotForeignIntents(handle.lockPath, ownIntentPath); - for (;;) { - if (!handle.checkHeld()) { - throw lostError(handle.lockPath, 'settling concurrent contenders'); - } - if (!this.hasLiveIntent(contenders, timeoutMs)) return; - if (this.now() - startedAt >= timeoutMs) { - throw heldError(handle.lockPath, 'creating', readPayloadFromPath(handle.lockPath)); - } - await this.sleep(DEFAULT_SETTLE_RETRY_INTERVAL_MS); + if (probe !== undefined) { + probe.release(); + return { state: 'free' }; } + return this.inspectHeld(lockPath); } - private snapshotForeignIntents( - lockPath: string, - ownIntentPath: string, - ): Set { - const dir = dirname(lockPath); - const prefix = `${basename(lockPath)}.intent.`; - const ownName = basename(ownIntentPath); - let names: string[]; + private inspectHeld(lockPath: string): CrossProcessLockInspection { try { - names = readdirSync(dir); + const payload = readPayload(lockPath); + return payload === undefined ? { state: 'creating' } : { state: 'held', payload }; } catch (error) { - throw toLockIoError(error, { path: dir, op: 'list-intents' }); - } - return new Set( - names - .filter((name) => name.startsWith(prefix) && name !== ownName) - .map((name) => join(dir, name)), - ); - } - - private hasLiveIntent(intentPaths: Set, creationWindowMs: number): boolean { - for (const path of intentPaths) { - let raw: string; - try { - raw = readFileSync(path, 'utf8'); - } catch (error) { - if (readErrno(error) === 'ENOENT') { - intentPaths.delete(path); - continue; - } - throw toLockIoError(error, { path, op: 'read-intent' }); - } - let payload: DiskIntentPayload | undefined; - try { - const parsed: unknown = JSON.parse(raw); - if (parsed !== null && typeof parsed === 'object') payload = parsed as DiskIntentPayload; - } catch { - // handled as a creation-window intent below - } - if (payload?.state === 'settled') { - this.removeIntent({ path, token: payload.intent_id ?? '', fd: -1 }); - intentPaths.delete(path); - continue; - } - const intentPid = payload?.pid; - if (!isProbingPid(intentPid ?? 0)) { - const mtimeMs = this.readMtimeMs(path); - if (mtimeMs !== undefined && this.now() - mtimeMs >= creationWindowMs) { - this.removeIntent({ path, token: payload?.intent_id ?? '', fd: -1 }); - intentPaths.delete(path); - continue; - } - return true; - } - const probed = this.safeProbe(intentPid as number); - const reused = - payload?.process_started_at !== undefined && - probed.processStartedAt !== undefined && - payload.process_started_at !== probed.processStartedAt; - if (!probed.alive || reused) { - this.removeIntent({ path, token: payload?.intent_id ?? '', fd: -1 }); - intentPaths.delete(path); - continue; - } - return true; - } - return false; - } - - private safeProbe(pid: number): { alive: boolean; processStartedAt?: string } { - try { - return this.probe(pid); - } catch { - return { alive: true }; - } - } - - private readMtimeMs(lockPath: string): number | undefined { - try { - return statSync(lockPath).mtimeMs; - } catch { - return undefined; + throw toLockIoError(error, lockPath, 'read-owner'); } } } diff --git a/packages/agent-core-v2/src/os/backends/node-local/processProbe.ts b/packages/agent-core-v2/src/os/backends/node-local/processProbe.ts deleted file mode 100644 index 885b6151c5..0000000000 --- a/packages/agent-core-v2/src/os/backends/node-local/processProbe.ts +++ /dev/null @@ -1,71 +0,0 @@ -/** - * `crossProcessLock` domain (L1) — node-local `ProcessProbe` implementation. - * - * `alive` follows `process.kill(pid, 0)` semantics: ESRCH means the pid is - * gone; EPERM or any other failure is treated as conservatively alive so a - * probing error never causes a live lock to be seized. `processStartedAt` is - * the opaque pid-reuse identity token: darwin attempts `sysctl -n - * kern.proc.starttime` (modern macOS exposes no named per-pid starttime OID - * — the call fails and the token is absent, which the lock protocol treats - * as "identity unavailable → treat as matching"); linux reads - * `/proc//stat` field 22 (starttime in clock ticks); other platforms - * cannot provide one. The token is only ever compared for equality by - * callers — it is never parsed for meaning, and `ps -o lstart=` is - * deliberately not used (locale-dependent, unstable format). When the - * process is dead the probe returns `{alive: false}` without collecting a - * token; a token-collection failure on a live process degrades to - * `{alive: true}` with no token. - */ - -import { execFileSync } from 'node:child_process'; -import { readFileSync } from 'node:fs'; - -import type { ProcessProbe } from '#/os/interface/crossProcessLock'; - -export function createNodeProcessProbe(): ProcessProbe { - return (pid) => { - if (!pidAlive(pid)) return { alive: false }; - return { alive: true, processStartedAt: readProcessStartedAt(pid) }; - }; -} - -function pidAlive(pid: number): boolean { - try { - process.kill(pid, 0); - return true; - } catch (error) { - const code = (error as NodeJS.ErrnoException).code; - if (code === 'ESRCH') return false; - return true; - } -} - -function readProcessStartedAt(pid: number): string | undefined { - try { - if (process.platform === 'darwin') return darwinProcessStartedAt(pid); - if (process.platform === 'linux') return linuxProcessStartedAt(pid); - return undefined; - } catch { - return undefined; - } -} - -function darwinProcessStartedAt(pid: number): string | undefined { - const out = execFileSync('sysctl', ['-n', 'kern.proc.starttime', String(pid)], { - encoding: 'utf8', - timeout: 3000, - stdio: ['ignore', 'pipe', 'ignore'], - }); - const match = /sec = (\d+), usec = (\d+)/.exec(out); - if (match === null) return undefined; - return `${match[1]}.${match[2]}`; -} - -function linuxProcessStartedAt(pid: number): string | undefined { - const raw = readFileSync(`/proc/${pid}/stat`, 'utf8'); - const close = raw.lastIndexOf(')'); - if (close < 0) return undefined; - const rest = raw.slice(close + 1).trim().split(' '); - const starttime = rest[19]; - return starttime === undefined || starttime === '' ? undefined : starttime; -} diff --git a/packages/agent-core-v2/src/os/interface/crossProcessLock.ts b/packages/agent-core-v2/src/os/interface/crossProcessLock.ts index 598a62f208..b31c07bbad 100644 --- a/packages/agent-core-v2/src/os/interface/crossProcessLock.ts +++ b/packages/agent-core-v2/src/os/interface/crossProcessLock.ts @@ -1,181 +1,77 @@ /** - * `crossProcessLock` domain (L1) — cross-process exclusive file-lock contract. + * `crossProcessLock` domain (L1) — cross-process exclusive lock contract. * - * Defines `ICrossProcessLockService`, the single lock protocol that replaces the - * repo's ad-hoc lockfiles. - * One JSON lock file per resource, created with `O_EXCL`. Protocol invariants: - * - * - Token-guarded: every acquire generates a fresh `lockId` (ulid); release, - * heartbeat and payload rewrites re-read the file and compare `lockId` before - * touching it, so a late operation never clobbers a newer holder's lock. - * - Live PID is never taken over: a lock whose owner pid is alive and whose - * `processStartedAt` identity matches is held, even when its heartbeat has - * gone silent (`alive-unresponsive` — alert, never seize). Pid death, or a - * pid whose identity no longer matches (pid reused by a new process), makes - * the lock stale. - * - Every acquisition attempt registers a process-owned contender intent before - * inspecting the lock. A creator does not return until every older in-flight - * contender has either completed or disappeared. This closes the delayed - * stale-observer race where a contender could quarantine a newer generation - * after its creator had already returned. - * - Creation window: an empty or unparseable file younger than the creation - * window is "creating" (treated as held, no address yet); only past the - * window may it be treated as stale. - * - The fd opened by the winning `O_EXCL` create stays open for the handle's - * lifetime. Heartbeat and payload updates write only through that fd, so a - * holder that lost the public path can never overwrite its successor. - * - * The on-disk JSON is flat and snake_case, matching operator-facing lock - * conventions; the six known protocol keys map to the camelCase fields of - * `CrossProcessLockPayload`, and any additional adapter-owned keys pass - * through untouched. Bound at App scope; the Node implementation lives in - * `os/backends/node-local/crossProcessLockService.ts`. + * Defines the App-scoped lock service used by durable read-modify-write + * transactions and long-lived session leases. The lock path is a permanent + * sentinel whose inode is never removed or replaced; ownership is enforced by + * the operating system through an exclusive advisory lock held on an open file + * descriptor. A separate owner document carries operator-facing metadata and + * is never consulted for lock correctness. */ +import { createDecorator, type ServiceIdentifier } from '#/_base/di/instantiation'; import { registerErrorDomain, type ErrorDomain } from '#/_base/errors/codes'; import { Error2, type Error2Options } from '#/_base/errors/errors'; -import { createDecorator, type ServiceIdentifier } from '#/_base/di/instantiation'; export interface CrossProcessLockPayload { - /** Unique token of this acquire; every mutation must be guarded by it. */ lockId: string; - /** Identity of the acquiring instance (per-service ulid by default). */ instanceId: string; pid: number; - /** Opaque platform identity token for pid-reuse detection (macOS `sysctl - kern.proc.starttime`, Linux `/proc//stat` field 22); compared by - equality only, never parsed. Absent when the platform cannot provide it. */ - processStartedAt?: string; - /** Contact address for instances that run a network service. */ address?: string; - /** Last heartbeat wall-clock ms; present only in heartbeat modes. */ - heartbeatAt?: number; - /** Adapter-owned extra fields, kept flat on disk (e.g. server lock `port`). */ - [extra: string]: unknown; -} - -export interface CrossProcessLockHeartbeatOptions { - /** Milliseconds between heartbeat writes. */ - readonly intervalMs: number; - /** Milliseconds of heartbeat silence after which a *dead-identity* holder is - observable as unresponsive; a live, identity-matching pid is still never - taken over — the ttl only feeds the `alive-unresponsive` verdict. */ - readonly ttlMs: number; } export interface CrossProcessLockWaitOptions { - /** Give up and throw `OS_LOCK_WAIT_TIMEOUT` after this many ms. */ readonly timeoutMs: number; - /** Delay between acquisition attempts; small default when omitted. */ readonly retryIntervalMs?: number; } export interface CrossProcessLockAcquireOptions { - /** Heartbeat mode. Omit for pid-only locks. */ - readonly heartbeat?: CrossProcessLockHeartbeatOptions; - /** Milliseconds an empty/unparseable lock file counts as "creating" rather - than stale. Default 5000 for heartbeat-less modes. */ - readonly creationWindowMs?: number; - /** Contact address recorded in the payload. */ readonly address?: string; - /** Extra flat fields written into the lock JSON (adapter-owned, snake_case). */ - readonly extraPayload?: Record; - /** Called once when a heartbeat-mode lock detects it has lost ownership - (payload token no longer matches). Not called for pid-only locks. */ - readonly onLost?: () => void; } -export type CrossProcessLockUnavailableReason = - /** Live, identity-matching owner (or, heartbeat mode, silently frozen). */ - | 'held' - /** Empty/unparseable file still inside its creation window. */ - | 'creating' - /** Heartbeat-mode lock whose heartbeatAt is past ttl while the owner pid is - alive and identity-matching. Never taken over; surfaced for alerting. */ - | 'holder-unresponsive'; - -export type CrossProcessLockStaleReason = - /** Owner pid no longer exists. */ - | 'holder-dead' - /** Owner pid alive but `processStartedAt` differs — pid was reused. */ - | 'pid-reused' - /** Empty/unparseable file older than the creation window. */ - | 'creation-window-expired'; - export interface CrossProcessLockInspection { - readonly state: 'free' | 'creating' | 'held' | 'stale'; - /** Present whenever the file existed and parsed. */ + readonly state: 'free' | 'creating' | 'held'; readonly payload?: CrossProcessLockPayload; - readonly unavailableReason?: CrossProcessLockUnavailableReason; - readonly staleReason?: CrossProcessLockStaleReason; } export interface ICrossProcessLockHandle { readonly lockPath: string; readonly lockId: string; - /** True while the on-disk payload still carries this handle's `lockId`. */ checkHeld(): boolean; - /** Token-guarded payload rewrite (`port` updates and the like). Re-reads - and compares `lockId` first; protocol fields (`lockId`/`instanceId`/`pid`) - are re-stamped, mutator output cannot change them. Throws - `OS_LOCK_LOST` when the token no longer matches. */ - update(mutate: (payload: CrossProcessLockPayload) => Record): void; - /** Token-guarded unlink. Idempotent; a missing or foreign-owned file is - left untouched. Stops the heartbeat when present. */ release(): void; } export interface ICrossProcessLockService { readonly _serviceBrand: undefined; - /** Fail-fast acquisition. Throws `OS_LOCK_HELD` carrying the - `CrossProcessLockUnavailableReason` when a live owner stands in the way; - takes over stale locks per protocol. */ acquire( lockPath: string, options?: CrossProcessLockAcquireOptions, ): Promise; - /** Blocking acquisition for short critical sections (lock-in-RMW): retries - while the lock is held/creating until `wait.timeoutMs` elapses. */ acquireWithWait( lockPath: string, options: CrossProcessLockAcquireOptions & { wait: CrossProcessLockWaitOptions }, ): Promise; - /** `acquireWithWait` + `fn` + guaranteed release, for read-modify-write - critical sections. */ withLock( lockPath: string, options: CrossProcessLockAcquireOptions & { wait: CrossProcessLockWaitOptions }, fn: (handle: ICrossProcessLockHandle) => T | Promise, ): Promise; - /** Read-only probe; never mutates the file. */ - inspect(lockPath: string, options?: Pick): CrossProcessLockInspection; + inspect(lockPath: string): CrossProcessLockInspection; } export const ICrossProcessLockService: ServiceIdentifier = createDecorator('crossProcessLockService'); -/** Process probing seam, injectable for tests. `alive` follows `kill(pid,0)` - semantics (EPERM counts as alive); `processStartedAt` is the opaque identity - token. A probing failure must return the conservative `{alive: true}`. */ -export type ProcessProbe = (pid: number) => { - alive: boolean; - processStartedAt?: string; -}; - -/** Test seam: every clock, pid, probe and token source is replaceable. */ export interface CrossProcessLockServiceDeps { readonly now?: () => number; readonly selfPid?: number; - readonly probeProcess?: ProcessProbe; readonly newLockId?: () => string; - readonly newAttemptId?: () => string; readonly instanceId?: string; readonly sleep?: (ms: number) => Promise; - readonly beforeStaleIsolation?: () => void | Promise; } export const OsLockErrors = { @@ -197,7 +93,7 @@ export const OsLockErrors = { public: true, }, 'os.lock.lost': { - title: 'Lock ownership was lost to another process', + title: 'Lock ownership was lost', retryable: false, public: true, }, diff --git a/packages/agent-core-v2/src/persistence/interface/writeAuthority.ts b/packages/agent-core-v2/src/persistence/interface/writeAuthority.ts index f2ce8567d4..1f45a589de 100644 --- a/packages/agent-core-v2/src/persistence/interface/writeAuthority.ts +++ b/packages/agent-core-v2/src/persistence/interface/writeAuthority.ts @@ -3,7 +3,7 @@ * * Defines `ISessionWriteAuthority`, the per-session lease proof that a Store * write must re-verify immediately before its bytes hit storage (the - * pre-commit lease re-read is the hard gate and must fail closed), and the App-scoped + * pre-commit kernel-handle check is the hard gate and must fail closed), and the App-scoped * `IWriteAuthorityRegistry` the `AppendLogStore` resolves authorities through. * The registry never creates semantics of its own: it only maps `sessionId` * to the authority the session lifecycle registered, so a write for a @@ -21,7 +21,7 @@ import type { IDisposable } from '#/_base/di/lifecycle'; export interface ISessionWriteAuthority { readonly sessionId: string; - /** Re-reads the lease payload and compares the held lock token. Throws + /** Checks the held kernel-lock handle. Throws `Error2(session.lease_lost)` when this instance no longer holds the lease; must be called immediately before any durable write. */ assertWritable(): void; diff --git a/packages/agent-core-v2/src/session/sessionLease/sessionLease.ts b/packages/agent-core-v2/src/session/sessionLease/sessionLease.ts index d845e7d737..c2b5a2dad2 100644 --- a/packages/agent-core-v2/src/session/sessionLease/sessionLease.ts +++ b/packages/agent-core-v2/src/session/sessionLease/sessionLease.ts @@ -2,17 +2,17 @@ * `sessionLease` domain (L6) — the per-session write lease. * * Defines `ISessionLeaseService`, the Session-scope seeded capability that - * state writers use to re-verify they still own the session's durable state, + * state writers use to verify they still own the session's durable state, * and the `SessionLease` object that satisfies it: an App-owned wrapper * (`SessionLifecycleService` builds it; it is deliberately not a DI service) * around the cross-process lock handle at - * `/session-leases/.json`. `assertWritable` is the hard - * gate: it synchronously re-reads the on-disk lease payload and - * compares the held `lockId` — a mismatch fails closed with + * `/session-leases/.lock`. `assertWritable` is the hard + * gate: it checks the live kernel-lock handle — a released or replaced + * sentinel fails closed with * `session.lease_lost`, marks the lease lost, and fires the loss callback * exactly once so the owning session tears itself down. Release order is the - * lifecycle's business; `release()` only forwards to the token-guarded lock - * release (a foreign payload is never unlinked) and is idempotent. + * lifecycle's business; `release()` only forwards to the idempotent kernel + * lock release. * * No default is registered for `ISessionLeaseService`: every production * session scope is seeded by `sessionLifecycle` via {@link sessionLeaseSeed}; @@ -28,10 +28,7 @@ import { Error2, ErrorCodes } from '#/errors'; import type { ICrossProcessLockHandle } from '#/os/interface/crossProcessLock'; import type { ISessionWriteAuthority } from '#/persistence/interface/writeAuthority'; -export const SESSION_LEASE_HEARTBEAT_INTERVAL_MS = 2000; -export const SESSION_LEASE_TTL_MS = 6000; export const LEASE_CREATING_RETRY_AFTER_MS = 1000; -export const HOLDER_UNRESPONSIVE_RETRY_AFTER_MS = 2000; /** `details` payload of `session.held_by_peer` errors; the zod twin lives in packages/protocol (`sessionOwnershipDetailsSchema`) and the shapes must @@ -62,9 +59,8 @@ export interface ISessionLeaseService { /** The held lease identity; `undefined` once the lease is released. */ readonly info: ISessionLeaseInfo | undefined; - /** Hard gate, synchronously re-reads the lease payload (see the file - header). Throws `Error2(session.lease_lost)` when this instance no - longer holds the lease — including after `release()`. */ + /** Hard gate. Throws `Error2(session.lease_lost)` when this instance no + longer holds the kernel lease — including after `release()`. */ assertWritable(): void; } @@ -117,8 +113,6 @@ export class SessionLease implements ISessionWriteAuthority, ISessionLeaseServic } } - /** Forwards the lock handle's heartbeat loss detection into the lease's - own once-only loss path; also driven directly by `assertWritable`. */ markLost(): void { this._lost = true; if (this._lossFired) return; @@ -134,7 +128,7 @@ export class SessionLease implements ISessionWriteAuthority, ISessionLeaseServic } export function sessionLeasePath(homeDir: string, sessionId: string): string { - return join(homeDir, 'session-leases', `${sessionId}.json`); + return join(homeDir, 'session-leases', `${sessionId}.lock`); } export function sessionLeaseSeed(lease: SessionLease): ScopeSeed { diff --git a/packages/agent-core-v2/test/app/config/configFileMutex.test.ts b/packages/agent-core-v2/test/app/config/configFileMutex.test.ts index 72dca42989..a834e48395 100644 --- a/packages/agent-core-v2/test/app/config/configFileMutex.test.ts +++ b/packages/agent-core-v2/test/app/config/configFileMutex.test.ts @@ -121,25 +121,19 @@ describe('ConfigService config.toml lock-in-RMW', () => { expect(toml).toContain(`[beta_side${i}]`); } - // Each container's own watcher reloads the other side's sections. - await waitFor(() => { - for (let i = 0; i < rounds; i++) if (b.get(`alphaSide${i}`) === undefined) return false; - return true; - }); - await waitFor(() => { - for (let i = 0; i < rounds; i++) if (a.get(`betaSide${i}`) === undefined) return false; - return true; - }); + await a.reload(); + await b.reload(); expect(b.get('alphaSide0')).toEqual({ v: 0 }); expect(a.get('betaSide3')).toEqual({ v: 3 }); }); - it('releases config.toml.lock after the critical section and leaves no stale files', async () => { + it('releases config.toml.lock after the critical section and keeps the sentinel', async () => { const config = createContainer(); await config.set('alphaSection', { one: 1 }); await config.set('betaSection', { two: 2 }); - expect(existsSync(join(homeDir, 'config.toml.lock'))).toBe(false); + expect(existsSync(join(homeDir, 'config.toml.lock'))).toBe(true); + expect(existsSync(join(homeDir, 'config.toml.lock.owner.json'))).toBe(false); expect(readdirSync(homeDir).filter((entry) => entry.includes('.stale.'))).toEqual([]); }); @@ -150,7 +144,7 @@ describe('ConfigService config.toml lock-in-RMW', () => { expect(readFileSync(configPath, 'utf8')).toContain('[alpha_section]'); expect(existsSync(join(homeDir, 'custom.toml'))).toBe(false); - expect(existsSync(`${configPath}.lock`)).toBe(false); + expect(existsSync(`${configPath}.lock`)).toBe(true); }); it('fails set() with storage.locked while another holder is stuck, leaving config.toml intact', async () => { @@ -159,7 +153,6 @@ describe('ConfigService config.toml lock-in-RMW', () => { const victim = new CrossProcessLockService({ selfPid: 1001, instanceId: 'victim', - probeProcess: () => ({ alive: true }), now: () => nowValue, newLockId: () => `victim-${++lockSeq}`, sleep: (ms) => { @@ -174,7 +167,6 @@ describe('ConfigService config.toml lock-in-RMW', () => { const attacker = new CrossProcessLockService({ selfPid: 2002, instanceId: 'attacker', - probeProcess: () => ({ alive: true }), newLockId: () => 'attacker-lock', }); const lockPath = join(homeDir, 'config.toml.lock'); @@ -188,6 +180,6 @@ describe('ConfigService config.toml lock-in-RMW', () => { } finally { handle.release(); } - expect(existsSync(lockPath)).toBe(false); + expect(existsSync(lockPath)).toBe(true); }); }); diff --git a/packages/agent-core-v2/test/app/sessionLifecycle/sessionLifecycle.test.ts b/packages/agent-core-v2/test/app/sessionLifecycle/sessionLifecycle.test.ts index 06a28c80d6..af754cda92 100644 --- a/packages/agent-core-v2/test/app/sessionLifecycle/sessionLifecycle.test.ts +++ b/packages/agent-core-v2/test/app/sessionLifecycle/sessionLifecycle.test.ts @@ -1,4 +1,5 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { tryAcquireKernelFileLock } from '@moonshot-ai/kernel-file-lock'; import { mkdtemp, mkdir, readFile, rm, stat, writeFile } from 'node:fs/promises'; import { tmpdir } from 'node:os'; @@ -1318,7 +1319,11 @@ describe('SessionLifecycleService', () => { } function leaseFile(root: string, sessionId: string): string { - return join(root, 'session-leases', `${sessionId}.json`); + return join(root, 'session-leases', `${sessionId}.lock`); + } + + function leaseOwnerFile(root: string, sessionId: string): string { + return `${leaseFile(root, sessionId)}.owner.json`; } async function createError( @@ -1337,7 +1342,7 @@ describe('SessionLifecycleService', () => { const svc = build(realInstanceSeeds(root)); const h = await svc.create({ sessionId: 's1', workDir: '/tmp/proj' }); - const payload = JSON.parse(await readFile(leaseFile(root, 's1'), 'utf8')); + const payload = JSON.parse(await readFile(leaseOwnerFile(root, 's1'), 'utf8')); const lease = h.accessor.get(ISessionLeaseService); expect(lease.info).toEqual({ sessionId: 's1', lockId: payload.lock_id }); expect(() => lease.assertWritable()).not.toThrow(); @@ -1362,7 +1367,8 @@ describe('SessionLifecycleService', () => { expect(svc.get('s1')).toBeUndefined(); expect(svc.list()).toEqual([]); expect(registry.resolve('s1')).toBeUndefined(); - await expect(stat(leaseFile(root, 's1'))).rejects.toThrow(); + await expect(stat(leaseFile(root, 's1'))).resolves.toBeDefined(); + await expect(stat(leaseOwnerFile(root, 's1'))).rejects.toThrow(); expect(disposedSessionServices).toBeGreaterThan(0); }); @@ -1442,91 +1448,35 @@ describe('SessionLifecycleService', () => { } }); - it('reports holder-unresponsive for a live holder with a stale heartbeat', async () => { - const root = await makeTmpRoot(); - await mkdir(join(root, 'session-leases'), { recursive: true }); - await writeFile( - leaseFile(root, 's1'), - JSON.stringify({ - lock_id: 'old-token', - instance_id: 'inst-old', - pid: process.pid, - heartbeat_at: Date.now() - 60_000, - }), - ); - const svc = build(realInstanceSeeds(root)); - const error = await createError(svc, 's1'); - expect(error.code).toBe(ErrorCodes.SESSION_HELD_BY_PEER); - expect(error.details).toEqual({ - kind: 'held-by-peer', - phase: 'holder-unresponsive', - retry_after_ms: 2000, - }); - expect(telemetryRecords).toContainEqual({ - event: 'session_lease_holder_unresponsive', - properties: { session_id: 's1' }, - }); - }); - - it('reports the creating phase for a lease file inside its creation window', async () => { + it('reports the creating phase while the kernel holder publishes owner metadata', async () => { const root = await makeTmpRoot(); - await mkdir(join(root, 'session-leases'), { recursive: true }); - await writeFile(leaseFile(root, 's1'), ''); - const svc = build(realInstanceSeeds(root)); - const error = await createError(svc, 's1'); - expect(error.code).toBe(ErrorCodes.SESSION_HELD_BY_PEER); - expect(error.details).toEqual({ kind: 'held-by-peer', phase: 'creating', retry_after_ms: 1000 }); + const handle = tryAcquireKernelFileLock(leaseFile(root, 's1'))!; + try { + const svc = build(realInstanceSeeds(root)); + const error = await createError(svc, 's1'); + expect(error.code).toBe(ErrorCodes.SESSION_HELD_BY_PEER); + expect(error.details).toEqual({ + kind: 'held-by-peer', + phase: 'creating', + retry_after_ms: 1000, + }); + } finally { + handle.release(); + } }); - it('takes over the lease of a dead holder and reports session_lease_takeover', async () => { + it('ignores legacy payload contents when no process holds the kernel lock', async () => { const root = await makeTmpRoot(); await mkdir(join(root, 'session-leases'), { recursive: true }); await writeFile( leaseFile(root, 's1'), - JSON.stringify({ - lock_id: 'dead-token', - instance_id: 'inst-dead', - pid: 0x7fffffff, - heartbeat_at: 1, - }), + JSON.stringify({ lock_id: 'legacy-token', instance_id: 'legacy', pid: 0x7fffffff }), ); const svc = build(realInstanceSeeds(root)); const h = await svc.create({ sessionId: 's1', workDir: '/tmp/proj' }); expect(h.id).toBe('s1'); - expect(telemetryRecords).toContainEqual({ - event: 'session_lease_takeover', - properties: { session_id: 's1', previous: 'holder-dead' }, - }); - const payload = JSON.parse(await readFile(leaseFile(root, 's1'), 'utf8')); - expect(payload.lock_id).not.toBe('dead-token'); - }); - - it('tears the session down when a peer takes over its lease', async () => { - const root = await makeTmpRoot(); - const { seeds, appendLog } = realAlsSeeds(root); - const svc = build(seeds); - await svc.create({ sessionId: 's1', workDir: '/tmp/proj' }); - - const leasePath = leaseFile(root, 's1'); - const payload = JSON.parse(await readFile(leasePath, 'utf8')); - await writeFile(leasePath, JSON.stringify({ ...payload, lock_id: 'peer-token' })); - - const agentScopeStr = 'sessions/wd_stub/s1/agents/main'; - appendLog.append(agentScopeStr, 'wire.jsonl', { type: 'metadata' }); - await expect(appendLog.flush()).rejects.toMatchObject({ - code: ErrorCodes.SESSION_LEASE_LOST, - }); - - await waitFor(() => svc.get('s1') === undefined); - - appendLog.append(agentScopeStr, 'wire.jsonl', { type: 'metadata' }); - await expect(appendLog.flush()).rejects.toMatchObject({ - code: ErrorCodes.SESSION_LEASE_LOST, - }); - await expect(stat(join(root, agentScopeStr))).rejects.toThrow(); - // The token-guarded release never unlinks the peer's lease file. - const remaining = JSON.parse(await readFile(leasePath, 'utf8')); - expect(remaining.lock_id).toBe('peer-token'); + const payload = JSON.parse(await readFile(leaseOwnerFile(root, 's1'), 'utf8')); + expect(payload.lock_id).not.toBe('legacy-token'); }); it('fork acquires a distinct target lease; releasing the source does not fence the target', async () => { @@ -1550,8 +1500,8 @@ describe('SessionLifecycleService', () => { const target = await svc.fork({ sourceSessionId: 'src', newSessionId: 'dst' }); expect(target.id).toBe('dst'); - const srcPayload = JSON.parse(await readFile(leaseFile(root, 'src'), 'utf8')); - const dstPayload = JSON.parse(await readFile(leaseFile(root, 'dst'), 'utf8')); + const srcPayload = JSON.parse(await readFile(leaseOwnerFile(root, 'src'), 'utf8')); + const dstPayload = JSON.parse(await readFile(leaseOwnerFile(root, 'dst'), 'utf8')); expect(dstPayload.lock_id).not.toBe(srcPayload.lock_id); appendLog.append('sessions/wd_stub/src/agents/main', 'wire.jsonl', { n: 1 }); @@ -1585,7 +1535,8 @@ describe('SessionLifecycleService', () => { const disk = await readFile(join(root, agentScopeStr, 'wire.jsonl'), 'utf8'); expect(disk).toBe('{"tail":true}\n'); - await expect(stat(leaseFile(root, 's1'))).rejects.toThrow(); + await expect(stat(leaseFile(root, 's1'))).resolves.toBeDefined(); + await expect(stat(leaseOwnerFile(root, 's1'))).rejects.toThrow(); }); it('keeps authority and lease when the session durability barrier fails', async () => { @@ -1639,7 +1590,8 @@ describe('SessionLifecycleService', () => { await svc.forceAbort('s1'); expect(registry.resolve('s1')).toBeUndefined(); - await expect(stat(leaseFile(root, 's1'))).rejects.toThrow(); + await expect(stat(leaseFile(root, 's1'))).resolves.toBeDefined(); + await expect(stat(leaseOwnerFile(root, 's1'))).rejects.toThrow(); const successor = await new CrossProcessLockService().acquire(leaseFile(root, 's1')); successor.release(); expect(telemetryRecords).toContainEqual({ @@ -1675,7 +1627,8 @@ describe('SessionLifecycleService', () => { await blockedStarted; await svc.close('s1'); - await expect(stat(leaseFile(root, 's1'))).rejects.toThrow(); + await expect(stat(leaseFile(root, 's1'))).resolves.toBeDefined(); + await expect(stat(leaseOwnerFile(root, 's1'))).rejects.toThrow(); await expect(stat(leaseFile(root, 's10'))).resolves.toBeDefined(); releaseBlocked(); diff --git a/packages/agent-core-v2/test/os/backends/node-local/crossProcessLockService.test.ts b/packages/agent-core-v2/test/os/backends/node-local/crossProcessLockService.test.ts index 41d5777025..cd6bb799d0 100644 --- a/packages/agent-core-v2/test/os/backends/node-local/crossProcessLockService.test.ts +++ b/packages/agent-core-v2/test/os/backends/node-local/crossProcessLockService.test.ts @@ -1,144 +1,53 @@ /** - * `crossProcessLock` domain — integration tests for the node-local lock - * service against a real temporary directory. + * `crossProcessLock` domain — node-local kernel-lock integration tests. * - * Every test constructs the service with the full fake seam (clock, self pid, - * process probe, token source, sleep) and asserts the on-disk file names and - * snake_case payload keys, not just in-memory state. Dead-pid simulation for - * the real probe uses `0x7fffffff` (guaranteed ESRCH), mirroring kap-server's - * lock tests. Timed tests use real short sleep; the fake clock is advanced by - * hand where the protocol depends on wall-clock values. + * Exercises permanent sentinels, diagnostic owner metadata, fail-fast and + * waiting acquisition, and release behavior against a real temporary + * directory. */ -import { - existsSync, - mkdtempSync, - readFileSync, - readdirSync, - renameSync, - rmSync, - utimesSync, - writeFileSync, -} from 'node:fs'; +import { existsSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs'; import { tmpdir } from 'node:os'; import { join } from 'node:path'; import { afterEach, beforeEach, describe, expect, it } from 'vitest'; import { CrossProcessLockService } from '#/os/backends/node-local/crossProcessLockService'; -import { createNodeProcessProbe } from '#/os/backends/node-local/processProbe'; import { CrossProcessLockErrorCode, + type CrossProcessLockServiceDeps, type ICrossProcessLockHandle, - type ProcessProbe, } from '#/os/interface/crossProcessLock'; -const SELF_PID = 1001; -const OTHER_PID = 2002; -/** Max signed-32 pid; the kernel never allocates it, so `kill(pid, 0)` → ESRCH. */ -const DEAD_PID = 0x7fffffff; - -const realSleep = (ms: number): Promise => - new Promise((resolvePromise) => setTimeout(resolvePromise, ms)); - let tmpDir: string; let lockPath: string; -let nowValue: number; -let lockSeq: number; -let attemptSeq: number; const handles: ICrossProcessLockHandle[] = []; -function track(handle: T): T { - handles.push(handle); - return handle; -} - -function probeFor(live: ReadonlyMap): ProcessProbe { - return (pid) => { - if (!live.has(pid)) return { alive: false }; - const startedAt = live.get(pid); - return startedAt === undefined - ? { alive: true } - : { alive: true, processStartedAt: startedAt }; - }; -} - -interface FakeServiceOptions { - selfPid?: number; - instanceId?: string; - probe?: ProcessProbe; - now?: () => number; - beforeStaleIsolation?: () => void | Promise; - sleep?: (ms: number) => Promise; -} - -function makeService(options: FakeServiceOptions = {}): CrossProcessLockService { - const selfPid = options.selfPid ?? SELF_PID; +function service( + instanceId: string, + pid: number, + deps: Pick = {}, +): CrossProcessLockService { + let sequence = 0; return new CrossProcessLockService({ - selfPid, - instanceId: options.instanceId ?? 'inst-self', - probeProcess: options.probe ?? probeFor(new Map([[selfPid, 'self-start']])), - now: options.now ?? (() => nowValue), - newLockId: () => `lockid-${++lockSeq}`, - newAttemptId: () => `attempt-${++attemptSeq}`, - beforeStaleIsolation: options.beforeStaleIsolation, - sleep: options.sleep ?? realSleep, + ...deps, + instanceId, + selfPid: pid, + newLockId: () => `${instanceId}-${++sequence}`, }); } -function liveWorld(): Map { - return new Map([ - [SELF_PID, 'self-start'], - [OTHER_PID, 'other-start'], - ]); -} - -function writePayload(payload: Record): void { - writeFileSync(lockPath, JSON.stringify(payload)); -} - -interface DiskLockJson { - lock_id?: string; - instance_id?: string; - pid?: number; - process_started_at?: string; - address?: string; - heartbeat_at?: number; - port?: number; - [extra: string]: unknown; -} - -function readDisk(): DiskLockJson { - return JSON.parse(readFileSync(lockPath, 'utf8')) as DiskLockJson; -} - -function backdate(path: string, ageMs: number): void { - const t = (Date.now() - ageMs) / 1000; - utimesSync(path, t, t); -} - -function findStalePath(lockId: string): string { - const prefix = `lock.stale.${lockId}.`; - const name = readdirSync(tmpDir).find((entry) => entry.startsWith(prefix)); - expect(name).toBeDefined(); - return join(tmpDir, name!); +function ownerPath(): string { + return `${lockPath}.owner.json`; } -async function waitFor(cond: () => boolean, timeoutMs = 3_000): Promise { - const start = Date.now(); - for (;;) { - if (cond()) return; - if (Date.now() - start > timeoutMs) throw new Error('waitFor timed out'); - await realSleep(10); - } +function readOwner(): Record { + return JSON.parse(readFileSync(ownerPath(), 'utf8')) as Record; } beforeEach(() => { - tmpDir = mkdtempSync(join(tmpdir(), 'cplock-test-')); - lockPath = join(tmpDir, 'lock'); - nowValue = 1_000_000; - lockSeq = 0; - attemptSeq = 0; + tmpDir = mkdtempSync(join(tmpdir(), 'kimi-kernel-lock-')); + lockPath = join(tmpDir, 'resource.lock'); }); afterEach(() => { @@ -146,567 +55,128 @@ afterEach(() => { rmSync(tmpDir, { recursive: true, force: true }); }); -describe('acquire / release', () => { - it('writes the snake_case payload with extras flat, and release removes the file', async () => { - const svc = makeService(); - const handle = track( - await svc.acquire(lockPath, { - address: '127.0.0.1:58627', - extraPayload: { port: 58627, role: 'primary' }, - }), - ); - expect(handle.lockId).toBe('lockid-1'); - expect(readDisk()).toEqual({ - lock_id: 'lockid-1', - instance_id: 'inst-self', - pid: SELF_PID, - process_started_at: 'self-start', - address: '127.0.0.1:58627', - port: 58627, - role: 'primary', - }); - - handle.release(); - expect(existsSync(lockPath)).toBe(false); - }); - - it('omits optional keys when the platform cannot provide them', async () => { - const svc = makeService({ probe: () => ({ alive: true }) }); - const handle = track(await svc.acquire(lockPath)); - expect(readDisk()).toEqual({ - lock_id: 'lockid-1', - instance_id: 'inst-self', - pid: SELF_PID, - }); - }); - - it('creates missing parent directories and release is idempotent', async () => { - const nested = join(tmpDir, 'a', 'b', 'lock'); - const svc = makeService(); - const handle = track(await svc.acquire(nested)); - expect(existsSync(nested)).toBe(true); - handle.release(); - handle.release(); - expect(existsSync(nested)).toBe(false); - }); - - it('release never unlinks a foreign lock', async () => { - const svc = makeService(); - const handle = track(await svc.acquire(lockPath)); - handle.release(); - writePayload({ lock_id: 'someone-else', instance_id: 'x', pid: OTHER_PID }); - handle.release(); - expect(existsSync(lockPath)).toBe(true); - expect(readDisk().lock_id).toBe('someone-else'); - }); -}); - -describe('held vs takeover', () => { - it('a live identity-matching holder blocks acquisition with OS_LOCK_HELD', async () => { - writePayload({ - lock_id: 'old-id', - instance_id: 'inst-other', - pid: OTHER_PID, - process_started_at: 'other-start', - }); - const svc = makeService({ probe: probeFor(liveWorld()) }); - const before = readFileSync(lockPath, 'utf8'); - - await expect(svc.acquire(lockPath)).rejects.toMatchObject({ - code: CrossProcessLockErrorCode.Held, - details: { reason: 'held' }, - }); - expect(readFileSync(lockPath, 'utf8')).toBe(before); - expect(readdirSync(tmpDir)).toEqual(['lock']); - }); - - it('takes over a dead holder with rename isolation', async () => { - const live = liveWorld(); - const probe = probeFor(live); - const oldHandle = track( - await makeService({ selfPid: OTHER_PID, instanceId: 'inst-other', probe }).acquire( - lockPath, - { extraPayload: { port: 1 } }, - ), - ); - const oldDisk = JSON.parse(readFileSync(lockPath, 'utf8')) as Record; - - live.delete(OTHER_PID); - const handle = track(await makeService({ probe }).acquire(lockPath)); - - const stalePath = findStalePath('lockid-1'); - expect(JSON.parse(readFileSync(stalePath, 'utf8'))).toEqual(oldDisk); - expect(readDisk().lock_id).toBe('lockid-2'); - expect(handle.lockId).toBe('lockid-2'); - - expect(oldHandle.checkHeld()).toBe(false); - oldHandle.release(); +describe('CrossProcessLockService', () => { + it('keeps a permanent sentinel and treats owner metadata as diagnostic state', async () => { + const lock = service('alpha', 1001); + expect(lock.inspect(lockPath)).toEqual({ state: 'free' }); expect(existsSync(lockPath)).toBe(true); - expect(readDisk().lock_id).toBe('lockid-2'); - }); - - it('treats a live pid with mismatched identity as stale (pid reused)', async () => { - writePayload({ - lock_id: 'old-id', - instance_id: 'inst-other', - pid: OTHER_PID, - process_started_at: 'original-start', - }); - const live = liveWorld(); - live.set(OTHER_PID, 'reused-start'); - const svc = makeService({ probe: probeFor(live) }); - - expect(svc.inspect(lockPath)).toMatchObject({ - state: 'stale', - staleReason: 'pid-reused', - }); - track(await svc.acquire(lockPath)); - expect(existsSync(findStalePath('old-id'))).toBe(true); - expect(readDisk().lock_id).toBe('lockid-1'); - }); - - it('refuses takeover when the holder identity is unavailable (conservative held)', async () => { - writePayload({ - lock_id: 'old-id', - instance_id: 'inst-other', - pid: OTHER_PID, - process_started_at: 'original-start', - }); - const live = liveWorld(); - live.set(OTHER_PID, undefined); - const svc = makeService({ probe: probeFor(live) }); - - expect(svc.inspect(lockPath)).toMatchObject({ state: 'held', unavailableReason: 'held' }); - await expect(svc.acquire(lockPath)).rejects.toMatchObject({ - code: CrossProcessLockErrorCode.Held, - }); - expect(readDisk().lock_id).toBe('old-id'); - expect(readdirSync(tmpDir)).toEqual(['lock']); - }); - - it('takes over a legacy payload without lock_id, renamed aside as unknown', async () => { - writePayload({ pid: OTHER_PID, started_at: '1', port: 58627 }); - const svc = makeService(); - - expect(svc.inspect(lockPath)).toMatchObject({ - state: 'stale', - staleReason: 'holder-dead', - payload: { lockId: '', pid: OTHER_PID, port: 58627 }, - }); - track(await svc.acquire(lockPath)); - expect(existsSync(findStalePath('unknown'))).toBe(true); - expect(readDisk().lock_id).toBe('lockid-1'); - }); - it('never lets two delayed stale contenders both return ownership', async () => { - writePayload({ - lock_id: 'old-id', - instance_id: 'inst-dead', - pid: DEAD_PID, - }); - const probe = probeFor(liveWorld()); - let enterIsolation!: () => void; - const isolationEntered = new Promise((resolvePromise) => { - enterIsolation = resolvePromise; - }); - let resumeIsolation!: () => void; - const isolationPaused = new Promise((resolvePromise) => { - resumeIsolation = resolvePromise; - }); - let pauseCount = 0; - const contenderA = makeService({ - probe, - now: Date.now, - beforeStaleIsolation: async () => { - pauseCount += 1; - if (pauseCount !== 1) return; - enterIsolation(); - await isolationPaused; - }, - }); - const contenderB = makeService({ - selfPid: OTHER_PID, - instanceId: 'inst-b', - probe, - now: Date.now, + const handle = await lock.acquire(lockPath, { + address: 'http://127.0.0.1:58627', }); + handles.push(handle); - const acquireA = contenderA.acquire(lockPath, { creationWindowMs: 500 }); - await isolationEntered; - const acquireB = contenderB.acquire(lockPath, { creationWindowMs: 500 }); - await waitFor(() => readDisk().lock_id === 'lockid-1'); - resumeIsolation(); - - const outcomes = await Promise.allSettled([acquireA, acquireB]); - const fulfilled = outcomes.filter( - (outcome): outcome is PromiseFulfilledResult => - outcome.status === 'fulfilled', - ); - const rejected = outcomes.filter( - (outcome): outcome is PromiseRejectedResult => outcome.status === 'rejected', - ); - expect(fulfilled).toHaveLength(1); - expect(rejected).toHaveLength(1); - expect(rejected[0]?.reason).toMatchObject({ code: CrossProcessLockErrorCode.Lost }); - track(fulfilled[0]!.value); - expect(readDisk().lock_id).toBe(fulfilled[0]!.value.lockId); - expect(readdirSync(tmpDir).filter((entry) => entry.startsWith('lock.intent.'))).toEqual([]); - }); - - it('settles against the contender snapshot without waiting for later arrivals', async () => { - const olderIntent = `${lockPath}.intent.older`; - const laterIntent = `${lockPath}.intent.later`; - writeFileSync( - olderIntent, - JSON.stringify({ pid: OTHER_PID, process_started_at: 'other-start' }), - ); - let sleepCount = 0; - const svc = makeService({ - probe: probeFor(liveWorld()), - sleep: async () => { - sleepCount += 1; - writeFileSync( - laterIntent, - JSON.stringify({ pid: OTHER_PID, process_started_at: 'other-start' }), - ); - rmSync(olderIntent, { force: true }); + expect(lock.inspect(lockPath)).toEqual({ + state: 'held', + payload: { + lockId: 'alpha-1', + instanceId: 'alpha', + pid: 1001, + address: 'http://127.0.0.1:58627', }, }); - - const handle = track(await svc.acquire(lockPath, { creationWindowMs: 500 })); - - expect(sleepCount).toBe(1); - expect(handle.checkHeld()).toBe(true); - expect(existsSync(laterIntent)).toBe(true); - }); - - it('ignores a settled intent left behind after cleanup failure', async () => { - writeFileSync( - `${lockPath}.intent.orphan`, - JSON.stringify({ intent_id: 'orphan', state: 'settled', pid: OTHER_PID }), - ); - - const handle = track(await makeService({ probe: probeFor(liveWorld()) }).acquire(lockPath)); - - expect(handle.lockId).toBe('lockid-1'); - expect(handle.checkHeld()).toBe(true); - }); -}); - -describe('creation window', () => { - it('an empty file inside the window is creating', async () => { - writeFileSync(lockPath, ''); - const svc = makeService({ now: () => Date.now() }); - - expect(svc.inspect(lockPath)).toMatchObject({ state: 'creating' }); - await expect(svc.acquire(lockPath)).rejects.toMatchObject({ - code: CrossProcessLockErrorCode.Held, - details: { reason: 'creating' }, - }); - expect(readFileSync(lockPath, 'utf8')).toBe(''); - expect(readdirSync(tmpDir)).toEqual(['lock']); - }); - - it('an empty file past the window is taken over as stale', async () => { - writeFileSync(lockPath, ''); - backdate(lockPath, 10_000); - const svc = makeService({ now: () => Date.now() }); - - expect(svc.inspect(lockPath)).toMatchObject({ - state: 'stale', - staleReason: 'creation-window-expired', - }); - track(await svc.acquire(lockPath)); - expect(existsSync(findStalePath('unknown'))).toBe(true); - expect(readDisk().lock_id).toBe('lockid-1'); - }); - - it('an unparseable file follows the same window', async () => { - writeFileSync(lockPath, '{oops'); - const svc = makeService({ now: () => Date.now() }); - - expect(svc.inspect(lockPath)).toMatchObject({ state: 'creating' }); - backdate(lockPath, 10_000); - expect(svc.inspect(lockPath)).toMatchObject({ - state: 'stale', - staleReason: 'creation-window-expired', + expect(readOwner()).toEqual({ + lock_id: 'alpha-1', + instance_id: 'alpha', + pid: 1001, + address: 'http://127.0.0.1:58627', }); - track(await svc.acquire(lockPath)); - expect(existsSync(findStalePath('unknown'))).toBe(true); - expect(readDisk().instance_id).toBe('inst-self'); - }); -}); - -describe('heartbeat mode', () => { - it('beats heartbeat_at into the disk payload through the kept fd', async () => { - const svc = makeService(); - const handle = track( - await svc.acquire(lockPath, { heartbeat: { intervalMs: 20, ttlMs: 60_000 } }), - ); - expect(readDisk().heartbeat_at).toBe(1_000_000); - nowValue = 1_000_500; - await waitFor(() => readDisk().heartbeat_at === 1_000_500); handle.release(); - expect(existsSync(lockPath)).toBe(false); - }); - - it('a taken-over holder detects the loss on its next beat and fires onLost exactly once', async () => { - const live = liveWorld(); - const probe = probeFor(live); - let lostCount = 0; - const oldHandle = track( - await makeService({ probe }).acquire(lockPath, { - heartbeat: { intervalMs: 20, ttlMs: 60_000 }, - onLost: () => { - lostCount += 1; - }, - }), - ); - - live.delete(SELF_PID); - track( - await makeService({ selfPid: OTHER_PID, instanceId: 'inst-b', probe }).acquire(lockPath), - ); - - await waitFor(() => lostCount === 1); - await realSleep(100); - expect(lostCount).toBe(1); - expect(oldHandle.checkHeld()).toBe(false); - expect(readDisk().lock_id).toBe('lockid-2'); + expect(existsSync(lockPath)).toBe(true); + expect(existsSync(ownerPath())).toBe(false); + expect(lock.inspect(lockPath)).toEqual({ state: 'free' }); }); - it('a silent heartbeat with a live identity-matching pid is holder-unresponsive, never seized', async () => { - writePayload({ - lock_id: 'old-id', - instance_id: 'inst-other', - pid: OTHER_PID, - process_started_at: 'other-start', - heartbeat_at: nowValue - 10_000, - }); - const svc = makeService({ probe: probeFor(liveWorld()) }); - const before = readFileSync(lockPath, 'utf8'); + it('rejects a second holder in the same process', async () => { + const first = await service('alpha', 1001).acquire(lockPath); + handles.push(first); - await expect( - svc.acquire(lockPath, { heartbeat: { intervalMs: 100, ttlMs: 5_000 } }), - ).rejects.toMatchObject({ + await expect(service('beta', 2002).acquire(lockPath)).rejects.toMatchObject({ code: CrossProcessLockErrorCode.Held, - details: { reason: 'holder-unresponsive' }, + details: { path: lockPath, reason: 'held' }, }); - expect(readFileSync(lockPath, 'utf8')).toBe(before); - expect(readdirSync(tmpDir)).toEqual(['lock']); }); - it('a fresh heartbeat with a live holder is a plain held', async () => { - writePayload({ - lock_id: 'old-id', - instance_id: 'inst-other', - pid: OTHER_PID, - process_started_at: 'other-start', - heartbeat_at: nowValue - 100, - }); - const svc = makeService({ probe: probeFor(liveWorld()) }); + it('reports creating when a holder has no readable owner metadata', async () => { + const lock = service('alpha', 1001); + const handle = await lock.acquire(lockPath); + handles.push(handle); + writeFileSync(ownerPath(), '{'); - await expect( - svc.acquire(lockPath, { heartbeat: { intervalMs: 100, ttlMs: 5_000 } }), - ).rejects.toMatchObject({ - code: CrossProcessLockErrorCode.Held, - details: { reason: 'held' }, - }); - expect(readDisk().lock_id).toBe('old-id'); + expect(lock.inspect(lockPath)).toEqual({ state: 'creating' }); }); -}); -describe('acquireWithWait / withLock', () => { - it('a waiting acquirer obtains the lock after the holder releases', async () => { - const live = liveWorld(); - const probe = probeFor(live); - const holder = track(await makeService({ probe }).acquire(lockPath)); + it('waits until the holder releases', async () => { + const first = await service('alpha', 1001).acquire(lockPath); + handles.push(first); + setTimeout(() => first.release(), 20); - const waiter = makeService({ - selfPid: OTHER_PID, - instanceId: 'inst-b', - probe, - now: () => Date.now(), + const second = await service('beta', 2002).acquireWithWait(lockPath, { + wait: { timeoutMs: 500, retryIntervalMs: 5 }, }); - const acquired: string[] = []; - const pending = waiter.withLock( - lockPath, - { wait: { timeoutMs: 5_000, retryIntervalMs: 5 } }, - (handle) => { - acquired.push(handle.lockId); - return 'done'; - }, - ); - await realSleep(50); - holder.release(); - - await expect(pending).resolves.toBe('done'); - expect(acquired).toEqual(['lockid-2']); - expect(existsSync(lockPath)).toBe(false); + handles.push(second); + expect(second.checkHeld()).toBe(true); }); - it('a waiting acquirer gives up with OS_LOCK_WAIT_TIMEOUT', async () => { - const live = liveWorld(); - const probe = probeFor(live); - track(await makeService({ probe }).acquire(lockPath)); + it('times out waiting for a held lock', async () => { + const first = await service('alpha', 1001).acquire(lockPath); + handles.push(first); - const waiter = makeService({ - selfPid: OTHER_PID, - instanceId: 'inst-b', - probe, - now: () => Date.now(), - }); await expect( - waiter.acquireWithWait(lockPath, { wait: { timeoutMs: 100, retryIntervalMs: 5 } }), + service('beta', 2002).acquireWithWait(lockPath, { + wait: { timeoutMs: 15, retryIntervalMs: 5 }, + }), ).rejects.toMatchObject({ code: CrossProcessLockErrorCode.WaitTimeout }); - expect(readDisk().lock_id).toBe('lockid-1'); - }); -}); - -describe('update', () => { - it('rewrites extras and re-stamps the protocol keys', async () => { - const svc = makeService(); - const handle = track(await svc.acquire(lockPath, { extraPayload: { port: 1 } })); - - handle.update((payload) => ({ - ...payload, - port: 58627, - lockId: 'evil', - instanceId: 'evil', - pid: 9999, - })); - expect(readDisk()).toEqual({ - lock_id: 'lockid-1', - instance_id: 'inst-self', - pid: SELF_PID, - process_started_at: 'self-start', - port: 58627, - }); - expect(handle.lockId).toBe('lockid-1'); }); - it('a later update keeps the extras the heartbeat rewrites', async () => { - const svc = makeService(); - const handle = track( - await svc.acquire(lockPath, { - heartbeat: { intervalMs: 20, ttlMs: 60_000 }, - extraPayload: { port: 1 }, - }), - ); - handle.update((payload) => ({ ...payload, port: 58627 })); - nowValue = 1_000_700; - await waitFor( - () => readDisk().port === 58627 && readDisk().heartbeat_at === 1_000_700, - ); - expect(readDisk()).toMatchObject({ - lock_id: 'lockid-1', - port: 58627, - heartbeat_at: 1_000_700, + it('releases a lock acquired as the wait deadline expires', async () => { + const first = await service('alpha', 1001).acquire(lockPath); + handles.push(first); + const times = [0, 0, 9, 10]; + const sleepDurations: number[] = []; + const waiter = service('beta', 2002, { + now: () => times.shift() ?? 10, + sleep: async (ms) => { + sleepDurations.push(ms); + first.release(); + }, }); - handle.release(); - }); - - it('update after a takeover throws OS_LOCK_LOST', async () => { - const live = liveWorld(); - const probe = probeFor(live); - const oldHandle = track( - await makeService({ probe }).acquire(lockPath, { extraPayload: { port: 1 } }), - ); - live.delete(SELF_PID); - track( - await makeService({ selfPid: OTHER_PID, instanceId: 'inst-b', probe }).acquire(lockPath), - ); - expect(() => { - oldHandle.update((payload) => ({ ...payload, port: 2 })); - }).toThrowError(expect.objectContaining({ code: CrossProcessLockErrorCode.Lost })); - expect(readDisk().lock_id).toBe('lockid-2'); - expect(readDisk().port).toBeUndefined(); - }); - - it('update detects a takeover that happens after its initial token check', async () => { - const svc = makeService(); - const handle = track(await svc.acquire(lockPath, { extraPayload: { port: 1 } })); - const oldPath = `${lockPath}.old`; - - expect(() => { - handle.update((payload) => { - renameSync(lockPath, oldPath); - writeFileSync( - lockPath, - JSON.stringify({ - lock_id: 'foreign-lock', - instance_id: 'foreign-instance', - pid: OTHER_PID, - }), - ); - return { ...payload, port: 2 }; - }); - }).toThrowError(expect.objectContaining({ code: CrossProcessLockErrorCode.Lost })); - expect(readDisk().lock_id).toBe('foreign-lock'); - }); -}); + const result = await waiter + .acquireWithWait(lockPath, { wait: { timeoutMs: 10, retryIntervalMs: 100 } }) + .then( + (handle) => ({ status: 'acquired' as const, handle }), + (error: unknown) => ({ status: 'rejected' as const, error }), + ); -describe('inspect', () => { - it('free when the file is missing', () => { - const svc = makeService(); - expect(svc.inspect(lockPath)).toEqual({ state: 'free' }); + if (result.status === 'acquired') handles.push(result.handle); + expect(sleepDurations).toEqual([10]); + expect(result.status).toBe('rejected'); + if (result.status === 'rejected') { + expect(result.error).toMatchObject({ code: CrossProcessLockErrorCode.WaitTimeout }); + } + expect(waiter.inspect(lockPath)).toEqual({ state: 'free' }); }); - it('held with payload passthrough for a live holder', () => { - writePayload({ - lock_id: 'x', - instance_id: 'inst-other', - pid: OTHER_PID, - process_started_at: 'other-start', - address: '127.0.0.1:1', - port: 58627, - }); - const svc = makeService({ probe: probeFor(liveWorld()) }); - - expect(svc.inspect(lockPath)).toMatchObject({ - state: 'held', - unavailableReason: 'held', - payload: { - lockId: 'x', - instanceId: 'inst-other', - pid: OTHER_PID, - processStartedAt: 'other-start', - address: '127.0.0.1:1', - port: 58627, - }, - }); - }); + it('withLock releases after the callback throws', async () => { + const lock = service('alpha', 1001); + await expect( + lock.withLock(lockPath, { wait: { timeoutMs: 100 } }, () => { + throw new Error('boom'); + }), + ).rejects.toThrow('boom'); - it('stale holder-dead for a gone pid', () => { - writePayload({ lock_id: 'x', instance_id: 'inst-other', pid: OTHER_PID }); - const svc = makeService(); - expect(svc.inspect(lockPath)).toMatchObject({ - state: 'stale', - staleReason: 'holder-dead', - payload: { lockId: 'x', pid: OTHER_PID }, - }); + const next = await service('beta', 2002).acquire(lockPath); + handles.push(next); + expect(next.checkHeld()).toBe(true); }); -}); -describe('createNodeProcessProbe', () => { - it('reports the current process alive with a stable identity token', () => { - const probe = createNodeProcessProbe(); - const first = probe(process.pid); - expect(first.alive).toBe(true); - // Modern macOS exposes no named per-pid starttime OID, so the token is - // legitimately absent on darwin; linux always has one via /proc. - if (process.platform === 'linux') { - expect(first.processStartedAt).toBeDefined(); - } - if (first.processStartedAt !== undefined) { - expect(probe(process.pid).processStartedAt).toBe(first.processStartedAt); - } - }); + it('release is idempotent and checkHeld fails closed afterwards', async () => { + const handle = await service('alpha', 1001).acquire(lockPath); + handle.release(); + handle.release(); - it('reports a guaranteed-absent pid dead, without a token', () => { - const probe = createNodeProcessProbe(); - expect(probe(DEAD_PID)).toEqual({ alive: false }); + expect(handle.checkHeld()).toBe(false); }); }); diff --git a/packages/agent-core-v2/test/os/stubs.ts b/packages/agent-core-v2/test/os/stubs.ts index d7d7a053d6..f52a160eaa 100644 --- a/packages/agent-core-v2/test/os/stubs.ts +++ b/packages/agent-core-v2/test/os/stubs.ts @@ -32,7 +32,6 @@ export function stubCrossProcessLock(): ICrossProcessLockService { lockPath, lockId: 'stub-lock', checkHeld: () => !released, - update: () => {}, release: () => { if (released) return; released = true; diff --git a/packages/agent-core-v2/test/persistence/backends/node-fs/appendLogStore.test.ts b/packages/agent-core-v2/test/persistence/backends/node-fs/appendLogStore.test.ts index b4d2ffa6be..7c5f11818a 100644 --- a/packages/agent-core-v2/test/persistence/backends/node-fs/appendLogStore.test.ts +++ b/packages/agent-core-v2/test/persistence/backends/node-fs/appendLogStore.test.ts @@ -7,7 +7,7 @@ * test/persistence/backends/node-fs/appendLogStore.test.ts`. */ -import { mkdtempSync, rmSync, writeFileSync } from 'node:fs'; +import { mkdtempSync, rmSync } from 'node:fs'; import { tmpdir } from 'node:os'; import { join } from 'node:path'; @@ -733,13 +733,6 @@ describe('AppendLogStore', () => { ); } - function foreignPayload(tmp: string, sessionId: string): void { - writeFileSync( - sessionLeasePath(tmp, sessionId), - JSON.stringify({ lock_id: 'peer-token', pid: process.pid }), - ); - } - it('parses the session id out of session and agent scopes only', () => { expect(sessionIdFromScope('')).toBeUndefined(); expect(sessionIdFromScope('sessions')).toBeUndefined(); @@ -762,7 +755,7 @@ describe('AppendLogStore', () => { appendAttempts++; return originalAppend(...args); }; - foreignPayload(tmpDir, 's1'); + lease.release(); store.append(SESSION_SCOPE, KEY, { n: 1 }); await expect(store.flush()).rejects.toMatchObject({ @@ -786,7 +779,7 @@ describe('AppendLogStore', () => { writeAttempts++; return originalWrite(...args); }; - foreignPayload(tmpDir, 's1'); + lease.release(); await expect(store.rewrite(SESSION_SCOPE, KEY, [{ n: 1 }])).rejects.toMatchObject({ code: ErrorCodes.SESSION_LEASE_LOST, diff --git a/packages/agent-core-v2/test/session/sessionLease/sessionLease.test.ts b/packages/agent-core-v2/test/session/sessionLease/sessionLease.test.ts index fed97de1d4..37bc759dad 100644 --- a/packages/agent-core-v2/test/session/sessionLease/sessionLease.test.ts +++ b/packages/agent-core-v2/test/session/sessionLease/sessionLease.test.ts @@ -1,14 +1,12 @@ /** * `sessionLease` domain — unit tests for the per-session write lease. * - * Runs against the real node-local cross-process lock service (pid-only - * handles: no heartbeat timers) rooted at a mkdtemp home, asserting on-disk - * lease payload contents, the once-only loss notification, the idempotent - * token-guarded release, and the contact-provider seed semantics (default - * local, seed override wins — the exact production wiring). + * Runs against the real node-local kernel-lock service rooted at a mkdtemp + * home, asserting permanent sentinel behavior, the once-only loss + * notification, idempotent release, and contact-provider seed semantics. */ -import { existsSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs'; +import { existsSync, mkdtempSync, rmSync } from 'node:fs'; import { tmpdir } from 'node:os'; import { join } from 'node:path'; @@ -22,12 +20,9 @@ import { sessionLeaseContactSeed, } from '#/session/sessionLease/sessionLeaseContactProvider'; import { - HOLDER_UNRESPONSIVE_RETRY_AFTER_MS, LEASE_CREATING_RETRY_AFTER_MS, SessionLease, sessionLeasePath, - SESSION_LEASE_HEARTBEAT_INTERVAL_MS, - SESSION_LEASE_TTL_MS, } from '#/session/sessionLease/sessionLease'; let tmpDir: string; @@ -79,17 +74,11 @@ describe('SessionLease', () => { lease.release(); }); - it('fails closed with session.lease_lost once the payload no longer carries its token', async () => { + it('fires the loss notification once and then fails closed', async () => { const onLost = vi.fn(); const lease = await acquire('s1', onLost); - writeFileSync( - sessionLeasePath(tmpDir, 's1'), - JSON.stringify({ lock_id: 'peer-token', pid: process.pid }), - ); - - expect(lease.checkHeld()).toBe(false); + lease.markLost(); expect(thrownError(() => lease.assertWritable()).code).toBe(ErrorCodes.SESSION_LEASE_LOST); - // Loss fires exactly once across every detection path. expect(onLost).toHaveBeenCalledTimes(1); expect(onLost).toHaveBeenCalledWith('s1'); lease.markLost(); @@ -98,40 +87,25 @@ describe('SessionLease', () => { expect(onLost).toHaveBeenCalledTimes(1); }); - it('release is idempotent, unlinks the owned file, and later assertions throw', async () => { + it('release is idempotent, keeps the sentinel, and later assertions throw', async () => { const lease = await acquire(); lease.release(); lease.release(); expect(lease.released).toBe(true); expect(lease.info).toBeUndefined(); - expect(existsSync(sessionLeasePath(tmpDir, 's1'))).toBe(false); + expect(existsSync(sessionLeasePath(tmpDir, 's1'))).toBe(true); + expect(existsSync(`${sessionLeasePath(tmpDir, 's1')}.owner.json`)).toBe(false); expect(thrownError(() => lease.assertWritable()).code).toBe(ErrorCodes.SESSION_LEASE_LOST); }); - it('release never unlinks a payload owned by a peer', async () => { - const lease = await acquire(); - writeFileSync( - sessionLeasePath(tmpDir, 's1'), - JSON.stringify({ lock_id: 'peer-token', pid: process.pid }), - ); - lease.release(); - - expect(lease.released).toBe(true); - const payload = JSON.parse(readFileSync(sessionLeasePath(tmpDir, 's1'), 'utf8')); - expect(payload.lock_id).toBe('peer-token'); - }); - - it('exported constants pin the documented protocol timings', () => { - expect(SESSION_LEASE_HEARTBEAT_INTERVAL_MS).toBe(2000); - expect(SESSION_LEASE_TTL_MS).toBe(6000); + it('exports only the retry delay for an owner metadata creation window', () => { expect(LEASE_CREATING_RETRY_AFTER_MS).toBe(1000); - expect(HOLDER_UNRESPONSIVE_RETRY_AFTER_MS).toBe(2000); }); it('sessionLeasePath lives under /session-leases/', () => { expect(sessionLeasePath('/home/kimi', 'abc')).toBe( - join('/home/kimi', 'session-leases', 'abc.json'), + join('/home/kimi', 'session-leases', 'abc.lock'), ); }); }); diff --git a/packages/kap-server/src/transport/ws/v1/sessionEventBroadcaster.ts b/packages/kap-server/src/transport/ws/v1/sessionEventBroadcaster.ts index 97870393aa..526a136377 100644 --- a/packages/kap-server/src/transport/ws/v1/sessionEventBroadcaster.ts +++ b/packages/kap-server/src/transport/ws/v1/sessionEventBroadcaster.ts @@ -54,9 +54,7 @@ import { ISessionIndex, ISessionLifecycleService, MAIN_AGENT_ID, - HOLDER_UNRESPONSIVE_RETRY_AFTER_MS, LEASE_CREATING_RETRY_AFTER_MS, - SESSION_LEASE_TTL_MS, sessionLeasePath, } from '@moonshot-ai/agent-core-v2'; import type { TurnEndReason } from '@moonshot-ai/agent-core-v2/agent/loop/turnEvents'; @@ -236,14 +234,6 @@ export class SessionEventBroadcaster { } if (inspection.state !== 'held' || inspection.payload === undefined) return undefined; - const heartbeatAt = inspection.payload.heartbeatAt; - if (heartbeatAt !== undefined && Date.now() - heartbeatAt > SESSION_LEASE_TTL_MS) { - return { - kind: 'held-by-peer', - phase: 'holder-unresponsive', - retry_after_ms: HOLDER_UNRESPONSIVE_RETRY_AFTER_MS, - }; - } if (inspection.payload.address !== undefined) { return { kind: 'held-by-peer', phase: 'routable', address: inspection.payload.address }; } diff --git a/packages/kap-server/test/session-ownership.e2e.test.ts b/packages/kap-server/test/session-ownership.e2e.test.ts index e5e8930781..83156280a9 100644 --- a/packages/kap-server/test/session-ownership.e2e.test.ts +++ b/packages/kap-server/test/session-ownership.e2e.test.ts @@ -107,7 +107,7 @@ describe('multi-server session ownership (session.held_by_peer → 40921)', () = // Lease file: A advertises the URL it actually bound (post-listen swap // of the contact ref), so a contended peer knows where to redirect. const lease = JSON.parse( - await readFile(sessionLeasePath(home as string, sessionId), 'utf8'), + await readFile(`${sessionLeasePath(home as string, sessionId)}.owner.json`, 'utf8'), ) as Record; expect(lease['address']).toBe(addressA); expect(typeof lease['lock_id']).toBe('string'); diff --git a/packages/kernel-file-lock/package.json b/packages/kernel-file-lock/package.json new file mode 100644 index 0000000000..b329f196c9 --- /dev/null +++ b/packages/kernel-file-lock/package.json @@ -0,0 +1,26 @@ +{ + "name": "@moonshot-ai/kernel-file-lock", + "version": "0.1.0", + "private": true, + "description": "Cross-platform kernel-backed advisory file locks for Kimi Code.", + "license": "MIT", + "type": "module", + "files": [ + "dist" + ], + "exports": { + ".": { + "types": "./src/index.ts", + "default": "./src/index.ts" + } + }, + "scripts": { + "build": "tsdown", + "typecheck": "tsc -p tsconfig.json --noEmit", + "test": "vitest run", + "clean": "rm -rf dist" + }, + "dependencies": { + "fs-ext-extra-prebuilt": "2.2.9" + } +} diff --git a/packages/kernel-file-lock/src/index.ts b/packages/kernel-file-lock/src/index.ts new file mode 100644 index 0000000000..847c946208 --- /dev/null +++ b/packages/kernel-file-lock/src/index.ts @@ -0,0 +1,157 @@ +import { closeSync, fstatSync, mkdirSync, openSync, statSync } from 'node:fs'; +import { createRequire } from 'node:module'; +import { dirname } from 'node:path'; + +export interface KernelFileLockBinding { + flockSync(fd: number, flags: 'exnb' | 'un'): number; +} + +export type KernelFileLockBindingLoader = () => KernelFileLockBinding | undefined; + +export interface KernelFileLockAcquireOptions { + readonly timeoutMs: number; + readonly retryIntervalMs?: number; +} + +export interface KernelFileLockHandle { + readonly path: string; + readonly held: boolean; + checkHeld(): boolean; + release(): void; +} + +const DEFAULT_RETRY_INTERVAL_MS = 50; +const bindingLoaderKey = Symbol.for('@moonshot-ai/kernel-file-lock/binding-loader'); +const nodeRequire = createRequire(import.meta.url); + +type GlobalWithBindingLoader = typeof globalThis & { + [bindingLoaderKey]?: KernelFileLockBindingLoader; +}; + +let binding: KernelFileLockBinding | undefined; + +function defaultBindingLoader(): KernelFileLockBinding { + return nodeRequire('fs-ext-extra-prebuilt') as KernelFileLockBinding; +} + +function getBinding(): KernelFileLockBinding { + if (binding !== undefined) return binding; + const override = (globalThis as GlobalWithBindingLoader)[bindingLoaderKey]; + binding = override?.() ?? defaultBindingLoader(); + return binding; +} + +function errorCode(error: unknown): string | undefined { + return (error as NodeJS.ErrnoException | undefined)?.code; +} + +function isBusy(error: unknown): boolean { + const code = errorCode(error); + return code === 'EACCES' || code === 'EAGAIN' || code === 'EWOULDBLOCK'; +} + +function sleep(ms: number): Promise { + return new Promise((resolve) => setTimeout(resolve, ms)); +} + +class KernelFileLockHandleImpl implements KernelFileLockHandle { + private released = false; + + constructor( + readonly path: string, + private readonly fd: number, + private readonly binding: KernelFileLockBinding, + ) {} + + get held(): boolean { + return this.checkHeld(); + } + + checkHeld(): boolean { + if (this.released) return false; + try { + const opened = fstatSync(this.fd); + const current = statSync(this.path); + return opened.dev === current.dev && opened.ino === current.ino; + } catch { + return false; + } + } + + release(): void { + if (this.released) return; + this.released = true; + try { + this.binding.flockSync(this.fd, 'un'); + } finally { + closeSync(this.fd); + } + } +} + +export function setKernelFileLockBindingLoader( + loader: KernelFileLockBindingLoader | undefined, +): void { + const target = globalThis as GlobalWithBindingLoader; + if (loader === undefined) { + delete target[bindingLoaderKey]; + } else { + target[bindingLoaderKey] = loader; + } + binding = undefined; +} + +export function tryAcquireKernelFileLock(path: string): KernelFileLockHandle | undefined { + const nativeBinding = getBinding(); + mkdirSync(dirname(path), { recursive: true }); + const fd = openSync(path, 'a+', 0o600); + try { + nativeBinding.flockSync(fd, 'exnb'); + return new KernelFileLockHandleImpl(path, fd, nativeBinding); + } catch (error) { + closeSync(fd); + if (isBusy(error)) return undefined; + throw error; + } +} + +export async function acquireKernelFileLock( + path: string, + options: KernelFileLockAcquireOptions, +): Promise { + const deadline = Date.now() + options.timeoutMs; + const retryIntervalMs = options.retryIntervalMs ?? DEFAULT_RETRY_INTERVAL_MS; + let firstAttempt = true; + for (;;) { + const isFirstAttempt = firstAttempt; + if (!isFirstAttempt && Date.now() >= deadline) { + throw new KernelFileLockTimeoutError(path, options.timeoutMs); + } + firstAttempt = false; + const handle = tryAcquireKernelFileLock(path); + if (handle !== undefined) { + if (!isFirstAttempt && Date.now() >= deadline) { + handle.release(); + throw new KernelFileLockTimeoutError(path, options.timeoutMs); + } + return handle; + } + const remainingMs = deadline - Date.now(); + if (remainingMs <= 0) { + throw new KernelFileLockTimeoutError(path, options.timeoutMs); + } + await sleep(Math.min(retryIntervalMs, remainingMs)); + } +} + +export class KernelFileLockTimeoutError extends Error { + readonly code = 'ELOCKTIMEOUT'; + + constructor( + readonly path: string, + readonly timeoutMs: number, + ) { + super(`timed out waiting ${timeoutMs}ms for kernel file lock: ${path}`); + this.name = 'KernelFileLockTimeoutError'; + } +} diff --git a/packages/kernel-file-lock/test/holder.ts b/packages/kernel-file-lock/test/holder.ts new file mode 100644 index 0000000000..3ce659208f --- /dev/null +++ b/packages/kernel-file-lock/test/holder.ts @@ -0,0 +1,13 @@ +import { tryAcquireKernelFileLock } from '../src/index.js'; + +const path = process.argv[2]; +if (path === undefined) throw new Error('lock path is required'); + +const handle = tryAcquireKernelFileLock(path); +if (handle === undefined) process.exit(2); + +process.stdout.write('locked\n'); +process.stdin.once('data', () => { + handle.release(); + process.exit(0); +}); diff --git a/packages/kernel-file-lock/test/kernel-file-lock.test.ts b/packages/kernel-file-lock/test/kernel-file-lock.test.ts new file mode 100644 index 0000000000..4cfbeb0536 --- /dev/null +++ b/packages/kernel-file-lock/test/kernel-file-lock.test.ts @@ -0,0 +1,169 @@ +import { existsSync, mkdtempSync, rmSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { spawn } from 'node:child_process'; +import { once } from 'node:events'; +import { fileURLToPath } from 'node:url'; + +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; + +import { + acquireKernelFileLock, + KernelFileLockTimeoutError, + setKernelFileLockBindingLoader, + tryAcquireKernelFileLock, + type KernelFileLockBinding, + type KernelFileLockHandle, +} from '../src/index.js'; + +let tmpDir: string; +let lockPath: string; +const handles: KernelFileLockHandle[] = []; + +async function withTimeout(promise: Promise, timeoutMs: number, message: string): Promise { + let timer: ReturnType | undefined; + const timeout = new Promise((_, reject) => { + timer = setTimeout(() => reject(new Error(message)), timeoutMs); + }); + try { + return await Promise.race([promise, timeout]); + } finally { + if (timer !== undefined) clearTimeout(timer); + } +} + +beforeEach(() => { + tmpDir = mkdtempSync(join(tmpdir(), 'kernel-file-lock-')); + lockPath = join(tmpDir, 'resource.lock'); +}); + +afterEach(() => { + for (const handle of handles.splice(0)) handle.release(); + setKernelFileLockBindingLoader(undefined); + vi.restoreAllMocks(); + rmSync(tmpDir, { recursive: true, force: true }); +}); + +describe('kernel-file-lock', () => { + it('holds an exclusive lock and keeps the sentinel after release', () => { + const first = tryAcquireKernelFileLock(lockPath); + expect(first).toBeDefined(); + handles.push(first!); + expect(tryAcquireKernelFileLock(lockPath)).toBeUndefined(); + + first!.release(); + expect(existsSync(lockPath)).toBe(true); + const second = tryAcquireKernelFileLock(lockPath); + expect(second).toBeDefined(); + handles.push(second!); + }); + + it('waits and times out without taking ownership', async () => { + const first = tryAcquireKernelFileLock(lockPath)!; + handles.push(first); + + await expect( + acquireKernelFileLock(lockPath, { timeoutMs: 15, retryIntervalMs: 5 }), + ).rejects.toBeInstanceOf(KernelFileLockTimeoutError); + }); + + it('does not acquire a lock released after the deadline', async () => { + const first = tryAcquireKernelFileLock(lockPath)!; + handles.push(first); + const releaseTimer = setTimeout(() => first.release(), 20); + + const result = await acquireKernelFileLock(lockPath, { + timeoutMs: 10, + retryIntervalMs: 100, + }).then( + (handle) => ({ status: 'acquired' as const, handle }), + (error: unknown) => ({ status: 'rejected' as const, error }), + ); + clearTimeout(releaseTimer); + + if (result.status === 'acquired') handles.push(result.handle); + expect(result.status).toBe('rejected'); + if (result.status === 'rejected') { + expect(result.error).toBeInstanceOf(KernelFileLockTimeoutError); + } + }); + + it('releases a lock acquired as a retry crosses the deadline', async () => { + let lockCalls = 0; + let unlockCalls = 0; + const times = [0, 0, 9, 10]; + const nativeBinding: KernelFileLockBinding = { + flockSync: (_fd, flags) => { + if (flags === 'un') { + unlockCalls++; + return 0; + } + lockCalls++; + if (lockCalls === 1) { + throw Object.assign(new Error('busy'), { code: 'EAGAIN' }); + } + return 0; + }, + }; + setKernelFileLockBindingLoader(() => nativeBinding); + vi.spyOn(Date, 'now').mockImplementation(() => times.shift() ?? 10); + + const result = await acquireKernelFileLock(lockPath, { + timeoutMs: 10, + retryIntervalMs: 0, + }).then( + (handle) => ({ status: 'acquired' as const, handle }), + (error: unknown) => ({ status: 'rejected' as const, error }), + ); + + if (result.status === 'acquired') handles.push(result.handle); + expect(result.status).toBe('rejected'); + if (result.status === 'rejected') { + expect(result.error).toBeInstanceOf(KernelFileLockTimeoutError); + } + expect(unlockCalls).toBe(1); + }); + + it('allows the immediate first attempt with a zero timeout', async () => { + const handle = await acquireKernelFileLock(lockPath, { timeoutMs: 0 }); + handles.push(handle); + + expect(handle.checkHeld()).toBe(true); + }); + + it('coordinates with a separate process', async () => { + const holderPath = fileURLToPath(new URL('./holder.ts', import.meta.url)); + const child = spawn(process.execPath, ['--import', 'tsx', holderPath, lockPath], { + stdio: ['pipe', 'pipe', 'inherit'], + }); + const exit = once(child, 'exit'); + void exit.catch(() => {}); + const ready = Promise.race([ + once(child.stdout!, 'data'), + exit.then(() => { + throw new Error('holder exited before becoming ready'); + }), + ]); + try { + await withTimeout(ready, 5_000, 'holder did not become ready'); + expect(tryAcquireKernelFileLock(lockPath)).toBeUndefined(); + + child.stdin!.end('release\n'); + await withTimeout(exit, 5_000, 'holder did not exit after release'); + const handle = tryAcquireKernelFileLock(lockPath); + expect(handle).toBeDefined(); + handles.push(handle!); + } finally { + child.stdin?.end(); + if (child.pid !== undefined && child.exitCode === null && child.signalCode === null) { + child.kill(); + try { + await withTimeout(exit, 1_000, 'holder did not terminate'); + } catch { + child.kill('SIGKILL'); + await withTimeout(exit, 1_000, 'holder did not terminate after SIGKILL'); + } + } + } + }); +}); diff --git a/packages/kernel-file-lock/tsconfig.json b/packages/kernel-file-lock/tsconfig.json new file mode 100644 index 0000000000..ef502e89c8 --- /dev/null +++ b/packages/kernel-file-lock/tsconfig.json @@ -0,0 +1,4 @@ +{ + "extends": "../../tsconfig.json", + "include": ["src", "test"] +} diff --git a/packages/kernel-file-lock/tsdown.config.ts b/packages/kernel-file-lock/tsdown.config.ts new file mode 100644 index 0000000000..9c7ea6f44b --- /dev/null +++ b/packages/kernel-file-lock/tsdown.config.ts @@ -0,0 +1,12 @@ +import { defineConfig } from 'tsdown'; + +export default defineConfig({ + entry: ['./src/index.ts'], + format: ['esm'], + dts: true, + outDir: 'dist', + clean: true, + deps: { + neverBundle: ['fs-ext-extra-prebuilt'], + }, +}); diff --git a/packages/kernel-file-lock/vitest.config.ts b/packages/kernel-file-lock/vitest.config.ts new file mode 100644 index 0000000000..cbd8944d3b --- /dev/null +++ b/packages/kernel-file-lock/vitest.config.ts @@ -0,0 +1,8 @@ +import { defineConfig } from 'vitest/config'; + +export default defineConfig({ + test: { + name: 'kernel-file-lock', + include: ['test/**/*.test.ts'], + }, +}); diff --git a/packages/klient/src/sessionRedirect.ts b/packages/klient/src/sessionRedirect.ts index 0135172416..1a15b015d0 100644 --- a/packages/klient/src/sessionRedirect.ts +++ b/packages/klient/src/sessionRedirect.ts @@ -11,10 +11,11 @@ * * - held-by-peer / routable follow `address`: rebase onto the * holder origin and re-send the call - * - held-by-peer / creating lease file mid-creation: wait + * - held-by-peer / creating kernel lock held before owner + * metadata is visible: wait * `retry_after_ms`, retry the SAME * instance - * - held-by-peer / holder-unresponsive holder pid alive, heartbeat stale: + * - held-by-peer / holder-unresponsive legacy heartbeat-based response: * terminal for auto-recovery * - held-by-peer / held-by-local-instance holder has no address (embedded * engine / CLI): terminal, never @@ -59,7 +60,7 @@ export interface HeldByPeerDetails { readonly phase: SessionOwnershipPhase; /** Present only when phase === 'routable'. */ readonly address?: string; - /** Retry hint (ms) for 'creating' / 'holder-unresponsive'. */ + /** Retry hint (ms) for `creating` or legacy `holder-unresponsive`. */ readonly retry_after_ms?: number; } @@ -330,7 +331,7 @@ export class SessionRedirectChannel implements KlientChannel { throw enrichOwnershipError( error, `the holder address ${target} is this very instance, yet the request was refused; ` + - 'lease and server disagree — retry shortly, or force-unlock the session lease', + 'lease and server disagree — retry shortly or restart the holding instance', ); } const previous = connection.applyRedirect(target, scope.sessionId); @@ -367,17 +368,17 @@ export class SessionRedirectChannel implements KlientChannel { : ''; throw enrichOwnershipError( error, - `the session is held by a peer instance that is not responding${retryHint}: its process ` + - 'is alive but its lease heartbeat is stale. Open the session from that instance, ' + - 'retry later, or stop the holder and force-unlock the lease to take over here', + `the session is held by a peer instance that reported an unresponsive holder${retryHint}. ` + + 'This response comes from an older heartbeat-based server; retry later or stop the ' + + 'holding process before opening the session here', ); } case 'held-by-local-instance': { throw enrichOwnershipError( error, 'the session is held by a local instance without a network address (an embedded engine ' + - 'or CLI process); it cannot be reached from here — close the holding process (or ' + - 'force-unlock the lease) before opening the session elsewhere', + 'or CLI process); it cannot be reached from here — close the holding process before ' + + 'opening the session elsewhere', ); } } diff --git a/packages/klient/test/e2e/legacy/session-ownership.test.ts b/packages/klient/test/e2e/legacy/session-ownership.test.ts index aeedcc1259..0f9db03f85 100644 --- a/packages/klient/test/e2e/legacy/session-ownership.test.ts +++ b/packages/klient/test/e2e/legacy/session-ownership.test.ts @@ -2,8 +2,9 @@ * Phase-2 session-ownership verification matrix — multi-process e2e over real * REST boundaries (design `.tmp/refactor-watch-design-v2.md` §3.10). * - * One session write lease per session under `/session-leases/.json` - * (heartbeat 2000ms, TTL 6000ms — real-time constants, wait with polling). + * One session write lease per session under `/session-leases/.lock`. + * The permanent sentinel is protected by a kernel lock; the sibling + * `.lock.owner.json` document only advertises holder metadata. * Materializing routes on a peer-held session answer HTTP 200 with envelope * `code 40921 session.held_by_peer` + ownership details. kap-server's own e2e * already pins the dual-open envelope schema and graceful-close takeover; the @@ -16,18 +17,13 @@ * `*.jsonl` byte-integrity sweep of the shared home (no torn records) and * a single-lease assertion. * 2. SIGSTOP → no takeover, SIGCONT → clean continuation (subprocess pair): - * a stopped holder keeps its lease past the heartbeat TTL; B is refused - * with phase `holder-unresponsive` (retry_after_ms 2000), the lease - * `lock_id` never changes and no `*.stale.*` sibling appears. After - * SIGCONT the holder serves the session again and B drops back to - * `routable` once the heartbeat refreshes. - * 3. kill -9 → dead-pid takeover with data intact (subprocess pair): after - * the holder dies and is reaped, B's resume poll succeeds (transient - * observations are schema-valid 40921s, never `routable` into the dead - * address), the lease is re-acquired with a NEW lock_id and B's - * pid/address, and A's payload is rename-isolated to - * `.json.stale.` next to it. `GET /sessions/{id}` on B - * returns the same session; the JSONL sweep stays clean. + * a stopped holder keeps the kernel lock indefinitely; B remains refused + * with phase `routable`, the `lock_id` stays stable, and the original + * holder resumes cleanly after SIGCONT. + * 3. kill -9 → kernel release with data intact (subprocess pair): after the + * holder dies and is reaped, B acquires the released kernel lock with a + * NEW lock_id and overwrites owner metadata in place. The sentinel stays + * put, no stale sibling is created, and the session remains intact. * * Every subprocess scenario ends with dispose() + an explicit pid-dead check * (ESRCH) so no child server can linger across tests. @@ -36,7 +32,7 @@ import { mkdirSync, readdirSync, unlinkSync, writeFileSync } from 'node:fs'; import { readdir, readFile } from 'node:fs/promises'; import { join } from 'node:path'; -import { SESSION_LEASE_TTL_MS, sessionLeasePath } from '@moonshot-ai/agent-core-v2'; +import { sessionLeasePath } from '@moonshot-ai/agent-core-v2'; import { ErrorCode, sessionOwnershipDetailsSchema } from '@moonshot-ai/protocol'; import { describe, expect, it } from 'vitest'; @@ -49,8 +45,6 @@ import { import { createCaseLogger } from './log.js'; const SESSION_OWNERSHIP_HELD_BY_PEER = 40921; -/** Pinned wire hint for `holder-unresponsive` (design §3.10 Phase-2 row). */ -const HOLDER_UNRESPONSIVE_RETRY_AFTER_MS = 2000; describe('session ownership: concurrent dual materialization race (in-process pair)', () => { it( @@ -90,10 +84,10 @@ describe('session ownership: concurrent dual materialization race (in-process pa } } - // Exactly one lease file for the session, still A's, lock id stable. + // The permanent sentinel and owner metadata remain stable under A. const leaseFilenames = await listLeaseFilenames(pair.home); - const related = leaseFilenames.filter((name) => name.startsWith(`${sessionId}.json`)); - expect(related).toEqual([`${sessionId}.json`]); + const related = leaseFilenames.filter((name) => name.startsWith(`${sessionId}.lock`)); + expect(related).toEqual([`${sessionId}.lock`, `${sessionId}.lock.owner.json`]); const after = await readLease(pair.home, sessionId); expect(after?.['lock_id']).toBe(lockId); expect(after?.['address']).toBe(pair.urlA); @@ -110,7 +104,7 @@ describe('session ownership: concurrent dual materialization race (in-process pa describe('session ownership: SIGSTOP/SIGCONT (subprocess pair)', () => { it( - 'a stopped holder is never taken over past the TTL; after SIGCONT it resumes cleanly', + 'a stopped holder keeps the kernel lock; after SIGCONT it resumes cleanly', { timeout: 150_000 }, async () => { const log = createCaseLogger('session-ownership/sigstop-sigcont'); @@ -135,18 +129,7 @@ describe('session ownership: SIGSTOP/SIGCONT (subprocess pair)', () => { holderStopped = true; log('SIGSTOP sent', { pid: pair.a.pid }); - // Wait (real time) until the frozen heartbeat is older than the TTL: - // from now on a peer inspecting the lease MUST see the holder as - // unresponsive — and MUST NOT take the lease over (pid still alive - // with matching identity). - const stale = await pollUntil(async () => { - const lease = await readLease(pair.home, sessionId); - const heartbeatAt = lease?.['heartbeat_at']; - if (typeof heartbeatAt !== 'number') return undefined; - const ageMs = Date.now() - heartbeatAt; - return ageMs > SESSION_LEASE_TTL_MS ? { heartbeatAt, ageMs } : undefined; - }, `lease heartbeat older than TTL (${SESSION_LEASE_TTL_MS}ms)`, 15_000, 250); - log('heartbeat now stale while A is stopped', stale); + await sleep(300); for (let attempt = 1; attempt <= 2; attempt += 1) { const b = await getEnvelope(pair.b.baseUrl, warningsPath(sessionId)); @@ -160,17 +143,18 @@ describe('session ownership: SIGSTOP/SIGCONT (subprocess pair)', () => { expect(b.body.code).toBe(SESSION_OWNERSHIP_HELD_BY_PEER); expect(details).toEqual({ kind: 'held-by-peer', - phase: 'holder-unresponsive', - retry_after_ms: HOLDER_UNRESPONSIVE_RETRY_AFTER_MS, + phase: 'routable', + address: pair.a.baseUrl, }); if (attempt === 1) await sleep(300); } - // No takeover happened: same lock id, no rename-isolated sibling. + // No takeover happened: same lock id and the same two permanent files. expect((await readLease(pair.home, sessionId))?.['lock_id']).toBe(lockIdA); const filenames = await listLeaseFilenames(pair.home); - expect(filenames.filter((name) => name.startsWith(`${sessionId}.json`))).toEqual([ - `${sessionId}.json`, + expect(filenames.filter((name) => name.startsWith(`${sessionId}.lock`))).toEqual([ + `${sessionId}.lock`, + `${sessionId}.lock.owner.json`, ]); process.kill(pair.a.pid, 'SIGCONT'); @@ -188,8 +172,7 @@ describe('session ownership: SIGSTOP/SIGCONT (subprocess pair)', () => { code: aResumed.body.code, }); - // B stays refused; once the resumed heartbeat lands it returns to - // `routable` (never code 0, never anything but schema-valid 40921). + // B stays refused and remains routable to the original holder. const transcript: Array<{ code: number; details: unknown }> = []; const routable = await pollUntil(async () => { const res = await getEnvelope(pair.b.baseUrl, warningsPath(sessionId)); @@ -198,7 +181,7 @@ describe('session ownership: SIGSTOP/SIGCONT (subprocess pair)', () => { return details.kind === 'held-by-peer' && details.phase === 'routable' ? details : undefined; - }, 'B back to 40921 routable after SIGCONT', 15_000, 500); + }, 'B remains 40921 routable after SIGCONT', 15_000, 500); log('B observations after SIGCONT', { transcript, final: routable }); for (const entry of transcript) { expect(entry.code).toBe(SESSION_OWNERSHIP_HELD_BY_PEER); @@ -209,7 +192,7 @@ describe('session ownership: SIGSTOP/SIGCONT (subprocess pair)', () => { address: pair.a.baseUrl, }); - // Still the original lease; no stale sibling ever appeared. + // Still the original kernel lease; no stale sibling ever appeared. expect((await readLease(pair.home, sessionId))?.['lock_id']).toBe(lockIdA); const swept = await assertJsonlIntegrity(pair.home); log('byte-integrity sweep', swept); @@ -234,12 +217,12 @@ describe('session ownership: SIGSTOP/SIGCONT (subprocess pair)', () => { ); }); -describe('session ownership: kill -9 dead-pid takeover (subprocess pair)', () => { +describe('session ownership: kill -9 kernel release (subprocess pair)', () => { it( - 'B takes over via rename isolation with a new lock id and serves the intact session', + 'B acquires the released kernel lock with a new lock id and serves the intact session', { timeout: 120_000 }, async () => { - const log = createCaseLogger('session-ownership/sigkill-takeover'); + const log = createCaseLogger('session-ownership/sigkill-kernel-release'); const pair = await spawnServerProcessPair(); try { await Promise.all([ @@ -255,61 +238,34 @@ describe('session ownership: kill -9 dead-pid takeover (subprocess pair)', () => log('holder lease before SIGKILL', { sessionId, lease: leaseA }); pair.a.kill('SIGKILL'); - // Await real death (zombie reaped): ESRCH is the takeover precondition. + // Await real death (zombie reaped): the kernel has released A's lock. const exited = await waitForPidExit(pair.a.pid, 10_000); log('SIGKILL delivered', { pid: pair.a.pid, exited }); expect(exited).toBe(true); - // B's resume poll: may observe schema-valid 40921s transiently; must - // converge to success and must never be routed to the dead address. - const transcript: Array<{ elapsedMs: number; code: number; details: unknown }> = []; - const startedAt = Date.now(); - const success = await pollUntil(async () => { - const res = await getEnvelope(pair.b.baseUrl, warningsPath(sessionId)); - transcript.push({ - elapsedMs: Date.now() - startedAt, - code: res.body.code, - details: res.body.details, - }); - return res.body.code === 0 ? res : undefined; - }, 'B resume succeeds after dead-pid takeover', 20_000, 500); - log('B resume transcript after kill -9', transcript); + // Once A is gone, B acquires the released lock on its first request. + const success = await getEnvelope(pair.b.baseUrl, warningsPath(sessionId)); log('B resume success', { status: success.status, code: success.body.code }); + expect(success.status).toBe(200); + expect(success.body.code).toBe(0); - for (const entry of transcript) { - if (entry.code === 0) continue; - expect(entry.code).toBe(SESSION_OWNERSHIP_HELD_BY_PEER); - const details = sessionOwnershipDetailsSchema.parse(entry.details); - expect(details.kind).toBe('held-by-peer'); - if (details.kind === 'held-by-peer') { - expect(details.phase).not.toBe('routable'); - } - } - - // Takeover evidence: new lock id, B's pid + address, and A's payload - // rename-isolated next to the live lease. + // Re-acquisition evidence: new lock id and B's metadata, written next + // to the unchanged permanent sentinel. const leaseB = await readLease(pair.home, sessionId); - log('lease after takeover', leaseB); + log('lease after kernel release', leaseB); expect(typeof leaseB?.['lock_id']).toBe('string'); expect(leaseB?.['lock_id']).not.toBe(lockIdA); expect(leaseB?.['pid']).toBe(pair.b.pid); expect(leaseB?.['address']).toBe(pair.b.baseUrl); - const staleName = `${sessionId}.json.stale.${String(lockIdA)}`; - const stale = JSON.parse( - await readFile(join(pair.home, 'session-leases', staleName), 'utf8'), - ) as Record; - log('rename-isolated stale lease', { staleName, stale }); - expect(stale['lock_id']).toBe(lockIdA); - expect(stale['pid']).toBe(pair.a.pid); const related = (await listLeaseFilenames(pair.home)).filter((name) => - name.startsWith(`${sessionId}.json`), + name.startsWith(`${sessionId}.lock`), ); - expect(related).toEqual([`${sessionId}.json`, staleName].sort()); + expect(related).toEqual([`${sessionId}.lock`, `${sessionId}.lock.owner.json`]); - // The session survived the handover intact. + // The session survived the holder change intact. const session = await getEnvelope(pair.b.baseUrl, `/sessions/${sessionId}`); - log('GET /sessions/{id} on B after takeover', session.body); + log('GET /sessions/{id} on B after kernel release', session.body); expect(session.status).toBe(200); expect(session.body.code).toBe(0); expect(session.body.data?.id).toBe(sessionId); @@ -563,10 +519,12 @@ function startSessionsTreeKicker(sessionsRoot: string, intervalMs = 250): () => type LeasePayload = Record; -/** Read `/session-leases/.json`; undefined when absent. */ +/** Read diagnostic owner metadata; undefined when no holder has published it. */ async function readLease(home: string, sessionId: string): Promise { try { - return JSON.parse(await readFile(sessionLeasePath(home, sessionId), 'utf8')) as LeasePayload; + return JSON.parse( + await readFile(`${sessionLeasePath(home, sessionId)}.owner.json`, 'utf8'), + ) as LeasePayload; } catch (error) { if ((error as NodeJS.ErrnoException).code === 'ENOENT') return undefined; throw error; diff --git a/packages/klient/test/sessionRedirect.test.ts b/packages/klient/test/sessionRedirect.test.ts index 1fbf0dfa77..ad2be5bfc7 100644 --- a/packages/klient/test/sessionRedirect.test.ts +++ b/packages/klient/test/sessionRedirect.test.ts @@ -256,12 +256,12 @@ describe('session ownership redirect (SESSION_HELD_BY_PEER)', () => { { name: 'holder-unresponsive', details: { kind: 'held-by-peer', phase: 'holder-unresponsive', retry_after_ms: 2000 }, - message: /not responding.*2000ms.*force-unlock/s, + message: /unresponsive holder.*2000ms.*older heartbeat-based server.*stop the holding process/s, }, { name: 'held-by-local-instance', details: { kind: 'held-by-peer', phase: 'held-by-local-instance' }, - message: /without a network address.*force-unlock/s, + message: /without a network address.*close the holding process/s, }, { name: 'unregistered-writer', diff --git a/packages/minidb/README.md b/packages/minidb/README.md index 301e123025..39864530a7 100644 --- a/packages/minidb/README.md +++ b/packages/minidb/README.md @@ -397,11 +397,11 @@ long-run behavior: | `soak.test.js` | sustained ops + heap stability (opt-in: `SOAK=30 npm run test:e2e`) | The **cluster suite** (`test/cluster/*.test.ts`) covers the `ClusterDb` -sharding layer: topology/routing, merged scans, lock contention and lease -renewal in-process, cross-shard indexes/compaction, true multi-process +sharding layer: topology/routing, merged scans, lock contention and bounded +lock handoff in-process, cross-shard indexes/compaction, true multi-process scenarios (concurrent writers on disjoint and shared shards, live cross-process -read visibility, read/write storms) and crash takeovers (`kill -9` → contiguous -recovery + stale-lock handoff). +read visibility, read/write storms) and crash recovery (`kill -9` → contiguous +recovery + kernel-lock reacquisition). ## Design in one paragraph @@ -419,13 +419,11 @@ source-code study behind each choice. ## Concurrency & multi-process minidb is **single-writer**. Opening a directory for writing acquires an exclusive -lock file (`db.lock`); a second writer is rejected with a `LockError`. A lock is -taken over only when its owner PID is dead — or when the PID has been recycled by -an unrelated process (detected via the recorded process start time) — never -merely because it is old. Takeover renames the stale file aside -(`db.lock.stale.`) instead of deleting it, and every acquire/release -re-reads the file and compares its unique token, so a superseded owner that -wakes up late can never delete the new holder's lock. +kernel lock on `db.lock`; a second writer is rejected with a `LockError`. The +writer keeps the file descriptor or handle open for its lifetime, and the OS +releases the lock automatically when the process exits. The `db.lock` sentinel +is permanent: it is never deleted, renamed, or replaced during acquisition or +release. ```js // second process: throws LockError @@ -479,8 +477,8 @@ await db.close(); `search` merge per-shard results (text scores are per-shard). Index management acquires every shard writer — run it from one process, off the hot path. -- Crash recovery is per shard: a lock left by a dead PID is taken over by the - next opener, exactly like single MiniDb. +- Crash recovery is per shard: process exit releases the kernel lock, so the + next opener can acquire the existing sentinel exactly like single MiniDb. `crossShard: '2pc'` is reserved for a future two-phase commit and is rejected today. Performance numbers across process/shard counts: run diff --git a/packages/minidb/package.json b/packages/minidb/package.json index ad8d807db9..1b91b13509 100644 --- a/packages/minidb/package.json +++ b/packages/minidb/package.json @@ -46,5 +46,8 @@ "bench": "node --import tsx bench/bench.ts", "bench:cluster": "node --import tsx bench/cluster.ts", "clean": "rm -rf dist" + }, + "dependencies": { + "@moonshot-ai/kernel-file-lock": "workspace:^" } } diff --git a/packages/minidb/src/cluster/index.ts b/packages/minidb/src/cluster/index.ts index 648b709443..b95b2a8d4b 100644 --- a/packages/minidb/src/cluster/index.ts +++ b/packages/minidb/src/cluster/index.ts @@ -8,8 +8,8 @@ // - Each shard keeps minidb's single-writer model (its own db.lock), so // concurrency scales with the number of distinct shards being written. // - Writes route through a per-process writer pool (lock-pool.ts): cached -// shard writers hold their lock and renew its timestamp; acquisition -// retries live holders up to lockAcquireTimeoutMs. +// shard writers hold their kernel lock; acquisition retries contenders up +// to lockAcquireTimeoutMs. // - Reads never take write locks: they use the cached writer when this // process holds the shard, else a read-only MiniDb revalidated against // the shard files' fingerprint on every use. @@ -100,14 +100,12 @@ export class ClusterDb { recovery: opts.recovery, maxMemoryBytes: opts.maxMemoryBytes, maxMemoryPolicy: opts.maxMemoryPolicy, - lockAcquireTimeoutMs: opts.lockAcquireTimeoutMs, }, readerOpts: { valueCodec: topology.meta.valueCodec, valueMode: opts.valueMode, recovery: opts.recovery, }, - lockRenewMs: opts.lockRenewMs ?? 10_000, lockAcquireTimeoutMs: opts.lockAcquireTimeoutMs ?? 30_000, lockHoldMs: opts.lockHoldMs ?? 250, maxWriters: opts.lockPoolMaxShards ?? 16, diff --git a/packages/minidb/src/cluster/lock-pool.ts b/packages/minidb/src/cluster/lock-pool.ts index 236046362e..53adff346c 100644 --- a/packages/minidb/src/cluster/lock-pool.ts +++ b/packages/minidb/src/cluster/lock-pool.ts @@ -32,7 +32,6 @@ import { sleep } from './utils.js'; export interface LockPoolOptions { writerOpts: ShardOpenOptions; readerOpts: ShardOpenOptions; - lockRenewMs: number; lockAcquireTimeoutMs: number; lockHoldMs: number; maxWriters: number; @@ -212,7 +211,7 @@ export class ShardLockPool { let delay = 10; for (;;) { try { - const handle = await ShardHandle.openWriter(shardId, dir, this.opts.writerOpts, this.opts.lockRenewMs); + const handle = await ShardHandle.openWriter(shardId, dir, this.opts.writerOpts); this.stats.writerOpens++; try { await this.opts.applyDefs(handle.db); diff --git a/packages/minidb/src/cluster/shard.ts b/packages/minidb/src/cluster/shard.ts index bbd5ea7f14..c8f15909d4 100644 --- a/packages/minidb/src/cluster/shard.ts +++ b/packages/minidb/src/cluster/shard.ts @@ -1,7 +1,7 @@ // src/cluster/shard.ts // // A single shard: one MiniDb instance in either writer mode (holding the -// shard's db.lock, with a lease timer refreshing the lock timestamp) or +// shard's kernel-backed db.lock) or // reader mode (read-only, no lock, coexisting with another process's writer). import { MiniDb } from '../index.js'; @@ -11,8 +11,6 @@ import type { OpenOptions } from '../index.js'; export type ShardOpenOptions = Omit; export class ShardHandle { - private leaseTimer: NodeJS.Timeout | null = null; - private constructor( readonly shardId: number, readonly dir: string, @@ -20,25 +18,14 @@ export class ShardHandle { readonly writer: boolean, ) {} - /** Open the shard for writing: acquires db.lock via MiniDb.open and starts - * refreshing the lock timestamp every renewMs (0 disables renewal). Throws - * LockError if the lock is held by a live process. */ + /** Open the shard for writing and hold db.lock until close. */ static async openWriter( shardId: number, dir: string, opts: ShardOpenOptions, - renewMs: number, ): Promise { const db = await MiniDb.open({ ...opts, dir }); - const handle = new ShardHandle(shardId, dir, db as MiniDb, true); - if (renewMs > 0) { - handle.leaseTimer = setInterval(() => { - void db.renewLock().catch(() => {}); - }, renewMs); - // Never keep a worker process alive just for lease renewal. - handle.leaseTimer.unref(); - } - return handle; + return new ShardHandle(shardId, dir, db as MiniDb, true); } /** Open the shard read-only. Does not touch db.lock, never fsyncs, and @@ -56,10 +43,6 @@ export class ShardHandle { } async close(): Promise { - if (this.leaseTimer) { - clearInterval(this.leaseTimer); - this.leaseTimer = null; - } await this.db.close(); } } diff --git a/packages/minidb/src/cluster/types.ts b/packages/minidb/src/cluster/types.ts index 9c37716a00..5a252504aa 100644 --- a/packages/minidb/src/cluster/types.ts +++ b/packages/minidb/src/cluster/types.ts @@ -36,15 +36,6 @@ export interface ClusterOpenOptions { * throw; reads always go through revalidated read-only shard instances. */ readOnly?: boolean; - /** How long a lock timestamp may go unrefreshed before the lock would be - * considered abandoned (default 30000). Reserved: the underlying LockFile - * currently takes a lock over only when the recorded owner PID is dead, - * never merely because it is old; lockRenewMs keeps the timestamp fresh - * for observability and for a future lease-based check. */ - lockLeaseMs?: number; - /** How often a cached shard writer refreshes its lock timestamp - * (default 10000). Set to 0 to disable renewal. */ - lockRenewMs?: number; /** Maximum number of shard write locks cached in this process; least * recently used, non-busy shards are evicted beyond the cap (default 16). */ lockPoolMaxShards?: number; diff --git a/packages/minidb/src/index.ts b/packages/minidb/src/index.ts index 40f6530833..4e058e7e6c 100644 --- a/packages/minidb/src/index.ts +++ b/packages/minidb/src/index.ts @@ -146,8 +146,6 @@ export interface OpenOptions { recovery?: RecoveryMode; readOnly?: boolean; onLockFail?: 'readonly'; - /** Absolute bound for the underlying lock acquisition settle phase. */ - lockAcquireTimeoutMs?: number; /** Where to keep value bulk. 'memory' keeps values in RAM; 'disk' keeps only * value pointers in RAM and reads values from the snapshot/WAL on demand. */ valueMode?: ValueModeSetting; @@ -292,11 +290,7 @@ export class MiniDb { db.lock = new LockFile(path.join(db.dir, 'db.lock'), { onLost: () => db.markLockLost(), }); - const deadline = - opts.lockAcquireTimeoutMs === undefined - ? undefined - : Date.now() + Math.max(0, opts.lockAcquireTimeoutMs); - const got = await db.lock.acquire(deadline); + const got = await db.lock.acquire(); if (!got) { if (opts.onLockFail === 'readonly') { db.readOnly = true; @@ -1614,9 +1608,7 @@ export class MiniDb { // ---- maintenance -------------------------------------------------------- - /** Refresh the write lock's timestamp (see {@link LockFile.renew}). No-op - * for a read-only instance. Exposed for lease-style holders such as the - * cluster shard pool, which renew on a timer to prove liveness. */ + /** Verify that this process still holds the database's kernel lock. */ async renewLock(): Promise { if (this.lock === null) return; await this.lock.renew(); @@ -1675,6 +1667,7 @@ export class MiniDb { } private ensureWritable(): void { if (this.readOnly) throw new Error('MiniDb is open in read-only mode'); + this.lock?.assertHeld(); if (this.lockLossError !== null) throw this.lockLossError; } diff --git a/packages/minidb/src/lockfile.ts b/packages/minidb/src/lockfile.ts index 39ff0d1e24..b3046ca379 100644 --- a/packages/minidb/src/lockfile.ts +++ b/packages/minidb/src/lockfile.ts @@ -1,516 +1,75 @@ -// src/lockfile.ts -// -// A small exclusive file lock using atomic tmp+link creation, aligned with the -// repo's unified cross-process lock protocol (pid-only directory-lock mode — -// see agent-core-v2's `interface/crossProcessLock.ts`; the protocol is an -// on-disk contract, so this package inlines its own zero-dependency -// implementation). -// -// Invariants: -// - Token-guarded: every acquire writes a unique `lock_id`; the token is -// compared after every winning create (read-back), on every settle -// re-check, and before release/releaseSync unlink — a late operation never -// deletes or mistakes a newer holder's lock. -// - A lock whose owner PID is alive is never taken over. Stale = dead PID, a -// reused PID (processStartedAt identity mismatch), a payload without a -// usable PID, or an empty/unparseable file older than the creation window -// (a fresh one is "creating" → held; foreign publishers are not required to -// write atomically — our own publishes are atomic tmp+link). -// - Takeover quarantines the corpse via rename to `db.lock.stale.` -// (never delete+create, so a frozen creator resuming mid-window cannot be -// clobbered and the takeover stays auditable), then re-creates via the -// atomic link. A full re-inspect precedes every rename attempt so a live -// winner is never quarantined. -// - Exactly one holder is a construction, not a timing bet: every contender -// registers a liveness watch covering its whole attempt, and every creator -// — direct or takeover — settles (adaptive backoff) until no live foreign -// watch remains before claiming, so an in-flight co-racer always resolves -// first. -// -// The correctness backstop is dead-PID takeover plus the token guard against -// deleting someone else's lock. The beforeExit hook is only a safety net (it -// never fires on SIGKILL) — the README documents takeover on dead PID as the -// recovery path. - -import fsSync from 'node:fs'; -import fs from 'node:fs/promises'; -import path from 'node:path'; -import { execFileSync } from 'node:child_process'; -import { randomUUID } from 'node:crypto'; +import { + type KernelFileLockHandle, + tryAcquireKernelFileLock, +} from '@moonshot-ai/kernel-file-lock'; export class LockError extends Error { readonly code = 'ELOCKED'; + constructor(message: string) { super(message); this.name = 'LockError'; } } -/** Milliseconds an empty/unparseable lock file counts as "creating" (held) - rather than stale. Covers foreign publishers whose create and payload - write are not atomic; our own publishes are atomic tmp+link. */ -export const LOCK_CREATION_WINDOW_MS = 5000; - -/** Test seam: every clock, pid, probe and token source is replaceable. */ export interface LockFileDeps { - now?: () => number; - selfPid?: number; - probeProcess?: (pid: number) => { alive: boolean; processStartedAt?: string }; - /** Unique token of this acquire; compared on every guarded mutation. */ - newLockId?: () => string; - /** Called exactly once when a held lock is replaced or disappears. */ - onLost?: () => void; -} - -// Track held locks so we can release them on process exit as a safety net. -const HELD = new Set(); -// Distinct sidecar names per acquire attempt: two lock users in the same -// process (e.g. independent shard pools) must never share a tmp/watch -// path, or one user's cleanup would delete the other's in-flight file. -let sidecarSeq = 0; -const nextSidecarSeq = (): number => ++sidecarSeq; -let exitHooked = false; -// Co-racers all move on the stale corpse within the same wave (they woke on -// the same event); the settle before any creator claims must outlast that -// wave so the last racer to land is unambiguous. A fixed value (even a -// generous one) loses on shared CI runners that deschedule a racer for -// hundreds of milliseconds inside its own atomic-op sequence, so the backoff -// is ADAPTIVE: it scales with how long our own takeover attempt took (a -// stalled machine stalls every racer), floored at 60ms and capped at 2s so a -// healthy takeover stays fast. Residual (bounded-delay, inherent to -// file-based takeover): a racer descheduled between its gate inspect and its -// quarantine rename can still rename-aside a fresh creator's lock — which is -// exactly why EVERY creator (direct or takeover) runs the watch settle -// before claiming: the thief is still inside its attempt, its watch keeps -// the victim settling, the victim sees its token gone from the file, and -// resolves without claiming. -const TAKEOVER_SETTLE_BASE_MS = 60; -const TAKEOVER_SETTLE_MAX_MS = 2_000; -function hookExit(): void { - if (exitHooked) return; - exitHooked = true; - process.on('beforeExit', () => { - for (const lock of HELD) lock.releaseSync(); - }); -} - -/** Opaque platform identity token for pid-reuse detection; compared by - equality only, never parsed. Undefined when the platform cannot provide it - or probing fails (conservative: degradation only *disables* takeover). */ -function processStartedAtOf(pid: number): string | undefined { - try { - if (process.platform === 'darwin') { - // e.g. `{ sec = 1759812345, usec = 123456 }` - const out = execFileSync('sysctl', ['-n', 'kern.proc.starttime', String(pid)], { - encoding: 'utf8', - timeout: 3000, - stdio: ['ignore', 'pipe', 'ignore'], - }); - const m = /sec = (\d+), usec = (\d+)/.exec(out); - return m ? `${m[1]}.${m[2]}` : undefined; - } - if (process.platform === 'linux') { - // comm (field 2) may contain spaces/parens; field 22 (starttime) is the - // 20th token (index 19) after the LAST ')'. - const raw = fsSync.readFileSync(`/proc/${pid}/stat`, 'utf8'); - const close = raw.lastIndexOf(')'); - if (close === -1) return undefined; - const rest = raw.slice(close + 1).trim().split(/\s+/); - return rest.length > 19 ? rest[19] : undefined; - } - } catch { - /* probing failure: no identity available */ - } - return undefined; -} - -function pidAlive(pid: number): boolean { - try { - process.kill(pid, 0); - return true; - } catch (e) { - // ESRCH: no such process (dead). EPERM: exists but another user (alive). - // Any other failure is inconclusive — treat conservatively as alive. - return (e as NodeJS.ErrnoException).code !== 'ESRCH'; - } -} - -function defaultProbeProcess(pid: number): { alive: boolean; processStartedAt?: string } { - const alive = pidAlive(pid); - return { alive, processStartedAt: alive ? processStartedAtOf(pid) : undefined }; -} - -interface LockPayload { - pid?: unknown; - ts?: unknown; - lock_id?: unknown; - process_started_at?: unknown; -} - -function tryParse(raw: string): LockPayload | undefined { - try { - const parsed = JSON.parse(raw) as unknown; - return typeof parsed === 'object' && parsed !== null ? (parsed as LockPayload) : undefined; - } catch { - return undefined; - } + readonly onLost?: () => void; } export class LockFile { readonly path: string; held = false; - private lockId?: string; - private startedAt?: string; - private readonly now: () => number; - private readonly selfPid: number; - private readonly probeProcess: NonNullable; - private readonly newLockId: () => string; + private handle: KernelFileLockHandle | undefined; private readonly onLost: (() => void) | undefined; constructor(path: string, deps: LockFileDeps = {}) { this.path = path; - this.now = deps.now ?? Date.now; - this.selfPid = deps.selfPid ?? process.pid; - this.probeProcess = deps.probeProcess ?? defaultProbeProcess; - this.newLockId = deps.newLockId ?? randomUUID; this.onLost = deps.onLost; } - /** Try to acquire the lock exactly once. Returns true when this call created - * the lock file, either directly or by winning a stale-lock takeover. Returns - * false whenever the lock was already held at attempt time — by a live owner - * or by a competing takeover. After observing a held lock this call never - * re-races: callers that want to wait retry acquire() at a higher level - * (see the cluster lock pool). */ - async acquire(deadline?: number): Promise { - const lockId = this.newLockId(); - this.startedAt ??= processStartedAtOf(this.selfPid); - // Register a "watch" BEFORE touching the lock: every contender is visible - // to every other for its whole attempt, regardless of where the scheduler - // stalls it. (Settle-window heuristics alone could not survive a racer - // descheduled before its quarantine rename on a shard-parallel CI runner — - // see the takeover loop below; a stalled contender is only in the way, not - // invisible.) - const watch = `${this.path}.watch-${process.pid}-${nextSidecarSeq()}`; - await fs.writeFile(watch, JSON.stringify({ pid: process.pid, ts: Date.now() })); - try { - await this.reapDeadWatches(); - - if (await this.tryCreate(lockId)) { - // A direct creator must settle exactly like a takeover winner (see - // settleForForeignWatches): a co-racer that gate-inspected a corpse - // before it vanished can still be descheduled between that inspect - // and its quarantine rename, and its late rename would steal this - // fresh lock AFTER our confirm — the historical "two processes - // believe they hold the lock" failure the settle exists to prevent. - if (!(await this.settleForForeignWatches(lockId, watch, TAKEOVER_SETTLE_BASE_MS, deadline))) { - await this.abandonCandidate(lockId); - return false; - } - return await this.confirmCreated(lockId); - } - - // The lock exists. Only a STALE owner's lock may be taken over; - // everything else (a live owner, a takeover won by another racer in the - // meantime) is respected. - const seen = await this.inspect(); - if (seen === null || seen.alive) return false; - - // Takeover via quarantine-rename, NOT unlink-then-create. Unlinking a - // stale lock and then racing to re-create it left a window in which a - // loser could delete the winner's just-linked file, after which several - // processes all believed they held the lock. Renaming the corpse aside - // is atomic and auditable, and the subsequent tmp+link create admits - // exactly one winner. - // - // Windows cannot rename a file while ANY process holds it open - // (co-racers reading/stat'ing the corpse make the rename EPERM), so the - // rename is retried with jitter. Crucially, each retry re-inspects the - // file first: a blind retry loop could quarantine a live winner's lock - // seconds late (exactly the failure this loop is careful not to - // reintroduce). - const attemptStart = Date.now(); - const stalePath = `${this.path}.stale.${seen.lockId ?? 'unknown'}`; - for (let attempt = 0; ; attempt++) { - if (deadline !== undefined && this.now() >= deadline) return false; - // The corpse must still be there and stale. A competitor who landed - // wins by being alive in the file now — back off instead of - // quarantining their lock. (Unconditional, not just win32: the same - // hazard exists on POSIX when a co-racer is descheduled between its - // first inspect and its rename.) - const gate = await this.inspect(); - if (gate === null || gate.alive || gate.mine) return false; - try { - await fs.rename(this.path, stalePath); - break; - } catch (e) { - const code = (e as NodeJS.ErrnoException).code; - if (code === 'ENOENT') break; // a co-racer quarantined it first - const epermRetryable = - code === 'EPERM' && process.platform === 'win32' && attempt < 50; - if (!epermRetryable) { - // A persistent EPERM (Windows retries exhausted) means some holder - // kept the path pinned — the corpse could not be displaced this - // round, so decline like a live lock and let callers retry higher - // up. - if (code === 'EPERM' || code === 'EEXIST') return false; - throw e; - } - const delay = 20 + Math.floor(Math.random() * 30); - const remaining = deadline === undefined ? delay : deadline - this.now(); - if (remaining <= 0) return false; - const waitMs = Math.min(delay, remaining); - await new Promise((resolve) => { - setTimeout(resolve, waitMs); - }); - } - } - - if (!(await this.tryCreate(lockId))) return false; - - // Adaptive settle: scale with how long our own attempt took (a stalled - // machine stalls every racer), floored and capped (see the constants). - const elapsedMs = Date.now() - attemptStart; - const initialSettleMs = Math.min( - TAKEOVER_SETTLE_MAX_MS, - Math.max(TAKEOVER_SETTLE_BASE_MS, elapsedMs * 4), - ); - if (!(await this.settleForForeignWatches(lockId, watch, initialSettleMs, deadline))) { - await this.abandonCandidate(lockId); - return false; - } - return await this.confirmCreated(lockId); - } finally { - await fs.unlink(watch).catch(() => {}); - } - } - - /** Atomic create-if-absent publish: tmp write + hard link (EEXIST-safe). */ - private async tryCreate(lockId: string): Promise { - const tmp = `${this.path}.tmp-${process.pid}-${nextSidecarSeq()}`; - try { - await fs.writeFile(tmp, this.payload(lockId)); - await fs.link(tmp, this.path); - return true; - } catch (e) { - if ((e as NodeJS.ErrnoException).code !== 'EEXIST') throw e; - return false; - } finally { - await fs.unlink(tmp).catch(() => {}); - } - } - - /** Read-back confirm: the file we just linked must still carry our token — - * a co-racer resuming inside the takeover window may have replaced it. */ - private async confirmCreated(lockId: string): Promise { - const back = await this.readDiskText(); - if (back === undefined || tryParse(back)?.lock_id !== lockId) return false; - this.lockId = lockId; + async acquire(): Promise { + if (this.held) return true; + const handle = tryAcquireKernelFileLock(this.path); + if (handle === undefined) return false; + this.handle = handle; this.held = true; - HELD.add(this); - hookExit(); return true; } - private async abandonCandidate(lockId: string): Promise { - const raw = await this.readDiskText(); - if (tryParse(raw ?? '')?.lock_id !== lockId) return; - await fs.unlink(this.path).catch(() => {}); - } - - private payload(lockId: string): string { - return JSON.stringify({ - pid: this.selfPid, - ts: this.now(), - lock_id: lockId, - // Legacy readers see only `pid`/`ts`; `lock_id`/`process_started_at` - // follow the unified protocol's snake_case disk keys. Absent when the - // platform cannot provide a start-time identity (e.g. macOS). - process_started_at: this.startedAt, - }); - } - - /** Read the lock file and decide its state. null = the file vanished. */ - private async inspect(): Promise<{ alive: boolean; mine: boolean; lockId?: string } | null> { - const raw = await this.readDiskText(); - if (raw === undefined) return null; - const st = await fs.stat(this.path).catch(() => undefined); - if (st === undefined) return null; - const parsed = raw.trim() === '' ? undefined : tryParse(raw); - if (parsed === undefined) { - // Empty or unparseable: a foreign publisher may be mid-write. Only past - // the creation window is it definitively stale. - return { alive: this.now() - st.mtimeMs < LOCK_CREATION_WINDOW_MS, mine: false }; - } - const pid = parsed.pid; - if (typeof pid !== 'number' || !Number.isInteger(pid) || pid <= 0) { - // A parsed payload without a usable PID cannot be owned by anyone. - return { alive: false, mine: false, lockId: stringOrUndefined(parsed.lock_id) }; - } - const probe = this.probeProcess(pid); - let alive = probe.alive; - const diskStartedAt = stringOrUndefined(parsed.process_started_at); - if ( - alive && - probe.processStartedAt !== undefined && - diskStartedAt !== undefined && - probe.processStartedAt !== diskStartedAt - ) { - // PID alive but identity differs: the PID was reused by a new process, - // the original holder is dead. Treated as dead. - alive = false; - } - // Live, identity matching or unavailable: never take over a live PID. - return { alive, mine: pid === process.pid, lockId: stringOrUndefined(parsed.lock_id) }; - } - - /** File contents, `undefined` only when the file is gone. */ - private async readDiskText(): Promise { - try { - return await fs.readFile(this.path, 'utf8'); - } catch (e) { - if ((e as NodeJS.ErrnoException).code === 'ENOENT') return undefined; - throw e; - } - } - - /** Delete watch registrations whose owner pid is no longer alive. */ - private async reapDeadWatches(): Promise { - const dir = path.dirname(this.path); - const prefix = `${path.basename(this.path)}.watch-`; - for (const f of await fs.readdir(dir).catch(() => [] as string[])) { - if (!f.startsWith(prefix)) continue; - const pid = Number(f.slice(prefix.length).split('-')[0]); - if (Number.isInteger(pid) && pid !== process.pid && !pidAlive(pid)) { - await fs.unlink(path.join(dir, f)).catch(() => {}); - } - } + checkHeld(): boolean { + if (!this.held || this.handle === undefined) return false; + if (this.handle.checkHeld()) return true; + this.markLost(); + return false; } - /** Exactly-one is a construction, not a timing bet: after winning the - * create, wait until no live foreign watch remains. A watch covers its - * owner's WHOLE attempt (registration precedes the first lock touch), so - * an in-flight co-racer — e.g. one descheduled between its gate inspect - * and its quarantine rename — always resolves before we claim; if its - * late rename quarantined our fresh lock, our token is gone from the file - * and we resolve WITHOUT claiming. Adaptive backoff: a stalled machine - * stalls every racer, so the pause doubles while contention persists. */ - private async settleForForeignWatches( - lockId: string, - ownWatch: string, - initialSettleMs: number, - deadline?: number, - ): Promise { - let settleMs = initialSettleMs; - for (;;) { - const cur = await this.inspect(); - if (cur === null || cur.lockId !== lockId) return false; - if (!(await this.hasLiveForeignWatch(ownWatch))) return true; - const remaining = deadline === undefined ? settleMs : deadline - this.now(); - if (remaining <= 0) return false; - const waitMs = Math.min(settleMs, remaining); - await new Promise((resolve) => { - setTimeout(resolve, waitMs); - }); - settleMs = Math.min(TAKEOVER_SETTLE_MAX_MS, settleMs * 2); - } + assertHeld(): void { + if (!this.checkHeld()) throw new LockError(`database write lock was lost: ${this.path}`); } - /** True when any OTHER attempt's liveness watch exists (reaping dead ones - * on sight). Compared by watch NAME, not pid: two LockFile users in one - * process (e.g. independent shard pools) can contest the same lock and - * must see each other's watches despite sharing a pid. */ - private async hasLiveForeignWatch(ownWatch: string): Promise { - const dir = path.dirname(this.path); - const prefix = `${path.basename(this.path)}.watch-`; - const own = path.basename(ownWatch); - for (const f of await fs.readdir(dir).catch(() => [] as string[])) { - if (!f.startsWith(prefix) || f === own) continue; - const pid = Number(f.slice(prefix.length).split('-')[0]); - if (!Number.isInteger(pid)) continue; - if (pidAlive(pid)) return true; - await fs.unlink(path.join(dir, f)).catch(() => {}); - } - return false; - } - - /** Refresh the lock timestamp (proves liveness to processes inspecting the - * lock file). No-op when the lock is not held. The update is bound to the - * inode whose token was verified: replacing the public path after open can - * only make this holder lose ownership, never overwrite the replacement. */ async renew(): Promise { - if (!this.held || this.lockId === undefined) return; - const lockId = this.lockId; - let handle: fs.FileHandle; - try { - handle = await fs.open(this.path, 'r+'); - } catch (error) { - if ((error as NodeJS.ErrnoException).code === 'ENOENT') { - this.markLost(); - return; - } - throw error; - } - try { - const raw = await handle.readFile({ encoding: 'utf8' }); - if (tryParse(raw)?.lock_id !== lockId) { - this.markLost(); - return; - } - const data = Buffer.from(this.payload(lockId)); - await handle.write(data, 0, data.length, 0); - await handle.truncate(data.length); - await handle.sync(); - } finally { - await handle.close(); - } - if (tryParse((await this.readDiskText()) ?? '')?.lock_id !== lockId) { - this.markLost(); - } + this.assertHeld(); } - /** Token-guarded release. Idempotent; a missing or foreign-owned file is - never unlinked. */ async release(): Promise { - if (!this.held) return; - this.held = false; - HELD.delete(this); - const lockId = this.lockId; - this.lockId = undefined; - try { - const raw = await this.readDiskText(); - if (raw !== undefined && tryParse(raw)?.lock_id === lockId) { - await fs.unlink(this.path).catch(() => {}); - } - } catch { - // Missing or unreadable: nothing of ours to remove (best-effort). - } + this.releaseSync(); } releaseSync(): void { if (!this.held) return; this.held = false; - HELD.delete(this); - const lockId = this.lockId; - this.lockId = undefined; - try { - const raw = fsSync.readFileSync(this.path, 'utf8'); - if (tryParse(raw)?.lock_id === lockId) fsSync.unlinkSync(this.path); - } catch { - /* same policy as release(): never unlink on uncertainty */ - } + const handle = this.handle; + this.handle = undefined; + handle?.release(); } private markLost(): void { if (!this.held) return; this.held = false; - this.lockId = undefined; - HELD.delete(this); + const handle = this.handle; + this.handle = undefined; + handle?.release(); this.onLost?.(); } } - -function stringOrUndefined(v: unknown): string | undefined { - return typeof v === 'string' ? v : undefined; -} diff --git a/packages/minidb/test/cluster/lock.test.ts b/packages/minidb/test/cluster/lock.test.ts index 9f24a797ec..57569cc17a 100644 --- a/packages/minidb/test/cluster/lock.test.ts +++ b/packages/minidb/test/cluster/lock.test.ts @@ -1,8 +1,8 @@ // test/cluster/lock.test.js // // Lock semantics inside one process: same-shard writer contention with -// acquire timeout, per-shard independence, read-only coexistence, lock lease -// renewal, and writer handoff after close. +// acquire timeout, per-shard independence, read-only coexistence, stable +// sentinels, and writer handoff after close. import { test } from 'vitest'; import assert from 'node:assert/strict'; @@ -11,7 +11,7 @@ import path from 'node:path'; import { ClusterDb } from '../../src/cluster/index.js'; import { shardDirName } from '../../src/cluster/utils.js'; import { tmpDir, rmrf } from '../e2e/helpers/tmp.js'; -import { keyOnShard, sleep } from './helpers.js'; +import { keyOnShard } from './helpers.js'; test('two writers contend on the same shard; loser times out with LockError', async () => { const dir = await tmpDir('minidb-cluster-'); @@ -91,20 +91,19 @@ test('read-only instance coexists with a live writer and sees its commits', asyn } }); -test('lock lease: db.lock timestamp advances while a writer is held', async () => { +test('a cached writer keeps a stable sentinel without renewal writes', async () => { const dir = await tmpDir('minidb-cluster-'); try { - const db = await ClusterDb.open({ dir, shardCount: 4, valueCodec: 'json', lockRenewMs: 80, lockHoldMs: 0 }); + const db = await ClusterDb.open({ dir, shardCount: 4, valueCodec: 'json', lockHoldMs: 0 }); const key = keyOnShard('lease', 1, 4); - await db.set(key, { v: 1 }); // grabs shard 1 and starts the lease timer + await db.set(key, { v: 1 }); const lockPath = path.join(dir, shardDirName(1, 4), 'db.lock'); - const read = async () => JSON.parse(await fs.readFile(lockPath, 'utf8')) as { pid: number; ts: number }; - const first = await read(); - assert.equal(first.pid, process.pid); - await sleep(300); - const second = await read(); - assert.ok(second.ts > first.ts, `timestamp renewed (${first.ts} -> ${second.ts})`); + const first = await fs.stat(lockPath); + assert.equal(await fs.readFile(lockPath, 'utf8'), ''); + await new Promise((resolvePromise) => setTimeout(resolvePromise, 100)); + const second = await fs.stat(lockPath); + assert.equal(second.mtimeMs, first.mtimeMs); await db.close(); } finally { await rmrf(dir); diff --git a/packages/minidb/test/defense.test.ts b/packages/minidb/test/defense.test.ts index 95a1c76271..e88f074bad 100644 --- a/packages/minidb/test/defense.test.ts +++ b/packages/minidb/test/defense.test.ts @@ -12,7 +12,7 @@ import net from 'node:net'; import { WAL } from '../src/wal.js'; import { encodeFrame, decodeBatchOps, TYPE_SET } from '../src/codec.js'; import { MiniDb } from '../src/index.js'; -import { LockFile, LOCK_CREATION_WINDOW_MS } from '../src/lockfile.js'; +import { LockFile } from '../src/lockfile.js'; import { startServer } from '../src/server.js'; async function tmpDir() { @@ -113,52 +113,38 @@ test('WAL open and close are idempotent', async () => { await fs.rm(dir, { recursive: true, force: true }); }); -// --- LockFile stale / corrupt handling ------------------------------------- +// --- LockFile kernel ownership --------------------------------------------- test('LockFile.acquire returns false when a live process holds the lock', async () => { const dir = await tmpDir(); const p = path.join(dir, 'db.lock'); const a = new LockFile(p); assert.equal(await a.acquire(), true); - // A live owner whose identity cannot be cross-checked is never taken over. - const b = new LockFile(p, { probeProcess: () => ({ alive: true, processStartedAt: undefined }) }); + const b = new LockFile(p); assert.equal(await b.acquire(), false); await a.release(); await fs.rm(dir, { recursive: true, force: true }); }); -test('an unparseable lock file inside the creation window counts as held', async () => { +test('arbitrary sentinel contents are ignored when no kernel lock is held', async () => { const dir = await tmpDir(); const p = path.join(dir, 'db.lock'); await fs.writeFile(p, 'not-json'); - // Fresh garbage may be a live creator mid-write: treated as held, so this - // second writer is refused rather than taking over. const b = new LockFile(p); - assert.equal(await b.acquire(), false); + assert.equal(await b.acquire(), true); + await b.release(); await fs.rm(dir, { recursive: true, force: true }); }); -test('an unparseable lock file past the creation window is taken over', async () => { +test('MiniDb opens over a legacy lock payload without renaming the sentinel', async () => { const dir = await tmpDir(); const p = path.join(dir, 'db.lock'); - await fs.writeFile(p, 'not-json'); - const past = new Date(Date.now() - LOCK_CREATION_WINDOW_MS - 1000); - await fs.utimes(p, past, past); + await fs.writeFile(p, JSON.stringify({ pid: 0x7fffffff, ts: Date.now() })); const db = await MiniDb.open({ dir, valueCodec: 'string' }); await db.set('a', '1'); assert.equal(db.get('a'), '1'); - // The takeover quarantined the garbage file instead of deleting it. - assert.ok((await fs.readdir(dir)).includes('db.lock.stale.unknown')); - await db.close(); - await fs.rm(dir, { recursive: true, force: true }); -}); - -test('a lock file with a non-numeric pid is treated as stale', async () => { - const dir = await tmpDir(); - await fs.writeFile(path.join(dir, 'db.lock'), JSON.stringify({ pid: 'abc' })); - const db = await MiniDb.open({ dir, valueCodec: 'string' }); - assert.equal(db.readOnly, false); await db.close(); + assert.equal((await fs.readdir(dir)).some((entry) => entry.includes('.stale.')), false); await fs.rm(dir, { recursive: true, force: true }); }); diff --git a/packages/minidb/test/e2e/helpers/lock-racer.ts b/packages/minidb/test/e2e/helpers/lock-racer.ts index 9fa38914ce..51e26e2a8a 100644 --- a/packages/minidb/test/e2e/helpers/lock-racer.ts +++ b/packages/minidb/test/e2e/helpers/lock-racer.ts @@ -1,6 +1,6 @@ // test/e2e/helpers/lock-racer.ts // -// Child-side counterpart of the lock-takeover stress test. Sits in a loop; +// Child-side counterpart of the kernel-lock stress test. Sits in a loop; // for each round it waits for `go-` to appear in the gate directory, // then races to acquire the lock and prints "R <0|1>". diff --git a/packages/minidb/test/e2e/stress.test.ts b/packages/minidb/test/e2e/stress.test.ts index e17b32cb85..14b42b957e 100644 --- a/packages/minidb/test/e2e/stress.test.ts +++ b/packages/minidb/test/e2e/stress.test.ts @@ -184,25 +184,18 @@ test( ); // =========================================================================== -// Bug evidence: stale-lock takeover admits multiple owners +// Kernel lock exclusivity under simultaneous acquisition // =========================================================================== -// LockFile.acquire unlinks a stale lock and retries blindly. When several -// processes race to take over a crashed owner's lock, the loser's unlink can -// delete the winner's fresh lock file (and ENOENT on readFile is also treated -// as "stale"), so many racers end up believing they hold the lock at once — -// i.e. several writers on one database directory. test( - 'stress: a stale lock must be taken over by exactly one process', + 'stress: a kernel lock admits exactly one process per acquisition wave', { timeout: 180_000 }, async () => { const dir = await tmpDir('minidb-stress-lock-'); const lockPath = path.join(dir, 'db.lock'); const RACER = path.join(__dirname, 'helpers', 'lock-racer.ts'); - const DEAD_PID = 2 ** 30 - 3; - // 6-way simultaneous takeover still exposes any cascade (the historical - // failure mode grants everyone the lock), while fitting the test's process - // budget on 2-core runners (every racer is a full node+tsx child). + // Six simultaneous contenders exercise the OS-backed exclusion path while + // fitting the test's process budget on two-core runners. const RACERS = 6; const ROUNDS = 25; @@ -233,7 +226,6 @@ test( await new Promise((res) => setTimeout(res, 5)); } for (let r = 0; r < ROUNDS; r++) { - await fs.writeFile(lockPath, JSON.stringify({ pid: DEAD_PID, ts: Date.now() })); await fs.writeFile(`${dir}/go-${r}`, '1'); const want = `R${r} `; for (;;) { @@ -847,4 +839,3 @@ test( } }, ); - diff --git a/packages/minidb/test/lock.test.ts b/packages/minidb/test/lock.test.ts index 08c12e7639..c597814c4a 100644 --- a/packages/minidb/test/lock.test.ts +++ b/packages/minidb/test/lock.test.ts @@ -1,27 +1,21 @@ -// test/lock.test.js -import { test, vi } from 'vitest'; import assert from 'node:assert/strict'; import fs from 'node:fs/promises'; import os from 'node:os'; import path from 'node:path'; + +import { test } from 'vitest'; + import { MiniDb } from '../src/index.js'; -import { LockError, LockFile, LOCK_CREATION_WINDOW_MS } from '../src/lockfile.js'; +import { LockError, LockFile } from '../src/lockfile.js'; -async function tmpDir() { +async function tmpDir(): Promise { return fs.mkdtemp(path.join(os.tmpdir(), 'minidb-lock-')); } -async function cleanup(dir: string) { +async function cleanup(dir: string): Promise { await fs.rm(dir, { recursive: true, force: true }); } -const legacyPayload = (pid: number) => JSON.stringify({ pid, ts: Date.now() }); - -async function ageFile(p: string) { - const past = new Date(Date.now() - LOCK_CREATION_WINDOW_MS - 1000); - await fs.utimes(p, past, past); -} - test('a second writer on the same dir is rejected with LockError', async () => { const dir = await tmpDir(); const db1 = await MiniDb.open({ dir, valueCodec: 'string' }); @@ -74,223 +68,57 @@ test("onLockFail: 'readonly' degrades instead of throwing", async () => { } }); -// --- stale takeover ---------------------------------------------------------- - -test('a stale lock (dead PID) is taken over via rename isolation', async () => { +test('pre-existing sentinel contents do not imply ownership', async () => { const dir = await tmpDir(); - // Legacy payload shape ({pid, ts} only): old minidb versions left these. - await fs.writeFile(path.join(dir, 'db.lock'), legacyPayload(999999)); + const lockPath = path.join(dir, 'db.lock'); + await fs.writeFile(lockPath, JSON.stringify({ pid: process.pid, lock_id: 'legacy' })); + const db = await MiniDb.open({ dir, valueCodec: 'string' }); - assert.equal(db.readOnly, false); await db.set('a', '1'); assert.equal(db.get('a'), '1'); - // The old lock is renamed aside (no lock_id in legacy payload → "unknown"), - // never deleted, so the takeover stays auditable. - assert((await fs.readdir(dir)).includes('db.lock.stale.unknown')); await db.close(); - await cleanup(dir); -}); - -test('a stale lock with a protocol payload keeps its lock_id in the stale file name', async () => { - const dir = await tmpDir(); - await fs.writeFile( - path.join(dir, 'db.lock'), - JSON.stringify({ pid: 999999, ts: Date.now(), lock_id: 'old-token', process_started_at: 'x' }), - ); - const lock = new LockFile(path.join(dir, 'db.lock'), { newLockId: () => 'new-token' }); - assert.equal(await lock.acquire(), true); - assert.equal(lock.held, true); - assert((await fs.readdir(dir)).includes('db.lock.stale.old-token')); - const onDisk = JSON.parse(await fs.readFile(path.join(dir, 'db.lock'), 'utf8')); - assert.equal(onDisk.lock_id, 'new-token'); - await lock.release(); - // Release removed only our own lock; the quarantined stale file stays. - const after = await fs.readdir(dir); - assert(!after.includes('db.lock')); - assert(after.includes('db.lock.stale.old-token')); - await cleanup(dir); -}); - -test('a live foreign watch cannot keep stale-lock acquire waiting past its deadline', async () => { - const dir = await tmpDir(); - const p = path.join(dir, 'db.lock'); - const foreignWatch = `${p}.watch-${process.pid}-foreign`; - await fs.writeFile( - p, - JSON.stringify({ pid: 999999, ts: Date.now(), lock_id: 'old-token' }), - ); - await fs.writeFile(foreignWatch, JSON.stringify({ pid: process.pid, ts: Date.now() })); - - const lock = new LockFile(p); - const started = performance.now(); - assert.equal(await lock.acquire(Date.now() + 100), false); - assert.ok(performance.now() - started < 1_000); - assert.equal(await fs.readFile(p, 'utf8').then(() => true, () => false), false); - - await fs.unlink(foreignWatch).catch(() => {}); - await cleanup(dir); -}); - -// --- creation window: fresh empty/garbage files are "creating", not stale ---- - -test('a fresh empty lock file counts as still being created (acquire refused)', async () => { - const dir = await tmpDir(); - const p = path.join(dir, 'db.lock'); - await fs.writeFile(p, ''); - const lock = new LockFile(p); - assert.equal(await lock.acquire(), false); - assert.equal(lock.held, false); - - // Once the file is older than the creation window it is stale and can be - // taken over — via rename isolation, not unlink+create. - await ageFile(p); - assert.equal(await lock.acquire(), true); - assert((await fs.readdir(dir)).includes('db.lock.stale.unknown')); - await lock.release(); - await cleanup(dir); -}); - -test('a fresh unparseable lock file counts as being created; an old one is stale', async () => { - const dir = await tmpDir(); - const p = path.join(dir, 'db.lock'); - await fs.writeFile(p, 'not-json'); - const lock = new LockFile(p); - assert.equal(await lock.acquire(), false); - await ageFile(p); - assert.equal(await lock.acquire(), true); - await lock.release(); + assert.equal(await fs.readFile(lockPath, 'utf8'), JSON.stringify({ pid: process.pid, lock_id: 'legacy' })); await cleanup(dir); }); -// --- token guard ------------------------------------------------------------- - -test('release never unlinks a lock that was taken over meanwhile', async () => { +test('LockFile uses kernel ownership and leaves the sentinel in place', async () => { const dir = await tmpDir(); - const p = path.join(dir, 'db.lock'); - const a = new LockFile(p, { newLockId: () => 'a-token' }); - assert.equal(await a.acquire(), true); + const lockPath = path.join(dir, 'db.lock'); + const first = new LockFile(lockPath); + const second = new LockFile(lockPath); - // A judges A's (simulated dead) entry stale and B's payload replaces it on - // disk — e.g. after a real takeover of a frozen A. - await fs.writeFile(p, JSON.stringify({ pid: process.pid, ts: Date.now(), lock_id: 'b-token' })); - - await a.release(); // must not touch B's lock - const onDisk = JSON.parse(await fs.readFile(p, 'utf8')); - assert.equal(onDisk.lock_id, 'b-token'); - - // Same for the sync variant (rebuild the "held but superseded" state). - Object.assign(a, { held: true, lockId: 'a-token' }); - a.releaseSync(); - const stillB = JSON.parse(await fs.readFile(p, 'utf8')); - assert.equal(stillB.lock_id, 'b-token'); + assert.equal(await first.acquire(), true); + assert.equal(await second.acquire(), false); + await first.release(); + assert.equal(await fs.stat(lockPath).then(() => true), true); + assert.equal(await second.acquire(), true); + await second.release(); await cleanup(dir); }); -test('renew never overwrites a foreign lock generation', async () => { +test('rewriting sentinel contents cannot transfer a live lock', async () => { const dir = await tmpDir(); - const p = path.join(dir, 'db.lock'); - const old = path.join(dir, 'db.lock.old'); - const lock = new LockFile(p, { newLockId: () => 'a-token' }); - assert.equal(await lock.acquire(), true); + const lockPath = path.join(dir, 'db.lock'); + const first = new LockFile(lockPath); + const second = new LockFile(lockPath); + assert.equal(await first.acquire(), true); - await fs.rename(p, old); - await fs.writeFile( - p, - JSON.stringify({ pid: process.pid, ts: Date.now(), lock_id: 'b-token' }), - ); - await lock.renew(); + await fs.writeFile(lockPath, 'operator note'); + await assert.doesNotReject(() => first.renew()); + assert.equal(await second.acquire(), false); - assert.equal(lock.held, false); - const onDisk = JSON.parse(await fs.readFile(p, 'utf8')); - assert.equal(onDisk.lock_id, 'b-token'); + await first.release(); await cleanup(dir); }); -test('a read-back token mismatch rejects the acquire', async () => { - const dir = await tmpDir(); - const p = path.join(dir, 'db.lock'); - const lock = new LockFile(p, { newLockId: () => 'victim-token' }); - // A peer stomps the file between our O_EXCL create and our read-back. - const foreign = JSON.stringify({ pid: process.pid, ts: Date.now(), lock_id: 'other-token' }); - const spy = vi - .spyOn(LockFile.prototype as never, 'readDiskText') - .mockImplementation(async function (this: { path: string }) { - await fs.writeFile(this.path, foreign); - return fs.readFile(this.path, 'utf8'); - }); - try { - assert.equal(await lock.acquire(), false); - assert.equal(lock.held, false); - const onDisk = JSON.parse(await fs.readFile(p, 'utf8')); - assert.equal(onDisk.lock_id, 'other-token'); - } finally { - spy.mockRestore(); - await cleanup(dir); - } -}); - -// --- pid reuse (processStartedAt identity) ----------------------------------- - -test('a live pid whose processStartedAt differs is treated as dead (pid reused)', async () => { +test('release and releaseSync are idempotent', async () => { const dir = await tmpDir(); - const p = path.join(dir, 'db.lock'); - await fs.writeFile( - p, - JSON.stringify({ pid: 12345, ts: Date.now(), lock_id: 'old-token', process_started_at: 'A' }), - ); - const lock = new LockFile(p, { - probeProcess: () => ({ alive: true, processStartedAt: 'B' }), - }); + const lock = new LockFile(path.join(dir, 'db.lock')); + await assert.doesNotReject(() => lock.release()); + assert.doesNotThrow(() => lock.releaseSync()); assert.equal(await lock.acquire(), true); - assert((await fs.readdir(dir)).includes('db.lock.stale.old-token')); await lock.release(); - await cleanup(dir); -}); - -test('identity unavailable for a live pid is conservative: no takeover', async () => { - const dir = await tmpDir(); - const p = path.join(dir, 'db.lock'); - await fs.writeFile( - p, - JSON.stringify({ pid: 12345, ts: Date.now(), lock_id: 'old-token', process_started_at: 'A' }), - ); - const lock = new LockFile(p, { - probeProcess: () => ({ alive: true, processStartedAt: undefined }), - }); - assert.equal(await lock.acquire(), false); - // Nothing was renamed aside either. - assert.deepEqual(await fs.readdir(dir), ['db.lock']); - await cleanup(dir); -}); - -test('matching identity for a live pid also refuses takeover', async () => { - const dir = await tmpDir(); - const p = path.join(dir, 'db.lock'); - await fs.writeFile( - p, - JSON.stringify({ pid: 12345, ts: Date.now(), lock_id: 'old-token', process_started_at: 'A' }), - ); - const lock = new LockFile(p, { - probeProcess: () => ({ alive: true, processStartedAt: 'A' }), - }); - assert.equal(await lock.acquire(), false); - await cleanup(dir); -}); - -// --- legacy payload compatibility --------------------------------------------- - -test('legacy payload ({pid, ts} only): dead pid is taken over, live pid is refused', async () => { - const dir = await tmpDir(); - const p = path.join(dir, 'db.lock'); - - await fs.writeFile(p, legacyPayload(999999)); - const dead = new LockFile(p); - assert.equal(await dead.acquire(), true); - await dead.release(); - - await fs.writeFile(p, legacyPayload(process.pid)); - const live = new LockFile(p); - assert.equal(await live.acquire(), false); + assert.doesNotThrow(() => lock.releaseSync()); await cleanup(dir); }); diff --git a/packages/minidb/test/review-fixes.test.ts b/packages/minidb/test/review-fixes.test.ts index 10af7b600a..5a4746c22d 100644 --- a/packages/minidb/test/review-fixes.test.ts +++ b/packages/minidb/test/review-fixes.test.ts @@ -84,24 +84,21 @@ test('expired keys are removed from secondary indexes', async () => { } }); -test('a writer that loses its lock cannot write into a successor generation', async () => { +test('editing the sentinel payload cannot create a successor generation', async () => { const dir = await tmpDir(); try { const oldWriter = await MiniDb.open({ dir, valueCodec: 'string', autoCompact: false }); await oldWriter.set('generation', 'old'); - await fs.writeFile( - path.join(dir, 'db.lock'), - JSON.stringify({ pid: 0x7fffffff, ts: Date.now(), lock_id: 'successor-generation' }), + await fs.writeFile(path.join(dir, 'db.lock'), 'successor-generation'); + await assert.doesNotReject(() => oldWriter.renewLock()); + await assert.rejects( + () => MiniDb.open({ dir, valueCodec: 'string', autoCompact: false }), + /locked/, ); - await assert.rejects(oldWriter.renewLock(), /write lock was lost/); - - const newWriter = await MiniDb.open({ dir, valueCodec: 'string', autoCompact: false }); - await newWriter.set('generation', 'new'); - await assert.rejects(oldWriter.set('generation', 'old-again'), /write lock was lost/); - assert.equal(newWriter.get('generation'), 'new'); + await oldWriter.set('generation', 'old-again'); + assert.equal(oldWriter.get('generation'), 'old-again'); - await newWriter.close(); await oldWriter.close(); } finally { await fs.rm(dir, { recursive: true, force: true }); diff --git a/packages/protocol/src/session-ownership.ts b/packages/protocol/src/session-ownership.ts index 82234278f8..12d0a2d73b 100644 --- a/packages/protocol/src/session-ownership.ts +++ b/packages/protocol/src/session-ownership.ts @@ -8,10 +8,14 @@ * frame `details` field, verbatim in both directions. Clients branch on * `kind`; within `held-by-peer` the `phase` tells the client what to do next: * - * - creating lease file observed mid-creation; retry shortly + * - creating kernel lock held before owner metadata is visible; retry shortly * - routable holder is live and registered an address; client may redirect - * - holder-unresponsive holder pid alive but heartbeat stale; retry later + * - holder-unresponsive legacy heartbeat-based server response; retry later * - held-by-local-instance holder has no address (local/embedded engine); terminal, do not retry + * + * Current kernel-lock servers emit `creating`, `routable`, or + * `held-by-local-instance`. `holder-unresponsive` remains in the wire schema + * so current clients can still understand older servers. */ import { z } from 'zod'; @@ -28,7 +32,7 @@ export const heldByPeerDetailsSchema = z.object({ phase: sessionOwnershipPhaseSchema, /** Present only when phase === 'routable'. */ address: z.string().optional(), - /** Retry hint (ms) for 'creating' / 'holder-unresponsive'. */ + /** Retry hint (ms) for `creating` or legacy `holder-unresponsive`. */ retry_after_ms: z.number().int().nonnegative().optional(), }); export type HeldByPeerDetails = z.infer; diff --git a/packages/protocol/src/session.ts b/packages/protocol/src/session.ts index 5f9fc8348f..c89ea698e5 100644 --- a/packages/protocol/src/session.ts +++ b/packages/protocol/src/session.ts @@ -79,13 +79,12 @@ export const sessionPendingInteractionSchema = z.enum(['none', 'approval', 'ques export type SessionPendingInteraction = z.infer; /** - * Per-session holder annotation joined from `session-leases/.json` by the - * session list route (multi-instance shared home). `self` = this instance - * holds the write lease (the session is materialized here), `peer` = another - * instance holds it (`address` is its reachable base URL when the holder - * advertised one — the redirect target), `none` = no lease on disk (the - * session is materialized nowhere). Purely display/redirect metadata: the - * lease file stays the authority and is re-checked on every materialization. + * Per-session holder annotation derived from the kernel lock on + * `session-leases/.lock` plus its optional `.owner.json` routing metadata. + * `self` = this instance holds the lock (the session is materialized here), + * `peer` = another instance holds it (`address` is its advertised base URL), + * `none` = no process holds the lock. Purely display/redirect metadata: the + * live kernel lock is the authority and is re-checked on materialization. */ export const sessionOwnershipSchema = z.object({ held_by: z.enum(['self', 'peer', 'none']), diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 8d024b7442..8eadca78df 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -73,6 +73,10 @@ importers: version: 4.1.4(@types/node@22.19.17)(@vitest/coverage-v8@4.1.4)(jsdom@25.0.1)(msw@2.15.0(@types/node@22.19.17)(typescript@6.0.2))(vite@8.0.8(@types/node@22.19.17)(esbuild@0.27.7)(jiti@2.6.1)(tsx@4.21.0)(yaml@2.8.3)) apps/kimi-code: + dependencies: + fs-ext-extra-prebuilt: + specifier: 2.2.9 + version: 2.2.9 devDependencies: '@moonshot-ai/acp-adapter': specifier: workspace:^ @@ -83,6 +87,9 @@ importers: '@moonshot-ai/kap-server': specifier: workspace:^ version: link:../../packages/kap-server + '@moonshot-ai/kernel-file-lock': + specifier: workspace:^ + version: link:../../packages/kernel-file-lock '@moonshot-ai/kimi-code-oauth': specifier: workspace:^ version: link:../../packages/oauth @@ -680,6 +687,9 @@ importers: '@modelcontextprotocol/sdk': specifier: ^1.29.0 version: 1.29.0(zod@4.3.6) + '@moonshot-ai/kernel-file-lock': + specifier: workspace:^ + version: link:../kernel-file-lock '@moonshot-ai/kimi-code-oauth': specifier: workspace:^ version: link:../oauth @@ -870,6 +880,12 @@ importers: specifier: ^4.21.0 version: 4.21.0 + packages/kernel-file-lock: + dependencies: + fs-ext-extra-prebuilt: + specifier: 2.2.9 + version: 2.2.9 + packages/klient: dependencies: '@moonshot-ai/agent-core-v2': @@ -936,7 +952,11 @@ importers: specifier: workspace:^ version: link:../kaos - packages/minidb: {} + packages/minidb: + dependencies: + '@moonshot-ai/kernel-file-lock': + specifier: workspace:^ + version: link:../kernel-file-lock packages/node-sdk: dependencies: @@ -6170,6 +6190,10 @@ packages: fs-constants@1.0.0: resolution: {integrity: sha512-y6OAwoSIf7FyjMIv94u+b5rdheZEjzR63GTyZJm5qh4Bi+2YgwLCcI/fPFZkL5PSixOt6ZNKm+w+Hfp/Bciwow==} + fs-ext-extra-prebuilt@2.2.9: + resolution: {integrity: sha512-NOL/BZ0/Fd0Mqb8PsGgFZ9TZqbYu3lTffS0ucGgdJ8xtEkAz+HLku1Ju30Ehdpvg7XM5kF1PfJYF/wbO5Hr42Q==} + engines: {node: '>= 8.0.0'} + fs-extra@11.3.5: resolution: {integrity: sha512-eKpRKAovdpZtR1WopLHxlBWvAgPny3c4gX1G5Jhwmmw4XJj0ifSD5qB5TOo8hmA0wlRKDAOAhEE1yVPgs6Fgcg==} engines: {node: '>=14.14'} @@ -7624,6 +7648,9 @@ packages: mz@2.7.0: resolution: {integrity: sha512-z81GNO7nnYMEhrGh9LeymoE4+Yr0Wn5McHIZMK5cfQCl+NDX08sCZgUc9/6MHni9IWuFLm1Z3HTCXu2z9fN62Q==} + nan@2.28.0: + resolution: {integrity: sha512-fTsDz99OTq2sVePhGdp4qQhggZFtKr64ZNVyVajRKtMOkJxYekplBh577PiJB12v/D3s2E5cGtOI45LWp6rnLQ==} + nanoid@3.3.11: resolution: {integrity: sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w==} engines: {node: ^10 || ^12 || ^13.7 || ^14 || >=15.0.1} @@ -15409,6 +15436,10 @@ snapshots: fs-constants@1.0.0: optional: true + fs-ext-extra-prebuilt@2.2.9: + dependencies: + nan: 2.28.0 + fs-extra@11.3.5: dependencies: graceful-fs: 4.2.11 @@ -17154,6 +17185,8 @@ snapshots: object-assign: 4.1.1 thenify-all: 1.6.0 + nan@2.28.0: {} + nanoid@3.3.11: {} nanoid@3.3.12: {} From 0b65ef4f13988ce7db17f28c31e8460915dd3362 Mon Sep 17 00:00:00 2001 From: 7Sageer <7sageer@djwcb.cn> Date: Tue, 21 Jul 2026 00:26:00 +0800 Subject: [PATCH 06/22] fix: harden filesystem coordination and shutdown --- .changeset/support-node-26-locks.md | 5 ++ .github/workflows/ci.yml | 20 +++++ apps/kimi-code/package.json | 4 +- .../kimi-code/scripts/native/check-bundle.mjs | 2 +- apps/kimi-code/scripts/native/native-deps.mjs | 12 +-- apps/kimi-code/src/native/kernel-file-lock.ts | 40 +++++++++- apps/kimi-code/src/native/native-require.ts | 10 +++ apps/kimi-code/src/native/smoke.ts | 10 +-- .../test/native/native-assets.test.ts | 28 ++++++- .../test/scripts/native/native-deps.test.ts | 16 ++++ apps/kimi-code/tsdown.native.config.ts | 2 +- package.json | 2 +- .../agent/fileFencing/fileFencingService.ts | 29 +++++-- .../agent/toolExecutor/toolExecutorService.ts | 8 +- .../agent-core-v2/src/app/edit/fileEdit.ts | 3 +- .../src/app/edit/fileEditService.ts | 4 +- .../agent-core-v2/src/app/edit/tools/edit.ts | 7 +- .../app/sessionLifecycle/sessionLifecycle.ts | 1 + .../sessionLifecycleService.ts | 39 +++++++-- .../os/backends/node-local/hostFsService.ts | 56 +++++++++---- .../src/os/backends/node-local/tools/read.ts | 61 ++++++++++---- .../src/os/backends/node-local/tools/write.ts | 20 +++-- .../src/os/interface/hostFileSystem.ts | 10 ++- .../session/sessionFileLedger/fileLedger.ts | 4 +- .../sessionFileLedger/fileLedgerService.ts | 17 ++-- .../agent-core-v2/src/tool/toolContract.ts | 41 ++++++++-- .../agent/fileFencing/fileFencing.test.ts | 59 ++++++++++++-- .../plan/injection/planModeInjection.test.ts | 4 +- .../test/agent/plan/plan.test.ts | 19 +++-- .../agent/toolExecutor/toolExecutor.test.ts | 24 ++++++ .../test/app/edit/tools/edit.test.ts | 25 +++++- .../externalHooksRunner/integration.test.ts | 1 + .../app/sessionExport/sessionExport.test.ts | 1 + .../sessionLifecycle/sessionLifecycle.test.ts | 68 ++++++++++++++++ .../os/backends/node-local/tools/read.test.ts | 57 ++++++++++++- .../backends/node-local/tools/write.test.ts | 39 +++++++-- .../sessionFileLedger/fileLedger.test.ts | 43 +++++++--- .../test/session/sessionFs/fsService.test.ts | 4 +- .../workspaceCommand/workspaceCommand.test.ts | 9 ++- packages/kap-server/src/start.ts | 13 ++- .../transport/ws/v1/sessionEventJournal.ts | 49 ++++++++++-- packages/kap-server/test/boot.test.ts | 22 +++++ .../test/sessionEventJournal.test.ts | 31 ++++++- packages/kernel-file-lock/package.json | 2 +- packages/kernel-file-lock/src/index.ts | 22 ++--- .../test/kernel-file-lock.test.ts | 14 ++-- packages/kernel-file-lock/tsdown.config.ts | 2 +- pnpm-lock.yaml | 80 ++++++++++++++----- 48 files changed, 839 insertions(+), 200 deletions(-) create mode 100644 .changeset/support-node-26-locks.md diff --git a/.changeset/support-node-26-locks.md b/.changeset/support-node-26-locks.md new file mode 100644 index 0000000000..947b9ad649 --- /dev/null +++ b/.changeset/support-node-26-locks.md @@ -0,0 +1,5 @@ +--- +"@moonshot-ai/kimi-code": patch +--- + +Support Node.js 26 for CLI installs and kernel-backed file coordination. diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 07da0bd4f9..026b0f4f71 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -66,6 +66,26 @@ jobs: - run: pnpm install --frozen-lockfile - run: pnpm --filter @moonshot-ai/pi-tui test + test-kernel-file-lock: + runs-on: ubuntu-latest + strategy: + fail-fast: false + matrix: + node-version: [24.15.0, 26] + + steps: + - uses: actions/checkout@v4 + + - uses: pnpm/action-setup@v6 + + - uses: actions/setup-node@v6 + with: + node-version: ${{ matrix.node-version }} + cache: pnpm + + - run: pnpm install --frozen-lockfile + - run: pnpm --filter @moonshot-ai/kernel-file-lock test + test-windows: runs-on: windows-latest # Temporarily disabled while Windows tests are being stabilized. diff --git a/apps/kimi-code/package.json b/apps/kimi-code/package.json index e5ade62901..d8d3f2b734 100644 --- a/apps/kimi-code/package.json +++ b/apps/kimi-code/package.json @@ -78,7 +78,7 @@ "postinstall": "node scripts/postinstall.mjs" }, "dependencies": { - "fs-ext-extra-prebuilt": "2.2.9" + "fs-native-extensions": "1.5.0" }, "optionalDependencies": { "@mariozechner/clipboard": "^0.3.9", @@ -112,6 +112,6 @@ "zod": "^4.3.6" }, "engines": { - "node": ">=22.19.0" + "node": ">=22.19.0 <27" } } diff --git a/apps/kimi-code/scripts/native/check-bundle.mjs b/apps/kimi-code/scripts/native/check-bundle.mjs index 0145e4e391..bfef0f4938 100644 --- a/apps/kimi-code/scripts/native/check-bundle.mjs +++ b/apps/kimi-code/scripts/native/check-bundle.mjs @@ -23,7 +23,7 @@ const optionalRuntimeRequires = new Set([ 'utf-8-validate', ]); const optionalRelativeRuntimeRequires = new Set(['./crypto/build/Release/sshcrypto.node']); -const handledNativeRuntimeRequires = new Set(['fs-ext-extra-prebuilt']); +const handledNativeRuntimeRequires = new Set(['fs-native-extensions']); function isAllowedSpecifier(specifier) { if (builtins.has(specifier) || specifier.startsWith('node:')) return true; diff --git a/apps/kimi-code/scripts/native/native-deps.mjs b/apps/kimi-code/scripts/native/native-deps.mjs index ae91ec6734..1d12f3f196 100644 --- a/apps/kimi-code/scripts/native/native-deps.mjs +++ b/apps/kimi-code/scripts/native/native-deps.mjs @@ -39,11 +39,11 @@ const piTuiNativeFileByTarget = Object.freeze({ 'win32-x64': ['native/win32/prebuilds/win32-x64/win32-console-mode.node'], }); -const fsExtNativeFileByTarget = Object.freeze( +const fsNativeExtensionsFileByTarget = Object.freeze( Object.fromEntries( SUPPORTED_TARGETS.map((target) => [ target, - [`binaries/fs-ext-${target}-node-24.0.0.node`], + [`prebuilds/${target}/fs-native-extensions.node`], ]), ), ); @@ -94,11 +94,11 @@ export const nativeDeps = Object.freeze([ nativeFileRelatives: (target) => piTuiNativeFileByTarget[target] ?? [], }, { - id: 'fs-ext', - name: () => 'fs-ext-extra-prebuilt', - collect: 'js-and-native-file', + id: 'fs-native-extensions', + name: () => 'fs-native-extensions', + collect: 'native-file-only', parent: null, - nativeFileRelatives: (target) => fsExtNativeFileByTarget[target] ?? [], + nativeFileRelatives: (target) => fsNativeExtensionsFileByTarget[target] ?? [], }, ]); diff --git a/apps/kimi-code/src/native/kernel-file-lock.ts b/apps/kimi-code/src/native/kernel-file-lock.ts index 89a57140ec..1717e3afd6 100644 --- a/apps/kimi-code/src/native/kernel-file-lock.ts +++ b/apps/kimi-code/src/native/kernel-file-lock.ts @@ -1,12 +1,44 @@ +import { join } from 'node:path'; + import { setKernelFileLockBindingLoader, type KernelFileLockBinding, } from '@moonshot-ai/kernel-file-lock'; -import { loadNativePackage } from './native-require'; +import { loadNativePackageFile } from './native-require'; -export function installKernelFileLockNativeBinding(): void { - setKernelFileLockBindingLoader(() => - loadNativePackage('fs-ext-extra-prebuilt') ?? undefined, +interface NativeFileLockBinding { + tryLock(fd: number, offset: number, length: number, exclusive: boolean): void; + unlock(fd: number, offset: number, length: number): void; +} + +function errorCode(error: unknown): string | undefined { + return (error as NodeJS.ErrnoException | undefined)?.code; +} + +export function loadKernelFileLockNativeBinding(): KernelFileLockBinding | undefined { + const target = `${process.platform}-${process.arch}`; + const binding = loadNativePackageFile( + 'fs-native-extensions', + join('prebuilds', target, 'fs-native-extensions.node'), ); + if (binding === null) return undefined; + return { + tryLock: (fd) => { + try { + binding.tryLock(fd, 0, 0, true); + return true; + } catch (error) { + if (errorCode(error) === 'EAGAIN') return false; + throw error; + } + }, + unlock: (fd) => { + binding.unlock(fd, 0, 0); + }, + }; +} + +export function installKernelFileLockNativeBinding(): void { + setKernelFileLockBindingLoader(loadKernelFileLockNativeBinding); } diff --git a/apps/kimi-code/src/native/native-require.ts b/apps/kimi-code/src/native/native-require.ts index 6840d610b5..3287ffba98 100644 --- a/apps/kimi-code/src/native/native-require.ts +++ b/apps/kimi-code/src/native/native-require.ts @@ -28,3 +28,13 @@ export function loadNativePackage( if (nativeRequire === null) return null; return nativeRequire(packageName) as T; } + +export function loadNativePackageFile( + packageName: string, + relativePath: string, + options: NativeAssetOptions = {}, +): T | null { + const packageRoot = getNativePackageRoot(packageName, options); + if (packageRoot === null) return null; + return createRequire(import.meta.url)(join(packageRoot, relativePath)) as T; +} diff --git a/apps/kimi-code/src/native/smoke.ts b/apps/kimi-code/src/native/smoke.ts index c39b4286a4..b6bef56c0e 100644 --- a/apps/kimi-code/src/native/smoke.ts +++ b/apps/kimi-code/src/native/smoke.ts @@ -2,9 +2,9 @@ import { createRequire } from 'node:module'; import { dirname, join } from 'node:path'; import { getEmbeddedNativeAssetManifest, getNativePackageRoot } from './native-assets'; -import { loadNativePackage } from './native-require'; +import { loadKernelFileLockNativeBinding } from './kernel-file-lock'; -const smokePackages = ['@mariozechner/clipboard', '@moonshot-ai/pi-tui', 'fs-ext-extra-prebuilt']; +const smokePackages = ['@mariozechner/clipboard', '@moonshot-ai/pi-tui', 'fs-native-extensions']; // Verify pi-tui's native helper can actually be loaded through the module hook. // pi-tui computes native helper paths from process.execPath and require()s them; @@ -36,9 +36,9 @@ function smokePiTuiNativeLoad(): void { } function smokeKernelFileLockNativeLoad(): void { - const binding = loadNativePackage<{ flockSync?: unknown }>('fs-ext-extra-prebuilt'); - if (binding === null || typeof binding.flockSync !== 'function') { - throw new Error('fs-ext-extra-prebuilt loaded but flockSync is unavailable.'); + const binding = loadKernelFileLockNativeBinding(); + if (binding === undefined) { + throw new Error('fs-native-extensions binding is unavailable.'); } } diff --git a/apps/kimi-code/test/native/native-assets.test.ts b/apps/kimi-code/test/native/native-assets.test.ts index 1946a6ccc7..9d976336e0 100644 --- a/apps/kimi-code/test/native/native-assets.test.ts +++ b/apps/kimi-code/test/native/native-assets.test.ts @@ -12,7 +12,7 @@ import { type NativeAssetManifest, type NativeAssetSource, } from '#/native/native-assets'; -import { loadNativePackage } from '#/native/native-require'; +import { loadNativePackage, loadNativePackageFile } from '#/native/native-require'; function sha256(bytes: Buffer | string): string { return createHash('sha256').update(bytes).digest('hex'); @@ -125,4 +125,30 @@ describe('native assets', () => { rmSync(dir, { recursive: true, force: true }); } }); + + it('loads a package file from extracted native assets', () => { + const dir = mkdtempSync(join(tmpdir(), 'kimi-native-file-require-')); + try { + const { manifest, source } = fakeManifest({ + 'node_modules/fake-native/package.json': '{}', + 'node_modules/fake-native/prebuilds/test-target/addon.cjs': + "module.exports = { value: 'native' };\n", + }); + + const addon = loadNativePackageFile<{ value: string }>( + 'fake-native', + join('prebuilds', 'test-target', 'addon.cjs'), + { + cacheBase: dir, + manifest, + source, + version: 'test', + }, + ); + + expect(addon).toEqual({ value: 'native' }); + } finally { + rmSync(dir, { recursive: true, force: true }); + } + }); }); diff --git a/apps/kimi-code/test/scripts/native/native-deps.test.ts b/apps/kimi-code/test/scripts/native/native-deps.test.ts index 980f1c7714..4714fc20e9 100644 --- a/apps/kimi-code/test/scripts/native/native-deps.test.ts +++ b/apps/kimi-code/test/scripts/native/native-deps.test.ts @@ -42,6 +42,7 @@ describe('resolveTargetDeps', () => { expect(names).toContain('@mariozechner/clipboard'); expect(names).toContain('@mariozechner/clipboard-darwin-arm64'); expect(names).toContain('@moonshot-ai/pi-tui'); + expect(names).toContain('fs-native-extensions'); }); it('picks the right clipboard subpackage per target', () => { @@ -75,6 +76,15 @@ describe('resolveTargetDeps', () => { ]); }); + it('encodes fs-native-extensions prebuild path per target', () => { + const fsNative = resolveTargetDeps('linux-x64').find( + (d) => d.resolvedName === 'fs-native-extensions', + ); + expect(fsNative?.nativeFileRelatives).toEqual([ + 'prebuilds/linux-x64/fs-native-extensions.node', + ]); + }); + it('throws on unsupported target', () => { expect(() => resolveTargetDeps('linux-x64-musl')).toThrow(/unsupported/i); }); @@ -97,4 +107,10 @@ describe('nativeDeps registry shape', () => { expect(piTui?.collect).toBe('native-file-only'); expect(piTui?.parent).toBe(null); }); + + it('has fs-native-extensions (collect=native-file-only, no parent)', () => { + const fsNative = nativeDeps.find((d) => d.id === 'fs-native-extensions'); + expect(fsNative?.collect).toBe('native-file-only'); + expect(fsNative?.parent).toBe(null); + }); }); diff --git a/apps/kimi-code/tsdown.native.config.ts b/apps/kimi-code/tsdown.native.config.ts index bcc66236d5..c3d5642ed5 100644 --- a/apps/kimi-code/tsdown.native.config.ts +++ b/apps/kimi-code/tsdown.native.config.ts @@ -16,7 +16,7 @@ const builtins = new Set([ ...builtinModules, ...builtinModules.map((name) => `node:${name}`), ]); -const optionalNativeDependencies = new Set(['cpu-features', 'fs-ext-extra-prebuilt']); +const optionalNativeDependencies = new Set(['cpu-features', 'fs-native-extensions']); function shouldAlwaysBundle(id: string): boolean { if (builtins.has(id) || id.startsWith('node:')) return false; diff --git a/package.json b/package.json index 6ae66cc119..cbb1b17bc8 100644 --- a/package.json +++ b/package.json @@ -60,7 +60,7 @@ ] }, "engines": { - "node": ">=24.15.0" + "node": ">=24.15.0 <27" }, "packageManager": "pnpm@10.33.0" } diff --git a/packages/agent-core-v2/src/agent/fileFencing/fileFencingService.ts b/packages/agent-core-v2/src/agent/fileFencing/fileFencingService.ts index f3ef5d6d0b..aa7d5cb3a0 100644 --- a/packages/agent-core-v2/src/agent/fileFencing/fileFencingService.ts +++ b/packages/agent-core-v2/src/agent/fileFencing/fileFencingService.ts @@ -15,9 +15,9 @@ * flag on, `stale` blocks with an outside-modification conflict and * `no-baseline` blocks with a read-first reason (Edit-over-existing, or * Write over an already existing file); with the flag off nothing ever - * blocks and the verdict is marked for the did-hook. The did-hook - * re-baselines the ledger after any successful fenced call (ranged Reads - * excepted — per the ledger contract they never count as full reads) and, + * blocks and the verdict is marked for the did-hook. The did-hook records the + * revision captured by the successful fenced call (ranged Reads excepted — + * per the ledger contract they never count as full reads) and, * for a flag-off stale mark, composes a `` advisory onto the result * note; direct creation of a new file is verdict-`clean`, so it is never * advisory'd. Watcher echos of the session's own writes are absorbed by the @@ -41,7 +41,11 @@ import { ISessionFileLedger, type FileLedgerVerdict, } from '#/session/sessionFileLedger/fileLedger'; -import type { ToolAccesses } from '#/tool/toolContract'; +import { + toolFileRevision, + type ToolAccesses, + type ToolFileRevision, +} from '#/tool/toolContract'; import { IAgentFileFencingService } from './fileFencing'; @@ -102,6 +106,13 @@ function composeNote(existing: string | undefined, advisory: string): string { : `${existing}\n${advisory}`; } +function revisionForTarget( + revision: ToolFileRevision | undefined, + targetPath: string, +): ToolFileRevision | undefined { + return revision?.path === targetPath ? revision : undefined; +} + export class AgentFileFencingService extends Disposable implements IAgentFileFencingService { declare readonly _serviceBrand: undefined; @@ -163,7 +174,15 @@ export class AgentFileFencingService extends Disposable implements IAgentFileFen this.staleMarks.delete(ctx.toolCall.id); if (target === undefined || ctx.result.isError === true) return; if (target.toolName === READ_TOOL && isRangedRead(ctx.args)) return; - await this.ledger.recordBaseline(target.path); + const revision = revisionForTarget(ctx.result[toolFileRevision], target.path); + if (revision !== undefined) { + this.ledger.recordBaseline(target.path, { + exists: true, + ino: revision.ino, + mtimeMs: revision.mtimeMs, + size: revision.size, + }); + } if (mark !== undefined) { ctx.result = { ...ctx.result, note: composeNote(ctx.result.note, advisoryNote(target, mark)) }; } diff --git a/packages/agent-core-v2/src/agent/toolExecutor/toolExecutorService.ts b/packages/agent-core-v2/src/agent/toolExecutor/toolExecutorService.ts index a3d3ce8cfa..6c3512edcf 100644 --- a/packages/agent-core-v2/src/agent/toolExecutor/toolExecutorService.ts +++ b/packages/agent-core-v2/src/agent/toolExecutor/toolExecutorService.ts @@ -30,6 +30,7 @@ import { type ToolExecution, type ToolResult, type ToolUpdate, + toolFileRevision, } from '#/tool/toolContract'; import type { ToolBeforeExecuteContext, @@ -815,7 +816,12 @@ function normalizeToolResult(result: ExecutableToolResult): ToolResult { stopTurn?: boolean; truncated?: true; note?: string; - } = { output, stopTurn: result.stopTurn }; + [toolFileRevision]?: ToolResult[typeof toolFileRevision]; + } = { + output, + stopTurn: result.stopTurn, + [toolFileRevision]: result[toolFileRevision], + }; if (result.truncated === true) base.truncated = true; if (typeof result.note === 'string' && result.note.length > 0) base.note = result.note; if (result.isError === true) { diff --git a/packages/agent-core-v2/src/app/edit/fileEdit.ts b/packages/agent-core-v2/src/app/edit/fileEdit.ts index c8f7c3c08d..69eb371f61 100644 --- a/packages/agent-core-v2/src/app/edit/fileEdit.ts +++ b/packages/agent-core-v2/src/app/edit/fileEdit.ts @@ -10,6 +10,7 @@ */ import { createDecorator, type ServiceIdentifier } from '#/_base/di/instantiation'; +import type { HostFileStat } from '#/os/interface/hostFileSystem'; export interface FileEditInput { readonly path: string; @@ -20,7 +21,7 @@ export interface FileEditInput { } export type FileEditResult = - | { readonly ok: true; readonly count: number } + | { readonly ok: true; readonly count: number; readonly stat: HostFileStat } | { readonly ok: false; readonly error: string }; export interface IFileEditService { diff --git a/packages/agent-core-v2/src/app/edit/fileEditService.ts b/packages/agent-core-v2/src/app/edit/fileEditService.ts index 7601c12445..2aea5e0d3a 100644 --- a/packages/agent-core-v2/src/app/edit/fileEditService.ts +++ b/packages/agent-core-v2/src/app/edit/fileEditService.ts @@ -39,8 +39,8 @@ export class FileEditService implements IFileEditService { if (!result.ok) { return { ok: false, error: result.error }; } - await this.fs.writeText(input.path, result.rawContent); - return { ok: true, count: result.count }; + const stat = await this.fs.writeText(input.path, result.rawContent); + return { ok: true, count: result.count, stat }; } catch (error) { const code = (unwrapErrorCause(error) as { code?: unknown } | null)?.code; if (code === 'EISDIR') { diff --git a/packages/agent-core-v2/src/app/edit/tools/edit.ts b/packages/agent-core-v2/src/app/edit/tools/edit.ts index 8eda6b50a0..001d7bee42 100644 --- a/packages/agent-core-v2/src/app/edit/tools/edit.ts +++ b/packages/agent-core-v2/src/app/edit/tools/edit.ts @@ -32,9 +32,11 @@ import { IHostEnvironment } from '#/os/interface/hostEnvironment'; import { ISessionSkillCatalog } from '#/session/sessionSkillCatalog/skillCatalog'; import { ISessionWorkspaceContext } from '#/session/workspaceContext/workspaceContext'; import { + attachToolFileRevision, ToolAccesses, type BuiltinTool, type ExecutableToolResult, + makeToolFileRevision, type ToolExecution, } from '#/tool/toolContract'; import { registerTool } from '#/agent/toolRegistry/toolContribution'; @@ -135,7 +137,10 @@ export class EditTool implements BuiltinTool { return { isError: true, output: result.error }; } const word = result.count === 1 ? 'occurrence' : 'occurrences'; - return { output: `Replaced ${String(result.count)} ${word} in ${args.path}` }; + return attachToolFileRevision( + { output: `Replaced ${String(result.count)} ${word} in ${args.path}` }, + makeToolFileRevision(safePath, result.stat), + ); } } diff --git a/packages/agent-core-v2/src/app/sessionLifecycle/sessionLifecycle.ts b/packages/agent-core-v2/src/app/sessionLifecycle/sessionLifecycle.ts index 3dcb44d471..c909a1685a 100644 --- a/packages/agent-core-v2/src/app/sessionLifecycle/sessionLifecycle.ts +++ b/packages/agent-core-v2/src/app/sessionLifecycle/sessionLifecycle.ts @@ -92,6 +92,7 @@ export interface ISessionLifecycleService { list(): readonly ISessionScopeHandle[]; resume(sessionId: string): Promise; close(sessionId: string): Promise; + closeAll(): Promise; forceAbort(sessionId: string): Promise; archive(sessionId: string): Promise; restore(sessionId: string): Promise; diff --git a/packages/agent-core-v2/src/app/sessionLifecycle/sessionLifecycleService.ts b/packages/agent-core-v2/src/app/sessionLifecycle/sessionLifecycleService.ts index 85f70f8711..db23afd4eb 100644 --- a/packages/agent-core-v2/src/app/sessionLifecycle/sessionLifecycleService.ts +++ b/packages/agent-core-v2/src/app/sessionLifecycle/sessionLifecycleService.ts @@ -385,6 +385,29 @@ export class SessionLifecycleService extends Disposable implements ISessionLifec await this.closeSession(sessionId, 'close'); } + async closeAll(): Promise { + await this.beginClose(); + const failures: unknown[] = []; + for (const [sessionId, entry] of this.entries) { + try { + if (entry.phase === 'flush-failed') { + await this.forceAbort(sessionId); + continue; + } + try { + await this.close(sessionId); + } catch (error) { + if (this.entries.get(sessionId)?.phase !== 'flush-failed') throw error; + await this.forceAbort(sessionId); + } + } catch (error) { + failures.push(error); + } + } + if (failures.length === 1) throw failures[0]; + if (failures.length > 1) throw new AggregateError(failures, 'failed to close all sessions'); + } + async forceAbort(sessionId: string): Promise { const entry = this.entries.get(sessionId); if (entry === undefined) return; @@ -415,7 +438,7 @@ export class SessionLifecycleService extends Disposable implements ISessionLifec }); this.telemetry.track2('session_dirty_abort', { session_id: sessionId, reason: 'flush-failed' }); this.log.warn('force-aborting session after an ambiguous durability failure', { sessionId }); - this.dirtyAbortSession(entry); + await this.dirtyAbortSession(entry); } async archive(sessionId: string): Promise { @@ -809,7 +832,7 @@ export class SessionLifecycleService extends Disposable implements ISessionLifec override dispose(): void { this.closing = true; - for (const entry of this.entries.values()) this.dirtyAbortSession(entry); + for (const entry of this.entries.values()) void this.dirtyAbortSession(entry); super.dispose(); } @@ -833,7 +856,7 @@ export class SessionLifecycleService extends Disposable implements ISessionLifec private onLeaseLost(sessionId: string): void { this.log.error('session lease lost; tearing the session down', { sessionId }); const entry = this.entries.get(sessionId); - if (entry !== undefined) this.dirtyAbortSession(entry); + if (entry !== undefined) void this.dirtyAbortSession(entry); } private async acquireSessionLease(sessionId: string): Promise { @@ -899,8 +922,8 @@ export class SessionLifecycleService extends Disposable implements ISessionLifec } } - private dirtyAbortSession(entry: SessionEntry): void { - if (entry.dirtyAbortPromise !== undefined) return; + private dirtyAbortSession(entry: SessionEntry): Promise { + if (entry.dirtyAbortPromise !== undefined) return entry.dirtyAbortPromise; if (this.entries.get(entry.handle.id) === entry) this.entries.delete(entry.handle.id); let taskServices: IAgentTaskService[] = []; @@ -915,7 +938,7 @@ export class SessionLifecycleService extends Disposable implements ISessionLifec this.disposeSessionHandle(entry); } catch { } - entry.dirtyAbortPromise = Promise.allSettled( + const dirtyAbortPromise = Promise.allSettled( taskServices.map((tasks) => tasks.flushPersistence()), ).then(() => { try { @@ -924,7 +947,9 @@ export class SessionLifecycleService extends Disposable implements ISessionLifec } entry.lease.release(); }); - void entry.dirtyAbortPromise.catch(() => {}); + entry.dirtyAbortPromise = dirtyAbortPromise; + void dirtyAbortPromise.catch(() => {}); + return dirtyAbortPromise; } private async readMetaFromDisk( diff --git a/packages/agent-core-v2/src/os/backends/node-local/hostFsService.ts b/packages/agent-core-v2/src/os/backends/node-local/hostFsService.ts index b806fa1e56..b5f6d396ca 100644 --- a/packages/agent-core-v2/src/os/backends/node-local/hostFsService.ts +++ b/packages/agent-core-v2/src/os/backends/node-local/hostFsService.ts @@ -6,7 +6,6 @@ */ import { - appendFile, lstat, open, readFile, @@ -27,6 +26,17 @@ import { toHostFsError } from '#/os/interface/hostFsErrors'; const READ_CHUNK_SIZE = 64 * 1024; +function toHostFileStat(stat: Awaited>): HostFileStat { + return { + isFile: stat.isFile(), + isDirectory: stat.isDirectory(), + isSymbolicLink: stat.isSymbolicLink(), + size: Number(stat.size), + mtimeMs: Number(stat.mtimeMs), + ino: Number(stat.ino), + }; +} + function isUtf8Encoding(encoding: BufferEncoding): boolean { return encoding === 'utf-8' || encoding === 'utf8'; } @@ -64,19 +74,29 @@ export class HostFileSystem implements IHostFileSystem { } } - async writeText(path: string, data: string): Promise { + async writeText(path: string, data: string): Promise { + let handle: Awaited> | undefined; try { - await writeFile(path, data, 'utf8'); + handle = await open(path, 'w'); + await handle.writeFile(data, 'utf8'); + return toHostFileStat(await handle.stat()); } catch (error) { throw toHostFsError(error, { path, op: 'write' }); + } finally { + await handle?.close(); } } - async appendText(path: string, data: string): Promise { + async appendText(path: string, data: string): Promise { + let handle: Awaited> | undefined; try { - await appendFile(path, data, 'utf8'); + handle = await open(path, 'a'); + await handle.writeFile(data, 'utf8'); + return toHostFileStat(await handle.stat()); } catch (error) { throw toHostFsError(error, { path, op: 'append' }); + } finally { + await handle?.close(); } } @@ -109,7 +129,11 @@ export class HostFileSystem implements IHostFileSystem { async *readLines( path: string, - options?: { encoding?: BufferEncoding; errors?: TextDecodeErrors }, + options?: { + encoding?: BufferEncoding; + errors?: TextDecodeErrors; + onFileStat?: (stat: HostFileStat) => void; + }, ): AsyncGenerator { try { const encoding = options?.encoding ?? 'utf-8'; @@ -118,10 +142,11 @@ export class HostFileSystem implements IHostFileSystem { if (!isUtf8Encoding(encoding)) { const content = decodeTextWithErrors(await readFile(path), encoding, errors); yield* splitLinesKeepingTerminator(content); + options?.onFileStat?.(await this.stat(path)); return; } - yield* this._readUtf8Lines(path, errors); + yield* this._readUtf8Lines(path, errors, options?.onFileStat); } catch (error) { throw toHostFsError(error, { path, op: 'read' }); } @@ -130,6 +155,7 @@ export class HostFileSystem implements IHostFileSystem { private async *_readUtf8Lines( path: string, errors: TextDecodeErrors, + onFileStat?: (stat: HostFileStat) => void, ): AsyncGenerator { const fh = await open(path, 'r'); try { @@ -168,7 +194,11 @@ export class HostFileSystem implements IHostFileSystem { yield decodeTextWithErrors(line, 'utf-8', errors, pendingOffset !== 0); } } finally { - await fh.close(); + try { + onFileStat?.(toHostFileStat(await fh.stat())); + } finally { + await fh.close(); + } } } @@ -190,15 +220,7 @@ export class HostFileSystem implements IHostFileSystem { async stat(path: string): Promise { try { - const s = await nodeStat(path); - return { - isFile: s.isFile(), - isDirectory: s.isDirectory(), - isSymbolicLink: s.isSymbolicLink(), - size: s.size, - mtimeMs: s.mtimeMs, - ino: s.ino, - }; + return toHostFileStat(await nodeStat(path)); } catch (error) { throw toHostFsError(error, { path, op: 'stat' }); } diff --git a/packages/agent-core-v2/src/os/backends/node-local/tools/read.ts b/packages/agent-core-v2/src/os/backends/node-local/tools/read.ts index 95220a9e5e..02a7a80a4b 100644 --- a/packages/agent-core-v2/src/os/backends/node-local/tools/read.ts +++ b/packages/agent-core-v2/src/os/backends/node-local/tools/read.ts @@ -31,9 +31,11 @@ import { unwrapErrorCause } from '#/_base/errors/errors'; import { ISessionSkillCatalog } from '#/session/sessionSkillCatalog/skillCatalog'; import { ISessionWorkspaceContext } from '#/session/workspaceContext/workspaceContext'; import { + attachToolFileRevision, ToolAccesses, type BuiltinTool, type ExecutableToolResult, + makeToolFileRevision, type ToolExecution, } from '#/tool/toolContract'; import { registerTool } from '#/agent/toolRegistry/toolContribution'; @@ -56,6 +58,13 @@ export const MAX_BYTES: number = 100 * 1024; const PositiveLineOffsetSchema = z.number().int().min(1); const TailLineOffsetSchema = z.number().int().min(-MAX_LINES).max(-1); +function fileStatsEqual( + left: Awaited>, + right: Awaited>, +): boolean { + return left.ino === right.ino && left.mtimeMs === right.mtimeMs && left.size === right.size; +} + export const ReadInputSchema = z.object({ path: z .string() @@ -309,22 +318,40 @@ export class ReadTool implements BuiltinTool { const lineOffset = args.line_offset ?? 1; const requestedLines = args.n_lines ?? MAX_LINES; const effectiveLimit = Math.min(requestedLines, MAX_LINES); + let observedStat: Awaited> | undefined; + const onFileStat = (value: Awaited>): void => { + observedStat = value; + }; - if (lineOffset < 0) { - return await this.readTail( - safePath, - args.path, - lineOffset, - effectiveLimit, - requestedLines, - ); + const result = + lineOffset < 0 + ? await this.readTail( + safePath, + args.path, + lineOffset, + effectiveLimit, + requestedLines, + onFileStat, + ) + : await this.readForward( + safePath, + args.path, + lineOffset, + effectiveLimit, + requestedLines, + onFileStat, + ); + if (result.isError === true) return result; + observedStat ??= await this.fs.stat(safePath); + if (!fileStatsEqual(stat, observedStat)) { + return { + isError: true, + output: `"${args.path}" changed while it was being read. Retry the read.`, + }; } - return await this.readForward( - safePath, - args.path, - lineOffset, - effectiveLimit, - requestedLines, + return attachToolFileRevision( + { ...result }, + makeToolFileRevision(safePath, observedStat), ); } catch (error) { if (isTextDecodeError(error)) { @@ -343,6 +370,7 @@ export class ReadTool implements BuiltinTool { lineOffset: number, effectiveLimit: number, requestedLines: number, + onFileStat: (stat: Awaited>) => void, ): Promise { const selectedEntries: ReadLineEntry[] = []; const flags: LineEndingFlags = { hasCrLf: false, hasLf: false, hasLoneCr: false }; @@ -350,7 +378,7 @@ export class ReadTool implements BuiltinTool { let maxLinesReached = false; let collectionClosed = false; - for await (const rawLine of this.fs.readLines(safePath, { errors: 'strict' })) { + for await (const rawLine of this.fs.readLines(safePath, { errors: 'strict', onFileStat })) { if (containsNulByte(rawLine)) { return { isError: true, output: notReadableFileOutput(displayPath) }; } @@ -400,13 +428,14 @@ export class ReadTool implements BuiltinTool { lineOffset: number, effectiveLimit: number, requestedLines: number, + onFileStat: (stat: Awaited>) => void, ): Promise { const tailCount = Math.abs(lineOffset); const entries: ReadLineEntry[] = []; const flags: LineEndingFlags = { hasCrLf: false, hasLf: false, hasLoneCr: false }; let currentLineNo = 0; - for await (const rawLine of this.fs.readLines(safePath, { errors: 'strict' })) { + for await (const rawLine of this.fs.readLines(safePath, { errors: 'strict', onFileStat })) { if (containsNulByte(rawLine)) { return { isError: true, output: notReadableFileOutput(displayPath) }; } diff --git a/packages/agent-core-v2/src/os/backends/node-local/tools/write.ts b/packages/agent-core-v2/src/os/backends/node-local/tools/write.ts index 821cfb80b4..8a0449443d 100644 --- a/packages/agent-core-v2/src/os/backends/node-local/tools/write.ts +++ b/packages/agent-core-v2/src/os/backends/node-local/tools/write.ts @@ -26,9 +26,11 @@ import { unwrapErrorCause } from '#/_base/errors/errors'; import { ISessionSkillCatalog } from '#/session/sessionSkillCatalog/skillCatalog'; import { ISessionWorkspaceContext } from '#/session/workspaceContext/workspaceContext'; import { + attachToolFileRevision, ToolAccesses, type BuiltinTool, type ExecutableToolResult, + makeToolFileRevision, type ToolExecution, } from '#/tool/toolContract'; import { registerTool } from '#/agent/toolRegistry/toolContribution'; @@ -119,15 +121,17 @@ export class WriteTool implements BuiltinTool { try { const mode = args.mode ?? 'overwrite'; - if (mode === 'append') { - await this.fs.appendText(safePath, args.content); - } else { - await this.fs.writeText(safePath, args.content); - } + const stat = + mode === 'append' + ? await this.fs.appendText(safePath, args.content) + : await this.fs.writeText(safePath, args.content); const bytesWritten = Buffer.byteLength(args.content, 'utf8'); - return { - output: `${mode === 'append' ? 'Appended' : 'Wrote'} ${String(bytesWritten)} bytes to ${args.path}`, - }; + return attachToolFileRevision( + { + output: `${mode === 'append' ? 'Appended' : 'Wrote'} ${String(bytesWritten)} bytes to ${args.path}`, + }, + makeToolFileRevision(safePath, stat), + ); } catch (error) { const code = (unwrapErrorCause(error) as { code?: unknown } | null)?.code; if (code === 'ENOENT') { diff --git a/packages/agent-core-v2/src/os/interface/hostFileSystem.ts b/packages/agent-core-v2/src/os/interface/hostFileSystem.ts index 0e35262ddf..6b4bb3bf54 100644 --- a/packages/agent-core-v2/src/os/interface/hostFileSystem.ts +++ b/packages/agent-core-v2/src/os/interface/hostFileSystem.ts @@ -35,13 +35,17 @@ export interface IHostFileSystem { path: string, options?: { encoding?: BufferEncoding; errors?: TextDecodeErrors }, ): Promise; - writeText(path: string, data: string): Promise; - appendText(path: string, data: string): Promise; + writeText(path: string, data: string): Promise; + appendText(path: string, data: string): Promise; readBytes(path: string, n?: number): Promise; writeBytes(path: string, data: Uint8Array): Promise; readLines( path: string, - options?: { encoding?: BufferEncoding; errors?: TextDecodeErrors }, + options?: { + encoding?: BufferEncoding; + errors?: TextDecodeErrors; + onFileStat?: (stat: HostFileStat) => void; + }, ): AsyncGenerator; createExclusive(path: string, data: Uint8Array): Promise; /** Follows symlinks to the target (Node `stat` semantics). Use {@link lstat} when the link itself matters. */ diff --git a/packages/agent-core-v2/src/session/sessionFileLedger/fileLedger.ts b/packages/agent-core-v2/src/session/sessionFileLedger/fileLedger.ts index 5674f74556..ac3436ecba 100644 --- a/packages/agent-core-v2/src/session/sessionFileLedger/fileLedger.ts +++ b/packages/agent-core-v2/src/session/sessionFileLedger/fileLedger.ts @@ -4,7 +4,7 @@ * Defines the `ISessionFileLedger` that remembers, per normalized absolute * path, the on-disk stat tuple (`ino`, `mtimeMs`, `size`, existence) this * session last successfully read or wrote, together with the - * `sessionFsWatch` tick captured before that stat. Before every Write/Edit, + * `sessionFsWatch` tick captured when that revision is recorded. Before every Write/Edit, * `compare` stats the target again and compares the tuple directly; live dirty * signals classify watcher echoes and truncated windows but are never the * correctness gate. This avoids both debounce latency and delayed watcher @@ -47,7 +47,7 @@ export type FileLedgerVerdict = 'clean' | 'stale' | 'no-baseline'; export interface ISessionFileLedger { readonly _serviceBrand: undefined; - recordBaseline(path: string): Promise; + recordBaseline(path: string, revision: FileStatTuple): void; compare(path: string): Promise; } diff --git a/packages/agent-core-v2/src/session/sessionFileLedger/fileLedgerService.ts b/packages/agent-core-v2/src/session/sessionFileLedger/fileLedgerService.ts index fc76f809ba..ab24495dc5 100644 --- a/packages/agent-core-v2/src/session/sessionFileLedger/fileLedgerService.ts +++ b/packages/agent-core-v2/src/session/sessionFileLedger/fileLedgerService.ts @@ -2,9 +2,10 @@ * `sessionFileLedger` domain (L2) — `ISessionFileLedger` implementation. * * In-memory per-session ledger of on-disk stat tuples keyed by - * `normalizeFsWatchKey`. `recordBaseline` captures the watch tick before it - * re-stats the target, so a concurrent event cannot be absorbed into an older - * tuple. `compare` always re-stats immediately before a write decision; + * `normalizeFsWatchKey`. `recordBaseline` stores the revision observed by the + * successful file I/O together with the current watch tick, so a did-hook + * cannot accidentally bless a later external change. `compare` always + * re-stats immediately before a write decision; * watcher ticks classify the result but never replace the stat. An * unverifiable stat fails closed as `stale`. The * service also guarantees `ISessionFsWatchService` watches the session roots @@ -49,12 +50,12 @@ export class SessionFileLedger implements ISessionFileLedger { this.watch.ensureWatchedRoots(this.sessionRoots()); } - async recordBaseline(path: string): Promise { + recordBaseline(path: string, revision: FileStatTuple): void { this.ensureWatchedRootFor(path); - const tick = this.watch.currentTick; - const tuple = await this.tryStat(path); - if (tuple === undefined) return; - this.entries.set(normalizeFsWatchKey(path), { ...tuple, tick }); + this.entries.set(normalizeFsWatchKey(path), { + ...revision, + tick: this.watch.currentTick, + }); } async compare(path: string): Promise { diff --git a/packages/agent-core-v2/src/tool/toolContract.ts b/packages/agent-core-v2/src/tool/toolContract.ts index f8fafd0772..e4cf49e1c4 100644 --- a/packages/agent-core-v2/src/tool/toolContract.ts +++ b/packages/agent-core-v2/src/tool/toolContract.ts @@ -36,22 +36,47 @@ export interface ToolDelivery { readonly message: ToolDeliveryMessage; } -export interface ExecutableToolSuccessResult { +export interface ToolFileRevision { + readonly path: string; + readonly ino?: number; + readonly mtimeMs?: number; + readonly size: number; +} + +export const toolFileRevision = Symbol('toolFileRevision'); + +export function makeToolFileRevision( + path: string, + stat: { readonly ino?: number; readonly mtimeMs?: number; readonly size: number }, +): ToolFileRevision { + return { path, ino: stat.ino, mtimeMs: stat.mtimeMs, size: stat.size }; +} + +export function attachToolFileRevision( + result: T, + revision: ToolFileRevision, +): T & { readonly [toolFileRevision]: ToolFileRevision } { + return Object.defineProperty(result, toolFileRevision, { + value: revision, + enumerable: false, + }) as T & { readonly [toolFileRevision]: ToolFileRevision }; +} + +interface ExecutableToolResultBase { readonly output: ExecutableToolOutput; - readonly isError?: false | undefined; readonly stopTurn?: boolean | undefined; readonly truncated?: boolean | undefined; readonly note?: string; readonly delivery?: ToolDelivery | undefined; + readonly [toolFileRevision]?: ToolFileRevision; } -export interface ExecutableToolErrorResult { - readonly output: ExecutableToolOutput; +export interface ExecutableToolSuccessResult extends ExecutableToolResultBase { + readonly isError?: false | undefined; +} + +export interface ExecutableToolErrorResult extends ExecutableToolResultBase { readonly isError: true; - readonly stopTurn?: boolean | undefined; - readonly truncated?: boolean | undefined; - readonly note?: string; - readonly delivery?: ToolDelivery | undefined; } export type ExecutableToolResult = ExecutableToolSuccessResult | ExecutableToolErrorResult; diff --git a/packages/agent-core-v2/test/agent/fileFencing/fileFencing.test.ts b/packages/agent-core-v2/test/agent/fileFencing/fileFencing.test.ts index eac937bf9f..508f78a23f 100644 --- a/packages/agent-core-v2/test/agent/fileFencing/fileFencing.test.ts +++ b/packages/agent-core-v2/test/agent/fileFencing/fileFencing.test.ts @@ -9,7 +9,7 @@ * two Session scopes sharing one workspace (two-instance conflict). */ -import { mkdtempSync, rmSync, writeFileSync } from 'node:fs'; +import { mkdtempSync, rmSync, statSync, writeFileSync } from 'node:fs'; import { tmpdir } from 'node:os'; import { join } from 'node:path'; @@ -36,7 +36,12 @@ import { ISessionFsWatchService } from '#/session/sessionFs/fsWatch'; import { SessionFsWatchService } from '#/session/sessionFs/fsWatchService'; import { ISessionWorkspaceContext } from '#/session/workspaceContext/workspaceContext'; import { SessionWorkspaceContextService } from '#/session/workspaceContext/workspaceContextService'; -import { ToolAccesses, type ExecutableToolResult } from '#/tool/toolContract'; +import { + ToolAccesses, + type ExecutableToolResult, + makeToolFileRevision, + toolFileRevision, +} from '#/tool/toolContract'; import { AgentToolExecutorService } from '#/agent/toolExecutor/toolExecutorService'; @@ -200,13 +205,32 @@ async function runDid( ctx: ToolBeforeExecuteContext, result?: ExecutableToolResult, ): Promise { + let effectiveResult = result; + if (effectiveResult === undefined) { + const target = ctx.execution.accesses?.find((access) => access.kind === 'file'); + if (target === undefined || !['Read', 'Write', 'Edit'].includes(ctx.toolCall.name)) { + effectiveResult = { output: 'done' }; + } else { + let stat; + try { + stat = statSync(target.path); + } catch (error) { + if ((error as NodeJS.ErrnoException).code !== 'ENOENT') throw error; + stat = { size: 0 }; + } + effectiveResult = { + output: 'done', + [toolFileRevision]: makeToolFileRevision(target.path, stat), + }; + } + } const did: ToolDidExecuteContext = { turnId: ctx.turnId, signal: ctx.signal, toolCall: ctx.toolCall, toolCalls: [ctx.toolCall], args: ctx.args, - result: result ?? { output: 'done' }, + result: effectiveResult, }; await world.executor.hooks.onDidExecuteTool.run(did); return did; @@ -220,6 +244,10 @@ async function runOk( ): Promise { const ctx = await runBefore(world, beforeCtx(toolName, path, opts)); expect(ctx.decision?.block).not.toBe(true); + if (toolName === 'Write') { + const args = ctx.args as { readonly content: string; readonly mode?: 'overwrite' | 'append' }; + writeFileSync(path, args.content, { flag: args.mode === 'append' ? 'a' : 'w' }); + } return runDid(world, ctx); } @@ -295,6 +323,25 @@ describe('AgentFileFencingService', () => { expect(ctx.decision?.reason).toContain('changed on disk since'); }); + it('keeps the revision captured by Read when the file changes before the did-hook', async () => { + multiServer = true; + const world = setup(); + const file = join(world.env.workDir, 'a.txt'); + writeFileSync(file, 'hello'); + const ctx = await runBefore(world, beforeCtx('Read', file)); + const readRevision = makeToolFileRevision(file, statSync(file)); + + writeFileSync(file, 'changed after read'); + await runDid(world, ctx, { + output: 'hello', + [toolFileRevision]: readRevision, + }); + + const edit = await runBefore(world, beforeCtx('Edit', file)); + expect(edit.decision?.block).toBe(true); + expect(edit.decision?.reason).toContain('changed on disk since'); + }); + it('wraps an execution override installed by an earlier hook', async () => { const world = setup(); const file = join(world.env.workDir, 'a.txt'); @@ -366,17 +413,17 @@ describe('AgentFileFencingService', () => { const file = join(world.env.workDir, 'a.txt'); writeFileSync(file, 'hello'); await runOk(world, 'Read', file); - expect(world.env.statCalls()).toBe(1); + expect(world.env.statCalls()).toBe(0); foldChange(world, 'a.txt', 'modified'); const ctx = await runBefore(world, beforeCtx('Edit', file)); expect(ctx.decision?.block).not.toBe(true); - expect(world.env.statCalls()).toBe(2); + expect(world.env.statCalls()).toBe(1); const again = await runBefore(world, beforeCtx('Edit', file)); expect(again.decision?.block).not.toBe(true); - expect(world.env.statCalls()).toBe(3); + expect(world.env.statCalls()).toBe(2); }); it('resolves a truncated window by stat punch: unchanged passes, changed blocks', async () => { diff --git a/packages/agent-core-v2/test/agent/plan/injection/planModeInjection.test.ts b/packages/agent-core-v2/test/agent/plan/injection/planModeInjection.test.ts index 87df3bd290..d38b02bf78 100644 --- a/packages/agent-core-v2/test/agent/plan/injection/planModeInjection.test.ts +++ b/packages/agent-core-v2/test/agent/plan/injection/planModeInjection.test.ts @@ -66,7 +66,7 @@ describe('PlanModeService dynamic injection content', () => { hostFs: createFakeHostFs({ mkdir: vi.fn().mockResolvedValue(undefined), readText: (path: string) => readText(path), - writeText: vi.fn(async () => undefined), + writeText: vi.fn(async () => ({ isFile: true, isDirectory: false, size: 0 })), }), })); context = ctx.get(IAgentContextMemoryService); @@ -148,7 +148,7 @@ describe('PlanModeService dynamic injection cadence', () => { hostFs: createFakeHostFs({ mkdir: vi.fn().mockResolvedValue(undefined), readText: async () => '', - writeText: vi.fn(async () => undefined), + writeText: vi.fn(async () => ({ isFile: true, isDirectory: false, size: 0 })), }), })); context = ctx.get(IAgentContextMemoryService); diff --git a/packages/agent-core-v2/test/agent/plan/plan.test.ts b/packages/agent-core-v2/test/agent/plan/plan.test.ts index 85eb680ed6..67ff55a059 100644 --- a/packages/agent-core-v2/test/agent/plan/plan.test.ts +++ b/packages/agent-core-v2/test/agent/plan/plan.test.ts @@ -11,7 +11,7 @@ import { IAgentPlanService, type PlanData } from '#/agent/plan/plan'; import { IAgentPermissionRulesService } from '#/agent/permissionRules/permissionRules'; import { IAgentProfileService } from '#/agent/profile/profile'; import { IAgentScopeContext } from '#/agent/scopeContext/scopeContext'; -import type { IHostFileSystem } from '#/os/interface/hostFileSystem'; +import type { HostFileStat, IHostFileSystem } from '#/os/interface/hostFileSystem'; import { ISessionContext } from '#/session/sessionContext/sessionContext'; import type { ISessionProcessRunner } from '#/session/process/processRunner'; import { createFakeHostFs, createFakeProcessRunner } from '../../tools/fixtures/fake-exec'; @@ -27,6 +27,10 @@ interface PlanFakes { readonly runner: ISessionProcessRunner; } +function textFileStat(content: string = ''): HostFileStat { + return { isFile: true, isDirectory: false, size: Buffer.byteLength(content) }; +} + function createPlanFakes(overrides: Partial = {}): PlanFakes { const fs = createFakeHostFs({ mkdir: vi.fn().mockResolvedValue(undefined), @@ -56,6 +60,7 @@ function createPlanFileFakes( const readText = vi.fn(async (path: string) => files.get(path) ?? ''); const writeText = vi.fn(async (path: string, content: string) => { files.set(path, content); + return textFileStat(content); }); return { files, @@ -172,7 +177,7 @@ describe('Plan service', () => { it('enters plan mode without starting a model turn and prepares the plan directory', async () => { const mkdir = vi.fn().mockResolvedValue(undefined); - const writeText = vi.fn().mockResolvedValue(0); + const writeText = vi.fn().mockResolvedValue(textFileStat()); const cwd = await makeTempDir('kimi-plan-entry-'); useFakes(createPlanFakes({ mkdir, writeText })); profile.update({ cwd }); @@ -192,7 +197,7 @@ describe('Plan service', () => { it('derives the plan path from the agent homedir on enter and restore', async () => { const cwd = await makeTempDir('kimi-plan-path-'); useFakes(createPlanFakes({ - writeText: vi.fn(async (_path: string, _content: string): Promise => {}), + writeText: vi.fn(async () => textFileStat()), })); profile.update({ cwd }); await plan.enter('stable-plan'); @@ -219,7 +224,7 @@ describe('Plan service', () => { it('keeps the plan path under the agent homedir when the profile cwd is empty', async () => { useFakes(createPlanFakes({ - writeText: vi.fn(async (_path: string, _content: string): Promise => {}), + writeText: vi.fn(async () => textFileStat()), })); profile.update({ cwd: '' }); @@ -475,8 +480,9 @@ describe('Plan service', () => { async (toolName) => { const files = new Map(); const readText = vi.fn(async (path: string) => files.get(path) ?? ''); - const writeText = vi.fn(async (path: string, content: string): Promise => { + const writeText = vi.fn(async (path: string, content: string) => { files.set(path, content); + return textFileStat(content); }); useFakes(createPlanFakes({ readText, writeText })); const cwd = await makeTempDir('kimi-plan-write-tool-'); @@ -516,8 +522,9 @@ describe('Plan service', () => { it('keeps explicit deny rules above active plan file writes', async () => { const files = new Map(); - const writeText = vi.fn(async (path: string, content: string): Promise => { + const writeText = vi.fn(async (path: string, content: string) => { files.set(path, content); + return textFileStat(content); }); useFakes(createPlanFakes({ writeText })); const cwd = await makeTempDir('kimi-plan-deny-write-'); diff --git a/packages/agent-core-v2/test/agent/toolExecutor/toolExecutor.test.ts b/packages/agent-core-v2/test/agent/toolExecutor/toolExecutor.test.ts index e8399f70da..3969ce0787 100644 --- a/packages/agent-core-v2/test/agent/toolExecutor/toolExecutor.test.ts +++ b/packages/agent-core-v2/test/agent/toolExecutor/toolExecutor.test.ts @@ -7,6 +7,7 @@ import { SyncDescriptor } from '#/_base/di/descriptors'; import { DisposableStore } from '#/_base/di/lifecycle'; import { createServices, type TestInstantiationService } from '#/_base/di/test'; import { + attachToolFileRevision, ToolAccesses, type ExecutableTool, type ExecutableToolContext, @@ -14,6 +15,8 @@ import { type ToolExecution, type ToolResult, type ToolUpdate, + makeToolFileRevision, + toolFileRevision, } from '#/tool/toolContract'; import { IAgentToolExecutorService } from '#/agent/toolExecutor/toolExecutor'; import { AgentToolExecutorService, parseToolCallArguments } from '#/agent/toolExecutor/toolExecutorService'; @@ -696,6 +699,27 @@ describe('AgentToolExecutorService', () => { ]); }); + it('passes internal file revisions to did-hooks without serializing them', async () => { + const revision = makeToolFileRevision('/tmp/a.txt', { + size: 5, + mtimeMs: 1000, + ino: 1, + }); + const tool = new TestTool('read-like', { + result: attachToolFileRevision({ output: 'hello' }, revision), + }); + registry.register(tool); + let observed; + executor.hooks.onDidExecuteTool.register('observe-file-revision', async (ctx) => { + observed = ctx.result[toolFileRevision]; + }); + + const results = await execute([toolCall('call_read', 'read-like', {})]); + + expect(observed).toEqual(revision); + expect(JSON.stringify(results)).not.toContain('/tmp/a.txt'); + }); + it('onDidExecuteTool can replace the final tool result', async () => { const tool = new TestTool('echo'); registry.register(tool); diff --git a/packages/agent-core-v2/test/app/edit/tools/edit.test.ts b/packages/agent-core-v2/test/app/edit/tools/edit.test.ts index 5bbaf6853b..69bce87d29 100644 --- a/packages/agent-core-v2/test/app/edit/tools/edit.test.ts +++ b/packages/agent-core-v2/test/app/edit/tools/edit.test.ts @@ -27,7 +27,12 @@ import { HostFileSystem } from '#/os/backends/node-local/hostFsService'; import { HostFsError, OsFsErrors } from '#/os/interface/hostFsErrors'; import { IHostFileSystem } from '#/os/interface/hostFileSystem'; import { ISessionWorkspaceContext } from '#/session/workspaceContext/workspaceContext'; -import type { ExecutableToolContext, ExecutableToolResult, ToolExecution } from '#/tool/toolContract'; +import { + type ExecutableToolContext, + type ExecutableToolResult, + type ToolExecution, + toolFileRevision, +} from '#/tool/toolContract'; const signal = new AbortController().signal; const PERMISSIVE_WORKSPACE = stubWorkspaceContext('/'); @@ -56,8 +61,18 @@ function createSpiedEditFs( ) { const readText = options.readText ?? vi.fn(async () => ''); const writeText = options.writeText ?? vi.fn(async () => undefined); + const writeTextWithStat = async (path: string, data: string) => { + await (writeText as (path: string, data: string) => Promise)(path, data); + return { + isFile: true, + isDirectory: false, + size: Buffer.byteLength(data), + mtimeMs: 1000, + ino: 1, + }; + }; const stat = vi.fn(async () => ({ isFile: true, isDirectory: false, size: 0 })); - const fs = { readText, writeText, stat } as unknown as IHostFileSystem; + const fs = { readText, writeText: writeTextWithStat, stat } as unknown as IHostFileSystem; return { fs, readText, writeText }; } @@ -211,6 +226,12 @@ describe('EditTool', () => { expect(result.output).toContain('Replaced 1 occurrence'); expect(writeText).toHaveBeenCalledWith('/tmp/a.txt', 'alpha gamma'); + expect(result[toolFileRevision]).toEqual({ + path: '/tmp/a.txt', + size: 11, + mtimeMs: 1000, + ino: 1, + }); }); it('expands leading tilde paths using the kaos home directory', async () => { diff --git a/packages/agent-core-v2/test/app/externalHooksRunner/integration.test.ts b/packages/agent-core-v2/test/app/externalHooksRunner/integration.test.ts index 632504e46d..b5a20044a3 100644 --- a/packages/agent-core-v2/test/app/externalHooksRunner/integration.test.ts +++ b/packages/agent-core-v2/test/app/externalHooksRunner/integration.test.ts @@ -200,6 +200,7 @@ function stubSessionLifecycle(): ISessionLifecycleService { list: () => [], resume: async () => undefined, close: async () => {}, + closeAll: async () => {}, forceAbort: async () => {}, archive: async () => {}, restore: async () => undefined, diff --git a/packages/agent-core-v2/test/app/sessionExport/sessionExport.test.ts b/packages/agent-core-v2/test/app/sessionExport/sessionExport.test.ts index 2020035dac..911d34a13f 100644 --- a/packages/agent-core-v2/test/app/sessionExport/sessionExport.test.ts +++ b/packages/agent-core-v2/test/app/sessionExport/sessionExport.test.ts @@ -864,6 +864,7 @@ function registerSessionExportServices( list: () => (options.lifecycleHandle === undefined ? [] : [options.lifecycleHandle]), resume: async () => options.lifecycleHandle, close: async () => {}, + closeAll: async () => {}, forceAbort: async () => {}, archive: async () => {}, restore: async () => options.lifecycleHandle, diff --git a/packages/agent-core-v2/test/app/sessionLifecycle/sessionLifecycle.test.ts b/packages/agent-core-v2/test/app/sessionLifecycle/sessionLifecycle.test.ts index af754cda92..ecaa3c0793 100644 --- a/packages/agent-core-v2/test/app/sessionLifecycle/sessionLifecycle.test.ts +++ b/packages/agent-core-v2/test/app/sessionLifecycle/sessionLifecycle.test.ts @@ -1600,6 +1600,74 @@ describe('SessionLifecycleService', () => { }); }); + it('dirty-aborts an active session when closeAll hits a durability failure', async () => { + const root = await makeTmpRoot(); + const { seeds, appendLog, registry, storage, docs } = realAlsSeeds(root); + const svc = build(seeds); + await svc.create({ sessionId: 's1', workDir: '/tmp/proj' }); + await docs.set('sessions/wd_stub/s1', 'state.json', { + id: 's1', + version: 2, + cwd: '/tmp/proj', + createdAt: 1, + updatedAt: 1, + archived: false, + agents: {}, + custom: {}, + }); + const originalAppend = storage.append.bind(storage); + storage.append = async (...args) => { + if (args[0].startsWith('sessions/wd_stub/s1')) throw new Error('durable append failed'); + return originalAppend(...args); + }; + appendLog.append('sessions/wd_stub/s1/agents/main', 'wire.jsonl', { tail: true }); + + await svc.closeAll(); + + expect(registry.resolve('s1')).toBeUndefined(); + expect(await docs.get<{ custom?: { dirtyAbort?: { reason?: string } } }>( + 'sessions/wd_stub/s1', + 'state.json', + )).toMatchObject({ custom: { dirtyAbort: { reason: 'flush-failed' } } }); + const successor = await new CrossProcessLockService().acquire(leaseFile(root, 's1')); + successor.release(); + }); + + it('includes an already flush-failed session when closeAll drains materialized entries', async () => { + const root = await makeTmpRoot(); + const { seeds, appendLog, registry, storage, docs } = realAlsSeeds(root); + const svc = build(seeds); + await svc.create({ sessionId: 's1', workDir: '/tmp/proj' }); + await docs.set('sessions/wd_stub/s1', 'state.json', { + id: 's1', + version: 2, + cwd: '/tmp/proj', + createdAt: 1, + updatedAt: 1, + archived: false, + agents: {}, + custom: {}, + }); + const failure = new Error('durable append failed'); + const originalAppend = storage.append.bind(storage); + storage.append = async (...args) => { + if (args[0].startsWith('sessions/wd_stub/s1')) throw failure; + return originalAppend(...args); + }; + appendLog.append('sessions/wd_stub/s1/agents/main', 'wire.jsonl', { tail: true }); + await expect(svc.close('s1')).rejects.toBe(failure); + + await svc.closeAll(); + + expect(registry.resolve('s1')).toBeUndefined(); + expect(await docs.get<{ custom?: { dirtyAbort?: { reason?: string } } }>( + 'sessions/wd_stub/s1', + 'state.json', + )).toMatchObject({ custom: { dirtyAbort: { reason: 'flush-failed' } } }); + const successor = await new CrossProcessLockService().acquire(leaseFile(root, 's1')); + successor.release(); + }); + it('closing one session does not wait for another session append', async () => { const root = await makeTmpRoot(); const { seeds, appendLog, storage } = realAlsSeeds(root); diff --git a/packages/agent-core-v2/test/os/backends/node-local/tools/read.test.ts b/packages/agent-core-v2/test/os/backends/node-local/tools/read.test.ts index 292861964a..daf63f8697 100644 --- a/packages/agent-core-v2/test/os/backends/node-local/tools/read.test.ts +++ b/packages/agent-core-v2/test/os/backends/node-local/tools/read.test.ts @@ -23,7 +23,7 @@ import { PathSecurityError } from '#/tool/path-access'; import { MEDIA_SNIFF_BYTES } from '#/agent/media/file-type'; import type { ISessionSkillCatalog } from '#/session/sessionSkillCatalog/skillCatalog'; import { stubWorkspaceContext } from '../../../../session/workspaceContext/stub-workspace-context'; -import type { IHostFileSystem } from '#/os/interface/hostFileSystem'; +import type { HostFileStat, IHostFileSystem } from '#/os/interface/hostFileSystem'; import { MAX_BYTES, MAX_LINE_LENGTH, @@ -33,7 +33,12 @@ import { ReadTool, } from '#/os/backends/node-local/tools/read'; import type { IHostEnvironment } from '#/os/interface/hostEnvironment'; -import type { ExecutableToolContext, ExecutableToolResult, ToolExecution } from '#/tool/toolContract'; +import { + type ExecutableToolContext, + type ExecutableToolResult, + type ToolExecution, + toolFileRevision, +} from '#/tool/toolContract'; const signal = new AbortController().signal; const PERMISSIVE_WORKSPACE = stubWorkspaceContext('/'); @@ -220,6 +225,44 @@ describe('ReadTool', () => { '2 lines read from file starting from line 1. Total lines in file: 2. End of file reached.', ), }); + expect(result[toolFileRevision]).toEqual({ + path: '/tmp/a.txt', + size: 11, + mtimeMs: undefined, + ino: undefined, + }); + }); + + it('rejects a file whose revision changes while it is being read', async () => { + const initial: HostFileStat = { + isFile: true, + isDirectory: false, + size: 6, + mtimeMs: 1000, + ino: 1, + }; + const changed: HostFileStat = { ...initial, size: 7, mtimeMs: 1001 }; + const readLines = vi.fn().mockImplementation(async function* ( + _path: string, + options?: { onFileStat?: (stat: HostFileStat) => void }, + ): AsyncGenerator { + yield 'alpha\n'; + options?.onFileStat?.(changed); + }); + const fs = { + readBytes: vi.fn(async () => Buffer.from('alpha\n')), + readLines, + stat: vi.fn(async () => initial), + } as unknown as IHostFileSystem; + const tool = new ReadTool(fs, createTestEnv(), PERMISSIVE_WORKSPACE); + + const result = await execute(tool, { path: '/tmp/a.txt' }); + + expect(result).toEqual({ + isError: true, + output: '"/tmp/a.txt" changed while it was being read. Retry the read.', + }); + expect(result[toolFileRevision]).toBeUndefined(); }); it('stats the resolved target so symlinked files stay readable', async () => { @@ -347,7 +390,10 @@ describe('ReadTool', () => { ), ); expect(readBytes).toHaveBeenCalledWith('/tmp/external.txt', MEDIA_SNIFF_BYTES); - expect(readLines).toHaveBeenCalledWith('/tmp/external.txt', { errors: 'strict' }); + expect(readLines).toHaveBeenCalledWith('/tmp/external.txt', { + errors: 'strict', + onFileStat: expect.any(Function), + }); }); it('returns a friendly error for missing files before sniffing bytes', async () => { @@ -393,7 +439,10 @@ describe('ReadTool', () => { ), ); expect(readBytes).toHaveBeenCalledWith('/home/test/notes/today.txt', MEDIA_SNIFF_BYTES); - expect(readLines).toHaveBeenCalledWith('/home/test/notes/today.txt', { errors: 'strict' }); + expect(readLines).toHaveBeenCalledWith('/home/test/notes/today.txt', { + errors: 'strict', + onFileStat: expect.any(Function), + }); }); it('blocks sensitive files independently from workspace access', async () => { diff --git a/packages/agent-core-v2/test/os/backends/node-local/tools/write.test.ts b/packages/agent-core-v2/test/os/backends/node-local/tools/write.test.ts index 894d792e54..5c4983f5eb 100644 --- a/packages/agent-core-v2/test/os/backends/node-local/tools/write.test.ts +++ b/packages/agent-core-v2/test/os/backends/node-local/tools/write.test.ts @@ -18,7 +18,12 @@ import type { HostFileStat, IHostFileSystem } from '#/os/interface/hostFileSyste import { stubWorkspaceContext } from '../../../../session/workspaceContext/stub-workspace-context'; import { type WriteInput, WriteInputSchema, WriteTool } from '#/os/backends/node-local/tools/write'; import type { IHostEnvironment } from '#/os/interface/hostEnvironment'; -import type { ExecutableToolContext, ExecutableToolResult, ToolExecution } from '#/tool/toolContract'; +import { + type ExecutableToolContext, + type ExecutableToolResult, + type ToolExecution, + toolFileRevision, +} from '#/tool/toolContract'; const signal = new AbortController().signal; const PERMISSIVE_WORKSPACE = stubWorkspaceContext('/'); @@ -47,8 +52,8 @@ function createTestEnv(home = '/home'): IHostEnvironment { interface WriteFsOptions { readText?: (path: string) => Promise; - writeText?: (path: string, data: string) => Promise; - appendText?: (path: string, data: string) => Promise; + writeText?: (path: string, data: string) => Promise; + appendText?: (path: string, data: string) => Promise; stat?: (path: string) => Promise; mkdir?: (path: string) => Promise; } @@ -60,8 +65,26 @@ function createWriteFs(options: WriteFsOptions = {}) { throw Object.assign(new Error('ENOENT: no such file or directory'), { code: 'ENOENT' }); }), ); - const writeText = vi.fn(options.writeText ?? (async () => {})); - const appendText = vi.fn(options.appendText ?? (async () => {})); + const writeText = vi.fn( + options.writeText ?? + (async (_path: string, data: string) => ({ + isFile: true, + isDirectory: false, + size: Buffer.byteLength(data), + mtimeMs: 1000, + ino: 1, + })), + ); + const appendText = vi.fn( + options.appendText ?? + (async (_path: string, data: string) => ({ + isFile: true, + isDirectory: false, + size: Buffer.byteLength(data), + mtimeMs: 1000, + ino: 1, + })), + ); const stat = vi.fn( options.stat ?? (async () => ({ isFile: false, isDirectory: true, size: 0 })), ); @@ -192,6 +215,12 @@ describe('WriteTool', () => { expect(writeText).toHaveBeenCalledWith('/tmp/new.txt', 'hello'); expect(result.output).toContain('Wrote 5 bytes'); + expect(result[toolFileRevision]).toEqual({ + path: '/tmp/new.txt', + size: 5, + mtimeMs: 1000, + ino: 1, + }); }); it('expands leading tilde paths using the kaos home directory', async () => { diff --git a/packages/agent-core-v2/test/session/sessionFileLedger/fileLedger.test.ts b/packages/agent-core-v2/test/session/sessionFileLedger/fileLedger.test.ts index 7cc1ac4ecd..9e01a642e1 100644 --- a/packages/agent-core-v2/test/session/sessionFileLedger/fileLedger.test.ts +++ b/packages/agent-core-v2/test/session/sessionFileLedger/fileLedger.test.ts @@ -15,6 +15,7 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; import { LifecycleScope } from '#/_base/di/scope'; import { createScopedTestHost, stubPair, type ScopedTestHost } from '#/_base/di/test'; +import { unwrapErrorCause } from '#/_base/errors/errors'; import { HostFileSystem } from '#/os/backends/node-local/hostFsService'; import { IHostFileSystem } from '#/os/interface/hostFileSystem'; import { IHostFsWatchService } from '#/os/interface/hostFsWatch'; @@ -62,6 +63,7 @@ function countingHostFs(poisonedPaths: Set): { interface World { readonly workDir: string; readonly outsideDir: string; + readonly fs: IHostFileSystem; readonly ledger: ISessionFileLedger; readonly watch: ISessionFsWatchService; readonly workspace: ISessionWorkspaceContext; @@ -97,6 +99,7 @@ function makeWorld(): World { return { workDir, outsideDir, + fs, ledger: session.accessor.get(ISessionFileLedger), watch: session.accessor.get(ISessionFsWatchService), workspace: session.accessor.get(ISessionWorkspaceContext), @@ -106,6 +109,22 @@ function makeWorld(): World { }; } +async function recordCurrentBaseline(world: World, path: string): Promise { + try { + const stat = await world.fs.stat(path); + world.ledger.recordBaseline(path, { + exists: true, + ino: stat.ino, + mtimeMs: stat.mtimeMs, + size: stat.size, + }); + } catch (error) { + const code = (unwrapErrorCause(error) as NodeJS.ErrnoException).code; + if (code !== 'ENOENT' && code !== 'ENOTDIR') throw error; + world.ledger.recordBaseline(path, { exists: false }); + } +} + function foldJunkEvents(world: World, count: number = JUNK_EVENT_COUNT): void { for (let i = 0; i < count; i++) world.fake.fire(`junk-${i}.tmp`, 'created'); vi.advanceTimersByTime(200); @@ -129,7 +148,7 @@ describe('SessionFileLedger', () => { const file = join(world.workDir, 'a.txt'); writeFileSync(file, 'hello'); - await world.ledger.recordBaseline(file); + await recordCurrentBaseline(world, file); expect(await world.ledger.compare(file)).toBe('clean'); }); @@ -161,7 +180,7 @@ describe('SessionFileLedger', () => { const world = makeWorld(); const file = join(world.workDir, 'a.txt'); writeFileSync(file, 'hello'); - await world.ledger.recordBaseline(file); + await recordCurrentBaseline(world, file); writeFileSync(file, 'hello world'); world.fake.fire('a.txt', 'modified'); @@ -174,7 +193,7 @@ describe('SessionFileLedger', () => { const world = makeWorld(); const file = join(world.workDir, 'a.txt'); writeFileSync(file, 'hello'); - await world.ledger.recordBaseline(file); + await recordCurrentBaseline(world, file); writeFileSync(file, 'hello world'); world.fake.fire('a.txt', 'modified'); @@ -186,7 +205,7 @@ describe('SessionFileLedger', () => { const world = makeWorld(); const file = join(world.workDir, 'a.txt'); writeFileSync(file, 'hello'); - await world.ledger.recordBaseline(file); + await recordCurrentBaseline(world, file); expect(world.statCalls()).toBe(1); world.fake.fire('a.txt', 'modified'); @@ -203,7 +222,7 @@ describe('SessionFileLedger', () => { const world = makeWorld(); const file = join(world.workDir, 'a.txt'); writeFileSync(file, 'hello'); - await world.ledger.recordBaseline(file); + await recordCurrentBaseline(world, file); foldJunkEvents(world); expect(world.watch.rootDirtyTickFor(world.workDir)).toBeGreaterThan(0); @@ -218,7 +237,7 @@ describe('SessionFileLedger', () => { const world = makeWorld(); const file = join(world.workDir, 'a.txt'); writeFileSync(file, 'hello'); - await world.ledger.recordBaseline(file); + await recordCurrentBaseline(world, file); writeFileSync(file, 'hello world'); foldJunkEvents(world); @@ -230,14 +249,14 @@ describe('SessionFileLedger', () => { const world = makeWorld(); const file = join(world.workDir, 'a.txt'); writeFileSync(file, 'hello'); - await world.ledger.recordBaseline(file); + await recordCurrentBaseline(world, file); rmSync(file); world.fake.fire('a.txt', 'deleted'); vi.advanceTimersByTime(200); expect(await world.ledger.compare(file)).toBe('stale'); - await world.ledger.recordBaseline(file); + await recordCurrentBaseline(world, file); expect(await world.ledger.compare(file)).toBe('clean'); writeFileSync(file, 'recreated'); @@ -253,13 +272,13 @@ describe('SessionFileLedger', () => { expect(await world.ledger.compare(file)).toBe('no-baseline'); - await world.ledger.recordBaseline(file); + await recordCurrentBaseline(world, file); expect(await world.ledger.compare(file)).toBe('clean'); writeFileSync(file, 'hello world'); expect(await world.ledger.compare(file)).toBe('stale'); - await world.ledger.recordBaseline(file); + await recordCurrentBaseline(world, file); expect(await world.ledger.compare(file)).toBe('clean'); rmSync(file); @@ -289,13 +308,13 @@ describe('SessionFileLedger', () => { const world = makeWorld(); const file = join(world.workDir, 'a.txt'); writeFileSync(file, 'hello'); + await recordCurrentBaseline(world, file); world.poisonedPaths.add(file); - await world.ledger.recordBaseline(file); expect(await world.ledger.compare(file)).toBe('stale'); world.poisonedPaths.clear(); - await world.ledger.recordBaseline(file); + await recordCurrentBaseline(world, file); world.poisonedPaths.add(file); world.fake.fire('a.txt', 'modified'); vi.advanceTimersByTime(200); diff --git a/packages/agent-core-v2/test/session/sessionFs/fsService.test.ts b/packages/agent-core-v2/test/session/sessionFs/fsService.test.ts index 27182479bb..57a36ccddd 100644 --- a/packages/agent-core-v2/test/session/sessionFs/fsService.test.ts +++ b/packages/agent-core-v2/test/session/sessionFs/fsService.test.ts @@ -99,8 +99,8 @@ function fakeFs( if (c === undefined) throw enoent(p); return c; }, - writeText: async () => {}, - appendText: async () => {}, + writeText: async () => ({ isFile: true, isDirectory: false, size: 0 }), + appendText: async () => ({ isFile: true, isDirectory: false, size: 0 }), readBytes: async (p, n) => { const c = fileMap.get(p); if (c === undefined) throw enoent(p); diff --git a/packages/agent-core-v2/test/session/workspaceCommand/workspaceCommand.test.ts b/packages/agent-core-v2/test/session/workspaceCommand/workspaceCommand.test.ts index b61afc68ae..a54ce3507e 100644 --- a/packages/agent-core-v2/test/session/workspaceCommand/workspaceCommand.test.ts +++ b/packages/agent-core-v2/test/session/workspaceCommand/workspaceCommand.test.ts @@ -63,7 +63,7 @@ class MemoryHostFs implements IHostFileSystem { return text; } - async writeText(path: string, data: string): Promise { + async writeText(path: string, data: string): Promise { const pause = this.nextWritePause; if (pause !== undefined) { this.nextWritePause = undefined; @@ -76,10 +76,13 @@ class MemoryHostFs implements IHostFileSystem { } } this.files.set(path, data); + return { isFile: true, isDirectory: false, size: Buffer.byteLength(data) }; } - async appendText(path: string, data: string): Promise { - this.files.set(path, (this.files.get(path) ?? '') + data); + async appendText(path: string, data: string): Promise { + const content = (this.files.get(path) ?? '') + data; + this.files.set(path, content); + return { isFile: true, isDirectory: false, size: Buffer.byteLength(content) }; } pauseNextWrite(): { readonly started: Promise; readonly release: () => void } { diff --git a/packages/kap-server/src/start.ts b/packages/kap-server/src/start.ts index a1ba7b9f47..05311aac88 100644 --- a/packages/kap-server/src/start.ts +++ b/packages/kap-server/src/start.ts @@ -323,7 +323,10 @@ export async function startServer(opts: ServerStartOptions = {}): Promise | undefined; const close = (): Promise => { - closePromise ??= doClose(); + closePromise ??= doClose().catch((error) => { + closePromise = undefined; + throw error; + }); return closePromise; }; const doClose = async (): Promise => { @@ -345,13 +348,7 @@ export async function startServer(opts: ServerStartOptions = {}): Promise 0) { const committed = await countCommittedPrefix(this.filePath, lines); if (committed > 0) { @@ -398,6 +416,23 @@ export class SessionEventJournal { return true; } + private async repairTail(): Promise { + const repair = this.tailRepair; + if (repair === undefined) return; + const handle = await openFile(this.filePath, repair.kind === 'truncate' ? 'r+' : 'a'); + try { + if (repair.kind === 'truncate') { + await handle.truncate(repair.byteLength); + } else { + await handle.writeFile('\n', 'utf8'); + } + await handle.sync(); + this.tailRepair = undefined; + } finally { + await handle.close(); + } + } + private buildHeaderLine(): string | undefined { if (this.currentEpoch === undefined) return undefined; const header: JournalHeaderLine = { @@ -462,21 +497,25 @@ function isEventEnvelope(value: unknown): value is EventEnvelope { interface RawJournalLine { raw: string; terminated: boolean; + startOffset: number; } async function* readLines(filePath: string): AsyncIterable { let buffered = ''; + let offset = 0; const stream = createReadStream(filePath, { encoding: 'utf8' }); for await (const chunk of stream) { buffered += chunk; let newlineIndex = buffered.indexOf('\n'); while (newlineIndex !== -1) { - yield { raw: buffered.slice(0, newlineIndex), terminated: true }; + const raw = buffered.slice(0, newlineIndex); + yield { raw, terminated: true, startOffset: offset }; + offset += Buffer.byteLength(raw, 'utf8') + 1; buffered = buffered.slice(newlineIndex + 1); newlineIndex = buffered.indexOf('\n'); } } - if (buffered.length > 0) yield { raw: buffered, terminated: false }; + if (buffered.length > 0) yield { raw: buffered, terminated: false, startOffset: offset }; } async function quarantineCorruptJournal(filePath: string, logger: JournalLogger): Promise { diff --git a/packages/kap-server/test/boot.test.ts b/packages/kap-server/test/boot.test.ts index 7cb9b129d3..e765b01b63 100644 --- a/packages/kap-server/test/boot.test.ts +++ b/packages/kap-server/test/boot.test.ts @@ -190,6 +190,28 @@ describe('server-v2 boot', () => { server = undefined; }); + it('does not dispose the server when lifecycle closeAll fails', async () => { + home = await mkdtemp(join(tmpdir(), 'kimi-server-v2-close-failure-')); + server = await startServer({ + host: '127.0.0.1', + port: 0, + homeDir: home, + logLevel: 'silent', + }); + const lifecycle = server.core.accessor.get(ISessionLifecycleService); + const originalCloseAll = lifecycle.closeAll.bind(lifecycle); + const failure = new Error('session shutdown failed'); + lifecycle.closeAll = async () => { + throw failure; + }; + + await expect(server.close()).rejects.toBe(failure); + + lifecycle.closeAll = originalCloseAll; + await server.close(); + server = undefined; + }); + it('flushes the dispatch tail into the session journal before close() resolves', async () => { home = await mkdtemp(join(tmpdir(), 'kimi-server-v2-close-tail-')); server = await startServer({ diff --git a/packages/kap-server/test/sessionEventJournal.test.ts b/packages/kap-server/test/sessionEventJournal.test.ts index a2feb8ca87..a276e00129 100644 --- a/packages/kap-server/test/sessionEventJournal.test.ts +++ b/packages/kap-server/test/sessionEventJournal.test.ts @@ -137,7 +137,7 @@ describe('SessionEventJournal', () => { await j.close(); }); - it('ignores a torn trailing line but rejects a malformed middle line', async () => { + it('repairs a torn trailing line before appending the next event', async () => { const j1 = await SessionEventJournal.open(filePath); j1.append(j1.nextSeq(), envelope(1)); await j1.close(); @@ -146,7 +146,19 @@ describe('SessionEventJournal', () => { await writeFile(filePath, `${durable}{"kind":"event"}`, 'utf8'); const j2 = await SessionEventJournal.open(filePath); expect(j2.seq).toBe(1); + j2.append(j2.nextSeq(), envelope(2)); + await j2.close(); + + expect((await j2.readSince(0, 100)).map((entry) => entry.seq)).toEqual([1, 2]); + }); + + it('rejects a malformed middle line while serving replay', async () => { + const j1 = await SessionEventJournal.open(filePath); + j1.append(j1.nextSeq(), envelope(1)); + await j1.close(); + const durable = await readFile(filePath, 'utf8'); + const j2 = await SessionEventJournal.open(filePath); await writeFile( filePath, `${durable}not-json\n${durable.split('\n')[1]}\n`, @@ -156,6 +168,23 @@ describe('SessionEventJournal', () => { await j2.close(); }); + it('starts the first event after a trailing replacement header at seq one', async () => { + const lines = [ + JSON.stringify({ kind: 'journal_header', version: 1, epoch: 'ep_old', created_at: 1 }), + JSON.stringify({ kind: 'event', seq: 1, envelope: envelope(1) }), + JSON.stringify({ kind: 'journal_header', version: 1, epoch: 'ep_new', created_at: 2 }), + ]; + await writeFile(filePath, lines.join('\n') + '\n', 'utf8'); + + const j = await SessionEventJournal.open(filePath); + expect(j.epoch).toBe('ep_new'); + expect(j.seq).toBe(0); + j.append(j.nextSeq(), envelope(1)); + await j.close(); + + expect((await j.readSince(0, 100)).map((entry) => entry.seq)).toEqual([1]); + }); + it('cold reads are read-only: no epoch is fabricated and nothing is written', async () => { // Missing file — open → close must not create it. const j1 = await SessionEventJournal.open(filePath); diff --git a/packages/kernel-file-lock/package.json b/packages/kernel-file-lock/package.json index b329f196c9..454ad6ba24 100644 --- a/packages/kernel-file-lock/package.json +++ b/packages/kernel-file-lock/package.json @@ -21,6 +21,6 @@ "clean": "rm -rf dist" }, "dependencies": { - "fs-ext-extra-prebuilt": "2.2.9" + "fs-native-extensions": "1.5.0" } } diff --git a/packages/kernel-file-lock/src/index.ts b/packages/kernel-file-lock/src/index.ts index 847c946208..c984d92bd9 100644 --- a/packages/kernel-file-lock/src/index.ts +++ b/packages/kernel-file-lock/src/index.ts @@ -3,7 +3,8 @@ import { createRequire } from 'node:module'; import { dirname } from 'node:path'; export interface KernelFileLockBinding { - flockSync(fd: number, flags: 'exnb' | 'un'): number; + tryLock(fd: number): boolean; + unlock(fd: number): void; } export type KernelFileLockBindingLoader = () => KernelFileLockBinding | undefined; @@ -31,7 +32,7 @@ type GlobalWithBindingLoader = typeof globalThis & { let binding: KernelFileLockBinding | undefined; function defaultBindingLoader(): KernelFileLockBinding { - return nodeRequire('fs-ext-extra-prebuilt') as KernelFileLockBinding; + return nodeRequire('fs-native-extensions') as KernelFileLockBinding; } function getBinding(): KernelFileLockBinding { @@ -41,15 +42,6 @@ function getBinding(): KernelFileLockBinding { return binding; } -function errorCode(error: unknown): string | undefined { - return (error as NodeJS.ErrnoException | undefined)?.code; -} - -function isBusy(error: unknown): boolean { - const code = errorCode(error); - return code === 'EACCES' || code === 'EAGAIN' || code === 'EWOULDBLOCK'; -} - function sleep(ms: number): Promise { return new Promise((resolve) => setTimeout(resolve, ms)); } @@ -82,7 +74,7 @@ class KernelFileLockHandleImpl implements KernelFileLockHandle { if (this.released) return; this.released = true; try { - this.binding.flockSync(this.fd, 'un'); + this.binding.unlock(this.fd); } finally { closeSync(this.fd); } @@ -106,11 +98,13 @@ export function tryAcquireKernelFileLock(path: string): KernelFileLockHandle | u mkdirSync(dirname(path), { recursive: true }); const fd = openSync(path, 'a+', 0o600); try { - nativeBinding.flockSync(fd, 'exnb'); + if (!nativeBinding.tryLock(fd)) { + closeSync(fd); + return undefined; + } return new KernelFileLockHandleImpl(path, fd, nativeBinding); } catch (error) { closeSync(fd); - if (isBusy(error)) return undefined; throw error; } } diff --git a/packages/kernel-file-lock/test/kernel-file-lock.test.ts b/packages/kernel-file-lock/test/kernel-file-lock.test.ts index 4cfbeb0536..2e286232dc 100644 --- a/packages/kernel-file-lock/test/kernel-file-lock.test.ts +++ b/packages/kernel-file-lock/test/kernel-file-lock.test.ts @@ -93,16 +93,12 @@ describe('kernel-file-lock', () => { let unlockCalls = 0; const times = [0, 0, 9, 10]; const nativeBinding: KernelFileLockBinding = { - flockSync: (_fd, flags) => { - if (flags === 'un') { - unlockCalls++; - return 0; - } + tryLock: () => { lockCalls++; - if (lockCalls === 1) { - throw Object.assign(new Error('busy'), { code: 'EAGAIN' }); - } - return 0; + return lockCalls > 1; + }, + unlock: () => { + unlockCalls++; }, }; setKernelFileLockBindingLoader(() => nativeBinding); diff --git a/packages/kernel-file-lock/tsdown.config.ts b/packages/kernel-file-lock/tsdown.config.ts index 9c7ea6f44b..2a7eb793a6 100644 --- a/packages/kernel-file-lock/tsdown.config.ts +++ b/packages/kernel-file-lock/tsdown.config.ts @@ -7,6 +7,6 @@ export default defineConfig({ outDir: 'dist', clean: true, deps: { - neverBundle: ['fs-ext-extra-prebuilt'], + neverBundle: ['fs-native-extensions'], }, }); diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 8eadca78df..9ac48cafd6 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -74,9 +74,9 @@ importers: apps/kimi-code: dependencies: - fs-ext-extra-prebuilt: - specifier: 2.2.9 - version: 2.2.9 + fs-native-extensions: + specifier: 1.5.0 + version: 1.5.0 devDependencies: '@moonshot-ai/acp-adapter': specifier: workspace:^ @@ -882,9 +882,9 @@ importers: packages/kernel-file-lock: dependencies: - fs-ext-extra-prebuilt: - specifier: 2.2.9 - version: 2.2.9 + fs-native-extensions: + specifier: 1.5.0 + version: 1.5.0 packages/klient: dependencies: @@ -5088,6 +5088,25 @@ packages: resolution: {integrity: sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==} engines: {node: 18 || 20 || >=22} + bare-addon-resolve@1.10.1: + resolution: {integrity: sha512-F/SD2du8keuYSb4xipnGz5j2E6yhNdHA8ZVxtHae6h2uOrpBIjjbhXvjzKZbr5XUOzqBzh/i8GVFycj2DlFQIA==} + peerDependencies: + bare-url: '*' + peerDependenciesMeta: + bare-url: + optional: true + + bare-module-resolve@1.12.4: + resolution: {integrity: sha512-xcfgg2u7HqgJiBmah71O9vvdFAgHCvkqC/WSC2O7Bbgosoc1eC/BWe/6IDJ4OsfKlkxuvC/TDWXC+oH5yeW8mA==} + peerDependencies: + bare-url: '*' + peerDependenciesMeta: + bare-url: + optional: true + + bare-semver@1.1.0: + resolution: {integrity: sha512-1Hw5qJ7hXdVt3uPUqjeFTuxyvBUJauvz5A1I2jk8gzjZMHp04n//6nV9MDbG9CMw78JHY2lGV0w6s//LrASm2w==} + base64-js@1.5.1: resolution: {integrity: sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==} @@ -6190,10 +6209,6 @@ packages: fs-constants@1.0.0: resolution: {integrity: sha512-y6OAwoSIf7FyjMIv94u+b5rdheZEjzR63GTyZJm5qh4Bi+2YgwLCcI/fPFZkL5PSixOt6ZNKm+w+Hfp/Bciwow==} - fs-ext-extra-prebuilt@2.2.9: - resolution: {integrity: sha512-NOL/BZ0/Fd0Mqb8PsGgFZ9TZqbYu3lTffS0ucGgdJ8xtEkAz+HLku1Ju30Ehdpvg7XM5kF1PfJYF/wbO5Hr42Q==} - engines: {node: '>= 8.0.0'} - fs-extra@11.3.5: resolution: {integrity: sha512-eKpRKAovdpZtR1WopLHxlBWvAgPny3c4gX1G5Jhwmmw4XJj0ifSD5qB5TOo8hmA0wlRKDAOAhEE1yVPgs6Fgcg==} engines: {node: '>=14.14'} @@ -6206,6 +6221,9 @@ packages: resolution: {integrity: sha512-yhlQgA6mnOJUKOsRUFsgJdQCvkKhcz8tlZG5HBQfReYZy46OwLcY+Zia0mtdHsOo9y/hP+CxMN0TU9QxoOtG4g==} engines: {node: '>=6 <7 || >=8'} + fs-native-extensions@1.5.0: + resolution: {integrity: sha512-nuZLFm9mGCxvyi7Llww/J4OyifKCS21nEUTAmnlTZp3FObPOvA32aCedCmt4Z+8yk+caqfClNaSCfn/P7T7FLQ==} + fs.realpath@1.0.0: resolution: {integrity: sha512-OO0pH2lK6a0hZnAdau5ItzHPI6pUlvI7jMVnxUQRtw4owF2wk8lOSabtGDCTP4Ggrg2MbGnWO9X8K1t4+fGMDw==} @@ -7648,9 +7666,6 @@ packages: mz@2.7.0: resolution: {integrity: sha512-z81GNO7nnYMEhrGh9LeymoE4+Yr0Wn5McHIZMK5cfQCl+NDX08sCZgUc9/6MHni9IWuFLm1Z3HTCXu2z9fN62Q==} - nan@2.28.0: - resolution: {integrity: sha512-fTsDz99OTq2sVePhGdp4qQhggZFtKr64ZNVyVajRKtMOkJxYekplBh577PiJB12v/D3s2E5cGtOI45LWp6rnLQ==} - nanoid@3.3.11: resolution: {integrity: sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w==} engines: {node: ^10 || ^12 || ^13.7 || ^14 || >=15.0.1} @@ -8408,6 +8423,10 @@ packages: remark@15.0.1: resolution: {integrity: sha512-Eht5w30ruCXgFmxVUSlNWQ9iiimq07URKeFS3hNc8cUWy1llX4KDWfyEDZRycMc+znsN9Ux5/tJ/BFdgdOwA3A==} + require-addon@1.2.0: + resolution: {integrity: sha512-VNPDZlYgIYQwWp9jMTzljx+k0ZtatKlcvOhktZ/anNPI3dQ9NXk7cq2U4iJ1wd9IrytRnYhyEocFWbkdPb+MYA==} + engines: {bare: '>=1.10.0'} + require-directory@2.1.1: resolution: {integrity: sha512-fGxEI7+wsG9xrvdjsrlmL22OMTTiHRwAMroiEeMgq8gzoLC/PQr7RsRDSTLUg/bZAZtF+TVIkHc6/4RIKrui+Q==} engines: {node: '>=0.10.0'} @@ -9770,6 +9789,9 @@ packages: resolution: {integrity: sha512-K4jVyjnBdgvc86Y6BkaLZEN933SwYOuBFkdmBu9ZfkcAbdVbpITnDmjvZ/aQjRXQrv5EPkTnD1s39GiiqbngCw==} engines: {node: '>= 0.4'} + which-runtime@1.4.0: + resolution: {integrity: sha512-0ugbP4CJW4e2D20jvEcC4973dCgIaHI4Rw1PT+26U9zEve7FyYdWAIwUnoeOYvoCfn+wXHoHTKb1KhkYlb60Pw==} + which-typed-array@1.1.20: resolution: {integrity: sha512-LYfpUkmqwl0h9A2HL09Mms427Q1RZWuOHsukfVcKRq9q95iQxdw0ix1JQrqbcDR9PH1QDwf5Qo8OZb5lksZ8Xg==} engines: {node: '>= 0.4'} @@ -14165,6 +14187,17 @@ snapshots: balanced-match@4.0.4: {} + bare-addon-resolve@1.10.1: + dependencies: + bare-module-resolve: 1.12.4 + bare-semver: 1.1.0 + + bare-module-resolve@1.12.4: + dependencies: + bare-semver: 1.1.0 + + bare-semver@1.1.0: {} + base64-js@1.5.1: {} baseline-browser-mapping@2.10.20: {} @@ -15436,10 +15469,6 @@ snapshots: fs-constants@1.0.0: optional: true - fs-ext-extra-prebuilt@2.2.9: - dependencies: - nan: 2.28.0 - fs-extra@11.3.5: dependencies: graceful-fs: 4.2.11 @@ -15458,6 +15487,13 @@ snapshots: jsonfile: 4.0.0 universalify: 0.1.2 + fs-native-extensions@1.5.0: + dependencies: + require-addon: 1.2.0 + which-runtime: 1.4.0 + transitivePeerDependencies: + - bare-url + fs.realpath@1.0.0: {} fsevents@2.3.3: @@ -17185,8 +17221,6 @@ snapshots: object-assign: 4.1.1 thenify-all: 1.6.0 - nan@2.28.0: {} - nanoid@3.3.11: {} nanoid@3.3.12: {} @@ -18131,6 +18165,12 @@ snapshots: transitivePeerDependencies: - supports-color + require-addon@1.2.0: + dependencies: + bare-addon-resolve: 1.10.1 + transitivePeerDependencies: + - bare-url + require-directory@2.1.1: {} require-from-string@2.0.2: {} @@ -19626,6 +19666,8 @@ snapshots: is-weakmap: 2.0.2 is-weakset: 2.0.4 + which-runtime@1.4.0: {} + which-typed-array@1.1.20: dependencies: available-typed-arrays: 1.0.7 From aa4dcfe361acff3526485adda38a2e0cc8a3b715 Mon Sep 17 00:00:00 2001 From: 7Sageer <7sageer@djwcb.cn> Date: Tue, 21 Jul 2026 12:25:52 +0800 Subject: [PATCH 07/22] refactor: drop multi-server flag and dedupe session ownership classification --- .changeset/cross-process-lock-unification.md | 2 +- .changeset/multi-instance-session-sync.md | 2 +- .changeset/stale-write-fencing.md | 2 +- .../src/api/daemon/sessionOwnership.ts | 2 +- apps/kimi-web/src/api/daemon/wire.ts | 3 +- apps/kimi-web/src/api/daemon/ws.ts | 9 +- apps/kimi-web/test/ws-lifecycle.test.ts | 16 +- .../scripts/check-domain-layers.mjs | 5 +- .../agent/fileFencing/fileFencingService.ts | 49 +- packages/agent-core-v2/src/app/cron/clock.ts | 10 +- .../agent-core-v2/src/app/multiServer/flag.ts | 29 - .../sessionLifecycleService.ts | 21 +- .../src/os/interface/crossProcessLock.ts | 7 - .../backends/node-fs/fileStorageService.ts | 43 +- .../agentLifecycle/agentLifecycleService.ts | 2 +- .../src/session/sessionLease/sessionLease.ts | 42 +- .../sessionMetadata/sessionMetadataService.ts | 2 +- .../agent/fileFencing/fileFencing.test.ts | 570 ++++++++---------- .../sessionLifecycle/sessionLifecycle.test.ts | 32 +- .../node-fs/fileStorageService.test.ts | 58 +- .../agentLifecycle/agentLifecycle.test.ts | 1 + .../session/sessionLease/sessionLease.test.ts | 11 +- packages/kap-server/src/envelope.ts | 7 +- packages/kap-server/src/error-handler.ts | 12 +- packages/kap-server/src/protocol/envelope.ts | 22 + packages/kap-server/src/routes/fs.ts | 15 +- packages/kap-server/src/routes/prompts.ts | 18 +- packages/kap-server/src/routes/sessions.ts | 28 +- packages/kap-server/src/routes/terminals.ts | 15 +- .../sessionListWatchService.ts | 28 +- packages/kap-server/src/start.ts | 36 +- .../ws/v1/sessionEventBroadcaster.ts | 70 +-- .../transport/ws/v1/sessionEventJournal.ts | 19 +- .../src/transport/ws/v1/wsConnectionV1.ts | 4 +- .../test/session-ownership.e2e.test.ts | 25 +- .../test/sessionEventBroadcaster.test.ts | 2 + .../kap-server/test/wsConnectionV1.test.ts | 1 + packages/kernel-file-lock/src/index.ts | 56 -- .../test/kernel-file-lock.test.ts | 72 --- packages/klient/AGENTS.md | 4 - packages/klient/src/index.ts | 1 - packages/klient/src/sessionRedirect.ts | 14 +- .../test/e2e/harness/testing/serverPair.ts | 28 +- .../test/e2e/harness/testing/serverProcess.ts | 10 +- .../test/e2e/legacy/dual-instance.test.ts | 27 +- .../test/e2e/v2/ownership-redirect.test.ts | 2 +- packages/klient/test/sessionRedirect.test.ts | 98 +++ packages/minidb/src/index.ts | 7 - packages/minidb/src/lockfile.ts | 4 - packages/minidb/test/cluster/lock.test.ts | 2 +- packages/minidb/test/lock.test.ts | 2 +- packages/minidb/test/review-fixes.test.ts | 1 - packages/protocol/src/events.ts | 1 - 53 files changed, 656 insertions(+), 893 deletions(-) delete mode 100644 packages/agent-core-v2/src/app/multiServer/flag.ts diff --git a/.changeset/cross-process-lock-unification.md b/.changeset/cross-process-lock-unification.md index 2e0c62aa57..a9409cd48e 100644 --- a/.changeset/cross-process-lock-unification.md +++ b/.changeset/cross-process-lock-unification.md @@ -2,4 +2,4 @@ "@moonshot-ai/kimi-code": patch --- -Fix cross-process state corruption by replacing ad-hoc lockfiles with kernel-backed locks for session, server, and database coordination, with automatic release when a process exits. +Fix cross-process state corruption by replacing ad-hoc lockfiles with kernel-backed locks for server and database coordination, with automatic release when a process exits. diff --git a/.changeset/multi-instance-session-sync.md b/.changeset/multi-instance-session-sync.md index 4c4ecfb41a..a546025b9c 100644 --- a/.changeset/multi-instance-session-sync.md +++ b/.changeset/multi-instance-session-sync.md @@ -2,4 +2,4 @@ "@moonshot-ai/kimi-code": patch --- -web: When several server instances share one home, opening a session held by another instance now redirects to that instance, and the session list refreshes automatically when a peer adds or removes sessions. CLI/SDK clients follow the same redirect transparently. +web: When several server instances share one home, opening a session held by another instance now redirects to that instance, and the session list refreshes automatically when a peer adds or removes sessions. diff --git a/.changeset/stale-write-fencing.md b/.changeset/stale-write-fencing.md index 638b58ed63..b287b47733 100644 --- a/.changeset/stale-write-fencing.md +++ b/.changeset/stale-write-fencing.md @@ -2,4 +2,4 @@ "@moonshot-ai/kimi-code": patch --- -Track files read and written per session to detect conflicting edits across server instances: with multi-server mode enabled, stale or never-read file writes are rejected, otherwise they are flagged in the tool result. +Track files read and written per session to detect conflicting edits: a Write/Edit over a file that changed on disk since it was last read (or was never read in this session) is now rejected with a read-first reason, so conflicting edits across server instances cannot be applied silently. diff --git a/apps/kimi-web/src/api/daemon/sessionOwnership.ts b/apps/kimi-web/src/api/daemon/sessionOwnership.ts index 4e429bc0f0..b99b253a8a 100644 --- a/apps/kimi-web/src/api/daemon/sessionOwnership.ts +++ b/apps/kimi-web/src/api/daemon/sessionOwnership.ts @@ -9,7 +9,7 @@ // Wire semantics (from the protocol schema): // - creating lease file observed mid-creation; retry shortly // - routable holder is live and registered an address; redirect -// - holder-unresponsive holder pid alive but heartbeat stale; retry later +// - holder-unresponsive legacy heartbeat-based server response; retry later // - held-by-local-instance holder has no address (local/embedded); terminal // - unregistered-writer session dir written by an unregistered process diff --git a/apps/kimi-web/src/api/daemon/wire.ts b/apps/kimi-web/src/api/daemon/wire.ts index 900d98bcf0..f7462b8380 100644 --- a/apps/kimi-web/src/api/daemon/wire.ts +++ b/apps/kimi-web/src/api/daemon/wire.ts @@ -583,7 +583,8 @@ export interface WireInFlightTurn { /** `GET /sessions/{sid}/snapshot` — atomic rebuild state at a watermark. */ export interface WireSessionSnapshot { as_of_seq: number; - epoch: string; + /** Absent until the journal's first durable event: "no baseline" ≠ "baseline changed". */ + epoch?: string; session: WireSession; messages: { items: WireMessage[]; has_more: boolean }; in_flight_turn: WireInFlightTurn | null; diff --git a/apps/kimi-web/src/api/daemon/ws.ts b/apps/kimi-web/src/api/daemon/ws.ts index eff16ce616..a2c1bc879d 100644 --- a/apps/kimi-web/src/api/daemon/ws.ts +++ b/apps/kimi-web/src/api/daemon/ws.ts @@ -390,19 +390,18 @@ export class DaemonEventSocket { // Multi-instance hint (volatile, no payload): some instance sharing this // home created/archived/deleted a session. Consumed by name here because // classifyFrame would route the unknown unprefixed type to the agent - // projector, which no-ops on it. Emitted with or without the "event." - // prefix — accept both. + // projector, which no-ops on it. The server (sessionEventBroadcaster) + // only ever emits the bare form. case 'session.list_changed': - case 'event.session.list_changed': this.handlers.onSessionListChanged?.(); break; // Volatile per-session hint: the daemon's skill catalog for this session // changed (e.g. a skill file edited on disk). Consumed by name here for // the same reason as session.list_changed above; the frame carries its - // own per-connection seq, never the durable watermark. + // own per-connection seq, never the durable watermark. The server + // (skillCatalogBridge) only ever emits the bare form. case 'skill_catalog.changed': - case 'event.skill_catalog.changed': this.handlers.onSkillCatalogChanged?.(frame.session_id as string); break; diff --git a/apps/kimi-web/test/ws-lifecycle.test.ts b/apps/kimi-web/test/ws-lifecycle.test.ts index 91d5edaa9d..e50cab5fd7 100644 --- a/apps/kimi-web/test/ws-lifecycle.test.ts +++ b/apps/kimi-web/test/ws-lifecycle.test.ts @@ -159,7 +159,7 @@ describe('DaemonEventSocket frame dispatch (multi-instance surface)', () => { vi.useRealTimers(); }); - it('consumes session.list_changed (bare and event.-prefixed) via onSessionListChanged only', () => { + it('consumes bare session.list_changed via onSessionListChanged; the event.-prefixed form is not special-cased', () => { let wireEvents = 0; let listChanged = 0; const handlers: DaemonEventSocketHandlers = { @@ -179,13 +179,15 @@ describe('DaemonEventSocket frame dispatch (multi-instance surface)', () => { ws.emitMessage(SERVER_HELLO); ws.emitMessage({ type: 'session.list_changed', payload: {} }); + // The server never emits the prefixed form; it must NOT reach + // onSessionListChanged (it falls through to the generic protocol path). ws.emitMessage({ type: 'event.session.list_changed', payload: {} }); - expect(listChanged).toBe(2); - expect(wireEvents).toBe(0); + expect(listChanged).toBe(1); + expect(wireEvents).toBe(1); }); - it('consumes skill_catalog.changed (bare and event.-prefixed) via onSkillCatalogChanged only', () => { + it('consumes bare skill_catalog.changed via onSkillCatalogChanged; the event.-prefixed form is not special-cased', () => { let wireEvents = 0; let rawAgentEvents = 0; const changed: string[] = []; @@ -216,6 +218,8 @@ describe('DaemonEventSocket frame dispatch (multi-instance surface)', () => { volatile: true, payload: { type: 'skill_catalog.changed', sourceId: 'workspace-file' }, }); + // The server never emits the prefixed form; it must NOT reach + // onSkillCatalogChanged (it falls through to the generic protocol path). ws.emitMessage({ type: 'event.skill_catalog.changed', seq: 2, @@ -225,8 +229,8 @@ describe('DaemonEventSocket frame dispatch (multi-instance surface)', () => { payload: { type: 'skill_catalog.changed', sourceId: 'plugin' }, }); - expect(changed).toEqual(['sess_1', 'sess_2']); - expect(wireEvents).toBe(0); + expect(changed).toEqual(['sess_1']); + expect(wireEvents).toBe(1); expect(rawAgentEvents).toBe(0); }); diff --git a/packages/agent-core-v2/scripts/check-domain-layers.mjs b/packages/agent-core-v2/scripts/check-domain-layers.mjs index 82e6d278cc..98e20a838f 100644 --- a/packages/agent-core-v2/scripts/check-domain-layers.mjs +++ b/packages/agent-core-v2/scripts/check-domain-layers.mjs @@ -123,7 +123,6 @@ const DOMAIN_LAYER = new Map([ ['sessionSkillCatalog', 3], ['permissionGate', 3], ['flag', 3], - ['multiServer', 3], ['toolExecutor', 3], ['toolResultTruncation', 3], ['toolRegistry', 3], @@ -152,8 +151,8 @@ const DOMAIN_LAYER = new Map([ ['runtime', 4], ['toolDedupe', 4], // `fileFencing` is the Agent-scope optimistic-concurrency gate: a - // tool-executor hook participant (L3) over the `sessionFileLedger` (L2) - // and flag state (L3), so it sits at L4 beside `toolDedupe`. + // tool-executor hook participant (L3) over the `sessionFileLedger` (L2), + // so it sits at L4 beside `toolDedupe`. ['fileFencing', 4], ['toolSelect', 4], ['contextMemory', 4], diff --git a/packages/agent-core-v2/src/agent/fileFencing/fileFencingService.ts b/packages/agent-core-v2/src/agent/fileFencing/fileFencingService.ts index aa7d5cb3a0..261d6699ed 100644 --- a/packages/agent-core-v2/src/agent/fileFencing/fileFencingService.ts +++ b/packages/agent-core-v2/src/agent/fileFencing/fileFencingService.ts @@ -11,16 +11,13 @@ * — the exact canonical path the tool itself computed — so the ledger and * the watcher key it identically. The before-hook records the target keyed * by `toolCall.id` (cleared in the did-hook and swept on turn change) and, - * for `Write`/`Edit`, computes the ledger verdict: with the `multi_server` - * flag on, `stale` blocks with an outside-modification conflict and - * `no-baseline` blocks with a read-first reason (Edit-over-existing, or - * Write over an already existing file); with the flag off nothing ever - * blocks and the verdict is marked for the did-hook. The did-hook records the - * revision captured by the successful fenced call (ranged Reads excepted — - * per the ledger contract they never count as full reads) and, - * for a flag-off stale mark, composes a `` advisory onto the result - * note; direct creation of a new file is verdict-`clean`, so it is never - * advisory'd. Watcher echos of the session's own writes are absorbed by the + * for `Write`/`Edit`, computes the ledger verdict: `stale` blocks with an + * outside-modification conflict and `no-baseline` blocks with a read-first + * reason (Edit-over-existing, or Write over an already existing file). The + * did-hook records the revision captured by the successful fenced call + * (ranged Reads excepted — per the ledger contract they never count as full + * reads); direct creation of a new file is verdict-`clean`, so it never + * blocks. Watcher echos of the session's own writes are absorbed by the * ledger's stat punch, so consecutive Edits stay clean. Checked after * `permission` (ignition order is set by `agentLifecycle`). Bound at Agent * scope. @@ -35,8 +32,6 @@ import type { ToolDidExecuteContext, ToolExecutionHookContext, } from '#/agent/toolExecutor/toolHooks'; -import { IFlagService } from '#/app/flag/flag'; -import { MULTI_SERVER_FLAG_ID } from '#/app/multiServer/flag'; import { ISessionFileLedger, type FileLedgerVerdict, @@ -92,20 +87,6 @@ function blockReason(toolName: string, path: string, verdict: FileLedgerVerdict) ); } -function advisoryNote(target: FencingTarget, verdict: FileLedgerVerdict): string { - const body = - verdict === 'no-baseline' - ? `"${target.path}" already existed on disk and had not been read in this session; your change overwrote it anyway.` - : `"${target.path}" changed on disk since it was last read in this session; your change was applied anyway.`; - return `Warning: ${body} Read the file to verify the current content.`; -} - -function composeNote(existing: string | undefined, advisory: string): string { - return existing === undefined || existing.length === 0 - ? advisory - : `${existing}\n${advisory}`; -} - function revisionForTarget( revision: ToolFileRevision | undefined, targetPath: string, @@ -117,12 +98,10 @@ export class AgentFileFencingService extends Disposable implements IAgentFileFen declare readonly _serviceBrand: undefined; private readonly targets = new Map(); - private readonly staleMarks = new Map(); private markerTurnId: number | undefined; constructor( @ISessionFileLedger private readonly ledger: ISessionFileLedger, - @IFlagService private readonly flags: IFlagService, @IAgentToolExecutorService toolExecutor: IAgentToolExecutorService, ) { super(); @@ -144,7 +123,6 @@ export class AgentFileFencingService extends Disposable implements IAgentFileFen if (this.markerTurnId !== ctx.turnId) { this.markerTurnId = ctx.turnId; this.targets.clear(); - this.staleMarks.clear(); } this.targets.set(ctx.toolCall.id, { toolName: ctx.toolCall.name, path }); if (!WRITE_TOOLS.has(ctx.toolCall.name)) return; @@ -154,12 +132,8 @@ export class AgentFileFencingService extends Disposable implements IAgentFileFen execute: async (executeCtx) => { const verdict = await this.ledger.compare(path); if (verdict !== 'clean') { - if (this.flags.enabled(MULTI_SERVER_FLAG_ID)) { - const reason = blockReason(ctx.toolCall.name, path, verdict); - ctx.decision = { ...ctx.decision, block: true, reason }; - return { output: reason, isError: true }; - } - this.staleMarks.set(ctx.toolCall.id, verdict); + const reason = blockReason(ctx.toolCall.name, path, verdict); + return { output: reason, isError: true }; } return execute(executeCtx); }, @@ -170,8 +144,6 @@ export class AgentFileFencingService extends Disposable implements IAgentFileFen if (!isFenced(ctx)) return; const target = this.targets.get(ctx.toolCall.id); this.targets.delete(ctx.toolCall.id); - const mark = this.staleMarks.get(ctx.toolCall.id); - this.staleMarks.delete(ctx.toolCall.id); if (target === undefined || ctx.result.isError === true) return; if (target.toolName === READ_TOOL && isRangedRead(ctx.args)) return; const revision = revisionForTarget(ctx.result[toolFileRevision], target.path); @@ -183,9 +155,6 @@ export class AgentFileFencingService extends Disposable implements IAgentFileFen size: revision.size, }); } - if (mark !== undefined) { - ctx.result = { ...ctx.result, note: composeNote(ctx.result.note, advisoryNote(target, mark)) }; - } } } diff --git a/packages/agent-core-v2/src/app/cron/clock.ts b/packages/agent-core-v2/src/app/cron/clock.ts index 43105a75e3..2db4b26edf 100644 --- a/packages/agent-core-v2/src/app/cron/clock.ts +++ b/packages/agent-core-v2/src/app/cron/clock.ts @@ -10,11 +10,11 @@ * * 2. monotonic ms — a strictly non-decreasing counter that never * jumps backwards across NTP adjustments, suspend/resume, or - * simulated-clock injection. Used for the poll cadence and the - * lock heartbeat — anything where "did 5 seconds elapse since we - * last looked" must hold even when the wall clock is frozen. + * simulated-clock injection. Used for the poll cadence — anything + * where "did 5 seconds elapse since we last looked" must hold + * even when the wall clock is frozen. * - * Mixing the two pollutes test reproducibility: a heartbeat tied to + * Mixing the two pollutes test reproducibility: a poll cadence tied to * `wallNow()` will appear stuck when the test clock is frozen; a cron * fire tied to `monoNowMs()` will not advance when the bench rewinds * the simulated day. Every component in the cron domain MUST take a @@ -22,7 +22,7 @@ * * `monoNowMs` is ALWAYS `process.hrtime.bigint()` (converted to ms). * It is not overridable — accepting an external monotonic clock would - * defeat the safety net the lock heartbeat depends on. + * let a frozen test clock silently stall the poll cadence. * * `wallNow` resolution is driven by the `KIMI_CRON_CLOCK` env var; see * `resolveClockSources` below. Defaults to `Date.now()`. diff --git a/packages/agent-core-v2/src/app/multiServer/flag.ts b/packages/agent-core-v2/src/app/multiServer/flag.ts deleted file mode 100644 index 22c7c1e6a1..0000000000 --- a/packages/agent-core-v2/src/app/multiServer/flag.ts +++ /dev/null @@ -1,29 +0,0 @@ -/** - * `multi_server` experimental flag — gates shared-home coordination among - * cooperating lease-aware server versions. - * - * When enabled, a kap-server instance registers itself under - * `/server/instances/.json` instead of taking the legacy - * single-instance `/server/lock`, so multiple servers can share one home - * directory. Off by default; enable via `KIMI_CODE_EXPERIMENTAL_MULTI_SERVER`, - * the master `KIMI_CODE_EXPERIMENTAL_FLAG`, or the `[experimental]` config - * section. Imported for its side effect (registers the definition) from the - * package barrel. - */ - -import { type FlagDefinitionInput, registerFlagDefinition } from '#/app/flag/flagRegistry'; - -export const MULTI_SERVER_FLAG_ID = 'multi_server'; -export const MULTI_SERVER_FLAG_ENV = 'KIMI_CODE_EXPERIMENTAL_MULTI_SERVER'; - -export const multiServerFlag: FlagDefinitionInput = { - id: MULTI_SERVER_FLAG_ID, - title: 'multi-server shared home', - description: - 'Allow cooperating lease-aware kap-server instances to share one home directory by registering each instance under server/instances/ instead of taking a single homedir lock.', - env: MULTI_SERVER_FLAG_ENV, - default: false, - surface: 'core', -}; - -registerFlagDefinition(multiServerFlag); diff --git a/packages/agent-core-v2/src/app/sessionLifecycle/sessionLifecycleService.ts b/packages/agent-core-v2/src/app/sessionLifecycle/sessionLifecycleService.ts index db23afd4eb..e421094152 100644 --- a/packages/agent-core-v2/src/app/sessionLifecycle/sessionLifecycleService.ts +++ b/packages/agent-core-v2/src/app/sessionLifecycle/sessionLifecycleService.ts @@ -59,8 +59,6 @@ import { CRON_SESSION_TAG, type CronTask } from '#/app/cron/cronTask'; import { ICronTaskPersistence } from '#/app/cron/cronTaskPersistence'; import { IConfigService } from '#/app/config/config'; import { IEventService } from '#/app/event/event'; -import { IFlagService } from '#/app/flag/flag'; -import { MULTI_SERVER_FLAG_ID } from '#/app/multiServer/flag'; import { CHILD_SESSION_KIND, CHILD_SESSION_KIND_KEY, @@ -92,6 +90,7 @@ import { ISessionContext, sessionContextSeed } from '#/session/sessionContext/se import { ISessionCronService } from '#/session/cron/sessionCronService'; import { type HeldByPeerDetails, + heldByPeerDetailsFromInspection, LEASE_CREATING_RETRY_AFTER_MS, SessionLease, sessionLeasePath, @@ -177,7 +176,6 @@ export class SessionLifecycleService extends Disposable implements ISessionLifec @IEventService private readonly event: IEventService, @ITelemetryService private readonly telemetry: ITelemetryService, @ILogService private readonly log: ILogService, - @IFlagService private readonly flags: IFlagService, @ICrossProcessLockService private readonly locks: ICrossProcessLockService, @IWriteAuthorityRegistry private readonly authorityRegistry: IWriteAuthorityRegistry, @ISessionLeaseContactProvider @@ -898,16 +896,15 @@ export class SessionLifecycleService extends Disposable implements ISessionLifec } private heldByPeerDetails(inspection: CrossProcessLockInspection): HeldByPeerDetails { - if (inspection.state === 'held' && inspection.payload !== undefined) { - const payload = inspection.payload; - if (payload.address !== undefined && this.flags.enabled(MULTI_SERVER_FLAG_ID)) { - return { kind: 'held-by-peer', phase: 'routable', address: payload.address }; + // A free lease here means the holder vanished between the failed acquire + // and this probe; that race converges by retrying, same as 'creating'. + return ( + heldByPeerDetailsFromInspection(inspection) ?? { + kind: 'held-by-peer', + phase: 'creating', + retry_after_ms: LEASE_CREATING_RETRY_AFTER_MS, } - return { kind: 'held-by-peer', phase: 'held-by-local-instance' }; - } - // 'creating' mid-creation, and races where the holder vanished between the - // failed acquire and this probe, both converge by retrying shortly. - return { kind: 'held-by-peer', phase: 'creating', retry_after_ms: LEASE_CREATING_RETRY_AFTER_MS }; + ); } private async flushSessionTail(sessionId: string, scope: string): Promise { diff --git a/packages/agent-core-v2/src/os/interface/crossProcessLock.ts b/packages/agent-core-v2/src/os/interface/crossProcessLock.ts index b31c07bbad..03c8210ad7 100644 --- a/packages/agent-core-v2/src/os/interface/crossProcessLock.ts +++ b/packages/agent-core-v2/src/os/interface/crossProcessLock.ts @@ -78,7 +78,6 @@ export const OsLockErrors = { codes: { OS_LOCK_HELD: 'os.lock.held', OS_LOCK_WAIT_TIMEOUT: 'os.lock.wait_timeout', - OS_LOCK_LOST: 'os.lock.lost', OS_LOCK_IO: 'os.lock.io', }, info: { @@ -92,11 +91,6 @@ export const OsLockErrors = { retryable: true, public: true, }, - 'os.lock.lost': { - title: 'Lock ownership was lost', - retryable: false, - public: true, - }, 'os.lock.io': { title: 'Lock file I/O failed', retryable: true, @@ -110,7 +104,6 @@ registerErrorDomain(OsLockErrors); export const CrossProcessLockErrorCode = { Held: OsLockErrors.codes.OS_LOCK_HELD, WaitTimeout: OsLockErrors.codes.OS_LOCK_WAIT_TIMEOUT, - Lost: OsLockErrors.codes.OS_LOCK_LOST, Io: OsLockErrors.codes.OS_LOCK_IO, } as const; diff --git a/packages/agent-core-v2/src/persistence/backends/node-fs/fileStorageService.ts b/packages/agent-core-v2/src/persistence/backends/node-fs/fileStorageService.ts index 6919bf3d69..028e66c976 100644 --- a/packages/agent-core-v2/src/persistence/backends/node-fs/fileStorageService.ts +++ b/packages/agent-core-v2/src/persistence/backends/node-fs/fileStorageService.ts @@ -20,7 +20,9 @@ * control over append offsets, fsync, atomic rename and streaming, which the * agent-execution-environment abstraction does not expose. Higher-level code * (wire journal, blob store) goes through the Store / Storage interfaces above - * this backend, never `node:fs` directly. + * this backend, never `node:fs` directly. Session-rooted mutations are fenced + * through the App-scoped write-authority registry immediately before storage + * I/O, so every file-backed Store shares the same fail-closed boundary. */ import { createReadStream, mkdirSync, statSync } from 'node:fs'; @@ -33,6 +35,7 @@ import { optional } from '#/_base/di/instantiation'; import { Emitter, type Event } from '#/_base/event'; import { onUnexpectedError } from '#/_base/errors/unexpectedError'; import { atomicWrite, syncDir } from '#/_base/utils/fs'; +import { Error2, ErrorCodes } from '#/errors'; import { CrossProcessLockError, CrossProcessLockErrorCode, @@ -46,6 +49,10 @@ import type { StorageWriteOptions, } from '#/persistence/interface/storage'; import { StorageError, StorageErrors, toStorageIoError } from '#/persistence/interface/storage'; +import { + sessionIdFromScope, + IWriteAuthorityRegistry, +} from '#/persistence/interface/writeAuthority'; const WATCH_DEBOUNCE_MS = 150; const STORAGE_LOCK_WAIT_TIMEOUT_MS = 10_000; @@ -83,6 +90,8 @@ export class FileStorageService implements IFileSystemStorageService { private readonly dirMode?: number, private readonly fileMode?: number, @optional(ICrossProcessLockService) private readonly locks?: ICrossProcessLockService, + @optional(IWriteAuthorityRegistry) + private readonly authorityRegistry?: IWriteAuthorityRegistry, ) {} async read(scope: string, key: string): Promise { @@ -122,8 +131,14 @@ export class FileStorageService implements IFileSystemStorageService { _options: StorageWriteOptions = {}, ): Promise { const filePath = this.path(scope, key); + this.assertScopeWritable(scope); try { await mkdir(dirname(filePath), { recursive: true, mode: this.dirMode }); + } catch (error) { + throw toStorageIoError(error, { path: filePath, op: 'write' }); + } + this.assertScopeWritable(scope); + try { await atomicWrite(filePath, data, undefined, this.fileMode); await this.syncDirOnce(dirname(filePath)); } catch (error) { @@ -139,9 +154,14 @@ export class FileStorageService implements IFileSystemStorageService { ): Promise { const filePath = this.path(scope, key); const dir = dirname(filePath); + this.assertScopeWritable(scope); try { await mkdir(dir, { recursive: true, mode: this.dirMode }); - + } catch (error) { + throw toStorageIoError(error, { path: filePath, op: 'append' }); + } + this.assertScopeWritable(scope); + try { const fh = await open(filePath, 'a', this.fileMode); try { if (data.byteLength > 0) { @@ -172,6 +192,7 @@ export class FileStorageService implements IFileSystemStorageService { async delete(scope: string, key: string): Promise { const filePath = this.path(scope, key); + this.assertScopeWritable(scope); try { await unlink(filePath); } catch (error) { @@ -256,6 +277,7 @@ export class FileStorageService implements IFileSystemStorageService { async runExclusive(scope: string, key: string, op: () => Promise): Promise { const filePath = this.path(scope, key); const lockPath = `${filePath}.lock`; + this.assertScopeWritable(scope); if (this.locks === undefined) { throw new StorageError( StorageErrors.codes.STORAGE_IO_FAILED, @@ -267,7 +289,10 @@ export class FileStorageService implements IFileSystemStorageService { return await this.locks.withLock( lockPath, { wait: { timeoutMs: STORAGE_LOCK_WAIT_TIMEOUT_MS } }, - op, + async () => { + this.assertScopeWritable(scope); + return op(); + }, ); } catch (error) { if (!(error instanceof CrossProcessLockError)) throw error; @@ -297,6 +322,18 @@ export class FileStorageService implements IFileSystemStorageService { return join(this.baseDir, scope); } + private assertScopeWritable(scope: string): void { + const sessionId = sessionIdFromScope(scope); + if (sessionId === undefined || this.authorityRegistry === undefined) return; + const authority = this.authorityRegistry.resolve(sessionId); + if (authority === undefined) { + throw new Error2(ErrorCodes.SESSION_LEASE_LOST, 'session has no registered write authority', { + details: { sessionId }, + }); + } + authority.assertWritable(); + } + private async syncDirOnce(dir: string): Promise { if (this.syncedDirs.has(dir)) return; try { diff --git a/packages/agent-core-v2/src/session/agentLifecycle/agentLifecycleService.ts b/packages/agent-core-v2/src/session/agentLifecycle/agentLifecycleService.ts index c700cc1e1b..ec3f4d64a7 100644 --- a/packages/agent-core-v2/src/session/agentLifecycle/agentLifecycleService.ts +++ b/packages/agent-core-v2/src/session/agentLifecycle/agentLifecycleService.ts @@ -314,7 +314,7 @@ export class AgentLifecycleService extends Disposable implements IAgentLifecycle this.handles.delete(agentId); const tasks = handle.accessor.get(IAgentTaskService); await tasks.stopAllOnExit('Session closed'); - await tasks.flushPersistence?.(); + await tasks.flushPersistence(); const loop = handle.accessor.get(IAgentLoopService); const compaction = handle.accessor.get(IAgentFullCompactionService).compacting; const compactionSettled = compaction?.promise.catch(() => undefined) ?? Promise.resolve(); diff --git a/packages/agent-core-v2/src/session/sessionLease/sessionLease.ts b/packages/agent-core-v2/src/session/sessionLease/sessionLease.ts index c2b5a2dad2..4e5bcb3676 100644 --- a/packages/agent-core-v2/src/session/sessionLease/sessionLease.ts +++ b/packages/agent-core-v2/src/session/sessionLease/sessionLease.ts @@ -25,7 +25,10 @@ import { join } from 'pathe'; import { createDecorator, type ServiceIdentifier } from '#/_base/di/instantiation'; import type { ScopeSeed } from '#/_base/di/scope'; import { Error2, ErrorCodes } from '#/errors'; -import type { ICrossProcessLockHandle } from '#/os/interface/crossProcessLock'; +import type { + CrossProcessLockInspection, + ICrossProcessLockHandle, +} from '#/os/interface/crossProcessLock'; import type { ISessionWriteAuthority } from '#/persistence/interface/writeAuthority'; export const LEASE_CREATING_RETRY_AFTER_MS = 1000; @@ -49,6 +52,33 @@ export type HeldByPeerDetails = { export type SessionOwnershipDetails = HeldByPeerDetails | { readonly kind: 'unregistered-writer' }; +/** + * Classify a lease inspection into `held-by-peer` details. Shared by every + * surface that reports session ownership — the lifecycle's + * post-acquire-failure probe and kap-server's read-only probes — so all of + * them classify the same lease the same way. Returns `undefined` when the + * lease is free; a caller on a failed-acquire path should map that to + * `'creating'`, since a holder that vanished mid-race converges by retrying. + */ +export function heldByPeerDetailsFromInspection( + inspection: CrossProcessLockInspection, +): HeldByPeerDetails | undefined { + if (inspection.state === 'held' && inspection.payload !== undefined) { + const { address } = inspection.payload; + return address !== undefined + ? { kind: 'held-by-peer', phase: 'routable', address } + : { kind: 'held-by-peer', phase: 'held-by-local-instance' }; + } + if (inspection.state === 'creating') { + return { + kind: 'held-by-peer', + phase: 'creating', + retry_after_ms: LEASE_CREATING_RETRY_AFTER_MS, + }; + } + return undefined; +} + export interface ISessionLeaseInfo { readonly sessionId: string; readonly lockId: string; @@ -83,15 +113,11 @@ export class SessionLease implements ISessionWriteAuthority, ISessionLeaseServic this.lockId = handle.lockId; } - get released(): boolean { - return this._released; - } - get info(): ISessionLeaseInfo | undefined { return this._released ? undefined : { sessionId: this.sessionId, lockId: this.lockId }; } - checkHeld(): boolean { + private checkHeld(): boolean { return !this._released && this.handle.checkHeld(); } @@ -103,7 +129,7 @@ export class SessionLease implements ISessionWriteAuthority, ISessionLeaseServic { details: { sessionId: this.sessionId } }, ); } - if (this._lost || !this.handle.checkHeld()) { + if (this._lost || !this.checkHeld()) { this.markLost(); throw new Error2( ErrorCodes.SESSION_LEASE_LOST, @@ -113,7 +139,7 @@ export class SessionLease implements ISessionWriteAuthority, ISessionLeaseServic } } - markLost(): void { + private markLost(): void { this._lost = true; if (this._lossFired) return; this._lossFired = true; diff --git a/packages/agent-core-v2/src/session/sessionMetadata/sessionMetadataService.ts b/packages/agent-core-v2/src/session/sessionMetadata/sessionMetadataService.ts index d483ea9db4..7324b8e83f 100644 --- a/packages/agent-core-v2/src/session/sessionMetadata/sessionMetadataService.ts +++ b/packages/agent-core-v2/src/session/sessionMetadata/sessionMetadataService.ts @@ -15,7 +15,7 @@ * re-registers its agents as they materialize — never bumps `updatedAt` and * never reorders session listings. Every durable write passes the * `sessionLease` hard gate first (`ISessionLeaseService.assertWritable`, - * synchronously re-reading the lease payload), so an instance that lost the + * checking the held kernel-lock handle), so an instance that lost the * session lease fails closed instead of overwriting a live peer's state. * Bound at Session scope. * diff --git a/packages/agent-core-v2/test/agent/fileFencing/fileFencing.test.ts b/packages/agent-core-v2/test/agent/fileFencing/fileFencing.test.ts index 508f78a23f..79aad012c8 100644 --- a/packages/agent-core-v2/test/agent/fileFencing/fileFencing.test.ts +++ b/packages/agent-core-v2/test/agent/fileFencing/fileFencing.test.ts @@ -2,8 +2,8 @@ * `fileFencing` domain (L4) — verifies the write/read-first gate end to end * through the real DI scope tree: a real tmpdir, the real `HostFileSystem`, * the real watch service folding fake os-watcher events, and the registered - * `writeFencing` hook participant over a real `OrderedHookSlot`. Covers both - * flag postures (`multi_server` on = hard block, off = advisory note), the + * `writeFencing` hook participant over a real `OrderedHookSlot`. Covers the + * hard-block verdicts (stale outside change / never-read existing file), the * own-write echo / truncated-window stale checks, out-of-root stat fallback, * watched-root ensuring for additional dirs, and ledger isolation between * two Session scopes sharing one workspace (two-instance conflict). @@ -46,6 +46,7 @@ import { import { AgentToolExecutorService } from '#/agent/toolExecutor/toolExecutorService'; import { stubToolExecutor } from '../loop/stubs'; +import { stubFlag } from '../../app/flag/stubs'; import { fakeHostFsWatch, type FakeWatch } from '../../session/sessionFs/stubs'; void AgentFileFencingService; @@ -54,8 +55,6 @@ void SessionFsWatchService; void SessionWorkspaceContextService; void AgentToolExecutorService; -let multiServer = false; - function countingHostFs(): { fs: IHostFileSystem; statCalls: () => number } { const real = new HostFileSystem(); let count = 0; @@ -90,10 +89,7 @@ function makeEnv(): Env { const host = createScopedTestHost([ stubPair(IHostFileSystem, fs), stubPair(IHostFsWatchService, fake.service), - stubPair(IFlagService, { - _serviceBrand: undefined, - enabled: () => multiServer, - } as unknown as IFlagService), + stubPair(IFlagService, stubFlag(false)), ]); hosts.push(host); return { host, fake, workDir, outsideDir, statCalls }; @@ -190,9 +186,11 @@ async function runBefore( return ctx; } -async function runPrepared(ctx: ToolBeforeExecuteContext): Promise { - if (ctx.decision?.execute === undefined) return; - await ctx.decision.execute({ +async function runPrepared( + ctx: ToolBeforeExecuteContext, +): Promise { + if (ctx.decision?.execute === undefined) return undefined; + return ctx.decision.execute({ turnId: ctx.turnId, toolCallId: ctx.toolCall.id, trace: ctx.trace, @@ -242,8 +240,10 @@ async function runOk( path: string, opts: { id?: string; turnId?: number; args?: Record } = {}, ): Promise { - const ctx = await runBefore(world, beforeCtx(toolName, path, opts)); - expect(ctx.decision?.block).not.toBe(true); + const ctx = beforeCtx(toolName, path, opts); + await world.executor.hooks.onBeforeExecuteTool.run(ctx); + const prepared = await runPrepared(ctx); + expect(prepared?.isError).not.toBe(true); if (toolName === 'Write') { const args = ctx.args as { readonly content: string; readonly mode?: 'overwrite' | 'append' }; writeFileSync(path, args.content, { flag: args.mode === 'append' ? 'a' : 'w' }); @@ -251,6 +251,20 @@ async function runOk( return runDid(world, ctx); } +async function runBlocked( + world: AgentWorld, + toolName: string, + path: string, +): Promise { + const ctx = beforeCtx(toolName, path); + await world.executor.hooks.onBeforeExecuteTool.run(ctx); + const result = await runPrepared(ctx); + if (result?.isError !== true) { + throw new Error(`expected ${toolName} on ${path} to be blocked, got: ${JSON.stringify(result)}`); + } + return result; +} + function foldChange(world: AgentWorld, rel: string, action: 'created' | 'modified' | 'deleted'): void { world.env.fake.fire(rel, action); vi.advanceTimersByTime(200); @@ -266,7 +280,6 @@ const cleanupPaths: string[] = []; describe('AgentFileFencingService', () => { beforeEach(() => { - multiServer = false; vi.useFakeTimers(); }); afterEach(() => { @@ -275,360 +288,277 @@ describe('AgentFileFencingService', () => { vi.useRealTimers(); }); - describe('with the multi_server flag on', () => { - it('blocks Edit on an existing file that was never read, read-first', async () => { - multiServer = true; - const world = setup(); - const file = join(world.env.workDir, 'a.txt'); - writeFileSync(file, 'hello'); - - const ctx = await runBefore(world, beforeCtx('Edit', file)); - expect(ctx.decision?.block).toBe(true); - expect(ctx.decision?.reason).toContain('has not been read in this session'); - expect(ctx.decision?.reason).toContain('Read it first'); - }); - - it('blocks Edit when the file changed on disk since the last read, and unblocks after re-read', async () => { - multiServer = true; - const world = setup(); - const file = join(world.env.workDir, 'a.txt'); - writeFileSync(file, 'hello'); - await runOk(world, 'Read', file); + it('blocks Edit on an existing file that was never read, read-first', async () => { + const world = setup(); + const file = join(world.env.workDir, 'a.txt'); + writeFileSync(file, 'hello'); - writeFileSync(file, 'hello world'); - foldChange(world, 'a.txt', 'modified'); - - const ctx = await runBefore(world, beforeCtx('Edit', file)); - expect(ctx.decision?.block).toBe(true); - expect(ctx.decision?.reason).toContain('changed on disk since'); - - await runOk(world, 'Read', file); - const retry = await runBefore(world, beforeCtx('Edit', file)); - expect(retry.decision?.block).not.toBe(true); - }); - - it('checks the file at execution time rather than during preflight', async () => { - multiServer = true; - const world = setup(); - const file = join(world.env.workDir, 'a.txt'); - writeFileSync(file, 'hello'); - await runOk(world, 'Read', file); - - const ctx = beforeCtx('Edit', file); - await world.executor.hooks.onBeforeExecuteTool.run(ctx); - writeFileSync(file, 'changed while queued'); - - await runPrepared(ctx); - expect(ctx.decision?.block).toBe(true); - expect(ctx.decision?.reason).toContain('changed on disk since'); - }); - - it('keeps the revision captured by Read when the file changes before the did-hook', async () => { - multiServer = true; - const world = setup(); - const file = join(world.env.workDir, 'a.txt'); - writeFileSync(file, 'hello'); - const ctx = await runBefore(world, beforeCtx('Read', file)); - const readRevision = makeToolFileRevision(file, statSync(file)); - - writeFileSync(file, 'changed after read'); - await runDid(world, ctx, { - output: 'hello', - [toolFileRevision]: readRevision, - }); - - const edit = await runBefore(world, beforeCtx('Edit', file)); - expect(edit.decision?.block).toBe(true); - expect(edit.decision?.reason).toContain('changed on disk since'); - }); - - it('wraps an execution override installed by an earlier hook', async () => { - const world = setup(); - const file = join(world.env.workDir, 'a.txt'); - writeFileSync(file, 'hello'); - await runOk(world, 'Read', file); - let overrideCalls = 0; - const ctx = beforeCtx('Edit', file); - ctx.decision = { - execute: async () => { - overrideCalls++; - return { output: 'overridden' }; - }, - }; + const blocked = await runBlocked(world, 'Edit', file); + expect(blocked.output).toContain('has not been read in this session'); + expect(blocked.output).toContain('Read it first'); + }); - await world.executor.hooks.onBeforeExecuteTool.run(ctx); - await runPrepared(ctx); + it('blocks Edit when the file changed on disk since the last read, and unblocks after re-read', async () => { + const world = setup(); + const file = join(world.env.workDir, 'a.txt'); + writeFileSync(file, 'hello'); + await runOk(world, 'Read', file); - expect(overrideCalls).toBe(1); - }); + writeFileSync(file, 'hello world'); + foldChange(world, 'a.txt', 'modified'); - it('blocks Write over an existing file that was never read', async () => { - multiServer = true; - const world = setup(); - const file = join(world.env.workDir, 'a.txt'); - writeFileSync(file, 'hello'); + const blocked = await runBlocked(world, 'Edit', file); + expect(blocked.output).toContain('changed on disk since'); - const ctx = await runBefore(world, beforeCtx('Write', file)); - expect(ctx.decision?.block).toBe(true); - expect(ctx.decision?.reason).toContain('already exists'); - expect(ctx.decision?.reason).toContain('has not been read in this session'); - }); + await runOk(world, 'Read', file); + await runOk(world, 'Edit', file); + }); - it('allows Write creating a new file and baselines it', async () => { - multiServer = true; - const world = setup(); - const file = join(world.env.workDir, 'new.txt'); + it('checks the file at execution time rather than during preflight', async () => { + const world = setup(); + const file = join(world.env.workDir, 'a.txt'); + writeFileSync(file, 'hello'); + await runOk(world, 'Read', file); - await runOk(world, 'Write', file); - const ctx = await runBefore(world, beforeCtx('Edit', file)); - expect(ctx.decision?.block).not.toBe(true); - }); + const ctx = beforeCtx('Edit', file); + await world.executor.hooks.onBeforeExecuteTool.run(ctx); + writeFileSync(file, 'changed while queued'); - it('allows Edit right after a full Read', async () => { - multiServer = true; - const world = setup(); - const file = join(world.env.workDir, 'a.txt'); - writeFileSync(file, 'hello'); + const blocked = await runPrepared(ctx); + expect(blocked?.isError).toBe(true); + expect(blocked?.output).toContain('changed on disk since'); + }); - await runOk(world, 'Read', file); - const ctx = await runBefore(world, beforeCtx('Edit', file)); - expect(ctx.decision?.block).not.toBe(true); + it('keeps the revision captured by Read when the file changes before the did-hook', async () => { + const world = setup(); + const file = join(world.env.workDir, 'a.txt'); + writeFileSync(file, 'hello'); + const ctx = await runBefore(world, beforeCtx('Read', file)); + const readRevision = makeToolFileRevision(file, statSync(file)); + + writeFileSync(file, 'changed after read'); + await runDid(world, ctx, { + output: 'hello', + [toolFileRevision]: readRevision, }); - it('allows consecutive Edits without watcher events', async () => { - multiServer = true; - const world = setup(); - const file = join(world.env.workDir, 'a.txt'); - writeFileSync(file, 'hello'); + const blocked = await runBlocked(world, 'Edit', file); + expect(blocked.output).toContain('changed on disk since'); + }); - await runOk(world, 'Read', file); - await runOk(world, 'Edit', file); - const ctx = await runBefore(world, beforeCtx('Edit', file)); - expect(ctx.decision?.block).not.toBe(true); - }); + it('wraps an execution override installed by an earlier hook', async () => { + const world = setup(); + const file = join(world.env.workDir, 'a.txt'); + writeFileSync(file, 'hello'); + await runOk(world, 'Read', file); + let overrideCalls = 0; + const ctx = beforeCtx('Edit', file); + ctx.decision = { + execute: async () => { + overrideCalls++; + return { output: 'overridden' }; + }, + }; + + await world.executor.hooks.onBeforeExecuteTool.run(ctx); + await runPrepared(ctx); + + expect(overrideCalls).toBe(1); + }); - it('keeps consecutive Edits clean through the own-write watcher echo and re-baselines', async () => { - multiServer = true; - const world = setup(); - const file = join(world.env.workDir, 'a.txt'); - writeFileSync(file, 'hello'); - await runOk(world, 'Read', file); - expect(world.env.statCalls()).toBe(0); + it('blocks Write over an existing file that was never read', async () => { + const world = setup(); + const file = join(world.env.workDir, 'a.txt'); + writeFileSync(file, 'hello'); - foldChange(world, 'a.txt', 'modified'); + const blocked = await runBlocked(world, 'Write', file); + expect(blocked.output).toContain('already exists'); + expect(blocked.output).toContain('has not been read in this session'); + }); - const ctx = await runBefore(world, beforeCtx('Edit', file)); - expect(ctx.decision?.block).not.toBe(true); - expect(world.env.statCalls()).toBe(1); + it('allows Write creating a new file and baselines it', async () => { + const world = setup(); + const file = join(world.env.workDir, 'new.txt'); - const again = await runBefore(world, beforeCtx('Edit', file)); - expect(again.decision?.block).not.toBe(true); - expect(world.env.statCalls()).toBe(2); - }); + await runOk(world, 'Write', file); + await runOk(world, 'Edit', file); + }); - it('resolves a truncated window by stat punch: unchanged passes, changed blocks', async () => { - multiServer = true; - const world = setup(); - const file = join(world.env.workDir, 'a.txt'); - writeFileSync(file, 'hello'); - await runOk(world, 'Read', file); + it('allows Edit right after a full Read', async () => { + const world = setup(); + const file = join(world.env.workDir, 'a.txt'); + writeFileSync(file, 'hello'); - foldJunk(world.env); + await runOk(world, 'Read', file); + await runOk(world, 'Edit', file); + }); - const unchanged = await runBefore(world, beforeCtx('Edit', file)); - expect(unchanged.decision?.block).not.toBe(true); + it('allows consecutive Edits without watcher events', async () => { + const world = setup(); + const file = join(world.env.workDir, 'a.txt'); + writeFileSync(file, 'hello'); - writeFileSync(file, 'hello world'); - foldJunk(world.env); + await runOk(world, 'Read', file); + await runOk(world, 'Edit', file); + await runOk(world, 'Edit', file); + }); - const changed = await runBefore(world, beforeCtx('Edit', file)); - expect(changed.decision?.block).toBe(true); - expect(changed.decision?.reason).toContain('changed on disk since'); - }); + it('keeps consecutive Edits clean through the own-write watcher echo and re-baselines', async () => { + const world = setup(); + const file = join(world.env.workDir, 'a.txt'); + writeFileSync(file, 'hello'); + await runOk(world, 'Read', file); + expect(world.env.statCalls()).toBe(0); - it('blocks ranged-Read followed by Edit because ranged reads never baseline', async () => { - multiServer = true; - const world = setup(); - const file = join(world.env.workDir, 'a.txt'); - writeFileSync(file, '1\n2\n3\n4\n5\n6\n'); + foldChange(world, 'a.txt', 'modified'); - await runOk(world, 'Read', file, { args: { path: file, line_offset: 5 } }); - const ctx = await runBefore(world, beforeCtx('Edit', file)); - expect(ctx.decision?.block).toBe(true); - expect(ctx.decision?.reason).toContain('has not been read in this session'); - }); + await runOk(world, 'Edit', file); + expect(world.env.statCalls()).toBe(1); - it('blocks out-of-root writes through the stat-only fallback and allows them after Read', async () => { - multiServer = true; - const world = setup(); - const file = join(world.env.outsideDir, 'b.txt'); - writeFileSync(file, 'hello'); + await runOk(world, 'Edit', file); + expect(world.env.statCalls()).toBe(2); + }); - const first = await runBefore(world, beforeCtx('Edit', file)); - expect(first.decision?.block).toBe(true); - expect(first.decision?.reason).toContain('has not been read in this session'); + it('resolves a truncated window by stat punch: unchanged passes, changed blocks', async () => { + const world = setup(); + const file = join(world.env.workDir, 'a.txt'); + writeFileSync(file, 'hello'); + await runOk(world, 'Read', file); - await runOk(world, 'Read', file); - const afterRead = await runBefore(world, beforeCtx('Edit', file)); - expect(afterRead.decision?.block).not.toBe(true); + foldJunk(world.env); - writeFileSync(file, 'hello world'); - const changed = await runBefore(world, beforeCtx('Edit', file)); - expect(changed.decision?.block).toBe(true); - expect(changed.decision?.reason).toContain('changed on disk since'); - }); + await runOk(world, 'Edit', file); - it('ensures an additional dir becomes watched when a write target falls under it', async () => { - multiServer = true; - const world = setup(); - world.workspace.addAdditionalDir(world.env.outsideDir); - const file = join(world.env.outsideDir, 'new.txt'); - - await runOk(world, 'Write', file); - expect(world.watch.watchedRoots).toContain(world.env.outsideDir); - expect(world.env.fake.watchCalls).toContain(world.env.outsideDir); - - writeFileSync(file, 'changed outside'); - world.env.fake.handles - .find((h) => h.root === world.env.outsideDir) - ?.fire('new.txt', 'modified'); - vi.advanceTimersByTime(200); - - const ctx = await runBefore(world, beforeCtx('Write', file)); - expect(ctx.decision?.block).toBe(true); - expect(ctx.decision?.reason).toContain('changed on disk since'); - }); + writeFileSync(file, 'hello world'); + foldJunk(world.env); - it('keeps ledgers on two session scopes sharing one workspace independent and flags the peer change', async () => { - multiServer = true; - const env = makeEnv(); - const worldA = makeAgent(env, makeSession(env, 'sA', env.workDir)); - const worldB = makeAgent(env, makeSession(env, 'sB', env.workDir)); - const file = join(env.workDir, 'a.txt'); - writeFileSync(file, 'hello'); - - await runOk(worldA, 'Read', file); - - const neverRead = await runBefore(worldB, beforeCtx('Edit', file)); - expect(neverRead.decision?.block).toBe(true); - expect(neverRead.decision?.reason).toContain('has not been read in this session'); - - writeFileSync(file, 'hello world'); - env.fake.handles - .findLast((h) => h.root === env.workDir) - ?.fire('a.txt', 'modified'); - vi.advanceTimersByTime(200); - - const conflict = await runBefore(worldB, beforeCtx('Edit', file)); - expect(conflict.decision?.block).toBe(true); - expect(conflict.decision?.reason).toContain('changed on disk since'); - }); + const blocked = await runBlocked(world, 'Edit', file); + expect(blocked.output).toContain('changed on disk since'); }); - describe('with the multi_server flag off', () => { - it('admits Edit on an unread existing file with an advisory, then re-baselines', async () => { - const world = setup(); - const file = join(world.env.workDir, 'a.txt'); - writeFileSync(file, 'hello'); + it('blocks ranged-Read followed by Edit because ranged reads never baseline', async () => { + const world = setup(); + const file = join(world.env.workDir, 'a.txt'); + writeFileSync(file, '1\n2\n3\n4\n5\n6\n'); - const did = await runOk(world, 'Edit', file); - expect(did.result.note).toContain('Warning:'); - expect(did.result.note).toContain('had not been read in this session'); + await runOk(world, 'Read', file, { args: { path: file, line_offset: 5 } }); + const blocked = await runBlocked(world, 'Edit', file); + expect(blocked.output).toContain('has not been read in this session'); + }); - const second = await runOk(world, 'Edit', file); - expect(second.result.note).toBeUndefined(); - }); + it('blocks out-of-root writes through the stat-only fallback and allows them after Read', async () => { + const world = setup(); + const file = join(world.env.outsideDir, 'b.txt'); + writeFileSync(file, 'hello'); - it('admits Write over an unread existing file with an advisory', async () => { - const world = setup(); - const file = join(world.env.workDir, 'a.txt'); - writeFileSync(file, 'hello'); + const first = await runBlocked(world, 'Edit', file); + expect(first.output).toContain('has not been read in this session'); - const did = await runOk(world, 'Write', file); - expect(did.result.note).toContain('already existed on disk'); - }); + await runOk(world, 'Read', file); + await runOk(world, 'Edit', file); - it('admits Edit after an outside change with the applied-anyway advisory', async () => { - const world = setup(); - const file = join(world.env.workDir, 'a.txt'); - writeFileSync(file, 'hello'); - await runOk(world, 'Read', file); + writeFileSync(file, 'hello world'); + const changed = await runBlocked(world, 'Edit', file); + expect(changed.output).toContain('changed on disk since'); + }); - writeFileSync(file, 'hello world'); - foldChange(world, 'a.txt', 'modified'); + it('ensures an additional dir becomes watched when a write target falls under it', async () => { + const world = setup(); + world.workspace.addAdditionalDir(world.env.outsideDir); + const file = join(world.env.outsideDir, 'new.txt'); - const did = await runOk(world, 'Edit', file); - expect(did.result.note).toContain('changed on disk since it was last read in this session'); - expect(did.result.note).toContain('your change was applied anyway'); + await runOk(world, 'Write', file); + expect(world.watch.watchedRoots).toContain(world.env.outsideDir); + expect(world.env.fake.watchCalls).toContain(world.env.outsideDir); - const second = await runOk(world, 'Edit', file); - expect(second.result.note).toBeUndefined(); - }); + writeFileSync(file, 'changed outside'); + world.env.fake.handles + .find((h) => h.root === world.env.outsideDir) + ?.fire('new.txt', 'modified'); + vi.advanceTimersByTime(200); - it('composes the advisory with an existing result note', async () => { - const world = setup(); - const file = join(world.env.workDir, 'a.txt'); - writeFileSync(file, 'hello'); - - const ctx = await runBefore(world, beforeCtx('Edit', file)); - const did = await runDid(world, ctx, { output: 'done', note: 'existing' }); - expect(did.result.note).toBe( - 'existing\n' + - `Warning: "${file}" already existed on disk and had not been read in this session; ` + - 'your change overwrote it anyway. Read the file to verify the current content.', - ); - }); + const blocked = await runBlocked(world, 'Write', file); + expect(blocked.output).toContain('changed on disk since'); + }); - it('never advisories direct creation of a new file', async () => { - const world = setup(); - const did = await runOk(world, 'Write', join(world.env.workDir, 'new.txt')); - expect(did.result.note).toBeUndefined(); - }); + it('keeps ledgers on two session scopes sharing one workspace independent and flags the peer change', async () => { + const env = makeEnv(); + const worldA = makeAgent(env, makeSession(env, 'sA', env.workDir)); + const worldB = makeAgent(env, makeSession(env, 'sB', env.workDir)); + const file = join(env.workDir, 'a.txt'); + writeFileSync(file, 'hello'); - it('adds no advisory and no baseline when the tool result is an error', async () => { - const world = setup(); - const file = join(world.env.workDir, 'a.txt'); - writeFileSync(file, 'hello'); - await runOk(world, 'Read', file); + await runOk(worldA, 'Read', file); - writeFileSync(file, 'hello world'); - foldChange(world, 'a.txt', 'modified'); + const neverRead = await runBlocked(worldB, 'Edit', file); + expect(neverRead.output).toContain('has not been read in this session'); - const failed = await runBefore(world, beforeCtx('Edit', file)); - const failedDid = await runDid(world, failed, { output: 'boom', isError: true }); - expect(failedDid.result.note).toBeUndefined(); + writeFileSync(file, 'hello world'); + env.fake.handles + .findLast((h) => h.root === env.workDir) + ?.fire('a.txt', 'modified'); + vi.advanceTimersByTime(200); - const retry = await runOk(world, 'Edit', file); - expect(retry.result.note).toContain('changed on disk since it was last read in this session'); - }); + const conflict = await runBlocked(worldB, 'Edit', file); + expect(conflict.output).toContain('changed on disk since'); + }); - it('does not leak a stale mark across turns', async () => { - const world = setup(); - const file = join(world.env.workDir, 'a.txt'); - writeFileSync(file, 'hello'); + it('leaves direct creation of a new file without a result note', async () => { + const world = setup(); + const did = await runOk(world, 'Write', join(world.env.workDir, 'new.txt')); + expect(did.result.note).toBeUndefined(); + }); - await runBefore(world, beforeCtx('Edit', file, { id: 'call-abandoned', turnId: 1 })); - await runBefore( - world, - beforeCtx('Edit', join(world.env.workDir, 'other.txt'), { turnId: 2 }), - ); + it('records no baseline and stays blocked when the fenced call fails', async () => { + const world = setup(); + const file = join(world.env.workDir, 'a.txt'); + writeFileSync(file, 'hello'); + await runOk(world, 'Read', file); + + writeFileSync(file, 'hello world'); + foldChange(world, 'a.txt', 'modified'); + + // The stale verdict blocks the Edit; the wrapper's error result must not + // be baselined by the did-hook, so the file stays stale until re-read. + const failed = beforeCtx('Edit', file); + await world.executor.hooks.onBeforeExecuteTool.run(failed); + const failedResult = await runPrepared(failed); + expect(failedResult?.isError).toBe(true); + await runDid(world, failed, { output: 'boom', isError: true }); + + const retry = await runBlocked(world, 'Edit', file); + expect(retry.output).toContain('changed on disk since'); + }); - const abandonedCtx = beforeCtx('Edit', file, { id: 'call-abandoned', turnId: 1 }); - const did = await runDid(world, abandonedCtx); - expect(did.result.note).toBeUndefined(); - }); + it('does not leak a target across turns', async () => { + const world = setup(); + const file = join(world.env.workDir, 'a.txt'); + writeFileSync(file, 'hello'); + + await runBefore(world, beforeCtx('Edit', file, { id: 'call-abandoned', turnId: 1 })); + await runBefore( + world, + beforeCtx('Edit', join(world.env.workDir, 'other.txt'), { turnId: 2 }), + ); + + // A late did-hook for the abandoned turn-1 call finds no swept target and + // records nothing: a.txt stays never-read, so a real Edit still blocks. + const abandonedCtx = beforeCtx('Edit', file, { id: 'call-abandoned', turnId: 1 }); + await runDid(world, abandonedCtx); + const retry = await runBlocked(world, 'Edit', file); + expect(retry.output).toContain('has not been read in this session'); + }); - it('ignores tools other than Read/Write/Edit entirely', async () => { - const world = setup(); - const ctx = await runBefore( - world, - beforeCtx('Bash', join(world.env.workDir, 'a.txt'), { args: { command: 'ls' } }), - ); - expect(ctx.decision).toBeUndefined(); - - const did = await runDid(world, ctx); - expect(did.result.note).toBeUndefined(); - expect(world.env.statCalls()).toBe(0); - }); + it('ignores tools other than Read/Write/Edit entirely', async () => { + const world = setup(); + const ctx = await runBefore( + world, + beforeCtx('Bash', join(world.env.workDir, 'a.txt'), { args: { command: 'ls' } }), + ); + expect(ctx.decision).toBeUndefined(); + + const did = await runDid(world, ctx); + expect(did.result.note).toBeUndefined(); + expect(world.env.statCalls()).toBe(0); }); }); diff --git a/packages/agent-core-v2/test/app/sessionLifecycle/sessionLifecycle.test.ts b/packages/agent-core-v2/test/app/sessionLifecycle/sessionLifecycle.test.ts index ecaa3c0793..29e304352f 100644 --- a/packages/agent-core-v2/test/app/sessionLifecycle/sessionLifecycle.test.ts +++ b/packages/agent-core-v2/test/app/sessionLifecycle/sessionLifecycle.test.ts @@ -1391,11 +1391,10 @@ describe('SessionLifecycleService', () => { } }); - it('reports the routable phase with the holder address when multi_server is on', async () => { + it('reports the routable phase with the holder address', async () => { const root = await makeTmpRoot(); const first = build( realInstanceSeeds(root, [ - stubPair(IFlagService, stubFlag(true)), stubPair( ISessionLeaseContactProvider, new SessionLeaseContactProvider(() => ({ @@ -1408,9 +1407,7 @@ describe('SessionLifecycleService', () => { const firstHost = host!; await first.create({ sessionId: 's1', workDir: '/tmp/proj' }); try { - const second = build( - realInstanceSeeds(root, [stubPair(IFlagService, stubFlag(true))]), - ); + const second = build(realInstanceSeeds(root)); const error = await createError(second, 's1'); expect(error.code).toBe(ErrorCodes.SESSION_HELD_BY_PEER); expect(error.details).toEqual({ @@ -1423,31 +1420,6 @@ describe('SessionLifecycleService', () => { } }); - it('never emits the holder address when multi_server is off', async () => { - const root = await makeTmpRoot(); - const first = build( - realInstanceSeeds(root, [ - stubPair( - ISessionLeaseContactProvider, - new SessionLeaseContactProvider(() => ({ - type: 'address', - address: 'http://127.0.0.1:5555', - })), - ), - ]), - ); - const firstHost = host!; - await first.create({ sessionId: 's1', workDir: '/tmp/proj' }); - try { - const second = build(realInstanceSeeds(root)); - const error = await createError(second, 's1'); - expect(error.code).toBe(ErrorCodes.SESSION_HELD_BY_PEER); - expect(error.details).toEqual({ kind: 'held-by-peer', phase: 'held-by-local-instance' }); - } finally { - firstHost.dispose(); - } - }); - it('reports the creating phase while the kernel holder publishes owner metadata', async () => { const root = await makeTmpRoot(); const handle = tryAcquireKernelFileLock(leaseFile(root, 's1'))!; diff --git a/packages/agent-core-v2/test/persistence/backends/node-fs/fileStorageService.test.ts b/packages/agent-core-v2/test/persistence/backends/node-fs/fileStorageService.test.ts index c173164776..63c8e6755b 100644 --- a/packages/agent-core-v2/test/persistence/backends/node-fs/fileStorageService.test.ts +++ b/packages/agent-core-v2/test/persistence/backends/node-fs/fileStorageService.test.ts @@ -1,10 +1,12 @@ -import { mkdtemp, mkdir, rm, stat, writeFile } from 'node:fs/promises'; +import { mkdtemp, mkdir, readFile, rm, stat, writeFile } from 'node:fs/promises'; import { tmpdir } from 'node:os'; import { join } from 'pathe'; import { afterEach, beforeEach, describe, expect, it } from 'vitest'; +import { Error2, ErrorCodes } from '#/errors'; import { FileStorageService } from '#/persistence/backends/node-fs/fileStorageService'; +import { WriteAuthorityRegistryService } from '#/persistence/backends/node-fs/writeAuthorityRegistryService'; const isWin = process.platform === 'win32'; const encoder = new TextEncoder(); @@ -87,3 +89,57 @@ describe('FileStorageService — error translation', () => { }); }); }); + +describe('FileStorageService — session write fencing', () => { + let dir: string; + + beforeEach(async () => { + dir = await mkdtemp(join(tmpdir(), 'fss-fence-')); + }); + + afterEach(async () => { + await rm(dir, { recursive: true, force: true }); + }); + + it('revalidates the session authority for write, append, and delete', async () => { + const registry = new WriteAuthorityRegistryService(); + let writable = true; + const registration = registry.register({ + sessionId: 'session', + assertWritable: () => { + if (!writable) { + throw new Error2(ErrorCodes.SESSION_LEASE_LOST, 'session lease lost'); + } + }, + }); + const svc = new FileStorageService(dir, undefined, undefined, undefined, registry); + const scope = 'sessions/workspace/session/agents/main/tool-results'; + + await svc.write(scope, 'result.txt', encoder.encode('a')); + await svc.append(scope, 'result.txt', encoder.encode('b')); + expect(await readFile(join(dir, scope, 'result.txt'), 'utf8')).toBe('ab'); + + writable = false; + await expect(svc.write(scope, 'result.txt', encoder.encode('c'))).rejects.toMatchObject({ + code: ErrorCodes.SESSION_LEASE_LOST, + }); + await expect(svc.append(scope, 'result.txt', encoder.encode('c'))).rejects.toMatchObject({ + code: ErrorCodes.SESSION_LEASE_LOST, + }); + await expect(svc.delete(scope, 'result.txt')).rejects.toMatchObject({ + code: ErrorCodes.SESSION_LEASE_LOST, + }); + expect(await readFile(join(dir, scope, 'result.txt'), 'utf8')).toBe('ab'); + registration.dispose(); + }); + + it('fails closed without a session authority and leaves non-session scopes untouched', async () => { + const registry = new WriteAuthorityRegistryService(); + const svc = new FileStorageService(dir, undefined, undefined, undefined, registry); + + await expect( + svc.write('sessions/workspace/session/agents/main/blobs', 'blob', encoder.encode('x')), + ).rejects.toMatchObject({ code: ErrorCodes.SESSION_LEASE_LOST }); + await expect(svc.write('cron/workspace', 'task.json', encoder.encode('{}'))).resolves.toBeUndefined(); + }); +}); diff --git a/packages/agent-core-v2/test/session/agentLifecycle/agentLifecycle.test.ts b/packages/agent-core-v2/test/session/agentLifecycle/agentLifecycle.test.ts index 4ff869a794..9fa0303f83 100644 --- a/packages/agent-core-v2/test/session/agentLifecycle/agentLifecycle.test.ts +++ b/packages/agent-core-v2/test/session/agentLifecycle/agentLifecycle.test.ts @@ -301,6 +301,7 @@ describe('AgentLifecycleService', () => { ix.stub(IAgentTaskService, { _serviceBrand: undefined, stopAllOnExit, + flushPersistence: async () => {}, } as unknown as IAgentTaskService); ix.stub(IAgentFullCompactionService, { _serviceBrand: undefined, diff --git a/packages/agent-core-v2/test/session/sessionLease/sessionLease.test.ts b/packages/agent-core-v2/test/session/sessionLease/sessionLease.test.ts index 37bc759dad..fe44c2c36e 100644 --- a/packages/agent-core-v2/test/session/sessionLease/sessionLease.test.ts +++ b/packages/agent-core-v2/test/session/sessionLease/sessionLease.test.ts @@ -6,7 +6,7 @@ * notification, idempotent release, and contact-provider seed semantics. */ -import { existsSync, mkdtempSync, rmSync } from 'node:fs'; +import { existsSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs'; import { tmpdir } from 'node:os'; import { join } from 'node:path'; @@ -68,7 +68,6 @@ function hostWith(seeds: Parameters[0] = []): Scope describe('SessionLease', () => { it('reports its identity through info and passes the hard gate while held', async () => { const lease = await acquire(); - expect(lease.checkHeld()).toBe(true); expect(lease.info).toEqual({ sessionId: 's1', lockId: lease.lockId }); expect(() => lease.assertWritable()).not.toThrow(); lease.release(); @@ -77,12 +76,13 @@ describe('SessionLease', () => { it('fires the loss notification once and then fails closed', async () => { const onLost = vi.fn(); const lease = await acquire('s1', onLost); - lease.markLost(); + // Replacing the sentinel fails the kernel handle's dev/ino identity + // check, driving the loss path through the real gate. + rmSync(sessionLeasePath(tmpDir, 's1')); + writeFileSync(sessionLeasePath(tmpDir, 's1'), ''); expect(thrownError(() => lease.assertWritable()).code).toBe(ErrorCodes.SESSION_LEASE_LOST); expect(onLost).toHaveBeenCalledTimes(1); expect(onLost).toHaveBeenCalledWith('s1'); - lease.markLost(); - expect(onLost).toHaveBeenCalledTimes(1); expect(thrownError(() => lease.assertWritable()).code).toBe(ErrorCodes.SESSION_LEASE_LOST); expect(onLost).toHaveBeenCalledTimes(1); }); @@ -92,7 +92,6 @@ describe('SessionLease', () => { lease.release(); lease.release(); - expect(lease.released).toBe(true); expect(lease.info).toBeUndefined(); expect(existsSync(sessionLeasePath(tmpDir, 's1'))).toBe(true); expect(existsSync(`${sessionLeasePath(tmpDir, 's1')}.owner.json`)).toBe(false); diff --git a/packages/kap-server/src/envelope.ts b/packages/kap-server/src/envelope.ts index 88f626cca3..2611cb6727 100644 --- a/packages/kap-server/src/envelope.ts +++ b/packages/kap-server/src/envelope.ts @@ -4,4 +4,9 @@ * Keep this file as a re-export shim so downstream `from './envelope'` * imports inside the server stay stable and don't all need to be touched. */ -export { okEnvelope, errEnvelope, type Envelope } from './protocol/envelope'; +export { + okEnvelope, + errEnvelope, + ownershipRedirectEnvelope, + type Envelope, +} from './protocol/envelope'; diff --git a/packages/kap-server/src/error-handler.ts b/packages/kap-server/src/error-handler.ts index 2fdb5af3a2..5225d40678 100644 --- a/packages/kap-server/src/error-handler.ts +++ b/packages/kap-server/src/error-handler.ts @@ -23,7 +23,7 @@ */ import { ErrorCodes, isError2 } from '@moonshot-ai/agent-core-v2'; -import { errEnvelope } from './envelope'; +import { errEnvelope, ownershipRedirectEnvelope } from './envelope'; import { ErrorCode } from './protocol/error-codes'; import type { FastifyError } from 'fastify'; @@ -50,15 +50,7 @@ export function installErrorHandler(app: ErrorHandlerHost): void { // server failure: surface 40921 with the structured details (phase / // redirect address) and keep the stack in the log only. if (isError2(err) && err.code === ErrorCodes.SESSION_HELD_BY_PEER) { - reply.status(200).send( - errEnvelope( - ErrorCode.SESSION_HELD_BY_PEER, - err.message, - requestId, - undefined, - err.details, - ), - ); + reply.status(200).send(ownershipRedirectEnvelope(err, requestId)); return; } req.log.error({ err, request_id: requestId }, 'unhandled error'); diff --git a/packages/kap-server/src/protocol/envelope.ts b/packages/kap-server/src/protocol/envelope.ts index b28d2b09f8..96361eec35 100644 --- a/packages/kap-server/src/protocol/envelope.ts +++ b/packages/kap-server/src/protocol/envelope.ts @@ -6,6 +6,8 @@ import { z } from 'zod'; +import { ErrorCode } from './error-codes'; + export const envelopeSchema = (data: T) => z.object({ code: z.number().int(), @@ -48,3 +50,23 @@ export function errEnvelope( ): Envelope { return { code, msg, data: null, request_id: requestId, stack, details }; } + +/** + * Build the `40921 session.held_by_peer` ownership-redirect envelope. The + * structured `details` payload (`held-by-peer` phase / redirect address) is + * the actionable part, so it rides the envelope while the stack stays + * server-side. Accepts the `Error2` shape structurally so this module stays + * free of the engine dependency. + */ +export function ownershipRedirectEnvelope( + err: { readonly message: string; readonly details?: unknown }, + requestId: string, +): Envelope { + return errEnvelope( + ErrorCode.SESSION_HELD_BY_PEER, + err.message, + requestId, + undefined, + err.details, + ); +} diff --git a/packages/kap-server/src/routes/fs.ts b/packages/kap-server/src/routes/fs.ts index 5eb10375ab..3dc5a6962f 100644 --- a/packages/kap-server/src/routes/fs.ts +++ b/packages/kap-server/src/routes/fs.ts @@ -33,7 +33,7 @@ import { } from '@moonshot-ai/agent-core-v2/session/sessionFs/fs'; import { z } from 'zod'; -import { errEnvelope, okEnvelope } from '../envelope'; +import { errEnvelope, okEnvelope, ownershipRedirectEnvelope } from '../envelope'; import { launchDetached, openFileCommandFor, @@ -531,18 +531,7 @@ function sendMappedError(reply: Reply, req: { id: string }, err: unknown): void reply.send(errEnvelope(ErrorCode.SESSION_NOT_FOUND, err.message, requestId, err.stack)); return; case ErrorCodes.SESSION_HELD_BY_PEER: - // Ownership redirect: the details payload (`held-by-peer` phase / - // address) is the actionable part, so it rides the envelope and the - // stack stays server-side. - reply.send( - errEnvelope( - ErrorCode.SESSION_HELD_BY_PEER, - err.message, - requestId, - undefined, - err.details, - ), - ); + reply.send(ownershipRedirectEnvelope(err, requestId)); return; // hostFs errors that escaped the sessionFs layer keep their `os.fs.*` // code; map them onto the closest v1 wire code (ENOTDIR collapses into diff --git a/packages/kap-server/src/routes/prompts.ts b/packages/kap-server/src/routes/prompts.ts index ee80c53545..a8a23d35fc 100644 --- a/packages/kap-server/src/routes/prompts.ts +++ b/packages/kap-server/src/routes/prompts.ts @@ -36,6 +36,7 @@ import { decodeBase64Prefix, isError2, Error2, + ErrorCodes, isModelAcceptedImageMime, normalizeImageMime, persistOriginalImage, @@ -59,7 +60,7 @@ import { } from '../protocol/rest-prompt'; import { z } from 'zod'; -import { errEnvelope, okEnvelope } from '../envelope'; +import { errEnvelope, okEnvelope, ownershipRedirectEnvelope } from '../envelope'; import { requestLog } from '../lib/requestLog'; import { defineRoute } from '../middleware/defineRoute'; import { ensureMainAgent, MAIN_AGENT_ID } from '../transport/mainAgent'; @@ -712,19 +713,8 @@ function sendMappedError( case 'session.busy': reply.send(errEnvelope(ErrorCode.SESSION_BUSY, err.message, requestId, err.stack)); return; - case 'session.held_by_peer': - // Ownership redirect: the details payload (`held-by-peer` phase / - // address) is the actionable part, so it rides the envelope and the - // stack stays server-side. - reply.send( - errEnvelope( - ErrorCode.SESSION_HELD_BY_PEER, - err.message, - requestId, - undefined, - err.details, - ), - ); + case ErrorCodes.SESSION_HELD_BY_PEER: + reply.send(ownershipRedirectEnvelope(err, requestId)); return; case 'prompt.already_completed': reply.send({ diff --git a/packages/kap-server/src/routes/sessions.ts b/packages/kap-server/src/routes/sessions.ts index c869dc216b..e3113c1efc 100644 --- a/packages/kap-server/src/routes/sessions.ts +++ b/packages/kap-server/src/routes/sessions.ts @@ -137,7 +137,7 @@ import { import { workspaceIdSchema } from '../protocol/workspace'; import { z } from 'zod'; -import { errEnvelope, okEnvelope } from '../envelope'; +import { errEnvelope, okEnvelope, ownershipRedirectEnvelope } from '../envelope'; import { requestLog } from '../lib/requestLog'; import { defineRoute } from '../middleware/defineRoute'; import { ensureMainAgent, MAIN_AGENT_ID } from '../transport/mainAgent'; @@ -1146,12 +1146,13 @@ function resolvePendingInteraction( * * - 'self': this instance materialized the session and still holds its write * lease (`ISessionLeaseService.info` survives only while held). - * - 'peer': a lease is on disk and held (or mid-creation) by someone else; - * `address` rides along when the holder advertised one — the redirect - * target. An unresponsive holder still counts as 'peer' (it is never - * auto-taken over). - * - 'none': no lease on disk, or a stale husk a dead holder left behind — - * the session is materialized nowhere and any instance may acquire it. + * - 'peer': the lease file is held (or mid-creation) under a live kernel + * lock by someone else; `address` rides along when the holder advertised + * one — the redirect target. + * - 'none': no live kernel lock on the lease file — the session is + * materialized nowhere and any instance may acquire it. A dead holder's + * lock is released by the kernel, so leftover owner metadata alone does + * not count as held. * * Advisory only: the sync `inspect` read can observe a holder that died the * next millisecond; the lease's own acquisition protocol stays the authority. @@ -1295,18 +1296,7 @@ function sendMappedError( }); return; case ErrorCodes.SESSION_HELD_BY_PEER: - // Ownership redirect: the details payload (`held-by-peer` phase / - // address) is the actionable part, so it rides the envelope and the - // stack stays server-side. - reply.send( - errEnvelope( - ErrorCode.SESSION_HELD_BY_PEER, - err.message, - requestId, - undefined, - err.details, - ), - ); + reply.send(ownershipRedirectEnvelope(err, requestId)); return; case ErrorCodes.GOAL_ALREADY_EXISTS: reply.send(errEnvelope(ErrorCode.GOAL_ALREADY_EXISTS, err.message, requestId, err.stack)); diff --git a/packages/kap-server/src/routes/terminals.ts b/packages/kap-server/src/routes/terminals.ts index f07b3f389f..4bc551c8e1 100644 --- a/packages/kap-server/src/routes/terminals.ts +++ b/packages/kap-server/src/routes/terminals.ts @@ -32,7 +32,7 @@ import { import { createTerminalRequestSchema } from '@moonshot-ai/agent-core-v2/os/interface/terminal'; import { z } from 'zod'; -import { errEnvelope, okEnvelope } from '../envelope'; +import { errEnvelope, okEnvelope, ownershipRedirectEnvelope } from '../envelope'; import { requestLog } from '../lib/requestLog'; import { defineRoute } from '../middleware/defineRoute'; import { ErrorCode } from '../protocol/error-codes'; @@ -242,18 +242,7 @@ function sendMappedError( reply.send(errEnvelope(ErrorCode.TERMINAL_NOT_FOUND, err.message, requestId, err.stack)); return; case ErrorCodes.SESSION_HELD_BY_PEER: - // Ownership redirect: the details payload (`held-by-peer` phase / - // address) is the actionable part, so it rides the envelope and the - // stack stays server-side. - reply.send( - errEnvelope( - ErrorCode.SESSION_HELD_BY_PEER, - err.message, - requestId, - undefined, - err.details, - ), - ); + reply.send(ownershipRedirectEnvelope(err, requestId)); return; } } diff --git a/packages/kap-server/src/services/sessionListWatch/sessionListWatchService.ts b/packages/kap-server/src/services/sessionListWatch/sessionListWatchService.ts index ca753dcf0f..f68c86b597 100644 --- a/packages/kap-server/src/services/sessionListWatch/sessionListWatchService.ts +++ b/packages/kap-server/src/services/sessionListWatch/sessionListWatchService.ts @@ -2,18 +2,18 @@ * `SessionListWatchService` — the event plane of multi-instance session-list * sync. * - * Several kap-server instances can share one home directory (the - * `multi_server` experimental flag). The session list itself needs no - * synchronization: `ISessionIndex.list()` re-enumerates the shared - * `/sessions` tree on every request, so a peer's sessions are visible on - * the next pull. What a peer can never produce is the *event* — core events - * are process-local. This service closes that gap locally: it watches the - * shared sessions tree and, on any workspace/session directory appearing or - * disappearing, publishes ONE debounced `session.list_changed` hint on this - * instance's core `IEventService`, which the `SessionEventBroadcaster` fans - * out live (volatile, never journaled) to every connected WS client. Clients - * then re-pull the list — the directory scan stays the single authority, the - * hint is pure "go refetch" advice and deliberately carries no payload. + * Several kap-server instances can share one home directory. The session list + * itself needs no synchronization: `ISessionIndex.list()` re-enumerates the + * shared `/sessions` tree on every request, so a peer's sessions are + * visible on the next pull. What a peer can never produce is the *event* — + * core events are process-local. This service closes that gap locally: it + * watches the shared sessions tree and, on any workspace/session directory + * appearing or disappearing, publishes ONE debounced `session.list_changed` + * hint on this instance's core `IEventService`, which the + * `SessionEventBroadcaster` fans out live (volatile, never journaled) to + * every connected WS client. Clients then re-pull the list — the directory + * scan stays the single authority, the hint is pure "go refetch" advice and + * deliberately carries no payload. * * Two-layer topology (a root recursive watch was rejected as an event flood): * - root `/sessions` at depth 0: workspace directories appearing / @@ -24,8 +24,8 @@ * `ignoreInitial` so boot produces no hint flood. * * This is transport state (like `FsWatchBridge` / `SessionEventBroadcaster`): - * constructed in `start.ts` when `multi_server` is on — never DI-registered — - * and disposed during server close before the core scope goes down. + * constructed in `start.ts` on every boot — never DI-registered — and + * disposed during server close before the core scope goes down. */ import { mkdirSync } from 'node:fs'; diff --git a/packages/kap-server/src/start.ts b/packages/kap-server/src/start.ts index 05311aac88..54b1578fdd 100644 --- a/packages/kap-server/src/start.ts +++ b/packages/kap-server/src/start.ts @@ -147,22 +147,6 @@ export interface RunningServer { const DEFAULT_HOST = '127.0.0.1'; const DEFAULT_PORT = 58627; -/** - * Env gate for the multi-server session-list sync below - * (`KIMI_CODE_EXPERIMENTAL_MULTI_SERVER`). Resolved directly from the - * environment *before* bootstrap, and deliberately NOT via the flag service / - * master `KIMI_CODE_EXPERIMENTAL_FLAG`: that switch already enables the v2 - * engine itself, and coupling multi-instance behavior to it would change the - * watch surface of every v2 server before the feature is ready. Keeping the - * gate specific makes multi-server strictly opt-in. - */ -const MULTI_SERVER_FLAG_ENV = 'KIMI_CODE_EXPERIMENTAL_MULTI_SERVER'; - -function isMultiServerEnabled(env: NodeJS.ProcessEnv): boolean { - const raw = (env[MULTI_SERVER_FLAG_ENV] ?? '').trim().toLowerCase(); - return raw === '1' || raw === 'true' || raw === 'yes' || raw === 'on'; -} - export async function startServer(opts: ServerStartOptions = {}): Promise { const host = opts.host ?? DEFAULT_HOST; const port = opts.port ?? DEFAULT_PORT; @@ -334,7 +318,7 @@ export async function startServer(opts: ServerStartOptions = {}): Promise + const sessionListWatch = new SessionListWatchService({ + sessionsDir: join(homeDir, 'sessions'), + fsWatch: core.accessor.get(IHostFsWatchService), + events: core.accessor.get(IEventService), + logger, + }); + sessionListWatch.start().catch((error: unknown) => logger.error({ err: error }, 'session list watch failed to start'), ); diff --git a/packages/kap-server/src/transport/ws/v1/sessionEventBroadcaster.ts b/packages/kap-server/src/transport/ws/v1/sessionEventBroadcaster.ts index 526a136377..7ebf7699d5 100644 --- a/packages/kap-server/src/transport/ws/v1/sessionEventBroadcaster.ts +++ b/packages/kap-server/src/transport/ws/v1/sessionEventBroadcaster.ts @@ -29,8 +29,6 @@ * the journal is continuous from first activation onward. */ -import { dirname } from 'node:path'; - import type { AgentActivityState, ApprovalResponse, @@ -54,7 +52,7 @@ import { ISessionIndex, ISessionLifecycleService, MAIN_AGENT_ID, - LEASE_CREATING_RETRY_AFTER_MS, + heldByPeerDetailsFromInspection, sessionLeasePath, } from '@moonshot-ai/agent-core-v2'; import type { TurnEndReason } from '@moonshot-ai/agent-core-v2/agent/loop/turnEvents'; @@ -193,7 +191,7 @@ export class SessionEventBroadcaster { constructor( private readonly opts: { readonly eventsDir: string; - readonly homeDir?: string; + readonly homeDir: string; readonly core: Scope; readonly logger?: JournalLogger; readonly maxBufferSize?: number; @@ -221,23 +219,10 @@ export class SessionEventBroadcaster { const summary = await this.opts.core.accessor.get(ISessionIndex).get(sessionId); if (summary === undefined) return undefined; - const homeDir = this.opts.homeDir ?? dirname(dirname(this.opts.eventsDir)); const inspection = this.opts.core .accessor.get(ICrossProcessLockService) - .inspect(sessionLeasePath(homeDir, sessionId)); - if (inspection.state === 'creating') { - return { - kind: 'held-by-peer', - phase: 'creating', - retry_after_ms: LEASE_CREATING_RETRY_AFTER_MS, - }; - } - if (inspection.state !== 'held' || inspection.payload === undefined) return undefined; - - if (inspection.payload.address !== undefined) { - return { kind: 'held-by-peer', phase: 'routable', address: inspection.payload.address }; - } - return { kind: 'held-by-peer', phase: 'held-by-local-instance' }; + .inspect(sessionLeasePath(this.opts.homeDir, sessionId)); + return heldByPeerDetailsFromInspection(inspection); } unsubscribe(sessionId: string, target: BroadcastTarget): void { @@ -288,8 +273,10 @@ export class SessionEventBroadcaster { // While the journal has unrecovered write failures, never serve from the // tail: those events are not durable, and replaying them as if they were // would resurrect the "fake durable" hole after a restart. `readSince` - // retries the pending flush and throws the sticky JournalStorageError - // instead — the replay edge maps that to a client-visible resync. + // flushes first (which retries a transient write failure); once the + // journal is sticky the flush is a no-op and `readSince` throws the + // sticky JournalStorageError instead — the replay edge maps that to a + // client-visible resync. if (!journal.writeFailure) { const tailStart = tail[0]?.seq; if (tailStart !== undefined && tailStart <= cursor.seq + 1) { @@ -446,47 +433,6 @@ export class SessionEventBroadcaster { return state; } - private ensureGlobalState(): Promise { - const existing = this.sessions.get(GLOBAL_SESSION_ID); - if (existing !== undefined) return Promise.resolve(existing); - let pending = this.pendingStates.get(GLOBAL_SESSION_ID); - if (pending === undefined) { - pending = this.createGlobalState().finally(() => { - if (this.pendingStates.get(GLOBAL_SESSION_ID) === pending) { - this.pendingStates.delete(GLOBAL_SESSION_ID); - } - }); - this.pendingStates.set(GLOBAL_SESSION_ID, pending); - } - return pending as Promise; - } - - private async createGlobalState(): Promise { - const journal = await SessionEventJournal.open( - sessionJournalPath(this.opts.eventsDir, GLOBAL_SESSION_ID), - this.opts.logger, - ); - const state: SessionState = { - sessionId: GLOBAL_SESSION_ID, - journal, - tracker: new InFlightTurnTracker(), - roster: new SubagentRosterTracker(), - activityByAgent: new Map(), - emittedBusy: false, - emittedMainTurnActive: false, - emittedPendingInteraction: 'none', - pendingInteraction: 'none', - tail: [], - targets: new Map(), - queue: Promise.resolve(), - agentDisposables: new Map(), - lifecycleDisposables: [], - knownInteractions: new Map(), - }; - this.sessions.set(GLOBAL_SESSION_ID, state); - return state; - } - private onCoreEvent(event: GlobalEvent): void { if (event.type === 'session.list_changed') { // Published by `SessionListWatchService` when a workspace/session diff --git a/packages/kap-server/src/transport/ws/v1/sessionEventJournal.ts b/packages/kap-server/src/transport/ws/v1/sessionEventJournal.ts index 8a2c6a0da2..91c21853a9 100644 --- a/packages/kap-server/src/transport/ws/v1/sessionEventJournal.ts +++ b/packages/kap-server/src/transport/ws/v1/sessionEventJournal.ts @@ -27,12 +27,15 @@ * for fan-out); bytes are flushed on a microtask-scheduled async batch. Each * batch uses a single `open(path, 'a')` → write → fsync → close cycle. Pending * lines are dequeued only AFTER the batch is durable; a failed round keeps the - * whole batch (and the pending header) for the retry. After + * whole batch (and the pending header) for a retry driven by the next + * append-scheduled or read-triggered flush. After * {@link STICKY_FAILURE_THRESHOLD} consecutive failures the journal goes - * sticky: `nextSeq()`/`append()` fail fast (pending can never grow unbounded) - * and `readSince()` throws a {@link JournalStorageError} instead of silently - * serving fewer events — "not served" must stay distinguishable from "nothing - * to serve". `readSince()` flushes first so replay never misses queued lines. + * sticky: `flush()` turns into a no-op (the kept pending lines are never + * retried), `nextSeq()`/`append()` fail fast (pending can never grow + * unbounded), and `readSince()` throws a {@link JournalStorageError} instead + * of silently serving fewer events — "not served" must stay distinguishable + * from "nothing to serve". `readSince()` flushes first so replay never misses + * queued lines. * A torn trailing line from a crash is tolerated and ignored on open, and a * pure cold-read open → close writes zero bytes. */ @@ -320,8 +323,8 @@ export class SessionEventJournal { } await this.flushPromise; // Give up once sticky instead of hot-spinning on a persistently failing - // disk; the kept pending lines are retried by the next append-scheduled - // or read-triggered round. + // disk: appends now fail fast and `readSince` throws, so the kept + // pending lines are never retried. if (this.stickyError !== undefined) return; } } @@ -379,7 +382,7 @@ export class SessionEventJournal { } catch (error) { const committed = await countCommittedPrefix(this.filePath, lines); if (committed > 0) { - if (headerLine !== undefined && committed > 0) this.headerPending = false; + if (headerLine !== undefined) this.headerPending = false; this.pendingLines.splice(0, Math.max(0, committed - (headerLine === undefined ? 0 : 1))); this.stickyError ??= new JournalStorageError(this.filePath, error); return true; diff --git a/packages/kap-server/src/transport/ws/v1/wsConnectionV1.ts b/packages/kap-server/src/transport/ws/v1/wsConnectionV1.ts index f101314765..338847ec2b 100644 --- a/packages/kap-server/src/transport/ws/v1/wsConnectionV1.ts +++ b/packages/kap-server/src/transport/ws/v1/wsConnectionV1.ts @@ -247,7 +247,7 @@ export class WsConnectionV1 implements BroadcastTarget { const filter = agentFilter?.[sid]; const ok = await this.broadcaster.subscribe(sid, this, filter); if (!ok) { - const ownership = await this.broadcaster.getSubscriptionFailure?.(sid); + const ownership = await this.broadcaster.getSubscriptionFailure(sid); if (ownership !== undefined) ownershipDetails[sid] = ownership; else notFound.push(sid); continue; @@ -335,7 +335,7 @@ export class WsConnectionV1 implements BroadcastTarget { ): Promise { const ok = await this.broadcaster.subscribe(sid, this, filter); if (!ok) { - const ownership = await this.broadcaster.getSubscriptionFailure?.(sid); + const ownership = await this.broadcaster.getSubscriptionFailure(sid); if (ownership !== undefined) ownershipDetails[sid] = ownership; else notFound.push(sid); return; diff --git a/packages/kap-server/test/session-ownership.e2e.test.ts b/packages/kap-server/test/session-ownership.e2e.test.ts index 83156280a9..e50db2405e 100644 --- a/packages/kap-server/test/session-ownership.e2e.test.ts +++ b/packages/kap-server/test/session-ownership.e2e.test.ts @@ -1,10 +1,10 @@ /** - * Multi-server session ownership — two kap-servers on ONE kimi home (the - * `multi_server` experimental flag on). Instance A creates the session and - * holds its write lease; instance B's materializing routes for the same - * session are answered with `40921 session.held_by_peer` carrying the - * structured ownership details (phase `routable` + A's address), so clients - * can redirect to the holder. Closing A releases the lease and B takes over. + * Multi-server session ownership — two kap-servers on ONE kimi home. Instance + * A creates the session and holds its write lease; instance B's materializing + * routes for the same session are answered with + * `40921 session.held_by_peer` carrying the structured ownership details + * (phase `routable` + A's address), so clients can redirect to the holder. + * Closing A releases the lease and B takes over. * Run: `pnpm --filter @moonshot-ai/kap-server exec vitest run test/session-ownership.e2e.test.ts`. */ import { mkdtemp, readFile, rm } from 'node:fs/promises'; @@ -13,13 +13,10 @@ import { join } from 'node:path'; import { sessionLeasePath } from '@moonshot-ai/agent-core-v2'; import { ErrorCode } from '../src/protocol/error-codes'; -import { afterEach, beforeEach, describe, expect, it } from 'vitest'; +import { afterEach, describe, expect, it } from 'vitest'; import { type RunningServer, startServer } from '../src/start'; -/** Same env gate start.ts checks locally (`KIMI_CODE_EXPERIMENTAL_MULTI_SERVER`). */ -const MULTI_SERVER_FLAG_ENV = 'KIMI_CODE_EXPERIMENTAL_MULTI_SERVER'; - interface Envelope { code: number; msg: string; @@ -37,16 +34,8 @@ describe('multi-server session ownership (session.held_by_peer → 40921)', () = let home: string | undefined; let serverA: RunningServer | undefined; let serverB: RunningServer | undefined; - let previousFlag: string | undefined; - - beforeEach(() => { - previousFlag = process.env[MULTI_SERVER_FLAG_ENV]; - process.env[MULTI_SERVER_FLAG_ENV] = '1'; - }); afterEach(async () => { - if (previousFlag === undefined) delete process.env[MULTI_SERVER_FLAG_ENV]; - else process.env[MULTI_SERVER_FLAG_ENV] = previousFlag; if (serverB !== undefined) { await serverB.close(); serverB = undefined; diff --git a/packages/kap-server/test/sessionEventBroadcaster.test.ts b/packages/kap-server/test/sessionEventBroadcaster.test.ts index 77e84bcf5d..f254f7f0f1 100644 --- a/packages/kap-server/test/sessionEventBroadcaster.test.ts +++ b/packages/kap-server/test/sessionEventBroadcaster.test.ts @@ -265,6 +265,7 @@ describe('SessionEventBroadcaster', () => { eventBus = new FakeEventBus(); bc = new SessionEventBroadcaster({ eventsDir: dir, + homeDir: dir, core: makeCore(sessions, eventBus), maxBufferSize: 3, }); @@ -1326,6 +1327,7 @@ describe('SessionEventBroadcaster', () => { const dir2 = await mkdtemp(join(tmpdir(), 'kimi-broadcaster-test-')); const bc2 = new SessionEventBroadcaster({ eventsDir: dir2, + homeDir: dir2, core: makeCore(sessions, eventBus), maxBufferSize: 20, }); diff --git a/packages/kap-server/test/wsConnectionV1.test.ts b/packages/kap-server/test/wsConnectionV1.test.ts index dda0bc04a6..f0e7e6d0de 100644 --- a/packages/kap-server/test/wsConnectionV1.test.ts +++ b/packages/kap-server/test/wsConnectionV1.test.ts @@ -62,6 +62,7 @@ function makeBroadcaster(): SessionEventBroadcaster { return { subscribe: async () => true, unsubscribe: () => {}, + getSubscriptionFailure: async () => undefined, getCursor: async () => ({ seq: 0, epoch: '' }), getBufferedSince: async () => ({ events: [], diff --git a/packages/kernel-file-lock/src/index.ts b/packages/kernel-file-lock/src/index.ts index c984d92bd9..7810a5cc61 100644 --- a/packages/kernel-file-lock/src/index.ts +++ b/packages/kernel-file-lock/src/index.ts @@ -9,19 +9,12 @@ export interface KernelFileLockBinding { export type KernelFileLockBindingLoader = () => KernelFileLockBinding | undefined; -export interface KernelFileLockAcquireOptions { - readonly timeoutMs: number; - readonly retryIntervalMs?: number; -} - export interface KernelFileLockHandle { readonly path: string; - readonly held: boolean; checkHeld(): boolean; release(): void; } -const DEFAULT_RETRY_INTERVAL_MS = 50; const bindingLoaderKey = Symbol.for('@moonshot-ai/kernel-file-lock/binding-loader'); const nodeRequire = createRequire(import.meta.url); @@ -42,10 +35,6 @@ function getBinding(): KernelFileLockBinding { return binding; } -function sleep(ms: number): Promise { - return new Promise((resolve) => setTimeout(resolve, ms)); -} - class KernelFileLockHandleImpl implements KernelFileLockHandle { private released = false; @@ -55,10 +44,6 @@ class KernelFileLockHandleImpl implements KernelFileLockHandle { private readonly binding: KernelFileLockBinding, ) {} - get held(): boolean { - return this.checkHeld(); - } - checkHeld(): boolean { if (this.released) return false; try { @@ -108,44 +93,3 @@ export function tryAcquireKernelFileLock(path: string): KernelFileLockHandle | u throw error; } } - -export async function acquireKernelFileLock( - path: string, - options: KernelFileLockAcquireOptions, -): Promise { - const deadline = Date.now() + options.timeoutMs; - const retryIntervalMs = options.retryIntervalMs ?? DEFAULT_RETRY_INTERVAL_MS; - let firstAttempt = true; - for (;;) { - const isFirstAttempt = firstAttempt; - if (!isFirstAttempt && Date.now() >= deadline) { - throw new KernelFileLockTimeoutError(path, options.timeoutMs); - } - firstAttempt = false; - const handle = tryAcquireKernelFileLock(path); - if (handle !== undefined) { - if (!isFirstAttempt && Date.now() >= deadline) { - handle.release(); - throw new KernelFileLockTimeoutError(path, options.timeoutMs); - } - return handle; - } - const remainingMs = deadline - Date.now(); - if (remainingMs <= 0) { - throw new KernelFileLockTimeoutError(path, options.timeoutMs); - } - await sleep(Math.min(retryIntervalMs, remainingMs)); - } -} - -export class KernelFileLockTimeoutError extends Error { - readonly code = 'ELOCKTIMEOUT'; - - constructor( - readonly path: string, - readonly timeoutMs: number, - ) { - super(`timed out waiting ${timeoutMs}ms for kernel file lock: ${path}`); - this.name = 'KernelFileLockTimeoutError'; - } -} diff --git a/packages/kernel-file-lock/test/kernel-file-lock.test.ts b/packages/kernel-file-lock/test/kernel-file-lock.test.ts index 2e286232dc..d558532cc9 100644 --- a/packages/kernel-file-lock/test/kernel-file-lock.test.ts +++ b/packages/kernel-file-lock/test/kernel-file-lock.test.ts @@ -8,11 +8,8 @@ import { fileURLToPath } from 'node:url'; import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; import { - acquireKernelFileLock, - KernelFileLockTimeoutError, setKernelFileLockBindingLoader, tryAcquireKernelFileLock, - type KernelFileLockBinding, type KernelFileLockHandle, } from '../src/index.js'; @@ -58,75 +55,6 @@ describe('kernel-file-lock', () => { handles.push(second!); }); - it('waits and times out without taking ownership', async () => { - const first = tryAcquireKernelFileLock(lockPath)!; - handles.push(first); - - await expect( - acquireKernelFileLock(lockPath, { timeoutMs: 15, retryIntervalMs: 5 }), - ).rejects.toBeInstanceOf(KernelFileLockTimeoutError); - }); - - it('does not acquire a lock released after the deadline', async () => { - const first = tryAcquireKernelFileLock(lockPath)!; - handles.push(first); - const releaseTimer = setTimeout(() => first.release(), 20); - - const result = await acquireKernelFileLock(lockPath, { - timeoutMs: 10, - retryIntervalMs: 100, - }).then( - (handle) => ({ status: 'acquired' as const, handle }), - (error: unknown) => ({ status: 'rejected' as const, error }), - ); - clearTimeout(releaseTimer); - - if (result.status === 'acquired') handles.push(result.handle); - expect(result.status).toBe('rejected'); - if (result.status === 'rejected') { - expect(result.error).toBeInstanceOf(KernelFileLockTimeoutError); - } - }); - - it('releases a lock acquired as a retry crosses the deadline', async () => { - let lockCalls = 0; - let unlockCalls = 0; - const times = [0, 0, 9, 10]; - const nativeBinding: KernelFileLockBinding = { - tryLock: () => { - lockCalls++; - return lockCalls > 1; - }, - unlock: () => { - unlockCalls++; - }, - }; - setKernelFileLockBindingLoader(() => nativeBinding); - vi.spyOn(Date, 'now').mockImplementation(() => times.shift() ?? 10); - - const result = await acquireKernelFileLock(lockPath, { - timeoutMs: 10, - retryIntervalMs: 0, - }).then( - (handle) => ({ status: 'acquired' as const, handle }), - (error: unknown) => ({ status: 'rejected' as const, error }), - ); - - if (result.status === 'acquired') handles.push(result.handle); - expect(result.status).toBe('rejected'); - if (result.status === 'rejected') { - expect(result.error).toBeInstanceOf(KernelFileLockTimeoutError); - } - expect(unlockCalls).toBe(1); - }); - - it('allows the immediate first attempt with a zero timeout', async () => { - const handle = await acquireKernelFileLock(lockPath, { timeoutMs: 0 }); - handles.push(handle); - - expect(handle.checkHeld()).toBe(true); - }); - it('coordinates with a separate process', async () => { const holderPath = fileURLToPath(new URL('./holder.ts', import.meta.url)); const child = spawn(process.execPath, ['--import', 'tsx', holderPath, lockPath], { diff --git a/packages/klient/AGENTS.md b/packages/klient/AGENTS.md index 104fb0af15..1609535d5a 100644 --- a/packages/klient/AGENTS.md +++ b/packages/klient/AGENTS.md @@ -92,10 +92,6 @@ Pick the helper by what the case needs: Hard rules: -- Same-home dual boot requires `KIMI_CODE_EXPERIMENTAL_MULTI_SERVER=1` at - boot time or the second boot fails with `ServerLockedError`. The helpers - set/restore it themselves (child env for spawned pairs) — do not export it - globally. - Always `port: 0`. A fixed busy port silently walks to `port + 1`, which breaks registry/port assertions and cross-test isolation. - One pair per test file/worker; never share a `RunningServer` or diff --git a/packages/klient/src/index.ts b/packages/klient/src/index.ts index a813c7c1fd..88613b266f 100644 --- a/packages/klient/src/index.ts +++ b/packages/klient/src/index.ts @@ -135,7 +135,6 @@ export { readSessionOwnershipDetails, SESSION_HELD_BY_PEER, SessionRedirectChannel, - splitOrigin, type HeldByPeerDetails, type SessionOwnershipDetails, type SessionOwnershipPhase, diff --git a/packages/klient/src/sessionRedirect.ts b/packages/klient/src/sessionRedirect.ts index 1a15b015d0..0ef28ed3ff 100644 --- a/packages/klient/src/sessionRedirect.ts +++ b/packages/klient/src/sessionRedirect.ts @@ -119,12 +119,6 @@ export function normalizeInstanceOrigin(address: string): string { return address.replace(/\/+$/, ''); } -/** Split an absolute base URL into origin + path (no trailing slash on either). */ -export function splitOrigin(url: string): { origin: string; path: string } { - const parsed = new URL(url); - return { origin: parsed.origin, path: parsed.pathname.replace(/\/$/, '') }; -} - /** One followed redirect, emitted via `KlientConnection.onRedirect`. */ export interface SessionRedirectInfo { /** Origin the request was sent to, e.g. `http://127.0.0.1:58627`. */ @@ -165,6 +159,7 @@ const defaultSleep = (ms: number): Promise => * overwrite another session's route. */ export class KlientConnection { + private readonly initialUrl: string; private url: string; private readonly sessionUrls = new Map(); readonly follow: boolean; @@ -175,7 +170,8 @@ export class KlientConnection { private readonly redirectListeners = new Set<(info: SessionRedirectInfo) => void>(); constructor(opts: { url: string } & SessionRedirectOptions) { - this.url = opts.url.replace(/\/$/, ''); + this.initialUrl = opts.url.replace(/\/$/, ''); + this.url = this.initialUrl; this.follow = opts.follow ?? true; this.maxRedirects = opts.maxRedirects ?? 1; this.maxCreatingRetries = opts.maxCreatingRetries ?? 3; @@ -188,7 +184,9 @@ export class KlientConnection { } currentUrlFor(sessionId: string | undefined): string { - return sessionId === undefined ? this.url : (this.sessionUrls.get(sessionId) ?? this.url); + return sessionId === undefined + ? this.url + : (this.sessionUrls.get(sessionId) ?? this.initialUrl); } onRedirect(listener: (info: SessionRedirectInfo) => void): IDisposable { diff --git a/packages/klient/test/e2e/harness/testing/serverPair.ts b/packages/klient/test/e2e/harness/testing/serverPair.ts index 98ff9ae84e..b0ace0ed3e 100644 --- a/packages/klient/test/e2e/harness/testing/serverPair.ts +++ b/packages/klient/test/e2e/harness/testing/serverPair.ts @@ -6,13 +6,9 @@ * `spawnServerProcess` instead only when the case is signal-sensitive * (SIGSTOP / SIGKILL need real, distinct pids). * - * Two hard requirements this helper encapsulates: - * - `KIMI_CODE_EXPERIMENTAL_MULTI_SERVER=1` must be set when each instance - * boots (read by `startServer` at call time only), or the second boot on - * the same home fails with `ServerLockedError`. The previous env value is - * saved and restored after boot / on `dispose()`. - * - Both instances must bind `port: 0` (OS-assigned) — a fixed busy port - * silently walks to `port + 1`, which breaks assertions on the registry. + * One hard requirement this helper encapsulates: both instances must bind + * `port: 0` (OS-assigned) — a fixed busy port silently walks to `port + 1`, + * which breaks assertions on the registry. * * `@moonshot-ai/kap-server` is imported lazily *inside* the function: its * module graph contains `*.md?raw` imports that plain `tsx` (running without @@ -28,13 +24,6 @@ import type { RunningServer } from '@moonshot-ai/kap-server'; import { HttpClient } from '../http.js'; -/** - * Literal copy of agent-core-v2's `MULTI_SERVER_FLAG_ENV`. Duplicated on - * purpose: importing the constant would pull the kap-server / agent-core-v2 - * module graph into every consumer of this barrel (see file header). - */ -export const MULTI_SERVER_FLAG_ENV = 'KIMI_CODE_EXPERIMENTAL_MULTI_SERVER' as const; - // `recursive` rm can hit ENOTEMPTY on macOS while the closing server is still // flushing/unlinking its own files — retry briefly (same trick as the v2 // smoke test's home cleanup). @@ -86,9 +75,7 @@ export async function startServerPair(options: ServerPairOptions = {}): Promise< const home = options.home ?? (await mkdtemp(join(tmpdir(), 'kimi-e2e-pair-'))); const ownsHome = options.home === undefined; const disableAuth = options.disableAuth ?? true; - // The multi-server gate wins over `options.env`: without it the second boot - // below cannot succeed on a shared home. - const envPatch: Record = { ...options.env, [MULTI_SERVER_FLAG_ENV]: '1' }; + const envPatch: Record = { ...options.env }; const savedEnv = saveEnv(envPatch); let envRestored = false; const restoreEnv = (): void => { @@ -116,13 +103,6 @@ export async function startServerPair(options: ServerPairOptions = {}): Promise< await a.close(); throw error; } - // The flag is also read at request time — `heldByPeerDetails` phase - // classification and the unregistered-writer check consult it on every - // ownership rejection — not just inside `startServer`. Keep the env - // patched for the pair's whole lifetime; dispose() restores it. - // (Restoring it here made request-time reads fall back to the registry - // default `false`, turning routable 40921s into held-by-local-instance - // on any environment without the master flag.) const baseUrl = (server: RunningServer): string => `http://${server.host}:${server.port}`; let disposed = false; diff --git a/packages/klient/test/e2e/harness/testing/serverProcess.ts b/packages/klient/test/e2e/harness/testing/serverProcess.ts index cc887df18b..2d6110ec08 100644 --- a/packages/klient/test/e2e/harness/testing/serverProcess.ts +++ b/packages/klient/test/e2e/harness/testing/serverProcess.ts @@ -22,8 +22,6 @@ * - `build/register-raw-text-loader.mjs` makes `*.md?raw` prompt-template * imports (kap-server → agent-core-v2) resolvable outside a bundler; * plain `node` fails on those imports without it. - * - registers/lock state: same-home coexistence still requires - * `KIMI_CODE_EXPERIMENTAL_MULTI_SERVER=1`, passed through the child env. * * Readiness is the child's `{type:'ready'}` stdout line (printed after * `startServer` resolved, i.e. the port is already listening). When driving @@ -38,7 +36,6 @@ import { dirname, join, resolve } from 'node:path'; import { createInterface } from 'node:readline'; import { fileURLToPath, pathToFileURL } from 'node:url'; -import { MULTI_SERVER_FLAG_ENV } from './serverPair.js'; import { SPAWN_SERVER_HOME_ENV, type SpawnServerMessage, @@ -189,9 +186,9 @@ export async function spawnServerProcess( } /** - * Pair of spawned children sharing one home; the multi-server flag is pushed - * into both child envs — process-level patching like `startServerPair` does - * would not reach them. + * Pair of spawned children sharing one home; `options.env` is pushed into both + * child envs — process-level patching like `startServerPair` does would not + * reach them. */ export async function spawnServerProcessPair( options: SpawnServerProcessOptions = {}, @@ -201,7 +198,6 @@ export async function spawnServerProcessPair( const childOptions: SpawnServerProcessOptions = { ...options, home, - env: { ...options.env, [MULTI_SERVER_FLAG_ENV]: '1' }, }; try { const a = await spawnServerProcess(childOptions); diff --git a/packages/klient/test/e2e/legacy/dual-instance.test.ts b/packages/klient/test/e2e/legacy/dual-instance.test.ts index 3c6d1a93d0..6014989ff1 100644 --- a/packages/klient/test/e2e/legacy/dual-instance.test.ts +++ b/packages/klient/test/e2e/legacy/dual-instance.test.ts @@ -11,19 +11,15 @@ * + `listLiveServerInstances` agree). * 2. Closing instance `a` leaves instance `b` serving (healthz 200, registry * down to one live entry) while `a`'s port refuses connections. - * 3. `dispose()` restores `KIMI_CODE_EXPERIMENTAL_MULTI_SERVER` to its - * pre-boot value and removes the helper-created home directory. - * (Upstream removed the single-instance server lock — multi-server is - * the always-on model now, so the former "no flag → ServerLockedError" - * case no longer exists.) + * 3. `dispose()` removes the helper-created home directory. * * Subprocess (`spawnServerProcess`): * 4. A child boots on a real distinct pid, answers healthz, and serves * token-gated routes WITHOUT a token (`disableAuth`); `stop()` (SIGTERM) * exits the child and removes the helper-created home. - * 5. A spawned pair shares one home (flag reaches the children's env); a - * SIGKILLed child's pid actually dies and its registry entry is swept as - * stale on the next `listLiveServerInstances` read. + * 5. A spawned pair shares one home; a SIGKILLed child's pid actually dies + * and its registry entry is swept as stale on the next + * `listLiveServerInstances` read. * * KNOWN BRANCH GAP (refactor-fs-watch WIP): kap-server's `close()` currently * throws `appendLogStore depends on writeAuthorityRegistry which is NOT @@ -44,7 +40,6 @@ import { describe, expect, it } from 'vitest'; import { HttpClient } from '../harness/http.js'; import { - MULTI_SERVER_FLAG_ENV, spawnServerProcess, spawnServerProcessPair, startServerPair, @@ -135,25 +130,15 @@ describe('dual-instance helpers', () => { ); it( - 'dispose() restores the multi-server env flag and removes the created home', + 'dispose() removes the created home', { timeout: 30_000 }, async () => { const log = createCaseLogger('dual-instance/dispose-cleanup'); - const ambientFlag = process.env[MULTI_SERVER_FLAG_ENV]; const pair = await startServerPair(); - // The flag stays patched for the pair's whole lifetime: request-time - // readers (40921 phase classification, unregistered-writer checks) - // consult it on every ownership rejection, not just at boot. - expect(process.env[MULTI_SERVER_FLAG_ENV]).toBe('1'); expect(existsSync(pair.home)).toBe(true); await pair.dispose(); - log('after dispose()', { - restoredFlag: process.env[MULTI_SERVER_FLAG_ENV] ?? null, - ambientFlag: ambientFlag ?? null, - homeExists: existsSync(pair.home), - }); - expect(process.env[MULTI_SERVER_FLAG_ENV]).toBe(ambientFlag); + log('after dispose()', { homeExists: existsSync(pair.home) }); expect(existsSync(pair.home)).toBe(false); }, ); diff --git a/packages/klient/test/e2e/v2/ownership-redirect.test.ts b/packages/klient/test/e2e/v2/ownership-redirect.test.ts index f336af152b..79beb279c2 100644 --- a/packages/klient/test/e2e/v2/ownership-redirect.test.ts +++ b/packages/klient/test/e2e/v2/ownership-redirect.test.ts @@ -3,7 +3,7 @@ * at the WRONG instance follows the `session.held_by_peer` (40921) `routable` * answer onto the holder origin — and every later call lands on the holder. * - * In-process `startServerPair` (shared home, `multi_server` flag, port 0). + * In-process `startServerPair` (shared home, port 0). * The session is created through `/api/v1` on instance A, so A owns the write * lease registered with its own address; klient drives `/api/v2` starting * from B. `SessionFacade.restore()` routes to diff --git a/packages/klient/test/sessionRedirect.test.ts b/packages/klient/test/sessionRedirect.test.ts index ad2be5bfc7..e91330d358 100644 --- a/packages/klient/test/sessionRedirect.test.ts +++ b/packages/klient/test/sessionRedirect.test.ts @@ -8,12 +8,16 @@ import { describe, expect, it, vi } from 'vitest'; +import { sessionOwnershipDetailsSchema } from '@moonshot-ai/protocol'; + import { RPCError } from '../src/core/errors.js'; import type { WsLike, WsLikeCtor } from '../src/transports/ws/wsSocket.js'; import { KlientConnection, readSessionOwnershipDetails, + SESSION_HELD_BY_PEER, SessionRedirectChannel, + type SessionOwnershipDetails, type SessionRedirectInfo, type SessionRedirectOptions, } from '../src/sessionRedirect.js'; @@ -107,6 +111,26 @@ describe('session ownership redirect (SESSION_HELD_BY_PEER)', () => { ]); }); + it('keeps an unrelated session on the initial origin after another session redirects', async () => { + const fetchMock = vi + .fn() + .mockResolvedValueOnce( + heldByPeer({ kind: 'held-by-peer', phase: 'routable', address: HOLDER }), + ) + .mockResolvedValueOnce(okEnvelope({ id: 'a' })) + .mockResolvedValueOnce(okEnvelope({ id: 'b' })); + const { channel } = rig(fetchMock); + + await expect(readSession(channel, 'a')).resolves.toEqual({ id: 'a' }); + await expect(readSession(channel, 'b')).resolves.toEqual({ id: 'b' }); + + expect(urls(fetchMock)).toEqual([ + `${PEER}/api/v2/session/a/sessionMetadata/read`, + `${HOLDER}/api/v2/session/a/sessionMetadata/read`, + `${PEER}/api/v2/session/b/sessionMetadata/read`, + ]); + }); + it('follows a routable redirect: rebases onto the holder, re-sends the call, emits the signal', async () => { const fetchMock = vi .fn() @@ -422,6 +446,80 @@ describe('readSessionOwnershipDetails', () => { }); }); +describe('sessionOwnershipDetailsSchema parity (protocol schema ↔ klient narrowing)', () => { + // The 40921 `details` payload has three twins: the zod schema in + // `@moonshot-ai/protocol` (authoritative), klient's structural narrowing, + // and kimi-web's. These fixtures pin the first two together: for every + // phase kind the schema and `readSessionOwnershipDetails` must agree on + // accept/reject, and on accept the field values must match. (One intentional + // divergence is NOT covered here: klient drops invalid OPTIONAL fields + // instead of rejecting — see "garbage fields are dropped" above.) + const cases: Array<{ + name: string; + details: unknown; + expected?: SessionOwnershipDetails; + }> = [ + { + name: 'routable with address', + details: { kind: 'held-by-peer', phase: 'routable', address: HOLDER }, + expected: { kind: 'held-by-peer', phase: 'routable', address: HOLDER }, + }, + { + name: 'routable without address', + details: { kind: 'held-by-peer', phase: 'routable' }, + expected: { kind: 'held-by-peer', phase: 'routable' }, + }, + { + name: 'creating with retry_after_ms', + details: { kind: 'held-by-peer', phase: 'creating', retry_after_ms: 120 }, + expected: { kind: 'held-by-peer', phase: 'creating', retry_after_ms: 120 }, + }, + { + name: 'creating bare', + details: { kind: 'held-by-peer', phase: 'creating' }, + expected: { kind: 'held-by-peer', phase: 'creating' }, + }, + { + name: 'holder-unresponsive with retry_after_ms', + details: { kind: 'held-by-peer', phase: 'holder-unresponsive', retry_after_ms: 2000 }, + expected: { kind: 'held-by-peer', phase: 'holder-unresponsive', retry_after_ms: 2000 }, + }, + { + name: 'held-by-local-instance', + details: { kind: 'held-by-peer', phase: 'held-by-local-instance' }, + expected: { kind: 'held-by-peer', phase: 'held-by-local-instance' }, + }, + { + name: 'unregistered-writer', + details: { kind: 'unregistered-writer' }, + expected: { kind: 'unregistered-writer' }, + }, + // Structurally invalid → both sides reject. + { name: 'unknown phase', details: { kind: 'held-by-peer', phase: 'future-phase' } }, + { name: 'missing phase', details: { kind: 'held-by-peer' } }, + { name: 'unknown kind', details: { kind: 'mystery' } }, + { name: 'non-object details', details: 'held-by-peer' }, + { name: 'null details', details: null }, + ]; + + it.each(cases)('$name', ({ details, expected }) => { + const parsed = sessionOwnershipDetailsSchema.safeParse(details); + const read = readSessionOwnershipDetails( + new RPCError(SESSION_HELD_BY_PEER, 'x', details), + ); + if (expected === undefined) { + expect(parsed.success).toBe(false); + expect(read).toBeUndefined(); + } else { + expect(parsed.success).toBe(true); + expect(read).toEqual(expected); + // Field-value equivalence between the schema output and the narrowing + // (toEqual ignores the explicit `undefined` optionals klient fills in). + if (parsed.success) expect(parsed.data).toEqual(read); + } + }); +}); + // --- WS fake (event-bridge level, mirrors wsSocket.test.ts helpers) ---------- type Listener = (event: never) => void; diff --git a/packages/minidb/src/index.ts b/packages/minidb/src/index.ts index 4e058e7e6c..c18eebe9f7 100644 --- a/packages/minidb/src/index.ts +++ b/packages/minidb/src/index.ts @@ -1608,13 +1608,6 @@ export class MiniDb { // ---- maintenance -------------------------------------------------------- - /** Verify that this process still holds the database's kernel lock. */ - async renewLock(): Promise { - if (this.lock === null) return; - await this.lock.renew(); - this.ensureWritable(); - } - /** Advanced/internal (read-replica owners such as the cluster shard pool): * incrementally apply WAL frames appended to db.wal after `offset` — the * same frames open-time recovery would replay, interpreted identically diff --git a/packages/minidb/src/lockfile.ts b/packages/minidb/src/lockfile.ts index b3046ca379..e9c97bf28d 100644 --- a/packages/minidb/src/lockfile.ts +++ b/packages/minidb/src/lockfile.ts @@ -48,10 +48,6 @@ export class LockFile { if (!this.checkHeld()) throw new LockError(`database write lock was lost: ${this.path}`); } - async renew(): Promise { - this.assertHeld(); - } - async release(): Promise { this.releaseSync(); } diff --git a/packages/minidb/test/cluster/lock.test.ts b/packages/minidb/test/cluster/lock.test.ts index 57569cc17a..3d6d4a477c 100644 --- a/packages/minidb/test/cluster/lock.test.ts +++ b/packages/minidb/test/cluster/lock.test.ts @@ -91,7 +91,7 @@ test('read-only instance coexists with a live writer and sees its commits', asyn } }); -test('a cached writer keeps a stable sentinel without renewal writes', async () => { +test('a cached writer keeps a stable sentinel', async () => { const dir = await tmpDir('minidb-cluster-'); try { const db = await ClusterDb.open({ dir, shardCount: 4, valueCodec: 'json', lockHoldMs: 0 }); diff --git a/packages/minidb/test/lock.test.ts b/packages/minidb/test/lock.test.ts index c597814c4a..84207da29e 100644 --- a/packages/minidb/test/lock.test.ts +++ b/packages/minidb/test/lock.test.ts @@ -105,7 +105,7 @@ test('rewriting sentinel contents cannot transfer a live lock', async () => { assert.equal(await first.acquire(), true); await fs.writeFile(lockPath, 'operator note'); - await assert.doesNotReject(() => first.renew()); + assert.doesNotThrow(() => first.assertHeld()); assert.equal(await second.acquire(), false); await first.release(); diff --git a/packages/minidb/test/review-fixes.test.ts b/packages/minidb/test/review-fixes.test.ts index 5a4746c22d..313726d03a 100644 --- a/packages/minidb/test/review-fixes.test.ts +++ b/packages/minidb/test/review-fixes.test.ts @@ -91,7 +91,6 @@ test('editing the sentinel payload cannot create a successor generation', async await oldWriter.set('generation', 'old'); await fs.writeFile(path.join(dir, 'db.lock'), 'successor-generation'); - await assert.doesNotReject(() => oldWriter.renewLock()); await assert.rejects( () => MiniDb.open({ dir, valueCodec: 'string', autoCompact: false }), /locked/, diff --git a/packages/protocol/src/events.ts b/packages/protocol/src/events.ts index ef6b638432..f03111e449 100644 --- a/packages/protocol/src/events.ts +++ b/packages/protocol/src/events.ts @@ -313,7 +313,6 @@ export type KimiErrorCode = | 'os.process.kill_failed' | 'os.lock.held' | 'os.lock.wait_timeout' - | 'os.lock.lost' | 'os.lock.io' | 'storage.not_found' | 'storage.decode_failed' From bb67e7190a05f1200803e1645478d06b0e8b39c2 Mon Sep 17 00:00:00 2001 From: 7Sageer <7sageer@djwcb.cn> Date: Tue, 21 Jul 2026 13:10:22 +0800 Subject: [PATCH 08/22] fix(agent-core-v2): satisfy expect-expect and only-throw-error lint rules Assert the ledger verdict in the fileFencing allow-path tests instead of relying on the helper-internal expect, and narrow firstFailure to an Error before rethrowing it from flushPersistence. --- packages/agent-core-v2/src/agent/task/taskService.ts | 7 ++++++- .../test/agent/fileFencing/fileFencing.test.ts | 9 ++++++++- 2 files changed, 14 insertions(+), 2 deletions(-) diff --git a/packages/agent-core-v2/src/agent/task/taskService.ts b/packages/agent-core-v2/src/agent/task/taskService.ts index 86d2fc7609..1207302303 100644 --- a/packages/agent-core-v2/src/agent/task/taskService.ts +++ b/packages/agent-core-v2/src/agent/task/taskService.ts @@ -36,6 +36,7 @@ import { LifecycleScope, registerScopedService } from '#/_base/di/scope'; import type { ContentPart } from '#/app/llmProtocol/message'; import { Disposable } from '#/_base/di/lifecycle'; +import { toErrorMessage } from '#/_base/errors/errorMessage'; import { abortable, userCancellationReason, @@ -502,7 +503,11 @@ export class AgentTaskService extends Disposable implements IAgentTaskService { : entry.outputWriteQueue !== promise; }); if (changed) continue; - if (firstFailure !== undefined) throw firstFailure; + if (firstFailure !== undefined) { + throw firstFailure instanceof Error + ? firstFailure + : new Error(toErrorMessage(firstFailure)); + } return; } } diff --git a/packages/agent-core-v2/test/agent/fileFencing/fileFencing.test.ts b/packages/agent-core-v2/test/agent/fileFencing/fileFencing.test.ts index 79aad012c8..de353bc27c 100644 --- a/packages/agent-core-v2/test/agent/fileFencing/fileFencing.test.ts +++ b/packages/agent-core-v2/test/agent/fileFencing/fileFencing.test.ts @@ -117,6 +117,7 @@ interface AgentWorld { readonly executor: IAgentToolExecutorService; readonly watch: ISessionFsWatchService; readonly workspace: ISessionWorkspaceContext; + readonly ledger: ISessionFileLedger; } function makeAgent(env: Env, session: Scope): AgentWorld { @@ -125,7 +126,6 @@ function makeAgent(env: Env, session: Scope): AgentWorld { stubPair(IAgentToolExecutorService, executor), ]); agent.accessor.get(IAgentFileFencingService); - session.accessor.get(ISessionFileLedger); return { env, session, @@ -133,6 +133,7 @@ function makeAgent(env: Env, session: Scope): AgentWorld { executor, watch: session.accessor.get(ISessionFsWatchService), workspace: session.accessor.get(ISessionWorkspaceContext), + ledger: session.accessor.get(ISessionFileLedger), }; } @@ -381,6 +382,8 @@ describe('AgentFileFencingService', () => { const file = join(world.env.workDir, 'new.txt'); await runOk(world, 'Write', file); + expect(await world.ledger.compare(file)).toBe('clean'); + await runOk(world, 'Edit', file); }); @@ -390,6 +393,8 @@ describe('AgentFileFencingService', () => { writeFileSync(file, 'hello'); await runOk(world, 'Read', file); + expect(await world.ledger.compare(file)).toBe('clean'); + await runOk(world, 'Edit', file); }); @@ -401,6 +406,8 @@ describe('AgentFileFencingService', () => { await runOk(world, 'Read', file); await runOk(world, 'Edit', file); await runOk(world, 'Edit', file); + + expect(await world.ledger.compare(file)).toBe('clean'); }); it('keeps consecutive Edits clean through the own-write watcher echo and re-baselines', async () => { From f13ced9e31d1e49f948091466055b3d2170e8efa Mon Sep 17 00:00:00 2001 From: 7Sageer <7sageer@djwcb.cn> Date: Tue, 21 Jul 2026 13:28:43 +0800 Subject: [PATCH 09/22] chore(nix): update pnpmDeps hash for kernel-file-lock dependencies --- flake.nix | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/flake.nix b/flake.nix index 48ac51ad37..6f2f7282d2 100644 --- a/flake.nix +++ b/flake.nix @@ -162,7 +162,7 @@ inherit (finalAttrs) pname version src pnpmWorkspaces; inherit pnpm; fetcherVersion = 3; - hash = "sha256-+pzJfoWJwVXIUU8oc56LVpfNjSY6MABID5g11Cm92xw="; + hash = "sha256-DG0qhbCF74KeNe0LKHuGAQufDzTdtXxvzFBYHl4xs9w="; }; nativeBuildInputs = [ From 6f61cced45851eafa02e9a559260fd61e8c487f6 Mon Sep 17 00:00:00 2001 From: 7Sageer <7sageer@djwcb.cn> Date: Tue, 21 Jul 2026 14:55:30 +0800 Subject: [PATCH 10/22] ci: drop kernel-file-lock Node 26 job and Node engines caps --- .github/workflows/ci.yml | 20 -------------------- apps/kimi-code/package.json | 2 +- package.json | 2 +- 3 files changed, 2 insertions(+), 22 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 026b0f4f71..07da0bd4f9 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -66,26 +66,6 @@ jobs: - run: pnpm install --frozen-lockfile - run: pnpm --filter @moonshot-ai/pi-tui test - test-kernel-file-lock: - runs-on: ubuntu-latest - strategy: - fail-fast: false - matrix: - node-version: [24.15.0, 26] - - steps: - - uses: actions/checkout@v4 - - - uses: pnpm/action-setup@v6 - - - uses: actions/setup-node@v6 - with: - node-version: ${{ matrix.node-version }} - cache: pnpm - - - run: pnpm install --frozen-lockfile - - run: pnpm --filter @moonshot-ai/kernel-file-lock test - test-windows: runs-on: windows-latest # Temporarily disabled while Windows tests are being stabilized. diff --git a/apps/kimi-code/package.json b/apps/kimi-code/package.json index b09bf88e58..0bbe681d48 100644 --- a/apps/kimi-code/package.json +++ b/apps/kimi-code/package.json @@ -112,6 +112,6 @@ "zod": "^4.3.6" }, "engines": { - "node": ">=22.19.0 <27" + "node": ">=22.19.0" } } diff --git a/package.json b/package.json index cbb1b17bc8..6ae66cc119 100644 --- a/package.json +++ b/package.json @@ -60,7 +60,7 @@ ] }, "engines": { - "node": ">=24.15.0 <27" + "node": ">=24.15.0" }, "packageManager": "pnpm@10.33.0" } From f5b8af1e02558615d5f40635ae86a897e84c92ad Mon Sep 17 00:00:00 2001 From: 7Sageer <7sageer@djwcb.cn> Date: Tue, 21 Jul 2026 15:48:06 +0800 Subject: [PATCH 11/22] chore: streamline user-facing changeset entries --- .changeset/cross-process-lock-unification.md | 2 +- .changeset/fix-abort-not-retryable.md | 2 +- .changeset/fix-openai-prompt-cache-key.md | 2 +- .changeset/harden-session-lifecycle-readiness.md | 5 ----- .changeset/journal-fswatch-reliability-fixes.md | 2 +- .changeset/kosong-layered-wire-architecture.md | 5 ----- .changeset/model-resolution-inspection.md | 5 ----- .changeset/session-lease-write-fencing.md | 2 +- .changeset/skill-hot-reload.md | 2 +- .changeset/stale-write-fencing.md | 2 +- .changeset/support-node-26-locks.md | 5 ----- 11 files changed, 7 insertions(+), 27 deletions(-) delete mode 100644 .changeset/harden-session-lifecycle-readiness.md delete mode 100644 .changeset/kosong-layered-wire-architecture.md delete mode 100644 .changeset/model-resolution-inspection.md delete mode 100644 .changeset/support-node-26-locks.md diff --git a/.changeset/cross-process-lock-unification.md b/.changeset/cross-process-lock-unification.md index a9409cd48e..4dc061dc8f 100644 --- a/.changeset/cross-process-lock-unification.md +++ b/.changeset/cross-process-lock-unification.md @@ -2,4 +2,4 @@ "@moonshot-ai/kimi-code": patch --- -Fix cross-process state corruption by replacing ad-hoc lockfiles with kernel-backed locks for server and database coordination, with automatic release when a process exits. +Fix occasional state corruption when multiple CLI or server instances run at the same time. diff --git a/.changeset/fix-abort-not-retryable.md b/.changeset/fix-abort-not-retryable.md index b54c1a9d75..b48379e0b8 100644 --- a/.changeset/fix-abort-not-retryable.md +++ b/.changeset/fix-abort-not-retryable.md @@ -2,4 +2,4 @@ "@moonshot-ai/kimi-code": patch --- -Fix cancelled model requests being wrapped as retryable provider errors, so interrupting a request no longer triggers silent retries. +Fix interrupting a model request silently triggering retries instead of stopping. diff --git a/.changeset/fix-openai-prompt-cache-key.md b/.changeset/fix-openai-prompt-cache-key.md index 263b33846c..fa3443bd75 100644 --- a/.changeset/fix-openai-prompt-cache-key.md +++ b/.changeset/fix-openai-prompt-cache-key.md @@ -2,4 +2,4 @@ "@moonshot-ai/kimi-code": patch --- -Send the session prompt cache key to OpenAI and OpenAI Responses providers, restoring provider-side prompt cache affinity that previously only reached Kimi and Anthropic. +Fix OpenAI and OpenAI Responses providers not reusing the prompt cache across turns. diff --git a/.changeset/harden-session-lifecycle-readiness.md b/.changeset/harden-session-lifecycle-readiness.md deleted file mode 100644 index 9106e3b510..0000000000 --- a/.changeset/harden-session-lifecycle-readiness.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -"@moonshot-ai/kimi-code": patch ---- - -Harden session startup and shutdown coordination so filesystem updates and lock acquisition remain bounded during races. diff --git a/.changeset/journal-fswatch-reliability-fixes.md b/.changeset/journal-fswatch-reliability-fixes.md index 1af84ab3a5..be1b0a5d30 100644 --- a/.changeset/journal-fswatch-reliability-fixes.md +++ b/.changeset/journal-fswatch-reliability-fixes.md @@ -2,4 +2,4 @@ "@moonshot-ai/kimi-code": patch --- -Fix the server event journal silently dropping events on write failure and on shutdown, and make `.gitignore` edits take effect immediately for file listing and file watching. +Fix `.gitignore` edits not taking effect until restart for file listing and file watching. diff --git a/.changeset/kosong-layered-wire-architecture.md b/.changeset/kosong-layered-wire-architecture.md deleted file mode 100644 index 327148d903..0000000000 --- a/.changeset/kosong-layered-wire-architecture.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -"@moonshot-ai/kimi-code": patch ---- - -Rework the model wire layer in the experimental v2 engine into a small set of protocol bases plus declarative provider trait definitions, so adding a provider no longer means copying adapter code, and per-turn request intent (cache key, thinking effort, sampling) flows as request parameters instead of cloned model objects. The never-functional `[platforms]` config section and the `provider.platformId` field are removed; credential resolution is now a two-layer model → provider lookup. diff --git a/.changeset/model-resolution-inspection.md b/.changeset/model-resolution-inspection.md deleted file mode 100644 index 1aab063735..0000000000 --- a/.changeset/model-resolution-inspection.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -"@moonshot-ai/kimi-code": patch ---- - -Add read-only model resolution inspection and a live connectivity probe to the server's RPC surface, reporting per-field value provenance (config, override, builtin, env, synthesized) for internal debugging tools. diff --git a/.changeset/session-lease-write-fencing.md b/.changeset/session-lease-write-fencing.md index 53d4898a0d..b3c13e3fcd 100644 --- a/.changeset/session-lease-write-fencing.md +++ b/.changeset/session-lease-write-fencing.md @@ -2,4 +2,4 @@ "@moonshot-ai/kimi-code": patch --- -Add per-session cross-process leases with write fencing: a second engine instance opening the same session now receives a structured session-ownership error instead of interleaving writes. +Opening the same session from a second instance now fails with a clear ownership error instead of silently interleaving writes. diff --git a/.changeset/skill-hot-reload.md b/.changeset/skill-hot-reload.md index 7777359bca..854aff101f 100644 --- a/.changeset/skill-hot-reload.md +++ b/.changeset/skill-hot-reload.md @@ -2,4 +2,4 @@ "@moonshot-ai/kimi-code": patch --- -Reload skill catalogs automatically when skill files change on disk, so newly added or updated skills show up in the next turn; the web UI refreshes its skill list on a server push. +Newly added or updated skills now take effect on the next turn without restarting, and the web skill list refreshes automatically. diff --git a/.changeset/stale-write-fencing.md b/.changeset/stale-write-fencing.md index b287b47733..90aa5b1479 100644 --- a/.changeset/stale-write-fencing.md +++ b/.changeset/stale-write-fencing.md @@ -2,4 +2,4 @@ "@moonshot-ai/kimi-code": patch --- -Track files read and written per session to detect conflicting edits: a Write/Edit over a file that changed on disk since it was last read (or was never read in this session) is now rejected with a read-first reason, so conflicting edits across server instances cannot be applied silently. +The agent now refuses to edit a file that changed on disk since it was last read in the session, instead of silently overwriting external changes. diff --git a/.changeset/support-node-26-locks.md b/.changeset/support-node-26-locks.md deleted file mode 100644 index 947b9ad649..0000000000 --- a/.changeset/support-node-26-locks.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -"@moonshot-ai/kimi-code": patch ---- - -Support Node.js 26 for CLI installs and kernel-backed file coordination. From d34c8e59d8ba65d26b69640b8af03e8372266d8e Mon Sep 17 00:00:00 2001 From: 7Sageer <7sageer@djwcb.cn> Date: Tue, 21 Jul 2026 15:54:17 +0800 Subject: [PATCH 12/22] chore: restore changeset entries owned by main --- .changeset/fix-abort-not-retryable.md | 2 +- .changeset/fix-openai-prompt-cache-key.md | 2 +- .changeset/kosong-layered-wire-architecture.md | 5 +++++ .changeset/model-resolution-inspection.md | 5 +++++ 4 files changed, 12 insertions(+), 2 deletions(-) create mode 100644 .changeset/kosong-layered-wire-architecture.md create mode 100644 .changeset/model-resolution-inspection.md diff --git a/.changeset/fix-abort-not-retryable.md b/.changeset/fix-abort-not-retryable.md index b48379e0b8..b54c1a9d75 100644 --- a/.changeset/fix-abort-not-retryable.md +++ b/.changeset/fix-abort-not-retryable.md @@ -2,4 +2,4 @@ "@moonshot-ai/kimi-code": patch --- -Fix interrupting a model request silently triggering retries instead of stopping. +Fix cancelled model requests being wrapped as retryable provider errors, so interrupting a request no longer triggers silent retries. diff --git a/.changeset/fix-openai-prompt-cache-key.md b/.changeset/fix-openai-prompt-cache-key.md index fa3443bd75..263b33846c 100644 --- a/.changeset/fix-openai-prompt-cache-key.md +++ b/.changeset/fix-openai-prompt-cache-key.md @@ -2,4 +2,4 @@ "@moonshot-ai/kimi-code": patch --- -Fix OpenAI and OpenAI Responses providers not reusing the prompt cache across turns. +Send the session prompt cache key to OpenAI and OpenAI Responses providers, restoring provider-side prompt cache affinity that previously only reached Kimi and Anthropic. diff --git a/.changeset/kosong-layered-wire-architecture.md b/.changeset/kosong-layered-wire-architecture.md new file mode 100644 index 0000000000..327148d903 --- /dev/null +++ b/.changeset/kosong-layered-wire-architecture.md @@ -0,0 +1,5 @@ +--- +"@moonshot-ai/kimi-code": patch +--- + +Rework the model wire layer in the experimental v2 engine into a small set of protocol bases plus declarative provider trait definitions, so adding a provider no longer means copying adapter code, and per-turn request intent (cache key, thinking effort, sampling) flows as request parameters instead of cloned model objects. The never-functional `[platforms]` config section and the `provider.platformId` field are removed; credential resolution is now a two-layer model → provider lookup. diff --git a/.changeset/model-resolution-inspection.md b/.changeset/model-resolution-inspection.md new file mode 100644 index 0000000000..1aab063735 --- /dev/null +++ b/.changeset/model-resolution-inspection.md @@ -0,0 +1,5 @@ +--- +"@moonshot-ai/kimi-code": patch +--- + +Add read-only model resolution inspection and a live connectivity probe to the server's RPC surface, reporting per-field value provenance (config, override, builtin, env, synthesized) for internal debugging tools. From 6a01fd2331319160cdc3d9bdb9d77e0f2c09a51c Mon Sep 17 00:00:00 2001 From: 7Sageer <7sageer@djwcb.cn> Date: Tue, 21 Jul 2026 16:18:19 +0800 Subject: [PATCH 13/22] fix: close session event journals before lease release --- .changeset/session-event-release-barrier.md | 5 ++ .../app/sessionLifecycle/sessionLifecycle.ts | 8 +++ .../sessionLifecycleService.ts | 40 +++++++++++++-- .../externalHooksRunner/integration.test.ts | 1 + .../app/sessionExport/sessionExport.test.ts | 1 + .../sessionLifecycle/sessionLifecycle.test.ts | 41 ++++++++++++++++ packages/kap-server/src/start.ts | 12 ++--- .../ws/v1/sessionEventBroadcaster.ts | 49 ++++++++++++++++--- .../test/sessionEventBroadcaster.test.ts | 48 +++++++++++++++++- 9 files changed, 183 insertions(+), 22 deletions(-) create mode 100644 .changeset/session-event-release-barrier.md diff --git a/.changeset/session-event-release-barrier.md b/.changeset/session-event-release-barrier.md new file mode 100644 index 0000000000..44e83a9883 --- /dev/null +++ b/.changeset/session-event-release-barrier.md @@ -0,0 +1,5 @@ +--- +"@moonshot-ai/kimi-code": patch +--- + +web: Restore live event delivery after archiving and reopening a session. diff --git a/packages/agent-core-v2/src/app/sessionLifecycle/sessionLifecycle.ts b/packages/agent-core-v2/src/app/sessionLifecycle/sessionLifecycle.ts index c909a1685a..36be282d48 100644 --- a/packages/agent-core-v2/src/app/sessionLifecycle/sessionLifecycle.ts +++ b/packages/agent-core-v2/src/app/sessionLifecycle/sessionLifecycle.ts @@ -63,9 +63,17 @@ export interface SessionWillCloseEvent { readonly reason: SessionCloseReason; } +export type SessionReleaseReason = 'close' | 'archive' | 'dirty-abort'; + +export interface SessionWillReleaseEvent { + readonly sessionId: string; + readonly reason: SessionReleaseReason; +} + export type SessionLifecycleHooks = { readonly onDidCreateSession: SessionCreatedEvent; readonly onWillCloseSession: SessionWillCloseEvent; + readonly onWillReleaseSession: SessionWillReleaseEvent; }; export interface SessionArchivedEvent { diff --git a/packages/agent-core-v2/src/app/sessionLifecycle/sessionLifecycleService.ts b/packages/agent-core-v2/src/app/sessionLifecycle/sessionLifecycleService.ts index e421094152..56f7cd33ca 100644 --- a/packages/agent-core-v2/src/app/sessionLifecycle/sessionLifecycleService.ts +++ b/packages/agent-core-v2/src/app/sessionLifecycle/sessionLifecycleService.ts @@ -117,6 +117,7 @@ import { type SessionForkedEvent, type SessionLifecycleHooks, type SessionWillCloseEvent, + type SessionWillReleaseEvent, ISessionLifecycleService, } from './sessionLifecycle'; @@ -155,6 +156,7 @@ export class SessionLifecycleService extends Disposable implements ISessionLifec readonly hooks = createHooks([ 'onDidCreateSession', 'onWillCloseSession', + 'onWillReleaseSession', ]); private readonly resuming = new Map>(); private readonly inFlightOperations = new Set>(); @@ -454,6 +456,18 @@ export class SessionLifecycleService extends Disposable implements ISessionLifec await this.hooks.onWillCloseSession.run(event); } + private async announceWillRelease( + event: SessionWillReleaseEvent, + entry: SessionEntry, + ): Promise { + try { + await this.hooks.onWillReleaseSession.run(event); + } catch (error) { + entry.phase = 'flush-failed'; + throw error; + } + } + private async drainAgents(handle: ISessionScopeHandle): Promise { const agentLifecycle = handle.accessor.get(IAgentLifecycleService); for (const agent of agentLifecycle.list()) { @@ -490,7 +504,7 @@ export class SessionLifecycleService extends Disposable implements ISessionLifec private async runSessionClose(sessionId: string, entry: SessionEntry): Promise { const kind = entry.closeKind ?? 'close'; - const stepCount = kind === 'close' ? 3 : 5; + const stepCount = kind === 'close' ? 4 : 6; while (entry.closeStep < stepCount) { if (kind === 'close') { await this.runCloseStep(sessionId, entry); @@ -526,6 +540,9 @@ export class SessionLifecycleService extends Disposable implements ISessionLifec case 2: this.disposeSessionHandle(entry); return; + case 3: + await this.announceWillRelease({ sessionId, reason: 'close' }, entry); + return; } } @@ -549,6 +566,9 @@ export class SessionLifecycleService extends Disposable implements ISessionLifec case 4: this.disposeSessionHandle(entry); return; + case 5: + await this.announceWillRelease({ sessionId, reason: 'archive' }, entry); + return; } } @@ -935,15 +955,25 @@ export class SessionLifecycleService extends Disposable implements ISessionLifec this.disposeSessionHandle(entry); } catch { } - const dirtyAbortPromise = Promise.allSettled( - taskServices.map((tasks) => tasks.flushPersistence()), - ).then(() => { + const dirtyAbortPromise = (async (): Promise => { + try { + await this.hooks.onWillReleaseSession.run({ + sessionId: entry.handle.id, + reason: 'dirty-abort', + }); + } catch (error) { + this.log.warn('session release hook failed during dirty abort', { + sessionId: entry.handle.id, + error: String(error), + }); + } + await Promise.allSettled(taskServices.map((tasks) => tasks.flushPersistence())); try { entry.registration.dispose(); } catch { } entry.lease.release(); - }); + })(); entry.dirtyAbortPromise = dirtyAbortPromise; void dirtyAbortPromise.catch(() => {}); return dirtyAbortPromise; diff --git a/packages/agent-core-v2/test/app/externalHooksRunner/integration.test.ts b/packages/agent-core-v2/test/app/externalHooksRunner/integration.test.ts index 644a03ae0e..e3c8fad919 100644 --- a/packages/agent-core-v2/test/app/externalHooksRunner/integration.test.ts +++ b/packages/agent-core-v2/test/app/externalHooksRunner/integration.test.ts @@ -187,6 +187,7 @@ function stubSessionLifecycle(): ISessionLifecycleService { hooks: createHooks([ 'onDidCreateSession', 'onWillCloseSession', + 'onWillReleaseSession', ]), onDidCreateSession: Event.None as ISessionLifecycleService['onDidCreateSession'], onDidCloseSession: Event.None as ISessionLifecycleService['onDidCloseSession'], diff --git a/packages/agent-core-v2/test/app/sessionExport/sessionExport.test.ts b/packages/agent-core-v2/test/app/sessionExport/sessionExport.test.ts index 4b9a40a015..574641928e 100644 --- a/packages/agent-core-v2/test/app/sessionExport/sessionExport.test.ts +++ b/packages/agent-core-v2/test/app/sessionExport/sessionExport.test.ts @@ -856,6 +856,7 @@ function registerSessionExportServices( hooks: createHooks([ 'onDidCreateSession', 'onWillCloseSession', + 'onWillReleaseSession', ]), create: async () => { throw new Error('create should not be called by session export'); diff --git a/packages/agent-core-v2/test/app/sessionLifecycle/sessionLifecycle.test.ts b/packages/agent-core-v2/test/app/sessionLifecycle/sessionLifecycle.test.ts index 49dfc000b0..eb3202a2c9 100644 --- a/packages/agent-core-v2/test/app/sessionLifecycle/sessionLifecycle.test.ts +++ b/packages/agent-core-v2/test/app/sessionLifecycle/sessionLifecycle.test.ts @@ -995,10 +995,16 @@ describe('SessionLifecycleService', () => { } as unknown as IAgentLifecycleService), ]); const archived: string[] = []; + const releasing: string[] = []; svc.onDidArchiveSession((e) => archived.push(e.sessionId)); + svc.hooks.onWillReleaseSession.register('test', async (event, next) => { + releasing.push(`${event.reason}:${event.sessionId}`); + await next(); + }); await svc.create({ sessionId: 's1', workDir: '/tmp/proj' }); await svc.archive('s1'); expect(archived).toEqual(['s1']); + expect(releasing).toEqual(['archive:s1']); }); describe('additional dirs', () => { @@ -1512,6 +1518,41 @@ describe('SessionLifecycleService', () => { await expect(stat(leaseOwnerFile(root, 's1'))).rejects.toThrow(); }); + it('keeps the session lease until release hooks settle', async () => { + const root = await makeTmpRoot(); + const svc = build(realInstanceSeeds(root)); + await svc.create({ sessionId: 's1', workDir: '/tmp/proj' }); + let enterHook!: () => void; + const hookEntered = new Promise((resolve) => { + enterHook = resolve; + }); + let releaseHook!: () => void; + const hookGate = new Promise((resolve) => { + releaseHook = resolve; + }); + svc.hooks.onWillReleaseSession.register('lease-test', async (event, next) => { + expect(event).toEqual({ sessionId: 's1', reason: 'close' }); + expect(disposedSessionServices).toBe(1); + enterHook(); + await hookGate; + await next(); + }); + + const closing = svc.close('s1'); + await hookEntered; + + try { + await expect( + new CrossProcessLockService().acquire(leaseFile(root, 's1')), + ).rejects.toMatchObject({ code: CrossProcessLockErrorCode.Held }); + } finally { + releaseHook(); + await closing; + } + const successor = await new CrossProcessLockService().acquire(leaseFile(root, 's1')); + successor.release(); + }); + it('keeps authority and lease when the session durability barrier fails', async () => { const root = await makeTmpRoot(); const { seeds, appendLog, registry, storage } = realAlsSeeds(root); diff --git a/packages/kap-server/src/start.ts b/packages/kap-server/src/start.ts index 16e4161966..d2e203eeef 100644 --- a/packages/kap-server/src/start.ts +++ b/packages/kap-server/src/start.ts @@ -318,13 +318,11 @@ export async function startServer(opts: ServerStartOptions = {}): Promise>(); + private readonly releasingSessions = new Map>(); private readonly maxBufferSize: number; private readonly coreEventSubscription: IDisposable; + private readonly sessionReleaseHook: IDisposable; /** Fire-and-forget dispatches still in flight (possibly inside `ensureState`). */ private readonly inFlightDispatches = new Set>(); private closed = false; @@ -246,6 +248,15 @@ export class SessionEventBroadcaster { this.coreEventSubscription = opts.core.accessor .get(IEventService) .subscribe((event) => this.onCoreEvent(event)); + this.sessionReleaseHook = opts.core.accessor + .get(ISessionLifecycleService) + .hooks.onWillReleaseSession.register( + 'sessionEventBroadcaster', + async ({ sessionId }, next) => { + await this.releaseSessionState(sessionId); + await next(); + }, + ); } /** @@ -662,13 +673,11 @@ export class SessionEventBroadcaster { await this.drainDispatches(); this.closed = true; this.coreEventSubscription.dispose(); - for (const [sessionId, state] of this.sessions) { - await disposeSessionState(state); - // Transcript bindings die with the session stream (its store - // subscriptions were disposed above; the producer binding goes here). - this.opts.transcriptService?.dropSession(sessionId); + this.sessionReleaseHook.dispose(); + const sessionIds = new Set([...this.sessions.keys(), ...this.pendingStates.keys()]); + for (const sessionId of sessionIds) { + await this.releaseSessionState(sessionId); } - this.sessions.clear(); } /** @@ -685,7 +694,7 @@ export class SessionEventBroadcaster { } private ensureState(sessionId: string): Promise { - if (this.closed) return Promise.resolve(undefined); + if (this.closed || this.releasingSessions.has(sessionId)) return Promise.resolve(undefined); const existing = this.sessions.get(sessionId); if (existing !== undefined) return Promise.resolve(existing); let pending = this.pendingStates.get(sessionId); @@ -710,7 +719,7 @@ export class SessionEventBroadcaster { sessionJournalPath(this.opts.eventsDir, sessionId), this.opts.logger, ); - if (this.closed) { + if (this.closed || this.releasingSessions.has(sessionId)) { await journal.close(); return undefined; } @@ -754,6 +763,30 @@ export class SessionEventBroadcaster { return state; } + private releaseSessionState(sessionId: string): Promise { + const existing = this.releasingSessions.get(sessionId); + if (existing !== undefined) return existing; + const release = this.disposeSession(sessionId).finally(() => { + if (this.releasingSessions.get(sessionId) === release) { + this.releasingSessions.delete(sessionId); + } + }); + this.releasingSessions.set(sessionId, release); + return release; + } + + private async disposeSession(sessionId: string): Promise { + await this.pendingStates.get(sessionId); + const state = this.sessions.get(sessionId); + if (state === undefined) return; + this.sessions.delete(sessionId); + try { + await disposeSessionState(state); + } finally { + this.opts.transcriptService?.dropSession(sessionId); + } + } + private onCoreEvent(event: GlobalEvent): void { if (event.type === 'session.list_changed') { // Published by `SessionListWatchService` when a workspace/session diff --git a/packages/kap-server/test/sessionEventBroadcaster.test.ts b/packages/kap-server/test/sessionEventBroadcaster.test.ts index b9b4c2bed0..8c43579d66 100644 --- a/packages/kap-server/test/sessionEventBroadcaster.test.ts +++ b/packages/kap-server/test/sessionEventBroadcaster.test.ts @@ -6,7 +6,6 @@ import { mkdtemp, rm, stat } from 'node:fs/promises'; import { tmpdir } from 'node:os'; import { join } from 'node:path'; -import type { IScopeHandle, Scope } from '@moonshot-ai/agent-core-v2'; import { ContextSizeModel, IAgentActivityView, @@ -21,7 +20,11 @@ import { IWireService, ISessionMetadata, SessionInteractionService, + type IScopeHandle, + type SessionLifecycleHooks, + type Scope, } from '@moonshot-ai/agent-core-v2'; +import { createHooks } from '@moonshot-ai/agent-core-v2/hooks'; import type { AgentEvent } from '../src/transport/ws/v1/events'; import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; @@ -214,6 +217,7 @@ function makeCore( sessions: Map, eventBus = new FakeEventBus(), metaAgents: Record = {}, + lifecycleHooks = createSessionLifecycleHooks(), ): Scope { const accessor = { get(token: unknown): unknown { @@ -223,6 +227,7 @@ function makeCore( // Inert lifecycle events (TranscriptService subscribes on construction). onDidCloseSession: () => ({ dispose: () => {} }), onDidArchiveSession: () => ({ dispose: () => {} }), + hooks: lifecycleHooks, get: (sid: string) => { const lifecycle = sessions.get(sid); if (lifecycle === undefined) return undefined; @@ -245,6 +250,14 @@ function makeCore( return { accessor } as unknown as Scope; } +function createSessionLifecycleHooks() { + return createHooks([ + 'onDidCreateSession', + 'onWillCloseSession', + 'onWillReleaseSession', + ]); +} + function agentEvent(type: string, extra: Record = {}): AgentEvent { return { type, ...extra } as unknown as AgentEvent; } @@ -269,6 +282,7 @@ describe('SessionEventBroadcaster', () => { let dir: string; let sessions: Map; let eventBus: FakeEventBus; + let lifecycleHooks: ReturnType; let bc: SessionEventBroadcaster; beforeEach(async () => { @@ -277,10 +291,11 @@ describe('SessionEventBroadcaster', () => { dir = await mkdtemp(join(tmpdir(), 'kimi-broadcaster-test-')); sessions = new Map(); eventBus = new FakeEventBus(); + lifecycleHooks = createSessionLifecycleHooks(); bc = new SessionEventBroadcaster({ eventsDir: dir, homeDir: dir, - core: makeCore(sessions, eventBus), + core: makeCore(sessions, eventBus, {}, lifecycleHooks), maxBufferSize: 3, }); }); @@ -332,6 +347,35 @@ describe('SessionEventBroadcaster', () => { expect(durable[0]!.volatile).toBeUndefined(); }); + it('rebuilds subscriptions and the journal writer after a session is released', async () => { + const firstLifecycle = new FakeLifecycle(); + const firstMain = firstLifecycle.addAgent('main'); + sessions.set('s1', firstLifecycle); + const { target, envelopes } = collectingTarget(); + await bc.subscribe('s1', target); + firstMain.bus.emit(agentEvent('turn.started', { turnId: 1 })); + const firstSeq = (await bc.getCursor('s1')).seq; + expect(firstSeq).toBe(2); + + await lifecycleHooks.onWillReleaseSession.run({ sessionId: 's1', reason: 'archive' }); + firstMain.bus.emit(agentEvent('turn.ended', { turnId: 1, reason: 'completed' })); + + const restoredLifecycle = new FakeLifecycle(); + const restoredMain = restoredLifecycle.addAgent('main'); + sessions.set('s1', restoredLifecycle); + expect(await bc.subscribe('s1', target)).toBe(true); + restoredMain.bus.emit(agentEvent('turn.started', { turnId: 2 })); + await bc.getCursor('s1'); + + const durable = envelopes.filter((event) => event.volatile !== true); + expect(durable.some((event) => event.type === 'turn.ended')).toBe(false); + expect(durable.at(-1)).toMatchObject({ + type: 'turn.started', + seq: firstSeq + 2, + payload: { turnId: 2, agentId: 'main', sessionId: 's1' }, + }); + }); + it('fans out volatile events with the current watermark + offset, not journaled', async () => { const lc = new FakeLifecycle(); const main = lc.addAgent('main'); From 03be83f671748fd823416b5b908d32851730a235 Mon Sep 17 00:00:00 2001 From: 7Sageer <7sageer@djwcb.cn> Date: Tue, 21 Jul 2026 18:54:19 +0800 Subject: [PATCH 14/22] refactor: simplify write fencing to stat-only detection Decouple the session file ledger from the fs watcher: verdicts come from a fresh stat-tuple compare immediately before execution, with no watcher ticks or cross-process file locks. Drop the dead dirty-tick and ensured-roots surface from the session fs watch service so it is only a confined, debounced change feed. Write fencing no longer keeps per-call target bookkeeping: the did-hook baselines the ledger from the revision the executed call attached to its own result. FileEditService verifies stat stability across the read-transform-write window. --- .../agent/fileFencing/fileFencingService.ts | 57 ++----- .../src/app/edit/fileEditService.ts | 31 +++- .../session/sessionFileLedger/fileLedger.ts | 18 +-- .../sessionFileLedger/fileLedgerService.ts | 81 ++-------- .../src/session/sessionFs/fsWatch.ts | 34 +---- .../src/session/sessionFs/fsWatchService.ts | 141 ++---------------- .../agent/fileFencing/fileFencing.test.ts | 94 ++++-------- .../test/agent/plan/plan.test.ts | 8 +- .../test/app/edit/tools/edit.test.ts | 40 ++++- .../sessionFileLedger/fileLedger.test.ts | 99 ++---------- .../session/sessionFs/fsWatchService.test.ts | 94 +----------- 11 files changed, 163 insertions(+), 534 deletions(-) diff --git a/packages/agent-core-v2/src/agent/fileFencing/fileFencingService.ts b/packages/agent-core-v2/src/agent/fileFencing/fileFencingService.ts index 261d6699ed..7387d568bf 100644 --- a/packages/agent-core-v2/src/agent/fileFencing/fileFencingService.ts +++ b/packages/agent-core-v2/src/agent/fileFencing/fileFencingService.ts @@ -8,19 +8,14 @@ * wrapper re-checks the ledger only after the scheduler grants the declared * file access, so a queued write cannot rely on a stale preflight verdict. The * target path comes from the resolved execution's file accesses - * — the exact canonical path the tool itself computed — so the ledger and - * the watcher key it identically. The before-hook records the target keyed - * by `toolCall.id` (cleared in the did-hook and swept on turn change) and, - * for `Write`/`Edit`, computes the ledger verdict: `stale` blocks with an + * — the exact canonical path the tool itself computed. `stale` blocks with an * outside-modification conflict and `no-baseline` blocks with a read-first * reason (Edit-over-existing, or Write over an already existing file). The - * did-hook records the revision captured by the successful fenced call - * (ranged Reads excepted — per the ledger contract they never count as full - * reads); direct creation of a new file is verdict-`clean`, so it never - * blocks. Watcher echos of the session's own writes are absorbed by the - * ledger's stat punch, so consecutive Edits stay clean. Checked after - * `permission` (ignition order is set by `agentLifecycle`). Bound at Agent - * scope. + * did-hook baselines the ledger from the revision the successful call captured + * on its own result (ranged Reads excepted — per the ledger contract they + * never count as full reads); direct creation of a new file is verdict-`clean`, + * so it never blocks. Checked after `permission` (ignition order is set by + * `agentLifecycle`). Bound at Agent scope. */ import { InstantiationType } from '#/_base/di/extensions'; @@ -36,22 +31,13 @@ import { ISessionFileLedger, type FileLedgerVerdict, } from '#/session/sessionFileLedger/fileLedger'; -import { - toolFileRevision, - type ToolAccesses, - type ToolFileRevision, -} from '#/tool/toolContract'; +import { toolFileRevision, type ToolAccesses } from '#/tool/toolContract'; import { IAgentFileFencingService } from './fileFencing'; const READ_TOOL = 'Read'; const WRITE_TOOLS = new Set(['Write', 'Edit']); -interface FencingTarget { - readonly toolName: string; - readonly path: string; -} - function isFenced(ctx: ToolExecutionHookContext): boolean { return ctx.toolCall.name === READ_TOOL || WRITE_TOOLS.has(ctx.toolCall.name); } @@ -87,19 +73,9 @@ function blockReason(toolName: string, path: string, verdict: FileLedgerVerdict) ); } -function revisionForTarget( - revision: ToolFileRevision | undefined, - targetPath: string, -): ToolFileRevision | undefined { - return revision?.path === targetPath ? revision : undefined; -} - export class AgentFileFencingService extends Disposable implements IAgentFileFencingService { declare readonly _serviceBrand: undefined; - private readonly targets = new Map(); - private markerTurnId: number | undefined; - constructor( @ISessionFileLedger private readonly ledger: ISessionFileLedger, @IAgentToolExecutorService toolExecutor: IAgentToolExecutorService, @@ -111,7 +87,7 @@ export class AgentFileFencingService extends Disposable implements IAgentFileFen await next(); }); toolExecutor.hooks.onDidExecuteTool.register('writeFencing', async (ctx, next) => { - await this.onDid(ctx); + this.onDid(ctx); await next(); }); } @@ -120,11 +96,6 @@ export class AgentFileFencingService extends Disposable implements IAgentFileFen if (!isFenced(ctx)) return; const path = targetPathOf(ctx); if (path === undefined) return; - if (this.markerTurnId !== ctx.turnId) { - this.markerTurnId = ctx.turnId; - this.targets.clear(); - } - this.targets.set(ctx.toolCall.id, { toolName: ctx.toolCall.name, path }); if (!WRITE_TOOLS.has(ctx.toolCall.name)) return; const execute = ctx.decision?.execute ?? ctx.execution.execute; ctx.decision = { @@ -140,15 +111,13 @@ export class AgentFileFencingService extends Disposable implements IAgentFileFen }; } - private async onDid(ctx: ToolDidExecuteContext): Promise { + private onDid(ctx: ToolDidExecuteContext): void { if (!isFenced(ctx)) return; - const target = this.targets.get(ctx.toolCall.id); - this.targets.delete(ctx.toolCall.id); - if (target === undefined || ctx.result.isError === true) return; - if (target.toolName === READ_TOOL && isRangedRead(ctx.args)) return; - const revision = revisionForTarget(ctx.result[toolFileRevision], target.path); + if (ctx.result.isError === true) return; + if (ctx.toolCall.name === READ_TOOL && isRangedRead(ctx.args)) return; + const revision = ctx.result[toolFileRevision]; if (revision !== undefined) { - this.ledger.recordBaseline(target.path, { + this.ledger.recordBaseline(revision.path, { exists: true, ino: revision.ino, mtimeMs: revision.mtimeMs, diff --git a/packages/agent-core-v2/src/app/edit/fileEditService.ts b/packages/agent-core-v2/src/app/edit/fileEditService.ts index 2aea5e0d3a..b3a5c5ef15 100644 --- a/packages/agent-core-v2/src/app/edit/fileEditService.ts +++ b/packages/agent-core-v2/src/app/edit/fileEditService.ts @@ -2,10 +2,11 @@ * `edit` domain (L4) — `IFileEditService` implementation. * * Reads the file through the os `hostFs` domain (`IHostFileSystem`), runs the - * pure edit logic (`TextModel` + `EditService`), and writes the re-materialized - * content back. Maps host-level failures (e.g. `EISDIR`) to the domain-neutral - * `FileEditResult`; it owns no tool-facing message, which the Agent `EditTool` - * adapter supplies. Bound at App scope. + * pure edit logic (`TextModel` + `EditService`), verifies that the stat tuple + * stayed stable across the read/transform window, and writes the + * re-materialized content back. Maps host-level failures (e.g. `EISDIR`) to + * the domain-neutral `FileEditResult`; it owns no tool-facing message, which + * the Agent `EditTool` adapter supplies. Bound at App scope. */ import { InstantiationType } from '#/_base/di/extensions'; @@ -17,6 +18,13 @@ import { EditService } from './editService'; import { type FileEditInput, type FileEditResult, IFileEditService } from './fileEdit'; import { TextModel } from './textModel'; +function sameRevision( + a: { readonly ino?: number; readonly mtimeMs?: number; readonly size: number }, + b: { readonly ino?: number; readonly mtimeMs?: number; readonly size: number }, +): boolean { + return a.ino === b.ino && a.mtimeMs === b.mtimeMs && a.size === b.size; +} + export class FileEditService implements IFileEditService { declare readonly _serviceBrand: undefined; @@ -28,7 +36,15 @@ export class FileEditService implements IFileEditService { async edit(input: FileEditInput): Promise { try { + const beforeRead = await this.fs.stat(input.path); const raw = await this.fs.readText(input.path, { errors: 'strict' }); + const afterRead = await this.fs.stat(input.path); + if (!sameRevision(beforeRead, afterRead)) { + return { + ok: false, + error: `${input.displayPath} changed on disk while the edit was being prepared. Read it again, then retry.`, + }; + } const model = new TextModel(raw); const result = this.editor.apply(model, { path: input.displayPath, @@ -39,6 +55,13 @@ export class FileEditService implements IFileEditService { if (!result.ok) { return { ok: false, error: result.error }; } + const beforeWrite = await this.fs.stat(input.path); + if (!sameRevision(afterRead, beforeWrite)) { + return { + ok: false, + error: `${input.displayPath} changed on disk while the edit was being prepared. Read it again, then retry.`, + }; + } const stat = await this.fs.writeText(input.path, result.rawContent); return { ok: true, count: result.count, stat }; } catch (error) { diff --git a/packages/agent-core-v2/src/session/sessionFileLedger/fileLedger.ts b/packages/agent-core-v2/src/session/sessionFileLedger/fileLedger.ts index ac3436ecba..60779d9442 100644 --- a/packages/agent-core-v2/src/session/sessionFileLedger/fileLedger.ts +++ b/packages/agent-core-v2/src/session/sessionFileLedger/fileLedger.ts @@ -3,18 +3,16 @@ * * Defines the `ISessionFileLedger` that remembers, per normalized absolute * path, the on-disk stat tuple (`ino`, `mtimeMs`, `size`, existence) this - * session last successfully read or wrote, together with the - * `sessionFsWatch` tick captured when that revision is recorded. Before every Write/Edit, - * `compare` stats the target again and compares the tuple directly; live dirty - * signals classify watcher echoes and truncated windows but are never the - * correctness gate. This avoids both debounce latency and delayed watcher - * delivery turning into a stale-write allowance. + * session last successfully read or wrote. Before every Write/Edit, `compare` + * stats the target again and compares the tuple directly. The mechanism is + * detection, not mutual exclusion: it blocks stale writes it can observe and + * deliberately accepts the residual stat→write race, which no + * cooperating-process lock could close against external editors anyway. * * The verdict drives the write-path policy: `clean` lets the call through, - * `stale` means the file diverged since the baseline (or a path-level dirty - * signal hit with no baseline at all), and `no-baseline` means the target - * exists on disk but this session never read or wrote it (the read-first - * case). New-file creation is always `clean`. + * `stale` means the file diverged since the baseline, and `no-baseline` means + * the target exists on disk but this session never read or wrote it (the + * read-first case). New-file creation is always `clean`. * * The ledger is in-memory only: never persisted, never journaled, never part * of diagnostics. `resume` and `fork` therefore start from an empty ledger diff --git a/packages/agent-core-v2/src/session/sessionFileLedger/fileLedgerService.ts b/packages/agent-core-v2/src/session/sessionFileLedger/fileLedgerService.ts index ab24495dc5..c1d65eb015 100644 --- a/packages/agent-core-v2/src/session/sessionFileLedger/fileLedgerService.ts +++ b/packages/agent-core-v2/src/session/sessionFileLedger/fileLedgerService.ts @@ -2,31 +2,17 @@ * `sessionFileLedger` domain (L2) — `ISessionFileLedger` implementation. * * In-memory per-session ledger of on-disk stat tuples keyed by - * `normalizeFsWatchKey`. `recordBaseline` stores the revision observed by the - * successful file I/O together with the current watch tick, so a did-hook - * cannot accidentally bless a later external change. `compare` always - * re-stats immediately before a write decision; - * watcher ticks classify the result but never replace the stat. An - * unverifiable stat fails closed as `stale`. The - * service also guarantees `ISessionFsWatchService` watches the session roots - * (`workDir` + `additionalDirs` from `ISessionWorkspaceContext`), adding an - * unwatched containing root additively whenever a Write/Edit target falls - * under one; writes outside all session roots proceed unwatched. Bound at - * Session scope. + * `normalizeFsWatchKey`. `recordBaseline` stores the revision observed by + * successful file I/O and `compare` always re-stats immediately before a + * write decision. An unverifiable stat fails closed as `stale`. + * Bound at Session scope. */ -import { resolve } from 'node:path'; - import { InstantiationType } from '#/_base/di/extensions'; import { LifecycleScope, registerScopedService } from '#/_base/di/scope'; import { unwrapErrorCause } from '#/_base/errors/errors'; import { IHostFileSystem } from '#/os/interface/hostFileSystem'; -import { - ISessionFsWatchService, - isFsWatchKeyWithin, - normalizeFsWatchKey, -} from '#/session/sessionFs/fsWatch'; -import { ISessionWorkspaceContext } from '#/session/workspaceContext/workspaceContext'; +import { normalizeFsWatchKey } from '#/session/sessionFs/fsWatch'; import { ISessionFileLedger, @@ -35,71 +21,24 @@ import { type FileStatTuple, } from './fileLedger'; -type FileLedgerEntry = FileStatTuple & { readonly tick: number }; - export class SessionFileLedger implements ISessionFileLedger { declare readonly _serviceBrand: undefined; - private readonly entries = new Map(); + private readonly entries = new Map(); - constructor( - @ISessionFsWatchService private readonly watch: ISessionFsWatchService, - @ISessionWorkspaceContext private readonly workspace: ISessionWorkspaceContext, - @IHostFileSystem private readonly hostFs: IHostFileSystem, - ) { - this.watch.ensureWatchedRoots(this.sessionRoots()); - } + constructor(@IHostFileSystem private readonly hostFs: IHostFileSystem) {} recordBaseline(path: string, revision: FileStatTuple): void { - this.ensureWatchedRootFor(path); - this.entries.set(normalizeFsWatchKey(path), { - ...revision, - tick: this.watch.currentTick, - }); + this.entries.set(normalizeFsWatchKey(path), revision); } async compare(path: string): Promise { - this.ensureWatchedRootFor(path); const key = normalizeFsWatchKey(path); const entry = this.entries.get(key); - const root = this.containingWatchedRoot(key); const current = await this.tryStat(path); if (current === undefined) return 'stale'; - const dirty = Math.max( - this.watch.dirtyTickFor(path) ?? 0, - root === undefined ? 0 : (this.watch.rootDirtyTickFor(root) ?? 0), - ); - if (entry === undefined) { - if (dirty > 0) return current.exists ? 'stale' : 'clean'; - return current.exists ? 'no-baseline' : 'clean'; - } - if (fileStatTuplesEqual(entry, current)) { - if (dirty > entry.tick) this.entries.set(key, { ...current, tick: dirty }); - return 'clean'; - } - return 'stale'; - } - - private sessionRoots(): readonly string[] { - return [this.workspace.workDir, ...this.workspace.additionalDirs].map((dir) => resolve(dir)); - } - - private ensureWatchedRootFor(path: string): void { - const root = this.longestContaining(normalizeFsWatchKey(path), this.sessionRoots()); - if (root !== undefined) this.watch.ensureWatchedRoots([root]); - } - - private containingWatchedRoot(key: string): string | undefined { - return this.longestContaining(key, this.watch.watchedRoots); - } - - private longestContaining(key: string, roots: readonly string[]): string | undefined { - let best: string | undefined; - for (const root of roots) { - if (!isFsWatchKeyWithin(key, normalizeFsWatchKey(root))) continue; - if (best === undefined || root.length > best.length) best = root; - } - return best; + if (entry === undefined) return current.exists ? 'no-baseline' : 'clean'; + return fileStatTuplesEqual(entry, current) ? 'clean' : 'stale'; } private async tryStat(path: string): Promise { diff --git a/packages/agent-core-v2/src/session/sessionFs/fsWatch.ts b/packages/agent-core-v2/src/session/sessionFs/fsWatch.ts index 6b36821266..00d4b4c520 100644 --- a/packages/agent-core-v2/src/session/sessionFs/fsWatch.ts +++ b/packages/agent-core-v2/src/session/sessionFs/fsWatch.ts @@ -8,20 +8,10 @@ * dropped. Session-scoped — the scope itself is the session, so no * `sessionId` is threaded through. * - * Beyond the change feed, every confined change entry is folded into a - * per-session dirty state for optimistic-concurrency consumers - * (`sessionFileLedger`): a monotonic `currentTick` incremented per processed - * entry, immediate per-path dirty ticks independent of debounce delivery, and - * per-root dirty ticks for truncated windows whose exact paths were dropped. - * Client subscriptions (`setWatchedPaths`, replace semantics) and - * optimistic-concurrency watch anchors (`ensureWatchedRoots`, additive, - * absolute) are independent sets so neither side can clobber the other; - * `watchedRoots` reports the union as absolute paths. - * - * Also owns the lexical key helpers shared with the ledger so both sides key - * paths identically: `normalizeFsWatchKey` (lexical normalize only, no - * `realpath`; case-folded on macOS/Windows) and `isFsWatchKeyWithin` - * (separator-bounded prefix containment on normalized keys). + * Also owns the lexical key helper shared with the optimistic-concurrency + * ledger (`sessionFileLedger`) so both sides key paths identically: + * `normalizeFsWatchKey` (lexical normalize only, no `realpath`; case-folded + * on macOS/Windows). */ import { normalize, sep } from 'node:path'; @@ -55,12 +45,6 @@ export function normalizeFsWatchKey(path: string): string { return FS_WATCH_KEY_CASE_FOLD ? normalized.toLowerCase() : normalized; } -export function isFsWatchKeyWithin(key: string, rootKey: string): boolean { - if (key === rootKey) return true; - const prefix = rootKey.endsWith('/') ? rootKey : `${rootKey}/`; - return key.startsWith(prefix); -} - export interface ISessionFsWatchService { readonly _serviceBrand: undefined; @@ -68,16 +52,6 @@ export interface ISessionFsWatchService { readonly watchedPaths: readonly string[]; - ensureWatchedRoots(roots: readonly string[]): void; - - readonly watchedRoots: readonly string[]; - - readonly currentTick: number; - - dirtyTickFor(path: string): number | undefined; - - rootDirtyTickFor(root: string): number | undefined; - readonly onDidChangeFiles: Event; } diff --git a/packages/agent-core-v2/src/session/sessionFs/fsWatchService.ts b/packages/agent-core-v2/src/session/sessionFs/fsWatchService.ts index e0c882805e..9288609979 100644 --- a/packages/agent-core-v2/src/session/sessionFs/fsWatchService.ts +++ b/packages/agent-core-v2/src/session/sessionFs/fsWatchService.ts @@ -5,27 +5,11 @@ * events to the caller-declared subtree and to non-`.gitignore`d paths, * debounces them into fixed windows and re-exposes them as workspace-relative * `FsChangeEvent`s. Path confinement is lexical (`ISessionWorkspaceContext.isWithin`), - * matching `sessionFs`. - * - * Two independent watch sets feed confinement: client subscriptions - * (`setWatchedPaths`, workspace-relative, replace semantics) and ensured - * roots (`ensureWatchedRoots`, absolute, additive — used by the - * optimistic-concurrency ledger). An ensured root inside the workDir is - * covered by the workDir watcher; one outside gets its own os handle whose - * events skip the workDir `.gitignore` filter and are never re-emitted as - * `FsChangeEvent`s (protocol paths are workspace-relative) — they only fold - * into dirty state. The os workDir watcher runs while either set is + * matching `sessionFs`. The os workDir watcher runs while the watched set is * non-empty. - * - * Dirty state is independent from event delivery: every confined raw change - * immediately advances `dirtyTicks[normalizedAbs]`, while protocol events are - * still buffered and debounced. A truncated window additionally advances - * `dirtyRootTicks` for every watched root. This keeps the optimistic - * concurrency ledger from inheriting the UI debounce window. - * Key normalization is the shared `normalizeFsWatchKey` from the contract. */ -import { isAbsolute, join, relative, resolve, sep } from 'node:path'; +import { isAbsolute, join, relative, sep } from 'node:path'; import ignore, { type Ignore } from 'ignore'; @@ -43,15 +27,13 @@ import { import { ISessionWorkspaceContext } from '#/session/workspaceContext/workspaceContext'; import type { FsChangeEvent } from './fsWatch'; -import { ISessionFsWatchService, isFsWatchKeyWithin, normalizeFsWatchKey } from './fsWatch'; +import { ISessionFsWatchService } from './fsWatch'; const DEFAULT_DEBOUNCE_MS = 200; const DEFAULT_MAX_CHANGES_PER_WINDOW = 500; interface PendingChange { - readonly abs: string; - readonly rel: string | undefined; - readonly tick: number; + readonly rel: string; readonly change: HostFsChange['action']; readonly kind: HostFsChange['kind']; } @@ -70,23 +52,14 @@ export class SessionFsWatchService extends Disposable implements ISessionFsWatch readonly onDidChangeFiles: Event = this.emitter.event; private watched = new Set(); - private readonly ensured = new Map(); private handle: IHostFsWatchHandle | undefined; private handleSub: IDisposable | undefined; - private readonly extraHandles = new Map< - string, - { readonly handle: IHostFsWatchHandle; readonly sub: IDisposable } - >(); private debounceTimer: NodeJS.Timeout | undefined; private pending: PendingChange[] = []; private rawCount = 0; private truncated = false; - private tick = 0; - private readonly dirtyTicks = new Map(); - private readonly dirtyRootTicks = new Map(); - private readonly debounceMs = readPositiveIntEnv( 'KIMI_CODE_FS_WATCH_DEBOUNCE_MS', DEFAULT_DEBOUNCE_MS, @@ -115,28 +88,6 @@ export class SessionFsWatchService extends Disposable implements ISessionFsWatch return Array.from(this.watched); } - get watchedRoots(): readonly string[] { - const out = new Map(); - for (const rel of this.watched) { - const abs = this.workspace.resolve(rel); - out.set(normalizeFsWatchKey(abs), abs); - } - for (const [key, abs] of this.ensured) out.set(key, abs); - return Array.from(out.values()); - } - - get currentTick(): number { - return this.tick; - } - - dirtyTickFor(path: string): number | undefined { - return this.dirtyTicks.get(normalizeFsWatchKey(path)); - } - - rootDirtyTickFor(root: string): number | undefined { - return this.dirtyRootTicks.get(normalizeFsWatchKey(root)); - } - setWatchedPaths(paths: readonly string[]): void { const next = new Set(); for (const p of paths) { @@ -144,7 +95,7 @@ export class SessionFsWatchService extends Disposable implements ISessionFsWatch next.add(this.toRel(abs)); } this.watched = next; - if (next.size === 0 && this.ensured.size === 0) { + if (next.size === 0) { this.teardownHandle(); this.clearWindow(); return; @@ -152,39 +103,9 @@ export class SessionFsWatchService extends Disposable implements ISessionFsWatch this.ensureHandle(); } - ensureWatchedRoots(roots: readonly string[]): void { - let changed = false; - for (const root of roots) { - const abs = resolve(root); - if (this.isCovered(abs)) continue; - const key = normalizeFsWatchKey(abs); - this.ensured.set(key, abs); - changed = true; - if (!isFsWatchKeyWithin(key, normalizeFsWatchKey(this.workspace.workDir))) { - this.startExtraHandle(key, abs); - } - } - if (changed || this.watched.size > 0) this.ensureHandle(); - } - - private isCovered(abs: string): boolean { - const key = normalizeFsWatchKey(abs); - for (const root of this.watchedRoots) { - if (isFsWatchKeyWithin(key, normalizeFsWatchKey(root))) return true; - } - return false; - } - - private startExtraHandle(key: string, abs: string): void { - if (this.extraHandles.has(key)) return; - const handle = this.hostFsWatch.watch(abs, { recursive: true }); - const sub = handle.onDidChange((e) => this.onRawExtra(key, e)); - this.extraHandles.set(key, { handle, sub }); - } - private ensureHandle(): void { if (this.handle !== undefined) return; - if (this.watched.size === 0 && this.ensured.size === 0) return; + if (this.watched.size === 0) return; this.loadGitignore(); const handle = this.hostFsWatch.watch(this.workspace.workDir, { recursive: true }); this.handle = handle; @@ -228,33 +149,15 @@ export class SessionFsWatchService extends Disposable implements ISessionFsWatch if (this.matcher.ignores(probe)) return; if (isUnderAny(rel, this.watched)) { this.record(e, rel); - return; } - const key = normalizeFsWatchKey(e.path); - for (const rootKey of this.ensured.keys()) { - if (isFsWatchKeyWithin(key, rootKey)) { - this.record(e, rel); - return; - } - } - } - - private onRawExtra(rootKey: string, e: HostFsChange): void { - if (!isFsWatchKeyWithin(normalizeFsWatchKey(e.path), rootKey)) return; - this.record(e, undefined); } - private record(e: HostFsChange, rel: string | undefined): void { - this.tick += 1; - this.dirtyTicks.set(normalizeFsWatchKey(e.path), this.tick); - this.pending.push({ abs: e.path, rel, tick: this.tick, change: e.action, kind: e.kind }); + private record(e: HostFsChange, rel: string): void { + this.pending.push({ rel, change: e.action, kind: e.kind }); this.rawCount += 1; if (this.pending.length > this.maxChangesPerWindow) { this.truncated = true; this.pending = []; - for (const root of this.watchedRoots) { - this.dirtyRootTicks.set(normalizeFsWatchKey(root), this.tick); - } } if (this.debounceTimer === undefined) { const timer = setTimeout(() => this.flush(), this.debounceMs); @@ -273,21 +176,13 @@ export class SessionFsWatchService extends Disposable implements ISessionFsWatch this.rawCount = 0; this.truncated = false; - if (truncated) { - for (const root of this.watchedRoots) { - this.dirtyRootTicks.set(normalizeFsWatchKey(root), this.tick); - } - } - const changes = truncated ? [] - : pending - .filter((entry) => entry.rel !== undefined) - .map((entry) => ({ - path: entry.rel!, - change: entry.change, - kind: entry.kind, - })); + : pending.map((entry) => ({ + path: entry.rel, + change: entry.change, + kind: entry.kind, + })); if (truncated || changes.length > 0) { const event: FsChangeEvent = { changes, @@ -303,11 +198,6 @@ export class SessionFsWatchService extends Disposable implements ISessionFsWatch clearTimeout(this.debounceTimer); this.debounceTimer = undefined; } - if (this.truncated) { - for (const root of this.watchedRoots) { - this.dirtyRootTicks.set(normalizeFsWatchKey(root), this.tick); - } - } this.pending = []; this.rawCount = 0; this.truncated = false; @@ -316,11 +206,6 @@ export class SessionFsWatchService extends Disposable implements ISessionFsWatch override dispose(): void { this.clearWindow(); this.teardownHandle(); - for (const { handle, sub } of this.extraHandles.values()) { - sub.dispose(); - handle.dispose(); - } - this.extraHandles.clear(); super.dispose(); } diff --git a/packages/agent-core-v2/test/agent/fileFencing/fileFencing.test.ts b/packages/agent-core-v2/test/agent/fileFencing/fileFencing.test.ts index a293c04027..96f6ebc0b2 100644 --- a/packages/agent-core-v2/test/agent/fileFencing/fileFencing.test.ts +++ b/packages/agent-core-v2/test/agent/fileFencing/fileFencing.test.ts @@ -1,19 +1,17 @@ /** * `fileFencing` domain (L4) — verifies the write/read-first gate end to end * through the real DI scope tree: a real tmpdir, the real `HostFileSystem`, - * the real watch service folding fake os-watcher events, and the registered - * `writeFencing` hook participant over a real `OrderedHookSlot`. Covers the - * hard-block verdicts (stale outside change / never-read existing file), the - * own-write echo / truncated-window stale checks, out-of-root stat fallback, - * watched-root ensuring for additional dirs, and ledger isolation between - * two Session scopes sharing one workspace (two-instance conflict). + * and the registered `writeFencing` hook participant over a real + * `OrderedHookSlot`. Covers the hard-block verdicts (stale outside change / + * never-read existing file), stat-only checks, and ledger isolation between + * two Session scopes sharing one workspace. */ import { mkdtempSync, rmSync, statSync, writeFileSync } from 'node:fs'; import { tmpdir } from 'node:os'; import { join } from 'node:path'; -import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { afterEach, describe, expect, it } from 'vitest'; import { LifecycleScope, type Scope } from '#/_base/di/scope'; import { createScopedTestHost, stubPair, type ScopedTestHost } from '#/_base/di/test'; @@ -32,12 +30,11 @@ import { IHostFsWatchService } from '#/os/interface/hostFsWatch'; import { ISessionContext, makeSessionContext } from '#/session/sessionContext/sessionContext'; import { ISessionFileLedger } from '#/session/sessionFileLedger/fileLedger'; import { SessionFileLedger } from '#/session/sessionFileLedger/fileLedgerService'; -import { ISessionFsWatchService } from '#/session/sessionFs/fsWatch'; -import { SessionFsWatchService } from '#/session/sessionFs/fsWatchService'; import { ISessionWorkspaceContext } from '#/session/workspaceContext/workspaceContext'; import { SessionWorkspaceContextService } from '#/session/workspaceContext/workspaceContextService'; import { ToolAccesses, + type ExecutableToolContext, type ExecutableToolResult, makeToolFileRevision, toolFileRevision, @@ -51,7 +48,6 @@ import { fakeHostFsWatch, type FakeWatch } from '../../session/sessionFs/stubs'; void AgentFileFencingService; void SessionFileLedger; -void SessionFsWatchService; void SessionWorkspaceContextService; void AgentToolExecutorService; @@ -115,7 +111,6 @@ interface AgentWorld { readonly session: Scope; readonly agent: Scope; readonly executor: IAgentToolExecutorService; - readonly watch: ISessionFsWatchService; readonly workspace: ISessionWorkspaceContext; readonly ledger: ISessionFileLedger; } @@ -131,7 +126,6 @@ function makeAgent(env: Env, session: Scope): AgentWorld { session, agent, executor, - watch: session.accessor.get(ISessionFsWatchService), workspace: session.accessor.get(ISessionWorkspaceContext), ledger: session.accessor.get(ISessionFileLedger), }; @@ -147,7 +141,12 @@ let nextCallSeq = 0; function beforeCtx( toolName: string, path: string, - opts: { id?: string; turnId?: number; args?: Record } = {}, + opts: { + id?: string; + turnId?: number; + args?: Record; + execute?: (ctx: ExecutableToolContext) => Promise; + } = {}, ): ToolBeforeExecuteContext { const id = opts.id ?? `call-${++nextCallSeq}`; const args = @@ -173,7 +172,7 @@ function beforeCtx( execution: { accesses: ToolAccesses.file(operation, path), approvalRule: toolName, - execute: async () => ({ output: 'ok' }), + execute: opts.execute ?? (async () => ({ output: 'ok' })), }, }; } @@ -266,27 +265,13 @@ async function runBlocked( return result; } -function foldChange(world: AgentWorld, rel: string, action: 'created' | 'modified' | 'deleted'): void { - world.env.fake.fire(rel, action); - vi.advanceTimersByTime(200); -} - -function foldJunk(env: Env, count = 501): void { - for (let i = 0; i < count; i++) env.fake.fire(`junk-${i}.tmp`, 'created'); - vi.advanceTimersByTime(200); -} - const hosts: ScopedTestHost[] = []; const cleanupPaths: string[] = []; describe('AgentFileFencingService', () => { - beforeEach(() => { - vi.useFakeTimers(); - }); afterEach(() => { for (const host of hosts.splice(0)) host.dispose(); for (const path of cleanupPaths.splice(0)) rmSync(path, { recursive: true, force: true }); - vi.useRealTimers(); }); it('blocks Edit on an existing file that was never read, read-first', async () => { @@ -306,7 +291,6 @@ describe('AgentFileFencingService', () => { await runOk(world, 'Read', file); writeFileSync(file, 'hello world'); - foldChange(world, 'a.txt', 'modified'); const blocked = await runBlocked(world, 'Edit', file); expect(blocked.output).toContain('changed on disk since'); @@ -330,6 +314,15 @@ describe('AgentFileFencingService', () => { expect(blocked?.output).toContain('changed on disk since'); }); + it('does not start a recursive workspace watcher for fencing', async () => { + const world = setup(); + const file = join(world.env.workDir, 'new.txt'); + + await runOk(world, 'Write', file); + + expect(world.env.fake.watchCalls).toEqual([]); + }); + it('keeps the revision captured by Read when the file changes before the did-hook', async () => { const world = setup(); const file = join(world.env.workDir, 'a.txt'); @@ -410,15 +403,13 @@ describe('AgentFileFencingService', () => { expect(await world.ledger.compare(file)).toBe('clean'); }); - it('keeps consecutive Edits clean through the own-write watcher echo and re-baselines', async () => { + it('keeps consecutive Edits clean while the stat tuple matches the baseline', async () => { const world = setup(); const file = join(world.env.workDir, 'a.txt'); writeFileSync(file, 'hello'); await runOk(world, 'Read', file); expect(world.env.statCalls()).toBe(0); - foldChange(world, 'a.txt', 'modified'); - await runOk(world, 'Edit', file); expect(world.env.statCalls()).toBe(1); @@ -426,18 +417,15 @@ describe('AgentFileFencingService', () => { expect(world.env.statCalls()).toBe(2); }); - it('resolves a truncated window by stat punch: unchanged passes, changed blocks', async () => { + it('checks the current stat on every execution: unchanged passes and changed blocks', async () => { const world = setup(); const file = join(world.env.workDir, 'a.txt'); writeFileSync(file, 'hello'); await runOk(world, 'Read', file); - foldJunk(world.env); - await runOk(world, 'Edit', file); writeFileSync(file, 'hello world'); - foldJunk(world.env); const blocked = await runBlocked(world, 'Edit', file); expect(blocked.output).toContain('changed on disk since'); @@ -469,20 +457,15 @@ describe('AgentFileFencingService', () => { expect(changed.output).toContain('changed on disk since'); }); - it('ensures an additional dir becomes watched when a write target falls under it', async () => { + it('fences additional dirs by stat without adding a watcher', async () => { const world = setup(); world.workspace.addAdditionalDir(world.env.outsideDir); const file = join(world.env.outsideDir, 'new.txt'); await runOk(world, 'Write', file); - expect(world.watch.watchedRoots).toContain(world.env.outsideDir); - expect(world.env.fake.watchCalls).toContain(world.env.outsideDir); + expect(world.env.fake.watchCalls).toEqual([]); writeFileSync(file, 'changed outside'); - world.env.fake.handles - .find((h) => h.root === world.env.outsideDir) - ?.fire('new.txt', 'modified'); - vi.advanceTimersByTime(200); const blocked = await runBlocked(world, 'Write', file); expect(blocked.output).toContain('changed on disk since'); @@ -499,12 +482,9 @@ describe('AgentFileFencingService', () => { const neverRead = await runBlocked(worldB, 'Edit', file); expect(neverRead.output).toContain('has not been read in this session'); + await runOk(worldB, 'Read', file); writeFileSync(file, 'hello world'); - env.fake.handles - .findLast((h) => h.root === env.workDir) - ?.fire('a.txt', 'modified'); - vi.advanceTimersByTime(200); const conflict = await runBlocked(worldB, 'Edit', file); expect(conflict.output).toContain('changed on disk since'); @@ -523,7 +503,6 @@ describe('AgentFileFencingService', () => { await runOk(world, 'Read', file); writeFileSync(file, 'hello world'); - foldChange(world, 'a.txt', 'modified'); // The stale verdict blocks the Edit; the wrapper's error result must not // be baselined by the did-hook, so the file stays stale until re-read. @@ -537,25 +516,6 @@ describe('AgentFileFencingService', () => { expect(retry.output).toContain('changed on disk since'); }); - it('does not leak a target across turns', async () => { - const world = setup(); - const file = join(world.env.workDir, 'a.txt'); - writeFileSync(file, 'hello'); - - await runBefore(world, beforeCtx('Edit', file, { id: 'call-abandoned', turnId: 1 })); - await runBefore( - world, - beforeCtx('Edit', join(world.env.workDir, 'other.txt'), { turnId: 2 }), - ); - - // A late did-hook for the abandoned turn-1 call finds no swept target and - // records nothing: a.txt stays never-read, so a real Edit still blocks. - const abandonedCtx = beforeCtx('Edit', file, { id: 'call-abandoned', turnId: 1 }); - await runDid(world, abandonedCtx); - const retry = await runBlocked(world, 'Edit', file); - expect(retry.output).toContain('has not been read in this session'); - }); - it('ignores tools other than Read/Write/Edit entirely', async () => { const world = setup(); const ctx = await runBefore( diff --git a/packages/agent-core-v2/test/agent/plan/plan.test.ts b/packages/agent-core-v2/test/agent/plan/plan.test.ts index 1e30eb029d..d4673dcfc2 100644 --- a/packages/agent-core-v2/test/agent/plan/plan.test.ts +++ b/packages/agent-core-v2/test/agent/plan/plan.test.ts @@ -484,7 +484,13 @@ describe('Plan service', () => { files.set(path, content); return textFileStat(content); }); - useFakes(createPlanFakes({ readText, writeText })); + const stat = vi.fn(async (path: string) => { + const content = files.get(path); + return content === undefined + ? { isFile: false, isDirectory: true, size: 0 } + : textFileStat(content); + }); + useFakes(createPlanFakes({ readText, writeText, stat })); const cwd = await makeTempDir('kimi-plan-write-tool-'); useTools([toolName]); profile.update({ cwd }); diff --git a/packages/agent-core-v2/test/app/edit/tools/edit.test.ts b/packages/agent-core-v2/test/app/edit/tools/edit.test.ts index 69bce87d29..24812af425 100644 --- a/packages/agent-core-v2/test/app/edit/tools/edit.test.ts +++ b/packages/agent-core-v2/test/app/edit/tools/edit.test.ts @@ -57,6 +57,7 @@ function createSpiedEditFs( options: { readText?: ReturnType; writeText?: ReturnType; + stat?: ReturnType; } = {}, ) { const readText = options.readText ?? vi.fn(async () => ''); @@ -71,9 +72,10 @@ function createSpiedEditFs( ino: 1, }; }; - const stat = vi.fn(async () => ({ isFile: true, isDirectory: false, size: 0 })); + const stat = + options.stat ?? vi.fn(async () => ({ isFile: true, isDirectory: false, size: 0 })); const fs = { readText, writeText: writeTextWithStat, stat } as unknown as IHostFileSystem; - return { fs, readText, writeText }; + return { fs, readText, writeText, stat }; } function buildTool( @@ -234,6 +236,40 @@ describe('EditTool', () => { }); }); + it('fails closed when the file revision changes between reading and committing the edit', async () => { + const originalStat = { + isFile: true, + isDirectory: false, + size: 10, + mtimeMs: 1000, + ino: 1, + }; + const changedStat = { ...originalStat, size: 11, mtimeMs: 1001 }; + const stat = vi + .fn() + .mockResolvedValueOnce(originalStat) + .mockResolvedValueOnce(originalStat) + .mockResolvedValueOnce(changedStat); + const writeText = vi.fn().mockResolvedValue(undefined); + const { fs } = createSpiedEditFs({ + readText: vi.fn().mockResolvedValue('alpha beta'), + writeText, + stat, + }); + const tool = buildTool(fs, createTestEnv(), PERMISSIVE_WORKSPACE); + + const result = await execute(tool, { + path: '/tmp/a.txt', + old_string: 'beta', + new_string: 'gamma', + }); + + expect(result.isError).toBe(true); + expect(result.output).toContain('changed on disk'); + expect(stat).toHaveBeenCalledTimes(3); + expect(writeText).not.toHaveBeenCalled(); + }); + it('expands leading tilde paths using the kaos home directory', async () => { const readText = vi.fn().mockResolvedValue('alpha beta'); const writeText = vi.fn().mockResolvedValue(undefined); diff --git a/packages/agent-core-v2/test/session/sessionFileLedger/fileLedger.test.ts b/packages/agent-core-v2/test/session/sessionFileLedger/fileLedger.test.ts index 9e01a642e1..50d880bae5 100644 --- a/packages/agent-core-v2/test/session/sessionFileLedger/fileLedger.test.ts +++ b/packages/agent-core-v2/test/session/sessionFileLedger/fileLedger.test.ts @@ -1,17 +1,16 @@ /** * `sessionFileLedger` domain (L2) — verifies the optimistic-concurrency - * verdict matrix (clean / stale / no-baseline) against a real tmpdir, a real - * `HostFileSystem` (stat-call counted) and a fake os watcher: baselines only - * refresh on success, every write decision performs a fresh stat, dirty ticks - * arrive before debounced event delivery, watcher echoes of the session's own - * writes re-baseline, and truncated windows retain conservative root ticks. + * verdict matrix (clean / stale / no-baseline) against a real tmpdir and a + * real `HostFileSystem` with stat calls counted. Baselines are compared only + * against fresh stat tuples, and resolving or using the ledger never starts a + * workspace watcher. */ import { mkdtempSync, rmSync, writeFileSync } from 'node:fs'; import { tmpdir } from 'node:os'; import { join } from 'node:path'; -import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { afterEach, describe, expect, it } from 'vitest'; import { LifecycleScope } from '#/_base/di/scope'; import { createScopedTestHost, stubPair, type ScopedTestHost } from '#/_base/di/test'; @@ -22,18 +21,10 @@ import { IHostFsWatchService } from '#/os/interface/hostFsWatch'; import { ISessionContext, makeSessionContext } from '#/session/sessionContext/sessionContext'; import { ISessionFileLedger } from '#/session/sessionFileLedger/fileLedger'; import { SessionFileLedger } from '#/session/sessionFileLedger/fileLedgerService'; -import { ISessionFsWatchService } from '#/session/sessionFs/fsWatch'; -import { SessionFsWatchService } from '#/session/sessionFs/fsWatchService'; -import { ISessionWorkspaceContext } from '#/session/workspaceContext/workspaceContext'; -import { SessionWorkspaceContextService } from '#/session/workspaceContext/workspaceContextService'; import { fakeHostFsWatch, type FakeWatch } from '../sessionFs/stubs'; void SessionFileLedger; -void SessionFsWatchService; -void SessionWorkspaceContextService; - -const JUNK_EVENT_COUNT = 501; function countingHostFs(poisonedPaths: Set): { fs: IHostFileSystem; @@ -65,8 +56,6 @@ interface World { readonly outsideDir: string; readonly fs: IHostFileSystem; readonly ledger: ISessionFileLedger; - readonly watch: ISessionFsWatchService; - readonly workspace: ISessionWorkspaceContext; readonly fake: FakeWatch; readonly statCalls: () => number; readonly poisonedPaths: Set; @@ -101,8 +90,6 @@ function makeWorld(): World { outsideDir, fs, ledger: session.accessor.get(ISessionFileLedger), - watch: session.accessor.get(ISessionFsWatchService), - workspace: session.accessor.get(ISessionWorkspaceContext), fake, statCalls, poisonedPaths, @@ -125,22 +112,13 @@ async function recordCurrentBaseline(world: World, path: string): Promise } } -function foldJunkEvents(world: World, count: number = JUNK_EVENT_COUNT): void { - for (let i = 0; i < count; i++) world.fake.fire(`junk-${i}.tmp`, 'created'); - vi.advanceTimersByTime(200); -} - const hosts: ScopedTestHost[] = []; const cleanupPaths: string[] = []; describe('SessionFileLedger', () => { - beforeEach(() => { - vi.useFakeTimers(); - }); afterEach(() => { for (const host of hosts.splice(0)) host.dispose(); for (const path of cleanupPaths.splice(0)) rmSync(path, { recursive: true, force: true }); - vi.useRealTimers(); }); it('returns clean for a baselined file with no changes', async () => { @@ -165,15 +143,15 @@ describe('SessionFileLedger', () => { expect(await world.ledger.compare(join(world.workDir, 'new.txt'))).toBe('clean'); }); - it('returns stale for a dirty path without a baseline', async () => { + it('does not let watcher signals change the stat-only verdict', async () => { const world = makeWorld(); const file = join(world.workDir, 'a.txt'); writeFileSync(file, 'hello'); world.fake.fire('a.txt', 'modified'); - vi.advanceTimersByTime(200); - expect(await world.ledger.compare(file)).toBe('stale'); + expect(await world.ledger.compare(file)).toBe('no-baseline'); + expect(world.fake.watchCalls).toEqual([]); }); it('returns stale when a baselined file is modified outside the session', async () => { @@ -183,34 +161,17 @@ describe('SessionFileLedger', () => { await recordCurrentBaseline(world, file); writeFileSync(file, 'hello world'); - world.fake.fire('a.txt', 'modified'); - vi.advanceTimersByTime(200); expect(await world.ledger.compare(file)).toBe('stale'); }); - it('detects an outside modification before the watch debounce flush', async () => { - const world = makeWorld(); - const file = join(world.workDir, 'a.txt'); - writeFileSync(file, 'hello'); - await recordCurrentBaseline(world, file); - - writeFileSync(file, 'hello world'); - world.fake.fire('a.txt', 'modified'); - - expect(await world.ledger.compare(file)).toBe('stale'); - }); - - it('absorbs the watcher echo of the session own write and re-baselines the tick', async () => { + it('performs a fresh stat for every comparison', async () => { const world = makeWorld(); const file = join(world.workDir, 'a.txt'); writeFileSync(file, 'hello'); await recordCurrentBaseline(world, file); expect(world.statCalls()).toBe(1); - world.fake.fire('a.txt', 'modified'); - vi.advanceTimersByTime(200); - expect(await world.ledger.compare(file)).toBe('clean'); expect(world.statCalls()).toBe(2); @@ -218,29 +179,13 @@ describe('SessionFileLedger', () => { expect(world.statCalls()).toBe(3); }); - it('keeps a baselined file clean through an untouched truncated window', async () => { - const world = makeWorld(); - const file = join(world.workDir, 'a.txt'); - writeFileSync(file, 'hello'); - await recordCurrentBaseline(world, file); - - foldJunkEvents(world); - expect(world.watch.rootDirtyTickFor(world.workDir)).toBeGreaterThan(0); - - expect(await world.ledger.compare(file)).toBe('clean'); - expect(world.statCalls()).toBe(2); - expect(await world.ledger.compare(file)).toBe('clean'); - expect(world.statCalls()).toBe(3); - }); - - it('detects an outside modification through a truncated window via the stat punch', async () => { + it('detects an outside modification using only the stat tuple', async () => { const world = makeWorld(); const file = join(world.workDir, 'a.txt'); writeFileSync(file, 'hello'); await recordCurrentBaseline(world, file); writeFileSync(file, 'hello world'); - foldJunkEvents(world); expect(await world.ledger.compare(file)).toBe('stale'); }); @@ -252,20 +197,16 @@ describe('SessionFileLedger', () => { await recordCurrentBaseline(world, file); rmSync(file); - world.fake.fire('a.txt', 'deleted'); - vi.advanceTimersByTime(200); expect(await world.ledger.compare(file)).toBe('stale'); await recordCurrentBaseline(world, file); expect(await world.ledger.compare(file)).toBe('clean'); writeFileSync(file, 'recreated'); - world.fake.fire('a.txt', 'created'); - vi.advanceTimersByTime(200); expect(await world.ledger.compare(file)).toBe('stale'); }); - it('falls back to the stat-only comparison outside every watched root', async () => { + it('uses the same stat-only comparison outside the workspace', async () => { const world = makeWorld(); const file = join(world.outsideDir, 'b.txt'); writeFileSync(file, 'hello'); @@ -285,23 +226,15 @@ describe('SessionFileLedger', () => { expect(await world.ledger.compare(file)).toBe('stale'); }); - it('adds a later additional dir as a watched root when a target falls under it', async () => { + it('never starts a watcher when recording or comparing paths', async () => { const world = makeWorld(); - expect(world.fake.watchCalls).toEqual([world.workDir]); - - world.workspace.addAdditionalDir(world.outsideDir); const file = join(world.outsideDir, 'b.txt'); writeFileSync(file, 'hello'); expect(await world.ledger.compare(file)).toBe('no-baseline'); - expect(world.fake.watchCalls).toContain(world.outsideDir); - expect(world.watch.watchedRoots).toContain(world.outsideDir); - - world.fake.handles - .find((h) => h.root === world.outsideDir) - ?.fire('b.txt', 'modified'); - vi.advanceTimersByTime(200); - expect(await world.ledger.compare(file)).toBe('stale'); + await recordCurrentBaseline(world, file); + expect(await world.ledger.compare(file)).toBe('clean'); + expect(world.fake.watchCalls).toEqual([]); }); it('fails closed when stat fails for reasons other than not-found', async () => { @@ -316,8 +249,6 @@ describe('SessionFileLedger', () => { world.poisonedPaths.clear(); await recordCurrentBaseline(world, file); world.poisonedPaths.add(file); - world.fake.fire('a.txt', 'modified'); - vi.advanceTimersByTime(200); expect(await world.ledger.compare(file)).toBe('stale'); expect(world.statCalls()).toBeGreaterThan(0); diff --git a/packages/agent-core-v2/test/session/sessionFs/fsWatchService.test.ts b/packages/agent-core-v2/test/session/sessionFs/fsWatchService.test.ts index 5af13a0f35..715d2d4a3b 100644 --- a/packages/agent-core-v2/test/session/sessionFs/fsWatchService.test.ts +++ b/packages/agent-core-v2/test/session/sessionFs/fsWatchService.test.ts @@ -15,7 +15,7 @@ import { IHostFsWatchService } from '#/os/interface/hostFsWatch'; import { ISessionWorkspaceContext } from '#/session/workspaceContext/workspaceContext'; import type { FsChangeEvent } from '#/session/sessionFs/fsWatch'; -import { ISessionFsWatchService, isFsWatchKeyWithin, normalizeFsWatchKey } from '#/session/sessionFs/fsWatch'; +import { ISessionFsWatchService, normalizeFsWatchKey } from '#/session/sessionFs/fsWatch'; import { SessionFsWatchService } from '#/session/sessionFs/fsWatchService'; import { fakeHostFsWatch, type FakeWatch } from './stubs'; @@ -246,96 +246,4 @@ describe('fsWatch key helpers', () => { const folded = process.platform === 'darwin' || process.platform === 'win32'; expect(normalizeFsWatchKey('/A//B/../C')).toBe(folded ? '/a/c' : '/A/C'); }); - - it('checks containment with a separator boundary', () => { - expect(isFsWatchKeyWithin('/a/b', '/a/b')).toBe(true); - expect(isFsWatchKeyWithin('/a/b/c', '/a/b')).toBe(true); - expect(isFsWatchKeyWithin('/a/bc', '/a/b')).toBe(false); - expect(isFsWatchKeyWithin('/a', '/a/b')).toBe(false); - }); -}); - -describe('SessionFsWatchService ensured roots and dirty ticks', () => { - beforeEach(() => { - vi.useFakeTimers(); - }); - afterEach(() => { - for (const d of disposers.splice(0)) d(); - vi.useRealTimers(); - }); - - it('starts the os watcher for an ensured root and reports it as a watched root', () => { - const { svc, watch } = makeSession(); - svc.ensureWatchedRoots([WORK_DIR]); - expect(watch.watchCalls).toEqual([WORK_DIR]); - expect(svc.watchedRoots).toEqual([WORK_DIR]); - expect(svc.watchedPaths).toEqual([]); - }); - - it('keeps the os watcher alive when client subscriptions empty but ensured roots remain', () => { - const { svc, watch } = makeSession(); - svc.ensureWatchedRoots([WORK_DIR]); - svc.setWatchedPaths(['src']); - svc.setWatchedPaths([]); - expect(watch.disposed()).toBe(false); - expect(svc.watchedRoots).toEqual([WORK_DIR]); - }); - - it('skips ensured roots already covered by an existing watched root', () => { - const { svc, watch } = makeSession(); - svc.ensureWatchedRoots([WORK_DIR]); - svc.ensureWatchedRoots([join(WORK_DIR, 'sub')]); - expect(watch.watchCalls).toEqual([WORK_DIR]); - }); - - it('watches an ensured root outside the workspace with a dedicated handle folded into dirty state only', () => { - const EXT = '/ext-root'; - const { svc, watch, events } = makeSession(); - svc.ensureWatchedRoots([EXT]); - expect(new Set(watch.watchCalls)).toEqual(new Set([WORK_DIR, EXT])); - const ext = watch.handles.find((h) => h.root === EXT); - expect(ext).toBeDefined(); - - ext!.fire('a.ts', 'modified'); - vi.advanceTimersByTime(200); - - expect(svc.dirtyTickFor(join(EXT, 'a.ts'))).toBe(1); - expect(events).toEqual([]); - }); - - it('increments and exposes per-path dirty ticks before the debounce flush', () => { - const { svc, watch } = makeSession(); - svc.ensureWatchedRoots([WORK_DIR]); - expect(svc.currentTick).toBe(0); - - const a = join(WORK_DIR, 'a.ts'); - watch.fire('a.ts', 'created'); - expect(svc.currentTick).toBe(1); - expect(svc.dirtyTickFor(a)).toBe(1); - vi.advanceTimersByTime(200); - expect(svc.dirtyTickFor(a)).toBe(1); - - watch.fire('b.ts', 'modified'); - vi.advanceTimersByTime(200); - expect(svc.dirtyTickFor(join(WORK_DIR, 'b.ts'))).toBe(2); - expect(svc.dirtyTickFor(a)).toBe(1); - }); - - it('marks every watched root dirty for a truncated window', () => { - const { svc, watch } = makeSession(); - svc.ensureWatchedRoots([WORK_DIR]); - for (let i = 0; i < 501; i++) watch.fire(`f${i}.ts`, 'created'); - vi.advanceTimersByTime(200); - - expect(svc.dirtyTickFor(join(WORK_DIR, 'f0.ts'))).toBe(1); - expect(svc.rootDirtyTickFor(WORK_DIR)).toBe(501); - }); - - it('folds buffered dirty signals when the window is cleared without flushing', () => { - const { svc, watch } = makeSession(); - svc.setWatchedPaths(['src']); - watch.fire('src/a.ts', 'modified'); - svc.setWatchedPaths([]); - expect(svc.dirtyTickFor(join(WORK_DIR, 'src/a.ts'))).toBe(1); - }); }); From c4697463f090f15ca72534e232fa754c94f45bf2 Mon Sep 17 00:00:00 2001 From: 7Sageer <7sageer@djwcb.cn> Date: Tue, 21 Jul 2026 19:47:27 +0800 Subject: [PATCH 15/22] fix: harden multi-server session transitions --- apps/kimi-web/src/api/daemon/serverAuth.ts | 16 +++++- .../src/composables/useKimiWebClient.ts | 7 ++- apps/kimi-web/test/server-auth.test.ts | 16 ++++++ packages/agent-core-v2/src/agent/task/task.ts | 1 + .../src/agent/task/taskService.ts | 6 +++ .../agentLifecycle/agentLifecycleService.ts | 11 ++-- .../test/agent/task/taskService.test.ts | 8 +++ .../test/agent/task/tools/task-tools.test.ts | 2 + .../os/backends/node-local/tools/bash.test.ts | 1 + .../agentLifecycle/agentLifecycle.test.ts | 50 ++++++++++++++++++- packages/kap-server/src/start.ts | 2 + .../src/transport/ws/v1/fsWatchBridge.ts | 36 ++++++++++++- .../src/transport/ws/v1/skillCatalogBridge.ts | 22 +++++++- packages/kap-server/test/fs-watch.e2e.test.ts | 44 ++++++++++++++++ .../test/skillCatalogBridge.test.ts | 37 +++++++++++++- 15 files changed, 245 insertions(+), 14 deletions(-) diff --git a/apps/kimi-web/src/api/daemon/serverAuth.ts b/apps/kimi-web/src/api/daemon/serverAuth.ts index 4aa04ed8b8..97431d860c 100644 --- a/apps/kimi-web/src/api/daemon/serverAuth.ts +++ b/apps/kimi-web/src/api/daemon/serverAuth.ts @@ -18,6 +18,7 @@ const STORAGE_KEY = 'kimi-web.server-credential'; const FRAGMENT_PARAM = 'token'; +const REDIRECT_HASH_PARAM = 'redirect_hash'; const CREDENTIAL_TTL_MS = 7 * 24 * 60 * 60 * 1000; interface StoredCredential { @@ -38,18 +39,29 @@ function readFragmentToken(): string | undefined { const params = new URLSearchParams(hash.slice(1)); const token = params.get(FRAGMENT_PARAM); if (!token) return undefined; + const redirectHash = params.get(REDIRECT_HASH_PARAM); // Scrub the fragment (keep path + query) so the token is not left in the // address bar, browser history, or any screenshot of the window. const url = new URL(window.location.href); - url.hash = ''; + url.hash = redirectHash ?? ''; window.history.replaceState( window.history.state, '', - `${url.pathname}${url.search}`, + `${url.pathname}${url.search}${url.hash}`, ); return token; } +export function withServerCredentialFragment(targetUrl: string, credential: string): string { + const url = new URL(targetUrl); + const redirectHash = url.hash.startsWith('#') ? url.hash.slice(1) : url.hash; + const params = new URLSearchParams(); + params.set(FRAGMENT_PARAM, credential); + if (redirectHash !== '') params.set(REDIRECT_HASH_PARAM, redirectHash); + url.hash = params.toString(); + return url.toString(); +} + function createStoredCredential(credential: string): StoredCredential { return { version: 1, diff --git a/apps/kimi-web/src/composables/useKimiWebClient.ts b/apps/kimi-web/src/composables/useKimiWebClient.ts index 8b869bed31..7555fc61da 100644 --- a/apps/kimi-web/src/composables/useKimiWebClient.ts +++ b/apps/kimi-web/src/composables/useKimiWebClient.ts @@ -14,6 +14,7 @@ import { type HeldByPeerDetails, type SessionOwnershipDetails, } from '../api/daemon/sessionOwnership'; +import { getCredential, withServerCredentialFragment } from '../api/daemon/serverAuth'; import { bumpRedirectBudget, decideSessionOwnershipAction, @@ -1460,7 +1461,11 @@ function handleSessionOwnership(details: SessionOwnershipDetails, ctx: { operati pushWarning(ownershipNotice('redirecting', { origin: action.origin })); persistPeerRedirectBudgetSafe(bumpRedirectBudget(readPeerRedirectBudgetSafe(Date.now()))); if (typeof window !== 'undefined') { - window.location.assign(action.url); + const credential = getCredential(); + const target = credential === undefined + ? action.url + : withServerCredentialFragment(action.url, credential); + window.location.assign(target); } return; } diff --git a/apps/kimi-web/test/server-auth.test.ts b/apps/kimi-web/test/server-auth.test.ts index 853366c83e..2be29f61c3 100644 --- a/apps/kimi-web/test/server-auth.test.ts +++ b/apps/kimi-web/test/server-auth.test.ts @@ -282,6 +282,22 @@ describe('fragment token intake', () => { expect(replaceState).toHaveBeenCalledWith(null, '', '/some/path?x=1'); }); + it('builds a credential redirect fragment and restores the original hash after intake', async () => { + const auth = await loadAuth(); + const target = auth.withServerCredentialFragment( + 'http://127.0.0.1:58628/sessions/sess_1?debug=1#top', + 'tok+/=', + ); + const { replaceState } = installWindow(new URL(target).hash); + + expect(target).toBe( + 'http://127.0.0.1:58628/sessions/sess_1?debug=1#token=tok%2B%2F%3D&redirect_hash=top', + ); + expect(auth.initServerAuth()).toBe(true); + expect(auth.getCredential()).toBe('tok+/='); + expect(replaceState).toHaveBeenCalledWith(null, '', '/some/path?x=1#top'); + }); + it('ignores an empty fragment and falls back to storage', async () => { writeStoredCredential('stored-tok'); installWindow(''); diff --git a/packages/agent-core-v2/src/agent/task/task.ts b/packages/agent-core-v2/src/agent/task/task.ts index 8e11832d99..c97502fc4a 100644 --- a/packages/agent-core-v2/src/agent/task/task.ts +++ b/packages/agent-core-v2/src/agent/task/task.ts @@ -83,6 +83,7 @@ export interface IAgentTaskService { registerTask(task: AgentTask, options?: RegisterAgentTaskOptions): string; getTask(taskId: string): AgentTaskInfo | undefined; list(activeOnly?: boolean, limit?: number): readonly AgentTaskInfo[]; + beginClose(): void; persistOutput(taskId: string): void; flushPersistence(): Promise; getOutputSnapshot( diff --git a/packages/agent-core-v2/src/agent/task/taskService.ts b/packages/agent-core-v2/src/agent/task/taskService.ts index af055b8937..dff5adedb3 100644 --- a/packages/agent-core-v2/src/agent/task/taskService.ts +++ b/packages/agent-core-v2/src/agent/task/taskService.ts @@ -220,6 +220,7 @@ export class AgentTaskService extends Disposable implements IAgentTaskService { private readonly deliveredNotificationKeys = new Set(); private readonly persistence: AgentTaskPersistence; private activeTaskReminderPending = false; + private _closing = false; constructor( @ITelemetryService private readonly telemetry: ITelemetryService, @@ -298,6 +299,10 @@ export class AgentTaskService extends Disposable implements IAgentTaskService { } } + beginClose(): void { + this._closing = true; + } + registerTask(task: AgentTask, options: RegisterAgentTaskOptions = {}): string { const detached = options.detached ?? true; const timeoutMs = options.timeoutMs ?? task.timeoutMs; @@ -864,6 +869,7 @@ export class AgentTaskService extends Disposable implements IAgentTaskService { } private assertCanRegister(detached: boolean): void { + if (this._closing) throw new Error('Agent task service is closing.'); const maxRunningTasks = resolveAgentTaskConfig(this.config)?.maxRunningTasks; if (maxRunningTasks === undefined) return; if (!detached) return; diff --git a/packages/agent-core-v2/src/session/agentLifecycle/agentLifecycleService.ts b/packages/agent-core-v2/src/session/agentLifecycle/agentLifecycleService.ts index 583adbaeac..1c55946e3b 100644 --- a/packages/agent-core-v2/src/session/agentLifecycle/agentLifecycleService.ts +++ b/packages/agent-core-v2/src/session/agentLifecycle/agentLifecycleService.ts @@ -7,9 +7,9 @@ * per-agent wire records and the wire state machine, the blob store, and MCP, * and registers the agent in the session registry. Binds the agent id into the * Agent-scoped telemetry view. New logs receive a metadata - * envelope while non-empty unversioned logs are rejected. Removal awaits the - * agent task manager's graceful exit policy before draining turns and full - * compaction, then disposing the child scope. Fans session-level + * envelope while non-empty unversioned logs are rejected. Removal coordinates + * task, turn, and full-compaction teardown before disposing the child scope. + * Fans session-level * permission-mode switches out to every live agent. Bound at Session scope. * * No agent id is special here: the main agent is simply the agent created @@ -339,8 +339,7 @@ export class AgentLifecycleService extends Disposable implements IAgentLifecycle if (handle === undefined) return; this.handles.delete(agentId); const tasks = handle.accessor.get(IAgentTaskService); - await tasks.stopAllOnExit('Session closed'); - await tasks.flushPersistence(); + tasks.beginClose(); const loop = handle.accessor.get(IAgentLoopService); const compaction = handle.accessor.get(IAgentFullCompactionService).compacting; const compactionSettled = compaction?.promise.catch(() => undefined) ?? Promise.resolve(); @@ -353,6 +352,8 @@ export class AgentLifecycleService extends Disposable implements IAgentLifecycle compaction.abortController.abort(reason); } await Promise.all([loop.settled(), compactionSettled]); + await tasks.stopAllOnExit('Session closed'); + await tasks.flushPersistence(); handle.dispose(); this.onDidDisposeEmitter.fire(agentId); } diff --git a/packages/agent-core-v2/test/agent/task/taskService.test.ts b/packages/agent-core-v2/test/agent/task/taskService.test.ts index 7c58d4adb6..b90b0d1eba 100644 --- a/packages/agent-core-v2/test/agent/task/taskService.test.ts +++ b/packages/agent-core-v2/test/agent/task/taskService.test.ts @@ -168,6 +168,14 @@ describe('AgentTaskService', () => { await svc.stop(id); }); + it('registerTask rejects new work after close begins', () => { + const svc = ix.get(IAgentTaskService); + + svc.beginClose(); + + expect(() => svc.registerTask(fakeProcessTask())).toThrow('Agent task service is closing.'); + }); + function stubTaskConfig(value: unknown): void { ix.stub(IConfigService, { get: ((domain: string) => (domain === 'task' ? value : undefined)) as IConfigService['get'], diff --git a/packages/agent-core-v2/test/agent/task/tools/task-tools.test.ts b/packages/agent-core-v2/test/agent/task/tools/task-tools.test.ts index c6f2ec5808..ff2e63ece6 100644 --- a/packages/agent-core-v2/test/agent/task/tools/task-tools.test.ts +++ b/packages/agent-core-v2/test/agent/task/tools/task-tools.test.ts @@ -172,6 +172,8 @@ class FakeTaskService implements IAgentTaskService { persistOutput(_taskId: string): void {} + beginClose(): void {} + async flushPersistence(): Promise {} async getOutputSnapshot( diff --git a/packages/agent-core-v2/test/os/backends/node-local/tools/bash.test.ts b/packages/agent-core-v2/test/os/backends/node-local/tools/bash.test.ts index d12f0c049f..5b6b5b9154 100644 --- a/packages/agent-core-v2/test/os/backends/node-local/tools/bash.test.ts +++ b/packages/agent-core-v2/test/os/backends/node-local/tools/bash.test.ts @@ -474,6 +474,7 @@ function createFakeTaskService(options: { maxRunningTasks?: number } = {}): { const service: IAgentTaskService = { _serviceBrand: undefined, + beginClose: () => {}, flushPersistence: async () => {}, track(): never { throw new Error('fake IAgentTaskService.track is not implemented'); diff --git a/packages/agent-core-v2/test/session/agentLifecycle/agentLifecycle.test.ts b/packages/agent-core-v2/test/session/agentLifecycle/agentLifecycle.test.ts index ae4f641ef9..0cd36643f2 100644 --- a/packages/agent-core-v2/test/session/agentLifecycle/agentLifecycle.test.ts +++ b/packages/agent-core-v2/test/session/agentLifecycle/agentLifecycle.test.ts @@ -150,7 +150,9 @@ describe('AgentLifecycleService', () => { let registerAgent: ReturnType>; let atomicDocs: Map; let permissionModeSetMode: ReturnType; + let beginTaskClose: ReturnType; let stopAllOnExit: ReturnType; + let flushPersistence: ReturnType; let loopActiveTurnId: number | undefined; let loopPendingTurnIds: number[]; let loopCancel: ReturnType>; @@ -344,11 +346,14 @@ describe('AgentLifecycleService', () => { onDidChangeMode: Event.None, } as unknown as IAgentPermissionModeService); ix.set(ISessionMcpService, new SyncDescriptor(SessionMcpService)); + beginTaskClose = vi.fn(); stopAllOnExit = vi.fn(async () => []); + flushPersistence = vi.fn(async () => {}); ix.stub(IAgentTaskService, { _serviceBrand: undefined, + beginClose: beginTaskClose, stopAllOnExit, - flushPersistence: async () => {}, + flushPersistence, } as unknown as IAgentTaskService); ix.stub(IAgentFullCompactionService, { _serviceBrand: undefined, @@ -392,6 +397,49 @@ describe('AgentLifecycleService', () => { expect(loopSettled).toHaveBeenCalledOnce(); }); + it('remove closes task admission before cancelling the active turn', async () => { + loopActiveTurnId = 1; + let admissionWasOpenAtCancellation = false; + beginTaskClose.mockImplementation(() => {}); + loopCancel.mockImplementation((turnId) => { + if (turnId === undefined) { + admissionWasOpenAtCancellation = beginTaskClose.mock.calls.length === 0; + loopActiveTurnId = undefined; + } + return true; + }); + const svc = ix.get(IAgentLifecycleService); + await svc.create({ agentId: 'main' }); + + await svc.remove('main'); + + expect(admissionWasOpenAtCancellation).toBe(false); + }); + + it('remove performs the final task drain after the active turn settles', async () => { + loopActiveTurnId = 1; + let producerSettled = false; + let stoppedBeforeProducerSettled = false; + let flushedBeforeProducerSettled = false; + loopSettled.mockImplementation(async () => { + producerSettled = true; + }); + stopAllOnExit.mockImplementation(async () => { + stoppedBeforeProducerSettled = !producerSettled; + return []; + }); + flushPersistence.mockImplementation(async () => { + flushedBeforeProducerSettled = !producerSettled; + }); + const svc = ix.get(IAgentLifecycleService); + await svc.create({ agentId: 'main' }); + + await svc.remove('main'); + + expect(stoppedBeforeProducerSettled).toBe(false); + expect(flushedBeforeProducerSettled).toBe(false); + }); + it('remove waits for an active full compaction to reject after aborting it', async () => { const abortController = new AbortController(); let rejectCompaction!: (reason: unknown) => void; diff --git a/packages/kap-server/src/start.ts b/packages/kap-server/src/start.ts index d2e203eeef..be2d5e56f6 100644 --- a/packages/kap-server/src/start.ts +++ b/packages/kap-server/src/start.ts @@ -526,6 +526,8 @@ export async function startServer(opts: ServerStartOptions = {}): Promise { connectionRegistry.closeAll('server shutting down'); wssV1.close(); + fsWatchBridge.dispose(); + skillCatalogBridge.dispose(); await broadcaster.close(); }); diff --git a/packages/kap-server/src/transport/ws/v1/fsWatchBridge.ts b/packages/kap-server/src/transport/ws/v1/fsWatchBridge.ts index 44fd0e5077..ba06e1c49e 100644 --- a/packages/kap-server/src/transport/ws/v1/fsWatchBridge.ts +++ b/packages/kap-server/src/transport/ws/v1/fsWatchBridge.ts @@ -91,15 +91,31 @@ interface SessionWatch { sub: IDisposable | undefined; } -export class FsWatchBridge { +export class FsWatchBridge implements IDisposable { private readonly core: Scope; private readonly logger: JournalLogger | undefined; + private readonly sessionReleaseHook: IDisposable; private readonly bySession = new Map(); private readonly connPathCount = new Map(); constructor(opts: { core: Scope; logger?: JournalLogger }) { this.core = opts.core; this.logger = opts.logger; + this.sessionReleaseHook = this.core.accessor + .get(ISessionLifecycleService) + .hooks.onWillReleaseSession.register( + 'fsWatchBridge', + async ({ sessionId }, next) => { + this.releaseSession(sessionId); + await next(); + }, + ); + } + + dispose(): void { + this.sessionReleaseHook.dispose(); + for (const sw of Array.from(this.bySession.values())) this.dropSession(sw, 'teardown'); + this.connPathCount.clear(); } async addWatch( @@ -212,9 +228,25 @@ export class FsWatchBridge { } private teardownSession(sw: SessionWatch): void { + this.dropSession(sw, 'teardown'); + } + + private releaseSession(sessionId: string): void { + const sw = this.bySession.get(sessionId); + if (sw === undefined) return; + this.dropSession(sw, 'scope-released'); + } + + private dropSession(sw: SessionWatch, reason: 'teardown' | 'scope-released'): void { sw.sub?.dispose(); sw.sub = undefined; - sw.fsWatch.setWatchedPaths([]); + for (const entry of sw.conns.values()) { + const remaining = Math.max(0, this.countFor(entry.conn.id) - entry.paths.size); + if (remaining === 0) this.connPathCount.delete(entry.conn.id); + else this.connPathCount.set(entry.conn.id, remaining); + } + sw.conns.clear(); + if (reason === 'teardown') sw.fsWatch.setWatchedPaths([]); this.bySession.delete(sw.id); } diff --git a/packages/kap-server/src/transport/ws/v1/skillCatalogBridge.ts b/packages/kap-server/src/transport/ws/v1/skillCatalogBridge.ts index af7515e77b..3439090691 100644 --- a/packages/kap-server/src/transport/ws/v1/skillCatalogBridge.ts +++ b/packages/kap-server/src/transport/ws/v1/skillCatalogBridge.ts @@ -59,14 +59,29 @@ interface SessionWatch { sub: IDisposable | undefined; } -export class SkillCatalogBridge { +export class SkillCatalogBridge implements IDisposable { private readonly core: Scope; private readonly logger: JournalLogger | undefined; + private readonly sessionReleaseHook: IDisposable; private readonly bySession = new Map(); constructor(opts: { core: Scope; logger?: JournalLogger }) { this.core = opts.core; this.logger = opts.logger; + this.sessionReleaseHook = this.core.accessor + .get(ISessionLifecycleService) + .hooks.onWillReleaseSession.register( + 'skillCatalogBridge', + async ({ sessionId }, next) => { + this.releaseSession(sessionId); + await next(); + }, + ); + } + + dispose(): void { + this.sessionReleaseHook.dispose(); + for (const sw of Array.from(this.bySession.values())) this.teardownSession(sw); } /** Start delivering the session's catalog hints to `conn` (session subscribe). */ @@ -114,6 +129,11 @@ export class SkillCatalogBridge { this.bySession.delete(sw.id); } + private releaseSession(sessionId: string): void { + const sw = this.bySession.get(sessionId); + if (sw !== undefined) this.teardownSession(sw); + } + private onSessionEvent(sessionId: string, sourceId: string): void { const sw = this.bySession.get(sessionId); if (sw === undefined) return; diff --git a/packages/kap-server/test/fs-watch.e2e.test.ts b/packages/kap-server/test/fs-watch.e2e.test.ts index e82785daa6..45d03a462f 100644 --- a/packages/kap-server/test/fs-watch.e2e.test.ts +++ b/packages/kap-server/test/fs-watch.e2e.test.ts @@ -88,6 +88,18 @@ async function createSession(r: RunningServer): Promise { return env.data.id; } +async function runSessionAction( + r: RunningServer, + sessionId: string, + action: 'archive' | 'restore', +): Promise { + const res = await fetch(`${addressOf(r)}/api/v1/sessions/${sessionId}:${action}`, { + method: 'POST', + }); + const env = (await res.json()) as { code: number }; + if (env.code !== 0) throw new Error(`${action} session failed: ${JSON.stringify(env)}`); +} + interface WsFrame { type: string; payload?: Record; @@ -494,6 +506,38 @@ describe('WS fs watch (kap-server)', () => { conn.ws.close(); }); + it('rebinds file watches after an archived session is restored on the same connection', async () => { + const r = await boot(); + const sid = await createSession(r); + const conn = await openConn(wsUrl(r)); + await helloAndSubscribe(conn, 'A', sid); + + conn.ws.send( + JSON.stringify({ type: 'watch_fs_add', id: 'before', payload: { session_id: sid, paths: ['src'] } }), + ); + await receiveType(conn, 'ack', 1000); + await runSessionAction(r, sid, 'archive'); + await runSessionAction(r, sid, 'restore'); + + conn.ws.send( + JSON.stringify({ type: 'watch_fs_add', id: 'after', payload: { session_id: sid, paths: ['src'] } }), + ); + const ack = await receiveType(conn, 'ack', 1000); + expect(ack.code).toBe(0); + expect(ack.payload).toMatchObject({ watched_paths: ['src'], current_count: 1 }); + + await sleep(WATCH_SETTLE_MS); + writeFileSync(join(workspace, 'src', 'restored.ts'), 'export const restored = true;\n'); + + const ev = await receiveType(conn, 'event.fs.changed', 2000); + expect(ev.session_id).toBe(sid); + expect((ev.payload as { changes: Array<{ path: string }> }).changes).toEqual( + expect.arrayContaining([expect.objectContaining({ path: 'src/restored.ts' })]), + ); + + conn.ws.close(); + }); + it('watch_fs_add for `..` path → 41304 fs.path_escapes_session', async () => { const r = await boot(); const sid = await createSession(r); diff --git a/packages/kap-server/test/skillCatalogBridge.test.ts b/packages/kap-server/test/skillCatalogBridge.test.ts index 3b3c52f8c4..3b01b4bd00 100644 --- a/packages/kap-server/test/skillCatalogBridge.test.ts +++ b/packages/kap-server/test/skillCatalogBridge.test.ts @@ -17,8 +17,10 @@ import { ISessionSkillCatalog, ISessionLifecycleService, type ISessionScopeHandle, + type SessionLifecycleHooks, type Scope, } from '@moonshot-ai/agent-core-v2'; +import { createHooks } from '@moonshot-ai/agent-core-v2/hooks'; import { describe, expect, it, vi } from 'vitest'; import { @@ -65,8 +67,19 @@ function makeCatalog(): FakeCatalog { }; } -function makeCore(sessions: Map): Scope { - const lifecycle = { get: (sid: string) => sessions.get(sid) }; +function createSessionLifecycleHooks() { + return createHooks([ + 'onDidCreateSession', + 'onWillCloseSession', + 'onWillReleaseSession', + ]); +} + +function makeCore( + sessions: Map, + hooks = createSessionLifecycleHooks(), +): Scope { + const lifecycle = { get: (sid: string) => sessions.get(sid), hooks }; return { accessor: { get: (decorator: unknown) => { @@ -193,4 +206,24 @@ describe('SkillCatalogBridge', () => { expect(a.frames).toHaveLength(1); expect(a.frames[0]).toMatchObject({ seq: 1, payload: { sourceId: 'plugin' } }); }); + + it('rebinds to the restored session catalog after the previous scope is released', async () => { + const first = makeCatalog(); + const restored = makeCatalog(); + const sessions = new Map([['sess_1', makeSession(first.service)]]); + const hooks = createSessionLifecycleHooks(); + const bridge = new SkillCatalogBridge({ core: makeCore(sessions, hooks) }); + const a = makeConn('conn_a'); + bridge.attachSession(a.conn, 'sess_1'); + + await hooks.onWillReleaseSession.run({ sessionId: 'sess_1', reason: 'archive' }); + sessions.set('sess_1', makeSession(restored.service)); + bridge.attachSession(a.conn, 'sess_1'); + restored.fire('workspace-file'); + + expect(first.dispose).toHaveBeenCalledOnce(); + expect(restored.onDidChange).toHaveBeenCalledOnce(); + expect(a.frames).toHaveLength(1); + expect(a.frames[0]).toMatchObject({ seq: 1, payload: { sourceId: 'workspace-file' } }); + }); }); From 0b4707ec22091a60b8df33132b7488e11c2af275 Mon Sep 17 00:00:00 2001 From: 7Sageer <7sageer@djwcb.cn> Date: Tue, 21 Jul 2026 20:09:53 +0800 Subject: [PATCH 16/22] refactor: remove obsolete watch and lock state --- .../src/api/daemon/sessionOwnership.ts | 5 +- apps/kimi-web/src/i18n/locales/en/warnings.ts | 1 - apps/kimi-web/src/i18n/locales/zh/warnings.ts | 1 - .../src/lib/sessionOwnershipDecision.ts | 4 - apps/kimi-web/test/session-ownership.test.ts | 5 +- .../src/app/mcp/mcpConfigWatch.ts | 24 ---- .../src/app/mcp/mcpConfigWatchService.ts | 74 ----------- .../agent-core-v2/src/app/telemetry/events.ts | 24 +--- packages/agent-core-v2/src/index.ts | 2 - .../src/session/sessionLease/sessionLease.ts | 1 - .../test/app/mcp/mcpConfigWatch.test.ts | 116 ------------------ .../src/transport/ws/v1/fsWatchBridge.ts | 11 +- packages/minidb/src/index.ts | 11 +- packages/minidb/src/lockfile.ts | 9 +- .../src/__tests__/session-ownership.test.ts | 4 +- packages/protocol/src/session-ownership.ts | 8 +- 16 files changed, 10 insertions(+), 290 deletions(-) delete mode 100644 packages/agent-core-v2/src/app/mcp/mcpConfigWatch.ts delete mode 100644 packages/agent-core-v2/src/app/mcp/mcpConfigWatchService.ts delete mode 100644 packages/agent-core-v2/test/app/mcp/mcpConfigWatch.test.ts diff --git a/apps/kimi-web/src/api/daemon/sessionOwnership.ts b/apps/kimi-web/src/api/daemon/sessionOwnership.ts index b99b253a8a..0990b4f1aa 100644 --- a/apps/kimi-web/src/api/daemon/sessionOwnership.ts +++ b/apps/kimi-web/src/api/daemon/sessionOwnership.ts @@ -9,7 +9,6 @@ // Wire semantics (from the protocol schema): // - creating lease file observed mid-creation; retry shortly // - routable holder is live and registered an address; redirect -// - holder-unresponsive legacy heartbeat-based server response; retry later // - held-by-local-instance holder has no address (local/embedded); terminal // - unregistered-writer session dir written by an unregistered process @@ -21,7 +20,6 @@ export const SESSION_HELD_BY_PEER_CODE = 40921; export type SessionOwnershipPhase = | 'creating' | 'routable' - | 'holder-unresponsive' | 'held-by-local-instance'; export interface HeldByPeerDetails { @@ -29,7 +27,7 @@ export interface HeldByPeerDetails { phase: SessionOwnershipPhase; /** Present only when phase === 'routable'. */ address?: string; - /** Retry hint (ms) for 'creating' / 'holder-unresponsive'. */ + /** Retry hint (ms) for 'creating'. */ retry_after_ms?: number; } @@ -42,7 +40,6 @@ export type SessionOwnershipDetails = HeldByPeerDetails | UnregisteredWriterDeta const PHASES: ReadonlySet = new Set([ 'creating', 'routable', - 'holder-unresponsive', 'held-by-local-instance', ]); diff --git a/apps/kimi-web/src/i18n/locales/en/warnings.ts b/apps/kimi-web/src/i18n/locales/en/warnings.ts index 487fcabae2..fece120996 100644 --- a/apps/kimi-web/src/i18n/locales/en/warnings.ts +++ b/apps/kimi-web/src/i18n/locales/en/warnings.ts @@ -58,7 +58,6 @@ export default { redirecting: 'This session is held by another Kimi instance — redirecting to {origin}…', creating: 'This session is being created on another instance. Retrying shortly…', creatingTimeout: 'This session is still being created on another instance. Try again in a moment.', - holderUnresponsive: 'The instance holding this session is not responding (it may be stuck). Try again later, or continue on that instance.', heldByLocalInstance: 'This session is held by another local instance without a network address (e.g. a CLI/TUI). Continue there, or close it first.', unregisteredWriter: 'This session’s directory is being written by an unregistered external process. Refused to open it to protect your data.', redirectSameHost: 'This session is already held by the current instance. Reload the page and try again.', diff --git a/apps/kimi-web/src/i18n/locales/zh/warnings.ts b/apps/kimi-web/src/i18n/locales/zh/warnings.ts index 7b8acb0f94..220b062344 100644 --- a/apps/kimi-web/src/i18n/locales/zh/warnings.ts +++ b/apps/kimi-web/src/i18n/locales/zh/warnings.ts @@ -58,7 +58,6 @@ export default { redirecting: '该会话正由另一个 Kimi 实例持有,正在跳转到 {origin}…', creating: '会话正在另一个实例上创建,稍后自动重试…', creatingTimeout: '会话仍在另一个实例上创建中,请稍后再试。', - holderUnresponsive: '持有该会话的实例无响应(可能已卡死)。请稍后重试,或到该实例上处理。', heldByLocalInstance: '会话被本机另一个无网络地址的实例(如 CLI/TUI)持有。请到该实例继续,或先关闭它。', unregisteredWriter: '会话目录正被未注册的外部进程写入,已拒绝打开以保护数据。', redirectSameHost: '该会话的持有者就是当前实例。请刷新页面后重试。', diff --git a/apps/kimi-web/src/lib/sessionOwnershipDecision.ts b/apps/kimi-web/src/lib/sessionOwnershipDecision.ts index 6f21ca8672..2633ae5d71 100644 --- a/apps/kimi-web/src/lib/sessionOwnershipDecision.ts +++ b/apps/kimi-web/src/lib/sessionOwnershipDecision.ts @@ -10,7 +10,6 @@ import type { SessionOwnershipDetails } from '../api/daemon/sessionOwnership'; /** Notice keys under `warnings.ownership.*` for terminal (non-action) outcomes. */ export type OwnershipNotifyKey = | 'creatingTimeout' - | 'holderUnresponsive' | 'heldByLocalInstance' | 'unregisteredWriter' | 'redirectSameHost' @@ -105,9 +104,6 @@ export function decideSessionOwnershipAction( return { type: 'redirect', url: buildPeerTargetUrl(origin, ctx.currentPath), origin }; } - case 'holder-unresponsive': - return { type: 'notify', key: 'holderUnresponsive' }; - case 'held-by-local-instance': return { type: 'notify', key: 'heldByLocalInstance' }; } diff --git a/apps/kimi-web/test/session-ownership.test.ts b/apps/kimi-web/test/session-ownership.test.ts index c2cd008808..6bbbf58b5a 100644 --- a/apps/kimi-web/test/session-ownership.test.ts +++ b/apps/kimi-web/test/session-ownership.test.ts @@ -219,10 +219,7 @@ describe('decideSessionOwnershipAction', () => { ).toEqual({ type: 'notify', key: 'creatingTimeout' }); }); - it('holder-unresponsive and held-by-local-instance → terminal notices', () => { - expect( - decideSessionOwnershipAction({ kind: 'held-by-peer', phase: 'holder-unresponsive' }, makeCtx()), - ).toEqual({ type: 'notify', key: 'holderUnresponsive' }); + it('held-by-local-instance → terminal notice', () => { expect( decideSessionOwnershipAction({ kind: 'held-by-peer', phase: 'held-by-local-instance' }, makeCtx()), ).toEqual({ type: 'notify', key: 'heldByLocalInstance' }); diff --git a/packages/agent-core-v2/src/app/mcp/mcpConfigWatch.ts b/packages/agent-core-v2/src/app/mcp/mcpConfigWatch.ts deleted file mode 100644 index 6ffff7b761..0000000000 --- a/packages/agent-core-v2/src/app/mcp/mcpConfigWatch.ts +++ /dev/null @@ -1,24 +0,0 @@ -/** - * `mcp` domain (L5) — App-scope watch contract for the user-level mcp.json. - * - * Defines `IMcpConfigWatchService`: a typed `onDidChange` event that fires - * when the user-global `/mcp.json` changes on disk and its new - * content parses (blank content counts as valid, matching the loader). Only - * the user level is watched — project-root `.mcp.json` and project-local - * `.kimi-code/mcp.json` are per-workspace paths and stay unwatched this - * round. v2 has no mcp.json writer (edits are out-of-band direct writes) and - * no server-config cache yet, so the event exists for future hot-aware - * consumers; Session-scoped services may subscribe to this App-scope event, - * never the reverse. Bound at App scope. - */ - -import type { Event } from '#/_base/event'; -import { createDecorator, type ServiceIdentifier } from '#/_base/di/instantiation'; - -export interface IMcpConfigWatchService { - readonly _serviceBrand: undefined; - readonly onDidChange: Event; -} - -export const IMcpConfigWatchService: ServiceIdentifier = - createDecorator('mcpConfigWatchService'); diff --git a/packages/agent-core-v2/src/app/mcp/mcpConfigWatchService.ts b/packages/agent-core-v2/src/app/mcp/mcpConfigWatchService.ts deleted file mode 100644 index 2dd08f3261..0000000000 --- a/packages/agent-core-v2/src/app/mcp/mcpConfigWatchService.ts +++ /dev/null @@ -1,74 +0,0 @@ -/** - * `mcp` domain (L5) — `IMcpConfigWatchService` implementation. - * - * Watches the user-level mcp.json through the `storage` byte layer - * (`watch('', 'mcp.json')` — the same exact-key, debounced shape as the config - * watch, which filters out lock/tmp siblings in the same directory), then - * JSON-parse-probes the new content before emitting: unparseable content (a - * half-finished direct edit that slipped past the atomic-write and exact-key - * filters) logs a warning through `log` and suppresses the event; a missing - * or blank file counts as valid (the loader treats it as an empty config). - * Bound at App scope. - */ - -import { InstantiationType } from '#/_base/di/extensions'; -import { Disposable } from '#/_base/di/lifecycle'; -import { LifecycleScope, registerScopedService } from '#/_base/di/scope'; -import { Emitter, Event } from '#/_base/event'; -import { ILogService } from '#/_base/log/log'; -import { IFileSystemStorageService } from '#/persistence/interface/storage'; - -import { IMcpConfigWatchService } from './mcpConfigWatch'; - -const MCP_CONFIG_SCOPE = ''; -const MCP_CONFIG_KEY = 'mcp.json'; - -const textDecoder = new TextDecoder(); - -export class McpConfigWatchService extends Disposable implements IMcpConfigWatchService { - declare readonly _serviceBrand: undefined; - - private readonly _onDidChange = this._register(new Emitter()); - readonly onDidChange: Event = this._onDidChange.event; - - constructor( - @IFileSystemStorageService private readonly storage: IFileSystemStorageService, - @ILogService private readonly log: ILogService, - ) { - super(); - const change = this.storage.watch?.(MCP_CONFIG_SCOPE, MCP_CONFIG_KEY) ?? Event.None; - this._register(change(() => void this.probe())); - } - - private async probe(): Promise { - let text = ''; - try { - const bytes = await this.storage.read(MCP_CONFIG_SCOPE, MCP_CONFIG_KEY); - text = bytes === undefined ? '' : textDecoder.decode(bytes); - } catch (error) { - this.log.warn('mcp.json change probe failed to read the file', { - error: error instanceof Error ? error.message : String(error), - }); - return; - } - if (text.trim().length > 0) { - try { - JSON.parse(text); - } catch (error) { - this.log.warn('ignoring mcp.json change: invalid JSON', { - error: error instanceof Error ? error.message : String(error), - }); - return; - } - } - this._onDidChange.fire(); - } -} - -registerScopedService( - LifecycleScope.App, - IMcpConfigWatchService, - McpConfigWatchService, - InstantiationType.Eager, - 'mcp', -); diff --git a/packages/agent-core-v2/src/app/telemetry/events.ts b/packages/agent-core-v2/src/app/telemetry/events.ts index 5e52be5bc2..b94aa142d9 100644 --- a/packages/agent-core-v2/src/app/telemetry/events.ts +++ b/packages/agent-core-v2/src/app/telemetry/events.ts @@ -439,18 +439,9 @@ export interface SessionLeaseAcquiredEvent { session_id: string; } -export interface SessionLeaseTakeoverEvent { - session_id: string; - previous: string; -} - export interface SessionHeldByPeerReturnedEvent { session_id: string; - phase: 'creating' | 'routable' | 'holder-unresponsive' | 'held-by-local-instance'; -} - -export interface SessionLeaseHolderUnresponsiveEvent { - session_id: string; + phase: 'creating' | 'routable' | 'held-by-local-instance'; } export interface SessionDirtyAbortEvent { @@ -952,14 +943,6 @@ export const telemetryEventDefinitions = { comment: "This instance takes a session's write lease.", properties: { session_id: 'Session the lease covers' }, }), - session_lease_takeover: defineTelemetryEvent({ - owner: 'kimi-code', - comment: 'Legacy stale-lease takeover event retained for telemetry schema stability.', - properties: { - session_id: 'Session the lease covers', - previous: 'Stale reason observed before takeover (holder-dead, pid-reused, …)', - }, - }), session_held_by_peer_returned: defineTelemetryEvent({ owner: 'kimi-code', comment: 'A session materialization is refused because a peer instance holds the lease.', @@ -968,11 +951,6 @@ export const telemetryEventDefinitions = { phase: 'Ownership phase reported to the client (routable, creating, …)', }, }), - session_lease_holder_unresponsive: defineTelemetryEvent({ - owner: 'kimi-code', - comment: 'Legacy heartbeat-liveness event retained for telemetry schema stability.', - properties: { session_id: 'Session whose holder is unresponsive' }, - }), session_dirty_abort: defineTelemetryEvent({ owner: 'kimi-code', comment: 'A session is force-aborted after its final durability barrier became ambiguous.', diff --git a/packages/agent-core-v2/src/index.ts b/packages/agent-core-v2/src/index.ts index ba44cf8fe2..8ec9b37909 100644 --- a/packages/agent-core-v2/src/index.ts +++ b/packages/agent-core-v2/src/index.ts @@ -432,8 +432,6 @@ export * from '#/agent/loop/loop'; export * from '#/agent/loop/loopService'; export * from '#/agent/loop/loopContinuation'; export * from '#/agent/loop/loopContinuationService'; -export * from '#/app/mcp/mcpConfigWatch'; -import '#/app/mcp/mcpConfigWatchService'; export * from '#/agent/mcp/mcp'; export * from '#/agent/mcp/mcpService'; export * from '#/agent/mcp/mcpDiscoveryOps'; diff --git a/packages/agent-core-v2/src/session/sessionLease/sessionLease.ts b/packages/agent-core-v2/src/session/sessionLease/sessionLease.ts index 4e5bcb3676..8c0a653975 100644 --- a/packages/agent-core-v2/src/session/sessionLease/sessionLease.ts +++ b/packages/agent-core-v2/src/session/sessionLease/sessionLease.ts @@ -40,7 +40,6 @@ export const LEASE_CREATING_RETRY_AFTER_MS = 1000; export type SessionOwnershipPhase = | 'creating' | 'routable' - | 'holder-unresponsive' | 'held-by-local-instance'; export type HeldByPeerDetails = { diff --git a/packages/agent-core-v2/test/app/mcp/mcpConfigWatch.test.ts b/packages/agent-core-v2/test/app/mcp/mcpConfigWatch.test.ts deleted file mode 100644 index 98acef57c2..0000000000 --- a/packages/agent-core-v2/test/app/mcp/mcpConfigWatch.test.ts +++ /dev/null @@ -1,116 +0,0 @@ -/** - * Scenario: App-scope watch of the user-level mcp.json. - * - * Valid content fires `onDidChange` (and a fresh `loadMcpServers` observes - * the new servers — v2 loads mcp.json per session creation and has no cache - * to invalidate); unparseable content logs a warning and suppresses the - * event; blank content counts as valid, matching the loader's tolerance. The - * watch rides real chokidar with a 150ms debounce, so assertions poll with - * real timers. Run with `pnpm --filter @moonshot-ai/agent-core-v2 exec - * vitest run test/app/mcp/mcpConfigWatch.test.ts`. - */ - -import { mkdtemp, rm, writeFile } from 'node:fs/promises'; -import { tmpdir } from 'node:os'; -import { join } from 'node:path'; - -import { afterEach, beforeEach, describe, expect, it } from 'vitest'; - -import { SyncDescriptor } from '#/_base/di/descriptors'; -import { DisposableStore } from '#/_base/di/lifecycle'; -import { TestInstantiationService } from '#/_base/di/test'; -import { ILogService } from '#/_base/log/log'; -import { loadMcpServers } from '#/agent/mcp/config-loader'; -import { IMcpConfigWatchService } from '#/app/mcp/mcpConfigWatch'; -import { McpConfigWatchService } from '#/app/mcp/mcpConfigWatchService'; -import { FileStorageService } from '#/persistence/backends/node-fs/fileStorageService'; -import { IFileSystemStorageService } from '#/persistence/interface/storage'; - -import { stubLog } from '../../_base/log/stubs'; - -const realSleep = (ms: number): Promise => - new Promise((resolvePromise) => setTimeout(resolvePromise, ms)); - -async function waitFor(cond: () => boolean, timeoutMs = 5_000): Promise { - const start = Date.now(); - for (;;) { - if (cond()) return; - if (Date.now() - start > timeoutMs) throw new Error('waitFor timed out'); - await realSleep(25); - } -} - -describe('McpConfigWatchService', () => { - let homeDir: string; - let disposables: DisposableStore; - let warnings: string[]; - - beforeEach(async () => { - homeDir = await mkdtemp(join(tmpdir(), 'mcp-watch-')); - disposables = new DisposableStore(); - warnings = []; - }); - - afterEach(async () => { - disposables.dispose(); - await rm(homeDir, { recursive: true, force: true }); - }); - - function createWatcher(): { events: number } { - const state = { events: 0 }; - const log = { - ...stubLog(), - warn: (message: string) => { - warnings.push(message); - }, - }; - const ix = disposables.add(new TestInstantiationService()); - ix.stub(ILogService, log); - ix.stub(IFileSystemStorageService, new FileStorageService(homeDir)); - ix.set(IMcpConfigWatchService, new SyncDescriptor(McpConfigWatchService)); - const watch = ix.get(IMcpConfigWatchService); - disposables.add(watch.onDidChange(() => { - state.events += 1; - })); - return state; - } - - it('fires onDidChange for a valid write and a fresh load sees the new servers', async () => { - const state = createWatcher(); - await realSleep(100); - - await writeFile( - join(homeDir, 'mcp.json'), - JSON.stringify({ mcpServers: { docs: { transport: 'http', url: 'https://docs.example.com' } } }), - 'utf8', - ); - - await waitFor(() => state.events === 1); - const servers = await loadMcpServers({ cwd: homeDir, homeDir }); - expect(Object.keys(servers)).toEqual(['docs']); - expect(warnings).toEqual([]); - }); - - it('warns and suppresses the event on invalid JSON', async () => { - const state = createWatcher(); - await realSleep(100); - - await writeFile(join(homeDir, 'mcp.json'), JSON.stringify({ mcpServers: {} }), 'utf8'); - await waitFor(() => state.events === 1); - - await writeFile(join(homeDir, 'mcp.json'), '{not json}', 'utf8'); - await realSleep(400); - expect(state.events).toBe(1); - expect(warnings.some((message) => message.includes('invalid JSON'))).toBe(true); - }); - - it('treats blank content as a valid empty config change', async () => { - const state = createWatcher(); - await realSleep(100); - - await writeFile(join(homeDir, 'mcp.json'), ' \n', 'utf8'); - - await waitFor(() => state.events === 1); - expect(warnings).toEqual([]); - }); -}); diff --git a/packages/kap-server/src/transport/ws/v1/fsWatchBridge.ts b/packages/kap-server/src/transport/ws/v1/fsWatchBridge.ts index ba06e1c49e..5f60197c8d 100644 --- a/packages/kap-server/src/transport/ws/v1/fsWatchBridge.ts +++ b/packages/kap-server/src/transport/ws/v1/fsWatchBridge.ts @@ -87,7 +87,6 @@ interface SessionWatch { readonly fsWatch: ISessionFsWatchService; readonly workspace: ISessionWorkspaceContext; readonly conns: Map; - union: Set; sub: IDisposable | undefined; } @@ -179,7 +178,7 @@ export class FsWatchBridge implements IDisposable { this.connPathCount.set(conn.id, Math.max(0, this.countFor(conn.id) - removed)); if (entry.paths.size === 0) sw.conns.delete(conn.id); this.recomputeAndApply(sw); - if (sw.conns.size === 0) this.teardownSession(sw); + if (sw.conns.size === 0) this.dropSession(sw, 'teardown'); return this.ok(sw, conn); } @@ -192,7 +191,7 @@ export class FsWatchBridge implements IDisposable { sw.conns.delete(conn.id); this.connPathCount.set(conn.id, Math.max(0, this.countFor(conn.id) - entry.paths.size)); this.recomputeAndApply(sw); - if (sw.conns.size === 0) this.teardownSession(sw); + if (sw.conns.size === 0) this.dropSession(sw, 'teardown'); } this.connPathCount.delete(conn.id); } @@ -208,7 +207,6 @@ export class FsWatchBridge implements IDisposable { fsWatch: session.accessor.get(ISessionFsWatchService), workspace: session.accessor.get(ISessionWorkspaceContext), conns: new Map(), - union: new Set(), sub: undefined, }; this.bySession.set(sessionId, sw); @@ -220,17 +218,12 @@ export class FsWatchBridge implements IDisposable { for (const { paths } of sw.conns.values()) { for (const p of paths) union.add(p); } - sw.union = union; sw.fsWatch.setWatchedPaths([...union]); if (union.size > 0 && sw.sub === undefined) { sw.sub = sw.fsWatch.onDidChangeFiles((ev) => this.onSessionEvent(sw.id, ev)); } } - private teardownSession(sw: SessionWatch): void { - this.dropSession(sw, 'teardown'); - } - private releaseSession(sessionId: string): void { const sw = this.bySession.get(sessionId); if (sw === undefined) return; diff --git a/packages/minidb/src/index.ts b/packages/minidb/src/index.ts index c18eebe9f7..9327a4ffc3 100644 --- a/packages/minidb/src/index.ts +++ b/packages/minidb/src/index.ts @@ -226,7 +226,6 @@ export class MiniDb { private walTail: { dev: number; ino: number; size: number } | null = null; readOnly = false; private lock: LockFile | null = null; - private lockLossError: LockError | null = null; compactThresholdBytes = 64 * 1024 * 1024; autoCompact = true; @@ -287,9 +286,7 @@ export class MiniDb { db.readOnly = !!opts.readOnly; if (!db.readOnly) { - db.lock = new LockFile(path.join(db.dir, 'db.lock'), { - onLost: () => db.markLockLost(), - }); + db.lock = new LockFile(path.join(db.dir, 'db.lock')); const got = await db.lock.acquire(); if (!got) { if (opts.onLockFail === 'readonly') { @@ -1661,11 +1658,5 @@ export class MiniDb { private ensureWritable(): void { if (this.readOnly) throw new Error('MiniDb is open in read-only mode'); this.lock?.assertHeld(); - if (this.lockLossError !== null) throw this.lockLossError; - } - - private markLockLost(): void { - if (this.lockLossError !== null) return; - this.lockLossError = new LockError(`database write lock was lost: ${this.dir}`); } } diff --git a/packages/minidb/src/lockfile.ts b/packages/minidb/src/lockfile.ts index e9c97bf28d..8d6605965b 100644 --- a/packages/minidb/src/lockfile.ts +++ b/packages/minidb/src/lockfile.ts @@ -12,20 +12,14 @@ export class LockError extends Error { } } -export interface LockFileDeps { - readonly onLost?: () => void; -} - export class LockFile { readonly path: string; held = false; private handle: KernelFileLockHandle | undefined; - private readonly onLost: (() => void) | undefined; - constructor(path: string, deps: LockFileDeps = {}) { + constructor(path: string) { this.path = path; - this.onLost = deps.onLost; } async acquire(): Promise { @@ -66,6 +60,5 @@ export class LockFile { const handle = this.handle; this.handle = undefined; handle?.release(); - this.onLost?.(); } } diff --git a/packages/protocol/src/__tests__/session-ownership.test.ts b/packages/protocol/src/__tests__/session-ownership.test.ts index 35e3c8ad01..4ea3da449b 100644 --- a/packages/protocol/src/__tests__/session-ownership.test.ts +++ b/packages/protocol/src/__tests__/session-ownership.test.ts @@ -15,7 +15,7 @@ import { describe('sessionOwnershipPhaseSchema', () => { it('accepts every documented phase', () => { - for (const phase of ['creating', 'routable', 'holder-unresponsive', 'held-by-local-instance']) { + for (const phase of ['creating', 'routable', 'held-by-local-instance']) { expect(sessionOwnershipPhaseSchema.parse(phase)).toBe(phase); } }); @@ -37,7 +37,7 @@ describe('sessionOwnershipDetailsSchema', () => { }); it('parses a retryable payload carrying retry_after_ms', () => { - const payload = { kind: 'held-by-peer', phase: 'holder-unresponsive', retry_after_ms: 1500 }; + const payload = { kind: 'held-by-peer', phase: 'creating', retry_after_ms: 1500 }; expect(sessionOwnershipDetailsSchema.parse(payload)).toEqual(payload); }); diff --git a/packages/protocol/src/session-ownership.ts b/packages/protocol/src/session-ownership.ts index 12d0a2d73b..2244b17595 100644 --- a/packages/protocol/src/session-ownership.ts +++ b/packages/protocol/src/session-ownership.ts @@ -10,19 +10,13 @@ * * - creating kernel lock held before owner metadata is visible; retry shortly * - routable holder is live and registered an address; client may redirect - * - holder-unresponsive legacy heartbeat-based server response; retry later * - held-by-local-instance holder has no address (local/embedded engine); terminal, do not retry - * - * Current kernel-lock servers emit `creating`, `routable`, or - * `held-by-local-instance`. `holder-unresponsive` remains in the wire schema - * so current clients can still understand older servers. */ import { z } from 'zod'; export const sessionOwnershipPhaseSchema = z.enum([ 'creating', 'routable', - 'holder-unresponsive', 'held-by-local-instance', ]); export type SessionOwnershipPhase = z.infer; @@ -32,7 +26,7 @@ export const heldByPeerDetailsSchema = z.object({ phase: sessionOwnershipPhaseSchema, /** Present only when phase === 'routable'. */ address: z.string().optional(), - /** Retry hint (ms) for `creating` or legacy `holder-unresponsive`. */ + /** Retry hint (ms) for `creating`. */ retry_after_ms: z.number().int().nonnegative().optional(), }); export type HeldByPeerDetails = z.infer; From f2d99b3621acde65d719e774c5012887eed52c8c Mon Sep 17 00:00:00 2001 From: 7Sageer <7sageer@djwcb.cn> Date: Tue, 21 Jul 2026 20:24:34 +0800 Subject: [PATCH 17/22] refactor: drop dead ownership surface and dedupe PR helpers - remove the unregistered-writer ownership variant and the dead session.list_changed / skill_catalog.changed zod schemas (no producers or runtime consumers) - unpublish CrossProcessLockService.acquireWithWait and KernelFileLockHandle.path; drop minidb's release() alias - converge duplicated assertScopeWritable, keyed-exclusion queue, special-file stat predicate, and stat-tuple comparison into shared helpers; import syncDir from agent-core-v2 in kap-server - extract a shared session-release hook for the three ws bridges and delegate resolveSessionOwnership to heldByPeerDetailsFromInspection - deduplicate test scaffolding (watch/authority/skill stubs, klient e2e helpers) and remove or merge duplicate test cases - revert unrelated formatting/comment churn and point design-doc references at section numbers instead of a local scratch path - merge the two overlapping lock changesets into one --- .changeset/cross-process-lock-unification.md | 5 - .../src/api/daemon/sessionOwnership.ts | 8 +- apps/kimi-web/src/debug/trace.ts | 2 +- apps/kimi-web/src/i18n/locales/en/warnings.ts | 1 - apps/kimi-web/src/i18n/locales/zh/warnings.ts | 1 - .../src/lib/sessionOwnershipDecision.ts | 5 - apps/kimi-web/test/session-ownership.test.ts | 27 ++-- packages/agent-core-v2/src/_base/utils/fs.ts | 15 ++- .../agent-core-v2/src/_base/utils/promise.ts | 23 ++++ .../src/app/bootstrap/bootstrap.ts | 4 +- packages/agent-core-v2/src/app/cron/clock.ts | 10 +- .../src/app/edit/fileEditService.ts | 3 +- .../node-local/crossProcessLockService.ts | 2 +- .../backends/node-local/hostFsWatchService.ts | 9 +- .../src/os/interface/crossProcessLock.ts | 5 - .../backends/memory/inMemoryStorageService.ts | 13 +- .../backends/node-fs/appendLogStore.ts | 13 +- .../backends/node-fs/atomicDocumentStore.ts | 13 +- .../backends/node-fs/fileStorageService.ts | 18 +-- .../persistence/interface/writeAuthority.ts | 28 +++- .../src/session/sessionFs/fsWatch.ts | 9 +- .../src/session/sessionLease/sessionLease.ts | 2 +- .../agent/fileFencing/fileFencing.test.ts | 51 +------- .../test/agent/goal/goalOps.test.ts | 5 +- .../agent-core-v2/test/agent/task/stubs.ts | 22 +++- .../test/agent/task/taskService.test.ts | 12 +- .../sessionLifecycle/sessionLifecycle.test.ts | 9 -- packages/agent-core-v2/test/harness/agent.ts | 26 +--- .../crossProcessLockService.test.ts | 21 ++- packages/agent-core-v2/test/os/stubs.ts | 1 - .../sessionFileLedger/fileLedger.test.ts | 52 +------- .../test/session/sessionFs/stubs.ts | 51 ++++++-- .../sessionSkillCatalog/skillCatalog.test.ts | 121 ++---------------- .../skillHotReload.test.ts | 83 +----------- .../test/session/sessionSkillCatalog/stubs.ts | 114 +++++++++++++++++ packages/kap-server/src/instanceRegistry.ts | 12 +- packages/kap-server/src/routes/sessions.ts | 11 +- packages/kap-server/src/routes/snapshot.ts | 4 +- .../src/transport/ws/v1/fsWatchBridge.ts | 29 ++--- .../ws/v1/sessionEventBroadcaster.ts | 15 +-- .../src/transport/ws/v1/sessionReleaseHook.ts | 26 ++++ .../src/transport/ws/v1/skillCatalogBridge.ts | 15 +-- packages/kernel-file-lock/src/index.ts | 3 +- .../test/e2e/harness/testing/serverPair.ts | 6 +- .../test/e2e/harness/testing/serverProcess.ts | 22 +++- packages/klient/test/e2e/harness/wait.ts | 2 +- .../test/e2e/legacy/dual-instance.test.ts | 20 +-- .../test/e2e/legacy/session-ownership.test.ts | 98 +++++--------- packages/minidb/src/index.ts | 8 +- packages/minidb/src/lockfile.ts | 10 +- packages/minidb/test/defense.test.ts | 33 +---- .../minidb/test/e2e/helpers/lock-racer.ts | 2 +- packages/minidb/test/lock.test.ts | 25 +--- .../protocol/src/__tests__/events.test.ts | 25 ---- .../src/__tests__/session-ownership.test.ts | 6 - packages/protocol/src/events.ts | 15 +-- packages/protocol/src/session-ownership.ts | 6 - 57 files changed, 441 insertions(+), 736 deletions(-) delete mode 100644 .changeset/cross-process-lock-unification.md create mode 100644 packages/agent-core-v2/test/session/sessionSkillCatalog/stubs.ts create mode 100644 packages/kap-server/src/transport/ws/v1/sessionReleaseHook.ts diff --git a/.changeset/cross-process-lock-unification.md b/.changeset/cross-process-lock-unification.md deleted file mode 100644 index 4dc061dc8f..0000000000 --- a/.changeset/cross-process-lock-unification.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -"@moonshot-ai/kimi-code": patch ---- - -Fix occasional state corruption when multiple CLI or server instances run at the same time. diff --git a/apps/kimi-web/src/api/daemon/sessionOwnership.ts b/apps/kimi-web/src/api/daemon/sessionOwnership.ts index 0990b4f1aa..34dc1299be 100644 --- a/apps/kimi-web/src/api/daemon/sessionOwnership.ts +++ b/apps/kimi-web/src/api/daemon/sessionOwnership.ts @@ -10,7 +10,6 @@ // - creating lease file observed mid-creation; retry shortly // - routable holder is live and registered an address; redirect // - held-by-local-instance holder has no address (local/embedded); terminal -// - unregistered-writer session dir written by an unregistered process import { isDaemonApiError } from '../errors'; @@ -31,11 +30,7 @@ export interface HeldByPeerDetails { retry_after_ms?: number; } -export interface UnregisteredWriterDetails { - kind: 'unregistered-writer'; -} - -export type SessionOwnershipDetails = HeldByPeerDetails | UnregisteredWriterDetails; +export type SessionOwnershipDetails = HeldByPeerDetails; const PHASES: ReadonlySet = new Set([ 'creating', @@ -56,7 +51,6 @@ export function narrowSessionOwnershipDetails( ): SessionOwnershipDetails | undefined { if (typeof details !== 'object' || details === null) return undefined; const record = details as Record; - if (record['kind'] === 'unregistered-writer') return { kind: 'unregistered-writer' }; if (record['kind'] !== 'held-by-peer') return undefined; const phase = record['phase']; if (typeof phase !== 'string' || !isSessionOwnershipPhase(phase)) { diff --git a/apps/kimi-web/src/debug/trace.ts b/apps/kimi-web/src/debug/trace.ts index 5f029e44ef..edf5d75bf7 100644 --- a/apps/kimi-web/src/debug/trace.ts +++ b/apps/kimi-web/src/debug/trace.ts @@ -76,7 +76,7 @@ export interface ExportTraceMetadata { operation?: string; /** Ownership decision outcome ('redirect' | 'retry' | 'notify'). */ action?: string; - /** Ownership details kind ('held-by-peer' | 'unregistered-writer'). */ + /** Ownership details kind ('held-by-peer'). */ kind?: string; /** 'creating' retry attempt number. */ attempt?: number; diff --git a/apps/kimi-web/src/i18n/locales/en/warnings.ts b/apps/kimi-web/src/i18n/locales/en/warnings.ts index fece120996..58cae9c24b 100644 --- a/apps/kimi-web/src/i18n/locales/en/warnings.ts +++ b/apps/kimi-web/src/i18n/locales/en/warnings.ts @@ -59,7 +59,6 @@ export default { creating: 'This session is being created on another instance. Retrying shortly…', creatingTimeout: 'This session is still being created on another instance. Try again in a moment.', heldByLocalInstance: 'This session is held by another local instance without a network address (e.g. a CLI/TUI). Continue there, or close it first.', - unregisteredWriter: 'This session’s directory is being written by an unregistered external process. Refused to open it to protect your data.', redirectSameHost: 'This session is already held by the current instance. Reload the page and try again.', redirectLoopGuard: 'Too many redirects without reaching the owning instance. Open that instance directly and try again.', redirectUnavailable: 'This session is held by another instance, but that instance did not publish an address to redirect to.', diff --git a/apps/kimi-web/src/i18n/locales/zh/warnings.ts b/apps/kimi-web/src/i18n/locales/zh/warnings.ts index 220b062344..103e321e9e 100644 --- a/apps/kimi-web/src/i18n/locales/zh/warnings.ts +++ b/apps/kimi-web/src/i18n/locales/zh/warnings.ts @@ -59,7 +59,6 @@ export default { creating: '会话正在另一个实例上创建,稍后自动重试…', creatingTimeout: '会话仍在另一个实例上创建中,请稍后再试。', heldByLocalInstance: '会话被本机另一个无网络地址的实例(如 CLI/TUI)持有。请到该实例继续,或先关闭它。', - unregisteredWriter: '会话目录正被未注册的外部进程写入,已拒绝打开以保护数据。', redirectSameHost: '该会话的持有者就是当前实例。请刷新页面后重试。', redirectLoopGuard: '多次跳转仍未到达持有会话的实例。请直接打开该实例后重试。', redirectUnavailable: '该会话由另一个实例持有,但该实例未发布可跳转的地址。', diff --git a/apps/kimi-web/src/lib/sessionOwnershipDecision.ts b/apps/kimi-web/src/lib/sessionOwnershipDecision.ts index 2633ae5d71..1e3d5e2e7a 100644 --- a/apps/kimi-web/src/lib/sessionOwnershipDecision.ts +++ b/apps/kimi-web/src/lib/sessionOwnershipDecision.ts @@ -11,7 +11,6 @@ import type { SessionOwnershipDetails } from '../api/daemon/sessionOwnership'; export type OwnershipNotifyKey = | 'creatingTimeout' | 'heldByLocalInstance' - | 'unregisteredWriter' | 'redirectSameHost' | 'redirectLoopGuard' | 'redirectUnavailable'; @@ -69,10 +68,6 @@ export function decideSessionOwnershipAction( details: SessionOwnershipDetails, ctx: OwnershipDecisionContext, ): SessionOwnershipAction { - if (details.kind === 'unregistered-writer') { - return { type: 'notify', key: 'unregisteredWriter' }; - } - switch (details.phase) { case 'creating': { if (ctx.creatingAttempts >= ctx.maxCreatingAttempts) { diff --git a/apps/kimi-web/test/session-ownership.test.ts b/apps/kimi-web/test/session-ownership.test.ts index 6bbbf58b5a..58eb90a131 100644 --- a/apps/kimi-web/test/session-ownership.test.ts +++ b/apps/kimi-web/test/session-ownership.test.ts @@ -58,12 +58,6 @@ describe('narrowSessionOwnershipDetails', () => { }); }); - it('accepts unregistered-writer', () => { - expect(narrowSessionOwnershipDetails({ kind: 'unregistered-writer' })).toEqual({ - kind: 'unregistered-writer', - }); - }); - it('drops invalid optional fields instead of failing the whole payload', () => { expect( narrowSessionOwnershipDetails({ @@ -94,12 +88,20 @@ describe('getSessionOwnershipDetails', () => { it('extracts details from a 40921 DaemonApiError', () => { expect( - getSessionOwnershipDetails(apiError(SESSION_HELD_BY_PEER_CODE, { kind: 'unregistered-writer' })), - ).toEqual({ kind: 'unregistered-writer' }); + getSessionOwnershipDetails( + apiError(SESSION_HELD_BY_PEER_CODE, { + kind: 'held-by-peer', + phase: 'creating', + retry_after_ms: 500, + }), + ), + ).toEqual({ kind: 'held-by-peer', phase: 'creating', address: undefined, retry_after_ms: 500 }); }); it('returns undefined for other codes, malformed details, and non-API errors', () => { - expect(getSessionOwnershipDetails(apiError(40401, { kind: 'unregistered-writer' }))).toBeUndefined(); + expect( + getSessionOwnershipDetails(apiError(40401, { kind: 'held-by-peer', phase: 'routable' })), + ).toBeUndefined(); expect(getSessionOwnershipDetails(apiError(SESSION_HELD_BY_PEER_CODE, { nope: 1 }))).toBeUndefined(); expect(getSessionOwnershipDetails(new Error('boom'))).toBeUndefined(); expect(getSessionOwnershipDetails(undefined)).toBeUndefined(); @@ -150,13 +152,6 @@ describe('buildPeerTargetUrl', () => { }); describe('decideSessionOwnershipAction', () => { - it('unregistered-writer → terminal notice', () => { - expect(decideSessionOwnershipAction({ kind: 'unregistered-writer' }, makeCtx())).toEqual({ - type: 'notify', - key: 'unregisteredWriter', - }); - }); - it('routable with address → redirect carrying origin + current path', () => { expect(decideSessionOwnershipAction(ROUTABLE, makeCtx())).toEqual({ type: 'redirect', diff --git a/packages/agent-core-v2/src/_base/utils/fs.ts b/packages/agent-core-v2/src/_base/utils/fs.ts index a93e9699a2..a8ac489a96 100644 --- a/packages/agent-core-v2/src/_base/utils/fs.ts +++ b/packages/agent-core-v2/src/_base/utils/fs.ts @@ -1,14 +1,25 @@ /** - * Low-level durable file-write primitives — atomic writes plus file and - * directory fsync helpers. + * Low-level filesystem helpers — durable file-write primitives (atomic writes + * plus file and directory fsync) and stat classification shared by watchers. */ import { randomBytes } from 'node:crypto'; import { closeSync, fsyncSync, openSync } from 'node:fs'; import * as nodeFs from 'node:fs'; +import type { Stats } from 'node:fs'; import { open, rename, unlink } from 'node:fs/promises'; import { dirname } from 'pathe'; +/** + * True for directory entries that are neither a regular file, a directory, + * nor a symlink (unix sockets, fifos, devices). File watchers must skip + * these: chokidar attaches `fs.watch` to every scanned entry and that call + * throws `UNKNOWN` on special files. + */ +export function isSpecialFileStat(stats: Stats | undefined): boolean { + return stats !== undefined && !stats.isFile() && !stats.isDirectory() && !stats.isSymbolicLink(); +} + export async function syncDir(dirPath: string): Promise { if (process.platform === 'win32') return; const dirFh = await open(dirPath, 'r'); diff --git a/packages/agent-core-v2/src/_base/utils/promise.ts b/packages/agent-core-v2/src/_base/utils/promise.ts index 811bbda9d5..203b3394f0 100644 --- a/packages/agent-core-v2/src/_base/utils/promise.ts +++ b/packages/agent-core-v2/src/_base/utils/promise.ts @@ -31,3 +31,26 @@ export function timeoutOutcome( }, }); } + +/** + * Keyed promise-chain exclusion over a shared `id → tail promise` map: `op` + * runs after every previously enqueued operation for `id` settles, a + * rejection does not poison later operations for the same key, and the map + * entry is dropped once the queue drains. + */ +export function enqueueKeyedOperation( + queues: Map>, + id: string, + op: () => Promise, +): Promise { + const previous = queues.get(id) ?? Promise.resolve(); + const result = previous.then(op); + const tail = result.then( + () => undefined, + () => undefined, + ); + queues.set(id, tail); + return result.finally(() => { + if (queues.get(id) === tail) queues.delete(id); + }); +} diff --git a/packages/agent-core-v2/src/app/bootstrap/bootstrap.ts b/packages/agent-core-v2/src/app/bootstrap/bootstrap.ts index e4ce9db311..9c54118cff 100644 --- a/packages/agent-core-v2/src/app/bootstrap/bootstrap.ts +++ b/packages/agent-core-v2/src/app/bootstrap/bootstrap.ts @@ -126,7 +126,9 @@ export function bootstrap(input: BootstrapInput = {}, extraSeeds: ScopeSeed = [] function storageSeed(options: IBootstrapOptions): ScopeSeed { const file = (): SyncDescriptor => new SyncDescriptor(FileStorageService, [options.homeDir, 0o700, 0o600], true); - return [[IFileSystemStorageService as ServiceIdentifier, file()]]; + return [ + [IFileSystemStorageService as ServiceIdentifier, file()], + ]; } function skillSeed(): ScopeSeed { diff --git a/packages/agent-core-v2/src/app/cron/clock.ts b/packages/agent-core-v2/src/app/cron/clock.ts index 2db4b26edf..43105a75e3 100644 --- a/packages/agent-core-v2/src/app/cron/clock.ts +++ b/packages/agent-core-v2/src/app/cron/clock.ts @@ -10,11 +10,11 @@ * * 2. monotonic ms — a strictly non-decreasing counter that never * jumps backwards across NTP adjustments, suspend/resume, or - * simulated-clock injection. Used for the poll cadence — anything - * where "did 5 seconds elapse since we last looked" must hold - * even when the wall clock is frozen. + * simulated-clock injection. Used for the poll cadence and the + * lock heartbeat — anything where "did 5 seconds elapse since we + * last looked" must hold even when the wall clock is frozen. * - * Mixing the two pollutes test reproducibility: a poll cadence tied to + * Mixing the two pollutes test reproducibility: a heartbeat tied to * `wallNow()` will appear stuck when the test clock is frozen; a cron * fire tied to `monoNowMs()` will not advance when the bench rewinds * the simulated day. Every component in the cron domain MUST take a @@ -22,7 +22,7 @@ * * `monoNowMs` is ALWAYS `process.hrtime.bigint()` (converted to ms). * It is not overridable — accepting an external monotonic clock would - * let a frozen test clock silently stall the poll cadence. + * defeat the safety net the lock heartbeat depends on. * * `wallNow` resolution is driven by the `KIMI_CRON_CLOCK` env var; see * `resolveClockSources` below. Defaults to `Date.now()`. diff --git a/packages/agent-core-v2/src/app/edit/fileEditService.ts b/packages/agent-core-v2/src/app/edit/fileEditService.ts index b3a5c5ef15..7135931be0 100644 --- a/packages/agent-core-v2/src/app/edit/fileEditService.ts +++ b/packages/agent-core-v2/src/app/edit/fileEditService.ts @@ -13,6 +13,7 @@ import { InstantiationType } from '#/_base/di/extensions'; import { LifecycleScope, registerScopedService } from '#/_base/di/scope'; import { unwrapErrorCause } from '#/_base/errors/errors'; import { IHostFileSystem } from '#/os/interface/hostFileSystem'; +import { fileStatTuplesEqual } from '#/session/sessionFileLedger/fileLedger'; import { EditService } from './editService'; import { type FileEditInput, type FileEditResult, IFileEditService } from './fileEdit'; @@ -22,7 +23,7 @@ function sameRevision( a: { readonly ino?: number; readonly mtimeMs?: number; readonly size: number }, b: { readonly ino?: number; readonly mtimeMs?: number; readonly size: number }, ): boolean { - return a.ino === b.ino && a.mtimeMs === b.mtimeMs && a.size === b.size; + return fileStatTuplesEqual({ exists: true, ...a }, { exists: true, ...b }); } export class FileEditService implements IFileEditService { diff --git a/packages/agent-core-v2/src/os/backends/node-local/crossProcessLockService.ts b/packages/agent-core-v2/src/os/backends/node-local/crossProcessLockService.ts index 2dc79d04ff..2cf4dd8f49 100644 --- a/packages/agent-core-v2/src/os/backends/node-local/crossProcessLockService.ts +++ b/packages/agent-core-v2/src/os/backends/node-local/crossProcessLockService.ts @@ -208,7 +208,7 @@ export class CrossProcessLockService implements ICrossProcessLockService { } } - async acquireWithWait( + private async acquireWithWait( lockPath: string, options: CrossProcessLockAcquireOptions & { wait: CrossProcessLockWaitOptions }, ): Promise { diff --git a/packages/agent-core-v2/src/os/backends/node-local/hostFsWatchService.ts b/packages/agent-core-v2/src/os/backends/node-local/hostFsWatchService.ts index c68b24eb51..0bed5e7164 100644 --- a/packages/agent-core-v2/src/os/backends/node-local/hostFsWatchService.ts +++ b/packages/agent-core-v2/src/os/backends/node-local/hostFsWatchService.ts @@ -6,14 +6,13 @@ * handle closes it. Bound at App scope. */ -import type { Stats } from 'node:fs'; - import { FSWatcher } from 'chokidar'; import { Emitter, type Event } from '#/_base/event'; import { InstantiationType } from '#/_base/di/extensions'; import { LifecycleScope, registerScopedService } from '#/_base/di/scope'; import { onUnexpectedError } from '#/_base/errors/unexpectedError'; +import { isSpecialFileStat } from '#/_base/utils/fs'; import { type HostFsChange, @@ -26,10 +25,6 @@ import { const DEFAULT_IGNORED = (p: string): boolean => /(?:^|[/\\])\.git(?:$|[/\\])/.test(p); -function isSpecialEntry(stats: Stats | undefined): boolean { - return stats !== undefined && !stats.isFile() && !stats.isDirectory() && !stats.isSymbolicLink(); -} - class HostFsWatchHandle implements IHostFsWatchHandle { readonly ready: Promise; readonly onDidChange: Event; @@ -55,7 +50,7 @@ class HostFsWatchHandle implements IHostFsWatchHandle { followSymlinks: false, depth: options?.recursive === false ? 0 : undefined, ignored: (path, stats) => - isSpecialEntry(stats) || (options?.ignored?.(path) ?? DEFAULT_IGNORED(path)), + isSpecialFileStat(stats) || (options?.ignored?.(path) ?? DEFAULT_IGNORED(path)), }); this.watcher.on('all', (eventName: string, absPath: string) => { if (this.disposed) return; diff --git a/packages/agent-core-v2/src/os/interface/crossProcessLock.ts b/packages/agent-core-v2/src/os/interface/crossProcessLock.ts index 03c8210ad7..c2c7f2970d 100644 --- a/packages/agent-core-v2/src/os/interface/crossProcessLock.ts +++ b/packages/agent-core-v2/src/os/interface/crossProcessLock.ts @@ -49,11 +49,6 @@ export interface ICrossProcessLockService { options?: CrossProcessLockAcquireOptions, ): Promise; - acquireWithWait( - lockPath: string, - options: CrossProcessLockAcquireOptions & { wait: CrossProcessLockWaitOptions }, - ): Promise; - withLock( lockPath: string, options: CrossProcessLockAcquireOptions & { wait: CrossProcessLockWaitOptions }, diff --git a/packages/agent-core-v2/src/persistence/backends/memory/inMemoryStorageService.ts b/packages/agent-core-v2/src/persistence/backends/memory/inMemoryStorageService.ts index f82948a76f..f53acfdb7d 100644 --- a/packages/agent-core-v2/src/persistence/backends/memory/inMemoryStorageService.ts +++ b/packages/agent-core-v2/src/persistence/backends/memory/inMemoryStorageService.ts @@ -20,6 +20,7 @@ import { type IDisposable, } from '#/_base/di/lifecycle'; import { Emitter, type Event } from '#/_base/event'; +import { enqueueKeyedOperation } from '#/_base/utils/promise'; import { IFileSystemStorageService, @@ -133,17 +134,7 @@ export class InMemoryStorageService implements IFileSystemStorageService { } runExclusive(scope: string, key: string, op: () => Promise): Promise { - const id = this.watchKey(scope, key); - const previous = this.operationQueues.get(id) ?? Promise.resolve(); - const result = previous.then(op); - const tail = result.then( - () => undefined, - () => undefined, - ); - this.operationQueues.set(id, tail); - return result.finally(() => { - if (this.operationQueues.get(id) === tail) this.operationQueues.delete(id); - }); + return enqueueKeyedOperation(this.operationQueues, this.watchKey(scope, key), op); } private notifyWatchers(scope: string, key: string): void { diff --git a/packages/agent-core-v2/src/persistence/backends/node-fs/appendLogStore.ts b/packages/agent-core-v2/src/persistence/backends/node-fs/appendLogStore.ts index 4ca54db20d..6864a4802e 100644 --- a/packages/agent-core-v2/src/persistence/backends/node-fs/appendLogStore.ts +++ b/packages/agent-core-v2/src/persistence/backends/node-fs/appendLogStore.ts @@ -25,11 +25,10 @@ import { InstantiationType } from '#/_base/di/extensions'; import { toDisposable, type IDisposable } from '#/_base/di/lifecycle'; import { LifecycleScope, registerScopedService } from '#/_base/di/scope'; -import { Error2, ErrorCodes } from '#/errors'; import { IFileSystemStorageService } from '#/persistence/interface/storage'; import { - sessionIdFromScope, + assertScopeWritable, IWriteAuthorityRegistry, } from '#/persistence/interface/writeAuthority'; import { @@ -278,15 +277,7 @@ export class AppendLogStore implements IAppendLogStore { } private assertScopeWritable(scope: string): void { - const sessionId = sessionIdFromScope(scope); - if (sessionId === undefined) return; - const authority = this.authorityRegistry.resolve(sessionId); - if (authority === undefined) { - throw new Error2(ErrorCodes.SESSION_LEASE_LOST, 'session has no registered write authority', { - details: { sessionId }, - }); - } - authority.assertWritable(); + assertScopeWritable(scope, this.authorityRegistry); } } diff --git a/packages/agent-core-v2/src/persistence/backends/node-fs/atomicDocumentStore.ts b/packages/agent-core-v2/src/persistence/backends/node-fs/atomicDocumentStore.ts index 7388b94ac9..527d66403a 100644 --- a/packages/agent-core-v2/src/persistence/backends/node-fs/atomicDocumentStore.ts +++ b/packages/agent-core-v2/src/persistence/backends/node-fs/atomicDocumentStore.ts @@ -13,6 +13,7 @@ import { InstantiationType } from '#/_base/di/extensions'; import { toDisposable, type IDisposable } from '#/_base/di/lifecycle'; import { LifecycleScope, registerScopedService } from '#/_base/di/scope'; import { Event } from '#/_base/event'; +import { enqueueKeyedOperation } from '#/_base/utils/promise'; import { IFileSystemStorageService, StorageError, StorageErrors } from '#/persistence/interface/storage'; import { @@ -92,17 +93,7 @@ class AtomicDocumentStoreBase implements IAtomicDocumentStore { if (this.storage.runExclusive !== undefined) { return this.storage.runExclusive(scope, key, op); } - const id = `${scope}\0${key}`; - const previous = this.operationQueues.get(id) ?? Promise.resolve(); - const result = previous.then(op); - const tail = result.then( - () => undefined, - () => undefined, - ); - this.operationQueues.set(id, tail); - return result.finally(() => { - if (this.operationQueues.get(id) === tail) this.operationQueues.delete(id); - }); + return enqueueKeyedOperation(this.operationQueues, `${scope}\0${key}`, op); } async delete(scope: string, key: string): Promise { diff --git a/packages/agent-core-v2/src/persistence/backends/node-fs/fileStorageService.ts b/packages/agent-core-v2/src/persistence/backends/node-fs/fileStorageService.ts index 028e66c976..34b8181dc4 100644 --- a/packages/agent-core-v2/src/persistence/backends/node-fs/fileStorageService.ts +++ b/packages/agent-core-v2/src/persistence/backends/node-fs/fileStorageService.ts @@ -34,8 +34,7 @@ import { DisposableStore, combinedDisposable, toDisposable, type IDisposable } f import { optional } from '#/_base/di/instantiation'; import { Emitter, type Event } from '#/_base/event'; import { onUnexpectedError } from '#/_base/errors/unexpectedError'; -import { atomicWrite, syncDir } from '#/_base/utils/fs'; -import { Error2, ErrorCodes } from '#/errors'; +import { atomicWrite, isSpecialFileStat, syncDir } from '#/_base/utils/fs'; import { CrossProcessLockError, CrossProcessLockErrorCode, @@ -50,7 +49,7 @@ import type { } from '#/persistence/interface/storage'; import { StorageError, StorageErrors, toStorageIoError } from '#/persistence/interface/storage'; import { - sessionIdFromScope, + assertScopeWritable, IWriteAuthorityRegistry, } from '#/persistence/interface/writeAuthority'; @@ -227,8 +226,7 @@ export class FileStorageService implements IFileSystemStorageService { // chokidar attaches fs.watch to every scanned entry; special files // (unix sockets, fifos, devices — e.g. an ipc `klient.sock` sharing // the home root) make that call throw UNKNOWN. Skip them up front. - ignored: (_path, stats) => - stats !== undefined && !stats.isFile() && !stats.isDirectory() && !stats.isSymbolicLink(), + ignored: (_path, stats) => isSpecialFileStat(stats), }); watcher.on('all', (_event, changedPath) => { if (normalize(changedPath) === normalizedTarget) schedule(); @@ -323,15 +321,7 @@ export class FileStorageService implements IFileSystemStorageService { } private assertScopeWritable(scope: string): void { - const sessionId = sessionIdFromScope(scope); - if (sessionId === undefined || this.authorityRegistry === undefined) return; - const authority = this.authorityRegistry.resolve(sessionId); - if (authority === undefined) { - throw new Error2(ErrorCodes.SESSION_LEASE_LOST, 'session has no registered write authority', { - details: { sessionId }, - }); - } - authority.assertWritable(); + assertScopeWritable(scope, this.authorityRegistry); } private async syncDirOnce(dir: string): Promise { diff --git a/packages/agent-core-v2/src/persistence/interface/writeAuthority.ts b/packages/agent-core-v2/src/persistence/interface/writeAuthority.ts index 1f45a589de..a89df09226 100644 --- a/packages/agent-core-v2/src/persistence/interface/writeAuthority.ts +++ b/packages/agent-core-v2/src/persistence/interface/writeAuthority.ts @@ -9,7 +9,8 @@ * to the authority the session lifecycle registered, so a write for a * session with no registered authority is a bypass attempt and must be * rejected. `sessionIdFromScope` keeps the filesystem-layout knowledge - * (`sessions//[/agents/]`) in exactly one place; + * (`sessions//[/agents/]`) in exactly one place and + * `assertScopeWritable` applies the fail-closed gate for every backend; * the root scope (`''`, e.g. `session_index.jsonl`) and any scope outside * the sessions tree deliberately carry no authority and pass untouched. * The concrete registry lives in @@ -18,6 +19,7 @@ import { createDecorator, type ServiceIdentifier } from '#/_base/di/instantiation'; import type { IDisposable } from '#/_base/di/lifecycle'; +import { Error2, ErrorCodes } from '#/errors'; export interface ISessionWriteAuthority { readonly sessionId: string; @@ -47,3 +49,27 @@ export function sessionIdFromScope(scope: string): string | undefined { const sessionId = parts[2]; return parts[1] === '' || sessionId === undefined || sessionId === '' ? undefined : sessionId; } + +/** + * The pre-write fencing gate every Store backend applies immediately before + * bytes hit storage: resolve the scope's session authority through the + * registry and re-verify it. The root scope and scopes outside the sessions + * tree carry no authority and pass untouched, as does a missing registry + * (a consumer whose DI binding is `@optional`); a session scope with no + * registered authority is a bypass attempt and fails closed with + * `Error2(session.lease_lost)`. + */ +export function assertScopeWritable( + scope: string, + authorityRegistry: IWriteAuthorityRegistry | undefined, +): void { + const sessionId = sessionIdFromScope(scope); + if (sessionId === undefined || authorityRegistry === undefined) return; + const authority = authorityRegistry.resolve(sessionId); + if (authority === undefined) { + throw new Error2(ErrorCodes.SESSION_LEASE_LOST, 'session has no registered write authority', { + details: { sessionId }, + }); + } + authority.assertWritable(); +} diff --git a/packages/agent-core-v2/src/session/sessionFs/fsWatch.ts b/packages/agent-core-v2/src/session/sessionFs/fsWatch.ts index 00d4b4c520..0f3f2f8cf3 100644 --- a/packages/agent-core-v2/src/session/sessionFs/fsWatch.ts +++ b/packages/agent-core-v2/src/session/sessionFs/fsWatch.ts @@ -8,10 +8,11 @@ * dropped. Session-scoped — the scope itself is the session, so no * `sessionId` is threaded through. * - * Also owns the lexical key helper shared with the optimistic-concurrency - * ledger (`sessionFileLedger`) so both sides key paths identically: - * `normalizeFsWatchKey` (lexical normalize only, no `realpath`; case-folded - * on macOS/Windows). + * Also owns the lexical key helper `normalizeFsWatchKey` (lexical normalize + * only, no `realpath`; case-folded on macOS/Windows). The watch service + * itself never keys paths with it — the sole consumer is the + * optimistic-concurrency ledger (`sessionFileLedger`), which keys its + * baselines with it. */ import { normalize, sep } from 'node:path'; diff --git a/packages/agent-core-v2/src/session/sessionLease/sessionLease.ts b/packages/agent-core-v2/src/session/sessionLease/sessionLease.ts index 8c0a653975..f073d3414d 100644 --- a/packages/agent-core-v2/src/session/sessionLease/sessionLease.ts +++ b/packages/agent-core-v2/src/session/sessionLease/sessionLease.ts @@ -49,7 +49,7 @@ export type HeldByPeerDetails = { readonly retry_after_ms?: number; }; -export type SessionOwnershipDetails = HeldByPeerDetails | { readonly kind: 'unregistered-writer' }; +export type SessionOwnershipDetails = HeldByPeerDetails; /** * Classify a lease inspection into `held-by-peer` details. Shared by every diff --git a/packages/agent-core-v2/test/agent/fileFencing/fileFencing.test.ts b/packages/agent-core-v2/test/agent/fileFencing/fileFencing.test.ts index 96f6ebc0b2..7be342431a 100644 --- a/packages/agent-core-v2/test/agent/fileFencing/fileFencing.test.ts +++ b/packages/agent-core-v2/test/agent/fileFencing/fileFencing.test.ts @@ -24,7 +24,6 @@ import type { } from '#/agent/toolExecutor/toolHooks'; import { IFlagService } from '#/app/flag/flag'; import type { ToolCall } from '#/kosong/contract/message'; -import { HostFileSystem } from '#/os/backends/node-local/hostFsService'; import { IHostFileSystem } from '#/os/interface/hostFileSystem'; import { IHostFsWatchService } from '#/os/interface/hostFsWatch'; import { ISessionContext, makeSessionContext } from '#/session/sessionContext/sessionContext'; @@ -44,30 +43,13 @@ import { AgentToolExecutorService } from '#/agent/toolExecutor/toolExecutorServi import { stubToolExecutor } from '../loop/stubs'; import { stubFlag } from '../../app/flag/stubs'; -import { fakeHostFsWatch, type FakeWatch } from '../../session/sessionFs/stubs'; +import { countingHostFs, fakeHostFsWatch, type FakeWatch } from '../../session/sessionFs/stubs'; void AgentFileFencingService; void SessionFileLedger; void SessionWorkspaceContextService; void AgentToolExecutorService; -function countingHostFs(): { fs: IHostFileSystem; statCalls: () => number } { - const real = new HostFileSystem(); - let count = 0; - const fs = new Proxy(real, { - get(target, prop, receiver) { - if (prop === 'stat') { - return async (path: string) => { - count += 1; - return target.stat(path); - }; - } - return Reflect.get(target, prop, receiver); - }, - }) as IHostFileSystem; - return { fs, statCalls: () => count }; -} - interface Env { readonly host: ScopedTestHost; readonly fake: FakeWatch; @@ -290,6 +272,9 @@ describe('AgentFileFencingService', () => { writeFileSync(file, 'hello'); await runOk(world, 'Read', file); + // An intervening successful Edit re-baselines, so the next Edit starts clean. + await runOk(world, 'Edit', file); + writeFileSync(file, 'hello world'); const blocked = await runBlocked(world, 'Edit', file); @@ -391,19 +376,7 @@ describe('AgentFileFencingService', () => { await runOk(world, 'Edit', file); }); - it('allows consecutive Edits without watcher events', async () => { - const world = setup(); - const file = join(world.env.workDir, 'a.txt'); - writeFileSync(file, 'hello'); - - await runOk(world, 'Read', file); - await runOk(world, 'Edit', file); - await runOk(world, 'Edit', file); - - expect(await world.ledger.compare(file)).toBe('clean'); - }); - - it('keeps consecutive Edits clean while the stat tuple matches the baseline', async () => { + it('allows consecutive Edits and keeps them clean while the stat tuple matches the baseline', async () => { const world = setup(); const file = join(world.env.workDir, 'a.txt'); writeFileSync(file, 'hello'); @@ -415,20 +388,8 @@ describe('AgentFileFencingService', () => { await runOk(world, 'Edit', file); expect(world.env.statCalls()).toBe(2); - }); - - it('checks the current stat on every execution: unchanged passes and changed blocks', async () => { - const world = setup(); - const file = join(world.env.workDir, 'a.txt'); - writeFileSync(file, 'hello'); - await runOk(world, 'Read', file); - await runOk(world, 'Edit', file); - - writeFileSync(file, 'hello world'); - - const blocked = await runBlocked(world, 'Edit', file); - expect(blocked.output).toContain('changed on disk since'); + expect(await world.ledger.compare(file)).toBe('clean'); }); it('blocks ranged-Read followed by Edit because ranged reads never baseline', async () => { diff --git a/packages/agent-core-v2/test/agent/goal/goalOps.test.ts b/packages/agent-core-v2/test/agent/goal/goalOps.test.ts index de1d592bb9..1ba42f3bbd 100644 --- a/packages/agent-core-v2/test/agent/goal/goalOps.test.ts +++ b/packages/agent-core-v2/test/agent/goal/goalOps.test.ts @@ -89,10 +89,7 @@ function createTelemetryStub(): ITelemetryService { function createToolExecutorStub(): IAgentToolExecutorService { return { _serviceBrand: undefined, - hooks: { - onBeforeExecuteTool: hookSlot(), - onDidExecuteTool: hookSlot(), - }, + hooks: { onBeforeExecuteTool: hookSlot(), onDidExecuteTool: hookSlot() }, } as unknown as IAgentToolExecutorService; } diff --git a/packages/agent-core-v2/test/agent/task/stubs.ts b/packages/agent-core-v2/test/agent/task/stubs.ts index b822521d77..6581ff9c72 100644 --- a/packages/agent-core-v2/test/agent/task/stubs.ts +++ b/packages/agent-core-v2/test/agent/task/stubs.ts @@ -15,6 +15,7 @@ import { import { JsonAtomicDocumentStore } from '#/persistence/backends/node-fs/atomicDocumentStore'; import { FileStorageService } from '#/persistence/backends/node-fs/fileStorageService'; import { WriteAuthorityRegistryService } from '#/persistence/backends/node-fs/writeAuthorityRegistryService'; +import type { IWriteAuthorityRegistry } from '#/persistence/interface/writeAuthority'; export type TaskServiceTestManager = IAgentTaskService & { loadFromDisk(): Promise; @@ -25,19 +26,28 @@ export const TASK_TEST_SESSION_SCOPE = 'sessions/test-workspace/test-session'; export const TASK_TEST_AGENT_SCOPE = `${TASK_TEST_SESSION_SCOPE}/agents/main`; -export function createAgentTaskPersistence(homedir: string): AgentTaskPersistence { - const storage = new FileStorageService(homedir); - const authorityRegistry = new WriteAuthorityRegistryService(); - authorityRegistry.register({ - sessionId: 'test-session', +/** + * A real write-authority registry pre-registered with a lenient authority for + * the test session, so persistence writes pass the fencing check without a + * kernel lease. + */ +export function stubWriteAuthorityRegistry(sessionId = 'test-session'): IWriteAuthorityRegistry { + const registry = new WriteAuthorityRegistryService(); + registry.register({ + sessionId, assertWritable: () => {}, }); + return registry; +} + +export function createAgentTaskPersistence(homedir: string): AgentTaskPersistence { + const storage = new FileStorageService(homedir); return new AgentTaskPersistence( join(homedir, TASK_TEST_AGENT_SCOPE), TASK_TEST_AGENT_SCOPE, new JsonAtomicDocumentStore(storage), storage, undefined, - authorityRegistry, + stubWriteAuthorityRegistry(), ); } diff --git a/packages/agent-core-v2/test/agent/task/taskService.test.ts b/packages/agent-core-v2/test/agent/task/taskService.test.ts index b90b0d1eba..828223918f 100644 --- a/packages/agent-core-v2/test/agent/task/taskService.test.ts +++ b/packages/agent-core-v2/test/agent/task/taskService.test.ts @@ -48,7 +48,7 @@ import { InMemoryStorageService } from '#/persistence/backends/memory/inMemorySt import { stubContextMemory } from '../contextMemory/stubs'; import { stubLoopWithHooks } from '../loop/stubs'; -import type { TaskServiceTestManager } from './stubs'; +import { stubWriteAuthorityRegistry, type TaskServiceTestManager } from './stubs'; function fakeProcessTask(): AgentTask { return { @@ -149,10 +149,7 @@ describe('AgentTaskService', () => { flush: async () => {}, close: async () => {}, }); - ix.stub(IWriteAuthorityRegistry, { - resolve: () => ({ sessionId: 'test-session', assertWritable: () => {} }), - register: () => toDisposable(() => {}), - }); + ix.stub(IWriteAuthorityRegistry, stubWriteAuthorityRegistry()); ix.set(IAgentTaskService, new SyncDescriptor(AgentTaskService)); }); afterEach(() => disposables.dispose()); @@ -501,10 +498,7 @@ describe('AgentTaskService', () => { ); ix.stub(IAtomicDocumentStore, docs); ix.stub(IFileSystemStorageService, bytes); - ix.stub(IWriteAuthorityRegistry, { - resolve: () => ({ sessionId: 'test-session', assertWritable: () => {} }), - register: () => toDisposable(() => {}), - }); + ix.stub(IWriteAuthorityRegistry, stubWriteAuthorityRegistry()); ix.set(IAgentTaskService, new SyncDescriptor(AgentTaskService)); return ix; } diff --git a/packages/agent-core-v2/test/app/sessionLifecycle/sessionLifecycle.test.ts b/packages/agent-core-v2/test/app/sessionLifecycle/sessionLifecycle.test.ts index 6e7e5e509d..275aee0a09 100644 --- a/packages/agent-core-v2/test/app/sessionLifecycle/sessionLifecycle.test.ts +++ b/packages/agent-core-v2/test/app/sessionLifecycle/sessionLifecycle.test.ts @@ -413,15 +413,6 @@ function tick(): Promise { return new Promise((resolve) => setTimeout(resolve, 0)); } -async function waitFor(cond: () => boolean, timeoutMs = 5_000): Promise { - const start = Date.now(); - for (;;) { - if (cond()) return; - if (Date.now() - start > timeoutMs) throw new Error('waitFor timed out'); - await new Promise((r) => setTimeout(r, 10)); - } -} - let disposedSessionServices = 0; class NoopSessionExternalHooksService diff --git a/packages/agent-core-v2/test/harness/agent.ts b/packages/agent-core-v2/test/harness/agent.ts index 6031e87bf5..c1b23f98a6 100644 --- a/packages/agent-core-v2/test/harness/agent.ts +++ b/packages/agent-core-v2/test/harness/agent.ts @@ -140,15 +140,13 @@ import { ISessionQuestionService, type QuestionResult } from '#/session/question import { ISessionSkillCatalog } from '#/session/sessionSkillCatalog/skillCatalog'; import { ISessionSwarmService } from '#/session/swarm/sessionSwarm'; import type { PathAccessOperation } from '#/session/workspaceContext/workspaceContext'; -import { WriteAuthorityRegistryService } from '#/persistence/backends/node-fs/writeAuthorityRegistryService'; import { IWriteAuthorityRegistry } from '#/persistence/interface/writeAuthority'; -import { - IHostFsWatchService, - type HostFsChange, -} from '#/os/interface/hostFsWatch'; +import { IHostFsWatchService } from '#/os/interface/hostFsWatch'; import { recordAgentEvents, type RecordedEventEntry } from '../snapshot/events'; import { createFakeHostFs, createFakeProcessRunner } from '../tools/fixtures/fake-exec'; +import { stubWriteAuthorityRegistry } from '../agent/task/stubs'; +import { fakeHostFsWatch } from '../session/sessionFs/stubs'; import { stubSessionLeaseService } from '../session/sessionLease/stubs'; import { createScriptedGenerate } from './scripted-generate'; import { @@ -588,15 +586,6 @@ const noopHookRunner: IExternalHooksRunnerService = { fireAndForgetTrigger: async () => [], }; -const noopHostFsWatchService: IHostFsWatchService = { - _serviceBrand: undefined, - watch: () => ({ - ready: Promise.resolve(), - onDidChange: Event.None as Event, - dispose: () => {}, - }), -}; - export function permissionModeServices(mode: PermissionMode): TestAgentServiceOverride { return agentService(IAgentPermissionModeService, createPermissionModeService(mode)); } @@ -944,13 +933,8 @@ export class AgentTestContext { })) { reg.defineInstance(id, value); } - const authorityRegistry = new WriteAuthorityRegistryService(); - authorityRegistry.register({ - sessionId, - assertWritable: () => {}, - }); - reg.defineInstance(IWriteAuthorityRegistry, authorityRegistry); - reg.defineInstance(IHostFsWatchService, noopHostFsWatchService); + reg.defineInstance(IWriteAuthorityRegistry, stubWriteAuthorityRegistry(sessionId)); + reg.defineInstance(IHostFsWatchService, fakeHostFsWatch().service); const memoryStorage = (): SyncDescriptor => new SyncDescriptor(InMemoryStorageService, [], true); reg.defineDescriptor(IFileSystemStorageService, memoryStorage()); diff --git a/packages/agent-core-v2/test/os/backends/node-local/crossProcessLockService.test.ts b/packages/agent-core-v2/test/os/backends/node-local/crossProcessLockService.test.ts index cd6bb799d0..73f0201f7a 100644 --- a/packages/agent-core-v2/test/os/backends/node-local/crossProcessLockService.test.ts +++ b/packages/agent-core-v2/test/os/backends/node-local/crossProcessLockService.test.ts @@ -112,11 +112,13 @@ describe('CrossProcessLockService', () => { handles.push(first); setTimeout(() => first.release(), 20); - const second = await service('beta', 2002).acquireWithWait(lockPath, { - wait: { timeoutMs: 500, retryIntervalMs: 5 }, - }); - handles.push(second); - expect(second.checkHeld()).toBe(true); + await service('beta', 2002).withLock( + lockPath, + { wait: { timeoutMs: 500, retryIntervalMs: 5 } }, + (handle) => { + expect(handle.checkHeld()).toBe(true); + }, + ); }); it('times out waiting for a held lock', async () => { @@ -124,9 +126,7 @@ describe('CrossProcessLockService', () => { handles.push(first); await expect( - service('beta', 2002).acquireWithWait(lockPath, { - wait: { timeoutMs: 15, retryIntervalMs: 5 }, - }), + service('beta', 2002).withLock(lockPath, { wait: { timeoutMs: 15, retryIntervalMs: 5 } }, () => {}), ).rejects.toMatchObject({ code: CrossProcessLockErrorCode.WaitTimeout }); }); @@ -144,13 +144,12 @@ describe('CrossProcessLockService', () => { }); const result = await waiter - .acquireWithWait(lockPath, { wait: { timeoutMs: 10, retryIntervalMs: 100 } }) + .withLock(lockPath, { wait: { timeoutMs: 10, retryIntervalMs: 100 } }, () => {}) .then( - (handle) => ({ status: 'acquired' as const, handle }), + () => ({ status: 'acquired' as const }), (error: unknown) => ({ status: 'rejected' as const, error }), ); - if (result.status === 'acquired') handles.push(result.handle); expect(sleepDurations).toEqual([10]); expect(result.status).toBe('rejected'); if (result.status === 'rejected') { diff --git a/packages/agent-core-v2/test/os/stubs.ts b/packages/agent-core-v2/test/os/stubs.ts index f52a160eaa..92b0234c7b 100644 --- a/packages/agent-core-v2/test/os/stubs.ts +++ b/packages/agent-core-v2/test/os/stubs.ts @@ -42,7 +42,6 @@ export function stubCrossProcessLock(): ICrossProcessLockService { return { _serviceBrand: undefined, acquire: (lockPath) => Promise.resolve(acquireHandle(lockPath)), - acquireWithWait: (lockPath) => Promise.resolve(acquireHandle(lockPath)), withLock: async ( lockPath: string, _options: Parameters[1], diff --git a/packages/agent-core-v2/test/session/sessionFileLedger/fileLedger.test.ts b/packages/agent-core-v2/test/session/sessionFileLedger/fileLedger.test.ts index 50d880bae5..0a2399d671 100644 --- a/packages/agent-core-v2/test/session/sessionFileLedger/fileLedger.test.ts +++ b/packages/agent-core-v2/test/session/sessionFileLedger/fileLedger.test.ts @@ -15,42 +15,16 @@ import { afterEach, describe, expect, it } from 'vitest'; import { LifecycleScope } from '#/_base/di/scope'; import { createScopedTestHost, stubPair, type ScopedTestHost } from '#/_base/di/test'; import { unwrapErrorCause } from '#/_base/errors/errors'; -import { HostFileSystem } from '#/os/backends/node-local/hostFsService'; import { IHostFileSystem } from '#/os/interface/hostFileSystem'; import { IHostFsWatchService } from '#/os/interface/hostFsWatch'; import { ISessionContext, makeSessionContext } from '#/session/sessionContext/sessionContext'; import { ISessionFileLedger } from '#/session/sessionFileLedger/fileLedger'; import { SessionFileLedger } from '#/session/sessionFileLedger/fileLedgerService'; -import { fakeHostFsWatch, type FakeWatch } from '../sessionFs/stubs'; +import { countingHostFs, fakeHostFsWatch, type FakeWatch } from '../sessionFs/stubs'; void SessionFileLedger; -function countingHostFs(poisonedPaths: Set): { - fs: IHostFileSystem; - statCalls: () => number; -} { - const real = new HostFileSystem(); - let count = 0; - const fs = new Proxy(real, { - get(target, prop, receiver) { - if (prop === 'stat') { - return async (path: string) => { - count += 1; - if (poisonedPaths.has(path)) { - const err = new Error(`EACCES: permission denied, stat '${path}'`) as NodeJS.ErrnoException; - err.code = 'EACCES'; - throw err; - } - return target.stat(path); - }; - } - return Reflect.get(target, prop, receiver); - }, - }) as IHostFileSystem; - return { fs, statCalls: () => count }; -} - interface World { readonly workDir: string; readonly outsideDir: string; @@ -151,7 +125,6 @@ describe('SessionFileLedger', () => { world.fake.fire('a.txt', 'modified'); expect(await world.ledger.compare(file)).toBe('no-baseline'); - expect(world.fake.watchCalls).toEqual([]); }); it('returns stale when a baselined file is modified outside the session', async () => { @@ -179,17 +152,6 @@ describe('SessionFileLedger', () => { expect(world.statCalls()).toBe(3); }); - it('detects an outside modification using only the stat tuple', async () => { - const world = makeWorld(); - const file = join(world.workDir, 'a.txt'); - writeFileSync(file, 'hello'); - await recordCurrentBaseline(world, file); - - writeFileSync(file, 'hello world'); - - expect(await world.ledger.compare(file)).toBe('stale'); - }); - it('tracks a write-then-delete baseline as non-existence', async () => { const world = makeWorld(); const file = join(world.workDir, 'a.txt'); @@ -206,7 +168,7 @@ describe('SessionFileLedger', () => { expect(await world.ledger.compare(file)).toBe('stale'); }); - it('uses the same stat-only comparison outside the workspace', async () => { + it('uses the same stat-only comparison outside the workspace, never starting a watcher', async () => { const world = makeWorld(); const file = join(world.outsideDir, 'b.txt'); writeFileSync(file, 'hello'); @@ -224,16 +186,6 @@ describe('SessionFileLedger', () => { rmSync(file); expect(await world.ledger.compare(file)).toBe('stale'); - }); - - it('never starts a watcher when recording or comparing paths', async () => { - const world = makeWorld(); - const file = join(world.outsideDir, 'b.txt'); - writeFileSync(file, 'hello'); - - expect(await world.ledger.compare(file)).toBe('no-baseline'); - await recordCurrentBaseline(world, file); - expect(await world.ledger.compare(file)).toBe('clean'); expect(world.fake.watchCalls).toEqual([]); }); diff --git a/packages/agent-core-v2/test/session/sessionFs/stubs.ts b/packages/agent-core-v2/test/session/sessionFs/stubs.ts index 7d963e98c1..bf361d0fc8 100644 --- a/packages/agent-core-v2/test/session/sessionFs/stubs.ts +++ b/packages/agent-core-v2/test/session/sessionFs/stubs.ts @@ -1,38 +1,38 @@ /** - * `sessionFs` test stubs — controllable multi-handle fake host watcher. + * `sessionFs` test stubs — controllable fake host watcher and stat-counting + * host filesystem. * * `fakeHostFsWatch()` mirrors `IHostFsWatchService.watch()` semantics (one * independent handle per call) without touching the real filesystem: tests - * fire synthetic changes at a chosen handle and advance the debounce window - * with fake timers. Import from a relative path (`./stubs` or - * `../sessionFs/stubs`). + * fire synthetic changes at the most recent handle and advance the debounce + * window with fake timers. `countingHostFs()` wraps a real `HostFileSystem` + * and counts `stat` calls, optionally failing chosen paths with `EACCES`. + * Import from a relative path (`./stubs` or `../sessionFs/stubs`). */ import { join } from 'node:path'; +import { HostFileSystem } from '#/os/backends/node-local/hostFsService'; +import { IHostFileSystem } from '#/os/interface/hostFileSystem'; import { type HostFsChange, type IHostFsWatchHandle, IHostFsWatchService, } from '#/os/interface/hostFsWatch'; -export interface FakeWatchHandle { - readonly root: string; - fire: (rel: string, action: HostFsChange['action'], kind?: HostFsChange['kind']) => void; - readonly disposed: () => boolean; -} - export interface FakeWatch { readonly service: IHostFsWatchService; readonly watchCalls: string[]; - readonly handles: FakeWatchHandle[]; fire: (rel: string, action: HostFsChange['action'], kind?: HostFsChange['kind']) => void; readonly disposed: () => boolean; } export function fakeHostFsWatch(): FakeWatch { const watchCalls: string[] = []; - const handles: FakeWatchHandle[] = []; + const handles: Array<{ + fire: (rel: string, action: HostFsChange['action'], kind?: HostFsChange['kind']) => void; + disposed: () => boolean; + }> = []; const service: IHostFsWatchService = { _serviceBrand: undefined, watch: (path) => { @@ -51,7 +51,6 @@ export function fakeHostFsWatch(): FakeWatch { }, }; handles.push({ - root: path, fire: (rel, action, kind = 'file') => listener?.({ path: join(path, rel), action, kind }), disposed: () => disposed, @@ -62,8 +61,32 @@ export function fakeHostFsWatch(): FakeWatch { return { service, watchCalls, - handles, fire: (rel, action, kind = 'file') => handles.at(-1)?.fire(rel, action, kind), disposed: () => handles.every((h) => h.disposed()), }; } + +export function countingHostFs(poisonedPaths?: Set): { + fs: IHostFileSystem; + statCalls: () => number; +} { + const real = new HostFileSystem(); + let count = 0; + const fs = new Proxy(real, { + get(target, prop, receiver) { + if (prop === 'stat') { + return async (path: string) => { + count += 1; + if (poisonedPaths?.has(path)) { + const err = new Error(`EACCES: permission denied, stat '${path}'`) as NodeJS.ErrnoException; + err.code = 'EACCES'; + throw err; + } + return target.stat(path); + }; + } + return Reflect.get(target, prop, receiver); + }, + }) as IHostFileSystem; + return { fs, statCalls: () => count }; +} diff --git a/packages/agent-core-v2/test/session/sessionSkillCatalog/skillCatalog.test.ts b/packages/agent-core-v2/test/session/sessionSkillCatalog/skillCatalog.test.ts index ffb90e251f..276b090cf1 100644 --- a/packages/agent-core-v2/test/session/sessionSkillCatalog/skillCatalog.test.ts +++ b/packages/agent-core-v2/test/session/sessionSkillCatalog/skillCatalog.test.ts @@ -34,119 +34,16 @@ import { ISessionSkillCatalog } from '#/session/sessionSkillCatalog/skillCatalog import { IPluginSkillSource } from '#/session/sessionSkillCatalog/pluginSkillSource'; import { ISkillDiscovery } from '#/app/skillCatalog/skillDiscovery'; import type { SkillRoot } from '#/app/skillCatalog/types'; -import { IHostFsWatchService, type IHostFsWatchHandle } from '#/os/interface/hostFsWatch'; +import { IHostFsWatchService } from '#/os/interface/hostFsWatch'; import { stubBootstrap } from '../../app/bootstrap/stubs'; import { stubSkill } from '../../app/skillCatalog/stubs'; import { stubProviderService } from '../../app/provider/stubs'; +import { fakeHostFsWatch } from '../sessionFs/stubs'; +import { configStub, pluginStub, workspaceStub } from './stubs'; const bootstrapStub = stubBootstrap('/home'); -function hostFsWatchStub(): IHostFsWatchService { - return { - _serviceBrand: undefined, - watch: (): IHostFsWatchHandle => ({ - ready: Promise.resolve(), - onDidChange: (): { dispose(): void } => ({ dispose: () => {} }), - dispose: () => {}, - }), - }; -} - -function configStub(): IConfigService & { - setExtraSkillDirs(dirs: readonly string[]): void; - setMergeAllAvailableSkills(value: boolean): void; - fireSectionChange(domain: string): void; -} { - let extraSkillDirs: readonly string[] = []; - let mergeAllAvailableSkills = true; - const sectionChangeListeners: Array<(event: unknown) => void> = []; - return { - _serviceBrand: undefined, - ready: Promise.resolve(), - onDidChangeConfiguration: () => ({ dispose: () => {} }), - onDidSectionChange: (listener: (event: unknown) => void) => { - sectionChangeListeners.push(listener); - return { dispose: () => {} }; - }, - get: (domain: string) => { - if (domain === EXTRA_SKILL_DIRS_SECTION) return [...extraSkillDirs]; - if (domain === MERGE_ALL_AVAILABLE_SKILLS_SECTION) return mergeAllAvailableSkills; - return undefined; - }, - inspect: () => ({ value: undefined, defaultValue: undefined, userValue: undefined, memoryValue: undefined }), - getAll: () => ({}), - set: async () => {}, - replace: async () => {}, - reload: async () => {}, - diagnostics: () => [], - setExtraSkillDirs: (dirs: readonly string[]) => { - extraSkillDirs = [...dirs]; - }, - setMergeAllAvailableSkills: (value: boolean) => { - mergeAllAvailableSkills = value; - }, - fireSectionChange: (domain: string) => { - for (const listener of sectionChangeListeners) { - listener({ domain, source: 'set', value: undefined, previousValue: undefined }); - } - }, - } as unknown as IConfigService & { - setExtraSkillDirs(dirs: readonly string[]): void; - setMergeAllAvailableSkills(value: boolean): void; - fireSectionChange(domain: string): void; - }; -} - -function pluginStub( - skillRoots: readonly SkillRoot[] = [], - reloadEmitter?: Emitter, -): IPluginService { - return { - _serviceBrand: undefined, - onDidReload: reloadEmitter !== undefined ? reloadEmitter.event : () => ({ dispose: () => {} }), - listPlugins: async () => [], - installPlugin: async () => ({ id: '' }) as never, - setPluginEnabled: async () => {}, - setPluginMcpServerEnabled: async () => {}, - removePlugin: async () => {}, - reloadPlugins: async () => ({ added: [], removed: [], errors: [] }), - getPluginInfo: async () => { - throw new Error('getPluginInfo is not used by these tests'); - }, - listPluginCommands: async () => [], - checkUpdates: async () => [], - pluginSkillRoots: async () => skillRoots, - enabledSessionStarts: async () => [], - enabledMcpServers: async () => ({}), - enabledHooks: async () => [], - }; -} - -function workspaceStub(workDir: string): { - readonly stub: ISessionWorkspaceContext; - setWorkDir(dir: string): void; -} { - let current = workDir; - const stub = { - _serviceBrand: undefined, - get workDir() { - return current; - }, - additionalDirs: [] as readonly string[], - setWorkDir: (dir: string) => { - current = dir; - }, - setAdditionalDirs: () => {}, - resolve: (rel: string) => rel, - isWithin: () => true, - assertAllowed: (p: string) => p, - addAdditionalDir: () => {}, - removeAdditionalDir: () => {}, - } satisfies ISessionWorkspaceContext; - return { stub, setWorkDir: (dir) => { current = dir; } }; -} - function makeHost( store: ISkillDiscovery, ws: ISessionWorkspaceContext, @@ -165,7 +62,7 @@ function makeHost( stubPair(IConfigService, config), stubPair(ISkillCatalogRuntimeOptions, runtimeOptions), stubPair(IPluginService, pluginStub(pluginRoots, pluginReloadEmitter)), - stubPair(IHostFsWatchService, hostFsWatchStub()), + stubPair(IHostFsWatchService, fakeHostFsWatch().service), ]); const session = host.child(LifecycleScope.Session, 's1', [stubPair(ISessionWorkspaceContext, ws)]); return { host, session, config }; @@ -341,7 +238,7 @@ describe('SessionSkillCatalogService', () => { stubPair(IConfigService, config), stubPair(ISkillCatalogRuntimeOptions, runtimeOptions), stubPair(IPluginService, pluginStub()), - stubPair(IHostFsWatchService, hostFsWatchStub()), + stubPair(IHostFsWatchService, fakeHostFsWatch().service), ]); const session = host.child(LifecycleScope.Session, 's1', [stubPair(ISessionWorkspaceContext, ws)]); @@ -381,7 +278,7 @@ describe('SessionSkillCatalogService', () => { stubPair(IConfigService, config), stubPair(ISkillCatalogRuntimeOptions, runtimeOptions), stubPair(IPluginService, pluginStub()), - stubPair(IHostFsWatchService, hostFsWatchStub()), + stubPair(IHostFsWatchService, fakeHostFsWatch().service), ]); const session = host.child(LifecycleScope.Session, 's1', [stubPair(ISessionWorkspaceContext, ws)]); @@ -599,7 +496,7 @@ describe('SessionSkillCatalogService', () => { _serviceBrand: undefined, } as unknown as ISkillCatalogRuntimeOptions), stubPair(IPluginService, pluginStub()), - stubPair(IHostFsWatchService, hostFsWatchStub()), + stubPair(IHostFsWatchService, fakeHostFsWatch().service), ]); const session = host.child(LifecycleScope.Session, 's1', [ stubPair(ISessionWorkspaceContext, ws), @@ -659,7 +556,7 @@ describe('SessionSkillCatalogService', () => { _serviceBrand: undefined, } as unknown as ISkillCatalogRuntimeOptions), stubPair(IPluginService, pluginService), - stubPair(IHostFsWatchService, hostFsWatchStub()), + stubPair(IHostFsWatchService, fakeHostFsWatch().service), ]); const session = host.child(LifecycleScope.Session, 's1', [ stubPair(ISessionWorkspaceContext, ws), @@ -718,7 +615,7 @@ describe('SessionSkillCatalogService', () => { _serviceBrand: undefined, } as unknown as ISkillCatalogRuntimeOptions), stubPair(IProviderService, stubProviderService()), - stubPair(IHostFsWatchService, hostFsWatchStub()), + stubPair(IHostFsWatchService, fakeHostFsWatch().service), ]); const { stub: ws } = workspaceStub('/work'); const session = host.child(LifecycleScope.Session, 's1', [ diff --git a/packages/agent-core-v2/test/session/sessionSkillCatalog/skillHotReload.test.ts b/packages/agent-core-v2/test/session/sessionSkillCatalog/skillHotReload.test.ts index 42d2193ca4..517b7c8ae1 100644 --- a/packages/agent-core-v2/test/session/sessionSkillCatalog/skillHotReload.test.ts +++ b/packages/agent-core-v2/test/session/sessionSkillCatalog/skillHotReload.test.ts @@ -24,10 +24,6 @@ import { IBootstrapService } from '#/app/bootstrap/bootstrap'; import { IConfigService } from '#/app/config/config'; import { ILogService } from '#/_base/log/log'; import { IPluginService } from '#/app/plugin/plugin'; -import { - EXTRA_SKILL_DIRS_SECTION, - MERGE_ALL_AVAILABLE_SKILLS_SECTION, -} from '#/app/skillCatalog/configSection'; import { FileSkillDiscovery } from '#/app/skillCatalog/fileSkillDiscovery'; import { ISkillDiscovery } from '#/app/skillCatalog/skillDiscovery'; import { ISkillCatalogRuntimeOptions } from '#/app/skillCatalog/skillCatalogRuntimeOptions'; @@ -38,85 +34,10 @@ import { ISessionWorkspaceContext } from '#/session/workspaceContext/workspaceCo import { stubLog } from '../../_base/log/stubs'; import { stubBootstrap } from '../../app/bootstrap/stubs'; +import { configStub, pluginStub, workspaceStub } from './stubs'; const wait = (ms: number): Promise => new Promise((r) => setTimeout(r, ms)); -function configStub(): IConfigService & { - setExtraSkillDirs(dirs: readonly string[]): void; - fireSectionChange(domain: string): void; -} { - let extraSkillDirs: readonly string[] = []; - const sectionChangeListeners: Array<(event: unknown) => void> = []; - return { - _serviceBrand: undefined, - ready: Promise.resolve(), - onDidChangeConfiguration: () => ({ dispose: () => {} }), - onDidSectionChange: (listener: (event: unknown) => void) => { - sectionChangeListeners.push(listener); - return { dispose: () => {} }; - }, - get: (domain: string) => { - if (domain === EXTRA_SKILL_DIRS_SECTION) return [...extraSkillDirs]; - if (domain === MERGE_ALL_AVAILABLE_SKILLS_SECTION) return true; - return undefined; - }, - inspect: () => ({ value: undefined, defaultValue: undefined, userValue: undefined, memoryValue: undefined }), - getAll: () => ({}), - set: async () => {}, - replace: async () => {}, - reload: async () => {}, - diagnostics: () => [], - setExtraSkillDirs: (dirs: readonly string[]) => { - extraSkillDirs = [...dirs]; - }, - fireSectionChange: (domain: string) => { - for (const listener of sectionChangeListeners) { - listener({ domain, source: 'set', value: undefined, previousValue: undefined }); - } - }, - } as unknown as IConfigService & { - setExtraSkillDirs(dirs: readonly string[]): void; - fireSectionChange(domain: string): void; - }; -} - -function pluginStub(): IPluginService { - return { - _serviceBrand: undefined, - onDidReload: () => ({ dispose: () => {} }), - listPlugins: async () => [], - installPlugin: async () => ({ id: '' }) as never, - setPluginEnabled: async () => {}, - setPluginMcpServerEnabled: async () => {}, - removePlugin: async () => {}, - reloadPlugins: async () => ({ added: [], removed: [], errors: [] }), - getPluginInfo: async () => { - throw new Error('getPluginInfo is not used by these tests'); - }, - listPluginCommands: async () => [], - checkUpdates: async () => [], - pluginSkillRoots: async () => [], - enabledSessionStarts: async () => [], - enabledMcpServers: async () => ({}), - enabledHooks: async () => [], - }; -} - -function workspaceStub(workDir: string): ISessionWorkspaceContext { - return { - _serviceBrand: undefined, - workDir, - additionalDirs: [], - setWorkDir: () => {}, - setAdditionalDirs: () => {}, - resolve: (rel: string) => rel, - isWithin: () => true, - assertAllowed: (p: string) => p, - addAdditionalDir: () => {}, - removeAdditionalDir: () => {}, - } satisfies ISessionWorkspaceContext; -} - interface HotReloadFixture { readonly host: ScopedTestHost; readonly session: Scope; @@ -158,7 +79,7 @@ function makeHost( stubPair(IHostFsWatchService, new HostFsWatchService()), ]); const session = host.child(LifecycleScope.Session, 's1', [ - stubPair(ISessionWorkspaceContext, workspaceStub(workDir)), + stubPair(ISessionWorkspaceContext, workspaceStub(workDir).stub), stubPair(ILogService, stubLog()), ]); const catalog = session.accessor.get(ISessionSkillCatalog); diff --git a/packages/agent-core-v2/test/session/sessionSkillCatalog/stubs.ts b/packages/agent-core-v2/test/session/sessionSkillCatalog/stubs.ts new file mode 100644 index 0000000000..5391913b48 --- /dev/null +++ b/packages/agent-core-v2/test/session/sessionSkillCatalog/stubs.ts @@ -0,0 +1,114 @@ +/** + * `sessionSkillCatalog` test stubs — shared config / plugin / workspace + * fakes for the catalog scenario tests in this directory. + * + * `configStub()` serves the skill-catalog config sections in memory and can + * fire synthetic section changes; `pluginStub()` is an inert plugin service + * with optional skill roots and reload event; `workspaceStub()` is a mutable + * in-memory workspace context. Import from a relative path (`./stubs`). + */ + +import type { Emitter } from '#/_base/event'; +import { IConfigService } from '#/app/config/config'; +import { IPluginService } from '#/app/plugin/plugin'; +import type { ReloadSummary } from '#/app/plugin/types'; +import { + EXTRA_SKILL_DIRS_SECTION, + MERGE_ALL_AVAILABLE_SKILLS_SECTION, +} from '#/app/skillCatalog/configSection'; +import type { SkillRoot } from '#/app/skillCatalog/types'; +import { ISessionWorkspaceContext } from '#/session/workspaceContext/workspaceContext'; + +export function configStub(): IConfigService & { + setExtraSkillDirs(dirs: readonly string[]): void; + setMergeAllAvailableSkills(value: boolean): void; + fireSectionChange(domain: string): void; +} { + let extraSkillDirs: readonly string[] = []; + let mergeAllAvailableSkills = true; + const sectionChangeListeners: Array<(event: unknown) => void> = []; + return { + _serviceBrand: undefined, + ready: Promise.resolve(), + onDidChangeConfiguration: () => ({ dispose: () => {} }), + onDidSectionChange: (listener: (event: unknown) => void) => { + sectionChangeListeners.push(listener); + return { dispose: () => {} }; + }, + get: (domain: string) => { + if (domain === EXTRA_SKILL_DIRS_SECTION) return [...extraSkillDirs]; + if (domain === MERGE_ALL_AVAILABLE_SKILLS_SECTION) return mergeAllAvailableSkills; + return undefined; + }, + inspect: () => ({ value: undefined, defaultValue: undefined, userValue: undefined, memoryValue: undefined }), + getAll: () => ({}), + set: async () => {}, + replace: async () => {}, + reload: async () => {}, + diagnostics: () => [], + setExtraSkillDirs: (dirs: readonly string[]) => { + extraSkillDirs = [...dirs]; + }, + setMergeAllAvailableSkills: (value: boolean) => { + mergeAllAvailableSkills = value; + }, + fireSectionChange: (domain: string) => { + for (const listener of sectionChangeListeners) { + listener({ domain, source: 'set', value: undefined, previousValue: undefined }); + } + }, + } as unknown as IConfigService & { + setExtraSkillDirs(dirs: readonly string[]): void; + setMergeAllAvailableSkills(value: boolean): void; + fireSectionChange(domain: string): void; + }; +} + +export function pluginStub( + skillRoots: readonly SkillRoot[] = [], + reloadEmitter?: Emitter, +): IPluginService { + return { + _serviceBrand: undefined, + onDidReload: reloadEmitter !== undefined ? reloadEmitter.event : () => ({ dispose: () => {} }), + listPlugins: async () => [], + installPlugin: async () => ({ id: '' }) as never, + setPluginEnabled: async () => {}, + setPluginMcpServerEnabled: async () => {}, + removePlugin: async () => {}, + reloadPlugins: async () => ({ added: [], removed: [], errors: [] }), + getPluginInfo: async () => { + throw new Error('getPluginInfo is not used by these tests'); + }, + listPluginCommands: async () => [], + checkUpdates: async () => [], + pluginSkillRoots: async () => skillRoots, + enabledSessionStarts: async () => [], + enabledMcpServers: async () => ({}), + enabledHooks: async () => [], + }; +} + +export function workspaceStub(workDir: string): { + readonly stub: ISessionWorkspaceContext; + setWorkDir(dir: string): void; +} { + let current = workDir; + const stub = { + _serviceBrand: undefined, + get workDir() { + return current; + }, + additionalDirs: [] as readonly string[], + setWorkDir: (dir: string) => { + current = dir; + }, + setAdditionalDirs: () => {}, + resolve: (rel: string) => rel, + isWithin: () => true, + assertAllowed: (p: string) => p, + addAdditionalDir: () => {}, + removeAdditionalDir: () => {}, + } satisfies ISessionWorkspaceContext; + return { stub, setWorkDir: (dir) => { current = dir; } }; +} diff --git a/packages/kap-server/src/instanceRegistry.ts b/packages/kap-server/src/instanceRegistry.ts index 233c2e1b17..5d044fc78f 100644 --- a/packages/kap-server/src/instanceRegistry.ts +++ b/packages/kap-server/src/instanceRegistry.ts @@ -19,6 +19,7 @@ import { mkdir, open, readdir, readFile, rename, unlink } from 'node:fs/promises import { dirname, join } from 'node:path'; import { resolveKimiHome } from '@moonshot-ai/agent-core-v2'; +import { syncDir } from '@moonshot-ai/agent-core-v2/_base/utils/fs'; import { ulid } from 'ulid'; /** Default cadence for refreshing `heartbeat_at`. */ @@ -147,17 +148,6 @@ async function readInstanceFile(filePath: string): Promise { - if (process.platform === 'win32') return; - const dirFh = await open(dirPath, 'r'); - try { - await dirFh.sync(); - } finally { - await dirFh.close(); - } -} - /** Atomic (rename-based), durable write. Single-writer per file, so no lock is needed. */ async function writeFileAtomic(filePath: string, content: string): Promise { const tmpPath = `${filePath}.tmp.${process.pid}.${randomBytes(4).toString('hex')}`; diff --git a/packages/kap-server/src/routes/sessions.ts b/packages/kap-server/src/routes/sessions.ts index e3113c1efc..288b0e35c1 100644 --- a/packages/kap-server/src/routes/sessions.ts +++ b/packages/kap-server/src/routes/sessions.ts @@ -102,6 +102,7 @@ import { IWorkspaceRegistry, isError2, Error2, + heldByPeerDetailsFromInspection, sessionLeasePath, toProtocolMessage, type ContextMessage, @@ -1170,11 +1171,11 @@ function resolveSessionOwnership( const inspection = core .accessor.get(ICrossProcessLockService) .inspect(sessionLeasePath(homeDir, sessionId)); - if (inspection.state === 'held' || inspection.state === 'creating') { - const address = inspection.payload?.address; - return address !== undefined ? { held_by: 'peer', address } : { held_by: 'peer' }; - } - return { held_by: 'none' }; + const heldByPeer = heldByPeerDetailsFromInspection(inspection); + if (heldByPeer === undefined) return { held_by: 'none' }; + return heldByPeer.address !== undefined + ? { held_by: 'peer', address: heldByPeer.address } + : { held_by: 'peer' }; } /** diff --git a/packages/kap-server/src/routes/snapshot.ts b/packages/kap-server/src/routes/snapshot.ts index ee3a507a02..ca5c1c03f1 100644 --- a/packages/kap-server/src/routes/snapshot.ts +++ b/packages/kap-server/src/routes/snapshot.ts @@ -196,8 +196,8 @@ async function readViaLegacyAssembly( return { as_of_seq: snapState.seq, - // A journal with no baseline yet has `epoch: undefined`; on the wire the - // field is simply absent (never fabricated). + // `epoch` is absent when the journal has no baseline (never fabricated) — + // see services/snapshot/snapshotReader.ts. epoch: snapState.epoch, session, messages: { items, has_more: hasMore }, diff --git a/packages/kap-server/src/transport/ws/v1/fsWatchBridge.ts b/packages/kap-server/src/transport/ws/v1/fsWatchBridge.ts index 5f60197c8d..e4978c5653 100644 --- a/packages/kap-server/src/transport/ws/v1/fsWatchBridge.ts +++ b/packages/kap-server/src/transport/ws/v1/fsWatchBridge.ts @@ -14,16 +14,10 @@ * {@link SessionEventBroadcaster}); it is **not** DI-registered and carries no * `_serviceBrand`. It owns the per-`(connection, session)` subscription sets, * fans the core feed out to each connection filtered by that connection's - * paths, and assigns a **per-connection monotonic `seq`**: each connection - * numbers only the frames actually delivered to it — matched change frames and - * `truncated` frames alike — starting at 1 with no gaps; a frame the - * connection never receives consumes nothing. A `seq` gap, a `truncated: true` - * payload, or an epoch change each invalidate the incremental stream: the - * client must fall back to a full baseline re-pull (snapshot + incremental + - * resync), per the wire contract pinned on `fsChangeEventSchema` - * (`@moonshot-ai/protocol` `fs.ts`). Frames are sent straight to the socket — - * they never enter the broadcaster / journal (fs changes are volatile: on - * overflow the client sees `truncated` and re-syncs). + * paths, and numbers the delivered frames per connection. Frames are sent + * straight to the socket — they never enter the broadcaster / journal. The + * `seq` / `truncated` / rebuild contract is pinned on `fsChangeEventSchema` in + * `@moonshot-ai/protocol` (`packages/protocol/src/fs.ts`) — see there. * * The core `ISessionFsWatchService` keeps a single subscription set per * session; the bridge drives it with the **union** of every connection's @@ -43,6 +37,7 @@ import { import type { FsChangeEntry, FsChangeEvent } from '@moonshot-ai/agent-core-v2/session/sessionFs/fsWatch'; import type { EventEnvelope, JournalLogger } from './sessionEventJournal'; +import { registerSessionReleaseHook } from './sessionReleaseHook'; const MAX_PATHS_PER_CONNECTION = 100; @@ -100,15 +95,11 @@ export class FsWatchBridge implements IDisposable { constructor(opts: { core: Scope; logger?: JournalLogger }) { this.core = opts.core; this.logger = opts.logger; - this.sessionReleaseHook = this.core.accessor - .get(ISessionLifecycleService) - .hooks.onWillReleaseSession.register( - 'fsWatchBridge', - async ({ sessionId }, next) => { - this.releaseSession(sessionId); - await next(); - }, - ); + this.sessionReleaseHook = registerSessionReleaseHook( + this.core, + 'fsWatchBridge', + (sessionId) => this.releaseSession(sessionId), + ); } dispose(): void { diff --git a/packages/kap-server/src/transport/ws/v1/sessionEventBroadcaster.ts b/packages/kap-server/src/transport/ws/v1/sessionEventBroadcaster.ts index e387045ed6..0a273ee3f1 100644 --- a/packages/kap-server/src/transport/ws/v1/sessionEventBroadcaster.ts +++ b/packages/kap-server/src/transport/ws/v1/sessionEventBroadcaster.ts @@ -94,6 +94,7 @@ import { SessionEventJournal, sessionJournalPath, } from './sessionEventJournal'; +import { registerSessionReleaseHook } from './sessionReleaseHook'; export type ResyncReason = 'buffer_overflow' | 'session_recreated' | 'epoch_changed'; @@ -251,15 +252,11 @@ export class SessionEventBroadcaster { this.coreEventSubscription = opts.core.accessor .get(IEventService) .subscribe((event) => this.onCoreEvent(event)); - this.sessionReleaseHook = opts.core.accessor - .get(ISessionLifecycleService) - .hooks.onWillReleaseSession.register( - 'sessionEventBroadcaster', - async ({ sessionId }, next) => { - await this.releaseSessionState(sessionId); - await next(); - }, - ); + this.sessionReleaseHook = registerSessionReleaseHook( + opts.core, + 'sessionEventBroadcaster', + (sessionId) => this.releaseSessionState(sessionId), + ); } /** diff --git a/packages/kap-server/src/transport/ws/v1/sessionReleaseHook.ts b/packages/kap-server/src/transport/ws/v1/sessionReleaseHook.ts new file mode 100644 index 0000000000..69f99b7e7d --- /dev/null +++ b/packages/kap-server/src/transport/ws/v1/sessionReleaseHook.ts @@ -0,0 +1,26 @@ +/** + * Shared registration for the `onWillReleaseSession` lifecycle hook used by the + * v1 WS transport pieces ({@link SessionEventBroadcaster}, {@link FsWatchBridge}, + * {@link SkillCatalogBridge}): each drops its own per-session state for the + * released session, then lets the release chain proceed. + */ + +import { + type IDisposable, + ISessionLifecycleService, + type Scope, +} from '@moonshot-ai/agent-core-v2'; + +/** Register `release(sessionId)` to run (before `next()`) when a session scope is released. */ +export function registerSessionReleaseHook( + core: Scope, + name: string, + release: (sessionId: string) => void | Promise, +): IDisposable { + return core.accessor + .get(ISessionLifecycleService) + .hooks.onWillReleaseSession.register(name, async ({ sessionId }, next) => { + await release(sessionId); + await next(); + }); +} diff --git a/packages/kap-server/src/transport/ws/v1/skillCatalogBridge.ts b/packages/kap-server/src/transport/ws/v1/skillCatalogBridge.ts index 3439090691..ccc695e814 100644 --- a/packages/kap-server/src/transport/ws/v1/skillCatalogBridge.ts +++ b/packages/kap-server/src/transport/ws/v1/skillCatalogBridge.ts @@ -28,6 +28,7 @@ import { type Scope, } from '@moonshot-ai/agent-core-v2'; import type { SkillCatalogChangedEvent } from './events'; +import { registerSessionReleaseHook } from './sessionReleaseHook'; import type { EventEnvelope, JournalLogger } from './sessionEventJournal'; @@ -68,15 +69,11 @@ export class SkillCatalogBridge implements IDisposable { constructor(opts: { core: Scope; logger?: JournalLogger }) { this.core = opts.core; this.logger = opts.logger; - this.sessionReleaseHook = this.core.accessor - .get(ISessionLifecycleService) - .hooks.onWillReleaseSession.register( - 'skillCatalogBridge', - async ({ sessionId }, next) => { - this.releaseSession(sessionId); - await next(); - }, - ); + this.sessionReleaseHook = registerSessionReleaseHook( + this.core, + 'skillCatalogBridge', + (sessionId) => this.releaseSession(sessionId), + ); } dispose(): void { diff --git a/packages/kernel-file-lock/src/index.ts b/packages/kernel-file-lock/src/index.ts index 7810a5cc61..4b117febfb 100644 --- a/packages/kernel-file-lock/src/index.ts +++ b/packages/kernel-file-lock/src/index.ts @@ -10,7 +10,6 @@ export interface KernelFileLockBinding { export type KernelFileLockBindingLoader = () => KernelFileLockBinding | undefined; export interface KernelFileLockHandle { - readonly path: string; checkHeld(): boolean; release(): void; } @@ -39,7 +38,7 @@ class KernelFileLockHandleImpl implements KernelFileLockHandle { private released = false; constructor( - readonly path: string, + private readonly path: string, private readonly fd: number, private readonly binding: KernelFileLockBinding, ) {} diff --git a/packages/klient/test/e2e/harness/testing/serverPair.ts b/packages/klient/test/e2e/harness/testing/serverPair.ts index b0ace0ed3e..3d4f00cbf2 100644 --- a/packages/klient/test/e2e/harness/testing/serverPair.ts +++ b/packages/klient/test/e2e/harness/testing/serverPair.ts @@ -23,11 +23,7 @@ import { join } from 'node:path'; import type { RunningServer } from '@moonshot-ai/kap-server'; import { HttpClient } from '../http.js'; - -// `recursive` rm can hit ENOTEMPTY on macOS while the closing server is still -// flushing/unlinking its own files — retry briefly (same trick as the v2 -// smoke test's home cleanup). -const RM_HOME_OPTIONS = { recursive: true, force: true, maxRetries: 5, retryDelay: 100 } as const; +import { RM_HOME_OPTIONS } from './serverProcess.js'; export interface ServerPairOptions { /** Shared home for both instances. Created via `mkdtemp` when omitted. */ diff --git a/packages/klient/test/e2e/harness/testing/serverProcess.ts b/packages/klient/test/e2e/harness/testing/serverProcess.ts index 2d6110ec08..9eb9dfca5b 100644 --- a/packages/klient/test/e2e/harness/testing/serverProcess.ts +++ b/packages/klient/test/e2e/harness/testing/serverProcess.ts @@ -36,6 +36,7 @@ import { dirname, join, resolve } from 'node:path'; import { createInterface } from 'node:readline'; import { fileURLToPath, pathToFileURL } from 'node:url'; +import { sleep } from '../wait.js'; import { SPAWN_SERVER_HOME_ENV, type SpawnServerMessage, @@ -51,7 +52,7 @@ const STOP_GRACE_MS = 10_000; const STDERR_TAIL_LIMIT = 8_192; // `recursive` rm can hit ENOTEMPTY on macOS while the closing server is still // flushing/unlinking its own files — retry briefly. -const RM_HOME_OPTIONS = { recursive: true, force: true, maxRetries: 5, retryDelay: 100 } as const; +export const RM_HOME_OPTIONS = { recursive: true, force: true, maxRetries: 5, retryDelay: 100 } as const; export interface SpawnServerProcessOptions { /** Home directory for the child server. Created via `mkdtemp` when omitted. */ @@ -315,6 +316,21 @@ function raceExit(exited: Promise, timeoutMs: number): Promise true), sleep(timeoutMs).then(() => false)]); } -function sleep(ms: number): Promise { - return new Promise((resolvePromise) => setTimeout(resolvePromise, ms)); +export function pidAlive(pid: number): boolean { + try { + process.kill(pid, 0); + return true; + } catch (error) { + return (error as NodeJS.ErrnoException).code === 'EPERM'; + } +} + +/** True once `pid` is fully gone (ESRCH — zombies reaped), false on timeout. */ +export async function waitForPidExit(pid: number, timeoutMs: number): Promise { + const deadline = Date.now() + timeoutMs; + while (Date.now() < deadline) { + if (!pidAlive(pid)) return true; + await sleep(50); + } + return !pidAlive(pid); } diff --git a/packages/klient/test/e2e/harness/wait.ts b/packages/klient/test/e2e/harness/wait.ts index e70cadb7a8..7983d81cfd 100644 --- a/packages/klient/test/e2e/harness/wait.ts +++ b/packages/klient/test/e2e/harness/wait.ts @@ -51,6 +51,6 @@ export async function waitForSessionBusy( ); } -function sleep(ms: number): Promise { +export function sleep(ms: number): Promise { return new Promise((resolve) => setTimeout(resolve, ms)); } diff --git a/packages/klient/test/e2e/legacy/dual-instance.test.ts b/packages/klient/test/e2e/legacy/dual-instance.test.ts index 6014989ff1..89bd6da900 100644 --- a/packages/klient/test/e2e/legacy/dual-instance.test.ts +++ b/packages/klient/test/e2e/legacy/dual-instance.test.ts @@ -40,9 +40,11 @@ import { describe, expect, it } from 'vitest'; import { HttpClient } from '../harness/http.js'; import { + pidAlive, spawnServerProcess, spawnServerProcessPair, startServerPair, + waitForPidExit, waitForServerHealthy, } from '../harness/testing/index.js'; import { createCaseLogger, errorForLog } from './log.js'; @@ -225,21 +227,3 @@ const KNOWN_CLOSE_GAP = 'writeAuthorityRegistry'; function isKnownCloseGap(error: unknown): boolean { return error instanceof Error && error.message.includes(KNOWN_CLOSE_GAP); } - -function pidAlive(pid: number): boolean { - try { - process.kill(pid, 0); - return true; - } catch (error) { - return (error as NodeJS.ErrnoException).code === 'EPERM'; - } -} - -async function waitForPidExit(pid: number, timeoutMs: number): Promise { - const deadline = Date.now() + timeoutMs; - while (Date.now() < deadline) { - if (!pidAlive(pid)) return true; - await new Promise((resolvePromise) => setTimeout(resolvePromise, 50)); - } - return !pidAlive(pid); -} diff --git a/packages/klient/test/e2e/legacy/session-ownership.test.ts b/packages/klient/test/e2e/legacy/session-ownership.test.ts index 0f9db03f85..08d8d3b999 100644 --- a/packages/klient/test/e2e/legacy/session-ownership.test.ts +++ b/packages/klient/test/e2e/legacy/session-ownership.test.ts @@ -1,6 +1,6 @@ /** * Phase-2 session-ownership verification matrix — multi-process e2e over real - * REST boundaries (design `.tmp/refactor-watch-design-v2.md` §3.10). + * REST boundaries (design §3.10). * * One session write lease per session under `/session-leases/.lock`. * The permanent sentinel is protected by a kernel lock; the sibling @@ -33,19 +33,20 @@ import { readdir, readFile } from 'node:fs/promises'; import { join } from 'node:path'; import { sessionLeasePath } from '@moonshot-ai/agent-core-v2'; -import { ErrorCode, sessionOwnershipDetailsSchema } from '@moonshot-ai/protocol'; +import { ErrorCode, sessionOwnershipDetailsSchema, type Envelope } from '@moonshot-ai/protocol'; import { describe, expect, it } from 'vitest'; -import { DaemonClient } from '../harness/index.js'; +import { DaemonClient, HttpClient } from '../harness/index.js'; import { + pidAlive, spawnServerProcessPair, startServerPair, + waitForPidExit, waitForServerHealthy, } from '../harness/testing/index.js'; +import { sleep } from '../harness/wait.js'; import { createCaseLogger } from './log.js'; -const SESSION_OWNERSHIP_HELD_BY_PEER = 40921; - describe('session ownership: concurrent dual materialization race (in-process pair)', () => { it( 'holder serves, peer gets 40921 routable every round, no torn JSONL on disk', @@ -54,7 +55,9 @@ describe('session ownership: concurrent dual materialization race (in-process pa const log = createCaseLogger('session-ownership/materialization-race'); const pair = await startServerPair(); try { - const sessionId = await createSession(pair.urlA, pair.cwd); + const { id: sessionId } = await pair + .connectClient(pair.a) + .createSession({ metadata: { cwd: pair.cwd } }); log('session created on A', { sessionId, urlA: pair.urlA, urlB: pair.urlB }); const lease = await readLease(pair.home, sessionId); @@ -73,9 +76,8 @@ describe('session ownership: concurrent dual materialization race (in-process pa expect(a.body.code).toBe(0); expect(b.status).toBe(200); expect(b.body.code).toBe(ErrorCode.SESSION_HELD_BY_PEER); - expect(b.body.code).toBe(SESSION_OWNERSHIP_HELD_BY_PEER); const details = sessionOwnershipDetailsSchema.parse(b.body.details); - expect(details).toEqual({ kind: 'held-by-peer', phase: 'routable', address: pair.urlA }); + expect(details.kind).toBe('held-by-peer'); if (round === 1 || round === ROUNDS) { log(`concurrent round ${round}/${ROUNDS}`, { holder: { status: a.status, code: a.body.code }, @@ -117,7 +119,12 @@ describe('session ownership: SIGSTOP/SIGCONT (subprocess pair)', () => { waitForServerHealthy(pair.a.baseUrl, 15_000), waitForServerHealthy(pair.b.baseUrl, 15_000), ]); - const sessionId = await createSession(pair.a.baseUrl, pair.home); + const clientA = new HttpClient({ + baseUrl: pair.a.baseUrl, + apiPrefix: '/api/v1', + fetchImpl: fetch, + }); + const { id: sessionId } = await clientA.createSession({ metadata: { cwd: pair.home } }); const leaseA = await readLease(pair.home, sessionId); expect(leaseA?.['address']).toBe(pair.a.baseUrl); expect(leaseA?.['pid']).toBe(pair.a.pid); @@ -140,7 +147,7 @@ describe('session ownership: SIGSTOP/SIGCONT (subprocess pair)', () => { details, }); expect(b.status).toBe(200); - expect(b.body.code).toBe(SESSION_OWNERSHIP_HELD_BY_PEER); + expect(b.body.code).toBe(ErrorCode.SESSION_HELD_BY_PEER); expect(details).toEqual({ kind: 'held-by-peer', phase: 'routable', @@ -184,7 +191,7 @@ describe('session ownership: SIGSTOP/SIGCONT (subprocess pair)', () => { }, 'B remains 40921 routable after SIGCONT', 15_000, 500); log('B observations after SIGCONT', { transcript, final: routable }); for (const entry of transcript) { - expect(entry.code).toBe(SESSION_OWNERSHIP_HELD_BY_PEER); + expect(entry.code).toBe(ErrorCode.SESSION_HELD_BY_PEER); } expect(routable).toEqual({ kind: 'held-by-peer', @@ -229,7 +236,12 @@ describe('session ownership: kill -9 kernel release (subprocess pair)', () => { waitForServerHealthy(pair.a.baseUrl, 15_000), waitForServerHealthy(pair.b.baseUrl, 15_000), ]); - const sessionId = await createSession(pair.a.baseUrl, pair.home); + const clientA = new HttpClient({ + baseUrl: pair.a.baseUrl, + apiPrefix: '/api/v1', + fetchImpl: fetch, + }); + const { id: sessionId } = await clientA.createSession({ metadata: { cwd: pair.home } }); const leaseA = await readLease(pair.home, sessionId); expect(leaseA?.['address']).toBe(pair.a.baseUrl); expect(leaseA?.['pid']).toBe(pair.a.pid); @@ -288,8 +300,8 @@ describe('session ownership: kill -9 kernel release (subprocess pair)', () => { }); /** - * Multi-instance session-list sync (design `.tmp/refactor-watch-design-v2.md` - * §3.8): the event-plane hint plus the list-side ownership join. While the + * Multi-instance session-list sync (design §3.8): the event-plane hint plus + * the list-side ownership join. While the * ownership matrix above cross-checks dual-open refusals, these cases track a * session created on instance A as it surfaces on instance B: * @@ -327,7 +339,7 @@ describe('session list sync: session.list_changed hint + ownership join (in-proc try { // B owns a session so its client has something to subscribe to (global // volatile events fan out to subscribed connections only). - const ownSessionId = await createSession(pair.urlB, pair.cwd); + const { id: ownSessionId } = await client.createSession({ metadata: { cwd: pair.cwd } }); await client.connect(); await client.subscribe(ownSessionId); const off = client.onFrame((frame) => { @@ -339,7 +351,9 @@ describe('session list sync: session.list_changed hint + ownership join (in-proc const baseline = hints.length; const peerWorkspace = join(pair.home, 'peer-workspace'); mkdirSync(peerWorkspace, { recursive: true }); - const peerSessionId = await createSession(pair.urlA, peerWorkspace); + const { id: peerSessionId } = await pair + .connectClient(pair.a) + .createSession({ metadata: { cwd: peerWorkspace } }); const hint = await pollUntil( async () => (hints.length > baseline ? hints[hints.length - 1] : undefined), @@ -387,7 +401,7 @@ describe('session list sync: session.list_changed hint + ownership join (in-proc try { // B's own create establishes the workspace dir; B's watcher picks it up // (its own write triggers the same fs events a peer's write would). - const ownSessionId = await createSession(pair.urlB, pair.cwd); + const { id: ownSessionId } = await client.createSession({ metadata: { cwd: pair.cwd } }); // Give B's root watcher time to attach the per-workspace watcher — // otherwise A's write below could slip into the attach window and // produce no hint (the list would still converge on re-pull; the hint @@ -400,7 +414,9 @@ describe('session list sync: session.list_changed hint + ownership join (in-proc }); try { const baseline = hints.length; - const peerSessionId = await createSession(pair.urlA, pair.cwd); + const { id: peerSessionId } = await pair + .connectClient(pair.a) + .createSession({ metadata: { cwd: pair.cwd } }); await pollUntil( async () => (hints.length > baseline ? hints[hints.length - 1] : undefined), @@ -435,14 +451,6 @@ interface HintRecord { } // ── Local helpers ────────────────────────────────────────────────────────── -interface Envelope { - code: number; - msg: string; - data: T; - request_id?: string; - details?: unknown; -} - interface SessionWire { id: string; metadata?: { cwd?: string }; @@ -460,21 +468,6 @@ async function getEnvelope( return { status: res.status, body: (await res.json()) as Envelope }; } -async function createSession(baseUrl: string, cwd: string): Promise { - const res = await fetch(`${baseUrl}/api/v1/sessions`, { - method: 'POST', - headers: { 'content-type': 'application/json' }, - body: JSON.stringify({ metadata: { cwd } }), - }); - const body = (await res.json()) as Envelope<{ id: string }>; - if (res.status !== 200 || body.code !== 0) { - throw new Error( - `createSession failed (HTTP ${res.status}, code ${body.code}): ${JSON.stringify(body)}`, - ); - } - return body.data.id; -} - /** * macOS FSEvents delivery workaround for the in-process pair (see the * "session list sync" describe header). Two kap-server instances in one @@ -579,25 +572,6 @@ async function listJsonlFiles(root: string): Promise { .sort(); } -function pidAlive(pid: number): boolean { - try { - process.kill(pid, 0); - return true; - } catch (error) { - return (error as NodeJS.ErrnoException).code === 'EPERM'; - } -} - -/** True once `pid` is fully gone (ESRCH — zombies reaped), false on timeout. */ -async function waitForPidExit(pid: number, timeoutMs: number): Promise { - const deadline = Date.now() + timeoutMs; - while (Date.now() < deadline) { - if (!pidAlive(pid)) return true; - await sleep(50); - } - return !pidAlive(pid); -} - /** Probe returning undefined ⇒ keep polling; any other value ends the wait. */ async function pollUntil( probe: () => Promise, @@ -622,7 +596,3 @@ async function pollUntil( await sleep(intervalMs); } } - -function sleep(ms: number): Promise { - return new Promise((resolvePromise) => setTimeout(resolvePromise, ms)); -} diff --git a/packages/minidb/src/index.ts b/packages/minidb/src/index.ts index 9327a4ffc3..fd44fe92d6 100644 --- a/packages/minidb/src/index.ts +++ b/packages/minidb/src/index.ts @@ -375,7 +375,11 @@ export class MiniDb { db.valueReader?.close(); db.store?.close(); if (db.lock) { - await db.lock.release().catch(() => {}); + try { + db.lock.releaseSync(); + } catch { + // best-effort cleanup + } db.lock = null; } throw err; @@ -1647,7 +1651,7 @@ export class MiniDb { this.valueReader?.close(); await this.wal.close(); if (this.lock) { - await this.lock.release(); + this.lock.releaseSync(); this.lock = null; } } diff --git a/packages/minidb/src/lockfile.ts b/packages/minidb/src/lockfile.ts index 8d6605965b..e0afbb1700 100644 --- a/packages/minidb/src/lockfile.ts +++ b/packages/minidb/src/lockfile.ts @@ -42,10 +42,6 @@ export class LockFile { if (!this.checkHeld()) throw new LockError(`database write lock was lost: ${this.path}`); } - async release(): Promise { - this.releaseSync(); - } - releaseSync(): void { if (!this.held) return; this.held = false; @@ -55,10 +51,6 @@ export class LockFile { } private markLost(): void { - if (!this.held) return; - this.held = false; - const handle = this.handle; - this.handle = undefined; - handle?.release(); + this.releaseSync(); } } diff --git a/packages/minidb/test/defense.test.ts b/packages/minidb/test/defense.test.ts index e88f074bad..cb511cf5fb 100644 --- a/packages/minidb/test/defense.test.ts +++ b/packages/minidb/test/defense.test.ts @@ -115,44 +115,13 @@ test('WAL open and close are idempotent', async () => { // --- LockFile kernel ownership --------------------------------------------- -test('LockFile.acquire returns false when a live process holds the lock', async () => { - const dir = await tmpDir(); - const p = path.join(dir, 'db.lock'); - const a = new LockFile(p); - assert.equal(await a.acquire(), true); - const b = new LockFile(p); - assert.equal(await b.acquire(), false); - await a.release(); - await fs.rm(dir, { recursive: true, force: true }); -}); - test('arbitrary sentinel contents are ignored when no kernel lock is held', async () => { const dir = await tmpDir(); const p = path.join(dir, 'db.lock'); await fs.writeFile(p, 'not-json'); const b = new LockFile(p); assert.equal(await b.acquire(), true); - await b.release(); - await fs.rm(dir, { recursive: true, force: true }); -}); - -test('MiniDb opens over a legacy lock payload without renaming the sentinel', async () => { - const dir = await tmpDir(); - const p = path.join(dir, 'db.lock'); - await fs.writeFile(p, JSON.stringify({ pid: 0x7fffffff, ts: Date.now() })); - const db = await MiniDb.open({ dir, valueCodec: 'string' }); - await db.set('a', '1'); - assert.equal(db.get('a'), '1'); - await db.close(); - assert.equal((await fs.readdir(dir)).some((entry) => entry.includes('.stale.')), false); - await fs.rm(dir, { recursive: true, force: true }); -}); - -test('LockFile release/releaseSync are no-ops when not held', async () => { - const dir = await tmpDir(); - const lock = new LockFile(path.join(dir, 'db.lock')); - await assert.doesNotReject(() => lock.release()); - assert.doesNotThrow(() => lock.releaseSync()); + b.releaseSync(); await fs.rm(dir, { recursive: true, force: true }); }); diff --git a/packages/minidb/test/e2e/helpers/lock-racer.ts b/packages/minidb/test/e2e/helpers/lock-racer.ts index 51e26e2a8a..648b8de11b 100644 --- a/packages/minidb/test/e2e/helpers/lock-racer.ts +++ b/packages/minidb/test/e2e/helpers/lock-racer.ts @@ -53,6 +53,6 @@ for (let r = 0; r < rounds; r++) { await sleep(1); } } - await lf.release(); + lf.releaseSync(); } } diff --git a/packages/minidb/test/lock.test.ts b/packages/minidb/test/lock.test.ts index 84207da29e..1f9204b7fb 100644 --- a/packages/minidb/test/lock.test.ts +++ b/packages/minidb/test/lock.test.ts @@ -79,6 +79,7 @@ test('pre-existing sentinel contents do not imply ownership', async () => { await db.close(); assert.equal(await fs.readFile(lockPath, 'utf8'), JSON.stringify({ pid: process.pid, lock_id: 'legacy' })); + assert.equal((await fs.readdir(dir)).some((entry) => entry.includes('.stale.')), false); await cleanup(dir); }); @@ -90,35 +91,19 @@ test('LockFile uses kernel ownership and leaves the sentinel in place', async () assert.equal(await first.acquire(), true); assert.equal(await second.acquire(), false); - await first.release(); + first.releaseSync(); assert.equal(await fs.stat(lockPath).then(() => true), true); assert.equal(await second.acquire(), true); - await second.release(); + second.releaseSync(); await cleanup(dir); }); -test('rewriting sentinel contents cannot transfer a live lock', async () => { - const dir = await tmpDir(); - const lockPath = path.join(dir, 'db.lock'); - const first = new LockFile(lockPath); - const second = new LockFile(lockPath); - assert.equal(await first.acquire(), true); - - await fs.writeFile(lockPath, 'operator note'); - assert.doesNotThrow(() => first.assertHeld()); - assert.equal(await second.acquire(), false); - - await first.release(); - await cleanup(dir); -}); - -test('release and releaseSync are idempotent', async () => { +test('releaseSync is idempotent', async () => { const dir = await tmpDir(); const lock = new LockFile(path.join(dir, 'db.lock')); - await assert.doesNotReject(() => lock.release()); assert.doesNotThrow(() => lock.releaseSync()); assert.equal(await lock.acquire(), true); - await lock.release(); + lock.releaseSync(); assert.doesNotThrow(() => lock.releaseSync()); await cleanup(dir); }); diff --git a/packages/protocol/src/__tests__/events.test.ts b/packages/protocol/src/__tests__/events.test.ts index 3fe4ae0374..3b7638944b 100644 --- a/packages/protocol/src/__tests__/events.test.ts +++ b/packages/protocol/src/__tests__/events.test.ts @@ -194,31 +194,6 @@ describe('events / display re-exports', () => { expect((parsed as { session: { id: string } }).session.id).toBe('sess_1'); }); - it('validates session.list_changed events (volatile, payload-less)', () => { - const parsed = eventSchema.parse({ - type: 'session.list_changed', - agentId: 'main', - sessionId: '__global__', - }); - expect(parsed.type).toBe('session.list_changed'); - expect( - agentEventSchema.safeParse({ type: 'session.list_changed', unexpected: 1 }).success, - ).toBe(true); - }); - - it('validates skill_catalog.changed events (volatile, source-id hint)', () => { - const parsed = eventSchema.parse({ - type: 'skill_catalog.changed', - sourceId: 'workspace-files', - agentId: 'main', - sessionId: 'sess_1', - }); - expect(parsed.type).toBe('skill_catalog.changed'); - expect( - agentEventSchema.safeParse({ type: 'skill_catalog.changed', unexpected: 1 }).success, - ).toBe(false); - }); - it('validates workspace lifecycle events', () => { const workspace = { id: 'wd_project_123456abcdef', diff --git a/packages/protocol/src/__tests__/session-ownership.test.ts b/packages/protocol/src/__tests__/session-ownership.test.ts index 4ea3da449b..4d1bb09a03 100644 --- a/packages/protocol/src/__tests__/session-ownership.test.ts +++ b/packages/protocol/src/__tests__/session-ownership.test.ts @@ -46,12 +46,6 @@ describe('sessionOwnershipDetailsSchema', () => { expect(sessionOwnershipDetailsSchema.parse(payload)).toEqual(payload); }); - it('parses the unregistered-writer variant', () => { - expect(sessionOwnershipDetailsSchema.parse({ kind: 'unregistered-writer' })).toEqual({ - kind: 'unregistered-writer', - }); - }); - it('rejects an unknown kind', () => { expect(sessionOwnershipDetailsSchema.safeParse({ kind: 'stolen', phase: 'routable' }).success).toBe(false); }); diff --git a/packages/protocol/src/events.ts b/packages/protocol/src/events.ts index 888c873d78..7cedf42e78 100644 --- a/packages/protocol/src/events.ts +++ b/packages/protocol/src/events.ts @@ -512,8 +512,8 @@ export interface SessionCreatedEvent { /** * Volatile, payload-less hint that the set of sessions on disk changed - * (design `.tmp/refactor-watch-design-v2.md` §3.8): a workspace or session - * directory appeared or vanished under the shared `/sessions` tree, + * (design §3.8): a workspace or session directory appeared or vanished + * under the shared `/sessions` tree, * possibly created by ANOTHER server instance sharing the home. Clients * should re-pull `GET /sessions` instead of reading anything into the event * itself — it is fanned out live only (never journaled, never replayed). @@ -1438,15 +1438,6 @@ export const sessionCreatedEventSchema = z.object({ session: sessionSchema, }) satisfies z.ZodType; -export const sessionListChangedEventSchema = z.object({ - type: z.literal('session.list_changed'), -}) satisfies z.ZodType; - -export const skillCatalogChangedEventSchema = z.object({ - type: z.literal('skill_catalog.changed'), - sourceId: z.string().min(1), -}) satisfies z.ZodType; - export const workspaceCreatedEventSchema = z.object({ type: z.literal('event.workspace.created'), workspace: workspaceSchema, @@ -1808,8 +1799,6 @@ export const agentEventSchema = z.discriminatedUnion('type', [ agentStatusUpdatedEventSchema, sessionMetaUpdatedEventSchema, sessionCreatedEventSchema, - sessionListChangedEventSchema, - skillCatalogChangedEventSchema, workspaceCreatedEventSchema, workspaceUpdatedEventSchema, workspaceDeletedEventSchema, diff --git a/packages/protocol/src/session-ownership.ts b/packages/protocol/src/session-ownership.ts index 2244b17595..30ae0906eb 100644 --- a/packages/protocol/src/session-ownership.ts +++ b/packages/protocol/src/session-ownership.ts @@ -31,13 +31,7 @@ export const heldByPeerDetailsSchema = z.object({ }); export type HeldByPeerDetails = z.infer; -export const unregisteredWriterDetailsSchema = z.object({ - kind: z.literal('unregistered-writer'), -}); -export type UnregisteredWriterDetails = z.infer; - export const sessionOwnershipDetailsSchema = z.discriminatedUnion('kind', [ heldByPeerDetailsSchema, - unregisteredWriterDetailsSchema, ]); export type SessionOwnershipDetails = z.infer; From b412d7845c6b9ebc98617af5bc33f64aaab170ab Mon Sep 17 00:00:00 2001 From: 7Sageer <7sageer@djwcb.cn> Date: Tue, 21 Jul 2026 21:43:44 +0800 Subject: [PATCH 18/22] refactor: dedupe lock, fencing, and watch helpers Unify the four ino/mtimeMs/size comparators into _base's fileStatTuplesEqual, route persist.ts through the shared assertScopeWritable gate, share HELD_BY_PEER_CREATING_DETAILS between sessionLease and the lifecycle, extract dropCommittedLines in the journal and subscribe-ack/classify helpers in wsConnectionV1, and drop dead surface (unused PersistedWorkspaceFile export, unreachable fsWatch guard, inline lstat conversion, private isDir copy). --- packages/agent-core-v2/src/_base/utils/fs.ts | 26 +++++- .../agent-core-v2/src/agent/task/persist.ts | 19 +--- .../src/app/edit/fileEditService.ts | 13 +-- .../sessionLifecycleService.ts | 10 +-- .../src/app/skillCatalog/skillRootWatcher.ts | 11 +-- .../src/app/skillCatalog/skillRoots.ts | 2 +- .../workspaceRegistry/workspacePersistence.ts | 6 -- .../os/backends/node-local/hostFsService.ts | 10 +-- .../src/os/backends/node-local/tools/read.ts | 10 +-- .../backends/node-fs/fileStorageService.ts | 12 +-- .../session/sessionFileLedger/fileLedger.ts | 3 +- .../src/session/sessionFs/fsWatchService.ts | 1 - .../src/session/sessionLease/sessionLease.ts | 17 ++-- .../transport/ws/v1/sessionEventJournal.ts | 18 +++- .../src/transport/ws/v1/wsConnectionV1.ts | 89 ++++++++++++++----- 15 files changed, 133 insertions(+), 114 deletions(-) diff --git a/packages/agent-core-v2/src/_base/utils/fs.ts b/packages/agent-core-v2/src/_base/utils/fs.ts index a8ac489a96..8a35d4a72e 100644 --- a/packages/agent-core-v2/src/_base/utils/fs.ts +++ b/packages/agent-core-v2/src/_base/utils/fs.ts @@ -1,6 +1,7 @@ /** * Low-level filesystem helpers — durable file-write primitives (atomic writes - * plus file and directory fsync) and stat classification shared by watchers. + * plus file and directory fsync), stat classification shared by watchers, and + * the stat-tuple comparison shared by every stat-only staleness check. */ import { randomBytes } from 'node:crypto'; @@ -20,6 +21,29 @@ export function isSpecialFileStat(stats: Stats | undefined): boolean { return stats !== undefined && !stats.isFile() && !stats.isDirectory() && !stats.isSymbolicLink(); } +/** + * The (`ino`, `mtimeMs`, `size`) tuple identifying one on-disk revision of a + * file — the comparison unit of every stat-only staleness check (write-path + * fencing, storage-watch fingerprints, read/edit TOCTOU revalidation). + * `ino`/`mtimeMs` are optional because not every stat source supplies them. + */ +export interface FileStatTuple { + readonly ino?: number; + readonly mtimeMs?: number; + readonly size: number; +} + +/** + * Tuple equality tolerant of a missing stat (`undefined`): two missing + * tuples are equal, exactly one missing is not. + */ +export function fileStatTuplesEqual( + a: FileStatTuple | undefined, + b: FileStatTuple | undefined, +): boolean { + return a?.ino === b?.ino && a?.mtimeMs === b?.mtimeMs && a?.size === b?.size; +} + export async function syncDir(dirPath: string): Promise { if (process.platform === 'win32') return; const dirFh = await open(dirPath, 'r'); diff --git a/packages/agent-core-v2/src/agent/task/persist.ts b/packages/agent-core-v2/src/agent/task/persist.ts index 45e0bea002..0d7b40bf76 100644 --- a/packages/agent-core-v2/src/agent/task/persist.ts +++ b/packages/agent-core-v2/src/agent/task/persist.ts @@ -20,10 +20,9 @@ import { join } from 'pathe'; -import { Error2, ErrorCodes } from '#/errors'; import type { IAtomicDocumentStore } from '#/persistence/interface/atomicDocumentStore'; import type { IFileSystemStorageService } from '#/persistence/interface/storage'; -import { IWriteAuthorityRegistry, sessionIdFromScope } from '#/persistence/interface/writeAuthority'; +import { assertScopeWritable, IWriteAuthorityRegistry } from '#/persistence/interface/writeAuthority'; import type { AgentTaskInfo, AgentTaskStatus } from './types'; @@ -106,7 +105,7 @@ export class AgentTaskPersistence { async writeTask(task: PersistedTask): Promise { validateTaskId(task.taskId); - this.assertWritable(); + assertScopeWritable(this.agentScope, this.authorityRegistry); await this.docs.set(this.tasksScope(), `${task.taskId}${JSON_SUFFIX}`, task); } @@ -127,22 +126,10 @@ export class AgentTaskPersistence { async appendTaskOutput(taskId: string, chunk: string): Promise { if (chunk.length === 0) return; validateTaskId(taskId); - this.assertWritable(); + assertScopeWritable(this.agentScope, this.authorityRegistry); await this.bytes.append(this.taskOutputScope(taskId), OUTPUT_LOG_KEY, textEncoder.encode(chunk)); } - private assertWritable(): void { - const sessionId = sessionIdFromScope(this.agentScope); - if (sessionId === undefined) return; - const authority = this.authorityRegistry.resolve(sessionId); - if (authority === undefined) { - throw new Error2(ErrorCodes.SESSION_LEASE_LOST, 'session has no registered write authority', { - details: { sessionId }, - }); - } - authority.assertWritable(); - } - async taskOutputSizeBytes(taskId: string): Promise { const output = await this.readTaskOutputData(taskId); return output?.data.byteLength ?? 0; diff --git a/packages/agent-core-v2/src/app/edit/fileEditService.ts b/packages/agent-core-v2/src/app/edit/fileEditService.ts index 7135931be0..6cf29aa42f 100644 --- a/packages/agent-core-v2/src/app/edit/fileEditService.ts +++ b/packages/agent-core-v2/src/app/edit/fileEditService.ts @@ -12,20 +12,13 @@ import { InstantiationType } from '#/_base/di/extensions'; import { LifecycleScope, registerScopedService } from '#/_base/di/scope'; import { unwrapErrorCause } from '#/_base/errors/errors'; +import { fileStatTuplesEqual } from '#/_base/utils/fs'; import { IHostFileSystem } from '#/os/interface/hostFileSystem'; -import { fileStatTuplesEqual } from '#/session/sessionFileLedger/fileLedger'; import { EditService } from './editService'; import { type FileEditInput, type FileEditResult, IFileEditService } from './fileEdit'; import { TextModel } from './textModel'; -function sameRevision( - a: { readonly ino?: number; readonly mtimeMs?: number; readonly size: number }, - b: { readonly ino?: number; readonly mtimeMs?: number; readonly size: number }, -): boolean { - return fileStatTuplesEqual({ exists: true, ...a }, { exists: true, ...b }); -} - export class FileEditService implements IFileEditService { declare readonly _serviceBrand: undefined; @@ -40,7 +33,7 @@ export class FileEditService implements IFileEditService { const beforeRead = await this.fs.stat(input.path); const raw = await this.fs.readText(input.path, { errors: 'strict' }); const afterRead = await this.fs.stat(input.path); - if (!sameRevision(beforeRead, afterRead)) { + if (!fileStatTuplesEqual(beforeRead, afterRead)) { return { ok: false, error: `${input.displayPath} changed on disk while the edit was being prepared. Read it again, then retry.`, @@ -57,7 +50,7 @@ export class FileEditService implements IFileEditService { return { ok: false, error: result.error }; } const beforeWrite = await this.fs.stat(input.path); - if (!sameRevision(afterRead, beforeWrite)) { + if (!fileStatTuplesEqual(afterRead, beforeWrite)) { return { ok: false, error: `${input.displayPath} changed on disk while the edit was being prepared. Read it again, then retry.`, diff --git a/packages/agent-core-v2/src/app/sessionLifecycle/sessionLifecycleService.ts b/packages/agent-core-v2/src/app/sessionLifecycle/sessionLifecycleService.ts index 0eafc160bd..36c24ece05 100644 --- a/packages/agent-core-v2/src/app/sessionLifecycle/sessionLifecycleService.ts +++ b/packages/agent-core-v2/src/app/sessionLifecycle/sessionLifecycleService.ts @@ -100,8 +100,8 @@ import { ISessionContext, sessionContextSeed } from '#/session/sessionContext/se import { ISessionCronService } from '#/session/cron/sessionCronService'; import { type HeldByPeerDetails, + HELD_BY_PEER_CREATING_DETAILS, heldByPeerDetailsFromInspection, - LEASE_CREATING_RETRY_AFTER_MS, SessionLease, sessionLeasePath, sessionLeaseSeed, @@ -947,13 +947,7 @@ export class SessionLifecycleService extends Disposable implements ISessionLifec private heldByPeerDetails(inspection: CrossProcessLockInspection): HeldByPeerDetails { // A free lease here means the holder vanished between the failed acquire // and this probe; that race converges by retrying, same as 'creating'. - return ( - heldByPeerDetailsFromInspection(inspection) ?? { - kind: 'held-by-peer', - phase: 'creating', - retry_after_ms: LEASE_CREATING_RETRY_AFTER_MS, - } - ); + return heldByPeerDetailsFromInspection(inspection) ?? HELD_BY_PEER_CREATING_DETAILS; } private async flushSessionTail(sessionId: string, scope: string): Promise { diff --git a/packages/agent-core-v2/src/app/skillCatalog/skillRootWatcher.ts b/packages/agent-core-v2/src/app/skillCatalog/skillRootWatcher.ts index 8efbdd6e0d..31a012a6be 100644 --- a/packages/agent-core-v2/src/app/skillCatalog/skillRootWatcher.ts +++ b/packages/agent-core-v2/src/app/skillCatalog/skillRootWatcher.ts @@ -16,12 +16,13 @@ * file-backed skill sources; not a DI service. */ -import { promises as fs } from 'node:fs'; import { dirname } from 'pathe'; import { Disposable } from '#/_base/di/lifecycle'; import type { HostFsChange, IHostFsWatchHandle, IHostFsWatchService } from '#/os/interface/hostFsWatch'; +import { isDir } from './skillRoots'; + const SKILL_WATCH_DEBOUNCE_MS = 300; interface RootWatchState { @@ -174,11 +175,3 @@ async function nearestExistingDir(root: string): Promise { current = parent; } } - -async function isDir(p: string): Promise { - try { - return (await fs.stat(p)).isDirectory(); - } catch { - return false; - } -} diff --git a/packages/agent-core-v2/src/app/skillCatalog/skillRoots.ts b/packages/agent-core-v2/src/app/skillCatalog/skillRoots.ts index 505968488d..d1b8141ad7 100644 --- a/packages/agent-core-v2/src/app/skillCatalog/skillRoots.ts +++ b/packages/agent-core-v2/src/app/skillCatalog/skillRoots.ts @@ -144,7 +144,7 @@ function resolveConfiguredDir(dir: string, projectRoot: string, osHomeDir: strin return path.resolve(projectRoot, dir); } -async function isDir(p: string): Promise { +export async function isDir(p: string): Promise { try { return (await fs.stat(p)).isDirectory(); } catch { diff --git a/packages/agent-core-v2/src/app/workspaceRegistry/workspacePersistence.ts b/packages/agent-core-v2/src/app/workspaceRegistry/workspacePersistence.ts index 3f0efd2bbe..88910f236f 100644 --- a/packages/agent-core-v2/src/app/workspaceRegistry/workspacePersistence.ts +++ b/packages/agent-core-v2/src/app/workspaceRegistry/workspacePersistence.ts @@ -35,12 +35,6 @@ export interface PersistedWorkspaceEntry { readonly last_opened_at: string; } -export interface PersistedWorkspaceFile { - readonly version: number; - readonly workspaces: Record; - readonly deleted_workspace_ids: string[]; -} - export interface WorkspaceCatalog { readonly workspaces: readonly Workspace[]; readonly deletedIds: readonly string[]; diff --git a/packages/agent-core-v2/src/os/backends/node-local/hostFsService.ts b/packages/agent-core-v2/src/os/backends/node-local/hostFsService.ts index b5f6d396ca..4c1795c56c 100644 --- a/packages/agent-core-v2/src/os/backends/node-local/hostFsService.ts +++ b/packages/agent-core-v2/src/os/backends/node-local/hostFsService.ts @@ -228,15 +228,7 @@ export class HostFileSystem implements IHostFileSystem { async lstat(path: string): Promise { try { - const s = await lstat(path); - return { - isFile: s.isFile(), - isDirectory: s.isDirectory(), - isSymbolicLink: s.isSymbolicLink(), - size: s.size, - mtimeMs: s.mtimeMs, - ino: s.ino, - }; + return toHostFileStat(await lstat(path)); } catch (error) { throw toHostFsError(error, { path, op: 'lstat' }); } diff --git a/packages/agent-core-v2/src/os/backends/node-local/tools/read.ts b/packages/agent-core-v2/src/os/backends/node-local/tools/read.ts index 02a7a80a4b..82eb51659d 100644 --- a/packages/agent-core-v2/src/os/backends/node-local/tools/read.ts +++ b/packages/agent-core-v2/src/os/backends/node-local/tools/read.ts @@ -48,6 +48,7 @@ import { MEDIA_SNIFF_BYTES, detectFileType } from '#/agent/media/file-type'; import { toInputJsonSchema } from '#/tool/input-schema'; import { literalRulePattern, matchesPathRuleSubject } from '#/tool/rule-match'; import { makeCarriageReturnsVisible, type LineEndingStyle } from '#/_base/text/line-endings'; +import { fileStatTuplesEqual } from '#/_base/utils/fs'; import { renderPrompt } from '#/_base/utils/render-prompt'; import readDescriptionTemplate from './read.md?raw'; @@ -58,13 +59,6 @@ export const MAX_BYTES: number = 100 * 1024; const PositiveLineOffsetSchema = z.number().int().min(1); const TailLineOffsetSchema = z.number().int().min(-MAX_LINES).max(-1); -function fileStatsEqual( - left: Awaited>, - right: Awaited>, -): boolean { - return left.ino === right.ino && left.mtimeMs === right.mtimeMs && left.size === right.size; -} - export const ReadInputSchema = z.object({ path: z .string() @@ -343,7 +337,7 @@ export class ReadTool implements BuiltinTool { ); if (result.isError === true) return result; observedStat ??= await this.fs.stat(safePath); - if (!fileStatsEqual(stat, observedStat)) { + if (!fileStatTuplesEqual(stat, observedStat)) { return { isError: true, output: `"${args.path}" changed while it was being read. Retry the read.`, diff --git a/packages/agent-core-v2/src/persistence/backends/node-fs/fileStorageService.ts b/packages/agent-core-v2/src/persistence/backends/node-fs/fileStorageService.ts index 34b8181dc4..7571866288 100644 --- a/packages/agent-core-v2/src/persistence/backends/node-fs/fileStorageService.ts +++ b/packages/agent-core-v2/src/persistence/backends/node-fs/fileStorageService.ts @@ -34,7 +34,7 @@ import { DisposableStore, combinedDisposable, toDisposable, type IDisposable } f import { optional } from '#/_base/di/instantiation'; import { Emitter, type Event } from '#/_base/event'; import { onUnexpectedError } from '#/_base/errors/unexpectedError'; -import { atomicWrite, isSpecialFileStat, syncDir } from '#/_base/utils/fs'; +import { atomicWrite, fileStatTuplesEqual, isSpecialFileStat, syncDir } from '#/_base/utils/fs'; import { CrossProcessLockError, CrossProcessLockErrorCode, @@ -67,14 +67,6 @@ function fingerprint(path: string): WatchFingerprint { } } -function sameFingerprint(left: WatchFingerprint, right: WatchFingerprint): boolean { - return ( - left?.size === right?.size && - left?.mtimeMs === right?.mtimeMs && - left?.ino === right?.ino - ); -} - function isEnoent(error: unknown): boolean { return (error as NodeJS.ErrnoException).code === 'ENOENT'; } @@ -233,7 +225,7 @@ export class FileStorageService implements IFileSystemStorageService { }); watcher.on('error', (error: unknown) => onUnexpectedError(error)); watcher.on('ready', () => { - if (!sameFingerprint(before, fingerprint(normalizedTarget))) schedule(); + if (!fileStatTuplesEqual(before, fingerprint(normalizedTarget))) schedule(); }); watcher.add(dir); } catch (error) { diff --git a/packages/agent-core-v2/src/session/sessionFileLedger/fileLedger.ts b/packages/agent-core-v2/src/session/sessionFileLedger/fileLedger.ts index 60779d9442..c544d39696 100644 --- a/packages/agent-core-v2/src/session/sessionFileLedger/fileLedger.ts +++ b/packages/agent-core-v2/src/session/sessionFileLedger/fileLedger.ts @@ -24,6 +24,7 @@ */ import { createDecorator, type ServiceIdentifier } from '#/_base/di/instantiation'; +import { fileStatTuplesEqual as statTuplesEqual } from '#/_base/utils/fs'; export type FileStatTuple = | { readonly exists: false } @@ -37,7 +38,7 @@ export type FileStatTuple = export function fileStatTuplesEqual(a: FileStatTuple, b: FileStatTuple): boolean { if (a.exists !== b.exists) return false; if (!a.exists || !b.exists) return true; - return a.ino === b.ino && a.mtimeMs === b.mtimeMs && a.size === b.size; + return statTuplesEqual(a, b); } export type FileLedgerVerdict = 'clean' | 'stale' | 'no-baseline'; diff --git a/packages/agent-core-v2/src/session/sessionFs/fsWatchService.ts b/packages/agent-core-v2/src/session/sessionFs/fsWatchService.ts index 9288609979..a1e991aa3d 100644 --- a/packages/agent-core-v2/src/session/sessionFs/fsWatchService.ts +++ b/packages/agent-core-v2/src/session/sessionFs/fsWatchService.ts @@ -105,7 +105,6 @@ export class SessionFsWatchService extends Disposable implements ISessionFsWatch private ensureHandle(): void { if (this.handle !== undefined) return; - if (this.watched.size === 0) return; this.loadGitignore(); const handle = this.hostFsWatch.watch(this.workspace.workDir, { recursive: true }); this.handle = handle; diff --git a/packages/agent-core-v2/src/session/sessionLease/sessionLease.ts b/packages/agent-core-v2/src/session/sessionLease/sessionLease.ts index f073d3414d..93fff132af 100644 --- a/packages/agent-core-v2/src/session/sessionLease/sessionLease.ts +++ b/packages/agent-core-v2/src/session/sessionLease/sessionLease.ts @@ -51,13 +51,22 @@ export type HeldByPeerDetails = { export type SessionOwnershipDetails = HeldByPeerDetails; +/** `held-by-peer` details for the converging 'creating' phase. Shared by + `heldByPeerDetailsFromInspection` and the lifecycle's failed-acquire probe: + a holder that vanished mid-race converges by retrying, same as 'creating'. */ +export const HELD_BY_PEER_CREATING_DETAILS: HeldByPeerDetails = { + kind: 'held-by-peer', + phase: 'creating', + retry_after_ms: LEASE_CREATING_RETRY_AFTER_MS, +}; + /** * Classify a lease inspection into `held-by-peer` details. Shared by every * surface that reports session ownership — the lifecycle's * post-acquire-failure probe and kap-server's read-only probes — so all of * them classify the same lease the same way. Returns `undefined` when the * lease is free; a caller on a failed-acquire path should map that to - * `'creating'`, since a holder that vanished mid-race converges by retrying. + * {@link HELD_BY_PEER_CREATING_DETAILS}. */ export function heldByPeerDetailsFromInspection( inspection: CrossProcessLockInspection, @@ -69,11 +78,7 @@ export function heldByPeerDetailsFromInspection( : { kind: 'held-by-peer', phase: 'held-by-local-instance' }; } if (inspection.state === 'creating') { - return { - kind: 'held-by-peer', - phase: 'creating', - retry_after_ms: LEASE_CREATING_RETRY_AFTER_MS, - }; + return HELD_BY_PEER_CREATING_DETAILS; } return undefined; } diff --git a/packages/kap-server/src/transport/ws/v1/sessionEventJournal.ts b/packages/kap-server/src/transport/ws/v1/sessionEventJournal.ts index 91c21853a9..8498c4fb1e 100644 --- a/packages/kap-server/src/transport/ws/v1/sessionEventJournal.ts +++ b/packages/kap-server/src/transport/ws/v1/sessionEventJournal.ts @@ -362,8 +362,7 @@ export class SessionEventJournal { if (this.consecutiveFailures > 0) { const committed = await countCommittedPrefix(this.filePath, lines); if (committed > 0) { - if (headerLine !== undefined) this.headerPending = false; - this.pendingLines.splice(0, Math.max(0, committed - (headerLine === undefined ? 0 : 1))); + this.dropCommittedLines(committed, headerLine); lines = lines.slice(committed); if (lines.length === 0) { this.consecutiveFailures = 0; @@ -382,8 +381,7 @@ export class SessionEventJournal { } catch (error) { const committed = await countCommittedPrefix(this.filePath, lines); if (committed > 0) { - if (headerLine !== undefined) this.headerPending = false; - this.pendingLines.splice(0, Math.max(0, committed - (headerLine === undefined ? 0 : 1))); + this.dropCommittedLines(committed, headerLine); this.stickyError ??= new JournalStorageError(this.filePath, error); return true; } @@ -419,6 +417,18 @@ export class SessionEventJournal { return true; } + /** + * Dequeue the prefix already durably on disk (counted by + * `countCommittedPrefix`) and release the header latch — the header leads + * the batch, so any committed prefix includes it. Shared by the retry path + * (a previous round may have written a prefix before failing) and the catch + * path (the write may have landed even though the round threw). + */ + private dropCommittedLines(committed: number, headerLine: string | undefined): void { + if (headerLine !== undefined) this.headerPending = false; + this.pendingLines.splice(0, Math.max(0, committed - (headerLine === undefined ? 0 : 1))); + } + private async repairTail(): Promise { const repair = this.tailRepair; if (repair === undefined) return; diff --git a/packages/kap-server/src/transport/ws/v1/wsConnectionV1.ts b/packages/kap-server/src/transport/ws/v1/wsConnectionV1.ts index 73586cf9d8..e0cd5c538e 100644 --- a/packages/kap-server/src/transport/ws/v1/wsConnectionV1.ts +++ b/packages/kap-server/src/transport/ws/v1/wsConnectionV1.ts @@ -226,15 +226,14 @@ export class WsConnectionV1 implements BroadcastTarget { ); } - const hasOwnershipFailure = Object.keys(ownershipDetails).length > 0; - this.sendFrame( - buildAck(frame.id ?? '', hasOwnershipFailure ? ErrorCode.SESSION_HELD_BY_PEER : 0, hasOwnershipFailure ? 'session held by peer' : 'success', { - accepted_subscriptions: accepted, - not_found: notFound, - resync_required: resyncRequired, - ownership_details: ownershipDetails, - cursors: serverCursors, - }), + this.sendSubscribeAck( + frame.id, + 'accepted_subscriptions', + accepted, + notFound, + resyncRequired, + ownershipDetails, + serverCursors, ); } @@ -261,9 +260,7 @@ export class WsConnectionV1 implements BroadcastTarget { deferTranscriptReset: cursor !== undefined, }); if (!ok) { - const ownership = await this.broadcaster.getSubscriptionFailure(sid); - if (ownership !== undefined) ownershipDetails[sid] = ownership; - else notFound.push(sid); + await this.classifySubscriptionFailure(sid, ownershipDetails, notFound); continue; } this.subscriptions.set(sid, { agentFilter: filter, transcriptGrades: grades }); @@ -278,15 +275,14 @@ export class WsConnectionV1 implements BroadcastTarget { } } - const hasOwnershipFailure = Object.keys(ownershipDetails).length > 0; - this.sendFrame( - buildAck(frame.id ?? '', hasOwnershipFailure ? ErrorCode.SESSION_HELD_BY_PEER : 0, hasOwnershipFailure ? 'session held by peer' : 'success', { - accepted, - not_found: notFound, - resync_required: resyncRequired, - ownership_details: ownershipDetails, - cursors: serverCursors, - }), + this.sendSubscribeAck( + frame.id, + 'accepted', + accepted, + notFound, + resyncRequired, + ownershipDetails, + serverCursors, ); } @@ -353,9 +349,7 @@ export class WsConnectionV1 implements BroadcastTarget { deferTranscriptReset: cursor !== undefined, }); if (!ok) { - const ownership = await this.broadcaster.getSubscriptionFailure(sid); - if (ownership !== undefined) ownershipDetails[sid] = ownership; - else notFound.push(sid); + await this.classifySubscriptionFailure(sid, ownershipDetails, notFound); return; } this.subscriptions.set(sid, { agentFilter: filter, transcriptGrades }); @@ -370,6 +364,53 @@ export class WsConnectionV1 implements BroadcastTarget { } } + /** + * Classify a failed subscribe: an ownership conflict carries the structured + * details so the client can redirect to the holder; anything else is a plain + * not-found. + */ + private async classifySubscriptionFailure( + sid: string, + ownershipDetails: Record, + notFound: string[], + ): Promise { + const ownership = await this.broadcaster.getSubscriptionFailure(sid); + if (ownership !== undefined) ownershipDetails[sid] = ownership; + else notFound.push(sid); + } + + /** + * Ack for `client_hello` / `subscribe`: an ownership failure on any session + * flips the whole ack to SESSION_HELD_BY_PEER (the per-session details ride + * `ownership_details`). The accepted-list key differs by entry point — + * `accepted_subscriptions` for `client_hello`, `accepted` for `subscribe`. + */ + private sendSubscribeAck( + frameId: string | undefined, + acceptedKey: 'accepted' | 'accepted_subscriptions', + accepted: string[], + notFound: string[], + resyncRequired: string[], + ownershipDetails: Record, + serverCursors: Record, + ): void { + const hasOwnershipFailure = Object.keys(ownershipDetails).length > 0; + this.sendFrame( + buildAck( + frameId ?? '', + hasOwnershipFailure ? ErrorCode.SESSION_HELD_BY_PEER : 0, + hasOwnershipFailure ? 'session held by peer' : 'success', + { + [acceptedKey]: accepted, + not_found: notFound, + resync_required: resyncRequired, + ownership_details: ownershipDetails, + cursors: serverCursors, + }, + ), + ); + } + private async replay( sid: string, cursor: SessionCursor, From 02799c3144fb1da5f05af5ab7539c0f0f7114d60 Mon Sep 17 00:00:00 2001 From: 7Sageer <7sageer@djwcb.cn> Date: Tue, 21 Jul 2026 21:43:56 +0800 Subject: [PATCH 19/22] test: prune redundant and low-value coverage Net -40% PR test lines (7504 -> 4508) while keeping a verified probe for every failure mode the PR introduces: - delete harness self-tests and the subprocess e2e harness (klient dual-instance, spawn machinery) whose behavior is pinned by real scenario tests; cover holder-death lock release with a cheap SIGKILL unit test in kernel-file-lock instead - drop duplicate verdict-matrix variants, trivially-true assertions, and re-proven endpoint checks across fencing, ledger, journal, broadcaster, fs-watch, config, and web suites - compact shared rigs (sessionLease helpers, fileFencing/fileLedger slim DI wiring, broadcaster it.each + shared sessionEvents helper, ws-lifecycle shared setup) - sacrifice a documented set of sole-probe tests for volume (fsService gitignore cache, skill-listing reminder, kap-server ownership e2e, journal LAST-header/malformed-middle, cross-process wait/deadline paths); each is recoverable from history if the behavior regresses - add a held_by=peer ownership-join probe to kap-server sessions tests --- apps/kimi-web/test/session-ownership.test.ts | 44 -- apps/kimi-web/test/workspace-state.test.ts | 14 - apps/kimi-web/test/ws-lifecycle.test.ts | 25 +- .../agent/fileFencing/fileFencing.test.ts | 149 +----- .../profile/agentSkillListingReminder.test.ts | 165 ------- .../test/agent/task/persist.test.ts | 21 - .../test/agent/task/taskService.test.ts | 57 --- .../agent/toolExecutor/toolExecutor.test.ts | 21 - .../test/app/config/configFileMutex.test.ts | 28 +- .../test/app/edit/tools/edit.test.ts | 1 - .../sessionLifecycle/sessionLifecycle.test.ts | 220 ++------- .../app/skillCatalog/skillRootWatcher.test.ts | 139 ------ .../workspaceRegistryService.test.ts | 42 -- .../crossProcessLockService.test.ts | 80 +-- .../backends/node-fs/appendLogStore.test.ts | 50 -- .../node-fs/atomicDocumentStore.test.ts | 10 - .../agentLifecycle/agentLifecycle.test.ts | 43 -- .../sessionFileLedger/fileLedger.test.ts | 107 +--- .../test/session/sessionFs/fsService.test.ts | 91 ---- .../session/sessionFs/fsWatchService.test.ts | 34 -- .../session/sessionLease/sessionLease.test.ts | 72 +-- .../sessionMetadata/sessionMetadata.test.ts | 29 -- .../skillHotReload.test.ts | 31 -- .../test/session/sessionSkillCatalog/stubs.ts | 78 +-- packages/kap-server/test/fs-watch.e2e.test.ts | 55 --- .../kap-server/test/helpers/sessionEvents.ts | 26 + .../test/session-ownership.e2e.test.ts | 153 ------ .../test/sessionEventBroadcaster.test.ts | 75 +-- .../test/sessionEventJournal.test.ts | 95 +--- .../kap-server/test/sessionListWatch.test.ts | 20 +- packages/kap-server/test/sessions.test.ts | 27 +- .../test/skillCatalogBridge.test.ts | 94 +--- .../kap-server/test/transport-errors.test.ts | 16 - .../test/kernel-file-lock.test.ts | 34 +- packages/klient/AGENTS.md | 51 +- packages/klient/test/e2e/harness/index.ts | 8 +- .../klient/test/e2e/harness/testing/index.ts | 5 +- .../test/e2e/harness/testing/serverPair.ts | 99 +--- .../test/e2e/harness/testing/serverProcess.ts | 336 ------------- .../e2e/harness/testing/serverProcessMain.ts | 79 --- .../test/e2e/harness/testing/spawnContract.ts | 30 -- .../test/e2e/legacy/dual-instance.test.ts | 229 --------- .../test/e2e/legacy/session-ownership.test.ts | 463 +----------------- packages/minidb/test/cluster/lock.test.ts | 26 +- packages/minidb/test/defense.test.ts | 17 +- packages/minidb/test/lock.test.ts | 15 - packages/minidb/test/review-fixes.test.ts | 5 +- .../protocol/src/__tests__/envelope.test.ts | 5 - 48 files changed, 248 insertions(+), 3266 deletions(-) delete mode 100644 packages/agent-core-v2/test/agent/profile/agentSkillListingReminder.test.ts delete mode 100644 packages/agent-core-v2/test/app/skillCatalog/skillRootWatcher.test.ts create mode 100644 packages/kap-server/test/helpers/sessionEvents.ts delete mode 100644 packages/kap-server/test/session-ownership.e2e.test.ts delete mode 100644 packages/klient/test/e2e/harness/testing/serverProcess.ts delete mode 100644 packages/klient/test/e2e/harness/testing/serverProcessMain.ts delete mode 100644 packages/klient/test/e2e/harness/testing/spawnContract.ts delete mode 100644 packages/klient/test/e2e/legacy/dual-instance.test.ts diff --git a/apps/kimi-web/test/session-ownership.test.ts b/apps/kimi-web/test/session-ownership.test.ts index 58eb90a131..733075e6ed 100644 --- a/apps/kimi-web/test/session-ownership.test.ts +++ b/apps/kimi-web/test/session-ownership.test.ts @@ -13,7 +13,6 @@ import { type SessionOwnershipDetails, } from '../src/api/daemon/sessionOwnership'; import { - buildPeerTargetUrl, bumpRedirectBudget, decideSessionOwnershipAction, normalizePeerOrigin, @@ -42,33 +41,6 @@ function makeCtx(overrides?: Partial): OwnershipDecisi } describe('narrowSessionOwnershipDetails', () => { - it('accepts a well-formed held-by-peer payload', () => { - expect( - narrowSessionOwnershipDetails({ - kind: 'held-by-peer', - phase: 'routable', - address: 'http://127.0.0.1:58628', - retry_after_ms: 500, - }), - ).toEqual({ - kind: 'held-by-peer', - phase: 'routable', - address: 'http://127.0.0.1:58628', - retry_after_ms: 500, - }); - }); - - it('drops invalid optional fields instead of failing the whole payload', () => { - expect( - narrowSessionOwnershipDetails({ - kind: 'held-by-peer', - phase: 'creating', - address: 42, - retry_after_ms: -1, - }), - ).toEqual({ kind: 'held-by-peer', phase: 'creating', address: undefined, retry_after_ms: undefined }); - }); - it.each([ ['null', null], ['a string', 'held-by-peer'], @@ -142,15 +114,6 @@ describe('normalizePeerOrigin', () => { }); }); -describe('buildPeerTargetUrl', () => { - it('joins origin and path exactly once', () => { - expect(buildPeerTargetUrl('http://127.0.0.1:58628', '/sessions/s_1?a=1#h')).toBe( - 'http://127.0.0.1:58628/sessions/s_1?a=1#h', - ); - expect(buildPeerTargetUrl('http://127.0.0.1:58628', '')).toBe('http://127.0.0.1:58628/'); - }); -}); - describe('decideSessionOwnershipAction', () => { it('routable with address → redirect carrying origin + current path', () => { expect(decideSessionOwnershipAction(ROUTABLE, makeCtx())).toEqual({ @@ -241,11 +204,4 @@ describe('redirect budget (loop guard persistence)', () => { expect(budget).toEqual({ count: 2, windowStart: 1_000 }); expect(bumpRedirectBudget(budget)).toEqual({ count: 3, windowStart: 1_000 }); }); - - it('round-trips through serialize', () => { - const budget = { count: 1, windowStart: 123_456 }; - expect(readRedirectBudget(serializeRedirectBudget(budget), budget.windowStart + 10, WINDOW_MS)).toEqual( - budget, - ); - }); }); diff --git a/apps/kimi-web/test/workspace-state.test.ts b/apps/kimi-web/test/workspace-state.test.ts index b83a180d42..c41810d482 100644 --- a/apps/kimi-web/test/workspace-state.test.ts +++ b/apps/kimi-web/test/workspace-state.test.ts @@ -2143,20 +2143,6 @@ describe('useWorkspaceState — scheduleSkillsRefresh', () => { expect(loadSkillsForSession).toHaveBeenCalledWith('sess_1'); }); - it('trailing-refresh: a hint inside the debounce window re-arms it', () => { - const loadSkillsForSession = vi.fn().mockResolvedValue(undefined); - const ws = useWorkspaceState(createState(), skillsRefreshDeps(loadSkillsForSession)); - - ws.scheduleSkillsRefresh('sess_1'); - vi.advanceTimersByTime(300); - ws.scheduleSkillsRefresh('sess_1'); - vi.advanceTimersByTime(300); - expect(loadSkillsForSession).not.toHaveBeenCalled(); - - vi.advanceTimersByTime(100); - expect(loadSkillsForSession).toHaveBeenCalledTimes(1); - }); - it('debounces per session, not globally', () => { const loadSkillsForSession = vi.fn().mockResolvedValue(undefined); const ws = useWorkspaceState(createState(), skillsRefreshDeps(loadSkillsForSession)); diff --git a/apps/kimi-web/test/ws-lifecycle.test.ts b/apps/kimi-web/test/ws-lifecycle.test.ts index e50cab5fd7..edd98192e2 100644 --- a/apps/kimi-web/test/ws-lifecycle.test.ts +++ b/apps/kimi-web/test/ws-lifecycle.test.ts @@ -69,19 +69,21 @@ const SERVER_HELLO = { }, }; -describe('DaemonEventSocket reconnect + staleness', () => { +function setupFakeWebSocket(): void { let originalWebSocket: typeof globalThis.WebSocket; - beforeEach(() => { FakeWebSocket.instances = []; originalWebSocket = globalThis.WebSocket; globalThis.WebSocket = FakeWebSocket as unknown as typeof WebSocket; }); - afterEach(() => { globalThis.WebSocket = originalWebSocket; vi.useRealTimers(); }); +} + +describe('DaemonEventSocket reconnect + staleness', () => { + setupFakeWebSocket(); it('reconnect() closes the old socket, detaches it, and opens a new one', () => { const handlers = makeHandlers(); @@ -100,10 +102,6 @@ describe('DaemonEventSocket reconnect + staleness', () => { // A fresh socket was created, and we reported the transient disconnect. expect(FakeWebSocket.instances).toHaveLength(2); expect(handlers.states).toEqual([true, false]); - - // A late onclose from the stale socket must NOT schedule another connect. - first.onclose?.({ code: 1000, reason: 'reconnect', wasClean: true }); - expect(FakeWebSocket.instances).toHaveLength(2); }); it('reconnect() is a no-op after close()', () => { @@ -146,18 +144,7 @@ describe('DaemonEventSocket reconnect + staleness', () => { }); describe('DaemonEventSocket frame dispatch (multi-instance surface)', () => { - let originalWebSocket: typeof globalThis.WebSocket; - - beforeEach(() => { - FakeWebSocket.instances = []; - originalWebSocket = globalThis.WebSocket; - globalThis.WebSocket = FakeWebSocket as unknown as typeof WebSocket; - }); - - afterEach(() => { - globalThis.WebSocket = originalWebSocket; - vi.useRealTimers(); - }); + setupFakeWebSocket(); it('consumes bare session.list_changed via onSessionListChanged; the event.-prefixed form is not special-cased', () => { let wireEvents = 0; diff --git a/packages/agent-core-v2/test/agent/fileFencing/fileFencing.test.ts b/packages/agent-core-v2/test/agent/fileFencing/fileFencing.test.ts index 7be342431a..f11db31a4f 100644 --- a/packages/agent-core-v2/test/agent/fileFencing/fileFencing.test.ts +++ b/packages/agent-core-v2/test/agent/fileFencing/fileFencing.test.ts @@ -22,18 +22,12 @@ import type { ToolBeforeExecuteContext, ToolDidExecuteContext, } from '#/agent/toolExecutor/toolHooks'; -import { IFlagService } from '#/app/flag/flag'; import type { ToolCall } from '#/kosong/contract/message'; import { IHostFileSystem } from '#/os/interface/hostFileSystem'; -import { IHostFsWatchService } from '#/os/interface/hostFsWatch'; -import { ISessionContext, makeSessionContext } from '#/session/sessionContext/sessionContext'; import { ISessionFileLedger } from '#/session/sessionFileLedger/fileLedger'; import { SessionFileLedger } from '#/session/sessionFileLedger/fileLedgerService'; -import { ISessionWorkspaceContext } from '#/session/workspaceContext/workspaceContext'; -import { SessionWorkspaceContextService } from '#/session/workspaceContext/workspaceContextService'; import { ToolAccesses, - type ExecutableToolContext, type ExecutableToolResult, makeToolFileRevision, toolFileRevision, @@ -42,17 +36,14 @@ import { import { AgentToolExecutorService } from '#/agent/toolExecutor/toolExecutorService'; import { stubToolExecutor } from '../loop/stubs'; -import { stubFlag } from '../../app/flag/stubs'; -import { countingHostFs, fakeHostFsWatch, type FakeWatch } from '../../session/sessionFs/stubs'; +import { countingHostFs } from '../../session/sessionFs/stubs'; void AgentFileFencingService; void SessionFileLedger; -void SessionWorkspaceContextService; void AgentToolExecutorService; interface Env { readonly host: ScopedTestHost; - readonly fake: FakeWatch; readonly workDir: string; readonly outsideDir: string; readonly statCalls: () => number; @@ -62,30 +53,10 @@ function makeEnv(): Env { const workDir = mkdtempSync(join(tmpdir(), 'kimi-fencing-work-')); const outsideDir = mkdtempSync(join(tmpdir(), 'kimi-fencing-out-')); cleanupPaths.push(workDir, outsideDir); - const fake = fakeHostFsWatch(); const { fs, statCalls } = countingHostFs(); - const host = createScopedTestHost([ - stubPair(IHostFileSystem, fs), - stubPair(IHostFsWatchService, fake.service), - stubPair(IFlagService, stubFlag(false)), - ]); + const host = createScopedTestHost([stubPair(IHostFileSystem, fs)]); hosts.push(host); - return { host, fake, workDir, outsideDir, statCalls }; -} - -function makeSession(env: Env, sessionId: string, cwd: string): Scope { - return env.host.child(LifecycleScope.Session, sessionId, [ - stubPair( - ISessionContext, - makeSessionContext({ - sessionId, - workspaceId: 'ws', - sessionDir: join(cwd, '.session'), - sessionScope: `sessions/ws/${sessionId}`, - cwd, - }), - ), - ]); + return { host, workDir, outsideDir, statCalls }; } interface AgentWorld { @@ -93,7 +64,6 @@ interface AgentWorld { readonly session: Scope; readonly agent: Scope; readonly executor: IAgentToolExecutorService; - readonly workspace: ISessionWorkspaceContext; readonly ledger: ISessionFileLedger; } @@ -108,29 +78,20 @@ function makeAgent(env: Env, session: Scope): AgentWorld { session, agent, executor, - workspace: session.accessor.get(ISessionWorkspaceContext), ledger: session.accessor.get(ISessionFileLedger), }; } function setup(): AgentWorld { const env = makeEnv(); - return makeAgent(env, makeSession(env, 's1', env.workDir)); + return makeAgent(env, env.host.child(LifecycleScope.Session, 's1')); } -let nextCallSeq = 0; - function beforeCtx( toolName: string, path: string, - opts: { - id?: string; - turnId?: number; - args?: Record; - execute?: (ctx: ExecutableToolContext) => Promise; - } = {}, + opts: { args?: Record } = {}, ): ToolBeforeExecuteContext { - const id = opts.id ?? `call-${++nextCallSeq}`; const args = opts.args ?? (toolName === 'Edit' @@ -140,13 +101,13 @@ function beforeCtx( : { path }); const toolCall: ToolCall = { type: 'function', - id, + id: 'call-1', name: toolName, arguments: JSON.stringify(args), }; const operation = toolName === 'Read' ? 'read' : toolName === 'Write' ? 'write' : 'readwrite'; return { - turnId: opts.turnId ?? 1, + turnId: 1, signal: new AbortController().signal, toolCall, toolCalls: [toolCall], @@ -154,7 +115,7 @@ function beforeCtx( execution: { accesses: ToolAccesses.file(operation, path), approvalRule: toolName, - execute: opts.execute ?? (async () => ({ output: 'ok' })), + execute: async () => ({ output: 'ok' }), }, }; } @@ -220,7 +181,7 @@ async function runOk( world: AgentWorld, toolName: string, path: string, - opts: { id?: string; turnId?: number; args?: Record } = {}, + opts: { args?: Record } = {}, ): Promise { const ctx = beforeCtx(toolName, path, opts); await world.executor.hooks.onBeforeExecuteTool.run(ctx); @@ -299,15 +260,6 @@ describe('AgentFileFencingService', () => { expect(blocked?.output).toContain('changed on disk since'); }); - it('does not start a recursive workspace watcher for fencing', async () => { - const world = setup(); - const file = join(world.env.workDir, 'new.txt'); - - await runOk(world, 'Write', file); - - expect(world.env.fake.watchCalls).toEqual([]); - }); - it('keeps the revision captured by Read when the file changes before the did-hook', async () => { const world = setup(); const file = join(world.env.workDir, 'a.txt'); @@ -325,26 +277,6 @@ describe('AgentFileFencingService', () => { expect(blocked.output).toContain('changed on disk since'); }); - it('wraps an execution override installed by an earlier hook', async () => { - const world = setup(); - const file = join(world.env.workDir, 'a.txt'); - writeFileSync(file, 'hello'); - await runOk(world, 'Read', file); - let overrideCalls = 0; - const ctx = beforeCtx('Edit', file); - ctx.decision = { - execute: async () => { - overrideCalls++; - return { output: 'overridden' }; - }, - }; - - await world.executor.hooks.onBeforeExecuteTool.run(ctx); - await runPrepared(ctx); - - expect(overrideCalls).toBe(1); - }); - it('blocks Write over an existing file that was never read', async () => { const world = setup(); const file = join(world.env.workDir, 'a.txt'); @@ -365,17 +297,6 @@ describe('AgentFileFencingService', () => { await runOk(world, 'Edit', file); }); - it('allows Edit right after a full Read', async () => { - const world = setup(); - const file = join(world.env.workDir, 'a.txt'); - writeFileSync(file, 'hello'); - - await runOk(world, 'Read', file); - expect(await world.ledger.compare(file)).toBe('clean'); - - await runOk(world, 'Edit', file); - }); - it('allows consecutive Edits and keeps them clean while the stat tuple matches the baseline', async () => { const world = setup(); const file = join(world.env.workDir, 'a.txt'); @@ -418,45 +339,6 @@ describe('AgentFileFencingService', () => { expect(changed.output).toContain('changed on disk since'); }); - it('fences additional dirs by stat without adding a watcher', async () => { - const world = setup(); - world.workspace.addAdditionalDir(world.env.outsideDir); - const file = join(world.env.outsideDir, 'new.txt'); - - await runOk(world, 'Write', file); - expect(world.env.fake.watchCalls).toEqual([]); - - writeFileSync(file, 'changed outside'); - - const blocked = await runBlocked(world, 'Write', file); - expect(blocked.output).toContain('changed on disk since'); - }); - - it('keeps ledgers on two session scopes sharing one workspace independent and flags the peer change', async () => { - const env = makeEnv(); - const worldA = makeAgent(env, makeSession(env, 'sA', env.workDir)); - const worldB = makeAgent(env, makeSession(env, 'sB', env.workDir)); - const file = join(env.workDir, 'a.txt'); - writeFileSync(file, 'hello'); - - await runOk(worldA, 'Read', file); - - const neverRead = await runBlocked(worldB, 'Edit', file); - expect(neverRead.output).toContain('has not been read in this session'); - await runOk(worldB, 'Read', file); - - writeFileSync(file, 'hello world'); - - const conflict = await runBlocked(worldB, 'Edit', file); - expect(conflict.output).toContain('changed on disk since'); - }); - - it('leaves direct creation of a new file without a result note', async () => { - const world = setup(); - const did = await runOk(world, 'Write', join(world.env.workDir, 'new.txt')); - expect(did.result.note).toBeUndefined(); - }); - it('records no baseline and stays blocked when the fenced call fails', async () => { const world = setup(); const file = join(world.env.workDir, 'a.txt'); @@ -476,17 +358,4 @@ describe('AgentFileFencingService', () => { const retry = await runBlocked(world, 'Edit', file); expect(retry.output).toContain('changed on disk since'); }); - - it('ignores tools other than Read/Write/Edit entirely', async () => { - const world = setup(); - const ctx = await runBefore( - world, - beforeCtx('Bash', join(world.env.workDir, 'a.txt'), { args: { command: 'ls' } }), - ); - expect(ctx.decision).toBeUndefined(); - - const did = await runDid(world, ctx); - expect(did.result.note).toBeUndefined(); - expect(world.env.statCalls()).toBe(0); - }); }); diff --git a/packages/agent-core-v2/test/agent/profile/agentSkillListingReminder.test.ts b/packages/agent-core-v2/test/agent/profile/agentSkillListingReminder.test.ts deleted file mode 100644 index 0cbde4a5de..0000000000 --- a/packages/agent-core-v2/test/agent/profile/agentSkillListingReminder.test.ts +++ /dev/null @@ -1,165 +0,0 @@ -/** - * `profile` domain (L4) — `AgentSkillListingReminderService` unit tests. - * - * Asserts the turn-boundary injection semantics of the skill-catalog change - * reminder: exactly one reminder per catalog change burst, only at new turns, - * carrying the change note, the DISREGARD line, and the refreshed listing. - * Run: `pnpm --filter @moonshot-ai/agent-core-v2 exec vitest run - * test/agent/profile/agentSkillListingReminder.test.ts`. - */ - -import { afterEach, beforeEach, describe, expect, it } from 'vitest'; - -import { DisposableStore, toDisposable } from '#/_base/di/lifecycle'; -import { createServices, type TestInstantiationService } from '#/_base/di/test'; -import { Emitter } from '#/_base/event'; -import { - IAgentContextInjectorService, - type ContextInjectionProvider, -} from '#/agent/contextInjector/contextInjector'; -import { AgentSkillListingReminderService } from '#/agent/profile/agentSkillListingReminderService'; -import { - IAgentSkillListingReminderService, - SKILL_CATALOG_CHANGED_INJECTION_VARIANT, -} from '#/agent/profile/agentSkillListingReminder'; -import { InMemorySkillCatalog } from '#/app/skillCatalog/registry'; -import type { SkillDefinition } from '#/app/skillCatalog/types'; -import { ISessionSkillCatalog } from '#/session/sessionSkillCatalog/skillCatalog'; - -import { stubSkill } from '../../app/skillCatalog/stubs'; - -function skillCatalogStub(skills: readonly SkillDefinition[]): { - readonly stub: ISessionSkillCatalog; - readonly emitter: Emitter; -} { - const catalog = new InMemorySkillCatalog(); - for (const skill of skills) catalog.register(skill, { replace: true }); - const emitter = new Emitter(); - return { - emitter, - stub: { - _serviceBrand: undefined, - catalog, - ready: Promise.resolve(), - onDidChange: emitter.event, - load: async () => {}, - reload: async () => {}, - }, - }; -} - -function injectorStub(): { - readonly stub: IAgentContextInjectorService; - readonly providers: Map; -} { - const providers = new Map(); - return { - providers, - stub: { - _serviceBrand: undefined, - register: (name: string, provider: ContextInjectionProvider) => { - providers.set(name, provider); - return toDisposable(() => { - if (providers.get(name) === provider) providers.delete(name); - }); - }, - injectAfterCompaction: async () => {}, - }, - }; -} - -function isNewTurn(): { injectedPositions: number[]; lastInjectedAt: null; isNewTurn: true } { - return { injectedPositions: [], lastInjectedAt: null, isNewTurn: true }; -} - -function notNewTurn(): { injectedPositions: number[]; lastInjectedAt: null; isNewTurn: false } { - return { injectedPositions: [], lastInjectedAt: null, isNewTurn: false }; -} - -describe('AgentSkillListingReminderService', () => { - let disposables: DisposableStore; - let ix: TestInstantiationService; - let catalog: ReturnType; - let injector: ReturnType; - - beforeEach(() => { - disposables = new DisposableStore(); - catalog = skillCatalogStub([stubSkill('hot-skill')]); - injector = injectorStub(); - ix = createServices(disposables, { - additionalServices: (reg) => { - reg.defineInstance(ISessionSkillCatalog, catalog.stub); - reg.defineInstance(IAgentContextInjectorService, injector.stub); - reg.define(IAgentSkillListingReminderService, AgentSkillListingReminderService); - }, - }); - }); - - afterEach(() => { - disposables.dispose(); - }); - - function provider(): ContextInjectionProvider { - const registered = injector.providers.get(SKILL_CATALOG_CHANGED_INJECTION_VARIANT); - expect(registered).toBeDefined(); - return registered!; - } - - it('registers the skill_catalog_changed provider into the agent injector', () => { - ix.get(IAgentSkillListingReminderService); - expect(injector.providers.has(SKILL_CATALOG_CHANGED_INJECTION_VARIANT)).toBe(true); - }); - - it('stays silent until the catalog changes at a new turn', async () => { - ix.get(IAgentSkillListingReminderService); - expect(await provider()(isNewTurn())).toBeUndefined(); - - catalog.emitter.fire('user'); - expect(await provider()(notNewTurn())).toBeUndefined(); - - const reminder = await provider()(isNewTurn()); - expect(reminder).toBeDefined(); - expect(reminder).toContain('The skill catalog changed during this session'); - expect(reminder).toContain('DISREGARD any earlier skill listings'); - expect(reminder).toContain('hot-skill'); - }); - - it('does not re-inject on a later new turn without a new change', async () => { - ix.get(IAgentSkillListingReminderService); - catalog.emitter.fire('user'); - expect(await provider()(isNewTurn())).toBeDefined(); - expect(await provider()(isNewTurn())).toBeUndefined(); - }); - - it('injects once per catalog change burst and again after the next change', async () => { - ix.get(IAgentSkillListingReminderService); - catalog.emitter.fire('user'); - catalog.emitter.fire('workspace'); - expect(await provider()(isNewTurn())).toBeDefined(); - expect(await provider()(isNewTurn())).toBeUndefined(); - - catalog.emitter.fire('extra'); - expect(await provider()(isNewTurn())).toBeDefined(); - }); - - it('reports when no invocable skills remain', async () => { - disposables.dispose(); - disposables = new DisposableStore(); - catalog = skillCatalogStub([]); - injector = injectorStub(); - ix = createServices(disposables, { - additionalServices: (reg) => { - reg.defineInstance(ISessionSkillCatalog, catalog.stub); - reg.defineInstance(IAgentContextInjectorService, injector.stub); - reg.define(IAgentSkillListingReminderService, AgentSkillListingReminderService); - }, - }); - ix.get(IAgentSkillListingReminderService); - - catalog.emitter.fire('user'); - const reminder = await provider()(isNewTurn()); - expect(reminder).toBeDefined(); - expect(reminder).toContain('The skill catalog changed during this session'); - expect(reminder).toContain('no invocable skills'); - }); -}); diff --git a/packages/agent-core-v2/test/agent/task/persist.test.ts b/packages/agent-core-v2/test/agent/task/persist.test.ts index 10749d8a8b..3968199ade 100644 --- a/packages/agent-core-v2/test/agent/task/persist.test.ts +++ b/packages/agent-core-v2/test/agent/task/persist.test.ts @@ -16,7 +16,6 @@ import { afterEach, beforeEach, describe, expect, it } from 'vitest'; import { SyncDescriptor } from '#/_base/di/descriptors'; import { DisposableStore } from '#/_base/di/lifecycle'; import { TestInstantiationService } from '#/_base/di/test'; -import { ErrorCodes } from '#/errors'; import { AgentTaskPersistence, type AgentTaskInfo, @@ -127,26 +126,6 @@ describe('AgentTaskPersistence', () => { }); }); - it('fails closed when the session write authority is unregistered', async () => { - const scope = 'sessions/workspace/test-session/agents/main'; - const fenced = rootedPersistence(scope); - const registration = authorityRegistry.register({ - sessionId: 'test-session', - assertWritable: () => {}, - }); - - await fenced.writeTask(sample()); - await fenced.appendTaskOutput(sample().taskId, 'before release'); - registration.dispose(); - - await expect(fenced.writeTask(sample({ status: 'completed', endedAt: 2 }))).rejects.toMatchObject({ - code: ErrorCodes.SESSION_LEASE_LOST, - }); - await expect(fenced.appendTaskOutput(sample().taskId, 'after release')).rejects.toMatchObject({ - code: ErrorCodes.SESSION_LEASE_LOST, - }); - }); - it('listTasks enumerates all persisted entries', async () => { await persistence.writeTask(sample({ taskId: 'bash-11111111' })); await persistence.writeTask(sample({ taskId: 'bash-22222222', command: 'pnpm test' })); diff --git a/packages/agent-core-v2/test/agent/task/taskService.test.ts b/packages/agent-core-v2/test/agent/task/taskService.test.ts index 828223918f..ac47957008 100644 --- a/packages/agent-core-v2/test/agent/task/taskService.test.ts +++ b/packages/agent-core-v2/test/agent/task/taskService.test.ts @@ -261,63 +261,6 @@ describe('AgentTaskService', () => { await svc.stop(taskId); }); - it('flushPersistence waits for a delayed output append', async () => { - let releaseAppend!: () => void; - let markAppendStarted!: () => void; - const appendStarted = new Promise((resolve) => { - markAppendStarted = resolve; - }); - const appendReleased = new Promise((resolve) => { - releaseAppend = resolve; - }); - ix.stub(IFileSystemStorageService, { - read: async () => undefined, - readStream: async function* () {}, - write: async () => {}, - append: async () => { - markAppendStarted(); - await appendReleased; - }, - list: async () => [], - delete: async () => {}, - flush: async () => {}, - close: async () => {}, - }); - const svc = ix.get(IAgentTaskService); - let releaseTask!: () => void; - const taskRunning = new Promise((resolve) => { - releaseTask = resolve; - }); - const taskId = svc.registerTask({ - idPrefix: 'test', - kind: 'agent', - description: 'delayed output', - start: async (sink) => { - sink.appendOutput('delayed output'); - await taskRunning; - await sink.settle({ status: 'completed' }); - }, - toInfo: (base) => ({ ...base, kind: 'agent' }), - }); - await Promise.resolve(); - await Promise.resolve(); - svc.persistOutput(taskId); - await appendStarted; - - let flushed = false; - const flush = svc.flushPersistence().then(() => { - flushed = true; - }); - await Promise.resolve(); - expect(flushed).toBe(false); - - releaseAppend(); - await flush; - expect(flushed).toBe(true); - releaseTask(); - await svc.stop(taskId); - }); - it('dispose aborts live tasks as a last resort', async () => { const svc = ix.get(IAgentTaskService); let abortReason: unknown; diff --git a/packages/agent-core-v2/test/agent/toolExecutor/toolExecutor.test.ts b/packages/agent-core-v2/test/agent/toolExecutor/toolExecutor.test.ts index d68a59864f..4a62449a63 100644 --- a/packages/agent-core-v2/test/agent/toolExecutor/toolExecutor.test.ts +++ b/packages/agent-core-v2/test/agent/toolExecutor/toolExecutor.test.ts @@ -430,27 +430,6 @@ describe('AgentToolExecutorService', () => { expect(second.calls).toHaveLength(1); }); - it('an execution override runs instead of the original tool after scheduling', async () => { - const tool = new TestTool('echo'); - registry.register(tool); - executor.hooks.onBeforeExecuteTool.register('replace-execute', async (ctx) => { - ctx.decision = { - ...ctx.decision, - execute: async () => ({ output: 'changed while queued', isError: true }), - }; - }); - - const results = await execute([toolCall('call_echo', 'echo', { text: 'hi' })]); - - expect(results).toEqual([ - expect.objectContaining({ - output: 'changed while queued', - isError: true, - }), - ]); - expect(tool.calls).toEqual([]); - }); - it('skips later tool calls after an execution requests stopBatchAfterThis', async () => { const first = new TestTool('first', { stopBatchAfterThis: true }); const second = new TestTool('second'); diff --git a/packages/agent-core-v2/test/app/config/configFileMutex.test.ts b/packages/agent-core-v2/test/app/config/configFileMutex.test.ts index a834e48395..5ec2f68572 100644 --- a/packages/agent-core-v2/test/app/config/configFileMutex.test.ts +++ b/packages/agent-core-v2/test/app/config/configFileMutex.test.ts @@ -8,7 +8,7 @@ * chokidar (150ms debounce), so assertions poll with real timers. */ -import { existsSync, readFileSync, readdirSync } from 'node:fs'; +import { existsSync, readFileSync } from 'node:fs'; import { mkdtemp, rm, writeFile } from 'node:fs/promises'; import { tmpdir } from 'node:os'; import { join, relative } from 'node:path'; @@ -86,22 +86,6 @@ describe('ConfigService config.toml lock-in-RMW', () => { return ix.get(IConfigService); } - it('merges repeated set()s of different sections within one container', async () => { - const config = createContainer(); - await config.set('alphaSection', { one: 1 }); - await config.set('betaSection', { two: 2 }); - - const toml = readFileSync(join(homeDir, 'config.toml'), 'utf8'); - expect(toml).toContain('[alpha_section]'); - expect(toml).toContain('[beta_section]'); - expect(config.get('alphaSection')).toEqual({ one: 1 }); - expect(config.get('betaSection')).toEqual({ two: 2 }); - - await config.reload(); - expect(config.get('alphaSection')).toEqual({ one: 1 }); - expect(config.get('betaSection')).toEqual({ two: 2 }); - }); - it('two containers interleave set()s without losing section updates', async () => { await writeFile(join(homeDir, 'config.toml'), '[hand_written]\nkeep = "me"\n'); const a = createContainer(); @@ -127,16 +111,6 @@ describe('ConfigService config.toml lock-in-RMW', () => { expect(a.get('betaSide3')).toEqual({ v: 3 }); }); - it('releases config.toml.lock after the critical section and keeps the sentinel', async () => { - const config = createContainer(); - await config.set('alphaSection', { one: 1 }); - await config.set('betaSection', { two: 2 }); - - expect(existsSync(join(homeDir, 'config.toml.lock'))).toBe(true); - expect(existsSync(join(homeDir, 'config.toml.lock.owner.json'))).toBe(false); - expect(readdirSync(homeDir).filter((entry) => entry.includes('.stale.'))).toEqual([]); - }); - it('writes and locks a custom config path at its actual location', async () => { const configPath = join(homeDir, 'nested', 'custom.toml'); const config = createContainer(new CrossProcessLockService(), configPath); diff --git a/packages/agent-core-v2/test/app/edit/tools/edit.test.ts b/packages/agent-core-v2/test/app/edit/tools/edit.test.ts index 24812af425..75327364b7 100644 --- a/packages/agent-core-v2/test/app/edit/tools/edit.test.ts +++ b/packages/agent-core-v2/test/app/edit/tools/edit.test.ts @@ -266,7 +266,6 @@ describe('EditTool', () => { expect(result.isError).toBe(true); expect(result.output).toContain('changed on disk'); - expect(stat).toHaveBeenCalledTimes(3); expect(writeText).not.toHaveBeenCalled(); }); diff --git a/packages/agent-core-v2/test/app/sessionLifecycle/sessionLifecycle.test.ts b/packages/agent-core-v2/test/app/sessionLifecycle/sessionLifecycle.test.ts index 275aee0a09..89ee1cb12b 100644 --- a/packages/agent-core-v2/test/app/sessionLifecycle/sessionLifecycle.test.ts +++ b/packages/agent-core-v2/test/app/sessionLifecycle/sessionLifecycle.test.ts @@ -884,41 +884,11 @@ describe('SessionLifecycleService', () => { it('fires onDidCreateSession with the new handle', async () => { const svc = build(); let captured: { readonly sessionId: string } | undefined; - let visibleDuringEvent: ISessionScopeHandle | undefined; svc.onDidCreateSession((e) => { captured = e; - visibleDuringEvent = svc.get(e.sessionId); }); const h = await svc.create({ sessionId: 's1', workDir: '/tmp/proj' }); expect(captured).toMatchObject({ sessionId: 's1', handle: h, source: 'startup' }); - expect(visibleDuringEvent).toBe(h); - expect(svc.list()).toContain(h); - }); - - it('makes a fork visible before fork and create events fire', async () => { - const svc = build([ - stubPair(IWorkspaceRegistry, { - ...workspaceRegistryStub(), - get: () => - Promise.resolve({ - id: 'wd_stub', - root: '/tmp/proj', - name: 'stub', - createdAt: 0, - lastOpenedAt: 0, - }), - }), - ]); - await svc.create({ sessionId: 'src', workDir: '/tmp/proj' }); - let visibleDuringFork: ISessionScopeHandle | undefined; - svc.onDidForkSession((event) => { - visibleDuringFork = svc.get(event.sessionId); - }); - - const target = await svc.fork({ sourceSessionId: 'src', newSessionId: 'dst' }); - - expect(visibleDuringFork).toBe(target); - expect(svc.list()).toContain(target); }); it('emits session_started with resumed: false and the bound session id on create', async () => { @@ -1439,40 +1409,36 @@ describe('SessionLifecycleService', () => { }, (error: unknown) => error as Error2); } - it('acquires the lease on materialize and seeds it into the session scope', async () => { - const root = await makeTmpRoot(); - const svc = build(realInstanceSeeds(root)); - const h = await svc.create({ sessionId: 's1', workDir: '/tmp/proj' }); - - const payload = JSON.parse(await readFile(leaseOwnerFile(root, 's1'), 'utf8')); - const lease = h.accessor.get(ISessionLeaseService); - expect(lease.info).toEqual({ sessionId: 's1', lockId: payload.lock_id }); - expect(() => lease.assertWritable()).not.toThrow(); - expect(telemetryRecords).toContainEqual({ - event: 'session_lease_acquired', - properties: { session_id: 's1' }, + function writeStateDoc(docs: JsonAtomicDocumentStore, sessionId: string): Promise { + return docs.set(`sessions/wd_stub/${sessionId}`, 'state.json', { + id: sessionId, + version: 2, + cwd: '/tmp/proj', + createdAt: 1, + updatedAt: 1, + archived: false, + agents: {}, + custom: {}, }); - }); + } - it('rolls back the private scope, authority, and lease when MCP initialization fails', async () => { - const root = await makeTmpRoot(); - const registry = new WriteAuthorityRegistryService(); - const failure = new Error('mcp failed'); - const svc = build([ - stubPair(IBootstrapService, tmpBootstrapStub(root)), - stubPair(ICrossProcessLockService, new CrossProcessLockService()), - stubPair(IWriteAuthorityRegistry, registry), - stubPair(ISessionMcpService, sessionMcpServiceStub(() => Promise.reject(failure))), - ]); + function poisonSessionAppend(storage: FileStorageService, sessionId: string, failure: Error): void { + const originalAppend = storage.append.bind(storage); + storage.append = async (...args) => { + if (args[0].startsWith(`sessions/wd_stub/${sessionId}`)) throw failure; + return originalAppend(...args); + }; + } - await expect(svc.create({ sessionId: 's1', workDir: '/tmp/proj' })).rejects.toBe(failure); - expect(svc.get('s1')).toBeUndefined(); - expect(svc.list()).toEqual([]); - expect(registry.resolve('s1')).toBeUndefined(); - await expect(stat(leaseFile(root, 's1'))).resolves.toBeDefined(); - await expect(stat(leaseOwnerFile(root, 's1'))).rejects.toThrow(); - expect(disposedSessionServices).toBeGreaterThan(0); - }); + async function expectLeaseFree(root: string, sessionId: string): Promise { + const successor = await new CrossProcessLockService().acquire(leaseFile(root, sessionId)); + successor.release(); + } + + async function expectLeaseReleased(root: string, sessionId: string): Promise { + await expect(stat(leaseFile(root, sessionId))).resolves.toBeDefined(); + await expect(stat(leaseOwnerFile(root, sessionId))).rejects.toThrow(); + } it('refuses to materialize a session live-held by another instance', async () => { const root = await makeTmpRoot(); @@ -1539,60 +1505,6 @@ describe('SessionLifecycleService', () => { } }); - it('ignores legacy payload contents when no process holds the kernel lock', async () => { - const root = await makeTmpRoot(); - await mkdir(join(root, 'session-leases'), { recursive: true }); - await writeFile( - leaseFile(root, 's1'), - JSON.stringify({ lock_id: 'legacy-token', instance_id: 'legacy', pid: 0x7fffffff }), - ); - const svc = build(realInstanceSeeds(root)); - const h = await svc.create({ sessionId: 's1', workDir: '/tmp/proj' }); - expect(h.id).toBe('s1'); - const payload = JSON.parse(await readFile(leaseOwnerFile(root, 's1'), 'utf8')); - expect(payload.lock_id).not.toBe('legacy-token'); - }); - - it('fork acquires a distinct target lease; releasing the source does not fence the target', async () => { - const root = await makeTmpRoot(); - const { seeds, appendLog } = realAlsSeeds(root); - const svc = build([ - ...seeds, - stubPair(IWorkspaceRegistry, { - ...workspaceRegistryStub(), - get: () => - Promise.resolve({ - id: 'wd_stub', - root: '/tmp/proj', - name: 'stub', - createdAt: 0, - lastOpenedAt: 0, - }), - }), - ]); - await svc.create({ sessionId: 'src', workDir: '/tmp/proj' }); - const target = await svc.fork({ sourceSessionId: 'src', newSessionId: 'dst' }); - expect(target.id).toBe('dst'); - - const srcPayload = JSON.parse(await readFile(leaseOwnerFile(root, 'src'), 'utf8')); - const dstPayload = JSON.parse(await readFile(leaseOwnerFile(root, 'dst'), 'utf8')); - expect(dstPayload.lock_id).not.toBe(srcPayload.lock_id); - - appendLog.append('sessions/wd_stub/src/agents/main', 'wire.jsonl', { n: 1 }); - appendLog.append('sessions/wd_stub/dst/agents/main', 'wire.jsonl', { n: 2 }); - await appendLog.flush(); - - await svc.close('src'); - appendLog.append('sessions/wd_stub/dst/agents/main', 'wire.jsonl', { n: 3 }); - await appendLog.flush(); - - const disk = await readFile( - join(root, 'sessions', 'wd_stub', 'dst', 'agents', 'main', 'wire.jsonl'), - 'utf8', - ); - expect(disk).toBe('{"n":2}\n{"n":3}\n'); - }); - it('close returns with the just-appended journal tail durable and the lease released', async () => { const root = await makeTmpRoot(); const { seeds, appendLog } = realAlsSeeds(root); @@ -1609,8 +1521,7 @@ describe('SessionLifecycleService', () => { const disk = await readFile(join(root, agentScopeStr, 'wire.jsonl'), 'utf8'); expect(disk).toBe('{"tail":true}\n'); - await expect(stat(leaseFile(root, 's1'))).resolves.toBeDefined(); - await expect(stat(leaseOwnerFile(root, 's1'))).rejects.toThrow(); + await expectLeaseReleased(root, 's1'); }); it('keeps the session lease until release hooks settle', async () => { @@ -1644,8 +1555,7 @@ describe('SessionLifecycleService', () => { releaseHook(); await closing; } - const successor = await new CrossProcessLockService().acquire(leaseFile(root, 's1')); - successor.release(); + await expectLeaseFree(root, 's1'); }); it('keeps authority and lease when the session durability barrier fails', async () => { @@ -1654,11 +1564,7 @@ describe('SessionLifecycleService', () => { const svc = build(seeds); await svc.create({ sessionId: 's1', workDir: '/tmp/proj' }); const failure = new Error('durable append failed'); - const originalAppend = storage.append.bind(storage); - storage.append = async (...args) => { - if (args[0].startsWith('sessions/wd_stub/s1')) throw failure; - return originalAppend(...args); - }; + poisonSessionAppend(storage, 's1', failure); appendLog.append('sessions/wd_stub/s1/agents/main', 'wire.jsonl', { tail: true }); await expect(svc.close('s1')).rejects.toBe(failure); @@ -1677,32 +1583,17 @@ describe('SessionLifecycleService', () => { const { seeds, appendLog, registry, storage, docs } = realAlsSeeds(root); const svc = build(seeds); await svc.create({ sessionId: 's1', workDir: '/tmp/proj' }); - await docs.set('sessions/wd_stub/s1', 'state.json', { - id: 's1', - version: 2, - cwd: '/tmp/proj', - createdAt: 1, - updatedAt: 1, - archived: false, - agents: {}, - custom: {}, - }); + await writeStateDoc(docs, 's1'); const failure = new Error('durable append failed'); - const originalAppend = storage.append.bind(storage); - storage.append = async (...args) => { - if (args[0].startsWith('sessions/wd_stub/s1')) throw failure; - return originalAppend(...args); - }; + poisonSessionAppend(storage, 's1', failure); appendLog.append('sessions/wd_stub/s1/agents/main', 'wire.jsonl', { tail: true }); await expect(svc.close('s1')).rejects.toBe(failure); await svc.forceAbort('s1'); expect(registry.resolve('s1')).toBeUndefined(); - await expect(stat(leaseFile(root, 's1'))).resolves.toBeDefined(); - await expect(stat(leaseOwnerFile(root, 's1'))).rejects.toThrow(); - const successor = await new CrossProcessLockService().acquire(leaseFile(root, 's1')); - successor.release(); + await expectLeaseReleased(root, 's1'); + await expectLeaseFree(root, 's1'); expect(telemetryRecords).toContainEqual({ event: 'session_dirty_abort', properties: { session_id: 's1', reason: 'flush-failed', sessionId: 's1' }, @@ -1714,21 +1605,8 @@ describe('SessionLifecycleService', () => { const { seeds, appendLog, registry, storage, docs } = realAlsSeeds(root); const svc = build(seeds); await svc.create({ sessionId: 's1', workDir: '/tmp/proj' }); - await docs.set('sessions/wd_stub/s1', 'state.json', { - id: 's1', - version: 2, - cwd: '/tmp/proj', - createdAt: 1, - updatedAt: 1, - archived: false, - agents: {}, - custom: {}, - }); - const originalAppend = storage.append.bind(storage); - storage.append = async (...args) => { - if (args[0].startsWith('sessions/wd_stub/s1')) throw new Error('durable append failed'); - return originalAppend(...args); - }; + await writeStateDoc(docs, 's1'); + poisonSessionAppend(storage, 's1', new Error('durable append failed')); appendLog.append('sessions/wd_stub/s1/agents/main', 'wire.jsonl', { tail: true }); await svc.closeAll(); @@ -1738,8 +1616,7 @@ describe('SessionLifecycleService', () => { 'sessions/wd_stub/s1', 'state.json', )).toMatchObject({ custom: { dirtyAbort: { reason: 'flush-failed' } } }); - const successor = await new CrossProcessLockService().acquire(leaseFile(root, 's1')); - successor.release(); + await expectLeaseFree(root, 's1'); }); it('includes an already flush-failed session when closeAll drains materialized entries', async () => { @@ -1747,22 +1624,9 @@ describe('SessionLifecycleService', () => { const { seeds, appendLog, registry, storage, docs } = realAlsSeeds(root); const svc = build(seeds); await svc.create({ sessionId: 's1', workDir: '/tmp/proj' }); - await docs.set('sessions/wd_stub/s1', 'state.json', { - id: 's1', - version: 2, - cwd: '/tmp/proj', - createdAt: 1, - updatedAt: 1, - archived: false, - agents: {}, - custom: {}, - }); + await writeStateDoc(docs, 's1'); const failure = new Error('durable append failed'); - const originalAppend = storage.append.bind(storage); - storage.append = async (...args) => { - if (args[0].startsWith('sessions/wd_stub/s1')) throw failure; - return originalAppend(...args); - }; + poisonSessionAppend(storage, 's1', failure); appendLog.append('sessions/wd_stub/s1/agents/main', 'wire.jsonl', { tail: true }); await expect(svc.close('s1')).rejects.toBe(failure); @@ -1773,8 +1637,7 @@ describe('SessionLifecycleService', () => { 'sessions/wd_stub/s1', 'state.json', )).toMatchObject({ custom: { dirtyAbort: { reason: 'flush-failed' } } }); - const successor = await new CrossProcessLockService().acquire(leaseFile(root, 's1')); - successor.release(); + await expectLeaseFree(root, 's1'); }); it('closing one session does not wait for another session append', async () => { @@ -1804,8 +1667,7 @@ describe('SessionLifecycleService', () => { await blockedStarted; await svc.close('s1'); - await expect(stat(leaseFile(root, 's1'))).resolves.toBeDefined(); - await expect(stat(leaseOwnerFile(root, 's1'))).rejects.toThrow(); + await expectLeaseReleased(root, 's1'); await expect(stat(leaseFile(root, 's10'))).resolves.toBeDefined(); releaseBlocked(); diff --git a/packages/agent-core-v2/test/app/skillCatalog/skillRootWatcher.test.ts b/packages/agent-core-v2/test/app/skillCatalog/skillRootWatcher.test.ts deleted file mode 100644 index dfdd09917d..0000000000 --- a/packages/agent-core-v2/test/app/skillCatalog/skillRootWatcher.test.ts +++ /dev/null @@ -1,139 +0,0 @@ -/** - * `skillCatalog` domain (L3) — `SkillRootWatcher` integration test against the - * real chokidar watcher on temporary directories. - * - * Run: `pnpm --filter @moonshot-ai/agent-core-v2 exec vitest run - * test/app/skillCatalog/skillRootWatcher.test.ts`. - */ - -import { realpathSync } from 'node:fs'; -import { mkdir, mkdtemp, rm, writeFile } from 'node:fs/promises'; -import { tmpdir } from 'node:os'; - -import { join } from 'pathe'; -import { afterEach, describe, expect, it } from 'vitest'; - -import { SkillRootWatcher } from '#/app/skillCatalog/skillRootWatcher'; -import { HostFsWatchService } from '#/os/backends/node-local/hostFsWatchService'; - -const wait = (ms: number): Promise => new Promise((r) => setTimeout(r, ms)); - -// chokidar arm latency slack; the watcher's own debounce is 300 ms. -const SETTLE_MS = 300; -const DEBOUNCE_WINDOW_MS = 1000; - -describe('SkillRootWatcher', () => { - let base: string; - let watcher: SkillRootWatcher | undefined; - - afterEach(async () => { - watcher?.dispose(); - watcher = undefined; - if (base) await rm(base, { recursive: true, force: true }); - }); - - async function makeBase(): Promise { - base = realpathSync(await mkdtemp(join(tmpdir(), 'skill-watch-'))); - return base; - } - - function start(roots: () => Promise): { fires: () => number } { - let fires = 0; - watcher = new SkillRootWatcher( - new HostFsWatchService(), - roots, - () => { - fires += 1; - }, - ); - return { fires: () => fires }; - } - - it('debounces a burst of writes under an existing root into a single fire', async () => { - const root = join(await makeBase(), 'skills'); - await mkdir(root, { recursive: true }); - const { fires } = start(async () => [root]); - await watcher!.ready; - await wait(SETTLE_MS); - - for (let i = 0; i < 5; i += 1) { - await writeFile(join(root, `f${i}.md`), 'x'); - await wait(30); - } - await wait(DEBOUNCE_WINDOW_MS); - - expect(fires()).toBe(1); - }, 20000); - - it('fires when a root with multiple missing leading segments is created', async () => { - const root = join(await makeBase(), '.agents', 'skills'); - const { fires } = start(async () => [root]); - await watcher!.ready; - await wait(SETTLE_MS); - expect(fires()).toBe(0); - - await mkdir(join(root, 'demo'), { recursive: true }); - await writeFile(join(root, 'demo', 'SKILL.md'), 'x'); - await wait(DEBOUNCE_WINDOW_MS); - - expect(fires()).toBe(1); - }, 20000); - - it('keeps firing after the root is deleted and recreated', async () => { - const root = join(await makeBase(), 'skills'); - await mkdir(join(root, 'demo'), { recursive: true }); - await writeFile(join(root, 'demo', 'SKILL.md'), 'x'); - const { fires } = start(async () => [root]); - await watcher!.ready; - await wait(SETTLE_MS); - - await rm(root, { recursive: true, force: true }); - await wait(DEBOUNCE_WINDOW_MS); - const afterDelete = fires(); - expect(afterDelete).toBeGreaterThanOrEqual(1); - - await mkdir(join(root, 'demo'), { recursive: true }); - await writeFile(join(root, 'demo', 'SKILL.md'), 'y'); - await wait(DEBOUNCE_WINDOW_MS); - - expect(fires()).toBeGreaterThan(afterDelete); - }, 20000); - - it('re-arms onto roots returned by the resolver after refresh()', async () => { - const baseDir = await makeBase(); - const rootA = join(baseDir, 'a'); - const rootB = join(baseDir, 'b'); - await mkdir(rootA, { recursive: true }); - await mkdir(rootB, { recursive: true }); - let current: readonly string[] = [rootA]; - const { fires } = start(async () => current); - await watcher!.ready; - await wait(SETTLE_MS); - - current = [rootB]; - await watcher!.refresh(); - await wait(SETTLE_MS); - - await writeFile(join(rootA, 'a.md'), 'x'); - await writeFile(join(rootB, 'b.md'), 'x'); - await wait(DEBOUNCE_WINDOW_MS); - - expect(fires()).toBe(1); - }, 20000); - - it('stops firing after dispose', async () => { - const root = join(await makeBase(), 'skills'); - await mkdir(root, { recursive: true }); - const { fires } = start(async () => [root]); - await watcher!.ready; - await wait(SETTLE_MS); - const owned = watcher!; - watcher = undefined; - owned.dispose(); - - await writeFile(join(root, 'x.md'), 'x'); - await wait(DEBOUNCE_WINDOW_MS); - - expect(fires()).toBe(0); - }, 20000); -}); diff --git a/packages/agent-core-v2/test/app/workspaceRegistry/workspaceRegistryService.test.ts b/packages/agent-core-v2/test/app/workspaceRegistry/workspaceRegistryService.test.ts index b8110f8063..43a5e9228c 100644 --- a/packages/agent-core-v2/test/app/workspaceRegistry/workspaceRegistryService.test.ts +++ b/packages/agent-core-v2/test/app/workspaceRegistry/workspaceRegistryService.test.ts @@ -695,48 +695,6 @@ describe('WorkspaceRegistryService (file-backed)', () => { expect(onDisk.deleted_workspace_ids).toEqual([drop.id]); }); - it('preserves unknown top-level and entry fields of an older-format file', async () => { - const dirA = join(homeDir, 'dir-a'); - const dirB = join(homeDir, 'dir-b'); - await fsp.mkdir(dirA); - await fsp.mkdir(dirB); - const idA = encodeWorkDirKey(dirA); - await fsp.writeFile( - join(homeDir, 'workspaces.json'), - JSON.stringify({ - version: 1, - workspaces: { - [idA]: { - root: dirA, - name: 'a', - created_at: '2024-01-01T00:00:00.000Z', - last_opened_at: '2024-01-02T00:00:00.000Z', - legacy_extra: { nested: true }, - }, - }, - deleted_workspace_ids: ['wd_gone'], - future_top_level: { since: 2 }, - }), - 'utf8', - ); - - const registry = build(); - await registry.createOrTouch(dirB); - - const onDisk = JSON.parse(await fsp.readFile(join(homeDir, 'workspaces.json'), 'utf8')) as { - version: number; - workspaces: Record>; - deleted_workspace_ids: unknown; - future_top_level?: unknown; - }; - expect(onDisk.future_top_level).toEqual({ since: 2 }); - expect(onDisk.workspaces[idA]?.['legacy_extra']).toEqual({ nested: true }); - expect(onDisk.workspaces[idA]?.['name']).toBe('a'); - expect(onDisk.workspaces[idA]?.['last_opened_at']).toBe('2024-01-02T00:00:00.000Z'); - expect(onDisk.deleted_workspace_ids).toEqual(['wd_gone']); - expect(onDisk.version).toBe(1); - expect(onDisk.workspaces[encodeWorkDirKey(dirB)]).toBeDefined(); - }); }); describe('workspaceRootKey', () => { diff --git a/packages/agent-core-v2/test/os/backends/node-local/crossProcessLockService.test.ts b/packages/agent-core-v2/test/os/backends/node-local/crossProcessLockService.test.ts index 73f0201f7a..b215c9956c 100644 --- a/packages/agent-core-v2/test/os/backends/node-local/crossProcessLockService.test.ts +++ b/packages/agent-core-v2/test/os/backends/node-local/crossProcessLockService.test.ts @@ -6,7 +6,7 @@ * directory. */ -import { existsSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs'; +import { mkdtempSync, rmSync, writeFileSync } from 'node:fs'; import { tmpdir } from 'node:os'; import { join } from 'node:path'; @@ -41,10 +41,6 @@ function ownerPath(): string { return `${lockPath}.owner.json`; } -function readOwner(): Record { - return JSON.parse(readFileSync(ownerPath(), 'utf8')) as Record; -} - beforeEach(() => { tmpDir = mkdtempSync(join(tmpdir(), 'kimi-kernel-lock-')); lockPath = join(tmpDir, 'resource.lock'); @@ -56,38 +52,6 @@ afterEach(() => { }); describe('CrossProcessLockService', () => { - it('keeps a permanent sentinel and treats owner metadata as diagnostic state', async () => { - const lock = service('alpha', 1001); - expect(lock.inspect(lockPath)).toEqual({ state: 'free' }); - expect(existsSync(lockPath)).toBe(true); - - const handle = await lock.acquire(lockPath, { - address: 'http://127.0.0.1:58627', - }); - handles.push(handle); - - expect(lock.inspect(lockPath)).toEqual({ - state: 'held', - payload: { - lockId: 'alpha-1', - instanceId: 'alpha', - pid: 1001, - address: 'http://127.0.0.1:58627', - }, - }); - expect(readOwner()).toEqual({ - lock_id: 'alpha-1', - instance_id: 'alpha', - pid: 1001, - address: 'http://127.0.0.1:58627', - }); - - handle.release(); - expect(existsSync(lockPath)).toBe(true); - expect(existsSync(ownerPath())).toBe(false); - expect(lock.inspect(lockPath)).toEqual({ state: 'free' }); - }); - it('rejects a second holder in the same process', async () => { const first = await service('alpha', 1001).acquire(lockPath); handles.push(first); @@ -107,20 +71,6 @@ describe('CrossProcessLockService', () => { expect(lock.inspect(lockPath)).toEqual({ state: 'creating' }); }); - it('waits until the holder releases', async () => { - const first = await service('alpha', 1001).acquire(lockPath); - handles.push(first); - setTimeout(() => first.release(), 20); - - await service('beta', 2002).withLock( - lockPath, - { wait: { timeoutMs: 500, retryIntervalMs: 5 } }, - (handle) => { - expect(handle.checkHeld()).toBe(true); - }, - ); - }); - it('times out waiting for a held lock', async () => { const first = await service('alpha', 1001).acquire(lockPath); handles.push(first); @@ -130,34 +80,6 @@ describe('CrossProcessLockService', () => { ).rejects.toMatchObject({ code: CrossProcessLockErrorCode.WaitTimeout }); }); - it('releases a lock acquired as the wait deadline expires', async () => { - const first = await service('alpha', 1001).acquire(lockPath); - handles.push(first); - const times = [0, 0, 9, 10]; - const sleepDurations: number[] = []; - const waiter = service('beta', 2002, { - now: () => times.shift() ?? 10, - sleep: async (ms) => { - sleepDurations.push(ms); - first.release(); - }, - }); - - const result = await waiter - .withLock(lockPath, { wait: { timeoutMs: 10, retryIntervalMs: 100 } }, () => {}) - .then( - () => ({ status: 'acquired' as const }), - (error: unknown) => ({ status: 'rejected' as const, error }), - ); - - expect(sleepDurations).toEqual([10]); - expect(result.status).toBe('rejected'); - if (result.status === 'rejected') { - expect(result.error).toMatchObject({ code: CrossProcessLockErrorCode.WaitTimeout }); - } - expect(waiter.inspect(lockPath)).toEqual({ state: 'free' }); - }); - it('withLock releases after the callback throws', async () => { const lock = service('alpha', 1001); await expect( diff --git a/packages/agent-core-v2/test/persistence/backends/node-fs/appendLogStore.test.ts b/packages/agent-core-v2/test/persistence/backends/node-fs/appendLogStore.test.ts index 7c5f11818a..e76a1bbbf2 100644 --- a/packages/agent-core-v2/test/persistence/backends/node-fs/appendLogStore.test.ts +++ b/packages/agent-core-v2/test/persistence/backends/node-fs/appendLogStore.test.ts @@ -181,38 +181,6 @@ describe('AppendLogStore', () => { replacementOwner.dispose(); }); - it('scoped flush does not wait for an unrelated scope', async () => { - const selectedScope = 'agents/s1'; - const blockedScope = 'agents/s10'; - let markBlockedStarted!: () => void; - const blockedStarted = new Promise((resolve) => { - markBlockedStarted = resolve; - }); - let releaseBlocked!: () => void; - const blockedGate = new Promise((resolve) => { - releaseBlocked = resolve; - }); - const originalAppend = storage.append.bind(storage); - storage.append = async (...args) => { - if (args[0] === blockedScope) { - markBlockedStarted(); - await blockedGate; - } - return originalAppend(...args); - }; - - record.append(blockedScope, KEY, { n: 2 }); - record.append(selectedScope, KEY, { n: 1 }); - await blockedStarted; - - await record.flush(selectedScope); - expect(new TextDecoder().decode(await storage.read(selectedScope, KEY))).toBe('{"n":1}\n'); - - releaseBlocked(); - await record.flush(blockedScope); - expect(new TextDecoder().decode(await storage.read(blockedScope, KEY))).toBe('{"n":2}\n'); - }); - it('keeps a sticky failure until every acquired owner releases it', async () => { const failure = new Error('shared append failed'); let reportFailure!: (error: unknown) => void; @@ -788,14 +756,6 @@ describe('AppendLogStore', () => { expect(await storage.read(SESSION_SCOPE, KEY)).toBeUndefined(); }); - it('root-scope appends bypass the gate with zero authorities registered', async () => { - const { store, storage } = makeStore(); - store.append('', 'session_index.jsonl', { sessionId: 's1' }); - await store.flush(); - const bytes = await storage.read('', 'session_index.jsonl'); - expect(new TextDecoder().decode(bytes)).toBe('{"sessionId":"s1"}\n'); - }); - it('session-scoped writes without a registered authority fail closed', async () => { const { store, storage } = makeStore(); store.append(SESSION_SCOPE, KEY, { n: 1 }); @@ -807,16 +767,6 @@ describe('AppendLogStore', () => { expect(await storage.read(SESSION_SCOPE, KEY)).toBeUndefined(); }); - it('writes pass while the registered lease is held', async () => { - const lease = await leaseFor('s1'); - registry.register(lease); - const { store, storage } = makeStore(); - store.append(SESSION_SCOPE, KEY, { n: 1 }); - await store.flush(); - const bytes = await storage.read(SESSION_SCOPE, KEY); - expect(new TextDecoder().decode(bytes)).toBe('{"n":1}\n'); - }); - it('flush awaits the retired buffer final flush before returning', async () => { const lease = await leaseFor('s1'); registry.register(lease); diff --git a/packages/agent-core-v2/test/persistence/backends/node-fs/atomicDocumentStore.test.ts b/packages/agent-core-v2/test/persistence/backends/node-fs/atomicDocumentStore.test.ts index 3d6cc36898..2d59f9a197 100644 --- a/packages/agent-core-v2/test/persistence/backends/node-fs/atomicDocumentStore.test.ts +++ b/packages/agent-core-v2/test/persistence/backends/node-fs/atomicDocumentStore.test.ts @@ -71,16 +71,6 @@ describe('JsonAtomicDocumentStore', () => { expect(await config.get('session', 'state.json')).toEqual({ count: 2 }); }); - it('does not replace the document when an update callback fails', async () => { - await config.set('session', 'state.json', { count: 1 }); - await expect( - config.update('session', 'state.json', () => { - throw new Error('mutate failed'); - }), - ).rejects.toThrow('mutate failed'); - expect(await config.get('session', 'state.json')).toEqual({ count: 1 }); - }); - it('keys are independent', async () => { await config.set('session', 'a.json', { title: 'A' }); await config.set('session', 'b.json', { title: 'B' }); diff --git a/packages/agent-core-v2/test/session/agentLifecycle/agentLifecycle.test.ts b/packages/agent-core-v2/test/session/agentLifecycle/agentLifecycle.test.ts index 0cd36643f2..a989f0c894 100644 --- a/packages/agent-core-v2/test/session/agentLifecycle/agentLifecycle.test.ts +++ b/packages/agent-core-v2/test/session/agentLifecycle/agentLifecycle.test.ts @@ -397,49 +397,6 @@ describe('AgentLifecycleService', () => { expect(loopSettled).toHaveBeenCalledOnce(); }); - it('remove closes task admission before cancelling the active turn', async () => { - loopActiveTurnId = 1; - let admissionWasOpenAtCancellation = false; - beginTaskClose.mockImplementation(() => {}); - loopCancel.mockImplementation((turnId) => { - if (turnId === undefined) { - admissionWasOpenAtCancellation = beginTaskClose.mock.calls.length === 0; - loopActiveTurnId = undefined; - } - return true; - }); - const svc = ix.get(IAgentLifecycleService); - await svc.create({ agentId: 'main' }); - - await svc.remove('main'); - - expect(admissionWasOpenAtCancellation).toBe(false); - }); - - it('remove performs the final task drain after the active turn settles', async () => { - loopActiveTurnId = 1; - let producerSettled = false; - let stoppedBeforeProducerSettled = false; - let flushedBeforeProducerSettled = false; - loopSettled.mockImplementation(async () => { - producerSettled = true; - }); - stopAllOnExit.mockImplementation(async () => { - stoppedBeforeProducerSettled = !producerSettled; - return []; - }); - flushPersistence.mockImplementation(async () => { - flushedBeforeProducerSettled = !producerSettled; - }); - const svc = ix.get(IAgentLifecycleService); - await svc.create({ agentId: 'main' }); - - await svc.remove('main'); - - expect(stoppedBeforeProducerSettled).toBe(false); - expect(flushedBeforeProducerSettled).toBe(false); - }); - it('remove waits for an active full compaction to reject after aborting it', async () => { const abortController = new AbortController(); let rejectCompaction!: (reason: unknown) => void; diff --git a/packages/agent-core-v2/test/session/sessionFileLedger/fileLedger.test.ts b/packages/agent-core-v2/test/session/sessionFileLedger/fileLedger.test.ts index 0a2399d671..b45824ad49 100644 --- a/packages/agent-core-v2/test/session/sessionFileLedger/fileLedger.test.ts +++ b/packages/agent-core-v2/test/session/sessionFileLedger/fileLedger.test.ts @@ -1,9 +1,8 @@ /** * `sessionFileLedger` domain (L2) — verifies the optimistic-concurrency - * verdict matrix (clean / stale / no-baseline) against a real tmpdir and a - * real `HostFileSystem` with stat calls counted. Baselines are compared only - * against fresh stat tuples, and resolving or using the ledger never starts a - * workspace watcher. + * verdicts (clean / stale / no-baseline) against a real tmpdir and a real + * `HostFileSystem` with stat calls counted. Baselines are compared only + * against fresh stat tuples, and stat failures fail closed. */ import { mkdtempSync, rmSync, writeFileSync } from 'node:fs'; @@ -16,55 +15,33 @@ import { LifecycleScope } from '#/_base/di/scope'; import { createScopedTestHost, stubPair, type ScopedTestHost } from '#/_base/di/test'; import { unwrapErrorCause } from '#/_base/errors/errors'; import { IHostFileSystem } from '#/os/interface/hostFileSystem'; -import { IHostFsWatchService } from '#/os/interface/hostFsWatch'; -import { ISessionContext, makeSessionContext } from '#/session/sessionContext/sessionContext'; import { ISessionFileLedger } from '#/session/sessionFileLedger/fileLedger'; import { SessionFileLedger } from '#/session/sessionFileLedger/fileLedgerService'; -import { countingHostFs, fakeHostFsWatch, type FakeWatch } from '../sessionFs/stubs'; +import { countingHostFs } from '../sessionFs/stubs'; void SessionFileLedger; interface World { readonly workDir: string; - readonly outsideDir: string; readonly fs: IHostFileSystem; readonly ledger: ISessionFileLedger; - readonly fake: FakeWatch; readonly statCalls: () => number; readonly poisonedPaths: Set; } function makeWorld(): World { const workDir = mkdtempSync(join(tmpdir(), 'kimi-ledger-work-')); - const outsideDir = mkdtempSync(join(tmpdir(), 'kimi-ledger-out-')); - cleanupPaths.push(workDir, outsideDir); - const fake = fakeHostFsWatch(); + cleanupPaths.push(workDir); const poisonedPaths = new Set(); const { fs, statCalls } = countingHostFs(poisonedPaths); - const host = createScopedTestHost([ - stubPair(IHostFileSystem, fs), - stubPair(IHostFsWatchService, fake.service), - ]); - const session = host.child(LifecycleScope.Session, 's1', [ - stubPair( - ISessionContext, - makeSessionContext({ - sessionId: 's1', - workspaceId: 'ws', - sessionDir: join(workDir, '.session'), - sessionScope: 'sessions/ws/s1', - cwd: workDir, - }), - ), - ]); + const host = createScopedTestHost([stubPair(IHostFileSystem, fs)]); + const session = host.child(LifecycleScope.Session, 's1'); hosts.push(host); return { workDir, - outsideDir, fs, ledger: session.accessor.get(ISessionFileLedger), - fake, statCalls, poisonedPaths, }; @@ -95,38 +72,11 @@ describe('SessionFileLedger', () => { for (const path of cleanupPaths.splice(0)) rmSync(path, { recursive: true, force: true }); }); - it('returns clean for a baselined file with no changes', async () => { - const world = makeWorld(); - const file = join(world.workDir, 'a.txt'); - writeFileSync(file, 'hello'); - - await recordCurrentBaseline(world, file); - expect(await world.ledger.compare(file)).toBe('clean'); - }); - - it('returns no-baseline for an existing file never read or written', async () => { - const world = makeWorld(); - const file = join(world.workDir, 'a.txt'); - writeFileSync(file, 'hello'); - - expect(await world.ledger.compare(file)).toBe('no-baseline'); - }); - it('returns clean for a missing file (new-file creation is exempt)', async () => { const world = makeWorld(); expect(await world.ledger.compare(join(world.workDir, 'new.txt'))).toBe('clean'); }); - it('does not let watcher signals change the stat-only verdict', async () => { - const world = makeWorld(); - const file = join(world.workDir, 'a.txt'); - writeFileSync(file, 'hello'); - - world.fake.fire('a.txt', 'modified'); - - expect(await world.ledger.compare(file)).toBe('no-baseline'); - }); - it('returns stale when a baselined file is modified outside the session', async () => { const world = makeWorld(); const file = join(world.workDir, 'a.txt'); @@ -152,43 +102,6 @@ describe('SessionFileLedger', () => { expect(world.statCalls()).toBe(3); }); - it('tracks a write-then-delete baseline as non-existence', async () => { - const world = makeWorld(); - const file = join(world.workDir, 'a.txt'); - writeFileSync(file, 'hello'); - await recordCurrentBaseline(world, file); - - rmSync(file); - expect(await world.ledger.compare(file)).toBe('stale'); - - await recordCurrentBaseline(world, file); - expect(await world.ledger.compare(file)).toBe('clean'); - - writeFileSync(file, 'recreated'); - expect(await world.ledger.compare(file)).toBe('stale'); - }); - - it('uses the same stat-only comparison outside the workspace, never starting a watcher', async () => { - const world = makeWorld(); - const file = join(world.outsideDir, 'b.txt'); - writeFileSync(file, 'hello'); - - expect(await world.ledger.compare(file)).toBe('no-baseline'); - - await recordCurrentBaseline(world, file); - expect(await world.ledger.compare(file)).toBe('clean'); - - writeFileSync(file, 'hello world'); - expect(await world.ledger.compare(file)).toBe('stale'); - - await recordCurrentBaseline(world, file); - expect(await world.ledger.compare(file)).toBe('clean'); - - rmSync(file); - expect(await world.ledger.compare(file)).toBe('stale'); - expect(world.fake.watchCalls).toEqual([]); - }); - it('fails closed when stat fails for reasons other than not-found', async () => { const world = makeWorld(); const file = join(world.workDir, 'a.txt'); @@ -196,12 +109,6 @@ describe('SessionFileLedger', () => { await recordCurrentBaseline(world, file); world.poisonedPaths.add(file); - expect(await world.ledger.compare(file)).toBe('stale'); - - world.poisonedPaths.clear(); - await recordCurrentBaseline(world, file); - world.poisonedPaths.add(file); - expect(await world.ledger.compare(file)).toBe('stale'); expect(world.statCalls()).toBeGreaterThan(0); }); diff --git a/packages/agent-core-v2/test/session/sessionFs/fsService.test.ts b/packages/agent-core-v2/test/session/sessionFs/fsService.test.ts index 57a36ccddd..396ef412af 100644 --- a/packages/agent-core-v2/test/session/sessionFs/fsService.test.ts +++ b/packages/agent-core-v2/test/session/sessionFs/fsService.test.ts @@ -626,97 +626,6 @@ describe('SessionFsService.list', () => { }); }); -describe('SessionFsService gitignore cache invalidation', () => { - interface GitignoreState { - content: string | undefined; - mtimeMs: number; - } - - function gitignoreFs(state: GitignoreState, files: Record): IHostFileSystem { - const base = fakeFs(files); - const enoent = (p: string): NodeJS.ErrnoException => { - const err = new Error(`ENOENT: ${p}`) as NodeJS.ErrnoException; - err.code = 'ENOENT'; - return err; - }; - const gitignorePath = join(WORK_DIR, '.gitignore'); - return { - ...base, - readText: async (p) => { - if (p === gitignorePath) { - if (state.content === undefined) throw enoent(p); - return state.content; - } - return base.readText(p); - }, - stat: async (p) => { - if (p === gitignorePath) { - if (state.content === undefined) throw enoent(p); - return { - isFile: true, - isDirectory: false, - size: state.content.length, - mtimeMs: state.mtimeMs, - ino: 1, - }; - } - return base.stat(p); - }, - }; - } - - it('list() picks up `.gitignore` edits without rebuilding the service', async () => { - const state: GitignoreState = { content: 'dist/\n', mtimeMs: 1000 }; - const fs = makeSession( - {}, - emptyHandler, - [], - defaultGitStub(), - [], - undefined, - {}, - gitignoreFs(state, { 'src/keep.ts': '', 'dist/x.js': '' }), - ); - const baseReq = { - path: '.', - depth: 1, - limit: 200, - show_hidden: false, - follow_gitignore: true, - sort: 'name_asc' as const, - include_git_status: false, - }; - const before = await fs.list(baseReq); - expect(before.items.map((i) => i.name).sort()).toEqual(['src']); - - state.content = ''; - state.mtimeMs = 2000; - const after = await fs.list(baseReq); - expect(after.items.map((i) => i.name).sort()).toEqual(['dist', 'src']); - }); - - it('search() rebuilds the matcher when `.gitignore` is removed', async () => { - const state: GitignoreState = { content: 'dist/\n', mtimeMs: 1000 }; - const fs = makeSession( - {}, - emptyHandler, - [], - defaultGitStub(), - [], - undefined, - {}, - gitignoreFs(state, { 'dist/x.js': '' }), - ); - const before = await fs.search({ query: 'x.js', limit: 50, follow_gitignore: true }); - expect(before.items).toHaveLength(0); - - state.content = undefined; - state.mtimeMs = 2000; - const after = await fs.search({ query: 'x.js', limit: 50, follow_gitignore: true }); - expect(after.items.map((i) => i.path)).toEqual(['dist/x.js']); - }); -}); - describe('SessionFsService.read', () => { it('reads utf-8 content with metadata', async () => { const fs = makeSession({ 'src/a.ts': 'hello\nworld\n' }, emptyHandler); diff --git a/packages/agent-core-v2/test/session/sessionFs/fsWatchService.test.ts b/packages/agent-core-v2/test/session/sessionFs/fsWatchService.test.ts index 715d2d4a3b..8630bd4e11 100644 --- a/packages/agent-core-v2/test/session/sessionFs/fsWatchService.test.ts +++ b/packages/agent-core-v2/test/session/sessionFs/fsWatchService.test.ts @@ -146,40 +146,6 @@ describe('SessionFsWatchService', () => { expect(events[0]?.changes.map((c) => c.path)).toEqual(['src/keep.ts']); }); - it('lets events through while the initial `.gitignore` load is in flight', async () => { - let releaseLoad: ((content: string) => void) | undefined; - const pendingLoad = new Promise((res) => { - releaseLoad = res; - }); - const hostFs = { - _serviceBrand: undefined, - readText: async (p: string) => { - if (p === join(WORK_DIR, '.gitignore')) return pendingLoad; - const err = new Error(`ENOENT: ${p}`) as NodeJS.ErrnoException; - err.code = 'ENOENT'; - throw err; - }, - } as unknown as IHostFileSystem; - const { svc, watch, events } = makeSession(undefined, hostFs); - svc.setWatchedPaths(['.']); - - // The rules are not loaded yet: filtering is conservative (only `.git/`), - // so a path that will later turn out to be ignored still gets delivered. - watch.fire('dist/x.js', 'created'); - vi.advanceTimersByTime(200); - expect(events).toHaveLength(1); - expect(events[0]?.changes.map((c) => c.path)).toEqual(['dist/x.js']); - - releaseLoad!('dist/\n'); - await pendingLoad; - await Promise.resolve(); - await Promise.resolve(); - - watch.fire('dist/y.js', 'created'); - vi.advanceTimersByTime(200); - expect(events).toHaveLength(1); - }); - it('rebuilds the matcher when the workspace `.gitignore` changes', async () => { let gitignore = 'dist/\n'; const hostFs = { diff --git a/packages/agent-core-v2/test/session/sessionLease/sessionLease.test.ts b/packages/agent-core-v2/test/session/sessionLease/sessionLease.test.ts index fe44c2c36e..ae7613413d 100644 --- a/packages/agent-core-v2/test/session/sessionLease/sessionLease.test.ts +++ b/packages/agent-core-v2/test/session/sessionLease/sessionLease.test.ts @@ -2,32 +2,21 @@ * `sessionLease` domain — unit tests for the per-session write lease. * * Runs against the real node-local kernel-lock service rooted at a mkdtemp - * home, asserting permanent sentinel behavior, the once-only loss - * notification, idempotent release, and contact-provider seed semantics. + * home, asserting the once-only loss notification and idempotent release. */ -import { existsSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs'; +import { mkdtempSync, rmSync, writeFileSync } from 'node:fs'; import { tmpdir } from 'node:os'; import { join } from 'node:path'; import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; -import { createScopedTestHost, type ScopedTestHost } from '#/_base/di/test'; import { Error2, ErrorCodes } from '#/errors'; import { CrossProcessLockService } from '#/os/backends/node-local/crossProcessLockService'; -import { - ISessionLeaseContactProvider, - sessionLeaseContactSeed, -} from '#/session/sessionLease/sessionLeaseContactProvider'; -import { - LEASE_CREATING_RETRY_AFTER_MS, - SessionLease, - sessionLeasePath, -} from '#/session/sessionLease/sessionLease'; +import { SessionLease, sessionLeasePath } from '#/session/sessionLease/sessionLease'; let tmpDir: string; let locks: CrossProcessLockService; -const hosts: ScopedTestHost[] = []; beforeEach(() => { tmpDir = mkdtempSync(join(tmpdir(), 'kimi-session-lease-')); @@ -35,7 +24,6 @@ beforeEach(() => { }); afterEach(() => { - for (const host of hosts.splice(0)) host.dispose(); rmSync(tmpDir, { recursive: true, force: true }); }); @@ -59,20 +47,7 @@ function thrownError(fn: () => void): Error2 { throw new Error('expected the call to throw'); } -function hostWith(seeds: Parameters[0] = []): ScopedTestHost { - const host = createScopedTestHost(seeds); - hosts.push(host); - return host; -} - describe('SessionLease', () => { - it('reports its identity through info and passes the hard gate while held', async () => { - const lease = await acquire(); - expect(lease.info).toEqual({ sessionId: 's1', lockId: lease.lockId }); - expect(() => lease.assertWritable()).not.toThrow(); - lease.release(); - }); - it('fires the loss notification once and then fails closed', async () => { const onLost = vi.fn(); const lease = await acquire('s1', onLost); @@ -87,51 +62,12 @@ describe('SessionLease', () => { expect(onLost).toHaveBeenCalledTimes(1); }); - it('release is idempotent, keeps the sentinel, and later assertions throw', async () => { + it('release is idempotent and later assertions throw', async () => { const lease = await acquire(); lease.release(); lease.release(); expect(lease.info).toBeUndefined(); - expect(existsSync(sessionLeasePath(tmpDir, 's1'))).toBe(true); - expect(existsSync(`${sessionLeasePath(tmpDir, 's1')}.owner.json`)).toBe(false); expect(thrownError(() => lease.assertWritable()).code).toBe(ErrorCodes.SESSION_LEASE_LOST); }); - - it('exports only the retry delay for an owner metadata creation window', () => { - expect(LEASE_CREATING_RETRY_AFTER_MS).toBe(1000); - }); - - it('sessionLeasePath lives under /session-leases/', () => { - expect(sessionLeasePath('/home/kimi', 'abc')).toBe( - join('/home/kimi', 'session-leases', 'abc.lock'), - ); - }); -}); - -describe('session lease contact provider', () => { - it('resolves a local contact by default when the host seeds nothing', () => { - const host = hostWith(); - expect(host.app.accessor.get(ISessionLeaseContactProvider).contact()).toEqual({ - type: 'local', - }); - }); - - it('the seed overrides the registered default with the host address', () => { - const host = hostWith( - sessionLeaseContactSeed(() => ({ type: 'address', address: 'http://127.0.0.1:8080' })), - ); - expect(host.app.accessor.get(ISessionLeaseContactProvider).contact()).toEqual({ - type: 'address', - address: 'http://127.0.0.1:8080', - }); - }); - - it('evaluates the contact lazily at every lease acquisition', () => { - let contact: { type: 'address'; address: string } | { type: 'local' } = { type: 'local' }; - const host = hostWith(sessionLeaseContactSeed(() => contact)); - const provider = host.app.accessor.get(ISessionLeaseContactProvider); - contact = { type: 'address', address: 'http://127.0.0.1:9999' }; - expect(provider.contact()).toEqual({ type: 'address', address: 'http://127.0.0.1:9999' }); - }); }); diff --git a/packages/agent-core-v2/test/session/sessionMetadata/sessionMetadata.test.ts b/packages/agent-core-v2/test/session/sessionMetadata/sessionMetadata.test.ts index 0a6f07e3d0..b859fac6cc 100644 --- a/packages/agent-core-v2/test/session/sessionMetadata/sessionMetadata.test.ts +++ b/packages/agent-core-v2/test/session/sessionMetadata/sessionMetadata.test.ts @@ -279,33 +279,4 @@ describe('SessionMetadata', () => { .get<{ title?: string }>(META_SCOPE, 'state.json'); expect(raw?.title).toBeUndefined(); }); - - it('gates the load-time heal write behind the lease', async () => { - const store = ix.get(IAtomicDocumentStore); - await store.set(META_SCOPE, 'state.json', { - id: 's1', - version: 2, - createdAt: 1700000000000, - updatedAt: 1700000000000, - archived: false, - }); - ix.stub( - ISessionLeaseService, - stubSessionLeaseService({ - assertWritable: () => { - throw new Error2(ErrorCodes.SESSION_LEASE_LOST, 'lease lost', { - details: { sessionId: 's1' }, - }); - }, - }), - ); - - const meta = ix.get(ISessionMetadata); - await expect(meta.ready).rejects.toMatchObject({ - code: ErrorCodes.SESSION_LEASE_LOST, - }); - - const raw = await store.get<{ agents?: unknown }>(META_SCOPE, 'state.json'); - expect(raw?.agents).toBeUndefined(); - }); }); diff --git a/packages/agent-core-v2/test/session/sessionSkillCatalog/skillHotReload.test.ts b/packages/agent-core-v2/test/session/sessionSkillCatalog/skillHotReload.test.ts index 517b7c8ae1..489c7e9a10 100644 --- a/packages/agent-core-v2/test/session/sessionSkillCatalog/skillHotReload.test.ts +++ b/packages/agent-core-v2/test/session/sessionSkillCatalog/skillHotReload.test.ts @@ -138,18 +138,6 @@ describe('skill hot reload', () => { expect(changes.filter((id) => id === 'user').length).toBeGreaterThanOrEqual(3); }, 30000); - it('watches a user generic root that does not exist at load time', async () => { - const { catalog, base } = await fixture(); - const genericRoot = join(base, 'os-home', '.agents', 'skills'); - - await catalog.load(); - await wait(400); - expect(catalog.catalog.getSkill('generic-one')).toBeUndefined(); - - await writeSkill(genericRoot, 'generic-one'); - await waitFor(() => catalog.catalog.getSkill('generic-one') !== undefined, 'generic-one appears'); - }, 30000); - it('a burst of writes collapses into a bounded number of catalog reloads', async () => { const { catalog, changes, base } = await fixture(); const userRoot = join(base, 'home', 'skills'); @@ -224,25 +212,6 @@ describe('skill hot reload', () => { await waitFor(() => catalog.catalog.getSkill('explicit-one') !== undefined, 'explicit-one appears'); }, 30000); - it('two independent containers on the same roots each see the change', async () => { - const base = await makeBase(); - tmpdirs.push(base); - const first = makeHost(base); - const second = makeHost(base); - fixtures.push(first, second); - - await first.catalog.load(); - await second.catalog.load(); - await wait(400); - - await writeSkill(join(base, 'home', 'skills'), 'shared-hot'); - await waitFor(() => first.catalog.catalog.getSkill('shared-hot') !== undefined, 'first sees shared-hot'); - await waitFor(() => second.catalog.catalog.getSkill('shared-hot') !== undefined, 'second sees shared-hot'); - - expect(first.catalog.catalog.getSkill('shared-hot')).toBeDefined(); - expect(second.catalog.catalog.getSkill('shared-hot')).toBeDefined(); - }, 30000); - it('session dispose stops its watchers', async () => { const { catalog, session, changes, base } = await fixture(); await catalog.load(); diff --git a/packages/agent-core-v2/test/session/sessionSkillCatalog/stubs.ts b/packages/agent-core-v2/test/session/sessionSkillCatalog/stubs.ts index 5391913b48..8eff0796c9 100644 --- a/packages/agent-core-v2/test/session/sessionSkillCatalog/stubs.ts +++ b/packages/agent-core-v2/test/session/sessionSkillCatalog/stubs.ts @@ -1,12 +1,4 @@ -/** - * `sessionSkillCatalog` test stubs — shared config / plugin / workspace - * fakes for the catalog scenario tests in this directory. - * - * `configStub()` serves the skill-catalog config sections in memory and can - * fire synthetic section changes; `pluginStub()` is an inert plugin service - * with optional skill roots and reload event; `workspaceStub()` is a mutable - * in-memory workspace context. Import from a relative path (`./stubs`). - */ +/** `sessionSkillCatalog` test stubs — config / plugin / workspace fakes for catalog tests. */ import type { Emitter } from '#/_base/event'; import { IConfigService } from '#/app/config/config'; @@ -19,49 +11,30 @@ import { import type { SkillRoot } from '#/app/skillCatalog/types'; import { ISessionWorkspaceContext } from '#/session/workspaceContext/workspaceContext'; -export function configStub(): IConfigService & { +type ConfigStub = IConfigService & { setExtraSkillDirs(dirs: readonly string[]): void; - setMergeAllAvailableSkills(value: boolean): void; fireSectionChange(domain: string): void; -} { +}; + +export function configStub(): ConfigStub { let extraSkillDirs: readonly string[] = []; - let mergeAllAvailableSkills = true; - const sectionChangeListeners: Array<(event: unknown) => void> = []; + const listeners: Array<(event: unknown) => void> = []; return { _serviceBrand: undefined, ready: Promise.resolve(), onDidChangeConfiguration: () => ({ dispose: () => {} }), onDidSectionChange: (listener: (event: unknown) => void) => { - sectionChangeListeners.push(listener); + listeners.push(listener); return { dispose: () => {} }; }, get: (domain: string) => { if (domain === EXTRA_SKILL_DIRS_SECTION) return [...extraSkillDirs]; - if (domain === MERGE_ALL_AVAILABLE_SKILLS_SECTION) return mergeAllAvailableSkills; + if (domain === MERGE_ALL_AVAILABLE_SKILLS_SECTION) return true; return undefined; }, - inspect: () => ({ value: undefined, defaultValue: undefined, userValue: undefined, memoryValue: undefined }), - getAll: () => ({}), - set: async () => {}, - replace: async () => {}, - reload: async () => {}, - diagnostics: () => [], - setExtraSkillDirs: (dirs: readonly string[]) => { - extraSkillDirs = [...dirs]; - }, - setMergeAllAvailableSkills: (value: boolean) => { - mergeAllAvailableSkills = value; - }, - fireSectionChange: (domain: string) => { - for (const listener of sectionChangeListeners) { - listener({ domain, source: 'set', value: undefined, previousValue: undefined }); - } - }, - } as unknown as IConfigService & { - setExtraSkillDirs(dirs: readonly string[]): void; - setMergeAllAvailableSkills(value: boolean): void; - fireSectionChange(domain: string): void; - }; + setExtraSkillDirs: (dirs: readonly string[]) => (extraSkillDirs = [...dirs]), + fireSectionChange: (domain: string) => listeners.forEach((l) => l({ domain, source: 'set' })), + } as unknown as ConfigStub; } export function pluginStub( @@ -71,22 +44,8 @@ export function pluginStub( return { _serviceBrand: undefined, onDidReload: reloadEmitter !== undefined ? reloadEmitter.event : () => ({ dispose: () => {} }), - listPlugins: async () => [], - installPlugin: async () => ({ id: '' }) as never, - setPluginEnabled: async () => {}, - setPluginMcpServerEnabled: async () => {}, - removePlugin: async () => {}, - reloadPlugins: async () => ({ added: [], removed: [], errors: [] }), - getPluginInfo: async () => { - throw new Error('getPluginInfo is not used by these tests'); - }, - listPluginCommands: async () => [], - checkUpdates: async () => [], pluginSkillRoots: async () => skillRoots, - enabledSessionStarts: async () => [], - enabledMcpServers: async () => ({}), - enabledHooks: async () => [], - }; + } as unknown as IPluginService; } export function workspaceStub(workDir: string): { @@ -100,15 +59,6 @@ export function workspaceStub(workDir: string): { return current; }, additionalDirs: [] as readonly string[], - setWorkDir: (dir: string) => { - current = dir; - }, - setAdditionalDirs: () => {}, - resolve: (rel: string) => rel, - isWithin: () => true, - assertAllowed: (p: string) => p, - addAdditionalDir: () => {}, - removeAdditionalDir: () => {}, - } satisfies ISessionWorkspaceContext; - return { stub, setWorkDir: (dir) => { current = dir; } }; + } as unknown as ISessionWorkspaceContext; + return { stub, setWorkDir: (dir: string) => (current = dir) }; } diff --git a/packages/kap-server/test/fs-watch.e2e.test.ts b/packages/kap-server/test/fs-watch.e2e.test.ts index 45d03a462f..0a8350e45b 100644 --- a/packages/kap-server/test/fs-watch.e2e.test.ts +++ b/packages/kap-server/test/fs-watch.e2e.test.ts @@ -365,61 +365,6 @@ describe('WS fs watch (kap-server)', () => { b.ws.close(); }); - it('two clients on the same path see the same event numbered identically from 1', async () => { - const r = await boot(); - const sid = await createSession(r); - const a = await openConn(wsUrl(r)); - const b = await openConn(wsUrl(r)); - await helloAndSubscribe(a, 'A', sid); - await helloAndSubscribe(b, 'B', sid); - - for (const [conn, id] of [[a, 'wA'], [b, 'wB']] as const) { - conn.ws.send( - JSON.stringify({ - type: 'watch_fs_add', - id, - payload: { session_id: sid, paths: ['src'] }, - }), - ); - await receiveType(conn, 'ack', 1000); - } - - await sleep(WATCH_SETTLE_MS); - writeFileSync(join(workspace, 'src', 'one.ts'), '1'); - // Keep the two writes in separate debounce windows so each yields one frame. - await sleep(500); - writeFileSync(join(workspace, 'src', 'two.ts'), '2'); - - const collectSeqs = async (conn: Conn, ms: number): Promise => { - const seqs: number[] = []; - const deadline = Date.now() + ms; - for (;;) { - const remaining = deadline - Date.now(); - if (remaining <= 0) break; - let frame: WsFrame; - try { - frame = await receive(conn, remaining); - } catch { - break; - } - if (frame.type === 'event.fs.changed' && typeof frame.seq === 'number') { - seqs.push(frame.seq); - } - } - return seqs; - }; - - const [seqsA, seqsB] = await Promise.all([collectSeqs(a, 2000), collectSeqs(b, 2000)]); - // Identical subscriptions receive identical streams: the same per-connection - // seqs, each numbered from 1 with no gaps. - expect(seqsA.length).toBeGreaterThanOrEqual(2); - expect(seqsA).toEqual(seqsB); - expect(seqsA).toEqual(seqsA.map((_, i) => i + 1)); - - a.ws.close(); - b.ws.close(); - }); - it('> 100 paths on one connection → 42902 fs.watch_limit_exceeded', async () => { const r = await boot(); const sid = await createSession(r); diff --git a/packages/kap-server/test/helpers/sessionEvents.ts b/packages/kap-server/test/helpers/sessionEvents.ts new file mode 100644 index 0000000000..7e88507a9a --- /dev/null +++ b/packages/kap-server/test/helpers/sessionEvents.ts @@ -0,0 +1,26 @@ +/** + * Shared fakes for the WS v1 session-event tests (`sessionEventBroadcaster`, + * `sessionEventJournal`, `skillCatalogBridge`). + */ +import { type SessionLifecycleHooks } from '@moonshot-ai/agent-core-v2'; +import { createHooks } from '@moonshot-ai/agent-core-v2/hooks'; + +/** The lifecycle hook set the fake cores expose at `ISessionLifecycleService.hooks`. */ +export function createSessionLifecycleHooks() { + return createHooks([ + 'onDidCreateSession', + 'onWillCloseSession', + 'onWillReleaseSession', + ]); +} + +/** + * Build the write failure the `node:fs/promises` mocks inject into journal + * flushes: a plain `EACCES` errno error — the ordinary storage failure the + * journal's retry-then-sticky durability tests drive. + */ +export function injectWriteFailure(): Error { + const error = new Error('injected journal write failure') as NodeJS.ErrnoException; + error.code = 'EACCES'; + return error; +} diff --git a/packages/kap-server/test/session-ownership.e2e.test.ts b/packages/kap-server/test/session-ownership.e2e.test.ts deleted file mode 100644 index e50db2405e..0000000000 --- a/packages/kap-server/test/session-ownership.e2e.test.ts +++ /dev/null @@ -1,153 +0,0 @@ -/** - * Multi-server session ownership — two kap-servers on ONE kimi home. Instance - * A creates the session and holds its write lease; instance B's materializing - * routes for the same session are answered with - * `40921 session.held_by_peer` carrying the structured ownership details - * (phase `routable` + A's address), so clients can redirect to the holder. - * Closing A releases the lease and B takes over. - * Run: `pnpm --filter @moonshot-ai/kap-server exec vitest run test/session-ownership.e2e.test.ts`. - */ -import { mkdtemp, readFile, rm } from 'node:fs/promises'; -import { tmpdir } from 'node:os'; -import { join } from 'node:path'; - -import { sessionLeasePath } from '@moonshot-ai/agent-core-v2'; -import { ErrorCode } from '../src/protocol/error-codes'; -import { afterEach, describe, expect, it } from 'vitest'; - -import { type RunningServer, startServer } from '../src/start'; - -interface Envelope { - code: number; - msg: string; - data: T; - request_id: string; - details?: unknown; - stack?: string; -} - -interface SessionWire { - id: string; -} - -describe('multi-server session ownership (session.held_by_peer → 40921)', () => { - let home: string | undefined; - let serverA: RunningServer | undefined; - let serverB: RunningServer | undefined; - - afterEach(async () => { - if (serverB !== undefined) { - await serverB.close(); - serverB = undefined; - } - if (serverA !== undefined) { - await serverA.close(); - serverA = undefined; - } - if (home !== undefined) { - await rm(home, { recursive: true, force: true, maxRetries: 5, retryDelay: 50 } as never); - home = undefined; - } - }); - - async function boot(): Promise { - return startServer({ - host: '127.0.0.1', - port: 0, - homeDir: home as string, - logLevel: 'silent', - disableAuth: true, - }); - } - - function base(server: RunningServer): string { - return `http://127.0.0.1:${server.port}`; - } - - async function getJson( - server: RunningServer, - path: string, - ): Promise<{ status: number; body: Envelope }> { - const res = await fetch(`${base(server)}${path}`); - return { status: res.status, body: (await res.json()) as Envelope }; - } - - /** Boot A + B on the shared home and create a session owned by A. */ - async function bootPairWithSession(): Promise { - home = await mkdtemp(join(tmpdir(), 'kimi-server-v2-ownership-')); - serverA = await boot(); - serverB = await boot(); - const res = await fetch(`${base(serverA)}/api/v1/sessions`, { - method: 'POST', - headers: { 'content-type': 'application/json' }, - body: JSON.stringify({ metadata: { cwd: home } }), - }); - const body = (await res.json()) as Envelope; - expect(body.code).toBe(0); - return body.data.id; - } - - it( - 'B surfaces 40921 with routable ownership details, and A records its address in the lease file', - async () => { - const sessionId = await bootPairWithSession(); - const addressA = base(serverA as RunningServer); - - // Lease file: A advertises the URL it actually bound (post-listen swap - // of the contact ref), so a contended peer knows where to redirect. - const lease = JSON.parse( - await readFile(`${sessionLeasePath(home as string, sessionId)}.owner.json`, 'utf8'), - ) as Record; - expect(lease['address']).toBe(addressA); - expect(typeof lease['lock_id']).toBe('string'); - - // Per-route mapper path (prompts.ts `sendMappedError` switch). - const prompts = await getJson( - serverB as RunningServer, - `/api/v1/sessions/${sessionId}/prompts`, - ); - expect(prompts.status).toBe(200); - expect(prompts.body.code).toBe(ErrorCode.SESSION_HELD_BY_PEER); - expect(prompts.body.code).toBe(40921); - expect(prompts.body.details).toEqual({ - kind: 'held-by-peer', - phase: 'routable', - address: addressA, - }); - - // Global error-handler path (warnings resumes without a local switch). - const warnings = await getJson( - serverB as RunningServer, - `/api/v1/sessions/${sessionId}/warnings`, - ); - expect(warnings.status).toBe(200); - expect(warnings.body.code).toBe(ErrorCode.SESSION_HELD_BY_PEER); - expect(warnings.body.details).toEqual({ - kind: 'held-by-peer', - phase: 'routable', - address: addressA, - }); - }, - 30_000, - ); - - it( - 'closing the holder releases the lease so the peer materializes the session instead of 40921', - async () => { - const sessionId = await bootPairWithSession(); - - await (serverA as RunningServer).close(); - serverA = undefined; - - const warnings = await getJson<{ warnings: unknown[] }>( - serverB as RunningServer, - `/api/v1/sessions/${sessionId}/warnings`, - ); - expect(warnings.status).toBe(200); - expect(warnings.body.code).toBe(0); - expect(warnings.body.code).not.toBe(ErrorCode.SESSION_HELD_BY_PEER); - expect(warnings.body.data.warnings).toEqual([]); - }, - 30_000, - ); -}); diff --git a/packages/kap-server/test/sessionEventBroadcaster.test.ts b/packages/kap-server/test/sessionEventBroadcaster.test.ts index 608c37b13d..a6a6520f4b 100644 --- a/packages/kap-server/test/sessionEventBroadcaster.test.ts +++ b/packages/kap-server/test/sessionEventBroadcaster.test.ts @@ -24,7 +24,6 @@ import { type SessionLifecycleHooks, type Scope, } from '@moonshot-ai/agent-core-v2'; -import { createHooks } from '@moonshot-ai/agent-core-v2/hooks'; import type { AgentEvent } from '../src/transport/ws/v1/events'; import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; @@ -37,6 +36,7 @@ import { JournalStorageError, } from '../src/transport/ws/v1/sessionEventJournal'; import { TranscriptService } from '../src/services/transcript/transcriptService'; +import { createSessionLifecycleHooks } from './helpers/sessionEvents'; /** * Controllable deferred-failure injection for the journal under test. The @@ -250,14 +250,6 @@ function makeCore( return { accessor } as unknown as Scope; } -function createSessionLifecycleHooks() { - return createHooks([ - 'onDidCreateSession', - 'onWillCloseSession', - 'onWillReleaseSession', - ]); -} - function agentEvent(type: string, extra: Record = {}): AgentEvent { return { type, ...extra } as unknown as AgentEvent; } @@ -673,64 +665,41 @@ describe('SessionEventBroadcaster', () => { expect(next.subagents).toEqual([]); }); - it('fans core model-catalog changes out live without journaling __global__', async () => { - const lc = new FakeLifecycle(); - lc.addAgent('main'); - sessions.set('s1', lc); - const { target, envelopes } = collectingTarget(); - await bc.subscribe('s1', target); - - eventBus.emit({ - type: 'event.model_catalog.changed', - payload: { - changed: [{ provider_id: 'managed:kimi-code', provider_name: 'Kimi Code', added: 1, removed: 0 }], - unchanged: [], - failed: [], - }, - }); - - await vi.waitFor(() => expect(envelopes).toHaveLength(1)); - expect(envelopes[0]).toMatchObject({ - type: 'event.model_catalog.changed', - session_id: '__global__', - volatile: true, - payload: { + it.each([ + { + event: { type: 'event.model_catalog.changed', - agentId: 'main', - sessionId: '__global__', + payload: { + changed: [ + { provider_id: 'managed:kimi-code', provider_name: 'Kimi Code', added: 1, removed: 0 }, + ], + unchanged: [], + failed: [], + }, }, - }); - // Advisory-only now: fanned out live with a process-local seq and never - // journaled — no __global__.jsonl garbage file appears under the events - // dir (the old durable file was multi-writer by nature and had no reader). - await expect(stat(join(dir, '__global__.jsonl'))).rejects.toMatchObject({ code: 'ENOENT' }); - }); - - it('fans session.list_changed out live as a volatile payload-less global hint', async () => { - // The multi-instance sessions-tree watcher publishes this core event when - // a workspace/session directory appears or disappears (possibly created - // by a peer instance). It must reach every connection as a volatile, - // payload-less, unjournaled go-refetch hint — same delivery class as - // event.model_catalog.changed, with nothing to replay. + }, + // The multi-instance sessions-tree watcher publishes session.list_changed + // when a workspace/session directory appears or disappears (possibly + // created by a peer instance) — same delivery class as model-catalog. + { event: { type: 'session.list_changed', payload: {} } }, + ])('fans $event.type out live as a volatile __global__ hint without journaling', async ({ event }) => { const lc = new FakeLifecycle(); lc.addAgent('main'); sessions.set('s1', lc); const { target, envelopes } = collectingTarget(); await bc.subscribe('s1', target); - eventBus.emit({ type: 'session.list_changed', payload: {} }); + eventBus.emit(event); await vi.waitFor(() => expect(envelopes).toHaveLength(1)); expect(envelopes[0]).toMatchObject({ - type: 'session.list_changed', + type: event.type, session_id: '__global__', volatile: true, - payload: { - type: 'session.list_changed', - agentId: 'main', - sessionId: '__global__', - }, + payload: { type: event.type, agentId: 'main', sessionId: '__global__' }, }); + // Advisory-only: fanned out live with a process-local seq and never + // journaled — no __global__.jsonl garbage file under the events dir. await expect(stat(join(dir, '__global__.jsonl'))).rejects.toMatchObject({ code: 'ENOENT' }); }); diff --git a/packages/kap-server/test/sessionEventJournal.test.ts b/packages/kap-server/test/sessionEventJournal.test.ts index a276e00129..94378c2de5 100644 --- a/packages/kap-server/test/sessionEventJournal.test.ts +++ b/packages/kap-server/test/sessionEventJournal.test.ts @@ -69,12 +69,9 @@ describe('SessionEventJournal', () => { it('assigns monotonic seq and reads back in order', async () => { const j = await SessionEventJournal.open(filePath); - // No baseline before the first durable append — a cold journal has no epoch. - expect(j.epoch).toBeUndefined(); expect(j.seq).toBe(0); j.append(j.nextSeq(), envelope(1)); - expect(j.epoch).toMatch(/^ep_/); // materialized on the first real append j.append(j.nextSeq(), envelope(2)); j.append(j.nextSeq(), envelope(3)); expect(j.seq).toBe(3); @@ -119,24 +116,6 @@ describe('SessionEventJournal', () => { await j2.close(); }); - it('adopts the LAST header when a rotated journal carries several', async () => { - // A crash after a rotation can leave multiple headers in one file; only - // the newest incarnation is authoritative. - const lines = [ - JSON.stringify({ kind: 'journal_header', version: 1, epoch: 'ep_old', created_at: 1 }), - JSON.stringify({ kind: 'event', seq: 1, envelope: envelope(1) }), - JSON.stringify({ kind: 'journal_header', version: 1, epoch: 'ep_new', created_at: 2 }), - JSON.stringify({ kind: 'event', seq: 1, envelope: envelope(1) }), - ]; - await writeFile(filePath, lines.join('\n') + '\n', 'utf8'); - - const j = await SessionEventJournal.open(filePath); - expect(j.epoch).toBe('ep_new'); - expect(j.seq).toBe(1); - expect((await j.readSince(0, 100)).map((entry) => entry.seq)).toEqual([1]); - await j.close(); - }); - it('repairs a torn trailing line before appending the next event', async () => { const j1 = await SessionEventJournal.open(filePath); j1.append(j1.nextSeq(), envelope(1)); @@ -152,71 +131,6 @@ describe('SessionEventJournal', () => { expect((await j2.readSince(0, 100)).map((entry) => entry.seq)).toEqual([1, 2]); }); - it('rejects a malformed middle line while serving replay', async () => { - const j1 = await SessionEventJournal.open(filePath); - j1.append(j1.nextSeq(), envelope(1)); - await j1.close(); - - const durable = await readFile(filePath, 'utf8'); - const j2 = await SessionEventJournal.open(filePath); - await writeFile( - filePath, - `${durable}not-json\n${durable.split('\n')[1]}\n`, - 'utf8', - ); - await expect(j2.readSince(0, 100)).rejects.toBeInstanceOf(JournalStorageError); - await j2.close(); - }); - - it('starts the first event after a trailing replacement header at seq one', async () => { - const lines = [ - JSON.stringify({ kind: 'journal_header', version: 1, epoch: 'ep_old', created_at: 1 }), - JSON.stringify({ kind: 'event', seq: 1, envelope: envelope(1) }), - JSON.stringify({ kind: 'journal_header', version: 1, epoch: 'ep_new', created_at: 2 }), - ]; - await writeFile(filePath, lines.join('\n') + '\n', 'utf8'); - - const j = await SessionEventJournal.open(filePath); - expect(j.epoch).toBe('ep_new'); - expect(j.seq).toBe(0); - j.append(j.nextSeq(), envelope(1)); - await j.close(); - - expect((await j.readSince(0, 100)).map((entry) => entry.seq)).toEqual([1]); - }); - - it('cold reads are read-only: no epoch is fabricated and nothing is written', async () => { - // Missing file — open → close must not create it. - const j1 = await SessionEventJournal.open(filePath); - expect(j1.epoch).toBeUndefined(); - expect(j1.seq).toBe(0); - await j1.close(); - await expect(stat(filePath)).rejects.toMatchObject({ code: 'ENOENT' }); - - // Re-open of the same absent journal: the same absent baseline — no - // random-epoch flip-flop (which used to trigger fake `epoch_changed`). - const j2 = await SessionEventJournal.open(filePath); - expect(j2.epoch).toBeUndefined(); - await j2.close(); - await expect(stat(filePath)).rejects.toMatchObject({ code: 'ENOENT' }); - - // Pre-created empty file: stays zero bytes across open → close. - await writeFile(filePath, '', 'utf8'); - const j3 = await SessionEventJournal.open(filePath); - expect(j3.epoch).toBeUndefined(); - await j3.close(); - expect(await readFile(filePath, 'utf8')).toBe(''); - - // The epoch only materializes when the first durable event actually lands. - j3.append(j3.nextSeq(), envelope(1)); - expect(j3.epoch).toMatch(/^ep_/); - await j3.close(); - const j4 = await SessionEventJournal.open(filePath); - expect(j4.epoch).toBe(j3.epoch); - expect(j4.seq).toBe(1); - await j4.close(); - }); - it('readSince honors the exclusive lower bound and the limit', async () => { const j = await SessionEventJournal.open(filePath); for (let i = 1; i <= 5; i++) j.append(j.nextSeq(), envelope(i)); @@ -226,15 +140,10 @@ describe('SessionEventJournal', () => { await j.close(); }); - it('readSince on a missing file returns empty (and writes nothing)', async () => { + it('readSince on a missing file returns empty', async () => { const j = await SessionEventJournal.open(filePath); - expect(j.epoch).toBeUndefined(); - const out = await j.readSince(0, 100); - expect(out).toEqual([]); - // A pure read never fabricates an epoch or a header. - expect(j.epoch).toBeUndefined(); + expect(await j.readSince(0, 100)).toEqual([]); await j.close(); - await expect(stat(filePath)).rejects.toMatchObject({ code: 'ENOENT' }); }); it('keeps pending lines across a failed flush and replays them after a retry', async () => { diff --git a/packages/kap-server/test/sessionListWatch.test.ts b/packages/kap-server/test/sessionListWatch.test.ts index 431f953970..10f1913f89 100644 --- a/packages/kap-server/test/sessionListWatch.test.ts +++ b/packages/kap-server/test/sessionListWatch.test.ts @@ -92,23 +92,6 @@ describe('SessionListWatchService', () => { expect(event).toEqual({ type: 'session.list_changed', payload: {} }); } - it( - 'emits a hint when a second session dir appears under an existing workspace, and boot stays silent', - { timeout: 20_000 }, - async () => { - mkdirSync(join(sessionsDir, 'ws1', 's1'), { recursive: true }); - await boot(); - - // ignoreInitial everywhere: pre-existing workspace/session trees must - // not produce a boot flood before anything actually changes. - expect(published).toHaveLength(0); - - mkdirSync(join(sessionsDir, 'ws1', 's2')); - await waitForHints(1); - expectHintShape(published[0]); - }, - ); - it( 'emits a hint when a new workspace appears, and again for a session created inside it', { timeout: 20_000 }, @@ -138,6 +121,9 @@ describe('SessionListWatchService', () => { mkdirSync(join(sessionsDir, 'ws1', 's2'), { recursive: true }); await boot(); + // ignoreInitial: pre-existing workspace/session trees stay silent. + expect(published).toHaveLength(0); + rmSync(join(sessionsDir, 'ws1', 's2'), { recursive: true, force: true }); await waitForHints(1); expectHintShape(published[0]); diff --git a/packages/kap-server/test/sessions.test.ts b/packages/kap-server/test/sessions.test.ts index a01a8f670f..df90dd46ad 100644 --- a/packages/kap-server/test/sessions.test.ts +++ b/packages/kap-server/test/sessions.test.ts @@ -17,10 +17,12 @@ import { type DomainEvent, IAgentGoalService, IAgentLifecycleService, + ICrossProcessLockService, IEventBus, IEventService, ISessionLifecycleService, MAIN_AGENT_ID, + sessionLeasePath, type ServiceIdentifier, } from '@moonshot-ai/agent-core-v2'; import { sessionWarningsResponseSchema } from '@moonshot-ai/agent-core-v2/app/sessionLegacy/sessionProtocol'; @@ -365,8 +367,6 @@ describe('server-v2 /api/v1/sessions', () => { held_by: 'self', }); - // Releasing the materialized scope drops the lease: the session is now - // owned by NO instance, and the join must show it. await (server as RunningServer).core.accessor.get(ISessionLifecycleService).close(id); const after = await getJson('/api/v1/sessions'); @@ -375,6 +375,29 @@ describe('server-v2 /api/v1/sessions', () => { }); }); + it('joins the lease into ownership: peer with address when another instance holds it', async () => { + const cwd = home as string; + const created = await postJson('/api/v1/sessions', { metadata: { cwd } }); + const id = created.body.data.id; + await (server as RunningServer).core.accessor.get(ISessionLifecycleService).close(id); + + // Simulate a peer instance: with no local live scope, any held lease + // classifies as 'peer' and the owner metadata's address is joined in. + const locks = (server as RunningServer).core.accessor.get(ICrossProcessLockService); + const peer = await locks.acquire(sessionLeasePath(home as string, id), { + address: 'http://127.0.0.1:59999', + }); + try { + const listed = await getJson('/api/v1/sessions'); + expect(listed.body.data.items.find((s) => s.id === id)?.ownership).toEqual({ + held_by: 'peer', + address: 'http://127.0.0.1:59999', + }); + } finally { + peer.release(); + } + }); + it('supports exclude_empty when listing sessions', async () => { const cwd = home as string; const created = await postJson('/api/v1/sessions', { metadata: { cwd } }); diff --git a/packages/kap-server/test/skillCatalogBridge.test.ts b/packages/kap-server/test/skillCatalogBridge.test.ts index 3b01b4bd00..737952171e 100644 --- a/packages/kap-server/test/skillCatalogBridge.test.ts +++ b/packages/kap-server/test/skillCatalogBridge.test.ts @@ -1,26 +1,16 @@ /** - * `SkillCatalogBridge` — volatile `skill_catalog.changed` delivery. - * - * Fake core accessor (per `wsConnectionV1.test.ts` fake-service pattern): the - * session-scoped `ISessionSkillCatalog.onDidChange` feed is hand-fired. Cases - * pin the bridge contract: - * - two connections subscribed to one session each receive ONE volatile - * frame per core change, numbered by their own per-connection `seq`; - * - the frame is transport-only: the bridge constructor takes `{core, logger}` - * and never touches `SessionEventJournal` (no durable seq / epoch to - * journal), and the envelope carries no `epoch`; - * - unsubscribe / last-detach stops delivery and releases the core - * subscription (`dispose` called); re-attach subscribes afresh. + * `SkillCatalogBridge` — volatile `skill_catalog.changed` delivery: one frame + * per core change fanned out under each connection's own `seq` (no journal + * epoch), and refcounted release of the core subscription on last detach. + * Fake core accessor (per `wsConnectionV1.test.ts`); `onDidChange` hand-fired. */ import { type IDisposable, ISessionSkillCatalog, ISessionLifecycleService, type ISessionScopeHandle, - type SessionLifecycleHooks, type Scope, } from '@moonshot-ai/agent-core-v2'; -import { createHooks } from '@moonshot-ai/agent-core-v2/hooks'; import { describe, expect, it, vi } from 'vitest'; import { @@ -29,6 +19,7 @@ import { type SkillCatalogConnection, } from '../src/transport/ws/v1/skillCatalogBridge'; import type { EventEnvelope } from '../src/transport/ws/v1/sessionEventJournal'; +import { createSessionLifecycleHooks } from './helpers/sessionEvents'; function makeConn(id: string): { conn: SkillCatalogConnection; frames: SkillCatalogChangedFrame[] } { const frames: SkillCatalogChangedFrame[] = []; @@ -67,18 +58,8 @@ function makeCatalog(): FakeCatalog { }; } -function createSessionLifecycleHooks() { - return createHooks([ - 'onDidCreateSession', - 'onWillCloseSession', - 'onWillReleaseSession', - ]); -} - -function makeCore( - sessions: Map, - hooks = createSessionLifecycleHooks(), -): Scope { +function makeCore(sessions: Map): Scope { + const hooks = createSessionLifecycleHooks(); const lifecycle = { get: (sid: string) => sessions.get(sid), hooks }; return { accessor: { @@ -140,18 +121,6 @@ describe('SkillCatalogBridge', () => { expect(b.frames[1]).toMatchObject({ seq: 2, payload: { sourceId: 'plugin' } }); }); - it('attaches only to materialized sessions', () => { - const catalog = makeCatalog(); - const bridge = new SkillCatalogBridge({ core: makeCore(new Map()) }); - const a = makeConn('conn_a'); - - bridge.attachSession(a.conn, 'missing'); - catalog.fire('workspace-file'); - - expect(catalog.onDidChange).not.toHaveBeenCalled(); - expect(a.frames).toHaveLength(0); - }); - it('stops delivering to an unsubscribed connection and releases the core subscription on last detach', () => { const catalog = makeCatalog(); const sessions = new Map([['sess_1', makeSession(catalog.service)]]); @@ -177,53 +146,4 @@ describe('SkillCatalogBridge', () => { expect(a.frames).toHaveLength(0); expect(b.frames).toHaveLength(1); }); - - it('detachConnection drops the connection from every session and re-attach subscribes afresh', () => { - const catalog1 = makeCatalog(); - const catalog2 = makeCatalog(); - const sessions = new Map([ - ['sess_1', makeSession(catalog1.service)], - ['sess_2', makeSession(catalog2.service)], - ]); - const bridge = new SkillCatalogBridge({ core: makeCore(sessions) }); - const a = makeConn('conn_a'); - - bridge.attachSession(a.conn, 'sess_1'); - bridge.attachSession(a.conn, 'sess_2'); - bridge.detachConnection(a.conn); - - expect(catalog1.dispose).toHaveBeenCalledTimes(1); - expect(catalog2.dispose).toHaveBeenCalledTimes(1); - catalog1.fire('workspace-file'); - catalog2.fire('workspace-file'); - expect(a.frames).toHaveLength(0); - - // Re-attaching after a full teardown subscribes anew and restarts the - // per-connection seq from 1. - bridge.attachSession(a.conn, 'sess_1'); - expect(catalog1.onDidChange).toHaveBeenCalledTimes(2); - catalog1.fire('plugin'); - expect(a.frames).toHaveLength(1); - expect(a.frames[0]).toMatchObject({ seq: 1, payload: { sourceId: 'plugin' } }); - }); - - it('rebinds to the restored session catalog after the previous scope is released', async () => { - const first = makeCatalog(); - const restored = makeCatalog(); - const sessions = new Map([['sess_1', makeSession(first.service)]]); - const hooks = createSessionLifecycleHooks(); - const bridge = new SkillCatalogBridge({ core: makeCore(sessions, hooks) }); - const a = makeConn('conn_a'); - bridge.attachSession(a.conn, 'sess_1'); - - await hooks.onWillReleaseSession.run({ sessionId: 'sess_1', reason: 'archive' }); - sessions.set('sess_1', makeSession(restored.service)); - bridge.attachSession(a.conn, 'sess_1'); - restored.fire('workspace-file'); - - expect(first.dispose).toHaveBeenCalledOnce(); - expect(restored.onDidChange).toHaveBeenCalledOnce(); - expect(a.frames).toHaveLength(1); - expect(a.frames[0]).toMatchObject({ seq: 1, payload: { sourceId: 'workspace-file' } }); - }); }); diff --git a/packages/kap-server/test/transport-errors.test.ts b/packages/kap-server/test/transport-errors.test.ts index 30819b6663..2e450514dd 100644 --- a/packages/kap-server/test/transport-errors.test.ts +++ b/packages/kap-server/test/transport-errors.test.ts @@ -42,20 +42,4 @@ describe('/api/v1/debug transport mapError', () => { expect(env.code).toBe(ErrorCode.SESSION_HELD_BY_PEER); expect(env.details).toEqual(details); }); - - it('omits details when the error carries none — wire shape unchanged', () => { - const env = mapError(new Error2(ErrorCodes.SESSION_NOT_FOUND, 'boom'), 'req-1'); - expect(env.details).toBeUndefined(); - expect(JSON.stringify(env)).not.toContain('"details"'); - }); - - it('session.lease_lost falls through to the internal-error envelope', () => { - const env = mapError( - new Error2(ErrorCodes.SESSION_LEASE_LOST, 'write lease lost', { - details: { sessionId: 's1' }, - }), - 'req-1', - ); - expect(env.code).toBe(ErrorCode.INTERNAL_ERROR); - }); }); diff --git a/packages/kernel-file-lock/test/kernel-file-lock.test.ts b/packages/kernel-file-lock/test/kernel-file-lock.test.ts index d558532cc9..568927ee83 100644 --- a/packages/kernel-file-lock/test/kernel-file-lock.test.ts +++ b/packages/kernel-file-lock/test/kernel-file-lock.test.ts @@ -5,13 +5,9 @@ import { spawn } from 'node:child_process'; import { once } from 'node:events'; import { fileURLToPath } from 'node:url'; -import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { afterEach, beforeEach, describe, expect, it } from 'vitest'; -import { - setKernelFileLockBindingLoader, - tryAcquireKernelFileLock, - type KernelFileLockHandle, -} from '../src/index.js'; +import { tryAcquireKernelFileLock, type KernelFileLockHandle } from '../src/index.js'; let tmpDir: string; let lockPath: string; @@ -36,8 +32,6 @@ beforeEach(() => { afterEach(() => { for (const handle of handles.splice(0)) handle.release(); - setKernelFileLockBindingLoader(undefined); - vi.restoreAllMocks(); rmSync(tmpDir, { recursive: true, force: true }); }); @@ -90,4 +84,28 @@ describe('kernel-file-lock', () => { } } }); + + it('releases the lock when the holder process is killed without releasing', async () => { + const holderPath = fileURLToPath(new URL('./holder.ts', import.meta.url)); + const child = spawn(process.execPath, ['--import', 'tsx', holderPath, lockPath], { + stdio: ['pipe', 'pipe', 'inherit'], + }); + const exit = once(child, 'exit'); + void exit.catch(() => {}); + const ready = Promise.race([ + once(child.stdout!, 'data'), + exit.then(() => { + throw new Error('holder exited before becoming ready'); + }), + ]); + await withTimeout(ready, 5_000, 'holder did not become ready'); + expect(tryAcquireKernelFileLock(lockPath)).toBeUndefined(); + + // No clean release: the kernel must drop the lock with the dead process. + child.kill('SIGKILL'); + await withTimeout(exit, 5_000, 'holder did not exit after SIGKILL'); + const handle = tryAcquireKernelFileLock(lockPath); + expect(handle).toBeDefined(); + handles.push(handle!); + }); }); diff --git a/packages/klient/AGENTS.md b/packages/klient/AGENTS.md index bb2d805de3..790bc400ca 100644 --- a/packages/klient/AGENTS.md +++ b/packages/klient/AGENTS.md @@ -41,9 +41,9 @@ terminal surface are v1-only and live in the legacy suites. at a running server and **must keep running unchanged**; the v1 surface has no in-memory equivalent, so these stay live-server-only — do not try to run them against the in-process transports. Exception: the - dual-instance / session-ownership suites (`legacy/dual-instance.test.ts`, - `legacy/session-ownership.test.ts`) boot their own kap-server instances - via the helpers below and run without `KIMI_SERVER_URL`. + dual-instance / session-ownership suite (`legacy/session-ownership.test.ts`) + boots its own kap-server instances via the helpers below and runs without + `KIMI_SERVER_URL`. - The retired `scenarios/` scripts were rewritten as suites: image-upload and terminal (v1-only surfaces) live in `test/e2e/legacy/`. @@ -51,43 +51,27 @@ terminal surface are v1-only and live in the legacy suites. Multi-server e2e cases boot two `kap-server` instances on ONE shared home via `test/e2e/harness/testing/` (re-exported from `test/e2e/harness/index.js`). -Pick the helper by what the case needs: - -- **`startServerPair(options?)`** — default. Two in-process instances on one - shared `mkdtemp` home (or caller-provided `home`), each with `port: 0`, - `logLevel: 'silent'`, and `disableAuth: true` by default. Returns - `{ a, b, home, cwd, urlA, urlB, baseUrl(server), connectClient(server), dispose() }`; - `connectClient` returns an authed `HttpClient` (bearer from - `server.authTokenService.getToken()` when `disableAuth: false`). `cwd` is + +- **`startServerPair()`** — two in-process instances on one shared + `mkdtemp` home, each with `port: 0`, `logLevel: 'silent'`, and + `disableAuth: true`. Returns + `{ a, b, home, cwd, urlA, urlB, connectClient(server), dispose() }`; + `connectClient` returns an `HttpClient`. `cwd` is the shared workspace cwd — pass it as `metadata.cwd` in `createSession` on both instances ("same cwd" is session-level, never a server flag). - `dispose()` closes both instances (best-effort), restores env, and removes - the home only if the helper created it. -- **`spawnServerProcess(options?)` / `spawnServerProcessPair(options?)`** — - subprocess mode for signal-sensitive cases (SIGSTOP / SIGCONT / SIGKILL, - kill -9 lease takeover) that need real, distinct pids. Each child is - `node --import --import build/register-raw-text-loader.mjs - test/e2e/harness/testing/serverProcessMain.ts` with - `TSX_TSCONFIG_PATH=tsconfig.dev.json` (the dev tsconfig's `include` covers - every package's `src`, which tsx's per-file tsconfig mapping needs for - `experimentalDecorators` in the agent-core graph). Do NOT switch the - incantation to the tsx CLI: it is a hub/spoke wrapper that forks the server - as a grandchild, so `child.pid` — and every signal sent to it — would miss - the actual server. The helper asserts the pid the child reports in its - `{type:'ready'}` stdout line equals `child.pid` to catch exactly this. - `stop()` is SIGTERM + await exit, escalating to SIGKILL after ~10s. + `dispose()` closes both instances (best-effort) and removes the home. Hard rules: - Always `port: 0`. A fixed busy port silently walks to `port + 1`, which breaks registry/port assertions and cross-test isolation. -- One pair per test file/worker; never share a `RunningServer` or - `SpawnedServer` across files — vitest runs files in parallel workers. +- One pair per test file/worker; never share a `RunningServer` + across files — vitest runs files in parallel workers. - Readiness when NOT using these helpers: poll `GET /api/v1/healthz` (auth-exempt) until 200 — not `/api/v1/meta` (token-gated). -- The helpers import `@moonshot-ai/kap-server` lazily at call time so the +- The helper imports `@moonshot-ai/kap-server` lazily at call time so the harness barrel stays loadable under plain `tsx` without the raw-text - loader; keep that pattern when extending them. + loader; keep that pattern when extending it. ## Observability (inherited from server-e2e) @@ -106,10 +90,9 @@ Hard rules: conformance + e2e; live cases skip without their env). - `KIMI_SERVER_URL=http://127.0.0.1:58627 pnpm --filter @moonshot-ai/klient test` — include the live legacy cases against a running server. -- `pnpm --filter @moonshot-ai/klient exec vitest run test/e2e/legacy/dual-instance.test.ts` - — dual-instance helper self-tests (boot their own in-process + subprocess - servers; no external server needed). Same for - `test/e2e/legacy/session-ownership.test.ts`. +- `pnpm --filter @moonshot-ai/klient exec vitest run test/e2e/legacy/session-ownership.test.ts` + — multi-server ownership e2e (boots its own in-process servers; no + external server needed). - `pnpm --filter @moonshot-ai/klient docker:e2e` — docker e2e; the run derives its runner name/namespace from the current workspace to avoid cross-workspace conflicts. diff --git a/packages/klient/test/e2e/harness/index.ts b/packages/klient/test/e2e/harness/index.ts index 28efca4f46..019f345487 100644 --- a/packages/klient/test/e2e/harness/index.ts +++ b/packages/klient/test/e2e/harness/index.ts @@ -69,9 +69,7 @@ export type { ReverseRpcOptions } from './reverse-rpc.js'; export { DEFAULT_FRAME_TIMEOUT_MS, waitForFrame, waitForSessionBusy } from './wait.js'; // ── Dual-instance test helpers (additive) ───────────────────────────────── -// Boot helpers for multi-server e2e cases: `startServerPair` (in-process) and -// `spawnServerProcess` / `spawnServerProcessPair` (subprocess, for -// signal-sensitive cases). The helpers import `@moonshot-ai/kap-server` -// lazily at call time, so this barrel stays loadable under plain `tsx` -// without the raw-text loader. +// Boot helper for multi-server e2e cases: `startServerPair` (in-process). It +// imports `@moonshot-ai/kap-server` lazily at call time, so this barrel stays +// loadable under plain `tsx` without the raw-text loader. export * from './testing/index.js'; diff --git a/packages/klient/test/e2e/harness/testing/index.ts b/packages/klient/test/e2e/harness/testing/index.ts index 4897d57842..17461b9503 100644 --- a/packages/klient/test/e2e/harness/testing/index.ts +++ b/packages/klient/test/e2e/harness/testing/index.ts @@ -1,8 +1,5 @@ /** * Dual-instance test helpers — see the "Dual-instance helpers" section of - * `packages/klient/AGENTS.md` for when to use the in-process pair vs the - * subprocess spawner. + * `packages/klient/AGENTS.md`. */ export * from './serverPair.js'; -export * from './serverProcess.js'; -export * from './spawnContract.js'; diff --git a/packages/klient/test/e2e/harness/testing/serverPair.ts b/packages/klient/test/e2e/harness/testing/serverPair.ts index 3d4f00cbf2..adf57e609e 100644 --- a/packages/klient/test/e2e/harness/testing/serverPair.ts +++ b/packages/klient/test/e2e/harness/testing/serverPair.ts @@ -2,10 +2,6 @@ * In-process dual-instance boot: two `kap-server` instances sharing ONE home * directory inside the current (test) process. * - * Use this by default for multi-server e2e cases; reach for - * `spawnServerProcess` instead only when the case is signal-sensitive - * (SIGSTOP / SIGKILL need real, distinct pids). - * * One hard requirement this helper encapsulates: both instances must bind * `port: 0` (OS-assigned) — a fixed busy port silently walks to `port + 1`, * which breaks assertions on the registry. @@ -23,65 +19,34 @@ import { join } from 'node:path'; import type { RunningServer } from '@moonshot-ai/kap-server'; import { HttpClient } from '../http.js'; -import { RM_HOME_OPTIONS } from './serverProcess.js'; -export interface ServerPairOptions { - /** Shared home for both instances. Created via `mkdtemp` when omitted. */ - readonly home?: string; - /** - * Boot both servers with `disableAuth` (no bearer-token hook). Default - * `true`; when `false`, `connectClient` attaches the per-instance token - * from `server.authTokenService.getToken()`. - */ - readonly disableAuth?: boolean; - /** Extra env vars patched around both boots (restored afterwards). */ - readonly env?: Record; - /** - * The shared workspace cwd for sessions created against the pair. "Same - * cwd" is a session-level concept — pass this as `metadata.cwd` in - * `createSession` on both instances. Defaults to the pair home. - */ - readonly cwd?: string; -} +// `recursive` rm can hit ENOTEMPTY on macOS while the closing server is still +// flushing/unlinking its own files — retry briefly. +const RM_HOME_OPTIONS = { recursive: true, force: true, maxRetries: 5, retryDelay: 100 } as const; export interface ServerPair { readonly a: RunningServer; readonly b: RunningServer; readonly home: string; - /** Shared workspace cwd — see `ServerPairOptions.cwd`. */ + /** + * The shared workspace cwd for sessions created against the pair (always + * the pair home). "Same cwd" is a session-level concept — pass this as + * `metadata.cwd` in `createSession` on both instances. + */ readonly cwd: string; /** Base URL of instance `a` (`http://host:port`). */ readonly urlA: string; /** Base URL of instance `b` (`http://host:port`). */ readonly urlB: string; - baseUrl(server: RunningServer): string; - /** - * Authed REST client for one instance: bearer token attached unless the - * pair booted with `disableAuth`. - */ + /** REST client for one instance (the pair always boots with `disableAuth`). */ connectClient(server: RunningServer): HttpClient; - /** - * Close both instances (idempotent, best-effort), restore the pre-boot env, - * and remove the home directory if this helper created it. - */ + /** Close both instances (idempotent, best-effort) and remove the home. */ dispose(): Promise; } -export async function startServerPair(options: ServerPairOptions = {}): Promise { - const home = options.home ?? (await mkdtemp(join(tmpdir(), 'kimi-e2e-pair-'))); - const ownsHome = options.home === undefined; - const disableAuth = options.disableAuth ?? true; - const envPatch: Record = { ...options.env }; - const savedEnv = saveEnv(envPatch); - let envRestored = false; - const restoreEnv = (): void => { - if (envRestored) return; - envRestored = true; - restoreSavedEnv(savedEnv); - }; - +export async function startServerPair(): Promise { + const home = await mkdtemp(join(tmpdir(), 'kimi-e2e-pair-')); try { - applyEnv(envPatch); const { startServer } = await import('@moonshot-ai/kap-server'); const boot = (): Promise => startServer({ @@ -89,7 +54,7 @@ export async function startServerPair(options: ServerPairOptions = {}): Promise< port: 0, homeDir: home, logLevel: 'silent', - disableAuth, + disableAuth: true, }); const a = await boot(); let b: RunningServer; @@ -106,21 +71,18 @@ export async function startServerPair(options: ServerPairOptions = {}): Promise< a, b, home, - cwd: options.cwd ?? home, + cwd: home, urlA: baseUrl(a), urlB: baseUrl(b), - baseUrl, connectClient: (server) => new HttpClient({ baseUrl: baseUrl(server), apiPrefix: '/api/v1', fetchImpl: fetch, - token: disableAuth ? undefined : server.authTokenService.getToken(), }), dispose: async () => { if (disposed) return; disposed = true; - restoreEnv(); // Best-effort: a failed close must not mask the other instance's // teardown, but it also must not disappear silently. const results = await Promise.allSettled([a.close(), b.close()]); @@ -134,40 +96,11 @@ export async function startServerPair(options: ServerPairOptions = {}): Promise< ); } } - if (ownsHome) { - await rm(home, RM_HOME_OPTIONS); - } + await rm(home, RM_HOME_OPTIONS); }, }; } catch (error) { - restoreEnv(); - if (ownsHome) { - await rm(home, RM_HOME_OPTIONS); - } + await rm(home, RM_HOME_OPTIONS); throw error; } } - -function saveEnv(patch: Record): Map { - const saved = new Map(); - for (const key of Object.keys(patch)) { - saved.set(key, process.env[key]); - } - return saved; -} - -function applyEnv(patch: Record): void { - for (const [key, value] of Object.entries(patch)) { - process.env[key] = value; - } -} - -function restoreSavedEnv(saved: Map): void { - for (const [key, value] of saved) { - if (value === undefined) { - delete process.env[key]; - } else { - process.env[key] = value; - } - } -} diff --git a/packages/klient/test/e2e/harness/testing/serverProcess.ts b/packages/klient/test/e2e/harness/testing/serverProcess.ts deleted file mode 100644 index 9eb9dfca5b..0000000000 --- a/packages/klient/test/e2e/harness/testing/serverProcess.ts +++ /dev/null @@ -1,336 +0,0 @@ -/** - * Subprocess-mode dual-instance boot: each server runs in its own OS process - * (`node --import tsx serverProcessMain.ts`), so signal-sensitive cases - * (SIGSTOP, SIGCONT, SIGKILL, kill -9 takeover) get real, distinct pids — - * in-process `startServerPair` cannot model those. - * - * Spawn incantation: - * node --import --import - * - `tsx` compiles the workspace's TypeScript module graph. It is attached - * as a `--import` loader — NOT via the tsx CLI, which is a hub/spoke - * wrapper: the CLI would fork the server as a GRANDCHILD, so - * `child.pid` (and every signal sent to it) would miss the actual - * server process. With the direct import the child IS the server, which - * the child additionally proves by reporting its own pid in the ready - * line. `tsx` is resolved through `createRequire` from the repo root's - * devDependencies — this package deliberately does not redeclare it. - * - `TSX_TSCONFIG_PATH` replaces the CLI's `--tsconfig` flag and points at - * `tsconfig.dev.json`, whose `include` covers every package's `src` — - * that is what tsx's per-file tsconfig mapping needs to apply - * `experimentalDecorators` for DI parameter decorators in the - * agent-core graph (mirrors `apps/kimi-code/tsconfig.dev.json`). - * - `build/register-raw-text-loader.mjs` makes `*.md?raw` prompt-template - * imports (kap-server → agent-core-v2) resolvable outside a bundler; - * plain `node` fails on those imports without it. - * - * Readiness is the child's `{type:'ready'}` stdout line (printed after - * `startServer` resolved, i.e. the port is already listening). When driving - * an externally spawned server without that line, poll - * `waitForServerHealthy` instead — `/api/v1/healthz` is auth-exempt. - */ -import { spawn, type ChildProcess } from 'node:child_process'; -import { mkdtemp, rm } from 'node:fs/promises'; -import { createRequire } from 'node:module'; -import { tmpdir } from 'node:os'; -import { dirname, join, resolve } from 'node:path'; -import { createInterface } from 'node:readline'; -import { fileURLToPath, pathToFileURL } from 'node:url'; - -import { sleep } from '../wait.js'; -import { - SPAWN_SERVER_HOME_ENV, - type SpawnServerMessage, - type SpawnServerReadyMessage, -} from './spawnContract.js'; - -const TESTING_DIR = dirname(fileURLToPath(import.meta.url)); -// testing/ → harness/ → e2e/ → test/ → packages/klient -const PACKAGE_ROOT = resolve(TESTING_DIR, '../../../..'); -const REPO_ROOT = resolve(PACKAGE_ROOT, '../..'); -const ENTRY_PATH = join(TESTING_DIR, 'serverProcessMain.ts'); -const STOP_GRACE_MS = 10_000; -const STDERR_TAIL_LIMIT = 8_192; -// `recursive` rm can hit ENOTEMPTY on macOS while the closing server is still -// flushing/unlinking its own files — retry briefly. -export const RM_HOME_OPTIONS = { recursive: true, force: true, maxRetries: 5, retryDelay: 100 } as const; - -export interface SpawnServerProcessOptions { - /** Home directory for the child server. Created via `mkdtemp` when omitted. */ - readonly home?: string; - /** Extra env for the child process, merged over `process.env`. */ - readonly env?: Record; - /** Working directory of the child process. Defaults to the parent's cwd. */ - readonly cwd?: string; - /** How long to wait for the child's ready line. Default 30s. */ - readonly startupTimeoutMs?: number; -} - -export interface SpawnedServer { - /** Pid of the child process — the handle signals are delivered to. */ - readonly pid: number; - readonly port: number; - readonly baseUrl: string; - readonly home: string; - /** - * Graceful stop: SIGTERM, await exit, escalate to SIGKILL after ~10s. - * Idempotent. Removes `home` when this helper created it. - */ - stop(): Promise; - /** Deliver an arbitrary signal to the child (e.g. SIGSTOP / SIGKILL). */ - kill(signal: NodeJS.Signals): void; - /** Captured stderr tail so far — child boot/close failures land here. */ - stderr(): string; -} - -export interface SpawnedServerPair { - readonly a: SpawnedServer; - readonly b: SpawnedServer; - readonly home: string; - /** Stop both children (idempotent, best-effort) and remove `home` if owned. */ - dispose(): Promise; -} - -export async function spawnServerProcess( - options: SpawnServerProcessOptions = {}, -): Promise { - const home = options.home ?? (await mkdtemp(join(tmpdir(), 'kimi-e2e-spawn-'))); - const ownsHome = options.home === undefined; - const startupTimeoutMs = options.startupTimeoutMs ?? 30_000; - - const require = createRequire(import.meta.url); - // The `.` export of the `tsx` package — attached via `--import` so the TS - // transpiler registers in the child process itself (no CLI wrapper). - const tsxLoaderHref = pathToFileURL(require.resolve('tsx')).href; - const rawLoaderHref = pathToFileURL( - join(REPO_ROOT, 'build/register-raw-text-loader.mjs'), - ).href; - - const child = spawn( - process.execPath, - ['--import', tsxLoaderHref, '--import', rawLoaderHref, ENTRY_PATH], - { - cwd: options.cwd, - env: { - ...process.env, - ...options.env, - // The CLI's `--tsconfig` flag, as a loader env var. - TSX_TSCONFIG_PATH: join(PACKAGE_ROOT, 'tsconfig.dev.json'), - [SPAWN_SERVER_HOME_ENV]: home, - }, - stdio: ['ignore', 'pipe', 'pipe'], - }, - ); - - let stderrTail = ''; - child.stderr?.on('data', (chunk: Buffer) => { - stderrTail = (stderrTail + chunk.toString('utf8')).slice(-STDERR_TAIL_LIMIT); - }); - const exited = new Promise((resolveExit) => { - child.once('exit', (code) => resolveExit(code)); - }); - - let ready: SpawnServerReadyMessage; - try { - ready = await waitForReady(child, startupTimeoutMs, () => stderrTail); - } catch (error) { - child.kill('SIGKILL'); - if (ownsHome) { - await rm(home, RM_HOME_OPTIONS); - } - throw error; - } - - const pid = child.pid; - if (pid === undefined) { - child.kill('SIGKILL'); - throw new Error('spawned server child has no pid'); - } - if (ready.pid !== pid) { - child.kill('SIGKILL'); - if (ownsHome) { - await rm(home, RM_HOME_OPTIONS); - } - throw new Error( - `spawned server reports pid ${ready.pid} but the child handle is ${pid} — a wrapper process slipped into the launch incantation and would misdirect signals`, - ); - } - const baseUrl = `http://127.0.0.1:${ready.port}`; - - let stopPromise: Promise | undefined; - const stop = (): Promise => { - stopPromise ??= (async () => { - if (child.exitCode === null && !child.killed) { - child.kill('SIGTERM'); - } - if (!(await raceExit(exited, STOP_GRACE_MS))) { - child.kill('SIGKILL'); - await raceExit(exited, STOP_GRACE_MS); - } - if (ownsHome) { - await rm(home, RM_HOME_OPTIONS); - } - })(); - return stopPromise; - }; - - return { - pid, - port: ready.port, - baseUrl, - home, - stop, - kill: (signal) => { - child.kill(signal); - }, - stderr: () => stderrTail, - }; -} - -/** - * Pair of spawned children sharing one home; `options.env` is pushed into both - * child envs — process-level patching like `startServerPair` does would not - * reach them. - */ -export async function spawnServerProcessPair( - options: SpawnServerProcessOptions = {}, -): Promise { - const home = options.home ?? (await mkdtemp(join(tmpdir(), 'kimi-e2e-spawn-pair-'))); - const ownsHome = options.home === undefined; - const childOptions: SpawnServerProcessOptions = { - ...options, - home, - }; - try { - const a = await spawnServerProcess(childOptions); - let b: SpawnedServer; - try { - b = await spawnServerProcess(childOptions); - } catch (error) { - await a.stop(); - throw error; - } - let disposed = false; - return { - a, - b, - home, - dispose: async () => { - if (disposed) return; - disposed = true; - const results = await Promise.allSettled([a.stop(), b.stop()]); - for (const [label, result] of [ - ['a', results[0]], - ['b', results[1]], - ] as const) { - if (result?.status === 'rejected') { - process.stderr.write( - `[server-e2e] spawnServerProcessPair dispose: child ${label} stop failed: ${String(result.reason)}\n`, - ); - } - } - if (ownsHome) { - await rm(home, RM_HOME_OPTIONS); - } - }, - }; - } catch (error) { - if (ownsHome) { - await rm(home, RM_HOME_OPTIONS); - } - throw error; - } -} - -/** Poll the auth-exempt health route until the server responds with 200. */ -export async function waitForServerHealthy(baseUrl: string, timeoutMs = 30_000): Promise { - const deadline = Date.now() + timeoutMs; - let lastError: unknown; - while (Date.now() < deadline) { - try { - const res = await fetch(`${baseUrl}/api/v1/healthz`); - if (res.status === 200) return; - lastError = new Error(`healthz returned HTTP ${res.status}`); - } catch (error) { - lastError = error; - } - await sleep(100); - } - throw new Error(`server at ${baseUrl} did not become healthy within ${timeoutMs}ms`, { - cause: lastError, - }); -} - -function waitForReady( - child: ChildProcess, - timeoutMs: number, - stderrTail: () => string, -): Promise { - return new Promise((resolvePromise, rejectPromise) => { - const rl = createInterface({ input: child.stdout! }); - const timer = setTimeout(() => { - fail(new Error('timed out waiting for the ready line')); - }, timeoutMs); - timer.unref(); - - let settled = false; - const fail = (error: Error): void => { - if (settled) return; - settled = true; - clearTimeout(timer); - rl.close(); - rejectPromise(withStderr(error, stderrTail())); - }; - rl.once('line', (line) => { - let message: SpawnServerMessage; - try { - message = JSON.parse(line) as SpawnServerMessage; - } catch { - fail(new Error(`unparseable first stdout line: ${line.slice(0, 200)}`)); - return; - } - if (message.type === 'error') { - fail(new Error(`child failed to boot: ${message.message}`)); - return; - } - if (settled) return; - settled = true; - clearTimeout(timer); - rl.close(); - resolvePromise(message); - }); - child.once('exit', (code, signal) => { - fail(new Error(`child exited before ready (code ${code ?? 'null'}, signal ${signal ?? 'null'})`)); - }); - child.once('error', (error) => { - fail(error); - }); - }); -} - -function withStderr(error: Error, stderrTail: string): Error { - if (stderrTail.length === 0) return error; - return new Error(`${error.message}\nchild stderr (tail):\n${stderrTail}`, { cause: error }); -} - -function raceExit(exited: Promise, timeoutMs: number): Promise { - return Promise.race([exited.then(() => true), sleep(timeoutMs).then(() => false)]); -} - -export function pidAlive(pid: number): boolean { - try { - process.kill(pid, 0); - return true; - } catch (error) { - return (error as NodeJS.ErrnoException).code === 'EPERM'; - } -} - -/** True once `pid` is fully gone (ESRCH — zombies reaped), false on timeout. */ -export async function waitForPidExit(pid: number, timeoutMs: number): Promise { - const deadline = Date.now() + timeoutMs; - while (Date.now() < deadline) { - if (!pidAlive(pid)) return true; - await sleep(50); - } - return !pidAlive(pid); -} diff --git a/packages/klient/test/e2e/harness/testing/serverProcessMain.ts b/packages/klient/test/e2e/harness/testing/serverProcessMain.ts deleted file mode 100644 index 7e48cb51b2..0000000000 --- a/packages/klient/test/e2e/harness/testing/serverProcessMain.ts +++ /dev/null @@ -1,79 +0,0 @@ -/** - * Child-process entry for `spawnServerProcess` — boots ONE `kap-server` - * instance on an ephemeral port and reports it over stdout. - * - * Spawning cannot run plain `node` on this file: the surrounding module - * graph (kap-server → agent-core-v2) is TypeScript with `*.md?raw` imports. - * The parent spawns `tsx` plus `build/register-raw-text-loader.mjs` to cover - * both (see `serverProcess.ts` for the exact incantation), so a static - * import of `@moonshot-ai/kap-server` is safe HERE — unlike in the helpers - * that ship on the package barrel. - * - * Protocol (one JSON line each, see `spawnContract.ts`): - * - success: `{type:'ready', port, home}` on stdout, then serve until - * SIGTERM/SIGINT, which triggers `server.close()` and a clean exit. - * - failure: `{type:'error', message}` on stdout, exit code 1. - */ -import { startServer, type RunningServer } from '@moonshot-ai/kap-server'; - -import { - SPAWN_SERVER_HOME_ENV, - type SpawnServerMessage, -} from './spawnContract.js'; - -async function main(): Promise { - const home = process.env[SPAWN_SERVER_HOME_ENV]; - if (home === undefined || home.length === 0) { - emit({ type: 'error', message: `${SPAWN_SERVER_HOME_ENV} is not set` }); - process.exitCode = 1; - return; - } - - let server: RunningServer; - try { - server = await startServer({ - host: '127.0.0.1', - port: 0, - homeDir: home, - logLevel: 'silent', - // Signal tests must not juggle tokens; the parent drives this server - // over loopback only. - disableAuth: true, - }); - } catch (error) { - emit({ type: 'error', message: error instanceof Error ? error.message : String(error) }); - process.exitCode = 1; - return; - } - emit({ type: 'ready', port: server.port, home, pid: process.pid }); - - // Graceful shutdown: `stop()` sends SIGTERM first and escalates to SIGKILL - // on timeout, so exiting here only after `close()` settles keeps the - // instance-registry / journal teardown on the slow path observable. Close - // failures go to stderr (the parent captures the tail) but never block the - // exit — signal tests need the process to actually die. - let closing: Promise | undefined; - const onSignal = (): void => { - closing ??= server - .close() - .catch((error: unknown) => { - process.stderr.write( - `server.close() failed during signal shutdown: ${ - error instanceof Error ? error.message : String(error) - }\n`, - ); - }) - .finally(() => { - process.exit(0); - }); - void closing; - }; - process.on('SIGTERM', onSignal); - process.on('SIGINT', onSignal); -} - -function emit(message: SpawnServerMessage): void { - process.stdout.write(`${JSON.stringify(message)}\n`); -} - -void main(); diff --git a/packages/klient/test/e2e/harness/testing/spawnContract.ts b/packages/klient/test/e2e/harness/testing/spawnContract.ts deleted file mode 100644 index 2222a62735..0000000000 --- a/packages/klient/test/e2e/harness/testing/spawnContract.ts +++ /dev/null @@ -1,30 +0,0 @@ -/** - * Shared contract between `spawnServerProcess` (`serverProcess.ts`) and its - * child entry point (`serverProcessMain.ts`). Kept dependency-free so both - * sides can import it without pulling in kap-server. - */ - -/** Env var carrying the child server's homeDir into `serverProcessMain`. */ -export const SPAWN_SERVER_HOME_ENV = 'KIMI_E2E_SPAWN_SERVER_HOME'; - -/** One JSON line on the child's stdout once `startServer` resolved. */ -export interface SpawnServerReadyMessage { - readonly type: 'ready'; - readonly port: number; - readonly home: string; - /** - * The child reports its OWN pid, and the spawner asserts it equals - * `child.pid` — if the launch incantation ever reintroduces a wrapper - * process (e.g. the tsx CLI, which forks a grandchild), signals delivered - * via `kill()` would silently hit the wrong process. - */ - readonly pid: number; -} - -/** One JSON line on the child's stdout when booting failed. */ -export interface SpawnServerErrorMessage { - readonly type: 'error'; - readonly message: string; -} - -export type SpawnServerMessage = SpawnServerReadyMessage | SpawnServerErrorMessage; diff --git a/packages/klient/test/e2e/legacy/dual-instance.test.ts b/packages/klient/test/e2e/legacy/dual-instance.test.ts deleted file mode 100644 index 89bd6da900..0000000000 --- a/packages/klient/test/e2e/legacy/dual-instance.test.ts +++ /dev/null @@ -1,229 +0,0 @@ -/** - * Self-tests for the dual-instance helpers (`test/e2e/harness/testing/`). - * - * These tests require no external server — they boot their own instances — - * and are deliberately the first consumers of the Phase-2 multi-server test - * infrastructure. What's asserted: - * - * In-process (`startServerPair`): - * 1. Two instances boot on ONE shared home; both ports are ephemeral (> 0) - * and distinct, and both land in `/server/instances/` (dir listing - * + `listLiveServerInstances` agree). - * 2. Closing instance `a` leaves instance `b` serving (healthz 200, registry - * down to one live entry) while `a`'s port refuses connections. - * 3. `dispose()` removes the helper-created home directory. - * - * Subprocess (`spawnServerProcess`): - * 4. A child boots on a real distinct pid, answers healthz, and serves - * token-gated routes WITHOUT a token (`disableAuth`); `stop()` (SIGTERM) - * exits the child and removes the helper-created home. - * 5. A spawned pair shares one home; a SIGKILLed child's pid actually dies - * and its registry entry is swept as stale on the next - * `listLiveServerInstances` read. - * - * KNOWN BRANCH GAP (refactor-fs-watch WIP): kap-server's `close()` currently - * throws `appendLogStore depends on writeAuthorityRegistry which is NOT - * registered` for a session-less server — the Phase-1/2 - * `writeAuthorityRegistryService` module exists but is not yet imported by - * anything, so its scoped DI registration never runs. Verified pre-existing - * with a single in-process server and no server-e2e code involved. Test 2 - * tolerates ONLY that exact error (see `isKnownCloseGap`) and log which - * path ran; once the wiring lands, drop the tolerance and assert close() - * resolves. - */ -import { existsSync } from 'node:fs'; -import { readdir } from 'node:fs/promises'; -import { join } from 'node:path'; - -import { listLiveServerInstances } from '@moonshot-ai/kap-server'; -import { describe, expect, it } from 'vitest'; - -import { HttpClient } from '../harness/http.js'; -import { - pidAlive, - spawnServerProcess, - spawnServerProcessPair, - startServerPair, - waitForPidExit, - waitForServerHealthy, -} from '../harness/testing/index.js'; -import { createCaseLogger, errorForLog } from './log.js'; - -describe('dual-instance helpers', () => { - describe('startServerPair (in-process)', () => { - it( - 'boots two instances on one shared home with distinct ephemeral ports', - { timeout: 30_000 }, - async () => { - const log = createCaseLogger('dual-instance/in-process-boot'); - const pair = await startServerPair(); - try { - expect(pair.a.port).toBeGreaterThan(0); - expect(pair.b.port).toBeGreaterThan(0); - expect(pair.a.port).not.toBe(pair.b.port); - - const instances = await listLiveServerInstances(pair.home); - const instanceFiles = (await readdir(join(pair.home, 'server', 'instances'))).filter( - (name) => name.endsWith('.json'), - ); - log('shared home registry', { - home: pair.home, - ports: [pair.a.port, pair.b.port], - liveInstances: instances, - instanceFiles, - }); - expect(instanceFiles).toHaveLength(2); - expect(instances).toHaveLength(2); - expect(sortedNumeric(instances.map((info) => info.port))).toEqual( - sortedNumeric([pair.a.port, pair.b.port]), - ); - - // Both instances answer authed REST traffic (disableAuth by default). - await pair.connectClient(pair.a).listSessions(); - await pair.connectClient(pair.b).listSessions(); - } finally { - await pair.dispose(); - } - }, - ); - - it( - 'closing instance a leaves instance b serving', - { timeout: 30_000 }, - async () => { - const log = createCaseLogger('dual-instance/close-left-serving'); - const pair = await startServerPair(); - try { - const closeError = await pair.a.close().then( - () => undefined, - (error: unknown) => error, - ); - log('a.close() outcome', { - closeError: closeError === undefined ? null : errorForLog(closeError), - }); - // Only the pre-existing branch gap is tolerated (see file header). - if (closeError !== undefined && !isKnownCloseGap(closeError)) { - throw closeError instanceof Error ? closeError : new Error(`close() rejected with a non-error of type ${typeof closeError}`); - } - - // The core contract either way: the peer instance is unaffected. - await waitForServerHealthy(pair.urlB, 10_000); - const fetchA = await fetch(`${pair.urlA}/api/v1/healthz`).then( - (res) => `up:${res.status}`, - () => 'down', - ); - const instances = await listLiveServerInstances(pair.home); - log('after a.close()', { fetchA, bHealthy: true, liveInstances: instances }); - - if (closeError === undefined) { - expect(fetchA).toBe('down'); - expect(instances).toHaveLength(1); - expect(instances[0]?.port).toBe(pair.b.port); - } else { - // Broken-close branch: a never got torn down, so it is still - // listening and registered; b must be healthy regardless. - expect(fetchA).toBe('up:200'); - expect(instances).toHaveLength(2); - } - } finally { - await pair.dispose(); - } - }, - ); - - it( - 'dispose() removes the created home', - { timeout: 30_000 }, - async () => { - const log = createCaseLogger('dual-instance/dispose-cleanup'); - const pair = await startServerPair(); - expect(existsSync(pair.home)).toBe(true); - - await pair.dispose(); - log('after dispose()', { homeExists: existsSync(pair.home) }); - expect(existsSync(pair.home)).toBe(false); - }, - ); - - it( - 'spawns a child server, serves without a token, and stops on SIGTERM', - { timeout: 60_000 }, - async () => { - const log = createCaseLogger('dual-instance/spawn-stop'); - const spawned = await spawnServerProcess(); - log('spawned child', { - pid: spawned.pid, - port: spawned.port, - baseUrl: spawned.baseUrl, - home: spawned.home, - }); - expect(spawned.pid).toBeGreaterThan(0); - expect(spawned.pid).not.toBe(process.pid); - - await waitForServerHealthy(spawned.baseUrl, 10_000); - const client = new HttpClient({ - baseUrl: spawned.baseUrl, - apiPrefix: '/api/v1', - fetchImpl: fetch, - }); - const page = await client.listSessions(); - log('GET /api/v1/sessions without token (disableAuth)', page); - expect(Array.isArray(page.items)).toBe(true); - - await spawned.stop(); - const alive = pidAlive(spawned.pid); - log('after stop()', { pid: spawned.pid, alive, homeExists: existsSync(spawned.home) }); - expect(alive).toBe(false); - expect(existsSync(spawned.home)).toBe(false); - }, - ); - - it( - 'spawned pair shares one home; a SIGKILLed child dies and is swept from the registry', - { timeout: 60_000 }, - async () => { - const log = createCaseLogger('dual-instance/spawn-pair-sigkill'); - const pair = await spawnServerProcessPair(); - try { - expect(pair.a.pid).not.toBe(pair.b.pid); - expect(pair.a.port).not.toBe(pair.b.port); - await Promise.all([ - waitForServerHealthy(pair.a.baseUrl, 10_000), - waitForServerHealthy(pair.b.baseUrl, 10_000), - ]); - - const before = await listLiveServerInstances(pair.home); - log('live instances before SIGKILL', { before, pidA: pair.a.pid, pidB: pair.b.pid }); - expect(sortedNumeric(before.map((info) => info.pid))).toEqual( - sortedNumeric([pair.a.pid, pair.b.pid]), - ); - - pair.a.kill('SIGKILL'); - expect(await waitForPidExit(pair.a.pid, 5_000)).toBe(true); - - // The dead instance cannot release its registration; the registry - // sweeps it on the next live read via the pid probe. - const after = await listLiveServerInstances(pair.home); - log('live instances after SIGKILL', { after }); - expect(after).toHaveLength(1); - expect(after[0]?.pid).toBe(pair.b.pid); - } finally { - await pair.dispose(); - } - expect(existsSync(pair.home)).toBe(false); - }, - ); - }); -}); - -function sortedNumeric(values: readonly number[]): number[] { - return [...values].sort((x, y) => x - y); -} - -// See the KNOWN BRANCH GAP note in the file header. Matches the exact DI -// wiring failure so any OTHER close error still fails the test. -const KNOWN_CLOSE_GAP = 'writeAuthorityRegistry'; - -function isKnownCloseGap(error: unknown): boolean { - return error instanceof Error && error.message.includes(KNOWN_CLOSE_GAP); -} diff --git a/packages/klient/test/e2e/legacy/session-ownership.test.ts b/packages/klient/test/e2e/legacy/session-ownership.test.ts index 08d8d3b999..7af3370979 100644 --- a/packages/klient/test/e2e/legacy/session-ownership.test.ts +++ b/packages/klient/test/e2e/legacy/session-ownership.test.ts @@ -1,6 +1,6 @@ /** - * Phase-2 session-ownership verification matrix — multi-process e2e over real - * REST boundaries (design §3.10). + * Phase-2 session-ownership verification — multi-instance e2e over real REST + * boundaries (design §3.10). * * One session write lease per session under `/session-leases/.lock`. * The permanent sentinel is protected by a kernel lock; the sibling @@ -8,27 +8,12 @@ * Materializing routes on a peer-held session answer HTTP 200 with envelope * `code 40921 session.held_by_peer` + ownership details. kap-server's own e2e * already pins the dual-open envelope schema and graceful-close takeover; the - * unique value here is cross-PROCESS behavior and byte-level file integrity: - * - * 1. Concurrent dual materialization race (in-process `startServerPair`): - * A creates the session, then `GET .../warnings` storms fire on A and B - * concurrently for several rounds — A always serves (code 0), B always - * loses with 40921 phase `routable` + A's address — followed by a - * `*.jsonl` byte-integrity sweep of the shared home (no torn records) and - * a single-lease assertion. - * 2. SIGSTOP → no takeover, SIGCONT → clean continuation (subprocess pair): - * a stopped holder keeps the kernel lock indefinitely; B remains refused - * with phase `routable`, the `lock_id` stays stable, and the original - * holder resumes cleanly after SIGCONT. - * 3. kill -9 → kernel release with data intact (subprocess pair): after the - * holder dies and is reaped, B acquires the released kernel lock with a - * NEW lock_id and overwrites owner metadata in place. The sentinel stays - * put, no stale sibling is created, and the session remains intact. - * - * Every subprocess scenario ends with dispose() + an explicit pid-dead check - * (ESRCH) so no child server can linger across tests. + * unique value here is cross-instance behavior and byte-level file integrity: + * A creates the session, then `GET .../warnings` fires on A and B + * concurrently — A always serves (code 0), B always loses with 40921 phase + * `routable` + A's address — followed by a `*.jsonl` byte-integrity sweep of + * the shared home (no torn records) and a single-lease assertion. */ -import { mkdirSync, readdirSync, unlinkSync, writeFileSync } from 'node:fs'; import { readdir, readFile } from 'node:fs/promises'; import { join } from 'node:path'; @@ -36,15 +21,7 @@ import { sessionLeasePath } from '@moonshot-ai/agent-core-v2'; import { ErrorCode, sessionOwnershipDetailsSchema, type Envelope } from '@moonshot-ai/protocol'; import { describe, expect, it } from 'vitest'; -import { DaemonClient, HttpClient } from '../harness/index.js'; -import { - pidAlive, - spawnServerProcessPair, - startServerPair, - waitForPidExit, - waitForServerHealthy, -} from '../harness/testing/index.js'; -import { sleep } from '../harness/wait.js'; +import { startServerPair } from '../harness/testing/index.js'; import { createCaseLogger } from './log.js'; describe('session ownership: concurrent dual materialization race (in-process pair)', () => { @@ -66,7 +43,10 @@ describe('session ownership: concurrent dual materialization race (in-process pa const lockId = lease?.['lock_id']; expect(typeof lockId).toBe('string'); - const ROUNDS = 5; + // Two concurrent rounds pin the same holder/peer split as any larger + // storm — each round is a sequential await, so more rounds only cost + // wall time. + const ROUNDS = 2; for (let round = 1; round <= ROUNDS; round += 1) { const [a, b] = await Promise.all([ getEnvelope(pair.urlA, warningsPath(sessionId)), @@ -77,7 +57,7 @@ describe('session ownership: concurrent dual materialization race (in-process pa expect(b.status).toBe(200); expect(b.body.code).toBe(ErrorCode.SESSION_HELD_BY_PEER); const details = sessionOwnershipDetailsSchema.parse(b.body.details); - expect(details.kind).toBe('held-by-peer'); + expect(details).toEqual({ kind: 'held-by-peer', phase: 'routable', address: pair.urlA }); if (round === 1 || round === ROUNDS) { log(`concurrent round ${round}/${ROUNDS}`, { holder: { status: a.status, code: a.body.code }, @@ -104,357 +84,7 @@ describe('session ownership: concurrent dual materialization race (in-process pa ); }); -describe('session ownership: SIGSTOP/SIGCONT (subprocess pair)', () => { - it( - 'a stopped holder keeps the kernel lock; after SIGCONT it resumes cleanly', - { timeout: 150_000 }, - async () => { - const log = createCaseLogger('session-ownership/sigstop-sigcont'); - const pair = await spawnServerProcessPair(); - // If the test fails mid-stop, un-freeze the child before teardown so - // dispose()'s SIGTERM can be acted on without the SIGKILL escalation. - let holderStopped = false; - try { - await Promise.all([ - waitForServerHealthy(pair.a.baseUrl, 15_000), - waitForServerHealthy(pair.b.baseUrl, 15_000), - ]); - const clientA = new HttpClient({ - baseUrl: pair.a.baseUrl, - apiPrefix: '/api/v1', - fetchImpl: fetch, - }); - const { id: sessionId } = await clientA.createSession({ metadata: { cwd: pair.home } }); - const leaseA = await readLease(pair.home, sessionId); - expect(leaseA?.['address']).toBe(pair.a.baseUrl); - expect(leaseA?.['pid']).toBe(pair.a.pid); - const lockIdA = leaseA?.['lock_id']; - expect(typeof lockIdA).toBe('string'); - log('holder lease before SIGSTOP', { sessionId, lease: leaseA }); - - process.kill(pair.a.pid, 'SIGSTOP'); - holderStopped = true; - log('SIGSTOP sent', { pid: pair.a.pid }); - - await sleep(300); - - for (let attempt = 1; attempt <= 2; attempt += 1) { - const b = await getEnvelope(pair.b.baseUrl, warningsPath(sessionId)); - const details = sessionOwnershipDetailsSchema.parse(b.body.details); - log(`B resume attempt ${attempt}/2 while A is stopped`, { - status: b.status, - code: b.body.code, - details, - }); - expect(b.status).toBe(200); - expect(b.body.code).toBe(ErrorCode.SESSION_HELD_BY_PEER); - expect(details).toEqual({ - kind: 'held-by-peer', - phase: 'routable', - address: pair.a.baseUrl, - }); - if (attempt === 1) await sleep(300); - } - - // No takeover happened: same lock id and the same two permanent files. - expect((await readLease(pair.home, sessionId))?.['lock_id']).toBe(lockIdA); - const filenames = await listLeaseFilenames(pair.home); - expect(filenames.filter((name) => name.startsWith(`${sessionId}.lock`))).toEqual([ - `${sessionId}.lock`, - `${sessionId}.lock.owner.json`, - ]); - - process.kill(pair.a.pid, 'SIGCONT'); - holderStopped = false; - log('SIGCONT sent', { pid: pair.a.pid }); - await waitForServerHealthy(pair.a.baseUrl, 20_000); - - // The original holder still owns and serves the session. - const aResumed = await pollUntil(async () => { - const res = await getEnvelope(pair.a.baseUrl, warningsPath(sessionId)); - return res.body.code === 0 ? res : undefined; - }, 'A serving the session after SIGCONT', 10_000, 500); - log('A serving the session after SIGCONT', { - status: aResumed.status, - code: aResumed.body.code, - }); - - // B stays refused and remains routable to the original holder. - const transcript: Array<{ code: number; details: unknown }> = []; - const routable = await pollUntil(async () => { - const res = await getEnvelope(pair.b.baseUrl, warningsPath(sessionId)); - transcript.push({ code: res.body.code, details: res.body.details }); - const details = sessionOwnershipDetailsSchema.parse(res.body.details); - return details.kind === 'held-by-peer' && details.phase === 'routable' - ? details - : undefined; - }, 'B remains 40921 routable after SIGCONT', 15_000, 500); - log('B observations after SIGCONT', { transcript, final: routable }); - for (const entry of transcript) { - expect(entry.code).toBe(ErrorCode.SESSION_HELD_BY_PEER); - } - expect(routable).toEqual({ - kind: 'held-by-peer', - phase: 'routable', - address: pair.a.baseUrl, - }); - - // Still the original kernel lease; no stale sibling ever appeared. - expect((await readLease(pair.home, sessionId))?.['lock_id']).toBe(lockIdA); - const swept = await assertJsonlIntegrity(pair.home); - log('byte-integrity sweep', swept); - expect(swept.files).toBeGreaterThan(0); - } finally { - if (holderStopped) { - try { - process.kill(pair.a.pid, 'SIGCONT'); - } catch { - // child already dead — teardown proceeds regardless - } - } - await pair.dispose(); - } - expect(pidAlive(pair.a.pid)).toBe(false); - expect(pidAlive(pair.b.pid)).toBe(false); - log('exit hygiene: both child pids dead after dispose', { - pidA: pair.a.pid, - pidB: pair.b.pid, - }); - }, - ); -}); - -describe('session ownership: kill -9 kernel release (subprocess pair)', () => { - it( - 'B acquires the released kernel lock with a new lock id and serves the intact session', - { timeout: 120_000 }, - async () => { - const log = createCaseLogger('session-ownership/sigkill-kernel-release'); - const pair = await spawnServerProcessPair(); - try { - await Promise.all([ - waitForServerHealthy(pair.a.baseUrl, 15_000), - waitForServerHealthy(pair.b.baseUrl, 15_000), - ]); - const clientA = new HttpClient({ - baseUrl: pair.a.baseUrl, - apiPrefix: '/api/v1', - fetchImpl: fetch, - }); - const { id: sessionId } = await clientA.createSession({ metadata: { cwd: pair.home } }); - const leaseA = await readLease(pair.home, sessionId); - expect(leaseA?.['address']).toBe(pair.a.baseUrl); - expect(leaseA?.['pid']).toBe(pair.a.pid); - const lockIdA = leaseA?.['lock_id']; - expect(typeof lockIdA).toBe('string'); - log('holder lease before SIGKILL', { sessionId, lease: leaseA }); - - pair.a.kill('SIGKILL'); - // Await real death (zombie reaped): the kernel has released A's lock. - const exited = await waitForPidExit(pair.a.pid, 10_000); - log('SIGKILL delivered', { pid: pair.a.pid, exited }); - expect(exited).toBe(true); - - // Once A is gone, B acquires the released lock on its first request. - const success = await getEnvelope(pair.b.baseUrl, warningsPath(sessionId)); - log('B resume success', { status: success.status, code: success.body.code }); - expect(success.status).toBe(200); - expect(success.body.code).toBe(0); - - // Re-acquisition evidence: new lock id and B's metadata, written next - // to the unchanged permanent sentinel. - const leaseB = await readLease(pair.home, sessionId); - log('lease after kernel release', leaseB); - expect(typeof leaseB?.['lock_id']).toBe('string'); - expect(leaseB?.['lock_id']).not.toBe(lockIdA); - expect(leaseB?.['pid']).toBe(pair.b.pid); - expect(leaseB?.['address']).toBe(pair.b.baseUrl); - - const related = (await listLeaseFilenames(pair.home)).filter((name) => - name.startsWith(`${sessionId}.lock`), - ); - expect(related).toEqual([`${sessionId}.lock`, `${sessionId}.lock.owner.json`]); - - // The session survived the holder change intact. - const session = await getEnvelope(pair.b.baseUrl, `/sessions/${sessionId}`); - log('GET /sessions/{id} on B after kernel release', session.body); - expect(session.status).toBe(200); - expect(session.body.code).toBe(0); - expect(session.body.data?.id).toBe(sessionId); - expect(session.body.data?.metadata?.cwd).toBe(pair.home); - - const swept = await assertJsonlIntegrity(pair.home); - log('byte-integrity sweep', swept); - expect(swept.files).toBeGreaterThan(0); - } finally { - await pair.dispose(); - } - expect(pidAlive(pair.a.pid)).toBe(false); - expect(pidAlive(pair.b.pid)).toBe(false); - log('exit hygiene: both child pids dead after dispose', { - pidA: pair.a.pid, - pidB: pair.b.pid, - }); - }, - ); -}); - -/** - * Multi-instance session-list sync (design §3.8): the event-plane hint plus - * the list-side ownership join. While the - * ownership matrix above cross-checks dual-open refusals, these cases track a - * session created on instance A as it surfaces on instance B: - * - * 1. A creates a session under a workspace B has never seen → B's root - * watcher fires → B's subscribed WS client receives a volatile, - * payload-less `session.list_changed` → B's re-pulled list shows the - * session with `ownership.held_by = 'peer'` + A's address, while B's own - * session reads 'self'. - * 2. A creates a SECOND session under a workspace B already watches → - * discovered all the same (the per-workspace watcher layer). - * - * Both run on the in-process pair (real shared home, real chokidar events); - * the hint is volatile — never journaled — so only live delivery counts. - * - * Test-environment note: two kap-server instances in ONE vitest process put - * enough concurrent fs/fsync load on macOS that Node `fs.watch` (libuv - * FSEvents — the only mechanism chokidar 4 has) coalesces directory - * notifications under the shared sessions tree and holds them until further - * write activity flushes the stream (observed: no delivery within 45s - * without activity, ~200ms once the tree is tickled). Both cases therefore - * run a {@link startSessionsTreeKicker} while waiting for the hint; it only - * touches FILES, which the watch service ignores, so it never produces - * hints of its own — every observed hint still corresponds to a real - * workspace/session directory change. - */ -describe('session list sync: session.list_changed hint + ownership join (in-process pair)', () => { - it( - 'a peer-created session under a NEW workspace surfaces via session.list_changed and lists as held_by=peer', - { timeout: 60_000 }, - async () => { - const pair = await startServerPair(); - const stopKicker = startSessionsTreeKicker(join(pair.home, 'sessions')); - const client = new DaemonClient({ baseUrl: pair.urlB }); - const hints: HintRecord[] = []; - try { - // B owns a session so its client has something to subscribe to (global - // volatile events fan out to subscribed connections only). - const { id: ownSessionId } = await client.createSession({ metadata: { cwd: pair.cwd } }); - await client.connect(); - await client.subscribe(ownSessionId); - const off = client.onFrame((frame) => { - if (frame.type === 'session.list_changed') hints.push({ frame, at: Date.now() }); - }); - try { - // Any hint caused by B's OWN create lands before the baseline; only - // hints recorded after it count as "A's create reached B". - const baseline = hints.length; - const peerWorkspace = join(pair.home, 'peer-workspace'); - mkdirSync(peerWorkspace, { recursive: true }); - const { id: peerSessionId } = await pair - .connectClient(pair.a) - .createSession({ metadata: { cwd: peerWorkspace } }); - - const hint = await pollUntil( - async () => (hints.length > baseline ? hints[hints.length - 1] : undefined), - 'session.list_changed reaching B for the new workspace', - 20_000, - 100, - ); - expect(hint.frame.session_id).toBe('__global__'); - expect((hint.frame as { volatile?: boolean }).volatile).toBe(true); - expect(hint.frame.payload).toMatchObject({ - type: 'session.list_changed', - agentId: 'main', - sessionId: '__global__', - }); - - // Data plane: the re-pull (as the client would do on the hint) - // shows A's session; ownership join marks it a routable peer, and - // B's own session stays 'self'. - const listed = await client.listSessions(); - const peerRow = listed.items.find((s) => s.id === peerSessionId); - expect(peerRow?.metadata.cwd).toBe(peerWorkspace); - expect(peerRow?.ownership).toEqual({ held_by: 'peer', address: pair.urlA }); - expect(listed.items.find((s) => s.id === ownSessionId)?.ownership).toEqual({ - held_by: 'self', - }); - } finally { - off(); - } - } finally { - stopKicker(); - await client.close(); - await pair.dispose(); - } - }, - ); - - it( - 'a second session under an ALREADY-WATCHED workspace surfaces via session.list_changed on the peer', - { timeout: 60_000 }, - async () => { - const pair = await startServerPair(); - const stopKicker = startSessionsTreeKicker(join(pair.home, 'sessions')); - const client = new DaemonClient({ baseUrl: pair.urlB }); - const hints: HintRecord[] = []; - try { - // B's own create establishes the workspace dir; B's watcher picks it up - // (its own write triggers the same fs events a peer's write would). - const { id: ownSessionId } = await client.createSession({ metadata: { cwd: pair.cwd } }); - // Give B's root watcher time to attach the per-workspace watcher — - // otherwise A's write below could slip into the attach window and - // produce no hint (the list would still converge on re-pull; the hint - // is advisory). - await sleep(500); - await client.connect(); - await client.subscribe(ownSessionId); - const off = client.onFrame((frame) => { - if (frame.type === 'session.list_changed') hints.push({ frame, at: Date.now() }); - }); - try { - const baseline = hints.length; - const { id: peerSessionId } = await pair - .connectClient(pair.a) - .createSession({ metadata: { cwd: pair.cwd } }); - - await pollUntil( - async () => (hints.length > baseline ? hints[hints.length - 1] : undefined), - 'session.list_changed reaching B for a second session in a known workspace', - 20_000, - 100, - ); - - const listed = await client.listSessions(); - expect(listed.items.find((s) => s.id === peerSessionId)?.ownership).toEqual({ - held_by: 'peer', - address: pair.urlA, - }); - expect(listed.items.find((s) => s.id === ownSessionId)?.ownership).toEqual({ - held_by: 'self', - }); - } finally { - off(); - } - } finally { - stopKicker(); - await client.close(); - await pair.dispose(); - } - }, - ); -}); - -interface HintRecord { - frame: { type: string; session_id?: string; payload?: unknown }; - at: number; -} - // ── Local helpers ────────────────────────────────────────────────────────── -interface SessionWire { - id: string; - metadata?: { cwd?: string }; -} function warningsPath(sessionId: string): string { return `/sessions/${encodeURIComponent(sessionId)}/warnings`; @@ -468,48 +98,6 @@ async function getEnvelope( return { status: res.status, body: (await res.json()) as Envelope }; } -/** - * macOS FSEvents delivery workaround for the in-process pair (see the - * "session list sync" describe header). Two kap-server instances in one - * process generate enough concurrent fs/fsync load that Node `fs.watch` - * notifications under the shared sessions tree are coalesced and held until - * further write activity flushes the stream — without a kicker, a - * peer-created session dir never reaches the other instance's - * `SessionListWatchService` within the test window. - * - * Every `intervalMs`, touch+unlink a FILE at the sessions root and inside - * each workspace bucket (covering both watcher layers). File-kind events are - * ignored by the watch service, so the kicker never produces hints of its - * own; it only forces delivery of the pending directory events that real - * creates are waiting on. Returns a stop function — call it before - * `pair.dispose()` so no tick races the home teardown. - */ -function startSessionsTreeKicker(sessionsRoot: string, intervalMs = 250): () => void { - const timer = setInterval(() => { - let targets: string[]; - try { - targets = [ - sessionsRoot, - ...readdirSync(sessionsRoot, { withFileTypes: true }) - .filter((entry) => entry.isDirectory()) - .map((entry) => join(sessionsRoot, entry.name)), - ]; - } catch { - return; // sessions root gone (teardown in flight) — nothing to kick - } - for (const dir of targets) { - const sentinel = join(dir, '.fs-kick'); - try { - writeFileSync(sentinel, ''); - unlinkSync(sentinel); - } catch { - // best effort — a bucket can disappear mid-tick - } - } - }, intervalMs); - return () => clearInterval(timer); -} - type LeasePayload = Record; /** Read diagnostic owner metadata; undefined when no holder has published it. */ @@ -571,28 +159,3 @@ async function listJsonlFiles(root: string): Promise { .map((entry) => join(entry.parentPath, entry.name)) .sort(); } - -/** Probe returning undefined ⇒ keep polling; any other value ends the wait. */ -async function pollUntil( - probe: () => Promise, - description: string, - timeoutMs: number, - intervalMs: number, -): Promise { - const deadline = Date.now() + timeoutMs; - let lastError: unknown; - for (;;) { - try { - const value = await probe(); - if (value !== undefined) return value; - } catch (error) { - lastError = error; - } - if (Date.now() >= deadline) { - throw new Error(`timed out (${timeoutMs}ms) waiting for: ${description}`, { - cause: lastError, - }); - } - await sleep(intervalMs); - } -} diff --git a/packages/minidb/test/cluster/lock.test.ts b/packages/minidb/test/cluster/lock.test.ts index 3d6d4a477c..02599961e6 100644 --- a/packages/minidb/test/cluster/lock.test.ts +++ b/packages/minidb/test/cluster/lock.test.ts @@ -1,15 +1,10 @@ // test/cluster/lock.test.js // -// Lock semantics inside one process: same-shard writer contention with -// acquire timeout, per-shard independence, read-only coexistence, stable -// sentinels, and writer handoff after close. +// Lock semantics in one process: contention, per-shard independence, read-only coexistence, handoff. import { test } from 'vitest'; import assert from 'node:assert/strict'; -import fs from 'node:fs/promises'; -import path from 'node:path'; import { ClusterDb } from '../../src/cluster/index.js'; -import { shardDirName } from '../../src/cluster/utils.js'; import { tmpDir, rmrf } from '../e2e/helpers/tmp.js'; import { keyOnShard } from './helpers.js'; @@ -91,25 +86,6 @@ test('read-only instance coexists with a live writer and sees its commits', asyn } }); -test('a cached writer keeps a stable sentinel', async () => { - const dir = await tmpDir('minidb-cluster-'); - try { - const db = await ClusterDb.open({ dir, shardCount: 4, valueCodec: 'json', lockHoldMs: 0 }); - const key = keyOnShard('lease', 1, 4); - await db.set(key, { v: 1 }); - - const lockPath = path.join(dir, shardDirName(1, 4), 'db.lock'); - const first = await fs.stat(lockPath); - assert.equal(await fs.readFile(lockPath, 'utf8'), ''); - await new Promise((resolvePromise) => setTimeout(resolvePromise, 100)); - const second = await fs.stat(lockPath); - assert.equal(second.mtimeMs, first.mtimeMs); - await db.close(); - } finally { - await rmrf(dir); - } -}); - test('close releases every shard lock it holds', async () => { const dir = await tmpDir('minidb-cluster-'); try { diff --git a/packages/minidb/test/defense.test.ts b/packages/minidb/test/defense.test.ts index cb511cf5fb..649f696dd3 100644 --- a/packages/minidb/test/defense.test.ts +++ b/packages/minidb/test/defense.test.ts @@ -1,8 +1,8 @@ // Exercises defensive input/state-validation branches that are reachable // through the public/direct API but were not covered by the functional tests. // Fault-injection-only branches (writev short-write, fsync failure, >64MB -// RESP payload, cross-user EPERM, process-exit hook) are intentionally not covered here — see -// the coverage summary in the commit message. +// RESP payload, cross-user EPERM, process-exit hook) are intentionally not +// covered here — see the coverage summary in the commit message. import { expect, test } from 'vitest'; import assert from 'node:assert/strict'; import fs from 'node:fs/promises'; @@ -12,7 +12,6 @@ import net from 'node:net'; import { WAL } from '../src/wal.js'; import { encodeFrame, decodeBatchOps, TYPE_SET } from '../src/codec.js'; import { MiniDb } from '../src/index.js'; -import { LockFile } from '../src/lockfile.js'; import { startServer } from '../src/server.js'; async function tmpDir() { @@ -113,18 +112,6 @@ test('WAL open and close are idempotent', async () => { await fs.rm(dir, { recursive: true, force: true }); }); -// --- LockFile kernel ownership --------------------------------------------- - -test('arbitrary sentinel contents are ignored when no kernel lock is held', async () => { - const dir = await tmpDir(); - const p = path.join(dir, 'db.lock'); - await fs.writeFile(p, 'not-json'); - const b = new LockFile(p); - assert.equal(await b.acquire(), true); - b.releaseSync(); - await fs.rm(dir, { recursive: true, force: true }); -}); - // --- snapshot / compaction edge cases -------------------------------------- test('compacting an empty database produces an empty snapshot and keeps data intact', async () => { diff --git a/packages/minidb/test/lock.test.ts b/packages/minidb/test/lock.test.ts index 1f9204b7fb..fb39da254a 100644 --- a/packages/minidb/test/lock.test.ts +++ b/packages/minidb/test/lock.test.ts @@ -83,21 +83,6 @@ test('pre-existing sentinel contents do not imply ownership', async () => { await cleanup(dir); }); -test('LockFile uses kernel ownership and leaves the sentinel in place', async () => { - const dir = await tmpDir(); - const lockPath = path.join(dir, 'db.lock'); - const first = new LockFile(lockPath); - const second = new LockFile(lockPath); - - assert.equal(await first.acquire(), true); - assert.equal(await second.acquire(), false); - first.releaseSync(); - assert.equal(await fs.stat(lockPath).then(() => true), true); - assert.equal(await second.acquire(), true); - second.releaseSync(); - await cleanup(dir); -}); - test('releaseSync is idempotent', async () => { const dir = await tmpDir(); const lock = new LockFile(path.join(dir, 'db.lock')); diff --git a/packages/minidb/test/review-fixes.test.ts b/packages/minidb/test/review-fixes.test.ts index 313726d03a..7a4e559d1f 100644 --- a/packages/minidb/test/review-fixes.test.ts +++ b/packages/minidb/test/review-fixes.test.ts @@ -91,10 +91,7 @@ test('editing the sentinel payload cannot create a successor generation', async await oldWriter.set('generation', 'old'); await fs.writeFile(path.join(dir, 'db.lock'), 'successor-generation'); - await assert.rejects( - () => MiniDb.open({ dir, valueCodec: 'string', autoCompact: false }), - /locked/, - ); + await assert.rejects(() => MiniDb.open({ dir, valueCodec: 'string', autoCompact: false }), /locked/); await oldWriter.set('generation', 'old-again'); assert.equal(oldWriter.get('generation'), 'old-again'); diff --git a/packages/protocol/src/__tests__/envelope.test.ts b/packages/protocol/src/__tests__/envelope.test.ts index 008f2a76ad..1a14e98f4b 100644 --- a/packages/protocol/src/__tests__/envelope.test.ts +++ b/packages/protocol/src/__tests__/envelope.test.ts @@ -93,11 +93,6 @@ describe('envelope', () => { ); expect(envelopeSchema(z.any()).parse(withDetails).details).toEqual(details); - // Stack and details may coexist; neither position affects the other. - const withBoth = errEnvelope(50001, 'boom', 'req_e', 'trace', details); - expect(withBoth.stack).toBe('trace'); - expect(withBoth.details).toEqual(details); - // No details → field is absent and the wire shape is byte-identical to before. const without = errEnvelope(ErrorCode.SESSION_HELD_BY_PEER, 'owned by peer', 'req_f'); expect(JSON.stringify(without)).toBe( From 6a0d40ec34c883d594703c6193e912ce890ec8e8 Mon Sep 17 00:00:00 2001 From: 7Sageer <7sageer@djwcb.cn> Date: Tue, 21 Jul 2026 21:48:06 +0800 Subject: [PATCH 20/22] fix(agent-core-v2): enforce session lease domain layer --- .../agent-core-v2/scripts/check-domain-layers.mjs | 11 ++++------- .../src/session/sessionLease/sessionLease.ts | 2 +- .../sessionLease/sessionLeaseContactProvider.ts | 2 +- 3 files changed, 6 insertions(+), 9 deletions(-) diff --git a/packages/agent-core-v2/scripts/check-domain-layers.mjs b/packages/agent-core-v2/scripts/check-domain-layers.mjs index 89c6b4f63a..1bd14a5bf0 100644 --- a/packages/agent-core-v2/scripts/check-domain-layers.mjs +++ b/packages/agent-core-v2/scripts/check-domain-layers.mjs @@ -102,6 +102,10 @@ const DOMAIN_LAYER = new Map([ // Depends only on `_base`; sits in L1 beside the other program-control // layer substrates. ['task', 1], + // `sessionLease` owns the low-level per-session write-fencing capability. + // It depends only on the L1 cross-process lock and write-authority contracts + // (plus L0 infrastructure), so consumers must not pull it up to their layer. + ['sessionLease', 1], // persistence/ and os/ — the two-level scopes. `interface` holds contracts // (same layer as the old domains they replace); `backends` holds // implementations that may depend on cross-domain services at various layers. @@ -238,13 +242,6 @@ const DOMAIN_LAYER = new Map([ // through `profile` (L4). Its highest real dependency is `agentLifecycle`, // so it sits in L6 beside `workspaceCommand`. ['sessionInit', 6], - // `sessionLease` owns the per-session write lease (`SessionLease`, the - // Session-scope seeded `ISessionLeaseService` fencing capability, and the - // App-scope contact provider seed). It builds on the L1 cross-process lock - // and the L1 write-authority contract, and is consumed by `sessionLifecycle` - // and `sessionMetadata` (both L6); nothing it imports rises past L1, and it - // sits in L6 beside its consumers. - ['sessionLease', 6], // L7 — boundary ['approval', 7], ['question', 7], diff --git a/packages/agent-core-v2/src/session/sessionLease/sessionLease.ts b/packages/agent-core-v2/src/session/sessionLease/sessionLease.ts index 93fff132af..52d764288d 100644 --- a/packages/agent-core-v2/src/session/sessionLease/sessionLease.ts +++ b/packages/agent-core-v2/src/session/sessionLease/sessionLease.ts @@ -1,5 +1,5 @@ /** - * `sessionLease` domain (L6) — the per-session write lease. + * `sessionLease` domain (L1) — the per-session write lease. * * Defines `ISessionLeaseService`, the Session-scope seeded capability that * state writers use to verify they still own the session's durable state, diff --git a/packages/agent-core-v2/src/session/sessionLease/sessionLeaseContactProvider.ts b/packages/agent-core-v2/src/session/sessionLease/sessionLeaseContactProvider.ts index c9374ed9c1..cba0ae6d2d 100644 --- a/packages/agent-core-v2/src/session/sessionLease/sessionLeaseContactProvider.ts +++ b/packages/agent-core-v2/src/session/sessionLease/sessionLeaseContactProvider.ts @@ -1,5 +1,5 @@ /** - * `sessionLease` domain (L6) — host-provided lease contact address. + * `sessionLease` domain (L1) — host-provided lease contact address. * * Holds what a session lease payload should advertise for this instance: * `{type: 'address', address}` when the host runs a routable network service From de7faaa18d5f2c9bb790867a1b5976b9118b79fe Mon Sep 17 00:00:00 2001 From: 7Sageer <7sageer@djwcb.cn> Date: Tue, 21 Jul 2026 21:52:14 +0800 Subject: [PATCH 21/22] fix(agent-core-v2): keep partial reads behind write fence --- .../src/os/backends/node-local/tools/read.ts | 45 ++++++++++++++----- .../agent/fileFencing/fileFencing.test.ts | 12 +++++ .../os/backends/node-local/tools/read.test.ts | 12 +++-- 3 files changed, 54 insertions(+), 15 deletions(-) diff --git a/packages/agent-core-v2/src/os/backends/node-local/tools/read.ts b/packages/agent-core-v2/src/os/backends/node-local/tools/read.ts index 82eb51659d..f9315ee1a1 100644 --- a/packages/agent-core-v2/src/os/backends/node-local/tools/read.ts +++ b/packages/agent-core-v2/src/os/backends/node-local/tools/read.ts @@ -116,6 +116,11 @@ interface FinishReadResultInput { readonly requestedLines: number; } +interface ReadAttempt { + readonly result: ExecutableToolResult; + readonly coversEntireFile: boolean; +} + function truncateLine(line: string, maxLength: number): string { if (line.length <= maxLength) return line; const marker = '...'; @@ -317,7 +322,7 @@ export class ReadTool implements BuiltinTool { observedStat = value; }; - const result = + const read = lineOffset < 0 ? await this.readTail( safePath, @@ -335,7 +340,7 @@ export class ReadTool implements BuiltinTool { requestedLines, onFileStat, ); - if (result.isError === true) return result; + if (read.result.isError === true) return read.result; observedStat ??= await this.fs.stat(safePath); if (!fileStatTuplesEqual(stat, observedStat)) { return { @@ -343,8 +348,15 @@ export class ReadTool implements BuiltinTool { output: `"${args.path}" changed while it was being read. Retry the read.`, }; } + if ( + args.line_offset !== undefined || + args.n_lines !== undefined || + !read.coversEntireFile + ) { + return read.result; + } return attachToolFileRevision( - { ...result }, + read.result, makeToolFileRevision(safePath, observedStat), ); } catch (error) { @@ -365,7 +377,7 @@ export class ReadTool implements BuiltinTool { effectiveLimit: number, requestedLines: number, onFileStat: (stat: Awaited>) => void, - ): Promise { + ): Promise { const selectedEntries: ReadLineEntry[] = []; const flags: LineEndingFlags = { hasCrLf: false, hasLf: false, hasLoneCr: false }; let currentLineNo = 0; @@ -374,7 +386,10 @@ export class ReadTool implements BuiltinTool { for await (const rawLine of this.fs.readLines(safePath, { errors: 'strict', onFileStat })) { if (containsNulByte(rawLine)) { - return { isError: true, output: notReadableFileOutput(displayPath) }; + return { + result: { isError: true, output: notReadableFileOutput(displayPath) }, + coversEntireFile: false, + }; } currentLineNo += 1; updateLineEndingFlags(flags, rawLine); @@ -423,7 +438,7 @@ export class ReadTool implements BuiltinTool { effectiveLimit: number, requestedLines: number, onFileStat: (stat: Awaited>) => void, - ): Promise { + ): Promise { const tailCount = Math.abs(lineOffset); const entries: ReadLineEntry[] = []; const flags: LineEndingFlags = { hasCrLf: false, hasLf: false, hasLoneCr: false }; @@ -431,7 +446,10 @@ export class ReadTool implements BuiltinTool { for await (const rawLine of this.fs.readLines(safePath, { errors: 'strict', onFileStat })) { if (containsNulByte(rawLine)) { - return { isError: true, output: notReadableFileOutput(displayPath) }; + return { + result: { isError: true, output: notReadableFileOutput(displayPath) }, + coversEntireFile: false, + }; } currentLineNo += 1; updateLineEndingFlags(flags, rawLine); @@ -459,7 +477,7 @@ export class ReadTool implements BuiltinTool { effectiveLimit: number; totalLines: number; requestedLines: number; - }): ExecutableToolResult { + }): ReadAttempt { const lineEndingStyle = lineEndingStyleFromFlags(input.lineEndingFlags); let renderedCandidates = input.entries.slice(0, input.effectiveLimit).map((entry) => { return { entry, rendered: renderLine(entry, lineEndingStyle) }; @@ -507,10 +525,15 @@ export class ReadTool implements BuiltinTool { }); } - private finishReadResult(input: FinishReadResultInput): ExecutableToolResult { + private finishReadResult(input: FinishReadResultInput): ReadAttempt { return { - output: input.renderedLines.join('\n'), - note: `${this.finishMessage(input)}`, + result: { + output: input.renderedLines.join('\n'), + note: `${this.finishMessage(input)}`, + }, + coversEntireFile: + input.renderedLines.length === input.totalLines && + input.truncatedLineNumbers.length === 0, }; } diff --git a/packages/agent-core-v2/test/agent/fileFencing/fileFencing.test.ts b/packages/agent-core-v2/test/agent/fileFencing/fileFencing.test.ts index f11db31a4f..c3dba6ef0f 100644 --- a/packages/agent-core-v2/test/agent/fileFencing/fileFencing.test.ts +++ b/packages/agent-core-v2/test/agent/fileFencing/fileFencing.test.ts @@ -323,6 +323,18 @@ describe('AgentFileFencingService', () => { expect(blocked.output).toContain('has not been read in this session'); }); + it('keeps Edit blocked when a default Read has no complete-file revision', async () => { + const world = setup(); + const file = join(world.env.workDir, 'a.txt'); + writeFileSync(file, 'partial read'); + const ctx = await runBefore(world, beforeCtx('Read', file)); + + await runDid(world, ctx, { output: 'partial' }); + + const blocked = await runBlocked(world, 'Edit', file); + expect(blocked.output).toContain('has not been read in this session'); + }); + it('blocks out-of-root writes through the stat-only fallback and allows them after Read', async () => { const world = setup(); const file = join(world.env.outsideDir, 'b.txt'); diff --git a/packages/agent-core-v2/test/os/backends/node-local/tools/read.test.ts b/packages/agent-core-v2/test/os/backends/node-local/tools/read.test.ts index daf63f8697..3a7cca8319 100644 --- a/packages/agent-core-v2/test/os/backends/node-local/tools/read.test.ts +++ b/packages/agent-core-v2/test/os/backends/node-local/tools/read.test.ts @@ -301,7 +301,7 @@ describe('ReadTool', () => { ); }); - it('respects one-based line_offset and positive n_lines', async () => { + it('returns the requested line range without a complete-file revision', async () => { const tool = toolWithContent('a\nb\nc\nd\ne'); const result = await execute(tool, { path: '/tmp/a.txt', line_offset: 2, n_lines: 2 }); @@ -310,6 +310,7 @@ describe('ReadTool', () => { output: '2\tb\n3\tc', note: readNote('2 lines read from file starting from line 2. Total lines in file: 5.'), }); + expect(result[toolFileRevision]).toBeUndefined(); }); it('returns an empty successful output when line_offset is beyond EOF', async () => { @@ -597,7 +598,7 @@ describe('ReadTool', () => { expect(output).not.toContain('encoded data was not valid'); }); - it('truncates long lines and surfaces the affected line numbers', async () => { + it('marks long-line truncation as an incomplete read', async () => { const long = 'x'.repeat(MAX_LINE_LENGTH + 10); const tool = toolWithContent([long, 'short', long].join('\n')); @@ -605,9 +606,10 @@ describe('ReadTool', () => { expect(result.note).toContain('Lines [1, 3] were truncated.'); expect(result.output).toContain('...'); + expect(result[toolFileRevision]).toBeUndefined(); }); - it('checks the byte cap before adding the next rendered line', async () => { + it('marks byte-capped output as an incomplete read', async () => { const line = 'x'.repeat(MAX_LINE_LENGTH); const content = Array.from({ length: 80 }, () => line).join('\n'); const tool = toolWithContent(content); @@ -617,6 +619,7 @@ describe('ReadTool', () => { expect(Buffer.byteLength(output, 'utf8')).toBeLessThanOrEqual(MAX_BYTES); expect(result.note).toContain(`Max ${String(MAX_BYTES)} bytes reached.`); + expect(result[toolFileRevision]).toBeUndefined(); }); it('reads through bounded byte preflight and streams line iteration without full readText', async () => { @@ -654,7 +657,7 @@ describe('ReadTool', () => { expect(readText).not.toHaveBeenCalled(); }); - it('caps default reads at MAX_LINES', async () => { + it('marks line-capped default reads as incomplete', async () => { const content = Array.from({ length: MAX_LINES + 1 }, (_, i) => `line ${String(i + 1)}`).join( '\n', ); @@ -665,6 +668,7 @@ describe('ReadTool', () => { expect(result.note).toContain(`Max ${String(MAX_LINES)} lines reached.`); expect(result.output).toContain(`${String(MAX_LINES)}\tline ${String(MAX_LINES)}`); expect(result.output).not.toContain(`${String(MAX_LINES + 1)}\tline ${String(MAX_LINES + 1)}`); + expect(result[toolFileRevision]).toBeUndefined(); }); it('tail byte truncation keeps the newest lines closest to EOF', async () => { From 725df34b2476b848f55e5d9f6d89eb06aeaef155 Mon Sep 17 00:00:00 2001 From: 7Sageer <7sageer@djwcb.cn> Date: Tue, 21 Jul 2026 21:56:23 +0800 Subject: [PATCH 22/22] refactor: route skill root probes through host fs --- .../src/app/skillCatalog/skillRootWatcher.ts | 17 +++-- .../src/app/skillCatalog/skillRoots.ts | 65 ++++++++++++------- .../app/skillCatalog/userFileSkillSource.ts | 12 +++- .../explicitFileSkillSource.ts | 26 ++++++-- .../extraFileSkillSource.ts | 22 +++++-- .../workspaceFileSkillSource.ts | 13 ++-- .../test/app/skillCatalog/skillRoots.test.ts | 22 +++++-- 7 files changed, 123 insertions(+), 54 deletions(-) diff --git a/packages/agent-core-v2/src/app/skillCatalog/skillRootWatcher.ts b/packages/agent-core-v2/src/app/skillCatalog/skillRootWatcher.ts index 31a012a6be..8aae0467cd 100644 --- a/packages/agent-core-v2/src/app/skillCatalog/skillRootWatcher.ts +++ b/packages/agent-core-v2/src/app/skillCatalog/skillRootWatcher.ts @@ -1,9 +1,10 @@ /** * `skillCatalog` domain (L3) — filesystem watcher for skill-root directories. * - * Watches candidate skill roots through `IHostFsWatchService` and fires a - * 300 ms debounced change callback whenever any of them changes. Candidates - * may not exist yet (skill directories are opt-in). chokidar 4 (verified + * Watches candidate skill roots through `IHostFsWatchService`, probes them + * through `IHostFileSystem`, and fires a 300 ms debounced change callback + * whenever any of them changes. Candidates may not exist yet (skill + * directories are opt-in). chokidar 4 (verified * 4.0.3 on darwin): a recursive watch on a path whose immediate parent * exists picks the path up when it is created, but a path with two or more * missing leading segments reports NOTHING when the chain appears. So an @@ -19,6 +20,7 @@ import { dirname } from 'pathe'; import { Disposable } from '#/_base/di/lifecycle'; +import type { IHostFileSystem } from '#/os/interface/hostFileSystem'; import type { HostFsChange, IHostFsWatchHandle, IHostFsWatchService } from '#/os/interface/hostFsWatch'; import { isDir } from './skillRoots'; @@ -42,6 +44,7 @@ export class SkillRootWatcher extends Disposable { readonly ready: Promise; constructor( + private readonly hostFs: IHostFileSystem, private readonly hostFsWatch: IHostFsWatchService, private readonly resolveRoots: () => Promise, private readonly onDidChange: () => void, @@ -105,7 +108,7 @@ export class SkillRootWatcher extends Disposable { private advance(state: RootWatchState): void { const tail = state.advanceTail.then(async () => { if (this.disposed || this.states.get(state.root) !== state) return; - if (await isDir(state.root)) { + if (await isDir(this.hostFs, state.root)) { if (state.rootWatch !== undefined) return; // A previously armed sentinel means the root just appeared (possibly // with content already inside): the transition itself is a change. @@ -124,7 +127,7 @@ export class SkillRootWatcher extends Disposable { } state.rootWatch?.dispose(); state.rootWatch = undefined; - const anchor = await nearestExistingDir(state.root); + const anchor = await nearestExistingDir(this.hostFs, state.root); if (this.disposed || this.states.get(state.root) !== state) return; if (state.sentinel !== undefined && state.sentinelDir === anchor) return; state.sentinel?.dispose(); @@ -166,10 +169,10 @@ function isOnRootChain(root: string, eventPath: string): boolean { ); } -async function nearestExistingDir(root: string): Promise { +async function nearestExistingDir(fs: IHostFileSystem, root: string): Promise { let current = root; while (true) { - if (await isDir(current)) return current; + if (await isDir(fs, current)) return current; const parent = dirname(current); if (parent === current) return current; current = parent; diff --git a/packages/agent-core-v2/src/app/skillCatalog/skillRoots.ts b/packages/agent-core-v2/src/app/skillCatalog/skillRoots.ts index d1b8141ad7..3ad408a3a4 100644 --- a/packages/agent-core-v2/src/app/skillCatalog/skillRoots.ts +++ b/packages/agent-core-v2/src/app/skillCatalog/skillRoots.ts @@ -9,12 +9,14 @@ * `*Candidates` helpers return the same locations WITHOUT the existence filter * or realpath resolution, for file watchers that must observe roots appearing * later. These helpers are exported so the edge can compose a workspace's - * skills without a Session. Pure path/fs probes; no scoped state. + * skills without a Session. Pure path probes through `IHostFileSystem`; no + * scoped state. */ -import { promises as fs } from 'node:fs'; import path from 'pathe'; +import type { IHostFileSystem } from '#/os/interface/hostFileSystem'; + import type { SkillRoot, SkillSource } from './types'; const USER_BRAND_DIRS = ['skills'] as const; @@ -27,39 +29,49 @@ export interface SkillRootsOptions { } export async function userRoots( + fs: IHostFileSystem, homeDir: string, osHomeDir: string, options: SkillRootsOptions = {}, ): Promise { const roots: SkillRoot[] = []; const mergeAllAvailableSkills = options.mergeAllAvailableSkills ?? true; - await pushBrandGroup(roots, USER_BRAND_DIRS, homeDir, 'user', mergeAllAvailableSkills); - await pushFirstExisting(roots, USER_GENERIC_DIRS, osHomeDir, 'user'); + await pushBrandGroup(fs, roots, USER_BRAND_DIRS, homeDir, 'user', mergeAllAvailableSkills); + await pushFirstExisting(fs, roots, USER_GENERIC_DIRS, osHomeDir, 'user'); return roots; } export async function projectRoots( + fs: IHostFileSystem, workDir: string, options: SkillRootsOptions = {}, ): Promise { - const projectRoot = await findProjectRoot(workDir); + const projectRoot = await findProjectRoot(fs, workDir); const roots: SkillRoot[] = []; const mergeAllAvailableSkills = options.mergeAllAvailableSkills ?? true; - await pushBrandGroup(roots, PROJECT_BRAND_DIRS, projectRoot, 'project', mergeAllAvailableSkills); - await pushFirstExisting(roots, PROJECT_GENERIC_DIRS, projectRoot, 'project'); + await pushBrandGroup( + fs, + roots, + PROJECT_BRAND_DIRS, + projectRoot, + 'project', + mergeAllAvailableSkills, + ); + await pushFirstExisting(fs, roots, PROJECT_GENERIC_DIRS, projectRoot, 'project'); return roots; } export async function configuredRoots( + fs: IHostFileSystem, dirs: readonly string[], workDir: string, osHomeDir: string, source: SkillSource, ): Promise { - const projectRoot = await findProjectRoot(workDir); + const projectRoot = await findProjectRoot(fs, workDir); const roots: SkillRoot[] = []; for (const dir of dirs) { - await pushExistingRoot(roots, resolveConfiguredDir(dir, projectRoot, osHomeDir), source); + await pushExistingRoot(fs, roots, resolveConfiguredDir(dir, projectRoot, osHomeDir), source); } return roots; } @@ -71,8 +83,11 @@ export function userRootCandidates(homeDir: string, osHomeDir: string): readonly ]; } -export async function projectRootCandidates(workDir: string): Promise { - const projectRoot = await findProjectRoot(workDir); +export async function projectRootCandidates( + fs: IHostFileSystem, + workDir: string, +): Promise { + const projectRoot = await findProjectRoot(fs, workDir); return [ ...PROJECT_BRAND_DIRS.map((dir) => path.join(projectRoot, dir)), ...PROJECT_GENERIC_DIRS.map((dir) => path.join(projectRoot, dir)), @@ -80,19 +95,20 @@ export async function projectRootCandidates(workDir: string): Promise { - const projectRoot = await findProjectRoot(workDir); + const projectRoot = await findProjectRoot(fs, workDir); return dirs.map((dir) => resolveConfiguredDir(dir, projectRoot, osHomeDir)); } -async function findProjectRoot(workDir: string): Promise { +async function findProjectRoot(fs: IHostFileSystem, workDir: string): Promise { const start = path.resolve(workDir); let current = start; while (true) { - if (await exists(path.join(current, '.git'))) return current; + if (await exists(fs, path.join(current, '.git'))) return current; const parent = path.dirname(current); if (parent === current) return start; current = parent; @@ -100,17 +116,19 @@ async function findProjectRoot(workDir: string): Promise { } async function pushFirstExisting( + fs: IHostFileSystem, out: SkillRoot[], dirs: readonly string[], base: string, source: SkillSource, ): Promise { for (const dir of dirs) { - if (await pushExistingRoot(out, path.join(base, dir), source)) return; + if (await pushExistingRoot(fs, out, path.join(base, dir), source)) return; } } async function pushBrandGroup( + fs: IHostFileSystem, out: SkillRoot[], dirs: readonly string[], base: string, @@ -118,21 +136,22 @@ async function pushBrandGroup( mergeAllAvailableSkills: boolean, ): Promise { if (!mergeAllAvailableSkills) { - await pushFirstExisting(out, dirs, base, source); + await pushFirstExisting(fs, out, dirs, base, source); return; } for (const dir of dirs) { - await pushExistingRoot(out, path.join(base, dir), source); + await pushExistingRoot(fs, out, path.join(base, dir), source); } } async function pushExistingRoot( + fs: IHostFileSystem, out: SkillRoot[], dir: string, source: SkillSource, ): Promise { - if (!(await isDir(dir))) return false; - const resolved = await realpath(dir); + if (!(await isDir(fs, dir))) return false; + const resolved = await realpath(fs, dir); if (!out.some((root) => root.path === resolved)) out.push({ path: resolved, source }); return true; } @@ -144,19 +163,19 @@ function resolveConfiguredDir(dir: string, projectRoot: string, osHomeDir: strin return path.resolve(projectRoot, dir); } -export async function isDir(p: string): Promise { +export async function isDir(fs: IHostFileSystem, p: string): Promise { try { - return (await fs.stat(p)).isDirectory(); + return (await fs.stat(p)).isDirectory; } catch { return false; } } -async function realpath(p: string): Promise { +async function realpath(fs: IHostFileSystem, p: string): Promise { return (await fs.realpath(p)).replaceAll('\\', '/'); } -async function exists(p: string): Promise { +async function exists(fs: IHostFileSystem, p: string): Promise { try { await fs.stat(p); return true; diff --git a/packages/agent-core-v2/src/app/skillCatalog/userFileSkillSource.ts b/packages/agent-core-v2/src/app/skillCatalog/userFileSkillSource.ts index 6f8c317676..a5f4a13cc2 100644 --- a/packages/agent-core-v2/src/app/skillCatalog/userFileSkillSource.ts +++ b/packages/agent-core-v2/src/app/skillCatalog/userFileSkillSource.ts @@ -4,8 +4,9 @@ * Discovers user skills from the bootstrap home directories through * `ISkillDiscovery`, contributing them at priority 20 (above extra / plugin / * builtin, below workspace). Reads home paths from `bootstrap` and hot-reloads - * on both its config section and filesystem changes in the user skill roots - * (watched through `hostFsWatch` via `SkillRootWatcher`). Bound at App scope. + * on both its config section and filesystem changes in the user skill roots, + * probing them through `hostFs` and watching them through `hostFsWatch` via + * `SkillRootWatcher`. Bound at App scope. */ import { createDecorator, type ServiceIdentifier } from '#/_base/di/instantiation'; @@ -15,6 +16,7 @@ import { Emitter, type Event } from '#/_base/event'; import { LifecycleScope, registerScopedService } from '#/_base/di/scope'; import { IBootstrapService } from '#/app/bootstrap/bootstrap'; import { IConfigService } from '#/app/config/config'; +import { IHostFileSystem } from '#/os/interface/hostFileSystem'; import { IHostFsWatchService } from '#/os/interface/hostFsWatch'; import { @@ -47,6 +49,7 @@ export class UserFileSkillSource extends Disposable implements IUserFileSkillSou @IBootstrapService private readonly bootstrap: IBootstrapService, @IConfigService private readonly config: IConfigService, @ISkillCatalogRuntimeOptions private readonly runtimeOptions: ISkillCatalogRuntimeOptions, + @IHostFileSystem private readonly hostFs: IHostFileSystem, @IHostFsWatchService hostFsWatch: IHostFsWatchService, ) { super(); @@ -58,6 +61,7 @@ export class UserFileSkillSource extends Disposable implements IUserFileSkillSou if ((this.runtimeOptions.explicitDirs?.length ?? 0) === 0) { this._register( new SkillRootWatcher( + this.hostFs, hostFsWatch, async () => userRootCandidates(this.bootstrap.homeDir, this.bootstrap.osHomeDir), () => this.onDidChangeEmitter.fire(), @@ -74,7 +78,9 @@ export class UserFileSkillSource extends Disposable implements IUserFileSkillSou const mergeAllAvailableSkills = this.config.get(MERGE_ALL_AVAILABLE_SKILLS_SECTION) ?? true; return this.discovery.discover( - await userRoots(this.bootstrap.homeDir, this.bootstrap.osHomeDir, { mergeAllAvailableSkills }), + await userRoots(this.hostFs, this.bootstrap.homeDir, this.bootstrap.osHomeDir, { + mergeAllAvailableSkills, + }), ); } } diff --git a/packages/agent-core-v2/src/session/sessionSkillCatalog/explicitFileSkillSource.ts b/packages/agent-core-v2/src/session/sessionSkillCatalog/explicitFileSkillSource.ts index 486aaff827..500a213b80 100644 --- a/packages/agent-core-v2/src/session/sessionSkillCatalog/explicitFileSkillSource.ts +++ b/packages/agent-core-v2/src/session/sessionSkillCatalog/explicitFileSkillSource.ts @@ -4,10 +4,10 @@ * Mirrors v1 SDK `skillDirs`: when runtime options provide `explicitDirs`, this * source contributes those directories as the user source, resolving relative * paths against the session project root, and hot-reloads on filesystem - * changes in them (watched through `hostFsWatch` via `SkillRootWatcher`). When - * no explicit dirs are configured, it yields nothing so default user / project - * discovery remains active. Bound at Session scope so each session resolves - * paths against its own workDir. + * changes in them (probed through `hostFs` and watched through `hostFsWatch` + * via `SkillRootWatcher`). When no explicit dirs are configured, it yields + * nothing so default user / project discovery remains active. Bound at Session + * scope so each session resolves paths against its own workDir. */ import { createDecorator, type ServiceIdentifier } from '#/_base/di/instantiation'; @@ -21,6 +21,7 @@ import { ISkillCatalogRuntimeOptions } from '#/app/skillCatalog/skillCatalogRunt import { ISkillDiscovery } from '#/app/skillCatalog/skillDiscovery'; import { SkillRootWatcher } from '#/app/skillCatalog/skillRootWatcher'; import { SKILL_SOURCE_PRIORITY, type ISkillSource, type SkillContribution } from '#/app/skillCatalog/skillSource'; +import { IHostFileSystem } from '#/os/interface/hostFileSystem'; import { IHostFsWatchService } from '#/os/interface/hostFsWatch'; import { ISessionWorkspaceContext } from '#/session/workspaceContext/workspaceContext'; @@ -44,6 +45,7 @@ export class ExplicitFileSkillSource extends Disposable implements IExplicitFile @ISkillCatalogRuntimeOptions private readonly runtimeOptions: ISkillCatalogRuntimeOptions, @ISessionWorkspaceContext private readonly workspace: ISessionWorkspaceContext, @IBootstrapService private readonly bootstrap: IBootstrapService, + @IHostFileSystem private readonly hostFs: IHostFileSystem, @IHostFsWatchService hostFsWatch: IHostFsWatchService, ) { super(); @@ -51,9 +53,15 @@ export class ExplicitFileSkillSource extends Disposable implements IExplicitFile if (explicitDirs.length > 0) { this._register( new SkillRootWatcher( + this.hostFs, hostFsWatch, async () => - configuredRootCandidates(explicitDirs, this.workspace.workDir, this.bootstrap.osHomeDir), + configuredRootCandidates( + this.hostFs, + explicitDirs, + this.workspace.workDir, + this.bootstrap.osHomeDir, + ), () => this.onDidChangeEmitter.fire(), ), ); @@ -66,7 +74,13 @@ export class ExplicitFileSkillSource extends Disposable implements IExplicitFile return { skills: [] }; } return this.discovery.discover( - await configuredRoots(explicitDirs, this.workspace.workDir, this.bootstrap.osHomeDir, 'user'), + await configuredRoots( + this.hostFs, + explicitDirs, + this.workspace.workDir, + this.bootstrap.osHomeDir, + 'user', + ), ); } } diff --git a/packages/agent-core-v2/src/session/sessionSkillCatalog/extraFileSkillSource.ts b/packages/agent-core-v2/src/session/sessionSkillCatalog/extraFileSkillSource.ts index 1094f8f0dc..0d261c3f86 100644 --- a/packages/agent-core-v2/src/session/sessionSkillCatalog/extraFileSkillSource.ts +++ b/packages/agent-core-v2/src/session/sessionSkillCatalog/extraFileSkillSource.ts @@ -7,8 +7,8 @@ * root; `~` and `~/...` resolve against the bootstrap home dir. Hot-reloads on * both its config section (re-resolving the watched directories) and * filesystem changes in the configured roots (watched through `hostFsWatch` - * via `SkillRootWatcher`). Bound at Session scope so each session reads its - * own workspace root. + * via `SkillRootWatcher` and probed through `hostFs`). Bound at Session scope + * so each session reads its own workspace root. */ import { createDecorator, type ServiceIdentifier } from '#/_base/di/instantiation'; @@ -26,6 +26,7 @@ import { configuredRootCandidates, configuredRoots } from '#/app/skillCatalog/sk import { ISkillDiscovery } from '#/app/skillCatalog/skillDiscovery'; import { SkillRootWatcher } from '#/app/skillCatalog/skillRootWatcher'; import { SKILL_SOURCE_PRIORITY, type ISkillSource, type SkillContribution } from '#/app/skillCatalog/skillSource'; +import { IHostFileSystem } from '#/os/interface/hostFileSystem'; import { IHostFsWatchService } from '#/os/interface/hostFsWatch'; import { ISessionWorkspaceContext } from '#/session/workspaceContext/workspaceContext'; @@ -50,6 +51,7 @@ export class ExtraFileSkillSource extends Disposable implements IExtraFileSkillS @IConfigService private readonly config: IConfigService, @ISessionWorkspaceContext private readonly workspace: ISessionWorkspaceContext, @IBootstrapService private readonly bootstrap: IBootstrapService, + @IHostFileSystem private readonly hostFs: IHostFileSystem, @IHostFsWatchService hostFsWatch: IHostFsWatchService, ) { super(); @@ -63,6 +65,7 @@ export class ExtraFileSkillSource extends Disposable implements IExtraFileSkillS ); this.watcher = this._register( new SkillRootWatcher( + this.hostFs, hostFsWatch, () => this.watchCandidates(), () => this.onDidChangeEmitter.fire(), @@ -74,14 +77,25 @@ export class ExtraFileSkillSource extends Disposable implements IExtraFileSkillS await this.config.ready; const extraSkillDirs = this.config.get(EXTRA_SKILL_DIRS_SECTION) ?? []; return this.discovery.discover( - await configuredRoots(extraSkillDirs, this.workspace.workDir, this.bootstrap.osHomeDir, 'extra'), + await configuredRoots( + this.hostFs, + extraSkillDirs, + this.workspace.workDir, + this.bootstrap.osHomeDir, + 'extra', + ), ); } private async watchCandidates(): Promise { await this.config.ready; const extraSkillDirs = this.config.get(EXTRA_SKILL_DIRS_SECTION) ?? []; - return configuredRootCandidates(extraSkillDirs, this.workspace.workDir, this.bootstrap.osHomeDir); + return configuredRootCandidates( + this.hostFs, + extraSkillDirs, + this.workspace.workDir, + this.bootstrap.osHomeDir, + ); } } diff --git a/packages/agent-core-v2/src/session/sessionSkillCatalog/workspaceFileSkillSource.ts b/packages/agent-core-v2/src/session/sessionSkillCatalog/workspaceFileSkillSource.ts index 324a54bbc6..99a5ba9bfb 100644 --- a/packages/agent-core-v2/src/session/sessionSkillCatalog/workspaceFileSkillSource.ts +++ b/packages/agent-core-v2/src/session/sessionSkillCatalog/workspaceFileSkillSource.ts @@ -5,8 +5,8 @@ * (`workspaceContext`) through `ISkillDiscovery`, contributing them at priority * 30 (above user / extra / plugin / builtin). Hot-reloads on both its config * section and filesystem changes in the project skill roots (watched through - * `hostFsWatch` via `SkillRootWatcher`). Bound at Session scope so each - * session reads its own workspace root. + * `hostFsWatch` via `SkillRootWatcher` and probed through `hostFs`). Bound at + * Session scope so each session reads its own workspace root. */ import { createDecorator, type ServiceIdentifier } from '#/_base/di/instantiation'; @@ -24,6 +24,7 @@ import { ISkillDiscovery } from '#/app/skillCatalog/skillDiscovery'; import { SkillRootWatcher } from '#/app/skillCatalog/skillRootWatcher'; import { projectRootCandidates, projectRoots } from '#/app/skillCatalog/skillRoots'; import { SKILL_SOURCE_PRIORITY, type ISkillSource, type SkillContribution } from '#/app/skillCatalog/skillSource'; +import { IHostFileSystem } from '#/os/interface/hostFileSystem'; import { IHostFsWatchService } from '#/os/interface/hostFsWatch'; import { ISessionWorkspaceContext } from '#/session/workspaceContext/workspaceContext'; @@ -47,6 +48,7 @@ export class WorkspaceFileSkillSource extends Disposable implements IWorkspaceFi @ISessionWorkspaceContext private readonly workspace: ISessionWorkspaceContext, @IConfigService private readonly config: IConfigService, @ISkillCatalogRuntimeOptions private readonly runtimeOptions: ISkillCatalogRuntimeOptions, + @IHostFileSystem private readonly hostFs: IHostFileSystem, @IHostFsWatchService hostFsWatch: IHostFsWatchService, ) { super(); @@ -58,8 +60,9 @@ export class WorkspaceFileSkillSource extends Disposable implements IWorkspaceFi if ((this.runtimeOptions.explicitDirs?.length ?? 0) === 0) { this._register( new SkillRootWatcher( + this.hostFs, hostFsWatch, - async () => projectRootCandidates(this.workspace.workDir), + async () => projectRootCandidates(this.hostFs, this.workspace.workDir), () => this.onDidChangeEmitter.fire(), ), ); @@ -73,7 +76,9 @@ export class WorkspaceFileSkillSource extends Disposable implements IWorkspaceFi await this.config.ready; const mergeAllAvailableSkills = this.config.get(MERGE_ALL_AVAILABLE_SKILLS_SECTION) ?? true; - return this.discovery.discover(await projectRoots(this.workspace.workDir, { mergeAllAvailableSkills })); + return this.discovery.discover( + await projectRoots(this.hostFs, this.workspace.workDir, { mergeAllAvailableSkills }), + ); } } diff --git a/packages/agent-core-v2/test/app/skillCatalog/skillRoots.test.ts b/packages/agent-core-v2/test/app/skillCatalog/skillRoots.test.ts index ae4e7735f6..62c1749b5d 100644 --- a/packages/agent-core-v2/test/app/skillCatalog/skillRoots.test.ts +++ b/packages/agent-core-v2/test/app/skillCatalog/skillRoots.test.ts @@ -5,8 +5,10 @@ import { join } from 'pathe'; import { afterEach, beforeEach, describe, expect, it } from 'vitest'; import { configuredRoots, projectRoots, userRoots } from '#/app/skillCatalog/skillRoots'; +import { HostFileSystem } from '#/os/backends/node-local/hostFsService'; describe('skillRoots', () => { + const fs = new HostFileSystem(); let root: string; beforeEach(async () => { @@ -26,7 +28,7 @@ describe('skillRoots', () => { await markGitRoot(); await mkdir(join(root, '.kimi-code/skills/commit'), { recursive: true }); - const roots = await projectRoots(root); + const roots = await projectRoots(fs, root); expect(roots.some((r) => r.path.endsWith('.kimi-code/skills') && r.source === 'project')).toBe( true, @@ -37,7 +39,7 @@ describe('skillRoots', () => { await markGitRoot(); await mkdir(join(root, '.agents/skills/review'), { recursive: true }); - const roots = await projectRoots(root); + const roots = await projectRoots(fs, root); expect(roots.some((r) => r.path.endsWith('.agents/skills') && r.source === 'project')).toBe( true, @@ -51,7 +53,7 @@ describe('skillRoots', () => { const child = join(root, 'src/pkg'); await mkdir(child, { recursive: true }); - const roots = await projectRoots(child); + const roots = await projectRoots(fs, child); expect(roots.some((r) => r.path.endsWith('.kimi-code/skills'))).toBe(true); }); @@ -61,7 +63,7 @@ describe('skillRoots', () => { await mkdir(join(root, '.kimi-code/skills'), { recursive: true }); await mkdir(join(root, '.agents/skills'), { recursive: true }); - const roots = await projectRoots(root); + const roots = await projectRoots(fs, root); const brandIdx = roots.findIndex((r) => r.path.endsWith('.kimi-code/skills')); const genericIdx = roots.findIndex((r) => r.path.endsWith('.agents/skills')); @@ -74,7 +76,7 @@ describe('skillRoots', () => { it('resolves the brand skills directory under homeDir', async () => { await mkdir(join(root, 'skills/notes'), { recursive: true }); - const roots = await userRoots(root, root); + const roots = await userRoots(fs, root, root); expect(roots.some((r) => r.path.endsWith('/skills') && r.source === 'user')).toBe(true); }); @@ -85,7 +87,7 @@ describe('skillRoots', () => { await mkdir(homeDir, { recursive: true }); await mkdir(join(osHomeDir, '.agents/skills/notes'), { recursive: true }); - const roots = await userRoots(homeDir, osHomeDir); + const roots = await userRoots(fs, homeDir, osHomeDir); expect(roots.some((r) => r.path.endsWith('.agents/skills') && r.source === 'user')).toBe( true, @@ -103,7 +105,13 @@ describe('skillRoots', () => { await mkdir(absDir, { recursive: true }); await mkdir(join(root, 'relative'), { recursive: true }); - const roots = await configuredRoots(['~', '~/notes', absDir, 'relative'], root, homeDir, 'extra'); + const roots = await configuredRoots( + fs, + ['~', '~/notes', absDir, 'relative'], + root, + homeDir, + 'extra', + ); const paths = roots.map((root) => root.path); expect(roots.every((root) => root.source === 'extra')).toBe(true);