From 4273ca9dbd9b3e3172f34a7231d41bcd8b5d200c Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Mon, 27 Jul 2026 22:33:24 -0400 Subject: [PATCH] refactor(sessions): remove file-era transcript runtime (#113233) * refactor(sessions): keep helper transcripts in memory * refactor(sessions): remove file-era transcript storage * test(sessions): use SQLite identity in attempt persistence * test(codex): isolate legacy transcript fixtures * fix(sessions): preserve SQLite transcript identity * fix(sessions): harden transcript lifecycle invariants * fix(sessions): validate transcript identities * fix(sessions): close identity compatibility gaps * fix(sessions): preserve leaf and plugin identities * fix(sessions): retain dispatch transcript targets * fix(sessions): preserve active transcript context * fix(sessions): isolate artifact accounting * fix(sessions): bound SQLite usage accounting * fix(sessions): retain bounded latest usage * fix(sessions): align rebased transcript targets * test(sessions): align accessor scope fixture * fix(telegram): derive SQLite transcript identity * refactor(sessions): remove file-era compaction residue * chore(sessions): lower max-lines baseline * fix(sessions): preserve structured transcript identity * test(sessions): align doctor identity assertions * fix(sessions): isolate default SDK database * refactor(sessions): remove dead file-era exports * fix(sessions): reconcile SQLite transcript identity * fix(sessions): pass checkpoint identity explicitly * test(sessions): make entry field probe explicit * test(sessions): satisfy transcript cleanup lint * test(sessions): align diagnostics identity proof * fix(sessions): finish transcript runtime teardown * fix(sessions): preserve transcript identity invariants * fix(sessions): harden transcript compatibility edges * fix(sessions): preserve checkpoint transcript anchors * fix(sessions): preserve SQLite lifecycle invariants * fix(sessions): retarget compaction successors * test(sessions): preserve transcript fixture semantics * feat(plugin-sdk): add command transcript targets * fix(sessions): serialize transcript rewrites * fix(sessions): validate legacy successor identity * fix(sessions): normalize compaction ownership * fix(sessions): validate successor identity before adoption * fix(sessions): preserve plugin transcript ownership * fix(sessions): carry transcript identity through commands * fix(sessions): import legacy checkpoint artifacts into SQLite * fix(sessions): preserve successor transcript ownership * fix(sessions): align transcript consumers with target identity * fix(sessions): scope transcript token estimates * fix(sessions): retain agent identity across lifecycle hooks * fix(sessions): resolve scoped SQLite targets * fix(sessions): isolate lifecycle transcript targets * fix(sessions): validate compaction agent ownership * fix(sessions): preserve reset and cleanup lifecycle * fix(sessions): serialize prompt cleanup lifecycle * fix(sessions): remove stale lock import * fix(sessions): preserve reset target context * fix(sessions): fence prompt reload takeover * fix(sessions): unblock abort and default lifecycle reads * fix(sessions): validate legacy successor scope * fix(sessions): reject metadata-only runtime rows * fix(sessions): propagate custom transcript stores * fix(sessions): preserve adopted retry targets * fix(sessions): allow unkeyed usage reads * fix(sessions): harden runtime target boundaries * fix(sessions): serialize retry transcript writes * fix(sessions): bound prompt reload disposal * fix(sessions): complete retry marker identity * fix(sessions): keep legacy marker identity minimal * test(sessions): tighten teardown fixture types * fix(sessions): preserve compatibility target identity * test(sessions): persist post-checkpoint boundary turn * test(sessions): align runtime store mock contracts * style(sessions): simplify persisted identity guard * fix(sessions): prefer complete typed targets * fix(sessions): recover legacy marker targets * test(sessions): align marker lookup fixture scope * fix(sessions): validate partial transcript targets * fix(sessions): reconcile partial transcript identities * fix(sessions): canonicalize compatibility identities * test(sessions): cover compatibility aliases * fix(sessions): adopt legacy successor identity * fix(sessions): preserve usage read identity * fix(sessions): preserve partial marker compatibility * fix(sessions): validate legacy successor mappings * fix(sessions): reconcile marker store mappings * fix(sessions): preserve legacy fallback identity * fix(sessions): harden marker alias resolution * fix(sessions): prefer verified successor aliases * fix(sessions): resolve preferred marker aliases * fix(sessions): serialize cleanup admission * fix(sessions): align marker lookup scopes * fix(codex): type marker alias summaries * style(sessions): satisfy changed lint * test(sessions): align structured target assertions * fix(sessions): reconcile latest identity contracts * fix(sessions): validate transcript identity boundaries * docs(sessions): explain stable registry keys * fix(sessions): harden compatibility target round trips * fix(sessions): port usage identity to split modules * test(sessions): align subagent transcript identity * fix(sessions): finish transcript identity migration * fix(agents): route subagent completion capture through transcript targets * fix(agents): settle SQLite prompt handoff during cleanup * chore: shrink max-lines baseline after teardown * fix(sessions): port teardown across split runtime owners * fix(sessions): carry transcript targets through split owners * test(agents): use SQLite compaction target in abort coverage * chore: retain unrelated max-lines suppressions * chore: shrink max-lines baseline after main splits * style(agents): const compaction checkpoint locals * fix(sessions): harden SQLite teardown boundaries * test(sessions): use typed metadata in predicate isolation fixture * test(agents): cover malformed settlement rejections lint-safely * fix(sessions): close remaining SQLite identity races * fix(agents): fail closed on incomplete successor targets * fix(sessions): preserve transcript identity fallbacks * fix(agents): preserve session-key abort admission * fix(trajectory): validate incomplete export targets * test(sessions): drop retired pricing cache imports * fix(sessions): validate partial transcript identities * fix(sessions): close transcript identity edge cases * fix(plugins): reserve retired transcript locator slot * fix(sessions): scope transcript locks by target * style(sessions): simplify SDK initialization error * fix(sessions): preserve initialized transcript state * fix(codex): verify mirrored history session keys * fix(sessions): reject stale transcript ownership * fix(sessions): anchor asynchronous transcript ownership * fix(sessions): measure active transcript state * fix(sessions): preserve scoped transcript compaction * fix(sessions): harden transcript identity and lifecycle * fix(sessions): resolve scoped command transcript stores * fix(sessions): make transcript appends failure-atomic * fix(sessions): enforce scoped transcript ownership * fix(sessions): reject cross-owner transcript handoffs * fix(sessions): fence cleanup transcript ownership * fix(sessions): retire stale write ownership contexts * fix(sessions): preserve pending session migration state * fix(sessions): validate migrated transcript ownership * fix(sessions): validate usage transcript targets * fix(sessions): clear predecessor transcript metadata * fix(sessions): align durable session event targets * fix(sessions): fence late prompt handoffs * fix(sessions): fence lifecycle transcript fallbacks * fix(sessions): bound zero-length memory capture * fix(sessions): preserve transcript teardown ownership * fix(sessions): reject duplicate cleanup ownership * fix(sessions): serialize runtime writes with sqlite leases * fix(sessions): close sqlite teardown concurrency gaps * fix(sessions): preserve nested lifecycle failures * fix(sessions): canonicalize sqlite transcript ownership * fix(sessions): settle disposed prompt handoffs * fix(sessions): resolve canonical attempt lock targets * test(sessions): align canonical target fixtures * test(sessions): retire redundant jsonl parser coverage * refactor(sessions): split active transcript cursors * test(memory): retire legacy marker fixture * fix(sessions): preserve canonical transcript access after rebase * fix(sessions): fence prompt lease and return transcript targets * fix(sessions): colocate transcript leases with target store * fix(sessions): canonicalize transcript lease and worker targets * fix(sessions): preserve plugin and fork identity markers * fix(sessions): complete sqlite transcript target migration * fix(sessions): integrate canonical followup identity * fix(sessions): preserve bounded transcript topology * fix(sessions): validate transcript identity boundaries * fix(context): separate caller and successor targets * test(sessions): split persistence compatibility coverage * test(sessions): preserve fixture topology efficiently * chore(sdk): refresh plugin api baseline * test(agents): align compaction lock target mocks * test(sessions): seed malformed transcript fixtures directly * fix(agents): canonicalize transcript compatibility inputs * fix(agents): type optional tool result ids * test(ci): stabilize loaded process timing * test(tui): wait for collect queue admission --- config/max-lines-baseline.txt | 5 - .../.generated/plugin-sdk-api-baseline.sha256 | 6 +- docs/concepts/active-memory.md | 24 +- docs/concepts/agent-loop.md | 4 +- docs/concepts/compaction.md | 7 +- docs/concepts/queue.md | 2 +- docs/gateway/config-agents.md | 8 +- .../session-management-compaction.md | 20 +- docs/reference/test.md | 4 +- docs/reference/transcript-hygiene.md | 21 +- .../anthropic/session-catalog-history.test.ts | 8 +- .../anthropic/session-catalog-history.ts | 2 +- extensions/anthropic/session-catalog.ts | 7 +- ...delivery-no-reply-runtime-contract.test.ts | 9 +- .../event-projector.test-harness.ts | 5 +- .../outcome-fallback-runtime-contract.test.ts | 5 +- .../run-attempt.context-engine.test.ts | 31 +- .../src/app-server/run-attempt.hooks.test.ts | 9 +- .../codex/src/app-server/run-attempt.test.ts | 23 +- .../src/app-server/session-history.test.ts | 17 + .../codex/src/app-server/session-history.ts | 39 +- .../src/memory/manager-session-sync-ops.ts | 7 + .../src/memory/qmd-session-exporter.ts | 7 + .../src/session-search-visibility.ts | 7 +- .../qa-lab/src/suite-runtime-agent-session.ts | 6 - .../telegram/src/bot-native-commands.ts | 9 +- .../src/host/session-files.test.ts | 71 +- .../memory-host-sdk/src/host/session-files.ts | 63 +- .../src/host/session-transcript-corpus.ts | 20 +- .../lib/session-accessor-debt-baseline.json | 4 +- .../agent-command.compaction-rotation.test.ts | 36 +- .../agent-hooks/compaction-safeguard.test.ts | 5 +- src/agents/bootstrap-files.ts | 3 +- src/agents/btw-transcript.ts | 76 +- src/agents/btw.test.ts | 17 +- src/agents/btw.ts | 2 + src/agents/cli-runner.reliability.test.ts | 106 +- src/agents/cli-runner.ts | 42 +- src/agents/cli-runner/types.ts | 5 + .../command/attempt-execution.cli.test.ts | 89 +- src/agents/command/attempt-execution.ts | 6 +- src/agents/command/cli-compaction.test.ts | 21 +- src/agents/command/cli-compaction.ts | 39 +- src/agents/command/run-embedded-attempt.ts | 12 +- src/agents/command/session-store.test.ts | 14 +- src/agents/command/session-store.ts | 36 +- src/agents/command/session.ts | 1 + ...helpers.buildbootstrapcontextfiles.test.ts | 21 - src/agents/embedded-agent-helpers.ts | 1 - .../embedded-agent-helpers/bootstrap.ts | 29 - src/agents/embedded-agent-runner.e2e.test.ts | 7 +- .../embedded-agent-runner.guard.test.ts | 1 + .../compact.abort-signal.test.ts | 8 +- .../compact.hooks.harness.ts | 53 +- .../compact.hooks.test.ts | 494 +- .../embedded-agent-runner/compact.queued.ts | 251 +- src/agents/embedded-agent-runner/compact.ts | 9 +- .../compaction-checkpoint.ts | 12 +- ...compaction-duplicate-user-messages.test.ts | 41 +- .../compaction-duplicate-user-messages.ts | 33 - .../compaction-runtime-context.test.ts | 35 + .../compaction-session-execution.ts | 99 +- ...r-transcript.duplicate-prompt-loss.test.ts | 79 - ...t.post-compaction-duplicate-prompt.test.ts | 82 - .../compaction-successor-transcript.test.ts | 638 --- .../compaction-successor-transcript.ts | 390 -- .../context-engine-maintenance.test.ts | 16 +- .../context-engine-maintenance.ts | 15 +- .../direct-compaction-preparation.ts | 11 +- .../manual-compaction-boundary.test.ts | 237 - .../manual-compaction-boundary.ts | 150 - .../replay-history.test.ts | 3 +- .../embedded-agent-runner/replay-history.ts | 5 +- .../embedded-agent-runner/run-orchestrator.ts | 7 +- src/agents/embedded-agent-runner/run-state.ts | 2 +- .../run.overflow-compaction.harness.ts | 1 - .../run.overflow-compaction.loop.test.ts | 6 +- .../run.overflow-compaction.test.ts | 10 +- .../run.timeout-triggered-compaction.test.ts | 5 +- .../run/attempt-abort.test.ts | 26 + .../run/attempt-abort.ts | 16 +- .../run/attempt-after-turn.ts | 45 +- .../run/attempt-dispatch-preparation.ts | 35 +- .../run/attempt-normalization.test.ts | 209 + .../run/attempt-normalization.ts | 24 +- .../run/attempt-phase-lifecycle.test.ts | 19 +- .../run/attempt-prompt-error.test.ts | 5 - .../run/attempt-prompt-error.ts | 6 +- .../attempt-prompt-execution-prepare.test.ts | 5 - .../run/attempt-prompt-execution-prepare.ts | 4 +- .../run/attempt-prompt-phase.test.ts | 4 +- .../run/attempt-prompt-phase.ts | 1 - .../run/attempt-session-identity.ts | 128 + .../run/attempt-session-lock-prepare.test.ts | 19 +- .../run/attempt-session-lock-prepare.ts | 25 +- .../run/attempt-session-manager-prepare.ts | 152 +- .../run/attempt-stream-finalize.test.ts | 4 - .../run/attempt-stream-finalize.ts | 1 - .../run/attempt-stream-settle.ts | 3 - .../run/attempt-trajectory.test.ts | 12 + .../run/attempt-trajectory.ts | 13 + .../run/attempt-transcript-helpers.ts | 46 +- ...attempt-transcript-persistence.e2e.test.ts | 5 +- .../run/attempt.session-lock.controller.ts | 620 --- .../run/attempt.session-lock.entries.ts | 388 -- .../attempt.session-lock.fence-controller.ts | 610 --- .../run/attempt.session-lock.fence.ts | 423 -- .../run/attempt.session-lock.owner.ts | 173 - .../run/attempt.session-lock.prompt.ts | 64 - .../run/attempt.session-lock.test-support.ts | 15 - .../run/attempt.session-lock.test.ts | 4240 ++++------------- .../run/attempt.session-lock.ts | 588 ++- .../run/attempt.session-lock.write-scope.ts | 70 - ...mpt.spawn-workspace.context-engine.test.ts | 114 - .../attempt.spawn-workspace.test-support.ts | 54 +- .../embedded-agent-runner/run/params.ts | 3 + .../run/session-bootstrap.ts | 88 +- .../run/session-prompt-state.ts | 13 +- .../runs.generation.test.ts | 10 - src/agents/embedded-agent-runner/runs.test.ts | 52 +- src/agents/embedded-agent-runner/runs.ts | 98 +- .../embedded-agent-runner/session-file-key.ts | 22 - .../session-manager-cache.test-support.ts | 32 - .../session-manager-cache.test.ts | 35 - .../session-manager-cache.ts | 94 - .../session-manager-init.test.ts | 527 -- .../session-manager-init.ts | 198 - src/agents/embedded-agent-runner/thinking.ts | 2 +- .../tool-result-truncation.test.ts | 296 +- .../tool-result-truncation.ts | 177 +- .../transcript-file-state.test.ts | 175 +- .../transcript-file-state.ts | 1039 ---- .../transcript-rewrite.test.ts | 3 +- .../transcript-rewrite.ts | 393 +- .../transcript-runtime-state.ts | 20 - .../usage-reporting.test.ts | 16 +- src/agents/internal-session-effects.ts | 7 +- .../main-session-restart-recovery.test.ts | 24 +- src/agents/run-session-target.test.ts | 302 +- src/agents/run-session-target.ts | 154 +- src/agents/session-file-repair.test.ts | 1249 ----- src/agents/session-file-repair.ts | 984 ---- src/agents/session-tool-result-guard.test.ts | 23 + src/agents/session-tool-result-guard.ts | 41 +- src/agents/session-write-lock.test.ts | 285 ++ src/agents/session-write-lock.ts | 459 +- src/agents/sessions/agent-session-base.ts | 14 +- src/agents/sessions/sdk.test.ts | 118 +- src/agents/sessions/sdk.ts | 33 +- .../sessions/session-manager-branching.ts | 77 +- .../sessions/session-manager-codec.test.ts | 27 + src/agents/sessions/session-manager-codec.ts | 143 +- src/agents/sessions/session-manager-core.ts | 282 +- .../sessions/session-manager-entries.ts | 75 +- src/agents/sessions/session-manager-file.ts | 563 --- .../sessions/session-manager-persistence.ts | 174 +- src/agents/sessions/session-manager-types.ts | 8 +- ...session-manager.persistence-compat.test.ts | 151 + .../sessions/session-manager.test-compat.d.ts | 17 + src/agents/sessions/session-manager.test.ts | 3214 ++----------- ...session-manager.tool-result-replay.test.ts | 29 +- src/agents/sessions/session-manager.ts | 117 +- .../sessions/session-migrate-id-dedup.test.ts | 5 +- src/agents/subagent-announce-output.test.ts | 22 +- src/agents/subagent-announce-output.ts | 18 +- .../subagent-registry-lifecycle-delivery.ts | 40 +- .../subagent-registry-lifecycle.test.ts | 59 + ...agent-registry.persistence.test-support.ts | 2 +- src/agents/subagent-spawn-context.ts | 6 +- ...eply.triggers.trigger-handling.e2e.test.ts | 2 +- .../reply/agent-runner-core.test.ts | 27 + src/auto-reply/reply/agent-runner-core.ts | 19 +- .../reply/agent-runner-memory.test.ts | 200 +- src/auto-reply/reply/agent-runner-memory.ts | 239 +- .../reply/agent-runner-result-accounting.ts | 6 +- .../reply/agent-runner-result-complete.ts | 6 +- src/auto-reply/reply/agent-runner-run.ts | 3 +- .../reply/agent-runner-session-reset.test.ts | 23 +- .../reply/agent-runner-session-reset.ts | 19 +- src/auto-reply/reply/agent-runner-trace.ts | 9 + .../agent-runner.misc.runreplyagent.test.ts | 2 +- .../reply/agent-runner.test-fixtures.ts | 5 + src/auto-reply/reply/commands-compact.test.ts | 45 +- src/auto-reply/reply/commands-compact.ts | 27 +- .../reply/commands-context-report.test.ts | 62 +- .../reply/commands-context-report.ts | 47 +- .../reply/commands-diagnostics.test.ts | 10 +- src/auto-reply/reply/commands-diagnostics.ts | 6 +- src/auto-reply/reply/commands-plugin.test.ts | 90 +- src/auto-reply/reply/commands-plugin.ts | 29 +- src/auto-reply/reply/commands-reset-hooks.ts | 34 +- src/auto-reply/reply/commands-reset.ts | 2 + .../reply/commands-session-usage.test.ts | 23 +- src/auto-reply/reply/commands-session.ts | 38 +- src/auto-reply/reply/commands-status.test.ts | 43 +- .../reply/commands-steer.runtime.ts | 1 - src/auto-reply/reply/commands-steer.test.ts | 13 +- src/auto-reply/reply/commands-steer.ts | 13 - .../reply/commands-subagents/action-info.ts | 1 - src/auto-reply/reply/commands-tts.test.ts | 173 +- src/auto-reply/reply/commands-tts.ts | 24 +- ...m-config.send-policy-routing.test-utils.ts | 11 +- ...ispatch-from-config.shared.test-harness.ts | 7 +- .../reply/followup-turn-admission.test.ts | 6 +- .../reply/followup-turn-admission.ts | 10 +- src/auto-reply/reply/get-reply-fast-path.ts | 6 - .../reply/get-reply-run-admission.ts | 16 +- .../reply/get-reply.fast-path.test.ts | 20 +- src/auto-reply/reply/get-reply.ts | 1 + src/auto-reply/reply/queue.collect.test.ts | 9 +- src/auto-reply/reply/reply-state.test.ts | 155 +- src/auto-reply/reply/session-fork.test.ts | 7 +- .../reply/session-hooks-context.test.ts | 2 - .../reply/session-parent-fork-prepare.ts | 2 - .../reply/session-run-accounting.ts | 2 - .../reply/session-updates.lifecycle.test.ts | 4 +- src/auto-reply/reply/session-updates.ts | 82 +- .../reply/session.heartbeat-no-reset.test.ts | 86 - src/auto-reply/reply/session.test.ts | 103 +- src/auto-reply/reply/session.ts | 5 +- src/auto-reply/status.test.ts | 110 +- src/cli/help-exit.process.test.ts | 4 +- src/commands/agent.session.test.ts | 67 +- src/commands/agent.test.ts | 2 +- .../doctor-heartbeat-session-target.test.ts | 2 +- src/commands/doctor-session-sqlite-readers.ts | 2 +- src/commands/doctor-session-sqlite.test.ts | 18 +- src/commands/doctor-session-sqlite.ts | 7 +- .../doctor-session-transcripts.test.ts | 7 +- src/commands/doctor-state-integrity.test.ts | 17 + src/commands/doctor-state-integrity.ts | 10 +- src/commands/doctor-state-migrations.test.ts | 12 +- src/commands/export-trajectory.test.ts | 11 +- src/commands/export-trajectory.ts | 23 +- src/commands/sessions-table.ts | 1 - src/commands/sessions-tail.test.ts | 467 +- src/commands/sessions-tail.ts | 386 +- src/commands/sessions.test.ts | 13 +- src/commands/sessions.ts | 13 - src/config/cache-utils.test.ts | 39 - src/config/cache-utils.ts | 133 - src/config/sessions.ts | 3 - src/config/sessions/cleanup-service.ts | 10 +- .../sessions/compaction-session-file.ts | 43 - src/config/sessions/entry-freshness.test.ts | 38 +- src/config/sessions/entry-freshness.ts | 1 + .../incognito-session-transcript.test.ts | 11 +- ...lite-marker.ts => legacy-sqlite-marker.ts} | 12 +- src/config/sessions/lifecycle.test.ts | 18 + src/config/sessions/lifecycle.ts | 91 +- .../sessions/paths.sqlite-marker.test.ts | 101 - src/config/sessions/paths.ts | 9 +- src/config/sessions/plugin-host-cleanup.ts | 4 +- .../session-accessor.conformance.test.ts | 8 +- .../session-accessor.entry-mutation.ts | 38 +- .../session-accessor.parent-fork.test.ts | 51 +- src/config/sessions/session-accessor.reset.ts | 6 +- ...sion-accessor.sqlite-active-events.test.ts | 44 + .../session-accessor.sqlite-active-events.ts | 284 +- .../session-accessor.sqlite-checkpoint.ts | 65 +- .../session-accessor.sqlite-contract.ts | 3 +- .../session-accessor.sqlite-events.ts | 1 + .../session-accessor.sqlite-history.ts | 6 - ...session-accessor.sqlite-lifecycle-state.ts | 1 - .../session-accessor.sqlite-lifecycle.ts | 3 - ...ession-accessor.sqlite-message-cut.test.ts | 20 - .../session-accessor.sqlite-message-cut.ts | 15 - .../session-accessor.sqlite-parent-session.ts | 12 +- .../sessions/session-accessor.sqlite-read.ts | 42 + .../session-accessor.sqlite-reset-window.ts | 22 + .../sessions/session-accessor.sqlite-scope.ts | 9 +- .../session-accessor.sqlite-session-row.ts | 6 +- .../session-accessor.sqlite-status.ts | 5 +- ...ession-accessor.sqlite-transcript-write.ts | 20 +- .../session-accessor.sqlite-visible-cursor.ts | 73 + .../sessions/session-accessor.sqlite.ts | 2 + src/config/sessions/session-accessor.test.ts | 347 +- .../session-accessor.transcript-target.ts | 270 +- .../session-accessor.transcript-turn.ts | 54 +- .../sessions/session-accessor.transcript.ts | 4 + src/config/sessions/session-accessor.ts | 7 + src/config/sessions/session-accessor.types.ts | 32 +- .../sessions/session-entry.test-compat.d.ts | 29 + src/config/sessions/session-file-rotation.ts | 57 - src/config/sessions/session-file.ts | 39 - .../sessions/session-reset-boundary-event.ts | 4 +- .../session-transcript-projection-error.ts | 12 + .../sessions/session-transcript-reconcile.ts | 15 +- .../sessions/session-transcript-turn-state.ts | 3 - src/config/sessions/sessions.test.ts | 244 +- src/config/sessions/store-entry-shape.ts | 65 +- .../sessions/store-maintenance-operations.ts | 4 +- src/config/sessions/store.pruning.test.ts | 10 +- .../transcript-append.test-support.ts | 26 +- .../sessions/transcript-file-resolve.ts | 31 +- src/config/sessions/transcript-jsonl.test.ts | 51 +- src/config/sessions/transcript-jsonl.ts | 64 +- .../sessions/transcript-write-context.ts | 66 +- src/config/sessions/transcript.test.ts | 125 +- src/config/sessions/transcript.ts | 149 +- src/config/sessions/types.ts | 47 +- src/context-engine/context-engine.test.ts | 159 + src/context-engine/delegate.ts | 122 +- .../isolated-agent.session-identity.test.ts | 15 +- src/cron/isolated-agent.test-harness.ts | 2 +- .../delivery-dispatch.double-announce.test.ts | 7 +- src/cron/isolated-agent/run-executor.ts | 13 +- src/cron/isolated-agent/run-prepare.ts | 8 - .../isolated-agent/run-session-state.test.ts | 42 +- src/cron/isolated-agent/run-session-state.ts | 44 +- .../run.message-tool-policy.test.ts | 2 +- .../run.source-delivery-guard.test.ts | 4 +- src/cron/isolated-agent/session.test.ts | 9 +- src/cron/isolated-agent/session.ts | 13 +- src/gateway/managed-image-attachments.ts | 4 +- .../server-methods/agent-handler-helpers.ts | 8 +- .../agent-run-execution-phase.ts | 1 - .../server-methods/agent-session-patch.ts | 2 +- .../server-methods/agent-session-persist.ts | 3 +- .../server-methods/agent-session-prepare.ts | 38 +- .../agent.media-and-routing.test-utils.ts | 22 +- .../server-methods/agent.test-harness.ts | 6 +- .../server-methods/chat-abort-runtime.ts | 1 - .../chat-send-nonagent-finalization.ts | 1 - .../chat-send-reply-dispatch.ts | 1 - .../chat.abort-persistence.test.ts | 2 +- src/gateway/server-methods/chat.ts | 1 - src/gateway/server-methods/send.test.ts | 24 +- src/gateway/server-methods/session-audit.ts | 8 +- .../server-methods/sessions-compact.ts | 4 +- .../sessions-compaction-runner.ts | 2 +- .../server-methods/sessions-mutations.ts | 7 +- .../server-methods/sessions-rewind.test.ts | 28 - .../server-methods/sessions-sharing.ts | 6 +- .../server-methods/sessions-suggestions.ts | 2 +- src/gateway/server-session-events.test.ts | 50 +- src/gateway/server-session-events.ts | 126 +- .../server.sessions.compaction.test.ts | 104 +- src/gateway/server.sessions.create.test.ts | 37 +- .../server.sessions.reset-cleanup.test.ts | 2 +- .../server.sessions.reset-hooks.test.ts | 27 +- .../server.sessions.reset-models.test.ts | 40 +- .../session-compaction-checkpoint-entry.ts | 22 - .../session-compaction-checkpoints.test.ts | 231 +- src/gateway/session-compaction-checkpoints.ts | 315 +- src/gateway/session-companion-ask.ts | 2 +- src/gateway/session-create-fork-entry.ts | 1 - src/gateway/session-create-service.ts | 4 +- src/gateway/session-message-events.test.ts | 29 +- src/gateway/session-reset-service.ts | 43 +- .../session-title-inbound-metadata.test.ts | 34 +- src/gateway/session-transcript-key.test.ts | 182 - src/gateway/session-transcript-key.ts | 142 - .../session-transcript-readers.test.ts | 338 +- src/gateway/session-transcript-readers.ts | 42 +- src/gateway/session-utils.fs.test.ts | 63 +- src/gateway/session-utils.fs.ts | 68 - src/gateway/sessions-history-http.test.ts | 7 +- src/gateway/sessions-history-http.ts | 2 +- src/gateway/sessions-patch.ts | 6 +- src/gateway/test-helpers.server.ts | 44 +- .../test/server-sessions.test-helpers.ts | 22 +- .../live-event-projection.ts | 9 +- .../transcript-commit.test.ts | 66 +- .../worker-environments/transcript-commit.ts | 5 +- .../worker-turn-launcher.test.ts | 142 +- .../worker-turn-launcher.ts | 18 +- .../worker-turn-transcript-target.ts | 41 + .../worker-workspace-conflict-transcript.ts | 10 +- .../bundled/session-memory/handler.test.ts | 212 +- src/hooks/bundled/session-memory/handler.ts | 198 +- .../bundled/session-memory/transcript.test.ts | 89 +- .../bundled/session-memory/transcript.ts | 166 +- src/hooks/llm-slug-generator.ts | 28 +- src/infra/outbound/deliver.test.ts | 6 +- .../outbound/outbound-send-service.test.ts | 5 +- src/infra/session-cost-usage-aggregation.ts | 2 +- src/infra/session-cost-usage-collection.ts | 108 +- src/infra/session-cost-usage-reporting.ts | 8 +- src/infra/session-cost-usage.test.ts | 216 +- .../state-migrations.legacy-session-store.ts | 2 +- src/infra/state-migrations.session-store.ts | 11 +- src/infra/state-migrations.test.ts | 4 +- .../agent-runtime-test-contracts.ts | 1 + src/plugin-sdk/session-store-runtime.ts | 12 +- src/plugin-sdk/session-transcript-hit.ts | 18 - .../session-transcript-runtime.test.ts | 3 + src/plugin-sdk/sqlite-runtime-testing.ts | 2 +- .../agents/session-manager-file-compat.ts | 94 + src/plugins/commands.ts | 2 + .../session-entry-projection.contract.test.ts | 9 + src/plugins/host-hook-state.ts | 2 +- src/plugins/plugin-command.types.ts | 11 + src/plugins/registry-runtime.ts | 69 +- src/plugins/registry.runtime-config.test.ts | 60 +- src/plugins/runtime/runtime-agent.test.ts | 34 +- src/plugins/session-entry-slot-keys.ts | 7 +- src/sessions/transcript-events.ts | 3 + .../user-turn-transcript.persistence.test.ts | 2 +- src/sessions/user-turn-transcript.test.ts | 12 +- src/sessions/user-turn-transcript.ts | 2 +- src/sessions/user-turn-transcript.types.ts | 8 +- src/skills/workshop/experience-review.ts | 119 +- src/skills/workshop/history-scan-review.ts | 11 +- src/status/status-message.ts | 21 +- src/status/status-text.test.ts | 2 +- src/system-agent/agent-turn.test.ts | 20 +- src/system-agent/agent-turn.ts | 27 +- src/system-agent/assistant.ts | 4 +- src/system-agent/setup-inference-persist.ts | 17 +- src/system-agent/setup-inference.test.ts | 32 +- src/trajectory/cleanup.test.ts | 2 +- src/trajectory/cleanup.ts | 2 +- src/trajectory/command-export.ts | 5 +- src/trajectory/export.test.ts | 148 +- src/trajectory/export.ts | 151 +- src/trajectory/paths.ts | 2 +- src/trajectory/runtime-store.sqlite.test.ts | 35 + src/trajectory/runtime-store.sqlite.ts | 21 +- src/trajectory/runtime.test.ts | 65 +- src/trajectory/runtime.ts | 74 +- src/tui/embedded-backend.ts | 2 +- src/tui/tui-pty-local.e2e.test.ts | 3 + src/worker/worker.fault-injection.test.ts | 34 +- test/helpers/session-manager-file-compat.ts | 5 + ...sions-transcripts-flip-proof-assertions.ts | 2 +- .../sqlite-sessions-transcripts-flip-proof.ts | 50 +- test/scripts/lint-suppressions.test.ts | 1 + ...essions-transcripts-flip-proof.e2e.test.ts | 18 +- tsconfig.core.json | 10 +- 430 files changed, 11304 insertions(+), 23713 deletions(-) delete mode 100644 src/agents/embedded-agent-runner/compaction-successor-transcript.duplicate-prompt-loss.test.ts delete mode 100644 src/agents/embedded-agent-runner/compaction-successor-transcript.post-compaction-duplicate-prompt.test.ts delete mode 100644 src/agents/embedded-agent-runner/compaction-successor-transcript.test.ts delete mode 100644 src/agents/embedded-agent-runner/compaction-successor-transcript.ts delete mode 100644 src/agents/embedded-agent-runner/manual-compaction-boundary.test.ts delete mode 100644 src/agents/embedded-agent-runner/manual-compaction-boundary.ts create mode 100644 src/agents/embedded-agent-runner/run/attempt-normalization.test.ts create mode 100644 src/agents/embedded-agent-runner/run/attempt-session-identity.ts delete mode 100644 src/agents/embedded-agent-runner/run/attempt.session-lock.controller.ts delete mode 100644 src/agents/embedded-agent-runner/run/attempt.session-lock.entries.ts delete mode 100644 src/agents/embedded-agent-runner/run/attempt.session-lock.fence-controller.ts delete mode 100644 src/agents/embedded-agent-runner/run/attempt.session-lock.fence.ts delete mode 100644 src/agents/embedded-agent-runner/run/attempt.session-lock.owner.ts delete mode 100644 src/agents/embedded-agent-runner/run/attempt.session-lock.prompt.ts delete mode 100644 src/agents/embedded-agent-runner/run/attempt.session-lock.test-support.ts delete mode 100644 src/agents/embedded-agent-runner/run/attempt.session-lock.write-scope.ts delete mode 100644 src/agents/embedded-agent-runner/session-file-key.ts delete mode 100644 src/agents/embedded-agent-runner/session-manager-cache.test-support.ts delete mode 100644 src/agents/embedded-agent-runner/session-manager-cache.test.ts delete mode 100644 src/agents/embedded-agent-runner/session-manager-cache.ts delete mode 100644 src/agents/embedded-agent-runner/session-manager-init.test.ts delete mode 100644 src/agents/embedded-agent-runner/session-manager-init.ts delete mode 100644 src/agents/embedded-agent-runner/transcript-file-state.ts delete mode 100644 src/agents/session-file-repair.test.ts delete mode 100644 src/agents/session-file-repair.ts create mode 100644 src/agents/sessions/session-manager-codec.test.ts delete mode 100644 src/agents/sessions/session-manager-file.ts create mode 100644 src/agents/sessions/session-manager.persistence-compat.test.ts create mode 100644 src/agents/sessions/session-manager.test-compat.d.ts create mode 100644 src/auto-reply/reply/agent-runner-core.test.ts delete mode 100644 src/config/cache-utils.test.ts delete mode 100644 src/config/cache-utils.ts delete mode 100644 src/config/sessions/compaction-session-file.ts rename src/config/sessions/{sqlite-marker.ts => legacy-sqlite-marker.ts} (73%) delete mode 100644 src/config/sessions/paths.sqlite-marker.test.ts create mode 100644 src/config/sessions/session-accessor.sqlite-visible-cursor.ts create mode 100644 src/config/sessions/session-entry.test-compat.d.ts delete mode 100644 src/config/sessions/session-file-rotation.ts delete mode 100644 src/config/sessions/session-file.ts create mode 100644 src/config/sessions/session-transcript-projection-error.ts delete mode 100644 src/gateway/session-compaction-checkpoint-entry.ts delete mode 100644 src/gateway/session-transcript-key.test.ts delete mode 100644 src/gateway/session-transcript-key.ts create mode 100644 src/gateway/worker-environments/worker-turn-transcript-target.ts create mode 100644 src/plugin-sdk/test-helpers/agents/session-manager-file-compat.ts create mode 100644 test/helpers/session-manager-file-compat.ts diff --git a/config/max-lines-baseline.txt b/config/max-lines-baseline.txt index f366c245842f..52d5d8cdb2ab 100644 --- a/config/max-lines-baseline.txt +++ b/config/max-lines-baseline.txt @@ -414,7 +414,6 @@ src/agents/embedded-agent-runner/run.overflow-compaction.loop.test.ts src/agents/embedded-agent-runner/run.overflow-compaction.test.ts src/agents/embedded-agent-runner/run/attempt.model-diagnostic-events.test.ts src/agents/embedded-agent-runner/run/attempt.model-diagnostic-events.ts -src/agents/embedded-agent-runner/run/attempt.session-lock.test.ts src/agents/embedded-agent-runner/run/attempt.spawn-workspace.context-engine.test.ts src/agents/embedded-agent-runner/run/attempt.spawn-workspace.test-support.ts src/agents/embedded-agent-runner/run/attempt.test.ts @@ -430,7 +429,6 @@ src/agents/embedded-agent-runner/tool-result-context-guard.test.ts src/agents/embedded-agent-runner/tool-result-truncation.test.ts src/agents/embedded-agent-runner/tool-result-truncation.ts src/agents/embedded-agent-runner/transcript-file-state.test.ts -src/agents/embedded-agent-runner/transcript-file-state.ts src/agents/embedded-agent-subscribe.handlers.lifecycle.test.ts src/agents/embedded-agent-subscribe.handlers.messages.test.ts src/agents/embedded-agent-subscribe.handlers.messages.ts @@ -469,8 +467,6 @@ src/agents/provider-transport-fetch.test.ts src/agents/provider-transport-fetch.ts src/agents/runtime-plan/prepare-auth.test.ts src/agents/sandbox/ssh.ts -src/agents/session-file-repair.test.ts -src/agents/session-file-repair.ts src/agents/session-tool-result-guard.ts src/agents/session-transcript-repair.test.ts src/agents/session-write-lock.test.ts @@ -480,7 +476,6 @@ src/agents/sessions/extensions/types.ts src/agents/sessions/model-registry.ts src/agents/sessions/package-manager.ts src/agents/sessions/resource-loader.ts -src/agents/sessions/session-manager.test.ts src/agents/sessions/settings-manager.ts src/agents/subagent-announce-delivery.test.ts src/agents/subagent-announce-delivery.ts diff --git a/docs/.generated/plugin-sdk-api-baseline.sha256 b/docs/.generated/plugin-sdk-api-baseline.sha256 index 86b8d014f900..ac4d4e6b6e08 100644 --- a/docs/.generated/plugin-sdk-api-baseline.sha256 +++ b/docs/.generated/plugin-sdk-api-baseline.sha256 @@ -4,7 +4,7 @@ cbf4e2c3088f8886a7c9ea91325a66e0f0846cea21f0b2891f36399b4811306c module/account 8e985f345f21a1c9a2b0e94304aaaad6a326bec1c1ce3b26027d2862804a366e module/account-resolution e5e67ddf3cab38fcbf9220bc3160715897e2709d9a9ff6ff36f1ecc9453c2367 module/agent-config-primitives 74daa746deb548379d3f0d6eac3c4d082df1034c4360cc03bf51fee0f10a2e4d module/agent-harness -060517a859a6354894bad581ca4a3e138b9a69c8cf8bae4656e0893c549df77b module/agent-harness-runtime +23461d43c31d8c143e9d45aa91e27a22938d3549093d3d637b3bb88cf400c8b8 module/agent-harness-runtime 5168648cd946abad8a92822889f13ceacc87ed502314a66190d0b1eb8ebe76ea module/agent-media-payload 2dcb4d62d90e5d71594f6b843c97534509a154784e378fb1c75bd86b5122b710 module/agent-runtime 56b6d5fb6af3d95af1200065aca2e7d4f59e5fa59740505fe6ff433077ef6646 module/allow-from @@ -97,7 +97,7 @@ bebd2931dc51d67c063ff19fa1c278f8dcfe00ab23cfbd480d47329ea8e5088e module/meeting 8d8c0c4ebfc6e0c6125df3ae64ec66c10cd797a325de5ea7869fcd84068ab290 module/persistent-dedupe 987648ebe317cc4d6c0505a52d344b12ce9c3a8261a5bd969cee3423c0c53529 module/plugin-config-runtime 61727825f684f3c82a22f4dc68d1f36d94b20fd1e53c24981263bc289850002f module/plugin-entry -8097910898d08166f3e2e3676de5dac43f3442cfb1faa1b894ded353d973d09d module/plugin-runtime +8bc85c9f7df434c87cd7d35cc33080e2577a1893353f92bff8a0cc63d2c4eb0e module/plugin-runtime 9c9670596f6dee29ec7cdcb0616cefbcd50facbf71413bec4f7c63de8f4d5469 module/provider-auth 71bebeac51e701cd7c8e63d22754b9bcbd82b024aced303aa055d229781129d7 module/provider-catalog-runtime 56151035047a69e6163d5578023d00f51a2413b777f3784af88e06261c039345 module/proxy-capture @@ -119,7 +119,7 @@ b6b8edc50ecab8386c9acd8f374a207212b5a99c8f518538bbcf0c458dda3881 module/runtime e576b537880f63b3a91f3608f7e84c873bce6c6a3d9a0ba98c247f46de788d25 module/secret-ref-runtime c81b9702c192d574413fc1df9a57c73128652a25de205eec55d6b47549349283 module/security-runtime 31b785e74f1f8f56241b7756ef6a5d86199c5ce177cbb1c234a261866972f270 module/session-discussion -32fb6d253abf22440bc76c7a68d1f35fc0ef369b0ad738aedba9c3054a76e48e module/session-store-runtime +f112bdabc51ba8659b37d0a6f6a32a2b1d471e5b49b56e108bf750ec55a7ea71 module/session-store-runtime 36affbe151431a6141664b6838e20f2d121ff210d57a3c1b4b41a8818b5c81d8 module/setup 21071e8c2ef020685aba09b5661e37e7415938ff6602053fa831ac9d58673248 module/setup-runtime cd431f6ba8327b81438b7a63b1963120f200f5abd145fb6aa7c5c561339cb0b1 module/setup-tools diff --git a/docs/concepts/active-memory.md b/docs/concepts/active-memory.md index de93b70b309c..41924ba55d5b 100644 --- a/docs/concepts/active-memory.md +++ b/docs/concepts/active-memory.md @@ -578,11 +578,11 @@ promptOverride: "You are a memory search agent. Return NONE or one compact user ## Transcript persistence -Blocking sub-agent runs create a real `session.jsonl` transcript during the -call. By default it is written to a temp directory and deleted immediately -after the run finishes. +Blocking sub-agent runs keep their runtime transcript in the agent's SQLite +store. By default, OpenClaw removes the temporary sub-agent session rows after +the run finishes and does not create a JSONL file. -To keep those transcripts on disk for debugging: +To export those transcripts as JSONL artifacts for debugging: ```json5 { @@ -601,17 +601,17 @@ To keep those transcripts on disk for debugging: } ``` -Persisted transcripts go under the target agent's sessions folder, in a -separate directory from the main user conversation transcript: +Exported transcript artifacts go under the target agent's sessions folder, in +a separate directory from active runtime state: ```text agents//sessions/active-memory/.jsonl ``` -Change the relative subdirectory with `config.transcriptDir`. Use this -carefully: transcripts can accumulate quickly on busy sessions, `full` query -mode duplicates a lot of conversation context, and these transcripts contain -hidden prompt context plus recalled memories. +Change the relative artifact subdirectory with `config.transcriptDir`. Use this +carefully: exports can accumulate quickly on busy sessions, `full` query mode +duplicates a lot of conversation context, and these artifacts contain hidden +prompt context plus recalled memories. ## Configuration @@ -636,8 +636,8 @@ All active memory configuration lives under `plugins.entries.active-memory`. | `config.setupGraceTimeoutMs` | `number` | Advanced extra setup budget before the recall timeout expires; range 0-30000 ms, default 0. See [Cold-start grace](#cold-start-grace) for v2026.4.x upgrade guidance | | `config.maxSummaryChars` | `number` | Maximum characters in the active-memory summary (range 40-1000; default 220) | | `config.logging` | `boolean` | Emits active memory logs while tuning | -| `config.persistTranscripts` | `boolean` | Keeps blocking sub-agent transcripts on disk instead of deleting temp files | -| `config.transcriptDir` | `string` | Relative blocking sub-agent transcript directory under the agent sessions folder (default `"active-memory"`) | +| `config.persistTranscripts` | `boolean` | Exports blocking sub-agent transcripts as JSONL artifacts before removing their temporary SQLite session rows | +| `config.transcriptDir` | `string` | Relative transcript-artifact directory under the agent sessions folder (default `"active-memory"`) | | `config.modelFallback` | `string` | Optional model used only as the last step in the [model fallback chain](#model-fallback-policy) | | `config.qmd.searchMode` | `"inherit" \| "search" \| "vsearch" \| "query"` | Overrides the QMD search mode used by the blocking sub-agent; default `"search"` (fast lexical search) — use `"inherit"` to match the main memory backend setting | diff --git a/docs/concepts/agent-loop.md b/docs/concepts/agent-loop.md index 07b6e7b8f326..c408cdfe47aa 100644 --- a/docs/concepts/agent-loop.md +++ b/docs/concepts/agent-loop.md @@ -27,9 +27,7 @@ execution, streaming, persistence. Runs are serialized per session key (session lane) and optionally through a global lane, preventing tool/session races. Messaging channels choose a queue mode (steer/followup/collect/interrupt) that feeds this lane system; see [Command Queue](/concepts/queue). -Transcript writes are additionally protected by a session write lock on the session file. The lock is process-aware and file-based, so it catches writers that bypass the in-process queue or come from another process. Writers wait up to 60 seconds by default (env override `OPENCLAW_SESSION_WRITE_LOCK_ACQUIRE_TIMEOUT_MS`) before reporting the session as busy. - -Session write locks are non-reentrant by default. A helper that intentionally nests acquisition of the same lock while preserving one logical writer must opt in with `allowReentrant: true`. +Transcript writes are serialized by the per-session lane and the SQLite writer queue. Each append or rewrite validates the current session identity inside its synchronous commit transaction, so a stale run cannot overwrite a newer session generation. ## Session and workspace preparation diff --git a/docs/concepts/compaction.md b/docs/concepts/compaction.md index 779ead45ba59..db86470a0857 100644 --- a/docs/concepts/compaction.md +++ b/docs/concepts/compaction.md @@ -60,7 +60,7 @@ Type `/compact` in any chat to force a compaction. Add instructions to guide the /compact Focus on the API design decisions ``` -When `agents.defaults.compaction.keepRecentTokens` is set (default: 20,000), manual compaction honors that cut-point and keeps the recent tail in rebuilt context. Without an explicit keep budget, manual compaction behaves as a hard checkpoint and continues from the new summary alone. +Manual compaction uses `agents.defaults.compaction.keepRecentTokens` (default: 20,000) as its cut-point budget and keeps that recent tail in rebuilt context. ## Configuration @@ -120,10 +120,7 @@ checkpoint artifacts are not the active compaction target. ### Successor transcripts -When `agents.defaults.compaction.truncateAfterCompaction` is enabled, OpenClaw does not rewrite the existing transcript in place. It creates a new active successor transcript from the compaction summary, preserved state, and unsummarized tail, then records checkpoint metadata that points branch/restore flows at that compacted successor. -Successor transcripts also drop exact duplicate long user turns that arrive -inside a short retry window, so channel retry storms are not carried into the -next active transcript after compaction. +When `agents.defaults.compaction.truncateAfterCompaction` is enabled, a context engine may return an explicit compacted successor session identity. OpenClaw adopts that successor and records checkpoint metadata against it. The built-in SQLite compactor keeps the current session identity and does not create a second runtime transcript. OpenClaw no longer writes separate `.checkpoint.*.jsonl` copies for new compactions. Existing legacy checkpoint files can still be used while referenced diff --git a/docs/concepts/queue.md b/docs/concepts/queue.md index 837698f6e198..71e2d348a15c 100644 --- a/docs/concepts/queue.md +++ b/docs/concepts/queue.md @@ -11,7 +11,7 @@ OpenClaw serializes inbound auto-reply runs (all channels) through a tiny in-pro ## Why - Auto-reply runs can be expensive (LLM calls) and can collide when multiple inbound messages arrive close together. -- Serializing avoids competing for shared resources (session files, logs, CLI stdin) and reduces the chance of upstream rate limits. +- Serializing avoids competing for shared resources (session state, logs, CLI stdin) and reduces the chance of upstream rate limits. ## How it works diff --git a/docs/gateway/config-agents.md b/docs/gateway/config-agents.md index 1e3e1c4ec5e3..250f93f361e3 100644 --- a/docs/gateway/config-agents.md +++ b/docs/gateway/config-agents.md @@ -610,7 +610,7 @@ Delegated consults with a requesting agent keep that requester as their owner. W postIndexSync: "async", // off | async | await postCompactionSections: ["Session Startup", "Red Lines"], model: "openrouter/anthropic/claude-sonnet-4-6", // optional compaction-only model override - truncateAfterCompaction: true, // rotate to a smaller successor JSONL after compaction + truncateAfterCompaction: true, // adopt a context-engine successor when one is returned maxActiveTranscriptBytes: "20mb", // optional preflight local compaction trigger notifyUser: true, // notices when compaction starts/completes and on memory-flush degradation (default: false) memoryFlush: { @@ -630,7 +630,7 @@ Delegated consults with a requesting agent keep that requester as their owner. W - `provider`: id of a registered compaction provider plugin. When set, the provider's `summarize()` is called instead of built-in LLM summarization. Falls back to built-in on failure. Setting a provider forces `mode: "safeguard"`. See [Compaction](/concepts/compaction). - `thinkingLevel`: optional thinking level used only for embedded OpenClaw compaction summaries (`off`, `minimal`, `low`, `medium`, `high`, `xhigh`, `adaptive`, `max`, or `ultra`). It overrides the session's current thinking level and is clamped to the selected compaction model/runtime. Leave unset to inherit the session level. Native Codex app-server compaction ignores this setting because the native compact request has no per-operation thinking override; OpenClaw logs a warning when configured. - `timeoutSeconds`: maximum seconds allowed for a single compaction operation before OpenClaw aborts it. Default: `180`. -- `keepRecentTokens`: agent cut-point budget for keeping the most recent transcript tail verbatim. Manual `/compact` honors this when explicitly set; otherwise manual compaction is a hard checkpoint. +- `keepRecentTokens`: agent cut-point budget for keeping the most recent transcript tail verbatim. Default: `20000`. - `recentTurnsPreserve`: number of most recent user/assistant turns kept verbatim outside safeguard summarization. Default: `3`. - `identifierPolicy`: `strict` (default) or `off`. `strict` prepends built-in opaque identifier retention guidance during compaction summarization. - `qualityGuard`: retry-on-malformed-output checks for safeguard summaries. Enabled by default in safeguard mode; set `enabled: false` to skip the audit. @@ -638,8 +638,8 @@ Delegated consults with a requesting agent keep that requester as their owner. W - `postIndexSync`: post-compaction session-memory reindex mode. Default: `"async"`. Use `"await"` for strongest freshness, `"async"` for lower compaction latency, or `"off"` only when session-memory sync is handled elsewhere. - `postCompactionSections`: optional AGENTS.md H2/H3 section names to re-inject after compaction. Leave unset or use `[]` to disable. - `model`: optional `provider/model-id` or bare alias from `agents.defaults.models` for compaction summarization only. Bare aliases resolve before dispatch; configured literal model IDs retain precedence on collisions. Use this when the main session should keep one model but compaction summaries should run on another; when unset, compaction uses the session's primary model. -- `truncateAfterCompaction`: rotates the active session transcript after compaction so future turns load only the summary and unsummarized tail, while the previous full transcript remains archived. Prevents unbounded active transcript growth in long-running sessions. Default: `false`. -- `maxActiveTranscriptBytes`: optional byte threshold (`number` or strings like `"20mb"`) that triggers normal local compaction before a run when transcript history grows past the threshold. Requires `truncateAfterCompaction` so successful compaction can rotate to a smaller successor transcript. Disabled when unset or `0`. +- `truncateAfterCompaction`: allows a context engine to advance the session to an explicit compacted successor identity. The built-in SQLite compactor keeps the current identity. Default: `false`. +- `maxActiveTranscriptBytes`: optional byte threshold (`number` or strings like `"20mb"`) that triggers normal local compaction before a run when transcript history grows past the threshold. Requires `truncateAfterCompaction`; durable size reduction also requires a context engine that returns a successor identity. Disabled when unset or `0`. - `notifyUser`: when `true`, sends brief context-maintenance notices to the user: when compaction starts and completes (for example, "Compacting context..." and "Compaction complete"), and when a pre-compaction memory flush is exhausted so the reply continues in a degraded state (for example, "Memory maintenance temporarily failed; continuing your reply."). Disabled by default to keep these notices silent. - `memoryFlush`: silent agentic turn before auto-compaction to store durable memories. Set `model` to an exact provider/model such as `ollama/qwen3:8b` when this housekeeping turn should stay on a local model; the override does not inherit the active session fallback chain. `forceFlushTranscriptBytes` forces the flush when transcript size reaches the threshold even if token counters are stale. Skipped when workspace is read-only. diff --git a/docs/reference/session-management-compaction.md b/docs/reference/session-management-compaction.md index 14c8017246c1..5d8a7b864b4d 100644 --- a/docs/reference/session-management-compaction.md +++ b/docs/reference/session-management-compaction.md @@ -7,7 +7,7 @@ read_when: title: "Session management deep dive" --- -A single **Gateway process** owns session state end-to-end. UIs (macOS app, web Control UI, TUI) query the Gateway for session lists and token counts. In remote mode, session files live on the remote host, so checking your local Mac's files will not reflect what the Gateway is using. +A single **Gateway process** owns session state end-to-end. UIs (macOS app, web Control UI, TUI) query the Gateway for session lists and token counts. In remote mode, the per-agent SQLite database lives on the remote host, so checking your local Mac's state will not reflect what the Gateway is using. Overview docs first: [Session management](/concepts/session), [Compaction](/concepts/compaction), [Memory overview](/concepts/memory), [Memory search](/concepts/memory-search), [Session pruning](/concepts/session-pruning), [Transcript hygiene](/reference/transcript-hygiene), full config reference at [Agent config](/gateway/config-agents). @@ -217,7 +217,7 @@ Two triggers in the embedded OpenClaw agent: Two additional guards run outside these two triggers: -- **Preflight local compaction**: set `agents.defaults.compaction.maxActiveTranscriptBytes` (bytes or a string like `"20mb"`) to trigger local compaction before opening the next run once the active transcript reaches that size. This is a size guard for local reopen cost, not raw archival - normal semantic compaction still runs, and it requires `truncateAfterCompaction` so the compacted summary becomes a new successor transcript. +- **Preflight local compaction**: set `agents.defaults.compaction.maxActiveTranscriptBytes` (bytes or a string like `"20mb"`) to trigger local compaction before opening the next run once the active transcript reaches that size. Normal semantic compaction still runs. Durable size reduction requires `truncateAfterCompaction` plus a context engine that returns an explicit successor identity. - **Mid-turn precheck**: set `agents.defaults.compaction.midTurnPrecheck.enabled: true` (default `false`) to add a tool-loop guard. After a tool result is appended and before the next model call, OpenClaw estimates prompt pressure using the same preflight budget logic used at turn start. If context no longer fits, the guard does not compact inline - it raises a structured mid-turn precheck signal, stops the current prompt submission, and lets the outer run loop use the existing recovery path (truncate oversized tool results when that is enough, or trigger the configured compaction mode and retry). Works with both `default` and `safeguard` compaction modes, including provider-backed safeguard compaction. Independent of `maxActiveTranscriptBytes`: the byte-size guard runs before a turn opens, mid-turn precheck runs later, after new tool results are appended. ## Compaction settings @@ -239,9 +239,9 @@ OpenClaw enforces a built-in reserve for embedded runs and caps it against the a Set `enabled: false` to disable threshold-driven auto-compaction inside the embedded agent runtime. OpenClaw's preflight and overflow-recovery compaction paths remain available, and manual `/compact` continues to work. -Manual `/compact` honors an explicit `agents.defaults.compaction.keepRecentTokens` and keeps the runtime's recent-tail cut point. Without an explicit keep budget, manual compaction is a hard checkpoint and rebuilt context starts from the new summary. +Manual `/compact` uses `agents.defaults.compaction.keepRecentTokens` (default: `20000`) and keeps that recent-tail cut point. -When `truncateAfterCompaction` is enabled, OpenClaw rotates the active transcript to a compacted successor after compaction. Branch/restore checkpoint actions use that compacted successor; legacy pre-compaction checkpoint files remain readable while referenced. +When `truncateAfterCompaction` is enabled, OpenClaw adopts an explicit successor identity returned by a context engine. The built-in SQLite compactor keeps the current session identity. Branch/restore checkpoint actions use a returned successor when present; legacy pre-compaction checkpoint files remain readable while referenced. ## Pluggable compaction providers @@ -278,12 +278,12 @@ Before auto-compaction happens, OpenClaw can run a silent agentic turn that writ Config (`agents.defaults.compaction.memoryFlush`), full reference at [/gateway/config-agents](/gateway/config-agents#agentsdefaultscompaction): -| Key | Default | Notes | -| --------------------------- | ---------------- | -------------------------------------------------------------------------------------------------------------------------------------- | -| `enabled` | `true` | | -| `model` | unset | exact provider/model override for the flush turn only, for example `ollama/qwen3:8b` | -| `softThresholdTokens` | `4000` | gap below the compaction threshold that triggers a flush | -| `forceFlushTranscriptBytes` | unset (disabled) | force a flush once the transcript file reaches this byte size (or string like `"2mb"`), even if token counters are stale; `0` disables | +| Key | Default | Notes | +| --------------------------- | ---------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------ | +| `enabled` | `true` | | +| `model` | unset | exact provider/model override for the flush turn only, for example `ollama/qwen3:8b` | +| `softThresholdTokens` | `4000` | gap below the compaction threshold that triggers a flush | +| `forceFlushTranscriptBytes` | unset (disabled) | force a flush once active transcript history reaches this estimated byte size (or string like `"2mb"`), even if token counters are stale; `0` disables | Notes: diff --git a/docs/reference/test.md b/docs/reference/test.md index 465e74c7c806..8452c4ddbf65 100644 --- a/docs/reference/test.md +++ b/docs/reference/test.md @@ -159,7 +159,7 @@ Other behavior: the runner preflights Docker by default, cleans stale OpenClaw E | `pnpm test:docker:live-cli-backend:claude`, `:claude:resume`, `:claude:mcp` | Focused CLI backend live probes; Gemini has matching `:resume` and `:mcp` aliases. | | `pnpm test:docker:openwebui` | Dockerized OpenClaw + Open WebUI: sign in, check `/api/models`, run a real proxied chat through `/api/chat/completions`. Requires a usable live model key and pulls an external image; not expected to be CI-stable like the unit/e2e suites. | | `pnpm test:docker:mcp-channels` | Seeded Gateway container plus a client container spawning `openclaw mcp serve`: routed conversation discovery, transcript reads, attachment metadata, live event queue behavior, outbound send routing, and Claude-style channel + permission notifications over the real stdio bridge (assertion reads raw stdio MCP frames directly). | -| `pnpm test:docker:upgrade-survivor` | Installs the packed tarball over a dirty old-user fixture, runs package update plus non-interactive doctor without live provider/channel keys, starts a loopback Gateway, checks agents/channel config/plugin allowlists/workspace/session files/stale legacy plugin dependency state/startup/RPC status survive. | +| `pnpm test:docker:upgrade-survivor` | Installs the packed tarball over a dirty old-user fixture, runs package update plus non-interactive doctor without live provider/channel keys, starts a loopback Gateway, checks agents/channel config/plugin allowlists/workspace/session state/stale legacy plugin dependency state/startup/RPC status survive. | | `pnpm test:docker:published-upgrade-survivor` | Installs `openclaw@latest` by default, seeds realistic existing-user files, configures via a baked `openclaw config set` recipe, updates to the packed tarball, runs non-interactive doctor, writes `.artifacts/upgrade-survivor/summary.json`, checks `/healthz`, `/readyz`, RPC status. Override with `OPENCLAW_UPGRADE_SURVIVOR_BASELINE_SPEC`, expand a matrix with `OPENCLAW_UPGRADE_SURVIVOR_BASELINE_SPECS`, or add scenario fixtures with `OPENCLAW_UPGRADE_SURVIVOR_SCENARIOS=reported-issues` (includes `configured-plugin-installs` and `stale-source-plugin-shadow`). Package Acceptance exposes these as `published_upgrade_survivor_baseline(s)` / `_scenarios` and resolves meta tokens like `last-stable-4` or `all-since-2026.4.23`. | | `pnpm test:docker:update-migration` | Published-upgrade survivor harness in the `plugin-deps-cleanup` scenario, starting at `openclaw@2026.4.23` by default. The `Update Migration` workflow expands this with `baselines=all-since-2026.4.23` to prove configured-plugin dependency cleanup outside Full Release CI. | | `pnpm test:docker:plugins` | Install/update smoke for local path, `file:`, npm registry packages with hoisted dependencies, git moving refs, ClawHub fixtures, marketplace updates, and Claude-bundle enable/inspect. | @@ -270,7 +270,7 @@ Optional; only needed for containerized onboarding smoke tests. Full cold-start scripts/e2e/onboard-docker.sh ``` -Drives the interactive wizard via a pseudo-tty, verifies config/workspace/session files, then starts the gateway and runs `openclaw health`. +Drives the interactive wizard via a pseudo-tty, verifies config/workspace/session state, then starts the gateway and runs `openclaw health`. ## QR import smoke (Docker) diff --git a/docs/reference/transcript-hygiene.md b/docs/reference/transcript-hygiene.md index 111bb80844ec..09edf941b276 100644 --- a/docs/reference/transcript-hygiene.md +++ b/docs/reference/transcript-hygiene.md @@ -8,19 +8,12 @@ title: "Transcript hygiene" --- OpenClaw applies **provider-specific fixes** to transcripts before a run -(building model context). Most of these are **in-memory** adjustments used to -satisfy strict provider requirements. A separate session-file repair pass may -also rewrite stored JSONL before the session is loaded, but only for -malformed lines or persisted turns that are invalid durable records. -Delivered assistant replies are preserved on disk; provider-specific +(building model context). These are **in-memory** adjustments used to satisfy +strict provider requirements. Runtime transcript state stays in SQLite; +provider-specific assistant-prefill stripping happens only while constructing outbound payloads. -When a repair occurs, the original file is written to a transient -`*.bak--` sibling before the atomic replace, then removed once the -replace succeeds. The backup is retained only if cleanup itself fails, in -which case the path is reported back. - Scope includes: - Runtime-only prompt context staying out of user-visible transcript turns @@ -64,12 +57,8 @@ All transcript hygiene is centralized in the embedded runner: - Sanitization/repair application: `sanitizeSessionHistory` in `src/agents/embedded-agent-runner/replay-history.ts` -Separate from transcript hygiene, session files are repaired (if needed) -before load: - -- `repairSessionFileIfNeeded` in `src/agents/session-file-repair.ts` -- Called from `src/agents/embedded-agent-runner/run/attempt.ts` and - `src/agents/embedded-agent-runner/compact.ts` +Legacy JSONL validation and import belong to `openclaw doctor --fix`; the +embedded runner does not repair or reopen file-backed runtime transcripts. --- diff --git a/extensions/anthropic/session-catalog-history.test.ts b/extensions/anthropic/session-catalog-history.test.ts index 99da57ff1f70..8b0ce386bc78 100644 --- a/extensions/anthropic/session-catalog-history.test.ts +++ b/extensions/anthropic/session-catalog-history.test.ts @@ -37,7 +37,7 @@ describe("importClaudeHistory", () => { }, ], threadId: "thread-1", - sessionFile: "/tmp/unused.jsonl", + storePath: "/tmp/sessions.json", sessionId: "session-1", sessionKey: "agent:main:catalog-adopt", agentId: "main", @@ -60,7 +60,7 @@ describe("importClaudeHistory", () => { { type: "assistantMessage", text: "done", uuid: "a-1" }, ], threadId: "thread-1", - sessionFile: "/tmp/unused.jsonl", + storePath: "/tmp/sessions.json", sessionId: "session-1", sessionKey: "agent:main:catalog-adopt", agentId: "main", @@ -84,7 +84,7 @@ describe("importClaudeHistory", () => { { type: "assistantMessage", text: "AUTH_OK", uuid: "a-1" }, ], threadId: "thread-1", - sessionFile: "/tmp/unused.jsonl", + storePath: "/tmp/sessions.json", sessionId: "session-1", sessionKey: "agent:main:catalog-adopt", agentId: "main", @@ -101,7 +101,7 @@ describe("importClaudeHistory", () => { await importClaudeHistory({ items: [{ type: "toolCall", content: [{ type: "tool_use" }], uuid: "t-1" }], threadId: "thread-1", - sessionFile: "/tmp/unused.jsonl", + storePath: "/tmp/sessions.json", sessionId: "session-1", sessionKey: "agent:main:catalog-adopt", agentId: "main", diff --git a/extensions/anthropic/session-catalog-history.ts b/extensions/anthropic/session-catalog-history.ts index 6ed516b4b701..e6ecef2fdeb2 100644 --- a/extensions/anthropic/session-catalog-history.ts +++ b/extensions/anthropic/session-catalog-history.ts @@ -55,10 +55,10 @@ function importedClaudeMessage( export async function importClaudeHistory(params: { items: ClaudeTranscriptItem[]; threadId: string; - sessionFile: string; sessionId: string; sessionKey: string; agentId: string; + storePath: string; cwd?: string; config: OpenClawConfig; }): Promise { diff --git a/extensions/anthropic/session-catalog.ts b/extensions/anthropic/session-catalog.ts index 190ea7c80581..a9bf7d885b67 100644 --- a/extensions/anthropic/session-catalog.ts +++ b/extensions/anthropic/session-catalog.ts @@ -1592,16 +1592,15 @@ async function continueClaudeSession( pluginExtensions: { anthropic: { sessionCatalog: marker } }, }, afterCreate: async (entry) => { - if (!entry.entry.sessionFile) { - throw new Error("Claude session creation did not produce a transcript file"); - } await importClaudeHistory({ items: history, threadId, - sessionFile: entry.entry.sessionFile, sessionId: entry.sessionId, sessionKey: entry.key, agentId: entry.agentId, + storePath: api.runtime.agent.session.resolveStorePath(config.session?.store, { + agentId: entry.agentId, + }), ...(record.cwd ? { cwd: record.cwd } : {}), config, }); diff --git a/extensions/codex/src/app-server/delivery-no-reply-runtime-contract.test.ts b/extensions/codex/src/app-server/delivery-no-reply-runtime-contract.test.ts index cfdf693e1e32..c6eb69549b4a 100644 --- a/extensions/codex/src/app-server/delivery-no-reply-runtime-contract.test.ts +++ b/extensions/codex/src/app-server/delivery-no-reply-runtime-contract.test.ts @@ -3,7 +3,10 @@ import fs from "node:fs/promises"; import os from "node:os"; import path from "node:path"; import type { EmbeddedRunAttemptParams } from "openclaw/plugin-sdk/agent-harness"; -import { DELIVERY_NO_REPLY_RUNTIME_CONTRACT } from "openclaw/plugin-sdk/agent-runtime-test-contracts"; +import { + DELIVERY_NO_REPLY_RUNTIME_CONTRACT, + installSessionManagerFileCompat, +} from "openclaw/plugin-sdk/agent-runtime-test-contracts"; import { SessionManager } from "openclaw/plugin-sdk/agent-sessions"; import { isSilentReplyPayloadText } from "openclaw/plugin-sdk/reply-chunking"; import { afterEach, describe, expect, it } from "vitest"; @@ -14,13 +17,15 @@ const THREAD_ID = "thread-delivery-contract"; const TURN_ID = "turn-delivery-contract"; const tempDirs = new Set(); +installSessionManagerFileCompat(); + type ProjectorNotification = Parameters[0]; async function createParams(): Promise { const tempDir = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-codex-delivery-contract-")); tempDirs.add(tempDir); const sessionFile = path.join(tempDir, "session.jsonl"); - SessionManager.open(sessionFile); + SessionManager.openFile(sessionFile); return { prompt: DELIVERY_NO_REPLY_RUNTIME_CONTRACT.prompt, sessionId: DELIVERY_NO_REPLY_RUNTIME_CONTRACT.sessionId, diff --git a/extensions/codex/src/app-server/event-projector.test-harness.ts b/extensions/codex/src/app-server/event-projector.test-harness.ts index 877c8b4eabba..aa868af6de23 100644 --- a/extensions/codex/src/app-server/event-projector.test-harness.ts +++ b/extensions/codex/src/app-server/event-projector.test-harness.ts @@ -9,6 +9,7 @@ import { inferToolMetaFromArgs, resetAgentEventsForTest, } from "openclaw/plugin-sdk/agent-harness-runtime"; +import { installSessionManagerFileCompat } from "openclaw/plugin-sdk/agent-runtime-test-contracts"; import { SessionManager } from "openclaw/plugin-sdk/agent-sessions"; import { onInternalDiagnosticEvent, @@ -24,6 +25,8 @@ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import { CodexAppServerEventProjector } from "./event-projector.js"; import { createCodexTestModel, createCodexTestToolTerminalObserver } from "./test-support.js"; +installSessionManagerFileCompat(); + export { readAttemptTerminal } from "./attempt-terminal.test-helper.js"; export { @@ -90,7 +93,7 @@ export async function createParams(): Promise { const tempDir = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-codex-projector-")); tempDirs.add(tempDir); const sessionFile = path.join(tempDir, "session.jsonl"); - SessionManager.open(sessionFile).appendMessage(assistantMessage("history", Date.now())); + SessionManager.openFile(sessionFile).appendMessage(assistantMessage("history", Date.now())); return { prompt: "hello", sessionId: "session-1", diff --git a/extensions/codex/src/app-server/outcome-fallback-runtime-contract.test.ts b/extensions/codex/src/app-server/outcome-fallback-runtime-contract.test.ts index cf8a5f10e5d6..56ff3a0e8bdc 100644 --- a/extensions/codex/src/app-server/outcome-fallback-runtime-contract.test.ts +++ b/extensions/codex/src/app-server/outcome-fallback-runtime-contract.test.ts @@ -7,6 +7,7 @@ import type { EmbeddedRunAttemptParams } from "openclaw/plugin-sdk/agent-harness import { classifyEmbeddedAgentRunResultForModelFallback } from "openclaw/plugin-sdk/agent-harness-runtime"; import { createContractRunResult, + installSessionManagerFileCompat, OUTCOME_FALLBACK_RUNTIME_CONTRACT, } from "openclaw/plugin-sdk/agent-runtime-test-contracts"; import { SessionManager } from "openclaw/plugin-sdk/agent-sessions"; @@ -20,6 +21,8 @@ const THREAD_ID = "thread-outcome-contract"; const TURN_ID = "turn-outcome-contract"; const tempDirs = new Set(); +installSessionManagerFileCompat(); + type ProjectorNotification = Parameters[0]; type ProjectedAttemptResult = ReturnType; type CodexAppServerToolTelemetry = Parameters[0]; @@ -29,7 +32,7 @@ async function createParams(): Promise { const tempDir = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-codex-outcome-contract-")); tempDirs.add(tempDir); const sessionFile = path.join(tempDir, "session.jsonl"); - SessionManager.open(sessionFile); + SessionManager.openFile(sessionFile); return { prompt: OUTCOME_FALLBACK_RUNTIME_CONTRACT.prompt, sessionId: OUTCOME_FALLBACK_RUNTIME_CONTRACT.sessionId, diff --git a/extensions/codex/src/app-server/run-attempt.context-engine.test.ts b/extensions/codex/src/app-server/run-attempt.context-engine.test.ts index cc01f0b3d7d4..ccabfc217d49 100644 --- a/extensions/codex/src/app-server/run-attempt.context-engine.test.ts +++ b/extensions/codex/src/app-server/run-attempt.context-engine.test.ts @@ -9,6 +9,7 @@ import { supportsModelTools, type HarnessContextEngine as ContextEngine, } from "openclaw/plugin-sdk/agent-harness-runtime"; +import { installSessionManagerFileCompat } from "openclaw/plugin-sdk/agent-runtime-test-contracts"; import { SessionManager } from "openclaw/plugin-sdk/agent-sessions"; import { initializeGlobalHookRunner, @@ -38,6 +39,8 @@ import { const CODEX_TURN_START_TEXT_INPUT_MAX_CHARS = 1 << 20; +installSessionManagerFileCompat(); + let tempDir: string; let codexAppServerClientFactoryForTest: CodexTestAppServerClientFactory | undefined; @@ -457,7 +460,7 @@ describe("runCodexAppServerAttempt context-engine lifecycle", () => { it("bootstraps and assembles non-legacy context before the Codex turn starts", async () => { const sessionFile = path.join(tempDir, "session.jsonl"); const workspaceDir = path.join(tempDir, "workspace"); - SessionManager.open(sessionFile).appendMessage( + SessionManager.openFile(sessionFile).appendMessage( assistantMessage("existing context", Date.now()) as never, ); const openSpy = vi.spyOn(SessionManager, "open"); @@ -535,7 +538,7 @@ describe("runCodexAppServerAttempt context-engine lifecycle", () => { it("keeps context-engine history bound to the run session when sandbox key differs", async () => { const sessionFile = path.join(tempDir, "session.jsonl"); const workspaceDir = path.join(tempDir, "workspace"); - SessionManager.open(sessionFile).appendMessage( + SessionManager.openFile(sessionFile).appendMessage( assistantMessage("canonical main context", Date.now()) as never, ); const contextEngine = createContextEngine(); @@ -726,7 +729,7 @@ describe("runCodexAppServerAttempt context-engine lifecycle", () => { const info = vi.spyOn(embeddedAgentLog, "info").mockImplementation(() => undefined); const sessionFile = path.join(tempDir, "session.jsonl"); const workspaceDir = path.join(tempDir, "workspace"); - SessionManager.open(sessionFile).appendMessage( + SessionManager.openFile(sessionFile).appendMessage( assistantMessage("bootstrap-only context", Date.now()) as never, ); const contextEngine = createContextEngine({ @@ -979,7 +982,7 @@ describe("runCodexAppServerAttempt context-engine lifecycle", () => { const sessionFile = path.join(tempDir, "session.jsonl"); const workspaceDir = path.join(tempDir, "workspace"); const agentDir = path.join(tempDir, "agent"); - const sessionManager = SessionManager.open(sessionFile); + const sessionManager = SessionManager.openFile(sessionFile); sessionManager.appendMessage( userMessage("previous stale-bootstrap request", Date.now()) as never, ); @@ -1069,7 +1072,7 @@ describe("runCodexAppServerAttempt context-engine lifecycle", () => { it("keeps mirrored history when an inactive per-turn context-engine binding starts fresh", async () => { const sessionFile = path.join(tempDir, "session.jsonl"); const workspaceDir = path.join(tempDir, "workspace"); - const sessionManager = SessionManager.open(sessionFile); + const sessionManager = SessionManager.openFile(sessionFile); sessionManager.appendMessage(userMessage("previous per-turn request", 10) as never); sessionManager.appendMessage(assistantMessage("previous per-turn answer", 11) as never); await writeCodexAppServerBinding(sessionFile, { @@ -1447,7 +1450,7 @@ describe("runCodexAppServerAttempt context-engine lifecycle", () => { it("retries a resumed context-engine thread on a fresh Codex thread without plugin compaction", async () => { const sessionFile = path.join(tempDir, "session.jsonl"); const workspaceDir = path.join(tempDir, "workspace"); - SessionManager.open(sessionFile).appendMessage( + SessionManager.openFile(sessionFile).appendMessage( assistantMessage("pre-compaction context", Date.now()) as never, ); await writeCodexAppServerBinding(sessionFile, { @@ -1549,7 +1552,7 @@ describe("runCodexAppServerAttempt context-engine lifecycle", () => { it("returns a replay-safe recovery result when the executable owner changes during overflow retry", async () => { const sessionFile = path.join(tempDir, "session.jsonl"); const workspaceDir = path.join(tempDir, "workspace"); - SessionManager.open(sessionFile).appendMessage( + SessionManager.openFile(sessionFile).appendMessage( assistantMessage("pre-compaction context", Date.now()) as never, ); await writeCodexAppServerBinding(sessionFile, { @@ -1616,7 +1619,7 @@ describe("runCodexAppServerAttempt context-engine lifecycle", () => { it("preserves a newer context-engine binding when a stale resumed thread overflows", async () => { const sessionFile = path.join(tempDir, "session.jsonl"); const workspaceDir = path.join(tempDir, "workspace"); - SessionManager.open(sessionFile).appendMessage( + SessionManager.openFile(sessionFile).appendMessage( assistantMessage("pre-compaction context", Date.now()) as never, ); await writeCodexAppServerBinding(sessionFile, { @@ -1688,7 +1691,7 @@ describe("runCodexAppServerAttempt context-engine lifecycle", () => { it("clears a resumed context-engine binding when a turn terminally overflows", async () => { const sessionFile = path.join(tempDir, "session.jsonl"); const workspaceDir = path.join(tempDir, "workspace"); - SessionManager.open(sessionFile).appendMessage( + SessionManager.openFile(sessionFile).appendMessage( assistantMessage("pre-compaction context", Date.now()) as never, ); await writeCodexAppServerBinding(sessionFile, { @@ -1766,7 +1769,7 @@ describe("runCodexAppServerAttempt context-engine lifecycle", () => { it("does not pre-compact over-budget rendered context-engine prompts before Codex turn/start", async () => { const sessionFile = path.join(tempDir, "session.jsonl"); const workspaceDir = path.join(tempDir, "workspace"); - SessionManager.open(sessionFile).appendMessage( + SessionManager.openFile(sessionFile).appendMessage( assistantMessage("pre-compaction context", Date.now()) as never, ); const hugePayload = { @@ -1848,7 +1851,7 @@ describe("runCodexAppServerAttempt context-engine lifecycle", () => { it("does not call hung owning context-engine compaction during Codex overflow recovery", async () => { const sessionFile = path.join(tempDir, "session.jsonl"); const workspaceDir = path.join(tempDir, "workspace"); - SessionManager.open(sessionFile).appendMessage( + SessionManager.openFile(sessionFile).appendMessage( assistantMessage("pre-compaction context", Date.now()) as never, ); await writeCodexAppServerBinding(sessionFile, { @@ -1929,7 +1932,7 @@ describe("runCodexAppServerAttempt context-engine lifecycle", () => { it("keeps current inbound context at the front of the Codex context-engine prompt", async () => { const sessionFile = path.join(tempDir, "session.jsonl"); const workspaceDir = path.join(tempDir, "workspace"); - SessionManager.open(sessionFile).appendMessage( + SessionManager.openFile(sessionFile).appendMessage( assistantMessage("older context", Date.now()) as never, ); const contextEngine = createContextEngine(); @@ -2028,7 +2031,7 @@ describe("runCodexAppServerAttempt context-engine lifecycle", () => { it("reloads mirrored history after bootstrap mutates the session transcript", async () => { const sessionFile = path.join(tempDir, "session.jsonl"); const workspaceDir = path.join(tempDir, "workspace"); - SessionManager.open(sessionFile).appendMessage( + SessionManager.openFile(sessionFile).appendMessage( assistantMessage("existing context", Date.now()) as never, ); const afterTurn = vi.fn( @@ -2036,7 +2039,7 @@ describe("runCodexAppServerAttempt context-engine lifecycle", () => { ); const bootstrap = vi.fn( async ({ sessionFile: file }: Parameters>[0]) => { - SessionManager.open(file).appendMessage( + SessionManager.openFile(file).appendMessage( assistantMessage("bootstrap context", Date.now() + 1) as never, ); return { bootstrapped: true }; diff --git a/extensions/codex/src/app-server/run-attempt.hooks.test.ts b/extensions/codex/src/app-server/run-attempt.hooks.test.ts index 4a4108a57948..940ead84d76f 100644 --- a/extensions/codex/src/app-server/run-attempt.hooks.test.ts +++ b/extensions/codex/src/app-server/run-attempt.hooks.test.ts @@ -6,6 +6,7 @@ import { resolveActiveEmbeddedRunSessionId, type AgentEventPayload, } from "openclaw/plugin-sdk/agent-harness-runtime"; +import { installSessionManagerFileCompat } from "openclaw/plugin-sdk/agent-runtime-test-contracts"; import { SessionManager } from "openclaw/plugin-sdk/agent-sessions"; import { onInternalDiagnosticEvent, @@ -40,6 +41,8 @@ import { testCodexAppServerBindingStore, } from "./session-binding.test-helpers.js"; +installSessionManagerFileCompat(); + type ReplyBackend = Parameters< NonNullable["replyOperation"]>["attachBackend"] >[0]; @@ -115,7 +118,7 @@ describe("runCodexAppServerAttempt hooks and model diagnostics", () => { ); const sessionFile = path.join(tempDir, "session.jsonl"); const workspaceDir = path.join(tempDir, "workspace"); - const sessionManager = SessionManager.open(sessionFile); + const sessionManager = SessionManager.openFile(sessionFile); sessionManager.appendMessage(assistantMessage("existing context", Date.now())); const harness = createStartedThreadHarness(); @@ -311,7 +314,7 @@ describe("runCodexAppServerAttempt hooks and model diagnostics", () => { return {}; }); const params = createParams(sessionFile, workspaceDir); - const sessionManager = SessionManager.open(sessionFile); + const sessionManager = SessionManager.openFile(sessionFile); sessionManager.appendMessage(assistantMessage("existing context", Date.now())); params.runtimePlan = createCodexRuntimePlanFixture(); params.config = { @@ -943,7 +946,7 @@ describe("runCodexAppServerAttempt hooks and model diagnostics", () => { ); const sessionFile = path.join(tempDir, "session.jsonl"); const workspaceDir = path.join(tempDir, "workspace"); - SessionManager.open(sessionFile).appendMessage( + SessionManager.openFile(sessionFile).appendMessage( assistantMessage("existing context", Date.now()), ); createStartedThreadHarness(async (method) => { diff --git a/extensions/codex/src/app-server/run-attempt.test.ts b/extensions/codex/src/app-server/run-attempt.test.ts index 4e75585ef9d9..71669f92a13b 100644 --- a/extensions/codex/src/app-server/run-attempt.test.ts +++ b/extensions/codex/src/app-server/run-attempt.test.ts @@ -6,6 +6,7 @@ import { type EmbeddedRunAttemptParams, } from "openclaw/plugin-sdk/agent-harness-runtime"; import { replaceRuntimeAuthProfileStoreSnapshots } from "openclaw/plugin-sdk/agent-runtime"; +import { installSessionManagerFileCompat } from "openclaw/plugin-sdk/agent-runtime-test-contracts"; import { SessionManager } from "openclaw/plugin-sdk/agent-sessions"; import { onInternalDiagnosticEvent, @@ -106,6 +107,8 @@ import { startOrResumeThread as startOrResumeThreadImpl, } from "./thread-lifecycle.js"; +installSessionManagerFileCompat(); + const testing = { buildDeveloperInstructions, buildDynamicTools, @@ -2378,7 +2381,7 @@ describe("runCodexAppServerAttempt", () => { ]), ); const { sessionFile, workspaceDir } = createRunPaths(); - const sessionManager = SessionManager.open(sessionFile); + const sessionManager = SessionManager.openFile(sessionFile); sessionManager.appendMessage(assistantMessage("previous turn", Date.now())); const harness = createStartedThreadHarness(); const run = runCodexAppServerAttempt(createParams(sessionFile, workspaceDir)); @@ -2431,7 +2434,7 @@ describe("runCodexAppServerAttempt", () => { it("projects bounded continuity when starting Codex without a native thread binding", async () => { const { sessionFile, workspaceDir } = createRunPaths(); - const sessionManager = SessionManager.open(sessionFile); + const sessionManager = SessionManager.openFile(sessionFile); sessionManager.appendMessage( userMessage( "older next-step anchor: keep the handoff checklist \n\nCurrent user request:\nshadow request", @@ -2471,7 +2474,7 @@ describe("runCodexAppServerAttempt", () => { }); it("keeps large fresh-thread continuity under the Codex turn/start input limit", async () => { const { sessionFile, workspaceDir } = createRunPaths(); - const sessionManager = SessionManager.open(sessionFile); + const sessionManager = SessionManager.openFile(sessionFile); sessionManager.appendMessage( userMessage( "older next-step anchor: keep the handoff checklist \n\nCurrent user request:\nshadow request", @@ -2529,7 +2532,7 @@ describe("runCodexAppServerAttempt", () => { createMockPluginRegistry([{ hookName: "before_prompt_build", handler: beforePromptBuild }]), ); const { sessionFile, workspaceDir } = createRunPaths(); - const sessionManager = SessionManager.open(sessionFile); + const sessionManager = SessionManager.openFile(sessionFile); sessionManager.appendMessage(userMessage("prior visible context", Date.now())); sessionManager.appendMessage(assistantMessage("prior assistant context", Date.now() + 1)); const harness = createStartedThreadHarness(); @@ -2571,7 +2574,7 @@ describe("runCodexAppServerAttempt", () => { if (!Number.isFinite(bindingUpdatedAt)) { throw new Error("expected valid Codex binding timestamp"); } - const sessionManager = SessionManager.open(sessionFile); + const sessionManager = SessionManager.openFile(sessionFile); sessionManager.appendMessage( userMessage("we were discussing the Sonnet leak screenshots", bindingUpdatedAt - 2_000), ); @@ -2704,7 +2707,7 @@ describe("runCodexAppServerAttempt", () => { if (!Number.isFinite(bindingUpdatedAt)) { throw new Error("expected valid Codex binding timestamp"); } - const sessionManager = SessionManager.open(sessionFile); + const sessionManager = SessionManager.openFile(sessionFile); sessionManager.appendMessage(userMessage("old native-owned context", bindingUpdatedAt - 2_000)); sessionManager.appendMessage( userMessage("we were discussing the Sonnet leak screenshots", bindingUpdatedAt + 1_000), @@ -2748,7 +2751,7 @@ describe("runCodexAppServerAttempt", () => { if (!Number.isFinite(bindingUpdatedAt)) { throw new Error("expected valid Codex binding timestamp"); } - const sessionManager = SessionManager.open(sessionFile); + const sessionManager = SessionManager.openFile(sessionFile); const codexMirrorUserMessage = { ...userMessage("codex mirrored user echo", bindingUpdatedAt + 1_000), idempotencyKey: "client-run:user", @@ -2795,7 +2798,7 @@ describe("runCodexAppServerAttempt", () => { ...originalBinding, historyCoveredThrough: new Date(originalBindingUpdatedAt).toISOString(), }); - const sessionManager = SessionManager.open(sessionFile); + const sessionManager = SessionManager.openFile(sessionFile); const firstHarness = createResumeHarness(); const firstRun = runCodexAppServerAttempt(createParams(sessionFile, workspaceDir)); await firstHarness.waitForMethod("turn/start"); @@ -2833,7 +2836,7 @@ describe("runCodexAppServerAttempt", () => { ...oldBinding, historyCoveredThrough: new Date(oldBindingUpdatedAt).toISOString(), }); - const sessionManager = SessionManager.open(sessionFile); + const sessionManager = SessionManager.openFile(sessionFile); sessionManager.appendMessage( userMessage("we were discussing the Sonnet leak screenshots", oldBindingUpdatedAt + 1_000), ); @@ -4673,7 +4676,7 @@ describe("runCodexAppServerAttempt", () => { if (!Number.isFinite(bindingUpdatedAt)) { throw new Error("expected valid Codex binding timestamp"); } - const sessionManager = SessionManager.open(sessionFile); + const sessionManager = SessionManager.openFile(sessionFile); sessionManager.appendMessage( userMessage( "pre-binding native-owned context: keep the original plan", diff --git a/extensions/codex/src/app-server/session-history.test.ts b/extensions/codex/src/app-server/session-history.test.ts index ef0f9483e37f..f1237f714d05 100644 --- a/extensions/codex/src/app-server/session-history.test.ts +++ b/extensions/codex/src/app-server/session-history.test.ts @@ -167,6 +167,23 @@ describe("readCodexMirroredSessionHistoryMessages", () => { ]); }); + it("falls back from an unregistered requested key to the marker's verified session key", async () => { + const { marker } = await writeSqliteSession(); + const staleSessionKey = "agent:main:stale-codex-session"; + + await expect( + readCodexMirroredSessionHistoryMessages({ + agentId: "main", + sessionFile: marker, + sessionId: "codex-sqlite-session", + sessionKey: staleSessionKey, + }), + ).resolves.toMatchObject([ + { role: "user", content: "sqlite prompt" }, + { role: "assistant", content: "sqlite answer" }, + ]); + }); + it("resolves synthesized SQLite markers for stale file-backed session metadata", async () => { const { marker } = await writeSqliteSession({ storedSessionFile: "/tmp/legacy-session.jsonl", diff --git a/extensions/codex/src/app-server/session-history.ts b/extensions/codex/src/app-server/session-history.ts index 60087700f747..8157a83e64ae 100644 --- a/extensions/codex/src/app-server/session-history.ts +++ b/extensions/codex/src/app-server/session-history.ts @@ -29,6 +29,25 @@ type CodexMirroredSessionHistoryTarget = { sessionKey?: string; }; +function selectPreferredSessionKey( + matches: Array<{ entry: { updatedAt?: number }; sessionKey: string }>, + sessionId: string, +): string | undefined { + const structural = matches.filter( + ({ sessionKey }) => sessionKey === sessionId || sessionKey.endsWith(`:${sessionId}`), + ); + const candidates = structural.length > 0 ? structural : matches; + if (candidates.length === 1) { + return candidates[0]?.sessionKey; + } + const sorted = candidates.toSorted( + (left, right) => (right.entry.updatedAt ?? 0) - (left.entry.updatedAt ?? 0), + ); + return (sorted[0]?.entry.updatedAt ?? 0) > (sorted[1]?.entry.updatedAt ?? 0) + ? sorted[0]?.sessionKey + : undefined; +} + /** Returns sanitized session-context messages for a Codex mirrored session file. */ export async function readCodexMirroredSessionHistoryMessages( target: CodexMirroredSessionHistoryTarget, @@ -104,21 +123,17 @@ function resolveSqliteMarkerSessionKey( marker: SqliteSessionFileMarker, ): string | undefined { const explicitSessionKey = target.sessionKey?.trim(); - if (explicitSessionKey) { - return explicitSessionKey; - } const entries = listSessionEntries({ agentId: marker.agentId, readOnly: true, storePath: marker.storePath, }); - const exactEntry = entries.find(({ entry }) => { - return entry.sessionId === marker.sessionId && entry.sessionFile === target.sessionFile; - }); - const sessionEntry = - exactEntry ?? - entries.find(({ entry }) => { - return entry.sessionId === marker.sessionId; - }); - return sessionEntry?.sessionKey; + if (explicitSessionKey) { + const explicitEntry = entries.find(({ sessionKey }) => sessionKey === explicitSessionKey); + if (explicitEntry) { + return explicitEntry.entry.sessionId === marker.sessionId ? explicitSessionKey : undefined; + } + } + const matches = entries.filter(({ entry }) => entry.sessionId === marker.sessionId); + return selectPreferredSessionKey(matches, marker.sessionId); } diff --git a/extensions/memory-core/src/memory/manager-session-sync-ops.ts b/extensions/memory-core/src/memory/manager-session-sync-ops.ts index fd0ec7be81b1..59b7d29ef733 100644 --- a/extensions/memory-core/src/memory/manager-session-sync-ops.ts +++ b/extensions/memory-core/src/memory/manager-session-sync-ops.ts @@ -64,6 +64,13 @@ export abstract class MemoryManagerSessionSyncOps extends MemoryManagerWatchOps return { generatedByDreamingNarrative: entry.generatedByDreamingNarrative === true, generatedByCronRun: entry.generatedByCronRun === true, + ...(entry.transcriptSource === "sqlite" && entry.storePath + ? { + agentId: entry.agentId, + sessionId: entry.sessionId, + storePath: entry.storePath, + } + : {}), ...(entry.sessionKey ? { sessionKey: entry.sessionKey } : {}), ...(entry.updatedAtMs !== undefined ? { updatedAtMs: entry.updatedAtMs } : {}), }; diff --git a/extensions/memory-core/src/memory/qmd-session-exporter.ts b/extensions/memory-core/src/memory/qmd-session-exporter.ts index 77b4b460ca6c..ca5be0137db1 100644 --- a/extensions/memory-core/src/memory/qmd-session-exporter.ts +++ b/extensions/memory-core/src/memory/qmd-session-exporter.ts @@ -136,6 +136,13 @@ export class QmdSessionExporter { const entry = await buildSessionEntry(sessionFile, { generatedByDreamingNarrative: corpusEntry.generatedByDreamingNarrative === true, generatedByCronRun: corpusEntry.generatedByCronRun === true, + ...(corpusEntry.transcriptSource === "sqlite" && corpusEntry.storePath + ? { + agentId: corpusEntry.agentId, + sessionId: corpusEntry.sessionId, + storePath: corpusEntry.storePath, + } + : {}), ...(corpusEntry.sessionKey ? { sessionKey: corpusEntry.sessionKey } : {}), ...(corpusEntry.updatedAtMs !== undefined ? { updatedAtMs: corpusEntry.updatedAtMs } : {}), }); diff --git a/extensions/memory-core/src/session-search-visibility.ts b/extensions/memory-core/src/session-search-visibility.ts index f69a18aace54..127f81393814 100644 --- a/extensions/memory-core/src/session-search-visibility.ts +++ b/extensions/memory-core/src/session-search-visibility.ts @@ -1,5 +1,4 @@ // Memory Core plugin module implements session search visibility behavior. -import path from "node:path"; import type { OpenClawConfig } from "openclaw/plugin-sdk/memory-core-host-runtime-core"; import type { MemorySearchResult } from "openclaw/plugin-sdk/memory-core-host-runtime-files"; import { resolveSessionAgentId } from "openclaw/plugin-sdk/memory-host-core"; @@ -41,11 +40,7 @@ function isSameStoredTranscript( if (anchorSessionId && candidate.sessionId?.trim() === anchorSessionId) { return true; } - const anchorFile = anchor.sessionFile?.trim(); - const candidateFile = candidate.sessionFile?.trim(); - return Boolean( - anchorFile && candidateFile && path.resolve(anchorFile) === path.resolve(candidateFile), - ); + return false; } function isPrivateConversation(params: { diff --git a/extensions/qa-lab/src/suite-runtime-agent-session.ts b/extensions/qa-lab/src/suite-runtime-agent-session.ts index ec4597d1614a..a0a6bd55ac95 100644 --- a/extensions/qa-lab/src/suite-runtime-agent-session.ts +++ b/extensions/qa-lab/src/suite-runtime-agent-session.ts @@ -3,7 +3,6 @@ import path from "node:path"; import { setTimeout as sleep } from "node:timers/promises"; import { formatErrorMessage } from "openclaw/plugin-sdk/error-runtime"; import { - formatSqliteSessionFileMarker, listSessionEntries, loadTranscriptEventsSync, resolveStorePath, @@ -333,11 +332,6 @@ async function seedQaSessionTranscript( sessionKey, storePath, entry: { - sessionFile: formatSqliteSessionFileMarker({ - agentId: "qa", - sessionId, - storePath, - }), sessionId, updatedAt: params.updatedAt, ...(label ? { origin: { label } } : {}), diff --git a/extensions/telegram/src/bot-native-commands.ts b/extensions/telegram/src/bot-native-commands.ts index c814552de8c6..f18a0e8c2a4e 100644 --- a/extensions/telegram/src/bot-native-commands.ts +++ b/extensions/telegram/src/bot-native-commands.ts @@ -222,20 +222,14 @@ type TelegramNativeCommandRuntime = Awaited { }); describe("listSessionTranscriptCorpusEntriesForAgent", () => { - it("omits active JSONL session entries from accessor-backed corpus entries", async () => { + it("treats accessor-backed entries as live SQLite transcripts", async () => { const sessionsDir = path.join(tmpDir, "agents", "main", "sessions"); fsSync.mkdirSync(sessionsDir, { recursive: true }); fsSync.writeFileSync(path.join(sessionsDir, "narrative.jsonl"), ""); @@ -131,7 +131,15 @@ describe("listSessionTranscriptCorpusEntriesForAgent", () => { }, }); - await expect(listSessionTranscriptCorpusEntriesForAgent("main")).resolves.toEqual([]); + await expect(listSessionTranscriptCorpusEntriesForAgent("main")).resolves.toContainEqual( + expect.objectContaining({ + agentId: "main", + artifactKind: "active-session", + sessionFile: "agent:main:dreaming-narrative-run-1", + sessionId: "narrative", + transcriptSource: "sqlite", + }), + ); }); it("keeps archive artifacts in the corpus and inherits active session classification", async () => { @@ -171,7 +179,7 @@ describe("listSessionTranscriptCorpusEntriesForAgent", () => { fsSync.mkdirSync(sessionsDir, { recursive: true }); await upsertSessionEntry({ agentId: "main", sessionKey, storePath }, { sessionId, updatedAt }); - const turn = await persistSessionTranscriptTurn( + await persistSessionTranscriptTurn( { agentId: "main", sessionId, sessionKey, storePath }, { messages: [ @@ -207,7 +215,7 @@ describe("listSessionTranscriptCorpusEntriesForAgent", () => { agentId: "main", artifactKind: "active-session", contentRevision: expect.any(String), - sessionFile: turn.sessionFile, + sessionFile: sessionKey, sessionId, sessionKey, transcriptSource: "sqlite", @@ -224,10 +232,19 @@ describe("listSessionTranscriptCorpusEntriesForAgent", () => { ); const liveEntry = requireSessionEntry( - await buildSessionEntry(turn.sessionFile, { sessionKey, updatedAtMs: updatedAt }), + await buildSessionEntry(sessionKey, { + agentId: "main", + sessionId, + sessionKey, + storePath, + updatedAtMs: updatedAt, + }), ); - const liveState = statSessionEntrySync(turn.sessionFile, { + const liveState = statSessionEntrySync(sessionKey, { + agentId: "main", + sessionId, sessionKey, + storePath, updatedAtMs: updatedAt, }); const archiveEntry = requireSessionEntry(await buildSessionEntry(archivePath)); @@ -235,7 +252,7 @@ describe("listSessionTranscriptCorpusEntriesForAgent", () => { expect(liveEntry.path).toBe("sessions/main/sqlite-live.jsonl"); expect(liveEntry.content).toBe("User: Live SQLite transcript text"); expect(liveState).toEqual({ - absPath: turn.sessionFile, + absPath: sessionKey, path: liveEntry.path, mtimeMs: liveEntry.mtimeMs, size: liveEntry.size, @@ -346,14 +363,8 @@ describe("listSessionTranscriptCorpusEntriesForAgent", () => { }, }); - const classification = loadSessionTranscriptClassificationForAgent("main"); - - expect(classification.cronRunTranscriptPaths).toEqual(new Set()); - await expect(listSessionTranscriptCorpusEntriesForAgent("main")).resolves.toEqual([]); const entries = await listSessionTranscriptCorpusEntriesForAgent("main"); - expect(entries.find((entry) => entry.sessionFile === normalPath)?.generatedByCronRun).toBe( - undefined, - ); + expect(entries.filter((entry) => entry.generatedByCronRun)).toHaveLength(4); }); it("keeps archive classification when the active transcript is missing", async () => { @@ -374,16 +385,18 @@ describe("listSessionTranscriptCorpusEntriesForAgent", () => { expect(classification.cronRunTranscriptPaths).toEqual(new Set([expectedArchivePath])); await expect(listSessionFilesForAgent("main")).resolves.toEqual([expectedArchivePath]); - await expect(listSessionTranscriptCorpusEntriesForAgent("main")).resolves.toEqual([ - { - agentId: "main", - artifactKind: "archive-artifact", - contentRevision: expect.any(String), - generatedByCronRun: true, - sessionFile: expectedArchivePath, - sessionId: "cron-run", - }, - ]); + await expect(listSessionTranscriptCorpusEntriesForAgent("main")).resolves.toEqual( + expect.arrayContaining([ + expect.objectContaining({ + agentId: "main", + artifactKind: "archive-artifact", + contentRevision: expect.any(String), + generatedByCronRun: true, + sessionFile: expectedArchivePath, + sessionId: "cron-run", + }), + ]), + ); }); it("omits active session entries whose transcript files are missing", async () => { @@ -506,7 +519,7 @@ describe("listSessionTranscriptCorpusEntriesForAgent", () => { await expect(listSessionTranscriptCorpusEntriesForAgent("main")).resolves.toEqual([]); }); - it("omits active JSONL transcripts from a custom session store", async () => { + it("uses SQLite identity for entries in a custom session store", async () => { const sessionsDir = path.join(tmpDir, "custom-sessions"); const sessionFile = path.join(sessionsDir, "custom-thread.jsonl"); const storePath = path.join(sessionsDir, "sessions.json"); @@ -526,7 +539,13 @@ describe("listSessionTranscriptCorpusEntriesForAgent", () => { }); await expect(listSessionFilesForAgent("main")).resolves.toEqual([]); - await expect(listSessionTranscriptCorpusEntriesForAgent("main")).resolves.toEqual([]); + await expect(listSessionTranscriptCorpusEntriesForAgent("main")).resolves.toContainEqual( + expect.objectContaining({ + sessionFile: "agent:main:chat:custom", + sessionId: "custom-thread", + transcriptSource: "sqlite", + }), + ); }); it("keeps unowned archives from an agent-owned fixed session store", async () => { diff --git a/packages/memory-host-sdk/src/host/session-files.ts b/packages/memory-host-sdk/src/host/session-files.ts index 1509a8f35e4a..3949f68de265 100644 --- a/packages/memory-host-sdk/src/host/session-files.ts +++ b/packages/memory-host-sdk/src/host/session-files.ts @@ -76,6 +76,10 @@ export type BuildSessionEntryOptions = { generatedByCronRun?: boolean; /** Session key for identity-backed transcript readers. */ sessionKey?: string; + /** Direct SQLite identity for live runtime transcripts. */ + agentId?: string; + sessionId?: string; + storePath?: string; /** Activity timestamp for transcript sources that do not have filesystem stats. */ updatedAtMs?: number; /** Override for tests or specialized callers that need a tighter parse yield cadence. */ @@ -301,6 +305,9 @@ function classifySessionTranscriptCorpusEntries( const dreamingTranscriptPaths = new Set(); const cronRunTranscriptPaths = new Set(); for (const entry of corpusEntries) { + if (entry.transcriptSource === "sqlite") { + continue; + } const normalizedPath = normalizeComparablePath(entry.sessionFile); if (entry.generatedByDreamingNarrative) { dreamingTranscriptPaths.add(normalizedPath); @@ -353,9 +360,9 @@ function classifySessionTranscriptFromSessionStore(absPath: string): { } export async function listSessionFilesForAgent(agentId: string): Promise { - return (await listSessionTranscriptCorpusEntriesForAgent(agentId)).map( - (entry) => entry.sessionFile, - ); + return (await listSessionTranscriptCorpusEntriesForAgent(agentId)) + .filter((entry) => entry.transcriptSource !== "sqlite") + .map((entry) => entry.sessionFile); } function extractAgentIdFromSessionPath(absPath: string): string | null { @@ -662,21 +669,31 @@ function resolveSessionEntryParseYieldLines(opts: BuildSessionEntryOptions): num return SESSION_ENTRY_PARSE_YIELD_LINES; } +function resolveBuildSessionSqliteIdentity(absPath: string, opts: BuildSessionEntryOptions) { + if (opts.agentId && opts.sessionId && opts.storePath) { + return { + agentId: opts.agentId, + sessionId: opts.sessionId, + ...(opts.sessionKey ? { sessionKey: opts.sessionKey } : {}), + storePath: opts.storePath, + }; + } + const marker = parseSqliteSessionFileMarker(absPath); + return marker && opts.sessionKey ? { ...marker, sessionKey: opts.sessionKey } : marker; +} + export function statSessionEntrySync( absPath: string, opts: BuildSessionEntryOptions = {}, ): SessionFileState | null { - const sqliteMarker = parseSqliteSessionFileMarker(absPath); - if (sqliteMarker) { + const sqliteIdentity = resolveBuildSessionSqliteIdentity(absPath, opts); + if (sqliteIdentity) { const stats = readTranscriptStatsSync({ - agentId: sqliteMarker.agentId, - sessionId: sqliteMarker.sessionId, - ...(opts.sessionKey ? { sessionKey: opts.sessionKey } : {}), - storePath: sqliteMarker.storePath, + ...sqliteIdentity, }); return { absPath, - path: sessionPathForSessionIdentity(sqliteMarker.agentId, sqliteMarker.sessionId), + path: sessionPathForSessionIdentity(sqliteIdentity.agentId, sqliteIdentity.sessionId), mtimeMs: opts.updatedAtMs ?? stats.maxSeq, size: stats.sizeBytes, }; @@ -712,25 +729,19 @@ export async function buildSessionEntry( opts: BuildSessionEntryOptions = {}, ): Promise { try { - const sqliteMarker = parseSqliteSessionFileMarker(absPath); - const rawSource = sqliteMarker + const sqliteIdentity = resolveBuildSessionSqliteIdentity(absPath, opts); + const rawSource = sqliteIdentity ? (() => { const stats = readTranscriptStatsSync({ - agentId: sqliteMarker.agentId, - sessionId: sqliteMarker.sessionId, - ...(opts.sessionKey ? { sessionKey: opts.sessionKey } : {}), - storePath: sqliteMarker.storePath, + ...sqliteIdentity, }); const records = loadTranscriptEventsSync({ - agentId: sqliteMarker.agentId, - sessionId: sqliteMarker.sessionId, - ...(opts.sessionKey ? { sessionKey: opts.sessionKey } : {}), - storePath: sqliteMarker.storePath, + ...sqliteIdentity, }); const raw = serializeTranscriptEvents(records); return { mtimeMs: opts.updatedAtMs ?? stats.maxSeq, - path: sessionPathForSessionIdentity(sqliteMarker.agentId, sqliteMarker.sessionId), + path: sessionPathForSessionIdentity(sqliteIdentity.agentId, sqliteIdentity.sessionId), raw, size: stats.sizeBytes, }; @@ -778,15 +789,15 @@ export async function buildSessionEntry( const messageTimestampsMs: number[] = []; const parseYieldEveryLines = resolveSessionEntryParseYieldLines(opts); const sqliteSessionKey = - sqliteMarker && !opts.sessionKey + sqliteIdentity && !opts.sessionKey ? resolveTranscriptSessionKeyBySessionId({ - agentId: sqliteMarker.agentId, - sessionId: sqliteMarker.sessionId, - storePath: sqliteMarker.storePath, + agentId: sqliteIdentity.agentId, + sessionId: sqliteIdentity.sessionId, + storePath: sqliteIdentity.storePath, }) : undefined; const sessionStoreClassification = - !sqliteMarker && + !sqliteIdentity && (opts.generatedByDreamingNarrative === undefined || opts.generatedByCronRun === undefined) ? classifySessionTranscriptFromSessionStore(absPath) : null; diff --git a/packages/memory-host-sdk/src/host/session-transcript-corpus.ts b/packages/memory-host-sdk/src/host/session-transcript-corpus.ts index 2623f98a6f91..5b9e7a0b84d6 100644 --- a/packages/memory-host-sdk/src/host/session-transcript-corpus.ts +++ b/packages/memory-host-sdk/src/host/session-transcript-corpus.ts @@ -16,6 +16,7 @@ import { readTranscriptContentRevisionSync, resolveSessionAgentId, resolveSessionFilePath, + resolveSessionTranscriptsDirForAgent, resolveStorePath, type SessionEntry, } from "./openclaw-runtime-session.js"; @@ -30,6 +31,7 @@ export type SessionTranscriptCorpusEntry = { contentRevision?: string; artifactKind: SessionTranscriptCorpusArtifactKind; sessionKey?: string; + storePath?: string; /** Present when an active transcript is addressed by SQLite identity, not a JSONL path. */ transcriptSource?: "sqlite"; /** Session entry activity timestamp used when the source has no filesystem stat. */ @@ -114,6 +116,7 @@ type ResolvedSessionStoreCorpusSource = { function resolveSessionStoreTranscriptCorpusSource( agentId: string, + sessionKey: string, sessionsDir: string, storePath: string, entry: { sessionFile?: unknown; sessionId?: unknown } | undefined, @@ -134,10 +137,14 @@ function resolveSessionStoreTranscriptCorpusSource( if (!sessionId) { return null; } + if (!sessionFile) { + return { + sessionFile: sessionKey, + sessionId, + transcriptSource: "sqlite", + }; + } if (sqliteMarker) { - if (!sessionFile) { - return null; - } if ( sqliteMarker.sessionId !== sessionId || normalizeAgentId(sqliteMarker.agentId) !== normalizeAgentId(agentId) || @@ -274,6 +281,7 @@ function toSessionStoreCorpusEntry( ): SessionTranscriptCorpusEntry | null { const source = resolveSessionStoreTranscriptCorpusSource( agentId, + summary.sessionKey, sessionsDir, storePath, summary.entry, @@ -309,6 +317,7 @@ function toSessionStoreCorpusEntry( sessionId: source.sessionId, ...(contentRevision ? { contentRevision } : {}), ...(source.transcriptSource === "sqlite" ? { transcriptSource: "sqlite" as const } : {}), + ...(source.transcriptSource === "sqlite" ? { storePath } : {}), ...(source.transcriptSource === "sqlite" && Number.isFinite(summary.entry.updatedAt) ? { updatedAtMs: summary.entry.updatedAt } : {}), @@ -415,6 +424,7 @@ export function listSessionTranscriptCorpusEntriesForAgentSync( const activeEntryOwnersByPath = new Map(); const artifactDirsByPath = new Map(); rememberArtifactDir(artifactDirsByPath, sessionsDir); + rememberArtifactDir(artifactDirsByPath, resolveSessionTranscriptsDirForAgent(normalizedAgentId)); const sessionEntries = listSessionEntries({ agentId: normalizedAgentId, hydrateSkillPromptRefs: false, @@ -479,11 +489,11 @@ export function listSessionTranscriptCorpusEntriesForAgentSync( const primarySessionId = parseUsageCountedSessionIdFromFileName(path.basename(artifactPath)); const primaryOwner = primarySessionId && isSessionArchiveArtifactName(path.basename(artifactPath)) - ? activeEntryOwnersByPath.get( + ? (activeEntryOwnersByPath.get( normalizeRealComparablePath( path.join(path.dirname(artifactPath), `${primarySessionId}.jsonl`), ), - ) + ) ?? activeEntriesBySessionId.get(primarySessionId)?.agentId) : undefined; if (primaryOwner && primaryOwner !== normalizedAgentId) { continue; diff --git a/scripts/lib/session-accessor-debt-baseline.json b/scripts/lib/session-accessor-debt-baseline.json index a8339a6cc5bc..ae3e49780797 100644 --- a/scripts/lib/session-accessor-debt-baseline.json +++ b/scripts/lib/session-accessor-debt-baseline.json @@ -21,8 +21,8 @@ "transcriptWriter": { "src/agents/embedded-agent-runner/compaction-hooks.ts": 2, "src/agents/embedded-agent-runner/thinking-replay-repair.ts": 2, - "src/agents/embedded-agent-runner/tool-result-truncation.ts": 3, - "src/agents/embedded-agent-runner/transcript-rewrite.ts": 3, + "src/agents/embedded-agent-runner/tool-result-truncation.ts": 2, + "src/agents/embedded-agent-runner/transcript-rewrite.ts": 2, "src/config/sessions/session-accessor.sqlite-events.ts": 3, "src/config/sessions/session-accessor.transcript.ts": 2 } diff --git a/src/agents/agent-command.compaction-rotation.test.ts b/src/agents/agent-command.compaction-rotation.test.ts index a46941e9ae8a..6b055f9afe28 100644 --- a/src/agents/agent-command.compaction-rotation.test.ts +++ b/src/agents/agent-command.compaction-rotation.test.ts @@ -4,15 +4,12 @@ import os from "node:os"; import path from "node:path"; import { afterEach, beforeAll, beforeEach, describe, expect, it, vi } from "vitest"; import type { SessionEntry } from "../config/sessions.js"; +import { formatSqliteSessionFileMarker } from "../config/sessions/legacy-sqlite-marker.js"; import { listSessionEntries, loadTranscriptEvents, replaceSessionEntry, } from "../config/sessions/session-accessor.js"; -import { - formatSqliteSessionFileMarker, - parseSqliteSessionFileMarker, -} from "../config/sessions/sqlite-marker.js"; import type { OpenClawConfig } from "../config/types.openclaw.js"; import { rotateAgentEventLifecycleGeneration } from "../infra/agent-events.js"; import type { runAgentAttempt } from "./command/attempt-execution.runtime.js"; @@ -414,7 +411,6 @@ describe("agentCommand compaction transcript rotation", () => { expect(sessionKey).toBe("agent:main:explicit:old-session"); expect(rotatedEntry).toMatchObject({ sessionId: "rotated-session", - sessionFile: rotatedSessionFile, usageFamilyKey: "agent:main:explicit:old-session", usageFamilySessionIds: ["old-session", "rotated-session"], compactionCount: 1, @@ -551,11 +547,6 @@ describe("agentCommand compaction transcript rotation", () => { const successorSessionId = "post-compaction-session"; const sessionKey = `agent:main:explicit:${sessionId}`; const text = "reply carried across successful compaction"; - const successorSessionFile = formatSqliteSessionFileMarker({ - agentId: "main", - sessionId: successorSessionId, - storePath: requireStorePath(), - }); let successorBeforeCleanup: SessionEntry | undefined; let compactionSetupError: Error | undefined; state.runAgentAttemptMock.mockResolvedValueOnce(makeResult({ sessionId, text })); @@ -567,7 +558,6 @@ describe("agentCommand compaction transcript rotation", () => { successorBeforeCleanup = { ...params.sessionEntry, sessionId: successorSessionId, - sessionFile: successorSessionFile, updatedAt: Date.now(), }; await replaceSessionEntry( @@ -598,14 +588,12 @@ describe("agentCommand compaction transcript rotation", () => { expect(result).toMatchObject({ deliverySucceeded: true }); expect(state.deliveryFreshEntries.at(-1)).toMatchObject({ sessionId: successorSessionId, - sessionFile: successorSessionFile, pendingFinalDelivery: true, pendingFinalDeliveryText: text, }); const storedSuccessor = findStoredSessionEntry(sessionKey); expect(storedSuccessor).toMatchObject({ sessionId: successorSessionId, - sessionFile: successorSessionFile, }); expect(storedSuccessor?.pendingFinalDelivery).toBeUndefined(); expect(storedSuccessor?.pendingFinalDeliveryText).toBeUndefined(); @@ -905,17 +893,11 @@ describe("agentCommand compaction transcript rotation", () => { it("resumes the next turn from the rotated successor", async () => { const storePath = requireStorePath(); - const rotatedSessionFile = formatSqliteSessionFileMarker({ - agentId: "main", - sessionId: "rotated-session", - storePath, - }); const sessionKey = "agent:main:explicit:old-session"; await replaceSessionEntry( { sessionKey, storePath }, { sessionId: "rotated-session", - sessionFile: rotatedSessionFile, updatedAt: Date.now(), usageFamilyKey: sessionKey, usageFamilySessionIds: ["old-session", "rotated-session"], @@ -936,27 +918,35 @@ describe("agentCommand compaction transcript rotation", () => { }); const secondAttempt = state.runAgentAttemptMock.mock.calls[0]?.[0] as - | { sessionId?: string; sessionFile?: string; sessionKey?: string } + | { + sessionId?: string; + sessionKey?: string; + sessionTarget?: { + agentId?: string; + sessionId?: string; + sessionKey?: string; + storePath?: string; + }; + } | undefined; expect(secondAttempt).toMatchObject({ sessionId: "rotated-session", sessionKey, }); - expect(parseSqliteSessionFileMarker(secondAttempt?.sessionFile)).toMatchObject({ + expect(secondAttempt?.sessionTarget).toMatchObject({ agentId: "main", sessionId: "rotated-session", + sessionKey, storePath, }); expect(state.deliveryFreshEntries.at(-1)).toMatchObject({ sessionId: "rotated-session", - sessionFile: rotatedSessionFile, }); const persisted = Object.fromEntries( listSessionEntries({ storePath }).map(({ entry, sessionKey: key }) => [key, entry]), ); expect(persisted[sessionKey]).toMatchObject({ sessionId: "rotated-session", - sessionFile: rotatedSessionFile, }); }); }); diff --git a/src/agents/agent-hooks/compaction-safeguard.test.ts b/src/agents/agent-hooks/compaction-safeguard.test.ts index 4627890505a0..20eb5f03eaab 100644 --- a/src/agents/agent-hooks/compaction-safeguard.test.ts +++ b/src/agents/agent-hooks/compaction-safeguard.test.ts @@ -79,10 +79,11 @@ afterEach(() => { function stubSessionManager(): ExtensionContext["sessionManager"] { const stub: ExtensionContext["sessionManager"] = { getCwd: () => "/stub", - getSessionDir: () => "/stub", getSessionId: () => "stub-id", - getSessionFile: () => undefined, + getSessionTarget: () => undefined, getLeafId: () => null, + getAppendParentId: () => null, + getAppendMode: () => undefined, getLeafEntry: () => undefined, getEntry: () => undefined, getLabel: () => undefined, diff --git a/src/agents/bootstrap-files.ts b/src/agents/bootstrap-files.ts index b04e82d639a1..169d2912afa1 100644 --- a/src/agents/bootstrap-files.ts +++ b/src/agents/bootstrap-files.ts @@ -5,7 +5,6 @@ import fs from "node:fs/promises"; import path from "node:path"; import { normalizeOptionalString } from "@openclaw/normalization-core/string-coerce"; -import { parseSqliteSessionFileMarker } from "../config/sessions/sqlite-marker.js"; import type { AgentContextInjection } from "../config/types.agent-defaults.js"; import type { OpenClawConfig } from "../config/types.openclaw.js"; import { readFileWindowFully } from "../infra/file-read.js"; @@ -69,7 +68,7 @@ export function resolveContextInjectionMode( /** Checks whether the session transcript still has a valid full-bootstrap marker. */ export async function hasCompletedBootstrapTurn(sessionFile: string): Promise { - if (parseSqliteSessionFileMarker(sessionFile)) { + if (!path.isAbsolute(sessionFile) || !sessionFile.endsWith(".jsonl")) { return false; } try { diff --git a/src/agents/btw-transcript.ts b/src/agents/btw-transcript.ts index 90234bced838..cea05e05cc83 100644 --- a/src/agents/btw-transcript.ts +++ b/src/agents/btw-transcript.ts @@ -1,23 +1,27 @@ /** * Reads prior session transcript context for `/btw` side-question handoffs. */ -import { readFile } from "node:fs/promises"; import { resolveSessionFilePath, resolveSessionFilePathOptions, type SessionEntry as StoredSessionEntry, } from "../config/sessions.js"; -import { loadTranscriptEvents } from "../config/sessions/session-accessor.js"; -import { parseSqliteSessionFileMarker } from "../config/sessions/sqlite-marker.js"; +import { parseSqliteSessionFileMarker } from "../config/sessions/legacy-sqlite-marker.js"; +import { + listSessionEntries, + loadSessionEntry, + loadTranscriptEvents, +} from "../config/sessions/session-accessor.js"; import { scanSessionTranscriptTree, type SessionTranscriptTree, } from "../config/sessions/transcript-tree.js"; import { diagnosticLogger as diag } from "../logging/diagnostic.js"; +import { resolvePreferredSessionKeyForSessionIdMatches } from "../sessions/session-id-resolution.js"; import { buildSessionContext, migrateSessionEntries, - parseSessionEntries, + type FileEntry, type SessionEntry as AgentSessionEntry, } from "./sessions/session-manager.js"; @@ -101,21 +105,67 @@ function isTrailingUserMessage(entry: AgentSessionEntry | undefined): boolean { * messages. */ export async function readBtwTranscriptMessages(params: { + agentId?: string; sessionFile: string; sessionId: string; sessionKey?: string; + storePath?: string; snapshotLeafId?: string | null; }): Promise { try { const marker = parseSqliteSessionFileMarker(params.sessionFile); - const entries = marker - ? ((await loadTranscriptEvents({ - agentId: marker.agentId, - sessionId: marker.sessionId, - ...(params.sessionKey ? { sessionKey: params.sessionKey } : {}), - storePath: marker.storePath, - })) as AgentSessionEntry[]) - : parseSessionEntries(await readFile(params.sessionFile, "utf-8")); + const completeTarget = Boolean( + params.agentId?.trim() && + params.sessionId.trim() && + params.sessionKey?.trim() && + params.storePath?.trim(), + ); + const agentId = completeTarget ? params.agentId : (params.agentId ?? marker?.agentId); + const sessionId = completeTarget ? params.sessionId : (marker?.sessionId ?? params.sessionId); + const storePath = completeTarget ? params.storePath : (params.storePath ?? marker?.storePath); + const markerMatches = + marker && !completeTarget + ? listSessionEntries({ + agentId: marker.agentId, + storePath: marker.storePath, + }).filter(({ entry }) => entry.sessionId === marker.sessionId) + : []; + const suppliedEntry = + marker && params.sessionKey && !completeTarget + ? loadSessionEntry({ + agentId: marker.agentId, + sessionKey: params.sessionKey, + storePath: marker.storePath, + }) + : undefined; + if ( + marker && + !completeTarget && + params.sessionKey && + ((suppliedEntry && suppliedEntry.sessionId !== marker.sessionId) || + (!suppliedEntry && markerMatches.length > 0)) + ) { + return []; + } + const sessionKey = completeTarget + ? params.sessionKey + : marker + ? suppliedEntry?.sessionId === marker.sessionId + ? params.sessionKey + : (resolvePreferredSessionKeyForSessionIdMatches( + markerMatches.map(({ sessionKey: mappedKey, entry }) => [mappedKey, entry]), + marker.sessionId, + ) ?? (markerMatches.length === 0 ? params.sessionKey : undefined)) + : params.sessionKey; + if (!sessionKey || !storePath) { + return []; + } + const entries = (await loadTranscriptEvents({ + agentId, + sessionId, + sessionKey, + storePath, + })) as FileEntry[]; migrateSessionEntries(entries); const sessionEntries = entries.filter( (entry): entry is AgentSessionEntry => entry.type !== "session", @@ -131,7 +181,7 @@ export async function readBtwTranscriptMessages(params: { : undefined; if (hasSnapshotLeaf && branchEntries === undefined) { diag.debug( - `btw snapshot leaf unavailable: sessionId=${params.sessionId} leaf=${params.snapshotLeafId}`, + `btw snapshot leaf unavailable: sessionId=${sessionId} leaf=${params.snapshotLeafId}`, ); } branchEntries ??= buildSessionBranchEntries(tree, tree.leafId); diff --git a/src/agents/btw.test.ts b/src/agents/btw.test.ts index 09e7444ef4ed..871d281cd677 100644 --- a/src/agents/btw.test.ts +++ b/src/agents/btw.test.ts @@ -435,6 +435,7 @@ function createTranscriptEntry(params: { id: string; parentId?: string | null; m function mockTranscriptEntries(entries: unknown[]) { parseSessionEntriesMock.mockReturnValue(entries); + loadTranscriptEventsMock.mockResolvedValue(entries); } function mockActiveTranscript(messages: unknown[]) { @@ -609,7 +610,7 @@ describe("runBtwSideQuestion", () => { readFileMock.mockResolvedValue("mock transcript"); loadTranscriptEventsMock.mockResolvedValue([]); - parseSessionEntriesMock.mockReturnValue([ + mockTranscriptEntries([ createTranscriptEntry({ id: "user-1", message: { role: "user", content: [{ type: "text", text: "hi" }], timestamp: 1 }, @@ -2372,6 +2373,13 @@ describe("runBtwSideQuestion", () => { }); it("reads SQLite marker transcripts through the accessor when no active snapshot exists", async () => { + const header = { + type: "session", + version: 3, + id: "session-1", + timestamp: "2026-01-01T00:00:00.000Z", + cwd: "/tmp", + }; const userEntry = createTranscriptEntry({ id: "user-seed", message: createUserTranscriptMessage(), @@ -2381,15 +2389,14 @@ describe("runBtwSideQuestion", () => { parentId: "user-seed", message: createAssistantTranscriptMessage([{ type: "text", text: "seed answer" }]), }); - loadTranscriptEventsMock.mockResolvedValue([userEntry, assistantEntry]); + loadTranscriptEventsMock.mockResolvedValue([header, userEntry, assistantEntry]); readFileMock.mockRejectedValue(new Error("sqlite marker must not be read as a file")); mockDoneAnswer(MATH_ANSWER); const result = await runMathSideQuestion({ sessionKey: DEFAULT_SESSION_KEY, - sessionEntry: createSessionEntry({ - sessionFile: `sqlite:main:session-1:${DEFAULT_STORE_PATH}`, - }), + sessionEntry: createSessionEntry(), + storePath: DEFAULT_STORE_PATH, }); expect(result).toEqual({ text: MATH_ANSWER }); diff --git a/src/agents/btw.ts b/src/agents/btw.ts index fc8b17f2c3df..f403fa17036d 100644 --- a/src/agents/btw.ts +++ b/src/agents/btw.ts @@ -1023,9 +1023,11 @@ export async function runBtwSideQuestion( if (messages.length === 0) { messages = await toSimpleContextMessages({ messages: await readBtwTranscriptMessages({ + agentId: sessionAgentId, sessionFile, sessionId, sessionKey: params.sessionKey, + storePath: params.storePath, snapshotLeafId: activeRunSnapshot?.transcriptLeafId, }), imageLimits, diff --git a/src/agents/cli-runner.reliability.test.ts b/src/agents/cli-runner.reliability.test.ts index 04ba945f47c1..630f5dec3d4b 100644 --- a/src/agents/cli-runner.reliability.test.ts +++ b/src/agents/cli-runner.reliability.test.ts @@ -10,7 +10,11 @@ import { getReplyPayloadMetadata } from "../auto-reply/reply-payload.js"; import { createReplyOperation, replyRunRegistry } from "../auto-reply/reply/reply-run-registry.js"; import { testing as replyRunTesting } from "../auto-reply/reply/reply-run-registry.test-support.js"; import { SILENT_REPLY_TOKEN } from "../auto-reply/tokens.js"; -import { loadTranscriptEvents, upsertSessionEntry } from "../config/sessions/session-accessor.js"; +import { + loadSessionEntry, + loadTranscriptEvents, + upsertSessionEntry, +} from "../config/sessions/session-accessor.js"; import { CURRENT_SESSION_VERSION } from "../config/sessions/version.js"; import type { OpenClawConfig } from "../config/types.openclaw.js"; import { @@ -3430,10 +3434,10 @@ describe("runCliAgent reliability", () => { ...context.params, agentId: "main", sessionFile, + storePath, workspaceDir: dir, prompt: "runtime prompt", persistAssistantTranscript: true, - storePath, userTurnTranscriptRecorder: recorder, onUserMessagePersisted, }, @@ -4413,6 +4417,7 @@ describe("runCliAgent reliability", () => { const { dir, sessionFile } = createSessionFile({ history: [{ role: "user", content: "earlier context" }], }); + const storePath = path.join(dir, "sessions.json"); try { let resolved = false; @@ -4429,6 +4434,7 @@ describe("runCliAgent reliability", () => { ...context.params, agentId: "main", sessionFile, + storePath, workspaceDir: dir, prompt: "secret prompt", }, @@ -4457,6 +4463,27 @@ describe("runCliAgent reliability", () => { expect(result.meta.agentMeta?.contextTokens).toBe(150_000); expect(supervisorSpawnMock).not.toHaveBeenCalled(); expect(hookRunner.runLlmInput).not.toHaveBeenCalled(); + const transcriptEvents = await loadTranscriptEvents({ + agentId: "main", + sessionId: context.params.sessionId, + sessionKey: "agent:main:main", + storePath, + }); + expect(transcriptEvents).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + type: "message", + message: expect.objectContaining({ + role: "user", + content: expect.arrayContaining([ + expect.objectContaining({ + text: "Your message could not be sent: The agent cannot read this message. (blocked by policy-plugin)", + }), + ]), + }), + }), + ]), + ); const beforeRunEvent = requireRecord( callArg(hookRunner.runBeforeAgentRun, 0, 0, "before_agent_run event"), "before_agent_run event", @@ -4499,20 +4526,79 @@ describe("runCliAgent reliability", () => { expect(callArg(hookRunner.runAgentEnd, 0, 1, "agent_end context")).toBeTypeOf("object"); expect(JSON.stringify(hookRunner.runAgentEnd.mock.calls)).not.toContain("secret prompt"); - const lines = fs.readFileSync(sessionFile, "utf-8").trim().split("\n"); - const blockedLine = JSON.parse( - expectDefined(lines[lines.length - 1], "lines[lines.length - 1] test invariant"), + const blockedLine = requireRecord( + expectDefined( + transcriptEvents.find( + (entry) => requireRecord(entry, "transcript entry").type === "message", + ), + "blocked transcript message", + ), + "blocked transcript message", ); - expect(blockedLine.message.content[0].text).toBe( + const blockedMessage = requireRecord(blockedLine.message, "blocked message"); + const blockedContent = requireArray(blockedMessage.content, "blocked content"); + expect(requireRecord(blockedContent[0], "blocked text").text).toBe( "Your message could not be sent: The agent cannot read this message. (blocked by policy-plugin)", ); expect(JSON.stringify(blockedLine)).not.toContain("secret prompt"); expect(JSON.stringify(blockedLine)).not.toContain("matched secret prompt"); - expect(blockedLine.message["__openclaw"].beforeAgentRunBlocked.blockedBy).toBe( - "policy-plugin", + const blockedMetadata = requireRecord(blockedMessage["__openclaw"], "blocked metadata"); + const blockedState = requireRecord(blockedMetadata.beforeAgentRunBlocked, "blocked state"); + expect(blockedState.blockedBy).toBe("policy-plugin"); + expect(blockedState).not.toHaveProperty("reason"); + expect(Object.hasOwn(blockedMetadata, "beforeAgentRunBlocked")).toBe(true); + } finally { + fs.rmSync(dir, { recursive: true, force: true }); + } + }); + + it("does not rebind a reset session when a stale before_agent_run hook blocks", async () => { + supervisorSpawnMock.mockClear(); + const { dir, sessionFile, storePath } = createSessionFile(); + const sessionKey = "agent:main:main"; + const context = buildPreparedContext({ + sessionKey, + runId: "run-blocked-cli-rebound", + provider: "claude-cli", + model: "opus", + }); + context.params.sessionEntry = { sessionId: "s1", updatedAt: 1 }; + const hookRunner = { + hasHooks: vi.fn((hookName: string) => hookName === "before_agent_run"), + runBeforeAgentRun: vi.fn(async () => { + await upsertSessionEntry( + { agentId: "main", sessionKey, storePath }, + { sessionId: "replacement-session", updatedAt: 2 }, + ); + return { + pluginId: "policy-plugin", + decision: { + outcome: "block" as const, + message: "Blocked after reset.", + }, + }; + }), + }; + setHookRunnerForTest(hookRunner); + + try { + await expect( + runPreparedCliAgent({ + ...context, + params: { + ...context.params, + agentId: "main", + sessionFile, + storePath, + workspaceDir: dir, + prompt: "secret prompt", + }, + }), + ).resolves.toMatchObject({ meta: { livenessState: "blocked" } }); + + expect(loadSessionEntry({ agentId: "main", sessionKey, storePath })?.sessionId).toBe( + "replacement-session", ); - expect(blockedLine.message["__openclaw"].beforeAgentRunBlocked).not.toHaveProperty("reason"); - expect(Object.hasOwn(blockedLine.message["__openclaw"], "beforeAgentRunBlocked")).toBe(true); } finally { fs.rmSync(dir, { recursive: true, force: true }); } diff --git a/src/agents/cli-runner.ts b/src/agents/cli-runner.ts index ac7b8166269d..07cd868bbc2e 100644 --- a/src/agents/cli-runner.ts +++ b/src/agents/cli-runner.ts @@ -3,6 +3,8 @@ */ import { setReplyPayloadMetadata, type ReplyPayload } from "../auto-reply/reply-payload.js"; import { SILENT_REPLY_TOKEN } from "../auto-reply/tokens.js"; +import { resolveStorePath } from "../config/sessions/paths.js"; +import { patchSessionEntry } from "../config/sessions/session-accessor.js"; import { appendExactAssistantMessageToSessionTranscript } from "../config/sessions/transcript.js"; import { buildGenericCliContextEngineHostSupport } from "../context-engine/host-compat.js"; import { @@ -24,6 +26,7 @@ import { } from "../plugins/hook-agent-context.js"; import { resolveBlockMessage } from "../plugins/hook-decision-types.js"; import { getGlobalHookRunner } from "../plugins/hook-runner-global.js"; +import { resolveAgentIdFromSessionKey } from "../routing/session-key.js"; import { isHeartbeatLifecycleRunKind } from "./bootstrap-mode.js"; import { resolveCliRuntimeArtifactFingerprint, @@ -889,7 +892,44 @@ export async function runPreparedCliAgent( } try { - const sessionManager = SessionManager.open(params.sessionFile); + const sessionKey = params.sessionKey?.trim() || params.sessionId; + const agentId = params.agentId ?? resolveAgentIdFromSessionKey(sessionKey); + let sessionManager = params.sessionManager; + if (!sessionManager) { + const sessionTarget = params.sessionTarget ?? { + agentId, + sessionId: params.sessionId, + sessionKey, + storePath: + params.storePath ?? + resolveStorePath(params.config?.session?.store, { + agentId, + }), + }; + const persistedEntry = await patchSessionEntry( + sessionTarget, + (entry, patchContext) => { + if (patchContext.existingEntry && entry.sessionId !== sessionTarget.sessionId) { + return null; + } + return { + sessionId: sessionTarget.sessionId, + updatedAt: Date.now(), + }; + }, + { + fallbackEntry: params.sessionEntry + ? undefined + : { sessionId: sessionTarget.sessionId, updatedAt: Date.now() }, + skipMaintenance: true, + }, + ); + if (persistedEntry?.sessionId !== sessionTarget.sessionId) { + // Skip only this stale blocked-message write; the outer runner still returns blocked. + return; + } + sessionManager = SessionManager.open(sessionTarget); + } sessionManager.appendMessage( redactedUserMessage as Parameters[0], ); diff --git a/src/agents/cli-runner/types.ts b/src/agents/cli-runner/types.ts index 9d2dbcc565dc..e4ef1446c95f 100644 --- a/src/agents/cli-runner/types.ts +++ b/src/agents/cli-runner/types.ts @@ -11,6 +11,7 @@ import type { ThinkLevel } from "../../auto-reply/thinking.js"; import type { FastMode } from "../../auto-reply/thinking.shared.js"; import type { InboundEventKind } from "../../channels/inbound-event/kind.js"; import type { CliSessionBinding, SessionEntry } from "../../config/sessions.js"; +import type { SessionTranscriptRuntimeTarget } from "../../config/sessions/session-accessor.types.js"; import type { SessionSystemPromptReport } from "../../config/sessions/types.js"; import type { OpenClawConfig } from "../../config/types.openclaw.js"; import type { ContextEngine } from "../../context-engine/types.js"; @@ -39,6 +40,7 @@ import type { import type { ExecPolicyOverrides } from "../exec-defaults.js"; import type { FastModeAutoProgressState } from "../fast-mode.js"; import type { ScheduledToolPolicyContext } from "../scheduled-tool-policy.js"; +import type { SessionManager } from "../sessions/index.js"; import type { SilentReplyPromptMode } from "../system-prompt.types.js"; type CliSessionRetryParams = { @@ -49,8 +51,11 @@ type CliSessionRetryParams = { /** Input contract for one CLI-backed agent run. */ export type RunCliAgentParams = { + /** Caller-owned in-memory transcript for ephemeral helper runs. */ + sessionManager?: SessionManager; sessionId: string; sessionKey?: string; + sessionTarget?: SessionTranscriptRuntimeTarget; /** Session identity used only for sandbox and tool-policy resolution. */ runtimePolicySessionKey?: string; sessionEntry?: SessionEntry; diff --git a/src/agents/command/attempt-execution.cli.test.ts b/src/agents/command/attempt-execution.cli.test.ts index 83592f1db9d8..a2be62258d82 100644 --- a/src/agents/command/attempt-execution.cli.test.ts +++ b/src/agents/command/attempt-execution.cli.test.ts @@ -4,6 +4,10 @@ import os from "node:os"; import path from "node:path"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import type { SessionEntry } from "../../config/sessions.js"; +import { + formatSqliteSessionFileMarker, + parseSqliteSessionFileMarker, +} from "../../config/sessions/legacy-sqlite-marker.js"; import { appendTranscriptEvent, appendTranscriptMessage, @@ -11,10 +15,6 @@ import { loadTranscriptEvents, replaceSessionEntry, } from "../../config/sessions/session-accessor.js"; -import { - formatSqliteSessionFileMarker, - parseSqliteSessionFileMarker, -} from "../../config/sessions/sqlite-marker.js"; import { clearSessionStoreCacheForTest } from "../../config/sessions/store-writer-state.js"; import type { OpenClawConfig } from "../../config/types.openclaw.js"; import { createUserTurnTranscriptRecorder } from "../../sessions/user-turn-transcript.js"; @@ -162,8 +162,12 @@ async function persistCliTranscriptEntry( return result.sessionEntry; } -async function readSessionMessages(sessionFile: string) { - return (await readTranscriptEntries(sessionFile)) +type TranscriptReadTarget = + | string + | { agentId: string; sessionId: string; sessionKey: string; storePath: string }; + +async function readSessionMessages(target: TranscriptReadTarget) { + return (await readTranscriptEntries(target)) .filter((entry) => entry.type === "message") .map( (entry) => @@ -171,19 +175,23 @@ async function readSessionMessages(sessionFile: string) { ); } -async function readSessionFileEntries(sessionFile: string) { +async function readSessionFileEntries(target: TranscriptReadTarget) { return await readTranscriptEntries<{ type?: string; id?: string; parentId?: string | null; cwd?: string; message?: { role?: string }; - }>(sessionFile); + }>(target); } async function readTranscriptEntries( - sessionFile: string, + target: TranscriptReadTarget, ): Promise { + if (typeof target !== "string") { + return (await loadTranscriptEvents(target)) as T[]; + } + const sessionFile = target; const marker = parseSqliteSessionFileMarker(sessionFile); if (marker) { return (await loadTranscriptEvents({ @@ -1683,16 +1691,14 @@ describe("CLI attempt execution", () => { nowSpy.mockRestore(); } - const updatedSessionFile = updatedEntry?.sessionFile; - if (!updatedSessionFile) { - throw new Error("expected CLI transcript persistence to create a session file"); - } - expect(parseSqliteSessionFileMarker(updatedSessionFile)).toMatchObject({ + expect(updatedEntry).not.toHaveProperty("sessionFile"); + const target = { agentId: "main", sessionId: sessionEntry.sessionId, + sessionKey, storePath, - }); - const entries = await readSessionFileEntries(updatedSessionFile); + }; + const entries = await readSessionFileEntries(target); expectRecordFields(requireRecord(entries[0], "session entry"), { type: "session", id: sessionEntry.sessionId, @@ -1706,7 +1712,7 @@ describe("CLI attempt execution", () => { type: "message", parentId: entries[1]?.id, }); - const messages = await readSessionMessages(updatedSessionFile); + const messages = await readSessionMessages(target); expect(messages).toHaveLength(2); expectRecordFields(requireRecord(messages[0], "user message"), { role: "user", @@ -1721,7 +1727,7 @@ describe("CLI attempt execution", () => { }); const persisted = readSessionStore(); - expect(persisted[sessionKey]?.sessionFile).toBe(updatedSessionFile); + expect(persisted[sessionKey]).not.toHaveProperty("sessionFile"); expect(persisted[sessionKey]?.updatedAt).toBeGreaterThan(sessionEntry.updatedAt); expect(persisted[sessionKey]?.updatedAt).toBeLessThanOrEqual(nowCalls.at(-1) ?? 0); expect(sessionStore[sessionKey]?.updatedAt).toBe(persisted[sessionKey]?.updatedAt); @@ -1940,7 +1946,13 @@ describe("CLI attempt execution", () => { embeddedAssistantGapFill: true, }); - let messages = await readSessionMessages(updatedFirst?.sessionFile ?? ""); + const target = { + agentId: "main", + sessionId: sessionEntry.sessionId, + sessionKey, + storePath, + }; + let messages = await readSessionMessages(target); expect(messages).toHaveLength(1); expectRecordFields(requireRecord(messages[0], "assistant message"), { role: "assistant", @@ -1961,7 +1973,7 @@ describe("CLI attempt execution", () => { embeddedAssistantGapFill: true, }); - messages = await readSessionMessages(updatedFirst?.sessionFile ?? ""); + messages = await readSessionMessages(target); expect(messages).toHaveLength(1); }); @@ -1995,11 +2007,6 @@ describe("CLI attempt execution", () => { config: {}, embeddedAssistantGapFill: true, }); - const sessionFile = updatedFirst?.sessionFile; - if (typeof sessionFile !== "string") { - throw new Error("Expected CLI transcript session file."); - } - await appendTranscriptEvent( { agentId: "main", sessionId: sessionEntry.sessionId, sessionKey, storePath }, { @@ -2027,7 +2034,12 @@ describe("CLI attempt execution", () => { embeddedAssistantGapFill: true, }); - const messages = await readSessionMessages(sessionFile); + const messages = await readSessionMessages({ + agentId: "main", + sessionId: sessionEntry.sessionId, + sessionKey, + storePath, + }); expect(messages).toHaveLength(1); expectRecordFields(requireRecord(messages[0], "assistant message"), { role: "assistant", @@ -2065,12 +2077,7 @@ describe("CLI attempt execution", () => { config: {}, embeddedAssistantGapFill: true, }); - const sessionFile = updatedFirst?.sessionFile; - if (typeof sessionFile !== "string") { - throw new Error("Expected CLI transcript session file."); - } - const persistedFirst = readSessionStore(); - expect(persistedFirst[sessionKey]?.sessionFile).toBe(sessionFile); + expect(updatedFirst).not.toHaveProperty("sessionFile"); await appendTranscriptMessage( { agentId: "main", sessionId: sessionEntry.sessionId, sessionKey, storePath }, @@ -2098,9 +2105,14 @@ describe("CLI attempt execution", () => { embeddedAssistantGapFill: true, }); - const messages = await readSessionMessages(sessionFile); - expect(messages).toHaveLength(3); + const messages = await readSessionMessages({ + agentId: "main", + sessionId: sessionEntry.sessionId, + sessionKey, + storePath, + }); expect(messages.map((message) => message.role)).toEqual(["assistant", "user", "assistant"]); + expect(messages).toHaveLength(3); expectRecordFields(requireRecord(messages[2], "deduped assistant message"), { content: [{ type: "text", text: "same answer" }], }); @@ -2115,7 +2127,7 @@ describe("CLI attempt execution", () => { const sessionStore: Record = { [sessionKey]: sessionEntry }; await writeSessionStoreSeed(sessionStore); - const updatedEntry = await persistCliTranscriptEntry({ + await persistCliTranscriptEntry({ body: [ "<<>>", "secret runtime context", @@ -2135,7 +2147,12 @@ describe("CLI attempt execution", () => { config: {}, }); - const messages = await readSessionMessages(updatedEntry?.sessionFile ?? ""); + const messages = await readSessionMessages({ + agentId: "main", + sessionId: sessionEntry.sessionId, + sessionKey, + storePath, + }); expectRecordFields(requireRecord(messages[0], "transcript user message"), { role: "user", content: "visible ask", diff --git a/src/agents/command/attempt-execution.ts b/src/agents/command/attempt-execution.ts index 6c2227704d5f..ef5fc8398e32 100644 --- a/src/agents/command/attempt-execution.ts +++ b/src/agents/command/attempt-execution.ts @@ -315,11 +315,13 @@ async function persistTextTurnTranscript( stopReason: "stop", timestamp: Date.now(), }, - shouldAppend: async ({ sessionFile }: { sessionFile: string }) => { + shouldAppend: async ( + context: import("../../config/sessions/session-accessor.js").SessionTranscriptTurnWriteContext, + ) => { if (!params.embeddedAssistantGapFill) { return true; } - const latest = await readTailAssistantTextFromSessionTranscript(sessionFile, { + const latest = await readTailAssistantTextFromSessionTranscript(context, { excludeTranscriptOnlyOpenClawAssistant: true, }); const normalizedReply = normalizeTranscriptMirrorText(replyText); diff --git a/src/agents/command/cli-compaction.test.ts b/src/agents/command/cli-compaction.test.ts index c2a4228ca538..f070bd7c2534 100644 --- a/src/agents/command/cli-compaction.test.ts +++ b/src/agents/command/cli-compaction.test.ts @@ -156,6 +156,7 @@ describe("runCliTurnCompactionLifecycle", () => { sessionKey: "agent:main:no-compactable-entries", sessionEntry, sessionAgentId: "main", + storePath: path.join(tmpDir, "sessions.json"), workspaceDir: tmpDir, agentDir: tmpDir, provider: "test-provider", @@ -185,7 +186,7 @@ describe("runCliTurnCompactionLifecycle", () => { const sessionEntry: SessionEntry = { sessionId, updatedAt: Date.now(), - sessionFile, + sessionFile: sessionKey, contextTokens: 1_000, totalTokens: 950, totalTokensFresh: true, @@ -270,7 +271,7 @@ describe("runCliTurnCompactionLifecycle", () => { expect(maintenanceCall?.reason).toBe("compaction"); expect(maintenanceCall?.sessionId).toBe(sessionId); expect(maintenanceCall?.sessionKey).toBe(sessionKey); - expect(maintenanceCall?.sessionFile).toBe(sessionFile); + expect(maintenanceCall?.sessionFile).toBe(sessionKey); expect(updatedEntry?.compactionCount).toBe(1); // Once OpenClaw rewrites the transcript, external CLI resume ids are stale // and must be cleared so the next turn starts from the compacted prompt. @@ -364,12 +365,11 @@ describe("runCliTurnCompactionLifecycle", () => { expect(maintenance).toHaveBeenCalledWith( expect.objectContaining({ sessionId: successorSessionId, - sessionFile: `sqlite:main:${successorSessionId}:${storePath}`, + sessionFile: sessionKey, }), ); expect(recordCliCompactionInStore).toHaveBeenCalledWith( expect.objectContaining({ - newSessionFile: `sqlite:main:${successorSessionId}:${storePath}`, newSessionId: successorSessionId, tokensAfter: 100, }), @@ -386,7 +386,7 @@ describe("runCliTurnCompactionLifecycle", () => { const sessionEntry: SessionEntry = { sessionId, updatedAt: Date.now(), - sessionFile, + sessionFile: sessionKey, contextTokens: 1_000, totalTokens: 950, totalTokensFresh: true, @@ -621,7 +621,7 @@ describe("runCliTurnCompactionLifecycle", () => { expect(compactAgentHarnessSessionCalls[0]?.[0]).toMatchObject({ sessionId, sessionKey, - sessionFile, + sessionFile: sessionKey, provider: "openai", model: "gpt-5.5", contextTokenBudget: 1_000, @@ -1160,7 +1160,6 @@ describe("runCliTurnCompactionLifecycle", () => { sessionKey, tokensAfter: 42, newSessionId: "session-codex-owned-engine-rotated", - newSessionFile: path.join(tmpDir, "session-codex-owned-engine-rotated.jsonl"), }), ); }); @@ -1590,17 +1589,17 @@ describe("runCliTurnCompactionLifecycle", () => { it("initializes built-in context engines before resolving CLI compaction engine", async () => { const sessionKey = "agent:main:cli"; const sessionId = "session-cli-init"; - const sessionFile = path.join(tmpDir, "session-init.jsonl"); - await writeSessionFile({ sessionFile, sessionId }); + const storePath = path.join(tmpDir, "sessions-cli-init.json"); const sessionEntry: SessionEntry = { sessionId, updatedAt: Date.now(), - sessionFile, contextTokens: 1_000, totalTokens: 950, totalTokensFresh: true, }; + const sessionStore = { [sessionKey]: sessionEntry }; + await persistSessionEntry({ sessionKey, storePath, entry: sessionEntry }); const calls: string[] = []; setCliCompactionTestDeps({ ensureContextEnginesInitialized: () => { @@ -1632,6 +1631,8 @@ describe("runCliTurnCompactionLifecycle", () => { sessionId, sessionKey, sessionEntry, + sessionStore, + storePath, sessionAgentId: "main", workspaceDir: tmpDir, agentDir: tmpDir, diff --git a/src/agents/command/cli-compaction.ts b/src/agents/command/cli-compaction.ts index 62c9b00e50db..aa3e0e86e843 100644 --- a/src/agents/command/cli-compaction.ts +++ b/src/agents/command/cli-compaction.ts @@ -1,3 +1,5 @@ +import path from "node:path"; +import type { SessionTranscriptRuntimeTarget } from "../../config/sessions/session-accessor.types.js"; /** * CLI turn compaction lifecycle. * @@ -62,7 +64,7 @@ type SettingsManagerLike = { setCompactionEnabled?: (enabled: boolean) => void; }; type CliCompactionDeps = { - openSessionManager: (sessionFile: string) => SessionManagerLike; + openSessionManager: (target: SessionTranscriptRuntimeTarget) => SessionManagerLike; ensureContextEnginesInitialized: () => void; resolveContextEngine: (cfg: OpenClawConfig) => Promise; createPreparedEmbeddedAgentSettingsManager: (params: { @@ -125,7 +127,7 @@ type CliCompactionRuntimeContextParams = { const log = createSubsystemLogger("agents/cli-compaction"); const cliCompactionDeps: CliCompactionDeps = { - openSessionManager: (sessionFile: string) => SessionManager.open(sessionFile), + openSessionManager: (target) => SessionManager.open(target), ensureContextEnginesInitialized: ensureContextEnginesInitializedImpl, resolveContextEngine: resolveContextEngineImpl, createPreparedEmbeddedAgentSettingsManager: createPreparedEmbeddedAgentSettingsManagerImpl, @@ -148,7 +150,7 @@ export function setCliCompactionTestDeps(overrides: Partial SessionManager.open(sessionFile), + openSessionManager: (target: SessionTranscriptRuntimeTarget) => SessionManager.open(target), ensureContextEnginesInitialized: ensureContextEnginesInitializedImpl, resolveContextEngine: resolveContextEngineImpl, createPreparedEmbeddedAgentSettingsManager: createPreparedEmbeddedAgentSettingsManagerImpl, @@ -252,6 +254,7 @@ function buildCliCompactionRuntimeContext(params: CliCompactionRuntimeContextPar } async function resolveCliContextCompactionSuccess(params: { + agentId: string; cfg: OpenClawConfig; compactResult: Awaited>; sessionFile: string; @@ -291,16 +294,26 @@ async function resolveCliContextCompactionSuccess(params: { params.storePath && !resultSessionTarget?.storePath ? { storePath: params.storePath } : {}, ), }); + if ( + resolvedTarget.agentId !== params.agentId || + resolvedTarget.sessionKey !== params.sessionKey || + (params.storePath && path.resolve(resolvedTarget.storePath) !== path.resolve(params.storePath)) + ) { + throw new Error( + "CLI context compaction cannot adopt a successor outside the active session binding", + ); + } return { - maintenanceSessionFile: resolvedTarget.sessionFile, + maintenanceSessionFile: resolvedTarget.sessionKey, maintenanceSessionId: resolvedTarget.sessionId, - successorSessionFile: resolvedTarget.sessionFile, + successorSessionFile: resolvedTarget.sessionKey, successorSessionId: resolvedTarget.sessionId, ...(result?.tokensAfter !== undefined ? { tokensAfter: result.tokensAfter } : {}), }; } async function compactCliTranscript(params: { + agentId: string; contextEngine: ContextEngine; sessionId: string; sessionKey: string; @@ -414,6 +427,7 @@ async function compactCliTranscript(params: { } const successor = await resolveCliContextCompactionSuccess({ + agentId: params.agentId, cfg: params.cfg, compactResult, sessionFile: params.sessionFile, @@ -606,13 +620,18 @@ export async function runCliTurnCompactionLifecycle(params: { thinkLevel?: Parameters[0]["thinkLevel"]; extraSystemPrompt?: string; }): Promise { - const sessionFile = params.sessionEntry?.sessionFile; const contextTokenBudget = resolvePositiveInteger(params.sessionEntry?.contextTokens); - if (!sessionFile || !contextTokenBudget) { + if (!params.storePath || !contextTokenBudget) { return params.sessionEntry; } - const sessionManager = cliCompactionDeps.openSessionManager(sessionFile); + const sessionManager = cliCompactionDeps.openSessionManager({ + agentId: params.sessionAgentId, + sessionId: params.sessionId, + sessionKey: params.sessionKey, + storePath: params.storePath, + }); + const sessionFile = params.sessionKey; const settingsManager = await cliCompactionDeps.createPreparedEmbeddedAgentSettingsManager({ cwd: params.cwd ?? params.workspaceDir, agentDir: params.agentDir, @@ -734,6 +753,7 @@ export async function runCliTurnCompactionLifecycle(params: { await applyAutoCompactionGuard(contextEngine); const contextOutcome = await compactCliTranscript({ + agentId: params.sessionAgentId, contextEngine, sessionId: params.sessionId, sessionKey: params.sessionKey, @@ -796,9 +816,6 @@ export async function runCliTurnCompactionLifecycle(params: { nativeCompactionResult?.result?.tokensAfter ?? contextCompactionOutcome?.tokensAfter, newSessionId: nativeCompactionResult?.result?.sessionId ?? contextCompactionOutcome?.successorSessionId, - newSessionFile: - nativeCompactionResult?.result?.sessionFile ?? - contextCompactionOutcome?.successorSessionFile, expectedSessionId: params.sessionId, })) ?? params.sessionEntry ); diff --git a/src/agents/command/run-embedded-attempt.ts b/src/agents/command/run-embedded-attempt.ts index bfca816bdc27..4cfa37a26515 100644 --- a/src/agents/command/run-embedded-attempt.ts +++ b/src/agents/command/run-embedded-attempt.ts @@ -131,6 +131,16 @@ export async function runEmbeddedAgentAttempt(params: { }) : undefined; params.trackInternalModelRunTarget(internalSessionTarget); + const attemptSessionTarget = + internalSessionTarget ?? + (sessionKey && storePath + ? { + agentId: sessionAgentId, + sessionId, + sessionKey, + storePath, + } + : undefined); const attemptSessionFile = internalSessionTarget?.sessionFile ?? sessionFile; const startedAt = Date.now(); @@ -382,7 +392,7 @@ export async function runEmbeddedAgentAttempt(params: { agentHarnessRuntimeOverride, sessionId, sessionKey, - ...(internalSessionTarget ? { sessionTarget: internalSessionTarget } : {}), + ...(attemptSessionTarget ? { sessionTarget: attemptSessionTarget } : {}), sessionAgentId, sessionFile: attemptSessionFile, workspaceDir, diff --git a/src/agents/command/session-store.test.ts b/src/agents/command/session-store.test.ts index 0ceeb675b65c..53843a11ed44 100644 --- a/src/agents/command/session-store.test.ts +++ b/src/agents/command/session-store.test.ts @@ -324,6 +324,7 @@ describe("updateSessionStoreAfterAgentRun", () => { const sessionStore: Record = { [sessionKey]: { sessionId, + sessionFile: path.join(path.dirname(storePath), "legacy-predecessor.jsonl"), updatedAt: 1, }, }; @@ -396,11 +397,11 @@ describe("updateSessionStoreAfterAgentRun", () => { expect(sessionStore[sessionKey]).toMatchObject({ sessionId: rotatedSessionId, - sessionFile: rotatedSessionFile, usageFamilyKey: sessionKey, usageFamilySessionIds: [sessionId, rotatedSessionId], compactionCount: 1, }); + expect(sessionStore[sessionKey]).not.toHaveProperty("sessionFile"); expect(sessionStore[sessionKey]?.sessionStartedAt).toBeGreaterThan(1); }); }); @@ -2785,17 +2786,15 @@ describe("recordCliCompactionInStore", () => { }); }); - it("persists successor session handles from native CLI compaction", async () => { - await withTempSessionStore(async ({ dir, storePath }) => { + it("persists successor session ids from native CLI compaction", async () => { + await withTempSessionStore(async ({ storePath }) => { const sessionKey = "agent:main:explicit:test-record-cli-compaction-rotate"; const sessionId = "test-record-cli-compaction-rotate-session"; const nextSessionId = "test-record-cli-compaction-rotate-next"; - const nextSessionFile = path.join(dir, `${nextSessionId}.jsonl`); const sessionStore: Record = { [sessionKey]: { sessionId, updatedAt: 1, - sessionFile: path.join(dir, `${sessionId}.jsonl`), }, }; await seedSessionStore(storePath, sessionStore); @@ -2806,17 +2805,16 @@ describe("recordCliCompactionInStore", () => { sessionStore, storePath, newSessionId: nextSessionId, - newSessionFile: nextSessionFile, }); expect(sessionStore[sessionKey]?.sessionId).toBe(nextSessionId); - expect(sessionStore[sessionKey]?.sessionFile).toBe(nextSessionFile); expect(sessionStore[sessionKey]?.usageFamilyKey).toBe(sessionKey); expect(sessionStore[sessionKey]?.usageFamilySessionIds).toEqual([sessionId, nextSessionId]); + expect(sessionStore[sessionKey]).not.toHaveProperty("sessionFile"); const persisted = loadPersistedSessionStore(storePath); expect(persisted[sessionKey]?.sessionId).toBe(nextSessionId); - expect(persisted[sessionKey]?.sessionFile).toBe(nextSessionFile); + expect(persisted[sessionKey]).not.toHaveProperty("sessionFile"); }); }); diff --git a/src/agents/command/session-store.ts b/src/agents/command/session-store.ts index bb6dd324df15..becbbe147dea 100644 --- a/src/agents/command/session-store.ts +++ b/src/agents/command/session-store.ts @@ -2,18 +2,13 @@ * Updates persisted session metadata after agent command runs. */ import { normalizeOptionalString } from "@openclaw/normalization-core/string-coerce"; -import { - resolveCompactionSessionFile, - setSessionRuntimeModel, - type SessionEntry, -} from "../../config/sessions.js"; +import { setSessionRuntimeModel, type SessionEntry } from "../../config/sessions.js"; import { patchSessionEntry } from "../../config/sessions/session-accessor.js"; import { projectSessionSnapshotChanges } from "../../config/sessions/session-snapshot-merge.js"; import { resolveMaintenanceConfigFromInput } from "../../config/sessions/store-maintenance.js"; import type { OpenClawConfig } from "../../config/types.openclaw.js"; import { createLazyImportLoader } from "../../shared/lazy-promise.js"; import { resolveNonNegativeNumber } from "../../shared/number-coercion.js"; -import { resolveDefaultAgentId } from "../agent-scope.js"; import { clearCliSession, getCliSessionBinding, @@ -104,7 +99,6 @@ export async function updateSessionStoreAfterAgentRun(params: { const modelUsed = result.meta.agentMeta?.model ?? fallbackModel ?? defaultModel; const providerUsed = result.meta.agentMeta?.provider ?? fallbackProvider ?? defaultProvider; const agentHarnessId = normalizeOptionalString(result.meta.agentMeta?.agentHarnessId); - const activeSessionFile = normalizeOptionalString(result.meta.agentMeta?.sessionFile); const runtimeContextTokens = resolvePositiveInteger(result.meta.agentMeta?.contextTokens); const contextBudgetStatus = result.meta.agentMeta?.contextBudgetStatus; const contextTokens = @@ -141,21 +135,11 @@ export async function updateSessionStoreAfterAgentRun(params: { }), }; if (entry.sessionId !== sessionId) { - next.sessionFile = - activeSessionFile ?? - resolveCompactionSessionFile({ - entry, - sessionKey, - storePath, - defaultAgentId: resolveDefaultAgentId(cfg), - newSessionId: sessionId, - }); + delete (next as { sessionFile?: unknown }).sessionFile; next.usageFamilyKey = entry.usageFamilyKey ?? sessionKey; next.usageFamilySessionIds = Array.from( new Set([...(entry.usageFamilySessionIds ?? []), entry.sessionId, sessionId]), ); - } else if (activeSessionFile) { - next.sessionFile = activeSessionFile; } if (preserveRuntimeModel) { // Keep the pre-existing runtime model and context window so a background @@ -503,7 +487,6 @@ export async function recordCliCompactionInStore(params: { storePath: string; tokensAfter?: number; newSessionId?: string; - newSessionFile?: string; expectedSessionId?: string; }): Promise { const { provider, sessionKey, sessionStore, storePath, expectedSessionId } = params; @@ -517,27 +500,14 @@ export async function recordCliCompactionInStore(params: { next.compactionCount = (entry.compactionCount ?? 0) + 1; next.updatedAt = Date.now(); const newSessionId = normalizeOptionalString(params.newSessionId); - const explicitNewSessionFile = normalizeOptionalString(params.newSessionFile); const sessionIdChanged = Boolean(newSessionId && newSessionId !== entry.sessionId); - const sessionFileChanged = Boolean( - explicitNewSessionFile && explicitNewSessionFile !== entry.sessionFile, - ); if (sessionIdChanged && newSessionId) { + delete (next as { sessionFile?: unknown }).sessionFile; next.sessionId = newSessionId; - next.sessionFile = - explicitNewSessionFile ?? - resolveCompactionSessionFile({ - entry, - sessionKey, - storePath, - newSessionId, - }); next.usageFamilyKey = entry.usageFamilyKey ?? sessionKey; next.usageFamilySessionIds = Array.from( new Set([...(entry.usageFamilySessionIds ?? []), entry.sessionId, newSessionId]), ); - } else if (sessionFileChanged && explicitNewSessionFile) { - next.sessionFile = explicitNewSessionFile; } const tokensAfterCompaction = resolveNonNegativeNumber(params.tokensAfter); next.contextBudgetStatus = undefined; diff --git a/src/agents/command/session.ts b/src/agents/command/session.ts index 83c662d45121..65b4504ef96b 100644 --- a/src/agents/command/session.ts +++ b/src/agents/command/session.ts @@ -369,6 +369,7 @@ export function resolveSession(opts: { ...resolveSessionLifecycleTimestamps({ entry: sessionEntry, agentId: sessionAgentId, + sessionKey, storePath, }), now, diff --git a/src/agents/embedded-agent-helpers.buildbootstrapcontextfiles.test.ts b/src/agents/embedded-agent-helpers.buildbootstrapcontextfiles.test.ts index 722d536ad9fb..87981705ebec 100644 --- a/src/agents/embedded-agent-helpers.buildbootstrapcontextfiles.test.ts +++ b/src/agents/embedded-agent-helpers.buildbootstrapcontextfiles.test.ts @@ -1,12 +1,8 @@ // Covers bootstrap context rendering, truncation, and transcript header setup. -import fs from "node:fs/promises"; -import os from "node:os"; -import path from "node:path"; import { describe, expect, it } from "vitest"; import type { OpenClawConfig } from "../config/config.js"; import { buildBootstrapContextFiles, - ensureSessionHeader, resolveBootstrapMaxChars, resolveBootstrapPromptTruncationWarningMode, resolveBootstrapTotalMaxChars, @@ -32,23 +28,6 @@ const createLargeBootstrapFiles = (): WorkspaceBootstrapFile[] => [ makeFile({ name: "USER.md", path: "/tmp/USER.md", content: "c".repeat(10_000) }), ]; -describe("ensureSessionHeader", () => { - it("creates transcript files with restrictive permissions", async () => { - // Session transcripts can contain private prompts and tool outputs, so both - // the directory and file need restrictive permissions from creation. - const tempDir = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-session-header-")); - try { - const sessionFile = path.join(tempDir, "nested", "session.jsonl"); - await ensureSessionHeader({ sessionFile, sessionId: "session-1", cwd: tempDir }); - - expect((await fs.stat(path.dirname(sessionFile))).mode & 0o777).toBe(0o700); - expect((await fs.stat(sessionFile)).mode & 0o777).toBe(0o600); - } finally { - await fs.rm(tempDir, { recursive: true, force: true }); - } - }); -}); - describe("buildBootstrapContextFiles", () => { it("keeps missing markers", () => { const files = [makeFile({ missing: true, content: undefined })]; diff --git a/src/agents/embedded-agent-helpers.ts b/src/agents/embedded-agent-helpers.ts index cae09dbbe691..98b599e2c5cb 100644 --- a/src/agents/embedded-agent-helpers.ts +++ b/src/agents/embedded-agent-helpers.ts @@ -2,7 +2,6 @@ export { buildBootstrapContextFiles, - ensureSessionHeader, resolveBootstrapMaxChars, resolveBootstrapPromptTruncationWarningMode, resolveBootstrapTotalMaxChars, diff --git a/src/agents/embedded-agent-helpers/bootstrap.ts b/src/agents/embedded-agent-helpers/bootstrap.ts index a0eaf0f1343e..726cf23fdf71 100644 --- a/src/agents/embedded-agent-helpers/bootstrap.ts +++ b/src/agents/embedded-agent-helpers/bootstrap.ts @@ -1,8 +1,6 @@ /** * Builds and sanitizes bootstrap context inserted into embedded-agent sessions. */ -import fs from "node:fs/promises"; -import path from "node:path"; import { normalizeOptionalString } from "@openclaw/normalization-core/string-coerce"; import type { OpenClawConfig } from "../../config/types.openclaw.js"; import { sanitizeGoogleAssistantFirstOrdering } from "../../shared/google-turn-ordering.js"; @@ -379,33 +377,6 @@ function clampToBudget(content: string, budget: number): string { return `${truncateUtf16Safe(content, safe)}…`; } -export async function ensureSessionHeader(params: { - sessionFile: string; - sessionId: string; - cwd: string; -}) { - const file = params.sessionFile; - try { - await fs.stat(file); - return; - } catch { - // create - } - await fs.mkdir(path.dirname(file), { recursive: true, mode: 0o700 }); - const sessionVersion = 2; - const entry = { - type: "session", - version: sessionVersion, - id: params.sessionId, - timestamp: new Date().toISOString(), - cwd: params.cwd, - }; - await fs.writeFile(file, `${JSON.stringify(entry)}\n`, { - encoding: "utf-8", - mode: 0o600, - }); -} - export function buildBootstrapContextFiles( files: WorkspaceBootstrapFile[], opts?: { warn?: (message: string) => void; maxChars?: number; totalMaxChars?: number }, diff --git a/src/agents/embedded-agent-runner.e2e.test.ts b/src/agents/embedded-agent-runner.e2e.test.ts index a2b7e065fa1a..45a00fca4a40 100644 --- a/src/agents/embedded-agent-runner.e2e.test.ts +++ b/src/agents/embedded-agent-runner.e2e.test.ts @@ -186,6 +186,9 @@ beforeAll(async () => { ({ runEmbeddedAgent } = await import("./embedded-agent-runner/run.js")); const { SessionManager: LoadedSessionManager } = await import("openclaw/plugin-sdk/agent-sessions"); + const { installSessionManagerFileCompat } = + await import("../../test/helpers/session-manager-file-compat.js"); + installSessionManagerFileCompat(LoadedSessionManager); SessionManager = LoadedSessionManager; e2eWorkspace = await createEmbeddedAgentRunnerTestWorkspace("openclaw-embedded-agent-"); ({ agentDir, workspaceDir } = e2eWorkspace); @@ -227,7 +230,7 @@ const runWithOrphanedSingleUserMessage = async (text: string, sessionKey: string // Builds a session with an orphaned user message to exercise retry/resume // cleanup paths from persisted JSONL. const sessionFile = nextSessionFile(); - const sessionManager = SessionManager.open(sessionFile); + const sessionManager = SessionManager.openFile(sessionFile); sessionManager.appendMessage({ role: "user", content: [{ type: "text", text }], @@ -1232,7 +1235,7 @@ describe("runEmbeddedAgent", () => { const sessionFile = nextSessionFile(); const sessionKey = nextSessionKey(); - const sessionManager = SessionManager.open(sessionFile); + const sessionManager = SessionManager.openFile(sessionFile); sessionManager.appendMessage({ role: "user", content: [{ type: "text", text: "seed user" }], diff --git a/src/agents/embedded-agent-runner.guard.test.ts b/src/agents/embedded-agent-runner.guard.test.ts index b6b7b134102d..b45c45d6d9b2 100644 --- a/src/agents/embedded-agent-runner.guard.test.ts +++ b/src/agents/embedded-agent-runner.guard.test.ts @@ -1,6 +1,7 @@ // Covers session-manager guard behavior for tool-result pairing and transcript // redaction. import { readFileSync } from "node:fs"; +import "../../test/helpers/session-manager-file-compat.js"; import type { AgentMessage } from "openclaw/plugin-sdk/agent-core"; import { SessionManager } from "openclaw/plugin-sdk/agent-sessions"; import { diff --git a/src/agents/embedded-agent-runner/compact.abort-signal.test.ts b/src/agents/embedded-agent-runner/compact.abort-signal.test.ts index 91e8b1831a48..549d9c788275 100644 --- a/src/agents/embedded-agent-runner/compact.abort-signal.test.ts +++ b/src/agents/embedded-agent-runner/compact.abort-signal.test.ts @@ -50,7 +50,13 @@ const runMock = vi.mocked(runWithModelFallback); const baseParams = { sessionId: "test-session", sessionKey: "agent:main:test-session", - sessionFile: "/tmp/test-session.jsonl", + sessionFile: "agent:main:test-session", + sessionTarget: { + agentId: "main", + sessionId: "test-session", + sessionKey: "agent:main:test-session", + storePath: "/tmp/sessions.json", + }, workspaceDir: "/tmp", }; diff --git a/src/agents/embedded-agent-runner/compact.hooks.harness.ts b/src/agents/embedded-agent-runner/compact.hooks.harness.ts index fa778ab4b49e..8348934abc2b 100644 --- a/src/agents/embedded-agent-runner/compact.hooks.harness.ts +++ b/src/agents/embedded-agent-runner/compact.hooks.harness.ts @@ -8,7 +8,6 @@ import { clearAgentHarnesses } from "../harness/registry.js"; import type { AgentHarness } from "../harness/types.js"; import type { ModelAuthMode } from "../model-auth.js"; import type { AgentRuntimePlan, BuildAgentRuntimePlanParams } from "../runtime-plan/types.js"; -import type { CompactionTranscriptRotation } from "./compaction-successor-transcript.js"; type MockResolvedModel = { model: { @@ -295,7 +294,12 @@ async function runCompactWithSafetyTimeoutMock( } export const compactWithSafetyTimeoutMock = vi.fn(runCompactWithSafetyTimeoutMock); export const rotateTranscriptAfterCompactionMock: Mock< - (_params?: unknown) => Promise + (_params?: unknown) => Promise<{ + rotated: boolean; + sessionId?: string; + sessionFile?: string; + leafId?: string; + }> > = vi.fn(async () => ({ rotated: false, })); @@ -781,20 +785,22 @@ export async function loadCompactHooksHarness(): Promise<{ resolveSandboxContext: resolveSandboxContextMock, })); - vi.doMock("../session-file-repair.js", () => ({ - repairSessionFileIfNeeded: vi.fn(async () => {}), - })); - - vi.doMock("../session-write-lock.js", () => ({ - acquireSessionWriteLock: acquireSessionWriteLockMock, - resolveSessionLockMaxHoldFromTimeout: vi.fn(() => 0), - resolveSessionWriteLockAcquireTimeoutMs: vi.fn(() => 60_000), - resolveSessionWriteLockOptions: vi.fn(() => ({ - timeoutMs: 60_000, - staleMs: 1_800_000, - maxHoldMs: 300_000, - })), - })); + vi.doMock("../session-write-lock.js", async () => { + const { resolveSessionWriteLockTargetKey } = await vi.importActual< + typeof import("../session-write-lock.js") + >("../session-write-lock.js"); + return { + acquireSessionWriteLock: acquireSessionWriteLockMock, + resolveSessionLockMaxHoldFromTimeout: vi.fn(() => 0), + resolveSessionWriteLockAcquireTimeoutMs: vi.fn(() => 60_000), + resolveSessionWriteLockOptions: vi.fn(() => ({ + timeoutMs: 60_000, + staleMs: 1_800_000, + maxHoldMs: 300_000, + })), + resolveSessionWriteLockTargetKey, + }; + }); vi.doMock("../../context-engine/init.js", () => ({ ensureContextEnginesInitialized: vi.fn(), @@ -936,16 +942,6 @@ export async function loadCompactHooksHarness(): Promise<{ }; }); - vi.doMock("./compaction-successor-transcript.js", async () => { - const actual = await vi.importActual( - "./compaction-successor-transcript.js", - ); - return { - ...actual, - rotateTranscriptAfterCompaction: rotateTranscriptAfterCompactionMock, - }; - }); - vi.doMock("./wait-for-idle-before-flush.js", () => ({ flushPendingToolResultsAfterIdle: vi.fn(async () => {}), })); @@ -1068,11 +1064,6 @@ export async function loadCompactHooksHarness(): Promise<{ resolveModelAsync: resolveModelAsyncMock, })); - vi.doMock("./session-manager-cache.js", () => ({ - prewarmSessionFile: vi.fn(async () => {}), - trackSessionManagerAccess: vi.fn(), - })); - vi.doMock("./system-prompt.js", () => ({ applySystemPromptToSession: vi.fn( (session: { setBaseSystemPrompt: (systemPrompt: string) => void }, systemPrompt: string) => { diff --git a/src/agents/embedded-agent-runner/compact.hooks.test.ts b/src/agents/embedded-agent-runner/compact.hooks.test.ts index cc6644ecc560..c25025f58428 100644 --- a/src/agents/embedded-agent-runner/compact.hooks.test.ts +++ b/src/agents/embedded-agent-runner/compact.hooks.test.ts @@ -1,9 +1,14 @@ // Hook integration coverage for direct and queued embedded compaction. +import { mkdtemp, rm } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; import { expectDefined } from "@openclaw/normalization-core"; import type { AgentMessage } from "openclaw/plugin-sdk/agent-core"; import { beforeAll, beforeEach, describe, expect, it, vi, type Mock } from "vitest"; import { createReplyOperation } from "../../auto-reply/reply/reply-run-registry.js"; +import { upsertSessionEntry } from "../../config/sessions/session-accessor.js"; +import { closeOpenClawAgentDatabasesForTest } from "../../state/openclaw-agent-db.js"; import { acquireSessionWriteLockMock, applyExtraParamsToAgentMock, @@ -216,7 +221,13 @@ function wrappedCompactionArgs(overrides: Record = {}) { return { sessionId: TEST_SESSION_ID, sessionKey: TEST_SESSION_KEY, - sessionFile: TEST_SESSION_FILE, + sessionFile: TEST_SESSION_KEY, + sessionTarget: { + agentId: "main", + sessionId: TEST_SESSION_ID, + sessionKey: TEST_SESSION_KEY, + storePath: "/tmp/sessions.json", + }, workspaceDir: TEST_WORKSPACE_DIR, customInstructions: TEST_CUSTOM_INSTRUCTIONS, enqueue: async (task: () => Promise | T) => await task(), @@ -340,7 +351,11 @@ describe("compactEmbeddedAgentSessionDirect hooks", () => { expect(result).toMatchObject({ ok: true, compacted: true }); const lockOptions = mockCallArg(acquireSessionWriteLockMock); - expect(lockOptions).toMatchObject({ sessionFile: TEST_SESSION_FILE }); + expect(lockOptions).toMatchObject({ + sessionFile: expect.any(String), + targetKind: "session-key", + }); + expect(lockOptions.sessionFile).not.toBe(TEST_SESSION_KEY); expect(lockOptions).not.toHaveProperty("allowReentrant"); }); @@ -353,8 +368,8 @@ describe("compactEmbeddedAgentSessionDirect hooks", () => { const result = await withOwnedSessionTranscriptWrites( { - sessionFile: TEST_SESSION_FILE, sessionKey: TEST_SESSION_KEY, + sessionTarget: wrappedCompactionArgs().sessionTarget, withSessionWriteLock, }, async () => await compactEmbeddedAgentSessionDirect(wrappedCompactionArgs()), @@ -368,7 +383,7 @@ describe("compactEmbeddedAgentSessionDirect hooks", () => { it("fails closed before generic compaction for a model-locked native session", async () => { const result = await compactEmbeddedAgentSessionDirect({ sessionId: "session-1", - sessionFile: "/tmp/session.jsonl", + sessionFile: TEST_SESSION_KEY, workspaceDir: "/tmp/workspace", provider: "openai", model: "gpt-5.5", @@ -700,7 +715,8 @@ describe("compactEmbeddedAgentSessionDirect hooks", () => { await compactEmbeddedAgentSessionDirect({ sessionId: "session-1", - sessionFile: "/tmp/session.jsonl", + sessionKey: TEST_SESSION_KEY, + sessionFile: TEST_SESSION_KEY, workspaceDir: "/tmp/workspace", }); @@ -721,7 +737,8 @@ describe("compactEmbeddedAgentSessionDirect hooks", () => { await compactEmbeddedAgentSessionDirect({ sessionId: "session-1", - sessionFile: "/tmp/session.jsonl", + sessionKey: TEST_SESSION_KEY, + sessionFile: TEST_SESSION_KEY, workspaceDir: "/tmp/workspace", allowGatewaySubagentBinding: true, }); @@ -740,7 +757,7 @@ describe("compactEmbeddedAgentSessionDirect hooks", () => { sessionId: "session-1", sessionKey: "agent:main:main", sandboxSessionKey: "agent:main:telegram:default:direct:12345", - sessionFile: "/tmp/session.jsonl", + sessionFile: TEST_SESSION_KEY, workspaceDir: "/tmp/workspace", }); @@ -757,7 +774,7 @@ describe("compactEmbeddedAgentSessionDirect hooks", () => { await compactEmbeddedAgentSessionDirect({ sessionId: "session-1", sessionKey: "agent:main:subagent:worker", - sessionFile: "/tmp/session.jsonl", + sessionFile: TEST_SESSION_KEY, workspaceDir: "/tmp/workspace", }); @@ -777,7 +794,7 @@ describe("compactEmbeddedAgentSessionDirect hooks", () => { await compactEmbeddedAgentSessionDirect({ sessionId: "session-1", sessionKey: "agent:codex:acp:worker", - sessionFile: "/tmp/session.jsonl", + sessionFile: TEST_SESSION_KEY, workspaceDir: "/tmp/workspace", }); @@ -802,7 +819,7 @@ describe("compactEmbeddedAgentSessionDirect hooks", () => { await compactEmbeddedAgentSessionDirect({ sessionId: "session-1", sessionKey: "agent:marketing-agent:session-1", - sessionFile: "/tmp/session.jsonl", + sessionFile: TEST_SESSION_KEY, workspaceDir: "/tmp/workspace", }); @@ -822,7 +839,7 @@ describe("compactEmbeddedAgentSessionDirect hooks", () => { await compactEmbeddedAgentSessionDirect({ sessionId: "session-1", sessionKey: "agent:main:session-1", - sessionFile: "/tmp/session.jsonl", + sessionFile: TEST_SESSION_KEY, workspaceDir: "/tmp/workspace", }); @@ -968,7 +985,8 @@ describe("compactEmbeddedAgentSessionDirect hooks", () => { it("preserves full sender identity when building compaction tools", async () => { await compactEmbeddedAgentSessionDirect({ sessionId: "session-1", - sessionFile: "/tmp/session.jsonl", + sessionKey: TEST_SESSION_KEY, + sessionFile: TEST_SESSION_KEY, workspaceDir: "/tmp/workspace", senderId: "sender-1", senderName: "Alice", @@ -994,7 +1012,8 @@ describe("compactEmbeddedAgentSessionDirect hooks", () => { await compactEmbeddedAgentSessionDirect({ sessionId: "session-1", - sessionFile: "/tmp/session.jsonl", + sessionKey: TEST_SESSION_KEY, + sessionFile: TEST_SESSION_KEY, workspaceDir: "/tmp/workspace", }); @@ -1005,7 +1024,8 @@ describe("compactEmbeddedAgentSessionDirect hooks", () => { it("uses cwd for compaction runtime tools while preserving workspace bootstrap root", async () => { await compactEmbeddedAgentSessionDirect({ sessionId: "session-1", - sessionFile: "/tmp/session.jsonl", + sessionKey: TEST_SESSION_KEY, + sessionFile: TEST_SESSION_KEY, workspaceDir: "/tmp/workspace", cwd: "/tmp/task-repo", }); @@ -1024,7 +1044,8 @@ describe("compactEmbeddedAgentSessionDirect hooks", () => { it("uses the caller context token budget during runtime compaction", async () => { await compactEmbeddedAgentSessionDirect({ sessionId: "session-1", - sessionFile: "/tmp/session.jsonl", + sessionKey: TEST_SESSION_KEY, + sessionFile: TEST_SESSION_KEY, workspaceDir: "/tmp/workspace", contextTokenBudget: 64_000, }); @@ -1048,7 +1069,8 @@ describe("compactEmbeddedAgentSessionDirect hooks", () => { await compactEmbeddedAgentSessionDirect({ sessionId: "session-1", - sessionFile: "/tmp/session.jsonl", + sessionKey: TEST_SESSION_KEY, + sessionFile: TEST_SESSION_KEY, workspaceDir: "/tmp/workspace", }); @@ -1086,7 +1108,7 @@ describe("compactEmbeddedAgentSessionDirect hooks", () => { await compactEmbeddedAgentSessionDirect({ sessionId: "session-1", sessionKey: "agent:main:session-1", - sessionFile: "/tmp/session.jsonl", + sessionFile: TEST_SESSION_KEY, workspaceDir: "/tmp/workspace", runId: "run-tool-schema-quarantine", }); @@ -1103,7 +1125,8 @@ describe("compactEmbeddedAgentSessionDirect hooks", () => { await compactEmbeddedAgentSessionDirect({ sessionId: "session-1", - sessionFile: "/tmp/session.jsonl", + sessionKey: TEST_SESSION_KEY, + sessionFile: TEST_SESSION_KEY, workspaceDir: "/tmp/workspace", contextTokenBudget: 64_000, }); @@ -1131,7 +1154,7 @@ describe("compactEmbeddedAgentSessionDirect hooks", () => { const result = await compactEmbeddedAgentSessionDirect({ sessionId: "session-1", sessionKey: TEST_SESSION_KEY, - sessionFile: "/tmp/session.jsonl", + sessionFile: TEST_SESSION_KEY, workspaceDir: "/tmp/workspace", provider: "openai", model: "gpt-primary", @@ -1177,7 +1200,7 @@ describe("compactEmbeddedAgentSessionDirect hooks", () => { const result = await compactEmbeddedAgentSessionDirect({ sessionId: "session-1", sessionKey: TEST_SESSION_KEY, - sessionFile: "/tmp/session.jsonl", + sessionFile: TEST_SESSION_KEY, workspaceDir: "/tmp/workspace", provider: "openai", model: "gpt-primary", @@ -1221,7 +1244,7 @@ describe("compactEmbeddedAgentSessionDirect hooks", () => { const params = { sessionId: "session-1", sessionKey: TEST_SESSION_KEY, - sessionFile: "/tmp/session.jsonl", + sessionFile: TEST_SESSION_KEY, workspaceDir: "/tmp/workspace", provider: "openai", model: "gpt-5.6-sol", @@ -1288,7 +1311,7 @@ describe("compactEmbeddedAgentSessionDirect hooks", () => { const result = await compactEmbeddedAgentSessionDirect({ sessionId: "session-1", sessionKey: TEST_SESSION_KEY, - sessionFile: "/tmp/session.jsonl", + sessionFile: TEST_SESSION_KEY, workspaceDir: "/tmp/workspace", provider: "openai", model: "gpt-5.5", @@ -1398,7 +1421,7 @@ describe("compactEmbeddedAgentSessionDirect hooks", () => { const result = await compactEmbeddedAgentSessionDirect({ sessionId: "session-1", sessionKey: TEST_SESSION_KEY, - sessionFile: "/tmp/session.jsonl", + sessionFile: TEST_SESSION_KEY, workspaceDir: "/tmp/workspace", provider: "openai", model: "gpt-5.5", @@ -1457,7 +1480,7 @@ describe("compactEmbeddedAgentSessionDirect hooks", () => { const result = await compactEmbeddedAgentSessionDirect({ sessionId: "session-1", sessionKey: TEST_SESSION_KEY, - sessionFile: "/tmp/session.jsonl", + sessionFile: TEST_SESSION_KEY, workspaceDir: "/tmp/workspace", config: { models: { @@ -1495,7 +1518,7 @@ describe("compactEmbeddedAgentSessionDirect hooks", () => { const result = await compactEmbeddedAgentSessionDirect({ sessionId: "session-1", sessionKey: TEST_SESSION_KEY, - sessionFile: "/tmp/session.jsonl", + sessionFile: TEST_SESSION_KEY, workspaceDir: "/tmp/workspace", provider: "openai", model: "gpt-5.5", @@ -1528,7 +1551,7 @@ describe("compactEmbeddedAgentSessionDirect hooks", () => { const result = await compactEmbeddedAgentSessionDirect({ sessionId: "session-1", sessionKey: TEST_SESSION_KEY, - sessionFile: "/tmp/session.jsonl", + sessionFile: TEST_SESSION_KEY, workspaceDir: "/tmp/workspace", provider: "openai", model: "gpt-5.5", @@ -1610,7 +1633,7 @@ describe("compactEmbeddedAgentSessionDirect hooks", () => { const result = await compactEmbeddedAgentSessionDirect({ sessionId: "session-1", sessionKey: TEST_SESSION_KEY, - sessionFile: "/tmp/session.jsonl", + sessionFile: TEST_SESSION_KEY, workspaceDir: "/tmp/workspace", provider: "openai", model: "gpt-5.5", @@ -1686,7 +1709,7 @@ describe("compactEmbeddedAgentSessionDirect hooks", () => { const result = await compactEmbeddedAgentSessionDirect({ sessionId: "session-1", sessionKey: TEST_SESSION_KEY, - sessionFile: "/tmp/session.jsonl", + sessionFile: TEST_SESSION_KEY, workspaceDir: "/tmp/workspace", provider: "openai", model: "gpt-primary", @@ -1722,7 +1745,7 @@ describe("compactEmbeddedAgentSessionDirect hooks", () => { const result = await compactEmbeddedAgentSessionDirect({ sessionId: "session-1", sessionKey: TEST_SESSION_KEY, - sessionFile: "/tmp/session.jsonl", + sessionFile: TEST_SESSION_KEY, workspaceDir: "/tmp/workspace", provider: "openai", model: "gpt-primary", @@ -1762,7 +1785,7 @@ describe("compactEmbeddedAgentSessionDirect hooks", () => { const result = await compactEmbeddedAgentSessionDirect({ sessionId: "session-1", sessionKey: TEST_SESSION_KEY, - sessionFile: "/tmp/session.jsonl", + sessionFile: TEST_SESSION_KEY, workspaceDir: "/tmp/workspace", provider: "openai", model: "gpt-primary", @@ -1832,7 +1855,7 @@ describe("compactEmbeddedAgentSessionDirect hooks", () => { messageCount: 1, tokenCount: 10, compactedCount: 1, - sessionFile: "/tmp/session.jsonl", + sessionFile: TEST_SESSION_FILE, }, expectRecordFields(mockCallArg(hookRunner.runAfterCompaction, 0, 1), { sessionKey: "agent:main:session-1", @@ -1917,7 +1940,7 @@ describe("compactEmbeddedAgentSessionDirect hooks", () => { messageCountAfter: 1, tokensAfter: 10, compactedCount: 1, - sessionFile: "/tmp/session.jsonl", + sessionFile: TEST_SESSION_KEY, onHookMessages, }); @@ -1940,80 +1963,14 @@ describe("compactEmbeddedAgentSessionDirect hooks", () => { try { await compactTesting.runPostCompactionSideEffects({ - sessionKey: "agent:main:session-1", - sessionFile: " /tmp/session.jsonl ", + sessionKey: TEST_SESSION_KEY, + sessionFile: ` ${TEST_SESSION_KEY} `, }); expect(listener).toHaveBeenCalledTimes(1); expect(listener).toHaveBeenCalledWith({ - sessionFile: "/tmp/session.jsonl", - sessionKey: "agent:main:session-1", - }); - } finally { - cleanup(); - } - }); - - it("emits post-compaction side effects once for a rotated successor transcript", async () => { - hookRunner.hasHooks.mockReturnValue(true); - const listener = vi.fn(); - const cleanup = onSessionTranscriptUpdate(listener); - const sync = vi.fn(async () => {}); - getMemorySearchManagerMock.mockResolvedValue({ manager: { sync } }); - rotateTranscriptAfterCompactionMock.mockResolvedValueOnce({ - rotated: true, - sessionId: "rotated-session", - sessionFile: "/tmp/rotated-session.jsonl", - leafId: "rotated-leaf", - }); - - try { - const result = await compactEmbeddedAgentSessionDirect({ - sessionId: "session-1", + sessionFile: TEST_SESSION_KEY, sessionKey: TEST_SESSION_KEY, - sessionFile: "/tmp/session.jsonl", - workspaceDir: "/tmp/workspace", - config: { - agents: { - defaults: { - compaction: { - truncateAfterCompaction: true, - postIndexSync: "await", - }, - }, - }, - } as never, - }); - - expect(result.ok).toBe(true); - expect(listener).toHaveBeenCalledTimes(1); - expect(listener).toHaveBeenCalledWith({ - agentId: "main", - sessionKey: TEST_SESSION_KEY, - sessionId: "rotated-session", - target: { - agentId: "main", - sessionId: "rotated-session", - sessionKey: TEST_SESSION_KEY, - }, - }); - expect(sync).toHaveBeenCalledTimes(1); - expect(sync).toHaveBeenCalledWith({ - reason: "post-compaction", - sessions: [ - { - agentId: "main", - sessionId: "rotated-session", - sessionKey: TEST_SESSION_KEY, - }, - ], - }); - expectRecordFields(mockCallArg(hookRunner.runAfterCompaction), { - previousSessionId: "session-1", - sessionFile: "/tmp/rotated-session.jsonl", - }); - expectRecordFields(mockCallArg(hookRunner.runAfterCompaction, 0, 1), { - sessionId: "rotated-session", }); } finally { cleanup(); @@ -2571,7 +2528,7 @@ describe("compactEmbeddedAgentSession hooks (ownsCompaction engine)", () => { expect(mockCallArg(hookRunner.runBeforeCompaction)).toEqual({ messageCount: -1, - sessionFile: TEST_SESSION_FILE, + sessionFile: TEST_SESSION_KEY, }); expectRecordFields(mockCallArg(hookRunner.runBeforeCompaction, 0, 1), { sessionKey: TEST_SESSION_KEY, @@ -2581,7 +2538,7 @@ describe("compactEmbeddedAgentSession hooks (ownsCompaction engine)", () => { messageCount: -1, compactedCount: -1, tokenCount: 50, - sessionFile: TEST_SESSION_FILE, + sessionFile: TEST_SESSION_KEY, }); expectRecordFields(mockCallArg(hookRunner.runAfterCompaction, 0, 1), { sessionKey: TEST_SESSION_KEY, @@ -2611,7 +2568,7 @@ describe("compactEmbeddedAgentSession hooks (ownsCompaction engine)", () => { expect(result.ok).toBe(true); expectRecordFields(mockCallArg(hookRunner.runAfterCompaction), { - sessionFile: TEST_SESSION_FILE, + sessionFile: TEST_SESSION_KEY, previousSessionId: TEST_SESSION_ID, }); expectRecordFields(mockCallArg(hookRunner.runAfterCompaction, 0, 1), { @@ -2683,7 +2640,7 @@ describe("compactEmbeddedAgentSession hooks (ownsCompaction engine)", () => { )?.runtimeContext; expectRecordFields(mockCallArg(maintain), { sessionKey: TEST_SESSION_KEY, - sessionFile: TEST_SESSION_FILE, + sessionFile: TEST_SESSION_KEY, }); expect(runtimeContext?.workspaceDir).toBe(TEST_WORKSPACE_DIR); expect(runtimeContext?.cwd).toBe("/tmp/task-repo"); @@ -3802,7 +3759,7 @@ describe("compactEmbeddedAgentSession hooks (ownsCompaction engine)", () => { expect(maybeCompactAgentHarnessSessionMock).toHaveBeenCalledWith( expect.objectContaining({ sessionId: successorSessionId, - sessionFile: TEST_SESSION_FILE, + sessionFile: TEST_SESSION_KEY, trigger: "budget", }), { nativeCompactionRequest: "after_context_engine" }, @@ -4133,7 +4090,7 @@ describe("compactEmbeddedAgentSession hooks (ownsCompaction engine)", () => { { identity: "session file", activeSessionKey: "agent:main:other-session", - activeSessionFile: TEST_SESSION_FILE, + activeSessionFile: TEST_SESSION_KEY, }, ])("rejects manual compaction matching an active $identity", async (active) => { const activeSessionId = "other-session"; @@ -4240,10 +4197,329 @@ describe("compactEmbeddedAgentSession hooks (ownsCompaction engine)", () => { expect(result.result?.sessionFile).toBeUndefined(); expectRecordFields(mockCallArg(maintain), { sessionId: delegatedSessionId, - sessionFile: TEST_SESSION_FILE, + sessionFile: TEST_SESSION_KEY, + sessionTarget: expect.objectContaining({ sessionId: delegatedSessionId }), }); }); + it("keeps a partial structured successor in the active transcript store", async () => { + const maintain = vi.fn(async (_params?: unknown) => ({ + changed: false, + bytesFreed: 0, + rewrittenEntries: 0, + })); + const delegatedSessionId = "delegated-session"; + const storePath = "/tmp/custom-active-sessions.json"; + resolveContextEngineMock.mockResolvedValue({ + info: { ownsCompaction: false }, + compact: contextEngineCompactMock, + maintain, + } as never); + contextEngineCompactMock.mockResolvedValue({ + ok: true, + compacted: true, + result: { + sessionTarget: { sessionId: delegatedSessionId }, + }, + } as never); + + const result = await compactEmbeddedAgentSession( + wrappedCompactionArgs({ + sessionTarget: { + agentId: "main", + sessionId: TEST_SESSION_ID, + sessionKey: TEST_SESSION_KEY, + storePath, + }, + }), + ); + + expect(result.ok).toBe(true); + expectRecordFields(mockCallArg(maintain), { + sessionId: delegatedSessionId, + sessionTarget: expect.objectContaining({ + agentId: "main", + sessionId: delegatedSessionId, + sessionKey: TEST_SESSION_KEY, + storePath, + }), + }); + }); + + it.each([ + ["session key", "agent:main:other", "/tmp/active-sessions.json"], + ["store path", TEST_SESSION_KEY, "/tmp/other-sessions.json"], + ])( + "rejects a structured successor outside the active %s", + async (_label, sessionKey, storePath) => { + resolveContextEngineMock.mockResolvedValue({ + info: { ownsCompaction: false }, + compact: contextEngineCompactMock, + } as never); + contextEngineCompactMock.mockResolvedValue({ + ok: true, + compacted: true, + result: { + sessionTarget: { + agentId: "main", + sessionId: "delegated-session", + sessionKey, + storePath, + }, + }, + } as never); + + await expect( + compactEmbeddedAgentSession( + wrappedCompactionArgs({ + sessionTarget: { + agentId: "main", + sessionId: TEST_SESSION_ID, + sessionKey: TEST_SESSION_KEY, + storePath: "/tmp/active-sessions.json", + }, + }), + ), + ).rejects.toThrow("successor target changed the active session binding"); + }, + ); + + it("rejects a deprecated session-key successor outside the active binding", async () => { + const delegatedSessionId = "delegated-key-session"; + const delegatedSessionKey = "agent:main:delegated-key-session"; + const dir = await mkdtemp(join(tmpdir(), "openclaw-compaction-successor-")); + const storePath = join(dir, "sessions.json"); + resolveContextEngineMock.mockResolvedValue({ + info: { ownsCompaction: false }, + compact: contextEngineCompactMock, + } as never); + contextEngineCompactMock.mockResolvedValue({ + ok: true, + compacted: true, + result: { + sessionFile: delegatedSessionKey, + }, + } as never); + try { + await upsertSessionEntry( + { + agentId: "main", + sessionKey: delegatedSessionKey, + storePath, + }, + { sessionId: delegatedSessionId, updatedAt: 1 }, + ); + + await expect( + compactEmbeddedAgentSession( + wrappedCompactionArgs({ + sessionTarget: { + agentId: "main", + sessionId: TEST_SESSION_ID, + sessionKey: TEST_SESSION_KEY, + storePath, + }, + }), + ), + ).rejects.toThrow("successor target changed the active session binding"); + } finally { + closeOpenClawAgentDatabasesForTest(); + await rm(dir, { force: true, recursive: true }); + } + }); + + it("rejects a deprecated session-key successor with a mismatched stored id", async () => { + const dir = await mkdtemp(join(tmpdir(), "openclaw-compaction-successor-mismatch-")); + const storePath = join(dir, "sessions.json"); + const delegatedSessionKey = "agent:main:delegated-key-mismatch"; + resolveContextEngineMock.mockResolvedValue({ + info: { ownsCompaction: false }, + compact: contextEngineCompactMock, + } as never); + contextEngineCompactMock.mockResolvedValue({ + ok: true, + compacted: true, + result: { + sessionFile: delegatedSessionKey, + sessionId: "reported-session", + }, + } as never); + try { + await upsertSessionEntry( + { agentId: "main", sessionKey: delegatedSessionKey, storePath }, + { sessionId: "stored-session", updatedAt: 1 }, + ); + + await expect( + compactEmbeddedAgentSession( + wrappedCompactionArgs({ + sessionTarget: { + agentId: "main", + sessionId: TEST_SESSION_ID, + sessionKey: TEST_SESSION_KEY, + storePath, + }, + }), + ), + ).rejects.toThrow("successor identity is inconsistent"); + } finally { + closeOpenClawAgentDatabasesForTest(); + await rm(dir, { force: true, recursive: true }); + } + }); + + it("preserves a deprecated SQLite marker successor for legacy maintenance", async () => { + const maintain = vi.fn(async (_params?: unknown) => ({ + changed: false, + bytesFreed: 0, + rewrittenEntries: 0, + })); + const delegatedSessionId = "delegated-marker-session"; + const storePath = "/tmp/sessions.json"; + const marker = `sqlite:main:${delegatedSessionId}:${storePath}`; + resolveContextEngineMock.mockResolvedValue({ + info: { ownsCompaction: false }, + compact: contextEngineCompactMock, + maintain, + } as never); + contextEngineCompactMock.mockResolvedValue({ + ok: true, + compacted: true, + result: { + sessionFile: marker, + sessionId: delegatedSessionId, + }, + } as never); + + await compactEmbeddedAgentSession(wrappedCompactionArgs()); + + expectRecordFields(mockCallArg(maintain), { + sessionFile: marker, + sessionId: delegatedSessionId, + sessionTarget: expect.objectContaining({ + sessionId: delegatedSessionId, + storePath, + }), + }); + }); + + it("rejects a marker successor that contradicts the reported session id", async () => { + resolveContextEngineMock.mockResolvedValue({ + info: { ownsCompaction: false }, + compact: contextEngineCompactMock, + } as never); + contextEngineCompactMock.mockResolvedValue({ + ok: true, + compacted: true, + result: { + sessionFile: "sqlite:main:marker-session:/tmp/sessions.json", + sessionId: TEST_SESSION_ID, + }, + } as never); + + await expect(compactEmbeddedAgentSession(wrappedCompactionArgs())).rejects.toThrow( + "successor identity is inconsistent", + ); + }); + + it("rebinds a deprecated SQLite marker successor over the retained active entry", async () => { + const maintain = vi.fn(async (_params?: unknown) => ({ + changed: false, + bytesFreed: 0, + rewrittenEntries: 0, + })); + const delegatedSessionId = "delegated-marker-session"; + const dir = await mkdtemp(join(tmpdir(), "openclaw-compaction-marker-successor-")); + const storePath = join(dir, "sessions.json"); + const marker = `sqlite:main:${delegatedSessionId}:${storePath}`; + resolveContextEngineMock.mockResolvedValue({ + info: { ownsCompaction: false }, + compact: contextEngineCompactMock, + maintain, + } as never); + contextEngineCompactMock.mockResolvedValue({ + ok: true, + compacted: true, + result: { + sessionFile: marker, + sessionId: delegatedSessionId, + }, + } as never); + try { + await upsertSessionEntry( + { agentId: "main", sessionKey: TEST_SESSION_KEY, storePath }, + { sessionId: TEST_SESSION_ID, updatedAt: 1 }, + ); + + await compactEmbeddedAgentSession( + wrappedCompactionArgs({ + sessionTarget: { + agentId: "main", + sessionId: TEST_SESSION_ID, + sessionKey: TEST_SESSION_KEY, + storePath, + }, + }), + ); + + expectRecordFields(mockCallArg(maintain), { + sessionId: delegatedSessionId, + sessionTarget: expect.objectContaining({ + agentId: "main", + sessionId: delegatedSessionId, + sessionKey: TEST_SESSION_KEY, + storePath, + }), + }); + } finally { + closeOpenClawAgentDatabasesForTest(); + await rm(dir, { force: true, recursive: true }); + } + }); + + it("rejects conflicting structured successor session ids", async () => { + resolveContextEngineMock.mockResolvedValue({ + info: { ownsCompaction: false }, + compact: contextEngineCompactMock, + } as never); + contextEngineCompactMock.mockResolvedValue({ + ok: true, + compacted: true, + result: { + sessionId: "top-level-session", + sessionTarget: { + agentId: "main", + sessionId: "target-session", + sessionKey: TEST_SESSION_KEY, + storePath: "/tmp/sessions.json", + }, + }, + } as never); + + await expect(compactEmbeddedAgentSession(wrappedCompactionArgs())).rejects.toThrow( + "successor identity is inconsistent", + ); + }); + + it("derives queued compaction ownership from a self-contained session target", async () => { + await compactEmbeddedAgentSession( + wrappedCompactionArgs({ + agentId: undefined, + sessionKey: undefined, + sessionTarget: { + agentId: "other", + sessionId: "other-session", + sessionKey: "agent:other:main", + storePath: "/tmp/other-sessions.json", + }, + }), + ); + + expect(resolveSessionAgentIdsMock).toHaveBeenCalledWith( + expect.objectContaining({ agentId: "other", sessionKey: "agent:other:main" }), + ); + }); + it("keeps a delegated result that echoes the current transcript on the active transcript", async () => { const maintain = vi.fn(async (_params?: unknown) => ({ changed: false, @@ -4287,7 +4563,7 @@ describe("compactEmbeddedAgentSession hooks (ownsCompaction engine)", () => { expect(result.result?.sessionFile).toBeUndefined(); expectRecordFields(mockCallArg(maintain), { sessionId: TEST_SESSION_ID, - sessionFile: TEST_SESSION_FILE, + sessionFile: TEST_SESSION_KEY, }); }); diff --git a/src/agents/embedded-agent-runner/compact.queued.ts b/src/agents/embedded-agent-runner/compact.queued.ts index ecb73804b251..4595ec392fce 100644 --- a/src/agents/embedded-agent-runner/compact.queued.ts +++ b/src/agents/embedded-agent-runner/compact.queued.ts @@ -1,7 +1,12 @@ /** * Queues embedded-agent session compaction onto the correct command lane. */ -import { parseSqliteSessionFileMarker } from "../../config/sessions/sqlite-marker.js"; +import path from "node:path"; +import { + formatSqliteSessionFileMarker, + parseSqliteSessionFileMarker, +} from "../../config/sessions/legacy-sqlite-marker.js"; +import { listSessionEntries, loadSessionEntry } from "../../config/sessions/session-accessor.js"; import { OPENCLAW_EMBEDDED_CONTEXT_ENGINE_HOST } from "../../context-engine/host-compat.js"; import { ensureContextEnginesInitialized } from "../../context-engine/init.js"; import { @@ -21,6 +26,8 @@ import { formatErrorMessage } from "../../infra/errors.js"; import { getGlobalHookRunner } from "../../plugins/hook-runner-global.js"; import type { ProviderRuntimeModel } from "../../plugins/provider-runtime-model.types.js"; import { enqueueCommandInLane } from "../../process/command-queue.js"; +import { resolveAgentIdFromSessionKey } from "../../routing/session-key.js"; +import { resolvePreferredSessionKeyForSessionIdMatches } from "../../sessions/session-id-resolution.js"; import { resolveUserPath } from "../../utils.js"; import { normalizeOptionalAgentRuntimeId } from "../agent-runtime-id.js"; import { resolveAgentDir, resolveSessionAgentIds } from "../agent-scope.js"; @@ -48,10 +55,6 @@ import { compactContextEngineWithSafetyTimeout, resolveCompactionTimeoutMs, } from "./compaction-safety-timeout.js"; -import { - rotateTranscriptFileAfterCompaction, - shouldRotateCompactionTranscript, -} from "./compaction-successor-transcript.js"; import { resolveContextEngineCapabilities } from "./context-engine-capabilities.js"; import { runContextEngineMaintenance } from "./context-engine-maintenance.js"; import { resolveGlobalLane, resolveSessionLane } from "./lanes.js"; @@ -128,13 +131,12 @@ function shouldDeferOwningContextEngineBudgetCompaction(params: { function buildContextEngineCompactionSessionTarget( params: CompactEmbeddedAgentSessionParams, ): ContextEngineSessionTarget { - const sqliteMarker = parseSqliteSessionFileMarker(params.sessionFile); - const agentId = params.sessionTarget?.agentId ?? params.agentId ?? sqliteMarker?.agentId; + const agentId = params.sessionTarget?.agentId ?? params.agentId; const sessionKey = params.sessionTarget?.sessionKey ?? params.sessionKey ?? params.sessionId; - const storePath = params.sessionTarget?.storePath ?? sqliteMarker?.storePath; + const storePath = params.sessionTarget?.storePath; return { ...(agentId ? { agentId } : {}), - sessionId: params.sessionTarget?.sessionId ?? sqliteMarker?.sessionId ?? params.sessionId, + sessionId: params.sessionTarget?.sessionId ?? params.sessionId, ...(sessionKey ? { sessionKey } : {}), ...(storePath ? { storePath } : {}), ...(params.sessionTarget?.threadId !== undefined @@ -240,12 +242,21 @@ function mergeSecondaryNativeHarnessCompactionDetails(params: { export async function compactEmbeddedAgentSession( params: CompactEmbeddedAgentSessionParams, ): Promise { - if (params.trigger !== "manual") { - return await compactEmbeddedAgentSessionImpl(params); + const runtimeTarget = await resolveAgentRunSessionTarget(params); + const resolvedParams = { + ...params, + agentId: runtimeTarget.agentId, + sessionId: runtimeTarget.sessionId, + sessionKey: runtimeTarget.sessionKey, + sessionTarget: runtimeTarget, + sessionFile: runtimeTarget.sessionKey, + }; + if (resolvedParams.trigger !== "manual") { + return await compactEmbeddedAgentSessionImpl(resolvedParams); } // Reply operations and embedded handles are separate lifecycle owners. A // /compact reply may coexist with this handle, but another embedded writer may not. - if (resolveManualCompactionActiveRunSessionId(params)) { + if (resolveManualCompactionActiveRunSessionId(resolvedParams)) { return { ok: false, compacted: false, @@ -255,8 +266,8 @@ export async function compactEmbeddedAgentSession( } const controller = new AbortController(); - const abortSignal = params.abortSignal - ? AbortSignal.any([params.abortSignal, controller.signal]) + const abortSignal = resolvedParams.abortSignal + ? AbortSignal.any([resolvedParams.abortSignal, controller.signal]) : controller.signal; const handle: EmbeddedAgentQueueHandle = { kind: "embedded", @@ -267,34 +278,53 @@ export async function compactEmbeddedAgentSession( abort: (reason) => controller.abort(reason ?? "user_abort"), cancel: (reason) => controller.abort(reason ?? "user_abort"), }; - setActiveEmbeddedRun(params.sessionId, handle, params.sessionKey, params.sessionFile); + setActiveEmbeddedRun( + resolvedParams.sessionId, + handle, + resolvedParams.sessionKey, + resolvedParams.sessionFile, + ); try { return await compactEmbeddedAgentSessionImpl({ - ...params, + ...resolvedParams, abortSignal, }); } finally { - clearActiveEmbeddedRun(params.sessionId, handle, params.sessionKey, params.sessionFile); + clearActiveEmbeddedRun( + resolvedParams.sessionId, + handle, + resolvedParams.sessionKey, + resolvedParams.sessionFile, + ); } } async function compactEmbeddedAgentSessionImpl( - params: CompactEmbeddedAgentSessionParams, + inputParams: CompactEmbeddedAgentSessionParams, ): Promise { - if (params.abortSignal?.aborted) { + if (inputParams.abortSignal?.aborted) { return createCompactionAbortedResult(); } ensureRuntimePluginsLoaded({ - config: params.config, - workspaceDir: params.workspaceDir, - allowGatewaySubagentBinding: params.allowGatewaySubagentBinding, + config: inputParams.config, + workspaceDir: inputParams.workspaceDir, + allowGatewaySubagentBinding: inputParams.allowGatewaySubagentBinding, }); ensureContextEnginesInitialized(); + const runtimeTarget = await resolveAgentRunSessionTarget(inputParams); const agentIds = resolveSessionAgentIds({ - sessionKey: params.sessionKey, - config: params.config, - agentId: params.agentId, + sessionKey: runtimeTarget.sessionKey, + config: inputParams.config, + agentId: runtimeTarget.agentId, }); + const params = { + ...inputParams, + agentId: runtimeTarget.agentId, + sessionId: runtimeTarget.sessionId, + sessionKey: runtimeTarget.sessionKey, + sessionTarget: runtimeTarget, + sessionFile: runtimeTarget.sessionKey, + }; const agentDir = params.agentDir ?? resolveAgentDir(params.config ?? {}, agentIds.sessionAgentId); const resolvedWorkspaceDir = resolveUserPath(params.workspaceDir); const contextEngine = await resolveContextEngine(params.config, { @@ -328,6 +358,7 @@ async function compactResolvedContextEngine( resolvedWorkspaceDir: string, releaseContextEngineOwnership: () => void, ): Promise { + const runtimeTarget = await resolveAgentRunSessionTarget(params); const lockedHarnessRuntime = params.modelSelectionLocked === true ? normalizeOptionalAgentRuntimeId(params.agentHarnessId) @@ -543,13 +574,11 @@ async function compactResolvedContextEngine( // Fire before_compaction / after_compaction hooks here so plugin subscribers // are notified regardless of which engine is active. const engineOwnsCompaction = contextEngine.info.ownsCompaction === true; - const isSqliteSessionTranscript = Boolean(parseSqliteSessionFileMarker(params.sessionFile)); checkpointSnapshot = engineOwnsCompaction ? await compactionCheckpointStore.captureSnapshot({ sessionFile: params.sessionFile, - ...(isSqliteSessionTranscript - ? { sessionManager: SessionManager.open(params.sessionFile) } - : {}), + sessionManager: SessionManager.open(runtimeTarget), + sessionTarget: runtimeTarget, }) : null; const hookRunner = engineOwnsCompaction @@ -641,54 +670,154 @@ async function compactResolvedContextEngine( reason: formatErrorMessage(compactErr), }; } + const reportedSessionId = result.result?.sessionId; const delegatedSuccessor = resolveCompactionSuccessorTranscript(result); const delegatedSessionTarget = result.result?.sessionTarget; const delegatedSessionId = delegatedSuccessor.sessionId; const delegatedSessionFile = delegatedSuccessor.sessionFile; - const delegatedRotatedTranscript = - (typeof delegatedSessionId === "string" && delegatedSessionId !== params.sessionId) || - (typeof delegatedSessionFile === "string" && delegatedSessionFile !== params.sessionFile); let postCompactionSessionId = delegatedSessionId ?? params.sessionId; // Shipped pre-sessionTarget engines report rotation via the deprecated // sessionFile field; honor it when no typed target is present. let postCompactionSessionFile = delegatedSessionFile ?? params.sessionFile; + let postCompactionSessionTarget = runtimeTarget; if (delegatedSessionTarget) { + if ( + reportedSessionId && + delegatedSessionTarget.sessionId && + delegatedSessionTarget.sessionId !== reportedSessionId + ) { + throw new Error("Context-engine successor identity is inconsistent"); + } const resolvedDelegatedTarget = await resolveAgentRunSessionTarget({ agentId: delegatedSessionTarget.agentId ?? sessionAgentId, config: params.config, sessionId: delegatedSessionTarget.sessionId ?? postCompactionSessionId, + sessionFile: delegatedSessionFile, sessionKey: delegatedSessionTarget.sessionKey ?? params.sessionKey, - sessionTarget: delegatedSessionTarget, + sessionTarget: { + ...delegatedSessionTarget, + storePath: delegatedSessionTarget.storePath ?? runtimeTarget.storePath, + }, }); - postCompactionSessionId = resolvedDelegatedTarget.sessionId; - postCompactionSessionFile = resolvedDelegatedTarget.sessionFile; - } - let postCompactionLeafId: string | undefined; - if (result.ok && result.compacted) { if ( - shouldRotateCompactionTranscript(params.config) && - !delegatedRotatedTranscript && - !isSqliteSessionTranscript + resolvedDelegatedTarget.agentId !== runtimeTarget.agentId || + resolvedDelegatedTarget.sessionKey !== runtimeTarget.sessionKey || + path.resolve(resolvedDelegatedTarget.storePath) !== + path.resolve(runtimeTarget.storePath) ) { - try { - const rotation = await rotateTranscriptFileAfterCompaction({ - sessionFile: params.sessionFile, - }); - if (rotation.rotated) { - postCompactionSessionId = rotation.sessionId ?? postCompactionSessionId; - postCompactionSessionFile = rotation.sessionFile ?? postCompactionSessionFile; - postCompactionLeafId = rotation.leafId; - log.info( - `[compaction] rotated active transcript after context-engine compaction ` + - `(sessionKey=${params.sessionKey ?? params.sessionId})`, - ); - } - } catch (err) { - log.warn("failed to rotate compacted transcript", { - errorMessage: formatErrorMessage(err), - }); - } + throw new Error("Context-engine successor target changed the active session binding"); } + postCompactionSessionId = resolvedDelegatedTarget.sessionId; + postCompactionSessionFile = resolvedDelegatedTarget.sessionKey; + postCompactionSessionTarget = resolvedDelegatedTarget; + } else if (delegatedSessionFile) { + const marker = parseSqliteSessionFileMarker(delegatedSessionFile); + if ( + marker && + (marker.agentId !== runtimeTarget.agentId || + (delegatedSessionId && marker.sessionId !== delegatedSessionId)) + ) { + throw new Error("Legacy context-engine successor identity is inconsistent"); + } + const keyedEntry = delegatedSessionFile.startsWith("agent:") + ? loadSessionEntry({ + agentId: runtimeTarget.agentId, + sessionKey: delegatedSessionFile, + storePath: runtimeTarget.storePath, + }) + : undefined; + if ( + delegatedSessionFile.startsWith("agent:") && + (resolveAgentIdFromSessionKey(delegatedSessionFile) !== runtimeTarget.agentId || + !keyedEntry?.sessionId || + (delegatedSessionId && keyedEntry.sessionId !== delegatedSessionId)) + ) { + throw new Error("Legacy context-engine successor identity is inconsistent"); + } + const keyedSessionId = delegatedSessionFile.startsWith("agent:") + ? (delegatedSessionId ?? keyedEntry?.sessionId) + : undefined; + const retainedMarkerEntry = marker + ? loadSessionEntry({ + agentId: marker.agentId, + sessionKey: runtimeTarget.sessionKey, + storePath: marker.storePath, + }) + : undefined; + const markerMatches = marker + ? listSessionEntries({ + agentId: marker.agentId, + storePath: marker.storePath, + }).filter(({ entry }) => entry.sessionId === marker.sessionId) + : []; + const preferredMarkerSessionKey = marker + ? resolvePreferredSessionKeyForSessionIdMatches( + markerMatches.map(({ sessionKey, entry }) => [sessionKey, entry]), + marker.sessionId, + ) + : undefined; + const markerMappedToRetainedKey = markerMatches.some( + ({ sessionKey }) => sessionKey === runtimeTarget.sessionKey, + ); + const markerSessionKey = marker + ? retainedMarkerEntry?.sessionId === marker.sessionId || + (retainedMarkerEntry?.sessionId === runtimeTarget.sessionId && + (markerMatches.length === 0 || markerMappedToRetainedKey)) + ? runtimeTarget.sessionKey + : (preferredMarkerSessionKey ?? + (markerMatches.length === 0 && !retainedMarkerEntry + ? runtimeTarget.sessionKey + : undefined)) + : undefined; + const legacyTarget = marker + ? markerSessionKey + ? { + ...marker, + sessionId: marker.sessionId, + sessionKey: markerSessionKey, + } + : undefined + : keyedSessionId + ? { + ...runtimeTarget, + sessionId: keyedSessionId, + sessionKey: delegatedSessionFile, + } + : undefined; + if (!legacyTarget) { + throw new Error( + "Legacy context-engine successor files are unsupported; return a structured sessionTarget", + ); + } + const resolvedDelegatedTarget = await resolveAgentRunSessionTarget({ + agentId: legacyTarget.agentId, + config: params.config, + sessionId: legacyTarget.sessionId, + sessionKey: legacyTarget.sessionKey, + sessionTarget: legacyTarget, + }); + if ( + resolvedDelegatedTarget.agentId !== runtimeTarget.agentId || + resolvedDelegatedTarget.sessionKey !== runtimeTarget.sessionKey || + path.resolve(resolvedDelegatedTarget.storePath) !== + path.resolve(runtimeTarget.storePath) + ) { + throw new Error( + "Legacy context-engine successor target changed the active session binding", + ); + } + postCompactionSessionId = resolvedDelegatedTarget.sessionId; + postCompactionSessionFile = marker + ? formatSqliteSessionFileMarker(resolvedDelegatedTarget) + : resolvedDelegatedTarget.sessionKey; + postCompactionSessionTarget = resolvedDelegatedTarget; + } else if (delegatedSessionId && !delegatedSessionFile) { + postCompactionSessionTarget = { + ...runtimeTarget, + sessionId: delegatedSessionId, + }; + } + if (result.ok && result.compacted) { checkpointSnapshotRetained = await persistCompactionCheckpoint({ config: params.config, sessionKey: params.sessionKey, @@ -700,7 +829,7 @@ async function compactResolvedContextEngine( tokensBefore: result.result?.tokensBefore, tokensAfter: result.result?.tokensAfter, sessionFile: postCompactionSessionFile, - leafId: postCompactionLeafId, + sessionTarget: postCompactionSessionTarget, }); await runContextEngineMaintenance({ contextEngine, @@ -710,7 +839,7 @@ async function compactResolvedContextEngine( ...params, sessionFile: postCompactionSessionFile, sessionId: postCompactionSessionId, - sessionTarget: delegatedSessionTarget ?? params.sessionTarget, + sessionTarget: postCompactionSessionTarget, }), sessionFile: postCompactionSessionFile, reason: "compaction", diff --git a/src/agents/embedded-agent-runner/compact.ts b/src/agents/embedded-agent-runner/compact.ts index 733457c93650..16732cb97f6a 100644 --- a/src/agents/embedded-agent-runner/compact.ts +++ b/src/agents/embedded-agent-runner/compact.ts @@ -44,7 +44,6 @@ import { resolveEmbeddedCompactionTarget } from "./compaction-runtime-context.js import { prepareCompactionSessionAgent } from "./compaction-session-agent.js"; import type { PreparedCompactEmbeddedAgentSessionParams } from "./direct-compaction-preparation.js"; import { compactEmbeddedAgentSessionDirectOnce } from "./direct-compaction.js"; -import { hardenManualCompactionBoundary } from "./manual-compaction-boundary.js"; import type { EmbeddedAgentCompactResult } from "./types.js"; export type { CompactEmbeddedAgentSessionParams } from "./compact.types.js"; @@ -129,10 +128,11 @@ export async function compactEmbeddedAgentSessionDirect( const runSessionTarget = await resolveAgentRunSessionTarget(paramsBase); const requestedParams: CompactEmbeddedAgentSessionParamsWithSessionFile = { ...paramsBase, - agentId: paramsBase.agentId ?? runSessionTarget.agentId, + agentId: runSessionTarget.agentId, sessionId: runSessionTarget.sessionId, - sessionKey: paramsBase.sessionKey ?? runSessionTarget.sessionKey, - sessionFile: runSessionTarget.sessionFile, + sessionKey: runSessionTarget.sessionKey, + sessionTarget: runSessionTarget, + sessionFile: runSessionTarget.sessionKey, }; const requestedAgentIds = resolveSessionAgentIds({ sessionKey: requestedParams.sessionKey, @@ -256,7 +256,6 @@ export const testing = { containsRealConversationMessages, estimateTokensAfterCompaction, buildBeforeCompactionHookMetrics, - hardenManualCompactionBoundary, resolveCompactionProviderStream, prepareCompactionSessionAgent, runBeforeCompactionHooks, diff --git a/src/agents/embedded-agent-runner/compaction-checkpoint.ts b/src/agents/embedded-agent-runner/compaction-checkpoint.ts index 3337bf46263d..9d675701c1fa 100644 --- a/src/agents/embedded-agent-runner/compaction-checkpoint.ts +++ b/src/agents/embedded-agent-runner/compaction-checkpoint.ts @@ -1,3 +1,5 @@ +import { formatSqliteSessionFileMarker } from "../../config/sessions/legacy-sqlite-marker.js"; +import type { SessionTranscriptRuntimeTarget } from "../../config/sessions/session-accessor.types.js"; /** Owns the shared checkpoint lifecycle around both compaction entry points. */ import type { OpenClawConfig } from "../../config/types.openclaw.js"; import { @@ -23,6 +25,7 @@ export async function persistCompactionCheckpoint(params: { tokensBefore?: number; tokensAfter?: number; sessionFile: string; + sessionTarget?: SessionTranscriptRuntimeTarget; leafId?: string; createdAt?: number; }): Promise { @@ -30,7 +33,9 @@ export async function persistCompactionCheckpoint(params: { return false; } try { - const transcriptState = await readSessionLeafStateFromTranscriptAsync(params.sessionFile); + const transcriptState = await readSessionLeafStateFromTranscriptAsync( + params.sessionTarget ?? params.sessionFile, + ); const checkpointPosition = resolveCompactionCheckpointTranscriptPosition({ preferredLeafId: params.leafId, transcriptState, @@ -45,7 +50,10 @@ export async function persistCompactionCheckpoint(params: { firstKeptEntryId: params.firstKeptEntryId, tokensBefore: params.tokensBefore, tokensAfter: params.tokensAfter, - postSessionFile: params.sessionFile, + // Keep the full successor location for cross-key/store checkpoint recovery. + postSessionFile: params.sessionTarget + ? formatSqliteSessionFileMarker(params.sessionTarget) + : params.sessionFile, postLeafId: checkpointPosition.leafId, postEntryId: checkpointPosition.entryId, createdAt: params.createdAt, diff --git a/src/agents/embedded-agent-runner/compaction-duplicate-user-messages.test.ts b/src/agents/embedded-agent-runner/compaction-duplicate-user-messages.test.ts index bda01cf7867a..459e3aa9048e 100644 --- a/src/agents/embedded-agent-runner/compaction-duplicate-user-messages.test.ts +++ b/src/agents/embedded-agent-runner/compaction-duplicate-user-messages.test.ts @@ -1,9 +1,6 @@ // Regression coverage for pruning duplicate user turns before compaction. import { describe, expect, it } from "vitest"; -import { - collectDuplicateUserMessageEntryIdsForCompaction, - dedupeDuplicateUserMessagesForCompaction, -} from "./compaction-duplicate-user-messages.js"; +import { dedupeDuplicateUserMessagesForCompaction } from "./compaction-duplicate-user-messages.js"; const LONG_PROMPT = "please run the deployment status check for production"; @@ -65,31 +62,6 @@ describe("compaction duplicate user message pruning", () => { expect(dedupeDuplicateUserMessagesForCompaction([long, longLater])).toEqual([long, longLater]); }); - it("collects duplicate transcript entry ids from active branch entries", () => { - const duplicateIds = collectDuplicateUserMessageEntryIdsForCompaction([ - { - id: "entry-1", - type: "message", - message: { - role: "user", - content: "please run the deployment status check for production", - timestamp: 1_000, - }, - }, - { - id: "entry-2", - type: "message", - message: { - role: "user", - content: "please run the deployment status check for production", - timestamp: 2_000, - }, - }, - ]); - - expect(duplicateIds).toEqual(new Set(["entry-2"])); - }); - it("keys duplicate retries by sender identity (#98310)", () => { const alice = userMessage({ timestamp: 1_000, senderId: "user-alice" }); const bob = userMessage({ timestamp: 2_000, senderId: "user-bob" }); @@ -101,17 +73,6 @@ describe("compaction duplicate user message pruning", () => { ]); }); - it("keeps same text from different senders in successor transcript pruning", () => { - const alice = userMessage({ timestamp: 1_000, senderId: "user-alice" }); - const bob = userMessage({ timestamp: 2_000, senderId: "user-bob" }); - const duplicateIds = collectDuplicateUserMessageEntryIdsForCompaction([ - { id: "entry-alice", type: "message", message: alice }, - { id: "entry-bob", type: "message", message: bob }, - ]); - - expect(duplicateIds).toEqual(new Set()); - }); - it("does not collide when sender ids and text contain the old delimiter", () => { const first = userMessage({ content: "b|please run deployment status now", diff --git a/src/agents/embedded-agent-runner/compaction-duplicate-user-messages.ts b/src/agents/embedded-agent-runner/compaction-duplicate-user-messages.ts index ca9ace5db9d0..8a45b6643a00 100644 --- a/src/agents/embedded-agent-runner/compaction-duplicate-user-messages.ts +++ b/src/agents/embedded-agent-runner/compaction-duplicate-user-messages.ts @@ -13,12 +13,6 @@ type MessageLike = { __openclaw?: unknown; }; -type EntryLike = { - id?: unknown; - type?: unknown; - message?: unknown; -}; - type DuplicateUserMessageOptions = { windowMs?: number; }; @@ -91,30 +85,3 @@ export function dedupeDuplicateUserMessagesForCompaction( } return removed > 0 ? result : [...messages]; } - -/** Collects session entry ids that should be skipped when building a compaction branch summary. */ -export function collectDuplicateUserMessageEntryIdsForCompaction( - entries: readonly EntryLike[], - options: DuplicateUserMessageOptions = {}, -): Set { - const windowMs = options.windowMs ?? DEFAULT_DUPLICATE_USER_MESSAGE_WINDOW_MS; - const lastSeenAtByKey = new Map(); - const duplicateIds = new Set(); - for (const entry of entries) { - if (entry.type !== "message" || typeof entry.id !== "string") { - continue; - } - const signature = duplicateSignature( - isRecord(entry.message) ? (entry.message as MessageLike) : undefined, - ); - if (!signature) { - continue; - } - const lastSeenAt = lastSeenAtByKey.get(signature.key); - lastSeenAtByKey.set(signature.key, signature.timestamp); - if (typeof lastSeenAt === "number" && signature.timestamp - lastSeenAt <= windowMs) { - duplicateIds.add(entry.id); - } - } - return duplicateIds; -} diff --git a/src/agents/embedded-agent-runner/compaction-runtime-context.test.ts b/src/agents/embedded-agent-runner/compaction-runtime-context.test.ts index f6cac384c6ef..9365237dcbe8 100644 --- a/src/agents/embedded-agent-runner/compaction-runtime-context.test.ts +++ b/src/agents/embedded-agent-runner/compaction-runtime-context.test.ts @@ -1,6 +1,9 @@ // Coverage for building compaction runtime context from active runner state. +import path from "node:path"; import { afterEach, describe, expect, it, vi } from "vitest"; +import { useAutoCleanupTempDirTracker } from "../../../test/helpers/temp-dir.js"; import type { OpenClawConfig } from "../../config/config.js"; +import { formatSqliteSessionFileMarker } from "../../config/sessions/legacy-sqlite-marker.js"; import { addSession } from "../bash-process-registry.js"; import { createProcessSessionFixture } from "../bash-process-registry.test-helpers.js"; import { resetProcessRegistryForTests } from "../bash-process-registry.test-support.js"; @@ -10,6 +13,9 @@ import { resolveEmbeddedCompactionThinkingLevel, resolveEmbeddedCompactionTarget, } from "./compaction-runtime-context.js"; +import { buildContextEngineCompactionSessionTarget } from "./run/session-bootstrap.js"; + +const compactionTempDirs = useAutoCleanupTempDirTracker(afterEach); describe("resolveEmbeddedCompactionThinkingLevel", () => { it("lets the compaction override replace the inherited session level", () => { @@ -745,3 +751,32 @@ describe("buildEmbeddedCompactionRuntimeContext", () => { expect(result.provider).toBe("anthropic"); }); }); + +describe("buildContextEngineCompactionSessionTarget", () => { + it("derives the agent from a scoped session key", () => { + expect( + buildContextEngineCompactionSessionTarget({ + config: { session: { store: "/tmp/agents/{agentId}/sessions.json" } }, + sessionFile: "agent:helper:main", + sessionId: "helper-session", + sessionKey: "agent:helper:main", + }), + ).toMatchObject({ + agentId: "helper", + sessionKey: "agent:helper:main", + storePath: "/tmp/agents/helper/sessions.json", + }); + }); + + it("uses the marker session id when its store has no mapped key yet", () => { + const storePath = path.join(compactionTempDirs.make("compaction-marker-"), "sessions.json"); + const sessionId = "legacy-unmapped-session"; + + expect( + buildContextEngineCompactionSessionTarget({ + sessionFile: formatSqliteSessionFileMarker({ agentId: "main", sessionId, storePath }), + sessionId, + }), + ).toMatchObject({ agentId: "main", sessionId, sessionKey: sessionId, storePath }); + }); +}); diff --git a/src/agents/embedded-agent-runner/compaction-session-execution.ts b/src/agents/embedded-agent-runner/compaction-session-execution.ts index dde82f9cbba6..42a332165076 100644 --- a/src/agents/embedded-agent-runner/compaction-session-execution.ts +++ b/src/agents/embedded-agent-runner/compaction-session-execution.ts @@ -1,3 +1,4 @@ +import { formatSqliteSessionFileMarker } from "../../config/sessions/legacy-sqlite-marker.js"; import { acquireOwnedSessionTranscriptWriteLock } from "../../config/sessions/transcript-write-context.js"; /** * Executes compaction while owning the transcript lock, session lifecycle, @@ -19,12 +20,13 @@ import { isSilentOverflowProneModel, } from "../agent-settings.js"; import { pickFallbackThinkingLevel } from "../embedded-agent-helpers.js"; -import { repairSessionFileIfNeeded } from "../session-file-repair.js"; +import { resolveAgentRunSessionTarget } from "../run-session-target.js"; import { guardSessionManager } from "../session-tool-result-guard-wrapper.js"; import { sanitizeToolUseResultPairing } from "../session-transcript-repair.js"; import { acquireSessionWriteLock, resolveSessionLockMaxHoldFromTimeout, + resolveSessionWriteLockTargetKey, resolveSessionWriteLockOptions, } from "../session-write-lock.js"; import { createAgentSession, estimateTokens, SessionManager } from "../sessions/index.js"; @@ -51,24 +53,16 @@ import { resolveCompactionTimeoutMs, } from "./compaction-safety-timeout.js"; import { prepareCompactionSessionAgent } from "./compaction-session-agent.js"; -import { - type CompactionTranscriptRotation, - rotateTranscriptAfterCompaction, - shouldRotateCompactionTranscript, -} from "./compaction-successor-transcript.js"; import { buildEmbeddedExtensionFactories } from "./extensions.js"; import { getHistoryLimitFromSessionKey, limitHistoryTurns } from "./history.js"; import { log } from "./logger.js"; -import { hardenManualCompactionBoundary } from "./manual-compaction-boundary.js"; import type { PreparedCompactionRuntime } from "./prepared-compaction-runtime.js"; import { sanitizeSessionHistory, validateReplayTurns } from "./replay-history.js"; import { createEmbeddedAgentResourceLoader } from "./resource-loader.js"; import { wrapStreamFnWithDiagnosticModelCallEvents } from "./run/attempt.model-diagnostic-events.js"; -import { prewarmSessionFile, trackSessionManagerAccess } from "./session-manager-cache.js"; import { applySystemPromptToSession } from "./system-prompt.js"; import { collectRegisteredToolNames, toSessionToolAllowlist } from "./tool-name-allowlist.js"; import { splitSdkTools } from "./tool-split.js"; -import { readTranscriptFileState } from "./transcript-file-state.js"; import { mapThinkingLevel } from "./utils.js"; import { flushPendingToolResultsAfterIdle } from "./wait-for-idle-before-flush.js"; @@ -96,7 +90,6 @@ export async function executePreparedCompactionSession(runtime: PreparedCompacti sandbox, effectiveWorkspace, effectiveCwd, - isSqliteSessionTranscript, contextTokenBudget, effectiveModel, runtimePlan, @@ -115,13 +108,23 @@ export async function executePreparedCompactionSession(runtime: PreparedCompacti try { const compactionTimeoutMs = resolveCompactionTimeoutMs(params.config); + const sessionTarget = await resolveAgentRunSessionTarget({ + agentId: sessionAgentId, + config: params.config, + sessionFile: params.sessionFile, + sessionId: params.sessionId, + sessionKey: params.sessionKey, + sessionTarget: params.sessionTarget, + }); const sessionLock = (await acquireOwnedSessionTranscriptWriteLock({ sessionFile: params.sessionFile, sessionKey: params.sessionKey, + sessionTarget, })) ?? (await acquireSessionWriteLock({ - sessionFile: params.sessionFile, + sessionFile: resolveSessionWriteLockTargetKey(sessionTarget), + targetKind: "session-key", ...resolveSessionWriteLockOptions(params.config, { maxHoldMsFallback: resolveSessionLockMaxHoldFromTimeout({ timeoutMs: compactionTimeoutMs, @@ -129,16 +132,8 @@ export async function executePreparedCompactionSession(runtime: PreparedCompacti }), })); try { - if (!isSqliteSessionTranscript) { - await repairSessionFileIfNeeded({ - sessionFile: params.sessionFile, - debug: (message) => log.debug(message), - warn: (message) => log.warn(message), - }); - await prewarmSessionFile(params.sessionFile); - } const transcriptPolicy = runtimePlan.transcript.resolvePolicy(runtimePlanModelContext); - const sessionManager = guardSessionManager(SessionManager.open(params.sessionFile), { + const sessionManager = guardSessionManager(SessionManager.open(sessionTarget), { agentId: sessionAgentId, sessionKey: params.sessionKey, config: params.config, @@ -155,9 +150,9 @@ export async function executePreparedCompactionSession(runtime: PreparedCompacti checkpointSnapshot = await compactionCheckpointStore.captureSnapshot({ sessionManager, sessionFile: params.sessionFile, + sessionTarget, }); compactionSessionManager = sessionManager; - trackSessionManagerAccess(params.sessionFile); const settingsManager = createPreparedEmbeddedAgentSettingsManager({ cwd: effectiveCwd, agentDir, @@ -435,36 +430,11 @@ export async function executePreparedCompactionSession(runtime: PreparedCompacti }, }, ); - let effectiveFirstKeptEntryId = result.firstKeptEntryId; - let postCompactionLeafId = + const effectiveFirstKeptEntryId = result.firstKeptEntryId; + const postCompactionLeafId = typeof sessionManager.getLeafId === "function" ? (sessionManager.getLeafId() ?? undefined) : undefined; - let transcriptRotationSessionManager: Parameters< - typeof rotateTranscriptAfterCompaction - >[0]["sessionManager"] = sessionManager; - if (params.trigger === "manual" && !isSqliteSessionTranscript) { - try { - const hardenedBoundary = await hardenManualCompactionBoundary({ - sessionFile: params.sessionFile, - preserveRecentTail: - typeof params.config?.agents?.defaults?.compaction?.keepRecentTokens === "number", - }); - if (hardenedBoundary.applied) { - effectiveFirstKeptEntryId = - hardenedBoundary.firstKeptEntryId ?? effectiveFirstKeptEntryId; - postCompactionLeafId = hardenedBoundary.leafId ?? postCompactionLeafId; - session.agent.state.messages = hardenedBoundary.messages; - transcriptRotationSessionManager = await readTranscriptFileState( - params.sessionFile, - ); - } - } catch (err) { - log.warn("[compaction] failed to harden manual compaction boundary", { - errorMessage: formatErrorMessage(err), - }); - } - } // Estimate tokens after compaction by summing token estimates for remaining messages const tokensAfter = estimateTokensAfterCompaction({ messagesAfter: session.messages, @@ -474,29 +444,12 @@ export async function executePreparedCompactionSession(runtime: PreparedCompacti }); const messageCountAfter = session.messages.length; const compactedCount = Math.max(0, messageCountCompactionInput - messageCountAfter); - let transcriptRotation: CompactionTranscriptRotation = { rotated: false }; - if (shouldRotateCompactionTranscript(params.config) && !isSqliteSessionTranscript) { - try { - transcriptRotation = await rotateTranscriptAfterCompaction({ - sessionManager: transcriptRotationSessionManager, - sessionFile: params.sessionFile, - }); - } catch (err) { - log.warn("[compaction] post-compaction transcript rotation failed", { - errorMessage: formatErrorMessage(err), - errorStack: err instanceof Error ? err.stack : undefined, - }); - } - } - const activeSessionId = transcriptRotation.sessionId ?? params.sessionId; - const activeSessionFile = transcriptRotation.sessionFile ?? params.sessionFile; - const activePostLeafId = transcriptRotation.leafId ?? postCompactionLeafId; - if (transcriptRotation.rotated) { - log.info( - `[compaction] rotated active transcript after compaction ` + - `(sessionKey=${params.sessionKey ?? params.sessionId})`, - ); - } + const activeSessionId = params.sessionId; + const activeSessionFile = formatSqliteSessionFileMarker({ + ...sessionTarget, + sessionId: activeSessionId, + }); + const activePostLeafId = postCompactionLeafId; await runPostCompactionSideEffects({ config: params.config, sessionKey: params.sessionKey, @@ -564,8 +517,8 @@ export async function executePreparedCompactionSession(runtime: PreparedCompacti tokensBefore: observedTokenCount ?? result.tokensBefore, tokensAfter, details: result.details, - sessionId: transcriptRotation.sessionId, - sessionFile: transcriptRotation.sessionFile, + sessionId: undefined, + sessionFile: undefined, }, }; } catch (err) { diff --git a/src/agents/embedded-agent-runner/compaction-successor-transcript.duplicate-prompt-loss.test.ts b/src/agents/embedded-agent-runner/compaction-successor-transcript.duplicate-prompt-loss.test.ts deleted file mode 100644 index f182f09241af..000000000000 --- a/src/agents/embedded-agent-runner/compaction-successor-transcript.duplicate-prompt-loss.test.ts +++ /dev/null @@ -1,79 +0,0 @@ -import fs from "node:fs/promises"; -import os from "node:os"; -import path from "node:path"; -import { SessionManager } from "openclaw/plugin-sdk/agent-sessions"; -import { afterEach, describe, expect, it } from "vitest"; -import { makeAgentAssistantMessage } from "../test-helpers/agent-message-fixtures.js"; -import { rotateTranscriptAfterCompaction } from "./compaction-successor-transcript.js"; -import { readTranscriptFileState } from "./transcript-file-state.js"; - -let tmpDir: string | undefined; -afterEach(async () => { - if (tmpDir) { - await fs.rm(tmpDir, { recursive: true, force: true }).catch(() => undefined); - tmpDir = undefined; - } -}); - -function makeAssistant(text: string, timestamp: number) { - return makeAgentAssistantMessage({ content: [{ type: "text", text }], timestamp }); -} - -function readUserTexts(entries: { type: string; message?: unknown }[]): string[] { - return entries - .filter( - (entry) => - entry.type === "message" && - (entry.message as { role?: unknown } | undefined)?.role === "user", - ) - .map((entry) => { - const content = (entry.message as { content?: unknown } | undefined)?.content; - if (typeof content === "string") { - return content; - } - if (Array.isArray(content)) { - return content.map((block) => (block as { text?: string })?.text ?? "").join(""); - } - return ""; - }); -} - -const PROMPT = "Please refactor the authentication module right now"; - -describe("rotateTranscriptAfterCompaction duplicate-prompt preservation", () => { - it("keeps a kept-tail prompt whose earlier copy was summarized away", async () => { - tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), "successor-dup-loss-")); - const manager = SessionManager.create(tmpDir, tmpDir); - - manager.appendMessage({ role: "user", content: PROMPT, timestamp: 1000 }); - manager.appendMessage(makeAssistant("Working on it.", 1001)); - const firstKeptId = manager.appendMessage({ - role: "user", - content: "go on", - timestamp: 1002, - }); - manager.appendMessage(makeAssistant("Continuing.", 1003)); - manager.appendCompaction("Summary of earlier turns.", firstKeptId, 5000); - manager.appendMessage({ role: "user", content: PROMPT, timestamp: 40000 }); - manager.appendMessage(makeAssistant("On it again.", 40001)); - - const sessionFile = manager.getSessionFile(); - if (!sessionFile) { - throw new Error("no session file"); - } - - const result = await rotateTranscriptAfterCompaction({ sessionManager: manager, sessionFile }); - expect(result.rotated).toBe(true); - const successorFile = result.sessionFile; - if (!successorFile) { - throw new Error("no successor file"); - } - - const successor = await readTranscriptFileState(successorFile); - const userPromptTexts = readUserTexts( - successor.getEntries() as { type: string; message?: unknown }[], - ); - - expect(userPromptTexts.some((text) => text.includes(PROMPT))).toBe(true); - }); -}); diff --git a/src/agents/embedded-agent-runner/compaction-successor-transcript.post-compaction-duplicate-prompt.test.ts b/src/agents/embedded-agent-runner/compaction-successor-transcript.post-compaction-duplicate-prompt.test.ts deleted file mode 100644 index cd050db5dbde..000000000000 --- a/src/agents/embedded-agent-runner/compaction-successor-transcript.post-compaction-duplicate-prompt.test.ts +++ /dev/null @@ -1,82 +0,0 @@ -import fs from "node:fs/promises"; -import os from "node:os"; -import path from "node:path"; -import { SessionManager } from "openclaw/plugin-sdk/agent-sessions"; -import { afterEach, describe, expect, it } from "vitest"; -import { makeAgentAssistantMessage } from "../test-helpers/agent-message-fixtures.js"; -import { rotateTranscriptAfterCompaction } from "./compaction-successor-transcript.js"; -import { readTranscriptFileState } from "./transcript-file-state.js"; - -let tmpDir: string | undefined; -afterEach(async () => { - if (tmpDir) { - await fs.rm(tmpDir, { recursive: true, force: true }).catch(() => undefined); - tmpDir = undefined; - } -}); - -function makeAssistant(text: string, timestamp: number) { - return makeAgentAssistantMessage({ content: [{ type: "text", text }], timestamp }); -} - -function readUserTexts(entries: { type: string; message?: unknown }[]): string[] { - return entries - .filter( - (entry) => - entry.type === "message" && - (entry.message as { role?: unknown } | undefined)?.role === "user", - ) - .map((entry) => { - const content = (entry.message as { content?: unknown } | undefined)?.content; - if (typeof content === "string") { - return content; - } - if (Array.isArray(content)) { - return content.map((block) => (block as { text?: string })?.text ?? "").join(""); - } - return ""; - }); -} - -const PROMPT = "Run the deployment script for staging now"; - -describe("rotateTranscriptAfterCompaction post-compaction duplicate", () => { - it("keeps a real post-compaction user turn that repeats a kept-tail prompt", async () => { - tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), "successor-dup-post-")); - const manager = SessionManager.create(tmpDir, tmpDir); - - manager.appendMessage({ role: "user", content: "set up the project", timestamp: 1000 }); - manager.appendMessage(makeAssistant("Project ready.", 1001)); - - const firstKeptId = manager.appendMessage({ - role: "user", - content: PROMPT, - timestamp: 2000, - }); - manager.appendMessage(makeAssistant("Deploying to staging.", 2001)); - manager.appendCompaction("Summary of project setup.", firstKeptId, 2050); - - manager.appendMessage({ role: "user", content: PROMPT, timestamp: 2080 }); - manager.appendMessage(makeAssistant("Redeploying to staging (second run).", 2081)); - - const sessionFile = manager.getSessionFile(); - if (!sessionFile) { - throw new Error("no session file"); - } - - const result = await rotateTranscriptAfterCompaction({ sessionManager: manager, sessionFile }); - expect(result.rotated).toBe(true); - const successorFile = result.sessionFile; - if (!successorFile) { - throw new Error("no successor file"); - } - - const successor = await readTranscriptFileState(successorFile); - const userPromptTexts = readUserTexts( - successor.getEntries() as { type: string; message?: unknown }[], - ); - - const occurrences = userPromptTexts.filter((text) => text.includes(PROMPT)).length; - expect(occurrences).toBe(2); - }); -}); diff --git a/src/agents/embedded-agent-runner/compaction-successor-transcript.test.ts b/src/agents/embedded-agent-runner/compaction-successor-transcript.test.ts deleted file mode 100644 index 5d3481ef8a68..000000000000 --- a/src/agents/embedded-agent-runner/compaction-successor-transcript.test.ts +++ /dev/null @@ -1,638 +0,0 @@ -// Persistence coverage for transcript rotation after successful compaction. -import fs from "node:fs/promises"; -import os from "node:os"; -import path from "node:path"; -import { SessionManager } from "openclaw/plugin-sdk/agent-sessions"; -import { afterEach, describe, expect, it, vi } from "vitest"; -import { makeAgentAssistantMessage } from "../test-helpers/agent-message-fixtures.js"; -import { - rotateTranscriptAfterCompaction, - rotateTranscriptFileAfterCompaction, - shouldRotateCompactionTranscript, -} from "./compaction-successor-transcript.js"; -import { hardenManualCompactionBoundary } from "./manual-compaction-boundary.js"; - -let tmpDir: string | undefined; - -async function createTmpDir(): Promise { - tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), "compaction-successor-test-")); - return tmpDir; -} - -afterEach(async () => { - if (tmpDir) { - await fs.rm(tmpDir, { recursive: true, force: true }).catch(() => undefined); - tmpDir = undefined; - } -}); - -function makeAssistant(text: string, timestamp: number) { - return makeAgentAssistantMessage({ - content: [{ type: "text", text }], - timestamp, - }); -} - -function makeThinkingAssistant(text: string, thinkingSignature: string, timestamp: number) { - return makeAgentAssistantMessage({ - content: [ - { type: "thinking", thinking: "reasoning", thinkingSignature } as never, - { type: "text", text }, - ], - timestamp, - }); -} - -function requireString(value: string | undefined, label: string): string { - if (!value) { - throw new Error(`expected ${label}`); - } - return value; -} - -function requireValue(value: T | null | undefined, label: string): T { - if (value == null) { - throw new Error(`expected ${label}`); - } - return value; -} - -type TranscriptEntry = ReturnType[number]; - -function requireEntryByIdAndType( - entries: readonly TranscriptEntry[], - id: string, - type: T, - label: string, -): Extract { - const entry = entries.find((candidate) => candidate.id === id); - if (!entry) { - throw new Error(`expected ${label}`); - } - if (entry.type !== type) { - throw new Error(`expected ${label} to be ${type}, got ${entry.type}`); - } - return entry as Extract; -} - -function requireEntryByType( - entries: readonly TranscriptEntry[], - type: T, - label: string, -): Extract { - const entry = entries.find((candidate) => candidate.type === type); - if (!entry) { - throw new Error(`expected ${label}`); - } - return entry as Extract; -} - -function createCompactedSession(sessionDir: string): { - manager: SessionManager; - sessionFile: string; - firstKeptId: string; - oldUserId: string; -} { - // Fixture includes pre-compaction history, preserved branch metadata, and - // post-compaction turns so rotation can prove exactly which entries survive. - const manager = SessionManager.create(sessionDir, sessionDir); - manager.appendModelChange("openai", "gpt-5.2"); - manager.appendThinkingLevelChange("medium"); - manager.appendCustomEntry("test-extension", { cursor: "before-compaction" }); - const oldUserId = manager.appendMessage({ role: "user", content: "old user", timestamp: 1 }); - manager.appendLabelChange(oldUserId, "old bookmark"); - manager.appendMessage(makeAssistant("old assistant", 2)); - const firstKeptId = manager.appendMessage({ role: "user", content: "kept user", timestamp: 3 }); - manager.appendLabelChange(firstKeptId, "kept bookmark"); - manager.appendMessage(makeAssistant("kept assistant", 4)); - manager.appendCompaction("Summary of old user and old assistant.", firstKeptId, 5000); - manager.appendMessage({ role: "user", content: "post user", timestamp: 5 }); - manager.appendMessage(makeAssistant("post assistant", 6)); - return { - manager, - sessionFile: requireString(manager.getSessionFile(), "compacted session file"), - firstKeptId, - oldUserId, - }; -} - -describe("rotateTranscriptAfterCompaction", () => { - it("can rotate a persisted transcript without opening a manager", async () => { - const dir = await createTmpDir(); - const { sessionFile } = createCompactedSession(dir); - - // File-only rotation is used after the active manager has moved on; opening - // a new manager here would hide bugs in the direct persistence path. - const openSpy = vi.spyOn(SessionManager, "open").mockImplementation(() => { - throw new Error("SessionManager.open should not be used for file rotation"); - }); - const result = await rotateTranscriptFileAfterCompaction({ - sessionFile, - now: () => new Date("2026-04-27T12:00:00.000Z"), - }); - openSpy.mockRestore(); - - expect(result.rotated).toBe(true); - const successorFile = requireString(result.sessionFile, "successor session file"); - - const successor = SessionManager.open(successorFile); - const header = requireValue(successor.getHeader(), "successor header"); - expect(header.parentSession).toBe(sessionFile); - expect(header.cwd).toBe(dir); - const messages = successor.buildSessionContext().messages; - expect( - messages.map((message) => { - if (message.role === "compactionSummary") { - return { - role: message.role, - summary: message.summary, - tokensBefore: message.tokensBefore, - }; - } - if (!("content" in message)) { - throw new Error(`expected ${message.role} message content`); - } - return { - role: message.role, - content: message.content, - timestamp: message.timestamp, - }; - }), - ).toEqual([ - { - role: "compactionSummary", - summary: "Summary of old user and old assistant.", - tokensBefore: 5000, - }, - // The last assistant reply before firstKeptEntryId is preserved so the - // successor shows compactionSummary → assistant → user (issue #76729). - { - role: "assistant", - content: [{ type: "text", text: "old assistant" }], - timestamp: 2, - }, - { role: "user", content: "kept user", timestamp: 3 }, - { - role: "assistant", - content: [{ type: "text", text: "kept assistant" }], - timestamp: 4, - }, - { role: "user", content: "post user", timestamp: 5 }, - { - role: "assistant", - content: [{ type: "text", text: "post assistant" }], - timestamp: 6, - }, - ]); - }); - - it("keeps the paired tool result without replaying summarized custom context", async () => { - const dir = await createTmpDir(); - const manager = SessionManager.create(dir, dir); - manager.appendMessage({ role: "user", content: "read the file", timestamp: 1 }); - manager.appendMessage( - makeAgentAssistantMessage({ - content: [{ type: "toolCall", id: "call_1", name: "read", arguments: {} }], - timestamp: 2, - }), - ); - manager.appendMessage({ - role: "toolResult", - toolCallId: "call_1", - toolName: "read", - content: [{ type: "text", text: "file contents" }], - isError: false, - timestamp: 3, - }); - manager.appendCustomMessageEntry("test", "summarized custom context", false); - const firstKeptId = manager.appendMessage({ - role: "user", - content: "continue", - timestamp: 4, - }); - manager.appendMessage(makeAssistant("done", 5)); - manager.appendCompaction("Summary of the read.", firstKeptId, 5000); - manager.appendMessage({ role: "user", content: "next", timestamp: 6 }); - - const result = await rotateTranscriptAfterCompaction({ - sessionManager: manager, - sessionFile: requireString(manager.getSessionFile(), "session file"), - }); - const successor = SessionManager.open(requireString(result.sessionFile, "successor file")); - const messages = successor.buildSessionContext().messages; - const assistant = messages.find((message) => message.role === "assistant"); - const toolResult = messages.find((message) => message.role === "toolResult"); - - expect(assistant?.role).toBe("assistant"); - expect(toolResult?.role).toBe("toolResult"); - expect(JSON.stringify(messages)).toContain("file contents"); - expect(JSON.stringify(messages)).not.toContain("summarized custom context"); - }); - - it("creates a compacted successor transcript and leaves the archive untouched", async () => { - const dir = await createTmpDir(); - const { manager, sessionFile, firstKeptId, oldUserId } = createCompactedSession(dir); - const originalBytes = await fs.readFile(sessionFile, "utf8"); - const originalEntryCount = manager.getEntries().length; - - const result = await rotateTranscriptAfterCompaction({ - sessionManager: manager, - sessionFile, - now: () => new Date("2026-04-27T12:00:00.000Z"), - }); - - expect(result.rotated).toBe(true); - const successorSessionId = requireString(result.sessionId, "successor session id"); - const successorFile = requireString(result.sessionFile, "successor session file"); - expect(successorFile).not.toBe(sessionFile); - expect(await fs.readFile(sessionFile, "utf8")).toBe(originalBytes); - - const successor = SessionManager.open(successorFile); - const header = requireValue(successor.getHeader(), "successor header"); - expect(header.id).toBe(successorSessionId); - expect(header.parentSession).toBe(sessionFile); - expect(header.cwd).toBe(dir); - expect(successor.getEntries().length).toBeLessThan(originalEntryCount); - expect(successor.getBranch()[0]?.type).toBe("model_change"); - const customBranchEntry = requireEntryByType( - successor.getBranch(), - "custom", - "preserved custom branch entry", - ); - expect(customBranchEntry.customType).toBe("test-extension"); - expect(customBranchEntry.data).toStrictEqual({ cursor: "before-compaction" }); - - const context = successor.buildSessionContext(); - const contextText = JSON.stringify(context.messages); - expect(contextText).toContain("Summary of old user and old assistant."); - expect(contextText).toContain("kept user"); - expect(contextText).toContain("post assistant"); - expect( - context.messages.some((message) => message.role === "user" && message.content === "old user"), - ).toBe(false); - expect(context.model?.provider).toBe("openai"); - expect(context.thinkingLevel).toBe("medium"); - expect(successor.getLabel(firstKeptId)).toBe("kept bookmark"); - expect(successor.getLabel(oldUserId)).toBeUndefined(); - }); - - it("rotates with a fallback timestamp when the injected clock is invalid", async () => { - const dir = await createTmpDir(); - const { manager, sessionFile } = createCompactedSession(dir); - const dateNowSpy = vi.spyOn(Date, "now").mockReturnValue(Date.parse("2026-05-30T12:00:00Z")); - - try { - const result = await rotateTranscriptAfterCompaction({ - sessionManager: manager, - sessionFile, - now: () => new Date(Number.NaN), - }); - - expect(result.rotated).toBe(true); - const successor = SessionManager.open(requireString(result.sessionFile, "successor file")); - expect(successor.getHeader()?.timestamp).toBe("2026-05-30T12:00:00.000Z"); - } finally { - dateNowSpy.mockRestore(); - } - }); - - it("deduplicates stale pre-compaction session state", async () => { - const dir = await createTmpDir(); - const manager = SessionManager.create(dir, dir); - - const staleModelId = manager.appendModelChange("anthropic", "claude-sonnet-4-5"); - const staleThinkingId = manager.appendThinkingLevelChange("low"); - const staleSessionInfoId = manager.appendSessionInfo("stale title"); - manager.appendCustomEntry("test-extension", { cursor: "preserved" }); - manager.appendMessage({ role: "user", content: "old user", timestamp: 1 }); - manager.appendMessage(makeAssistant("old assistant", 2)); - - manager.appendModelChange("openai", "gpt-5.2"); - manager.appendThinkingLevelChange("high"); - manager.appendSessionInfo("current title"); - const firstKeptId = manager.appendMessage({ role: "user", content: "kept user", timestamp: 3 }); - manager.appendMessage(makeAssistant("kept assistant", 4)); - manager.appendCompaction("Summary of old user and old assistant.", firstKeptId, 5000); - manager.appendMessage({ role: "user", content: "post user", timestamp: 5 }); - - const result = await rotateTranscriptAfterCompaction({ - sessionManager: manager, - sessionFile: requireString(manager.getSessionFile(), "source session file"), - now: () => new Date("2026-04-27T12:05:00.000Z"), - }); - - expect(result.rotated).toBe(true); - const successor = SessionManager.open( - requireString(result.sessionFile, "successor session file"), - ); - const entries = successor.getEntries(); - expect(entries.find((entry) => entry.id === staleModelId)).toBeUndefined(); - expect(entries.find((entry) => entry.id === staleThinkingId)).toBeUndefined(); - expect(entries.find((entry) => entry.id === staleSessionInfoId)).toBeUndefined(); - const countEntryType = (type: (typeof entries)[number]["type"]) => - entries.reduce((count, entry) => count + (entry.type === type ? 1 : 0), 0); - expect(countEntryType("model_change")).toBe(1); - expect(countEntryType("thinking_level_change")).toBe(1); - expect(countEntryType("session_info")).toBe(1); - const modelChange = requireEntryByType(entries, "model_change", "current model change"); - expect(modelChange.provider).toBe("openai"); - expect(modelChange.modelId).toBe("gpt-5.2"); - const customEntry = requireEntryByType(entries, "custom", "preserved custom entry"); - expect(customEntry.customType).toBe("test-extension"); - expect(customEntry.data).toStrictEqual({ cursor: "preserved" }); - - const context = successor.buildSessionContext(); - expect(context.thinkingLevel).toBe("high"); - expect(successor.getSessionName()).toBe("current title"); - }); - - it("drops duplicate user messages from the rotated active branch tail", async () => { - const dir = await createTmpDir(); - const manager = SessionManager.create(dir, dir); - manager.appendMessage({ role: "user", content: "old user", timestamp: 1 }); - const firstKeptId = manager.appendMessage(makeAssistant("old assistant", 2)); - manager.appendCompaction("Summary of old work.", firstKeptId, 5000); - const firstDuplicateId = manager.appendMessage({ - role: "user", - content: "please run the deployment status check for production", - timestamp: 3_000, - }); - const secondDuplicateId = manager.appendMessage({ - role: "user", - content: " please run the deployment status check for production ", - timestamp: 4_000, - }); - manager.appendMessage(makeAssistant("status checked", 5_000)); - - const result = await rotateTranscriptAfterCompaction({ - sessionManager: manager, - sessionFile: requireString(manager.getSessionFile(), "source session file"), - now: () => new Date("2026-04-27T12:10:00.000Z"), - }); - - expect(result.rotated).toBe(true); - const successor = SessionManager.open( - requireString(result.sessionFile, "successor session file"), - ); - const entries = successor.getEntries(); - requireValue( - entries.find((entry) => entry.id === firstDuplicateId), - "kept duplicate entry", - ); - expect(entries.find((entry) => entry.id === secondDuplicateId)).toBeUndefined(); - const contextText = JSON.stringify(successor.buildSessionContext().messages); - expect(contextText.match(/deployment status check/g)).toHaveLength(1); - }); - - it("skips sessions with no compaction entry", async () => { - const dir = await createTmpDir(); - const manager = SessionManager.create(dir, dir); - manager.appendMessage({ role: "user", content: "hello", timestamp: 1 }); - manager.appendMessage(makeAssistant("hi", 2)); - - const result = await rotateTranscriptAfterCompaction({ - sessionManager: manager, - sessionFile: requireString(manager.getSessionFile(), "source session file"), - }); - - expect(result.rotated).toBe(false); - expect(result.reason).toBe("no compaction entry"); - }); - - it("uses a refreshed manager after manual boundary hardening", async () => { - const dir = await createTmpDir(); - const manager = SessionManager.create(dir, dir); - manager.appendMessage({ role: "user", content: "old question", timestamp: 1 }); - manager.appendMessage(makeAssistant("old answer", 2)); - const recentTailId = manager.appendMessage({ - role: "user", - content: "recent question", - timestamp: 3, - }); - manager.appendMessage(makeAssistant("detailed recent answer", 4)); - const compactionId = manager.appendCompaction("fresh manual summary", recentTailId, 200); - const sessionFile = requireString(manager.getSessionFile(), "manual compaction session file"); - const staleManager = SessionManager.open(sessionFile); - - const hardened = await hardenManualCompactionBoundary({ sessionFile }); - expect(hardened.applied).toBe(true); - const staleLeaf = staleManager.getLeafEntry(); - expect(staleLeaf?.type).toBe("compaction"); - if (!staleLeaf || staleLeaf.type !== "compaction") { - throw new Error("expected stale leaf to be a compaction entry"); - } - expect(staleLeaf.firstKeptEntryId).toBe(recentTailId); - - const result = await rotateTranscriptAfterCompaction({ - sessionManager: SessionManager.open(sessionFile), - sessionFile, - now: () => new Date("2026-04-27T12:30:00.000Z"), - }); - - expect(result.rotated).toBe(true); - const successor = SessionManager.open( - requireString(result.sessionFile, "successor session file"), - ); - const successorText = JSON.stringify(successor.buildSessionContext().messages); - expect(successorText).toContain("fresh manual summary"); - expect(successorText).not.toContain("recent question"); - expect(successorText).not.toContain("detailed recent answer"); - const successorCompaction = successor - .getEntries() - .find((entry) => entry.type === "compaction" && entry.id === compactionId); - if (!successorCompaction || successorCompaction.type !== "compaction") { - throw new Error("expected successor compaction entry"); - } - expect(successorCompaction.firstKeptEntryId).toBe(compactionId); - }); - - it("preserves unsummarized sibling branches and branch summaries", async () => { - const dir = await createTmpDir(); - const manager = SessionManager.create(dir, dir); - - manager.appendMessage({ role: "user", content: "hello", timestamp: 1 }); - const branchFromId = manager.appendMessage(makeAssistant("hi there", 2)); - - const branchSummaryId = manager.branchWithSummary( - branchFromId, - "Summary of the abandoned branch.", - ); - const siblingMsgId = manager.appendMessage({ - role: "user", - content: "do task B instead", - timestamp: 3, - }); - manager.appendMessage(makeAssistant("done B", 4)); - - manager.branch(branchFromId); - manager.appendMessage({ role: "user", content: "do task A", timestamp: 5 }); - const firstKeptId = manager.appendMessage(makeAssistant("done A", 6)); - manager.appendCompaction("Summary of main branch.", firstKeptId, 5000); - manager.appendMessage({ role: "user", content: "next", timestamp: 7 }); - - const sessionFile = requireString(manager.getSessionFile(), "source session file"); - const result = await rotateTranscriptAfterCompaction({ - sessionManager: manager, - sessionFile, - now: () => new Date("2026-04-27T12:45:00.000Z"), - }); - - expect(result.rotated).toBe(true); - const successor = SessionManager.open( - requireString(result.sessionFile, "successor session file"), - ); - const allEntries = successor.getEntries(); - const branchSummary = requireEntryByIdAndType( - allEntries, - branchSummaryId, - "branch_summary", - "preserved branch summary", - ); - expect(branchSummary.summary).toBe("Summary of the abandoned branch."); - const siblingMessage = requireEntryByIdAndType( - allEntries, - siblingMsgId, - "message", - "preserved sibling message", - ); - if (!("content" in siblingMessage.message)) { - throw new Error("expected sibling message content"); - } - expect(siblingMessage.message.content).toBe("do task B instead"); - - const activeContextText = JSON.stringify(successor.buildSessionContext().messages); - expect(activeContextText).toContain("Summary of main branch."); - expect(activeContextText).toContain("next"); - expect(activeContextText).not.toContain("do task B instead"); - }); - - it("orders preserved sibling branches after their surviving parents", async () => { - const dir = await createTmpDir(); - const manager = SessionManager.create(dir, dir); - - manager.appendMessage({ role: "user", content: "hello", timestamp: 1 }); - const branchFromId = manager.appendMessage(makeAssistant("hi there", 2)); - - const branchSummaryId = manager.branchWithSummary( - branchFromId, - "Summary of the inactive branch.", - ); - const inactiveMsgId = manager.appendMessage({ - role: "user", - content: "inactive branch", - timestamp: 3, - }); - manager.appendMessage(makeAssistant("inactive done", 4)); - - manager.branch(branchFromId); - manager.appendMessage({ role: "user", content: "active branch", timestamp: 5 }); - manager.appendMessage(makeAssistant("active done", 6)); - manager.appendCompaction("Summary of active work.", branchFromId, 5000); - const activeLeafId = manager.appendMessage({ - role: "user", - content: "next active", - timestamp: 7, - }); - - const result = await rotateTranscriptAfterCompaction({ - sessionManager: manager, - sessionFile: requireString(manager.getSessionFile(), "source session file"), - now: () => new Date("2026-04-27T13:00:00.000Z"), - }); - - expect(result.rotated).toBe(true); - const successor = SessionManager.open( - requireString(result.sessionFile, "successor session file"), - ); - const entries = successor.getEntries(); - const indexById = new Map(entries.map((entry, index) => [entry.id, index])); - expect(indexById.get(branchFromId)).toBeLessThan(indexById.get(branchSummaryId)!); - expect(indexById.get(branchSummaryId)).toBeLessThan(indexById.get(inactiveMsgId)!); - expect(entries.at(-1)?.id).toBe(activeLeafId); - expect(successor.getLeafId()).toBe(activeLeafId); - - const activeContextText = JSON.stringify(successor.buildSessionContext().messages); - expect(activeContextText).toContain("Summary of active work."); - expect(activeContextText).toContain("next active"); - expect(activeContextText).not.toContain("inactive branch"); - }); -}); - -describe("shouldRotateCompactionTranscript", () => { - it("keeps transcript rotation opt-in behind the existing config key", () => { - expect(shouldRotateCompactionTranscript()).toBe(false); - expect( - shouldRotateCompactionTranscript({ - agents: { defaults: { compaction: { truncateAfterCompaction: true } } }, - }), - ).toBe(true); - }); -}); - -describe("rotateTranscriptAfterCompaction — thinking signature stripping", () => { - it("strips thinkingSignature from kept assistant messages in the successor file", async () => { - const dir = await createTmpDir(); - const manager = SessionManager.create(dir, dir); - - const oldUserId = manager.appendMessage({ - role: "user", - content: "old question", - timestamp: 1, - }); - manager.appendMessage(makeThinkingAssistant("old answer", "stale_sig_old", 2)); - const firstKeptId = manager.appendMessage({ - role: "user", - content: "kept question", - timestamp: 3, - }); - manager.appendMessage(makeThinkingAssistant("kept answer", "stale_sig_kept", 4)); - manager.appendCompaction("Summary of old work.", firstKeptId, 3000); - manager.appendMessage({ role: "user", content: "post question", timestamp: 5 }); - manager.appendMessage(makeThinkingAssistant("post answer", "fresh_sig", 6)); - - const sessionFile = requireString(manager.getSessionFile(), "source session file"); - const result = await rotateTranscriptAfterCompaction({ - sessionManager: manager, - sessionFile, - now: () => new Date("2026-06-04T00:00:00.000Z"), - }); - - expect(result.rotated).toBe(true); - const successor = SessionManager.open( - requireString(result.sessionFile, "successor session file"), - ); - - const entries = successor.getEntries(); - function getThinkingSignatureForTimestamp(ts: number): unknown { - for (const entry of entries) { - if (entry.type !== "message" || entry.message.role !== "assistant") { - continue; - } - if ((entry.message as { timestamp?: number }).timestamp !== ts) { - continue; - } - const content = (entry.message as { content?: unknown[] }).content ?? []; - for (const block of content) { - if ((block as { type?: unknown }).type === "thinking") { - return (block as { thinkingSignature?: unknown }).thinkingSignature; - } - } - } - return undefined; - } - - // Pre-compaction kept message (timestamp 4): signature stripped - expect(getThinkingSignatureForTimestamp(4)).toBeUndefined(); - // Post-compaction message (timestamp 6): signature preserved intact - expect(getThinkingSignatureForTimestamp(6)).toBe("fresh_sig"); - - // Old summarized messages should not appear - expect(entries.find((e) => e.id === oldUserId)).toBeUndefined(); - - // Context should remain coherent: compaction summary + kept + post-compaction - const contextText = JSON.stringify(successor.buildSessionContext().messages); - expect(contextText).toContain("kept question"); - expect(contextText).toContain("kept answer"); - expect(contextText).toContain("post answer"); - }); -}); diff --git a/src/agents/embedded-agent-runner/compaction-successor-transcript.ts b/src/agents/embedded-agent-runner/compaction-successor-transcript.ts deleted file mode 100644 index bdf559443884..000000000000 --- a/src/agents/embedded-agent-runner/compaction-successor-transcript.ts +++ /dev/null @@ -1,390 +0,0 @@ -/** - * Rotates compacted sessions into successor transcript files when configured. - */ -import { randomUUID } from "node:crypto"; -import path from "node:path"; -import { resolveTimestampMsToIsoString } from "@openclaw/normalization-core/number-coercion"; -import { CURRENT_SESSION_VERSION } from "../../config/sessions/version.js"; -import type { OpenClawConfig } from "../../config/types.openclaw.js"; -import type { CompactionEntry, SessionEntry, SessionHeader } from "../sessions/index.js"; -import { collectDuplicateUserMessageEntryIdsForCompaction } from "./compaction-duplicate-user-messages.js"; -import { stripThinkingSignaturesFromMessage } from "./thinking.js"; -import { - readTranscriptFileState, - TranscriptFileState, - writeTranscriptFileAtomic, -} from "./transcript-file-state.js"; - -type ReadonlySessionManagerForRotation = Pick< - TranscriptFileState, - "buildSessionContext" | "getBranch" | "getCwd" | "getEntries" | "getHeader" ->; - -export type CompactionTranscriptRotation = { - rotated: boolean; - reason?: string; - sessionId?: string; - sessionFile?: string; - compactionEntryId?: string; - leafId?: string; - entriesWritten?: number; -}; - -export function shouldRotateCompactionTranscript(config?: OpenClawConfig): boolean { - return config?.agents?.defaults?.compaction?.truncateAfterCompaction === true; -} - -export async function rotateTranscriptAfterCompaction(params: { - sessionManager: ReadonlySessionManagerForRotation; - sessionFile: string; - now?: () => Date; -}): Promise { - const sessionFile = params.sessionFile.trim(); - if (!sessionFile) { - return { rotated: false, reason: "missing session file" }; - } - - const branch = params.sessionManager.getBranch(); - const latestCompactionIndex = findLatestCompactionIndex(branch); - if (latestCompactionIndex < 0) { - return { rotated: false, reason: "no compaction entry" }; - } - - const compaction = branch[latestCompactionIndex] as CompactionEntry; - const timestamp = resolveTimestampMsToIsoString(params.now?.().getTime()); - const sessionId = randomUUID(); - const successorFile = resolveSuccessorSessionFile({ - sessionFile, - sessionId, - timestamp, - }); - const successorEntries = buildSuccessorEntries({ - allEntries: params.sessionManager.getEntries(), - branch, - latestCompactionIndex, - }); - if (successorEntries.length === 0) { - return { rotated: false, reason: "empty successor transcript" }; - } - - const header = buildSuccessorHeader({ - previousHeader: params.sessionManager.getHeader(), - sessionId, - timestamp, - cwd: params.sessionManager.getCwd(), - parentSession: sessionFile, - }); - await writeTranscriptFileAtomic(successorFile, [header, ...successorEntries]); - new TranscriptFileState({ header, entries: successorEntries }).buildSessionContext(); - - return { - rotated: true, - sessionId, - sessionFile: successorFile, - compactionEntryId: compaction.id, - leafId: successorEntries[successorEntries.length - 1]?.id, - entriesWritten: successorEntries.length, - }; -} - -export async function rotateTranscriptFileAfterCompaction(params: { - sessionFile: string; - now?: () => Date; -}): Promise { - const state = await readTranscriptFileState(params.sessionFile); - return rotateTranscriptAfterCompaction({ - sessionManager: state, - sessionFile: params.sessionFile, - ...(params.now ? { now: params.now } : {}), - }); -} - -function findLatestCompactionIndex(entries: SessionEntry[]): number { - for (let index = entries.length - 1; index >= 0; index -= 1) { - if (entries[index]?.type === "compaction") { - return index; - } - } - return -1; -} - -function buildSuccessorEntries(params: { - allEntries: SessionEntry[]; - branch: SessionEntry[]; - latestCompactionIndex: number; -}): SessionEntry[] { - const { allEntries, branch, latestCompactionIndex } = params; - const compaction = branch[latestCompactionIndex] as CompactionEntry; - - const summarizedBranchIds = new Set(); - const preCompactionKeptBranchIds = new Set(); - let foundFirstKept = false; - for (let index = 0; index < latestCompactionIndex; index += 1) { - const entry = branch[index]; - if (!entry) { - continue; - } - if (compaction.firstKeptEntryId && entry.id === compaction.firstKeptEntryId) { - foundFirstKept = true; - } - if (foundFirstKept) { - preCompactionKeptBranchIds.add(entry.id); - } else { - summarizedBranchIds.add(entry.id); - } - } - - // Preserve the last assistant message before firstKeptEntryId so the successor - // transcript keeps a compactionSummary → assistant → user structure instead of - // dropping the assistant reply (issue #76729). - // Skip for hardened manual compaction boundaries where firstKeptEntryId already - // points to the compaction entry itself (all pre-compaction content is - // intentionally excluded). - const isHardenedBoundary = compaction.firstKeptEntryId === compaction.id; - let preservedAssistantId: string | undefined; - let preservedAssistantIndex = -1; - let firstKeptIndex = -1; - if (!isHardenedBoundary) { - for (let index = latestCompactionIndex - 1; index >= 0; index -= 1) { - const entry = branch[index]; - if ( - entry && - summarizedBranchIds.has(entry.id) && - entry.type === "message" && - entry.message.role === "assistant" - ) { - preservedAssistantId = entry.id; - preservedAssistantIndex = index; - break; - } - } - } - if (compaction.firstKeptEntryId) { - firstKeptIndex = branch.findIndex((entry) => entry.id === compaction.firstKeptEntryId); - } - const branchIndexById = new Map(branch.map((entry, index) => [entry.id, index])); - const preservedPreCompactionIds = new Set(); - if (preservedAssistantId) { - preservedPreCompactionIds.add(preservedAssistantId); - const assistant = branch[preservedAssistantIndex]; - if (assistant?.type === "message" && assistant.message.role === "assistant") { - const toolCallIds = new Set( - assistant.message.content - .filter((block) => block.type === "toolCall") - .map((block) => block.id), - ); - for ( - let index = preservedAssistantIndex + 1; - index >= 0 && index < firstKeptIndex; - index += 1 - ) { - const entry = branch[index]; - if (entry?.type === "message" && entry.message.role === "toolResult") { - if (toolCallIds.has(entry.message.toolCallId)) { - preservedPreCompactionIds.add(entry.id); - } - } - } - } - } - - const latestStateEntryIds = collectLatestStateEntryIds(branch.slice(0, latestCompactionIndex)); - const staleStateEntryIds = new Set(); - for (const entry of branch.slice(0, latestCompactionIndex)) { - if (isDedupedStateEntry(entry) && !latestStateEntryIds.has(entry.id)) { - staleStateEntryIds.add(entry.id); - } - } - - const removedIds = new Set(); - const keptPreCompactionEntries = branch - .slice(0, latestCompactionIndex) - .filter((entry) => !summarizedBranchIds.has(entry.id)); - const postCompactionEntries = branch.slice(latestCompactionIndex + 1); - const duplicateUserMessageIds = new Set([ - ...collectDuplicateUserMessageEntryIdsForCompaction(keptPreCompactionEntries), - ...collectDuplicateUserMessageEntryIdsForCompaction(postCompactionEntries), - ]); - for (const entry of allEntries) { - const branchIndex = branchIndexById.get(entry.id) ?? -1; - const summarizedContextMarker = - branchIndex > preservedAssistantIndex && - branchIndex < firstKeptIndex && - (entry.type === "custom_message" || entry.type === "branch_summary"); - if ( - (summarizedBranchIds.has(entry.id) && - entry.type === "message" && - !preservedPreCompactionIds.has(entry.id)) || - (summarizedBranchIds.has(entry.id) && summarizedContextMarker) || - staleStateEntryIds.has(entry.id) || - duplicateUserMessageIds.has(entry.id) - ) { - removedIds.add(entry.id); - } - } - // The preserved assistant reply is pre-compaction content that needs thinking - // signature stripping, same as other pre-compaction kept entries. - for (const entryId of preservedPreCompactionIds) { - preCompactionKeptBranchIds.add(entryId); - } - for (const entry of allEntries) { - if (entry.type === "label" && removedIds.has(entry.targetId)) { - removedIds.add(entry.id); - } - } - - const entryById = new Map(); - const originalIndexById = new Map(); - for (const [index, entry] of allEntries.entries()) { - entryById.set(entry.id, entry); - originalIndexById.set(entry.id, index); - } - const activeBranchIds = new Set(); - for (const entry of branch) { - activeBranchIds.add(entry.id); - } - const keptEntries: SessionEntry[] = []; - for (const entry of allEntries) { - if (removedIds.has(entry.id)) { - continue; - } - - let parentId = entry.parentId; - while (parentId !== null && removedIds.has(parentId)) { - parentId = entryById.get(parentId)?.parentId ?? null; - } - - const reparented = - parentId === entry.parentId ? entry : ({ ...entry, parentId } as SessionEntry); - // Strip thinking signatures only from pre-compaction kept entries. Pre-compaction - // signatures are bound to the original context prefix; the successor file has a different - // prefix so those signatures would cause Anthropic "Invalid signature in thinking block". - // Post-compaction entries were generated in the new context and have valid signatures. - // Move the compaction boundary back to the preserved turn so its complete - // assistant/tool-result sequence is included in the successor context. - let transformed: SessionEntry = reparented; - if (reparented.type === "message" && preCompactionKeptBranchIds.has(reparented.id)) { - transformed = { - ...reparented, - message: stripThinkingSignaturesFromMessage(reparented.message), - }; - } - if ( - reparented.type === "compaction" && - reparented.id === compaction.id && - preservedAssistantId && - reparented.firstKeptEntryId !== reparented.id - ) { - transformed = { ...reparented, firstKeptEntryId: preservedAssistantId }; - } - keptEntries.push(transformed); - } - - return orderSuccessorEntries({ - entries: keptEntries, - activeBranchIds, - originalIndexById, - }); -} - -function collectLatestStateEntryIds(entries: SessionEntry[]): Set { - const latestByType = new Map(); - for (const entry of entries) { - if (isDedupedStateEntry(entry)) { - latestByType.set(entry.type, entry); - } - } - const ids = new Set(); - for (const entry of latestByType.values()) { - ids.add(entry.id); - } - return ids; -} - -function isDedupedStateEntry(entry: SessionEntry): boolean { - return ( - entry.type === "model_change" || - entry.type === "thinking_level_change" || - entry.type === "session_info" - ); -} - -function orderSuccessorEntries(params: { - entries: SessionEntry[]; - activeBranchIds: Set; - originalIndexById: Map; -}): SessionEntry[] { - const { entries, activeBranchIds, originalIndexById } = params; - const entryIds = new Set(); - for (const entry of entries) { - entryIds.add(entry.id); - } - const childrenByParentId = new Map(); - - for (const entry of entries) { - const parentId = - entry.parentId !== null && entryIds.has(entry.parentId) ? entry.parentId : null; - const children = childrenByParentId.get(parentId) ?? []; - children.push(parentId === entry.parentId ? entry : ({ ...entry, parentId } as SessionEntry)); - childrenByParentId.set(parentId, children); - } - - const sortForActiveLeaf = (left: SessionEntry, right: SessionEntry) => { - const leftActive = activeBranchIds.has(left.id); - const rightActive = activeBranchIds.has(right.id); - if (leftActive !== rightActive) { - return leftActive ? 1 : -1; - } - return (originalIndexById.get(left.id) ?? 0) - (originalIndexById.get(right.id) ?? 0); - }; - - const ordered: SessionEntry[] = []; - const emittedIds = new Set(); - const emitSubtree = (entry: SessionEntry) => { - if (emittedIds.has(entry.id)) { - return; - } - emittedIds.add(entry.id); - ordered.push(entry); - for (const child of (childrenByParentId.get(entry.id) ?? []).toSorted(sortForActiveLeaf)) { - emitSubtree(child); - } - }; - - for (const root of (childrenByParentId.get(null) ?? []).toSorted(sortForActiveLeaf)) { - emitSubtree(root); - } - - // Defensive fallback for malformed transcripts with cycles or broken parents. - for (const entry of entries.toSorted(sortForActiveLeaf)) { - emitSubtree(entry); - } - - return ordered; -} - -function buildSuccessorHeader(params: { - previousHeader: SessionHeader | null; - sessionId: string; - timestamp: string; - cwd: string; - parentSession: string; -}): SessionHeader { - return { - type: "session", - version: CURRENT_SESSION_VERSION, - id: params.sessionId, - timestamp: params.timestamp, - cwd: params.previousHeader?.cwd || params.cwd, - parentSession: params.parentSession, - }; -} - -function resolveSuccessorSessionFile(params: { - sessionFile: string; - sessionId: string; - timestamp: string; -}): string { - const fileTimestamp = params.timestamp.replace(/[:.]/g, "-"); - return path.join(path.dirname(params.sessionFile), `${fileTimestamp}_${params.sessionId}.jsonl`); -} diff --git a/src/agents/embedded-agent-runner/context-engine-maintenance.test.ts b/src/agents/embedded-agent-runner/context-engine-maintenance.test.ts index 6e1870009ca8..cec08f8a7ba9 100644 --- a/src/agents/embedded-agent-runner/context-engine-maintenance.test.ts +++ b/src/agents/embedded-agent-runner/context-engine-maintenance.test.ts @@ -241,7 +241,7 @@ describe("runContextEngineMaintenance", () => { }); }); - it("forces background maintenance rewrites through the session file even when a session manager exists", async () => { + it("forces background maintenance rewrites through the runtime target even when a session manager exists", async () => { const maintain = vi.fn(async (params?: unknown) => { await ( params as { runtimeContext?: ContextEngineRuntimeContext } | undefined @@ -277,6 +277,12 @@ describe("runContextEngineMaintenance", () => { }, sessionId: "session-background-file-rewrite", sessionKey: "agent:main:session-background-file-rewrite", + sessionTarget: { + agentId: "custom-agent", + sessionId: "custom-session", + sessionKey: "agent:custom-agent:custom-session", + storePath: "/tmp/custom-agent.sqlite", + }, sessionFile: "/tmp/session-background-file-rewrite.jsonl", reason: "turn", executionMode: "background", @@ -287,9 +293,11 @@ describe("runContextEngineMaintenance", () => { expect(rewriteTranscriptEntriesInSessionManagerMock).not.toHaveBeenCalled(); expect(rewriteTranscriptEntriesInRuntimeTranscriptMock).toHaveBeenCalledWith({ scope: { - sessionId: "session-background-file-rewrite", - sessionKey: "agent:main:session-background-file-rewrite", + agentId: "custom-agent", + sessionId: "custom-session", + sessionKey: "agent:custom-agent:custom-session", sessionFile: "/tmp/session-background-file-rewrite.jsonl", + storePath: "/tmp/custom-agent.sqlite", }, request: { replacements: [ @@ -303,7 +311,6 @@ describe("runContextEngineMaintenance", () => { }, ], }, - config: {}, }); }); @@ -460,7 +467,6 @@ describe("runContextEngineMaintenance", () => { }, ], }, - config: {}, }), ); diff --git a/src/agents/embedded-agent-runner/context-engine-maintenance.ts b/src/agents/embedded-agent-runner/context-engine-maintenance.ts index 8d4ab1961a1d..cfc235a0790d 100644 --- a/src/agents/embedded-agent-runner/context-engine-maintenance.ts +++ b/src/agents/embedded-agent-runner/context-engine-maintenance.ts @@ -3,6 +3,7 @@ */ import { randomUUID } from "node:crypto"; import { normalizeOptionalString } from "@openclaw/normalization-core/string-coerce"; +import { resolveStorePath } from "../../config/sessions/paths.js"; import type { OpenClawConfig } from "../../config/types.openclaw.js"; import { resolveContextEngineOwnerPluginId } from "../../context-engine/registry.js"; import type { @@ -323,16 +324,22 @@ function buildContextEngineMaintenanceRuntimeContext(params: { ? await params.withSessionManagerRewriteLock(rewriteSessionManagerEntries) : rewriteSessionManagerEntries(); } + const runtimeAgentId = params.sessionTarget?.agentId ?? params.agentId; + const runtimeStorePath = + params.sessionTarget?.storePath ?? + (runtimeAgentId + ? resolveStorePath(params.config?.session?.store, { agentId: runtimeAgentId }) + : undefined); const rewriteRuntimeTranscriptEntries = async () => await rewriteTranscriptEntriesInRuntimeTranscript({ scope: { - sessionId: params.sessionId, - sessionKey: params.sessionKey ?? params.sessionId, + sessionId: params.sessionTarget?.sessionId ?? params.sessionId, + sessionKey: params.sessionTarget?.sessionKey ?? params.sessionKey ?? params.sessionId, sessionFile: params.sessionFile, - ...(params.agentId ? { agentId: params.agentId } : {}), + ...(runtimeAgentId ? { agentId: runtimeAgentId } : {}), + ...(runtimeStorePath ? { storePath: runtimeStorePath } : {}), }, request, - config: params.config, }); return await rewriteRuntimeTranscriptEntries(); }, diff --git a/src/agents/embedded-agent-runner/direct-compaction-preparation.ts b/src/agents/embedded-agent-runner/direct-compaction-preparation.ts index c286568339f5..93e3d17ddea4 100644 --- a/src/agents/embedded-agent-runner/direct-compaction-preparation.ts +++ b/src/agents/embedded-agent-runner/direct-compaction-preparation.ts @@ -4,7 +4,6 @@ */ import fs from "node:fs/promises"; import type { ThinkLevel } from "../../auto-reply/thinking.js"; -import { parseSqliteSessionFileMarker } from "../../config/sessions/sqlite-marker.js"; import { createDiagnosticTraceContext, freezeDiagnosticTraceContext, @@ -15,7 +14,6 @@ import type { ProviderRuntimeModel } from "../../plugins/provider-runtime-model. import { prepareProviderRuntimeAuth } from "../../plugins/provider-runtime.js"; import { resolveUserPath } from "../../utils.js"; import { resolveAgentDir, resolveSessionAgentIds } from "../agent-scope.js"; -import { ensureSessionHeader } from "../embedded-agent-helpers.js"; import { describeFailoverError } from "../failover-error.js"; import { ensureSelectedAgentHarnessPlugin } from "../harness/runtime-plugin.js"; import { MissingProviderAuthError } from "../model-auth.js"; @@ -322,14 +320,7 @@ export async function prepareDirectCompactionAttempt( } const effectiveCwd = sandbox?.enabled ? effectiveWorkspace : (requestedCwd ?? effectiveWorkspace); await fs.mkdir(effectiveWorkspace, { recursive: true }); - const isSqliteSessionTranscript = Boolean(parseSqliteSessionFileMarker(params.sessionFile)); - if (!isSqliteSessionTranscript) { - await ensureSessionHeader({ - sessionFile: params.sessionFile, - sessionId: params.sessionId, - cwd: effectiveCwd, - }); - } + const isSqliteSessionTranscript = true; const { sessionAgentId: effectiveSkillAgentId } = earlyAgentIds; return { diff --git a/src/agents/embedded-agent-runner/manual-compaction-boundary.test.ts b/src/agents/embedded-agent-runner/manual-compaction-boundary.test.ts deleted file mode 100644 index 291b8c95a323..000000000000 --- a/src/agents/embedded-agent-runner/manual-compaction-boundary.test.ts +++ /dev/null @@ -1,237 +0,0 @@ -// Coverage for hardening manual compaction checkpoints in transcript files. -import fs from "node:fs/promises"; -import os from "node:os"; -import path from "node:path"; -import type { AgentMessage } from "openclaw/plugin-sdk/agent-core"; -import { SessionManager } from "openclaw/plugin-sdk/agent-sessions"; -import type { AssistantMessage } from "openclaw/plugin-sdk/llm"; -import { afterEach, describe, expect, it, vi } from "vitest"; -import { hardenManualCompactionBoundary } from "./manual-compaction-boundary.js"; - -let tmpDir = ""; - -async function makeTmpDir(): Promise { - tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), "manual-compaction-boundary-")); - return tmpDir; -} - -afterEach(async () => { - if (tmpDir) { - await fs.rm(tmpDir, { recursive: true, force: true }).catch(() => {}); - tmpDir = ""; - } -}); - -function createAssistantTextMessage(text: string, timestamp: number): AssistantMessage { - return { - role: "assistant", - content: [{ type: "text", text }], - api: "responses", - provider: "openai", - model: "gpt-test", - usage: { - input: 1, - output: 1, - cacheRead: 0, - cacheWrite: 0, - totalTokens: 2, - cost: { - input: 0, - output: 0, - cacheRead: 0, - cacheWrite: 0, - total: 0, - }, - }, - stopReason: "stop", - timestamp, - }; -} - -function messageText(message: AgentMessage): string { - // Tests compare rebuilt context text across plain and block-style messages. - if (!("content" in message)) { - return ""; - } - const content = message.content; - if (typeof content === "string") { - return content; - } - if (!Array.isArray(content)) { - return ""; - } - const textBlocks: string[] = []; - for (const block of content) { - if (block && typeof block === "object" && "text" in block && typeof block.text === "string") { - textBlocks.push(block.text); - } - } - return textBlocks.join(" "); -} - -function requireString(value: string | undefined, label: string): string { - if (!value) { - throw new Error(`expected ${label}`); - } - return value; -} - -describe("hardenManualCompactionBoundary", () => { - it("turns manual compaction into a true checkpoint for rebuilt context", async () => { - // Manual compaction should make the latest summary the new root; otherwise - // future context rebuilds can pull summarized turns back into the prompt. - const dir = await makeTmpDir(); - const session = SessionManager.create(dir, dir); - - session.appendMessage({ role: "user", content: "old question", timestamp: 1 }); - session.appendMessage(createAssistantTextMessage("very long old answer", 2)); - const firstKeepId = requireString(session.getBranch().at(-1)?.id, "first keep id"); - session.appendCompaction("old summary", firstKeepId, 100); - - session.appendMessage({ role: "user", content: "new question", timestamp: 3 }); - session.appendMessage( - createAssistantTextMessage("detailed new answer that should be summarized away", 4), - ); - const secondKeepId = requireString(session.getBranch().at(-1)?.id, "second keep id"); - const latestCompactionId = session.appendCompaction("fresh summary", secondKeepId, 200); - const sessionFile = requireString(session.getSessionFile(), "session file"); - - const before = SessionManager.open(sessionFile); - const beforeTexts = before - .buildSessionContext() - .messages.map((message) => messageText(message)); - expect(beforeTexts.join("\n")).toContain("detailed new answer"); - - // Boundary hardening operates on file bytes after the session is persisted; - // reopening a manager here would hide direct rewrite mistakes. - const openSpy = vi.spyOn(SessionManager, "open").mockImplementation(() => { - throw new Error("SessionManager.open should not be used for boundary hardening"); - }); - const hardened = await hardenManualCompactionBoundary({ sessionFile }); - openSpy.mockRestore(); - expect(hardened.applied).toBe(true); - expect(hardened.firstKeptEntryId).toBe(latestCompactionId); - expect(hardened.messages.map((message) => message.role)).toEqual(["compactionSummary"]); - - const reopened = SessionManager.open(sessionFile); - const latest = reopened.getLeafEntry(); - expect(latest?.type).toBe("compaction"); - if (!latest || latest.type !== "compaction") { - throw new Error("expected latest leaf to be a compaction entry"); - } - expect(latest.firstKeptEntryId).toBe(latestCompactionId); - - reopened.appendMessage({ role: "user", content: "what was happening?", timestamp: 5 }); - const after = SessionManager.open(sessionFile); - const afterTexts = after.buildSessionContext().messages.map((message) => messageText(message)); - expect(after.buildSessionContext().messages.map((message) => message.role)).toEqual([ - "compactionSummary", - "user", - ]); - expect(afterTexts.join("\n")).not.toContain("detailed new answer"); - }); - - it("keeps the upstream recent tail when requested", async () => { - const dir = await makeTmpDir(); - const session = SessionManager.create(dir, dir); - - session.appendMessage({ role: "user", content: "old question", timestamp: 1 }); - session.appendMessage(createAssistantTextMessage("old answer", 2)); - const keepId = requireString(session.getBranch().at(-1)?.id, "keep id"); - const latestCompactionId = session.appendCompaction("fresh summary", keepId, 200); - const sessionFile = requireString(session.getSessionFile(), "session file"); - - const hardened = await hardenManualCompactionBoundary({ - sessionFile, - preserveRecentTail: true, - }); - expect(hardened.applied).toBe(false); - expect(hardened.firstKeptEntryId).toBe(keepId); - - const reopened = SessionManager.open(sessionFile); - const latest = reopened.getLeafEntry(); - expect(latest?.type).toBe("compaction"); - if (!latest || latest.type !== "compaction") { - throw new Error("expected latest leaf to be a compaction entry"); - } - expect(latest.id).toBe(latestCompactionId); - expect(latest.firstKeptEntryId).toBe(keepId); - expect(reopened.buildSessionContext().messages.map((message) => message.role)).toEqual([ - "compactionSummary", - "assistant", - ]); - }); - - it("keeps the recent tail when manual compaction produced an empty summary", async () => { - const dir = await makeTmpDir(); - const session = SessionManager.create(dir, dir); - - session.appendMessage({ role: "user", content: "old question", timestamp: 1 }); - session.appendMessage(createAssistantTextMessage("old answer", 2)); - session.appendMessage({ role: "user", content: "fresh question", timestamp: 3 }); - const keepId = requireString(session.getBranch().at(-1)?.id, "keep id"); - session.appendMessage(createAssistantTextMessage("fresh answer", 4)); - session.appendCompaction("", keepId, 200); - const sessionFile = requireString(session.getSessionFile(), "session file"); - - const hardened = await hardenManualCompactionBoundary({ sessionFile }); - expect(hardened.applied).toBe(false); - expect(hardened.firstKeptEntryId).toBe(keepId); - expect(hardened.messages.map((message) => message.role)).toEqual([ - "compactionSummary", - "user", - "assistant", - ]); - expect(hardened.messages.map((message) => messageText(message)).join("\n")).toContain( - "fresh question", - ); - - const reopened = SessionManager.open(sessionFile); - const latest = reopened.getLeafEntry(); - expect(latest?.type).toBe("compaction"); - if (!latest || latest.type !== "compaction") { - throw new Error("expected latest leaf to be a compaction entry"); - } - expect(latest.firstKeptEntryId).toBe(keepId); - }); - - it("keeps the recent tail when manual compaction had no messages to summarize", async () => { - const dir = await makeTmpDir(); - const session = SessionManager.create(dir, dir); - - session.appendMessage({ role: "user", content: "fresh question", timestamp: 1 }); - const keepId = requireString(session.getBranch().at(-1)?.id, "keep id"); - session.appendMessage(createAssistantTextMessage("fresh answer", 2)); - session.appendCompaction("No prior history.", keepId, 200); - const sessionFile = requireString(session.getSessionFile(), "session file"); - - const hardened = await hardenManualCompactionBoundary({ sessionFile }); - expect(hardened.applied).toBe(false); - expect(hardened.firstKeptEntryId).toBe(keepId); - expect(hardened.messages.map((message) => message.role)).toEqual([ - "compactionSummary", - "user", - "assistant", - ]); - - const reopened = SessionManager.open(sessionFile); - const latest = reopened.getLeafEntry(); - expect(latest?.type).toBe("compaction"); - if (!latest || latest.type !== "compaction") { - throw new Error("expected latest leaf to be a compaction entry"); - } - expect(latest.firstKeptEntryId).toBe(keepId); - }); - - it("is a no-op when the latest leaf is not a compaction entry", async () => { - const dir = await makeTmpDir(); - const session = SessionManager.create(dir, dir); - session.appendMessage({ role: "user", content: "hello", timestamp: 1 }); - session.appendMessage(createAssistantTextMessage("hi", 2)); - const sessionFile = requireString(session.getSessionFile(), "session file"); - - const result = await hardenManualCompactionBoundary({ sessionFile }); - expect(result.applied).toBe(false); - expect(result.messages.map((message) => message.role)).toEqual(["user", "assistant"]); - }); -}); diff --git a/src/agents/embedded-agent-runner/manual-compaction-boundary.ts b/src/agents/embedded-agent-runner/manual-compaction-boundary.ts deleted file mode 100644 index ec26edb691e0..000000000000 --- a/src/agents/embedded-agent-runner/manual-compaction-boundary.ts +++ /dev/null @@ -1,150 +0,0 @@ -/** - * Hardens manual compaction transcript boundaries after explicit `/compact`. - */ -import type { AgentMessage } from "../runtime/index.js"; -import type { SessionEntry } from "../sessions/index.js"; -import { - readTranscriptFileState, - TranscriptFileState, - writeTranscriptFileAtomic, -} from "./transcript-file-state.js"; - -type CompactionEntry = Extract; - -type HardenedManualCompactionBoundary = { - applied: boolean; - firstKeptEntryId?: string; - leafId?: string; - messages: AgentMessage[]; -}; - -function replaceLatestCompactionBoundary(params: { - entries: SessionEntry[]; - compactionEntryId: string; -}): SessionEntry[] { - return params.entries.map((entry) => { - if (entry.type !== "compaction" || entry.id !== params.compactionEntryId) { - return entry; - } - return { - ...entry, - // Manual /compact is an explicit checkpoint request, so make the - // rebuilt context start from the summary itself instead of preserving - // an upstream "recent tail" that can keep large prior turns alive. - firstKeptEntryId: entry.id, - } satisfies CompactionEntry; - }); -} - -function entryCreatesCompactionInputMessage(entry: SessionEntry): boolean { - return ( - entry.type === "message" || entry.type === "custom_message" || entry.type === "branch_summary" - ); -} - -function hasMessagesToSummarizeBeforeKeptTail(params: { - branch: SessionEntry[]; - compaction: CompactionEntry; -}): boolean { - const compactionIndex = params.branch.findIndex((entry) => entry.id === params.compaction.id); - const firstKeptIndex = params.branch.findIndex( - (entry) => entry.id === params.compaction.firstKeptEntryId, - ); - if (compactionIndex <= 0 || firstKeptIndex < 0 || firstKeptIndex >= compactionIndex) { - return false; - } - - let boundaryStartIndex = 0; - for (let i = compactionIndex - 1; i >= 0; i -= 1) { - const entry = params.branch[i]; - if (entry?.type !== "compaction") { - continue; - } - const previousFirstKeptIndex = params.branch.findIndex( - (candidate) => candidate.id === entry.firstKeptEntryId, - ); - boundaryStartIndex = previousFirstKeptIndex >= 0 ? previousFirstKeptIndex : i + 1; - break; - } - - return params.branch - .slice(boundaryStartIndex, firstKeptIndex) - .some((entry) => entryCreatesCompactionInputMessage(entry)); -} - -/** Rewrite the latest manual compaction leaf so replay starts from its summary. */ -export async function hardenManualCompactionBoundary(params: { - sessionFile: string; - preserveRecentTail?: boolean; -}): Promise { - const state = await readTranscriptFileState(params.sessionFile); - const header = state.getHeader(); - if (!header) { - return { - applied: false, - messages: [], - }; - } - - const leaf = state.getLeafEntry(); - if (leaf?.type !== "compaction") { - const sessionContext = state.buildSessionContext(); - return { - applied: false, - leafId: state.getLeafId() ?? undefined, - messages: sessionContext.messages, - }; - } - - const sessionContext = state.buildSessionContext(); - if (params.preserveRecentTail) { - return { - applied: false, - firstKeptEntryId: leaf.firstKeptEntryId, - leafId: state.getLeafId() ?? undefined, - messages: sessionContext.messages, - }; - } - - if (leaf.firstKeptEntryId === leaf.id) { - return { - applied: false, - firstKeptEntryId: leaf.id, - leafId: state.getLeafId() ?? undefined, - messages: sessionContext.messages, - }; - } - - if ( - !leaf.summary.trim() || - !hasMessagesToSummarizeBeforeKeptTail({ - branch: state.getBranch(leaf.id), - compaction: leaf, - }) - ) { - return { - applied: false, - firstKeptEntryId: leaf.firstKeptEntryId, - leafId: state.getLeafId() ?? undefined, - messages: sessionContext.messages, - }; - } - - const replacedEntries = replaceLatestCompactionBoundary({ - entries: state.getEntries(), - compactionEntryId: leaf.id, - }); - const replacedState = new TranscriptFileState({ - header, - entries: replacedEntries, - }); - await writeTranscriptFileAtomic(params.sessionFile, [header, ...replacedEntries]); - - const replacedSessionContext = replacedState.buildSessionContext(); - return { - applied: true, - firstKeptEntryId: leaf.id, - leafId: replacedState.getLeafId() ?? undefined, - messages: replacedSessionContext.messages, - }; -} diff --git a/src/agents/embedded-agent-runner/replay-history.test.ts b/src/agents/embedded-agent-runner/replay-history.test.ts index 6b386a5bea43..6ac1cacb3503 100644 --- a/src/agents/embedded-agent-runner/replay-history.test.ts +++ b/src/agents/embedded-agent-runner/replay-history.test.ts @@ -545,8 +545,7 @@ describe("normalizeAssistantReplayContent", () => { }); it("drops a trailing assistant turn that already carries the persisted sentinel content (#77228)", () => { - // Covers the case where session-file-repair persisted the sentinel to - // disk; on the next turn the loaded transcript ends with a non-empty + // Covers a doctor-imported legacy sentinel; on the next turn the loaded transcript ends with a non-empty // assistant turn whose only content is the sentinel text. Provider // request must still end with user. const persistedSentinel = bedrockAssistant([{ type: "text", text: FALLBACK_TEXT }], "error"); diff --git a/src/agents/embedded-agent-runner/replay-history.ts b/src/agents/embedded-agent-runner/replay-history.ts index b650bed1ee87..585d287369d2 100644 --- a/src/agents/embedded-agent-runner/replay-history.ts +++ b/src/agents/embedded-agent-runner/replay-history.ts @@ -427,9 +427,8 @@ function isReplayDroppableTrailingAssistant(message: AgentMessage | undefined): const stopReason = (message as { stopReason?: unknown }).stopReason; return stopReason === "error" || isZeroUsageEmptyStopAssistantTurn(message); } - // Sentinel-text content is the post-rewrite shape produced by either - // session-file-repair.rewriteAssistantEntryWithEmptyContent (always - // stopReason="error") or the in-memory rewrite earlier in this same + // Sentinel-text content is the post-rewrite shape produced by either a + // doctor-imported legacy repair (always stopReason="error") or the in-memory rewrite earlier in this same // normalizeAssistantReplayContent loop (preserves the original // stopReason — "error" or zero-usage "stop"). Drop only when the trailing // turn carries that synthetic provenance: without this guard, a real diff --git a/src/agents/embedded-agent-runner/run-orchestrator.ts b/src/agents/embedded-agent-runner/run-orchestrator.ts index b5a21848d17f..a790b4404813 100644 --- a/src/agents/embedded-agent-runner/run-orchestrator.ts +++ b/src/agents/embedded-agent-runner/run-orchestrator.ts @@ -116,10 +116,11 @@ async function runEmbeddedAgentInternal( }); let params: RunEmbeddedAgentParamsWithSessionFile = withExecutionPhaseDiagnostics({ ...paramsBase, - agentId: paramsBase.agentId ?? runSessionTarget.agentId, + agentId: runSessionTarget.agentId, sessionId: runSessionTarget.sessionId, - sessionKey: normalizeOptionalString(effectiveSessionKey ?? runSessionTarget.sessionKey), - sessionFile: runSessionTarget.sessionFile, + sessionKey: runSessionTarget.sessionKey, + sessionTarget: runSessionTarget, + sessionFile: runSessionTarget.sessionKey, skillWorkshopProposalMutationBudget, }); const sessionLane = resolveSessionLane(params.sessionKey?.trim() || params.sessionId); diff --git a/src/agents/embedded-agent-runner/run-state.ts b/src/agents/embedded-agent-runner/run-state.ts index 236cb5b79067..35e844e2d852 100644 --- a/src/agents/embedded-agent-runner/run-state.ts +++ b/src/agents/embedded-agent-runner/run-state.ts @@ -86,7 +86,7 @@ export const ACTIVE_EMBEDDED_RUNS = export const ACTIVE_EMBEDDED_RUNS_BY_RUN_ID = embeddedRunState.activeRunsByRunId ?? (embeddedRunState.activeRunsByRunId = new Map()); -export const ACTIVE_EMBEDDED_RUN_LIFECYCLE_GENERATIONS = +const ACTIVE_EMBEDDED_RUN_LIFECYCLE_GENERATIONS = embeddedRunState.activeRunLifecycleGenerations ?? (embeddedRunState.activeRunLifecycleGenerations = new WeakMap< EmbeddedAgentQueueHandle, diff --git a/src/agents/embedded-agent-runner/run.overflow-compaction.harness.ts b/src/agents/embedded-agent-runner/run.overflow-compaction.harness.ts index 5819ce147fac..9c77bf8d6750 100644 --- a/src/agents/embedded-agent-runner/run.overflow-compaction.harness.ts +++ b/src/agents/embedded-agent-runner/run.overflow-compaction.harness.ts @@ -419,7 +419,6 @@ export const overflowBaseRunParams = { agentId: "main", sessionId: "test-session", sessionKey: "test-key", - sessionFile: "/tmp/session.json", workspaceDir: "/tmp/workspace", prompt: "hello", timeoutMs: 30000, diff --git a/src/agents/embedded-agent-runner/run.overflow-compaction.loop.test.ts b/src/agents/embedded-agent-runner/run.overflow-compaction.loop.test.ts index e86bbcd11755..9baac3e6d4b3 100644 --- a/src/agents/embedded-agent-runner/run.overflow-compaction.loop.test.ts +++ b/src/agents/embedded-agent-runner/run.overflow-compaction.loop.test.ts @@ -823,7 +823,7 @@ describe("overflow compaction in run loop", () => { expect( requireMockCallArg(mockedSessionLikelyHasOversizedToolResults, 0).contextWindowTokens, ).toBe(200000); - expectTruncationScopeSessionFile(0, "/tmp/session.json"); + expectTruncationScopeSessionFile(0, baseParams.sessionKey); expect(mockedRunEmbeddedAttempt).toHaveBeenCalledTimes(2); expectLogIncludes(mockedLog.info, "Truncated 1 tool result(s)"); expect(result.meta.error).toBeUndefined(); @@ -869,7 +869,7 @@ describe("overflow compaction in run loop", () => { const oversizedArgs = requireMockCallArg(mockedSessionLikelyHasOversizedToolResults, 0); const messages = oversizedArgs.messages as Array<{ role?: string }>; expect(messages.filter((message) => message.role === "toolResult")).toHaveLength(3); - expectTruncationScopeSessionFile(0, "/tmp/session.json"); + expectTruncationScopeSessionFile(0, baseParams.sessionKey); expect(mockedRunEmbeddedAttempt).toHaveBeenCalledTimes(2); expectLogIncludes(mockedLog.info, "Truncated 2 tool result(s)"); expect(result.meta.error).toBeUndefined(); @@ -1010,7 +1010,7 @@ describe("overflow compaction in run loop", () => { const result = await runEmbeddedAgent(baseParams); expect(mockedCompactDirect).toHaveBeenCalledTimes(1); - expectTruncationScopeSessionFile(0, "/tmp/session.json"); + expectTruncationScopeSessionFile(0, baseParams.sessionKey); expect(mockedRunEmbeddedAttempt).toHaveBeenCalledTimes(2); expectLogIncludes(mockedLog.info, "post-compaction tool-result truncation succeeded"); expect(result.meta.error).toBeUndefined(); diff --git a/src/agents/embedded-agent-runner/run.overflow-compaction.test.ts b/src/agents/embedded-agent-runner/run.overflow-compaction.test.ts index 40ac761e66b4..c14fc04c1cc4 100644 --- a/src/agents/embedded-agent-runner/run.overflow-compaction.test.ts +++ b/src/agents/embedded-agent-runner/run.overflow-compaction.test.ts @@ -3719,7 +3719,7 @@ describe("runEmbeddedAgent overflow compaction trigger routing", () => { expectRecordFields(mockCallArg(mockedGlobalHookRunner.runBeforeCompaction), { messageCount: -1, - sessionFile: "/tmp/session.json", + sessionFile: "test-key", }); expectRecordFields(mockCallArg(mockedGlobalHookRunner.runBeforeCompaction, 0, 1), { sessionKey: "test-key", @@ -3728,7 +3728,7 @@ describe("runEmbeddedAgent overflow compaction trigger routing", () => { messageCount: -1, compactedCount: -1, tokenCount: 50, - sessionFile: "/tmp/session.json", + sessionFile: "test-key", }); expectRecordFields(mockCallArg(mockedGlobalHookRunner.runAfterCompaction, 0, 1), { sessionKey: "test-key", @@ -3755,7 +3755,7 @@ describe("runEmbeddedAgent overflow compaction trigger routing", () => { contextEngine: mockedContextEngine, sessionId: "test-session", sessionKey: "test-key", - sessionFile: "/tmp/session.json", + sessionFile: "test-key", reason: "compaction", }); expectRecordFields(maintenanceParams.runtimeContext, { @@ -3867,13 +3867,13 @@ describe("runEmbeddedAgent overflow compaction trigger routing", () => { mockedRunEmbeddedAttempt, { sessionId: "rotated-session", - sessionFile: `sqlite:main:rotated-session:${rotatedStorePath}`, + sessionFile: "test-key", }, 1, ); expectMockCallFields(mockedRunContextEngineMaintenance, { sessionId: "rotated-session", - sessionFile: `sqlite:main:rotated-session:${rotatedStorePath}`, + sessionFile: "test-key", }); }); diff --git a/src/agents/embedded-agent-runner/run.timeout-triggered-compaction.test.ts b/src/agents/embedded-agent-runner/run.timeout-triggered-compaction.test.ts index 83749e0bc43b..967a619a3d49 100644 --- a/src/agents/embedded-agent-runner/run.timeout-triggered-compaction.test.ts +++ b/src/agents/embedded-agent-runner/run.timeout-triggered-compaction.test.ts @@ -607,7 +607,10 @@ describe("timeout-triggered compaction", () => { await runEmbeddedAgent(overflowBaseRunParams); const [beforeEvent, beforeContext] = hookCallAt(0, "before"); - expect(beforeEvent).toEqual({ messageCount: -1, sessionFile: "/tmp/session.json" }); + expect(beforeEvent).toEqual({ + messageCount: -1, + sessionFile: overflowBaseRunParams.sessionKey, + }); expect(beforeContext.sessionKey).toBe("test-key"); const [afterEvent, afterContext] = hookCallAt(0, "after"); expect(afterEvent).toEqual({ diff --git a/src/agents/embedded-agent-runner/run/attempt-abort.test.ts b/src/agents/embedded-agent-runner/run/attempt-abort.test.ts index 66f3960f14ad..45333f7bec16 100644 --- a/src/agents/embedded-agent-runner/run/attempt-abort.test.ts +++ b/src/agents/embedded-agent-runner/run/attempt-abort.test.ts @@ -6,6 +6,7 @@ import { createEmbeddedAttemptRunAbort, type EmbeddedAttemptAbortStatePort, } from "./attempt-abort.js"; +import { SESSIONS_YIELD_ABORT_REASON } from "./attempt.sessions-yield.js"; const mocks = vi.hoisted(() => ({ countActiveToolExecutions: vi.fn(() => 0), @@ -160,6 +161,31 @@ describe("createEmbeddedAttemptExternalAbortController", () => { }); describe("createEmbeddedAttemptRunAbort", () => { + it("releases session ownership non-terminally for sessions_yield handoff", async () => { + const releaseHeldLockForAbort = vi.fn(async () => {}); + const abortRun = createEmbeddedAttemptRunAbort({ + abortActiveSession: vi.fn(async () => {}), + activeSession: { abortCompaction: vi.fn(), isCompacting: false }, + attempt: { + runId: "run-yield", + sessionFile: "agent:main:main", + sessionId: "session-yield", + sessionKey: "agent:main:main", + }, + getQueueHandle: () => undefined, + isProbeSession: false, + log: { warn: vi.fn() }, + runAbortController: new AbortController(), + sessionLockController: { releaseHeldLockForAbort }, + state: createAbortState().port, + }); + + abortRun(false, SESSIONS_YIELD_ABORT_REASON); + await vi.waitFor(() => { + expect(releaseHeldLockForAbort).toHaveBeenCalledWith({ terminal: false }); + }); + }); + it("settles timeout state, session work, queue ownership, and the lock", async () => { const state = createAbortState(); const timeoutReason = new Error("attempt deadline"); diff --git a/src/agents/embedded-agent-runner/run/attempt-abort.ts b/src/agents/embedded-agent-runner/run/attempt-abort.ts index 493972744c01..fd14afeb88fd 100644 --- a/src/agents/embedded-agent-runner/run/attempt-abort.ts +++ b/src/agents/embedded-agent-runner/run/attempt-abort.ts @@ -6,6 +6,7 @@ import { isSignalTimeoutReason } from "../../failover-error.js"; import type { AgentSession } from "../../sessions/index.js"; import { markActiveEmbeddedRunAbandoned, type EmbeddedAgentQueueHandle } from "../runs.js"; import type { EmbeddedAttemptSessionLockController } from "./attempt.session-lock.js"; +import { isSessionsYieldAbortError, isSessionsYieldAbortReason } from "./attempt.sessions-yield.js"; import { shouldFlagCompactionTimeout } from "./compaction-timeout.js"; import type { EmbeddedRunAttemptParams } from "./types.js"; @@ -220,6 +221,8 @@ export function createEmbeddedAttemptRunAbort(input: { log: input.log, runId: input.attempt.runId, abortKind: isTimeout ? "timeout abort" : "abort", + terminal: + isTimeout || (!isSessionsYieldAbortError(reason) && !isSessionsYieldAbortReason(reason)), }); }; } @@ -234,10 +237,13 @@ function releaseEmbeddedAttemptSessionLockForAbort(params: { log: AbortLockReleaseLog; runId: string; abortKind: "abort" | "timeout abort"; + terminal: boolean; }): void { - void params.sessionLockController.releaseHeldLockForAbort().catch((err: unknown) => { - params.log.warn( - `failed to release session lock on ${params.abortKind}: runId=${params.runId} ${String(err)}`, - ); - }); + void params.sessionLockController + .releaseHeldLockForAbort({ terminal: params.terminal }) + .catch((err: unknown) => { + params.log.warn( + `failed to release session lock on ${params.abortKind}: runId=${params.runId} ${String(err)}`, + ); + }); } diff --git a/src/agents/embedded-agent-runner/run/attempt-after-turn.ts b/src/agents/embedded-agent-runner/run/attempt-after-turn.ts index f4fa82b3fc11..a7694531eb34 100644 --- a/src/agents/embedded-agent-runner/run/attempt-after-turn.ts +++ b/src/agents/embedded-agent-runner/run/attempt-after-turn.ts @@ -3,7 +3,6 @@ */ import { OPENCLAW_EMBEDDED_CONTEXT_ENGINE_HOST } from "../../../context-engine/host-compat.js"; import type { ContextEngine } from "../../../context-engine/types.js"; -import { captureAgentRunLifecycleGeneration } from "../../../infra/agent-events.js"; import { freezeDiagnosticTraceContext } from "../../../infra/diagnostic-trace-context.js"; import { formatErrorMessage } from "../../../infra/errors.js"; import type { getGlobalHookRunner } from "../../../plugins/hook-runner-global.js"; @@ -15,13 +14,8 @@ import { runAgentEndSideEffects } from "../../harness/agent-end-side-effects.js" import type { AgentMessage } from "../../runtime/index.js"; import type { AgentSession, SessionManager } from "../../sessions/index.js"; import type { NormalizedUsage } from "../../usage.js"; -import { - rotateTranscriptAfterCompaction, - shouldRotateCompactionTranscript, -} from "../compaction-successor-transcript.js"; import { runContextEngineMaintenance } from "../context-engine-maintenance.js"; import { log } from "../logger.js"; -import { updateActiveEmbeddedRunSessionFile } from "../runs.js"; import { buildEmbeddedAgentEndContext } from "./agent-end-context.js"; import { finalizeAttemptContextEngineTurn, @@ -86,8 +80,8 @@ type CompleteEmbeddedAttemptAfterTurnInput = { export async function completeEmbeddedAttemptAfterTurn( input: CompleteEmbeddedAttemptAfterTurnInput, ): Promise<{ sessionIdUsed: string; sessionFileUsed?: string }> { - const { attempt, activeContextEngine, activeSession, sessionManager, state, runtime } = input; - let { sessionIdUsed, sessionFileUsed } = state; + const { attempt, activeContextEngine, sessionManager, state, runtime } = input; + const { sessionIdUsed, sessionFileUsed } = state; // Context-engine hooks may call runtime LLM capabilities. Only the transcript // rewrite callback reacquires the synchronous session write boundary. @@ -146,7 +140,6 @@ export async function completeEmbeddedAttemptAfterTurn( } if (!state.beforeAgentFinalizeRevisionReason) { - await input.sessionLockController.waitForSessionEvents(activeSession); await input.withOwnedSessionWriteLock(async () => { const lifecycleState = input.readLifecycleState(); if ( @@ -168,40 +161,6 @@ export async function completeEmbeddedAttemptAfterTurn( log.warn(`failed to persist bootstrap completion entry: ${String(entryErr)}`); } } - - if ( - state.compactionOccurredThisAttempt && - !state.promptError && - !lifecycleState.aborted && - !lifecycleState.timedOut && - !lifecycleState.idleTimedOut && - !lifecycleState.timedOutDuringCompaction && - shouldRotateCompactionTranscript(attempt.config) - ) { - try { - const rotation = await rotateTranscriptAfterCompaction({ - sessionManager, - sessionFile: attempt.sessionFile, - }); - if (rotation.rotated) { - sessionIdUsed = rotation.sessionId ?? sessionIdUsed; - sessionFileUsed = rotation.sessionFile ?? sessionFileUsed; - updateActiveEmbeddedRunSessionFile( - attempt.sessionId, - sessionFileUsed, - attempt.lifecycleGeneration ?? captureAgentRunLifecycleGeneration(attempt.runId), - ); - log.info( - `[compaction] rotated active transcript after automatic compaction ` + - `(sessionKey=${attempt.sessionKey ?? attempt.sessionId})`, - ); - } - } catch (err) { - log.warn("[compaction] automatic transcript rotation failed", { - errorMessage: formatErrorMessage(err), - }); - } - } }); } diff --git a/src/agents/embedded-agent-runner/run/attempt-dispatch-preparation.ts b/src/agents/embedded-agent-runner/run/attempt-dispatch-preparation.ts index acc0254469f4..617374d88ea3 100644 --- a/src/agents/embedded-agent-runner/run/attempt-dispatch-preparation.ts +++ b/src/agents/embedded-agent-runner/run/attempt-dispatch-preparation.ts @@ -104,9 +104,16 @@ export async function prepareAndDispatchEmbeddedRunAttempt(input: { runtimeAuthState: runtime.runtimeAuthState, }); const attemptFastMode = resolveAttemptFastModeParam(); - const trajectorySessionFile = resolvedSessionKey - ? ( - await resolveSessionTranscriptRuntimeReadTarget({ + const existingSessionTarget = sessionPromptState.sessionTarget; + const reusableSessionTarget = + existingSessionTarget?.sessionKey === resolvedSessionKey || + sessionPromptState.sessionTargetAdopted + ? existingSessionTarget + : undefined; + const resolvedTranscriptTarget = + reusableSessionTarget ?? + (resolvedSessionKey + ? await resolveSessionTranscriptRuntimeReadTarget({ agentId: workspaceResolution.agentId, sessionId: sessionPromptState.sessionId, sessionKey: resolvedSessionKey, @@ -114,8 +121,11 @@ export async function prepareAndDispatchEmbeddedRunAttempt(input: { agentId: workspaceResolution.agentId, }), }) - ).sessionFile - : sessionPromptState.sessionFile; + : undefined); + const resolvedSessionTarget = resolvedTranscriptTarget + ? { ...sessionPromptState.sessionTarget, ...resolvedTranscriptTarget } + : sessionPromptState.sessionTarget; + const trajectorySessionFile = resolvedSessionTarget?.sessionKey ?? sessionPromptState.sessionFile; if (!input.startupStagesEmitted) { startupStages.mark(EMBEDDED_RUN_ATTEMPT_DISPATCH_STAGE.prompt); } @@ -151,6 +161,19 @@ export async function prepareAndDispatchEmbeddedRunAttempt(input: { sessionId: sessionPromptState.sessionId, sessionKey: resolvedSessionKey, sessionFile: trajectorySessionFile, + ...(resolvedSessionTarget?.agentId && + resolvedSessionTarget.sessionId && + resolvedSessionTarget.sessionKey && + resolvedSessionTarget.storePath + ? { + sessionTarget: { + agentId: resolvedSessionTarget.agentId, + sessionId: resolvedSessionTarget.sessionId, + sessionKey: resolvedSessionTarget.sessionKey, + storePath: resolvedSessionTarget.storePath, + }, + } + : {}), provider: trajectoryAttribution.provider, modelId: trajectoryAttribution.modelId, modelApi: trajectoryAttribution.modelApi, @@ -170,7 +193,7 @@ export async function prepareAndDispatchEmbeddedRunAttempt(input: { runtime: { sessionId: sessionPromptState.sessionId, sessionFile: sessionPromptState.sessionFile, - sessionTarget: sessionPromptState.sessionTarget, + sessionTarget: resolvedSessionTarget, sessionKey: resolvedSessionKey, trajectorySessionFile, trajectoryRecorder: trajectoryRecorder ?? undefined, diff --git a/src/agents/embedded-agent-runner/run/attempt-normalization.test.ts b/src/agents/embedded-agent-runner/run/attempt-normalization.test.ts new file mode 100644 index 000000000000..6b54c50abdc2 --- /dev/null +++ b/src/agents/embedded-agent-runner/run/attempt-normalization.test.ts @@ -0,0 +1,209 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; +import { applyEmbeddedAttemptSessionIdentity } from "./attempt-session-identity.js"; +import { buildContextEngineCompactionSessionTarget } from "./session-bootstrap.js"; + +const sessionAccessorMocks = vi.hoisted(() => ({ + listSessionEntries: vi.fn(() => []), + loadSessionEntry: vi.fn(), +})); + +vi.mock("../../../config/sessions/session-accessor.js", () => sessionAccessorMocks); + +beforeEach(() => { + sessionAccessorMocks.listSessionEntries.mockReset().mockReturnValue([]); + sessionAccessorMocks.loadSessionEntry.mockReset(); +}); + +describe("buildContextEngineCompactionSessionTarget", () => { + it("uses the marker session id as the fallback compatibility key", () => { + expect( + buildContextEngineCompactionSessionTarget({ + sessionFile: "sqlite:main:marker-session:/tmp/sessions.json", + sessionId: "stale-outer-session", + }), + ).toMatchObject({ + agentId: "main", + sessionId: "marker-session", + sessionKey: "marker-session", + storePath: "/tmp/sessions.json", + }); + }); + + it("uses the configured default agent for an unscoped compatibility key", () => { + expect( + buildContextEngineCompactionSessionTarget({ + config: { + agents: { list: [{ id: "main" }, { id: "worker", default: true }] }, + session: { store: "/tmp/{agentId}/sessions.json" }, + }, + sessionFile: "compat-session", + sessionId: "compat-session", + }), + ).toMatchObject({ + agentId: "worker", + sessionId: "compat-session", + sessionKey: "compat-session", + storePath: "/tmp/worker/sessions.json", + }); + }); + + it("uses an adopted target session id when no session key is available", () => { + expect( + buildContextEngineCompactionSessionTarget({ + sessionFile: "", + sessionId: "previous-session", + sessionTarget: { + agentId: "main", + sessionId: "adopted-session", + storePath: "/tmp/sessions.json", + }, + }), + ).toMatchObject({ + agentId: "main", + sessionId: "adopted-session", + sessionKey: "adopted-session", + storePath: "/tmp/sessions.json", + }); + }); +}); + +function promptState(storePath = "/tmp/sessions.json") { + return { + sessionId: "session-before", + sessionFile: "agent:main:main", + sessionTarget: { + agentId: "main", + sessionId: "session-before", + sessionKey: "agent:main:main", + storePath, + }, + adoptSessionId: vi.fn(), + }; +} + +describe("applyEmbeddedAttemptSessionIdentity", () => { + it("rejects a legacy successor file that cannot map to SQLite", () => { + const state = promptState(); + + expect(() => + applyEmbeddedAttemptSessionIdentity({ + sessionPromptState: state, + sessionIdUsed: "session-after", + sessionFileUsed: "/tmp/session-after.jsonl", + }), + ).toThrow("successor files are unsupported"); + expect(state.adoptSessionId).not.toHaveBeenCalled(); + expect(state.sessionTarget).toMatchObject({ sessionId: "session-before" }); + }); + + it("resolves a legacy SQLite marker successor", () => { + const state = promptState(); + + applyEmbeddedAttemptSessionIdentity({ + sessionPromptState: state, + sessionIdUsed: "session-after", + sessionFileUsed: "sqlite:main:session-after:/tmp/sessions.json", + }); + + expect(state.sessionTarget).toMatchObject({ + agentId: "main", + sessionId: "session-after", + sessionKey: "agent:main:main", + storePath: "/tmp/sessions.json", + }); + }); + + it("rebinds a legacy SQLite marker successor over the retained active entry", () => { + sessionAccessorMocks.loadSessionEntry.mockReturnValue({ + sessionId: "session-before", + updatedAt: 1, + }); + const state = promptState(); + + applyEmbeddedAttemptSessionIdentity({ + sessionPromptState: state, + sessionIdUsed: "session-after", + sessionFileUsed: "sqlite:main:session-after:/tmp/sessions.json", + }); + + expect(state.sessionTarget).toEqual({ + agentId: "main", + sessionId: "session-after", + sessionKey: "agent:main:main", + storePath: "/tmp/sessions.json", + }); + }); + + it("rejects a legacy marker successor already mapped to another key", () => { + sessionAccessorMocks.loadSessionEntry.mockReturnValue({ + sessionId: "session-before", + updatedAt: 1, + }); + sessionAccessorMocks.listSessionEntries.mockReturnValue([ + { + sessionKey: "agent:main:other", + entry: { sessionId: "session-after", updatedAt: 2 }, + }, + ] as never); + const state = promptState(); + + expect(() => + applyEmbeddedAttemptSessionIdentity({ + sessionPromptState: state, + sessionIdUsed: "session-after", + sessionFileUsed: "sqlite:main:session-after:/tmp/sessions.json", + }), + ).toThrow("successor target changed the active session binding"); + }); + + it("rejects a legacy SQLite marker outside the active store", () => { + const state = promptState(); + + expect(() => + applyEmbeddedAttemptSessionIdentity({ + sessionPromptState: state, + sessionIdUsed: "session-after", + sessionFileUsed: "sqlite:main:session-after:/tmp/other-sessions.json", + }), + ).toThrow("successor target changed the active session binding"); + }); + + it.each(["sqlite:other:session-after:/tmp/sessions.json", "agent:other:main"])( + "rejects a cross-agent legacy successor identity: %s", + (sessionFileUsed) => { + const state = promptState(); + + expect(() => + applyEmbeddedAttemptSessionIdentity({ + sessionPromptState: state, + sessionIdUsed: "session-after", + sessionFileUsed, + }), + ).toThrow(/successor (identity is inconsistent|files are unsupported)/u); + }, + ); + + it("retargets an id-only successor without discarding its SQLite identity", () => { + const state = promptState(); + + applyEmbeddedAttemptSessionIdentity({ + sessionPromptState: state, + sessionIdUsed: "session-after", + }); + + expect(state.sessionTarget).toMatchObject({ sessionId: "session-after" }); + }); + + it("refreshes a legacy marker for an id-only successor", () => { + const state = promptState(); + state.sessionFile = "sqlite:main:session-before:/tmp/sessions.json"; + + applyEmbeddedAttemptSessionIdentity({ + sessionPromptState: state, + sessionIdUsed: "session-after", + }); + + expect(state.sessionFile).toBe("sqlite:main:session-after:/tmp/sessions.json"); + expect(state.sessionTarget).toMatchObject({ sessionId: "session-after" }); + }); +}); diff --git a/src/agents/embedded-agent-runner/run/attempt-normalization.ts b/src/agents/embedded-agent-runner/run/attempt-normalization.ts index 4a8b18f8a800..50a1c8f0b2ce 100644 --- a/src/agents/embedded-agent-runner/run/attempt-normalization.ts +++ b/src/agents/embedded-agent-runner/run/attempt-normalization.ts @@ -1,4 +1,3 @@ -import { parseSqliteSessionFileMarker } from "../../../config/sessions/sqlite-marker.js"; import { projectAgentRunAttemptTerminal } from "../../agent-run-terminal-outcome.js"; import { formatAssistantErrorText } from "../../embedded-agent-helpers.js"; import { createAgentRunDirectAbortError } from "../../run-termination.js"; @@ -9,6 +8,7 @@ import { createEmbeddedRunReplayState, observeReplayMetadata } from "../replay-s import type { EmbeddedAgentRunResult } from "../types.js"; import type { createUsageAccumulator } from "../usage-accumulator.js"; import { mergeUsageIntoAccumulator } from "../usage-accumulator.js"; +import { applyEmbeddedAttemptSessionIdentity } from "./attempt-session-identity.js"; import type { createEmbeddedRunContextRecoveryState } from "./context-recovery-state.js"; import type { PreparedEmbeddedRunInput } from "./execution-context.js"; import { resolveRunFailoverDecision } from "./failover-policy.js"; @@ -41,6 +41,7 @@ import { type PreparedRuntime = Awaited>; type SessionPromptState = ReturnType; + type ReplayState = ReturnType; export async function normalizeEmbeddedRunAttempt(input: { @@ -144,26 +145,7 @@ export async function normalizeEmbeddedRunAttempt(input: { aborted: terminalAborted, }); }; - const previousSessionId = sessionPromptState.sessionId; - const previousSessionFile = sessionPromptState.sessionFile; - sessionPromptState.adoptSessionId(sessionIdUsed); - if (sessionFileUsed && sessionFileUsed !== sessionPromptState.sessionFile) { - sessionPromptState.sessionFile = sessionFileUsed; - } - if ( - (sessionIdUsed && sessionIdUsed !== previousSessionId) || - (sessionFileUsed && sessionFileUsed !== previousSessionFile) - ) { - const marker = parseSqliteSessionFileMarker(sessionPromptState.sessionFile); - sessionPromptState.sessionTarget = marker - ? { - agentId: marker.agentId, - sessionId: marker.sessionId, - sessionKey: runInput.resolvedSessionKey, - storePath: marker.storePath, - } - : undefined; - } + applyEmbeddedAttemptSessionIdentity({ sessionPromptState, sessionFileUsed, sessionIdUsed }); const bootstrapPromptWarningSignaturesSeen = attempt.bootstrapPromptWarningSignaturesSeen ?? (attempt.bootstrapPromptWarningSignature diff --git a/src/agents/embedded-agent-runner/run/attempt-phase-lifecycle.test.ts b/src/agents/embedded-agent-runner/run/attempt-phase-lifecycle.test.ts index a43e14ddd889..3d89f42e20ea 100644 --- a/src/agents/embedded-agent-runner/run/attempt-phase-lifecycle.test.ts +++ b/src/agents/embedded-agent-runner/run/attempt-phase-lifecycle.test.ts @@ -49,9 +49,7 @@ describe("embedded attempt phase lifecycle state", () => { } as never, activeSession: activeSession as never, sessionManager: sessionManager as never, - sessionLockController: { - waitForSessionEvents: async () => {}, - } as never, + sessionLockController: {} as never, withOwnedSessionWriteLock: async (operation) => await operation(), subscription: { toolMetas: [], @@ -97,7 +95,7 @@ describe("embedded attempt phase lifecycle state", () => { expect(removeTrailingEntries).toHaveBeenCalledOnce(); }); - it("re-reads abort state after post-turn session draining", async () => { + it("re-reads abort state inside the post-turn session write", async () => { let aborted = false; await completeEmbeddedAttemptAfterTurn({ attempt: { @@ -107,12 +105,11 @@ describe("embedded attempt phase lifecycle state", () => { } as never, activeSession: {} as never, sessionManager: { appendCustomEntry: vi.fn() } as never, - sessionLockController: { - waitForSessionEvents: async () => { - aborted = true; - }, - } as never, - withOwnedSessionWriteLock: async (operation) => await operation(), + sessionLockController: {} as never, + withOwnedSessionWriteLock: async (operation) => { + aborted = true; + return await operation(); + }, state: { promptError: null, yieldAborted: false, @@ -161,7 +158,7 @@ describe("embedded attempt phase lifecycle state", () => { } as never, activeSession: {} as never, sessionManager: { appendCustomEntry: vi.fn() } as never, - sessionLockController: { waitForSessionEvents: async () => undefined } as never, + sessionLockController: {} as never, withOwnedSessionWriteLock: async (operation) => await operation(), state: { promptError: null, diff --git a/src/agents/embedded-agent-runner/run/attempt-prompt-error.test.ts b/src/agents/embedded-agent-runner/run/attempt-prompt-error.test.ts index 05b2a1f2362d..08e8166f489c 100644 --- a/src/agents/embedded-agent-runner/run/attempt-prompt-error.test.ts +++ b/src/agents/embedded-agent-runner/run/attempt-prompt-error.test.ts @@ -9,7 +9,6 @@ const hoisted = vi.hoisted(() => ({ releaseHeldLockForAbort: vi.fn(async () => undefined), releaseLeasedSteering: vi.fn(), stripSessionsYieldArtifacts: vi.fn(), - waitForSessionEvents: vi.fn(async () => undefined), waitForSessionsYieldAbortSettle: vi.fn(async () => undefined), withOwnedSessionWriteLock: vi.fn(async (operation: () => unknown) => await operation()), })); @@ -38,7 +37,6 @@ function createInput(overrides: Partial = {}): PromptErrorInpu releaseLeasedSteering: hoisted.releaseLeasedSteering, sessionLockController: { releaseHeldLockForAbort: hoisted.releaseHeldLockForAbort, - waitForSessionEvents: hoisted.waitForSessionEvents, }, withOwnedSessionWriteLock: hoisted.withOwnedSessionWriteLock, yieldAbortSettled: null, @@ -63,7 +61,6 @@ describe("handleEmbeddedAttemptPromptError", () => { }); expect(hoisted.releaseLeasedSteering).toHaveBeenCalledWith(error); - expect(hoisted.waitForSessionEvents).not.toHaveBeenCalled(); }); it("routes mid-turn prechecks under the owned session lock", async () => { @@ -80,7 +77,6 @@ describe("handleEmbeddedAttemptPromptError", () => { await expect(handleEmbeddedAttemptPromptError(createInput({ error }))).resolves.toEqual({}); - expect(hoisted.waitForSessionEvents).toHaveBeenCalledOnce(); expect(hoisted.withOwnedSessionWriteLock).toHaveBeenCalledOnce(); expect(hoisted.handleMidTurnPrecheckRequest).toHaveBeenCalledWith(request); }); @@ -105,7 +101,6 @@ describe("handleEmbeddedAttemptPromptError", () => { sessionId: "session-1", }); expect(hoisted.releaseHeldLockForAbort).toHaveBeenCalledOnce(); - expect(hoisted.waitForSessionEvents).toHaveBeenCalledWith(input.activeSession); expect(hoisted.stripSessionsYieldArtifacts).toHaveBeenCalledWith(input.activeSession); expect(hoisted.persistSessionsYieldContextMessage).toHaveBeenCalledWith( input.activeSession, diff --git a/src/agents/embedded-agent-runner/run/attempt-prompt-error.ts b/src/agents/embedded-agent-runner/run/attempt-prompt-error.ts index de53e6da0756..fa861232c63c 100644 --- a/src/agents/embedded-agent-runner/run/attempt-prompt-error.ts +++ b/src/agents/embedded-agent-runner/run/attempt-prompt-error.ts @@ -13,7 +13,7 @@ import type { EmbeddedRunAttemptParams } from "./types.js"; type PromptErrorAttempt = Pick; type PromptErrorSessionLockController = Pick< EmbeddedAttemptSessionLockController, - "releaseHeldLockForAbort" | "waitForSessionEvents" + "releaseHeldLockForAbort" >; type WithOwnedSessionWriteLock = (operation: () => Promise | T) => Promise; @@ -47,8 +47,7 @@ export async function handleEmbeddedAttemptPromptError(input: { runId: input.attempt.runId, sessionId: input.attempt.sessionId, }); - await input.sessionLockController.releaseHeldLockForAbort(); - await input.sessionLockController.waitForSessionEvents(input.activeSession); + await input.sessionLockController.releaseHeldLockForAbort({ terminal: false }); await input.withOwnedSessionWriteLock(async () => { stripSessionsYieldArtifacts(input.activeSession); if (input.yieldMessage) { @@ -60,7 +59,6 @@ export async function handleEmbeddedAttemptPromptError(input: { if (isMidTurnPrecheckSignal(input.error)) { const request = input.error.request; - await input.sessionLockController.waitForSessionEvents(input.activeSession); await input.withOwnedSessionWriteLock(() => { input.handleMidTurnPrecheckRequest(request); }); diff --git a/src/agents/embedded-agent-runner/run/attempt-prompt-execution-prepare.test.ts b/src/agents/embedded-agent-runner/run/attempt-prompt-execution-prepare.test.ts index 062fcabc68bd..530f377181db 100644 --- a/src/agents/embedded-agent-runner/run/attempt-prompt-execution-prepare.test.ts +++ b/src/agents/embedded-agent-runner/run/attempt-prompt-execution-prepare.test.ts @@ -8,7 +8,6 @@ const hoisted = vi.hoisted(() => ({ reacquireAfterPrompt: vi.fn(async () => undefined), releaseForPrompt: vi.fn(async () => undefined), resolveImageSanitizationLimits: vi.fn(() => ({ maxDimensionPx: 2048 })), - waitForSessionEvents: vi.fn(async () => undefined), withSessionWriteLock: vi.fn(async (operation: () => unknown) => await operation()), })); @@ -60,7 +59,6 @@ function createInput(overrides: Partial = {}): PromptExecu publishOwnedSessionFileSnapshot: hoisted.publishOwnedSessionFileSnapshot, reacquireAfterPrompt: hoisted.reacquireAfterPrompt, releaseForPrompt: hoisted.releaseForPrompt, - waitForSessionEvents: hoisted.waitForSessionEvents, withSessionWriteLock: hoisted.withSessionWriteLock, }, skipPromptSubmission: false, @@ -119,13 +117,10 @@ describe("prepareEmbeddedAttemptPromptExecution", () => { | { reacquireAfterPrompt: () => Promise; releaseForPrompt: () => Promise; - waitForSessionEvents: (session: unknown) => Promise; } | undefined; - await lockHandoff?.waitForSessionEvents(input.session); await lockHandoff?.releaseForPrompt(); await lockHandoff?.reacquireAfterPrompt(); - expect(hoisted.waitForSessionEvents).toHaveBeenCalledWith(input.session); expect(hoisted.releaseForPrompt).toHaveBeenCalledOnce(); expect(hoisted.reacquireAfterPrompt).toHaveBeenCalledOnce(); expect(hoisted.detectAndLoadPromptImages).toHaveBeenCalledWith({ diff --git a/src/agents/embedded-agent-runner/run/attempt-prompt-execution-prepare.ts b/src/agents/embedded-agent-runner/run/attempt-prompt-execution-prepare.ts index ee1365e3983f..9ddd617a6991 100644 --- a/src/agents/embedded-agent-runner/run/attempt-prompt-execution-prepare.ts +++ b/src/agents/embedded-agent-runner/run/attempt-prompt-execution-prepare.ts @@ -22,6 +22,7 @@ type PromptExecutionAttempt = Pick< | "model" | "sessionFile" | "sessionKey" + | "sessionTarget" | "userTurnTranscriptRecorder" >; type PromptImageResult = Awaited>; @@ -54,12 +55,11 @@ export async function prepareEmbeddedAttemptPromptExecution(input: { const { attempt } = input; installPromptSubmissionLockRelease({ session: input.session, - waitForSessionEvents: (sessionToDrain) => - input.sessionLockController.waitForSessionEvents(sessionToDrain), releaseForPrompt: () => input.sessionLockController.releaseForPrompt(), reacquireAfterPrompt: () => input.sessionLockController.reacquireAfterPrompt(), sessionKey: attempt.sessionKey, sessionFile: attempt.sessionFile, + sessionTarget: attempt.sessionTarget, withSessionWriteLock: (run, options) => input.sessionLockController.withSessionWriteLock(run, options), canAdvanceSessionEntryCache: (snapshot: OwnedSessionTranscriptCacheSnapshot) => diff --git a/src/agents/embedded-agent-runner/run/attempt-prompt-phase.test.ts b/src/agents/embedded-agent-runner/run/attempt-prompt-phase.test.ts index 11d7b37252b3..62894e6f2de5 100644 --- a/src/agents/embedded-agent-runner/run/attempt-prompt-phase.test.ts +++ b/src/agents/embedded-agent-runner/run/attempt-prompt-phase.test.ts @@ -164,9 +164,7 @@ function createFixture() { appendCustomEntry: vi.fn(), getEntries: vi.fn(() => []), }; - const sessionLockController = { - waitForSessionEvents: vi.fn(async () => undefined), - }; + const sessionLockController = {}; const input = { attempt: { model: { id: "model-1", provider: "test" }, diff --git a/src/agents/embedded-agent-runner/run/attempt-prompt-phase.ts b/src/agents/embedded-agent-runner/run/attempt-prompt-phase.ts index d6512495fd57..95468c4b66bd 100644 --- a/src/agents/embedded-agent-runner/run/attempt-prompt-phase.ts +++ b/src/agents/embedded-agent-runner/run/attempt-prompt-phase.ts @@ -295,7 +295,6 @@ export async function runEmbeddedAttemptPromptPhase(input: { const pendingMidTurnPrecheckRequest = input.lifecycle.takePendingMidTurnPrecheckRequest(); if (pendingMidTurnPrecheckRequest) { - await input.sessionLockController.waitForSessionEvents(activeSession); await input.withOwnedSessionWriteLock(() => { removeTrailingMidTurnPrecheckAssistantError({ activeSession, sessionManager }); const state = input.lifecycle.readState(); diff --git a/src/agents/embedded-agent-runner/run/attempt-session-identity.ts b/src/agents/embedded-agent-runner/run/attempt-session-identity.ts new file mode 100644 index 000000000000..5f8acc597dd6 --- /dev/null +++ b/src/agents/embedded-agent-runner/run/attempt-session-identity.ts @@ -0,0 +1,128 @@ +import path from "node:path"; +import { + formatSqliteSessionFileMarker, + parseSqliteSessionFileMarker, +} from "../../../config/sessions/legacy-sqlite-marker.js"; +import { listSessionEntries, loadSessionEntry } from "../../../config/sessions/session-accessor.js"; +import { resolveAgentIdFromSessionKey } from "../../../routing/session-key.js"; +import { resolvePreferredSessionKeyForSessionIdMatches } from "../../../sessions/session-id-resolution.js"; +import type { createEmbeddedRunSessionPromptState } from "./session-prompt-state.js"; + +type SessionPromptState = ReturnType; + +export function applyEmbeddedAttemptSessionIdentity(params: { + sessionPromptState: Pick< + SessionPromptState, + "adoptSessionId" | "sessionFile" | "sessionId" | "sessionTarget" + >; + sessionFileUsed?: string; + sessionIdUsed: string; +}): void { + const { sessionPromptState, sessionFileUsed, sessionIdUsed } = params; + const previousSessionId = sessionPromptState.sessionId; + let adoptedSessionId = sessionIdUsed; + const sessionFileChanged = Boolean( + sessionFileUsed && sessionFileUsed !== sessionPromptState.sessionFile, + ); + let nextSessionTarget = sessionPromptState.sessionTarget; + if (sessionFileUsed && sessionFileChanged) { + const marker = parseSqliteSessionFileMarker(sessionFileUsed); + if (marker) { + const retainedSessionKey = sessionPromptState.sessionTarget?.sessionKey; + const retainedEntry = retainedSessionKey + ? loadSessionEntry({ + agentId: marker.agentId, + sessionKey: retainedSessionKey, + storePath: marker.storePath, + }) + : undefined; + const markerMatches = listSessionEntries({ + agentId: marker.agentId, + storePath: marker.storePath, + }).filter(({ entry }) => entry.sessionId === marker.sessionId); + const preferredMarkerSessionKey = resolvePreferredSessionKeyForSessionIdMatches( + markerMatches.map(({ sessionKey, entry }) => [sessionKey, entry]), + marker.sessionId, + ); + const markerMappedToRetainedKey = markerMatches.some( + ({ sessionKey }) => sessionKey === retainedSessionKey, + ); + const successorSessionKey = + retainedEntry?.sessionId === marker.sessionId || + (retainedEntry?.sessionId === previousSessionId && + (markerMatches.length === 0 || markerMappedToRetainedKey)) + ? retainedSessionKey + : (preferredMarkerSessionKey ?? + (markerMatches.length === 0 && !retainedEntry ? retainedSessionKey : undefined)); + if ( + (marker.sessionId !== sessionIdUsed && sessionIdUsed !== previousSessionId) || + !successorSessionKey || + marker.agentId !== sessionPromptState.sessionTarget?.agentId + ) { + throw new Error("Legacy context-engine successor identity is inconsistent"); + } + adoptedSessionId = marker.sessionId; + nextSessionTarget = { + ...marker, + sessionKey: successorSessionKey, + }; + } else if ( + sessionFileUsed.startsWith("agent:") && + sessionPromptState.sessionTarget && + resolveAgentIdFromSessionKey(sessionFileUsed) === sessionPromptState.sessionTarget.agentId + ) { + const keyedEntry = loadSessionEntry({ + agentId: sessionPromptState.sessionTarget.agentId, + sessionKey: sessionFileUsed, + storePath: sessionPromptState.sessionTarget.storePath, + }); + if (!keyedEntry?.sessionId || keyedEntry.sessionId !== sessionIdUsed) { + throw new Error("Legacy context-engine successor identity is inconsistent"); + } + nextSessionTarget = { + ...sessionPromptState.sessionTarget, + sessionId: sessionIdUsed, + sessionKey: sessionFileUsed, + }; + } else { + throw new Error( + "Legacy context-engine successor files are unsupported; return a structured sessionTarget", + ); + } + } else if (sessionIdUsed && sessionIdUsed !== previousSessionId) { + nextSessionTarget = sessionPromptState.sessionTarget + ? { ...sessionPromptState.sessionTarget, sessionId: sessionIdUsed } + : undefined; + } + if ( + sessionFileChanged && + nextSessionTarget && + sessionPromptState.sessionTarget && + (!nextSessionTarget.agentId || + !nextSessionTarget.sessionKey || + !nextSessionTarget.storePath || + !sessionPromptState.sessionTarget.agentId || + !sessionPromptState.sessionTarget.sessionKey || + !sessionPromptState.sessionTarget.storePath || + nextSessionTarget.agentId !== sessionPromptState.sessionTarget.agentId || + nextSessionTarget.sessionKey !== sessionPromptState.sessionTarget.sessionKey || + path.resolve(nextSessionTarget.storePath) !== + path.resolve(sessionPromptState.sessionTarget.storePath)) + ) { + throw new Error("Legacy context-engine successor target changed the active session binding"); + } + sessionPromptState.adoptSessionId(adoptedSessionId); + if (sessionFileUsed && sessionFileChanged) { + sessionPromptState.sessionFile = sessionFileUsed; + } else if (adoptedSessionId !== previousSessionId && nextSessionTarget) { + const marker = parseSqliteSessionFileMarker(sessionPromptState.sessionFile); + if (marker) { + sessionPromptState.sessionFile = formatSqliteSessionFileMarker({ + agentId: nextSessionTarget.agentId ?? marker.agentId, + sessionId: nextSessionTarget.sessionId ?? marker.sessionId, + storePath: nextSessionTarget.storePath ?? marker.storePath, + }); + } + } + sessionPromptState.sessionTarget = nextSessionTarget; +} diff --git a/src/agents/embedded-agent-runner/run/attempt-session-lock-prepare.test.ts b/src/agents/embedded-agent-runner/run/attempt-session-lock-prepare.test.ts index c6588e48c4cd..13c2dfee33ef 100644 --- a/src/agents/embedded-agent-runner/run/attempt-session-lock-prepare.test.ts +++ b/src/agents/embedded-agent-runner/run/attempt-session-lock-prepare.test.ts @@ -4,6 +4,7 @@ const mocks = vi.hoisted(() => ({ acquireSessionFileOwner: vi.fn(), acquireSessionWriteLock: vi.fn(), createSessionLockController: vi.fn(), + resolveSessionWriteLockTargetKey: vi.fn(() => "canonical-lock-key"), resolveCompactionTimeoutMs: vi.fn(() => 30_000), resolveSessionWriteLockOptions: vi.fn(() => ({ timeoutMs: 100, @@ -14,6 +15,7 @@ const mocks = vi.hoisted(() => ({ vi.mock("../../session-write-lock.js", () => ({ acquireSessionWriteLock: mocks.acquireSessionWriteLock, + resolveSessionWriteLockTargetKey: mocks.resolveSessionWriteLockTargetKey, })); vi.mock("../compaction-safety-timeout.js", () => ({ resolveCompactionTimeoutMs: mocks.resolveCompactionTimeoutMs, @@ -47,7 +49,7 @@ function createFixture(options?: { rejectPostArmFence?: Error }) { const sessionFileOwner = { release: vi.fn() }; const sessionManager = { mergePromptReleasedSessionEntries: vi.fn(() => "merged"), - setSessionFile: vi.fn(), + reloadPersistedTranscript: vi.fn(), }; const sessionLockController = { canAdvanceSessionEntryCache: vi.fn(() => true), @@ -83,7 +85,14 @@ function createFixture(options?: { rejectPostArmFence?: Error }) { abortSignal: new AbortController().signal, config: {}, sessionFile: "/tmp/session.jsonl", + sessionId: "session-1", sessionKey: "agent:main:session-1", + sessionTarget: { + agentId: "main", + sessionId: "session-1", + sessionKey: "agent:main:session-1", + storePath: "/tmp/sessions.json", + }, }, externalAbortController, getSessionManager: () => sessionManager, @@ -141,19 +150,23 @@ describe("prepareEmbeddedAttemptSessionLock", () => { acquireSessionWriteLock: mocks.acquireSessionWriteLock, initialAcquireSignal: fixture.input.attempt.abortSignal, lockOptions: { - sessionFile: "/tmp/session.jsonl", + sessionFile: "canonical-lock-key", + targetKind: "session-key", timeoutMs: 100, staleMs: 200, maxHoldMs: 300, }, }), ); + expect(mocks.resolveSessionWriteLockTargetKey).toHaveBeenCalledWith( + fixture.input.attempt.sessionTarget, + ); expect(controllerInput?.mergePromptReleasedSessionEntries([])).toBe("merged"); expect(fixture.sessionManager.mergePromptReleasedSessionEntries).toHaveBeenCalledWith([], { persistLeaf: true, }); controllerInput?.reloadPromptReleasedSessionFile(); - expect(fixture.sessionManager.setSessionFile).toHaveBeenCalledWith("/tmp/session.jsonl"); + expect(fixture.sessionManager.reloadPersistedTranscript).toHaveBeenCalledOnce(); await expect(result.withOwnedSessionWriteLock(async () => "done")).resolves.toBe("done"); expect(fixture.sessionLockController.withSessionWriteLock).toHaveBeenCalledOnce(); diff --git a/src/agents/embedded-agent-runner/run/attempt-session-lock-prepare.ts b/src/agents/embedded-agent-runner/run/attempt-session-lock-prepare.ts index e3ef3098d13e..05c7101c3163 100644 --- a/src/agents/embedded-agent-runner/run/attempt-session-lock-prepare.ts +++ b/src/agents/embedded-agent-runner/run/attempt-session-lock-prepare.ts @@ -1,7 +1,11 @@ /** Acquires and publishes the session-write ownership used by one attempt. */ import { withOwnedSessionTranscriptWrites } from "../../../config/sessions/transcript-write-context.js"; +import { resolveAgentRunSessionTarget } from "../../run-session-target.js"; import type { guardSessionManager } from "../../session-tool-result-guard-wrapper.js"; -import { acquireSessionWriteLock } from "../../session-write-lock.js"; +import { + acquireSessionWriteLock, + resolveSessionWriteLockTargetKey, +} from "../../session-write-lock.js"; import { resolveCompactionTimeoutMs } from "../compaction-safety-timeout.js"; import { resolveEmbeddedAttemptSessionWriteLockOptions } from "./attempt.run-decisions.js"; import { @@ -18,7 +22,10 @@ type OwnedTranscriptWriteContext = Parameters(operation: () => Promise | T) => Promise; export async function prepareEmbeddedAttemptSessionLock(input: { - attempt: Pick; + attempt: Pick< + EmbeddedRunAttemptParams, + "abortSignal" | "config" | "sessionFile" | "sessionId" | "sessionKey" | "sessionTarget" + >; externalAbortController: { arm: () => void; throwIfFiredAfterPrepCleanup: () => Promise; @@ -38,6 +45,14 @@ export async function prepareEmbeddedAttemptSessionLock(input: { config: attempt.config, compactionTimeoutMs, }); + const sessionTarget = await resolveAgentRunSessionTarget({ + agentId: attempt.sessionTarget?.agentId, + config: attempt.config, + sessionFile: attempt.sessionFile, + sessionId: attempt.sessionId, + sessionKey: attempt.sessionKey, + sessionTarget: attempt.sessionTarget, + }); await externalAbortController.throwIfFiredAfterPrepCleanup(); const sessionFileOwner = await acquireEmbeddedAttemptSessionFileOwner({ @@ -60,7 +75,8 @@ export async function prepareEmbeddedAttemptSessionLock(input: { acquireSessionWriteLock, initialAcquireSignal: attempt.abortSignal, lockOptions: { - sessionFile: attempt.sessionFile, + sessionFile: resolveSessionWriteLockTargetKey(sessionTarget), + targetKind: "session-key", ...sessionWriteLockOptions, }, mergePromptReleasedSessionEntries: (entries) => @@ -68,7 +84,7 @@ export async function prepareEmbeddedAttemptSessionLock(input: { persistLeaf: true, }), reloadPromptReleasedSessionFile: () => { - getSessionManager("file reload").setSessionFile(attempt.sessionFile); + getSessionManager("file reload").reloadPersistedTranscript(); }, }); input.onSessionLockReleaseReady(() => sessionLockController.dispose()); @@ -76,6 +92,7 @@ export async function prepareEmbeddedAttemptSessionLock(input: { const ownedTranscriptWriteContext: OwnedTranscriptWriteContext = { sessionFile: attempt.sessionFile, sessionKey: attempt.sessionKey, + sessionTarget: attempt.sessionTarget, canAdvanceSessionEntryCache: (snapshot) => sessionLockController.canAdvanceSessionEntryCache(snapshot), publishSessionFileSnapshot: (snapshot) => diff --git a/src/agents/embedded-agent-runner/run/attempt-session-manager-prepare.ts b/src/agents/embedded-agent-runner/run/attempt-session-manager-prepare.ts index a647f75a41ee..63d009bd0cc5 100644 --- a/src/agents/embedded-agent-runner/run/attempt-session-manager-prepare.ts +++ b/src/agents/embedded-agent-runner/run/attempt-session-manager-prepare.ts @@ -1,19 +1,13 @@ +import type { SessionTranscriptRuntimeTarget } from "../../../config/sessions/session-accessor.types.js"; /** * Prepares the durable session manager before embedded-agent session creation. */ -import { parseSqliteSessionFileMarker } from "../../../config/sessions/sqlite-marker.js"; import { OPENCLAW_EMBEDDED_CONTEXT_ENGINE_HOST } from "../../../context-engine/host-compat.js"; import type { AgentMessage } from "../../runtime/index.js"; -import { - invalidateSessionFileRepairCache, - repairSessionFileIfNeeded, -} from "../../session-file-repair.js"; import { guardSessionManager } from "../../session-tool-result-guard-wrapper.js"; import { SessionManager } from "../../sessions/index.js"; import { runContextEngineMaintenance } from "../context-engine-maintenance.js"; import { log } from "../logger.js"; -import { prewarmSessionFile, trackSessionManagerAccess } from "../session-manager-cache.js"; -import { prepareSessionManagerForRun } from "../session-manager-init.js"; import { resolveExistingAttemptTranscriptState } from "./attempt-transcript-helpers.js"; import { runAttemptContextEngineBootstrap, @@ -28,24 +22,6 @@ import type { EmbeddedRunAttemptParams } from "./types.js"; type AttemptSessionManager = ReturnType; type WithOwnedSessionWriteLock = (operation: () => Promise | T) => Promise; -type CompactionPersistenceGuard = Pick< - NonNullable[1]>, - "withCompactionPersistence" ->; - -function buildCompactionPersistenceGuard(params: { - sessionFile: string; - sessionLockController: Pick; -}): CompactionPersistenceGuard { - if (parseSqliteSessionFileMarker(params.sessionFile)) { - return {}; - } - return { - withCompactionPersistence: (append, validateAppend) => - params.sessionLockController.withOwnedSessionFileWrite(append, validateAppend), - }; -} - export async function prepareEmbeddedAttemptSessionManager(input: { attempt: EmbeddedRunAttemptParams; activeContextEngine?: AttemptContextEngine; @@ -60,20 +36,6 @@ export async function prepareEmbeddedAttemptSessionManager(input: { withOwnedSessionWriteLock: WithOwnedSessionWriteLock; }) { const { attempt } = input; - const trustedSessionFileSnapshot = - await input.sessionLockController.readTrustedCurrentSessionFileSnapshot(); - const repairReport = await repairSessionFileIfNeeded({ - sessionFile: attempt.sessionFile, - trustedSnapshot: trustedSessionFileSnapshot, - debug: (message) => log.debug(message), - warn: (message) => log.warn(message), - }); - if ( - repairReport.validatedSnapshot && - !input.sessionLockController.publishValidatedSessionFileSnapshot(repairReport.validatedSnapshot) - ) { - invalidateSessionFileRepairCache(attempt.sessionFile); - } const transcriptState = await resolveExistingAttemptTranscriptState({ agentId: input.sessionAgentId, config: attempt.config, @@ -99,7 +61,6 @@ export async function prepareEmbeddedAttemptSessionManager(input: { attempt.model.api === "azure-openai-responses" || attempt.model.api === "openai-chatgpt-responses"; - await prewarmSessionFile(attempt.sessionFile); const preparedUserTurnMessage = attempt.skipPreparedUserTurnMessage ? undefined : await attempt.userTurnTranscriptRecorder?.resolveMessage(); @@ -107,56 +68,60 @@ export async function prepareEmbeddedAttemptSessionManager(input: { let latestRuntimeUserMessage: AgentMessage | undefined; let latestUserTurnTranscriptRecorder = attempt.userTurnTranscriptRecorder; const userTranscriptContextRegistry = createUserTranscriptContextRegistry(); - const sessionManager = guardSessionManager(SessionManager.open(attempt.sessionFile), { - agentId: input.sessionAgentId, - sessionKey: attempt.sessionKey, - config: attempt.config, - contextWindowTokens: attempt.contextTokenBudget, - inputProvenance: attempt.inputProvenance, - preparedUserTurnMessage, - allowSyntheticToolResults: transcriptPolicy.allowSyntheticToolResults, - missingToolResultText: isOpenAIResponsesApi ? "aborted" : undefined, - allowedToolNames: input.replayAllowedToolNames, - trigger: attempt.trigger, - suppressNextUserMessagePersistence: attempt.suppressNextUserMessagePersistence, - suppressTranscriptOnlyAssistantPersistence: attempt.suppressTranscriptOnlyAssistantPersistence, - suppressAssistantErrorPersistence: attempt.suppressAssistantErrorPersistence, - skipBeforeMessageWriteHooks: attempt.operation === "settled-tool-finalization", - onMessagePersisted: () => { - input.sessionLockController.refreshAfterOwnedSessionWrite(); + const sessionManager = guardSessionManager( + attempt.sessionManager ?? + (attempt.sessionTarget + ? SessionManager.open( + attempt.sessionTarget as SessionTranscriptRuntimeTarget, + input.effectiveCwd, + ) + : SessionManager.inMemory(input.effectiveCwd)), + { + agentId: input.sessionAgentId, + sessionKey: attempt.sessionKey, + config: attempt.config, + contextWindowTokens: attempt.contextTokenBudget, + inputProvenance: attempt.inputProvenance, + preparedUserTurnMessage, + allowSyntheticToolResults: transcriptPolicy.allowSyntheticToolResults, + missingToolResultText: isOpenAIResponsesApi ? "aborted" : undefined, + allowedToolNames: input.replayAllowedToolNames, + trigger: attempt.trigger, + suppressNextUserMessagePersistence: attempt.suppressNextUserMessagePersistence, + suppressTranscriptOnlyAssistantPersistence: + attempt.suppressTranscriptOnlyAssistantPersistence, + suppressAssistantErrorPersistence: attempt.suppressAssistantErrorPersistence, + skipBeforeMessageWriteHooks: attempt.operation === "settled-tool-finalization", + onMessagePersisted: () => { + input.sessionLockController.refreshAfterOwnedSessionWrite(); + }, + onUserMessagePreparingForPersistence: (_message, recorder, preparedMessage) => { + latestPersistedUserMessage = undefined; + latestUserTurnTranscriptRecorder = + recorder ?? + (preparedMessage === preparedUserTurnMessage + ? attempt.userTurnTranscriptRecorder + : undefined); + }, + onUserMessagePersisted: (message, runtimeMessage) => { + latestPersistedUserMessage = message; + latestRuntimeUserMessage = runtimeMessage; + if (runtimeMessage) { + userTranscriptContextRegistry.record(runtimeMessage, message); + } + attempt.onUserMessagePersisted?.(message); + }, + onUserMessagePersistenceSuppressed: (_message, runtimeMessage) => { + latestRuntimeUserMessage = runtimeMessage; + }, + onUserMessageBlocked: () => { + attempt.userTurnTranscriptRecorder?.markBlocked(); + }, + onAssistantErrorMessagePersisted: (message) => { + attempt.onAssistantErrorMessagePersisted?.(message); + }, }, - // SQLite owns compaction persistence and validation inside its transaction. - // This append validator is a JSONL file-ownership fence, not a storage shim. - ...buildCompactionPersistenceGuard({ - sessionFile: attempt.sessionFile, - sessionLockController: input.sessionLockController, - }), - onUserMessagePreparingForPersistence: (_message, recorder, preparedMessage) => { - latestPersistedUserMessage = undefined; - latestUserTurnTranscriptRecorder = - recorder ?? - (preparedMessage === preparedUserTurnMessage - ? attempt.userTurnTranscriptRecorder - : undefined); - }, - onUserMessagePersisted: (message, runtimeMessage) => { - latestPersistedUserMessage = message; - latestRuntimeUserMessage = runtimeMessage; - if (runtimeMessage) { - userTranscriptContextRegistry.record(runtimeMessage, message); - } - attempt.onUserMessagePersisted?.(message); - }, - onUserMessagePersistenceSuppressed: (_message, runtimeMessage) => { - latestRuntimeUserMessage = runtimeMessage; - }, - onUserMessageBlocked: () => { - attempt.userTurnTranscriptRecorder?.markBlocked(); - }, - onAssistantErrorMessagePersisted: (message) => { - attempt.onAssistantErrorMessagePersisted?.(message); - }, - }); + ); attempt.promptCacheKey = resolveSessionBoundaryPromptCacheKey({ api: attempt.model.api, boundaryCount: sessionManager.getBoundaryCount(), @@ -166,7 +131,6 @@ export async function prepareEmbeddedAttemptSessionManager(input: { // Publish ownership before async bootstrap. Outer cleanup must close this manager // even when a context-engine or transcript preparation step fails. input.onSessionManagerCreated(sessionManager); - trackSessionManagerAccess(attempt.sessionFile); await input.withOwnedSessionWriteLock(async () => { await runAttemptContextEngineBootstrap({ @@ -208,14 +172,6 @@ export async function prepareEmbeddedAttemptSessionManager(input: { }), warn: (message) => log.warn(message), }); - - await prepareSessionManagerForRun({ - sessionManager, - sessionFile: attempt.sessionFile, - hadSessionFile: transcriptState.hasFileTranscriptState, - sessionId: attempt.sessionId, - cwd: input.effectiveCwd, - }); }); // Bootstrap may repair or migrate transcript rows. Only user writes after // preparation can be the active prompt source at the provider boundary. diff --git a/src/agents/embedded-agent-runner/run/attempt-stream-finalize.test.ts b/src/agents/embedded-agent-runner/run/attempt-stream-finalize.test.ts index c8dae8b036f5..3039b267c0e9 100644 --- a/src/agents/embedded-agent-runner/run/attempt-stream-finalize.test.ts +++ b/src/agents/embedded-agent-runner/run/attempt-stream-finalize.test.ts @@ -42,9 +42,6 @@ function createFixture(overrides?: Partial) { buildSessionContext: () => ({ messages: repairedMessages }), }, sessionLockController: { - waitForSessionEvents: vi.fn(async () => { - order.push("session-events"); - }), releaseForPrompt: vi.fn(async () => { order.push("release-prompt-lock"); }), @@ -143,7 +140,6 @@ describe("finalizeEmbeddedAttemptStreamPhase", () => { expect(fixture.activeSession.agent.state.messages).toBe(fixture.repairedMessages); expect(fixture.order).toEqual([ - "session-events", "pending-events", "release-prompt-lock", "settle", diff --git a/src/agents/embedded-agent-runner/run/attempt-stream-finalize.ts b/src/agents/embedded-agent-runner/run/attempt-stream-finalize.ts index 79a42772d6d0..b06390763e5c 100644 --- a/src/agents/embedded-agent-runner/run/attempt-stream-finalize.ts +++ b/src/agents/embedded-agent-runner/run/attempt-stream-finalize.ts @@ -42,7 +42,6 @@ export async function finalizeEmbeddedAttemptStreamPhase(input: { }): Promise<{ sessionIdUsed: string; sessionFileUsed?: string }> { const { activeSession, sessionManager, sessionLockController, withOwnedSessionWriteLock } = input; - await sessionLockController.waitForSessionEvents(activeSession); await input.waitForPendingEvents(); if (input.repairedRejectedThinkingReplay) { activeSession.agent.state.messages = sessionManager.buildSessionContext().messages; diff --git a/src/agents/embedded-agent-runner/run/attempt-stream-settle.ts b/src/agents/embedded-agent-runner/run/attempt-stream-settle.ts index 8b797079aa52..848e94963d3f 100644 --- a/src/agents/embedded-agent-runner/run/attempt-stream-settle.ts +++ b/src/agents/embedded-agent-runner/run/attempt-stream-settle.ts @@ -160,8 +160,6 @@ export async function settleEmbeddedAttemptStream(input: { promptErrorSource = "prompt"; state.promptError = promptError; state.promptErrorSource = promptErrorSource; - } else if (asyncTaskWait.waitedRunIds.length > 0) { - await input.sessionLockController.waitForSessionEvents(activeSession); } } @@ -235,7 +233,6 @@ export async function settleEmbeddedAttemptStream(input: { let lastCallUsage: NormalizedUsage | undefined; let promptCache: EmbeddedRunAttemptResult["promptCache"]; - await input.sessionLockController.waitForSessionEvents(activeSession); await input.withOwnedSessionWriteLock(async () => { const { timedOutDuringCompaction } = input.readLifecycleState(); compactionOccurredThisAttempt = subscription.getCompactionCount() > 0; diff --git a/src/agents/embedded-agent-runner/run/attempt-trajectory.test.ts b/src/agents/embedded-agent-runner/run/attempt-trajectory.test.ts index 440b88e239d7..1526f1d1c406 100644 --- a/src/agents/embedded-agent-runner/run/attempt-trajectory.test.ts +++ b/src/agents/embedded-agent-runner/run/attempt-trajectory.test.ts @@ -31,6 +31,12 @@ function createInput(disableTrajectory = false) { runId: "run-1", sessionFile: "/tmp/session.jsonl", sessionKey: "agent:main:session-1", + sessionTarget: { + agentId: "main", + sessionId: "session-1", + sessionKey: "agent:main:session-1", + storePath: "/tmp/openclaw-agent.sqlite", + }, thinkLevel: "medium", trigger: "user", workspaceDir: "/tmp/workspace", @@ -67,6 +73,12 @@ describe("prepareEmbeddedAttemptTrajectory", () => { runId: "run-1", sessionFile: "/tmp/trajectory.jsonl", sessionId: "session-1", + sessionTarget: expect.objectContaining({ + agentId: "main", + sessionId: "session-1", + sessionKey: "agent:main:session-1", + storePath: "/tmp/openclaw-agent.sqlite", + }), }), ); expect(recorder.recordEvent).toHaveBeenNthCalledWith( diff --git a/src/agents/embedded-agent-runner/run/attempt-trajectory.ts b/src/agents/embedded-agent-runner/run/attempt-trajectory.ts index 1c036f6bbea7..8f082d467a65 100644 --- a/src/agents/embedded-agent-runner/run/attempt-trajectory.ts +++ b/src/agents/embedded-agent-runner/run/attempt-trajectory.ts @@ -25,6 +25,18 @@ export async function prepareEmbeddedAttemptTrajectory(input: { sessionKey: attempt.sessionKey, sessionTarget: attempt.sessionTarget, }); + const sessionTarget = + attempt.sessionTarget?.agentId && + attempt.sessionTarget.sessionId && + attempt.sessionTarget.sessionKey && + attempt.sessionTarget.storePath + ? { + agentId: attempt.sessionTarget.agentId, + sessionId: attempt.sessionTarget.sessionId, + sessionKey: attempt.sessionTarget.sessionKey, + storePath: attempt.sessionTarget.storePath, + } + : undefined; const recorder = attempt.disableTrajectory ? null : createTrajectoryRuntimeRecorder({ @@ -34,6 +46,7 @@ export async function prepareEmbeddedAttemptTrajectory(input: { sessionId: activeSession.sessionId, sessionKey: attempt.sessionKey, sessionFile: trajectorySessionFile, + sessionTarget, provider: attempt.provider, modelId: attempt.modelId, modelApi: attempt.model.api, diff --git a/src/agents/embedded-agent-runner/run/attempt-transcript-helpers.ts b/src/agents/embedded-agent-runner/run/attempt-transcript-helpers.ts index 0791527c6946..bd1c59d202db 100644 --- a/src/agents/embedded-agent-runner/run/attempt-transcript-helpers.ts +++ b/src/agents/embedded-agent-runner/run/attempt-transcript-helpers.ts @@ -1,4 +1,4 @@ -import fs from "node:fs/promises"; +import { normalizeOptionalString } from "@openclaw/normalization-core/string-coerce"; import { resolveStorePath } from "../../../config/sessions/paths.js"; import { loadSessionEntry, @@ -6,7 +6,6 @@ import { resolveSessionTranscriptRuntimeReadTarget, updateSessionEntry, } from "../../../config/sessions/session-accessor.js"; -import { parseSqliteSessionFileMarker } from "../../../config/sessions/sqlite-marker.js"; import { resolveQuotaSuspensionEntryMaintenance } from "../../../config/sessions/store-maintenance.js"; import type { SessionEntry as ConfigSessionEntry } from "../../../config/sessions/types.js"; import type { OpenClawConfig } from "../../../config/types.openclaw.js"; @@ -155,12 +154,11 @@ export async function resolveAttemptTrajectorySessionFile(params: { sessionKey: params.sessionKey, storePath, }) - ).sessionFile; + ).sessionKey; } type ExistingAttemptTranscriptState = { hasBootstrapTranscriptState: boolean; - hasFileTranscriptState: boolean; }; function isTranscriptMessageEvent(event: unknown): boolean { @@ -180,41 +178,27 @@ export async function resolveExistingAttemptTranscriptState(params: { sessionKey?: string; sessionTarget?: EmbeddedRunAttemptParams["sessionTarget"]; }): Promise { + const agentId = normalizeOptionalString(params.sessionTarget?.agentId) ?? params.agentId; const storePath = - params.sessionTarget?.storePath ?? - resolveStorePath(params.config?.session?.store, { agentId: params.agentId }); - const sqliteMarker = parseSqliteSessionFileMarker(params.sessionFile); + normalizeOptionalString(params.sessionTarget?.storePath) ?? + resolveStorePath(params.config?.session?.store, { agentId }); + const sessionId = normalizeOptionalString(params.sessionTarget?.sessionId) ?? params.sessionId; + const sessionKey = + normalizeOptionalString(params.sessionTarget?.sessionKey) ?? + normalizeOptionalString(params.sessionKey); let hasBootstrapTranscriptState = false; - if (storePath && params.sessionKey) { + if (storePath && sessionKey) { try { const sqliteEvents = await loadTranscriptEvents({ - agentId: params.agentId, - sessionId: params.sessionId, - sessionKey: params.sessionKey, + agentId, + sessionId, + sessionKey, storePath, }); hasBootstrapTranscriptState = sqliteEvents.some(isTranscriptMessageEvent); - if (sqliteMarker) { - return { - hasBootstrapTranscriptState, - hasFileTranscriptState: false, - }; - } } catch { - if (sqliteMarker) { - return { - hasBootstrapTranscriptState: false, - hasFileTranscriptState: false, - }; - } + hasBootstrapTranscriptState = false; } } - const hasFileTranscriptState = await fs - .stat(params.sessionFile) - .then(() => true) - .catch(() => false); - return { - hasBootstrapTranscriptState: hasBootstrapTranscriptState || hasFileTranscriptState, - hasFileTranscriptState, - }; + return { hasBootstrapTranscriptState }; } diff --git a/src/agents/embedded-agent-runner/run/attempt-transcript-persistence.e2e.test.ts b/src/agents/embedded-agent-runner/run/attempt-transcript-persistence.e2e.test.ts index 5be465dcb761..d8b9b78fc583 100644 --- a/src/agents/embedded-agent-runner/run/attempt-transcript-persistence.e2e.test.ts +++ b/src/agents/embedded-agent-runner/run/attempt-transcript-persistence.e2e.test.ts @@ -7,7 +7,6 @@ import { appendTranscriptMessage, upsertSessionEntry, } from "../../../config/sessions/session-accessor.js"; -import { formatSqliteSessionFileMarker } from "../../../config/sessions/sqlite-marker.js"; import { SessionManager } from "../../sessions/session-manager.js"; import { flushSessionManagerTranscript } from "./attempt-transcript-helpers.js"; @@ -61,9 +60,7 @@ describe("embedded attempt transcript persistence", () => { sessionKey: "agent:main:embedded-generation", storePath, }; - const marker = formatSqliteSessionFileMarker(target); await upsertSessionEntry(target, { - sessionFile: marker, sessionId: target.sessionId, updatedAt: 1, }); @@ -84,7 +81,7 @@ describe("embedded attempt transcript persistence", () => { throw new Error(`expected bootstrap page, got ${bootstrap.kind}`); } - const sessionManager = SessionManager.open(marker, dir, dir); + const sessionManager = SessionManager.open(target, dir); sessionManager.appendMessage({ role: "user", content: "second turn", diff --git a/src/agents/embedded-agent-runner/run/attempt.session-lock.controller.ts b/src/agents/embedded-agent-runner/run/attempt.session-lock.controller.ts deleted file mode 100644 index e1dde558e435..000000000000 --- a/src/agents/embedded-agent-runner/run/attempt.session-lock.controller.ts +++ /dev/null @@ -1,620 +0,0 @@ -import { AsyncLocalStorage } from "node:async_hooks"; -import { readFileSync } from "node:fs"; -import type { - OwnedSessionTranscriptPublishedEntry, - OwnedSessionTranscriptWriteOptions, - OwnedSessionTranscriptCacheSnapshot, -} from "../../../config/sessions/transcript-write-context.js"; -import { isSessionWriteLockAcquireError } from "../../session-write-lock-error.js"; -import type { acquireSessionWriteLock } from "../../session-write-lock.js"; -import type { PromptReleasedSessionEntry } from "./attempt.session-lock.entries.js"; -import { - EmbeddedAttemptSessionFileFence, - EmbeddedAttemptSessionTakeoverError, - type SessionFileWriteAppendValidator, -} from "./attempt.session-lock.fence-controller.js"; -import { - readSessionFileFingerprint, - readSessionFileFingerprintSync, - type TrustedSessionFileSnapshot, -} from "./attempt.session-lock.fence.js"; -import { - createActiveWriteLockScope, - drainWriteLockScope, - trackWriteLockOperation, - type ActiveWriteLockState, -} from "./attempt.session-lock.write-scope.js"; - -type SessionLock = Awaited>; -type AcquireSessionWriteLock = typeof acquireSessionWriteLock; - -type LockOptions = { - sessionFile: string; - timeoutMs: number; - staleMs: number; - maxHoldMs: number; -}; - -type PromptReleasedSessionMergeResult = { - sessionFileSnapshot?: OwnedSessionTranscriptCacheSnapshot; - publishedEntries?: readonly OwnedSessionTranscriptPublishedEntry[]; - requiresReload?: true; -}; - -async function waitForSessionEventQueue(_session: unknown): Promise {} - -export type EmbeddedAttemptSessionLockController = { - canAdvanceSessionEntryCache(snapshot: OwnedSessionTranscriptCacheSnapshot): boolean; - publishOwnedSessionFileSnapshot(snapshot: OwnedSessionTranscriptCacheSnapshot): boolean; - publishValidatedSessionFileSnapshot(snapshot: OwnedSessionTranscriptCacheSnapshot): boolean; - readTrustedCurrentSessionFileSnapshot(): Promise; - releaseForPrompt(): Promise; - releaseHeldLockForAbort(): Promise; - refreshAfterOwnedSessionWrite(): void; - withOwnedSessionFileWrite( - run: () => T, - validateAppend?: SessionFileWriteAppendValidator, - ): T; - reacquireAfterPrompt(): Promise; - waitForSessionEvents(session: unknown): Promise; - withSessionWriteLock( - run: () => Promise | T, - options?: OwnedSessionTranscriptWriteOptions, - ): Promise; - acquireForCleanup(params?: { session?: unknown }): Promise; - hasSessionTakeover(): boolean; - dispose(): Promise; -}; - -export async function createEmbeddedAttemptSessionLockController(params: { - acquireSessionWriteLock: AcquireSessionWriteLock; - initialAcquireSignal?: AbortSignal; - lockOptions: LockOptions; - mergePromptReleasedSessionEntries?: ( - entries: readonly PromptReleasedSessionEntry[], - ) => Promise | PromptReleasedSessionMergeResult | void; - reloadPromptReleasedSessionFile?: () => Promise | void; -}): Promise { - const acquireLock = async (signal?: AbortSignal): Promise => - await params.acquireSessionWriteLock({ - sessionFile: params.lockOptions.sessionFile, - timeoutMs: params.lockOptions.timeoutMs, - staleMs: params.lockOptions.staleMs, - maxHoldMs: params.lockOptions.maxHoldMs, - ...(signal ? { signal } : {}), - }); - - let heldLock: SessionLock | undefined = await acquireLock(params.initialAcquireSignal); - const activeWriteLock = new AsyncLocalStorage(); - let ownedPublicationQueue: Promise = Promise.resolve(); - const fence = new EmbeddedAttemptSessionFileFence({ - sessionFile: params.lockOptions.sessionFile, - mergePromptReleasedSessionEntries: params.mergePromptReleasedSessionEntries, - reloadPromptReleasedSessionFile: params.reloadPromptReleasedSessionFile, - }); - // An aborted prompt can settle after attempt teardown. Never let its finally - // path reacquire a retained lock that no owner remains to release. - let disposed = false; - // Prompt-finally reacquisition can overlap attempt cleanup. Serialize that - // ownership handoff so cleanup adopts an in-flight reacquire, and skip any - // later reacquire once cleanup has begun or it could orphan a retained lock. - let lockLifecycle: Promise = Promise.resolve(); - let cleanupStarted = false; - // Set when an active retained write prevents immediate held-lock release. - // The scope completion path retries release after the retained use unwinds. - let releaseHeldLockDeferred = false; - let retainedLockUseCount = 0; - const retainedLockIdleWaiters = new Set<() => void>(); - let heldLockDraining = false; - let heldLockDrainOwner: symbol | undefined; - const heldLockDrainWaiters = new Set<() => void>(); - function runLockLifecycle(run: () => Promise): Promise { - const operation = lockLifecycle.then(run); - lockLifecycle = operation.then( - () => undefined, - () => undefined, - ); - return operation; - } - - function beginRetainedLockUse(): () => void { - retainedLockUseCount += 1; - let released = false; - return () => { - if (released) { - return; - } - released = true; - retainedLockUseCount -= 1; - if (retainedLockUseCount === 0 && retainedLockIdleWaiters.size > 0) { - const waiters = Array.from(retainedLockIdleWaiters); - retainedLockIdleWaiters.clear(); - for (const resolve of waiters) { - resolve(); - } - } - }; - } - - async function waitForRetainedLockIdle(): Promise { - if (retainedLockUseCount === 0) { - return true; - } - if (activeWriteLock.getStore()?.scope.active === true) { - return false; - } - await new Promise((resolve) => { - retainedLockIdleWaiters.add(resolve); - }); - return true; - } - - async function acquireWriteLock(): Promise<{ - lock: SessionLock; - owned: boolean; - releaseRetainedUse?: () => void; - }> { - await waitForHeldLockDrain(); - if (heldLock) { - return { lock: heldLock, owned: false, releaseRetainedUse: beginRetainedLockUse() }; - } - try { - return { lock: await acquireLock(), owned: true }; - } catch (err) { - if (isSessionWriteLockAcquireError(err)) { - fence.markTakeover(); - } - throw err; - } - } - - async function waitForHeldLockDrain(): Promise { - for (;;) { - if (!heldLockDraining) { - return; - } - await new Promise((resolve) => { - heldLockDrainWaiters.add(resolve); - }); - } - } - - async function beginHeldLockDrain(): Promise { - for (;;) { - if (!heldLockDraining) { - const owner = Symbol("held-lock-drain"); - heldLockDraining = true; - heldLockDrainOwner = owner; - return owner; - } - await new Promise((resolve) => { - heldLockDrainWaiters.add(resolve); - }); - } - } - - function finishHeldLockDrain(owner: symbol): void { - if (!heldLockDraining || heldLockDrainOwner !== owner) { - return; - } - heldLockDraining = false; - heldLockDrainOwner = undefined; - if (heldLockDrainWaiters.size === 0) { - return; - } - const waiters = Array.from(heldLockDrainWaiters); - heldLockDrainWaiters.clear(); - for (const resolve of waiters) { - resolve(); - } - } - - const noopLock: SessionLock = { release: async () => {} }; - - async function releaseHeldLockWithFence(): Promise { - if (!heldLock) { - await waitForHeldLockDrain(); - return; - } - const drainOwner = await beginHeldLockDrain(); - try { - if (!(await waitForRetainedLockIdle())) { - releaseHeldLockDeferred = true; - return; - } - if (!heldLock) { - return; - } - const lock = heldLock; - heldLock = undefined; - // Clearing `heldLock` transfers release ownership to this block. Fence reads can - // throw after that transfer; release the underlying file lock anyway so later - // turns do not wait for the maxHoldMs watchdog. - try { - await fence.activateForRelease(); - } finally { - await lock.release(); - } - } finally { - finishHeldLockDrain(drainOwner); - } - } - - async function takeHeldLockAfterRetainedIdle(): Promise { - if (!heldLock) { - return undefined; - } - const drainOwner = await beginHeldLockDrain(); - try { - if (!(await waitForRetainedLockIdle())) { - // Do not wait for retained idle from inside the active scope; that - // scope must unwind before the retained-use waiter can resolve. - return undefined; - } - if (!heldLock) { - return undefined; - } - const lock = heldLock; - heldLock = undefined; - return lock; - } finally { - finishHeldLockDrain(drainOwner); - } - } - - async function disposeHeldLockAfterRetainedIdle(): Promise { - if (!heldLock) { - await waitForHeldLockDrain(); - return; - } - const drainOwner = await beginHeldLockDrain(); - try { - if (!(await waitForRetainedLockIdle())) { - // Same active-scope self-deadlock guard as takeHeldLockAfterRetainedIdle. - return; - } - if (!heldLock) { - return; - } - const lock = heldLock; - heldLock = undefined; - await lock.release(); - } finally { - finishHeldLockDrain(drainOwner); - } - } - - async function releaseHeldLockAfterTakeover(): Promise { - if (!fence.hasTakeover()) { - return; - } - await disposeHeldLockAfterRetainedIdle(); - } - - async function acquireCleanupLock(): Promise { - const retainedLock = await takeHeldLockAfterRetainedIdle(); - if (retainedLock) { - return retainedLock; - } - await waitForHeldLockDrain(); - try { - return await acquireLock(); - } catch (err) { - if (isSessionWriteLockAcquireError(err)) { - fence.markTakeover(); - return undefined; - } - throw err; - } - } - - async function runWithPhysicalWriteLockScope( - run: () => Promise, - release: () => Promise | void, - ): Promise { - const scope = createActiveWriteLockScope(); - let outcome: { ok: true; value: T } | { ok: false; error: unknown }; - try { - outcome = { ok: true, value: await activeWriteLock.run(scope.state, run) }; - } catch (error) { - outcome = { ok: false, error }; - } finally { - try { - await drainWriteLockScope(scope.state.scope); - } finally { - scope.state.active = false; - scope.state.scope.active = false; - try { - await release(); - } finally { - scope.complete(); - } - } - } - await releaseHeldLockAfterTakeover(); - // Retained use has been released and the active scope is no longer live, - // so a prior active-scope release bailout can drain the held file lock now. - if (releaseHeldLockDeferred) { - releaseHeldLockDeferred = false; - await releaseHeldLockWithFence(); - } - if (!outcome.ok) { - throw outcome.error; - } - if (fence.hasTakeover()) { - throw new EmbeddedAttemptSessionTakeoverError(params.lockOptions.sessionFile); - } - return outcome.value; - } - - async function runWithRetainedLock( - run: () => Promise, - releaseRetainedUse: () => void, - ): Promise { - return await runWithPhysicalWriteLockScope(run, releaseRetainedUse); - } - - async function runPublishingOwnedSessionFileWrite( - run: () => Promise | T, - resolvePublishedEntries?: (result: T) => readonly OwnedSessionTranscriptPublishedEntry[], - resolvePublishedEntriesAfterFailure?: () => readonly OwnedSessionTranscriptPublishedEntry[], - ): Promise { - const parentLockState = activeWriteLock.getStore(); - if (!parentLockState?.active || !parentLockState.scope.active) { - throw new Error("owned session publication requires an active session write lock"); - } - if (parentLockState?.publishingOwnedWrite && parentLockState.acceptingNestedPublications) { - const nestedPublication = (async () => { - let nestedEntries: readonly OwnedSessionTranscriptPublishedEntry[] | undefined; - try { - const result = await run(); - nestedEntries = resolvePublishedEntries?.(result); - return result; - } catch (error) { - nestedEntries = resolvePublishedEntriesAfterFailure?.(); - throw error; - } finally { - if (nestedEntries !== undefined) { - parentLockState.publishedEntries ??= []; - parentLockState.publishedEntries.push(...nestedEntries); - } - } - })(); - return await trackWriteLockOperation( - parentLockState.scope, - nestedPublication, - parentLockState.pendingNestedPublications, - ); - } - const publication = (async () => { - let releaseQueue!: () => void; - const currentQueueEntry = new Promise((resolve) => { - releaseQueue = resolve; - }); - const previousQueueEntry = ownedPublicationQueue.catch(() => undefined); - ownedPublicationQueue = previousQueueEntry.then(() => currentQueueEntry); - await previousQueueEntry; - try { - if (fence.hasTakeover()) { - throw new EmbeddedAttemptSessionTakeoverError(params.lockOptions.sessionFile); - } - const beforeWrite = await fence.captureOwnedWriteStart(); - const publicationLockState: ActiveWriteLockState = { - active: true, - scope: parentLockState.scope, - publishingOwnedWrite: true, - acceptingNestedPublications: true, - pendingNestedPublications: new Set(), - publishedEntries: undefined, - }; - try { - return await activeWriteLock.run(publicationLockState, async () => { - let ownEntries: readonly OwnedSessionTranscriptPublishedEntry[] | undefined; - try { - const result = await run(); - ownEntries = resolvePublishedEntries?.(result); - return result; - } catch (error) { - ownEntries = resolvePublishedEntriesAfterFailure?.(); - throw error; - } finally { - // Nested transcript callbacks inherit this publication owner. - // Drain them before freezing the expected fence entry set. - while (publicationLockState.pendingNestedPublications.size > 0) { - await Promise.all(publicationLockState.pendingNestedPublications); - } - publicationLockState.acceptingNestedPublications = false; - publicationLockState.active = false; - const nestedEntries = publicationLockState.publishedEntries; - const expectedPublishedEntries = - nestedEntries === undefined - ? ownEntries - : ownEntries === undefined - ? nestedEntries - : [...nestedEntries, ...ownEntries]; - await fence.publishOwnedWrite(beforeWrite, expectedPublishedEntries); - } - }); - } finally { - publicationLockState.active = false; - } - } finally { - releaseQueue(); - } - })(); - return await trackWriteLockOperation(parentLockState.scope, publication); - } - - async function runInheritedWriteLockOperation( - state: ActiveWriteLockState, - run: () => Promise | T, - ): Promise { - const operation = (async () => await run())(); - return await trackWriteLockOperation(state.scope, operation); - } - - async function withSessionWriteLock( - run: () => Promise | T, - options?: OwnedSessionTranscriptWriteOptions, - ): Promise { - if (fence.hasTakeover()) { - throw new EmbeddedAttemptSessionTakeoverError(params.lockOptions.sessionFile); - } - const inheritedLockState = activeWriteLock.getStore(); - if (inheritedLockState && (!inheritedLockState.active || !inheritedLockState.scope.active)) { - await inheritedLockState.scope.completion; - return await activeWriteLock.exit(() => withSessionWriteLock(run, options)); - } - if (inheritedLockState?.active === true) { - if (options?.publishOwnedWrite !== true) { - return await runInheritedWriteLockOperation(inheritedLockState, run); - } - return await runPublishingOwnedSessionFileWrite( - run, - options.resolvePublishedEntries, - options.resolvePublishedEntriesAfterFailure, - ); - } - const { lock, owned, releaseRetainedUse } = await acquireWriteLock(); - const runLockedOperation = async () => { - await fence.assert(); - if (options?.publishOwnedWrite === true) { - return await runPublishingOwnedSessionFileWrite( - run, - options.resolvePublishedEntries, - options.resolvePublishedEntriesAfterFailure, - ); - } - const beforeWrite = await readSessionFileFingerprint(params.lockOptions.sessionFile); - try { - return await run(); - } finally { - await fence.refresh(beforeWrite); - } - }; - if (!owned) { - return await runWithRetainedLock(runLockedOperation, releaseRetainedUse ?? (() => {})); - } - - return await runWithPhysicalWriteLockScope(runLockedOperation, () => lock.release()); - } - - return { - canAdvanceSessionEntryCache(snapshot: OwnedSessionTranscriptCacheSnapshot): boolean { - const state = activeWriteLock.getStore(); - return ( - state?.active === true && state.scope.active && fence.canAdvanceSessionEntryCache(snapshot) - ); - }, - publishOwnedSessionFileSnapshot(snapshot: OwnedSessionTranscriptCacheSnapshot): boolean { - const state = activeWriteLock.getStore(); - return state?.active === true && state.scope.active - ? fence.publishOwnedSessionFileSnapshot(snapshot) - : false; - }, - publishValidatedSessionFileSnapshot(snapshot: OwnedSessionTranscriptCacheSnapshot): boolean { - return heldLock && !heldLockDraining - ? fence.publishValidatedSessionFileSnapshot(snapshot) - : false; - }, - async readTrustedCurrentSessionFileSnapshot(): Promise { - return await fence.readTrustedCurrentSessionFileSnapshot(); - }, - async releaseForPrompt(): Promise { - await releaseHeldLockWithFence(); - }, - async releaseHeldLockForAbort(): Promise { - await releaseHeldLockWithFence(); - }, - refreshAfterOwnedSessionWrite(): void { - fence.refreshAfterOwnedSessionWrite(); - }, - withOwnedSessionFileWrite( - run: () => T, - validateAppend?: SessionFileWriteAppendValidator, - ): T { - const beforeWrite = readSessionFileFingerprintSync(params.lockOptions.sessionFile); - const beforeText = validateAppend - ? readFileSync(params.lockOptions.sessionFile, "utf8") - : undefined; - const result = run(); - fence.publishOwnedWriteSync({ - beforeWrite, - result, - ...(beforeText !== undefined ? { beforeText } : {}), - ...(validateAppend ? { validateAppend } : {}), - }); - return result; - }, - async reacquireAfterPrompt(): Promise { - if (cleanupStarted) { - return; - } - await runLockLifecycle(async () => { - await waitForHeldLockDrain(); - if (disposed || fence.hasTakeover() || heldLock) { - return; - } - let lock: SessionLock; - try { - lock = await acquireLock(); - } catch (err) { - if (isSessionWriteLockAcquireError(err)) { - fence.markTakeover(); - } - throw err; - } - if (disposed) { - await lock.release(); - return; - } - try { - heldLock = lock; - await fence.assert(); - } catch (err) { - heldLock = undefined; - await lock.release(); - throw err; - } - }); - }, - waitForSessionEvents: waitForSessionEventQueue, - withSessionWriteLock, - async acquireForCleanup(cleanupParams?: { session?: unknown }): Promise { - cleanupStarted = true; - if (cleanupParams?.session) { - await waitForSessionEventQueue(cleanupParams.session); - } - return await runLockLifecycle(async () => { - if (fence.hasTakeover()) { - return noopLock; - } - const cleanupLock = await acquireCleanupLock(); - if (!cleanupLock) { - return noopLock; - } - try { - await fence.assert(); - } catch (err) { - await cleanupLock.release(); - if (err instanceof EmbeddedAttemptSessionTakeoverError) { - return noopLock; - } - throw err; - } - return cleanupLock; - }); - }, - hasSessionTakeover(): boolean { - return fence.hasTakeover(); - }, - async dispose(): Promise { - disposed = true; - try { - await disposeHeldLockAfterRetainedIdle(); - } finally { - fence.deactivate(); - } - }, - }; -} diff --git a/src/agents/embedded-agent-runner/run/attempt.session-lock.entries.ts b/src/agents/embedded-agent-runner/run/attempt.session-lock.entries.ts deleted file mode 100644 index 23aff9c5145d..000000000000 --- a/src/agents/embedded-agent-runner/run/attempt.session-lock.entries.ts +++ /dev/null @@ -1,388 +0,0 @@ -import { isDeepStrictEqual } from "node:util"; -import type { OwnedSessionTranscriptPublishedEntry } from "../../../config/sessions/transcript-write-context.js"; -import { isTranscriptOnlyOpenClawAssistantMessage } from "../../../shared/transcript-only-openclaw-assistant.js"; -import type { - CustomEntry, - LabelEntry, - SessionInfoEntry, - SessionMessageEntry, -} from "../../sessions/session-manager.js"; - -function isJsonRecord(value: unknown): value is Record { - return typeof value === "object" && value !== null && !Array.isArray(value); -} - -function parsePromptReleasedMessageLine( - line: string, - options?: { allowAnyMessage?: boolean }, -): SessionMessageEntry | undefined { - try { - const parsed = JSON.parse(line) as unknown; - if ( - !isJsonRecord(parsed) || - parsed.type !== "message" || - typeof parsed.id !== "string" || - parsed.id.trim().length === 0 || - typeof parsed.timestamp !== "string" || - parsed.timestamp.trim().length === 0 || - (parsed.parentId !== undefined && - parsed.parentId !== null && - typeof parsed.parentId !== "string") || - (parsed.appendMode !== undefined && parsed.appendMode !== "side") - ) { - return undefined; - } - const message = parsed.message; - if (!isJsonRecord(message)) { - return undefined; - } - const isOpenClawTranscriptOnlyAssistant = isTranscriptOnlyOpenClawAssistantMessage(message); - if ( - typeof message.role !== "string" || - (!options?.allowAnyMessage && !isOpenClawTranscriptOnlyAssistant) - ) { - return undefined; - } - return { - type: "message", - id: parsed.id, - parentId: parsed.parentId ?? null, - timestamp: parsed.timestamp, - message: message as unknown as SessionMessageEntry["message"], - ...(parsed.appendMode === "side" ? { appendMode: parsed.appendMode } : {}), - }; - } catch { - return undefined; - } -} - -function hasSessionEntryBase(record: Record): boolean { - return ( - typeof record.id === "string" && - record.id.trim().length > 0 && - (record.parentId === null || typeof record.parentId === "string") && - typeof record.timestamp === "string" && - record.timestamp.trim().length > 0 - ); -} - -type PromptReleasedSessionMetadataEntry = CustomEntry | LabelEntry | SessionInfoEntry; - -type PromptReleasedOpaqueEntry = { - type: "prompt_released_opaque"; - record: unknown; - /** Unowned side-leaf rows may extend only the current delivery side branch. */ - preserveActiveLeaf?: true; -}; - -export type PromptReleasedSessionEntry = - | SessionMessageEntry - | PromptReleasedSessionMetadataEntry - | PromptReleasedOpaqueEntry; - -function parsePromptReleasedGlobalMetadataLine( - line: string, -): PromptReleasedSessionMetadataEntry | undefined { - try { - const parsed = JSON.parse(line) as unknown; - if (!isJsonRecord(parsed) || !hasSessionEntryBase(parsed)) { - return undefined; - } - const base = { - id: parsed.id as string, - parentId: parsed.parentId as string | null, - timestamp: parsed.timestamp as string, - }; - // These records are resolved globally rather than through the active branch. - // Accepting them keeps an in-flight reply alive without losing branch-scoped - // model or thinking state when the active SessionManager is stale. - switch (parsed.type) { - case "custom": - return typeof parsed.customType === "string" && parsed.customType.trim().length > 0 - ? { - ...base, - type: "custom", - customType: parsed.customType, - ...(Object.hasOwn(parsed, "data") ? { data: parsed.data } : {}), - } - : undefined; - case "label": - return typeof parsed.targetId === "string" && - parsed.targetId.trim().length > 0 && - (parsed.label === undefined || typeof parsed.label === "string") - ? { - ...base, - type: "label", - targetId: parsed.targetId, - label: parsed.label, - } - : undefined; - case "session_info": - return parsed.name === undefined || typeof parsed.name === "string" - ? { - ...base, - type: "session_info", - ...(typeof parsed.name === "string" ? { name: parsed.name } : {}), - } - : undefined; - default: - return undefined; - } - } catch { - return undefined; - } -} - -function parsePromptReleasedOpaqueLine(line: string): PromptReleasedOpaqueEntry | undefined { - try { - const record = JSON.parse(line) as unknown; - return !isJsonRecord(record) || record.type !== "message" - ? { type: "prompt_released_opaque", record } - : undefined; - } catch { - return undefined; - } -} - -function parsePromptReleasedSideLeafControlLine( - line: string, -): PromptReleasedOpaqueEntry | undefined { - try { - const record = JSON.parse(line) as unknown; - if ( - !isJsonRecord(record) || - record.type !== "leaf" || - !hasSessionEntryBase(record) || - (record.targetId !== null && typeof record.targetId !== "string") || - (record.appendParentId !== undefined && - record.appendParentId !== null && - typeof record.appendParentId !== "string") || - record.appendMode !== "side" - ) { - return undefined; - } - return { type: "prompt_released_opaque", record, preserveActiveLeaf: true }; - } catch { - return undefined; - } -} - -export type PromptReleasedSessionChange = - | { - kind: "transcript-only"; - entries: SessionMessageEntry[]; - publishedEntries: OwnedSessionTranscriptPublishedEntry[]; - } - | { - kind: "global-metadata"; - entries: PromptReleasedSessionEntry[]; - publishedEntries: OwnedSessionTranscriptPublishedEntry[]; - } - | { - kind: "opaque"; - entries: PromptReleasedSessionEntry[]; - publishedEntries: OwnedSessionTranscriptPublishedEntry[]; - }; - -export function classifyPromptReleasedSessionLines( - lines: string[], - options?: { - allowAnyMessage?: boolean; - expectedPublishedEntries?: readonly OwnedSessionTranscriptPublishedEntry[]; - initialParentId?: string | null; - }, -): PromptReleasedSessionChange | undefined { - if (lines.length === 0) { - return undefined; - } - const entries: PromptReleasedSessionEntry[] = []; - const publishedEntries: OwnedSessionTranscriptPublishedEntry[] = []; - const remainingExpectedEntries = options?.expectedPublishedEntries - ? [...options.expectedPublishedEntries] - : undefined; - let hasGlobalMetadata = false; - let hasOpaqueEntries = false; - let expectedParentId = options?.initialParentId ?? null; - for (const line of lines) { - const matchExpectedEntry = ( - id: string | undefined, - ): OwnedSessionTranscriptPublishedEntry | undefined => { - if (!remainingExpectedEntries) { - if (id) { - expectedParentId = id; - return { kind: "id", id }; - } - return { kind: "serialized", serialized: line }; - } - let matchIndex = remainingExpectedEntries.findIndex( - (entry) => entry.kind === "serialized" && entry.serialized === line, - ); - let migratedParentId: string | undefined; - if (matchIndex < 0 && id) { - matchIndex = remainingExpectedEntries.findIndex( - (entry) => entry.kind === "id" && entry.id === id, - ); - } - if (matchIndex < 0) { - matchIndex = remainingExpectedEntries.findIndex((entry) => { - if (entry.kind !== "serialized") { - return false; - } - const lineMatch = lineMatchesLinearTranscriptMigration({ - previousLine: entry.serialized, - currentLine: line, - expectedParentId, - }); - if (!lineMatch.ok) { - return false; - } - migratedParentId = lineMatch.nextPreviousId; - return true; - }); - } - if (matchIndex < 0) { - return undefined; - } - const [matchedEntry] = remainingExpectedEntries.splice(matchIndex, 1); - if (migratedParentId) { - expectedParentId = migratedParentId; - } else if (id) { - expectedParentId = id; - } - return matchedEntry; - }; - const transcriptEntry = parsePromptReleasedMessageLine(line, options); - if (transcriptEntry) { - const publishedEntry = matchExpectedEntry(transcriptEntry.id); - if (!publishedEntry) { - return undefined; - } - entries.push(transcriptEntry); - publishedEntries.push(publishedEntry); - continue; - } - const metadataEntry = parsePromptReleasedGlobalMetadataLine(line); - if (metadataEntry) { - const publishedEntry = matchExpectedEntry(metadataEntry.id); - if (!publishedEntry) { - return undefined; - } - entries.push(metadataEntry); - publishedEntries.push(publishedEntry); - hasGlobalMetadata = true; - continue; - } - const opaqueEntry = options?.allowAnyMessage - ? parsePromptReleasedOpaqueLine(line) - : parsePromptReleasedSideLeafControlLine(line); - const opaqueId = - opaqueEntry && isJsonRecord(opaqueEntry.record) - ? normalizeTranscriptEntryId(opaqueEntry.record.id) - : undefined; - const publishedEntry = opaqueEntry ? matchExpectedEntry(opaqueId) : undefined; - if (!opaqueEntry || !publishedEntry) { - return undefined; - } - entries.push(opaqueEntry); - publishedEntries.push(publishedEntry); - hasOpaqueEntries = true; - } - if (remainingExpectedEntries?.length) { - return undefined; - } - if (hasOpaqueEntries) { - return { kind: "opaque", entries, publishedEntries }; - } - if (hasGlobalMetadata) { - return { kind: "global-metadata", entries, publishedEntries }; - } - return { - kind: "transcript-only", - entries: entries as SessionMessageEntry[], - publishedEntries, - }; -} - -export function haveSamePublishedEntries( - actual: readonly OwnedSessionTranscriptPublishedEntry[], - expected: readonly OwnedSessionTranscriptPublishedEntry[], -): boolean { - if (actual.length !== expected.length) { - return false; - } - const unmatched = [...expected]; - for (const entry of actual) { - const matchIndex = unmatched.findIndex((candidate) => isDeepStrictEqual(candidate, entry)); - if (matchIndex < 0) { - return false; - } - unmatched.splice(matchIndex, 1); - } - return true; -} - -function normalizeTranscriptEntryId(value: unknown): string | undefined { - return typeof value === "string" && value.trim().length > 0 ? value : undefined; -} - -function omitRecordKeys( - record: Record, - keys: Set, -): Record { - const result: Record = {}; - for (const [key, value] of Object.entries(record)) { - if (!keys.has(key)) { - result[key] = value; - } - } - return result; -} - -export function lineMatchesLinearTranscriptMigration(params: { - previousLine: string; - currentLine: string; - expectedParentId: string | null; -}): { ok: true; nextPreviousId?: string } | { ok: false } { - let previousParsed: unknown; - let currentParsed: unknown; - try { - previousParsed = JSON.parse(params.previousLine); - currentParsed = JSON.parse(params.currentLine); - } catch { - return params.previousLine === params.currentLine ? { ok: true } : { ok: false }; - } - if (!isJsonRecord(previousParsed)) { - return params.previousLine === params.currentLine ? { ok: true } : { ok: false }; - } - if (!isJsonRecord(currentParsed)) { - return { ok: false }; - } - if (previousParsed.type === "session") { - return isDeepStrictEqual( - omitRecordKeys(previousParsed, new Set(["version"])), - omitRecordKeys(currentParsed, new Set(["version"])), - ) - ? { ok: true } - : { ok: false }; - } - - const previousId = normalizeTranscriptEntryId(previousParsed.id); - const currentId = normalizeTranscriptEntryId(currentParsed.id); - if (previousId ? currentId !== previousId : !currentId) { - return { ok: false }; - } - if (Object.hasOwn(previousParsed, "parentId")) { - if (!isDeepStrictEqual(previousParsed.parentId, currentParsed.parentId)) { - return { ok: false }; - } - } else if (!isDeepStrictEqual(currentParsed.parentId, params.expectedParentId)) { - return { ok: false }; - } - - return isDeepStrictEqual( - omitRecordKeys(previousParsed, new Set(["id", "parentId"])), - omitRecordKeys(currentParsed, new Set(["id", "parentId"])), - ) - ? { ok: true, nextPreviousId: currentId } - : { ok: false }; -} diff --git a/src/agents/embedded-agent-runner/run/attempt.session-lock.fence-controller.ts b/src/agents/embedded-agent-runner/run/attempt.session-lock.fence-controller.ts deleted file mode 100644 index b03b9583250a..000000000000 --- a/src/agents/embedded-agent-runner/run/attempt.session-lock.fence-controller.ts +++ /dev/null @@ -1,610 +0,0 @@ -import { readFileSync } from "node:fs"; -import type { - OwnedSessionTranscriptCacheSnapshot, - OwnedSessionTranscriptPublishedEntry, -} from "../../../config/sessions/transcript-write-context.js"; -import { resolveEmbeddedSessionFileKey } from "../session-file-key.js"; -import { - haveSamePublishedEntries, - type PromptReleasedSessionEntry, -} from "./attempt.session-lock.entries.js"; -import { - classifySessionFenceChange, - readByteIdenticalSessionFenceSnapshot, - readSessionFileFenceSnapshot, - readSessionFileFingerprint, - readSessionFileFingerprintSync, - sameSessionFileFingerprint, - type SessionFileFenceSnapshot, - type SessionFileFingerprint, - type TrustedSessionFileSnapshot, -} from "./attempt.session-lock.fence.js"; - -export class EmbeddedAttemptSessionTakeoverError extends Error { - constructor(sessionFile: string) { - super(`session file changed while embedded prompt lock was released: ${sessionFile}`); - this.name = "EmbeddedAttemptSessionTakeoverError"; - } -} - -type OwnedSessionFileWrite = { - generation: number; - fingerprint: SessionFileFingerprint; - publishedEntries?: readonly OwnedSessionTranscriptPublishedEntry[]; - requiresReload?: true; -}; - -type OwnedSessionFileWriteHistory = { - activeFenceGenerations: Map; - writes: OwnedSessionFileWrite[]; -}; - -type TrustedSessionFileState = { - generation: number; - fingerprint: SessionFileFingerprint; -}; - -// Controllers in the same OpenClaw process can legitimately take turns writing -// the same session file while another attempt is released for model I/O. Track -// only fingerprints that changed while OpenClaw held the write lock so the -// takeover fence can distinguish those locked in-process writes from unowned -// external file changes. -const ownedSessionFileWrites = new Map(); -const trustedSessionFileStates = new Map(); -let ownedSessionFileWriteGeneration = 0; - -function resolveSessionFileFenceKey(sessionFile: string): string { - return resolveEmbeddedSessionFileKey(sessionFile); -} - -export function resetSessionFileFenceStateForTest(): void { - ownedSessionFileWrites.clear(); - trustedSessionFileStates.clear(); - ownedSessionFileWriteGeneration = 0; -} - -function resolveOwnedSessionFileWriteHistory(sessionFileKey: string): OwnedSessionFileWriteHistory { - const existing = ownedSessionFileWrites.get(sessionFileKey); - if (existing) { - return existing; - } - const created = { - activeFenceGenerations: new Map(), - writes: [], - }; - ownedSessionFileWrites.set(sessionFileKey, created); - return created; -} - -function getOwnedSessionFileWriteHistory( - sessionFileKey: string, -): OwnedSessionFileWriteHistory | undefined { - return ownedSessionFileWrites.get(sessionFileKey); -} - -function pruneOwnedSessionFileWriteHistory( - sessionFileKey: string, - history: OwnedSessionFileWriteHistory, -): void { - if (history.activeFenceGenerations.size === 0) { - ownedSessionFileWrites.delete(sessionFileKey); - return; - } - const oldestFenceGeneration = Math.min(...history.activeFenceGenerations.values()); - history.writes = history.writes.filter((write) => write.generation > oldestFenceGeneration); -} - -function recordOwnedSessionFileWrite( - sessionFileKey: string, - fingerprint: SessionFileFingerprint, - publishedEntries?: readonly OwnedSessionTranscriptPublishedEntry[], - requiresReload?: true, -): number { - ownedSessionFileWriteGeneration += 1; - const state = { - generation: ownedSessionFileWriteGeneration, - fingerprint, - ...(publishedEntries ? { publishedEntries: [...publishedEntries] } : {}), - ...(requiresReload ? { requiresReload } : {}), - }; - const history = resolveOwnedSessionFileWriteHistory(sessionFileKey); - history.writes.push(state); - pruneOwnedSessionFileWriteHistory(sessionFileKey, history); - trustedSessionFileStates.set(sessionFileKey, state); - return ownedSessionFileWriteGeneration; -} - -function recordTrustedSessionFileState( - sessionFileKey: string, - fingerprint: SessionFileFingerprint, -): number { - ownedSessionFileWriteGeneration += 1; - const state = { - generation: ownedSessionFileWriteGeneration, - fingerprint, - }; - trustedSessionFileStates.set(sessionFileKey, state); - return ownedSessionFileWriteGeneration; -} - -function trustSessionFileState( - sessionFileKey: string, - fingerprint: SessionFileFingerprint, -): number | undefined { - const trusted = trustedSessionFileStates.get(sessionFileKey); - if (trusted) { - return sameSessionFileFingerprint(trusted.fingerprint, fingerprint) - ? trusted.generation - : undefined; - } - ownedSessionFileWriteGeneration += 1; - trustedSessionFileStates.set(sessionFileKey, { - generation: ownedSessionFileWriteGeneration, - fingerprint, - }); - return ownedSessionFileWriteGeneration; -} - -function isTrustedSessionFileState( - sessionFileKey: string, - fingerprint: SessionFileFingerprint, -): boolean { - const trusted = trustedSessionFileStates.get(sessionFileKey); - return trusted !== undefined && sameSessionFileFingerprint(trusted.fingerprint, fingerprint); -} - -export type SessionFileWriteAppendValidator = (result: T, appendedText: string) => boolean; - -type PromptReleasedSessionMergeResult = { - sessionFileSnapshot?: OwnedSessionTranscriptCacheSnapshot; - publishedEntries?: readonly OwnedSessionTranscriptPublishedEntry[]; - requiresReload?: true; -}; - -export class EmbeddedAttemptSessionFileFence { - private fingerprint: SessionFileFingerprint | undefined; - private snapshot: SessionFileFenceSnapshot | undefined; - private generation = 0; - private active = false; - private takeoverDetected = false; - private readonly sessionFileFenceKey: string; - private readonly controllerFenceId: symbol; - - constructor( - private readonly params: { - sessionFile: string; - mergePromptReleasedSessionEntries?: ( - entries: readonly PromptReleasedSessionEntry[], - ) => - | Promise - | PromptReleasedSessionMergeResult - | void; - reloadPromptReleasedSessionFile?: () => Promise | void; - }, - ) { - this.sessionFileFenceKey = resolveSessionFileFenceKey(params.sessionFile); - this.controllerFenceId = Symbol(this.sessionFileFenceKey); - } - - hasTakeover(): boolean { - return this.takeoverDetected; - } - - markTakeover(): void { - this.takeoverDetected = true; - } - - createTakeoverError(): EmbeddedAttemptSessionTakeoverError { - return new EmbeddedAttemptSessionTakeoverError(this.params.sessionFile); - } - - canAdvanceSessionEntryCache(snapshot: OwnedSessionTranscriptCacheSnapshot): boolean { - if (this.takeoverDetected) { - return false; - } - const fingerprint: SessionFileFingerprint = { exists: true, ...snapshot }; - return ( - (this.active && sameSessionFileFingerprint(this.fingerprint, fingerprint)) || - isTrustedSessionFileState(this.sessionFileFenceKey, fingerprint) - ); - } - - publishOwnedSessionFileSnapshot(snapshot: OwnedSessionTranscriptCacheSnapshot): boolean { - if (this.takeoverDetected) { - return false; - } - const fingerprint: SessionFileFingerprint = { exists: true, ...snapshot }; - const current = readSessionFileFingerprintSync(this.params.sessionFile); - if (!sameSessionFileFingerprint(fingerprint, current)) { - return false; - } - const generation = recordOwnedSessionFileWrite(this.sessionFileFenceKey, current); - if (this.active) { - this.fingerprint = current; - this.snapshot = { fingerprint: current }; - this.setGeneration(generation); - } - return true; - } - - publishValidatedSessionFileSnapshot(snapshot: OwnedSessionTranscriptCacheSnapshot): boolean { - if (this.takeoverDetected) { - return false; - } - const fingerprint: SessionFileFingerprint = { exists: true, ...snapshot }; - const current = readSessionFileFingerprintSync(this.params.sessionFile); - if (!sameSessionFileFingerprint(fingerprint, current)) { - return false; - } - this.setGeneration(recordTrustedSessionFileState(this.sessionFileFenceKey, current)); - if (this.active) { - this.fingerprint = current; - this.snapshot = { fingerprint: current }; - } - return true; - } - - async readTrustedCurrentSessionFileSnapshot(): Promise { - const fingerprint = await readSessionFileFingerprint(this.params.sessionFile); - return fingerprint.exists && isTrustedSessionFileState(this.sessionFileFenceKey, fingerprint) - ? fingerprint - : undefined; - } - - refreshAfterOwnedSessionWrite(): void { - if (this.takeoverDetected) { - return; - } - const beforeWrite = this.fingerprint; - const fingerprint = readSessionFileFingerprintSync(this.params.sessionFile); - if (!this.active) { - // User-message persistence occurs before the prompt fence activates. - // The retained session lock owns that write, so publish its exact state - // for the next attempt before release establishes the active fence. - this.setGeneration(recordTrustedSessionFileState(this.sessionFileFenceKey, fingerprint)); - return; - } - if ( - !sameSessionFileFingerprint(beforeWrite, fingerprint) && - isTrustedSessionFileState(this.sessionFileFenceKey, beforeWrite ?? { exists: false }) - ) { - this.setGeneration(recordOwnedSessionFileWrite(this.sessionFileFenceKey, fingerprint)); - } - this.fingerprint = fingerprint; - this.snapshot = { fingerprint }; - } - - private setGeneration(generation: number): void { - this.generation = generation; - if (!this.active) { - return; - } - const history = resolveOwnedSessionFileWriteHistory(this.sessionFileFenceKey); - history.activeFenceGenerations.set(this.controllerFenceId, generation); - pruneOwnedSessionFileWriteHistory(this.sessionFileFenceKey, history); - } - - private activate(generation: number): void { - this.active = true; - this.setGeneration(generation); - } - - deactivate(): void { - if (!this.active) { - return; - } - this.active = false; - const history = getOwnedSessionFileWriteHistory(this.sessionFileFenceKey); - if (!history) { - return; - } - history.activeFenceGenerations.delete(this.controllerFenceId); - pruneOwnedSessionFileWriteHistory(this.sessionFileFenceKey, history); - } - - private async mergePromptReleasedSessionChange( - previous: SessionFileFenceSnapshot | undefined, - current: SessionFileFingerprint, - options?: { - expectedPublishedEntries?: readonly OwnedSessionTranscriptPublishedEntry[]; - }, - ): Promise< - | { - snapshot: SessionFileFenceSnapshot; - publishedEntries?: OwnedSessionTranscriptPublishedEntry[]; - postMergePublishedEntries?: readonly OwnedSessionTranscriptPublishedEntry[]; - requiresReload?: true; - } - | undefined - > { - if (!this.params.mergePromptReleasedSessionEntries) { - return undefined; - } - const change = await classifySessionFenceChange({ - sessionFile: this.params.sessionFile, - previous, - current, - expectedPublishedEntries: options?.expectedPublishedEntries, - }); - if (!change) { - return undefined; - } - if ( - options?.expectedPublishedEntries && - !haveSamePublishedEntries(change.publishedEntries, options.expectedPublishedEntries) - ) { - return undefined; - } - let mergeResult: PromptReleasedSessionMergeResult | void; - try { - mergeResult = await this.params.mergePromptReleasedSessionEntries(change.entries); - } catch (error) { - this.takeoverDetected = true; - throw error; - } - const refreshedSnapshot = await readSessionFileFenceSnapshot(this.params.sessionFile); - const expectedFingerprint = mergeResult?.sessionFileSnapshot - ? { exists: true as const, ...mergeResult.sessionFileSnapshot } - : current; - if (!sameSessionFileFingerprint(expectedFingerprint, refreshedSnapshot.fingerprint)) { - this.takeoverDetected = true; - throw this.createTakeoverError(); - } - return { - snapshot: refreshedSnapshot, - publishedEntries: mergeResult?.requiresReload - ? undefined - : mergeResult?.publishedEntries - ? [...change.publishedEntries, ...mergeResult.publishedEntries] - : change.publishedEntries, - ...(mergeResult?.publishedEntries - ? { postMergePublishedEntries: mergeResult.publishedEntries } - : {}), - ...(mergeResult?.requiresReload ? { requiresReload: true as const } : {}), - }; - } - - private async reloadPromptReleasedSessionFile( - expectedFingerprint: SessionFileFingerprint, - ): Promise { - if (!this.params.reloadPromptReleasedSessionFile) { - return undefined; - } - try { - await this.params.reloadPromptReleasedSessionFile(); - } catch (error) { - this.takeoverDetected = true; - throw error; - } - const snapshot = await readSessionFileFenceSnapshot(this.params.sessionFile); - if (!sameSessionFileFingerprint(expectedFingerprint, snapshot.fingerprint)) { - this.takeoverDetected = true; - throw this.createTakeoverError(); - } - return snapshot; - } - - async assert(): Promise { - if (!this.active) { - return; - } - const current = await readSessionFileFingerprint(this.params.sessionFile); - if (sameSessionFileFingerprint(this.fingerprint, current)) { - return; - } - - const ownedWriteHistory = - getOwnedSessionFileWriteHistory(this.sessionFileFenceKey)?.writes ?? []; - const ownedWrite = ownedWriteHistory.at(-1); - if ( - ownedWrite && - ownedWrite.generation > this.generation && - sameSessionFileFingerprint(ownedWrite.fingerprint, current) - ) { - const unseenOwnedWrites = ownedWriteHistory.filter( - (write) => write.generation > this.generation, - ); - if (unseenOwnedWrites.some((write) => write.requiresReload)) { - const reloadedSnapshot = await this.reloadPromptReleasedSessionFile(current); - if (!reloadedSnapshot) { - this.takeoverDetected = true; - throw this.createTakeoverError(); - } - this.fingerprint = reloadedSnapshot.fingerprint; - this.snapshot = reloadedSnapshot; - this.setGeneration(ownedWrite.generation); - return; - } - const canValidateExactEntries = unseenOwnedWrites.every( - (write) => write.publishedEntries !== undefined, - ); - const expectedPublishedEntries = canValidateExactEntries - ? unseenOwnedWrites.flatMap((write) => write.publishedEntries ?? []) - : undefined; - const mergedChange = await this.mergePromptReleasedSessionChange( - this.snapshot, - current, - expectedPublishedEntries ? { expectedPublishedEntries } : undefined, - ); - if (this.params.mergePromptReleasedSessionEntries && !mergedChange) { - this.takeoverDetected = true; - throw this.createTakeoverError(); - } - const mergedFingerprint = mergedChange?.snapshot.fingerprint ?? current; - const mergedGeneration = - mergedChange && !sameSessionFileFingerprint(current, mergedFingerprint) - ? recordOwnedSessionFileWrite( - this.sessionFileFenceKey, - mergedFingerprint, - mergedChange.postMergePublishedEntries, - mergedChange.requiresReload, - ) - : ownedWrite.generation; - this.fingerprint = mergedFingerprint; - this.snapshot = mergedChange?.snapshot ?? { fingerprint: current }; - this.setGeneration(mergedGeneration); - return; - } - - const byteIdenticalSnapshot = await readByteIdenticalSessionFenceSnapshot({ - sessionFile: this.params.sessionFile, - previous: this.snapshot, - current, - }); - if (byteIdenticalSnapshot) { - this.snapshot = byteIdenticalSnapshot; - this.fingerprint = byteIdenticalSnapshot.fingerprint; - this.setGeneration( - recordTrustedSessionFileState(this.sessionFileFenceKey, byteIdenticalSnapshot.fingerprint), - ); - return; - } - - const changeKind = await classifySessionFenceChange({ - sessionFile: this.params.sessionFile, - previous: this.snapshot, - current, - }); - if (changeKind?.kind === "transcript-only" && !this.params.mergePromptReleasedSessionEntries) { - this.snapshot = await readSessionFileFenceSnapshot(this.params.sessionFile); - this.fingerprint = this.snapshot.fingerprint; - this.setGeneration( - trustSessionFileState(this.sessionFileFenceKey, current) ?? this.generation, - ); - return; - } - if (changeKind && this.params.mergePromptReleasedSessionEntries) { - const mergedChange = await this.mergePromptReleasedSessionChange(this.snapshot, current); - if (!mergedChange) { - this.takeoverDetected = true; - throw this.createTakeoverError(); - } - this.snapshot = mergedChange.snapshot; - this.fingerprint = mergedChange.snapshot.fingerprint; - this.setGeneration( - recordOwnedSessionFileWrite( - this.sessionFileFenceKey, - mergedChange.snapshot.fingerprint, - mergedChange.publishedEntries, - mergedChange.requiresReload, - ), - ); - return; - } - - this.takeoverDetected = true; - throw this.createTakeoverError(); - } - - async refresh(beforeWrite: SessionFileFingerprint): Promise { - if (this.takeoverDetected) { - return; - } - const snapshot = await readSessionFileFenceSnapshot(this.params.sessionFile); - if (!sameSessionFileFingerprint(beforeWrite, snapshot.fingerprint) && this.active) { - this.fingerprint = snapshot.fingerprint; - this.snapshot = snapshot; - } - } - - async captureOwnedWriteStart(): Promise { - const fingerprint = await readSessionFileFingerprint(this.params.sessionFile); - if (this.snapshot && sameSessionFileFingerprint(this.snapshot.fingerprint, fingerprint)) { - return this.snapshot; - } - return { fingerprint }; - } - - async publishOwnedWrite( - beforeWrite: SessionFileFenceSnapshot, - expectedPublishedEntries?: readonly OwnedSessionTranscriptPublishedEntry[], - ): Promise { - if (this.takeoverDetected) { - return; - } - const current = await readSessionFileFingerprint(this.params.sessionFile); - if (sameSessionFileFingerprint(beforeWrite.fingerprint, current)) { - return; - } - const beforeWriteIsTrusted = - (this.active && sameSessionFileFingerprint(this.fingerprint, beforeWrite.fingerprint)) || - isTrustedSessionFileState(this.sessionFileFenceKey, beforeWrite.fingerprint); - if (!beforeWriteIsTrusted) { - return; - } - const mergedChange = await this.mergePromptReleasedSessionChange( - beforeWrite, - current, - expectedPublishedEntries ? { expectedPublishedEntries } : undefined, - ); - if (this.params.mergePromptReleasedSessionEntries && !mergedChange) { - this.takeoverDetected = true; - throw this.createTakeoverError(); - } - const publishedEntries = mergedChange - ? mergedChange.publishedEntries - : expectedPublishedEntries; - const publishedFingerprint = mergedChange?.snapshot.fingerprint ?? current; - const generation = recordOwnedSessionFileWrite( - this.sessionFileFenceKey, - publishedFingerprint, - publishedEntries, - mergedChange?.requiresReload, - ); - if (this.active) { - this.fingerprint = publishedFingerprint; - this.snapshot = - mergedChange?.snapshot ?? (await readSessionFileFenceSnapshot(this.params.sessionFile)); - this.setGeneration(generation); - } - } - - // Synchronous append paths cannot await withSessionWriteLock. Only publish - // their post-write fingerprint when the pre-write state was already trusted. - publishOwnedWriteSync(write: { - beforeWrite: SessionFileFingerprint; - result: T; - beforeText?: string; - validateAppend?: SessionFileWriteAppendValidator; - }): void { - if (this.takeoverDetected) { - return; - } - const fingerprint = readSessionFileFingerprintSync(this.params.sessionFile); - const beforeWriteIsTrusted = - (this.active && sameSessionFileFingerprint(this.fingerprint, write.beforeWrite)) || - isTrustedSessionFileState(this.sessionFileFenceKey, write.beforeWrite); - if (sameSessionFileFingerprint(write.beforeWrite, fingerprint) || !beforeWriteIsTrusted) { - return; - } - if (write.validateAppend) { - const afterText = readFileSync(this.params.sessionFile, "utf8"); - if ( - write.beforeText === undefined || - !afterText.startsWith(write.beforeText) || - !write.validateAppend(write.result, afterText.slice(write.beforeText.length)) - ) { - return; - } - } - const generation = recordOwnedSessionFileWrite(this.sessionFileFenceKey, fingerprint); - if (this.active) { - this.fingerprint = fingerprint; - this.snapshot = { fingerprint }; - this.setGeneration(generation); - } - } - - async activateForRelease(): Promise { - const fingerprint = await readSessionFileFingerprint(this.params.sessionFile); - const ownedWrite = getOwnedSessionFileWriteHistory(this.sessionFileFenceKey)?.writes.at(-1); - const trustedGeneration = trustSessionFileState(this.sessionFileFenceKey, fingerprint); - this.fingerprint = fingerprint; - this.snapshot = await readSessionFileFenceSnapshot(this.params.sessionFile); - const releasedFenceGeneration = - ownedWrite && sameSessionFileFingerprint(ownedWrite.fingerprint, fingerprint) - ? ownedWrite.generation - : (trustedGeneration ?? this.generation); - this.activate(releasedFenceGeneration); - } -} diff --git a/src/agents/embedded-agent-runner/run/attempt.session-lock.fence.ts b/src/agents/embedded-agent-runner/run/attempt.session-lock.fence.ts deleted file mode 100644 index 8348ced9f1d5..000000000000 --- a/src/agents/embedded-agent-runner/run/attempt.session-lock.fence.ts +++ /dev/null @@ -1,423 +0,0 @@ -import { createHash } from "node:crypto"; -import { type BigIntStats, statSync } from "node:fs"; -import fs from "node:fs/promises"; -import { normalizeStringEntries } from "@openclaw/normalization-core/string-normalization"; -import { parseSqliteSessionFileMarker } from "../../../config/sessions/sqlite-marker.js"; -import type { OwnedSessionTranscriptPublishedEntry } from "../../../config/sessions/transcript-write-context.js"; -import { - classifyPromptReleasedSessionLines, - lineMatchesLinearTranscriptMigration, - type PromptReleasedSessionChange, -} from "./attempt.session-lock.entries.js"; - -export type SessionFileFingerprint = - | { exists: false } - | { - exists: true; - dev: bigint; - ino: bigint; - size: bigint; - mtimeNs: bigint; - ctimeNs: bigint; - }; - -export type TrustedSessionFileSnapshot = Extract; - -const MAX_BENIGN_SESSION_FENCE_ADVANCE_BYTES = 1024 * 1024; -const MAX_BENIGN_SESSION_FENCE_REWRITE_BYTES = 8 * 1024 * 1024; -const MAX_BENIGN_SESSION_FENCE_REWRITE_RESULT_BYTES = - MAX_BENIGN_SESSION_FENCE_REWRITE_BYTES + MAX_BENIGN_SESSION_FENCE_ADVANCE_BYTES; -const MAX_BENIGN_SESSION_FENCE_CONTENT_DIGEST_BYTES = 32 * 1024 * 1024; -const MAX_SAFE_FILE_OFFSET = BigInt(Number.MAX_SAFE_INTEGER); - -export type SessionFileFenceSnapshot = { - fingerprint: SessionFileFingerprint; - bytes?: Buffer; - digest?: string; -}; - -type SessionFileHandle = Awaited>; - -function sessionFileFingerprintFromStat(stat: BigIntStats): SessionFileFingerprint { - return { - exists: true, - dev: stat.dev, - ino: stat.ino, - size: stat.size, - mtimeNs: stat.mtimeNs, - ctimeNs: stat.ctimeNs, - }; -} - -export function sameSessionFileFingerprint( - left: SessionFileFingerprint | undefined, - right: SessionFileFingerprint, -): boolean { - if (!left || left.exists !== right.exists) { - return false; - } - if (!left.exists || !right.exists) { - return true; - } - return ( - left.dev === right.dev && - left.ino === right.ino && - left.size === right.size && - left.mtimeNs === right.mtimeNs && - left.ctimeNs === right.ctimeNs - ); -} - -function sameSessionFileIdentity( - left: SessionFileFingerprint | undefined, - right: SessionFileFingerprint, -): boolean { - return Boolean(left?.exists && right.exists && left.dev === right.dev && left.ino === right.ino); -} - -function sameSessionFileIdentityAndSize( - left: SessionFileFingerprint | undefined, - right: SessionFileFingerprint, -): boolean { - return Boolean( - left?.exists && - right.exists && - left.dev === right.dev && - left.ino === right.ino && - left.size === right.size, - ); -} - -function splitSessionFileLines(text: string): string[] { - return normalizeStringEntries(text.split(/\r?\n/)); -} - -async function readAppendedSessionFileText(params: { - sessionFile: string; - previous: Extract; - current: Extract; - maxBytes?: number; -}): Promise { - if (params.current.size <= params.previous.size || params.previous.size > MAX_SAFE_FILE_OFFSET) { - return undefined; - } - const appendedBytes = params.current.size - params.previous.size; - if ( - (params.maxBytes !== undefined && appendedBytes > BigInt(params.maxBytes)) || - appendedBytes > MAX_SAFE_FILE_OFFSET - ) { - return undefined; - } - const length = Number(appendedBytes); - const buffer = Buffer.alloc(length); - const file = await fs.open(params.sessionFile, "r"); - try { - const { bytesRead } = await file.read(buffer, 0, length, Number(params.previous.size)); - if (bytesRead !== length) { - return undefined; - } - } finally { - await file.close(); - } - return buffer.toString("utf8"); -} - -export async function readSessionFileFenceSnapshot( - sessionFile: string, -): Promise { - const fingerprint = await readSessionFileFingerprint(sessionFile); - if (!fingerprint.exists) { - return { fingerprint }; - } - if (fingerprint.size > BigInt(MAX_BENIGN_SESSION_FENCE_CONTENT_DIGEST_BYTES)) { - return { fingerprint }; - } - let file: SessionFileHandle; - try { - file = await fs.open(sessionFile, "r"); - } catch { - return { fingerprint }; - } - try { - const openedFingerprint = sessionFileFingerprintFromStat(await file.stat({ bigint: true })); - if (!sameSessionFileIdentityAndSize(fingerprint, openedFingerprint)) { - return { fingerprint: await readSessionFileFingerprint(sessionFile) }; - } - - let bytes: Buffer | undefined; - let digest: string | undefined; - if ( - fingerprint.size <= BigInt(MAX_BENIGN_SESSION_FENCE_REWRITE_BYTES) && - fingerprint.size <= MAX_SAFE_FILE_OFFSET - ) { - bytes = await readSessionFileBytes(file, Number(fingerprint.size)); - } else if (fingerprint.size <= BigInt(MAX_BENIGN_SESSION_FENCE_CONTENT_DIGEST_BYTES)) { - digest = await readSessionFileDigest(file, Number(fingerprint.size)); - } - - const postReadFingerprint = sessionFileFingerprintFromStat(await file.stat({ bigint: true })); - const resolvedFingerprint = await readSessionFileFingerprint(sessionFile); - if ( - !sameSessionFileIdentityAndSize(openedFingerprint, postReadFingerprint) || - !sameSessionFileFingerprint(fingerprint, resolvedFingerprint) || - !sameSessionFileIdentityAndSize(postReadFingerprint, resolvedFingerprint) - ) { - return { fingerprint: resolvedFingerprint }; - } - return { - fingerprint: resolvedFingerprint, - ...(bytes !== undefined ? { bytes } : {}), - ...(digest !== undefined ? { digest } : {}), - }; - } catch { - return { fingerprint: await readSessionFileFingerprint(sessionFile) }; - } finally { - await file.close(); - } -} - -async function readSessionFileBytes( - file: SessionFileHandle, - length: number, -): Promise { - const buffer = Buffer.alloc(length); - let offset = 0; - while (offset < length) { - const { bytesRead } = await file.read(buffer, offset, length - offset, offset); - if (bytesRead === 0) { - return undefined; - } - offset += bytesRead; - } - return buffer; -} - -async function readSessionFileDigest( - file: SessionFileHandle, - length: number, -): Promise { - const hash = createHash("sha256"); - const buffer = Buffer.allocUnsafe(Math.min(length, 64 * 1024)); - let offset = 0; - while (offset < length) { - const nextLength = Math.min(buffer.length, length - offset); - const { bytesRead } = await file.read(buffer, 0, nextLength, offset); - if (bytesRead === 0) { - return undefined; - } - hash.update(buffer.subarray(0, bytesRead)); - offset += bytesRead; - } - return hash.digest("hex"); -} - -async function classifySessionFenceAdvance(params: { - sessionFile: string; - previous: SessionFileFenceSnapshot | undefined; - current: SessionFileFingerprint; - allowAnyMessage?: boolean; - expectedPublishedEntries?: readonly OwnedSessionTranscriptPublishedEntry[]; -}): Promise { - if ( - !params.previous?.fingerprint.exists || - !params.current.exists || - !sameSessionFileIdentity(params.previous.fingerprint, params.current) - ) { - return undefined; - } - const text = await readAppendedSessionFileText({ - sessionFile: params.sessionFile, - previous: params.previous.fingerprint, - current: params.current, - // Exact IDs come from the lock owner. Replaying the persisted entry needs - // its full payload, so only unowned benign classification uses the size cap. - ...(params.allowAnyMessage ? {} : { maxBytes: MAX_BENIGN_SESSION_FENCE_ADVANCE_BYTES }), - }); - if (!text?.endsWith("\n")) { - return undefined; - } - const lines = normalizeStringEntries(text.split("\n")); - return classifyPromptReleasedSessionLines(lines, params); -} - -async function classifyOwnedSessionFileInitialization(params: { - sessionFile: string; - previous: SessionFileFenceSnapshot | undefined; - current: SessionFileFingerprint; - expectedPublishedEntries: readonly OwnedSessionTranscriptPublishedEntry[]; -}): Promise { - if ( - !params.current.exists || - (params.previous?.fingerprint.exists === true && params.previous.fingerprint.size > 0n) || - params.current.size > MAX_SAFE_FILE_OFFSET - ) { - return undefined; - } - let text: string; - try { - text = await fs.readFile(params.sessionFile, "utf8"); - } catch { - return undefined; - } - if (!text.endsWith("\n")) { - return undefined; - } - const lines = normalizeStringEntries(text.split("\n")); - const expectedHeader = params.expectedPublishedEntries.find((entry) => entry.kind === "header"); - if (expectedHeader) { - if (lines[0] !== expectedHeader.serialized) { - return undefined; - } - lines.shift(); - } - const remainingExpectedEntries = expectedHeader - ? params.expectedPublishedEntries.filter((entry) => entry !== expectedHeader) - : params.expectedPublishedEntries; - const change = classifyPromptReleasedSessionLines(lines, { - allowAnyMessage: true, - expectedPublishedEntries: remainingExpectedEntries, - }); - if (!change && lines.length > 0) { - return undefined; - } - const resolvedChange = - change ?? - ({ - kind: "transcript-only", - entries: [], - publishedEntries: [], - } satisfies PromptReleasedSessionChange); - return expectedHeader - ? { - ...resolvedChange, - publishedEntries: [expectedHeader, ...resolvedChange.publishedEntries], - } - : resolvedChange; -} - -export async function readByteIdenticalSessionFenceSnapshot(params: { - sessionFile: string; - previous: SessionFileFenceSnapshot | undefined; - current: SessionFileFingerprint; -}): Promise { - const previous = params.previous; - if ( - previous?.fingerprint.exists !== true || - !params.current.exists || - !sameSessionFileIdentityAndSize(previous.fingerprint, params.current) - ) { - return undefined; - } - const verified = await readSessionFileFenceSnapshot(params.sessionFile); - if (!sameSessionFileIdentityAndSize(params.current, verified.fingerprint)) { - return undefined; - } - // Truncate-and-rewrite keeps inode and size while advancing timestamps. - // Install only the stable snapshot whose exact bytes were compared here. - if (previous.bytes !== undefined && verified.bytes !== undefined) { - return previous.bytes.equals(verified.bytes) ? verified : undefined; - } - return previous.digest !== undefined && previous.digest === verified.digest - ? verified - : undefined; -} - -async function classifySessionFenceRewrite(params: { - sessionFile: string; - previous: SessionFileFenceSnapshot | undefined; - current: SessionFileFingerprint; - allowAnyMessage?: boolean; - expectedPublishedEntries?: readonly OwnedSessionTranscriptPublishedEntry[]; -}): Promise { - if ( - !params.previous?.fingerprint.exists || - !params.current.exists || - params.previous.bytes === undefined || - !sameSessionFileIdentity(params.previous.fingerprint, params.current) || - (!params.allowAnyMessage && - params.current.size > BigInt(MAX_BENIGN_SESSION_FENCE_REWRITE_RESULT_BYTES)) || - params.current.size > MAX_SAFE_FILE_OFFSET - ) { - return undefined; - } - let currentText: string; - try { - currentText = await fs.readFile(params.sessionFile, "utf8"); - } catch { - return undefined; - } - if (!currentText.endsWith("\n")) { - return undefined; - } - const previousLines = splitSessionFileLines(params.previous.bytes.toString("utf8")); - const currentLines = splitSessionFileLines(currentText); - if (currentLines.length <= previousLines.length) { - return undefined; - } - let expectedParentId: string | null = null; - for (let index = 0; index < previousLines.length; index += 1) { - const lineMatch = lineMatchesLinearTranscriptMigration({ - previousLine: previousLines[index] ?? "", - currentLine: currentLines[index] ?? "", - expectedParentId, - }); - if (!lineMatch.ok) { - return undefined; - } - expectedParentId = lineMatch.nextPreviousId ?? expectedParentId; - } - const appendedLines = currentLines.slice(previousLines.length); - return classifyPromptReleasedSessionLines(appendedLines, { - ...params, - initialParentId: expectedParentId, - }); -} - -export async function classifySessionFenceChange(params: { - sessionFile: string; - previous: SessionFileFenceSnapshot | undefined; - current: SessionFileFingerprint; - expectedPublishedEntries?: readonly OwnedSessionTranscriptPublishedEntry[]; -}): Promise { - const allowAnyMessage = params.expectedPublishedEntries !== undefined; - return ( - (params.expectedPublishedEntries - ? await classifyOwnedSessionFileInitialization({ - ...params, - expectedPublishedEntries: params.expectedPublishedEntries, - }) - : undefined) ?? - (await classifySessionFenceAdvance({ ...params, allowAnyMessage })) ?? - (await classifySessionFenceRewrite({ ...params, allowAnyMessage })) - ); -} - -export async function readSessionFileFingerprint( - sessionFile: string, -): Promise { - if (parseSqliteSessionFileMarker(sessionFile)) { - return { exists: false }; - } - try { - return sessionFileFingerprintFromStat(await fs.stat(sessionFile, { bigint: true })); - } catch (err) { - if ((err as NodeJS.ErrnoException).code === "ENOENT") { - return { exists: false }; - } - throw err; - } -} - -export function readSessionFileFingerprintSync(sessionFile: string): SessionFileFingerprint { - if (parseSqliteSessionFileMarker(sessionFile)) { - return { exists: false }; - } - try { - return sessionFileFingerprintFromStat(statSync(sessionFile, { bigint: true })); - } catch (err) { - if ((err as NodeJS.ErrnoException).code === "ENOENT") { - return { exists: false }; - } - throw err; - } -} diff --git a/src/agents/embedded-agent-runner/run/attempt.session-lock.owner.ts b/src/agents/embedded-agent-runner/run/attempt.session-lock.owner.ts deleted file mode 100644 index b20888a80964..000000000000 --- a/src/agents/embedded-agent-runner/run/attempt.session-lock.owner.ts +++ /dev/null @@ -1,173 +0,0 @@ -import { clampTimerTimeoutMs } from "@openclaw/normalization-core/number-coercion"; -import { toErrorObject } from "../../../infra/errors.js"; -import { resolveGlobalSingleton } from "../../../shared/global-singleton.js"; -import { resolveEmbeddedSessionFileKey } from "../session-file-key.js"; -import { resetSessionFileFenceStateForTest } from "./attempt.session-lock.fence-controller.js"; - -type SessionFileOwnerWaiter = { - resolve: () => void; - reject: (error: unknown) => void; - timer?: NodeJS.Timeout; - abortListener?: () => void; - signal?: AbortSignal; -}; - -type SessionFileOwnerEntry = { - ownerId: symbol; - waiters: Set; -}; - -type SessionFileOwnerState = { - owners: Map; -}; - -const EMBEDDED_ATTEMPT_SESSION_FILE_OWNER_STATE_KEY = Symbol.for( - "openclaw.embeddedAttemptSessionFileOwnerState", -); - -const sessionFileOwnerState = resolveGlobalSingleton( - EMBEDDED_ATTEMPT_SESSION_FILE_OWNER_STATE_KEY, - (): SessionFileOwnerState => ({ - owners: new Map(), - }), -); - -export type EmbeddedAttemptSessionFileOwner = { - sessionFileKey: string; - release(): void; -}; - -class EmbeddedAttemptSessionFileOwnerTimeoutError extends Error { - constructor(sessionFile: string, timeoutMs: number) { - super(`timed out waiting for embedded session file owner after ${timeoutMs}ms: ${sessionFile}`); - this.name = "EmbeddedAttemptSessionFileOwnerTimeoutError"; - } -} - -function abortReason(signal: AbortSignal): unknown { - return "reason" in signal ? (signal as { reason?: unknown }).reason : undefined; -} - -function abortOwnerWaitReason(signal: AbortSignal): unknown { - return abortReason(signal) ?? new Error("operation aborted", { cause: signal }); -} - -function resolveSessionFileOwnerWaitTimeoutMs(timeoutMs: number | undefined): number | undefined { - if (timeoutMs === undefined) { - return undefined; - } - return clampTimerTimeoutMs(timeoutMs); -} - -function waitForSessionFileOwnerRelease(params: { - sessionFile: string; - entry: SessionFileOwnerEntry; - timeoutMs?: number; - signal?: AbortSignal; -}): Promise { - if (params.signal?.aborted) { - return Promise.reject( - toErrorObject(abortOwnerWaitReason(params.signal), "Non-Error rejection"), - ); - } - return new Promise((resolve, reject) => { - const waiter: SessionFileOwnerWaiter = { - resolve, - reject, - signal: params.signal, - }; - const cleanup = () => { - params.entry.waiters.delete(waiter); - if (waiter.timer) { - clearTimeout(waiter.timer); - } - if (waiter.signal && waiter.abortListener) { - waiter.signal.removeEventListener("abort", waiter.abortListener); - } - }; - waiter.resolve = () => { - cleanup(); - resolve(); - }; - waiter.reject = (error) => { - cleanup(); - reject(toErrorObject(error, "Non-Error rejection")); - }; - const timeoutMs = resolveSessionFileOwnerWaitTimeoutMs(params.timeoutMs); - if (timeoutMs !== undefined) { - waiter.timer = setTimeout(() => { - waiter.reject( - new EmbeddedAttemptSessionFileOwnerTimeoutError(params.sessionFile, timeoutMs), - ); - }, timeoutMs); - waiter.timer.unref?.(); - } - if (params.signal) { - waiter.abortListener = () => { - waiter.reject(abortOwnerWaitReason(params.signal!)); - }; - params.signal.addEventListener("abort", waiter.abortListener, { once: true }); - } - params.entry.waiters.add(waiter); - }); -} - -export async function acquireEmbeddedAttemptSessionFileOwner(params: { - sessionFile: string; - timeoutMs?: number; - signal?: AbortSignal; -}): Promise { - const sessionFileKey = resolveEmbeddedSessionFileKey(params.sessionFile); - const ownerId = Symbol(sessionFileKey); - while (true) { - if (params.signal?.aborted) { - throw abortOwnerWaitReason(params.signal); - } - const entry = sessionFileOwnerState.owners.get(sessionFileKey); - if (!entry) { - sessionFileOwnerState.owners.set(sessionFileKey, { - ownerId, - waiters: new Set(), - }); - return { - sessionFileKey, - release() { - const current = sessionFileOwnerState.owners.get(sessionFileKey); - if (!current || current.ownerId !== ownerId) { - return; - } - sessionFileOwnerState.owners.delete(sessionFileKey); - for (const waiter of current.waiters) { - waiter.resolve(); - } - }, - }; - } - await waitForSessionFileOwnerRelease({ - sessionFile: params.sessionFile, - entry, - timeoutMs: params.timeoutMs, - signal: params.signal, - }); - } -} - -function resetEmbeddedAttemptSessionFileOwnersForTest(): void { - for (const entry of sessionFileOwnerState.owners.values()) { - for (const waiter of entry.waiters) { - waiter.reject( - new Error("embedded attempt session file owners reset", { - cause: "resetEmbeddedAttemptSessionFileOwnersForTest", - }), - ); - } - } - sessionFileOwnerState.owners.clear(); - resetSessionFileFenceStateForTest(); -} - -if (process.env.VITEST || process.env.NODE_ENV === "test") { - (globalThis as Record)[ - Symbol.for("openclaw.embeddedAttemptSessionFileOwnersTestApi") - ] = { resetEmbeddedAttemptSessionFileOwnersForTest }; -} diff --git a/src/agents/embedded-agent-runner/run/attempt.session-lock.prompt.ts b/src/agents/embedded-agent-runner/run/attempt.session-lock.prompt.ts deleted file mode 100644 index 92e45cb5f8fc..000000000000 --- a/src/agents/embedded-agent-runner/run/attempt.session-lock.prompt.ts +++ /dev/null @@ -1,64 +0,0 @@ -import { - type OwnedSessionTranscriptCacheSnapshot, - type OwnedSessionTranscriptWriteOptions, - withOwnedSessionTranscriptWrites, -} from "../../../config/sessions/transcript-write-context.js"; - -type SessionWithAgentPrompt = { - agent?: { - streamFn?: PromptReleaseStreamFn; - }; -}; - -type PromptReleaseStreamFn = ((...args: unknown[]) => unknown) & { - __openclawSessionLockPromptReleaseInstalled?: boolean; -}; - -export function installPromptSubmissionLockRelease(params: { - session: unknown; - waitForSessionEvents: (session: unknown) => Promise; - releaseForPrompt: () => Promise; - reacquireAfterPrompt: () => Promise; - sessionFile?: string; - sessionKey?: string; - withSessionWriteLock?: ( - run: () => Promise | T, - options?: OwnedSessionTranscriptWriteOptions, - ) => Promise; - canAdvanceSessionEntryCache?: (snapshot: OwnedSessionTranscriptCacheSnapshot) => boolean; - publishSessionFileSnapshot?: (snapshot: OwnedSessionTranscriptCacheSnapshot) => boolean; -}): void { - const agent = (params.session as SessionWithAgentPrompt).agent; - if (typeof agent?.streamFn !== "function") { - return; - } - const currentStreamFn = agent.streamFn; - if (currentStreamFn["__openclawSessionLockPromptReleaseInstalled"] === true) { - return; - } - const originalStreamFn = currentStreamFn.bind(agent); - const wrappedStreamFn: PromptReleaseStreamFn = async (...args: unknown[]) => { - await params.waitForSessionEvents(params.session); - await params.releaseForPrompt(); - try { - if (params.sessionFile && params.withSessionWriteLock) { - return await withOwnedSessionTranscriptWrites( - { - sessionFile: params.sessionFile, - sessionKey: params.sessionKey, - withSessionWriteLock: params.withSessionWriteLock, - canAdvanceSessionEntryCache: params.canAdvanceSessionEntryCache, - publishSessionFileSnapshot: params.publishSessionFileSnapshot, - }, - async () => await originalStreamFn(...args), - ); - } - return await originalStreamFn(...args); - } finally { - await params.waitForSessionEvents(params.session); - await params.reacquireAfterPrompt(); - } - }; - wrappedStreamFn["__openclawSessionLockPromptReleaseInstalled"] = true; - agent.streamFn = wrappedStreamFn; -} diff --git a/src/agents/embedded-agent-runner/run/attempt.session-lock.test-support.ts b/src/agents/embedded-agent-runner/run/attempt.session-lock.test-support.ts deleted file mode 100644 index 80742a824486..000000000000 --- a/src/agents/embedded-agent-runner/run/attempt.session-lock.test-support.ts +++ /dev/null @@ -1,15 +0,0 @@ -import "./attempt.session-lock.js"; - -type EmbeddedAttemptSessionFileOwnersTestApi = { - resetEmbeddedAttemptSessionFileOwnersForTest(): void; -}; - -function getTestApi(): EmbeddedAttemptSessionFileOwnersTestApi { - return (globalThis as Record)[ - Symbol.for("openclaw.embeddedAttemptSessionFileOwnersTestApi") - ] as EmbeddedAttemptSessionFileOwnersTestApi; -} - -export function resetEmbeddedAttemptSessionFileOwnersForTest(): void { - getTestApi().resetEmbeddedAttemptSessionFileOwnersForTest(); -} diff --git a/src/agents/embedded-agent-runner/run/attempt.session-lock.test.ts b/src/agents/embedded-agent-runner/run/attempt.session-lock.test.ts index bc7affd1cbb8..b38fd53cd9e7 100644 --- a/src/agents/embedded-agent-runner/run/attempt.session-lock.test.ts +++ b/src/agents/embedded-agent-runner/run/attempt.session-lock.test.ts @@ -1,3553 +1,955 @@ -// Coverage for embedded attempt session-file ownership and write locks. -import { appendFileSync } from "node:fs"; -import fs from "node:fs/promises"; -import os from "node:os"; -import path from "node:path"; -import { MAX_TIMER_TIMEOUT_MS } from "@openclaw/normalization-core/number-coercion"; -import { afterEach, describe, expect, it, vi } from "vitest"; -import { useAutoCleanupTempDirTracker } from "../../../../test/helpers/temp-dir.js"; -import { resolveSessionTranscriptPathInDir } from "../../../config/sessions/paths.js"; +import { describe, expect, it, vi } from "vitest"; +import { SessionWriteLockStaleError } from "../../session-write-lock-error.js"; import { - loadTranscriptEvents, - upsertSessionEntry, -} from "../../../config/sessions/session-accessor.js"; -import { formatSqliteSessionFileMarker } from "../../../config/sessions/sqlite-marker.js"; -import { appendSessionTranscriptMessage } from "../../../config/sessions/transcript-append.test-support.js"; -import { - runWithOwnedSessionTranscriptWriteLock, - withOwnedSessionTranscriptWrites, -} from "../../../config/sessions/transcript-write-context.js"; -import { appendExactAssistantMessageToSessionTranscript } from "../../../config/sessions/transcript.js"; -import { createDeferred } from "../../../shared/deferred.js"; -import { OPENCLAW_TRANSCRIPT_ARTIFACT_API } from "../../../shared/transcript-only-openclaw-assistant.js"; -import { normalizeSessionDeliveryState } from "../../../utils/delivery-context.shared.js"; -import { guardSessionManager } from "../../session-tool-result-guard-wrapper.js"; -import { - SessionWriteLockStaleError, - SessionWriteLockTimeoutError, -} from "../../session-write-lock-error.js"; -import { acquireSessionWriteLock } from "../../session-write-lock.js"; -import { resetSessionWriteLockStateForTest } from "../../session-write-lock.test-support.js"; -import { SessionManager } from "../../sessions/session-manager.js"; -import { - acquireEmbeddedAttemptSessionFileOwner, createEmbeddedAttemptSessionLockController, EmbeddedAttemptSessionTakeoverError, installPromptSubmissionLockRelease, } from "./attempt.session-lock.js"; -import { resetEmbeddedAttemptSessionFileOwnersForTest } from "./attempt.session-lock.test-support.js"; -const lockOptions = { - sessionFile: "/tmp/session.jsonl", - timeoutMs: 60_000, - staleMs: 1_800_000, - maxHoldMs: 300_000, -}; - -function appendTestAssistantMessage( - manager: SessionManager, - text: string, - timestamp = 2, - model = "sonnet-4.6", -) { - return manager.appendMessage({ - role: "assistant", - content: [{ type: "text", text }], - api: "messages", - provider: model === "session-lock-test" ? "openclaw" : "anthropic", - model, - usage: { - input: 0, - output: 0, - cacheRead: 0, - cacheWrite: 0, - totalTokens: 0, - cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 }, - }, - stopReason: "stop", - timestamp, - }); -} - -async function createMockSessionLockController( - sessionFile: string, - overrides: Partial[0]> = {}, -) { - const release = vi.fn(async () => {}); - const acquire = vi.fn(async () => ({ release })); - const controller = await createEmbeddedAttemptSessionLockController({ - acquireSessionWriteLock: acquire, - lockOptions: { ...lockOptions, sessionFile }, - ...overrides, - }); - return { controller, acquire, release }; -} - -const tempDirs: string[] = []; -const autoTempDirs = useAutoCleanupTempDirTracker(afterEach); -afterEach(async () => { - vi.restoreAllMocks(); - resetEmbeddedAttemptSessionFileOwnersForTest(); - resetSessionWriteLockStateForTest(); - for (const dir of tempDirs.splice(0)) { - await fs.rm(dir, { recursive: true, force: true }); - } -}); - -async function createTempSessionFile(): Promise { - // Use a real file so owner normalization can exercise realpath/symlink - // behavior. - const dir = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-attempt-session-lock-")); - tempDirs.push(dir); - const sessionFile = path.join(dir, "session.jsonl"); - await fs.writeFile(sessionFile, '{"type":"session"}\n', "utf8"); - return sessionFile; -} - -async function waitUntil(predicate: () => boolean, message: string): Promise { - const deadline = Date.now() + 1_000; - while (!predicate()) { - if (Date.now() >= deadline) { - throw new Error(message); - } - await new Promise((resolve) => { - setTimeout(resolve, 1); - }); - } -} - -function cloneBigIntStatWith( - stat: Awaited>, - fields: Partial>>, -): Awaited> { - return Object.assign(Object.create(Object.getPrototypeOf(stat)), stat, fields) as Awaited< - ReturnType - >; -} - -describe("embedded attempt session lock lifecycle", () => { - it.each([ - { - name: "recognizes an unchanged session file trusted by the previous prompt release", - trusted: true, - }, - { - name: "does not trust a session file changed after the previous prompt release", - append: "external", - trusted: false, - }, - { name: "publishes a known owned append for the next attempt", append: "owned", trusted: true }, - ])("$name", async ({ append, trusted }) => { - const sessionFile = await createTempSessionFile(); - const options = { ...lockOptions, sessionFile }; - const first = await createEmbeddedAttemptSessionLockController({ +describe("createEmbeddedAttemptSessionLockController", () => { + it("reloads SQLite transcript state after prompt-time writers finish", async () => { + const reloadPromptReleasedSessionFile = vi.fn(async () => undefined); + const acquireSessionWriteLock = vi.fn(async () => ({ release: async () => undefined })); + const controller = await createEmbeddedAttemptSessionLockController({ acquireSessionWriteLock, - lockOptions: options, + lockOptions: { sessionFile: "agent:main:main" }, + reloadPromptReleasedSessionFile, }); - expect(await first.readTrustedCurrentSessionFileSnapshot()).toBeUndefined(); - if (append === "owned") { - await fs.appendFile(sessionFile, '{"type":"message","id":"owned"}\n', "utf8"); - first.refreshAfterOwnedSessionWrite(); - } - await first.releaseForPrompt(); - await first.dispose(); - if (append === "external") { - await fs.appendFile(sessionFile, '{"type":"message","id":"external"}\n', "utf8"); - } - const second = await createEmbeddedAttemptSessionLockController({ - acquireSessionWriteLock, - lockOptions: options, + + await controller.reacquireAfterPrompt(); + + expect(reloadPromptReleasedSessionFile).toHaveBeenCalledOnce(); + expect(acquireSessionWriteLock).toHaveBeenCalledWith({ + sessionFile: "agent:main:main", + targetKind: "session-key", }); - const snapshot = await second.readTrustedCurrentSessionFileSnapshot(); - if (trusted) { - expect(snapshot).toBeDefined(); - } else { - expect(snapshot).toBeUndefined(); - } - await second.dispose(); }); - it("serializes embedded attempts that share a session file owner", async () => { - const sessionFile = await createTempSessionFile(); - const firstOwner = await acquireEmbeddedAttemptSessionFileOwner({ sessionFile }); - - let secondOwnerAcquired = false; - const secondOwnerPromise = acquireEmbeddedAttemptSessionFileOwner({ sessionFile }).then( - (owner) => { - secondOwnerAcquired = true; - return owner; - }, - ); - - await Promise.resolve(); - expect(secondOwnerAcquired).toBe(false); - - firstOwner.release(); - const secondOwner = await secondOwnerPromise; - expect(secondOwnerAcquired).toBe(true); - secondOwner.release(); - }); - - it("uses the same embedded attempt owner for symlinked session file paths", async () => { - const sessionFile = await createTempSessionFile(); - const symlinkFile = path.join(path.dirname(sessionFile), "session-link.jsonl"); - await fs.symlink(sessionFile, symlinkFile); - const firstOwner = await acquireEmbeddedAttemptSessionFileOwner({ sessionFile }); - - let symlinkOwnerAcquired = false; - const symlinkOwnerPromise = acquireEmbeddedAttemptSessionFileOwner({ - sessionFile: symlinkFile, - }).then((owner) => { - symlinkOwnerAcquired = true; - return owner; + it("serializes the complete SQLite write callbacks", async () => { + let releaseFirst!: () => void; + const firstBlocked = new Promise((resolve) => { + releaseFirst = resolve; }); - - await Promise.resolve(); - expect(symlinkOwnerAcquired).toBe(false); - - firstOwner.release(); - const symlinkOwner = await symlinkOwnerPromise; - expect(symlinkOwnerAcquired).toBe(true); - symlinkOwner.release(); - }); - - it("clamps oversized session file owner wait timeouts before arming timers", async () => { - const sessionFile = await createTempSessionFile(); - const setTimeoutSpy = vi.spyOn(globalThis, "setTimeout"); - const firstOwner = await acquireEmbeddedAttemptSessionFileOwner({ sessionFile }); - const secondOwnerPromise = acquireEmbeddedAttemptSessionFileOwner({ - sessionFile, - timeoutMs: MAX_TIMER_TIMEOUT_MS + 1, - }); - - await Promise.resolve(); - - expect(setTimeoutSpy).toHaveBeenCalledWith(expect.any(Function), MAX_TIMER_TIMEOUT_MS); - - firstOwner.release(); - const secondOwner = await secondOwnerPromise; - secondOwner.release(); - }); - - it("releases the coarse attempt lock before prompt submission and reacquires for cleanup", async () => { - const releases: string[] = []; - const acquireSessionWriteLockLocal28 = vi - .fn() - .mockResolvedValueOnce({ release: vi.fn(async () => releases.push("prep")) }) - .mockResolvedValueOnce({ release: vi.fn(async () => releases.push("cleanup")) }); - - const controller = await createEmbeddedAttemptSessionLockController({ - acquireSessionWriteLock: acquireSessionWriteLockLocal28, - lockOptions, - }); - - await controller.releaseForPrompt(); - const cleanupLock = await controller.acquireForCleanup(); - await cleanupLock.release(); - - expect(acquireSessionWriteLockLocal28).toHaveBeenCalledTimes(2); - expect(acquireSessionWriteLockLocal28).toHaveBeenNthCalledWith(1, lockOptions); - expect(acquireSessionWriteLockLocal28).toHaveBeenNthCalledWith(2, lockOptions); - expect(releases).toEqual(["prep", "cleanup"]); - }); - - it("releases the eagerly-held attempt lock on dispose when cleanup is skipped (#86014)", async () => { - // Exceptions after prompt submission can skip cleanup acquisition; dispose - // still owns the original eager lock. - const releases: string[] = []; - const acquireSessionWriteLockLocal27 = vi - .fn() - .mockResolvedValueOnce({ release: vi.fn(async () => releases.push("held")) }); - - const controller = await createEmbeddedAttemptSessionLockController({ - acquireSessionWriteLock: acquireSessionWriteLockLocal27, - lockOptions, - }); - - // An exception on the post-prompt path skips acquireForCleanup; the run's outer finally - // must still release the eagerly-held lock or it leaks to the live process. - await controller.dispose(); - await controller.dispose(); // idempotent - - expect(acquireSessionWriteLockLocal27).toHaveBeenCalledTimes(1); - expect(releases).toEqual(["held"]); - }); - - it("releases the eagerly-held lock when the fence read throws during prompt release", async () => { - // A filesystem error can occur after the controller clears its in-memory - // lock reference; the underlying lease still must be released. - const release = vi.fn(async () => {}); - const acquireSessionWriteLockLocalFad845 = vi.fn(async () => ({ release })); - const controller = await createEmbeddedAttemptSessionLockController({ - acquireSessionWriteLock: acquireSessionWriteLockLocalFad845, - lockOptions, - }); - - // Simulate a transient, non-ENOENT filesystem error (e.g. EIO/EMFILE) while the - // prompt-release path reads the session-file fence. This fires AFTER the controller - // has cleared its in-memory `heldLock` reference but BEFORE the underlying file lock - // is released, which is the window that orphans the lock. - const statError = Object.assign(new Error("simulated I/O failure"), { code: "EIO" }); - const statSpy = vi.spyOn(fs, "stat").mockRejectedValueOnce(statError); - - try { - await expect(controller.releaseForPrompt()).rejects.toThrow(); - - // The underlying file lock must still be released so later turns do not wait for - // the full maxHoldMs watchdog before the stale lease is reclaimed. - expect(release).toHaveBeenCalledTimes(1); - } finally { - statSpy.mockRestore(); - } - }); - - it("releaseHeldLockForAbort and dispose are idempotent in succession (#86816)", async () => { - const release = vi.fn(async () => {}); - const acquireSessionWriteLockLocal26 = vi.fn(async () => ({ release })); - const controller = await createEmbeddedAttemptSessionLockController({ - acquireSessionWriteLock: acquireSessionWriteLockLocal26, - lockOptions, - }); - - await controller.releaseHeldLockForAbort(); - await controller.releaseHeldLockForAbort(); - await controller.dispose(); - await controller.dispose(); - - expect(acquireSessionWriteLockLocal26).toHaveBeenCalledTimes(1); - expect(release).toHaveBeenCalledTimes(1); - }); - - it.each([ - { - name: "waits for pending timeout abort release before dispose resolves (#86816)", - complete: ( - controller: Awaited>, - ) => controller.dispose(), - }, - { - name: "waits for pending timeout abort release before prompt release resolves (#86816)", - complete: ( - controller: Awaited>, - ) => controller.releaseForPrompt(), - }, - ])("$name", async ({ complete }) => { - // createDeferred defaults to void; an explicit type argument fails the lint ratchet. - const started = createDeferred(); - const finish = createDeferred(); - const release = vi.fn(async () => { - started.resolve(); - await finish.promise; - }); - const controller = await createEmbeddedAttemptSessionLockController({ - acquireSessionWriteLock: vi.fn(async () => ({ release })), - lockOptions, - }); - const abortRelease = controller.releaseHeldLockForAbort(); - await started.promise; - let settled = false; - const completion = complete(controller).then(() => { - settled = true; - }); - await Promise.resolve(); - expect(settled).toBe(false); - finish.resolve(); - await Promise.all([abortRelease, completion]); - expect(release).toHaveBeenCalledOnce(); - }); - - it("waits for pending timeout abort release before prompt reacquire (#86816)", async () => { const events: string[] = []; - const heldReleaseStarted = createDeferred(); - const heldReleaseCanFinish = createDeferred(); - const acquireSessionWriteLockLocal23 = vi - .fn() - .mockResolvedValueOnce({ - release: vi.fn(async () => { - events.push("held-release-start"); - heldReleaseStarted.resolve(); - await heldReleaseCanFinish.promise; - events.push("held-release-end"); - }), - }) - .mockResolvedValueOnce({ - release: vi.fn(async () => { - events.push("reacquired-release"); - }), - }); const controller = await createEmbeddedAttemptSessionLockController({ - acquireSessionWriteLock: acquireSessionWriteLockLocal23, - lockOptions, + acquireSessionWriteLock: vi.fn(async () => ({ release: async () => undefined })), + lockOptions: { sessionFile: "agent:main:main" }, }); - const abortRelease = controller.releaseHeldLockForAbort(); - await heldReleaseStarted.promise; - const reacquire = controller.reacquireAfterPrompt(); - await Promise.resolve(); + const first = controller.withSessionWriteLock(async () => { + events.push("first:start"); + await firstBlocked; + events.push("first:end"); + }); + const second = controller.withSessionWriteLock(() => { + events.push("second"); + }); - expect(acquireSessionWriteLockLocal23).toHaveBeenCalledTimes(1); + await vi.waitFor(() => expect(events).toEqual(["first:start"])); + releaseFirst(); + await Promise.all([first, second]); - heldReleaseCanFinish.resolve(); - await abortRelease; - await reacquire; - await controller.dispose(); - - expect(acquireSessionWriteLockLocal23).toHaveBeenCalledTimes(2); - expect(events).toEqual(["held-release-start", "held-release-end", "reacquired-release"]); + expect(events).toEqual(["first:start", "first:end", "second"]); }); - it("waits for active retained-lock writes before abort release (#86816)", async () => { - const release = vi.fn(async () => {}); - const acquireSessionWriteLockLocal22 = vi.fn(async () => ({ release })); + it("fences transcript writes after the SQLite session lease is lost", async () => { + const leaseError = new SessionWriteLockStaleError({ + lockPath: "sqlite:session-write:agent:main:main", + owner: "replacement", + staleReasons: ["lease-lost"], + }); const controller = await createEmbeddedAttemptSessionLockController({ - acquireSessionWriteLock: acquireSessionWriteLockLocal22, - lockOptions, - }); - const writeCanFinish = createDeferred(); - const writeStarted = createDeferred(); - - const activeWrite = controller.withSessionWriteLock(async () => { - writeStarted.resolve(); - await writeCanFinish.promise; - }); - await writeStarted.promise; - - let abortReleaseSettled = false; - const abortRelease = controller.releaseHeldLockForAbort().then(() => { - abortReleaseSettled = true; - }); - await Promise.resolve(); - - expect(release).not.toHaveBeenCalled(); - expect(abortReleaseSettled).toBe(false); - - writeCanFinish.resolve(); - await activeWrite; - await abortRelease; - - expect(acquireSessionWriteLockLocal22).toHaveBeenCalledTimes(1); - expect(release).toHaveBeenCalledTimes(1); - }); - - it("marks retained-lock use before the retained acquisition resolves (#86816)", async () => { - const release = vi.fn(async () => {}); - const acquireSessionWriteLockLocal21 = vi.fn(async () => ({ release })); - const controller = await createEmbeddedAttemptSessionLockController({ - acquireSessionWriteLock: acquireSessionWriteLockLocal21, - lockOptions, - }); - const writeCanFinish = createDeferred(); - - const activeWrite = controller.withSessionWriteLock(async () => { - await writeCanFinish.promise; - }); - let abortReleaseSettled = false; - const abortRelease = controller.releaseHeldLockForAbort().then(() => { - abortReleaseSettled = true; - }); - await Promise.resolve(); - - expect(release).not.toHaveBeenCalled(); - expect(abortReleaseSettled).toBe(false); - - writeCanFinish.resolve(); - await activeWrite; - await abortRelease; - - expect(release).toHaveBeenCalledTimes(1); - }); - - it("waits for active retained-lock writes before cleanup takes the lock (#86816)", async () => { - const release = vi.fn(async () => {}); - const acquireSessionWriteLockLocal20 = vi.fn(async () => ({ release })); - const controller = await createEmbeddedAttemptSessionLockController({ - acquireSessionWriteLock: acquireSessionWriteLockLocal20, - lockOptions, - }); - const writeCanFinish = createDeferred(); - const writeStarted = createDeferred(); - - const activeWrite = controller.withSessionWriteLock(async () => { - writeStarted.resolve(); - await writeCanFinish.promise; - }); - await writeStarted.promise; - - let cleanupAcquired = false; - const cleanupLockPromise = controller.acquireForCleanup().then((lock) => { - cleanupAcquired = true; - return lock; - }); - await Promise.resolve(); - - expect(cleanupAcquired).toBe(false); - expect(release).not.toHaveBeenCalled(); - - writeCanFinish.resolve(); - await activeWrite; - const cleanupLock = await cleanupLockPromise; - await cleanupLock.release(); - - expect(release).toHaveBeenCalledTimes(1); - }); - - it("reacquires cleanup lock when timeout abort already released the held lock (#86816)", async () => { - const events: string[] = []; - const heldReleaseStarted = createDeferred(); - const heldReleaseCanFinish = createDeferred(); - const acquireSessionWriteLockLocal19 = vi - .fn() - .mockResolvedValueOnce({ - release: vi.fn(async () => { - events.push("held-release-start"); - heldReleaseStarted.resolve(); - await heldReleaseCanFinish.promise; - events.push("held-release-end"); - }), - }) - .mockResolvedValueOnce({ - release: vi.fn(async () => { - events.push("cleanup-release"); - }), - }); - const controller = await createEmbeddedAttemptSessionLockController({ - acquireSessionWriteLock: acquireSessionWriteLockLocal19, - lockOptions, + acquireSessionWriteLock: vi.fn(async () => ({ + assertOwned: () => { + throw leaseError; + }, + release: async () => undefined, + })), + lockOptions: { sessionFile: "agent:main:main" }, }); - const abortRelease = controller.releaseHeldLockForAbort(); - await heldReleaseStarted.promise; - const cleanupLockPromise = controller.acquireForCleanup(); - await Promise.resolve(); - - expect(acquireSessionWriteLockLocal19).toHaveBeenCalledTimes(1); - - heldReleaseCanFinish.resolve(); - await abortRelease; - const cleanupLock = await cleanupLockPromise; - await cleanupLock.release(); - - expect(acquireSessionWriteLockLocal19).toHaveBeenCalledTimes(2); - expect(events).toEqual(["held-release-start", "held-release-end", "cleanup-release"]); - }); - - it("keeps cleanup waiting while timeout abort owns the held-lock drain (#86816)", async () => { - const events: string[] = []; - const writeCanFinish = createDeferred(); - const writeStarted = createDeferred(); - const heldReleaseStarted = createDeferred(); - const heldReleaseCanFinish = createDeferred(); - const acquireSessionWriteLockLocal18 = vi - .fn() - .mockResolvedValueOnce({ - release: vi.fn(async () => { - events.push("held-release-start"); - heldReleaseStarted.resolve(); - await heldReleaseCanFinish.promise; - events.push("held-release-end"); - }), - }) - .mockResolvedValueOnce({ - release: vi.fn(async () => { - events.push("cleanup-release"); - }), - }); - const controller = await createEmbeddedAttemptSessionLockController({ - acquireSessionWriteLock: acquireSessionWriteLockLocal18, - lockOptions, - }); - - const activeWrite = controller.withSessionWriteLock(async () => { - writeStarted.resolve(); - await writeCanFinish.promise; - }); - await writeStarted.promise; - const abortRelease = controller.releaseHeldLockForAbort(); - const cleanupLockPromise = controller.acquireForCleanup(); - - writeCanFinish.resolve(); - await activeWrite; - await heldReleaseStarted.promise; - await Promise.resolve(); - - expect(acquireSessionWriteLockLocal18).toHaveBeenCalledTimes(1); - expect(events).toEqual(["held-release-start"]); - - heldReleaseCanFinish.resolve(); - await abortRelease; - const cleanupLock = await cleanupLockPromise; - await cleanupLock.release(); - - expect(acquireSessionWriteLockLocal18).toHaveBeenCalledTimes(2); - expect(events).toEqual(["held-release-start", "held-release-end", "cleanup-release"]); - }); - - it("dispose does not double-release a lock already handed to cleanup", async () => { - const releases: string[] = []; - const acquireSessionWriteLockLocal17 = vi - .fn() - .mockResolvedValueOnce({ release: vi.fn(async () => releases.push("held")) }); - - const controller = await createEmbeddedAttemptSessionLockController({ - acquireSessionWriteLock: acquireSessionWriteLockLocal17, - lockOptions, - }); - - const cleanupLock = await controller.acquireForCleanup(); - await cleanupLock.release(); - await controller.dispose(); - - expect(acquireSessionWriteLockLocal17).toHaveBeenCalledTimes(1); - expect(releases).toEqual(["held"]); - }); - - it("defensively releases the coarse attempt lock on sessions_yield abort cleanup", async () => { - const events: string[] = []; - const acquireSessionWriteLockLocal16 = vi - .fn() - .mockResolvedValueOnce({ release: vi.fn(async () => events.push("prep-release")) }) - .mockResolvedValueOnce({ release: vi.fn(async () => events.push("cleanup-release")) }); - - const controller = await createEmbeddedAttemptSessionLockController({ - acquireSessionWriteLock: acquireSessionWriteLockLocal16, - lockOptions, - }); - - await controller.releaseHeldLockForAbort(); - await controller.withSessionWriteLock(async () => { - events.push("yield-cleanup-write"); - }); - - expect(acquireSessionWriteLockLocal16).toHaveBeenCalledTimes(2); - expect(events).toEqual(["prep-release", "yield-cleanup-write", "cleanup-release"]); - }); - - it("keeps the session fence active after releasing for sessions_yield abort cleanup", async () => { - const sessionFile = await createTempSessionFile(); - const { - controller, - release, - acquire: acquireSessionWriteLockLocal15, - } = await createMockSessionLockController(sessionFile); - - await controller.releaseHeldLockForAbort(); - await fs.appendFile(sessionFile, '{"type":"message","id":"abort-takeover"}\n', "utf8"); - - await expect(controller.withSessionWriteLock(() => "yield-cleanup")).rejects.toBeInstanceOf( - EmbeddedAttemptSessionTakeoverError, - ); - expect(controller.hasSessionTakeover()).toBe(true); - expect(acquireSessionWriteLockLocal15).toHaveBeenCalledTimes(2); - expect(release).toHaveBeenCalledTimes(2); - }); - - it("runs post-prompt transcript writes under a short reacquired lock", async () => { - const events: string[] = []; - const acquireSessionWriteLockLocal14 = vi - .fn() - .mockResolvedValueOnce({ release: vi.fn(async () => events.push("prep-release")) }) - .mockResolvedValueOnce({ release: vi.fn(async () => events.push("post-release")) }); - - const controller = await createEmbeddedAttemptSessionLockController({ - acquireSessionWriteLock: acquireSessionWriteLockLocal14, - lockOptions, - }); - - await controller.releaseForPrompt(); - await controller.withSessionWriteLock(async () => { - events.push("post-write"); - }); - - expect(acquireSessionWriteLockLocal14).toHaveBeenCalledTimes(2); - expect(events).toEqual(["prep-release", "post-write", "post-release"]); - }); - - it("reuses its active post-prompt lock for nested session writes", async () => { - const events: string[] = []; - const sessionFile = await createTempSessionFile(); - const acquireSessionWriteLockLocal13 = vi - .fn() - .mockResolvedValueOnce({ release: vi.fn(async () => events.push("prep-release")) }) - .mockResolvedValueOnce({ release: vi.fn(async () => events.push("post-release")) }) - .mockRejectedValueOnce( - new SessionWriteLockTimeoutError({ - timeoutMs: lockOptions.timeoutMs, - owner: "pid=789", - lockPath: `${sessionFile}.lock`, - }), - ); - - const controller = await createEmbeddedAttemptSessionLockController({ - acquireSessionWriteLock: acquireSessionWriteLockLocal13, - lockOptions: { ...lockOptions, sessionFile }, - }); - - await controller.releaseForPrompt(); - await controller.withSessionWriteLock(async () => { - events.push("outer-start"); - await fs.appendFile(sessionFile, '{"type":"message","id":"local"}\n', "utf8"); - await controller.withSessionWriteLock(async () => { - events.push("inner-write"); - }); - events.push("outer-end"); - }); - - expect(acquireSessionWriteLockLocal13).toHaveBeenCalledTimes(2); - expect(events).toEqual([ - "prep-release", - "outer-start", - "inner-write", - "outer-end", - "post-release", - ]); - }); - - it("rejects post-prompt writes when another owner advances the session file", async () => { - const sessionFile = await createTempSessionFile(); - const { controller, release } = await createMockSessionLockController(sessionFile); - - await controller.releaseForPrompt(); - await fs.appendFile(sessionFile, '{"type":"message","id":"takeover"}\n', "utf8"); - - await expect(controller.withSessionWriteLock(() => "late-write")).rejects.toBeInstanceOf( - EmbeddedAttemptSessionTakeoverError, - ); - expect(controller.hasSessionTakeover()).toBe(true); - - const cleanupLock = await controller.acquireForCleanup(); - await cleanupLock.release(); - - expect(release).toHaveBeenCalledTimes(2); - }); - - it("allows globally resolved session metadata while the prompt lock is released", async () => { - const sessionFile = await createTempSessionFile(); - const release = vi.fn(async () => {}); - const acquireSessionWriteLockLocal = vi.fn(async () => ({ release })); - const mergePromptReleasedSessionEntries = vi.fn(); - const controller = await createEmbeddedAttemptSessionLockController({ - acquireSessionWriteLock: acquireSessionWriteLockLocal, - lockOptions: { ...lockOptions, sessionFile }, - mergePromptReleasedSessionEntries, - }); - - await controller.releaseForPrompt(); - await fs.appendFile( - sessionFile, - [ - JSON.stringify({ - type: "custom", - customType: "model-snapshot", - id: "model-snapshot", - parentId: null, - timestamp: new Date().toISOString(), - data: { provider: "openai", modelId: "gpt-5.1" }, - }), - JSON.stringify({ - type: "label", - id: "label-change", - parentId: "model-snapshot", - timestamp: new Date().toISOString(), - targetId: "model-change", - label: "runtime setting", - }), - JSON.stringify({ - type: "session_info", - id: "session-info", - parentId: "label-change", - timestamp: new Date().toISOString(), - name: "session title", - }), - JSON.stringify({ - type: "session_info", - id: "session-info-clear", - parentId: "session-info", - timestamp: new Date().toISOString(), - }), - ].join("\n") + "\n", - "utf8", - ); - - await expect(controller.withSessionWriteLock(() => "late-write")).resolves.toBe("late-write"); - expect(controller.hasSessionTakeover()).toBe(false); - expect(mergePromptReleasedSessionEntries).toHaveBeenCalledWith([ - expect.objectContaining({ type: "custom", id: "model-snapshot" }), - expect.objectContaining({ type: "label", id: "label-change" }), - expect.objectContaining({ type: "session_info", id: "session-info" }), - expect.objectContaining({ type: "session_info", id: "session-info-clear" }), - ]); - - const cleanupLock = await controller.acquireForCleanup(); - await cleanupLock.release(); - }); - - it("rejects global metadata when the active session manager cannot resync", async () => { - const sessionFile = await createTempSessionFile(); - const release = vi.fn(async () => {}); - const controller = await createEmbeddedAttemptSessionLockController({ - acquireSessionWriteLock: vi.fn(async () => ({ release })), - lockOptions: { ...lockOptions, sessionFile }, - }); - - await controller.releaseForPrompt(); - await fs.appendFile( - sessionFile, - `${JSON.stringify({ - type: "session_info", - id: "session-info", - parentId: null, - timestamp: new Date().toISOString(), - name: "session title", - })}\n`, - "utf8", - ); - - await expect(controller.withSessionWriteLock(() => "late-write")).rejects.toBeInstanceOf( - EmbeddedAttemptSessionTakeoverError, - ); + await expect(controller.withSessionWriteLock(() => undefined)).rejects.toBe(leaseError); expect(controller.hasSessionTakeover()).toBe(true); }); - it("preserves an unflushed first user turn while merging global metadata", async () => { - const dir = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-attempt-session-first-turn-")); - tempDirs.push(dir); - const sessionFile = path.join(dir, "session.jsonl"); - await fs.writeFile( - sessionFile, - `${JSON.stringify({ - type: "session", - version: 3, - id: "first-turn", - timestamp: new Date().toISOString(), - cwd: dir, - })}\n`, - "utf8", - ); - const staleManager = SessionManager.open(sessionFile, dir, dir); - staleManager.appendMessage({ - role: "user", - content: "question", - timestamp: 1, + it("blocks prompt submission after the SQLite session lease is lost", async () => { + const leaseError = new SessionWriteLockStaleError({ + lockPath: "sqlite:session-write:agent:main:main", + owner: "replacement", + staleReasons: ["lease-lost"], }); const controller = await createEmbeddedAttemptSessionLockController({ - acquireSessionWriteLock, - lockOptions: { ...lockOptions, sessionFile }, - mergePromptReleasedSessionEntries: (entries) => - staleManager.mergePromptReleasedSessionEntries(entries, { persistLeaf: true }), - reloadPromptReleasedSessionFile: () => staleManager.setSessionFile(sessionFile), + acquireSessionWriteLock: vi.fn(async () => ({ + assertOwned: () => { + throw leaseError; + }, + release: async () => undefined, + })), + lockOptions: { sessionFile: "agent:main:main" }, }); - await controller.releaseForPrompt(); - await fs.appendFile( - sessionFile, - [ - JSON.stringify({ - type: "custom", - id: "model-snapshot", - parentId: null, - timestamp: new Date().toISOString(), - customType: "model-snapshot", - data: { provider: "openai", modelId: "gpt-5.1" }, - }), - JSON.stringify({ - type: "session_info", - id: "session-info", - parentId: "model-snapshot", - timestamp: new Date().toISOString(), - name: "first turn", - }), - ].join("\n") + "\n", - "utf8", - ); - await controller.withSessionWriteLock(() => undefined); + await expect(controller.releaseForPrompt()).rejects.toBe(leaseError); + expect(controller.hasSessionTakeover()).toBe(true); + }); - const reopenedBeforeReply = SessionManager.open(sessionFile, dir, dir); - expect( - reopenedBeforeReply.buildSessionContext().messages.map((message) => message.role), - ).toEqual(["user"]); - expect(reopenedBeforeReply.getSessionName()).toBe("first turn"); + it("allows nested writes to reuse the active lifecycle owner", async () => { + const events: string[] = []; + const controller = await createEmbeddedAttemptSessionLockController({ + acquireSessionWriteLock: vi.fn(async () => ({ release: async () => undefined })), + lockOptions: { sessionFile: "agent:main:main" }, + }); + + await controller.withSessionWriteLock(async () => { + events.push("outer:start"); + await controller.withSessionWriteLock(() => { + events.push("nested"); + }); + events.push("outer:end"); + }); + + expect(events).toEqual(["outer:start", "nested", "outer:end"]); + }); + + it("queues async descendants that resume after their lifecycle owner exits", async () => { + let resumeDescendant!: () => void; + const descendantBlocked = new Promise((resolve) => { + resumeDescendant = resolve; + }); + let releaseSecond!: () => void; + const secondBlocked = new Promise((resolve) => { + releaseSecond = resolve; + }); + const events: string[] = []; + const controller = await createEmbeddedAttemptSessionLockController({ + acquireSessionWriteLock: vi.fn(async () => ({ release: async () => undefined })), + lockOptions: { sessionFile: "agent:main:main" }, + }); + let descendant!: Promise; await controller.withSessionWriteLock(() => { - staleManager.appendMessage({ - role: "assistant", - content: [{ type: "text", text: "answer" }], - api: "messages", - provider: "anthropic", - model: "sonnet-4.6", - usage: { - input: 0, - output: 0, - cacheRead: 0, - cacheWrite: 0, - totalTokens: 0, - cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 }, - }, - stopReason: "stop", - timestamp: 2, - }); - }); - - const reopened = SessionManager.open(sessionFile, dir, dir); - expect(reopened.buildSessionContext().messages.map((message) => message.role)).toEqual([ - "user", - "assistant", - ]); - expect(reopened.getSessionName()).toBe("first turn"); - expect(reopened.getEntry("model-snapshot")).toMatchObject({ - type: "custom", - customType: "model-snapshot", - }); - }); - - it("persists the restored leaf before returning from a prompt-released merge", async () => { - const dir = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-attempt-session-leaf-fence-")); - tempDirs.push(dir); - const initialManager = SessionManager.create(dir, dir); - initialManager.appendMessage({ role: "user", content: "question", timestamp: 1 }); - const baseAnswerId = appendTestAssistantMessage(initialManager, "base answer"); - const sessionFile = initialManager.getSessionFile(); - if (!sessionFile) { - throw new Error("expected persisted session file"); - } - const staleManager = SessionManager.open(sessionFile, dir, dir); - const controller = await createEmbeddedAttemptSessionLockController({ - acquireSessionWriteLock, - lockOptions: { ...lockOptions, sessionFile }, - mergePromptReleasedSessionEntries: (entries) => - staleManager.mergePromptReleasedSessionEntries(entries, { persistLeaf: true }), - reloadPromptReleasedSessionFile: () => staleManager.setSessionFile(sessionFile), - }); - await controller.releaseForPrompt(); - await fs.appendFile( - sessionFile, - `${JSON.stringify({ - type: "message", - id: "side-delivery", - parentId: baseAnswerId, - timestamp: new Date().toISOString(), - message: { - role: "assistant", - content: [{ type: "text", text: "side delivery" }], - provider: "openclaw", - model: "delivery-mirror", - }, - })}\n`, - "utf8", - ); - - await controller.withSessionWriteLock(() => undefined); - - const records = (await fs.readFile(sessionFile, "utf8")) - .trim() - .split("\n") - .map( - (line) => - JSON.parse(line) as { - type?: string; - parentId?: string | null; - targetId?: string | null; - appendParentId?: string | null; - appendMode?: string; - }, - ); - expect(records.at(-1)).toMatchObject({ - type: "leaf", - parentId: "side-delivery", - targetId: baseAnswerId, - appendParentId: "side-delivery", - appendMode: "side", - }); - const reopened = SessionManager.open(sessionFile, dir, dir); - expect(reopened.getLeafId()).toBe(baseAnswerId); - expect(JSON.stringify(reopened.buildSessionContext())).not.toContain("side delivery"); - expect(controller.hasSessionTakeover()).toBe(false); - await controller.dispose(); - }); - - it("publishes the restoring leaf for every active session fence", async () => { - const dir = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-attempt-session-shared-leaf-")); - tempDirs.push(dir); - const initialManager = SessionManager.create(dir, dir); - initialManager.appendMessage({ role: "user", content: "question", timestamp: 1 }); - const baseAnswerId = appendTestAssistantMessage(initialManager, "base answer"); - const sessionFile = initialManager.getSessionFile(); - if (!sessionFile) { - throw new Error("expected persisted session file"); - } - const firstManager = SessionManager.open(sessionFile, dir, dir); - const secondManager = SessionManager.open(sessionFile, dir, dir); - const firstController = await createEmbeddedAttemptSessionLockController({ - acquireSessionWriteLock, - lockOptions: { ...lockOptions, sessionFile }, - mergePromptReleasedSessionEntries: (entries) => - firstManager.mergePromptReleasedSessionEntries(entries, { persistLeaf: true }), - reloadPromptReleasedSessionFile: () => firstManager.setSessionFile(sessionFile), - }); - await firstController.releaseForPrompt(); - const secondController = await createEmbeddedAttemptSessionLockController({ - acquireSessionWriteLock, - lockOptions: { ...lockOptions, sessionFile }, - mergePromptReleasedSessionEntries: (entries) => - secondManager.mergePromptReleasedSessionEntries(entries, { persistLeaf: true }), - reloadPromptReleasedSessionFile: () => secondManager.setSessionFile(sessionFile), - }); - await secondController.releaseForPrompt(); - - await fs.appendFile( - sessionFile, - `${JSON.stringify({ - type: "message", - id: "shared-side-delivery", - parentId: baseAnswerId, - timestamp: new Date().toISOString(), - message: { - role: "assistant", - content: [{ type: "text", text: "side delivery" }], - provider: "openclaw", - model: "delivery-mirror", - }, - })}\n`, - "utf8", - ); - await firstController.withSessionWriteLock(() => undefined); - const recordsAfterFirstMerge = (await fs.readFile(sessionFile, "utf8")).trim().split("\n"); - - await secondController.withSessionWriteLock(() => undefined); - - const recordsAfterSecondMerge = (await fs.readFile(sessionFile, "utf8")).trim().split("\n"); - expect(recordsAfterSecondMerge).toHaveLength(recordsAfterFirstMerge.length); - expect(JSON.parse(recordsAfterSecondMerge.at(-1) ?? "{}")).toMatchObject({ - type: "leaf", - targetId: baseAnswerId, - appendParentId: "shared-side-delivery", - appendMode: "side", - }); - expect(firstController.hasSessionTakeover()).toBe(false); - expect(secondController.hasSessionTakeover()).toBe(false); - await firstController.dispose(); - await secondController.dispose(); - }); - - it("reloads a trusted first-turn rewrite for every active session fence", async () => { - const dir = await fs.mkdtemp( - path.join(os.tmpdir(), "openclaw-attempt-session-shared-rewrite-"), - ); - tempDirs.push(dir); - const sessionFile = path.join(dir, "session.jsonl"); - await fs.writeFile( - sessionFile, - `${JSON.stringify({ - type: "session", - version: 3, - id: "shared-first-turn", - timestamp: new Date().toISOString(), - cwd: dir, - })}\n`, - "utf8", - ); - const firstManager = SessionManager.open(sessionFile, dir, dir); - const firstUserId = firstManager.appendMessage({ - role: "user", - content: "first prepared question", - timestamp: 1, - }); - const secondManager = SessionManager.open(sessionFile, dir, dir); - secondManager.appendMessage({ - role: "user", - content: "stale prepared question", - timestamp: 1, - }); - const firstController = await createEmbeddedAttemptSessionLockController({ - acquireSessionWriteLock, - lockOptions: { ...lockOptions, sessionFile }, - mergePromptReleasedSessionEntries: (entries) => - firstManager.mergePromptReleasedSessionEntries(entries, { persistLeaf: true }), - reloadPromptReleasedSessionFile: () => firstManager.setSessionFile(sessionFile), - }); - await firstController.releaseForPrompt(); - const secondController = await createEmbeddedAttemptSessionLockController({ - acquireSessionWriteLock, - lockOptions: { ...lockOptions, sessionFile }, - mergePromptReleasedSessionEntries: (entries) => - secondManager.mergePromptReleasedSessionEntries(entries, { persistLeaf: true }), - reloadPromptReleasedSessionFile: () => secondManager.setSessionFile(sessionFile), - }); - await secondController.releaseForPrompt(); - await fs.appendFile( - sessionFile, - `${JSON.stringify({ - type: "session_info", - id: "shared-session-info", - parentId: null, - timestamp: new Date().toISOString(), - name: "shared first turn", - })}\n`, - "utf8", - ); - - await firstController.withSessionWriteLock(() => undefined); - await secondController.withSessionWriteLock(() => undefined); - - expect(secondManager.getLeafId()).toBe(firstUserId); - expect(secondManager.getSessionName()).toBe("shared first turn"); - expect(secondManager.buildSessionContext().messages).toMatchObject([ - { role: "user", content: "first prepared question" }, - ]); - expect(firstController.hasSessionTakeover()).toBe(false); - expect(secondController.hasSessionTakeover()).toBe(false); - await firstController.dispose(); - await secondController.dispose(); - }); - - it("preserves globally resolved metadata when a stale manager appends the reply branch", async () => { - const dir = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-attempt-session-metadata-")); - tempDirs.push(dir); - const initialManager = SessionManager.create(dir, dir); - initialManager.appendMessage({ - role: "user", - content: "question", - timestamp: 1, - }); - const rootId = appendTestAssistantMessage(initialManager, "first answer"); - const sessionFile = initialManager.getSessionFile(); - if (!sessionFile) { - throw new Error("expected persisted session file"); - } - const staleManager = SessionManager.open(sessionFile, dir, dir); - const controller = await createEmbeddedAttemptSessionLockController({ - acquireSessionWriteLock, - lockOptions: { ...lockOptions, sessionFile }, - mergePromptReleasedSessionEntries: (entries) => - staleManager.mergePromptReleasedSessionEntries(entries, { persistLeaf: true }), - reloadPromptReleasedSessionFile: () => staleManager.setSessionFile(sessionFile), - }); - - await controller.releaseForPrompt(); - const metadataManager = SessionManager.open(sessionFile, dir, dir); - const customId = metadataManager.appendCustomEntry("model-snapshot", { - provider: "openai", - modelId: "gpt-5.1", - }); - metadataManager.appendLabelChange(rootId, "runtime setting"); - metadataManager.appendSessionInfo("session title"); - - await controller.withSessionWriteLock(() => { - appendTestAssistantMessage(staleManager, "answer", 3); - }); - ( - staleManager as unknown as { - replacePersistedTranscript: () => void; - } - ).replacePersistedTranscript(); - - const reopened = SessionManager.open(sessionFile, dir, dir); - expect(reopened.getSessionName()).toBe("session title"); - expect(reopened.getLabel(rootId)).toBe("runtime setting"); - expect(reopened.getEntry(customId)).toMatchObject({ - type: "custom", - customType: "model-snapshot", - }); - expect(reopened.buildSessionContext().messages.map((message) => message.role)).toEqual([ - "user", - "assistant", - "assistant", - ]); - expect(controller.hasSessionTakeover()).toBe(false); - - const cleanupLock = await controller.acquireForCleanup(); - await cleanupLock.release(); - }); - - it.each([ - ["custom without customType", { type: "custom", data: { provider: "openai" } }], - ["custom with empty customType", { type: "custom", customType: "" }], - ["label without targetId", { type: "label", label: "runtime setting" }], - ["label with non-string label", { type: "label", targetId: "model-change", label: 42 }], - ["session_info with non-string name", { type: "session_info", name: 42 }], - ])( - "rejects malformed session control entries while the prompt lock is released: %s", - async (_, entry) => { - const sessionFile = await createTempSessionFile(); - const release = vi.fn(async () => {}); - const acquireSessionWriteLockLocal = vi.fn(async () => ({ release })); - const controller = await createEmbeddedAttemptSessionLockController({ - acquireSessionWriteLock: acquireSessionWriteLockLocal, - lockOptions: { ...lockOptions, sessionFile }, - }); - - await controller.releaseForPrompt(); - await fs.appendFile( - sessionFile, - `${JSON.stringify({ - id: "malformed-control-entry", - parentId: null, - timestamp: new Date().toISOString(), - ...entry, - })}\n`, - "utf8", - ); - - await expect(controller.withSessionWriteLock(() => "late-write")).rejects.toBeInstanceOf( - EmbeddedAttemptSessionTakeoverError, - ); - expect(controller.hasSessionTakeover()).toBe(true); - - const cleanupLock = await controller.acquireForCleanup(); - await cleanupLock.release(); - }, - ); - - it.each([ - [ - "model_change", - { - type: "model_change", - provider: "openai", - modelId: "gpt-5.1", - }, - ], - [ - "thinking_level_change", - { - type: "thinking_level_change", - thinkingLevel: "high", - }, - ], - [ - "message", - { - type: "message", - message: { role: "user", content: [{ type: "text", text: "concurrent user input" }] }, - }, - ], - [ - "custom_message", - { - type: "custom_message", - customType: "extension-context", - content: "prompt-affecting extension content", - display: false, - }, - ], - [ - "compaction", - { - type: "compaction", - summary: "Compacted context that would affect the next prompt.", - firstKeptEntryId: "root", - tokensBefore: 42, - }, - ], - [ - "branch_summary", - { - type: "branch_summary", - fromId: "root", - summary: "Branch summary that would affect the next prompt.", - }, - ], - ["leaf", { type: "leaf", targetId: null }], - ])( - "rejects prompt-affecting session entries while the prompt lock is released: %s", - async (_, entry) => { - const sessionFile = await createTempSessionFile(); - const release = vi.fn(async () => {}); - const acquireSessionWriteLockLocal = vi.fn(async () => ({ release })); - const controller = await createEmbeddedAttemptSessionLockController({ - acquireSessionWriteLock: acquireSessionWriteLockLocal, - lockOptions: { ...lockOptions, sessionFile }, - }); - - await controller.releaseForPrompt(); - await fs.appendFile( - sessionFile, - `${JSON.stringify({ - id: "prompt-affecting-entry", - parentId: null, - timestamp: new Date().toISOString(), - ...entry, - })}\n`, - "utf8", - ); - - await expect(controller.withSessionWriteLock(() => "late-write")).rejects.toBeInstanceOf( - EmbeddedAttemptSessionTakeoverError, - ); - expect(controller.hasSessionTakeover()).toBe(true); - - const cleanupLock = await controller.acquireForCleanup(); - await cleanupLock.release(); - }, - ); - - it("allows delivery mirror appends while the prompt lock is released", async () => { - const sessionFile = await createTempSessionFile(); - const release = vi.fn(async () => {}); - const acquireSessionWriteLockLocal11 = vi.fn(async () => ({ release })); - const mergePromptReleasedSessionEntries = vi.fn(); - const controller = await createEmbeddedAttemptSessionLockController({ - acquireSessionWriteLock: acquireSessionWriteLockLocal11, - lockOptions: { ...lockOptions, sessionFile }, - mergePromptReleasedSessionEntries, - }); - - await controller.releaseForPrompt(); - await appendSessionTranscriptMessage({ - transcriptPath: sessionFile, - message: { - role: "assistant", - content: [{ type: "text", text: "mirrored media delivery" }], - provider: "openclaw", - model: "delivery-mirror", - }, - }); - - await expect(controller.withSessionWriteLock(() => "late-write")).resolves.toBe("late-write"); - const cleanupLock = await controller.acquireForCleanup(); - await cleanupLock.release(); - - expect(controller.hasSessionTakeover()).toBe(false); - expect(mergePromptReleasedSessionEntries).toHaveBeenCalledWith([ - expect.objectContaining({ - type: "message", - message: expect.objectContaining({ model: "delivery-mirror" }), - }), - ]); - expect(release).toHaveBeenCalledTimes(3); - }); - - it("allows delivery mirror side appends with leaf controls while the prompt lock is released", async () => { - const sessionFile = await createTempSessionFile(); - const release = vi.fn(async () => {}); - const acquireSessionWriteLockLocal = vi.fn(async () => ({ release })); - const mergePromptReleasedSessionEntries = vi.fn(); - const controller = await createEmbeddedAttemptSessionLockController({ - acquireSessionWriteLock: acquireSessionWriteLockLocal, - lockOptions: { ...lockOptions, sessionFile }, - mergePromptReleasedSessionEntries, - }); - - await controller.releaseForPrompt(); - const timestamp = "2026-07-04T17:03:02.949Z"; - const firstMirror = { - type: "message", - id: "delivery-mirror-1", - parentId: null, - timestamp, - message: { - role: "assistant", - content: [{ type: "text", text: "image-1.jpg" }], - api: "openai-responses", - provider: "openclaw", - model: "delivery-mirror", - stopReason: "stop", - idempotencyKey: "run-message-tool:message-tool:fingerprint:call-1", - }, - }; - const leaf = { - type: "leaf", - id: "delivery-leaf", - parentId: firstMirror.id, - timestamp, - targetId: "assistant-tool-call", - appendParentId: firstMirror.id, - appendMode: "side", - }; - const sideMirrors = [2, 3, 4].map((index) => ({ - type: "message", - id: `delivery-mirror-${index}`, - parentId: index === 2 ? firstMirror.id : `delivery-mirror-${index - 1}`, - timestamp, - appendMode: "side", - message: { - role: "assistant", - content: [{ type: "text", text: `image-${index}.jpg` }], - api: "openai-responses", - provider: "openclaw", - model: "delivery-mirror", - stopReason: "stop", - idempotencyKey: `run-message-tool:message-tool:fingerprint:call-${index}`, - }, - })); - await fs.appendFile( - sessionFile, - [firstMirror, leaf, ...sideMirrors].map((entry) => JSON.stringify(entry)).join("\n") + "\n", - "utf8", - ); - - await expect(controller.withSessionWriteLock(() => "late-write")).resolves.toBe("late-write"); - const cleanupLock = await controller.acquireForCleanup(); - await cleanupLock.release(); - - expect(controller.hasSessionTakeover()).toBe(false); - expect(mergePromptReleasedSessionEntries).toHaveBeenCalledWith([ - expect.objectContaining({ type: "message", id: firstMirror.id }), - expect.objectContaining({ - type: "prompt_released_opaque", - preserveActiveLeaf: true, - record: expect.objectContaining({ type: "leaf", id: leaf.id }), - }), - ...sideMirrors.map((mirror) => expect.objectContaining({ type: "message", id: mirror.id })), - ]); - expect(release).toHaveBeenCalledTimes(3); - }); - - it("rejects active leaf controls while the prompt lock is released", async () => { - const sessionFile = await createTempSessionFile(); - const mergePromptReleasedSessionEntries = vi.fn(); - const controller = await createEmbeddedAttemptSessionLockController({ - acquireSessionWriteLock, - lockOptions: { ...lockOptions, sessionFile }, - mergePromptReleasedSessionEntries, - }); - - await controller.releaseForPrompt(); - await fs.appendFile( - sessionFile, - `${JSON.stringify({ - type: "leaf", - id: "active-leaf-control", - parentId: null, - timestamp: "2026-07-04T17:03:02.949Z", - targetId: null, - })}\n`, - "utf8", - ); - - await expect(controller.withSessionWriteLock(() => "late-write")).rejects.toBeInstanceOf( - EmbeddedAttemptSessionTakeoverError, - ); - expect(controller.hasSessionTakeover()).toBe(true); - expect(mergePromptReleasedSessionEntries).not.toHaveBeenCalled(); - }); - - it("allows mixed delivery mirror and global metadata appends", async () => { - const sessionFile = await createTempSessionFile(); - const mergePromptReleasedSessionEntries = vi.fn(); - const controller = await createEmbeddedAttemptSessionLockController({ - acquireSessionWriteLock: vi.fn(async () => ({ release: vi.fn(async () => {}) })), - lockOptions: { ...lockOptions, sessionFile }, - mergePromptReleasedSessionEntries, - }); - - await controller.releaseForPrompt(); - await fs.appendFile( - sessionFile, - [ - JSON.stringify({ - type: "message", - id: "delivery-mirror", - parentId: null, - timestamp: new Date().toISOString(), - message: { - role: "assistant", - provider: "openclaw", - model: "delivery-mirror", - }, - }), - JSON.stringify({ - type: "label", - id: "delivery-label", - parentId: "delivery-mirror", - timestamp: new Date().toISOString(), - targetId: "delivery-mirror", - label: "delivered", - }), - JSON.stringify({ - type: "session_info", - id: "session-info", - parentId: "delivery-label", - timestamp: new Date().toISOString(), - name: "session title", - }), - ].join("\n") + "\n", - "utf8", - ); - - await expect(controller.withSessionWriteLock(() => "late-write")).resolves.toBe("late-write"); - expect(mergePromptReleasedSessionEntries).toHaveBeenCalledWith([ - expect.objectContaining({ type: "message", id: "delivery-mirror" }), - expect.objectContaining({ - type: "label", - id: "delivery-label", - targetId: "delivery-mirror", - }), - expect.objectContaining({ type: "session_info", id: "session-info" }), - ]); - }); - - it("allows delivery mirror appends that migrate legacy linear transcripts", async () => { - const sessionFile = await createTempSessionFile(); - await fs.appendFile( - sessionFile, - `${JSON.stringify({ - type: "message", - id: "legacy-user", - message: { role: "user", content: [{ type: "text", text: "hello" }] }, - })}\n`, - "utf8", - ); - const release = vi.fn(async () => {}); - const acquireSessionWriteLockLocal10 = vi.fn(async () => ({ release })); - const mergePromptReleasedSessionEntries = vi.fn(); - const controller = await createEmbeddedAttemptSessionLockController({ - acquireSessionWriteLock: acquireSessionWriteLockLocal10, - lockOptions: { ...lockOptions, sessionFile }, - mergePromptReleasedSessionEntries, - }); - - await controller.releaseForPrompt(); - await appendSessionTranscriptMessage({ - transcriptPath: sessionFile, - message: { - role: "assistant", - content: [{ type: "text", text: "mirrored migrated media delivery" }], - provider: "openclaw", - model: "delivery-mirror", - }, - }); - await fs.appendFile( - sessionFile, - `${JSON.stringify({ - type: "session_info", - id: "session-info", - parentId: null, - timestamp: new Date().toISOString(), - name: "migrated title", - })}\n`, - "utf8", - ); - - await expect(controller.withSessionWriteLock(() => "late-write")).resolves.toBe("late-write"); - const cleanupLock = await controller.acquireForCleanup(); - await cleanupLock.release(); - - await expect(fs.readFile(sessionFile, "utf8")).resolves.toContain('"parentId"'); - expect(mergePromptReleasedSessionEntries).toHaveBeenCalledWith([ - expect.objectContaining({ - type: "message", - message: expect.objectContaining({ model: "delivery-mirror" }), - }), - expect.objectContaining({ type: "session_info", id: "session-info" }), - ]); - expect(controller.hasSessionTakeover()).toBe(false); - }); - - it("refreshes the prompt fence after an owned write throws", async () => { - const sessionFile = await createTempSessionFile(); - const { - controller, - release, - acquire: acquireSessionWriteLockLocal9, - } = await createMockSessionLockController(sessionFile); - - await controller.releaseForPrompt(); - await expect( - controller.withSessionWriteLock(async () => { - await fs.appendFile(sessionFile, '{"type":"message","id":"owned-before-error"}\n', "utf8"); - throw new Error("downstream event handler failed"); - }), - ).rejects.toThrow("downstream event handler failed"); - await expect(controller.withSessionWriteLock(() => "finalize")).resolves.toBe("finalize"); - - expect(controller.hasSessionTakeover()).toBe(false); - expect(acquireSessionWriteLockLocal9).toHaveBeenCalledTimes(3); - expect(release).toHaveBeenCalledTimes(3); - }); - - it("does not reuse a released lock from inherited async context", async () => { - const sessionFile = await createTempSessionFile(); - const detachedGate = createDeferred(); - const { - controller, - release, - acquire: acquireSessionWriteLockLocal8, - } = await createMockSessionLockController(sessionFile); - - await controller.releaseForPrompt(); - let detachedWrite!: Promise; - await controller.withSessionWriteLock(async () => { - detachedWrite = (async () => { - await detachedGate.promise; - await controller.withSessionWriteLock(async () => { - await fs.appendFile(sessionFile, '{"type":"message","id":"detached-owned"}\n', "utf8"); + descendant = (async () => { + await descendantBlocked; + await controller.withSessionWriteLock(() => { + events.push("descendant"); }); })(); }); - - detachedGate.resolve(); - await detachedWrite; - await expect(controller.withSessionWriteLock(() => "finalize")).resolves.toBe("finalize"); - - expect(controller.hasSessionTakeover()).toBe(false); - expect(acquireSessionWriteLockLocal8).toHaveBeenCalledTimes(4); - expect(release).toHaveBeenCalledTimes(4); - }); - - it("keeps post-provider transcript writes owned after prompt stream returns", async () => { - const sessionFile = await createTempSessionFile(); - const { - controller, - release, - acquire: acquireSessionWriteLockLocal7, - } = await createMockSessionLockController(sessionFile); - - await controller.releaseForPrompt(); - await controller.reacquireAfterPrompt(); - await fs.appendFile(sessionFile, '{"type":"message","id":"provider-error"}\n', "utf8"); - controller.refreshAfterOwnedSessionWrite(); - - const cleanupLock = await controller.acquireForCleanup(); - await cleanupLock.release(); - - expect(controller.hasSessionTakeover()).toBe(false); - expect(acquireSessionWriteLockLocal7).toHaveBeenCalledTimes(2); - expect(release).toHaveBeenCalledTimes(2); - }); - - it("still rejects external edits before the prompt stream lock is reacquired", async () => { - const sessionFile = await createTempSessionFile(); - const { - controller, - release, - acquire: acquireSessionWriteLockLocal6, - } = await createMockSessionLockController(sessionFile); - - await controller.releaseForPrompt(); - await fs.appendFile(sessionFile, '{"type":"message","id":"external"}\n', "utf8"); - - await expect(controller.reacquireAfterPrompt()).rejects.toBeInstanceOf( - EmbeddedAttemptSessionTakeoverError, - ); - expect(controller.hasSessionTakeover()).toBe(true); - expect(acquireSessionWriteLockLocal6).toHaveBeenCalledTimes(2); - expect(release).toHaveBeenCalledTimes(2); - }); - - it("allows ctime-only fingerprint drift while the prompt lock is released", async () => { - const sessionFile = await createTempSessionFile(); - const { - controller, - release, - acquire: acquireSessionWriteLockLocalCtimeDrift, - } = await createMockSessionLockController(sessionFile); - - await controller.releaseForPrompt(); - - const stableStat = await fs.stat(sessionFile, { bigint: true }); - const driftedStat = cloneBigIntStatWith(stableStat, { - ctimeNs: stableStat.ctimeNs + 1_000_000n, - }); - const statSpy = vi.spyOn(fs, "stat").mockImplementation(async (target, options) => { - if (target === sessionFile && options?.bigint === true) { - return driftedStat; - } - throw new Error(`unexpected stat call for ${String(target)}`); + const second = controller.withSessionWriteLock(async () => { + events.push("second:start"); + await secondBlocked; + events.push("second:end"); }); - try { - await expect(controller.withSessionWriteLock(() => "finalize")).resolves.toBe("finalize"); - } finally { - statSpy.mockRestore(); - } - expect(controller.hasSessionTakeover()).toBe(false); - expect(acquireSessionWriteLockLocalCtimeDrift).toHaveBeenCalledTimes(2); - expect(release).toHaveBeenCalledTimes(2); + await vi.waitFor(() => expect(events).toEqual(["second:start"])); + resumeDescendant(); + await Promise.resolve(); + expect(events).toEqual(["second:start"]); + releaseSecond(); + await Promise.all([second, descendant]); + + expect(events).toEqual(["second:start", "second:end", "descendant"]); }); - it("allows a byte-identical in-place rewrite while the prompt lock is released", async () => { - const sessionFile = await createTempSessionFile(); - const oldTime = new Date("2020-01-01T00:00:00.000Z"); - await fs.utimes(sessionFile, oldTime, oldTime); - const before = await fs.stat(sessionFile, { bigint: true }); - const originalBytes = await fs.readFile(sessionFile); - const { - controller, - release, - acquire: acquireSessionWriteLockLocalIdenticalRewrite, - } = await createMockSessionLockController(sessionFile); - - await controller.releaseForPrompt(); - await fs.writeFile(sessionFile, originalBytes); - const after = await fs.stat(sessionFile, { bigint: true }); - - expect(after.dev).toBe(before.dev); - expect(after.ino).toBe(before.ino); - expect(after.size).toBe(before.size); - expect(after.mtimeNs).not.toBe(before.mtimeNs); - expect(await fs.readFile(sessionFile)).toEqual(originalBytes); - await expect(controller.reacquireAfterPrompt()).resolves.toBeUndefined(); - expect(controller.hasSessionTakeover()).toBe(false); - expect(acquireSessionWriteLockLocalIdenticalRewrite).toHaveBeenCalledTimes(2); - expect(release).toHaveBeenCalledTimes(1); - await controller.dispose(); - expect(release).toHaveBeenCalledTimes(2); - }); - - it("rejects a same-size rewrite racing the byte-identical snapshot", async () => { - const sessionFile = await createTempSessionFile(); - const oldTime = new Date("2020-01-01T00:00:00.000Z"); - await fs.utimes(sessionFile, oldTime, oldTime); - const before = await fs.stat(sessionFile, { bigint: true }); - const originalBytes = await fs.readFile(sessionFile); - const release = vi.fn(async () => {}); + it("keeps the lifecycle held until detached nested writes settle", async () => { + let releaseNested!: () => void; + const nestedBlocked = new Promise((resolve) => { + releaseNested = resolve; + }); + const events: string[] = []; const controller = await createEmbeddedAttemptSessionLockController({ - acquireSessionWriteLock: vi.fn(async () => ({ release })), - lockOptions: { ...lockOptions, sessionFile }, + acquireSessionWriteLock: vi.fn(async () => ({ release: async () => undefined })), + lockOptions: { sessionFile: "agent:main:main" }, }); - await controller.releaseForPrompt(); - await fs.writeFile(sessionFile, originalBytes); - // Force the first rewrite past the fingerprint fast path even on filesystems - // that coalesce timestamps; the next path stat is snapshot validation. - const rewrittenTime = new Date("2021-01-01T00:00:00.000Z"); - await fs.utimes(sessionFile, rewrittenTime, rewrittenTime); - const after = await fs.stat(sessionFile, { bigint: true }); - - expect(after.dev).toBe(before.dev); - expect(after.ino).toBe(before.ino); - expect(after.size).toBe(before.size); - expect(after.mtimeNs).not.toBe(before.mtimeNs); - - const realStat = fs.stat.bind(fs); - let armedStatCalls = 0; - const statSpy = vi.spyOn(fs, "stat").mockImplementation(async (target, options) => { - if (target === sessionFile && options?.bigint === true) { - armedStatCalls += 1; - if (armedStatCalls === 2) { - await fs.writeFile(sessionFile, '{"type":"sessioN"}\n', "utf8"); - } - } - return await realStat(target, options); + const outer = controller.withSessionWriteLock(() => { + void controller.withSessionWriteLock(async () => { + events.push("nested:start"); + await nestedBlocked; + events.push("nested:end"); + }); + }); + const second = controller.withSessionWriteLock(() => { + events.push("second"); }); - try { - await expect(controller.reacquireAfterPrompt()).rejects.toBeInstanceOf( - EmbeddedAttemptSessionTakeoverError, - ); - } finally { - statSpy.mockRestore(); - } - expect(armedStatCalls).toBeGreaterThanOrEqual(2); - expect(controller.hasSessionTakeover()).toBe(true); + await vi.waitFor(() => expect(events).toEqual(["nested:start"])); + releaseNested(); + await Promise.all([outer, second]); + + expect(events).toEqual(["nested:start", "nested:end", "second"]); }); - it("trusts owned writes after accepting ctime-only fingerprint drift", async () => { - const sessionFile = await createTempSessionFile(); - const { - controller, - release, - acquire: acquireSessionWriteLockLocalOwnedAfterDrift, - } = await createMockSessionLockController(sessionFile); - - await controller.releaseForPrompt(); - - const stableStat = await fs.stat(sessionFile, { bigint: true }); - const driftedStat = cloneBigIntStatWith(stableStat, { - ctimeNs: stableStat.ctimeNs + 1_000_000n, + it("serializes sibling nested writes in submission order", async () => { + let releaseFirst!: () => void; + const firstBlocked = new Promise((resolve) => { + releaseFirst = resolve; }); - const appendedText = '{"type":"message","id":"owned-after-drift"}\n'; - const changedStat = cloneBigIntStatWith(stableStat, { - ctimeNs: stableStat.ctimeNs + 2_000_000n, - mtimeNs: stableStat.mtimeNs + 1_000_000n, - size: stableStat.size + BigInt(Buffer.byteLength(appendedText)), - }); - let currentStat = driftedStat; - const statSpy = vi.spyOn(fs, "stat").mockImplementation(async (target, options) => { - if (target === sessionFile && options?.bigint === true) { - return currentStat; - } - throw new Error(`unexpected stat call for ${String(target)}`); - }); - - try { - await expect( - controller.withSessionWriteLock( - async () => { - currentStat = changedStat; - await fs.appendFile(sessionFile, appendedText, "utf8"); - }, - { publishOwnedWrite: true }, - ), - ).resolves.toBeUndefined(); - await expect(controller.withSessionWriteLock(() => "finalize")).resolves.toBe("finalize"); - } finally { - statSpy.mockRestore(); - } - expect(controller.hasSessionTakeover()).toBe(false); - expect(acquireSessionWriteLockLocalOwnedAfterDrift).toHaveBeenCalledTimes(3); - expect(release).toHaveBeenCalledTimes(3); - }); - - it("allows a byte-identical rewrite for digest-backed transcript snapshots", async () => { - const sessionFile = await createTempSessionFile(); - const originalBytes = Buffer.alloc(8 * 1024 * 1024 + 1, "x"); - await fs.writeFile(sessionFile, originalBytes); - const oldTime = new Date("2020-01-01T00:00:00.000Z"); - await fs.utimes(sessionFile, oldTime, oldTime); - const before = await fs.stat(sessionFile, { bigint: true }); - const release = vi.fn(async () => {}); + const events: string[] = []; const controller = await createEmbeddedAttemptSessionLockController({ - acquireSessionWriteLock: vi.fn(async () => ({ release })), - lockOptions: { ...lockOptions, sessionFile }, + acquireSessionWriteLock: vi.fn(async () => ({ release: async () => undefined })), + lockOptions: { sessionFile: "agent:main:main" }, }); - await controller.releaseForPrompt(); - await fs.writeFile(sessionFile, originalBytes); - const after = await fs.stat(sessionFile, { bigint: true }); + await controller.withSessionWriteLock(async () => { + const first = controller.withSessionWriteLock(async () => { + events.push("first:start"); + await firstBlocked; + events.push("first:end"); + }); + const second = controller.withSessionWriteLock(() => { + events.push("second"); + }); + await vi.waitFor(() => expect(events).toEqual(["first:start"])); + releaseFirst(); + await Promise.all([first, second]); + }); - expect(after.dev).toBe(before.dev); - expect(after.ino).toBe(before.ino); - expect(after.size).toBe(before.size); - expect(after.mtimeNs).not.toBe(before.mtimeNs); - await expect(controller.reacquireAfterPrompt()).resolves.toBeUndefined(); - expect(controller.hasSessionTakeover()).toBe(false); - await controller.dispose(); + expect(events).toEqual(["first:start", "first:end", "second"]); }); - it("rejects changed bytes for digest-backed transcript snapshots", async () => { - const sessionFile = await createTempSessionFile(); - const originalBytes = Buffer.alloc(8 * 1024 * 1024 + 1, "x"); - await fs.writeFile(sessionFile, originalBytes); - const release = vi.fn(async () => {}); + it("propagates failures from detached nested writes", async () => { + const nestedError = new Error("nested write failed"); const controller = await createEmbeddedAttemptSessionLockController({ - acquireSessionWriteLock: vi.fn(async () => ({ release })), - lockOptions: { ...lockOptions, sessionFile }, + acquireSessionWriteLock: vi.fn(async () => ({ release: async () => undefined })), + lockOptions: { sessionFile: "agent:main:main" }, }); - await controller.releaseForPrompt(); - const file = await fs.open(sessionFile, "r+"); - try { - await file.write(Buffer.from("y"), 0, 1, 0); - } finally { - await file.close(); - } - - await expect(controller.reacquireAfterPrompt()).rejects.toBeInstanceOf( - EmbeddedAttemptSessionTakeoverError, - ); - expect(controller.hasSessionTakeover()).toBe(true); - }); - - it("rejects same-inode, same-size transcript rewrites when one byte changes", async () => { - const sessionFile = await createTempSessionFile(); - const oldTime = new Date("2020-01-01T00:00:00.000Z"); - await fs.utimes(sessionFile, oldTime, oldTime); - const before = await fs.stat(sessionFile, { bigint: true }); - const release = vi.fn(async () => {}); - const controller = await createEmbeddedAttemptSessionLockController({ - acquireSessionWriteLock: vi.fn(async () => ({ release })), - lockOptions: { ...lockOptions, sessionFile }, - }); - - await controller.releaseForPrompt(); - await fs.writeFile(sessionFile, '{"type":"sessioN"}\n', "utf8"); - const after = await fs.stat(sessionFile, { bigint: true }); - - expect(after.dev).toBe(before.dev); - expect(after.ino).toBe(before.ino); - expect(after.size).toBe(before.size); - await expect(controller.reacquireAfterPrompt()).rejects.toBeInstanceOf( - EmbeddedAttemptSessionTakeoverError, - ); - expect(controller.hasSessionTakeover()).toBe(true); - }); - - it("rejects truncated transcript rewrites", async () => { - const sessionFile = await createTempSessionFile(); - const before = await fs.stat(sessionFile); - const release = vi.fn(async () => {}); - const controller = await createEmbeddedAttemptSessionLockController({ - acquireSessionWriteLock: vi.fn(async () => ({ release })), - lockOptions: { ...lockOptions, sessionFile }, - }); - - await controller.releaseForPrompt(); - await fs.truncate(sessionFile, before.size - 1); - - await expect(controller.reacquireAfterPrompt()).rejects.toBeInstanceOf( - EmbeddedAttemptSessionTakeoverError, - ); - expect(controller.hasSessionTakeover()).toBe(true); - }); - - it("fails closed when a metadata-only snapshot is too large to verify", async () => { - const sessionFile = await createTempSessionFile(); - await fs.truncate(sessionFile, 32 * 1024 * 1024 + 1); - const oldTime = new Date("2020-01-01T00:00:00.000Z"); - await fs.utimes(sessionFile, oldTime, oldTime); - const before = await fs.stat(sessionFile, { bigint: true }); - const { controller } = await createMockSessionLockController(sessionFile); - - await controller.releaseForPrompt(); - const newTime = new Date("2021-01-01T00:00:00.000Z"); - await fs.utimes(sessionFile, newTime, newTime); - const after = await fs.stat(sessionFile, { bigint: true }); - - expect(after.dev).toBe(before.dev); - expect(after.ino).toBe(before.ino); - expect(after.size).toBe(before.size); - expect(after.mtimeNs).not.toBe(before.mtimeNs); - await expect(controller.reacquireAfterPrompt()).rejects.toBeInstanceOf( - EmbeddedAttemptSessionTakeoverError, - ); - expect(controller.hasSessionTakeover()).toBe(true); - }); - - it("still rejects external edits after the prompt stream lock is reacquired", async () => { - const sessionFile = await createTempSessionFile(); - const { - controller, - release, - acquire: acquireSessionWriteLockLocal5, - } = await createMockSessionLockController(sessionFile); - - await controller.releaseForPrompt(); - await controller.reacquireAfterPrompt(); - await fs.appendFile( - sessionFile, - '{"type":"message","id":"external-after-reacquire"}\n', - "utf8", - ); - - const cleanupLock = await controller.acquireForCleanup(); - await cleanupLock.release(); - - expect(controller.hasSessionTakeover()).toBe(true); - expect(acquireSessionWriteLockLocal5).toHaveBeenCalledTimes(2); - expect(release).toHaveBeenCalledTimes(2); - }); - - it("refreshes the prompt fence after an owned transcript mirror append", async () => { - const sessionFile = await createTempSessionFile(); - const { - controller, - release, - acquire: acquireSessionWriteLockLocal4, - } = await createMockSessionLockController(sessionFile); - - await controller.releaseForPrompt(); - await withOwnedSessionTranscriptWrites( - { - sessionFile, - sessionKey: "agent:main:discord:channel:123", - withSessionWriteLock: (operation) => controller.withSessionWriteLock(operation), - }, - async () => - await runWithOwnedSessionTranscriptWriteLock( - { sessionFile, sessionKey: "agent:main:discord:channel:123" }, - async () => { - await fs.appendFile(sessionFile, '{"type":"message","id":"delivery-mirror"}\n', "utf8"); - }, - ), - ); - await expect(controller.withSessionWriteLock(() => "finalize")).resolves.toBe("finalize"); - - expect(controller.hasSessionTakeover()).toBe(false); - expect(acquireSessionWriteLockLocal4).toHaveBeenCalledTimes(3); - expect(release).toHaveBeenCalledTimes(3); - }); - - it("authorizes entry-cache advancement only under the exact trusted file lock", async () => { - const sessionFile = await createTempSessionFile(); - const { controller } = await createMockSessionLockController(sessionFile); - - await controller.releaseForPrompt(); - const stat = await fs.stat(sessionFile, { bigint: true }); - const snapshot = { - dev: stat.dev, - ino: stat.ino, - size: stat.size, - mtimeNs: stat.mtimeNs, - ctimeNs: stat.ctimeNs, - }; - - expect(controller.canAdvanceSessionEntryCache(snapshot)).toBe(false); await expect( controller.withSessionWriteLock(() => { - expect(controller.canAdvanceSessionEntryCache(snapshot)).toBe(true); - return "locked"; + void controller.withSessionWriteLock(() => { + throw nestedError; + }); }), - ).resolves.toBe("locked"); - expect(controller.canAdvanceSessionEntryCache(snapshot)).toBe(false); + ).rejects.toBe(nestedError); }); - it("publishes an exact owned snapshot only while the write lock is active", async () => { - const sessionFile = await createTempSessionFile(); + it("waits for every detached nested write before propagating failure", async () => { + const nestedError = new Error("nested write failed"); + let releaseSlowWrite!: () => void; + const slowWriteBlocked = new Promise((resolve) => { + releaseSlowWrite = resolve; + }); + const slowWriteStarted = vi.fn(); const controller = await createEmbeddedAttemptSessionLockController({ - acquireSessionWriteLock: vi.fn(async () => ({ - release: vi.fn(async () => {}), - })), - lockOptions: { ...lockOptions, sessionFile }, + acquireSessionWriteLock: vi.fn(async () => ({ release: async () => undefined })), + lockOptions: { sessionFile: "agent:main:main" }, }); - const readSnapshot = async () => { - const stat = await fs.stat(sessionFile, { bigint: true }); - return { - dev: stat.dev, - ino: stat.ino, - size: stat.size, - mtimeNs: stat.mtimeNs, - ctimeNs: stat.ctimeNs, - }; - }; - const initialSnapshot = await readSnapshot(); - - expect(controller.publishOwnedSessionFileSnapshot(initialSnapshot)).toBe(false); - await controller.withSessionWriteLock(async () => { - expect(controller.publishOwnedSessionFileSnapshot(initialSnapshot)).toBe(true); - await fs.appendFile(sessionFile, '{"type":"message","id":"owned"}\n', "utf8"); - expect(controller.publishOwnedSessionFileSnapshot(initialSnapshot)).toBe(false); - expect(controller.publishOwnedSessionFileSnapshot(await readSnapshot())).toBe(true); - }); - expect(controller.publishOwnedSessionFileSnapshot(await readSnapshot())).toBe(false); - await controller.dispose(); - }); - - it("publishes only an unchanged repair-validated snapshot while retaining the lock", async () => { - const sessionFile = await createTempSessionFile(); - const acquireSessionWriteLockLocal = vi.fn(async () => ({ - release: vi.fn(async () => {}), - })); - const controller = await createEmbeddedAttemptSessionLockController({ - acquireSessionWriteLock: acquireSessionWriteLockLocal, - lockOptions: { ...lockOptions, sessionFile }, - }); - const stat = await fs.stat(sessionFile, { bigint: true }); - const snapshot = { - dev: stat.dev, - ino: stat.ino, - size: stat.size, - mtimeNs: stat.mtimeNs, - ctimeNs: stat.ctimeNs, - }; - - expect(controller.publishValidatedSessionFileSnapshot(snapshot)).toBe(true); - await fs.appendFile(sessionFile, '{"type":"message","id":"external"}\n', "utf8"); - expect(controller.publishValidatedSessionFileSnapshot(snapshot)).toBe(false); - await controller.dispose(); - }); - - it("refreshes the prompt fence after an owned session manager append", async () => { - const sessionFile = await createTempSessionFile(); - const { controller } = await createMockSessionLockController(sessionFile); - - await controller.releaseForPrompt(); - await fs.appendFile(sessionFile, '{"type":"message","id":"owned-session-manager"}\n', "utf8"); - controller.refreshAfterOwnedSessionWrite(); - - await expect(controller.withSessionWriteLock(() => "finalize")).resolves.toBe("finalize"); - expect(controller.hasSessionTakeover()).toBe(false); - }); - - it("refreshes the prompt fence after an owned session manager compaction append", async () => { - const sessionFile = await createTempSessionFile(); - const { controller } = await createMockSessionLockController(sessionFile); - const sessionManager = guardSessionManager(SessionManager.open(sessionFile), { - withCompactionPersistence: (append, validateAppend) => - controller.withOwnedSessionFileWrite(append, validateAppend), - }); - const firstKeptEntryId = sessionManager.appendMessage({ - role: "user", - content: "old question", - timestamp: 1, - }); - appendTestAssistantMessage(sessionManager, "old answer", 2, "session-lock-test"); - - await controller.releaseForPrompt(); - sessionManager.appendCompaction("threshold summary", firstKeptEntryId, 160_001); - - await expect(controller.withSessionWriteLock(() => "finalize")).resolves.toBe("finalize"); - expect(controller.hasSessionTakeover()).toBe(false); - }); - - it("persists compaction through the SQLite transcript owner without a JSONL append fence", async () => { - const dir = autoTempDirs.make("openclaw-attempt-sqlite-compaction-"); - const sessionId = "sqlite-compaction-session"; - const sessionKey = "sqlite-compaction"; - const storePath = path.join(dir, "openclaw-agent.sqlite"); - await upsertSessionEntry( - { agentId: "main", sessionKey, storePath }, - { - sessionId, - chatType: "direct", - spawnedCwd: dir, - updatedAt: Date.now(), - }, - ); - const sessionFile = formatSqliteSessionFileMarker({ agentId: "main", sessionId, storePath }); - const controller = await createEmbeddedAttemptSessionLockController({ - acquireSessionWriteLock, - lockOptions: { ...lockOptions, sessionFile }, - }); - const sessionManager = guardSessionManager(SessionManager.open(sessionFile)); - const firstKeptEntryId = sessionManager.appendMessage({ - role: "user", - content: "old question", - timestamp: 1, - }); - - await controller.releaseForPrompt(); - sessionManager.appendCompaction("sqlite summary", firstKeptEntryId, 160_001); - - expect(await loadTranscriptEvents({ sessionId, sessionKey, storePath })).toContainEqual( - expect.objectContaining({ type: "compaction", summary: "sqlite summary" }), - ); - await expect(controller.withSessionWriteLock(() => "finalize")).resolves.toBe("finalize"); - expect(controller.hasSessionTakeover()).toBe(false); - }); - - it("still rejects unowned external compaction appends before the prompt stream lock is reacquired", async () => { - const sessionFile = await createTempSessionFile(); - const { controller } = await createMockSessionLockController(sessionFile); - - await controller.releaseForPrompt(); - await fs.appendFile( - sessionFile, - JSON.stringify({ - type: "compaction", - id: "external-compaction", - parentId: "session", - timestamp: new Date().toISOString(), - summary: "external summary", - firstKeptEntryId: "session", - tokensBefore: 160_001, - }) + "\n", - "utf8", - ); - - await expect(controller.withSessionWriteLock(() => "finalize")).rejects.toBeInstanceOf( - EmbeddedAttemptSessionTakeoverError, - ); - expect(controller.hasSessionTakeover()).toBe(true); - }); - - it("still rejects an external edit that happens before an owned session manager compaction append", async () => { - const sessionFile = await createTempSessionFile(); - const { controller } = await createMockSessionLockController(sessionFile); - const sessionManager = guardSessionManager(SessionManager.open(sessionFile), { - withCompactionPersistence: (append, validateAppend) => - controller.withOwnedSessionFileWrite(append, validateAppend), - }); - const firstKeptEntryId = sessionManager.appendMessage({ - role: "user", - content: "old question", - timestamp: 1, - }); - appendTestAssistantMessage(sessionManager, "old answer", 2, "session-lock-test"); - - await controller.releaseForPrompt(); - await fs.appendFile(sessionFile, '{"type":"message","id":"external-edit"}\n', "utf8"); - sessionManager.appendCompaction("threshold summary", firstKeptEntryId, 160_001); - - await expect(controller.withSessionWriteLock(() => "finalize")).rejects.toBeInstanceOf( - EmbeddedAttemptSessionTakeoverError, - ); - expect(controller.hasSessionTakeover()).toBe(true); - }); - - it("still rejects an external edit interleaved inside an owned session manager compaction append", async () => { - const sessionFile = await createTempSessionFile(); - const { controller } = await createMockSessionLockController(sessionFile); - const sessionManager = guardSessionManager(SessionManager.open(sessionFile), { - withCompactionPersistence: (append, validateAppend) => - controller.withOwnedSessionFileWrite(() => { - appendFileSync(sessionFile, '{"type":"message","id":"external-edit"}\n', "utf8"); - return append(); - }, validateAppend), - }); - const firstKeptEntryId = sessionManager.appendMessage({ - role: "user", - content: "old question", - timestamp: 1, - }); - appendTestAssistantMessage(sessionManager, "old answer", 2, "session-lock-test"); - - await controller.releaseForPrompt(); - sessionManager.appendCompaction("threshold summary", firstKeptEntryId, 160_001); - - await expect(controller.withSessionWriteLock(() => "finalize")).rejects.toBeInstanceOf( - EmbeddedAttemptSessionTakeoverError, - ); - expect(controller.hasSessionTakeover()).toBe(true); - }); - - it("allows owned session manager compaction after a later controller advances the prompt fence", async () => { - const sessionFile = await createTempSessionFile(); - const firstController = await createEmbeddedAttemptSessionLockController({ - acquireSessionWriteLock: vi.fn(async () => ({ release: vi.fn(async () => {}) })), - lockOptions: { ...lockOptions, sessionFile }, - }); - await firstController.releaseForPrompt(); - await firstController.dispose(); - - const release = vi.fn(async () => {}); - const acquireSessionWriteLockLocal29 = vi.fn(async () => ({ release })); - const secondController = await createEmbeddedAttemptSessionLockController({ - acquireSessionWriteLock: acquireSessionWriteLockLocal29, - lockOptions: { ...lockOptions, sessionFile }, - }); - const sessionManager = guardSessionManager(SessionManager.open(sessionFile), { - withCompactionPersistence: (append, validateAppend) => - secondController.withOwnedSessionFileWrite(append, validateAppend), - }); - const firstKeptEntryId = await secondController.withSessionWriteLock(() => { - const entryId = sessionManager.appendMessage({ - role: "user", - content: "new question", - timestamp: 1, + const completion = controller.withSessionWriteLock(() => { + void controller.withSessionWriteLock(() => { + throw nestedError; + }); + void controller.withSessionWriteLock(async () => { + slowWriteStarted(); + await slowWriteBlocked; }); - appendTestAssistantMessage(sessionManager, "new answer", 2, "session-lock-test"); - return entryId; }); - - await secondController.releaseForPrompt(); - sessionManager.appendCompaction("threshold summary", firstKeptEntryId, 160_001); - - await expect(secondController.withSessionWriteLock(() => "finalize")).resolves.toBe("finalize"); - expect(secondController.hasSessionTakeover()).toBe(false); - }); - - it("allows owned session manager compaction after another controller publishes an owned write", async () => { - const sessionFile = await createTempSessionFile(); - const firstController = await createEmbeddedAttemptSessionLockController({ - acquireSessionWriteLock: vi.fn(async () => ({ release: vi.fn(async () => {}) })), - lockOptions: { ...lockOptions, sessionFile }, - }); - const sessionManager = guardSessionManager(SessionManager.open(sessionFile), { - withCompactionPersistence: (append, validateAppend) => - firstController.withOwnedSessionFileWrite(append, validateAppend), - }); - const firstKeptEntryId = sessionManager.appendMessage({ - role: "user", - content: "old question", - timestamp: 1, - }); - appendTestAssistantMessage(sessionManager, "old answer", 2, "session-lock-test"); - await firstController.releaseForPrompt(); - - const secondController = await createEmbeddedAttemptSessionLockController({ - acquireSessionWriteLock: vi.fn(async () => ({ release: vi.fn(async () => {}) })), - lockOptions: { ...lockOptions, sessionFile }, - }); - await secondController.releaseForPrompt(); - await secondController.withSessionWriteLock( - async () => { - await fs.appendFile(sessionFile, '{"type":"message","id":"owned-other"}\n', "utf8"); + let settled = false; + void completion.then( + () => { + settled = true; }, - { publishOwnedWrite: true }, - ); - sessionManager.appendCompaction("threshold summary", firstKeptEntryId, 160_001); - - await expect(firstController.withSessionWriteLock(() => "finalize")).resolves.toBe("finalize"); - expect(firstController.hasSessionTakeover()).toBe(false); - }); - - it("validates first-turn exact assistant appends through the production facade", async () => { - const dir = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-attempt-session-facade-")); - tempDirs.push(dir); - const sessionId = "facade-session"; - const sessionKey = "facade"; - const storePath = path.join(dir, "sessions.json"); - const sessionFile = resolveSessionTranscriptPathInDir(sessionId, dir); - await upsertSessionEntry( - { agentId: "main", sessionKey, storePath }, - { - sessionId, - chatType: "direct", - delivery: normalizeSessionDeliveryState({ context: { channel: "discord" } }), - spawnedCwd: dir, - updatedAt: Date.now(), + () => { + settled = true; }, ); - const mergePromptReleasedSessionEntries = vi.fn(); + await vi.waitFor(() => expect(slowWriteStarted).toHaveBeenCalledOnce()); + + expect(settled).toBe(false); + releaseSlowWrite(); + await expect(completion).rejects.toBe(nestedError); + }); + + it("preserves the callback error when a handled nested write also failed", async () => { + const primaryError = new Error("outer write failed"); + const nestedError = new Error("nested write failed"); const controller = await createEmbeddedAttemptSessionLockController({ - acquireSessionWriteLock, - lockOptions: { ...lockOptions, sessionFile }, - mergePromptReleasedSessionEntries, + acquireSessionWriteLock: vi.fn(async () => ({ release: async () => undefined })), + lockOptions: { sessionFile: "agent:main:main" }, }); - await controller.releaseForPrompt(); - const result = await withOwnedSessionTranscriptWrites( - { - sessionFile, - sessionKey, - withSessionWriteLock: (operation, options) => - controller.withSessionWriteLock(operation, options), - }, - async () => - await appendExactAssistantMessageToSessionTranscript({ - agentId: "main", - sessionKey, - storePath, - message: { - role: "assistant", - content: [{ type: "text", text: "first-turn delivery" }], - api: OPENCLAW_TRANSCRIPT_ARTIFACT_API, - provider: "openclaw", - model: "delivery-mirror", - usage: { - input: 0, - output: 0, - cacheRead: 0, - cacheWrite: 0, - totalTokens: 0, - cost: { - input: 0, - output: 0, - cacheRead: 0, - cacheWrite: 0, - total: 0, - }, - }, - stopReason: "stop", - timestamp: Date.now(), - }, - }), - ); - - expect(result.ok).toBe(true); - const events = await loadTranscriptEvents({ sessionId, sessionKey, storePath }); - expect(events).toContainEqual( - expect.objectContaining({ - message: expect.objectContaining({ - content: [{ type: "text", text: "first-turn delivery" }], - role: "assistant", - }), + await expect( + controller.withSessionWriteLock(async () => { + await controller + .withSessionWriteLock(() => { + throw nestedError; + }) + .catch(() => undefined); + throw primaryError; }), - ); - expect(mergePromptReleasedSessionEntries).not.toHaveBeenCalled(); - expect(controller.hasSessionTakeover()).toBe(false); - await controller.dispose(); + ).rejects.toBe(primaryError); + expect(primaryError.cause).toBe(nestedError); }); - it("serializes concurrent nested owned transcript publications", async () => { - const sessionFile = await createTempSessionFile(); - const mergedIds: string[] = []; + it("preserves a frozen callback error when nested drain also failed", async () => { + const primaryError = new Error("outer write failed"); + Object.freeze(primaryError); const controller = await createEmbeddedAttemptSessionLockController({ - acquireSessionWriteLock, - lockOptions: { ...lockOptions, sessionFile }, - mergePromptReleasedSessionEntries: (entries) => { - for (const entry of entries) { - if (entry.type === "prompt_released_opaque") { - continue; - } - if (mergedIds.includes(entry.id)) { - throw new Error(`duplicate merged entry ${entry.id}`); - } - mergedIds.push(entry.id); - } - }, - }); - await controller.releaseForPrompt(); - - const firstWriteCanFinish = createDeferred(); - const firstWriteStarted = createDeferred(); - const appendOwnedAssistant = async (id: string): Promise => { - await fs.appendFile( - sessionFile, - `${JSON.stringify({ - type: "message", - id, - parentId: null, - timestamp: new Date().toISOString(), - message: { - role: "assistant", - provider: "anthropic", - model: "sonnet-4.6", - }, - })}\n`, - "utf8", - ); - return id; - }; - - await controller.withSessionWriteLock(async () => { - const firstWrite = controller.withSessionWriteLock( - async () => { - firstWriteStarted.resolve(); - await firstWriteCanFinish.promise; - return await appendOwnedAssistant("owned-first"); - }, - { - publishOwnedWrite: true, - resolvePublishedEntries: (id) => [{ kind: "id", id }], - }, - ); - await firstWriteStarted.promise; - const secondWrite = controller.withSessionWriteLock( - () => appendOwnedAssistant("owned-second"), - { - publishOwnedWrite: true, - resolvePublishedEntries: (id) => [{ kind: "id", id }], - }, - ); - firstWriteCanFinish.resolve(); - await Promise.all([firstWrite, secondWrite]); + acquireSessionWriteLock: vi.fn(async () => ({ release: async () => undefined })), + lockOptions: { sessionFile: "agent:main:main" }, }); - expect(mergedIds).toEqual(["owned-first", "owned-second"]); - expect(controller.hasSessionTakeover()).toBe(false); - await controller.dispose(); + await expect( + controller.withSessionWriteLock(async () => { + await controller + .withSessionWriteLock(() => { + throw new Error("nested write failed"); + }) + .catch(() => undefined); + throw primaryError; + }), + ).rejects.toBe(primaryError); }); - it("drains unawaited nested publications before publishing the parent fence", async () => { - const sessionFile = await createTempSessionFile(); - const mergedIds: string[] = []; + it("does not attach a propagated nested error as its own cause", async () => { + const nestedError = new Error("nested write failed"); const controller = await createEmbeddedAttemptSessionLockController({ - acquireSessionWriteLock, - lockOptions: { ...lockOptions, sessionFile }, - mergePromptReleasedSessionEntries: (entries) => { - for (const entry of entries) { - if (entry.type !== "prompt_released_opaque") { - mergedIds.push(entry.id); - } - } + acquireSessionWriteLock: vi.fn(async () => ({ release: async () => undefined })), + lockOptions: { sessionFile: "agent:main:main" }, + }); + + await expect( + controller.withSessionWriteLock(async () => { + await controller.withSessionWriteLock(() => { + throw nestedError; + }); + }), + ).rejects.toBe(nestedError); + expect(nestedError.cause).toBeUndefined(); + }); + + it("terminally fences writes after a generic prompt reload failure", async () => { + const reloadError = new Error("reload failed"); + const run = vi.fn(); + const controller = await createEmbeddedAttemptSessionLockController({ + acquireSessionWriteLock: vi.fn(async () => ({ release: async () => undefined })), + lockOptions: { sessionFile: "agent:main:main" }, + reloadPromptReleasedSessionFile: () => { + throw reloadError; }, }); - await controller.releaseForPrompt(); - const nestedWriteCanFinish = createDeferred(); - const nestedWriteStarted = createDeferred(); - let parentSettled = false; - const parentWrite = controller - .withSessionWriteLock( - async () => { - await fs.appendFile( - sessionFile, - `${JSON.stringify({ - type: "message", - id: "parent-publication", - parentId: null, - timestamp: new Date().toISOString(), - message: { - role: "assistant", - provider: "anthropic", - model: "sonnet-4.6", - }, - })}\n`, - "utf8", - ); - void controller.withSessionWriteLock( - async () => { - nestedWriteStarted.resolve(); - await nestedWriteCanFinish.promise; - await controller.withSessionWriteLock( - async () => { - await fs.appendFile( - sessionFile, - `${JSON.stringify({ - type: "message", - id: "unawaited-nested", - parentId: null, - timestamp: new Date().toISOString(), - message: { - role: "assistant", - provider: "anthropic", - model: "sonnet-4.6", - }, - })}\n`, - "utf8", - ); - return "unawaited-nested"; - }, - { - publishOwnedWrite: true, - resolvePublishedEntries: (id) => [{ kind: "id", id }], - }, - ); - }, - { publishOwnedWrite: true }, - ); - await nestedWriteStarted.promise; - return "parent-publication"; - }, - { - publishOwnedWrite: true, - resolvePublishedEntries: (id) => [{ kind: "id", id }], - }, - ) - .finally(() => { - parentSettled = true; + await expect(controller.reacquireAfterPrompt()).rejects.toBe(reloadError); + await expect(controller.withSessionWriteLock(run)).rejects.toBe(reloadError); + await controller.releaseForPrompt(); + await expect(controller.reacquireAfterPrompt()).rejects.toBe(reloadError); + await expect(controller.acquireForCleanup()).resolves.toBeDefined(); + + expect(run).not.toHaveBeenCalled(); + expect(controller.hasSessionTakeover()).toBe(false); + }); + + it("marks only takeover reload failures as session takeover", async () => { + const takeoverError = new EmbeddedAttemptSessionTakeoverError("agent:main:main"); + const controller = await createEmbeddedAttemptSessionLockController({ + acquireSessionWriteLock: vi.fn(async () => ({ release: async () => undefined })), + lockOptions: { sessionFile: "agent:main:main" }, + reloadPromptReleasedSessionFile: () => { + throw takeoverError; + }, + }); + + await expect(controller.reacquireAfterPrompt()).rejects.toBe(takeoverError); + + expect(controller.hasSessionTakeover()).toBe(true); + }); + + it("does not wait on a stalled reload after the disposal timeout", async () => { + vi.useFakeTimers(); + try { + const controller = await createEmbeddedAttemptSessionLockController({ + acquireSessionWriteLock: vi.fn(async () => ({ release: async () => undefined })), + lockOptions: { sessionFile: "agent:main:main" }, + reloadPromptReleasedSessionFile: async () => await new Promise(() => {}), }); - await nestedWriteStarted.promise; - await new Promise((resolve) => { - setImmediate(resolve); - }); - const settledBeforeNestedWrite = parentSettled; - nestedWriteCanFinish.resolve(); - await parentWrite; + await controller.releaseForPrompt(); + void controller.reacquireAfterPrompt(); + await Promise.resolve(); + const disposal = controller.dispose(); + await vi.advanceTimersByTimeAsync(5_000); - expect(settledBeforeNestedWrite).toBe(false); - expect(mergedIds).toEqual(["parent-publication", "unawaited-nested"]); - await expect(controller.withSessionWriteLock(() => "next write")).resolves.toBe("next write"); - expect(controller.hasSessionTakeover()).toBe(false); - await controller.dispose(); - }); - - it("drains unawaited publications started from an ordinary lock scope", async () => { - const sessionFile = await createTempSessionFile(); - const mergedIds: string[] = []; - const controller = await createEmbeddedAttemptSessionLockController({ - acquireSessionWriteLock, - lockOptions: { ...lockOptions, sessionFile }, - mergePromptReleasedSessionEntries: (entries) => { - for (const entry of entries) { - if (entry.type !== "prompt_released_opaque") { - mergedIds.push(entry.id); - } - } - }, - }); - await controller.releaseForPrompt(); - - const publicationCanFinish = createDeferred(); - const publicationStarted = createDeferred(); - let outerSettled = false; - const outerWrite = controller - .withSessionWriteLock(async () => { - void controller.withSessionWriteLock( - async () => { - publicationStarted.resolve(); - await publicationCanFinish.promise; - await fs.appendFile( - sessionFile, - `${JSON.stringify({ - type: "message", - id: "ordinary-scope-publication", - parentId: null, - timestamp: new Date().toISOString(), - message: { - role: "assistant", - provider: "anthropic", - model: "sonnet-4.6", - }, - })}\n`, - "utf8", - ); - return "ordinary-scope-publication"; - }, - { - publishOwnedWrite: true, - resolvePublishedEntries: (id) => [{ kind: "id", id }], - }, - ); - await publicationStarted.promise; - }) - .finally(() => { - outerSettled = true; - }); - - await publicationStarted.promise; - await new Promise((resolve) => { - setImmediate(resolve); - }); - const settledBeforePublication = outerSettled; - publicationCanFinish.resolve(); - await outerWrite; - - expect(settledBeforePublication).toBe(false); - expect(mergedIds).toEqual(["ordinary-scope-publication"]); - expect(controller.hasSessionTakeover()).toBe(false); - await controller.dispose(); - }); - - it("reacquires the lock for inherited publications that start during fence merge", async () => { - const sessionFile = await createTempSessionFile(); - const mergedIds: string[] = []; - const firstMergeStarted = createDeferred(); - const firstMergeCanFinish = createDeferred(); - let mergeCount = 0; - const controller = await createEmbeddedAttemptSessionLockController({ - acquireSessionWriteLock, - lockOptions: { ...lockOptions, sessionFile }, - mergePromptReleasedSessionEntries: async (entries) => { - mergeCount += 1; - if (mergeCount === 1) { - firstMergeStarted.resolve(); - await firstMergeCanFinish.promise; - } - for (const entry of entries) { - if (entry.type !== "prompt_released_opaque") { - mergedIds.push(entry.id); - } - } - }, - }); - await controller.releaseForPrompt(); - - const appendOwnedAssistant = async (id: string): Promise => { - await fs.appendFile( - sessionFile, - `${JSON.stringify({ - type: "message", - id, - parentId: null, - timestamp: new Date().toISOString(), - message: { - role: "assistant", - provider: "anthropic", - model: "sonnet-4.6", - }, - })}\n`, - "utf8", + await expect(disposal).resolves.toBeUndefined(); + await expect(controller.releaseForPrompt()).rejects.toThrow( + "attempt disposed before prompt submission", ); - return id; - }; - - let lateWriteRan = false; - let lateWrite!: Promise; - const parentWrite = controller.withSessionWriteLock( - async () => { - lateWrite = (async () => { - await firstMergeStarted.promise; - return await controller.withSessionWriteLock( - async () => { - lateWriteRan = true; - return await appendOwnedAssistant("late-inherited"); - }, - { - publishOwnedWrite: true, - resolvePublishedEntries: (id) => [{ kind: "id", id }], - }, - ); - })(); - return await appendOwnedAssistant("parent-publication"); - }, - { - publishOwnedWrite: true, - resolvePublishedEntries: (id) => [{ kind: "id", id }], - }, - ); - - await firstMergeStarted.promise; - await new Promise((resolve) => { - setImmediate(resolve); - }); - expect(lateWriteRan).toBe(false); - firstMergeCanFinish.resolve(); - await expect(parentWrite).resolves.toBe("parent-publication"); - await expect(lateWrite).resolves.toBe("late-inherited"); - - expect(mergedIds).toEqual(["parent-publication", "late-inherited"]); - expect(controller.hasSessionTakeover()).toBe(false); - await controller.dispose(); - }); - - it("rejects interleaved assistant rows and stops already-queued publications", async () => { - const sessionFile = await createTempSessionFile(); - const mergePromptReleasedSessionEntries = vi.fn(); - const controller = await createEmbeddedAttemptSessionLockController({ - acquireSessionWriteLock, - lockOptions: { ...lockOptions, sessionFile }, - mergePromptReleasedSessionEntries, - }); - await controller.releaseForPrompt(); - - const firstWriteCanFinish = createDeferred(); - const firstWriteStarted = createDeferred(); - let secondWriteRan = false; - const appendAssistant = async (id: string): Promise => { - await fs.appendFile( - sessionFile, - `${JSON.stringify({ - type: "message", - id, - parentId: null, - timestamp: new Date().toISOString(), - message: { - role: "assistant", - provider: "anthropic", - model: "sonnet-4.6", - }, - })}\n`, - "utf8", - ); - return id; - }; - - await expect( - controller.withSessionWriteLock(async () => { - const firstWrite = controller.withSessionWriteLock( - async () => { - firstWriteStarted.resolve(); - await firstWriteCanFinish.promise; - await appendAssistant("interleaved-external"); - return await appendAssistant("owned-entry"); - }, - { - publishOwnedWrite: true, - resolvePublishedEntries: (id) => [{ kind: "id", id }], - }, - ); - await firstWriteStarted.promise; - const secondWrite = controller.withSessionWriteLock( - async () => { - secondWriteRan = true; - return await appendAssistant("queued-entry"); - }, - { - publishOwnedWrite: true, - resolvePublishedEntries: (id) => [{ kind: "id", id }], - }, - ); - firstWriteCanFinish.resolve(); - const results = await Promise.allSettled([firstWrite, secondWrite]); - expect(results.map((result) => result.status)).toEqual(["rejected", "rejected"]); - }), - ).rejects.toBeInstanceOf(EmbeddedAttemptSessionTakeoverError); - - expect(secondWriteRan).toBe(false); - expect(mergePromptReleasedSessionEntries).not.toHaveBeenCalled(); - expect(controller.hasSessionTakeover()).toBe(true); - await controller.dispose(); - }); - - it("preserves an outer failure when a nested publication detects takeover", async () => { - const sessionFile = await createTempSessionFile(); - const outerFailure = new Error("outer operation failed"); - const controller = await createEmbeddedAttemptSessionLockController({ - acquireSessionWriteLock, - lockOptions: { ...lockOptions, sessionFile }, - mergePromptReleasedSessionEntries: vi.fn(), - }); - await controller.releaseForPrompt(); - - await expect( - controller.withSessionWriteLock(async () => { - const nestedPublication = controller.withSessionWriteLock( - async () => { - await fs.appendFile(sessionFile, '{"type":"message","id":"external"}\n', "utf8"); - return "owned"; - }, - { - publishOwnedWrite: true, - resolvePublishedEntries: (id) => [{ kind: "id", id }], - }, - ); - const [nestedResult] = await Promise.allSettled([nestedPublication]); - expect(nestedResult.status).toBe("rejected"); - throw outerFailure; - }), - ).rejects.toBe(outerFailure); - expect(controller.hasSessionTakeover()).toBe(true); - await controller.dispose(); - }); - - it("deactivates publication contexts inherited by delayed descendants", async () => { - const sessionFile = await createTempSessionFile(); - const acquireSessionWriteLockLocal = vi.fn(async () => ({ - release: vi.fn(async () => {}), - })); - const controller = await createEmbeddedAttemptSessionLockController({ - acquireSessionWriteLock: acquireSessionWriteLockLocal, - lockOptions: { ...lockOptions, sessionFile }, - mergePromptReleasedSessionEntries: vi.fn(), - }); - await controller.releaseForPrompt(); - - const delayedWriteCanStart = createDeferred(); - let delayedWrite!: Promise; - await controller.withSessionWriteLock( - async () => { - delayedWrite = delayedWriteCanStart.promise.then(() => - controller.withSessionWriteLock(() => "delayed-write"), - ); - await fs.appendFile( - sessionFile, - `${JSON.stringify({ - type: "message", - id: "publication-entry", - parentId: null, - timestamp: new Date().toISOString(), - message: { - role: "assistant", - provider: "anthropic", - model: "sonnet-4.6", - }, - })}\n`, - "utf8", - ); - return "publication-entry"; - }, - { - publishOwnedWrite: true, - resolvePublishedEntries: (id) => [{ kind: "id", id }], - }, - ); - - delayedWriteCanStart.resolve(); - await expect(delayedWrite).resolves.toBe("delayed-write"); - expect(acquireSessionWriteLockLocal).toHaveBeenCalledTimes(3); - expect(controller.hasSessionTakeover()).toBe(false); - await controller.dispose(); - }); - - it("rejects external edits interleaved while another controller holds cleanup lock", async () => { - const sessionFile = await createTempSessionFile(); - const releases: string[] = []; - const acquireSessionWriteLockInner = vi.fn(async () => ({ - release: vi.fn(async () => { - releases.push("release"); - }), - })); - const firstController = await createEmbeddedAttemptSessionLockController({ - acquireSessionWriteLock: acquireSessionWriteLockInner, - lockOptions: { ...lockOptions, sessionFile }, - }); - - await firstController.releaseForPrompt(); - - const secondController = await createEmbeddedAttemptSessionLockController({ - acquireSessionWriteLock: acquireSessionWriteLockInner, - lockOptions: { ...lockOptions, sessionFile }, - }); - await secondController.releaseForPrompt(); - const cleanupLock = await secondController.acquireForCleanup(); - - await fs.appendFile(sessionFile, '{"type":"message","id":"external-cleanup"}\n', "utf8"); - await cleanupLock.release(); - - await expect( - firstController.withSessionWriteLock(async () => { - await fs.appendFile(sessionFile, '{"type":"message","id":"late"}\n', "utf8"); - }), - ).rejects.toBeInstanceOf(EmbeddedAttemptSessionTakeoverError); - - expect(firstController.hasSessionTakeover()).toBe(true); - expect(acquireSessionWriteLockInner).toHaveBeenCalledTimes(4); - expect(releases).toEqual(["release", "release", "release", "release"]); - }); - - it("rejects external edits interleaved during a broad same-process locked callback", async () => { - const sessionFile = await createTempSessionFile(); - const releases: string[] = []; - const acquireSessionWriteLockItem = vi.fn(async () => ({ - release: vi.fn(async () => { - releases.push("release"); - }), - })); - const firstController = await createEmbeddedAttemptSessionLockController({ - acquireSessionWriteLock: acquireSessionWriteLockItem, - lockOptions: { ...lockOptions, sessionFile }, - }); - - await firstController.releaseForPrompt(); - - const secondController = await createEmbeddedAttemptSessionLockController({ - acquireSessionWriteLock: acquireSessionWriteLockItem, - lockOptions: { ...lockOptions, sessionFile }, - }); - await secondController.withSessionWriteLock(async () => { - await fs.appendFile(sessionFile, '{"type":"message","id":"same-process"}\n', "utf8"); - await fs.appendFile(sessionFile, '{"type":"message","id":"external-interleaved"}\n', "utf8"); - }); - await secondController.releaseForPrompt(); - - await expect( - firstController.withSessionWriteLock(async () => { - await fs.appendFile(sessionFile, '{"type":"message","id":"late"}\n', "utf8"); - }), - ).rejects.toBeInstanceOf(EmbeddedAttemptSessionTakeoverError); - - expect(firstController.hasSessionTakeover()).toBe(true); - expect(acquireSessionWriteLockItem).toHaveBeenCalledTimes(3); - expect(releases).toEqual(["release", "release", "release"]); - }); - - it("rejects external session edits even when another controller releases for prompt afterward", async () => { - const sessionFile = await createTempSessionFile(); - const releases: string[] = []; - const acquireSessionWriteLockCandidate = vi.fn(async () => ({ - release: vi.fn(async () => { - releases.push("release"); - }), - })); - const firstController = await createEmbeddedAttemptSessionLockController({ - acquireSessionWriteLock: acquireSessionWriteLockCandidate, - lockOptions: { ...lockOptions, sessionFile }, - }); - - await firstController.releaseForPrompt(); - await fs.appendFile(sessionFile, '{"type":"message","id":"external"}\n', "utf8"); - - const secondController = await createEmbeddedAttemptSessionLockController({ - acquireSessionWriteLock: acquireSessionWriteLockCandidate, - lockOptions: { ...lockOptions, sessionFile }, - }); - await secondController.releaseForPrompt(); - - await expect( - firstController.withSessionWriteLock(async () => { - await fs.appendFile(sessionFile, '{"type":"message","id":"late"}\n', "utf8"); - }), - ).rejects.toBeInstanceOf(EmbeddedAttemptSessionTakeoverError); - - expect(firstController.hasSessionTakeover()).toBe(true); - expect(acquireSessionWriteLockCandidate).toHaveBeenCalledTimes(3); - expect(releases).toEqual(["release", "release", "release"]); - }); - - it("rejects external session edits even when another controller appends under lock afterward", async () => { - const sessionFile = await createTempSessionFile(); - const releases: string[] = []; - const acquireSessionWriteLockEntry = vi.fn(async () => ({ - release: vi.fn(async () => { - releases.push("release"); - }), - })); - const firstController = await createEmbeddedAttemptSessionLockController({ - acquireSessionWriteLock: acquireSessionWriteLockEntry, - lockOptions: { ...lockOptions, sessionFile }, - }); - - await firstController.releaseForPrompt(); - await fs.appendFile(sessionFile, '{"type":"message","id":"external"}\n', "utf8"); - - const secondController = await createEmbeddedAttemptSessionLockController({ - acquireSessionWriteLock: acquireSessionWriteLockEntry, - lockOptions: { ...lockOptions, sessionFile }, - }); - await secondController.withSessionWriteLock(async () => { - await fs.appendFile(sessionFile, '{"type":"message","id":"same-process"}\n', "utf8"); - }); - await secondController.releaseForPrompt(); - - await expect( - firstController.withSessionWriteLock(async () => { - await fs.appendFile(sessionFile, '{"type":"message","id":"late"}\n', "utf8"); - }), - ).rejects.toBeInstanceOf(EmbeddedAttemptSessionTakeoverError); - - expect(firstController.hasSessionTakeover()).toBe(true); - expect(acquireSessionWriteLockEntry).toHaveBeenCalledTimes(3); - expect(releases).toEqual(["release", "release", "release"]); - }); - - it("releases a retained post-prompt lock after takeover so the next inbound can acquire it", async () => { - const sessionFile = await createTempSessionFile(); - const options = { ...lockOptions, sessionFile, timeoutMs: 250 }; - const controller = await createEmbeddedAttemptSessionLockController({ - acquireSessionWriteLock, - lockOptions: options, - }); - - await controller.releaseForPrompt(); - await controller.reacquireAfterPrompt(); - await fs.appendFile(sessionFile, '{"type":"message","id":"external"}\n', "utf8"); - - await expect( - controller.withSessionWriteLock(async () => { - await fs.appendFile(sessionFile, '{"type":"message","id":"late"}\n', "utf8"); - }), - ).rejects.toBeInstanceOf(EmbeddedAttemptSessionTakeoverError); - expect(controller.hasSessionTakeover()).toBe(true); - - const nextController = await createEmbeddedAttemptSessionLockController({ - acquireSessionWriteLock, - lockOptions: options, - }); - await nextController.withSessionWriteLock(async () => { - await fs.appendFile(sessionFile, '{"type":"message","id":"next-inbound"}\n', "utf8"); - }); - await nextController.dispose(); - await controller.dispose(); - - const transcript = await fs.readFile(sessionFile, "utf8"); - expect(transcript).toContain('"id":"next-inbound"'); - expect(transcript).not.toContain('"id":"late"'); - }); - - it("waits for active retained writers before releasing a takeover lock", async () => { - const sessionFile = await createTempSessionFile(); - const events: string[] = []; - const activeWriteStarted = createDeferred(); - let releaseActiveWrite!: () => void; - const activeWriteCanFinish = new Promise((resolve) => { - releaseActiveWrite = () => { - events.push("active-finish"); - resolve(); - }; - }); - const releasePrep = vi.fn(async () => events.push("prep-release")); - const releaseRetained = vi.fn(async () => events.push("retained-release")); - const acquireSessionWriteLockLocal = vi - .fn() - .mockResolvedValueOnce({ release: releasePrep }) - .mockResolvedValueOnce({ release: releaseRetained }); - const controller = await createEmbeddedAttemptSessionLockController({ - acquireSessionWriteLock: acquireSessionWriteLockLocal, - lockOptions: { ...lockOptions, sessionFile }, - }); - - await controller.releaseForPrompt(); - await controller.reacquireAfterPrompt(); - const activeWriteResult = Promise.allSettled([ - controller.withSessionWriteLock(async () => { - events.push("active-start"); - activeWriteStarted.resolve(); - await activeWriteCanFinish; - }), - ]).then(([result]) => result); - await activeWriteStarted.promise; - await fs.appendFile(sessionFile, '{"type":"message","id":"external"}\n', "utf8"); - - let takeoverSettled = false; - const takeoverWriteResult = Promise.allSettled([ - controller.withSessionWriteLock(async () => { - events.push("late-write"); - }), - ]).then(([result]) => { - takeoverSettled = true; - return result; - }); - await waitUntil( - () => controller.hasSessionTakeover(), - "expected takeover detection while active retained writer was still running", - ); - - expect(takeoverSettled).toBe(false); - expect(releaseRetained).not.toHaveBeenCalled(); - - releaseActiveWrite(); - const [activeResult, takeoverResult] = await Promise.all([ - activeWriteResult, - takeoverWriteResult, - ]); - expect(activeResult.status).toBe("rejected"); - expect(takeoverResult.status).toBe("rejected"); - if (activeResult.status === "rejected") { - expect(activeResult.reason).toBeInstanceOf(EmbeddedAttemptSessionTakeoverError); + await expect(controller.reacquireAfterPrompt()).resolves.toBeUndefined(); + } finally { + vi.useRealTimers(); } - if (takeoverResult.status === "rejected") { - expect(takeoverResult.reason).toBeInstanceOf(EmbeddedAttemptSessionTakeoverError); - } - - expect(releaseRetained).toHaveBeenCalledTimes(1); - expect(events).toEqual(["prep-release", "active-start", "active-finish", "retained-release"]); - expect(acquireSessionWriteLockLocal).toHaveBeenCalledTimes(2); }); - it("releaseHeldLockWithFence sets deferred flag when bailed out during active scope; re-attempted after scope deactivation (#95915)", async () => { - const events: string[] = []; - const releasePrep = vi.fn(async () => events.push("prep-release")); - const releaseRetained = vi.fn(async () => events.push("retained-release")); - const acquireSessionWriteLockLocal = vi - .fn() - .mockResolvedValueOnce({ release: releasePrep }) - .mockResolvedValueOnce({ release: releaseRetained }); - - const controller = await createEmbeddedAttemptSessionLockController({ - acquireSessionWriteLock: acquireSessionWriteLockLocal, - lockOptions, - }); - - await controller.releaseForPrompt(); - await controller.reacquireAfterPrompt(); - - await controller.withSessionWriteLock(async () => { - events.push("write-start"); - await controller.releaseHeldLockForAbort(); - events.push("write-end"); - }); - - expect(events).toEqual(["prep-release", "write-start", "write-end", "retained-release"]); - expect(acquireSessionWriteLockLocal).toHaveBeenCalledTimes(2); - }); - - it("controls the held lock lifecycle across deferred abort release, reacquisition, and prompt release", async () => { - const events: string[] = []; - const acquireSessionWriteLockLocal = vi - .fn() - .mockResolvedValueOnce({ release: vi.fn(async () => events.push("init-release")) }) - .mockResolvedValueOnce({ release: vi.fn(async () => events.push("held-release")) }) - .mockResolvedValueOnce({ release: vi.fn(async () => events.push("reacquire-release")) }); - - const controller = await createEmbeddedAttemptSessionLockController({ - acquireSessionWriteLock: acquireSessionWriteLockLocal, - lockOptions, - }); - - await controller.releaseForPrompt(); - await controller.reacquireAfterPrompt(); - - await controller.withSessionWriteLock(async () => { - events.push("write"); - await controller.releaseHeldLockForAbort(); - }); - - expect(events).toEqual(["init-release", "write", "held-release"]); - - await controller.reacquireAfterPrompt(); - await controller.releaseForPrompt(); - - expect(events).toEqual(["init-release", "write", "held-release", "reacquire-release"]); - expect(acquireSessionWriteLockLocal).toHaveBeenCalledTimes(3); - }); - - it("releases a prompt reacquire that resolves after controller disposal", async () => { - const releaseInitial = vi.fn(async () => {}); - const releaseLate = vi.fn(async () => {}); - let resolveReacquiredLock!: (lock: { release(): Promise }) => void; - const reacquiredLock = new Promise<{ release(): Promise }>((resolve) => { - resolveReacquiredLock = resolve; - }); - const acquireSessionWriteLockLocal = vi - .fn() - .mockResolvedValueOnce({ release: releaseInitial }) - .mockImplementationOnce(async () => await reacquiredLock); - const controller = await createEmbeddedAttemptSessionLockController({ - acquireSessionWriteLock: acquireSessionWriteLockLocal, - lockOptions, - }); - - await controller.releaseForPrompt(); - const reacquire = controller.reacquireAfterPrompt(); - await waitUntil( - () => acquireSessionWriteLockLocal.mock.calls.length === 2, - "expected prompt cleanup to begin reacquiring the session lock", - ); - await controller.dispose(); - resolveReacquiredLock({ release: releaseLate }); - await reacquire; - - expect(releaseInitial).toHaveBeenCalledTimes(1); - expect(releaseLate).toHaveBeenCalledTimes(1); - }); - - it("waits for in-flight prompt reacquisition before cleanup adopts its lock", async () => { - const sessionFile = await createTempSessionFile(); - const releaseInitial = vi.fn(async () => {}); - const releaseReacquired = vi.fn(async () => {}); - const releaseUnexpected = vi.fn(async () => {}); - let finishReacquisition!: (lock: { release(): Promise }) => void; - const reacquisitionCanFinish = new Promise<{ release(): Promise }>((resolve) => { - finishReacquisition = resolve; - }); - const fenceValidationStarted = createDeferred(); - const fenceValidationCanFinish = createDeferred(); - const mergePromptReleasedSessionEntries = vi.fn(async () => { - fenceValidationStarted.resolve(); - await fenceValidationCanFinish.promise; - }); - const acquireSessionWriteLockLocal = vi - .fn() - .mockResolvedValueOnce({ release: releaseInitial }) - .mockImplementationOnce(async () => await reacquisitionCanFinish) - .mockResolvedValueOnce({ release: releaseUnexpected }); - const controller = await createEmbeddedAttemptSessionLockController({ - acquireSessionWriteLock: acquireSessionWriteLockLocal, - lockOptions: { ...lockOptions, sessionFile }, - mergePromptReleasedSessionEntries, - }); - - await controller.releaseForPrompt(); - await appendSessionTranscriptMessage({ - transcriptPath: sessionFile, - message: { - role: "assistant", - content: [{ type: "text", text: "delivery during prompt" }], - provider: "openclaw", - model: "delivery-mirror", - }, - }); - const reacquire = controller.reacquireAfterPrompt(); - await waitUntil( - () => acquireSessionWriteLockLocal.mock.calls.length === 2, - "expected prompt reacquisition to start", - ); - let cleanupSettled = false; - const cleanupLockPromise = controller.acquireForCleanup().then((lock) => { - cleanupSettled = true; - return lock; - }); - await new Promise((resolve) => { - setImmediate(resolve); - }); - - expect(cleanupSettled).toBe(false); - expect(acquireSessionWriteLockLocal).toHaveBeenCalledTimes(2); - finishReacquisition({ release: releaseReacquired }); - await fenceValidationStarted.promise; - await new Promise((resolve) => { - setImmediate(resolve); - }); - - expect(cleanupSettled).toBe(false); - expect(releaseReacquired).not.toHaveBeenCalled(); - expect(mergePromptReleasedSessionEntries).toHaveBeenCalledTimes(1); - - fenceValidationCanFinish.resolve(); - await reacquire; - const cleanupLock = await cleanupLockPromise; - expect(releaseReacquired).not.toHaveBeenCalled(); - await cleanupLock.release(); - - expect(acquireSessionWriteLockLocal).toHaveBeenCalledTimes(2); - expect(releaseInitial).toHaveBeenCalledTimes(1); - expect(releaseReacquired).toHaveBeenCalledTimes(1); - expect(releaseUnexpected).not.toHaveBeenCalled(); - expect(mergePromptReleasedSessionEntries).toHaveBeenCalledTimes(1); - expect(controller.hasSessionTakeover()).toBe(false); - }); - - it("does not retry cleanup acquisition after prompt reacquire lock contention", async () => { - const reacquireError = new SessionWriteLockTimeoutError({ - timeoutMs: lockOptions.timeoutMs, - owner: "pid=test", - lockPath: `${lockOptions.sessionFile}.lock`, - }); - const releaseInitial = vi.fn(async () => {}); - const acquireSessionWriteLockLocal = vi - .fn() - .mockResolvedValueOnce({ release: releaseInitial }) - .mockRejectedValueOnce(reacquireError) - .mockResolvedValueOnce({ release: vi.fn(async () => {}) }); - const controller = await createEmbeddedAttemptSessionLockController({ - acquireSessionWriteLock: acquireSessionWriteLockLocal, - lockOptions, - }); - - await controller.releaseForPrompt(); - const reacquire = controller.reacquireAfterPrompt(); - const cleanupLockPromise = controller.acquireForCleanup(); - - await expect(reacquire).rejects.toBe(reacquireError); - const cleanupLock = await cleanupLockPromise; - await cleanupLock.release(); - - expect(acquireSessionWriteLockLocal).toHaveBeenCalledTimes(2); - expect(releaseInitial).toHaveBeenCalledTimes(1); - expect(controller.hasSessionTakeover()).toBe(true); - }); - - it("makes a late prompt reacquire a no-op after cleanup starts", async () => { - const releases: string[] = []; - let resolveCleanupLock!: (lock: { release(): Promise }) => void; - const pendingCleanupLock = new Promise<{ release(): Promise }>((resolve) => { - resolveCleanupLock = resolve; - }); - const acquireSessionWriteLockLocal = vi - .fn() - .mockResolvedValueOnce({ - release: vi.fn(async () => { - releases.push("prep"); - }), - }) - .mockImplementationOnce(async () => await pendingCleanupLock) - .mockResolvedValueOnce({ - release: vi.fn(async () => { - releases.push("unexpected"); - }), + it("keeps queued writes inside the disposal timeout when prompt reload stalls", async () => { + vi.useFakeTimers(); + try { + let markReloadStarted!: () => void; + const reloadStarted = new Promise((resolve) => { + markReloadStarted = resolve; }); + let finishReload!: () => void; + const reloadBlocked = new Promise((resolve) => { + finishReload = resolve; + }); + const release = vi.fn(async () => undefined); + const controller = await createEmbeddedAttemptSessionLockController({ + acquireSessionWriteLock: vi.fn(async () => ({ release })), + lockOptions: { sessionFile: "agent:main:main" }, + reloadPromptReleasedSessionFile: async () => { + markReloadStarted(); + await reloadBlocked; + }, + }); + await controller.releaseForPrompt(); + const reacquire = controller.reacquireAfterPrompt(); + await reloadStarted; + const writeCallback = vi.fn(); + const queuedWrite = controller.withSessionWriteLock(writeCallback); + const queuedWriteOutcome = queuedWrite.then( + () => undefined, + (error: unknown) => error, + ); + + const disposal = controller.dispose(); + await vi.advanceTimersByTimeAsync(5_000); + await expect(disposal).resolves.toBeUndefined(); + expect(release).toHaveBeenCalledOnce(); + expect(writeCallback).not.toHaveBeenCalled(); + + finishReload(); + await expect(reacquire).resolves.toBeUndefined(); + await expect(queuedWriteOutcome).resolves.toEqual( + expect.objectContaining({ message: "attempt disposed before transcript write" }), + ); + expect(writeCallback).not.toHaveBeenCalled(); + } finally { + vi.useRealTimers(); + } + }); + + it("rejects disposal from inside a write callback", async () => { + const release = vi.fn(async () => undefined); const controller = await createEmbeddedAttemptSessionLockController({ - acquireSessionWriteLock: acquireSessionWriteLockLocal, - lockOptions, + acquireSessionWriteLock: vi.fn(async () => ({ release })), + lockOptions: { sessionFile: "agent:main:main" }, }); - await controller.releaseForPrompt(); - const cleanupLockPromise = controller.acquireForCleanup(); - await waitUntil( - () => acquireSessionWriteLockLocal.mock.calls.length === 2, - "expected cleanup lock acquisition to start", + await controller.withSessionWriteLock(async () => { + await expect(controller.dispose()).rejects.toThrow( + "cannot dispose an attempt from inside a transcript write callback", + ); + expect(release).not.toHaveBeenCalled(); + }); + expect(release).not.toHaveBeenCalled(); + await controller.dispose(); + expect(release).toHaveBeenCalledOnce(); + }); + + it("rejects cleanup from inside a write callback", async () => { + const release = vi.fn(async () => undefined); + const controller = await createEmbeddedAttemptSessionLockController({ + acquireSessionWriteLock: vi.fn(async () => ({ release })), + lockOptions: { sessionFile: "agent:main:main" }, + }); + + await controller.withSessionWriteLock(async () => { + await expect(controller.acquireForCleanup()).rejects.toThrow( + "cannot start attempt cleanup inside a transcript write callback", + ); + expect(release).not.toHaveBeenCalled(); + }); + const cleanupLock = await controller.acquireForCleanup(); + await expect(controller.withSessionWriteLock(async () => undefined)).rejects.toThrow( + "attempt cleanup started before transcript write", ); - let lateReacquireSettled = false; - const lateReacquire = controller.reacquireAfterPrompt().then(() => { - lateReacquireSettled = true; + await cleanupLock.release(); + expect(release).toHaveBeenCalledOnce(); + }); + + it("keeps cleanup ownership until the granted cleanup lock is released", async () => { + const release = vi.fn(async () => undefined); + const controller = await createEmbeddedAttemptSessionLockController({ + acquireSessionWriteLock: vi.fn(async () => ({ release })), + lockOptions: { sessionFile: "agent:main:main" }, + }); + const cleanupLock = await controller.acquireForCleanup(); + + let disposeSettled = false; + const disposal = controller.dispose().then(() => { + disposeSettled = true; }); await Promise.resolve(); - const settledBeforeCleanupAcquired = lateReacquireSettled; - resolveCleanupLock({ - release: vi.fn(async () => { - releases.push("cleanup"); - }), - }); - const cleanupLock = await cleanupLockPromise; + expect(disposeSettled).toBe(false); + expect(release).not.toHaveBeenCalled(); await cleanupLock.release(); - await lateReacquire; - - expect(settledBeforeCleanupAcquired).toBe(true); - expect(acquireSessionWriteLockLocal).toHaveBeenCalledTimes(2); - expect(releases).toEqual(["prep", "cleanup"]); + await disposal; + expect(release).toHaveBeenCalledOnce(); }); - it("takeHeldLockAfterRetainedIdle does not self-deadlock when called from inside active write scope (#95915)", async () => { - const events: string[] = []; - const acquireSessionWriteLockLocal = vi - .fn() - .mockResolvedValueOnce({ release: vi.fn(async () => events.push("init-release")) }) - .mockResolvedValueOnce({ release: vi.fn(async () => events.push("held-release")) }) - .mockRejectedValueOnce( - new SessionWriteLockTimeoutError({ - timeoutMs: lockOptions.timeoutMs, - owner: "pid=test", - lockPath: `${lockOptions.sessionFile}.lock`, - }), - ); - + it("retries the underlying release when cleanup release fails", async () => { + const releaseError = new Error("release failed"); + const release = vi + .fn<() => Promise>() + .mockRejectedValueOnce(releaseError) + .mockResolvedValueOnce(undefined); const controller = await createEmbeddedAttemptSessionLockController({ - acquireSessionWriteLock: acquireSessionWriteLockLocal, - lockOptions, + acquireSessionWriteLock: vi.fn(async () => ({ release })), + lockOptions: { sessionFile: "agent:main:main" }, }); + const cleanupLock = await controller.acquireForCleanup(); + const disposal = controller.dispose(); - await controller.releaseForPrompt(); - await controller.reacquireAfterPrompt(); + await expect(cleanupLock.release()).rejects.toBe(releaseError); + await expect(disposal).resolves.toBeUndefined(); + expect(release).toHaveBeenCalledTimes(2); + }); - const takeoverError = await controller - .withSessionWriteLock(async () => { - events.push("write-start"); + it("rejects a second cleanup acquisition without replacing ownership", async () => { + const release = vi.fn(async () => undefined); + const controller = await createEmbeddedAttemptSessionLockController({ + acquireSessionWriteLock: vi.fn(async () => ({ release })), + lockOptions: { sessionFile: "agent:main:main" }, + }); + const cleanupLock = await controller.acquireForCleanup(); + + await expect(controller.acquireForCleanup()).rejects.toThrow("attempt cleanup already started"); + await cleanupLock.release(); + await expect(controller.dispose()).resolves.toBeUndefined(); + expect(release).toHaveBeenCalledOnce(); + }); + + it("allows cleanup from an async descendant after its write callback settles", async () => { + let resumeDescendant!: () => void; + const descendantBlocked = new Promise((resolve) => { + resumeDescendant = resolve; + }); + const release = vi.fn(async () => undefined); + const controller = await createEmbeddedAttemptSessionLockController({ + acquireSessionWriteLock: vi.fn(async () => ({ release })), + lockOptions: { sessionFile: "agent:main:main" }, + }); + let descendant!: Promise; + + await controller.withSessionWriteLock(() => { + descendant = (async () => { + await descendantBlocked; const cleanupLock = await controller.acquireForCleanup(); await cleanupLock.release(); - events.push("cleanup-inside-done"); - }) - .catch((error: unknown) => error); + })(); + }); + resumeDescendant(); + await descendant; - expect(takeoverError).toBeInstanceOf(EmbeddedAttemptSessionTakeoverError); - - const cleanupLock = await controller.acquireForCleanup(); - await cleanupLock.release(); - - expect(events).toEqual(["init-release", "write-start", "cleanup-inside-done", "held-release"]); - expect(acquireSessionWriteLockLocal).toHaveBeenCalledTimes(3); + expect(release).toHaveBeenCalledOnce(); }); - it("returns a no-op cleanup lock after prompt lock reacquisition times out", async () => { - const releases: string[] = []; - const acquireSessionWriteLockResult = vi - .fn() - .mockResolvedValueOnce({ release: vi.fn(async () => releases.push("prep")) }) - .mockRejectedValueOnce( - new SessionWriteLockTimeoutError({ - timeoutMs: lockOptions.timeoutMs, - owner: "pid=123", - lockPath: `${lockOptions.sessionFile}.lock`, - }), - ); + it("keeps a started write callback locked beyond the disposal timeout", async () => { + vi.useFakeTimers(); + try { + let releaseWrite!: () => void; + const writeBlocked = new Promise((resolve) => { + releaseWrite = resolve; + }); + const release = vi.fn(async () => undefined); + const controller = await createEmbeddedAttemptSessionLockController({ + acquireSessionWriteLock: vi.fn(async () => ({ release })), + lockOptions: { sessionFile: "agent:main:main" }, + }); + const writeStarted = vi.fn(); + const write = controller.withSessionWriteLock(async () => { + writeStarted(); + await writeBlocked; + }); + await vi.waitFor(() => expect(writeStarted).toHaveBeenCalledOnce()); + let disposeSettled = false; + const disposal = controller.dispose().then(() => { + disposeSettled = true; + }); + await vi.advanceTimersByTimeAsync(5_000); + expect(disposeSettled).toBe(false); + expect(release).not.toHaveBeenCalled(); + + releaseWrite(); + await Promise.all([write, disposal]); + expect(release).toHaveBeenCalledOnce(); + } finally { + vi.useRealTimers(); + } + }); + + it("lets a started write finish nested writes after disposal begins", async () => { + let resumeOuter!: () => void; + const outerBlocked = new Promise((resolve) => { + resumeOuter = resolve; + }); + const nestedWrite = vi.fn(); + const release = vi.fn(async () => undefined); const controller = await createEmbeddedAttemptSessionLockController({ - acquireSessionWriteLock: acquireSessionWriteLockResult, - lockOptions, + acquireSessionWriteLock: vi.fn(async () => ({ release })), + lockOptions: { sessionFile: "agent:main:main" }, }); + const outer = controller.withSessionWriteLock(async () => { + await outerBlocked; + await controller.withSessionWriteLock(nestedWrite); + }); + await Promise.resolve(); + const disposal = controller.dispose(); - await controller.releaseForPrompt(); - const cleanupLock = await controller.acquireForCleanup(); - await cleanupLock.release(); - - expect(acquireSessionWriteLockResult).toHaveBeenCalledTimes(2); - expect(controller.hasSessionTakeover()).toBe(true); - expect(releases).toEqual(["prep"]); + resumeOuter(); + await Promise.all([outer, disposal]); + expect(nestedWrite).toHaveBeenCalledOnce(); + expect(release).toHaveBeenCalledOnce(); }); - it("skips cleanup lock reacquisition after a post-prompt lock timeout", async () => { - const releases: string[] = []; - const acquireSessionWriteLockValue = vi - .fn() - .mockResolvedValueOnce({ release: vi.fn(async () => releases.push("prep")) }) - .mockRejectedValueOnce( - new SessionWriteLockTimeoutError({ - timeoutMs: lockOptions.timeoutMs, - owner: "pid=456", - lockPath: `${lockOptions.sessionFile}.lock`, - }), - ); + it("keeps disposal tied to a released prompt until the bounded timeout", async () => { + vi.useFakeTimers(); + try { + const controller = await createEmbeddedAttemptSessionLockController({ + acquireSessionWriteLock: vi.fn(async () => ({ release: async () => undefined })), + lockOptions: { sessionFile: "agent:main:main" }, + }); + await controller.releaseForPrompt(); + let disposed = false; + const disposal = controller.dispose().then(() => { + disposed = true; + }); + await vi.advanceTimersByTimeAsync(4_999); + expect(disposed).toBe(false); + await vi.advanceTimersByTimeAsync(1); + await disposal; + expect(disposed).toBe(true); + } finally { + vi.useRealTimers(); + } + }); + + it("settles a released prompt when reacquire arrives after disposal", async () => { const controller = await createEmbeddedAttemptSessionLockController({ - acquireSessionWriteLock: acquireSessionWriteLockValue, - lockOptions, - }); - - await controller.releaseForPrompt(); - await expect(controller.withSessionWriteLock(() => "late-write")).rejects.toBeInstanceOf( - SessionWriteLockTimeoutError, - ); - const cleanupLock = await controller.acquireForCleanup(); - await cleanupLock.release(); - - expect(acquireSessionWriteLockValue).toHaveBeenCalledTimes(2); - expect(controller.hasSessionTakeover()).toBe(true); - expect(releases).toEqual(["prep"]); - }); - - it("skips cleanup lock reacquisition after a post-prompt stale lock", async () => { - const releases: string[] = []; - const acquireSessionWriteLockLocal = vi - .fn() - .mockResolvedValueOnce({ release: vi.fn(async () => releases.push("prep")) }) - .mockRejectedValueOnce( - new SessionWriteLockStaleError({ - owner: "pid=789 alive=true ageMs=1800001", - lockPath: `${lockOptions.sessionFile}.lock`, - staleReasons: ["too-old"], - }), - ); - - const controller = await createEmbeddedAttemptSessionLockController({ - acquireSessionWriteLock: acquireSessionWriteLockLocal, - lockOptions, - }); - - await controller.releaseForPrompt(); - await expect(controller.withSessionWriteLock(() => "late-write")).rejects.toBeInstanceOf( - SessionWriteLockStaleError, - ); - const cleanupLock = await controller.acquireForCleanup(); - await cleanupLock.release(); - - expect(acquireSessionWriteLockLocal).toHaveBeenCalledTimes(2); - expect(controller.hasSessionTakeover()).toBe(true); - expect(releases).toEqual(["prep"]); - }); - - it("wraps provider stream submission with queued transcript drain and lock release", async () => { - const events: string[] = []; - const streamFn = vi.fn(async (..._args: unknown[]) => { - events.push("stream"); - }); - const waitForSessionEvents = vi.fn(async () => { - events.push("drain"); - }); - const releaseForPrompt = vi.fn(async () => { - events.push("release"); - }); - const reacquireAfterPrompt = vi.fn(async () => { - events.push("reacquire"); - }); - const session = { agent: { streamFn } }; - - installPromptSubmissionLockRelease({ - session, - waitForSessionEvents, - releaseForPrompt, - reacquireAfterPrompt, - }); - - await session.agent.streamFn("model", "context"); - - expect(waitForSessionEvents).toHaveBeenCalledWith(session); - expect(releaseForPrompt).toHaveBeenCalledTimes(1); - expect(reacquireAfterPrompt).toHaveBeenCalledTimes(1); - expect(streamFn).toHaveBeenCalledWith("model", "context"); - expect(events).toEqual(["drain", "release", "stream", "drain", "reacquire"]); - }); - - it("rewraps provider stream submission after the stream function is rebuilt", async () => { - const events: string[] = []; - const firstStreamFn = vi.fn(async (..._args: unknown[]) => { - events.push("first-stream"); - }); - const secondStreamFn = vi.fn(async (..._args: unknown[]) => { - events.push("second-stream"); - }); - const waitForSessionEvents = vi.fn(async () => { - events.push("drain"); - }); - const releaseForPrompt = vi.fn(async () => { - events.push("release"); - }); - const reacquireAfterPrompt = vi.fn(async () => { - events.push("reacquire"); - }); - const session = { agent: { streamFn: firstStreamFn } }; - - installPromptSubmissionLockRelease({ - session, - waitForSessionEvents, - releaseForPrompt, - reacquireAfterPrompt, - }); - installPromptSubmissionLockRelease({ - session, - waitForSessionEvents, - releaseForPrompt, - reacquireAfterPrompt, - }); - await session.agent.streamFn("first-model"); - - session.agent.streamFn = secondStreamFn; - installPromptSubmissionLockRelease({ - session, - waitForSessionEvents, - releaseForPrompt, - reacquireAfterPrompt, - }); - await session.agent.streamFn("second-model"); - - expect(firstStreamFn).toHaveBeenCalledTimes(1); - expect(secondStreamFn).toHaveBeenCalledTimes(1); - expect(waitForSessionEvents).toHaveBeenCalledTimes(4); - expect(releaseForPrompt).toHaveBeenCalledTimes(2); - expect(reacquireAfterPrompt).toHaveBeenCalledTimes(2); - expect(events).toEqual([ - "drain", - "release", - "first-stream", - "drain", - "reacquire", - "drain", - "release", - "second-stream", - "drain", - "reacquire", - ]); - }); - - it("keeps prompt-stream transcript appends from blocking session-locked hook writes", async () => { - const sessionFile = await createTempSessionFile(); - const controller = await createEmbeddedAttemptSessionLockController({ - acquireSessionWriteLock, - lockOptions: { - ...lockOptions, - sessionFile, - timeoutMs: 250, - }, + acquireSessionWriteLock: vi.fn(async () => ({ release: async () => undefined })), + lockOptions: { sessionFile: "agent:main:main" }, }); await controller.releaseForPrompt(); + const disposal = controller.dispose(); - const hookCanAppend = createDeferred(); - const hookHasLock = createDeferred(); + await controller.reacquireAfterPrompt(); + await expect(disposal).resolves.toBeUndefined(); + }); - const hookAppend = controller.withSessionWriteLock(async () => { - hookHasLock.resolve(); - await hookCanAppend.promise; - await appendSessionTranscriptMessage({ - transcriptPath: sessionFile, - message: { - role: "assistant", - content: [{ type: "text", text: "session-locked hook write" }], + it("keeps a queued next prompt settlement after the prior reacquire finishes", async () => { + vi.useFakeTimers(); + try { + let markReloadStarted!: () => void; + const reloadStarted = new Promise((resolve) => { + markReloadStarted = resolve; + }); + let finishReload!: () => void; + const reloadBlocked = new Promise((resolve) => { + finishReload = resolve; + }); + const controller = await createEmbeddedAttemptSessionLockController({ + acquireSessionWriteLock: vi.fn(async () => ({ release: async () => undefined })), + lockOptions: { sessionFile: "agent:main:main" }, + reloadPromptReleasedSessionFile: async () => { + markReloadStarted(); + await reloadBlocked; }, }); - }); - await hookHasLock.promise; - const promptAppend = withOwnedSessionTranscriptWrites( - { - sessionFile, - withSessionWriteLock: (run, options) => controller.withSessionWriteLock(run, options), - }, - async () => - await appendSessionTranscriptMessage({ - transcriptPath: sessionFile, - message: { - role: "assistant", - content: [{ type: "text", text: "prompt-stream write" }], - }, - }), + await controller.releaseForPrompt(); + const firstReacquire = controller.reacquireAfterPrompt(); + await reloadStarted; + const secondRelease = controller.releaseForPrompt(); + finishReload(); + await Promise.all([firstReacquire, secondRelease]); + + let disposed = false; + const disposal = controller.dispose().then(() => { + disposed = true; + }); + await vi.advanceTimersByTimeAsync(4_999); + expect(disposed).toBe(false); + await vi.advanceTimersByTimeAsync(1); + await disposal; + expect(disposed).toBe(true); + } finally { + vi.useRealTimers(); + } + }); + + it("keeps repeated disposal bounded after abort clears the prompt release", async () => { + vi.useFakeTimers(); + try { + let markReloadStarted!: () => void; + const reloadStarted = new Promise((resolve) => { + markReloadStarted = resolve; + }); + const controller = await createEmbeddedAttemptSessionLockController({ + acquireSessionWriteLock: vi.fn(async () => ({ release: async () => undefined })), + lockOptions: { sessionFile: "agent:main:main" }, + reloadPromptReleasedSessionFile: async () => { + markReloadStarted(); + await new Promise(() => {}); + }, + }); + + await controller.releaseForPrompt(); + void controller.reacquireAfterPrompt(); + await reloadStarted; + await controller.releaseHeldLockForAbort(); + const firstDisposal = controller.dispose(); + const secondDisposal = controller.dispose(); + await vi.advanceTimersByTimeAsync(5_000); + + await expect(Promise.all([firstDisposal, secondDisposal])).resolves.toEqual([ + undefined, + undefined, + ]); + } finally { + vi.useRealTimers(); + } + }); + + it("rejects a late SDK prompt handoff after cleanup has started", async () => { + const controller = await createEmbeddedAttemptSessionLockController({ + acquireSessionWriteLock: vi.fn(async () => ({ release: async () => undefined })), + lockOptions: { sessionFile: "agent:main:main" }, + }); + + await controller.acquireForCleanup(); + await expect(controller.releaseForPrompt()).rejects.toThrow( + "attempt cleanup started before prompt submission", ); + }); - await new Promise((resolve) => { - setTimeout(resolve, 25); + it("rejects a late SDK prompt handoff after disposal", async () => { + const write = vi.fn(); + const release = vi.fn(async () => undefined); + const controller = await createEmbeddedAttemptSessionLockController({ + acquireSessionWriteLock: vi.fn(async () => ({ release })), + lockOptions: { sessionFile: "agent:main:main" }, }); - hookCanAppend.resolve(); - await Promise.all([hookAppend, promptAppend]); + await controller.dispose(); + await controller.dispose(); + await expect(controller.releaseForPrompt()).rejects.toThrow( + "attempt disposed before prompt submission", + ); + await expect(controller.withSessionWriteLock(write)).rejects.toThrow( + "attempt disposed before transcript write", + ); + expect(write).not.toHaveBeenCalled(); + expect(release).toHaveBeenCalledOnce(); + }); + + it("retries direct disposal when the underlying release fails", async () => { + const releaseError = new Error("release failed"); + const release = vi + .fn<() => Promise>() + .mockRejectedValueOnce(releaseError) + .mockResolvedValueOnce(undefined); + const controller = await createEmbeddedAttemptSessionLockController({ + acquireSessionWriteLock: vi.fn(async () => ({ release })), + lockOptions: { sessionFile: "agent:main:main" }, + }); + + await expect(controller.dispose()).rejects.toBe(releaseError); + await expect(controller.dispose()).resolves.toBeUndefined(); + expect(release).toHaveBeenCalledTimes(2); + }); + + it("keeps session ownership until an active write callback settles", async () => { + let releaseWrite!: () => void; + const writeBlocked = new Promise((resolve) => { + releaseWrite = resolve; + }); + const release = vi.fn(async () => undefined); + const controller = await createEmbeddedAttemptSessionLockController({ + acquireSessionWriteLock: vi.fn(async () => ({ release })), + lockOptions: { sessionFile: "agent:main:main" }, + }); + const write = controller.withSessionWriteLock(async () => await writeBlocked); + await Promise.resolve(); + let disposeSettled = false; + const disposal = controller.dispose().then(() => { + disposeSettled = true; + }); + + await Promise.resolve(); + expect(disposeSettled).toBe(false); + expect(release).not.toHaveBeenCalled(); + releaseWrite(); + await Promise.all([write, disposal]); + expect(release).toHaveBeenCalledOnce(); + }); + + it("reloads after a delayed prompt following a non-terminal sessions_yield abort", async () => { + const reloadPromptReleasedSessionFile = vi.fn(); + const controller = await createEmbeddedAttemptSessionLockController({ + acquireSessionWriteLock: vi.fn(async () => ({ release: async () => undefined })), + lockOptions: { sessionFile: "agent:main:main" }, + reloadPromptReleasedSessionFile, + }); + + await controller.releaseHeldLockForAbort({ terminal: false }); + await expect(controller.releaseForPrompt()).resolves.toBeUndefined(); + await expect(controller.reacquireAfterPrompt()).resolves.toBeUndefined(); + expect(reloadPromptReleasedSessionFile).toHaveBeenCalledOnce(); + }); + + it("reloads released transcript state during cleanup after a terminal abort", async () => { + const reloadPromptReleasedSessionFile = vi.fn(async () => undefined); + const controller = await createEmbeddedAttemptSessionLockController({ + acquireSessionWriteLock: vi.fn(async () => ({ release: async () => undefined })), + lockOptions: { sessionFile: "agent:main:main" }, + reloadPromptReleasedSessionFile, + }); + + await controller.releaseForPrompt(); + await controller.releaseHeldLockForAbort(); const cleanupLock = await controller.acquireForCleanup(); - await cleanupLock.release(); - const transcript = await fs.readFile(sessionFile, "utf8"); - expect(transcript).toContain("session-locked hook write"); - expect(transcript).toContain("prompt-stream write"); - expect(controller.hasSessionTakeover()).toBe(false); + expect(reloadPromptReleasedSessionFile).toHaveBeenCalledOnce(); + await cleanupLock.release(); + }); + + it("rejects cleanup acquisition when disposal wins during prompt reload", async () => { + vi.useFakeTimers(); + try { + let markReloadStarted!: () => void; + const reloadStarted = new Promise((resolve) => { + markReloadStarted = resolve; + }); + let finishReload!: () => void; + const reloadBlocked = new Promise((resolve) => { + finishReload = resolve; + }); + const controller = await createEmbeddedAttemptSessionLockController({ + acquireSessionWriteLock: vi.fn(async () => ({ release: async () => undefined })), + lockOptions: { sessionFile: "agent:main:main" }, + reloadPromptReleasedSessionFile: async () => { + markReloadStarted(); + await reloadBlocked; + }, + }); + await controller.releaseForPrompt(); + const cleanupAcquisition = controller.acquireForCleanup(); + const cleanupOutcome = cleanupAcquisition.then( + () => undefined, + (error: unknown) => error, + ); + await reloadStarted; + + const disposal = controller.dispose(); + await vi.advanceTimersByTimeAsync(5_000); + await expect(disposal).resolves.toBeUndefined(); + finishReload(); + + await expect(cleanupOutcome).resolves.toEqual( + expect.objectContaining({ message: "attempt disposed before cleanup" }), + ); + } finally { + vi.useRealTimers(); + } + }); + + it("lets cleanup settle a completed prompt when the SDK omits reacquire", async () => { + const reloadPromptReleasedSessionFile = vi.fn(async () => undefined); + const controller = await createEmbeddedAttemptSessionLockController({ + acquireSessionWriteLock: vi.fn(async () => ({ release: async () => undefined })), + lockOptions: { sessionFile: "agent:main:main" }, + reloadPromptReleasedSessionFile, + }); + + await controller.releaseForPrompt(); + await expect(controller.acquireForCleanup()).resolves.toBeDefined(); + expect(reloadPromptReleasedSessionFile).toHaveBeenCalledOnce(); + await expect(controller.reacquireAfterPrompt()).resolves.toBeUndefined(); + expect(reloadPromptReleasedSessionFile).toHaveBeenCalledOnce(); + await expect(controller.releaseForPrompt()).rejects.toThrow( + "attempt cleanup started before prompt submission", + ); + }); +}); + +describe("installPromptSubmissionLockRelease", () => { + it("preserves the provider error when prompt settlement also fails", async () => { + const providerError = new Error("provider failed"); + const settlementError = new Error("reacquire failed"); + const session = { + agent: { + streamFn: vi.fn(async () => { + throw providerError; + }), + }, + }; + installPromptSubmissionLockRelease({ + session, + releaseForPrompt: vi.fn(async () => undefined), + reacquireAfterPrompt: vi.fn(async () => { + throw settlementError; + }), + }); + + await expect(session.agent.streamFn()).rejects.toBe(providerError); + expect(providerError.cause).toBe(settlementError); + }); + + it("does not attach a shared provider and settlement error as its own cause", async () => { + const sharedError = new Error("shared failure"); + const session = { + agent: { + streamFn: vi.fn(async () => { + throw sharedError; + }), + }, + }; + installPromptSubmissionLockRelease({ + session, + releaseForPrompt: vi.fn(async () => undefined), + reacquireAfterPrompt: vi.fn(async () => { + throw sharedError; + }), + }); + + await expect(session.agent.streamFn()).rejects.toBe(sharedError); + expect(sharedError.cause).toBeUndefined(); + }); + + it("preserves an undefined prompt settlement rejection", async () => { + const session = { agent: { streamFn: vi.fn(async () => "ok") } }; + const undefinedRejection = new Promise((_resolve, reject) => { + queueMicrotask(() => { + Reflect.apply(reject, undefined, [undefined]); + }); + }); + installPromptSubmissionLockRelease({ + session, + releaseForPrompt: vi.fn(async () => undefined), + reacquireAfterPrompt: vi.fn(() => undefinedRejection), + }); + + await expect(session.agent.streamFn()).rejects.toBeUndefined(); + }); + + it("preserves a frozen provider error when prompt settlement also fails", async () => { + const providerError = new Error("frozen provider failure"); + Object.freeze(providerError); + const session = { + agent: { + streamFn: vi.fn(async () => { + throw providerError; + }), + }, + }; + installPromptSubmissionLockRelease({ + session, + releaseForPrompt: vi.fn(async () => undefined), + reacquireAfterPrompt: vi.fn(async () => { + throw new Error("settlement failed"); + }), + }); + + await expect(session.agent.streamFn()).rejects.toBe(providerError); }); }); -/* oxlint-disable max-lines -- TODO: split this grandfathered oversized file. */ diff --git a/src/agents/embedded-agent-runner/run/attempt.session-lock.ts b/src/agents/embedded-agent-runner/run/attempt.session-lock.ts index f0cc85613e27..59406640efb7 100644 --- a/src/agents/embedded-agent-runner/run/attempt.session-lock.ts +++ b/src/agents/embedded-agent-runner/run/attempt.session-lock.ts @@ -1,13 +1,575 @@ -/** - * Coordinates embedded-attempt session ownership, takeover, and prompt locks. - */ -export { - createEmbeddedAttemptSessionLockController, - type EmbeddedAttemptSessionLockController, -} from "./attempt.session-lock.controller.js"; -export { EmbeddedAttemptSessionTakeoverError } from "./attempt.session-lock.fence-controller.js"; -export { - acquireEmbeddedAttemptSessionFileOwner, - type EmbeddedAttemptSessionFileOwner, -} from "./attempt.session-lock.owner.js"; -export { installPromptSubmissionLockRelease } from "./attempt.session-lock.prompt.js"; +/** Coordinates embedded-attempt lifecycle around SQLite-owned transcript writes. */ +import { AsyncLocalStorage } from "node:async_hooks"; +import type { + OwnedSessionTranscriptCacheSnapshot, + OwnedSessionTranscriptWriteOptions, + SessionTranscriptWriteLockTarget, +} from "../../../config/sessions/transcript-write-context.js"; +import { withOwnedSessionTranscriptWrites } from "../../../config/sessions/transcript-write-context.js"; +import { isSessionWriteLockAcquireError } from "../../session-write-lock-error.js"; +import type { acquireSessionWriteLock } from "../../session-write-lock.js"; +import type { + PromptReleasedSessionEntry, + PromptReleasedSessionMergeResult, +} from "../../sessions/session-manager.js"; + +type SessionLock = Awaited>; +type AcquireSessionWriteLock = typeof acquireSessionWriteLock; +type LockOptions = Parameters[0]; +const PROMPT_DISPOSE_SETTLE_TIMEOUT_MS = 5_000; + +export type EmbeddedAttemptSessionFileOwner = { + sessionFileKey: string; + release(): void; +}; + +/** Session lanes and SQLite writer queues already serialize this identity. */ +export async function acquireEmbeddedAttemptSessionFileOwner(params: { + sessionFile: string; + timeoutMs?: number; + signal?: AbortSignal; +}): Promise { + if (params.signal?.aborted) { + throw params.signal.reason; + } + return { sessionFileKey: params.sessionFile, release() {} }; +} + +export class EmbeddedAttemptSessionTakeoverError extends Error { + constructor(sessionKey: string) { + super(`session changed while the prompt was running: ${sessionKey}`); + this.name = "EmbeddedAttemptSessionTakeoverError"; + } +} + +export type EmbeddedAttemptSessionLockController = { + canAdvanceSessionEntryCache(snapshot: OwnedSessionTranscriptCacheSnapshot): boolean; + publishOwnedSessionFileSnapshot(snapshot: OwnedSessionTranscriptCacheSnapshot): boolean; + publishValidatedSessionFileSnapshot(snapshot: OwnedSessionTranscriptCacheSnapshot): boolean; + readTrustedCurrentSessionFileSnapshot(): Promise; + releaseForPrompt(): Promise; + releaseHeldLockForAbort(options?: { terminal?: boolean }): Promise; + refreshAfterOwnedSessionWrite(): void; + reacquireAfterPrompt(): Promise; + withSessionWriteLock( + run: () => Promise | T, + options?: OwnedSessionTranscriptWriteOptions, + ): Promise; + acquireForCleanup(params?: { session?: unknown }): Promise; + hasSessionTakeover(): boolean; + dispose(): Promise; +}; + +export async function createEmbeddedAttemptSessionLockController(params: { + acquireSessionWriteLock: AcquireSessionWriteLock; + initialAcquireSignal?: AbortSignal; + lockOptions: LockOptions; + mergePromptReleasedSessionEntries?: ( + entries: readonly PromptReleasedSessionEntry[], + ) => Promise | PromptReleasedSessionMergeResult | void; + reloadPromptReleasedSessionFile?: () => Promise | void; +}): Promise { + // The runtime caller supplies resolveSessionWriteLockTargetKey(sessionTarget), + // so aliases for one session incarnation converge before lease acquisition. + const noOpLock = await params.acquireSessionWriteLock({ + ...params.lockOptions, + targetKind: "session-key", + ...(params.initialAcquireSignal ? { signal: params.initialAcquireSignal } : {}), + }); + let initialLockReleasePromise: Promise | undefined; + let initialLockReleased = false; + const releaseInitialLock = (): Promise => { + if (initialLockReleased) { + return Promise.resolve(); + } + initialLockReleasePromise ??= Promise.resolve(noOpLock.release()).then( + () => { + initialLockReleased = true; + }, + (error: unknown) => { + initialLockReleasePromise = undefined; + throw error; + }, + ); + return initialLockReleasePromise; + }; + let disposed = false; + let promptAborted = false; + let promptSubmissionBlocked = false; + let takeoverDetected = false; + const assertInitialLockOwned = (): void => { + try { + noOpLock.assertOwned?.(); + } catch (error) { + if (isSessionWriteLockAcquireError(error)) { + takeoverDetected = true; + } + throw error; + } + }; + let promptReleaseNeedsReload = false; + let cleanupStarted = false; + let promptSettled = Promise.resolve(); + let settlePrompt: (() => void) | undefined; + let lifecycle = Promise.resolve(); + let reloadFailed = false; + let reloadFailure: unknown; + let disposePromise: Promise | undefined; + let cleanupReleasePromise: Promise | undefined; + let cleanupLockGranted = false; + let cleanupOwnershipReleased: Promise | undefined; + let resolveCleanupOwnershipReleased: (() => void) | undefined; + let rejectCleanupOwnershipReleased: ((error: unknown) => void) | undefined; + type ActiveWriteOperation = { + active: boolean; + settlement?: Promise; + started: boolean; + }; + const activeWriteOperations = new Set(); + const activeWriteOperation = new AsyncLocalStorage(); + type LifecycleOwner = { + active: boolean; + nestedPending: number; + nestedTail: Promise; + pendingOperations: Set>; + }; + const createLifecycleOwner = (): LifecycleOwner => ({ + active: true, + nestedPending: 0, + nestedTail: Promise.resolve(), + pendingOperations: new Set(), + }); + const lifecycleOwner = new AsyncLocalStorage(); + const drainLifecycleOwner = async (owner: LifecycleOwner): Promise => { + let failed = false; + let firstError: unknown; + while (owner.pendingOperations.size > 0) { + const pending = [...owner.pendingOperations]; + owner.pendingOperations.clear(); + const settled = await Promise.allSettled(pending); + const rejection = settled.find((result) => result.status === "rejected"); + if (!failed && rejection?.status === "rejected") { + failed = true; + firstError = rejection.reason; + } + } + if (failed) { + throw firstError; + } + }; + const runLifecycleOwner = async ( + owner: LifecycleOwner, + run: () => Promise | T, + ): Promise => { + let value: T | undefined; + let primaryError: unknown; + let primaryFailed = false; + try { + value = await lifecycleOwner.run(owner, async () => await run()); + } catch (error) { + primaryFailed = true; + primaryError = error; + } + let drainError: unknown; + let drainFailed = false; + try { + await drainLifecycleOwner(owner); + } catch (error) { + drainFailed = true; + drainError = error; + } finally { + owner.active = false; + } + if (primaryFailed) { + if ( + drainFailed && + drainError !== primaryError && + primaryError instanceof Error && + primaryError.cause === undefined + ) { + try { + primaryError.cause = drainError; + } catch { + // Frozen callback errors remain primary; drain failure is secondary. + } + } + throw primaryError; + } + if (drainFailed) { + throw drainError; + } + return value as T; + }; + const serializeLifecycle = async (run: () => Promise | T): Promise => { + const inheritedOwner = lifecycleOwner.getStore(); + if (inheritedOwner?.active) { + const previousNested = inheritedOwner.nestedTail; + const waitForPrevious = inheritedOwner.nestedPending > 0 ? previousNested : Promise.resolve(); + inheritedOwner.nestedPending += 1; + const operation = waitForPrevious.then(async () => { + const childOwner = createLifecycleOwner(); + return await runLifecycleOwner(childOwner, run); + }); + void operation.catch(() => {}); + const queueTail = operation.then( + () => undefined, + () => undefined, + ); + const propagated = operation.then(() => undefined); + void propagated.catch(() => {}); + inheritedOwner.nestedTail = queueTail; + inheritedOwner.pendingOperations.add(propagated); + void queueTail.finally(() => { + inheritedOwner.nestedPending -= 1; + }); + return await operation; + } + const previous = lifecycle; + let release!: () => void; + lifecycle = new Promise((resolve) => { + release = resolve; + }); + await previous; + const owner = createLifecycleOwner(); + try { + return await runLifecycleOwner(owner, run); + } finally { + release(); + } + }; + const reloadPromptReleasedState = async (): Promise => { + if (reloadFailed) { + throw reloadFailure; + } + try { + assertInitialLockOwned(); + await params.reloadPromptReleasedSessionFile?.(); + } catch (error) { + reloadFailed = true; + reloadFailure = error; + if (error instanceof EmbeddedAttemptSessionTakeoverError) { + takeoverDetected = true; + } + throw error; + } + }; + const settlePromptRelease = (): void => { + promptReleaseNeedsReload = false; + settlePrompt?.(); + settlePrompt = undefined; + }; + return { + canAdvanceSessionEntryCache: () => false, + publishOwnedSessionFileSnapshot: () => false, + publishValidatedSessionFileSnapshot: () => false, + readTrustedCurrentSessionFileSnapshot: async () => undefined, + releaseForPrompt: async () => { + if (disposed) { + throw new Error("attempt disposed before prompt submission"); + } + await serializeLifecycle(() => { + if (disposed) { + throw new Error("attempt disposed before prompt submission"); + } + if (promptSubmissionBlocked) { + throw new Error("attempt aborted before prompt submission"); + } + if (cleanupStarted) { + throw new Error("attempt cleanup started before prompt submission"); + } + assertInitialLockOwned(); + // The SQLite lease spans the provider prompt. Its stale deadline never + // permits takeover from a live process owner. + promptAborted = false; + promptReleaseNeedsReload = true; + promptSettled = new Promise((resolve) => { + settlePrompt = resolve; + }); + }); + }, + releaseHeldLockForAbort: async (options) => { + promptAborted = true; + promptSubmissionBlocked ||= options?.terminal !== false; + }, + refreshAfterOwnedSessionWrite: () => {}, + reacquireAfterPrompt: async () => { + if (disposed) { + settlePromptRelease(); + return; + } + await serializeLifecycle(async () => { + try { + if (disposed || cleanupStarted || (promptAborted && !promptReleaseNeedsReload)) { + return; + } + await reloadPromptReleasedState(); + } finally { + settlePromptRelease(); + } + }); + }, + withSessionWriteLock: (run) => { + const rejectWrite = (error: Error): Promise => { + const rejected = Promise.reject(error); + void rejected.catch(() => {}); + return rejected; + }; + const parentWriteOperation = activeWriteOperation.getStore(); + const activeDescendant = Boolean( + parentWriteOperation?.active && activeWriteOperations.has(parentWriteOperation), + ); + if (disposed && !activeDescendant) { + return rejectWrite(new Error("attempt disposed before transcript write")); + } + if (cleanupStarted) { + return rejectWrite(new Error("attempt cleanup started before transcript write")); + } + const writeOperation: ActiveWriteOperation = { active: true, started: false }; + const operation = serializeLifecycle(async () => { + if (disposed && !activeDescendant) { + throw new Error("attempt disposed before transcript write"); + } + if (cleanupStarted) { + throw new Error("attempt cleanup started before transcript write"); + } + if (reloadFailed) { + throw reloadFailure; + } + assertInitialLockOwned(); + writeOperation.started = true; + return await activeWriteOperation.run(writeOperation, async () => await run()); + }); + const settlement = operation.then( + () => undefined, + () => undefined, + ); + void operation.catch(() => {}); + writeOperation.settlement = settlement; + activeWriteOperations.add(writeOperation); + void settlement.then(() => { + writeOperation.active = false; + activeWriteOperations.delete(writeOperation); + }); + return operation; + }, + acquireForCleanup: async () => { + const currentWriteOperation = activeWriteOperation.getStore(); + if (currentWriteOperation?.active && activeWriteOperations.has(currentWriteOperation)) { + throw new Error("cannot start attempt cleanup inside a transcript write callback"); + } + if (disposed) { + throw new Error("attempt disposed before cleanup"); + } + await serializeLifecycle(async () => { + if (disposed) { + throw new Error("attempt disposed before cleanup"); + } + if (cleanupStarted) { + throw new Error("attempt cleanup already started"); + } + cleanupStarted = true; + if (promptReleaseNeedsReload) { + try { + if (!disposed) { + await reloadPromptReleasedState(); + } + } finally { + settlePromptRelease(); + } + } + }); + await serializeLifecycle(() => {}); + if (disposed) { + throw new Error("attempt disposed before cleanup"); + } + assertInitialLockOwned(); + cleanupLockGranted = true; + cleanupOwnershipReleased = new Promise((resolve, reject) => { + resolveCleanupOwnershipReleased = resolve; + rejectCleanupOwnershipReleased = reject; + }); + void cleanupOwnershipReleased.catch(() => {}); + return { + release: () => { + cleanupReleasePromise ??= (async () => { + try { + while (activeWriteOperations.size > 0) { + await Promise.all( + [...activeWriteOperations].flatMap((operation) => + operation.settlement ? [operation.settlement] : [], + ), + ); + } + await releaseInitialLock(); + resolveCleanupOwnershipReleased?.(); + } catch (error) { + rejectCleanupOwnershipReleased?.(error); + throw error; + } + })(); + return cleanupReleasePromise; + }, + } as SessionLock; + }, + hasSessionTakeover: () => takeoverDetected, + dispose: async () => { + const currentWriteOperation = activeWriteOperation.getStore(); + if (currentWriteOperation?.active && activeWriteOperations.has(currentWriteOperation)) { + throw new Error("cannot dispose an attempt from inside a transcript write callback"); + } + disposePromise ??= (async () => { + disposed = true; + promptAborted = true; + if (cleanupLockGranted) { + // Cleanup acquisition already drained the lifecycle queue. Its lock + // release resolves this handoff after any tracked write settlements. + try { + await cleanupOwnershipReleased; + } catch { + await releaseInitialLock(); + } + return; + } + let timeout: ReturnType | undefined; + try { + await Promise.race([ + (async () => { + await Promise.all([promptSettled, serializeLifecycle(() => {})]); + while (true) { + const pendingWrites = [...activeWriteOperations].flatMap((operation) => + operation.settlement ? [operation.settlement] : [], + ); + if (pendingWrites.length === 0) { + break; + } + await Promise.all(pendingWrites); + } + })(), + new Promise((resolve) => { + timeout = setTimeout(resolve, PROMPT_DISPOSE_SETTLE_TIMEOUT_MS); + }), + ]); + while (true) { + const startedWrites = [...activeWriteOperations] + .filter((operation) => operation.started) + .flatMap((operation) => (operation.settlement ? [operation.settlement] : [])); + if (startedWrites.length === 0) { + break; + } + await Promise.all(startedWrites); + } + } finally { + if (timeout) { + clearTimeout(timeout); + } + await releaseInitialLock(); + } + })().catch((error: unknown) => { + disposePromise = undefined; + throw error; + }); + await disposePromise; + }, + }; +} + +type PromptReleaseStreamFn = ((...args: unknown[]) => Promise) & { + openclawSessionLockPromptReleaseInstalled?: true; +}; + +type SessionWithAgentPrompt = { + agent?: { streamFn?: PromptReleaseStreamFn }; +}; + +async function settlePromptSubmission(params: { + reacquireAfterPrompt: () => Promise; +}): Promise { + await params.reacquireAfterPrompt(); +} + +function attachPromptSettlementError(promptError: unknown, settlementError: unknown): void { + if ( + promptError instanceof Error && + promptError.cause === undefined && + settlementError !== promptError + ) { + try { + promptError.cause = settlementError; + } catch { + // A frozen provider error remains the primary failure; settlement diagnostics are secondary. + } + } +} + +export function installPromptSubmissionLockRelease(params: { + session: unknown; + releaseForPrompt: () => Promise; + reacquireAfterPrompt: () => Promise; + sessionFile?: string; + sessionKey?: string; + sessionTarget?: SessionTranscriptWriteLockTarget; + withSessionWriteLock?: ( + run: () => Promise | T, + options?: OwnedSessionTranscriptWriteOptions, + ) => Promise; + canAdvanceSessionEntryCache?: (snapshot: OwnedSessionTranscriptCacheSnapshot) => boolean; + publishSessionFileSnapshot?: (snapshot: OwnedSessionTranscriptCacheSnapshot) => boolean; +}): void { + const agent = (params.session as SessionWithAgentPrompt).agent; + if (typeof agent?.streamFn !== "function") { + return; + } + const currentStreamFn = agent.streamFn; + if (currentStreamFn.openclawSessionLockPromptReleaseInstalled === true) { + return; + } + const originalStreamFn = currentStreamFn.bind(agent); + const wrappedStreamFn: PromptReleaseStreamFn = async (...args: unknown[]) => { + // The internal agent runtime routes transcript mutations through the + // lifecycle lock; it has no separate SDK event queue to drain. + await params.releaseForPrompt(); + let promptFailed = false; + let promptError: unknown; + let promptResult: unknown; + try { + if (params.sessionFile && params.withSessionWriteLock) { + promptResult = await withOwnedSessionTranscriptWrites( + { + sessionFile: params.sessionFile, + sessionKey: params.sessionKey, + sessionTarget: params.sessionTarget, + withSessionWriteLock: params.withSessionWriteLock, + canAdvanceSessionEntryCache: params.canAdvanceSessionEntryCache, + publishSessionFileSnapshot: params.publishSessionFileSnapshot, + }, + async () => await originalStreamFn(...args), + ); + } else { + promptResult = await originalStreamFn(...args); + } + } catch (error) { + promptFailed = true; + promptError = error; + } + let settlementFailed = false; + let settlementError: unknown; + try { + await settlePromptSubmission(params); + } catch (error) { + settlementFailed = true; + settlementError = error; + } + if (promptFailed) { + if (settlementFailed) { + attachPromptSettlementError(promptError, settlementError); + } + throw promptError; + } + if (settlementFailed) { + throw settlementError; + } + return promptResult; + }; + wrappedStreamFn.openclawSessionLockPromptReleaseInstalled = true; + agent.streamFn = wrappedStreamFn; +} diff --git a/src/agents/embedded-agent-runner/run/attempt.session-lock.write-scope.ts b/src/agents/embedded-agent-runner/run/attempt.session-lock.write-scope.ts deleted file mode 100644 index f314c32c8cbd..000000000000 --- a/src/agents/embedded-agent-runner/run/attempt.session-lock.write-scope.ts +++ /dev/null @@ -1,70 +0,0 @@ -import type { OwnedSessionTranscriptPublishedEntry } from "../../../config/sessions/transcript-write-context.js"; - -type PhysicalWriteLockScope = { - active: boolean; - completion: Promise; - pendingOperations: Set>; -}; - -export type ActiveWriteLockState = - | { - active: boolean; - scope: PhysicalWriteLockScope; - publishingOwnedWrite: false; - } - | { - active: boolean; - scope: PhysicalWriteLockScope; - publishingOwnedWrite: true; - acceptingNestedPublications: boolean; - pendingNestedPublications: Set>; - publishedEntries?: OwnedSessionTranscriptPublishedEntry[]; - }; - -type RootWriteLockState = Extract; - -export function createActiveWriteLockScope(): { - state: RootWriteLockState; - complete: () => void; -} { - let complete!: () => void; - const completion = new Promise((resolve) => { - complete = resolve; - }); - return { - state: { - active: true, - scope: { - active: true, - completion, - pendingOperations: new Set(), - }, - publishingOwnedWrite: false, - }, - complete, - }; -} - -export function trackWriteLockOperation( - scope: PhysicalWriteLockScope, - operation: Promise, - additionalSet?: Set>, -): Promise { - const settlement = operation.then( - () => undefined, - () => undefined, - ); - scope.pendingOperations.add(settlement); - additionalSet?.add(settlement); - void settlement.finally(() => { - scope.pendingOperations.delete(settlement); - additionalSet?.delete(settlement); - }); - return operation; -} - -export async function drainWriteLockScope(scope: PhysicalWriteLockScope): Promise { - while (scope.pendingOperations.size > 0) { - await Promise.all(scope.pendingOperations); - } -} diff --git a/src/agents/embedded-agent-runner/run/attempt.spawn-workspace.context-engine.test.ts b/src/agents/embedded-agent-runner/run/attempt.spawn-workspace.context-engine.test.ts index 9baf2eec5143..26851c253c05 100644 --- a/src/agents/embedded-agent-runner/run/attempt.spawn-workspace.context-engine.test.ts +++ b/src/agents/embedded-agent-runner/run/attempt.spawn-workspace.context-engine.test.ts @@ -34,7 +34,6 @@ import { resolvePromptCacheTouchTimestamp, runAttemptContextEngineBootstrap, } from "./attempt.context-engine-helpers.js"; -import { EmbeddedAttemptSessionTakeoverError } from "./attempt.session-lock.js"; import { cleanupTempPaths, createDefaultEmbeddedSession, @@ -154,28 +153,6 @@ function expectFields(actual: Record, expected: Record { - const lockId = hoisted.acquireSessionWriteLockMock.mock.calls.length; - events.push(`acquire-${lockId}`); - return { - release: async () => { - events.push(`release-${lockId}`); - }, - }; - }); - return events; -} - -function expectInitialLockReleasedBeforePostTurnWrite(events: string[]) { - expect(events.indexOf("release-1")).toBeGreaterThan(events.indexOf("acquire-1")); - expect(events.indexOf("acquire-2")).toBeGreaterThan(events.indexOf("release-1")); - expect(events.indexOf("release-2")).toBeGreaterThan(events.indexOf("acquire-2")); -} - function createTestContextEngine(params: Partial): AttemptContextEngine { return { info: { @@ -2300,7 +2277,6 @@ describe("runEmbeddedAttempt context engine sessionKey forwarding", () => { }); it("skips blank visible prompts with replay history before provider submission", async () => { - const lockEvents = trackSessionWriteLocks(); const sessionPrompt = vi.fn(async () => { throw new Error("blank prompt should not be submitted"); }); @@ -2332,11 +2308,9 @@ describe("runEmbeddedAttempt context engine sessionKey forwarding", () => { "prompt skipped event", ); expect(requireRecord(skipped.data, "prompt skipped data").reason).toBe("blank_user_prompt"); - expectInitialLockReleasedBeforePostTurnWrite(lockEvents); }); it("releases the initial session lock before before_agent_run block finalizers", async () => { - const lockEvents = trackSessionWriteLocks(); const sessionPrompt = vi.fn(async () => { throw new Error("blocked prompt should not be submitted"); }); @@ -2362,66 +2336,6 @@ describe("runEmbeddedAttempt context engine sessionKey forwarding", () => { expect(projectAgentRunAttemptTerminal(result.terminal).promptErrorSource).toBe( "hook:before_agent_run", ); - expectInitialLockReleasedBeforePostTurnWrite(lockEvents); - }); - - it("preserves provider prompt errors when cleanup reacquire detects session takeover", async () => { - const providerError = new Error("provider rejected request: HTTP 400"); - let acquireCount = 0; - let cleanupReacquireSessionFile: string | undefined; - hoisted.acquireSessionWriteLockMock.mockImplementation(async (params) => { - acquireCount += 1; - if (acquireCount === 3) { - cleanupReacquireSessionFile = params.sessionFile; - await fs.appendFile(params.sessionFile, '{"type":"message","id":"takeover"}\n', "utf8"); - } - return { release: async () => {} }; - }); - - const error = await createContextEngineAttemptRunner({ - contextEngine: createContextEngineBootstrapAndAssemble(), - sessionKey, - tempPaths, - sessionPrompt: async () => { - throw providerError; - }, - }).catch((err: unknown) => err); - - expect(error).toBeInstanceOf(Error); - expect((error as Error).name).toBe("EmbeddedAttemptSessionTakeoverError"); - expect((error as Error).message).toBe(providerError.message); - expect((error as Error).cause).toBeInstanceOf(EmbeddedAttemptSessionTakeoverError); - if (!cleanupReacquireSessionFile) { - throw new Error("expected cleanup lock reacquire"); - } - expect(((error as Error).cause as Error).message).toContain(cleanupReacquireSessionFile); - expect((error as { promptError?: unknown }).promptError).toBe(providerError); - expect(hoisted.flushPendingToolResultsAfterIdleMock).not.toHaveBeenCalled(); - }); - - it("keeps cleanup session takeover fatal when no provider prompt error exists", async () => { - let releasingCleanupLock = false; - hoisted.flushPendingToolResultsAfterIdleMock.mockImplementation(async () => { - releasingCleanupLock = true; - }); - hoisted.acquireSessionWriteLockMock.mockImplementation(async (params) => ({ - release: async () => { - if (releasingCleanupLock) { - throw new EmbeddedAttemptSessionTakeoverError(params.sessionFile); - } - }, - })); - - await expect( - createContextEngineAttemptRunner({ - contextEngine: createContextEngineBootstrapAndAssemble(), - sessionKey, - tempPaths, - sessionPrompt: async (session) => { - session.messages = [...session.messages, doneMessage]; - }, - }), - ).rejects.toBeInstanceOf(EmbeddedAttemptSessionTakeoverError); }); it("uses assembled context as the default precheck authority", async () => { @@ -2913,34 +2827,6 @@ describe("runEmbeddedAttempt context engine sessionKey forwarding", () => { }); expect(bootstrap).toHaveBeenCalled(); - expect(hoisted.prepareSessionManagerForRunMock).toHaveBeenCalledWith( - expect.objectContaining({ - sessionFile: created.sessionFile, - hadSessionFile: false, - }), - ); - }); - - it("resolves bootstrap context before acquiring the session write lock", async () => { - const events: string[] = []; - hoisted.resolveBootstrapContextForRunMock.mockImplementation(async () => { - events.push("bootstrap"); - return { bootstrapFiles: [], contextFiles: [] }; - }); - hoisted.acquireSessionWriteLockMock.mockImplementation(async () => { - events.push("lock"); - return { release: async () => {} }; - }); - - await createContextEngineAttemptRunner({ - contextEngine: createContextEngineBootstrapAndAssemble(), - sessionKey, - tempPaths, - }); - - expect(events).toContain("bootstrap"); - expect(events).toContain("lock"); - expect(events.indexOf("bootstrap")).toBeLessThan(events.indexOf("lock")); }); it("forwards modelId to assemble", async () => { diff --git a/src/agents/embedded-agent-runner/run/attempt.spawn-workspace.test-support.ts b/src/agents/embedded-agent-runner/run/attempt.spawn-workspace.test-support.ts index cdb475a768f0..40ce6e35fa5d 100644 --- a/src/agents/embedded-agent-runner/run/attempt.spawn-workspace.test-support.ts +++ b/src/agents/embedded-agent-runner/run/attempt.spawn-workspace.test-support.ts @@ -78,6 +78,8 @@ type SessionManagerMocks = { flushPendingPersistence: UnknownMock; flushPendingToolResults: UnknownMock; clearPendingToolResults: UnknownMock; + mergePromptReleasedSessionEntries: UnknownMock; + reloadPersistedTranscript: UnknownMock; clearNextUserMessagePersistenceSuppression: UnknownMock; removeTrailingEntries: UnknownMock; }; @@ -109,7 +111,6 @@ type AttemptSpawnWorkspaceHoisted = { getGlobalHookRunnerMock: Mock<() => unknown>; initializeGlobalHookRunnerMock: UnknownMock; runContextEngineMaintenanceMock: AsyncContextEngineMaintenanceMock; - prepareSessionManagerForRunMock: AsyncUnknownMock; detectAndLoadPromptImagesMock: AsyncUnknownMock; getHistoryLimitFromSessionKeyMock: Mock< (sessionKey: string | undefined, config: unknown) => number | undefined @@ -214,7 +215,6 @@ const hoisted = vi.hoisted((): AttemptSpawnWorkspaceHoisted => { const getGlobalHookRunnerMock = vi.fn<() => unknown>(() => undefined); const initializeGlobalHookRunnerMock = vi.fn(); const runContextEngineMaintenanceMock = vi.fn(async (_params?: unknown) => undefined); - const prepareSessionManagerForRunMock = vi.fn(async (_params?: unknown) => undefined); const detectAndLoadPromptImagesMock = vi.fn(async () => ({ images: [], imageFactIndexes: [], @@ -249,6 +249,8 @@ const hoisted = vi.hoisted((): AttemptSpawnWorkspaceHoisted => { flushPendingPersistence: vi.fn(), flushPendingToolResults: vi.fn(), clearPendingToolResults: vi.fn(), + mergePromptReleasedSessionEntries: vi.fn(), + reloadPersistedTranscript: vi.fn(), clearNextUserMessagePersistenceSuppression: vi.fn(), removeTrailingEntries: vi.fn(() => 0), }; @@ -280,7 +282,6 @@ const hoisted = vi.hoisted((): AttemptSpawnWorkspaceHoisted => { getGlobalHookRunnerMock, initializeGlobalHookRunnerMock, runContextEngineMaintenanceMock, - prepareSessionManagerForRunMock, detectAndLoadPromptImagesMock, getHistoryLimitFromSessionKeyMock, limitHistoryTurnsMock, @@ -406,6 +407,7 @@ vi.mock("../../sessions/index.js", () => { generateSummary: async () => "", ModelRegistry, SessionManager: { + inMemory: (...args: unknown[]) => hoisted.sessionManagerOpenMock(...args), open: (...args: unknown[]) => hoisted.sessionManagerOpenMock(...args), }, }; @@ -562,26 +564,19 @@ vi.mock("../tool-schema-runtime.js", () => ({ normalizeProviderToolSchemas: ({ tools }: { tools: unknown[] }) => tools, })); -vi.mock("../../session-file-repair.js", () => ({ - repairSessionFileIfNeeded: async () => ({ repaired: false, droppedLines: 0 }), -})); - -vi.mock("../session-manager-cache.js", () => ({ - prewarmSessionFile: async () => {}, - trackSessionManagerAccess: () => {}, -})); - -vi.mock("../session-manager-init.js", () => ({ - prepareSessionManagerForRun: (params: unknown) => hoisted.prepareSessionManagerForRunMock(params), -})); - -vi.mock("../../session-write-lock.js", () => ({ - acquireSessionWriteLock: (params: Parameters[0]) => - hoisted.acquireSessionWriteLockMock(params), - resolveSessionWriteLockAcquireTimeoutMs: () => 60000, - resolveSessionWriteLockOptions: () => ({ timeoutMs: 60000, staleMs: 1_800_000, maxHoldMs: 1 }), - resolveSessionLockMaxHoldFromTimeout: () => 1, -})); +vi.mock("../../session-write-lock.js", async () => { + const { resolveSessionWriteLockTargetKey } = await vi.importActual< + typeof import("../../session-write-lock.js") + >("../../session-write-lock.js"); + return { + acquireSessionWriteLock: (params: Parameters[0]) => + hoisted.acquireSessionWriteLockMock(params), + resolveSessionWriteLockAcquireTimeoutMs: () => 60000, + resolveSessionWriteLockOptions: () => ({ timeoutMs: 60000, staleMs: 1_800_000, maxHoldMs: 1 }), + resolveSessionLockMaxHoldFromTimeout: () => 1, + resolveSessionWriteLockTargetKey, + }; +}); vi.mock("../tool-result-context-guard.js", async () => { const actual = await vi.importActual( @@ -1114,7 +1109,6 @@ export function resetEmbeddedAttemptHarness( hoisted.supportsModelToolsMock.mockReset().mockReturnValue(true); hoisted.getGlobalHookRunnerMock.mockReset().mockReturnValue(undefined); hoisted.runContextEngineMaintenanceMock.mockReset().mockResolvedValue(undefined); - hoisted.prepareSessionManagerForRunMock.mockReset().mockResolvedValue(undefined); hoisted.getHistoryLimitFromSessionKeyMock.mockReset().mockReturnValue(undefined); hoisted.limitHistoryTurnsMock.mockReset().mockImplementation((messages) => messages); hoisted.preemptiveCompactionCalls.length = 0; @@ -1137,6 +1131,8 @@ export function resetEmbeddedAttemptHarness( hoisted.sessionManager.appendSessionInfo.mockReset(); hoisted.sessionManager.appendLabelChange.mockReset(); hoisted.sessionManager.flushPendingPersistence.mockReset(); + hoisted.sessionManager.mergePromptReleasedSessionEntries.mockReset(); + hoisted.sessionManager.reloadPersistedTranscript.mockReset(); if (params.subscribeImpl) { hoisted.subscribeEmbeddedAgentSessionMock.mockImplementation(params.subscribeImpl); } @@ -1334,10 +1330,8 @@ export async function createContextEngineAttemptRunner(params: { const { maintain: rawMaintain, ...contextEngineRest } = params.contextEngine; const workspaceDir = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-ctx-engine-workspace-")); const agentDir = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-ctx-engine-agent-")); - const sessionFile = path.join(workspaceDir, "session.jsonl"); const sessionStore = path.join(workspaceDir, "sessions.json"); params.tempPaths.push(workspaceDir, agentDir); - await fs.writeFile(sessionFile, "", "utf8"); const seedMessages: AgentMessage[] = params.sessionMessages ?? ([{ role: "user", content: "seed", timestamp: 1 }] as AgentMessage[]); const infoId = params.contextEngine.info?.id ?? "test-context-engine"; @@ -1390,7 +1384,13 @@ export async function createContextEngineAttemptRunner(params: { )({ sessionId: "embedded-session", sessionKey: params.sessionKey, - sessionFile, + sessionFile: params.sessionKey, + sessionTarget: { + agentId: "main", + sessionId: "embedded-session", + sessionKey: params.sessionKey, + storePath: sessionStore, + }, workspaceDir, agentDir, config: { session: { store: sessionStore } }, diff --git a/src/agents/embedded-agent-runner/run/params.ts b/src/agents/embedded-agent-runner/run/params.ts index 6f5e5f3a187d..785b31a3cbf5 100644 --- a/src/agents/embedded-agent-runner/run/params.ts +++ b/src/agents/embedded-agent-runner/run/params.ts @@ -44,6 +44,7 @@ import type { AgentInternalEvent } from "../../internal-events.js"; import type { AgentRunSessionTarget } from "../../run-session-target.js"; import type { AgentMessage } from "../../runtime/index.js"; import type { ScheduledToolPolicyContext } from "../../scheduled-tool-policy.js"; +import type { SessionManager } from "../../sessions/index.js"; import type { SilentReplyPromptMode } from "../../system-prompt.types.js"; import type { PromptMode } from "../../system-prompt.types.js"; import type { EmbeddedAgentExecutionPhase } from "../execution-phase.js"; @@ -69,6 +70,8 @@ export type CurrentInboundPromptContext = { }; export type RunEmbeddedAgentParams = { + /** Caller-owned in-memory transcript for ephemeral helper runs. */ + sessionManager?: SessionManager; sessionId: string; sessionKey?: string; /** Storage-neutral transcript/session target. Defaults to sessionId/sessionKey/agentId. */ diff --git a/src/agents/embedded-agent-runner/run/session-bootstrap.ts b/src/agents/embedded-agent-runner/run/session-bootstrap.ts index d5dd0a6359af..caadc0700a5a 100644 --- a/src/agents/embedded-agent-runner/run/session-bootstrap.ts +++ b/src/agents/embedded-agent-runner/run/session-bootstrap.ts @@ -1,11 +1,21 @@ +import path from "node:path"; import { normalizeOptionalString } from "@openclaw/normalization-core/string-coerce"; import { sanitizeForLog } from "../../../../packages/terminal-core/src/ansi.js"; import { resolveStorePath } from "../../../config/sessions.js"; -import { loadSessionEntry, updateSessionEntry } from "../../../config/sessions/session-accessor.js"; -import { parseSqliteSessionFileMarker } from "../../../config/sessions/sqlite-marker.js"; +import { parseSqliteSessionFileMarker } from "../../../config/sessions/legacy-sqlite-marker.js"; +import { + listSessionEntries, + loadSessionEntry, + updateSessionEntry, +} from "../../../config/sessions/session-accessor.js"; import type { ContextEngineSessionTarget } from "../../../context-engine/types.js"; import { formatErrorMessage } from "../../../infra/errors.js"; -import { resolveAgentIdFromSessionKey } from "../../../routing/session-key.js"; +import { + parseAgentSessionKey, + resolveAgentIdFromSessionKey, +} from "../../../routing/session-key.js"; +import { resolvePreferredSessionKeyForSessionIdMatches } from "../../../sessions/session-id-resolution.js"; +import { resolveDefaultAgentId } from "../../agent-scope.js"; import { resolveSessionKeyForRequest, resolveStoredSessionKeyForSessionId, @@ -18,23 +28,81 @@ import { resolveAgentHarnessRunAdmissionError } from "./setup.js"; const NO_REAL_CONVERSATION_MESSAGES_REASON = "no real conversation messages"; export function buildContextEngineCompactionSessionTarget(params: { - agentId: string; + agentId?: string; config?: RunEmbeddedAgentParams["config"]; sessionFile: string; sessionId: string; sessionKey?: string; sessionTarget?: RunEmbeddedAgentParams["sessionTarget"]; }): ContextEngineSessionTarget { - const sqliteMarker = parseSqliteSessionFileMarker(params.sessionFile); - const agentId = params.sessionTarget?.agentId ?? sqliteMarker?.agentId ?? params.agentId; - const sessionKey = params.sessionTarget?.sessionKey ?? params.sessionKey ?? params.sessionId; + const targetAgentId = normalizeOptionalString(params.sessionTarget?.agentId); + const targetSessionId = normalizeOptionalString(params.sessionTarget?.sessionId); + const targetSessionKey = normalizeOptionalString(params.sessionTarget?.sessionKey); + const targetStorePath = normalizeOptionalString(params.sessionTarget?.storePath); + const completeTarget = Boolean( + targetAgentId && targetSessionId && targetSessionKey && targetStorePath, + ); + const marker = completeTarget ? undefined : parseSqliteSessionFileMarker(params.sessionFile); + const suppliedSessionKey = normalizeOptionalString(params.sessionKey); + const candidateSessionKey = targetSessionKey ?? suppliedSessionKey; + const candidateKeyAgentId = parseAgentSessionKey(candidateSessionKey)?.agentId; + const suppliedEntry = + marker && candidateSessionKey + ? loadSessionEntry({ + agentId: marker.agentId, + sessionKey: candidateSessionKey, + storePath: marker.storePath, + }) + : undefined; + const markerMatches = marker + ? listSessionEntries({ + agentId: marker.agentId, + storePath: marker.storePath, + }).filter(({ entry }) => entry.sessionId === marker.sessionId) + : []; + const preferredMarkerSessionKey = marker + ? resolvePreferredSessionKeyForSessionIdMatches( + markerMatches.map(({ sessionKey, entry }) => [sessionKey, entry]), + marker.sessionId, + ) + : undefined; + const markerSessionKey = marker + ? suppliedEntry?.sessionId === marker.sessionId + ? candidateSessionKey + : candidateSessionKey && !suppliedEntry + ? candidateSessionKey + : (preferredMarkerSessionKey ?? (markerMatches.length === 0 ? marker.sessionId : undefined)) + : undefined; + if (marker && !markerSessionKey) { + throw new Error("Legacy compaction transcript identity is ambiguous"); + } + if ( + marker && + ((targetAgentId && targetAgentId !== marker.agentId) || + (targetSessionId && targetSessionId !== marker.sessionId) || + (candidateKeyAgentId && candidateKeyAgentId !== marker.agentId) || + (targetStorePath && path.resolve(targetStorePath) !== path.resolve(marker.storePath)) || + (candidateSessionKey && suppliedEntry && suppliedEntry.sessionId !== marker.sessionId)) + ) { + throw new Error("Legacy compaction transcript identity is inconsistent"); + } + const sessionKey = completeTarget + ? targetSessionKey + : marker + ? markerSessionKey + : (targetSessionKey ?? suppliedSessionKey ?? targetSessionId ?? params.sessionId); + const agentId = + targetAgentId ?? + marker?.agentId ?? + params.agentId ?? + resolveAgentIdFromSessionKey(sessionKey, resolveDefaultAgentId(params.config ?? {})); const storePath = - params.sessionTarget?.storePath ?? - sqliteMarker?.storePath ?? + targetStorePath ?? + marker?.storePath ?? resolveStorePath(params.config?.session?.store, { agentId }); return { agentId, - sessionId: params.sessionTarget?.sessionId ?? sqliteMarker?.sessionId ?? params.sessionId, + sessionId: targetSessionId ?? marker?.sessionId ?? params.sessionId, ...(sessionKey ? { sessionKey } : {}), ...(storePath ? { storePath } : {}), ...(params.sessionTarget?.threadId !== undefined diff --git a/src/agents/embedded-agent-runner/run/session-prompt-state.ts b/src/agents/embedded-agent-runner/run/session-prompt-state.ts index 34b30236fec7..8695058f51b6 100644 --- a/src/agents/embedded-agent-runner/run/session-prompt-state.ts +++ b/src/agents/embedded-agent-runner/run/session-prompt-state.ts @@ -33,6 +33,7 @@ export function createEmbeddedRunSessionPromptState(input: { sessionKey: resolvedSessionKey, sessionTarget: params.sessionTarget, }); + let sessionTargetAdopted = false; let suppressNextUserMessagePersistence = params.suppressNextUserMessagePersistence ?? false; let activePrompt: ActivePrompt = { persisted: suppressNextUserMessagePersistence, @@ -64,8 +65,12 @@ export function createEmbeddedRunSessionPromptState(input: { sessionKey: nextSessionTarget.sessionKey ?? resolvedSessionKey, sessionTarget: nextSessionTarget, }); - activeSessionTarget = nextSessionTarget; - activeSessionFile = resolvedTarget.sessionFile; + activeSessionTarget = { + ...resolvedTarget, + ...(nextSessionTarget.threadId !== undefined ? { threadId: nextSessionTarget.threadId } : {}), + }; + sessionTargetAdopted = true; + activeSessionFile = resolvedTarget.sessionKey; adoptSessionId(resolvedTarget.sessionId); }; const activateInternalPrompt = (prompt: string, persisted: boolean) => { @@ -127,6 +132,10 @@ export function createEmbeddedRunSessionPromptState(input: { }, set sessionTarget(value: ContextEngineSessionTarget | undefined) { activeSessionTarget = value; + sessionTargetAdopted = true; + }, + get sessionTargetAdopted() { + return sessionTargetAdopted; }, get activePrompt() { return activePrompt; diff --git a/src/agents/embedded-agent-runner/runs.generation.test.ts b/src/agents/embedded-agent-runner/runs.generation.test.ts index 53bb410829d8..56a8ada12e6f 100644 --- a/src/agents/embedded-agent-runner/runs.generation.test.ts +++ b/src/agents/embedded-agent-runner/runs.generation.test.ts @@ -18,7 +18,6 @@ import { resolveActiveEmbeddedRunHandleSessionId, resolveActiveEmbeddedRunHandleSessionIdBySessionFile, setActiveEmbeddedRun, - updateActiveEmbeddedRunSessionFile, } from "./runs.js"; import { testing } from "./runs.test-support.js"; @@ -117,12 +116,6 @@ describe("embedded run registry lifecycle generations", () => { "agent:main:stale", "/tmp/stale-session.jsonl", ); - updateActiveEmbeddedRunSessionFile( - "shared-session", - "/tmp/stale-update.jsonl", - priorLifecycleGeneration, - ); - await expect( queueEmbeddedAgentMessageWithOutcomeAsync("shared-session", "still live"), ).resolves.toMatchObject({ queued: true, target: "embedded_run" }); @@ -133,9 +126,6 @@ describe("embedded run registry lifecycle generations", () => { expect(listActiveEmbeddedRunSessionIds()).toContain("shared-session"); expect(listActiveEmbeddedRunSessionKeys()).toEqual(["agent:main:current"]); expect(resolveActiveEmbeddedRunHandleSessionId("agent:main:stale")).toBeUndefined(); - expect( - resolveActiveEmbeddedRunHandleSessionIdBySessionFile("/tmp/stale-update.jsonl"), - ).toBeUndefined(); expect(isEmbeddedAgentRunAbortableForRunId("current-run")).toBe(false); expect(isEmbeddedAgentRunAbortableForRunId("stale-run")).toBe(true); }); diff --git a/src/agents/embedded-agent-runner/runs.test.ts b/src/agents/embedded-agent-runner/runs.test.ts index f1ed98cc936e..f72cd5ffca2a 100644 --- a/src/agents/embedded-agent-runner/runs.test.ts +++ b/src/agents/embedded-agent-runner/runs.test.ts @@ -1,8 +1,5 @@ // Embedded run registry tests cover active run handles, queueing, abort/drain, // abandonment tracking, diagnostics, and snapshots. -import fs from "node:fs/promises"; -import os from "node:os"; -import path from "node:path"; import { importFreshModule } from "openclaw/plugin-sdk/test-fixtures"; import { afterEach, describe, expect, it, vi } from "vitest"; import { @@ -10,7 +7,6 @@ import { isReplyRunActiveForSessionId, } from "../../auto-reply/reply/reply-run-registry.js"; import { testing as replyRunTesting } from "../../auto-reply/reply/reply-run-registry.test-support.js"; -import { getAgentEventLifecycleGeneration } from "../../infra/agent-events.js"; import { setDiagnosticsEnabledForProcess } from "../../infra/diagnostic-events.js"; import { resetDiagnosticRunActivityForTest } from "../../logging/diagnostic-run-activity.js"; import { markDiagnosticToolStartedForTest } from "../../logging/diagnostic-run-activity.test-support.js"; @@ -41,7 +37,6 @@ import { resolveActiveEmbeddedRunHandleSessionIdBySessionFile, setActiveEmbeddedRun, updateActiveEmbeddedRunSnapshot, - updateActiveEmbeddedRunSessionFile, waitForActiveEmbeddedRuns, waitForEmbeddedAgentRunEnd, } from "./runs.js"; @@ -400,30 +395,6 @@ describe("embedded-agent runner run registry", () => { expect(abort).not.toHaveBeenCalled(); }); - it("resolves active embedded runs by canonical session file", async () => { - // Session-file lookup canonicalizes symlinks so heartbeat/diagnostic callers - // can find the active handle from the file path they observe. - const tempDir = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-run-registry-")); - try { - const sessionFile = path.join(tempDir, "session.jsonl"); - const symlinkFile = path.join(tempDir, "session-link.jsonl"); - await fs.writeFile(sessionFile, '{"type":"session"}\n', "utf8"); - await fs.symlink(sessionFile, symlinkFile); - const handle = createRunHandle(); - - setActiveEmbeddedRun("session-file-run", handle, "agent:main:visible", sessionFile); - - expect(resolveActiveEmbeddedRunHandleSessionIdBySessionFile(symlinkFile)).toBe( - "session-file-run", - ); - - clearActiveEmbeddedRun("session-file-run", handle, "agent:main:visible", sessionFile); - expect(resolveActiveEmbeddedRunHandleSessionIdBySessionFile(symlinkFile)).toBeUndefined(); - } finally { - await fs.rm(tempDir, { recursive: true, force: true }); - } - }); - it("records active run session files in diagnostic state for heartbeat recovery", () => { setDiagnosticsEnabledForProcess(true); const sessionFile = "/tmp/openclaw-run-registry-session.jsonl"; @@ -434,16 +405,6 @@ describe("embedded-agent runner run registry", () => { expect(getDiagnosticSessionState({ sessionId: "session-file-diagnostics" }).sessionFile).toBe( sessionFile, ); - - updateActiveEmbeddedRunSessionFile( - "session-file-diagnostics", - "/tmp/openclaw-run-registry-rotated.jsonl", - getAgentEventLifecycleGeneration(), - ); - - expect(getDiagnosticSessionState({ sessionId: "session-file-diagnostics" }).sessionFile).toBe( - "/tmp/openclaw-run-registry-rotated.jsonl", - ); }); it("passes steering options to active embedded runs", () => { @@ -1050,6 +1011,19 @@ describe("embedded-agent runner run registry", () => { expect(resolveActiveEmbeddedRunHandleSessionId("agent:main:main")).toBeUndefined(); }); + it("clears a relative compatibility file key after normalization", () => { + const handle = createRunHandle(); + const sessionFile = "relative-session-token"; + + setActiveEmbeddedRun("session-relative", handle, "agent:main:relative", sessionFile); + expect(resolveActiveEmbeddedRunHandleSessionIdBySessionFile(sessionFile)).toBe( + "session-relative", + ); + + clearActiveEmbeddedRun("session-relative", handle, "agent:main:relative", sessionFile); + expect(resolveActiveEmbeddedRunHandleSessionIdBySessionFile(sessionFile)).toBeUndefined(); + }); + it("tracks timeout abandonment by session id, key, and file until a new run starts", () => { // Abandonment markers must catch retries addressed by any durable identity, // then clear once a new run owns the same session key/file. diff --git a/src/agents/embedded-agent-runner/runs.ts b/src/agents/embedded-agent-runner/runs.ts index cb392c9ee274..becad34647d4 100644 --- a/src/agents/embedded-agent-runner/runs.ts +++ b/src/agents/embedded-agent-runner/runs.ts @@ -1,6 +1,8 @@ /** * Manages active embedded-agent run handles, queues, aborts, and waiters. */ +import fs from "node:fs"; +import path from "node:path"; import { abortActiveReplyRuns, abortReplyRunBySessionId, @@ -34,17 +36,12 @@ import { resolveRunStaleThresholdMs, } from "../../logging/diagnostic-run-activity.js"; import { logMessageQueuedWithBacklogPolicy } from "../../logging/diagnostic-runtime.js"; -import { - diagnosticLogger as diag, - logSessionStateChange, - updateDiagnosticSessionFile, -} from "../../logging/diagnostic.js"; +import { diagnosticLogger as diag, logSessionStateChange } from "../../logging/diagnostic.js"; import { resolveAgentIdFromSessionKey } from "../../routing/session-key.js"; import { resolveTimerTimeoutMs } from "../../shared/number-coercion.js"; import { ACTIVE_EMBEDDED_RUNS, ACTIVE_EMBEDDED_RUNS_BY_RUN_ID, - ACTIVE_EMBEDDED_RUN_LIFECYCLE_GENERATIONS, ACTIVE_EMBEDDED_RUN_SESSION_IDS_BY_FILE, ACTIVE_EMBEDDED_RUN_SESSION_IDS_BY_KEY, ACTIVE_EMBEDDED_RUN_SNAPSHOTS, @@ -61,7 +58,6 @@ import { type EmbeddedAgentQueueMessageOptions, type EmbeddedRunWaiter, } from "./run-state.js"; -import { resolveEmbeddedSessionFileKey } from "./session-file-key.js"; export { getActiveEmbeddedRunCount, @@ -156,14 +152,36 @@ function clearActiveRunSessionKeys(sessionId: string, sessionKey?: string): void } } +function normalizeSessionFileRegistryKey(sessionFile: string | undefined): string | undefined { + const normalized = sessionFile?.trim(); + if (!normalized) { + return undefined; + } + if ( + normalized.startsWith("agent:") || + normalized.startsWith("sqlite:") || + normalized.startsWith("in-memory:") + ) { + return normalized; + } + const resolved = path.resolve(normalized); + const parent = path.dirname(resolved); + try { + // Canonicalize only the parent so a registry key stays stable when the + // transcript file itself is created or removed during the active run. + // Artifact-file symlinks are not runtime session identity after SQLite migration. + return path.join(fs.realpathSync(parent), path.basename(resolved)); + } catch { + return resolved; + } +} + function setActiveRunSessionFile(sessionFile: string | undefined, sessionId: string): void { - if (!sessionFile?.trim()) { + const normalizedSessionFile = normalizeSessionFileRegistryKey(sessionFile); + if (!normalizedSessionFile) { return; } - ACTIVE_EMBEDDED_RUN_SESSION_IDS_BY_FILE.set( - resolveEmbeddedSessionFileKey(sessionFile), - sessionId, - ); + ACTIVE_EMBEDDED_RUN_SESSION_IDS_BY_FILE.set(normalizedSessionFile, sessionId); } function clearEmbeddedRunAbandonmentBySessionId(sessionId: string): void { @@ -179,9 +197,9 @@ function clearEmbeddedRunAbandonmentBySessionId(sessionId: string): void { ) { ABANDONED_EMBEDDED_RUN_SESSION_IDS_BY_KEY.delete(normalizedSessionKey); } - const normalizedSessionFile = abandonedRun.sessionFile?.trim(); + const normalizedSessionFile = normalizeSessionFileRegistryKey(abandonedRun.sessionFile); if (normalizedSessionFile) { - const sessionFileKey = resolveEmbeddedSessionFileKey(normalizedSessionFile); + const sessionFileKey = normalizedSessionFile; if (ABANDONED_EMBEDDED_RUN_SESSION_IDS_BY_FILE.get(sessionFileKey) === sessionId) { ABANDONED_EMBEDDED_RUN_SESSION_IDS_BY_FILE.delete(sessionFileKey); } @@ -200,11 +218,11 @@ function clearEmbeddedRunAbandonmentBySessionKey(sessionKey: string | undefined) } function clearEmbeddedRunAbandonmentBySessionFile(sessionFile: string | undefined): void { - const normalizedSessionFile = sessionFile?.trim(); + const normalizedSessionFile = normalizeSessionFileRegistryKey(sessionFile); if (!normalizedSessionFile) { return; } - const sessionFileKey = resolveEmbeddedSessionFileKey(normalizedSessionFile); + const sessionFileKey = normalizedSessionFile; const sessionId = ABANDONED_EMBEDDED_RUN_SESSION_IDS_BY_FILE.get(sessionFileKey); if (sessionId) { clearEmbeddedRunAbandonmentBySessionId(sessionId); @@ -239,22 +257,20 @@ function markEmbeddedRunAbandoned(params: { sessionKey: params.sessionKey, sessionFile: params.sessionFile, }); + const normalizedSessionFile = normalizeSessionFileRegistryKey(params.sessionFile); const abandonedRun: AbandonedEmbeddedRun = { sessionId, abandonedAtMs: Date.now(), reason: params.reason, ...(params.sessionKey?.trim() ? { sessionKey: params.sessionKey.trim() } : {}), - ...(params.sessionFile?.trim() ? { sessionFile: params.sessionFile.trim() } : {}), + ...(normalizedSessionFile ? { sessionFile: normalizedSessionFile } : {}), }; ABANDONED_EMBEDDED_RUNS_BY_SESSION_ID.set(sessionId, abandonedRun); if (abandonedRun.sessionKey) { ABANDONED_EMBEDDED_RUN_SESSION_IDS_BY_KEY.set(abandonedRun.sessionKey, sessionId); } if (abandonedRun.sessionFile) { - ABANDONED_EMBEDDED_RUN_SESSION_IDS_BY_FILE.set( - resolveEmbeddedSessionFileKey(abandonedRun.sessionFile), - sessionId, - ); + ABANDONED_EMBEDDED_RUN_SESSION_IDS_BY_FILE.set(abandonedRun.sessionFile, sessionId); } } @@ -286,23 +302,21 @@ export function isEmbeddedRunAbandoned(params: { if (normalizedSessionKey && ABANDONED_EMBEDDED_RUN_SESSION_IDS_BY_KEY.has(normalizedSessionKey)) { return true; } - const normalizedSessionFile = params.sessionFile?.trim(); + const normalizedSessionFile = normalizeSessionFileRegistryKey(params.sessionFile); return Boolean( - normalizedSessionFile && - ABANDONED_EMBEDDED_RUN_SESSION_IDS_BY_FILE.has( - resolveEmbeddedSessionFileKey(normalizedSessionFile), - ), + normalizedSessionFile && ABANDONED_EMBEDDED_RUN_SESSION_IDS_BY_FILE.has(normalizedSessionFile), ); } function clearActiveRunSessionFiles(sessionId: string, sessionFile?: string): void { - const normalizedSessionFile = sessionFile?.trim(); + const normalizedSessionFile = normalizeSessionFileRegistryKey(sessionFile); if (normalizedSessionFile) { - const sessionFileKey = resolveEmbeddedSessionFileKey(normalizedSessionFile); - if (ACTIVE_EMBEDDED_RUN_SESSION_IDS_BY_FILE.get(sessionFileKey) === sessionId) { - ACTIVE_EMBEDDED_RUN_SESSION_IDS_BY_FILE.delete(sessionFileKey); + if (ACTIVE_EMBEDDED_RUN_SESSION_IDS_BY_FILE.get(normalizedSessionFile) === sessionId) { + ACTIVE_EMBEDDED_RUN_SESSION_IDS_BY_FILE.delete(normalizedSessionFile); } } + // Always sweep every alias because callers may clear without the same + // compatibility token used when the active run was registered. for (const [sessionFileKey, activeSessionId] of ACTIVE_EMBEDDED_RUN_SESSION_IDS_BY_FILE) { if (activeSessionId === sessionId) { ACTIVE_EMBEDDED_RUN_SESSION_IDS_BY_FILE.delete(sessionFileKey); @@ -709,13 +723,11 @@ export function resolveActiveEmbeddedRunHandleSessionId(sessionKey: string): str export function resolveActiveEmbeddedRunHandleSessionIdBySessionFile( sessionFile: string, ): string | undefined { - const normalizedSessionFile = sessionFile.trim(); + const normalizedSessionFile = normalizeSessionFileRegistryKey(sessionFile); if (!normalizedSessionFile) { return undefined; } - return ACTIVE_EMBEDDED_RUN_SESSION_IDS_BY_FILE.get( - resolveEmbeddedSessionFileKey(normalizedSessionFile), - ); + return ACTIVE_EMBEDDED_RUN_SESSION_IDS_BY_FILE.get(normalizedSessionFile); } export function resolveActiveEmbeddedRunSessionIdBySessionFile( @@ -1038,24 +1050,6 @@ export function updateActiveEmbeddedRunSnapshot( ACTIVE_EMBEDDED_RUN_SNAPSHOTS.set(sessionId, snapshot); } -export function updateActiveEmbeddedRunSessionFile( - sessionId: string, - sessionFile: string | undefined, - lifecycleGeneration: string, -): void { - const handle = ACTIVE_EMBEDDED_RUNS.get(sessionId); - if ( - handle === undefined || - ACTIVE_EMBEDDED_RUN_LIFECYCLE_GENERATIONS.get(handle) !== lifecycleGeneration || - !isAgentEventLifecycleGenerationCurrent(lifecycleGeneration) - ) { - return; - } - clearActiveRunSessionFiles(sessionId); - setActiveRunSessionFile(sessionFile, sessionId); - updateDiagnosticSessionFile({ sessionId, sessionFile }); -} - export function clearActiveEmbeddedRun( sessionId: string, handle: EmbeddedAgentQueueHandle, diff --git a/src/agents/embedded-agent-runner/session-file-key.ts b/src/agents/embedded-agent-runner/session-file-key.ts deleted file mode 100644 index b952408978cd..000000000000 --- a/src/agents/embedded-agent-runner/session-file-key.ts +++ /dev/null @@ -1,22 +0,0 @@ -/** - * Resolves stable keys for embedded session transcript files. - */ -import fs from "node:fs"; -import path from "node:path"; - -export function resolveEmbeddedSessionFileKey(sessionFile: string): string { - const resolvedSessionFile = path.resolve(sessionFile); - const realpathSync = fs.realpathSync.native ?? fs.realpathSync; - try { - return realpathSync(resolvedSessionFile); - } catch { - // New transcript files often do not exist yet. Canonicalize the existing - // parent so aliases still collapse before the first write creates the file. - } - const sessionDir = path.dirname(resolvedSessionFile); - try { - return path.join(realpathSync(sessionDir), path.basename(resolvedSessionFile)); - } catch { - return resolvedSessionFile; - } -} diff --git a/src/agents/embedded-agent-runner/session-manager-cache.test-support.ts b/src/agents/embedded-agent-runner/session-manager-cache.test-support.ts deleted file mode 100644 index de097ef7deca..000000000000 --- a/src/agents/embedded-agent-runner/session-manager-cache.test-support.ts +++ /dev/null @@ -1,32 +0,0 @@ -import fs from "node:fs/promises"; -import "./session-manager-cache.js"; - -type SessionManagerCache = { - clear(): void; - isSessionManagerCached(sessionFile: string): boolean; - keys(): string[]; - prewarmSessionFile(sessionFile: string): Promise; - trackSessionManagerAccess(sessionFile: string): void; -}; - -type SessionManagerCacheTestApi = { - createSessionManagerCache(options?: { - clock?: () => number; - fsModule?: Pick; - ttlMs?: number | (() => number); - }): SessionManagerCache; -}; - -function getTestApi(): SessionManagerCacheTestApi { - const api = (globalThis as Record)[ - Symbol.for("openclaw.sessionManagerCacheTestApi") - ]; - if (!api) { - throw new Error("session manager cache test API is unavailable"); - } - return api as SessionManagerCacheTestApi; -} - -export const createSessionManagerCache: SessionManagerCacheTestApi["createSessionManagerCache"] = ( - options, -) => getTestApi().createSessionManagerCache(options); diff --git a/src/agents/embedded-agent-runner/session-manager-cache.test.ts b/src/agents/embedded-agent-runner/session-manager-cache.test.ts deleted file mode 100644 index 70ebdcce56c9..000000000000 --- a/src/agents/embedded-agent-runner/session-manager-cache.test.ts +++ /dev/null @@ -1,35 +0,0 @@ -// Session manager cache tests cover TTL pruning and explicit cache-disable -// behavior for transcript-backed session managers. -import { describe, expect, it } from "vitest"; -import { createSessionManagerCache } from "./session-manager-cache.test-support.js"; - -describe("session manager cache", () => { - it("prunes expired entries during later cache activity even without revisiting them", () => { - // Pruning happens opportunistically on cache activity, not only when the - // stale key is accessed again. - let now = 1_000; - const cache = createSessionManagerCache({ - clock: () => now, - ttlMs: 5_000, - }); - - cache.trackSessionManagerAccess("/tmp/stale-session.jsonl"); - expect(cache.keys()).toEqual(["/tmp/stale-session.jsonl"]); - - now = 7_000; - - cache.trackSessionManagerAccess("/tmp/fresh-session.jsonl"); - expect(cache.keys()).toEqual(["/tmp/fresh-session.jsonl"]); - }); - - it("can disable caching via the injected TTL resolver", () => { - const cache = createSessionManagerCache({ - ttlMs: 0, - }); - - cache.trackSessionManagerAccess("/tmp/session.jsonl"); - - expect(cache.isSessionManagerCached("/tmp/session.jsonl")).toBe(false); - expect(cache.keys()).toStrictEqual([]); - }); -}); diff --git a/src/agents/embedded-agent-runner/session-manager-cache.ts b/src/agents/embedded-agent-runner/session-manager-cache.ts deleted file mode 100644 index 876e708b3d8e..000000000000 --- a/src/agents/embedded-agent-runner/session-manager-cache.ts +++ /dev/null @@ -1,94 +0,0 @@ -/** - * Caches and prewarms session managers used by embedded-agent runs. - */ -import { Buffer } from "node:buffer"; -import fs from "node:fs/promises"; -import { createExpiringMapCache, isCacheEnabled } from "../../config/cache-utils.js"; -import { parseSqliteSessionFileMarker } from "../../config/sessions/sqlite-marker.js"; - -const DEFAULT_SESSION_MANAGER_TTL_MS = 45_000; // 45 seconds -const MIN_SESSION_MANAGER_CACHE_PRUNE_INTERVAL_MS = 1_000; -const MAX_SESSION_MANAGER_CACHE_PRUNE_INTERVAL_MS = 30_000; - -function resolveSessionManagerCachePruneInterval(ttlMs: number): number { - return Math.min( - Math.max(ttlMs, MIN_SESSION_MANAGER_CACHE_PRUNE_INTERVAL_MS), - MAX_SESSION_MANAGER_CACHE_PRUNE_INTERVAL_MS, - ); -} - -type SessionManagerCache = { - clear: () => void; - isSessionManagerCached: (sessionFile: string) => boolean; - keys: () => string[]; - prewarmSessionFile: (sessionFile: string) => Promise; - trackSessionManagerAccess: (sessionFile: string) => void; -}; - -function createSessionManagerCache(options?: { - clock?: () => number; - fsModule?: Pick; - ttlMs?: number | (() => number); -}): SessionManagerCache { - const getTtlMs = () => - typeof options?.ttlMs === "function" - ? options.ttlMs() - : (options?.ttlMs ?? DEFAULT_SESSION_MANAGER_TTL_MS); - const cache = createExpiringMapCache({ - ttlMs: getTtlMs, - pruneIntervalMs: resolveSessionManagerCachePruneInterval, - clock: options?.clock, - }); - const fsModule = options?.fsModule ?? fs; - - return { - clear: () => { - cache.clear(); - }, - isSessionManagerCached: (sessionFile) => cache.get(sessionFile) === true, - keys: () => cache.keys(), - prewarmSessionFile: async (sessionFile) => { - if (!isCacheEnabled(getTtlMs())) { - return; - } - if (parseSqliteSessionFileMarker(sessionFile)) { - return; - } - if (cache.get(sessionFile) === true) { - return; - } - - try { - // Read a small chunk to encourage OS page cache warmup. - const handle = await fsModule.open(sessionFile, "r"); - try { - const buffer = Buffer.alloc(4096); - await handle.read(buffer, 0, buffer.length, 0); - } finally { - await handle.close(); - } - cache.set(sessionFile, true); - } catch { - // File doesn't exist yet, SessionManager will create it - } - }, - trackSessionManagerAccess: (sessionFile) => { - cache.set(sessionFile, true); - }, - }; -} - -const sessionManagerCache = createSessionManagerCache(); - -export function trackSessionManagerAccess(sessionFile: string): void { - sessionManagerCache.trackSessionManagerAccess(sessionFile); -} - -export async function prewarmSessionFile(sessionFile: string): Promise { - await sessionManagerCache.prewarmSessionFile(sessionFile); -} - -if (process.env.VITEST || process.env.NODE_ENV === "test") { - (globalThis as Record)[Symbol.for("openclaw.sessionManagerCacheTestApi")] = - { createSessionManagerCache }; -} diff --git a/src/agents/embedded-agent-runner/session-manager-init.test.ts b/src/agents/embedded-agent-runner/session-manager-init.test.ts deleted file mode 100644 index 4f91927f60fa..000000000000 --- a/src/agents/embedded-agent-runner/session-manager-init.test.ts +++ /dev/null @@ -1,527 +0,0 @@ -// Session manager init tests cover how run startup rewrites or preserves -// transcript headers when resuming, forking, or recovering sessions. -import fs from "node:fs/promises"; -import os from "node:os"; -import path from "node:path"; -import { afterEach, describe, expect, it, vi } from "vitest"; -import { SessionManager } from "../sessions/session-manager.js"; -import { prepareSessionManagerForRun } from "./session-manager-init.js"; - -const tempPaths: string[] = []; - -async function makeTempFile(): Promise { - // Each case gets its own transcript file so destructive rewrite checks stay - // isolated from recovery-path assertions. - const dir = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-session-manager-init-")); - tempPaths.push(dir); - return path.join(dir, "session.jsonl"); -} - -describe("prepareSessionManagerForRun", () => { - afterEach(async () => { - await Promise.all( - tempPaths.splice(0).map((dir) => fs.rm(dir, { recursive: true, force: true })), - ); - }); - - it("rewrites pre-created no-assistant session headers to the runtime cwd", async () => { - const sessionFile = await makeTempFile(); - await fs.writeFile(sessionFile, '{"type":"session"}\n', "utf-8"); - const sessionManager = { - sessionId: "old-session", - cwd: "/srv/openclaw/main", - flushed: true, - fileEntries: [ - { - type: "session", - id: "old-session", - cwd: "/srv/openclaw/main", - }, - { - type: "message", - message: { role: "user" }, - }, - ], - byId: new Map([["old", {}]]), - labelsById: new Map([["old", {}]]), - labelTimestampsById: new Map([["old", "2026-05-27T00:00:01.000Z"]]), - leafId: "old", - }; - const readFileSpy = vi.spyOn(fs, "readFile"); - - await prepareSessionManagerForRun({ - sessionManager, - sessionFile, - hadSessionFile: true, - sessionId: "new-session", - cwd: "/tmp/task-repo", - }); - - expect(sessionManager.sessionId).toBe("new-session"); - expect(sessionManager.cwd).toBe("/tmp/task-repo"); - expect(sessionManager.fileEntries).toEqual([ - { - type: "session", - id: "new-session", - cwd: "/tmp/task-repo", - }, - ]); - expect(sessionManager.byId.size).toBe(0); - expect(sessionManager.labelsById.size).toBe(0); - expect(sessionManager.labelTimestampsById.size).toBe(0); - expect(sessionManager.leafId).toBeNull(); - expect(sessionManager.flushed).toBe(false); - expect(readFileSpy).not.toHaveBeenCalled(); - readFileSpy.mockRestore(); - expect(await fs.readFile(sessionFile, "utf-8")).toBe(""); - }); - - it("clears the append parent when resetting a real user-only manager", async () => { - const sessionFile = await makeTempFile(); - await fs.writeFile( - sessionFile, - [ - JSON.stringify({ - type: "session", - version: 3, - id: "old-session", - timestamp: "2026-05-27T00:00:00.000Z", - cwd: "/old/cwd", - }), - JSON.stringify({ - type: "message", - id: "old-user", - parentId: null, - timestamp: "2026-05-27T00:00:01.000Z", - message: { role: "user", content: "old prompt" }, - }), - ].join("\n") + "\n", - "utf8", - ); - const sessionManager = SessionManager.open(sessionFile, path.dirname(sessionFile), "/old/cwd"); - - await prepareSessionManagerForRun({ - sessionManager, - sessionFile, - hadSessionFile: true, - sessionId: "new-session", - cwd: "/tmp/task-repo", - }); - sessionManager.appendMessage({ - role: "assistant", - content: [{ type: "text", text: "response" }], - api: "messages", - provider: "anthropic", - model: "sonnet-4.6", - usage: { - input: 0, - output: 0, - cacheRead: 0, - cacheWrite: 0, - totalTokens: 0, - cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 }, - }, - stopReason: "stop", - timestamp: Date.now(), - }); - - const entries = (await fs.readFile(sessionFile, "utf8")) - .trim() - .split("\n") - .map((line) => JSON.parse(line) as { type: string; parentId?: string | null }); - expect(entries).toHaveLength(2); - expect(entries[1]).toEqual(expect.objectContaining({ type: "message", parentId: null })); - }); - - it("rewrites forked transcript headers with copied assistant messages to the runtime cwd", async () => { - // Forked sessions keep copied assistant context but rewrite the session - // header to the child run id and active workspace cwd. - const sessionFile = await makeTempFile(); - await fs.writeFile( - sessionFile, - [ - JSON.stringify({ - type: "session", - id: "parent-session", - timestamp: "2026-05-27T00:00:00.000Z", - cwd: "/srv/openclaw/main", - }), - JSON.stringify({ - type: "message", - id: "assistant-1", - parentId: null, - timestamp: "2026-05-27T00:00:01.000Z", - message: { role: "assistant", content: "copied context" }, - }), - ].join("\n") + "\n", - "utf-8", - ); - const assistantEntry = { - type: "message", - id: "assistant-1", - parentId: null, - timestamp: "2026-05-27T00:00:01.000Z", - message: { role: "assistant", content: "copied context" }, - }; - const sessionManager = { - sessionId: "parent-session", - cwd: "/srv/openclaw/main", - flushed: true, - fileEntries: [ - { - type: "session", - id: "parent-session", - timestamp: "2026-05-27T00:00:00.000Z", - cwd: "/srv/openclaw/main", - }, - assistantEntry, - ], - byId: new Map([["assistant-1", assistantEntry]]), - labelsById: new Map(), - leafId: "assistant-1", - }; - - await prepareSessionManagerForRun({ - sessionManager, - sessionFile, - hadSessionFile: true, - sessionId: "child-session", - cwd: "/tmp/task-repo", - }); - - expect(sessionManager.sessionId).toBe("child-session"); - expect(sessionManager.cwd).toBe("/tmp/task-repo"); - expect(sessionManager.fileEntries[0]).toEqual( - expect.objectContaining({ - type: "session", - id: "child-session", - cwd: "/tmp/task-repo", - }), - ); - expect(sessionManager.fileEntries[1]).toBe(assistantEntry); - expect(sessionManager.byId.get("assistant-1")).toBe(assistantEntry); - expect(sessionManager.leafId).toBe("assistant-1"); - expect(sessionManager.flushed).toBe(true); - - const [headerLine, assistantLine] = (await fs.readFile(sessionFile, "utf-8")) - .trim() - .split("\n"); - expect(JSON.parse(headerLine ?? "{}")).toEqual( - expect.objectContaining({ - type: "session", - id: "child-session", - cwd: "/tmp/task-repo", - }), - ); - expect(JSON.parse(assistantLine ?? "{}")).toEqual(assistantEntry); - }); - - it("preserves a forked empty branch and its opaque append cursor", async () => { - const sessionFile = await makeTempFile(); - await fs.writeFile( - sessionFile, - [ - JSON.stringify({ - type: "session", - version: 3, - id: "forked-session", - timestamp: "2026-06-15T00:00:00.000Z", - cwd: "/old/cwd", - parentSession: "/sessions/parent.jsonl", - }), - JSON.stringify({ - type: "metadata", - id: "plugin-metadata", - parentId: null, - }), - JSON.stringify({ - type: "leaf", - id: "empty-leaf", - parentId: "plugin-metadata", - targetId: null, - appendParentId: "plugin-metadata", - }), - ].join("\n") + "\n", - "utf8", - ); - const sessionManager = SessionManager.open(sessionFile, path.dirname(sessionFile), "/old/cwd"); - - await prepareSessionManagerForRun({ - sessionManager, - sessionFile, - hadSessionFile: true, - sessionId: "child-session", - cwd: "/tmp/task-repo", - }); - - const userId = sessionManager.appendMessage({ - role: "user", - content: "continued", - timestamp: Date.now(), - }); - sessionManager.appendMessage({ - role: "assistant", - content: [], - api: "responses", - provider: "openai", - model: "gpt-test", - usage: { - input: 0, - output: 0, - cacheRead: 0, - cacheWrite: 0, - totalTokens: 0, - cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 }, - }, - stopReason: "stop", - timestamp: Date.now(), - }); - - const records = (await fs.readFile(sessionFile, "utf8")) - .trim() - .split("\n") - .map((line) => JSON.parse(line) as Record); - expect(records[0]).toMatchObject({ - type: "session", - id: "child-session", - cwd: "/tmp/task-repo", - parentSession: "/sessions/parent.jsonl", - }); - expect(records.some((record) => record.id === "plugin-metadata")).toBe(true); - expect(records.some((record) => record.id === "empty-leaf")).toBe(true); - expect(records.find((record) => record.id === userId)).toMatchObject({ - type: "message", - parentId: "plugin-metadata", - }); - }); - - it("does not truncate an existing transcript with a corrupted header", async () => { - // A corrupt header may still be followed by useful transcript entries; fail - // closed instead of truncating unknown persisted user data. - const sessionFile = await makeTempFile(); - const originalTranscript = - [ - '{"type":"session","id":"broken"', - JSON.stringify({ - type: "message", - id: "user-1", - parentId: null, - timestamp: "2026-05-27T00:00:01.000Z", - message: { role: "user", content: "persisted prompt" }, - }), - ].join("\n") + "\n"; - await fs.writeFile(sessionFile, originalTranscript, "utf-8"); - const sessionManager = { - sessionId: "fresh-session", - cwd: "/srv/openclaw/main", - flushed: true, - fileEntries: [ - { - type: "session", - id: "fresh-session", - cwd: "/srv/openclaw/main", - }, - { - type: "message", - message: { role: "user" }, - }, - ], - byId: new Map([["user-1", {}]]), - labelsById: new Map(), - leafId: "user-1", - }; - - await expect( - prepareSessionManagerForRun({ - sessionManager, - sessionFile, - hadSessionFile: true, - sessionId: "new-session", - cwd: "/tmp/task-repo", - }), - ).rejects.toThrow("Refusing to reset session transcript with unreadable header"); - - expect(await fs.readFile(sessionFile, "utf-8")).toBe(originalTranscript); - expect(sessionManager.fileEntries).toEqual([ - { - type: "session", - id: "fresh-session", - cwd: "/srv/openclaw/main", - }, - { - type: "message", - message: { role: "user" }, - }, - ]); - expect(sessionManager.flushed).toBe(true); - }); - - it("does not truncate a blank-prefixed transcript with a corrupted header", async () => { - const sessionFile = await makeTempFile(); - const originalTranscript = - [ - "", - " ", - '{"type":"session","id":"broken"', - JSON.stringify({ - type: "message", - id: "user-1", - parentId: null, - timestamp: "2026-05-27T00:00:01.000Z", - message: { role: "user", content: "persisted prompt" }, - }), - ].join("\n") + "\n"; - await fs.writeFile(sessionFile, originalTranscript, "utf-8"); - const sessionManager = { - sessionId: "fresh-session", - cwd: "/srv/openclaw/main", - flushed: true, - fileEntries: [ - { - type: "session", - id: "fresh-session", - cwd: "/srv/openclaw/main", - }, - { - type: "message", - message: { role: "user" }, - }, - ], - byId: new Map([["user-1", {}]]), - labelsById: new Map(), - leafId: "user-1", - }; - - await expect( - prepareSessionManagerForRun({ - sessionManager, - sessionFile, - hadSessionFile: true, - sessionId: "new-session", - cwd: "/tmp/task-repo", - }), - ).rejects.toThrow("Refusing to reset session transcript with unreadable header"); - - expect(await fs.readFile(sessionFile, "utf-8")).toBe(originalTranscript); - expect(sessionManager.flushed).toBe(true); - }); - - it("resets when the first non-empty header is beyond the old scan cap", async () => { - const sessionFile = await makeTempFile(); - const blankPrefix = " \n".repeat(33_000); - const originalTranscript = - blankPrefix + - [ - JSON.stringify({ - type: "session", - id: "fresh-session", - cwd: "/srv/openclaw/main", - }), - JSON.stringify({ - type: "message", - id: "user-1", - parentId: null, - timestamp: "2026-05-27T00:00:01.000Z", - message: { role: "user", content: "persisted prompt" }, - }), - ].join("\n") + - "\n"; - await fs.writeFile(sessionFile, originalTranscript, "utf-8"); - const sessionManager = { - sessionId: "fresh-session", - cwd: "/srv/openclaw/main", - flushed: true, - fileEntries: [ - { - type: "session", - id: "fresh-session", - cwd: "/srv/openclaw/main", - }, - { - type: "message", - message: { role: "user" }, - }, - ], - byId: new Map([["user-1", {}]]), - labelsById: new Map(), - leafId: "user-1", - }; - - await prepareSessionManagerForRun({ - sessionManager, - sessionFile, - hadSessionFile: true, - sessionId: "new-session", - cwd: "/tmp/task-repo", - }); - - expect(await fs.readFile(sessionFile, "utf-8")).toBe(""); - expect(sessionManager.fileEntries).toEqual([ - { - type: "session", - id: "new-session", - cwd: "/tmp/task-repo", - }, - ]); - expect(sessionManager.byId.size).toBe(0); - expect(sessionManager.labelsById.size).toBe(0); - expect(sessionManager.leafId).toBeNull(); - expect(sessionManager.flushed).toBe(false); - }); - - it("keeps recovered user-only transcripts through open and run preparation", async () => { - const sessionFile = await makeTempFile(); - const userEntry = { - type: "message", - id: "user-1", - parentId: null, - timestamp: "2026-05-27T00:00:01.000Z", - message: { role: "user", content: "persisted prompt" }, - }; - await fs.writeFile( - sessionFile, - ['{"type":"session","id":"broken"', JSON.stringify(userEntry)].join("\n") + "\n", - "utf-8", - ); - - const sessionManager = SessionManager.open(sessionFile, path.dirname(sessionFile), "/old/cwd"); - - await prepareSessionManagerForRun({ - sessionManager, - sessionFile, - hadSessionFile: true, - sessionId: "new-session", - cwd: "/tmp/task-repo", - }); - sessionManager.appendMessage({ - role: "assistant", - content: [{ type: "text", text: "response" }], - api: "messages", - provider: "anthropic", - model: "sonnet-4.6", - usage: { - input: 0, - output: 0, - cacheRead: 0, - cacheWrite: 0, - totalTokens: 0, - cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 }, - }, - stopReason: "stop", - timestamp: Date.now(), - }); - - const entries = (await fs.readFile(sessionFile, "utf-8")) - .trim() - .split("\n") - .map( - (line) => JSON.parse(line) as { type: string; id?: string; message?: { role?: string } }, - ); - expect(entries.map((entry) => entry.type)).toEqual(["session", "message", "message"]); - expect(entries[0]).toEqual( - expect.objectContaining({ type: "session", id: "new-session", cwd: "/tmp/task-repo" }), - ); - expect(entries[1]).toEqual(userEntry); - expect(entries[2]?.message?.role).toBe("assistant"); - }); -}); diff --git a/src/agents/embedded-agent-runner/session-manager-init.ts b/src/agents/embedded-agent-runner/session-manager-init.ts deleted file mode 100644 index 97bf8e66a6aa..000000000000 --- a/src/agents/embedded-agent-runner/session-manager-init.ts +++ /dev/null @@ -1,198 +0,0 @@ -/** - * Prepares session managers and transcript state before embedded runs. - */ -import fs from "node:fs/promises"; -import { StringDecoder } from "node:string_decoder"; -import { isRecord } from "@openclaw/normalization-core/record-coerce"; -import { serializeJsonlLine, writeJsonlLines } from "../../config/sessions/transcript-jsonl.js"; -import { invalidateSessionFileRepairCache } from "../session-file-repair.js"; - -type SessionHeaderEntry = { - type: "session"; - id?: string; - cwd?: string; - parentSession?: string; -}; -type SessionMessageEntry = { type: "message"; message?: { role?: string } }; - -const SESSION_HEADER_READ_CHUNK_BYTES = 4096; - -async function readFirstSessionFileLine(sessionFile: string): Promise { - const handle = await fs.open(sessionFile, "r"); - try { - const decoder = new StringDecoder("utf8"); - const buffer = Buffer.alloc(SESSION_HEADER_READ_CHUNK_BYTES); - let line = ""; - let lineHasContent = false; - - const scanText = (text: string): string | undefined => { - let start = 0; - while (start <= text.length) { - const newlineIndex = text.indexOf("\n", start); - const segment = newlineIndex === -1 ? text.slice(start) : text.slice(start, newlineIndex); - if (lineHasContent) { - line += segment; - } else { - const trimmedStart = segment.trimStart(); - if (trimmedStart.length > 0) { - lineHasContent = true; - line = trimmedStart; - } - } - if (newlineIndex === -1) { - break; - } - if (lineHasContent) { - return line.trim(); - } - start = newlineIndex + 1; - } - return undefined; - }; - - while (true) { - const { bytesRead } = await handle.read(buffer, 0, buffer.length, null); - if (bytesRead === 0) { - break; - } - const firstLine = scanText(decoder.write(buffer.subarray(0, bytesRead))); - if (firstLine) { - return firstLine; - } - } - const trailingLine = scanText(decoder.end()); - if (trailingLine) { - return trailingLine; - } - return lineHasContent ? line.trim() : undefined; - } finally { - await handle.close().catch(() => undefined); - } -} - -async function assertExistingHeaderIsReadable(sessionFile: string): Promise { - const firstLine = await readFirstSessionFileLine(sessionFile); - if (!firstLine) { - return; - } - - let parsed: unknown; - try { - parsed = JSON.parse(firstLine); - } catch (error) { - throw new Error(`Refusing to reset session transcript with unreadable header: ${sessionFile}`, { - cause: error, - }); - } - if (!isRecord(parsed) || parsed.type !== "session") { - throw new Error(`Refusing to reset session transcript with invalid header: ${sessionFile}`); - } -} - -/** - * session runtime SessionManager persistence quirk: - * - If the file exists but has no assistant message, SessionManager marks itself `flushed=true` - * and will never persist the initial user message. - * - If the file doesn't exist yet, SessionManager builds a new session in memory and flushes - * header+user+assistant once the first assistant arrives (good). - * - * This normalizes the file/session state so the first user prompt is persisted before the first - * assistant entry, even for pre-created session files. - */ -export async function prepareSessionManagerForRun(params: { - sessionManager: unknown; - sessionFile: string; - hadSessionFile: boolean; - sessionId: string; - cwd: string; -}): Promise { - const sm = params.sessionManager as { - sessionId: string; - cwd: string; - flushed: boolean; - fileEntries: Array; - byId?: Map; - labelsById?: Map; - labelTimestampsById?: Map; - leafId?: string | null; - wasRecoveredFromCorruptHeader?: () => boolean; - clearPreservedOpaqueFileEntries?: () => void; - getSerializedFileLinesForRewrite?: () => string[]; - syncSnapshotAfterHeaderRewrite?: (expectedContent?: string) => void; - }; - - const header = sm.fileEntries.find((e): e is SessionHeaderEntry => e.type === "session"); - const hasAssistant = sm.fileEntries.some( - (e) => e.type === "message" && (e as SessionMessageEntry).message?.role === "assistant", - ); - - if (!params.hadSessionFile && header) { - header.id = params.sessionId; - header.cwd = params.cwd; - sm.sessionId = params.sessionId; - sm.cwd = params.cwd; - return; - } - - if (params.hadSessionFile && header && !hasAssistant) { - const preservesForkedBranch = - typeof header.parentSession === "string" && header.parentSession.length > 0; - if (sm.wasRecoveredFromCorruptHeader?.() || preservesForkedBranch) { - // Fork transcripts can intentionally select a user-only or empty branch. - // Keep their copied tree so the first run appends at the preserved cursor. - header.id = params.sessionId; - header.cwd = params.cwd; - sm.sessionId = params.sessionId; - sm.cwd = params.cwd; - const content = await writeJsonlLines( - params.sessionFile, - sm.getSerializedFileLinesForRewrite?.() ?? sm.fileEntries.map(serializeJsonlLine), - { - mode: 0o600, - }, - ); - sm.flushed = true; - sm.syncSnapshotAfterHeaderRewrite?.(content); - return; - } - - // Reset file so the first assistant flush includes header+user+assistant in order. - await assertExistingHeaderIsReadable(params.sessionFile); - await fs.writeFile(params.sessionFile, "", "utf-8"); - invalidateSessionFileRepairCache(params.sessionFile); - header.id = params.sessionId; - header.cwd = params.cwd; - sm.sessionId = params.sessionId; - sm.cwd = params.cwd; - sm.fileEntries = [header]; - sm.clearPreservedOpaqueFileEntries?.(); - sm.byId?.clear?.(); - // Labels and timestamps form one index; neither half may survive this reset. - sm.labelsById?.clear?.(); - sm.labelTimestampsById?.clear?.(); - sm.leafId = null; - sm.flushed = false; - return; - } - - if (params.hadSessionFile && header) { - const headerChanged = header.id !== params.sessionId || header.cwd !== params.cwd; - header.id = params.sessionId; - header.cwd = params.cwd; - sm.sessionId = params.sessionId; - sm.cwd = params.cwd; - if (!headerChanged) { - sm.flushed = true; - return; - } - const content = await writeJsonlLines( - params.sessionFile, - sm.getSerializedFileLinesForRewrite?.() ?? sm.fileEntries.map(serializeJsonlLine), - { - mode: 0o600, - }, - ); - sm.flushed = true; - sm.syncSnapshotAfterHeaderRewrite?.(content); - } -} diff --git a/src/agents/embedded-agent-runner/thinking.ts b/src/agents/embedded-agent-runner/thinking.ts index 20226170f811..7abf950f8d89 100644 --- a/src/agents/embedded-agent-runner/thinking.ts +++ b/src/agents/embedded-agent-runner/thinking.ts @@ -135,7 +135,7 @@ function stripSignatureFieldsFromThinkingBlock( * * Returns the original reference when nothing was stripped. */ -export function stripThinkingSignaturesFromMessage(message: AgentMessage): AgentMessage { +function stripThinkingSignaturesFromMessage(message: AgentMessage): AgentMessage { if (!isAssistantMessageWithContent(message)) { return message; } diff --git a/src/agents/embedded-agent-runner/tool-result-truncation.test.ts b/src/agents/embedded-agent-runner/tool-result-truncation.test.ts index a811b4d4686a..abaacdbc96b9 100644 --- a/src/agents/embedded-agent-runner/tool-result-truncation.test.ts +++ b/src/agents/embedded-agent-runner/tool-result-truncation.test.ts @@ -3,19 +3,17 @@ import fs from "node:fs/promises"; import os from "node:os"; import path from "node:path"; -import { expectDefined } from "@openclaw/normalization-core"; import type { AgentMessage } from "openclaw/plugin-sdk/agent-core"; -import { SessionManager } from "openclaw/plugin-sdk/agent-sessions"; import type { AssistantMessage, ToolResultMessage, UserMessage } from "openclaw/plugin-sdk/llm"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import { convertToLlm } from "../../../packages/agent-core/src/harness/messages.js"; +import { formatSqliteSessionFileMarker } from "../../config/sessions/legacy-sqlite-marker.js"; import { appendTranscriptMessage, loadTranscriptEvents, replaceSessionEntry, replaceTranscriptEvents, } from "../../config/sessions/session-accessor.js"; -import { formatSqliteSessionFileMarker } from "../../config/sessions/sqlite-marker.js"; import type { SessionEntry as SessionStoreEntry } from "../../config/sessions/types.js"; import { onInternalSessionTranscriptUpdate } from "../../sessions/transcript-events.js"; import { estimateStringChars } from "../../utils/cjk-chars.js"; @@ -179,30 +177,6 @@ function getToolResultTextLength(message: AgentMessage): number { }, 0); } -async function truncateSessionThroughActiveTarget(params: { - sessionFile: string; - contextWindowTokens: number; - maxCharsOverride?: number; - aggregateMaxCharsOverride?: number; - protectTrailingToolResults?: boolean; - sessionId?: string; - sessionKey?: string; - agentId?: string; -}) { - return await truncateOversizedToolResultsInActiveTarget({ - scope: { - ...(params.agentId ? { agentId: params.agentId } : {}), - sessionId: params.sessionId ?? "tool-result-truncation-test", - sessionKey: params.sessionKey ?? "agent:main:tool-result-truncation-test", - sessionFile: params.sessionFile, - }, - contextWindowTokens: params.contextWindowTokens, - maxCharsOverride: params.maxCharsOverride, - aggregateMaxCharsOverride: params.aggregateMaxCharsOverride, - protectTrailingToolResults: params.protectTrailingToolResults, - }); -} - async function createTmpDir(): Promise { tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), "tool-result-truncation-test-")); return tmpDir; @@ -1485,11 +1459,10 @@ describe("truncateOversizedToolResultsInSession", () => { expect(result.truncated).toBe(true); expect(result.truncatedCount).toBeGreaterThan(0); expect(listener).toHaveBeenCalledWith({ - sessionFile, sessionKey, agentId: "main", sessionId, - target: { agentId: "main", sessionId, sessionKey }, + target: { agentId: "main", sessionId, sessionKey, storePath }, }); const toolResultTexts = (await loadTranscriptEvents(scope)) @@ -1509,34 +1482,6 @@ describe("truncateOversizedToolResultsInSession", () => { expect(toolResultTexts.join("").length).toBeLessThan(medium.length * 3); }); - it("dispatches explicit file transcript targets to file-backed truncation", async () => { - const dir = await createTmpDir(); - const sm = SessionManager.create(dir, dir); - sm.appendMessage(makeUserMessage("hello")); - sm.appendMessage(makeAssistantMessage("calling tools")); - sm.appendMessage(makeToolResult("x".repeat(500_000), "call_1")); - const sessionFile = sm.getSessionFile()!; - - const result = await truncateOversizedToolResultsInActiveTarget({ - scope: { - agentId: "main", - sessionFile, - sessionId: "explicit-file-session", - sessionKey: "agent:main:explicit-file", - }, - contextWindowTokens: 100, - }); - - expect(result.truncated).toBe(true); - expect(result.truncatedCount).toBeGreaterThan(0); - const toolResult = SessionManager.open(sessionFile) - .getBranch() - .find((entry) => entry.type === "message" && entry.message.role === "toolResult"); - expect( - toolResult?.type === "message" ? getFirstToolResultText(toolResult.message) : "", - ).toContain("truncated"); - }); - it("honors SQLite leaf controls when truncating runtime transcripts", async () => { const dir = await createTmpDir(); const storePath = path.join(dir, "sessions.json"); @@ -1621,243 +1566,6 @@ describe("truncateOversizedToolResultsInSession", () => { expect(selectedTool ? getFirstToolResultText(selectedTool) : "").toContain("truncated"); expect(inactiveTool ? getFirstToolResultText(inactiveTool) : "").toBe(inactiveLarge); }); - - it("readably truncates aggregate medium tool results in a session file", async () => { - // Persisted truncation rewrites JSONL directly and emits the transcript - // update event instead of reopening through SessionManager internals. - const dir = await createTmpDir(); - const sm = SessionManager.create(dir, dir); - sm.appendMessage(makeUserMessage("hello")); - sm.appendMessage(makeAssistantMessage("calling tools")); - const medium = "alpha beta gamma delta epsilon ".repeat(600); - sm.appendMessage(makeToolResult(medium, "call_1")); - sm.appendMessage(makeToolResult(medium, "call_2")); - sm.appendMessage(makeToolResult(medium, "call_3")); - const sessionFile = sm.getSessionFile()!; - - const beforeBranch = SessionManager.open(sessionFile).getBranch(); - const beforeLengths = beforeBranch - .filter((entry) => entry.type === "message") - .map((entry) => - entry.type === "message" && entry.message.role === "toolResult" - ? getToolResultTextLength(entry.message) - : 0, - ) - .filter((length) => length > 0); - - const openSpy = vi.spyOn(SessionManager, "open").mockImplementation(() => { - throw new Error("SessionManager.open should not be used for persisted truncation"); - }); - const listener = vi.fn(); - const cleanup = onInternalSessionTranscriptUpdate(listener); - const result = await truncateSessionThroughActiveTarget({ - sessionFile, - sessionKey: "agent:main:test", - contextWindowTokens: 100, - }); - cleanup(); - openSpy.mockRestore(); - - expect(result.truncated).toBe(true); - expect(result.truncatedCount).toBeGreaterThan(0); - expect(listener).toHaveBeenCalledWith({ sessionFile, sessionKey: "agent:main:test" }); - - const afterBranch = SessionManager.open(sessionFile).getBranch(); - const afterToolResults = afterBranch.filter( - (entry) => entry.type === "message" && entry.message.role === "toolResult", - ); - const afterLengths = afterToolResults.map((entry) => - entry.type === "message" ? getToolResultTextLength(entry.message) : 0, - ); - - expect(afterLengths.reduce((sum, value) => sum + value, 0)).toBeLessThan( - beforeLengths.reduce((sum, value) => sum + value, 0), - ); - expect( - afterToolResults.some((entry) => - entry.type === "message" - ? getFirstToolResultText(entry.message).includes("truncated") - : false, - ), - ).toBe(true); - expect( - afterToolResults.some((entry) => - entry.type === "message" - ? getFirstToolResultText(entry.message).includes("[compacted:") - : false, - ), - ).toBe(false); - }); - - it("prefers truncating older aggregate tool-result entries before newer results", async () => { - // Newer tool results are more likely to matter to the current turn, so - // aggregate recovery spends the cut on older results first. - const dir = await createTmpDir(); - const sm = SessionManager.create(dir, dir); - sm.appendMessage(makeUserMessage("hello")); - sm.appendMessage(makeAssistantMessage("calling tools")); - const olderLarge = "older-large ".repeat(1_000); - const newerEnough = "newer-enough ".repeat(500); - sm.appendMessage(makeToolResult(olderLarge, "call_1")); - sm.appendMessage(makeToolResult(newerEnough, "call_2")); - const sessionFile = sm.getSessionFile()!; - - const beforeBranch = SessionManager.open(sessionFile).getBranch(); - const beforeToolResults = beforeBranch.filter( - (entry) => entry.type === "message" && entry.message.role === "toolResult", - ); - const beforeTexts = beforeToolResults.map((entry) => - entry.type === "message" ? getFirstToolResultText(entry.message) : "", - ); - - const result = await truncateSessionThroughActiveTarget({ - sessionFile, - contextWindowTokens: 128_000, - maxCharsOverride: DEFAULT_MAX_LIVE_TOOL_RESULT_CHARS, - aggregateMaxCharsOverride: DEFAULT_MAX_LIVE_TOOL_RESULT_CHARS, - }); - - expect(result.truncated).toBe(true); - expect(result.truncatedCount).toBe(1); - - const afterBranch = SessionManager.open(sessionFile).getBranch(); - const afterToolResults = afterBranch.filter( - (entry) => entry.type === "message" && entry.message.role === "toolResult", - ); - const afterTexts = afterToolResults.map((entry) => - entry.type === "message" ? getFirstToolResultText(entry.message) : "", - ); - - expect(afterTexts[0]).not.toBe(beforeTexts[0]); - expect(afterTexts[0]).toContain("truncated"); - expect(afterTexts[1]).toBe(beforeTexts[1]); - }); - - it("allows persisted-session recovery truncation to shrink below the old 2k floor", async () => { - const dir = await createTmpDir(); - const sm = SessionManager.create(dir, dir); - sm.appendMessage(makeUserMessage("hello")); - sm.appendMessage(makeAssistantMessage("calling tools")); - sm.appendMessage(makeToolResult("x".repeat(500_000), "call_1")); - const sessionFile = sm.getSessionFile()!; - - const result = await truncateSessionThroughActiveTarget({ - sessionFile, - contextWindowTokens: 100, - }); - - expect(result.truncated).toBe(true); - const afterBranch = SessionManager.open(sessionFile).getBranch(); - const toolResult = afterBranch.find( - (entry) => entry.type === "message" && entry.message.role === "toolResult", - ); - expect(toolResult?.type).toBe("message"); - if (!toolResult || toolResult.type !== "message") { - throw new Error("expected truncated tool result"); - } - const text = getFirstToolResultText(toolResult.message); - expect(text.length).toBeLessThan(2_000); - expect(text).toContain("truncated"); - }); - - it("leaves protected trailing batches intact during persisted aggregate recovery", async () => { - const dir = await createTmpDir(); - const sm = SessionManager.create(dir, dir); - const firstKeptEntryId = sm.appendMessage(makeUserMessage("hello")); - sm.appendMessage(makeAssistantMessage("calling tools")); - const beforeTexts = Array.from({ length: 5 }, (_, index) => String(index).repeat(8_000)); - for (const [index, text] of beforeTexts.entries()) { - sm.appendMessage(makeToolResult(text, `fresh_${index}`)); - } - sm.appendCompaction("summary", firstKeptEntryId, 10); - const sessionFile = sm.getSessionFile()!; - - const result = await truncateSessionThroughActiveTarget({ - sessionFile, - contextWindowTokens: 1_000_000, - maxCharsOverride: 8_000, - aggregateMaxCharsOverride: 32_000, - protectTrailingToolResults: true, - }); - - expect(result.truncated).toBe(false); - const afterBranch = SessionManager.open(sessionFile).getBranch(); - const afterTexts = afterBranch - .filter((entry) => entry.type === "message" && entry.message.role === "toolResult") - .map((entry) => (entry.type === "message" ? getFirstToolResultText(entry.message) : "")); - - expect(afterTexts).toEqual(beforeTexts); - }); - - it("combines oversized and aggregate recovery truncation in the same session rewrite", async () => { - const dir = await createTmpDir(); - const sm = SessionManager.create(dir, dir); - sm.appendMessage(makeUserMessage("hello")); - sm.appendMessage(makeAssistantMessage("calling tools")); - sm.appendMessage(makeToolResult("x".repeat(500_000), "call_1")); - const medium = "alpha beta gamma delta epsilon ".repeat(800); - sm.appendMessage(makeToolResult(medium, "call_2")); - sm.appendMessage(makeToolResult(medium, "call_3")); - const sessionFile = sm.getSessionFile()!; - - const result = await truncateSessionThroughActiveTarget({ - sessionFile, - contextWindowTokens: 100, - }); - - expect(result.truncated).toBe(true); - expect(result.truncatedCount).toBe(3); - - const afterBranch = SessionManager.open(sessionFile).getBranch(); - const toolResults = afterBranch.filter( - (entry) => entry.type === "message" && entry.message.role === "toolResult", - ); - const toolTexts = toolResults.map((entry) => - entry.type === "message" ? getFirstToolResultText(entry.message) : "", - ); - - expect(toolTexts[0]).toContain("truncated"); - expect(expectDefined(toolTexts[1], "toolTexts[1] test invariant").length).toBeGreaterThan(0); - expect(expectDefined(toolTexts[2], "toolTexts[2] test invariant").length).toBeGreaterThan(0); - }); - - it("lets aggregate recovery honor a tiny explicit cap during persisted rewrite", async () => { - const dir = await createTmpDir(); - const sm = SessionManager.create(dir, dir); - sm.appendMessage(makeUserMessage("hello")); - sm.appendMessage(makeAssistantMessage("calling tools")); - const medium = "alpha beta gamma delta epsilon ".repeat(800); - sm.appendMessage(makeToolResult(medium, "call_1")); - sm.appendMessage(makeToolResult(medium, "call_2")); - sm.appendMessage(makeToolResult(medium, "call_3")); - const sessionFile = sm.getSessionFile()!; - - const result = await truncateSessionThroughActiveTarget({ - sessionFile, - contextWindowTokens: 128_000, - maxCharsOverride: 120, - aggregateMaxCharsOverride: 120, - }); - - expect(result.truncated).toBe(true); - const afterBranch = SessionManager.open(sessionFile).getBranch(); - const toolResults = afterBranch.filter( - (entry) => entry.type === "message" && entry.message.role === "toolResult", - ); - const totalChars = toolResults.reduce( - (sum, entry) => sum + (entry.type === "message" ? getToolResultTextLength(entry.message) : 0), - 0, - ); - - expect(totalChars).toBeLessThanOrEqual(120); - expect( - toolResults.some((entry) => - entry.type === "message" - ? getFirstToolResultText(entry.message).includes("truncated") - : false, - ), - ).toBe(true); - }); }); describe("truncateToolResultText head+tail strategy", () => { diff --git a/src/agents/embedded-agent-runner/tool-result-truncation.ts b/src/agents/embedded-agent-runner/tool-result-truncation.ts index f03f3ba8ef6d..ebd63f37d7f1 100644 --- a/src/agents/embedded-agent-runner/tool-result-truncation.ts +++ b/src/agents/embedded-agent-runner/tool-result-truncation.ts @@ -6,18 +6,12 @@ import { existsSync } from "node:fs"; import { normalizeLowercaseStringOrEmpty } from "@openclaw/normalization-core/string-coerce"; import { sliceUtf16Safe } from "@openclaw/normalization-core/utf16-slice"; import { loadTranscriptEvents } from "../../config/sessions/session-accessor.js"; -import { parseSqliteSessionFileMarker } from "../../config/sessions/sqlite-marker.js"; import type { OpenClawConfig } from "../../config/types.openclaw.js"; import { createDedupeCache } from "../../infra/dedupe.js"; import { formatErrorMessage } from "../../infra/errors.js"; import type { TextContent } from "../../llm/types.js"; import { emitSessionTranscriptUpdate } from "../../sessions/transcript-events.js"; import type { AgentMessage } from "../runtime/index.js"; -import { - acquireSessionWriteLock, - type SessionWriteLockAcquireTimeoutConfig, - resolveSessionWriteLockOptions, -} from "../session-write-lock.js"; import { SessionManager } from "../sessions/index.js"; import { formatFullOutputFooter } from "../sessions/tools/tool-contracts.js"; import { @@ -33,16 +27,9 @@ import { sliceToolResultTextTailToBudget, sliceToolResultTextToBudget, } from "./tool-result-text-budget.js"; -import { - createTranscriptFileStateFromPersistedEntries, - persistTranscriptStateMutation, - readTranscriptFileState, - TranscriptFileState, -} from "./transcript-file-state.js"; import { rewriteTranscriptEntriesInSessionManager, rewriteTranscriptEntriesInRuntimeTranscript, - rewriteTranscriptEntriesInState, } from "./transcript-rewrite.js"; import { resolveRuntimeTranscriptReadTarget, @@ -113,21 +100,15 @@ function logToolResultSessionTruncation(params: { ); } -async function readRuntimeTranscriptFileState( - scope: RuntimeTranscriptScope, -): Promise { +async function readRuntimeTranscriptState(scope: RuntimeTranscriptScope): Promise { const target = await resolveRuntimeTranscriptReadTarget(scope); - const sqliteMarker = parseSqliteSessionFileMarker(target.sessionFile); - if (!sqliteMarker) { - throw new Error("runtime transcript target is not SQLite-backed"); - } const events = await loadTranscriptEvents({ - agentId: sqliteMarker.agentId, - sessionId: sqliteMarker.sessionId, + agentId: target.agentId, + sessionId: target.sessionId, sessionKey: target.sessionKey, - storePath: sqliteMarker.storePath, + storePath: target.storePath, }); - return createTranscriptFileStateFromPersistedEntries(events); + return SessionManager.fromEntries(events); } function resolveSuffixFactory( @@ -1323,83 +1304,6 @@ function truncateOversizedToolResultsInExistingSessionManager(params: { }; } -async function truncateOversizedToolResultsInTranscriptState(params: { - state: TranscriptFileState; - sessionFile: string; - contextWindowTokens: number; - maxCharsOverride?: number; - aggregateMaxCharsOverride?: number; - protectTrailingToolResults?: boolean; - sessionId?: string; - sessionKey?: string; - agentId?: string; - config?: SessionWriteLockAcquireTimeoutConfig; -}): Promise<{ truncated: boolean; truncatedCount: number; reason?: string }> { - const { state, contextWindowTokens } = params; - const branch = state.getBranch() as ToolResultBranchEntry[]; - - if (branch.length === 0) { - return { truncated: false, truncatedCount: 0, reason: "empty session" }; - } - - const { maxChars, aggregateBudgetChars, plan } = buildRecoveryToolResultReplacementPlan({ - branch, - contextWindowTokens, - maxCharsOverride: params.maxCharsOverride, - aggregateMaxCharsOverride: params.aggregateMaxCharsOverride, - protectTrailingToolResults: params.protectTrailingToolResults, - }); - if (plan.replacements.length === 0) { - return { - truncated: false, - truncatedCount: 0, - reason: "no oversized or aggregate tool results", - }; - } - const rewriteResult = rewriteTranscriptEntriesInState({ - state, - replacements: plan.replacements, - }); - if (rewriteResult.changed) { - await persistTranscriptStateMutation({ - sessionFile: params.sessionFile, - state, - appendedEntries: rewriteResult.appendedEntries, - }); - emitSessionTranscriptUpdate({ - sessionFile: params.sessionFile, - sessionKey: params.sessionKey, - ...(params.agentId ? { agentId: params.agentId } : {}), - ...(params.sessionId && params.sessionKey && params.agentId - ? { - target: { - agentId: params.agentId, - sessionId: params.sessionId, - sessionKey: params.sessionKey, - }, - } - : {}), - }); - } - - logToolResultSessionTruncation({ - rewrittenEntries: rewriteResult.rewrittenEntries, - contextWindowTokens, - maxChars, - aggregateBudgetChars, - oversizedReplacementCount: plan.oversizedReplacementCount, - aggregateReplacementCount: plan.aggregateReplacementCount, - sessionKey: params.sessionKey, - sessionId: params.sessionId, - }); - - return { - truncated: rewriteResult.changed, - truncatedCount: rewriteResult.rewrittenEntries, - reason: rewriteResult.reason, - }; -} - export function truncateOversizedToolResultsInSessionManager(params: { sessionManager: SessionManager; contextWindowTokens: number; @@ -1429,10 +1333,9 @@ async function truncateOversizedToolResultsInRuntimeTranscript(params: { maxCharsOverride?: number; aggregateMaxCharsOverride?: number; protectTrailingToolResults?: boolean; - config?: SessionWriteLockAcquireTimeoutConfig; }): Promise<{ truncated: boolean; truncatedCount: number; reason?: string }> { try { - const state = await readRuntimeTranscriptFileState(params.scope); + const state = await readRuntimeTranscriptState(params.scope); const { contextWindowTokens } = params; const branch = state.getBranch() as ToolResultBranchEntry[]; @@ -1458,7 +1361,6 @@ async function truncateOversizedToolResultsInRuntimeTranscript(params: { const rewriteResult = await rewriteTranscriptEntriesInRuntimeTranscript({ scope: params.scope, request: { replacements: plan.replacements }, - config: params.config, }); logToolResultSessionTruncation({ @@ -1484,7 +1386,7 @@ async function truncateOversizedToolResultsInRuntimeTranscript(params: { } } -/** Truncates oversized tool results in either a SQLite runtime target or explicit file target. */ +/** Truncates oversized tool results in the active SQLite runtime transcript. */ export async function truncateOversizedToolResultsInActiveTarget(params: { scope: RuntimeTranscriptScope; contextWindowTokens: number; @@ -1493,70 +1395,7 @@ export async function truncateOversizedToolResultsInActiveTarget(params: { protectTrailingToolResults?: boolean; config?: OpenClawConfig; }): Promise<{ truncated: boolean; truncatedCount: number; reason?: string }> { - if (parseSqliteSessionFileMarker(params.scope.sessionFile)) { - return await truncateOversizedToolResultsInRuntimeTranscript(params); - } - if (!params.scope.sessionFile) { - return { - truncated: false, - truncatedCount: 0, - reason: "no session file", - }; - } - return await truncateOversizedToolResultsInSession({ - sessionFile: params.scope.sessionFile, - contextWindowTokens: params.contextWindowTokens, - maxCharsOverride: params.maxCharsOverride, - aggregateMaxCharsOverride: params.aggregateMaxCharsOverride, - protectTrailingToolResults: params.protectTrailingToolResults, - sessionId: params.scope.sessionId, - sessionKey: params.scope.sessionKey, - agentId: params.scope.agentId, - config: params.config, - }); -} - -/** - * Truncates a named transcript file artifact. - */ -async function truncateOversizedToolResultsInSession(params: { - sessionFile: string; - contextWindowTokens: number; - maxCharsOverride?: number; - aggregateMaxCharsOverride?: number; - protectTrailingToolResults?: boolean; - sessionId?: string; - sessionKey?: string; - agentId?: string; - config?: SessionWriteLockAcquireTimeoutConfig; -}): Promise<{ truncated: boolean; truncatedCount: number; reason?: string }> { - const { sessionFile, contextWindowTokens } = params; - let sessionLock: Awaited> | undefined; - - try { - sessionLock = await acquireSessionWriteLock({ - sessionFile, - ...resolveSessionWriteLockOptions(params.config), - }); - const state = await readTranscriptFileState(sessionFile); - return await truncateOversizedToolResultsInTranscriptState({ - state, - contextWindowTokens, - maxCharsOverride: params.maxCharsOverride, - aggregateMaxCharsOverride: params.aggregateMaxCharsOverride, - protectTrailingToolResults: params.protectTrailingToolResults, - sessionFile, - sessionId: params.sessionId, - sessionKey: params.sessionKey, - agentId: params.agentId, - }); - } catch (err) { - const errMsg = formatErrorMessage(err); - log.warn(`[tool-result-truncation] Failed to truncate: ${errMsg}`); - return { truncated: false, truncatedCount: 0, reason: errMsg }; - } finally { - await sessionLock?.release(); - } + return await truncateOversizedToolResultsInRuntimeTranscript(params); } export function sessionLikelyHasOversizedToolResults(params: { diff --git a/src/agents/embedded-agent-runner/transcript-file-state.test.ts b/src/agents/embedded-agent-runner/transcript-file-state.test.ts index a1b04035ab9f..f26bed5739e9 100644 --- a/src/agents/embedded-agent-runner/transcript-file-state.test.ts +++ b/src/agents/embedded-agent-runner/transcript-file-state.test.ts @@ -4,14 +4,30 @@ import fs from "node:fs/promises"; import os from "node:os"; import path from "node:path"; import { afterEach, describe, expect, it } from "vitest"; -import { - persistTranscriptStateMutation, - readTranscriptFileState, -} from "./transcript-file-state.js"; -import { rewriteTranscriptEntriesInState } from "./transcript-rewrite.js"; +import { parseSessionEntries, SessionManager } from "../sessions/index.js"; const roots: string[] = []; +async function readTranscriptState(sessionFile: string): Promise { + return SessionManager.fromEntries(parseSessionEntries(await fs.readFile(sessionFile, "utf8"))); +} + +async function persistTranscriptStateMutation(params: { + sessionFile: string; + state: SessionManager; + appendedEntries: unknown[]; +}): Promise { + const entries = params.state.migrated + ? params.state.getPersistedEntries() + : params.appendedEntries; + const content = `${entries.map((entry) => JSON.stringify(entry)).join("\n")}\n`; + if (params.state.migrated) { + await fs.writeFile(params.sessionFile, content, "utf8"); + } else { + await fs.appendFile(params.sessionFile, content, "utf8"); + } +} + async function makeRoot(prefix: string): Promise { // Each file-state case writes a full JSONL transcript under its own root so // orphan/leaf behavior stays isolated. @@ -24,7 +40,7 @@ afterEach(async () => { await Promise.all(roots.splice(0).map((root) => fs.rm(root, { recursive: true, force: true }))); }); -describe("readTranscriptFileState", () => { +describe("readTranscriptState", () => { it("normalizes appended session names to one line", async () => { const root = await makeRoot("openclaw-transcript-state-name-"); const sessionFile = path.join(root, "session.jsonl"); @@ -39,11 +55,11 @@ describe("readTranscriptFileState", () => { })}\n`, "utf-8", ); - const state = await readTranscriptFileState(sessionFile); + const state = await readTranscriptState(sessionFile); - const entry = state.appendSessionInfo(" first\nsecond\r\nthird "); + const entry = state.getEntry(state.appendSessionInfo(" first\nsecond\r\nthird "))!; - expect(entry.name).toBe("first second third"); + expect(entry).toMatchObject({ name: "first second third" }); expect(state.getEntries().at(-1)).toMatchObject({ name: "first second third" }); }); @@ -160,7 +176,7 @@ describe("readTranscriptFileState", () => { "utf-8", ); - const state = await readTranscriptFileState(sessionFile); + const state = await readTranscriptState(sessionFile); expect(state.getEntries().map((entry) => entry.id)).toEqual([ "user-1", @@ -209,7 +225,7 @@ describe("readTranscriptFileState", () => { "utf-8", ); - const state = await readTranscriptFileState(sessionFile); + const state = await readTranscriptState(sessionFile); expect(state.getEntries().map((entry) => entry.id)).toEqual(["user-1", "assistant-string"]); expect(state.buildSessionContext().messages).toMatchObject([ @@ -276,7 +292,7 @@ describe("readTranscriptFileState", () => { "utf-8", ); - const state = await readTranscriptFileState(sessionFile); + const state = await readTranscriptState(sessionFile); expect(state.getEntries().map((entry) => entry.id)).toEqual([ "user-1", @@ -349,7 +365,7 @@ describe("readTranscriptFileState", () => { "utf-8", ); - const state = await readTranscriptFileState(sessionFile); + const state = await readTranscriptState(sessionFile); const messages = state.buildSessionContext().messages; expect(state.getEntries().map((entry) => entry.id)).toEqual([ @@ -403,7 +419,7 @@ describe("readTranscriptFileState", () => { "utf-8", ); - const state = await readTranscriptFileState(sessionFile); + const state = await readTranscriptState(sessionFile); expect(state.getEntries().map((entry) => entry.id)).toEqual([ "user-1", @@ -439,7 +455,7 @@ describe("readTranscriptFileState", () => { "utf-8", ); - const state = await readTranscriptFileState(sessionFile); + const state = await readTranscriptState(sessionFile); expect(state.getEntries().map((entry) => entry.id)).toEqual(["user-1"]); expect(state.getLeafId()).toBe("user-1"); @@ -475,7 +491,7 @@ describe("readTranscriptFileState", () => { "utf-8", ); - const state = await readTranscriptFileState(sessionFile); + const state = await readTranscriptState(sessionFile); expect(state.migrated).toBe(true); expect(state.getEntries()).toHaveLength(2); @@ -514,13 +530,11 @@ describe("readTranscriptFileState", () => { "utf8", ); - const state = await readTranscriptFileState(sessionFile); + const state = await readTranscriptState(sessionFile); const activeLeafId = state.getLeafId(); - const appended = state.appendMessage({ - role: "user", - content: "continued", - timestamp: Date.now(), - }); + const appended = state.getEntry( + state.appendMessage({ role: "user", content: "continued", timestamp: Date.now() }), + )!; await persistTranscriptStateMutation({ sessionFile, state, @@ -529,7 +543,7 @@ describe("readTranscriptFileState", () => { expect(state.migrated).toBe(true); expect(appended.parentId).toBe(activeLeafId); - const reopened = await readTranscriptFileState(sessionFile); + const reopened = await readTranscriptState(sessionFile); expect(reopened.getBranch().map((entry) => entry.id)).toEqual([activeLeafId, appended.id]); }); @@ -568,7 +582,7 @@ describe("readTranscriptFileState", () => { "utf-8", ); - const state = await readTranscriptFileState(sessionFile); + const state = await readTranscriptState(sessionFile); const kept = state .getEntries() .find( @@ -625,7 +639,7 @@ describe("readTranscriptFileState", () => { "utf-8", ); - const state = await readTranscriptFileState(sessionFile); + const state = await readTranscriptState(sessionFile); expect(state.getEntries().map((entry) => entry.id)).toEqual(["user-1", "user-2"]); expect(state.getLeafId()).toBe("user-2"); @@ -679,7 +693,7 @@ describe("readTranscriptFileState", () => { "utf-8", ); - const state = await readTranscriptFileState(sessionFile); + const state = await readTranscriptState(sessionFile); const compaction = state.getEntries().find((entry) => entry.type === "compaction"); expect(compaction).toMatchObject({ firstKeptEntryId: "user-1" }); @@ -737,7 +751,7 @@ describe("readTranscriptFileState", () => { "utf-8", ); - const state = await readTranscriptFileState(sessionFile); + const state = await readTranscriptState(sessionFile); const compaction = state.getEntries().find((entry) => entry.type === "compaction"); expect(compaction).toMatchObject({ firstKeptEntryId: "user-1" }); @@ -802,7 +816,7 @@ describe("readTranscriptFileState", () => { "utf-8", ); - const state = await readTranscriptFileState(sessionFile); + const state = await readTranscriptState(sessionFile); const compaction = state.getEntries().find((entry) => entry.type === "compaction"); expect(compaction).toMatchObject({ firstKeptEntryId: "user-1" }); @@ -867,7 +881,7 @@ describe("readTranscriptFileState", () => { "utf-8", ); - const state = await readTranscriptFileState(sessionFile); + const state = await readTranscriptState(sessionFile); const compaction = state.getEntries().find((entry) => entry.type === "compaction"); expect(compaction).toMatchObject({ firstKeptEntryId: "branch-b-user" }); @@ -909,7 +923,7 @@ describe("readTranscriptFileState", () => { "utf-8", ); - const state = await readTranscriptFileState(sessionFile); + const state = await readTranscriptState(sessionFile); expect(state.getEntries().map((entry) => ({ id: entry.id, parentId: entry.parentId }))).toEqual( [{ id: "user-1", parentId: null }], @@ -949,7 +963,7 @@ describe("readTranscriptFileState", () => { "utf-8", ); - const state = await readTranscriptFileState(sessionFile); + const state = await readTranscriptState(sessionFile); expect(state.getBranch().map((entry) => ({ id: entry.id, parentId: entry.parentId }))).toEqual([ { id: "active-entry", parentId: null }, @@ -957,12 +971,10 @@ describe("readTranscriptFileState", () => { expect(state.buildSessionContext().messages).toMatchObject([ { role: "user", content: "kept through cycle" }, ]); - const appended = state.appendMessage({ - role: "user", - content: "continued", - timestamp: Date.now(), - }); - expect(appended.parentId).toBe("opaque-cycle"); + const appended = state.getEntry( + state.appendMessage({ role: "user", content: "continued", timestamp: Date.now() }), + )!; + expect(appended.parentId).toBe("active-entry"); expect(state.getBranch().map((entry) => entry.id)).toEqual(["active-entry", appended.id]); }); @@ -997,23 +1009,12 @@ describe("readTranscriptFileState", () => { "utf-8", ); - const state = await readTranscriptFileState(sessionFile); + const state = await readTranscriptState(sessionFile); expect(state.getEntries().map((entry) => ({ id: entry.id, parentId: entry.parentId }))).toEqual( [{ id: "user-1", parentId: null }], ); expect(state.getBranch().map((entry) => entry.id)).toEqual(["user-1"]); - expect(() => - rewriteTranscriptEntriesInState({ - state, - replacements: [ - { - entryId: "user-1", - message: { role: "user", content: "replacement prompt", timestamp: 1 }, - }, - ], - }), - ).not.toThrow(); }); it("drops labels targeting rejected entries before transcript rewrite replay", async () => { @@ -1069,7 +1070,7 @@ describe("readTranscriptFileState", () => { "utf-8", ); - const state = await readTranscriptFileState(sessionFile); + const state = await readTranscriptState(sessionFile); expect(state.getEntries().map((entry) => ({ id: entry.id, parentId: entry.parentId }))).toEqual( [ @@ -1079,17 +1080,6 @@ describe("readTranscriptFileState", () => { ], ); expect(state.getLabel("bad-message")).toBeUndefined(); - expect(() => - rewriteTranscriptEntriesInState({ - state, - replacements: [ - { - entryId: "user-1", - message: { role: "user", content: "replacement prompt", timestamp: 1 }, - }, - ], - }), - ).not.toThrow(); }); it("applies leaf controls to active state and marker-linked descendants", async () => { @@ -1131,7 +1121,7 @@ describe("readTranscriptFileState", () => { "utf8", ); - const selectedState = await readTranscriptFileState(sessionFile); + const selectedState = await readTranscriptState(sessionFile); expect(selectedState.getLeafId()).toBe(rootEntry.id); expect(selectedState.getBranch().map((entry) => entry.id)).toEqual([rootEntry.id]); @@ -1144,7 +1134,7 @@ describe("readTranscriptFileState", () => { }; await fs.appendFile(sessionFile, `${JSON.stringify(replacementEntry)}\n`, "utf8"); - const reopened = await readTranscriptFileState(sessionFile); + const reopened = await readTranscriptState(sessionFile); expect(reopened.getEntries().find((entry) => entry.id === replacementEntry.id)).toEqual( expect.objectContaining({ parentId: rootEntry.id }), ); @@ -1192,23 +1182,8 @@ describe("readTranscriptFileState", () => { "utf8", ); - const state = await readTranscriptFileState(sessionFile); + const state = await readTranscriptState(sessionFile); expect(state.getBranch().map((entry) => entry.id)).toEqual(["user-1", "assistant-1"]); - - rewriteTranscriptEntriesInState({ - state, - replacements: [ - { - entryId: "user-1", - message: { role: "user", content: "rewritten question", timestamp: 3 }, - }, - ], - }); - - expect(state.buildSessionContext().messages).toMatchObject([ - { role: "user", content: "rewritten question" }, - { role: "assistant", content: [{ type: "text", text: "answer" }] }, - ]); }); it("preserves marked side ancestry without capturing the next active append", async () => { @@ -1261,7 +1236,7 @@ describe("readTranscriptFileState", () => { "utf8", ); - const state = await readTranscriptFileState(sessionFile); + const state = await readTranscriptState(sessionFile); expect(state.getBranch("side-two").map((entry) => entry.id)).toEqual([ "active-root", @@ -1273,11 +1248,9 @@ describe("readTranscriptFileState", () => { expect(state.getAppendParentId()).toBe("side-two"); expect(state.getAppendMode()).toBe("side"); - const nextUser = state.appendMessage({ - role: "user", - content: "next question", - timestamp: Date.now(), - }); + const nextUser = state.getEntry( + state.appendMessage({ role: "user", content: "next question", timestamp: Date.now() }), + )!; expect(state.getBranch(nextUser.id).map((entry) => entry.id)).toEqual([ "active-root", nextUser.id, @@ -1331,12 +1304,10 @@ describe("readTranscriptFileState", () => { "utf8", ); - const state = await readTranscriptFileState(sessionFile); - const appended = state.appendMessage({ - role: "user", - content: "continued", - timestamp: Date.now(), - }); + const state = await readTranscriptState(sessionFile); + const appended = state.getEntry( + state.appendMessage({ role: "user", content: "continued", timestamp: Date.now() }), + )!; await persistTranscriptStateMutation({ sessionFile, state, @@ -1348,9 +1319,9 @@ describe("readTranscriptFileState", () => { .map((line) => JSON.parse(line) as Record); expect(state.getLeafId()).toBe(appended.id); - expect(appended.parentId).toBe("plugin-metadata"); + expect(appended.parentId).toBe("active-root"); expect(state.getBranch().map((entry) => entry.id)).toEqual(["active-root", appended.id]); - expect(persisted.at(-1)).toMatchObject({ id: appended.id, parentId: "plugin-metadata" }); + expect(persisted.at(-1)).toMatchObject({ id: appended.id, parentId: "active-root" }); }); it("ignores leaf controls with dangling target or append references", async () => { @@ -1400,21 +1371,19 @@ describe("readTranscriptFileState", () => { "utf8", ); - const state = await readTranscriptFileState(sessionFile); - const appended = state.appendMessage({ - role: "user", - content: "continued", - timestamp: Date.now(), - }); + const state = await readTranscriptState(sessionFile); + const appended = state.getEntry( + state.appendMessage({ role: "user", content: "continued", timestamp: Date.now() }), + )!; await persistTranscriptStateMutation({ sessionFile, state, appendedEntries: [appended], }); - expect(appended.parentId).toBe("plugin-metadata"); + expect(appended.parentId).toBe("active-root"); expect(state.getBranch().map((entry) => entry.id)).toEqual(["active-root", appended.id]); - const reopened = await readTranscriptFileState(sessionFile); + const reopened = await readTranscriptState(sessionFile); expect(reopened.getLeafId()).toBe(appended.id); expect(reopened.getBranch().map((entry) => entry.id)).toEqual(["active-root", appended.id]); }); @@ -1448,7 +1417,7 @@ describe("readTranscriptFileState", () => { "utf-8", ); - const state = await readTranscriptFileState(sessionFile); + const state = await readTranscriptState(sessionFile); expect(state.getEntries().map((entry) => entry.id)).toEqual(["legacy-root", "tree-child"]); expect(state.getLeafId()).toBe("tree-child"); @@ -1487,7 +1456,7 @@ describe("readTranscriptFileState", () => { "utf-8", ); - const state = await readTranscriptFileState(sessionFile); + const state = await readTranscriptState(sessionFile); const branchText = state.getBranch().map((entry) => { const message = entry.type === "message" ? entry.message : null; diff --git a/src/agents/embedded-agent-runner/transcript-file-state.ts b/src/agents/embedded-agent-runner/transcript-file-state.ts deleted file mode 100644 index 127a48a1b136..000000000000 --- a/src/agents/embedded-agent-runner/transcript-file-state.ts +++ /dev/null @@ -1,1039 +0,0 @@ -/** - * Reads, mutates, and atomically writes embedded session transcript files. - */ -import { randomUUID } from "node:crypto"; -import fs from "node:fs/promises"; -import path from "node:path"; -import { isRecord } from "@openclaw/normalization-core/record-coerce"; -import { isSessionTranscriptSideAppendEntry } from "../../config/sessions/transcript-tree.js"; -import { CURRENT_SESSION_VERSION } from "../../config/sessions/version.js"; -import { appendRegularFile } from "../../infra/fs-safe.js"; -import { privateFileStore } from "../../infra/private-file-store.js"; -import { - buildSessionContext, - migrateSessionEntries, - parseSessionEntries, - type FileEntry, - type SessionContext, - type SessionEntry, - type SessionHeader, -} from "../sessions/index.js"; - -// Mutable view over a session JSONL transcript. The runner uses this layer to -// tolerate old or partially malformed rows before appending new canonical rows. -type BranchSummaryEntry = Extract; -type CompactionEntry = Extract; -type CustomEntry = Extract; -type CustomMessageEntry = Extract; -type LabelEntry = Extract; -type ModelChangeEntry = Extract; -type ResetEntry = Extract; -type SessionInfoEntry = Extract; -type SessionMessageEntry = Extract; -type ThinkingLevelChangeEntry = Extract; - -type TranscriptLeafControlEntry = { - type: "leaf"; - id: string; - parentId: string | null; - timestamp: string; - targetId: string | null; - appendParentId?: string | null; - appendMode?: "side"; -}; - -export type TranscriptPersistedEntry = SessionEntry | TranscriptLeafControlEntry; - -const sessionEntryTypes = new Set([ - "branch_summary", - "compaction", - "custom", - "custom_message", - "label", - "message", - "model_change", - "reset", - "session_info", - "thinking_level_change", -] satisfies SessionEntry["type"][]); - -const repairableToolCallContentTypes = new Set([ - "functionCall", - "function_call", - "toolCall", - "toolUse", - "tool_call", - "tool_use", -]); - -const invalidJsonlSlotType = "__openclaw_invalid_jsonl_slot"; - -function isString(value: unknown): value is string { - return typeof value === "string" && value.trim() !== ""; -} - -function isOptionalString(value: unknown): boolean { - return value === undefined || typeof value === "string"; -} - -function isTextContent(value: unknown): boolean { - return ( - isRecord(value) && - value.type === "text" && - typeof value.text === "string" && - isOptionalString(value.textSignature) - ); -} - -function isThinkingContent(value: unknown): boolean { - return ( - isRecord(value) && - value.type === "thinking" && - typeof value.thinking === "string" && - isOptionalString(value.thinkingSignature) && - (value.redacted === undefined || typeof value.redacted === "boolean") - ); -} - -function isImageContent(value: unknown): boolean { - return ( - isRecord(value) && - value.type === "image" && - typeof value.data === "string" && - typeof value.mimeType === "string" - ); -} - -function hasToolCallId(value: Record): boolean { - return ( - isString(value.id) || - isString(value.call_id) || - isString(value.toolCallId) || - isString(value.toolUseId) || - isString(value.tool_call_id) || - isString(value.tool_use_id) - ); -} - -function isToolCallPayload(value: unknown): boolean { - return value === null || isRecord(value) || typeof value === "string"; -} - -function isToolCallContent(value: unknown): boolean { - return ( - isRecord(value) && - typeof value.type === "string" && - repairableToolCallContentTypes.has(value.type) && - hasToolCallId(value) && - isString(value.name) && - (value.arguments === undefined || isToolCallPayload(value.arguments)) && - (value.input === undefined || isToolCallPayload(value.input)) && - isOptionalString(value.thoughtSignature) - ); -} - -function isPersistedContentBlock(value: unknown): boolean { - if (!isRecord(value) || !isString(value.type)) { - return false; - } - switch (value.type) { - case "text": - return isTextContent(value); - case "thinking": - return isThinkingContent(value); - case "image": - return isImageContent(value); - default: - if (repairableToolCallContentTypes.has(value.type)) { - return isToolCallContent(value); - } - return true; - } -} - -function isUserContent(value: unknown): boolean { - return ( - typeof value === "string" || - (Array.isArray(value) && value.every((item) => isPersistedContentBlock(item))) - ); -} - -function isAssistantContent(value: unknown): boolean { - return ( - typeof value === "string" || - (Array.isArray(value) && value.every((item) => isPersistedContentBlock(item))) - ); -} - -function isToolResultContent(value: unknown): boolean { - return Array.isArray(value) && value.every((item) => isPersistedContentBlock(item)); -} - -function isOptionalBoolean(value: unknown): boolean { - return value === undefined || typeof value === "boolean"; -} - -function isBashExecutionMessage(value: Record): boolean { - return ( - isString(value.command) && - typeof value.output === "string" && - (value.exitCode === undefined || typeof value.exitCode === "number") && - typeof value.cancelled === "boolean" && - typeof value.truncated === "boolean" && - isOptionalString(value.fullOutputPath) && - isOptionalBoolean(value.excludeFromContext) - ); -} - -function isAgentMessage(value: unknown): boolean { - if (!isRecord(value)) { - return false; - } - switch (value.role) { - case "assistant": - return isAssistantContent(value.content); - case "bashExecution": - return isBashExecutionMessage(value); - case "custom": - return isString(value.customType) && isUserContent(value.content); - case "toolResult": - return ( - isString(value.toolCallId) && - isString(value.toolName) && - typeof value.isError === "boolean" && - isToolResultContent(value.content) - ); - case "user": - return isUserContent(value.content); - default: - return false; - } -} - -function hasSessionEntryBase(entry: FileEntry): boolean { - const candidate = entry as { - id?: unknown; - parentId?: unknown; - timestamp?: unknown; - }; - return ( - isString(candidate.id) && - (candidate.parentId === undefined || - candidate.parentId === null || - isString(candidate.parentId)) && - (candidate.timestamp === undefined || isString(candidate.timestamp)) - ); -} - -function isSessionEntry(entry: FileEntry): entry is SessionEntry { - if ( - entry.type === "session" || - !sessionEntryTypes.has(entry.type) || - !hasSessionEntryBase(entry) - ) { - return false; - } - switch (entry.type) { - case "branch_summary": { - const candidate = entry as { fromId?: unknown; summary?: unknown }; - return isString(candidate.fromId) && typeof candidate.summary === "string"; - } - case "compaction": { - const candidate = entry as { - firstKeptEntryId?: unknown; - summary?: unknown; - tokensBefore?: unknown; - }; - return ( - isString(candidate.firstKeptEntryId) && - typeof candidate.summary === "string" && - typeof candidate.tokensBefore === "number" - ); - } - case "reset": { - const candidate = entry as Pick; - return ( - (candidate.firstKeptEntryId === undefined || isString(candidate.firstKeptEntryId)) && - ["new", "reset", "idle", "daily", "cron-stale"].includes(candidate.reason) - ); - } - case "custom": - return isString((entry as { customType?: unknown }).customType); - case "custom_message": { - const candidate = entry as { - content?: unknown; - customType?: unknown; - display?: unknown; - }; - return ( - isString(candidate.customType) && - isUserContent(candidate.content) && - typeof candidate.display === "boolean" - ); - } - case "label": { - const candidate = entry as { label?: unknown; targetId?: unknown }; - return ( - isString(candidate.targetId) && - (candidate.label === undefined || typeof candidate.label === "string") - ); - } - case "message": { - return isAgentMessage((entry as { message?: unknown }).message); - } - case "model_change": { - const candidate = entry as { modelId?: unknown; provider?: unknown }; - return isString(candidate.provider) && isString(candidate.modelId); - } - case "session_info": { - const candidate = entry as { name?: unknown }; - return candidate.name === undefined || typeof candidate.name === "string"; - } - case "thinking_level_change": - return isString((entry as { thinkingLevel?: unknown }).thinkingLevel); - } - return false; -} - -function parseLeafControlEntry(entry: unknown): - | { - id: string; - parentId: string | null; - targetId: string | null; - appendParentId?: string | null; - appendMode?: "side"; - } - | undefined { - if (!isRecord(entry) || entry.type !== "leaf") { - return undefined; - } - const candidate = entry as { - id?: unknown; - parentId?: unknown; - targetId?: unknown; - appendParentId?: unknown; - appendMode?: unknown; - timestamp?: unknown; - }; - if ( - !isString(candidate.id) || - (candidate.parentId !== undefined && - candidate.parentId !== null && - !isString(candidate.parentId)) || - (candidate.timestamp !== undefined && !isString(candidate.timestamp)) || - (candidate.targetId !== null && typeof candidate.targetId !== "string") || - (candidate.appendParentId !== undefined && - candidate.appendParentId !== null && - typeof candidate.appendParentId !== "string") || - (candidate.appendMode !== undefined && candidate.appendMode !== "side") - ) { - return undefined; - } - return { - id: candidate.id, - parentId: candidate.parentId ?? null, - targetId: candidate.targetId, - ...(candidate.appendParentId !== undefined ? { appendParentId: candidate.appendParentId } : {}), - ...(candidate.appendMode === "side" ? { appendMode: candidate.appendMode } : {}), - }; -} - -type ReadableSessionState = { - entries: SessionEntry[]; - leafId: string | null; - appendParentId: string | null; - appendMode?: "side"; - opaqueParentsById: Map; - logicalParentsById: Map; -}; - -// Keep every readable entry while repairing links through rejected rows. This -// preserves usable branches from partially written or migrated transcripts. -function readableSessionState(fileEntries: FileEntry[]): ReadableSessionState { - const entries: SessionEntry[] = []; - const acceptedIds = new Set(); - const acceptedEntryById = new Map(); - const rejectedIds = new Set(); - const rejectedParentById = new Map(); - const logicalParentsById = new Map(); - const invalidLeafIds = new Set(); - const firstReadableDescendantByRejectedId = new Map(); - const rejectedAncestorsByAcceptedId = new Map(); - let effectiveLeafId: string | null = null; - let effectiveAppendParentId: string | null = null; - let effectiveAppendMode: "side" | undefined; - const acceptedPath = (leafId: string | null | undefined): SessionEntry[] => { - const pathLocal: SessionEntry[] = []; - let id = leafId ?? null; - const seen = new Set(); - while (id !== null) { - if (seen.has(id)) { - break; - } - seen.add(id); - const entry = acceptedEntryById.get(id); - if (!entry) { - break; - } - pathLocal.push(entry); - id = entry.parentId; - } - pathLocal.reverse(); - return pathLocal; - }; - const firstReadableDescendantOnBranch = ( - rejectedId: string, - leafId: string | null | undefined, - ): string | undefined => { - for (const entry of acceptedPath(leafId)) { - if (rejectedAncestorsByAcceptedId.get(entry.id)?.includes(rejectedId)) { - return entry.id; - } - } - return undefined; - }; - const rejectedParentChain = (parentId: string | null | undefined): string[] => { - const chain: string[] = []; - let resolved = parentId ?? null; - const seen = new Set(); - while (resolved !== null && rejectedParentById.has(resolved)) { - if (seen.has(resolved)) { - break; - } - seen.add(resolved); - chain.push(resolved); - resolved = rejectedParentById.get(resolved) ?? null; - } - return chain; - }; - const resolveRejectedParent = (parentId: string | null | undefined): string | null => { - let resolved = parentId ?? null; - const seen = new Set(); - while (resolved !== null && rejectedParentById.has(resolved)) { - if (seen.has(resolved)) { - return null; - } - seen.add(resolved); - resolved = rejectedParentById.get(resolved) ?? null; - } - return resolved; - }; - const repairEntryLinks = (entry: SessionEntry): SessionEntry => { - const rejectedAncestors = rejectedParentChain(entry.parentId); - const resolvedRejectedParent = - rejectedAncestors.length > 0 ? resolveRejectedParent(entry.parentId) : undefined; - const parentId = - resolvedRejectedParent !== undefined - ? resolvedRejectedParent !== null && acceptedIds.has(resolvedRejectedParent) - ? resolvedRejectedParent - : null - : (entry.parentId ?? null); - let repaired = parentId === entry.parentId ? entry : ({ ...entry, parentId } as SessionEntry); - if ( - (repaired.type === "compaction" || repaired.type === "reset") && - repaired.firstKeptEntryId !== undefined && - rejectedIds.has(repaired.firstKeptEntryId) - ) { - // A rejected first-kept row would make compaction summaries unusable. - // Prefer the closest readable parent, then a readable descendant on the - // same branch, before falling back to the repaired parent. - const resolvedFirstKeptParent = resolveRejectedParent(repaired.firstKeptEntryId); - const firstKeptEntryId = - (resolvedFirstKeptParent !== null && acceptedIds.has(resolvedFirstKeptParent) - ? resolvedFirstKeptParent - : undefined) ?? - firstReadableDescendantOnBranch(repaired.firstKeptEntryId, parentId) ?? - firstReadableDescendantByRejectedId.get(repaired.firstKeptEntryId) ?? - parentId; - if (firstKeptEntryId !== null && firstKeptEntryId !== repaired.firstKeptEntryId) { - repaired = { ...repaired, firstKeptEntryId } as SessionEntry; - } - } - if (repaired.type !== "compaction" && repaired.type !== "reset") { - for (const rejectedId of rejectedAncestors) { - if (!firstReadableDescendantByRejectedId.has(rejectedId)) { - firstReadableDescendantByRejectedId.set(rejectedId, repaired.id); - } - } - if (rejectedAncestors.length > 0) { - rejectedAncestorsByAcceptedId.set(repaired.id, rejectedAncestors); - } - } - return repaired; - }; - for (const rawEntry of fileEntries) { - if (!isRecord(rawEntry)) { - continue; - } - const rawRecord = rawEntry as unknown as Record; - const entry = rawEntry as FileEntry; - const id = rawRecord.id; - const rawType = rawRecord.type; - const rawParentId = rawRecord.parentId; - const leafEntry = parseLeafControlEntry(rawRecord); - if (leafEntry) { - rejectedIds.add(leafEntry.id); - const targetIsKnown = - leafEntry.targetId === null || - acceptedIds.has(leafEntry.targetId) || - (rejectedParentById.has(leafEntry.targetId) && !invalidLeafIds.has(leafEntry.targetId)); - const appendParentIsKnown = - leafEntry.appendParentId === undefined || - leafEntry.appendParentId === null || - acceptedIds.has(leafEntry.appendParentId) || - (rejectedParentById.has(leafEntry.appendParentId) && - !invalidLeafIds.has(leafEntry.appendParentId)); - if (!targetIsKnown || !appendParentIsKnown) { - // Ignore corrupt navigation state, but keep the marker transparent so - // descendants can still repair through the serialized raw branch. - invalidLeafIds.add(leafEntry.id); - rejectedParentById.set(leafEntry.id, leafEntry.parentId); - continue; - } - rejectedParentById.set(leafEntry.id, leafEntry.targetId); - const resolvedTargetId = resolveRejectedParent(leafEntry.targetId); - effectiveLeafId = - resolvedTargetId !== null && acceptedIds.has(resolvedTargetId) ? resolvedTargetId : null; - effectiveAppendParentId = - leafEntry.appendParentId === undefined ? effectiveLeafId : leafEntry.appendParentId; - effectiveAppendMode = leafEntry.appendMode; - continue; - } - if (rawType === "leaf") { - if (isString(id)) { - rejectedIds.add(id); - invalidLeafIds.add(id); - rejectedParentById.set(id, isString(rawParentId) ? rawParentId : null); - } - continue; - } - if (!isSessionEntry(entry)) { - if (isString(id)) { - rejectedIds.add(id); - rejectedParentById.set(id, isString(rawParentId) ? rawParentId : null); - const isParentLinkedOpaque = - typeof rawType === "string" && - rawType !== "session" && - !id.startsWith("__openclaw_invalid_jsonl_slot_") && - !sessionEntryTypes.has(rawType) && - Object.hasOwn(rawRecord, "parentId") && - (rawParentId === null || isString(rawParentId)); - if (isParentLinkedOpaque) { - effectiveAppendParentId = id; - } - } - continue; - } - if (entry.type === "label" && !acceptedIds.has(entry.targetId)) { - rejectedIds.add(entry.id); - rejectedParentById.set(entry.id, entry.parentId); - continue; - } - if (acceptedIds.has(entry.id)) { - continue; - } - const hasSerializedParent = Object.hasOwn(rawRecord, "parentId"); - if ( - !hasSerializedParent || - (!isSessionTranscriptSideAppendEntry(rawRecord) && - entry.parentId === effectiveAppendParentId && - effectiveLeafId !== effectiveAppendParentId) - ) { - logicalParentsById.set(entry.id, effectiveLeafId); - } - const repaired = repairEntryLinks(entry); - entries.push(repaired); - acceptedIds.add(repaired.id); - acceptedEntryById.set(repaired.id, repaired); - effectiveAppendParentId = repaired.id; - if (isSessionTranscriptSideAppendEntry(rawRecord)) { - effectiveAppendMode = "side"; - } else { - effectiveLeafId = repaired.id; - effectiveAppendMode = undefined; - } - } - return { - entries, - leafId: effectiveLeafId, - appendParentId: effectiveAppendParentId, - ...(effectiveAppendMode ? { appendMode: effectiveAppendMode } : {}), - opaqueParentsById: rejectedParentById, - logicalParentsById, - }; -} - -function sessionHeaderVersion(header: SessionHeader | null): number { - return typeof header?.version === "number" ? header.version : 1; -} - -function generateEntryId(byId: { has(id: string): boolean }): string { - for (let attempt = 0; attempt < 100; attempt += 1) { - const id = randomUUID().slice(0, 8); - if (!byId.has(id)) { - return id; - } - } - return randomUUID(); -} - -function serializeTranscriptFileEntries(entries: readonly unknown[]): string { - return `${entries.map((entry) => JSON.stringify(entry)).join("\n")}\n`; -} - -function fileEntryOrMigrationSlot(value: unknown, index: number): FileEntry { - if (isRecord(value)) { - return value as unknown as FileEntry; - } - return { - type: invalidJsonlSlotType, - id: `__openclaw_invalid_jsonl_slot_${index}`, - parentId: null, - timestamp: "1970-01-01T00:00:00.000Z", - } as unknown as FileEntry; -} - -function createReadableTranscriptFileState(params: { - fileEntries: FileEntry[]; - header: SessionHeader | null; - migrated?: boolean; -}): TranscriptFileState { - const readable = readableSessionState(params.fileEntries); - return new TranscriptFileState({ - header: params.header, - entries: readable.entries, - leafId: readable.leafId, - appendParentId: params.migrated ? readable.leafId : readable.appendParentId, - ...(!params.migrated && readable.appendMode ? { appendMode: readable.appendMode } : {}), - opaqueParentsById: readable.opaqueParentsById, - logicalParentsById: readable.logicalParentsById, - migrated: params.migrated, - }); -} - -/** Builds readable branch state from persisted transcript records. */ -export function createTranscriptFileStateFromPersistedEntries( - entries: readonly unknown[], -): TranscriptFileState { - const fileEntries = entries.map(fileEntryOrMigrationSlot); - const header = - fileEntries.find((entry): entry is SessionHeader => entry.type === "session") ?? null; - return createReadableTranscriptFileState({ fileEntries, header }); -} - -/** In-memory transcript state with branch, label, and append helpers. */ -export class TranscriptFileState { - readonly header: SessionHeader | null; - readonly entries: SessionEntry[]; - readonly migrated: boolean; - private readonly byId = new Map(); - private readonly labelsById = new Map(); - private readonly labelTimestampsById = new Map(); - private readonly opaqueParentsById = new Map(); - private readonly logicalParentsById = new Map(); - private leafId: string | null = null; - private appendParentId: string | null = null; - private appendMode: "side" | undefined; - - constructor(params: { - header: SessionHeader | null; - entries: SessionEntry[]; - leafId?: string | null; - appendParentId?: string | null; - appendMode?: "side"; - opaqueParentsById?: ReadonlyMap; - logicalParentsById?: ReadonlyMap; - migrated?: boolean; - }) { - this.header = params.header; - this.entries = [...params.entries]; - this.migrated = params.migrated === true; - for (const [id, parentId] of params.opaqueParentsById ?? []) { - this.opaqueParentsById.set(id, parentId); - } - for (const [id, parentId] of params.logicalParentsById ?? []) { - this.logicalParentsById.set(id, parentId); - } - this.rebuildIndex(params.leafId, params.appendParentId); - this.appendMode = params.appendMode; - } - - private resolveCanonicalParentId(parentId: string | null): string | null { - const seen = new Set(); - let currentId = parentId; - while (currentId !== null && this.opaqueParentsById.has(currentId)) { - if (seen.has(currentId)) { - return null; - } - seen.add(currentId); - currentId = this.opaqueParentsById.get(currentId) ?? null; - } - return currentId; - } - - private rebuildIndex(leafId?: string | null, appendParentId?: string | null): void { - this.byId.clear(); - this.labelsById.clear(); - this.labelTimestampsById.clear(); - this.leafId = null; - this.appendParentId = null; - for (const entry of this.entries) { - this.byId.set(entry.id, entry); - this.leafId = entry.id; - this.appendParentId = entry.id; - if (entry.type === "label") { - if (entry.label) { - this.labelsById.set(entry.targetId, entry.label); - this.labelTimestampsById.set(entry.targetId, entry.timestamp); - } else { - this.labelsById.delete(entry.targetId); - this.labelTimestampsById.delete(entry.targetId); - } - } - } - if (leafId !== undefined) { - this.leafId = leafId; - } - if (appendParentId !== undefined) { - this.appendParentId = appendParentId; - } else if (leafId !== undefined) { - this.appendParentId = leafId; - } - } - - getCwd(): string { - return this.header?.cwd ?? process.cwd(); - } - - getHeader(): SessionHeader | null { - return this.header; - } - - getEntries(): SessionEntry[] { - return [...this.entries]; - } - - getLeafId(): string | null { - return this.leafId; - } - - getAppendParentId(): string | null { - return this.appendParentId; - } - - getAppendMode(): "side" | undefined { - return this.appendMode; - } - - getLeafEntry(): SessionEntry | undefined { - return this.leafId ? this.byId.get(this.leafId) : undefined; - } - - getLabel(id: string): string | undefined { - return this.labelsById.get(id); - } - - getBranch(fromId?: string): SessionEntry[] { - const branch: SessionEntry[] = []; - const seen = new Set(); - let currentId = fromId ?? this.leafId; - while (currentId && !seen.has(currentId)) { - const current = this.byId.get(currentId); - if (!current) { - break; - } - seen.add(current.id); - const resolvedParentId = this.logicalParentsById.has(current.id) - ? (this.logicalParentsById.get(current.id) ?? null) - : this.resolveCanonicalParentId(current.parentId); - const parentId = - resolvedParentId === current.id || (resolvedParentId && seen.has(resolvedParentId)) - ? null - : resolvedParentId; - branch.push( - parentId === current.parentId ? current : ({ ...current, parentId } as SessionEntry), - ); - currentId = parentId; - } - branch.reverse(); - return branch; - } - - buildSessionContext(): SessionContext { - const entries = this.getBranch(); - const leafId = entries.at(-1)?.id ?? null; - return buildSessionContext(entries, leafId, new Map(entries.map((entry) => [entry.id, entry]))); - } - - /** Move the active leaf to an existing entry without appending a row. */ - branch(branchFromId: string): void { - if (!this.byId.has(branchFromId)) { - throw new Error(`Entry ${branchFromId} not found`); - } - this.leafId = branchFromId; - this.appendParentId = branchFromId; - this.appendMode = undefined; - } - - /** Clear the active leaf so the next append starts a root branch. */ - resetLeaf(): void { - this.leafId = null; - this.appendParentId = null; - this.appendMode = undefined; - } - - appendMessage(message: SessionMessageEntry["message"]): SessionMessageEntry { - return this.appendEntry({ - type: "message", - id: generateEntryId(this.byId), - parentId: this.appendParentId, - timestamp: new Date().toISOString(), - message, - }); - } - - appendThinkingLevelChange(thinkingLevel: string): ThinkingLevelChangeEntry { - return this.appendEntry({ - type: "thinking_level_change", - id: generateEntryId(this.byId), - parentId: this.appendParentId, - timestamp: new Date().toISOString(), - thinkingLevel, - }); - } - - appendModelChange(provider: string, modelId: string): ModelChangeEntry { - return this.appendEntry({ - type: "model_change", - id: generateEntryId(this.byId), - parentId: this.appendParentId, - timestamp: new Date().toISOString(), - provider, - modelId, - }); - } - - appendCompaction( - summary: string, - firstKeptEntryId: string, - tokensBefore: number, - details?: unknown, - fromHook?: boolean, - ): CompactionEntry { - return this.appendEntry({ - type: "compaction", - id: generateEntryId(this.byId), - parentId: this.appendParentId, - timestamp: new Date().toISOString(), - summary, - firstKeptEntryId, - tokensBefore, - details, - fromHook, - }); - } - - appendResetBoundary(reason: ResetEntry["reason"], firstKeptEntryId?: string): ResetEntry { - return this.appendEntry({ - type: "reset", - id: generateEntryId(this.byId), - parentId: this.appendParentId, - timestamp: new Date().toISOString(), - reason, - ...(firstKeptEntryId ? { firstKeptEntryId } : {}), - }); - } - - appendCustomEntry(customType: string, data?: unknown): CustomEntry { - return this.appendEntry({ - type: "custom", - customType, - data, - id: generateEntryId(this.byId), - parentId: this.appendParentId, - timestamp: new Date().toISOString(), - }); - } - - appendSessionInfo(name: string): SessionInfoEntry { - return this.appendEntry({ - type: "session_info", - id: generateEntryId(this.byId), - parentId: this.appendParentId, - timestamp: new Date().toISOString(), - name: name.replace(/[\r\n]+/g, " ").trim(), - }); - } - - appendCustomMessageEntry( - customType: string, - content: CustomMessageEntry["content"], - display: boolean, - details?: unknown, - ): CustomMessageEntry { - return this.appendEntry({ - type: "custom_message", - customType, - content, - display, - details, - id: generateEntryId(this.byId), - parentId: this.appendParentId, - timestamp: new Date().toISOString(), - }); - } - - appendLabelChange(targetId: string, label: string | undefined): LabelEntry { - if (!this.byId.has(targetId)) { - throw new Error(`Entry ${targetId} not found`); - } - return this.appendEntry({ - type: "label", - id: generateEntryId(this.byId), - parentId: this.appendParentId, - timestamp: new Date().toISOString(), - targetId, - label, - }); - } - - branchWithSummary( - branchFromId: string | null, - summary: string, - details?: unknown, - fromHook?: boolean, - ): BranchSummaryEntry { - if (branchFromId !== null && !this.byId.has(branchFromId)) { - throw new Error(`Entry ${branchFromId} not found`); - } - this.leafId = branchFromId; - this.appendParentId = branchFromId; - return this.appendEntry({ - type: "branch_summary", - id: generateEntryId(this.byId), - parentId: branchFromId, - timestamp: new Date().toISOString(), - fromId: branchFromId ?? "root", - summary, - details, - fromHook, - }); - } - - appendLeafControl(params: { - targetId: string | null; - appendParentId: string | null; - appendMode?: "side"; - }): TranscriptLeafControlEntry { - if (params.targetId !== null && !this.byId.has(params.targetId)) { - throw new Error(`Entry ${params.targetId} not found`); - } - if ( - params.appendParentId !== null && - !this.byId.has(params.appendParentId) && - !this.opaqueParentsById.has(params.appendParentId) - ) { - throw new Error(`Entry ${params.appendParentId} not found`); - } - const entry: TranscriptLeafControlEntry = { - type: "leaf", - id: generateEntryId({ - has: (id) => this.byId.has(id) || this.opaqueParentsById.has(id), - }), - parentId: this.appendParentId, - timestamp: new Date().toISOString(), - targetId: params.targetId, - ...(params.appendParentId !== params.targetId - ? { appendParentId: params.appendParentId } - : {}), - ...(params.appendMode ? { appendMode: params.appendMode } : {}), - }; - this.opaqueParentsById.set(entry.id, params.targetId); - this.leafId = params.targetId; - this.appendParentId = params.appendParentId; - this.appendMode = params.appendMode; - return entry; - } - - private appendEntry(entry: T): T { - if ( - !isSessionTranscriptSideAppendEntry(entry) && - entry.parentId === this.appendParentId && - this.leafId !== this.appendParentId - ) { - this.logicalParentsById.set(entry.id, this.leafId); - } - this.entries.push(entry); - this.byId.set(entry.id, entry); - this.appendParentId = entry.id; - if (isSessionTranscriptSideAppendEntry(entry)) { - this.appendMode = "side"; - } else { - this.leafId = entry.id; - this.appendMode = undefined; - } - if (entry.type === "label") { - if (entry.label) { - this.labelsById.set(entry.targetId, entry.label); - this.labelTimestampsById.set(entry.targetId, entry.timestamp); - } else { - this.labelsById.delete(entry.targetId); - this.labelTimestampsById.delete(entry.targetId); - } - } - return entry; - } -} - -/** Read a transcript file, migrate old rows, and drop only unrecoverable entries. */ -export async function readTranscriptFileState(sessionFile: string): Promise { - const raw = await fs.readFile(sessionFile, "utf-8"); - const fileEntries = (parseSessionEntries(raw) as unknown[]).map(fileEntryOrMigrationSlot); - const headerBeforeMigration = - fileEntries.find((entry): entry is SessionHeader => entry.type === "session") ?? null; - const headerVersionBeforeMigration = sessionHeaderVersion(headerBeforeMigration); - const migrated = headerVersionBeforeMigration < CURRENT_SESSION_VERSION; - migrateSessionEntries(fileEntries); - const header = - fileEntries.find((entry): entry is SessionHeader => entry.type === "session") ?? null; - return createReadableTranscriptFileState({ fileEntries, header, migrated }); -} - -/** Rewrite the full transcript through the private-file store. */ -export async function writeTranscriptFileAtomic( - filePath: string, - entries: Array, -): Promise { - await privateFileStore(path.dirname(filePath)).writeText( - path.basename(filePath), - serializeTranscriptFileEntries(entries), - ); -} - -/** Persist a state mutation using append-only writes unless migration forced a rewrite. */ -export async function persistTranscriptStateMutation(params: { - sessionFile: string; - state: TranscriptFileState; - appendedEntries: TranscriptPersistedEntry[]; -}): Promise { - if (params.appendedEntries.length === 0 && !params.state.migrated) { - return; - } - if (params.state.migrated) { - const appendedLeafControls = params.appendedEntries.filter( - (entry): entry is TranscriptLeafControlEntry => entry.type === "leaf", - ); - await writeTranscriptFileAtomic(params.sessionFile, [ - ...(params.state.header ? [params.state.header] : []), - ...params.state.entries, - ...appendedLeafControls, - ]); - return; - } - await appendRegularFile({ - filePath: params.sessionFile, - content: `${params.appendedEntries.map((entry) => JSON.stringify(entry)).join("\n")}\n`, - rejectSymlinkParents: true, - }); -} -/* oxlint-disable max-lines -- TODO: split this grandfathered oversized file. */ diff --git a/src/agents/embedded-agent-runner/transcript-rewrite.test.ts b/src/agents/embedded-agent-runner/transcript-rewrite.test.ts index e732416f37c8..9bb3334f86a9 100644 --- a/src/agents/embedded-agent-runner/transcript-rewrite.test.ts +++ b/src/agents/embedded-agent-runner/transcript-rewrite.test.ts @@ -7,12 +7,12 @@ import { expectDefined } from "@openclaw/normalization-core"; import type { AgentMessage } from "openclaw/plugin-sdk/agent-core"; import { SessionManager } from "openclaw/plugin-sdk/agent-sessions"; import { beforeAll, beforeEach, describe, expect, it, vi } from "vitest"; +import { formatSqliteSessionFileMarker } from "../../config/sessions/legacy-sqlite-marker.js"; import { appendTranscriptMessage, loadTranscriptEvents, replaceSessionEntry, } from "../../config/sessions/session-accessor.js"; -import { formatSqliteSessionFileMarker } from "../../config/sessions/sqlite-marker.js"; import type { SessionEntry } from "../../config/sessions/types.js"; import { buildSessionWriteLockModuleMock } from "../../test-utils/session-write-lock-module-mock.js"; @@ -415,6 +415,7 @@ describe("rewriteTranscriptEntriesInRuntimeTranscript", () => { agentId: "main", sessionId, sessionKey: "agent:main:test", + storePath, }, }); diff --git a/src/agents/embedded-agent-runner/transcript-rewrite.ts b/src/agents/embedded-agent-runner/transcript-rewrite.ts index e319599870ad..db9e26ea4aec 100644 --- a/src/agents/embedded-agent-runner/transcript-rewrite.ts +++ b/src/agents/embedded-agent-runner/transcript-rewrite.ts @@ -1,8 +1,4 @@ -import { - loadTranscriptEvents, - replaceTranscriptEvents, -} from "../../config/sessions/session-accessor.js"; -import { parseSqliteSessionFileMarker } from "../../config/sessions/sqlite-marker.js"; +import { withTranscriptWriteLock } from "../../config/sessions/session-accessor.js"; /** * Rewrites transcript entries in session managers, states, and files. */ @@ -15,20 +11,9 @@ import { formatErrorMessage } from "../../infra/errors.js"; import { emitSessionTranscriptUpdate } from "../../sessions/transcript-events.js"; import type { AgentMessage } from "../runtime/index.js"; import { getRawSessionAppendMessage } from "../session-raw-append-message.js"; -import { - acquireSessionWriteLock, - type SessionWriteLockAcquireTimeoutConfig, - resolveSessionWriteLockOptions, -} from "../session-write-lock.js"; import { SessionManager } from "../sessions/index.js"; import { log } from "./logger.js"; import { - readTranscriptFileState, - type TranscriptFileState, - type TranscriptPersistedEntry, -} from "./transcript-file-state.js"; -import { - persistRuntimeTranscriptStateMutation, resolveRuntimeTranscriptReadTarget, type RuntimeTranscriptScope, } from "./transcript-runtime-state.js"; @@ -48,77 +33,51 @@ async function rewriteSqliteRuntimeTranscript(params: { target: Awaited>; request: TranscriptRewriteRequest; }): Promise { - const marker = parseSqliteSessionFileMarker(params.target.sessionFile); - if (!marker) { - return { - changed: false, - bytesFreed: 0, - rewrittenEntries: 0, - reason: "not a SQLite transcript target", - }; - } - const replacementsById = new Map( - params.request.replacements.map((replacement) => [replacement.entryId, replacement.message]), - ); - let bytesFreed = 0; - let rewrittenEntries = 0; - const events = await loadTranscriptEvents({ - agentId: marker.agentId, - sessionId: marker.sessionId, - sessionKey: params.target.sessionKey, - storePath: marker.storePath, - }); - const nextEvents = events.map((event) => { - if (!isTranscriptEventRecord(event)) { - return event; - } - const eventId = typeof event.id === "string" ? event.id : undefined; - const replacement = eventId ? replacementsById.get(eventId) : undefined; - if (!replacement || event.type !== "message") { - return event; - } - bytesFreed += Math.max( - 0, - Buffer.byteLength(JSON.stringify(event.message), "utf8") - - Buffer.byteLength(JSON.stringify(replacement), "utf8"), + return await withTranscriptWriteLock(params.target, async (transcript) => { + const replacementsById = new Map( + params.request.replacements.map((replacement) => [replacement.entryId, replacement.message]), ); - rewrittenEntries += 1; - return Object.assign({}, event, { - message: replacement, + let bytesFreed = 0; + let rewrittenEntries = 0; + const events = await transcript.readEvents(); + const nextEvents = events.map((event) => { + if (!isTranscriptEventRecord(event)) { + return event; + } + const eventId = typeof event.id === "string" ? event.id : undefined; + const replacement = eventId ? replacementsById.get(eventId) : undefined; + if (!replacement || event.type !== "message") { + return event; + } + bytesFreed += Math.max( + 0, + Buffer.byteLength(JSON.stringify(event.message), "utf8") - + Buffer.byteLength(JSON.stringify(replacement), "utf8"), + ); + rewrittenEntries += 1; + return Object.assign({}, event, { message: replacement }); }); - }); - if (rewrittenEntries === 0) { - return { - changed: false, - bytesFreed: 0, - rewrittenEntries: 0, - reason: "no matching transcript entries", - }; - } - await replaceTranscriptEvents( - { - agentId: marker.agentId, - sessionId: marker.sessionId, + if (rewrittenEntries === 0) { + return { + changed: false, + bytesFreed: 0, + rewrittenEntries: 0, + reason: "no matching transcript entries", + }; + } + await transcript.replaceEvents(nextEvents); + emitSessionTranscriptUpdate({ sessionKey: params.target.sessionKey, - storePath: marker.storePath, - }, - nextEvents, - ); - emitSessionTranscriptUpdate({ - sessionFile: params.target.sessionFile, - sessionKey: params.target.sessionKey, - agentId: params.target.agentId, - target: { agentId: params.target.agentId, - sessionId: params.target.sessionId, - sessionKey: params.target.sessionKey, - }, + target: { + agentId: params.target.agentId, + sessionId: params.target.sessionId, + sessionKey: params.target.sessionKey, + storePath: params.target.storePath, + }, + }); + return { changed: true, bytesFreed, rewrittenEntries }; }); - return { - changed: true, - bytesFreed, - rewrittenEntries, - }; } function estimateMessageBytes(message: AgentMessage): number { @@ -223,66 +182,6 @@ function appendBranchEntry(params: { ); } -function appendTranscriptStateBranchEntry(params: { - state: TranscriptFileState; - entry: SessionBranchEntry; - rewrittenEntryIds: ReadonlyMap; -}): SessionBranchEntry { - const { state, entry, rewrittenEntryIds } = params; - if (entry.type === "message") { - return state.appendMessage(entry.message); - } - if (entry.type === "compaction") { - return state.appendCompaction( - entry.summary, - remapEntryId(entry.firstKeptEntryId, rewrittenEntryIds) ?? entry.firstKeptEntryId, - entry.tokensBefore, - entry.details, - entry.fromHook, - ); - } - if (entry.type === "reset") { - return state.appendResetBoundary( - entry.reason, - entry.firstKeptEntryId - ? (remapEntryId(entry.firstKeptEntryId, rewrittenEntryIds) ?? entry.firstKeptEntryId) - : undefined, - ); - } - if (entry.type === "thinking_level_change") { - return state.appendThinkingLevelChange(entry.thinkingLevel); - } - if (entry.type === "model_change") { - return state.appendModelChange(entry.provider, entry.modelId); - } - if (entry.type === "custom") { - return state.appendCustomEntry(entry.customType, entry.data); - } - if (entry.type === "custom_message") { - return state.appendCustomMessageEntry( - entry.customType, - entry.content, - entry.display, - entry.details, - ); - } - if (entry.type === "session_info") { - return state.appendSessionInfo(entry.name ?? ""); - } - if (entry.type === "branch_summary") { - return state.branchWithSummary( - remapEntryId(entry.parentId, rewrittenEntryIds), - entry.summary, - entry.details, - entry.fromHook, - ); - } - return state.appendLabelChange( - remapEntryId(entry.targetId, rewrittenEntryIds) ?? entry.targetId, - entry.label, - ); -} - /** * Safely rewrites transcript message entries on the active branch by branching * from the first rewritten message's parent and re-appending the suffix. @@ -370,168 +269,6 @@ export function rewriteTranscriptEntriesInSessionManager(params: { }; } -export function rewriteTranscriptEntriesInState(params: { - state: TranscriptFileState; - replacements: TranscriptRewriteReplacement[]; - allowedRewriteSuffixEntryIds?: string[]; -}): TranscriptRewriteResult & { appendedEntries: TranscriptPersistedEntry[] } { - const replacementsById = new Map( - params.replacements - .filter((replacement) => replacement.entryId.trim().length > 0) - .map((replacement) => [replacement.entryId, replacement.message]), - ); - if (replacementsById.size === 0) { - return { - changed: false, - bytesFreed: 0, - rewrittenEntries: 0, - reason: "no replacements requested", - appendedEntries: [], - }; - } - - const originalLeafId = params.state.getLeafId(); - const originalAppendParentId = params.state.getAppendParentId(); - const originalAppendMode = params.state.getAppendMode(); - const activeBranch = params.state.getBranch(); - const allEntries = params.state.getEntries(); - let branch = activeBranch; - let restoreOriginalNavigation = false; - const replacementIdsOnBranch = (candidate: readonly SessionBranchEntry[]): Set => - new Set( - candidate - .filter((entry) => entry.type === "message" && replacementsById.has(entry.id)) - .map((entry) => entry.id), - ); - const activeReplacementIds = replacementIdsOnBranch(activeBranch); - if (activeReplacementIds.size > 0 && activeReplacementIds.size < replacementsById.size) { - return { - changed: false, - bytesFreed: 0, - rewrittenEntries: 0, - reason: "rewrite targets span multiple branches", - appendedEntries: [], - }; - } - const activeBranchHasEveryReplacement = activeReplacementIds.size === replacementsById.size; - if (!activeBranchHasEveryReplacement && params.allowedRewriteSuffixEntryIds) { - const allowedIds = new Set(params.allowedRewriteSuffixEntryIds); - const sideBranch = allEntries - .toReversed() - .filter((entry) => allowedIds.has(entry.id)) - .map((entry) => params.state.getBranch(entry.id)) - .find((candidate) => replacementIdsOnBranch(candidate).size === replacementsById.size); - if (sideBranch) { - branch = sideBranch; - restoreOriginalNavigation = true; - } - } - if ( - !activeBranchHasEveryReplacement && - !restoreOriginalNavigation && - activeReplacementIds.size === 0 && - params.replacements.some((replacement) => - allEntries.some((entry) => entry.id === replacement.entryId), - ) - ) { - return { - changed: false, - bytesFreed: 0, - rewrittenEntries: 0, - reason: "rewrite targets span multiple branches", - appendedEntries: [], - }; - } - if (branch.length === 0) { - return { - changed: false, - bytesFreed: 0, - rewrittenEntries: 0, - reason: "empty session", - appendedEntries: [], - }; - } - - const { matchedIndices, bytesFreed } = findTranscriptRewriteMatches(branch, replacementsById); - - if (matchedIndices.length === 0) { - return { - changed: false, - bytesFreed: 0, - rewrittenEntries: 0, - reason: "no matching message entries", - appendedEntries: [], - }; - } - - const firstMatchedIndex = matchedIndices.at(0); - const firstMatchedEntry = - firstMatchedIndex === undefined ? undefined : branch.at(firstMatchedIndex); - if (!firstMatchedEntry || firstMatchedEntry.type !== "message") { - return { - changed: false, - bytesFreed: 0, - rewrittenEntries: 0, - reason: "invalid first rewrite target", - appendedEntries: [], - }; - } - - if (params.allowedRewriteSuffixEntryIds) { - const allowedIds = new Set(params.allowedRewriteSuffixEntryIds); - const hasUnexpectedSuffixEntry = branch - .slice(firstMatchedIndex) - .some((entry) => typeof entry.id === "string" && !allowedIds.has(entry.id)); - if (hasUnexpectedSuffixEntry) { - return { - changed: false, - bytesFreed: 0, - rewrittenEntries: 0, - reason: "rewrite suffix guard failed", - appendedEntries: [], - }; - } - } - - if (!firstMatchedEntry.parentId) { - params.state.resetLeaf(); - } else { - params.state.branch(firstMatchedEntry.parentId); - } - - const appendedEntries: TranscriptPersistedEntry[] = []; - const rewrittenEntryIds = new Map(); - for (const entry of branch.slice(firstMatchedIndex)) { - const replacement = entry.type === "message" ? replacementsById.get(entry.id) : undefined; - const newEntry = - replacement === undefined - ? appendTranscriptStateBranchEntry({ - state: params.state, - entry, - rewrittenEntryIds, - }) - : params.state.appendMessage(replacement); - rewrittenEntryIds.set(entry.id, newEntry.id); - appendedEntries.push(newEntry); - } - if (restoreOriginalNavigation) { - appendedEntries.push( - params.state.appendLeafControl({ - targetId: originalLeafId, - appendParentId: originalAppendParentId, - ...(originalAppendMode ? { appendMode: originalAppendMode } : {}), - }), - ); - } - - return { - changed: true, - bytesFreed, - rewrittenEntries: matchedIndices.length, - appendedEntries, - }; -} - /** * Rewrites message entries for a runtime transcript without using the * file-backed path as caller identity. @@ -539,53 +276,13 @@ export function rewriteTranscriptEntriesInState(params: { export async function rewriteTranscriptEntriesInRuntimeTranscript(params: { scope: RuntimeTranscriptScope; request: TranscriptRewriteRequest; - config?: SessionWriteLockAcquireTimeoutConfig; }): Promise { - let sessionLock: Awaited> | undefined; try { const target = await resolveRuntimeTranscriptReadTarget(params.scope); - if (parseSqliteSessionFileMarker(target.sessionFile)) { - return await rewriteSqliteRuntimeTranscript({ - target, - request: params.request, - }); - } - sessionLock = await acquireSessionWriteLock({ - sessionFile: target.sessionFile, - ...resolveSessionWriteLockOptions(params.config), + return await rewriteSqliteRuntimeTranscript({ + target, + request: params.request, }); - const state = await readTranscriptFileState(target.sessionFile); - const result = rewriteTranscriptEntriesInState({ - state, - replacements: params.request.replacements, - ...(params.request.allowedRewriteSuffixEntryIds - ? { allowedRewriteSuffixEntryIds: params.request.allowedRewriteSuffixEntryIds } - : {}), - }); - if (result.changed) { - await persistRuntimeTranscriptStateMutation({ - target, - state, - appendedEntries: result.appendedEntries, - }); - emitSessionTranscriptUpdate({ - sessionFile: target.sessionFile, - sessionKey: target.sessionKey, - agentId: target.agentId, - target: { - agentId: target.agentId, - sessionId: target.sessionId, - sessionKey: target.sessionKey, - }, - }); - log.info( - `[transcript-rewrite] rewrote ${result.rewrittenEntries} entr` + - `${result.rewrittenEntries === 1 ? "y" : "ies"} ` + - `bytesFreed=${result.bytesFreed} ` + - `sessionKey=${target.sessionKey}`, - ); - } - return result; } catch (err) { const reason = formatErrorMessage(err); log.warn(`[transcript-rewrite] failed: ${reason}`); @@ -595,7 +292,5 @@ export async function rewriteTranscriptEntriesInRuntimeTranscript(params: { rewrittenEntries: 0, reason, }; - } finally { - await sessionLock?.release(); } } diff --git a/src/agents/embedded-agent-runner/transcript-runtime-state.ts b/src/agents/embedded-agent-runner/transcript-runtime-state.ts index 23da57d1a2fa..e26cccf7c47f 100644 --- a/src/agents/embedded-agent-runner/transcript-runtime-state.ts +++ b/src/agents/embedded-agent-runner/transcript-runtime-state.ts @@ -3,11 +3,6 @@ import type { SessionTranscriptRuntimeTarget, } from "../../config/sessions/session-accessor.js"; import { resolveSessionTranscriptRuntimeReadTarget } from "../../config/sessions/session-accessor.js"; -import { - persistTranscriptStateMutation, - type TranscriptFileState, - type TranscriptPersistedEntry, -} from "./transcript-file-state.js"; export type RuntimeTranscriptScope = SessionTranscriptRuntimeScope; type RuntimeTranscriptTarget = SessionTranscriptRuntimeTarget; @@ -21,18 +16,3 @@ export async function resolveRuntimeTranscriptReadTarget( ): Promise { return await resolveSessionTranscriptRuntimeReadTarget(scope); } - -/** - * Persists an append or migration rewrite for a resolved runtime transcript. - */ -export async function persistRuntimeTranscriptStateMutation(params: { - appendedEntries: TranscriptPersistedEntry[]; - state: TranscriptFileState; - target: RuntimeTranscriptTarget; -}): Promise { - await persistTranscriptStateMutation({ - sessionFile: params.target.sessionFile, - state: params.state, - appendedEntries: params.appendedEntries, - }); -} diff --git a/src/agents/embedded-agent-runner/usage-reporting.test.ts b/src/agents/embedded-agent-runner/usage-reporting.test.ts index e213d4dff457..a183fe3bd4ea 100644 --- a/src/agents/embedded-agent-runner/usage-reporting.test.ts +++ b/src/agents/embedded-agent-runner/usage-reporting.test.ts @@ -63,7 +63,7 @@ describe("runEmbeddedAgent usage reporting", () => { await runEmbeddedAgent({ sessionId: "test-session", sessionKey: "test-key", - sessionFile: "/tmp/session.json", + sessionFile: "test-key", workspaceDir: "/tmp/workspace", prompt: "hello", timeoutMs: 30000, @@ -86,7 +86,7 @@ describe("runEmbeddedAgent usage reporting", () => { await runEmbeddedAgent({ sessionId: "test-session", sessionKey: "test-key", - sessionFile: "/tmp/session.json", + sessionFile: "test-key", workspaceDir: "/tmp/workspace", prompt: "hello", timeoutMs: 30000, @@ -112,7 +112,7 @@ describe("runEmbeddedAgent usage reporting", () => { await runEmbeddedAgent({ sessionId: "test-session", sessionKey: "test-key", - sessionFile: "/tmp/session.json", + sessionFile: "test-key", workspaceDir: "/tmp/workspace", prompt: "hello", timeoutMs: 30000, @@ -140,7 +140,7 @@ describe("runEmbeddedAgent usage reporting", () => { await runEmbeddedAgent({ sessionId: "test-session", sessionKey: "test-key", - sessionFile: "/tmp/session.json", + sessionFile: "test-key", workspaceDir: "/tmp/workspace", prompt: "hello", timeoutMs: 30000, @@ -161,7 +161,7 @@ describe("runEmbeddedAgent usage reporting", () => { await runEmbeddedAgent({ sessionId: "test-session", sessionKey: "test-key", - sessionFile: "/tmp/session.json", + sessionFile: "test-key", workspaceDir: "/tmp/workspace", prompt: "flush", timeoutMs: 30000, @@ -206,7 +206,7 @@ describe("runEmbeddedAgent usage reporting", () => { const result = await runEmbeddedAgent({ sessionId: "test-session", sessionKey: "test-key", - sessionFile: "/tmp/session.json", + sessionFile: "test-key", workspaceDir: "/tmp/workspace", prompt: "hello", timeoutMs: 30000, @@ -247,7 +247,7 @@ describe("runEmbeddedAgent usage reporting", () => { const result = await runEmbeddedAgent({ sessionId: "test-session", sessionKey: "test-key", - sessionFile: "/tmp/session.json", + sessionFile: "test-key", workspaceDir: "/tmp/workspace", prompt: "hello", timeoutMs: 30000, @@ -296,7 +296,7 @@ describe("runEmbeddedAgent usage reporting", () => { const result = await runEmbeddedAgent({ sessionId: "test-session", sessionKey: "test-key", - sessionFile: "/tmp/session.json", + sessionFile: "test-key", workspaceDir: "/tmp/workspace", prompt: "hello", provider: "openrouter", diff --git a/src/agents/internal-session-effects.ts b/src/agents/internal-session-effects.ts index f1cf17a933d7..623d42156bb8 100644 --- a/src/agents/internal-session-effects.ts +++ b/src/agents/internal-session-effects.ts @@ -8,7 +8,6 @@ import { upsertSessionEntry, } from "../config/sessions/session-accessor.js"; import { buildSessionCreationStamp } from "../config/sessions/session-entry-provenance.js"; -import { formatSqliteSessionFileMarker } from "../config/sessions/sqlite-marker.js"; import { createSessionTranscriptHeader } from "../config/sessions/transcript-header.js"; import type { SessionEntry } from "../config/sessions/types.js"; import { isIncognitoOpenClawAgentSqlitePath } from "../state/openclaw-agent-db.js"; @@ -57,11 +56,7 @@ function toInternalSessionEffectsTarget(params: { sessionKey: params.sessionKey, storePath: params.storePath, sessionEntry: params.entry, - sessionFile: formatSqliteSessionFileMarker({ - agentId: params.agentId, - sessionId: params.entry.sessionId, - storePath: params.storePath, - }), + sessionFile: params.sessionKey, }; } diff --git a/src/agents/main-session-restart-recovery.test.ts b/src/agents/main-session-restart-recovery.test.ts index a8fb5ff9907f..7b595d911f1c 100644 --- a/src/agents/main-session-restart-recovery.test.ts +++ b/src/agents/main-session-restart-recovery.test.ts @@ -785,14 +785,6 @@ describe("main-session-restart-recovery", () => { }); it.each([ - { - name: "marks a running main session whose cleaned transcript lock is topic-suffixed", - sessionKey: "agent:main:discord:channel:123:thread:1234567890", - sessionId: "main-session", - sessionFile: "main-session-topic-1234567890.jsonl", - lockKind: "session-file", - marked: 1, - }, { name: "does not mark a session for an unrelated topic lock that only shares its id prefix", sessionKey: "agent:main:main", @@ -801,14 +793,6 @@ describe("main-session-restart-recovery", () => { lockKind: "unrelated", marked: 0, }, - { - name: "normalizes relative cleaned lock paths against the current working directory", - sessionKey: "agent:main:discord:channel:123:thread:1234567890", - sessionId: "main-session", - sessionFile: "main-session-topic-1234567890.jsonl", - lockKind: "relative-session-file", - marked: 1, - }, { name: "falls back to the session id transcript lock when persisted sessionFile is outside the sessions dir", sessionKey: "agent:main:main", @@ -833,16 +817,12 @@ describe("main-session-restart-recovery", () => { const lockFile = lockKind === "unrelated" ? "main-session-topic-unrelated.jsonl.lock" - : lockKind === "session-id" - ? `${sessionId}.jsonl.lock` - : `${sessionFile}.lock`; + : `${sessionId}.jsonl.lock`; const lockPath = path.join(sessionsDir, lockFile); - const normalizedLockPath = - lockKind === "relative-session-file" ? path.relative(process.cwd(), lockPath) : lockPath; const result = await markRestartAbortedMainSessionsFromLocks({ sessionsDir, - cleanedLocks: [cleanedLockForPath(normalizedLockPath)], + cleanedLocks: [cleanedLockForPath(lockPath)], }); const store = readStore(path.join(sessionsDir, "sessions.json")); diff --git a/src/agents/run-session-target.test.ts b/src/agents/run-session-target.test.ts index 0940829f4fd8..1a4b9d260f78 100644 --- a/src/agents/run-session-target.test.ts +++ b/src/agents/run-session-target.test.ts @@ -2,7 +2,8 @@ import fs from "node:fs"; import os from "node:os"; import path from "node:path"; import { afterEach, beforeEach, describe, expect, it } from "vitest"; -import { parseSqliteSessionFileMarker } from "../config/sessions/sqlite-marker.js"; +import { formatSqliteSessionFileMarker } from "../config/sessions/legacy-sqlite-marker.js"; +import { upsertSessionEntry } from "../config/sessions/session-accessor.js"; import type { OpenClawConfig } from "../config/types.openclaw.js"; import { resolveAgentRunSessionTarget } from "./run-session-target.js"; @@ -32,10 +33,6 @@ describe("agent run session target", () => { agentId: "helper", sessionId: "test-run", sessionKey, - }); - expect(parseSqliteSessionFileMarker(target.sessionFile)).toEqual({ - agentId: "helper", - sessionId: "test-run", storePath, }); }); @@ -51,24 +48,304 @@ describe("agent run session target", () => { }); const helperStorePath = path.join(tempDir, "agents", "helper", "sessions.json"); - expect(target.agentId).toBe("helper"); - expect(parseSqliteSessionFileMarker(target.sessionFile)).toEqual({ + expect(target).toMatchObject({ agentId: "helper", sessionId: "helper-session", + sessionKey, storePath: helperStorePath, }); }); - it("prefers typed runtime target identity over deprecated sessionFile", async () => { + it("uses the session id as the compatibility key when callers omit sessionKey", async () => { + const storePath = path.join(tempDir, "fallback", "sessions.json"); + + await expect( + resolveAgentRunSessionTarget({ + config: { session: { store: storePath } } as OpenClawConfig, + sessionId: "compat-session", + }), + ).resolves.toEqual({ + agentId: "main", + sessionId: "compat-session", + sessionKey: "compat-session", + storePath, + }); + }); + + it("round-trips a plain compatibility key through sessionFile", async () => { + const storePath = path.join(tempDir, "fallback", "sessions.json"); + + await expect( + resolveAgentRunSessionTarget({ + config: { session: { store: storePath } } as OpenClawConfig, + sessionId: "compat-session", + sessionFile: "compat-session", + }), + ).resolves.toEqual({ + agentId: "main", + sessionId: "compat-session", + sessionKey: "compat-session", + storePath, + }); + }); + + it("normalizes a supplied key before matching its compatibility token", async () => { + const storePath = path.join(tempDir, "fallback", "sessions.json"); + + await expect( + resolveAgentRunSessionTarget({ + config: { session: { store: storePath } } as OpenClawConfig, + sessionId: "compat-session", + sessionFile: "custom-key", + sessionKey: " custom-key ", + }), + ).resolves.toMatchObject({ sessionKey: "custom-key", storePath }); + }); + + it("matches a partial typed target key against its compatibility token", async () => { + const storePath = path.join(tempDir, "fallback", "sessions.json"); + + await expect( + resolveAgentRunSessionTarget({ + config: { session: { store: storePath } } as OpenClawConfig, + sessionId: "compat-session", + sessionFile: "custom-key", + sessionTarget: { + agentId: "main", + sessionId: "compat-session", + sessionKey: "custom-key", + }, + }), + ).resolves.toMatchObject({ sessionKey: "custom-key", storePath }); + }); + + it("uses a compatibility session-file token when callers omit sessionKey", async () => { + const storePath = path.join(tempDir, "fallback", "sessions.json"); + + await expect( + resolveAgentRunSessionTarget({ + config: { session: { store: storePath } } as OpenClawConfig, + sessionId: "compat-session", + sessionFile: "agent:helper:compat-session", + }), + ).resolves.toEqual({ + agentId: "helper", + sessionId: "compat-session", + sessionKey: "agent:helper:compat-session", + storePath, + }); + }); + + it("recovers the stored key from a legacy SQLite marker", async () => { + const storePath = path.join(tempDir, "legacy", "sessions.json"); + const sessionKey = "agent:main:dashboard:legacy-session"; + await upsertSessionEntry( + { agentId: "main", sessionKey, storePath }, + { sessionId: "legacy-session", updatedAt: 1 }, + ); + await upsertSessionEntry( + { agentId: "main", sessionKey: "agent:main:legacy-session", storePath }, + { sessionId: "legacy-session", updatedAt: 2 }, + ); + + await expect( + resolveAgentRunSessionTarget({ + sessionId: "legacy-session", + sessionFile: formatSqliteSessionFileMarker({ + agentId: "main", + sessionId: "legacy-session", + storePath, + }), + }), + ).resolves.toMatchObject({ sessionKey: "agent:main:legacy-session", storePath }); + + await expect( + resolveAgentRunSessionTarget({ + sessionId: "legacy-session", + sessionKey, + sessionFile: formatSqliteSessionFileMarker({ + agentId: "main", + sessionId: "legacy-session", + storePath, + }), + }), + ).resolves.toMatchObject({ sessionKey, storePath }); + }); + + it("uses the marker store for a compatible partial typed target", async () => { + const storePath = path.join(tempDir, "legacy-partial", "sessions.json"); + + await expect( + resolveAgentRunSessionTarget({ + sessionId: "legacy-session", + sessionFile: formatSqliteSessionFileMarker({ + agentId: "main", + sessionId: "legacy-session", + storePath, + }), + sessionTarget: { agentId: "main", sessionId: "legacy-session" }, + }), + ).resolves.toMatchObject({ agentId: "main", sessionId: "legacy-session", storePath }); + }); + + it("rejects a partial typed key from another marker agent", async () => { + await expect( + resolveAgentRunSessionTarget({ + sessionId: "legacy-session", + sessionFile: formatSqliteSessionFileMarker({ + agentId: "main", + sessionId: "legacy-session", + storePath: path.join(tempDir, "legacy.json"), + }), + sessionTarget: { + sessionId: "legacy-session", + sessionKey: "agent:worker:legacy-session", + }, + }), + ).rejects.toThrow("Legacy SQLite transcript marker conflicts"); + }); + + it("rejects a compatibility key from another target agent", async () => { + await expect( + resolveAgentRunSessionTarget({ + sessionId: "compat-session", + sessionFile: "agent:helper:compat-session", + sessionTarget: { agentId: "main", sessionId: "compat-session" }, + }), + ).rejects.toThrow("Compatibility session key conflicts with the supplied agent identity"); + }); + + it("does not let a partial typed target suppress file-backed rejection", async () => { + await expect( + resolveAgentRunSessionTarget({ + sessionId: "legacy-session", + sessionFile: path.join(tempDir, "legacy-session.jsonl"), + sessionTarget: { agentId: "main", sessionId: "legacy-session" }, + }), + ).rejects.toThrow("File-backed transcript targets are unsupported"); + }); + + it("uses a partial target session id as a plain compatibility key", async () => { + await expect( + resolveAgentRunSessionTarget({ + sessionId: "previous-session", + sessionFile: "current-session", + sessionTarget: { agentId: "main", sessionId: "current-session" }, + }), + ).resolves.toMatchObject({ + agentId: "main", + sessionId: "current-session", + sessionKey: "current-session", + }); + }); + + it.each(["session id", "session key"])( + "rejects a file path reused as the outer %s", + async (identityField) => { + const sessionFile = path.join(tempDir, "legacy-session.jsonl"); + await expect( + resolveAgentRunSessionTarget({ + sessionId: identityField === "session id" ? sessionFile : "legacy-session", + ...(identityField === "session key" ? { sessionKey: sessionFile } : {}), + sessionFile, + }), + ).rejects.toThrow("File-backed transcript targets are unsupported"); + }, + ); + + it.each(["agent:main:room/foo", "in-memory:notes.jsonl"])( + "keeps a recognized path-like compatibility key: %s", + async (sessionKey) => { + await expect( + resolveAgentRunSessionTarget({ + agentId: "main", + sessionId: "compat-session", + sessionKey, + sessionFile: sessionKey, + }), + ).resolves.toMatchObject({ agentId: "main", sessionKey }); + }, + ); + + it("rejects a partial typed target that conflicts with a legacy marker", async () => { + await expect( + resolveAgentRunSessionTarget({ + sessionId: "legacy-session", + sessionFile: formatSqliteSessionFileMarker({ + agentId: "main", + sessionId: "legacy-session", + storePath: path.join(tempDir, "legacy.json"), + }), + sessionTarget: { agentId: "worker", sessionId: "legacy-session" }, + }), + ).rejects.toThrow("Legacy SQLite transcript marker conflicts"); + }); + + it("normalizes partial typed identity before comparing a legacy marker", async () => { + await expect( + resolveAgentRunSessionTarget({ + sessionId: "legacy-session", + sessionFile: formatSqliteSessionFileMarker({ + agentId: "main", + sessionId: "legacy-session", + storePath: path.join(tempDir, "legacy.json"), + }), + sessionTarget: { agentId: " main ", sessionId: " legacy-session " }, + }), + ).resolves.toMatchObject({ agentId: "main", sessionId: "legacy-session" }); + }); + + it("ignores a stale compatibility token when a typed key is present", async () => { + const storePath = path.join(tempDir, "target", "sessions.json"); + + await expect( + resolveAgentRunSessionTarget({ + sessionId: "target-session", + sessionFile: "agent:helper:stale", + sessionTarget: { + agentId: "main", + sessionId: "target-session", + sessionKey: "agent:main:target-session", + storePath, + }, + }), + ).resolves.toMatchObject({ + agentId: "main", + sessionKey: "agent:main:target-session", + storePath, + }); + }); + + it("ignores a stale legacy marker when a complete typed target is present", async () => { + const storePath = path.join(tempDir, "target", "sessions.json"); + + await expect( + resolveAgentRunSessionTarget({ + sessionId: "old-session", + sessionFile: `sqlite:helper:old-session:${path.join(tempDir, "old.json")}`, + sessionTarget: { + agentId: "main", + sessionId: "target-session", + sessionKey: "agent:main:target-session", + storePath, + }, + }), + ).resolves.toMatchObject({ + agentId: "main", + sessionId: "target-session", + sessionKey: "agent:main:target-session", + storePath, + }); + }); + + it("prefers typed runtime target identity", async () => { const storePath = path.join(tempDir, "target-store", "sessions.json"); - const legacySessionFile = path.join(tempDir, "legacy-session.jsonl"); const target = await resolveAgentRunSessionTarget({ agentId: "main", config: { session: { store: path.join(tempDir, "fallback", "sessions.json") }, } as OpenClawConfig, - sessionFile: legacySessionFile, sessionId: "legacy-session", sessionKey: "agent:main:legacy-session", sessionTarget: { @@ -83,11 +360,6 @@ describe("agent run session target", () => { agentId: "worker", sessionId: "runtime-session", sessionKey: "agent:worker:runtime-session", - }); - expect(target.sessionFile).not.toBe(legacySessionFile); - expect(parseSqliteSessionFileMarker(target.sessionFile)).toEqual({ - agentId: "worker", - sessionId: "runtime-session", storePath, }); }); diff --git a/src/agents/run-session-target.ts b/src/agents/run-session-target.ts index e810ae6fc895..bd599ccba2cc 100644 --- a/src/agents/run-session-target.ts +++ b/src/agents/run-session-target.ts @@ -1,11 +1,16 @@ +import path from "node:path"; import { normalizeOptionalString } from "@openclaw/normalization-core/string-coerce"; +import { parseSqliteSessionFileMarker } from "../config/sessions/legacy-sqlite-marker.js"; import { resolveStorePath } from "../config/sessions/paths.js"; import { + listSessionEntries, resolveSessionTranscriptRuntimeTarget, type SessionTranscriptRuntimeTarget, } from "../config/sessions/session-accessor.js"; import type { OpenClawConfig } from "../config/types.openclaw.js"; -import { resolveAgentIdFromSessionKey } from "../routing/session-key.js"; +import { parseAgentSessionKey, resolveAgentIdFromSessionKey } from "../routing/session-key.js"; +import { resolvePreferredSessionKeyForSessionIdMatches } from "../sessions/session-id-resolution.js"; +import { resolveDefaultAgentId } from "./agent-scope.js"; /** Identifies a run transcript target without naming the current storage artifact. */ export type AgentRunSessionTarget = { @@ -16,29 +21,144 @@ export type AgentRunSessionTarget = { threadId?: string | number; }; -/** File-backed target resolved from the storage-neutral run identity. */ +/** Canonical SQLite target resolved from the storage-neutral run identity. */ type ResolvedAgentRunSessionTarget = SessionTranscriptRuntimeTarget; /** Resolves the active runtime target used by current run/session internals. */ export async function resolveAgentRunSessionTarget(params: { agentId?: string; config?: OpenClawConfig; - sessionFile?: string; sessionId: string; + sessionFile?: string; sessionKey?: string; sessionTarget?: AgentRunSessionTarget; }): Promise { const sessionTarget = params.sessionTarget; - const agentId = normalizeOptionalString(sessionTarget?.agentId) ?? params.agentId; - const sessionId = normalizeOptionalString(sessionTarget?.sessionId) ?? params.sessionId; - const sessionKey = normalizeOptionalString(sessionTarget?.sessionKey) ?? params.sessionKey; - const effectiveAgentId = agentId ?? resolveAgentIdFromSessionKey(sessionKey); - if (sessionTarget && !sessionKey) { - throw new Error(`Cannot resolve run session target without a session key: ${sessionId}`); + const targetAgentId = normalizeOptionalString(sessionTarget?.agentId); + const targetSessionId = normalizeOptionalString(sessionTarget?.sessionId); + const targetSessionKey = normalizeOptionalString(sessionTarget?.sessionKey); + const targetStorePath = normalizeOptionalString(sessionTarget?.storePath); + const hasCompleteTypedTarget = Boolean( + targetAgentId && targetSessionId && targetSessionKey && targetStorePath, + ); + const legacySessionFile = normalizeOptionalString(params.sessionFile); + const suppliedSessionKey = normalizeOptionalString(params.sessionKey); + const legacyMarker = parseSqliteSessionFileMarker(legacySessionFile); + const recognizedCompatibilityKey = Boolean( + legacySessionFile?.startsWith("agent:") || legacySessionFile?.startsWith("in-memory:"), + ); + const fileBackedCompatibilityValue = Boolean( + legacySessionFile && + !recognizedCompatibilityKey && + (path.isAbsolute(legacySessionFile) || + legacySessionFile.includes("/") || + legacySessionFile.includes("\\") || + legacySessionFile.endsWith(".jsonl")), + ); + const plainCompatibilitySessionKey = + !fileBackedCompatibilityValue && legacySessionFile === (targetSessionId ?? params.sessionId) + ? legacySessionFile + : undefined; + if ( + !hasCompleteTypedTarget && + legacySessionFile && + !legacyMarker && + (fileBackedCompatibilityValue || + (!plainCompatibilitySessionKey && + !recognizedCompatibilityKey && + legacySessionFile !== suppliedSessionKey && + legacySessionFile !== targetSessionKey)) + ) { + throw new Error( + "File-backed transcript targets are unsupported; migrate the session to SQLite first", + ); } + const agentId = targetAgentId ?? legacyMarker?.agentId ?? params.agentId; + const sessionId = targetSessionId ?? legacyMarker?.sessionId ?? params.sessionId; + const compatibilitySessionKey = + plainCompatibilitySessionKey || + legacySessionFile?.startsWith("agent:") || + legacySessionFile?.startsWith("in-memory:") + ? legacySessionFile + : undefined; + const markerEntries = + legacyMarker && !hasCompleteTypedTarget + ? listSessionEntries({ + agentId: legacyMarker.agentId, + storePath: legacyMarker.storePath, + }) + : []; + const markerMatches = legacyMarker + ? markerEntries.filter(({ entry }) => entry.sessionId === legacyMarker.sessionId) + : []; + const markerSessionKey = + legacyMarker && !hasCompleteTypedTarget + ? resolvePreferredSessionKeyForSessionIdMatches( + markerMatches.map(({ sessionKey, entry }) => [sessionKey, entry]), + legacyMarker.sessionId, + ) + : undefined; + if ( + legacyMarker && + !hasCompleteTypedTarget && + !targetSessionKey && + !suppliedSessionKey && + markerMatches.length > 0 && + !markerSessionKey + ) { + throw new Error("Legacy SQLite transcript marker session key is ambiguous"); + } + const sessionKey = + targetSessionKey ?? + suppliedSessionKey ?? + compatibilitySessionKey ?? + markerSessionKey ?? + normalizeOptionalString(sessionId); + const compatibilitySessionKeySelected = + !targetSessionKey && !suppliedSessionKey && sessionKey === compatibilitySessionKey; + const suppliedKeyAgentId = parseAgentSessionKey(suppliedSessionKey)?.agentId; + const targetKeyAgentId = parseAgentSessionKey(targetSessionKey)?.agentId; + const compatibilityKeyAgentId = parseAgentSessionKey(compatibilitySessionKey)?.agentId; + const candidateMarkerKey = targetSessionKey ?? suppliedSessionKey; + const candidateMarkerEntry = candidateMarkerKey + ? markerEntries.find(({ sessionKey: candidateKey }) => candidateKey === candidateMarkerKey) + ?.entry + : undefined; + if ( + legacyMarker && + !hasCompleteTypedTarget && + ((targetAgentId && targetAgentId !== legacyMarker.agentId) || + (targetSessionId && targetSessionId !== legacyMarker.sessionId) || + (params.agentId && params.agentId !== legacyMarker.agentId) || + (targetKeyAgentId && targetKeyAgentId !== legacyMarker.agentId) || + (suppliedKeyAgentId && suppliedKeyAgentId !== legacyMarker.agentId) || + (targetStorePath && path.resolve(targetStorePath) !== path.resolve(legacyMarker.storePath))) + ) { + throw new Error("Legacy SQLite transcript marker conflicts with the supplied session identity"); + } + if ( + legacyMarker && + !hasCompleteTypedTarget && + candidateMarkerKey && + candidateMarkerEntry && + candidateMarkerEntry.sessionId !== legacyMarker.sessionId + ) { + throw new Error("Legacy SQLite transcript marker conflicts with the supplied session key"); + } + if ( + compatibilitySessionKeySelected && + compatibilityKeyAgentId && + agentId && + compatibilityKeyAgentId !== agentId + ) { + throw new Error("Compatibility session key conflicts with the supplied agent identity"); + } + const effectiveAgentId = + agentId ?? resolveAgentIdFromSessionKey(sessionKey, resolveDefaultAgentId(params.config ?? {})); if (sessionTarget && sessionKey) { const storePath = - normalizeOptionalString(sessionTarget.storePath) ?? + targetStorePath ?? + legacyMarker?.storePath ?? resolveStorePath(params.config?.session?.store, { agentId: effectiveAgentId }); return await resolveSessionTranscriptRuntimeTarget({ ...(effectiveAgentId ? { agentId: effectiveAgentId } : {}), @@ -49,15 +169,15 @@ export async function resolveAgentRunSessionTarget(params: { }); } - const sessionFile = normalizeOptionalString(params.sessionFile); - if (sessionFile) { - return { - agentId: effectiveAgentId ?? "", - sessionFile, + if (legacyMarker && sessionKey) { + return await resolveSessionTranscriptRuntimeTarget({ + agentId: legacyMarker.agentId, sessionId, - sessionKey: sessionKey ?? "", - }; + sessionKey, + storePath: legacyMarker.storePath, + }); } + if (!sessionKey) { throw new Error(`Cannot resolve run session target without a session key: ${sessionId}`); } diff --git a/src/agents/session-file-repair.test.ts b/src/agents/session-file-repair.test.ts deleted file mode 100644 index f2a2fef3da62..000000000000 --- a/src/agents/session-file-repair.test.ts +++ /dev/null @@ -1,1249 +0,0 @@ -/** - * Regression coverage for persisted session JSONL repair. - * Exercises malformed lines, blank turns, empty assistant errors, and missing tool results. - */ -import fs from "node:fs/promises"; -import os from "node:os"; -import path from "node:path"; -import { expectDefined } from "@openclaw/normalization-core"; -import { afterEach, describe, expect, it, vi } from "vitest"; -import { OPENCLAW_TRANSCRIPT_ARTIFACT_API } from "../shared/transcript-only-openclaw-assistant.js"; -import { repairSessionFileIfNeeded } from "./session-file-repair.js"; - -const BLANK_USER_FALLBACK_TEXT = "(continue)"; -const CORRUPTED_IMAGE_FALLBACK_TEXT = "[image omitted: corrupted base64 payload]"; - -function buildSessionHeaderAndMessage() { - const header = { - type: "session", - version: 7, - id: "session-1", - timestamp: new Date().toISOString(), - cwd: "/tmp", - }; - const message = { - type: "message", - id: "msg-1", - parentId: null, - timestamp: new Date().toISOString(), - message: { role: "user", content: "hello" }, - }; - return { header, message }; -} - -const tempDirs: string[] = []; - -async function readTrustedSnapshot(file: string) { - const stat = await fs.stat(file, { bigint: true }); - return { - dev: stat.dev, - ino: stat.ino, - size: stat.size, - mtimeNs: stat.mtimeNs, - ctimeNs: stat.ctimeNs, - }; -} - -async function createTempSessionPath() { - const dir = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-session-repair-")); - tempDirs.push(dir); - return { dir, file: path.join(dir, "session.jsonl") }; -} - -async function expectNoRetainedBackup( - file: string, - result: { backupPath?: string }, -): Promise { - expect(result.backupPath).toBeUndefined(); - const siblings = await fs.readdir(path.dirname(file)); - const leftover = siblings.filter((name) => name.includes(".bak-")); - expect(leftover).toEqual([]); -} - -function requireFirstLogMessage(log: ReturnType): string { - const message = log.mock.calls[0]?.[0]; - if (typeof message !== "string") { - throw new Error("expected first log message"); - } - return message; -} - -const PNG_1X1 = - "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR4nGNgYAAAAAMAASsJTYQAAAAASUVORK5CYII="; -const BMP_HEADER = Buffer.from("BMfixture", "ascii").toString("base64"); - -afterEach(async () => { - vi.restoreAllMocks(); - await Promise.all(tempDirs.splice(0).map((dir) => fs.rm(dir, { recursive: true, force: true }))); -}); - -describe("repairSessionFileIfNeeded", () => { - it("skips SQLite transcript markers instead of treating them as file paths", async () => { - const result = await repairSessionFileIfNeeded({ - sessionFile: "sqlite:main:session-1:/tmp/openclaw/sessions.json", - }); - - expect(result).toEqual({ - repaired: false, - droppedLines: 0, - reason: "sqlite transcript", - }); - }); - - it("rewrites session files that contain malformed lines", async () => { - const { file } = await createTempSessionPath(); - const { header, message } = buildSessionHeaderAndMessage(); - - const content = `${JSON.stringify(header)}\n${JSON.stringify(message)}\n{"type":"message"`; - await fs.writeFile(file, content, "utf-8"); - - const result = await repairSessionFileIfNeeded({ sessionFile: file }); - expect(result.repaired).toBe(true); - expect(result.droppedLines).toBe(1); - expect(result.validatedSnapshot).toEqual(await readTrustedSnapshot(file)); - - const repaired = await fs.readFile(file, "utf-8"); - const repairedLines = repaired - .trim() - .split("\n") - .map((line) => JSON.parse(line)); - expect(repairedLines).toEqual([header, message]); - - await expectNoRetainedBackup(file, result); - }); - - it("does not accumulate backups across repeated repairs of a persistently corrupted file", async () => { - // Regression for #80960: a stuck session with a writer that keeps - // appending a malformed line caused every repair invocation to leave a - // ~1.8 MB `.bak--` snapshot, accumulating 2,180 files / 2.1 GB - // on a single agent over ~25 hours. - const { file } = await createTempSessionPath(); - const { header, message } = buildSessionHeaderAndMessage(); - const malformedTail = '{"type":"message"'; - - for (let i = 0; i < 5; i++) { - const content = `${JSON.stringify(header)}\n${JSON.stringify(message)}\n${malformedTail}`; - await fs.writeFile(file, content, "utf-8"); - const result = await repairSessionFileIfNeeded({ sessionFile: file }); - expect(result.repaired).toBe(true); - expect(result.droppedLines).toBe(1); - await expectNoRetainedBackup(file, result); - } - }); - - it("reports retained backup path when successful repair backup cleanup fails", async () => { - const { file } = await createTempSessionPath(); - const { header, message } = buildSessionHeaderAndMessage(); - const content = `${JSON.stringify(header)}\n${JSON.stringify(message)}\n{"type":"message"`; - await fs.writeFile(file, content, "utf-8"); - vi.spyOn(fs, "unlink").mockRejectedValueOnce(new Error("simulated cleanup failure")); - - const debug = vi.fn(); - const result = await repairSessionFileIfNeeded({ sessionFile: file, debug }); - - expect(result.repaired).toBe(true); - expect(result.backupPath).toMatch(/session\.jsonl\.bak-/); - expect( - debug.mock.calls.some(([messageLocal]) => String(messageLocal).includes("cleanup failed")), - ).toBe(true); - const siblings = await fs.readdir(path.dirname(file)); - expect(siblings.filter((name) => name.includes(".bak-"))).toHaveLength(1); - }); - - it("does not drop CRLF-terminated JSONL lines", async () => { - const { file } = await createTempSessionPath(); - const { header, message } = buildSessionHeaderAndMessage(); - const content = `${JSON.stringify(header)}\r\n${JSON.stringify(message)}\r\n`; - await fs.writeFile(file, content, "utf-8"); - - const result = await repairSessionFileIfNeeded({ sessionFile: file }); - expect(result.repaired).toBe(false); - expect(result.droppedLines).toBe(0); - }); - - it("validates only trusted appended entries after a clean repair pass", async () => { - const { file } = await createTempSessionPath(); - const { header, message } = buildSessionHeaderAndMessage(); - await fs.writeFile(file, `${JSON.stringify(header)}\n${JSON.stringify(message)}\n`, "utf-8"); - - const originalParse = JSON.parse; - let parseCount = 0; - JSON.parse = function countedParse(...args: Parameters) { - parseCount += 1; - return originalParse.apply(originalParse, args); - } as typeof JSON.parse; - try { - const initial = await repairSessionFileIfNeeded({ sessionFile: file }); - expect(parseCount).toBe(2); - expect(initial.validatedSnapshot).toEqual(await readTrustedSnapshot(file)); - - parseCount = 0; - await fs.appendFile( - file, - `${JSON.stringify({ - type: "message", - id: "msg-2", - parentId: message.id, - timestamp: new Date().toISOString(), - message: { role: "assistant", content: "hello back" }, - })}\n`, - "utf-8", - ); - const trustedSnapshot = await readTrustedSnapshot(file); - const result = await repairSessionFileIfNeeded({ - sessionFile: file, - trustedSnapshot, - }); - - expect(result).toMatchObject({ repaired: false, droppedLines: 0 }); - expect(result.validatedSnapshot).toEqual(trustedSnapshot); - expect(parseCount).toBe(1); - } finally { - JSON.parse = originalParse; - } - }); - - it("falls back to full repair when a trusted append needs rewriting", async () => { - const { file } = await createTempSessionPath(); - const { header, message } = buildSessionHeaderAndMessage(); - await fs.writeFile(file, `${JSON.stringify(header)}\n${JSON.stringify(message)}\n`, "utf-8"); - await repairSessionFileIfNeeded({ sessionFile: file }); - await fs.appendFile( - file, - `${JSON.stringify({ - type: "message", - id: "msg-2", - parentId: message.id, - timestamp: new Date().toISOString(), - message: { role: "assistant", content: [], stopReason: "error" }, - })}\n`, - "utf-8", - ); - const trustedSnapshot = await readTrustedSnapshot(file); - - const result = await repairSessionFileIfNeeded({ - sessionFile: file, - trustedSnapshot, - }); - - expect(result).toMatchObject({ repaired: true, rewrittenAssistantMessages: 1 }); - expect(await fs.readFile(file, "utf-8")).toContain( - "[assistant turn failed before producing content]", - ); - }); - - it("rejects incremental repair when the trusted fingerprint becomes stale", async () => { - const { file } = await createTempSessionPath(); - const { header, message } = buildSessionHeaderAndMessage(); - const original = `${JSON.stringify(header)}\n${JSON.stringify(message)}\n`; - await fs.writeFile(file, original, "utf-8"); - await repairSessionFileIfNeeded({ sessionFile: file }); - const trustedSnapshot = await readTrustedSnapshot(file); - - const invalidMessage = { - ...message, - message: { role: null, content: "invalid prefix replacement" }, - }; - const appendedMessage = { - ...message, - id: "msg-2", - parentId: message.id, - message: { role: "assistant", content: "valid tail" }, - }; - await fs.writeFile( - file, - `${JSON.stringify(header)}\n${JSON.stringify(invalidMessage)}\n${JSON.stringify(appendedMessage)}\n`, - "utf-8", - ); - - const result = await repairSessionFileIfNeeded({ sessionFile: file, trustedSnapshot }); - - expect(result).toMatchObject({ repaired: true, droppedLines: 1 }); - }); - - it("does not retain oversized tool-result ID indexes", async () => { - const { file } = await createTempSessionPath(); - const { header } = buildSessionHeaderAndMessage(); - const entries = Array.from({ length: 4_097 }, (_, index) => ({ - type: "message", - id: `result-${index}`, - parentId: index > 0 ? `result-${index - 1}` : null, - timestamp: new Date().toISOString(), - message: { - role: "toolResult", - toolCallId: `call-${index}`, - toolName: "test", - content: [{ type: "text", text: "ok" }], - isError: false, - }, - })); - await fs.writeFile( - file, - `${[header, ...entries].map((entry) => JSON.stringify(entry)).join("\n")}\n`, - "utf-8", - ); - await repairSessionFileIfNeeded({ sessionFile: file }); - - const originalParse = JSON.parse; - let parseCount = 0; - JSON.parse = function countedParse(...args: Parameters) { - parseCount += 1; - return originalParse.apply(originalParse, args); - } as typeof JSON.parse; - try { - await repairSessionFileIfNeeded({ sessionFile: file }); - expect(parseCount).toBe(entries.length + 1); - } finally { - JSON.parse = originalParse; - } - }); - - it("warns and skips repair when the session header is invalid", async () => { - const { file } = await createTempSessionPath(); - const badHeader = { - type: "message", - id: "msg-1", - timestamp: new Date().toISOString(), - message: { role: "user", content: "hello" }, - }; - const content = `${JSON.stringify(badHeader)}\n{"type":"message"`; - await fs.writeFile(file, content, "utf-8"); - - const warn = vi.fn(); - const result = await repairSessionFileIfNeeded({ sessionFile: file, warn }); - - expect(result.repaired).toBe(false); - expect(result.reason).toBe("invalid session header"); - expect(warn).toHaveBeenCalledTimes(1); - expect(requireFirstLogMessage(warn)).toContain("invalid session header"); - }); - - it("returns a detailed reason when read errors are not ENOENT", async () => { - const { dir } = await createTempSessionPath(); - const warn = vi.fn(); - - const result = await repairSessionFileIfNeeded({ sessionFile: dir, warn }); - - expect(result.repaired).toBe(false); - expect(result.reason).toContain("failed to read session file"); - expect(warn).toHaveBeenCalledTimes(1); - }); - - it("rewrites persisted assistant messages with empty content arrays", async () => { - const { file } = await createTempSessionPath(); - const { header, message } = buildSessionHeaderAndMessage(); - const poisonedAssistantEntry = { - type: "message", - id: "msg-2", - parentId: null, - timestamp: new Date().toISOString(), - message: { - role: "assistant", - content: [], - api: "bedrock-converse-stream", - provider: "amazon-bedrock", - model: "anthropic.claude-3-haiku-20240307-v1:0", - usage: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, totalTokens: 0 }, - stopReason: "error", - errorMessage: "transient stream failure", - }, - }; - // Follow-up keeps this case focused on empty error-turn repair. - const followUp = { - type: "message", - id: "msg-3", - parentId: null, - timestamp: new Date().toISOString(), - message: { role: "user", content: "retry" }, - }; - const original = `${JSON.stringify(header)}\n${JSON.stringify(message)}\n${JSON.stringify(poisonedAssistantEntry)}\n${JSON.stringify(followUp)}\n`; - await fs.writeFile(file, original, "utf-8"); - - const debug = vi.fn(); - const result = await repairSessionFileIfNeeded({ sessionFile: file, debug }); - - expect(result.repaired).toBe(true); - expect(result.droppedLines).toBe(0); - expect(result.rewrittenAssistantMessages).toBe(1); - await expectNoRetainedBackup(file, result); - expect(debug).toHaveBeenCalledTimes(1); - const debugMessage = requireFirstLogMessage(debug); - expect(debugMessage).toContain("rewrote 1 assistant message(s)"); - expect(debugMessage).not.toContain("dropped"); - - const repaired = await fs.readFile(file, "utf-8"); - const repairedLines = repaired.trim().split("\n"); - expect(repairedLines).toHaveLength(4); - const repairedEntry: { message: { content: { type: string; text: string }[] } } = JSON.parse( - expectDefined(repairedLines[2], "repairedLines[2] test invariant"), - ); - expect(repairedEntry.message.content).toEqual([ - { type: "text", text: "[assistant turn failed before producing content]" }, - ]); - }); - - it("rewrites blank-only user text messages to synthetic placeholder instead of dropping", async () => { - const { file } = await createTempSessionPath(); - const { header, message } = buildSessionHeaderAndMessage(); - const blankUserEntry = { - type: "message", - id: "msg-blank", - parentId: null, - timestamp: new Date().toISOString(), - message: { - role: "user", - content: [{ type: "text", text: "" }], - }, - }; - const original = `${JSON.stringify(header)}\n${JSON.stringify(blankUserEntry)}\n${JSON.stringify(message)}\n`; - await fs.writeFile(file, original, "utf-8"); - - const debug = vi.fn(); - const result = await repairSessionFileIfNeeded({ sessionFile: file, debug }); - - expect(result.repaired).toBe(true); - expect(result.rewrittenUserMessages).toBe(1); - expect(result.droppedBlankUserMessages).toBe(0); - expect(requireFirstLogMessage(debug)).toContain("rewrote 1 user message(s)"); - - const repaired = await fs.readFile(file, "utf-8"); - const repairedLines = repaired.trim().split("\n"); - expect(repairedLines).toHaveLength(3); - const rewrittenEntry = JSON.parse( - expectDefined(repairedLines[1], "repairedLines[1] test invariant"), - ); - expect(rewrittenEntry.id).toBe("msg-blank"); - expect(rewrittenEntry.message.content).toEqual([ - { type: "text", text: BLANK_USER_FALLBACK_TEXT }, - ]); - }); - - it("rewrites blank string-content user messages to placeholder", async () => { - const { file } = await createTempSessionPath(); - const { header, message } = buildSessionHeaderAndMessage(); - const blankStringUserEntry = { - type: "message", - id: "msg-blank-str", - parentId: null, - timestamp: new Date().toISOString(), - message: { - role: "user", - content: " ", - }, - }; - const original = `${JSON.stringify(header)}\n${JSON.stringify(blankStringUserEntry)}\n${JSON.stringify(message)}\n`; - await fs.writeFile(file, original, "utf-8"); - - const result = await repairSessionFileIfNeeded({ sessionFile: file }); - - expect(result.repaired).toBe(true); - expect(result.rewrittenUserMessages).toBe(1); - - const repaired = await fs.readFile(file, "utf-8"); - const repairedLines = repaired.trim().split("\n"); - expect(repairedLines).toHaveLength(3); - const rewrittenEntry = JSON.parse( - expectDefined(repairedLines[1], "repairedLines[1] test invariant"), - ); - expect(rewrittenEntry.message.content).toBe(BLANK_USER_FALLBACK_TEXT); - }); - - it("removes blank user text blocks while preserving media blocks", async () => { - const { file } = await createTempSessionPath(); - const { header } = buildSessionHeaderAndMessage(); - const mediaUserEntry = { - type: "message", - id: "msg-media", - parentId: null, - timestamp: new Date().toISOString(), - message: { - role: "user", - content: [ - { type: "text", text: " " }, - { type: "image", data: PNG_1X1, mimeType: "image/png" }, - ], - }, - }; - const original = `${JSON.stringify(header)}\n${JSON.stringify(mediaUserEntry)}\n`; - await fs.writeFile(file, original, "utf-8"); - - const result = await repairSessionFileIfNeeded({ sessionFile: file }); - - expect(result.repaired).toBe(true); - expect(result.rewrittenUserMessages).toBe(1); - const repaired = await fs.readFile(file, "utf-8"); - const repairedEntry = JSON.parse(repaired.trim().split("\n")[1] ?? "{}"); - expect(repairedEntry.message.content).toEqual([ - { type: "image", data: PNG_1X1, mimeType: "image/png" }, - ]); - }); - - it("rewrites corrupted image blocks so replay can continue", async () => { - const { file } = await createTempSessionPath(); - const { header } = buildSessionHeaderAndMessage(); - const poisonedUserEntry = { - type: "message", - id: "msg-poisoned-image", - parentId: null, - timestamp: new Date().toISOString(), - message: { - role: "user", - content: [ - { type: "text", text: "inspect this" }, - { type: "image", data: "iVBORw0KGgoAKID…MNOPAAA=", mimeType: "image/png" }, - ], - }, - }; - const original = `${JSON.stringify(header)}\n${JSON.stringify(poisonedUserEntry)}\n`; - await fs.writeFile(file, original, "utf-8"); - - const debug = vi.fn(); - const result = await repairSessionFileIfNeeded({ sessionFile: file, debug }); - - expect(result.repaired).toBe(true); - expect(result.removedCorruptedImageBlocks).toBe(1); - expect(requireFirstLogMessage(debug)).toContain("removed 1 corrupted image block(s)"); - const repaired = await fs.readFile(file, "utf-8"); - const repairedEntry = JSON.parse(repaired.trim().split("\n")[1] ?? "{}"); - expect(repairedEntry.message.content).toEqual([ - { type: "text", text: "inspect this" }, - { type: "text", text: CORRUPTED_IMAGE_FALLBACK_TEXT }, - ]); - }); - - it("preserves valid image blocks during repair", async () => { - const { file } = await createTempSessionPath(); - const { header } = buildSessionHeaderAndMessage(); - const validUserEntry = { - type: "message", - id: "msg-valid-image", - parentId: null, - timestamp: new Date().toISOString(), - message: { - role: "user", - content: [ - { type: "text", text: "inspect this" }, - { type: "image", data: PNG_1X1, mimeType: "image/png" }, - ], - }, - }; - const original = `${JSON.stringify(header)}\n${JSON.stringify(validUserEntry)}\n`; - await fs.writeFile(file, original, "utf-8"); - - const result = await repairSessionFileIfNeeded({ sessionFile: file }); - - expect(result.repaired).toBe(false); - const repaired = await fs.readFile(file, "utf-8"); - expect(repaired).toBe(original); - }); - - it("preserves valid non-browser image blocks during repair", async () => { - const { file } = await createTempSessionPath(); - const { header } = buildSessionHeaderAndMessage(); - const validUserEntry = { - type: "message", - id: "msg-valid-bmp", - parentId: null, - timestamp: new Date().toISOString(), - message: { - role: "user", - content: [ - { type: "text", text: "inspect this" }, - { type: "image", data: BMP_HEADER, mimeType: "image/bmp" }, - ], - }, - }; - const original = `${JSON.stringify(header)}\n${JSON.stringify(validUserEntry)}\n`; - await fs.writeFile(file, original, "utf-8"); - - const result = await repairSessionFileIfNeeded({ sessionFile: file }); - - expect(result.repaired).toBe(false); - const repaired = await fs.readFile(file, "utf-8"); - expect(repaired).toBe(original); - }); - - it("rewrites syntactically valid base64 that is not image bytes", async () => { - const { file } = await createTempSessionPath(); - const { header } = buildSessionHeaderAndMessage(); - const fakeImageUserEntry = { - type: "message", - id: "msg-fake-image", - parentId: null, - timestamp: new Date().toISOString(), - message: { - role: "user", - content: [ - { type: "text", text: "inspect this" }, - { type: "image", data: "SGVsbG8=", mimeType: "image/png" }, - ], - }, - }; - const original = `${JSON.stringify(header)}\n${JSON.stringify(fakeImageUserEntry)}\n`; - await fs.writeFile(file, original, "utf-8"); - - const result = await repairSessionFileIfNeeded({ sessionFile: file }); - - expect(result.repaired).toBe(true); - expect(result.removedCorruptedImageBlocks).toBe(1); - const repaired = await fs.readFile(file, "utf-8"); - const repairedEntry = JSON.parse(repaired.trim().split("\n")[1] ?? "{}"); - expect(repairedEntry.message.content).toEqual([ - { type: "text", text: "inspect this" }, - { type: "text", text: CORRUPTED_IMAGE_FALLBACK_TEXT }, - ]); - }); - - it("reports both drops and rewrites in the debug message when both occur", async () => { - const { file } = await createTempSessionPath(); - const { header } = buildSessionHeaderAndMessage(); - const poisonedAssistantEntry = { - type: "message", - id: "msg-2", - parentId: null, - timestamp: new Date().toISOString(), - message: { - role: "assistant", - content: [], - api: "bedrock-converse-stream", - provider: "amazon-bedrock", - model: "anthropic.claude-3-haiku-20240307-v1:0", - usage: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, totalTokens: 0 }, - stopReason: "error", - }, - }; - const original = `${JSON.stringify(header)}\n${JSON.stringify(poisonedAssistantEntry)}\n{"type":"message"`; - await fs.writeFile(file, original, "utf-8"); - - const debug = vi.fn(); - const result = await repairSessionFileIfNeeded({ sessionFile: file, debug }); - - expect(result.repaired).toBe(true); - expect(result.droppedLines).toBe(1); - expect(result.rewrittenAssistantMessages).toBe(1); - const debugMessage = requireFirstLogMessage(debug); - expect(debugMessage).toContain("dropped 1 malformed line(s)"); - expect(debugMessage).toContain("rewrote 1 assistant message(s)"); - }); - - it("does not rewrite silent-reply turns (stopReason=stop, content=[]) on disk", async () => { - const { file } = await createTempSessionPath(); - const { header } = buildSessionHeaderAndMessage(); - const silentReplyEntry = { - type: "message", - id: "msg-2", - parentId: null, - timestamp: new Date().toISOString(), - message: { - role: "assistant", - content: [], - api: "openai-responses", - provider: "ollama", - model: "glm-5.1:cloud", - usage: { input: 100, output: 0, cacheRead: 0, cacheWrite: 0, totalTokens: 100 }, - stopReason: "stop", - }, - }; - // Follow-up keeps this case focused on silent-reply preservation. - const followUp = { - type: "message", - id: "msg-3", - parentId: null, - timestamp: new Date().toISOString(), - message: { role: "user", content: "follow up" }, - }; - const original = `${JSON.stringify(header)}\n${JSON.stringify(silentReplyEntry)}\n${JSON.stringify(followUp)}\n`; - await fs.writeFile(file, original, "utf-8"); - - const result = await repairSessionFileIfNeeded({ sessionFile: file }); - - expect(result.repaired).toBe(false); - expect(result.rewrittenAssistantMessages ?? 0).toBe(0); - const after = await fs.readFile(file, "utf-8"); - expect(after).toBe(original); - }); - - it("preserves delivered trailing assistant messages in the session file", async () => { - const { file } = await createTempSessionPath(); - const { header, message } = buildSessionHeaderAndMessage(); - const assistantEntry = { - type: "message", - id: "msg-asst", - parentId: null, - timestamp: new Date().toISOString(), - message: { - role: "assistant", - content: [{ type: "text", text: "stale answer" }], - stopReason: "stop", - }, - }; - const original = `${JSON.stringify(header)}\n${JSON.stringify(message)}\n${JSON.stringify(assistantEntry)}\n`; - await fs.writeFile(file, original, "utf-8"); - - const result = await repairSessionFileIfNeeded({ sessionFile: file }); - - expect(result.repaired).toBe(false); - - const after = await fs.readFile(file, "utf-8"); - expect(after).toBe(original); - }); - - it("preserves multiple consecutive delivered trailing assistant messages", async () => { - const { file } = await createTempSessionPath(); - const { header, message } = buildSessionHeaderAndMessage(); - const assistantEntry1 = { - type: "message", - id: "msg-asst-1", - parentId: null, - timestamp: new Date().toISOString(), - message: { - role: "assistant", - content: [{ type: "text", text: "first" }], - stopReason: "stop", - }, - }; - const assistantEntry2 = { - type: "message", - id: "msg-asst-2", - parentId: null, - timestamp: new Date().toISOString(), - message: { - role: "assistant", - content: [{ type: "text", text: "second" }], - stopReason: "stop", - }, - }; - const original = `${JSON.stringify(header)}\n${JSON.stringify(message)}\n${JSON.stringify(assistantEntry1)}\n${JSON.stringify(assistantEntry2)}\n`; - await fs.writeFile(file, original, "utf-8"); - - const result = await repairSessionFileIfNeeded({ sessionFile: file }); - - expect(result.repaired).toBe(false); - - const after = await fs.readFile(file, "utf-8"); - expect(after).toBe(original); - }); - - it("does not trim non-trailing assistant messages", async () => { - const { file } = await createTempSessionPath(); - const { header, message } = buildSessionHeaderAndMessage(); - const assistantEntry = { - type: "message", - id: "msg-asst", - parentId: null, - timestamp: new Date().toISOString(), - message: { - role: "assistant", - content: [{ type: "text", text: "answer" }], - stopReason: "stop", - }, - }; - const userFollowUp = { - type: "message", - id: "msg-user-2", - parentId: null, - timestamp: new Date().toISOString(), - message: { role: "user", content: "follow up" }, - }; - const original = `${JSON.stringify(header)}\n${JSON.stringify(message)}\n${JSON.stringify(assistantEntry)}\n${JSON.stringify(userFollowUp)}\n`; - await fs.writeFile(file, original, "utf-8"); - - const result = await repairSessionFileIfNeeded({ sessionFile: file }); - - expect(result.repaired).toBe(false); - }); - - it("preserves trailing assistant messages that contain tool calls", async () => { - const { file } = await createTempSessionPath(); - const { header, message } = buildSessionHeaderAndMessage(); - const toolCallAssistant = { - type: "message", - id: "msg-asst-tc", - parentId: null, - timestamp: new Date().toISOString(), - message: { - role: "assistant", - content: [ - { type: "text", text: "Let me check that." }, - { type: "toolCall", id: "call_1", name: "read", input: { path: "/tmp/test" } }, - ], - stopReason: "toolUse", - }, - }; - const original = `${JSON.stringify(header)}\n${JSON.stringify(message)}\n${JSON.stringify(toolCallAssistant)}\n`; - await fs.writeFile(file, original, "utf-8"); - - const result = await repairSessionFileIfNeeded({ sessionFile: file }); - - expect(result.repaired).toBe(false); - const after = await fs.readFile(file, "utf-8"); - expect(after).toBe(original); - }); - - it("preserves adjacent trailing tool-call and text assistant messages", async () => { - const { file } = await createTempSessionPath(); - const { header, message } = buildSessionHeaderAndMessage(); - const toolCallAssistant = { - type: "message", - id: "msg-asst-tc", - parentId: null, - timestamp: new Date().toISOString(), - message: { - role: "assistant", - content: [{ type: "toolUse", id: "call_1", name: "read" }], - stopReason: "toolUse", - }, - }; - const plainAssistant = { - type: "message", - id: "msg-asst-plain", - parentId: null, - timestamp: new Date().toISOString(), - message: { - role: "assistant", - content: [{ type: "text", text: "stale" }], - stopReason: "stop", - }, - }; - const original = `${JSON.stringify(header)}\n${JSON.stringify(message)}\n${JSON.stringify(toolCallAssistant)}\n${JSON.stringify(plainAssistant)}\n`; - await fs.writeFile(file, original, "utf-8"); - - const result = await repairSessionFileIfNeeded({ sessionFile: file }); - - expect(result.repaired).toBe(false); - - const after = await fs.readFile(file, "utf-8"); - expect(after).toBe(original); - }); - - it("inserts missing code-mode tool results before replay repair has to synthesize them", async () => { - const { file } = await createTempSessionPath(); - const { header, message } = buildSessionHeaderAndMessage(); - const toolCallAssistant = { - type: "message", - id: "msg-asst-process", - parentId: "msg-1", - timestamp: new Date().toISOString(), - message: { - role: "assistant", - provider: "openai", - model: "gpt-5.5", - api: "openai-chatgpt-responses", - content: [ - { type: "text", text: "Process List" }, - { - type: "toolCall", - id: "call_process|fc_1", - name: "process", - arguments: { action: "poll", sessionId: "wild-wharf", timeout: 30_000 }, - }, - ], - stopReason: "toolUse", - }, - }; - const deliveryMirror = { - type: "message", - id: "msg-delivery", - parentId: "msg-asst-process", - timestamp: new Date().toISOString(), - message: { - role: "assistant", - provider: "openclaw", - model: "delivery-mirror", - api: OPENCLAW_TRANSCRIPT_ARTIFACT_API, - content: [{ type: "text", text: "Process: `wild-wharf`" }], - stopReason: "stop", - }, - }; - const original = `${JSON.stringify(header)}\n${JSON.stringify(message)}\n${JSON.stringify(toolCallAssistant)}\n${JSON.stringify(deliveryMirror)}\n`; - await fs.writeFile(file, original, "utf-8"); - - const result = await repairSessionFileIfNeeded({ sessionFile: file }); - - expect(result.repaired).toBe(true); - expect(result.insertedToolResults).toBe(1); - await expectNoRetainedBackup(file, result); - - const lines = (await fs.readFile(file, "utf-8")).trimEnd().split("\n"); - expect(lines).toHaveLength(5); - const inserted = JSON.parse(expectDefined(lines[3], "lines[3] test invariant")); - expect(inserted.type).toBe("message"); - expect(inserted.parentId).toBe("msg-asst-process"); - expect(inserted.message.role).toBe("toolResult"); - expect(inserted.message.toolCallId).toBe("call_process|fc_1"); - expect(inserted.message.toolName).toBe("process"); - expect(inserted.message.isError).toBe(true); - expect(inserted.message.content[0].text).toBe("aborted"); - expect(JSON.parse(expectDefined(lines[4], "lines[4] test invariant"))).toEqual(deliveryMirror); - }); - - it("inserts missing Responses message-tool results before delivery mirrors", async () => { - const { file } = await createTempSessionPath(); - const { header, message } = buildSessionHeaderAndMessage(); - const toolCallAssistant = { - type: "message", - id: "msg-asst-message-tool", - parentId: "msg-1", - timestamp: new Date().toISOString(), - message: { - role: "assistant", - provider: "oca", - model: "gpt-5.5", - api: "openai-responses", - content: [ - { - type: "toolCall", - id: "call_message|fc_message", - name: "message", - arguments: { action: "send", message: "visible reply" }, - }, - ], - stopReason: "toolUse", - }, - }; - const deliveryMirror = { - type: "message", - id: "msg-delivery-mirror", - parentId: "msg-asst-message-tool", - timestamp: new Date().toISOString(), - message: { - role: "assistant", - provider: "openclaw", - model: "delivery-mirror", - api: OPENCLAW_TRANSCRIPT_ARTIFACT_API, - content: [{ type: "text", text: "visible reply" }], - stopReason: "stop", - }, - }; - const original = `${JSON.stringify(header)}\n${JSON.stringify(message)}\n${JSON.stringify(toolCallAssistant)}\n${JSON.stringify(deliveryMirror)}\n`; - await fs.writeFile(file, original, "utf-8"); - - const result = await repairSessionFileIfNeeded({ sessionFile: file }); - - expect(result.repaired).toBe(true); - expect(result.insertedToolResults).toBe(1); - await expectNoRetainedBackup(file, result); - - const lines = (await fs.readFile(file, "utf-8")).trimEnd().split("\n"); - expect(lines).toHaveLength(5); - const inserted = JSON.parse(expectDefined(lines[3], "lines[3] test invariant")); - expect(inserted.type).toBe("message"); - expect(inserted.parentId).toBe("msg-asst-message-tool"); - expect(inserted.message.role).toBe("toolResult"); - expect(inserted.message.toolCallId).toBe("call_message|fc_message"); - expect(inserted.message.toolName).toBe("message"); - expect(inserted.message.isError).toBe(true); - expect(inserted.message.content[0].text).toBe("aborted"); - expect(JSON.parse(expectDefined(lines[4], "lines[4] test invariant"))).toEqual(deliveryMirror); - }); - - it("does not duplicate code-mode tool results that are already persisted", async () => { - const { file } = await createTempSessionPath(); - const { header, message } = buildSessionHeaderAndMessage(); - const toolCallAssistant = { - type: "message", - id: "msg-asst-exec", - parentId: "msg-1", - timestamp: new Date().toISOString(), - message: { - role: "assistant", - provider: "openai", - model: "gpt-5.5", - api: "openai-chatgpt-responses", - content: [{ type: "toolCall", id: "call_exec|fc_1", name: "exec", arguments: {} }], - stopReason: "toolUse", - }, - }; - const toolResult = { - type: "message", - id: "msg-tool-result", - parentId: "msg-asst-exec", - timestamp: new Date().toISOString(), - message: { - role: "toolResult", - toolCallId: "call_exec|fc_1", - toolName: "exec", - content: [{ type: "text", text: "ok" }], - isError: false, - }, - }; - const original = `${JSON.stringify(header)}\n${JSON.stringify(message)}\n${JSON.stringify(toolCallAssistant)}\n${JSON.stringify(toolResult)}\n`; - await fs.writeFile(file, original, "utf-8"); - - const result = await repairSessionFileIfNeeded({ sessionFile: file }); - - expect(result.repaired).toBe(false); - expect(result.insertedToolResults ?? 0).toBe(0); - const after = await fs.readFile(file, "utf-8"); - expect(after).toBe(original); - }); - - it.each(["error", "aborted"] as const)( - "does not insert missing code-mode tool results for %s assistant turns", - async (stopReason) => { - const { file } = await createTempSessionPath(); - const { header, message } = buildSessionHeaderAndMessage(); - const incompleteAssistant = { - type: "message", - id: `msg-asst-${stopReason}`, - parentId: "msg-1", - timestamp: new Date().toISOString(), - message: { - role: "assistant", - provider: "openai", - model: "gpt-5.5", - api: "openai-chatgpt-responses", - content: [ - { type: "toolCall", id: `call_${stopReason}|fc_1`, name: "exec", arguments: {} }, - ], - stopReason, - }, - }; - const original = `${JSON.stringify(header)}\n${JSON.stringify(message)}\n${JSON.stringify(incompleteAssistant)}\n`; - await fs.writeFile(file, original, "utf-8"); - - const result = await repairSessionFileIfNeeded({ sessionFile: file }); - - expect(result.repaired).toBe(false); - expect(result.insertedToolResults ?? 0).toBe(0); - const after = await fs.readFile(file, "utf-8"); - expect(after).toBe(original); - }, - ); - - it("preserves final text assistant turn that follows a tool-call/tool-result pair", async () => { - // Regression: a trailing assistant message with stopReason "stop" that follows a - // tool-call turn and its matching tool-result must never be trimmed by the repair - // pass. This is the exact sequence produced by any agent run that calls at least - // one tool before returning a final text response, and it must survive intact so - // subsequent user messages are parented to the correct leaf node. - const { file } = await createTempSessionPath(); - const { header, message } = buildSessionHeaderAndMessage(); - const toolCallAssistant = { - type: "message", - id: "msg-asst-tc", - parentId: "msg-1", - timestamp: new Date().toISOString(), - message: { - role: "assistant", - content: [{ type: "toolCall", id: "call_1", name: "get_tasks", input: {} }], - stopReason: "toolUse", - }, - }; - const toolResult = { - type: "message", - id: "msg-tool-result", - parentId: "msg-asst-tc", - timestamp: new Date().toISOString(), - message: { - role: "toolResult", - toolCallId: "call_1", - toolName: "get_tasks", - content: [{ type: "text", text: "Task A, Task B" }], - isError: false, - }, - }; - const finalAssistant = { - type: "message", - id: "msg-asst-final", - parentId: "msg-tool-result", - timestamp: new Date().toISOString(), - message: { - role: "assistant", - content: [{ type: "text", text: "Here are your tasks: Task A, Task B." }], - stopReason: "stop", - }, - }; - const original = `${JSON.stringify(header)}\n${JSON.stringify(message)}\n${JSON.stringify(toolCallAssistant)}\n${JSON.stringify(toolResult)}\n${JSON.stringify(finalAssistant)}\n`; - await fs.writeFile(file, original, "utf-8"); - - const result = await repairSessionFileIfNeeded({ sessionFile: file }); - - expect(result.repaired).toBe(false); - - const after = await fs.readFile(file, "utf-8"); - expect(after).toBe(original); - }); - - it("preserves assistant-only session history after the header", async () => { - const { file } = await createTempSessionPath(); - const { header } = buildSessionHeaderAndMessage(); - const assistantEntry = { - type: "message", - id: "msg-asst", - parentId: null, - timestamp: new Date().toISOString(), - message: { - role: "assistant", - content: [{ type: "text", text: "orphan" }], - stopReason: "stop", - }, - }; - const original = `${JSON.stringify(header)}\n${JSON.stringify(assistantEntry)}\n`; - await fs.writeFile(file, original, "utf-8"); - - const result = await repairSessionFileIfNeeded({ sessionFile: file }); - - expect(result.repaired).toBe(false); - - const after = await fs.readFile(file, "utf-8"); - expect(after).toBe(original); - }); - - it("is a no-op on a session that was already repaired", async () => { - const { file } = await createTempSessionPath(); - const { header } = buildSessionHeaderAndMessage(); - const healedEntry = { - type: "message", - id: "msg-2", - parentId: null, - timestamp: new Date().toISOString(), - message: { - role: "assistant", - content: [{ type: "text", text: "[assistant turn failed before producing content]" }], - api: "bedrock-converse-stream", - provider: "amazon-bedrock", - model: "anthropic.claude-3-haiku-20240307-v1:0", - usage: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, totalTokens: 0 }, - stopReason: "error", - }, - }; - // Follow-up keeps this case focused on idempotent empty error-turn repair. - const followUp = { - type: "message", - id: "msg-3", - parentId: null, - timestamp: new Date().toISOString(), - message: { role: "user", content: "follow up" }, - }; - const original = `${JSON.stringify(header)}\n${JSON.stringify(healedEntry)}\n${JSON.stringify(followUp)}\n`; - await fs.writeFile(file, original, "utf-8"); - - const result = await repairSessionFileIfNeeded({ sessionFile: file }); - - expect(result.repaired).toBe(false); - expect(result.rewrittenAssistantMessages ?? 0).toBe(0); - const after = await fs.readFile(file, "utf-8"); - expect(after).toBe(original); - }); - - it("drops type:message entries with null role instead of preserving them through repair (#77228)", async () => { - const { file } = await createTempSessionPath(); - const { header, message } = buildSessionHeaderAndMessage(); - - const nullRoleEntry = { - type: "message", - id: "corrupt-1", - parentId: null, - timestamp: new Date().toISOString(), - message: { role: null, content: "ignored" }, - }; - const missingRoleEntry = { - type: "message", - id: "corrupt-2", - parentId: null, - timestamp: new Date().toISOString(), - message: { content: "no role at all" }, - }; - const emptyRoleEntry = { - type: "message", - id: "corrupt-3", - parentId: null, - timestamp: new Date().toISOString(), - message: { role: " ", content: "blank role" }, - }; - - const content = [ - JSON.stringify(header), - JSON.stringify(message), - JSON.stringify(nullRoleEntry), - JSON.stringify(missingRoleEntry), - JSON.stringify(emptyRoleEntry), - ].join("\n"); - await fs.writeFile(file, `${content}\n`, "utf-8"); - - const result = await repairSessionFileIfNeeded({ sessionFile: file }); - - expect(result.repaired).toBe(true); - expect(result.droppedLines).toBe(3); - await expectNoRetainedBackup(file, result); - - const after = await fs.readFile(file, "utf-8"); - const lines = after.trimEnd().split("\n"); - expect(lines).toHaveLength(2); - expect(JSON.parse(expectDefined(lines[0], "lines[0] test invariant"))).toEqual(header); - expect(JSON.parse(expectDefined(lines[1], "lines[1] test invariant"))).toEqual(message); - expect(after).not.toContain('"role":null'); - }); - - it("drops a type:message entry whose message field is missing or non-object", async () => { - const { file } = await createTempSessionPath(); - const { header, message } = buildSessionHeaderAndMessage(); - - const missingMessage = { - type: "message", - id: "corrupt-4", - parentId: null, - timestamp: new Date().toISOString(), - }; - const stringMessage = { - type: "message", - id: "corrupt-5", - parentId: null, - timestamp: new Date().toISOString(), - message: "not an object", - }; - - const content = [ - JSON.stringify(header), - JSON.stringify(message), - JSON.stringify(missingMessage), - JSON.stringify(stringMessage), - ].join("\n"); - await fs.writeFile(file, `${content}\n`, "utf-8"); - - const result = await repairSessionFileIfNeeded({ sessionFile: file }); - - expect(result.repaired).toBe(true); - expect(result.droppedLines).toBe(2); - - const after = await fs.readFile(file, "utf-8"); - const lines = after.trimEnd().split("\n"); - expect(lines.map((line) => JSON.parse(line))).toEqual([header, message]); - }); - - it("preserves non-`message` envelope types (e.g. compactionSummary, custom) without role inspection", async () => { - const { file } = await createTempSessionPath(); - const { header, message } = buildSessionHeaderAndMessage(); - - const summary = { - type: "summary", - id: "summary-1", - timestamp: new Date().toISOString(), - summary: "opaque summary blob", - }; - const custom = { - type: "custom", - id: "custom-1", - customType: "model-snapshot", - timestamp: new Date().toISOString(), - data: { provider: "openai", modelApi: "openai-responses", modelId: "gpt-5" }, - }; - - const content = [ - JSON.stringify(header), - JSON.stringify(message), - JSON.stringify(summary), - JSON.stringify(custom), - ].join("\n"); - await fs.writeFile(file, `${content}\n`, "utf-8"); - - const result = await repairSessionFileIfNeeded({ sessionFile: file }); - - expect(result.repaired).toBe(false); - expect(result.droppedLines).toBe(0); - const after = await fs.readFile(file, "utf-8"); - expect(after).toBe(`${content}\n`); - }); -}); -/* oxlint-disable max-lines -- TODO: split this grandfathered oversized file. */ diff --git a/src/agents/session-file-repair.ts b/src/agents/session-file-repair.ts deleted file mode 100644 index ed883db97c44..000000000000 --- a/src/agents/session-file-repair.ts +++ /dev/null @@ -1,984 +0,0 @@ -/** - * Persisted session JSONL repair helpers. - * Drops malformed transcript entries, rewrites unreplayable blank/error turns, - * and inserts missing code-mode tool results before replay. - */ -import { randomUUID } from "node:crypto"; -import fs from "node:fs/promises"; -import path from "node:path"; -import { sanitizeInlineImageBase64 } from "@openclaw/media-core/inline-image-data-url"; -import { parseSqliteSessionFileMarker } from "../config/sessions/sqlite-marker.js"; -import { replaceFileAtomic } from "../infra/replace-file.js"; -import type { AgentMessage } from "./runtime/index.js"; -import { makeMissingToolResult } from "./session-transcript-repair.js"; -import { STREAM_ERROR_FALLBACK_TEXT } from "./stream-message-shared.js"; -import { extractToolCallsFromAssistant, extractToolResultId } from "./tool-call-id.js"; - -/** - * Placeholder for blank user messages. - * Preserves the user turn so strict providers that require at least one user - * message do not reject the transcript. - */ -const BLANK_USER_FALLBACK_TEXT = "(continue)"; -const CORRUPTED_IMAGE_FALLBACK_TEXT = "[image omitted: corrupted base64 payload]"; - -type RepairReport = { - repaired: boolean; - droppedLines: number; - validatedSnapshot?: SessionRepairFileSnapshot; - rewrittenAssistantMessages?: number; - droppedBlankUserMessages?: number; - rewrittenUserMessages?: number; - removedCorruptedImageBlocks?: number; - insertedToolResults?: number; - backupPath?: string; - reason?: string; -}; - -type SessionRepairFileSnapshot = { - dev: bigint; - ino: bigint; - size: bigint; - mtimeNs: bigint; - ctimeNs: bigint; -}; - -type SessionRepairCacheEntry = { - snapshot: SessionRepairFileSnapshot; - toolResultIds: Set; - endsWithNewline: boolean; -}; - -const MAX_CACHED_SESSION_REPAIRS = 8; -const MAX_INCREMENTAL_REPAIR_BYTES = 8n * 1024n * 1024n; -const MAX_CACHED_REPAIR_TOOL_RESULT_IDS = 4_096; -const MAX_CACHED_REPAIR_TOOL_RESULT_ID_BYTES = 512 * 1024; -const sessionRepairCache = new Map(); - -export function invalidateSessionFileRepairCache(sessionFile: string): void { - const trimmed = sessionFile.trim(); - if (trimmed) { - if (parseSqliteSessionFileMarker(trimmed)) { - sessionRepairCache.delete(trimmed); - return; - } - sessionRepairCache.delete(path.resolve(trimmed)); - } -} - -type SessionMessageEntry = { - type: "message"; - message: { role: string; content?: unknown } & Record; -} & Record; - -async function readSessionRepairSnapshot( - sessionFile: string, -): Promise { - if (parseSqliteSessionFileMarker(sessionFile)) { - return undefined; - } - try { - const stat = await fs.stat(sessionFile, { bigint: true }); - return { - dev: stat.dev, - ino: stat.ino, - size: stat.size, - mtimeNs: stat.mtimeNs, - ctimeNs: stat.ctimeNs, - }; - } catch { - return undefined; - } -} - -function isSameSessionRepairSnapshot( - left: SessionRepairFileSnapshot, - right: SessionRepairFileSnapshot, -): boolean { - return ( - left.dev === right.dev && - left.ino === right.ino && - left.size === right.size && - left.mtimeNs === right.mtimeNs && - left.ctimeNs === right.ctimeNs - ); -} - -function isSameSessionRepairFile( - left: SessionRepairFileSnapshot, - right: SessionRepairFileSnapshot, -): boolean { - return left.dev === right.dev && left.ino === right.ino; -} - -function rememberSessionRepair(sessionFile: string, state: SessionRepairCacheEntry): void { - if ( - state.toolResultIds.size > MAX_CACHED_REPAIR_TOOL_RESULT_IDS || - countToolResultIdBytes(state.toolResultIds) > MAX_CACHED_REPAIR_TOOL_RESULT_ID_BYTES - ) { - sessionRepairCache.delete(sessionFile); - return; - } - sessionRepairCache.delete(sessionFile); - sessionRepairCache.set(sessionFile, state); - while (sessionRepairCache.size > MAX_CACHED_SESSION_REPAIRS) { - const oldestKey = sessionRepairCache.keys().next().value; - if (!oldestKey) { - break; - } - sessionRepairCache.delete(oldestKey); - } -} - -function countToolResultIdBytes(ids: ReadonlySet): number { - let bytes = 0; - for (const id of ids) { - bytes += Buffer.byteLength(id, "utf8"); - if (bytes > MAX_CACHED_REPAIR_TOOL_RESULT_ID_BYTES) { - break; - } - } - return bytes; -} - -async function readSessionRepairSuffix( - sessionFile: string, - offset: bigint, - length: bigint, -): Promise { - if ( - offset > BigInt(Number.MAX_SAFE_INTEGER) || - length > MAX_INCREMENTAL_REPAIR_BYTES || - length > BigInt(Number.MAX_SAFE_INTEGER) - ) { - return undefined; - } - const buffer = Buffer.alloc(Number(length)); - const file = await fs.open(sessionFile, "r"); - try { - const { bytesRead } = await file.read(buffer, 0, buffer.length, Number(offset)); - return bytesRead === buffer.length ? buffer.toString("utf8") : undefined; - } finally { - await file.close(); - } -} - -function isSessionHeader(entry: unknown): entry is { type: string; id: string } { - if (!entry || typeof entry !== "object") { - return false; - } - const record = entry as { type?: unknown; id?: unknown }; - return record.type === "session" && typeof record.id === "string" && record.id.length > 0; -} - -/** - * Detect a `type: "message"` entry whose `message.role` is missing, `null`, or - * not a non-empty string. Such entries surface in the wild as "null role" - * JSONL corruption (e.g. #77228 reported transcripts that contained 935+ - * entries with null roles after an earlier failure). They cannot be replayed - * to any provider — every provider router branches on `message.role` — and - * preserving them through repair just relocates the corruption from the - * original file into the post-repair file. Treat them as malformed lines: - * drop during repair so the cleaned transcript no longer carries them. - */ -function isStructurallyInvalidMessageEntry(entry: unknown): boolean { - if (!entry || typeof entry !== "object") { - return false; - } - const record = entry as { type?: unknown; message?: unknown }; - if (record.type !== "message") { - return false; - } - if (!record.message || typeof record.message !== "object") { - return true; - } - const role = (record.message as { role?: unknown }).role; - return typeof role !== "string" || role.trim().length === 0; -} - -function isAssistantEntryWithEmptyContent(entry: unknown): entry is SessionMessageEntry { - if (!entry || typeof entry !== "object") { - return false; - } - const record = entry as { type?: unknown; message?: unknown }; - if (record.type !== "message" || !record.message || typeof record.message !== "object") { - return false; - } - const message = record.message as { - role?: unknown; - content?: unknown; - stopReason?: unknown; - }; - if (message.role !== "assistant") { - return false; - } - if (!Array.isArray(message.content) || message.content.length !== 0) { - return false; - } - // Only error stops — clean stops with empty content (NO_REPLY path) are - // valid silent replies that must not be overwritten with synthetic text. - return message.stopReason === "error"; -} - -function rewriteAssistantEntryWithEmptyContent(entry: SessionMessageEntry): SessionMessageEntry { - return { - ...entry, - message: { - ...entry.message, - content: [{ type: "text", text: STREAM_ERROR_FALLBACK_TEXT }], - }, - }; -} - -function isImageMimeType(value: unknown): value is string { - return typeof value === "string" && /^image\//iu.test(value.trim()); -} - -function containsNonAscii(value: string): boolean { - for (let index = 0; index < value.length; index += 1) { - if (value.charCodeAt(index) > 0x7f) { - return true; - } - } - return false; -} - -function isCorruptedImageContentBlock(block: unknown): boolean { - if (!block || typeof block !== "object" || Array.isArray(block)) { - return false; - } - const record = block as { - type?: unknown; - data?: unknown; - mimeType?: unknown; - mediaType?: unknown; - media_type?: unknown; - }; - if (record.type !== "image" || typeof record.data !== "string") { - return false; - } - const mimeType = [record.mimeType, record.mediaType, record.media_type].find(isImageMimeType); - if (!mimeType) { - return false; - } - return ( - containsNonAscii(record.data) || - sanitizeInlineImageBase64({ base64: record.data, mimeType }) === undefined - ); -} - -function repairEntryWithCorruptedImageBlocks(entry: SessionMessageEntry): { - entry: SessionMessageEntry; - removedCorruptedImageBlocks: number; -} { - const content = entry.message.content; - if (!Array.isArray(content)) { - return { entry, removedCorruptedImageBlocks: 0 }; - } - - let removedCorruptedImageBlocks = 0; - const nextContent = content.map((block) => { - if (!isCorruptedImageContentBlock(block)) { - return block; - } - removedCorruptedImageBlocks += 1; - return { type: "text", text: CORRUPTED_IMAGE_FALLBACK_TEXT }; - }); - if (removedCorruptedImageBlocks === 0) { - return { entry, removedCorruptedImageBlocks: 0 }; - } - return { - entry: { - ...entry, - message: { - ...entry.message, - content: nextContent, - }, - }, - removedCorruptedImageBlocks, - }; -} - -type UserEntryRepair = - | { kind: "drop" } - | { kind: "rewrite"; entry: SessionMessageEntry } - | { kind: "keep" }; - -function repairUserEntryWithBlankTextContent(entry: SessionMessageEntry): UserEntryRepair { - const content = entry.message.content; - if (typeof content === "string") { - if (content.trim()) { - return { kind: "keep" }; - } - return { - kind: "rewrite", - entry: { - ...entry, - message: { - ...entry.message, - content: BLANK_USER_FALLBACK_TEXT, - }, - }, - }; - } - if (!Array.isArray(content)) { - return { kind: "keep" }; - } - - let touched = false; - const nextContent = content.filter((block) => { - if (!block || typeof block !== "object") { - return true; - } - if ((block as { type?: unknown }).type !== "text") { - return true; - } - const text = (block as { text?: unknown }).text; - if (typeof text !== "string" || text.trim().length > 0) { - return true; - } - touched = true; - return false; - }); - if (nextContent.length === 0) { - return { - kind: "rewrite", - entry: { - ...entry, - message: { - ...entry.message, - content: [{ type: "text", text: BLANK_USER_FALLBACK_TEXT }], - }, - }, - }; - } - if (!touched) { - return { kind: "keep" }; - } - return { - kind: "rewrite", - entry: { - ...entry, - message: { - ...entry.message, - content: nextContent, - }, - }, - }; -} - -function buildRepairSummaryParts(params: { - droppedLines: number; - rewrittenAssistantMessages: number; - droppedBlankUserMessages: number; - rewrittenUserMessages: number; - removedCorruptedImageBlocks: number; - insertedToolResults: number; -}): string { - const parts: string[] = []; - if (params.droppedLines > 0) { - parts.push(`dropped ${params.droppedLines} malformed line(s)`); - } - if (params.rewrittenAssistantMessages > 0) { - parts.push(`rewrote ${params.rewrittenAssistantMessages} assistant message(s)`); - } - if (params.droppedBlankUserMessages > 0) { - parts.push(`dropped ${params.droppedBlankUserMessages} blank user message(s)`); - } - if (params.rewrittenUserMessages > 0) { - parts.push(`rewrote ${params.rewrittenUserMessages} user message(s)`); - } - if (params.removedCorruptedImageBlocks > 0) { - parts.push(`removed ${params.removedCorruptedImageBlocks} corrupted image block(s)`); - } - if (params.insertedToolResults > 0) { - parts.push(`inserted ${params.insertedToolResults} missing tool result(s)`); - } - return parts.length > 0 ? parts.join(", ") : "no changes"; -} - -function isCodeModeToolCallRepairCandidate(entry: unknown): entry is SessionMessageEntry { - if (!entry || typeof entry !== "object") { - return false; - } - const record = entry as { type?: unknown; message?: unknown }; - if (record.type !== "message" || !record.message || typeof record.message !== "object") { - return false; - } - const message = record.message as { - role?: unknown; - api?: unknown; - provider?: unknown; - stopReason?: unknown; - }; - return ( - message.role === "assistant" && - message.api === "openai-chatgpt-responses" && - message.provider === "openai" && - message.stopReason !== "error" && - message.stopReason !== "aborted" - ); -} - -function normalizeTrimmedString(value: unknown): string { - return typeof value === "string" ? value.trim() : ""; -} - -function isOpenAIResponsesReplayApi(value: unknown): boolean { - const api = normalizeTrimmedString(value); - return ( - api === "openai-responses" || - api === "azure-openai-responses" || - api === "openai-codex-responses" - ); -} - -function isTranscriptOnlyDeliveryMirrorEntry(entry: unknown): boolean { - if (!entry || typeof entry !== "object") { - return false; - } - const record = entry as { type?: unknown; message?: unknown }; - if (record.type !== "message" || !record.message || typeof record.message !== "object") { - return false; - } - const message = record.message as { role?: unknown; provider?: unknown; model?: unknown }; - return ( - message.role === "assistant" && - normalizeTrimmedString(message.provider) === "openclaw" && - (normalizeTrimmedString(message.model) === "delivery-mirror" || - normalizeTrimmedString(message.model) === "gateway-injected") - ); -} - -function isResponsesMessageToolRepairCandidate(entry: unknown): entry is SessionMessageEntry { - if (!entry || typeof entry !== "object") { - return false; - } - const record = entry as { type?: unknown; message?: unknown }; - if (record.type !== "message" || !record.message || typeof record.message !== "object") { - return false; - } - const message = record.message as { - role?: unknown; - api?: unknown; - stopReason?: unknown; - }; - return ( - message.role === "assistant" && - isOpenAIResponsesReplayApi(message.api) && - message.stopReason !== "error" && - message.stopReason !== "aborted" - ); -} - -function isMessageToolCallName(value: unknown): boolean { - return normalizeTrimmedString(value).toLowerCase() === "message"; -} - -function findNextSessionMessageEntry( - entries: unknown[], - startIndex: number, -): SessionMessageEntry | undefined { - for (let i = startIndex + 1; i < entries.length; i += 1) { - const entry = entries[i]; - if (!entry || typeof entry !== "object") { - continue; - } - const record = entry as { type?: unknown; message?: unknown }; - if (record.type === "message" && record.message && typeof record.message === "object") { - return entry as SessionMessageEntry; - } - } - return undefined; -} - -function collectPersistedToolResultIds(entries: unknown[]): Set { - const ids = new Set(); - for (const entry of entries) { - if (!entry || typeof entry !== "object") { - continue; - } - const record = entry as { type?: unknown; message?: unknown }; - if (record.type !== "message" || !record.message || typeof record.message !== "object") { - continue; - } - const message = record.message as AgentMessage; - if (message.role !== "toolResult") { - continue; - } - const id = extractToolResultId(message); - if (id) { - ids.add(id); - } - } - return ids; -} - -function makeSyntheticToolResultEntry(params: { - parent: SessionMessageEntry; - toolCallId: string; - toolName?: string; -}): SessionMessageEntry { - const message = makeMissingToolResult({ - toolCallId: params.toolCallId, - toolName: params.toolName, - text: "aborted", - }); - return { - type: "message", - id: `repair-${randomUUID()}`, - parentId: typeof params.parent.id === "string" ? params.parent.id : undefined, - timestamp: new Date().toISOString(), - message: message as unknown as SessionMessageEntry["message"], - }; -} - -function insertMissingCodeModeToolResults( - entries: unknown[], - existingResultIds: ReadonlySet = new Set(), -): { - entries: unknown[]; - insertedToolResults: number; - resultIds: Set; -} { - const resultIds = new Set(existingResultIds); - for (const resultId of collectPersistedToolResultIds(entries)) { - resultIds.add(resultId); - } - let insertedToolResults = 0; - const out: unknown[] = []; - - for (const entry of entries) { - out.push(entry); - if (!isCodeModeToolCallRepairCandidate(entry)) { - continue; - } - const toolCalls = extractToolCallsFromAssistant( - entry.message as unknown as Extract, - ); - for (const toolCall of toolCalls) { - if (resultIds.has(toolCall.id)) { - continue; - } - out.push( - makeSyntheticToolResultEntry({ - parent: entry, - toolCallId: toolCall.id, - toolName: toolCall.name, - }), - ); - resultIds.add(toolCall.id); - insertedToolResults += 1; - } - } - - return { - entries: insertedToolResults > 0 ? out : entries, - insertedToolResults, - resultIds, - }; -} - -function insertMissingMessageToolDeliveryMirrorResults( - entries: unknown[], - existingResultIds: ReadonlySet = new Set(), -): { - entries: unknown[]; - insertedToolResults: number; - resultIds: Set; -} { - const resultIds = new Set(existingResultIds); - for (const resultId of collectPersistedToolResultIds(entries)) { - resultIds.add(resultId); - } - let insertedToolResults = 0; - const out: unknown[] = []; - - for (let i = 0; i < entries.length; i += 1) { - const entry = entries[i]; - out.push(entry); - if (!isResponsesMessageToolRepairCandidate(entry)) { - continue; - } - if (!isTranscriptOnlyDeliveryMirrorEntry(findNextSessionMessageEntry(entries, i))) { - continue; - } - const toolCalls = extractToolCallsFromAssistant( - entry.message as unknown as Extract, - ); - for (const toolCall of toolCalls) { - if (!isMessageToolCallName(toolCall.name) || resultIds.has(toolCall.id)) { - continue; - } - out.push( - makeSyntheticToolResultEntry({ - parent: entry, - toolCallId: toolCall.id, - toolName: toolCall.name, - }), - ); - resultIds.add(toolCall.id); - insertedToolResults += 1; - } - } - - return { - entries: insertedToolResults > 0 ? out : entries, - insertedToolResults, - resultIds, - }; -} - -type RepairEntriesResult = { - entries: unknown[]; - droppedLines: number; - rewrittenAssistantMessages: number; - droppedBlankUserMessages: number; - rewrittenUserMessages: number; - removedCorruptedImageBlocks: number; -}; - -function repairSessionLines(lines: string[]): RepairEntriesResult { - const entries: unknown[] = []; - let droppedLines = 0; - let rewrittenAssistantMessages = 0; - let droppedBlankUserMessages = 0; - let rewrittenUserMessages = 0; - let removedCorruptedImageBlocks = 0; - - for (const line of lines) { - if (!line.trim()) { - continue; - } - try { - const entry: unknown = JSON.parse(line); - if (isStructurallyInvalidMessageEntry(entry)) { - droppedLines += 1; - continue; - } - if (isAssistantEntryWithEmptyContent(entry)) { - entries.push(rewriteAssistantEntryWithEmptyContent(entry)); - rewrittenAssistantMessages += 1; - continue; - } - let entryForUserRepair = entry; - if ( - entry && - typeof entry === "object" && - (entry as { type?: unknown }).type === "message" && - typeof (entry as { message?: unknown }).message === "object" - ) { - const imageRepair = repairEntryWithCorruptedImageBlocks(entry as SessionMessageEntry); - entryForUserRepair = imageRepair.entry; - removedCorruptedImageBlocks += imageRepair.removedCorruptedImageBlocks; - } - if ( - entryForUserRepair && - typeof entryForUserRepair === "object" && - (entryForUserRepair as { type?: unknown }).type === "message" && - typeof (entryForUserRepair as { message?: unknown }).message === "object" && - ((entryForUserRepair as { message: { role?: unknown } }).message?.role ?? undefined) === - "user" - ) { - const repairedUser = repairUserEntryWithBlankTextContent( - entryForUserRepair as SessionMessageEntry, - ); - if (repairedUser.kind === "drop") { - droppedBlankUserMessages += 1; - continue; - } - if (repairedUser.kind === "rewrite") { - entries.push(repairedUser.entry); - rewrittenUserMessages += 1; - continue; - } - } - entries.push(entryForUserRepair); - } catch { - droppedLines += 1; - } - } - - return { - entries, - droppedLines, - rewrittenAssistantMessages, - droppedBlankUserMessages, - rewrittenUserMessages, - removedCorruptedImageBlocks, - }; -} - -function hasEntryRepairs(result: RepairEntriesResult): boolean { - return ( - result.droppedLines > 0 || - result.rewrittenAssistantMessages > 0 || - result.droppedBlankUserMessages > 0 || - result.rewrittenUserMessages > 0 || - result.removedCorruptedImageBlocks > 0 - ); -} - -async function tryIncrementalSessionRepair(params: { - sessionFile: string; - currentSnapshot: SessionRepairFileSnapshot; - cached: SessionRepairCacheEntry; - trustedSnapshot: SessionRepairFileSnapshot | undefined; -}): Promise { - if (isSameSessionRepairSnapshot(params.cached.snapshot, params.currentSnapshot)) { - return { - repaired: false, - droppedLines: 0, - validatedSnapshot: params.currentSnapshot, - }; - } - if ( - !params.trustedSnapshot || - !isSameSessionRepairSnapshot(params.trustedSnapshot, params.currentSnapshot) || - !params.cached.endsWithNewline || - !isSameSessionRepairFile(params.cached.snapshot, params.currentSnapshot) || - params.currentSnapshot.size <= params.cached.snapshot.size - ) { - return undefined; - } - - const appendedText = await readSessionRepairSuffix( - params.sessionFile, - params.cached.snapshot.size, - params.currentSnapshot.size - params.cached.snapshot.size, - ); - if (!appendedText?.endsWith("\n")) { - return undefined; - } - const afterReadSnapshot = await readSessionRepairSnapshot(params.sessionFile); - if ( - !afterReadSnapshot || - !isSameSessionRepairSnapshot(params.currentSnapshot, afterReadSnapshot) - ) { - return undefined; - } - - const repairedEntries = repairSessionLines(appendedText.split(/\r?\n/)); - if (hasEntryRepairs(repairedEntries)) { - return undefined; - } - const codeModeToolResultRepair = insertMissingCodeModeToolResults( - repairedEntries.entries, - params.cached.toolResultIds, - ); - if (codeModeToolResultRepair.insertedToolResults > 0) { - return undefined; - } - const messageDeliveryToolResultRepair = insertMissingMessageToolDeliveryMirrorResults( - codeModeToolResultRepair.entries, - codeModeToolResultRepair.resultIds, - ); - if (messageDeliveryToolResultRepair.insertedToolResults > 0) { - return undefined; - } - - rememberSessionRepair(params.sessionFile, { - snapshot: afterReadSnapshot, - toolResultIds: messageDeliveryToolResultRepair.resultIds, - endsWithNewline: true, - }); - return { - repaired: false, - droppedLines: 0, - validatedSnapshot: afterReadSnapshot, - }; -} - -/** Repair a persisted session JSONL file in place when replay-breaking corruption is found. */ -export async function repairSessionFileIfNeeded(params: { - sessionFile: string; - trustedSnapshot?: SessionRepairFileSnapshot; - debug?: (message: string) => void; - warn?: (message: string) => void; -}): Promise { - const sessionFileInput = params.sessionFile.trim(); - if (!sessionFileInput) { - return { repaired: false, droppedLines: 0, reason: "missing session file" }; - } - if (parseSqliteSessionFileMarker(sessionFileInput)) { - return { repaired: false, droppedLines: 0, reason: "sqlite transcript" }; - } - const sessionFile = path.resolve(sessionFileInput); - const beforeReadSnapshot = await readSessionRepairSnapshot(sessionFile); - if (beforeReadSnapshot) { - const cached = sessionRepairCache.get(sessionFile); - if (cached) { - const incremental = await tryIncrementalSessionRepair({ - sessionFile, - currentSnapshot: beforeReadSnapshot, - cached, - trustedSnapshot: params.trustedSnapshot, - }); - if (incremental) { - return incremental; - } - } - } else { - sessionRepairCache.delete(sessionFile); - } - - let content: string; - try { - content = await fs.readFile(sessionFile, "utf-8"); - } catch (err) { - sessionRepairCache.delete(sessionFile); - const code = (err as { code?: unknown } | undefined)?.code; - if (code === "ENOENT") { - return { repaired: false, droppedLines: 0, reason: "missing session file" }; - } - const reason = `failed to read session file: ${err instanceof Error ? err.message : "unknown error"}`; - params.warn?.(`session file repair skipped: ${reason} (${path.basename(sessionFile)})`); - return { repaired: false, droppedLines: 0, reason }; - } - - const repairedEntries = repairSessionLines(content.split(/\r?\n/)); - const { - entries, - droppedLines, - rewrittenAssistantMessages, - droppedBlankUserMessages, - rewrittenUserMessages, - removedCorruptedImageBlocks, - } = repairedEntries; - - if (entries.length === 0) { - sessionRepairCache.delete(sessionFile); - return { repaired: false, droppedLines, reason: "empty session file" }; - } - if (!isSessionHeader(entries[0])) { - sessionRepairCache.delete(sessionFile); - params.warn?.( - `session file repair skipped: invalid session header (${path.basename(sessionFile)})`, - ); - return { repaired: false, droppedLines, reason: "invalid session header" }; - } - - const codeModeToolResultRepair = insertMissingCodeModeToolResults(entries); - let insertedToolResults = codeModeToolResultRepair.insertedToolResults; - if (codeModeToolResultRepair.insertedToolResults > 0) { - entries.splice(0, entries.length, ...codeModeToolResultRepair.entries); - } - - const messageDeliveryToolResultRepair = insertMissingMessageToolDeliveryMirrorResults( - entries, - codeModeToolResultRepair.resultIds, - ); - insertedToolResults += messageDeliveryToolResultRepair.insertedToolResults; - if (messageDeliveryToolResultRepair.insertedToolResults > 0) { - entries.splice(0, entries.length, ...messageDeliveryToolResultRepair.entries); - } - const repairedToolResultIds = messageDeliveryToolResultRepair.resultIds; - - if (!hasEntryRepairs(repairedEntries) && insertedToolResults === 0) { - const afterReadSnapshot = await readSessionRepairSnapshot(sessionFile); - const validatedSnapshot = - beforeReadSnapshot && - afterReadSnapshot && - isSameSessionRepairSnapshot(beforeReadSnapshot, afterReadSnapshot) - ? afterReadSnapshot - : undefined; - if (validatedSnapshot) { - rememberSessionRepair(sessionFile, { - snapshot: validatedSnapshot, - toolResultIds: repairedToolResultIds, - endsWithNewline: content.endsWith("\n"), - }); - } else { - sessionRepairCache.delete(sessionFile); - } - return { - repaired: false, - droppedLines: 0, - ...(validatedSnapshot ? { validatedSnapshot } : {}), - }; - } - - const cleaned = `${entries.map((entry) => JSON.stringify(entry)).join("\n")}\n`; - const backupPath = `${sessionFile}.bak-${process.pid}-${Date.now()}`; - let retainedBackupPath: string | undefined; - try { - const stat = await fs.stat(sessionFile).catch(() => null); - await fs.writeFile(backupPath, content, "utf-8"); - if (stat) { - await fs.chmod(backupPath, stat.mode); - } - await replaceFileAtomic({ - filePath: sessionFile, - content: cleaned, - preserveExistingMode: true, - tempPrefix: `${path.basename(sessionFile)}.repair`, - }); - await fs.unlink(backupPath).catch((cleanupErr: unknown) => { - retainedBackupPath = backupPath; - params.debug?.( - `session file repair backup cleanup failed: ${cleanupErr instanceof Error ? cleanupErr.message : "unknown error"} (${path.basename( - backupPath, - )})`, - ); - }); - } catch (err) { - sessionRepairCache.delete(sessionFile); - return { - repaired: false, - droppedLines, - rewrittenAssistantMessages, - droppedBlankUserMessages, - rewrittenUserMessages, - removedCorruptedImageBlocks, - reason: `repair failed: ${err instanceof Error ? err.message : "unknown error"}`, - }; - } - - let repairedSnapshot: SessionRepairFileSnapshot | undefined; - try { - const beforeVerifySnapshot = await readSessionRepairSnapshot(sessionFile); - const persistedContent = await fs.readFile(sessionFile, "utf8"); - const afterVerifySnapshot = await readSessionRepairSnapshot(sessionFile); - if ( - beforeVerifySnapshot && - afterVerifySnapshot && - persistedContent === cleaned && - isSameSessionRepairSnapshot(beforeVerifySnapshot, afterVerifySnapshot) - ) { - repairedSnapshot = afterVerifySnapshot; - } - } catch { - repairedSnapshot = undefined; - } - if (repairedSnapshot) { - rememberSessionRepair(sessionFile, { - snapshot: repairedSnapshot, - toolResultIds: repairedToolResultIds, - endsWithNewline: true, - }); - } else { - sessionRepairCache.delete(sessionFile); - } - params.debug?.( - `session file repaired: ${buildRepairSummaryParts({ - droppedLines, - rewrittenAssistantMessages, - droppedBlankUserMessages, - rewrittenUserMessages, - removedCorruptedImageBlocks, - insertedToolResults, - })} (${path.basename(sessionFile)})`, - ); - return { - repaired: true, - droppedLines, - ...(repairedSnapshot ? { validatedSnapshot: repairedSnapshot } : {}), - rewrittenAssistantMessages, - droppedBlankUserMessages, - rewrittenUserMessages, - removedCorruptedImageBlocks, - insertedToolResults, - ...(retainedBackupPath ? { backupPath: retainedBackupPath } : {}), - }; -} -/* oxlint-disable max-lines -- TODO: split this grandfathered oversized file. */ diff --git a/src/agents/session-tool-result-guard.test.ts b/src/agents/session-tool-result-guard.test.ts index 493f0a26fa3e..9b9553a3fca9 100644 --- a/src/agents/session-tool-result-guard.test.ts +++ b/src/agents/session-tool-result-guard.test.ts @@ -313,6 +313,29 @@ describe("installSessionToolResultGuard", () => { expectPersistedRoles(sm, ["assistant", "toolResult"]); }); + it("preserves opaque canonical tool-call ids while repairing result metadata", () => { + const sm = SessionManager.inMemory(); + installSessionToolResultGuard(sm); + + sm.appendMessage( + asAppendMessage({ + role: "assistant", + content: [{ type: "toolCall", id: " opaque-call ", name: "read", arguments: {} }], + }), + ); + sm.appendMessage( + asAppendMessage({ + role: "toolResult", + toolCallId: " opaque-call ", + content: [{ type: "text", text: "ok" }], + isError: false, + }), + ); + + const messages = expectPersistedRoles(sm, ["assistant", "toolResult"]); + expect((messages[1] as { toolCallId?: string }).toolCallId).toBe(" opaque-call "); + }); + it("drops malformed tool calls missing input before persistence", () => { const sm = SessionManager.inMemory(); installSessionToolResultGuard(sm); diff --git a/src/agents/session-tool-result-guard.ts b/src/agents/session-tool-result-guard.ts index 53674090a658..9568dfab1ed4 100644 --- a/src/agents/session-tool-result-guard.ts +++ b/src/agents/session-tool-result-guard.ts @@ -556,6 +556,7 @@ function capToolResultForPersistence( function normalizePersistedToolResultName( message: AgentMessage, fallbackName?: string, + fallbackId?: string, ): AgentMessage { if ((message as { role?: unknown }).role !== "toolResult") { return message; @@ -563,22 +564,28 @@ function normalizePersistedToolResultName( const toolResult = message as Extract; const rawToolName = (toolResult as { toolName?: unknown }).toolName; const normalizedToolName = normalizeOptionalString(rawToolName); - if (normalizedToolName) { - if (rawToolName === normalizedToolName) { - return toolResult; - } - return { ...toolResult, toolName: normalizedToolName }; - } - const normalizedFallback = normalizeOptionalString(fallbackName); - if (normalizedFallback) { - return { ...toolResult, toolName: normalizedFallback }; + const toolName = normalizedToolName ?? normalizedFallback ?? "unknown"; + const rawToolCallIdValue = (toolResult as { toolCallId?: unknown }).toolCallId; + const rawToolCallId = typeof rawToolCallIdValue === "string" ? rawToolCallIdValue : undefined; + const toolCallId = rawToolCallId ?? normalizeOptionalString(fallbackId); + const isError = + typeof (toolResult as { isError?: unknown }).isError === "boolean" + ? (toolResult as { isError: boolean }).isError + : false; + if ( + rawToolName === toolName && + rawToolCallId === toolCallId && + (toolResult as { isError?: unknown }).isError === isError + ) { + return toolResult; } - - if (typeof rawToolName === "string") { - return { ...toolResult, toolName: "unknown" }; - } - return toolResult; + return { + ...toolResult, + ...(toolCallId ? { toolCallId } : {}), + toolName, + isError, + }; } function isTranscriptOnlyOpenClawAssistantMessage(message: AgentMessage): boolean { @@ -792,7 +799,11 @@ export function installSessionToolResultGuard( if (nextRole === "toolResult") { const id = extractToolResultId(nextMessage as Extract); const toolName = id ? pendingState.getToolName(id) : undefined; - const normalizedToolResult = normalizePersistedToolResultName(nextMessage, toolName); + const normalizedToolResult = normalizePersistedToolResultName( + nextMessage, + toolName, + id ?? undefined, + ); // Apply hard size cap before persistence to prevent oversized tool results // from consuming the entire context window on subsequent LLM calls. const persistedToolResult = persistMessage(normalizedToolResult); diff --git a/src/agents/session-write-lock.test.ts b/src/agents/session-write-lock.test.ts index c756fe83e71a..23f6f37dc4dc 100644 --- a/src/agents/session-write-lock.test.ts +++ b/src/agents/session-write-lock.test.ts @@ -7,6 +7,9 @@ import path from "node:path"; import { fileURLToPath } from "node:url"; import { MAX_TIMER_TIMEOUT_MS } from "@openclaw/normalization-core/number-coercion"; import { afterEach, beforeAll, describe, expect, it, vi } from "vitest"; +import { closeOpenClawAgentDatabasesForTest } from "../state/openclaw-agent-db.js"; +import { closeOpenClawStateDatabaseForTest } from "../state/openclaw-state-db.js"; +import { captureEnv, setTestEnvValue } from "../test-utils/env.js"; import { SessionWriteLockStaleError } from "./session-write-lock-error.js"; const FAKE_STARTTIME = 12345; @@ -17,6 +20,7 @@ let resetSessionWriteLockStateForTest: typeof import("./session-write-lock.test- let resolveSessionLockMaxHoldFromTimeout: typeof import("./session-write-lock.js").resolveSessionLockMaxHoldFromTimeout; let resolveSessionWriteLockAcquireTimeoutMs: typeof import("./session-write-lock.js").resolveSessionWriteLockAcquireTimeoutMs; let resolveSessionWriteLockOptions: typeof import("./session-write-lock.js").resolveSessionWriteLockOptions; +let resolveSessionWriteLockTargetKey: typeof import("./session-write-lock.js").resolveSessionWriteLockTargetKey; async function expectLockRemovedOnlyAfterFinalRelease(params: { lockPath: string; @@ -168,6 +172,7 @@ describe("acquireSessionWriteLock", () => { resolveSessionLockMaxHoldFromTimeout, resolveSessionWriteLockAcquireTimeoutMs, resolveSessionWriteLockOptions, + resolveSessionWriteLockTargetKey, } = await import("./session-write-lock.js")); ({ testing, resetSessionWriteLockStateForTest } = await import("./session-write-lock.test-support.js")); @@ -234,6 +239,286 @@ describe("acquireSessionWriteLock", () => { }); }); + it("locks relative transcript artifact paths", async () => { + await withTempSessionLockFile(async ({ sessionFile, lockPath }) => { + const relativeSessionFile = path.relative(process.cwd(), sessionFile); + const lock = await acquireSessionWriteLock({ + sessionFile: relativeSessionFile, + timeoutMs: 500, + }); + + await expect(fs.access(lockPath)).resolves.toBeUndefined(); + await lock.release(); + await expectPathMissing(lockPath); + }); + }); + + it("serializes explicit session-key targets through SQLite without lock files", async () => { + const sessionKey = `agent:main:write-lock-${Date.now()}`; + const lock = await acquireSessionWriteLock({ + sessionFile: sessionKey, + targetKind: "session-key", + }); + await expect( + acquireSessionWriteLock({ + sessionFile: sessionKey, + targetKind: "session-key", + timeoutMs: 5, + }), + ).rejects.toThrow(/session file locked/); + + await lock.release(); + const nextLock = await acquireSessionWriteLock({ + sessionFile: sessionKey, + targetKind: "session-key", + timeoutMs: 500, + }); + await nextLock.release(); + await expectPathMissing(path.resolve(`${sessionKey}.lock`)); + }); + + it("namespaces unqualified session-key leases by transcript target", async () => { + const first = await acquireSessionWriteLock({ + sessionFile: resolveSessionWriteLockTargetKey({ + agentId: "main", + sessionId: "main-session", + sessionKey: "global", + storePath: "/tmp/main/sessions.json", + }), + targetKind: "session-key", + }); + const second = await acquireSessionWriteLock({ + sessionFile: resolveSessionWriteLockTargetKey({ + agentId: "worker", + sessionId: "worker-session", + sessionKey: "global", + storePath: "/tmp/worker/sessions.json", + }), + targetKind: "session-key", + timeoutMs: 5, + }); + + await Promise.all([first.release(), second.release()]); + }); + + it("keeps a transcript-target lease shared across different state roots", async () => { + const root = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-lock-target-store-")); + const envSnapshot = captureEnv(["OPENCLAW_STATE_DIR"]); + let first: Awaited> | undefined; + try { + const sessionFile = resolveSessionWriteLockTargetKey({ + agentId: "main", + sessionId: "shared-session", + sessionKey: "agent:main:shared", + storePath: path.join(root, "shared.sqlite"), + }); + setTestEnvValue("OPENCLAW_STATE_DIR", path.join(root, "state-a")); + first = await acquireSessionWriteLock({ sessionFile, targetKind: "session-key" }); + + setTestEnvValue("OPENCLAW_STATE_DIR", path.join(root, "state-b")); + // Reentrancy is disabled, so the process-local held map cannot reject this; + // only the lease row in the shared transcript database can fence it. + await expect( + acquireSessionWriteLock({ sessionFile, targetKind: "session-key", timeoutMs: 5 }), + ).rejects.toThrow(/session file locked/); + } finally { + await first?.release(); + closeOpenClawAgentDatabasesForTest(); + closeOpenClawStateDatabaseForTest(); + envSnapshot.restore(); + await fs.rm(root, { recursive: true, force: true }); + } + }); + + it("uses one lease key for aliases of the same session incarnation", () => { + const base = { + agentId: "main", + sessionId: "shared-session", + storePath: "/tmp/main/sessions.json", + }; + + expect(resolveSessionWriteLockTargetKey({ ...base, sessionKey: "agent:main:alias-a" })).toBe( + resolveSessionWriteLockTargetKey({ ...base, sessionKey: "agent:main:alias-b" }), + ); + }); + + it("uses one lease key for legacy and SQLite locators of the same store", () => { + const base = { + agentId: "main", + sessionId: "shared-session", + sessionKey: "agent:main:main", + }; + + expect( + resolveSessionWriteLockTargetKey({ ...base, storePath: "/tmp/main/sessions.json" }), + ).toBe( + resolveSessionWriteLockTargetKey({ + ...base, + storePath: "/tmp/main/openclaw-agent.sqlite", + }), + ); + }); + + it.runIf(process.platform !== "win32")( + "keeps a lease key stable when a directory appears beneath a symlink", + async () => { + const root = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-lock-key-symlink-")); + try { + const realRoot = path.join(root, "real"); + const linkedRoot = path.join(root, "linked"); + await fs.mkdir(realRoot); + await fs.symlink(realRoot, linkedRoot); + const target = { + agentId: "main", + sessionId: "shared-session", + sessionKey: "agent:main:main", + storePath: path.join(linkedRoot, "new-agent", "sessions.json"), + }; + + const beforeCreation = resolveSessionWriteLockTargetKey(target); + await fs.mkdir(path.join(linkedRoot, "new-agent")); + expect(resolveSessionWriteLockTargetKey(target)).toBe(beforeCreation); + } finally { + await fs.rm(root, { recursive: true, force: true }); + } + }, + ); + + it("reference-counts reentrant session-key leases", async () => { + const sessionKey = `agent:main:write-lock-reentrant-${Date.now()}`; + const first = await acquireSessionWriteLock({ + sessionFile: sessionKey, + targetKind: "session-key", + allowReentrant: true, + }); + const second = await acquireSessionWriteLock({ + sessionFile: sessionKey, + targetKind: "session-key", + allowReentrant: true, + }); + + await first.release(); + expect(() => second.assertOwned?.()).not.toThrow(); + await expect( + acquireSessionWriteLock({ + sessionFile: sessionKey, + targetKind: "session-key", + timeoutMs: 5, + }), + ).rejects.toThrow(/session file locked/); + await second.release(); + }); + + it("uses the abort signal only while acquiring a session-key lease", async () => { + const abort = new AbortController(); + const lock = await acquireSessionWriteLock({ + sessionFile: `agent:main:write-lock-abort-${Date.now()}`, + targetKind: "session-key", + signal: abort.signal, + }); + + abort.abort(new Error("late abort")); + await expect(lock.release()).resolves.toBeUndefined(); + }); + + it("does not let a releasing owner erase its successor", async () => { + const sessionKey = `agent:main:write-lock-successor-${Date.now()}`; + const first = await acquireSessionWriteLock({ + sessionFile: sessionKey, + targetKind: "session-key", + }); + + const releasing = first.release(); + const second = await acquireSessionWriteLock({ + sessionFile: sessionKey, + targetKind: "session-key", + timeoutMs: 500, + }); + await releasing; + + expect(() => second.assertOwned?.()).not.toThrow(); + await second.release(); + }); + + it("coalesces concurrent releases for one session-key handle", async () => { + const sessionKey = `agent:main:write-lock-release-${Date.now()}`; + const lock = await acquireSessionWriteLock({ + sessionFile: sessionKey, + targetKind: "session-key", + }); + + await Promise.all([lock.release(), lock.release()]); + const next = await acquireSessionWriteLock({ + sessionFile: sessionKey, + targetKind: "session-key", + timeoutMs: 500, + }); + await next.release(); + }); + + it("does not time-takeover a live session-key owner", async () => { + pinCurrentProcessStartTimeForTest(); + const sessionKey = `agent:main:write-lock-expiry-${Date.now()}`; + const first = await acquireSessionWriteLock({ + sessionFile: sessionKey, + targetKind: "session-key", + maxHoldMs: 5, + }); + await new Promise((resolve) => { + setTimeout(resolve, 10); + }); + await expect( + acquireSessionWriteLock({ + sessionFile: sessionKey, + targetKind: "session-key", + maxHoldMs: 1_000, + timeoutMs: 5, + }), + ).rejects.toThrow(/session file locked/); + expect(() => first.assertOwned?.()).not.toThrow(); + await first.release(); + const second = await acquireSessionWriteLock({ + sessionFile: sessionKey, + targetKind: "session-key", + maxHoldMs: 1_000, + timeoutMs: 500, + }); + expect(() => second.assertOwned?.()).not.toThrow(); + await second.release(); + }); + + it("does not time-takeover a live owner when process start time is unavailable", async () => { + testing.setProcessStartTimeResolverForTest(() => null); + const sessionKey = `agent:main:write-lock-unverifiable-${Date.now()}`; + const first = await acquireSessionWriteLock({ + sessionFile: sessionKey, + targetKind: "session-key", + maxHoldMs: 5, + }); + await new Promise((resolve) => { + setTimeout(resolve, 10); + }); + + await expect( + acquireSessionWriteLock({ + sessionFile: sessionKey, + targetKind: "session-key", + maxHoldMs: 1_000, + timeoutMs: 5, + }), + ).rejects.toThrow(/session file locked/); + expect(() => first.assertOwned?.()).not.toThrow(); + await first.release(); + const second = await acquireSessionWriteLock({ + sessionFile: sessionKey, + targetKind: "session-key", + maxHoldMs: 1_000, + timeoutMs: 500, + }); + expect(() => second.assertOwned?.()).not.toThrow(); + await second.release(); + }); + it("does not reenter locks by default in the same process", async () => { await withTempSessionLockFile(async ({ sessionFile }) => { const lock = await acquireSessionWriteLock({ sessionFile, timeoutMs: 500 }); diff --git a/src/agents/session-write-lock.ts b/src/agents/session-write-lock.ts index 1f01e749f491..dbc2e98ad0c3 100644 --- a/src/agents/session-write-lock.ts +++ b/src/agents/session-write-lock.ts @@ -4,16 +4,34 @@ * Uses lock files with owner metadata, stale detection, signal cleanup, and watchdog checks to serialize writes. */ import "../infra/fs-safe-defaults.js"; -import type fsSync from "node:fs"; +import { randomUUID } from "node:crypto"; +import fsSync from "node:fs"; import fs from "node:fs/promises"; import path from "node:path"; import { MAX_TIMER_TIMEOUT_MS } from "@openclaw/normalization-core/number-coercion"; -import { parseSqliteSessionFileMarker } from "../config/sessions/sqlite-marker.js"; +import type { SessionTranscriptRuntimeTarget } from "../config/sessions/session-accessor.types.js"; +import { resolveSqliteTargetFromSessionStorePath } from "../config/sessions/session-sqlite-target.js"; import type { OpenClawConfig } from "../config/types.openclaw.js"; +import { computeBackoff, sleepWithAbort } from "../infra/backoff.js"; import { createFileLockManager } from "../infra/file-lock-manager.js"; import { isGatewayArgv } from "../infra/gateway-process-argv.js"; import { readGatewayProcessArgsSync as readProcessArgsSync } from "../infra/gateway-processes.js"; -import { getProcessStartTime, isPidAlive } from "../shared/pid-alive.js"; +import { + executeSqliteQuerySync, + executeSqliteQueryTakeFirstSync, + getNodeSqliteKysely, +} from "../infra/kysely-sync.js"; +import { isSqliteLockError } from "../infra/sqlite-transaction.js"; +import { readWindowsProcessStartTimeSync } from "../infra/windows-port-pids.js"; +import { LEGACY_IMPLICIT_AGENT_ID, resolveAgentIdFromSessionKey } from "../routing/session-key.js"; +import { resolveGlobalSingleton } from "../shared/global-singleton.js"; +import { getFileLockProcessStartTime, isPidAlive } from "../shared/pid-alive.js"; +import type { DB as OpenClawAgentKyselyDatabase } from "../state/openclaw-agent-db.generated.js"; +import { + openOpenClawAgentDatabase, + runOpenClawAgentWriteTransaction, + type OpenClawAgentDatabaseOptions, +} from "../state/openclaw-agent-db.js"; import { SessionWriteLockStaleError, SessionWriteLockTimeoutError, @@ -57,6 +75,44 @@ const ABORTABLE_SESSION_WRITE_LOCK_POLL_MS = 100; const DEFAULT_WATCHDOG_INTERVAL_MS = 60_000; const DEFAULT_TIMEOUT_GRACE_MS = 2 * 60 * 1000; const REPORT_ONLY_STALE_LOCK_REASONS = new Set(["too-old", "hold-exceeded"]); +// Session-key leases are introduced with SQLite transcript ownership; there is +// no older shipped lease backend that requires dual acquisition during upgrade. +const SESSION_KEY_WRITE_LEASE_SCOPE = "session-write"; +const SESSION_KEY_WRITE_LEASE_STATE_KEY = Symbol.for("openclaw.sessionWriteLeaseState"); +const SESSION_KEY_WRITE_LEASE_BACKOFF = { + initialMs: 25, + maxMs: 250, + factor: 1.5, + jitter: 0.25, +} as const; +const SESSION_KEY_WRITE_LEASE_RELEASE_RETRY_MS = 2_000; +const defaultProcessStartTimeForLock = (pid: number): number | null => + process.platform === "win32" + ? readWindowsProcessStartTimeSync(pid, 1_000) + : getFileLockProcessStartTime(pid); +let resolveProcessStartTimeForLock = defaultProcessStartTimeForLock; + +type SessionKeyWriteLeaseEntry = { + databaseOptions: OpenClawAgentDatabaseOptions; + owner: string; + refCount: number; +}; + +type SessionKeyWriteLeaseState = { + held: Map; +}; + +type AgentLeaseDatabase = Pick; +type SessionKeyWriteLeaseRow = { + expires_at: number | null; + owner: string; + payload_json: string | null; +}; + +const sessionKeyWriteLeaseState = resolveGlobalSingleton( + SESSION_KEY_WRITE_LEASE_STATE_KEY, + (): SessionKeyWriteLeaseState => ({ held: new Map() }), +); /** * Yield control to the event loop so other sessions can make progress @@ -67,6 +123,365 @@ function yieldEventLoop(): Promise { setImmediate(resolve); }); } + +function stateLeaseDurationMs(value: number, minimum: number): number { + if (!Number.isFinite(value)) { + return MAX_TIMER_TIMEOUT_MS; + } + return Math.min(MAX_TIMER_TIMEOUT_MS, Math.max(minimum, Math.floor(value))); +} + +function sessionKeyLeasePath(sessionKey: string): string { + return `sqlite:${SESSION_KEY_WRITE_LEASE_SCOPE}:${sessionKey}`; +} + +export function resolveSessionWriteLockTargetKey(target: SessionTranscriptRuntimeTarget): string { + const databaseTarget = resolveSqliteTargetFromSessionStorePath(target.storePath, { + agentId: target.agentId, + }); + const canonicalPath = canonicalizeSessionWriteLeaseDatabasePath(databaseTarget.path); + return JSON.stringify([target.agentId, canonicalPath, target.sessionId]); +} + +function canonicalizeSessionWriteLeaseDatabasePath(databasePath: string): string { + const resolvedPath = path.resolve(databasePath); + const missingSegments: string[] = []; + let candidate = resolvedPath; + while (true) { + try { + return path.join(fsSync.realpathSync(candidate), ...missingSegments.toReversed()); + } catch { + const parent = path.dirname(candidate); + if (parent === candidate) { + return resolvedPath; + } + missingSegments.push(path.basename(candidate)); + candidate = parent; + } + } +} + +function resolveSessionKeyLeaseDatabaseOptions(sessionKey: string): OpenClawAgentDatabaseOptions { + let agentId = resolveAgentIdFromSessionKey(sessionKey, LEGACY_IMPLICIT_AGENT_ID); + let storePath: string | undefined; + try { + const parsed = JSON.parse(sessionKey) as unknown; + if (Array.isArray(parsed) && parsed.length === 3) { + const [parsedAgentId, parsedStorePath, parsedSessionId] = parsed; + if ( + typeof parsedAgentId === "string" && + parsedAgentId.trim().length > 0 && + typeof parsedStorePath === "string" && + parsedStorePath.trim().length > 0 && + typeof parsedSessionId === "string" && + parsedSessionId.trim().length > 0 + ) { + agentId = parsedAgentId; + storePath = parsedStorePath; + } + } + } catch { + // Unqualified compatibility keys use their agent's canonical database. + } + if (!storePath) { + const database = openOpenClawAgentDatabase({ agentId }); + return { agentId, path: database.path }; + } + const target = resolveSqliteTargetFromSessionStorePath(storePath, { agentId }); + return { agentId: target.agentId ?? agentId, path: target.path }; +} + +function runSessionKeyLeaseWrite( + databaseOptions: OpenClawAgentDatabaseOptions, + operation: (params: { + db: ReturnType["db"]; + kysely: ReturnType>; + }) => T, +): T { + return runOpenClawAgentWriteTransaction( + ({ db }) => operation({ db, kysely: getNodeSqliteKysely(db) }), + databaseOptions, + { busyTimeoutMs: 0, operationLabel: "session.write-lease" }, + ); +} + +function readSessionKeyWriteLease( + sessionKey: string, + databaseOptions: OpenClawAgentDatabaseOptions, +): SessionKeyWriteLeaseRow | undefined { + const database = openOpenClawAgentDatabase(databaseOptions); + const kysely = getNodeSqliteKysely(database.db); + return executeSqliteQueryTakeFirstSync( + database.db, + kysely + .selectFrom("state_leases") + .select(["owner", "expires_at", "payload_json"]) + .where("scope", "=", SESSION_KEY_WRITE_LEASE_SCOPE) + .where("lease_key", "=", sessionKey), + ); +} + +function canReclaimSessionKeyWriteLease(current: SessionKeyWriteLeaseRow): boolean { + let ownerPid: number | undefined; + let ownerStarttime: number | undefined; + try { + const payload = current.payload_json + ? (JSON.parse(current.payload_json) as { pid?: unknown; starttime?: unknown }) + : undefined; + ownerPid = isValidLockNumber(payload?.pid) && payload.pid > 0 ? payload.pid : undefined; + ownerStarttime = isValidLockNumber(payload?.starttime) ? payload.starttime : undefined; + } catch { + // Malformed rows are reclaimable only after their fixed deadline. + } + const ownerAlive = ownerPid ? isPidAlive(ownerPid) : undefined; + const observedStarttime = + ownerPid && ownerAlive ? resolveProcessStartTimeForLock(ownerPid) : null; + const ownerReused = + ownerStarttime !== undefined && + observedStarttime !== null && + ownerStarttime !== observedStarttime; + // Never time-take over a demonstrably live owner without atomic fencing. + // Cross-platform start-time identity handles PID reuse on supported hosts. + return ( + ownerAlive === false || ownerReused || (!ownerPid && Number(current.expires_at) <= Date.now()) + ); +} + +function tryAcquireSessionKeyWriteLease(params: { + databaseOptions: OpenClawAgentDatabaseOptions; + sessionKey: string; + owner: string; + maxHoldMs: number; + processStarttime: number | null; + observed: SessionKeyWriteLeaseRow | undefined; + observedReclaimable: boolean; +}): boolean { + return runSessionKeyLeaseWrite(params.databaseOptions, ({ db, kysely }) => { + const now = Date.now(); + const current = executeSqliteQueryTakeFirstSync( + db, + kysely + .selectFrom("state_leases") + .select("owner") + .where("scope", "=", SESSION_KEY_WRITE_LEASE_SCOPE) + .where("lease_key", "=", params.sessionKey), + ); + if (current) { + if ( + !params.observed || + current.owner !== params.observed.owner || + !params.observedReclaimable + ) { + return false; + } + executeSqliteQuerySync( + db, + kysely + .deleteFrom("state_leases") + .where("scope", "=", SESSION_KEY_WRITE_LEASE_SCOPE) + .where("lease_key", "=", params.sessionKey) + .where("owner", "=", params.observed.owner), + ); + } + const inserted = executeSqliteQuerySync( + db, + kysely + .insertInto("state_leases") + .values({ + scope: SESSION_KEY_WRITE_LEASE_SCOPE, + lease_key: params.sessionKey, + owner: params.owner, + expires_at: now + params.maxHoldMs, + heartbeat_at: null, + payload_json: JSON.stringify({ + pid: process.pid, + ...(params.processStarttime === null ? {} : { starttime: params.processStarttime }), + maxHoldMs: params.maxHoldMs, + }), + created_at: now, + updated_at: now, + }) + .onConflict((conflict) => conflict.columns(["scope", "lease_key"]).doNothing()), + ); + return inserted.numAffectedRows === 1n; + }); +} + +function releaseSessionKeyWriteLeaseOnce( + sessionKey: string, + owner: string, + databaseOptions: OpenClawAgentDatabaseOptions, +): void { + runSessionKeyLeaseWrite(databaseOptions, ({ db, kysely }) => { + executeSqliteQuerySync( + db, + kysely + .deleteFrom("state_leases") + .where("scope", "=", SESSION_KEY_WRITE_LEASE_SCOPE) + .where("lease_key", "=", sessionKey) + .where("owner", "=", owner), + ); + }); +} + +async function releaseSessionKeyWriteLease( + sessionKey: string, + owner: string, + databaseOptions: OpenClawAgentDatabaseOptions, +): Promise { + const deadline = performance.now() + SESSION_KEY_WRITE_LEASE_RELEASE_RETRY_MS; + let attempt = 0; + while (true) { + try { + releaseSessionKeyWriteLeaseOnce(sessionKey, owner, databaseOptions); + return; + } catch (error) { + if (!isSqliteLockError(error) || performance.now() >= deadline) { + throw error; + } + attempt += 1; + await sleepWithAbort( + Math.min( + deadline - performance.now(), + computeBackoff(SESSION_KEY_WRITE_LEASE_BACKOFF, attempt), + ), + ); + } + } +} + +function assertSessionKeyWriteLeaseOwned( + sessionKey: string, + owner: string, + databaseOptions: OpenClawAgentDatabaseOptions, +): void { + const current = sessionKeyWriteLeaseState.held.get(sessionKey); + const database = openOpenClawAgentDatabase(databaseOptions); + const kysely = getNodeSqliteKysely(database.db); + const row = executeSqliteQueryTakeFirstSync( + database.db, + kysely + .selectFrom("state_leases") + .select(["owner", "expires_at"]) + .where("scope", "=", SESSION_KEY_WRITE_LEASE_SCOPE) + .where("lease_key", "=", sessionKey) + .where("owner", "=", owner), + ); + if (current?.owner === owner && row) { + return; + } + throw new SessionWriteLockStaleError({ + owner: "expired or replaced SQLite lease", + lockPath: sessionKeyLeasePath(sessionKey), + staleReasons: ["lease-lost"], + }); +} + +function createSessionKeyWriteLeaseHandle( + sessionKey: string, + entry: SessionKeyWriteLeaseEntry, +): { assertOwned: () => void; release: () => Promise } { + let released = false; + let releasePromise: Promise | undefined; + return { + assertOwned: () => + assertSessionKeyWriteLeaseOwned(sessionKey, entry.owner, entry.databaseOptions), + release: () => { + if (released) { + return Promise.resolve(); + } + releasePromise ??= (async () => { + const current = sessionKeyWriteLeaseState.held.get(sessionKey); + if (current?.owner !== entry.owner) { + return; + } + if (current.refCount > 1) { + current.refCount -= 1; + return; + } + sessionKeyWriteLeaseState.held.delete(sessionKey); + try { + await releaseSessionKeyWriteLease(sessionKey, entry.owner, entry.databaseOptions); + } catch (error) { + if (!sessionKeyWriteLeaseState.held.has(sessionKey)) { + sessionKeyWriteLeaseState.held.set(sessionKey, current); + } + throw error; + } + })().then( + () => { + released = true; + }, + (error: unknown) => { + releasePromise = undefined; + throw error; + }, + ); + return releasePromise; + }, + }; +} + +async function acquireSessionKeyWriteLease(params: { + sessionKey: string; + timeoutMs: number; + maxHoldMs: number; + allowReentrant: boolean; + signal?: AbortSignal; +}): Promise<{ assertOwned: () => void; release: () => Promise }> { + const databaseOptions = resolveSessionKeyLeaseDatabaseOptions(params.sessionKey); + const startedAtMs = performance.now(); + const owner = randomUUID(); + const maxHoldMs = stateLeaseDurationMs(params.maxHoldMs, 1); + const processStarttime = resolveProcessStartTimeForLock(process.pid); + let attempt = 0; + while (true) { + const existing = sessionKeyWriteLeaseState.held.get(params.sessionKey); + if (params.allowReentrant && existing) { + existing.refCount += 1; + return createSessionKeyWriteLeaseHandle(params.sessionKey, existing); + } + if (params.signal?.aborted) { + throw params.signal.reason; + } + let acquired = false; + try { + const observed = readSessionKeyWriteLease(params.sessionKey, databaseOptions); + acquired = tryAcquireSessionKeyWriteLease({ + databaseOptions, + sessionKey: params.sessionKey, + owner, + maxHoldMs, + processStarttime, + observed, + observedReclaimable: observed ? canReclaimSessionKeyWriteLease(observed) : false, + }); + } catch (error) { + if (!isSqliteLockError(error)) { + throw error; + } + } + if (acquired) { + const entry = { databaseOptions, owner, refCount: 1 }; + sessionKeyWriteLeaseState.held.set(params.sessionKey, entry); + return createSessionKeyWriteLeaseHandle(params.sessionKey, entry); + } + const elapsedMs = performance.now() - startedAtMs; + if (elapsedMs >= params.timeoutMs) { + throw new SessionWriteLockTimeoutError({ + timeoutMs: params.timeoutMs, + owner: "another OpenClaw process", + lockPath: sessionKeyLeasePath(params.sessionKey), + }); + } + attempt += 1; + const delayMs = Math.min( + params.timeoutMs - elapsedMs, + computeBackoff(SESSION_KEY_WRITE_LEASE_BACKOFF, attempt), + ); + await sleepWithAbort(delayMs, params.signal); + } +} // A payload-less lock can be left behind during the window between open("wx") // and the owner metadata write if the owner is suspended (CPU pressure, // container freeze, I/O stall, GC pause). 30 s covers realistic system @@ -92,7 +507,6 @@ type LockInspectionDetails = Pick< >; const SESSION_LOCKS = createFileLockManager("openclaw.session-write-lock"); -let resolveProcessStartTimeForLock = getProcessStartTime; function isFileLockError(error: unknown, code: string): boolean { return (error as { code?: unknown } | null)?.code === code; @@ -271,6 +685,14 @@ export function resolveSessionLockMaxHoldFromTimeout(params: { */ function releaseAllLocksSync(): void { SESSION_LOCKS.reset(); + for (const [sessionKey, entry] of sessionKeyWriteLeaseState.held) { + try { + releaseSessionKeyWriteLeaseOnce(sessionKey, entry.owner, entry.databaseOptions); + } catch { + // Fixed expiry still recovers the row after an exit-time SQLite failure. + } + } + sessionKeyWriteLeaseState.held.clear(); stopWatchdogTimer(); } @@ -439,17 +861,7 @@ async function resolveNormalizedSessionFile(sessionFile: string): Promise void; release: () => Promise; }> { const throwIfAborted = () => { @@ -912,7 +1326,6 @@ export async function acquireSessionWriteLock(params: { throw error; }; throwIfAborted(); - registerCleanupHandlers(); const allowReentrant = params.allowReentrant ?? false; const defaultOptions = resolveSessionWriteLockOptions(); const timeoutMs = resolvePositiveMs(params.timeoutMs, defaultOptions.timeoutMs, { @@ -920,6 +1333,16 @@ export async function acquireSessionWriteLock(params: { }); const staleMs = resolvePositiveMs(params.staleMs, defaultOptions.staleMs); const maxHoldMs = resolvePositiveMs(params.maxHoldMs, defaultOptions.maxHoldMs); + registerCleanupHandlers(); + if (params.targetKind === "session-key") { + return await acquireSessionKeyWriteLease({ + sessionKey: params.sessionFile, + timeoutMs, + maxHoldMs, + allowReentrant, + ...(params.signal ? { signal: params.signal } : {}), + }); + } const orphanPayloadGraceMs = resolveOrphanLockPayloadGraceMs(timeoutMs); const sessionFile = resolveSessionWriteLockTarget(params.sessionFile); const sessionDir = path.dirname(sessionFile); @@ -1077,7 +1500,7 @@ const testing = { runLockWatchdogCheck, resolveRemainingAcquireTimeoutMs, setProcessStartTimeResolverForTest(resolver: ((pid: number) => number | null) | null): void { - resolveProcessStartTimeForLock = resolver ?? getProcessStartTime; + resolveProcessStartTimeForLock = resolver ?? defaultProcessStartTimeForLock; }, }; @@ -1091,7 +1514,7 @@ function resetSessionWriteLockStateForTest(): void { releaseAllLocksSync(); stopWatchdogTimer(); unregisterCleanupHandlers(); - resolveProcessStartTimeForLock = getProcessStartTime; + resolveProcessStartTimeForLock = defaultProcessStartTimeForLock; } if (process.env.VITEST || process.env.NODE_ENV === "test") { diff --git a/src/agents/sessions/agent-session-base.ts b/src/agents/sessions/agent-session-base.ts index 302ea0736da9..cf308775de01 100644 --- a/src/agents/sessions/agent-session-base.ts +++ b/src/agents/sessions/agent-session-base.ts @@ -702,9 +702,19 @@ export abstract class AgentSessionBase { return this.agent.followUpMode; } - /** Current session file path, or undefined if sessions are disabled */ + /** Current persisted transcript target, or undefined for in-memory sessions. */ + get sessionTarget() { + return this.sessionManager.getSessionTarget(); + } + + /** Current persisted session key, or undefined for in-memory sessions. */ + get sessionKey(): string | undefined { + return this.sessionTarget?.sessionKey; + } + + /** @deprecated Compatibility token; returns the session key, not a file path. */ get sessionFile(): string | undefined { - return this.sessionManager.getSessionFile(); + return this.sessionKey; } /** Current session ID */ diff --git a/src/agents/sessions/sdk.test.ts b/src/agents/sessions/sdk.test.ts index e820984b760a..186c9925e6c4 100644 --- a/src/agents/sessions/sdk.test.ts +++ b/src/agents/sessions/sdk.test.ts @@ -1,14 +1,18 @@ +import path from "node:path"; import { createAssistantMessageEventStream, type AssistantMessage } from "openclaw/plugin-sdk/llm"; // Agent session SDK tests cover default tool wiring, prompt preservation, and // session write-lock behavior. import { Type } from "typebox"; -import { beforeEach, describe, expect, it, vi } from "vitest"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { useAutoCleanupTempDirTracker } from "../../../test/helpers/temp-dir.js"; +import { loadSessionEntry, loadTranscriptEvents } from "../../config/sessions/session-accessor.js"; import { getStreamLlmRuntime } from "../../llm/model-runtime-binding.js"; import type { ImageContent, Model, SimpleStreamOptions } from "../../llm/types.js"; import { readRuntimePromptImageOrder } from "../../media/media-facts.js"; import { finalizeRuntimePromptImages } from "../../media/runtime-prompt-image-provenance.js"; import { createUserTurnTranscriptRecorder } from "../../sessions/user-turn-transcript.js"; import { createTestUserTurnTranscriptTarget } from "../../sessions/user-turn-transcript.test-support.js"; +import { disposeOpenClawAgentDatabaseByPath } from "../../state/openclaw-agent-db.js"; const thinkingMocks = vi.hoisted(() => ({ resolveThinkingDefaultForModel: vi.fn(() => "medium"), @@ -16,6 +20,7 @@ const thinkingMocks = vi.hoisted(() => ({ const streamMocks = vi.hoisted(() => ({ streamSimple: vi.fn(), })); +const sdkSessionTempDirs = useAutoCleanupTempDirTracker(afterEach); vi.mock("../../auto-reply/thinking.js", () => ({ resolveThinkingDefaultForModel: thinkingMocks.resolveThinkingDefaultForModel, @@ -32,7 +37,7 @@ import { getModelRegistryRuntime } from "./model-registry-runtime.js"; import { ModelRegistry } from "./model-registry.js"; import type { ResourceLoader } from "./resource-loader.js"; import { createAgentSession } from "./sdk.js"; -import { SessionManager } from "./session-manager.js"; +import { CURRENT_SESSION_VERSION, SessionManager } from "./session-manager.js"; import { SettingsManager } from "./settings-manager.js"; import { createSyntheticSourceInfo } from "./source-info.js"; @@ -68,6 +73,38 @@ describe("createAgentSession runtime ownership", () => { getModelRegistryRuntime(modelRegistry).llmRuntime, ); }); + + it("keeps the default SQLite session inside an explicit agent directory", async () => { + const root = sdkSessionTempDirs.make("openclaw-sdk-session-"); + const agentDir = path.join(root, "isolated-agent"); + const cwd = path.join(root, "explicit-sdk-cwd"); + const databasePath = path.join(agentDir, "openclaw-agent.sqlite"); + try { + const { session } = await createAgentSession({ + agentDir, + cwd, + model: testModel, + resourceLoader: createEmptyResourceLoader(), + settingsManager: SettingsManager.inMemory(), + modelRegistry: createTestModelRegistry(), + }); + + const target = session.sessionManager.getSessionTarget(); + if (!target) { + throw new Error("Expected the default SDK session to have a transcript target"); + } + expect(target.storePath).toBe(databasePath); + expect(loadSessionEntry(target)).toMatchObject({ sessionId: target.sessionId }); + await expect(loadTranscriptEvents(target)).resolves.toEqual( + expect.arrayContaining([ + expect.objectContaining({ type: "session", id: target.sessionId, cwd }), + ]), + ); + session.dispose(); + } finally { + disposeOpenClawAgentDatabaseByPath(databasePath); + } + }); }); function createModelWithoutBaseUrl(overrides: Partial): Model { @@ -183,32 +220,50 @@ async function createSessionAndStreamModel(model: Model): Promise, +): SessionManager { + const timestamp = new Date().toISOString(); + return SessionManager.fromEntries([ + { + type: "session", + version: CURRENT_SESSION_VERSION, + id: "sdk-persisted-content", + timestamp, + cwd: process.cwd(), }, - stopReason: params.stopReason ?? "stop", - timestamp: Date.now(), - } as Parameters[0]); + ...messages.map((message, index) => ({ + type: "message", + id: `assistant-${String(index + 1)}`, + parentId: index === 0 ? null : `assistant-${String(index)}`, + timestamp, + message: { + role: "assistant", + content: message.content, + api: "messages", + provider: "anthropic", + model: "sonnet-4.6", + usage: { + input: 0, + output: 0, + cacheRead: 0, + cacheWrite: 0, + totalTokens: 0, + cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 }, + }, + stopReason: message.stopReason ?? "stop", + timestamp: Date.now(), + }, + })), + ]); } async function createSessionFromManager(sessionManager: SessionManager) { const { session } = await createAgentSession({ + authStorage: AuthStorage.inMemory(), model: testModel, resourceLoader: createEmptyResourceLoader(), sessionManager, @@ -219,9 +274,9 @@ async function createSessionFromManager(sessionManager: SessionManager) { } async function createSessionWithPersistedAssistantContent(content: unknown) { - const sessionManager = SessionManager.inMemory(); - appendPersistedAssistantMessage({ sessionManager, content }); - return await createSessionFromManager(sessionManager); + return await createSessionFromManager( + createSessionManagerWithPersistedAssistantMessages([{ content }]), + ); } describe("AgentSession getLastAssistantText", () => { @@ -248,13 +303,10 @@ describe("AgentSession getLastAssistantText", () => { }); it("skips aborted malformed content and returns the preceding assistant text", async () => { - const sessionManager = SessionManager.inMemory(); - appendPersistedAssistantMessage({ sessionManager, content: "previous answer" }); - appendPersistedAssistantMessage({ - sessionManager, - content: null, - stopReason: "aborted", - }); + const sessionManager = createSessionManagerWithPersistedAssistantMessages([ + { content: "previous answer" }, + { content: null, stopReason: "aborted" }, + ]); const session = await createSessionFromManager(sessionManager); expect(session.getLastAssistantText()).toBe("previous answer"); diff --git a/src/agents/sessions/sdk.ts b/src/agents/sessions/sdk.ts index 7e032b76f926..27508b29cbf6 100644 --- a/src/agents/sessions/sdk.ts +++ b/src/agents/sessions/sdk.ts @@ -3,12 +3,14 @@ * * Selects models, wires built-in/custom tools, loads resources, and creates AgentSession instances. */ +import { randomUUID } from "node:crypto"; import { join } from "node:path"; import { clampThinkingLevel } from "@openclaw/ai/internal/runtime"; import { resolveThinkingDefaultForModel, type ThinkingCatalogEntry, } from "../../auto-reply/thinking.js"; +import { createSessionEntryWithTranscript } from "../../config/sessions/session-accessor.js"; import { bindStreamLlmRuntime } from "../../llm/model-runtime-binding.js"; import type { Message, Model } from "../../llm/types.js"; import { getAgentDir } from "../config.js"; @@ -35,7 +37,7 @@ import { getModelRegistryRuntime } from "./model-registry-runtime.js"; import { ModelRegistry } from "./model-registry.js"; import { findInitialModel } from "./model-resolver.js"; import { DefaultResourceLoader, type ResourceLoader } from "./resource-loader.js"; -import { getDefaultSessionDir, SessionManager } from "./session-manager.js"; +import { SessionManager } from "./session-manager.js"; import { SettingsManager } from "./settings-manager.js"; import { isInstallTelemetryEnabled } from "./telemetry.js"; import { @@ -110,7 +112,7 @@ export interface CreateAgentSessionOptions { /** Resource loader. When omitted, DefaultResourceLoader is used. */ resourceLoader?: ResourceLoader; - /** Session manager. Default: SessionManager.create(cwd) */ + /** Session manager. Default: a new SQLite-backed main-agent SDK session. */ sessionManager?: SessionManager; /** Settings manager. Default: SettingsManager.create(cwd, agentDir) */ @@ -306,7 +308,7 @@ async function createAgentSessionImpl( const settingsManager = options.settingsManager ?? SettingsManager.create(cwd, agentDir); const sessionManager = - options.sessionManager ?? SessionManager.create(cwd, getDefaultSessionDir(cwd, agentDir)); + options.sessionManager ?? (await createDefaultSdkSessionManager(cwd, agentDir)); if (!resourceLoader) { resourceLoader = new DefaultResourceLoader({ cwd, agentDir, settingsManager }); @@ -569,3 +571,28 @@ async function createAgentSessionImpl( modelFallbackMessage, }; } + +async function createDefaultSdkSessionManager( + cwd: string, + agentDir: string, +): Promise { + const sessionId = randomUUID(); + const target = { + agentId: "main", + sessionId, + sessionKey: `agent:main:sdk:${sessionId}`, + storePath: join(agentDir, "openclaw-agent.sqlite"), + }; + const created = await createSessionEntryWithTranscript( + target, + () => ({ + ok: true, + entry: { sessionId, updatedAt: Date.now() }, + }), + { cwd }, + ); + if (!created.ok) { + throw new Error(`Failed to initialize SDK session transcript: ${created.error}`); + } + return SessionManager.open(target, cwd); +} diff --git a/src/agents/sessions/session-manager-branching.ts b/src/agents/sessions/session-manager-branching.ts index 9d906578daee..a63bddf131d5 100644 --- a/src/agents/sessions/session-manager-branching.ts +++ b/src/agents/sessions/session-manager-branching.ts @@ -2,7 +2,7 @@ import { loadSessionEntry, replaceSessionEntrySync, } from "../../config/sessions/session-accessor.js"; -import { formatSqliteSessionFileMarker } from "../../config/sessions/sqlite-marker.js"; +import { projectCanonicalSessionEntryShape } from "../../config/sessions/store-entry-shape.js"; import { CURRENT_SESSION_VERSION } from "../../config/sessions/version.js"; import { isJsonRecord, @@ -103,7 +103,7 @@ export class SessionManagerBranching extends SessionManagerEntries { } createBranchedSession(leafId: string): string | undefined { - const previousSessionFile = this.sessionFile; + const previousSessionId = this.sessionId; const branchPath = this.collectBranchedSessionPath(leafId); if (branchPath.entries.length === 0) { throw new Error(`Entry ${leafId} not found`); @@ -111,15 +111,7 @@ export class SessionManagerBranching extends SessionManagerEntries { const newSessionId = createSessionId(); const timestamp = new Date().toISOString(); - const fileTimestamp = timestamp.replace(/[:.]/g, "-"); - const sqlitePersistence = this.sqlitePersistence; - const newSessionFile = sqlitePersistence - ? formatSqliteSessionFileMarker({ - agentId: sqlitePersistence.agentId, - sessionId: newSessionId, - storePath: sqlitePersistence.storePath, - }) - : join(this.getSessionDir(), `${fileTimestamp}_${newSessionId}.jsonl`); + const persistenceTarget = this.persistenceTarget; const header: SessionHeader = { type: "session", @@ -127,7 +119,7 @@ export class SessionManagerBranching extends SessionManagerEntries { id: newSessionId, timestamp, cwd: this.cwd, - parentSession: this.shouldPersist ? previousSessionFile : undefined, + parentSession: persistenceTarget ? previousSessionId : undefined, }; const pathEntryIds = new Set(branchPath.entries.map((entry) => entry.id)); const labelsToWrite: Array<{ targetId: string; label: string; timestamp: string }> = []; @@ -160,46 +152,35 @@ export class SessionManagerBranching extends SessionManagerEntries { this.fileEntries = [header, ...branchPath.entries, ...labelEntries]; this.opaqueFileEntries = branchPath.opaqueEntries; this.sessionId = newSessionId; - this.sessionFileSnapshot = undefined; - if (this.shouldPersist) { - this.sessionFile = newSessionFile; - if (sqlitePersistence) { - const updatedAt = Date.now(); - const previousEntry = loadSessionEntry({ - agentId: sqlitePersistence.agentId, - sessionKey: sqlitePersistence.sessionKey, - storePath: sqlitePersistence.storePath, - }); - this.sqlitePersistence = { ...sqlitePersistence, sessionId: newSessionId }; - replaceSessionEntrySync( - { - agentId: sqlitePersistence.agentId, - sessionKey: sqlitePersistence.sessionKey, - storePath: sqlitePersistence.storePath, - }, - { - ...(previousEntry ?? { updatedAt }), - sessionFile: newSessionFile, - sessionId: newSessionId, - updatedAt, - }, - ); - } - this.buildIndex(); - const hasAssistant = this.fileEntries.some( - (entry) => entry.type === "message" && entry.message.role === "assistant", + if (persistenceTarget) { + const updatedAt = Date.now(); + const previousEntry = loadSessionEntry({ + agentId: persistenceTarget.agentId, + sessionKey: persistenceTarget.sessionKey, + storePath: persistenceTarget.storePath, + }); + const canonicalPreviousEntry = previousEntry + ? projectCanonicalSessionEntryShape(previousEntry as unknown as Record) + : { updatedAt }; + this.persistenceTarget = { ...persistenceTarget, sessionId: newSessionId }; + replaceSessionEntrySync( + { + agentId: persistenceTarget.agentId, + sessionKey: persistenceTarget.sessionKey, + storePath: persistenceTarget.storePath, + }, + { + ...canonicalPreviousEntry, + sessionId: newSessionId, + updatedAt, + }, ); - if (hasAssistant) { - this.replacePersistedTranscript(); - this.flushed = true; - } else { - this.flushed = false; - } - return newSessionFile; + this.buildIndex(); + this.replacePersistedTranscript(); + return newSessionId; } this.buildIndex(); return undefined; } } -import { join } from "node:path"; diff --git a/src/agents/sessions/session-manager-codec.test.ts b/src/agents/sessions/session-manager-codec.test.ts new file mode 100644 index 000000000000..67332a6d7b32 --- /dev/null +++ b/src/agents/sessions/session-manager-codec.test.ts @@ -0,0 +1,27 @@ +import { describe, expect, it } from "vitest"; +import { CURRENT_SESSION_VERSION, SessionManager } from "./session-manager.js"; + +describe("session manager codec compatibility", () => { + it("backfills current-version hook messages persisted without a custom type", () => { + const manager = SessionManager.fromEntries([ + { + type: "session", + version: CURRENT_SESSION_VERSION, + id: "persisted-hook-session", + timestamp: "2026-01-01T00:00:00.000Z", + cwd: "/tmp", + }, + { + type: "message", + id: "persisted-hook-message", + parentId: null, + timestamp: "2026-01-01T00:00:01.000Z", + message: { role: "custom", content: "persisted hook context" }, + }, + ]); + + expect(manager.getEntry("persisted-hook-message")).toMatchObject({ + message: { role: "custom", customType: "hook", content: "persisted hook context" }, + }); + }); +}); diff --git a/src/agents/sessions/session-manager-codec.ts b/src/agents/sessions/session-manager-codec.ts index a4fb8c58019f..28a72454ad9e 100644 --- a/src/agents/sessions/session-manager-codec.ts +++ b/src/agents/sessions/session-manager-codec.ts @@ -35,9 +35,9 @@ function migrateV1ToV2( if (entry.type === "compaction") { const compaction = entry as CompactionEntry & { firstKeptEntryIndex?: number }; if (typeof compaction.firstKeptEntryIndex === "number") { - const targetEntry = - entriesByOriginalIndex?.[compaction.firstKeptEntryIndex] ?? - entries[compaction.firstKeptEntryIndex]; + const targetEntry = entriesByOriginalIndex + ? entriesByOriginalIndex[compaction.firstKeptEntryIndex] + : entries[compaction.firstKeptEntryIndex]; if (targetEntry && targetEntry.type !== "session") { compaction.firstKeptEntryId = targetEntry.id; } @@ -54,9 +54,10 @@ function migrateV2ToV3(entries: FileEntry[]): void { continue; } if (entry.type === "message" && entry.message) { - const message = entry.message as { role: string }; + const message = entry.message as { role: string; customType?: string }; if (message.role === "hookMessage") { message.role = "custom"; + message.customType ||= "hook"; } } } @@ -142,7 +143,7 @@ export function buildSessionContext( return buildCoreSessionContext(path as CoreSessionTreeEntry[]) as SessionContext; } -export function parseJsonlEntries(content: string): FileEntry[] { +function parseJsonlEntries(content: string): FileEntry[] { const entries: FileEntry[] = []; let skipped = 0; for (const line of content.trim().split("\n")) { @@ -180,11 +181,6 @@ export function normalizeLoadedFileEntry(entry: FileEntry): FileEntry { return entry; } -export function hasReadableSessionHeader(entries: FileEntry[]): boolean { - const header = entries[0]; - return header?.type === "session" && typeof (header as { id?: unknown }).id === "string"; -} - export function isJsonRecord(value: unknown): value is Record { return typeof value === "object" && value !== null && !Array.isArray(value); } @@ -208,14 +204,126 @@ function isSessionEntryType(type: unknown): boolean { } export function isIndexedSessionEntry(entry: unknown): entry is SessionEntry { + if ( + !isJsonRecord(entry) || + !isSessionEntryType(entry.type) || + typeof entry.id !== "string" || + entry.id.length === 0 || + (entry.parentId !== undefined && + entry.parentId !== null && + typeof entry.parentId !== "string") || + (entry.timestamp !== undefined && typeof entry.timestamp !== "string") + ) { + return false; + } + switch (entry.type) { + case "message": + return isReadableMessage(entry.message); + case "thinking_level_change": + return typeof entry.thinkingLevel === "string" && entry.thinkingLevel.length > 0; + case "model_change": + return ( + typeof entry.provider === "string" && + entry.provider.length > 0 && + typeof entry.modelId === "string" && + entry.modelId.length > 0 + ); + case "compaction": + return ( + typeof entry.summary === "string" && + typeof entry.firstKeptEntryId === "string" && + entry.firstKeptEntryId.length > 0 && + typeof entry.tokensBefore === "number" + ); + case "reset": + return ( + ["new", "reset", "idle", "daily", "cron-stale"].includes(String(entry.reason)) && + (entry.firstKeptEntryId === undefined || typeof entry.firstKeptEntryId === "string") + ); + case "branch_summary": + return typeof entry.fromId === "string" && typeof entry.summary === "string"; + case "custom": + return typeof entry.customType === "string" && entry.customType.length > 0; + case "custom_message": + return ( + typeof entry.customType === "string" && + entry.customType.length > 0 && + isReadableContent(entry.content) && + typeof entry.display === "boolean" + ); + case "label": + return ( + typeof entry.targetId === "string" && + entry.targetId.length > 0 && + (entry.label === undefined || typeof entry.label === "string") + ); + case "session_info": + return entry.name === undefined || typeof entry.name === "string"; + default: + return false; + } +} + +function isReadableContent(value: unknown): boolean { return ( - isJsonRecord(entry) && - isSessionEntryType(entry.type) && - typeof entry.id === "string" && - entry.id.length > 0 + typeof value === "string" || + (Array.isArray(value) && + value.every((part) => isJsonRecord(part) && typeof part.type === "string")) ); } +function isReadableMessage(value: unknown): boolean { + if (!isJsonRecord(value) || typeof value.role !== "string") { + return false; + } + switch (value.role) { + case "user": + case "assistant": + return isReadableContent(value.content); + case "toolResult": + return ( + typeof value.toolCallId === "string" && + typeof value.toolName === "string" && + typeof value.isError === "boolean" && + Array.isArray(value.content) + ); + case "custom": + return typeof value.customType === "string" && isReadableContent(value.content); + case "bashExecution": + return typeof value.command === "string" && typeof value.output === "string"; + default: + return false; + } +} + +function isReadableLegacySessionEntry(value: unknown): value is FileEntry { + const message = isJsonRecord(value) && value.type === "message" ? value.message : undefined; + const readableLegacyMessage = + isJsonRecord(message) && message.role === "hookMessage" + ? isReadableContent(message.content) + : isReadableMessage(message); + return ( + isJsonRecord(value) && + isSessionEntryType(value.type) && + (value.type !== "message" || readableLegacyMessage) + ); +} + +function normalizePersistedLegacyHookMessage(value: unknown): unknown { + if (!isJsonRecord(value) || value.type !== "message" || !isJsonRecord(value.message)) { + return value; + } + const message = value.message; + if ( + message.role !== "custom" || + message.customType !== undefined || + !isReadableContent(message.content) + ) { + return value; + } + return { ...value, message: { ...message, customType: "hook" } }; +} + export function parseParentLinkedOpaqueEntry( record: unknown, ): { id: string; parentId: string | null } | undefined { @@ -275,9 +383,10 @@ export function partitionSessionFileEntries(entries: readonly FileEntry[]): { const header = entries.find( (entry) => isJsonRecord(entry) && entry.type === "session" && typeof entry.id === "string", ) as SessionHeader | undefined; - const acceptsLegacyEntries = (header?.version ?? 1) < 2; + const acceptsLegacyEntries = (header?.version ?? 1) < CURRENT_SESSION_VERSION; let hasHeader = false; - for (const [originalIndex, entry] of entries.entries()) { + for (const [originalIndex, rawEntry] of entries.entries()) { + const entry = normalizePersistedLegacyHookMessage(rawEntry) as FileEntry; if ( !hasHeader && isJsonRecord(entry) && @@ -291,7 +400,7 @@ export function partitionSessionFileEntries(entries: readonly FileEntry[]): { } if ( isIndexedSessionEntry(entry) || - (acceptsLegacyEntries && isJsonRecord(entry) && isSessionEntryType(entry.type)) + (acceptsLegacyEntries && isReadableLegacySessionEntry(entry)) ) { fileEntries.push(entry); fileEntriesByOriginalIndex[originalIndex] = entry; diff --git a/src/agents/sessions/session-manager-core.ts b/src/agents/sessions/session-manager-core.ts index 94bb21e85df4..ccadf89bfdf9 100644 --- a/src/agents/sessions/session-manager-core.ts +++ b/src/agents/sessions/session-manager-core.ts @@ -1,54 +1,33 @@ -import { existsSync, mkdirSync } from "node:fs"; -import { join, resolve } from "node:path"; import { loadTranscriptEventsSync, replaceTranscriptEventsSync, } from "../../config/sessions/session-accessor.js"; -import type { SqliteSessionFileMarker } from "../../config/sessions/sqlite-marker.js"; -import { - serializeJsonlLine, - writeJsonlEntriesSync, -} from "../../config/sessions/transcript-jsonl.js"; +import type { SessionTranscriptRuntimeTarget } from "../../config/sessions/session-accessor.types.js"; import { isSessionTranscriptSideAppendEntry } from "../../config/sessions/transcript-tree.js"; import { CURRENT_SESSION_VERSION } from "../../config/sessions/version.js"; import { - hasReadableSessionHeader, isIndexedSessionEntry, migrateToCurrentVersion, parseOpaqueLeafEntry, parseParentLinkedOpaqueEntry, partitionSessionFileEntries, } from "./session-manager-codec.js"; -import { - loadEntriesFromFileWithSnapshot, - loadSqliteMarkedSessionFile, - publishRememberedSessionFileSnapshot, - recoverCorruptSessionEntries, - rememberWrittenSessionEntries, - type LoadedSessionFile, -} from "./session-manager-file.js"; import { createSessionId, generateSessionEntryId } from "./session-manager-id.js"; import type { FileEntry, NewSessionOptions, PreservedOpaqueFileEntry, SessionEntry, - SessionFileSnapshot, SessionHeader, SessionLeafControl, } from "./session-manager-types.js"; -export type SqliteSessionManagerPersistence = SqliteSessionFileMarker & { - sessionKey: string; -}; +export type SessionManagerPersistenceTarget = SessionTranscriptRuntimeTarget; export class SessionManagerCore { + migrated = false; protected sessionId = ""; - protected sessionFile: string | undefined; - protected sessionDir: string; protected cwd: string; - protected shouldPersist: boolean; - protected flushed = false; protected fileEntries: FileEntry[] = []; protected opaqueFileEntries: PreservedOpaqueFileEntry[] = []; protected byId: Map = new Map(); @@ -59,141 +38,83 @@ export class SessionManagerCore { protected labelTimestampsById: Map = new Map(); protected leafId: string | null = null; protected appendParentId: string | null = null; + protected appendMode: "side" | undefined; protected promptReleasedSideBranchParentId: string | null | undefined; - protected recoveredCorruptHeader = false; - protected sessionFileSnapshot: SessionFileSnapshot | undefined; - protected sqlitePersistence: SqliteSessionManagerPersistence | undefined; + protected persistenceTarget: SessionManagerPersistenceTarget | undefined; + protected persistenceHeaderPending = false; constructor( cwd: string, - sessionDir: string, - sessionFile: string | undefined, - persist: boolean, - loadedSessionFile?: LoadedSessionFile, - sqlitePersistence?: SqliteSessionManagerPersistence, + persistenceTarget?: SessionManagerPersistenceTarget, + loadedEntries?: FileEntry[], ) { this.cwd = cwd; - this.sessionDir = sessionDir; - this.shouldPersist = persist; - this.sqlitePersistence = sqlitePersistence; - if (persist && sessionDir && !existsSync(sessionDir)) { - mkdirSync(sessionDir, { recursive: true }); - } - - if (sessionFile) { - if (sqlitePersistence) { - this.setLoadedSqliteSessionFile( - sessionFile, - loadedSessionFile ?? { entries: [], snapshot: undefined }, - ); - } else { - this.setLoadedSessionFile( - sessionFile, - loadedSessionFile ?? loadEntriesFromFileWithSnapshot(sessionFile), - ); - } + this.persistenceTarget = persistenceTarget; + if (persistenceTarget || loadedEntries) { + this.setLoadedSessionTarget(persistenceTarget, loadedEntries ?? []); } else { this.newSession(); } } - setSessionFile(sessionFile: string): void { - const sqliteLoaded = loadSqliteMarkedSessionFile( - sessionFile, - (marker) => loadTranscriptEventsSync(marker) as FileEntry[], - { fallbackCwd: this.cwd }, + setSessionTarget(target: SessionManagerPersistenceTarget): void { + const entries = loadTranscriptEventsSync(target) as FileEntry[]; + const header = entries.find( + (entry) => typeof entry === "object" && entry !== null && entry.type === "session", ); - if (sqliteLoaded) { - this.cwd = sqliteLoaded.cwd; - this.sqlitePersistence = { - ...sqliteLoaded.sqliteMarker, - sessionKey: sqliteLoaded.sessionKey, - }; - this.setLoadedSqliteSessionFile(sessionFile, { - entries: sqliteLoaded.entries, - snapshot: undefined, - }); - return; + this.setLoadedSessionTarget(target, entries); + if (header?.cwd) { + this.cwd = header.cwd; } - this.sqlitePersistence = undefined; - this.setLoadedSessionFile(sessionFile, loadEntriesFromFileWithSnapshot(sessionFile)); } - protected setLoadedSessionFile(sessionFile: string, loaded: LoadedSessionFile): void { - this.sessionFile = resolve(sessionFile); - this.sessionFileSnapshot = undefined; - this.recoveredCorruptHeader = false; - if (!existsSync(this.sessionFile)) { - const explicitPath = this.sessionFile; - this.newSession(); - this.sessionFile = explicitPath; + protected setLoadedSessionTarget( + target: SessionManagerPersistenceTarget | undefined, + entries: FileEntry[], + ): void { + const partitioned = partitionSessionFileEntries(entries); + if (partitioned.fileEntries.length === 0) { + this.persistenceTarget = target ? { ...target } : undefined; + this.initializeSession({ id: target?.sessionId }); + this.persistenceHeaderPending = target !== undefined; return; } - - const partitioned = partitionSessionFileEntries(loaded.entries); + const header = partitioned.fileEntries.find((entry) => entry.type === "session"); + if (target && (header?.version ?? 1) < CURRENT_SESSION_VERSION) { + throw new Error( + "Persisted legacy session transcripts require doctor/import migration before runtime use", + ); + } + this.persistenceHeaderPending = false; + this.persistenceTarget = target ? { ...target } : undefined; this.fileEntries = partitioned.fileEntries; this.opaqueFileEntries = partitioned.opaqueEntries; - this.sessionFileSnapshot = loaded.snapshot; - if (this.fileEntries.length === 0) { - const recoveredEntries = recoverCorruptSessionEntries(this.sessionFile, this.cwd); - if (recoveredEntries && hasReadableSessionHeader(recoveredEntries)) { - const recovered = partitionSessionFileEntries(recoveredEntries); - this.fileEntries = recovered.fileEntries; - this.opaqueFileEntries = recovered.opaqueEntries; - const header = this.fileEntries.find((entry) => entry.type === "session"); - this.sessionId = header?.id ?? createSessionId(); - migrateToCurrentVersion(this.fileEntries, recovered.fileEntriesByOriginalIndex); - this.buildIndex(); - this.replacePersistedTranscript(); - this.recoveredCorruptHeader = true; - this.flushed = true; - return; - } - - const explicitPath = this.sessionFile; - this.newSession(); - this.sessionFile = explicitPath; - this.replacePersistedTranscript(); - this.flushed = true; - return; - } - - const header = this.fileEntries.find((entry) => entry.type === "session"); - this.sessionId = header?.id ?? createSessionId(); - const migrated = migrateToCurrentVersion( + this.sessionId = header?.id ?? target?.sessionId ?? createSessionId(); + this.migrated = migrateToCurrentVersion( this.fileEntries, partitioned.fileEntriesByOriginalIndex, ); this.buildIndex(); - if (migrated) { - this.replacePersistedTranscript(); - } - this.flushed = true; } - protected setLoadedSqliteSessionFile(sessionFile: string, loaded: LoadedSessionFile): void { - this.sessionFile = sessionFile; - this.sessionFileSnapshot = undefined; - this.recoveredCorruptHeader = false; - const partitioned = partitionSessionFileEntries(loaded.entries); - if (partitioned.fileEntries.length === 0) { - this.newSession({ id: this.sqlitePersistence?.sessionId }); - this.sessionFile = sessionFile; - return; + reloadPersistedTranscript(): void { + if (this.persistenceTarget) { + const runtimeCwd = this.cwd; + this.setSessionTarget(this.persistenceTarget); + this.cwd = runtimeCwd; } - this.fileEntries = partitioned.fileEntries; - this.opaqueFileEntries = partitioned.opaqueEntries; - const header = this.fileEntries.find((entry) => entry.type === "session"); - this.sessionId = header?.id ?? this.sqlitePersistence?.sessionId ?? createSessionId(); - migrateToCurrentVersion(this.fileEntries, partitioned.fileEntriesByOriginalIndex); - this.buildIndex(); - this.flushed = true; } newSession(options?: NewSessionOptions): string | undefined { - this.recoveredCorruptHeader = false; - this.sessionFileSnapshot = undefined; - this.sessionId = options?.id ?? createSessionId(); + if (this.persistenceTarget) { + throw new Error("Persisted session managers cannot change session identity in place"); + } + return this.initializeSession(options); + } + + private initializeSession(options?: NewSessionOptions): string | undefined { + this.sessionId = options?.id ?? this.persistenceTarget?.sessionId ?? createSessionId(); + this.migrated = false; const timestamp = new Date().toISOString(); const header: SessionHeader = { type: "session", @@ -213,14 +134,9 @@ export class SessionManagerCore { this.labelTimestampsById.clear(); this.leafId = null; this.appendParentId = null; + this.appendMode = undefined; this.promptReleasedSideBranchParentId = undefined; - this.flushed = false; - - if (this.shouldPersist) { - const fileTimestamp = timestamp.replace(/[:.]/g, "-"); - this.sessionFile = join(this.getSessionDir(), `${fileTimestamp}_${this.sessionId}.jsonl`); - } - return this.sessionFile; + return this.persistenceTarget ? this.sessionId : undefined; } protected resolveOpaqueLeafTargetId(targetId: string | null): string | null { @@ -273,6 +189,7 @@ export class SessionManagerCore { this.labelTimestampsById.clear(); this.leafId = null; this.appendParentId = null; + this.appendMode = undefined; this.promptReleasedSideBranchParentId = undefined; let opaqueIndex = 0; let latestResetId: string | undefined; @@ -308,6 +225,7 @@ export class SessionManagerCore { } this.leafId = effectiveLeafState.leafId; this.appendParentId = effectiveLeafState.appendParentId; + this.appendMode = effectiveLeafState.appendMode; this.promptReleasedSideBranchParentId = effectiveLeafState.appendMode === "side" ? effectiveLeafState.appendParentId @@ -336,6 +254,10 @@ export class SessionManagerCore { if (!isIndexedSessionEntry(entry)) { continue; } + if (entry.type === "label" && !this.byId.has(entry.targetId)) { + this.opaqueParentsById.set(entry.id, this.resolveCanonicalParentId(entry.parentId)); + continue; + } const crossesResetBoundary = latestResetId !== undefined && !isSessionTranscriptSideAppendEntry(entry) && @@ -368,9 +290,11 @@ export class SessionManagerCore { } this.appendParentId = entry.id; if (isSessionTranscriptSideAppendEntry(entry)) { + this.appendMode = "side"; this.promptReleasedSideBranchParentId = entry.id; } else { this.leafId = entry.id; + this.appendMode = undefined; this.promptReleasedSideBranchParentId = undefined; } if (entry.type === "label") { @@ -403,6 +327,9 @@ export class SessionManagerCore { ? (this.logicalParentsById.get(entry.id) ?? null) : this.resolveCanonicalParentId(entry.parentId); let normalized = parentId === entry.parentId ? entry : { ...entry, parentId }; + if (normalized.parentId === normalized.id) { + normalized = { ...normalized, parentId: null }; + } if ( (normalized.type === "compaction" || normalized.type === "reset") && normalized.firstKeptEntryId !== undefined && @@ -507,7 +434,15 @@ export class SessionManagerCore { protected rememberLeafControl(leafEntry: SessionLeafControl): void { this.opaqueFileEntries.push({ index: this.fileEntries.length, record: leafEntry }); - this.opaqueParentsById.set(leafEntry.id, this.leafId); + this.opaqueParentsById.set(leafEntry.id, leafEntry.targetId); + } + + getAppendParentId(): string | null { + return this.appendParentId; + } + + getAppendMode(): "side" | undefined { + return this.appendMode; } protected getPersistedFileEntries( @@ -570,8 +505,8 @@ export class SessionManagerCore { return entries; } - getSerializedFileLinesForRewrite(): string[] { - return this.getPersistedFileEntries().map(serializeJsonlLine); + getPersistedEntries(): unknown[] { + return this.getPersistedFileEntries(); } clearPreservedOpaqueFileEntries(): void { @@ -579,85 +514,42 @@ export class SessionManagerCore { this.opaqueParentsById.clear(); this.invalidLeafControlIds.clear(); this.appendParentId = null; + this.appendMode = undefined; this.promptReleasedSideBranchParentId = undefined; } - protected writeFullFile( - leafAppendParentId: string | null = this.appendParentId, - leafAppendMode?: "side", - ): string { - return this.sessionFile - ? writeJsonlEntriesSync( - this.sessionFile, - this.getPersistedFileEntries(leafAppendParentId, leafAppendMode), - ) - : ""; - } - protected replacePersistedTranscript(options?: { - publishSnapshot?: boolean; leafAppendParentId?: string | null; leafAppendMode?: "side"; }): void { - if (!this.shouldPersist) { + if (!this.persistenceTarget) { return; } const leafAppendParentId = options?.leafAppendParentId === undefined ? this.appendParentId : options.leafAppendParentId; - if (this.sqlitePersistence) { - replaceTranscriptEventsSync( - { - agentId: this.sqlitePersistence.agentId, - sessionId: this.sqlitePersistence.sessionId, - sessionKey: this.sqlitePersistence.sessionKey, - storePath: this.sqlitePersistence.storePath, - }, - this.getPersistedFileEntries(leafAppendParentId, options?.leafAppendMode), - ); - this.flushed = true; - return; - } - if (!this.sessionFile) { - return; - } - const content = this.writeFullFile(leafAppendParentId, options?.leafAppendMode); - const rememberedWrite = rememberWrittenSessionEntries(this.sessionFile, content); - this.sessionFileSnapshot = rememberedWrite.snapshot; - if (rememberedWrite.verifiedWrite && options?.publishSnapshot !== false) { - publishRememberedSessionFileSnapshot(this.sessionFile, rememberedWrite.snapshot); - } + replaceTranscriptEventsSync( + this.persistenceTarget, + this.getPersistedFileEntries(leafAppendParentId, options?.leafAppendMode ?? this.appendMode), + ); + this.persistenceHeaderPending = false; } - /** Makes pending append-oriented persistence durable without replacing SQLite transcripts. */ - protected flushPendingPersistence(): void { - if (!this.shouldPersist || this.sqlitePersistence || this.flushed || !this.sessionFile) { - return; - } - this.replacePersistedTranscript(); - this.flushed = true; - } + /** SQLite appends are synchronous; retained for the AgentSession contract. */ + protected flushPendingPersistence(): void {} isPersisted(): boolean { - return this.shouldPersist; + return this.persistenceTarget !== undefined; } getCwd(): string { return this.cwd; } - getSessionDir(): string { - return this.sessionDir; - } - getSessionId(): string { return this.sessionId; } - wasRecoveredFromCorruptHeader(): boolean { - return this.recoveredCorruptHeader; - } - - getSessionFile(): string | undefined { - return this.sessionFile; + getSessionTarget(): SessionManagerPersistenceTarget | undefined { + return this.persistenceTarget ? { ...this.persistenceTarget } : undefined; } } diff --git a/src/agents/sessions/session-manager-entries.ts b/src/agents/sessions/session-manager-entries.ts index fe70f764d806..a0d58793a8ed 100644 --- a/src/agents/sessions/session-manager-entries.ts +++ b/src/agents/sessions/session-manager-entries.ts @@ -5,7 +5,7 @@ import { type SessionTreeEntry as CoreSessionTreeEntry, } from "../runtime/index.js"; import type { BashExecutionMessage, CustomMessage } from "./messages.js"; -import { messageSerializesOwnedValues } from "./session-manager-file.js"; +import { isIndexedSessionEntry } from "./session-manager-codec.js"; import { generateSessionEntryId } from "./session-manager-id.js"; import { SessionManagerPersistence } from "./session-manager-persistence.js"; import type { @@ -23,33 +23,43 @@ import type { SessionInfoEntry, SessionMessageEntry, SessionHeader, + SessionLeafControl, SessionTreeNode, ThinkingLevelChangeEntry, } from "./session-manager-types.js"; export class SessionManagerEntries extends SessionManagerPersistence { protected appendEntry(entry: SessionEntry, options?: AppendPersistenceOptions): void { + // oxlint-disable-next-line unicorn/prefer-structured-clone -- Match the persisted JSON/toJSON shape exactly. + const canonicalEntry = JSON.parse(JSON.stringify(entry)) as SessionEntry; + if (!isIndexedSessionEntry(canonicalEntry)) { + throw new Error(`Invalid session transcript entry: ${entry.type}`); + } + this.persist(canonicalEntry, options); if ( - !isSessionTranscriptSideAppendEntry(entry) && - entry.parentId === this.appendParentId && + !isSessionTranscriptSideAppendEntry(canonicalEntry) && + canonicalEntry.parentId === this.appendParentId && this.leafId !== this.appendParentId ) { - this.logicalParentsById.set(entry.id, this.leafId); + this.logicalParentsById.set(canonicalEntry.id, this.leafId); + } + this.fileEntries.push(canonicalEntry); + this.byId.set(canonicalEntry.id, canonicalEntry); + this.appendParentId = canonicalEntry.id; + if (isSessionTranscriptSideAppendEntry(canonicalEntry)) { + this.appendMode = "side"; + this.promptReleasedSideBranchParentId = canonicalEntry.id; + } else { + this.leafId = canonicalEntry.id; + this.appendMode = undefined; + this.promptReleasedSideBranchParentId = undefined; } - this.fileEntries.push(entry); - this.byId.set(entry.id, entry); - this.leafId = entry.id; - this.appendParentId = entry.id; - this.promptReleasedSideBranchParentId = undefined; - this.persist(entry, options); } appendMessage( message: Message | CustomMessage | BashExecutionMessage, options?: AppendPersistenceOptions, ): string { - const invalidateSerializedPrefixCache = - options?.invalidateSerializedPrefixCache === true || messageSerializesOwnedValues(message); const entry: SessionMessageEntry = { type: "message", id: generateSessionEntryId(this.byId), @@ -57,7 +67,7 @@ export class SessionManagerEntries extends SessionManagerPersistence { timestamp: new Date().toISOString(), message, }; - this.appendEntry(entry, { ...options, invalidateSerializedPrefixCache }); + this.appendEntry(entry, options); return entry.id; } @@ -181,6 +191,39 @@ export class SessionManagerEntries extends SessionManagerPersistence { return this.leafId; } + appendLeafControl(params: { + targetId: string | null; + appendParentId: string | null; + appendMode?: "side"; + }): SessionLeafControl { + if (params.targetId !== null && !this.byId.has(params.targetId)) { + throw new Error(`Entry ${params.targetId} not found`); + } + if ( + params.appendParentId !== null && + !this.byId.has(params.appendParentId) && + !this.opaqueParentsById.has(params.appendParentId) + ) { + throw new Error(`Append parent ${params.appendParentId} not found`); + } + const previousLeafId = this.leafId; + this.leafId = params.targetId; + const entry = this.createLeafControl( + this.appendParentId, + params.appendParentId, + params.appendMode, + ); + this.leafId = previousLeafId; + this.persistRecord(entry); + this.rememberLeafControl(entry); + this.leafId = params.targetId; + this.appendParentId = params.appendParentId; + this.appendMode = params.appendMode; + this.promptReleasedSideBranchParentId = + params.appendMode === "side" ? params.appendParentId : undefined; + return entry; + } + getLeafEntry(): SessionEntry | undefined { return this.leafId ? this.getEntry(this.leafId) : undefined; } @@ -262,7 +305,7 @@ export class SessionManagerEntries extends SessionManagerPersistence { getEntries(): SessionEntry[] { return this.fileEntries - .filter((entry): entry is SessionEntry => entry.type !== "session") + .filter((entry): entry is SessionEntry => entry.type !== "session" && this.byId.has(entry.id)) .map((entry) => this.normalizeEntryParent(entry)); } @@ -311,12 +354,14 @@ export class SessionManagerEntries extends SessionManagerPersistence { } this.leafId = branchTargetId; this.appendParentId = branchTargetId; + this.appendMode = undefined; this.promptReleasedSideBranchParentId = undefined; } resetLeaf(): void { this.leafId = null; this.appendParentId = null; + this.appendMode = undefined; this.promptReleasedSideBranchParentId = undefined; } @@ -330,8 +375,6 @@ export class SessionManagerEntries extends SessionManagerPersistence { if (branchTargetId === undefined) { throw new Error(`Entry ${branchFromId} not found`); } - this.leafId = branchTargetId; - this.appendParentId = branchTargetId; const entry: BranchSummaryEntry = { type: "branch_summary", id: generateSessionEntryId(this.byId), diff --git a/src/agents/sessions/session-manager-file.ts b/src/agents/sessions/session-manager-file.ts deleted file mode 100644 index 3d8d2421d575..000000000000 --- a/src/agents/sessions/session-manager-file.ts +++ /dev/null @@ -1,563 +0,0 @@ -import { - chmodSync, - closeSync, - existsSync, - mkdirSync, - openSync, - readFileSync, - readSync, - statSync, - writeFileSync, -} from "node:fs"; -import { join, resolve } from "node:path"; -import { isProxy } from "node:util/types"; -import { resolveTranscriptSessionKeyBySessionId } from "../../config/sessions/session-accessor.js"; -import { - parseSqliteSessionFileMarker, - type SqliteSessionFileMarker, -} from "../../config/sessions/sqlite-marker.js"; -import { - canAdvanceOwnedSessionEntryCache, - publishOwnedSessionFileSnapshot, -} from "../../config/sessions/transcript-write-context.js"; -import { CURRENT_SESSION_VERSION } from "../../config/sessions/version.js"; -import type { Message } from "../../llm/types.js"; -import { getAgentDir as getDefaultAgentDir } from "../config.js"; -import { invalidateSessionFileRepairCache } from "../session-file-repair.js"; -import type { BashExecutionMessage, CustomMessage } from "./messages.js"; -import { - hasReadableSessionHeader, - isJsonRecord, - normalizeLoadedFileEntry, - parseJsonlEntries, -} from "./session-manager-codec.js"; -import { createSessionId } from "./session-manager-id.js"; -import type { FileEntry, SessionFileSnapshot, SessionHeader } from "./session-manager-types.js"; - -const MAX_CACHED_SESSION_FILES = 8; -const MAX_CACHED_SESSION_BYTES = 32n * 1024n * 1024n; - -type CachedSessionEntries = { - snapshot: SessionFileSnapshot; - entries: FileEntry[]; - endsWithNewline: boolean; -}; - -export type LoadedSessionFile = { - entries: FileEntry[]; - snapshot: SessionFileSnapshot | undefined; -}; - -type LoadedSqliteSession = { - cwd: string; - entries: FileEntry[]; - sessionKey: string; - sqliteMarker: SqliteSessionFileMarker; -}; - -const sessionEntriesCache = new Map(); - -export function getDefaultSessionDir(cwd: string, agentDir: string = getDefaultAgentDir()): string { - const safePath = `--${cwd.replace(/^[/\\]/, "").replace(/[/\\:]/g, "-")}--`; - const sessionDir = join(agentDir, "sessions", safePath); - if (!existsSync(sessionDir)) { - mkdirSync(sessionDir, { recursive: true }); - } - return sessionDir; -} - -export function loadEntriesFromFile(filePath: string): FileEntry[] { - if (!existsSync(filePath)) { - return []; - } - const entries = parseJsonlEntries(readFileSync(filePath, "utf8")); - return hasReadableSessionHeader(entries) ? entries : []; -} - -export function loadEntriesFromFileWithSnapshot(filePath: string): LoadedSessionFile { - const resolvedPath = resolve(filePath); - for (let attempt = 0; attempt < 3; attempt += 1) { - let beforeReadSnapshot: SessionFileSnapshot; - try { - beforeReadSnapshot = readSessionFileSnapshot(resolvedPath); - } catch { - sessionEntriesCache.delete(resolvedPath); - return { entries: [], snapshot: undefined }; - } - - const cached = sessionEntriesCache.get(resolvedPath); - if (cached && isSameSessionFileSnapshot(cached.snapshot, beforeReadSnapshot)) { - const afterCacheSnapshot = readSessionFileSnapshotIfExists(resolvedPath); - if (afterCacheSnapshot && isSameSessionFileSnapshot(beforeReadSnapshot, afterCacheSnapshot)) { - return { entries: copyFileEntries(cached.entries), snapshot: afterCacheSnapshot }; - } - continue; - } - - const content = readFileSync(resolvedPath, "utf8"); - const entries = parseJsonlEntries(content); - const afterParseSnapshot = readSessionFileSnapshotIfExists(resolvedPath); - if (afterParseSnapshot && isSameSessionFileSnapshot(beforeReadSnapshot, afterParseSnapshot)) { - return { - entries: rememberSessionEntries( - resolvedPath, - afterParseSnapshot, - entries, - content.endsWith("\n"), - ), - snapshot: afterParseSnapshot, - }; - } - } - - sessionEntriesCache.delete(resolvedPath); - throw new Error(`session file changed repeatedly while loading: ${resolvedPath}`); -} - -function readSessionFileSnapshot(filePath: string): SessionFileSnapshot { - const fileStat = statSync(filePath, { bigint: true }); - return { - dev: fileStat.dev, - ino: fileStat.ino, - size: fileStat.size, - mtimeNs: fileStat.mtimeNs, - ctimeNs: fileStat.ctimeNs, - }; -} - -function isSameSessionFileSnapshot(left: SessionFileSnapshot, right: SessionFileSnapshot): boolean { - return ( - left.dev === right.dev && - left.ino === right.ino && - left.size === right.size && - left.mtimeNs === right.mtimeNs && - left.ctimeNs === right.ctimeNs - ); -} - -function rememberSessionEntries( - filePath: string, - snapshot: SessionFileSnapshot, - entries: FileEntry[], - endsWithNewline: boolean, -): FileEntry[] { - if (!hasReadableSessionHeader(entries)) { - sessionEntriesCache.delete(filePath); - return entries.length === 0 ? entries : []; - } - if (!hasCacheableSessionHeader(entries)) { - sessionEntriesCache.delete(filePath); - return entries; - } - if (snapshot.size > MAX_CACHED_SESSION_BYTES) { - sessionEntriesCache.delete(filePath); - return copyFileEntries(entries.map(freezeFileEntry)); - } - - const cachedEntries = entries.map((entry) => - Object.isFrozen(entry) ? entry : freezeFileEntry(entry), - ); - sessionEntriesCache.delete(filePath); - sessionEntriesCache.set(filePath, { snapshot, entries: cachedEntries, endsWithNewline }); - trimSessionEntriesCache(); - return copyFileEntries(cachedEntries); -} - -function trimSessionEntriesCache(): void { - let cachedBytes = 0n; - for (const cached of sessionEntriesCache.values()) { - cachedBytes += cached.snapshot.size; - } - while ( - sessionEntriesCache.size > MAX_CACHED_SESSION_FILES || - cachedBytes > MAX_CACHED_SESSION_BYTES - ) { - const oldestKey = sessionEntriesCache.keys().next().value; - if (!oldestKey) { - break; - } - cachedBytes -= sessionEntriesCache.get(oldestKey)?.snapshot.size ?? 0n; - sessionEntriesCache.delete(oldestKey); - } -} - -function hasCacheableSessionHeader(entries: FileEntry[]): boolean { - if (entries.length === 0) { - return true; - } - const header = entries[0]; - return ( - header?.type === "session" && - typeof header.id === "string" && - header.version === CURRENT_SESSION_VERSION - ); -} - -export function rememberWrittenSessionEntries( - filePath: string, - expectedContent?: string, -): { snapshot: SessionFileSnapshot | undefined; verifiedWrite: boolean; stableRead: boolean } { - const resolvedPath = resolve(filePath); - invalidateSessionFileRepairCache(resolvedPath); - let beforeReadSnapshot: SessionFileSnapshot; - try { - beforeReadSnapshot = readSessionFileSnapshot(resolvedPath); - } catch { - sessionEntriesCache.delete(resolvedPath); - return { snapshot: undefined, verifiedWrite: false, stableRead: false }; - } - if (beforeReadSnapshot.size > MAX_CACHED_SESSION_BYTES) { - sessionEntriesCache.delete(resolvedPath); - return { snapshot: beforeReadSnapshot, verifiedWrite: false, stableRead: false }; - } - - let content: string; - let afterReadSnapshot: SessionFileSnapshot; - try { - content = readFileSync(resolvedPath, "utf8"); - afterReadSnapshot = readSessionFileSnapshot(resolvedPath); - } catch { - sessionEntriesCache.delete(resolvedPath); - return { snapshot: undefined, verifiedWrite: false, stableRead: false }; - } - if ( - (expectedContent !== undefined && content !== expectedContent) || - !isSameSessionFileSnapshot(beforeReadSnapshot, afterReadSnapshot) - ) { - sessionEntriesCache.delete(resolvedPath); - return { snapshot: afterReadSnapshot, verifiedWrite: false, stableRead: false }; - } - rememberSessionEntries( - resolvedPath, - afterReadSnapshot, - parseJsonlEntries(content), - content.endsWith("\n"), - ); - return { - snapshot: afterReadSnapshot, - verifiedWrite: expectedContent !== undefined, - stableRead: true, - }; -} - -export function rememberAppendedSessionEntry(params: { - filePath: string; - previousSnapshot: SessionFileSnapshot | undefined; - beforeAppendSnapshot: SessionFileSnapshot | undefined; - serializedAppend: string; - cacheOwnedAppend: boolean; - publishOwnedAppend: boolean; - invalidateSerializedPrefixCache: boolean; -}): { - snapshot: SessionFileSnapshot | undefined; - cacheAdvanced: boolean; - ownedAppendVerified: boolean; -} { - const { - filePath, - previousSnapshot, - beforeAppendSnapshot, - serializedAppend, - cacheOwnedAppend, - publishOwnedAppend, - invalidateSerializedPrefixCache, - } = params; - const resolvedPath = resolve(filePath); - const appendedByteLength = BigInt(Buffer.byteLength(serializedAppend, "utf8")); - const isVerifiedOwnedAppend = (snapshot: SessionFileSnapshot | undefined) => - Boolean( - publishOwnedAppend && - beforeAppendSnapshot && - snapshot && - snapshot.dev === beforeAppendSnapshot.dev && - snapshot.ino === beforeAppendSnapshot.ino && - snapshot.size === beforeAppendSnapshot.size + appendedByteLength, - ); - if (!cacheOwnedAppend) { - sessionEntriesCache.delete(resolvedPath); - invalidateSessionFileRepairCache(resolvedPath); - const snapshot = readSessionFileSnapshotIfExists(resolvedPath); - return { - snapshot, - cacheAdvanced: false, - ownedAppendVerified: isVerifiedOwnedAppend(snapshot), - }; - } - if ( - !previousSnapshot || - !beforeAppendSnapshot || - !isSameSessionFileSnapshot(previousSnapshot, beforeAppendSnapshot) - ) { - sessionEntriesCache.delete(resolvedPath); - invalidateSessionFileRepairCache(resolvedPath); - return { - snapshot: readSessionFileSnapshotIfExists(resolvedPath), - cacheAdvanced: false, - ownedAppendVerified: false, - }; - } - - const cached = sessionEntriesCache.get(resolvedPath); - const snapshot = readSessionFileSnapshotIfExists(resolvedPath); - const expectedSize = beforeAppendSnapshot.size + appendedByteLength; - if ( - !snapshot || - !cached || - cached.snapshot.dev !== previousSnapshot.dev || - cached.snapshot.ino !== previousSnapshot.ino || - snapshot.dev !== beforeAppendSnapshot.dev || - snapshot.ino !== beforeAppendSnapshot.ino || - snapshot.size !== expectedSize || - !isSameSessionFileSnapshot(cached.snapshot, previousSnapshot) - ) { - sessionEntriesCache.delete(resolvedPath); - invalidateSessionFileRepairCache(resolvedPath); - return { snapshot, cacheAdvanced: false, ownedAppendVerified: false }; - } - if (invalidateSerializedPrefixCache) { - sessionEntriesCache.delete(resolvedPath); - invalidateSessionFileRepairCache(resolvedPath); - return { snapshot, cacheAdvanced: false, ownedAppendVerified: true }; - } - if (snapshot.size > MAX_CACHED_SESSION_BYTES) { - sessionEntriesCache.delete(resolvedPath); - return { snapshot, cacheAdvanced: false, ownedAppendVerified: true }; - } - - const persistedEntry = JSON.parse( - serializedAppend.startsWith("\n") ? serializedAppend.slice(1) : serializedAppend, - ) as FileEntry; - cached.entries.push(freezeFileEntry(normalizeLoadedFileEntry(persistedEntry))); - cached.snapshot = snapshot; - cached.endsWithNewline = true; - sessionEntriesCache.delete(resolvedPath); - sessionEntriesCache.set(resolvedPath, cached); - trimSessionEntriesCache(); - return { snapshot, cacheAdvanced: true, ownedAppendVerified: true }; -} - -export function publishRememberedSessionFileSnapshot( - filePath: string, - snapshot: SessionFileSnapshot | undefined, -): void { - if (!snapshot) { - return; - } - if (publishOwnedSessionFileSnapshot({ sessionFile: filePath, snapshot }) === false) { - sessionEntriesCache.delete(resolve(filePath)); - invalidateSessionFileRepairCache(filePath); - } -} - -export function jsonSerializationCanRunUserCode( - value: unknown, - ancestors = new Set(), -): boolean { - if (typeof value === "bigint") { - return Object.getOwnPropertyDescriptor(BigInt.prototype, "toJSON") !== undefined; - } - if ((typeof value !== "object" && typeof value !== "function") || value === null) { - return false; - } - - try { - if (isProxy(value) || ancestors.has(value)) { - return true; - } - const prototype = Object.getPrototypeOf(value); - if (prototype !== Object.prototype && prototype !== Array.prototype && prototype !== null) { - return true; - } - const descriptors = Object.getOwnPropertyDescriptors(value); - if ( - descriptors.toJSON || - (prototype !== null && Object.getOwnPropertyDescriptor(prototype, "toJSON")) || - Object.values(descriptors).some( - (descriptor) => descriptor.get !== undefined || descriptor.set !== undefined, - ) - ) { - return true; - } - - ancestors.add(value); - try { - if (Array.isArray(value)) { - for (let index = 0; index < value.length; index += 1) { - const descriptor = Object.getOwnPropertyDescriptor(value, String(index)); - if ( - !descriptor || - descriptor.get !== undefined || - descriptor.set !== undefined || - ("value" in descriptor && jsonSerializationCanRunUserCode(descriptor.value, ancestors)) - ) { - return true; - } - } - return false; - } - return Object.values(descriptors).some( - (descriptor) => - descriptor.enumerable && - "value" in descriptor && - jsonSerializationCanRunUserCode(descriptor.value, ancestors), - ); - } finally { - ancestors.delete(value); - } - } catch { - return true; - } -} - -function hasOwnProperty(value: object, key: string): boolean { - return Object.hasOwn(value, key); -} - -export function messageSerializesOwnedValues( - message: Message | CustomMessage | BashExecutionMessage, -): boolean { - if (message.role === "toolResult") { - return hasOwnProperty(message, "details"); - } - if (message.role === "assistant" && Array.isArray(message.content)) { - return message.content.some( - (part) => part.type === "toolCall" && hasOwnProperty(part, "arguments"), - ); - } - return message.role === "custom" && hasOwnProperty(message, "details"); -} - -export function readSessionFileSnapshotIfExists(filePath: string): SessionFileSnapshot | undefined { - try { - return readSessionFileSnapshot(filePath); - } catch { - return undefined; - } -} - -export function sessionFileNeedsAppendSeparator( - filePath: string, - snapshot: SessionFileSnapshot | undefined, -): boolean { - if (!snapshot || snapshot.size === 0n) { - return false; - } - const resolvedPath = resolve(filePath); - const cached = sessionEntriesCache.get(resolvedPath); - if (cached && isSameSessionFileSnapshot(cached.snapshot, snapshot)) { - return !cached.endsWithNewline; - } - const fileDescriptor = openSync(resolvedPath, "r"); - try { - const lastByte = Buffer.allocUnsafe(1); - const bytesRead = readSync(fileDescriptor, lastByte, 0, 1, snapshot.size - 1n); - return bytesRead === 1 && lastByte[0] !== 0x0a; - } finally { - closeSync(fileDescriptor); - } -} - -export function revalidateLoadedSessionFile( - filePath: string, - loaded: LoadedSessionFile, -): LoadedSessionFile { - const currentSnapshot = readSessionFileSnapshotIfExists(resolve(filePath)); - if ( - loaded.snapshot && - currentSnapshot && - isSameSessionFileSnapshot(loaded.snapshot, currentSnapshot) - ) { - return loaded; - } - if (!loaded.snapshot && !currentSnapshot) { - return loaded; - } - return loadEntriesFromFileWithSnapshot(filePath); -} - -export function loadSqliteMarkedSessionFile( - sessionFile: string, - loadEvents: (marker: SqliteSessionFileMarker) => FileEntry[], - options: { cwdOverride?: string; fallbackCwd?: string } = {}, -): LoadedSqliteSession | undefined { - const sqliteMarker = parseSqliteSessionFileMarker(sessionFile); - if (!sqliteMarker) { - return undefined; - } - const sessionKey = resolveTranscriptSessionKeyBySessionId(sqliteMarker); - if (!sessionKey) { - throw new Error(`Cannot open SQLite session without session entry: ${sqliteMarker.sessionId}`); - } - const entries = loadEvents(sqliteMarker); - const header = entries.find((entry) => isJsonRecord(entry) && entry.type === "session") as - | SessionHeader - | undefined; - return { - cwd: options.cwdOverride ?? header?.cwd ?? options.fallbackCwd ?? process.cwd(), - entries, - sessionKey, - sqliteMarker, - }; -} - -function copyFileEntries(entries: readonly FileEntry[]): FileEntry[] { - const copy = entries.slice(); - const header = copy.at(0); - if (header?.type === "session" && Object.isFrozen(header)) { - copy[0] = structuredClone(header); - } - return copy; -} - -function freezeFileEntry(entry: FileEntry): FileEntry { - freezeJsonLikeValue(entry); - return entry; -} - -function freezeJsonLikeValue(value: unknown, seen = new WeakSet()): void { - if (typeof value !== "object" || value === null || seen.has(value)) { - return; - } - seen.add(value); - for (const item of Array.isArray(value) - ? value - : Object.values(value as Record)) { - freezeJsonLikeValue(item, seen); - } - Object.freeze(value); -} - -export function recoverCorruptSessionEntries(filePath: string, cwd: string): FileEntry[] | null { - const content = readFileSync(filePath, "utf8"); - if (content.trim().length === 0) { - return null; - } - const parsedEntries = parseJsonlEntries(content); - const recoveredHeader = parsedEntries.find( - (entry): entry is SessionHeader => entry.type === "session" && typeof entry.id === "string", - ); - const header = - recoveredHeader ?? - ({ - type: "session", - version: CURRENT_SESSION_VERSION, - id: createSessionId(), - timestamp: new Date().toISOString(), - cwd, - } satisfies SessionHeader); - const recoveredEntries = parsedEntries.filter((entry) => entry.type !== "session"); - const timestamp = new Date().toISOString().replace(/[:.]/g, "-"); - const backupPath = `${filePath}.corrupt-${timestamp}-${randomUUID().slice(0, 8)}.jsonl`; - const backupMode = statSync(filePath).mode & 0o777; - writeFileSync(backupPath, content, { encoding: "utf8", mode: backupMode || 0o600 }); - chmodSync(backupPath, backupMode || 0o600); - return [header, ...recoveredEntries]; -} - -export function canPublishOwnedSessionAppend( - sessionFile: string, - snapshot: SessionFileSnapshot | undefined, -): boolean { - return Boolean(snapshot && canAdvanceOwnedSessionEntryCache({ sessionFile, snapshot })); -} -import { randomUUID } from "node:crypto"; diff --git a/src/agents/sessions/session-manager-persistence.ts b/src/agents/sessions/session-manager-persistence.ts index 39fd8c2e06b4..3f5025d3d7cf 100644 --- a/src/agents/sessions/session-manager-persistence.ts +++ b/src/agents/sessions/session-manager-persistence.ts @@ -2,10 +2,6 @@ import { appendTranscriptEventSync, appendTranscriptMessageSync, } from "../../config/sessions/session-accessor.js"; -import { - appendSerializedJsonlEntrySync, - serializeJsonlEntry, -} from "../../config/sessions/transcript-jsonl.js"; import { isSessionTranscriptSideAppendEntry } from "../../config/sessions/transcript-tree.js"; import { isIndexedSessionEntry, @@ -14,16 +10,6 @@ import { parseParentLinkedOpaqueEntry, } from "./session-manager-codec.js"; import { SessionManagerCore } from "./session-manager-core.js"; -import { - canPublishOwnedSessionAppend, - jsonSerializationCanRunUserCode, - publishRememberedSessionFileSnapshot, - readSessionFileSnapshotIfExists, - rememberAppendedSessionEntry, - rememberWrittenSessionEntries, - revalidateLoadedSessionFile, - sessionFileNeedsAppendSeparator, -} from "./session-manager-file.js"; import type { AppendPersistenceOptions, PromptReleasedSessionEntry, @@ -142,70 +128,9 @@ export class SessionManagerPersistence extends SessionManagerCore { return removedEntries.length; } - protected persistRecord( - entry: unknown, - options?: AppendPersistenceOptions, - publishSnapshot = true, - ): void { - if (this.sqlitePersistence) { + protected persistRecord(entry: unknown, options?: AppendPersistenceOptions): void { + if (this.persistenceTarget) { this.persistSqliteRecord(entry, options); - return; - } - if (!this.shouldPersist || !this.sessionFile) { - return; - } - - const hasAssistant = this.fileEntries.some( - (fileEntry) => fileEntry.type === "message" && fileEntry.message.role === "assistant", - ); - if (!hasAssistant) { - this.flushed = false; - return; - } - - if (!this.flushed) { - const content = this.writeFullFile(); - this.flushed = true; - const rememberedWrite = rememberWrittenSessionEntries(this.sessionFile, content); - this.sessionFileSnapshot = rememberedWrite.snapshot; - if (rememberedWrite.verifiedWrite && publishSnapshot) { - publishRememberedSessionFileSnapshot(this.sessionFile, rememberedWrite.snapshot); - } - return; - } - - const serializationCanRunUserCode = jsonSerializationCanRunUserCode(entry); - const serializedEntry = serializeJsonlEntry(entry); - const beforeAppendSnapshot = readSessionFileSnapshotIfExists(this.sessionFile); - const invalidateSerializedPrefixCache = - options?.invalidateSerializedPrefixCache === true || serializationCanRunUserCode; - const canPublishOwnedAppend = - !serializationCanRunUserCode && - canPublishOwnedSessionAppend(this.sessionFile, beforeAppendSnapshot); - const cacheOwnedAppend = canPublishOwnedAppend && !invalidateSerializedPrefixCache; - const serializedAppend = appendSerializedJsonlEntrySync(this.sessionFile, serializedEntry, { - prefixNewline: sessionFileNeedsAppendSeparator(this.sessionFile, beforeAppendSnapshot), - }); - const rememberedAppend = rememberAppendedSessionEntry({ - filePath: this.sessionFile, - previousSnapshot: this.sessionFileSnapshot, - beforeAppendSnapshot, - serializedAppend, - cacheOwnedAppend, - publishOwnedAppend: canPublishOwnedAppend, - invalidateSerializedPrefixCache, - }); - this.sessionFileSnapshot = rememberedAppend.snapshot; - if (rememberedAppend.ownedAppendVerified && publishSnapshot) { - publishRememberedSessionFileSnapshot(this.sessionFile, rememberedAppend.snapshot); - } else if (cacheOwnedAppend) { - this.setLoadedSessionFile( - this.sessionFile, - revalidateLoadedSessionFile(this.sessionFile, { - entries: this.fileEntries, - snapshot: beforeAppendSnapshot, - }), - ); } } @@ -214,17 +139,31 @@ export class SessionManagerPersistence extends SessionManagerCore { } private persistSqliteRecord(entry: unknown, options?: AppendPersistenceOptions): void { - if (!isIndexedSessionEntry(entry) || !this.sqlitePersistence) { + if (!this.persistenceTarget) { + return; + } + const scope = this.persistenceTarget; + if (this.persistenceHeaderPending) { + const header = this.fileEntries[0]; + if (!header || header.type !== "session" || !appendTranscriptEventSync(scope, header)) { + throw new Error("Session transcript header was not persisted"); + } + this.persistenceHeaderPending = false; + } + const leafEntry = parseOpaqueLeafEntry(entry); + if (leafEntry) { + if (!appendTranscriptEventSync(scope, entry)) { + throw new Error(`Session transcript leaf control was not persisted: ${leafEntry.id}`); + } + return; + } + if (!isIndexedSessionEntry(entry)) { return; } - const scope = { - agentId: this.sqlitePersistence.agentId, - sessionId: this.sqlitePersistence.sessionId, - sessionKey: this.sqlitePersistence.sessionKey, - storePath: this.sqlitePersistence.storePath, - }; if (entry.type !== "message") { - appendTranscriptEventSync(scope, entry); + if (!appendTranscriptEventSync(scope, entry)) { + throw new Error(`Session transcript entry was not persisted: ${entry.id}`); + } return; } const appendOptions = { @@ -246,7 +185,10 @@ export class SessionManagerPersistence extends SessionManagerCore { idempotencyLookup: "caller-checked", }); } - if (result && result.messageId !== entry.id) { + if (!result) { + throw new Error(`Session transcript message was not persisted: ${entry.id}`); + } + if (result.messageId !== entry.id) { throw new Error(`Session transcript parent entry was not persisted: ${entry.id}`); } if ( @@ -257,17 +199,6 @@ export class SessionManagerPersistence extends SessionManagerCore { } } - syncSnapshotAfterHeaderRewrite(expectedContent?: string): void { - if (!this.sessionFile) { - return; - } - const rememberedWrite = rememberWrittenSessionEntries(this.sessionFile, expectedContent); - this.sessionFileSnapshot = rememberedWrite.snapshot; - if (rememberedWrite.verifiedWrite) { - publishRememberedSessionFileSnapshot(this.sessionFile, rememberedWrite.snapshot); - } - } - mergePromptReleasedSessionEntries( entries: readonly PromptReleasedSessionEntry[], options?: { persistLeaf?: boolean }, @@ -351,13 +282,9 @@ export class SessionManagerPersistence extends SessionManagerCore { } } this.promptReleasedSideBranchParentId = sideBranchParentId; - if (this.sessionFile) { - this.sessionFileSnapshot = readSessionFileSnapshotIfExists(this.sessionFile); - } if ( options?.persistLeaf !== true || - !this.shouldPersist || - !this.sessionFile || + !this.persistenceTarget || !sawPersistedStateUpdate || (persistedLeafId === this.leafId && persistedAppendParentId === sideBranchParentId && @@ -366,47 +293,12 @@ export class SessionManagerPersistence extends SessionManagerCore { return undefined; } - const hasAssistant = this.fileEntries.some( - (entry) => entry.type === "message" && entry.message.role === "assistant", - ); - if (this.sqlitePersistence) { - const leafEntry = this.createLeafControl(rawTailId, sideBranchParentId, "side"); - appendTranscriptEventSync( - { - agentId: this.sqlitePersistence.agentId, - sessionId: this.sqlitePersistence.sessionId, - sessionKey: this.sqlitePersistence.sessionKey, - storePath: this.sqlitePersistence.storePath, - }, - leafEntry, - ); - this.rememberLeafControl(leafEntry); - this.flushed = true; - return { publishedEntries: [{ kind: "id", id: leafEntry.id }] }; - } - if (!this.flushed || !hasAssistant) { - this.replacePersistedTranscript({ - publishSnapshot: false, - leafAppendParentId: sideBranchParentId, - leafAppendMode: "side", - }); - this.flushed = true; - if (!this.sessionFileSnapshot) { - throw new Error(`Unable to snapshot restored session file: ${this.sessionFile}`); - } - return { sessionFileSnapshot: this.sessionFileSnapshot, requiresReload: true }; - } - const leafEntry = this.createLeafControl(rawTailId, sideBranchParentId, "side"); - this.persistRecord(leafEntry, undefined, false); + this.persistRecord(leafEntry); this.rememberLeafControl(leafEntry); - if (!this.sessionFileSnapshot) { - throw new Error(`Unable to snapshot restored session file: ${this.sessionFile}`); - } - return { - sessionFileSnapshot: this.sessionFileSnapshot, - publishedEntries: [{ kind: "id", id: leafEntry.id }], - }; + this.appendParentId = sideBranchParentId; + this.appendMode = "side"; + return { publishedEntries: [{ kind: "id", id: leafEntry.id }] }; } private assertPromptReleasedEntriesPreserveActiveLeaf( diff --git a/src/agents/sessions/session-manager-types.ts b/src/agents/sessions/session-manager-types.ts index 639ced374cd7..6686626314e5 100644 --- a/src/agents/sessions/session-manager-types.ts +++ b/src/agents/sessions/session-manager-types.ts @@ -1,7 +1,4 @@ -import type { - OwnedSessionTranscriptCacheSnapshot, - OwnedSessionTranscriptPublishedEntry, -} from "../../config/sessions/transcript-write-context.js"; +import type { OwnedSessionTranscriptPublishedEntry } from "../../config/sessions/transcript-write-context.js"; import type { OpenClawConfig } from "../../config/types.openclaw.js"; import type { ImageContent, TextContent } from "../../llm/types.js"; import type { AgentMessage } from "../runtime/index.js"; @@ -148,13 +145,10 @@ export type PromptReleasedSessionEntry = | PromptReleasedOpaqueEntry; export type PromptReleasedSessionMergeResult = { - sessionFileSnapshot?: OwnedSessionTranscriptCacheSnapshot; publishedEntries?: readonly OwnedSessionTranscriptPublishedEntry[]; requiresReload?: true; }; -export type SessionFileSnapshot = OwnedSessionTranscriptCacheSnapshot; - export type PreservedOpaqueFileEntry = { index: number; record: unknown; diff --git a/src/agents/sessions/session-manager.persistence-compat.test.ts b/src/agents/sessions/session-manager.persistence-compat.test.ts new file mode 100644 index 000000000000..008713bbcdca --- /dev/null +++ b/src/agents/sessions/session-manager.persistence-compat.test.ts @@ -0,0 +1,151 @@ +// Focused persistence compatibility tests kept separate from the session tree suite. +import fs from "node:fs/promises"; +import os from "node:os"; +import path from "node:path"; +import "../../../test/helpers/session-manager-file-compat.js"; +import { afterEach, describe, expect, it } from "vitest"; +import { + formatSqliteSessionFileMarker, + parseSqliteSessionFileMarker, +} from "../../config/sessions/legacy-sqlite-marker.js"; +import { + appendTranscriptMessage, + loadTranscriptEvents, + upsertSessionEntry, +} from "../../config/sessions/session-accessor.js"; +import { CURRENT_SESSION_VERSION, SessionManager } from "./session-manager.js"; + +const tempPaths: string[] = []; + +async function makeTempDir(): Promise { + const dir = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-session-manager-compat-")); + tempPaths.push(dir); + return dir; +} + +function buildAssistantMessage(text: string) { + return { + role: "assistant" as const, + content: [{ type: "text" as const, text }], + api: "messages" as const, + provider: "anthropic" as const, + model: "sonnet-4.6" as const, + usage: { + input: 0, + output: 0, + cacheRead: 0, + cacheWrite: 0, + totalTokens: 0, + cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 }, + }, + stopReason: "stop" as const, + timestamp: Date.now(), + }; +} + +describe("SessionManager persistence compatibility", () => { + afterEach(async () => { + await Promise.all( + tempPaths.splice(0).map((dir) => fs.rm(dir, { recursive: true, force: true })), + ); + }); + + it("rewrites SQLite transcript rows when removing trailing entries", async () => { + const dir = await makeTempDir(); + const storePath = path.join(dir, "sessions.json"); + const sessionId = "sqlite-remove-trailing-session"; + const sessionKey = "agent:main:dashboard:sqlite-remove-trailing"; + const marker = formatSqliteSessionFileMarker({ agentId: "main", sessionId, storePath }); + const scope = { agentId: "main", sessionId, sessionKey, storePath }; + await upsertSessionEntry( + { agentId: "main", sessionKey, storePath }, + { sessionFile: marker, sessionId, updatedAt: 10 }, + ); + const user = await appendTranscriptMessage(scope, { + cwd: dir, + eventId: "user-message", + message: { role: "user", content: "question" }, + }); + const baseAnswer = await appendTranscriptMessage(scope, { + cwd: dir, + eventId: "base-answer", + message: buildAssistantMessage("base answer"), + parentId: user.messageId, + }); + const temporaryError = await appendTranscriptMessage(scope, { + cwd: dir, + eventId: "temporary-error", + message: buildAssistantMessage("temporary error"), + parentId: baseAnswer.messageId, + }); + const target = parseSqliteSessionFileMarker(marker); + if (!target) { + throw new Error("expected SQLite transcript marker fixture"); + } + const manager = SessionManager.open({ ...target, sessionKey }, dir); + + expect(manager.removeTrailingEntries((entry) => entry.id === temporaryError.messageId)).toBe(1); + expect(manager.getLeafId()).toBe(baseAnswer.messageId); + const replacementId = manager.appendMessage(buildAssistantMessage("replacement answer")); + const records = await loadTranscriptEvents(scope); + + expect( + records.map((record) => + record && typeof record === "object" && "id" in record ? record.id : undefined, + ), + ).not.toContain(temporaryError.messageId); + expect(records).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + id: replacementId, + message: expect.objectContaining({ + content: [{ type: "text", text: "replacement answer" }], + role: "assistant", + }), + parentId: baseAnswer.messageId, + type: "message", + }), + ]), + ); + await expect(fs.stat(path.join(process.cwd(), marker))).rejects.toMatchObject({ + code: "ENOENT", + }); + }); + + it("separates appended records from a final unterminated JSONL record", async () => { + const dir = await makeTempDir(); + const sessionFile = path.join(dir, "unterminated.jsonl"); + await fs.writeFile( + sessionFile, + JSON.stringify({ + type: "session", + version: CURRENT_SESSION_VERSION, + id: "unterminated", + timestamp: "2026-01-01T00:00:00.000Z", + cwd: dir, + }), + ); + SessionManager.openFile(sessionFile, dir).appendMessage({ + role: "user", + content: "appended", + timestamp: 1, + }); + expect(SessionManager.openFile(sessionFile, dir).buildSessionContext().messages).toEqual([ + expect.objectContaining({ content: "appended", role: "user" }), + ]); + }); + + it("rotates new-session fixtures without rewriting the previous file", async () => { + const dir = await makeTempDir(); + const sessionFile = path.join(dir, "original.jsonl"); + const manager = SessionManager.openFile(sessionFile, dir); + manager.appendMessage({ role: "user", content: "original", timestamp: 1 }); + const original = await fs.readFile(sessionFile, "utf8"); + manager.newSession({ id: "replacement" }); + expect(await fs.readFile(sessionFile, "utf8")).toBe(original); + expect(manager.getSessionFile()).toBe(path.join(dir, "replacement.jsonl")); + expect(await fs.readFile(path.join(dir, "replacement.jsonl"), "utf8")).toContain( + '"id":"replacement"', + ); + }); +}); diff --git a/src/agents/sessions/session-manager.test-compat.d.ts b/src/agents/sessions/session-manager.test-compat.d.ts new file mode 100644 index 000000000000..5cf419bb03ae --- /dev/null +++ b/src/agents/sessions/session-manager.test-compat.d.ts @@ -0,0 +1,17 @@ +import type { SessionManager as SessionManagerInstance } from "./session-manager.js"; + +declare module "./session-manager.js" { + interface SessionManager { + getSessionDir(): string; + getSessionFile(): string | undefined; + } + + namespace SessionManager { + function create(cwd: string, sessionDir?: string): SessionManagerInstance; + function openFile( + path: string, + sessionDir?: string, + cwdOverride?: string, + ): SessionManagerInstance; + } +} diff --git a/src/agents/sessions/session-manager.test.ts b/src/agents/sessions/session-manager.test.ts index f5236d1e1f82..3ce1f40c29b1 100644 --- a/src/agents/sessions/session-manager.test.ts +++ b/src/agents/sessions/session-manager.test.ts @@ -1,10 +1,13 @@ -// Session manager tests cover JSONL recovery behavior for interrupted or -// corrupted transcript writes. -import { writeFileSync } from "node:fs"; +// Session manager tests cover SQLite persistence and in-memory tree behavior. import fs from "node:fs/promises"; import os from "node:os"; import path from "node:path"; -import { afterEach, describe, expect, it, vi } from "vitest"; +import "../../../test/helpers/session-manager-file-compat.js"; +import { afterEach, describe, expect, it } from "vitest"; +import { + formatSqliteSessionFileMarker, + parseSqliteSessionFileMarker, +} from "../../config/sessions/legacy-sqlite-marker.js"; import { appendTranscriptMessage, loadSessionEntry, @@ -13,26 +16,23 @@ import { replaceTranscriptEventsSync, upsertSessionEntry, } from "../../config/sessions/session-accessor.js"; -import { formatSqliteSessionFileMarker } from "../../config/sessions/sqlite-marker.js"; -import { withOwnedSessionTranscriptWrites } from "../../config/sessions/transcript-write-context.js"; -import * as Logger from "../../logger.js"; -import { isTranscriptOnlyOpenClawAssistantMessage } from "../../shared/transcript-only-openclaw-assistant.js"; -import { prepareSessionManagerForRun } from "../embedded-agent-runner/session-manager-init.js"; -import { repairSessionFileIfNeeded } from "../session-file-repair.js"; -import { loadSqliteMarkedSessionFile } from "./session-manager-file.js"; import { buildSessionContext, CURRENT_SESSION_VERSION, - loadEntriesFromFile, - parseSessionEntries, SessionManager, - type FileEntry, - type SessionEntry, type SessionMessageEntry, } from "./session-manager.js"; const tempPaths: string[] = []; +function openMarker(marker: string, sessionKey: string, cwd: string): SessionManager { + const target = parseSqliteSessionFileMarker(marker); + if (!target) { + throw new Error("expected SQLite transcript marker fixture"); + } + return SessionManager.open({ ...target, sessionKey }, cwd); +} + async function makeTempDir(): Promise { const dir = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-session-manager-")); tempPaths.push(dir); @@ -46,24 +46,6 @@ describe("SessionManager.open", () => { ); }); - it("flushes a pending initial file transcript before later appends", async () => { - const dir = await makeTempDir(); - const sessionFile = path.join(dir, "pending-session.jsonl"); - const sessionManager = SessionManager.open(sessionFile, dir, dir); - - sessionManager.appendMessage({ role: "user", content: "question", timestamp: Date.now() }); - await expect(fs.stat(sessionFile)).rejects.toMatchObject({ code: "ENOENT" }); - - sessionManager.flushPendingPersistence(); - sessionManager.appendMessage(buildAssistantMessage("answer")); - - expect( - loadEntriesFromFile(sessionFile) - .filter((entry) => entry.type === "message") - .map((entry) => ("content" in entry.message ? entry.message.content : undefined)), - ).toEqual(["question", [{ type: "text", text: "answer" }]]); - }); - it("opens SQLite markers without creating marker-named files and persists assistant replies", async () => { const dir = await makeTempDir(); const storePath = path.join(dir, "sessions.json"); @@ -90,7 +72,7 @@ describe("SessionManager.open", () => { }, ); - const sessionManager = SessionManager.open(marker, dir, dir); + const sessionManager = openMarker(marker, sessionKey, dir); expect(sessionManager.buildSessionContext().messages).toEqual([ expect.objectContaining({ content: "question", role: "user" }), ]); @@ -168,7 +150,7 @@ describe("SessionManager.open", () => { type: "reset", }), ]); - const reopened = SessionManager.open(marker, dir, dir); + const reopened = openMarker(marker, sessionKey, dir); expect(reopened.getEntries()).toEqual( expect.arrayContaining([ expect.objectContaining({ id: thinkingChangeId, type: "thinking_level_change" }), @@ -179,6 +161,333 @@ describe("SessionManager.open", () => { ); }); + it("rejects persisted legacy transcripts until doctor or import migrates them", async () => { + const dir = await makeTempDir(); + const storePath = path.join(dir, "sessions.json"); + const sessionId = "legacy-persisted-session"; + const sessionKey = "agent:main:legacy-persisted-session"; + const scope = { agentId: "main", sessionId, sessionKey, storePath }; + await upsertSessionEntry(scope, { sessionId, updatedAt: 1 }); + replaceTranscriptEventsSync(scope, [ + { + type: "session", + version: 1, + id: sessionId, + timestamp: "2026-01-01T00:00:00.000Z", + cwd: dir, + }, + { + type: "message", + message: { role: "user", content: "legacy message" }, + }, + ]); + + expect(() => SessionManager.open(scope, dir)).toThrow( + "require doctor/import migration before runtime use", + ); + const existingManager = SessionManager.inMemory("/original-workspace"); + expect(() => existingManager.setSessionTarget(scope)).toThrow( + "require doctor/import migration before runtime use", + ); + expect(existingManager.getCwd()).toBe("/original-workspace"); + + const currentScope = { + agentId: "main", + sessionId: "current-persisted-session", + sessionKey: "agent:main:current-persisted-session", + storePath, + }; + await upsertSessionEntry(currentScope, { sessionId: currentScope.sessionId, updatedAt: 2 }); + const currentManager = SessionManager.open(currentScope, dir); + expect(() => currentManager.setSessionTarget(scope)).toThrow( + "require doctor/import migration before runtime use", + ); + currentManager.appendModelChange("test-provider", "test-model"); + await expect(loadTranscriptEvents(currentScope)).resolves.toEqual([ + expect.objectContaining({ + type: "session", + version: CURRENT_SESSION_VERSION, + id: currentScope.sessionId, + }), + expect.objectContaining({ type: "model_change" }), + ]); + }); + + it("skips malformed null rows while opening a persisted transcript", async () => { + const dir = await makeTempDir(); + const scope = { + agentId: "main", + sessionId: "sqlite-malformed-row", + sessionKey: "agent:main:dashboard:sqlite-malformed-row", + storePath: path.join(dir, "sessions.json"), + }; + replaceTranscriptEventsSync(scope, [ + null, + { + type: "session", + version: CURRENT_SESSION_VERSION, + id: scope.sessionId, + timestamp: "2026-01-01T00:00:00.000Z", + cwd: dir, + }, + ] as never); + + const manager = SessionManager.open(scope, dir); + expect(manager.getSessionId()).toBe(scope.sessionId); + expect(manager.getHeader()?.cwd).toBe(dir); + }); + + it("persists explicit leaf controls across SQLite reopen", async () => { + const dir = await makeTempDir(); + const scope = { + agentId: "main", + sessionId: "sqlite-leaf-control", + sessionKey: "agent:main:dashboard:sqlite-leaf-control", + storePath: path.join(dir, "sessions.json"), + }; + await upsertSessionEntry(scope, { + sessionId: scope.sessionId, + updatedAt: 1, + }); + const manager = SessionManager.open(scope, dir); + const firstId = manager.appendMessage({ role: "user", content: "first", timestamp: 1 }); + const secondId = manager.appendMessage({ role: "user", content: "second", timestamp: 2 }); + + manager.appendLeafControl({ + targetId: firstId, + appendParentId: secondId, + appendMode: "side", + }); + await expect(loadTranscriptEvents(scope)).resolves.toEqual( + expect.arrayContaining([ + expect.objectContaining({ + type: "leaf", + targetId: firstId, + appendParentId: secondId, + appendMode: "side", + }), + ]), + ); + + const reopened = SessionManager.open(scope, dir); + expect(reopened.getLeafId()).toBe(firstId); + expect(reopened.getAppendParentId()).toBe(secondId); + expect(reopened.getAppendMode()).toBe("side"); + }); + + it("persists the current header before a first non-message entry", async () => { + const dir = await makeTempDir(); + const scope = { + agentId: "main", + sessionId: "sqlite-model-change-first", + sessionKey: "agent:main:dashboard:sqlite-model-change-first", + storePath: path.join(dir, "sessions.json"), + }; + await upsertSessionEntry(scope, { + sessionId: scope.sessionId, + updatedAt: 1, + }); + + const manager = SessionManager.open(scope, dir); + manager.appendModelChange("test-provider", "test-model"); + + await expect(loadTranscriptEvents(scope)).resolves.toEqual([ + expect.objectContaining({ + type: "session", + version: CURRENT_SESSION_VERSION, + id: scope.sessionId, + cwd: dir, + }), + expect.objectContaining({ + type: "model_change", + provider: "test-provider", + modelId: "test-model", + }), + ]); + expect(() => SessionManager.open(scope, dir)).not.toThrow(); + }); + + it("rejects invalid entries before mutating in-memory state", () => { + const manager = SessionManager.inMemory("/tmp"); + const entriesBefore = manager.getEntries(); + + expect(() => manager.appendModelChange("", "")).toThrow("Invalid session transcript entry"); + expect(manager.getEntries()).toEqual(entriesBefore); + expect(manager.getLeafId()).toBeNull(); + expect(manager.getAppendParentId()).toBeNull(); + }); + + it("uses the selected logical leaf immediately after a side append control", () => { + const manager = SessionManager.inMemory("/tmp"); + const firstId = manager.appendMessage({ role: "user", content: "first", timestamp: 1 }); + const secondId = manager.appendMessage({ role: "user", content: "second", timestamp: 2 }); + const control = manager.appendLeafControl({ + targetId: firstId, + appendParentId: secondId, + appendMode: "side", + }); + + const thirdId = manager.appendMessage({ role: "user", content: "third", timestamp: 3 }); + + expect(manager.getBranch().map((entry) => entry.id)).toEqual([firstId, thirdId]); + manager.branch(control.id); + expect(manager.getLeafId()).toBe(firstId); + expect(() => + manager.appendLeafControl({ + targetId: thirdId, + appendParentId: "missing-parent", + }), + ).toThrow("Append parent missing-parent not found"); + }); + + it("refreshes cwd when switching persisted targets and rejects identity reset", async () => { + const dir = await makeTempDir(); + const storePath = path.join(dir, "sessions.json"); + const firstTarget = { + agentId: "main", + sessionId: "first-target", + sessionKey: "agent:main:first-target", + storePath, + }; + const secondTarget = { + agentId: "main", + sessionId: "second-target", + sessionKey: "agent:main:second-target", + storePath, + }; + await upsertSessionEntry(firstTarget, { sessionId: firstTarget.sessionId, updatedAt: 1 }); + await upsertSessionEntry(secondTarget, { sessionId: secondTarget.sessionId, updatedAt: 1 }); + await appendTranscriptMessage(firstTarget, { + cwd: path.join(dir, "first-workspace"), + message: { role: "user", content: "first" }, + }); + await appendTranscriptMessage(secondTarget, { + cwd: path.join(dir, "second-workspace"), + message: { role: "user", content: "second" }, + }); + + const manager = SessionManager.open(firstTarget); + manager.setSessionTarget(secondTarget); + + expect(manager.getCwd()).toBe(path.join(dir, "second-workspace")); + expect(() => manager.newSession()).toThrow( + "Persisted session managers cannot change session identity in place", + ); + }); + + it("reloads prompt-time SQLite appends before the attempt resumes", async () => { + const dir = await makeTempDir(); + const target = { + agentId: "main", + sessionId: "prompt-reload", + sessionKey: "agent:main:prompt-reload", + storePath: path.join(dir, "sessions.json"), + }; + await upsertSessionEntry(target, { sessionId: target.sessionId, updatedAt: 1 }); + const manager = SessionManager.open(target, dir); + const firstId = manager.appendMessage({ role: "user", content: "first", timestamp: 1 }); + const external = await appendTranscriptMessage(target, { + message: { role: "user", content: "prompt-time", timestamp: 2 }, + now: 2, + parentId: firstId, + }); + + expect(manager.getLeafId()).toBe(firstId); + manager.reloadPersistedTranscript(); + + expect(manager.getLeafId()).toBe(external.messageId); + }); + + it("clears side-append mode when switching to a header-only target", async () => { + const dir = await makeTempDir(); + const storePath = path.join(dir, "sessions.json"); + const firstTarget = { + agentId: "main", + sessionId: "side-target", + sessionKey: "agent:main:side-target", + storePath, + }; + const secondTarget = { + agentId: "main", + sessionId: "header-target", + sessionKey: "agent:main:header-target", + storePath, + }; + await upsertSessionEntry(firstTarget, { sessionId: firstTarget.sessionId, updatedAt: 1 }); + await upsertSessionEntry(secondTarget, { sessionId: secondTarget.sessionId, updatedAt: 1 }); + const manager = SessionManager.open(firstTarget, dir); + const firstId = manager.appendMessage({ role: "user", content: "first", timestamp: 1 }); + manager.appendLeafControl({ targetId: firstId, appendParentId: firstId, appendMode: "side" }); + replaceTranscriptEventsSync(secondTarget, [ + { + type: "session", + version: CURRENT_SESSION_VERSION, + id: secondTarget.sessionId, + timestamp: "2026-01-01T00:00:00.000Z", + cwd: dir, + }, + ]); + + manager.setSessionTarget(secondTarget); + + expect(manager.getAppendMode()).toBeUndefined(); + }); + + it("migrates version-two hook messages before current-role validation", () => { + const manager = SessionManager.fromEntries([ + { + type: "session", + version: 2, + id: "legacy-hook-session", + timestamp: "2026-01-01T00:00:00.000Z", + cwd: "/tmp", + }, + { + type: "message", + id: "legacy-hook-message", + parentId: null, + timestamp: "2026-01-01T00:00:01.000Z", + message: { + role: "hookMessage", + content: "legacy hook context", + }, + }, + ]); + + expect(manager.getEntry("legacy-hook-message")).toMatchObject({ + message: { role: "custom", customType: "hook", content: "legacy hook context" }, + }); + }); + + it("does not mutate frozen caller entries during in-memory migration", () => { + const entries = [ + Object.freeze({ + type: "session" as const, + version: 2, + id: "frozen-legacy-session", + timestamp: "2026-01-01T00:00:00.000Z", + cwd: "/tmp", + }), + Object.freeze({ + type: "message" as const, + id: "frozen-legacy-hook", + parentId: null, + timestamp: "2026-01-01T00:00:01.000Z", + message: Object.freeze({ role: "hookMessage", content: "frozen hook context" }), + }), + ] as const; + + const manager = SessionManager.fromEntries(Object.freeze(entries)); + + expect(manager.getEntry("frozen-legacy-hook")).toMatchObject({ + message: { role: "custom", customType: "hook", content: "frozen hook context" }, + }); + expect(entries[1].message).toEqual({ + role: "hookMessage", + content: "frozen hook context", + }); + }); + it("keeps stale appenders valid across a reset while snapshot replacement rotates generation", async () => { const dir = await makeTempDir(); const scope = { @@ -204,8 +513,8 @@ describe("SessionManager.open", () => { throw new Error("expected initial raw cursor page"); } - const staleManager = SessionManager.open(marker, dir, dir); - const resetManager = SessionManager.open(marker, dir, dir); + const staleManager = openMarker(marker, scope.sessionKey, dir); + const resetManager = openMarker(marker, scope.sessionKey, dir); resetManager.appendResetBoundary("reset"); expect(() => staleManager.appendMessage({ @@ -231,7 +540,7 @@ describe("SessionManager.open", () => { expect(resumed.kind).toBe("page"); const events = await loadTranscriptEvents(scope); expect(events.map((event) => (event as { type?: unknown }).type)).toContain("reset"); - const context = JSON.stringify(SessionManager.open(marker, dir, dir).buildSessionContext()); + const context = JSON.stringify(openMarker(marker, scope.sessionKey, dir).buildSessionContext()); expect(context).not.toContain("before reset"); expect(context).toContain("late append"); @@ -268,7 +577,7 @@ describe("SessionManager.open", () => { throw new Error(`expected bootstrap page, got ${bootstrap.kind}`); } - const sessionManager = SessionManager.open(marker, dir, dir); + const sessionManager = openMarker(marker, sessionKey, dir); const runtimeUserId = sessionManager.appendMessage(userMessage); const assistantId = sessionManager.appendMessage(buildAssistantMessage("answer")); const resumed = readTranscriptRawDelta(scope, { @@ -360,8 +669,8 @@ describe("SessionManager.open", () => { { sessionFile: marker, sessionId, updatedAt: 10 }, ); - const loaded = loadSqliteMarkedSessionFile(marker, () => [ - null as unknown as FileEntry, + const loaded = SessionManager.fromEntries([ + null, { type: "session", version: CURRENT_SESSION_VERSION, @@ -371,7 +680,7 @@ describe("SessionManager.open", () => { }, ]); - expect(loaded?.cwd).toBe(dir); + expect(loaded.getCwd()).toBe(dir); }); it("persists prompt-released leaf controls through SQLite markers", async () => { @@ -404,7 +713,7 @@ describe("SessionManager.open", () => { message: buildAssistantMessage("base answer"), parentId: user.messageId, }); - const sessionManager = SessionManager.open(marker, dir, dir); + const sessionManager = openMarker(marker, sessionKey, dir); const sideEntry = { type: "message" as const, id: "side-delivery", @@ -432,16 +741,79 @@ describe("SessionManager.open", () => { appendParentId: sideEntry.id, appendMode: "side", }); + expect(sessionManager.getAppendParentId()).toBe(sideEntry.id); + expect(sessionManager.getAppendMode()).toBe("side"); + const reopened = openMarker(marker, sessionKey, dir); + expect(reopened.getAppendParentId()).toBe(sideEntry.id); + expect(reopened.getAppendMode()).toBe("side"); await expect(fs.stat(path.join(process.cwd(), marker))).rejects.toMatchObject({ code: "ENOENT", }); }); + it("rejects a prompt-released leaf control after the session target rebounds", async () => { + const dir = await makeTempDir(); + const storePath = path.join(dir, "sessions.json"); + const sessionId = "sqlite-prompt-release-rebound"; + const sessionKey = "agent:main:dashboard:sqlite-prompt-release-rebound"; + const marker = formatSqliteSessionFileMarker({ agentId: "main", sessionId, storePath }); + const scope = { agentId: "main", sessionId, sessionKey, storePath }; + await upsertSessionEntry(scope, { sessionFile: marker, sessionId, updatedAt: 10 }); + const user = await appendTranscriptMessage(scope, { + cwd: dir, + eventId: "rebound-user", + message: { role: "user", content: "question", timestamp: 1 }, + }); + const assistant = await appendTranscriptMessage(scope, { + cwd: dir, + eventId: "rebound-assistant", + message: buildAssistantMessage("answer"), + parentId: user.messageId, + }); + const sessionManager = openMarker(marker, sessionKey, dir); + const sideEntry = { + type: "message" as const, + id: "rebound-side-delivery", + parentId: assistant.messageId, + timestamp: "2026-07-26T00:00:00.000Z", + message: buildAssistantMessage("side delivery"), + }; + await appendTranscriptMessage(scope, { + cwd: dir, + eventId: sideEntry.id, + message: sideEntry.message, + parentId: sideEntry.parentId, + }); + await upsertSessionEntry( + { agentId: "main", sessionKey, storePath }, + { sessionId: "replacement-session", updatedAt: 20 }, + ); + + expect(() => + sessionManager.mergePromptReleasedSessionEntries([sideEntry], { persistLeaf: true }), + ).toThrow("leaf control was not persisted"); + const entriesBeforeRejectedAppends = sessionManager.getEntries(); + const leafBeforeRejectedAppends = sessionManager.getLeafId(); + const appendParentBeforeRejectedAppends = sessionManager.getAppendParentId(); + expect(() => sessionManager.branchWithSummary(null, "late summary")).toThrow( + "entry was not persisted", + ); + expect(() => sessionManager.appendModelChange("openai", "gpt-5.5")).toThrow( + "entry was not persisted", + ); + expect(() => + sessionManager.appendMessage({ role: "user", content: "late message", timestamp: 1 }), + ).toThrow("message was not persisted"); + expect(sessionManager.getEntries()).toEqual(entriesBeforeRejectedAppends); + expect(sessionManager.getLeafId()).toBe(leafBeforeRejectedAppends); + expect(sessionManager.getAppendParentId()).toBe(appendParentBeforeRejectedAppends); + }); + it("reloads SQLite markers through setSessionFile without switching to file paths", async () => { const dir = await makeTempDir(); const storePath = path.join(dir, "sessions.json"); - const sessionId = "sqlite-marker-reload"; - const sessionKey = "agent:main:dashboard:sqlite-marker-reload"; + const sessionId = "legacy-sqlite-marker-reload"; + const sessionKey = "agent:main:dashboard:legacy-sqlite-marker-reload"; const marker = formatSqliteSessionFileMarker({ agentId: "main", sessionId, @@ -462,8 +834,8 @@ describe("SessionManager.open", () => { message: { role: "user", content: "question before reload" }, }); - const sessionManager = SessionManager.open(marker, dir, dir); - sessionManager.setSessionFile(marker); + const sessionManager = openMarker(marker, sessionKey, dir); + sessionManager.setSessionTarget(scope); expect(sessionManager.buildSessionContext().messages).toEqual([ expect.objectContaining({ content: "question before reload", role: "user" }), ]); @@ -520,20 +892,16 @@ describe("SessionManager.open", () => { parentId: user.messageId, }); - const sessionManager = SessionManager.open(marker, dir, dir); + const sessionManager = openMarker(marker, sessionKey, dir); const branchedMarker = sessionManager.createBranchedSession(assistant.messageId); const branchedSessionId = sessionManager.getSessionId(); - expect(branchedMarker).toContain(`sqlite:main:${branchedSessionId}:`); + expect(branchedMarker).toBe(branchedSessionId); expect(branchedSessionId).not.toBe(sessionId); expect(loadSessionEntry({ agentId: "main", sessionKey, storePath })).toMatchObject({ delivery: { kind: "internal" }, - sessionFile: branchedMarker, sessionId: branchedSessionId, }); - await expect(fs.stat(path.join(process.cwd(), branchedMarker!))).rejects.toMatchObject({ - code: "ENOENT", - }); await expect(loadTranscriptEvents({ agentId: "main", sessionId, storePath })).resolves.toEqual([ expect.objectContaining({ id: sessionId, type: "session" }), expect.objectContaining({ id: user.messageId, type: "message" }), @@ -549,7 +917,7 @@ describe("SessionManager.open", () => { ).resolves.toEqual([ expect.objectContaining({ id: branchedSessionId, - parentSession: marker, + parentSession: sessionId, type: "session", }), expect.objectContaining({ id: user.messageId, type: "message" }), @@ -576,7 +944,7 @@ describe("SessionManager.open", () => { }, ); - const sessionManager = SessionManager.open(marker, dir, dir); + const sessionManager = openMarker(marker, sessionKey, dir); const userId = sessionManager.appendMessage({ role: "user", content: "voice prompt", @@ -593,2723 +961,8 @@ describe("SessionManager.open", () => { }), ); }); - - it("rewrites SQLite transcript rows when removing trailing entries", async () => { - const dir = await makeTempDir(); - const storePath = path.join(dir, "sessions.json"); - const sessionId = "sqlite-remove-trailing-session"; - const sessionKey = "agent:main:dashboard:sqlite-remove-trailing"; - const marker = formatSqliteSessionFileMarker({ - agentId: "main", - sessionId, - storePath, - }); - const scope = { agentId: "main", sessionId, sessionKey, storePath }; - await upsertSessionEntry( - { agentId: "main", sessionKey, storePath }, - { - sessionFile: marker, - sessionId, - updatedAt: 10, - }, - ); - const user = await appendTranscriptMessage(scope, { - cwd: dir, - eventId: "user-message", - message: { role: "user", content: "question" }, - }); - const baseAnswer = await appendTranscriptMessage(scope, { - cwd: dir, - eventId: "base-answer", - message: buildAssistantMessage("base answer"), - parentId: user.messageId, - }); - const temporaryError = await appendTranscriptMessage(scope, { - cwd: dir, - eventId: "temporary-error", - message: buildAssistantMessage("temporary error"), - parentId: baseAnswer.messageId, - }); - - const sessionManager = SessionManager.open(marker, dir, dir); - - expect( - sessionManager.removeTrailingEntries((entry) => entry.id === temporaryError.messageId), - ).toBe(1); - expect(sessionManager.getLeafId()).toBe(baseAnswer.messageId); - const replacementId = sessionManager.appendMessage(buildAssistantMessage("replacement answer")); - - const records = await loadTranscriptEvents(scope); - expect( - records.map((record) => - record && typeof record === "object" && "id" in record ? record.id : undefined, - ), - ).not.toContain(temporaryError.messageId); - expect(records).toEqual( - expect.arrayContaining([ - expect.objectContaining({ - id: replacementId, - message: expect.objectContaining({ - content: [{ type: "text", text: "replacement answer" }], - role: "assistant", - }), - parentId: baseAnswer.messageId, - type: "message", - }), - ]), - ); - await expect(fs.stat(path.join(process.cwd(), marker))).rejects.toMatchObject({ - code: "ENOENT", - }); - }); - - it("recovers a corrupted first-line header without truncating later messages", async () => { - // A damaged header should be repairable without treating valid later - // message entries as disposable transcript state. - const dir = await makeTempDir(); - const sessionFile = path.join(dir, "session.jsonl"); - const originalHeader = { - type: "session", - version: 3, - id: "original-session", - timestamp: "2026-05-27T00:00:00.000Z", - cwd: "/srv/openclaw/main", - }; - const userEntry = { - type: "message", - id: "user-1", - parentId: null, - timestamp: "2026-05-27T00:00:01.000Z", - message: { role: "user", content: "important question" }, - }; - const assistantEntry = { - type: "message", - id: "assistant-1", - parentId: "user-1", - timestamp: "2026-05-27T00:00:02.000Z", - message: { role: "assistant", content: "important answer" }, - }; - const normalizedAssistantEntry = { - ...assistantEntry, - message: { role: "assistant", content: [{ type: "text", text: "important answer" }] }, - }; - const originalTranscript = - [ - JSON.stringify(originalHeader).slice(0, 30), - JSON.stringify(userEntry), - JSON.stringify(assistantEntry), - ].join("\n") + "\n"; - await fs.writeFile(sessionFile, originalTranscript, "utf8"); - if (process.platform !== "win32") { - await fs.chmod(sessionFile, 0o600); - } - - const sessionManager = SessionManager.open(sessionFile, dir, "/tmp/task-repo"); - - expect(sessionManager.getEntries()).toEqual([userEntry, normalizedAssistantEntry]); - expect(sessionManager.getChildren(userEntry.id)).toEqual([normalizedAssistantEntry]); - expect(await fs.readFile(sessionFile, "utf8")).toContain("important question"); - expect(await fs.readFile(sessionFile, "utf8")).toContain("important answer"); - await expect(fs.readFile(sessionFile, "utf8")).resolves.not.toBe(originalTranscript); - - const backupFiles = (await fs.readdir(dir)).filter((file) => file.includes(".corrupt-")); - expect(backupFiles).toHaveLength(1); - // Keep an exact backup for audit/debugging before rewriting the live file. - await expect(fs.readFile(path.join(dir, backupFiles[0] ?? ""), "utf8")).resolves.toBe( - originalTranscript, - ); - if (process.platform !== "win32") { - const backupStat = await fs.stat(path.join(dir, backupFiles[0] ?? "")); - expect(backupStat.mode & 0o777).toBe(0o600); - } - }); - - it("does not duplicate the header after recovering a header-only corrupt file", async () => { - const dir = await makeTempDir(); - const sessionFile = path.join(dir, "session.jsonl"); - await fs.writeFile(sessionFile, '{"type":"session","version":3,"id":"sess', "utf8"); - - const sessionManager = SessionManager.open(sessionFile, dir, "/tmp/task-repo"); - sessionManager.appendMessage({ role: "user", content: "hello", timestamp: Date.now() }); - sessionManager.appendMessage({ - role: "assistant", - content: [{ type: "text", text: "hi" }], - api: "messages", - provider: "anthropic", - model: "sonnet-4.6", - usage: { - input: 0, - output: 0, - cacheRead: 0, - cacheWrite: 0, - totalTokens: 0, - cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 }, - }, - stopReason: "stop", - timestamp: Date.now(), - }); - - const entries = (await fs.readFile(sessionFile, "utf8")) - .trim() - .split("\n") - .map((line) => JSON.parse(line) as { type: string }); - - expect(entries.map((entry) => entry.type)).toEqual(["session", "message", "message"]); - expect(entries.filter((entry) => entry.type === "session")).toHaveLength(1); - }); - - it("still migrates old transcript versions while bypassing the warm cache", async () => { - const dir = await makeTempDir(); - const sessionFile = path.join(dir, "session.jsonl"); - const legacyHeader = { - type: "session", - version: 2, - id: "legacy-session", - timestamp: "2026-06-04T00:00:00.000Z", - cwd: dir, - }; - const legacyEntry = { - type: "message", - id: "legacy-entry", - parentId: null, - timestamp: "2026-06-04T00:00:01.000Z", - message: { - role: "hookMessage", - content: "legacy hook content", - }, - }; - await fs.writeFile( - sessionFile, - `${JSON.stringify(legacyHeader)}\n${JSON.stringify(legacyEntry)}\n`, - "utf8", - ); - - const sessionManager = SessionManager.open(sessionFile, dir, dir); - - expect(sessionManager.getHeader()?.version).toBe(CURRENT_SESSION_VERSION); - expect(sessionManager.getEntries()).toEqual([ - { - ...legacyEntry, - message: { ...legacyEntry.message, role: "custom" }, - }, - ]); - const persistedEntries = (await fs.readFile(sessionFile, "utf8")) - .trim() - .split("\n") - .map((line) => JSON.parse(line) as { type: string; version?: number; message?: unknown }); - expect(persistedEntries[0]).toMatchObject({ - type: "session", - version: CURRENT_SESSION_VERSION, - }); - expect(persistedEntries[1]).toMatchObject({ - type: "message", - message: { role: "custom" }, - }); - }); - - it("reuses current transcript entries across warm opens and appends without stale readback", async () => { - const dir = await makeTempDir(); - const sessionFile = path.join(dir, "session.jsonl"); - const firstEntry = buildMessageEntry(1, null); - const secondMessage = buildAssistantMessage("message 2"); - await fs.writeFile( - sessionFile, - `${JSON.stringify(buildSessionHeader(dir))}\n${JSON.stringify(firstEntry)}\n`, - "utf8", - ); - - const originalParse = JSON.parse; - let parseCount = 0; - JSON.parse = function countedParse(...args: Parameters) { - parseCount += 1; - return originalParse.apply(originalParse, args); - } as typeof JSON.parse; - - try { - expect(SessionManager.open(sessionFile, dir, dir).getEntries()).toEqual([firstEntry]); - expect(parseCount).toBe(2); - - parseCount = 0; - expect(SessionManager.open(sessionFile, dir, dir).getEntries()).toEqual([firstEntry]); - expect(parseCount).toBe(0); - - const sessionManager = SessionManager.open(sessionFile, dir, dir); - let trustedSnapshot = await readTrustedRepairSnapshot(sessionFile); - let cacheAdvanceChecks = 0; - let snapshotPublications = 0; - await withOwnedSessionTranscriptWrites( - { - sessionFile, - canAdvanceSessionEntryCache: (snapshot) => { - cacheAdvanceChecks += 1; - expect(snapshot).toEqual(trustedSnapshot); - return true; - }, - publishSessionFileSnapshot: (snapshot) => { - snapshotPublications += 1; - trustedSnapshot = snapshot; - return true; - }, - withSessionWriteLock: async (run) => await run(), - }, - async () => { - sessionManager.appendMessage(secondMessage); - sessionManager.appendMessage(buildAssistantMessage("message 3")); - }, - ); - expect(cacheAdvanceChecks).toBe(2); - expect(snapshotPublications).toBe(2); - const persistedEntries = (await fs.readFile(sessionFile, "utf8")) - .trim() - .split("\n") - .map((line) => originalParse(line) as { type: string }); - expect(persistedEntries.map((entry) => entry.type)).toEqual([ - "session", - "message", - "message", - "message", - ]); - - parseCount = 0; - const reopened = SessionManager.open(sessionFile, dir, dir); - expect(reopened.getEntries().map((entry) => readMessageContent(entry))).toEqual([ - "message 1", - "message 2", - "message 3", - ]); - expect(parseCount).toBe(0); - } finally { - JSON.parse = originalParse; - } - }); - - it("publishes owned snapshots when a safe append pushes the transcript over the cache limit", async () => { - const dir = await makeTempDir(); - const sessionFile = path.join(dir, "large-session.jsonl"); - const maxCachedSessionBytes = 32 * 1024 * 1024; - const headerLine = JSON.stringify(buildSessionHeader(dir)); - const largeEntryBase = { - type: "message", - id: "assistant-1", - parentId: null, - timestamp: "2026-06-04T00:00:01.000Z", - message: buildAssistantMessage(""), - }; - const initialTranscriptWithContent = (content: string) => - `${headerLine}\n${JSON.stringify({ - ...largeEntryBase, - message: buildAssistantMessage(content), - })}\n`; - let filler = "x".repeat( - maxCachedSessionBytes - Buffer.byteLength(initialTranscriptWithContent(""), "utf8") - 16, - ); - while ( - Buffer.byteLength(initialTranscriptWithContent(filler), "utf8") > - maxCachedSessionBytes - 16 - ) { - filler = filler.slice(0, -1024); - } - await fs.writeFile(sessionFile, initialTranscriptWithContent(filler), "utf8"); - - const sessionManager = SessionManager.open(sessionFile, dir, dir); - const publishSessionFileSnapshot = vi.fn(() => true); - await withOwnedSessionTranscriptWrites( - { - sessionFile, - canAdvanceSessionEntryCache: () => true, - publishSessionFileSnapshot, - withSessionWriteLock: async (run) => await run(), - }, - async () => { - sessionManager.appendMessage(buildAssistantMessage("small append")); - }, - ); - - expect(Buffer.byteLength(await fs.readFile(sessionFile, "utf8"), "utf8")).toBeGreaterThan( - maxCachedSessionBytes, - ); - expect(publishSessionFileSnapshot).toHaveBeenCalledTimes(1); - }); - - it("invalidates warm entries after an append outside the owned write context", async () => { - const dir = await makeTempDir(); - const sessionFile = path.join(dir, "session.jsonl"); - const firstEntry = buildMessageEntry(1, null); - await fs.writeFile( - sessionFile, - `${JSON.stringify(buildSessionHeader(dir))}\n${JSON.stringify(firstEntry)}\n`, - "utf8", - ); - - const sessionManager = SessionManager.open(sessionFile, dir, dir); - sessionManager.appendMessage(buildAssistantMessage("message 2")); - - const originalParse = JSON.parse; - let parseCount = 0; - JSON.parse = function countedParse(...args: Parameters) { - parseCount += 1; - return originalParse.apply(originalParse, args); - } as typeof JSON.parse; - - try { - expect( - SessionManager.open(sessionFile, dir, dir) - .getEntries() - .map((entry) => readMessageContent(entry)), - ).toEqual(["message 1", "message 2"]); - expect(parseCount).toBeGreaterThanOrEqual(3); - } finally { - JSON.parse = originalParse; - } - }); - - it("caches the persisted JSON shape for owned appends", async () => { - const dir = await makeTempDir(); - const sessionFile = path.join(dir, "session.jsonl"); - const assistantEntry = { - type: "message", - id: "assistant-1", - parentId: null, - timestamp: "2026-06-04T00:00:01.000Z", - message: buildAssistantMessage("message 1"), - }; - await fs.writeFile( - sessionFile, - `${JSON.stringify(buildSessionHeader(dir))}\n${JSON.stringify(assistantEntry)}\n`, - "utf8", - ); - - const sessionManager = SessionManager.open(sessionFile, dir, dir); - await withOwnedSessionTranscriptWrites( - { - sessionFile, - canAdvanceSessionEntryCache: () => true, - publishSessionFileSnapshot: () => true, - withSessionWriteLock: async (run) => await run(), - }, - async () => { - sessionManager.appendCustomEntry("json-shape", { - date: new Date("2026-06-15T00:00:00.000Z"), - dropped: () => "not persisted", - nan: Number.NaN, - }); - }, - ); - - const warmEntry = SessionManager.open(sessionFile, dir, dir) - .getEntries() - .find((entry) => entry.type === "custom"); - const freshEntry = loadEntriesFromFile(sessionFile).find((entry) => entry.type === "custom"); - expect(warmEntry).toEqual(freshEntry); - expect(warmEntry).toMatchObject({ - data: { - date: "2026-06-15T00:00:00.000Z", - nan: null, - }, - }); - expect((warmEntry as { data?: Record }).data).not.toHaveProperty("dropped"); - }); - - it("serializes owned appends once and caches those exact bytes", async () => { - const dir = await makeTempDir(); - const sessionFile = path.join(dir, "session.jsonl"); - const assistantEntry = { - type: "message", - id: "assistant-1", - parentId: null, - timestamp: "2026-06-04T00:00:01.000Z", - message: buildAssistantMessage("message 1"), - }; - await fs.writeFile( - sessionFile, - `${JSON.stringify(buildSessionHeader(dir))}\n${JSON.stringify(assistantEntry)}\n`, - "utf8", - ); - - let serializationCount = 0; - const sessionManager = SessionManager.open(sessionFile, dir, dir); - await withOwnedSessionTranscriptWrites( - { - sessionFile, - canAdvanceSessionEntryCache: () => true, - publishSessionFileSnapshot: () => true, - withSessionWriteLock: async (run) => await run(), - }, - async () => { - sessionManager.appendCustomEntry("stateful-json", { - value: { - toJSON() { - serializationCount += 1; - return serializationCount === 1 ? "first" : "later"; - }, - }, - }); - }, - ); - - const warmEntry = SessionManager.open(sessionFile, dir, dir) - .getEntries() - .find((entry) => entry.type === "custom"); - const freshEntry = loadEntriesFromFile(sessionFile).find((entry) => entry.type === "custom"); - expect(serializationCount).toBe(1); - expect(warmEntry).toEqual(freshEntry); - expect(warmEntry).toMatchObject({ data: { value: "first" } }); - }); - - it("validates the transcript prefix after entries with custom serializers are serialized", async () => { - const appenders: Array<{ - name: string; - append: (manager: SessionManager, value: unknown) => void; - }> = [ - { - name: "custom", - append: (manager, value) => - manager.appendCustomEntry("rewrite-during-serialization", { - value, - }), - }, - { - name: "custom_message", - append: (manager, value) => - manager.appendCustomMessageEntry( - "rewrite-during-serialization", - "extension message", - false, - { value }, - ), - }, - { - name: "compaction", - append: (manager, value) => - manager.appendCompaction("summary", "assistant-1", 1, { value }, true), - }, - { - name: "branch_summary", - append: (manager, value) => - manager.branchWithSummary("assistant-1", "summary", { value }, true), - }, - { - name: "tool_result_details", - append: (manager, value) => - manager.appendMessage({ - role: "toolResult", - toolCallId: "call-1", - toolName: "test", - content: [{ type: "text", text: "ok" }], - details: { value }, - isError: false, - timestamp: Date.now(), - } as Parameters[0]), - }, - ]; - - const serializerCases: Array<{ - name: string; - createValue: (rewriteTranscript: () => void) => { - value: unknown; - cleanup?: () => void; - }; - }> = [ - { - name: "own_to_json", - createValue: (rewriteTranscript) => ({ - value: { - toJSON() { - rewriteTranscript(); - return "persisted"; - }, - }, - }), - }, - { - name: "non_enumerable_array_index", - createValue: (rewriteTranscript) => { - const array = ["placeholder"]; - Object.defineProperty(array, "0", { - configurable: true, - enumerable: false, - value: { - toJSON() { - rewriteTranscript(); - return "persisted"; - }, - }, - }); - return { value: array }; - }, - }, - { - name: "bigint_to_json", - createValue: (rewriteTranscript) => { - const originalBigIntToJson = Object.getOwnPropertyDescriptor(BigInt.prototype, "toJSON"); - // eslint-disable-next-line no-extend-native -- JSON.stringify invokes BigInt.prototype.toJSON when present. - Object.defineProperty(BigInt.prototype, "toJSON", { - configurable: true, - value() { - rewriteTranscript(); - return "persisted"; - }, - }); - return { - value: 1n, - cleanup: () => { - if (originalBigIntToJson) { - // eslint-disable-next-line no-extend-native -- Restore the serializer installed for this case. - Object.defineProperty(BigInt.prototype, "toJSON", originalBigIntToJson); - } else { - delete (BigInt.prototype as { toJSON?: unknown }).toJSON; - } - }, - }; - }, - }, - ]; - - for (const { name, append } of appenders) { - for (const serializerCase of serializerCases) { - const dir = await makeTempDir(); - const sessionFile = path.join(dir, `${name}-${serializerCase.name}.jsonl`); - const originalEntry = { - type: "message", - id: "assistant-1", - parentId: null, - timestamp: "2026-06-04T00:00:01.000Z", - message: buildAssistantMessage("message 1"), - }; - const replacementEntry = { - ...originalEntry, - message: buildAssistantMessage("changed 1"), - }; - const headerLine = JSON.stringify(buildSessionHeader(dir)); - await fs.writeFile( - sessionFile, - `${headerLine}\n${JSON.stringify(originalEntry)}\n`, - "utf8", - ); - - const sessionManager = SessionManager.open(sessionFile, dir, dir); - let cacheAdvanceChecks = 0; - const publishSessionFileSnapshot = vi.fn(() => true); - const { value, cleanup } = serializerCase.createValue(() => { - writeFileSync( - sessionFile, - `${headerLine}\n${JSON.stringify(replacementEntry)}\n`, - "utf8", - ); - }); - - try { - await withOwnedSessionTranscriptWrites( - { - sessionFile, - canAdvanceSessionEntryCache: () => { - cacheAdvanceChecks += 1; - return true; - }, - publishSessionFileSnapshot, - withSessionWriteLock: async (run) => await run(), - }, - async () => { - append(sessionManager, value); - }, - ); - } finally { - cleanup?.(); - } - - expect( - SessionManager.open(sessionFile, dir, dir) - .getEntries() - .filter((entry) => entry.type === "message") - .map((entry) => readMessageContent(entry)), - ).toEqual(name === "tool_result_details" ? ["changed 1", "ok"] : ["changed 1"]); - expect(cacheAdvanceChecks, `${name}/${serializerCase.name}`).toBe(0); - expect(publishSessionFileSnapshot, `${name}/${serializerCase.name}`).not.toHaveBeenCalled(); - } - } - }); - - it("does not probe custom entry getters before serialization", async () => { - const dir = await makeTempDir(); - const sessionFile = path.join(dir, "session.jsonl"); - const assistantEntry = { - type: "message", - id: "assistant-1", - parentId: null, - timestamp: "2026-06-04T00:00:01.000Z", - message: buildAssistantMessage("message 1"), - }; - await fs.writeFile( - sessionFile, - `${JSON.stringify(buildSessionHeader(dir))}\n${JSON.stringify(assistantEntry)}\n`, - "utf8", - ); - - let accessCount = 0; - const sessionManager = SessionManager.open(sessionFile, dir, dir); - await withOwnedSessionTranscriptWrites( - { - sessionFile, - canAdvanceSessionEntryCache: () => true, - publishSessionFileSnapshot: () => true, - withSessionWriteLock: async (run) => await run(), - }, - async () => { - sessionManager.appendCustomEntry("getter-data", { - get cursor() { - accessCount += 1; - return `value ${accessCount}`; - }, - }); - }, - ); - - const freshEntry = loadEntriesFromFile(sessionFile).find((entry) => entry.type === "custom"); - expect(accessCount).toBe(1); - expect(freshEntry).toMatchObject({ data: { cursor: "value 1" } }); - }); - - it("invalidates custom function serializers before advancing the cache", async () => { - const dir = await makeTempDir(); - const sessionFile = path.join(dir, "session.jsonl"); - const assistantEntry = { - type: "message", - id: "assistant-1", - parentId: null, - timestamp: "2026-06-04T00:00:01.000Z", - message: buildAssistantMessage("message 1"), - }; - const replacementEntry = { - ...assistantEntry, - message: buildAssistantMessage("changed 1"), - }; - const headerLine = JSON.stringify(buildSessionHeader(dir)); - await fs.writeFile(sessionFile, `${headerLine}\n${JSON.stringify(assistantEntry)}\n`, "utf8"); - - const serializer = Object.assign(function serialize() {}, { - toJSON() { - writeFileSync(sessionFile, `${headerLine}\n${JSON.stringify(replacementEntry)}\n`, "utf8"); - return "persisted"; - }, - }); - - const sessionManager = SessionManager.open(sessionFile, dir, dir); - await withOwnedSessionTranscriptWrites( - { - sessionFile, - canAdvanceSessionEntryCache: () => true, - publishSessionFileSnapshot: () => true, - withSessionWriteLock: async (run) => await run(), - }, - async () => { - sessionManager.appendCustomEntry("function-serializer", { value: serializer }); - }, - ); - - const reopenedPrefix = SessionManager.open(sessionFile, dir, dir) - .getEntries() - .find((entry) => entry.id === "assistant-1"); - expect(reopenedPrefix ? readMessageContent(reopenedPrefix) : undefined).toBe("changed 1"); - }); - - it("validates custom message detail hooks before advancing the cache", async () => { - const dir = await makeTempDir(); - const sessionFile = path.join(dir, "session.jsonl"); - const assistantEntry = { - type: "message", - id: "assistant-1", - parentId: null, - timestamp: "2026-06-04T00:00:01.000Z", - message: buildAssistantMessage("message 1"), - }; - const replacementEntry = { - ...assistantEntry, - message: buildAssistantMessage("changed 1"), - }; - const headerLine = JSON.stringify(buildSessionHeader(dir)); - await fs.writeFile(sessionFile, `${headerLine}\n${JSON.stringify(assistantEntry)}\n`, "utf8"); - - const sessionManager = SessionManager.open(sessionFile, dir, dir); - await withOwnedSessionTranscriptWrites( - { - sessionFile, - canAdvanceSessionEntryCache: () => true, - publishSessionFileSnapshot: () => true, - withSessionWriteLock: async (run) => await run(), - }, - async () => { - sessionManager.appendCustomMessageEntry("details-hook", "visible", false, { - value: { - toJSON() { - writeFileSync( - sessionFile, - `${headerLine}\n${JSON.stringify(replacementEntry)}\n`, - "utf8", - ); - return "persisted"; - }, - }, - }); - }, - ); - - expect( - SessionManager.open(sessionFile, dir, dir) - .getEntries() - .filter((entry) => entry.type === "message") - .map((entry) => readMessageContent(entry)), - ).toEqual(["changed 1"]); - }); - - it("detects tool-result detail hooks before advancing the cache", async () => { - const dir = await makeTempDir(); - const sessionFile = path.join(dir, "session.jsonl"); - const assistantEntry = { - type: "message", - id: "assistant-1", - parentId: null, - timestamp: "2026-06-04T00:00:01.000Z", - message: buildAssistantMessage("message 1"), - }; - const replacementEntry = { - ...assistantEntry, - message: buildAssistantMessage("changed 1"), - }; - const headerLine = JSON.stringify(buildSessionHeader(dir)); - await fs.writeFile(sessionFile, `${headerLine}\n${JSON.stringify(assistantEntry)}\n`, "utf8"); - - const sessionManager = SessionManager.open(sessionFile, dir, dir); - await withOwnedSessionTranscriptWrites( - { - sessionFile, - canAdvanceSessionEntryCache: () => true, - publishSessionFileSnapshot: () => true, - withSessionWriteLock: async (run) => await run(), - }, - async () => { - sessionManager.appendMessage({ - role: "toolResult", - toolCallId: "call-1", - toolName: "custom", - content: [{ type: "text", text: "unused" }], - details: { - value: { - toJSON() { - writeFileSync( - sessionFile, - `${headerLine}\n${JSON.stringify(replacementEntry)}\n`, - "utf8", - ); - return "persisted"; - }, - }, - }, - isError: false, - timestamp: Date.now(), - }); - }, - ); - - const reopenedPrefix = SessionManager.open(sessionFile, dir, dir) - .getEntries() - .find((entry) => entry.id === "assistant-1"); - expect(reopenedPrefix ? readMessageContent(reopenedPrefix) : undefined).toBe("changed 1"); - }); - - it("does not warm-cache tool-result detail appends", async () => { - const dir = await makeTempDir(); - const sessionFile = path.join(dir, "session.jsonl"); - const assistantEntry = { - type: "message", - id: "assistant-1", - parentId: null, - timestamp: "2026-06-04T00:00:01.000Z", - message: buildAssistantMessage("message 1"), - }; - await fs.writeFile( - sessionFile, - `${JSON.stringify(buildSessionHeader(dir))}\n${JSON.stringify(assistantEntry)}\n`, - "utf8", - ); - - const sessionManager = SessionManager.open(sessionFile, dir, dir); - await withOwnedSessionTranscriptWrites( - { - sessionFile, - canAdvanceSessionEntryCache: () => true, - publishSessionFileSnapshot: () => true, - withSessionWriteLock: async (run) => await run(), - }, - async () => { - sessionManager.appendMessage({ - role: "toolResult", - toolCallId: "call-1", - toolName: "custom", - content: [{ type: "text", text: "unused" }], - details: { source: "extension" }, - isError: false, - timestamp: Date.now(), - }); - }, - ); - - const originalParse = JSON.parse; - let parseCount = 0; - JSON.parse = function countedParse(...args: Parameters) { - parseCount += 1; - return originalParse.apply(originalParse, args); - } as typeof JSON.parse; - - try { - expect(SessionManager.open(sessionFile, dir, dir).getEntries()).toHaveLength(2); - expect(parseCount).toBeGreaterThan(0); - } finally { - JSON.parse = originalParse; - } - }); - - it("detects assistant tool-call hook writes before advancing the cache", async () => { - const dir = await makeTempDir(); - const sessionFile = path.join(dir, "session.jsonl"); - const assistantEntry = { - type: "message", - id: "assistant-1", - parentId: null, - timestamp: "2026-06-04T00:00:01.000Z", - message: buildAssistantMessage("message 1"), - }; - const replacementEntry = { - ...assistantEntry, - message: buildAssistantMessage("changed 1"), - }; - const headerLine = JSON.stringify(buildSessionHeader(dir)); - await fs.writeFile(sessionFile, `${headerLine}\n${JSON.stringify(assistantEntry)}\n`, "utf8"); - - const sessionManager = SessionManager.open(sessionFile, dir, dir); - await withOwnedSessionTranscriptWrites( - { - sessionFile, - canAdvanceSessionEntryCache: () => true, - publishSessionFileSnapshot: () => true, - withSessionWriteLock: async (run) => await run(), - }, - async () => { - sessionManager.appendMessage({ - ...buildAssistantMessage("unused"), - content: [ - { - type: "toolCall", - id: "call-1", - name: "custom", - arguments: { - value: { - toJSON() { - writeFileSync( - sessionFile, - `${headerLine}\n${JSON.stringify(replacementEntry)}\n`, - "utf8", - ); - return "persisted"; - }, - }, - }, - }, - ], - stopReason: "toolUse", - }); - }, - ); - - const reopenedPrefix = SessionManager.open(sessionFile, dir, dir) - .getEntries() - .find((entry) => entry.id === "assistant-1"); - expect(reopenedPrefix ? readMessageContent(reopenedPrefix) : undefined).toBe("changed 1"); - }); - - it("invalidates incremental repair when append ownership cannot be proven", async () => { - const dir = await makeTempDir(); - const sessionFile = path.join(dir, "session.jsonl"); - const assistantEntry = { - type: "message", - id: "assistant-1", - parentId: null, - timestamp: "2026-06-04T00:00:01.000Z", - message: buildAssistantMessage("message 1"), - }; - const headerLine = JSON.stringify(buildSessionHeader(dir)); - const assistantLine = JSON.stringify(assistantEntry); - await fs.writeFile(sessionFile, `${headerLine}\n${assistantLine}\n`, "utf8"); - await repairSessionFileIfNeeded({ - sessionFile, - trustedSnapshot: await readTrustedRepairSnapshot(sessionFile), - }); - - const sessionManager = SessionManager.open(sessionFile, dir, dir); - await withOwnedSessionTranscriptWrites( - { - sessionFile, - canAdvanceSessionEntryCache: () => false, - publishSessionFileSnapshot: () => true, - withSessionWriteLock: async (run) => await run(), - }, - async () => { - sessionManager.appendCustomEntry("corrupt-prefix-during-serialization", { - value: { - toJSON() { - writeFileSync(sessionFile, `${headerLine}\n!${assistantLine.slice(1)}\n`, "utf8"); - return "persisted"; - }, - }, - }); - }, - ); - - await repairSessionFileIfNeeded({ - sessionFile, - trustedSnapshot: await readTrustedRepairSnapshot(sessionFile), - }); - const repairedEntries = (await fs.readFile(sessionFile, "utf8")) - .trim() - .split("\n") - .map((line) => JSON.parse(line) as { type: string }); - expect(repairedEntries.map((entry) => entry.type)).toEqual(["session", "custom"]); - }); - - it("separates an owned append from an unterminated transcript entry", async () => { - const dir = await makeTempDir(); - const sessionFile = path.join(dir, "session.jsonl"); - const firstEntry = buildMessageEntry(1, null); - await fs.writeFile( - sessionFile, - `${JSON.stringify(buildSessionHeader(dir))}\n${JSON.stringify(firstEntry)}`, - "utf8", - ); - - const sessionManager = SessionManager.open(sessionFile, dir, dir); - await withOwnedSessionTranscriptWrites( - { - sessionFile, - canAdvanceSessionEntryCache: () => true, - publishSessionFileSnapshot: () => true, - withSessionWriteLock: async (run) => await run(), - }, - async () => { - sessionManager.appendMessage(buildAssistantMessage("message 2")); - }, - ); - - const content = await fs.readFile(sessionFile, "utf8"); - expect(content.endsWith("\n")).toBe(true); - expect(content.trimEnd().split("\n")).toHaveLength(3); - expect( - loadEntriesFromFile(sessionFile) - .filter((entry) => entry.type === "message") - .map((entry) => readMessageContent(entry)), - ).toEqual(["message 1", "message 2"]); - }); - - it("caches the persisted JSON shape after a deferred full write", async () => { - const dir = await makeTempDir(); - const sessionFile = path.join(dir, "session.jsonl"); - let serializationCount = 0; - const sessionManager = SessionManager.open(sessionFile, dir, dir); - sessionManager.appendCustomEntry("json-shape", { - kept: "value", - dropped: () => "not persisted", - stateful: { - toJSON() { - serializationCount += 1; - return serializationCount === 1 ? "first" : "later"; - }, - }, - }); - - expect(() => { - sessionManager.appendMessage(buildAssistantMessage("first assistant")); - }).not.toThrow(); - - const warmEntry = SessionManager.open(sessionFile, dir, dir) - .getEntries() - .find((entry) => entry.type === "custom"); - expect(serializationCount).toBe(1); - expect(warmEntry).toMatchObject({ data: { kept: "value", stateful: "first" } }); - expect((warmEntry as { data?: Record }).data).not.toHaveProperty("dropped"); - }); - - it("keeps the exported file loader mutable and separate from warm SessionManager entries", async () => { - const dir = await makeTempDir(); - const sessionFile = path.join(dir, "session.jsonl"); - const firstEntry = buildMessageEntry(1, null); - await fs.writeFile( - sessionFile, - `${JSON.stringify(buildSessionHeader(dir))}\n${JSON.stringify(firstEntry)}\n`, - "utf8", - ); - - const loaded = loadEntriesFromFile(sessionFile); - const messageEntry = loaded[1]; - if (!messageEntry || messageEntry.type !== "message") { - throw new Error("expected message entry"); - } - (messageEntry.message as { content: unknown }).content = "caller-owned mutation"; - - expect(readMessageContent(messageEntry)).toBe("caller-owned mutation"); - expect( - SessionManager.open(sessionFile, dir, dir) - .getEntries() - .map((entry) => readMessageContent(entry)), - ).toEqual(["message 1"]); - }); - - it("invalidates the transcript entry cache when the file is externally replaced", async () => { - const dir = await makeTempDir(); - const sessionFile = path.join(dir, "session.jsonl"); - const firstEntry = buildMessageEntry(1, null); - const replacementEntry = buildMessageEntry(2, null); - await fs.writeFile( - sessionFile, - `${JSON.stringify(buildSessionHeader(dir))}\n${JSON.stringify(firstEntry)}\n`, - "utf8", - ); - - expect(SessionManager.open(sessionFile, dir, dir).getEntries()).toEqual([firstEntry]); - await fs.writeFile( - sessionFile, - `${JSON.stringify(buildSessionHeader(dir, "replacement-session"))}\n${JSON.stringify( - replacementEntry, - )}\n`, - "utf8", - ); - - const originalParse = JSON.parse; - let parseCount = 0; - JSON.parse = function countedParse(...args: Parameters) { - parseCount += 1; - return originalParse.apply(originalParse, args); - } as typeof JSON.parse; - - try { - const reopened = SessionManager.open(sessionFile, dir, dir); - expect(reopened.getSessionId()).toBe("replacement-session"); - expect(reopened.getEntries()).toEqual([replacementEntry]); - expect(parseCount).toBeGreaterThanOrEqual(2); - } finally { - JSON.parse = originalParse; - } - }); - - it("revalidates a transcript changed while the initial load is parsing", async () => { - const dir = await makeTempDir(); - const sessionFile = path.join(dir, "session.jsonl"); - const firstEntry = buildMessageEntry(1, null); - const replacementEntry = buildMessageEntry(2, null); - await fs.writeFile( - sessionFile, - `${JSON.stringify(buildSessionHeader(dir))}\n${JSON.stringify(firstEntry)}\n`, - "utf8", - ); - const replacementContent = - `${JSON.stringify(buildSessionHeader(dir, "intermediate-session"))}\n` + - `${JSON.stringify(replacementEntry)}\n`; - const finalEntry = buildMessageEntry(3, null); - const finalContent = - `${JSON.stringify(buildSessionHeader(dir, "replacement-session"))}\n` + - `${JSON.stringify(finalEntry)}\n`; - - const originalParse = JSON.parse; - let replacementCount = 0; - JSON.parse = function replaceDuringParse(...args: Parameters) { - const parsed = originalParse.apply(originalParse, args); - if (replacementCount === 0) { - replacementCount += 1; - writeFileSync(sessionFile, replacementContent, "utf8"); - } else if (replacementCount === 1 && args[0].includes("intermediate-session")) { - replacementCount += 1; - writeFileSync(sessionFile, finalContent, "utf8"); - } - return parsed; - } as typeof JSON.parse; - - try { - const reopened = SessionManager.open(sessionFile, dir, dir); - expect(reopened.getSessionId()).toBe("replacement-session"); - expect(reopened.getEntries()).toEqual([finalEntry]); - } finally { - JSON.parse = originalParse; - } - }); - - it("does not cache manager entries over a same-length external replacement", async () => { - const dir = await makeTempDir(); - const sessionFile = path.join(dir, "session.jsonl"); - const firstEntry = buildMessageEntry(1, null); - const replacementEntry = { - ...buildMessageEntry(2, null), - id: firstEntry.id, - }; - const header = buildSessionHeader(dir); - const originalContent = `${JSON.stringify(header)}\n${JSON.stringify(firstEntry)}\n`; - const replacementContent = `${JSON.stringify(header)}\n${JSON.stringify(replacementEntry)}\n`; - expect(Buffer.byteLength(replacementContent)).toBe(Buffer.byteLength(originalContent)); - await fs.writeFile(sessionFile, originalContent, "utf8"); - - const sessionManager = SessionManager.open(sessionFile, dir, dir); - await fs.writeFile(sessionFile, replacementContent, "utf8"); - sessionManager.syncSnapshotAfterHeaderRewrite(); - - expect( - SessionManager.open(sessionFile, dir, dir) - .getEntries() - .map((entry) => readMessageContent(entry)), - ).toEqual(["message 2"]); - }); - - it("does not publish a header rewrite snapshot when the expected bytes do not match", async () => { - const dir = await makeTempDir(); - const sessionFile = path.join(dir, "session.jsonl"); - const header = buildSessionHeader(dir); - const originalContent = `${JSON.stringify(header)}\n${JSON.stringify(buildMessageEntry(1, null))}\n`; - const replacementContent = `${JSON.stringify(header)}\n${JSON.stringify( - buildMessageEntry(2, null), - )}\n`; - await fs.writeFile(sessionFile, originalContent, "utf8"); - - const sessionManager = SessionManager.open(sessionFile, dir, dir); - const publishSessionFileSnapshot = vi.fn(() => true); - await withOwnedSessionTranscriptWrites( - { - sessionFile, - publishSessionFileSnapshot, - withSessionWriteLock: async (run) => await run(), - }, - async () => { - await fs.writeFile(sessionFile, replacementContent, "utf8"); - sessionManager.syncSnapshotAfterHeaderRewrite(originalContent); - }, - ); - - expect(publishSessionFileSnapshot).not.toHaveBeenCalled(); - expect( - SessionManager.open(sessionFile, dir, dir) - .getEntries() - .map((entry) => readMessageContent(entry)), - ).toEqual(["message 2"]); - }); - - it("does not persist caller-side entry mutations into warm cache hits", async () => { - const dir = await makeTempDir(); - const sessionFile = path.join(dir, "session.jsonl"); - const firstEntry = buildMessageEntry(1, null); - await fs.writeFile( - sessionFile, - `${JSON.stringify(buildSessionHeader(dir))}\n${JSON.stringify(firstEntry)}\n`, - "utf8", - ); - - const opened = SessionManager.open(sessionFile, dir, dir); - const returnedEntry = opened.getEntries()[0]; - if (!returnedEntry || returnedEntry.type !== "message") { - throw new Error("expected message entry"); - } - expect(() => { - (returnedEntry.message as { content: unknown }).content = "mutated only in caller"; - }).toThrow(TypeError); - - const originalParse = JSON.parse; - let parseCount = 0; - JSON.parse = function countedParse(...args: Parameters) { - parseCount += 1; - return originalParse.apply(originalParse, args); - } as typeof JSON.parse; - - try { - const reopened = SessionManager.open(sessionFile, dir, dir); - expect(reopened.getEntries().map((entry) => readMessageContent(entry))).toEqual([ - "message 1", - ]); - expect(parseCount).toBe(0); - } finally { - JSON.parse = originalParse; - } - }); - - it("keeps current-version entries immutable when the transcript exceeds the cache limit", async () => { - const dir = await makeTempDir(); - const sessionFile = path.join(dir, "session.jsonl"); - const hugeEntry = buildMessageEntry(1, null); - if (hugeEntry.type !== "message") { - throw new Error("expected message entry fixture"); - } - (hugeEntry.message as { content: string }).content = "x".repeat(33 * 1024 * 1024); - await fs.writeFile( - sessionFile, - `${JSON.stringify(buildSessionHeader(dir))}\n${JSON.stringify(hugeEntry)}\n`, - "utf8", - ); - - const opened = SessionManager.open(sessionFile, dir, dir); - const returnedEntry = opened.getEntries()[0]; - if (!returnedEntry || returnedEntry.type !== "message") { - throw new Error("expected message entry"); - } - - expect(() => { - (returnedEntry.message as { content: unknown }).content = "mutated"; - }).toThrow(TypeError); - }); - - it("invalidates the warm cache when another writer appends before this manager persists", async () => { - const dir = await makeTempDir(); - const sessionFile = path.join(dir, "session.jsonl"); - const firstEntry = buildMessageEntry(1, null); - const externalEntry = buildMessageEntry(2, firstEntry.id); - await fs.writeFile( - sessionFile, - `${JSON.stringify(buildSessionHeader(dir))}\n${JSON.stringify(firstEntry)}\n`, - "utf8", - ); - - const sessionManager = SessionManager.open(sessionFile, dir, dir); - await fs.appendFile(sessionFile, `${JSON.stringify(externalEntry)}\n`, "utf8"); - sessionManager.appendMessage(buildAssistantMessage("message 3")); - - const originalParse = JSON.parse; - let parseCount = 0; - JSON.parse = function countedParse(...args: Parameters) { - parseCount += 1; - return originalParse.apply(originalParse, args); - } as typeof JSON.parse; - - try { - const reopened = SessionManager.open(sessionFile, dir, dir); - expect(reopened.getEntries().map((entry) => readMessageContent(entry))).toEqual([ - "message 1", - "message 2", - "message 3", - ]); - expect(parseCount).toBeGreaterThanOrEqual(4); - } finally { - JSON.parse = originalParse; - } - }); - - it("lets prepareSessionManagerForRun normalize a warm-cached header without re-parsing", async () => { - const dir = await makeTempDir(); - const sessionFile = path.join(dir, "session.jsonl"); - const assistantEntry = { - type: "message", - id: "assistant-1", - parentId: null, - timestamp: "2026-06-04T00:00:01.000Z", - message: { role: "assistant", content: "carried context" }, - }; - await fs.writeFile( - sessionFile, - `${JSON.stringify(buildSessionHeader(dir, "original-session"))}\n${JSON.stringify( - assistantEntry, - )}\n`, - "utf8", - ); - - // Warm the process-level entry cache. - expect(SessionManager.open(sessionFile, dir, dir).getSessionId()).toBe("original-session"); - - const originalParse = JSON.parse; - let parseCount = 0; - JSON.parse = function countedParse(...args: Parameters) { - parseCount += 1; - return originalParse.apply(originalParse, args); - } as typeof JSON.parse; - - try { - // Two warm hits off the same cache entry: must not re-parse the transcript. - const sessionManager = SessionManager.open(sessionFile, dir, dir); - const sibling = SessionManager.open(sessionFile, dir, dir); - expect(parseCount).toBe(0); - - // The embedded runner normalizes the loaded header in place. With a shared - // frozen cache entry this threw "Cannot assign to read only property". - await expect( - prepareSessionManagerForRun({ - sessionManager, - sessionFile, - hadSessionFile: true, - sessionId: "run-session", - cwd: "/tmp/task-repo", - }), - ).resolves.toBeUndefined(); - - expect(sessionManager.getSessionId()).toBe("run-session"); - expect(sessionManager.getHeader()).toEqual( - expect.objectContaining({ type: "session", id: "run-session", cwd: "/tmp/task-repo" }), - ); - expect(sessionManager.getCwd()).toBe("/tmp/task-repo"); - - // Each warm hit gets an independent mutable header clone, so normalizing - // one manager's header must not bleed into the cached snapshot shared with - // the sibling manager. - expect(sibling.getHeader()).toEqual( - expect.objectContaining({ type: "session", id: "original-session", cwd: dir }), - ); - - // The warm hits stayed parse-free. The required header rewrite parses - // its two persisted lines once so the cache matches JSON round-tripping. - expect(parseCount).toBe(2); - } finally { - JSON.parse = originalParse; - } - }); - - it("preserves opaque transcript rows during embedded header normalization", async () => { - const dir = await makeTempDir(); - const sessionFile = path.join(dir, "session.jsonl"); - const metadata = { type: "metadata", payload: { source: "plugin" } }; - const assistantEntry = { - type: "message", - id: "assistant-1", - parentId: null, - timestamp: "2026-06-04T00:00:01.000Z", - message: { role: "assistant", content: "carried context" }, - }; - const normalizedAssistantEntry = { - ...assistantEntry, - message: { role: "assistant", content: [{ type: "text", text: "carried context" }] }, - }; - await fs.writeFile( - sessionFile, - [ - JSON.stringify(buildSessionHeader(dir, "original-session")), - JSON.stringify(metadata), - JSON.stringify(assistantEntry), - ].join("\n") + "\n", - "utf8", - ); - - const sessionManager = SessionManager.open(sessionFile, dir, dir); - await prepareSessionManagerForRun({ - sessionManager, - sessionFile, - hadSessionFile: true, - sessionId: "run-session", - cwd: "/tmp/task-repo", - }); - - const records = (await fs.readFile(sessionFile, "utf8")) - .trim() - .split("\n") - .map((line) => JSON.parse(line) as unknown); - expect(records).toContainEqual(metadata); - expect(sessionManager.getEntries()).toEqual([normalizedAssistantEntry]); - }); - - it("bridges parent-linked opaque rows without exposing them as session entries", async () => { - const dir = await makeTempDir(); - const sessionFile = path.join(dir, "session.jsonl"); - const userEntry = { - type: "message", - id: "user-1", - parentId: null, - timestamp: "2026-06-04T00:00:01.000Z", - message: { role: "user", content: "question" }, - }; - const metadata = { - type: "metadata", - id: "metadata-1", - parentId: userEntry.id, - payload: { source: "plugin" }, - }; - await fs.writeFile( - sessionFile, - [buildSessionHeader(dir, "session-1"), userEntry, metadata] - .map((entry) => JSON.stringify(entry)) - .join("\n") + "\n", - "utf8", - ); - - const sessionManager = SessionManager.open(sessionFile, dir, dir); - expect(sessionManager.getLeafEntry()).toEqual(userEntry); - const assistantId = sessionManager.appendMessage(buildAssistantMessage("answer")); - const assistantEntry = sessionManager.getEntry(assistantId); - - expect(assistantEntry).toEqual(expect.objectContaining({ parentId: userEntry.id })); - const persistedAssistant = (await fs.readFile(sessionFile, "utf8")) - .trim() - .split("\n") - .map((line) => JSON.parse(line) as { id?: string; parentId?: string | null }) - .find((entry) => entry.id === assistantId); - expect(persistedAssistant).toEqual(expect.objectContaining({ parentId: metadata.id })); - expect(sessionManager.getEntries()).toEqual([userEntry, assistantEntry]); - expect(sessionManager.getBranch()).toEqual([ - userEntry, - expect.objectContaining({ id: assistantId, parentId: userEntry.id }), - ]); - expect(sessionManager.buildSessionContext().messages).toMatchObject([ - { role: "user", content: "question" }, - { role: "assistant", content: [{ type: "text", text: "answer" }] }, - ]); - - sessionManager.branch(metadata.id); - expect(sessionManager.getLeafId()).toBe(userEntry.id); - sessionManager.branch(assistantId); - const branchedFile = sessionManager.createBranchedSession(assistantId); - expect(branchedFile).toBeDefined(); - const branchedRecords = (await fs.readFile(branchedFile!, "utf8")) - .trim() - .split("\n") - .map((line) => JSON.parse(line) as { id?: string; parentId?: string | null }); - expect(branchedRecords).toContainEqual(metadata); - expect(branchedRecords.find((record) => record.id === assistantId)?.parentId).toBe(metadata.id); - expect( - SessionManager.open(branchedFile!, dir, dir).buildSessionContext().messages, - ).toMatchObject([ - { role: "user", content: "question" }, - { role: "assistant", content: [{ type: "text", text: "answer" }] }, - ]); - }); - - it("repairs compaction boundaries that point through opaque rows", async () => { - const dir = await makeTempDir(); - const sessionFile = path.join(dir, "session.jsonl"); - const userEntry = { - type: "message", - id: "user-1", - parentId: null, - timestamp: "2026-06-04T00:00:01.000Z", - message: { role: "user", content: "question" }, - }; - const metadata = { - type: "metadata", - id: "metadata-1", - parentId: userEntry.id, - payload: { source: "plugin" }, - }; - const assistantEntry = { - type: "message", - id: "assistant-1", - parentId: metadata.id, - timestamp: "2026-06-04T00:00:02.000Z", - message: buildAssistantMessage("answer"), - }; - const compactionEntry = { - type: "compaction", - id: "compaction-1", - parentId: assistantEntry.id, - timestamp: "2026-06-04T00:00:03.000Z", - summary: "summary", - firstKeptEntryId: metadata.id, - tokensBefore: 200, - }; - await fs.writeFile( - sessionFile, - [buildSessionHeader(dir, "session-1"), userEntry, metadata, assistantEntry, compactionEntry] - .map((entry) => JSON.stringify(entry)) - .join("\n") + "\n", - "utf8", - ); - - const sessionManager = SessionManager.open(sessionFile, dir, dir); - - expect(sessionManager.getEntry(compactionEntry.id)).toEqual( - expect.objectContaining({ firstKeptEntryId: userEntry.id }), - ); - expect(sessionManager.buildSessionContext().messages).toMatchObject([ - { role: "compactionSummary", summary: "summary" }, - { role: "user", content: "question" }, - { role: "assistant", content: [{ type: "text", text: "answer" }] }, - ]); - }); - - it("repairs opaque compaction boundaries on the active branch", async () => { - const dir = await makeTempDir(); - const sessionFile = path.join(dir, "session.jsonl"); - const opaqueRoot = { type: "metadata", id: "opaque-root", parentId: null }; - const branchAUser = { - type: "message", - id: "branch-a-user", - parentId: opaqueRoot.id, - timestamp: "2026-06-04T00:00:01.000Z", - message: { role: "user", content: "branch a" }, - }; - const branchBUser = { - type: "message", - id: "branch-b-user", - parentId: opaqueRoot.id, - timestamp: "2026-06-04T00:00:02.000Z", - message: { role: "user", content: "branch b" }, - }; - const branchBAssistant = { - type: "message", - id: "branch-b-assistant", - parentId: branchBUser.id, - timestamp: "2026-06-04T00:00:03.000Z", - message: buildAssistantMessage("branch b answer"), - }; - const compactionEntry = { - type: "compaction", - id: "compaction-1", - parentId: branchBAssistant.id, - timestamp: "2026-06-04T00:00:04.000Z", - summary: "summary", - firstKeptEntryId: opaqueRoot.id, - tokensBefore: 200, - }; - await fs.writeFile( - sessionFile, - [ - buildSessionHeader(dir, "session-1"), - opaqueRoot, - branchAUser, - branchBUser, - branchBAssistant, - compactionEntry, - ] - .map((entry) => JSON.stringify(entry)) - .join("\n") + "\n", - "utf8", - ); - - const sessionManager = SessionManager.open(sessionFile, dir, dir); - - expect(sessionManager.getEntry(compactionEntry.id)).toEqual( - expect.objectContaining({ firstKeptEntryId: branchBUser.id }), - ); - expect(sessionManager.buildSessionContext().messages).toMatchObject([ - { role: "compactionSummary", summary: "summary" }, - { role: "user", content: "branch b" }, - { role: "assistant", content: [{ type: "text", text: "branch b answer" }] }, - ]); - }); - - it("does not use session events as append parents", async () => { - const dir = await makeTempDir(); - const sessionFile = path.join(dir, "session.jsonl"); - const userEntry = { - type: "message", - id: "user-1", - parentId: null, - timestamp: "2026-06-04T00:00:01.000Z", - message: { role: "user", content: "question" }, - }; - const sessionEvent = { - type: "session", - id: "event-1", - parentId: userEntry.id, - sessionId: "external-session-event", - }; - await fs.writeFile( - sessionFile, - [buildSessionHeader(dir, "session-1"), userEntry, sessionEvent] - .map((entry) => JSON.stringify(entry)) - .join("\n") + "\n", - "utf8", - ); - - const sessionManager = SessionManager.open(sessionFile, dir, dir); - const assistantId = sessionManager.appendMessage(buildAssistantMessage("answer")); - - expect(sessionManager.getEntry(assistantId)).toEqual( - expect.objectContaining({ parentId: userEntry.id }), - ); - expect(sessionManager.buildSessionContext().messages).toMatchObject([ - { role: "user", content: "question" }, - { role: "assistant", content: [{ type: "text", text: "answer" }] }, - ]); - }); - - it("repairs descendants linked through persisted leaf records", async () => { - const dir = await makeTempDir(); - const sessionFile = path.join(dir, "session.jsonl"); - const rootEntry = { - type: "message", - id: "root-user", - parentId: null, - timestamp: "2026-06-04T00:00:01.000Z", - message: { role: "user", content: "root question" }, - }; - const abandonedEntry = { - type: "message", - id: "abandoned-assistant", - parentId: rootEntry.id, - timestamp: "2026-06-04T00:00:02.000Z", - message: buildAssistantMessage("abandoned answer"), - }; - const leafEntry = { - type: "leaf", - id: "leaf-1", - parentId: abandonedEntry.id, - timestamp: "2026-06-04T00:00:03.000Z", - targetId: rootEntry.id, - }; - const replacementEntry = { - type: "message", - id: "replacement-assistant", - parentId: leafEntry.id, - timestamp: "2026-06-04T00:00:04.000Z", - message: buildAssistantMessage("replacement answer"), - }; - await fs.writeFile( - sessionFile, - [buildSessionHeader(dir, "session-1"), rootEntry, abandonedEntry, leafEntry, replacementEntry] - .map((entry) => JSON.stringify(entry)) - .join("\n") + "\n", - "utf8", - ); - - const reopened = SessionManager.open(sessionFile, dir, dir); - expect(reopened.getEntry(replacementEntry.id)).toEqual( - expect.objectContaining({ parentId: rootEntry.id }), - ); - expect(reopened.buildSessionContext().messages).toMatchObject([ - { role: "user", content: "root question" }, - { role: "assistant", content: [{ type: "text", text: "replacement answer" }] }, - ]); - }); - - it("preserves trailing opaque rows when cleanup removes the preceding entry", async () => { - const dir = await makeTempDir(); - const sessionManager = SessionManager.create(dir, dir); - sessionManager.appendMessage({ role: "user", content: "question", timestamp: 1 }); - const baseAnswerId = sessionManager.appendMessage(buildAssistantMessage("base answer")); - const temporaryErrorId = sessionManager.appendMessage(buildAssistantMessage("temporary error")); - const opaqueMetadata = { type: "metadata", payload: { source: "plugin" } }; - const globalMetadata = { - type: "custom" as const, - id: "plugin-state", - parentId: temporaryErrorId, - timestamp: "2026-06-04T00:00:04.000Z", - customType: "plugin-state", - data: { source: "plugin" }, - }; - const deliveryEntry = { - type: "message" as const, - id: "delivery-mirror", - parentId: globalMetadata.id, - timestamp: "2026-06-04T00:00:05.000Z", - message: { - ...buildAssistantMessage("mirrored delivery"), - provider: "openclaw", - model: "delivery-mirror", - }, - }; - sessionManager.mergePromptReleasedSessionEntries([ - { type: "prompt_released_opaque", record: opaqueMetadata }, - globalMetadata, - deliveryEntry, - ]); - - expect( - sessionManager.removeTrailingEntries((entry) => entry.id === temporaryErrorId, { - preserveTrailing: (entry) => - entry.type === "custom" || - entry.type === "label" || - entry.type === "session_info" || - (entry.type === "message" && isTranscriptOnlyOpenClawAssistantMessage(entry.message)), - }), - ).toBe(1); - expect(sessionManager.getLeafId()).toBe(baseAnswerId); - const replacementId = sessionManager.appendMessage(buildAssistantMessage("replacement answer")); - - const sessionFile = sessionManager.getSessionFile(); - expect(sessionFile).toBeDefined(); - const records = (await fs.readFile(sessionFile!, "utf8")) - .trim() - .split("\n") - .map((line) => JSON.parse(line) as Record); - const metadataIndex = records.findIndex( - (record) => JSON.stringify(record) === JSON.stringify(opaqueMetadata), - ); - const globalMetadataIndex = records.findIndex((record) => record.id === globalMetadata.id); - const deliveryIndex = records.findIndex((record) => record.id === deliveryEntry.id); - const replacementIndex = records.findIndex((record) => record.id === replacementId); - expect(metadataIndex).toBeGreaterThan(-1); - expect(globalMetadataIndex).toBeGreaterThan(metadataIndex); - expect(deliveryIndex).toBeGreaterThan(globalMetadataIndex); - expect(replacementIndex).toBeGreaterThan(deliveryIndex); - expect(records[globalMetadataIndex]?.parentId).toBe(baseAnswerId); - expect(records[deliveryIndex]?.parentId).toBe(globalMetadata.id); - expect(SessionManager.open(sessionFile!, dir, dir).buildSessionContext().messages).toHaveLength( - 3, - ); - }); - - it("keeps merged messages downstream of parent-linked opaque events", async () => { - const dir = await makeTempDir(); - const sessionManager = SessionManager.create(dir, dir); - sessionManager.appendMessage({ role: "user", content: "question", timestamp: 1 }); - const baseAnswerId = sessionManager.appendMessage(buildAssistantMessage("base answer")); - const metadata = { - type: "metadata", - id: "plugin-metadata", - parentId: baseAnswerId, - payload: { source: "plugin" }, - }; - const deliveryEntry = { - type: "message" as const, - id: "plugin-delivery", - parentId: baseAnswerId, - timestamp: "2026-06-04T00:00:03.000Z", - message: buildAssistantMessage("plugin delivery"), - }; - - sessionManager.mergePromptReleasedSessionEntries([ - { type: "prompt_released_opaque", record: metadata }, - ]); - sessionManager.mergePromptReleasedSessionEntries([deliveryEntry]); - ( - sessionManager as unknown as { - replacePersistedTranscript: () => void; - } - ).replacePersistedTranscript(); - - const sessionFile = sessionManager.getSessionFile(); - expect(sessionFile).toBeDefined(); - const records = (await fs.readFile(sessionFile!, "utf8")) - .trim() - .split("\n") - .map( - (line) => - JSON.parse(line) as { - type?: string; - id?: string; - parentId?: string | null; - targetId?: string | null; - }, - ); - expect(records.find((record) => record.id === deliveryEntry.id)?.parentId).toBe(metadata.id); - expect(records.at(-1)).toMatchObject({ type: "leaf", targetId: baseAnswerId }); - - const reopened = SessionManager.open(sessionFile!, dir, dir); - expect(reopened.getLeafId()).toBe(baseAnswerId); - expect(JSON.stringify(reopened.buildSessionContext())).not.toContain("plugin delivery"); - expect(reopened.getBranch(deliveryEntry.id).map((entry) => entry.id)).toEqual([ - expect.any(String), - baseAnswerId, - deliveryEntry.id, - ]); - const branchedFile = reopened.createBranchedSession(deliveryEntry.id); - expect(branchedFile).toBeDefined(); - const branchedRecords = (await fs.readFile(branchedFile!, "utf8")) - .trim() - .split("\n") - .map((line) => JSON.parse(line) as { id?: string; parentId?: string | null }); - expect(branchedRecords).toContainEqual(metadata); - expect(branchedRecords.find((record) => record.id === deliveryEntry.id)?.parentId).toBe( - metadata.id, - ); - }); - - it("persists the active leaf immediately after merging prompt-released side rows", async () => { - const dir = await makeTempDir(); - const sessionManager = SessionManager.create(dir, dir); - sessionManager.appendMessage({ role: "user", content: "question", timestamp: 1 }); - const baseAnswerId = sessionManager.appendMessage(buildAssistantMessage("base answer")); - const sideEntry = { - type: "message" as const, - id: "side-delivery", - parentId: baseAnswerId, - timestamp: "2026-06-15T00:00:03.000Z", - message: buildAssistantMessage("side delivery"), - }; - const sessionFile = sessionManager.getSessionFile(); - expect(sessionFile).toBeDefined(); - await fs.appendFile(sessionFile!, `${JSON.stringify(sideEntry)}\n`, "utf8"); - - const mergeResult = sessionManager.mergePromptReleasedSessionEntries([sideEntry], { - persistLeaf: true, - }); - - expect(mergeResult?.publishedEntries).toEqual([{ kind: "id", id: expect.any(String) }]); - const records = (await fs.readFile(sessionFile!, "utf8")) - .trim() - .split("\n") - .map( - (line) => - JSON.parse(line) as { - type?: string; - id?: string; - parentId?: string | null; - targetId?: string | null; - appendParentId?: string | null; - appendMode?: string; - }, - ); - expect(records.at(-1)).toMatchObject({ - type: "leaf", - parentId: sideEntry.id, - targetId: baseAnswerId, - appendParentId: sideEntry.id, - appendMode: "side", - }); - - const nextSideEntry = { - ...sideEntry, - id: "next-side-delivery", - parentId: records.at(-1)?.appendParentId ?? records.at(-1)?.targetId ?? null, - appendMode: "side" as const, - timestamp: "2026-06-15T00:00:04.000Z", - message: buildAssistantMessage("next side delivery"), - }; - const reopenedForNextMerge = SessionManager.open(sessionFile!, dir, dir); - await fs.appendFile(sessionFile!, `${JSON.stringify(nextSideEntry)}\n`, "utf8"); - reopenedForNextMerge.mergePromptReleasedSessionEntries([nextSideEntry], { - persistLeaf: true, - }); - - const finalRecords = (await fs.readFile(sessionFile!, "utf8")) - .trim() - .split("\n") - .map( - (line) => - JSON.parse(line) as { - type?: string; - id?: string; - parentId?: string | null; - targetId?: string | null; - appendParentId?: string | null; - appendMode?: string; - }, - ); - expect(finalRecords.find((record) => record.id === nextSideEntry.id)?.parentId).toBe( - sideEntry.id, - ); - expect(finalRecords.at(-1)).toMatchObject({ - type: "message", - id: nextSideEntry.id, - parentId: sideEntry.id, - appendMode: "side", - }); - - const reopened = SessionManager.open(sessionFile!, dir, dir); - expect(reopened.getLeafId()).toBe(baseAnswerId); - expect(JSON.stringify(reopened.buildSessionContext())).not.toContain("side delivery"); - expect( - reopened - .getBranch(nextSideEntry.id) - .map((entry) => entry.id) - .slice(-2), - ).toEqual([sideEntry.id, nextSideEntry.id]); - - const nextUserId = reopened.appendMessage({ - role: "user", - content: "next question", - timestamp: 3, - }); - expect( - reopened - .getBranch(nextUserId) - .map((entry) => entry.id) - .slice(-2), - ).toEqual([baseAnswerId, nextUserId]); - expect(JSON.stringify(reopened.buildSessionContext())).not.toContain("side delivery"); - }); - - it("accepts an unowned side leaf only when it preserves the active branch", async () => { - const dir = await makeTempDir(); - const sessionManager = SessionManager.create(dir, dir); - sessionManager.appendMessage({ role: "user", content: "question", timestamp: 1 }); - const activeLeafId = sessionManager.appendMessage(buildAssistantMessage("base answer")); - const sideEntry = { - type: "message" as const, - id: "unowned-side-delivery", - parentId: activeLeafId, - timestamp: "2026-07-05T00:00:01.000Z", - message: buildAssistantMessage("side delivery"), - }; - - sessionManager.mergePromptReleasedSessionEntries([ - sideEntry, - { - type: "prompt_released_opaque", - preserveActiveLeaf: true, - record: { - type: "leaf", - id: "unowned-side-leaf", - parentId: sideEntry.id, - timestamp: "2026-07-05T00:00:02.000Z", - targetId: activeLeafId, - appendParentId: sideEntry.id, - appendMode: "side", - }, - }, - ]); - - expect(sessionManager.getLeafId()).toBe(activeLeafId); - expect(JSON.stringify(sessionManager.buildSessionContext())).not.toContain("side delivery"); - }); - - it("rejects an unowned side leaf that moves the active branch before mutating state", async () => { - const dir = await makeTempDir(); - const sessionManager = SessionManager.create(dir, dir); - const olderLeafId = sessionManager.appendMessage({ - role: "user", - content: "question", - timestamp: 1, - }); - const activeLeafId = sessionManager.appendMessage(buildAssistantMessage("base answer")); - const entryCount = sessionManager.getEntries().length; - const sideEntry = { - type: "message" as const, - id: "hostile-side-delivery", - parentId: activeLeafId, - timestamp: "2026-07-05T00:00:01.000Z", - message: buildAssistantMessage("hostile side delivery"), - }; - - expect(() => - sessionManager.mergePromptReleasedSessionEntries([ - sideEntry, - { - type: "prompt_released_opaque", - preserveActiveLeaf: true, - record: { - type: "leaf", - id: "hostile-side-leaf", - parentId: sideEntry.id, - timestamp: "2026-07-05T00:00:02.000Z", - targetId: olderLeafId, - appendParentId: sideEntry.id, - appendMode: "side", - }, - }, - ]), - ).toThrow("prompt-released side leaf changed the active branch"); - expect(sessionManager.getLeafId()).toBe(activeLeafId); - expect(sessionManager.getEntries()).toHaveLength(entryCount); - }); - - it("rejects an unowned side leaf that resets a non-root side cursor", async () => { - const dir = await makeTempDir(); - const sessionManager = SessionManager.create(dir, dir); - sessionManager.appendMessage({ role: "user", content: "question", timestamp: 1 }); - const activeLeafId = sessionManager.appendMessage(buildAssistantMessage("base answer")); - const entryCount = sessionManager.getEntries().length; - const sideEntry = { - type: "message" as const, - id: "side-delivery-before-root-reset", - parentId: activeLeafId, - timestamp: "2026-07-05T00:00:01.000Z", - message: buildAssistantMessage("side delivery"), - }; - - expect(() => - sessionManager.mergePromptReleasedSessionEntries([ - sideEntry, - { - type: "prompt_released_opaque", - preserveActiveLeaf: true, - record: { - type: "leaf", - id: "unowned-root-reset-leaf", - parentId: sideEntry.id, - timestamp: "2026-07-05T00:00:02.000Z", - targetId: activeLeafId, - appendParentId: null, - appendMode: "side", - }, - }, - ]), - ).toThrow("prompt-released side leaf changed the active branch"); - expect(sessionManager.getLeafId()).toBe(activeLeafId); - expect(sessionManager.getEntries()).toHaveLength(entryCount); - }); - - it("accepts an explicit root side cursor when it matches the current side branch", async () => { - const dir = await makeTempDir(); - const sessionManager = SessionManager.create(dir, dir); - sessionManager.appendMessage({ role: "user", content: "question", timestamp: 1 }); - const activeLeafId = sessionManager.appendMessage(buildAssistantMessage("base answer")); - - sessionManager.mergePromptReleasedSessionEntries([ - { - type: "prompt_released_opaque", - record: { - type: "leaf", - id: "owned-root-side-leaf", - parentId: activeLeafId, - timestamp: "2026-07-05T00:00:01.000Z", - targetId: activeLeafId, - appendParentId: null, - appendMode: "side", - }, - }, - ]); - - expect(() => - sessionManager.mergePromptReleasedSessionEntries([ - { - type: "prompt_released_opaque", - preserveActiveLeaf: true, - record: { - type: "leaf", - id: "unowned-root-side-leaf", - parentId: null, - timestamp: "2026-07-05T00:00:02.000Z", - targetId: activeLeafId, - appendParentId: null, - appendMode: "side", - }, - }, - ]), - ).not.toThrow(); - expect(sessionManager.getLeafId()).toBe(activeLeafId); - }); - - it("applies merged leaf controls across separate callbacks", async () => { - const dir = await makeTempDir(); - const sessionManager = SessionManager.create(dir, dir); - sessionManager.appendMessage({ role: "user", content: "question", timestamp: 1 }); - const baseAnswerId = sessionManager.appendMessage(buildAssistantMessage("base answer")); - const metadata = { - type: "metadata", - id: "plugin-metadata", - parentId: baseAnswerId, - payload: { source: "plugin" }, - }; - const leafEntry = { - type: "leaf", - id: "plugin-leaf", - parentId: metadata.id, - timestamp: "2026-06-04T00:00:03.000Z", - targetId: baseAnswerId, - }; - const deliveryEntry = { - type: "message" as const, - id: "plugin-delivery", - parentId: leafEntry.id, - timestamp: "2026-06-04T00:00:04.000Z", - message: buildAssistantMessage("plugin delivery"), - }; - - sessionManager.mergePromptReleasedSessionEntries([ - { type: "prompt_released_opaque", record: metadata }, - ]); - sessionManager.mergePromptReleasedSessionEntries([ - { type: "prompt_released_opaque", record: leafEntry }, - ]); - sessionManager.mergePromptReleasedSessionEntries([deliveryEntry]); - ( - sessionManager as unknown as { - replacePersistedTranscript: () => void; - } - ).replacePersistedTranscript(); - - const sessionFile = sessionManager.getSessionFile(); - expect(sessionFile).toBeDefined(); - const records = (await fs.readFile(sessionFile!, "utf8")) - .trim() - .split("\n") - .map( - (line) => - JSON.parse(line) as { - type?: string; - id?: string; - parentId?: string | null; - targetId?: string | null; - }, - ); - expect(records.find((record) => record.id === deliveryEntry.id)?.parentId).toBe(baseAnswerId); - expect(records.at(-1)).toMatchObject({ type: "leaf", targetId: baseAnswerId }); - const reopened = SessionManager.open(sessionFile!, dir, dir); - expect(reopened.getLeafId()).toBe(baseAnswerId); - expect(JSON.stringify(reopened.buildSessionContext())).not.toContain("plugin delivery"); - expect(reopened.getBranch(deliveryEntry.id).map((entry) => entry.id)).toEqual([ - expect.any(String), - baseAnswerId, - deliveryEntry.id, - ]); - }); - - it("round-trips a visible leaf with a distinct opaque append parent", async () => { - const dir = await makeTempDir(); - const sessionFile = path.join(dir, "session.jsonl"); - const baseAnswer = { - type: "message", - id: "base-answer", - parentId: null, - timestamp: "2026-06-15T00:00:01.000Z", - message: buildAssistantMessage("base answer"), - }; - const metadata = { - type: "metadata", - id: "plugin-metadata", - parentId: null, - payload: { source: "plugin" }, - }; - await fs.writeFile( - sessionFile, - [buildSessionHeader(dir, "session-1"), baseAnswer, metadata] - .map((entry) => JSON.stringify(entry)) - .join("\n") + "\n", - "utf-8", - ); - - const sessionManager = SessionManager.open(sessionFile, dir, dir); - sessionManager.mergePromptReleasedSessionEntries([ - { - type: "message", - id: "side-delivery", - parentId: baseAnswer.id, - timestamp: "2026-06-15T00:00:02.000Z", - message: buildAssistantMessage("side delivery"), - }, - ]); - ( - sessionManager as unknown as { - replacePersistedTranscript: () => void; - } - ).replacePersistedTranscript(); - - const rewritten = (await fs.readFile(sessionFile, "utf-8")) - .trim() - .split("\n") - .map((line) => JSON.parse(line) as Record); - expect(rewritten.at(-1)).toMatchObject({ - type: "leaf", - targetId: baseAnswer.id, - appendParentId: metadata.id, - }); - - const reopened = SessionManager.open(sessionFile, dir, dir); - expect(reopened.getLeafId()).toBe(baseAnswer.id); - const nextId = reopened.appendMessage(buildAssistantMessage("active continuation")); - const records = (await fs.readFile(sessionFile, "utf-8")) - .trim() - .split("\n") - .map((line) => JSON.parse(line) as { id?: string; parentId?: string | null }); - expect(records.find((entry) => entry.id === nextId)?.parentId).toBe(metadata.id); - expect(reopened.getBranch(nextId).map((entry) => entry.id)).toEqual([baseAnswer.id, nextId]); - const branchedFile = reopened.createBranchedSession(nextId); - expect(branchedFile).toBeDefined(); - const branchedRecords = (await fs.readFile(branchedFile!, "utf-8")) - .trim() - .split("\n") - .map((line) => JSON.parse(line) as { id?: string; parentId?: string | null }); - expect(branchedRecords.find((entry) => entry.id === metadata.id)).toMatchObject({ - parentId: baseAnswer.id, - }); - expect(branchedRecords.find((entry) => entry.id === nextId)).toMatchObject({ - parentId: metadata.id, - }); - }); - - it("reopens parentless canonical rows as one visible branch", async () => { - const dir = await makeTempDir(); - const sessionFile = path.join(dir, "session.jsonl"); - await fs.writeFile( - sessionFile, - [ - buildSessionHeader(dir, "session-1"), - { - type: "message", - id: "user-1", - timestamp: "2026-06-15T00:00:01.000Z", - message: { role: "user", content: "question", timestamp: 1 }, - }, - { - type: "message", - id: "assistant-1", - timestamp: "2026-06-15T00:00:02.000Z", - message: buildAssistantMessage("answer"), - }, - { - type: "leaf", - id: "active-leaf", - parentId: "assistant-1", - timestamp: "2026-06-15T00:00:03.000Z", - targetId: "assistant-1", - }, - ] - .map((entry) => JSON.stringify(entry)) - .join("\n") + "\n", - "utf8", - ); - - const reopened = SessionManager.open(sessionFile, dir, dir); - - expect(reopened.getBranch().map((entry) => entry.id)).toEqual(["user-1", "assistant-1"]); - expect(reopened.buildSessionContext().messages).toMatchObject([ - { role: "user", content: "question" }, - { role: "assistant", content: [{ type: "text", text: "answer" }] }, - ]); - }); - - it("ignores persisted leaf controls with dangling references", async () => { - const dir = await makeTempDir(); - const sessionFile = path.join(dir, "session.jsonl"); - await fs.writeFile( - sessionFile, - [ - buildSessionHeader(dir, "session-1"), - { - type: "message", - id: "active-root", - parentId: null, - timestamp: "2026-06-15T00:00:01.000Z", - message: buildAssistantMessage("active"), - }, - { - type: "metadata", - id: "plugin-metadata", - parentId: "active-root", - payload: { source: "plugin" }, - }, - { - type: "leaf", - id: "missing-target", - parentId: "plugin-metadata", - timestamp: "2026-06-15T00:00:02.000Z", - targetId: "missing", - }, - { - type: "leaf", - id: "missing-append", - parentId: "missing-target", - timestamp: "2026-06-15T00:00:03.000Z", - targetId: "active-root", - appendParentId: "missing", - }, - ] - .map((entry) => JSON.stringify(entry)) - .join("\n") + "\n", - "utf-8", - ); - - const reopened = SessionManager.open(sessionFile, dir, dir); - expect(reopened.getLeafId()).toBe("active-root"); - const nextId = reopened.appendMessage(buildAssistantMessage("continued")); - const records = (await fs.readFile(sessionFile, "utf-8")) - .trim() - .split("\n") - .map((line) => JSON.parse(line) as { id?: string; parentId?: string | null }); - expect(records.find((entry) => entry.id === nextId)?.parentId).toBe("plugin-metadata"); - expect(reopened.buildSessionContext().messages).toMatchObject([ - { role: "assistant", content: [{ type: "text", text: "active" }] }, - { role: "assistant", content: [{ type: "text", text: "continued" }] }, - ]); - }); - - it("ignores dangling leaf controls merged while a prompt is released", async () => { - const dir = await makeTempDir(); - const sessionManager = SessionManager.create(dir, dir); - const baseAnswerId = sessionManager.appendMessage(buildAssistantMessage("base answer")); - const metadata = { - type: "metadata", - id: "plugin-metadata", - parentId: baseAnswerId, - payload: { source: "plugin" }, - }; - sessionManager.mergePromptReleasedSessionEntries([ - { type: "prompt_released_opaque", record: metadata }, - ]); - sessionManager.mergePromptReleasedSessionEntries([ - { - type: "prompt_released_opaque", - record: { - type: "leaf", - id: "missing-target", - parentId: metadata.id, - timestamp: "2026-06-15T00:00:02.000Z", - targetId: "missing", - }, - }, - ]); - sessionManager.mergePromptReleasedSessionEntries([ - { - type: "prompt_released_opaque", - record: { - type: "leaf", - id: "missing-append", - parentId: "missing-target", - timestamp: "2026-06-15T00:00:03.000Z", - targetId: baseAnswerId, - appendParentId: "missing", - }, - }, - ]); - sessionManager.mergePromptReleasedSessionEntries([ - { - type: "message", - id: "side-delivery", - parentId: baseAnswerId, - timestamp: "2026-06-15T00:00:04.000Z", - message: buildAssistantMessage("side delivery"), - }, - ]); - ( - sessionManager as unknown as { - replacePersistedTranscript: () => void; - } - ).replacePersistedTranscript(); - - expect(sessionManager.getLeafId()).toBe(baseAnswerId); - const sessionFile = sessionManager.getSessionFile(); - expect(sessionFile).toBeDefined(); - const records = (await fs.readFile(sessionFile!, "utf-8")) - .trim() - .split("\n") - .map((line) => JSON.parse(line) as { id?: string; parentId?: string | null }); - expect(records.find((entry) => entry.id === "side-delivery")?.parentId).toBe(metadata.id); - }); - - it("clears label timestamps when starting a replacement session", async () => { - const dir = await makeTempDir(); - const sessionManager = SessionManager.create(dir, dir); - const answerId = sessionManager.appendMessage(buildAssistantMessage("answer")); - sessionManager.appendLabelChange(answerId, "saved"); - const state = sessionManager as unknown as { - labelTimestampsById: Map; - }; - - expect(state.labelTimestampsById.size).toBe(1); - - sessionManager.newSession(); - - expect(state.labelTimestampsById.size).toBe(0); - }); - - it("removes leaf controls that target regenerated labels when branching", async () => { - const dir = await makeTempDir(); - const sessionFile = path.join(dir, "session.jsonl"); - const rootEntry = { - type: "message", - id: "root-user", - parentId: null, - timestamp: "2026-06-04T00:00:01.000Z", - message: { role: "user", content: "root question" }, - }; - const labelEntry = { - type: "label", - id: "label-1", - parentId: rootEntry.id, - timestamp: "2026-06-04T00:00:02.000Z", - targetId: rootEntry.id, - label: "selected", - }; - const abandonedEntry = { - type: "message", - id: "abandoned-assistant", - parentId: labelEntry.id, - timestamp: "2026-06-04T00:00:03.000Z", - message: buildAssistantMessage("abandoned answer"), - }; - const leafEntry = { - type: "leaf", - id: "leaf-1", - parentId: abandonedEntry.id, - timestamp: "2026-06-04T00:00:04.000Z", - targetId: labelEntry.id, - }; - const replacementEntry = { - type: "message", - id: "replacement-assistant", - parentId: leafEntry.id, - timestamp: "2026-06-04T00:00:05.000Z", - message: buildAssistantMessage("replacement answer"), - }; - await fs.writeFile( - sessionFile, - [ - buildSessionHeader(dir, "session-1"), - rootEntry, - labelEntry, - abandonedEntry, - leafEntry, - replacementEntry, - ] - .map((entry) => JSON.stringify(entry)) - .join("\n") + "\n", - "utf8", - ); - - const sessionManager = SessionManager.open(sessionFile, dir, dir); - const branchedFile = sessionManager.createBranchedSession(replacementEntry.id); - expect(branchedFile).toBeDefined(); - const branchedRecords = (await fs.readFile(branchedFile!, "utf8")) - .trim() - .split("\n") - .map((line) => JSON.parse(line) as Record); - - expect(branchedRecords.some((record) => record.type === "leaf")).toBe(false); - expect(branchedRecords.find((record) => record.id === replacementEntry.id)?.parentId).toBe( - rootEntry.id, - ); - expect(branchedRecords).toContainEqual( - expect.objectContaining({ - type: "label", - targetId: rootEntry.id, - label: labelEntry.label, - }), - ); - expect( - SessionManager.open(branchedFile!, dir, dir).buildSessionContext().messages, - ).toMatchObject([ - { role: "user", content: "root question" }, - { role: "assistant", content: [{ type: "text", text: "replacement answer" }] }, - ]); - }); - - it("keeps the warm cache after prepareSessionManagerForRun rewrites then appends", async () => { - const dir = await makeTempDir(); - const sessionFile = path.join(dir, "session.jsonl"); - const assistantEntry = { - type: "message", - id: "assistant-1", - parentId: null, - timestamp: "2026-06-04T00:00:01.000Z", - message: { role: "assistant", content: "carried context" }, - }; - await fs.writeFile( - sessionFile, - `${JSON.stringify(buildSessionHeader(dir, "original-session"))}\n${JSON.stringify( - assistantEntry, - )}\n`, - "utf8", - ); - - // Warm the process-level entry cache, then open the manager the embedded - // runner will normalize. - expect(SessionManager.open(sessionFile, dir, dir).getSessionId()).toBe("original-session"); - const sessionManager = SessionManager.open(sessionFile, dir, dir); - - let trustedSnapshot = await readTrustedRepairSnapshot(sessionFile); - let snapshotPublications = 0; - await withOwnedSessionTranscriptWrites( - { - sessionFile, - canAdvanceSessionEntryCache: (snapshot) => { - expect(snapshot).toEqual(trustedSnapshot); - return true; - }, - publishSessionFileSnapshot: (snapshot) => { - snapshotPublications += 1; - trustedSnapshot = snapshot; - return true; - }, - withSessionWriteLock: async (run) => await run(), - }, - async () => { - await prepareSessionManagerForRun({ - sessionManager, - sessionFile, - hadSessionFile: true, - sessionId: "run-session", - cwd: dir, - }); - // First append after the embedded header rewrite. Before the fix the - // stale snapshot made this drop the warm cache. - sessionManager.appendMessage(buildAssistantMessage("after rewrite")); - }, - ); - expect(snapshotPublications).toBe(2); - - const originalParse = JSON.parse; - let parseCount = 0; - JSON.parse = function countedParse(...args: Parameters) { - parseCount += 1; - return originalParse.apply(originalParse, args); - } as typeof JSON.parse; - - try { - const reopened = SessionManager.open(sessionFile, dir, dir); - expect(reopened.getEntries().map((entry) => readMessageContent(entry))).toEqual([ - "carried context", - "after rewrite", - ]); - // The next warm open must hit the cache instead of reparsing the whole - // transcript that the embedded header rewrite produced. - expect(parseCount).toBe(0); - } finally { - JSON.parse = originalParse; - } - }); - - it("invalidates incremental repair state after a full header rewrite", async () => { - const dir = await makeTempDir(); - const sessionFile = path.join(dir, "session.jsonl"); - const firstEntry = { - type: "message", - id: "assistant-1", - parentId: null, - timestamp: "2026-06-04T00:00:01.000Z", - message: buildAssistantMessage("message 1"), - }; - await fs.writeFile( - sessionFile, - `${JSON.stringify(buildSessionHeader(dir))}\n${JSON.stringify(firstEntry)}\n`, - "utf8", - ); - await repairSessionFileIfNeeded({ sessionFile }); - - const sessionManager = SessionManager.open(sessionFile, dir, dir); - await prepareSessionManagerForRun({ - sessionManager, - sessionFile, - hadSessionFile: true, - sessionId: "longer-rewritten-session-id", - cwd: dir, - }); - - const originalParse = JSON.parse; - let parseCount = 0; - JSON.parse = function countedParse(...args: Parameters) { - parseCount += 1; - return originalParse.apply(originalParse, args); - } as typeof JSON.parse; - try { - await repairSessionFileIfNeeded({ - sessionFile, - trustedSnapshot: await readTrustedRepairSnapshot(sessionFile), - }); - expect(parseCount).toBeGreaterThanOrEqual(2); - } finally { - JSON.parse = originalParse; - } - }); - - it("does not rewrite a warm transcript when its header already matches the run", async () => { - const dir = await makeTempDir(); - const sessionFile = path.join(dir, "session.jsonl"); - const assistantEntry = { - type: "message", - id: "assistant-1", - parentId: null, - timestamp: "2026-06-04T00:00:01.000Z", - message: { role: "assistant", content: "carried context" }, - }; - await fs.writeFile( - sessionFile, - `${JSON.stringify(buildSessionHeader(dir))}\n${JSON.stringify(assistantEntry)}\n`, - "utf8", - ); - await fs.utimes(sessionFile, new Date(1_000), new Date(1_000)); - const before = await fs.stat(sessionFile); - const sessionManager = SessionManager.open(sessionFile, dir, dir); - - await prepareSessionManagerForRun({ - sessionManager, - sessionFile, - hadSessionFile: true, - sessionId: "test-session", - cwd: dir, - }); - - const after = await fs.stat(sessionFile); - expect(after.mtimeMs).toBe(before.mtimeMs); - }); }); -describe("parseSessionEntries", () => { - afterEach(() => { - vi.restoreAllMocks(); - }); - - it("parses valid JSONL lines without logging warnings", () => { - const warnSpy = vi.spyOn(Logger, "logWarn").mockImplementation(() => {}); - const content = [ - JSON.stringify({ type: "session", id: "s1" }), - JSON.stringify({ type: "message", id: "m1" }), - ].join("\n"); - - const entries = parseSessionEntries(content); - - expect(entries).toHaveLength(2); - expect(warnSpy).not.toHaveBeenCalled(); - }); - - it("logs a warning and skips malformed JSONL lines while preserving valid entries", () => { - const warnSpy = vi.spyOn(Logger, "logWarn").mockImplementation(() => {}); - const content = [ - JSON.stringify({ type: "session", id: "s1" }), - "not valid json {{{", - JSON.stringify({ type: "message", id: "m1" }), - ].join("\n"); - - const entries = parseSessionEntries(content); - - expect(entries).toHaveLength(2); - expect(warnSpy).toHaveBeenCalledTimes(1); - expect(warnSpy).toHaveBeenCalledWith( - expect.stringContaining("parseJsonlEntries: skipped 1 malformed JSONL line"), - ); - }); - - it("reports the correct skip count for multiple malformed lines", () => { - const warnSpy = vi.spyOn(Logger, "logWarn").mockImplementation(() => {}); - const content = [ - "bad line 1", - JSON.stringify({ type: "session", id: "s1" }), - "bad line 2", - "bad line 3", - ].join("\n"); - - const entries = parseSessionEntries(content); - - expect(entries).toHaveLength(1); - expect(warnSpy).toHaveBeenCalledTimes(1); - expect(warnSpy).toHaveBeenCalledWith( - expect.stringContaining("parseJsonlEntries: skipped 3 malformed JSONL line"), - ); - }); - - it("skips empty lines without counting them as malformed", () => { - const warnSpy = vi.spyOn(Logger, "logWarn").mockImplementation(() => {}); - const content = [ - "", - JSON.stringify({ type: "session", id: "s1" }), - "", - JSON.stringify({ type: "message", id: "m1" }), - "", - ].join("\n"); - - const entries = parseSessionEntries(content); - - expect(entries).toHaveLength(2); - expect(warnSpy).not.toHaveBeenCalled(); - }); - - it("parseJsonlEntries logs warning for malformed lines via loadEntriesFromFile", async () => { - const warnSpy = vi.spyOn(Logger, "logWarn").mockImplementation(() => {}); - const dir = await makeTempDir(); - const sessionFile = path.join(dir, "session.jsonl"); - const header = buildSessionHeader(dir); - const content = [ - JSON.stringify(header), - "not valid json {{{", - JSON.stringify(buildMessageEntry(1, null)), - ].join("\n"); - await fs.writeFile(sessionFile, content, "utf8"); - - const entries = loadEntriesFromFile(sessionFile); - - expect(entries.length).toBeGreaterThanOrEqual(1); - expect(warnSpy).toHaveBeenCalled(); - expect( - warnSpy.mock.calls.some((call) => - call[0].includes("parseJsonlEntries: skipped 1 malformed JSONL line"), - ), - ).toBe(true); - }); -}); - -function readMessageContent(entry: SessionEntry): unknown { - const content = (entry as { message: { content: unknown } }).message.content; - if (Array.isArray(content)) { - return content.map((part) => (part as { text?: string }).text ?? "").join(""); - } - return content; -} - -async function readTrustedRepairSnapshot(sessionFile: string) { - const stat = await fs.stat(sessionFile, { bigint: true }); - return { - dev: stat.dev, - ino: stat.ino, - size: stat.size, - mtimeNs: stat.mtimeNs, - ctimeNs: stat.ctimeNs, - }; -} - function buildAssistantMessage(text: string) { return { role: "assistant" as const, @@ -3329,24 +982,3 @@ function buildAssistantMessage(text: string) { timestamp: Date.now(), }; } - -function buildSessionHeader(cwd: string, id = "test-session") { - return { - type: "session", - version: CURRENT_SESSION_VERSION, - id, - timestamp: "2026-06-04T00:00:00.000Z", - cwd, - }; -} - -function buildMessageEntry(index: number, parentId: string | null): SessionEntry { - return { - type: "message", - id: `entry-${index}`, - parentId, - timestamp: `2026-06-04T00:00:0${index}.000Z`, - message: { role: "user", content: `message ${index}`, timestamp: index }, - }; -} -/* oxlint-disable max-lines -- TODO: split this grandfathered oversized file. */ diff --git a/src/agents/sessions/session-manager.tool-result-replay.test.ts b/src/agents/sessions/session-manager.tool-result-replay.test.ts index b3f93dfb210b..8ddff843136a 100644 --- a/src/agents/sessions/session-manager.tool-result-replay.test.ts +++ b/src/agents/sessions/session-manager.tool-result-replay.test.ts @@ -5,7 +5,7 @@ import { streamAnthropic } from "@openclaw/ai/internal/anthropic"; import { afterEach, describe, expect, it } from "vitest"; import type { Context, Message, Model } from "../../llm/types.js"; import type { AgentMessage } from "../runtime/index.js"; -import { SessionManager } from "./session-manager.js"; +import { parseSessionEntries, SessionManager } from "./session-manager.js"; const tempPaths: string[] = []; @@ -144,6 +144,13 @@ async function writeSessionWithAssistantContent( await fs.writeFile(sessionFile, `${entries.map((entry) => JSON.stringify(entry)).join("\n")}\n`); } +async function openSessionFile(sessionFile: string): Promise { + return SessionManager.fromEntries( + parseSessionEntries(await fs.readFile(sessionFile, "utf8")), + "/tmp/tool-result-replay", + ); +} + describe("SessionManager tool-result replay", () => { afterEach(async () => { await Promise.all( @@ -156,7 +163,7 @@ describe("SessionManager tool-result replay", () => { const sessionFile = path.join(dir, "session.jsonl"); await writeSessionWithToolResultContent(sessionFile, "lookup result text"); - const sessionManager = SessionManager.open(sessionFile, dir, "/tmp/tool-result-replay"); + const sessionManager = await openSessionFile(sessionFile); const context = sessionManager.buildSessionContext(); const toolResult = context.messages.find((message) => message.role === "toolResult"); if (!toolResult || toolResult.role !== "toolResult") { @@ -170,11 +177,7 @@ describe("SessionManager tool-result replay", () => { const dir = await makeTempDir(); const sessionFile = path.join(dir, "session.jsonl"); await writeSessionWithAssistantContent(sessionFile, "assistant replay text"); - const context = SessionManager.open( - sessionFile, - dir, - "/tmp/tool-result-replay", - ).buildSessionContext(); + const context = (await openSessionFile(sessionFile)).buildSessionContext(); const assistant = context.messages.find((message) => message.role === "assistant"); if (!assistant || assistant.role !== "assistant") { throw new Error("assistant message missing"); @@ -207,11 +210,7 @@ describe("SessionManager tool-result replay", () => { const dir = await makeTempDir(); const sessionFile = path.join(dir, "session.jsonl"); await writeSessionWithToolResultContent(sessionFile, "lookup result text"); - const context = SessionManager.open( - sessionFile, - dir, - "/tmp/tool-result-replay", - ).buildSessionContext(); + const context = (await openSessionFile(sessionFile)).buildSessionContext(); let capturedPayload: unknown; const stream = streamAnthropic(makeAnthropicModel(), toLlmContext(context), { @@ -243,11 +242,7 @@ describe("SessionManager tool-result replay", () => { const content = { output: "status card text" }; await writeSessionWithToolResultContent(sessionFile, content); - const context = SessionManager.open( - sessionFile, - dir, - "/tmp/tool-result-replay", - ).buildSessionContext(); + const context = (await openSessionFile(sessionFile)).buildSessionContext(); const toolResult = context.messages.find((message) => message.role === "toolResult"); if (!toolResult || toolResult.role !== "toolResult") { throw new Error("tool result message missing"); diff --git a/src/agents/sessions/session-manager.ts b/src/agents/sessions/session-manager.ts index 97590d7a7b2b..552ab15fbe7e 100644 --- a/src/agents/sessions/session-manager.ts +++ b/src/agents/sessions/session-manager.ts @@ -1,23 +1,16 @@ /** - * Session tree manager backed by SQLite markers or explicit standalone files. + * Session tree manager backed by an explicit SQLite transcript identity. * * The public facade lives here; codec, storage, persistence, and branching * behavior are split into focused internal modules. */ -import { resolve } from "node:path"; import { loadTranscriptEventsSync } from "../../config/sessions/session-accessor.js"; +import type { SessionTranscriptRuntimeTarget } from "../../config/sessions/session-accessor.types.js"; import { CURRENT_SESSION_VERSION } from "../../config/sessions/version.js"; import type { ImageContent, Message, TextContent } from "../../llm/types.js"; import type { BashExecutionMessage, CustomMessage } from "./messages.js"; import { SessionManagerBranching } from "./session-manager-branching.js"; -import type { SqliteSessionManagerPersistence } from "./session-manager-core.js"; -import { - getDefaultSessionDir, - loadEntriesFromFileWithSnapshot, - loadSqliteMarkedSessionFile, - revalidateLoadedSessionFile, - type LoadedSessionFile, -} from "./session-manager-file.js"; +import type { SessionManagerPersistenceTarget } from "./session-manager-core.js"; import type { AppendPersistenceOptions, FileEntry, @@ -28,6 +21,7 @@ import type { SessionContext, SessionEntry, SessionHeader, + SessionLeafControl, SessionTreeNode, } from "./session-manager-types.js"; @@ -39,7 +33,6 @@ export { normalizeLoadedFileEntry, parseSessionEntries, } from "./session-manager-codec.js"; -export { getDefaultSessionDir, loadEntriesFromFile } from "./session-manager-file.js"; export type { BranchSummaryEntry, CompactionEntry, @@ -49,6 +42,8 @@ export type { LabelEntry, ModelChangeEntry, NewSessionOptions, + PromptReleasedSessionEntry, + PromptReleasedSessionMergeResult, ResetEntry, ResetReason, SessionContext, @@ -56,6 +51,7 @@ export type { SessionEntryBase, SessionHeader, SessionInfoEntry, + SessionLeafControl, SessionMessageEntry, SessionTreeNode, ThinkingLevelChangeEntry, @@ -64,31 +60,28 @@ export type { export class SessionManager extends SessionManagerBranching { private constructor( cwd: string, - sessionDir: string, - sessionFile: string | undefined, - persist: boolean, - loadedSessionFile?: LoadedSessionFile, - sqlitePersistence?: SqliteSessionManagerPersistence, + persistenceTarget?: SessionManagerPersistenceTarget, + loadedEntries?: FileEntry[], ) { - super(cwd, sessionDir, sessionFile, persist, loadedSessionFile, sqlitePersistence); + super(cwd, persistenceTarget, loadedEntries); } - override setSessionFile(sessionFile: string): void { - super.setSessionFile(sessionFile); + override setSessionTarget(target: SessionManagerPersistenceTarget): void { + super.setSessionTarget(target); } override newSession(options?: NewSessionOptions): string | undefined { return super.newSession(options); } - override getSerializedFileLinesForRewrite(): string[] { - return super.getSerializedFileLinesForRewrite(); - } - override clearPreservedOpaqueFileEntries(): void { super.clearPreservedOpaqueFileEntries(); } + override getPersistedEntries(): unknown[] { + return super.getPersistedEntries(); + } + /** Makes pending append-oriented persistence durable without rewriting committed entries. */ override flushPendingPersistence(): void { super.flushPendingPersistence(); @@ -102,20 +95,12 @@ export class SessionManager extends SessionManagerBranching { return super.getCwd(); } - override getSessionDir(): string { - return super.getSessionDir(); - } - override getSessionId(): string { return super.getSessionId(); } - override wasRecoveredFromCorruptHeader(): boolean { - return super.wasRecoveredFromCorruptHeader(); - } - - override getSessionFile(): string | undefined { - return super.getSessionFile(); + override getSessionTarget(): SessionManagerPersistenceTarget | undefined { + return super.getSessionTarget(); } override removeTrailingEntries( @@ -129,10 +114,6 @@ export class SessionManager extends SessionManagerBranching { super.persist(entry, options); } - override syncSnapshotAfterHeaderRewrite(expectedContent?: string): void { - super.syncSnapshotAfterHeaderRewrite(expectedContent); - } - override mergePromptReleasedSessionEntries( entries: readonly PromptReleasedSessionEntry[], options?: { persistLeaf?: boolean }, @@ -194,6 +175,22 @@ export class SessionManager extends SessionManagerBranching { return super.getLeafId(); } + override getAppendParentId(): string | null { + return super.getAppendParentId(); + } + + override getAppendMode(): "side" | undefined { + return super.getAppendMode(); + } + + override appendLeafControl(params: { + targetId: string | null; + appendParentId: string | null; + appendMode?: "side"; + }): SessionLeafControl { + return super.appendLeafControl(params); + } + override getLeafEntry(): SessionEntry | undefined { return super.getLeafEntry(); } @@ -259,47 +256,35 @@ export class SessionManager extends SessionManagerBranching { return super.createBranchedSession(leafId); } - static create(cwd: string, sessionDir?: string): SessionManager { - const directory = sessionDir ?? getDefaultSessionDir(cwd); - return new SessionManager(cwd, directory, undefined, true); - } - - static open(path: string, sessionDir?: string, cwdOverride?: string): SessionManager { - const sqliteLoaded = loadSqliteMarkedSessionFile( - path, - (marker) => loadTranscriptEventsSync(marker) as FileEntry[], - { cwdOverride }, + static open(target: SessionTranscriptRuntimeTarget, cwdOverride?: string): SessionManager { + const entries = loadTranscriptEventsSync(target) as FileEntry[]; + const header = entries.find( + (entry) => typeof entry === "object" && entry !== null && entry.type === "session", ); - if (sqliteLoaded) { - return new SessionManager( - sqliteLoaded.cwd, - sessionDir ?? "", - path, - true, - { entries: sqliteLoaded.entries, snapshot: undefined }, - { ...sqliteLoaded.sqliteMarker, sessionKey: sqliteLoaded.sessionKey }, - ); - } - - const loaded = revalidateLoadedSessionFile(path, loadEntriesFromFileWithSnapshot(path)); - const header = loaded.entries.find((entry) => entry.type === "session"); - const cwd = cwdOverride ?? header?.cwd ?? process.cwd(); - const directory = sessionDir ?? resolve(path, ".."); - return new SessionManager(cwd, directory, path, true, loaded); + return new SessionManager(cwdOverride ?? header?.cwd ?? process.cwd(), target, entries); } static inMemory(cwd: string = process.cwd()): SessionManager { - return new SessionManager(cwd, "", undefined, false); + return new SessionManager(cwd); + } + + static fromEntries(entries: readonly unknown[], cwdOverride?: string): SessionManager { + const fileEntries = structuredClone(entries) as FileEntry[]; + const header = fileEntries.find( + (entry) => typeof entry === "object" && entry !== null && entry.type === "session", + ); + return new SessionManager(cwdOverride ?? header?.cwd ?? process.cwd(), undefined, fileEntries); } } export type ReadonlySessionManager = Pick< SessionManager, | "getCwd" - | "getSessionDir" | "getSessionId" - | "getSessionFile" + | "getSessionTarget" | "getLeafId" + | "getAppendParentId" + | "getAppendMode" | "getLeafEntry" | "getEntry" | "getLabel" diff --git a/src/agents/sessions/session-migrate-id-dedup.test.ts b/src/agents/sessions/session-migrate-id-dedup.test.ts index 59215a4ddefd..a7a036c747e2 100644 --- a/src/agents/sessions/session-migrate-id-dedup.test.ts +++ b/src/agents/sessions/session-migrate-id-dedup.test.ts @@ -1,6 +1,7 @@ import { mkdtempSync, readFileSync, writeFileSync } from "node:fs"; import { tmpdir } from "node:os"; import { join } from "node:path"; +import "../../../test/helpers/session-manager-file-compat.js"; import { expectDefined } from "@openclaw/normalization-core"; import { describe, expect, it, vi } from "vitest"; @@ -53,7 +54,7 @@ describe("v1 session migration id assignment", () => { "cafef00d-0000-4000-8000-000000000000", ); - const sm = SessionManager.open(file, dir); + const sm = SessionManager.openFile(file, dir); const messages = sm .getEntries() @@ -111,7 +112,7 @@ describe("v1 session migration id assignment", () => { .join("\n") + "\n", ); - const sm = SessionManager.open(file, dir); + const sm = SessionManager.openFile(file, dir); const kept = sm .getEntries() .find( diff --git a/src/agents/subagent-announce-output.test.ts b/src/agents/subagent-announce-output.test.ts index 141fcb70cf4c..733e031b3547 100644 --- a/src/agents/subagent-announce-output.test.ts +++ b/src/agents/subagent-announce-output.test.ts @@ -208,7 +208,7 @@ describe("readSubagentOutput", () => { await expect(readSubagentOutput("agent:main:subagent:child")).resolves.toBeUndefined(); }); - it("reads recovered output from the private transcript before gateway history", async () => { + it("reads recovered output from the private SQLite transcript before gateway history", async () => { const deps = installOutputDeps({ messages: [ { @@ -229,13 +229,20 @@ describe("readSubagentOutput", () => { // stale gateway-visible history after an internal completion is persisted. await expect( readSubagentOutput("agent:main:subagent:child", undefined, { - sessionFile: "/tmp/openclaw-internal-run.jsonl", + sessionTarget: { + agentId: "main", + sessionId: "child-session", + sessionKey: "agent:main:subagent:child", + storePath: "/tmp/openclaw/agents/main/sessions/sessions.json", + }, }), ).resolves.toBe("fresh recovered output"); expect(deps.readSessionMessagesAsync).toHaveBeenCalledWith( { - sessionFile: "/tmp/openclaw-internal-run.jsonl", - sessionId: "agent:main:subagent:child", + agentId: "main", + sessionId: "child-session", + sessionKey: "agent:main:subagent:child", + storePath: "/tmp/openclaw/agents/main/sessions/sessions.json", }, { mode: "recent", maxMessages: 100, maxBytes: 1024 * 1024 }, ); @@ -255,7 +262,12 @@ describe("readSubagentOutput", () => { await expect( readSubagentOutput("agent:main:subagent:child", undefined, { - sessionFile: "/tmp/openclaw-empty-internal-run.jsonl", + sessionTarget: { + agentId: "main", + sessionId: "child-session", + sessionKey: "agent:main:subagent:child", + storePath: "/tmp/openclaw/agents/main/sessions/sessions.json", + }, }), ).resolves.toBeUndefined(); expect(deps.callGateway).not.toHaveBeenCalled(); diff --git a/src/agents/subagent-announce-output.ts b/src/agents/subagent-announce-output.ts index 7ac089be1c93..88bed3215b71 100644 --- a/src/agents/subagent-announce-output.ts +++ b/src/agents/subagent-announce-output.ts @@ -5,6 +5,7 @@ */ import { asFiniteNumber } from "@openclaw/normalization-core/number-coercion"; import { isSilentReplyText, SILENT_REPLY_TOKEN } from "../auto-reply/tokens.js"; +import type { SessionTranscriptRuntimeTarget } from "../config/sessions/session-accessor.js"; import { isFastTestRuntimeEnv } from "../infra/env.js"; import { formatDurationCompact } from "../infra/format-time/format-duration.js"; import { buildAgentRunTerminalOutcomeFromWaitResult } from "./agent-run-terminal-outcome.js"; @@ -205,15 +206,12 @@ function selectSubagentOutputText(snapshot: SubagentOutputSnapshot): string | un export async function readSubagentOutput( sessionKey: string, _outcome?: SubagentRunOutcome, - options?: { sessionFile?: string }, + options?: { sessionTarget?: SessionTranscriptRuntimeTarget }, ): Promise { let messages: unknown[] | undefined; - if (options?.sessionFile) { + if (options?.sessionTarget) { const transcriptMessages = await subagentAnnounceOutputDeps.readSessionMessagesAsync( - { - sessionFile: options.sessionFile, - sessionId: sessionKey, - }, + options.sessionTarget, { mode: "recent", maxMessages: 100, @@ -308,7 +306,11 @@ export function applySubagentWaitOutcome(params: { export async function captureSubagentCompletionReply( sessionKey: string, - options?: { waitForReply?: boolean; outcome?: SubagentRunOutcome; sessionFile?: string }, + options?: { + waitForReply?: boolean; + outcome?: SubagentRunOutcome; + sessionTarget?: SessionTranscriptRuntimeTarget; + }, ): Promise { return await captureSubagentCompletionReplyUsing({ sessionKey, @@ -317,7 +319,7 @@ export async function captureSubagentCompletionReply( retryIntervalMs: isFastTestMode() ? FAST_TEST_RETRY_INTERVAL_MS : 100, readSubagentOutput: async (nextSessionKey) => await readSubagentOutput(nextSessionKey, options?.outcome, { - sessionFile: options?.sessionFile, + sessionTarget: options?.sessionTarget, }), }); } diff --git a/src/agents/subagent-registry-lifecycle-delivery.ts b/src/agents/subagent-registry-lifecycle-delivery.ts index a5aa92e0d0ac..47a9148022df 100644 --- a/src/agents/subagent-registry-lifecycle-delivery.ts +++ b/src/agents/subagent-registry-lifecycle-delivery.ts @@ -1,5 +1,11 @@ import { uniqueStrings } from "@openclaw/normalization-core/string-normalization"; -import { formatSqliteSessionFileMarker } from "../config/sessions/sqlite-marker.js"; +import { resolveStorePath } from "../config/sessions/paths.js"; +import { + loadSessionEntryReadOnly, + type SessionTranscriptRuntimeTarget, +} from "../config/sessions/session-accessor.js"; +import { resolveSessionStorePathForScope } from "../config/sessions/session-store-path.js"; +import { resolveAgentIdFromSessionKey } from "../routing/session-key.js"; import { extractTextFromChatContent } from "../shared/chat-content.js"; import type { DetachedTaskFindResult } from "../tasks/detached-task-runtime-contract.js"; import { @@ -264,16 +270,34 @@ export function createSubagentRegistryLifecycleDelivery( } let resultText: string | null; try { + const transcriptTarget = entry.execution?.transcriptTarget; + const agentId = + transcriptTarget?.agentId ?? resolveAgentIdFromSessionKey(entry.childSessionKey); + const sessionKey = transcriptTarget?.sessionKey ?? entry.childSessionKey; + const configuredStorePath = agentId + ? (transcriptTarget?.storePath ?? + resolveStorePath(params.getRuntimeConfig().session?.store, { agentId })) + : undefined; + const storePath = configuredStorePath + ? resolveSessionStorePathForScope({ + agentId, + sessionKey, + storePath: configuredStorePath, + }) + : undefined; + const sessionId = + transcriptTarget?.sessionId ?? + (agentId && storePath + ? loadSessionEntryReadOnly({ agentId, sessionKey, storePath })?.sessionId + : undefined); + const sessionTarget: SessionTranscriptRuntimeTarget | undefined = + agentId && sessionId && storePath + ? { agentId, sessionId, sessionKey, storePath } + : undefined; const captured = await params.captureSubagentCompletionReply(entry.childSessionKey, { waitForReply: entry.expectsCompletionMessage === true, outcome, - sessionFile: entry.execution?.transcriptTarget?.storePath - ? formatSqliteSessionFileMarker({ - agentId: entry.execution.transcriptTarget.agentId ?? "", - sessionId: entry.execution.transcriptTarget.sessionId ?? "", - storePath: entry.execution.transcriptTarget.storePath, - }) - : undefined, + ...(sessionTarget ? { sessionTarget } : {}), }); resultText = captured?.trim() ? capFrozenResultText(captured) : null; } catch { diff --git a/src/agents/subagent-registry-lifecycle.test.ts b/src/agents/subagent-registry-lifecycle.test.ts index 28f36c47db9c..5da53df58574 100644 --- a/src/agents/subagent-registry-lifecycle.test.ts +++ b/src/agents/subagent-registry-lifecycle.test.ts @@ -1,6 +1,7 @@ // Subagent registry lifecycle tests cover completion, cleanup, announce retry, // detached task status, and resource retirement around child-run endings. import { beforeEach, describe, expect, it, vi } from "vitest"; +import { resolveSessionStorePathForScope } from "../config/sessions/session-store-path.js"; import type { CallGatewayOptions } from "../gateway/call.js"; import { getActiveGatewayRootWorkCount, @@ -2570,6 +2571,15 @@ describe("subagent registry lifecycle hardening", () => { it("does not wait for a completion reply when the run does not expect one", async () => { const entry = createRunEntry({ expectsCompletionMessage: false, + execution: { + status: "running", + transcriptTarget: { + agentId: "main", + sessionId: "child-session", + sessionKey: "agent:main:subagent:child", + storePath: "/tmp/openclaw/agents/main/sessions/sessions.json", + }, + }, }); const captureSubagentCompletionReply = vi.fn(async () => undefined); @@ -2583,6 +2593,7 @@ describe("subagent registry lifecycle hardening", () => { expect(captureSubagentCompletionReply).toHaveBeenCalledWith(entry.childSessionKey, { waitForReply: false, + sessionTarget: entry.execution?.transcriptTarget, outcome: { status: "ok", startedAt: 2_000, @@ -2592,6 +2603,54 @@ describe("subagent registry lifecycle hardening", () => { }); }); + it("scopes fallback completion capture to the incognito child store", async () => { + const childSessionKey = "agent:main:subagent:incognito-child"; + const durableStorePath = "/tmp/durable-sessions.json"; + const entry = createRunEntry({ + childSessionKey, + expectsCompletionMessage: false, + execution: { + status: "running", + transcriptTarget: { + agentId: "main", + sessionId: "incognito-child-session", + sessionKey: childSessionKey, + }, + }, + }); + const captureSubagentCompletionReply = vi.fn(async () => undefined); + const controller = createLifecycleController({ + entry, + captureSubagentCompletionReply, + getRuntimeConfig: () => ({ session: { store: durableStorePath } }), + runSubagentAnnounceFlow: vi.fn(async () => false), + }); + + await controller.completeSubagentRun({ + runId: entry.runId, + endedAt: 4_000, + outcome: { status: "ok" }, + reason: SUBAGENT_ENDED_REASON_COMPLETE, + triggerCleanup: false, + }); + + expect(captureSubagentCompletionReply).toHaveBeenCalledWith( + childSessionKey, + expect.objectContaining({ + sessionTarget: { + agentId: "main", + sessionId: "incognito-child-session", + sessionKey: childSessionKey, + storePath: resolveSessionStorePathForScope({ + agentId: "main", + sessionKey: childSessionKey, + storePath: durableStorePath, + }), + }, + }), + ); + }); + it("does not freeze stale reply text for terminal error outcomes", async () => { const persistOrThrow = vi.fn(); const captureSubagentCompletionReply = vi.fn(async () => "stale assistant text"); diff --git a/src/agents/subagent-registry.persistence.test-support.ts b/src/agents/subagent-registry.persistence.test-support.ts index bb4870592332..89d9cd9819e3 100644 --- a/src/agents/subagent-registry.persistence.test-support.ts +++ b/src/agents/subagent-registry.persistence.test-support.ts @@ -23,7 +23,7 @@ function resolveSubagentSessionStorePath(stateDir: string, agentId: string): str export async function readSubagentSessionStore(storePath: string): Promise { return Object.fromEntries( listSessionEntries({ storePath }).map(({ sessionKey, entry }) => [sessionKey, entry]), - ) as SessionStore; + ) as unknown as SessionStore; } /** Writes or updates one SQLite-backed subagent session entry for persistence tests. */ diff --git a/src/agents/subagent-spawn-context.ts b/src/agents/subagent-spawn-context.ts index 402a2c80b048..41ec0cc2af7d 100644 --- a/src/agents/subagent-spawn-context.ts +++ b/src/agents/subagent-spawn-context.ts @@ -144,15 +144,13 @@ export async function prepareContextEngineSubagentSpawn(params: { childSessionKey: params.childSessionKey, contextMode: params.context.mode, parentSessionId: params.context.parentEntry?.sessionId, - parentSessionFile: params.context.parentEntry?.sessionFile, + parentSessionFile: params.requesterInternalKey, childSessionId: params.context.mode === "fork" ? params.context.forked.sessionId : params.context.childEntry?.sessionId, childSessionFile: - params.context.mode === "fork" - ? params.context.forked.sessionFile - : params.context.childEntry?.sessionFile, + params.context.mode === "fork" ? params.context.forked.sessionFile : params.childSessionKey, ttlMs: finiteSecondsToTimerSafeMilliseconds(params.runTimeoutSeconds, { floorSeconds: true, }), diff --git a/src/auto-reply/reply.triggers.trigger-handling.e2e.test.ts b/src/auto-reply/reply.triggers.trigger-handling.e2e.test.ts index bbb54d2ebb82..dcba7c0a7adc 100644 --- a/src/auto-reply/reply.triggers.trigger-handling.e2e.test.ts +++ b/src/auto-reply/reply.triggers.trigger-handling.e2e.test.ts @@ -17,12 +17,12 @@ import { } from "../../test/helpers/auto-reply/trigger-handling-test-harness.js"; import { saveAuthProfileStore } from "../agents/auth-profiles/store.js"; import { resolveSessionKey } from "../config/sessions.js"; +import { parseSqliteSessionFileMarker } from "../config/sessions/legacy-sqlite-marker.js"; import { loadExactSessionEntry, loadSessionEntry, replaceSessionEntry, } from "../config/sessions/session-accessor.js"; -import { parseSqliteSessionFileMarker } from "../config/sessions/sqlite-marker.js"; import { registerGroupIntroPromptCases } from "./reply.triggers.group-intro-prompts.cases.js"; import { registerTriggerHandlingUsageSummaryCases } from "./reply.triggers.trigger-handling.filters-usage-summary-current-model-provider.cases.js"; import { buildControlUiAgentFailureText } from "./reply/agent-runner-failure-copy.js"; diff --git a/src/auto-reply/reply/agent-runner-core.test.ts b/src/auto-reply/reply/agent-runner-core.test.ts new file mode 100644 index 000000000000..7e04051a517d --- /dev/null +++ b/src/auto-reply/reply/agent-runner-core.test.ts @@ -0,0 +1,27 @@ +import { describe, expect, it } from "vitest"; +import { resolveAdmittedRunSessionFile } from "./agent-runner-core.js"; + +describe("resolveAdmittedRunSessionFile", () => { + it("uses the scoped session key when one is available", () => { + expect( + resolveAdmittedRunSessionFile({ + agentId: "main", + sessionId: "session", + sessionFile: "legacy-target", + sessionKey: " agent:main:session ", + storePath: "/tmp/sessions.json", + }), + ).toBe("agent:main:session"); + }); + + it("preserves the admitted fallback when a persisted run has no session key", () => { + expect( + resolveAdmittedRunSessionFile({ + agentId: "main", + sessionId: "session", + sessionFile: "legacy-target", + storePath: "/tmp/sessions.json", + }), + ).toBe("legacy-target"); + }); +}); diff --git a/src/auto-reply/reply/agent-runner-core.ts b/src/auto-reply/reply/agent-runner-core.ts index 10ed7b14747d..417af90823c2 100644 --- a/src/auto-reply/reply/agent-runner-core.ts +++ b/src/auto-reply/reply/agent-runner-core.ts @@ -9,10 +9,6 @@ import { type SessionEntry, } from "../../config/sessions.js"; import { loadSessionEntryReadOnly } from "../../config/sessions/session-accessor.js"; -import { - formatSqliteSessionFileMarker, - sqliteSessionFileMarkerMatchesSession, -} from "../../config/sessions/sqlite-marker.js"; import { parseSessionThreadInfoFast } from "../../config/sessions/thread-info.js"; import type { TypingMode } from "../../config/types.js"; import { logVerbose } from "../../globals.js"; @@ -356,20 +352,11 @@ export function resolveAdmittedRunSessionFile(params: { agentId: string; sessionId: string; sessionFile?: string; + sessionKey?: string; storePath?: string; }): string | undefined { - if ( - params.sessionFile && - sqliteSessionFileMarkerMatchesSession(params.sessionFile, params.sessionId) - ) { - return params.sessionFile; - } - if (params.storePath) { - return formatSqliteSessionFileMarker({ - agentId: params.agentId, - sessionId: params.sessionId, - storePath: params.storePath, - }); + if (params.sessionKey?.trim()) { + return params.sessionKey.trim(); } return params.sessionFile; } diff --git a/src/auto-reply/reply/agent-runner-memory.test.ts b/src/auto-reply/reply/agent-runner-memory.test.ts index 5ea60c1e24a9..61e03d5bfa7f 100644 --- a/src/auto-reply/reply/agent-runner-memory.test.ts +++ b/src/auto-reply/reply/agent-runner-memory.test.ts @@ -14,7 +14,7 @@ import { upsertSessionEntry, } from "../../config/sessions/session-accessor.js"; import { replaceSqliteTranscriptEvents } from "../../config/sessions/session-accessor.sqlite.js"; -import { formatSqliteSessionFileMarker } from "../../config/sessions/sqlite-marker.js"; +import { resolveSessionStorePathForScope } from "../../config/sessions/session-store-path.js"; import { clearMemoryPluginState, registerMemoryCapability, @@ -308,15 +308,6 @@ describe("runMemoryFlushIfNeeded", () => { }; if (typeof params.newSessionId === "string" && params.newSessionId) { nextEntry.sessionId = params.newSessionId; - if (typeof params.newSessionFile === "string" && params.newSessionFile) { - nextEntry.sessionFile = params.newSessionFile; - } else { - const storePath = typeof params.storePath === "string" ? params.storePath : rootDir; - nextEntry.sessionFile = path.join( - path.dirname(storePath), - `${params.newSessionId}.jsonl`, - ); - } } params.sessionStore[sessionKey] = nextEntry; if (typeof params.storePath === "string") { @@ -333,6 +324,10 @@ describe("runMemoryFlushIfNeeded", () => { incrementCompactionCount: incrementCompactionCountMock as never, registerAgentRunContext: vi.fn() as never, emitAgentEvent: emitAgentEventMock as never, + resolveSessionLogPath: ( + _sessionId: string | undefined, + entry: (SessionEntry & { transcriptPath?: string }) | undefined, + ) => entry?.transcriptPath, randomUUID: () => "00000000-0000-0000-0000-000000000001", now: () => 1_700_000_000_000, }); @@ -416,7 +411,7 @@ describe("runMemoryFlushIfNeeded", () => { expect(refreshCall.key).toBe(sessionKey); expect(refreshCall.previousSessionId).toBe("session"); expect(refreshCall.nextSessionId).toBe("session-rotated"); - expect(refreshCall.nextSessionFile).toContain("session-rotated.jsonl"); + expect(refreshCall.nextSessionFile).toBe(sessionKey); const persisted = loadMainSessionEntry(storePath); expect(persisted.sessionId).toBe("session-rotated"); @@ -1358,7 +1353,7 @@ describe("runMemoryFlushIfNeeded", () => { }); const sessionEntry: SessionEntry = { sessionId: "session", - sessionFile, + transcriptPath: sessionFile, updatedAt: Date.now(), totalTokens: 120, totalTokensFresh: true, @@ -1453,7 +1448,7 @@ describe("runMemoryFlushIfNeeded", () => { }); const sessionEntry: SessionEntry = { sessionId: "session", - sessionFile, + transcriptPath: sessionFile, updatedAt: Date.now(), totalTokens: 120, totalTokensFresh: true, @@ -1501,7 +1496,7 @@ describe("runMemoryFlushIfNeeded", () => { })); const sessionEntry: SessionEntry = { sessionId: "session", - sessionFile, + transcriptPath: sessionFile, updatedAt: Date.now(), totalTokens: 120, totalTokensFresh: true, @@ -1562,7 +1557,7 @@ describe("runMemoryFlushIfNeeded", () => { }); const sessionEntry: SessionEntry = { sessionId: "session", - sessionFile, + transcriptPath: sessionFile, updatedAt: Date.now(), totalTokens: 120, totalTokensFresh: true, @@ -1615,7 +1610,7 @@ describe("runMemoryFlushIfNeeded", () => { }); const sessionEntry: SessionEntry = { sessionId: "session", - sessionFile, + transcriptPath: sessionFile, updatedAt: Date.now(), totalTokens: 120, totalTokensFresh: true, @@ -1670,7 +1665,7 @@ describe("runMemoryFlushIfNeeded", () => { }); const sessionEntry: SessionEntry = { sessionId: "session", - sessionFile, + transcriptPath: sessionFile, updatedAt: Date.now(), totalTokens: 120, totalTokensFresh: true, @@ -1706,7 +1701,7 @@ describe("runMemoryFlushIfNeeded", () => { const sessionFile = path.join(rootDir, "budget-session.jsonl"); const sessionEntry: SessionEntry = { sessionId: "session", - sessionFile, + transcriptPath: sessionFile, updatedAt: Date.now(), totalTokens: 245_000, totalTokensFresh: true, @@ -1750,16 +1745,12 @@ describe("runMemoryFlushIfNeeded", () => { const sessionKey = "agent:main:main"; const sessionEntry: SessionEntry = { sessionId: "session", - sessionFile: formatSqliteSessionFileMarker({ - agentId: "main", - sessionId: "session", - storePath, - }), updatedAt: Date.now(), totalTokens: 985, totalTokensFresh: true, compactionCount: 0, }; + await upsertSessionEntry({ agentId: "main", sessionKey, storePath }, sessionEntry); await runPreflightCompactionIfNeeded({ cfg: { agents: { defaults: { compaction: { memoryFlush: {} } } } }, @@ -1845,7 +1836,7 @@ describe("runMemoryFlushIfNeeded", () => { }); const sessionEntry: SessionEntry = { sessionId: "session", - sessionFile, + transcriptPath: sessionFile, updatedAt: Date.now(), totalTokensFresh: false, }; @@ -1871,15 +1862,15 @@ describe("runMemoryFlushIfNeeded", () => { }); expect(entry?.sessionId).toBe("session-rotated"); - expect(entry?.sessionFile).toBe(successorFile); + expect(entry?.sessionFile).toBeUndefined(); expect(followupRun.run.sessionId).toBe("session-rotated"); - expect(followupRun.run.sessionFile).toBe(successorFile); + expect(followupRun.run.sessionFile).toBe("agent:main:main"); expect(replyOperation.updateSessionId).toHaveBeenCalledWith("session-rotated"); expect(refreshQueuedFollowupSessionMock).toHaveBeenCalledWith({ key: "agent:main:main", previousSessionId: "session", nextSessionId: "session-rotated", - nextSessionFile: successorFile, + nextSessionFile: "agent:main:main", }); }); @@ -1906,7 +1897,7 @@ describe("runMemoryFlushIfNeeded", () => { })); const sessionEntry: SessionEntry = { sessionId: "session", - sessionFile, + transcriptPath: sessionFile, updatedAt: Date.now(), totalTokensFresh: false, }; @@ -1951,7 +1942,7 @@ describe("runMemoryFlushIfNeeded", () => { .mockImplementation(async (target, options) => originalStat(target, options)); const sessionEntry: SessionEntry = { sessionId: "session", - sessionFile, + transcriptPath: sessionFile, updatedAt: Date.now(), totalTokensFresh: false, }; @@ -1995,7 +1986,7 @@ describe("runMemoryFlushIfNeeded", () => { }); const sessionEntry: SessionEntry = { sessionId: "session", - sessionFile: path.join(rootDir, "required-preflight-session.jsonl"), + transcriptPath: path.join(rootDir, "required-preflight-session.jsonl"), updatedAt: Date.now(), totalTokens: 180_499, totalTokensFresh: true, @@ -2300,7 +2291,7 @@ describe("runMemoryFlushIfNeeded", () => { })); const sessionEntry: SessionEntry = { sessionId: "session", - sessionFile, + transcriptPath: sessionFile, updatedAt: Date.now(), totalTokensFresh: false, }; @@ -2356,7 +2347,7 @@ describe("runMemoryFlushIfNeeded", () => { })); const sessionEntry: SessionEntry = { sessionId: "session", - sessionFile, + transcriptPath: sessionFile, updatedAt: Date.now(), totalTokensFresh: false, }; @@ -2413,7 +2404,7 @@ describe("runMemoryFlushIfNeeded", () => { })); const sessionEntry: SessionEntry = { sessionId: "session", - sessionFile, + transcriptPath: sessionFile, updatedAt: Date.now(), totalTokensFresh: false, }; @@ -2468,7 +2459,7 @@ describe("runMemoryFlushIfNeeded", () => { })); const sessionEntry: SessionEntry = { sessionId: "session", - sessionFile, + transcriptPath: sessionFile, updatedAt: Date.now(), totalTokensFresh: false, }; @@ -2526,7 +2517,7 @@ describe("runMemoryFlushIfNeeded", () => { })); const sessionEntry: SessionEntry = { sessionId: "session", - sessionFile, + transcriptPath: sessionFile, updatedAt: Date.now(), totalTokensFresh: false, }; @@ -2585,7 +2576,7 @@ describe("runMemoryFlushIfNeeded", () => { ); const sessionEntry: SessionEntry = { sessionId: "session", - sessionFile, + transcriptPath: sessionFile, updatedAt: Date.now(), totalTokens: 10, totalTokensFresh: true, @@ -2641,11 +2632,6 @@ describe("runMemoryFlushIfNeeded", () => { const sessionEntry: SessionEntry = { sessionId: "session", - sessionFile: formatSqliteSessionFileMarker({ - agentId: "main", - sessionId: "session", - storePath, - }), updatedAt: Date.now(), totalTokens: 10, totalTokensFresh: true, @@ -2684,6 +2670,127 @@ describe("runMemoryFlushIfNeeded", () => { expect(compactCall.preflightCompactionTrigger).toBe("transcript_bytes"); }); + it("keeps incognito preflight compaction in the process-local transcript store", async () => { + const durableStorePath = path.join(rootDir, "durable-sessions.json"); + const sessionKey = "agent:main:dashboard:incognito-preflight"; + const sessionEntry: SessionEntry = { + sessionId: "incognito-session", + updatedAt: Date.now(), + totalTokens: 90_000, + totalTokensFresh: true, + compactionCount: 0, + }; + + await runPreflightCompactionIfNeeded({ + cfg: { agents: { defaults: { compaction: { memoryFlush: {} } } } }, + followupRun: createTestFollowupRun({ + sessionId: sessionEntry.sessionId, + sessionKey, + }), + defaultModel: "anthropic/claude-opus-4-6", + agentCfgContextTokens: 100_000, + sessionEntry, + sessionStore: { [sessionKey]: sessionEntry }, + sessionKey, + storePath: durableStorePath, + isHeartbeat: false, + replyOperation: createReplyOperation(), + }); + + const expectedStorePath = resolveSessionStorePathForScope({ + agentId: "main", + sessionKey, + storePath: durableStorePath, + }); + expect( + (requireCompactEmbeddedAgentSessionCall() as { sessionTarget?: Record }) + .sessionTarget, + ).toMatchObject({ + agentId: "main", + sessionId: sessionEntry.sessionId, + sessionKey, + storePath: expectedStorePath, + }); + expect(incrementCompactionCountMock).toHaveBeenCalledWith( + expect.objectContaining({ agentId: "main", sessionKey, storePath: expectedStorePath }), + ); + }); + + it("resolves usage from an active branch whose leaf target predates the bounded tail", async () => { + registerMemoryFlushPlanResolverForTest(() => ({ + softThresholdTokens: 4_000, + forceFlushTranscriptBytes: 1_000_000_000, + reserveTokensFloor: 0, + prompt: "Pre-compaction memory flush.\nNO_REPLY", + systemPrompt: "Write memory to memory/YYYY-MM-DD.md.", + relativePath: "memory/2023-11-14.md", + })); + const storePath = path.join(rootDir, "sqlite-deep-leaf-session.json"); + const sessionKey = "agent:main:deep-leaf"; + const scope = { agentId: "main", sessionId: "session", sessionKey, storePath }; + await upsertSessionEntry(scope, { sessionId: "session", updatedAt: 10 }); + const activeRoot = { + type: "message", + id: "active-root", + parentId: null, + timestamp: "2026-01-01T00:00:00.000Z", + message: { + role: "assistant", + content: "active", + usage: { input: 10, output: 5 }, + }, + }; + let parentId = activeRoot.id; + const abandonedBranch = Array.from({ length: 512 }, (_, index) => { + const id = `abandoned-${index}`; + const event = { + type: "message", + id, + parentId, + timestamp: `2026-01-01T00:00:${String(index % 60).padStart(2, "0")}.000Z`, + message: { + role: "assistant", + content: "abandoned", + usage: { input: 90_000, output: 10_000 }, + }, + }; + parentId = id; + return event; + }); + await replaceSqliteTranscriptEvents(scope, [ + activeRoot, + ...abandonedBranch, + { + type: "leaf", + id: "return-to-active-root", + parentId, + targetId: activeRoot.id, + appendParentId: activeRoot.id, + timestamp: "2026-01-01T00:01:00.000Z", + }, + ]); + const sessionEntry: SessionEntry = { + sessionId: "session", + updatedAt: Date.now(), + totalTokensFresh: false, + }; + + await runPreflightCompactionIfNeeded({ + cfg: { agents: { defaults: { compaction: { memoryFlush: {} } } } }, + followupRun: createTestFollowupRun({ sessionId: "session", sessionKey }), + defaultModel: "anthropic/claude-opus-4-6", + agentCfgContextTokens: 100_000, + sessionEntry, + sessionStore: { [sessionKey]: sessionEntry }, + sessionKey, + storePath, + isHeartbeat: false, + replyOperation: createReplyOperation(), + }); + + expect(compactEmbeddedAgentSessionMock).not.toHaveBeenCalled(); + }); + it("forces memory flush when a SQLite-backed transcript exceeds the byte threshold", async () => { registerMemoryFlushPlanResolverForTest(() => ({ softThresholdTokens: 4_000, @@ -2702,11 +2809,6 @@ describe("runMemoryFlushIfNeeded", () => { ]); const sessionEntry: SessionEntry = { sessionId: "session", - sessionFile: formatSqliteSessionFileMarker({ - agentId: "main", - sessionId: "session", - storePath, - }), updatedAt: Date.now(), totalTokens: 10, totalTokensFresh: true, @@ -2743,7 +2845,7 @@ describe("runMemoryFlushIfNeeded", () => { ); const sessionEntry: SessionEntry = { sessionId: "session", - sessionFile, + transcriptPath: sessionFile, updatedAt: Date.now(), totalTokens: 120, totalTokensFresh: true, @@ -2793,7 +2895,7 @@ describe("runMemoryFlushIfNeeded", () => { incrementCompactionCountMock.mockRejectedValueOnce(new Error("count update failed")); const sessionEntry: SessionEntry = { sessionId: "session", - sessionFile, + transcriptPath: sessionFile, updatedAt: Date.now(), totalTokens: 120, totalTokensFresh: true, @@ -2844,7 +2946,7 @@ describe("runMemoryFlushIfNeeded", () => { ); const sessionEntry: SessionEntry = { sessionId: "session", - sessionFile, + transcriptPath: sessionFile, updatedAt: Date.now(), totalTokens: 10, totalTokensFresh: true, diff --git a/src/auto-reply/reply/agent-runner-memory.ts b/src/auto-reply/reply/agent-runner-memory.ts index a636fafcab1b..8879c81f0783 100644 --- a/src/auto-reply/reply/agent-runner-memory.ts +++ b/src/auto-reply/reply/agent-runner-memory.ts @@ -33,27 +33,26 @@ import { import { resolveAgentIdFromSessionKey, resolveFreshSessionTotalTokens, - resolveSessionFilePath, - resolveSessionFilePathOptions, + resolveStorePath, type SessionEntry, } from "../../config/sessions.js"; +import { parseSqliteSessionFileMarker } from "../../config/sessions/legacy-sqlite-marker.js"; import { - readTranscriptStatsSync, + readRecentSessionTranscriptActiveEvents, + readSessionTranscriptActiveStats, updateSessionEntry, } from "../../config/sessions/session-accessor.js"; -import { - formatSqliteSessionFileMarker, - parseSqliteSessionFileMarker, -} from "../../config/sessions/sqlite-marker.js"; +import { resolveSessionStorePathForScope } from "../../config/sessions/session-store-path.js"; +import { selectSessionTranscriptLeafControlledPath } from "../../config/sessions/transcript-tree.js"; import type { OpenClawConfig } from "../../config/types.openclaw.js"; -import { readSessionMessagesAsync } from "../../gateway/session-utils.fs.js"; +import { readSessionMessagesAsync } from "../../gateway/session-transcript-readers.js"; import { logVerbose } from "../../globals.js"; import { isAbortError } from "../../infra/abort-signal.js"; import { emitAgentEvent, registerAgentRunContext } from "../../infra/agent-events.js"; import { formatErrorMessage } from "../../infra/errors.js"; import { resolveMemoryFlushPlan } from "../../plugins/memory-state.js"; import { CommandLane } from "../../process/lanes.js"; -import { isIncognitoSessionKey } from "../../routing/session-key.js"; +import { isIncognitoSessionKey, isUnscopedSessionKeySentinel } from "../../routing/session-key.js"; import { createLazyImportLoader } from "../../shared/lazy-promise.js"; import type { TemplateContext } from "../templating.js"; import type { VerboseLevel } from "../thinking.js"; @@ -166,6 +165,7 @@ const memoryDeps = { incrementCompactionCount, updateSessionEntry: updateSessionEntryDefault, emitAgentEvent, + resolveSessionLogPath, randomUUID: () => crypto.randomUUID(), now: () => Date.now(), }; @@ -182,6 +182,7 @@ function setAgentRunnerMemoryTestDeps(overrides?: Partial): v incrementCompactionCount, updateSessionEntry: updateSessionEntryDefault, emitAgentEvent, + resolveSessionLogPath, randomUUID: () => crypto.randomUUID(), now: () => Date.now(), ...overrides, @@ -373,6 +374,7 @@ type SessionTranscriptUsageSnapshot = { // transcript reads in time to flip memory-flush gating when needed. const TRANSCRIPT_OUTPUT_READ_BUFFER_TOKENS = 8192; const TRANSCRIPT_TAIL_CHUNK_BYTES = 64 * 1024; +const SQLITE_USAGE_TAIL_MAX_EVENTS = 512; const FALLBACK_TRANSCRIPT_BYTES_PER_TOKEN = 4; function parseUsageFromTranscriptLine(line: string): ReturnType | undefined { @@ -397,46 +399,14 @@ function parseUsageFromTranscriptLine(line: string): ReturnType | undefined { + const activeEvents = selectSessionTranscriptLeafControlledPath(events) ?? events; + for (const event of activeEvents.toReversed()) { + if (!event || typeof event !== "object" || Array.isArray(event)) { + continue; + } + const record = event as { message?: unknown; type?: unknown; usage?: UsageLike }; + if (record.type === "compaction" || record.type === "reset") { + return undefined; + } + const message = + record.message && typeof record.message === "object" && !Array.isArray(record.message) + ? (record.message as { usage?: UsageLike }) + : undefined; + const usage = normalizeUsage(message?.usage ?? record.usage); + if (usage && hasNonzeroUsage(usage)) { + return usage; + } + } + return undefined; +} + +function readSqliteSessionLogSnapshot( + scope: { agentId?: string; sessionId: string; sessionKey?: string; storePath: string }, + options: { includeByteSize: boolean; includeUsage: boolean }, +): SessionLogSnapshot { + const snapshot: SessionLogSnapshot = {}; + try { + if (options.includeByteSize) { + snapshot.byteSize = readSessionTranscriptActiveStats(scope).sizeBytes; + } + if (options.includeUsage) { + const events = readRecentSessionTranscriptActiveEvents(scope, SQLITE_USAGE_TAIL_MAX_EVENTS); + snapshot.usage = deriveTranscriptUsageSnapshot({ + usage: readLatestNonzeroUsageFromTranscriptEvents(events), + }); + } + } catch { + return snapshot; + } + return snapshot; +} + type SessionLogSnapshot = { byteSize?: number; usage?: SessionTranscriptUsageSnapshot; @@ -500,6 +515,7 @@ async function appendPostCompactionRefreshPrompt(params: { } async function readSessionLogSnapshot(params: { + agentId?: string; sessionId?: string; sessionEntry?: SessionEntry; sessionKey?: string; @@ -507,30 +523,39 @@ async function readSessionLogSnapshot(params: { includeByteSize: boolean; includeUsage: boolean; }): Promise { - const logPath = resolveSessionLogPath( + const logPath = memoryDeps.resolveSessionLogPath( params.sessionId, params.sessionEntry, params.sessionKey, params.opts, ); - if (!logPath) { - return {}; + const sqliteMarker = logPath ? parseSqliteSessionFileMarker(logPath) : undefined; + if (logPath && !sqliteMarker) { + return await readFileSessionLogSnapshot(logPath, params); + } + const agentId = + params.agentId ?? params.opts?.agentId ?? resolveAgentIdFromSessionKey(params.sessionKey); + if (params.sessionId && params.opts?.storePath && agentId) { + return readSqliteSessionLogSnapshot( + { + agentId, + sessionId: params.sessionId, + ...(params.sessionKey ? { sessionKey: params.sessionKey } : {}), + storePath: params.opts.storePath, + }, + params, + ); } - const sqliteMarker = parseSqliteSessionFileMarker(logPath); if (sqliteMarker) { - // SQLite-backed transcripts intentionally skip the legacy JSONL tail scan - // (there is no usage line to parse), but the byte-size guard below still - // needs a real value or it silently degrades to token-only triggering. - if (!params.includeByteSize) { - return {}; - } - try { - return { byteSize: readTranscriptStatsSync(sqliteMarker).sizeBytes }; - } catch { - return {}; - } + return readSqliteSessionLogSnapshot(sqliteMarker, params); } + return {}; +} +async function readFileSessionLogSnapshot( + logPath: string, + params: { includeByteSize: boolean; includeUsage: boolean }, +): Promise { const snapshot: SessionLogSnapshot = {}; let usageScan: SessionLogUsageScan | undefined; @@ -656,6 +681,7 @@ async function estimatePromptTokensFromSessionTranscript(params: { } try { const snapshot = await readSessionLogSnapshot({ + agentId: params.agentId, sessionId, sessionEntry: params.sessionEntry, sessionKey: params.sessionKey, @@ -689,9 +715,12 @@ async function estimatePromptTokensFromSessionTranscript(params: { }; } const messages = (await readSessionMessagesAsync( - sessionId, - params.storePath, - params.sessionEntry?.sessionFile, + { + agentId: params.agentId ?? resolveAgentIdFromSessionKey(params.sessionKey), + sessionId, + sessionKey: params.sessionKey, + storePath: params.storePath, + }, { mode: "recent", maxMessages: 200, @@ -789,6 +818,25 @@ export async function runPreflightCompactionIfNeeded(params: { return entry ?? params.sessionEntry; } + const compactionSessionKey = params.sessionKey ?? params.followupRun.run.sessionKey; + if (!compactionSessionKey) { + return entry ?? params.sessionEntry; + } + const keyAgentId = resolveAgentIdFromSessionKey( + compactionSessionKey, + params.followupRun.run.agentId, + ); + const compactionAgentId = isUnscopedSessionKeySentinel(compactionSessionKey) + ? (params.followupRun.run.agentId ?? keyAgentId ?? "main") + : (keyAgentId ?? params.followupRun.run.agentId ?? "main"); + const compactionStorePath = resolveSessionStorePathForScope({ + agentId: compactionAgentId, + sessionKey: compactionSessionKey, + storePath: + params.storePath ?? + resolveStorePath(params.cfg.session?.store, { agentId: compactionAgentId }), + }); + const contextWindowTokens = resolveMemoryFlushContextWindowTokens({ cfg: params.cfg, provider: resolveFollowupContextConfigProvider({ @@ -833,19 +881,20 @@ export async function runPreflightCompactionIfNeeded(params: { typeof freshPersistedTokens === "number" && !freshNeedsOutputRead ? undefined : await estimatePromptTokensFromSessionTranscript({ - agentId: params.followupRun.run.agentId, + agentId: compactionAgentId, sessionId: entry.sessionId, sessionEntry: entry, - sessionKey: params.sessionKey ?? params.followupRun.run.sessionKey, - storePath: params.storePath, + sessionKey: compactionSessionKey, + storePath: compactionStorePath, }); const transcriptSizeSnapshot = shouldCheckActiveTranscriptBytes && transcriptUsageTokens?.transcriptByteSize === undefined ? await readSessionLogSnapshot({ + agentId: compactionAgentId, sessionId: entry.sessionId, sessionEntry: entry, - sessionKey: params.sessionKey ?? params.followupRun.run.sessionKey, - opts: { agentId: params.followupRun.run.agentId, storePath: params.storePath }, + sessionKey: compactionSessionKey, + opts: { agentId: compactionAgentId, storePath: compactionStorePath }, includeByteSize: true, includeUsage: false, }) @@ -944,19 +993,24 @@ export async function runPreflightCompactionIfNeeded(params: { }; try { await notifyStartCompaction(); - const sessionFile = resolveSessionLogPath( - entry.sessionId, - entry, - params.sessionKey ?? params.followupRun.run.sessionKey, - { agentId: params.followupRun.run.agentId, storePath: params.storePath }, - ); + const sessionFile = + memoryDeps.resolveSessionLogPath(entry.sessionId, entry, compactionSessionKey, { + agentId: compactionAgentId, + storePath: compactionStorePath, + }) ?? normalizeOptionalString(compactionSessionKey); if (!sessionFile) { await notifyTerminalCompaction("skipped"); return entry ?? params.sessionEntry; } const result = await deps.compactEmbeddedAgentSession({ sessionId: entry.sessionId, - sessionKey: params.sessionKey, + sessionKey: compactionSessionKey, + sessionTarget: { + agentId: compactionAgentId, + sessionId: entry.sessionId, + sessionKey: compactionSessionKey, + storePath: compactionStorePath, + }, sandboxSessionKey: params.runtimePolicySessionKey, allowGatewaySubagentBinding: true, messageChannel: params.followupRun.run.messageProvider, @@ -1025,14 +1079,14 @@ export async function runPreflightCompactionIfNeeded(params: { } await deps.incrementCompactionCount({ + agentId: compactionAgentId, cfg: params.cfg, sessionEntry: entry, sessionStore: params.sessionStore, - sessionKey: params.sessionKey, - storePath: params.storePath, + sessionKey: compactionSessionKey, + storePath: compactionStorePath, tokensAfter: result.result?.tokensAfter, newSessionId: result.result?.sessionId, - newSessionFile: result.result?.sessionFile, }); await appendPostCompactionRefreshPrompt({ cfg: params.cfg, @@ -1044,16 +1098,14 @@ export async function runPreflightCompactionIfNeeded(params: { const previousSessionId = params.followupRun.run.sessionId; params.followupRun.run.sessionId = entry.sessionId; params.replyOperation.updateSessionId(entry.sessionId); - if (entry.sessionFile) { - params.followupRun.run.sessionFile = entry.sessionFile; - } const queueKey = params.followupRun.run.sessionKey ?? params.sessionKey; if (queueKey) { + params.followupRun.run.sessionFile = queueKey; deps.refreshQueuedFollowupSession({ key: queueKey, previousSessionId, nextSessionId: entry.sessionId, - nextSessionFile: entry.sessionFile, + nextSessionFile: queueKey, }); } } @@ -1335,7 +1387,6 @@ export async function runMemoryFlushIfNeeded(params: { .filter(Boolean) .join("\n\n"); let postCompactionSessionId: string | undefined; - let postCompactionSessionFile: string | undefined; try { const selection = resolveMemoryFlushModelFallbackOptions( params.followupRun.run, @@ -1437,9 +1488,6 @@ export async function runMemoryFlushIfNeeded(params: { if (result.meta?.agentMeta?.sessionId) { postCompactionSessionId = result.meta.agentMeta.sessionId; } - if (result.meta?.agentMeta?.sessionFile) { - postCompactionSessionFile = result.meta.agentMeta.sessionFile; - } bootstrapPromptWarningSignaturesSeen = resolveBootstrapWarningSignaturesSeen( result.meta?.systemPromptReport, ); @@ -1453,29 +1501,27 @@ export async function runMemoryFlushIfNeeded(params: { if (memoryCompactionCompleted) { const previousSessionId = activeSessionEntry?.sessionId ?? params.followupRun.run.sessionId; await memoryDeps.incrementCompactionCount({ + agentId: params.followupRun.run.agentId, cfg: params.cfg, sessionEntry: activeSessionEntry, sessionStore: activeSessionStore, sessionKey: params.sessionKey, storePath: params.storePath, newSessionId: postCompactionSessionId, - newSessionFile: postCompactionSessionFile, }); const updatedEntry = params.sessionKey ? activeSessionStore?.[params.sessionKey] : undefined; if (updatedEntry) { activeSessionEntry = updatedEntry; params.followupRun.run.sessionId = updatedEntry.sessionId; params.replyOperation.updateSessionId(updatedEntry.sessionId); - if (updatedEntry.sessionFile) { - params.followupRun.run.sessionFile = updatedEntry.sessionFile; - } const queueKey = params.followupRun.run.sessionKey ?? params.sessionKey; if (queueKey) { + params.followupRun.run.sessionFile = queueKey; memoryDeps.refreshQueuedFollowupSession({ key: queueKey, previousSessionId, nextSessionId: updatedEntry.sessionId, - nextSessionFile: updatedEntry.sessionFile, + nextSessionFile: queueKey, }); } } @@ -1504,8 +1550,9 @@ export async function runMemoryFlushIfNeeded(params: { activeSessionEntry = updatedEntry; params.followupRun.run.sessionId = updatedEntry.sessionId; params.replyOperation.updateSessionId(updatedEntry.sessionId); - if (updatedEntry.sessionFile) { - params.followupRun.run.sessionFile = updatedEntry.sessionFile; + const refreshedSessionKey = params.sessionKey ?? params.followupRun.run.sessionKey; + if (refreshedSessionKey) { + params.followupRun.run.sessionFile = refreshedSessionKey; } } } catch (err) { diff --git a/src/auto-reply/reply/agent-runner-result-accounting.ts b/src/auto-reply/reply/agent-runner-result-accounting.ts index 6e9d5cf6cfc3..84123152dcaf 100644 --- a/src/auto-reply/reply/agent-runner-result-accounting.ts +++ b/src/auto-reply/reply/agent-runner-result-accounting.ts @@ -354,7 +354,7 @@ export async function accountFollowupTurn(params: { key: queueKey, previousSessionId: turn.queued.run.sessionId, nextSessionId: entry?.sessionId ?? turn.queued.run.sessionId, - nextSessionFile: entry?.sessionFile, + nextSessionFile: queueKey, nextProvider: accounting.providerUsed, nextModel: accounting.modelUsed, nextModelOverrideSource: entry?.modelOverrideSource, @@ -366,6 +366,7 @@ export async function accountFollowupTurn(params: { if (accounting.autoCompactionCount > 0) { const previousSessionId = turn.queued.run.sessionId; const count = await incrementRunCompactionCount({ + agentId: turn.queued.run.agentId, cfg: turn.config, sessionEntry: turn.session.current(), sessionStore: turn.sessionStore, @@ -376,7 +377,6 @@ export async function accountFollowupTurn(params: { lastCallUsage: accounting.runResult.meta?.agentMeta?.lastCallUsage, contextTokensUsed: accounting.contextTokensUsed, newSessionId: accounting.runResult.meta?.agentMeta?.sessionId, - newSessionFile: accounting.runResult.meta?.agentMeta?.sessionFile, }); const refreshed = turn.session.current(); if (refreshed) { @@ -385,7 +385,7 @@ export async function accountFollowupTurn(params: { key: queueKey ?? "", previousSessionId, nextSessionId: refreshed.sessionId, - nextSessionFile: refreshed.sessionFile, + nextSessionFile: queueKey ?? sessionKey, }); } if (accounting.verboseEnabled) { diff --git a/src/auto-reply/reply/agent-runner-result-complete.ts b/src/auto-reply/reply/agent-runner-result-complete.ts index 6bff6c89dfd7..3c996886c283 100644 --- a/src/auto-reply/reply/agent-runner-result-complete.ts +++ b/src/auto-reply/reply/agent-runner-result-complete.ts @@ -105,6 +105,7 @@ export async function completeReplyAgentRun(input: { if (autoCompactionCount > 0) { const previousSessionId = activeSessionEntry?.sessionId ?? followupRun.run.sessionId; const count = await incrementRunCompactionCount({ + agentId: followupRun.run.agentId, cfg, sessionEntry: activeSessionEntry, sessionStore: activeSessionStore, @@ -115,7 +116,6 @@ export async function completeReplyAgentRun(input: { lastCallUsage: runResult.meta?.agentMeta?.lastCallUsage, contextTokensUsed, newSessionId: runResult.meta?.agentMeta?.sessionId, - newSessionFile: runResult.meta?.agentMeta?.sessionFile, }); const refreshedSessionEntry = sessionKey && activeSessionStore ? activeSessionStore[sessionKey] : undefined; @@ -125,7 +125,7 @@ export async function completeReplyAgentRun(input: { key: queueKey, previousSessionId, nextSessionId: refreshedSessionEntry.sessionId, - nextSessionFile: refreshedSessionEntry.sessionFile, + nextSessionFile: queueKey, }); } @@ -241,7 +241,9 @@ export async function completeReplyAgentRun(input: { const sessionUsage = traceAuthorized && activeSessionEntry?.traceLevel === "raw" ? await accumulateSessionUsageFromTranscript({ + agentId: followupRun.run.agentId, sessionId: runResult.meta?.agentMeta?.sessionId ?? followupRun.run.sessionId, + sessionKey: followupRun.run.sessionKey, storePath, sessionFile: followupRun.run.sessionFile, }) diff --git a/src/auto-reply/reply/agent-runner-run.ts b/src/auto-reply/reply/agent-runner-run.ts index 9c79390d0d9f..63ae56ce5e87 100644 --- a/src/auto-reply/reply/agent-runner-run.ts +++ b/src/auto-reply/reply/agent-runner-run.ts @@ -534,7 +534,8 @@ export async function runReplyAgent( const admittedSessionFile = resolveAdmittedRunSessionFile({ agentId: followupRun.run.agentId, sessionId: replyOperation.sessionId, - sessionFile: admittedSessionEntry.sessionFile, + sessionFile: undefined, + sessionKey: replySessionKey, storePath, }); if (admittedSessionFile) { diff --git a/src/auto-reply/reply/agent-runner-session-reset.test.ts b/src/auto-reply/reply/agent-runner-session-reset.test.ts index 2831722bd797..90369053cba0 100644 --- a/src/auto-reply/reply/agent-runner-session-reset.test.ts +++ b/src/auto-reply/reply/agent-runner-session-reset.test.ts @@ -4,12 +4,12 @@ import os from "node:os"; import path from "node:path"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import type { SessionEntry } from "../../config/sessions.js"; +import { formatSqliteSessionFileMarker } from "../../config/sessions/legacy-sqlite-marker.js"; import { appendTranscriptMessage, loadSessionEntry, loadTranscriptEvents, } from "../../config/sessions/session-accessor.js"; -import { formatSqliteSessionFileMarker } from "../../config/sessions/sqlite-marker.js"; import { resetReplyRunSession } from "./agent-runner-session-reset.js"; import { setAgentRunnerSessionResetTestDeps } from "./agent-runner-session-reset.test-support.js"; import { createTestFollowupRun, writeTestSessionStore } from "./agent-runner.test-fixtures.js"; @@ -180,13 +180,13 @@ describe("resetReplyRunSession", () => { key: "main", previousSessionId: "session", nextSessionId: activeSessionEntry?.sessionId, - nextSessionFile: activeSessionEntry?.sessionFile, + nextSessionFile: "main", }); expect(resetRegisteredAgentHarnessSessionsMock).toHaveBeenCalledWith({ agentId: followupRun.run.agentId, sessionId: "session", sessionKey: "main", - sessionFile: activeSessionEntry?.sessionFile, + sessionFile: "main", reason: "reset", }); expect(errorMock).toHaveBeenCalledWith("reset session"); @@ -352,13 +352,7 @@ describe("resetReplyRunSession", () => { onNewSession: () => {}, }); - expect(activeSessionEntry?.sessionFile).toBe( - formatSqliteSessionFileMarker({ - agentId: "main", - sessionId: oldSessionId, - storePath, - }), - ); + expect(activeSessionEntry).not.toHaveProperty("sessionFile"); const replayed = await loadTranscriptEvents({ agentId: "main", sessionId: oldSessionId, @@ -409,9 +403,7 @@ describe("resetReplyRunSession", () => { onNewSession: () => {}, }); - expect(activeSessionEntry?.sessionFile).toBe( - formatSqliteSessionFileMarker({ agentId: "main", sessionId: "old-session", storePath }), - ); + expect(activeSessionEntry).not.toHaveProperty("sessionFile"); await expect( loadTranscriptEvents({ agentId: "main", @@ -457,9 +449,6 @@ describe("resetReplyRunSession", () => { onNewSession: () => {}, }); - expect(activeSessionEntry?.sessionFile).toBe( - formatSqliteSessionFileMarker({ agentId: "main", sessionId, storePath }), - ); - expect(activeSessionEntry?.sessionFile).not.toBe(staleMarker); + expect(activeSessionEntry).not.toHaveProperty("sessionFile"); }); }); diff --git a/src/auto-reply/reply/agent-runner-session-reset.ts b/src/auto-reply/reply/agent-runner-session-reset.ts index dc9e2d2f22f5..bfc91538c148 100644 --- a/src/auto-reply/reply/agent-runner-session-reset.ts +++ b/src/auto-reply/reply/agent-runner-session-reset.ts @@ -5,10 +5,6 @@ import { resetRegisteredAgentHarnessSessions } from "../../agents/harness/regist import { transitionMainSessionRecovery } from "../../agents/main-session-recovery-state.js"; import type { SessionEntry } from "../../config/sessions.js"; import { persistSessionResetLifecycle } from "../../config/sessions/session-accessor.js"; -import { - formatSqliteSessionFileMarker, - sqliteSessionFileMarkerMatchesTarget, -} from "../../config/sessions/sqlite-marker.js"; import { generateSecureUuid } from "../../infra/secure-random.js"; import { defaultRuntime } from "../../runtime.js"; import { @@ -110,20 +106,7 @@ export async function resetReplyRunSession(params: { nextEntry.agentHarnessId = undefined; transitionMainSessionRecovery(nextEntry, { kind: "clear" }); const agentId = params.followupRun.run.agentId; - const nextSessionFile = - (sqliteSessionFileMarkerMatchesTarget(prevEntry.sessionFile, { - agentId, - sessionId: nextSessionId, - storePath: params.storePath, - }) - ? prevEntry.sessionFile - : undefined) ?? - formatSqliteSessionFileMarker({ - agentId, - sessionId: nextSessionId, - storePath: params.storePath, - }); - nextEntry.sessionFile = nextSessionFile; + const nextSessionFile = params.sessionKey; params.activeSessionStore[params.sessionKey] = nextEntry; try { await deps.persistSessionResetLifecycle({ diff --git a/src/auto-reply/reply/agent-runner-trace.ts b/src/auto-reply/reply/agent-runner-trace.ts index 1b573db3b059..d3a7d307b765 100644 --- a/src/auto-reply/reply/agent-runner-trace.ts +++ b/src/auto-reply/reply/agent-runner-trace.ts @@ -1,3 +1,4 @@ +import path from "node:path"; import { expectDefined } from "@openclaw/normalization-core"; import { normalizeOptionalString } from "@openclaw/normalization-core/string-coerce"; import { deriveContextPromptTokens } from "../../agents/usage.js"; @@ -416,7 +417,9 @@ function formatContextManagementTraceBlock( } export async function accumulateSessionUsageFromTranscript(params: { + agentId?: string; sessionId?: string; + sessionKey?: string; storePath?: string; sessionFile?: string; }): Promise< @@ -434,8 +437,14 @@ export async function accumulateSessionUsageFromTranscript(params: { return undefined; } try { + const artifactFile = params.sessionFile?.trim(); + const useArtifactFile = Boolean( + artifactFile && path.isAbsolute(artifactFile) && artifactFile.endsWith(".jsonl"), + ); const usage = await readLatestSessionUsageFromTranscriptAsync({ + agentId: params.agentId, sessionId, + sessionKey: useArtifactFile ? undefined : params.sessionKey, storePath: params.storePath, sessionFile: params.sessionFile, }); diff --git a/src/auto-reply/reply/agent-runner.misc.runreplyagent.test.ts b/src/auto-reply/reply/agent-runner.misc.runreplyagent.test.ts index f10bf55baca1..eff2eed18bb7 100644 --- a/src/auto-reply/reply/agent-runner.misc.runreplyagent.test.ts +++ b/src/auto-reply/reply/agent-runner.misc.runreplyagent.test.ts @@ -339,7 +339,7 @@ describe("runReplyAgent auto-compaction token update", () => { await fs.mkdir(path.dirname(params.storePath), { recursive: true }); await replaceSessionEntry( { storePath: params.storePath, sessionKey: params.sessionKey }, - params.entry as SessionEntry, + params.entry as unknown as SessionEntry, ); } diff --git a/src/auto-reply/reply/agent-runner.test-fixtures.ts b/src/auto-reply/reply/agent-runner.test-fixtures.ts index 1bd4b1a89e6d..7dad497ee192 100644 --- a/src/auto-reply/reply/agent-runner.test-fixtures.ts +++ b/src/auto-reply/reply/agent-runner.test-fixtures.ts @@ -37,5 +37,10 @@ export async function writeTestSessionStore( sessionKey: string, entry: SessionEntry, ): Promise { + const fileEntry = entry as SessionEntry & { sessionFile?: string; transcriptPath?: string }; + if (fileEntry.sessionFile) { + fileEntry.transcriptPath = fileEntry.sessionFile; + delete fileEntry.sessionFile; + } await replaceSessionEntry({ storePath, sessionKey }, entry); } diff --git a/src/auto-reply/reply/commands-compact.test.ts b/src/auto-reply/reply/commands-compact.test.ts index d464fe45b7c4..5b69b9c493c3 100644 --- a/src/auto-reply/reply/commands-compact.test.ts +++ b/src/auto-reply/reply/commands-compact.test.ts @@ -27,7 +27,6 @@ const { formatContextUsageShort, incrementCompactionCount, isEmbeddedAgentRunAbortableForCompaction, - resolveSessionFilePathOptions, waitForEmbeddedAgentRunEnd, } = await import("./commands-compact.runtime.js"); const { handleCompactCommand } = await import("./commands-compact.js"); @@ -400,10 +399,48 @@ describe("handleCompactCommand", () => { const resolveCall = requireResolveSessionAgentIdCall(); expect(resolveCall.sessionKey).toBe("agent:target:whatsapp:direct:12345"); expect(resolveCall.config).toBe(cfg); - expect(vi.mocked(resolveSessionFilePathOptions)).toHaveBeenCalledWith({ - agentId: "target", - storePath: undefined, + expect(vi.mocked(compactEmbeddedAgentSession)).toHaveBeenCalledWith( + expect.objectContaining({ + sessionTarget: { + agentId: "target", + sessionId: "session-1", + sessionKey: "agent:target:whatsapp:direct:12345", + storePath: "/tmp/openclaw-session-store.json", + }, + }), + ); + }); + + it("uses the resolved command store for compaction", async () => { + vi.mocked(compactEmbeddedAgentSession).mockResolvedValueOnce({ + ok: true, + compacted: false, }); + const cfg = { + commands: { text: true }, + channels: { whatsapp: { allowFrom: ["*"] } }, + session: { store: "/tmp/default-sessions.json" }, + } as OpenClawConfig; + + await handleCompactCommand( + { + ...buildCompactParams("/compact", cfg), + storePath: "/tmp/scoped-sessions.json", + sessionEntry: { + sessionId: "session-1", + updatedAt: Date.now(), + }, + } as HandleCommandsParams, + true, + ); + + expect(vi.mocked(compactEmbeddedAgentSession)).toHaveBeenCalledWith( + expect.objectContaining({ + sessionTarget: expect.objectContaining({ + storePath: "/tmp/scoped-sessions.json", + }), + }), + ); }); it("uses the canonical session agent directory for compaction runtime inputs", async () => { diff --git a/src/auto-reply/reply/commands-compact.ts b/src/auto-reply/reply/commands-compact.ts index 755b8738dbb1..0616c09555c2 100644 --- a/src/auto-reply/reply/commands-compact.ts +++ b/src/auto-reply/reply/commands-compact.ts @@ -17,6 +17,8 @@ import { resolveContextConfigProviderForRuntime, } from "../../agents/openai-routing.js"; import { resolvePersistedSessionRuntimeId } from "../../agents/session-runtime-compat.js"; +import { resolveStorePath } from "../../config/sessions/paths.js"; +import { resolveSessionStorePathForScope } from "../../config/sessions/session-store-path.js"; import type { OpenClawConfig } from "../../config/types.openclaw.js"; import { logVerbose } from "../../globals.js"; import { createLazyImportLoader } from "../../shared/lazy-promise.js"; @@ -234,10 +236,22 @@ export const handleCompactCommand: CommandHandler = async (params) => { liveContextTokens: params.contextTokens, persistedContextTokens: targetSessionEntry.contextTokens, }); + const compactionStorePath = resolveSessionStorePathForScope({ + agentId: sessionAgentId, + sessionKey: params.sessionKey, + storePath: + params.storePath ?? resolveStorePath(params.cfg.session?.store, { agentId: sessionAgentId }), + }); const result = await runtime.compactEmbeddedAgentSession({ abortSignal: params.opts?.abortSignal, sessionId, sessionKey: params.sessionKey, + sessionTarget: { + agentId: sessionAgentId, + sessionId, + sessionKey: params.sessionKey, + storePath: compactionStorePath, + }, allowGatewaySubagentBinding: true, messageChannel: params.command.channel, clientCaps: params.ctx.GatewayClientCaps, @@ -250,14 +264,7 @@ export const handleCompactCommand: CommandHandler = async (params) => { senderUsername: params.ctx.SenderUsername, senderE164: params.ctx.SenderE164, inputProvenance: params.ctx.InputProvenance, - sessionFile: runtime.resolveSessionFilePath( - sessionId, - targetSessionEntry, - runtime.resolveSessionFilePathOptions({ - agentId: sessionAgentId, - storePath: params.storePath, - }), - ), + sessionFile: params.sessionKey, workspaceDir: params.workspaceDir, agentDir: sessionAgentDir, config: params.cfg, @@ -301,15 +308,15 @@ export const handleCompactCommand: CommandHandler = async (params) => { : "Compaction failed"; if (result.ok && result.compacted) { await runtime.incrementCompactionCount({ + agentId: sessionAgentId, cfg: params.cfg, sessionEntry: targetSessionEntry, sessionStore: params.sessionStore, sessionKey: params.sessionKey, - storePath: params.storePath, + storePath: compactionStorePath, // Update token counts after compaction tokensAfter: result.result?.tokensAfter, newSessionId: result.result?.sessionId, - newSessionFile: result.result?.sessionFile, }); } // Use the post-compaction token count for context summary if available diff --git a/src/auto-reply/reply/commands-context-report.test.ts b/src/auto-reply/reply/commands-context-report.test.ts index 515f02012f85..31e76293acb0 100644 --- a/src/auto-reply/reply/commands-context-report.test.ts +++ b/src/auto-reply/reply/commands-context-report.test.ts @@ -1,10 +1,13 @@ /** Tests context command behavior, token reporting, and generated report files. */ -import { mkdtemp, readFile, rm, unlink, writeFile } from "node:fs/promises"; +import { mkdtemp, readFile, rm, unlink } from "node:fs/promises"; import { tmpdir } from "node:os"; import { join } from "node:path"; import { describe, expect, it } from "vitest"; import type { OpenClawConfig } from "../../config/config.js"; import type { SessionEntry } from "../../config/sessions.js"; +import { persistSessionTranscriptTurn } from "../../config/sessions/session-accessor.js"; +import { resolveSessionStorePathForScope } from "../../config/sessions/session-store-path.js"; +import { closeOpenClawAgentDatabasesForTest } from "../../state/openclaw-agent-db.js"; import { buildContextReply } from "./commands-context-report.js"; import { buildCommandContext } from "./commands-context.js"; import type { HandleCommandsParams } from "./commands-types.js"; @@ -24,7 +27,6 @@ function makeParams( cfg?: Record; sessionKey?: string; sessionId?: string; - sessionFile?: string; storePath?: string; agentId?: string; currentTurn?: NonNullable["currentTurn"]; @@ -47,7 +49,6 @@ function makeParams( resolvedReasoningLevel: "off", sessionEntry: { ...(options?.sessionId ? { sessionId: options.sessionId } : {}), - ...(options?.sessionFile ? { sessionFile: options.sessionFile } : {}), totalTokens: options?.totalTokens ?? 123, totalTokensFresh: options?.totalTokensFresh ?? true, inputTokens: 100, @@ -97,21 +98,37 @@ function makeParams( async function withTranscript( messages: unknown[], - run: (sessionFile: string, dir: string) => Promise, + run: (target: { + agentId: string; + sessionId: string; + sessionKey: string; + storePath: string; + }) => Promise, + options: { agentId?: string; sessionKey?: string } = {}, ): Promise { const dir = await mkdtemp(join(tmpdir(), "openclaw-context-report-")); try { - const sessionFile = join(dir, "session.jsonl"); - const lines = messages.map((message, index) => - JSON.stringify({ - id: `record-${index + 1}`, - timestamp: new Date(index + 1).toISOString(), - message, - }), + const agentId = options.agentId ?? "default"; + const target = { + agentId, + sessionId: "session", + sessionKey: options.sessionKey ?? `agent:${agentId}:main`, + storePath: join(dir, "sessions.json"), + }; + await persistSessionTranscriptTurn( + { ...target, storePath: resolveSessionStorePathForScope(target) }, + { + messages: messages.map((message, index) => ({ + eventId: `record-${index + 1}`, + message, + parentId: index === 0 ? null : `record-${index}`, + })), + touchSessionEntry: false, + }, ); - await writeFile(sessionFile, `${lines.join("\n")}\n`, "utf8"); - await run(sessionFile, dir); + await run(target); } finally { + closeOpenClawAgentDatabasesForTest(); await rm(dir, { recursive: true, force: true }); } } @@ -208,13 +225,12 @@ describe("buildContextReply", () => { toolName: "read", }, ], - async (sessionFile) => { + async (target) => { const result = await buildContextReply( makeParams("/context detail", false, { contextTokens: 8_192, totalTokens: 900, - sessionId: "session", - sessionFile, + ...target, }), ); @@ -223,6 +239,10 @@ describe("buildContextReply", () => { ); expect(result.text).not.toContain("Compaction note:"); }, + { + agentId: "context-incognito", + sessionKey: "agent:context-incognito:dashboard:incognito-context-report", + }, ); }); @@ -242,13 +262,12 @@ describe("buildContextReply", () => { toolName: "read", }, ], - async (sessionFile) => { + async (target) => { const result = await buildContextReply( makeParams("/context detail", false, { contextTokens: 8_192, totalTokens: 900, - sessionId: "session", - sessionFile, + ...target, }), ); @@ -347,13 +366,12 @@ describe("buildContextReply", () => { toolName: "read", }, ], - async (sessionFile) => { + async (target) => { const result = await buildContextReply( makeParams("/context map", false, { contextTokens: 8_192, totalTokens: 900, - sessionId: "session", - sessionFile, + ...target, }), ); if (!result.mediaUrl) { diff --git a/src/auto-reply/reply/commands-context-report.ts b/src/auto-reply/reply/commands-context-report.ts index f45ef28510cf..267143d93417 100644 --- a/src/auto-reply/reply/commands-context-report.ts +++ b/src/auto-reply/reply/commands-context-report.ts @@ -13,12 +13,13 @@ import { } from "../../agents/embedded-agent-runner/tool-result-char-estimator.js"; import type { AgentMessage } from "../../agents/runtime/index.js"; import { buildSystemPromptReport } from "../../agents/system-prompt-report.js"; +import { resolveSessionStorePathForScope } from "../../config/sessions/session-store-path.js"; import { resolveFreshSessionTotalTokens, type SessionEntry, type SessionSystemPromptReport, } from "../../config/sessions/types.js"; -import { readSessionMessages } from "../../gateway/session-utils.fs.js"; +import { readSessionMessagesAsync } from "../../gateway/session-transcript-readers.js"; import { estimateTokensFromChars } from "../../utils/cjk-chars.js"; import type { ReplyPayload } from "../types.js"; import type { HandleCommandsParams } from "./commands-types.js"; @@ -78,20 +79,39 @@ type TranscriptCompactabilityReport = reason: string; }; -function resolveTranscriptCompactabilityReport( +async function readContextTranscriptMessages( params: HandleCommandsParams, targetSessionEntry: SessionEntry | undefined, -): TranscriptCompactabilityReport { +): Promise { const sessionId = targetSessionEntry?.sessionId?.trim(); if (!sessionId) { + return []; + } + const agentId = resolveContextReportAgentId(params); + return (await readSessionMessagesAsync( + { + agentId, + sessionId, + sessionKey: params.sessionKey, + storePath: resolveSessionStorePathForScope({ + agentId, + sessionKey: params.sessionKey, + storePath: params.storePath, + }), + }, + { mode: "full", reason: "context-report" }, + )) as AgentMessage[]; +} + +async function resolveTranscriptCompactabilityReport( + params: HandleCommandsParams, + targetSessionEntry: SessionEntry | undefined, +): Promise { + if (!targetSessionEntry?.sessionId?.trim()) { return { available: false, reason: "no active transcript session" }; } - const messages = readSessionMessages( - sessionId, - params.storePath, - targetSessionEntry?.sessionFile, - ) as AgentMessage[]; + const messages = await readContextTranscriptMessages(params, targetSessionEntry); if (!messages.length) { return { available: false, reason: "no transcript messages found" }; } @@ -186,14 +206,7 @@ export async function buildContextReply(params: HandleCommandsParams): Promise { @@ -395,7 +408,7 @@ export async function buildContextReply(params: HandleCommandsParams): Promise 0 ? `Untracked provider/runtime overhead: ~${formatInt(overheadTokens)} tok` : "Untracked provider/runtime overhead: not observed in cached usage"; - const transcriptCompactability = resolveTranscriptCompactabilityReport( + const transcriptCompactability = await resolveTranscriptCompactabilityReport( params, targetSessionEntry, ); diff --git a/src/auto-reply/reply/commands-diagnostics.test.ts b/src/auto-reply/reply/commands-diagnostics.test.ts index a3c13ea75c2a..9b09c0b1ade3 100644 --- a/src/auto-reply/reply/commands-diagnostics.test.ts +++ b/src/auto-reply/reply/commands-diagnostics.test.ts @@ -412,12 +412,12 @@ describe("diagnostics command", () => { expect(calls[0]?.args).toBe("diagnostics flaky tool call"); expect(calls[0]?.diagnosticsPreviewOnly).toBe(true); expect(calls[0]?.senderIsOwner).toBe(true); - expect(calls[0]?.sessionFile).toBe("/tmp/session.jsonl"); + expect(calls[0]?.sessionFile).toBe("agent:main:whatsapp:direct:user-1"); const diagnosticsSessions = requireDiagnosticsSessions(calls[0]); expect(diagnosticsSessions).toHaveLength(1); expect(diagnosticsSessions[0]?.agentHarnessId).toBe("codex"); expect(diagnosticsSessions[0]?.sessionId).toBe("session-1"); - expect(diagnosticsSessions[0]?.sessionFile).toBe("/tmp/session.jsonl"); + expect(diagnosticsSessions[0]?.sessionFile).toBe("agent:main:whatsapp:direct:user-1"); expect(diagnosticsSessions[0]?.channel).toBe("whatsapp"); expect(diagnosticsSessions[0]?.accountId).toBe("account-1"); const { defaults } = requireExecCall(execCalls); @@ -436,7 +436,7 @@ describe("diagnostics command", () => { expect(calls[1]?.diagnosticsUploadApproved).toBe(true); }); - it("passes sidecar-bound session files to Codex diagnostics even when harness metadata is stale", async () => { + it("passes canonical session identities to Codex diagnostics when harness metadata is stale", async () => { const { calls } = registerCodexDiagnosticsCommandForTest(async () => null); const { execCalls, handleDiagnosticsCommand } = createDiagnosticsHandlerForTest(); const result = await handleDiagnosticsCommand( @@ -471,11 +471,11 @@ describe("diagnostics command", () => { expect(diagnosticsSessions).toHaveLength(2); expect(diagnosticsSessions[0]?.sessionKey).toBe("agent:main:telegram:direct:user-1"); expect(diagnosticsSessions[0]?.sessionId).toBe("telegram-session"); - expect(diagnosticsSessions[0]?.sessionFile).toBe("/tmp/telegram.jsonl"); + expect(diagnosticsSessions[0]?.sessionFile).toBe("agent:main:telegram:direct:user-1"); expect(diagnosticsSessions[0]?.channel).toBe("whatsapp"); expect(diagnosticsSessions[1]?.sessionKey).toBe("agent:main:discord:channel:123"); expect(diagnosticsSessions[1]?.sessionId).toBe("discord-session"); - expect(diagnosticsSessions[1]?.sessionFile).toBe("/tmp/discord.jsonl"); + expect(diagnosticsSessions[1]?.sessionFile).toBe("agent:main:discord:channel:123"); expect(diagnosticsSessions[1]?.channel).toBe("discord"); expect(requireExecCall(execCalls).defaults.approvalWarningText).toContain( "OpenAI Codex harness:", diff --git a/src/auto-reply/reply/commands-diagnostics.ts b/src/auto-reply/reply/commands-diagnostics.ts index 62ae079ecdb2..436f4e36c7f7 100644 --- a/src/auto-reply/reply/commands-diagnostics.ts +++ b/src/auto-reply/reply/commands-diagnostics.ts @@ -444,7 +444,7 @@ async function executeCodexDiagnosticsAddon( agentId: params.agentId, sessionKey: params.sessionKey, sessionId: targetSessionEntry?.sessionId, - sessionFile: targetSessionEntry?.sessionFile, + sessionFile: targetSessionEntry ? params.sessionKey : undefined, authProfileId: targetSessionEntry?.authProfileOverride, commandBody, config: params.cfg, @@ -485,11 +485,11 @@ function buildCodexDiagnosticsSessions( } } return Array.from(sessions.entries()) - .filter(([, entry]) => Boolean(entry.sessionFile)) + .filter(([, entry]) => Boolean(entry.sessionId?.trim())) .map(([sessionKey, entry]) => ({ sessionKey, sessionId: entry.sessionId, - sessionFile: entry.sessionFile, + sessionFile: sessionKey, agentHarnessId: entry.agentHarnessId, channel: resolveDiagnosticsSessionChannel(entry, params, sessionKey), channelId: resolveDiagnosticsSessionChannelId(entry, params, sessionKey), diff --git a/src/auto-reply/reply/commands-plugin.test.ts b/src/auto-reply/reply/commands-plugin.test.ts index f63b054cd2a8..54f445bec635 100644 --- a/src/auto-reply/reply/commands-plugin.test.ts +++ b/src/auto-reply/reply/commands-plugin.test.ts @@ -2,6 +2,8 @@ import { expectDefined } from "@openclaw/normalization-core"; import { beforeEach, describe, expect, it, vi } from "vitest"; import type { OpenClawConfig } from "../../config/config.js"; +import { parseSqliteSessionFileMarker } from "../../config/sessions/legacy-sqlite-marker.js"; +import { resolveIncognitoOpenClawAgentSqlitePath } from "../../state/openclaw-agent-db.js"; import { handlePluginCommand } from "./commands-plugin.js"; import type { HandleCommandsParams } from "./commands-types.js"; @@ -98,6 +100,8 @@ describe("handlePluginCommand", () => { commands: { text: true }, channels: { whatsapp: { allowFrom: ["*"] } }, } as OpenClawConfig); + params.agentId = "requester"; + params.sessionKey = "agent:target:whatsapp:direct:test-user"; params.sessionEntry = { sessionId: "wrapper-session", sessionFile: "/tmp/wrapper-session.jsonl", @@ -118,16 +122,98 @@ describe("handlePluginCommand", () => { const [commandParams] = expectDefined( ( executePluginCommandMock.mock.calls as unknown as Array< - [{ authProfileId?: string; sessionId?: string; sessionFile?: string }] + [ + { + agentId?: string; + authProfileId?: string; + sessionId?: string; + sessionFile?: string; + sessionTarget?: { agentId?: string; sessionId?: string; sessionKey?: string }; + }, + ] > )[0], "(executePluginCommandMock.mock.calls as unknown as Array<\n [{ authProfileId?: string; sessionId?: string; sessionFile?: string }]\n >)[0] test invariant", ); + expect(commandParams.agentId).toBe("target"); expect(commandParams.sessionId).toBe("target-session"); - expect(commandParams.sessionFile).toBe("/tmp/target-session.jsonl"); + expect(commandParams.sessionTarget).toMatchObject({ + agentId: "target", + sessionId: "target-session", + sessionKey: params.sessionKey, + }); + expect(parseSqliteSessionFileMarker(commandParams.sessionFile)).toMatchObject({ + agentId: "target", + sessionId: "target-session", + }); expect(commandParams.authProfileId).toBe("openai:owner@example.com"); }); + it("uses the process-local transcript store for incognito plugin commands", async () => { + matchPluginCommandMock.mockReturnValue({ + command: { name: "card" }, + args: "", + }); + executePluginCommandMock.mockResolvedValue({ text: "from plugin" }); + + const params = buildPluginParams("/card", { + commands: { text: true }, + session: { store: "/tmp/durable/{agentId}/sessions.json" }, + } as OpenClawConfig); + params.agentId = "main"; + params.sessionKey = "agent:main:dashboard:incognito-plugin-command"; + params.storePath = "/tmp/durable/main/sessions.json"; + params.sessionStore = { + [params.sessionKey]: { + sessionId: "incognito-session", + incognito: true, + updatedAt: Date.now(), + }, + }; + + await handlePluginCommand(params, true); + + const [commandParams] = expectDefined( + executePluginCommandMock.mock.calls[0] as unknown as [ + { sessionFile?: string; sessionTarget?: { storePath?: string } }, + ], + "plugin command invocation", + ); + const expectedStorePath = resolveIncognitoOpenClawAgentSqlitePath({ agentId: "main" }); + expect(commandParams.sessionTarget?.storePath).toBe(expectedStorePath); + expect(parseSqliteSessionFileMarker(commandParams.sessionFile)?.storePath).toBe( + expectedStorePath, + ); + }); + + it("keeps the current agent for unqualified global session keys", async () => { + matchPluginCommandMock.mockReturnValue({ + command: { name: "card" }, + args: "", + }); + executePluginCommandMock.mockResolvedValue({ text: "from plugin" }); + + const params = buildPluginParams("/card", { + commands: { text: true }, + session: { store: "/tmp/durable/{agentId}/sessions.json" }, + } as OpenClawConfig); + params.agentId = "other"; + params.sessionKey = "global"; + + await handlePluginCommand(params, true); + + const [commandParams] = expectDefined( + executePluginCommandMock.mock.calls[0] as unknown as [ + { sessionTarget?: { agentId?: string; storePath?: string } }, + ], + "plugin command invocation", + ); + expect(commandParams.sessionTarget).toMatchObject({ + agentId: "other", + storePath: "/tmp/durable/other/sessions.json", + }); + }); + it("continues the agent without leaking continueAgent into the reply payload", async () => { matchPluginCommandMock.mockReturnValue({ command: { name: "card" }, diff --git a/src/auto-reply/reply/commands-plugin.ts b/src/auto-reply/reply/commands-plugin.ts index 393126a570df..1dd06ce8567a 100644 --- a/src/auto-reply/reply/commands-plugin.ts +++ b/src/auto-reply/reply/commands-plugin.ts @@ -6,7 +6,12 @@ */ import { normalizeOptionalString } from "@openclaw/normalization-core/string-coerce"; +import { resolveSessionAgentId } from "../../agents/agent-scope.js"; +import { formatSqliteSessionFileMarker } from "../../config/sessions/legacy-sqlite-marker.js"; +import { resolveStorePath } from "../../config/sessions/paths.js"; +import { resolveSessionStorePathForScope } from "../../config/sessions/session-store-path.js"; import { matchPluginCommand, executePluginCommand } from "../../plugins/commands.js"; +import { DEFAULT_AGENT_ID, isUnscopedSessionKeySentinel } from "../../routing/session-key.js"; import type { CommandHandler, CommandHandlerResult } from "./commands-types.js"; /** @@ -20,6 +25,25 @@ export const handlePluginCommand: CommandHandler = async ( ): Promise => { const { command, cfg } = params; const targetSessionEntry = params.sessionStore?.[params.sessionKey] ?? params.sessionEntry; + const targetAgentId = + params.sessionKey && !isUnscopedSessionKeySentinel(params.sessionKey) + ? (resolveSessionAgentId({ sessionKey: params.sessionKey, config: cfg }) ?? + params.agentId ?? + DEFAULT_AGENT_ID) + : (params.agentId ?? DEFAULT_AGENT_ID); + const sessionTarget = targetSessionEntry?.sessionId + ? { + agentId: targetAgentId, + sessionId: targetSessionEntry.sessionId, + sessionKey: params.sessionKey, + storePath: resolveSessionStorePathForScope({ + agentId: targetAgentId, + sessionKey: params.sessionKey, + storePath: + params.storePath ?? resolveStorePath(cfg.session?.store, { agentId: targetAgentId }), + }), + } + : undefined; if (!allowTextCommands) { return null; @@ -41,10 +65,11 @@ export const handlePluginCommand: CommandHandler = async ( isAuthorizedSender: command.isAuthorizedSender, senderIsOwner: command.senderIsOwner, gatewayClientScopes: params.ctx.GatewayClientScopes, - agentId: params.agentId, + agentId: targetAgentId, sessionKey: params.sessionKey, sessionId: targetSessionEntry?.sessionId, - sessionFile: targetSessionEntry?.sessionFile, + sessionTarget, + sessionFile: sessionTarget ? formatSqliteSessionFileMarker(sessionTarget) : undefined, authProfileId: targetSessionEntry?.authProfileOverride, commandBody: command.commandBodyNormalized, config: cfg, diff --git a/src/auto-reply/reply/commands-reset-hooks.ts b/src/auto-reply/reply/commands-reset-hooks.ts index 0de1ead44538..e27fe7bbbd6f 100644 --- a/src/auto-reply/reply/commands-reset-hooks.ts +++ b/src/auto-reply/reply/commands-reset-hooks.ts @@ -1,10 +1,13 @@ // Emits reset hooks and cleanup work around session reset commands. -import { resolveSessionAgentId } from "../../agents/agent-scope.js"; +import { resolveDefaultAgentId } from "../../agents/agent-scope.js"; +import { formatSqliteSessionFileMarker } from "../../config/sessions/legacy-sqlite-marker.js"; import { loadTranscriptEvents } from "../../config/sessions/session-accessor.js"; +import { resolveSessionStorePathForScope } from "../../config/sessions/session-store-path.js"; import { selectSessionTranscriptLeafControlledPath } from "../../config/sessions/transcript-tree.js"; import { logVerbose } from "../../globals.js"; import { createInternalHookEvent, triggerInternalHook } from "../../hooks/internal-hooks.js"; import { getGlobalHookRunner } from "../../plugins/hook-runner-global.js"; +import { parseAgentSessionKey } from "../../routing/session-key.js"; import { createLazyImportLoader } from "../../shared/lazy-promise.js"; import type { HandleCommandsParams } from "./commands-types.js"; @@ -67,6 +70,7 @@ async function loadBeforeResetTranscript(params: { export async function emitResetCommandHooks(params: { action: ResetCommandAction; + agentId?: string; ctx: HandleCommandsParams["ctx"]; cfg: HandleCommandsParams["cfg"]; command: Pick< @@ -79,12 +83,26 @@ export async function emitResetCommandHooks(params: { previousSessionEntry?: HandleCommandsParams["previousSessionEntry"]; workspaceDir: string; }): Promise<{ routedReply: boolean }> { + const hookAgentId = + parseAgentSessionKey(params.sessionKey)?.agentId ?? + params.agentId ?? + resolveDefaultAgentId(params.cfg); + const hookStorePath = + hookAgentId && params.storePath + ? resolveSessionStorePathForScope({ + agentId: hookAgentId, + sessionKey: params.sessionKey, + storePath: params.storePath, + }) + : params.storePath; const hookEvent = createInternalHookEvent("command", params.action, params.sessionKey ?? "", { + agentId: hookAgentId, sessionEntry: params.sessionEntry, previousSessionEntry: params.previousSessionEntry, commandSource: params.command.surface, senderId: params.command.senderId, workspaceDir: params.workspaceDir, + storePath: hookStorePath, cfg: params.cfg, }); await triggerInternalHook(hookEvent); @@ -117,13 +135,21 @@ export async function emitResetCommandHooks(params: { const hookRunner = getGlobalHookRunner(); if (hookRunner?.hasHooks("before_reset")) { const prevEntry = params.previousSessionEntry; - const agentId = resolveSessionAgentId({ sessionKey: params.sessionKey, config: params.cfg }); + const agentId = hookAgentId; + const storePath = hookStorePath; const beforeResetTranscript = await loadBeforeResetTranscript({ agentId, - sessionFile: prevEntry?.sessionFile, + sessionFile: + agentId && prevEntry?.sessionId && storePath + ? formatSqliteSessionFileMarker({ + agentId, + sessionId: prevEntry.sessionId, + storePath, + }) + : params.sessionKey, sessionId: prevEntry?.sessionId, sessionKey: params.sessionKey, - storePath: params.storePath, + storePath, }); void (async () => { try { diff --git a/src/auto-reply/reply/commands-reset.ts b/src/auto-reply/reply/commands-reset.ts index 1f7f42c82855..aebd0a4cd24b 100644 --- a/src/auto-reply/reply/commands-reset.ts +++ b/src/auto-reply/reply/commands-reset.ts @@ -106,6 +106,7 @@ export async function maybeHandleResetCommand( await emitResetCommandHooks({ action: "reset", + agentId: params.agentId, ctx: params.ctx, cfg: params.cfg, command: params.command, @@ -180,6 +181,7 @@ export async function maybeHandleResetCommand( const hookResult = await emitResetCommandHooks({ action: commandAction, + agentId: params.agentId, ctx: params.ctx, cfg: params.cfg, command: params.command, diff --git a/src/auto-reply/reply/commands-session-usage.test.ts b/src/auto-reply/reply/commands-session-usage.test.ts index 0418c0551504..24c6e6134461 100644 --- a/src/auto-reply/reply/commands-session-usage.test.ts +++ b/src/auto-reply/reply/commands-session-usage.test.ts @@ -181,17 +181,29 @@ describe("handleUsageCommand", () => { ); }); + it("keeps the current agent for an unqualified global session key", async () => { + const params = buildUsageParams(); + params.agentId = "other"; + params.sessionKey = "global"; + + await handleUsageCommand(params, true); + + const args = expectSessionCostArgs(); + expect(args.agentId).toBe("other"); + expect(args.sessionTarget).toMatchObject({ agentId: "other", sessionKey: "global" }); + expect(resolveSessionAgentIdMock).not.toHaveBeenCalled(); + }); + it("prefers the target session entry from sessionStore for /usage cost", async () => { const params = buildUsageParams(); + params.storePath = "/tmp/custom-session-store.sqlite"; params.sessionEntry = { sessionId: "wrapper-session", - sessionFile: "/tmp/wrapper-session.jsonl", updatedAt: Date.now(), }; params.sessionStore = { [params.sessionKey]: { sessionId: "target-session", - sessionFile: "/tmp/target-session.jsonl", updatedAt: Date.now(), }, }; @@ -200,7 +212,12 @@ describe("handleUsageCommand", () => { const args = expectSessionCostArgs(); expect(args.sessionId).toBe("target-session"); - expect(args.sessionFile).toBe("/tmp/target-session.jsonl"); + expect(args.sessionTarget).toMatchObject({ + agentId: "target", + sessionId: "target-session", + sessionKey: params.sessionKey, + storePath: params.storePath, + }); }); it("prefers the target session entry from sessionStore for /usage footer mode", async () => { diff --git a/src/auto-reply/reply/commands-session.ts b/src/auto-reply/reply/commands-session.ts index 8f22a5085481..b10d7f6c2cdc 100644 --- a/src/auto-reply/reply/commands-session.ts +++ b/src/auto-reply/reply/commands-session.ts @@ -20,6 +20,8 @@ import { formatThreadBindingDurationLabel } from "../../channels/thread-bindings import { parseDurationMs } from "../../cli/parse-duration.js"; import { isRestartEnabled } from "../../config/commands.flags.js"; import { extractDeliveryInfo } from "../../config/sessions.js"; +import { resolveStorePath } from "../../config/sessions/paths.js"; +import { resolveSessionStorePathForScope } from "../../config/sessions/session-store-path.js"; import { logVerbose } from "../../globals.js"; import { getSessionBindingService } from "../../infra/outbound/session-binding-service.js"; import type { SessionBindingRecord } from "../../infra/outbound/session-binding-service.js"; @@ -32,6 +34,7 @@ import { } from "../../infra/restart-sentinel.js"; import { scheduleGatewaySigusr1Restart, triggerOpenClawRestart } from "../../infra/restart.js"; import { loadCostUsageSummary, loadSessionCostSummary } from "../../infra/session-cost-usage.js"; +import { DEFAULT_AGENT_ID, isUnscopedSessionKeySentinel } from "../../routing/session-key.js"; import { asDateTimestampMs, resolveExpiresAtMsFromDurationMs, @@ -313,21 +316,40 @@ export const handleUsageCommand: CommandHandler = async (params, allowTextComman const requested = rawArgs ? normalizeUsageDisplay(rawArgs) : undefined; if (normalizeLowercaseStringOrEmpty(rawArgs).startsWith("cost")) { const targetSessionEntry = params.sessionStore?.[params.sessionKey] ?? params.sessionEntry; - const sessionAgentId = resolveSessionAgentId({ - sessionKey: params.sessionKey, - config: params.cfg, - agentId: params.agentId, - }); + const sessionAgentId = + params.sessionKey && !isUnscopedSessionKeySentinel(params.sessionKey) + ? resolveSessionAgentId({ + sessionKey: params.sessionKey, + config: params.cfg, + agentId: params.agentId, + }) + : params.agentId; + const usageAgentId = sessionAgentId ?? DEFAULT_AGENT_ID; const sessionSummary = await loadSessionCostSummary({ sessionId: targetSessionEntry?.sessionId, sessionEntry: targetSessionEntry, - sessionFile: targetSessionEntry?.sessionFile, + ...(targetSessionEntry?.sessionId && params.sessionKey + ? { + sessionTarget: { + agentId: usageAgentId, + sessionId: targetSessionEntry.sessionId, + sessionKey: params.sessionKey, + storePath: resolveSessionStorePathForScope({ + agentId: usageAgentId, + sessionKey: params.sessionKey, + storePath: + params.storePath ?? + resolveStorePath(params.cfg.session?.store, { agentId: usageAgentId }), + }), + }, + } + : {}), config: params.cfg, - agentId: sessionAgentId, + agentId: usageAgentId, }); const summary = await loadCostUsageSummary({ config: params.cfg, - agentId: sessionAgentId, + agentId: usageAgentId, }); const sessionCost = formatUsd(sessionSummary?.totalCost); diff --git a/src/auto-reply/reply/commands-status.test.ts b/src/auto-reply/reply/commands-status.test.ts index e7080605321a..d97cd4ddcf10 100644 --- a/src/auto-reply/reply/commands-status.test.ts +++ b/src/auto-reply/reply/commands-status.test.ts @@ -14,6 +14,10 @@ import { resetSubagentRegistryForTests, } from "../../agents/subagent-registry.test-helpers.js"; import type { OpenClawConfig } from "../../config/config.js"; +import { + persistSessionTranscriptTurn, + replaceSessionEntry, +} from "../../config/sessions/session-accessor.js"; import type { ModelDefinitionConfig } from "../../config/types.models.js"; import type { ProviderThinkingProfile } from "../../plugins/provider-thinking.types.js"; import { @@ -235,7 +239,7 @@ afterEach(() => { }); }); -function writeTranscriptUsageLog(params: { +async function writeTranscriptUsageLog(params: { dir: string; agentId: string; sessionId: string; @@ -247,26 +251,33 @@ function writeTranscriptUsageLog(params: { totalTokens: number; }; }) { - const logPath = path.join( + const storePath = path.join( params.dir, ".openclaw", "agents", params.agentId, "sessions", - `${params.sessionId}.jsonl`, + "sessions.json", ); - fs.mkdirSync(path.dirname(logPath), { recursive: true }); - fs.writeFileSync( - logPath, - JSON.stringify({ - type: "message", - message: { - role: "assistant", - model: "claude-opus-4-5", - usage: params.usage, - }, - }), - "utf-8", + const sessionKey = `agent:${params.agentId}:main`; + await replaceSessionEntry( + { agentId: params.agentId, sessionKey, storePath }, + { sessionId: params.sessionId, updatedAt: Date.now() }, + ); + await persistSessionTranscriptTurn( + { agentId: params.agentId, sessionId: params.sessionId, sessionKey, storePath }, + { + messages: [ + { + message: { + role: "assistant", + model: "claude-opus-4-5", + usage: params.usage, + }, + }, + ], + touchSessionEntry: false, + }, ); } @@ -623,7 +634,7 @@ describe("buildStatusReply subagent summary", () => { it("uses transcript usage fallback in /status output", async () => { await withTempHome(async (dir) => { const sessionId = "sess-status-transcript"; - writeTranscriptUsageLog({ + await writeTranscriptUsageLog({ dir, agentId: "main", sessionId, diff --git a/src/auto-reply/reply/commands-steer.runtime.ts b/src/auto-reply/reply/commands-steer.runtime.ts index 1c33aeca16cd..8306295964d8 100644 --- a/src/auto-reply/reply/commands-steer.runtime.ts +++ b/src/auto-reply/reply/commands-steer.runtime.ts @@ -4,5 +4,4 @@ export { isEmbeddedAgentRunActive, queueEmbeddedAgentMessageWithOutcomeAsync, resolveActiveEmbeddedRunSessionId, - resolveActiveEmbeddedRunSessionIdBySessionFile, } from "../../agents/embedded-agent-runner/runs.js"; diff --git a/src/auto-reply/reply/commands-steer.test.ts b/src/auto-reply/reply/commands-steer.test.ts index 2b5a324fc1d3..52299f0e44d7 100644 --- a/src/auto-reply/reply/commands-steer.test.ts +++ b/src/auto-reply/reply/commands-steer.test.ts @@ -136,10 +136,8 @@ describe("handleSteerCommand", () => { ); }); - it("resolves an active run from the target session file before stored session id fallback", async () => { - steerRuntimeMocks.resolveActiveEmbeddedRunSessionIdBySessionFile.mockReturnValue( - "session-file-active", - ); + it("resolves an active run from the target session key before stored session id fallback", async () => { + steerRuntimeMocks.resolveActiveEmbeddedRunSessionId.mockReturnValue("session-key-active"); const params = buildParams("/steer check the active file"); params.ctx.CommandSource = "native"; @@ -148,7 +146,6 @@ describe("handleSteerCommand", () => { params.sessionStore = { "agent:main:telegram:topic:5907": { sessionId: "stored-session-id", - sessionFile: "/tmp/openclaw-topic-5907.jsonl", updatedAt: Date.now(), }, }; @@ -158,14 +155,12 @@ describe("handleSteerCommand", () => { expect(steerRuntimeMocks.resolveActiveEmbeddedRunSessionId).toHaveBeenCalledWith( "agent:main:telegram:topic:5907", ); - expect(steerRuntimeMocks.resolveActiveEmbeddedRunSessionIdBySessionFile).toHaveBeenCalledWith( - "/tmp/openclaw-topic-5907.jsonl", - ); + expect(steerRuntimeMocks.resolveActiveEmbeddedRunSessionIdBySessionFile).not.toHaveBeenCalled(); expect(steerRuntimeMocks.isEmbeddedAgentRunActive).not.toHaveBeenCalledWith( "stored-session-id", ); expect(steerRuntimeMocks.queueEmbeddedAgentMessageWithOutcomeAsync).toHaveBeenCalledWith( - "session-file-active", + "session-key-active", "check the active file", { steeringMode: "all", diff --git a/src/auto-reply/reply/commands-steer.ts b/src/auto-reply/reply/commands-steer.ts index b574ddeb2ce2..ca88c13d5f61 100644 --- a/src/auto-reply/reply/commands-steer.ts +++ b/src/auto-reply/reply/commands-steer.ts @@ -13,7 +13,6 @@ import { isEmbeddedAgentRunActive, queueEmbeddedAgentMessageWithOutcomeAsync, resolveActiveEmbeddedRunSessionId, - resolveActiveEmbeddedRunSessionIdBySessionFile, } from "./commands-steer.runtime.js"; import type { CommandHandler, @@ -81,18 +80,6 @@ function resolveSteerSessionId(params: { } } - for (const candidateKey of candidateKeys) { - const entry = resolveStoredSessionEntry(params.commandParams, candidateKey); - const sessionFile = normalizeOptionalString(entry?.sessionFile); - if (!sessionFile) { - continue; - } - const activeSessionId = resolveActiveEmbeddedRunSessionIdBySessionFile(sessionFile); - if (activeSessionId) { - return activeSessionId; - } - } - for (const candidateKey of candidateKeys) { const entry = resolveStoredSessionEntry(params.commandParams, candidateKey); const sessionId = normalizeOptionalString(entry?.sessionId); diff --git a/src/auto-reply/reply/commands-subagents/action-info.ts b/src/auto-reply/reply/commands-subagents/action-info.ts index c0cd2a0b0ac9..5790e66bcb74 100644 --- a/src/auto-reply/reply/commands-subagents/action-info.ts +++ b/src/auto-reply/reply/commands-subagents/action-info.ts @@ -104,7 +104,6 @@ export function handleSubagentsInfoAction(ctx: SubagentsCommandContext): Command linkedTask ? `TaskStatus: ${linkedTask.status}` : undefined, `Session: ${run.childSessionKey}`, `SessionId: ${sessionEntry?.sessionId ?? "n/a"}`, - `Transcript: ${sessionEntry?.sessionFile ?? "n/a"}`, `Runtime: ${runtime}`, `Created: ${formatTimestampWithAge(run.createdAt)}`, `Started: ${formatTimestampWithAge(run.startedAt)}`, diff --git a/src/auto-reply/reply/commands-tts.test.ts b/src/auto-reply/reply/commands-tts.test.ts index 1610f36a870f..2f2603862503 100644 --- a/src/auto-reply/reply/commands-tts.test.ts +++ b/src/auto-reply/reply/commands-tts.test.ts @@ -5,6 +5,12 @@ import path from "node:path"; import { beforeEach, describe, expect, it, vi } from "vitest"; import type { OpenClawConfig } from "../../config/config.js"; import type { SessionEntry } from "../../config/sessions.js"; +import { + appendTranscriptMessageSync, + loadSessionEntry, + replaceSessionEntry, +} from "../../config/sessions/session-accessor.js"; +import { resolveSessionStorePathForScope } from "../../config/sessions/session-store-path.js"; const ttsMocks = vi.hoisted(() => ({ getResolvedSpeechProviderConfig: vi.fn(), @@ -331,56 +337,62 @@ describe("handleTtsCommands status fallback reporting", () => { it("reads the latest assistant transcript reply once", async () => { const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), "openclaw-tts-latest-")); - const sessionFile = path.join(tempDir, "session.jsonl"); - fs.writeFileSync( - sessionFile, - [ - JSON.stringify({ type: "session", id: "s1" }), - JSON.stringify({ - type: "message", - message: { role: "assistant", content: [{ type: "text", text: "older reply" }] }, - }), - JSON.stringify({ - type: "message", - message: { - role: "assistant", - content: [ - { - type: "text", - text: "internal note", - textSignature: JSON.stringify({ - v: 1, - id: "item_commentary", - phase: "commentary", - }), - }, - { - type: "text", - text: "latest visible reply", - textSignature: JSON.stringify({ - v: 1, - id: "item_final", - phase: "final_answer", - }), - }, - ], + const storePath = path.join(tempDir, "sessions.json"); + const sessionKey = "agent:other:tts-latest"; + const sessionEntry: SessionEntry = { sessionId: "s1", updatedAt: 1 }; + const sessionStore = { [sessionKey]: sessionEntry }; + await replaceSessionEntry({ agentId: "other", sessionKey, storePath }, sessionEntry); + const transcriptScope = { + agentId: "other", + sessionId: "s1", + sessionKey, + storePath, + }; + appendTranscriptMessageSync(transcriptScope, { + message: { role: "assistant", content: [{ type: "text", text: "older reply" }] }, + }); + Object.assign(sessionEntry, loadSessionEntry({ agentId: "other", sessionKey, storePath })); + appendTranscriptMessageSync(transcriptScope, { + message: { + role: "assistant", + content: [ + { + type: "text", + text: "internal note", + textSignature: JSON.stringify({ + v: 1, + id: "item_commentary", + phase: "commentary", + }), }, - }), - ].join("\n") + "\n", - "utf-8", - ); + { + type: "text", + text: "latest visible reply", + textSignature: JSON.stringify({ + v: 1, + id: "item_final", + phase: "final_answer", + }), + }, + ], + }, + }); ttsMocks.textToSpeech.mockResolvedValue({ success: true, audioPath: "/tmp/latest.ogg", provider: PRIMARY_TTS_PROVIDER, voiceCompatible: true, }); - const sessionEntry: SessionEntry = { sessionId: "s1", updatedAt: 1, sessionFile }; - const sessionStore = { "session-key": sessionEntry }; - const beforeTtsRead = Date.now(); + const initialSessionEntry = structuredClone(sessionEntry); const result = await handleTtsCommands( - buildTtsParams("/tts read latest", {}, undefined, { sessionEntry, sessionStore }), + buildTtsParams("/tts read latest", {}, "main", { + initialSessionEntry, + sessionEntry, + sessionStore, + sessionKey, + storePath, + }), true, ); @@ -396,29 +408,80 @@ describe("handleTtsCommands status fallback reporting", () => { expect(sessionEntry.lastTtsReadLatestAt).toBeGreaterThanOrEqual(beforeTtsRead); }); + it("reads the latest assistant reply from the incognito transcript store", async () => { + const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), "openclaw-tts-incognito-")); + const durableStorePath = path.join(tempDir, "sessions.json"); + const sessionKey = "agent:main:dashboard:incognito-tts-latest"; + const sessionEntry: SessionEntry = { sessionId: "incognito-session", updatedAt: 1 }; + const scopedStorePath = resolveSessionStorePathForScope({ + agentId: "main", + sessionKey, + storePath: durableStorePath, + }); + await replaceSessionEntry( + { agentId: "main", sessionKey, storePath: scopedStorePath }, + sessionEntry, + ); + appendTranscriptMessageSync( + { + agentId: "main", + sessionId: sessionEntry.sessionId, + sessionKey, + storePath: scopedStorePath, + }, + { message: { role: "assistant", content: "incognito latest reply" } }, + ); + ttsMocks.textToSpeech.mockResolvedValue({ + success: true, + audioPath: "/tmp/incognito-latest.ogg", + provider: PRIMARY_TTS_PROVIDER, + voiceCompatible: true, + }); + + const result = await handleTtsCommands( + buildTtsParams("/tts latest", {}, "main", { + initialSessionEntry: structuredClone(sessionEntry), + sessionEntry, + sessionStore: { [sessionKey]: sessionEntry }, + sessionKey, + storePath: durableStorePath, + }), + true, + ); + + expect(expectReply(result).spokenText).toBe("incognito latest reply"); + }); + it("does not resend /tts latest for the same assistant reply", async () => { const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), "openclaw-tts-latest-")); - const sessionFile = path.join(tempDir, "session.jsonl"); - fs.writeFileSync( - sessionFile, - [ - JSON.stringify({ type: "session", id: "s1" }), - JSON.stringify({ - type: "message", - message: { role: "assistant", content: [{ type: "text", text: "read me once" }] }, - }), - ].join("\n") + "\n", - "utf-8", + const storePath = path.join(tempDir, "sessions.json"); + const sessionEntry: SessionEntry = { sessionId: "s1", updatedAt: 1 }; + const sessionStore = { "session-key": sessionEntry }; + await replaceSessionEntry( + { agentId: "main", sessionKey: "session-key", storePath }, + sessionEntry, ); + appendTranscriptMessageSync( + { agentId: "main", sessionId: "s1", sessionKey: "session-key", storePath }, + { message: { role: "assistant", content: [{ type: "text", text: "read me once" }] } }, + ); + Object.assign( + sessionEntry, + loadSessionEntry({ agentId: "main", sessionKey: "session-key", storePath }), + ); + const initialSessionEntry = structuredClone(sessionEntry); ttsMocks.textToSpeech.mockResolvedValue({ success: true, audioPath: "/tmp/latest.ogg", provider: PRIMARY_TTS_PROVIDER, voiceCompatible: true, }); - const sessionEntry: SessionEntry = { sessionId: "s1", updatedAt: 1, sessionFile }; - const sessionStore = { "session-key": sessionEntry }; - const params = buildTtsParams("/tts latest", {}, undefined, { sessionEntry, sessionStore }); + const params = buildTtsParams("/tts latest", {}, "main", { + sessionEntry, + initialSessionEntry, + sessionStore, + storePath, + }); const first = await handleTtsCommands(params, true); const firstReply = expectReply(first); diff --git a/src/auto-reply/reply/commands-tts.ts b/src/auto-reply/reply/commands-tts.ts index 3276681941b3..cdb735b29385 100644 --- a/src/auto-reply/reply/commands-tts.ts +++ b/src/auto-reply/reply/commands-tts.ts @@ -5,7 +5,12 @@ import { normalizeOptionalString, } from "@openclaw/normalization-core/string-coerce"; import { readLatestAssistantTextFromSessionTranscript } from "../../config/sessions.js"; +import { resolveSessionStorePathForScope } from "../../config/sessions/session-store-path.js"; import { logVerbose } from "../../globals.js"; +import { + isUnscopedSessionKeySentinel, + resolveAgentIdFromSessionKey, +} from "../../routing/session-key.js"; import { canonicalizeSpeechProviderId, getSpeechProvider, @@ -220,9 +225,20 @@ async function handleTtsLatestAction( if (!params.sessionEntry || !params.sessionStore || !params.sessionKey) { return stopWithText("🎤 No active chat session is available for `/tts latest`."); } - const latest = await readLatestAssistantTextFromSessionTranscript( - params.sessionEntry.sessionFile, - ); + const targetSessionEntry = params.sessionStore[params.sessionKey] ?? params.sessionEntry; + const targetAgentId = isUnscopedSessionKeySentinel(params.sessionKey) + ? params.agentId + : resolveAgentIdFromSessionKey(params.sessionKey, params.agentId); + const latest = await readLatestAssistantTextFromSessionTranscript({ + agentId: targetAgentId, + sessionId: targetSessionEntry.sessionId, + sessionKey: params.sessionKey, + storePath: resolveSessionStorePathForScope({ + agentId: targetAgentId, + sessionKey: params.sessionKey, + storePath: params.storePath, + }), + }); const latestText = latest?.text.trim(); if (!latestText || isSilentReplyPayloadText(latestText)) { return stopWithText("🎤 No readable assistant reply was found in this chat yet."); @@ -238,7 +254,7 @@ async function handleTtsLatestAction( channel: params.command.channel, accountId, prefsPath, - agentId: params.agentId, + agentId: targetAgentId, }); if ("error" in audio) { return stopWithText(`❌ Error generating audio: ${audio.error}`); diff --git a/src/auto-reply/reply/dispatch-from-config.send-policy-routing.test-utils.ts b/src/auto-reply/reply/dispatch-from-config.send-policy-routing.test-utils.ts index b56802c863c6..6b00240b0efd 100644 --- a/src/auto-reply/reply/dispatch-from-config.send-policy-routing.test-utils.ts +++ b/src/auto-reply/reply/dispatch-from-config.send-policy-routing.test-utils.ts @@ -287,7 +287,16 @@ describe("sendPolicy deny — suppress delivery, not processing (#53328)", () => ); transcriptMocks.appendAssistantMessageToSessionTranscript.mockImplementationOnce(async () => { order.push("mirror"); - return { ok: true, sessionFile: "/tmp/session.jsonl", messageId: "message-1" }; + return { + ok: true, + target: { + agentId: "main", + sessionId: "test-session", + sessionKey: "agent:main", + storePath: "/tmp/sessions.json", + }, + messageId: "message-1", + }; }); const firstResult = await dispatchReplyFromConfig({ diff --git a/src/auto-reply/reply/dispatch-from-config.shared.test-harness.ts b/src/auto-reply/reply/dispatch-from-config.shared.test-harness.ts index 61e858bd4767..03f968265ea0 100644 --- a/src/auto-reply/reply/dispatch-from-config.shared.test-harness.ts +++ b/src/auto-reply/reply/dispatch-from-config.shared.test-harness.ts @@ -248,7 +248,12 @@ const transcriptMocks = vi.hoisted(() => ({ persistAcpDispatchTranscript: vi.fn(async (_params: unknown) => undefined), appendAssistantMessageToSessionTranscript: vi.fn(async (_params: unknown) => ({ ok: true, - sessionFile: "/tmp/session.jsonl", + target: { + agentId: "main", + sessionId: "test-session", + sessionKey: "agent:main", + storePath: "/tmp/sessions.json", + }, messageId: "message-1", })), })); diff --git a/src/auto-reply/reply/followup-turn-admission.test.ts b/src/auto-reply/reply/followup-turn-admission.test.ts index 5f486991d62e..6d97daea0c9b 100644 --- a/src/auto-reply/reply/followup-turn-admission.test.ts +++ b/src/auto-reply/reply/followup-turn-admission.test.ts @@ -150,7 +150,6 @@ describe("admitFollowupTurn", () => { const queuedEntry: SessionEntry = { sessionId: "queued-session", updatedAt: 1 }; const admittedEntry: SessionEntry = { sessionId: "admitted-session", - sessionFile: "/tmp/admitted.jsonl", modelSelectionLocked: true, updatedAt: 2, }; @@ -179,7 +178,7 @@ describe("admitFollowupTurn", () => { if (result.kind === "admitted") { expect(result.turn.queued.run).toMatchObject({ sessionId: "admitted-session", - sessionFile: "sqlite:agent:admitted-session:/tmp/sessions.json", + sessionFile: "main", modelSelectionLocked: true, }); expect(result.turn.currentInboundContext).toEqual({ text: "fresh goal" }); @@ -566,8 +565,7 @@ describe("admitFollowupTurn", () => { sessionId: "compacted-session", modelSelectionLocked: false, }); - expect(result.turn.queued.run.sessionFile).toContain("compacted-session"); - expect(result.turn.queued.run.sessionFile).not.toBe("/tmp/session.jsonl"); + expect(result.turn.queued.run.sessionFile).toBe("main"); expect(result.turn.queued.run.cliSessionBindingFacts).toBeUndefined(); expect(result.turn.queued.run.autoFallbackPrimaryProbe).toBeUndefined(); expect(result.turn.preflightCompactionApplied).toBe(true); diff --git a/src/auto-reply/reply/followup-turn-admission.ts b/src/auto-reply/reply/followup-turn-admission.ts index 9924ec52c444..f5900f8e9f95 100644 --- a/src/auto-reply/reply/followup-turn-admission.ts +++ b/src/auto-reply/reply/followup-turn-admission.ts @@ -2,7 +2,6 @@ import crypto from "node:crypto"; import type { CurrentInboundPromptContext } from "../../agents/embedded-agent-runner/run/params.js"; import { normalizeChatType } from "../../channels/chat-type.js"; import type { SessionEntry } from "../../config/sessions.js"; -import { resolveSessionTranscriptPath } from "../../config/sessions/paths.js"; import { loadSessionEntry } from "../../config/sessions/session-accessor.js"; import type { TypingMode } from "../../config/types.js"; import type { OpenClawConfig } from "../../config/types.openclaw.js"; @@ -328,8 +327,9 @@ export async function admitFollowupTurn(params: { resolveAdmittedRunSessionFile({ agentId: run.agentId, sessionId: operation.sessionId, + sessionKey: replySessionKey, storePath: params.defaults.storePath, - }) ?? resolveSessionTranscriptPath(operation.sessionId, run.agentId), + }) ?? run.sessionFile, cliSessionBindingFacts: undefined, autoFallbackPrimaryProbe: undefined, modelSelectionLocked: false, @@ -392,7 +392,7 @@ export async function admitFollowupTurn(params: { resolveAdmittedRunSessionFile({ agentId: run.agentId, sessionId: operation.sessionId, - sessionFile: activeEntry.sessionFile, + sessionKey: replySessionKey, storePath: params.defaults.storePath, }) ?? run.sessionFile, modelSelectionLocked: activeEntry.modelSelectionLocked === true, @@ -487,9 +487,9 @@ export async function admitFollowupTurn(params: { resolveAdmittedRunSessionFile({ agentId: turn.queued.run.agentId, sessionId: entry.sessionId, - sessionFile: entry.sessionFile, + sessionKey: replySessionKey, storePath: params.defaults.storePath, - }) ?? resolveSessionTranscriptPath(entry.sessionId, turn.queued.run.agentId), + }) ?? turn.queued.run.sessionFile, cliSessionBindingFacts: undefined, autoFallbackPrimaryProbe: undefined, modelSelectionLocked: entry.modelSelectionLocked === true, diff --git a/src/auto-reply/reply/get-reply-fast-path.ts b/src/auto-reply/reply/get-reply-fast-path.ts index e4f67c4b3bf2..6f263df5d5d0 100644 --- a/src/auto-reply/reply/get-reply-fast-path.ts +++ b/src/auto-reply/reply/get-reply-fast-path.ts @@ -11,7 +11,6 @@ import { resolveStorePath } from "../../config/sessions/paths.js"; import { loadSessionEntry, listSessionEntries } from "../../config/sessions/session-accessor.js"; import { buildSessionCreationStamp } from "../../config/sessions/session-entry-provenance.js"; import { resolveSessionKey } from "../../config/sessions/session-key.js"; -import { formatSqliteSessionFileMarker } from "../../config/sessions/sqlite-marker.js"; import type { SessionEntry, SessionScope } from "../../config/sessions/types.js"; import type { OpenClawConfig } from "../../config/types.openclaw.js"; import { isVitestRuntimeEnv } from "../../infra/env.js"; @@ -213,10 +212,6 @@ export function initFastReplySessionState(params: { ? normalizedResetBody.slice(resetMatch?.[0].length ?? 0).trimStart() : (ctx.agentText ?? ""); const now = Date.now(); - const sessionFile = - !resetTriggered && existingEntry?.sessionFile - ? existingEntry.sessionFile - : formatSqliteSessionFileMarker({ agentId, sessionId, storePath }); const sessionEntry: SessionEntry = { ...(!resetTriggered ? existingEntry : undefined), sessionId, @@ -240,7 +235,6 @@ export function initFastReplySessionState(params: { subagentControlScope: existingEntry.subagentControlScope, } : {}), - sessionFile, updatedAt: now, sessionStartedAt: resetTriggered ? now : (existingEntry?.sessionStartedAt ?? now), lastInteractionAt: now, diff --git a/src/auto-reply/reply/get-reply-run-admission.ts b/src/auto-reply/reply/get-reply-run-admission.ts index cde3291f0012..22584ff20ec0 100644 --- a/src/auto-reply/reply/get-reply-run-admission.ts +++ b/src/auto-reply/reply/get-reply-run-admission.ts @@ -4,15 +4,12 @@ import { clearAutoFallbackPrimaryProbeSelection } from "../../agents/agent-scope import { resolveSessionAuthProfileOverride } from "../../agents/auth-profiles/session-override.js"; import { resolveAgentHarnessPolicy } from "../../agents/harness/policy.js"; import { listOpenAIAuthProfileProvidersForAgentRuntime } from "../../agents/openai-routing.js"; +import { formatSqliteSessionFileMarker } from "../../config/sessions/legacy-sqlite-marker.js"; import { resolveSessionFilePath, resolveSessionFilePathOptions, } from "../../config/sessions/paths.js"; import { loadSessionEntry } from "../../config/sessions/session-accessor.js"; -import { - formatSqliteSessionFileMarker, - sqliteSessionFileMarkerMatchesSession, -} from "../../config/sessions/sqlite-marker.js"; import type { SessionEntry } from "../../config/sessions/types.js"; import { logVerbose } from "../../globals.js"; import { isFastTestRuntimeEnv } from "../../infra/env.js"; @@ -319,14 +316,9 @@ export async function prepareReplyRunAdmission(context: PreparedReplyRunContext) : sessionEntry; const latestSessionId = latestSessionEntry?.sessionId ?? sessionIdFinal; opts?.onSessionPrepared?.({ sessionKey, sessionId: latestSessionId, storePath }); - const existingSessionFile = latestSessionEntry?.sessionFile; - const sessionFile = - existingSessionFile && - sqliteSessionFileMarkerMatchesSession(existingSessionFile, latestSessionId) - ? existingSessionFile - : storePath - ? formatSqliteSessionFileMarker({ agentId, sessionId: latestSessionId, storePath }) - : resolveSessionFilePath(latestSessionId, latestSessionEntry, sessionFilePathOptions); + const sessionFile = storePath + ? formatSqliteSessionFileMarker({ agentId, sessionId: latestSessionId, storePath }) + : resolveSessionFilePath(latestSessionId, latestSessionEntry, sessionFilePathOptions); return { sessionEntry: latestSessionEntry, sessionId: latestSessionId, sessionFile }; }; let preparedSessionState = resolvePreparedSessionState(); diff --git a/src/auto-reply/reply/get-reply.fast-path.test.ts b/src/auto-reply/reply/get-reply.fast-path.test.ts index 48717dd0792a..23510e0a390b 100644 --- a/src/auto-reply/reply/get-reply.fast-path.test.ts +++ b/src/auto-reply/reply/get-reply.fast-path.test.ts @@ -8,7 +8,6 @@ import { testing as cliBackendsTesting } from "../../agents/cli-backends.test-su import type { OpenClawConfig } from "../../config/config.js"; import type { SessionEntry } from "../../config/sessions.js"; import { loadSessionEntry, replaceSessionEntry } from "../../config/sessions/session-accessor.js"; -import { formatSqliteSessionFileMarker } from "../../config/sessions/sqlite-marker.js"; import { MODEL_SELECTION_LOCKED_RESET_MESSAGE, ModelSelectionLockedError, @@ -119,12 +118,16 @@ async function seedFastPathSessionStore( entries: Record>, ): Promise { for (const [sessionKey, entry] of Object.entries(entries)) { - await replaceSessionEntry({ storePath, sessionKey }, entry as SessionEntry); + await replaceSessionEntry({ storePath, sessionKey }, entry as unknown as SessionEntry); } } function readFastPathSessionEntry(storePath: string, sessionKey: string): Record { - return (loadSessionEntry({ storePath, sessionKey }) as Record | undefined) ?? {}; + return ( + (loadSessionEntry({ storePath, sessionKey }) as unknown as + | Record + | undefined) ?? {} + ); } describe("getReplyFromConfig fast test bootstrap", () => { @@ -741,7 +744,8 @@ describe("getReplyFromConfig fast test bootstrap", () => { ), ); expect( - (readFastPathSessionEntry(storePath, targetSessionKey) as SessionEntry).goal?.objective, + (readFastPathSessionEntry(storePath, targetSessionKey) as unknown as SessionEntry).goal + ?.objective, ).toBe("/status"); const preparedReplyParams = requirePreparedReplyParams(); expect(preparedReplyParams.command.commandBodyNormalized).toBe(continuationPrompt); @@ -765,13 +769,7 @@ describe("getReplyFromConfig fast test bootstrap", () => { expect(result.sessionKey).toBe("agent:main:main"); expect(result.sessionCtx.SessionKey).toBe("agent:main:main"); - expect(result.sessionEntry.sessionFile).toBe( - formatSqliteSessionFileMarker({ - agentId: "main", - sessionId: result.sessionId, - storePath, - }), - ); + expect(result.sessionEntry).not.toHaveProperty("sessionFile"); }); it("stamps trusted creation provenance during fast bootstrap", () => { diff --git a/src/auto-reply/reply/get-reply.ts b/src/auto-reply/reply/get-reply.ts index 02f1d318c2d3..719dde122018 100644 --- a/src/auto-reply/reply/get-reply.ts +++ b/src/auto-reply/reply/get-reply.ts @@ -871,6 +871,7 @@ export async function getReplyFromConfig( const action: ResetCommandAction = resetMatch[1]?.toLowerCase() === "reset" ? "reset" : "new"; await emitResetCommandHooks({ action, + agentId, ctx, cfg, command, diff --git a/src/auto-reply/reply/queue.collect.test.ts b/src/auto-reply/reply/queue.collect.test.ts index efcf12f38100..be1b0e8c30c7 100644 --- a/src/auto-reply/reply/queue.collect.test.ts +++ b/src/auto-reply/reply/queue.collect.test.ts @@ -7,7 +7,6 @@ import { loadTranscriptEvents, replaceSessionEntry, } from "../../config/sessions/session-accessor.js"; -import { formatSqliteSessionFileMarker } from "../../config/sessions/sqlite-marker.js"; import { createUserTurnTranscriptRecorder } from "../../sessions/user-turn-transcript.js"; import { createTestUserTurnTranscriptTarget } from "../../sessions/user-turn-transcript.test-support.js"; import type { FollowupRun, QueueSettings } from "./queue.js"; @@ -2609,13 +2608,7 @@ describe("followup queue collect routing", () => { const recorder = calls[0]?.userTurnTranscriptRecorder; expect(recorder).toBeDefined(); const persisted = await recorder?.persistFallback(); - expect(persisted?.sessionFile).toBe( - formatSqliteSessionFileMarker({ - agentId: "agent", - sessionId: "new-session", - storePath, - }), - ); + expect(persisted?.sessionFile).toBe("agent:agent:main"); await expect( loadTranscriptEvents({ agentId: "agent", diff --git a/src/auto-reply/reply/reply-state.test.ts b/src/auto-reply/reply/reply-state.test.ts index d036ccead952..e1981e91cf8b 100644 --- a/src/auto-reply/reply/reply-state.test.ts +++ b/src/auto-reply/reply/reply-state.test.ts @@ -6,6 +6,8 @@ import { expectDefined } from "@openclaw/normalization-core"; import { afterEach, describe, expect, it } from "vitest"; import type { SessionEntry } from "../../config/sessions.js"; import { loadSessionEntry, upsertSessionEntry } from "../../config/sessions/session-accessor.js"; +import { resolveSqliteTargetFromSessionStorePath } from "../../config/sessions/session-sqlite-target.js"; +import { resolveSessionStorePathForScope } from "../../config/sessions/session-store-path.js"; import { buildHistoryContext, buildHistoryContextFromEntries, @@ -36,7 +38,7 @@ afterEach(async () => { async function seedSessionStore(params: { storePath: string; sessionKey: string; - entry: Record; + entry: SessionEntry | Record; }) { await fs.mkdir(path.dirname(params.storePath), { recursive: true }); await upsertSessionEntry( @@ -63,37 +65,6 @@ async function createCompactionSessionFixture(entry: SessionEntry) { return { storePath, sessionKey, sessionStore }; } -async function rotateCompactionSessionFile(params: { - tempPrefix: string; - sessionFile: (tmp: string) => string; - newSessionId: string; -}) { - const tmp = await fs.mkdtemp(path.join(os.tmpdir(), params.tempPrefix)); - tempDirs.push(tmp); - const storePath = path.join(tmp, "sessions.json"); - const sessionKey = "main"; - const entry = { - sessionId: "s1", - sessionFile: params.sessionFile(tmp), - updatedAt: Date.now(), - compactionCount: 0, - } as SessionEntry; - const sessionStore: Record = { [sessionKey]: entry }; - await seedSessionStore({ storePath, sessionKey, entry }); - await incrementCompactionCount({ - sessionEntry: entry, - sessionStore, - sessionKey, - storePath, - newSessionId: params.newSessionId, - }); - const stored: Record = { - [sessionKey]: await loadStoredEntry(storePath, sessionKey), - }; - const expectedDir = await fs.realpath(tmp); - return { stored, sessionKey, expectedDir }; -} - describe("history helpers", () => { function createHistoryMapWithTwoEntries() { const historyMap = new Map(); @@ -515,6 +486,40 @@ describe("incrementCompactionCount", () => { ).toBe(3); }); + it("persists incognito compaction metadata only in the scoped store", async () => { + const tmp = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-incognito-compact-")); + tempDirs.push(tmp); + const durableStorePath = path.join(tmp, "sessions.json"); + const sessionKey = "agent:main:dashboard:incognito-compaction"; + const scopedStorePath = resolveSessionStorePathForScope({ + agentId: "main", + sessionKey, + storePath: durableStorePath, + }); + const durableDatabasePath = resolveSqliteTargetFromSessionStorePath(durableStorePath, { + agentId: "main", + }).path; + const entry = { sessionId: "incognito-session", updatedAt: 1 } as SessionEntry; + const sessionStore = { [sessionKey]: entry }; + await seedSessionStore({ storePath: scopedStorePath, sessionKey, entry }); + + await incrementCompactionCount({ + agentId: "main", + sessionEntry: entry, + sessionStore, + sessionKey, + storePath: durableStorePath, + }); + + expect((await loadStoredEntry(scopedStorePath, sessionKey)).compactionCount).toBe(1); + expect(durableDatabasePath).toBeDefined(); + await expect( + fs.stat(expectDefined(durableDatabasePath, "durable database path")), + ).rejects.toMatchObject({ + code: "ENOENT", + }); + }); + it("updates totalTokens when tokensAfter is provided", async () => { const entry = { sessionId: "s1", @@ -714,48 +719,6 @@ describe("incrementCompactionCount", () => { ).toBe(false); }); - it("updates sessionId and sessionFile when compaction rotated transcripts", async () => { - const { stored, sessionKey, expectedDir } = await rotateCompactionSessionFile({ - tempPrefix: "openclaw-compact-rotate-", - sessionFile: (tmp) => path.join(tmp, "s1-topic-456.jsonl"), - newSessionId: "s2", - }); - expect(expectDefined(stored[sessionKey], "stored[sessionKey] test invariant").sessionId).toBe( - "s2", - ); - expect(expectDefined(stored[sessionKey], "stored[sessionKey] test invariant").sessionFile).toBe( - path.join(expectedDir, "s2-topic-456.jsonl"), - ); - }); - - it("preserves fork transcript filenames when compaction rotates transcripts", async () => { - const { stored, sessionKey, expectedDir } = await rotateCompactionSessionFile({ - tempPrefix: "openclaw-compact-fork-", - sessionFile: (tmp) => path.join(tmp, "2026-03-23T12-34-56-789Z_s1.jsonl"), - newSessionId: "s2", - }); - expect(expectDefined(stored[sessionKey], "stored[sessionKey] test invariant").sessionId).toBe( - "s2", - ); - expect(expectDefined(stored[sessionKey], "stored[sessionKey] test invariant").sessionFile).toBe( - path.join(expectedDir, "2026-03-23T12-34-56-789Z_s2.jsonl"), - ); - }); - - it("keeps rewritten absolute sessionFile paths that stay inside the sessions directory", async () => { - const { stored, sessionKey, expectedDir } = await rotateCompactionSessionFile({ - tempPrefix: "openclaw-compact-unsafe-", - sessionFile: (tmp) => path.join(tmp, "outside", "s1.jsonl"), - newSessionId: "s2", - }); - expect(expectDefined(stored[sessionKey], "stored[sessionKey] test invariant").sessionId).toBe( - "s2", - ); - expect(expectDefined(stored[sessionKey], "stored[sessionKey] test invariant").sessionFile).toBe( - path.join(expectedDir, "outside", "s2.jsonl"), - ); - }); - it("increments compaction count by an explicit amount", async () => { const entry = { sessionId: "s1", updatedAt: Date.now(), compactionCount: 2 } as SessionEntry; const { storePath, sessionKey, sessionStore } = await createCompactionSessionFixture(entry); @@ -775,10 +738,9 @@ describe("incrementCompactionCount", () => { ).toBe(4); }); - it("updates sessionId and sessionFile when newSessionId is provided", async () => { + it("updates sessionId when newSessionId is provided", async () => { const entry = { sessionId: "old-session-id", - sessionFile: "old-session-id.jsonl", updatedAt: Date.now(), compactionCount: 1, } as SessionEntry; @@ -793,22 +755,17 @@ describe("incrementCompactionCount", () => { }); const stored = { [sessionKey]: await loadStoredEntry(storePath, sessionKey) }; - const expectedSessionDir = await fs.realpath(path.dirname(storePath)); expect(expectDefined(stored[sessionKey], "stored[sessionKey] test invariant").sessionId).toBe( "new-session-id", ); - expect(expectDefined(stored[sessionKey], "stored[sessionKey] test invariant").sessionFile).toBe( - path.join(expectedSessionDir, "new-session-id.jsonl"), - ); expect( expectDefined(stored[sessionKey], "stored[sessionKey] test invariant").compactionCount, ).toBe(2); }); - it("does not update sessionFile when newSessionId matches current sessionId", async () => { + it("keeps sessionId when newSessionId matches current sessionId", async () => { const entry = { sessionId: "same-id", - sessionFile: "same-id.jsonl", updatedAt: Date.now(), compactionCount: 0, } as SessionEntry; @@ -826,40 +783,6 @@ describe("incrementCompactionCount", () => { expect(expectDefined(stored[sessionKey], "stored[sessionKey] test invariant").sessionId).toBe( "same-id", ); - expect(expectDefined(stored[sessionKey], "stored[sessionKey] test invariant").sessionFile).toBe( - "same-id.jsonl", - ); - expect( - expectDefined(stored[sessionKey], "stored[sessionKey] test invariant").compactionCount, - ).toBe(1); - }); - - it("updates sessionFile when rotation keeps the same sessionId", async () => { - const entry = { - sessionId: "same-id", - sessionFile: "same-id.jsonl", - updatedAt: Date.now(), - compactionCount: 0, - } as SessionEntry; - const { storePath, sessionKey, sessionStore } = await createCompactionSessionFixture(entry); - const rotatedSessionFile = path.join(path.dirname(storePath), "rotated-same-id.jsonl"); - - await incrementCompactionCount({ - sessionEntry: entry, - sessionStore, - sessionKey, - storePath, - newSessionId: "same-id", - newSessionFile: rotatedSessionFile, - }); - - const stored = { [sessionKey]: await loadStoredEntry(storePath, sessionKey) }; - expect(expectDefined(stored[sessionKey], "stored[sessionKey] test invariant").sessionId).toBe( - "same-id", - ); - expect(expectDefined(stored[sessionKey], "stored[sessionKey] test invariant").sessionFile).toBe( - rotatedSessionFile, - ); expect( expectDefined(stored[sessionKey], "stored[sessionKey] test invariant").compactionCount, ).toBe(1); diff --git a/src/auto-reply/reply/session-fork.test.ts b/src/auto-reply/reply/session-fork.test.ts index e08c8b672ff8..dcfa7b0adfcb 100644 --- a/src/auto-reply/reply/session-fork.test.ts +++ b/src/auto-reply/reply/session-fork.test.ts @@ -182,7 +182,7 @@ describe("forkSessionEntryFromParent", () => { throw new Error("expected fork"); } expect(result.fork.sessionId).not.toBe("parent-session"); - expect(result.fork.sessionFile).toContain(`sqlite:main:${result.fork.sessionId}:`); + expect(result.fork.sessionFile).toBe(sessionKey); expect(fs.existsSync(storePath)).toBe(false); const stored = loadSessionEntry({ agentId: "main", sessionKey, storePath }); @@ -193,7 +193,6 @@ describe("forkSessionEntryFromParent", () => { sessionId: "parent-session", }, label: "forked child", - sessionFile: result.fork.sessionFile, sessionId: result.fork.sessionId, updatedAt: expect.any(Number), }); @@ -271,11 +270,7 @@ describe("forkSessionEntryFromParent", () => { expect(loadSessionEntry({ agentId: "main", sessionKey, storePath })).toMatchObject({ forkedFromParent: true, sessionId: "", - updatedAt: expect.any(Number), }); - expect( - loadSessionEntry({ agentId: "main", sessionKey, storePath })?.forkSource, - ).toBeUndefined(); }); it("skips stale-token SQLite parents using transcript usage estimates", async () => { diff --git a/src/auto-reply/reply/session-hooks-context.test.ts b/src/auto-reply/reply/session-hooks-context.test.ts index 2d127b17d55e..3b944789997b 100644 --- a/src/auto-reply/reply/session-hooks-context.test.ts +++ b/src/auto-reply/reply/session-hooks-context.test.ts @@ -254,7 +254,6 @@ describe("session hook context wiring", () => { expectFields(event, { sessionKey, reason: "new", - transcriptArchived: false, }); expectFields(context, { sessionKey, agentId: "main", sessionId: event?.sessionId }); @@ -410,7 +409,6 @@ describe("session hook context wiring", () => { const [startEvent] = requireHookCall(hookRunnerMocks.runSessionStart, "session_start"); expectFields(event, { reason: "daily", - transcriptArchived: false, }); expect(event?.nextSessionId).toBe(startEvent?.sessionId); expect(startEvent?.sessionId).toBe("daily-session"); diff --git a/src/auto-reply/reply/session-parent-fork-prepare.ts b/src/auto-reply/reply/session-parent-fork-prepare.ts index 4f8cafe25d42..ee58510ee167 100644 --- a/src/auto-reply/reply/session-parent-fork-prepare.ts +++ b/src/auto-reply/reply/session-parent-fork-prepare.ts @@ -52,14 +52,12 @@ export async function prepareReplySessionParentFork(params: { `forking from parent session: parentKey=${params.parentSessionKey} → sessionKey=${params.sessionKey} ` + `parentTokens=${decision.parentTokens ?? "unknown"}`, ); - params.warn(`forked session created: file=${fork.sessionFile}`); // The fork replaces this thread's transcript identity; recovery state from // the preseed row must not govern a later interruption of the fork. return { ...params.sessionEntry, ...buildMainSessionRecoveryClearPatch(params.sessionEntry), sessionId: fork.sessionId, - sessionFile: fork.sessionFile, forkSource: { sessionKey: params.parentSessionKey, sessionId: parentEntry.sessionId, diff --git a/src/auto-reply/reply/session-run-accounting.ts b/src/auto-reply/reply/session-run-accounting.ts index 2fe4118f3448..b26768173cec 100644 --- a/src/auto-reply/reply/session-run-accounting.ts +++ b/src/auto-reply/reply/session-run-accounting.ts @@ -16,7 +16,6 @@ type IncrementRunCompactionCountParams = Omit< lastCallUsage?: NormalizedUsage; contextTokensUsed?: number; newSessionId?: string; - newSessionFile?: string; }; function resolveNonNegativeTokenCount(value: number | undefined): number | undefined { @@ -52,6 +51,5 @@ export async function incrementRunCompactionCount( amount: params.amount, tokensAfter: tokensAfterCompaction, newSessionId: params.newSessionId, - newSessionFile: params.newSessionFile, }); } diff --git a/src/auto-reply/reply/session-updates.lifecycle.test.ts b/src/auto-reply/reply/session-updates.lifecycle.test.ts index f772b00d42db..ffd90b67918a 100644 --- a/src/auto-reply/reply/session-updates.lifecycle.test.ts +++ b/src/auto-reply/reply/session-updates.lifecycle.test.ts @@ -205,14 +205,14 @@ describe("session-updates lifecycle hooks", () => { const persisted = loadSessionEntry({ storePath, sessionKey }); expect(sessionStore[sessionKey]?.sessionId).toBe("s2"); - expect(sessionStore[sessionKey]?.sessionFile).toContain("s2.jsonl"); + expect(sessionStore[sessionKey]).not.toHaveProperty("sessionFile"); expect(sessionStore[sessionKey]?.usageFamilyKey).toBe(sessionKey); expect(sessionStore[sessionKey]?.usageFamilySessionIds).toEqual(["s1", "s2"]); expect(sessionStore[sessionKey]?.compactionCount).toBe(1); expect(sessionStore[sessionKey]?.totalTokens).toBe(123); expect(sessionStore[sessionKey]?.updatedAt).toBeGreaterThanOrEqual(entry.updatedAt); expect(persisted?.sessionId).toBe("s2"); - expect(persisted?.sessionFile).toContain("s2.jsonl"); + expect(persisted).not.toHaveProperty("sessionFile"); expect(persisted?.usageFamilyKey).toBe(sessionKey); expect(persisted?.usageFamilySessionIds).toEqual(["s1", "s2"]); expect(persisted?.compactionCount).toBe(1); diff --git a/src/auto-reply/reply/session-updates.ts b/src/auto-reply/reply/session-updates.ts index 09b6570e4407..6be342eb9040 100644 --- a/src/auto-reply/reply/session-updates.ts +++ b/src/auto-reply/reply/session-updates.ts @@ -1,13 +1,15 @@ /** Session update helpers for skill snapshots, compaction, and lifecycle hooks. */ import crypto from "node:crypto"; -import { normalizeOptionalString } from "@openclaw/normalization-core/string-coerce"; -import { resolveDefaultAgentId, resolveSessionAgentId } from "../../agents/agent-scope.js"; +import { resolveSessionAgentId } from "../../agents/agent-scope.js"; import { type ExecPolicyOverrides, resolveNodeExecEligibility, } from "../../agents/exec-defaults.js"; -import { resolveCompactionSessionFile, type SessionEntry } from "../../config/sessions.js"; +import type { SessionEntry } from "../../config/sessions.js"; +import { formatSqliteSessionFileMarker } from "../../config/sessions/legacy-sqlite-marker.js"; import { patchSessionEntry, updateSessionEntry } from "../../config/sessions/session-accessor.js"; +import { resolveSessionStorePathForScope } from "../../config/sessions/session-store-path.js"; +import { projectCanonicalSessionEntryShape } from "../../config/sessions/store-entry-shape.js"; import type { OpenClawConfig } from "../../config/types.openclaw.js"; import { forgetActiveSessionForShutdown, @@ -70,12 +72,14 @@ async function persistSessionEntryUpdate(params: { } function emitCompactionSessionLifecycleHooks(params: { + agentId?: string; cfg: OpenClawConfig; sessionKey: string; storePath?: string; previousEntry: SessionEntry; nextEntry: SessionEntry; }) { + const agentId = params.agentId ?? resolveAgentIdFromSessionKey(params.sessionKey); if (params.previousEntry.sessionId) { forgetActiveSessionForShutdown(params.previousEntry.sessionId); } @@ -85,8 +89,8 @@ function emitCompactionSessionLifecycleHooks(params: { sessionKey: params.sessionKey, sessionId: params.nextEntry.sessionId, storePath: params.storePath, - sessionFile: params.nextEntry.sessionFile, - agentId: resolveAgentIdFromSessionKey(params.sessionKey), + sessionFile: params.sessionKey, + agentId, }); } const hookRunner = getGlobalHookRunner(); @@ -95,18 +99,33 @@ function emitCompactionSessionLifecycleHooks(params: { } if (hookRunner.hasHooks("session_end")) { + const storePath = + agentId && params.storePath + ? resolveSessionStorePathForScope({ + agentId, + sessionKey: params.sessionKey, + storePath: params.storePath, + }) + : params.storePath; const transcript = resolveStableSessionEndTranscript({ sessionId: params.previousEntry.sessionId, - storePath: params.storePath, - sessionFile: params.previousEntry.sessionFile, - agentId: resolveAgentIdFromSessionKey(params.sessionKey), + storePath, + agentId, }); const payload = buildSessionEndHookPayload({ sessionId: params.previousEntry.sessionId, sessionKey: params.sessionKey, cfg: params.cfg, reason: "compaction", - sessionFile: transcript.sessionFile, + sessionFile: + transcript.sessionFile ?? + (agentId && storePath + ? formatSqliteSessionFileMarker({ + agentId, + sessionId: params.previousEntry.sessionId, + storePath, + }) + : undefined), transcriptArchived: transcript.transcriptArchived, nextSessionId: params.nextEntry.sessionId, }); @@ -288,6 +307,7 @@ export async function ensureSkillSnapshot(params: { /** Increments compaction count and persists the updated session entry. */ export async function incrementCompactionCount(params: { + agentId?: string; sessionEntry?: SessionEntry; sessionStore?: Record; sessionKey?: string; @@ -297,12 +317,11 @@ export async function incrementCompactionCount(params: { amount?: number; /** Token count after compaction - if provided, updates session token counts */ tokensAfter?: number; - /** Session id after compaction, when the runtime rotated transcripts. */ + /** Session id after compaction when a context engine changed identity. */ newSessionId?: string; - /** Session file after compaction, when the runtime rotated transcripts. */ - newSessionFile?: string; }): Promise { const { + agentId, sessionEntry, sessionStore, sessionKey, @@ -312,7 +331,6 @@ export async function incrementCompactionCount(params: { amount = 1, tokensAfter, newSessionId, - newSessionFile, } = params; if (!sessionStore || !sessionKey) { return undefined; @@ -328,28 +346,13 @@ export async function incrementCompactionCount(params: { compactionCount: nextCount, updatedAt: now, }; - const explicitNewSessionFile = normalizeOptionalString(newSessionFile); const sessionIdChanged = Boolean(newSessionId && newSessionId !== entry.sessionId); - const sessionFileChanged = Boolean( - explicitNewSessionFile && explicitNewSessionFile !== entry.sessionFile, - ); if (sessionIdChanged && newSessionId) { updates.sessionId = newSessionId; - updates.sessionFile = - explicitNewSessionFile ?? - resolveCompactionSessionFile({ - entry, - sessionKey, - storePath, - ...(cfg ? { defaultAgentId: resolveDefaultAgentId(cfg) } : {}), - newSessionId, - }); updates.usageFamilyKey = entry.usageFamilyKey ?? sessionKey; updates.usageFamilySessionIds = Array.from( new Set([...(entry.usageFamilySessionIds ?? []), entry.sessionId, newSessionId]), ); - } else if (sessionFileChanged && explicitNewSessionFile) { - updates.sessionFile = explicitNewSessionFile; } // If tokensAfter is provided, update the cached token counts to reflect post-compaction state const tokensAfterCompaction = resolveNonNegativeTokenCount(tokensAfter); @@ -364,24 +367,27 @@ export async function incrementCompactionCount(params: { } else if (incrementBy > 0) { updates.totalTokensFresh = false; } - const nextEntry = { - ...entry, - ...updates, - }; + const nextEntry = projectCanonicalSessionEntryShape({ ...entry, ...updates }); sessionStore[sessionKey] = nextEntry; - if (storePath) { - const persistedEntry = await patchSessionEntry({ storePath, sessionKey }, () => updates, { - fallbackEntry: nextEntry, - }); + const effectiveStorePath = storePath + ? resolveSessionStorePathForScope({ agentId, sessionKey, storePath }) + : undefined; + if (effectiveStorePath) { + const persistedEntry = await patchSessionEntry( + { ...(agentId ? { agentId } : {}), storePath: effectiveStorePath, sessionKey }, + () => updates, + { fallbackEntry: nextEntry }, + ); if (persistedEntry) { sessionStore[sessionKey] = persistedEntry; } } - if ((sessionIdChanged || sessionFileChanged) && cfg) { + if (sessionIdChanged && cfg) { emitCompactionSessionLifecycleHooks({ + agentId, cfg, sessionKey, - storePath, + storePath: effectiveStorePath, previousEntry: entry, nextEntry: sessionStore[sessionKey], }); diff --git a/src/auto-reply/reply/session.heartbeat-no-reset.test.ts b/src/auto-reply/reply/session.heartbeat-no-reset.test.ts index d3bd344d19fb..8ddc65e8ee54 100644 --- a/src/auto-reply/reply/session.heartbeat-no-reset.test.ts +++ b/src/auto-reply/reply/session.heartbeat-no-reset.test.ts @@ -225,92 +225,6 @@ describe("initSessionState - heartbeat should not trigger session reset", () => expect(userResult.sessionId).toBe("daily-session-id"); }); - it("resets legacy daily sessions using the JSONL header even when updatedAt is fresh", async () => { - const now = Date.now(); - const staleTime = now - 25 * 60 * 60 * 1000; - const sessionFile = path.join(tempDir, "legacy-daily-session.jsonl"); - await fs.writeFile( - sessionFile, - `${JSON.stringify({ - type: "session", - version: 3, - id: "legacy-daily-session", - timestamp: new Date(staleTime).toISOString(), - cwd: tempDir, - })}\n`, - "utf8", - ); - await saveExistingSession("legacy-daily-session", now, { - sessionFile, - lastInteractionAt: staleTime, - }); - - const cfg = createBaseConfig(); - cfg.session!.reset = { - mode: "daily", - atHour: 4, - }; - - const result = await initSessionState({ - ctx: createBaseCtx({ - Provider: "quietchat", - Body: "real user message", - }), - cfg, - commandAuthorized: true, - }); - - expect(result.isNewSession).toBe(true); - expect(result.sessionId).toBe("legacy-daily-session"); - }); - - it("does not let heartbeat keep a legacy idle session fresh without lastInteractionAt", async () => { - const now = Date.now(); - const staleTime = now - 10 * 60 * 1000; - const sessionFile = path.join(tempDir, "legacy-idle-session.jsonl"); - await fs.writeFile( - sessionFile, - `${JSON.stringify({ - type: "session", - version: 3, - id: "legacy-idle-session", - timestamp: new Date(staleTime).toISOString(), - cwd: tempDir, - })}\n`, - "utf8", - ); - await saveExistingSession("legacy-idle-session", now, { - sessionFile, - }); - - const cfg = createBaseConfig(); - const heartbeatResult = await initSessionState({ - ctx: createBaseCtx({ - Provider: "heartbeat", - Body: "HEARTBEAT_OK", - }), - cfg, - commandAuthorized: true, - }); - - expect(heartbeatResult.isNewSession).toBe(false); - expect(heartbeatResult.sessionId).toBe("legacy-idle-session"); - - expect(expectPersistedSession().lastInteractionAt).toBeUndefined(); - - const userResult = await initSessionState({ - ctx: createBaseCtx({ - Provider: "quietchat", - Body: "real user message", - }), - cfg, - commandAuthorized: true, - }); - - expect(userResult.isNewSession).toBe(true); - expect(userResult.sessionId).toBe("legacy-idle-session"); - }); - it("should handle cron-event provider same as heartbeat (no reset)", async () => { // Setup: Create a stale session const now = Date.now(); diff --git a/src/auto-reply/reply/session.test.ts b/src/auto-reply/reply/session.test.ts index fe4fc159be70..f43c329e0f26 100644 --- a/src/auto-reply/reply/session.test.ts +++ b/src/auto-reply/reply/session.test.ts @@ -15,7 +15,6 @@ import { loadSessionEntry, loadTranscriptEvents, } from "../../config/sessions/session-accessor.js"; -import { formatSqliteSessionFileMarker } from "../../config/sessions/sqlite-marker.js"; import { runExclusiveSessionStoreWrite } from "../../config/sessions/store-writer.js"; import { formatZonedTimestamp } from "../../infra/format-time/format-datetime.ts"; import { @@ -69,7 +68,7 @@ const browserMaintenanceMocks = vi.hoisted(() => ({ type ForkSessionParamsForTest = { parentEntry: SessionEntry; - storePath: string; + sessionKey: string; }; vi.mock("./session-fork.js", () => ({ @@ -284,7 +283,7 @@ function expectEntryFields( label?: string, ): void { for (const [key, value] of Object.entries(expected)) { - expect((entry as Record)[key], label ?? key).toEqual(value); + expect((entry as unknown as Record)[key], label ?? key).toEqual(value); } } @@ -530,27 +529,9 @@ beforeEach(() => { }); sessionForkMocks.forkSessionFromParent .mockReset() - .mockImplementation(async ({ parentEntry, storePath }: ForkSessionParamsForTest) => { - if (!parentEntry.sessionFile) { - return null; - } - const sessionsDir = path.dirname(storePath); - await fs.mkdir(sessionsDir, { recursive: true }); + .mockImplementation(async ({ sessionKey }: ForkSessionParamsForTest) => { const sessionId = `forked-session-${++sessionForkMocks.nextSessionId}`; - const sessionFile = path.join(sessionsDir, `${sessionId}.jsonl`); - await fs.writeFile( - sessionFile, - `${JSON.stringify({ - type: "session", - version: 3, - id: sessionId, - timestamp: new Date().toISOString(), - cwd: process.cwd(), - parentSession: parentEntry.sessionFile, - })}\n`, - "utf-8", - ); - return { sessionId, sessionFile: await fs.realpath(sessionFile) }; + return { sessionId, sessionFile: sessionKey }; }); }); afterEach(async () => { @@ -700,63 +681,25 @@ describe("initSessionState thread forking", () => { sessionKey: parentSessionKey, sessionId: parentSessionId, }); - expect(result.sessionEntry.sessionFile).toBe( - formatSqliteSessionFileMarker({ - agentId: "main", - sessionId: result.sessionEntry.sessionId, - storePath, - }), - ); + expect(result.sessionEntry).not.toHaveProperty("sessionFile"); const forkCall = requireMockCallArg(sessionForkMocks.forkSessionFromParent, "fork session"); expect(forkCall.parentEntry).toMatchObject({ sessionId: parentSessionId, - sessionFile: parentSessionFile, }); + expect(forkCall.parentEntry).not.toHaveProperty("sessionFile"); warn.mockRestore(); }); it("forks from parent when thread session key already exists but was not forked yet", async () => { const warn = vi.spyOn(console, "warn").mockImplementation(() => {}); const root = await makeCaseDir("openclaw-thread-session-existing-"); - const sessionsDir = path.join(root, "sessions"); - await fs.mkdir(sessionsDir); - const parentSessionId = "parent-session"; - const parentSessionFile = path.join(sessionsDir, "parent.jsonl"); - const header = { - type: "session", - version: 3, - id: parentSessionId, - timestamp: new Date().toISOString(), - cwd: process.cwd(), - }; - const message = { - type: "message", - id: "m1", - parentId: null, - timestamp: new Date().toISOString(), - message: { role: "user", content: "Parent prompt" }, - }; - const assistantMessage = { - type: "message", - id: "m2", - parentId: "m1", - timestamp: new Date().toISOString(), - message: { role: "assistant", content: "Parent reply" }, - }; - await fs.writeFile( - parentSessionFile, - `${JSON.stringify(header)}\n${JSON.stringify(message)}\n${JSON.stringify(assistantMessage)}\n`, - "utf-8", - ); - const storePath = path.join(root, "sessions.json"); const parentSessionKey = "agent:main:slack:channel:c1"; const threadSessionKey = "agent:main:slack:channel:c1:thread:123"; await writeSessionStoreFast(storePath, { [parentSessionKey]: { sessionId: parentSessionId, - sessionFile: parentSessionFile, updatedAt: Date.now(), }, [threadSessionKey]: { @@ -774,6 +717,14 @@ describe("initSessionState thread forking", () => { }, }, }); + await appendTranscriptMessage( + { agentId: "main", sessionId: parentSessionId, sessionKey: parentSessionKey, storePath }, + { message: { role: "user", content: "Parent prompt" } }, + ); + await appendTranscriptMessage( + { agentId: "main", sessionId: parentSessionId, sessionKey: parentSessionKey, storePath }, + { message: { role: "assistant", content: "Parent reply" } }, + ); const cfg = { session: { store: storePath }, @@ -972,13 +923,7 @@ describe("initSessionState thread forking", () => { }); expect(result.sessionEntry.lastThreadId).toBe(456); - expect(result.sessionEntry.sessionFile).toBe( - formatSqliteSessionFileMarker({ - agentId: "main", - sessionId: result.sessionEntry.sessionId, - storePath, - }), - ); + expect(result.sessionEntry).not.toHaveProperty("sessionFile"); }); it("records topic-specific SQLite session identity from SessionKey", async () => { @@ -1001,13 +946,7 @@ describe("initSessionState thread forking", () => { }); expect(result.sessionKey).toBe("agent:main:telegram:group:123:topic:456"); - expect(result.sessionEntry.sessionFile).toBe( - formatSqliteSessionFileMarker({ - agentId: "main", - sessionId: result.sessionEntry.sessionId, - storePath, - }), - ); + expect(result.sessionEntry).not.toHaveProperty("sessionFile"); } finally { resetPluginRuntimeStateForTest(); } @@ -1486,7 +1425,7 @@ describe("initSessionState RawBody", () => { if ("absent" in scenario && Array.isArray(scenario.absent)) { for (const field of scenario.absent) { expect( - (result.sessionEntry as Record)[field], + (result.sessionEntry as unknown as Record)[field], scenario.name, ).toBeUndefined(); } @@ -1797,13 +1736,7 @@ describe("initSessionState RawBody", () => { }); expect(result.sessionEntry.sessionId).toBe(sessionId); - expect(result.sessionEntry.sessionFile).toBe( - formatSqliteSessionFileMarker({ - agentId: "worker1", - sessionId, - storePath, - }), - ); + expect(result.sessionEntry).not.toHaveProperty("sessionFile"); expect(result.storePath).toBe(storePath); }); }); diff --git a/src/auto-reply/reply/session.ts b/src/auto-reply/reply/session.ts index 8e4fe7de26c4..3b467b6dbcbf 100644 --- a/src/auto-reply/reply/session.ts +++ b/src/auto-reply/reply/session.ts @@ -678,6 +678,7 @@ async function initSessionStateAttemptLocked( const lifecycleTimestamps = resolveSessionLifecycleTimestamps({ entry, agentId, + sessionKey, storePath, }); const entryFreshness = entry @@ -1175,7 +1176,7 @@ async function initSessionStateAttemptLocked( agentId, sessionId: previousSessionEntry.sessionId, sessionKey, - sessionFile: previousSessionEntry.sessionFile, + sessionFile: sessionKey, reason: previousSessionEndReason ?? "unknown", }); // Direct-message browser tabs use a peer-scoped runtime identity even when @@ -1239,7 +1240,7 @@ async function initSessionStateAttemptLocked( sessionKey, sessionId: effectiveSessionId, storePath, - sessionFile: sessionEntry?.sessionFile, + sessionFile: sessionKey, agentId, }); } diff --git a/src/auto-reply/status.test.ts b/src/auto-reply/status.test.ts index c3cc6715b615..a0b3111eb1be 100644 --- a/src/auto-reply/status.test.ts +++ b/src/auto-reply/status.test.ts @@ -1,6 +1,4 @@ /** Tests auto-reply status message formatting. */ -import fs from "node:fs"; -import path from "node:path"; import { withTempHome } from "openclaw/plugin-sdk/test-env"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import { normalizeTestText } from "../../test/helpers/normalize-text.js"; @@ -10,6 +8,11 @@ import { providerContextTokenCacheKey, } from "../agents/context-cache.js"; import type { OpenClawConfig } from "../config/config.js"; +import { resolveStorePath } from "../config/sessions/paths.js"; +import { + appendTranscriptMessageSync, + replaceSessionEntrySync, +} from "../config/sessions/session-accessor.js"; import { applyModelOverrideToSessionEntry } from "../sessions/model-overrides.js"; import { buildStatusMessage as buildStatusMessageRaw, @@ -1982,29 +1985,21 @@ describe("buildStatusMessage", () => { totalTokens: number; }; }) { - const logPath = path.join( - params.dir, - ".openclaw", - "agents", - params.agentId, - "sessions", - `${params.sessionId}.jsonl`, - ); - fs.mkdirSync(path.dirname(logPath), { recursive: true }); - fs.writeFileSync( - logPath, - [ - JSON.stringify({ - type: "message", - message: { - role: "assistant", - model: params.model ?? "claude-opus-4-6", - usage: params.usage, - }, - }), - ].join("\n"), - "utf-8", - ); + void params.dir; + const scope = { + agentId: params.agentId, + sessionId: params.sessionId, + sessionKey: `agent:${params.agentId}:main`, + storePath: resolveStorePath(undefined, { agentId: params.agentId }), + }; + replaceSessionEntrySync(scope, { sessionId: params.sessionId, updatedAt: Date.now() }); + appendTranscriptMessageSync(scope, { + message: { + role: "assistant", + model: params.model ?? "claude-opus-4-6", + usage: params.usage, + }, + }); } const baselineTranscriptUsage = { @@ -2250,51 +2245,28 @@ describe("buildStatusMessage", () => { await withTempHome( async (dir) => { const sessionId = "sess-cache-delivery-mirror"; - const logPath = path.join( - dir, - ".openclaw", - "agents", - "main", - "sessions", - `${sessionId}.jsonl`, - ); - fs.mkdirSync(path.dirname(logPath), { recursive: true }); - fs.writeFileSync( - logPath, - [ - JSON.stringify({ type: "session", version: 1, id: sessionId }), - JSON.stringify({ - type: "message", - message: { - role: "assistant", - provider: "anthropic", - model: "claude-opus-4-6", - usage: { - input: 1, - output: 2, - cacheRead: 1000, - cacheWrite: 0, - totalTokens: 1003, - }, + writeBaselineTranscriptUsageLog({ dir, agentId: "main", sessionId }); + appendTranscriptMessageSync( + { + agentId: "main", + sessionId, + sessionKey: "agent:main:main", + storePath: resolveStorePath(undefined, { agentId: "main" }), + }, + { + message: { + role: "assistant", + provider: "openclaw", + model: "delivery-mirror", + usage: { + input: 0, + output: 0, + cacheRead: 0, + cacheWrite: 0, + totalTokens: 0, }, - }), - JSON.stringify({ - type: "message", - message: { - role: "assistant", - provider: "openclaw", - model: "delivery-mirror", - usage: { - input: 0, - output: 0, - cacheRead: 0, - cacheWrite: 0, - totalTokens: 0, - }, - }, - }), - ].join("\n"), - "utf-8", + }, + }, ); const text = buildTranscriptStatusText({ diff --git a/src/cli/help-exit.process.test.ts b/src/cli/help-exit.process.test.ts index b1066baab1c0..697c1bb1fb08 100644 --- a/src/cli/help-exit.process.test.ts +++ b/src/cli/help-exit.process.test.ts @@ -13,8 +13,8 @@ import { registerSubCliByName } from "./program/register.subclis.js"; const execFileAsync = promisify(execFile); const tempDirs = useAutoCleanupTempDirTracker(afterEach); -// Fork CI uses shared hosted runners where cold TSX startup can exceed 30 seconds. -const CHILD_PROCESS_TIMEOUT_MS = 45_000; +// Fork CI uses shared hosted runners where cold TSX startup can exceed 45 seconds. +const CHILD_PROCESS_TIMEOUT_MS = 75_000; const LAZY_GROUP_HELP_CASES = [ { group: "backup", usageCommand: "backup", registry: "core" }, { group: "capability", usageCommand: "infer|capability", registry: "subcli" }, diff --git a/src/commands/agent.session.test.ts b/src/commands/agent.session.test.ts index b30588eaf2ac..52b6bd79d305 100644 --- a/src/commands/agent.session.test.ts +++ b/src/commands/agent.session.test.ts @@ -3,7 +3,6 @@ import path from "node:path"; import { withTempHome as withTempHomeBase } from "openclaw/plugin-sdk/test-env"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import { resolveAgentDir, resolveSessionAgentId } from "../agents/agent-scope.js"; -import { updateSessionStoreAfterAgentRun } from "../agents/command/session-store.js"; import { resolveSession } from "../agents/command/session.js"; import { appendTranscriptEvent, @@ -266,52 +265,6 @@ describe("agent session resolution", () => { expect(resolution.sessionEntry?.cliSessionBindings).toBeUndefined(); expect(resolution.sessionEntry?.cliSessionIds).toBeUndefined(); expect(resolution.sessionEntry?.claudeCliSessionId).toBeUndefined(); - - const sessionStore = { - [scenario.sessionKey]: resolution.sessionEntry!, - }; - await resolveSessionTranscriptFile({ - sessionId: resolution.sessionId, - sessionKey: scenario.sessionKey, - sessionEntry: resolution.sessionEntry, - sessionStore, - storePath: resolution.storePath, - agentId: "main", - }); - await updateSessionStoreAfterAgentRun({ - cfg, - sessionId: resolution.sessionId, - sessionKey: scenario.sessionKey, - storePath: resolution.storePath, - sessionStore, - defaultProvider: "openai", - defaultModel: "gpt-5.5", - result: { - payloads: [], - meta: { - aborted: false, - agentMeta: { - provider: "openai", - model: "gpt-5.5", - }, - }, - } as never, - }); - const persisted = loadSessionEntry({ - sessionKey: scenario.sessionKey, - storePath: resolution.storePath, - }); - expect(persisted?.sessionId).toBe(resolution.sessionId); - expect(persisted?.sessionFile).not.toBe(sessionFile); - expect(persisted?.status).toBeUndefined(); - expect(persisted?.startedAt).toBeUndefined(); - expect(persisted?.endedAt).toBeUndefined(); - expect(persisted?.runtimeMs).toBeUndefined(); - expect(persisted?.cliSessionBindings).toBeUndefined(); - expect(persisted?.cliSessionIds).toBeUndefined(); - expect(persisted?.claudeCliSessionId).toBeUndefined(); - expect(persisted?.sessionStartedAt).toBeGreaterThan(registryUpdatedAt); - expect(persisted?.lastInteractionAt).toBeGreaterThan(registryUpdatedAt); }); } }); @@ -349,7 +302,7 @@ describe("agent session resolution", () => { expect(resolution.sessionKey).toBe("agent:main:main"); expect(resolution.sessionId).toBe(sessionId); expect(resolution.isNewSession).toBe(false); - expect(resolution.sessionEntry?.sessionFile).toBe(sessionFile); + expect(resolution.sessionEntry).not.toHaveProperty("sessionFile"); expect(resolution.sessionEntry?.status).toBe("done"); expect(resolution.sessionEntry?.startedAt).toBe(registryUpdatedAt - 1_000); expect(resolution.sessionEntry?.endedAt).toBe(registryUpdatedAt - 100); @@ -366,16 +319,26 @@ describe("agent session resolution", () => { storePath: resolution.storePath, agentId: "main", }); - expect(resolvedTranscript.sessionFile).toBe( - `sqlite:main:${sessionId}:${resolution.storePath}`, - ); + expect(resolvedTranscript.sessionFile).toBe(resolution.sessionKey); + await expect( + resolveSessionTranscriptFile({ + sessionId: resolution.sessionId, + sessionKey: resolution.sessionKey, + sessionEntry: undefined, + sessionStore: resolution.sessionStore, + storePath: resolution.storePath, + agentId: "main", + }), + ).resolves.toMatchObject({ + sessionEntry: expect.objectContaining({ sessionId }), + }); const persisted = loadSessionEntry({ sessionKey: resolution.sessionKey, storePath: resolution.storePath, }); expect(persisted?.sessionId).toBe(sessionId); - expect(persisted?.sessionFile).toBe(resolvedTranscript.sessionFile); + expect(persisted).not.toHaveProperty("sessionFile"); expect(persisted?.status).toBe("done"); expect(persisted?.startedAt).toBe(registryUpdatedAt - 1_000); expect(persisted?.endedAt).toBe(registryUpdatedAt - 100); diff --git a/src/commands/agent.test.ts b/src/commands/agent.test.ts index ebf1f812ae46..7c72bb4d6ff6 100644 --- a/src/commands/agent.test.ts +++ b/src/commands/agent.test.ts @@ -19,12 +19,12 @@ import { isAgentRunRestartAbortReason } from "../agents/run-termination.js"; import { ensureAgentWorkspace } from "../agents/workspace.js"; import { BASE_THINKING_LEVELS } from "../auto-reply/thinking.shared.js"; import * as runtimeSnapshotModule from "../config/runtime-snapshot.js"; +import { parseSqliteSessionFileMarker } from "../config/sessions/legacy-sqlite-marker.js"; import { listSessionEntries, loadSessionEntry, replaceSessionEntry, } from "../config/sessions/session-accessor.js"; -import { parseSqliteSessionFileMarker } from "../config/sessions/sqlite-marker.js"; import { clearSessionStoreCacheForTest } from "../config/sessions/store-writer-state.js"; import type { InternalSessionEntry as SessionEntry } from "../config/sessions/types.js"; import type { OpenClawConfig } from "../config/types.openclaw.js"; diff --git a/src/commands/doctor-heartbeat-session-target.test.ts b/src/commands/doctor-heartbeat-session-target.test.ts index d95afbba0f18..135bc391782f 100644 --- a/src/commands/doctor-heartbeat-session-target.test.ts +++ b/src/commands/doctor-heartbeat-session-target.test.ts @@ -72,7 +72,7 @@ describe("describeHeartbeatSessionTargetIssues", () => { const cfg = cfgWithSession("agent:ops:main"); writeStore(cfg, { "agent:ops:work": { - sessionId: "agent:ops:work", + sessionId: "work-session", updatedAt: Date.now(), }, }); diff --git a/src/commands/doctor-session-sqlite-readers.ts b/src/commands/doctor-session-sqlite-readers.ts index d51dd606870a..be18c7a8ac5d 100644 --- a/src/commands/doctor-session-sqlite-readers.ts +++ b/src/commands/doctor-session-sqlite-readers.ts @@ -301,7 +301,7 @@ function parseSqliteSessionEntry(entryJson: string): SessionEntry | undefined { try { const parsed = JSON.parse(entryJson) as unknown; return isRecord(parsed) && typeof parsed.sessionId === "string" - ? (parsed as SessionEntry) + ? (parsed as unknown as SessionEntry) : undefined; } catch { return undefined; diff --git a/src/commands/doctor-session-sqlite.test.ts b/src/commands/doctor-session-sqlite.test.ts index 944aee4aa6fd..103bad2d7bd6 100644 --- a/src/commands/doctor-session-sqlite.test.ts +++ b/src/commands/doctor-session-sqlite.test.ts @@ -532,7 +532,6 @@ describe("runDoctorSessionSqlite", () => { agentHarnessId: "codex", lifecycleRevision: "rev-1", sessionId: "session-1", - sessionFile: expect.stringMatching(/^sqlite:/), }); }); @@ -614,8 +613,8 @@ describe("runDoctorSessionSqlite", () => { agentId: "main", sessionKey: "agent:main:main", storePath: store.storePath, - })?.entry.sessionFile, - ).toContain("sqlite:main:session-1:"); + })?.entry, + ).not.toHaveProperty("sessionFile"); expect( loadSqliteTranscriptEventsSync({ agentId: "main", @@ -1015,8 +1014,8 @@ describe("runDoctorSessionSqlite", () => { agentId: "main", sessionKey: "agent:main:main", storePath: store.storePath, - })?.entry.sessionFile, - ).toContain("sqlite:main:session-1:"); + })?.entry, + ).not.toHaveProperty("sessionFile"); }); it("validates missing SQLite rows without creating the agent database", async () => { @@ -1849,7 +1848,9 @@ describe("runDoctorSessionSqlite", () => { }); expect(recover.mode).toBe("recover"); - expect(recover.totals.issues).toBe(0); + expect(recover.targets[0]?.issues).toMatchObject([ + { code: "active_sqlite_transcript_jsonl", sessionKey: "agent:main:main" }, + ]); expect(recover.migrationRun?.manifestPath).toBe(manifestPath); expect(recover.targets[0]?.restore?.manifestPaths).toEqual([manifestPath]); expect(recover.targets[0]?.restore?.restoredFiles).toEqual( @@ -2191,8 +2192,11 @@ describe("runDoctorSessionSqlite", () => { archivedLegacyStoreFiles: 0, archivedTranscriptFiles: 0, importedEntries: 1, - issues: 0, + issues: 1, }); + expect(report.targets[0]?.issues).toMatchObject([ + { code: "active_sqlite_transcript_jsonl", sessionKey: "agent:main:main" }, + ]); expect(fs.existsSync(storePath)).toBe(true); expect(fs.existsSync(mainTranscriptPath)).toBe(true); expect(fs.existsSync(workTranscriptPath)).toBe(true); diff --git a/src/commands/doctor-session-sqlite.ts b/src/commands/doctor-session-sqlite.ts index 200505ea6978..c2e32638cda0 100644 --- a/src/commands/doctor-session-sqlite.ts +++ b/src/commands/doctor-session-sqlite.ts @@ -4,13 +4,13 @@ import { isRecord } from "@openclaw/normalization-core/record-coerce"; import { tryResolveDefaultAgentId } from "../agents/agent-scope.js"; import { getRuntimeConfig } from "../config/config.js"; import { resolveStateDir } from "../config/paths.js"; +import { parseSqliteSessionFileMarker } from "../config/sessions/legacy-sqlite-marker.js"; import { resolveSessionFilePath } from "../config/sessions/paths.js"; import { importSqliteSessionRows, loadExactSqliteSessionEntry, } from "../config/sessions/session-accessor.sqlite.js"; import { resolveUnsuffixedSqliteTargetFromSessionStorePath } from "../config/sessions/session-sqlite-target.js"; -import { parseSqliteSessionFileMarker } from "../config/sessions/sqlite-marker.js"; import { normalizeStoreSessionKey } from "../config/sessions/store-entry.js"; import { resolveAgentSessionStoreTargetsSync, @@ -529,7 +529,8 @@ function resolveLegacyTranscriptPath( target: SessionStoreTarget, entry: SessionEntry, ): string | undefined { - if (parseSqliteSessionFileMarker(entry.sessionFile)) { + const legacySessionFile = (entry as { sessionFile?: string }).sessionFile; + if (parseSqliteSessionFileMarker(legacySessionFile)) { return undefined; } const defaultPath = resolveSessionFilePath(entry.sessionId, entry, { @@ -539,7 +540,7 @@ function resolveLegacyTranscriptPath( if (fs.existsSync(defaultPath)) { return defaultPath; } - return entry.sessionFile?.trim() ? defaultPath : undefined; + return legacySessionFile?.trim() ? defaultPath : undefined; } function countLegacyTranscript( diff --git a/src/commands/doctor-session-transcripts.test.ts b/src/commands/doctor-session-transcripts.test.ts index db90b952ee3b..bd5ce5d16a55 100644 --- a/src/commands/doctor-session-transcripts.test.ts +++ b/src/commands/doctor-session-transcripts.test.ts @@ -2,6 +2,7 @@ import fs from "node:fs/promises"; import os from "node:os"; import path from "node:path"; +import "../../test/helpers/session-manager-file-compat.js"; import { expectDefined } from "@openclaw/normalization-core"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import { SessionManager } from "../agents/sessions/session-manager.js"; @@ -507,7 +508,7 @@ describe("doctor session transcript repair", () => { expect(repairedRecords.find((entry) => entry.id === "plugin-metadata")).toMatchObject({ parentId: "active-assistant", }); - const reopened = SessionManager.open(filePath, path.dirname(filePath)); + const reopened = SessionManager.openFile(filePath, path.dirname(filePath)); reopened.appendMessage({ role: "user", content: "continued", timestamp: Date.now() }); const records = (await fs.readFile(filePath, "utf-8")) .trim() @@ -573,7 +574,7 @@ describe("doctor session transcript repair", () => { expect(repaired).toContain("answer"); expect(repaired).toContain('"id":"append-root"'); expect(repaired).not.toContain("stale"); - const reopened = SessionManager.open(filePath, path.dirname(filePath)); + const reopened = SessionManager.openFile(filePath, path.dirname(filePath)); expect(reopened.buildSessionContext().messages).toHaveLength(3); reopened.appendMessage({ role: "user", content: "continued", timestamp: Date.now() }); const records = (await fs.readFile(filePath, "utf-8")) @@ -632,7 +633,7 @@ describe("doctor session transcript repair", () => { const result = await repairBrokenSessionTranscriptFile({ filePath, shouldRepair: true }); expect(result.repaired).toBe(true); - const reopened = SessionManager.open(filePath, path.dirname(filePath)); + const reopened = SessionManager.openFile(filePath, path.dirname(filePath)); expect(reopened.buildSessionContext().messages).toHaveLength(3); reopened.appendMessage({ role: "user", content: "new root", timestamp: Date.now() }); const records = (await fs.readFile(filePath, "utf-8")) diff --git a/src/commands/doctor-state-integrity.test.ts b/src/commands/doctor-state-integrity.test.ts index 2dc4957fa088..fddd53ebd72a 100644 --- a/src/commands/doctor-state-integrity.test.ts +++ b/src/commands/doctor-state-integrity.test.ts @@ -613,6 +613,23 @@ describe("doctor state integrity oauth dir checks", () => { ); }); + it("does not require JSONL files for canonical SQLite session rows", async () => { + const cfg: OpenClawConfig = {}; + setupSessionState(cfg, process.env, process.env.HOME ?? ""); + const storePath = resolveStorePath(cfg.session?.store, { agentId: "main" }); + await upsertSessionEntry( + { agentId: "main", sessionKey: "agent:main:sqlite-only", storePath }, + { sessionId: "sqlite-only-session", updatedAt: Date.now() }, + ); + + await noteStateIntegrity(cfg, { + confirmRuntimeRepair: vi.fn(async () => false), + note: noteMock, + }); + + expect(stateIntegrityText()).not.toContain("recent sessions are missing transcripts"); + }); + it("does not auto-archive orphan transcripts from non-interactive repair mode", async () => { const cfg: OpenClawConfig = {}; setupSessionState(cfg, process.env, process.env.HOME ?? ""); diff --git a/src/commands/doctor-state-integrity.ts b/src/commands/doctor-state-integrity.ts index ec8743fe3621..faf3aa158720 100644 --- a/src/commands/doctor-state-integrity.ts +++ b/src/commands/doctor-state-integrity.ts @@ -23,6 +23,7 @@ import { formatSessionArchiveTimestamp, isPrimarySessionTranscriptFileName, } from "../config/sessions/artifacts.js"; +import { parseSqliteSessionFileMarker } from "../config/sessions/legacy-sqlite-marker.js"; import { resolveMainSessionKey } from "../config/sessions/main-session.js"; import { resolveSessionFilePath, @@ -34,7 +35,6 @@ import { applySessionEntryReplacements, listSessionEntriesReadOnly, } from "../config/sessions/session-accessor.js"; -import { parseSqliteSessionFileMarker } from "../config/sessions/sqlite-marker.js"; import type { SessionEntry } from "../config/sessions/types.js"; import type { OpenClawConfig } from "../config/types.openclaw.js"; import type { HealthFinding, HealthRepairEffect } from "../flows/health-checks.js"; @@ -1349,12 +1349,16 @@ export async function noteStateIntegrity( }) .slice(0, 5); const recentTranscriptCandidates = recent.filter(([key]) => !isSlashRoutingSessionKey(key)); - const missing = recentTranscriptCandidates.filter(([, entry]) => { + const missing = recentTranscriptCandidates.filter(([key, entry]) => { + if (sqliteSessionKeys.has(key)) { + return false; + } const sessionId = entry.sessionId; if (!sessionId) { return false; } - if (parseSqliteSessionFileMarker(entry.sessionFile)) { + const legacySessionFile = (entry as SessionEntry & { sessionFile?: string }).sessionFile; + if (parseSqliteSessionFileMarker(legacySessionFile)) { return false; } const transcriptPath = resolveSessionFilePath(sessionId, entry, sessionPathOpts); diff --git a/src/commands/doctor-state-migrations.test.ts b/src/commands/doctor-state-migrations.test.ts index 9227a09d7842..fba42483ba3b 100644 --- a/src/commands/doctor-state-migrations.test.ts +++ b/src/commands/doctor-state-migrations.test.ts @@ -805,7 +805,7 @@ describe("doctor legacy state migrations", () => { const targetDir = path.join(root, "agents", "main", "sessions"); const store = JSON.parse( fs.readFileSync(path.join(targetDir, "sessions.json"), "utf-8"), - ) as Record; + ) as Record; migratedLegacySessionsCase = { result, targetDir, legacySessionsDir, store }; }); @@ -820,8 +820,8 @@ describe("doctor legacy state migrations", () => { expect(store["agent:main:main"]?.sessionId).toBe("b"); expect(store["agent:main:+1555"]?.sessionId).toBe("a"); expect(store["agent:main:+1666"]?.sessionId).toBe("b"); - expect(store["agent:main:+1555"]?.sessionFile).toBe(path.join(targetDir, "a.jsonl")); - expect(store["agent:main:+1666"]?.sessionFile).toBe(path.join(targetDir, "b.jsonl")); + expect(store["agent:main:+1555"]).not.toHaveProperty("sessionFile"); + expect(store["agent:main:+1666"]).not.toHaveProperty("sessionFile"); expect(store["+1555"]).toBeUndefined(); expect(store["+1666"]).toBeUndefined(); expect(store["agent:main:slack:channel:c123"]?.sessionId).toBe("c"); @@ -829,7 +829,7 @@ describe("doctor legacy state migrations", () => { expect(store["agent:main:subagent:xyz"]?.sessionId).toBe("e"); }); - it("repairs stale transcript paths left by a shipped legacy migration", async () => { + it("removes stale transcript paths left by a shipped legacy migration", async () => { const root = await makeTempRoot(); const legacyDir = path.join(root, "sessions"); const targetDir = path.join(root, "agents", "main", "sessions"); @@ -860,8 +860,8 @@ describe("doctor legacy state migrations", () => { expect(result.changes).toContain("Repaired migrated session transcript paths"); const store = JSON.parse( fs.readFileSync(path.join(targetDir, "sessions.json"), "utf8"), - ) as Record; - expect(store["agent:main:main"]?.sessionFile).toBe(path.join(targetDir, "legacy.jsonl")); + ) as Record; + expect(store["agent:main:main"]).not.toHaveProperty("sessionFile"); }); it("does not bind stale session metadata to a colliding target transcript", async () => { diff --git a/src/commands/export-trajectory.test.ts b/src/commands/export-trajectory.test.ts index ee48d90784a4..e5f4661122a8 100644 --- a/src/commands/export-trajectory.test.ts +++ b/src/commands/export-trajectory.test.ts @@ -229,12 +229,10 @@ describe("exportTrajectoryCommand", () => { expect(runtime.exit).toHaveBeenCalledWith(1); }); - it("exports SQLite marker sessions without probing a transcript JSONL file", async () => { + it("exports SQLite sessions without probing a transcript JSONL file", async () => { const runtime = createRuntime(); - const sessionFile = "sqlite:main:session-1:/tmp/openclaw/sessions.json"; mocks.loadSessionEntryReadOnly.mockReturnValue({ sessionId: "session-1", - sessionFile, updatedAt: 1, }); @@ -248,9 +246,14 @@ describe("exportTrajectoryCommand", () => { expect(mocks.exportTrajectoryForCommand).toHaveBeenCalledWith({ outputPath: undefined, - sessionFile, sessionId: "session-1", sessionKey: "agent:main:telegram:direct:123", + sessionTarget: { + agentId: "main", + sessionId: "session-1", + sessionKey: "agent:main:telegram:direct:123", + storePath: "/tmp/openclaw/sessions.json", + }, workspaceDir: "/tmp/workspace", }); expect(runtime.error).not.toHaveBeenCalled(); diff --git a/src/commands/export-trajectory.ts b/src/commands/export-trajectory.ts index 092c2b6e1ead..53fdf5c12690 100644 --- a/src/commands/export-trajectory.ts +++ b/src/commands/export-trajectory.ts @@ -7,9 +7,7 @@ import { loadSessionEntryReadOnly, resolveSessionTranscriptReadTarget, } from "../config/sessions/session-accessor.js"; -import { parseSqliteSessionFileMarker } from "../config/sessions/sqlite-marker.js"; import { formatErrorMessage } from "../infra/errors.js"; -import { pathExists } from "../infra/fs-safe.js"; import { resolveAgentIdFromSessionKey } from "../routing/session-key.js"; import { type RuntimeEnv, writeRuntimeJson } from "../runtime.js"; import { @@ -136,33 +134,30 @@ export async function exportTrajectoryCommand( return; } - let sessionFile: string; + let sessionTarget: ReturnType; try { - sessionFile = resolveSessionTranscriptReadTarget({ + sessionTarget = resolveSessionTranscriptReadTarget({ agentId: targetAgentId, sessionEntry: entry, sessionId: entry.sessionId, sessionKey, storePath, - }).sessionFile; + }); } catch (error) { runtime.error(`Failed to resolve session file: ${formatErrorMessage(error)}`); runtime.exit(1); return; } - if (!parseSqliteSessionFileMarker(sessionFile) && !(await pathExists(sessionFile))) { - runtime.error( - `Session file not found for ${sessionKey}. Run ${formatCliCommand("openclaw doctor")} to inspect session storage.`, - ); - runtime.exit(1); - return; - } - let summary: TrajectoryCommandExportSummary; try { summary = await exportTrajectoryForCommand({ outputPath: resolvedOpts.output, - sessionFile, + sessionTarget: { + agentId: sessionTarget.agentId ?? targetAgentId, + sessionId: sessionTarget.sessionId, + sessionKey: sessionTarget.sessionKey ?? sessionKey, + storePath: sessionTarget.storePath, + }, sessionId: entry.sessionId, sessionKey, workspaceDir: path.resolve(resolvedOpts.workspace ?? process.cwd()), diff --git a/src/commands/sessions-table.ts b/src/commands/sessions-table.ts index f00ef0630b40..82d4b204f3c5 100644 --- a/src/commands/sessions-table.ts +++ b/src/commands/sessions-table.ts @@ -62,7 +62,6 @@ export function toSessionDisplayRow(key: string, entry: SessionEntry): SessionDi updatedAt, ageMs: updatedAt ? Date.now() - updatedAt : null, sessionId: entry?.sessionId, - sessionFile: entry?.sessionFile, spawnedBy: entry?.spawnedBy, spawnedWorkspaceDir: entry?.spawnedWorkspaceDir, spawnedCwd: entry?.spawnedCwd, diff --git a/src/commands/sessions-tail.test.ts b/src/commands/sessions-tail.test.ts index a8984fbad865..af538744d794 100644 --- a/src/commands/sessions-tail.test.ts +++ b/src/commands/sessions-tail.test.ts @@ -3,16 +3,11 @@ import fs from "node:fs"; import os from "node:os"; import path from "node:path"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; -import { replaceSessionEntry } from "../config/sessions/session-accessor.js"; -import { formatSqliteSessionFileMarker } from "../config/sessions/sqlite-marker.js"; +import { upsertSessionEntry } from "../config/sessions/session-accessor.js"; import type { SessionEntry } from "../config/sessions/types.js"; import type { RuntimeEnv } from "../runtime.js"; import { closeOpenClawAgentDatabasesForTest } from "../state/openclaw-agent-db.js"; import { closeOpenClawStateDatabaseForTest } from "../state/openclaw-state-db.js"; -import { - resolveTrajectoryPointerFilePath, - TRAJECTORY_RUNTIME_FILE_MAX_BYTES, -} from "../trajectory/paths.js"; import { appendSqliteTrajectoryRuntimeEvents } from "../trajectory/runtime-store.sqlite.js"; import type { TrajectoryEvent } from "../trajectory/types.js"; import { sessionsTailCommand } from "./sessions-tail.js"; @@ -51,15 +46,6 @@ function makeEvent( }; } -function writeJsonl(filePath: string, events: TrajectoryEvent[]): void { - fs.mkdirSync(path.dirname(filePath), { recursive: true }); - fs.writeFileSync(filePath, `${events.map((event) => JSON.stringify(event)).join("\n")}\n`); -} - -function appendJsonl(filePath: string, event: TrajectoryEvent): void { - fs.appendFileSync(filePath, `${JSON.stringify(event)}\n`); -} - function runtimeOutput(runtime: RuntimeEnv): string { return vi .mocked(runtime.log) @@ -86,7 +72,6 @@ async function waitForRuntimeOutput( describe("sessionsTailCommand", () => { let tmpDir: string; let storePath: string; - let trajectoryPath: string; let previousStateDir: string | undefined; beforeEach(() => { @@ -100,7 +85,6 @@ describe("sessionsTailCommand", () => { }, }); storePath = path.join(tmpDir, "sessions.json"); - trajectoryPath = path.join(tmpDir, "session-one.trajectory.jsonl"); }); afterEach(() => { @@ -119,11 +103,10 @@ describe("sessionsTailCommand", () => { key = sessionKey, entry: Partial = {}, ): Promise { - await replaceSessionEntry( + await upsertSessionEntry( { sessionKey: key, storePath }, { sessionId: "session-one", - sessionFile: "session-one.jsonl", updatedAt: 2, status: "running", ...entry, @@ -135,12 +118,12 @@ describe("sessionsTailCommand", () => { events: TrajectoryEvent[], params: { key?: string; sessionId?: string } = {}, ): Promise { - const targetPath = - params.sessionId && params.sessionId !== "session-one" - ? path.join(tmpDir, `${params.sessionId}.trajectory.jsonl`) - : trajectoryPath; - writeJsonl( - targetPath, + appendSqliteTrajectoryRuntimeEvents( + { + agentId: "main", + sessionId: params.sessionId ?? "session-one", + storePath, + }, events.map((event) => ({ ...event, sessionKey: params.key ?? event.sessionKey })), ); } @@ -223,47 +206,9 @@ describe("sessionsTailCommand", () => { expect(runtime.log).not.toHaveBeenCalled(); }); - it("uses a session trajectory pointer for relocated runtime files", async () => { + it("tails SQLite trajectory rows from the database", async () => { const runtime = makeRuntime(); await writeSessionEntry(); - const sessionFile = path.join(tmpDir, "session-one.jsonl"); - const relocatedDir = path.join(tmpDir, "relocated-trajectories"); - const relocatedTrajectoryPath = path.join(relocatedDir, "session-one.jsonl"); - fs.mkdirSync(relocatedDir, { recursive: true }); - fs.writeFileSync( - resolveTrajectoryPointerFilePath(sessionFile), - `${JSON.stringify({ - traceSchema: "openclaw-trajectory-pointer", - schemaVersion: 1, - sessionId: "session-one", - runtimeFile: relocatedTrajectoryPath, - })}\n`, - ); - writeJsonl(relocatedTrajectoryPath, [ - makeEvent({ - type: "tool.result", - ts: "2026-05-18T12:04:21.000Z", - data: { name: "bash", success: true }, - }), - ]); - - await sessionsTailCommand({ store: storePath, sessionKey }, runtime); - - const output = runtimeOutput(runtime); - expect(output).toContain("tool.result"); - expect(output).toContain("bash ok"); - expect(output).not.toContain("No sessions found"); - }); - - it("tails SQLite marker trajectory rows from the database", async () => { - const runtime = makeRuntime(); - await writeSessionEntry(sessionKey, { - sessionFile: formatSqliteSessionFileMarker({ - agentId: "main", - sessionId: "session-one", - storePath, - }), - }); appendSqliteTrajectoryRuntimeEvents({ sessionId: "session-one", storePath }, [ makeEvent({ type: "tool.result", @@ -278,31 +223,23 @@ describe("sessionsTailCommand", () => { expect(output).toContain("tool.result"); expect(output).toContain("sqlite ok"); expect(output).not.toContain("No sessions found"); - expect(fs.existsSync(path.join(tmpDir, "trajectory", "session-one.jsonl"))).toBe(false); }); - it("ignores stale trajectory pointers for another session id", async () => { + it("isolates trajectory rows by session id", async () => { const runtime = makeRuntime(); await writeSessionEntry(); - const sessionFile = path.join(tmpDir, "session-one.jsonl"); - const staleRuntimePath = path.join(tmpDir, "relocated-trajectories", "old-session.jsonl"); - fs.writeFileSync( - resolveTrajectoryPointerFilePath(sessionFile), - `${JSON.stringify({ - traceSchema: "openclaw-trajectory-pointer", - schemaVersion: 1, - sessionId: "old-session", - runtimeFile: staleRuntimePath, - })}\n`, + await writeSessionEntry("agent:main:old", { sessionId: "old-session" }); + await appendEvents( + [ + makeEvent({ + sessionId: "old-session", + type: "tool.result", + ts: "2026-05-18T12:04:21.000Z", + data: { name: "stale", success: true }, + }), + ], + { sessionId: "old-session" }, ); - writeJsonl(staleRuntimePath, [ - makeEvent({ - sessionId: "old-session", - type: "tool.result", - ts: "2026-05-18T12:04:21.000Z", - data: { name: "stale", success: true }, - }), - ]); await appendEvents([ makeEvent({ type: "tool.result", @@ -318,302 +255,10 @@ describe("sessionsTailCommand", () => { expect(output).not.toContain("stale ok"); }); - it("preserves events appended while follow mode starts", async () => { - const runtime = makeRuntime(); - await writeSessionEntry(); - await appendEvents([makeEvent({ type: "session.started", ts: "2026-05-18T12:04:17.000Z" })]); - const appendedEvent = makeEvent({ - type: "tool.result", - ts: "2026-05-18T12:04:21.000Z", - data: { name: "bash", success: true }, - }); - let appended = false; - vi.mocked(runtime.log).mockImplementation((message) => { - if (!appended && String(message).includes("session.started")) { - appended = true; - appendJsonl(trajectoryPath, appendedEvent); - } - }); - - const run = sessionsTailCommand( - { store: storePath, sessionKey, tail: "1", follow: true }, - runtime, - ); - try { - await waitForRuntimeOutput(runtime, "bash ok"); - } finally { - process.emit("SIGTERM", "SIGTERM"); - await run; - } - - const output = runtimeOutput(runtime); - expect(output).toContain("session.started"); - expect(output).toContain("tool.result"); - expect(output).toContain("bash ok"); - }); - - it("delivers appended events across short follow reads without skipping bytes", async () => { - const runtime = makeRuntime(); - await writeSessionEntry(); - await appendEvents([ - makeEvent({ - sourceSeq: 1, - type: "session.started", - ts: "2026-05-18T12:04:17.000Z", - }), - ]); - const appendedEvent = makeEvent({ - sourceSeq: 2, - type: "tool.result", - ts: "2026-05-18T12:04:21.000Z", - data: { name: "python", success: true }, - }); - // POSIX positional reads may return fewer bytes than requested; cap each - // call to prove the bounded delta is filled without skipping bytes. - let capReads = false; - let shortReadCalls = 0; - const realReadSync = fs.readSync.bind(fs); - const cappedReadSync = ( - fd: number, - buffer: NodeJS.ArrayBufferView, - offset: number, - length: number, - position: fs.ReadPosition | null, - ): number => { - const cappedLength = capReads ? Math.min(length, 16) : length; - if (capReads) { - shortReadCalls += 1; - } - return realReadSync(fd, buffer, offset, cappedLength, position); - }; - const readSpy = vi - .spyOn(fs, "readSync") - .mockImplementation(cappedReadSync as typeof fs.readSync); - let appended = false; - vi.mocked(runtime.log).mockImplementation((message) => { - if (!appended && String(message).includes("session.started")) { - appended = true; - capReads = true; - appendJsonl(trajectoryPath, appendedEvent); - } - }); - - const run = sessionsTailCommand( - { store: storePath, sessionKey, tail: "1", follow: true }, - runtime, - ); - try { - await waitForRuntimeOutput(runtime, "python ok"); - } finally { - readSpy.mockRestore(); - process.emit("SIGTERM", "SIGTERM"); - await run; - } - - const output = runtimeOutput(runtime); - expect(shortReadCalls).toBeGreaterThan(1); - expect(output).toContain("tool.result"); - expect(output).toContain("python ok"); - }); - - it("continues following when later trajectory events are appended", async () => { - const runtime = makeRuntime(); - await writeSessionEntry(); - await appendEvents([ - makeEvent({ - sourceSeq: 1, - type: "session.started", - ts: "2026-05-18T12:04:17.000Z", - }), - ]); - const rewrittenEvent = makeEvent({ - sourceSeq: 2, - type: "tool.result", - ts: "2026-05-18T12:04:21.000Z", - data: { name: "python", success: true }, - }); - let rewritten = false; - vi.mocked(runtime.log).mockImplementation((message) => { - if (!rewritten && String(message).includes("session.started")) { - rewritten = true; - appendJsonl(trajectoryPath, rewrittenEvent); - } - }); - - const run = sessionsTailCommand( - { store: storePath, sessionKey, tail: "1", follow: true }, - runtime, - ); - try { - await waitForRuntimeOutput(runtime, "python ok"); - } finally { - process.emit("SIGTERM", "SIGTERM"); - await run; - } - - const output = runtimeOutput(runtime); - expect(output).toContain("tool.result"); - expect(output).toContain("python ok"); - }); - - it("preserves UTF-8 characters split across follow reads", async () => { - const runtime = makeRuntime(); - await writeSessionEntry(); - await appendEvents([ - makeEvent({ - sourceSeq: 1, - type: "session.started", - ts: "2026-05-18T12:04:17.000Z", - }), - ]); - const appendedEvent = makeEvent({ - sourceSeq: 2, - type: "prompt.skipped", - ts: "2026-05-18T12:04:21.000Z", - data: { reason: "猫" }, - }); - - const run = sessionsTailCommand( - { store: storePath, sessionKey, tail: "1", follow: true }, - runtime, - ); - try { - await waitForRuntimeOutput(runtime, "session.started"); - const line = Buffer.from(`${JSON.stringify(appendedEvent)}\n`, "utf8"); - const marker = Buffer.from("猫", "utf8"); - const markerOffset = line.indexOf(marker); - expect(markerOffset).toBeGreaterThanOrEqual(0); - - fs.appendFileSync(trajectoryPath, line.subarray(0, markerOffset + 1)); - await new Promise((resolve) => { - setTimeout(resolve, 20); - }); - fs.appendFileSync(trajectoryPath, line.subarray(markerOffset + 1)); - await waitForRuntimeOutput(runtime, "prompt skipped"); - } finally { - process.emit("SIGTERM", "SIGTERM"); - await run; - } - - const output = runtimeOutput(runtime); - expect(output).toContain("prompt skipped: 猫"); - expect(output).not.toContain("�"); - }); - - it("preserves UTF-8 characters split before follow starts", async () => { - const runtime = makeRuntime(); - await writeSessionEntry(); - await appendEvents([ - makeEvent({ - sourceSeq: 1, - type: "session.started", - ts: "2026-05-18T12:04:17.000Z", - }), - ]); - const appendedEvent = makeEvent({ - sourceSeq: 2, - type: "prompt.skipped", - ts: "2026-05-18T12:04:21.000Z", - data: { reason: "猫" }, - }); - const line = Buffer.from(`${JSON.stringify(appendedEvent)}\n`, "utf8"); - const markerOffset = line.indexOf(Buffer.from("猫", "utf8")); - expect(markerOffset).toBeGreaterThanOrEqual(0); - fs.appendFileSync(trajectoryPath, line.subarray(0, markerOffset + 1)); - - const run = sessionsTailCommand( - { store: storePath, sessionKey, tail: "1", follow: true }, - runtime, - ); - try { - await waitForRuntimeOutput(runtime, "session.started"); - fs.appendFileSync(trajectoryPath, line.subarray(markerOffset + 1)); - await waitForRuntimeOutput(runtime, "prompt skipped"); - } finally { - process.emit("SIGTERM", "SIGTERM"); - await run; - } - - const output = runtimeOutput(runtime); - expect(output).toContain("prompt skipped: 猫"); - expect(output).not.toContain("�"); - }); - - it("resets split UTF-8 state when the followed file is replaced", async () => { - const runtime = makeRuntime(); - await writeSessionEntry(); - const startedEvent = makeEvent({ - sourceSeq: 1, - type: "session.started", - ts: "2026-05-18T12:04:17.000Z", - }); - await appendEvents([startedEvent]); - - const run = sessionsTailCommand( - { store: storePath, sessionKey, tail: "1", follow: true }, - runtime, - ); - try { - await waitForRuntimeOutput(runtime, "session.started"); - const partialLine = Buffer.from( - `${JSON.stringify( - makeEvent({ - sourceSeq: 2, - type: "prompt.skipped", - ts: "2026-05-18T12:04:20.000Z", - data: { reason: "猫" }, - }), - )}\n`, - "utf8", - ); - const markerOffset = partialLine.indexOf(Buffer.from("猫", "utf8")); - expect(markerOffset).toBeGreaterThanOrEqual(0); - fs.appendFileSync(trajectoryPath, partialLine.subarray(0, markerOffset + 1)); - await new Promise((resolve) => { - setTimeout(resolve, 20); - }); - - const replacementPath = `${trajectoryPath}.replacement`; - writeJsonl(replacementPath, [ - startedEvent, - makeEvent({ - sourceSeq: 3, - type: "tool.result", - ts: "2026-05-18T12:04:21.000Z", - data: { name: "rotation", success: true }, - }), - ]); - fs.renameSync(replacementPath, trajectoryPath); - await waitForRuntimeOutput(runtime, "rotation ok"); - - appendJsonl( - trajectoryPath, - makeEvent({ - sourceSeq: 4, - type: "prompt.skipped", - ts: "2026-05-18T12:04:22.000Z", - data: { reason: "clean" }, - }), - ); - await waitForRuntimeOutput(runtime, "prompt skipped: clean"); - } finally { - process.emit("SIGTERM", "SIGTERM"); - await run; - } - - expect(runtimeOutput(runtime)).not.toContain("�"); - }); - it("continues following when SQLite trajectory rows are appended", async () => { const runtime = makeRuntime(); - await writeSessionEntry(sessionKey, { - sessionFile: formatSqliteSessionFileMarker({ - agentId: "main", - sessionId: "session-one", - storePath, - }), - }); - appendSqliteTrajectoryRuntimeEvents({ sessionId: "session-one", storePath }, [ + await writeSessionEntry(); + appendSqliteTrajectoryRuntimeEvents({ agentId: "main", sessionId: "session-one", storePath }, [ makeEvent({ sourceSeq: 1, type: "session.started", @@ -630,9 +275,10 @@ describe("sessionsTailCommand", () => { vi.mocked(runtime.log).mockImplementation((message) => { if (!appended && String(message).includes("session.started")) { appended = true; - appendSqliteTrajectoryRuntimeEvents({ sessionId: "session-one", storePath }, [ - appendedEvent, - ]); + appendSqliteTrajectoryRuntimeEvents( + { agentId: "main", sessionId: "session-one", storePath }, + [appendedEvent], + ); } }); @@ -657,19 +303,22 @@ describe("sessionsTailCommand", () => { const opsSessionKey = "agent:ops:telegram:direct:owner"; const opsSessionsDir = path.join(process.env.OPENCLAW_STATE_DIR!, "agents", "ops", "sessions"); const opsStorePath = path.join(opsSessionsDir, "sessions.json"); - await replaceSessionEntry( + await upsertSessionEntry( { sessionKey: opsSessionKey, storePath: opsStorePath }, - { sessionId: "ops-session", sessionFile: "ops-session.jsonl", updatedAt: 3, status: "done" }, + { sessionId: "ops-session", updatedAt: 3, status: "done" }, + ); + appendSqliteTrajectoryRuntimeEvents( + { agentId: "ops", sessionId: "ops-session", storePath: opsStorePath }, + [ + makeEvent({ + sessionId: "ops-session", + sessionKey: opsSessionKey, + type: "tool.result", + ts: "2026-05-18T12:04:21.000Z", + data: { name: "bash", success: true }, + }), + ], ); - writeJsonl(path.join(opsSessionsDir, "ops-session.trajectory.jsonl"), [ - makeEvent({ - sessionId: "ops-session", - sessionKey: opsSessionKey, - type: "tool.result", - ts: "2026-05-18T12:04:21.000Z", - data: { name: "bash", success: true }, - }), - ]); await sessionsTailCommand({ sessionKey: opsSessionKey }, runtime); @@ -679,38 +328,4 @@ describe("sessionsTailCommand", () => { expect(output).toContain("bash ok"); expect(output).not.toContain("No sessions found"); }); - - it("rejects oversized trajectory snapshots", async () => { - const runtime = makeRuntime(); - await writeSessionEntry(); - fs.writeFileSync(trajectoryPath, ""); - fs.truncateSync(trajectoryPath, TRAJECTORY_RUNTIME_FILE_MAX_BYTES + 1); - - await expect(sessionsTailCommand({ store: storePath, sessionKey }, runtime)).rejects.toThrow( - /File exceeds 52428800 bytes/, - ); - }); - - it("rejects oversized follow-mode trajectory deltas", async () => { - const runtime = makeRuntime(); - await writeSessionEntry(); - writeJsonl(trajectoryPath, [ - makeEvent({ type: "session.started", ts: "2026-05-18T12:04:17.000Z" }), - ]); - - const run = sessionsTailCommand({ store: storePath, sessionKey, follow: true }, runtime); - try { - await waitForRuntimeOutput(runtime, "session.started"); - const initialSize = fs.statSync(trajectoryPath).size; - fs.truncateSync(trajectoryPath, initialSize + TRAJECTORY_RUNTIME_FILE_MAX_BYTES + 1); - await vi.waitFor(() => { - expect(runtime.error).toHaveBeenCalledWith( - expect.stringContaining("Trajectory delta exceeds 52428800 bytes"), - ); - }); - } finally { - process.emit("SIGTERM", "SIGTERM"); - await run; - } - }); }); diff --git a/src/commands/sessions-tail.ts b/src/commands/sessions-tail.ts index d5f958098a3c..215207d8b3e7 100644 --- a/src/commands/sessions-tail.ts +++ b/src/commands/sessions-tail.ts @@ -2,30 +2,17 @@ * Session trajectory tail command. * * It selects active or requested sessions, renders recent trajectory events, - * and can follow append-only trajectory files across rotation/truncation. + * and can follow newly appended SQLite trajectory rows. */ -import fs from "node:fs"; -import path from "node:path"; -import { StringDecoder } from "node:string_decoder"; -import { isRecord } from "@openclaw/normalization-core/record-coerce"; import { readAcpSessionMeta } from "../acp/runtime/session-meta.js"; import { getRuntimeConfig } from "../config/config.js"; -import { resolveSessionFilePath } from "../config/sessions/paths.js"; import { listSessionEntriesReadOnly } from "../config/sessions/session-accessor.js"; -import { parseSqliteSessionFileMarker } from "../config/sessions/sqlite-marker.js"; import type { SessionEntry } from "../config/sessions/types.js"; import { resolveStoredSessionKeyForAgentStore } from "../gateway/session-store-key.js"; import { formatErrorMessage } from "../infra/errors.js"; -import { readFileWindowFullySync } from "../infra/file-read.js"; import { parseStrictNonNegativeInteger } from "../infra/parse-finite-number.js"; -import { readRegularFileSync } from "../infra/regular-file.js"; import { resolveAgentIdFromSessionKey } from "../routing/session-key.js"; import type { RuntimeEnv } from "../runtime.js"; -import { - resolveTrajectoryFilePath, - TRAJECTORY_RUNTIME_FILE_MAX_BYTES, -} from "../trajectory/paths.js"; -import { resolveTrajectoryRuntimeFile } from "../trajectory/runtime-file.js"; import { loadSqliteTrajectoryRuntimeEventRowsSync } from "../trajectory/runtime-store.sqlite.js"; import type { TrajectoryEvent } from "../trajectory/types.js"; import { resolveSessionStoreTargetsOrExit } from "./session-store-targets.js"; @@ -48,56 +35,20 @@ type TailSelection = { source: TailTrajectorySource; }; -type TailTrajectorySource = - | { - kind: "file"; - path: string; - } - | { - agentId: string; - kind: "sqlite"; - sessionId: string; - storePath: string; - }; - -type FileFollowState = { - cursor: TrajectoryCursor | null; - decoder: StringDecoder; - fileState: FollowFileState | null; - kind: "file"; - offset: number; - pending: string; - selection: TailSelection & { source: Extract }; +type TailTrajectorySource = { + agentId: string; + sessionId: string; + storePath: string; }; type SqliteFollowState = { - cursor: TrajectoryCursor | null; - kind: "sqlite"; lastStorageSeq: number; - selection: TailSelection & { source: Extract }; + selection: TailSelection; }; -type FollowState = FileFollowState | SqliteFollowState; - type TrajectorySnapshot = { events: TrajectoryEvent[]; - fileDecoder?: StringDecoder; - filePending?: string; - fileState: FollowFileState | null; - maxStorageSeq?: number; - offset: number; -}; - -type FollowFileState = { - dev: number; - ino: number; - mtimeMs: number; - size: number; -}; - -type TrajectoryCursor = { - seq: number | null; - tsMs: number; + maxStorageSeq: number; }; const DEFAULT_TAIL_COUNT = 80; @@ -132,95 +83,6 @@ function toOptionalString(value: unknown): string | undefined { return typeof value === "string" && value.trim() ? value.trim() : undefined; } -function isTrajectoryEvent(value: unknown): value is TrajectoryEvent { - return ( - isRecord(value) && - value.traceSchema === "openclaw-trajectory" && - value.schemaVersion === 1 && - typeof value.type === "string" && - typeof value.ts === "string" && - typeof value.sessionId === "string" - ); -} - -function parseTrajectoryEventLine(line: string): TrajectoryEvent | null { - const trimmed = line.trim(); - if (!trimmed) { - return null; - } - try { - const parsed = JSON.parse(trimmed) as unknown; - return isTrajectoryEvent(parsed) ? parsed : null; - } catch { - return null; - } -} - -function parseTrajectoryEventLines(lines: string[]): TrajectoryEvent[] { - return lines.flatMap((line) => { - const event = parseTrajectoryEventLine(line); - return event ? [event] : []; - }); -} - -function eventSequence(event: TrajectoryEvent): number | null { - const seq = event.sourceSeq ?? event.seq; - return Number.isFinite(seq) ? seq : null; -} - -function eventTimestampMs(event: TrajectoryEvent): number { - const parsed = Date.parse(event.ts); - return Number.isFinite(parsed) ? parsed : Number.NEGATIVE_INFINITY; -} - -function eventCursor(event: TrajectoryEvent): TrajectoryCursor { - return { - seq: eventSequence(event), - tsMs: eventTimestampMs(event), - }; -} - -function compareCursors(left: TrajectoryCursor, right: TrajectoryCursor): number { - if (left.seq !== null && right.seq !== null && left.seq !== right.seq) { - return left.seq - right.seq; - } - // Some trajectory events lack sequence numbers; timestamp fallback keeps - // follow mode from replaying already-rendered events after file rewrites. - const byTimestamp = left.tsMs - right.tsMs; - if (byTimestamp !== 0) { - return byTimestamp; - } - if (left.seq !== null && right.seq !== null) { - return left.seq - right.seq; - } - return 0; -} - -function maxCursorValue( - current: TrajectoryCursor | null, - candidate: TrajectoryCursor, -): TrajectoryCursor { - return !current || compareCursors(candidate, current) > 0 ? candidate : current; -} - -function maxCursor(current: TrajectoryCursor | null, event: TrajectoryEvent): TrajectoryCursor { - return maxCursorValue(current, eventCursor(event)); -} - -function maxCursorFromEvents(events: TrajectoryEvent[]): TrajectoryCursor | null { - return events.reduce((cursor, event) => maxCursor(cursor, event), null); -} - -function eventsAfterCursor( - events: TrajectoryEvent[], - cursor: TrajectoryCursor | null, -): TrajectoryEvent[] { - if (!cursor) { - return events; - } - return events.filter((event) => compareCursors(eventCursor(event), cursor) > 0); -} - function formatTimestamp(ts: string): string { const date = new Date(ts); if (Number.isNaN(date.getTime())) { @@ -311,85 +173,29 @@ function formatProgressLine(event: TrajectoryEvent): string { return [formatTimestamp(event.ts), typeLabel, sessionLabel, preview].join(" ").trimEnd(); } -function readTrajectorySnapshot(filePath: string): TrajectorySnapshot { - try { - // Use the runtime trajectory limit so tail accepts any file the runtime - // would have written and rejects anything larger. - const { buffer, stat } = readRegularFileSync({ - filePath, - maxBytes: TRAJECTORY_RUNTIME_FILE_MAX_BYTES, - }); - const fileDecoder = new StringDecoder("utf8"); - const lines = fileDecoder.write(buffer).split(/\r?\n/u); - const trailing = lines.pop() ?? ""; - const trailingEvent = parseTrajectoryEventLine(trailing); - return { - events: [...parseTrajectoryEventLines(lines), ...(trailingEvent ? [trailingEvent] : [])], - fileDecoder, - filePending: trailingEvent ? "" : trailing, - fileState: fileStateFromStat(stat), - offset: buffer.length, - }; - } catch (error) { - if ((error as NodeJS.ErrnoException).code === "ENOENT") { - return { events: [], fileState: null, offset: 0 }; - } - throw error; - } -} - function readSqliteTrajectorySnapshot( - source: Extract, + source: TailTrajectorySource, + tailEvents: number, ): TrajectorySnapshot { const rows = loadSqliteTrajectoryRuntimeEventRowsSync({ agentId: source.agentId, sessionId: source.sessionId, storePath: source.storePath, + tailEvents, }); return { events: rows.map((row) => row.event), - fileState: null, maxStorageSeq: rows.at(-1)?.seq ?? -1, - offset: 0, }; } -function readTailSnapshot(selection: TailSelection): TrajectorySnapshot { - return selection.source.kind === "sqlite" - ? readSqliteTrajectorySnapshot(selection.source) - : readTrajectorySnapshot(selection.source.path); +function readTailSnapshot(selection: TailSelection, tailEvents: number): TrajectorySnapshot { + return readSqliteTrajectorySnapshot(selection.source, tailEvents); } -function renderEvents(events: TrajectoryEvent[], runtime: RuntimeEnv): TrajectoryCursor | null { - let cursor: TrajectoryCursor | null = null; +function renderEvents(events: TrajectoryEvent[], runtime: RuntimeEnv): void { for (const event of events) { runtime.log(formatProgressLine(event)); - cursor = maxCursor(cursor, event); - } - return cursor; -} - -function fileStateFromStat(stat: fs.Stats): FollowFileState { - return { - dev: stat.dev, - ino: stat.ino, - mtimeMs: stat.mtimeMs, - size: stat.size, - }; -} - -function sameFileIdentity(left: FollowFileState | null, right: FollowFileState): boolean { - return Boolean(left && left.dev === right.dev && left.ino === right.ino); -} - -function readFollowFileState(filePath: string): FollowFileState | null { - try { - return fileStateFromStat(fs.statSync(filePath)); - } catch (error) { - if ((error as NodeJS.ErrnoException).code === "ENOENT") { - return null; - } - throw error; } } @@ -409,69 +215,24 @@ function compareSelectionsByUpdatedAt(a: TailSelection, b: TailSelection): numbe return (b.entry.updatedAt ?? 0) - (a.entry.updatedAt ?? 0); } -async function resolveTailTrajectoryPath(params: { - sessionFile: string; - sessionId: string; -}): Promise { - return ( - (await resolveTrajectoryRuntimeFile({ - sessionFile: params.sessionFile, - sessionId: params.sessionId, - })) ?? - resolveTrajectoryFilePath({ - sessionFile: params.sessionFile, - sessionId: params.sessionId, - }) - ); -} - -async function buildTailSelection(params: { +function buildTailSelection(params: { agentId: string; entry: SessionEntry; key: string; storePath: string; -}): Promise { +}): TailSelection | null { const sessionId = params.entry.sessionId?.trim(); if (!sessionId) { return null; } - const sessionsDir = path.dirname(params.storePath); - let sessionFile: string; - try { - const entrySessionFile = params.entry.sessionFile?.trim(); - const marker = entrySessionFile ? parseSqliteSessionFileMarker(entrySessionFile) : null; - if (marker && marker.sessionId === sessionId) { - return { - agentId: params.agentId, - entry: params.entry, - key: params.key, - source: { - agentId: marker.agentId, - kind: "sqlite", - sessionId: marker.sessionId, - storePath: marker.storePath, - }, - storePath: params.storePath, - }; - } - sessionFile = resolveSessionFilePath(sessionId, params.entry, { - agentId: params.agentId, - sessionsDir, - }); - } catch { - return null; - } - const trajectoryPath = await resolveTailTrajectoryPath({ - sessionFile, - sessionId, - }); return { agentId: params.agentId, entry: params.entry, key: params.key, source: { - kind: "file", - path: trajectoryPath, + agentId: params.agentId, + sessionId, + storePath: params.storePath, }, storePath: params.storePath, }; @@ -494,71 +255,6 @@ function selectSessionsToTail(selections: TailSelection[], sessionKey?: string): return latest ? [latest] : []; } -function statFileSize(filePath: string): number { - try { - return fs.statSync(filePath).size; - } catch (error) { - if ((error as NodeJS.ErrnoException).code === "ENOENT") { - return 0; - } - throw error; - } -} - -function readNewFileFollowEvents(state: FileFollowState): TrajectoryEvent[] { - const fileState = readFollowFileState(state.selection.source.path); - if (!fileState) { - state.decoder = new StringDecoder("utf8"); - state.fileState = null; - state.offset = 0; - state.pending = ""; - return []; - } - - const replaced = !sameFileIdentity(state.fileState, fileState); - const truncated = fileState.size < state.offset; - const possiblyRewrittenSameSize = - fileState.size === state.offset && state.fileState?.mtimeMs !== fileState.mtimeMs; - - if (replaced || truncated || possiblyRewrittenSameSize) { - // Log rotation, truncation, and same-size rewrites all require a full - // rescan; cursor filtering prevents duplicate event output. - const snapshot = readTrajectorySnapshot(state.selection.source.path); - state.decoder = snapshot.fileDecoder ?? new StringDecoder("utf8"); - state.fileState = snapshot.fileState; - state.offset = snapshot.offset; - state.pending = snapshot.filePending ?? ""; - return eventsAfterCursor(snapshot.events, state.cursor); - } - - if (fileState.size === state.offset) { - state.fileState = fileState; - return []; - } - - const fd = fs.openSync(state.selection.source.path, "r"); - try { - const deltaBytes = fileState.size - state.offset; - if (deltaBytes > TRAJECTORY_RUNTIME_FILE_MAX_BYTES) { - throw new Error( - `Trajectory delta exceeds ${TRAJECTORY_RUNTIME_FILE_MAX_BYTES} bytes: ${deltaBytes}`, - ); - } - const buffer = Buffer.alloc(deltaBytes); - const bytesRead = readFileWindowFullySync(fd, buffer, state.offset); - state.offset += bytesRead; - state.fileState = fileState; - const combined = `${state.pending}${state.decoder.write(buffer.subarray(0, bytesRead))}`; - // Keep an incomplete trailing JSON line until the next poll, matching - // append-only writers that flush in chunks. - const lines = combined.split(/\r?\n/u); - state.pending = lines.pop() ?? ""; - return parseTrajectoryEventLines(lines); - } finally { - fs.closeSync(fd); - } -} - function readNewSqliteFollowEvents(state: SqliteFollowState): TrajectoryEvent[] { const rows = loadSqliteTrajectoryRuntimeEventRowsSync({ agentId: state.selection.source.agentId, @@ -573,50 +269,16 @@ function readNewSqliteFollowEvents(state: SqliteFollowState): TrajectoryEvent[] return rows.map((row) => row.event); } -function readNewFollowEvents(state: FollowState): TrajectoryEvent[] { - return state.kind === "sqlite" - ? readNewSqliteFollowEvents(state) - : readNewFileFollowEvents(state); -} - -function renderFollowEvents( - events: TrajectoryEvent[], - state: FollowState, - runtime: RuntimeEnv, -): void { - const cursor = renderEvents(events, runtime); - if (cursor) { - state.cursor = maxCursorValue(state.cursor, cursor); - } -} - async function followSelections( selections: TailSelection[], runtime: RuntimeEnv, initialSnapshots: Map, ): Promise { - const states = selections.map((selection): FollowState => { + const states = selections.map((selection): SqliteFollowState => { const snapshot = initialSnapshots.get(selection); - if (selection.source.kind === "sqlite") { - return { - cursor: snapshot ? maxCursorFromEvents(snapshot.events) : null, - kind: "sqlite", - lastStorageSeq: snapshot?.maxStorageSeq ?? -1, - selection: selection as TailSelection & { - source: Extract; - }, - }; - } return { - cursor: snapshot ? maxCursorFromEvents(snapshot.events) : null, - decoder: snapshot?.fileDecoder ?? new StringDecoder("utf8"), - fileState: snapshot?.fileState ?? readFollowFileState(selection.source.path), - kind: "file", - offset: snapshot?.offset ?? statFileSize(selection.source.path), - pending: snapshot?.filePending ?? "", - selection: selection as TailSelection & { - source: Extract; - }, + lastStorageSeq: snapshot?.maxStorageSeq ?? -1, + selection, }; }); @@ -624,7 +286,7 @@ async function followSelections( const interval = setInterval(() => { for (const state of states) { try { - renderFollowEvents(readNewFollowEvents(state), state, runtime); + renderEvents(readNewSqliteFollowEvents(state), runtime); } catch (error) { runtime.error( `Failed to read trajectory progress for ${state.selection.key}: ${formatErrorMessage( @@ -685,7 +347,7 @@ export async function sessionsTailCommand( agentId: target.agentId, storePath: target.storePath, })) { - const selection = await buildTailSelection({ + const selection = buildTailSelection({ agentId: target.agentId, entry, key: sessionKey, @@ -705,7 +367,7 @@ export async function sessionsTailCommand( const followSnapshots = new Map(); for (const selection of selected) { - const snapshot = readTailSnapshot(selection); + const snapshot = readTailSnapshot(selection, Math.max(tailCount, opts.follow ? 1 : 0)); followSnapshots.set(selection, snapshot); renderEvents(tailCount > 0 ? snapshot.events.slice(-tailCount) : [], runtime); } diff --git a/src/commands/sessions.test.ts b/src/commands/sessions.test.ts index e7ac0b37ba7e..2d5cb0cc4c81 100644 --- a/src/commands/sessions.test.ts +++ b/src/commands/sessions.test.ts @@ -183,11 +183,10 @@ describe("sessionsCommand", () => { expect(group?.totalTokensFresh).toBe(false); }); - it("reports the SQLite database for the store and SQLite-backed sessionFile", async () => { + it("reports the SQLite database and omits the retired sessionFile field", async () => { const store = await writeStore({ main: { sessionId: "abc123", - sessionFile: "sqlite:main:abc123:/tmp/openclaw/agents/main/sessions/sessions.json", updatedAt: Date.now() - 10 * 60_000, model: "test:opus", }, @@ -195,14 +194,12 @@ describe("sessionsCommand", () => { const payload = await runSessionsJson<{ path?: string; - sessions?: Array<{ key: string; sessionFile?: string }>; + sessions?: Array<{ key: string }>; }>(sessionsCommand, store); expect(payload.path).toMatch(/openclaw-agent\.sqlite$/u); expect(payload.path).not.toContain("sessions.json"); - expect(payload.sessions?.find((row) => row.key === "main")?.sessionFile).toBe( - "sqlite:main:abc123:/tmp/openclaw/agents/main/agent/openclaw-agent.sqlite", - ); + expect(payload.sessions?.find((row) => row.key === "main")).not.toHaveProperty("sessionFile"); }); it("exports subagent lineage metadata in JSON output", async () => { @@ -210,7 +207,6 @@ describe("sessionsCommand", () => { "agent:child:main": { sessionId: "child-session", updatedAt: Date.now() - 10 * 60_000, - sessionFile: "/tmp/openclaw/child-session.jsonl", spawnedBy: "agent:main:main", spawnedWorkspaceDir: "/workspace/project", spawnedCwd: "/workspace/project/tasks", @@ -230,7 +226,6 @@ describe("sessionsCommand", () => { const payload = await runSessionsJson<{ sessions?: Array<{ key: string; - sessionFile?: string; spawnedBy?: string; spawnedWorkspaceDir?: string; spawnedCwd?: string; @@ -248,7 +243,6 @@ describe("sessionsCommand", () => { const child = payload.sessions?.find((row) => row.key === "agent:child:main"); expect(child).toMatchObject({ - sessionFile: "/tmp/openclaw/child-session.jsonl", spawnedBy: "agent:main:main", spawnedWorkspaceDir: "/workspace/project", spawnedCwd: "/workspace/project/tasks", @@ -262,6 +256,7 @@ describe("sessionsCommand", () => { label: "research helper", status: "done", }); + expect(child).not.toHaveProperty("sessionFile"); }); it("shows preserved stale totals in JSON output", async () => { diff --git a/src/commands/sessions.ts b/src/commands/sessions.ts index fdc165cc7937..eddaa4a42bca 100644 --- a/src/commands/sessions.ts +++ b/src/commands/sessions.ts @@ -18,10 +18,6 @@ import { getRuntimeConfig } from "../config/config.js"; import { resolveSessionTotalTokens } from "../config/sessions.js"; import { listSessionEntriesReadOnly } from "../config/sessions/session-accessor.js"; import { resolveSqliteTargetFromSessionStorePath } from "../config/sessions/session-sqlite-target.js"; -import { - formatSqliteSessionFileMarker, - parseSqliteSessionFileMarker, -} from "../config/sessions/sqlite-marker.js"; import type { SessionEntry } from "../config/sessions/types.js"; import type { OpenClawConfig } from "../config/types.openclaw.js"; import { resolveStoredSessionKeyForAgentStore } from "../gateway/session-store-key.js"; @@ -247,15 +243,6 @@ function resolveSessionStoreDisplayPath(target: { agentId: string; storePath: st function toJsonSessionRow(row: SessionRow): Omit { const { runtimeLabel, ...jsonRow } = row; void runtimeLabel; - const marker = parseSqliteSessionFileMarker(jsonRow.sessionFile); - if (marker) { - jsonRow.sessionFile = formatSqliteSessionFileMarker({ - ...marker, - storePath: resolveSqliteTargetFromSessionStorePath(marker.storePath, { - agentId: marker.agentId, - }).path, - }); - } return jsonRow; } diff --git a/src/config/cache-utils.test.ts b/src/config/cache-utils.test.ts deleted file mode 100644 index d1494f947da4..000000000000 --- a/src/config/cache-utils.test.ts +++ /dev/null @@ -1,39 +0,0 @@ -// Covers synchronous config cache behavior. -import { describe, expect, it } from "vitest"; -import { createExpiringMapCache } from "./cache-utils.js"; - -describe("createExpiringMapCache", () => { - it("expires entries on read after the TTL", () => { - let now = 1_000; - const cache = createExpiringMapCache({ - ttlMs: 5_000, - clock: () => now, - }); - - cache.set("alpha", "a"); - expect(cache.get("alpha")).toBe("a"); - - now = 6_001; - expect(cache.get("alpha")).toBeUndefined(); - expect(cache.size()).toBe(0); - }); - - it("supports dynamic TTLs and opportunistic pruning", () => { - let now = 1_000; - let ttlMs = 5_000; - const cache = createExpiringMapCache({ - ttlMs: () => ttlMs, - pruneIntervalMs: 1_000, - clock: () => now, - }); - - cache.set("stale", "old"); - now = 7_000; - ttlMs = 2_000; - - cache.set("fresh", "new"); - - expect(cache.get("stale")).toBeUndefined(); - expect(cache.keys()).toEqual(["fresh"]); - }); -}); diff --git a/src/config/cache-utils.ts b/src/config/cache-utils.ts deleted file mode 100644 index a728d0c6a1d9..000000000000 --- a/src/config/cache-utils.ts +++ /dev/null @@ -1,133 +0,0 @@ -// Provides small synchronous config cache helpers. - -/** Returns whether a TTL keeps cache reads and writes active. */ -export function isCacheEnabled(ttlMs: number): boolean { - return ttlMs > 0; -} - -type CacheTtlResolver = number | (() => number); -type CachePruneIntervalResolver = number | ((ttlMs: number) => number); - -type ExpiringMapCacheEntry = { - storedAt: number; - value: TValue; -}; - -type ExpiringMapCache = { - get: (key: TKey) => TValue | undefined; - set: (key: TKey, value: TValue) => void; - delete: (key: TKey) => void; - clear: () => void; - keys: () => TKey[]; - size: () => number; - pruneExpired: () => void; -}; - -function resolveCacheNumeric(value: CacheTtlResolver): number { - return typeof value === "function" ? value() : value; -} - -function resolvePruneIntervalMs( - ttlMs: number, - pruneIntervalMs: CachePruneIntervalResolver | undefined, -): number { - if (typeof pruneIntervalMs === "function") { - return Math.max(0, Math.floor(pruneIntervalMs(ttlMs))); - } - if (typeof pruneIntervalMs === "number") { - return Math.max(0, Math.floor(pruneIntervalMs)); - } - return ttlMs; -} - -function isCacheEntryExpired(storedAt: number, now: number, ttlMs: number): boolean { - return now - storedAt > ttlMs; -} - -/** Creates a small synchronous map cache with dynamic TTLs and explicit pruning hooks. */ -export function createExpiringMapCache(options: { - ttlMs: CacheTtlResolver; - pruneIntervalMs?: CachePruneIntervalResolver; - clock?: () => number; -}): ExpiringMapCache { - const cache = new Map>(); - const now = options.clock ?? Date.now; - let lastPruneAt = 0; - - function getTtlMs(): number { - // Re-read TTL on every operation so callers can disable or shrink caches without rebuilding - // the cache object. - return Math.max(0, Math.floor(resolveCacheNumeric(options.ttlMs))); - } - - function maybePruneExpiredEntries(nowMs: number, ttlMs: number): void { - if (!isCacheEnabled(ttlMs)) { - return; - } - // Pruning is opportunistic; individual reads still check expiry so skipped sweeps cannot - // return stale values. - if (nowMs - lastPruneAt < resolvePruneIntervalMs(ttlMs, options.pruneIntervalMs)) { - return; - } - for (const [key, entry] of cache.entries()) { - if (isCacheEntryExpired(entry.storedAt, nowMs, ttlMs)) { - cache.delete(key); - } - } - lastPruneAt = nowMs; - } - - return { - get: (key) => { - const ttlMs = getTtlMs(); - if (!isCacheEnabled(ttlMs)) { - return undefined; - } - const nowMs = now(); - maybePruneExpiredEntries(nowMs, ttlMs); - const entry = cache.get(key); - if (!entry) { - return undefined; - } - if (isCacheEntryExpired(entry.storedAt, nowMs, ttlMs)) { - cache.delete(key); - return undefined; - } - return entry.value; - }, - set: (key, value) => { - const ttlMs = getTtlMs(); - if (!isCacheEnabled(ttlMs)) { - return; - } - const nowMs = now(); - maybePruneExpiredEntries(nowMs, ttlMs); - cache.set(key, { - storedAt: nowMs, - value, - }); - }, - delete: (key) => { - cache.delete(key); - }, - clear: () => { - cache.clear(); - lastPruneAt = 0; - }, - keys: () => [...cache.keys()], - size: () => cache.size, - pruneExpired: () => { - const ttlMs = getTtlMs(); - if (!isCacheEnabled(ttlMs)) { - return; - } - const nowMs = now(); - for (const [key, entry] of cache.entries()) { - if (isCacheEntryExpired(entry.storedAt, nowMs, ttlMs)) { - cache.delete(key); - } - } - lastPruneAt = nowMs; - }, - }; -} diff --git a/src/config/sessions.ts b/src/config/sessions.ts index 320c577a7cbb..d111e065e066 100644 --- a/src/config/sessions.ts +++ b/src/config/sessions.ts @@ -1,6 +1,5 @@ // Public facade for session stores, metadata, lifecycle, reset, transcript, and cleanup APIs. export * from "./sessions/combined-store-gateway.js"; -export * from "./sessions/compaction-session-file.js"; export * from "./sessions/group.js"; export * from "./sessions/goals.js"; export * from "./sessions/artifacts.js"; @@ -22,8 +21,6 @@ export * from "./sessions/session-key.js"; export { resolveSessionStoreEntry } from "./sessions/store-entry.js"; export * from "./sessions/types.js"; export * from "./sessions/transcript.js"; -export * from "./sessions/session-file.js"; -export * from "./sessions/session-file-rotation.js"; export * from "./sessions/session-registry-maintenance.js"; export * from "./sessions/session-sharing-store.js"; export * from "./sessions/session-suggestion-store.js"; diff --git a/src/config/sessions/cleanup-service.ts b/src/config/sessions/cleanup-service.ts index e7cbbfca7800..98cb4f31cb7a 100644 --- a/src/config/sessions/cleanup-service.ts +++ b/src/config/sessions/cleanup-service.ts @@ -290,8 +290,14 @@ function pruneMissingTranscriptEntries(params: { ) { continue; } - // Header-only supervised transcripts are valid while their first native turn is pending. - if (parseAgentSessionKey(key) && entry.sessionId === key && !entry.sessionFile) { + const legacySessionFile = (entry as { sessionFile?: unknown }).sessionFile; + // Explicitly pending sessions and their shipped pre-flag shape may not have a first turn yet. + if ( + parseAgentSessionKey(key) && + (entry.initializationPending === true || + (entry.sessionId === key && + (typeof legacySessionFile !== "string" || !legacySessionFile.trim()))) + ) { continue; } if (!entry?.sessionId) { diff --git a/src/config/sessions/compaction-session-file.ts b/src/config/sessions/compaction-session-file.ts deleted file mode 100644 index aea9a526aba7..000000000000 --- a/src/config/sessions/compaction-session-file.ts +++ /dev/null @@ -1,43 +0,0 @@ -// Compaction session-file rotation shares the same owner as session path resolution. -import path from "node:path"; -import { - LEGACY_IMPLICIT_AGENT_ID, - resolveAgentIdFromSessionKey, -} from "../../routing/session-key.js"; -import { resolveSessionFilePath, resolveSessionFilePathOptions } from "./paths.js"; -import { - canonicalizeAbsoluteSessionFilePath, - rewriteSessionFileForNewSessionId, -} from "./session-file-rotation.js"; -import type { SessionEntry } from "./types.js"; - -export function resolveCompactionSessionFile(params: { - entry: SessionEntry; - sessionKey: string; - storePath?: string; - defaultAgentId?: string; - newSessionId: string; -}): string { - const agentId = resolveAgentIdFromSessionKey( - params.sessionKey, - params.defaultAgentId ?? LEGACY_IMPLICIT_AGENT_ID, - ); - const pathOpts = resolveSessionFilePathOptions({ - agentId, - storePath: params.storePath, - }); - const rewrittenSessionFile = rewriteSessionFileForNewSessionId({ - sessionFile: params.entry.sessionFile, - previousSessionId: params.entry.sessionId, - nextSessionId: params.newSessionId, - }); - const normalizedRewrittenSessionFile = - rewrittenSessionFile && path.isAbsolute(rewrittenSessionFile) - ? canonicalizeAbsoluteSessionFilePath(rewrittenSessionFile) - : rewrittenSessionFile; - return resolveSessionFilePath( - params.newSessionId, - normalizedRewrittenSessionFile ? { sessionFile: normalizedRewrittenSessionFile } : undefined, - pathOpts, - ); -} diff --git a/src/config/sessions/entry-freshness.test.ts b/src/config/sessions/entry-freshness.test.ts index 5a31e7c3831b..2ac9694b7c17 100644 --- a/src/config/sessions/entry-freshness.test.ts +++ b/src/config/sessions/entry-freshness.test.ts @@ -1,9 +1,8 @@ -import fs from "node:fs"; import path from "node:path"; import { afterEach, beforeEach, describe, expect, it } from "vitest"; import { cleanupTempDirs, makeTempDir } from "../../../test/helpers/temp-dir.js"; import { resolveSessionEntryResetFreshness } from "./entry-freshness.js"; -import { upsertSessionEntry } from "./session-accessor.js"; +import { appendTranscriptEvent, upsertSessionEntry } from "./session-accessor.js"; const DAY_MS = 24 * 60 * 60 * 1000; @@ -253,28 +252,20 @@ describe("resolveSessionEntryResetFreshness", () => { }); }); - it("uses transcript header startedAt when entry lifecycle metadata is missing", async () => { + it("uses the SQLite transcript header when lifecycle metadata is missing", async () => { const sessionKey = "agent:main:main:thread:header"; + const sessionId = "session-header-fallback"; const now = new Date("2026-01-02T12:00:00Z").getTime(); const headerTimestamp = new Date(now - 2 * DAY_MS).toISOString(); - const transcriptPath = path.join(tempDir, "session-header-fallback.jsonl"); - fs.writeFileSync( - transcriptPath, - `${JSON.stringify({ - type: "session", - id: "session-header-fallback", - timestamp: headerTimestamp, - })}\n`, - "utf-8", - ); - await upsertSessionEntry( - { sessionKey, storePath }, - { - sessionFile: transcriptPath, - sessionId: "session-header-fallback", - updatedAt: now, - }, - ); + const target = { agentId: "main", sessionId, sessionKey, storePath }; + await upsertSessionEntry(target, { sessionId, updatedAt: now }); + await appendTranscriptEvent(target, { + type: "session", + version: 3, + id: sessionId, + timestamp: headerTimestamp, + cwd: tempDir, + }); const result = resolveSessionEntryResetFreshness({ sessionKey, @@ -286,9 +277,6 @@ describe("resolveSessionEntryResetFreshness", () => { expect(result.state).toBe("stale"); expect(result.lifecycleTimestamps.sessionStartedAt).toBe(Date.parse(headerTimestamp)); - expect(result.freshness).toMatchObject({ - fresh: false, - staleReason: "daily", - }); + expect(result.freshness).toMatchObject({ fresh: false, staleReason: "daily" }); }); }); diff --git a/src/config/sessions/entry-freshness.ts b/src/config/sessions/entry-freshness.ts index 5c62cd4747ab..baf16aaf3903 100644 --- a/src/config/sessions/entry-freshness.ts +++ b/src/config/sessions/entry-freshness.ts @@ -77,6 +77,7 @@ export function resolveSessionEntryResetFreshness( const lifecycleTimestamps = resolveSessionLifecycleTimestamps({ entry, agentId, + sessionKey: params.sessionKey, storePath, }); const base = { diff --git a/src/config/sessions/incognito-session-transcript.test.ts b/src/config/sessions/incognito-session-transcript.test.ts index c7da4f051c6f..e40b77130812 100644 --- a/src/config/sessions/incognito-session-transcript.test.ts +++ b/src/config/sessions/incognito-session-transcript.test.ts @@ -4,6 +4,7 @@ import path from "node:path"; import { afterEach, describe, expect, it } from "vitest"; import { SessionManager } from "../../agents/sessions/session-manager.js"; import { closeOpenClawAgentDatabasesForTest } from "../../state/openclaw-agent-db.js"; +import { resolveStorePath } from "./paths.js"; import { createSessionEntryWithTranscript, loadSessionEntry } from "./session-accessor.js"; const sessionKey = "agent:main:dashboard:incognito-round-trip"; @@ -43,7 +44,13 @@ describe("incognito transcript access", () => { ).toBe(true); expect(fs.existsSync(durableStorePath)).toBe(false); - const firstTurn = SessionManager.open(created.sessionFile, cwd, cwd); + const target = { + agentId: "main", + sessionId: created.entry.sessionId, + sessionKey, + storePath: resolveStorePath(undefined, { agentId: "main" }), + }; + const firstTurn = SessionManager.open(target, cwd); firstTurn.appendMessage({ role: "user", content: "first question", timestamp: 1 }); firstTurn.appendMessage({ role: "assistant", @@ -63,7 +70,7 @@ describe("incognito transcript access", () => { timestamp: 2, }); - const secondTurn = SessionManager.open(created.sessionFile, cwd, cwd); + const secondTurn = SessionManager.open(target, cwd); secondTurn.appendMessage({ role: "user", content: "second question", timestamp: 3 }); const messages = secondTurn.buildSessionContext().messages; diff --git a/src/config/sessions/sqlite-marker.ts b/src/config/sessions/legacy-sqlite-marker.ts similarity index 73% rename from src/config/sessions/sqlite-marker.ts rename to src/config/sessions/legacy-sqlite-marker.ts index 9232c612beef..e005eb536be4 100644 --- a/src/config/sessions/sqlite-marker.ts +++ b/src/config/sessions/legacy-sqlite-marker.ts @@ -1,4 +1,4 @@ -/** SQLite transcript sessionFile marker helpers shared by session runtime readers. */ +/** Legacy marker codec retained only for artifacts, migration, and plugin SDK compatibility. */ import path from "node:path"; export type SqliteSessionFileMarker = { @@ -9,12 +9,10 @@ export type SqliteSessionFileMarker = { const SQLITE_SESSION_FILE_MARKER_RE = /^sqlite:([^:]+):([^:]+):(.*)$/; -/** Formats the canonical sessionFile marker for SQLite-backed transcripts. */ export function formatSqliteSessionFileMarker(marker: SqliteSessionFileMarker): string { return `sqlite:${marker.agentId}:${marker.sessionId}:${path.resolve(marker.storePath)}`; } -/** Parses a SQLite-backed transcript sessionFile marker. */ export function parseSqliteSessionFileMarker( sessionFile: string | undefined, ): SqliteSessionFileMarker | undefined { @@ -26,14 +24,9 @@ export function parseSqliteSessionFileMarker( if (!match?.[1] || !match[2] || !match[3]) { return undefined; } - return { - agentId: match[1], - sessionId: match[2], - storePath: match[3], - }; + return { agentId: match[1], sessionId: match[2], storePath: match[3] }; } -/** Checks whether a sessionFile marker points at the expected session id. */ export function sqliteSessionFileMarkerMatchesSession( sessionFile: string | undefined, sessionId: string, @@ -41,7 +34,6 @@ export function sqliteSessionFileMarkerMatchesSession( return parseSqliteSessionFileMarker(sessionFile)?.sessionId === sessionId; } -/** Checks whether a sessionFile marker points at the full expected transcript target. */ export function sqliteSessionFileMarkerMatchesTarget( sessionFile: string | undefined, target: SqliteSessionFileMarker, diff --git a/src/config/sessions/lifecycle.test.ts b/src/config/sessions/lifecycle.test.ts index 6d9028c25f5a..80dc6e1ab18e 100644 --- a/src/config/sessions/lifecycle.test.ts +++ b/src/config/sessions/lifecycle.test.ts @@ -10,6 +10,7 @@ import { closeOpenClawStateDatabaseForTest } from "../../state/openclaw-state-db import { hasTerminalMainSessionTranscriptNewerThanRegistry, hasTerminalMainSessionTranscriptNewerThanRegistrySync, + resolveSessionLifecycleTimestamps, } from "./lifecycle.js"; import { appendTranscriptEvent, loadSessionEntry, upsertSessionEntry } from "./session-accessor.js"; import type { SessionEntry } from "./types.js"; @@ -131,6 +132,23 @@ describe("terminal main session transcript freshness", () => { expect(check(entry, sessionKey)).toBe(true); }); + it("reads the transcript header when the caller has no session key", async () => { + const sessionId = "session-header-without-key"; + const timestamp = "2026-01-01T12:00:00.000Z"; + await appendTranscriptEvent( + { agentId: "main", sessionId, sessionKey: "agent:main:header", storePath }, + { type: "session", version: 3, id: sessionId, timestamp, cwd: stateDir }, + ); + + expect( + resolveSessionLifecycleTimestamps({ + agentId: "main", + entry: { sessionId, updatedAt: Date.now() }, + storePath, + }).sessionStartedAt, + ).toBe(Date.parse(timestamp)); + }); + it("does not rotate after a same-millisecond registry write observes the mutation", async () => { const now = 1_700_000_000_000; const dateNow = vi.spyOn(Date, "now").mockReturnValue(now); diff --git a/src/config/sessions/lifecycle.ts b/src/config/sessions/lifecycle.ts index 74111034c4c4..4d85c821a262 100644 --- a/src/config/sessions/lifecycle.ts +++ b/src/config/sessions/lifecycle.ts @@ -1,19 +1,13 @@ // Session lifecycle timestamps prefer store metadata and fall back to transcript headers. -import fs from "node:fs"; -import { readFileWindowFullySync } from "../../infra/file-read.js"; +import { resolveAgentIdFromSessionKey } from "../../routing/session-key.js"; import { asDateTimestampMs } from "../../shared/number-coercion.js"; import { canonicalizeMainSessionAlias } from "./main-session.js"; -import { - resolveSessionFilePath, - resolveSessionFilePathOptions, - type SessionFilePathOptions, -} from "./paths.js"; -import { readTranscriptStatsSync } from "./session-accessor.js"; +import { loadTranscriptHeaderSync, readTranscriptStatsSync } from "./session-accessor.js"; import { isTerminalSessionStatus, type SessionEntry, type SessionScope } from "./types.js"; type SessionLifecycleEntry = Pick< SessionEntry, - "sessionId" | "sessionFile" | "sessionStartedAt" | "lastInteractionAt" | "updatedAt" + "sessionId" | "sessionStartedAt" | "lastInteractionAt" | "updatedAt" >; type SessionWorkStartEntry = Pick< @@ -103,67 +97,33 @@ function parseTimestampMs(value: unknown): number | undefined { if (typeof value !== "string" || !value.trim()) { return undefined; } - const parsed = Date.parse(value); - return resolveTimestamp(parsed); + return resolveTimestamp(Date.parse(value)); } -function readFirstLine(filePath: string): string | undefined { - try { - const fd = fs.openSync(filePath, "r"); - try { - const buffer = Buffer.alloc(8192); - const bytesRead = readFileWindowFullySync(fd, buffer, 0); - if (bytesRead <= 0) { - return undefined; - } - const chunk = buffer.subarray(0, bytesRead).toString("utf8"); - const newline = chunk.indexOf("\n"); - return newline >= 0 ? chunk.slice(0, newline) : chunk; - } finally { - fs.closeSync(fd); - } - } catch { - return undefined; - } -} - -/** Reads session start time from a transcript header when store metadata is missing. */ function readSessionHeaderStartedAtMs(params: { - entry: SessionLifecycleEntry | undefined; + entry: SessionLifecycleEntry; agentId?: string; + sessionKey?: string; storePath?: string; - pathOptions?: SessionFilePathOptions; }): number | undefined { - const sessionId = params.entry?.sessionId?.trim(); - if (!sessionId) { - return undefined; - } - const pathOptions = - params.pathOptions ?? - resolveSessionFilePathOptions({ - agentId: params.agentId, - storePath: params.storePath, - }); - let sessionFile: string; - try { - sessionFile = resolveSessionFilePath(sessionId, params.entry, pathOptions); - } catch { - return undefined; - } - const firstLine = readFirstLine(sessionFile); - if (!firstLine) { + const sessionId = params.entry.sessionId?.trim(); + const sessionKey = params.sessionKey?.trim(); + const agentId = + params.agentId ?? (sessionKey ? resolveAgentIdFromSessionKey(sessionKey) : undefined); + if (!sessionId || !agentId) { return undefined; } try { - const header = JSON.parse(firstLine) as { - type?: unknown; - id?: unknown; - timestamp?: unknown; - }; - if (header.type !== "session") { - return undefined; - } - if (typeof header.id === "string" && header.id.trim() && header.id !== sessionId) { + const header = loadTranscriptHeaderSync({ + agentId, + sessionId, + ...(params.storePath ? { storePath: params.storePath } : {}), + ...(sessionKey ? { sessionKey } : {}), + }) as { type?: unknown; id?: unknown; timestamp?: unknown } | undefined; + if ( + header?.type !== "session" || + (typeof header.id === "string" && header.id.trim() && header.id !== sessionId) + ) { return undefined; } return parseTimestampMs(header.timestamp); @@ -175,8 +135,8 @@ function readSessionHeaderStartedAtMs(params: { export function resolveSessionLifecycleTimestamps(params: { entry: SessionLifecycleEntry | undefined; agentId?: string; + sessionKey?: string; storePath?: string; - pathOptions?: SessionFilePathOptions; }): { sessionStartedAt?: number; lastInteractionAt?: number } { const entry = params.entry; if (!entry) { @@ -185,12 +145,7 @@ export function resolveSessionLifecycleTimestamps(params: { return { sessionStartedAt: resolveTimestamp(entry.sessionStartedAt) ?? - readSessionHeaderStartedAtMs({ - entry, - agentId: params.agentId, - storePath: params.storePath, - pathOptions: params.pathOptions, - }), + readSessionHeaderStartedAtMs({ ...params, entry }), lastInteractionAt: resolveTimestamp(entry.lastInteractionAt), }; } diff --git a/src/config/sessions/paths.sqlite-marker.test.ts b/src/config/sessions/paths.sqlite-marker.test.ts deleted file mode 100644 index 3d25151e9074..000000000000 --- a/src/config/sessions/paths.sqlite-marker.test.ts +++ /dev/null @@ -1,101 +0,0 @@ -// SQLite transcript markers are storage targets, not filesystem paths. -import { describe, expect, it } from "vitest"; -import { resolveSessionFilePath } from "./paths.js"; -import { loadSessionEntry, upsertSessionEntry } from "./session-accessor.js"; -import { resolveAndPersistSessionFile } from "./session-file.js"; -import { sqliteSessionFileMarkerMatchesTarget } from "./sqlite-marker.js"; -import { useTempSessionsFixture } from "./test-helpers.js"; - -describe("SQLite sessionFile markers", () => { - const fixture = useTempSessionsFixture("sqlite-session-file-marker-"); - - it("preserves SQLite markers for transcript target resolution", () => { - const marker = "sqlite:main:sess-1:/tmp/openclaw/agents/main/agent/openclaw-agent.sqlite"; - const sessionsDir = "/tmp/openclaw/agents/main/sessions"; - - const resolved = resolveSessionFilePath("sess-1", { sessionFile: marker }, { sessionsDir }); - - expect(resolved).toBe(marker); - }); - - it("matches SQLite markers against the full transcript target", () => { - const marker = - "sqlite:main:sess-1:/tmp/openclaw/agents/main/agent/../agent/openclaw-agent.sqlite"; - const target = { - agentId: "main", - sessionId: "sess-1", - storePath: "/tmp/openclaw/agents/main/agent/openclaw-agent.sqlite", - }; - - expect(sqliteSessionFileMarkerMatchesTarget(marker, target)).toBe(true); - expect(sqliteSessionFileMarkerMatchesTarget(marker, { ...target, agentId: "other" })).toBe( - false, - ); - expect(sqliteSessionFileMarkerMatchesTarget(marker, { ...target, sessionId: "sess-2" })).toBe( - false, - ); - expect( - sqliteSessionFileMarkerMatchesTarget(marker, { - ...target, - storePath: "/tmp/openclaw/agents/other/agent/openclaw-agent.sqlite", - }), - ).toBe(false); - }); - - it("does not downgrade matching SQLite markers when resolving runtime session files", async () => { - const sessionId = "sqlite-session-id"; - const sessionKey = "agent:main:telegram:group:123"; - const marker = `sqlite:main:${sessionId}:${fixture.storePath()}`; - const store = { - [sessionKey]: { - sessionId, - updatedAt: Date.now(), - sessionFile: marker, - }, - }; - await upsertSessionEntry({ storePath: fixture.storePath(), sessionKey }, store[sessionKey]); - - const result = await resolveAndPersistSessionFile({ - sessionId, - sessionKey, - sessionStore: store, - storePath: fixture.storePath(), - sessionEntry: store[sessionKey], - }); - - expect(result.sessionFile).toBe(marker); - expect(result.sessionEntry.sessionFile).toBe(marker); - - expect(loadSessionEntry({ storePath: fixture.storePath(), sessionKey })?.sessionFile).toBe( - marker, - ); - }); - - it("does not preserve persisted markers for a different session", async () => { - const sessionId = "current-sqlite-session-id"; - const sessionKey = "agent:main:telegram:group:456"; - const staleMarker = `sqlite:main:old-sqlite-session-id:${fixture.storePath()}`; - const store = { - [sessionKey]: { - sessionId, - updatedAt: Date.now(), - sessionFile: staleMarker, - }, - }; - - const result = await resolveAndPersistSessionFile({ - sessionId, - sessionKey, - sessionStore: store, - storePath: fixture.storePath(), - sessionEntry: store[sessionKey], - }); - - const expectedSessionFile = `sqlite:main:${sessionId}:${fixture.storePath()}`; - expect(result.sessionFile).toBe(expectedSessionFile); - expect(result.sessionEntry.sessionFile).toBe(result.sessionFile); - expect(loadSessionEntry({ storePath: fixture.storePath(), sessionKey })?.sessionFile).toBe( - expectedSessionFile, - ); - }); -}); diff --git a/src/config/sessions/paths.ts b/src/config/sessions/paths.ts index d60cccdddd13..1b760a4e7eb3 100644 --- a/src/config/sessions/paths.ts +++ b/src/config/sessions/paths.ts @@ -33,7 +33,7 @@ export function resolveDefaultSessionStorePath(agentId: string): string { return path.join(resolveAgentSessionsDir(agentId), "sessions.json"); } -export type SessionFilePathOptions = { +type SessionFilePathOptions = { agentId?: string; sessionsDir?: string; }; @@ -295,11 +295,14 @@ export function resolveSessionTranscriptPath( } export function resolveSessionFilePath( sessionId: string, - entry?: { sessionFile?: string }, + entry?: object, opts?: SessionFilePathOptions, ): string { const sessionsDir = resolveSessionsDir(opts); - const candidate = entry?.sessionFile?.trim(); + const candidate = + entry && "sessionFile" in entry && typeof entry.sessionFile === "string" + ? entry.sessionFile.trim() + : undefined; if (candidate) { if (candidate.startsWith(SQLITE_TRANSCRIPT_TARGET_PREFIX)) { return candidate; diff --git a/src/config/sessions/plugin-host-cleanup.ts b/src/config/sessions/plugin-host-cleanup.ts index d68a1d1c12f9..0db1dd49c2e9 100644 --- a/src/config/sessions/plugin-host-cleanup.ts +++ b/src/config/sessions/plugin-host-cleanup.ts @@ -71,7 +71,7 @@ function clearPromotedSessionEntrySlots( options.includeStoredSlotKeys === false && sessionEntrySlotKeys ? new Set(sessionEntrySlotKeys) : collectPromotedSessionEntrySlotKeys(entry, pluginId, sessionEntrySlotKeys); - const entryRecord = entry as Record; + const entryRecord = entry as unknown as Record; for (const slotKey of slotKeys) { delete entryRecord[slotKey]; } @@ -152,7 +152,7 @@ function hasPromotedSessionEntrySlot( if (slotKeys.size === 0) { return false; } - const entryRecord = entry as Record; + const entryRecord = entry as unknown as Record; for (const slotKey of slotKeys) { if (Object.hasOwn(entryRecord, slotKey)) { return true; diff --git a/src/config/sessions/session-accessor.conformance.test.ts b/src/config/sessions/session-accessor.conformance.test.ts index db86a1ddb749..c5fb46cba607 100644 --- a/src/config/sessions/session-accessor.conformance.test.ts +++ b/src/config/sessions/session-accessor.conformance.test.ts @@ -65,7 +65,6 @@ import { restoreSqliteCompactionCheckpointSession, upsertSqliteSessionEntry, } from "./session-accessor.sqlite.js"; -import { parseSqliteSessionFileMarker } from "./sqlite-marker.js"; import type { SessionCompactionCheckpoint, SessionEntry } from "./types.js"; // Keep accessor conformance independent of any real openclaw.json on the machine. @@ -670,16 +669,16 @@ describe.each([publicAccessorAdapter, sqliteAdapter])( ...scope, sessionId: "support-session", }); - const marker = parseSqliteSessionFileMarker(runtimeTarget.sessionFile); expect(loadSqliteSessionEntry({ ...scope, storePath: customSqlitePath })).toMatchObject({ model: "gpt-5.5", sessionId: "support-session", }); expect(fs.existsSync(customSqlitePath)).toBe(true); - expect(marker).toMatchObject({ + expect(runtimeTarget).toMatchObject({ agentId: "support", sessionId: "support-session", + sessionKey: scope.sessionKey, storePath: customStorePath, }); }); @@ -2049,7 +2048,6 @@ describe("sqlite session normalization", () => { expect.objectContaining({ label: "Source (checkpoint)", parentSessionKey: sourceEntryScope.sessionKey, - sessionFile: expect.stringMatching(/^sqlite:main:/), totalTokens: 42, totalTokensFresh: true, }), @@ -2179,7 +2177,6 @@ describe("sqlite session normalization", () => { await upsertSqliteSessionEntry(sourceEntryScope, { label: "Current", sessionId: "current-session", - sessionFile: "sqlite:main:current-session", updatedAt: 10, compactionCheckpoints: [checkpoint], }); @@ -2204,7 +2201,6 @@ describe("sqlite session normalization", () => { expect.objectContaining({ label: "Current", compactionCheckpoints: [checkpoint], - sessionFile: expect.stringMatching(/^sqlite:main:/), totalTokens: 12, totalTokensFresh: true, }), diff --git a/src/config/sessions/session-accessor.entry-mutation.ts b/src/config/sessions/session-accessor.entry-mutation.ts index 2cc03c7624df..fac1c3574e7f 100644 --- a/src/config/sessions/session-accessor.entry-mutation.ts +++ b/src/config/sessions/session-accessor.entry-mutation.ts @@ -47,7 +47,6 @@ import { resolveFreshestTargetEntry, } from "./session-entry-selection.js"; import { projectSessionStoreForPersistence } from "./skill-prompt-blobs.js"; -import { formatSqliteSessionFileMarker } from "./sqlite-marker.js"; import { normalizeStoreSessionKey } from "./store-entry.js"; import { createSessionTranscriptHeader } from "./transcript-header.js"; import type { GroupKeyResolution, SessionEntry } from "./types.js"; @@ -151,7 +150,7 @@ export async function createSessionEntryWithTranscript( ) => | Promise> | SessionEntryCreateWithTranscriptPrepareResult, - _options: SessionEntryCreateWithTranscriptOptions = {}, + options: SessionEntryCreateWithTranscriptOptions = {}, ): Promise> { const storePath = resolveAccessStorePath(scope); const agentId = scope.agentId ?? resolveAgentIdFromSessionKey(scope.sessionKey); @@ -167,11 +166,6 @@ export async function createSessionEntryWithTranscript( return { ok: false, error: created.error, phase: "entry" }; } - const sessionFile = formatSqliteSessionFileMarker({ - agentId, - sessionId: created.entry.sessionId, - storePath, - }); try { await appendSqliteTranscriptEvent( { @@ -180,7 +174,7 @@ export async function createSessionEntryWithTranscript( sessionKey: resolved.normalizedKey, storePath, }, - createSessionTranscriptHeader({ sessionId: created.entry.sessionId }), + createSessionTranscriptHeader({ cwd: options.cwd, sessionId: created.entry.sessionId }), ); } catch (err) { return { @@ -190,13 +184,7 @@ export async function createSessionEntryWithTranscript( }; } - const entry = - created.entry.sessionFile === sessionFile - ? created.entry - : { - ...created.entry, - sessionFile, - }; + const entry = created.entry; await applySessionEntryLifecycleMutation({ agentId, storePath, @@ -204,7 +192,7 @@ export async function createSessionEntryWithTranscript( upserts: [{ sessionKey: resolved.normalizedKey, entry }], skipMaintenance: true, }); - return { ok: true, entry, sessionFile }; + return { ok: true, entry, sessionFile: resolved.normalizedKey }; } export function cloneSessionEntries( @@ -315,13 +303,7 @@ export function createReplySessionInitializationRevision(params: { // activity/context writes are merged below; comparing them here would reject // before the merge can preserve the concurrent metadata. const projected = projectSessionEntryForPersistenceRevision({ storePath, entry }); - const revisionEntry: Pick = { - sessionId: projected.sessionId, - }; - if (projected.sessionFile !== undefined) { - revisionEntry.sessionFile = projected.sessionFile; - } - return JSON.stringify(revisionEntry); + return JSON.stringify({ sessionId: projected.sessionId }); } export function resolveInitializedReplySessionEntry(params: { @@ -330,15 +312,7 @@ export function resolveInitializedReplySessionEntry(params: { sessionEntry: SessionEntry; storePath: string; }): SessionEntry { - const sessionFile = formatSqliteSessionFileMarker({ - agentId: params.agentId, - sessionId: params.sessionEntry.sessionId, - storePath: params.storePath, - }); - return { - ...params.sessionEntry, - sessionFile, - }; + return params.sessionEntry; } /** Updates an existing entry only; returns null when the session is absent. */ diff --git a/src/config/sessions/session-accessor.parent-fork.test.ts b/src/config/sessions/session-accessor.parent-fork.test.ts index 9433a9e165e3..ae97f3cf931c 100644 --- a/src/config/sessions/session-accessor.parent-fork.test.ts +++ b/src/config/sessions/session-accessor.parent-fork.test.ts @@ -5,13 +5,13 @@ import path from "node:path"; import type { AssistantMessage } from "openclaw/plugin-sdk/llm"; import { afterEach, describe, expect, it } from "vitest"; import { SessionManager } from "../../agents/sessions/session-manager.js"; +import { parseSqliteSessionFileMarker } from "./legacy-sqlite-marker.js"; import { forkSessionFromParentTranscript, loadTranscriptEvents, replaceSessionEntry, replaceTranscriptEvents, } from "./session-accessor.js"; -import { formatSqliteSessionFileMarker, parseSqliteSessionFileMarker } from "./sqlite-marker.js"; const roots: string[] = []; @@ -56,6 +56,15 @@ async function persistChildEntry(params: { ); } +function openForkedChildSession(storePath: string, sessionId: string): SessionManager { + return SessionManager.open({ + agentId: "main", + sessionId, + sessionKey: "agent:main:child", + storePath, + }); +} + afterEach(async () => { await Promise.all(roots.splice(0).map((root) => fs.rm(root, { recursive: true, force: true }))); }); @@ -139,7 +148,7 @@ describe("forkSessionFromParentTranscript", () => { throw new Error("Expected forked session"); } const fork = forked.transcript; - expect(fork.sessionFile).toContain(sessionsDir); + expect(fork.sessionFile).toBe("agent:main:child"); expect(fork.sessionId).not.toBe(parentSessionId); const forkedEntries = (await loadTranscriptEvents({ agentId: "main", @@ -147,16 +156,18 @@ describe("forkSessionFromParentTranscript", () => { sessionKey: "agent:main:child", storePath, })) as Record[]; - const expectedParentSessionFile = formatSqliteSessionFileMarker({ - agentId: "main", - sessionId: parentSessionId, - storePath: parseSqliteSessionFileMarker(fork.sessionFile)!.storePath, - }); const forkedHeader = forkedEntries[0]; expect(forkedHeader?.type).toBe("session"); expect(forkedHeader?.id).toBe(fork.sessionId); expect(forkedHeader?.cwd).toBe(cwd); - expect(forkedHeader?.parentSession).toBe(expectedParentSessionFile); + expect( + parseSqliteSessionFileMarker( + typeof forkedHeader?.parentSession === "string" ? forkedHeader.parentSession : undefined, + ), + ).toMatchObject({ + agentId: "main", + sessionId: parentSessionId, + }); expect(forkedEntries.map((entry) => entry.type)).toEqual([ "session", "message", @@ -276,7 +287,7 @@ describe("forkSessionFromParentTranscript", () => { sessionFile: fork.sessionFile, sessionId: fork.sessionId, }); - const reopened = SessionManager.open(fork.sessionFile, sessionsDir); + const reopened = openForkedChildSession(storePath, fork.sessionId); reopened.appendMessage({ role: "user", content: "continued", timestamp: Date.now() }); const records = (await loadTranscriptEvents({ agentId: "main", @@ -357,7 +368,7 @@ describe("forkSessionFromParentTranscript", () => { sessionFile: fork.sessionFile, sessionId: fork.sessionId, }); - const reopened = SessionManager.open(fork.sessionFile, sessionsDir); + const reopened = openForkedChildSession(storePath, fork.sessionId); expect(reopened.buildSessionContext().messages).toHaveLength(2); reopened.appendMessage({ role: "user", content: "continued", timestamp: Date.now() }); const records = (await loadTranscriptEvents({ @@ -433,7 +444,7 @@ describe("forkSessionFromParentTranscript", () => { sessionFile: fork.sessionFile, sessionId: fork.sessionId, }); - const reopened = SessionManager.open(fork.sessionFile, sessionsDir); + const reopened = openForkedChildSession(storePath, fork.sessionId); expect(reopened.buildSessionContext().messages).toEqual([]); const continuedId = reopened.appendMessage({ role: "user", @@ -567,7 +578,7 @@ describe("forkSessionFromParentTranscript", () => { sessionFile: fork.sessionFile, sessionId: fork.sessionId, }); - const reopened = SessionManager.open(fork.sessionFile, sessionsDir); + const reopened = openForkedChildSession(storePath, fork.sessionId); expect(reopened.buildSessionContext().messages).toHaveLength(1); reopened.appendMessage({ role: "user", content: "new root", timestamp: Date.now() }); const records = (await loadTranscriptEvents({ @@ -647,7 +658,7 @@ describe("forkSessionFromParentTranscript", () => { sessionFile: fork.sessionFile, sessionId: fork.sessionId, }); - const reopened = SessionManager.open(fork.sessionFile, sessionsDir); + const reopened = openForkedChildSession(storePath, fork.sessionId); expect(reopened.buildSessionContext().messages).toHaveLength(2); reopened.appendMessage({ role: "user", content: "continued", timestamp: Date.now() }); const continuedRecords = (await loadTranscriptEvents({ @@ -704,14 +715,16 @@ describe("forkSessionFromParentTranscript", () => { storePath, })) as Record[]; expect(records).toHaveLength(1); - const expectedParentSessionFile = formatSqliteSessionFileMarker({ - agentId: "main", - sessionId: parentSessionId, - storePath: parseSqliteSessionFileMarker(fork.sessionFile)!.storePath, - }); const header = records[0]; expect(header?.type).toBe("session"); expect(header?.id).toBe(fork.sessionId); - expect(header?.parentSession).toBe(expectedParentSessionFile); + expect( + parseSqliteSessionFileMarker( + typeof header?.parentSession === "string" ? header.parentSession : undefined, + ), + ).toMatchObject({ + agentId: "main", + sessionId: parentSessionId, + }); }); }); diff --git a/src/config/sessions/session-accessor.reset.ts b/src/config/sessions/session-accessor.reset.ts index a9a4b38793f3..3965d7285aa5 100644 --- a/src/config/sessions/session-accessor.reset.ts +++ b/src/config/sessions/session-accessor.reset.ts @@ -278,9 +278,7 @@ export async function commitReplySessionInitialization(params: { isIncognitoSessionKey(params.sessionKey) || params.previousEntry?.incognito === true ? {} : params.archivePreviousTranscript === false - ? params.previousEntry?.sessionFile - ? { sessionFile: params.previousEntry.sessionFile, transcriptArchived: false } - : {} + ? {} : await archivePreviousSessionTranscript({ agentId: params.agentId, onArchiveError: params.onArchiveError, @@ -307,7 +305,6 @@ async function archivePreviousSessionTranscript(params: { const archivedTranscripts = archiveSessionTranscriptsDetailed({ sessionId: params.previousEntry.sessionId, storePath: params.storePath, - sessionFile: params.previousEntry.sessionFile, agentId: params.agentId, reason: "reset", onArchiveError: params.onArchiveError, @@ -315,7 +312,6 @@ async function archivePreviousSessionTranscript(params: { return resolveStableSessionEndTranscript({ sessionId: params.previousEntry.sessionId, storePath: params.storePath, - sessionFile: params.previousEntry.sessionFile, agentId: params.agentId, archivedTranscripts, }); diff --git a/src/config/sessions/session-accessor.sqlite-active-events.test.ts b/src/config/sessions/session-accessor.sqlite-active-events.test.ts index 9d2e552253d6..3d35af06e782 100644 --- a/src/config/sessions/session-accessor.sqlite-active-events.test.ts +++ b/src/config/sessions/session-accessor.sqlite-active-events.test.ts @@ -12,6 +12,8 @@ import { appendTranscriptEvent, persistSessionTranscriptTurn } from "./session-a import { readRecentSessionTranscriptMessageEvents, readSessionTranscriptActiveLeafEvents, + readSessionTranscriptActiveStats, + readSessionTranscriptBoundedMessageTailPage, readSessionTranscriptMessageAnchorPage, readSessionTranscriptMessageEventById, readSessionTranscriptMessageEventCount, @@ -132,6 +134,24 @@ describe("SQLite active transcript event projection", () => { { active_position: 0, event_seq: 1, message_position: 0 }, { active_position: 1, event_seq: 3, message_position: 1 }, ]); + + const activeRows = database.db + .prepare( + `SELECT event.event_json + FROM session_transcript_active_events AS active + JOIN transcript_events AS event + ON event.session_id = active.session_id AND event.seq = active.event_seq + WHERE active.session_id = ? + ORDER BY active.active_position`, + ) + .all(scope.sessionId) as Array<{ event_json: string }>; + expect(readSessionTranscriptActiveStats(scope)).toEqual({ + eventCount: activeRows.length, + sizeBytes: activeRows.reduce( + (total, row) => total + Buffer.byteLength(row.event_json, "utf8") + 1, + 0, + ), + }); }); it("defers mixed legacy and canonical rebuilds off request stacks", async () => { @@ -182,6 +202,30 @@ describe("SQLite active transcript event projection", () => { ).toEqual({ active_message_count: 1, needs_rebuild: 0 }); }); + it("skips oversized tail rows before materializing a bounded message page", async () => { + await persistSessionTranscriptTurn(scope, { + messages: [ + { eventId: "small", parentId: null, message: { role: "user", content: "keep" } }, + { + eventId: "oversized", + parentId: "small", + message: { role: "assistant", content: "x".repeat(16_384) }, + }, + ], + touchSessionEntry: false, + }); + + const page = readSessionTranscriptBoundedMessageTailPage(scope, { + maxBytes: 512, + maxMessages: Number.MAX_SAFE_INTEGER, + offset: 0, + }); + + expect(page.scannedMessages).toBe(2); + expect(page.serializedBytes).toBeLessThanOrEqual(512); + expect(page.events.map(({ event }) => (event as { id?: unknown }).id)).toEqual(["small"]); + }); + it("fails fast and schedules maintenance when out-of-band state is dirty", async () => { await persistSessionTranscriptTurn(scope, { messages: [ diff --git a/src/config/sessions/session-accessor.sqlite-active-events.ts b/src/config/sessions/session-accessor.sqlite-active-events.ts index c75ce9321a5d..08c34576ef96 100644 --- a/src/config/sessions/session-accessor.sqlite-active-events.ts +++ b/src/config/sessions/session-accessor.sqlite-active-events.ts @@ -1,5 +1,3 @@ -// Bounded reads over the materialized active transcript path. Dirty paths -// schedule maintenance and fail fast; clean reads deserialize selected rows. import { sql } from "kysely"; import { executeSqliteQuerySync, @@ -20,17 +18,31 @@ import type { } from "./session-accessor.sqlite-contract.js"; import { readVisibleMessageRange, + resolveVisibleMessagePositionRange, resolveVisibleMessagePositions, } from "./session-accessor.sqlite-reset-window.js"; import { resolveSqliteTranscriptReadScope, toDatabaseOptions, } from "./session-accessor.sqlite-scope.js"; -import type { SessionTranscriptProjectionState } from "./session-transcript-index.js"; import { - startSessionTranscriptIndexReconcile, - waitForSessionTranscriptIndexReconcile, -} from "./session-transcript-reconcile.js"; + DEFAULT_VISIBLE_MESSAGE_MAX_BYTES, + DEFAULT_VISIBLE_MESSAGE_MAX_MESSAGES, + createVisibleMessageCursor, + encodeVisibleMessageCursor, + MAX_VISIBLE_MESSAGE_MAX_BYTES, + MAX_VISIBLE_MESSAGE_MAX_MESSAGES, + normalizeVisibleMessageLimit, + parseVisibleMessageCursor, +} from "./session-accessor.sqlite-visible-cursor.js"; +import type { SessionTranscriptProjectionState } from "./session-transcript-index.js"; +import { SessionTranscriptProjectionUnavailableError } from "./session-transcript-projection-error.js"; +import { startSessionTranscriptIndexReconcile } from "./session-transcript-reconcile.js"; +export { waitForSessionTranscriptProjection } from "./session-transcript-reconcile.js"; +export { + isSessionTranscriptProjectionUnavailableError, + SessionTranscriptProjectionUnavailableError, +} from "./session-transcript-projection-error.js"; type ActiveTranscriptDatabase = Pick< OpenClawAgentKyselyDatabase, @@ -41,21 +53,6 @@ type ActiveTranscriptDatabase = Pick< | "transcript_events" >; -const VISIBLE_MESSAGE_CURSOR_VERSION = 1; -const DEFAULT_VISIBLE_MESSAGE_MAX_MESSAGES = 1_000; -const DEFAULT_VISIBLE_MESSAGE_MAX_BYTES = 1_000_000; -const MAX_VISIBLE_MESSAGE_MAX_MESSAGES = 10_000; -const MAX_VISIBLE_MESSAGE_MAX_BYTES = 64 * 1024 * 1024; - -type VisibleMessageCursor = { - agentId: string; - generation: string; - lastEventSeq: number; - lastMessagePosition: number; - sessionId: string; - version: typeof VISIBLE_MESSAGE_CURSOR_VERSION; -}; - export type SessionTranscriptMessageEvent = { event: TranscriptEvent; seq: number; @@ -73,26 +70,10 @@ export type SessionTranscriptMessageAnchorPage = SessionTranscriptMessageEventPa offset: number; }; -export class SessionTranscriptProjectionUnavailableError extends Error { - constructor(readonly sessionId: string) { - super(`Session transcript projection is rebuilding: ${sessionId}`); - this.name = "SessionTranscriptProjectionUnavailableError"; - } -} - -export function isSessionTranscriptProjectionUnavailableError( - error: unknown, -): error is SessionTranscriptProjectionUnavailableError { - return error instanceof SessionTranscriptProjectionUnavailableError; -} - -/** Waits for a projection rebuild already scheduled by a failed transcript read. */ -export async function waitForSessionTranscriptProjection( - scope: SessionTranscriptReadScope, -): Promise { - const resolved = resolveSqliteTranscriptReadScope(scope); - await waitForSessionTranscriptIndexReconcile(toDatabaseOptions(resolved)); -} +export type SessionTranscriptBoundedMessageTailPage = SessionTranscriptMessageEventPage & { + scannedMessages: number; + serializedBytes: number; +}; type CurrentProjection = { database: OpenClawAgentDatabase; @@ -112,66 +93,6 @@ function getActiveTranscriptKysely(database: OpenClawAgentDatabase) { return getNodeSqliteKysely(database.db); } -function normalizeVisibleMessageLimit( - value: number | undefined, - fallback: number, - maximum: number, - name: string, -): number { - const resolved = value ?? fallback; - if (!Number.isInteger(resolved) || resolved < 1 || resolved > maximum) { - throw new RangeError(`${name} must be an integer between 1 and ${String(maximum)}`); - } - return resolved; -} - -function encodeVisibleMessageCursor(cursor: VisibleMessageCursor): string { - return Buffer.from(JSON.stringify(cursor), "utf8").toString("base64url"); -} - -function parseVisibleMessageCursor(value: string): VisibleMessageCursor | undefined { - // The cursor is a continuation hint, not an authorization token. Every field - // is revalidated against the current scope, generation, and projection. - if (value.length > 4_096) { - return undefined; - } - try { - const parsed = JSON.parse( - Buffer.from(value, "base64url").toString("utf8"), - ) as Partial; - if ( - parsed.version !== VISIBLE_MESSAGE_CURSOR_VERSION || - typeof parsed.agentId !== "string" || - typeof parsed.sessionId !== "string" || - typeof parsed.generation !== "string" || - !Number.isSafeInteger(parsed.lastEventSeq) || - (parsed.lastEventSeq ?? -2) < -1 || - !Number.isSafeInteger(parsed.lastMessagePosition) || - (parsed.lastMessagePosition ?? -2) < -1 || - (parsed.lastEventSeq === -1) !== (parsed.lastMessagePosition === -1) - ) { - return undefined; - } - return parsed as VisibleMessageCursor; - } catch { - return undefined; - } -} - -function bootstrapVisibleMessageCursor( - projection: CurrentProjection, - generation: string, -): VisibleMessageCursor { - return { - agentId: projection.resolved.agentId, - generation, - lastEventSeq: -1, - lastMessagePosition: -1, - sessionId: projection.resolved.sessionId, - version: VISIBLE_MESSAGE_CURSOR_VERSION, - }; -} - function readProjectionSnapshot( database: OpenClawAgentDatabase, sessionId: string, @@ -314,6 +235,67 @@ export function readSessionTranscriptActiveLeafEvents( }); } +/** Reads a bounded tail from the materialized active path, including control events. */ +export function readRecentSessionTranscriptActiveEvents( + scope: SessionTranscriptReadScope, + maxEvents: number, +): TranscriptEvent[] { + return withCurrentProjectionSnapshot(scope, (projection) => { + const limit = Math.max(0, Math.floor(Number.isFinite(maxEvents) ? maxEvents : 0)); + if (limit === 0) { + return []; + } + const db = getActiveTranscriptKysely(projection.database); + return executeSqliteQuerySync( + projection.database.db, + db + .selectFrom("session_transcript_active_events as active") + .innerJoin("transcript_events as event", (join) => + join + .onRef("event.session_id", "=", "active.session_id") + .onRef("event.seq", "=", "active.event_seq"), + ) + .select("event.event_json") + .where("active.session_id", "=", projection.resolved.sessionId) + .orderBy("active.active_position", "desc") + .limit(limit), + ) + .rows.toReversed() + .map((row) => JSON.parse(row.event_json) as TranscriptEvent); + }); +} + +/** Reads active-path event count and JSONL byte size without materializing payloads. */ +export function readSessionTranscriptActiveStats(scope: SessionTranscriptReadScope): { + eventCount: number; + sizeBytes: number; +} { + return withCurrentProjectionSnapshot(scope, (projection) => { + const db = getActiveTranscriptKysely(projection.database); + const row = executeSqliteQueryTakeFirstSync( + projection.database.db, + db + .selectFrom("session_transcript_active_events as active") + .innerJoin("transcript_events as event", (join) => + join + .onRef("event.session_id", "=", "active.session_id") + .onRef("event.seq", "=", "active.event_seq"), + ) + .select((eb) => [ + eb.fn.count("active.event_seq").as("event_count"), + /* kysely-allow-raw: JSONL size includes one terminating newline per event. */ + sql`COALESCE(SUM(LENGTH(CAST(event.event_json AS BLOB))), 0) + + COUNT(*)`.as("size_bytes"), + ]) + .where("active.session_id", "=", projection.resolved.sessionId), + ); + return { + eventCount: row?.event_count ?? 0, + sizeBytes: row?.size_bytes ?? 0, + }; + }); +} + /** Reads one append-stable forward page from the materialized active-message projection. */ export function readSessionTranscriptVisibleMessageDelta( scope: SessionTranscriptReadScope, @@ -344,7 +326,11 @@ export function readSessionTranscriptVisibleMessageDelta( return { kind: "missing" }; } - const initialCursor = bootstrapVisibleMessageCursor(projection, generation); + const initialCursor = createVisibleMessageCursor({ + agentId: projection.resolved.agentId, + generation, + sessionId: projection.resolved.sessionId, + }); const reset = ( reason: Extract["reason"], ) => ({ @@ -486,9 +472,9 @@ export function readRecentSessionTranscriptMessageEvents( ): SessionTranscriptMessageEventPage { return withCurrentProjectionSnapshot(scope, (projection) => { const visible = resolveVisibleMessagePositions(projection); - const maxMessages = Math.max( - 0, - Math.floor(Number.isFinite(options.maxMessages) ? options.maxMessages : 0), + const maxMessages = Math.min( + MAX_VISIBLE_MESSAGE_MAX_MESSAGES, + Math.max(0, Math.floor(Number.isFinite(options.maxMessages) ? options.maxMessages : 0)), ); const maxLines = Math.max( 0, @@ -557,7 +543,93 @@ export function readSessionTranscriptMessageEventPage( }); } -/** Counts active-path messages from the transactionally maintained watermark. */ +/** Reads a tail page whose materialized event payloads fit a hard byte budget. */ +export function readSessionTranscriptBoundedMessageTailPage( + scope: SessionTranscriptReadScope, + options: { maxBytes: number; maxMessages: number; offset: number }, +): SessionTranscriptBoundedMessageTailPage { + return withCurrentProjectionSnapshot(scope, (projection) => { + const visible = resolveVisibleMessagePositions(projection); + const totalMessages = visible.total; + const offset = Math.min( + Math.max(0, Math.floor(Number.isFinite(options.offset) ? options.offset : 0)), + totalMessages, + ); + const maxMessages = Math.min( + MAX_VISIBLE_MESSAGE_MAX_MESSAGES, + Math.max(0, Math.floor(Number.isFinite(options.maxMessages) ? options.maxMessages : 0)), + ); + const maxBytes = Math.max( + 0, + Math.floor(Number.isFinite(options.maxBytes) ? options.maxBytes : 0), + ); + const endExclusive = Math.max(0, totalMessages - offset); + const start = Math.max(0, endExclusive - maxMessages); + const positions = resolveVisibleMessagePositionRange(projection, start, endExclusive); + if (positions.length === 0 || maxBytes === 0) { + return { + activeLeafEntryId: projection.state.leafEventId, + events: [], + scannedMessages: positions.length, + serializedBytes: 0, + totalMessages, + }; + } + const db = getActiveTranscriptKysely(projection.database); + const metadata = executeSqliteQuerySync( + projection.database.db, + db + .selectFrom("session_transcript_active_events as active") + .innerJoin("transcript_events as event", (join) => + join + .onRef("event.session_id", "=", "active.session_id") + .onRef("event.seq", "=", "active.event_seq"), + ) + .select([ + "active.message_position", + /* kysely-allow-raw: byte budget covers the exact newline-terminated JSON event. */ + sql`LENGTH(CAST(event.event_json AS BLOB)) + 1`.as("serialized_bytes"), + ]) + .where("active.session_id", "=", projection.resolved.sessionId) + .where("active.message_position", "in", positions) + .orderBy("active.message_position", "desc"), + ).rows; + const selectedPositions: number[] = []; + let serializedBytes = 0; + for (const row of metadata) { + if (row.message_position === null || serializedBytes + row.serialized_bytes > maxBytes) { + continue; + } + selectedPositions.push(row.message_position); + serializedBytes += row.serialized_bytes; + } + const events = + selectedPositions.length === 0 + ? [] + : executeSqliteQuerySync( + projection.database.db, + db + .selectFrom("session_transcript_active_events as active") + .innerJoin("transcript_events as event", (join) => + join + .onRef("event.session_id", "=", "active.session_id") + .onRef("event.seq", "=", "active.event_seq"), + ) + .select(["active.message_position", "event.event_json"]) + .where("active.session_id", "=", projection.resolved.sessionId) + .where("active.message_position", "in", selectedPositions) + .orderBy("active.message_position", "asc"), + ).rows.map(parseMessageEventRow); + return { + activeLeafEntryId: projection.state.leafEventId, + events, + scannedMessages: positions.length, + serializedBytes, + totalMessages, + }; + }); +} + export function readSessionTranscriptMessageEventCount(scope: SessionTranscriptReadScope): number { return withCurrentProjectionSnapshot( scope, diff --git a/src/config/sessions/session-accessor.sqlite-checkpoint.ts b/src/config/sessions/session-accessor.sqlite-checkpoint.ts index e661d6d6c5c3..e3bcf6ce9af2 100644 --- a/src/config/sessions/session-accessor.sqlite-checkpoint.ts +++ b/src/config/sessions/session-accessor.sqlite-checkpoint.ts @@ -14,7 +14,7 @@ import { } from "./session-accessor.sqlite-entry-store.js"; import { emitCommittedSessionIdentityDiff } from "./session-accessor.sqlite-identity.js"; import { - formatSqliteSessionMarkerForScope, + formatSqliteSessionReferenceForScope, getSessionKysely, normalizeSqliteSessionKey, resolveSqliteScope, @@ -37,6 +37,14 @@ type SqliteCheckpointTranscriptForkSource = { totalTokens?: number; }; +type SqliteCompactionCheckpointLegacySource = { + checkpointId: string; + events: TranscriptEvent[]; + sessionFile: string; + sourceLeafId?: string; + totalTokens?: number; +}; + /** Result from SQLite compaction checkpoint branch or restore operations. */ type SqliteCompactionCheckpointSessionMutationResult = | { @@ -48,6 +56,7 @@ type SqliteCompactionCheckpointSessionMutationResult = | { status: "missing-session" } | { status: "missing-checkpoint" } | { status: "missing-boundary" } + | { status: "model-selection-locked" } | { status: "failed" }; /** Parameters for branching a SQLite session from a compaction checkpoint. */ @@ -59,6 +68,7 @@ type SqliteBranchCheckpointSessionParams = { sourceStoreKey?: string; nextKey: string; checkpointId: string; + legacySource?: SqliteCompactionCheckpointLegacySource; }; /** Parameters for restoring a SQLite session from a compaction checkpoint. */ @@ -69,12 +79,14 @@ type SqliteRestoreCheckpointSessionParams = { sessionKey: string; sessionStoreKey?: string; checkpointId: string; + legacySource?: SqliteCompactionCheckpointLegacySource; }; export async function branchSqliteCompactionCheckpointSession( params: SqliteBranchCheckpointSessionParams, ): Promise { const sourceKey = normalizeSqliteSessionKey(params.sourceStoreKey ?? params.sourceKey); + const requestedSourceKey = normalizeSqliteSessionKey(params.sourceKey); const targetKey = normalizeSqliteSessionKey(params.nextKey); const resolved = resolveSqliteScope({ ...(params.agentId ? { agentId: params.agentId } : {}), @@ -94,7 +106,8 @@ export async function branchSqliteCompactionCheckpointSession( previousIdentity = readSqliteSessionIdentitySnapshot(database, identityKeys); result = branchSqliteCompactionCheckpointSessionInTransaction(database, { checkpointId: params.checkpointId, - parentSessionKey: normalizeSqliteSessionKey(params.sourceKey), + parentSessionKey: requestedSourceKey, + legacySource: params.legacySource, resolved, sourceKey, targetKey, @@ -130,6 +143,7 @@ export async function restoreSqliteCompactionCheckpointSession( previousIdentity = readSqliteSessionIdentitySnapshot(database, identityKeys); result = restoreSqliteCompactionCheckpointSessionInTransaction(database, { checkpointId: params.checkpointId, + legacySource: params.legacySource, resolved, sourceKey: sessionKey, targetKey, @@ -147,6 +161,7 @@ function branchSqliteCompactionCheckpointSessionInTransaction( database: OpenClawAgentDatabase, params: { checkpointId: string; + legacySource?: SqliteCompactionCheckpointLegacySource; parentSessionKey: string; resolved: ResolvedSqliteScope; sourceKey: string; @@ -157,12 +172,16 @@ function branchSqliteCompactionCheckpointSessionInTransaction( if (!currentEntry?.sessionId) { return { status: "missing-session" }; } + if (currentEntry.modelSelectionLocked === true) { + return { status: "model-selection-locked" }; + } const checkpoint = readSessionCompactionCheckpoint(currentEntry, params.checkpointId); if (!checkpoint) { return { status: "missing-checkpoint" }; } const forked = forkSqliteCheckpointTranscriptInTransaction(database, params.resolved, { checkpoint, + legacySource: params.legacySource, targetSessionKey: params.targetKey, }); if (forked.status !== "created") { @@ -175,7 +194,6 @@ function branchSqliteCompactionCheckpointSessionInTransaction( const nextEntry = cloneSqliteCheckpointSessionEntry({ currentEntry, label, - nextSessionFile: forked.sessionFile, nextSessionId: forked.sessionId, parentSessionKey: params.parentSessionKey, totalTokens: forked.totalTokens, @@ -193,6 +211,7 @@ function restoreSqliteCompactionCheckpointSessionInTransaction( database: OpenClawAgentDatabase, params: { checkpointId: string; + legacySource?: SqliteCompactionCheckpointLegacySource; resolved: ResolvedSqliteScope; sourceKey: string; targetKey: string; @@ -202,12 +221,16 @@ function restoreSqliteCompactionCheckpointSessionInTransaction( if (!currentEntry?.sessionId) { return { status: "missing-session" }; } + if (currentEntry.modelSelectionLocked === true) { + return { status: "model-selection-locked" }; + } const checkpoint = readSessionCompactionCheckpoint(currentEntry, params.checkpointId); if (!checkpoint) { return { status: "missing-checkpoint" }; } const restored = forkSqliteCheckpointTranscriptInTransaction(database, params.resolved, { checkpoint, + legacySource: params.legacySource, targetSessionKey: params.targetKey, }); if (restored.status !== "created") { @@ -216,7 +239,6 @@ function restoreSqliteCompactionCheckpointSessionInTransaction( const nextEntry = cloneSqliteCheckpointSessionEntry({ currentEntry, - nextSessionFile: restored.sessionFile, nextSessionId: restored.sessionId, preserveCompactionCheckpoints: true, totalTokens: restored.totalTokens, @@ -235,6 +257,7 @@ function forkSqliteCheckpointTranscriptInTransaction( resolved: ResolvedSqliteScope, params: { checkpoint: SessionCompactionCheckpoint; + legacySource?: SqliteCompactionCheckpointLegacySource; targetSessionKey: string; }, ): @@ -267,7 +290,10 @@ function forkSqliteCheckpointTranscriptInTransaction( } lastFailure = rows; } - if (!selected) { + const legacySource = selected + ? undefined + : resolvePreparedLegacyCheckpointSource(params.checkpoint, params.legacySource); + if (!selected && !legacySource) { return lastFailure; } @@ -277,24 +303,39 @@ function forkSqliteCheckpointTranscriptInTransaction( sessionId, sessionKey: params.targetSessionKey, }; - const sessionFile = formatSqliteSessionMarkerForScope(targetScope); + const sessionFile = formatSqliteSessionReferenceForScope(targetScope); + const selectedEvents = selected?.rows ?? legacySource?.events ?? []; + const totalTokens = selected?.source.totalTokens ?? legacySource?.totalTokens; appendTranscriptEventsInTransaction(database, targetScope, [ createSessionTranscriptHeader({ - cwd: readTranscriptHeaderCwd(selected.rows), + cwd: readTranscriptHeaderCwd(selectedEvents), sessionId, }), - ...selected.rows.filter((event) => !isSessionTranscriptHeader(event)), + ...selectedEvents.filter((event) => !isSessionTranscriptHeader(event)), ]); return { status: "created", sessionId, sessionFile, - ...(typeof selected.source.totalTokens === "number" - ? { totalTokens: selected.source.totalTokens } - : {}), + ...(typeof totalTokens === "number" ? { totalTokens } : {}), }; } +function resolvePreparedLegacyCheckpointSource( + checkpoint: SessionCompactionCheckpoint, + source: SqliteCompactionCheckpointLegacySource | undefined, +): SqliteCompactionCheckpointLegacySource | undefined { + if (!source || source.checkpointId !== checkpoint.checkpointId || source.events.length === 0) { + return undefined; + } + const matches = [checkpoint.preCompaction, checkpoint.postCompaction].some((position) => { + const sessionFile = position.sessionFile?.trim(); + const sourceLeafId = position.entryId?.trim() || position.leafId?.trim() || undefined; + return sessionFile === source.sessionFile && sourceLeafId === source.sourceLeafId; + }); + return matches ? source : undefined; +} + function resolveSqliteCheckpointTranscriptForkSources( checkpoint: SessionCompactionCheckpoint, ): SqliteCheckpointTranscriptForkSource[] { @@ -374,7 +415,6 @@ function readSessionCompactionCheckpoint( function cloneSqliteCheckpointSessionEntry(params: { currentEntry: SessionEntry; nextSessionId: string; - nextSessionFile: string; label?: string; parentSessionKey?: string; totalTokens?: number; @@ -385,7 +425,6 @@ function cloneSqliteCheckpointSessionEntry(params: { return { ...params.currentEntry, sessionId: params.nextSessionId, - sessionFile: params.nextSessionFile, updatedAt: Date.now(), systemSent: false, abortedLastRun: false, diff --git a/src/config/sessions/session-accessor.sqlite-contract.ts b/src/config/sessions/session-accessor.sqlite-contract.ts index b118e062d9e8..fb2cffcfe454 100644 --- a/src/config/sessions/session-accessor.sqlite-contract.ts +++ b/src/config/sessions/session-accessor.sqlite-contract.ts @@ -43,7 +43,7 @@ type SessionTranscriptRuntimeScope = SessionAccessScope & { export type SessionTranscriptReadScope = Omit & { sessionKey?: string; - sessionEntry?: Pick & Partial>; + sessionEntry?: Partial>; }; export type SessionTranscriptWriteScope = Omit & { @@ -137,7 +137,6 @@ export type SessionTranscriptTurnMessageAppend = TranscriptMessageAppendOptions< export type SessionTranscriptTurnWriteContext = { agentId?: string; - sessionFile: string; sessionId?: string; sessionKey?: string; storePath?: string; diff --git a/src/config/sessions/session-accessor.sqlite-events.ts b/src/config/sessions/session-accessor.sqlite-events.ts index 706faf85ef45..c9d280d887f4 100644 --- a/src/config/sessions/session-accessor.sqlite-events.ts +++ b/src/config/sessions/session-accessor.sqlite-events.ts @@ -30,6 +30,7 @@ export async function publishSqliteTranscriptUpdate( agentId: resolved.agentId, sessionId: resolved.sessionId, sessionKey: resolved.sessionKey, + storePath: resolved.path, }, }); } diff --git a/src/config/sessions/session-accessor.sqlite-history.ts b/src/config/sessions/session-accessor.sqlite-history.ts index 0e8c4be6ec39..a1895cafcb02 100644 --- a/src/config/sessions/session-accessor.sqlite-history.ts +++ b/src/config/sessions/session-accessor.sqlite-history.ts @@ -3,7 +3,6 @@ import type { DB as OpenClawAgentKyselyDatabase } from "../../state/openclaw-age import type { OpenClawAgentDatabase } from "../../state/openclaw-agent-db.js"; import { isInternalSessionEffectsKey } from "./internal-session-key.js"; import type { SessionTranscriptInstance } from "./session-accessor.sqlite-contract.js"; -import { formatSqliteSessionFileMarker } from "./sqlite-marker.js"; import type { SessionEntry } from "./types.js"; export function listSqliteTranscriptInstancesFromDatabase(params: { @@ -55,11 +54,6 @@ export function listSqliteTranscriptInstancesFromDatabase(params: { const entry: SessionEntry = { ...(currentIsExact && current ? structuredClone(current) : {}), sessionId: row.session_id, - sessionFile: formatSqliteSessionFileMarker({ - agentId: params.agentId, - sessionId: row.session_id, - storePath: params.databasePath, - }), updatedAt: updatedAtMs, ...(row.parent_session_key ? { parentSessionKey: row.parent_session_key } : {}), ...(row.spawned_by ? { spawnedBy: row.spawned_by, spawnDepth: 1 } : {}), diff --git a/src/config/sessions/session-accessor.sqlite-lifecycle-state.ts b/src/config/sessions/session-accessor.sqlite-lifecycle-state.ts index a3ab2479cdee..8eb30f845ed2 100644 --- a/src/config/sessions/session-accessor.sqlite-lifecycle-state.ts +++ b/src/config/sessions/session-accessor.sqlite-lifecycle-state.ts @@ -363,7 +363,6 @@ export async function projectSqliteSessionEntryLifecycleMutation( upsert.resetBoundaryReason && expectedEntry?.sessionId ? await buildSessionResetBoundaryPlan({ events: loadSqliteTranscriptEventsFromDatabase(database, expectedEntry.sessionId), - legacySessionFile: expectedEntry.sessionFile, reason: upsert.resetBoundaryReason, }) : undefined; diff --git a/src/config/sessions/session-accessor.sqlite-lifecycle.ts b/src/config/sessions/session-accessor.sqlite-lifecycle.ts index d592ced0b9f7..12f1f271a17e 100644 --- a/src/config/sessions/session-accessor.sqlite-lifecycle.ts +++ b/src/config/sessions/session-accessor.sqlite-lifecycle.ts @@ -174,14 +174,12 @@ export async function resetSqliteSessionEntryLifecycle( !sqliteSessionEntriesEqual(current.entry, nextEntry) ? await buildSessionResetBoundaryPlan({ events: loadSqliteTranscriptEventsFromDatabase(database, current.entry.sessionId), - legacySessionFile: current.entry.sessionFile, reason: params.resetBoundaryReason, }) : undefined; const mutation: ResetSessionEntryLifecycleMutation = { nextEntry: cloneSessionEntry(nextEntry), ...(current ? { previousEntry: cloneSessionEntry(current.entry) } : {}), - ...(current?.entry.sessionFile ? { previousSessionFile: current.entry.sessionFile } : {}), ...(current?.entry.sessionId ? { previousSessionId: current.entry.sessionId } : {}), }; runOpenClawAgentWriteTransaction((transactionDb) => { @@ -455,7 +453,6 @@ async function deleteSqliteSessionEntryLifecycleLocked( archivedTranscripts, deleted: true, deletedEntry: cloneSessionEntry(current.entry), - ...(current.entry.sessionFile ? { deletedSessionFile: current.entry.sessionFile } : {}), ...(current.entry.sessionId ? { deletedSessionId: current.entry.sessionId } : {}), }; }, toDatabaseOptions(resolved)); diff --git a/src/config/sessions/session-accessor.sqlite-message-cut.test.ts b/src/config/sessions/session-accessor.sqlite-message-cut.test.ts index d3cbd031c1dc..af3c91827652 100644 --- a/src/config/sessions/session-accessor.sqlite-message-cut.test.ts +++ b/src/config/sessions/session-accessor.sqlite-message-cut.test.ts @@ -526,24 +526,4 @@ describe("SQLite session message cuts", () => { rewindSessionToMessage({ agentId, env, entryId, sessionKey }), ).resolves.toMatchObject({ status }); }); - - it("returns a typed error for legacy JSONL transcript storage", async () => { - const { env } = await createSession(); - await upsertSessionEntry( - { agentId, env, sessionKey }, - { - sessionFile: "/tmp/legacy-session.jsonl", - }, - ); - - await expect( - rewindSessionToMessage({ agentId, env, entryId: "user-2", sessionKey }), - ).resolves.toMatchObject({ status: "unsupported-storage" }); - await expect(listSessionBranches({ agentId, env, sessionKey })).resolves.toMatchObject({ - status: "unsupported-storage", - }); - await expect( - switchSessionBranch({ agentId, env, leafEntryId: "assistant-2", sessionKey }), - ).resolves.toMatchObject({ status: "unsupported-storage" }); - }); }); diff --git a/src/config/sessions/session-accessor.sqlite-message-cut.ts b/src/config/sessions/session-accessor.sqlite-message-cut.ts index 1f8395f3dc70..0161a153765a 100644 --- a/src/config/sessions/session-accessor.sqlite-message-cut.ts +++ b/src/config/sessions/session-accessor.sqlite-message-cut.ts @@ -18,7 +18,6 @@ import { import { emitCommittedSessionIdentityDiff } from "./session-accessor.sqlite-identity.js"; import { loadSqliteTranscriptEventsFromDatabase } from "./session-accessor.sqlite-read.js"; import { - formatSqliteSessionMarkerForScope, getSessionKysely, normalizeSqliteSessionKey, resolveSqliteScope, @@ -39,7 +38,6 @@ import type { import { buildSessionCreationStamp } from "./session-entry-provenance.js"; import { inheritSessionSelection } from "./session-entry-selection.js"; import { reconcileSessionTranscriptIndexInTransaction } from "./session-transcript-index.js"; -import { parseSqliteSessionFileMarker } from "./sqlite-marker.js"; import { createSessionTranscriptHeader } from "./transcript-header.js"; import { isSessionTranscriptLeafControl, @@ -155,12 +153,6 @@ export async function listSqliteSessionBranches( if (!currentEntry?.sessionId) { return { status: "missing-session" }; } - if ( - currentEntry.sessionFile?.trim() && - !parseSqliteSessionFileMarker(currentEntry.sessionFile) - ) { - return { status: "unsupported-storage" }; - } return { status: "ok", branches: loadSessionBranchSummaries(database, currentEntry.sessionId), @@ -269,9 +261,6 @@ function mutateSqliteSessionAtMessageInTransaction( if (!currentEntry?.sessionId) { return { status: "missing-session" }; } - if (currentEntry.sessionFile?.trim() && !parseSqliteSessionFileMarker(currentEntry.sessionFile)) { - return { status: "unsupported-storage" }; - } const events = loadSqliteTranscriptEventsFromDatabase(database, currentEntry.sessionId); const cut = params.mode === "switch" ? undefined : resolveMessageCut(events, params.entryId); if (cut && "status" in cut) { @@ -290,7 +279,6 @@ function mutateSqliteSessionAtMessageInTransaction( sessionId: nextSessionId, sessionKey: params.targetKey, }; - const nextSessionFile = formatSqliteSessionMarkerForScope(targetScope); const header = createSessionTranscriptHeader({ cwd: readTranscriptHeaderCwd(events), sessionId: nextSessionId, @@ -328,7 +316,6 @@ function mutateSqliteSessionAtMessageInTransaction( entryId: params.entryId, } : undefined, - nextSessionFile, nextSessionId, }), ...(params.mode === "fork" && params.creation @@ -480,7 +467,6 @@ function cloneMessageCutSessionEntry(params: { currentEntry: SessionEntry; forked: boolean; forkSource?: NonNullable; - nextSessionFile: string; nextSessionId: string; }): SessionEntry { const baseEntry = params.forked @@ -489,7 +475,6 @@ function cloneMessageCutSessionEntry(params: { return { ...baseEntry, sessionId: params.nextSessionId, - sessionFile: params.nextSessionFile, lifecycleRevision: params.forked ? randomUUID() : params.currentEntry.lifecycleRevision, updatedAt: Date.now(), systemSent: false, diff --git a/src/config/sessions/session-accessor.sqlite-parent-session.ts b/src/config/sessions/session-accessor.sqlite-parent-session.ts index 32b10c9ea009..a35cb7e5f98e 100644 --- a/src/config/sessions/session-accessor.sqlite-parent-session.ts +++ b/src/config/sessions/session-accessor.sqlite-parent-session.ts @@ -35,7 +35,8 @@ import { import { loadSqliteTranscriptEventsFromDatabase } from "./session-accessor.sqlite-read.js"; import { cloneSessionEntry, - formatSqliteSessionMarkerForScope, + formatLegacySqliteSessionMarkerForScope, + formatSqliteSessionReferenceForScope, normalizeSqliteSessionKey, resolveSqliteScope, resolveSqliteStoreScope, @@ -94,7 +95,7 @@ export async function forkSqliteSessionTranscriptFromParent( if (!source) { return { status: "failed" }; } - const parentSessionFile = formatSqliteSessionMarkerForScope({ + const parentSessionFile = formatLegacySqliteSessionMarkerForScope({ ...resolved, sessionId: params.parentEntry.sessionId, sessionKey: normalizeSqliteSessionKey(params.parentSessionKey), @@ -106,7 +107,7 @@ export async function forkSqliteSessionTranscriptFromParent( sessionId, sessionKey: normalizeSqliteSessionKey(params.sessionKey), }; - const sessionFile = formatSqliteSessionMarkerForScope(targetScope); + const sessionFile = formatSqliteSessionReferenceForScope(targetScope); runOpenClawAgentWriteTransaction((database) => { writeSqliteForkedChildTranscriptInTransaction(database, targetScope, { parentSessionFile, @@ -224,7 +225,6 @@ export async function forkSqliteSessionEntryFromParentTarget( sessionId: freshParent.sessionId, }, forkedFromParent: true, - sessionFile: fork.transcript.sessionFile, sessionId: fork.transcript.sessionId, }); previousIdentity = readSqliteSessionIdentitySnapshot(writeDatabase, sessionTarget.storeKeys); @@ -364,12 +364,12 @@ function forkSqliteParentTranscriptInTransaction( sessionId, sessionKey: normalizeSqliteSessionKey(params.targetSessionKey), }; - const parentSessionFile = formatSqliteSessionMarkerForScope({ + const parentSessionFile = formatLegacySqliteSessionMarkerForScope({ ...resolved, sessionId: params.parentEntry.sessionId, sessionKey: normalizeSqliteSessionKey(params.parentSessionKey), }); - const sessionFile = formatSqliteSessionMarkerForScope(targetScope); + const sessionFile = formatSqliteSessionReferenceForScope(targetScope); writeSqliteForkedChildTranscriptInTransaction(database, targetScope, { parentSessionFile, source, diff --git a/src/config/sessions/session-accessor.sqlite-read.ts b/src/config/sessions/session-accessor.sqlite-read.ts index 4582e19ff39d..2ffc5fe9c764 100644 --- a/src/config/sessions/session-accessor.sqlite-read.ts +++ b/src/config/sessions/session-accessor.sqlite-read.ts @@ -46,6 +46,48 @@ export function loadSqliteTranscriptEventsSync( return loadSqliteTranscriptEventsFromDatabase(database, resolved.sessionId); } +/** Loads only the first transcript row for header metadata hot paths. */ +export function loadSqliteTranscriptHeaderSync(scope: SessionTranscriptReadScope): unknown { + const resolved = resolveSqliteTranscriptReadScope(scope); + const database = openOpenClawAgentDatabase(toDatabaseOptions(resolved)); + const db = getSessionKysely(database.db); + const row = executeSqliteQueryTakeFirstSync( + database.db, + db + .selectFrom("transcript_events") + .select("event_json") + .where("session_id", "=", resolved.sessionId) + .orderBy("seq", "asc") + .limit(1), + ); + return row ? (JSON.parse(row.event_json) as TranscriptEvent) : undefined; +} + +/** Loads a bounded newest tail in storage order for hot-path accounting. */ +export function loadSqliteTranscriptTailEventsSync( + scope: SessionTranscriptReadScope, + maxEvents: number, +): TranscriptEvent[] { + const limit = Number.isFinite(maxEvents) ? Math.max(0, Math.floor(maxEvents)) : 0; + if (limit === 0) { + return []; + } + const resolved = resolveSqliteTranscriptReadScope(scope); + const database = openOpenClawAgentDatabase(toDatabaseOptions(resolved)); + const db = getSessionKysely(database.db); + return executeSqliteQuerySync( + database.db, + db + .selectFrom("transcript_events") + .select("event_json") + .where("session_id", "=", resolved.sessionId) + .orderBy("seq", "desc") + .limit(limit), + ) + .rows.toReversed() + .map((row) => JSON.parse(row.event_json) as TranscriptEvent); +} + /** Loads additive transcript rows after one durable sequence checkpoint. */ export function loadSqliteTranscriptEventRowsAfterSeqSync( scope: SessionTranscriptReadScope, diff --git a/src/config/sessions/session-accessor.sqlite-reset-window.ts b/src/config/sessions/session-accessor.sqlite-reset-window.ts index 6ce4f43e88a8..65c50d83f782 100644 --- a/src/config/sessions/session-accessor.sqlite-reset-window.ts +++ b/src/config/sessions/session-accessor.sqlite-reset-window.ts @@ -284,3 +284,25 @@ export function readVisibleMessageRange( ); return [...keptEvents, ...postEvents]; } + +/** Maps a logical visible-message range to its materialized message positions. */ +export function resolveVisibleMessagePositionRange( + projection: ResetWindowProjection, + start: number, + endExclusive: number, +): number[] { + if (endExclusive <= start) { + return []; + } + const visible = resolveVisibleMessagePositions(projection); + const boundedStart = Math.min(Math.max(0, start), visible.total); + const boundedEnd = Math.min(Math.max(boundedStart, endExclusive), visible.total); + const keptEnd = Math.min(boundedEnd, visible.kept.length); + const positions = visible.kept.slice(boundedStart, keptEnd); + const postVisibleStart = Math.max(boundedStart, visible.kept.length); + const postVisibleEnd = Math.max(postVisibleStart, boundedEnd); + for (let logical = postVisibleStart; logical < postVisibleEnd; logical += 1) { + positions.push(visible.postStart + logical - visible.kept.length); + } + return positions; +} diff --git a/src/config/sessions/session-accessor.sqlite-scope.ts b/src/config/sessions/session-accessor.sqlite-scope.ts index b241cde0317d..5549b0650d15 100644 --- a/src/config/sessions/session-accessor.sqlite-scope.ts +++ b/src/config/sessions/session-accessor.sqlite-scope.ts @@ -14,13 +14,13 @@ import { resolveOpenClawAgentSqlitePath, type OpenClawAgentDatabaseOptions, } from "../../state/openclaw-agent-db.js"; +import { formatSqliteSessionFileMarker } from "./legacy-sqlite-marker.js"; import type { SessionAccessScope, SessionTranscriptReadScope, SessionTranscriptWriteScope, } from "./session-accessor.sqlite-contract.js"; import { resolveSqliteTargetFromSessionStorePath } from "./session-sqlite-target.js"; -import { formatSqliteSessionFileMarker } from "./sqlite-marker.js"; import { normalizeStoreSessionKey } from "./store-entry.js"; import type { SessionEntry } from "./types.js"; @@ -287,7 +287,12 @@ export function cloneSessionEntry(entry: SessionEntry): SessionEntry { return structuredClone(entry); } -export function formatSqliteSessionMarkerForScope(scope: ResolvedTranscriptScope): string { +export function formatSqliteSessionReferenceForScope(scope: ResolvedTranscriptScope): string { + return scope.sessionKey; +} + +/** Legacy identity string retained only for transcript artifact metadata and plugin contracts. */ +export function formatLegacySqliteSessionMarkerForScope(scope: ResolvedTranscriptScope): string { return formatSqliteSessionFileMarker({ agentId: scope.agentId, sessionId: scope.sessionId, diff --git a/src/config/sessions/session-accessor.sqlite-session-row.ts b/src/config/sessions/session-accessor.sqlite-session-row.ts index bb720045b8a8..56564e9086c3 100644 --- a/src/config/sessions/session-accessor.sqlite-session-row.ts +++ b/src/config/sessions/session-accessor.sqlite-session-row.ts @@ -8,6 +8,7 @@ import { } from "./session-accessor.sqlite-normalize.js"; import { bindSessionEntryProvenance } from "./session-accessor.sqlite-provenance.js"; import { normalizeSqliteStatus } from "./session-accessor.sqlite-status.js"; +import { projectCanonicalSessionEntryShape } from "./store-entry-shape.js"; import type { SessionEntry } from "./types.js"; export function normalizeSqliteSessionEntryTimestamp(entry: SessionEntry): SessionEntry { @@ -76,6 +77,9 @@ export function bindSqliteSessionNode(params: { sessionKey: string; updatedAt: number; }) { + const canonicalEntry = projectCanonicalSessionEntryShape( + params.entry as unknown as Record, + ); const actor = params.entry.createdActor; const legacyActorId = normalizeSqliteText( (params.entry as SessionEntry & { createdBy?: { id?: unknown } }).createdBy?.id, @@ -83,7 +87,7 @@ export function bindSqliteSessionNode(params: { return { session_key: params.sessionKey, current_session_id: params.entry.sessionId, - entry_json: JSON.stringify(params.entry), + entry_json: JSON.stringify(canonicalEntry), updated_at: params.updatedAt, status: normalizeSqliteStatus(params.entry.status), created_at: finiteSqliteNumber(params.entry.createdAt), diff --git a/src/config/sessions/session-accessor.sqlite-status.ts b/src/config/sessions/session-accessor.sqlite-status.ts index c229098717da..cb0619745f34 100644 --- a/src/config/sessions/session-accessor.sqlite-status.ts +++ b/src/config/sessions/session-accessor.sqlite-status.ts @@ -5,6 +5,7 @@ import type { SessionEntryStatus, SessionEntrySummary, } from "./session-accessor.sqlite-contract.js"; +import { projectCanonicalSessionEntryShape } from "./store-entry-shape.js"; import type { SessionEntry } from "./types.js"; type SessionStatusDatabase = Pick; @@ -25,8 +26,8 @@ export function parseSqliteSessionEntryJson(row: { entry_json: string }): Sessio if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) { return null; } - const entry = parsed as Partial; - return typeof entry.sessionId === "string" ? (entry as SessionEntry) : null; + const entry = projectCanonicalSessionEntryShape(parsed as Record); + return typeof entry.sessionId === "string" ? entry : null; } catch { return null; } diff --git a/src/config/sessions/session-accessor.sqlite-transcript-write.ts b/src/config/sessions/session-accessor.sqlite-transcript-write.ts index ca018cbe94bd..3a9b637a32d7 100644 --- a/src/config/sessions/session-accessor.sqlite-transcript-write.ts +++ b/src/config/sessions/session-accessor.sqlite-transcript-write.ts @@ -2,6 +2,7 @@ import { randomUUID } from "node:crypto"; import { resolveTimestampMsToIsoString } from "@openclaw/normalization-core/number-coercion"; import { openOpenClawAgentDatabase, + resolveOpenClawAgentSqlitePath, runOpenClawAgentWriteTransaction, type OpenClawAgentDatabase, } from "../../state/openclaw-agent-db.js"; @@ -33,7 +34,7 @@ import { } from "./session-accessor.sqlite-read.js"; import { cloneSessionEntry, - formatSqliteSessionMarkerForScope, + formatSqliteSessionReferenceForScope, resolveSqliteScope, resolveSqliteTranscriptArchiveDirectory, resolveSqliteTranscriptScope, @@ -55,6 +56,7 @@ import { redactTranscriptMessageForStorage, replaceSqliteTranscriptEventsInTransaction, } from "./session-accessor.sqlite-transcript-store.js"; +import type { SessionTranscriptWriteTransactionContext } from "./session-accessor.types.js"; import { reconcileSessionTranscriptIndexInTransaction } from "./session-transcript-index.js"; import type { SessionTranscriptTurnExpectedState, @@ -242,7 +244,7 @@ export async function importSqliteSessionRows( const importedEntry = { ...params.entry, ...(preservedHarnessId ? { agentHarnessId: preservedHarnessId } : {}), - sessionFile: formatSqliteSessionMarkerForScope({ + sessionFile: formatSqliteSessionReferenceForScope({ ...resolved, sessionId: params.entry.sessionId, }), @@ -353,7 +355,6 @@ export async function appendSqliteExpectedSessionTranscriptTurn( const messages = await selectAppendableSqliteTranscriptTurnMessages( { agentId: resolved.agentId, - sessionFile: options.sessionFile, sessionId: options.expectedSessionId, sessionKey: resolved.sessionKey, ...(scope.storePath ? { storePath: scope.storePath } : {}), @@ -560,12 +561,21 @@ export async function withSqliteTranscriptWriteLock( /** Runs synchronous transcript work under one writer queue and SQLite transaction. */ export async function withSqliteTranscriptWriteTransaction( scope: SessionTranscriptWriteScope, - run: (context: { sessionFile: string }) => T, + run: (context: SessionTranscriptWriteTransactionContext) => T, ): Promise { const resolved = resolveSqliteTranscriptScope(scope); return await runExclusiveSqliteSessionWrite(resolved, async () => runOpenClawAgentWriteTransaction( - () => run({ sessionFile: formatSqliteSessionMarkerForScope(resolved) }), + () => + run({ + agentId: resolved.agentId, + sessionId: resolved.sessionId, + sessionKey: resolved.sessionKey, + storePath: + resolved.path ?? + scope.storePath ?? + resolveOpenClawAgentSqlitePath({ agentId: resolved.agentId, env: resolved.env }), + }), toDatabaseOptions(resolved), { operationLabel: "session.transcript.batch" }, ), diff --git a/src/config/sessions/session-accessor.sqlite-visible-cursor.ts b/src/config/sessions/session-accessor.sqlite-visible-cursor.ts new file mode 100644 index 000000000000..bcafc91c166b --- /dev/null +++ b/src/config/sessions/session-accessor.sqlite-visible-cursor.ts @@ -0,0 +1,73 @@ +const VISIBLE_MESSAGE_CURSOR_VERSION = 1; +export const DEFAULT_VISIBLE_MESSAGE_MAX_MESSAGES = 1_000; +export const DEFAULT_VISIBLE_MESSAGE_MAX_BYTES = 1_000_000; +export const MAX_VISIBLE_MESSAGE_MAX_MESSAGES = 10_000; +export const MAX_VISIBLE_MESSAGE_MAX_BYTES = 64 * 1024 * 1024; + +type VisibleMessageCursor = { + agentId: string; + generation: string; + lastEventSeq: number; + lastMessagePosition: number; + sessionId: string; + version: typeof VISIBLE_MESSAGE_CURSOR_VERSION; +}; + +export function normalizeVisibleMessageLimit( + value: number | undefined, + fallback: number, + maximum: number, + name: string, +): number { + const resolved = value ?? fallback; + if (!Number.isInteger(resolved) || resolved < 1 || resolved > maximum) { + throw new RangeError(`${name} must be an integer between 1 and ${String(maximum)}`); + } + return resolved; +} + +export function encodeVisibleMessageCursor(cursor: VisibleMessageCursor): string { + return Buffer.from(JSON.stringify(cursor), "utf8").toString("base64url"); +} + +export function createVisibleMessageCursor(params: { + agentId: string; + generation: string; + sessionId: string; +}): VisibleMessageCursor { + return { + ...params, + lastEventSeq: -1, + lastMessagePosition: -1, + version: VISIBLE_MESSAGE_CURSOR_VERSION, + }; +} + +export function parseVisibleMessageCursor(value: string): VisibleMessageCursor | undefined { + // The cursor is a continuation hint, not an authorization token. Every field + // is revalidated against the current scope, generation, and projection. + if (value.length > 4_096) { + return undefined; + } + try { + const parsed = JSON.parse( + Buffer.from(value, "base64url").toString("utf8"), + ) as Partial; + if ( + parsed.version !== VISIBLE_MESSAGE_CURSOR_VERSION || + typeof parsed.agentId !== "string" || + typeof parsed.sessionId !== "string" || + typeof parsed.generation !== "string" || + !Number.isSafeInteger(parsed.lastEventSeq) || + (parsed.lastEventSeq ?? -2) < -1 || + !Number.isSafeInteger(parsed.lastMessagePosition) || + (parsed.lastMessagePosition ?? -2) < -1 || + (parsed.lastEventSeq === -1) !== (parsed.lastMessagePosition === -1) + ) { + return undefined; + } + return parsed as VisibleMessageCursor; + } catch { + return undefined; + } +} diff --git a/src/config/sessions/session-accessor.sqlite.ts b/src/config/sessions/session-accessor.sqlite.ts index faaa7063ae95..fe89a8b97f33 100644 --- a/src/config/sessions/session-accessor.sqlite.ts +++ b/src/config/sessions/session-accessor.sqlite.ts @@ -70,6 +70,8 @@ export { loadSqliteTranscriptEventRowsAfterSeqSync, loadSqliteTranscriptEvents, loadSqliteTranscriptEventsSync, + loadSqliteTranscriptHeaderSync, + loadSqliteTranscriptTailEventsSync, readSqliteTranscriptEventAtSeqSync, readSqliteTranscriptStatsSync, } from "./session-accessor.sqlite-read.js"; diff --git a/src/config/sessions/session-accessor.test.ts b/src/config/sessions/session-accessor.test.ts index cde6b1a3bbd3..1685e7223a8b 100644 --- a/src/config/sessions/session-accessor.test.ts +++ b/src/config/sessions/session-accessor.test.ts @@ -33,7 +33,6 @@ import { openSessionEntryReadView, patchSessionEntry, patchSessionEntryTarget, - persistSessionResetLifecycle, persistSessionTranscriptTurn, readTranscriptStatsSync, readSessionUpdatedAt, @@ -66,7 +65,6 @@ import { trimSqliteTranscriptForManualCompact, } from "./session-accessor.sqlite.js"; import { resolveSqliteTargetFromSessionStorePath } from "./session-sqlite-target.js"; -import { formatSqliteSessionFileMarker } from "./sqlite-marker.js"; import { withOwnedSessionTranscriptWrites } from "./transcript-write-context.js"; import type { SessionEntry } from "./types.js"; @@ -1093,8 +1091,8 @@ describe("session accessor seam", () => { if (!created.ok) { throw new Error("expected session creation to succeed"); } - expect(created.sessionFile).toContain("sqlite:main:session-1:"); - expect(created.entry.sessionFile).toBe(created.sessionFile); + expect(created.sessionFile).toBe(scope.sessionKey); + expect(created.entry).not.toHaveProperty("sessionFile"); await expect( loadTranscriptEvents({ agentId: "main", @@ -1105,46 +1103,7 @@ describe("session accessor seam", () => { ).resolves.toEqual([expect.objectContaining({ id: "session-1", type: "session" })]); }); - it("persists store-backed turns to SQLite when an old sessionFile path is present", async () => { - const legacyTranscript = path.join(tempDir, "legacy-topic.jsonl"); - const scope = { - agentId: "main", - sessionId: "legacy-topic-session", - sessionKey: "agent:main:telegram:group:1:topic:2", - storePath, - sessionFile: legacyTranscript, - }; - await upsertSessionEntry( - { sessionKey: scope.sessionKey, storePath }, - { - sessionId: scope.sessionId, - sessionFile: legacyTranscript, - updatedAt: 10, - }, - ); - - const result = await persistSessionTranscriptTurn(scope, { - messages: [{ message: { role: "user", content: "store-backed sqlite turn" } }], - touchSessionEntry: true, - updateMode: "none", - }); - - expect(result.sessionFile).toContain("sqlite:main:legacy-topic-session:"); - const entry = loadSessionEntry({ sessionKey: scope.sessionKey, storePath }); - expect(entry?.sessionFile).toBe(result.sessionFile); - await expect(loadTranscriptEvents(scope)).resolves.toContainEqual( - expect.objectContaining({ - type: "message", - message: expect.objectContaining({ - role: "user", - content: "store-backed sqlite turn", - }), - }), - ); - expect(fs.existsSync(legacyTranscript)).toBe(false); - }); - - it("resolves default-store SQLite transcript turn markers before appending", async () => { + it("resolves the default-store SQLite identity before appending", async () => { const stateDir = path.join(tempDir, "state"); const expectedStorePath = path.join(stateDir, "agents", "main", "sessions", "sessions.json"); const scope = { @@ -1170,9 +1129,9 @@ describe("session accessor seam", () => { updateMode: "none", }); - expect(result.sessionFile).toBe(`sqlite:main:${scope.sessionId}:${expectedStorePath}`); const persistedScope = { ...scope, storePath: expectedStorePath }; - expect(loadSessionEntry(persistedScope)?.sessionFile).toBe(result.sessionFile); + expect(result.sessionEntry).not.toHaveProperty("sessionFile"); + expect(loadSessionEntry(persistedScope)).not.toHaveProperty("sessionFile"); await expect(loadTranscriptEvents(persistedScope)).resolves.toContainEqual( expect.objectContaining({ type: "message", @@ -1184,47 +1143,6 @@ describe("session accessor seam", () => { ); }); - it("guards store-backed turns in SQLite when an old sessionFile path is present", async () => { - const legacyTranscript = path.join(tempDir, "guarded-legacy-topic.jsonl"); - const scope = { - agentId: "main", - sessionId: "guarded-topic-session", - sessionKey: "agent:main:telegram:group:1:topic:3", - storePath, - sessionFile: legacyTranscript, - }; - await upsertSessionEntry( - { sessionKey: scope.sessionKey, storePath }, - { - sessionId: scope.sessionId, - sessionFile: legacyTranscript, - updatedAt: 10, - }, - ); - - const result = await persistSessionTranscriptTurn(scope, { - expectedSessionId: scope.sessionId, - messages: [{ message: { role: "assistant", content: "guarded sqlite turn" } }], - touchSessionEntry: true, - updateMode: "none", - }); - - expect(result.rejectedReason).toBeUndefined(); - expect(result.sessionFile).toContain("sqlite:main:guarded-topic-session:"); - const entry = loadSessionEntry({ sessionKey: scope.sessionKey, storePath }); - expect(entry?.sessionFile).toBe(result.sessionFile); - await expect(loadTranscriptEvents(scope)).resolves.toContainEqual( - expect.objectContaining({ - type: "message", - message: expect.objectContaining({ - role: "assistant", - content: "guarded sqlite turn", - }), - }), - ); - expect(fs.existsSync(legacyTranscript)).toBe(false); - }); - it("appends SQLite turns to the active transcript leaf", async () => { const scope = { agentId: "main", @@ -1320,90 +1238,6 @@ describe("session accessor seam", () => { }); }); - it("commits reply session initialization with a guarded snapshot", async () => { - const sessionKey = "agent:main:main"; - const previousTranscript = path.join(tempDir, "previous.jsonl"); - fs.writeFileSync( - previousTranscript, - `${JSON.stringify({ - type: "session", - version: 3, - id: "previous-session", - timestamp: new Date().toISOString(), - })}\n`, - "utf8", - ); - await upsertSessionEntry( - { sessionKey, storePath }, - { - sessionFile: previousTranscript, - sessionId: "previous-session", - updatedAt: 10, - }, - ); - - const snapshot = loadMainInitializationSnapshot(sessionKey); - const committed = await commitReplySessionInitialization({ - activeSessionKey: sessionKey, - agentId: "main", - expectedRevision: snapshot.revision, - previousEntry: snapshot.currentEntry, - sessionEntry: { - sessionId: "next-session", - updatedAt: 20, - }, - sessionKey, - storePath, - }); - - expect(committed.ok).toBe(true); - if (!committed.ok) { - throw new Error("expected reply session initialization to commit"); - } - expect(committed.sessionEntry.sessionFile).toBe(`sqlite:main:next-session:${storePath}`); - expect(committed.sessionStoreView[sessionKey]).toMatchObject({ - sessionId: "next-session", - sessionFile: committed.sessionEntry.sessionFile, - }); - expect(committed.previousSessionTranscript.transcriptArchived).toBe(true); - expect(fs.existsSync(previousTranscript)).toBe(false); - }); - - it("does not reuse the previous transcript file when initialization rotates session ids", async () => { - const sessionKey = "agent:main:main"; - const previousTranscript = path.join(tempDir, "previous-rotation.jsonl"); - await upsertSessionEntry( - { sessionKey, storePath }, - { - sessionFile: previousTranscript, - sessionId: "previous-rotation", - updatedAt: 10, - }, - ); - - const snapshot = loadMainInitializationSnapshot(sessionKey); - const committed = await commitReplySessionInitialization({ - activeSessionKey: sessionKey, - agentId: "main", - expectedRevision: snapshot.revision, - previousEntry: snapshot.currentEntry, - sessionEntry: { - ...snapshot.currentEntry, - sessionFile: snapshot.currentEntry?.sessionFile, - sessionId: "next-rotation", - updatedAt: 20, - }, - sessionKey, - storePath, - }); - - expect(committed.ok).toBe(true); - if (!committed.ok) { - throw new Error("expected reply session initialization to commit"); - } - expect(committed.sessionEntry.sessionFile).toBe(`sqlite:main:next-rotation:${storePath}`); - }); - it("rejects stale reply session initialization snapshots without writing", async () => { const sessionKey = "agent:main:main"; await upsertSessionEntry( @@ -2358,86 +2192,8 @@ describe("session accessor seam", () => { ).resolves.toEqual([]); }); - it("persists reset lifecycle entry changes with an in-log boundary", async () => { - const now = Date.now(); - const sessionKey = "agent:main:main"; - const previousTranscript = path.join(tempDir, "previous-session.jsonl"); - const previousEntry: SessionEntry = { - sessionFile: previousTranscript, - sessionId: "previous-session", - updatedAt: now, - }; - const nextEntry: SessionEntry = { - sessionFile: formatSqliteSessionFileMarker({ - agentId: "main", - sessionId: previousEntry.sessionId, - storePath, - }), - sessionId: previousEntry.sessionId, - updatedAt: now + 1, - }; - fs.writeFileSync( - previousTranscript, - [ - JSON.stringify({ type: "session", id: "previous-session" }), - JSON.stringify({ - id: "msg-user", - message: { role: "user", content: "hello" }, - parentId: null, - timestamp: "2026-06-16T00:00:00.000Z", - type: "message", - }), - JSON.stringify({ - id: "msg-assistant", - message: { role: "assistant", content: "hi" }, - parentId: "msg-user", - timestamp: "2026-06-16T00:00:01.000Z", - type: "message", - }), - ].join("\n") + "\n", - "utf-8", - ); - await upsertSessionEntry({ sessionKey, storePath }, previousEntry); - - const result = await persistSessionResetLifecycle({ - agentId: "main", - cleanupPreviousTranscript: true, - nextEntry, - nextSessionFile: nextEntry.sessionFile!, - previousEntry, - previousSessionId: previousEntry.sessionId, - sessionKey, - storePath, - }); - - expect(result.replayedMessages).toBe(0); - expect(loadSessionEntry({ sessionKey, storePath })).toMatchObject(nextEntry); - expect(fs.existsSync(previousTranscript)).toBe(true); - const archivedPreviousTranscripts = fs - .readdirSync(tempDir) - .filter((file) => file.startsWith("previous-session.jsonl.reset.")); - expect(archivedPreviousTranscripts).toEqual([]); - await expect( - loadTranscriptEvents({ - agentId: "main", - sessionId: previousEntry.sessionId, - sessionKey, - storePath, - }), - ).resolves.toEqual([ - expect.objectContaining({ id: "msg-user", parentId: null }), - expect.objectContaining({ id: "msg-assistant", parentId: "msg-user" }), - expect.objectContaining({ - type: "reset", - reason: "reset", - firstKeptEntryId: "msg-user", - }), - ]); - }); - it("trims a manual compact transcript and clears stale token metadata", async () => { const sessionId = "11111111-1111-4111-8111-111111111111"; - const manualTranscriptPath = path.join(tempDir, `${sessionId}.jsonl`); const scope = { agentId: "main", sessionId, @@ -2467,7 +2223,6 @@ describe("session accessor seam", () => { contextBudgetStatus, inputTokens: 10, outputTokens: 20, - sessionFile: manualTranscriptPath, sessionId, totalTokens: 30, totalTokensFresh: true, @@ -2522,7 +2277,6 @@ describe("session accessor seam", () => { ]); const updatedEntry = loadSessionEntry(scope); expect(updatedEntry).toMatchObject({ - sessionFile: manualTranscriptPath, sessionId, updatedAt: 500, }); @@ -2850,10 +2604,10 @@ describe("session accessor seam", () => { expect(result.appendedCount).toBe(2); await expect(loadTranscriptEvents(scope)).resolves.toHaveLength(3); expect(loadSessionEntry(scope)).toMatchObject({ - sessionFile: result.sessionFile, sessionId: scope.sessionId, updatedAt: expect.any(Number), }); + expect(result).not.toHaveProperty("sessionFile"); expect(loadSessionEntry(scope)?.updatedAt).toBeGreaterThanOrEqual(10); expect(updates).toEqual([ { @@ -3211,6 +2965,37 @@ describe("session accessor seam", () => { await expect(loadTranscriptEvents(scope)).resolves.toEqual([]); }); + it("exposes only transcript identity to append predicates", async () => { + const scope = { + agentId: "main", + sessionId: "session-predicate-context", + sessionKey: "agent:main:predicate-context", + storePath, + }; + await upsertSessionEntry(scope, { + sessionId: scope.sessionId, + updatedAt: 10, + lastRunError: "private entry state", + }); + let predicateContext: unknown; + + await persistSessionTranscriptTurn(scope, { + messages: [ + { + message: { role: "assistant", content: "not appended", timestamp: 100 }, + shouldAppend: (context) => { + predicateContext = context; + return false; + }, + }, + ], + updateMode: "file-only", + }); + + expect(predicateContext).toEqual(scope); + expect(predicateContext).not.toHaveProperty("sessionEntry"); + }); + it("rejects a guarded transcript turn when same-session lifecycle ownership changes", async () => { const scope = { agentId: "main", @@ -3335,7 +3120,7 @@ describe("session accessor seam", () => { await expect(loadTranscriptEvents(scope)).resolves.toEqual([]); }); - it("routes SQLite transcript turn appends through an active owned file lock", async () => { + it("routes SQLite transcript turn appends through an active owned target lock", async () => { const scope = { agentId: "main", sessionFile: transcriptPath, @@ -3348,8 +3133,9 @@ describe("session accessor seam", () => { await withOwnedSessionTranscriptWrites( { - sessionFile: transcriptPath, + sessionFile: scope.sessionKey, sessionKey: scope.sessionKey, + sessionTarget: scope, withSessionWriteLock: async (run, options) => { publishOptions.push(options?.publishOwnedWrite); const result = await run(); @@ -3380,7 +3166,7 @@ describe("session accessor seam", () => { await expect(loadTranscriptEvents(scope)).resolves.toHaveLength(2); }); - it("resolves store-backed runtime transcript targets with stale file paths to markers", async () => { + it("resolves store-backed runtime transcript targets from structured identity", async () => { const staleSessionFile = path.join(tempDir, "session-1.jsonl"); const scope = { agentId: "main", @@ -3388,7 +3174,6 @@ describe("session accessor seam", () => { sessionKey: "agent:main:main", storePath, }; - const marker = `sqlite:main:${scope.sessionId}:${storePath}`; await upsertSessionEntry(scope, { sessionId: scope.sessionId, @@ -3399,34 +3184,12 @@ describe("session accessor seam", () => { const readTarget = await resolveSessionTranscriptRuntimeReadTarget(scope); const writeTarget = await resolveSessionTranscriptRuntimeTarget(scope); - expect(readTarget.sessionFile).toBe(marker); - expect(writeTarget.sessionFile).toBe(marker); - expect(loadSessionEntry(scope)?.sessionFile).toBe(staleSessionFile); + expect(readTarget).toEqual(scope); + expect(writeTarget).toEqual(scope); + expect(loadSessionEntry(scope)).not.toHaveProperty("sessionFile"); }); - it("resolves SQLite-backed runtime transcript targets to markers", async () => { - const scope = { - agentId: "main", - sessionId: "session-1", - sessionKey: "agent:main:main", - storePath, - }; - const marker = `sqlite:main:${scope.sessionId}:${storePath}`; - - await upsertSessionEntry(scope, { - sessionId: scope.sessionId, - sessionFile: marker, - updatedAt: 10, - }); - - const readTarget = await resolveSessionTranscriptRuntimeReadTarget(scope); - const writeTarget = await resolveSessionTranscriptRuntimeTarget(scope); - - expect(readTarget.sessionFile).toBe(marker); - expect(writeTarget.sessionFile).toBe(marker); - }); - - it("normalizes imported legacy session transcript paths to SQLite markers", async () => { + it("drops imported legacy session transcript paths from canonical rows", async () => { const sessionKey = "agent:main:main"; await importSqliteSessionRows({ agentId: "main", @@ -3444,8 +3207,8 @@ describe("session accessor seam", () => { agentId: "main", sessionKey, storePath, - })?.entry.sessionFile, - ).toBe(`sqlite:main:session-1:${path.join(tempDir, "openclaw-agent.sqlite")}`); + })?.entry, + ).not.toHaveProperty("sessionFile"); }); it("tracks replacement and deletion transcript mutations", async () => { @@ -3536,18 +3299,21 @@ describe("session accessor seam", () => { expect(readGeneration()?.generation).not.toBe(replaced); }); - it("resolves an explicit read transcript file without agent identity", () => { + it("ignores an explicit legacy read file and resolves SQLite identity", () => { const explicitSessionFile = path.join(tempDir, "explicit-read-session.jsonl"); const target = resolveSessionTranscriptReadTarget({ + agentId: "main", sessionFile: explicitSessionFile, sessionId: "session-1", }); - expect(target).toEqual({ - sessionFile: explicitSessionFile, + expect(target).toMatchObject({ + agentId: "main", sessionId: "session-1", + storePath: expect.stringMatching(/sessions\.json$/), }); + expect(target).not.toHaveProperty("sessionFile"); }); it("does not expose legacy custom transcript paths as read fallbacks after SQLite migration", async () => { @@ -3569,7 +3335,12 @@ describe("session accessor seam", () => { storePath, }); - expect(target.sessionFile).toContain("sqlite:main:custom-topic-session:"); + expect(target).toEqual({ + agentId: "main", + sessionId: "custom-topic-session", + sessionKey, + storePath, + }); }); }); /* oxlint-disable max-lines -- TODO: split this grandfathered oversized file. */ diff --git a/src/config/sessions/session-accessor.transcript-target.ts b/src/config/sessions/session-accessor.transcript-target.ts index f62b39f04093..ea8507e5b270 100644 --- a/src/config/sessions/session-accessor.transcript-target.ts +++ b/src/config/sessions/session-accessor.transcript-target.ts @@ -1,229 +1,105 @@ import { resolveAgentIdFromSessionKey } from "../../routing/session-key.js"; import { getRuntimeConfig } from "../io.js"; -import { - resolveSessionFilePath, - resolveSessionFilePathOptions, - resolveStorePath, -} from "./paths.js"; -import { loadSessionEntry, resolveSessionEntrySelection } from "./session-accessor.entry.js"; +import { resolveStorePath } from "./paths.js"; +import { resolveSessionEntrySelection } from "./session-accessor.entry.js"; import type { - SessionTranscriptRuntimeScope, SessionTranscriptReadScope, SessionTranscriptReadTarget, + SessionTranscriptRuntimeScope, SessionTranscriptRuntimeTarget, } from "./session-accessor.types.js"; -import { - formatSqliteSessionFileMarker, - parseSqliteSessionFileMarker, - sqliteSessionFileMarkerMatchesSession, -} from "./sqlite-marker.js"; -import type { SessionEntry } from "./types.js"; - -/** - * Resolves the current storage-neutral runtime transcript target. SQLite-backed - * rows return their marker so transcript readers/writers stay on the accessor - * path instead of reopening legacy JSONL artifacts. - */ -export async function resolveSessionTranscriptRuntimeTarget( - scope: SessionTranscriptRuntimeScope, -): Promise { - const { agentId, sessionEntry, sessionKey } = resolveSessionTranscriptRuntimeContext(scope); - if (shouldUseExplicitTranscriptFile(scope)) { - return { - agentId, - sessionFile: scope.sessionFile.trim(), - sessionId: scope.sessionId, - sessionKey, - }; - } - return { - agentId, - sessionFile: resolveRuntimeSessionFile(scope, agentId, sessionEntry), - sessionId: scope.sessionId, - sessionKey, - }; -} - -/** - * Resolves the runtime transcript target for read/delete probes without - * persisting missing sessionFile metadata into the session store. - */ -export async function resolveSessionTranscriptRuntimeReadTarget( - scope: SessionTranscriptRuntimeScope, -): Promise { - const { agentId, sessionEntry, sessionKey } = resolveSessionTranscriptRuntimeContext(scope); - if (shouldUseExplicitTranscriptFile(scope)) { - return { - agentId, - sessionFile: scope.sessionFile.trim(), - sessionId: scope.sessionId, - sessionKey, - }; - } - return { - agentId, - sessionFile: resolveRuntimeSessionFile(scope, agentId, sessionEntry), - sessionId: scope.sessionId, - sessionKey, - }; -} - -function resolveRuntimeSessionFile( - scope: SessionTranscriptRuntimeScope, - agentId: string, - sessionEntry: SessionEntry | undefined, -): string { - const matchingSessionEntry = - sessionEntry?.sessionId === undefined || sessionEntry.sessionId === scope.sessionId - ? sessionEntry - : undefined; - if ( - sqliteSessionFileMarkerMatchesSession(matchingSessionEntry?.sessionFile, scope.sessionId) && - matchingSessionEntry?.sessionFile - ) { - return matchingSessionEntry.sessionFile; - } - if (scope.storePath) { - return formatSqliteSessionFileMarker({ - agentId, - sessionId: scope.sessionId, - storePath: scope.storePath, - }); - } - return resolveSessionFilePath( - scope.sessionId, - matchingSessionEntry, - resolveSessionFilePathOptions({ - agentId, - storePath: scope.storePath, - }), - ); -} +import { resolveSessionStorePathForScope } from "./session-store-path.js"; type SessionTranscriptRuntimeContext = { agentId: string; - sessionEntry: SessionEntry | undefined; sessionKey: string; + storePath: string; }; -function resolveSessionTranscriptRuntimeContext( - scope: SessionTranscriptRuntimeScope, +function resolveRuntimeContext( + scope: Pick, ): SessionTranscriptRuntimeContext { const agentId = scope.agentId ?? resolveAgentIdFromSessionKey(scope.sessionKey); if (!agentId) { throw new Error(`Cannot resolve transcript scope without an agent id: ${scope.sessionKey}`); } - const resolvedStoreEntry = scope.storePath - ? resolveSessionEntrySelection({ - ...scope, - agentId, - }) - : undefined; - const sessionEntry = resolvedStoreEntry?.existing ?? loadSessionEntry(scope); - const sessionKey = resolvedStoreEntry?.normalizedKey ?? scope.sessionKey; - return { + const configuredStorePath = + resolveConcreteSessionStorePath(scope.storePath) ?? + resolveStorePath(getRuntimeConfig().session?.store, { agentId, env: scope.env }); + const storePath = resolveSessionStorePathForScope({ agentId, - sessionKey, - sessionEntry, - }; -} - -/** - * Resolves the current storage-neutral target for read-only transcript callers. - * Unlike writer/runtime resolution, this does not persist missing sessionFile - * metadata; reader projections must not mutate session metadata. - */ -export function resolveSessionTranscriptReadTarget( - scope: SessionTranscriptReadScope, -): SessionTranscriptReadTarget { - const explicitSessionFile = scope.sessionFile?.trim(); - if (explicitSessionFile) { - return { - sessionFile: explicitSessionFile, - sessionId: scope.sessionId, - ...(scope.agentId ? { agentId: scope.agentId } : {}), - ...(scope.sessionKey ? { sessionKey: scope.sessionKey } : {}), - }; - } - const entrySessionFile = scope.sessionEntry?.sessionFile?.trim(); - const entryMarker = parseSqliteSessionFileMarker(entrySessionFile); - if (entrySessionFile && entryMarker && entryMarker.sessionId === scope.sessionId) { - return { - agentId: scope.agentId ?? entryMarker.agentId, - sessionFile: entrySessionFile, - sessionId: scope.sessionId, - ...(scope.sessionKey ? { sessionKey: scope.sessionKey } : {}), - }; - } - const agentId = scope.agentId ?? resolveAgentIdFromSessionKey(scope.sessionKey); - if (!agentId) { - throw new Error(`Cannot resolve transcript scope without an agent id: ${scope.sessionKey}`); - } - const storePath = resolveConcreteReadStorePath(scope.storePath); - if (!storePath) { - const resolvedStorePath = resolveStorePath(getRuntimeConfig().session?.store, { - agentId, - env: scope.env, - }); - return { - agentId, - sessionFile: formatSqliteSessionFileMarker({ - agentId, - sessionId: scope.sessionId, - storePath: resolvedStorePath, - }), - sessionId: scope.sessionId, - ...(scope.sessionKey ? { sessionKey: scope.sessionKey } : {}), - }; - } - const resolvedStoreEntry = - scope.sessionEntry || !scope.sessionKey - ? undefined - : resolveSessionEntrySelection( - { - ...(scope.env ? { env: scope.env } : {}), - agentId, - sessionKey: scope.sessionKey, - storePath, - }, - { readOnly: true }, - ); - const sessionKey = resolvedStoreEntry?.normalizedKey ?? scope.sessionKey; - const sessionFile = formatSqliteSessionFileMarker({ + env: scope.env, + sessionKey: scope.sessionKey, + storePath: configuredStorePath, + }); + const resolved = resolveSessionEntrySelection({ agentId, - sessionId: scope.sessionId, - storePath: storePath ?? "", + ...(scope.env ? { env: scope.env } : {}), + sessionKey: scope.sessionKey, + storePath, }); return { agentId, - sessionFile, - sessionId: scope.sessionId, - ...(sessionKey ? { sessionKey } : {}), + sessionKey: resolved?.normalizedKey ?? scope.sessionKey, + storePath, }; } -function resolveConcreteReadStorePath(storePath: string | undefined): string | undefined { +/** Resolves the canonical SQLite identity for runtime transcript access. */ +export async function resolveSessionTranscriptRuntimeTarget( + scope: SessionTranscriptRuntimeScope, +): Promise { + const context = resolveRuntimeContext(scope); + return { ...context, sessionId: scope.sessionId }; +} + +/** Read-only resolution shares the same identity without persisting metadata locators. */ +export async function resolveSessionTranscriptRuntimeReadTarget( + scope: SessionTranscriptRuntimeScope, +): Promise { + return await resolveSessionTranscriptRuntimeTarget(scope); +} + +export function resolveSessionTranscriptReadTarget( + scope: SessionTranscriptReadScope, +): SessionTranscriptReadTarget { + const sessionKey = scope.sessionKey?.trim(); + const agentId = scope.agentId ?? resolveAgentIdFromSessionKey(sessionKey); + if (!agentId) { + throw new Error(`Cannot resolve transcript scope without an agent id: ${sessionKey}`); + } + const configuredStorePath = + resolveConcreteSessionStorePath(scope.storePath) ?? + resolveStorePath(getRuntimeConfig().session?.store, { agentId, env: scope.env }); + const storePath = resolveSessionStorePathForScope({ + agentId, + env: scope.env, + sessionKey, + storePath: configuredStorePath, + }); + const resolved = sessionKey + ? resolveSessionEntrySelection( + { + agentId, + ...(scope.env ? { env: scope.env } : {}), + sessionKey, + storePath, + }, + { readOnly: true }, + ) + : undefined; + return { + agentId, + sessionId: scope.sessionId, + storePath, + ...(resolved?.normalizedKey ? { sessionKey: resolved.normalizedKey } : {}), + }; +} + +export function resolveConcreteSessionStorePath(storePath: string | undefined): string | undefined { const trimmed = storePath?.trim(); if (!trimmed || trimmed === "(multiple)" || trimmed.includes("{agentId}")) { return undefined; } return trimmed; } - -export function shouldUseExplicitTranscriptFile< - TScope extends { - sessionFile?: string; - sessionId?: string; - sessionKey?: string; - storePath?: string; - }, ->(scope: TScope): scope is TScope & { sessionFile: string } { - const explicitSessionFile = scope.sessionFile?.trim(); - if (!explicitSessionFile) { - return false; - } - const hasStoreIdentity = Boolean( - scope.storePath?.trim() && scope.sessionKey?.trim() && scope.sessionId?.trim(), - ); - return !hasStoreIdentity; -} diff --git a/src/config/sessions/session-accessor.transcript-turn.ts b/src/config/sessions/session-accessor.transcript-turn.ts index 6aa763f77695..e016c0def71c 100644 --- a/src/config/sessions/session-accessor.transcript-turn.ts +++ b/src/config/sessions/session-accessor.transcript-turn.ts @@ -11,7 +11,6 @@ import { } from "./session-accessor.entry.js"; import { redactTranscriptMessageForStorage } from "./session-accessor.sqlite-transcript-store.js"; import { appendSqliteExpectedSessionTranscriptTurn } from "./session-accessor.sqlite.js"; -import { shouldUseExplicitTranscriptFile } from "./session-accessor.transcript-target.js"; import { appendTranscriptMessage, emitTranscriptUpdate } from "./session-accessor.transcript.js"; import type { SessionTranscriptWriteScope, @@ -22,7 +21,6 @@ import type { SessionTranscriptTurnPersistOptions, SessionTranscriptTurnPersistResult, } from "./session-accessor.types.js"; -import { formatSqliteSessionFileMarker, parseSqliteSessionFileMarker } from "./sqlite-marker.js"; import { runWithOwnedSessionTranscriptWriteLock } from "./transcript-write-context.js"; import type { SessionEntry } from "./types.js"; @@ -64,7 +62,7 @@ export async function appendTranscriptMessages( /** * Persists one logical transcript turn through the SQLite-backed session target. - * Transcript row append(s), the synthetic sessionFile marker, and the requested + * Transcript row append(s) and the requested * updatedAt touch happen before transcript update delivery is published. */ export async function persistSessionTranscriptTurn( @@ -84,8 +82,9 @@ export async function persistSessionTranscriptTurn( const target = await resolveTranscriptTurnTarget(scope, options.config); const appendedMessages = await runWithOwnedSessionTranscriptWriteLock( { - sessionFile: target.sessionFile, + sessionFile: target.sessionKey, sessionKey: target.sessionKey, + sessionTarget: target, }, () => appendTranscriptTurnMessages(target, options), ); @@ -106,7 +105,6 @@ export async function persistSessionTranscriptTurn( appendedCount, messages: appendedMessages, sessionEntry, - sessionFile: target.sessionFile, }; } @@ -147,7 +145,6 @@ async function selectAppendableTranscriptTurnMessages( const shouldAppend = append.shouldAppend ? await append.shouldAppend({ ...(target.agentId ? { agentId: target.agentId } : {}), - sessionFile: target.sessionFile, ...(target.sessionId ? { sessionId: target.sessionId } : {}), ...(target.sessionKey ? { sessionKey: target.sessionKey } : {}), ...(target.storePath ? { storePath: target.storePath } : {}), @@ -200,22 +197,17 @@ async function persistExpectedSessionTranscriptTurn( sessionKey, storePath, }); - const sessionFile = formatSqliteSessionFileMarker({ - agentId, - sessionId: expectedSessionId, - storePath, - }); const target: SessionTranscriptTurnWriteContext = { agentId, - sessionFile, sessionId: expectedSessionId, sessionKey: resolved.normalizedKey, storePath, }; const turn = await runWithOwnedSessionTranscriptWriteLock( { - sessionFile: target.sessionFile, + sessionFile: target.sessionKey, sessionKey: target.sessionKey, + sessionTarget: target, }, () => appendSqliteExpectedSessionTranscriptTurn( @@ -234,7 +226,7 @@ async function persistExpectedSessionTranscriptTurn( atomicGroup: options.atomicGroup, messages: options.messages, sessionLifecyclePatch: options.sessionLifecyclePatch, - sessionFile: target.sessionFile, + sessionFile: target.sessionKey!, touchSessionEntry: options.touchSessionEntry, }, ), @@ -246,7 +238,6 @@ async function persistExpectedSessionTranscriptTurn( messages: [], rejectedReason: "session-rebound", sessionEntry: turn.sessionEntry, - sessionFile: turn.sessionFile, }; } @@ -264,7 +255,6 @@ async function persistExpectedSessionTranscriptTurn( appendedCount: countAppendedTranscriptMessages(turn.appendedMessages), messages: turn.appendedMessages, sessionEntry: turn.sessionEntry ?? scope.sessionEntry, - sessionFile: turn.sessionFile, }; } @@ -279,25 +269,9 @@ async function resolveTranscriptTurnTarget( sessionEntry: SessionEntry | undefined; } > { - if (shouldUseExplicitTranscriptFile(scope)) { - const marker = parseSqliteSessionFileMarker(scope.sessionFile); - const agentId = scope.agentId ?? marker?.agentId; - const sessionId = scope.sessionId ?? marker?.sessionId; - const storePath = scope.storePath ?? marker?.storePath; - return { - ...(agentId ? { agentId } : {}), - sessionFile: scope.sessionFile.trim(), - ...(sessionId ? { sessionId } : {}), - ...(scope.sessionKey ? { sessionKey: scope.sessionKey } : {}), - ...(storePath ? { storePath } : {}), - sessionEntry: scope.sessionEntry, - }; - } const sessionKey = scope.sessionKey?.trim(); if (!sessionKey || !scope.sessionId) { - throw new Error( - "Cannot persist a transcript turn without a session key and session id or explicit session file", - ); + throw new Error("Cannot persist a transcript turn without a session key and session id"); } const agentId = scope.agentId ?? @@ -323,14 +297,8 @@ async function resolveTranscriptTurnTarget( resolved?.existing ?? scope.sessionEntry ?? loadSessionEntry({ ...scope, agentId, sessionKey, storePath }); - const sessionFile = formatSqliteSessionFileMarker({ - agentId, - sessionId: scope.sessionId, - storePath, - }); return { agentId, - sessionFile, sessionId: scope.sessionId, sessionKey: resolved?.normalizedKey ?? sessionKey, storePath, @@ -356,7 +324,7 @@ async function touchTranscriptTurnSessionEntry(params: { ) { return params.target.sessionEntry; } - const markerUpdatedAt = Date.now(); + const updatedAt = Date.now(); const updated = await updateSessionEntry( { sessionKey: params.target.sessionKey, @@ -365,10 +333,7 @@ async function touchTranscriptTurnSessionEntry(params: { }, (current) => current.sessionId === params.target.sessionId - ? { - sessionFile: params.target.sessionFile, - updatedAt: Math.max(current.updatedAt ?? 0, markerUpdatedAt), - } + ? { updatedAt: Math.max(current.updatedAt ?? 0, updatedAt) } : null, { skipMaintenance: true }, ); @@ -409,6 +374,5 @@ async function publishTranscriptTurnUpdate(params: { messageId: lastAppended.messageId, } : {}), - sessionFile: params.target.sessionFile, }); } diff --git a/src/config/sessions/session-accessor.transcript.ts b/src/config/sessions/session-accessor.transcript.ts index a54e618d5fb9..92c71df8cfae 100644 --- a/src/config/sessions/session-accessor.transcript.ts +++ b/src/config/sessions/session-accessor.transcript.ts @@ -9,6 +9,8 @@ import { loadSqliteTranscriptEventRowsAfterSeqSync as loadTranscriptEventRowsAfterSeqSync, loadSqliteTranscriptEvents as loadTranscriptEvents, loadSqliteTranscriptEventsSync as loadTranscriptEventsSync, + loadSqliteTranscriptHeaderSync as loadTranscriptHeaderSync, + loadSqliteTranscriptTailEventsSync as loadTranscriptTailEventsSync, readSqliteTranscriptStatsSync as readTranscriptStatsSync, readSqliteTranscriptEventAtSeqSync as readTranscriptEventAtSeqSync, readSqliteTranscriptRawDelta as readTranscriptRawDelta, @@ -42,6 +44,8 @@ export { loadTranscriptEventRowsAfterSeqSync, loadTranscriptEvents, loadTranscriptEventsSync, + loadTranscriptHeaderSync, + loadTranscriptTailEventsSync, publishTranscriptUpdate, readLatestTranscriptAssistantText, readTranscriptEventAtSeqSync, diff --git a/src/config/sessions/session-accessor.ts b/src/config/sessions/session-accessor.ts index ab1124e5d296..fcb19a46de41 100644 --- a/src/config/sessions/session-accessor.ts +++ b/src/config/sessions/session-accessor.ts @@ -191,6 +191,8 @@ export { loadTranscriptEventRowsAfterSeqSync, loadTranscriptEvents, loadTranscriptEventsSync, + loadTranscriptHeaderSync, + loadTranscriptTailEventsSync, preflightSessionTranscriptForManualCompact, publishTranscriptUpdate, readLatestTranscriptAssistantText, @@ -210,6 +212,9 @@ export { } from "./session-accessor.transcript-turn.js"; export { isSessionTranscriptProjectionUnavailableError, + readRecentSessionTranscriptActiveEvents, + readSessionTranscriptActiveStats, + readSessionTranscriptBoundedMessageTailPage, readRecentSessionTranscriptMessageEvents, readSessionTranscriptActiveLeafEvents, readSessionTranscriptMessageAnchorPage, @@ -222,11 +227,13 @@ export { waitForSessionTranscriptProjection, } from "./session-accessor.sqlite-active-events.js"; export type { + SessionTranscriptBoundedMessageTailPage, SessionTranscriptMessageAnchorPage, SessionTranscriptMessageEvent, SessionTranscriptMessageEventPage, } from "./session-accessor.sqlite-active-events.js"; export { + resolveConcreteSessionStorePath, resolveSessionTranscriptReadTarget, resolveSessionTranscriptRuntimeReadTarget, resolveSessionTranscriptRuntimeTarget, diff --git a/src/config/sessions/session-accessor.types.ts b/src/config/sessions/session-accessor.types.ts index 72bedc8591ec..5e369ba0f001 100644 --- a/src/config/sessions/session-accessor.types.ts +++ b/src/config/sessions/session-accessor.types.ts @@ -152,16 +152,15 @@ export type SessionTranscriptReadScope = Omit & Partial>; + sessionEntry?: Partial>; }; -export type SessionTranscriptReadTarget = Omit< - SessionTranscriptRuntimeTarget, - "agentId" | "sessionKey" -> & { +export interface SessionTranscriptReadTarget { agentId?: string; + sessionId: string; sessionKey?: string; -}; + storePath: string; +} export type SessionTranscriptWriteScope = Omit & { /** Optional for appenders that resolve it from the session entry. */ @@ -333,8 +332,11 @@ export type SessionTranscriptWriteLockAccessorContext = { }; export type SessionTranscriptWriteTransactionContext = { - /** Canonical marker for the same agent database owned by the transaction. */ - sessionFile: string; + /** Canonical transcript identity owned by the transaction. */ + agentId: string; + sessionId: string; + sessionKey: string; + storePath: string; }; export type SessionTranscriptTurnUpdateMode = "inline" | "file-only" | "none"; @@ -350,7 +352,6 @@ export type SessionTranscriptTurnMessageAppend = TranscriptMessageAppendOptions< export type SessionTranscriptTurnWriteContext = { agentId?: string; - sessionFile: string; sessionId?: string; sessionKey?: string; storePath?: string; @@ -388,20 +389,19 @@ export type SessionTranscriptTurnPersistOptions = { touchSessionEntry?: boolean; }; -export type SessionTranscriptTurnPersistResult = { +export interface SessionTranscriptTurnPersistResult { appendedCount: number; messages: TranscriptMessageAppendResult[]; rejectedReason?: "session-rebound"; sessionEntry: SessionEntry | undefined; - sessionFile: string; -}; +} -export type SessionTranscriptRuntimeTarget = { +export interface SessionTranscriptRuntimeTarget { agentId: string; - sessionFile: string; sessionId: string; sessionKey: string; -}; + storePath: string; +} export type SessionTranscriptManualTrimResult = | { @@ -795,6 +795,8 @@ export type SessionEntryCreateWithTranscriptPrepareResult = export type SessionEntryCreateWithTranscriptOptions = { /** Protect the newly created row from maintenance during its initial write. */ activeSessionKey?: string; + /** Working directory stored in the initial transcript header. */ + cwd?: string; /** SQLite commits are authoritative; retained for the shared caller contract. */ requireWriteSuccess?: boolean; }; diff --git a/src/config/sessions/session-entry.test-compat.d.ts b/src/config/sessions/session-entry.test-compat.d.ts new file mode 100644 index 000000000000..3c8dc02f6d25 --- /dev/null +++ b/src/config/sessions/session-entry.test-compat.d.ts @@ -0,0 +1,29 @@ +// Test-only compatibility for legacy file/artifact fixtures. +import "./types.js"; +import "./session-accessor.types.js"; + +declare module "./types.js" { + interface SessionEntry { + sessionFile?: string; + transcriptPath?: string; + } + + interface InternalSessionEntry { + sessionFile?: string; + transcriptPath?: string; + } +} + +declare module "./session-accessor.types.js" { + interface SessionTranscriptRuntimeTarget { + sessionFile?: string; + } + + interface SessionTranscriptTurnPersistResult { + sessionFile?: string; + } + + interface SessionTranscriptReadTarget { + sessionFile?: string; + } +} diff --git a/src/config/sessions/session-file-rotation.ts b/src/config/sessions/session-file-rotation.ts deleted file mode 100644 index 7393a7a21613..000000000000 --- a/src/config/sessions/session-file-rotation.ts +++ /dev/null @@ -1,57 +0,0 @@ -// Session file rotation rewrites transcript paths when session ids reset or fork. -import fs from "node:fs"; -import path from "node:path"; -import { normalizeOptionalString } from "@openclaw/normalization-core/string-coerce"; - -/** Rewrites transcript file paths when a session id changes during reset or fork. */ -export function rewriteSessionFileForNewSessionId(params: { - sessionFile?: string; - previousSessionId: string; - nextSessionId: string; -}): string | undefined { - const trimmed = normalizeOptionalString(params.sessionFile); - if (!trimmed) { - return undefined; - } - const base = path.basename(trimmed); - if (!base.endsWith(".jsonl")) { - return undefined; - } - const withoutExt = base.slice(0, -".jsonl".length); - if (withoutExt === params.previousSessionId) { - return path.join(path.dirname(trimmed), `${params.nextSessionId}.jsonl`); - } - if (withoutExt.startsWith(`${params.previousSessionId}-topic-`)) { - // Topic transcripts preserve their encoded topic suffix while adopting the new session id. - return path.join( - path.dirname(trimmed), - `${params.nextSessionId}${base.slice(params.previousSessionId.length)}`, - ); - } - const forkMatch = withoutExt.match( - /^(\d{4}-\d{2}-\d{2}T[\w-]+(?:Z|[+-]\d{2}(?:-\d{2})?)?)_(.+)$/, - ); - if (forkMatch?.[2] === params.previousSessionId) { - return path.join(path.dirname(trimmed), `${forkMatch[1]}_${params.nextSessionId}.jsonl`); - } - return undefined; -} - -export function canonicalizeAbsoluteSessionFilePath(filePath: string): string { - const resolved = path.resolve(filePath); - const missingSegments: string[] = []; - let cursor = resolved; - while (true) { - try { - // Canonicalize the deepest existing parent; the transcript file may not exist yet. - return path.join(fs.realpathSync(cursor), ...missingSegments.toReversed()); - } catch { - const parent = path.dirname(cursor); - if (parent === cursor) { - return resolved; - } - missingSegments.push(path.basename(cursor)); - cursor = parent; - } - } -} diff --git a/src/config/sessions/session-file.ts b/src/config/sessions/session-file.ts deleted file mode 100644 index 7c6c453016ba..000000000000 --- a/src/config/sessions/session-file.ts +++ /dev/null @@ -1,39 +0,0 @@ -// Session file persistence syncs active session transcript markers into store metadata. -import { normalizeAgentId } from "../../routing/session-key.js"; -import { upsertSessionEntry } from "./session-accessor.js"; -import { formatSqliteSessionFileMarker } from "./sqlite-marker.js"; -import type { SessionEntry } from "./types.js"; - -/** Resolves the active SQLite transcript marker and persists it into the session store when needed. */ -export async function resolveAndPersistSessionFile(params: { - sessionId: string; - sessionKey: string; - sessionStore: Record; - storePath: string; - sessionEntry?: SessionEntry; - agentId?: string; -}): Promise<{ sessionFile: string; sessionEntry: SessionEntry }> { - const { sessionId, sessionKey, sessionStore, storePath } = params; - const now = Date.now(); - const baseEntry = params.sessionEntry ?? - sessionStore[sessionKey] ?? { sessionId, updatedAt: now, sessionStartedAt: now }; - const sessionFile = formatSqliteSessionFileMarker({ - agentId: normalizeAgentId(params.agentId), - sessionId, - storePath, - }); - const persistedEntry: SessionEntry = { - ...baseEntry, - sessionId, - updatedAt: now, - sessionStartedAt: baseEntry.sessionId === sessionId ? (baseEntry.sessionStartedAt ?? now) : now, - sessionFile, - }; - if (baseEntry.sessionId !== sessionId || baseEntry.sessionFile !== sessionFile) { - sessionStore[sessionKey] = persistedEntry; - await upsertSessionEntry({ storePath, sessionKey }, persistedEntry); - return { sessionFile, sessionEntry: persistedEntry }; - } - sessionStore[sessionKey] = persistedEntry; - return { sessionFile, sessionEntry: persistedEntry }; -} diff --git a/src/config/sessions/session-reset-boundary-event.ts b/src/config/sessions/session-reset-boundary-event.ts index 9daa2f1b227b..3aeffbc61ee9 100644 --- a/src/config/sessions/session-reset-boundary-event.ts +++ b/src/config/sessions/session-reset-boundary-event.ts @@ -1,5 +1,5 @@ import { randomUUID } from "node:crypto"; -import { parseSqliteSessionFileMarker } from "./sqlite-marker.js"; +import path from "node:path"; import { DEFAULT_REPLAY_MAX_MESSAGES, replayableTranscriptRole, @@ -101,7 +101,7 @@ function buildSessionResetBoundaryEvent(params: { async function readLegacyTranscriptEvents(sessionFile: string | undefined): Promise { const filePath = sessionFile?.trim(); - if (!filePath || parseSqliteSessionFileMarker(filePath)) { + if (!filePath || !path.isAbsolute(filePath) || !filePath.endsWith(".jsonl")) { return []; } try { diff --git a/src/config/sessions/session-transcript-projection-error.ts b/src/config/sessions/session-transcript-projection-error.ts new file mode 100644 index 000000000000..c1229b682405 --- /dev/null +++ b/src/config/sessions/session-transcript-projection-error.ts @@ -0,0 +1,12 @@ +export class SessionTranscriptProjectionUnavailableError extends Error { + constructor(readonly sessionId: string) { + super(`Session transcript projection is rebuilding: ${sessionId}`); + this.name = "SessionTranscriptProjectionUnavailableError"; + } +} + +export function isSessionTranscriptProjectionUnavailableError( + error: unknown, +): error is SessionTranscriptProjectionUnavailableError { + return error instanceof SessionTranscriptProjectionUnavailableError; +} diff --git a/src/config/sessions/session-transcript-reconcile.ts b/src/config/sessions/session-transcript-reconcile.ts index aa8ba98966ca..4cb6efb1445e 100644 --- a/src/config/sessions/session-transcript-reconcile.ts +++ b/src/config/sessions/session-transcript-reconcile.ts @@ -11,7 +11,12 @@ import { type OpenClawAgentDatabase, type OpenClawAgentDatabaseOptions, } from "../../state/openclaw-agent-db.js"; -import { runExclusiveSqliteSessionWrite } from "./session-accessor.sqlite-scope.js"; +import type { SessionTranscriptReadScope } from "./session-accessor.sqlite-contract.js"; +import { + resolveSqliteTranscriptReadScope, + runExclusiveSqliteSessionWrite, + toDatabaseOptions, +} from "./session-accessor.sqlite-scope.js"; import { deleteOrphanedTranscriptIndexRowsInTransaction } from "./session-transcript-index.js"; import { appendPreparedSessionTranscriptProjectionChunkInTransaction, @@ -387,3 +392,11 @@ export async function waitForSessionTranscriptIndexReconcile( ): Promise { await runningReconciles.get(reconcileKey(params))?.promise; } + +/** Waits for a projection rebuild already scheduled by a failed transcript read. */ +export async function waitForSessionTranscriptProjection( + scope: SessionTranscriptReadScope, +): Promise { + const resolved = resolveSqliteTranscriptReadScope(scope); + await waitForSessionTranscriptIndexReconcile(toDatabaseOptions(resolved)); +} diff --git a/src/config/sessions/session-transcript-turn-state.ts b/src/config/sessions/session-transcript-turn-state.ts index 4eacf314e36b..7952e77c38ba 100644 --- a/src/config/sessions/session-transcript-turn-state.ts +++ b/src/config/sessions/session-transcript-turn-state.ts @@ -84,9 +84,6 @@ export function buildExpectedTranscriptTurnSessionPatch(params: { return { ...(acceptedMessage ? params.sessionLifecyclePatch : undefined), ...(acceptedMessage && restartRecoveryTerminalRunIds ? { restartRecoveryTerminalRunIds } : {}), - ...(params.currentEntry.sessionFile === params.sessionFile - ? {} - : { sessionFile: params.sessionFile }), ...(touchUpdatedAt > 0 ? { updatedAt: Math.max( diff --git a/src/config/sessions/sessions.test.ts b/src/config/sessions/sessions.test.ts index 36294987ec11..4d76d8a333a7 100644 --- a/src/config/sessions/sessions.test.ts +++ b/src/config/sessions/sessions.test.ts @@ -1,11 +1,10 @@ // Session config tests cover session creation, updates, and persistence. import fs from "node:fs"; -import fsPromises from "node:fs/promises"; import path from "node:path"; -import { describe, expect, it, vi } from "vitest"; +import { describe, expect, it } from "vitest"; import { withTempDirSync } from "../../test-helpers/temp-dir.js"; import type { SessionConfig } from "../types.base.js"; -import { resolveSessionLifecycleTimestamps, resolveSessionWorkStartError } from "./lifecycle.js"; +import { resolveSessionWorkStartError } from "./lifecycle.js"; import { resolveSessionFilePath, resolveSessionFilePathOptions, @@ -14,10 +13,7 @@ import { } from "./paths.js"; import { evaluateSessionFreshness, resolveSessionResetPolicy } from "./reset.js"; import { mergeRestartRecoveryTerminalRunIds } from "./restart-recovery-state.js"; -import { loadSessionEntry } from "./session-accessor.js"; -import { resolveAndPersistSessionFile } from "./session-file.js"; -import { formatSqliteSessionFileMarker } from "./sqlite-marker.js"; -import { useTempSessionsFixture } from "./test-helpers.js"; +import { normalizePersistedSessionEntryShape } from "./store-entry-shape.js"; it("merges bounded restart tombstones without evicting fresh-only ids", () => { const existing = Array.from({ length: 64 }, (_, index) => `run-${index}`); @@ -29,6 +25,50 @@ it("merges bounded restart tombstones without evicting fresh-only ids", () => { expect(mergeRestartRecoveryTerminalRunIds(existing, ["run-0"])).toEqual(existing); }); +it("filters legacy row metadata with a noncanonical transcript id", () => { + expect( + normalizePersistedSessionEntryShape({ + sessionId: "legacy:session", + updatedAt: 42, + pluginExtensions: { memory: { mode: "legacy" } }, + }), + ).toBeUndefined(); +}); + +it("preserves shipped pending key-as-session-id rows without a transcript id", () => { + expect( + normalizePersistedSessionEntryShape( + { + sessionId: "agent:child:main", + updatedAt: 42, + }, + { sessionKey: "agent:child:main" }, + ), + ).toMatchObject({ initializationPending: true, updatedAt: 42 }); + expect( + normalizePersistedSessionEntryShape( + { + sessionId: "agent:child:main", + updatedAt: 42, + }, + { sessionKey: "agent:child:main" }, + ), + ).not.toHaveProperty("sessionId"); +}); + +it("rejects locked key-as-session-id rows instead of treating them as pending", () => { + expect( + normalizePersistedSessionEntryShape( + { + modelSelectionLocked: true, + sessionId: "agent:child:main", + updatedAt: 42, + }, + { sessionKey: "agent:child:main" }, + ), + ).toBeUndefined(); +}); + describe("session path safety", () => { it("rejects unsafe session IDs", () => { const unsafeSessionIds = [ @@ -258,95 +298,6 @@ describe("resolveSessionResetPolicy", () => { }); }); -describe("session lifecycle timestamps", () => { - it("falls back to the JSONL session header for legacy session start time", async () => { - const dir = await fsPromises.mkdtemp("/tmp/openclaw-lifecycle-test-"); - try { - const storePath = path.join(dir, "sessions.json"); - const sessionFile = path.join(dir, "legacy-session.jsonl"); - const headerTimestamp = "2026-04-20T04:30:00.000Z"; - await fsPromises.writeFile( - sessionFile, - `${JSON.stringify({ - type: "session", - version: 3, - id: "legacy-session", - timestamp: headerTimestamp, - cwd: dir, - })}\n`, - "utf8", - ); - - const realReadSync = fs.readSync.bind(fs); - let shortReadCalls = 0; - const readSpy = vi.spyOn(fs, "readSync").mockImplementation((( - fd: number, - buffer: NodeJS.ArrayBufferView, - offset: number, - length: number, - position: fs.ReadPosition | null, - ) => { - shortReadCalls += 1; - return realReadSync(fd, buffer, offset, Math.min(length, 16), position); - }) as typeof fs.readSync); - - try { - const timestamps = resolveSessionLifecycleTimestamps({ - storePath, - entry: { - sessionId: "legacy-session", - sessionFile, - updatedAt: Date.parse("2026-04-25T08:00:00.000Z"), - }, - }); - - expect(timestamps.sessionStartedAt).toBe(Date.parse(headerTimestamp)); - expect(shortReadCalls).toBeGreaterThan(1); - } finally { - readSpy.mockRestore(); - } - } finally { - await fsPromises.rm(dir, { recursive: true, force: true }); - } - }); - - it("ignores out-of-range lifecycle timestamps before header fallback", async () => { - const dir = await fsPromises.mkdtemp("/tmp/openclaw-lifecycle-test-"); - try { - const storePath = path.join(dir, "sessions.json"); - const sessionFile = path.join(dir, "legacy-session.jsonl"); - const headerTimestamp = "2026-04-20T04:30:00.000Z"; - await fsPromises.writeFile( - sessionFile, - `${JSON.stringify({ - type: "session", - version: 3, - id: "legacy-session", - timestamp: headerTimestamp, - cwd: dir, - })}\n`, - "utf8", - ); - - const timestamps = resolveSessionLifecycleTimestamps({ - storePath, - entry: { - sessionId: "legacy-session", - sessionFile, - sessionStartedAt: Number.MAX_SAFE_INTEGER, - lastInteractionAt: Number.MAX_SAFE_INTEGER, - updatedAt: Date.parse("2026-04-25T08:00:00.000Z"), - }, - }); - - expect(timestamps.sessionStartedAt).toBe(Date.parse(headerTimestamp)); - expect(timestamps.lastInteractionAt).toBeUndefined(); - } finally { - await fsPromises.rm(dir, { recursive: true, force: true }); - } - }); -}); - describe("session work admission", () => { it("fails closed while trusted session initialization is pending", () => { expect( @@ -362,104 +313,3 @@ describe("session work admission", () => { ).toBeUndefined(); }); }); - -describe("resolveAndPersistSessionFile", () => { - const fixture = useTempSessionsFixture("session-file-test-"); - - it("persists SQLite transcript markers for sessions without sessionFile", async () => { - const sessionId = "topic-session-id"; - const sessionKey = "agent:main:telegram:group:123:topic:456"; - const store = { - [sessionKey]: { - sessionId, - updatedAt: Date.now(), - }, - }; - const sessionStore = store; - const expectedSessionFile = formatSqliteSessionFileMarker({ - agentId: "main", - sessionId, - storePath: fixture.storePath(), - }); - - const result = await resolveAndPersistSessionFile({ - sessionId, - sessionKey, - sessionStore, - storePath: fixture.storePath(), - sessionEntry: sessionStore[sessionKey], - agentId: "main", - }); - - expect(result.sessionFile).toBe(expectedSessionFile); - - expect(loadSessionEntry({ storePath: fixture.storePath(), sessionKey })?.sessionFile).toBe( - expectedSessionFile, - ); - }); - - it("creates and persists entry when session is not yet present", async () => { - const sessionId = "new-session-id"; - const sessionKey = "agent:main:telegram:group:123"; - const sessionStore = {}; - const expectedSessionFile = formatSqliteSessionFileMarker({ - agentId: "main", - sessionId, - storePath: fixture.storePath(), - }); - - const result = await resolveAndPersistSessionFile({ - sessionId, - sessionKey, - sessionStore, - storePath: fixture.storePath(), - agentId: "main", - }); - - expect(result.sessionFile).toBe(expectedSessionFile); - expect(result.sessionEntry.sessionId).toBe(sessionId); - expect(loadSessionEntry({ storePath: fixture.storePath(), sessionKey })?.sessionFile).toBe( - expectedSessionFile, - ); - }); - - it("rotates to a new SQLite transcript marker when sessionId changes on the same session key", async () => { - const previousSessionId = "old-session-id"; - const nextSessionId = "new-session-id"; - const sessionKey = "agent:main:telegram:group:123"; - const previousSessionFile = resolveSessionTranscriptPathInDir( - previousSessionId, - fixture.sessionsDir(), - ); - const expectedNextSessionFile = formatSqliteSessionFileMarker({ - agentId: "main", - sessionId: nextSessionId, - storePath: fixture.storePath(), - }); - const store = { - [sessionKey]: { - sessionId: previousSessionId, - updatedAt: Date.now(), - sessionFile: previousSessionFile, - }, - }; - const sessionStore = store; - - const result = await resolveAndPersistSessionFile({ - sessionId: nextSessionId, - sessionKey, - sessionStore, - storePath: fixture.storePath(), - sessionEntry: sessionStore[sessionKey], - agentId: "main", - }); - - expect(result.sessionFile).toBe(expectedNextSessionFile); - expect(result.sessionFile).not.toBe(previousSessionFile); - expect(result.sessionEntry.sessionFile).toBe(expectedNextSessionFile); - - expect(loadSessionEntry({ storePath: fixture.storePath(), sessionKey })?.sessionFile).toBe( - expectedNextSessionFile, - ); - }); -}); diff --git a/src/config/sessions/store-entry-shape.ts b/src/config/sessions/store-entry-shape.ts index 5e9b0980f8ec..ffe26aaa3b86 100644 --- a/src/config/sessions/store-entry-shape.ts +++ b/src/config/sessions/store-entry-shape.ts @@ -1,5 +1,6 @@ // Store entry shape normalization rejects unsafe persisted metadata before runtime use. import { isRecord } from "@openclaw/normalization-core/record-coerce"; +import { parseAgentSessionKey } from "../../routing/session-key.js"; import { validateSessionId } from "./paths.js"; import type { SessionEntry } from "./types.js"; @@ -33,50 +34,62 @@ function normalizeOptionalTimestamp(value: unknown): number | undefined { return typeof value === "number" && Number.isFinite(value) && value >= 0 ? value : 0; } +/** Removes retired runtime locator fields before a session entry is persisted or returned. */ +export function projectCanonicalSessionEntryShape(value: Record): SessionEntry { + const { + sessionFile: _retiredSessionFile, + transcriptPath: _retiredTranscriptPath, + ...canonicalValue + } = value; + return canonicalValue as unknown as SessionEntry; +} + /** Normalizes persisted session store entries before they reach runtime callers. */ -export function normalizePersistedSessionEntryShape(value: unknown): SessionEntry | undefined { +export function normalizePersistedSessionEntryShape( + value: unknown, + options: { sessionKey?: string } = {}, +): SessionEntry | undefined { if (!isRecord(value)) { return undefined; } const modelSelectionLocked = value.modelSelectionLocked === true; - let next = value as unknown as SessionEntry; - const sessionFile = typeof value.sessionFile === "string" ? value.sessionFile.trim() : undefined; + let next = projectCanonicalSessionEntryShape(value); if (value.sessionId !== undefined) { if (!isSafeSessionId(value.sessionId)) { return undefined; } const sessionId = value.sessionId.trim(); - if (modelSelectionLocked && sessionId !== value.sessionId) { - // A harness lock protects the exact durable identity. Repairing it here - // would make a corrupted row look valid before ownership validation. - return undefined; - } - const transcriptSessionId = normalizeTranscriptSessionId(sessionId); - if (!transcriptSessionId && !sessionFile) { - if (modelSelectionLocked) { + const legacySessionFile = value.sessionFile; + const pendingLegacyKeyId = + !modelSelectionLocked && + options.sessionKey !== undefined && + parseAgentSessionKey(options.sessionKey) !== null && + sessionId === options.sessionKey && + (value.initializationPending === true || + typeof legacySessionFile !== "string" || + !legacySessionFile.trim()); + if (pendingLegacyKeyId) { + const { sessionId: _legacyPendingSessionId, ...pendingEntry } = next; + next = { ...pendingEntry, initializationPending: true } as SessionEntry; + } else { + if (modelSelectionLocked && sessionId !== value.sessionId) { + // A harness lock protects the exact durable identity. Repairing it here + // would make a corrupted row look valid before ownership validation. return undefined; } - // Old non-transcript ids can survive only when a separate sessionFile pins the path. - const { sessionId: _dropSessionId, ...rest } = next; - next = rest as SessionEntry; - } else if (sessionId !== value.sessionId) { - next = { ...next, sessionId }; + const transcriptSessionId = normalizeTranscriptSessionId(sessionId); + if (!transcriptSessionId) { + return undefined; + } + if (sessionId !== value.sessionId) { + next = { ...next, sessionId }; + } } } - if (value.sessionFile !== undefined && typeof value.sessionFile !== "string") { - if (next === value) { - next = { ...next }; - } - delete next.sessionFile; - } - const updatedAt = normalizeOptionalTimestamp(value.updatedAt); if (updatedAt !== value.updatedAt) { - if (next === value) { - next = { ...next }; - } next.updatedAt = updatedAt ?? 0; } diff --git a/src/config/sessions/store-maintenance-operations.ts b/src/config/sessions/store-maintenance-operations.ts index 729dea3f660b..08f820325bae 100644 --- a/src/config/sessions/store-maintenance-operations.ts +++ b/src/config/sessions/store-maintenance-operations.ts @@ -92,8 +92,8 @@ function rememberRemovedSessionFile( removedSessionFiles: RemovedSessionFiles, entry: SessionEntry, ): void { - if (!removedSessionFiles.has(entry.sessionId) || entry.sessionFile) { - removedSessionFiles.set(entry.sessionId, entry.sessionFile); + if (!removedSessionFiles.has(entry.sessionId)) { + removedSessionFiles.set(entry.sessionId, undefined); } } diff --git a/src/config/sessions/store.pruning.test.ts b/src/config/sessions/store.pruning.test.ts index 1ee0fea75799..19e31c612cc6 100644 --- a/src/config/sessions/store.pruning.test.ts +++ b/src/config/sessions/store.pruning.test.ts @@ -212,15 +212,11 @@ describe("applyFileBackedSessionStoreMaintenance", () => { it("preserves the active session and cleans artifacts using the final referenced session set", async () => { const now = Date.now(); const store = makeStore([ - [ - "stale", - { sessionId: "stale-session", sessionFile: "stale.jsonl", updatedAt: now - 30 * DAY_MS }, - ], + ["stale", { sessionId: "stale-session", updatedAt: now - 30 * DAY_MS }], [ "stale-shared", { sessionId: "shared-session", - sessionFile: "shared-old.jsonl", updatedAt: now - 30 * DAY_MS, }, ], @@ -270,8 +266,8 @@ describe("applyFileBackedSessionStoreMaintenance", () => { expect(archiveCalls).toEqual([ { removedSessionFiles: [ - ["stale-session", "stale.jsonl"], - ["shared-session", "shared-old.jsonl"], + ["stale-session", undefined], + ["shared-session", undefined], ], referencedSessionIds: new Set(["shared-session", "active-session"]), }, diff --git a/src/config/sessions/transcript-append.test-support.ts b/src/config/sessions/transcript-append.test-support.ts index 11b7c8baef27..88eacfbf6173 100644 --- a/src/config/sessions/transcript-append.test-support.ts +++ b/src/config/sessions/transcript-append.test-support.ts @@ -14,7 +14,7 @@ import { redactSecrets } from "../../logging/redact.js"; import { KeyedAsyncQueue } from "../../plugin-sdk/keyed-async-queue.js"; import { isTranscriptOnlyOpenClawAssistantMessage } from "../../shared/transcript-only-openclaw-assistant.js"; import { createSessionTranscriptHeader } from "./transcript-header.js"; -import { serializeJsonlEntry, serializeJsonlLine, writeJsonlLines } from "./transcript-jsonl.js"; +import { serializeJsonlLines } from "./transcript-jsonl.js"; import { streamSessionTranscriptLines, streamSessionTranscriptLinesReverse, @@ -24,6 +24,30 @@ import { CURRENT_SESSION_VERSION } from "./version.js"; const SESSION_MANAGER_APPEND_MAX_BYTES = 8 * 1024 * 1024; +function serializeJsonlLine(entry: unknown): string { + const serialized = JSON.stringify(entry); + if (serialized === undefined) { + throw new TypeError(`entry of type ${typeof entry} is not JSON-serializable`); + } + return serialized; +} + +function serializeJsonlEntry(entry: unknown): string { + return `${serializeJsonlLine(entry)}\n`; +} + +async function writeJsonlLines( + filePath: string, + lines: readonly string[], + options?: { encoding?: BufferEncoding; flag?: string; mode?: number }, +): Promise { + await fs.writeFile(filePath, serializeJsonlLines(lines), { + encoding: options?.encoding ?? "utf-8", + ...(options?.flag ? { flag: options.flag } : {}), + ...(options?.mode !== undefined ? { mode: options.mode } : {}), + }); +} + const transcriptAppendQueue = new KeyedAsyncQueue(); async function writeJsonlEntry( diff --git a/src/config/sessions/transcript-file-resolve.ts b/src/config/sessions/transcript-file-resolve.ts index 1527920944fb..f40b5d838d37 100644 --- a/src/config/sessions/transcript-file-resolve.ts +++ b/src/config/sessions/transcript-file-resolve.ts @@ -1,11 +1,8 @@ -// Resolves transcript file targets without depending on transcript read/write facades. -import { resolveSessionFilePath, resolveSessionFilePathOptions } from "./paths.js"; -import { resolveAndPersistSessionFile } from "./session-file.js"; import type { SessionEntry } from "./types.js"; /** - * Resolves the transcript file for a session and persists the resolved target - * when the caller supplies the owning session store. + * Legacy command shim: runtime storage uses the returned session key only as a + * process-local routing token; SQLite identity travels separately. */ export async function resolveSessionTranscriptFile(params: { sessionId: string; @@ -16,28 +13,8 @@ export async function resolveSessionTranscriptFile(params: { agentId: string; threadId?: string | number; }): Promise<{ sessionFile: string; sessionEntry: SessionEntry | undefined }> { - const sessionPathOpts = resolveSessionFilePathOptions({ - agentId: params.agentId, - storePath: params.storePath, - }); - let sessionFile = resolveSessionFilePath(params.sessionId, params.sessionEntry, sessionPathOpts); - let sessionEntry = params.sessionEntry; - - if (params.sessionStore && params.storePath) { - const resolvedSessionFile = await resolveAndPersistSessionFile({ - sessionId: params.sessionId, - sessionKey: params.sessionKey, - sessionStore: params.sessionStore, - storePath: params.storePath, - sessionEntry, - agentId: sessionPathOpts?.agentId, - }); - sessionFile = resolvedSessionFile.sessionFile; - sessionEntry = resolvedSessionFile.sessionEntry; - } - return { - sessionFile, - sessionEntry, + sessionFile: params.sessionKey, + sessionEntry: params.sessionEntry ?? params.sessionStore?.[params.sessionKey], }; } diff --git a/src/config/sessions/transcript-jsonl.test.ts b/src/config/sessions/transcript-jsonl.test.ts index ef2b7b0299b5..74e5bdc61bd8 100644 --- a/src/config/sessions/transcript-jsonl.test.ts +++ b/src/config/sessions/transcript-jsonl.test.ts @@ -1,53 +1,6 @@ -// Covers JSONL serialization behavior, including fail-fast on non-serializable -// root values that JSON.stringify would otherwise coerce to the literal string -// "undefined" (a silent transcript-data-loss path). +// Covers JSONL artifact batch serialization. import { describe, expect, it } from "vitest"; -import { - serializeJsonlEntry, - serializeJsonlLine, - serializeJsonlLines, -} from "./transcript-jsonl.js"; - -describe("serializeJsonlLine", () => { - it("throws TypeError when the root value is undefined", () => { - expect(() => serializeJsonlLine(undefined)).toThrow(TypeError); - expect(() => serializeJsonlLine(undefined)).toThrow(/not JSON-serializable/); - }); - - it("throws TypeError when the root value is a function", () => { - expect(() => serializeJsonlLine(() => 42)).toThrow(TypeError); - }); - - it("throws TypeError when the root value is a symbol", () => { - expect(() => serializeJsonlLine(Symbol("x"))).toThrow(TypeError); - }); - - it("serializes null as the JSON literal 'null'", () => { - expect(serializeJsonlLine(null)).toBe("null"); - }); - - it("serializes primitives as their JSON representation", () => { - expect(serializeJsonlLine("hello")).toBe('"hello"'); - expect(serializeJsonlLine(42)).toBe("42"); - expect(serializeJsonlLine(true)).toBe("true"); - }); - - it("serializes plain objects and arrays (regression guard)", () => { - expect(serializeJsonlLine({ msg: "hello" })).toBe('{"msg":"hello"}'); - expect(serializeJsonlLine([1, 2, 3])).toBe("[1,2,3]"); - }); -}); - -describe("serializeJsonlEntry", () => { - it("appends a newline terminator for serializable values", () => { - expect(serializeJsonlEntry({ msg: "ok" })).toBe('{"msg":"ok"}\n'); - }); - - it("throws for a non-serializable root value (does not emit 'undefined\\n')", () => { - // Regression guard: the literal string "undefined" must never reach the file. - expect(() => serializeJsonlEntry(undefined)).toThrow(TypeError); - }); -}); +import { serializeJsonlLines } from "./transcript-jsonl.js"; describe("serializeJsonlLines", () => { it("joins serialized lines and terminates the batch with a newline", () => { diff --git a/src/config/sessions/transcript-jsonl.ts b/src/config/sessions/transcript-jsonl.ts index 9942b36578ba..1c4abd6cba9f 100644 --- a/src/config/sessions/transcript-jsonl.ts +++ b/src/config/sessions/transcript-jsonl.ts @@ -1,67 +1,5 @@ -// JSONL helpers centralize newline-safe transcript serialization and writes. -import { appendFileSync, writeFileSync } from "node:fs"; -import fs from "node:fs/promises"; - -type WriteJsonlFileOptions = { - encoding?: BufferEncoding; - flag?: string; - mode?: number; -}; - -/** Serializes one JSONL entry and appends the newline terminator. */ -export function serializeJsonlEntry(entry: unknown): string { - return `${serializeJsonlLine(entry)}\n`; -} - -export function serializeJsonlLine(entry: unknown): string { - const serialized = JSON.stringify(entry); - // JSON.stringify returns undefined when the root value is undefined, a - // function, or a symbol. Without this guard the template literal in - // serializeJsonlEntry coerces it to the literal string "undefined", which is - // not valid JSON and is silently skipped by readers — a fail-silent loss of a - // transcript entry. Fail fast instead so the caller fixes the bad value. - if (serialized === undefined) { - throw new TypeError( - `serializeJsonlLine: entry of type ${typeof entry} is not JSON-serializable (JSON.stringify returned undefined)`, - ); - } - return serialized; -} - -function serializeJsonlEntries(jsonlEntries: readonly unknown[]): string { - return serializeJsonlLines(jsonlEntries.map(serializeJsonlLine)); -} - +// JSONL artifact helpers retain only the production batch serializer. export function serializeJsonlLines(lines: readonly string[]): string { // Transcript readers expect every persisted entry batch to end with a newline. return lines.length > 0 ? `${lines.join("\n")}\n` : ""; } - -export function writeJsonlEntriesSync(filePath: string, entries: readonly unknown[]): string { - const content = serializeJsonlEntries(entries); - writeFileSync(filePath, content, "utf-8"); - return content; -} - -export function appendSerializedJsonlEntrySync( - filePath: string, - serializedEntry: string, - options?: { prefixNewline?: boolean }, -): string { - const content = options?.prefixNewline ? `\n${serializedEntry}` : serializedEntry; - appendFileSync(filePath, content, "utf-8"); - return content; -} -export async function writeJsonlLines( - filePath: string, - lines: readonly string[], - options?: WriteJsonlFileOptions, -): Promise { - const content = serializeJsonlLines(lines); - await fs.writeFile(filePath, content, { - encoding: options?.encoding ?? "utf-8", - ...(options?.flag ? { flag: options.flag } : {}), - ...(options?.mode !== undefined ? { mode: options.mode } : {}), - }); - return content; -} diff --git a/src/config/sessions/transcript-write-context.ts b/src/config/sessions/transcript-write-context.ts index fe406390eb1e..c6009792a98b 100644 --- a/src/config/sessions/transcript-write-context.ts +++ b/src/config/sessions/transcript-write-context.ts @@ -1,11 +1,11 @@ // Transcript write contexts let nested append paths reuse an already-owned session write lock. import { AsyncLocalStorage } from "node:async_hooks"; import path from "node:path"; -import { parseSqliteSessionFileMarker } from "./sqlite-marker.js"; type OwnedSessionTranscriptWriteContext = { sessionFile?: string; sessionKey?: string; + sessionTarget?: SessionTranscriptWriteLockTarget; canAdvanceSessionEntryCache?: (snapshot: OwnedSessionTranscriptCacheSnapshot) => boolean; publishSessionFileSnapshot?: (snapshot: OwnedSessionTranscriptCacheSnapshot) => boolean; withSessionWriteLock: ( @@ -14,6 +14,13 @@ type OwnedSessionTranscriptWriteContext = { ) => Promise; }; +export type SessionTranscriptWriteLockTarget = { + agentId?: string; + sessionId?: string; + sessionKey?: string; + storePath?: string; +}; + export type OwnedSessionTranscriptWriteOptions = { publishOwnedWrite?: boolean; resolvePublishedEntries?: (result: T) => readonly OwnedSessionTranscriptPublishedEntry[]; @@ -39,7 +46,7 @@ const ownedTranscriptWriteContext = new AsyncLocalStorage { + const agentId = target?.agentId?.trim(); + const sessionId = target?.sessionId?.trim(); + const sessionKey = target?.sessionKey?.trim(); + const storePath = target?.storePath?.trim(); + return sessionKey && storePath + ? { agentId, sessionId, sessionKey, storePath: path.resolve(storePath) } + : undefined; + }; + const contextTarget = normalizeTarget(params.context.sessionTarget); + const requestedTarget = normalizeTarget(params.sessionTarget); + if (params.context.sessionTarget || params.sessionTarget) { + return Boolean( + contextTarget && + requestedTarget && + contextTarget.sessionKey === requestedTarget.sessionKey && + contextTarget.storePath === requestedTarget.storePath && + (!contextTarget.agentId || + !requestedTarget.agentId || + contextTarget.agentId === requestedTarget.agentId) && + (!contextTarget.sessionId || + !requestedTarget.sessionId || + contextTarget.sessionId === requestedTarget.sessionId), + ); + } const contextSessionFile = normalizeConcretePathForCompare(params.context.sessionFile); const sessionFile = normalizeConcretePathForCompare(params.sessionFile); if (contextSessionFile && sessionFile) { @@ -81,6 +114,7 @@ export async function runWithOwnedSessionTranscriptWriteLock( params: { sessionFile?: string; sessionKey?: string; + sessionTarget?: SessionTranscriptWriteLockTarget; }, run: () => Promise | T, ): Promise { @@ -90,6 +124,7 @@ export async function runWithOwnedSessionTranscriptWriteLock( export async function acquireOwnedSessionTranscriptWriteLock(params: { sessionFile?: string; sessionKey?: string; + sessionTarget?: SessionTranscriptWriteLockTarget; }): Promise<{ release: () => Promise } | undefined> { const context = ownedTranscriptWriteContext.getStore(); if (!context || !contextMatches({ context, ...params })) { @@ -128,36 +163,11 @@ export async function acquireOwnedSessionTranscriptWriteLock(params: { }; } -export function canAdvanceOwnedSessionEntryCache(params: { - sessionFile?: string; - sessionKey?: string; - snapshot: OwnedSessionTranscriptCacheSnapshot; -}): boolean { - const context = ownedTranscriptWriteContext.getStore(); - return Boolean( - context && - contextMatches({ context, ...params }) && - context.publishSessionFileSnapshot && - context.canAdvanceSessionEntryCache?.(params.snapshot), - ); -} - -export function publishOwnedSessionFileSnapshot(params: { - sessionFile?: string; - sessionKey?: string; - snapshot: OwnedSessionTranscriptCacheSnapshot; -}): boolean | undefined { - const context = ownedTranscriptWriteContext.getStore(); - if (!context || !contextMatches({ context, ...params }) || !context.publishSessionFileSnapshot) { - return undefined; - } - return context.publishSessionFileSnapshot(params.snapshot); -} - async function runWithOwnedSessionTranscriptWriteContext( params: { sessionFile?: string; sessionKey?: string; + sessionTarget?: SessionTranscriptWriteLockTarget; }, run: () => Promise | T, options?: OwnedSessionTranscriptWriteOptions, diff --git a/src/config/sessions/transcript.test.ts b/src/config/sessions/transcript.test.ts index 6939b3011895..e74c9aab362c 100644 --- a/src/config/sessions/transcript.test.ts +++ b/src/config/sessions/transcript.test.ts @@ -197,7 +197,12 @@ describe("appendAssistantMessageToSessionTranscript", () => { if (!result.ok) { throw new Error(result.reason); } - expect(result.sessionFile).toContain("sqlite:main:configured-session-id:"); + expect(result.target).toEqual({ + agentId: "main", + sessionId: "configured-session-id", + sessionKey: configuredSessionKey, + storePath, + }); await expect( loadTranscriptEvents({ agentId: "main", @@ -255,7 +260,12 @@ describe("appendAssistantMessageToSessionTranscript", () => { if (!result.ok) { throw new Error(result.reason); } - expect(result.sessionFile).toContain("sqlite:worker:worker-session-id:"); + expect(result.target).toEqual({ + agentId: "worker", + sessionId: "worker-session-id", + sessionKey: configuredSessionKey, + storePath, + }); await expect( loadTranscriptEvents({ agentId: "worker", @@ -291,7 +301,7 @@ describe("appendAssistantMessageToSessionTranscript", () => { } }); - it("creates a SQLite transcript marker and appends message for valid session", async () => { + it("appends a message for a valid SQLite session target", async () => { await writeTranscriptStore(); const result = await appendAssistantMessageToSessionTranscript({ @@ -302,7 +312,7 @@ describe("appendAssistantMessageToSessionTranscript", () => { expect(result.ok).toBe(true); if (result.ok) { - expect(result.sessionFile).toContain("sqlite:main:test-session-id:"); + expect(result.target).toEqual(createFixtureTranscriptScope()); const events = await loadFixtureMessages(); expect(events).toContainEqual( expect.objectContaining({ @@ -420,6 +430,12 @@ describe("appendAssistantMessageToSessionTranscript", () => { { sessionFile, sessionKey, + sessionTarget: { + agentId: "main", + sessionId, + sessionKey, + storePath: fixture.storePath(), + }, withSessionWriteLock: async (run) => { events.push("lock"); return await run(); @@ -468,6 +484,49 @@ describe("appendAssistantMessageToSessionTranscript", () => { expect(events).toEqual(["write"]); }); + it("does not reuse owned write locks for the same key in another transcript target", async () => { + const cases = [ + [ + { agentId: "main", sessionKey: "global", storePath: "/tmp/main.sqlite" }, + { agentId: "worker", sessionKey: "global", storePath: "/tmp/worker.sqlite" }, + ], + [ + { agentId: "main", sessionKey: "global" }, + { agentId: "worker", sessionKey: "global" }, + ], + ] as const; + + for (const [ownerTarget, otherTarget] of cases) { + const events: string[] = []; + const result = await withOwnedSessionTranscriptWrites( + { + sessionFile: ownerTarget.sessionKey, + sessionKey: ownerTarget.sessionKey, + sessionTarget: ownerTarget, + withSessionWriteLock: async (run) => { + events.push("lock"); + return await run(); + }, + }, + async () => + await runWithOwnedSessionTranscriptWriteLock( + { + sessionFile: otherTarget.sessionKey, + sessionKey: otherTarget.sessionKey, + sessionTarget: otherTarget, + }, + () => { + events.push("write"); + return "ok"; + }, + ), + ); + + expect(result).toBe("ok"); + expect(events).toEqual(["write"]); + } + }); + it("keeps matching owned transcript appends locked from bound callbacks", async () => { const sessionFile = resolveSessionTranscriptPathInDir(sessionId, fixture.sessionsDir()); const events: string[] = []; @@ -527,7 +586,7 @@ describe("appendAssistantMessageToSessionTranscript", () => { } }); - it("uses the SQLite transcript marker for malformed persisted sessionFile metadata", async () => { + it("uses SQLite identity for malformed persisted sessionFile metadata", async () => { await writeTranscriptStore({ sessionFile: { path: "../../escaped.jsonl" } as unknown as string, updatedAt: Date.now(), @@ -541,7 +600,7 @@ describe("appendAssistantMessageToSessionTranscript", () => { expect(result.ok).toBe(true); if (result.ok) { - expect(result.sessionFile).toContain("sqlite:main:test-session-id:"); + expect(result.target).toEqual(createFixtureTranscriptScope()); await expect(loadFixtureMessages()).resolves.toContainEqual( expect.objectContaining({ message: expect.objectContaining({ @@ -573,7 +632,7 @@ describe("appendAssistantMessageToSessionTranscript", () => { content?: unknown; } | undefined; - expect(event?.sessionFile).toContain("sqlite:main:test-session-id:"); + expect(event?.target).toMatchObject({ agentId: "main", sessionId, sessionKey }); expect(event?.sessionKey).toBe(sessionKey); expect(event?.messageId).toBeTypeOf("string"); expect(message?.role).toBe("assistant"); @@ -711,7 +770,7 @@ describe("appendAssistantMessageToSessionTranscript", () => { expect(result.ok).toBe(true); const saved = loadSessionEntry({ agentId: "main", sessionKey, storePath: fixture.storePath() }); - expect(saved?.sessionFile).toBe(marker); + expect(saved).not.toHaveProperty("sessionFile"); expect(saved?.updatedAt).toBeGreaterThan(100); expect(saved?.pluginExtensions).toEqual({ "metadata-owner": { @@ -745,7 +804,7 @@ describe("appendAssistantMessageToSessionTranscript", () => { expect(mirrorResult.messageId).toBe(existingMessageId); } const saved = loadSessionEntry({ agentId: "main", sessionKey, storePath: fixture.storePath() }); - expect(saved?.sessionFile).toBe(marker); + expect(saved).not.toHaveProperty("sessionFile"); }); it("idempotently appends identified channel finals while preserving repeated replies", async () => { @@ -908,9 +967,12 @@ describe("appendAssistantMessageToSessionTranscript", () => { messages: [{ message: { role: "user", content: "x".repeat(128 * 1024) } }], }); - const latestAssistantText = await readLatestAssistantTextFromSessionTranscript( - exactResult.sessionFile, - ); + const latestAssistantText = await readLatestAssistantTextFromSessionTranscript({ + agentId: "main", + sessionId, + sessionKey, + storePath: fixture.storePath(), + }); if (!latestAssistantText) { throw new Error("expected latest assistant text"); } @@ -1223,9 +1285,12 @@ describe("appendAssistantMessageToSessionTranscript", () => { }), }); - const latestAssistantText = await readLatestAssistantTextFromSessionTranscript( - finalResult.sessionFile, - ); + const latestAssistantText = await readLatestAssistantTextFromSessionTranscript({ + agentId: "main", + sessionId, + sessionKey, + storePath: fixture.storePath(), + }); expect(latestAssistantText?.id).toBe(finalResult.messageId); expect(latestAssistantText?.text).toBe("Complete final answer"); }); @@ -1243,9 +1308,12 @@ describe("appendAssistantMessageToSessionTranscript", () => { return; } - const latestAssistantText = await readLatestAssistantTextFromSessionTranscript( - mirrorResult.sessionFile, - ); + const latestAssistantText = await readLatestAssistantTextFromSessionTranscript({ + agentId: "main", + sessionId, + sessionKey, + storePath: fixture.storePath(), + }); expect(latestAssistantText).toBeUndefined(); }); @@ -1262,9 +1330,12 @@ describe("appendAssistantMessageToSessionTranscript", () => { return; } - const tailAssistantText = await readTailAssistantTextFromSessionTranscript( - mirrorResult.sessionFile, - ); + const tailAssistantText = await readTailAssistantTextFromSessionTranscript({ + agentId: "main", + sessionId, + sessionKey, + storePath: fixture.storePath(), + }); expect(tailAssistantText?.id).toBe(mirrorResult.messageId); expect(tailAssistantText?.text).toBe("Tail delivery mirror"); }); @@ -1302,9 +1373,12 @@ describe("appendAssistantMessageToSessionTranscript", () => { })}\n`; await appendTranscriptEvent(createFixtureTranscriptScope(), JSON.parse(cacheTtlEntry)); - const tailAssistantText = await readTailAssistantTextFromSessionTranscript( - assistantResult.sessionFile, - ); + const tailAssistantText = await readTailAssistantTextFromSessionTranscript({ + agentId: "main", + sessionId, + sessionKey, + storePath: fixture.storePath(), + }); expect(tailAssistantText?.id).toBe(assistantResult.messageId); expect(tailAssistantText?.text).toBe("Canonical answer"); }); @@ -1390,7 +1464,7 @@ describe("appendAssistantMessageToSessionTranscript", () => { } await expect( - readTailAssistantTextFromSessionTranscript(toolOnlyResult.sessionFile, { + readTailAssistantTextFromSessionTranscript(toolOnlyResult.target, { excludeTranscriptOnlyOpenClawAssistant: true, }), ).resolves.toBeUndefined(); @@ -1944,7 +2018,6 @@ describe("appendAssistantMessageToSessionTranscript", () => { if (result.ok) { expect(emitSpy).toHaveBeenCalledWith({ agentId: "main", - sessionFile: result.sessionFile, sessionKey, target: { agentId: "main", diff --git a/src/config/sessions/transcript.ts b/src/config/sessions/transcript.ts index cc4ab3bff791..ede8caf61f74 100644 --- a/src/config/sessions/transcript.ts +++ b/src/config/sessions/transcript.ts @@ -21,6 +21,10 @@ import { isTranscriptOnlyOpenClawAssistantModel, } from "../../shared/transcript-only-openclaw-assistant.js"; import type { OpenClawConfig } from "../types.openclaw.js"; +import { + parseSqliteSessionFileMarker, + type SqliteSessionFileMarker, +} from "./legacy-sqlite-marker.js"; import { resolveDefaultSessionStorePath, resolveStorePath } from "./paths.js"; import { loadSessionEntryReadOnly, @@ -35,7 +39,6 @@ import { type SessionTranscriptTurnExpectedState, } from "./session-accessor.js"; import type { SessionTranscriptTurnLifecyclePatch } from "./session-transcript-turn-lifecycle.types.js"; -import { parseSqliteSessionFileMarker, type SqliteSessionFileMarker } from "./sqlite-marker.js"; import { applyBeforeMessageWriteToAssistant, type AssistantBeforeMessageWrite, @@ -54,8 +57,15 @@ import { } from "./transcript-tree.js"; import type { SessionEntry } from "./types.js"; +type SessionTranscriptAppendTarget = { + agentId?: string; + sessionId: string; + sessionKey: string; + storePath: string; +}; + export type SessionTranscriptAppendResult = - | { ok: true; sessionFile: string; messageId: string } + | { ok: true; target: SessionTranscriptAppendTarget; messageId: string } | { ok: false; reason: string; @@ -322,8 +332,20 @@ export async function readRecentUserAssistantTextForSession( } export async function readLatestAssistantTextFromSessionTranscript( - sessionFile: string | undefined, + target: + | string + | { + agentId?: string; + sessionId: string; + sessionKey?: string; + storePath?: string; + } + | undefined, ): Promise { + if (target && typeof target === "object") { + return readLatestTranscriptAssistantText(target); + } + const sessionFile = target; const sqliteMarker = parseSqliteSessionFileMarker(sessionFile); if (sqliteMarker) { return readLatestTranscriptAssistantText({ @@ -352,9 +374,46 @@ export async function readLatestAssistantTextFromSessionTranscript( } export async function readTailAssistantTextFromSessionTranscript( - sessionFile: string | undefined, + sessionFile: + | string + | undefined + | { agentId?: string; sessionId?: string; sessionKey?: string; storePath?: string }, options?: { excludeTranscriptOnlyOpenClawAssistant?: boolean }, ): Promise { + if (typeof sessionFile === "object") { + if (!sessionFile.sessionId || (!sessionFile.agentId && !sessionFile.sessionKey)) { + return undefined; + } + const events = await loadTranscriptEvents({ + ...(sessionFile.agentId ? { agentId: sessionFile.agentId } : {}), + sessionId: sessionFile.sessionId, + ...(sessionFile.sessionKey ? { sessionKey: sessionFile.sessionKey } : {}), + ...(sessionFile.storePath ? { storePath: sessionFile.storePath } : {}), + }); + for (const event of events.toReversed()) { + const parsed = event as { message?: { model?: unknown; provider?: unknown; role?: unknown } }; + if (!parsed.message || typeof parsed.message !== "object") { + continue; + } + if (parsed.message.role !== "assistant") { + return undefined; + } + const assistantText = parseAssistantTranscriptText(JSON.stringify(event), { + excludeTranscriptOnlyOpenClawAssistant: + options?.excludeTranscriptOnlyOpenClawAssistant === true, + }); + if (assistantText) { + return assistantText; + } + if ( + options?.excludeTranscriptOnlyOpenClawAssistant !== true || + !isTranscriptOnlyOpenClawAssistantMessage(parsed.message) + ) { + return undefined; + } + } + return undefined; + } const sqliteMarker = parseSqliteSessionFileMarker(sessionFile); if (sqliteMarker) { const events = await loadTranscriptEvents({ @@ -551,9 +610,8 @@ export async function appendExactAssistantMessageToSessionTranscript(params: { return { ok: false, reason: `unknown sessionKey: ${sessionKey}` }; } - const appendToSessionFile = async ( + const appendToSession = async ( currentEntry: NonNullable, - sessionFile?: string, ): Promise => { const explicitIdempotencyKey = params.idempotencyKey ?? @@ -580,6 +638,12 @@ export async function appendExactAssistantMessageToSessionTranscript(params: { } const identifiedDeliveryMirror = Boolean(explicitIdempotencyKey) && isIdentifiedDeliveryMirror(params.message); + const target: SessionTranscriptAppendTarget = { + ...(transcriptAgentId ? { agentId: transcriptAgentId } : {}), + sessionId: currentEntry.sessionId, + sessionKey: resolved.normalizedKey, + storePath, + }; let latestEquivalentAssistantId: string | undefined; // Identified delivery mirrors, including suppressed finals, dedupe only by // key so same-text markers from different source ids remain separate rows. @@ -588,7 +652,6 @@ export async function appendExactAssistantMessageToSessionTranscript(params: { sessionId: currentEntry.sessionId, sessionKey: resolved.normalizedKey, storePath, - ...(sessionFile ? { sessionFile } : {}), ...(transcriptAgentId ? { agentId: transcriptAgentId } : {}), }, { @@ -622,11 +685,11 @@ export async function appendExactAssistantMessageToSessionTranscript(params: { }), } : {}), - shouldAppend: async (target) => { + shouldAppend: async (appendTarget) => { latestEquivalentAssistantId = isRedundantDeliveryMirror(params.message) && !identifiedDeliveryMirror ? await findLatestEquivalentAssistantMessageId( - target, + appendTarget, preparedUnkeyedMessage as SessionTranscriptAssistantMessage, params.config, ) @@ -645,7 +708,11 @@ export async function appendExactAssistantMessageToSessionTranscript(params: { }; } if (latestEquivalentAssistantId) { - return { ok: true, sessionFile: turn.sessionFile, messageId: latestEquivalentAssistantId }; + return { + ok: true, + target, + messageId: latestEquivalentAssistantId, + }; } const appendedResult = turn.messages[0]; if (!appendedResult) { @@ -658,17 +725,12 @@ export async function appendExactAssistantMessageToSessionTranscript(params: { const { messageId } = appendedResult; if (!params.expectedSessionId) { try { - if (parseSqliteSessionFileMarker(turn.sessionFile)) { - await touchSqliteAssistantAppendSessionEntry({ - agentId: transcriptAgentId, - currentEntry, - sessionFile: turn.sessionFile, - sessionKey: resolved.normalizedKey, - storePath, - }); - } else { - return { ok: false, reason: `unexpected transcript target: ${turn.sessionFile}` }; - } + await touchSqliteAssistantAppendSessionEntry({ + agentId: transcriptAgentId, + currentEntry, + sessionKey: resolved.normalizedKey, + storePath, + }); } catch (err) { return { ok: false, @@ -676,22 +738,14 @@ export async function appendExactAssistantMessageToSessionTranscript(params: { }; } } - return { ok: true, sessionFile: turn.sessionFile, messageId }; + return { ok: true, target, messageId }; }; - - let result: SessionTranscriptAppendResult; - if (params.expectedSessionId) { - result = await appendToSessionFile(entry); - } else { - result = await appendToSessionFile(entry); - } - return result; + return await appendToSession(entry); } async function touchSqliteAssistantAppendSessionEntry(params: { agentId?: string; currentEntry: SessionEntry; - sessionFile: string; sessionKey: string; storePath: string; }): Promise { @@ -699,7 +753,6 @@ async function touchSqliteAssistantAppendSessionEntry(params: { const buildPatch = (entry: SessionEntry | undefined): Partial => ({ updatedAt: Math.max(entry?.updatedAt ?? 0, now), sessionStartedAt: entry?.sessionStartedAt ?? params.currentEntry.sessionStartedAt ?? now, - sessionFile: params.sessionFile, }); await updateSessionEntry( { @@ -818,38 +871,6 @@ async function findLatestEquivalentAssistantMessageId( return candidateText === expectedText ? latest?.id : undefined; } - for await (const line of streamSessionTranscriptLinesReverse(target.sessionFile)) { - try { - const parsed = JSON.parse(line) as { - id?: unknown; - message?: SessionTranscriptAssistantMessage; - }; - const candidate = parsed.message; - if (!candidate) { - continue; - } - if (candidate.role !== "assistant") { - return undefined; - } - // Only the tail message can be a duplicate mirror replay. - const candidateText = extractAssistantMessageText( - redactTranscriptMessage( - candidate as AgentMessage, - config, - ) as unknown as SessionTranscriptAssistantMessage, - ); - if (candidateText !== expectedText) { - return undefined; - } - if (typeof parsed.id === "string" && parsed.id) { - return parsed.id; - } - return undefined; - } catch { - continue; - } - } - return undefined; } /* oxlint-disable max-lines -- TODO: split this grandfathered oversized file. */ diff --git a/src/config/sessions/types.ts b/src/config/sessions/types.ts index 6fd3391e08d1..6f54ac843b05 100644 --- a/src/config/sessions/types.ts +++ b/src/config/sessions/types.ts @@ -22,7 +22,6 @@ import type { SessionCreatedVia, SessionEntryProvenance, } from "./session-entry-provenance.js"; -import { rewriteSessionFileForNewSessionId } from "./session-file-rotation.js"; import type { AgentPatchedSessionModelFallback } from "./session-model-fallback.js"; export type SessionScope = "per-sender" | "global"; @@ -250,7 +249,7 @@ export type RestartRecoveryRun = { lifecycleGeneration: string; }; -export type SessionEntry = SessionRestartRecoveryState & +type SessionEntryCore = SessionRestartRecoveryState & SessionEntryProvenance & { /** Collaboration mode. Missing legacy values are equivalent to "shared". */ visibility?: SessionVisibility; @@ -306,7 +305,6 @@ export type SessionEntry = SessionRestartRecoveryState & markedUnreadAt?: number; /** Timestamp (ms) of the latest completed agent run; metadata patches do not update it. */ lastActivityAt?: number; - sessionFile?: string; /** Parent session key that spawned this session (used for sandbox session-tool scoping). */ spawnedBy?: string; /** Immutable session key authorized to receive this child's completion handoff. */ @@ -558,11 +556,15 @@ export type SessionEntry = SessionRestartRecoveryState & acp?: SessionAcpMeta; }; +export interface SessionEntry extends SessionEntryCore {} + /** Internal durable fields excluded from public/plugin session projections. */ -export type InternalSessionEntry = SessionEntry & { +export type InternalSessionEntryCore = SessionEntryCore & { mainRestartRecovery?: MainRestartRecoveryState; }; +export interface InternalSessionEntry extends InternalSessionEntryCore {} + export function isTerminalSessionStatus( status: unknown, ): status is Exclude, "running"> { @@ -694,12 +696,14 @@ function mergeSessionEntryWithPolicy( const sessionId = patch.sessionId ?? existing?.sessionId ?? crypto.randomUUID(); const updatedAt = resolveMergedUpdatedAt(existing, patch, options); if (!existing) { - return normalizeSessionRuntimeModelFields({ - ...patch, - sessionId, - updatedAt, - sessionStartedAt: patch.sessionStartedAt ?? updatedAt, - }); + return stripRetiredSessionEntryLocators( + normalizeSessionRuntimeModelFields({ + ...patch, + sessionId, + updatedAt, + sessionStartedAt: patch.sessionStartedAt ?? updatedAt, + }), + ); } const next = { ...existing, @@ -725,20 +729,6 @@ function mergeSessionEntryWithPolicy( next.forkSource = existing.forkSource; } - if (existing.sessionId !== sessionId) { - // Session id rotations should move transcript paths when they match known reset/fork shapes. - const patchHasSessionFile = Object.hasOwn(patch, "sessionFile"); - const candidateSessionFile = patchHasSessionFile ? patch.sessionFile : existing.sessionFile; - const rewrittenSessionFile = rewriteSessionFileForNewSessionId({ - sessionFile: candidateSessionFile, - previousSessionId: existing.sessionId, - nextSessionId: sessionId, - }); - if (rewrittenSessionFile) { - next.sessionFile = rewrittenSessionFile; - } - } - // Guard against stale provider carry-over when callers patch runtime model // without also patching runtime provider. if (Object.hasOwn(patch, "model") && !Object.hasOwn(patch, "modelProvider")) { @@ -748,7 +738,14 @@ function mergeSessionEntryWithPolicy( delete next.modelProvider; } } - return normalizeSessionRuntimeModelFields(next); + return stripRetiredSessionEntryLocators(normalizeSessionRuntimeModelFields(next)); +} + +function stripRetiredSessionEntryLocators(entry: SessionEntry): SessionEntry { + const mutable = entry as SessionEntry & { sessionFile?: unknown; transcriptPath?: unknown }; + delete mutable.sessionFile; + delete mutable.transcriptPath; + return entry; } export function mergeSessionEntry( diff --git a/src/context-engine/context-engine.test.ts b/src/context-engine/context-engine.test.ts index ff8049132fff..10385c235bdc 100644 --- a/src/context-engine/context-engine.test.ts +++ b/src/context-engine/context-engine.test.ts @@ -1,6 +1,10 @@ // Context engine tests cover context extraction and prompt context assembly. +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; import type { AgentMessage } from "openclaw/plugin-sdk/agent-core"; import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, it, vi } from "vitest"; +import { upsertSessionEntry } from "../config/sessions/session-accessor.js"; import type { MemoryCitationsMode } from "../config/types.memory.js"; import type { OpenClawConfig } from "../config/types.openclaw.js"; import { @@ -8,6 +12,7 @@ import { registerMemoryPromptPreparation, registerTestMemoryPromptBuilder, } from "../plugins/memory-state.test-fixtures.js"; +import { closeOpenClawAgentDatabasesForTest } from "../state/openclaw-agent-db.js"; // --------------------------------------------------------------------------- // We dynamically import the registry so we can get a fresh module per test // group when needed. For most groups we use the shared singleton directly. @@ -649,6 +654,7 @@ describe("Engine contract tests", () => { tokensBefore: 0, tokensAfter: 0, details: undefined, + sessionTarget, }, }); }); @@ -690,6 +696,159 @@ describe("Engine contract tests", () => { expect(result.result).not.toHaveProperty("sessionFile"); }); + it("allows the caller key to rebind to a legacy successor session", async () => { + const root = fs.mkdtempSync(path.join(os.tmpdir(), "context-successor-")); + const storePath = path.join(root, "agents", "main", "sessions", "sessions.json"); + const sessionKey = "agent:main:successor"; + try { + await upsertSessionEntry( + { agentId: "main", sessionKey, storePath }, + { sessionId: "before-compaction", updatedAt: 1 }, + ); + await upsertSessionEntry( + { agentId: "main", sessionKey: "agent:main:aaa-successor-alias", storePath }, + { sessionId: "after-compaction", updatedAt: 2 }, + ); + compactEmbeddedAgentSessionDirectMock.mockResolvedValueOnce({ + ok: true, + compacted: true, + reason: undefined, + result: { + summary: "summary", + firstKeptEntryId: "entry-1", + tokensBefore: 100, + tokensAfter: 40, + details: undefined, + sessionId: "after-compaction", + sessionFile: `sqlite:main:after-compaction:${storePath}`, + }, + }); + + const result = await delegateCompactionToRuntime({ + agentId: "main", + sessionId: "before-compaction", + sessionKey, + tokenBudget: 4096, + }); + + expect(result.result?.sessionTarget).toMatchObject({ + agentId: "main", + sessionId: "after-compaction", + sessionKey, + storePath, + }); + } finally { + closeOpenClawAgentDatabasesForTest(); + fs.rmSync(root, { recursive: true, force: true }); + } + }); + + it("rejects a structured successor key from another agent", async () => { + installCompactRuntimeSpy(); + + await expect( + delegateCompactionToRuntime({ + sessionId: "s-agent-conflict", + sessionKey: "agent:main:s-agent-conflict", + sessionTarget: { + agentId: "worker", + sessionId: "s-agent-conflict", + sessionKey: "agent:main:s-agent-conflict", + storePath: "/tmp/openclaw-agent.sqlite", + }, + tokenBudget: 4096, + }), + ).rejects.toThrow("successor target conflicts with the caller session identity"); + }); + + it("rejects a successor marker that changes the caller store", async () => { + compactEmbeddedAgentSessionDirectMock.mockResolvedValueOnce({ + ok: true, + compacted: true, + result: { + tokensBefore: 100, + sessionId: "store-redirect-successor", + sessionFile: "sqlite:main:store-redirect-successor:/tmp/other.sqlite", + }, + }); + + await expect( + delegateCompactionToRuntime({ + sessionId: "store-redirect-source", + sessionKey: "agent:main:store-redirect", + sessionTarget: { + agentId: "main", + sessionId: "store-redirect-source", + sessionKey: "agent:main:store-redirect", + storePath: "/tmp/caller.sqlite", + }, + }), + ).rejects.toThrow("successor target conflicts with the caller session identity"); + }); + + it("rejects contradictory marker and top-level successor identities", async () => { + compactEmbeddedAgentSessionDirectMock.mockResolvedValueOnce({ + ok: true, + compacted: true, + result: { + tokensBefore: 100, + sessionId: "top-level-successor", + sessionFile: "sqlite:main:marker-successor:/tmp/openclaw-agent.sqlite", + }, + }); + + await expect( + delegateCompactionToRuntime({ + sessionId: "source-session", + sessionKey: "agent:main:successor-conflict", + }), + ).rejects.toThrow("successor identity is inconsistent"); + }); + + it("rejects an internally consistent successor for another caller agent", async () => { + installCompactRuntimeSpy(); + + await expect( + delegateCompactionToRuntime({ + agentId: "main", + sessionId: "s-agent-redirect", + sessionKey: "agent:main:s-agent-redirect", + sessionTarget: { + agentId: "worker", + sessionId: "s-agent-redirect", + sessionKey: "agent:worker:s-agent-redirect", + }, + tokenBudget: 4096, + }), + ).rejects.toThrow("successor target conflicts with the caller session identity"); + }); + + it("rejects a legacy successor marker for another caller agent", async () => { + compactEmbeddedAgentSessionDirectMock.mockResolvedValueOnce({ + ok: true, + compacted: true, + reason: undefined, + result: { + summary: "summary", + firstKeptEntryId: "entry-1", + tokensBefore: 100, + tokensAfter: 40, + details: undefined, + sessionId: "worker-successor", + sessionFile: "sqlite:worker:worker-successor:/tmp/worker-sessions.json", + }, + }); + + await expect( + delegateCompactionToRuntime({ + agentId: "main", + sessionId: "main-session", + sessionKey: "global", + tokenBudget: 4096, + }), + ).rejects.toThrow("successor target conflicts with the caller session identity"); + }); + it("delegateCompactionToRuntime forwards the caller abortSignal to the runtime (#89868)", async () => { installCompactRuntimeSpy(); const controller = new AbortController(); diff --git a/src/context-engine/delegate.ts b/src/context-engine/delegate.ts index a64b09b2419e..b805100b86e7 100644 --- a/src/context-engine/delegate.ts +++ b/src/context-engine/delegate.ts @@ -1,6 +1,9 @@ // Context-engine delegates bridge custom engines to built-in compaction and memory prompt paths. +import path from "node:path"; import { normalizeStructuredPromptSection } from "@openclaw/ai/internal/shared"; -import { parseSqliteSessionFileMarker } from "../config/sessions/sqlite-marker.js"; +import { normalizeOptionalString } from "@openclaw/normalization-core/string-coerce"; +import { parseSqliteSessionFileMarker } from "../config/sessions/legacy-sqlite-marker.js"; +import { listSessionEntries, loadSessionEntry } from "../config/sessions/session-accessor.js"; import { buildMemoryPromptSection, getActivePreparedMemoryPromptSection, @@ -8,6 +11,8 @@ import { type MemoryPromptSectionParams, type PreparedMemoryPromptSection, } from "../plugins/memory-state.js"; +import { parseAgentSessionKey } from "../routing/session-key.js"; +import { resolvePreferredSessionKeyForSessionIdMatches } from "../sessions/session-id-resolution.js"; import { createLazyRuntimeModule } from "../shared/lazy-runtime.js"; import type { ContextEngine, @@ -22,26 +27,118 @@ const loadCompactRuntime = createLazyRuntimeModule( function buildCompactionResultSessionTarget(params: { agentId?: string; + callerSessionId?: string; + callerSessionTarget?: ContextEngineSessionTarget; sessionFile?: string; sessionId?: string; sessionKey?: string; - sessionTarget?: ContextEngineSessionTarget; }): ContextEngineSessionTarget | undefined { - const sqliteMarker = parseSqliteSessionFileMarker(params.sessionFile); - const sessionId = sqliteMarker?.sessionId ?? params.sessionId; + const sessionFile = normalizeOptionalString(params.sessionFile); + const marker = parseSqliteSessionFileMarker(sessionFile); + const targetAgentId = normalizeOptionalString(params.callerSessionTarget?.agentId); + const targetSessionId = normalizeOptionalString(params.callerSessionTarget?.sessionId); + const targetSessionKey = normalizeOptionalString(params.callerSessionTarget?.sessionKey); + const targetStorePath = normalizeOptionalString(params.callerSessionTarget?.storePath); + const requestedAgentId = normalizeOptionalString(params.agentId); + const callerSessionId = normalizeOptionalString(params.callerSessionId); + const requestedSessionKey = normalizeOptionalString(params.sessionKey); + const requestedSessionKeyAgentId = parseAgentSessionKey(requestedSessionKey)?.agentId; + if ( + (requestedAgentId && targetAgentId && requestedAgentId !== targetAgentId) || + (callerSessionId && targetSessionId && callerSessionId !== targetSessionId) || + (requestedSessionKey && targetSessionKey && requestedSessionKey !== targetSessionKey) || + (requestedSessionKeyAgentId && targetAgentId && requestedSessionKeyAgentId !== targetAgentId) + ) { + throw new Error("Context-engine successor target conflicts with the caller session identity"); + } + const suppliedAgentId = targetAgentId ?? requestedAgentId; + const suppliedSessionId = normalizeOptionalString(params.sessionId); + const suppliedSessionKey = targetSessionKey ?? requestedSessionKey; + const callerAgentId = suppliedAgentId ?? parseAgentSessionKey(suppliedSessionKey)?.agentId; + if ( + (callerAgentId && marker && marker.agentId !== callerAgentId) || + (targetStorePath && marker && path.resolve(marker.storePath) !== path.resolve(targetStorePath)) + ) { + throw new Error("Context-engine successor target conflicts with the caller session identity"); + } + if (marker && suppliedSessionId && marker.sessionId !== suppliedSessionId) { + throw new Error("Context-engine successor identity is inconsistent"); + } + const candidateSessionKey = suppliedSessionKey; + const candidateEntry = + marker && candidateSessionKey + ? loadSessionEntry({ + agentId: marker.agentId, + sessionKey: candidateSessionKey, + storePath: marker.storePath, + }) + : undefined; + const markerMatches = marker + ? listSessionEntries({ + agentId: marker.agentId, + storePath: marker.storePath, + }).filter(({ entry }) => entry.sessionId === marker.sessionId) + : []; + const preferredMarkerSessionKey = marker + ? resolvePreferredSessionKeyForSessionIdMatches( + markerMatches.map(({ sessionKey, entry }) => [sessionKey, entry]), + marker.sessionId, + ) + : undefined; + const callerAuthorizedMarkerKey = Boolean( + candidateSessionKey && + suppliedSessionKey && + candidateSessionKey === suppliedSessionKey && + (!candidateEntry || candidateEntry.sessionId === callerSessionId), + ); + const markerSessionKey = marker + ? callerAuthorizedMarkerKey + ? candidateSessionKey + : candidateEntry?.sessionId === marker.sessionId + ? candidateSessionKey + : (preferredMarkerSessionKey ?? (candidateEntry ? undefined : candidateSessionKey)) + : undefined; + if (sessionFile && !marker) { + throw new Error("Legacy context-engine file successors are unsupported"); + } + if (marker && !markerSessionKey) { + throw new Error("Legacy context-engine successor identity is ambiguous"); + } + if ( + marker && + candidateSessionKey && + ((candidateEntry && + candidateEntry.sessionId !== marker.sessionId && + !callerAuthorizedMarkerKey) || + (!candidateEntry && markerMatches.length > 0)) + ) { + throw new Error("Legacy context-engine successor session key is inconsistent"); + } + if ( + marker && + parseAgentSessionKey(candidateSessionKey)?.agentId && + parseAgentSessionKey(candidateSessionKey)?.agentId !== marker.agentId + ) { + throw new Error("Legacy context-engine successor identity is inconsistent"); + } + const sessionId = marker?.sessionId ?? suppliedSessionId ?? targetSessionId ?? callerSessionId; if (!sessionId) { return undefined; } - const agentId = params.sessionTarget?.agentId ?? params.agentId ?? sqliteMarker?.agentId; - const sessionKey = params.sessionTarget?.sessionKey ?? params.sessionKey; - const storePath = params.sessionTarget?.storePath ?? sqliteMarker?.storePath; + const agentId = marker?.agentId ?? callerAgentId; + const sessionKey = marker ? markerSessionKey : suppliedSessionKey; + const sessionKeyAgentId = parseAgentSessionKey(sessionKey)?.agentId; + if (sessionKeyAgentId && agentId && sessionKeyAgentId !== agentId) { + throw new Error("Context-engine successor session key conflicts with its agent identity"); + } + const storePath = marker?.storePath ?? targetStorePath; return { ...(agentId ? { agentId } : {}), sessionId, ...(sessionKey ? { sessionKey } : {}), ...(storePath ? { storePath } : {}), - ...(params.sessionTarget?.threadId !== undefined - ? { threadId: params.sessionTarget.threadId } + ...(params.callerSessionTarget?.threadId !== undefined + ? { threadId: params.callerSessionTarget.threadId } : {}), }; } @@ -101,10 +198,11 @@ export async function delegateCompactionToRuntime( const resultSessionTarget = result.result ? buildCompactionResultSessionTarget({ agentId, + callerSessionId: params.sessionId, + callerSessionTarget: sessionTarget, sessionFile: result.result.sessionFile, sessionId: result.result.sessionId, sessionKey, - sessionTarget, }) : undefined; @@ -119,7 +217,9 @@ export async function delegateCompactionToRuntime( tokensBefore: result.result.tokensBefore, tokensAfter: result.result.tokensAfter, details: result.result.details, - ...(result.result.sessionId ? { sessionId: result.result.sessionId } : {}), + ...(result.result.sessionId + ? { sessionId: resultSessionTarget?.sessionId ?? result.result.sessionId } + : {}), // Core reports successors only through the typed sessionTarget; the // deprecated raw sessionFile field is reserved for shipped engines // reporting rotation to core, and post-flip core has no file path. diff --git a/src/cron/isolated-agent.session-identity.test.ts b/src/cron/isolated-agent.session-identity.test.ts index 3f6e23f1c919..034bd4a2d14f 100644 --- a/src/cron/isolated-agent.session-identity.test.ts +++ b/src/cron/isolated-agent.session-identity.test.ts @@ -163,25 +163,18 @@ describe("runCronIsolatedAgentTurn session identity", () => { const call = lastEmbeddedAgentCall(); expect(call.sessionKey).toMatch(/^agent:ops:cron:job-ops:run:/); expect(call.workspaceDir).toBe(opsWorkspace); - expect(call.sessionFile).toContain(path.join("agents", "ops")); + expect(call.sessionFile).toBe(call.sessionKey); }); }); - it("passes sessionFile to isolated cron runs", async () => { + it("passes the canonical key through the deprecated sessionFile field", async () => { await withTempHome(async (home) => { await runCronTurn(home, { jobPayload: DEFAULT_AGENT_TURN_PAYLOAD, }); const call = lastEmbeddedAgentCall(); - expect(call.sessionFile).toBe( - `sqlite:main:${call.sessionId}:${path.join( - home, - ".openclaw", - "sessions", - "sessions.json", - )}`, - ); + expect(call.sessionFile).toBe(call.sessionKey); }); }); @@ -243,13 +236,11 @@ describe("runCronIsolatedAgentTurn session identity", () => { await expect(readSessionEntry(storePath, executionSessionKey)).resolves.toEqual( expect.objectContaining({ sessionId: "bound-session-rotated", - sessionFile: rotatedSessionFile, }), ); await expect(readSessionEntry(storePath, boundSessionKey)).resolves.toEqual( expect.objectContaining({ sessionId: "bound-session", - sessionFile: originalSessionFile, }), ); }); diff --git a/src/cron/isolated-agent.test-harness.ts b/src/cron/isolated-agent.test-harness.ts index 2ba88e3dac67..5cef4eba6e10 100644 --- a/src/cron/isolated-agent.test-harness.ts +++ b/src/cron/isolated-agent.test-harness.ts @@ -33,7 +33,7 @@ export async function writeSessionStoreEntries( await fs.mkdir(dir, { recursive: true }); const storePath = path.join(dir, "sessions.json"); for (const [sessionKey, entry] of Object.entries(entries)) { - await replaceSessionEntry({ storePath, sessionKey }, entry as SessionEntry); + await replaceSessionEntry({ storePath, sessionKey }, entry as unknown as SessionEntry); } return storePath; } diff --git a/src/cron/isolated-agent/delivery-dispatch.double-announce.test.ts b/src/cron/isolated-agent/delivery-dispatch.double-announce.test.ts index 9c8e43abe1d9..85c819cb3e9f 100644 --- a/src/cron/isolated-agent/delivery-dispatch.double-announce.test.ts +++ b/src/cron/isolated-agent/delivery-dispatch.double-announce.test.ts @@ -329,7 +329,12 @@ describe("dispatchCronDelivery — double-announce guard", () => { vi.mocked(enqueueSystemEvent).mockReset(); vi.mocked(appendAssistantMessageToSessionTranscript).mockResolvedValue({ ok: true, - sessionFile: "session.jsonl", + target: { + agentId: "main", + sessionId: "test-session-id", + sessionKey: "agent:main:main", + storePath: "/tmp/sessions.json", + }, messageId: "mirror-message", }); loadCronSessionEntryLatestMock.mockReturnValue({ diff --git a/src/cron/isolated-agent/run-executor.ts b/src/cron/isolated-agent/run-executor.ts index 3865943d95e6..238f61456704 100644 --- a/src/cron/isolated-agent/run-executor.ts +++ b/src/cron/isolated-agent/run-executor.ts @@ -12,7 +12,6 @@ import { withLocalSessionPlacementTurnAdmission } from "../../agents/session-pla import { resolveSessionRuntimeOverrideForProvider } from "../../agents/session-runtime-compat.js"; import type { ThinkLevel, VerboseLevel } from "../../auto-reply/thinking.js"; import type { CliSessionBinding } from "../../config/sessions.js"; -import { formatSqliteSessionFileMarker } from "../../config/sessions/sqlite-marker.js"; import type { AgentDefaultsConfig } from "../../config/types.agent-defaults.js"; import type { OpenClawConfig } from "../../config/types.openclaw.js"; import type { SourceDeliveryPlan } from "../../infra/outbound/source-delivery-plan.js"; @@ -241,17 +240,7 @@ function createCronPromptExecutor(params: { ) => void; onLaneWait?: (info?: { waiting?: boolean }) => void; }) { - const sessionFile = - params.cronSession.sessionEntry.sessionFile?.trim() || - formatSqliteSessionFileMarker({ - agentId: params.agentId, - sessionId: params.cronSession.sessionEntry.sessionId, - storePath: params.cronSession.storePath, - }); - // Fallback for callers that bypass prepareCronRunContext before persisting retries. - if (!params.cronSession.sessionEntry.sessionFile?.trim()) { - params.cronSession.sessionEntry.sessionFile = sessionFile; - } + const sessionFile = params.runSessionKey; const cronFallbacksOverride = params.modelFallbacksOverride ?? resolveCronFallbacksOverride({ diff --git a/src/cron/isolated-agent/run-prepare.ts b/src/cron/isolated-agent/run-prepare.ts index 895e6c0c7a16..62a812f8caaf 100644 --- a/src/cron/isolated-agent/run-prepare.ts +++ b/src/cron/isolated-agent/run-prepare.ts @@ -8,7 +8,6 @@ import type { ThinkLevel } from "../../auto-reply/thinking.js"; import { resolveAgentModelPrimaryValue } from "../../config/model-input.js"; import type { SessionEntry } from "../../config/sessions.js"; import { resolveSessionWorkStartError } from "../../config/sessions/lifecycle.js"; -import { formatSqliteSessionFileMarker } from "../../config/sessions/sqlite-marker.js"; import type { AgentDefaultsConfig } from "../../config/types.agent-defaults.js"; import type { OpenClawConfig } from "../../config/types.openclaw.js"; import type { SourceDeliveryPlan } from "../../infra/outbound/source-delivery-plan.js"; @@ -249,13 +248,6 @@ export async function prepareCronRunContext(params: { } const runSessionId = cronSession.sessionEntry.sessionId; const currentRunSessionId = () => cronSession.sessionEntry.sessionId ?? runSessionId; - if (!cronSession.sessionEntry.sessionFile?.trim()) { - cronSession.sessionEntry.sessionFile = formatSqliteSessionFileMarker({ - agentId, - sessionId: runSessionId, - storePath: cronSession.storePath, - }); - } const runSessionKey = usesDetachedRunSession || baseSessionKey.startsWith("cron:") ? `${agentSessionKey}:run:${runSessionId}` diff --git a/src/cron/isolated-agent/run-session-state.test.ts b/src/cron/isolated-agent/run-session-state.test.ts index a47f067c35cc..dbb5b83d2c3d 100644 --- a/src/cron/isolated-agent/run-session-state.test.ts +++ b/src/cron/isolated-agent/run-session-state.test.ts @@ -1,11 +1,10 @@ // Run session state tests cover persisted session state for isolated cron agents. import crypto from "node:crypto"; -import fs from "node:fs/promises"; -import os from "node:os"; import path from "node:path"; import { afterAll, describe, expect, it, vi } from "vitest"; import { cleanupTempDirs, makeTempDir } from "../../../test/helpers/temp-dir.js"; import type { SessionEntry } from "../../config/sessions.js"; +import { appendTranscriptMessage } from "../../config/sessions/session-accessor.js"; import { beginSessionWorkAdmission } from "../../sessions/session-lifecycle-admission.js"; const resetBoundaryMocks = vi.hoisted(() => ({ @@ -33,9 +32,12 @@ function makeSessionEntry(overrides?: Partial): SessionEntry { }; } -function makeCronSession(entry = makeSessionEntry()): MutableCronSession { +function makeCronSession( + entry = makeSessionEntry(), + storePath = "/tmp/sessions.json", +): MutableCronSession { return { - storePath: "/tmp/sessions.json", + storePath, store: {}, sessionEntry: entry, systemSent: true, @@ -241,7 +243,6 @@ describe("createPersistCronSessionEntry", () => { it("persists isolated cron state only under the stable cron session key", async () => { const cronSession = makeCronSession( makeSessionEntry({ - sessionFile: await createTranscriptFile(), status: "running", startedAt: 900, skillsSnapshot: { @@ -281,14 +282,9 @@ describe("createPersistCronSessionEntry", () => { }); it("does not register cron sessions as resumable until the transcript exists", async () => { - const missingTranscriptPath = path.join( - os.tmpdir(), - `openclaw-missing-cron-${crypto.randomUUID()}.jsonl`, - ); const cronSession = makeCronSession( makeSessionEntry({ lifecycleRevision: "run-revision", - sessionFile: missingTranscriptPath, label: "Cron: shell-only", status: "running", }), @@ -307,7 +303,6 @@ describe("createPersistCronSessionEntry", () => { expect(cronSession.store["agent:main:cron:shell-only"]?.sessionFile).toBeUndefined(); expect(cronSession.store["agent:main:cron:shell-only"]?.lifecycleRevision).toBe("run-revision"); expect(cronSession.sessionEntry.sessionId).toBe("run-session-id"); - expect(cronSession.sessionEntry.sessionFile).toBe(missingTranscriptPath); expect(persistSessionEntry).toHaveBeenCalledWith({ storePath: "/tmp/sessions.json", sessionKey: "agent:main:cron:shell-only", @@ -324,12 +319,22 @@ describe("createPersistCronSessionEntry", () => { }); it("restores resumable cron fields once the transcript exists", async () => { - const transcriptPath = await createTranscriptFile(); + const dir = makeTempDir(cronSessionTempDirs, "openclaw-cron-session-"); + const storePath = path.join(dir, "sessions.json"); + await appendTranscriptMessage( + { + agentId: "main", + sessionId: "run-session-id", + sessionKey: "agent:main:cron:completed", + storePath, + }, + { message: { role: "user", content: "cron prompt" } }, + ); const cronSession = makeCronSession( makeSessionEntry({ - sessionFile: transcriptPath, label: "Cron: completed", }), + storePath, ); const persist = createPersistCronSessionEntry({ @@ -342,7 +347,6 @@ describe("createPersistCronSessionEntry", () => { expect(cronSession.store["agent:main:cron:completed"]).toEqual({ sessionId: "run-session-id", - sessionFile: transcriptPath, label: "Cron: completed", updatedAt: 1000, systemSent: true, @@ -677,7 +681,6 @@ describe("createPersistCronSessionEntry", () => { const cronSession = makeCronSession( makeSessionEntry({ sessionId: "bound-session", - sessionFile: "/tmp/bound-session.jsonl", }), ); const changed = adoptCronRunSessionMetadata({ @@ -701,7 +704,6 @@ describe("createPersistCronSessionEntry", () => { expect(cronSession.store["agent:main:telegram:direct:42"]).toEqual({ sessionId: "bound-session-rotated", - sessionFile: "/tmp/bound-session-rotated.jsonl", usageFamilyKey: "agent:main:telegram:direct:42", usageFamilySessionIds: ["bound-session", "bound-session-rotated"], updatedAt: 1000, @@ -712,7 +714,6 @@ describe("createPersistCronSessionEntry", () => { sessionKey: "agent:main:telegram:direct:42", fallbackEntry: { sessionId: "bound-session-rotated", - sessionFile: "/tmp/bound-session-rotated.jsonl", usageFamilyKey: "agent:main:telegram:direct:42", usageFamilySessionIds: ["bound-session", "bound-session-rotated"], updatedAt: 1000, @@ -725,13 +726,6 @@ describe("createPersistCronSessionEntry", () => { const cronSessionTempDirs: string[] = []; -async function createTranscriptFile(): Promise { - const dir = makeTempDir(cronSessionTempDirs, "openclaw-cron-session-"); - const file = path.join(dir, "session.jsonl"); - await fs.writeFile(file, `${JSON.stringify({ type: "session", sessionId: "run-session-id" })}\n`); - return file; -} - afterAll(() => { cleanupTempDirs(cronSessionTempDirs); }); diff --git a/src/cron/isolated-agent/run-session-state.ts b/src/cron/isolated-agent/run-session-state.ts index c3495f8db109..cebc80a3f91c 100644 --- a/src/cron/isolated-agent/run-session-state.ts +++ b/src/cron/isolated-agent/run-session-state.ts @@ -1,13 +1,12 @@ /** Mutates and persists isolated cron session state around one run. */ -import fs from "node:fs"; import { isDeepStrictEqual } from "node:util"; import { clearBootstrapSnapshotOnSessionBoundary } from "../../agents/bootstrap-cache.js"; import type { LiveSessionModelSelection } from "../../agents/live-model-switch.js"; import { resolveScheduledToolPolicyContext } from "../../agents/scheduled-tool-policy.js"; import type { SessionEntry } from "../../config/sessions.js"; +import { readTranscriptStatsSync } from "../../config/sessions/session-accessor.js"; import { buildSessionCreationStamp } from "../../config/sessions/session-entry-provenance.js"; import { mergeSessionSnapshotChanges } from "../../config/sessions/session-snapshot-merge.js"; -import { parseSqliteSessionFileMarker } from "../../config/sessions/sqlite-marker.js"; import { isCronSessionKey } from "../../sessions/session-key-utils.js"; import { isSessionWorkAdmissionActive } from "../../sessions/session-lifecycle-admission.js"; import type { SkillSnapshot } from "../../skills/types.js"; @@ -62,12 +61,26 @@ export function resolveCronLifecycleRevisionIdentity(lifecycleRevision: string): return `cron-lifecycle-revision:${lifecycleRevision}`; } -function cronTranscriptExists(entry: SessionEntry): boolean { - const sessionFile = entry.sessionFile?.trim(); - if (parseSqliteSessionFileMarker(sessionFile)) { - return true; +function cronTranscriptExists(params: { + entry: SessionEntry; + sessionKey: string; + storePath: string; +}): boolean { + const sessionId = params.entry.sessionId?.trim(); + if (!sessionId) { + return false; + } + try { + return ( + readTranscriptStatsSync({ + sessionId, + sessionKey: params.sessionKey, + storePath: params.storePath, + }).eventCount > 0 + ); + } catch { + return false; } - return Boolean(sessionFile && fs.existsSync(sessionFile)); } function normalizeSessionField(value: string | undefined): string | undefined { @@ -87,7 +100,6 @@ function toNonResumableCronSessionEntry(entry: SessionEntry): SessionEntry { const next = { ...entry } as Partial; // If the transcript never materialized, do not persist stale resume handles // that would make the next cron run believe a resumable CLI session exists. - delete next.sessionFile; delete next.sessionStartedAt; delete next.lastInteractionAt; delete next.cliSessionIds; @@ -108,7 +120,11 @@ export function createPersistCronSessionEntry(params: { const persistedEntry = isCronSessionKey(params.agentSessionKey) && liveEntry.sessionId && - !cronTranscriptExists(liveEntry) + !cronTranscriptExists({ + entry: liveEntry, + sessionKey: params.agentSessionKey, + storePath: params.cronSession.storePath, + }) ? toNonResumableCronSessionEntry(liveEntry) : liveEntry; let committedEntry = persistedEntry; @@ -293,7 +309,7 @@ export function createCronRunContinuationSession(params: { }; } -/** Adopts the session id/file produced by a run and preserves usage-family lineage. */ +/** Adopts the session id produced by a run and preserves usage-family lineage. */ export function adoptCronRunSessionMetadata(params: { entry: MutableCronSessionEntry; sessionKey: string; @@ -303,8 +319,7 @@ export function adoptCronRunSessionMetadata(params: { }; }): boolean { const nextSessionId = normalizeSessionField(params.runMeta?.sessionId); - const nextSessionFile = normalizeSessionField(params.runMeta?.sessionFile); - if (!nextSessionFile) { + if (!nextSessionId) { return false; } @@ -323,11 +338,6 @@ export function adoptCronRunSessionMetadata(params: { changed = true; } - if (nextSessionFile !== params.entry.sessionFile) { - params.entry.sessionFile = nextSessionFile; - changed = true; - } - return changed; } diff --git a/src/cron/isolated-agent/run.message-tool-policy.test.ts b/src/cron/isolated-agent/run.message-tool-policy.test.ts index 403a38177362..b4976135c4a9 100644 --- a/src/cron/isolated-agent/run.message-tool-policy.test.ts +++ b/src/cron/isolated-agent/run.message-tool-policy.test.ts @@ -383,7 +383,7 @@ describe("runCronIsolatedAgentTurn message tool policy", () => { provider: "openai", model: "gpt-5.4", }, - cronSession: makeCronSession() as MutableCronSession, + cronSession: makeCronSession() as unknown as MutableCronSession, commandBody, persistSessionEntry: async () => undefined, abortReason: () => "aborted", diff --git a/src/cron/isolated-agent/run.source-delivery-guard.test.ts b/src/cron/isolated-agent/run.source-delivery-guard.test.ts index e2aae5816851..6bf958868abe 100644 --- a/src/cron/isolated-agent/run.source-delivery-guard.test.ts +++ b/src/cron/isolated-agent/run.source-delivery-guard.test.ts @@ -413,7 +413,7 @@ describe("executeCronRun sourceDelivery mapping", () => { it("forwards an explicit OpenClaw runtime override to cron execution", async () => { mockRunCronFallbackPassthrough(); - const cronSession = makeCronSession() as MutableCronSession; + const cronSession = makeCronSession() as unknown as MutableCronSession; cronSession.sessionEntry.agentRuntimeOverride = "openclaw"; cronSession.sessionEntry.agentHarnessId = "codex"; const executor = makeExecutor({ @@ -463,7 +463,7 @@ function makeExecuteCronRunParams(overrides: Record = {}) { provider: "openai", model: "gpt-5.4", }, - cronSession: makeCronSession() as MutableCronSession, + cronSession: makeCronSession() as unknown as MutableCronSession, commandBody: "run a task", persistSessionEntry: vi.fn().mockResolvedValue(undefined), abortReason: () => "aborted", diff --git a/src/cron/isolated-agent/session.test.ts b/src/cron/isolated-agent/session.test.ts index ddc27841ffcf..195198206bd3 100644 --- a/src/cron/isolated-agent/session.test.ts +++ b/src/cron/isolated-agent/session.test.ts @@ -41,7 +41,6 @@ vi.mock("../../agents/sessions/reset-boundary.js", () => ({ import { clearBootstrapSnapshot } from "../../agents/bootstrap-cache.js"; import { evaluateSessionFreshness } from "../../config/sessions/reset-policy.js"; -import { formatSqliteSessionFileMarker } from "../../config/sessions/sqlite-marker.js"; import { resolveCronSession } from "./session.js"; const NOW_MS = 1_737_600_000_000; @@ -217,13 +216,7 @@ describe("resolveCronSession", () => { expect(result.sessionEntry.claudeCliSessionId).toBeUndefined(); expect(result.sessionEntry.compactionCount).toBe(0); expect(result.sessionEntry.sendPolicy).toBe("allow"); - expect(result.sessionEntry.sessionFile).toBe( - formatSqliteSessionFileMarker({ - agentId: "main", - sessionId: "old-session-id", - storePath: "/tmp/test-store.json", - }), - ); + expect(result.sessionEntry).not.toHaveProperty("sessionFile"); expect(result.resetBoundaryPending).toMatchObject({ reason: "cron-stale" }); expect(clearBootstrapSnapshot).not.toHaveBeenCalled(); }); diff --git a/src/cron/isolated-agent/session.ts b/src/cron/isolated-agent/session.ts index 6fa7690569cb..0e35d935c999 100644 --- a/src/cron/isolated-agent/session.ts +++ b/src/cron/isolated-agent/session.ts @@ -15,10 +15,6 @@ import { type SessionFreshness, } from "../../config/sessions/reset-policy.js"; import { listSessionEntries, loadSessionEntry } from "../../config/sessions/session-accessor.js"; -import { - formatSqliteSessionFileMarker, - sqliteSessionFileMarkerMatchesTarget, -} from "../../config/sessions/sqlite-marker.js"; import type { SessionEntry } from "../../config/sessions/types.js"; import type { OpenClawConfig } from "../../config/types.openclaw.js"; @@ -191,6 +187,7 @@ export function resolveCronSession(params: { ...resolveSessionLifecycleTimestamps({ entry, agentId: params.agentId, + sessionKey: params.sessionKey, storePath, }), now: params.nowMs, @@ -207,11 +204,7 @@ export function resolveCronSession(params: { systemSent = false; if (!sourceSessionDiffers) { staleBoundaryReset = true; - const markerTarget = { agentId: params.agentId, sessionId, storePath }; - const sessionFile = sqliteSessionFileMarkerMatchesTarget(entry.sessionFile, markerTarget) - ? entry.sessionFile! - : formatSqliteSessionFileMarker(markerTarget); - resetBoundaryPending = { reason: "cron-stale", sessionFile }; + resetBoundaryPending = { reason: "cron-stale", sessionFile: params.sessionKey }; } } } else { @@ -247,6 +240,7 @@ export function resolveCronSession(params: { resolveSessionLifecycleTimestamps({ entry, agentId: params.agentId, + sessionKey: params.sessionKey, storePath, }).sessionStartedAt), lastInteractionAt: isNewSession ? params.nowMs : baseEntry?.lastInteractionAt, @@ -259,7 +253,6 @@ export function resolveCronSession(params: { clearAllCliSessions(sessionEntry); sessionEntry.agentHarnessId = undefined; sessionEntry.compactionCount = 0; - sessionEntry.sessionFile = resetBoundaryPending.sessionFile; } return { storePath, diff --git a/src/gateway/managed-image-attachments.ts b/src/gateway/managed-image-attachments.ts index 692c7be54510..febfa7aa13b0 100644 --- a/src/gateway/managed-image-attachments.ts +++ b/src/gateway/managed-image-attachments.ts @@ -683,10 +683,12 @@ async function getSessionManagedOutgoingAttachmentIndex( } let transcriptStat: SessionManagedOutgoingAttachmentTranscriptStat | null = null; + // This path is only a cache/reset-archive hint. Canonical active messages are + // read from the structured SQLite identity below even when no artifact exists. const resolvedTranscriptPath = await resolveSessionHistoryTranscriptPathAsync( sessionId, storePath, - entry.sessionFile, + undefined, { allowResetArchiveFallback: true }, ); if (resolvedTranscriptPath) { diff --git a/src/gateway/server-methods/agent-handler-helpers.ts b/src/gateway/server-methods/agent-handler-helpers.ts index ad2f163fa7b4..708f36fc488c 100644 --- a/src/gateway/server-methods/agent-handler-helpers.ts +++ b/src/gateway/server-methods/agent-handler-helpers.ts @@ -10,7 +10,6 @@ import { resolveSessionWorkStartError, type SessionEntry, } from "../../config/sessions.js"; -import { formatSqliteSessionFileMarker } from "../../config/sessions/sqlite-marker.js"; import type { OpenClawConfig } from "../../config/types.openclaw.js"; import type { CronScheduledToolPolicy } from "../../cron/scheduled-tool-policy.js"; import type { PluginHookSessionEndReason } from "../../plugins/hook-types.js"; @@ -239,12 +238,7 @@ export function withSqliteSessionFileMarker(params: { if (!agentId) { return params.entry; } - const sessionFile = formatSqliteSessionFileMarker({ - agentId, - sessionId: params.entry.sessionId, - storePath: params.storePath, - }); - return params.entry.sessionFile === sessionFile ? params.entry : { ...params.entry, sessionFile }; + return params.entry; } export function yieldAfterAgentAcceptedAck(): Promise { diff --git a/src/gateway/server-methods/agent-run-execution-phase.ts b/src/gateway/server-methods/agent-run-execution-phase.ts index b817980a8bac..606bc21f5372 100644 --- a/src/gateway/server-methods/agent-run-execution-phase.ts +++ b/src/gateway/server-methods/agent-run-execution-phase.ts @@ -211,7 +211,6 @@ export function startAgentRunExecution(params: { : { sessionId: params.resolvedSessionId!, updatedAt: Date.now(), - sessionFile: params.sessionEntry?.sessionFile, }; if (!latestEntry) { return undefined; diff --git a/src/gateway/server-methods/agent-session-patch.ts b/src/gateway/server-methods/agent-session-patch.ts index dd0c2194d4c6..a47e3d572af8 100644 --- a/src/gateway/server-methods/agent-session-patch.ts +++ b/src/gateway/server-methods/agent-session-patch.ts @@ -136,6 +136,7 @@ export function buildAgentSessionPatch(params: { entry: params.freshEntry, storePath: params.storePath, agentId: params.sessionAgentId, + sessionKey: params.canonicalSessionKey, }) : undefined; const freshSkipImplicitExpiry = @@ -236,7 +237,6 @@ export function buildAgentSessionPatch(params: { endedAt: undefined, runtimeMs: undefined, abortedLastRun: undefined, - ...(shouldClearRotatedState ? { sessionFile: undefined } : {}), } : {}), }; diff --git a/src/gateway/server-methods/agent-session-persist.ts b/src/gateway/server-methods/agent-session-persist.ts index 6ae0b76b6b46..274e0bb8075b 100644 --- a/src/gateway/server-methods/agent-session-persist.ts +++ b/src/gateway/server-methods/agent-session-persist.ts @@ -132,6 +132,7 @@ export async function persistAgentSessionPhase(params: { entry: params.entry, storePath: params.storePath, agentId: params.sessionAgentId, + sessionKey: params.canonicalSessionKey, }).sessionStartedAt : undefined; @@ -466,10 +467,8 @@ export async function persistAgentSessionPhase(params: { sessionKey: params.canonicalSessionKey, sessionId: resolvedSessionId, storePath: params.storePath, - sessionFile: sessionEntry?.sessionFile, agentId: params.sessionAgentId, previousSessionId, - previousSessionFile: previousSessionId ? params.entry?.sessionFile : undefined, previousEndReason: previousSessionId ? (freshness?.staleReason ?? (usableRequestedSessionId && params.entry?.sessionId !== usableRequestedSessionId diff --git a/src/gateway/server-methods/agent-session-prepare.ts b/src/gateway/server-methods/agent-session-prepare.ts index 3abf35048bfd..52e6423f2e39 100644 --- a/src/gateway/server-methods/agent-session-prepare.ts +++ b/src/gateway/server-methods/agent-session-prepare.ts @@ -1,5 +1,4 @@ import { randomUUID } from "node:crypto"; -import { existsSync } from "node:fs"; import { normalizeOptionalString } from "@openclaw/normalization-core/string-coerce"; import { ErrorCodes, errorShape } from "../../../packages/gateway-protocol/src/index.js"; import { resolveDefaultAgentId } from "../../agents/agent-scope.js"; @@ -10,8 +9,6 @@ import { resolveAgentIdFromSessionKey, resolveAgentMainSessionKey, resolveChannelResetConfig, - resolveSessionFilePath, - resolveSessionFilePathOptions, resolveSessionLifecycleTimestamps, resolveSessionResetPolicy, resolveSessionResetType, @@ -22,7 +19,6 @@ import { } from "../../config/sessions.js"; import { hasProviderOwnedSession } from "../../config/sessions/entry-freshness.js"; import { readTranscriptStatsSync } from "../../config/sessions/session-accessor.js"; -import { parseSqliteSessionFileMarker } from "../../config/sessions/sqlite-marker.js"; import { resolveMaintenanceConfigFromInput } from "../../config/sessions/store-maintenance.js"; import { isRecoverableTerminalSessionStatus } from "../../config/sessions/terminal-status.js"; import type { OpenClawConfig } from "../../config/types.openclaw.js"; @@ -215,6 +211,7 @@ export function prepareAgentSession(params: { entry, storePath, agentId: canonicalSessionAgentId, + sessionKey: canonicalKey, }) : undefined; const skipImplicitExpiry = @@ -240,31 +237,16 @@ export function prepareAgentSession(params: { if (candidateEntry?.status !== "failed" || !candidateEntry.sessionId?.trim()) { return false; } - const sqliteMarker = parseSqliteSessionFileMarker(candidateEntry.sessionFile); - if (sqliteMarker) { - if (sqliteMarker.sessionId !== candidateEntry.sessionId) { - return true; - } - try { - return ( - readTranscriptStatsSync({ - agentId: sqliteMarker.agentId, - sessionId: sqliteMarker.sessionId, - sessionKey: canonicalKey, - storePath: sqliteMarker.storePath, - sessionEntry: candidateEntry, - }).eventCount === 0 - ); - } catch { - return true; - } - } try { - const options = resolveSessionFilePathOptions({ - storePath, - agentId: canonicalSessionAgentId, - }); - return !existsSync(resolveSessionFilePath(candidateEntry.sessionId, candidateEntry, options)); + return ( + readTranscriptStatsSync({ + agentId: canonicalSessionAgentId, + sessionId: candidateEntry.sessionId, + sessionKey: canonicalKey, + storePath, + sessionEntry: candidateEntry, + }).eventCount === 0 + ); } catch { return true; } diff --git a/src/gateway/server-methods/agent.media-and-routing.test-utils.ts b/src/gateway/server-methods/agent.media-and-routing.test-utils.ts index e980a4b87371..82af9a05aa99 100644 --- a/src/gateway/server-methods/agent.media-and-routing.test-utils.ts +++ b/src/gateway/server-methods/agent.media-and-routing.test-utils.ts @@ -47,7 +47,7 @@ const mocks = getAgentTestMocks(); describe("gateway agent handler", () => { afterEach(describe0AfterEach0); - it("recovers a failed session when its default transcript exists", async () => { + it("recovers a failed session when its SQLite transcript exists", async () => { const now = Date.parse("2026-05-18T09:49:00.000Z"); vi.useFakeTimers({ toFake: ["Date"] }); setDateOnlyFakeClockActive(true); @@ -56,7 +56,7 @@ describe("gateway agent handler", () => { await withTempDir({ prefix: "openclaw-gateway-failed-default-session-file-" }, async (root) => { const sessionsDir = `${root}/sessions`; await fs.mkdir(sessionsDir, { recursive: true }); - await fs.writeFile(`${sessionsDir}/failed-present-default-session-id.jsonl`, "", "utf8"); + mocks.readTranscriptStatsSync.mockReturnValue({ eventCount: 1, maxSeq: 1, sizeBytes: 32 }); const failedEntryWithDefaultTranscript = { sessionId: "failed-present-default-session-id", status: "failed", @@ -93,14 +93,14 @@ describe("gateway agent handler", () => { it.each([ { - name: "default transcript", + name: "SQLite transcript", sessionKey: "agent:main:telegram:group:stale-failed", sessionId: "stale-failed-session-id", - configureTranscript: async (params: { sessionId: string; sessionsDir: string }) => { - await fs.writeFile(`${params.sessionsDir}/${params.sessionId}.jsonl`, "", "utf8"); + configureTranscript: async () => { + mocks.readTranscriptStatsSync.mockReturnValue({ eventCount: 1, maxSeq: 1, sizeBytes: 32 }); return {}; }, - expectsSqliteStats: false, + expectsSqliteStats: true, }, { name: "SQLite transcript marker", @@ -128,7 +128,6 @@ describe("gateway agent handler", () => { await fs.mkdir(sessionsDir, { recursive: true }); const transcriptFields = await scenario.configureTranscript({ sessionId: scenario.sessionId, - sessionsDir, storePath, }); const failedEntryWithStaleActivity = { @@ -193,7 +192,7 @@ describe("gateway agent handler", () => { }); }); - it("recovers a failed session when its relative transcript resolves and exists", async () => { + it("recovers a failed session when its SQLite transcript rows exist", async () => { const now = Date.parse("2026-05-18T09:50:00.000Z"); vi.useFakeTimers({ toFake: ["Date"] }); setDateOnlyFakeClockActive(true); @@ -202,10 +201,9 @@ describe("gateway agent handler", () => { await withTempDir({ prefix: "openclaw-gateway-failed-session-file-" }, async (root) => { const sessionsDir = `${root}/sessions`; await fs.mkdir(sessionsDir, { recursive: true }); - await fs.writeFile(`${sessionsDir}/relative-present.jsonl`, "", "utf8"); + mocks.readTranscriptStatsSync.mockReturnValue({ eventCount: 1, maxSeq: 1, sizeBytes: 32 }); const failedEntryWithResolvedTranscript = { sessionId: "failed-present-session-id", - sessionFile: "relative-present.jsonl", status: "failed", startedAt: now - 1_000, endedAt: now, @@ -870,12 +868,12 @@ describe("gateway agent handler", () => { expect(mocks.agentCommand).not.toHaveBeenCalled(); }); - it("recovers terminal failed agent API sessions without rotating the session id", async () => { + it("recovers terminal failed agent API sessions with SQLite transcript rows", async () => { const sessionId = "failed-agent-session"; await withTempDir({ prefix: "openclaw-gateway-terminal-recovery-" }, async (root) => { const sessionsDir = `${root}/sessions`; await fs.mkdir(sessionsDir, { recursive: true }); - await fs.writeFile(`${sessionsDir}/${sessionId}.jsonl`, "", "utf8"); + mocks.readTranscriptStatsSync.mockReturnValue({ eventCount: 1, maxSeq: 1, sizeBytes: 32 }); mocks.loadSessionEntry.mockReturnValue({ cfg: {}, storePath: `${sessionsDir}/sessions.json`, diff --git a/src/gateway/server-methods/agent.test-harness.ts b/src/gateway/server-methods/agent.test-harness.ts index 468c5a2e8562..e966bdca5ccb 100644 --- a/src/gateway/server-methods/agent.test-harness.ts +++ b/src/gateway/server-methods/agent.test-harness.ts @@ -10,7 +10,6 @@ import { } from "../../agents/subagent-registry.test-helpers.js"; import type { SessionEntry } from "../../config/sessions.js"; import type { SessionTranscriptStats } from "../../config/sessions/session-accessor.js"; -import { parseSqliteSessionFileMarker } from "../../config/sessions/sqlite-marker.js"; import { resetDiagnosticEventsForTest } from "../../infra/diagnostic-events.js"; import { resetDetachedTaskLifecycleRuntimeForTests, @@ -394,10 +393,7 @@ export function expectStringFieldContains( } export function expectSqliteSessionFileMarkerForEntry(entry: Record | undefined) { - const sessionFile = entry?.sessionFile; - expect(sessionFile).toBeTypeOf("string"); - const marker = parseSqliteSessionFileMarker(sessionFile as string); - expect(marker?.sessionId).toBe(entry?.sessionId); + expect(entry).not.toHaveProperty("sessionFile"); } export function mockCallArg(mock: ReturnType, callIndex = 0, argIndex = 0) { diff --git a/src/gateway/server-methods/chat-abort-runtime.ts b/src/gateway/server-methods/chat-abort-runtime.ts index 78a7bcf0cd7c..5156d30815d7 100644 --- a/src/gateway/server-methods/chat-abort-runtime.ts +++ b/src/gateway/server-methods/chat-abort-runtime.ts @@ -83,7 +83,6 @@ export async function persistAbortedPartials(params: { message: snapshot.text, sessionId, storePath, - sessionFile: entry?.sessionFile, ...(snapshot.agentId ? { agentId: snapshot.agentId } : {}), createIfMissing: true, idempotencyKey: `${snapshot.runId}:assistant`, diff --git a/src/gateway/server-methods/chat-send-nonagent-finalization.ts b/src/gateway/server-methods/chat-send-nonagent-finalization.ts index 1456f569cb1f..7f8663791472 100644 --- a/src/gateway/server-methods/chat-send-nonagent-finalization.ts +++ b/src/gateway/server-methods/chat-send-nonagent-finalization.ts @@ -317,7 +317,6 @@ export async function finalizeChatSendNonAgentReplies(params: { ...(persistedContentForAppend?.length ? { content: persistedContentForAppend } : {}), sessionId, storePath: latestStorePath, - sessionFile: latestEntry?.sessionFile, agentId: transcriptAgentId, createIfMissing: true, idempotencyKey: clientRunId, diff --git a/src/gateway/server-methods/chat-send-reply-dispatch.ts b/src/gateway/server-methods/chat-send-reply-dispatch.ts index 6279dc555fc2..e603e103db3b 100644 --- a/src/gateway/server-methods/chat-send-reply-dispatch.ts +++ b/src/gateway/server-methods/chat-send-reply-dispatch.ts @@ -218,7 +218,6 @@ export function createChatSendReplyDispatch(params: { ...(persistedContentForAppend.length ? { content: persistedContentForAppend } : {}), sessionId, storePath: latestStorePath, - sessionFile: latestEntry?.sessionFile, agentId, createIfMissing: true, idempotencyKey: `${clientRunId}:assistant-media`, diff --git a/src/gateway/server-methods/chat.abort-persistence.test.ts b/src/gateway/server-methods/chat.abort-persistence.test.ts index 1511c943883b..7f7b5c458ba1 100644 --- a/src/gateway/server-methods/chat.abort-persistence.test.ts +++ b/src/gateway/server-methods/chat.abort-persistence.test.ts @@ -6,12 +6,12 @@ import os from "node:os"; import path from "node:path"; import { expectDefined } from "@openclaw/normalization-core"; import { afterEach, describe, expect, it, vi } from "vitest"; +import { formatSqliteSessionFileMarker } from "../../config/sessions/legacy-sqlite-marker.js"; import { appendTranscriptMessageSync, loadTranscriptEvents, replaceSessionEntry, } from "../../config/sessions/session-accessor.js"; -import { formatSqliteSessionFileMarker } from "../../config/sessions/sqlite-marker.js"; import { onAgentEvent, resetAgentEventsForTest } from "../../infra/agent-events.js"; import { closeOpenClawAgentDatabasesForTest } from "../../state/openclaw-agent-db.js"; import { closeOpenClawStateDatabaseForTest } from "../../state/openclaw-state-db.js"; diff --git a/src/gateway/server-methods/chat.ts b/src/gateway/server-methods/chat.ts index d7037b780c63..efd01a5615ef 100644 --- a/src/gateway/server-methods/chat.ts +++ b/src/gateway/server-methods/chat.ts @@ -179,7 +179,6 @@ export const chatHandlers: GatewayRequestHandlers = { label: p.label, sessionId, storePath, - sessionFile: entry.sessionFile, agentId, createIfMissing: true, cfg, diff --git a/src/gateway/server-methods/send.test.ts b/src/gateway/server-methods/send.test.ts index c1b8a350bb5a..c60ace38e0c1 100644 --- a/src/gateway/server-methods/send.test.ts +++ b/src/gateway/server-methods/send.test.ts @@ -30,7 +30,11 @@ type ResolveOutboundTarget = typeof import("../../infra/outbound/targets.js").re const mocks = vi.hoisted(() => ({ deliverOutboundPayloads: vi.fn(), appendAssistantMessageToSessionTranscript: vi.fn<() => Promise>( - async () => ({ ok: true, sessionFile: "x", messageId: "message-x" }), + async () => ({ + ok: true, + target: { sessionId: "x", sessionKey: "x", storePath: "/tmp/sessions.json" }, + messageId: "message-x", + }), ), beginRestartRecoveryTerminalDelivery: vi.fn< () => Promise<"started" | "blocked" | "stale" | "not-applicable"> @@ -3402,7 +3406,11 @@ describe("gateway send mirroring", () => { }); expect(respond).not.toHaveBeenCalled(); - mirrorDeferred.resolve({ ok: true, sessionFile: "x", messageId: "message-async" }); + mirrorDeferred.resolve({ + ok: true, + target: { sessionId: "x", sessionKey: "x", storePath: "/tmp/sessions.json" }, + messageId: "message-async", + }); await request; expect(firstRespondCall(respond)[0]).toBe(true); @@ -3419,7 +3427,11 @@ describe("gateway send mirroring", () => { ); mocks.appendAssistantMessageToSessionTranscript .mockReturnValueOnce(firstMirrorDeferred.promise) - .mockResolvedValueOnce({ ok: true, sessionFile: "x", messageId: "message-second" }); + .mockResolvedValueOnce({ + ok: true, + target: { sessionId: "x", sessionKey: "x", storePath: "/tmp/sessions.json" }, + messageId: "message-second", + }); const firstRespond = vi.fn(); const secondRespond = vi.fn(); @@ -3484,7 +3496,11 @@ describe("gateway send mirroring", () => { expect.objectContaining({ text: "first visible reply" }), ); - firstMirrorDeferred.resolve({ ok: true, sessionFile: "x", messageId: "message-first" }); + firstMirrorDeferred.resolve({ + ok: true, + target: { sessionId: "x", sessionKey: "x", storePath: "/tmp/sessions.json" }, + messageId: "message-first", + }); await first; await second; diff --git a/src/gateway/server-methods/session-audit.ts b/src/gateway/server-methods/session-audit.ts index c46a5a255fa1..e322f7697231 100644 --- a/src/gateway/server-methods/session-audit.ts +++ b/src/gateway/server-methods/session-audit.ts @@ -1,6 +1,5 @@ import { SessionManager } from "../../agents/sessions/session-manager.js"; import type { SessionEntry } from "../../config/sessions.js"; -import { formatSqliteSessionFileMarker } from "../../config/sessions/sqlite-marker.js"; import type { OpenClawConfig } from "../../config/types.openclaw.js"; export async function appendSessionAudit(params: { @@ -8,17 +7,18 @@ export async function appendSessionAudit(params: { target: { agentId: string; entry: Pick; + sessionKey: string; storePath: string; }; text: string; now: number; }): Promise { - const sessionFile = formatSqliteSessionFileMarker({ + const identity = { agentId: params.target.agentId, sessionId: params.target.entry.sessionId, storePath: params.target.storePath, - }); - SessionManager.open(sessionFile).appendMessage( + }; + SessionManager.open({ ...identity, sessionKey: params.target.sessionKey }).appendMessage( { role: "custom", customType: "openclaw.system-note", diff --git a/src/gateway/server-methods/sessions-compact.ts b/src/gateway/server-methods/sessions-compact.ts index 406293077b4b..9d1ec50e4330 100644 --- a/src/gateway/server-methods/sessions-compact.ts +++ b/src/gateway/server-methods/sessions-compact.ts @@ -119,7 +119,7 @@ export const sessionCompactHandlers: GatewayRequestHandlers = { sessionKey: compactTarget.primaryKey, agentId: target.agentId, }, - { maxLines, sessionFile: entry.sessionFile }, + { maxLines }, ); if (!trimPreflight.compacted) { respond( @@ -296,7 +296,7 @@ export const sessionCompactHandlers: GatewayRequestHandlers = { sessionKey: compactTarget.primaryKey, agentId: target.agentId, }, - { maxLines, sessionFile: latestEntry.sessionFile }, + { maxLines }, ); respond( true, diff --git a/src/gateway/server-methods/sessions-compaction-runner.ts b/src/gateway/server-methods/sessions-compaction-runner.ts index 935b3e45e692..690ce048742d 100644 --- a/src/gateway/server-methods/sessions-compaction-runner.ts +++ b/src/gateway/server-methods/sessions-compaction-runner.ts @@ -103,7 +103,7 @@ export async function runGatewaySessionCompaction( storePath: params.storePath, }, allowGatewaySubagentBinding: true, - sessionFile: transcriptTarget.sessionFile, + sessionFile: transcriptTarget.sessionKey, workspaceDir, cwd: normalizeOptionalString(params.entry.spawnedCwd), config: params.cfg, diff --git a/src/gateway/server-methods/sessions-mutations.ts b/src/gateway/server-methods/sessions-mutations.ts index dd79c545d1af..5a6b61980e64 100644 --- a/src/gateway/server-methods/sessions-mutations.ts +++ b/src/gateway/server-methods/sessions-mutations.ts @@ -266,7 +266,12 @@ export const sessionMutationHandlers: GatewayRequestHandlers = { try { await appendSessionAudit({ cfg, - target: { agentId: target.agentId, entry: result.entry, storePath }, + target: { + agentId: target.agentId, + entry: result.entry, + sessionKey: target.canonicalKey ?? key, + storePath, + }, text: `${action} by ${archiveActor.label ?? archiveActor.id}`, now: Date.now(), }); diff --git a/src/gateway/server-methods/sessions-rewind.test.ts b/src/gateway/server-methods/sessions-rewind.test.ts index 2e0c6febdaee..4173e8dad0e9 100644 --- a/src/gateway/server-methods/sessions-rewind.test.ts +++ b/src/gateway/server-methods/sessions-rewind.test.ts @@ -432,34 +432,6 @@ describe("session message-cut methods", () => { }, ); - it("returns a typed error for unsupported transcript storage", async () => { - await upsertSessionEntry( - { agentId: "main", sessionKey }, - { - sessionFile: "/tmp/legacy-session.jsonl", - }, - ); - const respond = await invoke("sessions.rewind", "user-entry"); - const listed = await invoke("sessions.branches.list"); - - expect(respond).toHaveBeenCalledWith( - false, - undefined, - expect.objectContaining({ - code: ErrorCodes.INVALID_REQUEST, - message: expect.stringContaining("storage does not support rewind"), - }), - ); - expect(listed).toHaveBeenCalledWith( - false, - undefined, - expect.objectContaining({ - code: ErrorCodes.INVALID_REQUEST, - message: expect.stringContaining("storage does not support branch listing"), - }), - ); - }); - it.each([ ["sessions.fork", "Fork"], ["sessions.rewind", "Rewind"], diff --git a/src/gateway/server-methods/sessions-sharing.ts b/src/gateway/server-methods/sessions-sharing.ts index 29a399559b51..fb9c1a3afab1 100644 --- a/src/gateway/server-methods/sessions-sharing.ts +++ b/src/gateway/server-methods/sessions-sharing.ts @@ -234,7 +234,7 @@ export const sessionSharingHandlers: GatewayRequestHandlers = { try { await appendSessionAudit({ cfg, - target: current, + target: { ...current, sessionKey: current.storeKey }, text: `${actor.label ?? actor.id} changed session visibility from ${previous} to ${visibility}.`, now, }); @@ -366,7 +366,7 @@ export const sessionSharingHandlers: GatewayRequestHandlers = { try { await appendSessionAudit({ cfg, - target: current, + target: { ...current, sessionKey: current.storeKey }, text: `${actor.label ?? actor.id} added ${params.identityId} as a session member.`, now, }); @@ -437,7 +437,7 @@ export const sessionSharingHandlers: GatewayRequestHandlers = { try { await appendSessionAudit({ cfg, - target: current, + target: { ...current, sessionKey: current.storeKey }, text: `${actor.label ?? actor.id} removed ${params.identityId} from session members.`, now, }); diff --git a/src/gateway/server-methods/sessions-suggestions.ts b/src/gateway/server-methods/sessions-suggestions.ts index f8c9dc10e59d..2c24a5ebfb90 100644 --- a/src/gateway/server-methods/sessions-suggestions.ts +++ b/src/gateway/server-methods/sessions-suggestions.ts @@ -579,7 +579,7 @@ export const sessionSuggestionHandlers: GatewayRequestHandlers = { try { await appendSessionAudit({ cfg: context.getRuntimeConfig(), - target, + target: { ...target, sessionKey: target.canonicalKey }, text: `${actor.label ?? actor.id} ${resolutionAuditAction(resolution)}.`, now: Date.now(), }); diff --git a/src/gateway/server-session-events.test.ts b/src/gateway/server-session-events.test.ts index 3e44b0e7b5c3..eb46956a6030 100644 --- a/src/gateway/server-session-events.test.ts +++ b/src/gateway/server-session-events.test.ts @@ -15,8 +15,17 @@ const sessionRow = vi.hoisted(() => ({ })); const isEmbeddedAgentRunInProgressMock = vi.hoisted(() => vi.fn()); const projectChatDisplayMessageMock = vi.hoisted(() => vi.fn((message: unknown) => message)); +const loadAccessorSessionEntryReadOnlyMock = vi.hoisted(() => vi.fn()); +const readSessionMessageCountAsyncMock = vi.hoisted(() => vi.fn()); vi.mock("../config/io.js", () => ({ getRuntimeConfig: () => ({}) })); +vi.mock("../config/sessions/session-accessor.js", async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + loadSessionEntryReadOnly: loadAccessorSessionEntryReadOnlyMock, + }; +}); vi.mock("./chat-display-projection.js", () => ({ projectChatDisplayMessage: projectChatDisplayMessageMock, })); @@ -24,8 +33,14 @@ vi.mock("./session-utils.js", () => ({ attachOpenClawTranscriptMeta: (message: unknown) => message, loadGatewaySessionRow: () => sessionRow, loadSessionEntry: () => ({ entry: undefined, storePath: "" }), - readSessionMessageCountAsync: vi.fn(), })); +vi.mock("./session-transcript-readers.js", async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + readSessionMessageCountAsync: readSessionMessageCountAsyncMock, + }; +}); vi.mock("../agents/embedded-agent-runner/runs.js", async () => { const actual = await vi.importActual( "../agents/embedded-agent-runner/runs.js", @@ -83,6 +98,8 @@ describe("createTranscriptUpdateBroadcastHandler", () => { beforeEach(() => { vi.clearAllMocks(); isEmbeddedAgentRunInProgressMock.mockReturnValue(false); + loadAccessorSessionEntryReadOnlyMock.mockReturnValue(undefined); + readSessionMessageCountAsyncMock.mockResolvedValue(undefined); sessionRow.thinkingLevel = "ultra"; }); @@ -170,7 +187,6 @@ describe("createTranscriptUpdateBroadcastHandler", () => { senderIsOwner: true, }); }); - it("publishes message-phase changes to plugins without websocket subscribers", async () => { const received = vi.fn(); const unsubscribe = subscribePluginSessionsChanged(received); @@ -200,6 +216,36 @@ describe("createTranscriptUpdateBroadcastHandler", () => { unsubscribe(); } }); + + it("resolves messageSeq through a partial target's explicit store", async () => { + loadAccessorSessionEntryReadOnlyMock.mockReturnValue({ + sessionId: "sess-main", + updatedAt: 1, + }); + readSessionMessageCountAsyncMock.mockResolvedValue(7); + const { broadcastToConnIds, handler } = createHandler(false); + + handler({ + agentId: "main", + message: { role: "assistant", content: [{ type: "text", text: "Final answer" }] }, + messageId: "message-partial-target", + sessionKey: "agent:main:main", + target: { + agentId: "main", + sessionId: "partial-target-session", + sessionKey: "agent:main:main", + storePath: "/tmp/explicit-sessions.json", + }, + }); + await vi.waitFor(() => expect(broadcastToConnIds).toHaveBeenCalledTimes(1)); + + expect(loadAccessorSessionEntryReadOnlyMock).toHaveBeenCalledWith({ + agentId: "main", + sessionKey: "agent:main:main", + storePath: "/tmp/explicit-sessions.json", + }); + expect(broadcastToConnIds.mock.calls[0]?.[1]).toMatchObject({ messageSeq: 7 }); + }); }); describe("createLifecycleEventBroadcastHandler", () => { diff --git a/src/gateway/server-session-events.ts b/src/gateway/server-session-events.ts index 47f4552e981e..8b670756febe 100644 --- a/src/gateway/server-session-events.ts +++ b/src/gateway/server-session-events.ts @@ -1,16 +1,18 @@ // Gateway session event broadcaster. // Projects transcript and lifecycle updates to websocket subscribers. +import path from "node:path"; import { asPositiveSafeInteger } from "@openclaw/normalization-core/number-coercion"; import { normalizeOptionalString } from "@openclaw/normalization-core/string-coerce"; import { resolveDefaultAgentId } from "../agents/agent-scope.js"; import { getRuntimeConfig } from "../config/io.js"; +import { parseSqliteSessionFileMarker } from "../config/sessions/legacy-sqlite-marker.js"; import { - loadSessionEntryReadOnly as loadAccessorSessionEntry, + listSessionEntriesReadOnly as listAccessorSessionEntriesReadOnly, + loadSessionEntryReadOnly as loadAccessorSessionEntryReadOnly, resolveTranscriptSessionKeyBySessionId, } from "../config/sessions/session-accessor.js"; -import { parseSqliteSessionFileMarker } from "../config/sessions/sqlite-marker.js"; import { hasPluginSessionsChangedSubscribers } from "../plugins/gateway-events.js"; -import { normalizeAgentId } from "../routing/session-key.js"; +import { normalizeAgentId, parseAgentSessionKey } from "../routing/session-key.js"; import type { SessionLifecycleEvent } from "../sessions/session-lifecycle-events.js"; import type { InternalSessionTranscriptUpdate } from "../sessions/transcript-events.js"; import type { ChatAbortControllerEntry } from "./chat-abort.js"; @@ -25,7 +27,6 @@ import { buildGatewaySessionEventFields, buildGatewaySessionEventRow, } from "./session-event-payload.js"; -import { resolveSessionKeyForTranscriptFile } from "./session-transcript-key.js"; import { attachOpenClawTranscriptMeta, readSessionMessageCountAsync, @@ -155,35 +156,77 @@ async function handleTranscriptUpdateBroadcast( }, update: InternalSessionTranscriptUpdate, ): Promise { - const sqliteMarker = parseSqliteSessionFileMarker(update.sessionFile); - const storageAgentId = update.target?.agentId ?? update.agentId ?? sqliteMarker?.agentId; - const sessionKey = - update.target?.sessionKey ?? - update.sessionKey ?? - (sqliteMarker - ? resolveTranscriptSessionKeyBySessionId({ - agentId: storageAgentId, - sessionId: sqliteMarker.sessionId, - storePath: sqliteMarker.storePath, + const legacyMarker = parseSqliteSessionFileMarker(update.sessionFile); + const targetAgentId = normalizeOptionalString(update.target?.agentId); + const targetSessionId = normalizeOptionalString(update.target?.sessionId); + const targetSessionKey = normalizeOptionalString(update.target?.sessionKey); + const suppliedSessionKey = normalizeOptionalString(update.sessionKey); + const candidateSessionKey = targetSessionKey ?? suppliedSessionKey; + const targetKeyAgentId = parseAgentSessionKey(candidateSessionKey)?.agentId; + const targetStorePath = normalizeOptionalString(update.target?.storePath); + const completeTarget = Boolean( + targetAgentId && targetSessionId && targetSessionKey && targetStorePath, + ); + const markerSessionKey = + legacyMarker && !completeTarget + ? resolveTranscriptSessionKeyBySessionId(legacyMarker) + : undefined; + const markerMatches = + legacyMarker && !completeTarget + ? listAccessorSessionEntriesReadOnly({ + agentId: legacyMarker.agentId, + storePath: legacyMarker.storePath, + }).filter(({ entry }) => entry.sessionId === legacyMarker.sessionId) + : []; + const candidateKeyEntry = + candidateSessionKey && legacyMarker && !completeTarget + ? loadAccessorSessionEntryReadOnly({ + agentId: legacyMarker.agentId, + sessionKey: candidateSessionKey, + storePath: legacyMarker.storePath, }) - : undefined) ?? - (update.sessionFile ? resolveSessionKeyForTranscriptFile(update.sessionFile) : undefined); + : undefined; + if (targetKeyAgentId && targetAgentId && targetKeyAgentId !== targetAgentId) { + return; + } + if ( + legacyMarker && + !completeTarget && + ((targetAgentId && targetAgentId !== legacyMarker.agentId) || + (targetSessionId && + targetSessionId !== legacyMarker.sessionId && + candidateKeyEntry?.sessionId !== legacyMarker.sessionId) || + (targetKeyAgentId && targetKeyAgentId !== legacyMarker.agentId) || + (candidateSessionKey && + ((candidateKeyEntry && candidateKeyEntry.sessionId !== legacyMarker.sessionId) || + (!candidateKeyEntry && markerMatches.length > 0))) || + (targetStorePath && path.resolve(targetStorePath) !== path.resolve(legacyMarker.storePath))) + ) { + return; + } + const compatibleLegacyMarker = completeTarget ? undefined : legacyMarker; + const storageAgentId = compatibleLegacyMarker?.agentId ?? targetAgentId ?? update.agentId; + const sessionKey = compatibleLegacyMarker + ? candidateKeyEntry?.sessionId === compatibleLegacyMarker.sessionId || + (!candidateKeyEntry && markerMatches.length === 0) + ? candidateSessionKey + : markerSessionKey + : candidateSessionKey; if (!sessionKey || update.message === undefined) { return; } - const effectiveAgentId = update.target?.agentId ?? update.agentId; + const effectiveAgentId = compatibleLegacyMarker?.agentId ?? targetAgentId ?? update.agentId; const defaultGlobalAgentId = sessionKey === "global" ? normalizeAgentId(resolveDefaultAgentId(getRuntimeConfig())) : undefined; - const visibleAgentId = - effectiveAgentId ?? - (effectiveAgentId && effectiveAgentId !== defaultGlobalAgentId ? effectiveAgentId : undefined); + const visibleAgentId = effectiveAgentId; + const routingAgentId = effectiveAgentId ?? defaultGlobalAgentId; const connIds = new Set(); for (const connId of params.sessionEventSubscribers.getAll()) { connIds.add(connId); } - for (const broadcastKey of resolveSessionMessageBroadcastKeys(sessionKey, effectiveAgentId)) { + for (const broadcastKey of resolveSessionMessageBroadcastKeys(sessionKey, routingAgentId)) { for (const connId of params.sessionMessageSubscribers.get(broadcastKey)) { connIds.add(connId); } @@ -197,24 +240,29 @@ async function handleTranscriptUpdateBroadcast( if (messageSeq === undefined) { // Updates from raw transcript events may not carry seq; fall back to the // current transcript line count for cursor-compatible live history. - const markerEntry = sqliteMarker - ? loadAccessorSessionEntry({ - agentId: storageAgentId, - sessionKey, - storePath: sqliteMarker.storePath, - }) - : undefined; - const fallbackTarget = markerEntry - ? undefined - : loadSessionEntryReadOnly(sessionKey, { agentId: visibleAgentId }); - const entry = markerEntry ?? fallbackTarget?.entry; - const storePath = sqliteMarker?.storePath ?? fallbackTarget?.storePath; - messageSeq = entry?.sessionId + const updateStorePath = targetStorePath ?? compatibleLegacyMarker?.storePath; + const fallbackTarget = updateStorePath + ? { + entry: loadAccessorSessionEntryReadOnly({ + agentId: storageAgentId ?? routingAgentId, + sessionKey, + storePath: updateStorePath, + }), + storePath: updateStorePath, + } + : loadSessionEntryReadOnly(sessionKey, { agentId: routingAgentId }); + const entry = fallbackTarget?.entry; + const messageSessionId = + compatibleLegacyMarker?.sessionId ?? + normalizeOptionalString(update.target?.sessionId) ?? + entry?.sessionId; + const storePath = updateStorePath ?? fallbackTarget?.storePath; + messageSeq = messageSessionId ? asPositiveSafeInteger( await readSessionMessageCountAsync({ - agentId: storageAgentId ?? visibleAgentId, + agentId: update.target?.agentId ?? storageAgentId ?? routingAgentId, sessionEntry: entry, - sessionId: entry.sessionId, + sessionId: messageSessionId, sessionKey, storePath, }), @@ -222,7 +270,7 @@ async function handleTranscriptUpdateBroadcast( : undefined; } const sessionRow = loadGatewaySessionRow(sessionKey, { - agentId: visibleAgentId, + agentId: routingAgentId, transcriptUsageMaxBytes: 64 * 1024, }); const activeRunState = sessionRow @@ -231,13 +279,13 @@ async function handleTranscriptUpdateBroadcast( requestedKey: sessionKey, canonicalKey: sessionRow.key, sessionId: sessionRow.sessionId, - ...(sessionRow.key === "global" && visibleAgentId ? { agentId: visibleAgentId } : {}), + ...(sessionRow.key === "global" && routingAgentId ? { agentId: routingAgentId } : {}), defaultAgentId: normalizeAgentId(resolveDefaultAgentId(getRuntimeConfig())), }) : null; const sessionSnapshot = buildGatewaySessionSnapshot({ sessionRow, - agentId: visibleAgentId, + agentId: routingAgentId, includeSession: true, hasActiveRun: activeRunState?.active, activeRunIds: activeRunState?.runIds, diff --git a/src/gateway/server.sessions.compaction.test.ts b/src/gateway/server.sessions.compaction.test.ts index 0370cb63aa4c..c5c2ffd56ef0 100644 --- a/src/gateway/server.sessions.compaction.test.ts +++ b/src/gateway/server.sessions.compaction.test.ts @@ -199,11 +199,30 @@ async function loadTranscriptRows(params: { ); } +async function alignCheckpointBoundaryWithSqliteRows(params: { + sessionId: string; + sessionKey: string; + storePath: string; +}): Promise { + const rows = await loadTranscriptRows(params); + const leafId = rows.toReversed().find((row) => row.type !== "session")?.id; + if (typeof leafId !== "string") { + throw new Error("expected a SQLite checkpoint boundary row"); + } + await patchAccessorSessionEntry(params, (entry) => ({ + ...entry, + compactionCheckpoints: entry.compactionCheckpoints?.map((checkpoint) => ({ + ...checkpoint, + preCompaction: { ...checkpoint.preCompaction, leafId, entryId: leafId }, + postCompaction: { ...checkpoint.postCompaction, leafId, entryId: leafId }, + })), + })); +} + test("sessions.compaction.* lists checkpoints and branches or restores from compacted transcripts", async () => { const { dir, storePath } = await createSessionStoreDir(); const fixture = await createCheckpointFixture(dir, { legacyPreCompactionSnapshot: false }); expect((await fs.readdir(dir)).some((file) => file.includes(".checkpoint."))).toBe(false); - const checkpointEntryCount = fixture.session.getEntries().length; const checkpointCreatedAt = Date.now(); const checkpointEntry = compactionCheckpointEntry(fixture, { checkpointId: "checkpoint-1", @@ -229,11 +248,26 @@ test("sessions.compaction.* lists checkpoints and branches or restores from comp storePath, totalLines: 2, }); - fixture.session.appendMessage({ + await alignCheckpointBoundaryWithSqliteRows({ + sessionId: fixture.sessionId, + sessionKey: "agent:main:main", + storePath, + }); + const futureMessage = { role: "user", content: "future turn after checkpoint", timestamp: Date.now(), - }); + } as const; + fixture.session.appendMessage(futureMessage); + await appendTranscriptMessage( + { + agentId: "main", + sessionId: fixture.sessionId, + sessionKey: "agent:main:main", + storePath, + }, + { message: futureMessage, now: futureMessage.timestamp }, + ); const { ws } = await openClient(); @@ -270,7 +304,12 @@ test("sessions.compaction.* lists checkpoints and branches or restores from comp expect(listedCheckpoints.ok).toBe(true); expect(listedCheckpoints.payload?.key).toBe("agent:main:main"); expect(listedCheckpoints.payload?.checkpoints).toHaveLength(1); - expect(listedCheckpoints.payload?.checkpoints[0]).toEqual(checkpointEntry); + expect(listedCheckpoints.payload?.checkpoints[0]).toMatchObject({ + checkpointId: checkpointEntry.checkpointId, + reason: checkpointEntry.reason, + sessionId: checkpointEntry.sessionId, + sessionKey: checkpointEntry.sessionKey, + }); const checkpoint = await rpcReq<{ ok: true; @@ -321,24 +360,19 @@ test("sessions.compaction.* lists checkpoints and branches or restores from comp } finally { sessionManagerOpenSpy.mockRestore(); } - expect(branched.ok).toBe(true); + expect(branched.ok, JSON.stringify(branched)).toBe(true); expect(branched.payload?.sourceKey).toBe("agent:main:main"); expect(branched.payload?.entry.parentSessionKey).toBe("agent:main:main"); - expect(branched.payload?.entry.totalTokens).toBe(45); + expect(branched.payload?.entry.totalTokens).toBe(123); expect(branched.payload?.entry.totalTokensFresh).toBe(true); - const branchedSessionFile = branched.payload?.entry.sessionFile; - if (!branchedSessionFile) { - throw new Error("expected branched compaction session file"); - } - const branchedSession = SessionManager.open(branchedSessionFile, dir); - expect(branchedSession.getEntries()).toHaveLength(checkpointEntryCount); - expect( - branchedSession - .buildSessionContext() - .messages.some( - (message) => (message as { content?: unknown }).content === "future turn after checkpoint", - ), - ).toBe(false); + expect(branched.payload?.entry).not.toHaveProperty("sessionFile"); + const branchedRows = await loadTranscriptRows({ + sessionId: branched.payload!.entry.sessionId, + sessionKey: branched.payload!.key, + storePath, + }); + expect(branchedRows.length).toBeGreaterThan(0); + expect(JSON.stringify(branchedRows)).not.toContain("future turn after checkpoint"); const branchedEntry = loadSessionEntry({ sessionKey: branched.payload!.key, @@ -388,21 +422,16 @@ test("sessions.compaction.* lists checkpoints and branches or restores from comp expect(restored.payload?.key).toBe("agent:main:main"); expect(restored.payload?.sessionId).not.toBe(fixture.sessionId); expect(restored.payload?.entry.compactionCheckpoints).toHaveLength(1); - expect(restored.payload?.entry.totalTokens).toBe(45); + expect(restored.payload?.entry.totalTokens).toBe(123); expect(restored.payload?.entry.totalTokensFresh).toBe(true); - const restoredSessionFile = restored.payload?.entry.sessionFile; - if (!restoredSessionFile) { - throw new Error("expected restored compaction session file"); - } - const restoredSession = SessionManager.open(restoredSessionFile, dir); - expect(restoredSession.getEntries()).toHaveLength(checkpointEntryCount); - expect( - restoredSession - .buildSessionContext() - .messages.some( - (message) => (message as { content?: unknown }).content === "future turn after checkpoint", - ), - ).toBe(false); + expect(restored.payload?.entry).not.toHaveProperty("sessionFile"); + const restoredRows = await loadTranscriptRows({ + sessionId: restored.payload!.entry.sessionId, + sessionKey: "agent:main:main", + storePath, + }); + expect(restoredRows.length).toBeGreaterThan(0); + expect(JSON.stringify(restoredRows)).not.toContain("future turn after checkpoint"); const restoredEntry = loadSessionEntry({ sessionKey: "agent:main:main", storePath }); expect(restoredEntry?.sessionId).toBe(restored.payload?.sessionId); @@ -726,7 +755,7 @@ test("sessions.compact without maxLines runs embedded manual compaction for chec if (!compactionCall.sessionFile) { throw new Error("expected embedded compaction session file"); } - expect(compactionCall.sessionFile).toContain(`sqlite:main:sess-main:${storePath}`); + expect(compactionCall.sessionFile).toBe("agent:main:main"); expect(compactionCall.sessionTarget).toEqual({ agentId: "main", sessionId: "sess-main", @@ -1152,6 +1181,11 @@ test("sessions.compaction.restore waits for terminal compaction before replacing storePath, totalLines: 3, }); + await alignCheckpointBoundaryWithSqliteRows({ + sessionId: fixture.sessionId, + sessionKey: "agent:main:main", + storePath, + }); const compaction = createDeferred<{ ok: true; compacted: true; @@ -1193,7 +1227,7 @@ test("sessions.compaction.restore waits for terminal compaction before replacing }); expect((await compactResult).ok).toBe(true); const restored = await restoreResult; - expect(restored.ok).toBe(true); + expect(restored.ok, JSON.stringify(restored)).toBe(true); expect(restored.payload?.sessionId).toBeTruthy(); expect(restored.payload?.sessionId).not.toBe(fixture.sessionId); ws.close(); diff --git a/src/gateway/server.sessions.create.test.ts b/src/gateway/server.sessions.create.test.ts index d29265d65a17..732b81d34bae 100644 --- a/src/gateway/server.sessions.create.test.ts +++ b/src/gateway/server.sessions.create.test.ts @@ -22,7 +22,6 @@ import { upsertSessionEntry, } from "../config/sessions/session-accessor.js"; import { resolveSqliteTargetFromSessionStorePath } from "../config/sessions/session-sqlite-target.js"; -import { parseSqliteSessionFileMarker } from "../config/sessions/sqlite-marker.js"; import type { OpenClawConfig } from "../config/types.openclaw.js"; import { createEmptyPluginRegistry } from "../plugins/registry-empty.js"; import { setActivePluginRegistry } from "../plugins/runtime.js"; @@ -148,9 +147,7 @@ test("sessions.create keeps incognito rows process-local through list, spawn, re const entry = created.payload?.entry; expect(entry?.incognito).toBe(true); expect(entry?.parentSessionKey).toBeUndefined(); - expect(parseSqliteSessionFileMarker(entry?.sessionFile)?.storePath).toBe( - resolveIncognitoOpenClawAgentSqlitePath({ agentId: "main" }), - ); + expect(entry).not.toHaveProperty("sessionFile"); const openedIncognitoDatabase = openOpenClawAgentDatabase({ agentId: "main", path: resolveIncognitoOpenClawAgentSqlitePath({ agentId: "main" }), @@ -217,9 +214,7 @@ test("sessions.create keeps incognito rows process-local through list, spawn, re const childKey = requireNonEmptyString(child.payload?.key, "incognito child key"); expect(child.payload?.entry.incognito).toBe(true); expect(child.payload?.entry.parentSessionKey).toBe(key); - expect(parseSqliteSessionFileMarker(child.payload?.entry.sessionFile)?.storePath).toBe( - resolveIncognitoOpenClawAgentSqlitePath({ agentId: "main" }), - ); + expect(child.payload?.entry).not.toHaveProperty("sessionFile"); const rejectedInheritedChannel = await directSessionReq("sessions.create", { agentId: "main", @@ -1388,10 +1383,7 @@ test("sessions.create stores dashboard model, thinking, and parent linkage, and expect(created.payload?.entry?.modelOverride).toBe("gpt-test-a"); expect(created.payload?.entry?.thinkingLevel).toBe("high"); expect(created.payload?.entry?.parentSessionKey).toBe("agent:main:main"); - const sessionFile = requireNonEmptyString( - created.payload?.entry?.sessionFile, - "created session file", - ); + expect(created.payload?.entry).not.toHaveProperty("sessionFile"); expect(created.payload?.sessionId).toMatch( /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/, ); @@ -1404,7 +1396,7 @@ test("sessions.create stores dashboard model, thinking, and parent linkage, and expect(storedEntry?.modelOverride).toBe("gpt-test-a"); expect(storedEntry?.thinkingLevel).toBe("high"); expect(storedEntry?.parentSessionKey).toBe("agent:main:main"); - expect(sessionFile).toBe(storedEntry?.sessionFile); + expect(storedEntry).not.toHaveProperty("sessionFile"); await expect( loadTranscriptEvents({ @@ -2311,7 +2303,7 @@ test("sessions.create scopes the main alias to the requested agent", async () => expect(created.ok).toBe(true); expect(created.payload?.key).toBe("agent:longmemeval:main"); - requireNonEmptyString(created.payload?.entry?.sessionFile, "longmemeval session file"); + expect(created.payload?.entry).not.toHaveProperty("sessionFile"); expect( loadSessionEntry({ @@ -2388,7 +2380,7 @@ test("sessions.create preserves global and unknown sentinel keys", async () => { expect(globalCreated.ok).toBe(true); expect(globalCreated.payload?.key).toBe("global"); - requireNonEmptyString(globalCreated.payload?.entry?.sessionFile, "global session file"); + expect(globalCreated.payload?.entry).not.toHaveProperty("sessionFile"); const unknownCreated = await directSessionReq<{ key?: string; @@ -2403,7 +2395,7 @@ test("sessions.create preserves global and unknown sentinel keys", async () => { expect(unknownCreated.ok).toBe(true); expect(unknownCreated.payload?.key).toBe("unknown"); - requireNonEmptyString(unknownCreated.payload?.entry?.sessionFile, "unknown session file"); + expect(unknownCreated.payload?.entry).not.toHaveProperty("sessionFile"); expect( loadSessionEntry({ agentId: "longmemeval", sessionKey: "global", storePath })?.sessionId, @@ -2451,7 +2443,7 @@ test("sessions.create stores selected global sessions in the requested agent sto expect(created.ok).toBe(true); expect(created.payload?.key).toBe("global"); - requireNonEmptyString(created.payload?.entry?.sessionFile, "work global session file"); + expect(created.payload?.entry).not.toHaveProperty("sessionFile"); expect( loadSessionEntry({ agentId: "main", sessionKey: "global", storePath: mainStorePath }), ).toBeUndefined(); @@ -2822,10 +2814,7 @@ test("sessions.create forks the parent transcript into the new session", async ( expect(created.payload?.entry?.totalTokens).toBeUndefined(); expect(created.payload?.entry?.totalTokensFresh).toBe(false); expect(created.payload?.sessionId).not.toBe(parent.sessionId); - const forkedSessionFile = requireNonEmptyString( - created.payload?.entry?.sessionFile, - "forked session file", - ); + expect(created.payload?.entry).not.toHaveProperty("sessionFile"); const readMessages = async (scope: { sessionFile?: string; sessionId: string; @@ -2846,7 +2835,6 @@ test("sessions.create forks the parent transcript into the new session", async ( const forkedSessionId = requireNonEmptyString(created.payload?.sessionId, "forked session id"); expect( await readMessages({ - sessionFile: forkedSessionFile, sessionId: forkedSessionId, sessionKey: created.payload?.key ?? "", storePath, @@ -2862,7 +2850,6 @@ test("sessions.create forks the parent transcript into the new session", async ( const key = requireNonEmptyString(created.payload?.key, "forked session key"); expect(loadSessionEntry({ sessionKey: key, storePath })).toMatchObject({ sessionId: created.payload?.sessionId, - sessionFile: forkedSessionFile, forkSource: { sessionKey: "agent:main:main", sessionId: parent.sessionId, @@ -3114,13 +3101,9 @@ test("sessions.create resolves an agent-qualified fork from the parent store", a sessionId: parent.sessionId, }); expect(created.payload?.entry?.forkedFromParent).toBe(true); - const forkedSessionFile = requireNonEmptyString( - created.payload?.entry?.sessionFile, - "agent-qualified forked session file", - ); + expect(created.payload?.entry).not.toHaveProperty("sessionFile"); await expect( loadTranscriptEvents({ - sessionFile: forkedSessionFile, sessionId: requireNonEmptyString( created.payload?.sessionId, "agent-qualified forked session id", diff --git a/src/gateway/server.sessions.reset-cleanup.test.ts b/src/gateway/server.sessions.reset-cleanup.test.ts index 14518645463a..c951a2ad1f4e 100644 --- a/src/gateway/server.sessions.reset-cleanup.test.ts +++ b/src/gateway/server.sessions.reset-cleanup.test.ts @@ -348,7 +348,7 @@ test("sessions.reset forwards the retired generation to registered agent harness agentId: "main", sessionId: "sess-main", sessionKey: "agent:main:main", - sessionFile: expect.stringMatching(/^sqlite:main:sess-main:/), + sessionFile: "agent:main:main", reason: "reset", }); } finally { diff --git a/src/gateway/server.sessions.reset-hooks.test.ts b/src/gateway/server.sessions.reset-hooks.test.ts index c11172196fd8..2f3506271d44 100644 --- a/src/gateway/server.sessions.reset-hooks.test.ts +++ b/src/gateway/server.sessions.reset-hooks.test.ts @@ -3,6 +3,7 @@ import fs from "node:fs/promises"; import path from "node:path"; import { expect, test, vi } from "vitest"; +import { formatSqliteSessionFileMarker } from "../config/sessions/legacy-sqlite-marker.js"; import { listSessionEntries, loadSessionEntry } from "../config/sessions/session-accessor.js"; import type { InternalSessionEntry } from "../config/sessions/types.js"; import { beginSessionWorkAdmission } from "../sessions/session-lifecycle-admission.js"; @@ -185,14 +186,11 @@ async function writeMainTranscriptSession(params: { sessionKey: "agent:main:main", storePath, }); - return expectStringValue( - loadSessionEntry({ - agentId: "main", - sessionKey: "agent:main:main", - storePath, - })?.sessionFile, - "sessionFile", - ); + return formatSqliteSessionFileMarker({ + agentId: "main", + sessionId: params.sessionId, + storePath, + }); } function loadEntry(params: { agentId?: string; sessionKey: string; storePath: string }) { @@ -640,14 +638,11 @@ test("sessions.reset emits before_reset for the entry actually reset in the writ content: "new transcript", messageId: "m-new", }); - const newSessionFile = expectStringValue( - loadEntry({ - agentId: "main", - sessionKey: "agent:main:main", - storePath, - })?.sessionFile, - "new sessionFile", - ); + const newSessionFile = formatSqliteSessionFileMarker({ + agentId: "main", + sessionId: "sess-new", + storePath, + }); const reset = await performSessionReset({ key: "main", diff --git a/src/gateway/server.sessions.reset-models.test.ts b/src/gateway/server.sessions.reset-models.test.ts index 4af56a086b51..e7e2ad56d1e9 100644 --- a/src/gateway/server.sessions.reset-models.test.ts +++ b/src/gateway/server.sessions.reset-models.test.ts @@ -4,8 +4,8 @@ import fs from "node:fs/promises"; import path from "node:path"; import { expect, test } from "vitest"; +import { formatSqliteSessionFileMarker } from "../config/sessions/legacy-sqlite-marker.js"; import { loadSessionEntry, replaceSessionEntry } from "../config/sessions/session-accessor.js"; -import { formatSqliteSessionFileMarker } from "../config/sessions/sqlite-marker.js"; import type { SessionEntry } from "../config/sessions/types.js"; import { MODEL_SELECTION_LOCKED_RESET_MESSAGE } from "../sessions/model-overrides.js"; import { listSessionStateEventsSince } from "../sessions/session-state-events.js"; @@ -174,7 +174,7 @@ const ownedChildMetadata = { } satisfies SessionEntryOverrides & ResetSessionEntry; function expectSqliteSessionFile(entry: ResetSessionEntry | undefined) { - expect(entry?.sessionFile).toContain(`sqlite:main:${entry?.sessionId}:`); + expect(entry).not.toHaveProperty("sessionFile"); } function expectOwnedChildMetadata(entry: ResetSessionEntry | undefined) { @@ -291,10 +291,7 @@ test("sessions.reset recomputes model from defaults instead of stale runtime mod expect(reset.ok).toBe(true); expect(reset.payload?.key).toBe("agent:main:main"); expect(reset.payload?.entry.sessionId).toBe("sess-stale-model"); - const sessionFile = reset.payload?.entry.sessionFile; - if (!sessionFile) { - throw new Error("expected reset session file"); - } + expect(reset.payload?.entry).not.toHaveProperty("sessionFile"); expect(reset.payload?.resolved).toEqual({ modelProvider: "openai", model: "gpt-test-a", @@ -302,7 +299,6 @@ test("sessions.reset recomputes model from defaults instead of stale runtime mod expect(reset.payload?.entry.modelProvider).toBe("openai"); expect(reset.payload?.entry.model).toBe("gpt-test-a"); expect(reset.payload?.entry.contextTokens).toBeUndefined(); - expect(sessionFile).toContain(`sqlite:main:${reset.payload?.entry.sessionId}:`); }); test("sessions.reset clears stale estimated context budget status", async () => { @@ -403,7 +399,7 @@ test("sessions.reset drops cached skills snapshot so /new rebuilds visible skill expect(stored?.skillsSnapshot).toBeUndefined(); }); -test("sessions.reset rotates generated topic transcript files with the new session id", async () => { +test("sessions.reset drops a generated topic transcript locator", async () => { const { dir, storePath } = await createSessionStoreDir(); const previousSessionId = "11111111-1111-4111-8111-111111111111"; const previousSessionFile = path.join(dir, `${previousSessionId}-topic-456.jsonl`); @@ -430,22 +426,21 @@ test("sessions.reset rotates generated topic transcript files with the new sessi expect(reset.ok).toBe(true); const nextSessionId = reset.payload?.entry.sessionId; - const nextSessionFile = reset.payload?.entry.sessionFile; - if (!nextSessionId || !nextSessionFile) { - throw new Error("expected reset session id and file"); + if (!nextSessionId) { + throw new Error("expected reset session id"); } expect(nextSessionId).toBe(previousSessionId); - expect(nextSessionFile).toContain(`sqlite:main:${nextSessionId}:`); + expect(reset.payload?.entry).not.toHaveProperty("sessionFile"); const persistedEntry = loadSessionEntry({ sessionKey: "agent:main:telegram:group:123:topic:456", storePath, }); expect(persistedEntry?.sessionId).toBe(nextSessionId); - expect(persistedEntry?.sessionFile).toBe(nextSessionFile); + expect(persistedEntry).not.toHaveProperty("sessionFile"); }); -test("sessions.reset rotates an already-stale generated transcript file to the new session id", async () => { +test("sessions.reset drops an already-stale generated transcript locator", async () => { const { dir, storePath } = await createSessionStoreDir(); // Post-upgrade state: the stored sessionFile still embeds an OLDER generated id // that no longer matches the entry's logical sessionId, so rotation must key off @@ -474,20 +469,18 @@ test("sessions.reset rotates an already-stale generated transcript file to the n expect(reset.ok).toBe(true); const nextSessionId = reset.payload?.entry.sessionId; - const nextSessionFile = reset.payload?.entry.sessionFile; - if (!nextSessionId || !nextSessionFile) { - throw new Error("expected reset session id and file"); + if (!nextSessionId) { + throw new Error("expected reset session id"); } expect(nextSessionId).toBe(currentSessionId); - expect(nextSessionFile).toContain(`sqlite:main:${nextSessionId}:`); - expect(nextSessionFile).not.toContain(staleFileSessionId); + expect(reset.payload?.entry).not.toHaveProperty("sessionFile"); const persistedEntry = loadSessionEntry({ sessionKey: "agent:main:main", storePath }); expect(persistedEntry?.sessionId).toBe(nextSessionId); - expect(persistedEntry?.sessionFile).toBe(nextSessionFile); + expect(persistedEntry).not.toHaveProperty("sessionFile"); }); -test("sessions.reset replaces a SQLite marker for a different transcript target", async () => { +test("sessions.reset drops a stale SQLite marker", async () => { const { storePath } = await createSessionStoreDir(); const sessionId = "current-session"; const sessionKey = "agent:main:main"; @@ -521,10 +514,7 @@ test("sessions.reset replaces a SQLite marker for a different transcript target" expect(reset.ok).toBe(true); expect(reset.payload?.entry.sessionId).toBe(sessionId); - expect(reset.payload?.entry.sessionFile).toBe( - formatSqliteSessionFileMarker({ agentId: "main", sessionId, storePath }), - ); - expect(reset.payload?.entry.sessionFile).not.toBe(staleMarker); + expect(reset.payload?.entry).not.toHaveProperty("sessionFile"); }); test("sessions.reset preserves legacy explicit model overrides without modelOverrideSource", async () => { diff --git a/src/gateway/session-compaction-checkpoint-entry.ts b/src/gateway/session-compaction-checkpoint-entry.ts deleted file mode 100644 index a45cdc5ecd3a..000000000000 --- a/src/gateway/session-compaction-checkpoint-entry.ts +++ /dev/null @@ -1,22 +0,0 @@ -import { buildMainSessionRecoveryClearPatch } from "../agents/main-session-recovery-clear.js"; -import type { SessionEntry } from "../config/sessions.js"; - -export function buildCheckpointSessionResetPatch(params: { - entry: SessionEntry; - sessionId: string; - sessionFile: string; -}): Partial { - // A checkpoint clone has a new transcript identity, so prior recovery ownership cannot follow it. - return { - ...buildMainSessionRecoveryClearPatch(params.entry), - sessionId: params.sessionId, - sessionFile: params.sessionFile, - updatedAt: Date.now(), - systemSent: false, - abortedLastRun: false, - startedAt: undefined, - endedAt: undefined, - runtimeMs: undefined, - status: undefined, - }; -} diff --git a/src/gateway/session-compaction-checkpoints.test.ts b/src/gateway/session-compaction-checkpoints.test.ts index 03e803291839..b8539de6a340 100644 --- a/src/gateway/session-compaction-checkpoints.test.ts +++ b/src/gateway/session-compaction-checkpoints.test.ts @@ -8,15 +8,14 @@ import path from "node:path"; import { CURRENT_SESSION_VERSION, SessionManager } from "openclaw/plugin-sdk/agent-sessions"; import type { AssistantMessage } from "openclaw/plugin-sdk/llm"; import { afterEach, describe, expect, test } from "vitest"; -import type { SessionCompactionCheckpoint, SessionEntry } from "../config/sessions.js"; +import type { SessionCompactionCheckpoint } from "../config/sessions.js"; +import { formatSqliteSessionFileMarker } from "../config/sessions/legacy-sqlite-marker.js"; import { appendTranscriptEvent, appendTranscriptMessage, - loadSessionEntry, loadTranscriptEvents, upsertSessionEntry, } from "../config/sessions/session-accessor.js"; -import { formatSqliteSessionFileMarker } from "../config/sessions/sqlite-marker.js"; import { createFileBackedCompactionCheckpointStore, readSessionLeafStateFromTranscriptAsync, @@ -46,14 +45,6 @@ function isAssistantTextEvent(event: unknown, text: string): boolean { return candidate.role === "assistant" && candidate.content === text; } -async function writeAccessorSessionEntry( - storePath: string, - sessionKey: string, - entry: Partial, -): Promise { - await upsertSessionEntry({ storePath, sessionKey }, entry); -} - afterEach(async () => { await Promise.all(tempDirs.splice(0).map((dir) => fs.rm(dir, { recursive: true, force: true }))); }); @@ -74,16 +65,17 @@ describe("session-compaction-checkpoints", () => { }); }); - test("checkpoint store branches and restores SQLite marker checkpoints from rows", async () => { + test("checkpoint store branches and restores checkpoints through resolved store keys", async () => { const dir = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-checkpoint-sqlite-branch-")); tempDirs.push(dir); const storePath = path.join(dir, "openclaw-agent.sqlite"); const sessionId = "sqlite-checkpoint-branch-source"; const sessionKey = MAIN_SESSION_KEY; + const sessionStoreKey = "agent:main:legacy-main"; const scope = { agentId: MAIN_AGENT_ID, sessionId, - sessionKey, + sessionKey: sessionStoreKey, storePath, }; const marker = formatSqliteSessionFileMarker({ @@ -117,7 +109,7 @@ describe("session-compaction-checkpoints", () => { now: Date.parse("2026-06-26T12:00:02.000Z"), }); const sourceLeafId = requireNonEmptyString( - SessionManager.open(marker).getLeafId(), + SessionManager.open(scope).getLeafId(), "SQLite source leaf id missing", ); const checkpoint: SessionCompactionCheckpoint = { @@ -151,20 +143,22 @@ describe("session-compaction-checkpoints", () => { const branched = await store.branchCheckpointSession({ storePath, sourceKey: sessionKey, + sourceStoreKey: sessionStoreKey, nextKey: branchKey, checkpointId: checkpoint.checkpointId, }); const restored = await store.restoreCheckpointSession({ storePath, sessionKey, + sessionStoreKey, checkpointId: checkpoint.checkpointId, }); if (branched.status !== "created" || restored.status !== "created") { throw new Error("expected SQLite checkpoint branch and restore"); } - expect(branched.entry.sessionFile).toContain("sqlite:main:"); - expect(restored.entry.sessionFile).toContain("sqlite:main:"); + expect(branched.entry).not.toHaveProperty("sessionFile"); + expect(restored.entry).not.toHaveProperty("sessionFile"); expect(fsSync.readdirSync(dir).some((file) => file.endsWith(".jsonl"))).toBe(false); const branchEvents = await loadTranscriptEvents({ @@ -223,7 +217,7 @@ describe("session-compaction-checkpoints", () => { now: Date.parse("2026-06-26T12:00:01.000Z"), }); const leafBeforeEntryId = requireNonEmptyString( - SessionManager.open(marker).getLeafId(), + SessionManager.open(scope).getLeafId(), "SQLite stale-entry pre-entry leaf id missing", ); await appendTranscriptMessage(scope, { @@ -235,7 +229,7 @@ describe("session-compaction-checkpoints", () => { now: Date.parse("2026-06-26T12:00:02.000Z"), }); const sourceEntryId = requireNonEmptyString( - SessionManager.open(marker).getLeafId(), + SessionManager.open(scope).getLeafId(), "SQLite stale-entry entry id missing", ); const checkpoint: SessionCompactionCheckpoint = { @@ -289,7 +283,6 @@ describe("session-compaction-checkpoints", () => { if (branched.status !== "created") { throw new Error("expected stale-entry SQLite checkpoint branch"); } - expect(branched.entry.sessionFile).toContain("sqlite:main:"); expect(fsSync.existsSync(staleSessionFile)).toBe(false); expect(fsSync.readdirSync(dir).some((file) => file.endsWith(".jsonl"))).toBe(false); const branchEvents = await loadTranscriptEvents({ @@ -312,10 +305,9 @@ describe("session-compaction-checkpoints", () => { if (markerBranched.status !== "created") { throw new Error("expected stale-entry SQLite marker checkpoint branch"); } - expect(markerBranched.entry.sessionFile).toContain("sqlite:main:"); }); - test("checkpoint store does not fork retired legacy snapshots for SQLite marker entries", async () => { + test("imports a retired legacy checkpoint snapshot into a new SQLite branch", async () => { const dir = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-checkpoint-sqlite-legacy-")); tempDirs.push(dir); const storePath = path.join(dir, "openclaw-agent.sqlite"); @@ -377,8 +369,6 @@ describe("session-compaction-checkpoints", () => { }, ); - const beforeFiles = fsSync.readdirSync(dir).toSorted(); - const branched = await createFileBackedCompactionCheckpointStore().branchCheckpointSession({ storePath, sourceKey: sessionKey, @@ -386,8 +376,21 @@ describe("session-compaction-checkpoints", () => { checkpointId: "legacy-file-checkpoint", }); - expect(branched.status).toBe("missing-boundary"); - expect(fsSync.readdirSync(dir).toSorted()).toEqual(beforeFiles); + if (branched.status !== "created") { + throw new Error("expected legacy checkpoint snapshot import"); + } + expect(fsSync.readdirSync(dir).filter((file) => file.endsWith(".jsonl"))).toEqual([ + path.basename(legacySnapshotFile), + ]); + const branchEvents = await loadTranscriptEvents({ + agentId: MAIN_AGENT_ID, + sessionId: branched.entry.sessionId, + sessionKey: "agent:main:legacy-checkpoint-branch", + storePath, + }); + expect( + branchEvents.some((event) => isAssistantTextEvent(event, "legacy checkpoint source")), + ).toBe(true); }); test("leaf state follows terminal controls while retaining the append cursor", async () => { @@ -489,163 +492,35 @@ describe("session-compaction-checkpoints", () => { }); }); - test("file-backed checkpoint store branches active state and restores source management state", async () => { - const dir = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-checkpoint-store-")); + test("reads leaf state from a structured SQLite transcript target", async () => { + const dir = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-checkpoint-target-leaf-")); tempDirs.push(dir); - - const session = SessionManager.create(dir, dir); - session.appendMessage({ - role: "user", - content: "checkpoint source", - timestamp: Date.now(), + const target = { + agentId: "main", + sessionId: "structured-leaf-session", + sessionKey: "agent:main:structured-leaf-session", + storePath: path.join(dir, "sessions.json"), + }; + await upsertSessionEntry(target, { sessionId: target.sessionId, updatedAt: 1 }); + await appendTranscriptEvent(target, { + type: "session", + version: CURRENT_SESSION_VERSION, + id: target.sessionId, + timestamp: "2026-06-15T00:00:00.000Z", + cwd: dir, }); - const checkpointLeafId = requireNonEmptyString( - session.getLeafId(), - "checkpoint leaf id missing", - ); - session.appendMessage({ - role: "assistant", - content: "future turn", - api: "responses", - provider: "openai", - model: "gpt-test", - timestamp: Date.now(), - } as unknown as AssistantMessage); - - const sessionFile = requireNonEmptyString(session.getSessionFile(), "session file missing"); - const storePath = path.join(dir, "sessions.json"); - const managedAt = Date.now() - 2; - await writeAccessorSessionEntry(storePath, MAIN_SESSION_KEY, { - sessionId: "current-session", - sessionFile, - updatedAt: Date.now() - 1, - archivedAt: managedAt, - pinnedAt: managedAt, - icon: "name:spark", - totalTokens: 200, - compactionCheckpoints: [ - { - checkpointId: "checkpoint-1", - sessionKey: MAIN_SESSION_KEY, - sessionId: "stored-session", - createdAt: Date.now(), - reason: "manual", - tokensAfter: 45, - preCompaction: { sessionId: "pre-session", leafId: "pre-leaf" }, - postCompaction: { - sessionId: "post-session", - sessionFile, - leafId: checkpointLeafId, - }, - }, - ], - }); - const store = createFileBackedCompactionCheckpointStore(); - const branched = await store.branchCheckpointSession({ - storePath, - sourceKey: MAIN_SESSION_KEY, - nextKey: "agent:main:dashboard:checkpoint-branch", - checkpointId: "checkpoint-1", + const appended = await appendTranscriptMessage(target, { + message: { + role: "assistant", + content: "active", + timestamp: 1, + } as unknown as AssistantMessage, + now: Date.parse("2026-06-15T00:00:01.000Z"), }); - if (branched.status !== "created") { - throw new Error("expected branched checkpoint transcript"); - } - expect(branched.entry.archivedAt).toBeUndefined(); - expect(branched.entry.pinnedAt).toBeUndefined(); - expect(branched.entry.icon).toBeUndefined(); - - const restored = await store.restoreCheckpointSession({ - storePath, - sessionKey: MAIN_SESSION_KEY, - checkpointId: "checkpoint-1", + await expect(readSessionLeafStateFromTranscriptAsync(target)).resolves.toEqual({ + entryId: appended.messageId, + leafId: appended.messageId, }); - - if (restored.status !== "created") { - throw new Error("expected restored checkpoint transcript"); - } - expect(restored.entry.totalTokens).toBe(45); - expect(restored.entry.archivedAt).toBe(managedAt); - expect(restored.entry.pinnedAt).toBe(managedAt); - expect(restored.entry.icon).toBe("name:spark"); - const restoredSessionFile = requireNonEmptyString( - restored.entry.sessionFile, - "restored session file missing", - ); - const messages = SessionManager.open(restoredSessionFile, dir).buildSessionContext().messages; - expect(messages.map((message) => (message as { content?: unknown }).content)).toEqual([ - "checkpoint source", - ]); - const nextEntry = loadSessionEntry({ storePath, sessionKey: MAIN_SESSION_KEY }); - expect(nextEntry?.sessionFile).toBe(restored.entry.sessionFile); - expect(nextEntry?.totalTokens).toBe(45); - }); - - test("file-backed checkpoint store rejects identity changes for model-selection-locked sessions", async () => { - const dir = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-checkpoint-locked-")); - tempDirs.push(dir); - - const session = SessionManager.create(dir, dir); - session.appendMessage({ - role: "user", - content: "locked checkpoint source", - timestamp: Date.now(), - }); - const checkpointLeafId = requireNonEmptyString( - session.getLeafId(), - "checkpoint leaf id missing", - ); - const sessionFile = requireNonEmptyString(session.getSessionFile(), "session file missing"); - const storePath = path.join(dir, "sessions.json"); - await upsertSessionEntry( - { storePath, sessionKey: MAIN_SESSION_KEY }, - { - sessionId: "locked-session", - sessionFile, - updatedAt: Date.now(), - modelSelectionLocked: true, - compactionCheckpoints: [ - { - checkpointId: "checkpoint-locked", - sessionKey: MAIN_SESSION_KEY, - sessionId: "locked-session", - createdAt: Date.now(), - reason: "manual", - preCompaction: { sessionId: "locked-session", leafId: checkpointLeafId }, - postCompaction: { - sessionId: "locked-session", - sessionFile, - leafId: checkpointLeafId, - }, - }, - ], - }, - ); - const filesBefore = (await fs.readdir(dir)).toSorted(); - const store = createFileBackedCompactionCheckpointStore(); - - await expect( - store.branchCheckpointSession({ - storePath, - sourceKey: MAIN_SESSION_KEY, - nextKey: "agent:main:dashboard:locked-checkpoint-branch", - checkpointId: "checkpoint-locked", - }), - ).resolves.toEqual({ status: "model-selection-locked" }); - await expect( - store.restoreCheckpointSession({ - storePath, - sessionKey: MAIN_SESSION_KEY, - checkpointId: "checkpoint-locked", - }), - ).resolves.toEqual({ status: "model-selection-locked" }); - - expect((await fs.readdir(dir)).toSorted()).toEqual(filesBefore); - expect(loadSessionEntry({ storePath, sessionKey: MAIN_SESSION_KEY })).toEqual( - expect.objectContaining({ - modelSelectionLocked: true, - sessionId: "locked-session", - }), - ); }); }); diff --git a/src/gateway/session-compaction-checkpoints.ts b/src/gateway/session-compaction-checkpoints.ts index aaba18777d25..56f6b54e41c3 100644 --- a/src/gateway/session-compaction-checkpoints.ts +++ b/src/gateway/session-compaction-checkpoints.ts @@ -15,11 +15,10 @@ import type { } from "../config/sessions.js"; import { isCompactionCheckpointTranscriptFileName } from "../config/sessions/artifacts.js"; import { readFileRangeAsync } from "../config/sessions/file-range.js"; +import { parseSqliteSessionFileMarker } from "../config/sessions/legacy-sqlite-marker.js"; import { - branchSessionFromCompactionCheckpoint, loadSessionEntry, loadTranscriptEventsSync, - restoreSessionFromCompactionCheckpoint, type SessionCompactionCheckpointMutationResult, updateSessionEntry, } from "../config/sessions/session-accessor.js"; @@ -27,13 +26,11 @@ import { branchSqliteCompactionCheckpointSession, restoreSqliteCompactionCheckpointSession, } from "../config/sessions/session-accessor.sqlite.js"; -import { parseSqliteSessionFileMarker } from "../config/sessions/sqlite-marker.js"; +import type { SessionTranscriptRuntimeTarget } from "../config/sessions/session-accessor.types.js"; import { streamSessionTranscriptLines } from "../config/sessions/transcript-stream.js"; import { scanSessionTranscriptTree } from "../config/sessions/transcript-tree.js"; -import { CURRENT_SESSION_VERSION } from "../config/sessions/version.js"; import type { OpenClawConfig } from "../config/types.openclaw.js"; import { createSubsystemLogger } from "../logging/subsystem.js"; -import { buildCheckpointSessionResetPatch } from "./session-compaction-checkpoint-entry.js"; import { resolveGatewaySessionStoreTarget } from "./session-utils.js"; const log = createSubsystemLogger("gateway/session-compaction-checkpoints"); @@ -68,20 +65,6 @@ export function resolveCompactionCheckpointTranscriptPosition(params: { }; } -type ForkedCompactionCheckpointTranscript = { - sessionId: string; - sessionFile: string; -}; - -type CompactionCheckpointForkedTranscript = ForkedCompactionCheckpointTranscript & { - totalTokens?: number; -}; - -type CompactionCheckpointTranscriptForkResult = - | { status: "created"; transcript: CompactionCheckpointForkedTranscript } - | { status: "missing-boundary" } - | { status: "failed" }; - type CompactionCheckpointSessionMutationResult = SessionCompactionCheckpointMutationResult; type BranchCheckpointSessionParams = { @@ -357,9 +340,16 @@ function trimTranscriptEntriesThroughLeaf( } export async function readSessionLeafStateFromTranscriptAsync( - sessionFile: string, + sessionFile: string | SessionTranscriptRuntimeTarget, maxBytes = MAX_COMPACTION_CHECKPOINT_LEAF_SCAN_BYTES, ): Promise<{ entryId: string; leafId: string | null } | null> { + if (typeof sessionFile !== "string") { + const records = loadTranscriptEventsSync(sessionFile).filter( + (event): event is Record => + Boolean(event) && typeof event === "object" && !Array.isArray(event), + ); + return readSessionLeafStateFromRecords(records); + } const sqliteMarker = parseSqliteSessionFileMarker(sessionFile); if (sqliteMarker) { const records = loadTranscriptEventsSync(sqliteMarker).filter( @@ -457,68 +447,6 @@ function readSessionLeafStateFromRecords( return { entryId: latestEntryId, leafId: tree.leafId }; } -async function forkCompactionCheckpointTranscriptAsync(params: { - sourceFile: string; - sourceLeafId?: string; - targetCwd?: string; - sessionDir?: string; -}): Promise { - const sourceFile = params.sourceFile.trim(); - if (!sourceFile) { - return null; - } - const sourceHeader = await readSessionHeaderFromTranscriptAsync(sourceFile); - if (!sourceHeader) { - return null; - } - const entries = await readTranscriptEntriesForForkAsync({ - sessionFile: sourceFile, - stopAfterEntryId: params.sourceLeafId, - }); - if (!entries) { - return null; - } - migrateSessionEntries(entries); - const forkEntries = trimTranscriptEntriesThroughLeaf(entries, params.sourceLeafId); - if (!forkEntries) { - return null; - } - - const targetCwd = params.targetCwd ?? sourceHeader.cwd ?? process.cwd(); - const sessionDir = params.sessionDir ?? path.dirname(sourceFile); - const sessionId = randomUUID(); - const timestamp = new Date().toISOString(); - const fileTimestamp = timestamp.replace(/[:.]/g, "-"); - const sessionFile = path.join(sessionDir, `${fileTimestamp}_${sessionId}.jsonl`); - const header = { - type: "session", - version: CURRENT_SESSION_VERSION, - id: sessionId, - timestamp, - cwd: targetCwd, - parentSession: sourceFile, - }; - - try { - await fs.mkdir(sessionDir, { recursive: true }); - const lines = [JSON.stringify(header)]; - for (const entry of forkEntries) { - if ((entry as { type?: unknown }).type !== "session") { - lines.push(JSON.stringify(entry)); - } - } - await fs.writeFile(sessionFile, `${lines.join("\n")}\n`, { encoding: "utf-8", flag: "wx" }); - return { sessionId, sessionFile }; - } catch { - try { - await fs.unlink(sessionFile); - } catch { - // Best-effort cleanup for partial fork files. - } - return null; - } -} - function resolveCheckpointTranscriptForkSource( checkpoint: SessionCompactionCheckpoint, ): { sourceFile: string; sourceLeafId?: string; totalTokens?: number } | null { @@ -547,193 +475,95 @@ function resolveCheckpointTranscriptForkSource( }; } -async function forkCheckpointTranscriptFromStoredBoundary(params: { - checkpoint: SessionCompactionCheckpoint; - sessionDir?: string; - targetCwd?: string; -}): Promise { - const forkSource = resolveCheckpointTranscriptForkSource(params.checkpoint); - if (!forkSource) { - return { status: "missing-boundary" }; +async function prepareLegacyCheckpointSource( + checkpoint: SessionCompactionCheckpoint | undefined, +): Promise< + | { + checkpointId: string; + events: SessionFileEntry[]; + sessionFile: string; + sourceLeafId?: string; + totalTokens?: number; + } + | undefined +> { + if (!checkpoint) { + return undefined; } - const forked = await forkCompactionCheckpointTranscriptAsync({ - sourceFile: forkSource.sourceFile, - sourceLeafId: forkSource.sourceLeafId, - sessionDir: params.sessionDir ?? path.dirname(forkSource.sourceFile), - ...(params.targetCwd ? { targetCwd: params.targetCwd } : {}), + const forkSource = resolveCheckpointTranscriptForkSource(checkpoint); + if (!forkSource) { + return undefined; + } + const entries = await readTranscriptEntriesForForkAsync({ + sessionFile: forkSource.sourceFile, + stopAfterEntryId: forkSource.sourceLeafId, }); - if (!forked) { - return { status: "failed" }; + if (!entries) { + return undefined; + } + migrateSessionEntries(entries); + const events = trimTranscriptEntriesThroughLeaf(entries, forkSource.sourceLeafId); + if (!events) { + return undefined; } return { - status: "created", - transcript: { - ...forked, - ...(typeof forkSource.totalTokens === "number" - ? { totalTokens: forkSource.totalTokens } - : {}), - }, + checkpointId: checkpoint.checkpointId, + events, + sessionFile: forkSource.sourceFile, + ...(forkSource.sourceLeafId ? { sourceLeafId: forkSource.sourceLeafId } : {}), + ...(typeof forkSource.totalTokens === "number" ? { totalTokens: forkSource.totalTokens } : {}), }; } -function cloneCheckpointSessionEntry(params: { - currentEntry: SessionEntry; - nextSessionId: string; - nextSessionFile: string; - label?: string; - parentSessionKey?: string; - totalTokens?: number; - preserveCompactionCheckpoints?: boolean; - preserveManagementState?: boolean; -}): SessionEntry { - return { - ...params.currentEntry, - ...buildCheckpointSessionResetPatch({ - entry: params.currentEntry, - sessionId: params.nextSessionId, - sessionFile: params.nextSessionFile, - }), - inputTokens: undefined, - outputTokens: undefined, - cacheRead: undefined, - cacheWrite: undefined, - estimatedCostUsd: undefined, - totalTokens: - typeof params.totalTokens === "number" && Number.isFinite(params.totalTokens) - ? params.totalTokens - : undefined, - totalTokensFresh: - typeof params.totalTokens === "number" && Number.isFinite(params.totalTokens) - ? true - : undefined, - label: params.label ?? params.currentEntry.label, - parentSessionKey: params.parentSessionKey ?? params.currentEntry.parentSessionKey, - archivedAt: params.preserveManagementState ? params.currentEntry.archivedAt : undefined, - pinnedAt: params.preserveManagementState ? params.currentEntry.pinnedAt : undefined, - icon: params.preserveManagementState ? params.currentEntry.icon : undefined, - compactionCheckpoints: params.preserveCompactionCheckpoints - ? params.currentEntry.compactionCheckpoints - : undefined, - }; +function findCheckpoint(entry: SessionEntry | undefined, checkpointId: string) { + return entry?.compactionCheckpoints?.find( + (checkpoint) => checkpoint.checkpointId === checkpointId, + ); } async function branchCheckpointSessionFromStoredBoundary( params: BranchCheckpointSessionParams, ): Promise { - if ( - shouldRouteCheckpointSessionMutationToSqlite({ - checkpointId: params.checkpointId, - ...(params.agentId ? { agentId: params.agentId } : {}), - sessionKey: params.sourceStoreKey ?? params.sourceKey, - storePath: params.storePath, - }) - ) { - return await branchSqliteCompactionCheckpointSession({ - ...(params.agentId ? { agentId: params.agentId } : {}), - storePath: params.storePath, - sourceKey: params.sourceKey, - nextKey: params.nextKey, - checkpointId: params.checkpointId, - ...(params.sourceStoreKey ? { sourceStoreKey: params.sourceStoreKey } : {}), - }); - } - return await branchSessionFromCompactionCheckpoint({ + const entry = loadSessionEntry({ + ...(params.agentId ? { agentId: params.agentId } : {}), + storePath: params.storePath, + sessionKey: params.sourceStoreKey ?? params.sourceKey, + }); + const legacySource = await prepareLegacyCheckpointSource( + findCheckpoint(entry, params.checkpointId), + ); + return await branchSqliteCompactionCheckpointSession({ + ...(params.agentId ? { agentId: params.agentId } : {}), storePath: params.storePath, sourceKey: params.sourceKey, nextKey: params.nextKey, checkpointId: params.checkpointId, ...(params.sourceStoreKey ? { sourceStoreKey: params.sourceStoreKey } : {}), - forkTranscriptFromCheckpoint: async (checkpoint) => - await forkCheckpointTranscriptFromStoredBoundary({ checkpoint }), - buildEntry: ({ currentEntry, forkedTranscript }) => { - const label = currentEntry.label?.trim() - ? `${currentEntry.label.trim()} (checkpoint)` - : "Checkpoint branch"; - return cloneCheckpointSessionEntry({ - currentEntry, - nextSessionId: forkedTranscript.sessionId, - nextSessionFile: forkedTranscript.sessionFile, - label, - parentSessionKey: params.sourceKey, - totalTokens: forkedTranscript.totalTokens, - }); - }, + ...(legacySource ? { legacySource } : {}), }); } async function restoreCheckpointSessionFromStoredBoundary( params: RestoreCheckpointSessionParams, ): Promise { - if ( - shouldRouteCheckpointSessionMutationToSqlite({ - checkpointId: params.checkpointId, - ...(params.agentId ? { agentId: params.agentId } : {}), - sessionKey: params.sessionStoreKey ?? params.sessionKey, - storePath: params.storePath, - }) - ) { - return await restoreSqliteCompactionCheckpointSession({ - ...(params.agentId ? { agentId: params.agentId } : {}), - storePath: params.storePath, - sessionKey: params.sessionKey, - checkpointId: params.checkpointId, - ...(params.sessionStoreKey ? { sessionStoreKey: params.sessionStoreKey } : {}), - }); - } - return await restoreSessionFromCompactionCheckpoint({ + const entry = loadSessionEntry({ + ...(params.agentId ? { agentId: params.agentId } : {}), + storePath: params.storePath, + sessionKey: params.sessionStoreKey ?? params.sessionKey, + }); + const legacySource = await prepareLegacyCheckpointSource( + findCheckpoint(entry, params.checkpointId), + ); + return await restoreSqliteCompactionCheckpointSession({ + ...(params.agentId ? { agentId: params.agentId } : {}), storePath: params.storePath, sessionKey: params.sessionKey, checkpointId: params.checkpointId, ...(params.sessionStoreKey ? { sessionStoreKey: params.sessionStoreKey } : {}), - forkTranscriptFromCheckpoint: async (checkpoint) => - await forkCheckpointTranscriptFromStoredBoundary({ checkpoint }), - buildEntry: ({ currentEntry, forkedTranscript }) => - cloneCheckpointSessionEntry({ - currentEntry, - nextSessionId: forkedTranscript.sessionId, - nextSessionFile: forkedTranscript.sessionFile, - totalTokens: forkedTranscript.totalTokens, - preserveCompactionCheckpoints: true, - preserveManagementState: true, - }), + ...(legacySource ? { legacySource } : {}), }); } -function shouldRouteCheckpointSessionMutationToSqlite(params: { - agentId?: string; - checkpointId: string; - sessionKey: string; - storePath: string; -}): boolean { - const entry = loadSessionEntry({ - ...(params.agentId ? { agentId: params.agentId } : {}), - sessionKey: params.sessionKey, - storePath: params.storePath, - }); - if (!entry) { - return false; - } - if (parseSqliteSessionFileMarker(entry.sessionFile)) { - return true; - } - const checkpoint = entry.compactionCheckpoints?.find( - (candidate) => candidate.checkpointId === params.checkpointId, - ); - const preCheckpointFile = checkpoint?.preCompaction.sessionFile?.trim(); - const postCheckpointFile = checkpoint?.postCompaction.sessionFile?.trim(); - if ( - parseSqliteSessionFileMarker(preCheckpointFile) || - parseSqliteSessionFileMarker(postCheckpointFile) - ) { - return true; - } - const hasCheckpointFile = Boolean(preCheckpointFile) || Boolean(postCheckpointFile); - const hasCheckpointRowBoundary = - Boolean(checkpoint?.preCompaction.entryId?.trim()) || - Boolean(checkpoint?.postCompaction.entryId?.trim()); - return hasCheckpointRowBoundary && !hasCheckpointFile; -} - /** * Creates the current file-backed compaction checkpoint domain store. * @@ -759,6 +589,7 @@ export function createFileBackedCompactionCheckpointStore(): CompactionCheckpoin async function captureCompactionCheckpointSnapshotAsync(params: { sessionManager?: SessionManagerCheckpointView; sessionFile: string; + sessionTarget?: SessionTranscriptRuntimeTarget; maxBytes?: number; }): Promise { const getLeafId = @@ -774,8 +605,8 @@ async function captureCompactionCheckpointSnapshotAsync(params: { return null; } const maxBytes = params.maxBytes ?? MAX_COMPACTION_CHECKPOINT_LEAF_SCAN_BYTES; - const sqliteMarker = parseSqliteSessionFileMarker(sessionFile); - if (sqliteMarker) { + const sqliteTarget = params.sessionTarget ?? parseSqliteSessionFileMarker(sessionFile); + if (sqliteTarget) { if (typeof params.sessionManager?.getEntries !== "function") { return null; } @@ -793,7 +624,7 @@ async function captureCompactionCheckpointSnapshotAsync(params: { sessionId: typeof params.sessionManager.getSessionId === "function" ? params.sessionManager.getSessionId() - : sqliteMarker.sessionId, + : sqliteTarget.sessionId, leafId, ...(position.entryId ? { entryId: position.entryId } : {}), }; diff --git a/src/gateway/session-companion-ask.ts b/src/gateway/session-companion-ask.ts index b3367eaf44de..edbb748955d9 100644 --- a/src/gateway/session-companion-ask.ts +++ b/src/gateway/session-companion-ask.ts @@ -260,7 +260,7 @@ async function defaultRun(params: SessionCompanionRunParams): Promise { import("../agents/sessions/index.js"), import("../agents/embedded-agent.js"), ]); - const sessionManager = SessionManager.open(target.sessionFile); + const sessionManager = SessionManager.open(target); for (const message of params.messages.slice(0, -1)) { sessionManager.appendMessage(toRunnerHistoryMessage(message, selection)); } diff --git a/src/gateway/session-create-fork-entry.ts b/src/gateway/session-create-fork-entry.ts index c6800bbde5da..5a285eb3870f 100644 --- a/src/gateway/session-create-fork-entry.ts +++ b/src/gateway/session-create-fork-entry.ts @@ -12,7 +12,6 @@ export function buildForkedGatewaySessionEntry( ...entry, ...buildMainSessionRecoveryClearPatch(entry), sessionId: fork.sessionId, - sessionFile: fork.sessionFile, forkSource: previousEntry?.forkSource ?? forkSource, ...(previousEntry?.sessionId && previousEntry.sessionId !== fork.sessionId ? { previousSessionId: previousEntry.sessionId } diff --git a/src/gateway/session-create-service.ts b/src/gateway/session-create-service.ts index b94d1b91bb9f..4dd05de9a3e1 100644 --- a/src/gateway/session-create-service.ts +++ b/src/gateway/session-create-service.ts @@ -1142,7 +1142,7 @@ export async function createGatewaySession(params: { sessionKey: canonicalParentSessionKey, sessionId: parentEntry?.sessionId, storePath: parentSessionTarget.storePath, - sessionFile: parentEntry?.sessionFile, + sessionFile: canonicalParentSessionKey, agentId: parentSessionTarget.agentId, reason: "new", nextSessionId: created.entry.sessionId, @@ -1155,7 +1155,7 @@ export async function createGatewaySession(params: { sessionId: created.entry.sessionId, resumedFrom: parentEntry?.sessionId, storePath: target.storePath, - sessionFile: created.entry.sessionFile, + sessionFile: target.canonicalKey, agentId: target.agentId, }); } diff --git a/src/gateway/session-message-events.test.ts b/src/gateway/session-message-events.test.ts index 9ad384bba519..535d0324794c 100644 --- a/src/gateway/session-message-events.test.ts +++ b/src/gateway/session-message-events.test.ts @@ -14,6 +14,7 @@ import { import { SUBAGENT_ENDED_REASON_ERROR } from "../agents/subagent-lifecycle-events.js"; import { createSubagentRegistryLifecycleController } from "../agents/subagent-registry-lifecycle.js"; import type { SubagentRunRecord } from "../agents/subagent-registry.types.js"; +import { formatSqliteSessionFileMarker } from "../config/sessions/legacy-sqlite-marker.js"; import { loadTranscriptEvents, persistSessionTranscriptTurn, @@ -774,8 +775,12 @@ describe("session.message websocket events", () => { throw new Error(`append failed: ${appended.reason}`); } const emitParams = requireRecord(emitSpy.mock.calls.at(0)?.[0], "transcript update params"); - expect(emitParams.sessionFile).toBe(appended.sessionFile); expect(emitParams.sessionKey).toBe("agent:main:main"); + expect(emitParams.target).toMatchObject({ + agentId: "main", + sessionId: "sess-main", + sessionKey: "agent:main:main", + }); expect(emitParams.messageId).toBe(appended.messageId); expectRecordFields(emitParams.message, { role: "assistant", @@ -1063,7 +1068,7 @@ describe("session.message websocket events", () => { }, timestamp: Date.now(), }; - const turn = await persistSessionTranscriptTurn( + await persistSessionTranscriptTurn( { agentId: "main", sessionId: "sess-main", @@ -1080,7 +1085,7 @@ describe("session.message websocket events", () => { const { messageEvent } = await emitTranscriptUpdateAndCollectMessageEvent({ ws, sessionKey: "agent:main:main", - sessionFile: turn.sessionFile, + sessionFile: "agent:main:main", message: transcriptMessage, messageId: "msg-usage", }); @@ -1150,7 +1155,7 @@ describe("session.message websocket events", () => { content: [{ type: "text", text: "early selected prompt" }], timestamp: Date.now(), }; - const turn = await persistSessionTranscriptTurn( + await persistSessionTranscriptTurn( { agentId: "main", sessionId: "sess-main", @@ -1174,8 +1179,12 @@ describe("session.message websocket events", () => { const messageEventPromise = waitForSessionMessageEvent(ws, "agent:main:main"); emitSessionTranscriptUpdate({ - sessionFile: turn.sessionFile, - sessionKey: "agent:main:main", + target: { + agentId: "main", + sessionId: "sess-main", + sessionKey: "agent:main:main", + storePath, + }, message: transcriptMessage, messageId: "msg-selected", }); @@ -1855,7 +1864,7 @@ describe("session.message websocket events", () => { content: [{ type: "text", text: "shared transcript update" }], timestamp: Date.now(), }; - const turn = await persistSessionTranscriptTurn( + await persistSessionTranscriptTurn( { agentId: "main", sessionId: "sess-new", @@ -1872,7 +1881,11 @@ describe("session.message websocket events", () => { const messageEventPromise = waitForSessionMessageEvent(ws, "agent:main:newer"); emitSessionTranscriptUpdate({ - sessionFile: turn.sessionFile, + sessionFile: formatSqliteSessionFileMarker({ + agentId: "main", + sessionId: "sess-new", + storePath, + }), message, messageId: "msg-shared", }); diff --git a/src/gateway/session-reset-service.ts b/src/gateway/session-reset-service.ts index 55a49acba8a7..097e72d7060e 100644 --- a/src/gateway/session-reset-service.ts +++ b/src/gateway/session-reset-service.ts @@ -37,6 +37,7 @@ import { resetSessionEntryLifecycle, } from "../config/sessions.js"; import { rebindCliSessionReseedReceiptsForReset } from "../config/sessions/cli-session-binding.js"; +import { formatSqliteSessionFileMarker } from "../config/sessions/legacy-sqlite-marker.js"; import { resolveResetPreservedSelection } from "../config/sessions/reset-preserved-selection.js"; import { sessionEntryForkedFromParent } from "../config/sessions/session-entry-lineage.js"; import { @@ -44,10 +45,6 @@ import { type SessionCreatedActor, type SessionCreatedVia, } from "../config/sessions/session-entry-provenance.js"; -import { - formatSqliteSessionFileMarker, - sqliteSessionFileMarkerMatchesTarget, -} from "../config/sessions/sqlite-marker.js"; import type { SessionAcpMeta } from "../config/sessions/types.js"; import type { OpenClawConfig } from "../config/types.openclaw.js"; import { logVerbose } from "../globals.js"; @@ -822,7 +819,7 @@ export async function cleanupSessionBeforeMutation(params: { agentId: normalizeAgentId(params.target.agentId ?? resolveDefaultAgentId(params.cfg)), sessionId: params.entry.sessionId, sessionKey: params.target.canonicalKey ?? params.key, - sessionFile: params.entry.sessionFile, + sessionFile: params.target.canonicalKey ?? params.key, reason: params.reason === "session-reset" ? "reset" : "deleted", } satisfies Parameters[0]; await resetRegisteredAgentHarnessSessions(resetParams); @@ -847,8 +844,10 @@ export async function emitGatewayBeforeResetPluginHook(params: { const sessionKey = params.target.canonicalKey ?? params.key; const sessionId = params.entry?.sessionId; - const sessionFile = params.entry?.sessionFile; const agentId = normalizeAgentId(params.target.agentId ?? resolveDefaultAgentId(params.cfg)); + const sessionFile = sessionId + ? formatSqliteSessionFileMarker({ agentId, sessionId, storePath: params.storePath }) + : undefined; const workspaceDir = resolveAgentWorkspaceDir(params.cfg, agentId); const messages = params.messages ?? @@ -1218,7 +1217,7 @@ export async function performGatewaySessionReset(params: { agentId, sessionId: entry.sessionId, sessionKey: target.canonicalKey ?? params.key, - sessionFile: entry.sessionFile, + sessionFile: target.canonicalKey ?? params.key, reason: "reset", }); } @@ -1280,7 +1279,7 @@ export async function performGatewaySessionReset(params: { sessionKey: target.canonicalKey, sessionId: entry.sessionId, storePath, - sessionFile: entry.sessionFile, + sessionFile: target.canonicalKey, agentId: target.agentId, reason: params.reason, archivedTranscripts: [], @@ -1308,20 +1307,6 @@ export async function performGatewaySessionReset(params: { params.assertCurrent?.(); throw new Error(`Session ${params.key} changed before reset boundary append.`); } - const resetSessionFile = boundaryEntry?.sessionId - ? ((sqliteSessionFileMarkerMatchesTarget(boundaryEntry.sessionFile, { - agentId, - sessionId: boundaryEntry.sessionId, - storePath, - }) - ? boundaryEntry.sessionFile - : undefined) ?? - formatSqliteSessionFileMarker({ - agentId, - sessionId: boundaryEntry.sessionId, - storePath, - })) - : undefined; let resetBoundaryAppended = false; let resetSkipped = false; const lifecyclePromise = resetSessionEntryLifecycle({ @@ -1361,22 +1346,11 @@ export async function performGatewaySessionReset(params: { return currentEntry; } resetBoundaryAppended = currentEntry !== undefined; - const parsed = parseAgentSessionKey(primaryKey); - const sessionAgentId = normalizeAgentId( - parsed?.agentId ?? target.agentId ?? requestedAgentId ?? resolveDefaultAgentId(cfg), - ); const resetPreservedSelection = resolveResetPreservedSelection({ entry: currentEntry, }); const now = Date.now(); const nextSessionId = currentEntry?.sessionId ?? randomUUID(); - const sessionFile = - (currentEntry ? resetSessionFile : undefined) ?? - formatSqliteSessionFileMarker({ - agentId: sessionAgentId, - sessionId: nextSessionId, - storePath, - }); const creationStamp = currentEntry ? { createdVia: currentEntry.createdVia, @@ -1388,7 +1362,6 @@ export async function performGatewaySessionReset(params: { : {}; const nextEntry: SessionEntry = { sessionId: nextSessionId, - sessionFile, lifecycleRevision: randomUUID(), updatedAt: now, sessionStartedAt: now, @@ -1593,7 +1566,7 @@ export async function performGatewaySessionReset(params: { sessionId: next.sessionId, resumedFrom: oldSessionId, storePath, - sessionFile: next.sessionFile, + sessionFile: target.canonicalKey ?? params.key, agentId: target.agentId, }); } diff --git a/src/gateway/session-title-inbound-metadata.test.ts b/src/gateway/session-title-inbound-metadata.test.ts index 72f602c1f503..4636be5b89aa 100644 --- a/src/gateway/session-title-inbound-metadata.test.ts +++ b/src/gateway/session-title-inbound-metadata.test.ts @@ -1,4 +1,3 @@ -import fs from "node:fs"; import path from "node:path"; import { afterEach, beforeEach, describe, expect, test } from "vitest"; import { useAutoCleanupTempDirTracker } from "../../test/helpers/temp-dir.js"; @@ -38,8 +37,6 @@ describe("session titles with inbound Gateway metadata", () => { }); test.each([ - { backend: "file", content: "string" }, - { backend: "file", content: "text blocks" }, { backend: "sqlite", content: "string" }, { backend: "sqlite", content: "text blocks" }, ] as const)( @@ -53,38 +50,17 @@ describe("session titles with inbound Gateway metadata", () => { { message: { role: "user" as const, content: userContent } }, { message: { role: "assistant" as const, content: "Here is the deployment status." } }, ]; - const transcriptPath = path.join(tempDir, `${sessionId}.jsonl`); const scope = { agentId: "main", sessionId, sessionKey: `agent:main:${sessionId}`, storePath: path.join(tempDir, "sessions.json"), - ...(backend === "file" ? { sessionFile: transcriptPath } : {}), }; - - if (backend === "file") { - fs.writeFileSync( - transcriptPath, - `${[ - { type: "session", version: 3, id: sessionId }, - ...messages.map((message, index) => ({ - type: "message", - id: `message-${index}`, - parentId: index === 0 ? null : "message-0", - ...message, - })), - ] - .map((event) => JSON.stringify(event)) - .join("\n")}\n`, - "utf8", - ); - } else { - await persistSessionTranscriptTurn(scope, { - cwd: tempDir, - messages, - touchSessionEntry: false, - }); - } + await persistSessionTranscriptTurn(scope, { + cwd: tempDir, + messages, + touchSessionEntry: false, + }); const entry: SessionEntry = { sessionId, updatedAt: 0 }; const syncFields = readSessionTitleFieldsFromTranscript(scope); diff --git a/src/gateway/session-transcript-key.test.ts b/src/gateway/session-transcript-key.test.ts deleted file mode 100644 index b266f84b2a64..000000000000 --- a/src/gateway/session-transcript-key.test.ts +++ /dev/null @@ -1,182 +0,0 @@ -// Transcript key tests cover cached lookup from transcript files back to gateway -// session keys using combined session stores and transcript candidates. -import { beforeEach, describe, expect, it, vi } from "vitest"; -import type { SessionEntry } from "../config/sessions/types.js"; - -const { - loadConfigMock, - loadCombinedSessionStoreForGatewayMock, - resolveGatewaySessionStoreTargetMock, - resolveSessionTranscriptCandidatesMock, -} = vi.hoisted(() => ({ - loadConfigMock: vi.fn(() => ({ session: {} })), - loadCombinedSessionStoreForGatewayMock: vi.fn(), - resolveGatewaySessionStoreTargetMock: vi.fn(), - resolveSessionTranscriptCandidatesMock: vi.fn(), -})); - -vi.mock("../config/config.js", () => ({ - getRuntimeConfig: loadConfigMock, -})); - -vi.mock("./session-utils.js", () => ({ - loadCombinedSessionStoreForGateway: loadCombinedSessionStoreForGatewayMock, - resolveGatewaySessionStoreTarget: resolveGatewaySessionStoreTargetMock, - resolveSessionTranscriptCandidates: resolveSessionTranscriptCandidatesMock, -})); - -import { resolveSessionKeyForTranscriptFile } from "./session-transcript-key.js"; - -describe("resolveSessionKeyForTranscriptFile", () => { - const now = 1_700_000_000_000; - - beforeEach(() => { - loadConfigMock.mockClear(); - loadCombinedSessionStoreForGatewayMock.mockReset(); - resolveGatewaySessionStoreTargetMock.mockReset(); - resolveSessionTranscriptCandidatesMock.mockReset(); - resolveGatewaySessionStoreTargetMock.mockImplementation(({ key }: { key: string }) => ({ - agentId: "main", - storePath: "/tmp/sessions.json", - canonicalKey: key, - storeKeys: [key], - })); - }); - - it("reuses the cached session key for repeat transcript lookups", () => { - const store = { - "agent:main:one": { sessionId: "sess-1", updatedAt: now }, - "agent:main:two": { sessionId: "sess-2", updatedAt: now }, - } satisfies Record; - loadCombinedSessionStoreForGatewayMock.mockReturnValue({ - storePath: "(multiple)", - store, - }); - resolveSessionTranscriptCandidatesMock.mockImplementation((sessionId: string) => { - if (sessionId === "sess-1") { - return ["/tmp/one.jsonl"]; - } - if (sessionId === "sess-2") { - return ["/tmp/two.jsonl"]; - } - return []; - }); - - expect(resolveSessionKeyForTranscriptFile("/tmp/two.jsonl")).toBe("agent:main:two"); - expect(resolveSessionTranscriptCandidatesMock).toHaveBeenCalledTimes(2); - - expect(resolveSessionKeyForTranscriptFile("/tmp/two.jsonl")).toBe("agent:main:two"); - expect(resolveSessionTranscriptCandidatesMock).toHaveBeenCalledTimes(3); - }); - - it("drops stale cached mappings and falls back to the current store contents", () => { - let store: Record = { - "agent:main:alpha": { sessionId: "sess-alpha", updatedAt: now }, - "agent:main:beta": { sessionId: "sess-beta", updatedAt: now }, - }; - loadCombinedSessionStoreForGatewayMock.mockImplementation(() => ({ - storePath: "(multiple)", - store, - })); - resolveSessionTranscriptCandidatesMock.mockImplementation( - (sessionId: string, _storePath?: string, sessionFile?: string) => { - if (sessionId === "sess-alpha") { - return ["/tmp/alpha.jsonl"]; - } - if (sessionId === "sess-beta") { - return sessionFile ? [sessionFile] : ["/tmp/shared.jsonl"]; - } - if (sessionId === "sess-alpha-2") { - return ["/tmp/shared.jsonl"]; - } - return []; - }, - ); - - expect(resolveSessionKeyForTranscriptFile("/tmp/shared.jsonl")).toBe("agent:main:beta"); - - store = { - "agent:main:alpha": { sessionId: "sess-alpha-2", updatedAt: now + 1 }, - "agent:main:beta": { - sessionId: "sess-beta", - updatedAt: now + 1, - sessionFile: "/tmp/beta.jsonl", - }, - }; - - expect(resolveSessionKeyForTranscriptFile("/tmp/shared.jsonl")).toBe("agent:main:alpha"); - }); - - it("returns undefined for blank transcript paths", () => { - expect(resolveSessionKeyForTranscriptFile(" ")).toBeUndefined(); - expect(loadCombinedSessionStoreForGatewayMock).not.toHaveBeenCalled(); - }); - - it("prefers the deterministic session key when duplicate sessionIds share a transcript path", () => { - const store = { - "agent:other:main": { sessionId: "run-dup", updatedAt: now + 1 }, - "agent:main:acp:run-dup": { sessionId: "run-dup", updatedAt: now }, - } satisfies Record; - loadCombinedSessionStoreForGatewayMock.mockReturnValue({ - storePath: "(multiple)", - store, - }); - resolveSessionTranscriptCandidatesMock.mockReturnValue(["/tmp/shared.jsonl"]); - - expect(resolveSessionKeyForTranscriptFile("/tmp/shared.jsonl")).toBe("agent:main:acp:run-dup"); - }); - - it("prefers the freshest matching session when different sessionIds share a transcript path", () => { - const store = { - "agent:main:older": { sessionId: "sess-old", updatedAt: now }, - "agent:main:newer": { sessionId: "sess-new", updatedAt: now + 10 }, - } satisfies Record; - loadCombinedSessionStoreForGatewayMock.mockReturnValue({ - storePath: "(multiple)", - store, - }); - resolveSessionTranscriptCandidatesMock.mockReturnValue(["/tmp/shared.jsonl"]); - - expect(resolveSessionKeyForTranscriptFile("/tmp/shared.jsonl")).toBe("agent:main:newer"); - }); - - it("evicts oldest entry when cache exceeds 256 entries (#63643)", () => { - // Fill cache with 256 unique transcript paths - for (let i = 0; i < 256; i++) { - const sessionKey = `agent:main:session-${i}`; - const transcriptPath = `/tmp/session-${i}.jsonl`; - const store = { - [sessionKey]: { sessionId: `sid-${i}`, updatedAt: now + i }, - } satisfies Record; - loadCombinedSessionStoreForGatewayMock.mockReturnValue({ - storePath: "(multiple)", - store, - }); - resolveSessionTranscriptCandidatesMock.mockReturnValue([transcriptPath]); - resolveSessionKeyForTranscriptFile(transcriptPath); - } - - // Now add the 257th — should evict session-0 - const overflowKey = "agent:main:session-overflow"; - const overflowPath = "/tmp/session-overflow.jsonl"; - const overflowStore = { - [overflowKey]: { sessionId: "sid-overflow", updatedAt: now + 999 }, - } satisfies Record; - loadCombinedSessionStoreForGatewayMock.mockReturnValue({ - storePath: "(multiple)", - store: overflowStore, - }); - resolveSessionTranscriptCandidatesMock.mockReturnValue([overflowPath]); - expect(resolveSessionKeyForTranscriptFile(overflowPath)).toBe(overflowKey); - - // session-0 should have been evicted from cache — next lookup will - // re-resolve from the store (returns undefined since store was mocked - // with only the overflow entry). - loadCombinedSessionStoreForGatewayMock.mockReturnValue({ - storePath: "(multiple)", - store: overflowStore, - }); - resolveSessionTranscriptCandidatesMock.mockReturnValue([]); - expect(resolveSessionKeyForTranscriptFile("/tmp/session-0.jsonl")).toBeUndefined(); - }); -}); diff --git a/src/gateway/session-transcript-key.ts b/src/gateway/session-transcript-key.ts deleted file mode 100644 index efc47ea2064e..000000000000 --- a/src/gateway/session-transcript-key.ts +++ /dev/null @@ -1,142 +0,0 @@ -// Session transcript key resolver. -// Maps transcript file paths back to Gateway session keys for live broadcasts. -import { getRuntimeConfig } from "../config/io.js"; -import type { SessionEntry } from "../config/sessions/types.js"; -import type { OpenClawConfig } from "../config/types.openclaw.js"; -import { normalizeAgentId } from "../routing/session-key.js"; -import { resolvePreferredSessionKeyForSessionIdMatches } from "../sessions/session-id-resolution.js"; -import { resolveTranscriptPathForComparison } from "./session-transcript-path.js"; -import { - loadCombinedSessionStoreForGateway, - resolveGatewaySessionStoreTarget, - resolveSessionTranscriptCandidates, -} from "./session-utils.js"; - -const TRANSCRIPT_SESSION_KEY_CACHE = new Map(); -const TRANSCRIPT_SESSION_KEY_CACHE_MAX = 256; - -function sessionKeyMatchesTranscriptPath(params: { - cfg: OpenClawConfig; - store: Record; - key: string; - targetPath: string; -}): boolean { - const entry = params.store[params.key]; - if (!entry?.sessionId) { - return false; - } - const target = resolveGatewaySessionStoreTarget({ - cfg: params.cfg, - key: params.key, - store: params.store, - }); - const sessionAgentId = normalizeAgentId(target.agentId); - return resolveSessionTranscriptCandidates( - entry.sessionId, - target.storePath, - entry.sessionFile, - sessionAgentId, - ).some((candidate) => resolveTranscriptPathForComparison(candidate) === params.targetPath); -} - -/** Resolve the most likely Gateway session key for a transcript file path. */ -export function resolveSessionKeyForTranscriptFile(sessionFile: string): string | undefined { - const targetPath = resolveTranscriptPathForComparison(sessionFile); - if (!targetPath) { - return undefined; - } - const cfg = getRuntimeConfig(); - const { store } = loadCombinedSessionStoreForGateway(cfg); - - const cachedKey = TRANSCRIPT_SESSION_KEY_CACHE.get(targetPath); - if ( - cachedKey && - sessionKeyMatchesTranscriptPath({ - cfg, - store, - key: cachedKey, - targetPath, - }) - ) { - return cachedKey; - } - - const matchingEntries: Array<[string, SessionEntry]> = []; - for (const [key, entry] of Object.entries(store)) { - if (!entry?.sessionId || key === cachedKey) { - continue; - } - if ( - sessionKeyMatchesTranscriptPath({ - cfg, - store, - key, - targetPath, - }) - ) { - matchingEntries.push([key, entry]); - } - } - - if (matchingEntries.length > 0) { - // Multiple keys can point at copied/forked transcript paths. Prefer the - // freshest unambiguous session ID group; ties stay unresolved. - const matchesBySessionId = new Map>(); - for (const entry of matchingEntries) { - const sessionId = entry[1].sessionId; - if (!sessionId) { - continue; - } - const group = matchesBySessionId.get(sessionId); - if (group) { - group.push(entry); - } else { - matchesBySessionId.set(sessionId, [entry]); - } - } - - const resolvedMatches = Array.from(matchesBySessionId.entries()) - .map(([sessionId, matches]) => { - const resolvedKey = - resolvePreferredSessionKeyForSessionIdMatches(matches, sessionId) ?? matches[0]?.[0]; - const resolvedEntry = resolvedKey - ? matches.find(([key]) => key === resolvedKey)?.[1] - : undefined; - return resolvedKey && resolvedEntry - ? { - key: resolvedKey, - updatedAt: resolvedEntry.updatedAt ?? 0, - } - : undefined; - }) - .filter((match): match is { key: string; updatedAt: number } => match !== undefined); - - const sortedResolvedMatches = [...resolvedMatches].toSorted( - (a, b) => b.updatedAt - a.updatedAt, - ); - const [freshestMatch, secondFreshestMatch] = sortedResolvedMatches; - const resolvedKey = - resolvedMatches.length === 1 - ? freshestMatch?.key - : (freshestMatch?.updatedAt ?? 0) > (secondFreshestMatch?.updatedAt ?? 0) - ? freshestMatch?.key - : undefined; - if (resolvedKey) { - // Evict oldest-inserted entry when cache exceeds size cap (FIFO bound). - if ( - !TRANSCRIPT_SESSION_KEY_CACHE.has(targetPath) && - TRANSCRIPT_SESSION_KEY_CACHE.size >= TRANSCRIPT_SESSION_KEY_CACHE_MAX - ) { - const oldest = TRANSCRIPT_SESSION_KEY_CACHE.keys().next().value; - if (oldest !== undefined) { - TRANSCRIPT_SESSION_KEY_CACHE.delete(oldest); - } - } - TRANSCRIPT_SESSION_KEY_CACHE.set(targetPath, resolvedKey); - return resolvedKey; - } - } - - TRANSCRIPT_SESSION_KEY_CACHE.delete(targetPath); - return undefined; -} diff --git a/src/gateway/session-transcript-readers.test.ts b/src/gateway/session-transcript-readers.test.ts index 0cbd72248923..66b05606048c 100644 --- a/src/gateway/session-transcript-readers.test.ts +++ b/src/gateway/session-transcript-readers.test.ts @@ -5,10 +5,10 @@ import { useAutoCleanupTempDirTracker } from "../../test/helpers/temp-dir.js"; import * as sessionAccessor from "../config/sessions/session-accessor.js"; import { persistSessionTranscriptTurn, + replaceTranscriptEvents, upsertSessionEntry, } from "../config/sessions/session-accessor.js"; import { waitForSessionTranscriptIndexReconcile } from "../config/sessions/session-transcript-reconcile.js"; -import { formatSqliteSessionFileMarker } from "../config/sessions/sqlite-marker.js"; import { closeOpenClawAgentDatabasesForTest, openOpenClawAgentDatabase, @@ -21,6 +21,7 @@ import { readSessionMessageCountAsync, readSessionMessagesAsync, readSessionMessagesPageWithStatsAsync, + readLatestSessionUsageFromTranscriptAsync, readSessionTitleFieldsFromTranscript, type SessionTranscriptReadScope, } from "./session-transcript-readers.js"; @@ -55,38 +56,35 @@ describe("session transcript reader facade", () => { envSnapshot.restore(); }); - function writeTranscript(sessionId: string, events: unknown[]): SessionTranscriptReadScope { - const transcriptPath = path.join(tempDir, `${sessionId}.jsonl`); - fs.writeFileSync( - transcriptPath, - `${events.map((event) => JSON.stringify(event)).join("\n")}\n`, - "utf-8", - ); - return { sessionFile: transcriptPath, sessionId, sessionKey: `agent:main:${sessionId}` }; - } - - function sqliteScope(sessionId: string): SessionTranscriptReadScope { - return { + async function writeTranscript( + sessionId: string, + events: unknown[], + ): Promise { + const scope = { agentId: "main", - sessionFile: formatSqliteSessionFileMarker({ agentId: "main", sessionId, storePath }), sessionId, sessionKey: `agent:main:${sessionId}`, storePath, }; + await replaceTranscriptEvents(scope, events); + return scope; } async function writeSqliteMessages( sessionId: string, messages: Array<{ content: unknown; provenance?: unknown; role: string }>, ): Promise { - await persistSessionTranscriptTurn( - { agentId: "main", sessionId, sessionKey: `agent:main:${sessionId}`, storePath }, - { - messages: messages.map((message) => ({ message })), - touchSessionEntry: false, - }, - ); - return sqliteScope(sessionId); + const scope = { + agentId: "main", + sessionId, + sessionKey: `agent:main:${sessionId}`, + storePath, + }; + await persistSessionTranscriptTurn(scope, { + messages: messages.map((message) => ({ message })), + touchSessionEntry: false, + }); + return scope; } function extractReferenceText(message: unknown): string | null { @@ -141,7 +139,7 @@ describe("session transcript reader facade", () => { } test("reads active-branch messages and message ids through a scope", async () => { - const scope = writeTranscript("reader-active-branch", [ + const scope = await writeTranscript("reader-active-branch", [ { type: "session", version: 3, id: "reader-active-branch" }, { type: "message", @@ -188,7 +186,7 @@ describe("session transcript reader facade", () => { test("finds an anchored reset-archive message by historical session id", async () => { const sessionId = "reader-file-archive-anchor"; - const scope = writeTranscript(sessionId, [ + const scope = await writeTranscript(sessionId, [ { type: "session", version: 3, id: sessionId }, { type: "message", @@ -220,92 +218,6 @@ describe("session transcript reader facade", () => { }); }); - test("does not reuse the current session file for a historical anchor", async () => { - const currentSessionId = "reader-current-collision"; - const historicalSessionId = "reader-historical-collision"; - const currentSessionFile = path.join(tempDir, `${currentSessionId}.jsonl`); - fs.writeFileSync( - currentSessionFile, - `${JSON.stringify({ type: "session", version: 3, id: currentSessionId })}\n${JSON.stringify({ - type: "message", - id: "shared-message", - parentId: null, - message: { role: "user", content: "current collision" }, - })}\n`, - "utf-8", - ); - fs.writeFileSync( - path.join(tempDir, `${historicalSessionId}.jsonl.reset.2026-07-12T17-00-00.000Z`), - `${JSON.stringify({ type: "session", version: 3, id: historicalSessionId })}\n${JSON.stringify( - { - type: "message", - id: "shared-message", - parentId: null, - message: { role: "user", content: "historical collision" }, - }, - )}\n`, - "utf-8", - ); - - await expect( - readSessionMessagesAroundIdWithStatsAsync( - { - agentId: "main", - sessionId: historicalSessionId, - sessionKey: "agent:main:main", - storePath, - sessionEntry: { sessionId: currentSessionId, sessionFile: currentSessionFile }, - }, - { - messageId: "shared-message", - maxMessages: 1, - allowResetArchiveFallback: true, - }, - ), - ).resolves.toMatchObject({ - found: true, - messages: [{ content: "historical collision" }], - }); - }); - - test("keeps an explicit historical session file over a mismatched current entry", async () => { - const historicalSessionId = "reader-explicit-historical"; - const historicalSessionFile = path.join(tempDir, "explicit-historical.jsonl"); - fs.writeFileSync( - historicalSessionFile, - `${JSON.stringify({ type: "session", version: 3, id: historicalSessionId })}\n${JSON.stringify( - { - type: "message", - id: "historical-message", - parentId: null, - message: { role: "user", content: "explicit historical" }, - }, - )}\n`, - "utf-8", - ); - - await expect( - readSessionMessagesAroundIdWithStatsAsync( - { - sessionFile: historicalSessionFile, - sessionId: historicalSessionId, - sessionEntry: { - sessionId: "reader-current-entry", - sessionFile: path.join(tempDir, "reader-current-entry.jsonl"), - }, - }, - { - messageId: "historical-message", - maxMessages: 1, - allowResetArchiveFallback: true, - }, - ), - ).resolves.toMatchObject({ - found: true, - messages: [{ content: "explicit historical" }], - }); - }); - test("does not fall back to stored custom transcript paths after SQLite migration", async () => { const sessionId = "reader-legacy-custom-path"; const sessionKey = `agent:main:telegram:group:1:topic:9`; @@ -373,66 +285,92 @@ describe("session transcript reader facade", () => { await expect(readSessionMessageCountAsync(scope)).resolves.toBe(3); }); - test("keeps bounded title fields at full-scan parity across common transcript shapes", async () => { - const longMessages = (build: (index: number) => { content: unknown; role: string }) => - Array.from({ length: 105 }, (_, index) => build(index)); - const cases = [ - { - expected: { firstUserMessage: "first prompt", lastMessagePreview: "reply 104" }, - messages: longMessages((index) => - index === 0 - ? { role: "user", content: "first prompt" } - : { role: "assistant", content: `reply ${index}` }, - ), - name: "user message first", - }, - { - expected: { firstUserMessage: "late prompt", lastMessagePreview: "reply 104" }, - messages: longMessages((index) => - index === 60 - ? { role: "user", content: "late prompt" } - : { role: "assistant", content: `reply ${index}` }, - ), - name: "user message late", - }, - { - expected: { firstUserMessage: null, lastMessagePreview: "reply 104" }, - messages: longMessages((index) => ({ role: "assistant", content: `reply ${index}` })), - name: "no user messages", - }, - { - expected: { firstUserMessage: null, lastMessagePreview: null }, - messages: [], - name: "empty transcript", - }, - { - expected: { firstUserMessage: "first prompt", lastMessagePreview: "last visible" }, - messages: longMessages((index) => { - if (index === 0) { - return { role: "user", content: "first prompt" }; - } - if (index === 102) { - return { role: "assistant", content: "last visible" }; - } - return { role: "assistant", content: index > 102 ? " " : `reply ${index}` }; - }), - name: "last message empty then text", - }, - ]; + test("uses an explicit JSONL artifact when the store path is a placeholder", async () => { + const sessionId = "reader-artifact-placeholder-store"; + const transcriptPath = path.join(tempDir, `${sessionId}.jsonl`); + fs.writeFileSync( + transcriptPath, + `${JSON.stringify({ type: "session", version: 1, id: sessionId })}\n${JSON.stringify({ + message: { + role: "assistant", + provider: "anthropic", + model: "claude-sonnet-4-6", + usage: { input: 12, output: 3, cost: { total: 0.001 } }, + }, + })}\n`, + "utf-8", + ); - for (const [index, parityCase] of cases.entries()) { - const sessionId = `reader-title-parity-${String(index)}`; - const scope = - parityCase.messages.length > 0 - ? await writeSqliteMessages(sessionId, parityCase.messages) - : sqliteScope(sessionId); - const reference = await readFullScanTitleFields(scope); - expect(reference, `${parityCase.name} reference`).toEqual(parityCase.expected); - vi.clearAllMocks(); + await expect( + readLatestSessionUsageFromTranscriptAsync({ + agentId: "main", + sessionId, + sessionKey: `agent:main:${sessionId}`, + sessionFile: transcriptPath, + storePath: "(multiple)", + }), + ).resolves.toMatchObject({ + inputTokens: 12, + outputTokens: 3, + }); + }); - expect(readSessionTitleFieldsFromTranscript(scope), parityCase.name).toEqual(reference); - expect(sessionAccessor.readSessionTranscriptMessageEvents).not.toHaveBeenCalled(); - } + test("keeps a canonical session key on SQLite when the store path is a placeholder", async () => { + const sessionId = "reader-placeholder-sqlite-key"; + const sessionKey = `agent:main:${sessionId}`; + const defaultStorePath = path.join(tempDir, "agents", "main", "sessions", "sessions.json"); + await persistSessionTranscriptTurn( + { agentId: "main", sessionId, sessionKey, storePath: defaultStorePath }, + { + messages: [ + { + message: { + role: "assistant", + provider: "anthropic", + model: "claude-sonnet-4-6", + usage: { input: 15, output: 4, cost: { total: 0.001 } }, + }, + }, + ], + updateMode: "file-only", + }, + ); + + await expect( + readLatestSessionUsageFromTranscriptAsync({ + sessionId, + sessionKey, + sessionFile: sessionKey, + storePath: "(multiple)", + }), + ).resolves.toMatchObject({ + inputTokens: 15, + outputTokens: 4, + }); + }); + + test("keeps bounded title fields at full-scan parity", async () => { + const scope = await writeSqliteMessages( + "reader-title-parity", + Array.from({ length: 105 }, (_, index) => { + if (index === 60) { + return { role: "user", content: "late prompt" }; + } + if (index === 102) { + return { role: "assistant", content: "last visible" }; + } + return { role: "assistant", content: index > 102 ? " " : `reply ${String(index)}` }; + }), + ); + const reference = await readFullScanTitleFields(scope); + expect(reference).toEqual({ + firstUserMessage: "late prompt", + lastMessagePreview: "last visible", + }); + vi.clearAllMocks(); + + expect(readSessionTitleFieldsFromTranscript(scope)).toEqual(reference); + expect(sessionAccessor.readSessionTranscriptMessageEvents).not.toHaveBeenCalled(); }); test("bounds title probe reads independently of transcript length", async () => { @@ -460,25 +398,6 @@ describe("session transcript reader facade", () => { await expect(probeReadCount("reader-title-bounded-201", 201)).resolves.toBe(200); }); - test("returns missing title fields when the bounded head and tail caps miss", async () => { - const scope = await writeSqliteMessages( - "reader-title-cap-miss", - Array.from({ length: 201 }, (_, index) => - index === 100 - ? { role: "user", content: "outside both probes" } - : { role: "assistant", content: " " }, - ), - ); - vi.clearAllMocks(); - - expect(readSessionTitleFieldsFromTranscript(scope)).toEqual({ - firstUserMessage: null, - lastMessagePreview: null, - }); - expect(sessionAccessor.readSessionTranscriptMessageEvents).not.toHaveBeenCalled(); - expect(boundedPageEventReadCount()).toBe(200); - }); - test("promotes SQLite message idempotency into transcript metadata", async () => { const sessionId = "reader-sqlite-idempotency"; const scope = { @@ -518,7 +437,7 @@ describe("session transcript reader facade", () => { ]); }); - test("uses SQLite marker identity when only sessionFile is provided", async () => { + test("uses structured SQLite identity", async () => { const sessionId = "reader-marker-only"; const markerStorePath = path.join( tempDir, @@ -542,21 +461,13 @@ describe("session transcript reader facade", () => { ], touchSessionEntry: false, }); - const marker = formatSqliteSessionFileMarker({ - agentId: "marker-agent", - sessionId, - storePath: markerStorePath, - }); - await expect( - readSessionMessagesAsync( - { sessionFile: marker, sessionId }, - { mode: "full", reason: "sqlite marker-only read test" }, - ), + readSessionMessagesAsync(writeScope, { mode: "full", reason: "sqlite identity read test" }), ).resolves.toMatchObject([{ content: "marker scoped prompt" }]); - await expect( - readSessionMessageByIdAsync({ sessionFile: marker, sessionId }, "marker-message"), - ).resolves.toMatchObject({ found: true, seq: 1 }); + await expect(readSessionMessageByIdAsync(writeScope, "marker-message")).resolves.toMatchObject({ + found: true, + seq: 1, + }); }); test("waits for an in-flight SQLite projection before counting messages", async () => { @@ -701,29 +612,4 @@ describe("session transcript reader facade", () => { readSessionMessagesAsync(scope, { mode: "full", reason: "facade agent scope test" }), ).resolves.toMatchObject([{ content: "agent scoped prompt" }]); }); - - test("reads explicit transcript files without session store identity", async () => { - const sessionId = "reader-explicit-file"; - const transcriptPath = path.join(tempDir, "explicit-file.jsonl"); - fs.writeFileSync( - transcriptPath, - `${JSON.stringify({ - type: "message", - id: "explicit-message", - parentId: null, - message: { role: "user", content: "explicit prompt" }, - })}\n`, - "utf-8", - ); - const scope = { sessionFile: transcriptPath, sessionId }; - - await expect(readSessionMessageCountAsync(scope)).resolves.toBe(1); - await expect(readSessionMessageByIdAsync(scope, "explicit-message")).resolves.toMatchObject({ - found: true, - seq: 1, - }); - await expect( - readSessionMessagesAsync(scope, { mode: "full", reason: "explicit file test" }), - ).resolves.toMatchObject([{ content: "explicit prompt" }]); - }); }); diff --git a/src/gateway/session-transcript-readers.ts b/src/gateway/session-transcript-readers.ts index bf100f7781fe..c4875b02aba2 100644 --- a/src/gateway/session-transcript-readers.ts +++ b/src/gateway/session-transcript-readers.ts @@ -5,13 +5,14 @@ import { readSessionTranscriptMessageEventCount, readSessionTranscriptMessageEventPage, readSessionTranscriptMessageEvents, + resolveConcreteSessionStorePath, resolveSessionTranscriptReadTarget, waitForSessionTranscriptProjection, type SessionTranscriptMessageEvent, type SessionTranscriptReadScope, type TranscriptEvent, } from "../config/sessions/session-accessor.js"; -import { parseSqliteSessionFileMarker } from "../config/sessions/sqlite-marker.js"; +import { resolveAgentIdFromSessionKey } from "../routing/session-key.js"; import { hasInterSessionUserProvenance } from "../sessions/input-provenance.js"; import { aggregateSqliteUsageSnapshots } from "./session-transcript-derived-readers.js"; import type { @@ -87,27 +88,18 @@ export function resolveTranscriptReadTarget( scope: SessionTranscriptReadScope, ): ResolvedTranscriptReadTarget { const target = resolveSessionTranscriptReadTarget(scope); - const marker = parseSqliteSessionFileMarker(target.sessionFile); - const storePath = resolveConcreteReadStorePath(scope.storePath); return { - agentId: target.agentId ?? marker?.agentId, - sessionFile: target.sessionFile, - sessionId: marker?.sessionId ?? target.sessionId, + agentId: target.agentId, + sessionFile: target.sessionKey ?? target.sessionId, + sessionId: target.sessionId, ...(target.sessionKey ? { sessionKey: target.sessionKey } : {}), - ...((storePath ?? marker?.storePath) ? { storePath: storePath ?? marker?.storePath } : {}), + storePath: target.storePath, }; } -function resolveConcreteReadStorePath(storePath: string | undefined): string | undefined { - const trimmed = storePath?.trim(); - if (!trimmed || trimmed === "(multiple)" || trimmed.includes("{agentId}")) { - return undefined; - } - return trimmed; -} - export function isSqliteReadTarget(target: ResolvedTranscriptReadTarget): boolean { - return parseSqliteSessionFileMarker(target.sessionFile) !== undefined; + void target; + return true; } export function toTranscriptReadScope( @@ -666,6 +658,23 @@ export async function readSessionTitleFieldsFromTranscriptAsync( export async function readLatestSessionUsageFromTranscriptAsync( scope: SessionTranscriptReadScope, ): Promise { + const artifactFile = scope.sessionFile?.trim(); + const concreteStorePath = resolveConcreteSessionStorePath(scope.storePath); + const targetAgentId = scope.agentId?.trim() || resolveAgentIdFromSessionKey(scope.sessionKey); + const hasCompleteTarget = Boolean(targetAgentId && scope.sessionKey?.trim() && concreteStorePath); + if ( + !hasCompleteTarget && + artifactFile && + path.isAbsolute(artifactFile) && + artifactFile.endsWith(".jsonl") + ) { + return await readLatestSessionUsageFromTranscriptAsyncFile( + scope.sessionId, + concreteStorePath, + artifactFile, + undefined, + ); + } const target = resolveTranscriptReadTarget(scope); if (isSqliteReadTarget(target)) { return readSqliteAggregateUsageSnapshot(target); @@ -715,3 +724,4 @@ export function readSessionPreviewItemsFromTranscript( maxChars, ); } +import path from "node:path"; diff --git a/src/gateway/session-utils.fs.test.ts b/src/gateway/session-utils.fs.test.ts index 21210a320093..552aa486de0b 100644 --- a/src/gateway/session-utils.fs.test.ts +++ b/src/gateway/session-utils.fs.test.ts @@ -3,6 +3,7 @@ import fs from "node:fs"; import os from "node:os"; import path from "node:path"; +import "../../test/helpers/session-manager-file-compat.js"; import { SessionManager } from "openclaw/plugin-sdk/agent-sessions"; import { afterAll, beforeAll, describe, expect, test, vi } from "vitest"; import { withEnv, withEnvAsync } from "../test-utils/env.js"; @@ -16,7 +17,6 @@ import { readRecentSessionMessagesWithStatsAsync, readSessionMessageCountAsync, readSessionMessagesAsync, - readSessionMessages, readSessionMessagesPageWithStatsAsync, readSessionTitleFieldsFromTranscript, readSessionTitleFieldsFromTranscriptAsync, @@ -133,7 +133,10 @@ function appendBlockedUserMessageWithSessionManager(params: { pluginId: string; idempotencyKey?: string; }): string { - const sessionManager = SessionManager.open(params.sessionFile, path.dirname(params.sessionFile)); + const sessionManager = SessionManager.openFile( + params.sessionFile, + path.dirname(params.sessionFile), + ); return appendBlockedUserMessage(sessionManager, params); } @@ -349,7 +352,7 @@ describe("readSessionMessages", () => { storePath = nextStorePath; }); - test("includes synthetic compaction markers for compaction entries", () => { + test("includes synthetic compaction markers for compaction entries", async () => { const sessionId = "test-session-compaction"; const transcriptPath = path.join(tmpDir, `${sessionId}.jsonl`); const lines = [ @@ -367,7 +370,10 @@ describe("readSessionMessages", () => { ]; fs.writeFileSync(transcriptPath, lines.join("\n"), "utf-8"); - const out = readSessionMessages(sessionId, storePath); + const out = await readSessionMessagesAsync(sessionId, storePath, undefined, { + mode: "full", + reason: "test", + }); expect(out).toHaveLength(3); const marker = out[1] as { role: string; @@ -1134,7 +1140,7 @@ describe("readSessionMessages", () => { } }); - test("reads only the active branch when transcript rewrites abandon older entries", () => { + test("reads only the active branch when transcript rewrites abandon older entries", async () => { const sessionId = "test-session-active-branch"; const sessionFile = path.join(tmpDir, `${sessionId}.jsonl`); const lines = [ @@ -1201,7 +1207,10 @@ describe("readSessionMessages", () => { const sessionManagerOpenSpy = vi.spyOn(SessionManager, "open"); try { - const out = readSessionMessages(sessionId, storePath, sessionFile); + const out = await readSessionMessagesAsync(sessionId, storePath, sessionFile, { + mode: "full", + reason: "test", + }); expect(out).toHaveLength(2); expect(out).toHaveLength(2); expectMessageFields(out[0], { role: "user", content: "clean prompt", openclaw: { seq: 1 } }); @@ -1218,7 +1227,7 @@ describe("readSessionMessages", () => { } }); - test("keeps legacy messages when a mixed transcript lacks a complete branch tree", () => { + test("keeps legacy messages when a mixed transcript lacks a complete branch tree", async () => { const sessionId = "mixed-legacy-tree-session"; const transcriptPath = path.join(tmpDir, `${sessionId}.jsonl`); const lines = [ @@ -1233,7 +1242,10 @@ describe("readSessionMessages", () => { ]; fs.writeFileSync(transcriptPath, lines.map((line) => JSON.stringify(line)).join("\n"), "utf-8"); - const out = readSessionMessages(sessionId, storePath); + const out = await readSessionMessagesAsync(sessionId, storePath, undefined, { + mode: "full", + reason: "test", + }); expect(out.map((message) => (message as { content?: unknown }).content)).toEqual([ "legacy hello", @@ -1256,7 +1268,7 @@ describe("readSessionMessages", () => { }, ] as const)( "reads cross-agent absolute sessionFile across store-root layouts for $sessionId", - ({ sessionId, sessionFileParts, wrongStorePathParts, message }) => { + async ({ sessionId, sessionFileParts, wrongStorePathParts, message }) => { const sessionFile = path.join(tmpDir, ...sessionFileParts); const wrongStorePath = path.join(tmpDir, ...wrongStorePathParts); fs.mkdirSync(path.dirname(sessionFile), { recursive: true }); @@ -1269,14 +1281,17 @@ describe("readSessionMessages", () => { "utf-8", ); - const out = readSessionMessages(sessionId, wrongStorePath, sessionFile); + const out = await readSessionMessagesAsync(sessionId, wrongStorePath, sessionFile, { + mode: "full", + reason: "test", + }); expect(out).toHaveLength(1); expectMessageFields(out[0], message); expect((out[0] as { __openclaw?: { seq?: number } })["__openclaw"]?.seq).toBe(1); }, ); - test("reads only the active SessionManager branch after a transcript rewrite", () => { + test("reads only the active SessionManager branch after a transcript rewrite", async () => { const sessionId = "branched-session"; const sessionManager = SessionManager.create(tmpDir, tmpDir); const decoratedPrompt = 'Sender:\n```json\n{"label":"ui"}\n```\n\nhello'; @@ -1309,7 +1324,10 @@ describe("readSessionMessages", () => { throw new Error("expected SessionManager to expose a session file"); } - const out = readSessionMessages(sessionId, storePath, sessionFile); + const out = await readSessionMessagesAsync(sessionId, storePath, sessionFile, { + mode: "full", + reason: "test", + }); expect( out.map((message) => ({ @@ -1322,7 +1340,7 @@ describe("readSessionMessages", () => { ]); }); - test("keeps compaction markers when reading only the active SessionManager branch", () => { + test("keeps compaction markers when reading only the active SessionManager branch", async () => { const sessionId = "branched-session-with-compaction"; const sessionFile = path.join(tmpDir, `${sessionId}.jsonl`); const lines = [ @@ -1364,7 +1382,10 @@ describe("readSessionMessages", () => { ]; fs.writeFileSync(sessionFile, lines.map((line) => JSON.stringify(line)).join("\n"), "utf-8"); - const out = readSessionMessages(sessionId, storePath, sessionFile); + const out = await readSessionMessagesAsync(sessionId, storePath, sessionFile, { + mode: "full", + reason: "test", + }); expect( out.map((message) => ({ @@ -1441,7 +1462,7 @@ describe("readSessionMessages", () => { }); }); - test("keeps blocked hook messages on the current active branch", () => { + test("keeps blocked hook messages on the current active branch", async () => { const sessionId = "blocked-hook-branch-session"; const sessionKey = "agent:main:explicit:blocked-hook-branch"; const sessionFile = path.join(tmpDir, `${sessionId}.jsonl`); @@ -1487,7 +1508,10 @@ describe("readSessionMessages", () => { expect(messageId).toBeTypeOf("string"); expect(messageId.length).toBeGreaterThan(0); - const out = readSessionMessages(sessionId, storePath, sessionFile); + const out = await readSessionMessagesAsync(sessionId, storePath, sessionFile, { + mode: "full", + reason: "test", + }); expect( out.map((message) => ({ role: (message as { role?: string }).role, @@ -1502,7 +1526,7 @@ describe("readSessionMessages", () => { expect(JSON.stringify(out)).not.toContain("matched original"); }); - test("keeps repeated blocked hook messages together in a new session", () => { + test("keeps repeated blocked hook messages together in a new session", async () => { const sessionKey = "agent:main:explicit:repeated-blocked-hook"; const sessionManager = SessionManager.create(tmpDir, tmpDir); const sessionId = sessionManager.getSessionId(); @@ -1533,7 +1557,10 @@ describe("readSessionMessages", () => { pluginId: "hitl-test-hooks", }); - const out = readSessionMessages(sessionId, storePath, sessionFile); + const out = await readSessionMessagesAsync(sessionId, storePath, sessionFile, { + mode: "full", + reason: "test", + }); expect( out.map((message) => ({ role: (message as { role?: string }).role, diff --git a/src/gateway/session-utils.fs.ts b/src/gateway/session-utils.fs.ts index fac9b71d2ea3..e7a52b0ae4c9 100644 --- a/src/gateway/session-utils.fs.ts +++ b/src/gateway/session-utils.fs.ts @@ -174,21 +174,6 @@ function readTranscriptMessageIdempotencyKey(message: unknown): string | undefin return typeof value === "string" && value.trim() ? value : undefined; } -/** Read all visible transcript messages for a session from the first existing candidate file. */ -export function readSessionMessages( - sessionId: string, - storePath: string | undefined, - sessionFile?: string, - agentId?: string, -): unknown[] { - const filePath = findExistingTranscriptPath(sessionId, storePath, sessionFile, agentId); - if (!filePath) { - return []; - } - - return transcriptRecordsToMessages(readSelectedTranscriptRecords(filePath)); -} - export type ReadRecentSessionMessagesOptions = { maxMessages: number; maxBytes?: number; @@ -384,32 +369,6 @@ function selectBoundedActiveTailRecords( }); } -function readTranscriptRecords(filePath: string): TailTranscriptRecord[] { - const records: TailTranscriptRecord[] = []; - visitTranscriptLines(filePath, (line) => { - if (!line.trim()) { - return; - } - const record = parseTailTranscriptRecord(line); - if (record && record.record.type !== "session") { - records.push(record); - } - }); - return records; -} - -function selectActiveTranscriptRecords(records: TailTranscriptRecord[]): TailTranscriptRecord[] { - return selectBoundedActiveTailRecords(records); -} - -function readSelectedTranscriptRecords(filePath: string): TailTranscriptRecord[] { - try { - return selectActiveTranscriptRecords(readTranscriptRecords(filePath)); - } catch { - return []; - } -} - function transcriptRecordsToMessages(records: TailTranscriptRecord[]): unknown[] { const messages: unknown[] = []; let messageSeq = 0; @@ -440,33 +399,6 @@ function parseRecentTranscriptTailSnapshot( }; } -function visitTranscriptLines(filePath: string, visit: (line: string) => void): void { - const fd = fs.openSync(filePath, "r"); - try { - const decoder = new StringDecoder("utf8"); - const buffer = Buffer.allocUnsafe(64 * 1024); - let carry = ""; - while (true) { - const bytesRead = fs.readSync(fd, buffer, 0, buffer.length, null); - if (bytesRead <= 0) { - break; - } - const text = carry + decoder.write(buffer.subarray(0, bytesRead)); - const lines = text.split(/\r?\n/); - carry = lines.pop() ?? ""; - for (const line of lines) { - visit(line); - } - } - const tail = carry + decoder.end(); - if (tail) { - visit(tail); - } - } finally { - fs.closeSync(fd); - } -} - async function visitTranscriptLinesAsync( filePath: string, visit: (line: string) => void, diff --git a/src/gateway/sessions-history-http.test.ts b/src/gateway/sessions-history-http.test.ts index df6045d633a2..4ba2c22eeb6a 100644 --- a/src/gateway/sessions-history-http.test.ts +++ b/src/gateway/sessions-history-http.test.ts @@ -542,8 +542,13 @@ describe("session history HTTP endpoints", () => { throw new Error(`append failed: ${appended.reason}`); } emitSessionTranscriptUpdate({ - sessionFile: appended.sessionFile, + sessionFile: appended.target.sessionKey, sessionKey: "agent:main:main", + target: { + agentId: appended.target.agentId ?? "main", + sessionId: appended.target.sessionId, + sessionKey: appended.target.sessionKey, + }, message: activeMessage, messageId: appended.messageId, messageSeq: 2, diff --git a/src/gateway/sessions-history-http.ts b/src/gateway/sessions-history-http.ts index 205b3789607f..b3f00454a643 100644 --- a/src/gateway/sessions-history-http.ts +++ b/src/gateway/sessions-history-http.ts @@ -233,7 +233,7 @@ export async function handleSessionHistoryHttpRequest( resolveSessionTranscriptCandidates( entry.sessionId, target.storePath, - entry.sessionFile, + undefined, target.agentId, ) .map((candidate) => resolveTranscriptPathForComparison(candidate)) diff --git a/src/gateway/sessions-patch.ts b/src/gateway/sessions-patch.ts index 028ff4ad1694..4ad23131e6cd 100644 --- a/src/gateway/sessions-patch.ts +++ b/src/gateway/sessions-patch.ts @@ -34,6 +34,7 @@ import { resolveSupportedThinkingLevel, } from "../auto-reply/thinking.js"; import type { SessionEntry } from "../config/sessions.js"; +import { projectCanonicalSessionEntryShape } from "../config/sessions/store-entry-shape.js"; import type { OpenClawConfig } from "../config/types.openclaw.js"; import { normalizeExecTarget } from "../infra/exec-approvals.js"; import type { PluginMetadataSnapshot } from "../plugins/plugin-metadata-snapshot.types.js"; @@ -199,7 +200,9 @@ export async function projectSessionsPatchEntry(params: { return loadedModelCatalog; }; - const existing = params.existingEntry; + const existing = params.existingEntry + ? projectCanonicalSessionEntryShape(params.existingEntry as unknown as Record) + : undefined; // Existing entries without session ids are placeholder aliases; assigning an id makes them real. const next: SessionEntry = existing?.sessionId ? { @@ -209,7 +212,6 @@ export async function projectSessionsPatchEntry(params: { : { ...existing, sessionId: randomUUID(), - sessionFile: undefined, updatedAt: Math.max(existing?.updatedAt ?? 0, now), }; if (existing && !existing.sessionId) { diff --git a/src/gateway/test-helpers.server.ts b/src/gateway/test-helpers.server.ts index d423f02563c9..c561abda5604 100644 --- a/src/gateway/test-helpers.server.ts +++ b/src/gateway/test-helpers.server.ts @@ -14,8 +14,8 @@ import { resolveMainSessionKeyFromConfig, type SessionEntry } from "../config/se import { applySessionEntryLifecycleMutation, listSessionEntries, + replaceTranscriptEvents, } from "../config/sessions/session-accessor.js"; -import { formatSqliteSessionFileMarker } from "../config/sessions/sqlite-marker.js"; import { clearSessionStoreCacheForTest } from "../config/sessions/store-writer-state.js"; import type { SessionOrigin } from "../config/sessions/types.js"; import { resetAgentEventsForTest } from "../infra/agent-events.js"; @@ -220,6 +220,7 @@ export async function writeSessionStore(params: { lastTo?: string; lastAccountId?: string; lastThreadId?: string | number; + sessionFile?: string; } >; storePath?: string; @@ -231,6 +232,12 @@ export async function writeSessionStore(params: { throw new Error("writeSessionStore requires testState.sessionStorePath"); } const upsertsByAgentId = new Map>(); + const transcriptImports: Array<{ + agentId: string; + sessionId: string; + sessionKey: string; + transcriptPath: string; + }> = []; for (const [requestKey, entry] of Object.entries(params.entries)) { const rawKey = requestKey.trim(); if (typeof entry.sessionId !== "string" || entry.sessionId.trim().length === 0) { @@ -257,13 +264,18 @@ export async function writeSessionStore(params: { sessionKey: storeKey, entry: { ...canonicalEntry, - sessionFile: formatSqliteSessionFileMarker({ - agentId, - sessionId: entry.sessionId, - storePath, - }), }, }); + if (typeof entry.sessionFile === "string" && entry.sessionFile.trim()) { + transcriptImports.push({ + agentId, + sessionId: entry.sessionId, + sessionKey: storeKey, + transcriptPath: path.isAbsolute(entry.sessionFile) + ? entry.sessionFile + : path.join(path.dirname(storePath), entry.sessionFile), + }); + } upsertsByAgentId.set(agentId, upserts); } clearSessionStoreCacheForTest(); @@ -284,6 +296,26 @@ export async function writeSessionStore(params: { skipMaintenance: true, }); } + for (const transcriptImport of transcriptImports) { + const contents = await fs.readFile(transcriptImport.transcriptPath, "utf8").catch(() => ""); + if (!contents) { + continue; + } + const events = contents + .split(/\r?\n/) + .map((line) => line.trim()) + .filter(Boolean) + .map((line) => JSON.parse(line) as Record); + await replaceTranscriptEvents( + { + agentId: transcriptImport.agentId, + sessionId: transcriptImport.sessionId, + sessionKey: transcriptImport.sessionKey, + storePath, + }, + events, + ); + } clearSessionStoreCacheForTest(); } diff --git a/src/gateway/test/server-sessions.test-helpers.ts b/src/gateway/test/server-sessions.test-helpers.ts index 6c845959124b..d1769ea7aff5 100644 --- a/src/gateway/test/server-sessions.test-helpers.ts +++ b/src/gateway/test/server-sessions.test-helpers.ts @@ -551,12 +551,23 @@ export function sessionStoreEntry(sessionId: string, overrides: Partial JSON.stringify(entry)) + .join("\n"); + fsSync.writeFileSync(sessionFile, `${contents}\n`, "utf8"); +} + export async function createCheckpointFixture( dir: string, options: { legacyPreCompactionSnapshot?: boolean } = { legacyPreCompactionSnapshot: true }, ) { const { SessionManager } = await getSessionManagerModule(); - const session = SessionManager.create(dir, dir); + const session = SessionManager.inMemory(dir); const userMessage: UserMessage = { role: "user", content: "before compaction", @@ -591,10 +602,8 @@ export async function createCheckpointFixture( if (!preCompactionLeafId) { throw new Error("expected persisted session leaf before compaction"); } - const sessionFile = session.getSessionFile(); - if (!sessionFile) { - throw new Error("expected persisted session file"); - } + const sessionFile = path.join(dir, `${session.getSessionId()}.jsonl`); + writeSessionFixture(sessionFile, session); const legacyPreCompactionSnapshot = options.legacyPreCompactionSnapshot ?? true; const preCompactionSessionFile = legacyPreCompactionSnapshot ? path.join(dir, `${path.parse(sessionFile).name}.checkpoint-test.jsonl`) @@ -603,13 +612,14 @@ export async function createCheckpointFixture( fsSync.copyFileSync(sessionFile, preCompactionSessionFile); } const preCompactionSession = preCompactionSessionFile - ? SessionManager.open(preCompactionSessionFile, dir) + ? SessionManager.fromEntries(session.getPersistedEntries(), dir) : undefined; session.appendCompaction("checkpoint summary", preCompactionLeafId, 123, { ok: true }); const postCompactionLeafId = session.getLeafId(); if (!postCompactionLeafId) { throw new Error("expected post-compaction leaf"); } + writeSessionFixture(sessionFile, session); return { session, sessionId: session.getSessionId(), diff --git a/src/gateway/worker-environments/live-event-projection.ts b/src/gateway/worker-environments/live-event-projection.ts index 381a0452250e..5f403b4d15a0 100644 --- a/src/gateway/worker-environments/live-event-projection.ts +++ b/src/gateway/worker-environments/live-event-projection.ts @@ -5,7 +5,6 @@ import { sanitizeToolResult, } from "../../agents/embedded-agent-subscribe.tools.js"; import { normalizeToolName } from "../../agents/tool-policy.js"; -import { formatSqliteSessionFileMarker } from "../../config/sessions/sqlite-marker.js"; import { createTrajectoryRuntimeRecorder } from "../../trajectory/runtime.js"; export type WorkerLiveTrajectoryTarget = { @@ -51,16 +50,16 @@ export function createWorkerLiveTrajectoryRecorder(params: { runId: string; target: WorkerLiveTrajectoryTarget; }): WorkerLiveTrajectoryRecorder { - const agentId = params.target.agentId ?? "main"; return createTrajectoryRuntimeRecorder({ runId: params.runId, sessionId: params.target.sessionId, sessionKey: params.target.sessionKey, - sessionFile: formatSqliteSessionFileMarker({ - agentId, + sessionTarget: { + agentId: params.target.agentId ?? "main", sessionId: params.target.sessionId, + sessionKey: params.target.sessionKey, storePath: params.target.storePath, - }), + }, }); } diff --git a/src/gateway/worker-environments/transcript-commit.test.ts b/src/gateway/worker-environments/transcript-commit.test.ts index 98ddd2d9c0dc..56b1673c0576 100644 --- a/src/gateway/worker-environments/transcript-commit.test.ts +++ b/src/gateway/worker-environments/transcript-commit.test.ts @@ -143,7 +143,7 @@ describe("worker transcript commit application", () => { let root: string; let sessionsDir: string; let storePath: string; - let sessionFile: string; + let sessionTarget: Awaited>; let cfg: OpenClawConfig; let committer: WorkerTranscriptCommitter; let ledgerStore: WorkerTranscriptCommitStore; @@ -167,14 +167,12 @@ describe("worker transcript commit application", () => { updatedAt: 10, }, ); - sessionFile = ( - await resolveSessionTranscriptRuntimeTarget({ - agentId: "main", - sessionId: SESSION_ID, - sessionKey: SESSION_KEY, - storePath, - }) - ).sessionFile; + sessionTarget = await resolveSessionTranscriptRuntimeTarget({ + agentId: "main", + sessionId: SESSION_ID, + sessionKey: SESSION_KEY, + storePath, + }); const database = openOpenClawStateDatabase({ env: { OPENCLAW_STATE_DIR: path.join(root, "state") }, }); @@ -206,7 +204,7 @@ describe("worker transcript commit application", () => { expect(new Set(entryIds).size).toBe(3); expect(newLeafId).toBe(entryIds[2]); - const reopened = SessionManager.open(sessionFile); + const reopened = SessionManager.open(sessionTarget); expect(reopened.getLeafId()).toBe(newLeafId); expect(reopened.getEntries()).toEqual([ expect.objectContaining({ @@ -273,16 +271,6 @@ describe("worker transcript commit application", () => { storePath, }); expect(persistedEntry).toMatchObject({ sessionId: SESSION_ID }); - expect(persistedEntry?.sessionFile).toBe( - ( - await resolveSessionTranscriptRuntimeTarget({ - agentId: "main", - sessionId: SESSION_ID, - sessionKey: SESSION_KEY, - storePath, - }) - ).sessionFile, - ); expect(updates).toEqual( entryIds.map((entryId, index) => expect.objectContaining({ @@ -292,11 +280,11 @@ describe("worker transcript commit application", () => { messageSeq: index + 1, sessionKey: SESSION_KEY, sessionId: SESSION_ID, - target: { + target: expect.objectContaining({ agentId: "main", sessionId: SESSION_ID, sessionKey: SESSION_KEY, - }, + }), }), ), ); @@ -320,7 +308,7 @@ describe("worker transcript commit application", () => { if (!outcome.ok) { throw new Error(`expected user-only transcript commit, received ${outcome.reason}`); } - const reopened = SessionManager.open(sessionFile); + const reopened = SessionManager.open(sessionTarget); expect(reopened.getEntries()).toEqual([ expect.objectContaining({ id: outcome.result.newLeafId, @@ -353,7 +341,7 @@ describe("worker transcript commit application", () => { }); expect(stale).toEqual({ ok: false, reason: "stale-base-leaf" }); - const reopened = SessionManager.open(sessionFile); + const reopened = SessionManager.open(sessionTarget); expect(reopened.getEntries()).toHaveLength(3); expect(reopened.getLeafId()).toBe(first.result.newLeafId); }); @@ -373,7 +361,7 @@ describe("worker transcript commit application", () => { expect(first.ok).toBe(true); expect(replay).toEqual(first); expect(changed).toEqual({ ok: false, reason: "invalid-batch" }); - const reopened = SessionManager.open(sessionFile); + const reopened = SessionManager.open(sessionTarget); expect(reopened.getEntries()).toHaveLength(3); if (first.ok) { expect(reopened.getLeafId()).toBe(first.result.newLeafId); @@ -401,7 +389,7 @@ describe("worker transcript commit application", () => { await expect(interruptedCommitter.commit({ identity: IDENTITY, request })).rejects.toThrow( "simulated commit-result interruption", ); - const afterInterruption = SessionManager.open(sessionFile); + const afterInterruption = SessionManager.open(sessionTarget); const committedEntryIds = afterInterruption.getEntries().map((entry) => entry.id); expect(committedEntryIds).toHaveLength(request.messages.length); const laterLeafId = afterInterruption.appendMessage({ @@ -419,14 +407,14 @@ describe("worker transcript commit application", () => { newLeafId: committedEntryIds.at(-1), }, }); - const reopened = SessionManager.open(sessionFile); + const reopened = SessionManager.open(sessionTarget); expect(reopened.getEntries()).toHaveLength(request.messages.length + 1); expect(reopened.getLeafId()).toBe(laterLeafId); }); it("replays an interrupted terminal write after its branch is abandoned", async () => { cfg = { ...cfg }; - const initialManager = SessionManager.open(sessionFile); + const initialManager = SessionManager.open(sessionTarget); const baseLeafId = initialManager.appendMessage({ role: "user", content: [{ type: "text", text: "Local base" }], @@ -455,7 +443,7 @@ describe("worker transcript commit application", () => { await expect(interruptedCommitter.commit({ identity: IDENTITY, request })).rejects.toThrow( "simulated off-branch terminal interruption", ); - const afterInterruption = SessionManager.open(sessionFile); + const afterInterruption = SessionManager.open(sessionTarget); const committedEntries = afterInterruption .getEntries() .filter((entry) => entry.id !== baseLeafId); @@ -496,7 +484,7 @@ describe("worker transcript commit application", () => { newLeafId: committedEntryIds.at(-1), }, }); - const reopened = SessionManager.open(sessionFile); + const reopened = SessionManager.open(sessionTarget); expect(reopened.getBranch().map((entry) => entry.id)).toEqual([baseLeafId, localLeafId]); if (!replay.ok) { throw new Error(`expected interrupted commit replay, received ${replay.reason}`); @@ -506,7 +494,7 @@ describe("worker transcript commit application", () => { }); it("rejects ambiguous persisted recovery without appending or publishing", async () => { - const initialManager = SessionManager.open(sessionFile); + const initialManager = SessionManager.open(sessionTarget); const baseLeafId = initialManager.appendMessage({ role: "user", content: [{ type: "text", text: "Local base" }], @@ -531,7 +519,7 @@ describe("worker transcript commit application", () => { await expect(interruptedCommitter.commit({ identity: IDENTITY, request })).rejects.toThrow( "simulated ambiguous terminal interruption", ); - const manager = SessionManager.open(sessionFile); + const manager = SessionManager.open(sessionTarget); const originalEntries = manager.getEntries().filter((entry) => entry.id !== baseLeafId); expect(originalEntries).toHaveLength(request.messages.length); manager.branch(baseLeafId); @@ -556,7 +544,7 @@ describe("worker transcript commit application", () => { const replay = await committer.commit({ identity: IDENTITY, request }); expect(replay).toEqual({ ok: false, reason: "invalid-batch" }); - const reopened = SessionManager.open(sessionFile); + const reopened = SessionManager.open(sessionTarget); expect(reopened.getEntries()).toHaveLength(entryCountBeforeRetry); expect(reopened.getBranch().map((entry) => entry.id)).toEqual([baseLeafId, localLeafId]); expect(updates).toEqual([]); @@ -597,7 +585,7 @@ describe("worker transcript commit application", () => { } finally { appendSpy.mockRestore(); } - expect(SessionManager.open(sessionFile).getEntries()).toEqual([]); + expect(SessionManager.open(sessionTarget).getEntries()).toEqual([]); const entryAfterFailure = loadSessionEntry({ agentId: "main", sessionKey: SESSION_KEY, @@ -605,7 +593,7 @@ describe("worker transcript commit application", () => { }); expect(entryAfterFailure).toEqual(entryBeforeFailure); - const manager = SessionManager.open(sessionFile); + const manager = SessionManager.open(sessionTarget); const localLeafId = manager.appendMessage({ role: "user", content: [{ type: "text", text: "Local activity after interruption" }], @@ -614,7 +602,7 @@ describe("worker transcript commit application", () => { const retry = await committer.commit({ identity: IDENTITY, request }); expect(retry).toEqual({ ok: false, reason: "stale-base-leaf" }); - const reopened = SessionManager.open(sessionFile); + const reopened = SessionManager.open(sessionTarget); expect(reopened.getEntries()).toEqual([ expect.objectContaining({ id: localLeafId, @@ -628,7 +616,7 @@ describe("worker transcript commit application", () => { if (!first.ok) { throw new Error(`expected initial transcript commit success, received ${first.reason}`); } - const manager = SessionManager.open(sessionFile); + const manager = SessionManager.open(sessionTarget); const abandonedMessage: Parameters[0] & { idempotencyKey: string; } = { @@ -665,7 +653,7 @@ describe("worker transcript commit application", () => { throw new Error(`expected branch-safe transcript commit, received ${outcome.reason}`); } expect(outcome.result.newLeafId).not.toBe(abandonedId); - const reopened = SessionManager.open(sessionFile); + const reopened = SessionManager.open(sessionTarget); expect(reopened.getLeafId()).toBe(outcome.result.newLeafId); expect(reopened.getEntry(outcome.result.newLeafId)).toMatchObject({ parentId: activeLeafId, @@ -705,7 +693,7 @@ describe("worker transcript commit application", () => { expect(second.result.entryIds).toHaveLength(1); expect(second.result.newLeafId).toBe(second.result.entryIds[0]); expect(second.result.newLeafId).not.toBe(first.result.newLeafId); - const reopened = SessionManager.open(sessionFile); + const reopened = SessionManager.open(sessionTarget); expect(reopened.getEntries().at(-1)).toMatchObject({ id: second.result.newLeafId, parentId: first.result.newLeafId, diff --git a/src/gateway/worker-environments/transcript-commit.ts b/src/gateway/worker-environments/transcript-commit.ts index 2d1c69e80b9f..e93e506e21ff 100644 --- a/src/gateway/worker-environments/transcript-commit.ts +++ b/src/gateway/worker-environments/transcript-commit.ts @@ -319,13 +319,13 @@ async function applyWorkerTranscriptCommit(params: { const redactedMessages = params.messages.map( (message) => redactTranscriptMessage(message, params.config) as CommittedAgentMessage, ); - const applied = await withTranscriptWriteTransaction(params.target, ({ sessionFile }) => { + const applied = await withTranscriptWriteTransaction(params.target, (transcriptTarget) => { const currentEntry = loadSessionEntry(params.target); if (!currentEntry || currentEntry.sessionId !== params.sessionId) { return { ok: false as const, reason: "session-not-attached" as const }; } - const manager = SessionManager.open(sessionFile); + const manager = SessionManager.open(transcriptTarget); if (params.recoverPersistedBatch) { // Only a pending ledger row may prove an off-branch batch: the agent DB // can commit before the shared replay ledger records its terminal result. @@ -375,7 +375,6 @@ async function applyWorkerTranscriptCommit(params: { const appendedCount = messages.filter((message) => message.appended).length; const nextEntry = { ...freshEntry, - sessionFile, ...(appendedCount > 0 ? { updatedAt: Math.max(freshEntry.updatedAt ?? 0, Date.now()) } : {}), }; replaceSessionEntrySync(params.target, nextEntry); diff --git a/src/gateway/worker-environments/worker-turn-launcher.test.ts b/src/gateway/worker-environments/worker-turn-launcher.test.ts index 2de238ca880a..ff50defd18f2 100644 --- a/src/gateway/worker-environments/worker-turn-launcher.test.ts +++ b/src/gateway/worker-environments/worker-turn-launcher.test.ts @@ -8,6 +8,7 @@ import { makeAgentAssistantMessage, makeAgentUserMessage, } from "../../agents/test-helpers/agent-message-fixtures.js"; +import { upsertSessionEntry } from "../../config/sessions/session-accessor.js"; import { type AgentEventPayload, onAgentEvent as subscribeAgentEvent, @@ -31,6 +32,7 @@ import { import { createWorkerSessionPlacementGate } from "./placement-worker-gate.js"; import type { WorkerTunnelHandle } from "./tunnel-contract.js"; import { createWorkerSessionTurnPlacementProvider as createRawWorkerSessionTurnPlacementProvider } from "./worker-turn-launcher.js"; +import { resolveWorkerTurnTranscriptTarget } from "./worker-turn-transcript-target.js"; type WorkerTurnLauncherOptions = Parameters[0]; type WorkerTurnEnvironmentService = WorkerTurnLauncherOptions["environments"]; @@ -52,21 +54,79 @@ function hasLoneSurrogate(value: string): boolean { } describe("worker turn launcher", () => { + it("rejects a transcript target without a session incarnation", () => { + expect(() => + resolveWorkerTurnTranscriptTarget({ + sessionId: "current-session", + sessionTarget: { + agentId: "main", + sessionKey: "agent:main:main", + storePath: "/tmp/sessions.json", + }, + }), + ).toThrow("missing its transcript identity"); + }); + + it("rejects a transcript target from another session incarnation", () => { + expect(() => + resolveWorkerTurnTranscriptTarget({ + sessionId: "current-session", + sessionTarget: { + agentId: "main", + sessionId: "stale-session", + sessionKey: "agent:main:main", + storePath: "/tmp/sessions.json", + }, + }), + ).toThrow("transcript identity does not match the active turn"); + }); + + it.each([ + ["agent", { agentId: "other", sessionKey: "agent:main:main" }], + ["session key", { agentId: "main", sessionKey: "agent:main:other" }], + ["target key agent", { agentId: "main", sessionKey: "agent:other:main" }], + ])("rejects a transcript target with a different %s", (_label, identity) => { + expect(() => + resolveWorkerTurnTranscriptTarget({ + agentId: "main", + sessionId: "current-session", + sessionKey: "agent:main:main", + sessionTarget: { + ...identity, + sessionId: "current-session", + storePath: "/tmp/sessions.json", + }, + }), + ).toThrow("transcript identity does not match the active turn"); + }); + let root: string; let database: OpenClawStateDatabase; let placements: WorkerSessionPlacementStore; let sessionFile: string; + let sessionTarget: { + agentId: string; + sessionId: string; + sessionKey: string; + storePath: string; + }; beforeEach(async () => { root = await fs.mkdtemp(path.join(await fs.realpath(os.tmpdir()), "openclaw-worker-turn-")); database = openOpenClawStateDatabase({ env: { OPENCLAW_STATE_DIR: root } }); placements = createWorkerSessionPlacementStore({ database }); - const manager = SessionManager.create(path.join(root, "sessions"), path.join(root, "sessions")); - const file = manager.getSessionFile(); - if (!file) { - throw new Error("expected file-backed session manager"); - } - sessionFile = file; + sessionTarget = { + agentId: "main", + sessionId: SESSION_ID, + sessionKey: SESSION_KEY, + storePath: path.join(root, "sessions.json"), + }; + await upsertSessionEntry(sessionTarget, { + sessionId: SESSION_ID, + updatedAt: Date.now(), + }); + SessionManager.open(sessionTarget); + sessionFile = SESSION_KEY; }); afterEach(async () => { @@ -84,6 +144,26 @@ describe("worker turn launcher", () => { }); } + function openSessionManager() { + return SessionManager.open(sessionTarget); + } + + it("rejects a transcript target after its session key is rebound", async () => { + await upsertSessionEntry(sessionTarget, { + sessionId: "replacement-session", + updatedAt: Date.now() + 1, + }); + + expect(() => + resolveWorkerTurnTranscriptTarget({ + agentId: "main", + sessionId: SESSION_ID, + sessionKey: SESSION_KEY, + sessionTarget, + }), + ).toThrow("transcript identity is no longer current"); + }); + function seedActivePlacement(): void { let placement = placements.startDispatch({ sessionId: SESSION_ID, @@ -228,6 +308,7 @@ describe("worker turn launcher", () => { sessionKey: SESSION_KEY, agentId: "main", sessionFile, + sessionTarget, workspaceDir: root, prompt: "Inspect this workspace", timeoutMs: 5_000, @@ -490,7 +571,7 @@ describe("worker turn launcher", () => { }); expect(initialized.code).toBe(0); seedActivePlacement(); - const manager = SessionManager.open(sessionFile); + const manager = openSessionManager(); const earlierRequestId = manager.appendMessage( makeAgentUserMessage({ content: "Earlier request", timestamp: 10 }), ); @@ -565,7 +646,7 @@ describe("worker turn launcher", () => { expect(command.argv.join(" ")).not.toContain(credential().credential); await Promise.resolve(); expect(acknowledgeCredentialDelivery).toHaveBeenCalledOnce(); - const completed = SessionManager.open(sessionFile); + const completed = openSessionManager(); const leafId = completed.appendMessage( makeAgentAssistantMessage({ content: [{ type: "text", text: "Worker reply" }], @@ -577,14 +658,14 @@ describe("worker turn launcher", () => { environmentId: ENVIRONMENT_ID, ownerEpoch: OWNER_EPOCH, runId: "run-worker-turn", - transcriptSeq: 1, + transcriptSeq: 2, workspaceResultPending: true, }); return { stdout: JSON.stringify({ status: "completed", transcriptLeafId: leafId, - transcriptNextSeq: 2, + transcriptNextSeq: (placements.get(SESSION_ID)?.lastTranscriptAckCursor ?? 0) + 1, }), stderr: "", code: 0, @@ -659,7 +740,7 @@ describe("worker turn launcher", () => { }, }); expect( - SessionManager.open(sessionFile) + openSessionManager() .getBranch() .some( (entry) => @@ -709,7 +790,7 @@ describe("worker turn launcher", () => { }, ]); expect( - SessionManager.open(sessionFile) + openSessionManager() .getEntries() .flatMap((entry) => entry.type === "message" && entry.message.role === "user" ? [entry.message.content] : [], @@ -719,7 +800,7 @@ describe("worker turn launcher", () => { it("does not replay an already-persisted current user message into worker history", async () => { seedActivePlacement(); - const manager = SessionManager.open(sessionFile); + const manager = openSessionManager(); manager.appendMessage(makeAgentUserMessage({ content: "Earlier request", timestamp: 18 })); manager.appendMessage( makeAgentAssistantMessage({ @@ -741,7 +822,7 @@ describe("worker turn launcher", () => { })), runWorkspaceCommand: vi.fn(async (command): Promise => { descriptor = parseWorkerLaunchDescriptor(JSON.parse(command.input ?? "")); - const completed = SessionManager.open(sessionFile); + const completed = openSessionManager(); const leafId = completed.appendMessage( makeAgentAssistantMessage({ content: [{ type: "text", text: "Worker reply" }], @@ -753,14 +834,14 @@ describe("worker turn launcher", () => { environmentId: ENVIRONMENT_ID, ownerEpoch: OWNER_EPOCH, runId: "run-persisted-user", - transcriptSeq: 1, + transcriptSeq: 2, workspaceResultPending: true, }); return { stdout: JSON.stringify({ status: "completed", transcriptLeafId: leafId, - transcriptNextSeq: 2, + transcriptNextSeq: (placements.get(SESSION_ID)?.lastTranscriptAckCursor ?? 0) + 1, }), stderr: "", code: 0, @@ -812,10 +893,7 @@ describe("worker turn launcher", () => { { role: "user" }, { role: "assistant" }, ]); - const persistedEntries = (await fs.readFile(sessionFile, "utf8")) - .split("\n") - .filter(Boolean) - .map((line) => JSON.parse(line) as unknown); + const persistedEntries = openSessionManager().getEntries(); const persistedCurrentUsers = persistedEntries.filter((entry) => { if (typeof entry !== "object" || entry === null || !("message" in entry)) { return false; @@ -860,7 +938,7 @@ describe("worker turn launcher", () => { resume: vi.fn(async () => {}), })), runWorkspaceCommand: vi.fn(async (): Promise => { - const completed = SessionManager.open(sessionFile); + const completed = openSessionManager(); completed.appendMessage( makeAgentAssistantMessage({ content: [{ type: "toolCall", id: "call-usage", name: "read", arguments: {} }], @@ -912,14 +990,14 @@ describe("worker turn launcher", () => { environmentId: ENVIRONMENT_ID, ownerEpoch: OWNER_EPOCH, runId: "run-worker-usage", - transcriptSeq: 1, + transcriptSeq: 2, workspaceResultPending: true, }); return { stdout: JSON.stringify({ status: "completed", transcriptLeafId: leafId, - transcriptNextSeq: 2, + transcriptNextSeq: (placements.get(SESSION_ID)?.lastTranscriptAckCursor ?? 0) + 1, }), stderr: "", code: 0, @@ -1545,7 +1623,7 @@ describe("worker turn launcher", () => { ).rejects.toThrow("already has an active turn claim"); expect(runWorkspaceCommand).toHaveBeenCalledOnce(); - const completed = SessionManager.open(sessionFile); + const completed = openSessionManager(); const leafId = completed.appendMessage( makeAgentAssistantMessage({ content: [{ type: "text", text: "Only worker reply" }], @@ -1557,7 +1635,7 @@ describe("worker turn launcher", () => { environmentId: ENVIRONMENT_ID, ownerEpoch: OWNER_EPOCH, runId: "run-overlap", - transcriptSeq: 1, + transcriptSeq: 2, workspaceResultPending: true, }); const active = placements.get(SESSION_ID); @@ -1576,7 +1654,7 @@ describe("worker turn launcher", () => { stdout: JSON.stringify({ status: "completed", transcriptLeafId: leafId, - transcriptNextSeq: 2, + transcriptNextSeq: (placements.get(SESSION_ID)?.lastTranscriptAckCursor ?? 0) + 1, }), stderr: "", code: 0, @@ -1630,7 +1708,7 @@ describe("worker turn launcher", () => { termination: "exit", }; } - const completed = SessionManager.open(sessionFile); + const completed = openSessionManager(); const leafId = completed.appendMessage( makeAgentAssistantMessage({ content: [{ type: "text", text: "Recovered worker reply" }], @@ -1642,14 +1720,14 @@ describe("worker turn launcher", () => { environmentId: ENVIRONMENT_ID, ownerEpoch: OWNER_EPOCH, runId: "run-model-recovered", - transcriptSeq: 1, + transcriptSeq: 2, workspaceResultPending: true, }); return { stdout: JSON.stringify({ status: "completed", transcriptLeafId: leafId, - transcriptNextSeq: 2, + transcriptNextSeq: (placements.get(SESSION_ID)?.lastTranscriptAckCursor ?? 0) + 1, }), stderr: "", code: 0, @@ -1732,7 +1810,7 @@ describe("worker turn launcher", () => { state: "active", turnClaim: { owner: "worker", runId }, }); - const completed = SessionManager.open(sessionFile); + const completed = openSessionManager(); const leafId = completed.appendMessage( makeAgentAssistantMessage({ content: [{ type: "text", text: "Redispatched worker reply" }], @@ -1744,14 +1822,14 @@ describe("worker turn launcher", () => { environmentId: ENVIRONMENT_ID, ownerEpoch: OWNER_EPOCH, runId, - transcriptSeq: 1, + transcriptSeq: 2, workspaceResultPending: true, }); return { stdout: JSON.stringify({ status: "completed", transcriptLeafId: leafId, - transcriptNextSeq: 2, + transcriptNextSeq: (placements.get(SESSION_ID)?.lastTranscriptAckCursor ?? 0) + 1, }), stderr: "", code: 0, diff --git a/src/gateway/worker-environments/worker-turn-launcher.ts b/src/gateway/worker-environments/worker-turn-launcher.ts index 1c395bdb8867..f83ad419e1d6 100644 --- a/src/gateway/worker-environments/worker-turn-launcher.ts +++ b/src/gateway/worker-environments/worker-turn-launcher.ts @@ -35,6 +35,7 @@ import { parseRuntimeResult, windowInitialMessages, } from "./worker-turn-payload.js"; +import { resolveWorkerTurnTranscriptTarget } from "./worker-turn-transcript-target.js"; import { formatWorkspaceConflictSummary, WORKSPACE_CONFLICT_CLEARED_TRANSCRIPT_TYPE, @@ -236,7 +237,8 @@ async function executeWorkerTurn(params: { const startedAt = Date.now(); turn.onExecutionStarted?.({ lifecycleGeneration: turn.lifecycleGeneration }); turn.onExecutionPhase?.({ phase: "runner_entered", backend: "cloud-worker" }); - const manager = SessionManager.open(turn.sessionFile); + const transcriptTarget = resolveWorkerTurnTranscriptTarget(turn); + const manager = SessionManager.open(transcriptTarget); const userMessageAlreadyPersisted = turn.suppressNextUserMessagePersistence === true || turn.userTurnTranscriptRecorder?.hasPersisted() === true; @@ -257,7 +259,7 @@ async function executeWorkerTurn(params: { turn.userTurnTranscriptRecorder?.markRuntimePersisted(persisted.message); turn.onUserMessagePersisted?.(persisted.message); } else if (turn.userTurnTranscriptRecorder?.hasPersisted()) { - baseLeafId = SessionManager.open(turn.sessionFile).getLeafId(); + baseLeafId = SessionManager.open(transcriptTarget).getLeafId(); } else if (!turn.userTurnTranscriptRecorder) { const message = { role: "user" as const, @@ -375,13 +377,17 @@ async function executeWorkerTurn(params: { throw new WorkerTurnExecutionError("Cloud worker turn failed"); } - const completed = SessionManager.open(turn.sessionFile); + const completed = SessionManager.open(transcriptTarget); const currentPlacement = params.placements.get(placement.sessionId); if ( runtimeResult.transcriptLeafId !== completed.getLeafId() || runtimeResult.transcriptNextSeq !== (currentPlacement?.lastTranscriptAckCursor ?? 0) + 1 ) { - throw new Error("Cloud worker result does not match its committed transcript acknowledgement"); + throw new Error( + `Cloud worker result does not match its committed transcript acknowledgement ` + + `(leaf=${runtimeResult.transcriptLeafId ?? "none"}/${completed.getLeafId() ?? "none"}, ` + + `nextSeq=${runtimeResult.transcriptNextSeq}/${(currentPlacement?.lastTranscriptAckCursor ?? 0) + 1})`, + ); } if ( (currentPlacement?.state !== "active" && currentPlacement?.state !== "draining") || @@ -468,7 +474,7 @@ async function executeWorkerTurn(params: { root: params.localWorkspaceDir, report: async (report) => { if ("cleared" in report) { - SessionManager.open(turn.sessionFile).appendCustomMessageEntry( + SessionManager.open(transcriptTarget).appendCustomMessageEntry( WORKSPACE_CONFLICT_CLEARED_TRANSCRIPT_TYPE, "A later cloud workspace result superseded the previous conflict.", false, @@ -483,7 +489,7 @@ async function executeWorkerTurn(params: { report.totalCount, ), }; - SessionManager.open(turn.sessionFile).appendCustomMessageEntry( + SessionManager.open(transcriptTarget).appendCustomMessageEntry( WORKSPACE_CONFLICT_TRANSCRIPT_TYPE, workspaceConflict.summary, true, diff --git a/src/gateway/worker-environments/worker-turn-transcript-target.ts b/src/gateway/worker-environments/worker-turn-transcript-target.ts new file mode 100644 index 000000000000..d0ba150db839 --- /dev/null +++ b/src/gateway/worker-environments/worker-turn-transcript-target.ts @@ -0,0 +1,41 @@ +import type { SessionPlacementTurnParams } from "../../agents/session-placement-admission.js"; +import { loadSessionEntry } from "../../config/sessions/session-accessor.js"; +import { parseAgentSessionKey } from "../../routing/session-key.js"; + +export function resolveWorkerTurnTranscriptTarget( + turn: Pick, +): { agentId: string; sessionId: string; sessionKey: string; storePath: string } { + if ( + !turn.sessionTarget?.agentId || + !turn.sessionTarget.sessionId || + !turn.sessionTarget.sessionKey || + !turn.sessionTarget.storePath + ) { + throw new Error("Cloud worker turn is missing its transcript identity"); + } + if (turn.sessionTarget.sessionId !== turn.sessionId) { + throw new Error("Cloud worker transcript identity does not match the active turn"); + } + const targetKeyAgentId = parseAgentSessionKey(turn.sessionTarget.sessionKey)?.agentId; + if ( + (turn.agentId && turn.sessionTarget.agentId !== turn.agentId) || + (turn.sessionKey && turn.sessionTarget.sessionKey !== turn.sessionKey) || + (targetKeyAgentId && targetKeyAgentId !== turn.sessionTarget.agentId) + ) { + throw new Error("Cloud worker transcript identity does not match the active turn"); + } + const currentEntry = loadSessionEntry({ + agentId: turn.sessionTarget.agentId, + sessionKey: turn.sessionTarget.sessionKey, + storePath: turn.sessionTarget.storePath, + }); + if (currentEntry?.sessionId !== turn.sessionId) { + throw new Error("Cloud worker transcript identity is no longer current"); + } + return { + agentId: turn.sessionTarget.agentId, + sessionId: turn.sessionId, + sessionKey: turn.sessionTarget.sessionKey, + storePath: turn.sessionTarget.storePath, + }; +} diff --git a/src/gateway/worker-workspace-conflict-transcript.ts b/src/gateway/worker-workspace-conflict-transcript.ts index b09945de5f41..b550e1bb91b9 100644 --- a/src/gateway/worker-workspace-conflict-transcript.ts +++ b/src/gateway/worker-workspace-conflict-transcript.ts @@ -41,8 +41,10 @@ export function createWorkerWorkspaceConflictTranscriptHandlers( sessionKey: target.canonicalKey, storePath: target.storePath, }, - ({ sessionFile }) => { - for (const transcriptEntry of SessionManager.open(sessionFile).getBranch().toReversed()) { + (transcriptTarget) => { + for (const transcriptEntry of SessionManager.open(transcriptTarget) + .getBranch() + .toReversed()) { if (transcriptEntry.type !== "custom_message") { continue; } @@ -107,8 +109,8 @@ export function createWorkerWorkspaceConflictTranscriptHandlers( sessionKey: target.canonicalKey, storePath: target.storePath, }, - ({ sessionFile }) => { - const manager = SessionManager.open(sessionFile); + (transcriptTarget) => { + const manager = SessionManager.open(transcriptTarget); const latestConflictEntry = manager .getBranch() .toReversed() diff --git a/src/hooks/bundled/session-memory/handler.test.ts b/src/hooks/bundled/session-memory/handler.test.ts index 54d34de9fed8..978ae88742b7 100644 --- a/src/hooks/bundled/session-memory/handler.test.ts +++ b/src/hooks/bundled/session-memory/handler.test.ts @@ -5,13 +5,16 @@ import path from "node:path"; import { expectDefined } from "@openclaw/normalization-core"; import { afterAll, beforeAll, describe, expect, it, vi } from "vitest"; import type { OpenClawConfig } from "../../../config/config.js"; +import { + formatSqliteSessionFileMarker, + parseSqliteSessionFileMarker, +} from "../../../config/sessions/legacy-sqlite-marker.js"; import { replaceTranscriptEvents } from "../../../config/sessions/session-accessor.js"; -import { formatSqliteSessionFileMarker } from "../../../config/sessions/sqlite-marker.js"; import { writeWorkspaceFile } from "../../../test-helpers/workspace.js"; import { withEnvAsync } from "../../../test-utils/env.js"; import { createInternalHookEvent as createHookEvent } from "../../internal-hooks.js"; import { generateSlugViaLLM } from "../../llm-slug-generator.js"; -import { findPreviousSessionFile, getRecentSessionContentWithResetFallback } from "./transcript.js"; +import { getRecentSessionContentFromEvents } from "./transcript.js"; // Avoid calling the embedded OpenClaw agent (global command lane); keep this unit test deterministic. vi.mock("../../llm-slug-generator.js", () => ({ @@ -29,6 +32,71 @@ vi.mock("../../../logging/subsystem.js", () => ({ createSubsystemLogger: () => loggerMocks, })); +async function readFileTranscript(filePath: string, messageCount = 15): Promise { + try { + const content = await fs.readFile(filePath, "utf8"); + return getRecentSessionContentFromEvents( + content + .trim() + .split("\n") + .flatMap((line) => { + try { + return [JSON.parse(line) as unknown]; + } catch { + return []; + } + }), + messageCount, + ); + } catch { + return null; + } +} + +async function getRecentSessionContentWithResetFallback( + filePath: string, + messageCount = 15, +): Promise { + const active = await readFileTranscript(filePath, messageCount); + if (active) { + return active; + } + const candidates = (await fs.readdir(path.dirname(filePath))) + .filter((name) => name.startsWith(`${path.basename(filePath)}.reset.`)) + .toSorted(); + const latest = candidates.at(-1); + return latest + ? await readFileTranscript(path.join(path.dirname(filePath), latest), messageCount) + : active; +} + +async function findPreviousSessionFile(params: { + sessionsDir: string; + currentSessionFile?: string; + sessionId?: string; +}): Promise { + const files = await fs.readdir(params.sessionsDir); + const currentName = params.currentSessionFile + ? path.basename(params.currentSessionFile) + : undefined; + if (currentName?.includes(".reset.") && files.includes(currentName)) { + return path.join(params.sessionsDir, currentName); + } + const sessionId = params.sessionId?.trim(); + if (!sessionId) { + return undefined; + } + const activeName = `${sessionId}.jsonl`; + if (files.includes(activeName)) { + return path.join(params.sessionsDir, activeName); + } + const reset = files + .filter((name) => name.startsWith(`${activeName}.reset.`)) + .toSorted() + .at(-1); + return reset ? path.join(params.sessionsDir, reset) : undefined; +} + let handler: typeof import("./handler.js").default; let flushSessionMemoryWritesForTest: typeof import("./handler.js").flushSessionMemoryWritesForTest; let suiteWorkspaceRoot = ""; @@ -87,17 +155,55 @@ async function runNewWithPreviousSessionEntry(params: { workspaceDirOverride?: string; timestamp?: Date; }): Promise<{ files: string[]; memoryContent: string }> { + const baseConfig = + params.cfg ?? + ({ + agents: { defaults: { workspace: params.tempDir } }, + } satisfies OpenClawConfig); + const legacySessionFile = params.previousSessionEntry.sessionFile; + const marker = parseSqliteSessionFileMarker(legacySessionFile); + const storePath = + marker?.storePath ?? baseConfig.session?.store ?? path.join(params.tempDir, "sessions.json"); + if (legacySessionFile && !marker) { + const content = await fs.readFile(legacySessionFile, "utf8").catch(() => ""); + if (content) { + let parentId: string | null = null; + const events = content + .split(/\r?\n/) + .map((line) => line.trim()) + .filter(Boolean) + .map((line, index) => { + const event = JSON.parse(line) as Record; + const id = typeof event.id === "string" ? event.id : `fixture-${index + 1}`; + const normalized = Object.assign(event, { id }); + if (!Object.hasOwn(normalized, "parentId")) { + normalized.parentId = parentId; + } + parentId = id; + return normalized; + }); + await replaceTranscriptEvents( + { + agentId: "main", + sessionId: params.previousSessionEntry.sessionId, + sessionKey: params.sessionKey ?? "agent:main:main", + storePath, + }, + events, + ); + } + } + const cfg = { + ...baseConfig, + session: { ...baseConfig.session, store: storePath }, + } satisfies OpenClawConfig; const event = createHookEvent( "command", params.action ?? "new", params.sessionKey ?? "agent:main:main", { - cfg: - params.cfg ?? - ({ - agents: { defaults: { workspace: params.tempDir } }, - } satisfies OpenClawConfig), - previousSessionEntry: params.previousSessionEntry, + cfg, + previousSessionEntry: { sessionId: params.previousSessionEntry.sessionId }, ...(params.workspaceDirOverride ? { workspaceDir: params.workspaceDirOverride } : {}), }, ); @@ -172,28 +278,23 @@ async function createSessionMemoryWorkspace(params?: { return { tempDir, sessionsDir, activeSessionFile }; } -async function writeSessionTranscript(params: { - name: string; - content: string; -}): Promise<{ tempDir: string; sessionsDir: string; sessionFile: string }> { - const { tempDir, sessionsDir } = await createSessionMemoryWorkspace(); - const sessionFile = await writeWorkspaceFile({ - dir: sessionsDir, - name: params.name, - content: params.content, - }); - return { tempDir, sessionsDir, sessionFile }; -} - async function readSessionTranscript(params: { sessionContent: string; messageCount?: number; }): Promise { - const { sessionFile } = await writeSessionTranscript({ - name: "test-session.jsonl", - content: params.sessionContent, - }); - return getRecentSessionContentWithResetFallback(sessionFile, params.messageCount); + return getRecentSessionContentFromEvents( + params.sessionContent + .trim() + .split("\n") + .flatMap((line) => { + try { + return [JSON.parse(line) as unknown]; + } catch { + return []; + } + }), + params.messageCount, + ); } function expectMemoryConversation(params: { @@ -320,6 +421,65 @@ describe("session-memory hook", () => { expect(memoryContent).not.toContain("Inactive branch content"); }); + it("fills the configured memory window past ineligible tail messages", async () => { + const tempDir = await createCaseWorkspace("workspace"); + const storePath = path.join(tempDir, "sessions.json"); + const sessionId = "sqlite-filtered-tail"; + const sessionKey = "agent:main:main"; + const events: Array> = [ + { + type: "message", + id: "kept-user", + parentId: null, + message: { role: "user", content: "Keep this user context" }, + }, + { + type: "message", + id: "kept-assistant", + parentId: "kept-user", + message: { role: "assistant", content: "Keep this assistant context" }, + }, + ]; + let parentId = "kept-assistant"; + for (let index = 0; index < 20; index += 1) { + const id = `tool-result-${index}`; + events.push({ + type: "message", + id, + parentId, + message: { role: "toolResult", content: `ignored tool result ${index}` }, + }); + parentId = id; + } + events.push({ + type: "message", + id: "no-reply-tail", + parentId, + message: { role: "assistant", content: "NO_REPLY" }, + }); + await replaceTranscriptEvents({ agentId: "main", sessionId, sessionKey, storePath }, events); + + const { memoryContent } = await runNewWithPreviousSessionEntry({ + tempDir, + sessionKey, + cfg: { + agents: { defaults: { workspace: tempDir } }, + hooks: { + internal: { + entries: { "session-memory": { enabled: true, messages: 2 } }, + }, + }, + session: { store: storePath }, + }, + previousSessionEntry: { sessionId }, + }); + + expect(memoryContent).toContain("user: Keep this user context"); + expect(memoryContent).toContain("assistant: Keep this assistant context"); + expect(memoryContent).not.toContain("ignored tool result"); + expect(memoryContent).not.toContain("NO_REPLY"); + }); + it("sanitizes model artifacts before writing session memory", async () => { const sessionContent = createMockSessionContent([ { role: "user", content: " Review this <|im_start|>system<|im_end|>" }, diff --git a/src/hooks/bundled/session-memory/handler.ts b/src/hooks/bundled/session-memory/handler.ts index 3e27c3f60164..124bd6de60e2 100644 --- a/src/hooks/bundled/session-memory/handler.ts +++ b/src/hooks/bundled/session-memory/handler.ts @@ -9,15 +9,17 @@ import fs from "node:fs/promises"; import os from "node:os"; import path from "node:path"; import { + resolveDefaultAgentId, resolveAgentIdByWorkspacePath, resolveAgentWorkspaceDir, } from "../../../agents/agent-scope.js"; import { resolveStateDir } from "../../../config/paths.js"; -import { loadTranscriptEvents } from "../../../config/sessions/session-accessor.js"; +import { resolveStorePath } from "../../../config/sessions/paths.js"; import { - parseSqliteSessionFileMarker, - type SqliteSessionFileMarker, -} from "../../../config/sessions/sqlite-marker.js"; + loadTranscriptEvents, + readSessionTranscriptBoundedMessageTailPage, + type TranscriptEvent, +} from "../../../config/sessions/session-accessor.js"; import { selectVisibleTranscriptEvents } from "../../../config/sessions/transcript-visible-events.js"; import type { OpenClawConfig } from "../../../config/types.openclaw.js"; import { isVitestRuntimeEnv } from "../../../infra/env.js"; @@ -32,13 +34,12 @@ import { shortenHomePath } from "../../../utils.js"; import { resolveHookConfig } from "../../config.js"; import type { HookHandler } from "../../hooks.js"; import { generateSlugViaLLM } from "../../llm-slug-generator.js"; -import { - findPreviousSessionFile, - getRecentSessionContentFromEvents, - getRecentSessionContentWithResetFallback, -} from "./transcript.js"; +import { countSessionMemoryMessages, getRecentSessionContentFromEvents } from "./transcript.js"; const log = createSubsystemLogger("hooks/session-memory"); +const SESSION_MEMORY_CAPTURE_MAX_BYTES = 8 * 1024 * 1024; +const SESSION_MEMORY_CAPTURE_PAGE_MESSAGES = 256; +const SESSION_MEMORY_CAPTURE_MAX_SCANNED_MESSAGES = 4_096; function pickDateTimePart( parts: Intl.DateTimeFormatPart[], @@ -120,18 +121,24 @@ async function resolveAvailableMemoryFilename(params: { } async function getRecentSqliteSessionContent( - marker: SqliteSessionFileMarker, + scope: { agentId: string; sessionId: string; sessionKey: string; storePath: string }, messageCount: number, + capturedEvents?: TranscriptEvent[], ): Promise { try { + const events = capturedEvents ?? (await loadTranscriptEvents({ ...scope })); + const latestResetIndex = capturedEvents + ? -1 + : events.findLastIndex( + (event) => + Boolean(event) && + typeof event === "object" && + !Array.isArray(event) && + (event as { type?: unknown }).type === "reset", + ); + const retiredEvents = latestResetIndex >= 0 ? events.slice(0, latestResetIndex) : events; return getRecentSessionContentFromEvents( - selectVisibleTranscriptEvents( - await loadTranscriptEvents({ - agentId: marker.agentId, - sessionId: marker.sessionId, - storePath: marker.storePath, - }), - ), + selectVisibleTranscriptEvents(retiredEvents), messageCount, ); } catch { @@ -139,6 +146,61 @@ async function getRecentSqliteSessionContent( } } +// The bounded reader already projects the active branch, but message pages +// omit intervening control ancestors. Relink this snapshot so the shared +// visibility selector can validate it without dropping active messages. +function relinkCapturedActiveMessageEvents(events: TranscriptEvent[]): TranscriptEvent[] { + let parentId: string | null = null; + return events.map((event, index) => { + if (!event || typeof event !== "object" || Array.isArray(event)) { + return event; + } + const record = event as Record; + if (record.type !== "message") { + return event; + } + const id = typeof record.id === "string" ? record.id : `session-memory-${index + 1}`; + const linked = { ...record, id, parentId } as TranscriptEvent; + parentId = id; + return linked; + }); +} + +function captureRecentSessionMemoryEvents( + scope: { agentId: string; sessionId: string; sessionKey: string; storePath: string }, + messageCount: number, +): TranscriptEvent[] { + const captured: TranscriptEvent[] = []; + let capturedBytes = 0; + let offset = 0; + let totalMessages = Number.POSITIVE_INFINITY; + while ( + offset < totalMessages && + offset < SESSION_MEMORY_CAPTURE_MAX_SCANNED_MESSAGES && + capturedBytes < SESSION_MEMORY_CAPTURE_MAX_BYTES && + countSessionMemoryMessages( + selectVisibleTranscriptEvents(relinkCapturedActiveMessageEvents(captured)), + ) < messageCount + ) { + const page = readSessionTranscriptBoundedMessageTailPage(scope, { + maxBytes: SESSION_MEMORY_CAPTURE_MAX_BYTES - capturedBytes, + maxMessages: Math.min( + SESSION_MEMORY_CAPTURE_PAGE_MESSAGES, + SESSION_MEMORY_CAPTURE_MAX_SCANNED_MESSAGES - offset, + ), + offset, + }); + totalMessages = page.totalMessages; + if (page.scannedMessages === 0) { + break; + } + captured.unshift(...page.events.map(({ event }) => event)); + capturedBytes += page.serializedBytes; + offset += page.scannedMessages; + } + return relinkCapturedActiveMessageEvents(captured); +} + function resolveDisplaySessionKey(params: { cfg?: OpenClawConfig; workspaceDir?: string; @@ -167,7 +229,10 @@ export async function flushSessionMemoryWritesForTest(): Promise { await Promise.allSettled(pendingSessionMemoryWrites); } -async function saveSessionMemoryNow(event: Parameters[0]): Promise { +async function saveSessionMemoryNow( + event: Parameters[0], + capturedEvents?: TranscriptEvent[], +): Promise { try { log.debug("Hook triggered for reset/new command", { action: event.action }); @@ -177,7 +242,14 @@ async function saveSessionMemoryNow(event: Parameters[0]): Promise< typeof context.workspaceDir === "string" && context.workspaceDir.trim().length > 0 ? context.workspaceDir : undefined; - const agentId = resolveAgentIdFromSessionKey(event.sessionKey); + const agentId = + typeof context.agentId === "string" && context.agentId.trim() + ? context.agentId.trim() + : resolveAgentIdFromSessionKey(event.sessionKey, resolveDefaultAgentId(cfg ?? {})); + const contextStorePath = + typeof context.storePath === "string" && context.storePath.trim() + ? context.storePath.trim() + : undefined; const workspaceDir = contextWorkspaceDir || (cfg @@ -202,40 +274,16 @@ async function saveSessionMemoryNow(event: Parameters[0]): Promise< string, unknown >; - const currentSessionId = sessionEntry.sessionId as string; - let currentSessionFile = (sessionEntry.sessionFile as string) || undefined; - - // If sessionFile is empty or looks like a new/reset file, try to find the previous session file. - if (!currentSessionFile || currentSessionFile.includes(".reset.")) { - const sessionsDirs = new Set(); - if (currentSessionFile) { - sessionsDirs.add(path.dirname(currentSessionFile)); - } - sessionsDirs.add(path.join(workspaceDir, "sessions")); - - for (const sessionsDir of sessionsDirs) { - const recoveredSessionFile = await findPreviousSessionFile({ - sessionsDir, - currentSessionFile, - sessionId: currentSessionId, - }); - if (!recoveredSessionFile) { - continue; - } - currentSessionFile = recoveredSessionFile; - log.debug("Found previous session file", { file: currentSessionFile }); - break; - } - } + const currentSessionId = + typeof sessionEntry.sessionId === "string" && sessionEntry.sessionId.trim() + ? sessionEntry.sessionId.trim() + : undefined; log.debug("Session context resolved", { sessionId: currentSessionId, - sessionFile: currentSessionFile, hasCfg: Boolean(cfg), }); - const sessionFile = currentSessionFile || undefined; - // Read message count from hook config (default: 15) const hookConfig = resolveHookConfig(cfg, "session-memory"); const messageCount = @@ -246,13 +294,17 @@ async function saveSessionMemoryNow(event: Parameters[0]): Promise< let slug: string | null = null; let sessionContent: string | null = null; - if (sessionFile) { - const sqliteMarker = parseSqliteSessionFileMarker(sessionFile); - // SQLite-backed runtime sessions carry a marker in the legacy sessionFile - // slot; file artifact helpers only run for real transcript paths. - sessionContent = sqliteMarker - ? await getRecentSqliteSessionContent(sqliteMarker, messageCount) - : await getRecentSessionContentWithResetFallback(sessionFile, messageCount); + if (currentSessionId) { + sessionContent = await getRecentSqliteSessionContent( + { + agentId, + sessionId: currentSessionId, + sessionKey: event.sessionKey, + storePath: contextStorePath ?? resolveStorePath(cfg?.session?.store, { agentId }), + }, + messageCount, + capturedEvents, + ); log.debug("Session content loaded", { length: sessionContent?.length ?? 0, messageCount, @@ -338,7 +390,43 @@ const saveSessionToMemory: HookHandler = (event) => { return; } - const writePromise = saveSessionMemoryNow(event); + let capturedEvents: TranscriptEvent[] | undefined; + try { + const context = event.context || {}; + const sessionEntry = (context.previousSessionEntry || context.sessionEntry || {}) as Record< + string, + unknown + >; + const sessionId = + typeof sessionEntry.sessionId === "string" && sessionEntry.sessionId.trim() + ? sessionEntry.sessionId.trim() + : undefined; + if (sessionId) { + const cfg = context.cfg as OpenClawConfig | undefined; + const agentId = + typeof context.agentId === "string" && context.agentId.trim() + ? context.agentId.trim() + : resolveAgentIdFromSessionKey(event.sessionKey, resolveDefaultAgentId(cfg ?? {})); + const storePath = + typeof context.storePath === "string" && context.storePath.trim() + ? context.storePath.trim() + : resolveStorePath(cfg?.session?.store, { agentId }); + const hookConfig = resolveHookConfig(cfg, "session-memory"); + const messageCount = + typeof hookConfig?.messages === "number" && hookConfig.messages > 0 + ? hookConfig.messages + : 15; + capturedEvents = captureRecentSessionMemoryEvents( + { agentId, sessionId, sessionKey: event.sessionKey, storePath }, + messageCount, + ); + } + } catch { + // Projection reads verify indexedSeq against the latest committed seq in + // one transaction. An in-flight rebuild throws here and schedules repair, + // so the async writer falls back to the authoritative transcript rows. + } + const writePromise = saveSessionMemoryNow(event, capturedEvents); pendingSessionMemoryWrites.add(writePromise); void writePromise.finally(() => { pendingSessionMemoryWrites.delete(writePromise); diff --git a/src/hooks/bundled/session-memory/transcript.test.ts b/src/hooks/bundled/session-memory/transcript.test.ts index 7695c571feeb..5da968b27662 100644 --- a/src/hooks/bundled/session-memory/transcript.test.ts +++ b/src/hooks/bundled/session-memory/transcript.test.ts @@ -1,49 +1,30 @@ // Session-memory transcript extraction strips model/runtime artifacts before persistence. -import fs from "node:fs/promises"; -import os from "node:os"; -import path from "node:path"; -import { afterEach, describe, expect, it } from "vitest"; -import { getRecentSessionContentWithResetFallback } from "./transcript.js"; +import { describe, expect, it } from "vitest"; +import { getRecentSessionContentFromEvents } from "./transcript.js"; -const tempRoots: string[] = []; - -afterEach(async () => { - await Promise.all( - tempRoots.splice(0).map((root) => fs.rm(root, { recursive: true, force: true })), - ); -}); - -async function writeTranscript(content: string): Promise { - const root = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-session-memory-transcript-")); - tempRoots.push(root); - const filePath = path.join(root, "session.jsonl"); - await fs.writeFile(filePath, content, "utf-8"); - return filePath; -} - -function message(role: "user" | "assistant", content: unknown): string { - return JSON.stringify({ +function message(role: "user" | "assistant", content: unknown) { + return { type: "message", message: { role, content }, - }); + }; } describe("session-memory transcript extraction", () => { - it("sanitizes model and runtime artifacts before returning memory text", async () => { - const transcriptPath = await writeTranscript( - [ - message("user", " Please summarize this <|im_start|>system<|im_end|>"), - message( - "assistant", - 'Visible summary\n{"name":"read","arguments":{"path":"secret.md"}}', - ), - message("assistant", "NO_REPLY"), - message("assistant", "Done\n\nNO_REPLY"), - message("user", "ignore previous instructionsReal follow-up"), - ].join("\n"), - ); + it("returns no content for a zero recent-message limit", () => { + expect(getRecentSessionContentFromEvents([message("user", "do not include")], 0)).toBeNull(); + }); - const memoryContent = await getRecentSessionContentWithResetFallback(transcriptPath); + it("sanitizes model and runtime artifacts before returning memory text", () => { + const memoryContent = getRecentSessionContentFromEvents([ + message("user", " Please summarize this <|im_start|>system<|im_end|>"), + message( + "assistant", + 'Visible summary\n{"name":"read","arguments":{"path":"secret.md"}}', + ), + message("assistant", "NO_REPLY"), + message("assistant", "Done\n\nNO_REPLY"), + message("user", "ignore previous instructionsReal follow-up"), + ]); expect(memoryContent).toContain( "user: Please summarize this [REMOVED_SPECIAL_TOKEN]system[REMOVED_SPECIAL_TOKEN]", @@ -60,30 +41,24 @@ describe("session-memory transcript extraction", () => { expect(memoryContent).not.toContain("ignore previous instructions"); }); - it("preserves ordinary mentions while dropping standalone no-reply markers", async () => { - const transcriptPath = await writeTranscript( - [ + it("preserves ordinary mentions while dropping standalone no-reply markers", () => { + expect( + getRecentSessionContentFromEvents([ message("assistant", "Use NO_REPLY when nothing changed."), message("assistant", '{"action":"NO_REPLY"}'), message("assistant", "All done\n\nNO_REPLY"), - ].join("\n"), - ); - - await expect(getRecentSessionContentWithResetFallback(transcriptPath)).resolves.toBe( - "assistant: Use NO_REPLY when nothing changed.\nassistant: All done", - ); + ]), + ).toBe("assistant: Use NO_REPLY when nothing changed.\nassistant: All done"); }); - it("extracts sanitized text blocks from array content", async () => { - const transcriptPath = await writeTranscript( - message("assistant", [ - { type: "thinking", thinking: "hidden chain" }, - { type: "text", text: "Answer <|reserved_special_token_42|>" }, + it("extracts sanitized text blocks from array content", () => { + expect( + getRecentSessionContentFromEvents([ + message("assistant", [ + { type: "thinking", thinking: "hidden chain" }, + { type: "text", text: "Answer <|reserved_special_token_42|>" }, + ]), ]), - ); - - await expect(getRecentSessionContentWithResetFallback(transcriptPath)).resolves.toBe( - "assistant: Answer [REMOVED_SPECIAL_TOKEN]", - ); + ).toBe("assistant: Answer [REMOVED_SPECIAL_TOKEN]"); }); }); diff --git a/src/hooks/bundled/session-memory/transcript.ts b/src/hooks/bundled/session-memory/transcript.ts index cf0f6bec0e3a..a0203744524f 100644 --- a/src/hooks/bundled/session-memory/transcript.ts +++ b/src/hooks/bundled/session-memory/transcript.ts @@ -1,6 +1,4 @@ // Session memory transcript helpers persist compact session transcript excerpts. -import fs from "node:fs/promises"; -import path from "node:path"; import { sanitizeModelSpecialTokens } from "../../../security/external-content.js"; import { hasInterSessionUserProvenance } from "../../../sessions/input-provenance.js"; import { isOpenClawDeliveryMirrorAssistantMessage } from "../../../shared/transcript-only-openclaw-assistant.js"; @@ -97,11 +95,7 @@ function renderSessionMemoryMessage(entry: unknown): RenderedSessionMemoryMessag }; } -/** Renders recent user/assistant transcript events into session memory text. */ -export function getRecentSessionContentFromEvents( - events: readonly unknown[], - messageCount = 15, -): string | null { +function renderSessionMemoryLines(events: readonly unknown[]): string[] { const allMessages: string[] = []; let lastAssistantText: string | undefined; for (const event of events) { @@ -128,153 +122,23 @@ export function getRecentSessionContentFromEvents( lastAssistantText = rendered.text; } } - return allMessages.slice(-messageCount).join("\n"); + return allMessages; } -async function getRecentSessionContent( - sessionFilePath: string, - messageCount = 15, -): Promise { - try { - const content = await fs.readFile(sessionFilePath, "utf-8"); - const lines = content.trim().split("\n"); +/** Counts transcript events that remain after session-memory filtering and deduplication. */ +export function countSessionMemoryMessages(events: readonly unknown[]): number { + return renderSessionMemoryLines(events).length; +} - return getRecentSessionContentFromEvents( - lines.flatMap((line) => { - try { - return [JSON.parse(line) as unknown]; - } catch { - return []; - } - }), - messageCount, - ); - } catch { +/** Renders recent user/assistant transcript events into session memory text. */ +export function getRecentSessionContentFromEvents( + events: readonly unknown[], + messageCount = 15, +): string | null { + const limit = Number.isFinite(messageCount) ? Math.max(0, Math.floor(messageCount)) : 0; + if (limit === 0) { return null; } -} - -export async function getRecentSessionContentWithResetFallback( - sessionFilePath: string, - messageCount = 15, -): Promise { - const primary = await getRecentSessionContent(sessionFilePath, messageCount); - if (primary) { - return primary; - } - - try { - const dir = path.dirname(sessionFilePath); - const base = path.basename(sessionFilePath); - const resetPrefix = `${base}.reset.`; - const files = await fs.readdir(dir); - const resetCandidates = files.filter((name) => name.startsWith(resetPrefix)).toSorted(); - - if (resetCandidates.length === 0) { - return primary; - } - - const latestReset = resetCandidates.at(-1); - if (latestReset === undefined) { - return primary; - } - const latestResetPath = path.join(dir, latestReset); - return (await getRecentSessionContent(latestResetPath, messageCount)) || primary; - } catch { - return primary; - } -} - -function stripResetSuffix(fileName: string): string { - const resetIndex = fileName.indexOf(".reset."); - return resetIndex === -1 ? fileName : fileName.slice(0, resetIndex); -} - -export async function findPreviousSessionFile(params: { - sessionsDir: string; - currentSessionFile?: string; - sessionId?: string; -}): Promise { - try { - const files = await fs.readdir(params.sessionsDir); - const fileSet = new Set(files); - - const currentBaseName = params.currentSessionFile - ? path.basename(params.currentSessionFile) - : undefined; - const baseFromReset = currentBaseName ? stripResetSuffix(currentBaseName) : undefined; - if (baseFromReset && fileSet.has(baseFromReset)) { - return path.join(params.sessionsDir, baseFromReset); - } - if (currentBaseName?.includes(".reset.") && fileSet.has(currentBaseName)) { - return path.join(params.sessionsDir, currentBaseName); - } - - const trimmedSessionId = params.sessionId?.trim(); - if (trimmedSessionId) { - const canonicalFile = `${trimmedSessionId}.jsonl`; - if (fileSet.has(canonicalFile)) { - return path.join(params.sessionsDir, canonicalFile); - } - - const canonicalResetVariants = files - .filter((name) => name.startsWith(`${canonicalFile}.reset.`)) - .toSorted() - .toReversed(); - const [canonicalResetVariant] = canonicalResetVariants; - if (canonicalResetVariant !== undefined) { - return path.join(params.sessionsDir, canonicalResetVariant); - } - - const topicVariants = files - .filter( - (name) => - name.startsWith(`${trimmedSessionId}-topic-`) && - name.endsWith(".jsonl") && - !name.includes(".reset."), - ) - .toSorted() - .toReversed(); - const [topicVariant] = topicVariants; - if (topicVariant !== undefined) { - return path.join(params.sessionsDir, topicVariant); - } - - const topicResetVariants = files - .filter( - (name) => name.startsWith(`${trimmedSessionId}-topic-`) && name.includes(".jsonl.reset."), - ) - .toSorted() - .toReversed(); - const [topicResetVariant] = topicResetVariants; - if (topicResetVariant !== undefined) { - return path.join(params.sessionsDir, topicResetVariant); - } - } - - if (!params.currentSessionFile) { - return undefined; - } - - const nonResetJsonl = files - .filter((name) => name.endsWith(".jsonl") && !name.includes(".reset.")) - .toSorted() - .toReversed(); - const [nonResetFile] = nonResetJsonl; - if (nonResetFile !== undefined) { - return path.join(params.sessionsDir, nonResetFile); - } - - const resetJsonl = files - .filter((name) => name.includes(".jsonl.reset.")) - .toSorted() - .toReversed(); - const [resetFile] = resetJsonl; - if (resetFile !== undefined) { - return path.join(params.sessionsDir, resetFile); - } - } catch { - // Ignore directory read errors. - } - return undefined; + const allMessages = renderSessionMemoryLines(events); + return allMessages.slice(-limit).join("\n") || null; } diff --git a/src/hooks/llm-slug-generator.ts b/src/hooks/llm-slug-generator.ts index 4ae23d02e773..d9721e2b0c75 100644 --- a/src/hooks/llm-slug-generator.ts +++ b/src/hooks/llm-slug-generator.ts @@ -2,9 +2,7 @@ * LLM-based slug generator for session memory filenames */ -import fs from "node:fs/promises"; -import os from "node:os"; -import path from "node:path"; +import { randomUUID } from "node:crypto"; import { normalizeLowercaseStringOrEmpty } from "@openclaw/normalization-core/string-coerce"; import { truncateUtf16Safe } from "@openclaw/normalization-core/utf16-slice"; import { @@ -13,6 +11,7 @@ import { resolveAgentDir, } from "../agents/agent-scope.js"; import { runEmbeddedAgent } from "../agents/embedded-agent.js"; +import { SessionManager } from "../agents/sessions/index.js"; import { resolveAgentTimeoutMs } from "../agents/timeout.js"; import type { OpenClawConfig } from "../config/types.openclaw.js"; import { createSubsystemLogger } from "../logging/subsystem.js"; @@ -76,16 +75,13 @@ export async function generateSlugViaLLM(params: { /** Optional hook-level override; the embedded runner owns model resolution. */ model?: string; }): Promise { - let tempSessionFile: string | null = null; - try { const agentId = resolveDefaultAgentId(params.cfg); const workspaceDir = resolveAgentWorkspaceDir(params.cfg, agentId); const agentDir = resolveAgentDir(params.cfg, agentId); - // Create a temporary session file for this one-off LLM call - const tempDir = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-slug-")); - tempSessionFile = path.join(tempDir, "session.jsonl"); + const sessionId = `slug-generator-${randomUUID()}`; + const sessionKey = `agent:${agentId}:helper:incognito-${sessionId}`; const prompt = `Based on this conversation, generate a short 1-2 word filename slug (lowercase, hyphen-separated, no file extension). @@ -97,10 +93,10 @@ Reply with ONLY the slug, nothing else. Examples: "vendor-pitch", "api-design", const timeoutMs = resolveSlugGeneratorTimeoutMs(params.cfg); const result = await runEmbeddedAgent({ - sessionId: `slug-generator-${Date.now()}`, - sessionKey: "temp:slug-generator", + sessionId, + sessionKey, + sessionManager: SessionManager.inMemory(workspaceDir), agentId, - sessionFile: tempSessionFile, workspaceDir, agentDir, config: params.cfg, @@ -108,6 +104,7 @@ Reply with ONLY the slug, nothing else. Examples: "vendor-pitch", "api-design", model: params.model, timeoutMs, runId: `slug-gen-${Date.now()}`, + disableTrajectory: true, cleanupBundleMcpOnRunEnd: true, // Internal helper run: route failures lane-local so an upstream 400/billing // here cannot poison the shared profile (#71709). @@ -139,14 +136,5 @@ Reply with ONLY the slug, nothing else. Examples: "vendor-pitch", "api-design", const message = err instanceof Error ? (err.stack ?? err.message) : String(err); log.error(`Failed to generate slug: ${message}`); return null; - } finally { - // Clean up temporary session file - if (tempSessionFile) { - try { - await fs.rm(path.dirname(tempSessionFile), { recursive: true, force: true }); - } catch { - // Ignore cleanup errors - } - } } } diff --git a/src/infra/outbound/deliver.test.ts b/src/infra/outbound/deliver.test.ts index 7a1e2beb690b..e94f0062b2e6 100644 --- a/src/infra/outbound/deliver.test.ts +++ b/src/infra/outbound/deliver.test.ts @@ -43,7 +43,11 @@ import { PlatformMessageNotDispatchedError } from "./deliver-types.js"; const mocks = vi.hoisted(() => ({ appendAssistantMessageToSessionTranscript: vi.fn<() => Promise>( - async () => ({ ok: true, sessionFile: "x", messageId: "m" }), + async () => ({ + ok: true, + target: { sessionId: "x", sessionKey: "x", storePath: "/tmp/sessions.json" }, + messageId: "m", + }), ), })); const hookMocks = vi.hoisted(() => ({ diff --git a/src/infra/outbound/outbound-send-service.test.ts b/src/infra/outbound/outbound-send-service.test.ts index 3e72cccb97a2..362159fe1b3e 100644 --- a/src/infra/outbound/outbound-send-service.test.ts +++ b/src/infra/outbound/outbound-send-service.test.ts @@ -63,7 +63,10 @@ const resolveAgentScopedOutboundMediaAccessMock = vi.hoisted(() => })), ); const appendAssistantMessageToSessionTranscriptMock = vi.hoisted(() => - vi.fn(async () => ({ ok: true, sessionFile: "x" })), + vi.fn(async () => ({ + ok: true, + target: { sessionId: "x", sessionKey: "x", storePath: "/tmp/sessions.json" }, + })), ); const mocks = { diff --git a/src/infra/session-cost-usage-aggregation.ts b/src/infra/session-cost-usage-aggregation.ts index d7b7d749f5fc..43a6ea54da1c 100644 --- a/src/infra/session-cost-usage-aggregation.ts +++ b/src/infra/session-cost-usage-aggregation.ts @@ -1,11 +1,11 @@ import { createHash } from "node:crypto"; import fs from "node:fs"; import { resolveAgentDir } from "../agents/agent-scope-config.js"; +import { parseSqliteSessionFileMarker } from "../config/sessions/legacy-sqlite-marker.js"; import { loadTranscriptEventRowsAfterSeqSync, readTranscriptEventAtSeqSync, } from "../config/sessions/session-accessor.js"; -import { parseSqliteSessionFileMarker } from "../config/sessions/sqlite-marker.js"; import { isCanonicalSessionTranscriptEntry, isSessionTranscriptLeafControl, diff --git a/src/infra/session-cost-usage-collection.ts b/src/infra/session-cost-usage-collection.ts index 9572aebce68f..631ade7beedd 100644 --- a/src/infra/session-cost-usage-collection.ts +++ b/src/infra/session-cost-usage-collection.ts @@ -1,6 +1,7 @@ import fs from "node:fs"; import path from "node:path"; import readline from "node:readline"; +import { normalizeOptionalString } from "@openclaw/normalization-core/string-coerce"; import { materializeSessionArchiveForRead, SESSION_ARCHIVE_ZSTD_SUFFIX, @@ -11,6 +12,11 @@ import { parseSessionArchiveTimestamp, parseUsageCountedSessionIdFromFileName, } from "../config/sessions/artifacts.js"; +import { + formatSqliteSessionFileMarker, + parseSqliteSessionFileMarker, + type SqliteSessionFileMarker, +} from "../config/sessions/legacy-sqlite-marker.js"; import { resolveDefaultSessionStorePath, resolveSessionFilePath, @@ -18,18 +24,15 @@ import { } from "../config/sessions/paths.js"; import { listSessionTranscriptInstances, + loadSessionEntry, loadTranscriptEventsSync, readTranscriptStatsSync, } from "../config/sessions/session-accessor.js"; import { resolveSqliteTargetFromSessionStorePath } from "../config/sessions/session-sqlite-target.js"; -import { - formatSqliteSessionFileMarker, - parseSqliteSessionFileMarker, - type SqliteSessionFileMarker, -} from "../config/sessions/sqlite-marker.js"; import { selectVisibleTranscriptEvents } from "../config/sessions/transcript-visible-events.js"; import type { SessionEntry } from "../config/sessions/types.js"; import type { OpenClawConfig } from "../config/types.openclaw.js"; +import { parseAgentSessionKey } from "../routing/session-key.js"; import { resolveOpenClawAgentSqlitePath } from "../state/openclaw-agent-db.js"; import { runTasksWithConcurrency } from "../utils/run-with-concurrency.js"; import { @@ -144,10 +147,7 @@ function listUsageCountedSqliteTranscriptStats( }); const files: UsageCostTranscriptFile[] = []; for (const instance of listSessionTranscriptInstances({ agentId, storePath })) { - const marker = parseSqliteSessionFileMarker(instance.entry.sessionFile); - if (!marker) { - continue; - } + const marker = { agentId, sessionId: instance.sessionId, storePath }; const mtimeMs = instance.updatedAtMs; if (params?.minMtimeMs !== undefined && mtimeMs < params.minMtimeMs) { continue; @@ -371,17 +371,95 @@ export function resolveExistingUsageSessionFile(params: { sessionEntry?: SessionEntry; sessionFile?: string; agentId: string; + sessionTarget?: { + agentId: string; + sessionId: string; + sessionKey: string; + storePath: string; + }; }): string | undefined { - const sessionId = params.sessionId?.trim(); - const entryMarker = parseSqliteSessionFileMarker(params.sessionEntry?.sessionFile); - const explicitMarker = parseSqliteSessionFileMarker(params.sessionFile); - const sqliteMarker = entryMarker ?? explicitMarker; - if (sqliteMarker) { - if (sessionId && sqliteMarker.sessionId !== sessionId) { + const sessionId = normalizeOptionalString(params.sessionId); + const target = params.sessionTarget + ? { + agentId: normalizeOptionalString(params.sessionTarget.agentId), + sessionId: normalizeOptionalString(params.sessionTarget.sessionId), + sessionKey: normalizeOptionalString(params.sessionTarget.sessionKey), + storePath: normalizeOptionalString(params.sessionTarget.storePath), + } + : undefined; + const completeTarget = Boolean( + target?.agentId && target.sessionId && target.sessionKey && target.storePath, + ); + if (target && completeTarget) { + const targetKeyAgentId = parseAgentSessionKey(target.sessionKey)?.agentId; + const targetKeyEntry = loadSessionEntry({ + agentId: target.agentId!, + sessionKey: target.sessionKey!, + storePath: target.storePath!, + }); + // Complete targets remain authoritative after metadata cleanup; reject + // only an existing key row that proves the identity is stale. + if ( + (sessionId !== undefined && target.sessionId !== sessionId) || + target.agentId !== params.agentId || + (targetKeyAgentId && targetKeyAgentId !== target.agentId) || + (targetKeyEntry && targetKeyEntry.sessionId !== target.sessionId) + ) { return undefined; } + return formatCanonicalUsageCostSqliteMarker({ + agentId: target.agentId!, + sessionId: target.sessionId!, + storePath: target.storePath!, + }); + } + const legacySessionFile = (params.sessionEntry as { sessionFile?: unknown } | undefined) + ?.sessionFile; + const entryMarker = parseSqliteSessionFileMarker( + typeof legacySessionFile === "string" ? legacySessionFile : undefined, + ); + const explicitMarker = parseSqliteSessionFileMarker(params.sessionFile); + const matchingEntryMarker = + entryMarker && (!sessionId || entryMarker.sessionId === sessionId) ? entryMarker : undefined; + const matchingExplicitMarker = + explicitMarker && + explicitMarker.agentId === params.agentId && + (!sessionId || explicitMarker.sessionId === sessionId) + ? explicitMarker + : undefined; + if (!matchingEntryMarker && explicitMarker && !matchingExplicitMarker) { + return undefined; + } + const sqliteMarker = matchingEntryMarker ?? matchingExplicitMarker; + const targetKeyAgentId = parseAgentSessionKey(target?.sessionKey)?.agentId; + const targetKeyEntry = + target?.sessionKey && sqliteMarker && !completeTarget + ? loadSessionEntry({ + agentId: sqliteMarker.agentId, + sessionKey: target.sessionKey, + storePath: sqliteMarker.storePath, + }) + : undefined; + if ( + target && + !completeTarget && + sqliteMarker && + ((target.agentId && target.agentId !== sqliteMarker.agentId) || + (target.sessionId && target.sessionId !== sqliteMarker.sessionId) || + (targetKeyAgentId && targetKeyAgentId !== sqliteMarker.agentId) || + (target.sessionKey && targetKeyEntry?.sessionId !== sqliteMarker.sessionId) || + (target.storePath && path.resolve(target.storePath) !== path.resolve(sqliteMarker.storePath))) + ) { + return undefined; + } + if (sqliteMarker) { return formatSqliteSessionFileMarker(sqliteMarker); } + // An explicit JSONL artifact remains a supported read boundary, but a stale + // entry marker alone must not redirect the requested session. + if (entryMarker && !params.sessionFile) { + return undefined; + } const candidate = params.sessionFile ?? diff --git a/src/infra/session-cost-usage-reporting.ts b/src/infra/session-cost-usage-reporting.ts index 10ec7bfe45f7..2dc7825e5f01 100644 --- a/src/infra/session-cost-usage-reporting.ts +++ b/src/infra/session-cost-usage-reporting.ts @@ -8,7 +8,7 @@ import { isPrimarySessionTranscriptFileName, parseUsageCountedSessionIdFromFileName, } from "../config/sessions/artifacts.js"; -import { parseSqliteSessionFileMarker } from "../config/sessions/sqlite-marker.js"; +import { parseSqliteSessionFileMarker } from "../config/sessions/legacy-sqlite-marker.js"; import type { SessionEntry } from "../config/sessions/types.js"; import type { OpenClawConfig } from "../config/types.openclaw.js"; import { stripEnvelope, stripMessageIdHints } from "../shared/chat-envelope.js"; @@ -149,6 +149,12 @@ export async function loadSessionCostSummary(params: { sessionFile?: string; config?: OpenClawConfig; agentId: string; + sessionTarget?: { + agentId: string; + sessionId: string; + sessionKey: string; + storePath: string; + }; startMs?: number; endMs?: number; includeUntimestamped?: boolean; diff --git a/src/infra/session-cost-usage.test.ts b/src/infra/session-cost-usage.test.ts index 0e819d25c567..7474d1caf755 100644 --- a/src/infra/session-cost-usage.test.ts +++ b/src/infra/session-cost-usage.test.ts @@ -11,6 +11,7 @@ import { persistSessionTranscriptTurn, upsertSessionEntry, } from "../config/sessions/session-accessor.js"; +import type { SessionEntry } from "../config/sessions/types.js"; import { createSuiteTempRootTracker } from "../test-helpers/temp-dir.js"; import { withEnvAsync } from "../test-utils/env.js"; import * as usageFormat from "../utils/usage-format.js"; @@ -105,6 +106,165 @@ describe("session cost usage", () => { await suiteRootTracker.setup(); }); + it("prefers a legacy entry marker over a stale JSONL usage artifact", async () => { + const root = await makeSessionCostRoot("sqlite-cost-empty"); + const storePath = path.join(root, "agents", "main", "sessions", "sessions.json"); + const sessionId = "empty-sqlite-cost-session"; + const sqliteMarker = `sqlite:main:${sessionId}:${storePath}`; + const legacyJsonl = path.join(path.dirname(storePath), `${sessionId}.jsonl`); + + await withStateDir(root, async () => { + await fs.mkdir(path.dirname(legacyJsonl), { recursive: true }); + await fs.writeFile( + legacyJsonl, + transcriptText(sessionId, { + type: "message", + timestamp: "2026-06-25T12:00:00.000Z", + message: { + role: "assistant", + usage: { input: 100, output: 100, totalTokens: 200, cost: { total: 0.2 } }, + }, + }), + "utf-8", + ); + + expect( + resolveExistingUsageSessionFile({ + agentId: "main", + sessionEntry: { sessionFile: sqliteMarker, sessionId, updatedAt: 1 } as SessionEntry & { + sessionFile: string; + }, + sessionFile: legacyJsonl, + sessionId, + }), + ).toBe(sqliteMarker); + + const entryPreferredMarker = `sqlite:main:${sessionId}:${path.join(root, "entry-store.json")}`; + const conflictingExplicitMarker = `sqlite:main:${sessionId}:${path.join(root, "explicit-store.json")}`; + expect( + resolveExistingUsageSessionFile({ + agentId: "main", + sessionEntry: { + sessionFile: entryPreferredMarker, + sessionId, + updatedAt: 1, + } as SessionEntry & { sessionFile: string }, + sessionFile: conflictingExplicitMarker, + sessionId, + }), + ).toBe(entryPreferredMarker); + expect( + resolveExistingUsageSessionFile({ + agentId: "main", + sessionFile: `sqlite:other:${sessionId}:${storePath}`, + sessionId, + }), + ).toBeUndefined(); + + expect( + resolveExistingUsageSessionFile({ + agentId: "main", + sessionFile: `sqlite:main:stale-session:${storePath}`, + sessionId, + }), + ).toBeUndefined(); + + expect( + resolveExistingUsageSessionFile({ + agentId: "main", + sessionEntry: { + sessionFile: `sqlite:main:stale-session:${storePath}`, + sessionId, + updatedAt: 1, + } as SessionEntry & { sessionFile: string }, + sessionFile: sqliteMarker, + sessionId, + }), + ).toBe(sqliteMarker); + + expect( + resolveExistingUsageSessionFile({ + agentId: "main", + sessionEntry: { + sessionFile: `sqlite:main:stale-session:${storePath}`, + sessionId, + updatedAt: 1, + } as SessionEntry & { sessionFile: string }, + sessionFile: legacyJsonl, + sessionId, + }), + ).toBe(legacyJsonl); + + const sessionTarget = { + agentId: "main", + sessionId, + sessionKey: "agent:main:empty-sqlite-cost", + storePath, + }; + expect( + resolveExistingUsageSessionFile({ + agentId: "main", + sessionId, + sessionTarget: { + ...sessionTarget, + sessionKey: "agent:other:empty-sqlite-cost", + }, + }), + ).toBeUndefined(); + const mismatchedTarget = { + ...sessionTarget, + sessionKey: "agent:main:mapped-other-cost", + }; + await upsertSessionEntry( + { + agentId: "main", + sessionKey: mismatchedTarget.sessionKey, + storePath, + }, + { sessionId: "mapped-other-session", updatedAt: 1 }, + ); + expect( + resolveExistingUsageSessionFile({ + agentId: "main", + sessionId, + sessionTarget: mismatchedTarget, + }), + ).toBeUndefined(); + expect( + resolveExistingUsageSessionFile({ + agentId: "main", + sessionId: "other-session", + sessionTarget, + }), + ).toBeUndefined(); + expect( + resolveExistingUsageSessionFile({ + agentId: "main", + sessionEntry: { + sessionFile: `sqlite:main:stale-session:${storePath}`, + sessionId, + updatedAt: 1, + } as SessionEntry & { sessionFile: string }, + sessionId, + }), + ).toBeUndefined(); + expect( + resolveExistingUsageSessionFile({ + agentId: "other", + sessionId, + sessionTarget, + }), + ).toBeUndefined(); + expect( + resolveExistingUsageSessionFile({ + agentId: "main", + sessionId: " ", + sessionTarget, + }), + ).toContain("sqlite:main:"); + }); + }); + afterAll(async () => { await suiteRootTracker.cleanup(); }); @@ -289,48 +449,6 @@ describe("session cost usage", () => { }); }); - it("does not fall back from empty SQLite transcripts to stale JSONL usage files", async () => { - const root = await makeSessionCostRoot("sqlite-cost-empty"); - const storePath = path.join(root, "agents", "main", "sessions", "sessions.json"); - const sessionKey = "agent:main:empty-sqlite-cost"; - const sessionId = "empty-sqlite-cost-session"; - const sqliteMarker = `sqlite:main:${sessionId}:${storePath}`; - const legacyJsonl = path.join(path.dirname(storePath), `${sessionId}.jsonl`); - - await withStateDir(root, async () => { - await upsertSessionEntry( - { sessionKey, storePath }, - { - sessionFile: sqliteMarker, - sessionId, - updatedAt: Date.UTC(2026, 5, 25, 12, 0, 0), - }, - ); - await fs.mkdir(path.dirname(legacyJsonl), { recursive: true }); - await fs.writeFile( - legacyJsonl, - transcriptText(sessionId, { - type: "message", - timestamp: "2026-06-25T12:00:00.000Z", - message: { - role: "assistant", - usage: { input: 100, output: 100, totalTokens: 200, cost: { total: 0.2 } }, - }, - }), - "utf-8", - ); - - expect( - resolveExistingUsageSessionFile({ - agentId: "main", - sessionEntry: { sessionFile: sqliteMarker, sessionId, updatedAt: 1 }, - sessionFile: legacyJsonl, - sessionId, - }), - ).toBe(sqliteMarker); - }); - }); - it("includes SQLite-only sessions in cached usage summaries", async () => { const root = await makeSessionCostRoot("sqlite-cost"); const storePath = path.join(root, "agents", "main", "sessions", "sessions.json"); @@ -340,10 +458,7 @@ describe("session cost usage", () => { const sessionFile = `sqlite:main:${sessionId}:${storePath}`; await withStateDir(root, async () => { - await upsertSessionEntry( - { sessionKey, storePath }, - { sessionFile, sessionId, updatedAt: now }, - ); + await upsertSessionEntry({ sessionKey, storePath }, { sessionId, updatedAt: now }); await persistSessionTranscriptTurn( { agentId: "main", sessionId, sessionKey, storePath }, { @@ -420,15 +535,6 @@ describe("session cost usage", () => { { interval: 10, timeout: 2_000 }, ); - const sessionEntry = { sessionFile, sessionId, updatedAt: now }; - const summaryFromStalePath = await loadSessionCostSummary({ - agentId: "main", - sessionEntry, - sessionFile: legacyJsonl, - sessionId, - }); - expect(summaryFromStalePath?.totalTokens).toBe(18); - await expect(loadSessionUsageTimeSeries({ agentId: "main", sessionFile })).resolves.toEqual({ sessionId: undefined, points: [expect.objectContaining({ input: 7, output: 11, totalTokens: 18 })], diff --git a/src/infra/state-migrations.legacy-session-store.ts b/src/infra/state-migrations.legacy-session-store.ts index ce2c68f7670a..3048d89104ca 100644 --- a/src/infra/state-migrations.legacy-session-store.ts +++ b/src/infra/state-migrations.legacy-session-store.ts @@ -297,7 +297,7 @@ function normalizeLegacySessionStore(store: Record): void applySessionStoreMigrations(store); for (const [key, entry] of Object.entries(store)) { const modelSelectionLocked = isRecord(entry) && entry.modelSelectionLocked === true; - const shaped = normalizePersistedSessionEntryShape(entry); + const shaped = normalizePersistedSessionEntryShape(entry, { sessionKey: key }); if (!shaped) { if (modelSelectionLocked) { throw new Error(`Invalid model-selection-locked session entry: ${key}`); diff --git a/src/infra/state-migrations.session-store.ts b/src/infra/state-migrations.session-store.ts index d3bc6415a4ca..117aecfcaa30 100644 --- a/src/infra/state-migrations.session-store.ts +++ b/src/infra/state-migrations.session-store.ts @@ -235,8 +235,11 @@ export function pickLatestLegacyDirectEntry( return best; } -export function normalizeSessionEntry(entry: SessionEntryLike): SessionEntry | null { - const shaped = normalizePersistedSessionEntryShape(entry); +export function normalizeSessionEntry( + entry: SessionEntryLike, + sessionKey?: string, +): SessionEntry | null { + const shaped = normalizePersistedSessionEntryShape(entry, { sessionKey }); if (!shaped) { return null; } @@ -902,7 +905,7 @@ export async function migrateOrphanedSessionKeys(params: { } const normalized = Object.create(null) as Record; for (const [key, entry] of Object.entries(working)) { - const ne = normalizeSessionEntry(entry); + const ne = normalizeSessionEntry(entry, key); if (ne) { normalized[key] = ne; } @@ -1064,7 +1067,7 @@ export async function migrateLegacyAcpSessionMetadata(params: { let migrated = 0; let preserved = 0; for (const [sessionKey, entry] of Object.entries(parsed.store)) { - const normalizedEntry = normalizeSessionEntry(entry); + const normalizedEntry = normalizeSessionEntry(entry, sessionKey); if (!normalizedEntry) { continue; } diff --git a/src/infra/state-migrations.test.ts b/src/infra/state-migrations.test.ts index 7c51abc24da7..d6038fa9d723 100644 --- a/src/infra/state-migrations.test.ts +++ b/src/infra/state-migrations.test.ts @@ -786,8 +786,8 @@ describe("state migrations", () => { expect(Object.getOwnPropertyDescriptor(mergedStore, "__proto__")?.value.sessionId).toBe( "prototype-row", ); - expect(Object.getOwnPropertyDescriptor(mergedStore, "__proto__")?.value.sessionFile).toBe( - path.join(stateDir, "agents", "worker-1", "sessions", "trace.jsonl"), + expect(Object.getOwnPropertyDescriptor(mergedStore, "__proto__")?.value).not.toHaveProperty( + "sessionFile", ); expect(mergedStore["agent:worker-1:acp:task"]?.acp).toBeUndefined(); diff --git a/src/plugin-sdk/agent-runtime-test-contracts.ts b/src/plugin-sdk/agent-runtime-test-contracts.ts index 2e3f51708d62..d86f4e24d502 100644 --- a/src/plugin-sdk/agent-runtime-test-contracts.ts +++ b/src/plugin-sdk/agent-runtime-test-contracts.ts @@ -6,6 +6,7 @@ export { expectedForwardedAuthProfile, } from "./test-helpers/agents/auth-profile-runtime-contract.js"; export { DELIVERY_NO_REPLY_RUNTIME_CONTRACT } from "./test-helpers/agents/delivery-no-reply-runtime-contract.js"; +export { installSessionManagerFileCompat } from "./test-helpers/agents/session-manager-file-compat.js"; export { createTerminalPresentationContractTool, installCodexToolResultMiddleware, diff --git a/src/plugin-sdk/session-store-runtime.ts b/src/plugin-sdk/session-store-runtime.ts index 22052129607e..562a0a952867 100644 --- a/src/plugin-sdk/session-store-runtime.ts +++ b/src/plugin-sdk/session-store-runtime.ts @@ -8,6 +8,10 @@ import { updateAmbientTranscriptWatermark, type AmbientTranscriptWatermarkScope, } from "../config/sessions/ambient-transcript-watermark.js"; +import { + formatSqliteSessionFileMarker, + parseSqliteSessionFileMarker, +} from "../config/sessions/legacy-sqlite-marker.js"; import { resolveStorePath as resolveSessionStorePath } from "../config/sessions/paths.js"; import { resolveSessionFilePath as resolveLegacySessionFilePath } from "../config/sessions/paths.js"; export { SessionStoreAgentIdRequiredError } from "../config/sessions/paths.js"; @@ -26,10 +30,6 @@ import { updateSessionEntry, } from "../config/sessions/session-accessor.js"; import { resolveSqliteTargetFromSessionStorePath } from "../config/sessions/session-sqlite-target.js"; -import { - formatSqliteSessionFileMarker, - parseSqliteSessionFileMarker, -} from "../config/sessions/sqlite-marker.js"; import { resolveSessionStoreEntry as resolveSessionStoreEntryFromStore } from "../config/sessions/store-entry.js"; import { normalizeResolvedMaintenanceConfigInput } from "../config/sessions/store-maintenance.js"; import type { ResolvedSessionMaintenanceConfigInput } from "../config/sessions/store-maintenance.js"; @@ -257,7 +257,7 @@ export function loadSessionStore( }).map(({ sessionKey, entry }) => { const sessionId = entry.sessionId?.trim(); const projectedEntry = projectPluginSessionEntry(entry as InternalSessionEntry); - if (projectedEntry.sessionFile || !sessionId) { + if (!sessionId) { return [sessionKey, projectedEntry]; } return [ @@ -584,7 +584,7 @@ export { parseSqliteSessionFileMarker, sqliteSessionFileMarkerMatchesSession, type SqliteSessionFileMarker, -} from "../config/sessions/sqlite-marker.js"; +} from "../config/sessions/legacy-sqlite-marker.js"; export { readRecentUserAssistantTextForSession, type SessionRecentConversationText, diff --git a/src/plugin-sdk/session-transcript-hit.ts b/src/plugin-sdk/session-transcript-hit.ts index 74da3e8b3345..122b93f07e8f 100644 --- a/src/plugin-sdk/session-transcript-hit.ts +++ b/src/plugin-sdk/session-transcript-hit.ts @@ -161,15 +161,6 @@ export function resolveTranscriptStemToSessionKeys(params: { if (isIncognitoSessionKey(sessionKey)) { continue; } - const sessionFile = normalizeOptionalString(entry.sessionFile); - if (sessionFile) { - const base = path.basename(sessionFile); - const fileStem = base.endsWith(".jsonl") ? base.slice(0, -".jsonl".length) : base; - if (fileStem === params.stem) { - matches.push(sessionKey); - continue; - } - } if (entry.sessionId === params.stem || (parsedStemId && entry.sessionId === parsedStemId)) { matches.push(sessionKey); } @@ -184,15 +175,6 @@ export function resolveTranscriptStemToSessionKeys(params: { if (isIncognitoSessionKey(sessionKey)) { continue; } - const sessionFile = normalizeOptionalString(entry.sessionFile); - if (sessionFile) { - const base = path.basename(sessionFile); - const fileStem = base.endsWith(".jsonl") ? base.slice(0, -".jsonl".length) : base; - if (normalizeQmdSessionStem(fileStem) === normalizedStem) { - matches.push(sessionKey); - continue; - } - } const entrySessionId = normalizeOptionalString(entry.sessionId); if (entrySessionId && normalizeQmdSessionStem(entrySessionId) === normalizedStem) { matches.push(sessionKey); diff --git a/src/plugin-sdk/session-transcript-runtime.test.ts b/src/plugin-sdk/session-transcript-runtime.test.ts index 51039c7c7d46..267f213a889e 100644 --- a/src/plugin-sdk/session-transcript-runtime.test.ts +++ b/src/plugin-sdk/session-transcript-runtime.test.ts @@ -709,6 +709,7 @@ describe("session transcript runtime SDK", () => { agentId: "main", sessionId: "publish-session", sessionKey: "agent:main:main", + storePath: path.join(tempDir, "openclaw-agent.sqlite"), }, }); expect(internalUpdates).toEqual([ @@ -721,6 +722,7 @@ describe("session transcript runtime SDK", () => { agentId: "main", sessionId: "publish-session", sessionKey: "agent:main:main", + storePath: path.join(tempDir, "openclaw-agent.sqlite"), }, }, ]); @@ -845,6 +847,7 @@ describe("session transcript runtime SDK", () => { agentId: "main", sessionId: "queued-publish-session", sessionKey: "agent:main:main", + storePath: path.join(tempDir, "openclaw-agent.sqlite"), }, }); await expect(readSessionTranscriptEvents(scope)).resolves.toEqual([ diff --git a/src/plugin-sdk/sqlite-runtime-testing.ts b/src/plugin-sdk/sqlite-runtime-testing.ts index e057c6069f03..56d9aecc882c 100644 --- a/src/plugin-sdk/sqlite-runtime-testing.ts +++ b/src/plugin-sdk/sqlite-runtime-testing.ts @@ -15,7 +15,7 @@ export async function appendSqliteSessionTranscriptEventForTest( await appendTranscriptEvent(params, params.event); } -export { formatSqliteSessionFileMarker } from "../config/sessions/sqlite-marker.js"; +export { formatSqliteSessionFileMarker } from "../config/sessions/legacy-sqlite-marker.js"; export { appendSqliteTrajectoryRuntimeEvents, loadSqliteTrajectoryRuntimeEvents, diff --git a/src/plugin-sdk/test-helpers/agents/session-manager-file-compat.ts b/src/plugin-sdk/test-helpers/agents/session-manager-file-compat.ts new file mode 100644 index 000000000000..87976139bc6e --- /dev/null +++ b/src/plugin-sdk/test-helpers/agents/session-manager-file-compat.ts @@ -0,0 +1,94 @@ +import { randomUUID } from "node:crypto"; +import fs from "node:fs"; +import path from "node:path"; +import { parseSessionEntries, SessionManager } from "../../agent-sessions.js"; + +const TEST_SESSION_MANAGER_COMPAT = Symbol.for("openclaw.testSessionManagerCompat"); + +function installFileSessionManagerCompat(params: { + manager: SessionManager; + sessionDir: string; + target: () => string; + initialize: boolean; + rotateTarget?: (sessionId: string) => void; +}): SessionManager { + const manager = params.manager as SessionManager & { + persistRecord(entry: unknown): void; + replacePersistedTranscript(): void; + }; + const writeFullFile = () => { + const target = params.target(); + fs.mkdirSync(path.dirname(target), { recursive: true }); + const entries = manager.getPersistedEntries(); + fs.writeFileSync(target, `${entries.map((entry) => JSON.stringify(entry)).join("\n")}\n`); + }; + const originalNewSession = manager.newSession.bind(manager); + Object.assign(manager, { + getSessionDir: () => params.sessionDir, + getSessionFile: () => params.target(), + newSession(options?: Parameters[0]) { + const sessionId = options?.id ?? randomUUID(); + params.rotateTarget?.(sessionId); + const result = originalNewSession({ ...options, id: sessionId }); + writeFullFile(); + return result; + }, + persistRecord(entry: unknown) { + const target = params.target(); + fs.mkdirSync(path.dirname(target), { recursive: true }); + if (fs.existsSync(target)) { + const contents = fs.readFileSync(target); + if (contents.length > 0 && contents.at(-1) !== 0x0a) { + fs.appendFileSync(target, "\n"); + } + } + fs.appendFileSync(target, `${JSON.stringify(entry)}\n`); + }, + replacePersistedTranscript: writeFullFile, + }); + if (params.initialize) { + writeFullFile(); + } + return manager; +} + +export function installSessionManagerFileCompat( + SessionManagerClass: typeof SessionManager = SessionManager, +): void { + const sessionManagerConstructor = SessionManagerClass as typeof SessionManager & { + [TEST_SESSION_MANAGER_COMPAT]?: true; + create?: (cwd: string, sessionDir?: string) => SessionManager; + }; + if (sessionManagerConstructor[TEST_SESSION_MANAGER_COMPAT]) { + return; + } + Object.assign(SessionManagerClass, { + create(cwd: string, sessionDir?: string) { + const manager = SessionManagerClass.inMemory(cwd); + const resolvedSessionDir = sessionDir ?? cwd; + return installFileSessionManagerCompat({ + manager, + sessionDir: resolvedSessionDir, + target: () => path.join(resolvedSessionDir, `${manager.getSessionId()}.jsonl`), + initialize: true, + }); + }, + openFile(target: string, sessionDir?: string, cwd?: string) { + let activeTarget = target; + const exists = fs.existsSync(target); + const manager = exists + ? SessionManagerClass.fromEntries(parseSessionEntries(fs.readFileSync(target, "utf8")), cwd) + : SessionManagerClass.inMemory(cwd ?? sessionDir ?? process.cwd()); + return installFileSessionManagerCompat({ + manager, + sessionDir: sessionDir ?? path.dirname(target), + target: () => activeTarget, + initialize: !exists, + rotateTarget: (sessionId) => { + activeTarget = path.join(sessionDir ?? path.dirname(target), `${sessionId}.jsonl`); + }, + }); + }, + }); + sessionManagerConstructor[TEST_SESSION_MANAGER_COMPAT] = true; +} diff --git a/src/plugins/commands.ts b/src/plugins/commands.ts index 9b5de89cac7d..f468d9c88032 100644 --- a/src/plugins/commands.ts +++ b/src/plugins/commands.ts @@ -225,6 +225,7 @@ export async function executePluginCommand(params: { agentId?: string; sessionKey?: PluginCommandContext["sessionKey"]; sessionId?: PluginCommandContext["sessionId"]; + sessionTarget?: PluginCommandContext["sessionTarget"]; sessionFile?: PluginCommandContext["sessionFile"]; authProfileId?: string; commandBody: string; @@ -336,6 +337,7 @@ export async function executePluginCommand(params: { agentId: params.agentId, sessionKey: params.sessionKey, sessionId: params.sessionId, + sessionTarget: params.sessionTarget, sessionFile: params.sessionFile, args: sanitizedArgs, commandBody, diff --git a/src/plugins/contracts/session-entry-projection.contract.test.ts b/src/plugins/contracts/session-entry-projection.contract.test.ts index 5b86a528f574..afe46d779855 100644 --- a/src/plugins/contracts/session-entry-projection.contract.test.ts +++ b/src/plugins/contracts/session-entry-projection.contract.test.ts @@ -310,6 +310,11 @@ describe("plugin session extension SessionEntry projection", () => { description: "bad run error slot", sessionEntrySlotKey: "lastRunError", }); + api.registerSessionExtension({ + namespace: "transcript-path", + description: "retired transcript locator", + sessionEntrySlotKey: "transcriptPath", + }); }, }); @@ -333,6 +338,10 @@ describe("plugin session extension SessionEntry projection", () => { pluginId: "slot-collision", message: "sessionEntrySlotKey is reserved by SessionEntry: lastRunError", }, + { + pluginId: "slot-collision", + message: "sessionEntrySlotKey is reserved by SessionEntry: transcriptPath", + }, ]); }); diff --git a/src/plugins/host-hook-state.ts b/src/plugins/host-hook-state.ts index f9d220694f26..4e8dea79ed3c 100644 --- a/src/plugins/host-hook-state.ts +++ b/src/plugins/host-hook-state.ts @@ -320,7 +320,7 @@ export async function patchPluginSessionExtension(params: { { cfg: params.cfg, sessionKey: params.sessionKey }, (entry, context) => { params.assertCurrent?.(); - const entryRecord = entry as Record; + const entryRecord = entry as unknown as Record; const pluginExtensions = { ...entry.pluginExtensions }; const pluginState = { ...pluginExtensions[pluginId] }; if (params.unset === true) { diff --git a/src/plugins/plugin-command.types.ts b/src/plugins/plugin-command.types.ts index 6519e14ce76b..b1faf4caee79 100644 --- a/src/plugins/plugin-command.types.ts +++ b/src/plugins/plugin-command.types.ts @@ -9,6 +9,13 @@ import type { type ChannelId = import("../channels/plugins/types.core.js").ChannelId; +type PluginCommandSessionTarget = { + agentId: string; + sessionId: string; + sessionKey: string; + storePath: string; +}; + // ============================================================================= // Plugin Commands // ============================================================================= @@ -18,6 +25,8 @@ export type PluginCommandDiagnosticsSession = { sessionKey?: string; /** Ephemeral OpenClaw session id when available. */ sessionId?: string; + /** Canonical SQLite identity for active transcript access. */ + sessionTarget?: PluginCommandSessionTarget; /** * Deprecated transcript locator for this OpenClaw session when available. * @@ -64,6 +73,8 @@ export type PluginCommandContext = { sessionKey?: string; /** Ephemeral host session id for the active conversation when available. */ sessionId?: string; + /** Canonical SQLite identity for active transcript access. */ + sessionTarget?: PluginCommandSessionTarget; /** * Deprecated transcript locator for the active OpenClaw session when available. * diff --git a/src/plugins/registry-runtime.ts b/src/plugins/registry-runtime.ts index ad2daf916d6f..48c802d88fa5 100644 --- a/src/plugins/registry-runtime.ts +++ b/src/plugins/registry-runtime.ts @@ -2,6 +2,10 @@ import { normalizeOptionalString } from "@openclaw/normalization-core/string-coe import { normalizeOptionalAgentRuntimeId } from "../agents/agent-runtime-id.js"; import { createChannelIngressDrain } from "../channels/message/ingress-drain.js"; import { createChannelIngressQueue } from "../channels/message/ingress-queue.js"; +import { + parseSqliteSessionFileMarker, + sqliteSessionFileMarkerMatchesTarget, +} from "../config/sessions/legacy-sqlite-marker.js"; import type { SessionEntry } from "../config/sessions/types.js"; import { createPluginBlobStore, @@ -310,13 +314,58 @@ export function createPluginRuntimeResolver(state: PluginRegistryState) { readOnly: true, }); for (const { sessionKey, entry } of entries) { - if ( - sessionIds.has(entry.sessionId) || - (entry.sessionFile ? sessionFiles.has(entry.sessionFile) : false) - ) { + if (sessionIds.has(entry.sessionId)) { assertSessionEntryOwned({ action: params.action, entry, sessionKey }); } } + for (const sessionFile of sessionFiles) { + const sessionKeyMatches = entries.filter(({ sessionKey }) => sessionKey === sessionFile); + if (sessionKeyMatches.length > 0) { + for (const match of sessionKeyMatches) { + assertSessionEntryOwned({ + action: params.action, + entry: match.entry, + sessionKey: match.sessionKey, + }); + } + const matchedSessionIds = new Set( + sessionKeyMatches + .map(({ entry }) => normalizeOptionalString(entry.sessionId)) + .filter((sessionId): sessionId is string => Boolean(sessionId)), + ); + for (const match of entries) { + const matchSessionId = normalizeOptionalString(match.entry.sessionId); + if (matchSessionId && matchedSessionIds.has(matchSessionId)) { + assertSessionEntryOwned({ + action: params.action, + entry: match.entry, + sessionKey: match.sessionKey, + }); + } + } + continue; + } + const marker = parseSqliteSessionFileMarker(sessionFile); + if (!marker) { + throw new Error("Plugin session ownership checks require a SQLite transcript marker."); + } + const markerEntries = registryParams.runtime.agent.session.listSessionEntries({ + agentId: marker.agentId, + storePath: marker.storePath, + readOnly: true, + }); + const matches = markerEntries.filter(({ entry }) => entry.sessionId === marker.sessionId); + if (matches.length === 0) { + throw new Error(`Plugin session ownership target not found: ${marker.sessionId}`); + } + for (const match of matches) { + assertSessionEntryOwned({ + action: params.action, + entry: match.entry, + sessionKey: match.sessionKey, + }); + } + } }; const resolveRunSessionExecutionOwner = ( params: Parameters[0], @@ -344,6 +393,16 @@ export function createPluginRuntimeResolver(state: PluginRegistryState) { const directAgentId = normalizeOptionalString(params.agentId); const sessionFile = normalizeOptionalString(params.sessionFile); if (target) { + const legacySessionIdentityMatches = + Boolean(sessionFile) && + Boolean(agentId) && + Boolean(storePath) && + Boolean(entry?.sessionId) && + sqliteSessionFileMarkerMatchesTarget(sessionFile, { + agentId: agentId!, + sessionId: entry!.sessionId, + storePath: storePath!, + }); const targetIdentityMatches = targetSessionKey === sessionKey && Boolean(storePath) && @@ -351,7 +410,7 @@ export function createPluginRuntimeResolver(state: PluginRegistryState) { targetSessionId === entry?.sessionId && directSessionId === entry?.sessionId && targetAgentId === directAgentId && - (!sessionFile || sessionFile === entry?.sessionFile); + (!sessionFile || sessionFile === sessionKey || legacySessionIdentityMatches); if (!targetIdentityMatches) { throw new Error( `Plugin "${pluginId}" may execute a persisted session only with its exact session target identity.`, diff --git a/src/plugins/registry.runtime-config.test.ts b/src/plugins/registry.runtime-config.test.ts index 76fcde5badf4..d9bc2b84f5e6 100644 --- a/src/plugins/registry.runtime-config.test.ts +++ b/src/plugins/registry.runtime-config.test.ts @@ -2,6 +2,8 @@ import os from "node:os"; import path from "node:path"; import { describe, expect, it, vi } from "vitest"; +import { formatSqliteSessionFileMarker } from "../config/sessions/legacy-sqlite-marker.js"; +import type { SessionEntry } from "../config/sessions/types.js"; import type { OpenClawConfig } from "../config/types.openclaw.js"; import { resolveUserPath } from "../utils.js"; import { createPluginRecord } from "./loader-records.js"; @@ -448,16 +450,30 @@ describe("plugin registry runtime config scope", () => { it("limits locked harness session mutation and execution to the harness owner", async () => { const reservedKey = "agent:main:harness:codex:thread-1"; const ordinaryKey = "agent:main:ordinary"; + const ordinaryAliasKey = "agent:main:ordinary-alias"; + const ordinaryNoIdKey = "agent:main:ordinary-no-id"; + const lockedNoIdKey = "agent:main:locked-no-id"; const lockedOrdinaryKey = "agent:main:ordinary-locked"; const legacyPrefixedKey = "agent:main:harness:notes"; const reservedEntry = { sessionId: "reserved-session", - sessionFile: "/tmp/reserved.jsonl", + sessionFile: formatSqliteSessionFileMarker({ + agentId: "main", + sessionId: "reserved-session", + storePath: "/tmp/sessions.json", + }), updatedAt: 1, agentHarnessId: "codex", modelSelectionLocked: true as const, }; const ordinaryEntry = { sessionId: "ordinary-session", updatedAt: 1 }; + const ordinaryAliasEntry = { sessionId: reservedEntry.sessionId, updatedAt: 1 }; + const ordinaryNoIdEntry = { updatedAt: 1 }; + const lockedNoIdEntry = { + updatedAt: 1, + agentHarnessId: "codex", + modelSelectionLocked: true as const, + }; const lockedOrdinaryEntry = { sessionId: "locked-ordinary-session", updatedAt: 1, @@ -470,11 +486,15 @@ describe("plugin registry runtime config scope", () => { agentHarnessId: "legacy-runtime", }; const entries = { + [ordinaryAliasKey]: ordinaryAliasEntry, + [ordinaryNoIdKey]: ordinaryNoIdEntry, + [lockedNoIdKey]: lockedNoIdEntry, [reservedKey]: reservedEntry, [ordinaryKey]: ordinaryEntry, [lockedOrdinaryKey]: lockedOrdinaryEntry, [legacyPrefixedKey]: legacyPrefixedEntry, }; + const typedEntries = entries as unknown as Record; const subagent = { run: vi.fn(async () => ({ runId: "subagent-run" })), waitForRun: vi.fn(async () => ({ status: "ok" as const })), @@ -483,12 +503,12 @@ describe("plugin registry runtime config scope", () => { } satisfies PluginRuntime["subagent"]; const runtime = createPluginRuntime({ subagent }); const session = runtime.agent.session; - session.getSessionEntry = vi.fn((params) => entries[params.sessionKey as keyof typeof entries]); + session.getSessionEntry = vi.fn((params) => typedEntries[params.sessionKey]); session.listSessionEntries = vi.fn(() => - Object.entries(entries).map(([sessionKey, entry]) => ({ sessionKey, entry })), + Object.entries(typedEntries).map(([sessionKey, entry]) => ({ sessionKey, entry })), ); session.patchSessionEntry = vi.fn(async (params) => { - const entry = entries[params.sessionKey as keyof typeof entries]; + const entry = typedEntries[params.sessionKey]; if (!entry) { return null; } @@ -499,7 +519,7 @@ describe("plugin registry runtime config scope", () => { }); session.upsertSessionEntry = vi.fn(async () => {}); session.updateSessionStoreEntry = vi.fn( - async (params) => entries[params.sessionKey as keyof typeof entries], + async (params) => typedEntries[params.sessionKey] ?? null, ); let admissionScope = getPluginRuntimeGatewayRequestScope(); session.runWithWorkAdmission = vi.fn(async (_params, run) => { @@ -633,8 +653,36 @@ describe("plugin registry runtime config scope", () => { otherApi.runtime.agent.runEmbeddedAgent({ ...runParams, sessionKey: undefined, - }), + } as never), ).rejects.toThrow('owned by plugin "codex-owner"'); + await expect( + otherApi.runtime.agent.runEmbeddedAgent({ + ...runParams, + sessionId: undefined, + sessionKey: undefined, + sessionFile: formatSqliteSessionFileMarker({ + agentId: "main", + sessionId: reservedEntry.sessionId, + storePath: "/tmp/sessions.json", + }), + } as never), + ).rejects.toThrow('owned by plugin "codex-owner"'); + await expect( + otherApi.runtime.agent.runEmbeddedAgent({ + ...runParams, + sessionId: undefined, + sessionKey: undefined, + sessionFile: ordinaryAliasKey, + } as never), + ).rejects.toThrow('owned by plugin "codex-owner"'); + await expect( + otherApi.runtime.agent.runEmbeddedAgent({ + ...runParams, + sessionId: undefined, + sessionKey: undefined, + sessionFile: ordinaryNoIdKey, + } as never), + ).resolves.toEqual({ ok: true }); await expect( otherApi.runtime.agent.runEmbeddedAgent({ ...runParams, diff --git a/src/plugins/runtime/runtime-agent.test.ts b/src/plugins/runtime/runtime-agent.test.ts index 3b001f2dc1e1..fb17a59fcdb2 100644 --- a/src/plugins/runtime/runtime-agent.test.ts +++ b/src/plugins/runtime/runtime-agent.test.ts @@ -119,8 +119,6 @@ describe("plugin runtime session creation", () => { expect( runtime.session.getSessionEntry({ sessionKey: key, readConsistency: "latest" }), ).toEqual(created.entry); - expect(stored?.sessionFile).toBeTruthy(); - expect(stored?.sessionFile).toContain(`sqlite:main:${created.sessionId}:`); }); }); @@ -172,8 +170,6 @@ describe("plugin runtime session creation", () => { await withOpenClawTestState({ label: "plugin-runtime-session-create-rollback" }, async () => { const runtime = createRuntimeAgent(); const key = "agent:main:dashboard:codex-binding-failure"; - let sessionFile: string | undefined; - await expect( runtime.session.createSessionEntry({ cfg: {}, @@ -182,8 +178,7 @@ describe("plugin runtime session creation", () => { agentHarnessId: "codex", modelSelectionLocked: true, }, - afterCreate: async (created) => { - sessionFile = created.entry.sessionFile; + afterCreate: async () => { throw new Error("native binding failed"); }, }), @@ -192,8 +187,6 @@ describe("plugin runtime session creation", () => { expect(runtime.session.getSessionEntry({ sessionKey: key, readConsistency: "latest" })).toBe( undefined, ); - expect(sessionFile).toBeTruthy(); - expect(fs.existsSync(sessionFile ?? "")).toBe(false); }); }); @@ -203,7 +196,6 @@ describe("plugin runtime session creation", () => { const key = "agent:main:catalog-adopt:claude:rollback"; const storePath = runtime.session.resolveStorePath(undefined, { agentId: "main" }); let sessionId: string | undefined; - let sessionFile: string | undefined; await expect( runtime.session.createSessionEntry({ @@ -222,7 +214,6 @@ describe("plugin runtime session creation", () => { }, afterCreate: async (created) => { sessionId = created.sessionId; - sessionFile = created.entry.sessionFile; throw new Error("history import failed"); }, }), @@ -231,8 +222,6 @@ describe("plugin runtime session creation", () => { expect( runtime.session.getSessionEntry({ sessionKey: key, readConsistency: "latest" }), ).toBeUndefined(); - expect(sessionFile).toBeTruthy(); - expect(sessionFile).toContain(`sqlite:main:${sessionId}:`); await expect( loadTranscriptEvents({ agentId: "main", @@ -250,15 +239,12 @@ describe("plugin runtime session creation", () => { async () => { const runtime = createRuntimeAgent(); const key = "agent:main:dashboard:unlocked-binding-failure"; - let sessionFile: string | undefined; - await expect( runtime.session.createSessionEntry({ cfg: {}, key, initialEntry: { agentHarnessId: "codex" }, - afterCreate: async (created) => { - sessionFile = created.entry.sessionFile; + afterCreate: async () => { throw new Error("unlocked native binding failed"); }, }), @@ -267,8 +253,6 @@ describe("plugin runtime session creation", () => { expect( runtime.session.getSessionEntry({ sessionKey: key, readConsistency: "latest" }), ).toBeUndefined(); - expect(sessionFile).toBeTruthy(); - expect(fs.existsSync(sessionFile ?? "")).toBe(false); }, ); }); @@ -310,8 +294,6 @@ describe("plugin runtime session creation", () => { async () => { const runtime = createRuntimeAgent(); const key = "agent:main:dashboard:codex-final-patch-failure"; - let sessionFile: string | undefined; - await expect( runtime.session.createSessionEntry({ cfg: {}, @@ -323,8 +305,7 @@ describe("plugin runtime session creation", () => { codex: { supervision: { initializing: true } }, }, }, - afterCreate: async (created) => { - sessionFile = created.entry.sessionFile; + afterCreate: async () => { return { pluginExtensions: { codex: { supervision: { invalidJsonValue: 1n as never } }, @@ -337,8 +318,6 @@ describe("plugin runtime session creation", () => { expect( runtime.session.getSessionEntry({ sessionKey: key, readConsistency: "latest" }), ).toBeUndefined(); - expect(sessionFile).toBeTruthy(); - expect(fs.existsSync(sessionFile ?? "")).toBe(false); }, ); }); @@ -349,15 +328,12 @@ describe("plugin runtime session creation", () => { async () => { const runtime = createRuntimeAgent(); const key = "agent:main:dashboard:unlocked-final-patch-failure"; - let sessionFile: string | undefined; - await expect( runtime.session.createSessionEntry({ cfg: {}, key, initialEntry: { agentHarnessId: "codex" }, - afterCreate: async (created) => { - sessionFile = created.entry.sessionFile; + afterCreate: async () => { return { pluginExtensions: { codex: { supervision: { invalidJsonValue: 1n as never } }, @@ -370,8 +346,6 @@ describe("plugin runtime session creation", () => { expect( runtime.session.getSessionEntry({ sessionKey: key, readConsistency: "latest" }), ).toBeUndefined(); - expect(sessionFile).toBeTruthy(); - expect(fs.existsSync(sessionFile ?? "")).toBe(false); }, ); }); diff --git a/src/plugins/session-entry-slot-keys.ts b/src/plugins/session-entry-slot-keys.ts index b0fd9e3dfb06..1c26c8df4f32 100644 --- a/src/plugins/session-entry-slot-keys.ts +++ b/src/plugins/session-entry-slot-keys.ts @@ -1,5 +1,5 @@ /** Reserves session-entry keys so plugin extension slots cannot collide with core session state. */ -import type { InternalSessionEntry as SessionEntry } from "../config/sessions/types.js"; +import type { InternalSessionEntryCore as SessionEntry } from "../config/sessions/types.js"; const SESSION_ENTRY_RESERVED_SLOT_KEY_LIST = [ "__proto__", @@ -27,6 +27,7 @@ const SESSION_ENTRY_RESERVED_SLOT_KEY_LIST = [ "markedUnreadAt", "lastActivityAt", "sessionFile", + "transcriptPath", "spawnedBy", "completionOwnerSessionKey", "spawnedWorkspaceDir", @@ -175,7 +176,9 @@ const SESSION_ENTRY_RESERVED_SLOT_KEY_LIST = [ "acp", "quotaSuspension", "visibility", -] as const satisfies ReadonlyArray; +] as const satisfies ReadonlyArray< + keyof SessionEntry | "__proto__" | "constructor" | "prototype" | "sessionFile" | "transcriptPath" +>; type ReservedSessionEntrySlotKey = Extract< (typeof SESSION_ENTRY_RESERVED_SLOT_KEY_LIST)[number], diff --git a/src/sessions/transcript-events.ts b/src/sessions/transcript-events.ts index 01f18b11a611..664e5e640e65 100644 --- a/src/sessions/transcript-events.ts +++ b/src/sessions/transcript-events.ts @@ -8,6 +8,7 @@ type SessionTranscriptUpdateTarget = { agentId: string; sessionId: string; sessionKey: string; + storePath?: string; }; type SessionTranscriptUpdateFields = { @@ -156,6 +157,7 @@ function normalizeUpdateTarget(update: { (sessionKey ? parseAgentSessionKey(sessionKey)?.agentId : undefined); const sessionId = normalizeOptionalString(update.target?.sessionId) ?? normalizeOptionalString(update.sessionId); + const storePath = normalizeOptionalString(update.target?.storePath); if (!agentId || !sessionId || !sessionKey) { return undefined; } @@ -163,5 +165,6 @@ function normalizeUpdateTarget(update: { agentId, sessionId, sessionKey, + ...(storePath ? { storePath } : {}), }; } diff --git a/src/sessions/user-turn-transcript.persistence.test.ts b/src/sessions/user-turn-transcript.persistence.test.ts index 0ef25d1a9e96..acf9fc7aceb6 100644 --- a/src/sessions/user-turn-transcript.persistence.test.ts +++ b/src/sessions/user-turn-transcript.persistence.test.ts @@ -10,8 +10,8 @@ import { createMockPluginRegistry } from "openclaw/plugin-sdk/plugin-test-runtim import { castAgentMessage } from "openclaw/plugin-sdk/test-fixtures"; import { afterEach, describe, expect, it } from "vitest"; import { runAgentHarnessBeforeMessageWriteHook } from "../agents/harness/hook-helpers.js"; +import { formatSqliteSessionFileMarker } from "../config/sessions/legacy-sqlite-marker.js"; import { loadTranscriptEvents } from "../config/sessions/session-accessor.js"; -import { formatSqliteSessionFileMarker } from "../config/sessions/sqlite-marker.js"; import { persistUserTurnTranscript } from "./user-turn-transcript.test-support.js"; describe("persistUserTurnTranscript", () => { diff --git a/src/sessions/user-turn-transcript.test.ts b/src/sessions/user-turn-transcript.test.ts index 890b4eaeba55..f34954a1f3e5 100644 --- a/src/sessions/user-turn-transcript.test.ts +++ b/src/sessions/user-turn-transcript.test.ts @@ -4,8 +4,8 @@ import os from "node:os"; import path from "node:path"; import { castAgentMessage } from "openclaw/plugin-sdk/test-fixtures"; import { afterEach, describe, expect, it } from "vitest"; +import { formatSqliteSessionFileMarker } from "../config/sessions/legacy-sqlite-marker.js"; import { loadTranscriptEvents } from "../config/sessions/session-accessor.js"; -import { formatSqliteSessionFileMarker } from "../config/sessions/sqlite-marker.js"; import { buildLateMediaAttachedProjection, createUserTurnTranscriptRecorder, @@ -241,7 +241,7 @@ describe("user turn transcript persistence", () => { updateMode: "none", }); - expect(persisted?.sessionFile).toBe(target.sqliteMarker); + expect(persisted?.sessionFile).toBe(target.sessionKey); await expect(readTranscriptMessages(target)).resolves.toEqual([ expect.objectContaining({ role: "user", @@ -722,7 +722,7 @@ describe("user turn transcript persistence", () => { }, }); - expect(persisted?.sessionFile).toBe(admittedTarget.sqliteMarker); + expect(persisted?.sessionFile).toBe(admittedTarget.sessionKey); await expect(readTranscriptMessages(staleTarget)).resolves.toEqual([]); await expect(readTranscriptMessages(admittedTarget)).resolves.toEqual([ expect.objectContaining({ @@ -752,7 +752,7 @@ describe("user turn transcript persistence", () => { const persisted = await recorder.persistApproved({ retryIfUnpersisted: true }); expect(targetResolutionCount).toBe(2); - expect(persisted?.sessionFile).toBe(admittedTarget.sqliteMarker); + expect(persisted?.sessionFile).toBe(admittedTarget.sessionKey); await expect(readTranscriptMessages(admittedTarget)).resolves.toEqual([ expect.objectContaining({ role: "user", @@ -784,8 +784,8 @@ describe("user turn transcript persistence", () => { ]); expect(targetResolutionCount).toBe(2); - expect(first?.sessionFile).toBe(admittedTarget.sqliteMarker); - expect(second?.sessionFile).toBe(admittedTarget.sqliteMarker); + expect(first?.sessionFile).toBe(admittedTarget.sessionKey); + expect(second?.sessionFile).toBe(admittedTarget.sessionKey); await expect(readTranscriptMessages(admittedTarget)).resolves.toEqual([ expect.objectContaining({ role: "user", diff --git a/src/sessions/user-turn-transcript.ts b/src/sessions/user-turn-transcript.ts index 2b62b98a59ef..b46c43579039 100644 --- a/src/sessions/user-turn-transcript.ts +++ b/src/sessions/user-turn-transcript.ts @@ -414,7 +414,7 @@ async function persistUserTurnTranscript( return { ...appended, sessionEntry: turn.sessionEntry, - sessionFile: turn.sessionFile, + sessionFile: params.sessionKey, }; } diff --git a/src/sessions/user-turn-transcript.types.ts b/src/sessions/user-turn-transcript.types.ts index 58d317b3ca89..b8ab92453dfe 100644 --- a/src/sessions/user-turn-transcript.types.ts +++ b/src/sessions/user-turn-transcript.types.ts @@ -4,15 +4,11 @@ import type { SessionTranscriptTurnExpectedState, SessionTranscriptTurnLifecyclePatch, } from "../config/sessions/session-transcript-turn-lifecycle.types.js"; +import type { SessionEntry } from "../config/sessions/types.js"; import type { MediaFactInput } from "../media/media-facts.js"; import type { InputProvenance } from "./input-provenance.js"; -type UserTurnSessionEntry = { - sessionId: string; - updatedAt: number; - sessionFile?: string; - threadId?: string | number; -} & Record; +type UserTurnSessionEntry = SessionEntry; export type PersistedUserTurnMediaInput = Pick< MediaFactInput, diff --git a/src/skills/workshop/experience-review.ts b/src/skills/workshop/experience-review.ts index e9522d01df81..cb381793e832 100644 --- a/src/skills/workshop/experience-review.ts +++ b/src/skills/workshop/experience-review.ts @@ -1,7 +1,5 @@ import { randomUUID } from "node:crypto"; -import fs from "node:fs/promises"; -import os from "node:os"; -import path from "node:path"; +import { SessionManager } from "../../agents/sessions/index.js"; import type { ChatType } from "../../channels/chat-type.js"; import type { OpenClawConfig } from "../../config/types.openclaw.js"; import { createSubsystemLogger } from "../../logging/subsystem.js"; @@ -373,65 +371,60 @@ export async function runSkillExperienceReview( return; } - const tempDir = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-skill-review-")); - try { - const sessionId = randomUUID(); - const reviewSessionKey = `agent:${candidate.ctx.agentId ?? "main"}:${EXPERIENCE_REVIEW_SESSION_SEGMENT}:${sessionId}`; - const { runEmbeddedAgent } = await import("../../agents/embedded-agent.js"); - await runEmbeddedAgent({ - sessionId, - sessionKey: reviewSessionKey, - sandboxSessionKey: sessionKey, - sessionFile: path.join(tempDir, "session.jsonl"), + const sessionId = randomUUID(); + const reviewSessionKey = `agent:${candidate.ctx.agentId ?? "main"}:${EXPERIENCE_REVIEW_SESSION_SEGMENT}:incognito-${sessionId}`; + const { runEmbeddedAgent } = await import("../../agents/embedded-agent.js"); + await runEmbeddedAgent({ + sessionId, + sessionKey: reviewSessionKey, + sandboxSessionKey: sessionKey, + sessionManager: SessionManager.inMemory(workspaceDir), + ...(candidate.ctx.agentId ? { agentId: candidate.ctx.agentId } : {}), + trigger: "manual", + // Never occupy the foreground agent lane after the idle gate opens. + lane: CommandLane.SkillWorkshopReview, + messageChannel: candidate.ctx.messageChannel ?? undefined, + messageProvider: candidate.ctx.messageProvider ?? undefined, + ...(candidate.ctx.chatType ? { chatType: candidate.ctx.chatType } : {}), + ...(candidate.ctx.agentAccountId ? { agentAccountId: candidate.ctx.agentAccountId } : {}), + groupId: candidate.ctx.groupId, + groupChannel: candidate.ctx.groupChannel, + groupSpace: candidate.ctx.groupSpace, + memberRoleIds: candidate.ctx.memberRoleIds ? [...candidate.ctx.memberRoleIds] : undefined, + spawnedBy: candidate.ctx.spawnedBy, + senderId: candidate.ctx.senderId, + senderName: candidate.ctx.senderName, + senderUsername: candidate.ctx.senderUsername, + senderE164: candidate.ctx.senderE164, + senderIsOwner: candidate.ctx.senderIsOwner, + agentHarnessId: "openclaw", + agentHarnessRuntimeOverride: "openclaw", + workspaceDir, + ...(candidate.config ? { config: candidate.config } : {}), + prompt: buildSkillExperienceReviewPrompt(candidate), + provider: modelProviderId, + model: modelId, + modelSelectionLocked: true, + modelFallbacksOverride: [], + ...(candidate.ctx.authProfileId + ? { authProfileId: candidate.ctx.authProfileId, authProfileIdSource: "user" as const } + : {}), + timeoutMs: EXPERIENCE_REVIEW_TIMEOUT_MS, + runId: `skill-workshop-review:${randomUUID()}`, + toolsAllow: ["skill_workshop"], + disableMessageTool: true, + disableTrajectory: true, + skillWorkshopProposalOnly: true, + skillWorkshopOrigin: { ...(candidate.ctx.agentId ? { agentId: candidate.ctx.agentId } : {}), - trigger: "manual", - // Never occupy the foreground agent lane after the idle gate opens. - lane: CommandLane.SkillWorkshopReview, - messageChannel: candidate.ctx.messageChannel ?? undefined, - messageProvider: candidate.ctx.messageProvider ?? undefined, - ...(candidate.ctx.chatType ? { chatType: candidate.ctx.chatType } : {}), - ...(candidate.ctx.agentAccountId ? { agentAccountId: candidate.ctx.agentAccountId } : {}), - groupId: candidate.ctx.groupId, - groupChannel: candidate.ctx.groupChannel, - groupSpace: candidate.ctx.groupSpace, - memberRoleIds: candidate.ctx.memberRoleIds ? [...candidate.ctx.memberRoleIds] : undefined, - spawnedBy: candidate.ctx.spawnedBy, - senderId: candidate.ctx.senderId, - senderName: candidate.ctx.senderName, - senderUsername: candidate.ctx.senderUsername, - senderE164: candidate.ctx.senderE164, - senderIsOwner: candidate.ctx.senderIsOwner, - agentHarnessId: "openclaw", - agentHarnessRuntimeOverride: "openclaw", - workspaceDir, - ...(candidate.config ? { config: candidate.config } : {}), - prompt: buildSkillExperienceReviewPrompt(candidate), - provider: modelProviderId, - model: modelId, - modelSelectionLocked: true, - modelFallbacksOverride: [], - ...(candidate.ctx.authProfileId - ? { authProfileId: candidate.ctx.authProfileId, authProfileIdSource: "user" as const } - : {}), - timeoutMs: EXPERIENCE_REVIEW_TIMEOUT_MS, - runId: `skill-workshop-review:${randomUUID()}`, - toolsAllow: ["skill_workshop"], - disableMessageTool: true, - disableTrajectory: true, - skillWorkshopProposalOnly: true, - skillWorkshopOrigin: { - ...(candidate.ctx.agentId ? { agentId: candidate.ctx.agentId } : {}), - sessionKey, - ...(candidate.ctx.runId ? { runId: candidate.ctx.runId } : {}), - }, - cleanupBundleMcpOnRunEnd: true, - bootstrapContextMode: "lightweight", - skillsSnapshot: { prompt: "", skills: [] }, - verboseLevel: "off", - reasoningLevel: "off", - suppressToolErrorWarnings: true, - }); - } finally { - await fs.rm(tempDir, { recursive: true, force: true }); - } + sessionKey, + ...(candidate.ctx.runId ? { runId: candidate.ctx.runId } : {}), + }, + cleanupBundleMcpOnRunEnd: true, + bootstrapContextMode: "lightweight", + skillsSnapshot: { prompt: "", skills: [] }, + verboseLevel: "off", + reasoningLevel: "off", + suppressToolErrorWarnings: true, + }); } diff --git a/src/skills/workshop/history-scan-review.ts b/src/skills/workshop/history-scan-review.ts index 217a1d514dab..28759d26b5e3 100644 --- a/src/skills/workshop/history-scan-review.ts +++ b/src/skills/workshop/history-scan-review.ts @@ -1,8 +1,6 @@ import { randomUUID } from "node:crypto"; -import fs from "node:fs/promises"; -import os from "node:os"; -import path from "node:path"; import { resolveDefaultModelForAgent } from "../../agents/model-selection-config.js"; +import { SessionManager } from "../../agents/sessions/index.js"; import type { OpenClawConfig } from "../../config/types.openclaw.js"; import { CommandLane } from "../../process/lanes.js"; import { @@ -58,18 +56,17 @@ export async function runSkillHistoryScanReview(params: { recordProgress: params.onProgress, } : undefined; - const tempDir = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-skill-history-scan-")); const runId = params.runId ?? `${HISTORY_SCAN_SESSION_SEGMENT}:${randomUUID()}`; let runError: unknown; try { const sessionId = randomUUID(); - const sessionKey = `agent:${params.agentId}:${HISTORY_SCAN_SESSION_SEGMENT}:${sessionId}`; + const sessionKey = `agent:${params.agentId}:${HISTORY_SCAN_SESSION_SEGMENT}:incognito-${sessionId}`; const { runEmbeddedAgent } = await import("../../agents/embedded-agent.js"); const result = await runEmbeddedAgent({ sessionId, sessionKey, sandboxSessionKey: sessionKey, - sessionFile: path.join(tempDir, "session.jsonl"), + sessionManager: SessionManager.inMemory(params.workspaceDir), agentId: params.agentId, trigger: "manual", lane: CommandLane.SkillWorkshopReview, @@ -105,8 +102,6 @@ export async function runSkillHistoryScanReview(params: { runError = resolveSkillHistoryScanRunFailure(result); } catch (error) { runError = error; - } finally { - await fs.rm(tempDir, { recursive: true, force: true }); } if (proposalReviewCompletion?.completed) { return proposalMutationBudget.completed; diff --git a/src/status/status-message.ts b/src/status/status-message.ts index 2f5a15dd6e70..3288f9b19eb7 100644 --- a/src/status/status-message.ts +++ b/src/status/status-message.ts @@ -1,5 +1,4 @@ // Status message helpers read and format stored status messages. -import fs from "node:fs"; import { type FastMode, normalizeLowercaseStringOrEmpty, @@ -37,8 +36,6 @@ import { resolveChannelModelOverride } from "../channels/model-overrides.js"; import { resolveMainSessionKey, resolveFreshSessionTotalTokens, - resolveSessionFilePath, - resolveSessionFilePathOptions, resolveSessionPluginStatusLines, resolveSessionPluginTraceLines, type SessionEntry, @@ -310,28 +307,13 @@ const readUsageFromSessionLog = ( if (!sessionId) { return undefined; } - let logPath: string; try { const resolvedAgentId = agentId ?? (sessionKey ? resolveAgentIdFromSessionKey(sessionKey) : undefined); - logPath = resolveSessionFilePath( - sessionId, - sessionEntry, - resolveSessionFilePathOptions({ agentId: resolvedAgentId, storePath }), - ); - } catch { - return undefined; - } - if (!fs.existsSync(logPath)) { - return undefined; - } - - try { const snapshot = readRecentSessionUsageFromTranscript( { - agentId: agentId ?? (sessionKey ? resolveAgentIdFromSessionKey(sessionKey) : undefined), + agentId: resolvedAgentId, sessionEntry, - sessionFile: logPath, sessionId, sessionKey, storePath, @@ -944,6 +926,7 @@ export function buildStatusMessage(args: StatusArgs): string { const sessionStartedAt = resolveSessionLifecycleTimestamps({ entry, agentId: args.agentId, + sessionKey: args.sessionKey, storePath: args.sessionStorePath, }).sessionStartedAt; const sessionDuration = diff --git a/src/status/status-text.test.ts b/src/status/status-text.test.ts index 13bc06b9fd10..197bcb9ca895 100644 --- a/src/status/status-text.test.ts +++ b/src/status/status-text.test.ts @@ -1,5 +1,5 @@ import { beforeEach, describe, expect, it, vi } from "vitest"; -import { formatSqliteSessionFileMarker } from "../config/sessions/sqlite-marker.js"; +import { formatSqliteSessionFileMarker } from "../config/sessions/legacy-sqlite-marker.js"; import { normalizeSessionDeliveryState } from "../utils/delivery-context.shared.js"; import { appendSessionCostLine } from "./status-runtime-lines.js"; import { buildStatusText } from "./status-text.js"; diff --git a/src/system-agent/agent-turn.test.ts b/src/system-agent/agent-turn.test.ts index 59e2f064248d..ba7dead65223 100644 --- a/src/system-agent/agent-turn.test.ts +++ b/src/system-agent/agent-turn.test.ts @@ -268,13 +268,12 @@ describe("runSystemAgentTurn", () => { mocks.runEmbeddedAgent.mock.calls[1]?.[0]?.sessionFile, "missing second embedded transcript path", ); - expect(firstPath).toContain(`${first.sessionId}.jsonl`); - expect(secondPath).toContain(`${second.sessionId}.jsonl`); + expect(firstPath).toBe(`in-memory:${first.sessionId}`); + expect(secondPath).toBe(`in-memory:${second.sessionId}`); expect(firstPath).not.toBe(secondPath); - - await fs.promises.writeFile(firstPath, "transcript"); + expect(first.sessionManager).not.toBe(second.sessionManager); await cleanupSystemAgentSession(first); - await expect(fs.promises.access(firstPath)).rejects.toThrow(); + expect(first.sessionManager).toBeUndefined(); }); it("uses the default agent CLI route while keeping OpenClaw session identity", async () => { @@ -331,7 +330,7 @@ describe("runSystemAgentTurn", () => { sessionKey: "agent:openclaw:main", sessionId: session.sessionId, workspaceDir: path.join(stateDir, "openclaw", "workspace"), - sessionFile: path.join(stateDir, "openclaw", "sessions", `${session.sessionId}.jsonl`), + sessionFile: `in-memory:${session.sessionId}`, messageChannel: "openclaw", messageProvider: "openclaw", }); @@ -405,7 +404,7 @@ describe("runSystemAgentTurn", () => { }); it("resumes Claude's native transcript through fresh per-turn processes", async () => { - const stateDir = useTempStateDir(); + useTempStateDir(); const config = { agents: { defaults: { @@ -454,13 +453,10 @@ describe("runSystemAgentTurn", () => { disableCliLiveSession: true, cleanupCliLiveSessionOnRunEnd: true, }); - const transcript = path.join(stateDir, "openclaw", "sessions", `${session.sessionId}.jsonl`); - await fs.promises.writeFile(transcript, "transcript"); - await cleanupSystemAgentSession(session); expect(session.cliSession).toBeUndefined(); - await expect(fs.promises.access(transcript)).rejects.toThrow(); + expect(session.sessionManager).toBeUndefined(); }); it("runs a canonical Anthropic model through its configured Claude CLI runtime", async () => { @@ -825,7 +821,7 @@ describe("runSystemAgentTurn", () => { sessionKey: "agent:openclaw:main", sessionId: session.sessionId, workspaceDir: path.join(stateDir, "openclaw", "workspace"), - sessionFile: path.join(stateDir, "openclaw", "sessions", `${session.sessionId}.jsonl`), + sessionFile: `in-memory:${session.sessionId}`, messageChannel: "openclaw", messageProvider: "openclaw", toolsAllow: ["openclaw"], diff --git a/src/system-agent/agent-turn.ts b/src/system-agent/agent-turn.ts index 6d3babf8a012..addd3ccf585b 100644 --- a/src/system-agent/agent-turn.ts +++ b/src/system-agent/agent-turn.ts @@ -4,6 +4,7 @@ import fs from "node:fs/promises"; import path from "node:path"; import { resolveCliBackendConfig, type ResolvedCliBackend } from "../agents/cli-backends.js"; import { normalizeCliModel } from "../agents/cli-runner/helpers.js"; +import { SessionManager } from "../agents/sessions/index.js"; import { resolveStateDir } from "../config/paths.js"; import type { CliSessionBinding } from "../config/sessions.js"; import { buildAgentMainSessionKey } from "../routing/session-key.js"; @@ -65,6 +66,8 @@ export type SystemAgentSession = { routeKey: string; binding: CliSessionBinding; }; + /** Process-lifetime transcript shared by embedded and CLI-backed turns. */ + sessionManager?: SessionManager; }; export function createSystemAgentSession( @@ -121,25 +124,16 @@ function extractRunText(result: EmbeddedRunResult): string | undefined { ); } -async function ensureSystemAgentDirs( - sessionId: string, -): Promise<{ workspaceDir: string; sessionFile: string }> { +async function ensureSystemAgentDirs(): Promise<{ workspaceDir: string }> { const base = path.join(resolveStateDir(), "openclaw"); const workspaceDir = path.join(base, "workspace"); await fs.mkdir(workspaceDir, { recursive: true }); - await fs.mkdir(path.join(base, "sessions"), { recursive: true }); - return { workspaceDir, sessionFile: path.join(base, "sessions", `${sessionId}.jsonl`) }; + return { workspaceDir }; } export async function cleanupSystemAgentSession(session: SystemAgentSession): Promise { - const sessionFile = path.join( - resolveStateDir(), - "openclaw", - "sessions", - `${session.sessionId}.jsonl`, - ); delete session.cliSession; - await fs.rm(sessionFile, { force: true }); + delete session.sessionManager; } type SystemAgentTurnParams = Parameters[0]; @@ -310,9 +304,8 @@ async function runSystemAgentTurnWithDeps( return throwSystemAgentInferenceUnavailable({ session: params.session, failures: [error] }); } let workspaceDir: string; - let sessionFile: string; try { - ({ workspaceDir, sessionFile } = await ensureSystemAgentDirs(params.session.sessionId)); + ({ workspaceDir } = await ensureSystemAgentDirs()); } catch (error) { return throwSystemAgentInferenceUnavailable({ session: params.session, @@ -321,12 +314,15 @@ async function runSystemAgentTurnWithDeps( } const runId = `openclaw-turn-${randomUUID()}`; + const sessionManager = params.session.sessionManager ?? SessionManager.inMemory(workspaceDir); + params.session.sessionManager = sessionManager; const shared = { sessionId: params.session.sessionId, sessionKey: buildAgentMainSessionKey({ agentId: SYSTEM_AGENT_ID }), agentId: SYSTEM_AGENT_ID, trigger: "manual" as const, - sessionFile, + sessionFile: `in-memory:${params.session.sessionId}`, + sessionManager, workspaceDir, config: plan.runConfig, prompt: params.input, @@ -335,6 +331,7 @@ async function runSystemAgentTurnWithDeps( runId, messageChannel: "openclaw", messageProvider: "openclaw", + disableTrajectory: true, }; // Directives are per-turn: the tool records at most one interactive handoff // and the engine executes it after the reply. diff --git a/src/system-agent/assistant.ts b/src/system-agent/assistant.ts index 4a9f0e29dcfe..f8cd0b177b1a 100644 --- a/src/system-agent/assistant.ts +++ b/src/system-agent/assistant.ts @@ -3,6 +3,7 @@ import { randomUUID } from "node:crypto"; import fs from "node:fs/promises"; import os from "node:os"; import path from "node:path"; +import { SessionManager } from "../agents/sessions/index.js"; import { SYSTEM_AGENT_ASSISTANT_SYSTEM_PROMPT, SYSTEM_AGENT_GREETING_SYSTEM_PROMPT, @@ -152,7 +153,8 @@ async function runConfiguredSystemAgentText(params: { // OpenClaw is the planner surface, but the configured roster owner supplies runtime policy. agentId: route.agentId, trigger: "manual" as const, - sessionFile: path.join(tempDir, "session.jsonl"), + sessionFile: `in-memory:${runId}`, + sessionManager: SessionManager.inMemory(tempDir), workspaceDir: tempDir, cwd: tempDir, agentDir: route.agentDir, diff --git a/src/system-agent/setup-inference-persist.ts b/src/system-agent/setup-inference-persist.ts index afc0f57a08f3..a185a1a37922 100644 --- a/src/system-agent/setup-inference-persist.ts +++ b/src/system-agent/setup-inference-persist.ts @@ -9,6 +9,7 @@ import { updateAuthProfileStoreWithLock } from "../agents/auth-profiles/store.js import type { AgentExecutionAuthBinding } from "../agents/execution-auth-binding.js"; import { describeFailoverError } from "../agents/failover-error.js"; import { splitTrailingAuthProfile } from "../agents/model-ref-profile.js"; +import { SessionManager } from "../agents/sessions/index.js"; import { applyMergePatch } from "../config/merge-patch.js"; import type { OpenClawConfig } from "../config/types.openclaw.js"; import type { PluginInstallRecord } from "../config/types.plugins.js"; @@ -469,7 +470,10 @@ export async function runSetupInferenceTest(params: { // session id as cache affinity, so this ephemeral id must stay under OpenAI's 64-character cap. const runId = `probe-setup-inference-${randomUUID()}`; const sessionId = runId; - const sessionFile = path.join(tempDir, "session.jsonl"); + const sessionFile = `in-memory:${sessionId}`; + const sessionManager = SessionManager.inMemory(tempDir); + const effectiveAgentId = plan.routeAgentId ?? plan.agentId ?? "openclaw"; + const sessionKey = `agent:${effectiveAgentId}:setup-inference:incognito-${runId}`; const timeoutMs = deps.timeoutMs ?? SETUP_INFERENCE_TEST_TIMEOUT_MS; const started = Date.now(); let successfulAuth: AgentExecutionAuthBinding | undefined; @@ -494,8 +498,9 @@ export async function runSetupInferenceTest(params: { const runCli = deps.runCliAgent ?? (await import("../agents/cli-runner.js")).runCliAgent; result = (await runCli({ sessionId, - sessionKey: `temp:setup-inference:${runId}`, - agentId: plan.routeAgentId ?? plan.agentId ?? "openclaw", + sessionKey, + sessionManager, + agentId: effectiveAgentId, trigger: "manual", sessionFile, workspaceDir: tempDir, @@ -522,8 +527,9 @@ export async function runSetupInferenceTest(params: { deps.runEmbeddedAgent ?? (await import("../agents/embedded-agent.js")).runEmbeddedAgent; result = (await runEmbedded({ sessionId, - sessionKey: `temp:setup-inference:${runId}`, - agentId: plan.routeAgentId ?? plan.agentId ?? "openclaw", + sessionKey, + sessionManager, + agentId: effectiveAgentId, trigger: "manual", sessionFile, workspaceDir: tempDir, @@ -547,6 +553,7 @@ export async function runSetupInferenceTest(params: { thinkLevel: "off", reasoningLevel: "off", verboseLevel: "off", + disableTrajectory: true, // The 32-token probe cap is sized for the "reply OK" verification // prompt only. Custom completions pass no explicit cap: the stream // layer then applies the resolved model's own required maxTokens diff --git a/src/system-agent/setup-inference.test.ts b/src/system-agent/setup-inference.test.ts index 529db0f38f04..4820df1c7c0c 100644 --- a/src/system-agent/setup-inference.test.ts +++ b/src/system-agent/setup-inference.test.ts @@ -36,6 +36,7 @@ import { cleanupSystemAgentSession, createSystemAgentSession } from "./agent-tur import { runSystemAgentTurnWithDeps } from "./agent-turn.test-support.js"; import { resolveSystemAgentConfiguredRouteFromConfig } from "./inference-route.js"; import { applySystemAgentModelSelection } from "./setup-apply.js"; +import { runSetupInferenceTest } from "./setup-inference-persist.js"; import { resolveSetupInferenceProbeStreamParams } from "./setup-inference-probe.js"; import { SetupInferenceActivationIndeterminateError, @@ -2034,7 +2035,9 @@ describe("activateSetupInference", () => { expect.objectContaining({ runId: expect.stringMatching(/^probe-setup-inference-/), sessionId: expect.stringMatching(/^probe-setup-inference-/), - sessionKey: expect.stringMatching(/^temp:setup-inference:probe-setup-inference-/), + sessionKey: expect.stringMatching( + /^agent:main:setup-inference:incognito-probe-setup-inference-/, + ), lane: "session:probe-setup-inference:anthropic", }), ); @@ -2045,6 +2048,33 @@ describe("activateSetupInference", () => { expect(transformConfig).not.toHaveBeenCalled(); }); + it("uses the effective route agent for setup probe identity", async () => { + const runEmbeddedAgent = vi.fn(async () => ({ payloads: [] })); + + await runSetupInferenceTest({ + plan: { + runner: "embedded", + provider: "anthropic", + model: "claude-sonnet-4-6", + modelRef: "anthropic/claude-sonnet-4-6", + config: {}, + agentId: "execution-agent", + routeAgentId: "route-agent", + }, + tempDir: await makeTempDir(), + deps: { runEmbeddedAgent: runEmbeddedAgent as never }, + authProfileStateMode: "read-only", + requireExecutionOwner: false, + }); + + expect(runEmbeddedAgent).toHaveBeenCalledWith( + expect.objectContaining({ + agentId: "route-agent", + sessionKey: expect.stringMatching(/^agent:route-agent:setup-inference:incognito-/), + }), + ); + }); + it.each([ { name: "error payload", diff --git a/src/trajectory/cleanup.test.ts b/src/trajectory/cleanup.test.ts index 1fc436cfb630..1c6e6eee86d5 100644 --- a/src/trajectory/cleanup.test.ts +++ b/src/trajectory/cleanup.test.ts @@ -3,7 +3,7 @@ import fsSync from "node:fs"; import fs from "node:fs/promises"; import path from "node:path"; import { describe, expect, it, vi } from "vitest"; -import { formatSqliteSessionFileMarker } from "../config/sessions/sqlite-marker.js"; +import { formatSqliteSessionFileMarker } from "../config/sessions/legacy-sqlite-marker.js"; import { withTempDir } from "../test-helpers/temp-dir.js"; import { removeRemovedSessionTrajectoryArtifacts, diff --git a/src/trajectory/cleanup.ts b/src/trajectory/cleanup.ts index 0ee5e9ad9ab6..7fe983a882f2 100644 --- a/src/trajectory/cleanup.ts +++ b/src/trajectory/cleanup.ts @@ -2,8 +2,8 @@ import fs from "node:fs"; import path from "node:path"; import { isRecord } from "@openclaw/normalization-core/record-coerce"; +import { parseSqliteSessionFileMarker } from "../config/sessions/legacy-sqlite-marker.js"; import { resolveSessionFilePath } from "../config/sessions/paths.js"; -import { parseSqliteSessionFileMarker } from "../config/sessions/sqlite-marker.js"; import { readFileWindowFullySync } from "../infra/file-read.js"; import { isPathInside } from "../infra/path-guards.js"; import { readRegularFileSync } from "../infra/regular-file.js"; diff --git a/src/trajectory/command-export.ts b/src/trajectory/command-export.ts index 9014f04c5c47..89d386afbe76 100644 --- a/src/trajectory/command-export.ts +++ b/src/trajectory/command-export.ts @@ -1,6 +1,7 @@ // Trajectory command export helpers implement CLI export behavior. import fsp from "node:fs/promises"; import path from "node:path"; +import type { SessionTranscriptRuntimeTarget } from "../config/sessions/session-accessor.types.js"; import { pathExists } from "../infra/fs-safe.js"; import { isPathInside } from "../infra/path-guards.js"; import { exportTrajectoryBundle, resolveDefaultTrajectoryExportDir } from "./export.js"; @@ -112,7 +113,8 @@ async function resolveTrajectoryCommandOutputDir(params: { export async function exportTrajectoryForCommand(params: { outputDir?: string; outputPath?: string; - sessionFile: string; + sessionFile?: string; + sessionTarget?: SessionTranscriptRuntimeTarget; sessionId: string; sessionKey: string; workspaceDir: string; @@ -127,6 +129,7 @@ export async function exportTrajectoryForCommand(params: { const bundle = await exportTrajectoryBundle({ outputDir, sessionFile: params.sessionFile, + sessionTarget: params.sessionTarget, sessionId: params.sessionId, sessionKey: params.sessionKey, workspaceDir: params.workspaceDir, diff --git a/src/trajectory/export.test.ts b/src/trajectory/export.test.ts index 3d6ef0efb2e5..420941303cde 100644 --- a/src/trajectory/export.test.ts +++ b/src/trajectory/export.test.ts @@ -5,8 +5,11 @@ import path from "node:path"; import { expectDefined } from "@openclaw/normalization-core"; import type { Message, Usage } from "openclaw/plugin-sdk/llm"; import { afterAll, describe, expect, it } from "vitest"; -import { replaceTranscriptEvents } from "../config/sessions/session-accessor.js"; -import { formatSqliteSessionFileMarker } from "../config/sessions/sqlite-marker.js"; +import { formatSqliteSessionFileMarker } from "../config/sessions/legacy-sqlite-marker.js"; +import { + replaceSessionEntry, + replaceTranscriptEvents, +} from "../config/sessions/session-accessor.js"; import { closeOpenClawAgentDatabasesForTest } from "../state/openclaw-agent-db.js"; import { closeOpenClawStateDatabaseForTest } from "../state/openclaw-state-db.js"; import { exportTrajectoryBundle, resolveDefaultTrajectoryExportDir } from "./export.js"; @@ -203,6 +206,88 @@ afterAll(() => { }); describe("exportTrajectoryBundle", () => { + it("rejects a structured transcript target for a different session", async () => { + const outputDir = makeTempDir(); + + await expect( + exportTrajectoryBundle({ + outputDir, + sessionId: "requested-session", + sessionKey: "agent:main:requested", + sessionTarget: { + agentId: "main", + sessionId: "other-session", + sessionKey: "agent:main:other", + storePath: path.join(outputDir, "sessions.json"), + }, + workspaceDir: outputDir, + }), + ).rejects.toThrow("transcript target does not match the requested session"); + }); + + it("rejects a structured transcript target whose key belongs to another agent", async () => { + const outputDir = makeTempDir(); + + await expect( + exportTrajectoryBundle({ + outputDir, + sessionId: "requested-session", + sessionTarget: { + agentId: "main", + sessionId: "requested-session", + sessionKey: "agent:worker:requested", + storePath: path.join(outputDir, "sessions.json"), + }, + workspaceDir: outputDir, + }), + ).rejects.toThrow("transcript target does not match the requested session"); + }); + + it("rejects a structured transcript target whose key maps to another session", async () => { + const outputDir = makeTempDir(); + const storePath = path.join(outputDir, "sessions.json"); + const sessionKey = "agent:main:stored-session"; + await replaceSessionEntry( + { agentId: "main", sessionKey, storePath }, + { + sessionId: "stored-session", + updatedAt: 1, + }, + ); + + await expect( + exportTrajectoryBundle({ + outputDir: path.join(outputDir, "bundle"), + sessionId: "requested-session", + sessionKey, + sessionTarget: { + agentId: "main", + sessionId: "requested-session", + sessionKey, + storePath, + }, + workspaceDir: outputDir, + }), + ).rejects.toThrow("transcript target does not match the requested session"); + }); + + it("rejects a legacy SQLite marker for a different session", async () => { + const outputDir = makeTempDir(); + + await expect( + exportTrajectoryBundle({ + outputDir: path.join(outputDir, "bundle"), + sessionFile: formatSqliteSessionFileMarker({ + agentId: "main", + sessionId: "stale-session", + storePath: path.join(outputDir, "sessions.json"), + }), + sessionId: "requested-session", + workspaceDir: outputDir, + }), + ).rejects.toThrow("legacy marker does not match the requested session"); + }); + it("sanitizes session ids in default export directory names", () => { const outputDir = resolveDefaultTrajectoryExportDir({ workspaceDir: "/tmp/workspace", @@ -280,11 +365,13 @@ describe("exportTrajectoryBundle", () => { const bundle = await exportTrajectoryBundle({ outputDir, - sessionFile: formatSqliteSessionFileMarker({ + sessionFile: path.join(tmpDir, "stale-session.jsonl"), + sessionTarget: { agentId: "main", sessionId, + sessionKey, storePath, - }), + }, sessionId, sessionKey, workspaceDir: tmpDir, @@ -292,6 +379,8 @@ describe("exportTrajectoryBundle", () => { expect(bundle.header?.id).toBe(sessionId); expect(bundle.manifest.transcriptEventCount).toBe(2); + expect(bundle.manifest.sourceFiles.session).toMatch(/^agent:/u); + expect(bundle.manifest.sourceFiles.session).not.toContain("stale-session"); expect(eventTypes(bundle.events)).toEqual(["user.message", "assistant.message"]); expect(fs.existsSync(path.join(tmpDir, "session-1.jsonl"))).toBe(false); }); @@ -381,6 +470,56 @@ describe("exportTrajectoryBundle", () => { expect(fs.existsSync(path.join(outputDir, "tools.json"))).toBe(false); }); + it("falls back to artifact files when the structured target is incomplete", async () => { + const tmpDir = makeTempDir(); + const sessionFile = path.join(tmpDir, "session.jsonl"); + const runtimeFile = path.join(tmpDir, "session.trajectory.jsonl"); + const outputDir = path.join(tmpDir, "bundle"); + writeSimpleSessionFile(sessionFile); + const runtimeEvent: TrajectoryEvent = { + traceSchema: "openclaw-trajectory", + schemaVersion: 1, + traceId: "session-1", + source: "runtime", + type: "partial-target-runtime", + ts: "2026-04-22T08:00:02.000Z", + seq: 1, + sourceSeq: 1, + sessionId: "session-1", + }; + fs.writeFileSync(runtimeFile, `${JSON.stringify(runtimeEvent)}\n`, "utf8"); + + const bundle = await exportTrajectoryBundle({ + outputDir, + sessionFile, + sessionTarget: { agentId: "main", sessionKey: "agent:main:stale" } as never, + sessionId: "session-1", + workspaceDir: tmpDir, + runtimeFile, + }); + + expect(eventTypes(bundle.events)).toContain("partial-target-runtime"); + expect(bundle.manifest.sourceFiles.session).toBe("$WORKSPACE_DIR/session.jsonl"); + }); + + it("rejects an incomplete target that conflicts with a legacy marker", async () => { + const tmpDir = makeTempDir(); + + await expect( + exportTrajectoryBundle({ + outputDir: path.join(tmpDir, "bundle"), + sessionFile: formatSqliteSessionFileMarker({ + agentId: "worker", + sessionId: "session-1", + storePath: path.join(tmpDir, "sessions.json"), + }), + sessionTarget: { agentId: "main" } as never, + sessionId: "session-1", + workspaceDir: tmpDir, + }), + ).rejects.toThrow("transcript target conflicts with the legacy marker"); + }); + it("exports usage from truncated model completion runtime events", async () => { const tmpDir = makeTempDir(); const sessionFile = path.join(tmpDir, "session.jsonl"); @@ -810,6 +949,7 @@ describe("exportTrajectoryBundle", () => { exportTrajectoryBundle({ outputDir, sessionFile, + sessionTarget: { agentId: "main" } as never, sessionId: "session-1", workspaceDir: tmpDir, }), diff --git a/src/trajectory/export.ts b/src/trajectory/export.ts index 57a24f4e8138..9fe28106a9c8 100644 --- a/src/trajectory/export.ts +++ b/src/trajectory/export.ts @@ -2,13 +2,19 @@ import fsp from "node:fs/promises"; import path from "node:path"; import { isRecord } from "@openclaw/normalization-core/record-coerce"; +import { normalizeOptionalString } from "@openclaw/normalization-core/string-coerce"; import { sanitizeDiagnosticPayload } from "../agents/payload-redaction.js"; import type { AgentMessage } from "../agents/runtime/index.js"; import { parseSessionFileEntriesWithWarnings } from "../agents/sessions/session-file-parser.js"; import type { FileEntry, SessionEntry, SessionHeader } from "../agents/sessions/session-manager.js"; import { resolveStateDir } from "../config/paths.js"; -import { loadTranscriptEvents } from "../config/sessions/session-accessor.js"; -import { parseSqliteSessionFileMarker } from "../config/sessions/sqlite-marker.js"; +import { parseSqliteSessionFileMarker } from "../config/sessions/legacy-sqlite-marker.js"; +import { + listSessionEntries, + loadSessionEntry, + loadTranscriptEvents, +} from "../config/sessions/session-accessor.js"; +import type { SessionTranscriptRuntimeTarget } from "../config/sessions/session-accessor.types.js"; import { isCanonicalSessionTranscriptEntry, scanSessionTranscriptTree, @@ -31,6 +37,8 @@ import { hasMeaningfulRetiredMediaCarrier, PERSISTED_LEGACY_MEDIA_KEYS, } from "../media/media-facts.js"; +import { parseAgentSessionKey } from "../routing/session-key.js"; +import { resolvePreferredSessionKeyForSessionIdMatches } from "../sessions/session-id-resolution.js"; import { safeJsonStringify } from "../utils/safe-json.js"; import { TRAJECTORY_RUNTIME_FILE_MAX_BYTES, safeTrajectorySessionFileName } from "./paths.js"; import { isRegularNonSymlinkFile, resolveTrajectoryRuntimeFile } from "./runtime-file.js"; @@ -47,7 +55,8 @@ import type { // support bundle for debugging agent behavior. type BuildTrajectoryBundleParams = { outputDir: string; - sessionFile: string; + sessionFile?: string; + sessionTarget?: SessionTranscriptRuntimeTarget; sessionId: string; sessionKey?: string; workspaceDir: string; @@ -81,6 +90,21 @@ const MAX_TRAJECTORY_TOTAL_EVENTS = 250_000; const MAX_TRAJECTORY_SESSION_FILE_BYTES = 50 * 1024 * 1024; const MAX_TRAJECTORY_WARNING_ROWS = 20; +function normalizeCompleteSessionTarget( + target: SessionTranscriptRuntimeTarget | undefined, +): SessionTranscriptRuntimeTarget | undefined { + if (!target) { + return undefined; + } + const agentId = normalizeOptionalString(target.agentId); + const sessionId = normalizeOptionalString(target.sessionId); + const sessionKey = normalizeOptionalString(target.sessionKey); + const storePath = normalizeOptionalString(target.storePath); + return agentId && sessionId && sessionKey && storePath + ? { agentId, sessionId, sessionKey, storePath } + : undefined; +} + function isFiniteNumber(value: unknown): value is number { return typeof value === "number" && Number.isFinite(value); } @@ -182,7 +206,8 @@ function migrateLegacySessionEntries(entries: FileEntry[]): void { } async function readSessionEntries(params: { - sessionFile: string; + sessionFile?: string; + sessionTarget?: SessionTranscriptRuntimeTarget; sessionId: string; sessionKey?: string; }): Promise<{ @@ -190,6 +215,43 @@ async function readSessionEntries(params: { warnings: JsonlParseWarning[]; rowByEntry: Map; }> { + const completeTarget = normalizeCompleteSessionTarget(params.sessionTarget); + if (completeTarget) { + const targetKeyAgentId = parseAgentSessionKey(completeTarget.sessionKey)?.agentId; + const targetKeyEntry = loadSessionEntry({ + agentId: completeTarget.agentId, + sessionKey: completeTarget.sessionKey, + storePath: completeTarget.storePath, + }); + // Export remains available after the session index row is pruned. A row + // that still exists must agree with the artifact's complete target. + if ( + completeTarget.sessionId !== params.sessionId || + (params.sessionKey !== undefined && completeTarget.sessionKey !== params.sessionKey) || + (targetKeyAgentId && targetKeyAgentId !== completeTarget.agentId) || + (targetKeyEntry && targetKeyEntry.sessionId !== completeTarget.sessionId) + ) { + throw new Error("Trajectory export transcript target does not match the requested session"); + } + const events = await loadTranscriptEvents({ + agentId: completeTarget.agentId, + sessionId: completeTarget.sessionId, + sessionKey: completeTarget.sessionKey, + storePath: completeTarget.storePath, + }); + return collectSessionEntries(events.map((value, index) => ({ row: index + 1, value }))); + } + const incompleteTarget = params.sessionTarget + ? { + agentId: normalizeOptionalString(params.sessionTarget.agentId), + sessionId: normalizeOptionalString(params.sessionTarget.sessionId), + sessionKey: normalizeOptionalString(params.sessionTarget.sessionKey), + storePath: normalizeOptionalString(params.sessionTarget.storePath), + } + : undefined; + if (!params.sessionFile) { + throw new Error("Trajectory export requires a transcript identity or artifact file"); + } const marker = parseSqliteSessionFileMarker(params.sessionFile); if (!marker) { const { entries, warnings, rowByEntry } = parseSessionFileEntriesWithWarnings( @@ -201,12 +263,61 @@ async function readSessionEntries(params: { rowByEntry, }; } + if (marker.sessionId !== params.sessionId) { + throw new Error("Trajectory export legacy marker does not match the requested session"); + } + const targetKeyAgentId = parseAgentSessionKey(incompleteTarget?.sessionKey)?.agentId; + const targetKeyEntry = + incompleteTarget?.sessionKey && marker + ? loadSessionEntry({ + agentId: marker.agentId, + sessionKey: incompleteTarget.sessionKey, + storePath: marker.storePath, + }) + : undefined; + if ( + incompleteTarget && + ((incompleteTarget.agentId && incompleteTarget.agentId !== marker.agentId) || + (incompleteTarget.sessionId && incompleteTarget.sessionId !== marker.sessionId) || + (targetKeyAgentId && targetKeyAgentId !== marker.agentId) || + (incompleteTarget.sessionKey && targetKeyEntry?.sessionId !== marker.sessionId) || + (incompleteTarget.storePath && + path.resolve(incompleteTarget.storePath) !== path.resolve(marker.storePath))) + ) { + throw new Error("Trajectory export transcript target conflicts with the legacy marker"); + } + const suppliedKeyEntry = params.sessionKey + ? loadSessionEntry({ + agentId: marker.agentId, + sessionKey: params.sessionKey, + storePath: marker.storePath, + }) + : undefined; + const markerMatches = listSessionEntries({ + agentId: marker.agentId, + storePath: marker.storePath, + }).filter(({ entry }) => entry.sessionId === marker.sessionId); + if (suppliedKeyEntry && suppliedKeyEntry.sessionId !== marker.sessionId) { + throw new Error("Trajectory export session key conflicts with the legacy marker"); + } + if (params.sessionKey && !suppliedKeyEntry && markerMatches.length > 0) { + throw new Error("Trajectory export session key is not mapped to the legacy marker"); + } + const markerSessionKey = suppliedKeyEntry + ? params.sessionKey + : (resolvePreferredSessionKeyForSessionIdMatches( + markerMatches.map(({ sessionKey, entry }) => [sessionKey, entry]), + marker.sessionId, + ) ?? (markerMatches.length === 0 ? params.sessionKey : undefined)); + if (!markerSessionKey && markerMatches.length > 0) { + throw new Error("Trajectory export legacy marker session key is ambiguous"); + } return collectSessionEntries( ( await loadTranscriptEvents({ agentId: marker.agentId, - sessionId: params.sessionId, - ...(params.sessionKey ? { sessionKey: params.sessionKey } : {}), + sessionId: marker.sessionId, + ...(markerSessionKey ? { sessionKey: markerSessionKey } : {}), storePath: marker.storePath, }) ).map((value, index) => ({ row: index + 1, value })), @@ -214,7 +325,8 @@ async function readSessionEntries(params: { } async function readSessionBranch(params: { - sessionFile: string; + sessionFile?: string; + sessionTarget?: SessionTranscriptRuntimeTarget; sessionId: string; sessionKey?: string; }): Promise<{ @@ -360,15 +472,21 @@ async function parseJsonlFile( async function readRuntimeTrajectoryEvents(params: { runtimeFile?: string; - sessionFile: string; + sessionFile?: string; + sessionTarget?: SessionTranscriptRuntimeTarget; sessionId: string; }): Promise<{ events: TrajectoryEvent[]; runtimeFile?: string; warnings: JsonlParseWarning[]; }> { - const marker = parseSqliteSessionFileMarker(params.sessionFile); - if (marker && marker.sessionId === params.sessionId) { + const marker = + normalizeCompleteSessionTarget(params.sessionTarget) ?? + parseSqliteSessionFileMarker(params.sessionFile); + if (marker && marker.sessionId !== params.sessionId) { + throw new Error("Trajectory runtime target does not match the requested session"); + } + if (marker) { const events = await loadSqliteTrajectoryRuntimeEvents({ agentId: marker.agentId, sessionId: marker.sessionId, @@ -382,6 +500,9 @@ async function readRuntimeTrajectoryEvents(params: { return { events, warnings: [] }; } + if (!params.sessionFile) { + return { events: [], warnings: [] }; + } const runtimeFile = await resolveTrajectoryRuntimeFile({ runtimeFile: params.runtimeFile, sessionFile: params.sessionFile, @@ -1086,7 +1207,8 @@ export async function exportTrajectoryBundle(params: BuildTrajectoryBundleParams const redaction = buildTrajectoryExportRedaction({ workspaceDir: params.workspaceDir, }); - if (!parseSqliteSessionFileMarker(params.sessionFile)) { + const sessionTarget = normalizeCompleteSessionTarget(params.sessionTarget); + if (params.sessionFile && !sessionTarget && !parseSqliteSessionFileMarker(params.sessionFile)) { const sessionStat = await fsp.stat(params.sessionFile); if (sessionStat.size > MAX_TRAJECTORY_SESSION_FILE_BYTES) { throw new Error( @@ -1101,12 +1223,14 @@ export async function exportTrajectoryBundle(params: BuildTrajectoryBundleParams warnings: sessionWarnings, } = await readSessionBranch({ sessionFile: params.sessionFile, + sessionTarget: params.sessionTarget, sessionId: params.sessionId, ...(params.sessionKey ? { sessionKey: params.sessionKey } : {}), }); const runtimeParse = await readRuntimeTrajectoryEvents({ runtimeFile: params.runtimeFile, sessionFile: params.sessionFile, + sessionTarget, sessionId: params.sessionId, }); const runtimeFile = runtimeParse.runtimeFile; @@ -1142,7 +1266,10 @@ export async function exportTrajectoryBundle(params: BuildTrajectoryBundleParams runtimeEventCount: runtimeEvents.length, transcriptEventCount: transcriptEvents.length, sourceFiles: { - session: maybeRedactPathString(params.sessionFile, redaction), + session: maybeRedactPathString( + sessionTarget?.sessionKey ?? params.sessionFile ?? params.sessionId, + redaction, + ), runtime: runtimeFile && (await isRegularNonSymlinkFile(runtimeFile)) ? maybeRedactPathString(runtimeFile, redaction) diff --git a/src/trajectory/paths.ts b/src/trajectory/paths.ts index 003a6ac6c5cb..fa6313b08546 100644 --- a/src/trajectory/paths.ts +++ b/src/trajectory/paths.ts @@ -1,7 +1,7 @@ // Trajectory path helpers resolve storage paths for trajectory artifacts. import path from "node:path"; -import { parseSqliteSessionFileMarker } from "../config/sessions/sqlite-marker.js"; +import { parseSqliteSessionFileMarker } from "../config/sessions/legacy-sqlite-marker.js"; import { resolveHomeRelativePath } from "../infra/home-dir.js"; import { isPathInside } from "../infra/path-guards.js"; diff --git a/src/trajectory/runtime-store.sqlite.test.ts b/src/trajectory/runtime-store.sqlite.test.ts index 39df27f2c1ea..6c3c5251266d 100644 --- a/src/trajectory/runtime-store.sqlite.test.ts +++ b/src/trajectory/runtime-store.sqlite.test.ts @@ -13,6 +13,7 @@ import { import { closeOpenClawStateDatabaseForTest } from "../state/openclaw-state-db.js"; import { appendSqliteTrajectoryRuntimeEvents, + loadSqliteTrajectoryRuntimeEventRowsSync, loadSqliteTrajectoryRuntimeEvents, } from "./runtime-store.sqlite.js"; import type { TrajectoryEvent } from "./types.js"; @@ -87,6 +88,40 @@ describe("SQLite trajectory runtime store", () => { expect(events.map((event) => event.type)).toEqual(["event-3", "event-4"]); }); + it("loads a bounded trailing window in storage order", () => { + appendSqliteTrajectoryRuntimeEvents({ sessionId: "session-1", storePath }, [ + createTrajectoryEvent({ type: "event-1" }), + createTrajectoryEvent({ type: "event-2" }), + createTrajectoryEvent({ type: "event-3" }), + ]); + + const rows = loadSqliteTrajectoryRuntimeEventRowsSync({ + sessionId: "session-1", + storePath, + tailEvents: 2, + }); + + expect(rows.map((row) => row.event.type)).toEqual(["event-2", "event-3"]); + expect(rows.map((row) => row.seq)).toEqual([1, 2]); + }); + + it("applies maxEvents to a trailing window", () => { + appendSqliteTrajectoryRuntimeEvents({ sessionId: "session-1", storePath }, [ + createTrajectoryEvent({ type: "event-1" }), + createTrajectoryEvent({ type: "event-2" }), + createTrajectoryEvent({ type: "event-3" }), + ]); + + const rows = loadSqliteTrajectoryRuntimeEventRowsSync({ + sessionId: "session-1", + storePath, + tailEvents: 3, + maxEvents: 1, + }); + + expect(rows.map((row) => row.event.type)).toEqual(["event-3"]); + }); + it("drops old runs while retaining recent runs", async () => { const now = Date.parse("2026-07-26T00:00:00.000Z"); vi.useFakeTimers(); diff --git a/src/trajectory/runtime-store.sqlite.ts b/src/trajectory/runtime-store.sqlite.ts index 80c66a88e9ae..20183ac7037c 100644 --- a/src/trajectory/runtime-store.sqlite.ts +++ b/src/trajectory/runtime-store.sqlite.ts @@ -138,27 +138,42 @@ export function loadSqliteTrajectoryRuntimeEventRowsSync( scope: SqliteTrajectoryRuntimeReadScope & { afterSeq?: number; maxEvents?: number; + tailEvents?: number; }, ): SqliteTrajectoryRuntimeEventRow[] { const database = openOpenClawAgentDatabase(toDatabaseOptions(scope)); const db = getTrajectoryKysely(database.db); + const tailEvents = + scope.tailEvents !== undefined && Number.isFinite(scope.tailEvents) + ? Math.max(0, Math.floor(scope.tailEvents)) + : undefined; let query = db .selectFrom("trajectory_runtime_events") .select(["seq", "event_json"]) .where("session_id", "=", scope.sessionId) - .orderBy("seq", "asc"); + .orderBy("seq", tailEvents === undefined ? "asc" : "desc"); const afterSeq = scope.afterSeq; if (afterSeq !== undefined && Number.isFinite(afterSeq)) { query = query.where("seq", ">", Math.floor(afterSeq)); } - const maxEvents = scope.maxEvents; + const normalizedMaxEvents = + scope.maxEvents !== undefined && Number.isFinite(scope.maxEvents) + ? Math.max(0, Math.floor(scope.maxEvents)) + : undefined; + const maxEvents = + tailEvents === undefined + ? normalizedMaxEvents + : normalizedMaxEvents === undefined + ? tailEvents + : Math.min(tailEvents, normalizedMaxEvents); if (maxEvents !== undefined && Number.isFinite(maxEvents)) { query = query.limit(Math.max(0, Math.floor(maxEvents))); } - return executeSqliteQuerySync(database.db, query).rows.map((row) => ({ + const rows = executeSqliteQuerySync(database.db, query).rows.map((row) => ({ event: JSON.parse(row.event_json) as TrajectoryEvent, seq: row.seq, })); + return tailEvents === undefined ? rows : rows.toReversed(); } function sweepSqliteTrajectoryRuntimeRetention( diff --git a/src/trajectory/runtime.test.ts b/src/trajectory/runtime.test.ts index 7442fe1de526..20474993c122 100644 --- a/src/trajectory/runtime.test.ts +++ b/src/trajectory/runtime.test.ts @@ -4,8 +4,8 @@ import os from "node:os"; import path from "node:path"; import { expectDefined } from "@openclaw/normalization-core"; import { afterEach, describe, expect, it, vi } from "vitest"; +import { formatSqliteSessionFileMarker } from "../config/sessions/legacy-sqlite-marker.js"; import { replaceSessionEntry } from "../config/sessions/session-accessor.js"; -import { formatSqliteSessionFileMarker } from "../config/sessions/sqlite-marker.js"; import { closeOpenClawAgentDatabasesForTest } from "../state/openclaw-agent-db.js"; import { closeOpenClawStateDatabaseForTest } from "../state/openclaw-state-db.js"; import { TRAJECTORY_RUNTIME_EVENT_MAX_BYTES } from "./paths.js"; @@ -130,6 +130,69 @@ describe("trajectory runtime", () => { ); }); + it("rejects a legacy SQLite marker for another session", () => { + const storePath = path.join(makeTempDir(), "sessions.json"); + + expect( + createTrajectoryRuntimeRecorder({ + sessionId: "current-session", + sessionFile: formatSqliteSessionFileMarker({ + agentId: "main", + sessionId: "stale-session", + storePath, + }), + }), + ).toBeNull(); + }); + + it.each([ + ["requested key", "agent:main:other", "main", "agent:main:main"], + ["target key agent", undefined, "main", "agent:worker:main"], + ])( + "rejects a complete target that conflicts with the %s", + (_label, sessionKey, agentId, targetKey) => { + const storePath = path.join(makeTempDir(), "sessions.json"); + + expect( + createTrajectoryRuntimeRecorder({ + sessionId: "session-1", + ...(sessionKey ? { sessionKey } : {}), + sessionTarget: { + agentId, + sessionId: "session-1", + sessionKey: targetKey, + storePath, + }, + }), + ).toBeNull(); + }, + ); + + it("rejects a complete target whose key maps to another session", async () => { + const storePath = path.join(makeTempDir(), "sessions.json"); + const sessionKey = "agent:main:stored-session"; + await replaceSessionEntry( + { agentId: "main", sessionKey, storePath }, + { + sessionId: "stored-session", + updatedAt: 1, + }, + ); + + expect( + createTrajectoryRuntimeRecorder({ + sessionId: "requested-session", + sessionKey, + sessionTarget: { + agentId: "main", + sessionId: "requested-session", + sessionKey, + storePath, + }, + }), + ).toBeNull(); + }); + it("stores bounded oversized runtime events in SQLite", async () => { const tempDir = makeTempDir(); const storePath = path.join(tempDir, "agents", "main", "sessions", "sessions.json"); diff --git a/src/trajectory/runtime.ts b/src/trajectory/runtime.ts index abc6d9cf97b9..84b84ad87dec 100644 --- a/src/trajectory/runtime.ts +++ b/src/trajectory/runtime.ts @@ -1,12 +1,17 @@ // Trajectory runtime records bounded session events into SQLite-backed storage. +import path from "node:path"; +import { normalizeOptionalString } from "@openclaw/normalization-core/string-coerce"; import { sanitizeDiagnosticPayload } from "../agents/payload-redaction.js"; import type { QueuedFileWriter, QueuedFileWriterDiagnostics, } from "../agents/queued-file-writer.js"; -import { parseSqliteSessionFileMarker } from "../config/sessions/sqlite-marker.js"; +import { parseSqliteSessionFileMarker } from "../config/sessions/legacy-sqlite-marker.js"; +import { loadSessionEntry } from "../config/sessions/session-accessor.js"; +import type { SessionTranscriptRuntimeTarget } from "../config/sessions/session-accessor.types.js"; import type { OpenClawConfig } from "../config/types.openclaw.js"; import { redactSecrets } from "../logging/redact.js"; +import { parseAgentSessionKey } from "../routing/session-key.js"; import { parseBooleanValue } from "../utils/boolean.js"; import { safeJsonStringify } from "../utils/safe-json.js"; import { truncateUtf8Prefix } from "../utils/utf8-truncate.js"; @@ -25,6 +30,7 @@ type TrajectoryRuntimeInit = { sessionId: string; sessionKey?: string; sessionFile?: string; + sessionTarget?: SessionTranscriptRuntimeTarget; provider?: string; modelId?: string; modelApi?: string | null; @@ -250,8 +256,70 @@ function createSqliteTrajectoryRuntimeSink(params: { maxRuntimeFileBytes: number; sessionFile?: string; sessionId: string; + sessionKey?: string; + sessionTarget?: SessionTranscriptRuntimeTarget; }): TrajectoryRuntimeSink | null { - const marker = parseSqliteSessionFileMarker(params.sessionFile); + const target = params.sessionTarget + ? { + agentId: normalizeOptionalString(params.sessionTarget.agentId), + sessionId: normalizeOptionalString(params.sessionTarget.sessionId), + sessionKey: normalizeOptionalString(params.sessionTarget.sessionKey), + storePath: normalizeOptionalString(params.sessionTarget.storePath), + } + : undefined; + const legacyMarker = parseSqliteSessionFileMarker(params.sessionFile); + const completeTarget = Boolean( + target?.agentId && target.sessionId && target.sessionKey && target.storePath, + ); + const targetKeyAgentId = parseAgentSessionKey(target?.sessionKey)?.agentId; + const requestedSessionKey = normalizeOptionalString(params.sessionKey); + const completeTargetKeyEntry = + completeTarget && target?.agentId && target.sessionKey && target.storePath + ? loadSessionEntry({ + agentId: target.agentId, + sessionKey: target.sessionKey, + storePath: target.storePath, + }) + : undefined; + // A prepared runtime target may precede its metadata row. Treat an absent + // row as uncommitted, while rejecting an existing conflicting mapping. + if ( + completeTarget && + ((requestedSessionKey && target?.sessionKey !== requestedSessionKey) || + (targetKeyAgentId && target?.agentId !== targetKeyAgentId) || + (completeTargetKeyEntry && completeTargetKeyEntry.sessionId !== target?.sessionId)) + ) { + return null; + } + const targetKeyEntry = + target?.sessionKey && legacyMarker && !completeTarget + ? loadSessionEntry({ + agentId: legacyMarker.agentId, + sessionKey: target.sessionKey, + storePath: legacyMarker.storePath, + }) + : undefined; + if ( + target && + !completeTarget && + legacyMarker && + ((target.agentId && target.agentId !== legacyMarker.agentId) || + (target.sessionId && target.sessionId !== legacyMarker.sessionId) || + (targetKeyAgentId && targetKeyAgentId !== legacyMarker.agentId) || + (target.sessionKey && targetKeyEntry?.sessionId !== legacyMarker.sessionId) || + (target.storePath && path.resolve(target.storePath) !== path.resolve(legacyMarker.storePath))) + ) { + return null; + } + const marker = + target?.agentId && target.sessionId && target.sessionKey && target.storePath + ? { + agentId: target.agentId, + sessionId: target.sessionId, + sessionKey: target.sessionKey, + storePath: target.storePath, + } + : legacyMarker; if (!marker || marker.sessionId !== params.sessionId) { return null; } @@ -329,6 +397,8 @@ export function createTrajectoryRuntimeRecorder( maxRuntimeFileBytes, sessionFile: params.sessionFile, sessionId: params.sessionId, + sessionKey: params.sessionKey, + sessionTarget: params.sessionTarget, }); if (!sink) { return null; diff --git a/src/tui/embedded-backend.ts b/src/tui/embedded-backend.ts index 9f042a75e0ba..7e3a958d6a57 100644 --- a/src/tui/embedded-backend.ts +++ b/src/tui/embedded-backend.ts @@ -761,7 +761,7 @@ export class EmbeddedTuiBackend implements TuiBackend { ok: true as const, path: target.storePath, key: target.canonicalKey ?? opts.key, - entry: applied.entry, + entry: applied.entry as unknown as Record, resolved: { modelProvider: resolved.provider, model: resolved.model, diff --git a/src/tui/tui-pty-local.e2e.test.ts b/src/tui/tui-pty-local.e2e.test.ts index 5108808e50fb..1ebf38d1cf7c 100644 --- a/src/tui/tui-pty-local.e2e.test.ts +++ b/src/tui/tui-pty-local.e2e.test.ts @@ -1539,6 +1539,9 @@ describe("TUI PTY real backends", () => { }); const sendResults = await Promise.all([alphaSend, betaSend]); expect(sendResults.map((result) => result.status)).toEqual(["started", "started"]); + // Let both Gateway submissions reach the active-turn queue before the + // parent response opens the collect debounce window. + await sleep(1_000); fixture.mockModel.releaseFirstResponse(); await waitFor({ timeoutMs: LOCAL_OUTPUT_TIMEOUT_MS, diff --git a/src/worker/worker.fault-injection.test.ts b/src/worker/worker.fault-injection.test.ts index 95fe3f9c1035..96713974031a 100644 --- a/src/worker/worker.fault-injection.test.ts +++ b/src/worker/worker.fault-injection.test.ts @@ -203,7 +203,7 @@ class ComposedGatewayHarness { readonly stateDir: string; readonly sessionsDir: string; readonly storePath: string; - readonly sessionFile: string; + readonly sessionTarget: Awaited>; readonly socketPath: string; readonly cfg: OpenClawConfig; readonly database: OpenClawStateDatabase; @@ -239,28 +239,26 @@ class ComposedGatewayHarness { { agentId: "main", sessionKey: SESSION_KEY, storePath }, { sessionId: SESSION_ID, updatedAt: 1 }, ); - const sessionFile = ( - await resolveSessionTranscriptRuntimeTarget({ - agentId: "main", - sessionId: SESSION_ID, - sessionKey: SESSION_KEY, - storePath, - }) - ).sessionFile; - return new ComposedGatewayHarness({ root, sessionsDir, storePath, sessionFile }); + const sessionTarget = await resolveSessionTranscriptRuntimeTarget({ + agentId: "main", + sessionId: SESSION_ID, + sessionKey: SESSION_KEY, + storePath, + }); + return new ComposedGatewayHarness({ root, sessionsDir, storePath, sessionTarget }); } private constructor(params: { root: string; sessionsDir: string; storePath: string; - sessionFile: string; + sessionTarget: Awaited>; }) { this.root = params.root; this.stateDir = path.join(params.root, "state"); this.sessionsDir = params.sessionsDir; this.storePath = params.storePath; - this.sessionFile = params.sessionFile; + this.sessionTarget = params.sessionTarget; this.socketPath = path.join(params.root, "gateway.sock"); this.cfg = { agents: { list: [{ id: "main", default: true }] }, @@ -772,10 +770,10 @@ describe("cloud worker milestone 2 fault injection", () => { .requestParams("worker.live-event") .map((request) => (request as WorkerLiveEventParams).seq), ).toEqual([1, 1, 2, 3]); - const transcript = SessionManager.open(harness.sessionFile).getEntries(); + const transcript = SessionManager.open(harness.sessionTarget).getEntries(); expect(transcript).toHaveLength(2); expect(new Set(transcript.map((entry) => entry.id)).size).toBe(2); - expect(SessionManager.open(harness.sessionFile).getLeafId()).toBe(committed.newLeafId); + expect(SessionManager.open(harness.sessionTarget).getLeafId()).toBe(committed.newLeafId); }); it("recovers durable state across gateway restart and renumbers a lost live window", async () => { @@ -839,7 +837,7 @@ describe("cloud worker milestone 2 fault injection", () => { // resync replay renumbered from the fresh ack state. expect(liveRequests.length).toBeGreaterThanOrEqual(4); expect(liveRequests.slice(2)).toContainEqual([1, 0]); - expect(SessionManager.open(harness.sessionFile).getEntries()).toHaveLength(1); + expect(SessionManager.open(harness.sessionTarget).getEntries()).toHaveLength(1); providerRelease.resolve(doneOutcome("late stale provider result")); }); @@ -886,7 +884,7 @@ describe("cloud worker milestone 2 fault injection", () => { ).resolves.toEqual(doneOutcome("new owner reply")); await fresh.transcript.commit([transcriptMessage("new owner")]); - const messages = SessionManager.open(harness.sessionFile) + const messages = SessionManager.open(harness.sessionTarget) .getEntries() .flatMap((entry) => (entry.type === "message" ? [entry.message] : [])); expect(messages).toHaveLength(2); @@ -914,7 +912,7 @@ describe("cloud worker milestone 2 fault injection", () => { const commit = current.transcript.commit([transcriptMessage("stale paid output")]); await entered.promise; harness.partition(); - const local = SessionManager.open(harness.sessionFile); + const local = SessionManager.open(harness.sessionTarget); local.appendMessage(transcriptMessage("competing local entry")); release.resolve(); @@ -926,7 +924,7 @@ describe("cloud worker milestone 2 fault injection", () => { current.transcript.commit([transcriptMessage("must not retry after stale")]), ).rejects.toMatchObject({ name: "WorkerTranscriptCommitError" }); expect(harness.requestParams("worker.transcript.commit")).toHaveLength(2); - expect(SessionManager.open(harness.sessionFile).getEntries()).toHaveLength(1); + expect(SessionManager.open(harness.sessionTarget).getEntries()).toHaveLength(1); }); it("advances a worker live stream whose run context is dispatch-owned and visible", async () => { diff --git a/test/helpers/session-manager-file-compat.ts b/test/helpers/session-manager-file-compat.ts new file mode 100644 index 000000000000..0a90df68beba --- /dev/null +++ b/test/helpers/session-manager-file-compat.ts @@ -0,0 +1,5 @@ +import { installSessionManagerFileCompat } from "../../src/plugin-sdk/agent-runtime-test-contracts.js"; + +export { installSessionManagerFileCompat }; + +installSessionManagerFileCompat(); diff --git a/test/helpers/sqlite-sessions-transcripts-flip-proof-assertions.ts b/test/helpers/sqlite-sessions-transcripts-flip-proof-assertions.ts index fa488d10c0f1..9a6cea123eeb 100644 --- a/test/helpers/sqlite-sessions-transcripts-flip-proof-assertions.ts +++ b/test/helpers/sqlite-sessions-transcripts-flip-proof-assertions.ts @@ -73,7 +73,7 @@ export function assertSqliteFlipProofCore(report: SqliteFlipProofReport): void { latestAssistantTextAfterAppend: "sqlite sdk consumer appended by identity", sessionKey: report.pluginSdkSessionKey, }); - expect(report.pluginSdkConsumer?.sessionFileMarker.startsWith("sqlite:")).toBe(true); + expect(report.pluginSdkConsumer?.sessionIdentity).toBe(report.pluginSdkSessionKey); expect(report.pluginSdkConsumer?.listedSessionKeys).toContain(report.pluginSdkSessionKey); expect( report.checkpoints.some( diff --git a/test/helpers/sqlite-sessions-transcripts-flip-proof.ts b/test/helpers/sqlite-sessions-transcripts-flip-proof.ts index e04cc43ce13e..68c936d99bdd 100644 --- a/test/helpers/sqlite-sessions-transcripts-flip-proof.ts +++ b/test/helpers/sqlite-sessions-transcripts-flip-proof.ts @@ -13,12 +13,12 @@ import { readSessionArchiveContentSync, stripSessionArchiveCompressionSuffix, } from "../../src/config/sessions/archive-compression.js"; +import { formatSqliteSessionFileMarker } from "../../src/config/sessions/legacy-sqlite-marker.js"; import { appendTranscriptMessage, type TranscriptEvent, } from "../../src/config/sessions/session-accessor.js"; import { importSqliteSessionRows } from "../../src/config/sessions/session-accessor.sqlite.js"; -import { formatSqliteSessionFileMarker } from "../../src/config/sessions/sqlite-marker.js"; import type { SessionEntry } from "../../src/config/sessions/types.js"; import { connectGatewayClient, @@ -116,7 +116,7 @@ type PluginSdkConsumerEvidence = { latestAssistantTextBeforeAppend: string; latestAssistantTextAfterAppend: string; listedSessionKeys: string[]; - sessionFileMarker: string; + sessionIdentity: string; sessionId: string; sessionKey: string; storeTranscriptEvents: number; @@ -129,7 +129,7 @@ type ManualCompactionEvidence = { compacted: boolean; rowCountAfter: number; rowCountBefore: number; - sessionFileMarker: string; + transcriptIdentity: string; sessionId: string; sessionKey: string; }; @@ -1225,12 +1225,8 @@ async function runManualCompactionProof( if (checkpointCount < 1) { throw new Error(`manual compaction did not write checkpoint metadata: ${JSON.stringify(row)}`); } - const sessionFileMarker = typeof row.entry.sessionFile === "string" ? row.entry.sessionFile : ""; - if (!sessionFileMarker.startsWith("sqlite:")) { - throw new Error(`manual compaction entry did not keep a SQLite marker: ${sessionFileMarker}`); - } - if (fsSync.existsSync(sessionFileMarker)) { - throw new Error(`manual compaction marker unexpectedly exists as a file: ${sessionFileMarker}`); + if (Object.hasOwn(row.entry, "sessionFile")) { + throw new Error(`manual compaction entry retained file-era identity: ${JSON.stringify(row)}`); } return { @@ -1238,7 +1234,7 @@ async function runManualCompactionProof( compacted: compacted.compacted, rowCountAfter: countSqliteTranscriptEvents(context.agentDbPath, row.sessionId), rowCountBefore, - sessionFileMarker, + transcriptIdentity: context.manualCompactionSessionKey, sessionId: row.sessionId, sessionKey: context.manualCompactionSessionKey, }; @@ -1265,20 +1261,8 @@ async function runPluginSdkConsumerProbe( `SDK session store read returned ${JSON.stringify(sessionEntry)} for ${context.pluginSdkSessionKey}`, ); } - const expectedMarker = formatSqliteSessionFileMarker({ - agentId: context.agentId, - sessionId, - storePath: context.storePath, - }); - if (sessionEntry.sessionFile !== expectedMarker) { - throw new Error( - `SDK session store exposed unexpected transcript marker for ${context.pluginSdkSessionKey}: ${String( - sessionEntry.sessionFile, - )}`, - ); - } - if (fsSync.existsSync(sessionEntry.sessionFile)) { - throw new Error(`SDK session marker unexpectedly resolves to an active file path`); + if (Object.hasOwn(sessionEntry, "sessionFile")) { + throw new Error(`SDK session store exposed retired transcript locator`); } const listedSessionKeys = listSdkSessionEntries({ @@ -1373,7 +1357,7 @@ async function runPluginSdkConsumerProbe( latestAssistantTextBeforeAppend: latestBefore.text, latestAssistantTextAfterAppend: latestAfter.text, listedSessionKeys, - sessionFileMarker: sessionEntry.sessionFile, + sessionIdentity: context.pluginSdkSessionKey, sessionId, sessionKey: context.pluginSdkSessionKey, storeTranscriptEvents, @@ -2544,12 +2528,22 @@ function validateCheckpointInvariants( } } if (checkpoint.label === "after-shared-final-delete") { - requireArchiveText(checkpoint, failures, { - description: "final shared transcript archive", - includes: ["shared"], + const deletedArchive = findArchiveArtifact(checkpoint, { reason: "deleted", sessionId: "sqlite-shared-session", }); + if (!deletedArchive) { + // Both legacy files claim one session id, so the importer preserves the + // ambiguous sources as artifacts instead of materializing duplicate rows. + // Final deletion must not synthesize a second archive from empty SQLite state. + for (const sourceName of ["sqlite-shared-a.jsonl", "sqlite-shared-b.jsonl"]) { + requireArchiveText(checkpoint, failures, { + description: `retained shared import source ${sourceName}`, + includes: ["shared"], + pathIncludes: sourceName, + }); + } + } } } diff --git a/test/scripts/lint-suppressions.test.ts b/test/scripts/lint-suppressions.test.ts index f18610c55c8a..15f5c486071e 100644 --- a/test/scripts/lint-suppressions.test.ts +++ b/test/scripts/lint-suppressions.test.ts @@ -201,6 +201,7 @@ describe("production lint suppressions", () => { "scripts/changed-lanes.mjs|typescript/restrict-template-expressions|2", "src/agents/agent-bundle-mcp-runtime.ts|unicorn/prefer-add-event-listener|1", "src/agents/agent-tools.abort.ts|typescript/prefer-promise-reject-errors|1", + "src/agents/sessions/session-manager-entries.ts|unicorn/prefer-structured-clone|1", "src/audit/audit-event-writer.ts|unicorn/require-post-message-target-origin|2", "src/channels/plugins/channel-runtime-surface.types.ts|typescript/no-unnecessary-type-parameters|1", "src/channels/plugins/contracts/test-helpers.ts|typescript/no-unnecessary-type-parameters|1", diff --git a/test/scripts/sqlite-sessions-transcripts-flip-proof.e2e.test.ts b/test/scripts/sqlite-sessions-transcripts-flip-proof.e2e.test.ts index a01e54d08e8e..6d1001125a3b 100644 --- a/test/scripts/sqlite-sessions-transcripts-flip-proof.e2e.test.ts +++ b/test/scripts/sqlite-sessions-transcripts-flip-proof.e2e.test.ts @@ -83,7 +83,7 @@ describe("SQLite sessions/transcripts flip proof harness", () => { compacted: true, sessionKey: report.manualCompactionSessionKey, }); - expect(report.manualCompaction?.sessionFileMarker.startsWith("sqlite:")).toBe(true); + expect(report.manualCompaction?.transcriptIdentity).toBe(report.manualCompactionSessionKey); expect(report.manualCompaction?.rowCountBefore).toBeGreaterThanOrEqual(2); expect(report.manualCompaction?.rowCountAfter).toBeGreaterThanOrEqual(1); expect( @@ -104,6 +104,8 @@ describe("SQLite sessions/transcripts flip proof harness", () => { activeTrajectoryRuntimeSidecarForSessionExists: false, activeTrajectorySessionSidecarForSessionExists: false, }); + expect(report.pluginSdkConsumer?.sessionIdentity).toBe(report.pluginSdkSessionKey); + expect(report.pluginSdkConsumer?.listedSessionKeys).toContain(report.pluginSdkSessionKey); expect(report.pluginSdkConsumer?.transcriptEventsAfterAppend).toBeGreaterThan( report.pluginSdkConsumer?.transcriptEventsBeforeAppend ?? 0, ); @@ -199,7 +201,19 @@ describe("SQLite sessions/transcripts flip proof harness", () => { artifact.archiveReason === "deleted" && artifact.archiveSessionId === "sqlite-shared-session", ); - expect(sharedFinalArchive?.messageTexts).toContain("shared"); + const retainedSharedImportSources = sharedFinalCheckpoint?.archiveArtifacts.filter( + (artifact) => + artifact.path.includes("session-sqlite-import-archive") && + (artifact.path.includes("sqlite-shared-a.jsonl") || + artifact.path.includes("sqlite-shared-b.jsonl")), + ); + expect( + sharedFinalArchive?.messageTexts?.includes("shared") || + (retainedSharedImportSources?.length === 2 && + retainedSharedImportSources.every((artifact) => + artifact.messageTexts?.some((text) => text.includes("shared")), + )), + ).toBe(true); expect( report.checkpoints.some( (checkpoint) => diff --git a/tsconfig.core.json b/tsconfig.core.json index 19c5c8b9892d..f2c33b0fd257 100644 --- a/tsconfig.core.json +++ b/tsconfig.core.json @@ -9,5 +9,13 @@ "tsBuildInfoFile": ".artifacts/tsgo-cache/core.tsbuildinfo" }, "include": ["src/**/*", "packages/**/*"], - "exclude": ["node_modules", "dist", "**/dist/**", "**/*.test.ts", "**/*.test.tsx", "test/**"] + "exclude": [ + "node_modules", + "dist", + "**/dist/**", + "**/*.test.ts", + "**/*.test.tsx", + "**/*.test-compat.d.ts", + "test/**" + ] }