mixdog 0.9.63 → 0.9.64
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 +3 -0
- package/package.json +9 -2
- package/scripts/code-graph-description-contract.mjs +3 -3
- package/scripts/verify-release-assets.mjs +2 -2
- package/src/runtime/agent/orchestrator/agent-runtime/agent-dispatch.mjs +11 -14
- package/src/runtime/agent/orchestrator/agent-runtime/agent-progress-watchdog.mjs +6 -6
- package/src/runtime/agent/orchestrator/agent-runtime/cache-strategy.mjs +2 -2
- package/src/runtime/agent/orchestrator/agent-trace-format.mjs +1 -0
- package/src/runtime/agent/orchestrator/config.mjs +19 -124
- package/src/runtime/agent/orchestrator/context/collect.mjs +6 -6
- package/src/runtime/agent/orchestrator/dispatch-persist.mjs +2 -2
- package/src/runtime/agent/orchestrator/internal-agents.mjs +2 -2
- package/src/runtime/agent/orchestrator/mcp/child-tree.mjs +1 -1
- package/src/runtime/agent/orchestrator/mcp/client.mjs +4 -4
- package/src/runtime/agent/orchestrator/providers/admission-scheduler.mjs +2 -2
- package/src/runtime/agent/orchestrator/providers/anthropic-betas.mjs +4 -4
- package/src/runtime/agent/orchestrator/providers/anthropic-effort.mjs +2 -2
- package/src/runtime/agent/orchestrator/providers/anthropic-max-tokens.mjs +4 -4
- package/src/runtime/agent/orchestrator/providers/anthropic-messages.mjs +333 -0
- package/src/runtime/agent/orchestrator/providers/anthropic-model-resolve.mjs +2 -2
- package/src/runtime/agent/orchestrator/providers/anthropic-oauth-credentials.mjs +4 -4
- package/src/runtime/agent/orchestrator/providers/anthropic-oauth.mjs +1 -1
- package/src/runtime/agent/orchestrator/providers/anthropic.mjs +3 -277
- package/src/runtime/agent/orchestrator/providers/codex-client-meta.mjs +2 -2
- package/src/runtime/agent/orchestrator/providers/custom-tool-wire.mjs +1 -1
- package/src/runtime/agent/orchestrator/providers/gemini-cache.mjs +2 -2
- package/src/runtime/agent/orchestrator/providers/gemini-stream.mjs +6 -6
- package/src/runtime/agent/orchestrator/providers/grok-oauth-login.mjs +196 -0
- package/src/runtime/agent/orchestrator/providers/grok-oauth-tokens.mjs +460 -0
- package/src/runtime/agent/orchestrator/providers/grok-oauth.mjs +14 -593
- package/src/runtime/agent/orchestrator/providers/lib/anthropic-request-utils.mjs +1 -1
- package/src/runtime/agent/orchestrator/providers/media-normalization.mjs +1 -1
- package/src/runtime/agent/orchestrator/providers/openai-compat-wire.mjs +1 -1
- package/src/runtime/agent/orchestrator/providers/openai-compat-xai.mjs +4 -4
- package/src/runtime/agent/orchestrator/providers/openai-oauth-ws.mjs +2 -2
- package/src/runtime/agent/orchestrator/providers/openai-oauth.mjs +5 -280
- package/src/runtime/agent/orchestrator/providers/openai-responses-payload.mjs +357 -0
- package/src/runtime/agent/orchestrator/providers/openai-ws-headers.mjs +216 -0
- package/src/runtime/agent/orchestrator/providers/openai-ws-pool.mjs +2 -188
- package/src/runtime/agent/orchestrator/providers/openai-ws-stream.mjs +2 -2
- package/src/runtime/agent/orchestrator/providers/openai-ws.mjs +2 -2
- package/src/runtime/agent/orchestrator/providers/opencode-go.mjs +1 -1
- package/src/runtime/agent/orchestrator/providers/provider-catalog-cache.mjs +1 -1
- package/src/runtime/agent/orchestrator/providers/registry.mjs +1 -1
- package/src/runtime/agent/orchestrator/providers/retry-classifier.mjs +2 -2
- package/src/runtime/agent/orchestrator/providers/statusline-route-meta.mjs +1 -1
- package/src/runtime/agent/orchestrator/session/cache/prefetch-cache.mjs +1 -1
- package/src/runtime/agent/orchestrator/session/cache/scoped-cache.mjs +3 -3
- package/src/runtime/agent/orchestrator/session/compact/budget.mjs +2 -2
- package/src/runtime/agent/orchestrator/session/compact/file-reattach.mjs +2 -2
- package/src/runtime/agent/orchestrator/session/compact/summary-schema.mjs +1 -1
- package/src/runtime/agent/orchestrator/session/compact/summary.mjs +1 -1
- package/src/runtime/agent/orchestrator/session/compact/text-utils.mjs +2 -2
- package/src/runtime/agent/orchestrator/session/context-utils.mjs +4 -4
- package/src/runtime/agent/orchestrator/session/loop/deferred-call-through.mjs +1 -1
- package/src/runtime/agent/orchestrator/session/loop/pre-dispatch-deny.mjs +17 -0
- package/src/runtime/agent/orchestrator/session/loop/steering.mjs +1 -1
- package/src/runtime/agent/orchestrator/session/loop/stored-tool-args.mjs +18 -26
- package/src/runtime/agent/orchestrator/session/loop/termination.mjs +1 -1
- package/src/runtime/agent/orchestrator/session/loop/tool-classify.mjs +3 -3
- package/src/runtime/agent/orchestrator/session/manager/compaction-runner.mjs +1 -0
- package/src/runtime/agent/orchestrator/session/manager/context-meta.mjs +1 -1
- package/src/runtime/agent/orchestrator/session/manager/delivered-completions.mjs +1 -1
- package/src/runtime/agent/orchestrator/session/manager/status-telemetry.mjs +1 -1
- package/src/runtime/agent/orchestrator/session/store/live-state.mjs +49 -0
- package/src/runtime/agent/orchestrator/session/store/load-cache.mjs +49 -0
- package/src/runtime/agent/orchestrator/session/store/save-worker.mjs +260 -0
- package/src/runtime/agent/orchestrator/session/store/serialize.mjs +72 -0
- package/src/runtime/agent/orchestrator/session/store/summary-cache.mjs +163 -0
- package/src/runtime/agent/orchestrator/session/store/write-guards.mjs +1 -1
- package/src/runtime/agent/orchestrator/session/store-summary-index.mjs +1 -1
- package/src/runtime/agent/orchestrator/session/store-summary-reader.mjs +4 -1
- package/src/runtime/agent/orchestrator/session/store.mjs +9 -561
- package/src/runtime/agent/orchestrator/session/tool-envelope.mjs +1 -1
- package/src/runtime/agent/orchestrator/session/tool-result-offload.mjs +2 -2
- package/src/runtime/agent/orchestrator/stall-policy.mjs +11 -11
- package/src/runtime/agent/orchestrator/tools/bash-policy-scan.mjs +1 -1
- package/src/runtime/agent/orchestrator/tools/bash-session.mjs +2 -2
- package/src/runtime/agent/orchestrator/tools/builtin/advisory-lock.mjs +1 -1
- package/src/runtime/agent/orchestrator/tools/builtin/arg-guard.mjs +2 -2
- package/src/runtime/agent/orchestrator/tools/builtin/atomic-write.mjs +1 -1
- package/src/runtime/agent/orchestrator/tools/builtin/bash-tool.mjs +2 -154
- package/src/runtime/agent/orchestrator/tools/builtin/cache-layers.mjs +1 -1
- package/src/runtime/agent/orchestrator/tools/builtin/cwd-utils.mjs +1 -1
- package/src/runtime/agent/orchestrator/tools/builtin/fs-reachability.mjs +1 -1
- package/src/runtime/agent/orchestrator/tools/builtin/fuzzy-match.mjs +1 -1
- package/src/runtime/agent/orchestrator/tools/builtin/glob-walk.mjs +1 -1
- package/src/runtime/agent/orchestrator/tools/builtin/path-locks.mjs +1 -1
- package/src/runtime/agent/orchestrator/tools/builtin/path-utils.mjs +1 -1
- package/src/runtime/agent/orchestrator/tools/builtin/read-args.mjs +2 -2
- package/src/runtime/agent/orchestrator/tools/builtin/read-formatting.mjs +2 -3
- package/src/runtime/agent/orchestrator/tools/builtin/read-image-resize.mjs +4 -4
- package/src/runtime/agent/orchestrator/tools/builtin/read-open.mjs +1 -1
- package/src/runtime/agent/orchestrator/tools/builtin/read-streaming.mjs +1 -1
- package/src/runtime/agent/orchestrator/tools/builtin/rg-runner.mjs +2 -2
- package/src/runtime/agent/orchestrator/tools/builtin/search-path-diagnostics.mjs +5 -5
- package/src/runtime/agent/orchestrator/tools/builtin/shell-analysis.mjs +1 -1
- package/src/runtime/agent/orchestrator/tools/builtin/shell-job-paths.mjs +2 -2
- package/src/runtime/agent/orchestrator/tools/builtin/shell-job-process.mjs +2 -2
- package/src/runtime/agent/orchestrator/tools/builtin/shell-job-spawn.mjs +519 -0
- package/src/runtime/agent/orchestrator/tools/builtin/shell-jobs.mjs +5 -449
- package/src/runtime/agent/orchestrator/tools/builtin/snapshot-helpers.mjs +1 -1
- package/src/runtime/agent/orchestrator/tools/builtin/task-tool.mjs +212 -0
- package/src/runtime/agent/orchestrator/tools/builtin.mjs +1 -1
- package/src/runtime/agent/orchestrator/tools/code-graph/build.mjs +1 -7
- package/src/runtime/agent/orchestrator/tools/code-graph/disk-cache.mjs +9 -36
- package/src/runtime/agent/orchestrator/tools/code-graph/dispatch.mjs +15 -3
- package/src/runtime/agent/orchestrator/tools/code-graph/search-references.mjs +429 -0
- package/src/runtime/agent/orchestrator/tools/code-graph/search.mjs +2 -395
- package/src/runtime/agent/orchestrator/tools/code-graph/symbol-index.mjs +2 -2
- package/src/runtime/agent/orchestrator/tools/code-graph/text-mask.mjs +1 -1
- package/src/runtime/agent/orchestrator/tools/code-graph/trusted-roots.mjs +1 -1
- package/src/runtime/agent/orchestrator/tools/code-graph-prewarm-worker.mjs +1 -1
- package/src/runtime/agent/orchestrator/tools/code-graph-state.mjs +3 -3
- package/src/runtime/agent/orchestrator/tools/mutation-planner.mjs +5 -5
- package/src/runtime/agent/orchestrator/tools/next-call-utils.mjs +1 -1
- package/src/runtime/agent/orchestrator/tools/patch/dispatch.mjs +1 -1
- package/src/runtime/agent/orchestrator/tools/patch/matcher.mjs +7 -7
- package/src/runtime/agent/orchestrator/tools/patch/orchestrator.mjs +72 -9
- package/src/runtime/agent/orchestrator/tools/patch/parsing.mjs +4 -4
- package/src/runtime/agent/orchestrator/tools/patch/paths.mjs +1 -1
- package/src/runtime/agent/orchestrator/tools/patch/v4a-convert.mjs +1 -1
- package/src/runtime/agent/orchestrator/tools/patch-tool-defs.mjs +4 -4
- package/src/runtime/agent/orchestrator/tools/patch.mjs +1 -1
- package/src/runtime/agent/orchestrator/tools/result-compression.mjs +3 -3
- package/src/runtime/agent/orchestrator/tools/shell-command.mjs +2 -444
- package/src/runtime/agent/orchestrator/tools/shell-exec-output.mjs +505 -0
- package/src/runtime/agent/orchestrator/tools/shell-exec-policy.mjs +2 -2
- package/src/runtime/agent/orchestrator/tools/shell-snapshot.mjs +1 -1
- package/src/runtime/channels/lib/backend-dispatch.mjs +20 -5
- package/src/runtime/channels/lib/scheduler.mjs +1 -1
- package/src/runtime/channels/lib/telegram-format.mjs +1 -1
- package/src/runtime/channels/lib/voice-runtime-fetcher.mjs +4 -4
- package/src/runtime/channels/tool-defs.mjs +2 -3
- package/src/runtime/memory/index.mjs +1 -5
- package/src/runtime/memory/lib/core-memory-candidates.mjs +357 -0
- package/src/runtime/memory/lib/core-memory-store.mjs +7 -357
- package/src/runtime/memory/lib/cycle-signatures.mjs +1 -1
- package/src/runtime/memory/lib/embedding-model-config.mjs +1 -1
- package/src/runtime/memory/lib/embedding-provider.mjs +1 -5
- package/src/runtime/memory/lib/embedding-worker.mjs +1 -5
- package/src/runtime/memory/lib/ko-morph.mjs +2 -2
- package/src/runtime/memory/lib/memory-config-flags.mjs +2 -5
- package/src/runtime/memory/lib/memory-cycle-requests.mjs +3 -7
- package/src/runtime/memory/lib/memory-cycle1.mjs +4 -8
- package/src/runtime/memory/lib/memory-cycle2-gate.mjs +1 -1
- package/src/runtime/memory/lib/memory-cycle2-mutations.mjs +1 -1
- package/src/runtime/memory/lib/memory-cycle2-shared.mjs +2 -5
- package/src/runtime/memory/lib/memory-cycle3.mjs +1 -5
- package/src/runtime/memory/lib/memory-embed.mjs +2 -6
- package/src/runtime/memory/lib/memory-log.mjs +8 -0
- package/src/runtime/memory/lib/memory-ops-policy.mjs +4 -8
- package/src/runtime/memory/lib/memory-recall-scope-filter.mjs +1 -1
- package/src/runtime/memory/lib/memory-recall-store.mjs +2 -193
- package/src/runtime/memory/lib/memory-score.mjs +1 -1
- package/src/runtime/memory/lib/memory-text-utils.mjs +2 -2
- package/src/runtime/memory/lib/memory.mjs +2 -6
- package/src/runtime/memory/lib/pg/adapter.mjs +2 -6
- package/src/runtime/memory/lib/pg/process.mjs +1 -5
- package/src/runtime/memory/lib/pg/supervisor.mjs +1 -5
- package/src/runtime/memory/lib/recall-format.mjs +2 -2
- package/src/runtime/memory/lib/recall-scoring.mjs +195 -0
- package/src/runtime/memory/lib/runtime-fetcher.mjs +1 -5
- package/src/runtime/memory/lib/trace-store.mjs +2 -6
- package/src/runtime/search/lib/http-fetch.mjs +6 -6
- package/src/runtime/search/lib/web-tools.mjs +2 -2
- package/src/runtime/shared/buffered-appender.mjs +1 -1
- package/src/runtime/shared/child-guardian.mjs +1 -1
- package/src/runtime/shared/child-spawn-gate.mjs +1 -1
- package/src/runtime/shared/config.mjs +4 -4
- package/src/runtime/shared/launcher-control.mjs +9 -10
- package/src/runtime/shared/llm/index.mjs +5 -20
- package/src/runtime/shared/markdown-frontmatter.mjs +1 -1
- package/src/runtime/shared/memory-snapshot.mjs +4 -4
- package/src/runtime/shared/pristine-execution.mjs +1 -1
- package/src/runtime/shared/process-lifecycle.mjs +0 -1
- package/src/runtime/shared/process-shutdown.mjs +1 -1
- package/src/runtime/shared/resource-admission.mjs +2 -2
- package/src/runtime/shared/schedule-session-run.mjs +1 -1
- package/src/runtime/shared/schedules-db.mjs +1 -1
- package/src/runtime/shared/service-discovery.mjs +2 -2
- package/src/runtime/shared/staged-update.mjs +6 -6
- package/src/runtime/shared/tool-execution-contract.mjs +1 -1
- package/src/runtime/shared/tool-primitives.mjs +1 -1
- package/src/runtime/shared/tool-surface.mjs +2 -2
- package/src/runtime/shared/update-checker.mjs +1 -1
- package/src/runtime/shared/user-cwd.mjs +3 -3
- package/src/runtime/shared/user-data-guard.mjs +4 -4
- package/src/runtime/shared/workspace-router.mjs +5 -5
- package/src/session-runtime/config-helpers.mjs +2 -3
- package/src/session-runtime/config-lifecycle.mjs +1 -1
- package/src/session-runtime/hitch-profile.mjs +1 -1
- package/src/session-runtime/lifecycle-api.mjs +6 -4
- package/src/session-runtime/model-recency.mjs +5 -5
- package/src/session-runtime/plugin-mcp.mjs +1 -1
- package/src/session-runtime/provider-request-snapshot.mjs +338 -0
- package/src/session-runtime/session-text.mjs +4 -4
- package/src/session-runtime/tool-catalog-schema.mjs +197 -0
- package/src/session-runtime/tool-catalog.mjs +6 -483
- package/src/session-runtime/tool-defs.mjs +1 -1
- package/src/standalone/agent-task-status.mjs +1 -1
- package/src/standalone/agent-tool/helpers.mjs +2 -2
- package/src/standalone/channel-admin.mjs +4 -4
- package/src/standalone/explore-tool.mjs +3 -3
- package/src/standalone/hook-bus/config.mjs +1 -1
- package/src/standalone/hook-bus/constants.mjs +1 -1
- package/src/standalone/hook-bus/rules.mjs +1 -1
- package/src/standalone/projects.mjs +1 -1
- package/src/standalone/provider-admin.mjs +2 -2
- package/src/tui/app/app-format.mjs +2 -2
- package/src/tui/app/clipboard.mjs +3 -3
- package/src/tui/app/input-parsers.mjs +1 -1
- package/src/tui/app/text-layout.mjs +1 -1
- package/src/tui/app/transcript-row-estimate.mjs +360 -0
- package/src/tui/app/transcript-window.mjs +5 -347
- package/src/tui/app/use-mouse-input.mjs +1 -1
- package/src/tui/app/use-prompt-handlers.mjs +1 -1
- package/src/tui/components/tool-execution/surface-detail.mjs +9 -9
- package/src/tui/components/tool-execution/text-format.mjs +2 -2
- package/src/tui/components/tool-output-format.mjs +7 -7
- package/src/tui/display-width.mjs +4 -4
- package/src/tui/dist/index.mjs +63 -97
- package/src/tui/engine/agent-envelope.mjs +5 -5
- package/src/tui/engine/notification-plan.mjs +1 -1
- package/src/tui/engine/queue-helpers.mjs +3 -3
- package/src/tui/engine/render-timing.mjs +4 -5
- package/src/tui/engine/session-api-ext.mjs +3 -3
- package/src/tui/engine/tool-result-status.mjs +5 -5
- package/src/tui/engine/tool-result-text.mjs +1 -1
- package/src/tui/engine/transcript-spill.mjs +585 -0
- package/src/tui/engine/tui-steering-persist.mjs +1 -1
- package/src/tui/engine.mjs +2 -474
- package/src/tui/lib/voice-setup.mjs +1 -1
- package/src/tui/markdown/format-token.mjs +1 -1
- package/src/tui/markdown/render-ansi.mjs +1 -1
- package/src/tui/markdown/table-layout.mjs +4 -4
- package/src/tui/paste-attachments.mjs +1 -1
- package/src/tui/prompt-history-store.mjs +3 -3
- package/src/tui/statusline-ansi-bridge.mjs +2 -2
- package/src/tui/theme.mjs +1 -1
- package/src/tui/transcript-tool-failures.mjs +2 -2
- package/src/ui/ansi.mjs +0 -9
- package/src/ui/statusline-format.mjs +2 -4
- package/scripts/_bench-cwc.json +0 -20
- package/scripts/_jitter-fuzz.jsx +0 -42
- package/scripts/_jitter-fuzz2.jsx +0 -30
- package/scripts/_jitter-probe.jsx +0 -35
- package/scripts/_jp2.jsx +0 -16
- package/scripts/_smoke_wd.mjs +0 -7
- package/scripts/abort-recovery-test.mjs +0 -175
- package/scripts/agent-dispatch-abort-compose-test.mjs +0 -31
- package/scripts/agent-live-arg-guard-smoke.mjs +0 -20
- package/scripts/agent-live-path-suffix-smoke.mjs +0 -34
- package/scripts/agent-live-toolcall-args-smoke.mjs +0 -45
- package/scripts/agent-loop-policy-test.mjs +0 -37
- package/scripts/agent-model-liveness-test.mjs +0 -763
- package/scripts/agent-parallel-smoke.mjs +0 -435
- package/scripts/agent-route-batch-test.mjs +0 -40
- package/scripts/agent-tag-reuse-smoke.mjs +0 -441
- package/scripts/agent-terminal-reap-test.mjs +0 -252
- package/scripts/agent-trace-io-test.mjs +0 -133
- package/scripts/ansi-color-capability-test.mjs +0 -90
- package/scripts/anthropic-admission-retry-integration-test.mjs +0 -119
- package/scripts/anthropic-maxtokens-test.mjs +0 -119
- package/scripts/anthropic-oauth-refresh-race-test.mjs +0 -397
- package/scripts/anthropic-transport-policy-test.mjs +0 -726
- package/scripts/apply-patch-edit-smoke.mjs +0 -71
- package/scripts/arg-guard-test.mjs +0 -93
- package/scripts/async-notify-settlement-test.mjs +0 -99
- package/scripts/atomic-lock-tryonce-test.mjs +0 -125
- package/scripts/background-task-meta-smoke.mjs +0 -38
- package/scripts/bench-run.mjs +0 -508
- package/scripts/boot-smoke.mjs +0 -137
- package/scripts/build-runtime-windows.ps1 +0 -242
- package/scripts/channel-daemon-smoke.mjs +0 -1103
- package/scripts/code-graph-aggregate-cwd-test.mjs +0 -158
- package/scripts/code-graph-disk-hit-test.mjs +0 -290
- package/scripts/code-graph-dispatch-test.mjs +0 -96
- package/scripts/code-graph-root-federation-test.mjs +0 -273
- package/scripts/compact-active-turn-test.mjs +0 -68
- package/scripts/compact-file-reattach-test.mjs +0 -88
- package/scripts/compact-pressure-test.mjs +0 -788
- package/scripts/compact-prior-context-flatten-test.mjs +0 -252
- package/scripts/compact-recall-digest-test.mjs +0 -57
- package/scripts/compact-smoke.mjs +0 -1017
- package/scripts/compact-trigger-migration-smoke.mjs +0 -261
- package/scripts/compacted-placeholder-scrub-test.mjs +0 -63
- package/scripts/context-mcp-metering-test.mjs +0 -1406
- package/scripts/debounced-skills-async-save-test.mjs +0 -57
- package/scripts/deferred-tool-loading-test.mjs +0 -250
- package/scripts/desktop-session-bridge-test.mjs +0 -1013
- package/scripts/dispatch-persist-recovery-test.mjs +0 -141
- package/scripts/embedding-worker-exit-test.mjs +0 -76
- package/scripts/execution-completion-dedup-test.mjs +0 -205
- package/scripts/execution-pending-resume-kick-test.mjs +0 -151
- package/scripts/execution-resume-esc-integration-test.mjs +0 -176
- package/scripts/explore-bench-tmp.mjs +0 -36
- package/scripts/explore-bench.mjs +0 -248
- package/scripts/explore-prompt-policy-test.mjs +0 -256
- package/scripts/explore-timeout-cancel-test.mjs +0 -345
- package/scripts/find-fuzzy-hidden-test.mjs +0 -267
- package/scripts/forwarder-rebind-tail-test.mjs +0 -67
- package/scripts/freevar-smoke.mjs +0 -98
- package/scripts/gemini-provider-test.mjs +0 -1448
- package/scripts/generate-oc-icons.mjs +0 -11
- package/scripts/grok-oauth-refresh-race-test.mjs +0 -273
- package/scripts/headless-pristine-execution-test.mjs +0 -614
- package/scripts/hook-bus-test.mjs +0 -552
- package/scripts/ingest-pure-conversation-smoke.mjs +0 -175
- package/scripts/internal-comms-bench-test.mjs +0 -226
- package/scripts/internal-comms-bench.mjs +0 -853
- package/scripts/internal-comms-smoke.mjs +0 -277
- package/scripts/internal-tools-normalization-test.mjs +0 -62
- package/scripts/interrupted-turn-history-test.mjs +0 -399
- package/scripts/lead-workflow-smoke.mjs +0 -592
- package/scripts/lifecycle-api-test.mjs +0 -137
- package/scripts/live-share-test.mjs +0 -148
- package/scripts/live-worker-smoke.mjs +0 -333
- package/scripts/log-writer-guard-smoke.mjs +0 -131
- package/scripts/maintenance-default-routes-test.mjs +0 -164
- package/scripts/max-output-recovery-persist-test.mjs +0 -81
- package/scripts/max-output-recovery-test.mjs +0 -368
- package/scripts/mcp-client-normalization-test.mjs +0 -45
- package/scripts/mcp-grace-deferred-test.mjs +0 -225
- package/scripts/memory-core-input-test.mjs +0 -167
- package/scripts/memory-cycle-routing-test.mjs +0 -111
- package/scripts/memory-meta-concurrency-test.mjs +0 -20
- package/scripts/memory-pg-recovery-test.mjs +0 -59
- package/scripts/memory-rule-contract-test.mjs +0 -93
- package/scripts/model-list-sanitize-test.mjs +0 -37
- package/scripts/mouse-tracking-restore-smoke.mjs +0 -107
- package/scripts/native-edit-wire-test.mjs +0 -152
- package/scripts/notify-completion-mirror-test.mjs +0 -73
- package/scripts/openai-oauth-refresh-race-test.mjs +0 -120
- package/scripts/openai-oauth-ws-1006-retry-test.mjs +0 -954
- package/scripts/openai-ws-early-settle-test.mjs +0 -216
- package/scripts/output-style-bench.mjs +0 -292
- package/scripts/output-style-smoke.mjs +0 -145
- package/scripts/parent-abort-link-test.mjs +0 -90
- package/scripts/patch-binary-cache-test.mjs +0 -181
- package/scripts/path-suffix-test.mjs +0 -57
- package/scripts/pending-completion-drop-test.mjs +0 -239
- package/scripts/pending-messages-lock-nonblocking-test.mjs +0 -62
- package/scripts/pretool-ask-runtime-test.mjs +0 -54
- package/scripts/process-lifecycle-test.mjs +0 -461
- package/scripts/prompt-immediate-render-test.mjs +0 -89
- package/scripts/prompt-input-parity-test.mjs +0 -145
- package/scripts/provider-admission-scheduler-test.mjs +0 -681
- package/scripts/provider-contract-test.mjs +0 -632
- package/scripts/provider-stream-stall-test.mjs +0 -276
- package/scripts/provider-toolcall-test.mjs +0 -3063
- package/scripts/reactive-compact-persist-smoke.mjs +0 -203
- package/scripts/recall-bench.mjs +0 -359
- package/scripts/resource-admission-test.mjs +0 -792
- package/scripts/result-classification-test.mjs +0 -75
- package/scripts/rg-runner-test.mjs +0 -240
- package/scripts/routing-corpus-test.mjs +0 -349
- package/scripts/sanitize-tool-pairs-test.mjs +0 -260
- package/scripts/session-bench-cache-break-test.mjs +0 -102
- package/scripts/session-bench.mjs +0 -1708
- package/scripts/session-context-bench.mjs +0 -344
- package/scripts/session-heartbeat-lifecycle-test.mjs +0 -68
- package/scripts/session-ingest-compaction-smoke.mjs +0 -279
- package/scripts/session-ingest-smoke.mjs +0 -177
- package/scripts/session-orphan-sweep-test.mjs +0 -109
- package/scripts/set-effort-config-test.mjs +0 -41
- package/scripts/shell-failure-diagnostics-test.mjs +0 -280
- package/scripts/shell-hardening-test.mjs +0 -209
- package/scripts/shell-jobs-windows-hide-test.mjs +0 -164
- package/scripts/ship-mode-test.mjs +0 -98
- package/scripts/smoke-loop-failure-summary-test.mjs +0 -38
- package/scripts/smoke-loop-failure-summary.mjs +0 -16
- package/scripts/smoke-loop-report.mjs +0 -221
- package/scripts/smoke-loop.mjs +0 -202
- package/scripts/smoke-runtime-negative.ps1 +0 -106
- package/scripts/statusline-quota-hysteresis-test.mjs +0 -62
- package/scripts/steering-drain-buckets-test.mjs +0 -467
- package/scripts/steering-fold-provenance-test.mjs +0 -71
- package/scripts/streaming-tail-window-test.mjs +0 -175
- package/scripts/submit-commandbusy-race-test.mjs +0 -257
- package/scripts/task-bench.mjs +0 -205
- package/scripts/terminal-bench-isolation-guards-test.mjs +0 -102
- package/scripts/termio-input-smoke.mjs +0 -199
- package/scripts/tool-result-hook-test.mjs +0 -48
- package/scripts/tool-smoke.mjs +0 -2453
- package/scripts/tool-tui-presentation-test.mjs +0 -286
- package/scripts/toolcall-args-test.mjs +0 -239
- package/scripts/tui-ambiguous-width-test.mjs +0 -113
- package/scripts/tui-background-failure-smoke.mjs +0 -73
- package/scripts/tui-perf-run.ps1 +0 -26
- package/scripts/tui-render-smoke.mjs +0 -90
- package/scripts/tui-runtime-load-bench-entry.jsx +0 -608
- package/scripts/tui-runtime-load-bench.mjs +0 -50
- package/scripts/tui-store-frame-batch-test.mjs +0 -99
- package/scripts/tui-transcript-jitter-harness-entry.jsx +0 -383
- package/scripts/tui-transcript-perf-test.mjs +0 -934
- package/scripts/usage-metrics-epoch-smoke.mjs +0 -114
- package/scripts/verify-release-assets-test.mjs +0 -281
- package/scripts/web-fetch-routing-test.mjs +0 -158
- package/scripts/webhook-smoke.mjs +0 -201
- package/scripts/windows-hide-spawn-options-test.mjs +0 -19
- package/scripts/worker-notify-rejection-test.mjs +0 -51
- package/scripts/write-backpressure-test.mjs +0 -147
|
@@ -1,1103 +0,0 @@
|
|
|
1
|
-
// Scripted two-client simulation for the machine-global channels daemon
|
|
2
|
-
// transport (no Discord token needed — the channels runtime is stubbed).
|
|
3
|
-
//
|
|
4
|
-
// Verifies the transport + attach client contracts that later slices depend on:
|
|
5
|
-
// 1. two TUIs attach to ONE daemon and each get an independent SSE stream;
|
|
6
|
-
// 2. tool calls round-trip per client and carry the caller's leadPid;
|
|
7
|
-
// 3. notifies are TARGETED (never broadcast) to the routing-pointer client;
|
|
8
|
-
// 4. a bind-intent call (rebind_current_transcript) moves the pointer;
|
|
9
|
-
// 5. once every client deregisters, the daemon self-shutdown fires.
|
|
10
|
-
//
|
|
11
|
-
// Run: node scripts/channel-daemon-smoke.mjs
|
|
12
|
-
import os from 'node:os';
|
|
13
|
-
import path from 'node:path';
|
|
14
|
-
import http from 'node:http';
|
|
15
|
-
import { EventEmitter } from 'node:events';
|
|
16
|
-
import { spawn } from 'node:child_process';
|
|
17
|
-
import { mkdtempSync, rmSync, writeFileSync } from 'node:fs';
|
|
18
|
-
import { fileURLToPath } from 'node:url';
|
|
19
|
-
import { createChannelDaemonTransport } from '../src/standalone/channel-daemon-transport.mjs';
|
|
20
|
-
import { attachToDaemon } from '../src/standalone/channel-daemon-client.mjs';
|
|
21
|
-
import { createParentBridge, setChannelNotifySink } from '../src/runtime/channels/lib/parent-bridge.mjs';
|
|
22
|
-
|
|
23
|
-
const HERE = path.dirname(fileURLToPath(import.meta.url));
|
|
24
|
-
const STUB_ENTRY = path.join(HERE, 'channel-daemon-stub.mjs');
|
|
25
|
-
|
|
26
|
-
const delay = (ms) => new Promise((r) => setTimeout(r, ms));
|
|
27
|
-
const waitFor = async (fn, ms = 1500) => {
|
|
28
|
-
const end = Date.now() + ms;
|
|
29
|
-
while (Date.now() < end) { if (fn()) return true; await delay(20); }
|
|
30
|
-
return fn();
|
|
31
|
-
};
|
|
32
|
-
const waitForAsync = async (fn, ms = 1500) => {
|
|
33
|
-
const end = Date.now() + ms;
|
|
34
|
-
while (Date.now() < end) { if (await fn()) return true; await delay(20); }
|
|
35
|
-
return await fn();
|
|
36
|
-
};
|
|
37
|
-
let failures = 0;
|
|
38
|
-
function check(label, cond) {
|
|
39
|
-
const ok = !!cond;
|
|
40
|
-
if (!ok) failures++;
|
|
41
|
-
console.log(`${ok ? 'PASS' : 'FAIL'} ${label}`);
|
|
42
|
-
}
|
|
43
|
-
|
|
44
|
-
async function main() {
|
|
45
|
-
const tmp = mkdtempSync(path.join(os.tmpdir(), 'mixdog-daemon-smoke-'));
|
|
46
|
-
const discoveryPath = path.join(tmp, 'channel-daemon.json');
|
|
47
|
-
let clientsEmptyFired = false;
|
|
48
|
-
let sideEffects = 0; // counts non-idempotent 'reply' dispatches
|
|
49
|
-
|
|
50
|
-
// Stub channels runtime: echoes the call + the caller identity the transport
|
|
51
|
-
// resolved, so we can assert per-client leadPid threading.
|
|
52
|
-
const handleCall = async (name, args, ctx) => {
|
|
53
|
-
if (name === 'reply') { sideEffects++; await delay(40); } // simulate a side effect
|
|
54
|
-
return { ok: true, name, args, leadPid: ctx.leadPid };
|
|
55
|
-
};
|
|
56
|
-
|
|
57
|
-
const transport = createChannelDaemonTransport({
|
|
58
|
-
handleCall,
|
|
59
|
-
discoveryPath,
|
|
60
|
-
clientGraceMs: 250,
|
|
61
|
-
sweepMs: 1000,
|
|
62
|
-
onClientsEmpty: () => { clientsEmptyFired = true; },
|
|
63
|
-
log: (m) => process.env.DAEMON_SMOKE_VERBOSE && console.log(`[daemon] ${m}`),
|
|
64
|
-
});
|
|
65
|
-
const { port, token } = await transport.start();
|
|
66
|
-
const discovery = { port, token, pid: process.pid };
|
|
67
|
-
|
|
68
|
-
const notA = [];
|
|
69
|
-
const notB = [];
|
|
70
|
-
const clientA = await attachToDaemon({ discovery, leadPid: process.pid, cwd: 'A', onNotify: (m) => notA.push(m) });
|
|
71
|
-
const clientB = await attachToDaemon({ discovery, leadPid: process.pid, cwd: 'B', onNotify: (m) => notB.push(m) });
|
|
72
|
-
await delay(150); // let both SSE streams attach
|
|
73
|
-
|
|
74
|
-
check('two clients registered', transport._clientsForTest.size === 2);
|
|
75
|
-
|
|
76
|
-
// (2) calls round-trip per client.
|
|
77
|
-
const rA = await clientA.call('reload_config', { k: 1 });
|
|
78
|
-
check('client A call round-trips', rA?.ok === true && rA?.name === 'reload_config');
|
|
79
|
-
const rB = await clientB.call('fetch', { q: 2 });
|
|
80
|
-
check('client B call round-trips', rB?.ok === true && rB?.name === 'fetch');
|
|
81
|
-
|
|
82
|
-
// (3) LAST-WINS register: the NEWEST registrant (B) steals the ownership seat,
|
|
83
|
-
// so an inbound notify targets B — NOT the first registrant. (Under the old
|
|
84
|
-
// first-wins model this went to A.)
|
|
85
|
-
transport.notify('notifications/claude/channel', { content: 'to-B' });
|
|
86
|
-
await delay(120);
|
|
87
|
-
check('notify #1 targets newest registrant (B) only',
|
|
88
|
-
notB.length === 1 && notB[0]?.params?.content === 'to-B' && notA.length === 0);
|
|
89
|
-
|
|
90
|
-
// (4) bind-intent call moves the pointer back to A.
|
|
91
|
-
await clientA.call('rebind_current_transcript', { transcriptPath: '/tmp/a.jsonl' });
|
|
92
|
-
transport.notify('notifications/claude/channel', { content: 'to-A' });
|
|
93
|
-
await delay(120);
|
|
94
|
-
check('notify #2 delivered to A only after rebind',
|
|
95
|
-
notA.length === 1 && notA[0]?.params?.content === 'to-A' && notB.length === 1);
|
|
96
|
-
|
|
97
|
-
// (fix 1) idempotent replay: two /call with the SAME callId (a retried
|
|
98
|
-
// transport failure) must run the non-idempotent side-effect exactly once.
|
|
99
|
-
const dupId = 'dup-call-1';
|
|
100
|
-
const [d1, d2] = await Promise.all([
|
|
101
|
-
clientA.call('reply', { n: 1 }, { callId: dupId }),
|
|
102
|
-
clientA.call('reply', { n: 1 }, { callId: dupId }),
|
|
103
|
-
]);
|
|
104
|
-
check('idempotent replay: same callId → exactly one side-effect',
|
|
105
|
-
sideEffects === 1 && d1?.ok === true && d2?.ok === true);
|
|
106
|
-
|
|
107
|
-
// (targeted) the 'acquired' badge goes ONLY to the current owner (pointer=A
|
|
108
|
-
// after the rebind), never broadcast — a displaced/non-owner TUI (B) must not
|
|
109
|
-
// light its remote badge. Still sticky for a late owner attach (below).
|
|
110
|
-
const remoteBefore = { A: notA.length, B: notB.length };
|
|
111
|
-
transport.notify('notifications/mixdog/remote', { state: 'acquired' });
|
|
112
|
-
await delay(120);
|
|
113
|
-
const gotA = notA.slice(remoteBefore.A).filter((m) => m?.method === 'notifications/mixdog/remote');
|
|
114
|
-
const gotB = notB.slice(remoteBefore.B).filter((m) => m?.method === 'notifications/mixdog/remote');
|
|
115
|
-
check('remote-state acquired targets owner (A) only, not broadcast',
|
|
116
|
-
gotA.length === 1 && gotB.length === 0);
|
|
117
|
-
|
|
118
|
-
// A late client that registers becomes the NEWEST owner (steals the pointer),
|
|
119
|
-
// so on attach it replays the sticky 'acquired' badge for itself.
|
|
120
|
-
const notC = [];
|
|
121
|
-
const clientC = await attachToDaemon({ discovery, leadPid: process.pid, cwd: 'C', onNotify: (m) => notC.push(m) });
|
|
122
|
-
await waitFor(() => notC.some((m) => m?.method === 'notifications/mixdog/remote'), 1000);
|
|
123
|
-
check('late owner receives replayed remote-state on attach',
|
|
124
|
-
notC.filter((m) => m?.method === 'notifications/mixdog/remote' && m?.params?.state === 'acquired').length === 1);
|
|
125
|
-
await clientC.close();
|
|
126
|
-
|
|
127
|
-
// (replay) 'superseded' CLEARS the sticky: a client attaching after it must
|
|
128
|
-
// receive NO remote-state replay (else it would wrongly stop a fresh client).
|
|
129
|
-
transport.notify('notifications/mixdog/remote', { state: 'superseded' });
|
|
130
|
-
await delay(60);
|
|
131
|
-
const notD = [];
|
|
132
|
-
const clientD = await attachToDaemon({ discovery, leadPid: process.pid, cwd: 'D', onNotify: (m) => notD.push(m) });
|
|
133
|
-
await delay(150);
|
|
134
|
-
check('attach after superseded receives no remote-state replay',
|
|
135
|
-
notD.filter((m) => m?.method === 'notifications/mixdog/remote').length === 0);
|
|
136
|
-
await clientD.close();
|
|
137
|
-
|
|
138
|
-
// (sink) daemon-mode wiring: owned-runtime routes remote-state through the
|
|
139
|
-
// parent bridge's sendNotifyToParent (NOT raw process.send). The daemon
|
|
140
|
-
// installs a sink -> transport.notify (channel-daemon.mjs:106). Assert an
|
|
141
|
-
// 'acquired' emitted via that sink reaches the transport and replays to a
|
|
142
|
-
// late client — the exact daemon-mode path a raw process.send would drop.
|
|
143
|
-
const { sendNotifyToParent } = createParentBridge({ getInstanceId: () => 'smoke' });
|
|
144
|
-
setChannelNotifySink((method, params) => transport.notify(method, params));
|
|
145
|
-
sendNotifyToParent('notifications/mixdog/remote', { state: 'acquired' });
|
|
146
|
-
await delay(60);
|
|
147
|
-
const notE = [];
|
|
148
|
-
const clientE = await attachToDaemon({ discovery, leadPid: process.pid, cwd: 'E', onNotify: (m) => notE.push(m) });
|
|
149
|
-
await waitFor(() => notE.some((m) => m?.method === 'notifications/mixdog/remote'), 1000);
|
|
150
|
-
check('daemon-mode acquire via notify sink reaches transport + replays',
|
|
151
|
-
notE.filter((m) => m?.method === 'notifications/mixdog/remote' && m?.params?.state === 'acquired').length === 1);
|
|
152
|
-
await clientE.close();
|
|
153
|
-
setChannelNotifySink(null);
|
|
154
|
-
|
|
155
|
-
// (5) deregister both → self-shutdown fires after the client grace window.
|
|
156
|
-
await clientA.close();
|
|
157
|
-
await clientB.close();
|
|
158
|
-
await delay(600);
|
|
159
|
-
check('daemon self-shutdown fired after last client left', clientsEmptyFired === true);
|
|
160
|
-
|
|
161
|
-
await transport.stop();
|
|
162
|
-
try { rmSync(tmp, { recursive: true, force: true }); } catch {}
|
|
163
|
-
|
|
164
|
-
await flipTest();
|
|
165
|
-
await pointerStealTest();
|
|
166
|
-
await reattachBufferTest();
|
|
167
|
-
await pointerFailoverTest();
|
|
168
|
-
await pointerReconnectFollowsTest();
|
|
169
|
-
await tokenReplacementRetirementTest();
|
|
170
|
-
await tokenReplacementReplayTest();
|
|
171
|
-
await tokenReplacementResponseLossCloseTest();
|
|
172
|
-
await registrationReplayTtlTest();
|
|
173
|
-
await registrationReplayCleanupTest();
|
|
174
|
-
await staleTokenCallTest();
|
|
175
|
-
await clientReconnectSafetyTest();
|
|
176
|
-
await clientTruncatedReplacementCloseTest();
|
|
177
|
-
await workerAttachSafetyTest();
|
|
178
|
-
|
|
179
|
-
console.log(failures === 0 ? '\nALL PASS' : `\n${failures} FAILURE(S)`);
|
|
180
|
-
process.exit(failures === 0 ? 0 : 1);
|
|
181
|
-
}
|
|
182
|
-
|
|
183
|
-
// Flip coverage: two TUIs attach to ONE daemon via the REAL channel-worker.mjs
|
|
184
|
-
// spawn-or-attach path (against the stub daemon entry — no Discord token). The
|
|
185
|
-
// first worker spawns the daemon; the second attaches to it.
|
|
186
|
-
async function flipTest() {
|
|
187
|
-
const tmp = mkdtempSync(path.join(os.tmpdir(), 'mixdog-flip-smoke-'));
|
|
188
|
-
// channel-worker.mjs resolves DAEMON_ENTRY + runtimeRoot at import/call time,
|
|
189
|
-
// so the env must be set BEFORE importing it.
|
|
190
|
-
process.env.MIXDOG_RUNTIME_ROOT = tmp;
|
|
191
|
-
process.env.MIXDOG_DATA_DIR = tmp;
|
|
192
|
-
process.env.MIXDOG_CHANNEL_DAEMON_ENTRY = STUB_ENTRY;
|
|
193
|
-
const { createStandaloneChannelWorker } = await import('../src/standalone/channel-worker.mjs');
|
|
194
|
-
const { readDaemonDiscovery, probeDaemonHealth } = await import('../src/standalone/channel-daemon-client.mjs');
|
|
195
|
-
const discFile = path.join(tmp, 'channel-daemon.json');
|
|
196
|
-
|
|
197
|
-
const notA = [];
|
|
198
|
-
const notB = [];
|
|
199
|
-
const mk = (onNotify) => createStandaloneChannelWorker({ entry: STUB_ENTRY, rootDir: tmp, dataDir: tmp, cwd: tmp, onNotify });
|
|
200
|
-
const wA = mk((m) => notA.push(m));
|
|
201
|
-
const wB = mk((m) => notB.push(m));
|
|
202
|
-
|
|
203
|
-
await wA.start(); // spawns the (stub) daemon then attaches
|
|
204
|
-
await wB.start(); // attaches to the SAME daemon (no second spawn)
|
|
205
|
-
check('flip: worker A call round-trips', (await wA.execute('reply', { x: 1 }))?.ok === true);
|
|
206
|
-
check('flip: worker B call round-trips', (await wB.execute('reply', { y: 2 }))?.ok === true);
|
|
207
|
-
|
|
208
|
-
// B claims the transcript → routing pointer moves to B; a notify emitted by
|
|
209
|
-
// the daemon (on A's 'fetch') must reach B only, never both TUIs.
|
|
210
|
-
await wB.execute('rebind_current_transcript', { transcriptPath: path.join(tmp, 'b.jsonl') });
|
|
211
|
-
await wA.execute('fetch', { probe: true });
|
|
212
|
-
await waitFor(() => notB.some((m) => m?.params?.content === 'ping-from-stub'), 1500);
|
|
213
|
-
check('flip: notify targets the pointer TUI (B) only',
|
|
214
|
-
notB.some((m) => m?.params?.content === 'ping-from-stub') &&
|
|
215
|
-
!notA.some((m) => m?.params?.content === 'ping-from-stub'));
|
|
216
|
-
|
|
217
|
-
// (fix 1) daemon death mid-session: stale SSE clients must immediately
|
|
218
|
-
// invalidate and re-read discovery. Both workers reach the replacement
|
|
219
|
-
// without a tool call or the old five one-second stale-port retries.
|
|
220
|
-
const beforeKill = readDaemonDiscovery(discFile);
|
|
221
|
-
if (beforeKill?.pid) { try { process.kill(beforeKill.pid, 'SIGKILL'); } catch {} }
|
|
222
|
-
const reattached = await waitForAsync(async () => {
|
|
223
|
-
const discovery = readDaemonDiscovery(discFile);
|
|
224
|
-
if (!discovery?.pid || discovery.pid === beforeKill?.pid) return false;
|
|
225
|
-
const health = await probeDaemonHealth({ port: discovery.port, token: discovery.token, timeoutMs: 300 });
|
|
226
|
-
return health?.clients >= 2;
|
|
227
|
-
}, 2600);
|
|
228
|
-
const afterKill = readDaemonDiscovery(discFile);
|
|
229
|
-
let recovered = false;
|
|
230
|
-
try { recovered = (await wA.execute('reply', { after: 'stale-sse-replacement' }))?.ok === true; } catch {}
|
|
231
|
-
check('flip: stale SSE immediately re-attaches to replacement (no 1..5 stale-port storm)',
|
|
232
|
-
reattached === true && !!afterKill?.pid && afterKill.pid !== beforeKill?.pid && recovered === true);
|
|
233
|
-
|
|
234
|
-
await wA.stop();
|
|
235
|
-
await wB.stop();
|
|
236
|
-
await delay(700);
|
|
237
|
-
const disc = readDaemonDiscovery(discFile);
|
|
238
|
-
check('flip: daemon self-shutdown after last TUI detaches', disc === null);
|
|
239
|
-
try { rmSync(tmp, { recursive: true, force: true }); } catch {}
|
|
240
|
-
}
|
|
241
|
-
|
|
242
|
-
// Last-wins ownership steal coverage with DISTINCT alive leadPids (two sleeper
|
|
243
|
-
// child processes) — the only way to observe the targeted 'superseded' frame,
|
|
244
|
-
// which is skipped when old/new client share a leadPid (same-TUI rebind).
|
|
245
|
-
async function pointerStealTest() {
|
|
246
|
-
const tmp = mkdtempSync(path.join(os.tmpdir(), 'mixdog-steal-smoke-'));
|
|
247
|
-
const discoveryPath = path.join(tmp, 'channel-daemon.json');
|
|
248
|
-
const transport = createChannelDaemonTransport({
|
|
249
|
-
handleCall: async (name, args, ctx) => ({ ok: true, name, leadPid: ctx.leadPid }),
|
|
250
|
-
discoveryPath,
|
|
251
|
-
clientGraceMs: 2000,
|
|
252
|
-
sweepMs: 5000,
|
|
253
|
-
onClientsEmpty: () => {},
|
|
254
|
-
log: (m) => process.env.DAEMON_SMOKE_VERBOSE && console.log(`[steal] ${m}`),
|
|
255
|
-
});
|
|
256
|
-
const { port, token } = await transport.start();
|
|
257
|
-
const discovery = { port, token, pid: process.pid };
|
|
258
|
-
const REMOTE = 'notifications/mixdog/remote';
|
|
259
|
-
|
|
260
|
-
// Two long-lived children give us two DISTINCT alive lead pids.
|
|
261
|
-
const sleeperA = spawn(process.execPath, ['-e', 'setTimeout(()=>{}, 60000)'], { stdio: 'ignore' });
|
|
262
|
-
const sleeperB = spawn(process.execPath, ['-e', 'setTimeout(()=>{}, 60000)'], { stdio: 'ignore' });
|
|
263
|
-
await delay(80);
|
|
264
|
-
|
|
265
|
-
const notA = [];
|
|
266
|
-
const notB = [];
|
|
267
|
-
const clientA = await attachToDaemon({ discovery, leadPid: sleeperA.pid, cwd: 'A', onNotify: (m) => notA.push(m) });
|
|
268
|
-
await delay(100); // A registers → A is the owner
|
|
269
|
-
const clientB = await attachToDaemon({ discovery, leadPid: sleeperB.pid, cwd: 'B', onNotify: (m) => notB.push(m) });
|
|
270
|
-
await delay(150); // B registers → steals the seat, A gets targeted superseded
|
|
271
|
-
|
|
272
|
-
check('steal: newest registrant (B) becomes the owner pointer',
|
|
273
|
-
transport._resolveTargetForTest()?.leadPid === sleeperB.pid);
|
|
274
|
-
check('steal: displaced owner (A) got a TARGETED superseded on register',
|
|
275
|
-
notA.filter((m) => m?.method === REMOTE && m?.params?.state === 'superseded').length === 1);
|
|
276
|
-
check('steal: new owner (B) got no superseded',
|
|
277
|
-
notB.filter((m) => m?.method === REMOTE).length === 0);
|
|
278
|
-
|
|
279
|
-
// 'acquired' now targets the owner (B) only — never broadcast to A.
|
|
280
|
-
transport.notify(REMOTE, { state: 'acquired' });
|
|
281
|
-
await delay(120);
|
|
282
|
-
check('steal: acquired targets owner (B) only, not broadcast',
|
|
283
|
-
notB.filter((m) => m?.params?.state === 'acquired').length === 1 &&
|
|
284
|
-
notA.filter((m) => m?.params?.state === 'acquired').length === 0);
|
|
285
|
-
|
|
286
|
-
// A bind-intent /call from A steals the seat back → B (displaced) superseded.
|
|
287
|
-
await clientA.call('activate_channel_bridge', {});
|
|
288
|
-
await delay(120);
|
|
289
|
-
check('steal: bind /call moves pointer back to A',
|
|
290
|
-
transport._resolveTargetForTest()?.leadPid === sleeperA.pid);
|
|
291
|
-
check('steal: displaced owner (B) got targeted superseded on bind /call',
|
|
292
|
-
notB.filter((m) => m?.method === REMOTE && m?.params?.state === 'superseded').length === 1);
|
|
293
|
-
|
|
294
|
-
// superseded via notify() (seat lost to another daemon) still BROADCASTS to
|
|
295
|
-
// every live client, regardless of pointer.
|
|
296
|
-
const aBefore = notA.length;
|
|
297
|
-
const bBefore = notB.length;
|
|
298
|
-
transport.notify(REMOTE, { state: 'superseded' });
|
|
299
|
-
await delay(120);
|
|
300
|
-
check('steal: superseded via notify broadcasts to ALL live clients',
|
|
301
|
-
notA.slice(aBefore).some((m) => m?.params?.state === 'superseded') &&
|
|
302
|
-
notB.slice(bBefore).some((m) => m?.params?.state === 'superseded'));
|
|
303
|
-
|
|
304
|
-
await clientA.close();
|
|
305
|
-
await clientB.close();
|
|
306
|
-
try { sleeperA.kill(); } catch {}
|
|
307
|
-
try { sleeperB.kill(); } catch {}
|
|
308
|
-
await transport.stop();
|
|
309
|
-
try { rmSync(tmp, { recursive: true, force: true }); } catch {}
|
|
310
|
-
}
|
|
311
|
-
|
|
312
|
-
// Low-level helpers: register/attach WITHOUT the auto-attach client, so a
|
|
313
|
-
// client can be registered but leave its SSE stream closed (to exercise the
|
|
314
|
-
// buffered-superseded + reattach paths that attachToDaemon hides).
|
|
315
|
-
function rawPost(port, serverToken, p, body) {
|
|
316
|
-
return new Promise((resolve, reject) => {
|
|
317
|
-
const payload = JSON.stringify(body);
|
|
318
|
-
const req = http.request({
|
|
319
|
-
hostname: '127.0.0.1', port, path: p, method: 'POST',
|
|
320
|
-
headers: { 'X-Mixdog-Daemon-Token': serverToken, 'Content-Type': 'application/json', 'Content-Length': Buffer.byteLength(payload) },
|
|
321
|
-
}, (res) => { let d = ''; res.setEncoding('utf8'); res.on('data', (c) => { d += c; }); res.on('end', () => { try { resolve(JSON.parse(d)); } catch { resolve({}); } }); });
|
|
322
|
-
req.on('error', reject); req.write(payload); req.end();
|
|
323
|
-
});
|
|
324
|
-
}
|
|
325
|
-
function rawPostLoseResponse(port, serverToken, p, body) {
|
|
326
|
-
return new Promise((resolve, reject) => {
|
|
327
|
-
const payload = JSON.stringify(body);
|
|
328
|
-
let settled = false;
|
|
329
|
-
const done = (err) => {
|
|
330
|
-
if (settled) return;
|
|
331
|
-
settled = true;
|
|
332
|
-
if (err) reject(err); else resolve();
|
|
333
|
-
};
|
|
334
|
-
const req = http.request({
|
|
335
|
-
hostname: '127.0.0.1', port, path: p, method: 'POST',
|
|
336
|
-
headers: { 'X-Mixdog-Daemon-Token': serverToken, 'Content-Type': 'application/json', 'Content-Length': Buffer.byteLength(payload) },
|
|
337
|
-
}, (res) => {
|
|
338
|
-
// The daemon has already committed before emitting response headers; drop
|
|
339
|
-
// the body exactly where a transport response-loss retry would begin.
|
|
340
|
-
try { res.destroy(); } catch {}
|
|
341
|
-
done();
|
|
342
|
-
});
|
|
343
|
-
req.on('error', (err) => { if (!settled) done(err); });
|
|
344
|
-
req.write(payload);
|
|
345
|
-
req.end();
|
|
346
|
-
});
|
|
347
|
-
}
|
|
348
|
-
function rawSse(port, serverToken, clientToken, onNotify) {
|
|
349
|
-
const req = http.request({
|
|
350
|
-
hostname: '127.0.0.1', port,
|
|
351
|
-
path: `/events?token=${encodeURIComponent(clientToken)}&server_token=${encodeURIComponent(serverToken)}`,
|
|
352
|
-
method: 'GET', headers: { Accept: 'text/event-stream', 'X-Mixdog-Daemon-Token': serverToken },
|
|
353
|
-
}, (res) => {
|
|
354
|
-
res.setEncoding('utf8'); let buf = '';
|
|
355
|
-
res.on('data', (chunk) => {
|
|
356
|
-
buf += chunk; let idx;
|
|
357
|
-
while ((idx = buf.indexOf('\n\n')) >= 0) {
|
|
358
|
-
const raw = buf.slice(0, idx); buf = buf.slice(idx + 2);
|
|
359
|
-
for (const line of raw.split('\n')) {
|
|
360
|
-
if (!line.startsWith('data:')) continue;
|
|
361
|
-
const j = line.slice(5).trim(); if (!j) continue;
|
|
362
|
-
let m = null; try { m = JSON.parse(j); } catch { continue; }
|
|
363
|
-
if (m?.type === 'notify') onNotify(m);
|
|
364
|
-
}
|
|
365
|
-
}
|
|
366
|
-
});
|
|
367
|
-
});
|
|
368
|
-
req.end(); return req;
|
|
369
|
-
}
|
|
370
|
-
|
|
371
|
-
function readJsonBody(req) {
|
|
372
|
-
return new Promise((resolve) => {
|
|
373
|
-
let body = '';
|
|
374
|
-
req.setEncoding('utf8');
|
|
375
|
-
req.on('data', (chunk) => { body += chunk; });
|
|
376
|
-
req.on('end', () => { try { resolve(JSON.parse(body || '{}')); } catch { resolve({}); } });
|
|
377
|
-
});
|
|
378
|
-
}
|
|
379
|
-
function sendTestJson(res, statusCode, body) {
|
|
380
|
-
const json = JSON.stringify(body);
|
|
381
|
-
res.writeHead(statusCode, { 'Content-Type': 'application/json', 'Content-Length': Buffer.byteLength(json) });
|
|
382
|
-
res.end(json);
|
|
383
|
-
}
|
|
384
|
-
async function startTestServer(handle) {
|
|
385
|
-
const server = http.createServer(handle);
|
|
386
|
-
await new Promise((resolve, reject) => {
|
|
387
|
-
server.once('error', reject);
|
|
388
|
-
server.listen(0, '127.0.0.1', () => { server.off('error', reject); resolve(); });
|
|
389
|
-
});
|
|
390
|
-
return server;
|
|
391
|
-
}
|
|
392
|
-
function testPort(server) { return server.address().port; }
|
|
393
|
-
async function stopTestServer(server) {
|
|
394
|
-
await new Promise((resolve) => { try { server.close(() => resolve()); } catch { resolve(); } });
|
|
395
|
-
}
|
|
396
|
-
|
|
397
|
-
// Removed client tokens must never dispatch a tool with a null ownership
|
|
398
|
-
// context, including a callId that could otherwise hit the dedup cache.
|
|
399
|
-
async function staleTokenCallTest() {
|
|
400
|
-
let dispatched = 0;
|
|
401
|
-
const transport = createChannelDaemonTransport({
|
|
402
|
-
handleCall: async () => { dispatched++; return { ok: true }; },
|
|
403
|
-
clientGraceMs: 2000, sweepMs: 5000, onClientsEmpty: () => {},
|
|
404
|
-
});
|
|
405
|
-
const { port, token } = await transport.start();
|
|
406
|
-
const stale = await rawPost(port, token, '/call', { token: 'removed-token', name: 'reply', callId: 'stale-call' });
|
|
407
|
-
check('transport: stale /call token is rejected before dispatch',
|
|
408
|
-
stale?.error === 'unknown client token' && dispatched === 0);
|
|
409
|
-
await transport.stop();
|
|
410
|
-
}
|
|
411
|
-
|
|
412
|
-
// A reconnecting non-owner must replace its exact old token, not merely leave
|
|
413
|
-
// it behind because another client currently owns the pointer.
|
|
414
|
-
async function tokenReplacementRetirementTest() {
|
|
415
|
-
let dispatches = 0;
|
|
416
|
-
const transport = createChannelDaemonTransport({
|
|
417
|
-
handleCall: async (name, args, ctx) => {
|
|
418
|
-
dispatches++;
|
|
419
|
-
return { ok: true, name, args, leadPid: ctx.leadPid };
|
|
420
|
-
},
|
|
421
|
-
clientGraceMs: 2000, sweepMs: 5000, onClientsEmpty: () => {},
|
|
422
|
-
});
|
|
423
|
-
const { port, token } = await transport.start();
|
|
424
|
-
const a = spawn(process.execPath, ['-e', 'setTimeout(()=>{}, 60000)'], { stdio: 'ignore' });
|
|
425
|
-
const b = spawn(process.execPath, ['-e', 'setTimeout(()=>{}, 60000)'], { stdio: 'ignore' });
|
|
426
|
-
const c = spawn(process.execPath, ['-e', 'setTimeout(()=>{}, 60000)'], { stdio: 'ignore' });
|
|
427
|
-
await delay(80);
|
|
428
|
-
const oldA = await rawPost(port, token, '/client/register', { leadPid: a.pid, cwd: 'A' });
|
|
429
|
-
await rawPost(port, token, '/client/register', { leadPid: b.pid, cwd: 'B' });
|
|
430
|
-
const ownerC = await rawPost(port, token, '/client/register', { leadPid: c.pid, cwd: 'C' });
|
|
431
|
-
const oldBufferedBeforeReplace = transport._clientsForTest.get(oldA.token)?.pending?.length === 1;
|
|
432
|
-
const freshA = await rawPost(port, token, '/client/register', {
|
|
433
|
-
leadPid: a.pid, cwd: 'A', reattach: true, replaceToken: oldA.token,
|
|
434
|
-
});
|
|
435
|
-
const freshNotifies = [];
|
|
436
|
-
const freshSse = rawSse(port, token, freshA.token, (message) => freshNotifies.push(message));
|
|
437
|
-
await waitFor(() => freshNotifies.some((message) =>
|
|
438
|
-
message?.method === 'notifications/mixdog/remote' && message?.params?.state === 'superseded'), 1_000);
|
|
439
|
-
const nonOwnerStateMigrated =
|
|
440
|
-
freshNotifies.some((message) => message?.method === 'notifications/mixdog/remote' && message?.params?.state === 'superseded') &&
|
|
441
|
-
transport._clientsForTest.get(freshA.token)?.pending?.length === 0 &&
|
|
442
|
-
transport._resolveTargetForTest()?.token === ownerC.token;
|
|
443
|
-
const stale = await rawPost(port, token, '/call', {
|
|
444
|
-
token: oldA.token, name: 'rebind_current_transcript',
|
|
445
|
-
args: { transcriptPath: '/tmp/stale-a.jsonl' }, callId: 'late-old-dedup',
|
|
446
|
-
});
|
|
447
|
-
const [live1, live2] = await Promise.all([
|
|
448
|
-
rawPost(port, token, '/call', {
|
|
449
|
-
token: freshA.token, name: 'rebind_current_transcript',
|
|
450
|
-
args: { transcriptPath: '/tmp/fresh-a.jsonl' }, callId: 'fresh-live-dedup',
|
|
451
|
-
}),
|
|
452
|
-
rawPost(port, token, '/call', {
|
|
453
|
-
token: freshA.token, name: 'rebind_current_transcript',
|
|
454
|
-
args: { transcriptPath: '/tmp/fresh-a.jsonl' }, callId: 'fresh-live-dedup',
|
|
455
|
-
}),
|
|
456
|
-
]);
|
|
457
|
-
const beforeFreshClose = transport._clientsForTest.size;
|
|
458
|
-
const pointerBeforeFreshClose = transport._resolveTargetForTest()?.token;
|
|
459
|
-
await rawPost(port, token, '/client/deregister', { token: freshA.token });
|
|
460
|
-
check('reconnect: non-owner old token is retired; stale dedup call cannot move pointer',
|
|
461
|
-
stale?.error === 'unknown client token' &&
|
|
462
|
-
oldBufferedBeforeReplace &&
|
|
463
|
-
nonOwnerStateMigrated &&
|
|
464
|
-
!transport._clientsForTest.has(oldA.token) &&
|
|
465
|
-
beforeFreshClose === 3 &&
|
|
466
|
-
pointerBeforeFreshClose === freshA.token &&
|
|
467
|
-
live1?.result?.ok === true && live2?.result?.ok === true &&
|
|
468
|
-
dispatches === 1 &&
|
|
469
|
-
!transport._clientsForTest.has(freshA.token) &&
|
|
470
|
-
transport._clientsForTest.size === 2 &&
|
|
471
|
-
ownerC.token !== freshA.token);
|
|
472
|
-
try { a.kill(); } catch {}
|
|
473
|
-
try { b.kill(); } catch {}
|
|
474
|
-
try { c.kill(); } catch {}
|
|
475
|
-
try { freshSse.destroy(); } catch {}
|
|
476
|
-
await transport.stop();
|
|
477
|
-
}
|
|
478
|
-
|
|
479
|
-
// If replacement commits but its response is lost, the same logical
|
|
480
|
-
// registration id must replay the first fresh token rather than create another.
|
|
481
|
-
async function tokenReplacementReplayTest() {
|
|
482
|
-
const transport = createChannelDaemonTransport({
|
|
483
|
-
handleCall: async () => ({ ok: true }),
|
|
484
|
-
clientGraceMs: 2000, sweepMs: 5000, onClientsEmpty: () => {},
|
|
485
|
-
});
|
|
486
|
-
const { port, token } = await transport.start();
|
|
487
|
-
const a = spawn(process.execPath, ['-e', 'setTimeout(()=>{}, 60000)'], { stdio: 'ignore' });
|
|
488
|
-
const b = spawn(process.execPath, ['-e', 'setTimeout(()=>{}, 60000)'], { stdio: 'ignore' });
|
|
489
|
-
await delay(80);
|
|
490
|
-
const oldA = await rawPost(port, token, '/client/register', { leadPid: a.pid, cwd: 'A' });
|
|
491
|
-
const ownerB = await rawPost(port, token, '/client/register', { leadPid: b.pid, cwd: 'B' });
|
|
492
|
-
const replacement = {
|
|
493
|
-
leadPid: a.pid, cwd: 'A', reattach: true, replaceToken: oldA.token,
|
|
494
|
-
registrationId: 'response-loss-replacement-1',
|
|
495
|
-
};
|
|
496
|
-
await rawPostLoseResponse(port, token, '/client/register', replacement);
|
|
497
|
-
const freshA = await rawPost(port, token, '/client/register', replacement);
|
|
498
|
-
const countAfterReplay = transport._clientsForTest.size;
|
|
499
|
-
const freshNotifies = [];
|
|
500
|
-
const freshSse = rawSse(port, token, freshA.token, (message) => freshNotifies.push(message));
|
|
501
|
-
await waitFor(() => freshNotifies.some((message) =>
|
|
502
|
-
message?.method === 'notifications/mixdog/remote' && message?.params?.state === 'superseded'), 1_000);
|
|
503
|
-
const pendingMigrated = transport._clientsForTest.get(freshA.token)?.pending?.length === 0;
|
|
504
|
-
await rawPost(port, token, '/client/deregister', { token: freshA.token });
|
|
505
|
-
check('reconnect: response-loss replacement replays one fresh token and close leaves no orphan',
|
|
506
|
-
countAfterReplay === 2 &&
|
|
507
|
-
!transport._clientsForTest.has(oldA.token) &&
|
|
508
|
-
transport._resolveTargetForTest()?.token === ownerB.token &&
|
|
509
|
-
pendingMigrated &&
|
|
510
|
-
!transport._clientsForTest.has(freshA.token) &&
|
|
511
|
-
transport._clientsForTest.size === 1 &&
|
|
512
|
-
transport._registrationReplaysForTest.size === 0);
|
|
513
|
-
try { freshSse.destroy(); } catch {}
|
|
514
|
-
try { a.kill(); } catch {}
|
|
515
|
-
try { b.kill(); } catch {}
|
|
516
|
-
await transport.stop();
|
|
517
|
-
}
|
|
518
|
-
|
|
519
|
-
// If the replacement commit survives but its response is lost, an immediate
|
|
520
|
-
// close still knows the retired token + registration id and must cancel exactly
|
|
521
|
-
// that unknown fresh logical client, not an unrelated live owner.
|
|
522
|
-
async function tokenReplacementResponseLossCloseTest() {
|
|
523
|
-
const transport = createChannelDaemonTransport({
|
|
524
|
-
handleCall: async () => ({ ok: true }),
|
|
525
|
-
clientGraceMs: 2000, sweepMs: 5000, onClientsEmpty: () => {},
|
|
526
|
-
});
|
|
527
|
-
const { port, token } = await transport.start();
|
|
528
|
-
const a = spawn(process.execPath, ['-e', 'setTimeout(()=>{}, 60000)'], { stdio: 'ignore' });
|
|
529
|
-
const b = spawn(process.execPath, ['-e', 'setTimeout(()=>{}, 60000)'], { stdio: 'ignore' });
|
|
530
|
-
await delay(80);
|
|
531
|
-
const oldA = await rawPost(port, token, '/client/register', { leadPid: a.pid, cwd: 'A' });
|
|
532
|
-
const ownerB = await rawPost(port, token, '/client/register', { leadPid: b.pid, cwd: 'B' });
|
|
533
|
-
const cancellation = {
|
|
534
|
-
token: oldA.token, replaceToken: oldA.token, leadPid: a.pid, cwd: 'A',
|
|
535
|
-
registrationId: 'response-loss-immediate-close-1',
|
|
536
|
-
};
|
|
537
|
-
await rawPostLoseResponse(port, token, '/client/register', {
|
|
538
|
-
...cancellation, reattach: true,
|
|
539
|
-
});
|
|
540
|
-
const fresh = [...transport._clientsForTest.values()].find((client) => client.leadPid === a.pid);
|
|
541
|
-
const pendingMigrated = fresh?.pending?.length === 1;
|
|
542
|
-
const wrongClose = await rawPost(port, token, '/client/deregister', {
|
|
543
|
-
...cancellation, cwd: 'not-A',
|
|
544
|
-
});
|
|
545
|
-
const close1 = await rawPost(port, token, '/client/deregister', cancellation);
|
|
546
|
-
const close2 = await rawPost(port, token, '/client/deregister', cancellation);
|
|
547
|
-
check('reconnect: response-loss immediate close cancels unknown fresh client without stranding state',
|
|
548
|
-
wrongClose?.error === 'forbidden replacement deregister' &&
|
|
549
|
-
pendingMigrated &&
|
|
550
|
-
close1?.ok === true && close1?.cancelled === true &&
|
|
551
|
-
close2?.ok === true && close2?.cancelled === false &&
|
|
552
|
-
!transport._clientsForTest.has(oldA.token) &&
|
|
553
|
-
!transport._clientsForTest.has(fresh?.token) &&
|
|
554
|
-
transport._clientsForTest.size === 1 &&
|
|
555
|
-
transport._resolveTargetForTest()?.token === ownerB.token &&
|
|
556
|
-
transport._registrationReplaysForTest.size === 0);
|
|
557
|
-
try { a.kill(); } catch {}
|
|
558
|
-
try { b.kill(); } catch {}
|
|
559
|
-
await transport.stop();
|
|
560
|
-
}
|
|
561
|
-
|
|
562
|
-
// Replay TTL bounds only cancellation metadata after a response has flushed:
|
|
563
|
-
// a valid client may delay SSE/call well beyond that TTL. A replay near expiry
|
|
564
|
-
// refreshes the metadata lifetime so its returned token remains cancellable.
|
|
565
|
-
async function registrationReplayTtlTest() {
|
|
566
|
-
const transport = createChannelDaemonTransport({
|
|
567
|
-
handleCall: async () => ({ ok: true }),
|
|
568
|
-
clientGraceMs: 2000, sweepMs: 5000, registrationReplayTtlMs: 100,
|
|
569
|
-
onClientsEmpty: () => {},
|
|
570
|
-
});
|
|
571
|
-
const { port, token } = await transport.start();
|
|
572
|
-
const a = spawn(process.execPath, ['-e', 'setTimeout(()=>{}, 60000)'], { stdio: 'ignore' });
|
|
573
|
-
const b = spawn(process.execPath, ['-e', 'setTimeout(()=>{}, 60000)'], { stdio: 'ignore' });
|
|
574
|
-
await delay(80);
|
|
575
|
-
const oldA = await rawPost(port, token, '/client/register', { leadPid: a.pid, cwd: 'A' });
|
|
576
|
-
await rawPost(port, token, '/client/register', { leadPid: b.pid, cwd: 'B' });
|
|
577
|
-
const valid = await rawPost(port, token, '/client/register', {
|
|
578
|
-
leadPid: a.pid, cwd: 'A', reattach: true, replaceToken: oldA.token, registrationId: 'ttl-valid',
|
|
579
|
-
});
|
|
580
|
-
await delay(150);
|
|
581
|
-
const validSurvived = transport._clientsForTest.has(valid.token) &&
|
|
582
|
-
transport._clientsForTest.get(valid.token)?.pending?.length === 1 &&
|
|
583
|
-
transport._registrationReplaysForTest.size === 0;
|
|
584
|
-
await rawPost(port, token, '/client/deregister', { token: valid.token });
|
|
585
|
-
|
|
586
|
-
const oldA2 = await rawPost(port, token, '/client/register', { leadPid: a.pid, cwd: 'A2' });
|
|
587
|
-
const replacement = {
|
|
588
|
-
leadPid: a.pid, cwd: 'A2', reattach: true, replaceToken: oldA2.token, registrationId: 'ttl-refresh',
|
|
589
|
-
};
|
|
590
|
-
const fresh = await rawPost(port, token, '/client/register', replacement);
|
|
591
|
-
await delay(70);
|
|
592
|
-
const replayed = await rawPost(port, token, '/client/register', replacement);
|
|
593
|
-
await delay(60); // Past the original deadline, before the refreshed deadline.
|
|
594
|
-
const replayRefreshed = replayed.token === fresh.token &&
|
|
595
|
-
transport._clientsForTest.has(fresh.token) &&
|
|
596
|
-
transport._registrationReplaysForTest.size === 1;
|
|
597
|
-
await delay(70); // Full refreshed interval elapsed.
|
|
598
|
-
const freshSurvivedRefreshExpiry = transport._clientsForTest.has(fresh.token) &&
|
|
599
|
-
transport._registrationReplaysForTest.size === 0;
|
|
600
|
-
await rawPost(port, token, '/client/deregister', { token: fresh.token });
|
|
601
|
-
check('reconnect: replay TTL preserves flushed clients and refreshes near-expiry replay metadata',
|
|
602
|
-
validSurvived && replayRefreshed && freshSurvivedRefreshExpiry);
|
|
603
|
-
try { a.kill(); } catch {}
|
|
604
|
-
try { b.kill(); } catch {}
|
|
605
|
-
await transport.stop();
|
|
606
|
-
}
|
|
607
|
-
|
|
608
|
-
// Every explicit retirement, replacement, deregister, and stop must clear the
|
|
609
|
-
// associated replay entry/timer; no metadata may target an absent token.
|
|
610
|
-
async function registrationReplayCleanupTest() {
|
|
611
|
-
const transport = createChannelDaemonTransport({
|
|
612
|
-
handleCall: async () => ({ ok: true }),
|
|
613
|
-
clientGraceMs: 2000, sweepMs: 5000, registrationReplayTtlMs: 1_000,
|
|
614
|
-
onClientsEmpty: () => {},
|
|
615
|
-
});
|
|
616
|
-
const { port, token } = await transport.start();
|
|
617
|
-
const a = spawn(process.execPath, ['-e', 'setTimeout(()=>{}, 60000)'], { stdio: 'ignore' });
|
|
618
|
-
await delay(80);
|
|
619
|
-
const old = await rawPost(port, token, '/client/register', { leadPid: a.pid, cwd: 'A' });
|
|
620
|
-
const fresh1 = await rawPost(port, token, '/client/register', {
|
|
621
|
-
leadPid: a.pid, cwd: 'A', reattach: true, replaceToken: old.token, registrationId: 'cleanup-1',
|
|
622
|
-
});
|
|
623
|
-
const fresh2 = await rawPost(port, token, '/client/register', {
|
|
624
|
-
leadPid: a.pid, cwd: 'A', reattach: true, replaceToken: fresh1.token, registrationId: 'cleanup-2',
|
|
625
|
-
});
|
|
626
|
-
const replacementClean = !transport._clientsForTest.has(fresh1.token) &&
|
|
627
|
-
transport._registrationReplaysForTest.size === 1;
|
|
628
|
-
await rawPost(port, token, '/client/deregister', { token: fresh2.token });
|
|
629
|
-
const deregisterClean = transport._registrationReplaysForTest.size === 0;
|
|
630
|
-
const old3 = await rawPost(port, token, '/client/register', { leadPid: a.pid, cwd: 'A3' });
|
|
631
|
-
await rawPost(port, token, '/client/register', {
|
|
632
|
-
leadPid: a.pid, cwd: 'A3', reattach: true, replaceToken: old3.token, registrationId: 'cleanup-stop',
|
|
633
|
-
});
|
|
634
|
-
await transport.stop();
|
|
635
|
-
check('reconnect: replacement, deregister, and stop clear all replay metadata',
|
|
636
|
-
replacementClean && deregisterClean &&
|
|
637
|
-
transport._clientsForTest.size === 0 && transport._registrationReplaysForTest.size === 0);
|
|
638
|
-
try { a.kill(); } catch {}
|
|
639
|
-
}
|
|
640
|
-
|
|
641
|
-
// Client-only SSE races: immediate 200/end cycles consume the bounded budget;
|
|
642
|
-
// a late old response cannot start another token rotation; close waits for a
|
|
643
|
-
// delayed reconnect registration and removes its fresh token.
|
|
644
|
-
async function clientReconnectSafetyTest() {
|
|
645
|
-
let registerCount = 0;
|
|
646
|
-
let fatalReason = null;
|
|
647
|
-
const flapping = await startTestServer(async (req, res) => {
|
|
648
|
-
const url = new URL(req.url, 'http://127.0.0.1');
|
|
649
|
-
if (url.pathname === '/health') return sendTestJson(res, 200, { status: 'ok', pid: process.pid });
|
|
650
|
-
if (url.pathname === '/client/register') {
|
|
651
|
-
await readJsonBody(req);
|
|
652
|
-
registerCount++;
|
|
653
|
-
return sendTestJson(res, 200, { token: `flap-${registerCount}`, pid: process.pid });
|
|
654
|
-
}
|
|
655
|
-
if (url.pathname === '/client/deregister') { await readJsonBody(req); return sendTestJson(res, 200, { ok: true }); }
|
|
656
|
-
if (url.pathname === '/events') {
|
|
657
|
-
res.writeHead(200, { 'Content-Type': 'text/event-stream' });
|
|
658
|
-
res.write(': attached\n\n');
|
|
659
|
-
setTimeout(() => res.end(), 10);
|
|
660
|
-
return;
|
|
661
|
-
}
|
|
662
|
-
sendTestJson(res, 404, { error: 'not found' });
|
|
663
|
-
});
|
|
664
|
-
const flapClient = await attachToDaemon({
|
|
665
|
-
discovery: { port: testPort(flapping), token: 'flap', pid: process.pid },
|
|
666
|
-
onFatal: (reason) => { fatalReason = reason; },
|
|
667
|
-
});
|
|
668
|
-
await waitFor(() => fatalReason !== null, 8_000);
|
|
669
|
-
check('client: repeated 200/end SSE reconnects are bounded',
|
|
670
|
-
fatalReason !== null && registerCount === 6);
|
|
671
|
-
await flapClient.close();
|
|
672
|
-
await stopTestServer(flapping);
|
|
673
|
-
|
|
674
|
-
let lateRegisters = 0;
|
|
675
|
-
const lateServer = await startTestServer(async (req, res) => {
|
|
676
|
-
const url = new URL(req.url, 'http://127.0.0.1');
|
|
677
|
-
if (url.pathname === '/health') return sendTestJson(res, 200, { status: 'ok', pid: process.pid });
|
|
678
|
-
if (url.pathname === '/client/register') {
|
|
679
|
-
await readJsonBody(req);
|
|
680
|
-
lateRegisters++;
|
|
681
|
-
return sendTestJson(res, 200, { token: `late-${lateRegisters}`, pid: process.pid });
|
|
682
|
-
}
|
|
683
|
-
if (url.pathname === '/client/deregister') { await readJsonBody(req); return sendTestJson(res, 200, { ok: true }); }
|
|
684
|
-
if (url.pathname === '/events') return sendTestJson(res, 500, { error: 'test SSE should be intercepted' });
|
|
685
|
-
sendTestJson(res, 404, { error: 'not found' });
|
|
686
|
-
});
|
|
687
|
-
const realHttpRequest = http.request;
|
|
688
|
-
const fakeStreams = [];
|
|
689
|
-
http.request = function interceptedSseRequest(options, callback) {
|
|
690
|
-
if (!String(options?.path || '').startsWith('/events')) {
|
|
691
|
-
return realHttpRequest.apply(this, arguments);
|
|
692
|
-
}
|
|
693
|
-
const fakeReq = new EventEmitter();
|
|
694
|
-
fakeReq.destroy = () => {};
|
|
695
|
-
fakeReq.end = () => {
|
|
696
|
-
const fakeRes = new EventEmitter();
|
|
697
|
-
fakeRes.statusCode = 200;
|
|
698
|
-
fakeRes.setEncoding = () => {};
|
|
699
|
-
fakeRes.resume = () => {};
|
|
700
|
-
fakeStreams.push({ req: fakeReq, res: fakeRes });
|
|
701
|
-
queueMicrotask(() => callback(fakeRes));
|
|
702
|
-
};
|
|
703
|
-
return fakeReq;
|
|
704
|
-
};
|
|
705
|
-
let lateClient = null;
|
|
706
|
-
try {
|
|
707
|
-
lateClient = await attachToDaemon({ discovery: { port: testPort(lateServer), token: 'late', pid: process.pid } });
|
|
708
|
-
await waitFor(() => fakeStreams.length === 1, 500);
|
|
709
|
-
fakeStreams[0].res.emit('end');
|
|
710
|
-
await waitFor(() => lateRegisters === 2 && fakeStreams.length === 2, 2_500);
|
|
711
|
-
fakeStreams[0].res.emit('error', new Error('late old SSE error'));
|
|
712
|
-
await delay(100);
|
|
713
|
-
check('client: late old-SSE events cannot rotate a newer token', lateRegisters === 2);
|
|
714
|
-
} finally {
|
|
715
|
-
http.request = realHttpRequest;
|
|
716
|
-
await lateClient?.close();
|
|
717
|
-
}
|
|
718
|
-
await stopTestServer(lateServer);
|
|
719
|
-
|
|
720
|
-
let closeRegisters = 0;
|
|
721
|
-
let delayedRegisterResponse = null;
|
|
722
|
-
const liveTokens = new Set();
|
|
723
|
-
const closeServer = await startTestServer(async (req, res) => {
|
|
724
|
-
const url = new URL(req.url, 'http://127.0.0.1');
|
|
725
|
-
if (url.pathname === '/health') return sendTestJson(res, 200, { status: 'ok', pid: process.pid });
|
|
726
|
-
if (url.pathname === '/client/register') {
|
|
727
|
-
await readJsonBody(req);
|
|
728
|
-
closeRegisters++;
|
|
729
|
-
const fresh = `close-${closeRegisters}`;
|
|
730
|
-
liveTokens.add(fresh);
|
|
731
|
-
if (closeRegisters === 2) { delayedRegisterResponse = { res, fresh }; return; }
|
|
732
|
-
return sendTestJson(res, 200, { token: fresh, pid: process.pid });
|
|
733
|
-
}
|
|
734
|
-
if (url.pathname === '/client/deregister') {
|
|
735
|
-
const body = await readJsonBody(req);
|
|
736
|
-
liveTokens.delete(body.token);
|
|
737
|
-
return sendTestJson(res, 200, { ok: true });
|
|
738
|
-
}
|
|
739
|
-
if (url.pathname === '/events') {
|
|
740
|
-
res.writeHead(200, { 'Content-Type': 'text/event-stream' });
|
|
741
|
-
res.write(': attached\n\n');
|
|
742
|
-
if (closeRegisters === 1) setTimeout(() => res.end(), 10);
|
|
743
|
-
return;
|
|
744
|
-
}
|
|
745
|
-
sendTestJson(res, 404, { error: 'not found' });
|
|
746
|
-
});
|
|
747
|
-
const closeClient = await attachToDaemon({ discovery: { port: testPort(closeServer), token: 'close', pid: process.pid } });
|
|
748
|
-
await waitFor(() => delayedRegisterResponse !== null, 2_500);
|
|
749
|
-
const closing = closeClient.close('close during register');
|
|
750
|
-
await delay(20);
|
|
751
|
-
sendTestJson(delayedRegisterResponse.res, 200, { token: delayedRegisterResponse.fresh, pid: process.pid });
|
|
752
|
-
await closing;
|
|
753
|
-
check('client: close during re-register removes the fresh token',
|
|
754
|
-
!liveTokens.has(delayedRegisterResponse.fresh) && liveTokens.size === 0);
|
|
755
|
-
await stopTestServer(closeServer);
|
|
756
|
-
}
|
|
757
|
-
|
|
758
|
-
// End-to-end client path: the daemon commits replacement, but a proxy loses the
|
|
759
|
-
// register response after headers/partial JSON. request() must settle, and close
|
|
760
|
-
// must use the retained logical-registration identity to cancel the unknown
|
|
761
|
-
// fresh transport client before any later reconnect can revive it.
|
|
762
|
-
async function clientTruncatedReplacementCloseTest() {
|
|
763
|
-
let cancellationObserved = false;
|
|
764
|
-
const transport = createChannelDaemonTransport({
|
|
765
|
-
handleCall: async () => ({ ok: true }),
|
|
766
|
-
clientGraceMs: 2000, sweepMs: 5000, onClientsEmpty: () => {},
|
|
767
|
-
log: (line) => { if (String(line).includes('replacement deregister')) cancellationObserved = true; },
|
|
768
|
-
});
|
|
769
|
-
const { port, token } = await transport.start();
|
|
770
|
-
const client = await attachToDaemon({ discovery: { port, token, pid: process.pid }, cwd: 'truncated-client' });
|
|
771
|
-
const initialToken = client.clientToken;
|
|
772
|
-
const realHttpRequest = http.request;
|
|
773
|
-
let replacementRequests = 0;
|
|
774
|
-
let truncated = false;
|
|
775
|
-
http.request = function truncateCommittedRegister(options, callback) {
|
|
776
|
-
if (!String(options?.path || '').startsWith('/client/register')) {
|
|
777
|
-
return realHttpRequest.apply(this, arguments);
|
|
778
|
-
}
|
|
779
|
-
replacementRequests++;
|
|
780
|
-
const fakeReq = new EventEmitter();
|
|
781
|
-
let payload = null;
|
|
782
|
-
let inner = null;
|
|
783
|
-
fakeReq.write = (chunk) => { payload = Buffer.from(chunk); };
|
|
784
|
-
fakeReq.destroy = (error) => { try { inner?.destroy?.(error); } catch {} };
|
|
785
|
-
fakeReq.end = () => {
|
|
786
|
-
inner = realHttpRequest(options, (realRes) => {
|
|
787
|
-
realRes.resume();
|
|
788
|
-
realRes.once('end', () => {
|
|
789
|
-
const fakeRes = new EventEmitter();
|
|
790
|
-
fakeRes.statusCode = 200;
|
|
791
|
-
fakeRes.setEncoding = () => {};
|
|
792
|
-
fakeRes.resume = () => {};
|
|
793
|
-
fakeRes.destroy = () => {};
|
|
794
|
-
callback(fakeRes);
|
|
795
|
-
fakeRes.emit('data', '{"token":"committed-but-truncated');
|
|
796
|
-
truncated = true;
|
|
797
|
-
queueMicrotask(() => {
|
|
798
|
-
fakeRes.emit('aborted');
|
|
799
|
-
fakeRes.emit('close');
|
|
800
|
-
});
|
|
801
|
-
});
|
|
802
|
-
});
|
|
803
|
-
inner.on('error', (error) => fakeReq.emit('error', error));
|
|
804
|
-
if (payload) inner.write(payload);
|
|
805
|
-
inner.end();
|
|
806
|
-
};
|
|
807
|
-
return fakeReq;
|
|
808
|
-
};
|
|
809
|
-
try {
|
|
810
|
-
await waitFor(() => !!transport._clientsForTest.get(initialToken)?.sse, 1_000);
|
|
811
|
-
transport._clientsForTest.get(initialToken)?.sse?.end();
|
|
812
|
-
await waitFor(() => truncated && replacementRequests === 1 && transport._clientsForTest.size === 1, 2_500);
|
|
813
|
-
const closeStarted = Date.now();
|
|
814
|
-
await client.close('truncated replacement response');
|
|
815
|
-
const closeElapsed = Date.now() - closeStarted;
|
|
816
|
-
await delay(1_200);
|
|
817
|
-
check('client: truncated committed replacement settles and close cancels unknown fresh client',
|
|
818
|
-
closeElapsed < 1_000 &&
|
|
819
|
-
cancellationObserved &&
|
|
820
|
-
replacementRequests === 1 &&
|
|
821
|
-
transport._clientsForTest.size === 0 &&
|
|
822
|
-
transport._registrationReplaysForTest.size === 0 &&
|
|
823
|
-
transport._resolveTargetForTest() === null);
|
|
824
|
-
} finally {
|
|
825
|
-
http.request = realHttpRequest;
|
|
826
|
-
await client.close('truncated replacement cleanup');
|
|
827
|
-
await transport.stop();
|
|
828
|
-
}
|
|
829
|
-
}
|
|
830
|
-
|
|
831
|
-
// Worker-only attach races: auth rejection re-reads discovery instead of
|
|
832
|
-
// rejecting start(), a wrong but alive discovery PID is refused, and stop()
|
|
833
|
-
// waits for an in-flight register so it cannot publish a client afterward.
|
|
834
|
-
async function workerAttachSafetyTest() {
|
|
835
|
-
const { createStandaloneChannelWorker } = await import('../src/standalone/channel-worker.mjs');
|
|
836
|
-
|
|
837
|
-
const wrongPidServer = await startTestServer((req, res) => {
|
|
838
|
-
if (new URL(req.url, 'http://127.0.0.1').pathname === '/health') return sendTestJson(res, 200, { status: 'ok', pid: process.pid });
|
|
839
|
-
sendTestJson(res, 404, { error: 'not found' });
|
|
840
|
-
});
|
|
841
|
-
let wrongPidRejected = false;
|
|
842
|
-
try {
|
|
843
|
-
await attachToDaemon({ discovery: { port: testPort(wrongPidServer), token: 'wrong-pid', pid: process.pid + 100_000 } });
|
|
844
|
-
} catch (err) { wrongPidRejected = err?.daemonDiscoveryStale === true; }
|
|
845
|
-
check('client: alive but wrong discovery PID is rejected', wrongPidRejected);
|
|
846
|
-
await stopTestServer(wrongPidServer);
|
|
847
|
-
|
|
848
|
-
const authTmp = mkdtempSync(path.join(os.tmpdir(), 'mixdog-auth-attach-'));
|
|
849
|
-
const authDiscovery = path.join(authTmp, 'channel-daemon.json');
|
|
850
|
-
const goodToken = 'good-discovery-token';
|
|
851
|
-
let rejectedRegisters = 0;
|
|
852
|
-
const authServer = await startTestServer(async (req, res) => {
|
|
853
|
-
const url = new URL(req.url, 'http://127.0.0.1');
|
|
854
|
-
if (url.pathname === '/health') return sendTestJson(res, 200, { status: 'ok', pid: process.pid });
|
|
855
|
-
if (url.pathname === '/client/register') {
|
|
856
|
-
await readJsonBody(req);
|
|
857
|
-
if (req.headers['x-mixdog-daemon-token'] !== goodToken) {
|
|
858
|
-
rejectedRegisters++;
|
|
859
|
-
return sendTestJson(res, 403, { error: 'forbidden' });
|
|
860
|
-
}
|
|
861
|
-
return sendTestJson(res, 200, { token: 'auth-client', pid: process.pid });
|
|
862
|
-
}
|
|
863
|
-
if (url.pathname === '/client/deregister') { await readJsonBody(req); return sendTestJson(res, 200, { ok: true }); }
|
|
864
|
-
if (url.pathname === '/events') { res.writeHead(200, { 'Content-Type': 'text/event-stream' }); res.write(': attached\n\n'); return; }
|
|
865
|
-
sendTestJson(res, 404, { error: 'not found' });
|
|
866
|
-
});
|
|
867
|
-
const authPort = testPort(authServer);
|
|
868
|
-
writeFileSync(authDiscovery, JSON.stringify({ pid: process.pid, port: authPort, token: 'stale-discovery-token' }));
|
|
869
|
-
process.env.MIXDOG_RUNTIME_ROOT = authTmp;
|
|
870
|
-
process.env.MIXDOG_DATA_DIR = authTmp;
|
|
871
|
-
process.env.MIXDOG_CHANNEL_DAEMON_ENTRY = STUB_ENTRY;
|
|
872
|
-
const authWorker = createStandaloneChannelWorker({ entry: STUB_ENTRY, rootDir: authTmp, dataDir: authTmp, cwd: authTmp });
|
|
873
|
-
const publishGoodDiscovery = setTimeout(() => {
|
|
874
|
-
writeFileSync(authDiscovery, JSON.stringify({ pid: process.pid, port: authPort, token: goodToken }));
|
|
875
|
-
}, 250);
|
|
876
|
-
await authWorker.start();
|
|
877
|
-
clearTimeout(publishGoodDiscovery);
|
|
878
|
-
check('worker: initial register 403 re-reads discovery and attaches', rejectedRegisters > 0);
|
|
879
|
-
await authWorker.stop();
|
|
880
|
-
await stopTestServer(authServer);
|
|
881
|
-
try { rmSync(authTmp, { recursive: true, force: true }); } catch {}
|
|
882
|
-
|
|
883
|
-
const permanentTmp = mkdtempSync(path.join(os.tmpdir(), 'mixdog-auth-permanent-'));
|
|
884
|
-
const permanentDiscovery = path.join(permanentTmp, 'channel-daemon.json');
|
|
885
|
-
let permanentRejects = 0;
|
|
886
|
-
const permanentServer = await startTestServer(async (req, res) => {
|
|
887
|
-
const url = new URL(req.url, 'http://127.0.0.1');
|
|
888
|
-
if (url.pathname === '/health') return sendTestJson(res, 200, { status: 'ok', pid: process.pid });
|
|
889
|
-
if (url.pathname === '/client/register') {
|
|
890
|
-
await readJsonBody(req);
|
|
891
|
-
permanentRejects++;
|
|
892
|
-
return sendTestJson(res, 403, { error: 'forbidden' });
|
|
893
|
-
}
|
|
894
|
-
sendTestJson(res, 404, { error: 'not found' });
|
|
895
|
-
});
|
|
896
|
-
writeFileSync(permanentDiscovery, JSON.stringify({
|
|
897
|
-
pid: process.pid, port: testPort(permanentServer), token: 'permanently-stale-token',
|
|
898
|
-
}));
|
|
899
|
-
process.env.MIXDOG_RUNTIME_ROOT = permanentTmp;
|
|
900
|
-
process.env.MIXDOG_DATA_DIR = permanentTmp;
|
|
901
|
-
const permanentWorker = createStandaloneChannelWorker({
|
|
902
|
-
entry: STUB_ENTRY, rootDir: permanentTmp, dataDir: permanentTmp, cwd: permanentTmp,
|
|
903
|
-
});
|
|
904
|
-
const permanentStartedAt = Date.now();
|
|
905
|
-
let permanentRejected = false;
|
|
906
|
-
try { await permanentWorker.start(); } catch (err) {
|
|
907
|
-
permanentRejected = /register rejected discovery auth 5 times/.test(err?.message || '');
|
|
908
|
-
}
|
|
909
|
-
const permanentElapsed = Date.now() - permanentStartedAt;
|
|
910
|
-
check('worker: permanent register 403 uses bounded backoff and terminates',
|
|
911
|
-
permanentRejected && permanentRejects === 5 && permanentElapsed >= 1000 && permanentElapsed < 6000);
|
|
912
|
-
await permanentWorker.stop();
|
|
913
|
-
await stopTestServer(permanentServer);
|
|
914
|
-
try { rmSync(permanentTmp, { recursive: true, force: true }); } catch {}
|
|
915
|
-
|
|
916
|
-
const stopTmp = mkdtempSync(path.join(os.tmpdir(), 'mixdog-stop-attach-'));
|
|
917
|
-
const stopDiscovery = path.join(stopTmp, 'channel-daemon.json');
|
|
918
|
-
let pendingRegister = null;
|
|
919
|
-
const deregistered = new Set();
|
|
920
|
-
const stopServer = await startTestServer(async (req, res) => {
|
|
921
|
-
const url = new URL(req.url, 'http://127.0.0.1');
|
|
922
|
-
if (url.pathname === '/health') return sendTestJson(res, 200, { status: 'ok', pid: process.pid });
|
|
923
|
-
if (url.pathname === '/client/register') { await readJsonBody(req); pendingRegister = res; return; }
|
|
924
|
-
if (url.pathname === '/client/deregister') {
|
|
925
|
-
const body = await readJsonBody(req);
|
|
926
|
-
deregistered.add(body.token);
|
|
927
|
-
return sendTestJson(res, 200, { ok: true });
|
|
928
|
-
}
|
|
929
|
-
if (url.pathname === '/events') { res.writeHead(200, { 'Content-Type': 'text/event-stream' }); res.write(': attached\n\n'); return; }
|
|
930
|
-
sendTestJson(res, 404, { error: 'not found' });
|
|
931
|
-
});
|
|
932
|
-
const stopPort = testPort(stopServer);
|
|
933
|
-
writeFileSync(stopDiscovery, JSON.stringify({ pid: process.pid, port: stopPort, token: 'stop-token' }));
|
|
934
|
-
process.env.MIXDOG_RUNTIME_ROOT = stopTmp;
|
|
935
|
-
process.env.MIXDOG_DATA_DIR = stopTmp;
|
|
936
|
-
const stopWorker = createStandaloneChannelWorker({ entry: STUB_ENTRY, rootDir: stopTmp, dataDir: stopTmp, cwd: stopTmp });
|
|
937
|
-
const startResult = stopWorker.start().then(() => 'started').catch(() => 'cancelled');
|
|
938
|
-
await waitFor(() => pendingRegister !== null, 1_500);
|
|
939
|
-
const stopping = stopWorker.stop('stop during attach');
|
|
940
|
-
sendTestJson(pendingRegister, 200, { token: 'stop-fresh', pid: process.pid });
|
|
941
|
-
const [stopped, startState] = await Promise.all([stopping, startResult]);
|
|
942
|
-
check('worker: stop during attach cannot publish a client',
|
|
943
|
-
stopped === false && startState === 'cancelled' && deregistered.has('stop-fresh'));
|
|
944
|
-
await stopTestServer(stopServer);
|
|
945
|
-
try { rmSync(stopTmp, { recursive: true, force: true }); } catch {}
|
|
946
|
-
}
|
|
947
|
-
|
|
948
|
-
// Fix coverage: (1) an SSE-reconnect re-register must NOT steal ownership;
|
|
949
|
-
// (2) a targeted 'superseded' emitted while the displaced client has no live
|
|
950
|
-
// stream is buffered and flushed when its stream (re)attaches.
|
|
951
|
-
async function reattachBufferTest() {
|
|
952
|
-
const tmp = mkdtempSync(path.join(os.tmpdir(), 'mixdog-reattach-smoke-'));
|
|
953
|
-
const discoveryPath = path.join(tmp, 'channel-daemon.json');
|
|
954
|
-
const transport = createChannelDaemonTransport({
|
|
955
|
-
handleCall: async () => ({ ok: true }),
|
|
956
|
-
discoveryPath, clientGraceMs: 2000, sweepMs: 5000, onClientsEmpty: () => {},
|
|
957
|
-
log: (m) => process.env.DAEMON_SMOKE_VERBOSE && console.log(`[reattach] ${m}`),
|
|
958
|
-
});
|
|
959
|
-
const { port, token } = await transport.start();
|
|
960
|
-
const REMOTE = 'notifications/mixdog/remote';
|
|
961
|
-
const s1 = spawn(process.execPath, ['-e', 'setTimeout(()=>{}, 60000)'], { stdio: 'ignore' });
|
|
962
|
-
const s2 = spawn(process.execPath, ['-e', 'setTimeout(()=>{}, 60000)'], { stdio: 'ignore' });
|
|
963
|
-
await delay(80);
|
|
964
|
-
|
|
965
|
-
// (1) reconnect with the SAME leadPid: the pointer FOLLOWS to the fresh token
|
|
966
|
-
// (its old dead-stream entry is dropped), never stealing from a DIFFERENT
|
|
967
|
-
// owner. Register X (fresh) → owner; re-register reattach:true → pointer moves
|
|
968
|
-
// to the new token and the old entry is gone (no dead-stream blackhole).
|
|
969
|
-
const regX = await rawPost(port, token, '/client/register', { leadPid: s1.pid, cwd: 'X' });
|
|
970
|
-
await delay(40);
|
|
971
|
-
check('reattach: fresh register X becomes owner',
|
|
972
|
-
transport._resolveTargetForTest()?.token === regX.token);
|
|
973
|
-
const regReconnect = await rawPost(port, token, '/client/register', { leadPid: s1.pid, cwd: 'X', reattach: true });
|
|
974
|
-
await delay(40);
|
|
975
|
-
check('reattach: reconnect (same pid) pointer follows fresh token, old dropped',
|
|
976
|
-
transport._resolveTargetForTest()?.token === regReconnect.token &&
|
|
977
|
-
!transport._clientsForTest.has(regX.token));
|
|
978
|
-
|
|
979
|
-
// (2) X's current entry never opened a stream; a fresh register Y steals the
|
|
980
|
-
// seat → X's targeted 'superseded' is BUFFERED, then delivered on late attach.
|
|
981
|
-
const regY = await rawPost(port, token, '/client/register', { leadPid: s2.pid, cwd: 'Y' });
|
|
982
|
-
await delay(40);
|
|
983
|
-
check('reattach: fresh Y steals the seat from X',
|
|
984
|
-
transport._resolveTargetForTest()?.token === regY.token);
|
|
985
|
-
const notX = [];
|
|
986
|
-
const sseX = rawSse(port, token, regReconnect.token, (m) => notX.push(m));
|
|
987
|
-
await waitFor(() => notX.some((m) => m?.method === REMOTE && m?.params?.state === 'superseded'), 1000);
|
|
988
|
-
check('reattach: buffered superseded flushed to X after late SSE attach',
|
|
989
|
-
notX.filter((m) => m?.method === REMOTE && m?.params?.state === 'superseded').length === 1);
|
|
990
|
-
|
|
991
|
-
try { sseX.destroy(); } catch {}
|
|
992
|
-
try { s1.kill(); } catch {}
|
|
993
|
-
try { s2.kill(); } catch {}
|
|
994
|
-
await transport.stop();
|
|
995
|
-
try { rmSync(tmp, { recursive: true, force: true }); } catch {}
|
|
996
|
-
}
|
|
997
|
-
|
|
998
|
-
main().catch((err) => { console.error(err); process.exit(1); });
|
|
999
|
-
|
|
1000
|
-
// Pointer-failover coverage: the pointer client dies (deregister) while a
|
|
1001
|
-
// live client remains. The transport must move the pointer to the survivor
|
|
1002
|
-
// (reason 'failover'), deliver the sticky 'acquired' badge to it, and
|
|
1003
|
-
// re-dispatch the survivor's stored bind intent via the injected dispatchBind
|
|
1004
|
-
// so the output forwarder rebinds to the survivor's transcript.
|
|
1005
|
-
async function pointerFailoverTest() {
|
|
1006
|
-
const tmp = mkdtempSync(path.join(os.tmpdir(), 'mixdog-failover-smoke-'));
|
|
1007
|
-
const discoveryPath = path.join(tmp, 'channel-daemon.json');
|
|
1008
|
-
const REMOTE = 'notifications/mixdog/remote';
|
|
1009
|
-
const rebinds = []; // failover re-dispatches routed through dispatchBind
|
|
1010
|
-
const handleCall = async (name, args, ctx) => ({ ok: true, name, args, leadPid: ctx.leadPid });
|
|
1011
|
-
const dispatchBind = (name, args, ctx) => {
|
|
1012
|
-
rebinds.push({ name, args, leadPid: ctx.leadPid });
|
|
1013
|
-
return handleCall(name, args, ctx);
|
|
1014
|
-
};
|
|
1015
|
-
const transport = createChannelDaemonTransport({
|
|
1016
|
-
handleCall,
|
|
1017
|
-
dispatchBind,
|
|
1018
|
-
discoveryPath, clientGraceMs: 2000, sweepMs: 5000, onClientsEmpty: () => {},
|
|
1019
|
-
log: (m) => process.env.DAEMON_SMOKE_VERBOSE && console.log(`[failover] ${m}`),
|
|
1020
|
-
});
|
|
1021
|
-
const { port, token } = await transport.start();
|
|
1022
|
-
const discovery = { port, token, pid: process.pid };
|
|
1023
|
-
|
|
1024
|
-
const sleeperA = spawn(process.execPath, ['-e', 'setTimeout(()=>{}, 60000)'], { stdio: 'ignore' });
|
|
1025
|
-
const sleeperB = spawn(process.execPath, ['-e', 'setTimeout(()=>{}, 60000)'], { stdio: 'ignore' });
|
|
1026
|
-
await delay(80);
|
|
1027
|
-
|
|
1028
|
-
const notA = [];
|
|
1029
|
-
const notB = [];
|
|
1030
|
-
const clientA = await attachToDaemon({ discovery, leadPid: sleeperA.pid, cwd: 'A', onNotify: (m) => notA.push(m) });
|
|
1031
|
-
await clientA.call('rebind_current_transcript', { transcriptPath: '/tmp/a.jsonl' }); // A stores bind intent
|
|
1032
|
-
const clientB = await attachToDaemon({ discovery, leadPid: sleeperB.pid, cwd: 'B', onNotify: (m) => notB.push(m) });
|
|
1033
|
-
await clientB.call('rebind_current_transcript', { transcriptPath: '/tmp/b.jsonl' }); // B owner + bind intent
|
|
1034
|
-
await delay(120);
|
|
1035
|
-
check('failover: newest binder (B) owns the pointer', transport._resolveTargetForTest()?.leadPid === sleeperB.pid);
|
|
1036
|
-
|
|
1037
|
-
const aBefore = notA.length;
|
|
1038
|
-
await clientB.close(); // pointer client dies → failover to survivor A
|
|
1039
|
-
await waitFor(() => transport._resolveTargetForTest()?.leadPid === sleeperA.pid, 1000);
|
|
1040
|
-
check('failover: pointer moves to surviving client (A)',
|
|
1041
|
-
transport._resolveTargetForTest()?.leadPid === sleeperA.pid);
|
|
1042
|
-
await waitFor(() => notA.slice(aBefore).some((m) => m?.method === REMOTE && m?.params?.state === 'acquired'), 1000);
|
|
1043
|
-
check('failover: survivor (A) receives the sticky acquired badge',
|
|
1044
|
-
notA.slice(aBefore).filter((m) => m?.method === REMOTE && m?.params?.state === 'acquired').length === 1);
|
|
1045
|
-
await waitFor(() => rebinds.some((r) => r.leadPid === sleeperA.pid), 1000);
|
|
1046
|
-
check('failover: survivor bind intent re-dispatched to its transcript',
|
|
1047
|
-
rebinds.some((r) => r.name === 'rebind_current_transcript'
|
|
1048
|
-
&& r.args?.transcriptPath === '/tmp/a.jsonl' && r.leadPid === sleeperA.pid));
|
|
1049
|
-
|
|
1050
|
-
await clientA.close();
|
|
1051
|
-
try { sleeperA.kill(); } catch {}
|
|
1052
|
-
try { sleeperB.kill(); } catch {}
|
|
1053
|
-
await transport.stop();
|
|
1054
|
-
try { rmSync(tmp, { recursive: true, force: true }); } catch {}
|
|
1055
|
-
}
|
|
1056
|
-
|
|
1057
|
-
// Reattach-after-pointer-loss: the pointer client's SSE drops and it
|
|
1058
|
-
// re-registers (reattach:true, SAME leadPid) with a FRESH token. The pointer
|
|
1059
|
-
// must FOLLOW to the new token (old dead-stream entry dropped, no failover/
|
|
1060
|
-
// self-superseded), and a notify must reach the NEW stream — not blackhole on
|
|
1061
|
-
// the old entry.
|
|
1062
|
-
async function pointerReconnectFollowsTest() {
|
|
1063
|
-
const tmp = mkdtempSync(path.join(os.tmpdir(), 'mixdog-reconnect-smoke-'));
|
|
1064
|
-
const discoveryPath = path.join(tmp, 'channel-daemon.json');
|
|
1065
|
-
const transport = createChannelDaemonTransport({
|
|
1066
|
-
handleCall: async () => ({ ok: true }),
|
|
1067
|
-
discoveryPath, clientGraceMs: 2000, sweepMs: 5000, onClientsEmpty: () => {},
|
|
1068
|
-
log: (m) => process.env.DAEMON_SMOKE_VERBOSE && console.log(`[reconnect] ${m}`),
|
|
1069
|
-
});
|
|
1070
|
-
const { port, token } = await transport.start();
|
|
1071
|
-
const s1 = spawn(process.execPath, ['-e', 'setTimeout(()=>{}, 60000)'], { stdio: 'ignore' });
|
|
1072
|
-
await delay(80);
|
|
1073
|
-
|
|
1074
|
-
// Register X (fresh) → owner; open its SSE, then let the stream drop.
|
|
1075
|
-
const regX = await rawPost(port, token, '/client/register', { leadPid: s1.pid, cwd: 'X' });
|
|
1076
|
-
const notOld = [];
|
|
1077
|
-
const sseOld = rawSse(port, token, regX.token, (m) => notOld.push(m));
|
|
1078
|
-
await delay(60);
|
|
1079
|
-
try { sseOld.destroy(); } catch {}
|
|
1080
|
-
await delay(40);
|
|
1081
|
-
|
|
1082
|
-
// Reconnect re-register (same leadPid) → fresh token; pointer must FOLLOW it.
|
|
1083
|
-
const regNew = await rawPost(port, token, '/client/register', { leadPid: s1.pid, cwd: 'X', reattach: true });
|
|
1084
|
-
await delay(40);
|
|
1085
|
-
check('reconnect: pointer follows the fresh reconnect token',
|
|
1086
|
-
transport._resolveTargetForTest()?.token === regNew.token && regNew.token !== regX.token);
|
|
1087
|
-
check('reconnect: dead old entry was dropped', !transport._clientsForTest.has(regX.token));
|
|
1088
|
-
|
|
1089
|
-
// A notify must reach the NEW stream (no blackhole on the old dead entry).
|
|
1090
|
-
const notNew = [];
|
|
1091
|
-
const sseNew = rawSse(port, token, regNew.token, (m) => notNew.push(m));
|
|
1092
|
-
await delay(60);
|
|
1093
|
-
transport.notify('notifications/claude/channel', { content: 'to-reconnect' });
|
|
1094
|
-
await waitFor(() => notNew.some((m) => m?.params?.content === 'to-reconnect'), 1000);
|
|
1095
|
-
check('reconnect: notify reaches the new stream, not the dead old entry',
|
|
1096
|
-
notNew.some((m) => m?.params?.content === 'to-reconnect') &&
|
|
1097
|
-
!notOld.some((m) => m?.params?.content === 'to-reconnect'));
|
|
1098
|
-
|
|
1099
|
-
try { sseNew.destroy(); } catch {}
|
|
1100
|
-
try { s1.kill(); } catch {}
|
|
1101
|
-
await transport.stop();
|
|
1102
|
-
try { rmSync(tmp, { recursive: true, force: true }); } catch {}
|
|
1103
|
-
}
|