create-walle 0.10.0 → 0.10.2
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 +88 -41
- package/lib/owner-write-safety.js +135 -0
- package/lib/public-runtime.js +13 -5
- package/lib/release-schema.json +3 -2
- package/lib/runtime-closure-policy.json +7 -5
- package/package.json +10 -4
- package/template/bin/deps-doctor.js +1 -1
- package/template/bin/node-bin.sh +11 -0
- package/template/coding-task-manager/api-prompts.js +10 -22
- package/template/coding-task-manager/bin/capture-codex-rollout-records.js +417 -0
- package/template/coding-task-manager/bin/census-codex-lead-history.js +325 -0
- package/template/coding-task-manager/bin/claude-hook.js +7 -3
- package/template/coding-task-manager/bin/claude-table-parity.js +307 -0
- package/template/coding-task-manager/bin/conversation-storage-auto-migration-lib.js +16 -1
- package/template/coding-task-manager/bin/conversation-storage-generation-evidence.js +9 -2
- package/template/coding-task-manager/bin/conversation-storage-service-entry-lib.js +16 -1
- package/template/coding-task-manager/bin/conversation-storage-walle-owner.js +247 -0
- package/template/coding-task-manager/bin/ctm-machine-pair.js +160 -0
- package/template/coding-task-manager/bin/lead-wait-census.js +1348 -0
- package/template/coding-task-manager/bin/repair-foreign-clone.js +564 -0
- package/template/coding-task-manager/bin/repair-resume-echo-titles.js +248 -0
- package/template/coding-task-manager/bin/seam-replay-codex.js +301 -0
- package/template/coding-task-manager/bin/write-lock-bench.js +111 -0
- package/template/coding-task-manager/db.js +705 -286
- package/template/coding-task-manager/git-utils.js +44 -0
- package/template/coding-task-manager/lib/adopted-host-redial.js +4 -0
- package/template/coding-task-manager/lib/agent-presets.js +15 -0
- package/template/coding-task-manager/lib/agent-title-apply.js +88 -0
- package/template/coding-task-manager/lib/agent-title-backfill-read.js +92 -0
- package/template/coding-task-manager/lib/api/host-stats.js +98 -0
- package/template/coding-task-manager/lib/api/ingress.js +35 -12
- package/template/coding-task-manager/lib/api/machine-pairing.js +285 -0
- package/template/coding-task-manager/lib/api/machine-pool.js +307 -0
- package/template/coding-task-manager/lib/api/machines.js +461 -0
- package/template/coding-task-manager/lib/api/misc-ops.js +20 -5
- package/template/coding-task-manager/lib/api/providers.js +53 -6
- package/template/coding-task-manager/lib/api/resume-from-store.js +25 -0
- package/template/coding-task-manager/lib/api/sessions.js +160 -8
- package/template/coding-task-manager/lib/api/transcript-history.js +17 -4
- package/template/coding-task-manager/lib/api/worktrees.js +38 -37
- package/template/coding-task-manager/lib/approval-drift.js +16 -1
- package/template/coding-task-manager/lib/approval-logical-frame.js +5 -1
- package/template/coding-task-manager/lib/approval-scope.js +68 -4
- package/template/coding-task-manager/lib/auth-context.js +32 -2
- package/template/coding-task-manager/lib/auth-rate-limit.js +41 -1
- package/template/coding-task-manager/lib/auth-rules.js +186 -45
- package/template/coding-task-manager/lib/broadcast-serialize.js +2 -1
- package/template/coding-task-manager/lib/browser-session-broker.js +9 -1
- package/template/coding-task-manager/lib/browser-use-adapter.js +33 -2
- package/template/coding-task-manager/lib/claude-markdown-ansi.js +27 -85
- package/template/coding-task-manager/lib/claude-session-title.js +6 -30
- package/template/coding-task-manager/lib/claude-table-layout.js +322 -0
- package/template/coding-task-manager/lib/client-diag-handler.js +25 -0
- package/template/coding-task-manager/lib/codex-config-guard.js +33 -3
- package/template/coding-task-manager/lib/codex-harness-qualification.js +12 -0
- package/template/coding-task-manager/lib/codex-history-terminal-renderer.js +33 -33
- package/template/coding-task-manager/lib/codex-launch-health.js +29 -2
- package/template/coding-task-manager/lib/codex-link-preparation.js +9 -8
- package/template/coding-task-manager/lib/codex-local-links.js +267 -0
- package/template/coding-task-manager/lib/codex-markdown-lists.js +160 -0
- package/template/coding-task-manager/lib/codex-native-history-prefix.js +4 -0
- package/template/coding-task-manager/lib/codex-paths.js +2 -1
- package/template/coding-task-manager/lib/codex-source-handoff.js +186 -0
- package/template/coding-task-manager/lib/coding-agent-models.js +21 -30
- package/template/coding-task-manager/lib/coding-session-model-adapters.js +27 -2
- package/template/coding-task-manager/lib/coding-session-model-projection.js +5 -0
- package/template/coding-task-manager/lib/coding-session-model-runtime.js +1 -2
- package/template/coding-task-manager/lib/coding-session-model-state.js +10 -0
- package/template/coding-task-manager/lib/coding-session-model-sync-state.js +0 -1
- package/template/coding-task-manager/lib/conversation-import/cursor-guard-owner.js +10 -0
- package/template/coding-task-manager/lib/create/resolve-launch-params.js +8 -1
- package/template/coding-task-manager/lib/ctm-boot-lifecycle.js +17 -1
- package/template/coding-task-manager/lib/db-domain-tables.js +7 -0
- package/template/coding-task-manager/lib/db-domains.js +21 -2
- package/template/coding-task-manager/lib/db-owner-op-registry.js +7 -0
- package/template/coding-task-manager/lib/db-owner-worker-client.js +22 -0
- package/template/coding-task-manager/lib/db-schema-source-files.js +1 -0
- package/template/coding-task-manager/lib/feature-manifest.js +1 -3
- package/template/coding-task-manager/lib/fs-watcher.js +3 -2
- package/template/coding-task-manager/lib/harness-history-transition.js +41 -14
- package/template/coding-task-manager/lib/headless-inflight-ops.js +203 -0
- package/template/coding-task-manager/lib/headless-missing-reply.js +42 -0
- package/template/coding-task-manager/lib/headless-term-service.js +60 -23
- package/template/coding-task-manager/lib/history-transition-read-op.js +179 -23
- package/template/coding-task-manager/lib/host-control-dial.js +167 -0
- package/template/coding-task-manager/lib/host-handoff-orchestrator.js +16 -2
- package/template/coding-task-manager/lib/host-handoff.js +51 -13
- package/template/coding-task-manager/lib/host-health-ledger.js +164 -12
- package/template/coding-task-manager/lib/host-self-truth-watchdog.js +4 -0
- package/template/coding-task-manager/lib/host-stats.js +328 -0
- package/template/coding-task-manager/lib/host-threadpool-evacuation.js +160 -0
- package/template/coding-task-manager/lib/host-threadpool-monitor.js +14 -1
- package/template/coding-task-manager/lib/hosted-input-dispatch.js +203 -0
- package/template/coding-task-manager/lib/hosted-sync-worker.js +113 -5
- package/template/coding-task-manager/lib/instance-id-ownership-manifest.js +4 -0
- package/template/coding-task-manager/lib/instance-manifest-runtime.js +3 -0
- package/template/coding-task-manager/lib/instance-manifest.js +13 -1
- package/template/coding-task-manager/lib/instance-sync-status.js +15 -0
- package/template/coding-task-manager/lib/kernel/diag-log.js +12 -0
- package/template/coding-task-manager/lib/launch-model-override.js +160 -1
- package/template/coding-task-manager/lib/launch-plan-worker.js +59 -2
- package/template/coding-task-manager/lib/lead-model-observer.js +17 -2
- package/template/coding-task-manager/lib/lead-model-switch-runtime.js +57 -4
- package/template/coding-task-manager/lib/lead-model-switch.js +11 -2
- package/template/coding-task-manager/lib/lead-model-transition-runtime.js +42 -4
- package/template/coding-task-manager/lib/lead-model-transition-store.js +61 -3
- package/template/coding-task-manager/lib/legacy-draft-cancellation-recovery.js +111 -0
- package/template/coding-task-manager/lib/login-tripwire.js +18 -0
- package/template/coding-task-manager/lib/machine-pairing.js +238 -0
- package/template/coding-task-manager/lib/machine-pool.js +409 -0
- package/template/coding-task-manager/lib/machine-registry.js +749 -0
- package/template/coding-task-manager/lib/machine-self.js +66 -0
- package/template/coding-task-manager/lib/main-thread-lint-exemptions.json +56 -4
- package/template/coding-task-manager/lib/media-journal-control-reader.js +473 -0
- package/template/coding-task-manager/lib/media-journal.js +84 -27
- package/template/coding-task-manager/lib/media-store-options.js +43 -15
- package/template/coding-task-manager/lib/migrations/v56-host-samples-and-machine-pool.js +76 -0
- package/template/coding-task-manager/lib/mirror-feed-sanitize.js +12 -3
- package/template/coding-task-manager/lib/model-execution-recipe-metadata.js +16 -3
- package/template/coding-task-manager/lib/model-proxy/anthropic-face.js +4 -0
- package/template/coding-task-manager/lib/model-proxy/chat-response-translators.js +3 -0
- package/template/coding-task-manager/lib/model-proxy/collaboration-codec.js +94 -0
- package/template/coding-task-manager/lib/model-proxy/handoff.js +494 -0
- package/template/coding-task-manager/lib/model-proxy/lead-worker-picks.js +25 -0
- package/template/coding-task-manager/lib/model-proxy/native-lead-listener.js +61 -7
- package/template/coding-task-manager/lib/model-proxy/proxy-cli.js +200 -0
- package/template/coding-task-manager/lib/model-proxy/proxy-instance-record.js +257 -0
- package/template/coding-task-manager/lib/model-proxy/responses-face.js +24 -2
- package/template/coding-task-manager/lib/model-proxy/stream-accounting.js +205 -0
- package/template/coding-task-manager/lib/model-proxy/tokens.js +78 -0
- package/template/coding-task-manager/lib/model-proxy/upstream-outcome.js +9 -0
- package/template/coding-task-manager/lib/native-history-control.js +62 -14
- package/template/coding-task-manager/lib/native-lead-settings-client.js +2 -1
- package/template/coding-task-manager/lib/native-lead-tls.js +18 -4
- package/template/coding-task-manager/lib/oauth-proxy-supervisor.js +628 -64
- package/template/coding-task-manager/lib/owner-action-manifest-data.json +14 -0
- package/template/coding-task-manager/lib/painted-row-recorder.js +9 -1
- package/template/coding-task-manager/lib/prompt-admission-trace.js +69 -0
- package/template/coding-task-manager/lib/prompt-submission-contract.js +21 -1
- package/template/coding-task-manager/lib/provider-login-broker.js +192 -9
- package/template/coding-task-manager/lib/provider-login-capture.js +153 -0
- package/template/coding-task-manager/lib/queue-draft-status-overlay.js +36 -0
- package/template/coding-task-manager/lib/raw-codex-history-window.js +5 -3
- package/template/coding-task-manager/lib/raw-history-block.js +3 -1
- package/template/coding-task-manager/lib/read-pool-client.js +36 -24
- package/template/coding-task-manager/lib/read-pool-op-registry.js +10 -1
- package/template/coding-task-manager/lib/read-pool-telemetry.js +14 -5
- package/template/coding-task-manager/lib/recent-catalog-query.js +26 -0
- package/template/coding-task-manager/lib/recent-session-catalog-response.js +9 -0
- package/template/coding-task-manager/lib/remote-command-context.js +12 -0
- package/template/coding-task-manager/lib/remote-command-runtime.js +18 -5
- package/template/coding-task-manager/lib/remote-machine-catalog.js +1062 -0
- package/template/coding-task-manager/lib/remote-machine-create.js +693 -0
- package/template/coding-task-manager/lib/remote-machine-link.js +939 -0
- package/template/coding-task-manager/lib/remote-machine-proxy.js +520 -0
- package/template/coding-task-manager/lib/remote-session-forward.js +306 -0
- package/template/coding-task-manager/lib/remote-submission-status.js +6 -0
- package/template/coding-task-manager/lib/restart-outcome-truth.js +20 -3
- package/template/coding-task-manager/lib/restart-preflight.js +9 -2
- package/template/coding-task-manager/lib/restore-backoff-gate.js +10 -2
- package/template/coding-task-manager/lib/restore-prepare-retry.js +12 -5
- package/template/coding-task-manager/lib/restore-resume-batch.js +22 -1
- package/template/coding-task-manager/lib/resume-blocked-wire.js +62 -1
- package/template/coding-task-manager/lib/resume-cwd.js +13 -0
- package/template/coding-task-manager/lib/resume-from-store.js +12 -1
- package/template/coding-task-manager/lib/resume-origin-facts.js +38 -1
- package/template/coding-task-manager/lib/resume-preflight.js +21 -16
- package/template/coding-task-manager/lib/review-repo-admission.js +39 -6
- package/template/coding-task-manager/lib/review-repo-registry.js +17 -0
- package/template/coding-task-manager/lib/root-delivery-runtime.js +72 -15
- package/template/coding-task-manager/lib/root-delivery-selection.js +37 -1
- package/template/coding-task-manager/lib/root-prompt-admission.js +43 -11
- package/template/coding-task-manager/lib/root-prompt-flow.js +175 -38
- package/template/coding-task-manager/lib/scrollback-archive-store.js +7 -4
- package/template/coding-task-manager/lib/server-memory-sampler-runtime.js +10 -0
- package/template/coding-task-manager/lib/server-memory-sampler.js +41 -2
- package/template/coding-task-manager/lib/session-analysis-memory.js +2 -0
- package/template/coding-task-manager/lib/session-create/codex-native-provider-gate.js +218 -0
- package/template/coding-task-manager/lib/session-create/codex-resume-exit-classify.js +52 -0
- package/template/coding-task-manager/lib/session-create/delegate-worker.js +95 -6
- package/template/coding-task-manager/lib/session-create/identity.js +20 -1
- package/template/coding-task-manager/lib/session-create/prepared.js +69 -1
- package/template/coding-task-manager/lib/session-create/provider-launch-context.js +77 -0
- package/template/coding-task-manager/lib/session-create/provider-process.js +38 -39
- package/template/coding-task-manager/lib/session-create/response.js +30 -12
- package/template/coding-task-manager/lib/session-create/transcript.js +13 -4
- package/template/coding-task-manager/lib/session-create/worker-continuation.js +43 -1
- package/template/coding-task-manager/lib/session-create/worker-notices.js +94 -5
- package/template/coding-task-manager/lib/session-create/worker-observability.js +99 -7
- package/template/coding-task-manager/lib/session-create/worker-stop.js +11 -3
- package/template/coding-task-manager/lib/session-create/worker-wait-watcher.js +315 -0
- package/template/coding-task-manager/lib/session-geometry-authority-handler.js +10 -1
- package/template/coding-task-manager/lib/session-host-manager.js +152 -206
- package/template/coding-task-manager/lib/session-jobs.js +17 -9
- package/template/coding-task-manager/lib/session-lifecycle.js +20 -50
- package/template/coding-task-manager/lib/session-model-action-preconditions.js +68 -3
- package/template/coding-task-manager/lib/session-resource-sampler.js +67 -9
- package/template/coding-task-manager/lib/session-standup.js +67 -4
- package/template/coding-task-manager/lib/session-status-ledger.js +42 -3
- package/template/coding-task-manager/lib/session-status-runtime.js +45 -1
- package/template/coding-task-manager/lib/session-status.js +2 -2
- package/template/coding-task-manager/lib/session-title-mirror.js +25 -57
- package/template/coding-task-manager/lib/session-transcript-trash.js +8 -1
- package/template/coding-task-manager/lib/startup-pointer-write.js +57 -0
- package/template/coding-task-manager/lib/state-sync/painted-frame-field.js +11 -0
- package/template/coding-task-manager/lib/storage/coding-session-model-store.js +21 -22
- package/template/coding-task-manager/lib/surviving-owner-bootstrap.js +7 -2
- package/template/coding-task-manager/lib/surviving-owner-recovery.js +2 -1
- package/template/coding-task-manager/lib/terminal-draft-http-status.js +25 -0
- package/template/coding-task-manager/lib/transcript-archiver.js +229 -229
- package/template/coding-task-manager/lib/transcript-frame-cache.js +12 -2
- package/template/coding-task-manager/lib/transcript-guard-job.js +55 -4
- package/template/coding-task-manager/lib/transcript-link.js +77 -0
- package/template/coding-task-manager/lib/transcript-manifest-store.js +8 -4
- package/template/coding-task-manager/lib/transcript-source.js +8 -3
- package/template/coding-task-manager/lib/transcript-store.js +40 -0
- package/template/coding-task-manager/lib/tunnel-lane-governor.js +6 -3
- package/template/coding-task-manager/lib/typing-interaction-trace.js +136 -2
- package/template/coding-task-manager/lib/typing-worker-stages.js +208 -1
- package/template/coding-task-manager/lib/usage-ledger/claude-parse.js +53 -2
- package/template/coding-task-manager/lib/usage-ledger/ingest.js +13 -4
- package/template/coding-task-manager/lib/usage-ledger/read.js +14 -0
- package/template/coding-task-manager/lib/usage-ledger/schema.js +49 -1
- package/template/coding-task-manager/lib/usage-ledger/store.js +337 -6
- package/template/coding-task-manager/lib/usage-ledger/task-read.js +123 -5
- package/template/coding-task-manager/lib/visible-revision-watchdog.js +8 -12
- package/template/coding-task-manager/lib/wake-skip-reasons.js +46 -0
- package/template/coding-task-manager/lib/walle-supervisor.js +98 -14
- package/template/coding-task-manager/lib/worker-failure-classify.js +13 -1
- package/template/coding-task-manager/lib/ws/input.js +1 -0
- package/template/coding-task-manager/lib/ws/state-sync.js +19 -2
- package/template/coding-task-manager/lib/ws/terminal-attach.js +32 -1
- package/template/coding-task-manager/lib/ws-router.js +68 -1
- package/template/coding-task-manager/lib/ws-send-admission.js +22 -4
- package/template/coding-task-manager/oauth-proxy.js +99 -55
- package/template/coding-task-manager/package-lock.json +24 -0
- package/template/coding-task-manager/package.json +3 -0
- package/template/coding-task-manager/protocol/message-table.json +0 -10
- package/template/coding-task-manager/protocol/messages.js +0 -10
- package/template/coding-task-manager/protocol/ws-schema.json +0 -20
- package/template/coding-task-manager/protocol/ws-schema.md +0 -2
- package/template/coding-task-manager/providers/codex-mcp-extract.js +121 -19
- package/template/coding-task-manager/providers/codex-mcp.js +14 -5
- package/template/coding-task-manager/public/css/app.css +279 -2
- package/template/coding-task-manager/public/css/reviews.css +4 -0
- package/template/coding-task-manager/public/index.html +297 -240
- package/template/coding-task-manager/public/js/app/machines-page.js +1252 -0
- package/template/coding-task-manager/public/js/app/models-tab.js +546 -36
- package/template/coding-task-manager/public/js/app/worktrees-panel.js +7 -5
- package/template/coding-task-manager/public/js/background-work.js +13 -7
- package/template/coding-task-manager/public/js/client-health-core.js +81 -0
- package/template/coding-task-manager/public/js/client-health-main.js +61 -0
- package/template/coding-task-manager/public/js/coding-session-model-presentation.js +44 -4
- package/template/coding-task-manager/public/js/core/session-commands.js +676 -0
- package/template/coding-task-manager/public/js/core/session-selectors.js +5 -1
- package/template/coding-task-manager/public/js/history-app-seam.js +26 -2
- package/template/coding-task-manager/public/js/history-painted-rows.js +17 -2
- package/template/coding-task-manager/public/js/history-reader-park.js +13 -1
- package/template/coding-task-manager/public/js/history-scroller.js +187 -9
- package/template/coding-task-manager/public/js/history-transcript-pages.js +5 -5
- package/template/coding-task-manager/public/js/machine-pool-shared.js +436 -0
- package/template/coding-task-manager/public/js/private-state-scope.js +31 -3
- package/template/coding-task-manager/public/js/prompt-submission-client.js +173 -4
- package/template/coding-task-manager/public/js/prompt-submission-tray.js +281 -0
- package/template/coding-task-manager/public/js/protocol.js +0 -10
- package/template/coding-task-manager/public/js/queue-panel-submission-reconcile.js +174 -1
- package/template/coding-task-manager/public/js/reviews.js +7 -3
- package/template/coding-task-manager/public/js/root-draft-lifetime.js +139 -2
- package/template/coding-task-manager/public/js/root-queue-builder-lifetime.js +16 -0
- package/template/coding-task-manager/public/js/row-join.js +18 -3
- package/template/coding-task-manager/public/js/session-model-picker.js +1 -1
- package/template/coding-task-manager/public/js/session-search-utils.js +46 -19
- package/template/coding-task-manager/public/js/session-usage-view.js +1 -0
- package/template/coding-task-manager/public/js/state-sync-client.js +4 -0
- package/template/coding-task-manager/public/js/terminal/runtime-02.js +16 -7
- package/template/coding-task-manager/public/js/terminal/runtime-03.js +41 -0
- package/template/coding-task-manager/public/js/terminal/runtime-04.js +41 -4
- package/template/coding-task-manager/public/js/terminal/runtime-12.js +52 -2
- package/template/coding-task-manager/public/js/terminal/runtime-16.js +12 -12
- package/template/coding-task-manager/public/js/terminal/runtime-18.js +14 -0
- package/template/coding-task-manager/public/js/terminal/runtime-19.js +8 -4
- package/template/coding-task-manager/public/js/terminal/runtime-20.js +210 -73
- package/template/coding-task-manager/public/js/terminal/runtime-21.js +19 -6
- package/template/coding-task-manager/public/js/terminal/runtime-22.js +86 -0
- package/template/coding-task-manager/public/js/terminal-history-viewport.js +1 -3
- package/template/coding-task-manager/public/js/terminal-wheel.js +39 -2
- package/template/coding-task-manager/public/js/walle/chat-conversation.js +11 -3
- package/template/coding-task-manager/public/js/walle-session.js +30 -7
- package/template/coding-task-manager/public/m/app.css +226 -8
- package/template/coding-task-manager/public/m/app.js +502 -9
- package/template/coding-task-manager/public/m/index.html +38 -1
- package/template/coding-task-manager/public/m/machines.js +1313 -0
- package/template/coding-task-manager/public/m/pool-failover.js +391 -0
- package/template/coding-task-manager/public/m/private-queue-actions.js +26 -7
- package/template/coding-task-manager/public/m/sw.js +5 -0
- package/template/coding-task-manager/queue-engine.js +13 -0
- package/template/coding-task-manager/server/approvals.js +5 -6
- package/template/coding-task-manager/server/boot.js +136 -17
- package/template/coding-task-manager/server.js +964 -425
- package/template/coding-task-manager/session-integrity.js +7 -9
- package/template/coding-task-manager/workers/db-owner-worker.js +53 -7
- package/template/coding-task-manager/workers/read-pool-worker.js +17 -6
- package/template/coding-task-manager/workers/session-host-pool-process.js +18 -4
- package/template/coding-task-manager/workers/session-host-process.js +14 -3
- package/template/coding-task-manager/workers/transcript-archive-worker.js +1 -1
- package/template/coding-task-manager/workers/worktree-worker.js +34 -0
- package/template/package-lock.json +2 -2
- package/template/package.json +1 -1
- package/template/scripts/conversation-storage-v2-migration.js +78 -11
- package/template/shared/conversation-storage-v2-contract.js +42 -6
- package/template/shared/fixtures/worker-status-payloads.json +392 -0
- package/template/shared/git-remote-key.js +83 -0
- package/template/shared/host-identity.js +98 -2
- package/template/shared/owner-write-safety.js +135 -0
- package/template/shared/public-runtime.js +13 -5
- package/template/shared/release-schema.json +3 -2
- package/template/shared/report-citations.js +14 -0
- package/template/shared/sqlite-write-lock.js +361 -223
- package/template/shared/storage/contract.js +35 -3
- package/template/wall-e/agent.js +21 -0
- package/template/wall-e/api-walle.js +5 -0
- package/template/wall-e/bin/brain-purge-legacy-ctm.js +3 -2
- package/template/wall-e/bin/walle-mcp-stdio.js +12 -6
- package/template/wall-e/brain/helper-rules-schema.js +129 -0
- package/template/wall-e/brain/migrations.js +51 -22
- package/template/wall-e/brain/model-deployment-facts.js +31 -1
- package/template/wall-e/brain/owner-call-whitelist.js +16 -0
- package/template/wall-e/brain/schema-ensures.js +22 -0
- package/template/wall-e/brain.js +1 -1
- package/template/wall-e/coding/host-capabilities.js +2 -2
- package/template/wall-e/coding/journal-dispatcher.js +51 -0
- package/template/wall-e/coding/session-journal.js +28 -40
- package/template/wall-e/coding-orchestrator.js +23 -26
- package/template/wall-e/fly.toml +1 -1
- package/template/wall-e/http/auth.js +18 -1
- package/template/wall-e/http/blog-views-api.js +318 -0
- package/template/wall-e/http/helper-rules-api.js +321 -0
- package/template/wall-e/http/model-proxy-providers.js +3 -1
- package/template/wall-e/http/telemetry-api.js +12 -0
- package/template/wall-e/lib/blog-view-policy.js +88 -0
- package/template/wall-e/lib/blog-view-store.js +169 -0
- package/template/wall-e/lib/client-config-lock.js +2 -1
- package/template/wall-e/lib/coding-journal-writer-child-client.js +67 -18
- package/template/wall-e/lib/error-kinds.js +10 -1
- package/template/wall-e/lib/feature-manifest.js +1 -1
- package/template/wall-e/lib/helper-rules/cell-state.js +123 -0
- package/template/wall-e/lib/helper-rules/evaluate.js +357 -0
- package/template/wall-e/lib/helper-rules/explore-rubric.md +12 -0
- package/template/wall-e/lib/helper-rules/grade.js +277 -0
- package/template/wall-e/lib/helper-rules/label.js +247 -0
- package/template/wall-e/lib/helper-rules/lead-call-hook.js +522 -0
- package/template/wall-e/lib/helper-rules/store.js +667 -0
- package/template/wall-e/lib/mcp-integration.js +5 -6
- package/template/wall-e/lib/runtime-worker-pool.js +1 -0
- package/template/wall-e/lib/scheduler-worker-jobs.js +6 -0
- package/template/wall-e/lib/telemetry-db-schema.js +37 -0
- package/template/wall-e/lib/telemetry-process-client.js +76 -1
- package/template/wall-e/llm/default-fallback.js +4 -0
- package/template/wall-e/llm/model-catalog.js +13 -4
- package/template/wall-e/llm/model-roles.js +6 -0
- package/template/wall-e/llm/portkey-http.js +150 -0
- package/template/wall-e/llm/portkey-sync.js +15 -145
- package/template/wall-e/llm/worker-mcp-routing.js +61 -0
- package/template/wall-e/llm/worker-route.js +43 -1
- package/template/wall-e/loops/brain-custom-writes.js +45 -0
- package/template/wall-e/mcp-protocol-contract.js +41 -1
- package/template/wall-e/mcp-server.js +13 -2
- package/template/wall-e/mcp-worker-schemas.js +20 -2
- package/template/wall-e/memory/ctm-context-client.js +25 -5
- package/template/wall-e/shared/release-schema.json +3 -2
- package/template/wall-e/shared/sqlite-write-lock.js +361 -223
- package/template/wall-e/shared/storage/contract.js +35 -3
- package/template/wall-e/skills/_templates/delegate-worker/VERSION +1 -1
- package/template/wall-e/skills/_templates/delegate-worker/released-sha256.json +6 -0
- package/template/wall-e/skills/_templates/delegate-worker/templates/claude/SKILL.md +7 -2
- package/template/wall-e/skills/_templates/delegate-worker/templates/codex/SKILL.md +7 -2
- package/template/wall-e/tools/local-tools.js +12 -5
- package/template/wall-e/workers/brain-owner-worker.js +9 -0
- package/template/wall-e/workers/coding-journal-writer-child.js +8 -1
- package/template/wall-e/workers/telemetry-worker.js +8 -0
- package/template/coding-task-manager/lib/giant-freeze-park.js +0 -31
|
@@ -25,11 +25,26 @@ const crypto = require('node:crypto');
|
|
|
25
25
|
const { spawn } = require('node:child_process');
|
|
26
26
|
const { resolveRealNode } = require('./real-node');
|
|
27
27
|
const { getDataDir } = require('./data-dir');
|
|
28
|
+
const { readExitRecord, clearExitRecord } = require('./model-proxy/stream-accounting');
|
|
29
|
+
const instanceRecord = require('./model-proxy/proxy-instance-record');
|
|
30
|
+
const { hostRuntimeCompatibilityDecision } = require('./host-runtime-compatibility');
|
|
31
|
+
const { hashClosureFiles } = require('./host-closure-hash');
|
|
32
|
+
const { HANDOFF_READY_DEADLINE_MS } = require('./model-proxy/handoff');
|
|
28
33
|
const walleClient = require('./walle-client');
|
|
29
34
|
|
|
30
35
|
const PROXY_SCRIPT = path.resolve(__dirname, '..', 'oauth-proxy.js');
|
|
31
36
|
const DEFAULT_PORT = parseInt(process.env.OAUTH_PROXY_PORT || '3459', 10);
|
|
32
37
|
const ADMIN_HEADER = 'X-CTM-Proxy-Admin';
|
|
38
|
+
// §5 D-3/§6: the proxy's own identity protocol and floor. A proxy that reports a different floor is not
|
|
39
|
+
// adoptable at all (hostRuntimeCompatibilityDecision), which is what makes a WP-2 successor safe to run
|
|
40
|
+
// beside its predecessor.
|
|
41
|
+
const PROXY_PROTOCOL = instanceRecord.PROXY_PROTOCOL;
|
|
42
|
+
const PROXY_FLOOR_VERSION = instanceRecord.PROXY_FLOOR_VERSION;
|
|
43
|
+
// hostRuntimeCompatibilityDecision() compares protocol VERSIONS numerically (its canonical caller passes
|
|
44
|
+
// host-registry's integer protocol), while /_health reports the wire NAME. The name is mapped to the
|
|
45
|
+
// version it names here, so an identical proxy answers 'compatible' rather than the 'upgrade-pending' a
|
|
46
|
+
// NaN-to-NaN comparison would produce for every restart -- which would schedule a handoff each time.
|
|
47
|
+
const PROXY_PROTOCOL_VERSION = 1;
|
|
33
48
|
const SAFE_LEAD_OWNER_ID = /^[A-Za-z0-9][A-Za-z0-9._:@/+\-]{0,255}$/;
|
|
34
49
|
const SAFE_LEAD_DEPLOYMENT_ID = /^[@A-Za-z0-9][A-Za-z0-9._:/@+-]{0,159}$/;
|
|
35
50
|
// WP-0A (claude-native-auth-mtls-and-routed-subagents-2026-10-15 §6.2, D-2/D-7): the immutable
|
|
@@ -74,6 +89,21 @@ const _pendingLeadRevokes = new Set();
|
|
|
74
89
|
// tokens. A tight crash loop must not become a spawn storm.
|
|
75
90
|
const LEAD_RESTART_MIN_INTERVAL_MS = 10_000;
|
|
76
91
|
let _lastLeadAutoRestartAt = 0;
|
|
92
|
+
// §5 D-6: how often CTM asks whether the ADOPTED proxy is still there. It is
|
|
93
|
+
// LEAD_RESTART_MIN_INTERVAL_MS by value ON PURPOSE: a probe that found a death
|
|
94
|
+
// faster than the restart rate limit could only be answered by the suppression
|
|
95
|
+
// line below, so probing faster would buy nothing but wakeups.
|
|
96
|
+
const PROXY_LIVENESS_MS = 10_000;
|
|
97
|
+
// The signal name a probe-found death carries into _onProxyGone(). It is not a
|
|
98
|
+
// real signal: it is what makes an adopted death "unexpected" to the ONE
|
|
99
|
+
// recovery branch below, exactly as a SIGKILL of a spawned child is.
|
|
100
|
+
const ADOPTED_PID_GONE = 'ADOPTED_PID_GONE';
|
|
101
|
+
// The armed probe, or null. Module memory only: it is a live timer, and it is
|
|
102
|
+
// dropped on stop()/_resetForTest() and whenever the mode leaves 'adopted'.
|
|
103
|
+
let _livenessTimer = null;
|
|
104
|
+
// §6 test seam: the cadence a test can shrink, so AC-3a's 10 s bound is not a
|
|
105
|
+
// 10 s test. Production never writes it.
|
|
106
|
+
let _livenessProbeMs = PROXY_LIVENESS_MS;
|
|
77
107
|
// Set by stop() right before it kills the child, cleared by the child's 'exit'
|
|
78
108
|
// handler. A graceful stop must never trigger the lead-token auto-restart.
|
|
79
109
|
let _stopping = false;
|
|
@@ -97,22 +127,141 @@ let _adminSecret = null;
|
|
|
97
127
|
// child's environment, and a request for a DIFFERENT directory while a child is running is refused
|
|
98
128
|
// rather than silently retargeted by an implicit restart.
|
|
99
129
|
let _activeChildDataDir = null;
|
|
130
|
+
// §5 D-10/§9 S-12: the last-exit record read by the most recent start(), or null when the previous exit
|
|
131
|
+
// left none. Module memory only, and consumed by lastRestartOutcome() for the restore_complete row.
|
|
132
|
+
let _lastExitRecord = null;
|
|
133
|
+
// The in-flight read+clear of that record; a test awaits it before asserting lastRestartOutcome().
|
|
134
|
+
let _exitRecordLoad = Promise.resolve();
|
|
135
|
+
|
|
136
|
+
// §5 D-4: which identity this module is holding. 'child' = a proxy THIS CTM spawned (the only mode that
|
|
137
|
+
// owns a process handle); 'adopting'/'adopted' = a surviving proxy found through its instance record,
|
|
138
|
+
// which CTM did not spawn and therefore only ever signals by ps-verified pid; 'stopped' = nothing.
|
|
139
|
+
let _mode = 'stopped';
|
|
140
|
+
let _adopted = null;
|
|
141
|
+
// The in-flight adoption decision (record → ps → /_health → secret → compatibility). waitForHealth()
|
|
142
|
+
// awaits it, because every caller already follows start() with waitForHealth().
|
|
143
|
+
let _adoptionPromise = null;
|
|
144
|
+
// Adoption attempt sequence: stop() and a newer start() bump it, so a probe that lands afterwards cannot
|
|
145
|
+
// adopt a proxy CTM has already given up on (or spawn a second child beside a fresh one).
|
|
146
|
+
let _adoptionSeq = 0;
|
|
147
|
+
|
|
148
|
+
function _ownedPid() {
|
|
149
|
+
if (_mode === 'child') return _child ? _child.pid : null;
|
|
150
|
+
return _adopted ? _adopted.pid : null;
|
|
151
|
+
}
|
|
100
152
|
|
|
101
153
|
function _ensureShutdownHook() {
|
|
102
154
|
if (_shutdownRegistered) return;
|
|
103
155
|
_shutdownRegistered = true;
|
|
156
|
+
// §5 D-5 (+ hosts' D-A6): a CTM exit or SIGTERM is a RECONNECT for the proxy, never a stop -- the
|
|
157
|
+
// surviving proxy is adopted by the next CTM (§5 D-4), so its in-flight streams are never cut. SIGINT
|
|
158
|
+
// is the one verb an operator expects to stop everything: it reaches a foreground `node server.js`,
|
|
159
|
+
// and the primary under launchd never receives one. stop() knows both identities (a child this CTM
|
|
160
|
+
// spawned, or a survivor it adopted) and also reaps the record and secret, so Ctrl-C leaves nothing
|
|
161
|
+
// for the next boot to adopt.
|
|
104
162
|
const stopOnExit = () => {
|
|
105
|
-
|
|
106
|
-
try { _child.kill('SIGTERM'); } catch {}
|
|
107
|
-
}
|
|
163
|
+
try { stop(); } catch { /* expected: the process is exiting; a failed signal changes nothing */ }
|
|
108
164
|
};
|
|
109
|
-
process.on('exit', stopOnExit);
|
|
110
165
|
process.on('SIGINT', stopOnExit);
|
|
111
|
-
process.on('SIGTERM', stopOnExit);
|
|
112
166
|
}
|
|
113
167
|
|
|
114
168
|
function isRunning() {
|
|
115
|
-
return !!(_child && !_child.killed && _child.exitCode === null);
|
|
169
|
+
if (_mode === 'child') return !!(_child && !_child.killed && _child.exitCode === null);
|
|
170
|
+
return _mode === 'adopted' || _mode === 'adopting';
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
// ── §5 D-6: liveness in adopted mode ─────────────────────────────────────
|
|
174
|
+
// A proxy THIS CTM spawned reports its own death: the child handle has an 'exit' event. An ADOPTED proxy
|
|
175
|
+
// has no handle -- it is somebody else's process that outlived the CTM that started it -- so nothing but
|
|
176
|
+
// CTM's own probe can notice the pid going away. That is the whole of D-6: one timer, the same
|
|
177
|
+
// _onProxyGone() handler, and the same one rate-limited restart on the other side of it.
|
|
178
|
+
|
|
179
|
+
function _clearLivenessProbe() {
|
|
180
|
+
if (_livenessTimer) clearTimeout(_livenessTimer);
|
|
181
|
+
_livenessTimer = null;
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
/** §6 test seam: shrink the probe cadence. Pass null (or anything not a positive integer) to restore. */
|
|
185
|
+
function _setLivenessProbeMsForTest(ms) {
|
|
186
|
+
_livenessProbeMs = Number.isSafeInteger(ms) && ms > 0 ? ms : PROXY_LIVENESS_MS;
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
/**
|
|
190
|
+
* §5 D-6: arm the ONE probe for the proxy currently adopted. Re-arming replaces the timer, so a call
|
|
191
|
+
* after a WP-2 handoff watches the SUCCESSOR's pid and not the predecessor's.
|
|
192
|
+
*/
|
|
193
|
+
function _armLivenessProbe({ port, dataDir }) {
|
|
194
|
+
_clearLivenessProbe();
|
|
195
|
+
if (_mode !== 'adopted' || !_adopted) return;
|
|
196
|
+
_livenessTimer = setTimeout(() => {
|
|
197
|
+
_livenessTimer = null;
|
|
198
|
+
// Re-checked at fire time: a stop() or a newer adoption in between owns the state now.
|
|
199
|
+
if (_mode !== 'adopted' || !_adopted) return;
|
|
200
|
+
const pid = _adopted.pid;
|
|
201
|
+
if (instanceRecord.isProcessAlive(pid)) {
|
|
202
|
+
_armLivenessProbe({ port, dataDir });
|
|
203
|
+
return;
|
|
204
|
+
}
|
|
205
|
+
_onProxyGone({ code: null, signal: ADOPTED_PID_GONE, port, dataDir });
|
|
206
|
+
}, _livenessProbeMs);
|
|
207
|
+
// §5 D-6: a probe is never a reason for CTM to stay alive.
|
|
208
|
+
_livenessTimer.unref();
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
/**
|
|
212
|
+
* §5 D-6: an admin call that cannot reach an ADOPTED proxy is the cheapest probe there is -- run the same
|
|
213
|
+
* test the timer runs, so a dead proxy is noticed now instead of on the next tick. Only the two codes that
|
|
214
|
+
* mean "the process is not serving" count; a timeout is not evidence of death.
|
|
215
|
+
*/
|
|
216
|
+
function _probeAdoptedAfterAdminFailure({ port, err }) {
|
|
217
|
+
const code = (err && err.cause && err.cause.code) || (err && err.code);
|
|
218
|
+
if (code !== 'ECONNREFUSED' && code !== 'ECONNRESET') return;
|
|
219
|
+
if (_mode !== 'adopted' || !_adopted) return;
|
|
220
|
+
// Without the directory the record on disk belongs to, the recovery below cannot reap it and a restart
|
|
221
|
+
// would target the wrong root -- so the probe stays silent rather than acting on a guess.
|
|
222
|
+
if (!_activeChildDataDir) return;
|
|
223
|
+
if (instanceRecord.isProcessAlive(_adopted.pid)) return;
|
|
224
|
+
_onProxyGone({ code: null, signal: ADOPTED_PID_GONE, port, dataDir: _activeChildDataDir });
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
/**
|
|
228
|
+
* §5 D-10 / §9 S-12: the proxy's own restart outcome, as the restore_complete ledger row reports it
|
|
229
|
+
* (`adoption.proxy`). Exactly eight keys, so lib/restart-ledger.js's boundedRecordDetail keeps it whole.
|
|
230
|
+
* `action` is 'spawn' for the fresh child start() created and 'adopt' for the surviving proxy it took
|
|
231
|
+
* over; the previous-exit fields come from the record that spawn consumed and are null when the previous
|
|
232
|
+
* exit left none -- an ADOPTED proxy never exited, so a stale exit record must not be reported as this
|
|
233
|
+
* restart's cut (WP-1 §5 D-5).
|
|
234
|
+
*/
|
|
235
|
+
function lastRestartOutcome() {
|
|
236
|
+
// Only a DECIDED adoption is reported as one; while the decision is in flight the fallback may still
|
|
237
|
+
// be a spawn, and the restore_complete row must not name an outcome that has not happened yet.
|
|
238
|
+
if (_adopted && _mode === 'adopted') {
|
|
239
|
+
return {
|
|
240
|
+
// §5 D-7 (WP-2): an adoption that also performed a handoff is reported as one, with the measured
|
|
241
|
+
// handoff time and the SUCCESSOR's pid/generation (a handoff always bumps the generation).
|
|
242
|
+
// `drainForcedCuts` stays null at restore time on purpose: the predecessor's drain finishes later,
|
|
243
|
+
// in its own process, and its count is only in the old proxy's log line.
|
|
244
|
+
action: _adopted.handoffMs == null ? 'adopt' : 'handoff',
|
|
245
|
+
pid: _adopted.pid,
|
|
246
|
+
generation: _adopted.generation,
|
|
247
|
+
openStreamsAtExit: null,
|
|
248
|
+
handoffMs: _adopted.handoffMs == null ? null : _adopted.handoffMs,
|
|
249
|
+
drainForcedCuts: null,
|
|
250
|
+
previousPid: null,
|
|
251
|
+
previousExitAt: null,
|
|
252
|
+
};
|
|
253
|
+
}
|
|
254
|
+
const record = _lastExitRecord;
|
|
255
|
+
return {
|
|
256
|
+
action: 'spawn',
|
|
257
|
+
pid: isRunning() ? _ownedPid() : null,
|
|
258
|
+
generation: 1,
|
|
259
|
+
openStreamsAtExit: record ? record.openStreams : null,
|
|
260
|
+
handoffMs: null,
|
|
261
|
+
drainForcedCuts: null,
|
|
262
|
+
previousPid: record ? record.pid : null,
|
|
263
|
+
previousExitAt: record ? record.at : null,
|
|
264
|
+
};
|
|
116
265
|
}
|
|
117
266
|
|
|
118
267
|
function start({ port = DEFAULT_PORT, dataDir } = {}) {
|
|
@@ -122,12 +271,155 @@ function start({ port = DEFAULT_PORT, dataDir } = {}) {
|
|
|
122
271
|
if (_activeChildDataDir && _activeChildDataDir !== resolvedDataDir) {
|
|
123
272
|
// No implicit restart: the running child owns its directory (and any mounted authority, tokens and
|
|
124
273
|
// worker budgets that came with it). The caller must stop it explicitly.
|
|
125
|
-
return { ok: false, running: true, port, pid:
|
|
274
|
+
return { ok: false, running: true, port, pid: _ownedPid(), reason: 'data_dir_mismatch' };
|
|
126
275
|
}
|
|
127
|
-
return { ok: true, running: true, port, pid:
|
|
276
|
+
return { ok: true, running: true, port, pid: _ownedPid(), alreadyRunning: true };
|
|
128
277
|
}
|
|
129
278
|
_ensureShutdownHook();
|
|
130
279
|
_lastError = null;
|
|
280
|
+
// §5 D-4 (+ D-11, D-12): ADOPT BEFORE SPAWN, on the boot path and on the crash-recovery path alike. A
|
|
281
|
+
// record that names a live, ps-verified proxy on THIS port and in THIS data directory is the previous
|
|
282
|
+
// process answering; it is taken over instead of being replaced (and its in-flight streams are never
|
|
283
|
+
// cut). Anything else -- a dead pid, another port, a foreign process that reused the pid -- is stale
|
|
284
|
+
// truth and is removed before the fresh spawn below. The decision itself is synchronous because six
|
|
285
|
+
// callers and the boot log read start()'s return value; only the HTTP half is asynchronous.
|
|
286
|
+
const record = instanceRecord.readInstanceRecord({ dataDir: resolvedDataDir });
|
|
287
|
+
if (record && record.port === port && instanceRecord.isProxyProcess(record.pid)) {
|
|
288
|
+
_mode = 'adopting';
|
|
289
|
+
_adopted = { pid: record.pid, generation: record.generation, closureHash: null, decision: null };
|
|
290
|
+
_adminSecret = null;
|
|
291
|
+
_activeChildDataDir = resolvedDataDir;
|
|
292
|
+
_stopping = false;
|
|
293
|
+
const seq = (_adoptionSeq += 1);
|
|
294
|
+
_adoptionPromise = _finishAdoption({ port, dataDir: resolvedDataDir, record, seq });
|
|
295
|
+
return { ok: true, running: true, port, pid: record.pid, mode: 'adopting' };
|
|
296
|
+
}
|
|
297
|
+
if (record) instanceRecord.unlinkInstanceRecord({ dataDir: resolvedDataDir });
|
|
298
|
+
return _spawnFresh({ port, resolvedDataDir });
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
/**
|
|
302
|
+
* §5 D-4: the asynchronous half of an adoption -- the secret read, the two loopback probes and the
|
|
303
|
+
* compatibility verdict. On success the proxy keeps running under this CTM; on any failure the record is
|
|
304
|
+
* reaped and the caller's start() falls through to a fresh spawn, so the next isRunning()/waitForHealth()
|
|
305
|
+
* sees a child. WP-1 adopts an `upgrade-pending` proxy and leaves it running: the handoff that would
|
|
306
|
+
* replace it is WP-2.
|
|
307
|
+
*/
|
|
308
|
+
async function _finishAdoption({ port, dataDir, record, seq }) {
|
|
309
|
+
try {
|
|
310
|
+
const secret = instanceRecord.readAdminSecret({ dataDir });
|
|
311
|
+
const health = secret ? await _probeHealth(port) : null;
|
|
312
|
+
const authed = secret ? await _probeAdminSecret({ port, secret }) : false;
|
|
313
|
+
const decision = health && authed && health.pid === record.pid
|
|
314
|
+
? hostRuntimeCompatibilityDecision({
|
|
315
|
+
expectedProtocol: PROXY_PROTOCOL_VERSION,
|
|
316
|
+
actualProtocol: health.protocol === PROXY_PROTOCOL ? PROXY_PROTOCOL_VERSION : 0,
|
|
317
|
+
expectedFloor: PROXY_FLOOR_VERSION,
|
|
318
|
+
actualFloor: health.floor_version,
|
|
319
|
+
diskClosureHash: _hashClosureFiles(health.closure_files),
|
|
320
|
+
hostClosureHash: health.closure_hash,
|
|
321
|
+
})
|
|
322
|
+
: { compatible: false, reason: secret ? 'proxy_not_answering' : 'admin_secret_missing' };
|
|
323
|
+
if (seq !== _adoptionSeq) return; // a stop() or a newer start() superseded this attempt
|
|
324
|
+
if (decision.compatible) {
|
|
325
|
+
_mode = 'adopted';
|
|
326
|
+
_adopted = { pid: record.pid, generation: record.generation, closureHash: health.closure_hash, decision: decision.reason };
|
|
327
|
+
_adminSecret = secret;
|
|
328
|
+
_activeChildDataDir = dataDir;
|
|
329
|
+
// §5 D-5: the previous proxy did not exit, so there is no previous exit to report -- and a
|
|
330
|
+
// last-exit.json left behind by some earlier stop must not be attributed to this restart.
|
|
331
|
+
_lastExitRecord = null;
|
|
332
|
+
_exitRecordLoad = Promise.resolve();
|
|
333
|
+
// D-4 step 7: replay into the adopted proxy exactly as the fresh-spawn path does; the token
|
|
334
|
+
// registry and the pick table live in CTM, the adopted proxy's own table survived with it.
|
|
335
|
+
await _replayLeadTokens({ port });
|
|
336
|
+
process.stderr.write(`[oauth-proxy-supervisor] adopted proxy pid=${record.pid} generation=${record.generation} decision=${decision.reason}\n`);
|
|
337
|
+
// §5 D-7 (WP-2): `upgrade-pending` means the proxy answering on the wire is not the code on disk,
|
|
338
|
+
// so it is asked ONCE to hand its listening sockets and its memory-only state to a successor it
|
|
339
|
+
// spawns from that disk. Adopting first (above) is what makes a refusal harmless: the adopted
|
|
340
|
+
// proxy keeps serving, and the next restart asks again.
|
|
341
|
+
if (decision.reason === 'upgrade-pending') await _performUpgradeHandoff({ port });
|
|
342
|
+
// §5 D-6, LAST: after the handoff above, `_adopted.pid` is the SUCCESSOR's, which is the process
|
|
343
|
+
// that will be answering from here on. Nothing before this point may probe -- the adoption is not
|
|
344
|
+
// decided yet (and _pollHealth, never waitForHealth, is what the steps above may await).
|
|
345
|
+
_armLivenessProbe({ port, dataDir });
|
|
346
|
+
return;
|
|
347
|
+
}
|
|
348
|
+
// Rejected: reap the record. The pid is signalled ONLY when ps proved it is this proxy -- a language
|
|
349
|
+
// runtime is free to hand a dead proxy's pid to an unrelated process, and that process is not ours.
|
|
350
|
+
process.stderr.write(`[oauth-proxy-supervisor] proxy record rejected: ${decision.reason}\n`);
|
|
351
|
+
instanceRecord.unlinkInstanceRecord({ dataDir });
|
|
352
|
+
try { process.kill(record.pid, 'SIGTERM'); } catch { /* expected: the pid may be gone; the record is already reaped */ }
|
|
353
|
+
} catch (err) {
|
|
354
|
+
// REPORT: an adoption that throws is refused like any other; the spawn below is the fallback.
|
|
355
|
+
process.stderr.write(`[oauth-proxy-supervisor] adoption failed: ${String(err && err.message || err).slice(0, 200)}\n`);
|
|
356
|
+
instanceRecord.unlinkInstanceRecord({ dataDir });
|
|
357
|
+
}
|
|
358
|
+
if (seq !== _adoptionSeq) return;
|
|
359
|
+
_mode = 'stopped';
|
|
360
|
+
_adopted = null;
|
|
361
|
+
_adminSecret = null;
|
|
362
|
+
_activeChildDataDir = null;
|
|
363
|
+
_adoptionPromise = null;
|
|
364
|
+
_spawnFresh({ port, resolvedDataDir: dataDir });
|
|
365
|
+
}
|
|
366
|
+
|
|
367
|
+
/**
|
|
368
|
+
* §5 D-7 (WP-2, AC-2f): the ONE handoff a `upgrade-pending` adoption schedules. On 202 the module
|
|
369
|
+
* switches to the successor's identity and replays CTM's lead registry and pick table into it exactly as
|
|
370
|
+
* the fresh-spawn path does (idempotent: the rows that survived with the predecessor are re-registered
|
|
371
|
+
* verbatim). A refusal or a throw is REPORTED and the adopted proxy is kept -- it still serves, and there
|
|
372
|
+
* is nothing here worth failing a boot over.
|
|
373
|
+
*/
|
|
374
|
+
async function _performUpgradeHandoff({ port }) {
|
|
375
|
+
let handed = null;
|
|
376
|
+
try {
|
|
377
|
+
handed = await requestHandoff({ port, reason: 'upgrade-pending' });
|
|
378
|
+
} catch (err) {
|
|
379
|
+
// REPORT: the old proxy is still serving; the next restart asks again.
|
|
380
|
+
process.stderr.write(`[oauth-proxy-supervisor] proxy handoff refused: ${String((err && err.reason) || (err && err.message) || err).slice(0, 200)}\n`);
|
|
381
|
+
return;
|
|
382
|
+
}
|
|
383
|
+
// The successor loaded the code on disk, so the predecessor's closure hash is no longer this adoption's
|
|
384
|
+
// identity: status() and the restore_complete row must describe the process now answering. (WP-4 live
|
|
385
|
+
// proof, 2026-10-23: status reported the predecessor's hash after a handoff while /_health had the new one.)
|
|
386
|
+
const successorHealth = await _probeHealth(port);
|
|
387
|
+
_adopted = {
|
|
388
|
+
..._adopted,
|
|
389
|
+
pid: handed.successorPid,
|
|
390
|
+
generation: handed.generation,
|
|
391
|
+
handoffMs: handed.handoffMs,
|
|
392
|
+
decision: 'upgrade-pending',
|
|
393
|
+
closureHash: successorHealth && successorHealth.pid === handed.successorPid ? (successorHealth.closure_hash || null) : null,
|
|
394
|
+
};
|
|
395
|
+
// The successor starts with the token table the predecessor handed over PLUS whatever CTM replays; the
|
|
396
|
+
// replay is the same idempotent call the fresh-spawn path makes, so a token cannot be double-registered.
|
|
397
|
+
// Its ROUTE and PICK tables, though, start EMPTY (§1.5 rows 4-5: both are CTM's, and the handoff
|
|
398
|
+
// carries only the state that has no other authority), so `successor:true` pushes them even when CTM
|
|
399
|
+
// holds no lead token at all -- the carried-over tokens would otherwise 404 on `unknown route alias`
|
|
400
|
+
// until the next provider save happened to re-push.
|
|
401
|
+
await _replayLeadTokens({ port, successor: true });
|
|
402
|
+
process.stderr.write(`[oauth-proxy-supervisor] proxy handoff generation=${handed.generation} successorPid=${handed.successorPid} handoffMs=${handed.handoffMs}\n`);
|
|
403
|
+
}
|
|
404
|
+
|
|
405
|
+
function _spawnFresh({ port, resolvedDataDir }) {
|
|
406
|
+
_mode = 'child';
|
|
407
|
+
_adopted = null;
|
|
408
|
+
_adoptionPromise = null;
|
|
409
|
+
// §5 D-6: the mode is leaving 'adopted', and a spawned child reports its own death.
|
|
410
|
+
_clearLivenessProbe();
|
|
411
|
+
// §5 D-10 / §9 S-12: what the PREVIOUS proxy was still carrying when it stopped. Read once, here,
|
|
412
|
+
// immediately before a fresh spawn, and cleared so one exit is reported once. The outcome this module
|
|
413
|
+
// reports afterwards always describes THIS spawn. The read is async (start() stays synchronous);
|
|
414
|
+
// lastRestartOutcome() answers null for the previous-exit fields until it lands, which is well before
|
|
415
|
+
// the restore_complete row that reads them.
|
|
416
|
+
_lastExitRecord = null;
|
|
417
|
+
_exitRecordLoad = readExitRecord({ dataDir: resolvedDataDir })
|
|
418
|
+
.then((record) => { _lastExitRecord = record; return clearExitRecord({ dataDir: resolvedDataDir }); })
|
|
419
|
+
.catch((err) => {
|
|
420
|
+
// REPORT: an unreadable record costs the ledger one field, never the boot.
|
|
421
|
+
process.stderr.write(`[oauth-proxy-supervisor] exit record unreadable: ${String(err && err.code || err).slice(0, 80)}\n`);
|
|
422
|
+
});
|
|
131
423
|
// A superseded child may exit late with _stopping still set; this spawn is not a stop, so the
|
|
132
424
|
// stale flag can never opt a future crash out of the lead auto-restart.
|
|
133
425
|
_stopping = false;
|
|
@@ -147,73 +439,54 @@ function start({ port = DEFAULT_PORT, dataDir } = {}) {
|
|
|
147
439
|
// stdin (never env/argv — oauth-proxy.js:_readAdminSecretFromStdin() is the
|
|
148
440
|
// only reader). Kept in module memory for the admin-channel calls below.
|
|
149
441
|
_adminSecret = crypto.randomBytes(32).toString('hex');
|
|
442
|
+
// §5 D-2: the secret is on disk BEFORE the child exists. A CTM that dies between this spawn and its
|
|
443
|
+
// own exit leaves no adopter behind, but the NEXT boot finds a readable secret and can take the
|
|
444
|
+
// survivor over; the child is handed the very same value on stdin.
|
|
445
|
+
instanceRecord.writeAdminSecret({ dataDir: resolvedDataDir, secret: _adminSecret });
|
|
446
|
+
// §5 D-1: the child's stdout/stderr ARE the log file (rotated once per generation inside
|
|
447
|
+
// openProxyLogFd). No pipe to relay and nothing for an orphaned child to write into -- a write to a
|
|
448
|
+
// dead parent's pipe is what killed the previous shape with EPIPE.
|
|
449
|
+
const logFd = instanceRecord.openProxyLogFd(resolvedDataDir);
|
|
150
450
|
try {
|
|
151
451
|
_child = spawn(resolveRealNode(), [PROXY_SCRIPT], {
|
|
152
|
-
|
|
452
|
+
detached: true,
|
|
453
|
+
stdio: ['pipe', logFd, logFd],
|
|
153
454
|
env: childEnv,
|
|
154
|
-
detached: false,
|
|
155
455
|
});
|
|
156
456
|
} catch (err) {
|
|
457
|
+
instanceRecord.closeProxyLogFd(logFd);
|
|
157
458
|
_lastError = err.message;
|
|
158
459
|
_adminSecret = null;
|
|
460
|
+
_mode = 'stopped';
|
|
159
461
|
return { ok: false, running: false, error: err.message };
|
|
160
462
|
}
|
|
463
|
+
instanceRecord.closeProxyLogFd(logFd); // the child holds its own copy of the descriptor
|
|
464
|
+
// §5 D-1: CTM is never held open by the proxy it spawned -- the proxy outlives this process and the
|
|
465
|
+
// next CTM adopts it (D-4), so nothing here may wait on its exit.
|
|
466
|
+
_child.unref();
|
|
161
467
|
// D-13: capture THIS spawn so a superseded child's late 'exit' (stop() -> start(), or a rapid
|
|
162
468
|
// restart) cannot null the current child's handle / admin secret out from under a fresh boot.
|
|
163
469
|
const child = _child;
|
|
470
|
+
// S-5: the secret travels on stdin, never env/argv -- oauth-proxy.js:_readAdminSecretFromStdin() is
|
|
471
|
+
// the only reader, and the child starts as soon as that line ends.
|
|
164
472
|
try {
|
|
165
473
|
_child.stdin.write(_adminSecret + '\n');
|
|
166
474
|
_child.stdin.end();
|
|
167
475
|
} catch (err) {
|
|
168
|
-
// expected: the child may exit before stdin flushes on a fast startup
|
|
169
|
-
//
|
|
170
|
-
// write.
|
|
476
|
+
// expected: the child may exit before stdin flushes on a fast startup failure -- the health probe
|
|
477
|
+
// below is the authoritative signal, not this write.
|
|
171
478
|
_lastError = `admin secret handoff failed: ${err.message}`;
|
|
172
479
|
}
|
|
173
480
|
_startedAt = Date.now();
|
|
174
|
-
|
|
175
|
-
// Forward to CTM stderr so it lands in the same log files. Prefix to
|
|
176
|
-
// disambiguate.
|
|
177
|
-
process.stderr.write('[oauth-proxy] ' + d.toString());
|
|
178
|
-
});
|
|
179
|
-
_child.stderr.on('data', (d) => {
|
|
180
|
-
process.stderr.write('[oauth-proxy] ' + d.toString());
|
|
181
|
-
});
|
|
182
|
-
_child.on('error', (err) => {
|
|
481
|
+
child.on('error', (err) => {
|
|
183
482
|
_lastError = err.message;
|
|
184
483
|
});
|
|
185
|
-
|
|
484
|
+
child.on('exit', (code, signal) => {
|
|
186
485
|
// D-13: a superseded child (stop() -> start(), or a rapid restart) can exit LATE, after a newer
|
|
187
486
|
// child is live. Its exit owns none of the module state below -- nulling the current child's
|
|
188
487
|
// handle or admin secret would tear the live proxy's admin channel down.
|
|
189
488
|
if (child !== _child) return;
|
|
190
|
-
|
|
191
|
-
_lastError = `exited code=${code}`;
|
|
192
|
-
}
|
|
193
|
-
_child = null;
|
|
194
|
-
_startedAt = null;
|
|
195
|
-
_adminSecret = null;
|
|
196
|
-
_activeChildDataDir = null;
|
|
197
|
-
// The dead child's in-memory token table is gone; any revoke that was still
|
|
198
|
-
// pending against it no longer has anything to reap.
|
|
199
|
-
_pendingLeadRevokes.clear();
|
|
200
|
-
// WP-0 (§6.2): an unexpected exit (signal, or a non-zero code) with live
|
|
201
|
-
// lead tokens must bring the proxy back so those tokens keep working. A
|
|
202
|
-
// graceful stop() opts out via _stopping; a crash-loop is rate-limited.
|
|
203
|
-
const unexpected = !!signal || code !== 0;
|
|
204
|
-
const wasStopping = _stopping;
|
|
205
|
-
_stopping = false;
|
|
206
|
-
if (unexpected && !wasStopping && _leadTokens.size > 0) {
|
|
207
|
-
if (Date.now() - _lastLeadAutoRestartAt >= LEAD_RESTART_MIN_INTERVAL_MS) {
|
|
208
|
-
_lastLeadAutoRestartAt = Date.now();
|
|
209
|
-
// WP-1 §5 D-3: the restart is bound to the directory the DEAD child was actually running with,
|
|
210
|
-
// captured here rather than re-resolved from a possibly-changed environment. A restart therefore
|
|
211
|
-
// cannot silently move the authority root out from under a surviving native owner.
|
|
212
|
-
start({ port, dataDir: resolvedDataDir });
|
|
213
|
-
} else {
|
|
214
|
-
process.stderr.write('[oauth-proxy-supervisor] model-proxy-restart-suppressed\n');
|
|
215
|
-
}
|
|
216
|
-
}
|
|
489
|
+
_onProxyGone({ code, signal, port, dataDir: resolvedDataDir });
|
|
217
490
|
});
|
|
218
491
|
// WP-0 (§6.2): a fresh child has an empty token registry. Replay ours without
|
|
219
492
|
// blocking the caller (start() is synchronous).
|
|
@@ -222,26 +495,108 @@ function start({ port = DEFAULT_PORT, dataDir } = {}) {
|
|
|
222
495
|
process.stderr.write(`[oauth-proxy-supervisor] lead replay failed: ${String(err && err.message || err).slice(0, 200)}\n`);
|
|
223
496
|
});
|
|
224
497
|
}
|
|
225
|
-
return { ok: true, running: true, port, pid: _child.pid };
|
|
498
|
+
return { ok: true, running: true, port, pid: _child.pid, mode: 'child' };
|
|
499
|
+
}
|
|
500
|
+
|
|
501
|
+
/**
|
|
502
|
+
* Everything the child's 'exit' event owns (WP-3 calls this from the liveness probe too). `dataDir` and
|
|
503
|
+
* `port` are the ones THIS spawn was started with, so a recovery restart can never be retargeted by a
|
|
504
|
+
* changed environment.
|
|
505
|
+
*/
|
|
506
|
+
function _onProxyGone({ code, signal, port, dataDir }) {
|
|
507
|
+
// §5 D-6: an ADOPTED proxy has no child handle, so this handler is reached from the probe (or from an
|
|
508
|
+
// admin call that could not connect) rather than from an 'exit' event. Everything below is the same
|
|
509
|
+
// recovery either way; only the reaping differs, because the record a dead adopted pid left behind is
|
|
510
|
+
// stale truth that start() would otherwise have to discover by ps.
|
|
511
|
+
const adoptedPid = _mode === 'adopted' || _mode === 'adopting' ? (_adopted ? _adopted.pid : null) : null;
|
|
512
|
+
_clearLivenessProbe();
|
|
513
|
+
if (adoptedPid !== null) {
|
|
514
|
+
process.stderr.write(`[oauth-proxy-supervisor] adopted proxy pid=${adoptedPid} gone\n`);
|
|
515
|
+
instanceRecord.unlinkInstanceRecord({ dataDir });
|
|
516
|
+
}
|
|
517
|
+
if (code !== 0 && !signal) {
|
|
518
|
+
_lastError = `exited code=${code}`;
|
|
519
|
+
}
|
|
520
|
+
_child = null;
|
|
521
|
+
_startedAt = null;
|
|
522
|
+
_adminSecret = null;
|
|
523
|
+
_activeChildDataDir = null;
|
|
524
|
+
_adopted = null;
|
|
525
|
+
_mode = 'stopped';
|
|
526
|
+
// The dead child's in-memory token table is gone; any revoke that was still
|
|
527
|
+
// pending against it no longer has anything to reap.
|
|
528
|
+
_pendingLeadRevokes.clear();
|
|
529
|
+
// WP-0 (§6.2): an unexpected exit (signal, or a non-zero code) with live
|
|
530
|
+
// lead tokens must bring the proxy back so those tokens keep working. A
|
|
531
|
+
// graceful stop() opts out via _stopping; a crash-loop is rate-limited.
|
|
532
|
+
const unexpected = !!signal || code !== 0;
|
|
533
|
+
const wasStopping = _stopping;
|
|
534
|
+
_stopping = false;
|
|
535
|
+
// §9 S-5: the child records every SIGTERM exit, including the one a deliberate stop() sent. A stop
|
|
536
|
+
// is not a restart, so its record must not become the next start()'s "previous exit".
|
|
537
|
+
if (wasStopping) clearExitRecord({ dataDir });
|
|
538
|
+
if (unexpected && !wasStopping && _leadTokens.size > 0) {
|
|
539
|
+
if (Date.now() - _lastLeadAutoRestartAt >= LEAD_RESTART_MIN_INTERVAL_MS) {
|
|
540
|
+
_lastLeadAutoRestartAt = Date.now();
|
|
541
|
+
// WP-1 §5 D-3: the restart is bound to the directory the DEAD child was actually running with,
|
|
542
|
+
// captured here rather than re-resolved from a possibly-changed environment. A restart therefore
|
|
543
|
+
// cannot silently move the authority root out from under a surviving native owner.
|
|
544
|
+
start({ port, dataDir });
|
|
545
|
+
} else {
|
|
546
|
+
process.stderr.write('[oauth-proxy-supervisor] model-proxy-restart-suppressed\n');
|
|
547
|
+
}
|
|
548
|
+
}
|
|
226
549
|
}
|
|
227
550
|
|
|
228
551
|
function stop() {
|
|
552
|
+
const mode = _mode;
|
|
553
|
+
// §5 D-6: whatever happens below, this CTM is no longer watching that pid.
|
|
554
|
+
_clearLivenessProbe();
|
|
229
555
|
if (!isRunning()) {
|
|
230
556
|
_adminSecret = null;
|
|
231
557
|
_activeChildDataDir = null;
|
|
232
|
-
|
|
558
|
+
_adopted = null;
|
|
559
|
+
return { ok: true, running: false, alreadyStopped: true, mode };
|
|
233
560
|
}
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
561
|
+
const dataDir = _activeChildDataDir;
|
|
562
|
+
if (mode === 'child') {
|
|
563
|
+
// WP-0 (§6.2): mark this exit as deliberate so the lead-token auto-restart in
|
|
564
|
+
// the 'exit' handler stands down.
|
|
565
|
+
_stopping = true;
|
|
566
|
+
try {
|
|
567
|
+
_child.kill('SIGTERM');
|
|
568
|
+
} catch (err) {
|
|
569
|
+
return { ok: false, error: err.message };
|
|
570
|
+
}
|
|
571
|
+
} else if (_adopted && instanceRecord.isProxyProcess(_adopted.pid)) {
|
|
572
|
+
// §5 D-5: an explicit stop -- the /api/setup/oauth-proxy/stop path -- is the one caller allowed to
|
|
573
|
+
// end a proxy this CTM did not spawn, and only after ps proved the pid is still that proxy.
|
|
574
|
+
try {
|
|
575
|
+
process.kill(_adopted.pid, 'SIGTERM');
|
|
576
|
+
} catch (err) {
|
|
577
|
+
// REPORT: a pid that vanished between the probe and the signal is already stopped.
|
|
578
|
+
process.stderr.write(`[oauth-proxy-supervisor] stop signal failed: ${String(err && err.code || err).slice(0, 40)}\n`);
|
|
579
|
+
}
|
|
580
|
+
}
|
|
581
|
+
// An adoption in flight is superseded here: its continuation must not adopt (or spawn) after a stop.
|
|
582
|
+
_adoptionSeq += 1;
|
|
583
|
+
_adoptionPromise = null;
|
|
584
|
+
// The record and the secret are THIS CTM's files: an explicit stop leaves neither behind, so the next
|
|
585
|
+
// boot cannot resurrect a proxy that was deliberately stopped.
|
|
586
|
+
if (dataDir) {
|
|
587
|
+
instanceRecord.unlinkInstanceRecord({ dataDir });
|
|
588
|
+
instanceRecord.unlinkAdminSecret({ dataDir });
|
|
241
589
|
}
|
|
242
590
|
_adminSecret = null;
|
|
243
591
|
_activeChildDataDir = null;
|
|
244
|
-
|
|
592
|
+
// §5 D-5: an adopted proxy is CTM's memory of a process it never owned, so the stop above ends that
|
|
593
|
+
// memory here. A child's own 'exit' handler owns the same reset, and only after the process is really
|
|
594
|
+
// gone -- the handle stays live until then so a start() in between is still refused.
|
|
595
|
+
if (mode !== 'child') {
|
|
596
|
+
_adopted = null;
|
|
597
|
+
_mode = 'stopped';
|
|
598
|
+
}
|
|
599
|
+
return { ok: true, running: false, mode };
|
|
245
600
|
}
|
|
246
601
|
|
|
247
602
|
async function status({ port = DEFAULT_PORT } = {}) {
|
|
@@ -250,6 +605,9 @@ async function status({ port = DEFAULT_PORT } = {}) {
|
|
|
250
605
|
running: false,
|
|
251
606
|
port,
|
|
252
607
|
pid: null,
|
|
608
|
+
mode: _mode,
|
|
609
|
+
generation: null,
|
|
610
|
+
closureHash: null,
|
|
253
611
|
uptime_ms: null,
|
|
254
612
|
health: null,
|
|
255
613
|
last_error: _lastError,
|
|
@@ -269,13 +627,101 @@ async function status({ port = DEFAULT_PORT } = {}) {
|
|
|
269
627
|
return {
|
|
270
628
|
running: true,
|
|
271
629
|
port,
|
|
272
|
-
|
|
630
|
+
// §5 D-4: which identity is answering -- a child this CTM spawned, or a surviving proxy it adopted.
|
|
631
|
+
mode: _mode,
|
|
632
|
+
pid: _ownedPid(),
|
|
633
|
+
generation: _adopted ? _adopted.generation : 1,
|
|
634
|
+
closureHash: _adopted ? _adopted.closureHash : null,
|
|
273
635
|
uptime_ms: _startedAt ? Date.now() - _startedAt : null,
|
|
636
|
+
// §5 D-6: the two things the PROXY knows and CTM does not -- lifted from /_health when it answered.
|
|
637
|
+
openStreams: health && Number.isSafeInteger(health.open_streams) ? health.open_streams : null,
|
|
638
|
+
draining: health ? !!health.draining : null,
|
|
274
639
|
health,
|
|
275
640
|
last_error: _lastError,
|
|
276
641
|
};
|
|
277
642
|
}
|
|
278
643
|
|
|
644
|
+
/**
|
|
645
|
+
* §5 D-10 / §6 (WP-3): what the NEXT boot will do with the model proxy. The preflight's `proxy` field is
|
|
646
|
+
* this answer, passed through by lib/restart-preflight.js -- never recomputed there.
|
|
647
|
+
*
|
|
648
|
+
* This reads LIVE state only -- the published record and the proxy's own `/_health` -- never this
|
|
649
|
+
* module's `_mode`: a preflight answers for the next boot, which adopts whatever the record names
|
|
650
|
+
* whether or not this process is the one that spawned it. The verdict is the boot's own rule (D-4): no
|
|
651
|
+
* record on this port means nothing to do (`none`); a record the port does not answer for is the stale
|
|
652
|
+
* truth the boot reaps and replaces (`spawn`); a record whose pid IS the process answering is adopted,
|
|
653
|
+
* or handed off when the code on disk is not the code that process loaded. `closureDiffers:null` means
|
|
654
|
+
* the answering proxy published no closure at all (an older build), which is adoptable as it stands.
|
|
655
|
+
*
|
|
656
|
+
* @returns {Promise<{action:'adopt'|'handoff'|'spawn'|'none', pid:number|null,
|
|
657
|
+
* openStreams:number|null, closureDiffers:boolean|null}>}
|
|
658
|
+
*/
|
|
659
|
+
async function proxyPreflight({ port = DEFAULT_PORT, dataDir } = {}) {
|
|
660
|
+
const resolvedDataDir = typeof dataDir === 'string' && dataDir.trim() ? path.resolve(dataDir) : getDataDir();
|
|
661
|
+
const record = instanceRecord.readInstanceRecord({ dataDir: resolvedDataDir });
|
|
662
|
+
if (!record || record.port !== port) {
|
|
663
|
+
return { action: 'none', pid: null, openStreams: null, closureDiffers: null };
|
|
664
|
+
}
|
|
665
|
+
const health = await _probeHealth(port);
|
|
666
|
+
// Same test _finishAdoption makes: the record names the process that must be answering on the port.
|
|
667
|
+
if (!health || health.pid !== record.pid) {
|
|
668
|
+
return { action: 'spawn', pid: null, openStreams: null, closureDiffers: null };
|
|
669
|
+
}
|
|
670
|
+
// The same seam the adoption decision reads: the disk side of the closure comparison.
|
|
671
|
+
const closureDiffers = Array.isArray(health.closure_files) && typeof health.closure_hash === 'string'
|
|
672
|
+
? _hashClosureFiles(health.closure_files) !== health.closure_hash
|
|
673
|
+
: null;
|
|
674
|
+
return {
|
|
675
|
+
action: closureDiffers ? 'handoff' : 'adopt',
|
|
676
|
+
pid: health.pid,
|
|
677
|
+
openStreams: Number.isSafeInteger(health.open_streams) ? health.open_streams : null,
|
|
678
|
+
closureDiffers,
|
|
679
|
+
};
|
|
680
|
+
}
|
|
681
|
+
|
|
682
|
+
/**
|
|
683
|
+
* §5 D-4 at boot (WP-4 AC-4e): is there a proxy on this port that this data dir already owns -- a published
|
|
684
|
+
* record whose pid is a live proxy process? server.js's boot-start gate asks this beside WALLE_AUTH_METHOD,
|
|
685
|
+
* because the live proof found the primary does NOT set that method (its proxy is started later by the
|
|
686
|
+
* setup surface): without this question a proxy that survived the restart was never adopted, its lead
|
|
687
|
+
* tokens never re-tracked, and the restore_complete row said `spawn`. Sync on purpose -- the gate is in
|
|
688
|
+
* the sync boot path -- and it reuses the exact test start() makes before adopting (`isProxyProcess`).
|
|
689
|
+
* A stale record (pid gone) is NOT a survivor: start() is not called for it, so an operator who never
|
|
690
|
+
* configured the proxy never gets one spawned by a leftover file.
|
|
691
|
+
*/
|
|
692
|
+
function hasLiveSurvivor({ port = DEFAULT_PORT, dataDir } = {}) {
|
|
693
|
+
const resolvedDataDir = typeof dataDir === 'string' && dataDir.trim() ? path.resolve(dataDir) : getDataDir();
|
|
694
|
+
const record = instanceRecord.readInstanceRecord({ dataDir: resolvedDataDir });
|
|
695
|
+
return !!record && record.port === port && instanceRecord.isProxyProcess(record.pid);
|
|
696
|
+
}
|
|
697
|
+
|
|
698
|
+
/** §5 D-4 step 4: the health probe the adoption decision reads, with its own 2 s budget. */
|
|
699
|
+
async function _probeHealth(port, timeoutMs = 2000) {
|
|
700
|
+
try {
|
|
701
|
+
const resp = await fetch(`http://127.0.0.1:${port}/_health`, { signal: AbortSignal.timeout(timeoutMs) });
|
|
702
|
+
return resp.ok ? await resp.json() : null;
|
|
703
|
+
} catch (err) {
|
|
704
|
+
// expected: an unreachable proxy is a refused adoption, never a thrown boot
|
|
705
|
+
void err;
|
|
706
|
+
return null;
|
|
707
|
+
}
|
|
708
|
+
}
|
|
709
|
+
|
|
710
|
+
/** §5 D-4 step 5: the secret check -- an authenticated admin read, 200 means this secret is its own. */
|
|
711
|
+
async function _probeAdminSecret({ port, secret, timeoutMs = 2000 }) {
|
|
712
|
+
try {
|
|
713
|
+
const resp = await fetch(`http://127.0.0.1:${port}/_admin/lead-worker-picks`, {
|
|
714
|
+
headers: { [ADMIN_HEADER]: secret },
|
|
715
|
+
signal: AbortSignal.timeout(timeoutMs),
|
|
716
|
+
});
|
|
717
|
+
return resp.ok;
|
|
718
|
+
} catch (err) {
|
|
719
|
+
// expected: an unreachable proxy cannot confirm the secret; the caller falls back to a spawn
|
|
720
|
+
void err;
|
|
721
|
+
return false;
|
|
722
|
+
}
|
|
723
|
+
}
|
|
724
|
+
|
|
279
725
|
// ── D-8 readiness ────────────────────────────────────────────────────────
|
|
280
726
|
// Polls /_health every ~100ms until it answers ok or the deadline passes.
|
|
281
727
|
// A single fetch is not enough here: during the child's cold start (just
|
|
@@ -287,6 +733,22 @@ async function status({ port = DEFAULT_PORT } = {}) {
|
|
|
287
733
|
const HEALTH_POLL_INTERVAL_MS = 100;
|
|
288
734
|
|
|
289
735
|
async function waitForHealth({ port = DEFAULT_PORT, timeoutMs = 2000, signal } = {}) {
|
|
736
|
+
// §5 D-4: an adoption in flight decides whether /_health will be answered by the proxy already
|
|
737
|
+
// listening or by the child start() falls back to. Every caller already follows start() with this
|
|
738
|
+
// wait, so this is the one place that must outlast the decision.
|
|
739
|
+
if (_adoptionPromise) {
|
|
740
|
+
await _adoptionPromise.catch(() => {
|
|
741
|
+
// expected: a failed adoption already reaped the record and spawned a child; the loop below is
|
|
742
|
+
// the verdict on whichever proxy is now listening.
|
|
743
|
+
});
|
|
744
|
+
}
|
|
745
|
+
return _pollHealth({ port, timeoutMs, signal });
|
|
746
|
+
}
|
|
747
|
+
|
|
748
|
+
// The bare /_health poll, with no adoption wait in front of it. `_replayLeadTokens` runs INSIDE
|
|
749
|
+
// `_finishAdoption` (the adoption promise itself), so it must use this one: awaiting the adoption from
|
|
750
|
+
// there is a wait on oneself, and the replay -- hence the adoption -- would never finish.
|
|
751
|
+
async function _pollHealth({ port = DEFAULT_PORT, timeoutMs = 2000, signal } = {}) {
|
|
290
752
|
const deadline = Date.now() + timeoutMs;
|
|
291
753
|
for (;;) {
|
|
292
754
|
if (signal?.aborted) throw signal.reason || new Error('proxy health wait aborted');
|
|
@@ -332,6 +794,8 @@ async function _adminRequest({ port = DEFAULT_PORT, method, path: adminPath, bod
|
|
|
332
794
|
signal: signal ? AbortSignal.any([AbortSignal.timeout(timeoutMs), signal]) : AbortSignal.timeout(timeoutMs),
|
|
333
795
|
});
|
|
334
796
|
} catch (err) {
|
|
797
|
+
// §5 D-6: a refused/reset connection to an ADOPTED proxy is a liveness answer for free.
|
|
798
|
+
_probeAdoptedAfterAdminFailure({ port, err });
|
|
335
799
|
throw new ProxyAdminError('proxy_unreachable', `admin request failed: ${err.message}`);
|
|
336
800
|
}
|
|
337
801
|
if (!resp.ok) {
|
|
@@ -361,6 +825,50 @@ async function _adminRequest({ port = DEFAULT_PORT, method, path: adminPath, bod
|
|
|
361
825
|
}
|
|
362
826
|
}
|
|
363
827
|
|
|
828
|
+
/**
|
|
829
|
+
* §5 D-7 (WP-2): ask the running proxy to hand its listening sockets and its memory-only state to a
|
|
830
|
+
* successor it spawns from disk. Resolves once the proxy answered 202 -- which it does only after the
|
|
831
|
+
* successor is genuinely serving AND the predecessor stopped accepting -- or throws a ProxyAdminError
|
|
832
|
+
* carrying the 409 reason (`already_draining`, `successor_not_ready`, `spawn_failed`).
|
|
833
|
+
*
|
|
834
|
+
* The two deadlines are TEST OVERRIDES and travel in the admin body only, never in the environment; the
|
|
835
|
+
* client's own wait outlives the proxy's readiness deadline, so a handoff the PROXY decides to abandon is
|
|
836
|
+
* reported by its own 409 rather than by a socket timeout here.
|
|
837
|
+
*
|
|
838
|
+
* @returns {Promise<{ok:true, generation:number, successorPid:number, handoffMs:number}>}
|
|
839
|
+
*/
|
|
840
|
+
async function requestHandoff({ port = DEFAULT_PORT, reason = 'closure-changed', readyDeadlineMs, drainDeadlineMs, signal } = {}) {
|
|
841
|
+
const body = { reason };
|
|
842
|
+
if (Number.isSafeInteger(readyDeadlineMs) && readyDeadlineMs > 0) body.readyDeadlineMs = readyDeadlineMs;
|
|
843
|
+
if (Number.isSafeInteger(drainDeadlineMs) && drainDeadlineMs > 0) body.drainDeadlineMs = drainDeadlineMs;
|
|
844
|
+
const readyMs = Number.isSafeInteger(readyDeadlineMs) && readyDeadlineMs > 0 ? readyDeadlineMs : HANDOFF_READY_DEADLINE_MS;
|
|
845
|
+
const result = await _adminRequest({
|
|
846
|
+
port,
|
|
847
|
+
method: 'POST',
|
|
848
|
+
path: '/_admin/handoff',
|
|
849
|
+
body,
|
|
850
|
+
timeoutMs: readyMs + 5000,
|
|
851
|
+
returnHttpErrors: true,
|
|
852
|
+
signal,
|
|
853
|
+
});
|
|
854
|
+
if (result && result.ok === true && Number.isSafeInteger(result.successorPid)) {
|
|
855
|
+
return { ok: true, generation: result.generation, successorPid: result.successorPid, handoffMs: result.handoffMs };
|
|
856
|
+
}
|
|
857
|
+
const refused = String((result && (result.reason || result.error)) || 'handoff_refused');
|
|
858
|
+
const err = new ProxyAdminError('handoff_refused', refused);
|
|
859
|
+
err.reason = refused;
|
|
860
|
+
throw err;
|
|
861
|
+
}
|
|
862
|
+
|
|
863
|
+
// Test seam (§6, AC-2f): the disk-side closure hash the compatibility decision compares, injectable so a
|
|
864
|
+
// test can make an otherwise-compatible adoption read `upgrade-pending` without editing source.
|
|
865
|
+
// Production never calls the setter.
|
|
866
|
+
let _hashClosureFiles = hashClosureFiles;
|
|
867
|
+
|
|
868
|
+
function _setHashClosureFilesForTest(fn) {
|
|
869
|
+
_hashClosureFiles = typeof fn === 'function' ? fn : hashClosureFiles;
|
|
870
|
+
}
|
|
871
|
+
|
|
364
872
|
// Forwards one worker-route contract row from Wall-E's internal feed verbatim.
|
|
365
873
|
// Field names match lib/model-proxy/routes.js's buildRoute() input contract
|
|
366
874
|
// ("model-proxy route protocol and gateway auth", 2026-09-26 §5 D-3..D-7,
|
|
@@ -745,9 +1253,13 @@ async function adoptLeadToken(registration = {}) {
|
|
|
745
1253
|
|
|
746
1254
|
// Replays the CTM-side lead-token registry into a freshly spawned child: the new
|
|
747
1255
|
// child starts empty, so the SAME tokens must be re-registered to keep working.
|
|
748
|
-
|
|
749
|
-
|
|
750
|
-
|
|
1256
|
+
// `successor:true` (§5 D-7, WP-2) names a handoff successor: it already holds the tokens its
|
|
1257
|
+
// predecessor passed over, but its route and pick tables are empty, so CTM's copies are pushed
|
|
1258
|
+
// even when this process has no lead token of its own to replay.
|
|
1259
|
+
async function _replayLeadTokens({ port = DEFAULT_PORT, successor = false } = {}) {
|
|
1260
|
+
if (_leadTokens.size === 0 && !successor) return;
|
|
1261
|
+
// Never waitForHealth() here: two of the three callers are inside the adoption promise it awaits.
|
|
1262
|
+
await _pollHealth({ port, timeoutMs: 5000 });
|
|
751
1263
|
try {
|
|
752
1264
|
await pushRoutesNow({ port });
|
|
753
1265
|
} catch (err) {
|
|
@@ -833,13 +1345,40 @@ async function probeRouteGeneration(alias, { model, timeoutMs = 30_000, port = D
|
|
|
833
1345
|
});
|
|
834
1346
|
}
|
|
835
1347
|
|
|
1348
|
+
/** Test seam (§6): drop every module global this module owns, so the next case starts from a cold boot. */
|
|
1349
|
+
function _resetForTest() {
|
|
1350
|
+
_child = null;
|
|
1351
|
+
_startedAt = null;
|
|
1352
|
+
_lastError = null;
|
|
1353
|
+
_mode = 'stopped';
|
|
1354
|
+
_adopted = null;
|
|
1355
|
+
_adoptionPromise = null;
|
|
1356
|
+
_adoptionSeq += 1;
|
|
1357
|
+
_adminSecret = null;
|
|
1358
|
+
_activeChildDataDir = null;
|
|
1359
|
+
_lastExitRecord = null;
|
|
1360
|
+
_exitRecordLoad = Promise.resolve();
|
|
1361
|
+
_stopping = false;
|
|
1362
|
+
_lastLeadAutoRestartAt = 0;
|
|
1363
|
+
_leadTokens.clear();
|
|
1364
|
+
_pendingLeadRevokes.clear();
|
|
1365
|
+
_leadWorkerPicks = [];
|
|
1366
|
+
_hashClosureFiles = hashClosureFiles;
|
|
1367
|
+
_clearLivenessProbe();
|
|
1368
|
+
_livenessProbeMs = PROXY_LIVENESS_MS;
|
|
1369
|
+
}
|
|
1370
|
+
|
|
836
1371
|
module.exports = {
|
|
837
1372
|
DEFAULT_PORT,
|
|
838
1373
|
start,
|
|
839
1374
|
stop,
|
|
840
1375
|
status,
|
|
841
1376
|
isRunning,
|
|
1377
|
+
// §5 D-10 / §9 S-12: the proxy's own restart outcome for the restore_complete ledger row.
|
|
1378
|
+
lastRestartOutcome,
|
|
842
1379
|
waitForHealth,
|
|
1380
|
+
// §5 D-7 (WP-2): the upgrade handoff CTM schedules on an `upgrade-pending` adoption.
|
|
1381
|
+
requestHandoff,
|
|
843
1382
|
pushRoutesNow,
|
|
844
1383
|
pushLeadWorkerPicks,
|
|
845
1384
|
listWorkerRoutes,
|
|
@@ -856,20 +1395,45 @@ module.exports = {
|
|
|
856
1395
|
adoptLeadToken,
|
|
857
1396
|
leadTokenCount,
|
|
858
1397
|
LEAD_RESTART_MIN_INTERVAL_MS,
|
|
1398
|
+
// §5 D-6: the adopted-mode probe cadence, exported beside the rate limit it mirrors.
|
|
1399
|
+
PROXY_LIVENESS_MS,
|
|
1400
|
+
// §5 D-10 (WP-3): what the next boot will do with the proxy, for computeRestartPreflight({ proxy }).
|
|
1401
|
+
proxyPreflight,
|
|
1402
|
+
// §5 D-4 at boot (WP-4 AC-4e): the boot-start gate's second question -- a live recorded survivor is adopted
|
|
1403
|
+
// whether or not WALLE_AUTH_METHOD names the proxy.
|
|
1404
|
+
hasLiveSurvivor,
|
|
859
1405
|
ProxyAdminError,
|
|
860
1406
|
_internals: {
|
|
861
1407
|
_rowToAdminRoute,
|
|
862
1408
|
workerTokenAdminBody,
|
|
863
1409
|
ADMIN_HEADER,
|
|
864
1410
|
_setRouteFetcherForTest,
|
|
1411
|
+
// §5 D-6/§6: shrink the adopted-mode probe cadence so a liveness case does not take 10 s (and so
|
|
1412
|
+
// AC-3a's own bound can be checked against production's constant, which is exported beside it).
|
|
1413
|
+
_setLivenessProbeMsForTest,
|
|
1414
|
+
// §5 D-7/§6 (AC-2f): make the disk-side closure hash disagree, so the adoption decision reads
|
|
1415
|
+
// `upgrade-pending` and the handoff path runs, without editing source. Pass null to restore.
|
|
1416
|
+
_setHashClosureFilesForTest,
|
|
865
1417
|
_leadTokens,
|
|
866
1418
|
_pendingLeadRevokes,
|
|
867
1419
|
// WP-1 §5 D-3/§6.5: the directory the LIVE child was actually started with, resolved once at spawn.
|
|
868
1420
|
// Exposed read-only for tests that assert pinning; nothing here is a secret.
|
|
869
1421
|
get _activeChildDataDir() { return _activeChildDataDir; },
|
|
1422
|
+
// §9 S-12: the async read+clear of the previous exit record that start() kicked off; a test awaits it
|
|
1423
|
+
// before asserting lastRestartOutcome()'s previous-exit fields.
|
|
1424
|
+
get _exitRecordLoad() { return _exitRecordLoad; },
|
|
870
1425
|
// Test read/reset seam: a getter so tests see the CURRENT table after a
|
|
871
1426
|
// replay reassigns it, and a setter so a test can clear it in after().
|
|
872
1427
|
get _leadWorkerPicks() { return _leadWorkerPicks; },
|
|
873
1428
|
set _leadWorkerPicks(value) { _leadWorkerPicks = Array.isArray(value) ? value : []; },
|
|
1429
|
+
// §5 D-4: which identity this module holds ('stopped' | 'child' | 'adopting' | 'adopted') and the
|
|
1430
|
+
// adopted proxy's facts, plus the in-flight adoption decision a test can await.
|
|
1431
|
+
get _mode() { return _mode; },
|
|
1432
|
+
get _adopted() { return _adopted; },
|
|
1433
|
+
get _adoptionPromise() { return _adoptionPromise; },
|
|
1434
|
+
// Test seam: forgets every module-global this module owns, so the next case in the same process
|
|
1435
|
+
// starts from a genuinely cold boot. It does NOT signal anything -- a case that started a proxy
|
|
1436
|
+
// stops it in its own t.after().
|
|
1437
|
+
_resetForTest,
|
|
874
1438
|
},
|
|
875
1439
|
};
|