mixdog 1.2.1 → 1.2.3
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +18 -0
- package/package.json +2 -1
- package/scripts/gate-local.mjs +19 -3
- package/scripts/model-catalog-audit.mjs +1 -15
- package/scripts/provider-pingpong-live.mjs +509 -0
- package/src/agents/advisor/AGENT.md +1 -1
- package/src/agents/advisor/agent.json +1 -1
- package/src/agents/reviewer/AGENT.md +3 -3
- package/src/agents/reviewer/agent.json +1 -1
- package/src/defaults/shipped-history.json +6 -0
- package/src/defaults/skills/setup/SKILL.md +6 -1
- package/src/defaults/skills/setup/references/actions.md +44 -22
- package/src/defaults/skills/setup/references/surfaces.md +10 -8
- package/src/headless-exec.mjs +15 -8
- package/src/lib/keychain-cjs.cjs +7 -4
- package/src/output-styles/common.md +11 -10
- package/src/repl/slash-commands.mjs +16 -1
- package/src/rules/agent/AGENT.md +5 -5
- package/src/rules/lead/LEAD.md +5 -5
- package/src/runtime/agent/orchestrator/agent-runtime/agent-progress-watchdog.mjs +29 -4
- package/src/runtime/agent/orchestrator/agent-runtime/cache-strategy.mjs +10 -0
- package/src/runtime/agent/orchestrator/agent-trace-io.mjs +92 -61
- package/src/runtime/agent/orchestrator/cache-mtime.mjs +10 -1
- package/src/runtime/agent/orchestrator/config-providers-merge.mjs +7 -0
- package/src/runtime/agent/orchestrator/config.mjs +5 -0
- package/src/runtime/agent/orchestrator/context/collect.mjs +1 -0
- package/src/runtime/agent/orchestrator/context/deferred-tools.mjs +26 -7
- package/src/runtime/agent/orchestrator/context/skill-catalog.mjs +112 -7
- package/src/runtime/agent/orchestrator/mcp/client-config.mjs +36 -7
- package/src/runtime/agent/orchestrator/mcp/client-handshake.mjs +49 -10
- package/src/runtime/agent/orchestrator/mcp/client-transport.mjs +40 -3
- package/src/runtime/agent/orchestrator/mcp/client.mjs +230 -96
- package/src/runtime/agent/orchestrator/mcp/oauth-callback.mjs +103 -0
- package/src/runtime/agent/orchestrator/mcp/oauth-credentials.mjs +150 -0
- package/src/runtime/agent/orchestrator/mcp/oauth-login.mjs +111 -0
- package/src/runtime/agent/orchestrator/mcp/oauth-probe.mjs +87 -0
- package/src/runtime/agent/orchestrator/mcp/oauth-provider.mjs +95 -0
- package/src/runtime/agent/orchestrator/mcp/oauth-request.mjs +122 -0
- package/src/runtime/agent/orchestrator/mcp/oauth-shared.mjs +9 -0
- package/src/runtime/agent/orchestrator/mcp/oauth.mjs +229 -0
- package/src/runtime/agent/orchestrator/providers/account-pool.mjs +188 -29
- package/src/runtime/agent/orchestrator/providers/anthropic-api-recovery.mjs +2 -1
- package/src/runtime/agent/orchestrator/providers/anthropic-api-request.mjs +48 -20
- package/src/runtime/agent/orchestrator/providers/anthropic-api-transport.mjs +94 -31
- package/src/runtime/agent/orchestrator/providers/anthropic-error-contract.mjs +98 -0
- package/src/runtime/agent/orchestrator/providers/anthropic-fast-mode.mjs +39 -18
- package/src/runtime/agent/orchestrator/providers/anthropic-max-tokens-recovery.mjs +55 -0
- package/src/runtime/agent/orchestrator/providers/anthropic-max-tokens.mjs +48 -37
- package/src/runtime/agent/orchestrator/providers/anthropic-messages.mjs +10 -7
- package/src/runtime/agent/orchestrator/providers/anthropic-midstream-recovery.mjs +18 -4
- package/src/runtime/agent/orchestrator/providers/anthropic-model-resolve.mjs +13 -1
- package/src/runtime/agent/orchestrator/providers/anthropic-oauth-client-version.mjs +1 -1
- package/src/runtime/agent/orchestrator/providers/anthropic-oauth-midstream.mjs +10 -1
- package/src/runtime/agent/orchestrator/providers/anthropic-oauth-recovery.mjs +23 -9
- package/src/runtime/agent/orchestrator/providers/anthropic-oauth-request/initial-status.mjs +46 -20
- package/src/runtime/agent/orchestrator/providers/anthropic-oauth-request.mjs +14 -9
- package/src/runtime/agent/orchestrator/providers/anthropic-oauth.mjs +102 -45
- package/src/runtime/agent/orchestrator/providers/anthropic-overload-fallback.mjs +69 -0
- package/src/runtime/agent/orchestrator/providers/anthropic-server-fallback.mjs +4 -0
- package/src/runtime/agent/orchestrator/providers/anthropic-sse-turn/turn-blocks.mjs +20 -1
- package/src/runtime/agent/orchestrator/providers/anthropic-sse-turn/turn-events/content-block-delta.mjs +15 -2
- package/src/runtime/agent/orchestrator/providers/anthropic-sse-turn/turn-events/content-block-start.mjs +11 -4
- package/src/runtime/agent/orchestrator/providers/anthropic-sse-turn/turn-events/message-events.mjs +3 -0
- package/src/runtime/agent/orchestrator/providers/anthropic-sse-turn/turn-events.mjs +12 -5
- package/src/runtime/agent/orchestrator/providers/anthropic-sse-turn/turn-outcome.mjs +1 -0
- package/src/runtime/agent/orchestrator/providers/anthropic-sse-turn/turn-state.mjs +14 -0
- package/src/runtime/agent/orchestrator/providers/anthropic-sse.mjs +10 -1
- package/src/runtime/agent/orchestrator/providers/anthropic-turn-result.mjs +9 -0
- package/src/runtime/agent/orchestrator/providers/anthropic.mjs +40 -7
- package/src/runtime/agent/orchestrator/providers/antigravity-oauth-catalog.mjs +30 -9
- package/src/runtime/agent/orchestrator/providers/antigravity-oauth.mjs +7 -0
- package/src/runtime/agent/orchestrator/providers/antigravity-request.mjs +88 -21
- package/src/runtime/agent/orchestrator/providers/antigravity-response.mjs +30 -10
- package/src/runtime/agent/orchestrator/providers/antigravity-retry-policy.mjs +106 -0
- package/src/runtime/agent/orchestrator/providers/antigravity-stream.mjs +10 -61
- package/src/runtime/agent/orchestrator/providers/antigravity-transport.mjs +94 -15
- package/src/runtime/agent/orchestrator/providers/codex-client-meta.mjs +1 -1
- package/src/runtime/agent/orchestrator/providers/command-code-oauth.mjs +117 -72
- package/src/runtime/agent/orchestrator/providers/compat-request-policy.mjs +78 -2
- package/src/runtime/agent/orchestrator/providers/cursor-auth.mjs +75 -10
- package/src/runtime/agent/orchestrator/providers/cursor-client-version.mjs +1 -1
- package/src/runtime/agent/orchestrator/providers/cursor-wire-guards.mjs +97 -2
- package/src/runtime/agent/orchestrator/providers/cursor-wire-stream-bridge.mjs +103 -23
- package/src/runtime/agent/orchestrator/providers/cursor-wire-stream-messages.mjs +43 -2
- package/src/runtime/agent/orchestrator/providers/cursor-wire-transport.mjs +103 -33
- package/src/runtime/agent/orchestrator/providers/cursor-wire.mjs +32 -6
- package/src/runtime/agent/orchestrator/providers/cursor.mjs +27 -3
- package/src/runtime/agent/orchestrator/providers/custom-provider-config.mjs +64 -1
- package/src/runtime/agent/orchestrator/providers/custom-provider.mjs +70 -6
- package/src/runtime/agent/orchestrator/providers/custom-reasoning.mjs +34 -7
- package/src/runtime/agent/orchestrator/providers/custom-tool-wire.mjs +32 -6
- package/src/runtime/agent/orchestrator/providers/deepseek.mjs +67 -0
- package/src/runtime/agent/orchestrator/providers/effort-configuration.mjs +80 -18
- package/src/runtime/agent/orchestrator/providers/gemini-cache-policy.mjs +11 -4
- package/src/runtime/agent/orchestrator/providers/gemini-cache.mjs +46 -8
- package/src/runtime/agent/orchestrator/providers/gemini-error-contract.mjs +273 -0
- package/src/runtime/agent/orchestrator/providers/gemini-family.mjs +9 -0
- package/src/runtime/agent/orchestrator/providers/gemini-history-signatures.mjs +9 -4
- package/src/runtime/agent/orchestrator/providers/gemini-request-body.mjs +41 -5
- package/src/runtime/agent/orchestrator/providers/gemini-response.mjs +3 -1
- package/src/runtime/agent/orchestrator/providers/gemini-retry-policy.mjs +180 -0
- package/src/runtime/agent/orchestrator/providers/gemini-schema.mjs +369 -112
- package/src/runtime/agent/orchestrator/providers/gemini-sdk-request.mjs +3 -1
- package/src/runtime/agent/orchestrator/providers/gemini-stream.mjs +140 -35
- package/src/runtime/agent/orchestrator/providers/gemini-thinking.mjs +70 -24
- package/src/runtime/agent/orchestrator/providers/gemini.mjs +80 -29
- package/src/runtime/agent/orchestrator/providers/grok-client-version.mjs +1 -1
- package/src/runtime/agent/orchestrator/providers/grok-oauth-tokens.mjs +3 -0
- package/src/runtime/agent/orchestrator/providers/grok-oauth.mjs +69 -19
- package/src/runtime/agent/orchestrator/providers/lib/action-input-contract.mjs +60 -0
- package/src/runtime/agent/orchestrator/providers/lib/anthropic-native-blocks.mjs +1 -0
- package/src/runtime/agent/orchestrator/providers/lib/anthropic-request-utils.mjs +246 -42
- package/src/runtime/agent/orchestrator/providers/lib/anthropic-server-tool-usage.mjs +18 -0
- package/src/runtime/agent/orchestrator/providers/lib/anthropic-system-blocks.mjs +27 -6
- package/src/runtime/agent/orchestrator/providers/lib/claude-bridge-schema.mjs +388 -0
- package/src/runtime/agent/orchestrator/providers/lib/inclusive-usage.mjs +54 -0
- package/src/runtime/agent/orchestrator/providers/lib/origin-bound-fetch.mjs +16 -0
- package/src/runtime/agent/orchestrator/providers/lib/responses-terminal-fields.mjs +11 -0
- package/src/runtime/agent/orchestrator/providers/lib/usage-primitives.mjs +8 -0
- package/src/runtime/agent/orchestrator/providers/lib/wire-pairing.mjs +48 -22
- package/src/runtime/agent/orchestrator/providers/media-normalization.mjs +119 -18
- package/src/runtime/agent/orchestrator/providers/mimo-oauth-profile.mjs +13 -15
- package/src/runtime/agent/orchestrator/providers/mimo-oauth.mjs +3 -0
- package/src/runtime/agent/orchestrator/providers/model-list-sanitize.mjs +2 -1
- package/src/runtime/agent/orchestrator/providers/model-proxy-channel.d.mts +1 -1
- package/src/runtime/agent/orchestrator/providers/model-proxy-channel.mjs +1 -0
- package/src/runtime/agent/orchestrator/providers/model-proxy-provider.mjs +33 -1
- package/src/runtime/agent/orchestrator/providers/muse-code-oauth.mjs +4 -9
- package/src/runtime/agent/orchestrator/providers/oauth-credential-probes.mjs +2 -0
- package/src/runtime/agent/orchestrator/providers/openai-codex-metadata.mjs +2 -0
- package/src/runtime/agent/orchestrator/providers/openai-compat-chat-send.mjs +123 -61
- package/src/runtime/agent/orchestrator/providers/openai-compat-chat-stream/chunk-events.mjs +35 -14
- package/src/runtime/agent/orchestrator/providers/openai-compat-chat-stream/stream-result.mjs +27 -2
- package/src/runtime/agent/orchestrator/providers/openai-compat-chat-stream/stream-state.mjs +37 -7
- package/src/runtime/agent/orchestrator/providers/openai-compat-chat-stream/tool-call-acc.mjs +2 -9
- package/src/runtime/agent/orchestrator/providers/openai-compat-chat-stream.mjs +1 -0
- package/src/runtime/agent/orchestrator/providers/openai-compat-error-contract.mjs +50 -0
- package/src/runtime/agent/orchestrator/providers/openai-compat-models.mjs +47 -2
- package/src/runtime/agent/orchestrator/providers/openai-compat-options.mjs +47 -25
- package/src/runtime/agent/orchestrator/providers/openai-compat-presets.mjs +5 -1
- package/src/runtime/agent/orchestrator/providers/openai-compat-reasoning-field.mjs +151 -0
- package/src/runtime/agent/orchestrator/providers/openai-compat-response-normalization.mjs +64 -23
- package/src/runtime/agent/orchestrator/providers/openai-compat-responses-events.mjs +79 -33
- package/src/runtime/agent/orchestrator/providers/openai-compat-responses-head-parity.json +2901 -0
- package/src/runtime/agent/orchestrator/providers/openai-compat-responses-parity.fixtures.mjs +172 -0
- package/src/runtime/agent/orchestrator/providers/openai-compat-responses-state.mjs +3 -2
- package/src/runtime/agent/orchestrator/providers/openai-compat-responses.mjs +144 -15
- package/src/runtime/agent/orchestrator/providers/openai-compat-retry-profile.mjs +193 -0
- package/src/runtime/agent/orchestrator/providers/openai-compat-stream-common.mjs +66 -2
- package/src/runtime/agent/orchestrator/providers/openai-compat-stream.mjs +9 -2
- package/src/runtime/agent/orchestrator/providers/openai-compat-trace.mjs +2 -0
- package/src/runtime/agent/orchestrator/providers/openai-compat-wire.mjs +109 -26
- package/src/runtime/agent/orchestrator/providers/openai-compat-xai-send.mjs +27 -6
- package/src/runtime/agent/orchestrator/providers/openai-compat-xai.mjs +34 -20
- package/src/runtime/agent/orchestrator/providers/openai-compat.mjs +23 -4
- package/src/runtime/agent/orchestrator/providers/openai-direct-request.mjs +37 -28
- package/src/runtime/agent/orchestrator/providers/openai-error-contract.mjs +274 -0
- package/src/runtime/agent/orchestrator/providers/openai-http-sse-request.mjs +72 -32
- package/src/runtime/agent/orchestrator/providers/openai-http-sse-response-state/response-events/output-items.mjs +23 -10
- package/src/runtime/agent/orchestrator/providers/openai-http-sse-response-state/response-events/terminal-frames.mjs +43 -15
- package/src/runtime/agent/orchestrator/providers/openai-http-sse-response-state/response-events.mjs +10 -0
- package/src/runtime/agent/orchestrator/providers/openai-http-sse-response-state/response-items.mjs +18 -7
- package/src/runtime/agent/orchestrator/providers/openai-http-sse-response-state/response-outcome.mjs +5 -2
- package/src/runtime/agent/orchestrator/providers/openai-http-sse-response-state/response-stamps.mjs +2 -2
- package/src/runtime/agent/orchestrator/providers/openai-http-sse-response-state/response-state.mjs +7 -3
- package/src/runtime/agent/orchestrator/providers/openai-http-sse-response-state/text-relay.mjs +9 -1
- package/src/runtime/agent/orchestrator/providers/openai-http-sse-response-state.mjs +2 -2
- package/src/runtime/agent/orchestrator/providers/openai-http-sse-watchdogs.mjs +1 -3
- package/src/runtime/agent/orchestrator/providers/openai-model-capabilities.mjs +80 -0
- package/src/runtime/agent/orchestrator/providers/openai-oauth-dispatch.mjs +37 -4
- package/src/runtime/agent/orchestrator/providers/openai-oauth-http-sse.mjs +12 -1
- package/src/runtime/agent/orchestrator/providers/openai-oauth-tokens.mjs +22 -2
- package/src/runtime/agent/orchestrator/providers/openai-oauth.mjs +12 -1
- package/src/runtime/agent/orchestrator/providers/openai-responses-input.mjs +76 -33
- package/src/runtime/agent/orchestrator/providers/openai-responses-payload-parity.fixture.json +323 -0
- package/src/runtime/agent/orchestrator/providers/openai-responses-payload.mjs +44 -16
- package/src/runtime/agent/orchestrator/providers/openai-retry-policy.mjs +9 -0
- package/src/runtime/agent/orchestrator/providers/openai-ws-delta.mjs +7 -16
- package/src/runtime/agent/orchestrator/providers/openai-ws-handshake.mjs +16 -8
- package/src/runtime/agent/orchestrator/providers/openai-ws-response-state/refusals.mjs +60 -0
- package/src/runtime/agent/orchestrator/providers/openai-ws-response-state/tool-calls.mjs +51 -35
- package/src/runtime/agent/orchestrator/providers/openai-ws-response-state.mjs +30 -0
- package/src/runtime/agent/orchestrator/providers/openai-ws-send-attempts/attempt-context.mjs +1 -2
- package/src/runtime/agent/orchestrator/providers/openai-ws-send-attempts/policy.mjs +25 -14
- package/src/runtime/agent/orchestrator/providers/openai-ws-stream/event-switch.mjs +12 -0
- package/src/runtime/agent/orchestrator/providers/openai-ws-stream/terminal-frames.mjs +25 -6
- package/src/runtime/agent/orchestrator/providers/openai-ws-terminal.mjs +18 -11
- package/src/runtime/agent/orchestrator/providers/openai-ws.mjs +41 -22
- package/src/runtime/agent/orchestrator/providers/opencode-go-gemini.mjs +170 -0
- package/src/runtime/agent/orchestrator/providers/opencode-go.mjs +75 -70
- package/src/runtime/agent/orchestrator/providers/openrouter.mjs +75 -0
- package/src/runtime/agent/orchestrator/providers/provider-error-kind.mjs +720 -0
- package/src/runtime/agent/orchestrator/providers/registry.mjs +4 -1
- package/src/runtime/agent/orchestrator/providers/retry-backoff.mjs +46 -7
- package/src/runtime/agent/orchestrator/providers/retry-classification.mjs +123 -219
- package/src/runtime/agent/orchestrator/providers/retry-classifier.mjs +2 -0
- package/src/runtime/agent/orchestrator/providers/retry-executor.mjs +148 -45
- package/src/runtime/agent/orchestrator/providers/retry-midstream.mjs +81 -16
- package/src/runtime/agent/orchestrator/providers/transport-scope.d.mts +6 -0
- package/src/runtime/agent/orchestrator/providers/transport-scope.mjs +28 -0
- package/src/runtime/agent/orchestrator/providers/wire-gateway.mjs +157 -0
- package/src/runtime/agent/orchestrator/providers/wire-route.mjs +512 -0
- package/src/runtime/agent/orchestrator/providers/xai-tool-search-head-parity.json +1131 -0
- package/src/runtime/agent/orchestrator/providers/xai-tool-search.fixtures.mjs +183 -0
- package/src/runtime/agent/orchestrator/runtime-core/ask-user-tool-defs.mjs +100 -0
- package/src/runtime/agent/orchestrator/runtime-core/config-helpers.mjs +3 -0
- package/src/runtime/agent/orchestrator/runtime-core/deferred-tool-availability.mjs +21 -0
- package/src/runtime/agent/orchestrator/runtime-core/effort.mjs +15 -0
- package/src/runtime/agent/orchestrator/runtime-core/goal-reminder.mjs +0 -18
- package/src/runtime/agent/orchestrator/runtime-core/model-capabilities.mjs +57 -28
- package/src/runtime/agent/orchestrator/runtime-core/orchestration.mjs +19 -16
- package/src/runtime/agent/orchestrator/runtime-core/plugin-mcp.mjs +1 -97
- package/src/runtime/agent/orchestrator/runtime-core/provider-request-snapshot.mjs +7 -3
- package/src/runtime/agent/orchestrator/runtime-core/session-title.mjs +21 -1
- package/src/runtime/agent/orchestrator/runtime-core/skill-tool-loading.mjs +14 -3
- package/src/runtime/agent/orchestrator/runtime-core/tool-bridge.mjs +526 -0
- package/src/runtime/agent/orchestrator/runtime-core/tool-catalog-data.mjs +3 -0
- package/src/runtime/agent/orchestrator/runtime-core/tool-catalog-schema.mjs +10 -7
- package/src/runtime/agent/orchestrator/runtime-core/tool-catalog.mjs +176 -71
- package/src/runtime/agent/orchestrator/runtime-core/tool-defs.mjs +62 -10
- package/src/runtime/agent/orchestrator/runtime-core/tool-exposure.mjs +96 -0
- package/src/runtime/agent/orchestrator/session/catalog-db/schema.mjs +5 -1
- package/src/runtime/agent/orchestrator/session/compact/constants.mjs +3 -2
- package/src/runtime/agent/orchestrator/session/compact/messages.mjs +14 -93
- package/src/runtime/agent/orchestrator/session/compact/summary-anchor.mjs +5 -0
- package/src/runtime/agent/orchestrator/session/compact/user-message-kind.mjs +120 -0
- package/src/runtime/agent/orchestrator/session/context-tool-schema.mjs +17 -2
- package/src/runtime/agent/orchestrator/session/context-utils.mjs +8 -4
- package/src/runtime/agent/orchestrator/session/eager-dispatch/admission.mjs +2 -1
- package/src/runtime/agent/orchestrator/session/eager-dispatch.mjs +6 -1
- package/src/runtime/agent/orchestrator/session/loop/compact-policy.mjs +91 -1
- package/src/runtime/agent/orchestrator/session/loop/completion-guards.mjs +1 -1
- package/src/runtime/agent/orchestrator/session/loop/context-overflow.mjs +56 -0
- package/src/runtime/agent/orchestrator/session/loop/deferred-call-through.mjs +18 -8
- package/src/runtime/agent/orchestrator/session/loop/loop-state.mjs +1 -0
- package/src/runtime/agent/orchestrator/session/loop/pre-dispatch-deny.mjs +21 -10
- package/src/runtime/agent/orchestrator/session/loop/provider-tool-exclusions.mjs +132 -0
- package/src/runtime/agent/orchestrator/session/loop/request-boundary.mjs +30 -0
- package/src/runtime/agent/orchestrator/session/loop/send-phase.mjs +67 -20
- package/src/runtime/agent/orchestrator/session/loop/step-auto-effort.mjs +104 -19
- package/src/runtime/agent/orchestrator/session/loop/tool-call-resolution.mjs +95 -0
- package/src/runtime/agent/orchestrator/session/loop/tool-exec/before-hook.mjs +30 -3
- package/src/runtime/agent/orchestrator/session/loop/tool-exec/dispatch.mjs +15 -4
- package/src/runtime/agent/orchestrator/session/loop/tool-exec.mjs +15 -1
- package/src/runtime/agent/orchestrator/session/loop/tool-exposure-migration.mjs +150 -0
- package/src/runtime/agent/orchestrator/session/loop/tool-phase.mjs +7 -2
- package/src/runtime/agent/orchestrator/session/manager/ask-queue.mjs +30 -2
- package/src/runtime/agent/orchestrator/session/manager/ask-session.mjs +11 -2
- package/src/runtime/agent/orchestrator/session/manager/ask-turn-auto-effort.mjs +2 -1
- package/src/runtime/agent/orchestrator/session/manager/ask-turn-commit.mjs +34 -12
- package/src/runtime/agent/orchestrator/session/manager/ask-turn-execute.mjs +57 -21
- package/src/runtime/agent/orchestrator/session/manager/ask-turn-loop-options.mjs +57 -25
- package/src/runtime/agent/orchestrator/session/manager/ask-turn-prepare.mjs +6 -0
- package/src/runtime/agent/orchestrator/session/manager/ask-turn-settle.mjs +4 -0
- package/src/runtime/agent/orchestrator/session/manager/compaction-runner.mjs +8 -0
- package/src/runtime/agent/orchestrator/session/manager/pending-message-entry.mjs +5 -10
- package/src/runtime/agent/orchestrator/session/manager/prompt-utils.mjs +208 -14
- package/src/runtime/agent/orchestrator/session/manager/session-close.mjs +16 -4
- package/src/runtime/agent/orchestrator/session/manager/session-crud.mjs +25 -3
- package/src/runtime/agent/orchestrator/session/manager/session-fallback-title.mjs +82 -0
- package/src/runtime/agent/orchestrator/session/manager/session-lifecycle.mjs +19 -2
- package/src/runtime/agent/orchestrator/session/manager/session-record.mjs +3 -0
- package/src/runtime/agent/orchestrator/session/manager/tool-resolution.mjs +4 -1
- package/src/runtime/agent/orchestrator/session/manager/turn-interruption.mjs +18 -7
- package/src/runtime/agent/orchestrator/session/manager.mjs +2 -1
- package/src/runtime/agent/orchestrator/session/pre-send-compact.mjs +6 -0
- package/src/runtime/agent/orchestrator/session/send-with-recovery.mjs +168 -29
- package/src/runtime/agent/orchestrator/session/side-question-store.mjs +96 -0
- package/src/runtime/agent/orchestrator/session/side-question.mjs +192 -0
- package/src/runtime/agent/orchestrator/session/store/lifecycle-barriers.mjs +17 -3
- package/src/runtime/agent/orchestrator/session/store/live-state.mjs +23 -0
- package/src/runtime/agent/orchestrator/session/store/paths-heartbeat.mjs +58 -2
- package/src/runtime/agent/orchestrator/session/store/save-worker.mjs +6 -3
- package/src/runtime/agent/orchestrator/session/store/session-delete.mjs +41 -28
- package/src/runtime/agent/orchestrator/session/store/session-observers.mjs +4 -2
- package/src/runtime/agent/orchestrator/session/store/sweep/sweep-finish.mjs +12 -1
- package/src/runtime/agent/orchestrator/session/store/write-admission.mjs +3 -1
- package/src/runtime/agent/orchestrator/session/store/write-guards.mjs +16 -0
- package/src/runtime/agent/orchestrator/session/store-agent-worker-rows.mjs +22 -10
- package/src/runtime/agent/orchestrator/session/store-summary-archived-agent.mjs +34 -15
- package/src/runtime/agent/orchestrator/session/store-summary-catalog.mjs +6 -0
- package/src/runtime/agent/orchestrator/session/store-summary-fields.mjs +13 -0
- package/src/runtime/agent/orchestrator/session/store-summary-index.mjs +13 -1
- package/src/runtime/agent/orchestrator/session/store-summary-reader.mjs +10 -2
- package/src/runtime/agent/orchestrator/session/store.mjs +19 -3
- package/src/runtime/agent/orchestrator/session/tool-batch/execute-call.mjs +1 -0
- package/src/runtime/agent/orchestrator/session/tool-batch/finalize.mjs +1 -1
- package/src/runtime/agent/orchestrator/session/tool-batch/record-outcome.mjs +1 -1
- package/src/runtime/agent/orchestrator/session/tool-batch/state.mjs +7 -1
- package/src/runtime/agent/orchestrator/session/tool-result-offload.mjs +4 -0
- package/src/runtime/agent/orchestrator/session/tool-schema-rejection.mjs +77 -0
- package/src/runtime/agent/orchestrator/session/transcript-restore/restore.mjs +20 -1
- package/src/runtime/agent/orchestrator/tools/bash-policy-scan.mjs +8 -2
- package/src/runtime/agent/orchestrator/tools/builtin/arg-guard.mjs +2 -2
- package/src/runtime/agent/orchestrator/tools/builtin/bash-tool/command-preflight.mjs +3 -3
- package/src/runtime/agent/orchestrator/tools/builtin/bash-tool/deadline-plan.mjs +12 -15
- package/src/runtime/agent/orchestrator/tools/builtin/bash-tool.mjs +4 -1
- package/src/runtime/agent/orchestrator/tools/builtin/builtin-tools.mjs +5 -5
- package/src/runtime/agent/orchestrator/tools/builtin/external-tool-adapters.mjs +14 -10
- package/src/runtime/agent/orchestrator/tools/builtin/lib/shell-job-records.mjs +49 -34
- package/src/runtime/agent/orchestrator/tools/builtin/runtime-capabilities.mjs +23 -1
- package/src/runtime/agent/orchestrator/tools/code-graph/dispatch/root-resolution.mjs +1 -1
- package/src/runtime/agent/orchestrator/tools/code-graph/search.mjs +1 -1
- package/src/runtime/agent/orchestrator/tools/patch/ui-diff.mjs +73 -39
- package/src/runtime/agent/orchestrator/tools/progress-message.mjs +18 -1
- package/src/runtime/agent/orchestrator/tools/shell-command.mjs +7 -4
- package/src/runtime/agent/orchestrator/tools/shell-exec-policy.mjs +88 -12
- package/src/runtime/agent/orchestrator/tools/shell-policy.mjs +2 -0
- package/src/runtime/computer-bridge/action-schema.mjs +44 -21
- package/src/runtime/github/tool.mjs +92 -2
- package/src/runtime/memory/index.mjs +1 -0
- package/src/runtime/memory/lib/http-router/ingest-routes.mjs +22 -1
- package/src/runtime/memory/lib/ingest/transcript-offsets.mjs +2 -0
- package/src/runtime/memory/lib/pg/adapter.mjs +98 -1
- package/src/runtime/memory/lib/pg/process.mjs +65 -50
- package/src/runtime/memory/lib/pg/run-child.mjs +49 -0
- package/src/runtime/memory/lib/pg/supervisor.mjs +124 -16
- package/src/runtime/memory/lib/runtime-fetcher.mjs +12 -13
- package/src/runtime/memory/lib/transcript-ingest.mjs +14 -0
- package/src/runtime/office/README.md +2 -2
- package/src/runtime/office/authoring/html-document-author.mjs +2 -0
- package/src/runtime/office/authoring/pptx-contact-sheet.mjs +2 -0
- package/src/runtime/office/authoring/pptx-html-measure.mjs +2 -0
- package/src/runtime/office/authoring/pptx-html-runner.mjs +2 -0
- package/src/runtime/office/authoring/xlsx-html-runner.mjs +2 -0
- package/src/runtime/office/core/office-actions-read.mjs +3 -0
- package/src/runtime/office/core/office-render-preview.mjs +2 -0
- package/src/runtime/office/core/office-session-dispatch.mjs +7 -0
- package/src/runtime/office/core/office-sessionless-actions.mjs +8 -0
- package/src/runtime/office/core/office-sessions.mjs +5 -0
- package/src/runtime/office/core/office-transactions.mjs +3 -0
- package/src/runtime/office/core/office-write-ledger.mjs +98 -0
- package/src/runtime/office/design/library/templates/mixdog-executive.mixdog-edit.pptx +0 -0
- package/src/runtime/office/index.mjs +30 -0
- package/src/runtime/office/pdf/pdf-edit-document.mjs +2 -0
- package/src/runtime/office/pdf/pdf-render.mjs +23 -7
- package/src/runtime/office/pdf/pdf-security.mjs +2 -0
- package/src/runtime/office/portable/portable-opc.mjs +4 -1
- package/src/runtime/office/quality/visual-diff.mjs +2 -0
- package/src/runtime/office/shared/values.mjs +0 -5
- package/src/runtime/shared/background-tasks.mjs +92 -14
- package/src/runtime/shared/bounded-file-read.mjs +13 -0
- package/src/runtime/shared/bounded-line-diff.mjs +182 -0
- package/src/runtime/shared/buffered-appender.mjs +18 -0
- package/src/runtime/shared/config.mjs +78 -18
- package/src/runtime/shared/err-text.mjs +31 -0
- package/src/runtime/shared/error-presentation.mjs +159 -19
- package/src/runtime/shared/file-permissions.d.mts +6 -1
- package/src/runtime/shared/file-permissions.mjs +54 -6
- package/src/runtime/shared/llm/anthropic-betas.mjs +4 -3
- package/src/runtime/shared/llm/anthropic-effort.mjs +129 -134
- package/src/runtime/shared/llm/anthropic-thinking-contract.mjs +50 -14
- package/src/runtime/shared/llm/model-catalog.mjs +53 -40
- package/src/runtime/shared/llm/openai-daybreak.mjs +4 -16
- package/src/runtime/shared/llm/provider-catalog-registry.mjs +11 -0
- package/src/runtime/shared/llm/provider-model-identities.mjs +13 -4
- package/src/runtime/shared/llm/usage-accounting.mjs +5 -2
- package/src/runtime/shared/llm/usage-ledger-rollup.mjs +3 -0
- package/src/runtime/shared/llm/usage-rollup.mjs +0 -1
- package/src/runtime/shared/loopback-listener.mjs +9 -0
- package/src/runtime/shared/model-display.mjs +13 -0
- package/src/runtime/shared/provider-api-key.mjs +2 -0
- package/src/runtime/shared/quota-resume-store.mjs +11 -0
- package/src/runtime/shared/review-diff.mjs +52 -0
- package/src/runtime/shared/session-baseline-store.mjs +960 -0
- package/src/runtime/shared/session-sidebar-host.d.mts +4 -0
- package/src/runtime/shared/session-sidebar-host.mjs +38 -0
- package/src/runtime/shared/slow-span.mjs +114 -0
- package/src/runtime/shared/statusline/statusline-format.mjs +4 -4
- package/src/runtime/shared/statusline/statusline-segments.mjs +28 -4
- package/src/runtime/shared/task-notification-envelope.mjs +2 -2
- package/src/runtime/shared/tool-card-model/card-header.mjs +6 -1
- package/src/runtime/shared/tool-card-model/generic-detail.mjs +39 -5
- package/src/runtime/shared/tool-card-model.mjs +5 -2
- package/src/runtime/shared/tool-output-limit.mjs +4 -0
- package/src/runtime/shared/tool-primitives.mjs +13 -0
- package/src/runtime/shared/tool-result-summary.mjs +8 -1
- package/src/runtime/shared/tool-surface.mjs +18 -3
- package/src/runtime/shared/tool-work-units.mjs +40 -4
- package/src/runtime/shared/transcript-writer/purge.mjs +74 -0
- package/src/runtime/shared/transcript-writer.mjs +10 -2
- package/src/runtime/shared/turn-changes.mjs +670 -274
- package/src/runtime/shared/turn-review-reply.mjs +8 -39
- package/src/runtime/shared/turn-snapshot-store.mjs +94 -2
- package/src/runtime/shared/turn-snapshot.mjs +986 -690
- package/src/runtime/shared/turn-worktree-snapshot.mjs +279 -262
- package/src/runtime/shared/user-data-guard.mjs +262 -49
- package/src/runtime/shared/worktrees/carry.mjs +531 -0
- package/src/runtime/shared/worktrees/contract.mjs +34 -24
- package/src/runtime/shared/worktrees/file-recovery.mjs +68 -0
- package/src/runtime/shared/worktrees/git-head.mjs +205 -0
- package/src/runtime/shared/worktrees/git.mjs +8 -3
- package/src/runtime/shared/worktrees/maintenance.mjs +1 -1
- package/src/runtime/shared/worktrees/manager.mjs +156 -8
- package/src/runtime/shared/worktrees/merge.mjs +31 -21
- package/src/runtime/shared/worktrees/paths.d.mts +6 -0
- package/src/runtime/shared/worktrees/test-support.mjs +19 -4
- package/src/runtime/terminal-bridge/client.mjs +44 -11
- package/src/runtime/terminal-bridge/tool-defs.mjs +13 -6
- package/src/runtime/tidy/apply.mjs +47 -2
- package/src/runtime/tidy/run-engines.mjs +11 -5
- package/src/session-runtime/agent-tool-routing.mjs +19 -13
- package/src/session-runtime/boot/apis.mjs +19 -5
- package/src/session-runtime/boot/facade.mjs +1 -0
- package/src/session-runtime/boot/infrastructure.mjs +12 -1
- package/src/session-runtime/boot/session.mjs +0 -1
- package/src/session-runtime/boot/tools.mjs +4 -1
- package/src/session-runtime/cwd-plugins/cwd-apply.mjs +12 -5
- package/src/session-runtime/internal-tool-executor/ask-user-tool.mjs +65 -0
- package/src/session-runtime/internal-tool-executor/bridge-tools.mjs +3 -1
- package/src/session-runtime/internal-tool-executor/knowledge-tools.mjs +11 -4
- package/src/session-runtime/internal-tool-executor/workspace-tools.mjs +115 -18
- package/src/session-runtime/internal-tool-executor.mjs +6 -2
- package/src/session-runtime/lifecycle/cold-heir.mjs +188 -0
- package/src/session-runtime/lifecycle/inheritance.mjs +71 -27
- package/src/session-runtime/lifecycle/session-catalog.mjs +14 -3
- package/src/session-runtime/lifecycle/session-switch/resume.mjs +6 -0
- package/src/session-runtime/lifecycle/session-switch/session-delete.mjs +29 -6
- package/src/session-runtime/lifecycle/teardown.mjs +73 -8
- package/src/session-runtime/lifecycle-api.mjs +1 -0
- package/src/session-runtime/mcp-glue/connect.mjs +46 -14
- package/src/session-runtime/mcp-glue/server-catalog.mjs +20 -3
- package/src/session-runtime/mcp-glue.mjs +3 -0
- package/src/session-runtime/mcp-oauth-login.mjs +174 -0
- package/src/session-runtime/model-route/model-switch-target.mjs +34 -0
- package/src/session-runtime/model-route/set-route.mjs +85 -17
- package/src/session-runtime/model-route/web-search-route.mjs +1 -1
- package/src/session-runtime/native-web-search/routes.mjs +22 -3
- package/src/session-runtime/native-web-search-usage.mjs +51 -0
- package/src/session-runtime/native-web-search.mjs +10 -1
- package/src/session-runtime/new-session-config.mjs +4 -2
- package/src/session-runtime/provider-auth/login.mjs +4 -0
- package/src/session-runtime/provider-models/model-meta.mjs +12 -3
- package/src/session-runtime/quick-model-rows/web-search-rows.mjs +13 -8
- package/src/session-runtime/quick-web-search-models.mjs +13 -38
- package/src/session-runtime/resource-extension-sync.mjs +4 -0
- package/src/session-runtime/resource-mcp-api.mjs +98 -2
- package/src/session-runtime/resource-scope-decorate.mjs +10 -3
- package/src/session-runtime/runtime-review-api.mjs +21 -2
- package/src/session-runtime/services/agent-tool/index-write-queue.mjs +81 -20
- package/src/session-runtime/services/agent-tool/job-task-reconcile.mjs +13 -1
- package/src/session-runtime/services/agent-tool/job-views/agent-list.mjs +7 -2
- package/src/session-runtime/services/agent-tool/lead-worker-index/forgotten-leads.mjs +29 -0
- package/src/session-runtime/services/agent-tool/lead-worker-index/index-file.mjs +1 -1
- package/src/session-runtime/services/agent-tool/lead-worker-index.mjs +73 -7
- package/src/session-runtime/services/agent-tool/send-flow.mjs +22 -4
- package/src/session-runtime/services/agent-tool/spawn-flow/job-start.mjs +2 -0
- package/src/session-runtime/services/agent-tool/spawn-flow/spawn-run.mjs +22 -5
- package/src/session-runtime/services/agent-tool/tag-registry.mjs +15 -1
- package/src/session-runtime/services/agent-tool/worker-index/row-liveness.mjs +8 -1
- package/src/session-runtime/services/agent-tool/worker-index/row-shape.mjs +2 -2
- package/src/session-runtime/services/agent-tool/worker-index/row-store.mjs +10 -1
- package/src/session-runtime/services/agent-tool/worker-index.mjs +46 -7
- package/src/session-runtime/services/agent-tool/worker-rows.mjs +17 -0
- package/src/session-runtime/services/agent-tool.mjs +3 -0
- package/src/session-runtime/services/channel-admin.mjs +19 -3
- package/src/session-runtime/services/memory-runtime-proxy/calls.mjs +4 -0
- package/src/session-runtime/services/memory-runtime-proxy.mjs +1 -0
- package/src/session-runtime/services/provider-admin.mjs +81 -15
- package/src/session-runtime/session-transcript-purge.mjs +32 -0
- package/src/session-runtime/session-turn-api.mjs +14 -5
- package/src/session-runtime/settings-builtin-tools-api.mjs +9 -0
- package/src/session-runtime/settings-compaction-api.mjs +1 -0
- package/src/session-runtime/setup-tool/executor.mjs +36 -6
- package/src/session-runtime/setup-tool/extended-actions.mjs +86 -6
- package/src/session-runtime/setup-tool/settings-contract.mjs +13 -3
- package/src/session-runtime/setup-tool/tool-defs.mjs +13 -4
- package/src/session-runtime/skills-api.mjs +3 -5
- package/src/session-runtime/tool-surface.mjs +5 -1
- package/src/session-runtime/turn/compaction-gate.mjs +25 -0
- package/src/session-runtime/turn/session-ops.mjs +7 -5
- package/src/session-runtime/turn/side-question-ops.mjs +22 -0
- package/src/session-runtime/turn/turn-run/ask-callbacks.mjs +5 -1
- package/src/session-runtime/turn/turn-run/turn-prepare.mjs +8 -3
- package/src/session-runtime/turn/turn-run/turn-settle.mjs +10 -0
- package/src/session-runtime/turn/turn-run/turn-timing.mjs +4 -3
- package/src/session-runtime/turn/turn-run.mjs +25 -9
- package/src/session-runtime/usage-stats-api.mjs +43 -11
- package/src/session-runtime/workspace/project-branches.mjs +7 -5
- package/src/session-runtime/workspace/transition-steps.mjs +129 -31
- package/src/session-runtime/workspace/transitions.mjs +190 -15
- package/src/standalone/daemon-agent-control.mjs +9 -1
- package/src/standalone/daemon-drain-policy.mjs +8 -0
- package/src/standalone/daemon-local-session-bridge.mjs +14 -1
- package/src/standalone/daemon-owner.mjs +17 -4
- package/src/standalone/daemon-pg-stop.mjs +51 -0
- package/src/standalone/daemon-stop-budget.d.mts +2 -0
- package/src/standalone/daemon-stop-budget.mjs +18 -0
- package/src/standalone/daemon-stored-session-views.mjs +2 -0
- package/src/standalone/daemon-telemetry.mjs +74 -0
- package/src/standalone/daemon.mjs +64 -8
- package/src/standalone/desktop-service-registry.mjs +5 -0
- package/src/standalone/local-session-runtime.mjs +70 -19
- package/src/standalone/session-client.mjs +42 -0
- package/src/standalone/session-protocol.mjs +14 -0
- package/src/standalone/session-proxy/projection.mjs +41 -4
- package/src/standalone/session-runtime-inline-host/boot-loaders.mjs +15 -1
- package/src/standalone/session-runtime-inline-host.mjs +1 -0
- package/src/standalone/session-service/agent-tree/agent-cancel.mjs +33 -2
- package/src/standalone/session-service/agent-tree/agent-turns.mjs +26 -10
- package/src/standalone/session-service/agent-tree.mjs +12 -0
- package/src/standalone/session-service/quota-resume.mjs +8 -0
- package/src/standalone/session-service/retention/busy.mjs +15 -1
- package/src/standalone/session-service/session-calls/session-action.mjs +18 -3
- package/src/standalone/session-service-api.mjs +11 -1
- package/src/standalone/session-service.mjs +1 -0
- package/src/standalone/session-state-patch.d.mts +2 -1
- package/src/standalone/session-state-patch.mjs +41 -1
- package/src/tui/App.jsx +25 -0
- package/src/tui/app/app-view/floating-panel.jsx +26 -1
- package/src/tui/app/app-view.jsx +1 -0
- package/src/tui/app/btw-controller.mjs +118 -0
- package/src/tui/app/create-app-pickers.mjs +6 -0
- package/src/tui/app/extension-pickers/mcp-login.mjs +107 -0
- package/src/tui/app/extension-pickers/mcp-servers-picker.mjs +2 -1
- package/src/tui/app/extension-pickers.mjs +2 -1
- package/src/tui/app/model-options.mjs +11 -16
- package/src/tui/app/shell-layout/row-budgets.mjs +11 -1
- package/src/tui/app/shell-layout/surfaces.mjs +3 -1
- package/src/tui/app/slash-commands.mjs +20 -5
- package/src/tui/app/slash-dispatch/panel-commands.mjs +16 -1
- package/src/tui/app/slash-dispatch/session-commands.mjs +19 -6
- package/src/tui/app/use-global-key-input/approval-shortcut.mjs +12 -0
- package/src/tui/app/use-global-key-input.mjs +5 -12
- package/src/tui/app/use-prompt-handlers.mjs +11 -0
- package/src/tui/components/BtwPanel.jsx +53 -0
- package/src/tui/components/QuestionPanel.jsx +275 -0
- package/src/tui/components/StatusLine.jsx +2 -0
- package/src/tui/components/question-panel-model.mjs +156 -0
- package/src/tui/components/status-line/local-line.mjs +5 -2
- package/src/tui/components/status-line/use-statusline-text.mjs +7 -1
- package/src/tui/dist/index.mjs +3221 -1500
- package/src/tui/session/context-state.mjs +4 -2
- package/src/tui/session/quota-resume.mjs +46 -14
- package/src/tui/session/session-api/commands.mjs +43 -24
- package/src/tui/session/session-api/controls.mjs +2 -2
- package/src/tui/session/session-api/extensions.mjs +22 -0
- package/src/tui/session/session-api/integrations.mjs +16 -2
- package/src/tui/session/session-api/lifecycle/dispose.mjs +1 -0
- package/src/tui/session/session-api/lifecycle.mjs +7 -2
- package/src/tui/session/session-api/routes.mjs +71 -7
- package/src/tui/session/session-api/settings.mjs +11 -14
- package/src/tui/session/session-api.mjs +1 -0
- package/src/tui/session/session-flow/drain.mjs +7 -1
- package/src/tui/session/session-flow/failed-turn-rewind.mjs +10 -4
- package/src/tui/session/tool-approval.mjs +10 -7
- package/src/tui/session/transcript-spill.mjs +14 -7
- package/src/tui/session/turn-stream.mjs +174 -31
- package/src/tui/session/turn.mjs +46 -16
- package/src/tui/statusline-ansi-bridge.mjs +1 -1
- package/src/ui/statusline-agents.mjs +15 -4
- package/src/ui/statusline.mjs +7 -3
- package/src/ui/streaming-markdown-heal.mjs +34 -3
- package/src/vendor/statusline/src/gateway/claude-current.mjs +14 -4
- package/src/workflows/default/WORKFLOW.md +3 -1
- package/src/runtime/memory/lib/memory-session-merge.mjs +0 -32
- package/src/runtime/shared/llm/usage-session-history.mjs +0 -266
|
@@ -1,54 +1,80 @@
|
|
|
1
|
-
import {
|
|
2
|
-
import { lstat, mkdir, readFile, rm, writeFile } from 'node:fs/promises';
|
|
3
|
-
import { dirname, isAbsolute, relative, resolve } from 'node:path';
|
|
1
|
+
import { isAbsolute, relative, resolve } from 'node:path';
|
|
4
2
|
import {
|
|
5
3
|
_resetTurnWorktreeSnapshotsForTest,
|
|
4
|
+
canonicalPath,
|
|
6
5
|
createTurnWorktreeSnapshot,
|
|
7
|
-
freezeTurnWorktreeChanges,
|
|
8
6
|
noteTurnWorktreeChange,
|
|
9
7
|
refreshTurnWorktreeSnapshot,
|
|
10
8
|
recordTurnWorktreeFileChange,
|
|
9
|
+
resumeTurnWorktreeFilePatch,
|
|
11
10
|
resumeTurnWorktreeSnapshot,
|
|
12
|
-
revertTurnWorktreeFile,
|
|
13
|
-
revertTurnWorktreePaths,
|
|
14
|
-
revertTurnWorktreeSnapshot,
|
|
15
11
|
} from './turn-worktree-snapshot.mjs';
|
|
16
12
|
import {
|
|
13
|
+
clearSessionChangeHistory as clearStoredChangeHistory,
|
|
17
14
|
loadSessionSnapshotRecords,
|
|
18
15
|
loadTurnChangeRecord,
|
|
19
16
|
loadTurnChangeRecords,
|
|
20
17
|
loadTurnSnapshotRecord,
|
|
21
18
|
saveTurnSnapshotRecord,
|
|
19
|
+
sessionChangeHistoryCleared,
|
|
22
20
|
updateTurnChangeRecord,
|
|
23
21
|
upsertTurnChangeRecord,
|
|
24
22
|
} from './turn-snapshot-store.mjs';
|
|
25
23
|
import {
|
|
26
24
|
applyTurnChangeFiles,
|
|
25
|
+
changeDisplayPatch,
|
|
27
26
|
changePathKey,
|
|
28
27
|
findChangeFile,
|
|
28
|
+
isBinaryChangeContent,
|
|
29
29
|
measureLegacyChangeFile,
|
|
30
|
-
mergeRecordedSessionChanges,
|
|
31
30
|
namedChangePatch,
|
|
31
|
+
repositoryObjectFormat,
|
|
32
|
+
sessionChangeReview,
|
|
33
|
+
splitPatchSections,
|
|
32
34
|
storedChangeFile,
|
|
33
35
|
trackedChangeFile,
|
|
34
36
|
turnChangesReply,
|
|
35
37
|
unknownStateChangeFile,
|
|
36
38
|
} from './turn-changes.mjs';
|
|
37
|
-
import {
|
|
39
|
+
import {
|
|
40
|
+
_resetSessionBaselinesForTest,
|
|
41
|
+
captureSessionBaselines,
|
|
42
|
+
clearSessionBaselines,
|
|
43
|
+
loadSessionBaselines,
|
|
44
|
+
primeReviewOwner,
|
|
45
|
+
recordReviewOwner,
|
|
46
|
+
} from './session-baseline-store.mjs';
|
|
47
|
+
import {
|
|
48
|
+
boundReviewPatch,
|
|
49
|
+
createReviewPatchBudget,
|
|
50
|
+
filePatchReply,
|
|
51
|
+
patchOmittedFile,
|
|
52
|
+
REVIEW_FILE_PATCH_SHOW_MAX_BYTES,
|
|
53
|
+
reviewDiffTooLarge,
|
|
54
|
+
reviewSectionTruncated,
|
|
55
|
+
} from './review-diff.mjs';
|
|
38
56
|
import { PATCH_EDIT_MAX_BYTES } from './bounded-file-read.mjs';
|
|
39
57
|
import { cleanString as clean } from './clean.mjs';
|
|
58
|
+
import { createLineDiffBudget, lineDiffHunks, unifiedHunksBody } from './bounded-line-diff.mjs';
|
|
40
59
|
|
|
41
60
|
// Turn-scoped review registry.
|
|
42
61
|
//
|
|
43
|
-
//
|
|
44
|
-
//
|
|
45
|
-
// the
|
|
46
|
-
//
|
|
47
|
-
//
|
|
62
|
+
// Every file tool write folds its exact before/after bytes into the session's
|
|
63
|
+
// tracker as ordered own segments per file: a write that starts from the
|
|
64
|
+
// bytes the file's last segment ended with extends that segment, any other
|
|
65
|
+
// opens a new one, so whatever changed the file in between (a foreign edit)
|
|
66
|
+
// is in no segment. The turn review, its change record and every Undo/
|
|
67
|
+
// Reapply/latest-turn revert work from those segments only, reverse-applied
|
|
68
|
+
// newest first against the current disk with conflict detection: a foreign
|
|
69
|
+
// edit outside the turn's hunks survives an Undo, and one overlapping them
|
|
70
|
+
// fails it instead of being overwritten. Each file counts the summed line
|
|
71
|
+
// deltas of its own writes.
|
|
48
72
|
//
|
|
49
|
-
//
|
|
50
|
-
//
|
|
51
|
-
//
|
|
73
|
+
// A Git worktree also gets a shadow-index snapshot at turn start: the durable
|
|
74
|
+
// baseline of the session-wide review.
|
|
75
|
+
//
|
|
76
|
+
// Worker trackers remain separate until the worker completes: then its
|
|
77
|
+
// segments, owned paths and counts join the owning Lead turn's.
|
|
52
78
|
|
|
53
79
|
const DISABLED = /^(0|false|off)$/i.test(String(process.env.MIXDOG_TURN_SNAPSHOT || ''));
|
|
54
80
|
const TURN_CACHE_MAX = 32;
|
|
@@ -61,6 +87,9 @@ const MAX_AGENT_REVIEW_BYTES_PER_TURN = 4 * 1024 * 1024;
|
|
|
61
87
|
const _turnsBySession = new Map();
|
|
62
88
|
const _diffTrackersBySession = new Map();
|
|
63
89
|
let _agentTurnSeq = 0;
|
|
90
|
+
// Orders every tracked write across trackers, so a worker's segments merge into
|
|
91
|
+
// its Lead's in the order the writes happened.
|
|
92
|
+
let _writeSeq = 0;
|
|
64
93
|
// `${sessionId}\0${checkpointId}` → seals in flight. A sealing turn's change
|
|
65
94
|
// record is `pending` and its session refuses Undo/Reapply until it lands.
|
|
66
95
|
const _pendingFreezes = new Map();
|
|
@@ -130,28 +159,58 @@ function ownedPathsUnder(root, owned) {
|
|
|
130
159
|
return out;
|
|
131
160
|
}
|
|
132
161
|
|
|
133
|
-
/**
|
|
134
|
-
*
|
|
135
|
-
*
|
|
136
|
-
function
|
|
137
|
-
|
|
138
|
-
const
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
162
|
+
/** Fold one write's line counts into `stats` (owned entry key → summed
|
|
163
|
+
* counts). A rename moves the source's counts to its destination; a change
|
|
164
|
+
* that reports no counts (an Undo/Reapply fold) adds nothing. */
|
|
165
|
+
function foldOwnStats(stats, raw, source, destination) {
|
|
166
|
+
const measured = Number.isFinite(raw?.additions) || Number.isFinite(raw?.deletions);
|
|
167
|
+
const prior = stats.get(source.key);
|
|
168
|
+
if (destination) stats.delete(source.key);
|
|
169
|
+
const target = destination || source;
|
|
170
|
+
const existing = destination ? stats.get(destination.key) : null;
|
|
171
|
+
if (!prior && !existing && !measured) return;
|
|
172
|
+
stats.set(target.key, {
|
|
173
|
+
path: target.path,
|
|
174
|
+
display: target.display,
|
|
175
|
+
additions: (prior?.additions || 0) + (existing?.additions || 0) + (Number(raw?.additions) || 0),
|
|
176
|
+
deletions: (prior?.deletions || 0) + (existing?.deletions || 0) + (Number(raw?.deletions) || 0),
|
|
177
|
+
});
|
|
144
178
|
}
|
|
145
179
|
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
180
|
+
function mergeOwnStats(target, source) {
|
|
181
|
+
for (const [key, entry] of source) {
|
|
182
|
+
const prior = target.get(key);
|
|
183
|
+
target.set(
|
|
184
|
+
key,
|
|
185
|
+
prior
|
|
186
|
+
? { ...prior, additions: prior.additions + entry.additions, deletions: prior.deletions + entry.deletions }
|
|
187
|
+
: { ...entry }
|
|
188
|
+
);
|
|
153
189
|
}
|
|
154
|
-
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
/** Lookup of the tracker's own-write counts by a reviewed file path: relative
|
|
193
|
+
* to `root`, or (`display`) the tools' display path a tracked patch shows. */
|
|
194
|
+
function ownCountIndex(tracker, root, { display = false } = {}) {
|
|
195
|
+
const keyOf = (path) => pathKey(display ? path : resolve(root, path));
|
|
196
|
+
const index = new Map();
|
|
197
|
+
for (const entry of tracker?.ownStats?.values() || []) index.set(keyOf(display ? entry.display : entry.path), entry);
|
|
198
|
+
return (path) => (path ? index.get(keyOf(path)) : undefined);
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
/** Files counted by the summed per-call deltas of their own writes (a
|
|
202
|
+
* rename's old path included). A file no own write reported counts for
|
|
203
|
+
* keeps the counts it has. */
|
|
204
|
+
function withOwnCounts(files, find) {
|
|
205
|
+
return files.map((file) => {
|
|
206
|
+
const hits = [find(file.path), file.oldPath ? find(file.oldPath) : undefined].filter(Boolean);
|
|
207
|
+
if (hits.length === 0) return file;
|
|
208
|
+
return {
|
|
209
|
+
...file,
|
|
210
|
+
additions: hits.reduce((sum, hit) => sum + hit.additions, 0),
|
|
211
|
+
deletions: hits.reduce((sum, hit) => sum + hit.deletions, 0),
|
|
212
|
+
};
|
|
213
|
+
});
|
|
155
214
|
}
|
|
156
215
|
|
|
157
216
|
// A change whose bytes were never captured (binary/oversized) carries
|
|
@@ -206,23 +265,30 @@ function createDiffTracker(meta = {}) {
|
|
|
206
265
|
// The seal (refresh, record, freeze) in flight, if any.
|
|
207
266
|
freezing: null,
|
|
208
267
|
revision: 0,
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
268
|
+
// Current path key → { segments }: the file's own segments in write
|
|
269
|
+
// order, each `{ before, after, firstSeq, lastSeq }` whose sides are
|
|
270
|
+
// `{ path, displayPath, content }` (content null = absent).
|
|
271
|
+
chains: new Map(),
|
|
272
|
+
// Current path key → { chain, owned, stats } of a chain an Undo restored,
|
|
273
|
+
// kept so a Reapply puts it back.
|
|
274
|
+
undoneChains: new Map(),
|
|
275
|
+
// The review rendered for one revision (trackerView).
|
|
276
|
+
view: null,
|
|
214
277
|
worktreeSnapshot: null,
|
|
215
278
|
// Session-owned relative paths. These outlive the before/after buffers on
|
|
216
279
|
// purpose: they are what scopes a revert once the exact content is gone.
|
|
217
280
|
ownedPaths: new Set(),
|
|
281
|
+
// Owned entry key → { path, display, additions, deletions }: the summed
|
|
282
|
+
// line deltas of every own write of the file. Outlives the buffers too.
|
|
283
|
+
ownStats: new Map(),
|
|
218
284
|
};
|
|
219
285
|
}
|
|
220
286
|
|
|
221
287
|
function releaseDiffTrackerContent(tracker) {
|
|
222
288
|
if (!tracker) return;
|
|
223
|
-
tracker.
|
|
224
|
-
tracker.
|
|
225
|
-
tracker.
|
|
289
|
+
tracker.chains.clear();
|
|
290
|
+
tracker.undoneChains.clear();
|
|
291
|
+
tracker.view = null;
|
|
226
292
|
}
|
|
227
293
|
|
|
228
294
|
function resetDiffTracker(sessionId, meta = {}) {
|
|
@@ -235,254 +301,307 @@ function resetDiffTracker(sessionId, meta = {}) {
|
|
|
235
301
|
|
|
236
302
|
function trackedContentBytes(tracker) {
|
|
237
303
|
let total = 0;
|
|
238
|
-
for (const
|
|
239
|
-
|
|
304
|
+
for (const chain of tracker.chains.values()) {
|
|
305
|
+
for (const { before, after } of chain.segments) {
|
|
306
|
+
total += contentByteLength(before.content) + contentByteLength(after.content);
|
|
307
|
+
}
|
|
308
|
+
}
|
|
240
309
|
return total;
|
|
241
310
|
}
|
|
242
311
|
|
|
243
|
-
|
|
244
|
-
function
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
312
|
+
/** A segment that leaves its file where and as it found it. */
|
|
313
|
+
function noopSegment({ before, after }) {
|
|
314
|
+
return pathKey(before.path) === pathKey(after.path) && sameContent(before.content, after.content);
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
/** Join one own segment to the chain of the file it starts at. It extends the
|
|
318
|
+
* chain's last segment when it starts from that segment's exact after bytes;
|
|
319
|
+
* otherwise something else changed the file in between, and it opens a new
|
|
320
|
+
* segment so that change stays out of both. A move onto a path another chain
|
|
321
|
+
* holds lands as a delete of its source and a write of its destination. */
|
|
322
|
+
function appendSegment(tracker, segment) {
|
|
323
|
+
const startKey = pathKey(segment.before.path);
|
|
324
|
+
const endKey = pathKey(segment.after.path);
|
|
325
|
+
if (startKey !== endKey && tracker.chains.has(endKey)) {
|
|
326
|
+
appendSegment(tracker, { ...segment, after: { ...segment.before, content: null } });
|
|
327
|
+
appendSegment(tracker, { ...segment, before: { ...segment.after, content: null } });
|
|
328
|
+
return;
|
|
250
329
|
}
|
|
330
|
+
const chain = tracker.chains.get(startKey) || { segments: [] };
|
|
331
|
+
tracker.chains.delete(startKey);
|
|
332
|
+
const last = chain.segments.at(-1);
|
|
333
|
+
if (last && sameContent(last.after.content, segment.before.content)) {
|
|
334
|
+
last.after = segment.after;
|
|
335
|
+
last.lastSeq = segment.lastSeq;
|
|
336
|
+
} else {
|
|
337
|
+
chain.segments.push({ ...segment });
|
|
338
|
+
}
|
|
339
|
+
tracker.chains.set(endKey, chain);
|
|
340
|
+
}
|
|
341
|
+
|
|
342
|
+
const isBinarySide = (content) => isOmittedContent(content) || isBinaryChangeContent(content);
|
|
343
|
+
|
|
344
|
+
/** What a chain records, relative to `root`: its segments that change
|
|
345
|
+
* something — of a binary file only the last, since bytes from before a
|
|
346
|
+
* foreign rewrite cannot be restored around it — as steps with the
|
|
347
|
+
* root-relative paths of their sides ('' outside the root). `start`/`end`
|
|
348
|
+
* are the sides the change begins and ends at (a created file has no old
|
|
349
|
+
* side, a deleted one no new side). Null when the chain changes nothing. */
|
|
350
|
+
function chainEdit(chain, root) {
|
|
351
|
+
let segments = chain.segments.filter((segment) => !noopSegment(segment));
|
|
352
|
+
if (segments.length === 0) return null;
|
|
353
|
+
const binary = segments.some(({ before, after }) => [before.content, after.content].some(isBinarySide));
|
|
354
|
+
if (binary) segments = segments.slice(-1);
|
|
355
|
+
const steps = segments.map(({ before, after }) => ({
|
|
356
|
+
before,
|
|
357
|
+
after,
|
|
358
|
+
oldPath: trackedRelativePath(root, before),
|
|
359
|
+
newPath: trackedRelativePath(root, after),
|
|
360
|
+
}));
|
|
361
|
+
const first = steps[0];
|
|
362
|
+
const last = steps.at(-1);
|
|
363
|
+
const start = first.before.content === null ? first.after : first.before;
|
|
364
|
+
const end = last.after.content === null ? last.before : last.after;
|
|
365
|
+
let status = 'M';
|
|
366
|
+
if (first.before.content === null && last.after.content !== null) status = 'A';
|
|
367
|
+
else if (last.after.content === null && first.before.content !== null) status = 'D';
|
|
368
|
+
else if (pathKey(start.path) !== pathKey(end.path)) status = 'R';
|
|
369
|
+
return {
|
|
370
|
+
steps,
|
|
371
|
+
start,
|
|
372
|
+
end,
|
|
373
|
+
status,
|
|
374
|
+
binary,
|
|
375
|
+
inside: steps.every((step) => step.oldPath && step.newPath),
|
|
376
|
+
omitted: steps.some(({ before, after }) => [before.content, after.content].some(isOmittedContent)),
|
|
377
|
+
};
|
|
251
378
|
}
|
|
252
379
|
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
380
|
+
/** A side's name in the review: relative to `root` inside it, else the
|
|
381
|
+
* tool's display path. */
|
|
382
|
+
const viewPath = (root, entry) => trackedRelativePath(root, entry) || displayPath(entry.displayPath || entry.path);
|
|
383
|
+
|
|
384
|
+
/** One step's display section, named as the review shows it, with its line
|
|
385
|
+
* counts: hunks, a `Binary files … differ` line, or the not-captured note. */
|
|
386
|
+
function renderStep({ before, after }, root, budget) {
|
|
387
|
+
const oldGit = `a/${viewPath(root, before)}`;
|
|
388
|
+
const newGit = `b/${viewPath(root, after)}`;
|
|
389
|
+
const oldHeader = before.content === null ? '/dev/null' : oldGit;
|
|
390
|
+
const newHeader = after.content === null ? '/dev/null' : newGit;
|
|
260
391
|
let head = `diff --git ${oldGit} ${newGit}\n`;
|
|
261
|
-
if (before
|
|
262
|
-
else if (
|
|
263
|
-
const
|
|
392
|
+
if (before.content === null) head += 'new file mode 100644\n';
|
|
393
|
+
else if (after.content === null) head += 'deleted file mode 100644\n';
|
|
394
|
+
const binaryLine = `Binary files ${oldHeader} and ${newHeader} differ\n`;
|
|
395
|
+
const omitted = [before.content, after.content].find(isOmittedContent);
|
|
264
396
|
if (omitted) {
|
|
265
397
|
let change = 'changed';
|
|
266
|
-
if (after
|
|
267
|
-
else if (before
|
|
268
|
-
|
|
398
|
+
if (after.content === null) change = 'deleted';
|
|
399
|
+
else if (before.content === null) change = 'added';
|
|
400
|
+
const note = `binary/large file ${change} (${omitted.size} bytes; content not captured)\n`;
|
|
401
|
+
return { text: `${head}${note}${binaryLine}`, additions: 0, deletions: 0 };
|
|
269
402
|
}
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
if (oldText === null || newText === null) {
|
|
273
|
-
return `${head}Binary files ${oldHeader} and ${newHeader} differ\n`;
|
|
403
|
+
if ([before.content, after.content].some(isBinaryChangeContent)) {
|
|
404
|
+
return { text: `${head}${binaryLine}`, additions: 0, deletions: 0 };
|
|
274
405
|
}
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
406
|
+
const text = (content) => (content === null ? '' : content.toString('utf8'));
|
|
407
|
+
const { hunks } = lineDiffHunks(text(before.content), text(after.content), budget, [before.content, after.content]);
|
|
408
|
+
let additions = 0;
|
|
409
|
+
let deletions = 0;
|
|
410
|
+
for (const line of hunks.flatMap((hunk) => hunk.lines)) {
|
|
411
|
+
if (line.startsWith('+')) additions += 1;
|
|
412
|
+
else if (line.startsWith('-')) deletions += 1;
|
|
282
413
|
}
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
414
|
+
return { text: `${head}${unifiedHunksBody(oldHeader, newHeader, hunks)}`, additions, deletions };
|
|
415
|
+
}
|
|
416
|
+
|
|
417
|
+
/** The tracker's own changes as the review shows them: a row per file with
|
|
418
|
+
* its own counts, and the display patch of its segments, named relative to
|
|
419
|
+
* the tracker's patch root (a worker without one: the tools' display
|
|
420
|
+
* paths). Beside a Git root a file outside it is left out, as from the
|
|
421
|
+
* record's undoable files. Cached per revision; `budget` bounds a render. */
|
|
422
|
+
function trackerView(tracker, budget = createLineDiffBudget()) {
|
|
423
|
+
const { root, kind } = trackerPatchRoot(tracker);
|
|
424
|
+
if (tracker.view?.revision === tracker.revision && tracker.view.root === root) return tracker.view;
|
|
425
|
+
const rows = [];
|
|
291
426
|
if (!tracker.valid) {
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
const renamePairs = new Map();
|
|
296
|
-
const pairedDestinations = new Set();
|
|
297
|
-
for (const [destinationKey, originKey] of tracker.originByCurrentPath) {
|
|
298
|
-
if (
|
|
299
|
-
destinationKey === originKey ||
|
|
300
|
-
tracker.currentByPath.has(originKey) ||
|
|
301
|
-
!tracker.currentByPath.has(destinationKey) ||
|
|
302
|
-
!tracker.baselineByPath.has(originKey) ||
|
|
303
|
-
tracker.baselineByPath.has(destinationKey)
|
|
304
|
-
) {
|
|
305
|
-
continue;
|
|
427
|
+
// The content is gone: which files changed is known, their diff is not.
|
|
428
|
+
for (const path of ownedPathsUnder(root, tracker.ownedPaths)) {
|
|
429
|
+
rows.push({ path, status: 'M', additions: 0, deletions: 0, text: '' });
|
|
306
430
|
}
|
|
307
|
-
renamePairs.set(originKey, destinationKey);
|
|
308
|
-
pairedDestinations.add(destinationKey);
|
|
309
431
|
}
|
|
310
|
-
const
|
|
311
|
-
|
|
312
|
-
(
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
}
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
}
|
|
328
|
-
let unifiedDiff = '';
|
|
329
|
-
for (const [before, after] of pairs) {
|
|
330
|
-
const beforeContent = before?.content ?? null;
|
|
331
|
-
const afterContent = after?.content ?? null;
|
|
332
|
-
const samePath = before?.displayPath === after?.displayPath;
|
|
333
|
-
if (samePath && sameContent(beforeContent, afterContent)) continue;
|
|
334
|
-
const rendered = renderTrackedPair(
|
|
335
|
-
before || { displayPath: after?.displayPath, content: null },
|
|
336
|
-
after || { displayPath: before?.displayPath, content: null }
|
|
337
|
-
);
|
|
338
|
-
unifiedDiff += rendered;
|
|
339
|
-
}
|
|
340
|
-
const bounded = boundReviewPatch(unifiedDiff, MAX_PATCH_BYTES);
|
|
341
|
-
tracker.unifiedDiff = bounded.patch;
|
|
342
|
-
tracker.patchTruncated = bounded.truncated;
|
|
343
|
-
}
|
|
344
|
-
|
|
345
|
-
function trackedPairs(tracker) {
|
|
346
|
-
const pairs = [];
|
|
347
|
-
const handledOrigins = new Set();
|
|
348
|
-
for (const [destinationKey, after] of tracker.currentByPath) {
|
|
349
|
-
const originKey = tracker.originByCurrentPath.get(destinationKey) || destinationKey;
|
|
350
|
-
handledOrigins.add(originKey);
|
|
351
|
-
pairs.push({
|
|
352
|
-
originKey,
|
|
353
|
-
destinationKey,
|
|
354
|
-
before: tracker.baselineByPath.get(originKey) || null,
|
|
355
|
-
after,
|
|
432
|
+
for (const chain of tracker.chains.values()) {
|
|
433
|
+
const edit = chainEdit(chain, root);
|
|
434
|
+
if (!edit || (kind === 'git' && !edit.inside)) continue;
|
|
435
|
+
const rendered = edit.steps.map((step) => renderStep(step, root, budget));
|
|
436
|
+
const path = viewPath(root, edit.end);
|
|
437
|
+
const oldPath = viewPath(root, edit.start);
|
|
438
|
+
const omitted = edit.steps.flatMap(({ before, after }) => [before.content, after.content]).find(isOmittedContent);
|
|
439
|
+
rows.push({
|
|
440
|
+
path,
|
|
441
|
+
...(oldPath !== path ? { oldPath } : {}),
|
|
442
|
+
status: edit.status,
|
|
443
|
+
additions: rendered.reduce((sum, step) => sum + step.additions, 0),
|
|
444
|
+
deletions: rendered.reduce((sum, step) => sum + step.deletions, 0),
|
|
445
|
+
...(edit.binary ? { binary: true } : {}),
|
|
446
|
+
// Its bytes were never captured: listed, never diffed or undone.
|
|
447
|
+
...(omitted ? { omitted: true, size: omitted.size } : {}),
|
|
448
|
+
text: rendered.map((step) => step.text).join(''),
|
|
356
449
|
});
|
|
357
450
|
}
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
return target;
|
|
373
|
-
}
|
|
374
|
-
|
|
375
|
-
function pairMatchesFile(pair, worktree, file) {
|
|
376
|
-
const requested = safeTrackedTarget(worktree, file);
|
|
377
|
-
return [pair.before, pair.after].some((entry) => {
|
|
378
|
-
if (!entry) return false;
|
|
379
|
-
const values = [entry.path, entry.displayPath].filter(Boolean);
|
|
380
|
-
return values.some((value) => safeTrackedTarget(worktree, value) === requested);
|
|
381
|
-
});
|
|
382
|
-
}
|
|
383
|
-
|
|
384
|
-
async function currentTrackedContent(path) {
|
|
385
|
-
try {
|
|
386
|
-
const stat = await lstat(path);
|
|
387
|
-
if (!stat.isFile() || stat.isSymbolicLink()) {
|
|
388
|
-
throw new Error('turn review revert only supports regular files');
|
|
389
|
-
}
|
|
390
|
-
return await readFile(path);
|
|
391
|
-
} catch (error) {
|
|
392
|
-
if (error?.code === 'ENOENT') return null;
|
|
393
|
-
throw error;
|
|
394
|
-
}
|
|
451
|
+
rows.sort((left, right) => left.path.localeCompare(right.path));
|
|
452
|
+
const bounded = boundReviewPatch(rows.map((row) => row.text).join(''), MAX_PATCH_BYTES);
|
|
453
|
+
const counted = root ? ownCountIndex(tracker, root) : ownCountIndex(tracker, '', { display: true });
|
|
454
|
+
tracker.view = {
|
|
455
|
+
revision: tracker.revision,
|
|
456
|
+
root,
|
|
457
|
+
files: withOwnCounts(
|
|
458
|
+
rows.map(({ text, ...row }) => row),
|
|
459
|
+
counted
|
|
460
|
+
),
|
|
461
|
+
patch: bounded.patch,
|
|
462
|
+
truncated: bounded.truncated,
|
|
463
|
+
};
|
|
464
|
+
return tracker.view;
|
|
395
465
|
}
|
|
396
466
|
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
|
|
403
|
-
|
|
404
|
-
|
|
405
|
-
|
|
406
|
-
|
|
407
|
-
|
|
408
|
-
);
|
|
409
|
-
const afterPath = safeTrackedTarget(
|
|
410
|
-
worktree,
|
|
411
|
-
pair.after?.path || pair.after?.displayPath || pair.before?.path || pair.before?.displayPath
|
|
467
|
+
/** True when `value` (absolute, or relative to `root`) names a path the chain
|
|
468
|
+
* touched. */
|
|
469
|
+
function chainMatchesPath(chain, root, value) {
|
|
470
|
+
const target = clean(value);
|
|
471
|
+
if (!target) return false;
|
|
472
|
+
const key = changePathKey(relative(root, isAbsolute(target) ? resolve(target) : resolve(root, target)));
|
|
473
|
+
return chain.segments.some((segment) =>
|
|
474
|
+
[segment.before, segment.after].some((entry) => {
|
|
475
|
+
const path = trackedRelativePath(root, entry);
|
|
476
|
+
return Boolean(path) && changePathKey(path) === key;
|
|
477
|
+
})
|
|
412
478
|
);
|
|
413
|
-
const expectedAfter = pair.after?.content ?? null;
|
|
414
|
-
const actualAfter = await currentTrackedContent(afterPath);
|
|
415
|
-
if (!sameContent(actualAfter, expectedAfter)) {
|
|
416
|
-
throw new Error('turn review revert refused because the file changed after this session edit');
|
|
417
|
-
}
|
|
418
|
-
if (beforePath !== afterPath && (await currentTrackedContent(beforePath)) !== null) {
|
|
419
|
-
throw new Error('turn review revert refused because the original path changed after this session edit');
|
|
420
|
-
}
|
|
421
|
-
return { beforePath, afterPath };
|
|
422
479
|
}
|
|
423
480
|
|
|
424
|
-
|
|
425
|
-
|
|
426
|
-
|
|
427
|
-
|
|
428
|
-
|
|
429
|
-
|
|
430
|
-
|
|
481
|
+
/** The owned entries (and their counts) of every path a chain touched. */
|
|
482
|
+
function chainOwnership(tracker, chain) {
|
|
483
|
+
const owned = [
|
|
484
|
+
...new Set(
|
|
485
|
+
chain.segments
|
|
486
|
+
.flatMap((segment) => [segment.before, segment.after])
|
|
487
|
+
.map((entry) => ownedPathEntry(entry.path, displayPath(entry.displayPath || entry.path)))
|
|
488
|
+
),
|
|
489
|
+
].filter((entry) => tracker.ownedPaths.has(entry));
|
|
490
|
+
const stats = owned
|
|
491
|
+
.map((entry) => [pathKey(entry), tracker.ownStats.get(pathKey(entry))])
|
|
492
|
+
.filter(([, entry]) => entry);
|
|
493
|
+
return { owned, stats };
|
|
494
|
+
}
|
|
495
|
+
|
|
496
|
+
/** An Undo reverse-applied these chains: the turn no longer changes them, so
|
|
497
|
+
* they leave its review. Each is kept for a Reapply. */
|
|
498
|
+
function undoTrackedChains(tracker, chains) {
|
|
499
|
+
for (const [key, chain] of tracker.chains) {
|
|
500
|
+
if (!chains.includes(chain)) continue;
|
|
501
|
+
const ownership = chainOwnership(tracker, chain);
|
|
502
|
+
tracker.undoneChains.set(key, { chain, ...ownership });
|
|
503
|
+
for (const entry of ownership.owned) tracker.ownedPaths.delete(entry);
|
|
504
|
+
for (const [statKey] of ownership.stats) tracker.ownStats.delete(statKey);
|
|
505
|
+
tracker.chains.delete(key);
|
|
431
506
|
}
|
|
507
|
+
tracker.revision += 1;
|
|
432
508
|
}
|
|
433
509
|
|
|
434
|
-
|
|
435
|
-
|
|
436
|
-
tracker.
|
|
437
|
-
|
|
438
|
-
|
|
439
|
-
|
|
440
|
-
|
|
441
|
-
|
|
442
|
-
|
|
443
|
-
|
|
444
|
-
|
|
445
|
-
}
|
|
446
|
-
// Validate every current file before the first write so an external edit
|
|
447
|
-
// cannot leave a turn-wide revert half-applied.
|
|
448
|
-
const validated = [];
|
|
449
|
-
for (const pair of pairs) validated.push(await validateTrackedPair(pair, worktree));
|
|
450
|
-
for (let index = 0; index < pairs.length; index += 1) {
|
|
451
|
-
await restoreTrackedPair(pairs[index], validated[index]);
|
|
452
|
-
commitTrackedPairRestore(tracker, pairs[index]);
|
|
510
|
+
/** A Reapply forward-applied the undone chains matching `files` again. */
|
|
511
|
+
function redoTrackedChains(tracker, root, files) {
|
|
512
|
+
for (const [key, undone] of tracker.undoneChains) {
|
|
513
|
+
const matches = files.some((file) =>
|
|
514
|
+
[file.path, file.oldPath].some((path) => chainMatchesPath(undone.chain, root, path))
|
|
515
|
+
);
|
|
516
|
+
if (!matches) continue;
|
|
517
|
+
tracker.undoneChains.delete(key);
|
|
518
|
+
tracker.chains.set(key, undone.chain);
|
|
519
|
+
for (const entry of undone.owned) tracker.ownedPaths.add(entry);
|
|
520
|
+
for (const [statKey, entry] of undone.stats) tracker.ownStats.set(statKey, entry);
|
|
453
521
|
}
|
|
454
522
|
tracker.revision += 1;
|
|
455
|
-
refreshUnifiedDiff(tracker);
|
|
456
|
-
if (!tracker.unifiedDiff) releaseDiffTrackerContent(tracker);
|
|
457
|
-
// The absolute paths now back at their turn-start bytes.
|
|
458
|
-
return [...new Set(validated.flatMap(({ beforePath, afterPath }) => [beforePath, afterPath]))];
|
|
459
523
|
}
|
|
460
524
|
|
|
461
|
-
/** A
|
|
462
|
-
|
|
463
|
-
|
|
525
|
+
/** A new write supersedes whatever an Undo set aside for the paths it
|
|
526
|
+
* touches (`keys`). */
|
|
527
|
+
function dropUndoneChains(tracker, keys) {
|
|
528
|
+
for (const [key, undone] of tracker.undoneChains) {
|
|
529
|
+
const touched = undone.chain.segments.some(
|
|
530
|
+
(segment) => keys.has(pathKey(segment.before.path)) || keys.has(pathKey(segment.after.path))
|
|
531
|
+
);
|
|
532
|
+
if (touched) tracker.undoneChains.delete(key);
|
|
533
|
+
}
|
|
464
534
|
}
|
|
465
535
|
|
|
466
536
|
/**
|
|
467
537
|
* Fold exact committed before/after file states into a session's current turn.
|
|
468
538
|
* A change is `{ path, displayPath, before, after }`; a rename additionally
|
|
469
|
-
* carries `{ newPath, newDisplayPath }`.
|
|
539
|
+
* carries `{ newPath, newDisplayPath }`. `budget` is the line-diff budget of
|
|
540
|
+
* the tool call the changes come from. Returns the turn's review patch.
|
|
470
541
|
*/
|
|
471
|
-
export function recordTurnDiffChanges(sessionId, changes = []) {
|
|
542
|
+
export function recordTurnDiffChanges(sessionId, changes = [], budget = createLineDiffBudget()) {
|
|
472
543
|
if (DISABLED) return '';
|
|
473
544
|
const id = clean(sessionId);
|
|
474
545
|
if (!id) return '';
|
|
475
546
|
const tracker = _diffTrackersBySession.get(id) || resetDiffTracker(id);
|
|
476
|
-
|
|
477
|
-
const snapshot =
|
|
478
|
-
|
|
479
|
-
|
|
480
|
-
|
|
547
|
+
const ownerTracker = tracker?.ownerSessionId ? _diffTrackersBySession.get(tracker.ownerSessionId) : tracker;
|
|
548
|
+
const snapshot = ownerTracker?.worktreeSnapshot;
|
|
549
|
+
const raws = Array.isArray(changes) ? changes : [];
|
|
550
|
+
// Git reports the turn's root canonically (macOS /var -> /private/var):
|
|
551
|
+
// each written path is spelled the same way, or it never relates to it.
|
|
552
|
+
const list = snapshot?.root ? raws.map(canonicalChangePaths) : raws;
|
|
553
|
+
// Independent of this tracker's validity: the session review needs every
|
|
554
|
+
// write's first-seen state even once the turn's buffers are gone.
|
|
555
|
+
captureReviewBaselines(id, tracker, ownerTracker, list, snapshot);
|
|
556
|
+
if (!tracker) return '';
|
|
557
|
+
if (tracker.valid && !tracker.sealed) foldTrackedChanges(tracker, list, snapshot);
|
|
558
|
+
return trackerView(tracker, budget).patch;
|
|
559
|
+
}
|
|
560
|
+
|
|
561
|
+
function canonicalChangePaths(raw) {
|
|
562
|
+
const path = clean(raw?.path);
|
|
563
|
+
const newPath = clean(raw?.newPath);
|
|
564
|
+
return {
|
|
565
|
+
...raw,
|
|
566
|
+
...(isAbsolute(path) ? { path: canonicalPath(path) } : {}),
|
|
567
|
+
...(isAbsolute(newPath) ? { newPath: canonicalPath(newPath) } : {}),
|
|
568
|
+
};
|
|
481
569
|
}
|
|
482
570
|
|
|
483
|
-
|
|
484
|
-
|
|
485
|
-
|
|
571
|
+
const insidePath = (root, path) => {
|
|
572
|
+
const rel = relative(root, path);
|
|
573
|
+
return Boolean(rel) && !isAbsolute(rel) && rel.split(/[\\/]+/)[0] !== '..';
|
|
574
|
+
};
|
|
575
|
+
|
|
576
|
+
/** Hand each change's first-seen state to the session baselines of `sessionId`
|
|
577
|
+
* and the Lead it works for. A path inside the turn's Git root is skipped: the
|
|
578
|
+
* shadow baseline tree already vouches for it. */
|
|
579
|
+
function captureReviewBaselines(sessionId, tracker, ownerTracker, list, snapshot) {
|
|
580
|
+
const gitRoot = clean(snapshot?.root);
|
|
581
|
+
const root = clean(tracker?.worktreeRoot) || clean(ownerTracker?.worktreeRoot);
|
|
582
|
+
const changes = [];
|
|
583
|
+
for (const raw of list) {
|
|
584
|
+
const path = clean(raw?.path);
|
|
585
|
+
if (!path || !isAbsolute(path)) continue;
|
|
586
|
+
const newPath = isAbsolute(clean(raw?.newPath)) ? resolve(clean(raw.newPath)) : '';
|
|
587
|
+
if (gitRoot && insidePath(gitRoot, path) && (!newPath || insidePath(gitRoot, newPath))) continue;
|
|
588
|
+
const before = raw?.before ?? null;
|
|
589
|
+
const after = raw?.after ?? null;
|
|
590
|
+
if (!newPath && before === null && after === null) continue;
|
|
591
|
+
changes.push({
|
|
592
|
+
path: resolve(path),
|
|
593
|
+
root,
|
|
594
|
+
before,
|
|
595
|
+
...(newPath ? { newPath } : {}),
|
|
596
|
+
replaced: (before === null) !== (after === null),
|
|
597
|
+
});
|
|
598
|
+
}
|
|
599
|
+
captureSessionBaselines(sessionId, changes, { owner: tracker?.ownerSessionId || '' });
|
|
600
|
+
}
|
|
601
|
+
|
|
602
|
+
/** Fold exact before/after states into `tracker` as own segments (see
|
|
603
|
+
* appendSegment). `snapshot` — the owning Lead turn's Git baseline, when
|
|
604
|
+
* tools are writing — learns each written path's first-before bytes. */
|
|
486
605
|
function foldTrackedChanges(tracker, list, snapshot) {
|
|
487
606
|
// The content ceiling is enforced BEFORE anything is stored: an over-budget
|
|
488
607
|
// batch invalidates the tracker up front instead of retaining its buffers.
|
|
@@ -511,63 +630,67 @@ function foldTrackedChanges(tracker, list, snapshot) {
|
|
|
511
630
|
// attribution a shared worktree cannot otherwise reconstruct. It keeps the
|
|
512
631
|
// ABSOLUTE file: the display path is relative to the session cwd, which
|
|
513
632
|
// is not necessarily the repository root the scoped diff runs from.
|
|
514
|
-
|
|
633
|
+
const sourceEntry = ownedPathEntry(sourcePath, sourceDisplay);
|
|
634
|
+
tracker.ownedPaths.add(sourceEntry);
|
|
635
|
+
let destination = null;
|
|
515
636
|
if (destinationPath) {
|
|
516
|
-
|
|
637
|
+
const display = displayPath(raw?.newDisplayPath || destinationPath);
|
|
638
|
+
const entry = ownedPathEntry(destinationPath, display);
|
|
639
|
+
tracker.ownedPaths.add(entry);
|
|
640
|
+
destination = { key: pathKey(entry), path: entry, display };
|
|
517
641
|
}
|
|
642
|
+
foldOwnStats(
|
|
643
|
+
tracker.ownStats,
|
|
644
|
+
raw,
|
|
645
|
+
{ key: pathKey(sourceEntry), path: sourceEntry, display: sourceDisplay },
|
|
646
|
+
destination
|
|
647
|
+
);
|
|
518
648
|
if (!storeContent) continue;
|
|
519
649
|
const after = contentBuffer(raw?.after);
|
|
520
|
-
if (!destinationPath)
|
|
521
|
-
|
|
522
|
-
|
|
523
|
-
|
|
524
|
-
|
|
525
|
-
|
|
526
|
-
|
|
527
|
-
}
|
|
528
|
-
|
|
529
|
-
|
|
530
|
-
if (before !== null && after === null) {
|
|
531
|
-
if (!tracker.currentByPath.delete(sourceKey) && !tracker.baselineByPath.has(sourceKey)) {
|
|
532
|
-
tracker.baselineByPath.set(sourceKey, {
|
|
533
|
-
path: sourcePath,
|
|
534
|
-
displayPath: sourceDisplay,
|
|
535
|
-
content: before,
|
|
536
|
-
});
|
|
537
|
-
}
|
|
538
|
-
tracker.originByCurrentPath.delete(sourceKey);
|
|
539
|
-
continue;
|
|
540
|
-
}
|
|
541
|
-
if (before === null && after === null) continue;
|
|
542
|
-
recordFirstBaseline(tracker, sourceKey, { path: sourcePath, displayPath: sourceDisplay, content: before });
|
|
543
|
-
tracker.currentByPath.set(sourceKey, {
|
|
544
|
-
path: sourcePath,
|
|
545
|
-
displayPath: sourceDisplay,
|
|
546
|
-
content: after,
|
|
547
|
-
});
|
|
548
|
-
continue;
|
|
549
|
-
}
|
|
550
|
-
|
|
551
|
-
const destinationKey = pathKey(destinationPath);
|
|
552
|
-
const destinationDisplay = displayPath(raw?.newDisplayPath || destinationPath);
|
|
553
|
-
recordFirstBaseline(tracker, sourceKey, { path: sourcePath, displayPath: sourceDisplay, content: before });
|
|
554
|
-
const originKey = tracker.originByCurrentPath.get(sourceKey) || sourceKey;
|
|
555
|
-
tracker.currentByPath.delete(sourceKey);
|
|
556
|
-
tracker.originByCurrentPath.delete(sourceKey);
|
|
557
|
-
tracker.currentByPath.set(destinationKey, {
|
|
558
|
-
path: destinationPath,
|
|
559
|
-
displayPath: destinationDisplay,
|
|
560
|
-
content: after,
|
|
650
|
+
if (!destinationPath && before === null && after === null) continue;
|
|
651
|
+
dropUndoneChains(tracker, new Set([sourceKey, ...(destination ? [pathKey(destinationPath)] : [])]));
|
|
652
|
+
const seq = (_writeSeq += 1);
|
|
653
|
+
appendSegment(tracker, {
|
|
654
|
+
before: { path: sourcePath, displayPath: sourceDisplay, content: before },
|
|
655
|
+
after: destination
|
|
656
|
+
? { path: destinationPath, displayPath: destination.display, content: after }
|
|
657
|
+
: { path: sourcePath, displayPath: sourceDisplay, content: after },
|
|
658
|
+
firstSeq: seq,
|
|
659
|
+
lastSeq: seq,
|
|
561
660
|
});
|
|
562
|
-
tracker.originByCurrentPath.delete(destinationKey);
|
|
563
|
-
if (destinationKey !== originKey) tracker.originByCurrentPath.set(destinationKey, originKey);
|
|
564
661
|
}
|
|
565
662
|
tracker.revision += 1;
|
|
566
663
|
if (trackedContentBytes(tracker) > MAX_TRACKED_CONTENT_BYTES) {
|
|
567
664
|
tracker.valid = false;
|
|
568
665
|
releaseDiffTrackerContent(tracker);
|
|
569
666
|
}
|
|
570
|
-
|
|
667
|
+
}
|
|
668
|
+
|
|
669
|
+
/** Join a completed worker's own segments to its Lead's tracker: both sets
|
|
670
|
+
* replay in write order, so a worker write between two Lead writes of one
|
|
671
|
+
* file continues their segment instead of reading as a foreign gap. A
|
|
672
|
+
* worker whose content was dropped drops the Lead's exact content too. */
|
|
673
|
+
function mergeTrackedContent(target, source) {
|
|
674
|
+
// The worker's owned paths and counts joined as well: any view is stale.
|
|
675
|
+
target.revision += 1;
|
|
676
|
+
if (!target.valid) return;
|
|
677
|
+
if (!source.valid) {
|
|
678
|
+
target.valid = false;
|
|
679
|
+
releaseDiffTrackerContent(target);
|
|
680
|
+
return;
|
|
681
|
+
}
|
|
682
|
+
const segmentsOf = (tracker) => [...tracker.chains.values()].flatMap((chain) => chain.segments);
|
|
683
|
+
const written = new Set(
|
|
684
|
+
segmentsOf(source).flatMap((segment) => [pathKey(segment.before.path), pathKey(segment.after.path)])
|
|
685
|
+
);
|
|
686
|
+
const segments = [...segmentsOf(target), ...segmentsOf(source)].sort((left, right) => left.firstSeq - right.firstSeq);
|
|
687
|
+
target.chains = new Map();
|
|
688
|
+
for (const segment of segments) appendSegment(target, segment);
|
|
689
|
+
dropUndoneChains(target, written);
|
|
690
|
+
if (trackedContentBytes(target) > MAX_TRACKED_CONTENT_BYTES) {
|
|
691
|
+
target.valid = false;
|
|
692
|
+
releaseDiffTrackerContent(target);
|
|
693
|
+
}
|
|
571
694
|
}
|
|
572
695
|
|
|
573
696
|
function trimAgentReviews(turn) {
|
|
@@ -626,16 +749,20 @@ function continueTurnSnapshot(ownerSessionId) {
|
|
|
626
749
|
export async function beginTurnSnapshot(worktree, sessionId, options = {}) {
|
|
627
750
|
const ownerSessionId = clean(sessionId);
|
|
628
751
|
if (DISABLED || !ownerSessionId) return undefined;
|
|
752
|
+
// A worker resumed after a restart still credits its Lead's review (a
|
|
753
|
+
// capture racing this lookup waits for it).
|
|
754
|
+
void primeReviewOwner(ownerSessionId);
|
|
629
755
|
// Turn start and Undo/Reapply share the per-session lock: a start waits for
|
|
630
756
|
// an in-flight mutation, and a mutation queued after this start re-checks
|
|
631
757
|
// under the lock and finds the turn running.
|
|
632
|
-
//
|
|
633
|
-
// this same step
|
|
634
|
-
// the
|
|
758
|
+
// Late-worker re-seals still queued for the previous turn run first, in
|
|
759
|
+
// this same step — including one a worker queued while an earlier one ran —
|
|
760
|
+
// so the workers' edits stay in that turn's record and nothing the new turn
|
|
761
|
+
// writes can reach it. (A queued re-seal implies a chain.)
|
|
635
762
|
const { token, tracker } = _changeMutations.has(ownerSessionId)
|
|
636
763
|
? await serializeChangeMutation(ownerSessionId, async () => {
|
|
637
764
|
const previous = _diffTrackersBySession.get(ownerSessionId);
|
|
638
|
-
|
|
765
|
+
while (previous?.resealQueued) await runQueuedReseal(ownerSessionId, previous).catch(() => {});
|
|
639
766
|
return openTurnSnapshot(worktree, ownerSessionId, options);
|
|
640
767
|
})
|
|
641
768
|
: openTurnSnapshot(worktree, ownerSessionId, options);
|
|
@@ -701,6 +828,9 @@ export function beginAgentTurnReview(ownerSessionId, childSessionId, meta = {})
|
|
|
701
828
|
if (DISABLED) return null;
|
|
702
829
|
const owner = clean(ownerSessionId);
|
|
703
830
|
const child = clean(childSessionId);
|
|
831
|
+
// Durable: the worker's edits join the Lead's session review even when it
|
|
832
|
+
// runs its own session turn, and whatever the turn's agent list keeps.
|
|
833
|
+
recordReviewOwner(child, owner);
|
|
704
834
|
const turn = _turnsBySession.get(owner);
|
|
705
835
|
if (!owner || !child || !turn) return null;
|
|
706
836
|
const existingTracker = _diffTrackersBySession.get(child);
|
|
@@ -726,20 +856,29 @@ export function beginAgentTurnReview(ownerSessionId, childSessionId, meta = {})
|
|
|
726
856
|
};
|
|
727
857
|
}
|
|
728
858
|
|
|
729
|
-
/** Freeze successful worker apply_patch diffs into their owning Lead turn.
|
|
859
|
+
/** Freeze successful worker apply_patch diffs into their owning Lead turn.
|
|
860
|
+
* Everything a seal reads — the agent entry, the worker's owned paths and a
|
|
861
|
+
* late re-seal — is published before this returns, so a seal started right
|
|
862
|
+
* after it already includes the worker. */
|
|
730
863
|
export function completeAgentTurnReview(handle, patches = []) {
|
|
731
864
|
if (!handle || handle.completed === true) return false;
|
|
732
865
|
handle.completed = true;
|
|
733
|
-
const
|
|
866
|
+
const ownerSessionId = clean(handle.ownerSessionId);
|
|
734
867
|
const key = clean(handle.sessionId) || handle.id;
|
|
868
|
+
const turn = _turnsBySession.get(ownerSessionId);
|
|
735
869
|
const tracker = _diffTrackersBySession.get(key);
|
|
736
870
|
const matchingTracker =
|
|
737
|
-
tracker && tracker.ownerSessionId ===
|
|
871
|
+
tracker && tracker.ownerSessionId === ownerSessionId && tracker.ownerGeneration === handle.generation;
|
|
872
|
+
// A worker that runs its own session turn replaces the bound tracker with
|
|
873
|
+
// its turn's own one (no owner). beginAgentTurnReview reset any older
|
|
874
|
+
// tracker, so an unowned one here holds this execution's edits.
|
|
875
|
+
const source = matchingTracker || (tracker && !tracker.ownerSessionId) ? tracker : null;
|
|
876
|
+
const ownerTracker = _diffTrackersBySession.get(ownerSessionId);
|
|
738
877
|
try {
|
|
739
878
|
if (!turn || turn.generation !== handle.generation) return false;
|
|
740
879
|
const prior = turn.agents.get(key);
|
|
741
|
-
const tracked =
|
|
742
|
-
const patch = tracked ?
|
|
880
|
+
const tracked = Boolean(source) && source.revision > 0;
|
|
881
|
+
const patch = tracked ? trackerView(source).patch : mergePatches(patches);
|
|
743
882
|
if (!patch) {
|
|
744
883
|
if (tracked && prior) {
|
|
745
884
|
turn.agents.delete(key);
|
|
@@ -754,15 +893,17 @@ export function completeAgentTurnReview(handle, patches = []) {
|
|
|
754
893
|
tag: handle.tag || prior?.tag || null,
|
|
755
894
|
patch: tracked ? patch : mergePatches([prior?.patch, patch]),
|
|
756
895
|
});
|
|
757
|
-
|
|
758
|
-
|
|
759
|
-
|
|
896
|
+
if (source && ownerTracker && ownerTracker !== source) {
|
|
897
|
+
for (const path of source.ownedPaths) ownerTracker.ownedPaths.add(path);
|
|
898
|
+
mergeOwnStats(ownerTracker.ownStats, source.ownStats);
|
|
899
|
+
mergeTrackedContent(ownerTracker, source);
|
|
760
900
|
}
|
|
761
901
|
trimAgentReviews(turn);
|
|
762
|
-
// A worker that finishes after its Lead turn sealed
|
|
763
|
-
// change record (its edits are part of the turn's net
|
|
902
|
+
// A worker that finishes after its Lead turn sealed (or while it seals)
|
|
903
|
+
// re-seals that turn's change record (its edits are part of the turn's net
|
|
904
|
+
// worktree diff).
|
|
764
905
|
if (ownerTracker && ownerTracker !== tracker && ownerTracker.ownerGeneration === handle.generation) {
|
|
765
|
-
resealTurn(
|
|
906
|
+
resealTurn(ownerSessionId, ownerTracker);
|
|
766
907
|
}
|
|
767
908
|
return true;
|
|
768
909
|
} finally {
|
|
@@ -780,8 +921,8 @@ export function completeAgentTurnReview(handle, patches = []) {
|
|
|
780
921
|
* are precisely the attribution that contention destroys. */
|
|
781
922
|
async function persistTurnSnapshotRecord(ownerSessionId, tracker) {
|
|
782
923
|
const snapshot = tracker?.worktreeSnapshot;
|
|
783
|
-
if (!ownerSessionId || !snapshot?.baselineTree || !snapshot.root) return;
|
|
784
|
-
const owned = [...
|
|
924
|
+
if (!ownerSessionId || tracker.discarded || !snapshot?.baselineTree || !snapshot.root) return;
|
|
925
|
+
const owned = [...ownedPathsUnder(snapshot.root, tracker.ownedPaths)];
|
|
785
926
|
if (owned.length === 0) return;
|
|
786
927
|
try {
|
|
787
928
|
await saveTurnSnapshotRecord(ownerSessionId, {
|
|
@@ -810,10 +951,11 @@ function attributedAgents(ownerSessionId, tracker) {
|
|
|
810
951
|
}));
|
|
811
952
|
}
|
|
812
953
|
|
|
813
|
-
/** Path of a tracked entry relative to `root`, or '' outside it
|
|
954
|
+
/** Path of a tracked entry relative to `root`, or '' outside it (or without
|
|
955
|
+
* a root). */
|
|
814
956
|
function trackedRelativePath(root, entry) {
|
|
815
957
|
const value = clean(entry?.path) || clean(entry?.displayPath);
|
|
816
|
-
if (!value) return '';
|
|
958
|
+
if (!root || !value) return '';
|
|
817
959
|
const rel = relative(root, isAbsolute(value) ? resolve(value) : resolve(root, value));
|
|
818
960
|
if (!rel || isAbsolute(rel) || rel.split(/[\\/]+/).includes('..')) return '';
|
|
819
961
|
return rel.replace(/\\/g, '/');
|
|
@@ -821,42 +963,79 @@ function trackedRelativePath(root, entry) {
|
|
|
821
963
|
|
|
822
964
|
/** A record file for an edit the worktree root cannot replay: outside it, or
|
|
823
965
|
* too large/binary to capture. Display-only (`viewPatch`), never undone. */
|
|
824
|
-
function untrackableRecordFile(
|
|
825
|
-
|
|
826
|
-
|
|
827
|
-
|
|
828
|
-
|
|
829
|
-
|
|
830
|
-
|
|
831
|
-
|
|
966
|
+
function untrackableRecordFile(edit, root, budget) {
|
|
967
|
+
const path = viewPath(root, edit.end);
|
|
968
|
+
const oldPath = viewPath(root, edit.start);
|
|
969
|
+
const named = edit.omitted
|
|
970
|
+
? []
|
|
971
|
+
: edit.steps.map(({ before, after }) =>
|
|
972
|
+
namedChangePatch({ before: before.content, after: after.content, oldName: path, newName: path, budget })
|
|
973
|
+
);
|
|
974
|
+
const viewPatch = named.map((step) => step.patch).join('');
|
|
832
975
|
return {
|
|
833
|
-
path
|
|
834
|
-
...(
|
|
835
|
-
status,
|
|
836
|
-
additions: named
|
|
837
|
-
deletions: named
|
|
838
|
-
...(
|
|
839
|
-
...(omitted ? { binary: true } : {}),
|
|
976
|
+
path,
|
|
977
|
+
...(oldPath !== path ? { oldPath } : {}),
|
|
978
|
+
status: edit.status,
|
|
979
|
+
additions: named.reduce((sum, step) => sum + step.additions, 0),
|
|
980
|
+
deletions: named.reduce((sum, step) => sum + step.deletions, 0),
|
|
981
|
+
...(viewPatch ? { viewPatch } : {}),
|
|
982
|
+
...(edit.omitted ? { binary: true } : {}),
|
|
840
983
|
revertable: false,
|
|
841
|
-
reason: omitted ? 'binary/large file content was not captured' : 'file is outside the recorded root',
|
|
984
|
+
reason: edit.omitted ? 'binary/large file content was not captured' : 'file is outside the recorded root',
|
|
985
|
+
};
|
|
986
|
+
}
|
|
987
|
+
|
|
988
|
+
/** The root a tracker's own patches are relative to, and a getter for the
|
|
989
|
+
* object format `git apply` hashes their binary content with there (one Git
|
|
990
|
+
* call, made only when asked). */
|
|
991
|
+
function trackerPatchRoot(tracker, fallback = '') {
|
|
992
|
+
const git = Boolean(clean(tracker?.worktreeSnapshot?.root));
|
|
993
|
+
const root = git ? tracker.worktreeSnapshot.root : clean(tracker?.worktreeRoot) || clean(fallback);
|
|
994
|
+
let format = null;
|
|
995
|
+
const objectFormat = () => {
|
|
996
|
+
format ||= git ? repositoryObjectFormat(root) : Promise.resolve('sha1');
|
|
997
|
+
return format;
|
|
998
|
+
};
|
|
999
|
+
return { root: root ? resolve(root) : '', kind: git ? 'git' : 'tracked', objectFormat };
|
|
1000
|
+
}
|
|
1001
|
+
|
|
1002
|
+
/** One chain's edit (chainEdit) as a record file: one replayable patch
|
|
1003
|
+
* section per own segment, oldest first, or a view-only entry when it lies
|
|
1004
|
+
* outside the root or its bytes were never captured. `objectFormat` hashes a
|
|
1005
|
+
* binary patch, `budget` bounds the line diff. */
|
|
1006
|
+
function editChangeFile(edit, root, objectFormat, budget) {
|
|
1007
|
+
if (edit.omitted || !edit.inside) return untrackableRecordFile(edit, root, budget);
|
|
1008
|
+
const sections = edit.steps.map(({ before, after, oldPath, newPath }) =>
|
|
1009
|
+
trackedChangeFile({ oldPath, newPath, before: before.content, after: after.content, objectFormat, budget })
|
|
1010
|
+
);
|
|
1011
|
+
if (sections.length === 1) return sections[0];
|
|
1012
|
+
const path = trackedRelativePath(root, edit.end);
|
|
1013
|
+
const oldPath = trackedRelativePath(root, edit.start);
|
|
1014
|
+
const base = {
|
|
1015
|
+
path,
|
|
1016
|
+
...(oldPath !== path ? { oldPath } : {}),
|
|
1017
|
+
status: edit.status,
|
|
1018
|
+
additions: sections.reduce((sum, section) => sum + section.additions, 0),
|
|
1019
|
+
deletions: sections.reduce((sum, section) => sum + section.deletions, 0),
|
|
842
1020
|
};
|
|
1021
|
+
const blocked = sections.find((section) => section.revertable !== true);
|
|
1022
|
+
if (blocked) return { ...base, revertable: false, reason: blocked.reason };
|
|
1023
|
+
return { ...base, revertable: true, patch: Buffer.concat(sections.map((section) => section.patch)) };
|
|
843
1024
|
}
|
|
844
1025
|
|
|
845
|
-
/** The
|
|
846
|
-
|
|
847
|
-
|
|
848
|
-
const
|
|
849
|
-
|
|
850
|
-
const oldPath = trackedRelativePath(root, pair.before || pair.after);
|
|
851
|
-
const newPath = trackedRelativePath(root, pair.after || pair.before);
|
|
852
|
-
if (oldPath === newPath && sameContent(before, after)) return null;
|
|
853
|
-
return { before, after, oldPath, newPath, omitted: [before, after].some(isOmittedContent) };
|
|
1026
|
+
/** The object format the binary patches of `edits` hash with: resolved (one
|
|
1027
|
+
* Git call) only when one of them replays binary content. */
|
|
1028
|
+
async function editsObjectFormat(edits, patchRoot) {
|
|
1029
|
+
const binary = edits.some((edit) => edit.binary && edit.inside && !edit.omitted);
|
|
1030
|
+
return binary ? await patchRoot.objectFormat() : 'sha1';
|
|
854
1031
|
}
|
|
855
1032
|
|
|
856
|
-
/** Record files of
|
|
857
|
-
|
|
1033
|
+
/** Record files of the tracker's own changes. `outside` are those beyond the
|
|
1034
|
+
* root (view-only beside a Git turn's files). */
|
|
1035
|
+
async function ownRecordFiles(tracker, patchRoot) {
|
|
1036
|
+
const { root } = patchRoot;
|
|
858
1037
|
if (!tracker.valid) {
|
|
859
|
-
|
|
1038
|
+
const files = [...ownedPathsUnder(root, tracker.ownedPaths)].map((path) => ({
|
|
860
1039
|
path,
|
|
861
1040
|
status: 'M',
|
|
862
1041
|
additions: 0,
|
|
@@ -864,63 +1043,41 @@ function trackedRecordFiles(tracker, root) {
|
|
|
864
1043
|
revertable: false,
|
|
865
1044
|
reason: 'file content exceeded the undo tracking limit',
|
|
866
1045
|
}));
|
|
1046
|
+
return { files, outside: [] };
|
|
867
1047
|
}
|
|
1048
|
+
const edits = [...tracker.chains.values()].map((chain) => chainEdit(chain, root)).filter(Boolean);
|
|
1049
|
+
const objectFormat = await editsObjectFormat(edits, patchRoot);
|
|
1050
|
+
// One line-diff budget for the whole seal pass.
|
|
1051
|
+
const budget = createLineDiffBudget();
|
|
868
1052
|
const files = [];
|
|
869
|
-
|
|
870
|
-
|
|
871
|
-
|
|
872
|
-
if (edit.omitted || !edit.oldPath || !edit.newPath) {
|
|
873
|
-
files.push(untrackableRecordFile(pair, edit));
|
|
874
|
-
continue;
|
|
875
|
-
}
|
|
876
|
-
files.push(
|
|
877
|
-
trackedChangeFile({ oldPath: edit.oldPath, newPath: edit.newPath, before: edit.before, after: edit.after })
|
|
878
|
-
);
|
|
879
|
-
}
|
|
880
|
-
return files;
|
|
1053
|
+
const outside = [];
|
|
1054
|
+
for (const edit of edits) (edit.inside ? files : outside).push(editChangeFile(edit, root, objectFormat, budget));
|
|
1055
|
+
return { files, outside };
|
|
881
1056
|
}
|
|
882
1057
|
|
|
883
|
-
/**
|
|
884
|
-
*
|
|
885
|
-
|
|
886
|
-
|
|
887
|
-
|
|
888
|
-
for (const pair of trackedPairs(tracker)) {
|
|
889
|
-
const edit = recordedPair(pair, root);
|
|
890
|
-
if (edit && (!edit.oldPath || !edit.newPath)) {
|
|
891
|
-
files.push(untrackableRecordFile(pair, edit));
|
|
892
|
-
}
|
|
893
|
-
}
|
|
894
|
-
return files;
|
|
895
|
-
}
|
|
896
|
-
|
|
897
|
-
/** Freeze the turn's net diff into its durable change record. A Git turn
|
|
898
|
-
* records the baseline→end worktree diff (workers' edits included, scoped to
|
|
899
|
-
* session-owned paths when the worktree is shared); a non-Git turn records
|
|
900
|
-
* the Lead's exact apply_patch pairs. */
|
|
1058
|
+
/** Freeze the turn's own changes into its durable change record: per file,
|
|
1059
|
+
* the session's own segments (its completed workers' included) as one
|
|
1060
|
+
* replayable patch, never the worktree's net diff, counted by the summed
|
|
1061
|
+
* deltas of those writes (`counts: 'own'`; older records without the marker
|
|
1062
|
+
* carry net worktree patches and counts). */
|
|
901
1063
|
async function freezeTurnChanges(ownerSessionId, tracker, agents) {
|
|
902
1064
|
const checkpointId = clean(tracker.checkpointId);
|
|
903
1065
|
if (!checkpointId) return null;
|
|
904
|
-
|
|
905
|
-
if (
|
|
906
|
-
|
|
907
|
-
|
|
908
|
-
|
|
909
|
-
const result = await freezeTurnWorktreeChanges(tracker.worktreeSnapshot, scope);
|
|
910
|
-
if (result) {
|
|
911
|
-
frozen = { kind: 'git', ...result, outside: outsideRecordFiles(tracker, tracker.worktreeSnapshot.root) };
|
|
912
|
-
}
|
|
913
|
-
} else if (tracker.worktreeRoot) {
|
|
914
|
-
frozen = { kind: 'tracked', root: tracker.worktreeRoot, files: trackedRecordFiles(tracker, tracker.worktreeRoot) };
|
|
915
|
-
}
|
|
916
|
-
if (!frozen) return null;
|
|
1066
|
+
const patchRoot = trackerPatchRoot(tracker);
|
|
1067
|
+
if (!patchRoot.root) return null;
|
|
1068
|
+
const { files, outside } = await ownRecordFiles(tracker, patchRoot);
|
|
1069
|
+
const git = patchRoot.kind === 'git';
|
|
1070
|
+
const counted = ownCountIndex(tracker, patchRoot.root);
|
|
917
1071
|
const record = await upsertTurnChangeRecord(ownerSessionId, {
|
|
918
1072
|
checkpointId,
|
|
919
|
-
kind:
|
|
920
|
-
root:
|
|
1073
|
+
kind: patchRoot.kind,
|
|
1074
|
+
root: patchRoot.root,
|
|
921
1075
|
agents,
|
|
922
|
-
|
|
923
|
-
|
|
1076
|
+
counts: 'own',
|
|
1077
|
+
// A non-Git turn has no view-only side list: an edit it cannot replay
|
|
1078
|
+
// blocks its Undo like before.
|
|
1079
|
+
files: withOwnCounts(git ? files : [...files, ...outside], counted).map(storedChangeFile),
|
|
1080
|
+
outside: git ? withOwnCounts(outside, counted).map(storedChangeFile) : [],
|
|
924
1081
|
});
|
|
925
1082
|
// A fresh freeze measured the disk itself; nothing about it is unknown.
|
|
926
1083
|
_unknownChangeStates.delete(pendingFreezeKey(ownerSessionId, checkpointId));
|
|
@@ -934,11 +1091,14 @@ const pendingFreezeKey = (ownerSessionId, checkpointId) => `${ownerSessionId}\0$
|
|
|
934
1091
|
* baseline and freeze the change record. Single-flight per tracker; a seal
|
|
935
1092
|
* requested while another runs waits for it and then seals again only if a
|
|
936
1093
|
* completion wake reopened the turn meanwhile. `reseal` (a late worker) only
|
|
937
|
-
* re-freezes a turn that is still sealed
|
|
1094
|
+
* re-freezes a turn that is still sealed, after any seal in flight: that one
|
|
1095
|
+
* captured its agents before the worker was attributed.
|
|
938
1096
|
*/
|
|
939
1097
|
function sealTurn(ownerSessionId, tracker, { reseal = false } = {}) {
|
|
1098
|
+
// Its history was deleted (clearSessionChangeHistory): never written again.
|
|
1099
|
+
if (tracker.discarded) return Promise.resolve();
|
|
940
1100
|
if (tracker.freezing) {
|
|
941
|
-
if (reseal) return tracker.freezing;
|
|
1101
|
+
if (reseal) return tracker.freezing.then(() => sealTurn(ownerSessionId, tracker, { reseal: true }));
|
|
942
1102
|
return tracker.freezing.then(() => (tracker.sealed ? undefined : sealTurn(ownerSessionId, tracker)));
|
|
943
1103
|
}
|
|
944
1104
|
const epoch = tracker.sealEpoch;
|
|
@@ -953,19 +1113,18 @@ function sealTurn(ownerSessionId, tracker, { reseal = false } = {}) {
|
|
|
953
1113
|
// Best-effort refresh: seal with the last captured worktree state.
|
|
954
1114
|
}
|
|
955
1115
|
}
|
|
956
|
-
if (tracker.sealEpoch !== epoch || (reseal && !tracker.sealed)) return;
|
|
1116
|
+
if (tracker.discarded || tracker.sealEpoch !== epoch || (reseal && !tracker.sealed)) return;
|
|
1117
|
+
// A re-seal never rewrites a turn once the next one opened.
|
|
1118
|
+
if (reseal && turnSuperseded(ownerSessionId, tracker)) return;
|
|
957
1119
|
tracker.sealed = true;
|
|
958
1120
|
await persistTurnSnapshotRecord(ownerSessionId, tracker);
|
|
959
1121
|
try {
|
|
960
|
-
await freezeTurnChanges(ownerSessionId, tracker, agents);
|
|
1122
|
+
if (!tracker.discarded) await freezeTurnChanges(ownerSessionId, tracker, agents);
|
|
961
1123
|
} catch {
|
|
962
1124
|
/* the change record adds Undo history; it never fails a turn */
|
|
963
1125
|
}
|
|
964
|
-
//
|
|
965
|
-
//
|
|
966
|
-
// this session's bounded before/after content so Review can still undo its
|
|
967
|
-
// own files after the turn completes.
|
|
968
|
-
if (worktreeSnapshotUsable(tracker)) releaseDiffTrackerContent(tracker);
|
|
1126
|
+
// The bounded own before/after content stays until the next turn: a late
|
|
1127
|
+
// worker's re-seal and an Undo/Reapply of this turn fold into it.
|
|
969
1128
|
})().finally(() => {
|
|
970
1129
|
if (tracker.freezing === run) tracker.freezing = null;
|
|
971
1130
|
const count = (_pendingFreezes.get(key) || 1) - 1;
|
|
@@ -977,7 +1136,9 @@ function sealTurn(ownerSessionId, tracker, { reseal = false } = {}) {
|
|
|
977
1136
|
}
|
|
978
1137
|
|
|
979
1138
|
function resealTurn(ownerSessionId, tracker) {
|
|
980
|
-
|
|
1139
|
+
// A turn still running seals with every published agent; one sealed or
|
|
1140
|
+
// sealing queues its re-seal (sealTurn runs it after a seal in flight).
|
|
1141
|
+
if (!(tracker.sealed || tracker.freezing) || !clean(tracker.checkpointId) || tracker.resealQueued) return;
|
|
981
1142
|
// On the session lock: an in-flight Undo/Reapply lands before the re-freeze
|
|
982
1143
|
// reads the worktree. A turn start queued ahead of it runs it before opening
|
|
983
1144
|
// (beginTurnSnapshot), so one queued re-seal covers every late worker.
|
|
@@ -985,10 +1146,17 @@ function resealTurn(ownerSessionId, tracker) {
|
|
|
985
1146
|
void serializeChangeMutation(ownerSessionId, () => runQueuedReseal(ownerSessionId, tracker)).catch(() => {});
|
|
986
1147
|
}
|
|
987
1148
|
|
|
1149
|
+
/** True once a newer turn of the session opened over `tracker`'s. */
|
|
1150
|
+
function turnSuperseded(ownerSessionId, tracker) {
|
|
1151
|
+
const turn = _turnsBySession.get(ownerSessionId);
|
|
1152
|
+
return Boolean(turn) && turn.generation !== tracker.ownerGeneration;
|
|
1153
|
+
}
|
|
1154
|
+
|
|
988
1155
|
/** Run `tracker`'s queued re-seal once, whichever lock step reaches it first. */
|
|
989
1156
|
function runQueuedReseal(ownerSessionId, tracker) {
|
|
990
1157
|
if (!tracker.resealQueued) return undefined;
|
|
991
1158
|
tracker.resealQueued = false;
|
|
1159
|
+
if (turnSuperseded(ownerSessionId, tracker)) return undefined;
|
|
992
1160
|
return sealTurn(ownerSessionId, tracker, { reseal: true });
|
|
993
1161
|
}
|
|
994
1162
|
|
|
@@ -1005,71 +1173,126 @@ export async function completeTurnSnapshot(sessionId, expected = null) {
|
|
|
1005
1173
|
return true;
|
|
1006
1174
|
}
|
|
1007
1175
|
|
|
1008
|
-
/**
|
|
1009
|
-
*
|
|
1010
|
-
* worktree cannot attribute concurrent mutations to one session, and a
|
|
1011
|
-
* non-Git worktree never had a baseline to begin with. In both cases the
|
|
1012
|
-
* exact apply_patch buffers are the only safe revert source.
|
|
1013
|
-
*/
|
|
1176
|
+
/** Is this turn's review labelled with its uncontended Git baseline? (The
|
|
1177
|
+
* review itself is always the tracker's own segments.) */
|
|
1014
1178
|
function worktreeSnapshotUsable(tracker) {
|
|
1015
1179
|
return Boolean(tracker && !tracker.worktreeContended && tracker.worktreeSnapshot);
|
|
1016
1180
|
}
|
|
1017
1181
|
|
|
1018
|
-
/**
|
|
1019
|
-
|
|
1020
|
-
|
|
1021
|
-
|
|
1022
|
-
|
|
1023
|
-
const
|
|
1024
|
-
const relativeTarget =
|
|
1025
|
-
normalizedRoot && normalizedTarget.startsWith(`${normalizedRoot}/`)
|
|
1026
|
-
? normalizedTarget.slice(normalizedRoot.length + 1)
|
|
1027
|
-
: normalizedTarget;
|
|
1028
|
-
const normalizedOwned = pathKey(owned);
|
|
1029
|
-
return normalizedOwned === relativeTarget || normalizedOwned === normalizedTarget;
|
|
1030
|
-
}
|
|
1031
|
-
|
|
1032
|
-
/** Re-open the recorded baseline for a session whose tracker is gone. Returns
|
|
1033
|
-
* null when the record is missing or its tree object has been collected. */
|
|
1034
|
-
async function resumeRecordedSnapshot(ownerSessionId, { omitPatch = false } = {}) {
|
|
1035
|
-
if (!ownerSessionId) return null;
|
|
1036
|
-
let record = null;
|
|
1037
|
-
try {
|
|
1038
|
-
record = await loadTurnSnapshotRecord(ownerSessionId);
|
|
1039
|
-
} catch {
|
|
1040
|
-
return null;
|
|
1041
|
-
}
|
|
1182
|
+
/** The latest turn's review once its tracker is gone (the next turn replaced
|
|
1183
|
+
* it, the cache evicted it, the runtime restarted): its change record's
|
|
1184
|
+
* files still applied, with their own counts and recorded patches — never a
|
|
1185
|
+
* worktree diff, so a foreign edit inside an own file stays out. */
|
|
1186
|
+
async function recordedTurnReview(ownerSessionId, { omitPatch }) {
|
|
1187
|
+
const record = await latestTurnRecord(ownerSessionId);
|
|
1042
1188
|
if (!record) return null;
|
|
1043
|
-
const
|
|
1044
|
-
|
|
1045
|
-
|
|
1046
|
-
|
|
1047
|
-
|
|
1048
|
-
|
|
1049
|
-
|
|
1050
|
-
|
|
1051
|
-
|
|
1052
|
-
|
|
1053
|
-
|
|
1054
|
-
|
|
1055
|
-
|
|
1056
|
-
|
|
1057
|
-
|
|
1189
|
+
const files = (record.files || []).filter((file) => fileState(file) === 'applied');
|
|
1190
|
+
const patches = files.map(changeDisplayPatch);
|
|
1191
|
+
const rows = files.map((file, index) => {
|
|
1192
|
+
const row = {
|
|
1193
|
+
path: file.path,
|
|
1194
|
+
...(file.oldPath ? { oldPath: file.oldPath } : {}),
|
|
1195
|
+
status: file.status,
|
|
1196
|
+
additions: Number(file.additions) || 0,
|
|
1197
|
+
deletions: Number(file.deletions) || 0,
|
|
1198
|
+
...(file.binary ? { binary: true } : {}),
|
|
1199
|
+
};
|
|
1200
|
+
// The record dropped its patch for size: still listed too large.
|
|
1201
|
+
return recordPatchOmitted(file, patches[index]) ? patchOmittedFile(row) : row;
|
|
1202
|
+
});
|
|
1203
|
+
const shaped = omitPatch ? { files: rows, patch: '' } : perFileReview(rows, patches.join(''));
|
|
1058
1204
|
return {
|
|
1059
1205
|
supported: true,
|
|
1060
|
-
files:
|
|
1061
|
-
patch:
|
|
1062
|
-
...(
|
|
1206
|
+
files: shaped.files,
|
|
1207
|
+
patch: shaped.patch,
|
|
1208
|
+
...(omitPatch ? { patchOmitted: true } : {}),
|
|
1209
|
+
...(shaped.diffTooLarge ? { diffTooLarge: true } : {}),
|
|
1063
1210
|
snapshotKind: 'scoped',
|
|
1064
1211
|
revertMode: 'scoped',
|
|
1065
|
-
checkpointId: clean(
|
|
1066
|
-
patchTruncated:
|
|
1212
|
+
checkpointId: clean(record.checkpointId),
|
|
1213
|
+
patchTruncated: false,
|
|
1067
1214
|
authoritative: true,
|
|
1068
1215
|
agents: publicAgentReviews(ownerSessionId),
|
|
1069
1216
|
reason: 'recorded',
|
|
1070
1217
|
};
|
|
1071
1218
|
}
|
|
1072
1219
|
|
|
1220
|
+
/** A record file kept without its patch for size. */
|
|
1221
|
+
const recordPatchOmitted = (file, patch) => file?.patchOmitted === true && !patch;
|
|
1222
|
+
|
|
1223
|
+
/** A review reply's files and patch under the per-file caps: a file whose
|
|
1224
|
+
* sections were cut for display, or pass the per-file cap or the total left,
|
|
1225
|
+
* is listed with its counts but without its patch (read on its own when it
|
|
1226
|
+
* is opened). `diffTooLarge` once nothing fit. */
|
|
1227
|
+
function perFileReview(files, patch) {
|
|
1228
|
+
const sections = new Map();
|
|
1229
|
+
for (const section of splitPatchSections(Buffer.from(patch || '', 'utf8'))) {
|
|
1230
|
+
const key = pathKey(section.newPath ?? section.oldPath ?? '');
|
|
1231
|
+
const entry = sections.get(key) || { parts: [], bytes: 0, cut: false };
|
|
1232
|
+
entry.parts.push(section.patch);
|
|
1233
|
+
entry.bytes += section.patch.length;
|
|
1234
|
+
entry.cut ||= reviewSectionTruncated(section.patch);
|
|
1235
|
+
sections.set(key, entry);
|
|
1236
|
+
}
|
|
1237
|
+
const budget = createReviewPatchBudget();
|
|
1238
|
+
const omitted = new Set();
|
|
1239
|
+
const kept = [];
|
|
1240
|
+
for (const [key, entry] of sections) {
|
|
1241
|
+
if (!entry.cut && budget.admit(entry.bytes)) kept.push(...entry.parts);
|
|
1242
|
+
else omitted.add(key);
|
|
1243
|
+
}
|
|
1244
|
+
const shaped = files.map((file) => (omitted.has(pathKey(file.path)) ? patchOmittedFile(file) : file));
|
|
1245
|
+
const text = Buffer.concat(kept).toString('utf8');
|
|
1246
|
+
return { files: shaped, patch: text, diffTooLarge: reviewDiffTooLarge(shaped, text) };
|
|
1247
|
+
}
|
|
1248
|
+
|
|
1249
|
+
/** The latest turn's change record, its unrecordable file states applied. */
|
|
1250
|
+
async function latestTurnRecord(ownerSessionId) {
|
|
1251
|
+
const checkpointId = await latestRecordedCheckpoint(ownerSessionId);
|
|
1252
|
+
const stored = checkpointId ? await loadTurnChangeRecord(ownerSessionId, checkpointId).catch(() => null) : null;
|
|
1253
|
+
return withUnknownChangeStates(ownerSessionId, stored);
|
|
1254
|
+
}
|
|
1255
|
+
|
|
1256
|
+
/** One file's whole display patch in the tracker's view, rendered on its own
|
|
1257
|
+
* instead of within the reply's caps; '' when the view lists no such file. */
|
|
1258
|
+
function trackerFilePatch(tracker, path) {
|
|
1259
|
+
const { root, kind } = trackerPatchRoot(tracker);
|
|
1260
|
+
const key = pathKey(path);
|
|
1261
|
+
const budget = createLineDiffBudget();
|
|
1262
|
+
for (const chain of tracker.chains.values()) {
|
|
1263
|
+
const edit = chainEdit(chain, root);
|
|
1264
|
+
if (!edit || (kind === 'git' && !edit.inside) || pathKey(viewPath(root, edit.end)) !== key) continue;
|
|
1265
|
+
return edit.steps.map((step) => renderStep(step, root, budget).text).join('');
|
|
1266
|
+
}
|
|
1267
|
+
return '';
|
|
1268
|
+
}
|
|
1269
|
+
|
|
1270
|
+
/** One file of the latest turn's review with its whole patch, up to the
|
|
1271
|
+
* file-patch read's cap: the file a reply listed `patchOmitted`. */
|
|
1272
|
+
export async function getTurnReviewFilePatch(_worktree, sessionId, file) {
|
|
1273
|
+
const path = clean(file).replace(/\\/g, '/');
|
|
1274
|
+
if (DISABLED) return { supported: false, path, patch: '' };
|
|
1275
|
+
const ownerSessionId = clean(sessionId);
|
|
1276
|
+
if (!ownerSessionId || !path) return { supported: true, path, patch: '' };
|
|
1277
|
+
const tracker = _diffTrackersBySession.get(ownerSessionId);
|
|
1278
|
+
if (tracker) return filePatchReply(path, trackerFilePatch(tracker, path));
|
|
1279
|
+
// Only a file the recorded review lists, by its own path.
|
|
1280
|
+
const record = await latestTurnRecord(ownerSessionId);
|
|
1281
|
+
const match = record ? findChangeFile(record, path) : null;
|
|
1282
|
+
if (!match || fileState(match) !== 'applied' || changePathKey(match.path) !== changePathKey(relativeRecordPath(record, path))) {
|
|
1283
|
+
return filePatchReply(path, '');
|
|
1284
|
+
}
|
|
1285
|
+
const patch = changeDisplayPatch(match);
|
|
1286
|
+
if (recordPatchOmitted(match, patch)) return { ...filePatchReply(path, ''), ...patchOmittedFile({}) };
|
|
1287
|
+
return filePatchReply(path, patch);
|
|
1288
|
+
}
|
|
1289
|
+
|
|
1290
|
+
/** `value` as findChangeFile reads it: relative to the record's root. */
|
|
1291
|
+
function relativeRecordPath(record, value) {
|
|
1292
|
+
const target = clean(value).replace(/\\/g, '/');
|
|
1293
|
+
return isAbsolute(target) && record?.root ? relative(record.root, resolve(target)).replace(/\\/g, '/') : target;
|
|
1294
|
+
}
|
|
1295
|
+
|
|
1073
1296
|
function assertCheckpointMatches(expectedCheckpointId, actualCheckpointId) {
|
|
1074
1297
|
const expected = clean(expectedCheckpointId);
|
|
1075
1298
|
if (!expected) return;
|
|
@@ -1080,63 +1303,38 @@ function assertCheckpointMatches(expectedCheckpointId, actualCheckpointId) {
|
|
|
1080
1303
|
}
|
|
1081
1304
|
}
|
|
1082
1305
|
|
|
1083
|
-
/**
|
|
1084
|
-
|
|
1085
|
-
const resumed = await resumeRecordedSnapshot(ownerSessionId);
|
|
1086
|
-
if (!resumed) return null;
|
|
1087
|
-
assertCheckpointMatches(expectedCheckpointId, resumed.record.checkpointId);
|
|
1088
|
-
const owned = resumed.record.toolFiles;
|
|
1089
|
-
const targets = file ? owned.filter((path) => recordedPathMatchesFile(path, resumed.record.root, file)) : owned;
|
|
1090
|
-
if (targets.length === 0) {
|
|
1091
|
-
throw new Error('turn review revert cannot attribute this file to the session');
|
|
1092
|
-
}
|
|
1093
|
-
const paths = await revertTurnWorktreePaths(resumed.snapshot, targets);
|
|
1094
|
-
return { checkpointId: clean(resumed.record.checkpointId), paths };
|
|
1095
|
-
}
|
|
1096
|
-
|
|
1097
|
-
/** Return the authoritative Lead net diff plus attributed child reviews. */
|
|
1306
|
+
/** Return the Lead turn's own-file diff plus attributed child reviews, and
|
|
1307
|
+
* `historyTurns`: the retained turn records clearSessionChangeHistory deletes. */
|
|
1098
1308
|
export async function getTurnReviewDiff(_worktree, sessionId, options = {}) {
|
|
1099
|
-
if (DISABLED) return { supported: false, files: [], patch: '', agents: [] };
|
|
1309
|
+
if (DISABLED) return { supported: false, files: [], patch: '', agents: [], historyTurns: 0 };
|
|
1100
1310
|
const ownerSessionId = clean(sessionId);
|
|
1311
|
+
const review = await readTurnReview(ownerSessionId, options);
|
|
1312
|
+
const records = await loadTurnChangeRecords(ownerSessionId).catch(() => []);
|
|
1313
|
+
return { ...review, historyTurns: records.length };
|
|
1314
|
+
}
|
|
1315
|
+
|
|
1316
|
+
async function readTurnReview(ownerSessionId, options) {
|
|
1101
1317
|
const turn = _turnsBySession.get(ownerSessionId);
|
|
1102
1318
|
const tracker = _diffTrackersBySession.get(ownerSessionId);
|
|
1103
|
-
|
|
1104
|
-
|
|
1105
|
-
// Only the review bar's interval timer sends `poll: 'background'`; an
|
|
1106
|
-
// open review (asking for its patch, no `summary`) keeps a short gap.
|
|
1107
|
-
// Every other read (opening the review, a manual or tool-boundary
|
|
1108
|
-
// refresh, a client that sends no `poll`) captures the worktree now.
|
|
1109
|
-
const refreshOptions = options.poll === 'background' ? { visible: options.summary !== true } : { force: true };
|
|
1110
|
-
try {
|
|
1111
|
-
await refreshTurnWorktreeSnapshot(isolatedWorktreeSnapshot, refreshOptions);
|
|
1112
|
-
} catch {
|
|
1113
|
-
// Best-effort refresh: the review reads the last captured state.
|
|
1114
|
-
}
|
|
1115
|
-
}
|
|
1116
|
-
const snapshot = isolatedWorktreeSnapshot;
|
|
1117
|
-
// Without a usable baseline the review falls back to this session's own
|
|
1118
|
-
// mutations — including a worktree that has no Git repository at all, which
|
|
1119
|
-
// used to leave the bar with no revert mode whatsoever.
|
|
1120
|
-
const trackedRevertAvailable = !snapshot && tracker?.valid === true && trackedPairs(tracker).length > 0;
|
|
1121
|
-
if (!snapshot && !trackedRevertAvailable) {
|
|
1122
|
-
// Both in-memory sources are gone: the next turn replaced the tracker, the
|
|
1123
|
-
// turn cache evicted it, or the runtime restarted. The recorded baseline is
|
|
1124
|
-
// still on disk and still exact. Rebuilt per read and then discarded, so a
|
|
1125
|
-
// summary read skips its patch text instead of dropping it afterwards.
|
|
1126
|
-
const recorded = await scopedReviewFromRecord(ownerSessionId, { omitPatch: options.summary === true });
|
|
1319
|
+
if (!tracker) {
|
|
1320
|
+
const recorded = await recordedTurnReview(ownerSessionId, { omitPatch: options.summary === true });
|
|
1127
1321
|
if (recorded) return recorded;
|
|
1128
1322
|
}
|
|
1323
|
+
const view = tracker ? trackerView(tracker) : null;
|
|
1324
|
+
const worktree = worktreeSnapshotUsable(tracker);
|
|
1129
1325
|
let revertMode = '';
|
|
1130
|
-
if (
|
|
1131
|
-
else if (
|
|
1326
|
+
if (worktree) revertMode = 'worktree';
|
|
1327
|
+
else if (tracker?.valid && view.files.length) revertMode = 'tracked';
|
|
1328
|
+
const shaped = perFileReview(view?.files || [], view?.patch || '');
|
|
1132
1329
|
return {
|
|
1133
1330
|
supported: true,
|
|
1134
|
-
files:
|
|
1135
|
-
patch:
|
|
1136
|
-
|
|
1331
|
+
files: shaped.files,
|
|
1332
|
+
patch: shaped.patch,
|
|
1333
|
+
...(shaped.diffTooLarge ? { diffTooLarge: true } : {}),
|
|
1334
|
+
snapshotKind: worktree ? 'worktree' : 'tool',
|
|
1137
1335
|
revertMode,
|
|
1138
1336
|
checkpointId: clean(turn?.checkpointId || tracker?.checkpointId),
|
|
1139
|
-
patchTruncated:
|
|
1337
|
+
patchTruncated: false,
|
|
1140
1338
|
authoritative: Boolean(turn && tracker),
|
|
1141
1339
|
agents: publicAgentReviews(ownerSessionId),
|
|
1142
1340
|
...(turn ? { generation: turn.generation } : { reason: 'no-turn' }),
|
|
@@ -1152,51 +1350,101 @@ function scopeMatchesWorktree(scope, worktree, tracker) {
|
|
|
1152
1350
|
return requested === root || requested.startsWith(`${root}${process.platform === 'win32' ? '\\' : '/'}`);
|
|
1153
1351
|
}
|
|
1154
1352
|
|
|
1155
|
-
/** Return the net
|
|
1156
|
-
*
|
|
1157
|
-
*
|
|
1158
|
-
*
|
|
1353
|
+
/** Return the session's net change: inside a Git root, the diff from its
|
|
1354
|
+
* earliest retained baseline tree to the worktree, scoped to the paths it
|
|
1355
|
+
* changed; everywhere else (a non-Git root, files outside the Git root) each
|
|
1356
|
+
* file's FIRST state observed in the session — by the Lead or a worker
|
|
1357
|
+
* attributed to it — against the disk now. Unlike getTurnReviewDiff this
|
|
1358
|
+
* survives user-turn boundaries and has no revert contract; the composer
|
|
1359
|
+
* keeps owning latest-turn Undo. */
|
|
1159
1360
|
export async function getSessionReviewDiff(worktree, sessionId) {
|
|
1160
1361
|
if (DISABLED) return { supported: false, files: [], patch: '' };
|
|
1161
1362
|
const ownerSessionId = clean(sessionId);
|
|
1162
1363
|
if (!ownerSessionId) return { supported: true, files: [], patch: '', authoritative: true };
|
|
1163
|
-
const review = await
|
|
1164
|
-
|
|
1165
|
-
|
|
1166
|
-
|
|
1167
|
-
|
|
1168
|
-
|
|
1169
|
-
|
|
1170
|
-
|
|
1171
|
-
|
|
1364
|
+
const [review, baselines, records] = await Promise.all([
|
|
1365
|
+
baselineSessionReviewDiff(worktree, ownerSessionId),
|
|
1366
|
+
loadSessionBaselines(ownerSessionId).catch(() => null),
|
|
1367
|
+
// Read through the mtime-keyed cache: only files no baseline knows (a
|
|
1368
|
+
// session recorded before baselines existed) come from them.
|
|
1369
|
+
loadTurnChangeRecords(ownerSessionId).catch(() => []),
|
|
1370
|
+
]);
|
|
1371
|
+
return await sessionChangeReview(review, { baselines, records });
|
|
1372
|
+
}
|
|
1373
|
+
|
|
1374
|
+
/** What the session review diffs inside a Git root: the stored scope (or
|
|
1375
|
+
* live tracker) of this worktree, the paths the session owns there, and its
|
|
1376
|
+
* earliest baseline tree with the recorded first states that complete it. */
|
|
1377
|
+
async function sessionReviewScope(worktree, ownerSessionId) {
|
|
1378
|
+
const tracker = _diffTrackersBySession.get(ownerSessionId);
|
|
1379
|
+
const scopes = await loadSessionSnapshotRecords(ownerSessionId).catch(() => []);
|
|
1380
|
+
const stored = scopes.find((scope) => scopeMatchesWorktree(scope, worktree, tracker));
|
|
1381
|
+
const root = stored?.root || tracker?.worktreeSnapshot?.root || clean(worktree);
|
|
1382
|
+
const ownedPaths = new Set(stored?.toolFiles || []);
|
|
1383
|
+
for (const path of ownedPathsUnder(root, tracker?.ownedPaths)) ownedPaths.add(path);
|
|
1384
|
+
const baselineTree = stored?.baselineTree || tracker?.worktreeSnapshot?.baselineTree;
|
|
1385
|
+
const baselineFiles = new Map((stored?.baselineFiles || []).map((entry) => [pathKey(entry.path), entry]));
|
|
1386
|
+
const storedPaths = new Set((stored?.toolFiles || []).map(pathKey));
|
|
1387
|
+
for (const entry of tracker?.worktreeSnapshot?.baselineFiles.values() || []) {
|
|
1388
|
+
if (!storedPaths.has(pathKey(entry.path))) baselineFiles.set(pathKey(entry.path), entry);
|
|
1389
|
+
}
|
|
1390
|
+
return { tracker, stored, root, ownedPaths, baselineTree, baselineFiles: [...baselineFiles.values()] };
|
|
1172
1391
|
}
|
|
1173
1392
|
|
|
1174
|
-
|
|
1175
|
-
|
|
1393
|
+
/** `value` (absolute, or relative to `root`) as a path relative to `root`;
|
|
1394
|
+
* null outside it. */
|
|
1395
|
+
function relativeInside(root, value) {
|
|
1396
|
+
const target = clean(value);
|
|
1397
|
+
if (!root || !target) return null;
|
|
1398
|
+
const rel = relative(resolve(root), isAbsolute(target) ? resolve(target) : resolve(root, target));
|
|
1399
|
+
if (!rel || isAbsolute(rel) || rel.split(/[\\/]+/).includes('..')) return null;
|
|
1400
|
+
return rel.replace(/\\/g, '/');
|
|
1401
|
+
}
|
|
1176
1402
|
|
|
1177
|
-
/**
|
|
1178
|
-
*
|
|
1179
|
-
|
|
1180
|
-
|
|
1181
|
-
|
|
1182
|
-
|
|
1183
|
-
|
|
1403
|
+
/** One file of the session review with its whole patch, up to the file-patch
|
|
1404
|
+
* read's cap: the file a reply listed `patchOmitted`. `file` is the listed
|
|
1405
|
+
* file's `id` or path, `oldPath` a rename's source. Inside the Git root it is
|
|
1406
|
+
* the session's baseline tree against the worktree now for that path alone;
|
|
1407
|
+
* elsewhere its first-seen state measured against the disk now. */
|
|
1408
|
+
export async function getSessionReviewFilePatch(worktree, sessionId, file, oldPath = '') {
|
|
1409
|
+
const path = clean(file).replace(/\\/g, '/');
|
|
1410
|
+
if (DISABLED) return { supported: false, path, patch: '' };
|
|
1411
|
+
const ownerSessionId = clean(sessionId);
|
|
1412
|
+
if (!ownerSessionId || !path) return { supported: true, path, patch: '' };
|
|
1413
|
+
const scope = await sessionReviewScope(worktree, ownerSessionId);
|
|
1414
|
+
const rel = scope.baselineTree ? relativeInside(scope.root, path) : null;
|
|
1415
|
+
if (rel !== null) {
|
|
1416
|
+
// Each target is authorized on its own: an unowned file is never read,
|
|
1417
|
+
// an unowned rename source never joins its diff.
|
|
1418
|
+
const owned = new Set([...scope.ownedPaths].map(pathKey));
|
|
1419
|
+
if (!owned.has(pathKey(rel))) return { supported: true, path, patch: '' };
|
|
1420
|
+
const source = relativeInside(scope.root, oldPath) || '';
|
|
1421
|
+
const oldRel = source && owned.has(pathKey(source)) ? source : '';
|
|
1422
|
+
const result = await resumeTurnWorktreeFilePatch(scope.root, scope.baselineTree, {
|
|
1423
|
+
paths: [...scope.ownedPaths],
|
|
1424
|
+
baselineFiles: scope.baselineFiles,
|
|
1425
|
+
path: rel,
|
|
1426
|
+
oldPath: oldRel,
|
|
1427
|
+
maxBytes: REVIEW_FILE_PATCH_SHOW_MAX_BYTES,
|
|
1428
|
+
}).catch(() => null);
|
|
1429
|
+
if (result) return { supported: true, path, ...result };
|
|
1184
1430
|
}
|
|
1185
|
-
const
|
|
1186
|
-
|
|
1187
|
-
|
|
1188
|
-
|
|
1189
|
-
|
|
1190
|
-
|
|
1191
|
-
|
|
1192
|
-
|
|
1431
|
+
const [base, baselines, records] = await Promise.all([
|
|
1432
|
+
// Beside a Git baseline a file outside its root is measured from its
|
|
1433
|
+
// first state; the Git side's own files need no diff for that.
|
|
1434
|
+
rel === null && scope.baselineTree && scope.root
|
|
1435
|
+
? { supported: true, root: scope.root, files: [], patch: '', source: 'git' }
|
|
1436
|
+
: baselineSessionReviewDiff(worktree, ownerSessionId),
|
|
1437
|
+
loadSessionBaselines(ownerSessionId).catch(() => null),
|
|
1438
|
+
loadTurnChangeRecords(ownerSessionId).catch(() => []),
|
|
1439
|
+
]);
|
|
1440
|
+
return await sessionChangeReview(base, { baselines, records, file: path });
|
|
1193
1441
|
}
|
|
1194
1442
|
|
|
1195
1443
|
async function baselineSessionReviewDiff(worktree, ownerSessionId) {
|
|
1196
|
-
const tracker =
|
|
1197
|
-
|
|
1198
|
-
|
|
1199
|
-
|
|
1444
|
+
const { tracker, stored, root, ownedPaths, baselineTree, baselineFiles } = await sessionReviewScope(
|
|
1445
|
+
worktree,
|
|
1446
|
+
ownerSessionId
|
|
1447
|
+
);
|
|
1200
1448
|
// A running turn is still mutating the worktree: shell, office and script
|
|
1201
1449
|
// edits only reach the checkpoint diff once it is refreshed, exactly as the
|
|
1202
1450
|
// turn review bar does.
|
|
@@ -1207,12 +1455,11 @@ async function baselineSessionReviewDiff(worktree, ownerSessionId) {
|
|
|
1207
1455
|
// Best-effort refresh: the checkpoint diff uses the last captured state.
|
|
1208
1456
|
}
|
|
1209
1457
|
}
|
|
1210
|
-
const ownedPaths = new Set(stored?.toolFiles || []);
|
|
1211
|
-
for (const path of sessionAttributedPaths(root, tracker)) ownedPaths.add(path);
|
|
1212
1458
|
if (ownedPaths.size === 0) {
|
|
1213
1459
|
// Authoritatively empty only when the session left ownership metadata
|
|
1214
|
-
// behind (a stored scope or a
|
|
1215
|
-
// change records are the only memory of what it changed.
|
|
1460
|
+
// behind (a stored scope, a live tracker, or a deleted history); with
|
|
1461
|
+
// none, the retained change records are the only memory of what it changed.
|
|
1462
|
+
const known = Boolean(stored || tracker) || (await sessionChangeHistoryCleared(ownerSessionId).catch(() => false));
|
|
1216
1463
|
return {
|
|
1217
1464
|
supported: true,
|
|
1218
1465
|
root,
|
|
@@ -1220,19 +1467,13 @@ async function baselineSessionReviewDiff(worktree, ownerSessionId) {
|
|
|
1220
1467
|
patch: '',
|
|
1221
1468
|
snapshotKind: 'session',
|
|
1222
1469
|
authoritative: true,
|
|
1223
|
-
source:
|
|
1470
|
+
source: known ? 'empty' : 'absent',
|
|
1224
1471
|
};
|
|
1225
1472
|
}
|
|
1226
|
-
const baselineTree = stored?.baselineTree || tracker?.worktreeSnapshot?.baselineTree;
|
|
1227
1473
|
if (root && baselineTree) {
|
|
1228
|
-
const baselineFiles = new Map((stored?.baselineFiles || []).map((entry) => [pathKey(entry.path), entry]));
|
|
1229
|
-
const storedPaths = new Set((stored?.toolFiles || []).map(pathKey));
|
|
1230
|
-
for (const entry of tracker?.worktreeSnapshot?.baselineFiles.values() || []) {
|
|
1231
|
-
if (!storedPaths.has(pathKey(entry.path))) baselineFiles.set(pathKey(entry.path), entry);
|
|
1232
|
-
}
|
|
1233
1474
|
const snapshot = await resumeTurnWorktreeSnapshot(root, baselineTree, {
|
|
1234
1475
|
paths: [...ownedPaths],
|
|
1235
|
-
baselineFiles
|
|
1476
|
+
baselineFiles,
|
|
1236
1477
|
}).catch(() => null);
|
|
1237
1478
|
if (snapshot) {
|
|
1238
1479
|
return {
|
|
@@ -1250,74 +1491,131 @@ async function baselineSessionReviewDiff(worktree, ownerSessionId) {
|
|
|
1250
1491
|
// A non-Git first turn still has an exact apply_patch tracker. It cannot
|
|
1251
1492
|
// cross a runtime restart, but remains preferable to widening into unrelated
|
|
1252
1493
|
// files from the surrounding directory.
|
|
1494
|
+
// `owned` lets the session review flag an owned file no other source knows.
|
|
1253
1495
|
if (tracker?.valid) {
|
|
1254
1496
|
return {
|
|
1255
1497
|
supported: true,
|
|
1498
|
+
root,
|
|
1256
1499
|
files: [],
|
|
1257
|
-
patch:
|
|
1258
|
-
patchTruncated: tracker.patchTruncated === true,
|
|
1500
|
+
patch: '',
|
|
1259
1501
|
snapshotKind: 'session-tool',
|
|
1260
1502
|
authoritative: true,
|
|
1261
1503
|
source: 'tool',
|
|
1504
|
+
owned: [...ownedPaths],
|
|
1262
1505
|
};
|
|
1263
1506
|
}
|
|
1264
1507
|
return {
|
|
1265
1508
|
supported: false,
|
|
1509
|
+
root,
|
|
1266
1510
|
files: [],
|
|
1267
1511
|
patch: '',
|
|
1268
1512
|
snapshotKind: 'session',
|
|
1269
1513
|
authoritative: false,
|
|
1270
1514
|
reason: 'session baseline is unavailable',
|
|
1271
1515
|
source: 'unavailable',
|
|
1516
|
+
owned: [...ownedPaths],
|
|
1272
1517
|
};
|
|
1273
1518
|
}
|
|
1274
1519
|
|
|
1275
|
-
|
|
1276
|
-
function unavailableTrackedRevertReason(tracker) {
|
|
1277
|
-
return tracker.worktreeContended
|
|
1278
|
-
? 'turn review revert is unavailable while sessions share a worktree'
|
|
1279
|
-
: 'turn review tracked file snapshot is unavailable';
|
|
1280
|
-
}
|
|
1520
|
+
const UNATTRIBUTED_FILE = 'turn review revert cannot attribute this file to the session';
|
|
1281
1521
|
|
|
1282
|
-
/**
|
|
1283
|
-
*
|
|
1284
|
-
*
|
|
1285
|
-
*
|
|
1286
|
-
|
|
1522
|
+
/** Reverse-apply the latest turn's own changes — every file its own tools
|
|
1523
|
+
* wrote (`file` null) or the own file the user picked. A sealed turn undoes
|
|
1524
|
+
* its change record (state included); a running one its tracker's own
|
|
1525
|
+
* segments. Resolves to the turn's checkpoint, the absolute paths touched and
|
|
1526
|
+
* whether the record already carries the new state. */
|
|
1527
|
+
async function revertLatestTurnUnlocked(worktree, ownerSessionId, file, expectedCheckpointId) {
|
|
1287
1528
|
const tracker = _diffTrackersBySession.get(ownerSessionId);
|
|
1288
1529
|
if (tracker) assertCheckpointMatches(expectedCheckpointId, tracker.checkpointId);
|
|
1289
|
-
|
|
1290
|
-
|
|
1291
|
-
|
|
1292
|
-
const
|
|
1293
|
-
if (
|
|
1294
|
-
|
|
1295
|
-
if (!whole) {
|
|
1296
|
-
const pair = pairs.find((entry) => pairMatchesFile(entry, worktree, file));
|
|
1297
|
-
if (!pair) throw new Error('turn review tracked file snapshot is unavailable');
|
|
1298
|
-
selected = [pair];
|
|
1299
|
-
}
|
|
1300
|
-
return restored(await revertTrackedPairs(tracker, worktree, selected));
|
|
1301
|
-
}
|
|
1302
|
-
const recorded = await revertFromRecord(ownerSessionId, recordFile, expectedCheckpointId);
|
|
1303
|
-
if (recorded) return recorded;
|
|
1304
|
-
throw new Error(unavailableTrackedRevertReason(tracker));
|
|
1530
|
+
if (!tracker || tracker.sealed) {
|
|
1531
|
+
const checkpointId = clean(tracker?.checkpointId) || (await latestRecordedCheckpoint(ownerSessionId));
|
|
1532
|
+
if (!tracker) assertCheckpointMatches(expectedCheckpointId, checkpointId);
|
|
1533
|
+
const record = checkpointId ? await loadTurnChangeRecord(ownerSessionId, checkpointId) : null;
|
|
1534
|
+
if (record) return await revertRecordedTurn(ownerSessionId, withUnknownChangeStates(ownerSessionId, record), file);
|
|
1535
|
+
if (!tracker) throw new Error('turn worktree snapshot is unavailable');
|
|
1305
1536
|
}
|
|
1306
|
-
|
|
1307
|
-
|
|
1308
|
-
|
|
1309
|
-
|
|
1310
|
-
|
|
1537
|
+
const paths = await revertTrackedTurn(tracker, worktree, file);
|
|
1538
|
+
return { checkpointId: clean(tracker.checkpointId), paths, recorded: false };
|
|
1539
|
+
}
|
|
1540
|
+
|
|
1541
|
+
/** The latest turn on record: the stored Git review's, else the newest change record's. */
|
|
1542
|
+
async function latestRecordedCheckpoint(ownerSessionId) {
|
|
1543
|
+
const snapshot = await loadTurnSnapshotRecord(ownerSessionId).catch(() => null);
|
|
1544
|
+
if (clean(snapshot?.checkpointId)) return clean(snapshot.checkpointId);
|
|
1545
|
+
const records = await loadTurnChangeRecords(ownerSessionId).catch(() => []);
|
|
1546
|
+
return clean(records.at(-1)?.checkpointId);
|
|
1547
|
+
}
|
|
1548
|
+
|
|
1549
|
+
const recordFilePaths = (root, files) =>
|
|
1550
|
+
files.flatMap((entry) => [entry.path, entry.oldPath]).filter(Boolean).map((path) => resolve(root, path));
|
|
1551
|
+
|
|
1552
|
+
/** Undo a sealed turn's applied record files (all, or the one named). */
|
|
1553
|
+
async function revertRecordedTurn(ownerSessionId, record, file) {
|
|
1554
|
+
let targets;
|
|
1555
|
+
if (file) {
|
|
1556
|
+
const match = findChangeFile(record, file);
|
|
1557
|
+
if (!match) throw new Error(UNATTRIBUTED_FILE);
|
|
1558
|
+
if (fileState(match) !== 'applied') throw new Error(`${match.path} is already undone`);
|
|
1559
|
+
targets = [match];
|
|
1560
|
+
} else {
|
|
1561
|
+
targets = (record.files || []).filter((entry) => fileState(entry) === 'applied');
|
|
1562
|
+
}
|
|
1563
|
+
if (targets.length > 0) await mutateRecordFiles(ownerSessionId, record, targets, true);
|
|
1564
|
+
return { checkpointId: clean(record.checkpointId), paths: recordFilePaths(record.root, targets), recorded: true };
|
|
1565
|
+
}
|
|
1566
|
+
|
|
1567
|
+
/** Undo a running turn's own segments (every file, or the one matching
|
|
1568
|
+
* `file`), newest first. */
|
|
1569
|
+
async function revertTrackedTurn(tracker, worktree, file) {
|
|
1570
|
+
if (!tracker.valid) throw new Error('turn review tracked file snapshot is unavailable');
|
|
1571
|
+
const patchRoot = trackerPatchRoot(tracker, resolve(clean(worktree) || '.'));
|
|
1572
|
+
const git = patchRoot.kind === 'git';
|
|
1573
|
+
let targets = [...tracker.chains.values()]
|
|
1574
|
+
.map((chain) => ({ chain, edit: chainEdit(chain, patchRoot.root) }))
|
|
1575
|
+
// Beside a Git root, an edit outside it is view-only, never undone.
|
|
1576
|
+
.filter(({ edit }) => edit && (!git || edit.inside));
|
|
1577
|
+
if (file) {
|
|
1578
|
+
const target = targets.find(({ chain }) => chainMatchesPath(chain, patchRoot.root, file));
|
|
1579
|
+
if (!target) throw new Error(UNATTRIBUTED_FILE);
|
|
1580
|
+
targets = [target];
|
|
1581
|
+
}
|
|
1582
|
+
if (targets.length === 0) return [];
|
|
1583
|
+
for (const { edit } of targets) {
|
|
1584
|
+
const omitted = edit.steps.flatMap(({ before, after }) => [before.content, after.content]).find(isOmittedContent);
|
|
1585
|
+
if (!omitted) continue;
|
|
1586
|
+
throw new Error(
|
|
1587
|
+
`turn review revert is unavailable for ${edit.start.displayPath}: binary/large file content was not captured (${omitted.size} bytes)`
|
|
1311
1588
|
);
|
|
1312
1589
|
}
|
|
1313
|
-
const
|
|
1314
|
-
|
|
1315
|
-
|
|
1590
|
+
const edits = targets.map(({ edit }) => edit);
|
|
1591
|
+
const objectFormat = await editsObjectFormat(edits, patchRoot);
|
|
1592
|
+
const budget = createLineDiffBudget();
|
|
1593
|
+
const files = edits.map((edit) => storedChangeFile(editChangeFile(edit, patchRoot.root, objectFormat, budget)));
|
|
1594
|
+
const blocked = files.find((entry) => entry.revertable !== true);
|
|
1595
|
+
if (blocked) throw new Error(`turn review revert is unavailable for ${blocked.path}: ${blocked.reason}`);
|
|
1596
|
+
await applyTurnChangeFiles({ root: patchRoot.root, kind: patchRoot.kind, counts: 'own' }, files, {
|
|
1597
|
+
reverse: true,
|
|
1598
|
+
});
|
|
1599
|
+
undoTrackedChains(
|
|
1600
|
+
tracker,
|
|
1601
|
+
targets.map(({ chain }) => chain)
|
|
1602
|
+
);
|
|
1603
|
+
const paths = recordFilePaths(patchRoot.root, files);
|
|
1604
|
+
noteTurnWorktreeChange(paths);
|
|
1605
|
+
if (tracker.worktreeSnapshot) {
|
|
1606
|
+
try {
|
|
1607
|
+
await refreshTurnWorktreeSnapshot(tracker.worktreeSnapshot, { force: true });
|
|
1608
|
+
} catch {
|
|
1609
|
+
// Best-effort refresh: the next review read captures afresh.
|
|
1610
|
+
}
|
|
1611
|
+
}
|
|
1612
|
+
return paths;
|
|
1316
1613
|
}
|
|
1317
1614
|
|
|
1318
|
-
/** Record a composer
|
|
1319
|
-
*
|
|
1320
|
-
*
|
|
1615
|
+
/** Record a running turn's composer Undo in the record an earlier seal of the
|
|
1616
|
+
* same turn left (a completion wake reopened it): a record file whose every
|
|
1617
|
+
* side (a rename's old path too) was undone is `reverted`; every other file
|
|
1618
|
+
* keeps its state. */
|
|
1321
1619
|
async function recordComposerRevert(ownerSessionId, { checkpointId, paths }) {
|
|
1322
1620
|
if (!ownerSessionId || !checkpointId) return;
|
|
1323
1621
|
const record = await loadTurnChangeRecord(ownerSessionId, checkpointId);
|
|
@@ -1343,26 +1641,28 @@ async function recordComposerRevert(ownerSessionId, { checkpointId, paths }) {
|
|
|
1343
1641
|
/** Composer Undo of the latest turn, on the per-session change lock shared
|
|
1344
1642
|
* with historical Undo/Reapply, recorded in the turn's change record so the
|
|
1345
1643
|
* historical view offers Reapply rather than a second Undo. */
|
|
1346
|
-
function revertLatestTurn(worktree, sessionId, file,
|
|
1644
|
+
function revertLatestTurn(worktree, sessionId, file, expectedCheckpointId) {
|
|
1347
1645
|
const ownerSessionId = clean(sessionId);
|
|
1348
1646
|
return serializeChangeMutation(ownerSessionId, async () => {
|
|
1349
|
-
// A seal still freezing would record the pre-restore
|
|
1647
|
+
// A seal still freezing would record the pre-restore state after this.
|
|
1350
1648
|
const freezing = _diffTrackersBySession.get(ownerSessionId)?.freezing;
|
|
1351
1649
|
if (freezing) await freezing.catch(() => {});
|
|
1352
|
-
const restored = await revertLatestTurnUnlocked(worktree, ownerSessionId, file,
|
|
1353
|
-
await recordComposerRevert(ownerSessionId, restored);
|
|
1650
|
+
const restored = await revertLatestTurnUnlocked(worktree, ownerSessionId, file, expectedCheckpointId);
|
|
1651
|
+
if (!restored.recorded) await recordComposerRevert(ownerSessionId, restored);
|
|
1354
1652
|
return await getTurnReviewDiff(worktree, ownerSessionId);
|
|
1355
1653
|
});
|
|
1356
1654
|
}
|
|
1357
1655
|
|
|
1358
|
-
/**
|
|
1656
|
+
/** Undo this turn's own changes to one reviewed file (never a restore to HEAD
|
|
1657
|
+
* or to the turn-start bytes: foreign edits outside its hunks stay). */
|
|
1359
1658
|
export function revertTurnReviewFile(worktree, sessionId, file, expectedCheckpointId = '') {
|
|
1360
|
-
return
|
|
1659
|
+
if (!clean(file)) return Promise.reject(new TypeError('turn review file path is required'));
|
|
1660
|
+
return revertLatestTurn(worktree, sessionId, file, expectedCheckpointId);
|
|
1361
1661
|
}
|
|
1362
1662
|
|
|
1363
|
-
/**
|
|
1663
|
+
/** Undo this turn's own changes to every file its tools wrote. */
|
|
1364
1664
|
export function revertTurnReview(worktree, sessionId, expectedCheckpointId = '') {
|
|
1365
|
-
return revertLatestTurn(worktree, sessionId, null,
|
|
1665
|
+
return revertLatestTurn(worktree, sessionId, null, expectedCheckpointId);
|
|
1366
1666
|
}
|
|
1367
1667
|
|
|
1368
1668
|
// ── Historical turn changes (per-turn Undo/Reapply) ─────────────────────────
|
|
@@ -1401,9 +1701,10 @@ export async function getTurnChanges(sessionId, options = {}) {
|
|
|
1401
1701
|
// An old record's outside-root file kept no counts: measure it so the card
|
|
1402
1702
|
// agrees with the session review and the island.
|
|
1403
1703
|
if (record?.files?.length) {
|
|
1704
|
+
const budget = createLineDiffBudget();
|
|
1404
1705
|
record = {
|
|
1405
1706
|
...record,
|
|
1406
|
-
files: await Promise.all(record.files.map((file) => measureLegacyChangeFile(file, record.root))),
|
|
1707
|
+
files: await Promise.all(record.files.map((file) => measureLegacyChangeFile(file, record.root, budget))),
|
|
1407
1708
|
};
|
|
1408
1709
|
}
|
|
1409
1710
|
let status = 'missing';
|
|
@@ -1471,88 +1772,68 @@ function withUnknownChangeStates(ownerSessionId, record) {
|
|
|
1471
1772
|
return keys ? { ...record, files: markUnknownStates(record.files, keys) } : record;
|
|
1472
1773
|
}
|
|
1473
1774
|
|
|
1474
|
-
/**
|
|
1475
|
-
* Undo/Reapply
|
|
1476
|
-
*
|
|
1477
|
-
*
|
|
1478
|
-
|
|
1775
|
+
/** Bring the latest-turn review (composer bar, session Diff) in line with an
|
|
1776
|
+
* Undo/Reapply that just landed: signal the moved paths, fold the record's
|
|
1777
|
+
* own chains out of (or back into) the turn's tracker when it is that turn,
|
|
1778
|
+
* re-capture the Git snapshot (sealed ones too) and persist its
|
|
1779
|
+
* attribution. Best-effort: the mutation itself already landed. */
|
|
1780
|
+
async function refreshLatestTurnReview(ownerSessionId, record, targets, reverse) {
|
|
1781
|
+
noteTurnWorktreeChange(recordFilePaths(record.root, targets));
|
|
1479
1782
|
const tracker = _diffTrackersBySession.get(ownerSessionId);
|
|
1480
|
-
if (!tracker
|
|
1481
|
-
|
|
1482
|
-
const
|
|
1483
|
-
|
|
1484
|
-
|
|
1485
|
-
|
|
1486
|
-
|
|
1487
|
-
|
|
1488
|
-
|
|
1489
|
-
|
|
1490
|
-
if (inside(move.source) && inside(move.destination)) moves.push(move);
|
|
1783
|
+
if (!tracker) return;
|
|
1784
|
+
if (tracker.valid && clean(tracker.checkpointId) === clean(record.checkpointId)) {
|
|
1785
|
+
const root = resolve(record.root);
|
|
1786
|
+
if (reverse) {
|
|
1787
|
+
const matches = (chain) =>
|
|
1788
|
+
targets.some((file) => [file.path, file.oldPath].some((path) => chainMatchesPath(chain, root, path)));
|
|
1789
|
+
undoTrackedChains(tracker, [...tracker.chains.values()].filter(matches));
|
|
1790
|
+
} else {
|
|
1791
|
+
redoTrackedChains(tracker, root, targets);
|
|
1792
|
+
}
|
|
1491
1793
|
}
|
|
1492
|
-
if (
|
|
1794
|
+
if (!tracker.worktreeSnapshot) return;
|
|
1493
1795
|
try {
|
|
1494
|
-
|
|
1796
|
+
await refreshTurnWorktreeSnapshot(tracker.worktreeSnapshot, { force: true });
|
|
1495
1797
|
} catch {
|
|
1496
|
-
|
|
1798
|
+
// Best-effort refresh: persist the last captured state.
|
|
1497
1799
|
}
|
|
1498
|
-
|
|
1499
|
-
}
|
|
1500
|
-
|
|
1501
|
-
/** One moved file as a tracked change: its pre-mutation bytes and what the
|
|
1502
|
-
* mutation left at its destination. */
|
|
1503
|
-
async function movedFileChange(root, move) {
|
|
1504
|
-
const change = {
|
|
1505
|
-
path: move.source,
|
|
1506
|
-
displayPath: trackedRelativePath(root, { path: move.source }),
|
|
1507
|
-
before: move.before,
|
|
1508
|
-
after: await currentTrackedContent(move.destination),
|
|
1509
|
-
};
|
|
1510
|
-
if (move.destination === move.source) return change;
|
|
1511
|
-
return {
|
|
1512
|
-
...change,
|
|
1513
|
-
newPath: move.destination,
|
|
1514
|
-
newDisplayPath: trackedRelativePath(root, { path: move.destination }),
|
|
1515
|
-
};
|
|
1800
|
+
if (tracker.sealed) await persistTurnSnapshotRecord(ownerSessionId, tracker);
|
|
1516
1801
|
}
|
|
1517
1802
|
|
|
1518
|
-
/**
|
|
1519
|
-
*
|
|
1520
|
-
*
|
|
1521
|
-
|
|
1522
|
-
|
|
1523
|
-
|
|
1524
|
-
|
|
1525
|
-
|
|
1526
|
-
|
|
1527
|
-
|
|
1528
|
-
|
|
1529
|
-
tracker.valid = false;
|
|
1530
|
-
releaseDiffTrackerContent(tracker);
|
|
1531
|
-
refreshUnifiedDiff(tracker);
|
|
1803
|
+
/** Reverse-apply (`reverse`) or forward-apply the record files `targets`
|
|
1804
|
+
* against the current disk, all-or-nothing: `git apply` checks every hunk
|
|
1805
|
+
* (every binary file's exact bytes) before it writes, so an overlapping
|
|
1806
|
+
* foreign edit fails the whole call and nothing is overwritten. Then their
|
|
1807
|
+
* state is recorded. Resolves to the updated record. */
|
|
1808
|
+
async function mutateRecordFiles(ownerSessionId, record, targets, reverse) {
|
|
1809
|
+
const verb = reverse ? 'undone' : 'reapplied';
|
|
1810
|
+
const checkpointId = clean(record.checkpointId);
|
|
1811
|
+
const blocked = targets.find((entry) => entry.revertable !== true);
|
|
1812
|
+
if (blocked) {
|
|
1813
|
+
throw new Error(`${blocked.path} cannot be ${verb}: ${blocked.reason || 'no replayable patch was recorded'}`);
|
|
1532
1814
|
}
|
|
1533
|
-
}
|
|
1534
|
-
|
|
1535
|
-
|
|
1536
|
-
|
|
1537
|
-
|
|
1538
|
-
* too) and persist its attribution. Best-effort: the mutation itself
|
|
1539
|
-
* already landed. */
|
|
1540
|
-
async function refreshLatestTurnReview(ownerSessionId, root, targets, fold) {
|
|
1541
|
-
noteTurnWorktreeChange(
|
|
1542
|
-
targets
|
|
1543
|
-
.flatMap((entry) => [entry.path, entry.oldPath])
|
|
1544
|
-
.filter(Boolean)
|
|
1545
|
-
.map((path) => resolve(root, path))
|
|
1546
|
-
);
|
|
1547
|
-
if (fold?.tracker.valid) await foldReviewMutation(fold);
|
|
1548
|
-
const tracker = _diffTrackersBySession.get(ownerSessionId);
|
|
1549
|
-
if (!tracker?.worktreeSnapshot) return;
|
|
1815
|
+
await applyTurnChangeFiles(record, targets, { reverse });
|
|
1816
|
+
// State lands only after git applied every target; a state that cannot
|
|
1817
|
+
// land takes the worktree change back with it.
|
|
1818
|
+
const keys = new Set(targets.map((entry) => changePathKey(entry.path)));
|
|
1819
|
+
let updated;
|
|
1550
1820
|
try {
|
|
1551
|
-
await
|
|
1552
|
-
} catch {
|
|
1553
|
-
|
|
1821
|
+
updated = await writeTurnChangeState(ownerSessionId, checkpointId, keys, reverse ? 'reverted' : 'applied');
|
|
1822
|
+
} catch (error) {
|
|
1823
|
+
try {
|
|
1824
|
+
await applyTurnChangeFiles(record, targets, { reverse: !reverse });
|
|
1825
|
+
} catch (rollbackError) {
|
|
1826
|
+
await markTurnChangeStateUnknown(ownerSessionId, checkpointId, keys);
|
|
1827
|
+
await refreshLatestTurnReview(ownerSessionId, record, targets, reverse);
|
|
1828
|
+
throw new Error(
|
|
1829
|
+
`turn changes were ${verb} but could not be recorded (${error.message}), and restoring the worktree failed (${rollbackError.message}); Undo/Reapply of those files is disabled`,
|
|
1830
|
+
{ cause: error }
|
|
1831
|
+
);
|
|
1832
|
+
}
|
|
1833
|
+
throw error;
|
|
1554
1834
|
}
|
|
1555
|
-
|
|
1835
|
+
await refreshLatestTurnReview(ownerSessionId, record, targets, reverse);
|
|
1836
|
+
return updated;
|
|
1556
1837
|
}
|
|
1557
1838
|
|
|
1558
1839
|
/** Undo (`reverse`) or Reapply one file or the whole turn, all-or-nothing. */
|
|
@@ -1581,32 +1862,7 @@ async function mutateTurnChanges(sessionId, checkpointIdValue, fileValue, revisi
|
|
|
1581
1862
|
targets = (record.files || []).filter((entry) => fileState(entry) === from);
|
|
1582
1863
|
if (targets.length === 0) throw new Error(`this turn has no changes to be ${verb}`);
|
|
1583
1864
|
}
|
|
1584
|
-
const
|
|
1585
|
-
if (blocked) {
|
|
1586
|
-
throw new Error(`${blocked.path} cannot be ${verb}: ${blocked.reason || 'no replayable patch was recorded'}`);
|
|
1587
|
-
}
|
|
1588
|
-
const fold = await trackedReviewFold(ownerSessionId, record.root, targets, reverse);
|
|
1589
|
-
const root = await applyTurnChangeFiles(record, targets, { reverse });
|
|
1590
|
-
// State lands only after git applied every target; a state that cannot
|
|
1591
|
-
// land takes the worktree change back with it.
|
|
1592
|
-
const keys = new Set(targets.map((entry) => changePathKey(entry.path)));
|
|
1593
|
-
let updated;
|
|
1594
|
-
try {
|
|
1595
|
-
updated = await writeTurnChangeState(ownerSessionId, checkpointId, keys, reverse ? 'reverted' : 'applied');
|
|
1596
|
-
} catch (error) {
|
|
1597
|
-
try {
|
|
1598
|
-
await applyTurnChangeFiles(record, targets, { reverse: !reverse });
|
|
1599
|
-
} catch (rollbackError) {
|
|
1600
|
-
await markTurnChangeStateUnknown(ownerSessionId, checkpointId, keys);
|
|
1601
|
-
await refreshLatestTurnReview(ownerSessionId, root, targets, fold);
|
|
1602
|
-
throw new Error(
|
|
1603
|
-
`turn changes were ${verb} but could not be recorded (${error.message}), and restoring the worktree failed (${rollbackError.message}); Undo/Reapply of those files is disabled`,
|
|
1604
|
-
{ cause: error }
|
|
1605
|
-
);
|
|
1606
|
-
}
|
|
1607
|
-
throw error;
|
|
1608
|
-
}
|
|
1609
|
-
await refreshLatestTurnReview(ownerSessionId, root, targets, fold);
|
|
1865
|
+
const updated = await mutateRecordFiles(ownerSessionId, record, targets, reverse);
|
|
1610
1866
|
return turnChangesReply({
|
|
1611
1867
|
status: updated ? 'ready' : 'missing',
|
|
1612
1868
|
checkpointId,
|
|
@@ -1625,6 +1881,45 @@ export function reapplyTurnChanges(sessionId, checkpointId, file, revision) {
|
|
|
1625
1881
|
return mutateTurnChanges(sessionId, checkpointId, file, revision, false);
|
|
1626
1882
|
}
|
|
1627
1883
|
|
|
1884
|
+
/** Permanently delete this session's retained change history — every turn's
|
|
1885
|
+
* change record (patches, file metadata, applied/reverted state), its stored
|
|
1886
|
+
* Git baselines, and the live and latest-turn trackers that would resurface
|
|
1887
|
+
* them — so its reviews answer empty, across a restart too. Edits from the
|
|
1888
|
+
* next turn on are tracked again. Refused while a turn runs or seals; on the
|
|
1889
|
+
* per-session lock, behind any Undo/Reapply. Files, the Git index, commits
|
|
1890
|
+
* and the conversation are never touched. Resolves to `{ turns }`, the
|
|
1891
|
+
* number of turn records deleted. */
|
|
1892
|
+
export async function clearSessionChangeHistory(sessionId) {
|
|
1893
|
+
const ownerSessionId = clean(sessionId);
|
|
1894
|
+
if (!ownerSessionId) throw new Error('change history is unavailable without a session');
|
|
1895
|
+
return await serializeChangeMutation(ownerSessionId, async () => {
|
|
1896
|
+
if (sessionTurnBusy(ownerSessionId)) {
|
|
1897
|
+
throw new Error('change history cannot be deleted while this session is running a turn');
|
|
1898
|
+
}
|
|
1899
|
+
// Invalidate every producer of the old history BEFORE the awaited
|
|
1900
|
+
// deletion: a worker finishing meanwhile finds no turn to fold into, and
|
|
1901
|
+
// a re-seal it (or anything earlier) already queued finds its tracker
|
|
1902
|
+
// discarded, so nothing begun before the delete can write it back.
|
|
1903
|
+
for (const [trackedSessionId, tracker] of _diffTrackersBySession) {
|
|
1904
|
+
if (trackedSessionId !== ownerSessionId && tracker.ownerSessionId !== ownerSessionId) continue;
|
|
1905
|
+
tracker.discarded = true;
|
|
1906
|
+
tracker.resealQueued = false;
|
|
1907
|
+
releaseDiffTrackerContent(tracker);
|
|
1908
|
+
_diffTrackersBySession.delete(trackedSessionId);
|
|
1909
|
+
}
|
|
1910
|
+
_turnsBySession.delete(ownerSessionId);
|
|
1911
|
+
for (const key of _unknownChangeStates.keys()) {
|
|
1912
|
+
if (key.startsWith(`${ownerSessionId}\0`)) _unknownChangeStates.delete(key);
|
|
1913
|
+
}
|
|
1914
|
+
// The session's first-seen baselines go too: later edits start fresh ones.
|
|
1915
|
+
const [turns] = await Promise.all([
|
|
1916
|
+
clearStoredChangeHistory(ownerSessionId),
|
|
1917
|
+
clearSessionBaselines(ownerSessionId),
|
|
1918
|
+
]);
|
|
1919
|
+
return { turns };
|
|
1920
|
+
});
|
|
1921
|
+
}
|
|
1922
|
+
|
|
1628
1923
|
export function _resetTurnSnapshotForTest() {
|
|
1629
1924
|
_turnsBySession.clear();
|
|
1630
1925
|
_diffTrackersBySession.clear();
|
|
@@ -1634,4 +1929,5 @@ export function _resetTurnSnapshotForTest() {
|
|
|
1634
1929
|
_turnOpenedHookForTest = null;
|
|
1635
1930
|
_agentTurnSeq = 0;
|
|
1636
1931
|
_resetTurnWorktreeSnapshotsForTest();
|
|
1932
|
+
_resetSessionBaselinesForTest();
|
|
1637
1933
|
}
|