From 844e85545af6858dcb3d6cfe42bbfcf2ca0be4e5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Sebastian=20M=C3=BCller?= Date: Mon, 7 Sep 2026 03:56:32 +0200 Subject: [PATCH] Harden session persistence, worker recovery, and daemon refreshes (#2028) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * refactor(coding-agent): move the semantic-edge ledger onto the event-log substrate The recorder's private append/replay/repair IO is deleted; EventLog owns it, the same move #1987 made for the RLM spawn ledger. One durability rule is unified in the substrate rather than dropped: an unterminated final line is an uncommitted append, skipped on read and truncated before the next append — never newline-completed and never surfaced to a consumer whose next append destroys it. * fix(coding-agent): make the explicit ledger reader's ENOENT contract atomic readSemanticEdgeLedger probed with statSync before reading through EventLog, which swallows ENOENT; a ledger deleted between the two returned [] instead of throwing. The missing-file decision now lives at the single open (replaySync missingFileThrows), so no check-then-read window exists. * docs(coding-agent): state the event-log tail rule once The unterminated-tail contract was restated four times (module doc, replaySync doc, two test comments). It now lives once in the module doc; the method doc keeps only its own parse/missing-file semantics and the test comments reference the contract. * fix(coding-agent): write event-log appends fully and gate appends on tail repair writeSync may write short (ENOSPC after a prefix); appendSync now loops until the payload is fully on disk so write-before-action callers never act on a torn record reported as success. A tail-repair failure (e.g. append-only ACL permitting O_APPEND but not r+) now propagates instead of being swallowed: writing through an unrepaired torn tail would weld it to the new record as permanent interior corruption. ENOENT and the concurrent-writer instability path keep their existing semantics. * fix(coding-agent): reclaim short event-log writes instead of completing them The rlm spawn ledger is multi-writer by documented design (supervisor plus each worker over one file), so completing a short O_APPEND write with a second write could interleave with a rival append and weld two records. A short write now truncates its own torn prefix back off (only while this writer still owns the tail) and fails the append; a torn tail is read-tolerated, a weld is permanent corruption. The append fd opens a+ so the ownership check can read the tail. * fix(coding-agent): leave the torn tail on a short write instead of reclaiming it The tail-match reclaim could truncate a rival's committed record whose final bytes coincide with our torn prefix - committed-data loss, strictly worse than the torn tail it prevented. A short write now just fails the append: the torn tail is the one tolerated shape, skipped on read and truncated by any writer's next repair (verified for both topologies: a resumed single-writer recorder repairs on its first append; every rlm-ledger writer repairs before each append). * refactor(coding-agent): compress event-log comments * fix(ai): omit the default service tier, reprice cache writes from message_delta, repoint the zai default Incorporates #2032 at f82c7fa3688b7e25cef80e2663fe77ed153c5cd9. * fix(tui,coding-agent): survive lone surrogates in table cells and terminate the WebP EXIF scan Incorporates #2033 at a3d1139ee6e1f77337ab134b0f0c0cede8c942af. * fix(coding-agent): restart dead kernels on ensure() and read mcp>=2 tool schemas Incorporates #2034 at 749e216e901304b310ead9f46c3813a032608d7b. * fix: one crash-safe owner for durable state writes Incorporates #2035 at f0f02d2fb090ec427307a65b9e9578ae3c8fc783. * fix(coding-agent): one zombie-aware process-liveness probe Incorporates #2041 at 92a0eacf47efc831444b0b4e412f35bd17ac3f28. * fix(coding-agent): snapshot transfer ids from the materialized cursor; mismatches settle the transfer, not the worker channel Incorporates #2044 at 5af3bbe8090240507d6b0297d0da007159e15305. * fix(coding-agent): failed workers recover on touch; roster gaps answer a structured recovering error Incorporates #2047 at 77b747afab402a9e2f5eada227ffe6fed626a634. * fix(coding-agent): seven session and IO correctness defects Incorporates #2037 at 41b5d72d7758723f22ba07f8738d217868ed7baf. * fix(coding-agent): coalesce child-usage attribution and gate agent-status persistence on real changes Incorporates #2050 at 6b0af5d2679ddff987ef3b4d43235a872fe2b315. * fix(coding-agent): incremental single-flight session metadata scans Incorporates #2043 at df032c1192dd2e38d35fcbaca589db7194f26b6c. * fix(coding-agent): memoize the passive RLM topology derivation Incorporates #2051 at 0ee114ce77942d9298136ca193ddc2c0b5d27d58. * fix(coding-agent): preserve accounting and metadata across deferred updates Keep durable child-usage aggregates separate from pending sibling usage. Retry optional topology metadata after transient reads. Completes #2050 and #2051 integration. * fix: preserve session accounting and read-only persistence boundaries --------- Co-authored-by: Seth --- .../.changes/service-tier-default-omitted.md | 1 + packages/ai/src/providers/anthropic.ts | 16 +- packages/ai/src/providers/openai-responses.ts | 4 +- .../ai/test/anthropic-sse-parsing.test.ts | 35 +- packages/ai/test/openai-codex-stream.test.ts | 2 + .../openai-responses-copilot-provider.test.ts | 35 ++ .../coding-agent/.changes/dead-kernel-memo.md | 1 + .../res-1260-semantic-edges-on-event-log.md | 1 + .../.changes/res-1267-atomic-persistence.md | 1 + .../res-1272-coalesced-bookkeeping-appends.md | 1 + .../res-1272-incremental-session-scans.md | 1 + .../res-1272-memoized-passive-topology.md | 1 + .../.changes/session-io-defects.md | 1 + .../.changes/session-reader-safety.md | 2 + .../.changes/webp-exif-scan-termination.md | 1 + .../.changes/worker-snapshot-cursor.md | 1 + .../worker-state-one-liveness-probe.md | 1 + .../.changes/worker-state-recovery.md | 1 + .../.changes/zai-default-model.md | 1 + packages/coding-agent/src/cli/daemon-ps.ts | 49 +- .../src/cli/daemon-update-restart.ts | 10 +- .../coding-agent/src/cli/initial-message.ts | 2 +- .../coding-agent/src/core/agent-session.ts | 188 +++++- .../coding-agent/src/core/auth-storage.ts | 33 +- .../coding-agent/src/core/bash-executor.ts | 47 +- .../src/core/compaction/compaction.ts | 2 + packages/coding-agent/src/core/cron-jobs.ts | 35 +- packages/coding-agent/src/core/event-log.ts | 101 ++- .../src/core/export-html/index.ts | 3 +- .../coding-agent/src/core/kernel/bootstrap.ts | 43 +- .../src/core/kernel/repl-manager.ts | 4 + .../coding-agent/src/core/kernel/shared.ts | 2 + .../coding-agent/src/core/model-registry.ts | 7 +- .../coding-agent/src/core/model-resolver.ts | 2 +- .../src/core/refinement/refinement.ts | 26 +- .../coding-agent/src/core/semantic-edges.ts | 75 +-- .../coding-agent/src/core/session-lease.ts | 37 +- .../coding-agent/src/core/session-manager.ts | 594 +++++++++++++----- .../coding-agent/src/core/settings-manager.ts | 17 +- packages/coding-agent/src/core/telemetry.ts | 19 +- packages/coding-agent/src/core/tools/bash.ts | 18 +- .../coding-agent/src/core/tools/ipython.ts | 5 + .../src/core/tools/output-accumulator.ts | 123 ++-- .../coding-agent/src/core/tools/truncate.ts | 11 +- packages/coding-agent/src/main.ts | 71 ++- packages/coding-agent/src/migrations.ts | 40 +- .../src/modes/agents-view/agents-view-mode.ts | 7 +- .../modes/agents-view/agents-view-state.ts | 2 +- .../modes/daemon/command-recovery-journal.ts | 23 +- .../src/modes/daemon/daemon-errors.ts | 16 + .../src/modes/daemon/daemon-mode.ts | 232 +++++-- .../src/modes/daemon/daemon-protocol.ts | 6 +- .../modes/daemon/daemon-session-summarizer.ts | 53 +- .../daemon/daemon-supervisor-ownership.ts | 62 +- .../src/modes/daemon/daemon-supervisor.ts | 260 +++++--- .../src/modes/daemon/rlm-ledger.ts | 3 +- .../src/modes/daemon/rlm-subagent-display.ts | 25 +- .../coding-agent/src/utils/atomic-file.ts | 119 ++++ .../coding-agent/src/utils/child-process.ts | 47 ++ packages/coding-agent/src/utils/dir-lock.ts | 207 ++++++ .../src/utils/exif-orientation.ts | 5 +- packages/coding-agent/src/utils/file-lines.ts | 28 +- .../coding-agent/src/utils/frontmatter.ts | 6 +- .../test/agent-session-recursion.test.ts | 379 ++++++++++- .../test/agents-view-state.test.ts | 25 + .../test/atomic-persistence.test.ts | 360 +++++++++++ .../coding-agent/test/auth-storage.test.ts | 154 ++++- .../coding-agent/test/child-process.test.ts | 79 ++- packages/coding-agent/test/compaction.test.ts | 24 + .../coding-agent/test/daemon-errors.test.ts | 35 +- .../coding-agent/test/daemon-mode.test.ts | 234 ++++++- ...aemon-session-summarizer-lifecycle.test.ts | 1 + .../test/daemon-session-summarizer.test.ts | 112 +++- .../test/daemon-supervisor-monitor.test.ts | 196 ++++++ .../test/daemon-supervisor-ownership.test.ts | 63 +- .../test/event-log-faults.test.ts | 70 +++ packages/coding-agent/test/event-log.test.ts | 6 +- .../test/exif-orientation.test.ts | 19 + packages/coding-agent/test/file-lines.test.ts | 12 + .../test/fixtures/zombie-process.ts | 28 + .../coding-agent/test/frontmatter.test.ts | 7 + .../coding-agent/test/initial-message.test.ts | 11 +- .../test/ipython-provisioner.test.ts | 27 + .../test/main-interactive-routing.test.ts | 30 + packages/coding-agent/test/migrations.test.ts | 112 +++- .../coding-agent/test/model-resolver.test.ts | 15 +- .../test/output-accumulator.test.ts | 73 +++ .../coding-agent/test/semantic-edges.test.ts | 8 +- .../coding-agent/test/session-cwd.test.ts | 98 ++- .../coding-agent/test/session-lease.test.ts | 23 +- .../test/session-manager-flush.test.ts | 29 +- .../session-manager/file-operations.test.ts | 288 ++++++++- .../test/session-reader-persistence.test.ts | 126 ++++ .../test/settings-manager-bug.test.ts | 22 +- .../suite/agent-session-retry-events.test.ts | 64 +- ...4602-snapshot-transfer-idempotency.test.ts | 75 ++- packages/coding-agent/test/tools.test.ts | 27 + packages/coding-agent/test/truncate.test.ts | 34 + .../selection-marker-lone-surrogates.md | 1 + packages/tui/src/selection-metadata.ts | 4 +- packages/tui/test/selection-metadata.test.ts | 17 + prime-agent-runtime/src/rlm/harness.py | 22 +- prime-agent-runtime/src/rlm/mcp_base.py | 14 +- prime-agent-runtime/test/test_harness.py | 97 +++ prime-agent-runtime/test/test_mcp_base.py | 23 + 105 files changed, 4854 insertions(+), 962 deletions(-) create mode 100644 packages/ai/.changes/service-tier-default-omitted.md create mode 100644 packages/coding-agent/.changes/dead-kernel-memo.md create mode 100644 packages/coding-agent/.changes/res-1260-semantic-edges-on-event-log.md create mode 100644 packages/coding-agent/.changes/res-1267-atomic-persistence.md create mode 100644 packages/coding-agent/.changes/res-1272-coalesced-bookkeeping-appends.md create mode 100644 packages/coding-agent/.changes/res-1272-incremental-session-scans.md create mode 100644 packages/coding-agent/.changes/res-1272-memoized-passive-topology.md create mode 100644 packages/coding-agent/.changes/session-io-defects.md create mode 100644 packages/coding-agent/.changes/session-reader-safety.md create mode 100644 packages/coding-agent/.changes/webp-exif-scan-termination.md create mode 100644 packages/coding-agent/.changes/worker-snapshot-cursor.md create mode 100644 packages/coding-agent/.changes/worker-state-one-liveness-probe.md create mode 100644 packages/coding-agent/.changes/worker-state-recovery.md create mode 100644 packages/coding-agent/.changes/zai-default-model.md create mode 100644 packages/coding-agent/src/utils/atomic-file.ts create mode 100644 packages/coding-agent/src/utils/dir-lock.ts create mode 100644 packages/coding-agent/test/atomic-persistence.test.ts create mode 100644 packages/coding-agent/test/event-log-faults.test.ts create mode 100644 packages/coding-agent/test/exif-orientation.test.ts create mode 100644 packages/coding-agent/test/fixtures/zombie-process.ts create mode 100644 packages/coding-agent/test/output-accumulator.test.ts create mode 100644 packages/coding-agent/test/session-reader-persistence.test.ts create mode 100644 packages/coding-agent/test/truncate.test.ts create mode 100644 packages/tui/.changes/selection-marker-lone-surrogates.md create mode 100644 packages/tui/test/selection-metadata.test.ts diff --git a/packages/ai/.changes/service-tier-default-omitted.md b/packages/ai/.changes/service-tier-default-omitted.md new file mode 100644 index 000000000..7d2fc3b20 --- /dev/null +++ b/packages/ai/.changes/service-tier-default-omitted.md @@ -0,0 +1 @@ +- Fixed GitHub Copilot requests to omit unsupported service tiers while preserving explicit tiers for other providers, and corrected Anthropic cache-write pricing when streaming usage changes. diff --git a/packages/ai/src/providers/anthropic.ts b/packages/ai/src/providers/anthropic.ts index 4f7bcfed4..b3f58f380 100644 --- a/packages/ai/src/providers/anthropic.ts +++ b/packages/ai/src/providers/anthropic.ts @@ -6,7 +6,11 @@ import type { MessageParam, RawMessageStreamEvent, } from "@anthropic-ai/sdk/resources/messages.js"; -import { getAnthropicCacheWriteCost, hasStandardAnthropicCachePricing } from "../cache-pricing.js"; +import { + type AnthropicCacheCreationUsage, + getAnthropicCacheWriteCost, + hasStandardAnthropicCachePricing, +} from "../cache-pricing.js"; import { getEnvApiKey } from "../env-api-keys.js"; import { calculateCost, clampThinkingLevel } from "../models.js"; import type { @@ -695,6 +699,16 @@ export const streamAnthropic: StreamFunction<"anthropic-messages", AnthropicOpti if (event.usage.cache_creation_input_tokens != null) { output.usage.cacheWrite = event.usage.cache_creation_input_tokens; } + // The SDK's MessageDeltaUsage type omits cache_creation, but the wire carries it. + const deltaCacheCreation = (event.usage as { cache_creation?: AnthropicCacheCreationUsage | null }) + .cache_creation; + if (cacheControl && usesAnthropicCachePricing && deltaCacheCreation) { + cacheWriteCost = getAnthropicCacheWriteCost( + model.cost.input, + cacheControl.ttl === "1h" ? "1h" : "5m", + deltaCacheCreation, + ); + } output.usage.totalTokens = output.usage.input + output.usage.output + output.usage.cacheRead + output.usage.cacheWrite; calculateCost( diff --git a/packages/ai/src/providers/openai-responses.ts b/packages/ai/src/providers/openai-responses.ts index 0370d63fc..5094dfc95 100644 --- a/packages/ai/src/providers/openai-responses.ts +++ b/packages/ai/src/providers/openai-responses.ts @@ -237,7 +237,9 @@ function buildParams(model: Model<"openai-responses">, context: Context, options params.temperature = options?.temperature; } - if (options?.serviceTier !== undefined) { + // GitHub Copilot rejects the service_tier FIELD itself (400) for every value. + // Elsewhere it is always sent: absence means "auto" (project tier), not "default". + if (options?.serviceTier !== undefined && model.provider !== "github-copilot") { params.service_tier = options.serviceTier; } diff --git a/packages/ai/test/anthropic-sse-parsing.test.ts b/packages/ai/test/anthropic-sse-parsing.test.ts index 147eb874d..b14e3b57b 100644 --- a/packages/ai/test/anthropic-sse-parsing.test.ts +++ b/packages/ai/test/anthropic-sse-parsing.test.ts @@ -78,11 +78,13 @@ function createFakeAnthropicClient(response: Response): Anthropic { } as unknown as Anthropic; } -function createCacheUsageEvents(cacheCreation: { - ephemeral_5m_input_tokens: number; - ephemeral_1h_input_tokens: number; -}): Array<{ event: string; data: string }> { - const cacheWriteTokens = cacheCreation.ephemeral_5m_input_tokens + cacheCreation.ephemeral_1h_input_tokens; +type CacheCreation = { ephemeral_5m_input_tokens: number; ephemeral_1h_input_tokens: number }; + +function createCacheUsageEvents( + cacheCreation: CacheCreation, + deltaCacheCreation?: CacheCreation, +): Array<{ event: string; data: string }> { + const tokens = (c: CacheCreation) => c.ephemeral_5m_input_tokens + c.ephemeral_1h_input_tokens; return [ { event: "message_start", @@ -94,7 +96,7 @@ function createCacheUsageEvents(cacheCreation: { input_tokens: 12, output_tokens: 0, cache_read_input_tokens: 0, - cache_creation_input_tokens: cacheWriteTokens, + cache_creation_input_tokens: tokens(cacheCreation), cache_creation: cacheCreation, }, }, @@ -109,7 +111,8 @@ function createCacheUsageEvents(cacheCreation: { input_tokens: 12, output_tokens: 5, cache_read_input_tokens: 0, - cache_creation_input_tokens: cacheWriteTokens, + cache_creation_input_tokens: tokens(deltaCacheCreation ?? cacheCreation), + ...(deltaCacheCreation ? { cache_creation: deltaCacheCreation } : {}), }, }), }, @@ -153,6 +156,24 @@ describe("Anthropic raw SSE parsing", () => { expect(result.usage.cost.cacheWrite).toBeCloseTo(testCase.expectedCacheWriteCost); }); + it("reprices cache writes from a message_delta usage breakdown", async () => { + const model = getModel("anthropic", "claude-haiku-4-5"); + const response = createSseResponse( + createCacheUsageEvents( + { ephemeral_5m_input_tokens: 1000, ephemeral_1h_input_tokens: 0 }, + { ephemeral_5m_input_tokens: 0, ephemeral_1h_input_tokens: 2000 }, + ), + ); + const result = await streamAnthropic( + model, + { messages: [{ role: "user", content: "Say hello.", timestamp: Date.now() }] }, + { client: createFakeAnthropicClient(response), cacheRetention: "long" }, + ).result(); + + expect(result.usage.cacheWrite).toBe(2000); + // 2000 one-hour tokens at 2x input cost, not the stale 1.25x rate from message_start. + expect(result.usage.cost.cacheWrite).toBeCloseTo(0.004, 6); + }); it("preserves configured cache write pricing for non-Anthropic models", async () => { const model = getModel("minimax", "MiniMax-M2.7-highspeed"); const response = createSseResponse( diff --git a/packages/ai/test/openai-codex-stream.test.ts b/packages/ai/test/openai-codex-stream.test.ts index e05b9d983..e4091e177 100644 --- a/packages/ai/test/openai-codex-stream.test.ts +++ b/packages/ai/test/openai-codex-stream.test.ts @@ -562,6 +562,8 @@ describe("openai-codex streaming", () => { }); it.each([ + // "default" must stay on the wire: absence means "auto" (the project tier). + ["gpt-5.1-codex", "default", 1], ["gpt-5.1-codex", "flex", 0.5], ["gpt-5.1-codex", "priority", 2], ["gpt-5.4", "priority", 2], diff --git a/packages/ai/test/openai-responses-copilot-provider.test.ts b/packages/ai/test/openai-responses-copilot-provider.test.ts index c4140a5c0..b97f12dcc 100644 --- a/packages/ai/test/openai-responses-copilot-provider.test.ts +++ b/packages/ai/test/openai-responses-copilot-provider.test.ts @@ -212,6 +212,41 @@ describe("openai-responses provider defaults", () => { expect(captured).toEqual({ sessionId: null, clientRequestId: null }); }); + it.each([ + ["github-copilot" as const, "auto" as const, false], + ["github-copilot" as const, "default" as const, false], + ["openai" as const, "default" as const, true], + ])("scopes service_tier serialization to the provider (%s, %s)", async (provider, serviceTier, expected) => { + const base = getModel("openai", "gpt-5.4"); + const model = { ...base, provider }; + const sse = `data: ${JSON.stringify({ + type: "response.completed", + response: { + status: "completed", + usage: { input_tokens: 1, output_tokens: 1, total_tokens: 2, input_tokens_details: { cached_tokens: 0 } }, + }, + })}\n\n`; + let wireBody: Record | undefined; + vi.spyOn(globalThis, "fetch").mockImplementation(async (_input, init) => { + wireBody = JSON.parse(String(init?.body)) as Record; + return new Response(sse, { status: 200, headers: { "content-type": "text/event-stream" } }); + }); + + const result = await streamOpenAIResponses( + model, + { systemPrompt: "sys", messages: [{ role: "user", content: "hi", timestamp: Date.now() }] }, + { apiKey: "test-key", serviceTier }, + ).result(); + + expect(result.stopReason).toBe("stop"); + // Copilot rejects the FIELD for every value; elsewhere absence means "auto" + // (the project tier), so an explicit "default" must stay on the wire. + expect(wireBody && "service_tier" in wireBody).toBe(expected); + if (expected) { + expect((wireBody as Record).service_tier).toBe(serviceTier); + } + }); + it.each([ ["gpt-5.4", "priority", 2], ["gpt-5.5", "priority", 2.5], diff --git a/packages/coding-agent/.changes/dead-kernel-memo.md b/packages/coding-agent/.changes/dead-kernel-memo.md new file mode 100644 index 000000000..0f67b1dda --- /dev/null +++ b/packages/coding-agent/.changes/dead-kernel-memo.md @@ -0,0 +1 @@ +- A Python kernel that dies after a successful startup is restarted on the next use instead of every call being handed the dead kernel forever, and skill-MCP tools advertise their real input schemas again under mcp>=2 (the SDK renamed the field to input_schema). diff --git a/packages/coding-agent/.changes/res-1260-semantic-edges-on-event-log.md b/packages/coding-agent/.changes/res-1260-semantic-edges-on-event-log.md new file mode 100644 index 000000000..81ba62115 --- /dev/null +++ b/packages/coding-agent/.changes/res-1260-semantic-edges-on-event-log.md @@ -0,0 +1 @@ +- Moved the semantic-edge ledger's append and replay IO onto the shared event-log substrate. One behavior unified across both ledgers: an unterminated final line is an uncommitted append — skipped on read and truncated before the next append, never newline-completed. diff --git a/packages/coding-agent/.changes/res-1267-atomic-persistence.md b/packages/coding-agent/.changes/res-1267-atomic-persistence.md new file mode 100644 index 000000000..7f978f9a8 --- /dev/null +++ b/packages/coding-agent/.changes/res-1267-atomic-persistence.md @@ -0,0 +1 @@ +- Made every durable JSON/JSONL state write crash-safe through one shared atomic-write owner (temp file + rename, Windows rename retry): auth.json is no longer written in place (an interrupted write can no longer log you out everywhere), the auth migration writes its destination before destroying its sources, racing first-time settings writers no longer silently discard each other, and the kernel bootstrap lock can no longer be stolen mid-reclaim. Session files now repair crash damage (torn tails, zero-filled records) at open instead of silently losing the next message, and a session lease whose owner file is momentarily unreadable is no longer treated as stale and destroyed. diff --git a/packages/coding-agent/.changes/res-1272-coalesced-bookkeeping-appends.md b/packages/coding-agent/.changes/res-1272-coalesced-bookkeeping-appends.md new file mode 100644 index 000000000..0de98f3d5 --- /dev/null +++ b/packages/coding-agent/.changes/res-1272-coalesced-bookkeeping-appends.md @@ -0,0 +1 @@ +- Fixed unbounded session-journal growth from derived bookkeeping: child usage attribution now flushes one entry per child turn instead of one per model request, and idle status sweeps no longer persist fabricated fallback verdicts, duplicate statuses, or retry failed summary generations (including paid model calls) every 25 seconds on unchanged content. diff --git a/packages/coding-agent/.changes/res-1272-incremental-session-scans.md b/packages/coding-agent/.changes/res-1272-incremental-session-scans.md new file mode 100644 index 000000000..661976567 --- /dev/null +++ b/packages/coding-agent/.changes/res-1272-incremental-session-scans.md @@ -0,0 +1 @@ +- Fixed session-list refreshes re-reading entire session files on every change: metadata scans now resume from the last scanned byte offset, stop at the file size seen at scan start, and concurrent readers of the same session share one scan. diff --git a/packages/coding-agent/.changes/res-1272-memoized-passive-topology.md b/packages/coding-agent/.changes/res-1272-memoized-passive-topology.md new file mode 100644 index 000000000..07bab8ba1 --- /dev/null +++ b/packages/coding-agent/.changes/res-1272-memoized-passive-topology.md @@ -0,0 +1 @@ +- Fixed daemon request latency on large agent trees: the passive-subagent topology is derived once and memoized, with every consumer (session list, snapshots, cron recovery, agent messaging, passivation) reading the cached walk until the spawn ledger, residency, or a child session file changes. diff --git a/packages/coding-agent/.changes/session-io-defects.md b/packages/coding-agent/.changes/session-io-defects.md new file mode 100644 index 000000000..b9696bd4a --- /dev/null +++ b/packages/coding-agent/.changes/session-io-defects.md @@ -0,0 +1 @@ +- Seven small correctness fixes: compaction keeps only the final turn when the budget is crossed inside trailing tool results (instead of silently keeping everything); a retry whose scheduled continue cannot run ends the retry instead of leaving the session stuck retrying; saved subagent sessions with a lost parent edge still display as subagents; tail truncation rescues an oversized final line even when output ends with a newline; a failed output-spill stream degrades to the in-memory tail instead of crashing the process; piped stdin and a CLI instruction are joined with a blank line instead of glued together; and frontmatter parses behind a UTF-8 BOM. diff --git a/packages/coding-agent/.changes/session-reader-safety.md b/packages/coding-agent/.changes/session-reader-safety.md new file mode 100644 index 000000000..a2fa67ba1 --- /dev/null +++ b/packages/coding-agent/.changes/session-reader-safety.md @@ -0,0 +1,2 @@ +- Prevented session export and daemon-client startup from repairing or rewriting transcripts owned by another process. +- Enforced the retained session-scan usage cache limit for oversized transcripts. diff --git a/packages/coding-agent/.changes/webp-exif-scan-termination.md b/packages/coding-agent/.changes/webp-exif-scan-termination.md new file mode 100644 index 000000000..8d3a06821 --- /dev/null +++ b/packages/coding-agent/.changes/webp-exif-scan-termination.md @@ -0,0 +1 @@ +- The WebP EXIF chunk scan reads chunk sizes as unsigned, so a crafted or corrupt image can no longer hang the process in an infinite scan loop. diff --git a/packages/coding-agent/.changes/worker-snapshot-cursor.md b/packages/coding-agent/.changes/worker-snapshot-cursor.md new file mode 100644 index 000000000..4af271765 --- /dev/null +++ b/packages/coding-agent/.changes/worker-snapshot-cursor.md @@ -0,0 +1 @@ +- Fixed chunked session-snapshot transfers so the transfer id names the exact materialized snapshot cut, and a mismatched or restarted transfer now fails only that transfer (clients resync) instead of bouncing the whole worker channel. diff --git a/packages/coding-agent/.changes/worker-state-one-liveness-probe.md b/packages/coding-agent/.changes/worker-state-one-liveness-probe.md new file mode 100644 index 000000000..d925e4368 --- /dev/null +++ b/packages/coding-agent/.changes/worker-state-one-liveness-probe.md @@ -0,0 +1 @@ +- Fixed zombie processes being treated as live owners by the daemon supervisor ownership registry, session leases, supervisor launch locks, `daemon ps` process stops, and update-restart liveness checks; all process liveness probes now share the zombie-aware helper. diff --git a/packages/coding-agent/.changes/worker-state-recovery.md b/packages/coding-agent/.changes/worker-state-recovery.md new file mode 100644 index 000000000..4105a6fcd --- /dev/null +++ b/packages/coding-agent/.changes/worker-state-recovery.md @@ -0,0 +1 @@ +- Fixed daemon sessions bricking behind a terminal failed worker state: attach, create, and retry now re-run recovery for a failed worker whose process is verified alive, and a known-but-still-recovering session answers with a structured retryable error instead of "Unknown active session". diff --git a/packages/coding-agent/.changes/zai-default-model.md b/packages/coding-agent/.changes/zai-default-model.md new file mode 100644 index 000000000..3afdf4bb1 --- /dev/null +++ b/packages/coding-agent/.changes/zai-default-model.md @@ -0,0 +1 @@ +- The zai provider default model now points at glm-5.3; the previous default was removed from the catalog and silently fell back to a template model. diff --git a/packages/coding-agent/src/cli/daemon-ps.ts b/packages/coding-agent/src/cli/daemon-ps.ts index 42c05f9a7..ed7cdbdfc 100644 --- a/packages/coding-agent/src/cli/daemon-ps.ts +++ b/packages/coding-agent/src/cli/daemon-ps.ts @@ -19,7 +19,12 @@ import { import { defaultDaemonSocketDir, defaultDaemonSocketPath, normalizeSocketPath } from "../modes/daemon/daemon-socket.js"; import { acquireDaemonShutdownAdmission } from "../modes/daemon/daemon-supervisor-ownership.js"; import type { DaemonWorkerDescriptor } from "../modes/daemon/daemon-worker-protocol.js"; -import { signalProcessGroupOrProcess } from "../utils/child-process.js"; +import { + isProcessAlive, + processGroupHasLiveMember, + processIdExists, + signalProcessGroupIfHeld, +} from "../utils/child-process.js"; import { formatDaemonListTable } from "./daemon-ps-format.js"; import { promptYesNo } from "./daemon-stop-confirm.js"; @@ -1061,34 +1066,47 @@ async function stopTrackedProcess( expectedStartId: string | undefined, assertAdmission: () => Promise, ): Promise { - if (!isProcessAlive(pid)) { + if (trackedProcessStopped(pid)) { return true; } - if (!expectedStartId || getProcessStartId(pid) !== expectedStartId) { + if (!expectedStartId || !trackedLeaderIdentityCurrent(pid, expectedStartId)) { return false; } await assertAdmission(); - if (getProcessStartId(pid) !== expectedStartId) { + if (!trackedLeaderIdentityCurrent(pid, expectedStartId)) { return false; } - signalProcessGroupOrProcess(pid, "SIGTERM"); + signalProcessGroupIfHeld(pid, "SIGTERM"); let deadline = Date.now() + 500; - while (isProcessAlive(pid) && Date.now() < deadline) { + while (!trackedProcessStopped(pid) && Date.now() < deadline) { await delay(25); } - if (!isProcessAlive(pid)) { + if (trackedProcessStopped(pid)) { return true; } await assertAdmission(); - if (getProcessStartId(pid) !== expectedStartId) { + if (!trackedLeaderIdentityCurrent(pid, expectedStartId)) { return false; } - signalProcessGroupOrProcess(pid, "SIGKILL"); + signalProcessGroupIfHeld(pid, "SIGKILL"); deadline = Date.now() + 1000; - while (isProcessAlive(pid) && Date.now() < deadline) { + while (!trackedProcessStopped(pid) && Date.now() < deadline) { await delay(25); } - return !isProcessAlive(pid); + return trackedProcessStopped(pid); +} + +/** A GROUP stop completes when the leader is gone AND no live member remains; unreaped zombies do not block it. */ +function trackedProcessStopped(pid: number): boolean { + return !isProcessAlive(pid) && !processGroupHasLiveMember(pid); +} + +/** Identity gates guard pid reuse, so they apply only while the leader exists; a pgid cannot be reused while members hold it. */ +function trackedLeaderIdentityCurrent(pid: number, expectedStartId: string): boolean { + if (!processIdExists(pid)) { + return true; + } + return getProcessStartId(pid) === expectedStartId; } export async function runReap(json: boolean, force: boolean): Promise { @@ -1220,15 +1238,6 @@ async function forceKillDaemon(pid: number): Promise { } } -function isProcessAlive(pid: number): boolean { - try { - process.kill(pid, 0); - return true; - } catch (error) { - return (error as NodeJS.ErrnoException).code === "EPERM"; - } -} - function delay(ms: number): Promise { return new Promise((resolve) => setTimeout(resolve, ms)); } diff --git a/packages/coding-agent/src/cli/daemon-update-restart.ts b/packages/coding-agent/src/cli/daemon-update-restart.ts index f436f2f6d..73af5e37a 100644 --- a/packages/coding-agent/src/cli/daemon-update-restart.ts +++ b/packages/coding-agent/src/cli/daemon-update-restart.ts @@ -14,6 +14,7 @@ import { DAEMON_WORKER_SUPERVISOR_SOCKET_ENV, DAEMON_WORKER_TOKEN_ENV, } from "../modes/daemon/daemon-worker-protocol.js"; +import { isProcessAlive } from "../utils/child-process.js"; import { createCliSubprocessLaunchSpec } from "./subprocess-launch.js"; export const DAEMON_UPDATE_RESTART_COORDINATOR_FLAG = "--internal-update-restart-coordinator"; @@ -354,15 +355,6 @@ async function withCoordinatorRegistryGuard(registryDir: string, action: () = } } -function isProcessAlive(pid: number): boolean { - try { - process.kill(pid, 0); - } catch (error) { - return (error as NodeJS.ErrnoException).code !== "ESRCH"; - } - return true; -} - function matchesProcessStartId(identity: DaemonUpdateRestartProcessIdentity): boolean { if (!identity.processStartId) { return true; diff --git a/packages/coding-agent/src/cli/initial-message.ts b/packages/coding-agent/src/cli/initial-message.ts index a3a7f1b75..e6a54e25b 100644 --- a/packages/coding-agent/src/cli/initial-message.ts +++ b/packages/coding-agent/src/cli/initial-message.ts @@ -37,7 +37,7 @@ export function buildInitialMessage({ } return { - initialMessage: parts.length > 0 ? parts.join("") : undefined, + initialMessage: parts.length > 0 ? parts.join("\n\n") : undefined, initialImages: fileImages && fileImages.length > 0 ? fileImages : undefined, }; } diff --git a/packages/coding-agent/src/core/agent-session.ts b/packages/coding-agent/src/core/agent-session.ts index be375cc04..6b64f9659 100644 --- a/packages/coding-agent/src/core/agent-session.ts +++ b/packages/coding-agent/src/core/agent-session.ts @@ -255,7 +255,14 @@ import { transitionSessionAction, type WakePolicy, } from "./session-action-store.js"; -import type { BranchSummaryEntry, CompactionEntry, SessionContext, SessionMessageEntry } from "./session-manager.js"; +import type { + BranchSummaryEntry, + ChildUsageAttributionEntry, + CompactionEntry, + SessionContext, + SessionEntry, + SessionMessageEntry, +} from "./session-manager.js"; import { CURRENT_SESSION_VERSION, getLatestCompactionEntry, @@ -280,7 +287,14 @@ import { type BashOperations, createLocalBashOperations } from "./tools/bash.js" import { createAllToolDefinitions } from "./tools/index.js"; import { IpythonKernelProvisioner } from "./tools/ipython.js"; import { createToolDefinitionFromAgentTool } from "./tools/tool-definition-wrapper.js"; -import { addAssistantUsage, emptyUsage, type SessionUsageSummary, sessionUsageSummaryFrom } from "./usage.js"; +import { + addAssistantUsage, + cloneUsage, + emptyUsage, + type SessionUsageSummary, + sessionUsageSummaryFrom, + subtractAssistantUsage, +} from "./usage.js"; import { SERPER_CREDENTIAL_ID, SERPER_ENV_VAR, WEBSEARCH_SKILL_NAME } from "./websearch-credential.js"; export type { GoalState, GoalStatus } from "./goals.js"; @@ -1037,6 +1051,26 @@ function waitForPromiseOrAbort( }); } +// Bounds how much accumulated child usage a parent process crash can lose. +const RLM_CHILD_USAGE_FLUSH_MAX_PENDING_MS = 60_000; + +/** Label a child completion's usage by the nearest preceding prompt that triggered it. */ +function rlmChildUsageOrigin( + messages: readonly AgentMessage[], + assistant: AssistantMessage, +): ChildUsageAttributionEntry["origin"] { + for (let index = messages.lastIndexOf(assistant) - 1; index >= 0; index--) { + const message = messages[index]; + if (message.role !== "user" && message.role !== "custom") continue; + return message.role === "custom" && isAgentSessionMessage(message) + ? message.details.id.startsWith("spawn:") + ? "spawn_task" + : "agent_message" + : "direct_user"; + } + return "direct_user"; +} + function attributeChildUsage(parentUsage: Usage, childUsage: Usage): void { const parentContextTokens = parentUsage.totalTokens || @@ -1114,6 +1148,8 @@ export class AgentSession { private _retryAbortController: AbortController | undefined = undefined; private _retryAttempt = 0; + /** Bumped by every retry resolution; stale scheduled-continue callbacks check it before touching retry state. */ + private _retryGeneration = 0; private _retryPromise: Promise | undefined = undefined; private _retryResolve: (() => void) | undefined = undefined; private _retryAuthFailureSources: AuthSourceToken[] = []; @@ -1181,6 +1217,10 @@ export class AgentSession { private _repliedToParentSinceTask: boolean | undefined; private _parentReplyCount = 0; private _subagentRuntimeHost?: SubagentRuntimeHost; + // Shared by children charged to the same assistant; excludes usage not yet attributed on disk. + private _rlmDurableParentUsage = new WeakMap(); + // Child usage not yet represented by an indexed attribution, including a delayed parent entry. + private _rlmUnindexedChildUsage = new WeakMap(); private _activeRlmChildRuns = new Map(); private _unsettledRlmChildRuns = new Set(); private _abandonedRlmQuiescenceChildIds = new Set(); @@ -3760,6 +3800,7 @@ export class AgentSession { } private _resolveRetry(): void { + this._retryGeneration += 1; this._semanticEdges.clearTurnRetry(); if (this._retryResolve) { this._retryResolve(); @@ -10599,6 +10640,74 @@ export class AgentSession { if (!requestedSessionName) await this._assertRlmSubagentSessionNameAvailable(sessionName); const startedAt = Date.now(); const parentAssistantForUsage = this._findLastAssistantMessage(); + if (parentAssistantForUsage && !this._rlmDurableParentUsage.has(parentAssistantForUsage)) { + this._rlmDurableParentUsage.set(parentAssistantForUsage, cloneUsage(parentAssistantForUsage.usage)); + } + // Child completions accumulate per origin and flush one durable entry per + // settle boundary (agent_end, settlement); the staleness checkpoints and + // timer bound crash loss to one window of accumulated usage. + const pendingChildUsage = new Map(); + let pendingChildUsageSince = 0; + let pendingChildUsageTimer: ReturnType | undefined; + let parentEntryDrainScheduled = false; + const flushPendingChildUsageAttribution = (afterParentDrain = false) => { + if (pendingChildUsageTimer !== undefined) { + clearTimeout(pendingChildUsageTimer); + pendingChildUsageTimer = undefined; + } + if (pendingChildUsage.size === 0 || !parentAssistantForUsage) return; + const parentEntry = this._findAssistantEntryForMessage(parentAssistantForUsage); + if (!parentEntry) { + if (!afterParentDrain && !parentEntryDrainScheduled) { + parentEntryDrainScheduled = true; + const flushAfterParentDrain = () => { + parentEntryDrainScheduled = false; + flushPendingChildUsageAttribution(true); + }; + // A message_end extension may still be holding the parent assistant before its append. + // The parent drain owns this retry; child settlement never waits for that queue. + this._agentEventQueue = this._agentEventQueue.then(flushAfterParentDrain, flushAfterParentDrain); + this._agentEventQueue.catch(() => {}); + } + return; + } + const batches = [...pendingChildUsage.entries()]; + pendingChildUsage.clear(); + for (const [origin, childUsage] of batches) { + const aggregateUsage = cloneUsage(this._rlmDurableParentUsage.get(parentAssistantForUsage)!); + attributeChildUsage(aggregateUsage, childUsage); + const liveUsage = parentAssistantForUsage.usage; + const entryCount = this.sessionManager.getEntries().length; + try { + this.sessionManager.appendChildUsageAttribution(parentEntry.id, childUsage, aggregateUsage, origin); + this._rlmDurableParentUsage.set(parentAssistantForUsage, aggregateUsage); + } catch { + // Attribution is recoverable bookkeeping; a failed append must not break run settlement. + } finally { + // The manager updates this same message; retain siblings' still-pending live usage. + parentAssistantForUsage.usage = liveUsage; + const indexed = this.sessionManager.getEntries()[entryCount]; + const unindexedUsage = this._rlmUnindexedChildUsage.get(parentAssistantForUsage); + // _persist can throw after indexing. That row already participates in live own-usage subtraction. + if ( + indexed?.type === "child_usage_attributed" && + indexed.targetId === parentEntry.id && + unindexedUsage + ) { + subtractAssistantUsage(unindexedUsage, childUsage); + } + this._ownUsageMemo = undefined; + } + } + }; + const flushPendingChildUsageIfStale = () => { + if ( + pendingChildUsage.size > 0 && + Date.now() - pendingChildUsageSince >= RLM_CHILD_USAGE_FLUSH_MAX_PENDING_MS + ) { + flushPendingChildUsageAttribution(); + } + }; let runningToolCount = 0; let childSession: AgentSession | undefined; const run: RlmChildRun = { @@ -10712,34 +10821,35 @@ export class AgentSession { run.activity = { kind: "waiting" }; emitChildUpdate(); } else if (event.type === "agent_end") { + flushPendingChildUsageAttribution(); run.activity = undefined; emitChildUpdate(); } else if (event.type === "message_end" && event.message.role === "assistant") { const assistant = event.message as AssistantMessage; if (assistant.stopReason !== "error" && assistant.stopReason !== "aborted") { + // Flush before the fold: a persisted aggregate may only include + // completions whose childUsage is durable with or before it. + flushPendingChildUsageIfStale(); attributeChildUsage(parentAssistantForUsage?.usage ?? emptyUsage(), assistant.usage); if (parentAssistantForUsage) { - const parentEntry = this._findAssistantEntryForMessage(parentAssistantForUsage); - if (parentEntry) { - const messages = child.messages; - const assistantIndex = messages.lastIndexOf(assistant); - const precedingPrompt = messages - .slice(0, assistantIndex) - .reverse() - .find((message) => message.role === "user" || message.role === "custom"); - const origin = - precedingPrompt?.role === "custom" && isAgentSessionMessage(precedingPrompt) - ? precedingPrompt.details.id.startsWith("spawn:") - ? "spawn_task" - : "agent_message" - : "direct_user"; - this.sessionManager.appendChildUsageAttribution( - parentEntry.id, - assistant.usage, - parentAssistantForUsage.usage, - origin, + const unindexedUsage = + this._rlmUnindexedChildUsage.get(parentAssistantForUsage) ?? emptyUsage(); + addAssistantUsage(unindexedUsage, assistant.usage); + this._rlmUnindexedChildUsage.set(parentAssistantForUsage, unindexedUsage); + this._ownUsageMemo = undefined; + const origin = rlmChildUsageOrigin(child.messages, assistant); + if (pendingChildUsage.size === 0) { + pendingChildUsageSince = Date.now(); + // Wall-clock backstop for long tool runs without checkpoints. + pendingChildUsageTimer = setTimeout( + flushPendingChildUsageAttribution, + RLM_CHILD_USAGE_FLUSH_MAX_PENDING_MS, ); + pendingChildUsageTimer.unref?.(); } + const bucket = pendingChildUsage.get(origin) ?? emptyUsage(); + addAssistantUsage(bucket, assistant.usage); + pendingChildUsage.set(origin, bucket); } } const text = compactRlmText(readAssistantText(assistant)); @@ -10753,6 +10863,7 @@ export class AgentSession { emitChildUpdate(); } } else if (event.type === "tool_execution_start") { + flushPendingChildUsageIfStale(); run.toolUseCount += 1; runningToolCount += 1; run.activity = { kind: "executing", toolName: event.toolName }; @@ -10901,6 +11012,7 @@ export class AgentSession { } } } finally { + flushPendingChildUsageAttribution(); if (run.detachedDeletion) { run.deletionRunFinished = true; if (!run.settled) { @@ -11201,8 +11313,24 @@ export class AgentSession { } this._retryAbortController = undefined; + const retryGeneration = this._retryGeneration; setTimeout(() => { - this.agent.continue().catch(() => {}); + this.agent.continue().catch((error: unknown) => { + // A continue that never starts must still resolve the retry (else isRetrying + // sticks forever) — unless a newer retry owns the state by now. + if (this._retryGeneration !== retryGeneration || !this.isRetrying) return; + this._markProviderAuthStaleForRetryFailure(message, options); + const attempt = this._retryAttempt; + this._retryAttempt = 0; + this._retryAuthFailureSources = []; + this._emit({ + type: "auto_retry_end", + success: false, + attempt, + finalError: error instanceof Error ? error.message : String(error), + }); + this._resolveRetry(); + }); }, 0); return true; @@ -11944,6 +12072,14 @@ export class AgentSession { return (provider, modelId) => this._modelRegistry.find(provider, modelId)?.contextWindow; } + private _subtractUnindexedChildUsage(ownUsage: Usage, entries: SessionEntry[]): void { + for (const entry of entries) { + if (entry.type !== "message" || entry.message.role !== "assistant") continue; + const unindexedUsage = this._rlmUnindexedChildUsage.get(entry.message); + if (unindexedUsage) subtractAssistantUsage(ownUsage, unindexedUsage); + } + } + private _ownUsageMemo?: { count: number; tailId: string | undefined; usage: SessionUsageSummary | undefined }; // Whole-file own spend, identical to the catalog scan so rows never shift at passivation. @@ -11955,6 +12091,7 @@ export class AgentSession { return memo.usage; } const { ownUsage } = computeOwnAndTotalUsage(entries, entries); + this._subtractUnindexedChildUsage(ownUsage, entries); const usage = sessionUsageSummaryFrom(ownUsage); this._ownUsageMemo = { count: entries.length, tailId, usage }; return usage; @@ -11968,10 +12105,9 @@ export class AgentSession { */ getContextTree(): ContextTreeNode { const resolveContextWindow = this._contextWindowResolver(); - const { ownUsage, totalUsage } = computeOwnAndTotalUsage( - this.sessionManager.getBranch(), - this.sessionManager.getEntries(), - ); + const branch = this.sessionManager.getBranch(); + const { ownUsage, totalUsage } = computeOwnAndTotalUsage(branch, this.sessionManager.getEntries()); + this._subtractUnindexedChildUsage(ownUsage, branch); const children: ContextTreeNode[] = []; const liveIds = new Set(); diff --git a/packages/coding-agent/src/core/auth-storage.ts b/packages/coding-agent/src/core/auth-storage.ts index 389bc64f8..5c4618424 100644 --- a/packages/coding-agent/src/core/auth-storage.ts +++ b/packages/coding-agent/src/core/auth-storage.ts @@ -15,10 +15,11 @@ import { type OAuthProviderId, } from "@earendil-works/pi-ai"; import { getOAuthApiKey, getOAuthProvider, getOAuthProviders } from "@earendil-works/pi-ai/oauth"; -import { chmodSync, existsSync, mkdirSync, readFileSync, writeFileSync } from "fs"; +import { closeSync, existsSync, fchmodSync, mkdirSync, openSync, readFileSync, writeSync } from "fs"; import { dirname, join } from "path"; import lockfile from "proper-lockfile"; import { getAgentDir } from "../config.js"; +import { realpathIfPresentSync, writeFileAtomicSync } from "../utils/atomic-file.js"; import { clearPrimeCliCredentials, getPrimeCliConfigPath, @@ -116,9 +117,27 @@ export class FileAuthStorageBackend implements AuthStorageBackend { } private ensureFileExists(): void { - if (!existsSync(this.authPath)) { - writeFileSync(this.authPath, "{}", "utf-8"); - chmodSync(this.authPath, 0o600); + let descriptor: number; + try { + // Exclusive create: a racing initializer must never replace saved credentials. + descriptor = openSync(this.authPath, "wx", 0o600); + } catch (error) { + if ((error as NodeJS.ErrnoException).code !== "EEXIST") { + throw error; + } + return; + } + try { + const bytes = Buffer.from("{}"); + let offset = 0; + while (offset < bytes.length) { + const written = writeSync(descriptor, bytes, offset, bytes.length - offset); + if (written <= 0) throw new Error(`Short write initializing ${this.authPath}`); + offset += written; + } + fchmodSync(descriptor, 0o600); // Exact bits despite the umask. + } finally { + closeSync(descriptor); } } @@ -171,8 +190,7 @@ export class FileAuthStorageBackend implements AuthStorageBackend { const current = existsSync(this.authPath) ? readFileSync(this.authPath, "utf-8") : undefined; const { result, next } = fn(current); if (next !== undefined) { - writeFileSync(this.authPath, next, "utf-8"); - chmodSync(this.authPath, 0o600); + writeFileAtomicSync(realpathIfPresentSync(this.authPath), next, { mode: 0o600 }); } return result; } finally { @@ -216,8 +234,7 @@ export class FileAuthStorageBackend implements AuthStorageBackend { const { result, next } = await fn(current); throwIfCompromised(); if (next !== undefined) { - writeFileSync(this.authPath, next, "utf-8"); - chmodSync(this.authPath, 0o600); + writeFileAtomicSync(realpathIfPresentSync(this.authPath), next, { mode: 0o600 }); } throwIfCompromised(); return result; diff --git a/packages/coding-agent/src/core/bash-executor.ts b/packages/coding-agent/src/core/bash-executor.ts index 410362949..f3b119119 100644 --- a/packages/coding-agent/src/core/bash-executor.ts +++ b/packages/coding-agent/src/core/bash-executor.ts @@ -6,13 +6,10 @@ * - Direct calls from modes that need bash execution */ -import { randomBytes } from "node:crypto"; -import { createWriteStream, type WriteStream } from "node:fs"; -import { tmpdir } from "node:os"; -import { join } from "node:path"; import stripAnsi from "strip-ansi"; import { sanitizeBinaryOutput } from "../utils/shell.js"; import type { BashOperations } from "./tools/bash.js"; +import { OutputSpill } from "./tools/output-accumulator.js"; import { DEFAULT_MAX_BYTES, truncateTail } from "./tools/truncate.js"; export interface BashExecutorOptions { /** Callback for streaming output chunks (already sanitized) */ @@ -47,34 +44,19 @@ export async function executeBashWithOperations( let outputBytes = 0; const maxOutputBytes = DEFAULT_MAX_BYTES * 2; - let tempFilePath: string | undefined; - let tempFileStream: WriteStream | undefined; + const spill = new OutputSpill("pi-bash"); let totalBytes = 0; - const ensureTempFile = () => { - if (tempFilePath) { - return; - } - const id = randomBytes(8).toString("hex"); - tempFilePath = join(tmpdir(), `pi-bash-${id}.log`); - tempFileStream = createWriteStream(tempFilePath); - for (const chunk of outputChunks) { - tempFileStream.write(chunk); - } - }; - const decoder = new TextDecoder(); const onData = (data: Buffer) => { totalBytes += data.length; const text = sanitizeBinaryOutput(stripAnsi(decoder.decode(data, { stream: true }))).replace(/\r/g, ""); if (totalBytes > DEFAULT_MAX_BYTES) { - ensureTempFile(); + spill.open(outputChunks); } - if (tempFileStream) { - tempFileStream.write(text); - } + spill.write(text); outputChunks.push(text); outputBytes += text.length; while (outputBytes > maxOutputBytes && outputChunks.length > 1) { @@ -95,11 +77,10 @@ export async function executeBashWithOperations( const fullOutput = outputChunks.join(""); const truncationResult = truncateTail(fullOutput); if (truncationResult.truncated) { - ensureTempFile(); - } - if (tempFileStream) { - tempFileStream.end(); + spill.open(outputChunks); } + // Settled before advertising: the path refers to the COMPLETE file or is undefined. + const fullOutputPath = await spill.finalize(); const cancelled = options?.signal?.aborted ?? false; return { @@ -107,30 +88,26 @@ export async function executeBashWithOperations( exitCode: cancelled ? undefined : (result.exitCode ?? undefined), cancelled, truncated: truncationResult.truncated, - fullOutputPath: tempFilePath, + fullOutputPath, }; } catch (err) { if (options?.signal?.aborted) { const fullOutput = outputChunks.join(""); const truncationResult = truncateTail(fullOutput); if (truncationResult.truncated) { - ensureTempFile(); - } - if (tempFileStream) { - tempFileStream.end(); + spill.open(outputChunks); } + const fullOutputPath = await spill.finalize(); return { output: truncationResult.truncated ? truncationResult.content : fullOutput, exitCode: undefined, cancelled: true, truncated: truncationResult.truncated, - fullOutputPath: tempFilePath, + fullOutputPath, }; } - if (tempFileStream) { - tempFileStream.end(); - } + void spill.finalize(); throw err; } diff --git a/packages/coding-agent/src/core/compaction/compaction.ts b/packages/coding-agent/src/core/compaction/compaction.ts index a47152b35..c620ec4b3 100644 --- a/packages/coding-agent/src/core/compaction/compaction.ts +++ b/packages/coding-agent/src/core/compaction/compaction.ts @@ -392,6 +392,8 @@ export function findCutPoint( const messageTokens = estimateTokens(entry.message); accumulatedTokens += messageTokens; if (accumulatedTokens >= keepRecentTokens) { + // No cut point at/after i (trailing tool results): keep only the final turn, not everything. + cutIndex = cutPoints[cutPoints.length - 1]; for (let c = 0; c < cutPoints.length; c++) { if (cutPoints[c] >= i) { cutIndex = cutPoints[c]; diff --git a/packages/coding-agent/src/core/cron-jobs.ts b/packages/coding-agent/src/core/cron-jobs.ts index c9dede047..28fa49b13 100644 --- a/packages/coding-agent/src/core/cron-jobs.ts +++ b/packages/coding-agent/src/core/cron-jobs.ts @@ -1,16 +1,8 @@ import { randomUUID } from "node:crypto"; -import { - closeSync, - existsSync, - fsyncSync, - mkdirSync, - openSync, - readFileSync, - renameSync, - writeFileSync, -} from "node:fs"; +import { existsSync, mkdirSync, readFileSync, renameSync } from "node:fs"; import { dirname, join, resolve } from "node:path"; import { lockSync } from "proper-lockfile"; +import { writeFileAtomicSync } from "../utils/atomic-file.js"; import { getSessionArtifactPathForFile } from "./session-manager.js"; export type AgentCronJobStatus = "active" | "paused" | "completed" | "cancelled"; @@ -1558,27 +1550,8 @@ function writeJobsFile(path: string, jobs: readonly AgentCronJob[], mergeCurrent } function writeJobsState(path: string, state: CronJobsState): void { - const directory = dirname(path); - mkdirSync(directory, { recursive: true, mode: 0o700 }); - const tempPath = `${path}.${process.pid}.${randomUUID()}.tmp`; - const descriptor = openSync(tempPath, "w", 0o600); - try { - writeFileSync(descriptor, `${JSON.stringify(state, null, 2)}\n`, "utf-8"); - fsyncSync(descriptor); - } finally { - closeSync(descriptor); - } - renameSync(tempPath, path); - try { - const directoryDescriptor = openSync(directory, "r"); - try { - fsyncSync(directoryDescriptor); - } finally { - closeSync(directoryDescriptor); - } - } catch { - // Directory fsync is unavailable on some platforms; the atomic rename still protects readers. - } + mkdirSync(dirname(path), { recursive: true, mode: 0o700 }); + writeFileAtomicSync(path, `${JSON.stringify(state, null, 2)}\n`, { mode: 0o600, fsync: true, fsyncDir: true }); } function claimDueInState(state: CronJobsState, dueAt: Date, claimedAt: Date): AgentCronDispatch[] { diff --git a/packages/coding-agent/src/core/event-log.ts b/packages/coding-agent/src/core/event-log.ts index 1c856c402..2180577da 100644 --- a/packages/coding-agent/src/core/event-log.ts +++ b/packages/coding-agent/src/core/event-log.ts @@ -16,16 +16,14 @@ import { dirname } from "node:path"; * Append-only JSONL event log: the shared crash-safety substrate under the * RLM spawn ledger and the ACP semantic-edge ledger. * - * Appends are single O_APPEND writes (PIPE_BUF-scale sizes, whose atomicity - * multi-writer consumers rely on for interleaving), fsynced only when the - * caller needs durability. Replay tolerates exactly one torn FINAL line - * (rejected by the consumer's parser AND unterminated: a crashed writer's - * in-progress append) and fails closed on any malformed interior line. - * Repair happens only on append, never on read — a viewer may replay a live - * writer's log. EVERY unterminated tail is truncated at its byte offset, - * even one that parses as JSON: completing it with a newline would turn a - * line a strict consumer parser rejects into permanent fail-closed interior - * poison. Unifying consumers keeps the union of their safety behaviors. + * Appends are single O_APPEND writes (PIPE_BUF-scale atomicity), fsynced only + * when the caller needs durability. Tail rule (union of every consumer's + * safety): an unterminated final line is an uncommitted append — skipped on + * read even when it parses, truncated at its byte offset on the next append, + * never newline-completed (completion turns a line a strict parser rejects + * into permanent fail-closed interior poison). Interior malformed lines fail + * closed. Repair runs only on append, never on read: a viewer may replay a + * live writer's log. */ export interface EventLogOptions { @@ -66,17 +64,20 @@ export class EventLog { ) {} /** - * Replay every line through `parse`. `parse` throws for a line it rejects - * (fail-closed for interior lines, tolerated for a torn final line) and - * returns undefined for a line it deliberately skips. + * Replay every terminated line through `parse`: throw to reject a line, + * return undefined to skip one. The missing-file decision is made at the + * open, so no check-then-read window exists. */ - replaySync(parse: (line: string, index: number) => T | undefined): T[] { + replaySync( + parse: (line: string, index: number) => T | undefined, + options?: { missingFileThrows?: boolean }, + ): T[] { const { maxBytes, maxRecords } = this.options; let fd: number; try { fd = openSync(this.path, "r"); } catch (error) { - if ((error as NodeJS.ErrnoException).code === "ENOENT") return []; + if (!options?.missingFileThrows && (error as NodeJS.ErrnoException).code === "ENOENT") return []; throw error; } let contents: string; @@ -92,19 +93,14 @@ export class EventLog { for (let index = 0; index < rawLines.length; index++) { const line = rawLines[index].trim(); if (!line) continue; + if (index === rawLines.length - 1 && !endsWithNewline) { + this.options.log?.("ignored torn final line"); + continue; + } if (maxRecords !== undefined && ++recordCount > maxRecords) { throw new Error(`event log ${this.path} exceeds ${maxRecords} records; refusing to read`); } - let event: T | undefined; - try { - event = parse(line, index); - } catch (error) { - if (index === rawLines.length - 1 && !endsWithNewline) { - this.options.log?.(`ignored torn final line: ${error instanceof Error ? error.message : String(error)}`); - continue; - } - throw error; - } + const event = parse(line, index); if (event !== undefined) events.push(event); } return events; @@ -128,57 +124,50 @@ export class EventLog { const payload = [...leadLines, ...lines].join(""); const handle = openSync(this.path, "a", 0o600); try { - writeSync(handle, payload); + const buffer = Buffer.from(payload, "utf8"); + const written = writeSync(handle, buffer); + if (written < buffer.length) { + // A short write must fail, not complete or reclaim: a second write could weld + // into a rival's append, and reclaiming could destroy a rival's committed record. + throw new Error(`event log ${this.path}: short write (${written} of ${buffer.length} bytes)`); + } if (options?.durable) fsyncSync(handle); } finally { closeSync(handle); } } - /** - * Truncate a torn final line from a crashed writer before appending: - * otherwise the append would turn a tolerable torn tail into a fail-closed - * interior line. The torn bytes were never readable data. - */ + /** Truncate an unterminated tail before appending (module-doc tail rule); a failure gates the append. */ private repairTailSync(): void { const { maxBytes } = this.options; let size: number; try { size = statSync(this.path).size; - } catch { - return; + } catch (error) { + if ((error as NodeJS.ErrnoException).code === "ENOENT") return; + throw error; } if (size === 0) return; - // Fail closed loudly at the read bound BEFORE the swallowing repair - // try-block: an oversized log must never trigger a file-sized - // allocation, and the error must not be silenced as a repair failure. if (maxBytes !== undefined && size > maxBytes) { throw new Error(`event log ${this.path} exceeds ${maxBytes} bytes (${size}); refusing to read`); } // All offsets are BYTE offsets on raw buffers: string indices diverge // from byte offsets as soon as any record carries multi-byte UTF-8, // and ftruncate takes bytes. + const fd = openSync(this.path, "r+"); try { - const fd = openSync(this.path, "r+"); - try { - const lastByte = Buffer.alloc(1); - if (readSync(fd, lastByte, 0, 1, size - 1) !== 1 || lastByte[0] === 0x0a) return; - // Truncate guarded by a double-read stability check (cheap - // cross-process hardening; a racing append between the check and - // the ftruncate stays in the same trust bucket as the documented - // O_APPEND small-write atomicity assumption). - const first = readAllSync(fd, maxBytes, this.path); - const second = readAllSync(fd, maxBytes, this.path); - if (second.length !== first.length || !second.equals(first)) return; - if (fstatSync(fd).size !== first.length) return; - const keep = first.lastIndexOf(0x0a) + 1; - ftruncateSync(fd, keep); - this.options.log?.(`truncated torn final line (${first.length - keep} bytes)`); - } finally { - closeSync(fd); - } - } catch { - // Leave the tail for the reader's torn-line tolerance. + const lastByte = Buffer.alloc(1); + if (readSync(fd, lastByte, 0, 1, size - 1) !== 1 || lastByte[0] === 0x0a) return; + // Double-read stability: unstable bytes mean a live rival writer whose own append terminates the tail. + const first = readAllSync(fd, maxBytes, this.path); + const second = readAllSync(fd, maxBytes, this.path); + if (second.length !== first.length || !second.equals(first)) return; + if (fstatSync(fd).size !== first.length) return; + const keep = first.lastIndexOf(0x0a) + 1; + ftruncateSync(fd, keep); + this.options.log?.(`truncated torn final line (${first.length - keep} bytes)`); + } finally { + closeSync(fd); } } } diff --git a/packages/coding-agent/src/core/export-html/index.ts b/packages/coding-agent/src/core/export-html/index.ts index a6733fe9a..562a7b6a4 100644 --- a/packages/coding-agent/src/core/export-html/index.ts +++ b/packages/coding-agent/src/core/export-html/index.ts @@ -277,7 +277,8 @@ export async function exportFromFile(inputPath: string, options?: ExportOptions throw new Error(`File not found: ${inputPath}`); } - const sm = SessionManager.open(inputPath); + const sm = SessionManager.inMemory(); + sm.setSessionFile(inputPath); const sessionData: SessionData = { header: sm.getHeader(), diff --git a/packages/coding-agent/src/core/kernel/bootstrap.ts b/packages/coding-agent/src/core/kernel/bootstrap.ts index 239074930..7ad13bc15 100644 --- a/packages/coding-agent/src/core/kernel/bootstrap.ts +++ b/packages/coding-agent/src/core/kernel/bootstrap.ts @@ -9,6 +9,8 @@ import { createInterface } from "node:readline/promises"; import { setTimeout as sleep } from "node:timers/promises"; import { fileURLToPath } from "node:url"; import { getPackageDir } from "../../config.js"; +import { isProcessAlive } from "../../utils/child-process.js"; +import { tryAcquireDirLock } from "../../utils/dir-lock.js"; import type { PythonSkillRuntimeInfo } from "../skills.js"; const BOOTSTRAP_SCHEMA = 9; @@ -85,10 +87,6 @@ function errorMessage(error: unknown): string { return error instanceof Error ? error.message : String(error); } -function isNodeError(error: unknown, code: string): boolean { - return error instanceof Error && "code" in error && error.code === code; -} - function isRecord(value: unknown): value is Record { return typeof value === "object" && value !== null && !Array.isArray(value); } @@ -440,25 +438,6 @@ function bootstrapLockDir(venv: string): string { return path.join(path.dirname(venv), `${path.basename(venv)}${BOOTSTRAP_LOCK_NAME}`); } -function processIsRunning(pid: number): boolean { - try { - process.kill(pid, 0); - return true; - } catch (error) { - return isNodeError(error, "EPERM"); - } -} - -async function readLockPid(lockDir: string): Promise { - try { - const raw = await readFile(path.join(lockDir, "pid"), "utf8"); - const pid = Number.parseInt(raw.trim(), 10); - return Number.isInteger(pid) && pid > 0 ? pid : null; - } catch { - return null; - } -} - async function lockMissingPidIsStale(lockDir: string): Promise { try { const lockStat = await stat(lockDir); @@ -473,19 +452,13 @@ async function acquireBootstrapLock(venv: string): Promise<() => Promise> await mkdir(path.dirname(lockDir), { recursive: true }); for (;;) { - try { - await mkdir(lockDir); - await writeFile(path.join(lockDir, "pid"), `${process.pid}\n`, "utf8"); + const attempt = await tryAcquireDirLock(lockDir, async (ownerPid) => + ownerPid === undefined ? !(await lockMissingPidIsStale(lockDir)) : isProcessAlive(ownerPid), + ); + if (attempt === "acquired") { return () => rm(lockDir, { recursive: true, force: true }); - } catch (error) { - if (!isNodeError(error, "EEXIST")) throw error; - - const pid = await readLockPid(lockDir); - if (pid === null ? await lockMissingPidIsStale(lockDir) : !processIsRunning(pid)) { - await rm(lockDir, { recursive: true, force: true }); - continue; - } - + } + if (attempt === "held") { await sleep(BOOTSTRAP_LOCK_RETRY_MS); } } diff --git a/packages/coding-agent/src/core/kernel/repl-manager.ts b/packages/coding-agent/src/core/kernel/repl-manager.ts index 8500c392a..537123943 100644 --- a/packages/coding-agent/src/core/kernel/repl-manager.ts +++ b/packages/coding-agent/src/core/kernel/repl-manager.ts @@ -1612,4 +1612,8 @@ export class ReplKernelManager { get isRunning(): boolean { return this.state === "running"; } + + get isDefunct(): boolean { + return this.state === "shutdown"; + } } diff --git a/packages/coding-agent/src/core/kernel/shared.ts b/packages/coding-agent/src/core/kernel/shared.ts index 6f5e4c322..e6a6c6376 100644 --- a/packages/coding-agent/src/core/kernel/shared.ts +++ b/packages/coding-agent/src/core/kernel/shared.ts @@ -280,6 +280,8 @@ export interface KernelShutdownOptions { export interface KernelClient { readonly ownerSessionId: string | undefined; readonly isRunning: boolean; + /** Terminal: the kernel died or was torn down; only a fresh manager can serve again. */ + readonly isDefunct: boolean; start(options?: KernelStartOptions): Promise; execute(code: string, opts?: ExecuteOptions): Promise; shutdown(opts?: KernelShutdownOptions): Promise; diff --git a/packages/coding-agent/src/core/model-registry.ts b/packages/coding-agent/src/core/model-registry.ts index 787fce938..92142f821 100644 --- a/packages/coding-agent/src/core/model-registry.ts +++ b/packages/coding-agent/src/core/model-registry.ts @@ -22,12 +22,13 @@ import { } from "@earendil-works/pi-ai"; import { registerBuiltinMcpOAuthProviders } from "@earendil-works/pi-ai/mcp"; import { registerOAuthProvider, resetOAuthProviders } from "@earendil-works/pi-ai/oauth"; -import { existsSync, readFileSync, renameSync, writeFileSync } from "fs"; +import { existsSync, readFileSync } from "fs"; import { dirname, join } from "path"; import { type Static, type TProperties, Type } from "typebox"; import type { Validator } from "typebox/compile"; import type { TLocalizedValidationError } from "typebox/error"; import { getAgentDir } from "../config.js"; +import { writeFileAtomicSync } from "../utils/atomic-file.js"; import type { AuthSourceToken, AuthStatus, AuthStorage } from "./auth-storage.js"; import { PRIME_INFERENCE_PROVIDER_ID } from "./prime-inference-auth.js"; import { @@ -926,9 +927,7 @@ export class ModelRegistry { return; } try { - const tmpPath = `${cachePath}.${process.pid}.tmp`; - writeFileSync(tmpPath, JSON.stringify({ ...cache, modelIds: [...cache.modelIds] }), { mode: 0o600 }); - renameSync(tmpPath, cachePath); + writeFileAtomicSync(cachePath, JSON.stringify({ ...cache, modelIds: [...cache.modelIds] }), { mode: 0o600 }); } catch { // A failed cache write only requires a later refetch. } diff --git a/packages/coding-agent/src/core/model-resolver.ts b/packages/coding-agent/src/core/model-resolver.ts index 950020e70..6e8404276 100644 --- a/packages/coding-agent/src/core/model-resolver.ts +++ b/packages/coding-agent/src/core/model-resolver.ts @@ -33,7 +33,7 @@ export const defaultModelPerProvider: Record = { xai: "grok-4.20-0309-reasoning", groq: "openai/gpt-oss-120b", cerebras: "gpt-oss-120b", - zai: "glm-5.1", + zai: "glm-5.3", mistral: "devstral-medium-latest", minimax: "MiniMax-M2.7", "minimax-cn": "MiniMax-M2.7", diff --git a/packages/coding-agent/src/core/refinement/refinement.ts b/packages/coding-agent/src/core/refinement/refinement.ts index 76cc70155..a8a6b1804 100644 --- a/packages/coding-agent/src/core/refinement/refinement.ts +++ b/packages/coding-agent/src/core/refinement/refinement.ts @@ -1,19 +1,10 @@ -import { randomUUID } from "node:crypto"; -import { - appendFileSync, - existsSync, - mkdirSync, - readFileSync, - renameSync, - statSync, - unlinkSync, - writeFileSync, -} from "node:fs"; +import { appendFileSync, existsSync, mkdirSync, readFileSync, statSync } from "node:fs"; import { join } from "node:path"; import type { AgentMessage, ThinkingLevel } from "@earendil-works/pi-agent-core"; import type { Model } from "@earendil-works/pi-ai"; import { completeSimple } from "@earendil-works/pi-ai"; import { getAgentDir } from "../../config.js"; +import { realpathIfPresentSync, writeFileAtomicSync } from "../../utils/atomic-file.js"; import { serializeConversation } from "../compaction/utils.js"; import { convertToLlm } from "../messages.js"; import type { CustomEntry } from "../session-manager.js"; @@ -344,17 +335,10 @@ export function mergeHarnessStates(globalState: HarnessState, localState?: Harne export function saveHarnessState(harnessStateDir: string, state: HarnessState): string { const statePath = getHarnessStatePath(harnessStateDir); - const tempPath = `${statePath}.${process.pid}.${randomUUID()}.tmp`; mkdirSync(harnessStateDir, { recursive: true }); - try { - const mode = existsSync(statePath) ? statSync(statePath).mode & 0o777 : 0o600; - writeFileSync(tempPath, `${JSON.stringify(state, null, 2)}\n`, { encoding: "utf8", mode }); - renameSync(tempPath, statePath); - } finally { - if (existsSync(tempPath)) { - unlinkSync(tempPath); - } - } + const targetPath = realpathIfPresentSync(statePath); + const mode = existsSync(targetPath) ? statSync(targetPath).mode & 0o777 : 0o600; + writeFileAtomicSync(targetPath, `${JSON.stringify(state, null, 2)}\n`, { mode }); return statePath; } diff --git a/packages/coding-agent/src/core/semantic-edges.ts b/packages/coding-agent/src/core/semantic-edges.ts index be2dc567d..198ea4072 100644 --- a/packages/coding-agent/src/core/semantic-edges.ts +++ b/packages/coding-agent/src/core/semantic-edges.ts @@ -1,7 +1,7 @@ import { createHash, randomUUID } from "node:crypto"; -import { appendFileSync, existsSync, mkdirSync, readFileSync, truncateSync } from "node:fs"; -import { dirname, join } from "node:path"; +import { join } from "node:path"; import type { StreamFn } from "@earendil-works/pi-agent-core"; +import { EventLog } from "./event-log.js"; /** * ACP semantic-edges-v1 producer: a durable per-agent ledger of model-request @@ -158,7 +158,7 @@ export function hashTurnBody( export class SemanticEdgeRecorder { readonly sessionId: string; private readonly _ledgerPath?: string; - private _pendingRepair?: { truncateToBytes: number } | { terminateLine: true }; + private readonly _eventLog?: EventLog; private _disabled = false; private _epoch = 0; private _lastTurn?: { requestId: string; epoch: number; bodyHash?: string }; @@ -174,10 +174,11 @@ export class SemanticEdgeRecorder { }) { this.sessionId = options.sessionId; this._ledgerPath = options.ledgerPath; + this._eventLog = options.ledgerPath ? new EventLog(options.ledgerPath) : undefined; let existing: SemanticEdgeLedgerEvent[] = []; try { - existing = this._loadExisting(); + existing = this._eventLog?.replaySync(parseSemanticEdgeLine) ?? []; } catch (error) { this._disable(error); return; @@ -330,41 +331,15 @@ export class SemanticEdgeRecorder { } } - // Construction never mutates the file: a viewer may be reading a live - // writer's ledger. Torn-tail repair is deferred to this recorder's first append. - private _loadExisting(): SemanticEdgeLedgerEvent[] { - if (!this._ledgerPath || !existsSync(this._ledgerPath)) { - return []; - } - const raw = readFileSync(this._ledgerPath, "utf8"); - const parsed = parseLedgerContent(raw); - if (parsed.validLength < raw.length) { - this._pendingRepair = { truncateToBytes: Buffer.byteLength(raw.slice(0, parsed.validLength)) }; - } else if (raw.length > 0 && !raw.endsWith("\n")) { - this._pendingRepair = { terminateLine: true }; - } - return parsed.events; - } - // Durable append first, in-memory state second: a failed write must not leave // commit state pointing at events that never reached the ledger. private _append(event: SemanticEdgeLedgerEvent): boolean { if (this._disabled) { return false; } - if (this._ledgerPath) { + if (this._eventLog) { try { - mkdirSync(dirname(this._ledgerPath), { recursive: true }); - if (this._pendingRepair) { - if ("truncateToBytes" in this._pendingRepair) { - // Discard the torn tail line so it never becomes mid-file corruption. - truncateSync(this._ledgerPath, this._pendingRepair.truncateToBytes); - } else { - appendFileSync(this._ledgerPath, "\n"); - } - this._pendingRepair = undefined; - } - appendFileSync(this._ledgerPath, `${JSON.stringify(event)}\n`); + this._eventLog.appendSync([event]); } catch (error) { this._disable(error); return false; @@ -375,39 +350,17 @@ export class SemanticEdgeRecorder { } } -/** - * Parse a ledger, tolerating only a torn final line: malformed AND - * unterminated (a killed mid-append). A newline-terminated malformed line is - * real corruption anywhere in the file and throws. - */ -function parseLedgerContent(raw: string): { events: SemanticEdgeLedgerEvent[]; validLength: number } { - const events: SemanticEdgeLedgerEvent[] = []; - let offset = 0; - let validLength = 0; - let lineNumber = 0; - while (offset < raw.length) { - const newlineIndex = raw.indexOf("\n", offset); - const end = newlineIndex === -1 ? raw.length : newlineIndex + 1; - const line = raw.slice(offset, end); - lineNumber += 1; - if (line.trim().length > 0) { - try { - events.push(JSON.parse(line) as SemanticEdgeLedgerEvent); - } catch (error) { - if (newlineIndex === -1) { - return { events, validLength }; - } - throw new Error(`corrupt semantic-edge ledger line ${lineNumber}: ${String(error)}`); - } - } - offset = end; - validLength = end; +function parseSemanticEdgeLine(line: string, index: number): SemanticEdgeLedgerEvent { + try { + return JSON.parse(line) as SemanticEdgeLedgerEvent; + } catch (error) { + throw new Error(`corrupt semantic-edge ledger line ${index + 1}: ${String(error)}`); } - return { events, validLength }; } export function readSemanticEdgeLedger(path: string): SemanticEdgeLedgerEvent[] { - return parseLedgerContent(readFileSync(path, "utf8")).events; + // A missing ledger stays loud for explicit readers; the recorder treats absence as empty. + return new EventLog(path).replaySync(parseSemanticEdgeLine, { missingFileThrows: true }); } interface FoldSession { diff --git a/packages/coding-agent/src/core/session-lease.ts b/packages/coding-agent/src/core/session-lease.ts index af0f205db..feb5f79b7 100644 --- a/packages/coding-agent/src/core/session-lease.ts +++ b/packages/coding-agent/src/core/session-lease.ts @@ -3,6 +3,7 @@ import { createHash, randomUUID } from "node:crypto"; import { existsSync, mkdirSync, readFileSync, realpathSync, renameSync, rmSync, writeFileSync } from "node:fs"; import { basename, dirname, join, resolve } from "node:path"; import { lockSync } from "proper-lockfile"; +import { isProcessAlive } from "../utils/child-process.js"; export const SESSION_LEASES_ENABLED_ENV = "PRIME_AGENT_INTERNAL_SESSION_LEASES"; export const SESSION_LEASE_OWNER_ID_ENV = "PRIME_AGENT_INTERNAL_SESSION_LEASE_OWNER_ID"; @@ -50,7 +51,7 @@ export class SessionLease { try { withLeaseGuard(this.directory, () => { const owner = readLeaseOwner(this.directory); - if (owner?.token === this.token) { + if (typeof owner === "object" && owner.token === this.token) { rmSync(this.directory, { recursive: true, force: true }); } }); @@ -83,9 +84,16 @@ export function canonicalSessionPath(sessionPath: string): string { } } -function readLeaseOwner(directory: string): SessionLeaseOwner | undefined { +// "absent" (missing/garbage) is safely stale; "unreadable" may be a LIVE lease and must never be reclaimed. +function readLeaseOwner(directory: string): SessionLeaseOwner | "absent" | "unreadable" { + let raw: string; try { - const parsed = JSON.parse(readFileSync(join(directory, "owner.json"), "utf8")) as Partial; + raw = readFileSync(join(directory, "owner.json"), "utf8"); + } catch (error) { + return (error as NodeJS.ErrnoException).code === "ENOENT" ? "absent" : "unreadable"; + } + try { + const parsed = JSON.parse(raw) as Partial; if ( parsed.version !== 1 || typeof parsed.token !== "string" || @@ -93,20 +101,11 @@ function readLeaseOwner(directory: string): SessionLeaseOwner | undefined { typeof parsed.sessionPath !== "string" || typeof parsed.createdAt !== "string" ) { - return undefined; + return "absent"; } return parsed as SessionLeaseOwner; } catch { - return undefined; - } -} - -function isProcessAlive(pid: number): boolean { - try { - process.kill(pid, 0); - return true; - } catch (error) { - return (error as NodeJS.ErrnoException).code === "EPERM"; + return "absent"; } } @@ -298,11 +297,15 @@ export function acquireSessionLease( } catch (error) { rmSync(candidateDirectory, { recursive: true, force: true }); const code = (error as NodeJS.ErrnoException).code; - if (code !== "EEXIST" && code !== "ENOTEMPTY") { + // win32 reports rename-onto-existing-directory as EPERM/EACCES, not EEXIST. + if (code !== "EEXIST" && code !== "ENOTEMPTY" && code !== "EPERM" && code !== "EACCES") { throw error; } const existingOwner = readLeaseOwner(directory); - if (existingOwner && isLeaseOwnerAlive(existingOwner)) { + if (existingOwner === "unreadable") { + continue; + } + if (existingOwner !== "absent" && isLeaseOwnerAlive(existingOwner)) { throw new SessionAlreadyActiveError(canonicalPath, existingOwner.activeSessionId); } reclaimStaleLease(directory); @@ -310,7 +313,7 @@ export function acquireSessionLease( } const owner = existsSync(directory) ? readLeaseOwner(directory) : undefined; - if (owner && isLeaseOwnerAlive(owner)) { + if (typeof owner === "object" && isLeaseOwnerAlive(owner)) { throw new SessionAlreadyActiveError(canonicalPath, owner.activeSessionId); } throw new Error(`Could not acquire session lease: ${canonicalPath}`); diff --git a/packages/coding-agent/src/core/session-manager.ts b/packages/coding-agent/src/core/session-manager.ts index fc8569787..6a1099731 100644 --- a/packages/coding-agent/src/core/session-manager.ts +++ b/packages/coding-agent/src/core/session-manager.ts @@ -3,23 +3,23 @@ import type { AssistantMessage, ImageContent, Message, ServiceTier, TextContent, import { randomUUID } from "crypto"; import { appendFileSync, - chmodSync, chownSync, + closeSync, existsSync, + fstatSync, mkdirSync, + openSync, readdirSync, readFileSync, - realpathSync, - renameSync, - rmSync, + readSync, statSync, - writeFileSync, } from "fs"; import { readdir, readFile, stat } from "fs/promises"; import { basename, dirname, join, resolve } from "path"; import { v7 as uuidv7 } from "uuid"; import { getAgentDir as getDefaultAgentDir, getSessionsDir } from "../config.js"; -import { readFirstLineSync, readLinesAsBuffers } from "../utils/file-lines.js"; +import { realpathIfPresentSync, writeFileAtomicSync } from "../utils/atomic-file.js"; +import { readBytesSync, readFirstLineSync, readLinesAsBuffers } from "../utils/file-lines.js"; import { captureGitContext, type GitContext, gitContextsEqual } from "../utils/git.js"; import { type BashExecutionMessage, @@ -60,15 +60,6 @@ const CONTENT_ENTRY_TYPES = new Set([ "branch_summary", ]); -function realpathIfPresent(path: string): string { - try { - return realpathSync(path); - } catch (error) { - if ((error as NodeJS.ErrnoException).code === "ENOENT") return path; - throw error; - } -} - function statMetadataIfPresent(path: string): { mode: number; uid: number; gid: number } | undefined { try { const { mode, uid, gid } = statSync(path); @@ -588,6 +579,125 @@ async function parseEntriesFromBufferAsync(buffer: Buffer): Promise return entries; } +// Crash damage (torn tail, zero-filled append) poisons the NEXT append into the +// same physical line, so the file is repaired once at open, not tolerated in memory. +const REPAIR_SUSPICION_WINDOW_BYTES = 1024 * 1024; + +// A bounded tail read gates the full repair scan: clean opens stay O(window). +function tailLooksDamaged(targetPath: string): boolean { + let descriptor: number; + try { + descriptor = openSync(targetPath, "r"); + } catch { + return false; + } + try { + const size = fstatSync(descriptor).size; + if (size === 0) return false; + const windowBytes = Math.min(size, REPAIR_SUSPICION_WINDOW_BYTES); + const window = Buffer.allocUnsafe(windowBytes); + readSync(descriptor, window, 0, windowBytes, size - windowBytes); + if (window.includes(0)) return true; + if (window[windowBytes - 1] !== 0x0a) return true; + const previousNewline = window.lastIndexOf(0x0a, windowBytes - 2); + // No boundary inside the window: the final line exceeds it; scan to be sure. + if (previousNewline === -1 && windowBytes < size) return true; + const lastLine = window.subarray(previousNewline + 1, windowBytes - 1); + // A blank final line is benign (the loader skips it) and appends stay safe. + return lastLine.length > 0 && !parsesAsJson(lastLine); + } catch { + return true; + } finally { + closeSync(descriptor); + } +} + +function repairJsonlDamage(filePath: string): void { + const targetPath = realpathIfPresentSync(filePath); + if (!tailLooksDamaged(targetPath)) return; + let buffer: Buffer; + let snapshot: { size: number; mtimeMs: number }; + try { + buffer = readFileSync(targetPath); + const measured = statSync(targetPath); + snapshot = { size: measured.size, mtimeMs: measured.mtimeMs }; + } catch { + return; + } + if (buffer.length === 0 || snapshot.size !== buffer.length) return; + const keptLines: Buffer[] = []; + let recoveredNulLines = 0; + let droppedLines = 0; + let repairedTail = false; + let dirty = false; + let start = 0; + while (start < buffer.length) { + let end = buffer.indexOf(0x0a, start); + const terminated = end !== -1; + if (!terminated) end = buffer.length; + let lineStart = start; + while (lineStart < end && buffer[lineStart] === 0) lineStart++; + const line = buffer.subarray(lineStart, end); + if (lineStart > start) { + dirty = true; + if (line.length > 0 && parsesAsJson(line)) { + keptLines.push(line); + recoveredNulLines++; + } else { + droppedLines++; + } + } else if (!terminated) { + // An unterminated tail merges with the next append: re-terminate or truncate. + dirty = true; + if (line.length > 0 && parsesAsJson(line)) { + keptLines.push(line); + repairedTail = true; + } else { + droppedLines++; + } + } else if (end + 1 >= buffer.length && line.length > 0 && !parsesAsJson(line)) { + dirty = true; + droppedLines++; + } else { + keptLines.push(line); + } + start = end + 1; + } + if (!dirty) return; + const metadata = statMetadataIfPresent(targetPath); + const content = keptLines.length > 0 ? `${keptLines.map((kept) => kept.toString("utf8")).join("\n")}\n` : ""; + try { + writeFileAtomicSync(targetPath, content, { + ...(metadata === undefined ? {} : { mode: metadata.mode }), + beforeRename: (tempPath) => { + if (metadata !== undefined) chownSync(tempPath, metadata.uid, metadata.gid); + // A concurrent appender wins; skipping the repair is safe (next open retries). + const current = statSync(targetPath); + if (current.size !== snapshot.size || current.mtimeMs !== snapshot.mtimeMs) { + throw new RepairSupersededError(); + } + }, + }); + } catch (error) { + if (error instanceof RepairSupersededError) return; + throw error; + } + console.error( + `Repaired crash damage in ${targetPath}: recovered ${recoveredNulLines} zero-filled line(s), dropped ${droppedLines} unrecoverable line(s)${repairedTail ? ", restored the trailing newline" : ""}`, + ); +} + +class RepairSupersededError extends Error {} + +function parsesAsJson(line: Buffer): boolean { + try { + JSON.parse(line.toString("utf8")); + return true; + } catch { + return false; + } +} + function finalizeLoadedEntries(entries: FileEntry[]): FileEntry[] { if (entries.length === 0) return entries; const header = entries[0]; @@ -927,166 +1037,324 @@ function extractOversizedMessageSummary(line: string): { }; } -interface SessionInfoCacheEntry { - size: number; +interface SessionScanAccumulator { + header?: SessionHeader; + /** The first parsed entry was not a session header; appends cannot repair this. */ + invalid: boolean; + messageCount: number; + firstMessage: string; + allMessagesText: string; + name?: string; + state?: SessionState; + agentStatus?: AgentStatus; + lastActivityTime?: number; + // Fold attribution aggregates like the loader: either disk representation cancels to the same own spend. + assistantUsageById: Map; + attributedChildUsage: Usage; + summarizationUsage: Usage; +} + +interface SessionScanState { + fileSize: number; mtimeMs: number; + /** Identity of the scanned file: a rename rewrite replaces the inode and invalidates the resume state. */ + dev: number; + ino: number; + /** Bytes consumed as complete newline-terminated lines; the resume point for the next scan. */ + offset: number; + /** Last bytes of the consumed prefix; a resume only proceeds while the file still starts with them. */ + tail: Buffer; + acc: SessionScanAccumulator; info: SessionInfo | null; + /** Usage entries counted against the retained bound at the last store. */ + accountedUsageEntries: number; +} + +const SESSION_SCAN_RESUME_TAIL_BYTES = 16; +const NEWLINE_BUFFER = Buffer.from("\n"); +// Memory bound (~tens of MB): LRU whole-state eviction only while over it, so +// small states never thrash and an evicted file just pays one full rescan. +const SESSION_SCAN_MAX_RETAINED_USAGE_ENTRIES = 100_000; + +// Session files are append-only between whole-file rewrites, so scans resume +// from the last consumed byte offset; rewrites are detected by shrink, +// same-size mtime change, replaced inode, or a changed prefix tail. In-place +// interior edits that defeat all four are outside the writer model. +const sessionScanStates = new Map(); +// Scans of a path run one at a time; a later caller chains its own pass (its +// stat sees every prior append) instead of joining an earlier scan's result. +const sessionScanQueue = new Map>(); + +export function readSessionInfo(filePath: string): Promise { + const previous = sessionScanQueue.get(filePath); + const scan = previous + ? previous.then( + () => scanSessionInfo(filePath), + () => scanSessionInfo(filePath), + ) + : scanSessionInfo(filePath); + sessionScanQueue.set(filePath, scan); + void scan.finally(() => { + if (sessionScanQueue.get(filePath) === scan) sessionScanQueue.delete(filePath); + }); + return scan; +} + +let retainedUsageEntries = 0; + +function dropSessionScanState(filePath: string): void { + const state = sessionScanStates.get(filePath); + if (!state) return; + retainedUsageEntries -= state.accountedUsageEntries; + sessionScanStates.delete(filePath); +} + +/** Refresh LRU recency and enforce the retained-usage bound. */ +function storeSessionScanState(filePath: string, state: SessionScanState): void { + dropSessionScanState(filePath); + state.accountedUsageEntries = state.acc.assistantUsageById.size; + retainedUsageEntries += state.accountedUsageEntries; + sessionScanStates.set(filePath, state); + for (const key of sessionScanStates.keys()) { + if (retainedUsageEntries <= SESSION_SCAN_MAX_RETAINED_USAGE_ENTRIES) break; + dropSessionScanState(key); + } +} + +function createSessionScanAccumulator(): SessionScanAccumulator { + return { + invalid: false, + messageCount: 0, + firstMessage: "", + allMessagesText: "", + assistantUsageById: new Map(), + attributedChildUsage: emptyUsage(), + summarizationUsage: emptyUsage(), + }; +} + +function scannedPrefixIntact(filePath: string, state: SessionScanState): boolean { + if (state.offset === 0) return true; + try { + const start = Math.max(0, state.offset - SESSION_SCAN_RESUME_TAIL_BYTES); + return readBytesSync(filePath, start, state.offset).equals(state.tail); + } catch { + return false; + } } -// Session files are append-only, so an unchanged (size, mtimeMs) means identical -// content: cache list metadata and rescan only files that changed. -const sessionInfoCache = new Map(); +/** Last bytes of the consumed prefix after appending one line and its newline, copied out of the stream chunk. */ +function advanceScanTail(tail: Buffer, line: Buffer): Buffer { + if (line.length >= SESSION_SCAN_RESUME_TAIL_BYTES - 1) { + return Buffer.concat([line.subarray(line.length - (SESSION_SCAN_RESUME_TAIL_BYTES - 1)), NEWLINE_BUFFER]); + } + const combined = Buffer.concat([tail, line, NEWLINE_BUFFER]); + return combined.length <= SESSION_SCAN_RESUME_TAIL_BYTES + ? combined + : Buffer.from(combined.subarray(combined.length - SESSION_SCAN_RESUME_TAIL_BYTES)); +} -export async function readSessionInfo(filePath: string): Promise { +async function scanSessionInfo(filePath: string, retryOnReplacement = true): Promise { let stats: Awaited>; try { stats = await stat(filePath); } catch { + dropSessionScanState(filePath); + return null; + } + const previous = sessionScanStates.get(filePath); + const sameFile = previous !== undefined && previous.dev === stats.dev && previous.ino === stats.ino; + if (sameFile && previous.fileSize === stats.size && previous.mtimeMs === stats.mtimeMs) { + storeSessionScanState(filePath, previous); + return previous.info; + } + const resume = sameFile && stats.size > previous.fileSize && scannedPrefixIntact(filePath, previous); + const state: SessionScanState = resume + ? previous + : { + fileSize: 0, + mtimeMs: 0, + dev: stats.dev, + ino: stats.ino, + offset: 0, + tail: Buffer.alloc(0), + acc: createSessionScanAccumulator(), + info: null, + accountedUsageEntries: 0, + }; + try { + const tornTail = await scanSessionLines(filePath, state, stats.size); + state.info = snapshotSessionInfo(state.acc, tornTail, filePath, stats); + } catch { + dropSessionScanState(filePath); return null; } - const cached = sessionInfoCache.get(filePath); - if (cached && cached.size === stats.size && cached.mtimeMs === stats.mtimeMs) { - return cached.info; + // A rename rewrite racing the scan can mix two files' bytes into one + // accumulator: a changed inode afterwards discards the state and rescans. + let after: Awaited> | undefined; + try { + after = await stat(filePath); + } catch { + after = undefined; + } + if (!after || after.dev !== stats.dev || after.ino !== stats.ino) { + dropSessionScanState(filePath); + return retryOnReplacement ? scanSessionInfo(filePath, false) : null; } - const info = await scanSessionInfo(filePath, stats); - sessionInfoCache.set(filePath, { size: stats.size, mtimeMs: stats.mtimeMs, info }); - return info; + state.fileSize = stats.size; + state.mtimeMs = stats.mtimeMs; + storeSessionScanState(filePath, state); + return state.info; } -async function scanSessionInfo(filePath: string, stats: Awaited>): Promise { - try { - let header: SessionHeader | undefined; - let messageCount = 0; - let firstMessage = ""; - let allMessagesText = ""; - let name: string | undefined; - let state: SessionState | undefined; - let agentStatus: AgentStatus | undefined; - let lastActivityTime: number | undefined; - // Fold attribution aggregates like the loader: either disk representation cancels to the same own spend. - const assistantUsageById = new Map(); - const attributedChildUsages: Usage[] = []; - const summarizationUsages: Usage[] = []; - - for await (const lineBuffer of readLinesAsBuffers(filePath)) { - const line = lineBuffer.toString("utf8"); - if (!line.trim()) continue; - - // Large tool-result entries can be many MB. They do not carry the - // session-list metadata we need, and parsing them during every refresh - // can exhaust the daemon heap. - if (line.length > SESSION_LIST_PARSE_MAX_LINE_CHARS) { - if (looksLikeMessageEntry(line)) { - messageCount++; - const summary = extractOversizedMessageSummary(line); - if (typeof summary.timestamp === "number" && (summary.role === "user" || summary.role === "assistant")) { - lastActivityTime = Math.max(lastActivityTime ?? 0, summary.timestamp); - } - if (summary.role === "user" && !firstMessage) { - firstMessage = summary.textPreview || "(large message)"; - } - } - continue; - } +/** + * Fold the complete lines in [state.offset, size) into the accumulator. An + * unterminated final line may be an in-progress append: it is returned for + * snapshot-only folding, never consumed into the resumable accumulator. + */ +async function scanSessionLines(filePath: string, state: SessionScanState, size: number): Promise { + if (state.acc.invalid || state.offset >= size) return undefined; + for await (const lineBuffer of readLinesAsBuffers(filePath, { start: state.offset, end: size - 1 })) { + const lineEnd = state.offset + lineBuffer.length; + if (lineEnd >= size) return lineBuffer; + foldSessionScanLine(state.acc, lineBuffer); + state.tail = advanceScanTail(state.tail, lineBuffer); + state.offset = lineEnd + 1; + if (state.acc.invalid) break; + } + return undefined; +} - const trimmed = line.trim(); - let entry: FileEntry; - try { - entry = JSON.parse(trimmed) as FileEntry; - } catch { - continue; - } +function foldSessionScanLine(acc: SessionScanAccumulator, lineBuffer: Buffer): void { + const line = lineBuffer.toString("utf8"); + if (!line.trim()) return; - if (entry.type === "session_info") { - const infoEntry = entry as SessionInfoEntry; - name = infoEntry.name?.trim() || undefined; - } - if (entry.type === "session_state") { - const stateEntry = entry as SessionStateEntry; - const status = normalizeSessionStateStatus(stateEntry.state?.status); - if (status) { - state = { status }; - } - } - // Keep the latest recap/verdict so off-daemon sessions don't all show as - // unjudged in the agents view. Append-only, so last seen wins. - if (entry.type === "agent_status") { - agentStatus = (entry as AgentStatusEntry).status; - } - if (entry.type === "child_usage_attributed") { - const attribution = entry as ChildUsageAttributionEntry; - if (assistantUsageById.has(attribution.targetId)) { - assistantUsageById.set(attribution.targetId, attribution.aggregateUsage); - attributedChildUsages.push(attribution.childUsage); - } + // Large tool-result entries can be many MB. They do not carry the + // session-list metadata we need, and parsing them during every refresh + // can exhaust the daemon heap. + if (line.length > SESSION_LIST_PARSE_MAX_LINE_CHARS) { + if (looksLikeMessageEntry(line)) { + acc.messageCount++; + const summary = extractOversizedMessageSummary(line); + if (typeof summary.timestamp === "number" && (summary.role === "user" || summary.role === "assistant")) { + acc.lastActivityTime = Math.max(acc.lastActivityTime ?? 0, summary.timestamp); } - if (entry.type === "compaction" || entry.type === "branch_summary") { - const summarizationUsage = (entry as CompactionEntry | BranchSummaryEntry).usage; - if (summarizationUsage) summarizationUsages.push(summarizationUsage); - } - if (!header) { - if (entry.type !== "session") { - return null; - } - header = entry as SessionHeader; + if (summary.role === "user" && !acc.firstMessage) { + acc.firstMessage = summary.textPreview || "(large message)"; } + } + return; + } - lastActivityTime = updateLastActivityTime(lastActivityTime, entry); + const trimmed = line.trim(); + let entry: FileEntry; + try { + entry = JSON.parse(trimmed) as FileEntry; + } catch { + return; + } - if (entry.type !== "message") continue; - messageCount++; + if (entry.type === "session_info") { + const infoEntry = entry as SessionInfoEntry; + acc.name = infoEntry.name?.trim() || undefined; + } + if (entry.type === "session_state") { + const stateEntry = entry as SessionStateEntry; + const status = normalizeSessionStateStatus(stateEntry.state?.status); + if (status) { + acc.state = { status }; + } + } + // Keep the latest recap/verdict so off-daemon sessions don't all show as + // unjudged in the agents view. Append-only, so last seen wins. + if (entry.type === "agent_status") { + acc.agentStatus = (entry as AgentStatusEntry).status; + } + if (entry.type === "child_usage_attributed") { + const attribution = entry as ChildUsageAttributionEntry; + if (acc.assistantUsageById.has(attribution.targetId)) { + acc.assistantUsageById.set(attribution.targetId, attribution.aggregateUsage); + addAssistantUsage(acc.attributedChildUsage, attribution.childUsage); + } + } + if (entry.type === "compaction" || entry.type === "branch_summary") { + const summarizationUsage = (entry as CompactionEntry | BranchSummaryEntry).usage; + if (summarizationUsage) addAssistantUsage(acc.summarizationUsage, summarizationUsage); + } + if (!acc.header) { + if (entry.type !== "session") { + acc.invalid = true; + return; + } + acc.header = entry as SessionHeader; + } - const message = (entry as SessionMessageEntry).message; - if (message.role === "assistant" && (message as { usage?: Usage }).usage) { - assistantUsageById.set(entry.id, (message as { usage: Usage }).usage); - } - if (!isMessageWithContent(message)) continue; - if (message.role !== "user" && message.role !== "assistant") continue; + acc.lastActivityTime = updateLastActivityTime(acc.lastActivityTime, entry); - const textContent = extractTextContent(message); - if (!textContent) continue; + if (entry.type !== "message") return; + acc.messageCount++; - allMessagesText = appendCappedSearchText(allMessagesText, textContent); - if (!firstMessage && message.role === "user") { - firstMessage = textContent; - } - } + const message = (entry as SessionMessageEntry).message; + if (message.role === "assistant" && (message as { usage?: Usage }).usage) { + acc.assistantUsageById.set(entry.id, (message as { usage: Usage }).usage); + } + if (!isMessageWithContent(message)) return; + if (message.role !== "user" && message.role !== "assistant") return; - if (!header) return null; - const usageTotal = emptyUsage(); - for (const usage of assistantUsageById.values()) { - addAssistantUsage(usageTotal, usage); - } - for (const usage of summarizationUsages) { - addAssistantUsage(usageTotal, usage); - } - for (const childUsage of attributedChildUsages) { - subtractAssistantUsage(usageTotal, childUsage); - } - const cwd = typeof header.cwd === "string" ? header.cwd : ""; - const parentSessionPath = header.parentSession; - const rlmDepth = resolveSessionRlmDepth(header, filePath); - const modified = getSessionModifiedDateFromLastActivity(lastActivityTime, header, stats.mtime); + const textContent = extractTextContent(message); + if (!textContent) return; - return { - path: filePath, - id: header.id, - cwd, - name, - state, - parentSessionPath, - rlmDepth, - created: new Date(header.timestamp), - modified, - messageCount, - firstMessage: firstMessage || "(no messages)", - allMessagesText, - agentStatus, - usage: sessionUsageSummaryFrom(usageTotal), + acc.allMessagesText = appendCappedSearchText(acc.allMessagesText, textContent); + if (!acc.firstMessage && message.role === "user") { + acc.firstMessage = textContent; + } +} + +function snapshotSessionInfo( + persistent: SessionScanAccumulator, + tornTail: Buffer | undefined, + filePath: string, + stats: Awaited>, +): SessionInfo | null { + let acc = persistent; + if (tornTail !== undefined && tornTail.length > 0 && !acc.invalid) { + acc = { + ...persistent, + assistantUsageById: new Map(persistent.assistantUsageById), + attributedChildUsage: cloneUsage(persistent.attributedChildUsage), + summarizationUsage: cloneUsage(persistent.summarizationUsage), }; - } catch { - return null; + foldSessionScanLine(acc, tornTail); + } + if (acc.invalid || !acc.header) return null; + const usageTotal = emptyUsage(); + for (const usage of acc.assistantUsageById.values()) { + addAssistantUsage(usageTotal, usage); } + addAssistantUsage(usageTotal, acc.summarizationUsage); + subtractAssistantUsage(usageTotal, acc.attributedChildUsage); + const header = acc.header; + const cwd = typeof header.cwd === "string" ? header.cwd : ""; + const parentSessionPath = header.parentSession; + const rlmDepth = resolveSessionRlmDepth(header, filePath); + const modified = getSessionModifiedDateFromLastActivity(acc.lastActivityTime, header, stats.mtime); + + return { + path: filePath, + id: header.id, + cwd, + name: acc.name, + state: acc.state, + parentSessionPath, + rlmDepth, + created: new Date(header.timestamp), + modified, + messageCount: acc.messageCount, + firstMessage: acc.firstMessage || "(no messages)", + allMessagesText: acc.allMessagesText, + agentStatus: acc.agentStatus, + usage: sessionUsageSummaryFrom(usageTotal), + }; } export type SessionListProgress = (loaded: number, total: number) => void; @@ -1105,6 +1373,9 @@ async function listSessionsFromDir( ): Promise { const sessions: SessionInfo[] = []; if (!existsSync(dir)) { + for (const key of sessionScanStates.keys()) { + if (dirname(key) === dir) dropSessionScanState(key); + } return sessions; } @@ -1114,9 +1385,9 @@ async function listSessionsFromDir( const total = progressTotal ?? files.length; const present = new Set(files); - for (const key of sessionInfoCache.keys()) { + for (const key of sessionScanStates.keys()) { if (dirname(key) === dir && !present.has(key)) { - sessionInfoCache.delete(key); + dropSessionScanState(key); } } @@ -1180,6 +1451,7 @@ export class SessionManager { setSessionFile(sessionFile: string, preloadedEntries?: FileEntry[]): void { this.sessionFile = resolve(sessionFile); if (existsSync(this.sessionFile)) { + if (this.persist && preloadedEntries === undefined) repairJsonlDamage(this.sessionFile); this.fileEntries = preloadedEntries ?? loadEntriesFromFile(this.sessionFile); // If file was empty or corrupted (no valid header), truncate and start fresh @@ -1294,21 +1566,16 @@ export class SessionManager { private _rewriteFile(): void { if (!this.persist || !this.sessionFile) return; const content = `${this.fileEntries.map((e) => JSON.stringify(e)).join("\n")}\n`; - const targetPath = realpathIfPresent(this.sessionFile); + const targetPath = realpathIfPresentSync(this.sessionFile); const directory = dirname(targetPath); mkdirSync(directory, { recursive: true }); - const tempPath = join(directory, `.${basename(targetPath)}.${process.pid}.${randomUUID()}.tmp`); - try { - const metadata = statMetadataIfPresent(targetPath); - writeFileSync(tempPath, content, metadata === undefined ? undefined : { mode: metadata.mode }); - if (metadata !== undefined) { - chownSync(tempPath, metadata.uid, metadata.gid); - chmodSync(tempPath, metadata.mode); - } - renameSync(tempPath, targetPath); - } finally { - rmSync(tempPath, { force: true }); - } + const metadata = statMetadataIfPresent(targetPath); + writeFileAtomicSync(targetPath, content, { + ...(metadata === undefined ? {} : { mode: metadata.mode }), + beforeRename: (tempPath) => { + if (metadata !== undefined) chownSync(tempPath, metadata.uid, metadata.gid); + }, + }); this._notifyPersistListeners(); } @@ -2032,6 +2299,7 @@ export class SessionManager { if (!existsSync(path)) { return SessionManager.open(path, sessionDir, cwdOverride); } + repairJsonlDamage(path); const entries = await loadEntriesFromFileAsync(path); if (entries.length === 0) { return SessionManager.open(path, sessionDir, cwdOverride); diff --git a/packages/coding-agent/src/core/settings-manager.ts b/packages/coding-agent/src/core/settings-manager.ts index e2b7ee8e7..7abf0f563 100644 --- a/packages/coding-agent/src/core/settings-manager.ts +++ b/packages/coding-agent/src/core/settings-manager.ts @@ -1,9 +1,10 @@ import type { ServiceTier, Transport } from "@earendil-works/pi-ai"; -import { existsSync, mkdirSync, readFileSync, renameSync, unlinkSync, writeFileSync } from "fs"; +import { existsSync, mkdirSync, readFileSync } from "fs"; import { homedir } from "os"; import { dirname, join } from "path"; import lockfile from "proper-lockfile"; import { CONFIG_DIR_NAME, getAgentDir } from "../config.js"; +import { writeFileAtomicSync } from "../utils/atomic-file.js"; const RECENT_MODELS_LIMIT = 20; export const DEFAULT_IDLE_EVICTION_MINUTES = 90; @@ -282,20 +283,20 @@ export class FileSettingsStorage implements SettingsStorage { release = this.acquireLockSyncWithRetry(path); } const current = fileExists ? readFileSync(path, "utf-8") : undefined; - const next = fn(current); + let next = fn(current); if (next !== undefined) { if (!existsSync(dir)) { mkdirSync(dir, { recursive: true }); } if (!release) { release = this.acquireLockSyncWithRetry(path); + // The first-write read ran unlocked; a racing first writer may have landed since. + if (existsSync(path)) { + next = fn(readFileSync(path, "utf-8")); + } } - const temporaryPath = `${path}.${process.pid}.${Date.now()}.tmp`; - try { - writeFileSync(temporaryPath, next, { encoding: "utf-8", mode: 0o600 }); - renameSync(temporaryPath, path); - } finally { - if (existsSync(temporaryPath)) unlinkSync(temporaryPath); + if (next !== undefined) { + writeFileAtomicSync(path, next, { mode: 0o600 }); } } } finally { diff --git a/packages/coding-agent/src/core/telemetry.ts b/packages/coding-agent/src/core/telemetry.ts index bb32bce5f..15dbcb5fd 100644 --- a/packages/coding-agent/src/core/telemetry.ts +++ b/packages/coding-agent/src/core/telemetry.ts @@ -1,9 +1,10 @@ import { randomUUID } from "node:crypto"; -import { lstatSync, mkdirSync, readFileSync, renameSync, unlinkSync, writeFileSync } from "node:fs"; +import { lstatSync, mkdirSync, readFileSync, writeFileSync } from "node:fs"; import { arch, platform } from "node:os"; import { join } from "node:path"; import type { AssistantMessage, Usage } from "@earendil-works/pi-ai"; import { detectInstallMethod, VERSION } from "../config.js"; +import { writeFileAtomicSync } from "../utils/atomic-file.js"; import type { AgentSession, AgentSessionEvent } from "./agent-session.js"; import type { AgentExecutionMode } from "./agent-session-config.js"; import type { AuthCredential, AuthStatus } from "./auth-storage.js"; @@ -234,21 +235,7 @@ function readInstallationId(path: string): string | undefined { } function writeTelemetryStateAtomically(path: string, state: TelemetryState): void { - const temporaryPath = `${path}.${process.pid}.${randomUUID()}.tmp`; - try { - writeFileSync(temporaryPath, JSON.stringify(state, null, 2), { - encoding: "utf8", - flag: "wx", - mode: 0o600, - }); - renameSync(temporaryPath, path); - } finally { - try { - unlinkSync(temporaryPath); - } catch { - // The rename succeeded or the temporary file was never created. - } - } + writeFileAtomicSync(path, JSON.stringify(state, null, 2), { mode: 0o600 }); } export function getOrCreateTelemetryInstallationId(agentDir: string, randomId: () => string = randomUUID): string { diff --git a/packages/coding-agent/src/core/tools/bash.ts b/packages/coding-agent/src/core/tools/bash.ts index 154106349..b6e93adb2 100644 --- a/packages/coding-agent/src/core/tools/bash.ts +++ b/packages/coding-agent/src/core/tools/bash.ts @@ -301,7 +301,7 @@ export function createBashToolDefinition( if (!onUpdate || !updateDirty) return; updateDirty = false; lastUpdateAt = Date.now(); - const snapshot = output.snapshot({ persistIfTruncated: true }); + const snapshot = output.snapshot(); onUpdate({ content: [{ type: "text", text: snapshot.content || "" }], details: { @@ -346,9 +346,9 @@ export function createBashToolDefinition( output.finish(); clearUpdateTimer(); emitOutputUpdate(); - const snapshot = output.snapshot({ persistIfTruncated: true }); + // Snapshot only after the spill settled: the advertised path is terminal. await output.closeTempFile(); - return snapshot; + return output.snapshot(); }; const formatOutput = (snapshot: Awaited>, emptyText = "(no output)") => { @@ -359,13 +359,17 @@ export function createBashToolDefinition( details = { truncation, fullOutputPath: snapshot.fullOutputPath }; const startLine = truncation.totalLines - truncation.outputLines + 1; const endLine = truncation.totalLines; + // A degraded spill has no path; never advertise "Full output: undefined". + const location = snapshot.fullOutputPath ? `. Full output: ${snapshot.fullOutputPath}` : ""; if (truncation.lastLinePartial) { - const lastLineSize = formatSize(output.getLastLineBytes()); - text += `\n\n[Showing last ${formatSize(truncation.outputBytes)} of line ${endLine} (line is ${lastLineSize}). Full output: ${snapshot.fullOutputPath}]`; + // The partial line is the first SHOWN line; trailing blanks can follow it. + const lastLineBytes = output.getLastLineBytes(); + const lineSize = lastLineBytes > 0 ? ` (line is ${formatSize(lastLineBytes)})` : ""; + text += `\n\n[Showing last ${formatSize(truncation.outputBytes)} of line ${startLine}${lineSize}${location}]`; } else if (truncation.truncatedBy === "lines") { - text += `\n\n[Showing lines ${startLine}-${endLine} of ${truncation.totalLines}. Full output: ${snapshot.fullOutputPath}]`; + text += `\n\n[Showing lines ${startLine}-${endLine} of ${truncation.totalLines}${location}]`; } else { - text += `\n\n[Showing lines ${startLine}-${endLine} of ${truncation.totalLines} (${formatSize(DEFAULT_MAX_BYTES)} limit). Full output: ${snapshot.fullOutputPath}]`; + text += `\n\n[Showing lines ${startLine}-${endLine} of ${truncation.totalLines} (${formatSize(DEFAULT_MAX_BYTES)} limit)${location}]`; } } return { text, details }; diff --git a/packages/coding-agent/src/core/tools/ipython.ts b/packages/coding-agent/src/core/tools/ipython.ts index 5f1171024..acab77b79 100644 --- a/packages/coding-agent/src/core/tools/ipython.ts +++ b/packages/coding-agent/src/core/tools/ipython.ts @@ -389,6 +389,11 @@ export class IpythonKernelProvisioner { if (signal?.aborted) { return Promise.reject(createAbortError()); } + // Only a terminally dead kernel drops the memo; a repairing manager (idle/starting) recovers itself. + if (this.startedManager?.isDefunct) { + this.managerPromise = undefined; + this.startedManager = undefined; + } let cleanupProgressListener: (() => void) | undefined; if (onProgress && !this.startedManager) { this.startupListeners.add(onProgress); diff --git a/packages/coding-agent/src/core/tools/output-accumulator.ts b/packages/coding-agent/src/core/tools/output-accumulator.ts index 30f4d6633..591bf28c5 100644 --- a/packages/coding-agent/src/core/tools/output-accumulator.ts +++ b/packages/coding-agent/src/core/tools/output-accumulator.ts @@ -1,5 +1,5 @@ import { randomBytes } from "node:crypto"; -import { createWriteStream, type WriteStream } from "node:fs"; +import { createWriteStream, rmSync, type WriteStream } from "node:fs"; import { tmpdir } from "node:os"; import { join } from "node:path"; import { DEFAULT_MAX_BYTES, DEFAULT_MAX_LINES, type TruncationResult, truncateTail } from "./truncate.js"; @@ -21,6 +21,77 @@ function defaultTempFilePath(prefix: string): string { return join(tmpdir(), `${prefix}-${id}.log`); } +/** + * One spill lifecycle with exactly two terminal states: a COMPLETE file whose + * path finalize() resolves, or a DEGRADED spill (failure at open, write, or + * final flush) whose path is never advertised. finalize() never rejects; the + * caller keeps its bounded in-memory tail either way. + */ +export class OutputSpill { + private path?: string; + private stream?: WriteStream; + private failed = false; + + constructor(private readonly prefix: string) {} + + get isOpen(): boolean { + return this.stream !== undefined; + } + + /** Advertisable path; undefined once the spill degraded. */ + get currentPath(): string | undefined { + return this.path; + } + + /** Open once, writing `replay` first; a degraded spill never reopens. */ + open(replay: Iterable): void { + if (this.stream || this.failed) { + return; + } + this.path = defaultTempFilePath(this.prefix); + const stream = createWriteStream(this.path); + // An unlistened 'error' (ENOSPC, unwritable tmpdir) would crash the process. + stream.on("error", () => { + this.failed = true; + const partial = this.path; + this.path = undefined; + if (this.stream === stream) { + this.stream = undefined; + } + // The partial file would squat on the disk pressure that degraded the spill. + stream.once("close", () => { + try { + if (partial) rmSync(partial, { force: true }); + } catch { + // Best-effort cleanup: an EACCES/EBUSY here must not kill the process. + } + }); + }); + this.stream = stream; + for (const chunk of replay) { + stream.write(chunk); + } + } + + write(chunk: Buffer | string): void { + this.stream?.write(chunk); + } + + /** Flush and settle: the complete file's path, or undefined when degraded. */ + async finalize(): Promise { + const stream = this.stream; + this.stream = undefined; + if (stream && !stream.closed) { + await new Promise((resolve) => { + // 'close' fires after finish AND after error, so this never rejects. + stream.once("close", resolve); + stream.end(); + }); + } + return this.failed ? undefined : this.path; + } +} + function byteLength(text: string): number { return Buffer.byteLength(text, "utf-8"); } @@ -49,14 +120,14 @@ export class OutputAccumulator { private currentLineBytes = 0; private finished = false; - private tempFilePath: string | undefined; - private tempFileStream: WriteStream | undefined; + private readonly spill: OutputSpill; constructor(options: OutputAccumulatorOptions = {}) { this.maxLines = options.maxLines ?? DEFAULT_MAX_LINES; this.maxBytes = options.maxBytes ?? DEFAULT_MAX_BYTES; this.maxRollingBytes = Math.max(this.maxBytes * 2, 1); this.tempFilePrefix = options.tempFilePrefix ?? "pi-output"; + this.spill = new OutputSpill(this.tempFilePrefix); } append(data: Buffer): void { @@ -67,9 +138,9 @@ export class OutputAccumulator { this.totalRawBytes += data.length; this.appendDecodedText(this.decoder.decode(data, { stream: true })); - if (this.tempFileStream || this.shouldUseTempFile()) { + if (this.spill.isOpen || this.shouldUseTempFile()) { this.ensureTempFile(); - this.tempFileStream?.write(data); + this.spill.write(data); } else if (data.length > 0) { this.rawChunks.push(data); } @@ -86,7 +157,7 @@ export class OutputAccumulator { } } - snapshot(options: { persistIfTruncated?: boolean } = {}): OutputSnapshot { + snapshot(): OutputSnapshot { const tailTruncation = truncateTail(this.getSnapshotText(), { maxLines: this.maxLines, maxBytes: this.maxBytes, @@ -105,38 +176,19 @@ export class OutputAccumulator { maxBytes: this.maxBytes, }; - if (options.persistIfTruncated && truncation.truncated) { - this.ensureTempFile(); - } - return { content: truncation.content, truncation, - fullOutputPath: this.tempFilePath, + fullOutputPath: this.spill.currentPath, }; } + /** + * Settle the spill; never rejects. Afterwards snapshot().fullOutputPath is + * terminal: a complete file, or undefined when the spill degraded. + */ async closeTempFile(): Promise { - if (!this.tempFileStream) { - return; - } - - const stream = this.tempFileStream; - this.tempFileStream = undefined; - - await new Promise((resolve, reject) => { - const onError = (error: Error) => { - stream.off("finish", onFinish); - reject(error); - }; - const onFinish = () => { - stream.off("error", onError); - resolve(); - }; - stream.once("error", onError); - stream.once("finish", onFinish); - stream.end(); - }); + await this.spill.finalize(); } getLastLineBytes(): number { @@ -203,14 +255,7 @@ export class OutputAccumulator { } private ensureTempFile(): void { - if (this.tempFilePath) { - return; - } - this.tempFilePath = defaultTempFilePath(this.tempFilePrefix); - this.tempFileStream = createWriteStream(this.tempFilePath); - for (const chunk of this.rawChunks) { - this.tempFileStream.write(chunk); - } + this.spill.open(this.rawChunks); this.rawChunks = []; } } diff --git a/packages/coding-agent/src/core/tools/truncate.ts b/packages/coding-agent/src/core/tools/truncate.ts index a770db881..b5bf51bc7 100644 --- a/packages/coding-agent/src/core/tools/truncate.ts +++ b/packages/coding-agent/src/core/tools/truncate.ts @@ -185,12 +185,13 @@ export function truncateTail(content: string, options: TruncationOptions = {}): if (outputBytesCount + lineBytes > maxBytes) { truncatedBy = "bytes"; - // Edge case: if we haven't added ANY lines yet and this line exceeds maxBytes, - // take the end of the line (partial) - if (outputLinesArr.length === 0) { - const truncatedLine = truncateStringToBytesFromEnd(line, maxBytes); + // Trailing blanks must not defeat the oversized-line rescue; keep as many as the budget allows. + if (outputLinesArr.every((collected) => collected.length === 0)) { + const keptBlanks = Math.min(outputLinesArr.length, Math.max(0, maxBytes - 1)); + outputLinesArr.length = keptBlanks; + const truncatedLine = truncateStringToBytesFromEnd(line, maxBytes - keptBlanks); outputLinesArr.unshift(truncatedLine); - outputBytesCount = Buffer.byteLength(truncatedLine, "utf-8"); + outputBytesCount = Buffer.byteLength(truncatedLine, "utf-8") + keptBlanks; lastLinePartial = true; } break; diff --git a/packages/coding-agent/src/main.ts b/packages/coding-agent/src/main.ts index 5b7370507..64641cf5b 100644 --- a/packages/coding-agent/src/main.ts +++ b/packages/coding-agent/src/main.ts @@ -5,7 +5,7 @@ * createAgentSession() options. The SDK does the heavy lifting. */ -import { join, resolve } from "node:path"; +import { dirname, join, resolve } from "node:path"; import { createInterface } from "node:readline"; import { type Api, type ImageContent, type Model, modelsAreEqual } from "@earendil-works/pi-ai"; import { registerBuiltinMcpOAuthProviders } from "@earendil-works/pi-ai/mcp"; @@ -68,14 +68,23 @@ import { type SessionCwdIssue, } from "./core/session-cwd.js"; import { canonicalSessionPath, SessionAlreadyActiveError } from "./core/session-lease.js"; -import { SessionManager } from "./core/session-manager.js"; +import { + findMostRecentSessionForCwd, + getDefaultSessionDir, + loadEntriesFromFile, + SessionManager, +} from "./core/session-manager.js"; import { SettingsManager } from "./core/settings-manager.js"; import { isTelemetryEnabled } from "./core/telemetry.js"; import { printTimings, resetTimings, time } from "./core/timings.js"; import { runMigrations, showDeprecationWarnings } from "./migrations.js"; import { isDaemonCatalogProcess, runDaemonCatalogProcess } from "./modes/daemon/daemon-catalog-process.js"; -import { DaemonSessionCreateError, deserializeDaemonCreateError } from "./modes/daemon/daemon-errors.js"; -import { collectDaemonClientEnv, collectDaemonLaunchEnv } from "./modes/daemon/daemon-protocol.js"; +import { + DaemonSessionCreateError, + deserializeDaemonCreateError, + deserializeDaemonError, +} from "./modes/daemon/daemon-errors.js"; +import { collectDaemonClientEnv, collectDaemonLaunchEnv, type DaemonResponse } from "./modes/daemon/daemon-protocol.js"; import { DAEMON_WORKER_ACTIVE_SESSION_ID_ENV, daemonWorkerInstanceId, @@ -451,10 +460,22 @@ function getResumeSelector(parsed: Pick): string | undefined { return typeof parsed.resume === "string" ? parsed.resume : undefined; } +function readSessionManager(path: string, sessionDir?: string, cwdOverride?: string): SessionManager { + const entries = loadEntriesFromFile(path); + const header = entries.find((entry) => entry.type === "session"); + const manager = SessionManager.inMemory( + cwdOverride ?? header?.cwd ?? process.cwd(), + sessionDir ?? dirname(resolve(path)), + ); + manager.setSessionFile(path, entries); + return manager; +} + export async function createSessionManager( parsed: Args, cwd: string, sessionDir: string | undefined, + readOnly = false, ): Promise { const explicitCwdOverride = parsed.cwd ? cwd : undefined; @@ -480,7 +501,9 @@ export async function createSessionManager( switch (resolved.type) { case "path": case "local": - return SessionManager.open(resolved.path, sessionDir, explicitCwdOverride); + return readOnly + ? readSessionManager(resolved.path, sessionDir, explicitCwdOverride) + : SessionManager.open(resolved.path, sessionDir, explicitCwdOverride); case "global": { console.log(chalk.yellow(`Session found in different project: ${resolved.cwd}`)); @@ -495,10 +518,15 @@ export async function createSessionManager( } if (parsed.continue) { + if (readOnly) { + const dir = sessionDir ?? getDefaultSessionDir(cwd); + const path = findMostRecentSessionForCwd(dir, cwd); + return path ? readSessionManager(path, dir, cwd) : SessionManager.inMemory(cwd, dir); + } return SessionManager.continueRecent(cwd, sessionDir); } - return SessionManager.create(cwd, sessionDir); + return readOnly ? SessionManager.inMemory(cwd, sessionDir) : SessionManager.create(cwd, sessionDir); } function buildSessionOptions( @@ -948,6 +976,22 @@ function isUnknownActiveSessionError(message: string): boolean { return message.startsWith("Unknown active session:"); } +/** + * Unknown falls back to the saved-session path (whose create/open route retries recovery); recovering + * throws typed so an explicit --attach-agent surfaces the retryable state, not "No active agent found". + */ +export function resolveActiveSessionLookupFailure( + response: Extract, +): Error | undefined { + if (response.errorInfo?.code === "session_recovering") { + return deserializeDaemonError(response); + } + if (isUnknownActiveSessionError(response.error)) { + return undefined; + } + return new Error(response.error); +} + async function findActiveDaemonSessionSummary( socketPath: string, selector: string, @@ -958,10 +1002,11 @@ async function findActiveDaemonSessionSummary( try { const response = await client.request({ type: "get_state", activeSessionId: selector }, 3000); if (!response.success) { - if (isUnknownActiveSessionError(response.error)) { - return undefined; + const failure = resolveActiveSessionLookupFailure(response); + if (failure) { + throw failure; } - throw new Error(response.error); + return undefined; } if (!isDaemonSessionSummary(response.data)) { throw new Error("Daemon returned an invalid active session summary"); @@ -976,7 +1021,7 @@ function createSessionManagerForActiveDaemonSummary(summary: SessionSummary, fal const cwd = summary.cwd || fallbackCwd; if (summary.sessionFile) { try { - return SessionManager.open(summary.sessionFile, undefined, cwd); + return readSessionManager(summary.sessionFile, undefined, cwd); } catch { return SessionManager.inMemory(cwd); } @@ -1280,7 +1325,7 @@ export async function main(args: string[], options?: MainOptions) { sessionManager = SessionManager.inMemory(cwd); } else { try { - sessionManager = await createSessionManager(parsed, cwd, sessionDir); + sessionManager = await createSessionManager(parsed, cwd, sessionDir, useDaemonClient); } catch (error) { if (!(error instanceof SessionSelectorError)) { throw error; @@ -1301,7 +1346,9 @@ export async function main(args: string[], options?: MainOptions) { if (!selectedCwd) { process.exit(0); } - sessionManager = SessionManager.open(missingSessionCwdIssue.sessionFile!, sessionDir, selectedCwd); + sessionManager = useDaemonClient + ? readSessionManager(missingSessionCwdIssue.sessionFile!, sessionDir, selectedCwd) + : SessionManager.open(missingSessionCwdIssue.sessionFile!, sessionDir, selectedCwd); } else { console.error(chalk.red(new MissingSessionCwdError(missingSessionCwdIssue).message)); process.exit(1); diff --git a/packages/coding-agent/src/migrations.ts b/packages/coding-agent/src/migrations.ts index 0085d7b48..5e9f45d91 100644 --- a/packages/coding-agent/src/migrations.ts +++ b/packages/coding-agent/src/migrations.ts @@ -18,6 +18,7 @@ import { import { basename, dirname, join } from "path"; import { CONFIG_DIR_NAME, getAgentDir, getBinDir, getSessionsDir } from "./config.js"; import { migrateKeybindingsConfig } from "./core/keybindings.js"; +import { realpathIfPresentSync, writeFileAtomicSync } from "./utils/atomic-file.js"; import { readFirstLineSync } from "./utils/file-lines.js"; const MIGRATION_GUIDE_URL = @@ -42,7 +43,7 @@ export function migrateAuthToAuthJson(): string[] { const migrated: Record = {}; const providers: string[] = []; - // Migrate oauth.json + let oauthReadable = false; if (existsSync(oauthPath)) { try { const oauth = JSON.parse(readFileSync(oauthPath, "utf-8")); @@ -50,17 +51,18 @@ export function migrateAuthToAuthJson(): string[] { migrated[provider] = { type: "oauth", ...(cred as object) }; providers.push(provider); } - renameSync(oauthPath, `${oauthPath}.migrated`); + oauthReadable = true; } catch { // Skip on error } } - // Migrate settings.json apiKeys + let settingsWithoutApiKeys: string | undefined; + let settingsMode: number | undefined; if (existsSync(settingsPath)) { try { - const content = readFileSync(settingsPath, "utf-8"); - const settings = JSON.parse(content); + settingsMode = statSync(settingsPath).mode & 0o777; + const settings = JSON.parse(readFileSync(settingsPath, "utf-8")); if (settings.apiKeys && typeof settings.apiKeys === "object") { for (const [provider, key] of Object.entries(settings.apiKeys)) { if (!migrated[provider] && typeof key === "string") { @@ -69,16 +71,40 @@ export function migrateAuthToAuthJson(): string[] { } } delete settings.apiKeys; - writeFileSync(settingsPath, JSON.stringify(settings, null, 2)); + settingsWithoutApiKeys = JSON.stringify(settings, null, 2); } } catch { // Skip on error } } + // The destination must be durable before any source is destroyed. if (Object.keys(migrated).length > 0) { mkdirSync(dirname(authPath), { recursive: true }); - writeFileSync(authPath, JSON.stringify(migrated, null, 2), { mode: 0o600 }); + writeFileAtomicSync(realpathIfPresentSync(authPath), JSON.stringify(migrated, null, 2), { + mode: 0o600, + fsync: true, + fsyncDir: true, + }); + } + // Source cleanup is best-effort: with auth.json durable, leftovers are inert. + try { + if (oauthReadable) { + renameSync(oauthPath, `${oauthPath}.migrated`); + } + } catch { + // Skip on error + } + try { + if (settingsWithoutApiKeys !== undefined) { + writeFileAtomicSync( + realpathIfPresentSync(settingsPath), + settingsWithoutApiKeys, + settingsMode === undefined ? {} : { mode: settingsMode }, + ); + } + } catch { + // Skip on error } return providers; diff --git a/packages/coding-agent/src/modes/agents-view/agents-view-mode.ts b/packages/coding-agent/src/modes/agents-view/agents-view-mode.ts index ee09e0430..450ac81ec 100644 --- a/packages/coding-agent/src/modes/agents-view/agents-view-mode.ts +++ b/packages/coding-agent/src/modes/agents-view/agents-view-mode.ts @@ -29,6 +29,7 @@ import { ensureTool } from "../../utils/tools-manager.js"; import { DaemonAgentConnection } from "../agent-connection/daemon-agent-connection.js"; import type { AgentConnectionHeartbeat, AgentConnectionSavedSessionInfo } from "../agent-connection/types.js"; import { DaemonClient, getDaemonSocketCloseReason } from "../daemon/daemon-client.js"; +import { DaemonSessionRecoveringError } from "../daemon/daemon-errors.js"; import { collectDaemonClientEnv, type DaemonClosingReason, @@ -345,7 +346,11 @@ async function openAgentsViewSession( return { connection, summary }; } catch (error) { client.close(); - if (!summary.sessionFile || !isUnknownActiveSessionError(error)) { + // Recovering takes the saved-session path too; its create/open route retries the recovery. + if ( + !summary.sessionFile || + !(isUnknownActiveSessionError(error) || error instanceof DaemonSessionRecoveringError) + ) { throw error; } client = await connectAgentsViewDaemonClient(socketPath); diff --git a/packages/coding-agent/src/modes/agents-view/agents-view-state.ts b/packages/coding-agent/src/modes/agents-view/agents-view-state.ts index 131787a7e..a282b0874 100644 --- a/packages/coding-agent/src/modes/agents-view/agents-view-state.ts +++ b/packages/coding-agent/src/modes/agents-view/agents-view-state.ts @@ -273,7 +273,7 @@ export function summaryForUnifiedRecord(record: UnifiedSessionRecord): SessionSu lifecycle: "archived", activity: "idle", isSessionActive: false, - runtimeKind: saved.parentSessionPath ? "subagent" : "top-level", + runtimeKind: (saved.rlmDepth ?? (saved.parentSessionPath ? 1 : 0)) > 0 ? "subagent" : "top-level", rlmDepth: saved.rlmDepth, sessionId: saved.id, sessionFile: canonicalSessionPath(saved.path), diff --git a/packages/coding-agent/src/modes/daemon/command-recovery-journal.ts b/packages/coding-agent/src/modes/daemon/command-recovery-journal.ts index 05fcec064..d78a8b7df 100644 --- a/packages/coding-agent/src/modes/daemon/command-recovery-journal.ts +++ b/packages/coding-agent/src/modes/daemon/command-recovery-journal.ts @@ -1,5 +1,6 @@ -import { chmodSync, closeSync, fsyncSync, mkdirSync, openSync, readFileSync, renameSync, writeSync } from "node:fs"; +import { chmodSync, closeSync, fsyncSync, mkdirSync, openSync, readFileSync, writeSync } from "node:fs"; import { dirname } from "node:path"; +import { writeFileAtomicSync } from "../../utils/atomic-file.js"; import type { DaemonClientId, DaemonCommandId, DaemonResponse } from "./daemon-protocol.js"; interface ReceivedRecord { @@ -184,7 +185,6 @@ export class CommandRecoveryJournal { } private compact(): void { - const tempPath = `${this.path}.${process.pid}.tmp`; const records: JournalRecord[] = []; for (const [key, entry] of this.entries) { records.push(entry.received); @@ -198,20 +198,11 @@ export class CommandRecoveryJournal { }); } } - const descriptor = openSync(tempPath, "w", 0o600); - try { - writeSync(descriptor, `${records.map((record) => JSON.stringify(record)).join("\n")}\n`); - fsyncSync(descriptor); - } finally { - closeSync(descriptor); - } - renameSync(tempPath, this.path); - const directoryDescriptor = openSync(dirname(this.path), "r"); - try { - fsyncSync(directoryDescriptor); - } finally { - closeSync(directoryDescriptor); - } + writeFileAtomicSync(this.path, `${records.map((record) => JSON.stringify(record)).join("\n")}\n`, { + mode: 0o600, + fsync: true, + fsyncDir: true, + }); this.recordCount = records.length; } } diff --git a/packages/coding-agent/src/modes/daemon/daemon-errors.ts b/packages/coding-agent/src/modes/daemon/daemon-errors.ts index 966810ee8..760ed0b33 100644 --- a/packages/coding-agent/src/modes/daemon/daemon-errors.ts +++ b/packages/coding-agent/src/modes/daemon/daemon-errors.ts @@ -3,6 +3,16 @@ import { SessionImportFileNotFoundError } from "../../core/session-import-errors import { SessionAlreadyActiveError } from "../../core/session-lease.js"; import type { DaemonErrorInfo, DaemonResponse } from "./daemon-protocol.js"; +/** A known session (a persisted descriptor names it) that cannot be routed to yet; retryable, unlike "Unknown active session". */ +export class DaemonSessionRecoveringError extends Error { + readonly code = "session_recovering" as const; + + constructor(readonly activeSessionId: string) { + super(`Active session ${activeSessionId} is recovering; retry shortly`); + this.name = "DaemonSessionRecoveringError"; + } +} + export function serializeDaemonError(error: unknown): DaemonErrorInfo | undefined { if (error instanceof MissingSessionCwdError) { return { code: "missing_session_cwd", issue: error.issue }; @@ -17,6 +27,9 @@ export function serializeDaemonError(error: unknown): DaemonErrorInfo | undefine activeSessionId: error.activeSessionId, }; } + if (error instanceof DaemonSessionRecoveringError) { + return { code: "session_recovering", activeSessionId: error.activeSessionId }; + } return undefined; } @@ -45,5 +58,8 @@ export function deserializeDaemonError(response: Extract + ownerPid !== undefined ? isProcessAlive(ownerPid) : false, + ); + if (result === "held") { + return; } + ownsLock = result === "acquired"; } if (!ownsLock) { return; @@ -925,15 +899,6 @@ export class AgentDaemon { } } - private isProcessAlive(pid: number): boolean { - try { - process.kill(pid, 0); - return true; - } catch (error) { - return (error as NodeJS.ErrnoException).code === "EPERM"; - } - } - private cleanupSocketPath(): void { if (!this.ownsSocketPath) { return; @@ -1017,9 +982,11 @@ export class AgentDaemon { private readLegacyRlmSubagentRegistry( path: string, throwOnReadError = false, + onReadError?: () => void, ): Promise { return readLegacyRlmSubagentRegistryFile(path, { throwOnReadError, + onReadError, log: (message) => this.log(message), }); } @@ -1203,6 +1170,7 @@ export class AgentDaemon { edge: RlmLedgerEdge, parent: { sessionId: string; sessionFile: string }, legacyRegistryCache?: Map>, + onReadError?: (path: string) => void, ): Promise { const edgeChild = canonicalSessionPath(edge.child); const base = { @@ -1236,7 +1204,9 @@ export class AgentDaemon { status: source.status, createdAt: source.createdAt, }); - const display = await readRlmSubagentDisplayEntry(dirname(edge.child)); + const display = await readRlmSubagentDisplayEntry(dirname(edge.child), () => + onReadError?.(rlmSubagentDisplayPath(dirname(edge.child))), + ); if (display && display.childId === edge.childId) { // A display-file child was ledger-spawned: the edge depth is real. return { ...metadataFields(display), rlmDepth: edge.depth }; @@ -1244,7 +1214,7 @@ export class AgentDaemon { const registryPath = this.legacyRlmSubagentRegistryPath(parent.sessionFile, parent.sessionId); let registryRead = legacyRegistryCache?.get(registryPath); if (!registryRead) { - registryRead = this.readLegacyRlmSubagentRegistry(registryPath); + registryRead = this.readLegacyRlmSubagentRegistry(registryPath, false, () => onReadError?.(registryPath)); legacyRegistryCache?.set(registryPath, registryRead); } const legacy = (await registryRead).find((entry) => entry.childId === edge.childId); @@ -1264,19 +1234,140 @@ export class AgentDaemon { return { ...base, rlmDepth: edge.depth, status: "completed", createdAt }; } - /** List each root's passive (non-resident) descendants from the ledger, without creating runtimes. */ - private async listPassiveRlmSubagents( + // One memoized passive-topology derivation per argument shape; all daemon + // consumers read the cached walk. A hit requires the ledger stat, roster, + // live roots, root-state identities, and every visited file input's stat + // (captured before its read, so mid-walk writes cost one extra re-walk, + // never a stale memo) to be unchanged. Same-shape walks run one at a time; + // a caller never joins an earlier walk. + private readonly passiveRlmSubagentWalks = new Map>(); + private readonly passiveRlmSubagentMemo = new Map< + string, + { fingerprint: string; inputStats: Map; result: PassiveRlmSubagent[] } + >(); + private static readonly PASSIVE_RLM_MEMO_MAX_KEYS = 4; + + private hasPersistedResidentSession(): boolean { + for (const state of this.sessions.values()) { + if (state.runtime.session.sessionFile) return true; + } + return false; + } + + private async passiveRlmTopologyFingerprint(savedRootInfos: SessionInfo[]): Promise { + const ledgerStat = await this.passiveRlmStatString(this.rlmSpawnLedger().ledgerPath); + const resident = [...this.sessions.values()] + .map((state) => `${state.activeSessionId}:${state.runtime.session.sessionFile ?? ""}`) + .sort(); + const roots = savedRootInfos.map((info) => resolve(info.path)).sort(); + return `${ledgerStat}|${resident.join(",")}|${roots.join(",")}`; + } + + private async passiveRlmStatString(path: string): Promise { + try { + const stats = await stat(path); + return `${stats.size}:${stats.mtimeMs}:${stats.ino}`; + } catch { + return "absent"; + } + } + + private async passiveRlmInputStatsUnchanged(inputStats: Map): Promise { + const checks = await Promise.all( + [...inputStats].map(async ([path, statString]) => (await this.passiveRlmStatString(path)) === statString), + ); + return checks.every(Boolean); + } + + private passiveRlmRootsStillResident(result: PassiveRlmSubagent[]): boolean { + return result.every( + (passive) => + !passive.rootParentState || + this.sessions.get(passive.rootParentState.activeSessionId) === passive.rootParentState, + ); + } + + private listPassiveRlmSubagents( savedRoots: SessionInfo[] = [], includeResident = false, ): Promise { + const savedRootInfos = savedRoots.filter((rootInfo) => inactiveLifecycleForSession(rootInfo) === "live"); + if (savedRootInfos.length === 0 && !this.hasPersistedResidentSession()) { + // Keep the empty topology IO-free: no roots means no walk, no ledger stat. + return Promise.resolve([]); + } + const key = `${includeResident}|${savedRootInfos + .map((info) => resolve(info.path)) + .sort() + .join(",")}`; + const run = async (): Promise => { + const before = await this.passiveRlmTopologyFingerprint(savedRootInfos); + const memo = this.passiveRlmSubagentMemo.get(key); + if ( + memo && + memo.fingerprint === before && + this.passiveRlmRootsStillResident(memo.result) && + (await this.passiveRlmInputStatsUnchanged(memo.inputStats)) + ) { + return memo.result; + } + const walked = await this.walkPassiveRlmSubagents(savedRootInfos, includeResident); + // Only a walk whose inputs held still qualifies as a memo: not the + // ledger-seeding first walk, not a degraded one. + const after = await this.passiveRlmTopologyFingerprint(savedRootInfos); + if (after === before && !walked.degraded) { + this.passiveRlmSubagentMemo.delete(key); + this.passiveRlmSubagentMemo.set(key, { + fingerprint: after, + inputStats: walked.inputStats, + result: walked.result, + }); + for (const staleKey of this.passiveRlmSubagentMemo.keys()) { + if (this.passiveRlmSubagentMemo.size <= AgentDaemon.PASSIVE_RLM_MEMO_MAX_KEYS) break; + this.passiveRlmSubagentMemo.delete(staleKey); + } + } else { + this.passiveRlmSubagentMemo.delete(key); + } + return walked.result; + }; + const previous = this.passiveRlmSubagentWalks.get(key); + const walk = previous ? previous.then(run, run) : run(); + this.passiveRlmSubagentWalks.set(key, walk); + // Not finally(): its discarded promise would turn a rejecting walk into a + // daemon-crashing unhandled rejection. The caller still sees the rejection. + const cleanup = () => { + if (this.passiveRlmSubagentWalks.get(key) === walk) this.passiveRlmSubagentWalks.delete(key); + }; + walk.then(cleanup, cleanup); + return walk; + } + + /** List each root's passive (non-resident) descendants from the ledger, without creating runtimes. */ + private async walkPassiveRlmSubagents( + savedRootInfos: SessionInfo[], + includeResident: boolean, + ): Promise<{ result: PassiveRlmSubagent[]; inputStats: Map; degraded: boolean }> { + // Captured before each read: the identity can only be older than the content. + const inputStats = new Map(); + let degraded = false; + const recordInputStat = async (path: string): Promise => { + const resolved = resolve(path); + const existing = inputStats.get(resolved); + if (existing !== undefined) return existing; + const statString = await this.passiveRlmStatString(path); + inputStats.set(resolved, statString); + return statString; + }; const residentRoots: Array<{ parentState: ActiveSessionState; sessionFile: string }> = []; for (const parentState of this.sessions.values()) { const parentFile = parentState.runtime.session.sessionFile; // An in-memory session cannot own persisted children. if (parentFile) residentRoots.push({ parentState, sessionFile: parentFile }); } - const savedRootInfos = savedRoots.filter((rootInfo) => inactiveLifecycleForSession(rootInfo) === "live"); - if (residentRoots.length === 0 && savedRootInfos.length === 0) return []; + if (residentRoots.length === 0 && savedRootInfos.length === 0) { + return { result: [], inputStats, degraded }; + } const edges = await this.rlmSpawnLedger().edges(); const childrenByParent = new Map(); for (const edge of edges) { @@ -1294,19 +1385,29 @@ export class AgentDaemon { visited: Set, ): Promise => { for (const edge of childrenByParent.get(canonicalSessionPath(parent.sessionFile)) ?? []) { + await recordInputStat(rlmSubagentDisplayPath(dirname(edge.child))); + await recordInputStat(this.legacyRlmSubagentRegistryPath(parent.sessionFile, parent.sessionId)); // The ledger stores realpath-canonical paths while the rest of the // daemon keys by resolve(): work with the writer-recorded path from // the metadata entry so passive rows keep matching residency, // opens, and passivation bookkeeping. - const entry = await this.passiveRlmSubagentEntryForEdge(edge, parent, legacyRegistryCache); + const entry = await this.passiveRlmSubagentEntryForEdge(edge, parent, legacyRegistryCache, (path) => { + // A present metadata file may recover without a stat change. + if (inputStats.get(resolve(path)) !== "absent") degraded = true; + }); const sessionKey = resolve(entry.sessionFile); if (entry.status === "deleted" || visited.has(sessionKey)) continue; visited.add(sessionKey); - const info = await readSessionInfo(entry.sessionFile); - if (!info) continue; - // A resident child walks its own subtree as an outer root below. Avoid - // both duplicate rows and attributing its descendants to an ancestor. + // A resident child walks as an outer root below; skipping before the + // stat capture keeps its streamed transcript out of the input set. if (!includeResident && this.findSessionBySessionFile(entry.sessionFile)) continue; + const childStat = await recordInputStat(entry.sessionFile); + const info = await readSessionInfo(entry.sessionFile); + if (!info) { + // A present file that fails to list may recover without a stat change. + if (childStat !== "absent") degraded = true; + continue; + } const chain = [...parentChain, entry]; passive.push({ ...root, entry, info, chain }); await visit(root, { sessionId: info.id, sessionFile: entry.sessionFile }, chain, visited); @@ -1328,7 +1429,7 @@ export class AgentDaemon { if (residentRootPaths.has(rootPath)) continue; await visit({ rootInfo }, { sessionId: rootInfo.id, sessionFile: rootInfo.path }, [], new Set([rootPath])); } - return passive; + return { result: passive, inputStats, degraded }; } private async passiveRlmSubagentsByPath( @@ -3956,7 +4057,7 @@ export class AgentDaemon { }); } if (streamsSnapshot) { - const snapshotId = `${state.activeSessionId}-${state.eventGeneration}-${state.lastEventSequence}`; + const snapshotId = snapshotTransferId(result.snapshot); let transcript: SnapshotTranscriptChunkSource; try { transcript = createSnapshotTranscriptChunks({ @@ -6674,10 +6775,9 @@ export class AgentDaemon { state: ActiveSessionState, message: Extract, ): void { - const snapshotId = `${state.activeSessionId}-${state.eventGeneration}-${state.lastEventSequence}`; // Mark before the registry read so later events queue behind this snapshot. const snapshotSignal = markClientSnapshotStreaming(client, state.activeSessionId); - void this.prepareReplacementSnapshot(client, state, message, snapshotId, snapshotSignal).catch((error) => { + void this.prepareReplacementSnapshot(client, state, message, snapshotSignal).catch((error) => { finishClientSnapshotStreaming(client, state.activeSessionId); this.log(`could not prepare replacement snapshot: ${String(error)}`); if (!client.socket.destroyed && this.sessions.get(state.activeSessionId) === state) { @@ -6695,13 +6795,13 @@ export class AgentDaemon { client: DaemonSocketClient, state: ActiveSessionState, message: Extract, - snapshotId: string, snapshotSignal: AbortSignal, ): Promise { const result = await this.createAttachResult(client, state, { type: "attach", activeSessionId: state.activeSessionId, }); + const snapshotId = snapshotTransferId(result.snapshot); if (this.sessions.get(state.activeSessionId) !== state) { finishClientSnapshotStreaming(client, state.activeSessionId); if (!client.snapshotStreaming && client.catchupActiveSessionIds?.size) { @@ -7103,7 +7203,7 @@ export class AgentDaemon { ), }); } - const snapshotId = `${activeSessionId}-${state.eventGeneration}-${state.lastEventSequence}`; + const snapshotId = snapshotTransferId(result.snapshot); const snapshotSignal = markClientSnapshotStreaming(client, activeSessionId); let transcript: SnapshotTranscriptChunkSource; try { @@ -7385,6 +7485,16 @@ const ROSTER_SESSION_EVENT_TRIGGERS = new Set([ "thinking_level_changed", ]); +/** + * The transfer id must name the cursor observed at materialization, not the live session cursor: + * events appended in between would let two different byte streams share one snapshot id. + */ +function snapshotTransferId(snapshot: DaemonSessionSnapshot): string { + // createSessionSnapshot always sets lastEventCursor; it is optional only on the wire. + const cursor = snapshot.lastEventCursor!; + return `${snapshot.activeSessionId}-${cursor.generation}-${cursor.sequence}`; +} + function hasDaemonOutboundActiveSessionId( message: DaemonOutbound, ): message is DaemonOutbound & { activeSessionId: string } { diff --git a/packages/coding-agent/src/modes/daemon/daemon-protocol.ts b/packages/coding-agent/src/modes/daemon/daemon-protocol.ts index 1511eaa6b..557630b87 100644 --- a/packages/coding-agent/src/modes/daemon/daemon-protocol.ts +++ b/packages/coding-agent/src/modes/daemon/daemon-protocol.ts @@ -72,8 +72,9 @@ export const DAEMON_COMMAND_ENVELOPE_MIN_PROTOCOL_VERSION = 7; // Revision 24 adds the capability-gated agent-roster subscription and push. // Revision 25 adds capability-gated direct worker peer transport discovery. // Revision 26 publishes own-session usage totals on session summary and saved-session rows. -export const DAEMON_SCHEMA_REVISION = 26; -export const DAEMON_SCHEMA_ID = "protocol-7-schema-26-962b8b4c5e35"; +// Revision 27 adds structured session_recovering failure info for known-but-unaddressable sessions. +export const DAEMON_SCHEMA_REVISION = 27; +export const DAEMON_SCHEMA_ID = "protocol-7-schema-27-962b8b4c5e35"; export type DaemonProtocolName = typeof DAEMON_PROTOCOL_NAME; export type DaemonProtocolVersion = number; @@ -1026,6 +1027,7 @@ export type DaemonErrorInfo = | { code: "missing_session_cwd"; issue: SessionCwdIssue } | { code: "session_import_file_not_found"; filePath: string } | { code: "session_already_active"; sessionPath: string; activeSessionId?: string } + | { code: "session_recovering"; activeSessionId: string } | { code: "command_result_uncertain"; clientId: DaemonClientId; commandId: DaemonCommandId }; export type DaemonSessionClosedReason = "killed" | "shutdown" | "completed" | "replaced" | "update"; diff --git a/packages/coding-agent/src/modes/daemon/daemon-session-summarizer.ts b/packages/coding-agent/src/modes/daemon/daemon-session-summarizer.ts index f2dba51f6..5c01b741b 100644 --- a/packages/coding-agent/src/modes/daemon/daemon-session-summarizer.ts +++ b/packages/coding-agent/src/modes/daemon/daemon-session-summarizer.ts @@ -8,6 +8,10 @@ import type { ActiveSessionState } from "./active-session-state.js"; const SWEEP_INTERVAL_MS = 25_000; // Collapse a tool-use loop's rapid turn_end bursts into one summarization. const SETTLE_DEBOUNCE_MS = 2_000; +// Idle generations stop retrying (and paying) on unchanged content until the +// backoff elapses, so transient outages and late credentials still recover. +const IDLE_GENERATION_ATTEMPT_LIMIT = 3; +const IDLE_GENERATION_RETRY_BACKOFF_MS = 30 * 60_000; const SUMMARY_MODEL_PROVIDER = "prime-inference"; const SUMMARY_MODEL_ID = "qwen/qwen3-30b-a3b-instruct-2507"; @@ -207,6 +211,11 @@ export class DaemonSessionSummarizer { private readonly inFlight = new Map(); // Sessions requested while one was running; get one more pass on completion. private readonly rerunRequested = new Set(); + // Failed idle generations per session, keyed to the settled content they saw. + private readonly failedIdleGenerations = new Map< + string, + { contentKey: string; attempts: number; lastFailureAt: number } + >(); constructor( private readonly listSessions: () => readonly ActiveSessionState[], @@ -242,6 +251,7 @@ export class DaemonSessionSummarizer { controller.abort(); } this.rerunRequested.clear(); + this.failedIdleGenerations.clear(); } /** Drop any pending work for a session that is closing. */ @@ -253,6 +263,7 @@ export class DaemonSessionSummarizer { } this.inFlight.get(activeSessionId)?.abort(); this.rerunRequested.delete(activeSessionId); + this.failedIdleGenerations.delete(activeSessionId); } /** Seed in-memory status from the persisted entry when a session is added. */ @@ -306,6 +317,18 @@ export class DaemonSessionSummarizer { if (contentUnchanged && !isWorking && !owesIdleVerdict && !owesSummary) { return; } + // The leaf entry id is the branch-tip identity (appends, edits, and branch + // navigation all move it; counts and timestamps collide across siblings). + const contentKey = `${session.sessionManager.getLeafId() ?? "root"}:${messageCount}`; + const failed = this.failedIdleGenerations.get(id); + if ( + !isWorking && + failed?.contentKey === contentKey && + failed.attempts >= IDLE_GENERATION_ATTEMPT_LIMIT && + Date.now() - failed.lastFailureAt < IDLE_GENERATION_RETRY_BACKOFF_MS + ) { + return; + } // Include the in-progress message so a long streaming turn gets a live recap. const streaming = isWorking ? session.state.streamingMessage : undefined; const contextMessages = streaming ? [...messages, streaming] : messages; @@ -319,6 +342,16 @@ export class DaemonSessionSummarizer { isWorking, signal: controller.signal, }); + if (generated) { + this.failedIdleGenerations.delete(id); + } else if (!isWorking && !controller.signal.aborted) { + // The aborted check keeps a racing forget() from repopulating the map. + this.failedIdleGenerations.set(id, { + contentKey, + attempts: failed?.contentKey === contentKey ? failed.attempts + 1 : 1, + lastFailureAt: Date.now(), + }); + } // A failed classification on an idle session would spin at "working" // forever (the activity axis holds unjudged idle sessions there), so // settle it to needs_input. @@ -356,12 +389,20 @@ export class DaemonSessionSummarizer { previous?.taskState !== status.taskState || (!isWorking && previous?.basedOnMessageCount !== status.basedOnMessageCount); state.summaryState = status; - // Persist only settled idle verdicts, never mid-stream. - if (!isWorking) { - try { - session.sessionManager.appendAgentStatus(status); - } catch { - // best-effort; in-memory status still shows + // Persist only settled idle verdicts from real classifications that + // differ from the latest persisted entry: idle sweeps must not grow the journal. + if (!isWorking && generated) { + const persisted = session.sessionManager.getLatestAgentStatus(); + if ( + persisted?.summary !== status.summary || + persisted.taskState !== status.taskState || + persisted.basedOnMessageCount !== status.basedOnMessageCount + ) { + try { + session.sessionManager.appendAgentStatus(status); + } catch { + // best-effort; in-memory status still shows + } } } if (changed) { diff --git a/packages/coding-agent/src/modes/daemon/daemon-supervisor-ownership.ts b/packages/coding-agent/src/modes/daemon/daemon-supervisor-ownership.ts index 599fcd75d..ba6920114 100644 --- a/packages/coding-agent/src/modes/daemon/daemon-supervisor-ownership.ts +++ b/packages/coding-agent/src/modes/daemon/daemon-supervisor-ownership.ts @@ -1,19 +1,11 @@ import { createHash, randomUUID } from "node:crypto"; -import { - existsSync, - mkdirSync, - readdirSync, - readFileSync, - realpathSync, - renameSync, - rmSync, - statSync, - writeFileSync, -} from "node:fs"; +import { existsSync, mkdirSync, readdirSync, readFileSync, realpathSync, renameSync, rmSync, statSync } from "node:fs"; import { homedir } from "node:os"; import { basename, dirname, join, resolve } from "node:path"; import lockfile from "proper-lockfile"; import { getProcessStartId } from "../../core/session-lease.js"; +import { writeFileAtomicSync } from "../../utils/atomic-file.js"; +import { isProcessAlive, isZombieProcess, processIdExists } from "../../utils/child-process.js"; import { defaultDaemonSocketDir, normalizeSocketPath } from "./daemon-socket.js"; const DAEMON_SUPERVISOR_REGISTRY_DIR_ENV = "PRIME_AGENT_INTERNAL_DAEMON_SUPERVISOR_REGISTRY_DIR"; @@ -506,6 +498,34 @@ export async function acquireDaemonSupervisorOwnership( return new DaemonSupervisorOwnership(record, registryDir, ownerDirectory); } +// The 250ms fence poll must not spawn `ps` (macOS/BSD zombie check) per tick; existence stays kill(0)-checked every tick. +const OWNER_ZOMBIE_CONFIRM_INTERVAL_MS = 5000; +const ownerZombieConfirmations = new Map(); + +function isOwnerProcessAlive(pid: number): boolean { + if (!processIdExists(pid)) { + ownerZombieConfirmations.delete(pid); + return false; + } + const now = Date.now(); + const confirmedAt = ownerZombieConfirmations.get(pid); + if (confirmedAt !== undefined && now - confirmedAt < OWNER_ZOMBIE_CONFIRM_INTERVAL_MS) { + return true; + } + if (isZombieProcess(pid)) { + ownerZombieConfirmations.delete(pid); + return false; + } + // Expired entries belong to owners nothing asserts anymore; dropping them keeps the cache bounded. + for (const [staleOwnerPid, staleConfirmedAt] of ownerZombieConfirmations) { + if (now - staleConfirmedAt >= OWNER_ZOMBIE_CONFIRM_INTERVAL_MS) { + ownerZombieConfirmations.delete(staleOwnerPid); + } + } + ownerZombieConfirmations.set(pid, now); + return true; +} + export async function assertDaemonSupervisorOwnerCurrent( owner: { generation: string; @@ -526,7 +546,7 @@ export async function assertDaemonSupervisorOwnerCurrent( current.pid !== owner.pid || current.processStartId !== owner.processStartId || current.socketPath !== normalizeSocketPath(owner.socketPath) || - !isProcessAlive(current.pid) + !isOwnerProcessAlive(current.pid) ) { throw new DaemonSupervisorOwnershipLostError(owner.generation, { socketPath: owner.socketPath, registryDir }); } @@ -693,15 +713,6 @@ function matchesExactProcessIdentity(identity: ProcessIdentity): boolean { return identity.processStartId === undefined || getProcessStartId(identity.pid) === identity.processStartId; } -function isProcessAlive(pid: number): boolean { - try { - process.kill(pid, 0); - } catch (error) { - return (error as NodeJS.ErrnoException).code !== "ESRCH"; - } - return true; -} - function canonicalizeFilesystemPath(path: string): string { let existingAncestor = resolve(path); const missingSuffix: string[] = []; @@ -933,14 +944,7 @@ function readShutdownAdmission(path: string): DaemonShutdownAdmissionRecord | un } function writeJsonAtomically(path: string, value: unknown): void { - const tempPath = `${path}.${process.pid}.${randomUUID()}.tmp`; - try { - writeFileSync(tempPath, `${JSON.stringify(value, null, 2)}\n`, { mode: 0o600 }); - renameSync(tempPath, path); - } catch (error) { - rmSync(tempPath, { force: true }); - throw error; - } + writeFileAtomicSync(path, `${JSON.stringify(value, null, 2)}\n`, { mode: 0o600 }); } function startupFencePath(directory: string, socketPath: string): string { diff --git a/packages/coding-agent/src/modes/daemon/daemon-supervisor.ts b/packages/coding-agent/src/modes/daemon/daemon-supervisor.ts index 55ade4be1..61ca03713 100644 --- a/packages/coding-agent/src/modes/daemon/daemon-supervisor.ts +++ b/packages/coding-agent/src/modes/daemon/daemon-supervisor.ts @@ -1,15 +1,6 @@ import { type ChildProcess, spawn } from "node:child_process"; import { createHash, randomBytes, randomUUID } from "node:crypto"; -import { - chmodSync, - existsSync, - mkdirSync, - readdirSync, - readFileSync, - renameSync, - rmSync, - writeFileSync, -} from "node:fs"; +import { chmodSync, existsSync, mkdirSync, readdirSync, readFileSync, rmSync } from "node:fs"; import { createServer, type Server, type Socket } from "node:net"; import { basename, dirname, join, resolve } from "node:path"; import { Writable } from "node:stream"; @@ -61,6 +52,7 @@ import { canonicalSessionPath, getProcessStartId, SessionAlreadyActiveError } fr import { getSessionArtifactPathForFile, readSessionInfo, type SessionInfo } from "../../core/session-manager.js"; import { looksLikeSessionPath } from "../../core/session-resolver.js"; import { SettingsManager } from "../../core/settings-manager.js"; +import { writeFileAtomicSync } from "../../utils/atomic-file.js"; import { isProcessAlive, processIdExists, signalProcessGroupOrProcess } from "../../utils/child-process.js"; import type { AgentConnectionHeartbeat } from "../agent-connection/types.js"; import { attachJsonlLineReader, serializeJsonLine } from "../rpc/jsonl.js"; @@ -79,7 +71,7 @@ import { import { CommandRecoveryJournal, createCommandIdempotencyKey } from "./command-recovery-journal.js"; import { CompactAssistantStreamReconstructor, isCompactAssistantDelta } from "./compact-session-stream.js"; import { DAEMON_CATALOG_ROLE_ENV, DaemonCatalogClient } from "./daemon-catalog-process.js"; -import { deserializeDaemonError, serializeDaemonError } from "./daemon-errors.js"; +import { DaemonSessionRecoveringError, deserializeDaemonError, serializeDaemonError } from "./daemon-errors.js"; import { collectDaemonClientEnv, createDaemonEventMeta, @@ -1393,10 +1385,7 @@ export class DaemonSupervisor { socketPath: this.socketPath, defaultSessionConfig: durableAgentSessionRuntimeConfig(this.defaultSessionConfig), }; - const tempPath = `${this.supervisorConfigPath}.${process.pid}.tmp`; - writeFileSync(tempPath, `${JSON.stringify(persisted, null, 2)}\n`, { mode: 0o600 }); - chmodSync(tempPath, 0o600); - renameSync(tempPath, this.supervisorConfigPath); + writeFileAtomicSync(this.supervisorConfigPath, `${JSON.stringify(persisted, null, 2)}\n`, { mode: 0o600 }); } private hasPersistedWorkerDescriptors(): boolean { @@ -1408,10 +1397,7 @@ export class DaemonSupervisor { private persistWorker(worker: ResidentWorker): void { worker.descriptor.updatedAt = new Date().toISOString(); const persisted = durableDaemonWorkerDescriptor(worker.descriptor); - const tempPath = `${worker.descriptorPath}.${process.pid}.tmp`; - writeFileSync(tempPath, `${JSON.stringify(persisted, null, 2)}\n`, { mode: 0o600 }); - chmodSync(tempPath, 0o600); - renameSync(tempPath, worker.descriptorPath); + writeFileAtomicSync(worker.descriptorPath, `${JSON.stringify(persisted, null, 2)}\n`, { mode: 0o600 }); } private deleteWorkerDescriptor(worker: { descriptorPath: string; descriptor: DaemonWorkerDescriptor }): void { @@ -2208,14 +2194,7 @@ export class DaemonSupervisor { if ((this.workerStopCounts?.get(worker) ?? 0) > 0) { throw new Error("Session worker is stopping; retry after it finishes"); } - worker.intentionalStop = false; - worker.descriptor.stopRequestedAt = undefined; - worker.descriptor.archiveOnStop = undefined; - worker.descriptor.lifecycle = "recovering"; - worker.descriptor.consecutiveFailures = 0; - worker.deferredRecoveryRounds = 0; - this.persistWorker(worker); - await this.recoverWorker(worker); + await this.retryWorkerRecovery(worker); if (this.workers.get(worker.descriptor.workerId)?.descriptor.lifecycle !== "ready") { throw new Error(worker.descriptor.lastError ?? "Session worker recovery failed"); } @@ -2923,13 +2902,15 @@ export class DaemonSupervisor { ownerClientId: string | undefined, sessionPath: string, ): Promise { - if (worker.descriptor.lifecycle === "failed") { + if (worker.descriptor.lifecycle === "failed" && !this.canRetryFailedWorker(worker)) { throw new Error( `Session "${sessionPath}" is registered to a failed worker that could not be safely reclaimed`, ); } this.assertWorkerCreateOwner(worker, ownerClientId, sessionPath); - if (!this.isWorkerReadyForCreate(worker)) { + if (this.canRetryFailedWorker(worker)) { + await this.retryWorkerRecovery(worker); + } else if (!this.isWorkerReadyForCreate(worker)) { if (worker.recovery) { await worker.recovery; } else if (this.isWorkerRecoveryEligible(worker)) { @@ -3595,6 +3576,28 @@ export class DaemonSupervisor { return this.isWorkerRecoveryCandidate(worker) && worker.recovery === undefined; } + /** Failed is not terminal for an identity-verified live worker: any touch retries recovery, like manual retry_worker. */ + private canRetryFailedWorker(worker: ResidentWorker): boolean { + return ( + worker.descriptor.lifecycle === "failed" && + (this.workerStopCounts?.get(worker) ?? 0) === 0 && + // A user-stopped worker stays stopped; only an explicit retry_worker clears the persisted stop markers. + !this.isWorkerStopping(worker) && + this.processIdentity(worker.descriptor.pid, worker.descriptor.processStartId) === "current" + ); + } + + private async retryWorkerRecovery(worker: ResidentWorker): Promise { + worker.intentionalStop = false; + worker.descriptor.stopRequestedAt = undefined; + worker.descriptor.archiveOnStop = undefined; + worker.descriptor.lifecycle = "recovering"; + worker.descriptor.consecutiveFailures = 0; + worker.deferredRecoveryRounds = 0; + this.persistWorker(worker); + await this.recoverWorker(worker); + } + private isWorkerRecoveryCandidate(worker: ResidentWorker): boolean { return ( !this.shuttingDown && @@ -3610,7 +3613,8 @@ export class DaemonSupervisor { return; } // A live-but-silent worker must not probe forever: park it failed (user-visible through the - // roster's failed status) and keep its process alive for a manual retry_worker. + // roster's failed status) and keep its process alive. The park is not terminal: retry_worker, + // attach, and create all retry recovery while the process identity stays current. worker.deferredRecoveryRounds = (worker.deferredRecoveryRounds ?? 0) + 1; if (worker.deferredRecoveryRounds > MAX_DEFERRED_RECOVERY_ROUNDS) { worker.descriptor.lifecycle = "failed"; @@ -3705,6 +3709,33 @@ export class DaemonSupervisor { } } + /** Settle a transfer anomaly with the transfer as the blast radius: the worker channel stays up and clients resync fresh. */ + private failSnapshotTransfer( + worker: ResidentWorker, + activeSessionId: string, + snapshotId: string, + error: Error, + snapshotPurpose: Extract["snapshotPurpose"], + ): void { + const published = worker.transcriptCaches.get(activeSessionId)?.snapshotId === snapshotId; + this.failWorkerSnapshotCache(worker, activeSessionId, error, false, snapshotId); + // The published-cache drop drives the resync, not the frame's purpose: a published transfer + // can be serving any client's catch-up wait, whose queue entry drainClientCatchups already cleared. + if (published) { + this.queueSnapshotResync(activeSessionId, snapshotPurpose === "replacement" ? "replacement" : "catchup"); + } + } + + private queueSnapshotResync(activeSessionId: string, snapshotPurpose: "replacement" | "catchup"): void { + for (const client of this.clients) { + if (!client.attachedActiveSessionIds.has(activeSessionId)) continue; + this.queueCatchup(client, activeSessionId, snapshotPurpose === "replacement" ? "replacement" : "resync"); + void this.catchUpClient(client).catch((error) => + this.log(`Failed to catch up client ${client.id}: ${String(error)}`), + ); + } + } + private retireWorkerSnapshotCache( worker: ResidentWorker, activeSessionId: string, @@ -4831,6 +4862,31 @@ export class DaemonSupervisor { if (matches.length > 1) { throw new Error(`Ambiguous active session "${selector}"`); } + // Descriptors are the durable half of addressability: an unhydrated root is recovering, not unknown; + // failed workers stay unknown so clients take the create fallback, which reclaims or retries them. + const recoveringRoots = (matchesSelector: (worker: ResidentWorker) => boolean) => + [...this.workers.values()].filter( + (worker) => + matchesSelector(worker) && + (!includeWorker || includeWorker(worker)) && + worker.descriptor.lifecycle !== "failed" && + this.isWorkerRecoveryCandidate(worker), + ); + // matchWorkers' addressing rule: exact ids first, unambiguous hex suffixes second. + const exactRecovering = recoveringRoots( + (worker) => worker.descriptor.rootActiveSessionId === selector || worker.descriptor.rootSessionId === selector, + ); + const recoveringMatches = + exactRecovering.length > 0 + ? exactRecovering + : recoveringRoots( + (worker) => + matchesSessionIdSuffix(worker.descriptor.rootActiveSessionId, selector) || + matchesSessionIdSuffix(worker.descriptor.rootSessionId ?? "", selector), + ); + if (recoveringMatches.length === 1) { + throw new DaemonSessionRecoveringError(recoveringMatches[0]!.descriptor.rootActiveSessionId); + } throw new Error(`Unknown active session: ${selector}`); } @@ -4931,6 +4987,14 @@ export class DaemonSupervisor { command: DaemonCommand, timeoutMs = WORKER_REQUEST_TIMEOUT_MS, ): Promise { + // Every forwarded command is a touch: a cached failed roster row must not outrank + // the descriptor truth that the worker is recoverable (--attach-agent's get_state preflight lands here). + if (this.canRetryFailedWorker(worker)) { + await this.retryWorkerRecovery(worker); + } else if (worker.recovery) { + // Join a concurrent touch's in-flight recovery instead of throwing mid-ladder. + await worker.recovery; + } const client = this.requireAvailableWorkerClient(worker, command.type === "kill"); const response = await client.request(withoutCommandId(command), timeoutMs); if (command.type === "get_state" && response.success && isSessionSummary(response.data)) { @@ -4947,46 +5011,47 @@ export class DaemonSupervisor { client: DaemonSocketClient, command: Extract, ): Promise { - const ownedWorker = [...this.workers.values()].find( + const descriptorWorker = [...this.workers.values()].find( (worker) => - worker.descriptor.ownerClientId !== undefined && - (worker.descriptor.rootActiveSessionId === command.activeSessionId || - worker.descriptor.rootSessionId === command.activeSessionId), + worker.descriptor.rootActiveSessionId === command.activeSessionId || + worker.descriptor.rootSessionId === command.activeSessionId, ); - if (ownedWorker) { - if (ownedWorker.descriptor.ownerClientId !== this.protocolClientId(client)) { - throw new Error(`Unknown active session: ${command.activeSessionId}`); - } - this.assertTelemetryAttachAllowed(ownedWorker, command.telemetryDisabled); - ownedWorker.launchEnv = command.launchEnv ?? ownedWorker.launchEnv; - if (!ownedWorker.client || ownedWorker.descriptor.lifecycle !== "ready") { - if (command.recoveryConfig) { - ownedWorker.transientCreateCommand = { - ...ownedWorker.descriptor.createCommand, - config: { - ...command.recoveryConfig, - ...(ownedWorker.descriptor.telemetryDisabled === true ? { telemetryDisabled: true } : {}), - }, - env: command.env, - launchEnv: command.launchEnv, - lifecycle: "client_owned", - }; + if (descriptorWorker) { + // The descriptor lookup is universal; owner-only attach payload stays in the owned branch. + if (descriptorWorker.descriptor.ownerClientId !== undefined) { + if (descriptorWorker.descriptor.ownerClientId !== this.protocolClientId(client)) { + throw new Error(`Unknown active session: ${command.activeSessionId}`); } - if (!ownedWorker.launchEnv) { - throw new Error("Client-owned session recovery requires the owning client environment"); + this.assertTelemetryAttachAllowed(descriptorWorker, command.telemetryDisabled); + descriptorWorker.launchEnv = command.launchEnv ?? descriptorWorker.launchEnv; + if (!descriptorWorker.client || descriptorWorker.descriptor.lifecycle !== "ready") { + if (command.recoveryConfig) { + descriptorWorker.transientCreateCommand = { + ...descriptorWorker.descriptor.createCommand, + config: { + ...command.recoveryConfig, + ...(descriptorWorker.descriptor.telemetryDisabled === true ? { telemetryDisabled: true } : {}), + }, + env: command.env, + launchEnv: command.launchEnv, + lifecycle: "client_owned", + }; + } + if (!descriptorWorker.launchEnv) { + throw new Error("Client-owned session recovery requires the owning client environment"); + } + await this.retryWorkerRecovery(descriptorWorker); } - ownedWorker.intentionalStop = false; - ownedWorker.descriptor.stopRequestedAt = undefined; - ownedWorker.descriptor.archiveOnStop = undefined; - ownedWorker.descriptor.lifecycle = "recovering"; - ownedWorker.descriptor.consecutiveFailures = 0; - ownedWorker.deferredRecoveryRounds = 0; - this.persistWorker(ownedWorker); - await this.recoverWorker(ownedWorker); + } else if (this.canRetryFailedWorker(descriptorWorker)) { + await this.retryWorkerRecovery(descriptorWorker); } } const match = await this.findWorkerForClient(client, command.activeSessionId); this.assertTelemetryAttachAllowed(match.worker, command.telemetryDisabled); + if (match.worker !== descriptorWorker && this.canRetryFailedWorker(match.worker)) { + // Child-session attaches land here without a descriptor match; one touch runs at most one ladder. + await this.retryWorkerRecovery(match.worker); + } this.requireAvailableWorkerClient(match.worker); const activeSessionId = match.summary.activeSessionId ?? match.summary.id; const duplicateValidation = this.currentSnapshotGeneration(match.worker, activeSessionId)?.validation; @@ -5511,12 +5576,12 @@ export class DaemonSupervisor { const generations = this.snapshotGenerationsFor(worker, activeSessionId); let generation = generations.get(begin.snapshotId); if (generation?.incoming) { - this.failWorkerSnapshotCache( + this.failSnapshotTransfer( worker, activeSessionId, - new Error(`Snapshot ${begin.snapshotId} restarted before completion`), - true, begin.snapshotId, + new Error(`Snapshot ${begin.snapshotId} restarted before completion`), + snapshotPurpose, ); return; } @@ -5533,12 +5598,12 @@ export class DaemonSupervisor { generation.result.snapshot.lastEventCursor?.generation === result.snapshot.lastEventCursor?.generation && generation.result.snapshot.lastEventCursor?.sequence === result.snapshot.lastEventCursor?.sequence; if (generation?.transcript.complete && !duplicate) { - this.failWorkerSnapshotCache( + this.failSnapshotTransfer( worker, activeSessionId, - new Error(`Snapshot ${begin.snapshotId} did not match the cached transfer`), - true, begin.snapshotId, + new Error(`Snapshot ${begin.snapshotId} did not match the cached transfer`), + snapshotPurpose, ); return; } @@ -5647,12 +5712,12 @@ export class DaemonSupervisor { generation.duplicateChunkIndex = duplicateIndex + 1; } } catch (error) { - this.failWorkerSnapshotCache( + this.failSnapshotTransfer( worker, activeSessionId, - error instanceof Error ? error : new Error(String(error)), - true, generation.transcript.snapshotId, + error instanceof Error ? error : new Error(String(error)), + snapshotPurpose, ); } } @@ -5704,12 +5769,12 @@ export class DaemonSupervisor { generation.duplicateChunkIndex = undefined; generation.duplicateResult = undefined; } catch (error) { - this.failWorkerSnapshotCache( + this.failSnapshotTransfer( worker, activeSessionId, - error instanceof Error ? error : new Error(String(error)), - true, transcript.snapshotId, + error instanceof Error ? error : new Error(String(error)), + snapshotPurpose, ); return; } @@ -5719,13 +5784,7 @@ export class DaemonSupervisor { transcript.dispose(); } if (published && (snapshotPurpose === "replacement" || snapshotPurpose === "catchup")) { - for (const client of this.clients) { - if (!client.attachedActiveSessionIds.has(activeSessionId)) continue; - this.queueCatchup(client, activeSessionId, snapshotPurpose === "replacement" ? "replacement" : "resync"); - void this.catchUpClient(client).catch((error) => - this.log(`Failed to catch up client ${client.id}: ${String(error)}`), - ); - } + this.queueSnapshotResync(activeSessionId, snapshotPurpose); } return; } @@ -5751,21 +5810,13 @@ export class DaemonSupervisor { if (!generation) { return; } - const published = worker.transcriptCaches.get(activeSessionId) === generation.transcript; - this.failWorkerSnapshotCache(worker, activeSessionId, new Error(failed.error), false, failed.snapshotId); - if (published && (snapshotPurpose === "replacement" || snapshotPurpose === "catchup")) { - for (const client of this.clients) { - if (!client.attachedActiveSessionIds.has(activeSessionId)) continue; - this.queueCatchup( - client, - activeSessionId, - snapshotPurpose === "replacement" ? "replacement" : "resync", - ); - void this.catchUpClient(client).catch((error) => - this.log(`Failed to catch up client ${client.id}: ${String(error)}`), - ); - } - } + this.failSnapshotTransfer( + worker, + activeSessionId, + failed.snapshotId, + new Error(failed.error), + snapshotPurpose, + ); } catch (error) { this.failWorkerSnapshotCache( worker, @@ -6212,14 +6263,15 @@ export class DaemonSupervisor { } const path = getDaemonUpdateRestartManifestPath(this.socketPath, agentDir); mkdirSync(dirname(path), { recursive: true, mode: 0o700 }); - const tempPath = `${path}.${process.pid}.tmp`; - writeFileSync(tempPath, `${JSON.stringify(manifest)}\n`, { mode: 0o600 }); - chmodSync(tempPath, 0o600); - const validated = JSON.parse(readFileSync(tempPath, "utf8")) as DaemonUpdateRestartManifest; - if (!Array.isArray(validated.sessions) || validated.sessions.length !== manifest.sessions.length) { - throw new Error("Could not validate aggregate update manifest"); - } - renameSync(tempPath, path); + writeFileAtomicSync(path, `${JSON.stringify(manifest)}\n`, { + mode: 0o600, + beforeRename: (tempPath) => { + const validated = JSON.parse(readFileSync(tempPath, "utf8")) as DaemonUpdateRestartManifest; + if (!Array.isArray(validated.sessions) || validated.sessions.length !== manifest.sessions.length) { + throw new Error("Could not validate aggregate update manifest"); + } + }, + }); } /** diff --git a/packages/coding-agent/src/modes/daemon/rlm-ledger.ts b/packages/coding-agent/src/modes/daemon/rlm-ledger.ts index dd5288b0e..f8900ae23 100644 --- a/packages/coding-agent/src/modes/daemon/rlm-ledger.ts +++ b/packages/coding-agent/src/modes/daemon/rlm-ledger.ts @@ -119,12 +119,13 @@ export interface RlmLedgerSeedSource { export async function readLegacyRlmSubagentRegistry( path: string, - options: { throwOnReadError?: boolean; log?: (message: string) => void } = {}, + options: { throwOnReadError?: boolean; log?: (message: string) => void; onReadError?: () => void } = {}, ): Promise { let contents: string; try { contents = await readFile(path, "utf8"); } catch (error) { + options.onReadError?.(); if ((error as NodeJS.ErrnoException).code !== "ENOENT") { options.log?.( `failed to read RLM subagent registry: ${error instanceof Error ? error.message : String(error)}`, diff --git a/packages/coding-agent/src/modes/daemon/rlm-subagent-display.ts b/packages/coding-agent/src/modes/daemon/rlm-subagent-display.ts index 7f03cb5c8..271993d85 100644 --- a/packages/coding-agent/src/modes/daemon/rlm-subagent-display.ts +++ b/packages/coding-agent/src/modes/daemon/rlm-subagent-display.ts @@ -1,6 +1,7 @@ -import { closeSync, fsyncSync, mkdirSync, openSync, renameSync, rmSync, writeSync } from "node:fs"; +import { mkdirSync } from "node:fs"; import { readFile } from "node:fs/promises"; import { join } from "node:path"; +import { writeFileAtomicSync } from "../../utils/atomic-file.js"; /** * Per-child RLM subagent hydration/display metadata. @@ -54,28 +55,18 @@ function isRlmSubagentDisplayEntry(value: unknown): value is RlmSubagentDisplayE export function writeRlmSubagentDisplayEntry(entry: RlmSubagentDisplayEntry): void { const path = rlmSubagentDisplayPath(entry.sessionDir); mkdirSync(entry.sessionDir, { recursive: true }); - const tempPath = `${path}.tmp-${process.pid}-${Date.now()}`; - const handle = openSync(tempPath, "wx", 0o600); - try { - try { - writeSync(handle, `${JSON.stringify(entry)}\n`); - fsyncSync(handle); - } finally { - closeSync(handle); - } - renameSync(tempPath, path); - } catch (error) { - // A failed write, fsync, or rename must not leak the temp file. - rmSync(tempPath, { force: true }); - throw error; - } + writeFileAtomicSync(path, `${JSON.stringify(entry)}\n`, { mode: 0o600, fsync: true }); } -export async function readRlmSubagentDisplayEntry(sessionDir: string): Promise { +export async function readRlmSubagentDisplayEntry( + sessionDir: string, + onReadError?: () => void, +): Promise { let contents: string; try { contents = await readFile(rlmSubagentDisplayPath(sessionDir), "utf8"); } catch { + onReadError?.(); return undefined; } try { diff --git a/packages/coding-agent/src/utils/atomic-file.ts b/packages/coding-agent/src/utils/atomic-file.ts new file mode 100644 index 000000000..8b4076048 --- /dev/null +++ b/packages/coding-agent/src/utils/atomic-file.ts @@ -0,0 +1,119 @@ +import { randomUUID } from "node:crypto"; +import { + chmodSync, + closeSync, + fsyncSync, + openSync, + readlinkSync, + realpathSync, + renameSync, + rmSync, + writeSync, +} from "node:fs"; +import { dirname, resolve } from "node:path"; + +const WIN32_RENAME_ATTEMPTS = 5; + +function sleepSync(ms: number): void { + Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, ms); +} + +// Windows raises transient EPERM/EACCES when the destination is held open (antivirus, indexer). +function renameOntoSync(from: string, to: string): void { + for (let attempt = 1; ; attempt++) { + try { + renameSync(from, to); + return; + } catch (error) { + const code = (error as NodeJS.ErrnoException).code; + if ( + process.platform !== "win32" || + (code !== "EPERM" && code !== "EACCES" && code !== "EBUSY") || + attempt >= WIN32_RENAME_ATTEMPTS + ) { + throw error; + } + sleepSync(10 * attempt); + } + } +} + +export interface WriteFileAtomicOptions { + mode?: number; + /** fsync the temp file before the rename. */ + fsync?: boolean; + /** Best-effort directory fsync after the rename. */ + fsyncDir?: boolean; + /** Runs on the written temp file before it replaces the destination (validation, ownership). */ + beforeRename?: (tempPath: string) => void; +} + +/** Durable-write owner: temp file beside the destination, then an atomic rename. */ +export function writeFileAtomicSync(path: string, data: string, options: WriteFileAtomicOptions = {}): void { + const tempPath = `${path}.${process.pid}.${randomUUID()}.tmp`; + try { + const descriptor = options.mode === undefined ? openSync(tempPath, "wx") : openSync(tempPath, "wx", options.mode); + try { + // writeSync may return a short count without throwing; a partial temp must never be renamed in. + const bytes = Buffer.from(data, "utf8"); + let offset = 0; + while (offset < bytes.length) { + const written = writeSync(descriptor, bytes, offset, bytes.length - offset); + if (written <= 0) throw new Error(`Short write persisting ${path}`); + offset += written; + } + if (options.fsync) fsyncSync(descriptor); + } finally { + closeSync(descriptor); + } + // openSync's mode is masked by the umask; enforce the requested bits exactly. + if (options.mode !== undefined) chmodSync(tempPath, options.mode); + options.beforeRename?.(tempPath); + renameOntoSync(tempPath, path); + } finally { + rmSync(tempPath, { force: true }); + } + if (options.fsyncDir) { + try { + const directoryDescriptor = openSync(dirname(path), "r"); + try { + fsyncSync(directoryDescriptor); + } finally { + closeSync(directoryDescriptor); + } + } catch { + // Unavailable on some platforms; the atomic rename still protects readers. + } + } +} + +/** Resolve symlink aliases so a replace lands on the real file (in-place-write parity). */ +export function realpathIfPresentSync(path: string): string { + try { + return realpathSync(path); + } catch (error) { + if ((error as NodeJS.ErrnoException).code !== "ENOENT") { + throw error; + } + } + // ENOENT also means a DANGLING symlink chain: follow it like in-place writes did. + let current = path; + for (let hop = 0; hop < 32; hop++) { + let target: string; + try { + target = readlinkSync(current); + } catch { + return current; + } + // A relative target resolves against the link's PHYSICAL parent directory. + let parent = dirname(current); + try { + parent = realpathSync(parent); + } catch { + // Fall back to the alias parent. + } + current = resolve(parent, target); + } + // A loud failure beats silently replacing an intermediate link (or looping on a cycle). + throw new Error(`Too many symlink hops resolving ${path}`); +} diff --git a/packages/coding-agent/src/utils/child-process.ts b/packages/coding-agent/src/utils/child-process.ts index 3f587327f..e6f59ef83 100644 --- a/packages/coding-agent/src/utils/child-process.ts +++ b/packages/coding-agent/src/utils/child-process.ts @@ -51,6 +51,53 @@ export function isProcessAlive(pid: number): boolean { return processIdExists(pid) && !isZombieProcess(pid); } +/** True while the group has any member left, zombies included; a group can outlive its leader. */ +export function processGroupExists(pgid: number): boolean { + if (process.platform === "win32") { + return false; + } + try { + process.kill(-pgid, 0); + return true; + } catch (error) { + return (error as NodeJS.ErrnoException).code === "EPERM"; + } +} + +/** True while the group has a RUNNING member; unreaped zombies have exited and must not block a group stop. */ +export function processGroupHasLiveMember(pgid: number): boolean { + if (!processGroupExists(pgid)) { + return false; + } + try { + const listing = execFileSync("ps", ["-A", "-o", "pgid=", "-o", "stat="], { encoding: "utf8" }); + for (const line of listing.split("\n")) { + const fields = line.trim().split(/\s+/); + if (fields.length < 2) continue; + if (Number(fields[0]) === pgid && !fields[1]!.startsWith("Z")) { + return true; + } + } + return false; + } catch { + // Unverifiable listing reads alive: callers keep escalating instead of dropping records over live descendants. + return true; + } +} + +/** + * Signal the group only while it is provably still the target: the leader process (even a zombie) + * anchors its pgid against reuse; once the leader is gone, a live member must hold the pgid at + * signal time, narrowing reuse exposure to the inherent kill() TOCTOU of any single-pid signal. + */ +export function signalProcessGroupIfHeld(pgid: number, signal: NodeJS.Signals): boolean { + if (!processIdExists(pgid) && !processGroupHasLiveMember(pgid)) { + return false; + } + signalProcessGroupOrProcess(pgid, signal); + return true; +} + export function signalProcessGroupOrProcess(pid: number, signal: NodeJS.Signals): void { try { process.kill(-pid, signal); diff --git a/packages/coding-agent/src/utils/dir-lock.ts b/packages/coding-agent/src/utils/dir-lock.ts new file mode 100644 index 000000000..9311cb44b --- /dev/null +++ b/packages/coding-agent/src/utils/dir-lock.ts @@ -0,0 +1,207 @@ +import { randomUUID } from "node:crypto"; +import { + closeSync, + fstatSync, + linkSync, + openSync, + readdirSync, + readFileSync, + renameSync, + rmSync, + statSync, + writeFileSync, +} from "node:fs"; +import { basename, dirname, join } from "node:path"; + +export type DirLockAttempt = "acquired" | "held" | "reclaimed"; + +/** + * link(2)-published lock file: born with its owner content, EEXIST the only + * collision signal; stale locks are renamed aside, verified, then deleted or + * restored. A directory at the lock path is a legacy lock from the old protocol. + */ +const CANDIDATE_SWEEP_AGE_MS = 60 * 60 * 1000; + +// The candidate prefix can never match the lock; the age gate spares mid-publish rivals. +function sweepAbandonedCandidates(lockPath: string): void { + try { + const directory = dirname(lockPath); + const prefix = `${basename(lockPath)}.candidate-`; + const cutoff = Date.now() - CANDIDATE_SWEEP_AGE_MS; + for (const name of readdirSync(directory)) { + if (!name.startsWith(prefix)) continue; + try { + const abandoned = join(directory, name); + if (statSync(abandoned).mtimeMs < cutoff) { + rmSync(abandoned, { force: true }); + } + } catch { + // Litter collection only. + } + } + } catch { + // Litter collection only: the next acquire retries. + } +} + +export async function tryAcquireDirLock( + lockPath: string, + ownerAlive: (ownerPid: number | undefined) => Promise | boolean, +): Promise { + sweepAbandonedCandidates(lockPath); + return acquireAttempt(lockPath, ownerAlive, true); +} + +async function acquireAttempt( + lockPath: string, + ownerAlive: (ownerPid: number | undefined) => Promise | boolean, + retryOnSweptCandidate: boolean, +): Promise { + const token = `${process.pid}-${randomUUID()}`; + const tempPath = `${lockPath}.candidate-${token}`; + writeFileSync(tempPath, `${process.pid}\n`, { mode: 0o600 }); + try { + try { + linkSync(tempPath, lockPath); + return "acquired"; + } catch (error) { + // NFS can report failure for a link that landed: nlink 2 means it published. + let candidateSwept = false; + let recheckedNlink: number | undefined; + try { + recheckedNlink = statSync(tempPath).nlink; + } catch (statError) { + // Only a definite ENOENT means the candidate was swept. + if ((statError as NodeJS.ErrnoException).code !== "ENOENT") { + throw error; + } + candidateSwept = true; + } + if (recheckedNlink === 2) { + return "acquired"; + } + if (candidateSwept && retryOnSweptCandidate) { + return acquireAttempt(lockPath, ownerAlive, false); + } + if ((error as NodeJS.ErrnoException).code !== "EEXIST") { + throw error; + } + } + // One immutable dev+ino capture keys every later decision about the judged lock. + let captured: { dev: bigint; ino: bigint; isDir: boolean }; + try { + const measured = statSync(lockPath, { bigint: true }); + captured = { dev: measured.dev, ino: measured.ino, isDir: measured.isDirectory() }; + } catch (error) { + if ((error as NodeJS.ErrnoException).code === "ENOENT") { + return "reclaimed"; + } + // An unjudgeable lock may be live: fail safe. + return "held"; + } + if (captured.ino === 0n) { + // Some Windows filesystems report no stable file index: identity unavailable. + return "held"; + } + // An open descriptor pins the inode number against Linux's immediate reuse. + let pinned: number | undefined; + try { + try { + pinned = openSync(lockPath, "r"); + const pinnedIdentity = fstatSync(pinned, { bigint: true }); + if (pinnedIdentity.dev !== captured.dev || pinnedIdentity.ino !== captured.ino) { + // The lock changed hands between the capture and the pin: treat as live. + return "held"; + } + } catch { + // Unpinnable (Windows directories): stat identity without the reuse guarantee. + } + return await judgeAndReclaim(lockPath, ownerAlive, captured, token); + } finally { + if (pinned !== undefined) closeSync(pinned); + } + } finally { + try { + rmSync(tempPath, { force: true }); + } catch { + // Cleanup only: a leaked candidate must never mask a settled acquisition. + } + } +} + +async function judgeAndReclaim( + lockPath: string, + ownerAlive: (ownerPid: number | undefined) => Promise | boolean, + captured: { dev: bigint; ino: bigint; isDir: boolean }, + token: string, +): Promise { + { + const judged = readOwnerRaw(lockPath, captured.isDir); + if (judged === "unreadable") { + // A transient read failure may hide a LIVE lock: never judge it stale. + return "held"; + } + if (await ownerAlive(strictPid(judged === "absent" ? undefined : judged))) { + return "held"; + } + const asidePath = `${lockPath}.stale-${token}`; + try { + renameSync(lockPath, asidePath); + } catch (reclaimError) { + // ENOENT: a racing reclaimer moved it first. + if ((reclaimError as NodeJS.ErrnoException).code === "ENOENT") { + return "reclaimed"; + } + // A lost-reply rename: if the lock path is gone, something moved - fall to verify. + if (statIdentity(lockPath) !== undefined) { + throw reclaimError; + } + } + const aside = statIdentity(asidePath); + if (aside !== undefined && aside.dev === captured.dev && aside.ino === captured.ino) { + rmSync(asidePath, { recursive: true, force: true }); + return "reclaimed"; + } + // Not the judged lock: restore, never delete. Known dirs rename back; everything + // else links back (link can never replace a rival). Any failure leaves it aside. + try { + if (aside?.isDir === true) { + renameSync(asidePath, lockPath); + } else { + linkSync(asidePath, lockPath); + rmSync(asidePath, { force: true }); + } + } catch { + // Preserved aside. + } + return "held"; + } +} + +function statIdentity(path: string): { dev: bigint; ino: bigint; isDir: boolean } | undefined { + try { + const measured = statSync(path, { bigint: true }); + return { dev: measured.dev, ino: measured.ino, isDir: measured.isDirectory() }; + } catch { + return undefined; + } +} + +// "absent" is safely stale territory; "unreadable" may be a LIVE lock (transient EPERM/EBUSY). +function readOwnerRaw(path: string, legacyDir: boolean): string | "absent" | "unreadable" { + try { + return readFileSync(legacyDir ? join(path, "pid") : path, "utf8"); + } catch (error) { + return (error as NodeJS.ErrnoException).code === "ENOENT" ? "absent" : "unreadable"; + } +} + +// kill(0)/kill(-n) probe our own process group: only an exact positive integer owns. +function strictPid(raw: string | undefined): number | undefined { + const trimmed = raw?.trim(); + if (trimmed === undefined || !/^\d+$/.test(trimmed)) { + return undefined; + } + const parsed = Number.parseInt(trimmed, 10); + return Number.isInteger(parsed) && parsed > 0 ? parsed : undefined; +} diff --git a/packages/coding-agent/src/utils/exif-orientation.ts b/packages/coding-agent/src/utils/exif-orientation.ts index ac34c790a..1c9b6572f 100644 --- a/packages/coding-agent/src/utils/exif-orientation.ts +++ b/packages/coding-agent/src/utils/exif-orientation.ts @@ -14,7 +14,7 @@ function readOrientationFromTiff(bytes: Uint8Array, tiffStart: number): number { }; const read32 = (pos: number): number => { - if (le) return bytes[pos] | (bytes[pos + 1] << 8) | (bytes[pos + 2] << 16) | (bytes[pos + 3] << 24); + if (le) return (bytes[pos] | (bytes[pos + 1] << 8) | (bytes[pos + 2] << 16) | (bytes[pos + 3] << 24)) >>> 0; return ((bytes[pos] << 24) | (bytes[pos + 1] << 16) | (bytes[pos + 2] << 8) | bytes[pos + 3]) >>> 0; }; @@ -66,8 +66,9 @@ function findWebpTiffOffset(bytes: Uint8Array): number { let offset = 12; while (offset + 8 <= bytes.length) { const chunkId = String.fromCharCode(bytes[offset], bytes[offset + 1], bytes[offset + 2], bytes[offset + 3]); + // Unsigned: a high-bit chunk size read as negative would walk the scan backward forever. const chunkSize = - bytes[offset + 4] | (bytes[offset + 5] << 8) | (bytes[offset + 6] << 16) | (bytes[offset + 7] << 24); + (bytes[offset + 4] | (bytes[offset + 5] << 8) | (bytes[offset + 6] << 16) | (bytes[offset + 7] << 24)) >>> 0; const dataStart = offset + 8; if (chunkId === "EXIF") { diff --git a/packages/coding-agent/src/utils/file-lines.ts b/packages/coding-agent/src/utils/file-lines.ts index 84f226512..2550c4db0 100644 --- a/packages/coding-agent/src/utils/file-lines.ts +++ b/packages/coding-agent/src/utils/file-lines.ts @@ -34,10 +34,34 @@ export function readFirstLineSync(filePath: string, maxBytes = 64 * 1024): strin return Buffer.concat(chunks).toString("utf8").replace(/\r$/, ""); } -export async function* readLinesAsBuffers(filePath: string): AsyncGenerator { +/** Read the bytes in [start, endExclusive), stopping early at EOF. */ +export function readBytesSync(filePath: string, start: number, endExclusive: number): Buffer { + const length = Math.max(0, endExclusive - start); + const buffer = Buffer.alloc(length); + const fd = openSync(filePath, "r"); + try { + let offset = 0; + while (offset < length) { + const bytesRead = readSync(fd, buffer, offset, length - offset, start + offset); + if (bytesRead === 0) break; + offset += bytesRead; + } + return buffer.subarray(0, offset); + } finally { + closeSync(fd); + } +} + +export interface ReadLinesRange { + start?: number; + /** Inclusive, as in createReadStream: bounds the read to a stat() snapshot so a growing file cannot extend the scan. */ + end?: number; +} + +export async function* readLinesAsBuffers(filePath: string, range?: ReadLinesRange): AsyncGenerator { const pendingParts: Buffer[] = []; let pendingBytes = 0; - for await (const chunk of createReadStream(filePath)) { + for await (const chunk of createReadStream(filePath, range)) { const buffer = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk); let start = 0; while (start < buffer.length) { diff --git a/packages/coding-agent/src/utils/frontmatter.ts b/packages/coding-agent/src/utils/frontmatter.ts index 847e2e539..69c280237 100644 --- a/packages/coding-agent/src/utils/frontmatter.ts +++ b/packages/coding-agent/src/utils/frontmatter.ts @@ -5,7 +5,11 @@ type ParsedFrontmatter> = { body: string; }; -const normalizeNewlines = (value: string): string => value.replace(/\r\n/g, "\n").replace(/\r/g, "\n"); +const normalizeNewlines = (value: string): string => + value + .replace(/^\uFEFF/, "") + .replace(/\r\n/g, "\n") + .replace(/\r/g, "\n"); const extractFrontmatter = (content: string): { yamlString: string | null; body: string } => { const normalized = normalizeNewlines(content); diff --git a/packages/coding-agent/test/agent-session-recursion.test.ts b/packages/coding-agent/test/agent-session-recursion.test.ts index 2dad4fcd9..4ef7ca7f7 100644 --- a/packages/coding-agent/test/agent-session-recursion.test.ts +++ b/packages/coding-agent/test/agent-session-recursion.test.ts @@ -20,6 +20,7 @@ import { } from "../src/core/agent-messages.js"; import { AgentSession, type RlmChildAgentSnapshot } from "../src/core/agent-session.js"; import { AuthStorage } from "../src/core/auth-storage.js"; +import { computeOwnAndTotalUsage } from "../src/core/context-tree.js"; import type { LoadExtensionsResult } from "../src/core/extensions/index.js"; import { type HostRequestHandlers, ReplKernelManager } from "../src/core/kernel/index.js"; import { convertToLlm } from "../src/core/messages.js"; @@ -30,7 +31,7 @@ import { createRlmRunHostHandler, type SubagentRuntimeHost, } from "../src/core/rlm-runtime.js"; -import { SessionManager } from "../src/core/session-manager.js"; +import { readSessionInfo, SessionManager } from "../src/core/session-manager.js"; import { SettingsManager, type SettingsStorage } from "../src/core/settings-manager.js"; import type { Skill } from "../src/core/skills.js"; import { createSyntheticSourceInfo } from "../src/core/source-info.js"; @@ -248,6 +249,51 @@ describe("AgentSession rlm recursion", () => { return session; } + /** Session with a zero-usage parent assistant whose child answers a tool loop: usages[i] per request, tool calls until the last, then stop. */ + function createToolLoopSession(requests: number, usages: Usage[], onRequest?: (toolResultCount: number) => void) { + const tool = { + name: "echo", + description: "Echo a value", + label: "echo", + parameters: Type.Object({ value: Type.String() }), + execute: async (_toolCallId: string, params: { value: string }) => ({ + content: [{ type: "text" as const, text: params.value }], + details: {}, + }), + }; + const root = createSession({ + customTools: [tool], + streamFn: (_model, context) => { + const toolResultCount = context.messages.filter((message) => message.role === "toolResult").length; + onRequest?.(toolResultCount); + const last = toolResultCount >= requests - 1; + const stream = createAssistantMessageEventStream(); + queueMicrotask(() => { + const message = last + ? assistantMessage("done", usages[toolResultCount]) + : { + ...assistantMessage("", usages[toolResultCount]), + content: [ + { + type: "toolCall" as const, + id: `echo-${toolResultCount}`, + name: "echo", + arguments: { value: "ok" }, + }, + ], + stopReason: "toolUse" as const, + }; + stream.push({ type: "done", reason: last ? "stop" : "toolUse", message }); + }); + return stream; + }, + }); + const parentAssistant = assistantMessage("running ipython", usage(0, 0)); + root.agent.state.messages.push(parentAssistant); + root.sessionManager.appendMessage(parentAssistant); + return root; + } + function createAbortInsensitiveChild(): { child: AgentSession; completion: ReturnType>; @@ -2462,56 +2508,323 @@ describe("AgentSession rlm recursion", () => { expect(attribution.aggregateUsage.cost.total).toBe(10); }); - it("attributes every tool-loop turn in the admitted task to spawn usage", async () => { - const tool = { - name: "echo", - description: "Echo a value", - label: "echo", - parameters: Type.Object({ value: Type.String() }), - execute: async (_toolCallId: string, params: { value: string }) => ({ - content: [{ type: "text" as const, text: params.value }], - details: {}, - }), - }; + it("retains child usage until a delayed parent message_end hook persists its assistant", async () => { + const hookEntered = deferred(); + const releaseHook = deferred(); + let parentAssistant: AssistantMessage | undefined; + let child: AgentSession | undefined; + const extensionsResult = await createTestExtensionsResult([ + (pi) => { + pi.on("message_end", async (event) => { + if ( + event.message.role === "assistant" && + event.message.content.some( + (block) => block.type === "toolCall" && block.name === "spawn_accounting_child", + ) + ) { + parentAssistant = event.message; + hookEntered.resolve(); + await releaseHook.promise; + } + }); + }, + ]); const root = createSession({ - customTools: [tool], + extensionsResult, + customTools: [ + { + name: "spawn_accounting_child", + description: "Spawn a child for the accounting test", + label: "spawn child", + parameters: Type.Object({}), + execute: async () => { + const admitted = await root.runRlmChild("charge child"); + child = root.getRlmChildSession(admitted.rlm_child_id); + return { content: [{ type: "text" as const, text: admitted.rlm_child_id }], details: {} }; + }, + }, + ], streamFn: (_model, context) => { - const toolResultCount = context.messages.filter((message) => message.role === "toolResult").length; + const text = userText(context); const stream = createAssistantMessageEventStream(); queueMicrotask(() => { - const message = - toolResultCount === 0 - ? { - ...assistantMessage("", usage(1, 1)), + if (text === "spawn accounting child") { + stream.push({ + type: "done", + reason: "toolUse", + message: { + ...assistantMessage("", usage(2, 1)), + content: [ + { type: "toolCall", id: "spawn-accounting", name: "spawn_accounting_child", arguments: {} }, + ], + stopReason: "toolUse", + }, + }); + } else { + stream.push({ + type: "done", + reason: "stop", + message: assistantMessage( + text === "charge child" ? "child done" : "parent done", + text === "charge child" ? usage(7, 3) : usage(0, 0), + ), + }); + } + }); + return stream; + }, + }); + const prompt = root.prompt("spawn accounting child"); + try { + await hookEntered.promise; + await waitFor(() => root.getRlmChildSnapshots().some((snapshot) => snapshot.status === "done")); + expect(child?.getLastAssistantText()).toBe("child done"); + expect(parentAssistant?.usage.cost.total).toBe(13); + expect( + root.sessionManager + .getEntries() + .some((entry) => entry.type === "message" && entry.message === parentAssistant), + ).toBe(false); + } finally { + releaseHook.resolve(); + await prompt; + await root.agent.waitForIdle(); + } + root.sessionManager.flushNow(); + const sessionFile = root.sessionFile; + if (!sessionFile || !child?.sessionFile) throw new Error("Missing persisted sessions"); + const reopenedEntries = SessionManager.open(sessionFile, join(tempDir, "sessions")).getEntries(); + const reopenedUsage = computeOwnAndTotalUsage(reopenedEntries, reopenedEntries); + const childEntries = SessionManager.open(child.sessionFile, join(tempDir, "sessions")).getEntries(); + const childUsage = computeOwnAndTotalUsage(childEntries, childEntries); + const attributions = reopenedEntries.filter((entry) => entry.type === "child_usage_attributed"); + expect({ + liveTotal: root.getSessionStats().cost, + liveOwn: root.getOwnUsageSummary()?.cost, + attributedChild: attributions.reduce((total, entry) => total + entry.childUsage.cost.total, 0), + reopenedTotal: reopenedUsage.totalUsage.cost.total, + reopenedOwn: reopenedUsage.ownUsage.cost.total, + scannedOwn: (await readSessionInfo(sessionFile))?.usage?.cost, + childLiveOwn: child.getOwnUsageSummary()?.cost, + childReopenedOwn: childUsage.ownUsage.cost.total, + childScannedOwn: (await readSessionInfo(child.sessionFile))?.usage?.cost, + }).toEqual({ + liveTotal: 13, + liveOwn: 3, + attributedChild: 10, + reopenedTotal: 13, + reopenedOwn: 3, + scannedOwn: 3, + childLiveOwn: 10, + childReopenedOwn: 10, + childScannedOwn: 10, + }); + }); + + it.each([ + { memoized: false, failFirstAppend: false }, + { memoized: true, failFirstAppend: false }, + { memoized: true, failFirstAppend: true }, + ])( + "keeps a sibling's pending usage out of own spend (memoized=$memoized, failedAppend=$failFirstAppend)", + async ({ memoized, failFirstAppend }) => { + const gates = new Map([ + ["A", deferred()], + ["B", deferred()], + ]); + const started = new Set(); + const root = createSession({ + customTools: [ + { + name: "hold", + description: "Wait for release", + label: "hold", + parameters: Type.Object({ child: Type.String() }), + execute: async (_toolCallId: string, params: { child: string }) => { + started.add(params.child); + await gates.get(params.child)!.promise; + return { content: [{ type: "text" as const, text: "released" }], details: {} }; + }, + }, + ], + streamFn: (_model, context) => { + const stream = createAssistantMessageEventStream(); + const child = userText(context); + const finished = context.messages.some((message) => message.role === "toolResult") || !gates.has(child); + queueMicrotask(() => { + const message = finished + ? assistantMessage("done", usage(0, 0)) + : { + ...assistantMessage("", child === "A" ? usage(7, 3) : usage(11, 5)), content: [ - { type: "toolCall" as const, id: "echo-1", name: "echo", arguments: { value: "ok" } }, + { type: "toolCall" as const, id: `hold-${child}`, name: "hold", arguments: { child } }, ], stopReason: "toolUse" as const, - } - : assistantMessage("done", usage(2, 2)); - stream.push({ - type: "done", - reason: toolResultCount === 0 ? "toolUse" : "stop", - message, + }; + stream.push({ type: "done", reason: finished ? "stop" : "toolUse", message }); }); + return stream; + }, + }); + const parentAssistant = assistantMessage("running ipython", usage(2, 1)); + root.agent.state.messages.push(parentAssistant); + root.sessionManager.appendMessage(parentAssistant); + const sessionFile = root.sessionManager.getSessionFile(); + if (!sessionFile) throw new Error("Missing parent session file"); + if (memoized) expect(root.getOwnUsageSummary()?.cost).toBe(3); + let failedAppend = false; + let appendFailureCode: string | undefined; + let attributionAttempts = 0; + const persist = root.sessionManager._persist.bind(root.sessionManager); + vi.spyOn(root.sessionManager, "_persist").mockImplementation((entry) => { + if (entry.type === "child_usage_attributed") attributionAttempts++; + if (failFirstAppend && !failedAppend && entry.type === "child_usage_attributed") { + failedAppend = true; + const content = readFileSync(sessionFile); + rmSync(sessionFile); + mkdirSync(sessionFile); + try { + persist(entry); + } catch (error) { + appendFailureCode = (error as NodeJS.ErrnoException).code; + throw error; + } finally { + rmSync(sessionFile, { recursive: true, force: true }); + writeFileSync(sessionFile, content); + } + return; + } + persist(entry); + }); + let otherBranchInput = 0; + const expectLiveOwnUsage = () => { + const tree = root.getContextTree(); + expect(tree.totalUsage.cost.total).toBe(29); + expect(tree.ownUsage.cost.total).toBe(3); + expect(root.getOwnUsageSummary()).toEqual({ + inputTokens: 2 + otherBranchInput, + outputTokens: 1, + cost: 3 + otherBranchInput, }); - return stream; - }, - }); - const parentAssistant = assistantMessage("running ipython", usage(0, 0)); - root.agent.state.messages.push(parentAssistant); - root.sessionManager.appendMessage(parentAssistant); + }; + const children: AgentSession[] = []; + const attributions = () => + root.sessionManager.getEntries().filter((entry) => entry.type === "child_usage_attributed"); + const expectDurableOwnUsage = async () => { + const reopened = SessionManager.open(sessionFile, join(tempDir, "sessions")); + const entries = reopened.getEntries(); + const { ownUsage } = computeOwnAndTotalUsage(entries, entries); + expect(ownUsage.input).toBe(2 + otherBranchInput); + expect(ownUsage.output).toBe(1); + expect(ownUsage.cost.total).toBe(3 + otherBranchInput); + expect((await readSessionInfo(sessionFile))?.usage).toEqual({ + inputTokens: 2 + otherBranchInput, + outputTokens: 1, + cost: 3 + otherBranchInput, + }); + }; + try { + for (const name of ["A", "B"]) { + const admitted = await root.runRlmChild(name); + const child = root.getRlmChildSession(admitted.rlm_child_id); + if (!child) throw new Error("Missing child session"); + children.push(child); + } + await waitFor(() => started.size === 2); + expect(parentAssistant.usage.cost.total).toBe(29); + expect(attributions()).toHaveLength(0); + expectLiveOwnUsage(); + const chargedBranch = root.sessionManager.appendCustomEntry("usage-test-marker", {}); + expectLiveOwnUsage(); + root.sessionManager.resetLeaf(); + root.sessionManager.appendMessage(assistantMessage("other branch", usage(5, 0))); + otherBranchInput = 5; + const otherTree = root.getContextTree(); + expect(otherTree.ownUsage.cost.total).toBe(5); + expect(otherTree.totalUsage.cost.total).toBe(5); + expect(root.getOwnUsageSummary()?.cost).toBe(8); + root.sessionManager.branch(chargedBranch); + expectLiveOwnUsage(); + gates.get("A")!.resolve(); + await waitFor(() => attributions().length === 1); + expect(attributions()[0]?.aggregateUsage.cost.total).toBe(13); + expect(parentAssistant.usage.cost.total).toBe(29); + expect(parentAssistant.usage.totalTokens).toBe(3); + expectLiveOwnUsage(); + await expectDurableOwnUsage(); + gates.get("B")!.resolve(); + await waitFor(() => attributions().length === 2); + expect(attributions().map((entry) => entry.aggregateUsage.cost.total)).toEqual([ + 13, + failFirstAppend ? 19 : 29, + ]); + expect(parentAssistant.usage.cost.total).toBe(29); + expect(parentAssistant.usage.totalTokens).toBe(3); + expectLiveOwnUsage(); + await expectDurableOwnUsage(); + expect(failedAppend).toBe(failFirstAppend); + expect(appendFailureCode).toBe(failFirstAppend ? "EISDIR" : undefined); + await waitFor(() => root.getRlmChildSnapshots().every((snapshot) => snapshot.status === "done")); + expect(attributionAttempts).toBe(2); + } finally { + for (const gate of gates.values()) gate.resolve(); + await Promise.all(children.map((child) => child.agent.waitForIdle())); + } + }, + ); + + it("coalesces the admitted task's tool-loop turns into one flushed spawn-usage attribution", async () => { + const root = createToolLoopSession(2, [usage(1, 1), usage(2, 2)]); await root.runRlmChild("use a tool"); await vi.waitFor(() => { const attributions = root.sessionManager .getEntries() .filter((entry) => entry.type === "child_usage_attributed"); - expect(attributions).toHaveLength(2); - expect(attributions.map((entry) => entry.origin)).toEqual(["spawn_task", "spawn_task"]); + expect(attributions).toHaveLength(1); + expect(attributions[0]?.origin).toBe("spawn_task"); + expect(attributions[0]?.childUsage.input).toBe(3); + expect(attributions[0]?.childUsage.output).toBe(3); }); }); + it("flushes a stale pending usage batch before extending it, bounding crash loss", async () => { + vi.useFakeTimers({ toFake: ["Date"] }); + try { + // The batch from the first two completions is older than the staleness + // bound when the third lands. + const root = createToolLoopSession(3, [usage(1, 1), usage(2, 2), usage(4, 4)], (toolResultCount) => { + if (toolResultCount === 2) vi.setSystemTime(Date.now() + 61_000); + }); + + await root.runRlmChild("use a tool"); + await vi.waitFor(() => { + const attributions = root.sessionManager + .getEntries() + .filter((entry) => entry.type === "child_usage_attributed"); + expect(attributions.map((entry) => [entry.childUsage.input, entry.childUsage.output])).toEqual([ + [3, 3], + [4, 4], + ]); + expect(attributions.map((entry) => entry.origin)).toEqual(["spawn_task", "spawn_task"]); + // Each aggregate covers exactly the completions durable with or + // before it, so any prefix replays to the exact own spend. + expect(attributions.map((entry) => entry.aggregateUsage.input)).toEqual([3, 7]); + }); + + const sessionFile = root.sessionManager.getSessionFile(); + if (!sessionFile) throw new Error("parent session file was not created"); + const reloadedAttributions = SessionManager.open(sessionFile, join(tempDir, "sessions")) + .getEntries() + .filter((entry) => entry.type === "child_usage_attributed"); + const childTotal = reloadedAttributions.reduce((total, entry) => total + entry.childUsage.input, 0); + // Parent own spend is zero here, so the reloaded aggregate must equal the summed child usage. + expect(reloadedAttributions.at(-1)?.aggregateUsage.input).toBe(childTotal); + } finally { + vi.useRealTimers(); + } + }); + it("gets and persists per-chat max-depth changes without transcript messages", async () => { const root = createSession(); const originalMessages = [...root.messages]; diff --git a/packages/coding-agent/test/agents-view-state.test.ts b/packages/coding-agent/test/agents-view-state.test.ts index fc87951e4..07b95cb95 100644 --- a/packages/coding-agent/test/agents-view-state.test.ts +++ b/packages/coding-agent/test/agents-view-state.test.ts @@ -21,6 +21,7 @@ import { resolveAgentsViewSessionUiServices, shouldReconnectAgentsViewDaemon, } from "../src/modes/agents-view/agents-view-mode.js"; +import { summaryForUnifiedRecord } from "../src/modes/agents-view/agents-view-state.js"; import { type AgentsViewScopeFrame, aggregateSessionHeartbeats, @@ -70,6 +71,30 @@ function heartbeat(id: string, nextRunAt?: string, activeSessionId = "child", st } describe("agents view state", () => { + test("classifies a saved orphan with rlmDepth > 0 as a subagent", () => { + const saved = { + path: "/tmp/sessions/child.jsonl", + id: "child", + cwd: "/tmp", + rlmDepth: 2, + created: new Date(0), + modified: new Date(0), + messageCount: 1, + firstMessage: "", + allMessagesText: "", + }; + const summary = summaryForUnifiedRecord({ + saved, + identity: "child", + identityAliases: [], + section: "archived", + searchableText: "", + } as never); + // The parent edge can be reconciliation-dropped while the depth survives; + // a depth > 0 session must never be presented as top-level. + expect(summary.runtimeKind).toBe("subagent"); + }); + test("classifies sessions by live runtime status at any depth", () => { expect(classifyAgentsViewSession(makeSummary({ isStreaming: true, activity: "working" }))).toBe("running"); expect( diff --git a/packages/coding-agent/test/atomic-persistence.test.ts b/packages/coding-agent/test/atomic-persistence.test.ts new file mode 100644 index 000000000..0c086cea2 --- /dev/null +++ b/packages/coding-agent/test/atomic-persistence.test.ts @@ -0,0 +1,360 @@ +import type * as FsModule from "node:fs"; +import { + mkdirSync, + mkdtempSync, + readdirSync, + readFileSync, + rmSync, + utimesSync, + writeFileSync, + type writeSync, +} from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { afterEach, describe, expect, it, vi } from "vitest"; + +type WriteSync = typeof writeSync; +const shortWrites = vi.hoisted(() => ({ remaining: 0 })); +const rmFault = vi.hoisted(() => ({ error: undefined as Error | undefined })); +const linkSweep = vi.hoisted(() => ({ remaining: 0 })); +const asideStatFault = vi.hoisted(() => ({ remaining: 0, plantRivalAt: undefined as string | undefined })); +const renameFault = vi.hoisted(() => ({ code: "", remaining: 0, calls: 0 })); +const renamePerformThenThrow = vi.hoisted(() => ({ remaining: 0 })); +vi.mock("node:fs", async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + renameSync: ((from: Parameters[0], to: Parameters[1]) => { + if (renameFault.code) { + renameFault.calls++; + if (renameFault.remaining-- > 0) + throw Object.assign(new Error("rename blocked"), { code: renameFault.code }); + } + if (renamePerformThenThrow.remaining > 0 && String(to).includes(".stale-")) { + renamePerformThenThrow.remaining--; + actual.renameSync(from, to); + throw Object.assign(new Error("EIO: reply lost"), { code: "EIO" }); + } + return actual.renameSync(from, to); + }) as typeof actual.renameSync, + statSync: ((path: Parameters[0], options?: never) => { + if (asideStatFault.remaining > 0 && String(path).includes(".stale-")) { + asideStatFault.remaining--; + if (asideStatFault.plantRivalAt !== undefined) { + actual.writeFileSync(asideStatFault.plantRivalAt, "31337\n"); + } + throw Object.assign(new Error("EPERM: probe blocked"), { code: "EPERM" }); + } + return actual.statSync(path, options); + }) as typeof actual.statSync, + linkSync: ((existing: Parameters[0], created: Parameters[1]) => { + if (linkSweep.remaining > 0) { + linkSweep.remaining--; + // A rival's sweep claims the candidate between write and publish. + actual.rmSync(existing, { force: true }); + } + return actual.linkSync(existing, created); + }) as typeof actual.linkSync, + rmSync: ((path: Parameters[0], options?: Parameters[1]) => { + if (rmFault.error && String(path).includes(".candidate-")) throw rmFault.error; + return actual.rmSync(path, options); + }) as typeof actual.rmSync, + writeSync: ((fd: number, data: NodeJS.ArrayBufferView | string, offset?: number, length?: number) => { + if (shortWrites.remaining > 0 && typeof data === "string" && data.length > 1) { + shortWrites.remaining--; + return (actual.writeSync as WriteSync)(fd, data.slice(0, 1) as never); + } + if (shortWrites.remaining > 0 && typeof length === "number" && length > 1) { + shortWrites.remaining--; + return (actual.writeSync as WriteSync)(fd, data as NodeJS.ArrayBufferView, offset, 1); + } + return (actual.writeSync as WriteSync)(fd, data as never, offset as never, length as never); + }) as WriteSync, + }; +}); + +import { writeFileAtomicSync } from "../src/utils/atomic-file.js"; +import { tryAcquireDirLock } from "../src/utils/dir-lock.js"; + +const tempDirs: string[] = []; + +afterEach(() => { + for (const dir of tempDirs.splice(0)) { + rmSync(dir, { recursive: true, force: true }); + } +}); + +function createTempDir(): string { + const dir = mkdtempSync(join(tmpdir(), "prime-atomic-persistence-")); + tempDirs.push(dir); + return dir; +} + +describe("writeFileAtomicSync", () => { + it.each(["EBUSY", "EPERM", "EACCES"])("retries transient Windows %s without exposing partial state", (code) => { + const dir = createTempDir(); + const path = join(dir, "state.json"); + writeFileSync(path, "old"); + vi.spyOn(process, "platform", "get").mockReturnValue("win32"); + Object.assign(renameFault, { code, remaining: 2, calls: 0 }); + try { + writeFileAtomicSync(path, "new"); + expect(readFileSync(path, "utf8")).toBe("new"); + expect(renameFault.calls).toBe(3); + expect(readdirSync(dir)).toEqual(["state.json"]); + } finally { + renameFault.code = ""; + vi.restoreAllMocks(); + } + }); + + it.each(["win32", "linux"])("bounds failed rename retries on %s and keeps the previous state", (platform) => { + const dir = createTempDir(); + const path = join(dir, "state.json"); + writeFileSync(path, "old"); + vi.spyOn(process, "platform", "get").mockReturnValue(platform as NodeJS.Platform); + Object.assign(renameFault, { code: "EBUSY", remaining: 10, calls: 0 }); + try { + expect(() => writeFileAtomicSync(path, "new")).toThrow("rename blocked"); + expect(readFileSync(path, "utf8")).toBe("old"); + expect(renameFault.calls).toBe(platform === "win32" ? 5 : 1); + expect(readdirSync(dir)).toEqual(["state.json"]); + } finally { + renameFault.code = ""; + vi.restoreAllMocks(); + } + }); + + it("completes short kernel writes and preserves the destination when a write fails", () => { + const dir = createTempDir(); + const path = join(dir, "state.json"); + shortWrites.remaining = 3; + try { + writeFileAtomicSync(path, JSON.stringify({ key: "value".repeat(10) })); + } finally { + shortWrites.remaining = 0; + } + expect(JSON.parse(readFileSync(path, "utf8"))).toEqual({ key: "value".repeat(10) }); + + expect(() => + writeFileAtomicSync(path, "next", { + beforeRename: () => { + throw new Error("validation failed"); + }, + }), + ).toThrow("validation failed"); + expect(JSON.parse(readFileSync(path, "utf8"))).toEqual({ key: "value".repeat(10) }); + expect(readdirSync(dir).filter((name) => name.endsWith(".tmp"))).toEqual([]); + }); +}); + +function seedDirLock(dir: string, name: string, pid: string): string { + const lockDir = join(dir, name); + mkdirSync(lockDir); + writeFileSync(join(lockDir, "pid"), pid); + return lockDir; +} + +describe("tryAcquireDirLock", () => { + it("recovers a lost-reply rename: reclaims a stale lock, restores a swapped rival", async () => { + const dir = createTempDir(); + const lockPath = join(dir, "eio.lock"); + writeFileSync(lockPath, "999999999\n"); + renamePerformThenThrow.remaining = 1; + try { + expect(await tryAcquireDirLock(lockPath, () => false)).toBe("reclaimed"); + } finally { + renamePerformThenThrow.remaining = 0; + } + expect(readdirSync(dir)).toEqual([]); + expect(await tryAcquireDirLock(lockPath, () => false)).toBe("acquired"); + + rmSync(lockPath, { force: true }); + writeFileSync(lockPath, "999999999\n"); + renamePerformThenThrow.remaining = 1; + try { + const swapped = await tryAcquireDirLock(lockPath, () => { + // A rival replaces the judged-stale lock before the rename fires. + rmSync(lockPath, { force: true }); + writeFileSync(lockPath, "777777\n"); + return false; + }); + expect(swapped).toBe("held"); + } finally { + renamePerformThenThrow.remaining = 0; + } + expect(readFileSync(lockPath, "utf8").trim()).toBe("777777"); + }); + + it("parks a moved lock whose shape cannot be probed instead of deleting or clobbering", async () => { + const dir = createTempDir(); + const lockPath = join(dir, "shape.lock"); + writeFileSync(lockPath, "999999999\n"); + // A rival publishes at the path while the aside probe is failing: only a + // link-back (which cannot replace it) is a safe restore attempt. + asideStatFault.remaining = 1; + asideStatFault.plantRivalAt = lockPath; + + try { + expect(await tryAcquireDirLock(lockPath, () => false)).toBe("held"); + } finally { + asideStatFault.remaining = 0; + asideStatFault.plantRivalAt = undefined; + } + expect(readFileSync(lockPath, "utf8").trim()).toBe("31337"); + const parked = readdirSync(dir) + .filter((name) => name.includes(".stale-")) + .map((name) => readFileSync(join(dir, name), "utf8").trim()); + expect(parked).toEqual(["999999999"]); + }); + + it("retries with a fresh candidate when a rival's sweep claims the first mid-publish", async () => { + const dir = createTempDir(); + const lockPath = join(dir, "suspended.lock"); + linkSweep.remaining = 1; + + try { + expect(await tryAcquireDirLock(lockPath, () => false)).toBe("acquired"); + } finally { + linkSweep.remaining = 0; + } + expect(readFileSync(lockPath, "utf8").trim()).toBe(String(process.pid)); + }); + + it("sweeps abandoned candidates on acquire while sparing fresh ones and the lock", async () => { + const dir = createTempDir(); + const lockPath = join(dir, "swept.lock"); + const abandoned = join(dir, "swept.lock.candidate-1234-dead"); + const fresh = join(dir, "swept.lock.candidate-5678-mid-publish"); + writeFileSync(abandoned, "1234\n"); + const twoHoursAgo = new Date(Date.now() - 2 * 60 * 60 * 1000); + utimesSync(abandoned, twoHoursAgo, twoHoursAgo); + writeFileSync(fresh, "5678\n"); + + expect(await tryAcquireDirLock(lockPath, () => false)).toBe("acquired"); + + const names = readdirSync(dir).sort(); + expect(names).not.toContain("swept.lock.candidate-1234-dead"); + expect(names).toContain("swept.lock.candidate-5678-mid-publish"); + expect(readFileSync(lockPath, "utf8").trim()).toBe(String(process.pid)); + }); + + it("reports held instead of reclaiming when the owner cannot be read", async () => { + const dir = createTempDir(); + const lockDir = join(dir, "opaque.lock"); + // Legacy lock whose pid entry is a directory: every read fails non-ENOENT. + mkdirSync(join(lockDir, "pid"), { recursive: true }); + + expect(await tryAcquireDirLock(lockDir, () => false)).toBe("held"); + expect(readdirSync(dir)).toEqual(["opaque.lock"]); + }); + + it("keeps a settled acquisition when candidate cleanup fails", async () => { + const dir = createTempDir(); + const lockPath = join(dir, "cleanup.lock"); + rmFault.error = new Error("EBUSY: held by scanner"); + + try { + expect(await tryAcquireDirLock(lockPath, () => false)).toBe("acquired"); + } finally { + rmFault.error = undefined; + } + expect(readFileSync(lockPath, "utf8").trim()).toBe(String(process.pid)); + }); + + it("publishes the lock as a file born with its owner and puts back a swapped file lock", async () => { + const dir = createTempDir(); + const lockPath = join(dir, "file.lock"); + const alive = (ownerPid: number | undefined) => ownerPid === process.pid || ownerPid === 424242; + + expect(await tryAcquireDirLock(lockPath, alive)).toBe("acquired"); + expect(readFileSync(lockPath, "utf8").trim()).toBe(String(process.pid)); + expect(await tryAcquireDirLock(lockPath, alive)).toBe("held"); + + // Stale file lock: dead owner content is reclaimed. + writeFileSync(lockPath, "999999999\n"); + const swapped = await tryAcquireDirLock(lockPath, () => { + // A rival replaces the lock while the staleness judgment runs. + rmSync(lockPath, { force: true }); + writeFileSync(lockPath, "424242\n"); + return false; + }); + expect(swapped).toBe("held"); + expect(readFileSync(lockPath, "utf8").trim()).toBe("424242"); + }); + + // kill(0) probes our own process group; parseInt would trust "123garbage" as 123. + it.each([ + [ + "0\n", + (ownerPid: number | undefined) => (ownerPid === undefined ? false : process.kill(ownerPid, 0) !== undefined), + ], + ["123garbage\n", (ownerPid: number | undefined) => ownerPid === 123], + ])("treats a legacy lock with pid content %j as stale", async (pidContent, alive) => { + const dir = createTempDir(); + const lockDir = join(dir, "hygiene.lock"); + mkdirSync(lockDir); + writeFileSync(join(lockDir, "pid"), pidContent); + + expect(await tryAcquireDirLock(lockDir, alive)).toBe("reclaimed"); + expect(await tryAcquireDirLock(lockDir, alive)).toBe("acquired"); + }); + + it("puts back a lock that changed owners between the staleness judgment and the reclaim", async () => { + const dir = createTempDir(); + const lockDir = seedDirLock(dir, "raced.lock", "2147483647\n"); + + const result = await tryAcquireDirLock(lockDir, () => { + // The stale owner releases and a rival acquires while this judgment runs. + rmSync(lockDir, { recursive: true, force: true }); + mkdirSync(lockDir); + writeFileSync(join(lockDir, "pid"), "424242\n"); + return false; + }); + + expect(result).toBe("held"); + expect(readFileSync(join(lockDir, "pid"), "utf8").trim()).toBe("424242"); + + // Pid reuse: a NEW lock with the SAME pid content is a different inode and + // must be restored, not judged identical and deleted. + const reused = await tryAcquireDirLock(lockDir, () => { + rmSync(lockDir, { recursive: true, force: true }); + mkdirSync(lockDir); + writeFileSync(join(lockDir, "pid"), "424242\n"); + return false; + }); + expect(reused).toBe("held"); + expect(readFileSync(join(lockDir, "pid"), "utf8").trim()).toBe("424242"); + + // Cross-shape swap: a judged FILE lock replaced by a rival's legacy DIR is + // restored at the path by the moved entry's own shape (rename-back). + rmSync(lockDir, { recursive: true, force: true }); + writeFileSync(lockDir, "999999999\n"); + const crossType = await tryAcquireDirLock(lockDir, () => { + rmSync(lockDir, { recursive: true, force: true }); + mkdirSync(lockDir); + writeFileSync(join(lockDir, "pid"), "555555\n"); + return false; + }); + expect(crossType).toBe("held"); + expect(readFileSync(join(lockDir, "pid"), "utf8").trim()).toBe("555555"); + }); + + it("acquires over a stale lock without deleting a lock that changed owners", async () => { + const dir = createTempDir(); + const lockDir = join(dir, "work.lock"); + // A stale lock: dead owner. + mkdirSync(lockDir); + writeFileSync(join(lockDir, "pid"), "2147483647\n"); + + const alive = (ownerPid: number | undefined) => ownerPid === process.pid; + expect(await tryAcquireDirLock(lockDir, alive)).toBe("reclaimed"); + expect(await tryAcquireDirLock(lockDir, alive)).toBe("acquired"); + expect(readFileSync(lockDir, "utf8").trim()).toBe(String(process.pid)); + + // Every rival attempt against the live owner reports "held" and leaves the lock alone. + const rivals = await Promise.all(Array.from({ length: 8 }, () => tryAcquireDirLock(lockDir, alive))); + expect(rivals).toEqual(Array.from({ length: 8 }, () => "held")); + expect(readFileSync(lockDir, "utf8").trim()).toBe(String(process.pid)); + }); +}); diff --git a/packages/coding-agent/test/auth-storage.test.ts b/packages/coding-agent/test/auth-storage.test.ts index 049d25420..663419cdd 100644 --- a/packages/coding-agent/test/auth-storage.test.ts +++ b/packages/coding-agent/test/auth-storage.test.ts @@ -1,10 +1,41 @@ -import { existsSync, mkdirSync, readFileSync, rmSync, statSync, writeFileSync } from "node:fs"; +import { existsSync, lstatSync, mkdirSync, readFileSync, rmSync, statSync, symlinkSync, writeFileSync } from "node:fs"; import { tmpdir } from "node:os"; import { join } from "node:path"; import { registerOAuthProvider } from "@earendil-works/pi-ai/oauth"; import lockfile from "proper-lockfile"; import { afterEach, beforeEach, describe, expect, test, vi } from "vitest"; -import { AuthStorage } from "../src/core/auth-storage.js"; +import { AuthStorage, FileAuthStorageBackend } from "../src/core/auth-storage.js"; + +const initialWriteFault = vi.hoisted(() => ({ count: -1 })); +const renameFault = vi.hoisted(() => ({ error: undefined as Error | undefined })); +const absenceIllusion = vi.hoisted(() => ({ paths: new Set() })); +vi.mock("node:fs", async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + writeSync: ((fd: number, data: NodeJS.ArrayBufferView | string, offset?: number, length?: number) => { + if (initialWriteFault.count >= 0) { + const count = initialWriteFault.count; + initialWriteFault.count = -1; + if (count === 0) return 0; + const bytes = + typeof data === "string" + ? Buffer.from(data) + : Buffer.from(data.buffer, data.byteOffset, data.byteLength); + return actual.writeSync(fd, bytes, offset ?? 0, count); + } + return actual.writeSync(fd, data as never, offset as never, length as never); + }) as typeof actual.writeSync, + renameSync: (from: Parameters[0], to: Parameters[1]) => { + if (renameFault.error && String(to).endsWith("auth.json")) throw renameFault.error; + return actual.renameSync(from, to); + }, + existsSync: (path: Parameters[0]) => { + if (absenceIllusion.paths.has(String(path))) return false; + return actual.existsSync(path); + }, + }; +}); describe("AuthStorage", () => { let tempDir: string; @@ -951,6 +982,125 @@ describe("AuthStorage", () => { }); describe("persistence semantics", () => { + test("completes a short initial write before loading and saving credentials", () => { + initialWriteFault.count = 1; + try { + authStorage = AuthStorage.create(authJsonPath); + } finally { + initialWriteFault.count = -1; + } + + expect(authStorage.drainErrors()).toEqual([]); + expect(JSON.parse(readFileSync(authJsonPath, "utf8"))).toEqual({}); + authStorage.set("openai", { type: "api_key", key: "new-key" }); + expect(JSON.parse(readFileSync(authJsonPath, "utf8"))).toMatchObject({ + openai: { type: "api_key", key: "new-key" }, + }); + }); + + test("fails initial writes that make no progress before exposing storage", () => { + const backend = new FileAuthStorageBackend(authJsonPath); + const consume = vi.fn(() => ({ result: undefined })); + initialWriteFault.count = 0; + try { + expect(() => backend.withLock(consume)).toThrow(/Short write/); + expect(consume).not.toHaveBeenCalled(); + } finally { + initialWriteFault.count = -1; + } + }); + + test("first-run initialization survives a restrictive umask", () => { + const previousUmask = process.umask(0o700); + try { + authStorage = AuthStorage.create(authJsonPath); + authStorage.set("openai", { type: "api_key", key: "masked-key" }); + } finally { + process.umask(previousUmask); + } + + expect(statSync(authJsonPath).mode & 0o777).toBe(0o600); + const onDisk = JSON.parse(readFileSync(authJsonPath, "utf-8")) as Record; + expect(onDisk.openai.key).toBe("masked-key"); + }); + + test.each([ + [ + "an existing target", + (): { alias: string; target: string } => { + const target = join(tempDir, "real-auth.json"); + writeFileSync(target, "{}"); + rmSync(authJsonPath, { force: true }); + symlinkSync(target, authJsonPath); + return { alias: authJsonPath, target }; + }, + ], + [ + "a dangling absolute target", + (): { alias: string; target: string } => { + const target = join(tempDir, "vault", "auth.json"); + mkdirSync(join(tempDir, "vault"), { recursive: true }); + symlinkSync(target, authJsonPath); + return { alias: authJsonPath, target }; + }, + ], + [ + "a dangling relative target under a symlinked directory", + (): { alias: string; target: string } => { + const realDir = join(tempDir, "real-dir"); + mkdirSync(realDir, { recursive: true }); + const aliasDir = join(tempDir, "alias-dir"); + symlinkSync(realDir, aliasDir); + symlinkSync("./credentials.json", join(aliasDir, "auth.json")); + return { alias: join(aliasDir, "auth.json"), target: join(realDir, "credentials.json") }; + }, + ], + ])("writes through a symlinked auth.json (%s) with the alias intact", (_name, setup) => { + const { alias, target } = setup(); + authStorage = AuthStorage.create(alias); + + authStorage.set("openai", { type: "api_key", key: "through-alias" }); + + expect(lstatSync(alias).isSymbolicLink()).toBe(true); + const real = JSON.parse(readFileSync(target, "utf-8")) as Record; + expect(real.openai.key).toBe("through-alias"); + }); + + test("initialization never replaces credentials another process already saved", () => { + authStorage = AuthStorage.create(authJsonPath); + // A rival process persists credentials between the absence check and the write. + writeAuthJson({ anthropic: { type: "api_key", key: "already-saved" } }); + absenceIllusion.paths.add(authJsonPath); + + try { + const backend = (authStorage as unknown as { storage: { ensureFileExists(): void } }).storage; + backend.ensureFileExists(); + } finally { + absenceIllusion.paths.delete(authJsonPath); + } + + const onDisk = JSON.parse(readFileSync(authJsonPath, "utf-8")) as Record; + expect(onDisk.anthropic.key).toBe("already-saved"); + }); + + test("a write failing at the replace boundary leaves the previous credentials intact", () => { + writeAuthJson({ anthropic: { type: "api_key", key: "old-key" } }); + authStorage = AuthStorage.create(authJsonPath); + renameFault.error = new Error("disk full"); + + try { + authStorage.set("anthropic", { type: "api_key", key: "new-key" }); + } finally { + renameFault.error = undefined; + } + + expect(authStorage.drainErrors().map((error) => String(error))).toEqual([ + expect.stringContaining("disk full"), + ]); + const onDisk = JSON.parse(readFileSync(authJsonPath, "utf-8")) as Record; + expect(onDisk.anthropic.key).toBe("old-key"); + }); + test("set preserves unrelated external edits", () => { writeAuthJson({ anthropic: { type: "api_key", key: "old-anthropic" }, diff --git a/packages/coding-agent/test/child-process.test.ts b/packages/coding-agent/test/child-process.test.ts index 08a87793f..921df6a34 100644 --- a/packages/coding-agent/test/child-process.test.ts +++ b/packages/coding-agent/test/child-process.test.ts @@ -1,7 +1,16 @@ import { type ChildProcess, spawn } from "node:child_process"; import { EventEmitter } from "node:events"; import { describe, expect, it } from "vitest"; -import { isProcessAlive, isZombieProcess, waitForChildProcess } from "../src/utils/child-process.js"; +import { + isProcessAlive, + isZombieProcess, + processGroupExists, + processGroupHasLiveMember, + signalProcessGroupIfHeld, + signalProcessGroupOrProcess, + waitForChildProcess, +} from "../src/utils/child-process.js"; +import { spawnZombieProcess } from "./fixtures/zombie-process.js"; describe("waitForChildProcess", () => { it("reports signaled already-exited children as failures", async () => { @@ -30,32 +39,56 @@ describe("process liveness", () => { }); it.skipIf(process.platform === "win32")("treats a zombie process as dead", async () => { - const parent = spawn( - "perl", - ["-e", '$| = 1; my $pid = fork(); if ($pid) { print "$pid\\n"; sleep 30 } else { exit 0 }'], - { stdio: ["ignore", "pipe", "ignore"] }, - ); + const { zombiePid, dispose } = await spawnZombieProcess(); try { - const zombiePid = await new Promise((resolvePid, rejectPid) => { - let output = ""; - const timer = setTimeout(() => rejectPid(new Error("Timed out waiting for the zombie pid")), 5000); - parent.stdout.on("data", (chunk: Buffer) => { - output += chunk.toString(); - const parsed = Number.parseInt(output.trim(), 10); - if (Number.isInteger(parsed) && parsed > 0) { - clearTimeout(timer); - resolvePid(parsed); - } - }); - }); - const deadline = Date.now() + 5000; - while (!isZombieProcess(zombiePid) && Date.now() < deadline) { - await new Promise((resolveDelay) => setTimeout(resolveDelay, 25)); - } expect(isZombieProcess(zombiePid)).toBe(true); expect(isProcessAlive(zombiePid)).toBe(false); } finally { - parent.kill("SIGKILL"); + dispose(); + } + }); + + it.skipIf(process.platform === "win32")("does not let an unreaped zombie block group-stop completion", async () => { + // setpgrp makes the zombie its group's only member: the group exists, but + // a stop waiting on it must complete because nothing is left running. + const { zombiePid, dispose } = await spawnZombieProcess("setpgrp(0, 0);"); + try { + expect(isZombieProcess(zombiePid)).toBe(true); + expect(processGroupExists(zombiePid)).toBe(true); + expect(processGroupHasLiveMember(zombiePid)).toBe(false); + } finally { + dispose(); + } + }); + + it.skipIf(process.platform === "win32")("keeps a process group alive after its leader exits", async () => { + const childless = spawn("sh", ["-c", "exit 0"], { detached: true, stdio: "ignore" }); + const childlessExited = new Promise((resolveExit) => childless.once("exit", () => resolveExit())); + const leader = spawn("sh", ["-c", "sleep 30 & echo started"], { + detached: true, + stdio: ["ignore", "pipe", "ignore"], + }); + const leaderExited = new Promise((resolveExit) => leader.once("exit", () => resolveExit())); + const pgid = leader.pid!; + try { + await new Promise((resolveStart, rejectStart) => { + const timer = setTimeout(() => rejectStart(new Error("Timed out waiting for the group member")), 5000); + leader.stdout?.once("data", () => { + clearTimeout(timer); + resolveStart(); + }); + }); + await leaderExited; + expect(isProcessAlive(pgid)).toBe(false); + expect(processGroupExists(pgid)).toBe(true); + expect(processGroupHasLiveMember(pgid)).toBe(true); + // A held group signals; a fully-gone group refuses (pgid-reuse gate). + expect(signalProcessGroupIfHeld(pgid, "SIGKILL")).toBe(true); + await childlessExited; + expect(processGroupExists(childless.pid!)).toBe(false); + expect(signalProcessGroupIfHeld(childless.pid!, "SIGKILL")).toBe(false); + } finally { + signalProcessGroupOrProcess(pgid, "SIGKILL"); } }); }); diff --git a/packages/coding-agent/test/compaction.test.ts b/packages/coding-agent/test/compaction.test.ts index ea8a280bb..cfc545c17 100644 --- a/packages/coding-agent/test/compaction.test.ts +++ b/packages/coding-agent/test/compaction.test.ts @@ -324,6 +324,30 @@ describe("findCutPoint", () => { expect(result.firstKeptEntryIndex).toBe(0); }); + it("keeps only the final turn when the budget is crossed inside trailing tool results", () => { + const hugeToolResult = { + role: "toolResult" as const, + toolCallId: "tc1", + toolName: "ipython", + content: [{ type: "text" as const, text: "x".repeat(40_000) }], + isError: false, + timestamp: Date.now(), + }; + const entries: SessionEntry[] = [ + createMessageEntry(createUserMessage("Turn 1")), + createMessageEntry(createAssistantMessage("A1", createMockUsage(0, 100, 1000, 0))), + createMessageEntry(createUserMessage("Turn 2")), // index 2 + createMessageEntry(createAssistantMessage("A2", createMockUsage(0, 100, 2000, 0))), // index 3: last cut point + createMessageEntry(hugeToolResult), + createMessageEntry(hugeToolResult), + ]; + + // The budget is crossed inside the trailing tool results, past every cut + // point; the whole history must not be silently kept. + const result = findCutPoint(entries, 0, entries.length, 1000); + expect(result.firstKeptEntryIndex).toBe(3); + }); + it("should indicate split turn when cutting at assistant message", () => { // Create a scenario where we cut at an assistant message mid-turn const entries: SessionEntry[] = [ diff --git a/packages/coding-agent/test/daemon-errors.test.ts b/packages/coding-agent/test/daemon-errors.test.ts index b0d56037c..e1c17942f 100644 --- a/packages/coding-agent/test/daemon-errors.test.ts +++ b/packages/coding-agent/test/daemon-errors.test.ts @@ -1,6 +1,12 @@ import { describe, expect, it } from "vitest"; import { SessionAlreadyActiveError } from "../src/core/session-lease.js"; -import { DaemonSessionCreateError, deserializeDaemonCreateError } from "../src/modes/daemon/daemon-errors.js"; +import { + DaemonSessionCreateError, + DaemonSessionRecoveringError, + deserializeDaemonCreateError, + deserializeDaemonError, + serializeDaemonError, +} from "../src/modes/daemon/daemon-errors.js"; describe("deserializeDaemonCreateError", () => { it("wraps generic create failures so the CLI boundary prints one line instead of rethrowing", () => { @@ -25,3 +31,30 @@ describe("deserializeDaemonCreateError", () => { expect(error).toBeInstanceOf(SessionAlreadyActiveError); }); }); + +describe("session_recovering wire round-trip", () => { + it("serializes for old clients (readable message) and deserializes for new clients (typed, retryable)", () => { + const error = new DaemonSessionRecoveringError("active-gap"); + const errorInfo = serializeDaemonError(error); + expect(errorInfo).toEqual({ code: "session_recovering", activeSessionId: "active-gap" }); + // Old client / new daemon: errorInfo is ignored, the message alone must carry the state. + expect(error.message).toBe("Active session active-gap is recovering; retry shortly"); + const roundTripped = deserializeDaemonError({ + type: "response", + command: "attach", + success: false, + error: error.message, + errorInfo, + }); + expect(roundTripped).toBeInstanceOf(DaemonSessionRecoveringError); + expect((roundTripped as DaemonSessionRecoveringError).activeSessionId).toBe("active-gap"); + // New client / old daemon: a plain unknown-session failure stays a plain error. + const legacy = deserializeDaemonError({ + type: "response", + command: "attach", + success: false, + error: "Unknown active session: active-gap", + }); + expect(legacy).not.toBeInstanceOf(DaemonSessionRecoveringError); + }); +}); diff --git a/packages/coding-agent/test/daemon-mode.test.ts b/packages/coding-agent/test/daemon-mode.test.ts index 0638f1989..7af474c40 100644 --- a/packages/coding-agent/test/daemon-mode.test.ts +++ b/packages/coding-agent/test/daemon-mode.test.ts @@ -1,16 +1,21 @@ import { createHash } from "node:crypto"; import { EventEmitter } from "node:events"; import { + appendFileSync, chmodSync, existsSync, mkdirSync, mkdtempSync, readdirSync, readFileSync, + renameSync, rmSync, + statSync, symlinkSync, writeFileSync, } from "node:fs"; +import fsPromises from "node:fs/promises"; +import { syncBuiltinESMExports } from "node:module"; import { createServer, type Server, type Socket } from "node:net"; import { tmpdir } from "node:os"; import { basename, join, resolve } from "node:path"; @@ -3650,7 +3655,14 @@ describe("daemon mode helpers", () => { client.transport = "private-framed"; const result = { activeSessionId: state.activeSessionId, - snapshot: { summary: {}, state: {}, messages: [] }, + snapshot: { + activeSessionId: state.activeSessionId, + summary: {}, + state: {}, + messages: [], + lastEventSequence: 0, + lastEventCursor: { generation: state.eventGeneration, sequence: 0 }, + }, lastEventSequence: 0, } as unknown as DaemonAttachResult; const streamWorkerSnapshot = vi.fn(async () => undefined); @@ -3717,9 +3729,12 @@ describe("daemon mode helpers", () => { const result = { activeSessionId: state.activeSessionId, snapshot: { + activeSessionId: state.activeSessionId, summary: {}, state: {}, messages: [{ role: "user", content: "x".repeat(4 * 1024 * 1024 + 1), timestamp: 0 }], + lastEventSequence: 0, + lastEventCursor: { generation: state.eventGeneration, sequence: 0 }, }, lastEventSequence: 0, } as unknown as DaemonAttachResult; @@ -4362,6 +4377,223 @@ describe("daemon mode helpers", () => { } }); + function makePassiveMemoHarness(tempDir: string) { + const fixture = makePersistedRlmDaemonFixture(tempDir); + const internals = fixture.daemon as unknown as { + createRuntime(command: Extract): Promise; + listPassiveRlmSubagents(): Promise< + Array<{ entry: { childId: string; status: string }; info: { messageCount: number } }> + >; + }; + return { fixture, internals }; + } + + const passiveMessageLine = (id: string, text: string) => + `${JSON.stringify({ + type: "message", + id, + parentId: null, + timestamp: "2026-01-01T00:00:02.000Z", + message: { role: "user", content: text, timestamp: 3 }, + })}\n`; + + it("memoizes the passive topology walk until a topology input changes", async () => { + const tempDir = mkdtempSync(join(tmpdir(), "prime-agent-daemon-passive-memo-")); + try { + const { fixture, internals } = makePassiveMemoHarness(tempDir); + await internals.createRuntime({ type: "create", sessionPath: fixture.parentSessionFile }); + + // The first walk seeds the ledger, so it never memoizes; the second is + // the first stable derivation and the third must reuse it. + await internals.listPassiveRlmSubagents(); + const first = await internals.listPassiveRlmSubagents(); + expect(first.map(({ entry }) => entry.childId)).toEqual( + expect.arrayContaining([fixture.childId, fixture.grandchildId]), + ); + expect(await internals.listPassiveRlmSubagents()).toBe(first); + + // A child session append invalidates the memo and re-derives fresh infos. + appendFileSync(fixture.childSessionFile, passiveMessageLine("m2", "one more instruction")); + const third = await internals.listPassiveRlmSubagents(); + expect(third).not.toBe(first); + expect(third.find(({ entry }) => entry.childId === fixture.childId)?.info.messageCount).toBe(2); + } finally { + rmSync(tempDir, { recursive: true, force: true }); + } + }); + + it("keeps a rejecting topology walk request-scoped and retries on the next call", async () => { + const tempDir = mkdtempSync(join(tmpdir(), "prime-agent-daemon-passive-reject-")); + try { + const { fixture, internals } = makePassiveMemoHarness(tempDir); + await internals.createRuntime({ type: "create", sessionPath: fixture.parentSessionFile }); + await internals.listPassiveRlmSubagents(); + + const ledgerDir = join(tempDir, "rlm-ledger"); + const ledgerFile = readdirSync(ledgerDir).find((name) => name.endsWith(".jsonl")); + if (!ledgerFile) throw new Error("Missing spawn ledger file"); + const ledgerPath = join(ledgerDir, ledgerFile); + const intact = readFileSync(ledgerPath, "utf8"); + appendFileSync(ledgerPath, "not json\n"); + + // The walk rejects to its caller only; a discarded cleanup promise must + // not surface as an unhandled rejection (the daemon crashes on those). + await expect(internals.listPassiveRlmSubagents()).rejects.toThrow(); + + writeFileSync(ledgerPath, intact); + expect((await internals.listPassiveRlmSubagents()).map(({ entry }) => entry.childId)).toContain( + fixture.childId, + ); + } finally { + rmSync(tempDir, { recursive: true, force: true }); + } + }); + + it("re-lists a child whose session file returns after being absent", async () => { + const tempDir = mkdtempSync(join(tmpdir(), "prime-agent-daemon-passive-absent-")); + try { + const { fixture, internals } = makePassiveMemoHarness(tempDir); + await internals.createRuntime({ type: "create", sessionPath: fixture.parentSessionFile }); + await internals.listPassiveRlmSubagents(); + + const asideFile = `${fixture.grandchildSessionFile}.aside`; + renameSync(fixture.grandchildSessionFile, asideFile); + const without = await internals.listPassiveRlmSubagents(); + expect(without.map(({ entry }) => entry.childId)).not.toContain(fixture.grandchildId); + await internals.listPassiveRlmSubagents(); + + // An absent input is a stat identity too: the file reappearing must + // invalidate the memo even though no listed child changed. + renameSync(asideFile, fixture.grandchildSessionFile); + const restored = await internals.listPassiveRlmSubagents(); + expect(restored.map(({ entry }) => entry.childId)).toContain(fixture.grandchildId); + } finally { + rmSync(tempDir, { recursive: true, force: true }); + } + }); + + it("re-derives passive metadata when a child display record is rewritten", async () => { + const tempDir = mkdtempSync(join(tmpdir(), "prime-agent-daemon-passive-display-")); + try { + const { fixture, internals } = makePassiveMemoHarness(tempDir); + const display = { + type: "rlm_subagent" as const, + childId: fixture.childId, + sessionName: "spawn-worker", + sessionDir: fixture.childSessionDir, + sessionFile: fixture.childSessionFile, + rlmParentNodeId: fixture.childId, + status: "running" as const, + createdAt: 1, + updatedAt: "2026-01-01T00:00:00.000Z", + }; + const displayPath = join(fixture.childSessionDir, "rlm-subagent.json"); + writeFileSync(displayPath, `${JSON.stringify(display)}\n`); + await internals.createRuntime({ type: "create", sessionPath: fixture.parentSessionFile }); + await internals.listPassiveRlmSubagents(); + const running = await internals.listPassiveRlmSubagents(); + expect(running.find(({ entry }) => entry.childId === fixture.childId)?.entry.status).toBe("running"); + + // A cross-process completion rewrites only the display record; the memo + // must treat it as a walk input and re-derive. + writeFileSync(displayPath, `${JSON.stringify({ ...display, status: "completed" })}\n`); + const completed = await internals.listPassiveRlmSubagents(); + expect(completed.find(({ entry }) => entry.childId === fixture.childId)?.entry.status).toBe("completed"); + } finally { + rmSync(tempDir, { recursive: true, force: true }); + } + }); + + it.each(["display", "legacy"] as const)( + "retries a transient %s metadata read failure without a stat change", + async (source) => { + const tempDir = mkdtempSync(join(tmpdir(), "prime-agent-daemon-passive-read-retry-")); + const readFile = fsPromises.readFile; + let readSpy: ReturnType | undefined; + try { + const { fixture, internals } = makePassiveMemoHarness(tempDir); + await internals.createRuntime({ type: "create", sessionPath: fixture.parentSessionFile }); + // Seed the ledger before failing optional metadata reads. + await internals.listPassiveRlmSubagents(); + const metadata = { + type: "rlm_subagent", + childId: fixture.childId, + sessionName: "spawn-worker", + sessionDir: fixture.childSessionDir, + sessionFile: fixture.childSessionFile, + rlmDepth: 1, + rlmMaxDepth: 7, + prompt: "recover the original task", + model: { provider: "test-provider", modelId: "test-model" }, + status: "running", + createdAt: 1, + updatedAt: "2026-01-01T00:00:00.000Z", + }; + const metadataPath = + source === "display" + ? join(fixture.childSessionDir, "rlm-subagent.json") + : join(fixture.parentArtifactDir, "rlm-subagents.jsonl"); + writeFileSync(metadataPath, `${JSON.stringify(metadata)}\n`); + const before = statSync(metadataPath); + let metadataReads = 0; + readSpy = vi.spyOn(fsPromises, "readFile").mockImplementation((...args) => { + if ( + typeof args[0] === "string" && + canonicalSessionPath(args[0]) === canonicalSessionPath(metadataPath) && + ++metadataReads === 1 + ) { + return Promise.reject( + Object.assign(new Error("transient metadata read failure"), { code: "EACCES" }), + ); + } + return readFile(...args); + }); + syncBuiltinESMExports(); + + const fallback = await internals.listPassiveRlmSubagents(); + expect(fallback.find(({ entry }) => entry.childId === fixture.childId)?.entry.status).toBe("completed"); + expect(metadataReads).toBe(1); + const recovered = await internals.listPassiveRlmSubagents(); + expect(recovered.find(({ entry }) => entry.childId === fixture.childId)?.entry).toMatchObject({ + prompt: metadata.prompt, + model: metadata.model, + rlmDepth: 1, + rlmMaxDepth: 7, + status: "running", + }); + expect(metadataReads).toBe(2); + const after = statSync(metadataPath); + expect([after.size, after.mtimeMs, after.ino]).toEqual([before.size, before.mtimeMs, before.ino]); + expect(await internals.listPassiveRlmSubagents()).toBe(recovered); + expect(metadataReads).toBe(2); + } finally { + readSpy?.mockRestore(); + syncBuiltinESMExports(); + rmSync(tempDir, { recursive: true, force: true }); + } + }, + ); + + it("keeps the memo across appends to a resident child's transcript", async () => { + const tempDir = mkdtempSync(join(tmpdir(), "prime-agent-daemon-passive-resident-append-")); + try { + const { fixture, internals } = makePassiveMemoHarness(tempDir); + await internals.createRuntime({ type: "create", sessionPath: fixture.parentSessionFile }); + await internals.createRuntime({ type: "create", sessionPath: fixture.childSessionFile }); + await internals.listPassiveRlmSubagents(); + const first = await internals.listPassiveRlmSubagents(); + expect(first.map(({ entry }) => entry.childId)).toContain(fixture.grandchildId); + expect(first.map(({ entry }) => entry.childId)).not.toContain(fixture.childId); + + // A resident child's identity comes from the roster, not its transcript: + // its streaming appends must not invalidate the passive memo. + appendFileSync(fixture.childSessionFile, passiveMessageLine("m9", "streamed while resident")); + expect(await internals.listPassiveRlmSubagents()).toBe(first); + } finally { + rmSync(tempDir, { recursive: true, force: true }); + } + }); + it("cancels a detached subagent heartbeat when its parent is archived", async () => { const tempDir = mkdtempSync(join(tmpdir(), "prime-agent-daemon-archived-subagent-heartbeat-")); try { diff --git a/packages/coding-agent/test/daemon-session-summarizer-lifecycle.test.ts b/packages/coding-agent/test/daemon-session-summarizer-lifecycle.test.ts index c14c2a8a1..ad5cdbb3b 100644 --- a/packages/coding-agent/test/daemon-session-summarizer-lifecycle.test.ts +++ b/packages/coding-agent/test/daemon-session-summarizer-lifecycle.test.ts @@ -26,6 +26,7 @@ function makeState( sessionManager: { appendAgentStatus: (s: unknown) => appended.push(s), getLatestAgentStatus: () => opts.persisted, + getLeafId: () => null, }, }, }, diff --git a/packages/coding-agent/test/daemon-session-summarizer.test.ts b/packages/coding-agent/test/daemon-session-summarizer.test.ts index 18cc2752a..659885dac 100644 --- a/packages/coding-agent/test/daemon-session-summarizer.test.ts +++ b/packages/coding-agent/test/daemon-session-summarizer.test.ts @@ -160,6 +160,9 @@ describe("daemon session summarizer", () => { messages: AgentMessage[]; isSessionActive: boolean; summaryState?: AgentStatus; + persistedStatus?: AgentStatus; + appendAgentStatus?: (status: AgentStatus) => void; + getLeafId?: () => string | null; }): ActiveSessionState { return { activeSessionId: "active-1", @@ -170,7 +173,11 @@ describe("daemon session summarizer", () => { messages: options.messages, modelRegistry: {}, state: { streamingMessage: undefined }, - sessionManager: { appendAgentStatus: () => {} }, + sessionManager: { + appendAgentStatus: options.appendAgentStatus ?? (() => {}), + getLatestAgentStatus: () => options.persistedStatus, + getLeafId: options.getLeafId ?? (() => null), + }, }, }, } as unknown as ActiveSessionState; @@ -201,6 +208,109 @@ describe("daemon session summarizer", () => { expect(onStatusChanged).toHaveBeenCalledOnce(); }); + function failingIdleSetup( + stateOptions: Parameters[0], + generateFn: () => Promise = async () => undefined, + ) { + const state = makeState(stateOptions); + const generate = vi.fn(generateFn); + const summarizer = new DaemonSessionSummarizer(() => [state], undefined, generate); + const internal = summarizer as unknown as { + summarize(state: ActiveSessionState): Promise; + failedIdleGenerations: Map; + }; + return { state, generate, summarizer, internal }; + } + + // Warmup pins the ceiling itself: repeated failing idle sweeps stop paying + // after three attempts and never persist the fabricated in-memory fallback. + test.each([ + { + rearm: "branch navigation moving the leaf at the same length", + trigger: (leaf: { id: string }) => { + leaf.id = "leaf-b"; + }, + }, + { + rearm: "the backoff elapsing so external failures recover", + trigger: () => vi.setSystemTime(Date.now() + 31 * 60_000), + }, + ])("the exhausted idle retry ceiling re-arms on $rearm", async ({ trigger }) => { + vi.useFakeTimers(); + try { + const appendAgentStatus = vi.fn(); + const leaf = { id: "leaf-a" }; + const { state, generate, internal } = failingIdleSetup({ + messages: [userMessage("hi")], + isSessionActive: false, + appendAgentStatus, + getLeafId: () => leaf.id, + }); + + for (let sweep = 0; sweep < 5; sweep++) { + await internal.summarize(state); + } + expect(generate).toHaveBeenCalledTimes(3); + expect(appendAgentStatus).not.toHaveBeenCalled(); + expect(state.summaryState).toEqual({ summary: "", taskState: "needs_input", basedOnMessageCount: 1 }); + + trigger(leaf); + await internal.summarize(state); + expect(generate).toHaveBeenCalledTimes(4); + expect(appendAgentStatus).not.toHaveBeenCalled(); + } finally { + vi.useRealTimers(); + } + }); + + test("a forget() during an in-flight idle generation leaves no failure record behind", async () => { + let release!: () => void; + const gate = new Promise((resolveGate) => { + release = resolveGate; + }); + const { state, summarizer, internal } = failingIdleSetup( + { messages: [userMessage("hi")], isSessionActive: false }, + async () => { + await gate; + return undefined; + }, + ); + + const pass = internal.summarize(state); + summarizer.forget("active-1"); + release(); + await pass; + + expect(internal.failedIdleGenerations.size).toBe(0); + }); + + test("an idle re-settle matching the latest persisted status appends nothing", async () => { + const appendAgentStatus = vi.fn(); + const persisted: AgentStatus = { + summary: "Awaiting review", + taskState: "needs_input", + basedOnMessageCount: 1, + }; + const state = makeState({ + messages: [userMessage("hi")], + isSessionActive: false, + persistedStatus: persisted, + appendAgentStatus, + }); + + const onStatusChanged = vi.fn(); + const summarizer = new DaemonSessionSummarizer( + () => [state], + onStatusChanged, + async () => ({ summary: "Awaiting review", taskState: "needs_input" as const }), + ); + await (summarizer as unknown as { summarize(state: ActiveSessionState): Promise }).summarize(state); + + expect(appendAgentStatus).not.toHaveBeenCalled(); + expect(onStatusChanged).toHaveBeenCalledOnce(); + expect(state.summaryState).toEqual(persisted); + }); + test("a working refresh with unchanged text stays quiet", async () => { const previous: AgentStatus = { summary: "Working on it", taskState: "needs_input", basedOnMessageCount: 2 }; const state = makeState({ diff --git a/packages/coding-agent/test/daemon-supervisor-monitor.test.ts b/packages/coding-agent/test/daemon-supervisor-monitor.test.ts index 8d6954ffd..79c25c499 100644 --- a/packages/coding-agent/test/daemon-supervisor-monitor.test.ts +++ b/packages/coding-agent/test/daemon-supervisor-monitor.test.ts @@ -1799,6 +1799,202 @@ describe("daemon worker supervisor monitoring", () => { ); }); + /** A failed worker whose process identity is verifiably current (this test process). */ + function retryableWorkerFixture(prefix: string) { + const root = { + id: `active-${prefix}`, + activeSessionId: `active-${prefix}`, + sessionId: `session-${prefix}`, + cwd: "/tmp", + } as SessionSummary; + const worker = { + descriptor: { + workerId: `worker-${prefix}`, + rootActiveSessionId: `active-${prefix}`, + rootSessionId: `session-${prefix}`, + lifecycle: "failed" as string, + pid: process.pid, + processStartId: getProcessStartId(process.pid), + stopRequestedAt: undefined as string | undefined, + }, + client: undefined as { request: ReturnType } | undefined, + recovery: undefined as Promise | undefined, + summaries: new Map(), + intentionalStop: false, + deferredRecoveryRounds: 0, + }; + return { root, worker }; + } + + function retrySupervisor(worker: { descriptor: { workerId: string } }, overrides: Record) { + return Object.assign(Object.create(DaemonSupervisor.prototype), { + workers: new Map([[worker.descriptor.workerId, worker]]), + clients: new Set(), + shuttingDown: false, + persistWorker: vi.fn(), + ...overrides, + }) as object; + } + + it("retries recovery on create reuse for a failed worker with a current process identity", async () => { + const { worker } = retryableWorkerFixture("failed-live"); + worker.deferredRecoveryRounds = 11; + const recoverWorker = vi.fn(async () => { + worker.descriptor.lifecycle = "ready"; + }); + const supervisor = retrySupervisor(worker, { + recoverWorker, + isWorkerReadyForCreate: (target: typeof worker) => target.descriptor.lifecycle === "ready", + }) as unknown as { + reuseWorkerForCreate( + target: typeof worker, + ownerClientId: undefined, + sessionPath: string, + ): Promise; + }; + + await expect(supervisor.reuseWorkerForCreate(worker, undefined, "/tmp/failed-live.jsonl")).resolves.toBe(worker); + expect(recoverWorker).toHaveBeenCalledWith(worker); + expect(worker.deferredRecoveryRounds).toBe(0); + }); + + it("recovers a failed identity-current worker when a command is forwarded to it", async () => { + const { root, worker } = retryableWorkerFixture("failed-root"); + const request = vi.fn(async () => ({ type: "response", command: "get_state", success: true, data: root })); + const recoverWorker = vi.fn(async () => { + worker.descriptor.lifecycle = "ready"; + worker.client = { request }; + }); + const supervisor = retrySupervisor(worker, { recoverWorker }) as unknown as { + forwardToWorker( + target: typeof worker, + command: { type: "get_state"; activeSessionId: string }, + ): Promise<{ success: boolean; data?: SessionSummary }>; + }; + + const response = await supervisor.forwardToWorker(worker, { + type: "get_state", + activeSessionId: "active-failed-root", + }); + expect(recoverWorker).toHaveBeenCalledOnce(); + expect(response.success).toBe(true); + expect(response.data?.workerState).toBe("ready"); + }); + + it("joins an in-flight recovery instead of failing a concurrent forwarded command", async () => { + const { root, worker } = retryableWorkerFixture("race"); + const request = vi.fn(async () => ({ type: "response", command: "get_state", success: true, data: root })); + const release = createDeferred(); + const recoverWorker = vi.fn(() => { + worker.recovery = release.promise.then(() => { + worker.descriptor.lifecycle = "ready"; + worker.client = { request }; + worker.recovery = undefined; + }); + return worker.recovery; + }); + const supervisor = retrySupervisor(worker, { recoverWorker }) as unknown as { + forwardToWorker( + target: typeof worker, + command: { type: "get_state"; activeSessionId: string }, + ): Promise<{ success: boolean }>; + }; + + const first = supervisor.forwardToWorker(worker, { type: "get_state", activeSessionId: "active-race" }); + await Promise.resolve(); + const second = supervisor.forwardToWorker(worker, { type: "get_state", activeSessionId: "active-race" }); + await Promise.resolve(); + release.resolve(); + const [firstResponse, secondResponse] = await Promise.all([first, second]); + expect(firstResponse.success).toBe(true); + expect(secondResponse.success).toBe(true); + expect(recoverWorker).toHaveBeenCalledOnce(); + }); + + it("runs at most one recovery ladder for a single attach touch", async () => { + const { root, worker } = retryableWorkerFixture("double"); + worker.summaries.set("active-double", root); + // Recovery exhausts and parks the same live worker failed again. + const recoverWorker = vi.fn(async () => { + worker.descriptor.lifecycle = "failed"; + }); + const supervisor = retrySupervisor(worker, { recoverWorker }) as unknown as { + attachClient( + client: DaemonSocketClient, + command: { type: "attach"; activeSessionId: string }, + ): Promise; + }; + seedSupervisorRoster(supervisor as object as Parameters[0], worker); + + await expect( + supervisor.attachClient({} as DaemonSocketClient, { type: "attach", activeSessionId: "active-double" }), + ).rejects.toThrow("Session worker is failed"); + expect(recoverWorker).toHaveBeenCalledOnce(); + }); + + it("does not revive a stop-marked failed worker on attach", async () => { + const { worker } = retryableWorkerFixture("stopped"); + worker.descriptor.stopRequestedAt = new Date().toISOString(); + worker.intentionalStop = true; + const recoverWorker = vi.fn(async () => {}); + const persistWorker = vi.fn(); + const supervisor = retrySupervisor(worker, { + recoverWorker, + persistWorker, + findWorkerForClient: vi.fn(async () => { + throw new Error("Unknown active session: active-stopped"); + }), + }) as unknown as { + attachClient( + client: DaemonSocketClient, + command: { type: "attach"; activeSessionId: string }, + ): Promise; + }; + + await expect( + supervisor.attachClient({} as DaemonSocketClient, { type: "attach", activeSessionId: "active-stopped" }), + ).rejects.toThrow("Unknown active session: active-stopped"); + expect(recoverWorker).not.toHaveBeenCalled(); + expect(persistWorker).not.toHaveBeenCalled(); + expect(worker.intentionalStop).toBe(true); + expect(worker.descriptor.stopRequestedAt).toBeDefined(); + expect(worker.descriptor.lifecycle).toBe("failed"); + }); + + it("answers a descriptor-known unaddressable root session with a structured recovering error", async () => { + const { worker } = retryableWorkerFixture("gap"); + worker.descriptor.lifecycle = "recovering"; + const { worker: hexWorker } = retryableWorkerFixture("hex"); + hexWorker.descriptor.rootActiveSessionId = "00ff77aa11bb22cc"; + hexWorker.descriptor.rootSessionId = "0123456789abcdef"; + hexWorker.descriptor.lifecycle = "recovering"; + const supervisor = retrySupervisor(worker, { + matchWorkers: () => [], + refreshWorkerSummaries: vi.fn(async () => {}), + }) as unknown as { findWorker(selector: string): Promise }; + (supervisor as unknown as { workers: Map }).workers.set("worker-hex", hexWorker); + + await expect(supervisor.findWorker("active-gap")).rejects.toMatchObject({ + name: "DaemonSessionRecoveringError", + code: "session_recovering", + activeSessionId: "active-gap", + }); + // A stable-session-id selector still reports the ACTIVE id on the wire. + await expect(supervisor.findWorker("session-gap")).rejects.toMatchObject({ + code: "session_recovering", + activeSessionId: "active-gap", + }); + // Suffix addressing follows the roster rule: an unambiguous hex suffix of a recovering root is recovering. + await expect(supervisor.findWorker("77aa11bb22cc")).rejects.toMatchObject({ + code: "session_recovering", + activeSessionId: "00ff77aa11bb22cc", + }); + // Failed workers keep the unknown answer so clients take the create fallback that reclaims them. + worker.descriptor.lifecycle = "failed"; + await expect(supervisor.findWorker("active-gap")).rejects.toThrow("Unknown active session: active-gap"); + await expect(supervisor.findWorker("missing")).rejects.toThrow("Unknown active session: missing"); + }); + it("waits for worker recovery before reusing a saved session", async () => { const root = { id: "active-root", activeSessionId: "active-root", sessionId: "session-root", cwd: "/tmp" }; const recovery = createDeferred(); diff --git a/packages/coding-agent/test/daemon-supervisor-ownership.test.ts b/packages/coding-agent/test/daemon-supervisor-ownership.test.ts index c89e5c03f..21941b3b0 100644 --- a/packages/coding-agent/test/daemon-supervisor-ownership.test.ts +++ b/packages/coding-agent/test/daemon-supervisor-ownership.test.ts @@ -2,7 +2,8 @@ import { existsSync, mkdirSync, mkdtempSync, readdirSync, readFileSync, rmSync, import { tmpdir } from "node:os"; import { join } from "node:path"; import lockfile from "proper-lockfile"; -import { afterEach, describe, expect, it } from "vitest"; +import { afterEach, describe, expect, it, vi } from "vitest"; +import { getProcessStartId } from "../src/core/session-lease.js"; import { DaemonSupervisor } from "../src/modes/daemon/daemon-supervisor.js"; import { acquireDaemonShutdownAdmission, @@ -10,6 +11,9 @@ import { assertDaemonSupervisorOwnerCurrent, persistDaemonStartupFenceFromOwner, } from "../src/modes/daemon/daemon-supervisor-ownership.js"; +import * as childProcessModule from "../src/utils/child-process.js"; +import { isZombieProcess } from "../src/utils/child-process.js"; +import { spawnZombieProcess } from "./fixtures/zombie-process.js"; type Ownership = Awaited>; @@ -175,6 +179,63 @@ describe("daemon supervisor ownership registry", () => { await reaped.release(); }); + it("confirms owner zombie state at most once per interval across fence polls", async () => { + const paths = createPaths(); + const owner = await acquire(paths, "fence-owner"); + const claim = { + generation: owner.record.generation, + pid: owner.record.pid, + processStartId: owner.record.processStartId, + socketPath: owner.record.socketPath, + }; + const fingerprint = await assertDaemonSupervisorOwnerCurrent(claim, undefined, paths.registryDir); + const zombieSpy = vi.spyOn(childProcessModule, "isZombieProcess"); + const aliveSpy = vi.spyOn(childProcessModule, "isProcessAlive"); + try { + // Steady-state fence polls (validated fingerprint, confirmed owner) must + // not run a ps-backed probe per 250ms tick; existence stays kill(0)-cheap. + await assertDaemonSupervisorOwnerCurrent(claim, fingerprint, paths.registryDir); + await assertDaemonSupervisorOwnerCurrent(claim, fingerprint, paths.registryDir); + expect(zombieSpy.mock.calls.length + aliveSpy.mock.calls.length).toBe(0); + // Confirmations expire: after the interval the owner is re-probed once + // (the same timestamp the cache's bounded prune sweeps on). + vi.useFakeTimers(); + try { + vi.setSystemTime(Date.now() + 6000); + await assertDaemonSupervisorOwnerCurrent(claim, fingerprint, paths.registryDir); + expect(zombieSpy).toHaveBeenCalledTimes(1); + } finally { + vi.useRealTimers(); + } + } finally { + zombieSpy.mockRestore(); + aliveSpy.mockRestore(); + await owner.release(); + } + }); + + it.skipIf(process.platform === "win32")("treats a zombie owner process as reclaimable", async () => { + const paths = createPaths(); + const { zombiePid, dispose } = await spawnZombieProcess(); + try { + expect(isZombieProcess(zombiePid)).toBe(true); + const stale = await acquire(paths, "zombie-owner"); + const ownerPath = join(ownerDir(paths, "zombie-owner"), "owner.json"); + const record = readJson(ownerPath); + record.pid = zombiePid; + const zombieStartId = getProcessStartId(zombiePid); + if (zombieStartId) record.processStartId = zombieStartId; + else delete record.processStartId; + writeFileSync(ownerPath, `${JSON.stringify(record, null, 2)}\n`); + + const successor = await acquire(paths, "successor-owner"); + await successor.release(); + await stale.release(); + } finally { + dispose(); + } + }); + it("does not resurrect the shutdown admission when release overtakes an in-flight renew", async () => { const paths = createPaths(); mkdirSync(paths.registryDir, { recursive: true, mode: 0o700 }); diff --git a/packages/coding-agent/test/event-log-faults.test.ts b/packages/coding-agent/test/event-log-faults.test.ts new file mode 100644 index 000000000..34a3892f8 --- /dev/null +++ b/packages/coding-agent/test/event-log-faults.test.ts @@ -0,0 +1,70 @@ +import { appendFileSync, mkdtempSync, readFileSync, rmSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { EventLog } from "../src/core/event-log.js"; + +/** Armable fs faults; everything passes through to the real fs by default. */ +const faults: { shortWriteOnce?: boolean; truncateError?: Error } = {}; + +vi.mock("node:fs", async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + writeSync: ((fd: number, data: Uint8Array) => { + if (faults.shortWriteOnce && data.length > 1) { + faults.shortWriteOnce = false; + return actual.writeSync(fd, data.subarray(0, Math.floor(data.length / 2))); + } + return actual.writeSync(fd, data); + }) as typeof actual.writeSync, + ftruncateSync: ((fd: number, len?: number) => { + if (faults.truncateError) throw faults.truncateError; + return actual.ftruncateSync(fd, len); + }) as typeof actual.ftruncateSync, + }; +}); + +describe("event log fault injection", () => { + let dir: string; + + beforeEach(() => { + dir = mkdtempSync(join(tmpdir(), "prime-event-log-faults-")); + }); + + afterEach(() => { + faults.shortWriteOnce = undefined; + faults.truncateError = undefined; + rmSync(dir, { recursive: true, force: true }); + }); + + it("fails the append on a short write, leaving a repairable torn tail", () => { + const path = join(dir, "log.jsonl"); + const log = new EventLog(path); + log.appendSync([{ v: 1, id: "committed" }]); + faults.shortWriteOnce = true; + + // Neither completed nor reclaimed: the torn tail is the tolerated shape. + expect(() => log.appendSync([{ v: 1, id: "short-write" }])).toThrow(/short write/); + const parse = (line: string) => JSON.parse(line) as { id?: string }; + expect(new EventLog(path).replaySync(parse)).toEqual([{ v: 1, id: "committed" }]); + log.appendSync([{ v: 1, id: "next" }]); + expect(new EventLog(path).replaySync(parse)).toEqual([ + { v: 1, id: "committed" }, + { v: 1, id: "next" }, + ]); + }); + + it("refuses to append through a tail it could not repair", () => { + const path = join(dir, "log.jsonl"); + const log = new EventLog(path); + log.appendSync([{ v: 1, id: "committed" }]); + appendFileSync(path, '{"torn'); + const before = readFileSync(path, "utf8"); + + faults.truncateError = new Error("EPERM: append-only file"); + // Writing through would weld the torn tail to the new record forever. + expect(() => log.appendSync([{ v: 1, id: "next" }])).toThrow(/EPERM/); + expect(readFileSync(path, "utf8")).toBe(before); + }); +}); diff --git a/packages/coding-agent/test/event-log.test.ts b/packages/coding-agent/test/event-log.test.ts index 1a37d1ebf..bdde28c4a 100644 --- a/packages/coding-agent/test/event-log.test.ts +++ b/packages/coding-agent/test/event-log.test.ts @@ -27,9 +27,11 @@ describe("event log substrate", () => { const path = join(dir, "log.jsonl"); const log = new EventLog(path); log.appendSync([{ v: 1, keep: true }]); - // A newline-completion here would hand this line to strict parsers as - // permanent fail-closed interior poison; truncation must win. + // Tail rule: uncommitted append — see the EventLog module doc. writeFileSync(path, `${readFileSync(path, "utf8")}{"not":"a valid record"}`); + expect(new EventLog(path).replaySync((line) => JSON.parse(line) as { v?: number })).toEqual([ + { v: 1, keep: true }, + ]); log.appendSync([{ v: 1, second: true }]); const strict = new EventLog(path).replaySync((line, index) => { const value = JSON.parse(line) as { v?: number }; diff --git a/packages/coding-agent/test/exif-orientation.test.ts b/packages/coding-agent/test/exif-orientation.test.ts new file mode 100644 index 000000000..d1d60adb6 --- /dev/null +++ b/packages/coding-agent/test/exif-orientation.test.ts @@ -0,0 +1,19 @@ +import type * as PhotonModule from "@silvia-odwyer/photon-node"; +import { describe, expect, it } from "vitest"; +import { applyExifOrientation } from "../src/utils/exif-orientation.js"; +import type { PhotonImageType } from "../src/utils/photon.js"; + +type Photon = typeof PhotonModule; + +describe("exif orientation", () => { + it("terminates the WebP chunk scan when a chunk size has the high bit set", () => { + const bytes = new Uint8Array(20); + bytes.set([0x52, 0x49, 0x46, 0x46], 0); // RIFF + bytes.set([0x57, 0x45, 0x42, 0x50], 8); // WEBP + bytes.set([0x4a, 0x55, 0x4e, 0x4b], 12); // JUNK + bytes.set([0xf8, 0xff, 0xff, 0xff], 16); // chunk size 0xFFFFFFF8: read signed (-8), the scan re-visits this chunk forever + // Orientation resolves to 1, so neither photon nor the image is ever touched. + const image = {} as PhotonImageType; + expect(applyExifOrientation({} as Photon, image, bytes)).toBe(image); + }); +}); diff --git a/packages/coding-agent/test/file-lines.test.ts b/packages/coding-agent/test/file-lines.test.ts index 92421e9f9..649a4623c 100644 --- a/packages/coding-agent/test/file-lines.test.ts +++ b/packages/coding-agent/test/file-lines.test.ts @@ -42,6 +42,18 @@ describe("readLinesAsBuffers", () => { expect((await lines.next()).done).toBe(true); }); + it("passes the byte range through to the underlying read stream", async () => { + fsMocks.createReadStream.mockReturnValue(Readable.from([Buffer.from("cd\nef")])); + + const lines: string[] = []; + for await (const line of readLinesAsBuffers("/unused", { start: 2, end: 6 })) { + lines.push(line.toString("utf8")); + } + + expect(fsMocks.createReadStream).toHaveBeenCalledWith("/unused", { start: 2, end: 6 }); + expect(lines).toEqual(["cd", "ef"]); + }); + it("releases pending chunks before yielding an EOF-terminated multi-chunk record", async () => { const chunkSize = 64 * 1024; const firstPart = Buffer.alloc(chunkSize, 0x61); diff --git a/packages/coding-agent/test/fixtures/zombie-process.ts b/packages/coding-agent/test/fixtures/zombie-process.ts new file mode 100644 index 000000000..f45cd46eb --- /dev/null +++ b/packages/coding-agent/test/fixtures/zombie-process.ts @@ -0,0 +1,28 @@ +import { spawn } from "node:child_process"; +import { isZombieProcess } from "../../src/utils/child-process.js"; + +/** Fork a perl child that exits unreaped while its parent sleeps: a real zombie for liveness pins. */ +export async function spawnZombieProcess(childPerl = ""): Promise<{ zombiePid: number; dispose(): void }> { + const parent = spawn( + "perl", + ["-e", `$| = 1; my $pid = fork(); if ($pid) { print "$pid\\n"; sleep 30 } else { ${childPerl} exit 0 }`], + { stdio: ["ignore", "pipe", "ignore"] }, + ); + const zombiePid = await new Promise((resolvePid, rejectPid) => { + const timer = setTimeout(() => rejectPid(new Error("Timed out waiting for the zombie pid")), 5000); + let output = ""; + parent.stdout?.on("data", (chunk: Buffer) => { + output += chunk.toString(); + const parsed = Number.parseInt(output.trim(), 10); + if (Number.isInteger(parsed) && parsed > 0) { + clearTimeout(timer); + resolvePid(parsed); + } + }); + }); + const deadline = Date.now() + 5000; + while (!isZombieProcess(zombiePid) && Date.now() < deadline) { + await new Promise((resolveDelay) => setTimeout(resolveDelay, 25)); + } + return { zombiePid, dispose: () => parent.kill("SIGKILL") }; +} diff --git a/packages/coding-agent/test/frontmatter.test.ts b/packages/coding-agent/test/frontmatter.test.ts index 3879fedf6..4af3abefc 100644 --- a/packages/coding-agent/test/frontmatter.test.ts +++ b/packages/coding-agent/test/frontmatter.test.ts @@ -2,6 +2,13 @@ import { describe, expect, it } from "vitest"; import { parseFrontmatter, stripFrontmatter } from "../src/utils/frontmatter.js"; describe("parseFrontmatter", () => { + it("parses frontmatter behind a UTF-8 BOM", () => { + const input = "\uFEFF---\nname: bom-skill\n---\nBody"; + const result = parseFrontmatter(input); + expect(result.frontmatter).toEqual({ name: "bom-skill" }); + expect(result.body).toBe("Body"); + }); + it("parses keys, strips quotes, and returns body", () => { const input = "---\nname: \"skill-name\"\ndescription: 'A desc'\nfoo-bar: value\n---\n\nBody text"; const { frontmatter, body } = parseFrontmatter>(input); diff --git a/packages/coding-agent/test/initial-message.test.ts b/packages/coding-agent/test/initial-message.test.ts index 22b1612da..6b9ea1a52 100644 --- a/packages/coding-agent/test/initial-message.test.ts +++ b/packages/coding-agent/test/initial-message.test.ts @@ -16,10 +16,11 @@ describe("buildInitialMessage", () => { const parsed = createArgs(["Summarize the text given"]); const result = buildInitialMessage({ parsed, - stdinContent: "README contents\n", + stdinContent: "README contents", }); - expect(result.initialMessage).toBe("README contents\nSummarize the text given"); + // Unterminated stdin must not glue onto the instruction as one word. + expect(result.initialMessage).toBe("README contents\n\nSummarize the text given"); expect(parsed.messages).toEqual([]); }); @@ -38,11 +39,11 @@ describe("buildInitialMessage", () => { const parsed = createArgs(["Explain it", "Second message"]); const result = buildInitialMessage({ parsed, - stdinContent: "stdin\n", - fileText: "file\n", + stdinContent: "stdin", + fileText: "file", }); - expect(result.initialMessage).toBe("stdin\nfile\nExplain it"); + expect(result.initialMessage).toBe("stdin\n\nfile\n\nExplain it"); expect(parsed.messages).toEqual(["Second message"]); }); }); diff --git a/packages/coding-agent/test/ipython-provisioner.test.ts b/packages/coding-agent/test/ipython-provisioner.test.ts index 02bb0ef4a..27e7263d6 100644 --- a/packages/coding-agent/test/ipython-provisioner.test.ts +++ b/packages/coding-agent/test/ipython-provisioner.test.ts @@ -306,6 +306,33 @@ describe("IpythonKernelProvisioner", () => { expect(provisioner.manager).toBe(manager); }); + function primeKernelMemo(provisioner: IpythonKernelProvisioner, manager: KernelClient) { + Object.assign(provisioner as unknown as { managerPromise: Promise; startedManager: KernelClient }, { + managerPromise: Promise.resolve(manager), + startedManager: manager, + }); + } + + it("drops a dead kernel memo so ensure() restarts instead of reusing it", async () => { + const { python, countRuns } = writeFakePython(); + const provisioner = new IpythonKernelProvisioner(tempDir, { python }); + primeKernelMemo(provisioner, { isRunning: false, isDefunct: true } as unknown as KernelClient); + + await expect(provisioner.ensure()).rejects.toThrow(/Kernel exited before ready/); + expect(countRuns()).toBe(1); + }); + + it("keeps the memo for a kernel that is repairing itself, not defunct", async () => { + const { countRuns } = writeFakePython(); + const provisioner = new IpythonKernelProvisioner(tempDir, {}); + const repairing = { isRunning: false, isDefunct: false } as unknown as KernelClient; + primeKernelMemo(provisioner, repairing); + + // A second provisioner kernel during protocol repair would split the snapshot dir. + await expect(provisioner.ensure()).resolves.toBe(repairing); + expect(countRuns()).toBe(0); + }); + it("removes startup progress listeners when an ensure caller is aborted", async () => { const provisioner = new IpythonKernelProvisioner(tempDir, {}); Object.assign( diff --git a/packages/coding-agent/test/main-interactive-routing.test.ts b/packages/coding-agent/test/main-interactive-routing.test.ts index 726b62aaf..4c4268deb 100644 --- a/packages/coding-agent/test/main-interactive-routing.test.ts +++ b/packages/coding-agent/test/main-interactive-routing.test.ts @@ -12,6 +12,7 @@ import { type InteractiveDaemonStartupDecision, isClientOwnedDaemonSession, parseAgentsViewCommand, + resolveActiveSessionLookupFailure, resolveRuntimeSessionOptions, shouldEnsureDaemonBeforeActiveSessionLookup, shouldEnsureInteractiveDaemonForStartup, @@ -23,6 +24,7 @@ import { shouldUseDaemonInteractive, shouldUseEphemeralSessionManagerForDaemonInteractive, } from "../src/main.js"; +import { DaemonSessionRecoveringError } from "../src/modes/daemon/daemon-errors.js"; import type { SessionSummary } from "../src/modes/index.js"; describe("interactive startup routing", () => { @@ -448,6 +450,34 @@ describe("runtime session option resolution", () => { gates: { commands: ["npm test"], maxRetries: 3, timeoutMs: 1000 }, }); }); + + test("classifies active-session lookup failures: recovering is typed, unknown falls back", () => { + const recovering = resolveActiveSessionLookupFailure({ + type: "response", + command: "get_state", + success: false, + error: "Active session active-gap is recovering; retry shortly", + errorInfo: { code: "session_recovering", activeSessionId: "active-gap" }, + }); + expect(recovering).toBeInstanceOf(DaemonSessionRecoveringError); + expect((recovering as DaemonSessionRecoveringError).activeSessionId).toBe("active-gap"); + expect( + resolveActiveSessionLookupFailure({ + type: "response", + command: "get_state", + success: false, + error: "Unknown active session: active-gap", + }), + ).toBeUndefined(); + expect( + resolveActiveSessionLookupFailure({ + type: "response", + command: "get_state", + success: false, + error: "socket closed", + }), + ).toBeInstanceOf(Error); + }); }); function makeSessionSummary(overrides: Partial): SessionSummary { diff --git a/packages/coding-agent/test/migrations.test.ts b/packages/coding-agent/test/migrations.test.ts index 01f39a3f8..ebc5ab2d9 100644 --- a/packages/coding-agent/test/migrations.test.ts +++ b/packages/coding-agent/test/migrations.test.ts @@ -1,9 +1,36 @@ -import { existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs"; +import { + chmodSync, + existsSync, + lstatSync, + mkdirSync, + mkdtempSync, + readFileSync, + rmSync, + statSync, + symlinkSync, + writeFileSync, +} from "node:fs"; import { tmpdir } from "node:os"; import { join } from "node:path"; -import { afterEach, describe, expect, it } from "vitest"; +import { afterEach, describe, expect, it, vi } from "vitest"; import { ENV_AGENT_DIR } from "../src/config.js"; -import { migrateLegacySessionDirsToSessionRoot, migrateSessionsFromAgentRoot } from "../src/migrations.js"; +import { + migrateAuthToAuthJson, + migrateLegacySessionDirsToSessionRoot, + migrateSessionsFromAgentRoot, +} from "../src/migrations.js"; + +const atomicWriteMock = vi.hoisted(() => ({ error: undefined as Error | undefined })); +vi.mock("../src/utils/atomic-file.js", async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + writeFileAtomicSync: (path: string, data: string, options?: object) => { + if (atomicWriteMock.error && path.endsWith("auth.json")) throw atomicWriteMock.error; + return actual.writeFileAtomicSync(path, data, options); + }, + }; +}); describe("session migrations", () => { const tempDirs: string[] = []; @@ -105,3 +132,82 @@ describe("session migrations", () => { expect(existsSync(join(sessionsDir, "session-2.jsonl"))).toBe(false); }); }); + +describe("auth migration ordering", () => { + const tempDirs: string[] = []; + const previousAgentDir = process.env[ENV_AGENT_DIR]; + + afterEach(() => { + vi.restoreAllMocks(); + atomicWriteMock.error = undefined; + if (previousAgentDir === undefined) { + delete process.env[ENV_AGENT_DIR]; + } else { + process.env[ENV_AGENT_DIR] = previousAgentDir; + } + for (const dir of tempDirs.splice(0)) { + rmSync(dir, { recursive: true, force: true }); + } + }); + + function makeAgentDir(): string { + const agentDir = mkdtempSync(join(tmpdir(), "prime-agent-auth-migration-")); + tempDirs.push(agentDir); + process.env[ENV_AGENT_DIR] = agentDir; + return agentDir; + } + + it("preserves the settings file's own mode when stripping apiKeys", () => { + const agentDir = makeAgentDir(); + const settingsPath = join(agentDir, "settings.json"); + writeFileSync(settingsPath, JSON.stringify({ theme: "dark", apiKeys: { openai: "sk-key" } })); + chmodSync(settingsPath, 0o600); + + migrateAuthToAuthJson(); + + expect(statSync(settingsPath).mode & 0o777).toBe(0o600); + expect(JSON.parse(readFileSync(settingsPath, "utf-8")).apiKeys).toBeUndefined(); + }); + + it("strips apiKeys through a symlinked settings.json without replacing the alias", () => { + const agentDir = makeAgentDir(); + const realSettings = join(agentDir, "dotfiles-settings.json"); + const settingsPath = join(agentDir, "settings.json"); + writeFileSync(realSettings, JSON.stringify({ theme: "dark", apiKeys: { openai: "sk-key" } })); + symlinkSync(realSettings, settingsPath); + + migrateAuthToAuthJson(); + + expect(lstatSync(settingsPath).isSymbolicLink()).toBe(true); + expect(JSON.parse(readFileSync(realSettings, "utf-8")).apiKeys).toBeUndefined(); + expect(JSON.parse(readFileSync(join(agentDir, "auth.json"), "utf-8")).openai.key).toBe("sk-key"); + }); + + it("migrates credentials through a dangling auth.json symlink to its target", () => { + const agentDir = makeAgentDir(); + writeFileSync(join(agentDir, "oauth.json"), JSON.stringify({ anthropic: { access: "token" } })); + const target = join(agentDir, "vault-auth.json"); + symlinkSync(target, join(agentDir, "auth.json")); + + migrateAuthToAuthJson(); + + expect(lstatSync(join(agentDir, "auth.json")).isSymbolicLink()).toBe(true); + expect(JSON.parse(readFileSync(target, "utf-8")).anthropic.type).toBe("oauth"); + }); + + it("keeps every credential source when the auth.json write fails", () => { + const agentDir = makeAgentDir(); + const oauthPath = join(agentDir, "oauth.json"); + const settingsPath = join(agentDir, "settings.json"); + writeFileSync(oauthPath, JSON.stringify({ anthropic: { access: "token" } })); + writeFileSync(settingsPath, JSON.stringify({ theme: "dark", apiKeys: { openai: "sk-key" } })); + atomicWriteMock.error = new Error("disk full"); + + expect(() => migrateAuthToAuthJson()).toThrow("disk full"); + + // A crash at the destination write must leave both sources recoverable. + expect(existsSync(join(agentDir, "auth.json"))).toBe(false); + expect(existsSync(oauthPath)).toBe(true); + expect(JSON.parse(readFileSync(settingsPath, "utf-8")).apiKeys).toEqual({ openai: "sk-key" }); + }); +}); diff --git a/packages/coding-agent/test/model-resolver.test.ts b/packages/coding-agent/test/model-resolver.test.ts index 4988f7ad4..1ba158882 100644 --- a/packages/coding-agent/test/model-resolver.test.ts +++ b/packages/coding-agent/test/model-resolver.test.ts @@ -1,4 +1,4 @@ -import type { Model } from "@earendil-works/pi-ai"; +import { getModels, type KnownProvider, type Model } from "@earendil-works/pi-ai"; import { describe, expect, test, vi } from "vitest"; import { defaultModelPerProvider, @@ -303,8 +303,19 @@ describe("default model selection", () => { expect(defaultModelPerProvider["prime-inference"]).toBe("z-ai/glm-5.2"); }); + test("every per-provider default exists in the model catalog", () => { + for (const [provider, modelId] of Object.entries(defaultModelPerProvider)) { + const models = getModels(provider as KnownProvider); + if (models.length === 0) continue; + expect( + models.map((model) => model.id), + `default for ${provider}`, + ).toContain(modelId); + } + }); + test("zai, minimax, and cerebras defaults track current models", () => { - expect(defaultModelPerProvider.zai).toBe("glm-5.1"); + expect(defaultModelPerProvider.zai).toBe("glm-5.3"); expect(defaultModelPerProvider.minimax).toBe("MiniMax-M2.7"); expect(defaultModelPerProvider["minimax-cn"]).toBe("MiniMax-M2.7"); expect(defaultModelPerProvider.cerebras).toBe("gpt-oss-120b"); diff --git a/packages/coding-agent/test/output-accumulator.test.ts b/packages/coding-agent/test/output-accumulator.test.ts new file mode 100644 index 000000000..1db7d0b96 --- /dev/null +++ b/packages/coding-agent/test/output-accumulator.test.ts @@ -0,0 +1,73 @@ +import { mkdtempSync, rmSync, statSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { afterEach, beforeEach, describe, expect, it } from "vitest"; +import { executeBashWithOperations } from "../src/core/bash-executor.js"; +import type { BashOperations } from "../src/core/tools/bash.js"; +import { OutputAccumulator } from "../src/core/tools/output-accumulator.js"; + +describe("OutputAccumulator temp spill", () => { + let realTmp: string | undefined; + let scratch: string; + + beforeEach(() => { + scratch = mkdtempSync(join(tmpdir(), "pi-accumulator-")); + realTmp = process.env.TMPDIR; + }); + + afterEach(() => { + if (realTmp === undefined) delete process.env.TMPDIR; + else process.env.TMPDIR = realTmp; + rmSync(scratch, { recursive: true, force: true }); + }); + + it("degrades a failed spill to the in-memory tail without failing the close", async () => { + process.env.TMPDIR = join(scratch, "does-not-exist"); + const accumulator = new OutputAccumulator({ maxBytes: 8, maxLines: 100 }); + accumulator.append(Buffer.from("0123456789abcdef\n")); + accumulator.append(Buffer.from("tail\n")); + accumulator.finish(); + + // The open error lands while the close is waiting: degraded spill, not a tool failure. + await expect(accumulator.closeTempFile()).resolves.toBeUndefined(); + const snapshot = accumulator.snapshot(); + expect(snapshot.fullOutputPath).toBeUndefined(); + expect(snapshot.content).toContain("tail"); + }); + + it("swallows spill-cleanup failures, keeping the tail and the process", async () => { + // TMPDIR is a FILE: the open fails ENOTDIR and so does the cleanup rm. + const blocker = join(scratch, "not-a-dir"); + writeFileSync(blocker, "x"); + process.env.TMPDIR = blocker; + const accumulator = new OutputAccumulator({ maxBytes: 8, maxLines: 100 }); + accumulator.append(Buffer.from("0123456789abcdef\n")); + accumulator.append(Buffer.from("tail\n")); + accumulator.finish(); + + await expect(accumulator.closeTempFile()).resolves.toBeUndefined(); + const snapshot = accumulator.snapshot(); + expect(snapshot.fullOutputPath).toBeUndefined(); + expect(snapshot.content).toContain("tail"); + }); + + it("advertises the bash spill path only once the file is complete", async () => { + const chunk = Buffer.from(`${"x".repeat(4095)}\n`); + const chunks = 2048; // 8 MiB, far past the spill threshold + const ops: BashOperations = { + exec: async (_command, _cwd, { onData }) => { + for (let i = 0; i < chunks; i++) { + onData(chunk); + } + return { exitCode: 0 }; + }, + }; + + const result = await executeBashWithOperations("noop", scratch, ops); + + expect(result.truncated).toBe(true); + expect(result.fullOutputPath).toBeDefined(); + expect(statSync(result.fullOutputPath as string).size).toBe(chunk.length * chunks); + rmSync(result.fullOutputPath as string, { force: true }); + }); +}); diff --git a/packages/coding-agent/test/semantic-edges.test.ts b/packages/coding-agent/test/semantic-edges.test.ts index c2938d94f..181d77eee 100644 --- a/packages/coding-agent/test/semantic-edges.test.ts +++ b/packages/coding-agent/test/semantic-edges.test.ts @@ -347,20 +347,22 @@ describe("SemanticEdgeRecorder", () => { expect(requestIds).toEqual([originalId, firstId, secondId]); }); - it("newline-terminates a valid unterminated final line before appending", () => { + it("treats a valid unterminated final line as uncommitted: skipped on read, truncated on append", () => { const recorder = createRecorder(); - const firstId = recorder.startTurnRequest(); + recorder.startTurnRequest(); const path = join(tempDir, "semantic-edges.jsonl"); const raw = readFileSync(path, "utf8"); rmSync(path); appendFileSync(path, raw.slice(0, -1)); + // Tail rule: uncommitted append — see the EventLog module doc. + expect(readSemanticEdgeLedger(path).filter((event) => event.type === "request_started")).toEqual([]); const resumed = createRecorder(); const secondId = resumed.startTurnRequest(); const requestIds = readSemanticEdgeLedger(path) .filter((event) => event.type === "request_started") .map((event) => (event.type === "request_started" ? event.request_id : "")); - expect(requestIds).toEqual([firstId, secondId]); + expect(requestIds).toEqual([secondId]); }); it("treats a newline-terminated malformed final line as corruption, not a torn append", () => { diff --git a/packages/coding-agent/test/session-cwd.test.ts b/packages/coding-agent/test/session-cwd.test.ts index 1615d3160..5a742d666 100644 --- a/packages/coding-agent/test/session-cwd.test.ts +++ b/packages/coding-agent/test/session-cwd.test.ts @@ -1,4 +1,13 @@ -import { mkdirSync, rmSync, writeFileSync } from "node:fs"; +import { + existsSync, + lstatSync, + mkdirSync, + readFileSync, + rmSync, + symlinkSync, + utimesSync, + writeFileSync, +} from "node:fs"; import { tmpdir } from "node:os"; import { join } from "node:path"; import { afterEach, describe, expect, it } from "vitest"; @@ -145,6 +154,93 @@ describe("session cwd handling", () => { expect(createRuntimeCalled).toBe(false); }); + it.each([2, 3])( + "reads version %s resume files without repairing, migrating, or changing symlinks", + async (version) => { + const dir = createTempDir("pi-readonly-resume"); + cleanupPaths.push(dir); + const storedCwd = join(dir, "missing-project"); + const path = join(dir, "session.jsonl"); + const alias = join(dir, "alias.jsonl"); + const original = `not-json\n${JSON.stringify({ type: "session", version, id: "readonly", timestamp: "2026-01-01T00:00:00Z", cwd: storedCwd })}\n${JSON.stringify({ type: "session_info", id: "name", parentId: null, timestamp: "2026-01-01T00:00:00Z", name: "Reader name" })}\n{"type":"message","id":"torn`; + writeFileSync(path, original); + symlinkSync(path, alias); + const manager = await createSessionManager(parseArgs(["--resume", alias]), dir, dir, true); + expect(manager.isPersisted()).toBe(false); + expect(manager.getCwd()).toBe(storedCwd); + expect(manager.getSessionName()).toBe("Reader name"); + expect(manager.getSessionFile()).toBe(alias); + expect(manager.getSessionDir()).toBe(dir); + expect(manager.getEntries()).toHaveLength(1); + expect(manager.getLeafId()).toBe(manager.getEntries()[0].id); + expect(getMissingSessionCwdIssue(manager, dir)?.sessionCwd).toBe(storedCwd); + const overridden = await createSessionManager(parseArgs(["--cwd", dir, "--resume", alias]), dir, dir, true); + expect(overridden.getCwd()).toBe(dir); + expect(getMissingSessionCwdIssue(overridden, dir)).toBeUndefined(); + expect(lstatSync(alias).isSymbolicLink()).toBe(true); + expect(readFileSync(path, "utf8")).toBe(original); + }, + ); + + it("continues the latest matching cwd without mutating its transcript", async () => { + const dir = createTempDir("pi-readonly-continue"); + cleanupPaths.push(dir); + const older = join(dir, "older.jsonl"); + const latest = join(dir, "latest.jsonl"); + const other = join(dir, "other.jsonl"); + writeSessionFile(older, dir); + writeSessionFile(latest, dir); + writeSessionFile(other, join(dir, "other-project")); + const original = `${readFileSync(latest, "utf8")}{"type":"message","id":"torn`; + writeFileSync(latest, original); + utimesSync(older, 100, 100); + utimesSync(latest, 200, 200); + utimesSync(other, 300, 300); + const manager = await createSessionManager(parseArgs(["--continue"]), dir, dir, true); + expect(manager.getSessionFile()).toBe(latest); + expect(manager.getCwd()).toBe(dir); + expect(manager.isPersisted()).toBe(false); + expect(readFileSync(latest, "utf8")).toBe(original); + }); + + it("keeps a no-match readonly continue as an in-memory draft", async () => { + const dir = createTempDir("pi-readonly-continue-empty"); + cleanupPaths.push(dir); + writeSessionFile(join(dir, "other.jsonl"), join(dir, "other-project")); + const manager = await createSessionManager(parseArgs(["--continue"]), dir, dir, true); + expect(manager.isPersisted()).toBe(false); + expect(manager.getSessionFile()).toBeUndefined(); + expect(manager.getSessionDir()).toBe(dir); + expect(manager.getCwd()).toBe(dir); + }); + + it("keeps fork output writable when startup reads are readonly", async () => { + const dir = createTempDir("pi-readonly-fork"); + cleanupPaths.push(dir); + const source = join(dir, "source.jsonl"); + writeSessionFile(source, dir); + const manager = await createSessionManager(parseArgs(["--fork", source]), dir, dir, true); + expect(manager.isPersisted()).toBe(true); + expect(manager.getSessionFile()).not.toBe(source); + expect(existsSync(manager.getSessionFile()!)).toBe(true); + manager.appendSessionInfo("Writable fork"); + expect(SessionManager.open(manager.getSessionFile()!).getSessionName()).toBe("Writable fork"); + }); + + it("preserves writer-owned crash repair for default startup", async () => { + const dir = createTempDir("pi-writer-resume"); + cleanupPaths.push(dir); + const path = join(dir, "session.jsonl"); + writeSessionFile(path, dir); + const kept = readFileSync(path, "utf8"); + writeFileSync(path, `${kept}{"type":"message","id":"torn`); + const manager = await createSessionManager(parseArgs(["--resume", path]), dir, dir); + expect(manager.isPersisted()).toBe(true); + expect(readFileSync(path, "utf8")).toBe(kept); + manager.appendSessionInfo("After repair"); + expect(SessionManager.open(path).getSessionName()).toBe("After repair"); + }); + it("preserves an explicit catalog directory for in-memory bootstrap sessions", () => { const manager = SessionManager.inMemory("/tmp/project", "/tmp/sessions"); expect(manager.getSessionDir()).toBe("/tmp/sessions"); diff --git a/packages/coding-agent/test/session-lease.test.ts b/packages/coding-agent/test/session-lease.test.ts index 87338b686..ee9f2f064 100644 --- a/packages/coding-agent/test/session-lease.test.ts +++ b/packages/coding-agent/test/session-lease.test.ts @@ -1,9 +1,9 @@ import { createHash } from "node:crypto"; -import { mkdirSync, mkdtempSync, rmSync, symlinkSync, writeFileSync } from "node:fs"; +import { existsSync, mkdirSync, mkdtempSync, rmSync, symlinkSync, writeFileSync } from "node:fs"; import { tmpdir } from "node:os"; import { join, resolve } from "node:path"; import { lockSync } from "proper-lockfile"; -import { afterEach, describe, expect, it } from "vitest"; +import { afterEach, describe, expect, it, vi } from "vitest"; import { acquireSessionLease, canonicalSessionPath, @@ -116,6 +116,22 @@ describe("session leases", () => { lease?.release(); }); + it("never reclaims a lease whose owner file cannot be read", () => { + const agentDir = createTempDir(); + const sessionPath = canonicalSessionPath(resolve(agentDir, "unreadable.jsonl")); + const key = createHash("sha256").update(sessionPath).digest("hex"); + const lockDirectory = join(agentDir, "session-leases", `${key}.lock`); + // owner.json as a directory: every read fails with a non-ENOENT error, the + // same shape as a transient EPERM/EBUSY on Windows. That may be a LIVE + // lease, so acquisition must fail instead of destroying it. + mkdirSync(join(lockDirectory, "owner.json"), { recursive: true }); + + expect(() => acquireSessionLease(sessionPath, agentDir, enabledEnvironment("intruder"))).toThrow( + "Could not acquire session lease", + ); + expect(existsSync(join(lockDirectory, "owner.json"))).toBe(true); + }); + it("reports guard contention as a coordination failure", () => { const agentDir = createTempDir(); const sessionPath = canonicalSessionPath(join(agentDir, "session.jsonl")); @@ -129,6 +145,8 @@ describe("session leases", () => { stale: 5000, }); + // Keep the owner fresh while exercising the bounded synchronous retry count. + const wait = vi.spyOn(Atomics, "wait").mockReturnValue("timed-out"); try { let thrown: unknown; try { @@ -140,6 +158,7 @@ describe("session leases", () => { expect(thrown).not.toBeInstanceOf(SessionAlreadyActiveError); expect((thrown as Error).message).toContain("Could not coordinate session lease"); } finally { + wait.mockRestore(); release(); } }); diff --git a/packages/coding-agent/test/session-manager-flush.test.ts b/packages/coding-agent/test/session-manager-flush.test.ts index 50ddafd55..08a387c60 100644 --- a/packages/coding-agent/test/session-manager-flush.test.ts +++ b/packages/coding-agent/test/session-manager-flush.test.ts @@ -13,6 +13,7 @@ import { statSync, symlinkSync, type writeFileSync, + type writeSync as writeSyncFs, } from "node:fs"; import { tmpdir } from "node:os"; import { basename, dirname, join } from "node:path"; @@ -22,27 +23,33 @@ type ChmodSync = typeof chmodSync; type ChownSync = typeof chownSync; type RenameSync = typeof renameSync; type WriteFileSync = typeof writeFileSync; +type WriteSync = typeof writeSyncFs; const fsMocks = vi.hoisted(() => ({ actualWriteFileSync: undefined as WriteFileSync | undefined, + actualWriteSync: undefined as WriteSync | undefined, chmodSync: vi.fn(), chownSync: vi.fn(), renameSync: vi.fn(), writeFileSync: vi.fn(), + writeSync: vi.fn(), })); vi.mock("node:fs", async (importOriginal) => { const actual = await importOriginal(); fsMocks.actualWriteFileSync = actual.writeFileSync; + fsMocks.actualWriteSync = actual.writeSync; fsMocks.chmodSync.mockImplementation(actual.chmodSync); fsMocks.chownSync.mockImplementation(actual.chownSync); fsMocks.renameSync.mockImplementation(actual.renameSync); fsMocks.writeFileSync.mockImplementation(actual.writeFileSync); + fsMocks.writeSync.mockImplementation(actual.writeSync); return { ...actual, chmodSync: fsMocks.chmodSync, chownSync: fsMocks.chownSync, renameSync: fsMocks.renameSync, writeFileSync: fsMocks.writeFileSync, + writeSync: fsMocks.writeSync, }; }); @@ -100,13 +107,13 @@ describe("SessionManager.flushNow", () => { mgr.flushNow(); const file = mgr.getSessionFile()!; const before = readFileSync(file); - const tempPrefix = `.${basename(file)}.`; + const tempPrefix = `${basename(file)}.`; mgr.appendMessage({ role: "user", content: "pending", timestamp: Date.now() }); - fsMocks.writeFileSync.mockImplementationOnce((path, data, options) => { - fsMocks.actualWriteFileSync!(path, Buffer.from(String(data)).subarray(0, 12), options); + fsMocks.writeSync.mockImplementationOnce(((fd: number, data: string) => { + fsMocks.actualWriteSync!(fd, Buffer.from(String(data)).subarray(0, 12)); throw new Error("disk full"); - }); + }) as unknown as WriteSync); expect(() => mgr.flushNow()).toThrow("disk full"); expect(readFileSync(file)).toEqual(before); @@ -137,7 +144,7 @@ describe("SessionManager.flushNow", () => { expect(fsMocks.chmodSync).toHaveBeenCalledWith(tempPath, before.mode & 0o777); expect(fsMocks.renameSync).toHaveBeenCalledWith(tempPath, join(dirname(tempPath as string), basename(file))); expect(fsMocks.chownSync.mock.invocationCallOrder[0]!).toBeLessThan( - fsMocks.chmodSync.mock.invocationCallOrder[0]!, + fsMocks.renameSync.mock.invocationCallOrder[0]!, ); expect(fsMocks.chmodSync.mock.invocationCallOrder[0]!).toBeLessThan( fsMocks.renameSync.mock.invocationCallOrder[0]!, @@ -157,7 +164,7 @@ describe("SessionManager.flushNow", () => { mgr.flushNow(); const file = mgr.getSessionFile()!; const before = readFileSync(file); - const tempPrefix = `.${basename(file)}.`; + const tempPrefix = `${basename(file)}.`; const permissionError = Object.assign(new Error("operation not permitted"), { code: "EPERM" }); fsMocks.chownSync.mockImplementationOnce(() => { throw permissionError; @@ -346,8 +353,10 @@ function failNextOutcomeAppend(mgr: SessionManager, file: string): void { }; } -const failAfterPartialTempWrite: WriteFileSync = (path, data, options) => { - fsMocks.actualWriteFileSync!(path, Buffer.from(String(data)).subarray(0, 12), options); +const failAfterPartialTempWrite: WriteSync = (fd: number, data: NodeJS.ArrayBufferView | string) => { + const bytes = + typeof data === "string" ? Buffer.from(data) : Buffer.from(data.buffer, data.byteOffset, data.byteLength); + fsMocks.actualWriteSync!(fd, bytes.subarray(0, 12)); throw new Error("repair failed"); }; @@ -416,7 +425,7 @@ describe("SessionManager.appendCustomMessageEntryWithRollback", () => { it("preserves old history when rollback repair fails", () => { const { mgr, file, before } = createPersistedSessionForRollbackTest(); failNextOutcomeAppend(mgr, file); - fsMocks.writeFileSync.mockImplementationOnce(failAfterPartialTempWrite); + fsMocks.writeSync.mockImplementationOnce(failAfterPartialTempWrite); expect(() => mgr.appendCustomMessageEntryWithRollback("test.outcome", "details", false)).toThrow("append failed"); expect(readFileSync(file).subarray(0, before.length)).toEqual(before); @@ -425,7 +434,7 @@ describe("SessionManager.appendCustomMessageEntryWithRollback", () => { it("repairs a torn tail on the next successful retry", () => { const { mgr, file, before } = createPersistedSessionForRollbackTest(); failNextOutcomeAppend(mgr, file); - fsMocks.writeFileSync.mockImplementationOnce(failAfterPartialTempWrite); + fsMocks.writeSync.mockImplementationOnce(failAfterPartialTempWrite); expect(() => mgr.appendCustomMessageEntryWithRollback("test.outcome", "details", false)).toThrow(); mgr.flushNow(); diff --git a/packages/coding-agent/test/session-manager/file-operations.test.ts b/packages/coding-agent/test/session-manager/file-operations.test.ts index 2b91908ee..f26c1028b 100644 --- a/packages/coding-agent/test/session-manager/file-operations.test.ts +++ b/packages/coding-agent/test/session-manager/file-operations.test.ts @@ -1,7 +1,38 @@ -import { mkdirSync, readFileSync, rmSync, writeFileSync } from "fs"; +import { + appendFileSync, + chmodSync, + closeSync, + lstatSync, + mkdirSync, + openSync, + readFileSync, + renameSync, + rmSync, + statSync, + symlinkSync, + utimesSync, + writeFileSync, + writeSync, +} from "fs"; import { tmpdir } from "os"; import { join } from "path"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; + +const fullReadCounter = vi.hoisted(() => ({ suffix: undefined as string | undefined, count: 0 })); +vi.mock("node:fs", async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + readFileSync: ((path: Parameters[0], options?: never) => { + // Suffix match: repair resolves the realpath (/private/var vs /var on macOS). + if (fullReadCounter.suffix !== undefined && String(path).endsWith(fullReadCounter.suffix)) { + fullReadCounter.count++; + } + return actual.readFileSync(path, options); + }) as typeof actual.readFileSync, + }; +}); + import { computeOwnAndTotalUsage } from "../../src/core/context-tree.js"; import { findMostRecentSession, @@ -529,6 +560,127 @@ describe("SessionManager.setSessionFile with corrupted files", () => { rmSync(tempDir, { recursive: true, force: true }); }); + // The suspicion gate must keep clean opens at ONE full read (the loader's own). + it.each([ + [ + "a clean large session", + (): string[] => { + const filler = "x".repeat(2048); + const lines: string[] = []; + for (let index = 0; index < 2000; index++) { + lines.push( + JSON.stringify({ + type: "message", + id: `m${index}`, + parentId: index === 0 ? null : `m${index - 1}`, + message: { role: "user", content: filler, timestamp: index }, + }), + ); + } + return lines; + }, + ], + [ + "a benign trailing blank line", + (): string[] => [ + JSON.stringify({ + type: "message", + id: "m1", + parentId: null, + message: { role: "user", content: "hi", timestamp: 1 }, + }), + "", + ], + ], + ])("opens %s with exactly one full read", (_name, buildLines) => { + const file = join(tempDir, "gate.jsonl"); + const header = { + type: "session", + version: 3, + id: "gate-session", + timestamp: "2026-01-01T00:00:00Z", + cwd: "/tmp", + }; + writeFileSync(file, `${[JSON.stringify(header), ...buildLines()].join("\n")}\n`); + fullReadCounter.suffix = "gate.jsonl"; + fullReadCounter.count = 0; + + try { + SessionManager.open(file, tempDir); + expect(fullReadCounter.count).toBe(1); + } finally { + fullReadCounter.suffix = undefined; + } + }); + + it("repairs crash damage at open: torn tail truncated, zero-filled record recovered, appends stay separate lines", () => { + const file = join(tempDir, "crashed.jsonl"); + const header = { + type: "session", + version: 3, + id: "crashed-session", + timestamp: "2026-01-01T00:00:00Z", + cwd: "/tmp", + }; + const kept = { + type: "message", + id: "m1", + parentId: null, + message: { role: "user", content: "kept", timestamp: 1 }, + }; + const zeroFilled = { + type: "message", + id: "m2", + parentId: "m1", + message: { role: "user", content: "recovered", timestamp: 2 }, + }; + const damaged = `${JSON.stringify(header)}\n${JSON.stringify(kept)}\n\u0000\u0000\u0000\u0000${JSON.stringify(zeroFilled)}\n{"type":"message","id":"torn`; + writeFileSync(file, damaged); + const errorSpy = vi.spyOn(console, "error").mockImplementation(() => {}); + + try { + const sm = SessionManager.open(file, tempDir); + expect(sm.getHeader()?.id).toBe("crashed-session"); + expect(sm.getEntries().map((entry) => entry.id)).toEqual(["m1", "m2"]); + sm.appendMessage({ role: "user", content: "after crash", timestamp: 3 }); + sm.flushNow(); + + const lines = readFileSync(file, "utf-8").split("\n").filter(Boolean); + const parsed = lines.map((line) => JSON.parse(line)); + expect(parsed.map((entry) => entry.id ?? entry.type)).toEqual([ + "crashed-session", + "m1", + "m2", + expect.any(String), + ]); + expect(parsed.at(-1)?.message?.content).toBe("after crash"); + expect(errorSpy).toHaveBeenCalledTimes(1); + } finally { + errorSpy.mockRestore(); + } + }); + + it("repairs a damaged transcript through its symlink alias at the real file", () => { + const realFile = join(tempDir, "real.jsonl"); + const alias = join(tempDir, "alias.jsonl"); + const header = { type: "session", version: 3, id: "sym-session", timestamp: "2026-01-01T00:00:00Z", cwd: "/tmp" }; + writeFileSync(realFile, `${JSON.stringify(header)}\n{"type":"message","id":"torn`); + chmodSync(realFile, 0o600); + symlinkSync(realFile, alias); + const errorSpy = vi.spyOn(console, "error").mockImplementation(() => {}); + + try { + SessionManager.open(alias, tempDir); + expect(lstatSync(alias).isSymbolicLink()).toBe(true); + const repaired = readFileSync(realFile, "utf-8"); + expect(repaired.endsWith("\n")).toBe(true); + expect(repaired).not.toContain("torn"); + expect(statSync(realFile).mode & 0o777).toBe(0o600); + } finally { + errorSpy.mockRestore(); + } + }); + it("truncates and rewrites empty file with valid header", () => { const emptyFile = join(tempDir, "empty.jsonl"); writeFileSync(emptyFile, ""); @@ -652,3 +804,137 @@ describe("session info usage totals", () => { } }); }); + +describe("readSessionInfo incremental scans", () => { + let tempDir: string; + + beforeEach(() => { + tempDir = join(tmpdir(), `session-scan-test-${Date.now()}-${Math.random().toString(36).slice(2)}`); + mkdirSync(tempDir, { recursive: true }); + }); + + afterEach(() => { + rmSync(tempDir, { recursive: true, force: true }); + }); + + const header = { type: "session", version: 3, id: "scan1", timestamp: "2026-01-01T00:00:00Z", cwd: "/tmp" }; + const msg = (id: string, parentId: string | null, role: string, text: string) => ({ + type: "message", + id, + parentId, + timestamp: "2026-01-01T00:00:01Z", + message: { role, content: text, timestamp: 1 }, + }); + const line = (entry: unknown) => `${JSON.stringify(entry)}\n`; + + it("coalesces concurrent unchanged readers and gives post-append readers the fresh snapshot", async () => { + const file = join(tempDir, "serialized.jsonl"); + let content = line(header); + for (let i = 0; i < 20000; i++) { + content += line(msg(`m${i}`, i === 0 ? null : `m${i - 1}`, "user", `filler message ${i} ${"x".repeat(120)}`)); + } + writeFileSync(file, content); + + const [first, second] = await Promise.all([readSessionInfo(file), readSessionInfo(file)]); + expect(first?.messageCount).toBe(20000); + expect(second).toBe(first); + + const early = readSessionInfo(file); + // Let the scan stat the file and start streaming before the append. + await new Promise((resolveTick) => setImmediate(resolveTick)); + appendFileSync(file, line(msg("late", "m19999", "assistant", "post-append entry"))); + const late = await readSessionInfo(file); + expect(late?.messageCount).toBe(20001); + expect((await early)?.messageCount).toBeLessThanOrEqual(20001); + }); + + it("resumes from the scanned offset: prefix never re-read, torn tail folded exactly once", async () => { + const file = join(tempDir, "incremental.jsonl"); + const torn = line(msg("m2", "m1", "assistant", "answer")); + writeFileSync(file, line(header) + line(msg("m1", null, "user", "original question")) + torn.slice(0, 20)); + expect((await readSessionInfo(file))?.messageCount).toBe(1); + + // Same-length positional write into the scanned prefix, keeping the inode: + // outside the writer model, so consumed bytes are never re-read. + const position = readFileSync(file, "utf8").indexOf("original question"); + const fd = openSync(file, "r+"); + try { + writeSync(fd, Buffer.from("modified question"), 0, 17, position); + } finally { + closeSync(fd); + } + appendFileSync(file, torn.slice(20)); + + const info = await readSessionInfo(file); + expect(info?.messageCount).toBe(2); + expect(info?.firstMessage).toBe("original question"); + }); + + // The rename row preserves the 16 bytes before the old offset, so only the + // replaced inode identifies it; the truncate row keeps the inode, so only + // the changed prefix tail does. + it.each([ + { mode: "rename", first: "name variant AAAA", rewrittenFirst: "name variant BBBB" }, + { mode: "truncate", first: "first draft AAAAAA", rewrittenFirst: "rewritten opening line" }, + ])("rescans from byte 0 after a grown $mode rewrite", async ({ mode, first, rewrittenFirst }) => { + const file = join(tempDir, `${mode}-rewrite.jsonl`); + writeFileSync( + file, + line(header) + line(msg("m1", null, "user", first)) + line(msg("m2", "m1", "assistant", "stable reply")), + ); + expect((await readSessionInfo(file))?.firstMessage).toBe(first); + + const rewritten = + line(header) + + line(msg("m1", null, "user", rewrittenFirst)) + + line(msg("m2", "m1", "assistant", "stable reply")) + + line(msg("m3", "m2", "assistant", "appended")); + if (mode === "rename") { + const tempPath = join(tempDir, "rewrite.tmp"); + writeFileSync(tempPath, rewritten); + renameSync(tempPath, file); + } else { + writeFileSync(file, rewritten); + } + + const info = await readSessionInfo(file); + expect(info?.messageCount).toBe(3); + expect(info?.firstMessage).toBe(rewrittenFirst); + }); + + it("invalidates scanned bytes after crash repair and resumes later appends", async () => { + const file = join(tempDir, "repaired-scan.jsonl"); + writeFileSync( + file, + line(header) + + line(msg("m1", null, "user", "kept")) + + "\0\0" + + line(msg("m2", "m1", "user", "recovered")) + + '{"type":"message","id":"torn', + ); + expect((await readSessionInfo(file))?.messageCount).toBe(1); + const manager = SessionManager.open(file, tempDir); + expect((await readSessionInfo(file))?.messageCount).toBe(2); + manager.appendMessage({ role: "user", content: "after repair", timestamp: 3 }); + manager.flushNow(); + const scanned = await readSessionInfo(file); + expect(scanned?.messageCount).toBe(3); + expect(scanned?.allMessagesText).toContain("recovered"); + expect(scanned?.allMessagesText).toContain("after repair"); + }); + + it("evicts scan state when the file disappears so a recreated file rescans", async () => { + const file = join(tempDir, "recreated.jsonl"); + writeFileSync(file, line(header) + line(msg("m1", null, "user", "before delete"))); + const fixedTime = new Date("2026-01-02T00:00:00Z"); + utimesSync(file, fixedTime, fixedTime); + expect((await readSessionInfo(file))?.firstMessage).toBe("before delete"); + + rmSync(file); + expect(await readSessionInfo(file)).toBeNull(); + + writeFileSync(file, line(header) + line(msg("m1", null, "user", "after recreate"))); + utimesSync(file, fixedTime, fixedTime); + expect((await readSessionInfo(file))?.firstMessage).toBe("after recreate"); + }); +}); diff --git a/packages/coding-agent/test/session-reader-persistence.test.ts b/packages/coding-agent/test/session-reader-persistence.test.ts new file mode 100644 index 000000000..51ff236e6 --- /dev/null +++ b/packages/coding-agent/test/session-reader-persistence.test.ts @@ -0,0 +1,126 @@ +import { + appendFileSync, + existsSync, + lstatSync, + mkdtempSync, + readFileSync, + rmSync, + symlinkSync, + writeFileSync, +} from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; + +const io = vi.hoisted(() => ({ target: "", streams: 0 })); +vi.mock("node:fs", async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + createReadStream: ((...args: Parameters) => { + if (String(args[0]) === io.target) io.streams++; + return actual.createReadStream(...args); + }) as typeof actual.createReadStream, + }; +}); + +import { exportFromFile } from "../src/core/export-html/index.js"; +import { readSessionInfo } from "../src/core/session-manager.js"; + +const header = { type: "session", version: 3, id: "export-session", timestamp: "2026-01-01T00:00:00Z", cwd: "/tmp" }; +const usage = { + input: 1, + output: 2, + cacheRead: 0, + cacheWrite: 0, + totalTokens: 3, + cost: { input: 1, output: 2, cacheRead: 0, cacheWrite: 0, total: 3 }, +}; +const assistant = { + role: "assistant" as const, + content: [{ type: "text" as const, text: "kept" }], + api: "anthropic-messages" as const, + provider: "anthropic", + model: "test", + stopReason: "stop" as const, + timestamp: 1, + usage, +}; +const line = (entry: unknown) => `${JSON.stringify(entry)}\n`; +const initial = () => + line(header) + line({ type: "message", id: "a1", parentId: null, timestamp: header.timestamp, message: assistant }); + +describe("session catalog cache and standalone export", () => { + let dir: string; + beforeEach(() => { + dir = mkdtempSync(join(tmpdir(), "session-reader-")); + io.target = ""; + io.streams = 0; + }); + afterEach(() => { + rmSync(dir, { recursive: true, force: true }); + }); + + it("evicts an oversized usage map while keeping returned totals correct", async () => { + const path = join(dir, "oversized.jsonl"); + const count = 100_001; + const records = [line(header)]; + for (let i = 0; i < count; i++) + records.push( + line({ + type: "message", + id: `m${i}`, + parentId: i ? `m${i - 1}` : null, + timestamp: header.timestamp, + message: assistant, + }), + ); + writeFileSync(path, records.join("")); + io.target = path; + const first = await readSessionInfo(path); + expect(first?.messageCount).toBe(count); + expect(first?.usage).toEqual({ inputTokens: count, outputTokens: count * 2, cost: count * 3 }); + const firstStreams = io.streams; + expect(firstStreams).toBeGreaterThan(0); + const second = await readSessionInfo(path); + expect(second?.usage).toEqual(first?.usage); + expect(io.streams, "over-limit state should not survive as a warm cache hit").toBeGreaterThan(firstStreams); + appendFileSync( + path, + line({ + type: "message", + id: "late", + parentId: `m${count - 1}`, + timestamp: header.timestamp, + message: assistant, + }), + ); + const appended = await readSessionInfo(path); + expect(appended?.messageCount).toBe(count + 1); + expect(appended?.usage).toEqual({ inputTokens: count + 1, outputTokens: (count + 1) * 2, cost: (count + 1) * 3 }); + }); + + it.each([2, 3])( + "exports a damaged version %s transcript through a symlink without changing the input", + async (version) => { + const path = join(dir, "damaged.jsonl"); + const original = `${initial().replace('"version":3', `"version":${version}`)}{"type":"message","id":"torn`; + writeFileSync(path, original); + const alias = join(dir, "alias.jsonl"); + symlinkSync(path, alias); + const output = join(dir, "export.html"); + await exportFromFile(alias, { outputPath: output }); + expect(lstatSync(alias).isSymbolicLink()).toBe(true); + expect(existsSync(output)).toBe(true); + const html = readFileSync(output, "utf8"); + const encoded = html.match(/