@gajae-code/coding-agent 0.18.11 → 0.19.0
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/CHANGELOG.md +760 -0
- package/dist/types/async/job-manager.d.ts +7 -3
- package/dist/types/async/result-formatting.d.ts +2 -0
- package/dist/types/autoresearch/git.d.ts +1 -1
- package/dist/types/capability/fs.d.ts +2 -2
- package/dist/types/capability/rule.d.ts +7 -2
- package/dist/types/cli/accounts-cli.d.ts +2 -2
- package/dist/types/cli/append-system-prompt.d.ts +6 -0
- package/dist/types/cli/args.d.ts +7 -2
- package/dist/types/cli/customize-doctor-precedence.d.ts +16 -0
- package/dist/types/cli/customize-doctor.d.ts +4 -3
- package/dist/types/cli/doctor/install-repairs.d.ts +4 -1
- package/dist/types/cli/install-activation.d.ts +10 -4
- package/dist/types/cli/public-command-errors.d.ts +2 -0
- package/dist/types/cli/root-flags.d.ts +0 -12
- package/dist/types/cli/update-cli.d.ts +21 -3
- package/dist/types/cli-main.d.ts +0 -6
- package/dist/types/cli-ordinary.d.ts +2 -2
- package/dist/types/commands/accounts.d.ts +2 -1
- package/dist/types/commands/launch.d.ts +0 -6
- package/dist/types/commands/migrate.d.ts +1 -1
- package/dist/types/commands/sdk.d.ts +13 -0
- package/dist/types/commit/changelog/index.d.ts +1 -0
- package/dist/types/commit/map-reduce/index.d.ts +4 -4
- package/dist/types/config/autorouting-tier-map.d.ts +53 -1
- package/dist/types/config/config-hot-reload-trust.d.ts +12 -0
- package/dist/types/config/config-hot-reload.d.ts +44 -0
- package/dist/types/config/file-lock.d.ts +52 -3
- package/dist/types/config/model-discovery-manager.d.ts +2 -0
- package/dist/types/config/model-profile-activation.d.ts +79 -9
- package/dist/types/config/model-registry.d.ts +72 -8
- package/dist/types/config/model-resolver.d.ts +7 -4
- package/dist/types/config/models-config-schema.d.ts +10 -0
- package/dist/types/config/settings-schema.d.ts +57 -4
- package/dist/types/config/settings.d.ts +33 -1
- package/dist/types/coordinator-mcp/server.d.ts +4 -0
- package/dist/types/customization/import.d.ts +2 -0
- package/dist/types/customization/types.d.ts +1 -1
- package/dist/types/dap/config.d.ts +8 -4
- package/dist/types/dap/session.d.ts +11 -2
- package/dist/types/debug/system-info.d.ts +6 -1
- package/dist/types/discovery/helpers.d.ts +7 -2
- package/dist/types/discovery/mcp-compat.d.ts +3 -1
- package/dist/types/edit/index.d.ts +5 -0
- package/dist/types/edit/modes/patch.d.ts +14 -0
- package/dist/types/edit/modes/replace.d.ts +3 -0
- package/dist/types/eval/py/runtime.d.ts +9 -0
- package/dist/types/exec/bash-executor.d.ts +12 -0
- package/dist/types/export/ttsr.d.ts +19 -0
- package/dist/types/extensibility/extensions/types.d.ts +5 -1
- package/dist/types/extensibility/slash-commands.d.ts +1 -0
- package/dist/types/gjc-runtime/managed-owner-admission.d.ts +3 -0
- package/dist/types/gjc-runtime/ralplan-current-final.d.ts +54 -0
- package/dist/types/gjc-runtime/ralplan-runtime.d.ts +5 -0
- package/dist/types/gjc-runtime/session-state-lock.d.ts +2 -0
- package/dist/types/gjc-runtime/state-runtime.d.ts +0 -2
- package/dist/types/gjc-runtime/tmux-sessions.d.ts +24 -0
- package/dist/types/gjc-runtime/ultragoal-change-set.d.ts +6 -0
- package/dist/types/gjc-runtime/ultragoal-runtime.d.ts +1 -0
- package/dist/types/harness-control-plane/receipt-spool.d.ts +4 -0
- package/dist/types/hindsight/content.d.ts +5 -6
- package/dist/types/internal-urls/types.d.ts +2 -0
- package/dist/types/lsp/clients/swiftlint-client.d.ts +0 -4
- package/dist/types/main.d.ts +23 -18
- package/dist/types/migrate/action-planner.d.ts +10 -1
- package/dist/types/migrate/adapters/codex.d.ts +2 -1
- package/dist/types/migrate/adapters/index.d.ts +42 -1
- package/dist/types/migrate/executor.d.ts +3 -1
- package/dist/types/migrate/hook-module.d.ts +23 -0
- package/dist/types/migrate/report.d.ts +2 -1
- package/dist/types/migrate/types.d.ts +82 -3
- package/dist/types/modes/components/agent-dashboard.d.ts +1 -1
- package/dist/types/modes/components/model-selector.d.ts +43 -0
- package/dist/types/modes/components/settings-selector.d.ts +6 -0
- package/dist/types/modes/controllers/selector-controller.d.ts +2 -0
- package/dist/types/modes/prompt-suggestion-controller.d.ts +1 -1
- package/dist/types/modes/theme/theme.d.ts +1 -0
- package/dist/types/modes/utils/host-status.d.ts +15 -11
- package/dist/types/runtime-mcp/content-limits.d.ts +4 -0
- package/dist/types/runtime-mcp/manager.d.ts +7 -0
- package/dist/types/runtime-mcp/plugin-mcp-name-filter.d.ts +22 -0
- package/dist/types/runtime-mcp/project-host-values.d.ts +11 -5
- package/dist/types/runtime-mcp/tool-bridge.d.ts +4 -0
- package/dist/types/sdk/broker/broker-exit.d.ts +3 -3
- package/dist/types/sdk/broker/broker.d.ts +29 -2
- package/dist/types/sdk/broker/daemon-entry.d.ts +11 -1
- package/dist/types/sdk/broker/doctor-restart.d.ts +1 -1
- package/dist/types/sdk/broker/ensure.d.ts +141 -9
- package/dist/types/sdk/broker/hop.d.ts +45 -0
- package/dist/types/sdk/broker/lifecycle-ledger.d.ts +18 -10
- package/dist/types/sdk/broker/lifecycle.d.ts +22 -0
- package/dist/types/sdk/broker/lock-host-identity.d.ts +5 -0
- package/dist/types/sdk/broker/session-index.d.ts +6 -1
- package/dist/types/sdk/broker/transport.d.ts +2 -0
- package/dist/types/sdk/bus/chat-command-policy.d.ts +2 -1
- package/dist/types/sdk/bus/index.d.ts +44 -0
- package/dist/types/sdk/bus/reconciliation-store.d.ts +10 -0
- package/dist/types/sdk/bus/telegram-daemon-contract.d.ts +9 -1
- package/dist/types/sdk/bus/telegram-daemon.d.ts +2 -0
- package/dist/types/sdk/bus/threaded-render.d.ts +2 -1
- package/dist/types/sdk/cli/session-cli.d.ts +0 -1
- package/dist/types/sdk/host/control/operations.d.ts +5 -1
- package/dist/types/sdk/host/events.d.ts +4 -0
- package/dist/types/sdk/host/host.d.ts +2 -1
- package/dist/types/sdk/host/prompt-image-upload.d.ts +43 -0
- package/dist/types/sdk/host/session-runtime.d.ts +40 -8
- package/dist/types/sdk/host/surface-policy.d.ts +2 -1
- package/dist/types/sdk/lifecycle/service.d.ts +12 -0
- package/dist/types/sdk/prompt-deadline-flush.d.ts +42 -4
- package/dist/types/sdk/prompt-deadline-manager.d.ts +2 -0
- package/dist/types/sdk/prompt-status.d.ts +11 -4
- package/dist/types/sdk/protocol/adapter-validation.d.ts +10 -4
- package/dist/types/sdk/reconciliation-extensions.d.ts +1 -1
- package/dist/types/sdk/session-list.d.ts +1 -1
- package/dist/types/session/agent-session.d.ts +107 -9
- package/dist/types/session/internal/managed-session-scope.d.ts +2 -2
- package/dist/types/session/internal/managed-session-storage.d.ts +1 -1
- package/dist/types/session/internal/native-publish-outcome.d.ts +1 -1
- package/dist/types/session/messages.d.ts +5 -1
- package/dist/types/session/session-manager.d.ts +29 -3
- package/dist/types/session/startup-auth-config.d.ts +24 -2
- package/dist/types/session/streaming-output.d.ts +11 -0
- package/dist/types/session/terminal-abort.d.ts +3 -1
- package/dist/types/session-import/service.d.ts +1 -1
- package/dist/types/skill-state/workflow-mutation-guard.d.ts +20 -4
- package/dist/types/slash-commands/helpers/mcp.d.ts +5 -0
- package/dist/types/slash-commands/helpers/provider-auth-status.d.ts +8 -0
- package/dist/types/ssh/ssh-executor.d.ts +8 -1
- package/dist/types/system-prompt.d.ts +3 -2
- package/dist/types/task/executor.d.ts +19 -2
- package/dist/types/task/role-body.d.ts +11 -0
- package/dist/types/tools/ast-edit-write-path.d.ts +10 -0
- package/dist/types/tools/atomic-file-write.d.ts +9 -7
- package/dist/types/tools/awaiting-input.d.ts +28 -0
- package/dist/types/tools/bash-allowed-prefixes.d.ts +17 -0
- package/dist/types/tools/bash-skill-urls.d.ts +4 -0
- package/dist/types/tools/bash.d.ts +7 -0
- package/dist/types/tools/descriptors.d.ts +15 -2
- package/dist/types/tools/fetch.d.ts +7 -1
- package/dist/types/tools/index.d.ts +2 -0
- package/dist/types/tools/irc.d.ts +5 -3
- package/dist/types/tools/python.d.ts +1 -1
- package/dist/types/tools/report-issue.d.ts +41 -0
- package/dist/types/tools/subagent.d.ts +23 -0
- package/dist/types/tools/tmux-self-injection-guard.d.ts +2 -0
- package/dist/types/tools/ultragoal-ask-guard.d.ts +1 -1
- package/dist/types/tools/write.d.ts +2 -0
- package/dist/types/utils/file-mentions.d.ts +5 -0
- package/dist/types/utils/image-limits.d.ts +7 -0
- package/dist/types/utils/image-loading.d.ts +0 -1
- package/dist/types/utils/open.d.ts +0 -1
- package/dist/types/utils/pasted-image-loading.d.ts +1 -5
- package/dist/types/utils/prompt-suggestion.d.ts +2 -2
- package/dist/types/utils/shell-snapshot.d.ts +2 -0
- package/dist/types/utils/title-generator.d.ts +13 -3
- package/dist/types/web/scrapers/types.d.ts +8 -1
- package/dist/types/web/search/providers/codex.d.ts +6 -7
- package/dist/types/web/search/providers/insane.d.ts +7 -1
- package/dist/types/web/search/providers/searxng.d.ts +5 -0
- package/package.json +10 -9
- package/scripts/acp-conformance-agent.ts +12 -2
- package/scripts/build-binary.ts +2 -1
- package/scripts/capture-subagent-compact-panel-showcase.ts +105 -0
- package/scripts/generate-docs-index.ts +8 -1
- package/scripts/generate-sdk-operation-inventory.ts +38 -4
- package/src/async/job-manager.ts +356 -56
- package/src/async/result-formatting.ts +40 -0
- package/src/autoresearch/git.ts +9 -2
- package/src/autoresearch/prompts/prompt-setup.md +1 -1
- package/src/capability/fs.ts +58 -22
- package/src/capability/index.ts +2 -2
- package/src/capability/rule.ts +11 -2
- package/src/cli/accounts-cli.ts +176 -8
- package/src/cli/append-system-prompt.ts +12 -0
- package/src/cli/args.ts +11 -7
- package/src/cli/auth-broker-cli.ts +21 -11
- package/src/cli/customize-doctor-precedence.ts +31 -0
- package/src/cli/customize-doctor.ts +49 -39
- package/src/cli/doctor/install-repairs.ts +35 -5
- package/src/cli/fast-help.ts +1 -3
- package/src/cli/install-activation.ts +47 -4
- package/src/cli/migrate-cli.ts +19 -6
- package/src/cli/public-command-errors.ts +6 -0
- package/src/cli/root-flags.ts +2 -4
- package/src/cli/stats-cli.ts +9 -5
- package/src/cli/update-cli.ts +471 -13
- package/src/cli-ordinary.ts +7 -2
- package/src/commands/accounts.ts +10 -5
- package/src/commands/harness.ts +25 -15
- package/src/commands/migrate.ts +3 -3
- package/src/commands/sdk.ts +277 -50
- package/src/commands/ultragoal.ts +16 -8
- package/src/commit/agentic/agent.ts +6 -6
- package/src/commit/analysis/conventional.ts +14 -5
- package/src/commit/changelog/index.ts +39 -1
- package/src/commit/map-reduce/index.ts +7 -7
- package/src/commit/message.ts +10 -3
- package/src/commit/pipeline.ts +12 -12
- package/src/config/autorouting-tier-map.ts +50 -1
- package/src/config/config-file.ts +106 -3
- package/src/config/config-hot-reload-trust.ts +148 -0
- package/src/config/config-hot-reload.ts +742 -0
- package/src/config/file-lock.ts +1615 -328
- package/src/config/keybindings.ts +154 -7
- package/src/config/model-bindings-applier.ts +85 -47
- package/src/config/model-discovery-manager.ts +10 -0
- package/src/config/model-profile-activation.ts +889 -148
- package/src/config/model-registry.ts +1706 -205
- package/src/config/model-resolver.ts +52 -22
- package/src/config/models-config-schema.ts +6 -0
- package/src/config/settings-schema.ts +73 -4
- package/src/config/settings.ts +460 -24
- package/src/coordinator-mcp/policy.ts +26 -1
- package/src/coordinator-mcp/server.ts +37 -2
- package/src/crash/sanitize.ts +7 -1
- package/src/customization/import.ts +279 -8
- package/src/customization/inventory.ts +24 -8
- package/src/customization/types.ts +1 -0
- package/src/dap/client.ts +3 -2
- package/src/dap/config.ts +31 -11
- package/src/dap/defaults.json +1 -1
- package/src/dap/session.ts +161 -36
- package/src/debug/crash-diagnostics.ts +89 -6
- package/src/debug/report-bundle.ts +90 -2
- package/src/debug/system-info.ts +8 -3
- package/src/defaults/gjc/extensions/grok-cli-vendor/src/shared/base-url.ts +21 -7
- package/src/discovery/agents-md.ts +1 -1
- package/src/discovery/builtin.ts +4 -3
- package/src/discovery/helpers.ts +25 -16
- package/src/discovery/mcp-compat.ts +120 -17
- package/src/edit/apply-patch/index.ts +12 -1
- package/src/edit/index.ts +204 -22
- package/src/edit/modes/patch.ts +214 -8
- package/src/edit/modes/replace.ts +136 -0
- package/src/eval/js/context-manager.ts +29 -3
- package/src/eval/js/worker-entry.ts +51 -18
- package/src/eval/py/executor.ts +14 -4
- package/src/eval/py/runner.py +144 -5
- package/src/eval/py/runtime.ts +44 -4
- package/src/exec/bash-executor.ts +81 -4
- package/src/exec/bash-shell-guardian.ts +4 -1
- package/src/exec/bash-shell-supervisor.ts +8 -4
- package/src/exec/isolated-shell.ts +5 -1
- package/src/exec/non-interactive-env.ts +0 -1
- package/src/export/html/index.ts +86 -6
- package/src/export/ttsr.ts +176 -1
- package/src/extensibility/custom-tools/wrapper.ts +3 -1
- package/src/extensibility/extensions/loader.ts +10 -1
- package/src/extensibility/extensions/ouroboros-ooo-bridge.ts +63 -1
- package/src/extensibility/extensions/types.ts +5 -1
- package/src/extensibility/hooks/loader.ts +10 -2
- package/src/extensibility/plugins/marketplace/fetcher.ts +2 -2
- package/src/extensibility/plugins/marketplace/manager.ts +36 -10
- package/src/extensibility/slash-commands.ts +5 -0
- package/src/gjc-runtime/gc-runtime.ts +37 -7
- package/src/gjc-runtime/launch-tmux.ts +30 -15
- package/src/gjc-runtime/launch-worktree.test.ts +148 -0
- package/src/gjc-runtime/launch-worktree.ts +101 -14
- package/src/gjc-runtime/managed-owner-admission.ts +46 -2
- package/src/gjc-runtime/ralplan-current-final.ts +294 -0
- package/src/gjc-runtime/ralplan-runtime.ts +95 -1
- package/src/gjc-runtime/session-resolution.ts +38 -6
- package/src/gjc-runtime/session-state-lock.ts +59 -14
- package/src/gjc-runtime/state-runtime.ts +23 -19
- package/src/gjc-runtime/state-writer.ts +109 -5
- package/src/gjc-runtime/tmux-sessions.ts +93 -13
- package/src/gjc-runtime/ultragoal-change-set.ts +90 -11
- package/src/gjc-runtime/ultragoal-guard.ts +9 -1
- package/src/gjc-runtime/ultragoal-runtime.ts +15 -5
- package/src/harness-control-plane/finalize.ts +11 -0
- package/src/harness-control-plane/receipt-spool.ts +105 -28
- package/src/harness-control-plane/storage.ts +124 -6
- package/src/hindsight/content.ts +116 -16
- package/src/hindsight/mental-models.ts +22 -10
- package/src/hindsight/state.ts +7 -4
- package/src/hooks/native-skill-hook.ts +17 -12
- package/src/internal-urls/artifact-protocol.ts +3 -1
- package/src/internal-urls/docs-index.generated.ts +23 -22
- package/src/internal-urls/local-protocol.ts +167 -8
- package/src/internal-urls/types.ts +2 -0
- package/src/lsp/client.ts +2 -1
- package/src/lsp/clients/biome-client.ts +2 -0
- package/src/lsp/clients/swiftlint-client.ts +2 -0
- package/src/lsp/index.ts +29 -0
- package/src/lsp/workspace-path.ts +23 -0
- package/src/main.ts +330 -92
- package/src/memories/index.ts +25 -2
- package/src/migrate/action-planner.ts +574 -2
- package/src/migrate/adapters/claude-code.ts +162 -5
- package/src/migrate/adapters/codex.ts +77 -7
- package/src/migrate/adapters/index.ts +258 -5
- package/src/migrate/adapters/opencode.ts +10 -1
- package/src/migrate/executor.ts +222 -7
- package/src/migrate/hook-module.ts +193 -0
- package/src/migrate/report.ts +63 -2
- package/src/migrate/types.ts +84 -4
- package/src/modes/DESIGN.md +51 -0
- package/src/modes/acp/acp-agent.ts +10 -1
- package/src/modes/acp/permission-mode.ts +18 -1
- package/src/modes/components/agent-dashboard.ts +20 -4
- package/src/modes/components/assistant-message.ts +55 -1
- package/src/modes/components/customization/customization-dashboard.ts +7 -0
- package/src/modes/components/customization/import-wizard.ts +29 -3
- package/src/modes/components/model-selector.ts +157 -103
- package/src/modes/components/settings-selector.ts +103 -37
- package/src/modes/components/welcome.ts +3 -1
- package/src/modes/controllers/command-controller.ts +300 -9
- package/src/modes/controllers/event-controller.ts +1 -1
- package/src/modes/controllers/extension-ui-controller.ts +31 -1
- package/src/modes/controllers/goal-mode-controller.ts +79 -10
- package/src/modes/controllers/input-controller.ts +2 -0
- package/src/modes/controllers/plan-mode-controller.ts +66 -11
- package/src/modes/controllers/runtime-mcp-command-controller.ts +7 -6
- package/src/modes/controllers/selector-controller.ts +76 -7
- package/src/modes/interactive-mode.ts +123 -4
- package/src/modes/prompt-suggestion-controller.ts +1 -1
- package/src/modes/runtime-init.ts +5 -1
- package/src/modes/theme/theme.ts +15 -0
- package/src/modes/utils/host-status.ts +21 -10
- package/src/prompts/agent-fragments/browser-aside.md +2 -2
- package/src/prompts/system/irc-incoming.md +3 -1
- package/src/prompts/tools/debug.md +1 -1
- package/src/prompts/tools/irc.md +1 -1
- package/src/prompts/tools/read.md +1 -1
- package/src/prompts/tools/report-issue.md +13 -0
- package/src/prompts/tools/task.md +1 -1
- package/src/runtime-credential-selector.ts +2 -2
- package/src/runtime-mcp/content-limits.ts +7 -0
- package/src/runtime-mcp/manager-pool.test.ts +5 -4
- package/src/runtime-mcp/manager.ts +40 -3
- package/src/runtime-mcp/oauth-flow.ts +32 -6
- package/src/runtime-mcp/plugin-mcp-name-filter.ts +62 -0
- package/src/runtime-mcp/project-host-values.ts +12 -8
- package/src/runtime-mcp/render.ts +16 -1
- package/src/runtime-mcp/tool-bridge.ts +194 -21
- package/src/runtime-mcp/transports/stdio.ts +2 -1
- package/src/sdk/broker/broker-exit.ts +10 -5
- package/src/sdk/broker/broker.ts +484 -85
- package/src/sdk/broker/daemon-entry.ts +338 -116
- package/src/sdk/broker/doctor-restart.ts +2 -1
- package/src/sdk/broker/ensure.ts +1214 -268
- package/src/sdk/broker/hop.ts +286 -0
- package/src/sdk/broker/lifecycle-ledger.ts +38 -11
- package/src/sdk/broker/lifecycle.ts +102 -6
- package/src/sdk/broker/lock-host-identity.ts +29 -0
- package/src/sdk/broker/session-index.ts +91 -16
- package/src/sdk/broker/transport.ts +17 -1
- package/src/sdk/bus/chat-command-policy.ts +16 -3
- package/src/sdk/bus/chat-daemon-control.ts +2 -2
- package/src/sdk/bus/index.ts +460 -35
- package/src/sdk/bus/reconciliation-store.ts +201 -8
- package/src/sdk/bus/session-control.ts +2 -0
- package/src/sdk/bus/slack-daemon.ts +105 -9
- package/src/sdk/bus/telegram-daemon-contract.ts +9 -1
- package/src/sdk/bus/telegram-daemon.ts +10 -2
- package/src/sdk/bus/threaded-render.ts +20 -1
- package/src/sdk/cli/session-cli.ts +23 -6
- package/src/sdk/host/control/dispatch.ts +13 -1
- package/src/sdk/host/control/operations.ts +10 -1
- package/src/sdk/host/events.ts +67 -4
- package/src/sdk/host/host.ts +8 -2
- package/src/sdk/host/prompt-image-upload.ts +438 -0
- package/src/sdk/host/sdk-surface-parity.test.ts +15 -0
- package/src/sdk/host/session-runtime.test.ts +1737 -129
- package/src/sdk/host/session-runtime.ts +684 -83
- package/src/sdk/host/surface-policy.ts +4 -0
- package/src/sdk/host/websocket-transport.lifecycle.test.ts +90 -2
- package/src/sdk/host/websocket-transport.ts +19 -7
- package/src/sdk/lifecycle/service.ts +15 -1
- package/src/sdk/prompt-deadline-flush.ts +78 -5
- package/src/sdk/prompt-deadline-manager.ts +68 -14
- package/src/sdk/prompt-status.ts +6 -4
- package/src/sdk/protocol/adapter-validation.ts +81 -12
- package/src/sdk/protocol/operation-inventory.generated.json +261 -2
- package/src/sdk/protocol/operation-registry.ts +33 -3
- package/src/sdk/reconciliation-extensions.ts +1 -0
- package/src/sdk/session-list.ts +1 -0
- package/src/sdk/session.ts +237 -97
- package/src/session/agent-session.ts +2726 -479
- package/src/session/history-storage.ts +182 -12
- package/src/session/internal/managed-session-scope.ts +173 -57
- package/src/session/internal/managed-session-storage.ts +85 -12
- package/src/session/internal/native-publish-outcome.ts +5 -1
- package/src/session/messages.ts +47 -6
- package/src/session/session-manager.ts +326 -19
- package/src/session/startup-auth-config.ts +116 -8
- package/src/session/streaming-output.ts +262 -4
- package/src/session/terminal-abort.ts +14 -2
- package/src/session-import/redact.ts +53 -2
- package/src/session-import/service.ts +1 -0
- package/src/skill-state/workflow-mutation-guard.ts +2145 -206
- package/src/slash-commands/builtin-registry.ts +21 -6
- package/src/slash-commands/helpers/credential-switch.ts +10 -5
- package/src/slash-commands/helpers/mcp.ts +11 -3
- package/src/slash-commands/helpers/provider-auth-status.ts +456 -0
- package/src/ssh/ssh-executor.ts +9 -2
- package/src/stt/recorder.ts +26 -9
- package/src/system-prompt.ts +61 -16
- package/src/task/executor.ts +115 -20
- package/src/task/index.ts +389 -175
- package/src/task/role-body.ts +84 -0
- package/src/task/token-log-mode.test.ts +162 -0
- package/src/task/token-log.ts +91 -2
- package/src/tools/archive-reader.ts +650 -37
- package/src/tools/ask.ts +1 -0
- package/src/tools/ast-edit-write-path.ts +64 -0
- package/src/tools/ast-edit.ts +18 -1
- package/src/tools/ast-grep.ts +7 -2
- package/src/tools/atomic-file-write.ts +46 -15
- package/src/tools/auto-generated-guard.ts +15 -3
- package/src/tools/awaiting-input.ts +114 -0
- package/src/tools/bash-allowed-prefixes.ts +214 -6
- package/src/tools/bash-interactive.ts +14 -2
- package/src/tools/bash-skill-urls.ts +58 -5
- package/src/tools/bash.ts +113 -26
- package/src/tools/browser/tab-worker.ts +39 -13
- package/src/tools/browser.ts +17 -7
- package/src/tools/cron.ts +10 -1
- package/src/tools/debug.ts +16 -8
- package/src/tools/descriptors.test.ts +44 -0
- package/src/tools/descriptors.ts +50 -6
- package/src/tools/eval.ts +15 -0
- package/src/tools/fetch.ts +93 -4
- package/src/tools/gh.ts +27 -12
- package/src/tools/image-gen.ts +20 -6
- package/src/tools/index.ts +2 -0
- package/src/tools/irc.ts +5 -3
- package/src/tools/monitor.ts +9 -1
- package/src/tools/output-meta.ts +74 -5
- package/src/tools/python.ts +1 -1
- package/src/tools/read.ts +26 -37
- package/src/tools/recipe/runner.ts +23 -12
- package/src/tools/render-utils.ts +2 -2
- package/src/tools/report-issue.ts +184 -0
- package/src/tools/search.ts +3 -2
- package/src/tools/ssh.ts +274 -8
- package/src/tools/subagent-render.ts +114 -12
- package/src/tools/subagent.ts +118 -5
- package/src/tools/tmux-self-injection-guard.ts +3301 -122
- package/src/tools/todo-write.ts +41 -3
- package/src/tools/tool-catalog.generated.ts +32 -4
- package/src/tools/ultragoal-ask-guard.ts +13 -1
- package/src/tools/write.ts +42 -3
- package/src/tui/tree-list.ts +15 -1
- package/src/utils/commit-message-generator.ts +12 -4
- package/src/utils/external-editor.ts +21 -7
- package/src/utils/file-mentions.ts +75 -7
- package/src/utils/git.ts +38 -7
- package/src/utils/image-limits.ts +8 -0
- package/src/utils/image-loading.ts +1 -1
- package/src/utils/open.ts +73 -4
- package/src/utils/pasted-image-loading.ts +9 -8
- package/src/utils/prompt-suggestion.ts +5 -4
- package/src/utils/shell-snapshot.ts +9 -1
- package/src/utils/title-generator.ts +72 -22
- package/src/web/scrapers/index.ts +3 -1
- package/src/web/scrapers/types.ts +8 -0
- package/src/web/scrapers/youtube.ts +3 -2
- package/src/web/search/index.ts +2 -1
- package/src/web/search/providers/codex.ts +29 -32
- package/src/web/search/providers/insane.ts +91 -28
- package/src/web/search/providers/searxng.ts +62 -10
package/src/sdk/broker/ensure.ts
CHANGED
|
@@ -2,10 +2,18 @@ import { type ChildProcess, spawn } from "node:child_process";
|
|
|
2
2
|
import { randomUUID } from "node:crypto";
|
|
3
3
|
import type { FileHandle } from "node:fs/promises";
|
|
4
4
|
import * as fs from "node:fs/promises";
|
|
5
|
+
import * as os from "node:os";
|
|
5
6
|
import path from "node:path";
|
|
7
|
+
import { type ProjectEnvSnapshot, projectEnvSnapshot } from "@gajae-code/utils/env-file";
|
|
8
|
+
import { nativeProcessBindings } from "@gajae-code/utils/native-process";
|
|
6
9
|
import packageJson from "../../../package.json" with { type: "json" };
|
|
7
|
-
import {
|
|
8
|
-
|
|
10
|
+
import {
|
|
11
|
+
acquireFileLock,
|
|
12
|
+
FILE_LOCK_ORPHAN_TRANSITION_ADOPTION_AGE_MS,
|
|
13
|
+
type FileLockOptions,
|
|
14
|
+
withFileLock,
|
|
15
|
+
} from "../../config/file-lock";
|
|
16
|
+
import { trustedCoordinatorEnv } from "../../gjc-runtime/sidecar-bootstrap-env";
|
|
9
17
|
import { SdkClient, SdkClientError } from "../client/client";
|
|
10
18
|
import { type BrokerStartupExitRecord, clearBrokerStartupExitRecord, readBrokerStartupExitRecord } from "./broker-exit";
|
|
11
19
|
import {
|
|
@@ -14,7 +22,13 @@ import {
|
|
|
14
22
|
readBrokerDiscovery,
|
|
15
23
|
readBrokerRestartIntent,
|
|
16
24
|
} from "./discovery";
|
|
17
|
-
import {
|
|
25
|
+
import { BROKER_HANDOFF_ABORT, BROKER_HANDOFF_ACKNOWLEDGEMENT, BROKER_HANDOFF_COMMIT } from "./hop";
|
|
26
|
+
import { brokerLockHostIdentity } from "./lock-host-identity";
|
|
27
|
+
import {
|
|
28
|
+
isProcessIncarnation,
|
|
29
|
+
observeProcessIncarnation,
|
|
30
|
+
type ProcessIncarnationObservation,
|
|
31
|
+
} from "./process-incarnation";
|
|
18
32
|
import {
|
|
19
33
|
isSdkInternalRuntimeImagePresent,
|
|
20
34
|
resolveSdkInternalSpawnCommand,
|
|
@@ -28,6 +42,567 @@ import {
|
|
|
28
42
|
readBrokerStartupFailureMarker,
|
|
29
43
|
} from "./startup-failure";
|
|
30
44
|
|
|
45
|
+
/** Result of launching a broker through a hop/trampoline or as an exact fixture child. */
|
|
46
|
+
export interface BrokerSpawnResult {
|
|
47
|
+
/** The launcher process, or the exact broker process for fixture leases. */
|
|
48
|
+
process: ChildProcess;
|
|
49
|
+
/** The real broker's pid (from the launcher reply or exact child). */
|
|
50
|
+
realBrokerPid: number | undefined;
|
|
51
|
+
/** Kernel identity returned by the POSIX trampoline when available. */
|
|
52
|
+
realBrokerIncarnation?: string;
|
|
53
|
+
handoff?: BrokerLaunchHandoff;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
export interface BrokerLaunchHandoff {
|
|
57
|
+
release(): Promise<void>;
|
|
58
|
+
terminate(): Promise<void>;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/**
|
|
62
|
+
* Typed error returned when a Windows broker hop fails.
|
|
63
|
+
* The hop is mandatory on Windows for broker detachment; failure is unrecoverable.
|
|
64
|
+
*/
|
|
65
|
+
export class BrokerHopError extends Error {
|
|
66
|
+
readonly code = "broker_hop_failed";
|
|
67
|
+
readonly hopExitCode: number | null;
|
|
68
|
+
readonly hopStdout: string;
|
|
69
|
+
readonly hopStderr: string;
|
|
70
|
+
readonly reason: string;
|
|
71
|
+
|
|
72
|
+
constructor(fields: { exitCode: number | null; stdout: string; stderr: string; reason: string }) {
|
|
73
|
+
const stderr = fields.stderr.trim();
|
|
74
|
+
super(`Windows broker hop failed: ${fields.reason}${stderr ? `; ${stderr}` : ""}`);
|
|
75
|
+
this.name = "BrokerHopError";
|
|
76
|
+
this.hopExitCode = fields.exitCode;
|
|
77
|
+
this.hopStdout = fields.stdout;
|
|
78
|
+
this.hopStderr = fields.stderr;
|
|
79
|
+
this.reason = fields.reason;
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
export function resolveBrokerLaunchMode(
|
|
84
|
+
platform: NodeJS.Platform,
|
|
85
|
+
initiator: "discovery" | "fixture-lease",
|
|
86
|
+
): "windows-hop" | "posix-trampoline" | "darwin-trampoline" | "direct" {
|
|
87
|
+
if (initiator === "fixture-lease") return "direct";
|
|
88
|
+
if (platform === "win32") return "windows-hop";
|
|
89
|
+
return platform === "darwin" ? "darwin-trampoline" : "posix-trampoline";
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
/**
|
|
93
|
+
* Resolve the hop invocation command for the current runtime (source or compiled).
|
|
94
|
+
* Returns the executable path and arguments to spawn the hop process.
|
|
95
|
+
*/
|
|
96
|
+
function resolveHopInvocation(hopMessage: string): { file: string; args: string[] } {
|
|
97
|
+
const brokerCmd = resolveSdkInternalSpawnCommand("broker-internal");
|
|
98
|
+
if (brokerCmd.kind === "bun-source") {
|
|
99
|
+
// Source mode: args are ["--no-env-file", "--config=...", cli.ts, "sdk", "broker-internal"]
|
|
100
|
+
// Replace the last two with ["internal", "broker-hop", hopMessage]
|
|
101
|
+
const args = [...brokerCmd.args.slice(0, -2), "internal", "broker-hop", hopMessage];
|
|
102
|
+
return { file: brokerCmd.file, args };
|
|
103
|
+
} else {
|
|
104
|
+
// Compiled mode: file is gjc executable
|
|
105
|
+
return { file: brokerCmd.file, args: ["internal", "broker-hop", hopMessage] };
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
/** Spawn a broker as an exact child so its process handle can be retained for cleanup. */
|
|
110
|
+
function spawnBrokerDirect(
|
|
111
|
+
brokerFile: string,
|
|
112
|
+
brokerArgs: readonly string[],
|
|
113
|
+
options: {
|
|
114
|
+
stdioFd?: number;
|
|
115
|
+
env: NodeJS.ProcessEnv;
|
|
116
|
+
cwd?: string;
|
|
117
|
+
detached: boolean;
|
|
118
|
+
},
|
|
119
|
+
): BrokerSpawnResult {
|
|
120
|
+
const child = spawn(brokerFile, Array.from(brokerArgs), {
|
|
121
|
+
detached: options.detached,
|
|
122
|
+
stdio: ["ignore", "ignore", options.stdioFd ?? "ignore"],
|
|
123
|
+
env: options.env,
|
|
124
|
+
...(options.cwd ? { cwd: options.cwd } : {}),
|
|
125
|
+
});
|
|
126
|
+
return { process: child, realBrokerPid: child.pid };
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
/** What the parent sends the Windows hop. The broker environment is never serialized here. */
|
|
130
|
+
export interface BrokerHopMessage {
|
|
131
|
+
command: { file: string; args: string[] };
|
|
132
|
+
cwd?: string;
|
|
133
|
+
/** Path (never an inherited fd number) the hop opens for the broker's stderr. */
|
|
134
|
+
stderrLogPath?: string;
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
export interface BrokerLaunchResult {
|
|
138
|
+
process: ChildProcess;
|
|
139
|
+
realBrokerPid: number | undefined;
|
|
140
|
+
realBrokerIncarnation?: string;
|
|
141
|
+
error: Error | undefined;
|
|
142
|
+
handoff?: BrokerLaunchHandoff;
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
const MAX_LAUNCHER_OUTPUT_CHARS = 4_096;
|
|
146
|
+
const DEFAULT_LAUNCHER_TIMEOUT_MS = 30_000;
|
|
147
|
+
const HOP_HANDOFF_CLEANUP_TIMEOUT_MS = 1_000;
|
|
148
|
+
|
|
149
|
+
function appendBoundedOutput(current: string, chunk: Buffer | string): string {
|
|
150
|
+
const next = current + chunk.toString();
|
|
151
|
+
return next.length <= MAX_LAUNCHER_OUTPUT_CHARS ? next : next.slice(-MAX_LAUNCHER_OUTPUT_CHARS);
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
function endBrokerLauncherInput(child: ChildProcess, chunk?: string): void {
|
|
155
|
+
const input = child.stdin;
|
|
156
|
+
if (!input || input.destroyed || input.writableEnded) return;
|
|
157
|
+
try {
|
|
158
|
+
input.end(chunk);
|
|
159
|
+
} catch {
|
|
160
|
+
// The launcher treats an ended acknowledgement pipe as a failed handoff.
|
|
161
|
+
}
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
function writeBrokerLauncherInput(child: ChildProcess, chunk: string): Promise<void> {
|
|
165
|
+
const input = child.stdin;
|
|
166
|
+
if (!input || input.destroyed || input.writableEnded)
|
|
167
|
+
return Promise.reject(new Error("Broker trampoline control pipe is closed."));
|
|
168
|
+
const { promise, resolve, reject } = Promise.withResolvers<void>();
|
|
169
|
+
try {
|
|
170
|
+
input.write(chunk, error => (error ? reject(error) : resolve()));
|
|
171
|
+
} catch (error) {
|
|
172
|
+
reject(error instanceof Error ? error : new Error(String(error)));
|
|
173
|
+
}
|
|
174
|
+
return promise;
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
function awaitLauncherClose(child: ChildProcess): Promise<{ code: number | null; spawnError?: Error }> {
|
|
178
|
+
const { promise, resolve } = Promise.withResolvers<{ code: number | null; spawnError?: Error }>();
|
|
179
|
+
let spawnError: Error | undefined;
|
|
180
|
+
child.once("error", error => {
|
|
181
|
+
spawnError = error;
|
|
182
|
+
resolve({ code: null, spawnError });
|
|
183
|
+
});
|
|
184
|
+
child.once("close", code => resolve({ code, ...(spawnError ? { spawnError } : {}) }));
|
|
185
|
+
return promise;
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
async function terminateLauncher(child: ChildProcess): Promise<boolean> {
|
|
189
|
+
if (child.exitCode !== null || child.signalCode !== null) return true;
|
|
190
|
+
const exited = Promise.withResolvers<void>();
|
|
191
|
+
child.once("exit", exited.resolve);
|
|
192
|
+
child.once("close", exited.resolve);
|
|
193
|
+
child.on("error", () => {});
|
|
194
|
+
try {
|
|
195
|
+
child.kill("SIGKILL");
|
|
196
|
+
} catch {
|
|
197
|
+
// The exit events below still distinguish a launcher that already exited.
|
|
198
|
+
}
|
|
199
|
+
await Promise.race([exited.promise, Bun.sleep(1_000)]);
|
|
200
|
+
return child.exitCode !== null || child.signalCode !== null;
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
async function awaitLauncherCloseBeforeDeadline(
|
|
204
|
+
child: ChildProcess,
|
|
205
|
+
timeoutMs: number,
|
|
206
|
+
onTimeout?: () => void,
|
|
207
|
+
): Promise<
|
|
208
|
+
{ kind: "closed"; outcome: { code: number | null; spawnError?: Error } } | { kind: "timeout"; terminated: boolean }
|
|
209
|
+
> {
|
|
210
|
+
const closed = awaitLauncherClose(child);
|
|
211
|
+
const result = await Promise.race([
|
|
212
|
+
closed.then(outcome => ({ kind: "closed" as const, outcome })),
|
|
213
|
+
Bun.sleep(timeoutMs).then(() => ({ kind: "timeout" as const })),
|
|
214
|
+
]);
|
|
215
|
+
if (result.kind === "closed") return result;
|
|
216
|
+
if (onTimeout) {
|
|
217
|
+
onTimeout();
|
|
218
|
+
const cleanupFinished = await Promise.race([
|
|
219
|
+
closed.then(() => true),
|
|
220
|
+
Bun.sleep(HOP_HANDOFF_CLEANUP_TIMEOUT_MS).then(() => false),
|
|
221
|
+
]);
|
|
222
|
+
if (cleanupFinished) return { kind: "timeout", terminated: true };
|
|
223
|
+
}
|
|
224
|
+
return { kind: "timeout", terminated: await terminateLauncher(child) };
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
/** Test hook: proves a stuck launcher is terminated at its deadline. */
|
|
228
|
+
export async function awaitBrokerLauncherForTest(
|
|
229
|
+
child: ChildProcess,
|
|
230
|
+
timeoutMs: number,
|
|
231
|
+
onTimeout?: () => void,
|
|
232
|
+
): Promise<{ kind: "closed" } | { kind: "timeout"; terminated: boolean }> {
|
|
233
|
+
const result = await awaitLauncherCloseBeforeDeadline(child, timeoutMs, onTimeout);
|
|
234
|
+
return result.kind === "closed" ? { kind: "closed" } : result;
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
/**
|
|
238
|
+
* Windows only: launch the broker through the internal hop and await its reply.
|
|
239
|
+
*
|
|
240
|
+
* `taskkill /T /F` walks ParentProcessId, so a broker spawned directly by the client
|
|
241
|
+
* dies with it even when detached. The hop spawns the broker and exits after
|
|
242
|
+
* the parent acknowledges the complete reply, leaving the broker without a
|
|
243
|
+
* live parent in the client's tree. The broker inherits the environment set on
|
|
244
|
+
* the hop, never passed on the command line; stderr is passed as a path because
|
|
245
|
+
* a parent fd number does not exist inside the hop.
|
|
246
|
+
*/
|
|
247
|
+
export async function launchBrokerViaHop(
|
|
248
|
+
message: BrokerHopMessage,
|
|
249
|
+
options: { env: NodeJS.ProcessEnv; cwd?: string; timeoutMs?: number },
|
|
250
|
+
): Promise<BrokerLaunchResult> {
|
|
251
|
+
const timeoutMs = options.timeoutMs ?? DEFAULT_LAUNCHER_TIMEOUT_MS;
|
|
252
|
+
if (!Number.isFinite(timeoutMs) || timeoutMs <= 0) throw new Error("Windows broker hop startup deadline elapsed.");
|
|
253
|
+
const hopCmd = resolveHopInvocation(JSON.stringify(message));
|
|
254
|
+
const hop = spawn(hopCmd.file, hopCmd.args, {
|
|
255
|
+
detached: false,
|
|
256
|
+
windowsHide: true,
|
|
257
|
+
stdio: ["pipe", "pipe", "pipe"],
|
|
258
|
+
env: options.env,
|
|
259
|
+
...(options.cwd ? { cwd: options.cwd } : {}),
|
|
260
|
+
});
|
|
261
|
+
let stdout = "";
|
|
262
|
+
let stderr = "";
|
|
263
|
+
let acknowledgementSent = false;
|
|
264
|
+
hop.stdout?.on("data", chunk => {
|
|
265
|
+
stdout = appendBoundedOutput(stdout, chunk);
|
|
266
|
+
if (acknowledgementSent) return;
|
|
267
|
+
// A successful hop write callback does not prove this parent received the
|
|
268
|
+
// record. Keep the hop's pinned broker until this complete identity parses.
|
|
269
|
+
const newlineIndex = stdout.indexOf("\n");
|
|
270
|
+
if (newlineIndex < 0) return;
|
|
271
|
+
acknowledgementSent = true;
|
|
272
|
+
const replyText = stdout.slice(0, newlineIndex + 1);
|
|
273
|
+
const trailingOutput = stdout.slice(newlineIndex + 1);
|
|
274
|
+
const parsed = parseBrokerHopReply(0, replyText);
|
|
275
|
+
if (parsed.error === undefined && trailingOutput.length === 0)
|
|
276
|
+
endBrokerLauncherInput(hop, BROKER_HANDOFF_ACKNOWLEDGEMENT);
|
|
277
|
+
else endBrokerLauncherInput(hop);
|
|
278
|
+
});
|
|
279
|
+
hop.stderr?.on("data", chunk => {
|
|
280
|
+
stderr = appendBoundedOutput(stderr, chunk);
|
|
281
|
+
});
|
|
282
|
+
hop.stdin?.on("error", () => {});
|
|
283
|
+
const wait = await awaitLauncherCloseBeforeDeadline(hop, timeoutMs, () => {
|
|
284
|
+
hop.stdout?.destroy();
|
|
285
|
+
endBrokerLauncherInput(hop);
|
|
286
|
+
});
|
|
287
|
+
if (wait.kind === "timeout") {
|
|
288
|
+
return await brokerHopTimeoutResult(hop, stdout, stderr, timeoutMs, wait.terminated);
|
|
289
|
+
}
|
|
290
|
+
return { process: hop, ...parseBrokerHopReply(wait.outcome.code, stdout, wait.outcome.spawnError, stderr) };
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
async function brokerHopTimeoutResult(
|
|
294
|
+
hop: ChildProcess,
|
|
295
|
+
stdout: string,
|
|
296
|
+
stderr: string,
|
|
297
|
+
timeoutMs: number,
|
|
298
|
+
launcherTerminated: boolean,
|
|
299
|
+
): Promise<BrokerLaunchResult> {
|
|
300
|
+
// A complete reply can arrive before the hop's close event. Preserve its
|
|
301
|
+
// incarnation and reap that exact broker instead of leaving it detached after
|
|
302
|
+
// reporting a timeout to the caller.
|
|
303
|
+
const handoff = parseBrokerHopReply(0, stdout, undefined, stderr);
|
|
304
|
+
const brokerDetail = await reapTimedOutBrokerHandoff(handoff.realBrokerPid, handoff.realBrokerIncarnation);
|
|
305
|
+
const launcherDetail = launcherTerminated
|
|
306
|
+
? "hop exited after handoff cleanup"
|
|
307
|
+
: "hop did not exit after termination";
|
|
308
|
+
return {
|
|
309
|
+
process: hop,
|
|
310
|
+
realBrokerPid: handoff.realBrokerPid,
|
|
311
|
+
...(handoff.realBrokerIncarnation !== undefined ? { realBrokerIncarnation: handoff.realBrokerIncarnation } : {}),
|
|
312
|
+
error: new BrokerHopError({
|
|
313
|
+
exitCode: hop.exitCode,
|
|
314
|
+
stdout,
|
|
315
|
+
stderr,
|
|
316
|
+
reason: `hop exceeded its ${timeoutMs}ms startup deadline; ${launcherDetail}${brokerDetail}`,
|
|
317
|
+
}),
|
|
318
|
+
};
|
|
319
|
+
}
|
|
320
|
+
|
|
321
|
+
async function reapTimedOutBrokerHandoff(
|
|
322
|
+
realBrokerPid: number | undefined,
|
|
323
|
+
realBrokerIncarnation: string | undefined,
|
|
324
|
+
): Promise<string> {
|
|
325
|
+
if (realBrokerPid === undefined || realBrokerIncarnation === undefined) return "";
|
|
326
|
+
try {
|
|
327
|
+
await reapDetachedBrokerPid(realBrokerPid, realBrokerIncarnation, {
|
|
328
|
+
gracefulMs: HOP_HANDOFF_CLEANUP_TIMEOUT_MS,
|
|
329
|
+
killVerifyMs: HOP_HANDOFF_CLEANUP_TIMEOUT_MS,
|
|
330
|
+
});
|
|
331
|
+
return "; captured broker handoff was reaped";
|
|
332
|
+
} catch (error) {
|
|
333
|
+
const detail = error instanceof Error ? error.message : String(error);
|
|
334
|
+
return `; exact broker cleanup failed: ${detail}`;
|
|
335
|
+
}
|
|
336
|
+
}
|
|
337
|
+
|
|
338
|
+
/** Parses the hop's single-line pid/incarnation reply into broker identity or a typed error. */
|
|
339
|
+
export function parseBrokerHopReply(
|
|
340
|
+
code: number | null,
|
|
341
|
+
stdout: string,
|
|
342
|
+
spawnError?: Error,
|
|
343
|
+
stderr = "",
|
|
344
|
+
): { realBrokerPid: number | undefined; realBrokerIncarnation?: string; error: Error | undefined } {
|
|
345
|
+
const fail = (reason: string) => ({
|
|
346
|
+
realBrokerPid: undefined,
|
|
347
|
+
error: new BrokerHopError({ exitCode: code, stdout, stderr, reason }),
|
|
348
|
+
});
|
|
349
|
+
if (spawnError) return fail(`hop could not be spawned: ${spawnError.message}`);
|
|
350
|
+
if (code !== 0) return fail(`hop process exited with non-zero code ${code}`);
|
|
351
|
+
const line = stdout.trim();
|
|
352
|
+
if (!line) return fail("hop exited with code 0 but no response on stdout");
|
|
353
|
+
let reply: unknown;
|
|
354
|
+
try {
|
|
355
|
+
reply = JSON.parse(line);
|
|
356
|
+
} catch (error) {
|
|
357
|
+
return fail(`failed to parse hop JSON response: ${error instanceof Error ? error.message : String(error)}`);
|
|
358
|
+
}
|
|
359
|
+
const pid = (reply as { pid?: unknown } | null)?.pid;
|
|
360
|
+
if (typeof pid !== "number" || !Number.isSafeInteger(pid) || pid <= 0)
|
|
361
|
+
return fail(`hop response missing or invalid pid: ${String(pid)}`);
|
|
362
|
+
const incarnation = (reply as { incarnation?: unknown } | null)?.incarnation;
|
|
363
|
+
if (!isProcessIncarnation(incarnation)) return fail("hop response missing or invalid process incarnation");
|
|
364
|
+
return { realBrokerPid: pid, realBrokerIncarnation: incarnation, error: undefined };
|
|
365
|
+
}
|
|
366
|
+
|
|
367
|
+
/** POSIX only: the trampoline holds its exact broker child until the publication decision. */
|
|
368
|
+
export async function launchBrokerViaPosixTrampoline(
|
|
369
|
+
agentDir: string,
|
|
370
|
+
options: {
|
|
371
|
+
env: NodeJS.ProcessEnv;
|
|
372
|
+
cwd?: string;
|
|
373
|
+
stderrFd?: number;
|
|
374
|
+
timeoutMs?: number;
|
|
375
|
+
retainChildUntilPublication?: boolean;
|
|
376
|
+
},
|
|
377
|
+
): Promise<BrokerLaunchResult> {
|
|
378
|
+
const timeoutMs = options.timeoutMs ?? DEFAULT_LAUNCHER_TIMEOUT_MS;
|
|
379
|
+
if (!Number.isFinite(timeoutMs) || timeoutMs <= 0)
|
|
380
|
+
throw new Error("POSIX broker trampoline startup deadline elapsed.");
|
|
381
|
+
const command = resolveSdkInternalSpawnCommand("broker-trampoline-internal");
|
|
382
|
+
const trampolineEnvironment = { ...options.env };
|
|
383
|
+
if (options.retainChildUntilPublication) trampolineEnvironment.GJC_BROKER_TRAMPOLINE_RETAIN_UNTIL_PUBLICATION = "1";
|
|
384
|
+
else delete trampolineEnvironment.GJC_BROKER_TRAMPOLINE_RETAIN_UNTIL_PUBLICATION;
|
|
385
|
+
const child = spawn(command.file, [...command.args, "--agent-dir", agentDir], {
|
|
386
|
+
detached: true,
|
|
387
|
+
stdio: ["pipe", "pipe", options.stderrFd ?? "ignore"],
|
|
388
|
+
env: trampolineEnvironment,
|
|
389
|
+
...(options.cwd ? { cwd: options.cwd } : {}),
|
|
390
|
+
});
|
|
391
|
+
if (options.retainChildUntilPublication) return await launchBrokerViaRetainedPosixTrampoline(child, timeoutMs);
|
|
392
|
+
let stdout = "";
|
|
393
|
+
let acknowledgementSent = false;
|
|
394
|
+
child.stdout?.on("data", chunk => {
|
|
395
|
+
stdout = appendBoundedOutput(stdout, chunk);
|
|
396
|
+
if (acknowledgementSent) return;
|
|
397
|
+
const newlineIndex = stdout.indexOf("\n");
|
|
398
|
+
if (newlineIndex < 0) return;
|
|
399
|
+
acknowledgementSent = true;
|
|
400
|
+
const replyText = stdout.slice(0, newlineIndex + 1);
|
|
401
|
+
const trailingOutput = stdout.slice(newlineIndex + 1);
|
|
402
|
+
const parsed = parseBrokerTrampolineReply(0, replyText);
|
|
403
|
+
if (parsed.error === undefined && trailingOutput.length === 0)
|
|
404
|
+
endBrokerLauncherInput(child, BROKER_HANDOFF_ACKNOWLEDGEMENT);
|
|
405
|
+
else endBrokerLauncherInput(child);
|
|
406
|
+
});
|
|
407
|
+
child.stdin?.on("error", () => {});
|
|
408
|
+
const wait = await awaitLauncherCloseBeforeDeadline(child, timeoutMs, () => {
|
|
409
|
+
child.stdout?.destroy();
|
|
410
|
+
endBrokerLauncherInput(child);
|
|
411
|
+
});
|
|
412
|
+
if (wait.kind === "timeout") {
|
|
413
|
+
return await brokerTrampolineTimeoutResult(child, stdout, timeoutMs, wait.terminated);
|
|
414
|
+
}
|
|
415
|
+
const parsed = parseBrokerTrampolineReply(wait.outcome.code, stdout, wait.outcome.spawnError);
|
|
416
|
+
return { process: child, ...parsed };
|
|
417
|
+
}
|
|
418
|
+
|
|
419
|
+
const RETAINED_TRAMPOLINE_CLEANUP_TIMEOUT_MS = 5_000;
|
|
420
|
+
|
|
421
|
+
function createBrokerLaunchHandoff(
|
|
422
|
+
trampoline: ChildProcess,
|
|
423
|
+
brokerPid: number,
|
|
424
|
+
brokerIncarnation: string,
|
|
425
|
+
): BrokerLaunchHandoff {
|
|
426
|
+
const closed = awaitLauncherClose(trampoline);
|
|
427
|
+
let disposition: "release" | "terminate" | undefined;
|
|
428
|
+
let completion: Promise<void> | undefined;
|
|
429
|
+
const finish = (requested: "release" | "terminate"): Promise<void> => {
|
|
430
|
+
if (completion) {
|
|
431
|
+
if (disposition !== requested)
|
|
432
|
+
return Promise.reject(new Error(`Broker trampoline already received ${disposition}.`));
|
|
433
|
+
return completion;
|
|
434
|
+
}
|
|
435
|
+
disposition = requested;
|
|
436
|
+
completion = (async () => {
|
|
437
|
+
const message = requested === "release" ? BROKER_HANDOFF_COMMIT : BROKER_HANDOFF_ABORT;
|
|
438
|
+
let inputError: unknown;
|
|
439
|
+
if (!trampoline.stdin?.destroyed && !trampoline.stdin?.writableEnded) {
|
|
440
|
+
try {
|
|
441
|
+
await writeBrokerLauncherInput(trampoline, message);
|
|
442
|
+
} catch (error) {
|
|
443
|
+
inputError = error;
|
|
444
|
+
} finally {
|
|
445
|
+
endBrokerLauncherInput(trampoline);
|
|
446
|
+
}
|
|
447
|
+
} else if (requested === "release") {
|
|
448
|
+
inputError = new Error("POSIX broker trampoline control pipe closed before publication commit.");
|
|
449
|
+
}
|
|
450
|
+
const outcome = await Promise.race([
|
|
451
|
+
closed,
|
|
452
|
+
Bun.sleep(RETAINED_TRAMPOLINE_CLEANUP_TIMEOUT_MS).then(() => undefined),
|
|
453
|
+
]);
|
|
454
|
+
if (!outcome) throw new Error("POSIX broker trampoline did not complete its publication decision.");
|
|
455
|
+
if (outcome.spawnError) throw outcome.spawnError;
|
|
456
|
+
if (requested === "release") {
|
|
457
|
+
if (inputError)
|
|
458
|
+
throw new Error("Could not commit the POSIX broker handoff after publication.", { cause: inputError });
|
|
459
|
+
if (outcome.code !== 0)
|
|
460
|
+
throw new Error(`POSIX broker trampoline exited with code ${outcome.code} before release completed.`);
|
|
461
|
+
return;
|
|
462
|
+
}
|
|
463
|
+
const observation = observeProcessIncarnation(brokerPid);
|
|
464
|
+
if (
|
|
465
|
+
observation.status !== "absent" &&
|
|
466
|
+
!(observation.status === "present" && observation.incarnation !== brokerIncarnation)
|
|
467
|
+
)
|
|
468
|
+
throw new Error(`POSIX broker trampoline did not verify cleanup of broker ${brokerPid}.`);
|
|
469
|
+
})();
|
|
470
|
+
return completion;
|
|
471
|
+
};
|
|
472
|
+
return { release: () => finish("release"), terminate: () => finish("terminate") };
|
|
473
|
+
}
|
|
474
|
+
|
|
475
|
+
async function launchBrokerViaRetainedPosixTrampoline(
|
|
476
|
+
trampoline: ChildProcess,
|
|
477
|
+
timeoutMs: number,
|
|
478
|
+
): Promise<BrokerLaunchResult> {
|
|
479
|
+
let stdout = "";
|
|
480
|
+
let parsed: ReturnType<typeof parseBrokerTrampolineReply> | undefined;
|
|
481
|
+
let failure: Error | undefined;
|
|
482
|
+
let settled = false;
|
|
483
|
+
const reply = Promise.withResolvers<void>();
|
|
484
|
+
const finish = (error?: Error): void => {
|
|
485
|
+
if (settled) return;
|
|
486
|
+
settled = true;
|
|
487
|
+
failure = error;
|
|
488
|
+
reply.resolve();
|
|
489
|
+
};
|
|
490
|
+
const closed = awaitLauncherClose(trampoline);
|
|
491
|
+
void closed.then(outcome => {
|
|
492
|
+
if (settled) return;
|
|
493
|
+
const result = parseBrokerTrampolineReply(outcome.code, stdout, outcome.spawnError);
|
|
494
|
+
parsed = result;
|
|
495
|
+
finish(result.error ?? new Error("POSIX broker trampoline exited before publication was confirmed."));
|
|
496
|
+
});
|
|
497
|
+
trampoline.stdout?.on("data", chunk => {
|
|
498
|
+
stdout = appendBoundedOutput(stdout, chunk);
|
|
499
|
+
if (settled) return;
|
|
500
|
+
const newlineIndex = stdout.indexOf("\n");
|
|
501
|
+
if (newlineIndex < 0) return;
|
|
502
|
+
const replyText = stdout.slice(0, newlineIndex + 1);
|
|
503
|
+
const trailingOutput = stdout.slice(newlineIndex + 1);
|
|
504
|
+
const result = parseBrokerTrampolineReply(0, replyText);
|
|
505
|
+
if (result.error || trailingOutput.length > 0) {
|
|
506
|
+
parsed = result;
|
|
507
|
+
endBrokerLauncherInput(trampoline);
|
|
508
|
+
finish(result.error ?? new Error("POSIX broker trampoline wrote unexpected output."));
|
|
509
|
+
return;
|
|
510
|
+
}
|
|
511
|
+
parsed = result;
|
|
512
|
+
void writeBrokerLauncherInput(trampoline, BROKER_HANDOFF_ACKNOWLEDGEMENT).then(
|
|
513
|
+
() => finish(),
|
|
514
|
+
error => {
|
|
515
|
+
endBrokerLauncherInput(trampoline);
|
|
516
|
+
finish(error instanceof Error ? error : new Error(String(error)));
|
|
517
|
+
},
|
|
518
|
+
);
|
|
519
|
+
});
|
|
520
|
+
trampoline.stdin?.on("error", () => {});
|
|
521
|
+
const wait = await Promise.race([
|
|
522
|
+
reply.promise.then(() => ({ kind: "reply" as const })),
|
|
523
|
+
Bun.sleep(timeoutMs).then(() => ({ kind: "timeout" as const })),
|
|
524
|
+
]);
|
|
525
|
+
const identity = {
|
|
526
|
+
realBrokerPid: parsed?.realBrokerPid,
|
|
527
|
+
...(parsed?.realBrokerIncarnation === undefined ? {} : { realBrokerIncarnation: parsed.realBrokerIncarnation }),
|
|
528
|
+
};
|
|
529
|
+
if (wait.kind === "timeout") {
|
|
530
|
+
endBrokerLauncherInput(trampoline);
|
|
531
|
+
return {
|
|
532
|
+
process: trampoline,
|
|
533
|
+
...identity,
|
|
534
|
+
...(identity.realBrokerPid !== undefined && identity.realBrokerIncarnation !== undefined
|
|
535
|
+
? { handoff: createBrokerLaunchHandoff(trampoline, identity.realBrokerPid, identity.realBrokerIncarnation) }
|
|
536
|
+
: {}),
|
|
537
|
+
error: new Error(`POSIX broker trampoline did not report a verified identity within ${timeoutMs}ms.`),
|
|
538
|
+
};
|
|
539
|
+
}
|
|
540
|
+
if (failure) {
|
|
541
|
+
endBrokerLauncherInput(trampoline);
|
|
542
|
+
return {
|
|
543
|
+
process: trampoline,
|
|
544
|
+
...identity,
|
|
545
|
+
...(identity.realBrokerPid !== undefined && identity.realBrokerIncarnation !== undefined
|
|
546
|
+
? { handoff: createBrokerLaunchHandoff(trampoline, identity.realBrokerPid, identity.realBrokerIncarnation) }
|
|
547
|
+
: {}),
|
|
548
|
+
error: failure,
|
|
549
|
+
};
|
|
550
|
+
}
|
|
551
|
+
if (identity.realBrokerPid === undefined || identity.realBrokerIncarnation === undefined)
|
|
552
|
+
return {
|
|
553
|
+
process: trampoline,
|
|
554
|
+
...identity,
|
|
555
|
+
error: new Error("POSIX broker trampoline returned no verified process identity."),
|
|
556
|
+
};
|
|
557
|
+
return {
|
|
558
|
+
process: trampoline,
|
|
559
|
+
...identity,
|
|
560
|
+
error: undefined,
|
|
561
|
+
handoff: createBrokerLaunchHandoff(trampoline, identity.realBrokerPid, identity.realBrokerIncarnation),
|
|
562
|
+
};
|
|
563
|
+
}
|
|
564
|
+
|
|
565
|
+
async function brokerTrampolineTimeoutResult(
|
|
566
|
+
trampoline: ChildProcess,
|
|
567
|
+
stdout: string,
|
|
568
|
+
timeoutMs: number,
|
|
569
|
+
trampolineTerminated: boolean,
|
|
570
|
+
): Promise<BrokerLaunchResult> {
|
|
571
|
+
const handoff = parseBrokerTrampolineReply(0, stdout);
|
|
572
|
+
const brokerDetail = await reapTimedOutBrokerHandoff(handoff.realBrokerPid, handoff.realBrokerIncarnation);
|
|
573
|
+
const trampolineDetail = trampolineTerminated
|
|
574
|
+
? "trampoline was terminated"
|
|
575
|
+
: "trampoline did not exit after termination";
|
|
576
|
+
const handoffFailure = handoff.realBrokerPid === undefined && handoff.error ? `; ${handoff.error.message}` : "";
|
|
577
|
+
return {
|
|
578
|
+
process: trampoline,
|
|
579
|
+
realBrokerPid: handoff.realBrokerPid,
|
|
580
|
+
...(handoff.realBrokerIncarnation !== undefined ? { realBrokerIncarnation: handoff.realBrokerIncarnation } : {}),
|
|
581
|
+
error: new Error(
|
|
582
|
+
`POSIX broker trampoline exceeded its ${timeoutMs}ms startup deadline; ${trampolineDetail}${brokerDetail}${handoffFailure}`,
|
|
583
|
+
),
|
|
584
|
+
};
|
|
585
|
+
}
|
|
586
|
+
|
|
587
|
+
export function parseBrokerTrampolineReply(
|
|
588
|
+
code: number | null,
|
|
589
|
+
stdout: string,
|
|
590
|
+
spawnError?: Error,
|
|
591
|
+
): { realBrokerPid: number | undefined; realBrokerIncarnation?: string; error: Error | undefined } {
|
|
592
|
+
const fail = (reason: string) => ({
|
|
593
|
+
realBrokerPid: undefined,
|
|
594
|
+
error: new Error(`POSIX broker trampoline failed: ${reason}`),
|
|
595
|
+
});
|
|
596
|
+
if (spawnError) return fail(`could not be spawned: ${spawnError.message}`);
|
|
597
|
+
if (code !== 0) return fail(`exited with non-zero code ${code}: ${stdout.trim()}`);
|
|
598
|
+
const match = /^(\d+)\t([^\t\r\n]+)$/.exec(stdout.trim());
|
|
599
|
+
const pid = match ? Number(match[1]) : Number.NaN;
|
|
600
|
+
const incarnation = match?.[2];
|
|
601
|
+
if (!Number.isSafeInteger(pid) || pid <= 0 || !isProcessIncarnation(incarnation))
|
|
602
|
+
return fail("reply did not contain a valid pid and process incarnation");
|
|
603
|
+
return { realBrokerPid: pid, realBrokerIncarnation: incarnation, error: undefined };
|
|
604
|
+
}
|
|
605
|
+
|
|
31
606
|
function resolveExpectedBrokerGeneration(): string {
|
|
32
607
|
const v = (packageJson as { version?: unknown }).version;
|
|
33
608
|
return typeof v === "string" && v.length > 0 ? v : "unknown";
|
|
@@ -39,15 +614,36 @@ function brokerStartupFailureReason(marker: BrokerStartupFailureMarker | undefin
|
|
|
39
614
|
return marker.reason;
|
|
40
615
|
}
|
|
41
616
|
|
|
617
|
+
function brokerSpawnFailureError(spawnError: Error): Error {
|
|
618
|
+
return spawnError instanceof BrokerHopError
|
|
619
|
+
? spawnError
|
|
620
|
+
: new Error(`Failed to spawn detached SDK broker: ${spawnError.message}`);
|
|
621
|
+
}
|
|
622
|
+
|
|
42
623
|
function brokerStartupExitReason(record: BrokerStartupExitRecord | undefined): string | undefined {
|
|
43
624
|
if (!record) return undefined;
|
|
44
625
|
if (record.reason === "startup-lock-blocked")
|
|
45
626
|
return `SDK broker startup blocked by retained removal transition ${record.blockingLockPath}.`;
|
|
627
|
+
if (record.reason === "startup-lock-unavailable")
|
|
628
|
+
return `SDK broker startup could not acquire required lock ${record.blockingLockPath}.`;
|
|
46
629
|
if (record.reason === "startup-deadline")
|
|
47
630
|
return `SDK broker startup exceeded its ${record.timeoutMs}ms fence deadline.`;
|
|
48
631
|
return `SDK broker startup interrupted by ${record.signal} before readiness.`;
|
|
49
632
|
}
|
|
50
633
|
|
|
634
|
+
function brokerStartupExitStatus(
|
|
635
|
+
childExitCode: number | null,
|
|
636
|
+
childSignal: NodeJS.Signals | null,
|
|
637
|
+
startupExitRecord: Pick<BrokerStartupExitRecord, "exitCode" | "signal"> | undefined,
|
|
638
|
+
startupFailureMarker: Pick<BrokerStartupFailureMarker, "exitCode" | "signal"> | undefined,
|
|
639
|
+
): { exitCode: number | null; signal: string | null } {
|
|
640
|
+
const brokerExitEvidence = startupExitRecord ?? startupFailureMarker;
|
|
641
|
+
return {
|
|
642
|
+
exitCode: brokerExitEvidence ? brokerExitEvidence.exitCode : childExitCode,
|
|
643
|
+
signal: brokerExitEvidence ? brokerExitEvidence.signal : childSignal,
|
|
644
|
+
};
|
|
645
|
+
}
|
|
646
|
+
|
|
51
647
|
export function isBrokerGenerationCompatible(discovery: BrokerDiscovery | null): boolean {
|
|
52
648
|
if (!discovery) return false;
|
|
53
649
|
return discovery.packageGeneration === resolveExpectedBrokerGeneration();
|
|
@@ -83,13 +679,17 @@ export interface EnsureBrokerSettings {
|
|
|
83
679
|
}
|
|
84
680
|
|
|
85
681
|
const BROKER_PUBLICATION_TIMEOUT_MS = 15_000;
|
|
86
|
-
const STARTUP_LOCK_WAIT_MS = 9_000;
|
|
87
682
|
const STALE_BROKER_RETIREMENT_TIMEOUT_MS = 5_000;
|
|
683
|
+
const SPAWN_LOCK_RETRY_DELAY_MS = 50;
|
|
684
|
+
/** Keep the startup fence alive long enough to adopt an ownerless removal transition. */
|
|
685
|
+
const STARTUP_LOCK_WAIT_MS = Math.max(
|
|
686
|
+
9_000,
|
|
687
|
+
FILE_LOCK_ORPHAN_TRANSITION_ADOPTION_AGE_MS + SPAWN_LOCK_RETRY_DELAY_MS * 2,
|
|
688
|
+
);
|
|
88
689
|
/** Parent budget covers stale retirement, a full child-fence wait, and one publication attempt. */
|
|
89
690
|
const DISCOVERY_TIMEOUT_MS = STALE_BROKER_RETIREMENT_TIMEOUT_MS + STARTUP_LOCK_WAIT_MS + BROKER_PUBLICATION_TIMEOUT_MS;
|
|
90
691
|
/** A process that loses the spawn lock waits this long for the winner's discovery before giving up. */
|
|
91
692
|
const SPAWN_LOCK_WAIT_MS = STALE_BROKER_RETIREMENT_TIMEOUT_MS + DISCOVERY_TIMEOUT_MS + 5_000;
|
|
92
|
-
const SPAWN_LOCK_RETRY_DELAY_MS = 50;
|
|
93
693
|
const SPAWN_LOCK_TARGET_NAME = "broker.spawn";
|
|
94
694
|
const STARTUP_LOCK_TARGET_NAME = "broker.startup";
|
|
95
695
|
const BROKER_SESSION_ENV_NAMES = new Set([
|
|
@@ -121,18 +721,71 @@ const BROKER_SESSION_ENV_PREFIXES = [
|
|
|
121
721
|
"GJC_MANAGED_OWNER_",
|
|
122
722
|
] as const;
|
|
123
723
|
|
|
124
|
-
export interface
|
|
724
|
+
export interface BrokerStartupLockOptions {
|
|
725
|
+
/** Stop waiting for the startup lock at this absolute wall-clock deadline. */
|
|
726
|
+
deadlineAt?: number;
|
|
125
727
|
onAcquired?: () => void;
|
|
126
728
|
onContended?: () => void;
|
|
127
729
|
}
|
|
128
730
|
|
|
731
|
+
const BROKER_TEST_SIGNAL_DIR_ENV = "GJC_SDK_TEST_BROKER_SIGNAL_DIR";
|
|
732
|
+
/** Bound the project dotenv read. A FIFO or a larger file is incomplete and rejected. */
|
|
733
|
+
const BROKER_SIGNAL_DOTENV_MAX_BYTES = 64 * 1024;
|
|
734
|
+
|
|
735
|
+
/**
|
|
736
|
+
* Dotenv from the cwd that first loaded this module. `launch --worktree` changes
|
|
737
|
+
* cwd after Bun has already copied that file into `process.env`.
|
|
738
|
+
*/
|
|
739
|
+
const startupBrokerSignalSnapshot = projectEnvSnapshot(process.cwd(), {
|
|
740
|
+
maxFileBytes: BROKER_SIGNAL_DOTENV_MAX_BYTES,
|
|
741
|
+
});
|
|
742
|
+
|
|
743
|
+
function brokerSignalSnapshots(override?: readonly ProjectEnvSnapshot[]): readonly ProjectEnvSnapshot[] {
|
|
744
|
+
if (override) return override;
|
|
745
|
+
return [
|
|
746
|
+
projectEnvSnapshot(process.cwd(), { maxFileBytes: BROKER_SIGNAL_DOTENV_MAX_BYTES }),
|
|
747
|
+
startupBrokerSignalSnapshot,
|
|
748
|
+
];
|
|
749
|
+
}
|
|
750
|
+
|
|
751
|
+
/**
|
|
752
|
+
* Operator environment only. A directory declared by the project dotenv is
|
|
753
|
+
* ignored so startup cannot create a path the repository chose.
|
|
754
|
+
* No signal value means no later dotenv read, so an unused seam cannot stall startup.
|
|
755
|
+
* An incomplete startup or cwd snapshot rejects the value.
|
|
756
|
+
*/
|
|
757
|
+
function trustedBrokerTestSignalDirectory(
|
|
758
|
+
env: NodeJS.ProcessEnv,
|
|
759
|
+
snapshots?: readonly ProjectEnvSnapshot[],
|
|
760
|
+
): string | undefined {
|
|
761
|
+
const raw = env[BROKER_TEST_SIGNAL_DIR_ENV];
|
|
762
|
+
if (!raw) return undefined;
|
|
763
|
+
let accepted: string | undefined;
|
|
764
|
+
for (const snapshot of brokerSignalSnapshots(snapshots)) {
|
|
765
|
+
if (snapshot.incomplete) return undefined;
|
|
766
|
+
const trusted = trustedCoordinatorEnv(BROKER_TEST_SIGNAL_DIR_ENV, env, snapshot);
|
|
767
|
+
if (trusted === undefined) return undefined;
|
|
768
|
+
accepted = trusted;
|
|
769
|
+
}
|
|
770
|
+
return accepted;
|
|
771
|
+
}
|
|
772
|
+
|
|
773
|
+
/** Drop a project-dotenv signal directory. Keep a trusted directory as the value that was accepted. */
|
|
774
|
+
function scrubUntrustedBrokerTestSignalDirectory(environment: NodeJS.ProcessEnv): void {
|
|
775
|
+
const trusted = trustedBrokerTestSignalDirectory(environment);
|
|
776
|
+
for (const name of Object.keys(environment)) {
|
|
777
|
+
if (name.toUpperCase() === BROKER_TEST_SIGNAL_DIR_ENV) delete environment[name];
|
|
778
|
+
}
|
|
779
|
+
if (trusted !== undefined) environment[BROKER_TEST_SIGNAL_DIR_ENV] = trusted;
|
|
780
|
+
}
|
|
781
|
+
|
|
129
782
|
/**
|
|
130
783
|
* Test-only phase observation for detached broker startup regressions. The
|
|
131
|
-
*
|
|
132
|
-
* ownership or error handling when the
|
|
784
|
+
* directory is opt-in through a trusted operator environment and never affects
|
|
785
|
+
* broker ownership or error handling when the seam is absent.
|
|
133
786
|
*/
|
|
134
787
|
export async function emitBrokerStartupTestSignal(signal: string): Promise<void> {
|
|
135
|
-
const directory = process.env
|
|
788
|
+
const directory = trustedBrokerTestSignalDirectory(process.env);
|
|
136
789
|
if (!directory || !/^[a-z0-9-]+$/.test(signal)) return;
|
|
137
790
|
try {
|
|
138
791
|
await fs.mkdir(directory, { recursive: true, mode: 0o700 });
|
|
@@ -189,33 +842,6 @@ export function setEnsureBrokerTimingForTest(timing: EnsureBrokerTiming | undefi
|
|
|
189
842
|
|
|
190
843
|
type SpawnLockOptions = Pick<FileLockOptions, "retries" | "retryDelayMs" | "signal">;
|
|
191
844
|
|
|
192
|
-
interface BrokerLockHostIdentity {
|
|
193
|
-
ownerHostId: string;
|
|
194
|
-
previousOwnerHostIds: readonly string[];
|
|
195
|
-
}
|
|
196
|
-
|
|
197
|
-
const brokerLockHostIdentityPromises = new Map<string, Promise<BrokerLockHostIdentity>>();
|
|
198
|
-
|
|
199
|
-
async function brokerLockHostIdentity(agentDir: string): Promise<BrokerLockHostIdentity> {
|
|
200
|
-
const coordinationRoot = path.resolve(agentDir, "sdk");
|
|
201
|
-
const pending =
|
|
202
|
-
brokerLockHostIdentityPromises.get(coordinationRoot) ??
|
|
203
|
-
Promise.all([loadInstallationHostId({ configRootDir: coordinationRoot }), loadLegacyInstallationHostId()]).then(
|
|
204
|
-
([ownerHostId, legacyHostId]) => ({
|
|
205
|
-
ownerHostId,
|
|
206
|
-
previousOwnerHostIds: legacyHostId === ownerHostId ? [] : [legacyHostId],
|
|
207
|
-
}),
|
|
208
|
-
);
|
|
209
|
-
brokerLockHostIdentityPromises.set(coordinationRoot, pending);
|
|
210
|
-
try {
|
|
211
|
-
return await pending;
|
|
212
|
-
} catch (error) {
|
|
213
|
-
if (brokerLockHostIdentityPromises.get(coordinationRoot) === pending)
|
|
214
|
-
brokerLockHostIdentityPromises.delete(coordinationRoot);
|
|
215
|
-
throw error;
|
|
216
|
-
}
|
|
217
|
-
}
|
|
218
|
-
|
|
219
845
|
async function readBrokerDiscoveryBeforeDeadline(
|
|
220
846
|
agentDir: string,
|
|
221
847
|
heartbeatTtlMs: number | undefined,
|
|
@@ -268,17 +894,30 @@ async function acquireSpawnLock(agentDir: string, options: SpawnLockOptions = {}
|
|
|
268
894
|
export async function withBrokerStartupLock<T>(
|
|
269
895
|
agentDir: string,
|
|
270
896
|
operation: (deadline: number) => Promise<T>,
|
|
271
|
-
|
|
897
|
+
options: BrokerStartupLockOptions = {},
|
|
272
898
|
): Promise<T> {
|
|
899
|
+
if (options.deadlineAt !== undefined && !Number.isFinite(options.deadlineAt))
|
|
900
|
+
throw new TypeError("Broker startup-lock deadline must be finite.");
|
|
273
901
|
const hostIdentity = await brokerLockHostIdentity(agentDir);
|
|
902
|
+
const now = ensureBrokerTiming.now();
|
|
903
|
+
const lockWaitMs =
|
|
904
|
+
options.deadlineAt === undefined
|
|
905
|
+
? STARTUP_LOCK_WAIT_MS
|
|
906
|
+
: Math.max(0, Math.min(STARTUP_LOCK_WAIT_MS, options.deadlineAt - now));
|
|
274
907
|
return withFileLock(
|
|
275
908
|
path.join(agentDir, "sdk", STARTUP_LOCK_TARGET_NAME),
|
|
276
|
-
() =>
|
|
909
|
+
() => {
|
|
910
|
+
const operationDeadline = ensureBrokerTiming.now() + BROKER_DISCOVERY_BUDGET.fenceOperationMs;
|
|
911
|
+
return operation(
|
|
912
|
+
options.deadlineAt === undefined ? operationDeadline : Math.min(operationDeadline, options.deadlineAt),
|
|
913
|
+
);
|
|
914
|
+
},
|
|
277
915
|
{
|
|
278
|
-
retries: Math.ceil(
|
|
916
|
+
retries: Math.ceil(lockWaitMs / SPAWN_LOCK_RETRY_DELAY_MS),
|
|
279
917
|
retryDelayMs: SPAWN_LOCK_RETRY_DELAY_MS,
|
|
280
|
-
|
|
281
|
-
|
|
918
|
+
orphanTransitionAgeMs: FILE_LOCK_ORPHAN_TRANSITION_ADOPTION_AGE_MS,
|
|
919
|
+
onAcquired: options.onAcquired,
|
|
920
|
+
onContended: options.onContended,
|
|
282
921
|
...hostIdentity,
|
|
283
922
|
},
|
|
284
923
|
);
|
|
@@ -371,8 +1010,7 @@ export interface StartedFixtureBroker {
|
|
|
371
1010
|
interface BrokerOwner {
|
|
372
1011
|
stop(): Promise<void>;
|
|
373
1012
|
canReuse(discovery: BrokerDiscovery | null): boolean;
|
|
374
|
-
markReady(discovery: BrokerDiscovery): boolean
|
|
375
|
-
setTarget(pid: number, incarnation: string): void;
|
|
1013
|
+
markReady(discovery: BrokerDiscovery): Promise<boolean>;
|
|
376
1014
|
}
|
|
377
1015
|
type EnsureInitiator = "discovery" | "fixture-lease";
|
|
378
1016
|
type EnsureOutcome =
|
|
@@ -392,6 +1030,7 @@ interface ReapTiming {
|
|
|
392
1030
|
gracefulMs: number;
|
|
393
1031
|
killVerifyMs: number;
|
|
394
1032
|
}
|
|
1033
|
+
type DetachedBrokerSignal = (pid: number, brokerIncarnation: string, signal: NodeJS.Signals) => boolean;
|
|
395
1034
|
const DEFAULT_REAP_TIMING: ReapTiming = {
|
|
396
1035
|
gracefulMs: REAP_GRACEFUL_MS,
|
|
397
1036
|
killVerifyMs: REAP_SIGKILL_CAP_MS,
|
|
@@ -408,12 +1047,25 @@ const DEFAULT_REAP_TIMING: ReapTiming = {
|
|
|
408
1047
|
* teardown (e.g. a transient signal-delivery failure); that is diagnostic only
|
|
409
1048
|
* and never counts as exit, so the escalation cannot be skipped mid-shutdown.
|
|
410
1049
|
*/
|
|
411
|
-
async function reapSpawnedBroker(
|
|
1050
|
+
async function reapSpawnedBroker(
|
|
1051
|
+
child: ChildProcess,
|
|
1052
|
+
realBrokerPid?: number,
|
|
1053
|
+
brokerIncarnation?: string,
|
|
1054
|
+
timing: ReapTiming = DEFAULT_REAP_TIMING,
|
|
1055
|
+
): Promise<void> {
|
|
1056
|
+
// `child` is the actual broker only for direct fixture launches; hop and
|
|
1057
|
+
// trampoline launches keep the broker identity separate from the launcher.
|
|
1058
|
+
const pidToReap = realBrokerPid ?? child.pid;
|
|
412
1059
|
// A spawn failure (e.g. ENOENT) never created a kernel process: pid is
|
|
413
1060
|
// undefined and there is nothing to signal or await. The `error` event is the
|
|
414
1061
|
// only signal and is diagnostic here — termination trivially holds, so do not
|
|
415
1062
|
// run out the TERM/KILL windows or report a stuck child that never existed.
|
|
416
|
-
if (
|
|
1063
|
+
if (pidToReap === undefined) return;
|
|
1064
|
+
if (pidToReap !== child.pid) {
|
|
1065
|
+
// The retained ChildProcess is a hop/trampoline, not the broker itself.
|
|
1066
|
+
await reapDetachedBrokerPid(pidToReap, brokerIncarnation, timing);
|
|
1067
|
+
return;
|
|
1068
|
+
}
|
|
417
1069
|
// Reaping owns repeated teardown diagnostics too. Keep exactly one error
|
|
418
1070
|
// listener for the retained child so a later signal-delivery error cannot
|
|
419
1071
|
// become an unhandled EventEmitter error after the spawn listener is consumed.
|
|
@@ -454,81 +1106,196 @@ async function reapSpawnedBroker(child: ChildProcess, timing: ReapTiming = DEFAU
|
|
|
454
1106
|
if (hasExited()) return;
|
|
455
1107
|
// SIGKILL is uninterruptible; a child still alive past this bounded wait is a
|
|
456
1108
|
// kernel-level stuck state. Surface it rather than silently orphaning the spawn.
|
|
457
|
-
throw new Error(`Detached SDK broker (pid ${
|
|
1109
|
+
throw new Error(`Detached SDK broker (pid ${pidToReap}) did not exit after SIGKILL during reap.`);
|
|
1110
|
+
}
|
|
1111
|
+
|
|
1112
|
+
/**
|
|
1113
|
+
* Reap a broker launched by a short-lived hop or trampoline. The launcher has
|
|
1114
|
+
* already exited, so its ChildProcess carries no signal to await: the real broker
|
|
1115
|
+
* is targeted by the reported pid, and every poll is fenced by incarnation.
|
|
1116
|
+
* Signals use a pinned OS process handle; unsupported platforms fail closed.
|
|
1117
|
+
*/
|
|
1118
|
+
interface PinnedBrokerProcess {
|
|
1119
|
+
readonly incarnation: string;
|
|
1120
|
+
signalRoot(signal: number): boolean;
|
|
1121
|
+
}
|
|
1122
|
+
|
|
1123
|
+
function signalPinnedBrokerProcess(
|
|
1124
|
+
reference: PinnedBrokerProcess | null,
|
|
1125
|
+
expectedIncarnation: string,
|
|
1126
|
+
signal: NodeJS.Signals,
|
|
1127
|
+
): boolean {
|
|
1128
|
+
if (!reference || reference.incarnation !== expectedIncarnation) return false;
|
|
1129
|
+
const signalNumber = os.constants.signals[signal];
|
|
1130
|
+
return signalNumber !== undefined && reference.signalRoot(signalNumber);
|
|
1131
|
+
}
|
|
1132
|
+
|
|
1133
|
+
function signalDetachedBrokerProcess(
|
|
1134
|
+
pid: number,
|
|
1135
|
+
brokerIncarnation: string,
|
|
1136
|
+
signal: NodeJS.Signals,
|
|
1137
|
+
platform: NodeJS.Platform = process.platform,
|
|
1138
|
+
): boolean {
|
|
1139
|
+
// Darwin has no atomic identity-bound signal primitive. A separate
|
|
1140
|
+
// incarnation check cannot close the PID-reuse window before process.kill.
|
|
1141
|
+
if (platform === "darwin") return false;
|
|
1142
|
+
const reference = nativeProcessBindings().Process.fromPid(pid);
|
|
1143
|
+
return signalPinnedBrokerProcess(reference, brokerIncarnation, signal);
|
|
458
1144
|
}
|
|
459
1145
|
|
|
460
|
-
async function
|
|
1146
|
+
async function reapDetachedBrokerPid(
|
|
461
1147
|
pid: number,
|
|
462
|
-
|
|
1148
|
+
brokerIncarnation?: string,
|
|
463
1149
|
timing: ReapTiming = DEFAULT_REAP_TIMING,
|
|
1150
|
+
observe: (pid: number) => ProcessIncarnationObservation = observeProcessIncarnation,
|
|
1151
|
+
signalProcess: DetachedBrokerSignal = signalDetachedBrokerProcess,
|
|
464
1152
|
): Promise<void> {
|
|
465
|
-
const
|
|
466
|
-
const
|
|
467
|
-
|
|
1153
|
+
const inspect = (): "owned" | "gone" | "unknown" => {
|
|
1154
|
+
const observation = observe(pid);
|
|
1155
|
+
if (observation.status === "absent") return "gone";
|
|
1156
|
+
if (observation.status !== "present" || !brokerIncarnation) return "unknown";
|
|
1157
|
+
return observation.incarnation === brokerIncarnation ? "owned" : "gone";
|
|
468
1158
|
};
|
|
469
|
-
const
|
|
470
|
-
|
|
471
|
-
|
|
1159
|
+
const awaitGone = async (windowMs: number): Promise<boolean> => {
|
|
1160
|
+
const deadline = Date.now() + windowMs;
|
|
1161
|
+
for (;;) {
|
|
1162
|
+
const state = inspect();
|
|
1163
|
+
if (state === "gone") return true;
|
|
1164
|
+
if (Date.now() >= deadline) return false;
|
|
1165
|
+
await sleep(Math.min(20, Math.max(1, deadline - Date.now())));
|
|
1166
|
+
}
|
|
1167
|
+
};
|
|
1168
|
+
const signal = (sig: NodeJS.Signals): boolean => {
|
|
1169
|
+
const state = inspect();
|
|
1170
|
+
if (state === "gone") return false;
|
|
1171
|
+
if (state === "unknown") {
|
|
1172
|
+
throw new Error(`Detached SDK broker (pid ${pid}) cannot be signaled without a verified process incarnation.`);
|
|
1173
|
+
}
|
|
1174
|
+
if (!brokerIncarnation) {
|
|
1175
|
+
throw new Error(`Detached SDK broker (pid ${pid}) cannot be signaled without a verified process incarnation.`);
|
|
1176
|
+
}
|
|
1177
|
+
let delivered = false;
|
|
472
1178
|
try {
|
|
473
|
-
|
|
1179
|
+
delivered = signalProcess(pid, brokerIncarnation, sig);
|
|
474
1180
|
} catch {
|
|
475
|
-
//
|
|
1181
|
+
// Re-inspect below; only a confirmed gone/replaced process settles cleanup.
|
|
476
1182
|
}
|
|
1183
|
+
if (!delivered && inspect() !== "gone")
|
|
1184
|
+
throw new Error(`Detached SDK broker (pid ${pid}) could not be signaled under its pinned incarnation.`);
|
|
1185
|
+
return true;
|
|
477
1186
|
};
|
|
478
|
-
|
|
479
|
-
|
|
480
|
-
|
|
481
|
-
|
|
482
|
-
|
|
483
|
-
signal("
|
|
484
|
-
|
|
485
|
-
if (!
|
|
1187
|
+
const beforeTermination = inspect();
|
|
1188
|
+
if (beforeTermination === "gone") return;
|
|
1189
|
+
if (beforeTermination === "unknown") {
|
|
1190
|
+
throw new Error(`Detached SDK broker (pid ${pid}) cannot be reaped without a verified process incarnation.`);
|
|
1191
|
+
}
|
|
1192
|
+
if (!signal("SIGTERM")) return;
|
|
1193
|
+
if (await awaitGone(timing.gracefulMs)) return;
|
|
1194
|
+
if (!signal("SIGKILL")) return;
|
|
1195
|
+
if (await awaitGone(timing.killVerifyMs)) return;
|
|
1196
|
+
throw new Error(`Detached SDK broker (pid ${pid}) did not exit after SIGKILL during reap.`);
|
|
486
1197
|
}
|
|
487
1198
|
|
|
488
|
-
|
|
489
|
-
|
|
1199
|
+
/** Reap a failed launch through its pending trampoline lease or retained broker handle. */
|
|
1200
|
+
export async function reapFailedBrokerLaunch(
|
|
1201
|
+
launch: Pick<BrokerLaunchResult, "process" | "realBrokerPid" | "realBrokerIncarnation" | "handoff">,
|
|
1202
|
+
): Promise<void> {
|
|
1203
|
+
if (launch.handoff) {
|
|
1204
|
+
await launch.handoff.terminate();
|
|
1205
|
+
return;
|
|
1206
|
+
}
|
|
1207
|
+
if (launch.realBrokerPid === undefined) return;
|
|
1208
|
+
if (launch.realBrokerPid === launch.process.pid) {
|
|
1209
|
+
await reapSpawnedBroker(launch.process, launch.realBrokerPid, launch.realBrokerIncarnation);
|
|
1210
|
+
return;
|
|
1211
|
+
}
|
|
1212
|
+
await reapDetachedBrokerPid(launch.realBrokerPid, launch.realBrokerIncarnation);
|
|
1213
|
+
}
|
|
1214
|
+
|
|
1215
|
+
/** Reap the direct broker child held by the startup trampoline before publication. */
|
|
1216
|
+
export async function reapBrokerTrampolineChild(
|
|
1217
|
+
child: ChildProcess,
|
|
1218
|
+
brokerPid: number | undefined,
|
|
1219
|
+
brokerIncarnation?: string,
|
|
1220
|
+
): Promise<void> {
|
|
1221
|
+
await reapSpawnedBroker(child, brokerPid, brokerIncarnation);
|
|
490
1222
|
}
|
|
491
1223
|
|
|
492
1224
|
function registerBrokerOwner(
|
|
493
1225
|
agentDir: string,
|
|
494
1226
|
child: ChildProcess,
|
|
1227
|
+
realBrokerPid: number | undefined,
|
|
1228
|
+
brokerIncarnation: string | undefined,
|
|
495
1229
|
timing: ReapTiming = DEFAULT_REAP_TIMING,
|
|
1230
|
+
launchHandoff?: BrokerLaunchHandoff,
|
|
496
1231
|
): BrokerOwner {
|
|
497
|
-
|
|
498
|
-
|
|
1232
|
+
// Windows retains the hop process handle; POSIX retains the trampoline until publication
|
|
1233
|
+
// when requested. `realBrokerPid` and incarnation always identify the broker itself.
|
|
1234
|
+
const expectedBrokerPid = realBrokerPid ?? child.pid;
|
|
1235
|
+
const expectedBrokerIncarnation = brokerIncarnation;
|
|
1236
|
+
let handoff = launchHandoff;
|
|
1237
|
+
let publishedDiscovery: BrokerDiscovery | undefined;
|
|
499
1238
|
let state: "starting" | "ready" | "cleanup-unverified" = "starting";
|
|
500
|
-
const matches = (discovery: BrokerDiscovery | null): boolean =>
|
|
501
|
-
|
|
502
|
-
|
|
503
|
-
|
|
504
|
-
|
|
505
|
-
|
|
506
|
-
discovery.
|
|
1239
|
+
const matches = (discovery: BrokerDiscovery | null): boolean => {
|
|
1240
|
+
if (!discovery) return false;
|
|
1241
|
+
// Match discovery to the exact broker identity captured by the launch protocol.
|
|
1242
|
+
return Boolean(
|
|
1243
|
+
expectedBrokerPid !== undefined &&
|
|
1244
|
+
expectedBrokerIncarnation &&
|
|
1245
|
+
discovery.pid === expectedBrokerPid &&
|
|
1246
|
+
discovery.incarnation === expectedBrokerIncarnation,
|
|
507
1247
|
);
|
|
508
|
-
|
|
509
|
-
|
|
1248
|
+
};
|
|
1249
|
+
let stopTask: Promise<void> | undefined;
|
|
1250
|
+
const stop = (): Promise<void> => {
|
|
1251
|
+
if (stopTask) return stopTask;
|
|
1252
|
+
const task = (async () => {
|
|
510
1253
|
try {
|
|
511
|
-
if (
|
|
512
|
-
await
|
|
513
|
-
|
|
1254
|
+
if (handoff) {
|
|
1255
|
+
await handoff.terminate();
|
|
1256
|
+
handoff = undefined;
|
|
1257
|
+
} else if (publishedDiscovery) {
|
|
1258
|
+
try {
|
|
1259
|
+
await shutdownBrokerViaRpc(
|
|
1260
|
+
publishedDiscovery,
|
|
1261
|
+
Date.now() +
|
|
1262
|
+
Math.max(BROKER_DISCOVERY_BUDGET.staleRetirementMs, timing.gracefulMs + timing.killVerifyMs),
|
|
1263
|
+
agentDir,
|
|
1264
|
+
);
|
|
1265
|
+
} catch (error) {
|
|
1266
|
+
if (process.platform === "darwin" && expectedBrokerPid !== child.pid) throw error;
|
|
1267
|
+
await reapSpawnedBroker(child, expectedBrokerPid, expectedBrokerIncarnation, timing);
|
|
1268
|
+
}
|
|
1269
|
+
} else {
|
|
1270
|
+
await reapSpawnedBroker(child, expectedBrokerPid, expectedBrokerIncarnation, timing);
|
|
1271
|
+
}
|
|
514
1272
|
} catch (error) {
|
|
515
1273
|
state = "cleanup-unverified";
|
|
516
1274
|
throw error;
|
|
517
1275
|
}
|
|
518
1276
|
if (owners.get(agentDir) === owner) owners.delete(agentDir);
|
|
519
|
-
}
|
|
1277
|
+
})();
|
|
1278
|
+
stopTask = task;
|
|
1279
|
+
void task.catch(() => {
|
|
1280
|
+
if (stopTask === task) stopTask = undefined;
|
|
1281
|
+
});
|
|
1282
|
+
return task;
|
|
1283
|
+
};
|
|
1284
|
+
const owner: BrokerOwner = {
|
|
1285
|
+
stop,
|
|
520
1286
|
canReuse(discovery): boolean {
|
|
521
1287
|
return state === "ready" && matches(discovery);
|
|
522
1288
|
},
|
|
523
|
-
markReady(discovery): boolean {
|
|
1289
|
+
async markReady(discovery): Promise<boolean> {
|
|
524
1290
|
if (!matches(discovery)) return false;
|
|
1291
|
+
if (handoff) {
|
|
1292
|
+
await handoff.release();
|
|
1293
|
+
handoff = undefined;
|
|
1294
|
+
}
|
|
1295
|
+
publishedDiscovery = discovery;
|
|
525
1296
|
state = "ready";
|
|
526
1297
|
return true;
|
|
527
1298
|
},
|
|
528
|
-
setTarget(pid, incarnation): void {
|
|
529
|
-
targetPid = pid;
|
|
530
|
-
targetIncarnation = incarnation;
|
|
531
|
-
},
|
|
532
1299
|
};
|
|
533
1300
|
owners.set(agentDir, owner);
|
|
534
1301
|
return owner;
|
|
@@ -556,6 +1323,7 @@ function brokerSpawnEnvironment(command: SdkInternalSpawnCommand, override?: Nod
|
|
|
556
1323
|
delete environment.PI_COMPILED;
|
|
557
1324
|
delete environment.GJC_COMPILED;
|
|
558
1325
|
}
|
|
1326
|
+
scrubUntrustedBrokerTestSignalDirectory(environment);
|
|
559
1327
|
return environment;
|
|
560
1328
|
}
|
|
561
1329
|
|
|
@@ -585,39 +1353,113 @@ export async function closeBrokerClientBeforeDeadline(
|
|
|
585
1353
|
await Promise.race([close, ensureBrokerTiming.sleep(remainingMs)]);
|
|
586
1354
|
}
|
|
587
1355
|
|
|
1356
|
+
function brokerIdentityIsGone(discovery: BrokerDiscovery): boolean {
|
|
1357
|
+
const observation = observeProcessIncarnation(discovery.pid);
|
|
1358
|
+
return (
|
|
1359
|
+
observation.status === "absent" ||
|
|
1360
|
+
(observation.status === "present" && observation.incarnation !== discovery.incarnation)
|
|
1361
|
+
);
|
|
1362
|
+
}
|
|
1363
|
+
|
|
1364
|
+
async function waitForBrokerIdentityGone(discovery: BrokerDiscovery, deadline: number): Promise<void> {
|
|
1365
|
+
for (;;) {
|
|
1366
|
+
if (brokerIdentityIsGone(discovery)) return;
|
|
1367
|
+
const remainingMs = deadline - ensureBrokerTiming.now();
|
|
1368
|
+
if (remainingMs <= 0) throw new Error(`SDK broker ${discovery.pid} did not exit after authenticated shutdown.`);
|
|
1369
|
+
await ensureBrokerTiming.sleep(Math.min(STALE_BROKER_POLL_MS, remainingMs));
|
|
1370
|
+
}
|
|
1371
|
+
}
|
|
1372
|
+
|
|
1373
|
+
async function waitForBrokerDiscoveryOwnerGone(
|
|
1374
|
+
agentDir: string,
|
|
1375
|
+
discovery: BrokerDiscovery,
|
|
1376
|
+
deadline: number,
|
|
1377
|
+
): Promise<void> {
|
|
1378
|
+
for (;;) {
|
|
1379
|
+
// Heartbeat expiry is not proof that this owner removed its publication.
|
|
1380
|
+
const current = await readBrokerDiscoveryBeforeDeadline(agentDir, Number.POSITIVE_INFINITY, deadline);
|
|
1381
|
+
if (!current || !sameBrokerOwner(current, discovery)) return;
|
|
1382
|
+
const remainingMs = deadline - ensureBrokerTiming.now();
|
|
1383
|
+
if (remainingMs <= 0) throw new Error(`SDK broker ${discovery.pid} retained discovery during retirement.`);
|
|
1384
|
+
await ensureBrokerTiming.sleep(Math.min(STALE_BROKER_POLL_MS, remainingMs));
|
|
1385
|
+
}
|
|
1386
|
+
}
|
|
1387
|
+
|
|
1388
|
+
async function shutdownPublishedBrokerViaRpc(
|
|
1389
|
+
agentDir: string,
|
|
1390
|
+
discovery: BrokerDiscovery,
|
|
1391
|
+
deadline: number,
|
|
1392
|
+
): Promise<void> {
|
|
1393
|
+
if (brokerIdentityIsGone(discovery)) return;
|
|
1394
|
+
const current = await readBrokerDiscovery(agentDir);
|
|
1395
|
+
if (!current || !sameBrokerOwner(current, discovery)) {
|
|
1396
|
+
if (discovery.pid === process.pid) await waitForBrokerDiscoveryOwnerGone(agentDir, discovery, deadline);
|
|
1397
|
+
else await waitForBrokerIdentityGone(discovery, deadline);
|
|
1398
|
+
return;
|
|
1399
|
+
}
|
|
1400
|
+
await shutdownBrokerViaRpc(current, deadline, agentDir);
|
|
1401
|
+
}
|
|
1402
|
+
|
|
1403
|
+
/** Stop a broker whose authenticated discovery was retained by its local owner. */
|
|
1404
|
+
async function shutdownBrokerViaRpc(discovery: BrokerDiscovery, deadline: number, agentDir: string): Promise<void> {
|
|
1405
|
+
if (brokerIdentityIsGone(discovery)) return;
|
|
1406
|
+
const remainingMs = deadline - ensureBrokerTiming.now();
|
|
1407
|
+
if (remainingMs <= 0) throw new Error("SDK broker shutdown deadline elapsed before RPC connection.");
|
|
1408
|
+
const client = await SdkClient.connect(discovery.url, discovery.token, {
|
|
1409
|
+
timeoutMs: Math.max(1, Math.min(2_000, remainingMs)),
|
|
1410
|
+
});
|
|
1411
|
+
try {
|
|
1412
|
+
await client.global("broker.shutdown", {}, { timeoutMs: Math.max(1, deadline - ensureBrokerTiming.now()) });
|
|
1413
|
+
} finally {
|
|
1414
|
+
await closeBrokerClientBeforeDeadline(client, deadline).catch(() => undefined);
|
|
1415
|
+
}
|
|
1416
|
+
if (discovery.pid === process.pid) await waitForBrokerDiscoveryOwnerGone(agentDir, discovery, deadline);
|
|
1417
|
+
else await waitForBrokerIdentityGone(discovery, deadline);
|
|
1418
|
+
}
|
|
1419
|
+
|
|
588
1420
|
async function retireUnusableBroker(
|
|
589
1421
|
stale: BrokerDiscovery,
|
|
590
1422
|
settings: EnsureBrokerSettings,
|
|
591
1423
|
outerDeadline = Number.POSITIVE_INFINITY,
|
|
592
1424
|
): Promise<void> {
|
|
593
1425
|
const deadline = Math.min(outerDeadline, ensureBrokerTiming.now() + BROKER_DISCOVERY_BUDGET.staleRetirementMs);
|
|
594
|
-
|
|
595
|
-
|
|
596
|
-
|
|
597
|
-
|
|
598
|
-
|
|
599
|
-
try {
|
|
600
|
-
await client.global("broker.shutdown", {});
|
|
601
|
-
shutdownSucceeded = true;
|
|
602
|
-
} finally {
|
|
603
|
-
await closeBrokerClientBeforeDeadline(client, deadline).catch(() => undefined);
|
|
1426
|
+
const current = await readBrokerDiscoveryBeforeDeadline(settings.agentDir, settings.heartbeatTtlMs, deadline);
|
|
1427
|
+
if (!current || !sameBrokerOwner(current, stale)) {
|
|
1428
|
+
if (stale.pid === process.pid) {
|
|
1429
|
+
await waitForBrokerDiscoveryOwnerGone(settings.agentDir, stale, deadline);
|
|
1430
|
+
return;
|
|
604
1431
|
}
|
|
605
|
-
|
|
606
|
-
|
|
607
|
-
|
|
608
|
-
if (brokerProcessIncarnation(stale.pid) === stale.incarnation) {
|
|
609
|
-
try {
|
|
610
|
-
process.kill(stale.pid, "SIGTERM");
|
|
611
|
-
} catch {}
|
|
612
|
-
}
|
|
613
|
-
} catch {}
|
|
1432
|
+
await reapDetachedBrokerPid(stale.pid, stale.incarnation);
|
|
1433
|
+
await waitForBrokerIdentityGone(stale, deadline);
|
|
1434
|
+
return;
|
|
614
1435
|
}
|
|
615
|
-
|
|
616
|
-
|
|
617
|
-
|
|
618
|
-
|
|
619
|
-
|
|
1436
|
+
try {
|
|
1437
|
+
await shutdownPublishedBrokerViaRpc(settings.agentDir, current, deadline);
|
|
1438
|
+
return;
|
|
1439
|
+
} catch (rpcError) {
|
|
1440
|
+
if (stale.pid !== process.pid) {
|
|
1441
|
+
try {
|
|
1442
|
+
await reapDetachedBrokerPid(stale.pid, stale.incarnation);
|
|
1443
|
+
} catch (cleanupError) {
|
|
1444
|
+
throw new AggregateError(
|
|
1445
|
+
[rpcError, cleanupError],
|
|
1446
|
+
`SDK broker ${stale.pid} shutdown and identity-bound cleanup both failed.`,
|
|
1447
|
+
);
|
|
1448
|
+
}
|
|
1449
|
+
} else {
|
|
1450
|
+
try {
|
|
1451
|
+
await waitForBrokerDiscoveryOwnerGone(settings.agentDir, stale, deadline);
|
|
1452
|
+
} catch (retirementError) {
|
|
1453
|
+
throw new AggregateError(
|
|
1454
|
+
[rpcError, retirementError],
|
|
1455
|
+
`SDK broker ${stale.pid} shutdown RPC and discovery retirement both failed.`,
|
|
1456
|
+
);
|
|
1457
|
+
}
|
|
1458
|
+
return;
|
|
1459
|
+
}
|
|
1460
|
+
// If pid === process.pid, fall through to wait on identity
|
|
620
1461
|
}
|
|
1462
|
+
await waitForBrokerIdentityGone(stale, deadline);
|
|
621
1463
|
}
|
|
622
1464
|
|
|
623
1465
|
/** Reconcile an observed broker generation before entering a startup critical section. */
|
|
@@ -675,7 +1517,11 @@ function createFixtureLease(owner: BrokerOwner, child: ChildProcess): ExactFixtu
|
|
|
675
1517
|
return createFixtureLeaseFromChild(child, () => owner.stop());
|
|
676
1518
|
}
|
|
677
1519
|
|
|
678
|
-
async function ensureBrokerOnce(
|
|
1520
|
+
async function ensureBrokerOnce(
|
|
1521
|
+
settings: EnsureBrokerSettings,
|
|
1522
|
+
initiator: EnsureInitiator,
|
|
1523
|
+
retriedStartupLockUnavailable = false,
|
|
1524
|
+
): Promise<EnsureOutcome> {
|
|
679
1525
|
const initialDiscoveryDeadline =
|
|
680
1526
|
ensureBrokerTiming.now() + (initiator === "fixture-lease" ? FIXTURE_DISCOVERY_TIMEOUT_MS : DISCOVERY_TIMEOUT_MS);
|
|
681
1527
|
const priorOwner = owners.get(settings.agentDir);
|
|
@@ -716,6 +1562,7 @@ async function ensureBrokerOnce(settings: EnsureBrokerSettings, initiator: Ensur
|
|
|
716
1562
|
// Fixture leases always spawn their own.
|
|
717
1563
|
let spawnLog: BrokerSpawnLog | undefined;
|
|
718
1564
|
const releaseSpawnLock = initiator === "fixture-lease" ? async () => {} : await acquireSpawnLock(settings.agentDir);
|
|
1565
|
+
let spawnLockReleased = false;
|
|
719
1566
|
try {
|
|
720
1567
|
const deadline =
|
|
721
1568
|
ensureBrokerTiming.now() +
|
|
@@ -731,176 +1578,200 @@ async function ensureBrokerOnce(settings: EnsureBrokerSettings, initiator: Ensur
|
|
|
731
1578
|
// that finished between our first read and the lock acquisition.
|
|
732
1579
|
const discoveredUnderLock = await reconcileBrokerGenerationForStartup(settings, deadline);
|
|
733
1580
|
if (discoveredUnderLock) return { kind: "external-discovery", discovery: discoveredUnderLock };
|
|
734
|
-
const command = resolveSdkInternalSpawnCommand(
|
|
735
|
-
process.platform === "win32" || initiator === "fixture-lease"
|
|
736
|
-
? "broker-internal"
|
|
737
|
-
: "broker-trampoline-internal",
|
|
738
|
-
);
|
|
1581
|
+
const command = resolveSdkInternalSpawnCommand("broker-internal");
|
|
739
1582
|
spawnLog = await openBrokerSpawnLog(settings.agentDir);
|
|
740
1583
|
// A stale marker must never be misattributed to this spawn; clear it first.
|
|
741
1584
|
await clearBrokerStartupExitRecord(settings.agentDir);
|
|
742
1585
|
await clearBrokerStartupFailureMarker(settings.agentDir);
|
|
743
1586
|
const childSpawnedAt = Date.now();
|
|
744
|
-
const
|
|
745
|
-
|
|
746
|
-
|
|
747
|
-
env: brokerSpawnEnvironment(command, settings.env),
|
|
748
|
-
...(command.kind === "bun-source" ? { cwd: command.cwd } : {}),
|
|
749
|
-
});
|
|
750
|
-
const isTrampoline = process.platform !== "win32" && initiator !== "fixture-lease";
|
|
1587
|
+
const brokerArgs = [...command.args, "--agent-dir", settings.agentDir];
|
|
1588
|
+
const env = brokerSpawnEnvironment(command, settings.env);
|
|
1589
|
+
let spawnResult: BrokerSpawnResult;
|
|
751
1590
|
let spawnError: Error | undefined;
|
|
752
|
-
|
|
753
|
-
|
|
754
|
-
|
|
755
|
-
|
|
756
|
-
|
|
757
|
-
|
|
758
|
-
|
|
759
|
-
|
|
760
|
-
|
|
761
|
-
|
|
762
|
-
|
|
763
|
-
|
|
764
|
-
|
|
765
|
-
|
|
766
|
-
|
|
767
|
-
|
|
768
|
-
|
|
769
|
-
|
|
770
|
-
|
|
771
|
-
|
|
772
|
-
|
|
773
|
-
|
|
774
|
-
|
|
775
|
-
|
|
776
|
-
|
|
777
|
-
|
|
778
|
-
|
|
779
|
-
|
|
780
|
-
|
|
781
|
-
|
|
782
|
-
|
|
783
|
-
|
|
784
|
-
|
|
785
|
-
|
|
1591
|
+
const launchMode = resolveBrokerLaunchMode(process.platform, initiator);
|
|
1592
|
+
if (launchMode === "windows-hop") {
|
|
1593
|
+
const launched = await launchBrokerViaHop(
|
|
1594
|
+
{
|
|
1595
|
+
command: { file: command.file, args: brokerArgs },
|
|
1596
|
+
...(command.kind === "bun-source" ? { cwd: command.cwd } : {}),
|
|
1597
|
+
...(spawnLog ? { stderrLogPath: spawnLog.path } : {}),
|
|
1598
|
+
},
|
|
1599
|
+
{
|
|
1600
|
+
env,
|
|
1601
|
+
cwd: command.kind === "bun-source" ? command.cwd : undefined,
|
|
1602
|
+
timeoutMs: Math.max(0, deadline - ensureBrokerTiming.now()),
|
|
1603
|
+
},
|
|
1604
|
+
);
|
|
1605
|
+
spawnResult = {
|
|
1606
|
+
process: launched.process,
|
|
1607
|
+
realBrokerPid: launched.realBrokerPid,
|
|
1608
|
+
realBrokerIncarnation: launched.realBrokerIncarnation,
|
|
1609
|
+
};
|
|
1610
|
+
spawnError = launched.error;
|
|
1611
|
+
} else if (launchMode === "posix-trampoline" || launchMode === "darwin-trampoline") {
|
|
1612
|
+
const launched = await launchBrokerViaPosixTrampoline(settings.agentDir, {
|
|
1613
|
+
env,
|
|
1614
|
+
...(command.kind === "bun-source" ? { cwd: command.cwd } : {}),
|
|
1615
|
+
...(spawnLog ? { stderrFd: spawnLog.handle.fd } : {}),
|
|
1616
|
+
timeoutMs: Math.max(0, deadline - ensureBrokerTiming.now()),
|
|
1617
|
+
retainChildUntilPublication: launchMode === "darwin-trampoline",
|
|
1618
|
+
});
|
|
1619
|
+
spawnResult = {
|
|
1620
|
+
process: launched.process,
|
|
1621
|
+
realBrokerPid: launched.realBrokerPid,
|
|
1622
|
+
realBrokerIncarnation: launched.realBrokerIncarnation,
|
|
1623
|
+
handoff: launched.handoff,
|
|
1624
|
+
};
|
|
1625
|
+
spawnError = launched.error;
|
|
1626
|
+
} else {
|
|
1627
|
+
spawnResult = spawnBrokerDirect(command.file, brokerArgs, {
|
|
1628
|
+
stdioFd: spawnLog?.handle.fd,
|
|
1629
|
+
env,
|
|
1630
|
+
cwd: command.kind === "bun-source" ? command.cwd : undefined,
|
|
1631
|
+
detached: process.platform !== "win32",
|
|
1632
|
+
});
|
|
1633
|
+
spawnResult.process.once("error", error => {
|
|
1634
|
+
spawnError = error;
|
|
786
1635
|
});
|
|
787
1636
|
}
|
|
788
|
-
|
|
1637
|
+
|
|
1638
|
+
const child = spawnResult.process;
|
|
1639
|
+
const realBrokerPid = spawnResult.realBrokerPid;
|
|
1640
|
+
let childIncarnation =
|
|
1641
|
+
spawnResult.realBrokerIncarnation ??
|
|
1642
|
+
(launchMode === "direct" && realBrokerPid !== undefined ? brokerProcessIncarnation(realBrokerPid) : undefined);
|
|
1643
|
+
let owner = registerBrokerOwner(
|
|
1644
|
+
settings.agentDir,
|
|
1645
|
+
child,
|
|
1646
|
+
realBrokerPid,
|
|
1647
|
+
childIncarnation,
|
|
1648
|
+
DEFAULT_REAP_TIMING,
|
|
1649
|
+
spawnResult.handoff,
|
|
1650
|
+
);
|
|
1651
|
+
if (!spawnResult.handoff) child.unref();
|
|
789
1652
|
// The child holds its own duplicate of the descriptor. Failure to close the
|
|
790
1653
|
// parent's diagnostic handle must not discard exact ownership of a live child.
|
|
791
1654
|
await spawnLog?.handle.close().catch(() => undefined);
|
|
792
1655
|
let discoveryError: unknown;
|
|
793
|
-
|
|
794
|
-
|
|
795
|
-
spawnError ||
|
|
796
|
-
(isTrampoline && childExited && (!trampolineReported || childExitCode !== 0 || childSignalCode !== null)) ||
|
|
797
|
-
(!isTrampoline && process.platform === "win32" && (child.exitCode !== null || child.signalCode !== null))
|
|
798
|
-
)
|
|
799
|
-
break;
|
|
800
|
-
if (
|
|
801
|
-
isTrampoline &&
|
|
802
|
-
trampolineReported &&
|
|
803
|
-
spawnedBrokerPid !== undefined &&
|
|
804
|
-
spawnedBrokerIncarnation !== undefined
|
|
805
|
-
) {
|
|
806
|
-
const observed = observeProcessIncarnation(spawnedBrokerPid);
|
|
807
|
-
if (observed.status === "present" && observed.incarnation !== spawnedBrokerIncarnation) {
|
|
808
|
-
brokerDeathObserved = true;
|
|
809
|
-
break;
|
|
810
|
-
}
|
|
811
|
-
if (observed.status === "absent") {
|
|
812
|
-
brokerDeathObserved = true;
|
|
813
|
-
break;
|
|
814
|
-
}
|
|
815
|
-
}
|
|
816
|
-
// The trampoline's stdout line identifies the real broker. Do not read
|
|
817
|
-
// discovery until that line has been parsed, or a marker from the
|
|
818
|
-
// trampoline could be trusted as the broker's own startup evidence.
|
|
819
|
-
if (isTrampoline && !trampolineReported) {
|
|
820
|
-
await ensureBrokerTiming.sleep(50);
|
|
821
|
-
continue;
|
|
822
|
-
}
|
|
1656
|
+
let brokerExitedBeforeDiscovery = false;
|
|
1657
|
+
const observeDiscovery = async (): Promise<EnsureOutcome | undefined> => {
|
|
823
1658
|
try {
|
|
824
1659
|
const discovered = await readBrokerDiscoveryBeforeDeadline(
|
|
825
1660
|
settings.agentDir,
|
|
826
1661
|
settings.heartbeatTtlMs,
|
|
827
1662
|
deadline,
|
|
828
1663
|
);
|
|
829
|
-
if (discovered)
|
|
830
|
-
|
|
831
|
-
|
|
832
|
-
|
|
833
|
-
|
|
834
|
-
|
|
835
|
-
|
|
836
|
-
|
|
837
|
-
|
|
838
|
-
|
|
839
|
-
|
|
840
|
-
|
|
841
|
-
|
|
842
|
-
|
|
843
|
-
|
|
844
|
-
|
|
845
|
-
|
|
846
|
-
|
|
1664
|
+
if (!discovered || !(await isBrokerReusable(discovered))) return undefined;
|
|
1665
|
+
if (
|
|
1666
|
+
childIncarnation === undefined &&
|
|
1667
|
+
realBrokerPid !== undefined &&
|
|
1668
|
+
realBrokerPid === child.pid &&
|
|
1669
|
+
discovered.pid === realBrokerPid
|
|
1670
|
+
) {
|
|
1671
|
+
const verifiedIncarnation = brokerProcessIncarnation(realBrokerPid);
|
|
1672
|
+
if (verifiedIncarnation === discovered.incarnation) {
|
|
1673
|
+
childIncarnation = verifiedIncarnation;
|
|
1674
|
+
owner = registerBrokerOwner(
|
|
1675
|
+
settings.agentDir,
|
|
1676
|
+
child,
|
|
1677
|
+
realBrokerPid,
|
|
1678
|
+
childIncarnation,
|
|
1679
|
+
DEFAULT_REAP_TIMING,
|
|
1680
|
+
spawnResult.handoff,
|
|
1681
|
+
);
|
|
847
1682
|
}
|
|
848
|
-
if (owner.markReady(discovered)) {
|
|
849
|
-
return initiator === "fixture-lease"
|
|
850
|
-
? { kind: "local-started-fixture", discovery: discovered, owner, child }
|
|
851
|
-
: { kind: "local-started-discovery", discovery: discovered };
|
|
852
|
-
}
|
|
853
|
-
await owner.stop();
|
|
854
|
-
return { kind: "external-discovery", discovery: discovered };
|
|
855
1683
|
}
|
|
1684
|
+
if (await owner.markReady(discovered)) {
|
|
1685
|
+
return initiator === "fixture-lease"
|
|
1686
|
+
? { kind: "local-started-fixture", discovery: discovered, owner, child }
|
|
1687
|
+
: { kind: "local-started-discovery", discovery: discovered };
|
|
1688
|
+
}
|
|
1689
|
+
await owner.stop();
|
|
1690
|
+
return { kind: "external-discovery", discovery: discovered };
|
|
856
1691
|
} catch (error) {
|
|
857
1692
|
discoveryError = error;
|
|
1693
|
+
return undefined;
|
|
858
1694
|
}
|
|
859
|
-
|
|
860
|
-
|
|
861
|
-
|
|
862
|
-
|
|
863
|
-
|
|
864
|
-
|
|
865
|
-
|
|
866
|
-
|
|
867
|
-
|
|
868
|
-
|
|
869
|
-
|
|
870
|
-
|
|
871
|
-
|
|
872
|
-
|
|
873
|
-
|
|
874
|
-
|
|
875
|
-
|
|
876
|
-
|
|
877
|
-
|
|
878
|
-
|
|
879
|
-
|
|
880
|
-
|
|
881
|
-
|
|
882
|
-
|
|
1695
|
+
};
|
|
1696
|
+
while (ensureBrokerTiming.now() < deadline) {
|
|
1697
|
+
const failedSpawn =
|
|
1698
|
+
spawnError || child.signalCode !== null || (child.exitCode !== null && child.exitCode !== 0);
|
|
1699
|
+
if (failedSpawn) {
|
|
1700
|
+
const discovered = await observeDiscovery();
|
|
1701
|
+
if (discovered) return discovered;
|
|
1702
|
+
brokerExitedBeforeDiscovery = true;
|
|
1703
|
+
break;
|
|
1704
|
+
}
|
|
1705
|
+
let brokerObservation: ProcessIncarnationObservation | undefined;
|
|
1706
|
+
if (realBrokerPid !== undefined) {
|
|
1707
|
+
brokerObservation = observeProcessIncarnation(realBrokerPid);
|
|
1708
|
+
if (brokerObservation.status === "absent") {
|
|
1709
|
+
const discovered = await observeDiscovery();
|
|
1710
|
+
if (discovered) return discovered;
|
|
1711
|
+
brokerExitedBeforeDiscovery = true;
|
|
1712
|
+
break;
|
|
1713
|
+
}
|
|
1714
|
+
if (
|
|
1715
|
+
brokerObservation.status === "present" &&
|
|
1716
|
+
childIncarnation !== undefined &&
|
|
1717
|
+
brokerObservation.incarnation !== childIncarnation
|
|
1718
|
+
) {
|
|
1719
|
+
const discovered = await observeDiscovery();
|
|
1720
|
+
if (discovered) return discovered;
|
|
1721
|
+
brokerExitedBeforeDiscovery = true;
|
|
1722
|
+
break;
|
|
1723
|
+
}
|
|
1724
|
+
if (
|
|
1725
|
+
brokerObservation.status === "present" &&
|
|
1726
|
+
childIncarnation === undefined &&
|
|
1727
|
+
realBrokerPid === child.pid
|
|
1728
|
+
) {
|
|
1729
|
+
childIncarnation = brokerObservation.incarnation;
|
|
1730
|
+
owner = registerBrokerOwner(settings.agentDir, child, realBrokerPid, childIncarnation);
|
|
1731
|
+
}
|
|
1732
|
+
const startupExitRecord = await readBrokerStartupExitRecord(settings.agentDir);
|
|
1733
|
+
if (startupExitRecord?.pid === realBrokerPid && startupExitRecord.writtenAt >= childSpawnedAt) {
|
|
1734
|
+
const discovered = await observeDiscovery();
|
|
1735
|
+
if (discovered) return discovered;
|
|
1736
|
+
brokerExitedBeforeDiscovery = true;
|
|
1737
|
+
break;
|
|
1738
|
+
}
|
|
1739
|
+
if (brokerObservation.status === "present" && childIncarnation === brokerObservation.incarnation) {
|
|
1740
|
+
const marker = await readBrokerStartupFailureMarker(settings.agentDir);
|
|
1741
|
+
if (
|
|
1742
|
+
marker?.pid === realBrokerPid &&
|
|
1743
|
+
marker?.incarnation === childIncarnation &&
|
|
1744
|
+
marker.writtenAt >= childSpawnedAt
|
|
1745
|
+
) {
|
|
1746
|
+
const discovered = await observeDiscovery();
|
|
1747
|
+
if (discovered) return discovered;
|
|
1748
|
+
brokerExitedBeforeDiscovery = true;
|
|
1749
|
+
break;
|
|
883
1750
|
}
|
|
884
|
-
await ensureBrokerTiming.sleep(50);
|
|
885
1751
|
}
|
|
886
|
-
} catch {
|
|
887
|
-
// fall through to cleanup + failure
|
|
888
1752
|
}
|
|
1753
|
+
const discovered = await observeDiscovery();
|
|
1754
|
+
if (discovered) return discovered;
|
|
1755
|
+
await ensureBrokerTiming.sleep(50);
|
|
889
1756
|
}
|
|
1757
|
+
const exitedBeforeDiscovery = brokerExitedBeforeDiscovery;
|
|
890
1758
|
const marker = await readBrokerStartupFailureMarker(settings.agentDir);
|
|
891
1759
|
const startupExitRecord = await readBrokerStartupExitRecord(settings.agentDir);
|
|
1760
|
+
// Hops and committed trampolines may exit before the parent finishes polling.
|
|
1761
|
+
// Failure records are attributed to the real broker pid, not the launcher.
|
|
892
1762
|
const trustedMarker =
|
|
893
1763
|
marker &&
|
|
894
|
-
|
|
895
|
-
|
|
896
|
-
marker.pid ===
|
|
897
|
-
marker.incarnation ===
|
|
1764
|
+
childIncarnation !== undefined &&
|
|
1765
|
+
realBrokerPid !== undefined &&
|
|
1766
|
+
marker.pid === realBrokerPid &&
|
|
1767
|
+
marker.incarnation === childIncarnation &&
|
|
1768
|
+
marker.writtenAt >= childSpawnedAt
|
|
898
1769
|
? marker
|
|
899
1770
|
: undefined;
|
|
900
1771
|
const trustedStartupExitRecord =
|
|
901
1772
|
startupExitRecord &&
|
|
902
|
-
|
|
903
|
-
startupExitRecord.pid ===
|
|
1773
|
+
realBrokerPid !== undefined &&
|
|
1774
|
+
startupExitRecord.pid === realBrokerPid &&
|
|
904
1775
|
startupExitRecord.writtenAt >= childSpawnedAt
|
|
905
1776
|
? startupExitRecord
|
|
906
1777
|
: undefined;
|
|
@@ -909,7 +1780,7 @@ async function ensureBrokerOnce(settings: EnsureBrokerSettings, initiator: Ensur
|
|
|
909
1780
|
exitedBeforeDiscovery &&
|
|
910
1781
|
(trustedMarker?.reason.startsWith(`Failed to acquire lock for ${startupLockPath} after `) === true ||
|
|
911
1782
|
brokerStartupFailureCleanupTargetsLock(trustedMarker, `${startupLockPath}.lock`));
|
|
912
|
-
if (!startupFenceContention && exitedBeforeDiscovery
|
|
1783
|
+
if (!startupFenceContention && exitedBeforeDiscovery) {
|
|
913
1784
|
try {
|
|
914
1785
|
startupFenceContention = (await fs.stat(`${startupLockPath}.lock`)).isDirectory();
|
|
915
1786
|
} catch {
|
|
@@ -951,23 +1822,10 @@ async function ensureBrokerOnce(settings: EnsureBrokerSettings, initiator: Ensur
|
|
|
951
1822
|
// rejects that marker instead of misattributing a foreign failure to this
|
|
952
1823
|
// spawn's caller.
|
|
953
1824
|
const failure = spawnError
|
|
954
|
-
?
|
|
1825
|
+
? brokerSpawnFailureError(spawnError)
|
|
955
1826
|
: exitedBeforeDiscovery
|
|
956
1827
|
? new BrokerStartupError({
|
|
957
|
-
exitCode
|
|
958
|
-
? (trustedStartupExitRecord?.exitCode ??
|
|
959
|
-
trustedMarker?.exitCode ??
|
|
960
|
-
childExitCode ??
|
|
961
|
-
child.exitCode ??
|
|
962
|
-
null)
|
|
963
|
-
: (childExitCode ?? child.exitCode ?? trustedStartupExitRecord?.exitCode ?? null),
|
|
964
|
-
signal: isTrampoline
|
|
965
|
-
? (trustedStartupExitRecord?.signal ??
|
|
966
|
-
trustedMarker?.signal ??
|
|
967
|
-
childSignalCode ??
|
|
968
|
-
child.signalCode ??
|
|
969
|
-
null)
|
|
970
|
-
: (childSignalCode ?? child.signalCode ?? trustedStartupExitRecord?.signal ?? null),
|
|
1828
|
+
...brokerStartupExitStatus(child.exitCode, child.signalCode, trustedStartupExitRecord, trustedMarker),
|
|
971
1829
|
reason:
|
|
972
1830
|
brokerStartupExitReason(trustedStartupExitRecord) ?? brokerStartupFailureReason(trustedMarker),
|
|
973
1831
|
stderrExcerpt: spawnLogTail.length > 0 ? spawnLogTail : undefined,
|
|
@@ -983,10 +1841,20 @@ async function ensureBrokerOnce(settings: EnsureBrokerSettings, initiator: Ensur
|
|
|
983
1841
|
"SDK broker discovery and spawned broker cleanup both failed.",
|
|
984
1842
|
);
|
|
985
1843
|
}
|
|
1844
|
+
if (
|
|
1845
|
+
!retriedStartupLockUnavailable &&
|
|
1846
|
+
initiator === "discovery" &&
|
|
1847
|
+
trustedStartupExitRecord?.reason === "startup-lock-unavailable"
|
|
1848
|
+
) {
|
|
1849
|
+
await releaseSpawnLock();
|
|
1850
|
+
spawnLockReleased = true;
|
|
1851
|
+
await ensureBrokerTiming.sleep(SPAWN_LOCK_RETRY_DELAY_MS);
|
|
1852
|
+
return ensureBrokerOnce(settings, initiator, true);
|
|
1853
|
+
}
|
|
986
1854
|
throw failure;
|
|
987
1855
|
} finally {
|
|
988
1856
|
if (spawnLog) await removeBrokerSpawnLog(spawnLog.path);
|
|
989
|
-
await releaseSpawnLock();
|
|
1857
|
+
if (!spawnLockReleased) await releaseSpawnLock();
|
|
990
1858
|
}
|
|
991
1859
|
}
|
|
992
1860
|
|
|
@@ -1052,12 +1920,16 @@ export function startFixtureBrokerWithLeaseForTest(settings: EnsureBrokerSetting
|
|
|
1052
1920
|
* Test-only launch surface for topology fixtures. It accepts an already-resolved
|
|
1053
1921
|
* command and retains the exact spawned child; no production selection path
|
|
1054
1922
|
* reaches this function.
|
|
1923
|
+
*
|
|
1924
|
+
* Note: This function does NOT use the Windows broker hop. Fixtures are test-only
|
|
1925
|
+
* and do not require Windows process-tree detachment; the fixture needs to retain
|
|
1926
|
+
* exact ownership of the actual broker process to control its stdio[3] control pipe.
|
|
1055
1927
|
*/
|
|
1056
1928
|
export function startFixtureBrokerCommandWithLeaseForTest(command: FixtureBrokerCommand): StartedFixtureBrokerCommand {
|
|
1057
1929
|
if (!command.file || !Array.isArray(command.args)) throw new Error("Invalid fixture broker command.");
|
|
1058
|
-
const child = spawn(command.file,
|
|
1930
|
+
const child = spawn(command.file, command.args, {
|
|
1059
1931
|
cwd: command.cwd,
|
|
1060
|
-
detached:
|
|
1932
|
+
detached: process.platform !== "win32",
|
|
1061
1933
|
stdio: ["ignore", "ignore", "ignore", "pipe"],
|
|
1062
1934
|
env: command.env,
|
|
1063
1935
|
});
|
|
@@ -1107,8 +1979,48 @@ export function brokerOwnerIdentityMatchesForTest(
|
|
|
1107
1979
|
return sameBrokerOwner(left, right);
|
|
1108
1980
|
}
|
|
1109
1981
|
/** Test hook: drives the detached-broker reap on a controllable child surface. */
|
|
1110
|
-
export function reapSpawnedBrokerForTest(
|
|
1111
|
-
|
|
1982
|
+
export function reapSpawnedBrokerForTest(
|
|
1983
|
+
child: ChildProcess,
|
|
1984
|
+
realBrokerPid?: number,
|
|
1985
|
+
brokerIncarnation?: string,
|
|
1986
|
+
timing: ReapTiming = DEFAULT_REAP_TIMING,
|
|
1987
|
+
): Promise<void> {
|
|
1988
|
+
return reapSpawnedBroker(child, realBrokerPid, brokerIncarnation, timing);
|
|
1989
|
+
}
|
|
1990
|
+
/** Test hook: verifies detached PID reaping fences every signal and poll by incarnation. */
|
|
1991
|
+
export function reapDetachedBrokerPidForTest(
|
|
1992
|
+
pid: number,
|
|
1993
|
+
brokerIncarnation: string | undefined,
|
|
1994
|
+
timing: ReapTiming = DEFAULT_REAP_TIMING,
|
|
1995
|
+
observe: (pid: number) => ProcessIncarnationObservation = observeProcessIncarnation,
|
|
1996
|
+
signalProcess: DetachedBrokerSignal = signalDetachedBrokerProcess,
|
|
1997
|
+
): Promise<void> {
|
|
1998
|
+
return reapDetachedBrokerPid(pid, brokerIncarnation, timing, observe, signalProcess);
|
|
1999
|
+
}
|
|
2000
|
+
/** Test hook: verifies a pinned process handle matches before sending a signal. */
|
|
2001
|
+
export function signalPinnedBrokerProcessForTest(
|
|
2002
|
+
reference: PinnedBrokerProcess | null,
|
|
2003
|
+
expectedIncarnation: string,
|
|
2004
|
+
signal: NodeJS.Signals,
|
|
2005
|
+
): boolean {
|
|
2006
|
+
return signalPinnedBrokerProcess(reference, expectedIncarnation, signal);
|
|
2007
|
+
}
|
|
2008
|
+
/** Test hook: verifies platforms without identity-bound signaling fail closed. */
|
|
2009
|
+
export function signalDetachedBrokerProcessForTest(
|
|
2010
|
+
pid: number,
|
|
2011
|
+
brokerIncarnation: string,
|
|
2012
|
+
signal: NodeJS.Signals,
|
|
2013
|
+
platform: NodeJS.Platform,
|
|
2014
|
+
): boolean {
|
|
2015
|
+
return signalDetachedBrokerProcess(pid, brokerIncarnation, signal, platform);
|
|
2016
|
+
}
|
|
2017
|
+
/** Test hook: the signal directory accepted from this environment and snapshot. */
|
|
2018
|
+
export function trustedBrokerSignalDirectoryForTest(
|
|
2019
|
+
env: NodeJS.ProcessEnv,
|
|
2020
|
+
snapshot: ProjectEnvSnapshot | readonly ProjectEnvSnapshot[],
|
|
2021
|
+
): string | undefined {
|
|
2022
|
+
const snapshots = Array.isArray(snapshot) ? snapshot : [snapshot];
|
|
2023
|
+
return trustedBrokerTestSignalDirectory(env, snapshots);
|
|
1112
2024
|
}
|
|
1113
2025
|
/** Test hook: resolves the complete broker environment without spawning. */
|
|
1114
2026
|
export function brokerSpawnEnvironmentForTest(
|
|
@@ -1121,12 +2033,46 @@ export function brokerSpawnEnvironmentForTest(
|
|
|
1121
2033
|
export function registerBrokerOwnerForTest(
|
|
1122
2034
|
agentDir: string,
|
|
1123
2035
|
child: ChildProcess,
|
|
2036
|
+
realBrokerPid?: number,
|
|
2037
|
+
brokerIncarnation?: string,
|
|
1124
2038
|
timing: ReapTiming = DEFAULT_REAP_TIMING,
|
|
1125
2039
|
): BrokerOwner {
|
|
1126
|
-
return registerBrokerOwner(agentDir, child, timing);
|
|
2040
|
+
return registerBrokerOwner(agentDir, child, realBrokerPid, brokerIncarnation, timing);
|
|
1127
2041
|
}
|
|
1128
2042
|
|
|
1129
2043
|
/** Test hook: exercises the same trusted-marker reason reconstruction used by ensureBroker. */
|
|
1130
2044
|
export function brokerStartupFailureReasonForTest(marker: BrokerStartupFailureMarker | undefined): string {
|
|
1131
2045
|
return brokerStartupFailureReason(marker);
|
|
1132
2046
|
}
|
|
2047
|
+
/** Test hook: verifies typed hop errors survive ensureBroker's spawn-failure mapping. */
|
|
2048
|
+
export function brokerSpawnFailureErrorForTest(spawnError: Error): Error {
|
|
2049
|
+
return brokerSpawnFailureError(spawnError);
|
|
2050
|
+
}
|
|
2051
|
+
/** Test hook: preserves and reaps a verified broker reply captured at the hop deadline. */
|
|
2052
|
+
export function brokerHopTimeoutResultForTest(
|
|
2053
|
+
hop: ChildProcess,
|
|
2054
|
+
stdout: string,
|
|
2055
|
+
stderr: string,
|
|
2056
|
+
timeoutMs: number,
|
|
2057
|
+
launcherTerminated: boolean,
|
|
2058
|
+
): Promise<BrokerLaunchResult> {
|
|
2059
|
+
return brokerHopTimeoutResult(hop, stdout, stderr, timeoutMs, launcherTerminated);
|
|
2060
|
+
}
|
|
2061
|
+
/** Test hook: preserves and reaps a verified POSIX trampoline reply at its deadline. */
|
|
2062
|
+
export function brokerTrampolineTimeoutResultForTest(
|
|
2063
|
+
trampoline: ChildProcess,
|
|
2064
|
+
stdout: string,
|
|
2065
|
+
timeoutMs: number,
|
|
2066
|
+
trampolineTerminated: boolean,
|
|
2067
|
+
): Promise<BrokerLaunchResult> {
|
|
2068
|
+
return brokerTrampolineTimeoutResult(trampoline, stdout, timeoutMs, trampolineTerminated);
|
|
2069
|
+
}
|
|
2070
|
+
/** Test hook: verifies broker exit evidence takes precedence over the launcher's status. */
|
|
2071
|
+
export function brokerStartupExitStatusForTest(
|
|
2072
|
+
childExitCode: number | null,
|
|
2073
|
+
childSignal: NodeJS.Signals | null,
|
|
2074
|
+
startupExitRecord: Pick<BrokerStartupExitRecord, "exitCode" | "signal"> | undefined,
|
|
2075
|
+
startupFailureMarker: Pick<BrokerStartupFailureMarker, "exitCode" | "signal"> | undefined,
|
|
2076
|
+
): { exitCode: number | null; signal: string | null } {
|
|
2077
|
+
return brokerStartupExitStatus(childExitCode, childSignal, startupExitRecord, startupFailureMarker);
|
|
2078
|
+
}
|