botmux 3.7.1 → 3.8.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/dist/.runtime-build-id +1 -1
- package/dist/adapters/backend/capabilities.d.ts +17 -0
- package/dist/adapters/backend/capabilities.d.ts.map +1 -0
- package/dist/adapters/backend/capabilities.js +22 -0
- package/dist/adapters/backend/capabilities.js.map +1 -0
- package/dist/adapters/backend/critical-control-key.d.ts +19 -0
- package/dist/adapters/backend/critical-control-key.d.ts.map +1 -0
- package/dist/adapters/backend/critical-control-key.js +34 -0
- package/dist/adapters/backend/critical-control-key.js.map +1 -0
- package/dist/adapters/backend/herdr-backend.d.ts +9 -0
- package/dist/adapters/backend/herdr-backend.d.ts.map +1 -1
- package/dist/adapters/backend/herdr-backend.js +23 -4
- package/dist/adapters/backend/herdr-backend.js.map +1 -1
- package/dist/adapters/backend/riff-backend.d.ts.map +1 -1
- package/dist/adapters/backend/riff-backend.js +3 -2
- package/dist/adapters/backend/riff-backend.js.map +1 -1
- package/dist/adapters/backend/session-backend-selector.d.ts +61 -6
- package/dist/adapters/backend/session-backend-selector.d.ts.map +1 -1
- package/dist/adapters/backend/session-backend-selector.js +173 -9
- package/dist/adapters/backend/session-backend-selector.js.map +1 -1
- package/dist/adapters/backend/tmux-backend.d.ts +10 -3
- package/dist/adapters/backend/tmux-backend.d.ts.map +1 -1
- package/dist/adapters/backend/tmux-backend.js +25 -8
- package/dist/adapters/backend/tmux-backend.js.map +1 -1
- package/dist/adapters/backend/tmux-pipe-backend.d.ts.map +1 -1
- package/dist/adapters/backend/tmux-pipe-backend.js +3 -2
- package/dist/adapters/backend/tmux-pipe-backend.js.map +1 -1
- package/dist/adapters/backend/types.d.ts +45 -4
- package/dist/adapters/backend/types.d.ts.map +1 -1
- package/dist/adapters/backend/types.js +11 -0
- package/dist/adapters/backend/types.js.map +1 -1
- package/dist/adapters/backend/zellij-backend.d.ts.map +1 -1
- package/dist/adapters/backend/zellij-backend.js +3 -2
- package/dist/adapters/backend/zellij-backend.js.map +1 -1
- package/dist/adapters/backend/zmx-backend.d.ts +286 -0
- package/dist/adapters/backend/zmx-backend.d.ts.map +1 -0
- package/dist/adapters/backend/zmx-backend.js +2396 -0
- package/dist/adapters/backend/zmx-backend.js.map +1 -0
- package/dist/adapters/cli/claude-code.d.ts.map +1 -1
- package/dist/adapters/cli/claude-code.js +3 -0
- package/dist/adapters/cli/claude-code.js.map +1 -1
- package/dist/adapters/cli/codex-app.d.ts.map +1 -1
- package/dist/adapters/cli/codex-app.js +5 -1
- package/dist/adapters/cli/codex-app.js.map +1 -1
- package/dist/adapters/cli/codex.d.ts.map +1 -1
- package/dist/adapters/cli/codex.js +26 -1
- package/dist/adapters/cli/codex.js.map +1 -1
- package/dist/adapters/cli/fs-policy.d.ts +79 -1
- package/dist/adapters/cli/fs-policy.d.ts.map +1 -1
- package/dist/adapters/cli/fs-policy.js +290 -13
- package/dist/adapters/cli/fs-policy.js.map +1 -1
- package/dist/adapters/cli/grok.d.ts.map +1 -1
- package/dist/adapters/cli/grok.js +4 -1
- package/dist/adapters/cli/grok.js.map +1 -1
- package/dist/adapters/cli/oh-my-pi.d.ts.map +1 -1
- package/dist/adapters/cli/oh-my-pi.js +5 -3
- package/dist/adapters/cli/oh-my-pi.js.map +1 -1
- package/dist/adapters/cli/shared-hints.d.ts +7 -4
- package/dist/adapters/cli/shared-hints.d.ts.map +1 -1
- package/dist/adapters/cli/shared-hints.js +52 -35
- package/dist/adapters/cli/shared-hints.js.map +1 -1
- package/dist/adapters/cli/strict-input-handle.d.ts +13 -0
- package/dist/adapters/cli/strict-input-handle.d.ts.map +1 -0
- package/dist/adapters/cli/strict-input-handle.js +39 -0
- package/dist/adapters/cli/strict-input-handle.js.map +1 -0
- package/dist/adapters/cli/traex.d.ts.map +1 -1
- package/dist/adapters/cli/traex.js +6 -4
- package/dist/adapters/cli/traex.js.map +1 -1
- package/dist/adapters/cli/types.d.ts +16 -0
- package/dist/adapters/cli/types.d.ts.map +1 -1
- package/dist/bot-registry.d.ts +137 -8
- package/dist/bot-registry.d.ts.map +1 -1
- package/dist/bot-registry.js +298 -12
- package/dist/bot-registry.js.map +1 -1
- package/dist/cli/session-list-liveness.d.ts +1 -1
- package/dist/cli/session-list-liveness.js +1 -1
- package/dist/cli/zmx-managed-attach.d.ts +41 -0
- package/dist/cli/zmx-managed-attach.d.ts.map +1 -0
- package/dist/cli/zmx-managed-attach.js +72 -0
- package/dist/cli/zmx-managed-attach.js.map +1 -0
- package/dist/cli.d.ts.map +1 -1
- package/dist/cli.js +780 -131
- package/dist/cli.js.map +1 -1
- package/dist/codex-app-runner.js +90 -2
- package/dist/codex-app-runner.js.map +1 -1
- package/dist/codex-rpc-engine.d.ts.map +1 -1
- package/dist/codex-rpc-engine.js +22 -6
- package/dist/codex-rpc-engine.js.map +1 -1
- package/dist/config.d.ts +5 -0
- package/dist/config.d.ts.map +1 -1
- package/dist/config.js +29 -1
- package/dist/config.js.map +1 -1
- package/dist/core/ask-broker.d.ts +22 -1
- package/dist/core/ask-broker.d.ts.map +1 -1
- package/dist/core/ask-broker.js +12 -5
- package/dist/core/ask-broker.js.map +1 -1
- package/dist/core/botmux-wrapper.d.ts +18 -0
- package/dist/core/botmux-wrapper.d.ts.map +1 -1
- package/dist/core/botmux-wrapper.js +25 -1
- package/dist/core/botmux-wrapper.js.map +1 -1
- package/dist/core/chat-rename.d.ts +27 -0
- package/dist/core/chat-rename.d.ts.map +1 -0
- package/dist/core/chat-rename.js +53 -0
- package/dist/core/chat-rename.js.map +1 -0
- package/dist/core/command-handler.d.ts.map +1 -1
- package/dist/core/command-handler.js +160 -12
- package/dist/core/command-handler.js.map +1 -1
- package/dist/core/cost-calculator.d.ts +25 -1
- package/dist/core/cost-calculator.d.ts.map +1 -1
- package/dist/core/cost-calculator.js +102 -4
- package/dist/core/cost-calculator.js.map +1 -1
- package/dist/core/dashboard-ipc-server.d.ts +16 -2
- package/dist/core/dashboard-ipc-server.d.ts.map +1 -1
- package/dist/core/dashboard-ipc-server.js +611 -62
- package/dist/core/dashboard-ipc-server.js.map +1 -1
- package/dist/core/dashboard-rows.d.ts +22 -1
- package/dist/core/dashboard-rows.d.ts.map +1 -1
- package/dist/core/dashboard-rows.js +61 -0
- package/dist/core/dashboard-rows.js.map +1 -1
- package/dist/core/dashboard-url.d.ts +6 -0
- package/dist/core/dashboard-url.d.ts.map +1 -1
- package/dist/core/dashboard-url.js +11 -1
- package/dist/core/dashboard-url.js.map +1 -1
- package/dist/core/device-isolation-daemon.d.ts +28 -6
- package/dist/core/device-isolation-daemon.d.ts.map +1 -1
- package/dist/core/device-isolation-daemon.js +96 -13
- package/dist/core/device-isolation-daemon.js.map +1 -1
- package/dist/core/host-overload-alert.d.ts +75 -0
- package/dist/core/host-overload-alert.d.ts.map +1 -1
- package/dist/core/host-overload-alert.js +103 -0
- package/dist/core/host-overload-alert.js.map +1 -1
- package/dist/core/idle-worker-sweeper.js +1 -1
- package/dist/core/idle-worker-sweeper.js.map +1 -1
- package/dist/core/inflight-input-tracker.d.ts +3 -0
- package/dist/core/inflight-input-tracker.d.ts.map +1 -1
- package/dist/core/inflight-input-tracker.js +9 -0
- package/dist/core/inflight-input-tracker.js.map +1 -1
- package/dist/core/passthrough-commands.d.ts.map +1 -1
- package/dist/core/passthrough-commands.js +10 -0
- package/dist/core/passthrough-commands.js.map +1 -1
- package/dist/core/persistent-backend.d.ts +32 -4
- package/dist/core/persistent-backend.d.ts.map +1 -1
- package/dist/core/persistent-backend.js +75 -6
- package/dist/core/persistent-backend.js.map +1 -1
- package/dist/core/plugins/mcp/gateway.js +2 -2
- package/dist/core/plugins/mcp/gateway.js.map +1 -1
- package/dist/core/plugins/runtime.d.ts +3 -0
- package/dist/core/plugins/runtime.d.ts.map +1 -1
- package/dist/core/plugins/runtime.js.map +1 -1
- package/dist/core/plugins/service-manager.d.ts +4 -1
- package/dist/core/plugins/service-manager.d.ts.map +1 -1
- package/dist/core/plugins/service-manager.js +11 -6
- package/dist/core/plugins/service-manager.js.map +1 -1
- package/dist/core/role-library.d.ts +48 -1
- package/dist/core/role-library.d.ts.map +1 -1
- package/dist/core/role-library.js +111 -3
- package/dist/core/role-library.js.map +1 -1
- package/dist/core/role-resolver.d.ts +14 -2
- package/dist/core/role-resolver.d.ts.map +1 -1
- package/dist/core/role-resolver.js +47 -4
- package/dist/core/role-resolver.js.map +1 -1
- package/dist/core/session-activity.d.ts +14 -0
- package/dist/core/session-activity.d.ts.map +1 -1
- package/dist/core/session-activity.js +46 -1
- package/dist/core/session-activity.js.map +1 -1
- package/dist/core/session-board.d.ts +5 -2
- package/dist/core/session-board.d.ts.map +1 -1
- package/dist/core/session-board.js +5 -2
- package/dist/core/session-board.js.map +1 -1
- package/dist/core/session-liveness.d.ts +6 -5
- package/dist/core/session-liveness.d.ts.map +1 -1
- package/dist/core/session-liveness.js +34 -13
- package/dist/core/session-liveness.js.map +1 -1
- package/dist/core/session-manager.d.ts +3 -2
- package/dist/core/session-manager.d.ts.map +1 -1
- package/dist/core/session-manager.js +397 -103
- package/dist/core/session-manager.js.map +1 -1
- package/dist/core/session-marker.d.ts +11 -2
- package/dist/core/session-marker.d.ts.map +1 -1
- package/dist/core/session-marker.js +50 -11
- package/dist/core/session-marker.js.map +1 -1
- package/dist/core/session-message-preview.d.ts +20 -0
- package/dist/core/session-message-preview.d.ts.map +1 -0
- package/dist/core/session-message-preview.js +188 -0
- package/dist/core/session-message-preview.js.map +1 -0
- package/dist/core/session-title.d.ts +5 -1
- package/dist/core/session-title.d.ts.map +1 -1
- package/dist/core/session-title.js +20 -3
- package/dist/core/session-title.js.map +1 -1
- package/dist/core/startup-commands.d.ts +1 -1
- package/dist/core/startup-commands.js +1 -1
- package/dist/core/terminal-url.d.ts.map +1 -1
- package/dist/core/terminal-url.js +3 -2
- package/dist/core/terminal-url.js.map +1 -1
- package/dist/core/trigger-session.d.ts.map +1 -1
- package/dist/core/trigger-session.js +75 -12
- package/dist/core/trigger-session.js.map +1 -1
- package/dist/core/types.d.ts +35 -0
- package/dist/core/types.d.ts.map +1 -1
- package/dist/core/types.js +23 -0
- package/dist/core/types.js.map +1 -1
- package/dist/core/worker-ipc.d.ts +11 -0
- package/dist/core/worker-ipc.d.ts.map +1 -0
- package/dist/core/worker-ipc.js +26 -0
- package/dist/core/worker-ipc.js.map +1 -0
- package/dist/core/worker-pool.d.ts +179 -29
- package/dist/core/worker-pool.d.ts.map +1 -1
- package/dist/core/worker-pool.js +2118 -331
- package/dist/core/worker-pool.js.map +1 -1
- package/dist/daemon.d.ts +132 -3
- package/dist/daemon.d.ts.map +1 -1
- package/dist/daemon.js +1327 -449
- package/dist/daemon.js.map +1 -1
- package/dist/dashboard/auth.d.ts +2 -0
- package/dist/dashboard/auth.d.ts.map +1 -1
- package/dist/dashboard/auth.js +9 -0
- package/dist/dashboard/auth.js.map +1 -1
- package/dist/dashboard/bot-payload.d.ts +4 -0
- package/dist/dashboard/bot-payload.d.ts.map +1 -1
- package/dist/dashboard/bot-payload.js +5 -0
- package/dist/dashboard/bot-payload.js.map +1 -1
- package/dist/dashboard/federation-api.d.ts.map +1 -1
- package/dist/dashboard/federation-api.js +4 -0
- package/dist/dashboard/federation-api.js.map +1 -1
- package/dist/dashboard/federation-spoke-api.d.ts.map +1 -1
- package/dist/dashboard/federation-spoke-api.js +32 -5
- package/dist/dashboard/federation-spoke-api.js.map +1 -1
- package/dist/dashboard/public-redact.d.ts +8 -2
- package/dist/dashboard/public-redact.d.ts.map +1 -1
- package/dist/dashboard/public-redact.js +34 -7
- package/dist/dashboard/public-redact.js.map +1 -1
- package/dist/dashboard/session-card-model.d.ts.map +1 -1
- package/dist/dashboard/session-card-model.js +10 -2
- package/dist/dashboard/session-card-model.js.map +1 -1
- package/dist/dashboard/settings-write-applier.d.ts +35 -1
- package/dist/dashboard/settings-write-applier.d.ts.map +1 -1
- package/dist/dashboard/settings-write-applier.js +88 -1
- package/dist/dashboard/settings-write-applier.js.map +1 -1
- package/dist/dashboard/web/app.d.ts.map +1 -1
- package/dist/dashboard/web/app.js +0 -1
- package/dist/dashboard/web/app.js.map +1 -1
- package/dist/dashboard/web/bot-defaults-page.d.ts +15 -0
- package/dist/dashboard/web/bot-defaults-page.d.ts.map +1 -1
- package/dist/dashboard/web/bot-defaults-page.js +212 -58
- package/dist/dashboard/web/bot-defaults-page.js.map +1 -1
- package/dist/dashboard/web/bot-defaults.d.ts +3 -0
- package/dist/dashboard/web/bot-defaults.d.ts.map +1 -1
- package/dist/dashboard/web/bot-defaults.js.map +1 -1
- package/dist/dashboard/web/bot-onboarding.d.ts +2 -1
- package/dist/dashboard/web/bot-onboarding.d.ts.map +1 -1
- package/dist/dashboard/web/bot-onboarding.js.map +1 -1
- package/dist/dashboard/web/clipboard.d.ts +2 -0
- package/dist/dashboard/web/clipboard.d.ts.map +1 -0
- package/dist/dashboard/web/clipboard.js +42 -0
- package/dist/dashboard/web/clipboard.js.map +1 -0
- package/dist/dashboard/web/connectors-page.d.ts.map +1 -1
- package/dist/dashboard/web/connectors-page.js +12 -8
- package/dist/dashboard/web/connectors-page.js.map +1 -1
- package/dist/dashboard/web/dashboard-components.d.ts +17 -16
- package/dist/dashboard/web/dashboard-components.d.ts.map +1 -1
- package/dist/dashboard/web/dashboard-components.js.map +1 -1
- package/dist/dashboard/web/groups-page.d.ts.map +1 -1
- package/dist/dashboard/web/groups-page.js +7 -3
- package/dist/dashboard/web/groups-page.js.map +1 -1
- package/dist/dashboard/web/i18n.d.ts.map +1 -1
- package/dist/dashboard/web/i18n.js +205 -12
- package/dist/dashboard/web/i18n.js.map +1 -1
- package/dist/dashboard/web/icon-tooltip.d.ts.map +1 -1
- package/dist/dashboard/web/icon-tooltip.js +16 -1
- package/dist/dashboard/web/icon-tooltip.js.map +1 -1
- package/dist/dashboard/web/listener-filters.d.ts +23 -0
- package/dist/dashboard/web/listener-filters.d.ts.map +1 -0
- package/dist/dashboard/web/listener-filters.js +36 -0
- package/dist/dashboard/web/listener-filters.js.map +1 -0
- package/dist/dashboard/web/overview-page.d.ts.map +1 -1
- package/dist/dashboard/web/overview-page.js.map +1 -1
- package/dist/dashboard/web/plugin-page.d.ts.map +1 -1
- package/dist/dashboard/web/plugin-page.js.map +1 -1
- package/dist/dashboard/web/preview-markdown.d.ts +5 -0
- package/dist/dashboard/web/preview-markdown.d.ts.map +1 -0
- package/dist/dashboard/web/preview-markdown.js +52 -0
- package/dist/dashboard/web/preview-markdown.js.map +1 -0
- package/dist/dashboard/web/roles-page.d.ts.map +1 -1
- package/dist/dashboard/web/roles-page.js +535 -10
- package/dist/dashboard/web/roles-page.js.map +1 -1
- package/dist/dashboard/web/roles.d.ts +77 -0
- package/dist/dashboard/web/roles.d.ts.map +1 -1
- package/dist/dashboard/web/roles.js +98 -0
- package/dist/dashboard/web/roles.js.map +1 -1
- package/dist/dashboard/web/session-terminal.d.ts.map +1 -1
- package/dist/dashboard/web/session-terminal.js +6 -4
- package/dist/dashboard/web/session-terminal.js.map +1 -1
- package/dist/dashboard/web/sessions-kanban.d.ts +4 -1
- package/dist/dashboard/web/sessions-kanban.d.ts.map +1 -1
- package/dist/dashboard/web/sessions-kanban.js +1 -1
- package/dist/dashboard/web/sessions-kanban.js.map +1 -1
- package/dist/dashboard/web/sessions-page.d.ts +3 -2
- package/dist/dashboard/web/sessions-page.d.ts.map +1 -1
- package/dist/dashboard/web/sessions-page.js +163 -28
- package/dist/dashboard/web/sessions-page.js.map +1 -1
- package/dist/dashboard/web/sessions.d.ts +25 -0
- package/dist/dashboard/web/sessions.d.ts.map +1 -1
- package/dist/dashboard/web/sessions.js +58 -7
- package/dist/dashboard/web/sessions.js.map +1 -1
- package/dist/dashboard/web/settings-page.d.ts +28 -3
- package/dist/dashboard/web/settings-page.d.ts.map +1 -1
- package/dist/dashboard/web/settings-page.js +61 -1
- package/dist/dashboard/web/settings-page.js.map +1 -1
- package/dist/dashboard/web/skills-page.d.ts +6 -5
- package/dist/dashboard/web/skills-page.d.ts.map +1 -1
- package/dist/dashboard/web/skills-page.js +2 -0
- package/dist/dashboard/web/skills-page.js.map +1 -1
- package/dist/dashboard/web/v3-components.d.ts +3 -2
- package/dist/dashboard/web/v3-components.d.ts.map +1 -1
- package/dist/dashboard/web/v3-components.js.map +1 -1
- package/dist/dashboard/web/vc-consumer-profiles-section.d.ts +12 -2
- package/dist/dashboard/web/vc-consumer-profiles-section.d.ts.map +1 -1
- package/dist/dashboard/web/vc-consumer-profiles-section.js +0 -9
- package/dist/dashboard/web/vc-consumer-profiles-section.js.map +1 -1
- package/dist/dashboard/web/webhook-logs-page.d.ts +2 -1
- package/dist/dashboard/web/webhook-logs-page.d.ts.map +1 -1
- package/dist/dashboard/web/webhook-logs-page.js +4 -1
- package/dist/dashboard/web/webhook-logs-page.js.map +1 -1
- package/dist/dashboard-web/app.js +1 -1
- package/dist/dashboard-web/chunks/bot-defaults-page-FAPIMQV3.js +10 -0
- package/dist/dashboard-web/chunks/{chunk-QFMH6ITB.js → chunk-5CJMHTT4.js} +1 -1
- package/dist/dashboard-web/chunks/{chunk-KQB42ML5.js → chunk-DLZK52JU.js} +14 -12
- package/dist/dashboard-web/chunks/chunk-FDZ7WOBW.js +16 -0
- package/dist/dashboard-web/chunks/{chunk-3QAUJXAS.js → chunk-GTAJV5YC.js} +1 -1
- package/dist/dashboard-web/chunks/{chunk-FNPGOZMX.js → chunk-IHNG6KE6.js} +1 -1
- package/dist/dashboard-web/chunks/{chunk-FLGXZJTD.js → chunk-VA6QWSGQ.js} +1 -1
- package/dist/dashboard-web/chunks/chunk-XJ7BGPJH.js +1 -0
- package/dist/dashboard-web/chunks/chunk-ZJVTGU5X.js +1 -0
- package/dist/dashboard-web/chunks/connectors-page-LKWRT3GS.js +1 -0
- package/dist/dashboard-web/chunks/groups-page-V6654ICQ.js +8 -0
- package/dist/dashboard-web/chunks/insights-page-237RMFQ5.js +7 -0
- package/dist/dashboard-web/chunks/{monitor-room-7IVIS75W.js → monitor-room-OO5Y4FRQ.js} +1 -1
- package/dist/dashboard-web/chunks/{monitoring-page-5SSJRQZF.js → monitoring-page-OUEITM5P.js} +1 -1
- package/dist/dashboard-web/chunks/{office-page-EOND3L5D.js → office-page-4KNAHQ4Q.js} +1 -1
- package/dist/dashboard-web/chunks/{overview-page-NISBVBBJ.js → overview-page-QDP4YK2S.js} +1 -1
- package/dist/dashboard-web/chunks/{plugin-page-JXC5F5YV.js → plugin-page-VEGTCEAS.js} +1 -1
- package/dist/dashboard-web/chunks/roles-page-PGXNZZNP.js +1 -0
- package/dist/dashboard-web/chunks/{schedules-page-AVOOKLUH.js → schedules-page-BJNC5TID.js} +1 -1
- package/dist/dashboard-web/chunks/sessions-page-M2ZYYCMO.js +6 -0
- package/dist/dashboard-web/chunks/settings-page-IJFZKUUW.js +2 -0
- package/dist/dashboard-web/chunks/{skills-page-5MW54PCC.js → skills-page-YRAIRZOQ.js} +3 -3
- package/dist/dashboard-web/chunks/{team-federation-page-2SNIMVXI.js → team-federation-page-DEU27GRU.js} +1 -1
- package/dist/dashboard-web/chunks/{v3-page-W6SBXXQC.js → v3-page-6KHEW2YL.js} +5 -5
- package/dist/dashboard-web/chunks/{whiteboards-page-STC54KTP.js → whiteboards-page-VDECAALH.js} +1 -1
- package/dist/dashboard-web/style.css +12545 -11432
- package/dist/dashboard.js +215 -36
- package/dist/dashboard.js.map +1 -1
- package/dist/global-config.d.ts +41 -2
- package/dist/global-config.d.ts.map +1 -1
- package/dist/global-config.js +47 -0
- package/dist/global-config.js.map +1 -1
- package/dist/i18n/en.d.ts.map +1 -1
- package/dist/i18n/en.js +71 -19
- package/dist/i18n/en.js.map +1 -1
- package/dist/i18n/zh.d.ts.map +1 -1
- package/dist/i18n/zh.js +71 -19
- package/dist/i18n/zh.js.map +1 -1
- package/dist/im/lark/callback-button-marker.d.ts +34 -0
- package/dist/im/lark/callback-button-marker.d.ts.map +1 -0
- package/dist/im/lark/callback-button-marker.js +99 -0
- package/dist/im/lark/callback-button-marker.js.map +1 -0
- package/dist/im/lark/card-builder.d.ts +46 -10
- package/dist/im/lark/card-builder.d.ts.map +1 -1
- package/dist/im/lark/card-builder.js +299 -78
- package/dist/im/lark/card-builder.js.map +1 -1
- package/dist/im/lark/card-handler.d.ts +3 -4
- package/dist/im/lark/card-handler.d.ts.map +1 -1
- package/dist/im/lark/card-handler.js +380 -148
- package/dist/im/lark/card-handler.js.map +1 -1
- package/dist/im/lark/client.d.ts +24 -1
- package/dist/im/lark/client.d.ts.map +1 -1
- package/dist/im/lark/client.js +101 -8
- package/dist/im/lark/client.js.map +1 -1
- package/dist/im/lark/doc-comment.d.ts.map +1 -1
- package/dist/im/lark/doc-comment.js +6 -1
- package/dist/im/lark/doc-comment.js.map +1 -1
- package/dist/im/lark/event-dispatcher.d.ts +115 -3
- package/dist/im/lark/event-dispatcher.d.ts.map +1 -1
- package/dist/im/lark/event-dispatcher.js +534 -147
- package/dist/im/lark/event-dispatcher.js.map +1 -1
- package/dist/im/lark/grant-command.d.ts +16 -12
- package/dist/im/lark/grant-command.d.ts.map +1 -1
- package/dist/im/lark/grant-command.js +48 -151
- package/dist/im/lark/grant-command.js.map +1 -1
- package/dist/im/lark/grant-pending.d.ts +12 -4
- package/dist/im/lark/grant-pending.d.ts.map +1 -1
- package/dist/im/lark/grant-pending.js +31 -8
- package/dist/im/lark/grant-pending.js.map +1 -1
- package/dist/im/lark/invite-command.d.ts +20 -0
- package/dist/im/lark/invite-command.d.ts.map +1 -0
- package/dist/im/lark/invite-command.js +239 -0
- package/dist/im/lark/invite-command.js.map +1 -0
- package/dist/im/lark/md-card.d.ts +66 -4
- package/dist/im/lark/md-card.d.ts.map +1 -1
- package/dist/im/lark/md-card.js +201 -31
- package/dist/im/lark/md-card.js.map +1 -1
- package/dist/im/lark/mention-targets.d.ts +43 -0
- package/dist/im/lark/mention-targets.d.ts.map +1 -0
- package/dist/im/lark/mention-targets.js +190 -0
- package/dist/im/lark/mention-targets.js.map +1 -0
- package/dist/im/lark/message-parser.d.ts +31 -0
- package/dist/im/lark/message-parser.d.ts.map +1 -1
- package/dist/im/lark/message-parser.js +305 -32
- package/dist/im/lark/message-parser.js.map +1 -1
- package/dist/im/lark/reply-card-footer-signature.d.ts +7 -0
- package/dist/im/lark/reply-card-footer-signature.d.ts.map +1 -0
- package/dist/im/lark/reply-card-footer-signature.js +7 -0
- package/dist/im/lark/reply-card-footer-signature.js.map +1 -0
- package/dist/im/lark/sessions-card.d.ts.map +1 -1
- package/dist/im/lark/sessions-card.js +4 -1
- package/dist/im/lark/sessions-card.js.map +1 -1
- package/dist/im/lark/substitute-command.d.ts.map +1 -1
- package/dist/im/lark/substitute-command.js +8 -0
- package/dist/im/lark/substitute-command.js.map +1 -1
- package/dist/im/lark/topic-root-context.d.ts +35 -0
- package/dist/im/lark/topic-root-context.d.ts.map +1 -0
- package/dist/im/lark/topic-root-context.js +37 -0
- package/dist/im/lark/topic-root-context.js.map +1 -0
- package/dist/im/lark/v3-blocked-card.d.ts.map +1 -1
- package/dist/im/lark/v3-blocked-card.js +2 -1
- package/dist/im/lark/v3-blocked-card.js.map +1 -1
- package/dist/im/lark/v3-gate-card.d.ts.map +1 -1
- package/dist/im/lark/v3-gate-card.js +2 -1
- package/dist/im/lark/v3-gate-card.js.map +1 -1
- package/dist/im/lark/v3-loop-grant-card.d.ts.map +1 -1
- package/dist/im/lark/v3-loop-grant-card.js +2 -1
- package/dist/im/lark/v3-loop-grant-card.js.map +1 -1
- package/dist/im/lark/v3-progress-card.d.ts.map +1 -1
- package/dist/im/lark/v3-progress-card.js +2 -1
- package/dist/im/lark/v3-progress-card.js.map +1 -1
- package/dist/im/lark/v3-revisit-grant-card.d.ts.map +1 -1
- package/dist/im/lark/v3-revisit-grant-card.js +2 -1
- package/dist/im/lark/v3-revisit-grant-card.js.map +1 -1
- package/dist/index-core-only.d.ts +3 -0
- package/dist/index-core-only.d.ts.map +1 -0
- package/dist/index-core-only.js +107 -0
- package/dist/index-core-only.js.map +1 -0
- package/dist/services/async-trigger-state.d.ts +8 -0
- package/dist/services/async-trigger-state.d.ts.map +1 -1
- package/dist/services/async-trigger-state.js +3 -2
- package/dist/services/async-trigger-state.js.map +1 -1
- package/dist/services/async-trigger-store.d.ts +10 -2
- package/dist/services/async-trigger-store.d.ts.map +1 -1
- package/dist/services/async-trigger-store.js +3 -2
- package/dist/services/async-trigger-store.js.map +1 -1
- package/dist/services/backend-availability.d.ts +3 -1
- package/dist/services/backend-availability.d.ts.map +1 -1
- package/dist/services/backend-availability.js +14 -1
- package/dist/services/backend-availability.js.map +1 -1
- package/dist/services/backend-type-store.d.ts.map +1 -1
- package/dist/services/backend-type-store.js +1 -1
- package/dist/services/backend-type-store.js.map +1 -1
- package/dist/services/bot-config-store.d.ts.map +1 -1
- package/dist/services/bot-config-store.js +2 -1
- package/dist/services/bot-config-store.js.map +1 -1
- package/dist/services/bridge-fallback-gate.d.ts +14 -1
- package/dist/services/bridge-fallback-gate.d.ts.map +1 -1
- package/dist/services/bridge-fallback-gate.js +65 -1
- package/dist/services/bridge-fallback-gate.js.map +1 -1
- package/dist/services/card-prefs-store.d.ts +7 -2
- package/dist/services/card-prefs-store.d.ts.map +1 -1
- package/dist/services/card-prefs-store.js +30 -4
- package/dist/services/card-prefs-store.js.map +1 -1
- package/dist/services/codex-app-runner-protocol.d.ts +9 -0
- package/dist/services/codex-app-runner-protocol.d.ts.map +1 -1
- package/dist/services/codex-app-runner-protocol.js +17 -0
- package/dist/services/codex-app-runner-protocol.js.map +1 -1
- package/dist/services/codex-app-token-usage.d.ts +93 -0
- package/dist/services/codex-app-token-usage.d.ts.map +1 -0
- package/dist/services/codex-app-token-usage.js +190 -0
- package/dist/services/codex-app-token-usage.js.map +1 -0
- package/dist/services/codex-transcript.d.ts +6 -1
- package/dist/services/codex-transcript.d.ts.map +1 -1
- package/dist/services/codex-transcript.js +103 -32
- package/dist/services/codex-transcript.js.map +1 -1
- package/dist/services/federation-roster.d.ts +4 -0
- package/dist/services/federation-roster.d.ts.map +1 -1
- package/dist/services/federation-roster.js +3 -0
- package/dist/services/federation-roster.js.map +1 -1
- package/dist/services/federation-store.d.ts +6 -0
- package/dist/services/federation-store.d.ts.map +1 -1
- package/dist/services/federation-store.js.map +1 -1
- package/dist/services/grant-policy.d.ts +13 -0
- package/dist/services/grant-policy.d.ts.map +1 -0
- package/dist/services/grant-policy.js +40 -0
- package/dist/services/grant-policy.js.map +1 -0
- package/dist/services/grant-store.d.ts +24 -3
- package/dist/services/grant-store.d.ts.map +1 -1
- package/dist/services/grant-store.js +196 -12
- package/dist/services/grant-store.js.map +1 -1
- package/dist/services/groups-store.d.ts +36 -0
- package/dist/services/groups-store.d.ts.map +1 -1
- package/dist/services/groups-store.js +123 -1
- package/dist/services/groups-store.js.map +1 -1
- package/dist/services/jsonl-cursor.d.ts.map +1 -1
- package/dist/services/jsonl-cursor.js +8 -2
- package/dist/services/jsonl-cursor.js.map +1 -1
- package/dist/services/local-cli-opener.d.ts.map +1 -1
- package/dist/services/local-cli-opener.js +32 -0
- package/dist/services/local-cli-opener.js.map +1 -1
- package/dist/services/message-listener-run-preview-store.d.ts +43 -0
- package/dist/services/message-listener-run-preview-store.d.ts.map +1 -0
- package/dist/services/message-listener-run-preview-store.js +152 -0
- package/dist/services/message-listener-run-preview-store.js.map +1 -0
- package/dist/services/message-listener-store.d.ts +26 -0
- package/dist/services/message-listener-store.d.ts.map +1 -0
- package/dist/services/message-listener-store.js +138 -0
- package/dist/services/message-listener-store.js.map +1 -0
- package/dist/services/message-listener.d.ts +112 -0
- package/dist/services/message-listener.d.ts.map +1 -0
- package/dist/services/message-listener.js +391 -0
- package/dist/services/message-listener.js.map +1 -0
- package/dist/services/overload-alert-migration.d.ts +15 -0
- package/dist/services/overload-alert-migration.d.ts.map +1 -0
- package/dist/services/overload-alert-migration.js +115 -0
- package/dist/services/overload-alert-migration.js.map +1 -0
- package/dist/services/send-policy.d.ts +38 -0
- package/dist/services/send-policy.d.ts.map +1 -1
- package/dist/services/send-policy.js +34 -0
- package/dist/services/send-policy.js.map +1 -1
- package/dist/services/session-store.d.ts +12 -1
- package/dist/services/session-store.d.ts.map +1 -1
- package/dist/services/session-store.js +44 -2
- package/dist/services/session-store.js.map +1 -1
- package/dist/services/skill-registry-store.d.ts.map +1 -1
- package/dist/services/skill-registry-store.js +87 -12
- package/dist/services/skill-registry-store.js.map +1 -1
- package/dist/services/substitute-mode-normalize.d.ts +3 -0
- package/dist/services/substitute-mode-normalize.d.ts.map +1 -1
- package/dist/services/substitute-mode-normalize.js +8 -0
- package/dist/services/substitute-mode-normalize.js.map +1 -1
- package/dist/services/substitute-mode-store.d.ts.map +1 -1
- package/dist/services/substitute-mode-store.js +8 -1
- package/dist/services/substitute-mode-store.js.map +1 -1
- package/dist/services/transcript-resolver.d.ts +6 -1
- package/dist/services/transcript-resolver.d.ts.map +1 -1
- package/dist/services/transcript-resolver.js +145 -29
- package/dist/services/transcript-resolver.js.map +1 -1
- package/dist/services/trigger-types.d.ts +16 -0
- package/dist/services/trigger-types.d.ts.map +1 -1
- package/dist/services/trigger-types.js +6 -0
- package/dist/services/trigger-types.js.map +1 -1
- package/dist/setup/bot-config-editor.js +2 -2
- package/dist/setup/bot-config-editor.js.map +1 -1
- package/dist/setup/ensure-zmx.d.ts +31 -0
- package/dist/setup/ensure-zmx.d.ts.map +1 -0
- package/dist/setup/ensure-zmx.js +161 -0
- package/dist/setup/ensure-zmx.js.map +1 -0
- package/dist/setup/index.d.ts +1 -1
- package/dist/setup/index.js +1 -1
- package/dist/setup/open-platform-automation.d.ts +9 -0
- package/dist/setup/open-platform-automation.d.ts.map +1 -1
- package/dist/setup/open-platform-automation.js +15 -2
- package/dist/setup/open-platform-automation.js.map +1 -1
- package/dist/setup/setup-args.d.ts +1 -1
- package/dist/setup/setup-args.d.ts.map +1 -1
- package/dist/setup/setup-args.js +1 -1
- package/dist/skills/definitions.d.ts.map +1 -1
- package/dist/skills/definitions.js +32 -0
- package/dist/skills/definitions.js.map +1 -1
- package/dist/skills/injection-mode.d.ts +6 -2
- package/dist/skills/injection-mode.d.ts.map +1 -1
- package/dist/skills/injection-mode.js +10 -5
- package/dist/skills/injection-mode.js.map +1 -1
- package/dist/types.d.ts +79 -3
- package/dist/types.d.ts.map +1 -1
- package/dist/utils/child-env.d.ts +1 -1
- package/dist/utils/child-env.d.ts.map +1 -1
- package/dist/utils/child-env.js +4 -0
- package/dist/utils/child-env.js.map +1 -1
- package/dist/utils/effort-confirm-dialog.d.ts +40 -0
- package/dist/utils/effort-confirm-dialog.d.ts.map +1 -0
- package/dist/utils/effort-confirm-dialog.js +74 -0
- package/dist/utils/effort-confirm-dialog.js.map +1 -0
- package/dist/utils/pending-input-queue.d.ts +56 -0
- package/dist/utils/pending-input-queue.d.ts.map +1 -1
- package/dist/utils/pending-input-queue.js +80 -0
- package/dist/utils/pending-input-queue.js.map +1 -1
- package/dist/utils/render-dimensions.d.ts +8 -0
- package/dist/utils/render-dimensions.d.ts.map +1 -1
- package/dist/utils/render-dimensions.js +10 -0
- package/dist/utils/render-dimensions.js.map +1 -1
- package/dist/utils/stuck-key-guard.d.ts +3 -2
- package/dist/utils/stuck-key-guard.d.ts.map +1 -1
- package/dist/utils/stuck-key-guard.js +3 -3
- package/dist/utils/stuck-key-guard.js.map +1 -1
- package/dist/utils/terminal-renderer.d.ts +6 -0
- package/dist/utils/terminal-renderer.d.ts.map +1 -1
- package/dist/utils/terminal-renderer.js +15 -0
- package/dist/utils/terminal-renderer.js.map +1 -1
- package/dist/utils/tui-input-delivery.d.ts +32 -0
- package/dist/utils/tui-input-delivery.d.ts.map +1 -0
- package/dist/utils/tui-input-delivery.js +53 -0
- package/dist/utils/tui-input-delivery.js.map +1 -0
- package/dist/utils/xml.d.ts +19 -0
- package/dist/utils/xml.d.ts.map +1 -0
- package/dist/utils/xml.js +26 -0
- package/dist/utils/xml.js.map +1 -0
- package/dist/worker.js +1852 -374
- package/dist/worker.js.map +1 -1
- package/dist/workflows/definition.d.ts +8 -8
- package/dist/workflows/events/payloads.d.ts +8 -8
- package/dist/workflows/events/schema.d.ts +160 -160
- package/dist/workflows/v3/session-relay-client.js +1 -1
- package/dist/workflows/v3/session-relay-client.js.map +1 -1
- package/dist/workflows/v3/worker-fence.d.ts.map +1 -1
- package/dist/workflows/v3/worker-fence.js +15 -10
- package/dist/workflows/v3/worker-fence.js.map +1 -1
- package/package.json +1 -1
- package/dist/dashboard-web/chunks/bot-defaults-page-UKA7K4JV.js +0 -10
- package/dist/dashboard-web/chunks/chunk-DFLNXQNT.js +0 -1
- package/dist/dashboard-web/chunks/connectors-page-A77YAB5Y.js +0 -1
- package/dist/dashboard-web/chunks/groups-page-RUZNVXQB.js +0 -8
- package/dist/dashboard-web/chunks/insights-page-RF7U7RTA.js +0 -22
- package/dist/dashboard-web/chunks/roles-page-BUMKD7Y4.js +0 -1
- package/dist/dashboard-web/chunks/sessions-page-6HLSEAB7.js +0 -2
- package/dist/dashboard-web/chunks/settings-page-MJDZHQ4X.js +0 -2
package/dist/core/worker-pool.js
CHANGED
|
@@ -11,16 +11,18 @@ import { fileURLToPath } from 'node:url';
|
|
|
11
11
|
import { ensureSkills, ensureAskSkill, ensurePluginSkills, ensureWhiteboardSkill, removeGlobalBotmuxSkills } from '../skills/installer.js';
|
|
12
12
|
import { shouldInstallGlobalSkills } from '../skills/injection-mode.js';
|
|
13
13
|
import { whiteboardEnabled } from '../services/whiteboard-store.js';
|
|
14
|
+
import { cliSupportsNativeUsage } from '../services/transcript-resolver.js';
|
|
14
15
|
import { cleanupTraexAskHooks, installHook } from '../adapters/hook-installer.js';
|
|
15
16
|
import { hookCommandFor } from '../adapters/hook-command.js';
|
|
16
17
|
import { randomBytes, randomUUID } from 'node:crypto';
|
|
17
18
|
import { config } from '../config.js';
|
|
18
19
|
import * as sessionStore from '../services/session-store.js';
|
|
19
20
|
import * as asyncTriggerStore from '../services/async-trigger-store.js';
|
|
21
|
+
import { markMessageListenerRunPreviewFailed, markMessageListenerRunPreviewReplied, markMessageListenerRunPreviewRunning, } from '../services/message-listener-run-preview-store.js';
|
|
20
22
|
import { persistStreamCardState, rememberLastCliInput } from './session-manager.js';
|
|
21
23
|
import { fallbackTurnId, isSubstituteTurn } from './reply-target.js';
|
|
22
24
|
import { updateMessage, deleteMessage, sendEphemeralCard, sendUserMessage, addReaction, removeReaction, getMessageChatId, MessageWithdrawnError } from '../im/lark/client.js';
|
|
23
|
-
import { buildStreamingCard, buildPrivateSnapshotCard, buildSessionCard, buildTuiPromptCard, buildTuiPromptResolvedCard, buildRelayedFrozenCard, getCliDisplayName } from '../im/lark/card-builder.js';
|
|
25
|
+
import { buildStreamingCard, buildPrivateSnapshotCard, buildSessionCard, buildTuiPromptCard, buildTuiPromptResolvedCard, buildTuiPromptFailedCard, buildRelayedFrozenCard, getCliDisplayName } from '../im/lark/card-builder.js';
|
|
24
26
|
import { loadFrozenCards, saveFrozenCards } from '../services/frozen-card-store.js';
|
|
25
27
|
import { hashUrlForLog, cancelRiffTaskById } from '../adapters/backend/riff-backend.js';
|
|
26
28
|
import { logger } from '../utils/logger.js';
|
|
@@ -29,15 +31,19 @@ import { traeHome } from '../services/traex-paths.js';
|
|
|
29
31
|
import { botLocale, localeForBot, t as tr } from '../i18n/index.js';
|
|
30
32
|
import { claudeJsonlPathForSession } from '../adapters/cli/claude-code.js';
|
|
31
33
|
import { findUniqueClaudeSessionByCwd } from './session-discovery.js';
|
|
32
|
-
import { buildMarkdownCard, buildContextualReplyCard } from '../im/lark/md-card.js';
|
|
34
|
+
import { buildMarkdownCard, buildContextualReplyCard, } from '../im/lark/md-card.js';
|
|
35
|
+
import { getSessionUsageSnapshot } from './cost-calculator.js';
|
|
33
36
|
import { renderBrandTemplate } from '../im/lark/brand-template.js';
|
|
34
37
|
import { replyToDocComment, chunkCommentText, unsubscribeDocFile, removeCommentReaction } from '../im/lark/doc-comment.js';
|
|
35
38
|
import { listDocSubscriptionsForSession, removeDocSubscription } from '../services/doc-subs-store.js';
|
|
36
39
|
import { TmuxBackend } from '../adapters/backend/tmux-backend.js';
|
|
37
40
|
import { HerdrBackend } from '../adapters/backend/herdr-backend.js';
|
|
41
|
+
import { ZmxBackend } from '../adapters/backend/zmx-backend.js';
|
|
42
|
+
import { backendSupportsWebTerminal } from '../adapters/backend/capabilities.js';
|
|
38
43
|
import { sandboxEnabled } from '../adapters/backend/sandbox.js';
|
|
39
|
-
import {
|
|
40
|
-
import {
|
|
44
|
+
import { isStrongManagedHerdrAgentName, managedHerdrAgentName, } from '../adapters/backend/session-backend-selector.js';
|
|
45
|
+
import { isSuspendableBackendType, getSessionPersistentBackendType, persistentBackendTargetForSession, persistentSessionName, killPersistentBackendTarget, killPersistentSession, probePersistentBackendTarget, resolvePairedSpawnBackendType, resolvePersistentBackendTarget } from './persistent-backend.js';
|
|
46
|
+
import { getBot, getAllBots, resolveBrandLabel, getLoadedConfigPath, resolveUsageDisplay } from '../bot-registry.js';
|
|
41
47
|
import { RestartCoordinator } from './restart-coordinator.js';
|
|
42
48
|
import { runtimeBuildIdentity } from '../utils/runtime-build-id.js';
|
|
43
49
|
/** A random id minted once per daemon process (this lifetime). Stamped onto
|
|
@@ -45,6 +51,38 @@ import { runtimeBuildIdentity } from '../utils/runtime-build-id.js';
|
|
|
45
51
|
* distinguishable from a pane surviving a daemon restart (different id). */
|
|
46
52
|
const DAEMON_BOOT_ID = randomUUID();
|
|
47
53
|
const restartCoordinator = new RestartCoordinator();
|
|
54
|
+
const lifecycleRetiringWorkers = new WeakMap();
|
|
55
|
+
const transferRetiringWorkers = new WeakSet();
|
|
56
|
+
function trackLifecycleRetirement(ds, worker) {
|
|
57
|
+
let workers = lifecycleRetiringWorkers.get(ds);
|
|
58
|
+
if (!workers) {
|
|
59
|
+
workers = new Set();
|
|
60
|
+
lifecycleRetiringWorkers.set(ds, workers);
|
|
61
|
+
}
|
|
62
|
+
if (workers.has(worker))
|
|
63
|
+
return;
|
|
64
|
+
workers.add(worker);
|
|
65
|
+
const release = () => {
|
|
66
|
+
const current = lifecycleRetiringWorkers.get(ds);
|
|
67
|
+
current?.delete(worker);
|
|
68
|
+
if (current?.size === 0)
|
|
69
|
+
lifecycleRetiringWorkers.delete(ds);
|
|
70
|
+
};
|
|
71
|
+
worker.once('exit', release);
|
|
72
|
+
}
|
|
73
|
+
function clearLifecycleRetirement(ds, worker) {
|
|
74
|
+
const workers = lifecycleRetiringWorkers.get(ds);
|
|
75
|
+
workers?.delete(worker);
|
|
76
|
+
if (workers?.size === 0)
|
|
77
|
+
lifecycleRetiringWorkers.delete(ds);
|
|
78
|
+
}
|
|
79
|
+
/** Symmetric lifecycle fence: relay must not start while another operation is
|
|
80
|
+
* still restarting, spawning, suspending, or closing this worker generation. */
|
|
81
|
+
export function isSessionLifecycleInFlight(ds) {
|
|
82
|
+
return lifecycleRetiringWorkers.has(ds)
|
|
83
|
+
|| restartCoordinator.activeAttemptId(ds.session.sessionId) !== undefined
|
|
84
|
+
|| (!!ds.worker && !ds.worker.killed && ds.workerReady === false);
|
|
85
|
+
}
|
|
48
86
|
export function getDaemonBootId() {
|
|
49
87
|
return DAEMON_BOOT_ID;
|
|
50
88
|
}
|
|
@@ -62,19 +100,90 @@ function daemonCardLocalHomeLinkMode(ds) {
|
|
|
62
100
|
? 'lexical'
|
|
63
101
|
: 'filesystem';
|
|
64
102
|
}
|
|
103
|
+
/** Read one frozen native-usage snapshot at the reply boundary. Card delivery
|
|
104
|
+
* remains best-effort even when a CLI has no supported transcript or a usage
|
|
105
|
+
* resolver fails. */
|
|
106
|
+
export function getDaemonSessionUsageSnapshot(ds, effectiveCliId, opts) {
|
|
107
|
+
try {
|
|
108
|
+
const resolvedCliId = effectiveCliId ?? (ds.session.cliId
|
|
109
|
+
?? ds.session.adoptedFrom?.cliId
|
|
110
|
+
?? ds.adoptedFrom?.cliId
|
|
111
|
+
?? getBot(ds.larkAppId).config.cliId);
|
|
112
|
+
return getSessionUsageSnapshot({
|
|
113
|
+
cliId: resolvedCliId,
|
|
114
|
+
sessionId: ds.session.sessionId,
|
|
115
|
+
cliSessionId: ds.session.cliSessionId
|
|
116
|
+
?? ds.session.adoptedFrom?.sessionId
|
|
117
|
+
?? ds.adoptedFrom?.sessionId,
|
|
118
|
+
cwd: ds.workingDir
|
|
119
|
+
?? ds.session.workingDir
|
|
120
|
+
?? ds.session.adoptedFrom?.cwd
|
|
121
|
+
?? ds.adoptedFrom?.cwd,
|
|
122
|
+
// Enable the BOT_HOME transcript fallback for CLI-data-redirected /
|
|
123
|
+
// sandboxed bots (same as the ledger and dashboard-row readers). Without
|
|
124
|
+
// it a read-isolated bot's transcript is invisible and the card shows no
|
|
125
|
+
// usage even though it exists under BOT_HOME.
|
|
126
|
+
larkAppId: ds.larkAppId ?? ds.session.larkAppId,
|
|
127
|
+
// Reply cards read at the exact turn boundary (fresh); the live streaming
|
|
128
|
+
// card refreshes on every status tick and rides the reader's reparse
|
|
129
|
+
// throttle instead (fresh:false) to stay off the disk.
|
|
130
|
+
fresh: opts?.fresh ?? true,
|
|
131
|
+
});
|
|
132
|
+
}
|
|
133
|
+
catch (error) {
|
|
134
|
+
logger.warn(`[${ds.session.sessionId.slice(0, 8)}] Failed to read card usage snapshot: `
|
|
135
|
+
+ `${error instanceof Error ? error.message : String(error)}`);
|
|
136
|
+
return { context: null, tokens: null };
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
/** Reply-card (final output / adopt preamble / local-turn) usage. Only the
|
|
140
|
+
* `'footer'` display mode surfaces usage here; `'streaming'` and `'off'` yield a
|
|
141
|
+
* concrete empty snapshot. Keeping the display decision out of the native usage
|
|
142
|
+
* reader leaves accounting and dashboard consumers intact; the concrete empty
|
|
143
|
+
* snapshot (rather than undefined) also freezes "hidden" over final-output
|
|
144
|
+
* retries. */
|
|
145
|
+
export function getDaemonReplyCardUsageSnapshot(ds, effectiveCliId) {
|
|
146
|
+
try {
|
|
147
|
+
if (resolveUsageDisplay(ds.larkAppId) !== 'footer') {
|
|
148
|
+
return { context: null, tokens: null };
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
catch {
|
|
152
|
+
// Missing runtime config → default 'streaming' → no footer usage.
|
|
153
|
+
return { context: null, tokens: null };
|
|
154
|
+
}
|
|
155
|
+
return getDaemonSessionUsageSnapshot(ds, effectiveCliId);
|
|
156
|
+
}
|
|
157
|
+
/** Streaming-card usage. Only the `'streaming'` display mode (the default)
|
|
158
|
+
* surfaces usage in the live card body; `'footer'` and `'off'` yield empty.
|
|
159
|
+
* Returns a concrete empty snapshot on any config failure so the streaming
|
|
160
|
+
* renderer stays best-effort. `fresh` forces an exact read at meaningful
|
|
161
|
+
* boundaries (turn end / idle); intra-turn ticks leave it false to ride the
|
|
162
|
+
* reader's reparse throttle and stay off the disk. */
|
|
163
|
+
export function getDaemonStreamingCardUsageSnapshot(ds, effectiveCliId, opts) {
|
|
164
|
+
try {
|
|
165
|
+
if (resolveUsageDisplay(ds.larkAppId) !== 'streaming') {
|
|
166
|
+
return { context: null, tokens: null };
|
|
167
|
+
}
|
|
168
|
+
}
|
|
169
|
+
catch {
|
|
170
|
+
// Missing runtime config → default 'streaming' → show usage (best-effort).
|
|
171
|
+
}
|
|
172
|
+
return getDaemonSessionUsageSnapshot(ds, effectiveCliId, { fresh: opts?.fresh ?? false });
|
|
173
|
+
}
|
|
65
174
|
import { normalizeBrand } from '../im/lark/lark-hosts.js';
|
|
66
175
|
import { dashboardEventBus } from './dashboard-events.js';
|
|
67
176
|
import { composeRowFromActive, composeRowFromClosed } from './dashboard-rows.js';
|
|
68
|
-
import { publishAttentionPatch } from './session-activity.js';
|
|
177
|
+
import { publishAttentionPatch, publishClosedSessionPatch } from './session-activity.js';
|
|
69
178
|
import { knownBotOpenIdsFromCrossRef } from '../utils/bot-routing.js';
|
|
70
179
|
import { emitSessionLifecycleHook, emitSessionStateTransitionHook } from '../services/session-lifecycle-hooks.js';
|
|
71
180
|
import { anchorUsageForDaemonSession, recordOwnershipForDaemonSession, recordUsageForDaemonSession, reconcileUsageForDaemonSession } from '../services/usage-ledger.js';
|
|
72
181
|
import { isStructuredBridgeAdoptCli } from '../services/structured-bridge-clis.js';
|
|
73
182
|
import { ensureGatewayEntry } from './plugins/mcp/gateway-installer.js';
|
|
74
|
-
import { activeSessionKey, sessionKey, sessionAnchorId, isDocNativeSession } from './types.js';
|
|
183
|
+
import { activeSessionKey, sessionKey, sessionAnchorId, storedSessionAnchorId, isDocNativeSession, larkTransportEnabled } from './types.js';
|
|
75
184
|
import { DONE_REACTION_EMOJI_TYPE } from './pending-response.js';
|
|
76
185
|
import { buildTerminalUrl } from './terminal-url.js';
|
|
77
|
-
import { prependBotmuxBin } from './botmux-wrapper.js';
|
|
186
|
+
import { prependBotmuxBin, resolveBotmuxWrapperBinDir } from './botmux-wrapper.js';
|
|
78
187
|
import { usageLimitStateKey } from '../utils/cli-usage-limit.js';
|
|
79
188
|
import { evaluateVcMeetingManagedSend, resolveVcMeetingImTurnOrigin, } from '../services/vc-meeting-send-policy.js';
|
|
80
189
|
import { finishVcMeetingImReply, prepareVcMeetingDeliveryReply, prepareVcMeetingImReply, } from '../services/vc-meeting-im-reply.js';
|
|
@@ -86,14 +195,20 @@ import { isLocalCliOpenEnabled, isLocalCliOpenReady } from '../services/local-cl
|
|
|
86
195
|
import { isSilentScheduledTurn } from './silent-schedule-turns.js';
|
|
87
196
|
import { isTriggerFinalSuppressed } from './trigger-final-suppression.js';
|
|
88
197
|
import { writeDeferredTopicBinding } from './deferred-topic-binding.js';
|
|
89
|
-
import { deferWorkerSpawnDuringDeviceIsolation } from './device-isolation-activation.js';
|
|
198
|
+
import { currentDeviceIsolationFreezeLease, deferWorkerSpawnDuringDeviceIsolation, } from './device-isolation-activation.js';
|
|
90
199
|
import { buildBotmuxLarkNativeSessionTitle, extractBotmuxLarkNativeSessionTitlePrompt, } from './session-title.js';
|
|
91
200
|
import { acknowledgeSessionReady } from './session-ready-handshake.js';
|
|
92
201
|
import { recordDispatchInputCommit } from './dispatch.js';
|
|
202
|
+
import { sendWorkerIpc } from './worker-ipc.js';
|
|
93
203
|
const __filename = fileURLToPath(import.meta.url);
|
|
94
204
|
const __dirname = dirname(__filename);
|
|
95
205
|
const WORKER_SIGTERM_BACKSTOP_MS = 2_000;
|
|
96
206
|
const WORKER_SIGKILL_BACKSTOP_MS = 7_000;
|
|
207
|
+
const CLOSE_FENCE_WARN_MS = 8_000;
|
|
208
|
+
// Peer relay waits 5s for the whole transfer request. Tmux observer detach has
|
|
209
|
+
// its own 3s command timeout, so this fence leaves headroom for routing/fork.
|
|
210
|
+
const TRANSFER_DETACH_FENCE_MS = 3_500;
|
|
211
|
+
const TRANSFER_FORCE_EXIT_MS = 500;
|
|
97
212
|
const WORKER_REDACTED_ENV_KEYS = ['GITHUB_TOKEN', 'GH_TOKEN'];
|
|
98
213
|
function workerForkEnv(base) {
|
|
99
214
|
const env = { ...base };
|
|
@@ -186,6 +301,18 @@ export function isRelayableRealSession(ds) {
|
|
|
186
301
|
return true;
|
|
187
302
|
return false;
|
|
188
303
|
}
|
|
304
|
+
/** A worker-less row that never represented a CLI and carries no deferred
|
|
305
|
+
* user intent. Only this narrow class is safe to evict as command scaffolding. */
|
|
306
|
+
export function isDisposableCommandScratch(ds) {
|
|
307
|
+
return !ds.worker
|
|
308
|
+
&& !ds.pendingRepo
|
|
309
|
+
&& ds.pendingPrompt === undefined
|
|
310
|
+
&& ds.pendingRawInput === undefined
|
|
311
|
+
&& !ds.adoptedFrom
|
|
312
|
+
&& !ds.session.adoptedFrom
|
|
313
|
+
&& !ds.session.queued
|
|
314
|
+
&& !isRelayableRealSession(ds);
|
|
315
|
+
}
|
|
189
316
|
// Per-bot opt-out: when true, botmux never posts/patches the live streaming
|
|
190
317
|
// session card. Read fresh from the in-memory registry so a dashboard toggle
|
|
191
318
|
// takes effect without a daemon restart. The `/card` command can override it
|
|
@@ -223,11 +350,34 @@ function doneReactionEmojiFor(ds) {
|
|
|
223
350
|
return DONE_REACTION_EMOJI_TYPE;
|
|
224
351
|
}
|
|
225
352
|
}
|
|
353
|
+
/** Worker lifecycle readiness is independent from Web Terminal availability.
|
|
354
|
+
* Legacy/test sessions predate workerReady, so retain the old port inference
|
|
355
|
+
* only while the explicit flag is absent. */
|
|
356
|
+
export function workerHasInitialized(ds) {
|
|
357
|
+
return ds.workerReady === true
|
|
358
|
+
|| (ds.workerReady === undefined
|
|
359
|
+
&& sessionSupportsWebTerminal(ds)
|
|
360
|
+
&& !!(ds.workerPort ?? ds.session.webPort));
|
|
361
|
+
}
|
|
362
|
+
/** Capability of the backend frozen onto this worker/session generation. */
|
|
363
|
+
export function sessionSupportsWebTerminal(ds) {
|
|
364
|
+
const backendType = ds.initConfig?.backendType ?? ds.session.backendType;
|
|
365
|
+
// Pre-backend-stamp sessions are legacy tmux sessions and retain Web TUI.
|
|
366
|
+
return backendType === undefined || backendSupportsWebTerminal(backendType);
|
|
367
|
+
}
|
|
368
|
+
/** Empty means this session intentionally has no Web Terminal surface. */
|
|
369
|
+
export function readableTerminalUrlFor(ds) {
|
|
370
|
+
return sessionSupportsWebTerminal(ds) && (ds.workerPort ?? ds.session.webPort)
|
|
371
|
+
? buildTerminalUrl(ds)
|
|
372
|
+
: '';
|
|
373
|
+
}
|
|
226
374
|
// Per-bot opt-in: the writable terminal link to embed directly in the streaming
|
|
227
375
|
// card body (token included). Returns undefined unless the bot enabled it AND
|
|
228
376
|
// the worker port/token are known. Exported for card-handler's re-renders so the
|
|
229
377
|
// link stays put across button-driven card updates.
|
|
230
378
|
export function writableTerminalLinkFor(ds) {
|
|
379
|
+
if (!sessionSupportsWebTerminal(ds))
|
|
380
|
+
return undefined;
|
|
231
381
|
try {
|
|
232
382
|
if (getBot(ds.larkAppId).config.writableTerminalLinkInCard !== true)
|
|
233
383
|
return undefined;
|
|
@@ -251,13 +401,13 @@ function scheduleLocalCliOpenReadinessPatch(ds) {
|
|
|
251
401
|
ds.pendingLocalCliButtonRefresh = true;
|
|
252
402
|
return;
|
|
253
403
|
}
|
|
254
|
-
if (!ds.streamCardId || !ds
|
|
404
|
+
if (!ds.streamCardId || !workerHasInitialized(ds))
|
|
255
405
|
return;
|
|
256
406
|
ds.pendingLocalCliButtonRefresh = undefined;
|
|
257
407
|
const botCfg = getBot(ds.larkAppId).config;
|
|
258
408
|
const effectiveCliId = sessionCliId(ds, botCfg);
|
|
259
409
|
const status = ds.usageLimit ? 'limited' : (ds.lastScreenStatus ?? 'starting');
|
|
260
|
-
const cardJson = buildStreamingCard(ds.session.sessionId, sessionAnchorId(ds),
|
|
410
|
+
const cardJson = buildStreamingCard(ds.session.sessionId, sessionAnchorId(ds), readableTerminalUrlFor(ds), ds.currentTurnTitle || ds.session.title || getCliDisplayName(effectiveCliId), ds.lastScreenContent ?? '', status, effectiveCliId, ds.displayMode ?? 'hidden', ds.streamCardNonce, ds.currentImageKey, !!ds.adoptedFrom, false, localeForBot(ds.larkAppId), status === 'limited' ? ds.usageLimit : undefined, writableTerminalLinkFor(ds), isLocalCliOpenReady(ds, { cliId: effectiveCliId }), getDaemonStreamingCardUsageSnapshot(ds, effectiveCliId));
|
|
261
411
|
scheduleCardPatch(ds, cardJson);
|
|
262
412
|
}
|
|
263
413
|
function flushPendingLocalCliOpenReadinessPatch(ds) {
|
|
@@ -266,6 +416,100 @@ function flushPendingLocalCliOpenReadinessPatch(ds) {
|
|
|
266
416
|
ds.pendingLocalCliButtonRefresh = undefined;
|
|
267
417
|
scheduleLocalCliOpenReadinessPatch(ds);
|
|
268
418
|
}
|
|
419
|
+
/** How often the live streaming card is re-PATCHed with fresh usage while a
|
|
420
|
+
* turn executes. 12s stays off the prompt-cache-friendly path; the tick reads
|
|
421
|
+
* with fresh:true so it bypasses (does not merely out-wait) the usage reader's
|
|
422
|
+
* 15s reparse throttle — the transcript grows per tool step, so each refresh
|
|
423
|
+
* folds only the newly appended bytes. */
|
|
424
|
+
export const USAGE_REFRESH_INTERVAL_MS = 12_000;
|
|
425
|
+
/** Single source of truth for "this session should be periodically re-rendering
|
|
426
|
+
* its streaming card with fresh usage right now". Used by both the arm gate and
|
|
427
|
+
* the interval tick so arm/clear is a state-boundary invariant, not tied to one
|
|
428
|
+
* PATCH path. Requires: an actively-working turn, a live (non-sentinel, not a
|
|
429
|
+
* new-turn handoff) streaming card, the worker initialized (NOT a Web-Terminal
|
|
430
|
+
* port — ZMX reports ready with port=0), usageDisplay='streaming', and a CLI
|
|
431
|
+
* that actually has a native-usage transcript (gemini/opencode/pi/… have none
|
|
432
|
+
* → nothing to show). */
|
|
433
|
+
export function usageRefreshShouldRun(ds) {
|
|
434
|
+
if (ds.lastScreenStatus !== 'working')
|
|
435
|
+
return false;
|
|
436
|
+
if (streamingCardDisabled(ds) || ds.suppressRecoveryCard)
|
|
437
|
+
return false;
|
|
438
|
+
// New-turn handoff window: beginNewTurn sets streamCardPending=true and swaps
|
|
439
|
+
// currentTurnTitle while the OLD streamCardId is still present (the live
|
|
440
|
+
// worker is still `working`). A stray tick here would PATCH the previous
|
|
441
|
+
// card with the NEW turn's title/content before the next screen_update's
|
|
442
|
+
// managed/silent gate runs. The POST-in-flight sentinel is also covered.
|
|
443
|
+
if (ds.streamCardPending)
|
|
444
|
+
return false;
|
|
445
|
+
if (!ds.streamCardId || ds.streamCardId === CARD_POSTING_SENTINEL)
|
|
446
|
+
return false;
|
|
447
|
+
if (!workerHasInitialized(ds))
|
|
448
|
+
return false;
|
|
449
|
+
if (!cliSupportsNativeUsage(sessionCliId(ds, getBot(ds.larkAppId).config)))
|
|
450
|
+
return false;
|
|
451
|
+
try {
|
|
452
|
+
if (resolveUsageDisplay(ds.larkAppId) !== 'streaming')
|
|
453
|
+
return false;
|
|
454
|
+
}
|
|
455
|
+
catch { /* missing config → default streaming → allowed */ }
|
|
456
|
+
return true;
|
|
457
|
+
}
|
|
458
|
+
/** Re-render the current streaming card with the freshest usage snapshot. The
|
|
459
|
+
* interval tick is self-correcting: if the session no longer qualifies (turn
|
|
460
|
+
* settled, card gone, limit, etc.) it clears its own timer, bounding any missed
|
|
461
|
+
* explicit clear to a single interval. Reads with fresh:true so the periodic
|
|
462
|
+
* PATCH actually beats the cost reader's 15s reparse throttle. */
|
|
463
|
+
export function refreshStreamingCardUsage(ds) {
|
|
464
|
+
if (!usageRefreshShouldRun(ds)) {
|
|
465
|
+
clearUsageRefreshTimer(ds);
|
|
466
|
+
return;
|
|
467
|
+
}
|
|
468
|
+
const botCfg = getBot(ds.larkAppId).config;
|
|
469
|
+
const effectiveCliId = sessionCliId(ds, botCfg);
|
|
470
|
+
const cardJson = buildStreamingCard(ds.session.sessionId, sessionAnchorId(ds),
|
|
471
|
+
// readableTerminalUrlFor (NOT raw buildTerminalUrl): now that this tick can
|
|
472
|
+
// run for a port=0 backend (ZMX reports ready without a Web Terminal), a raw
|
|
473
|
+
// URL would render a fake `:undefined`/backend-less link. Mirror every other
|
|
474
|
+
// card path — empty string when there is no real terminal.
|
|
475
|
+
readableTerminalUrlFor(ds), ds.currentTurnTitle || ds.session.title || getCliDisplayName(effectiveCliId), ds.lastScreenContent ?? '', ds.lastScreenStatus ?? 'working', effectiveCliId, ds.displayMode ?? 'hidden', ds.streamCardNonce, ds.currentImageKey, !!ds.adoptedFrom, false, localeForBot(ds.larkAppId), cardUsageLimit(ds), writableTerminalLinkFor(ds), isLocalCliOpenReady(ds, { cliId: effectiveCliId }),
|
|
476
|
+
// fresh:true — the whole point of the tick is to break the 15s throttle so
|
|
477
|
+
// the total/turn usage actually climbs on-screen every interval.
|
|
478
|
+
getDaemonStreamingCardUsageSnapshot(ds, effectiveCliId, { fresh: true }));
|
|
479
|
+
scheduleCardPatch(ds, cardJson);
|
|
480
|
+
}
|
|
481
|
+
/** Bring the periodic usage refresh in line with current state — arm when the
|
|
482
|
+
* session qualifies, clear otherwise. Idempotent; safe to call after ANY
|
|
483
|
+
* lastScreenStatus assignment or streaming-card lifecycle change. This is the
|
|
484
|
+
* single choke point that makes the timer a state-boundary invariant. */
|
|
485
|
+
export function syncUsageRefreshTimer(ds) {
|
|
486
|
+
if (usageRefreshShouldRun(ds))
|
|
487
|
+
armUsageRefreshTimer(ds);
|
|
488
|
+
else
|
|
489
|
+
clearUsageRefreshTimer(ds);
|
|
490
|
+
}
|
|
491
|
+
/** Arm the periodic usage refresh. Idempotent — a running timer is left in
|
|
492
|
+
* place. Callers gate via syncUsageRefreshTimer/usageRefreshShouldRun; this
|
|
493
|
+
* just owns the setInterval. */
|
|
494
|
+
function armUsageRefreshTimer(ds) {
|
|
495
|
+
if (ds.usageRefreshTimer)
|
|
496
|
+
return;
|
|
497
|
+
ds.usageRefreshTimer = setInterval(() => {
|
|
498
|
+
try {
|
|
499
|
+
refreshStreamingCardUsage(ds);
|
|
500
|
+
}
|
|
501
|
+
catch { /* best-effort */ }
|
|
502
|
+
}, USAGE_REFRESH_INTERVAL_MS);
|
|
503
|
+
// Don't keep the daemon event loop alive solely for this cosmetic refresh.
|
|
504
|
+
ds.usageRefreshTimer.unref?.();
|
|
505
|
+
}
|
|
506
|
+
/** Stop the periodic usage refresh (turn ended, card gone, session closing). */
|
|
507
|
+
function clearUsageRefreshTimer(ds) {
|
|
508
|
+
if (ds.usageRefreshTimer) {
|
|
509
|
+
clearInterval(ds.usageRefreshTimer);
|
|
510
|
+
ds.usageRefreshTimer = undefined;
|
|
511
|
+
}
|
|
512
|
+
}
|
|
269
513
|
/**
|
|
270
514
|
* PATCH the live streaming card with the freshest riff sandbox URL. Mirrors
|
|
271
515
|
* {@link scheduleLocalCliOpenReadinessPatch}: when the card POST is still
|
|
@@ -290,7 +534,7 @@ export function scheduleRiffAccessUrlPatch(ds) {
|
|
|
290
534
|
const botCfg = getBot(ds.larkAppId).config;
|
|
291
535
|
const effectiveCliId = sessionCliId(ds, botCfg);
|
|
292
536
|
const status = ds.usageLimit ? 'limited' : (ds.lastScreenStatus ?? 'starting');
|
|
293
|
-
const cardJson = buildStreamingCard(ds.session.sessionId, sessionAnchorId(ds), buildTerminalUrl(ds), ds.currentTurnTitle || ds.session.title || getCliDisplayName(effectiveCliId), ds.lastScreenContent ?? '', status, effectiveCliId, ds.displayMode ?? 'hidden', ds.streamCardNonce, ds.currentImageKey, !!ds.adoptedFrom, false, localeForBot(ds.larkAppId), status === 'limited' ? ds.usageLimit : undefined, writableTerminalLinkFor(ds), isLocalCliOpenReady(ds, { cliId: effectiveCliId }));
|
|
537
|
+
const cardJson = buildStreamingCard(ds.session.sessionId, sessionAnchorId(ds), buildTerminalUrl(ds), ds.currentTurnTitle || ds.session.title || getCliDisplayName(effectiveCliId), ds.lastScreenContent ?? '', status, effectiveCliId, ds.displayMode ?? 'hidden', ds.streamCardNonce, ds.currentImageKey, !!ds.adoptedFrom, false, localeForBot(ds.larkAppId), status === 'limited' ? ds.usageLimit : undefined, writableTerminalLinkFor(ds), isLocalCliOpenReady(ds, { cliId: effectiveCliId }), getDaemonStreamingCardUsageSnapshot(ds, effectiveCliId));
|
|
294
538
|
scheduleCardPatch(ds, cardJson);
|
|
295
539
|
}
|
|
296
540
|
function flushPendingRiffUrlPatch(ds) {
|
|
@@ -336,6 +580,7 @@ function sessionAgentConfig(ds, botCfg) {
|
|
|
336
580
|
cliPathOverride: ds.session.cliPathOverride,
|
|
337
581
|
wrapperCli: ds.session.wrapperCli,
|
|
338
582
|
model: ds.session.model,
|
|
583
|
+
reasoningEffort: ds.session.reasoningEffort,
|
|
339
584
|
};
|
|
340
585
|
}
|
|
341
586
|
function loadKnownBotOpenIdsForApp(larkAppId) {
|
|
@@ -410,14 +655,13 @@ function scheduleUsageLimitCardPatch(ds) {
|
|
|
410
655
|
return;
|
|
411
656
|
if (ds.lastScreenStatus !== 'limited')
|
|
412
657
|
return;
|
|
413
|
-
|
|
414
|
-
if (!ds.streamCardId || ds.streamCardId === CARD_POSTING_SENTINEL || !port)
|
|
658
|
+
if (!ds.streamCardId || ds.streamCardId === CARD_POSTING_SENTINEL || !workerHasInitialized(ds))
|
|
415
659
|
return;
|
|
416
660
|
const bot = getBot(ds.larkAppId);
|
|
417
661
|
const effectiveCliId = sessionCliId(ds, bot.config);
|
|
418
|
-
const readUrl =
|
|
662
|
+
const readUrl = readableTerminalUrlFor(ds);
|
|
419
663
|
const turnTitle = ds.currentTurnTitle || ds.session.title || getCliDisplayName(effectiveCliId);
|
|
420
|
-
const cardJson = buildStreamingCard(ds.session.sessionId, sessionAnchorId(ds), readUrl, turnTitle, ds.lastScreenContent ?? '', 'limited', effectiveCliId, ds.displayMode ?? 'hidden', ds.streamCardNonce, ds.currentImageKey, !!ds.adoptedFrom, false, localeForBot(ds.larkAppId), ds.usageLimit, writableTerminalLinkFor(ds), isLocalCliOpenReady(ds, { cliId: effectiveCliId }));
|
|
664
|
+
const cardJson = buildStreamingCard(ds.session.sessionId, sessionAnchorId(ds), readUrl, turnTitle, ds.lastScreenContent ?? '', 'limited', effectiveCliId, ds.displayMode ?? 'hidden', ds.streamCardNonce, ds.currentImageKey, !!ds.adoptedFrom, false, localeForBot(ds.larkAppId), ds.usageLimit, writableTerminalLinkFor(ds), isLocalCliOpenReady(ds, { cliId: effectiveCliId }), getDaemonStreamingCardUsageSnapshot(ds, effectiveCliId));
|
|
421
665
|
scheduleCardPatch(ds, cardJson);
|
|
422
666
|
}
|
|
423
667
|
function armUsageLimitRetryTimer(ds, previous) {
|
|
@@ -556,8 +800,8 @@ export function recallFrozenCards(ds) {
|
|
|
556
800
|
* manually summon a live card in an otherwise-quiet session. Parks the current
|
|
557
801
|
* card (if any) first so `recallFrozenCards` withdraws it once the fresh one
|
|
558
802
|
* lands — the thread ends up with a single live card. Returns false when the
|
|
559
|
-
* worker
|
|
560
|
-
*
|
|
803
|
+
* worker isn't initialized yet, so the caller can surface a friendly "not
|
|
804
|
+
* ready" message. A ready backend without Web Terminal still gets the card.
|
|
561
805
|
*
|
|
562
806
|
* Note: this does NOT itself flip `ds.streamingCardForced` — the caller sets
|
|
563
807
|
* that so the card keeps live-patching afterwards even when the bot opted out.
|
|
@@ -569,12 +813,11 @@ export async function postFreshStreamingCard(ds, sessionReply) {
|
|
|
569
813
|
return false;
|
|
570
814
|
if (isDocNativeSession(ds))
|
|
571
815
|
return false;
|
|
572
|
-
|
|
573
|
-
if (!port)
|
|
816
|
+
if (!workerHasInitialized(ds))
|
|
574
817
|
return false;
|
|
575
818
|
const botCfg = getBot(ds.larkAppId).config;
|
|
576
819
|
const effectiveCliId = sessionCliId(ds, botCfg);
|
|
577
|
-
const readUrl =
|
|
820
|
+
const readUrl = readableTerminalUrlFor(ds);
|
|
578
821
|
const title = ds.currentTurnTitle || ds.session.title || getCliDisplayName(effectiveCliId);
|
|
579
822
|
const status = ds.lastScreenStatus ?? 'idle';
|
|
580
823
|
// Park the current card (no-op when there's none) so the fresh one replaces
|
|
@@ -586,7 +829,7 @@ export async function postFreshStreamingCard(ds, sessionReply) {
|
|
|
586
829
|
const prevNonce = ds.streamCardNonce;
|
|
587
830
|
const prevPending = ds.streamCardPending;
|
|
588
831
|
ds.streamCardNonce = randomBytes(4).toString('hex');
|
|
589
|
-
const cardJson = buildStreamingCard(ds.session.sessionId, sessionAnchorId(ds), readUrl, title, ds.lastScreenContent ?? '', status, effectiveCliId, ds.displayMode ?? 'hidden', ds.streamCardNonce, ds.currentImageKey, !!ds.adoptedFrom, false, localeForBot(ds.larkAppId), cardUsageLimit(ds), writableTerminalLinkFor(ds), isLocalCliOpenReady(ds, { cliId: effectiveCliId }));
|
|
832
|
+
const cardJson = buildStreamingCard(ds.session.sessionId, sessionAnchorId(ds), readUrl, title, ds.lastScreenContent ?? '', status, effectiveCliId, ds.displayMode ?? 'hidden', ds.streamCardNonce, ds.currentImageKey, !!ds.adoptedFrom, false, localeForBot(ds.larkAppId), cardUsageLimit(ds), writableTerminalLinkFor(ds), isLocalCliOpenReady(ds, { cliId: effectiveCliId }), getDaemonStreamingCardUsageSnapshot(ds, effectiveCliId));
|
|
590
833
|
ds.streamCardId = CARD_POSTING_SENTINEL;
|
|
591
834
|
try {
|
|
592
835
|
ds.streamCardId = await sessionReply(sessionAnchorId(ds), cardJson, 'interactive', ds.larkAppId, ds.currentReplyTarget?.turnId);
|
|
@@ -599,6 +842,10 @@ export async function postFreshStreamingCard(ds, sessionReply) {
|
|
|
599
842
|
recallFrozenCards(ds);
|
|
600
843
|
flushPendingLocalCliOpenReadinessPatch(ds);
|
|
601
844
|
flushPendingRiffUrlPatch(ds);
|
|
845
|
+
// Manual /card during a working turn lands a live card whose subsequent
|
|
846
|
+
// screen_updates are working→working (no status edge) — arm the periodic
|
|
847
|
+
// usage refresh here, now that the real id is committed and pending cleared.
|
|
848
|
+
syncUsageRefreshTimer(ds);
|
|
602
849
|
logger.info(`[${tag(ds)}] Posted streaming card via /card`);
|
|
603
850
|
return true;
|
|
604
851
|
}
|
|
@@ -608,6 +855,9 @@ export async function postFreshStreamingCard(ds, sessionReply) {
|
|
|
608
855
|
ds.streamCardPending = prevPending;
|
|
609
856
|
flushPendingLocalCliOpenReadinessPatch(ds);
|
|
610
857
|
flushPendingRiffUrlPatch(ds);
|
|
858
|
+
// Rolled back to the prior card identity — re-sync so a restored still-live
|
|
859
|
+
// working card keeps (or resumes) its refresh rather than losing the timer.
|
|
860
|
+
syncUsageRefreshTimer(ds);
|
|
611
861
|
logger.warn(`[${tag(ds)}] /card POST failed: ${err}`);
|
|
612
862
|
return false;
|
|
613
863
|
}
|
|
@@ -637,12 +887,11 @@ export function resolvePrivateCardAudience(ds) {
|
|
|
637
887
|
* the audience list into the chat.
|
|
638
888
|
*/
|
|
639
889
|
export async function postPrivateSnapshotCard(ds, audience) {
|
|
640
|
-
|
|
641
|
-
if (!port)
|
|
890
|
+
if (!workerHasInitialized(ds))
|
|
642
891
|
return { sent: 0, total: audience.length, notReady: true };
|
|
643
892
|
const botCfg = getBot(ds.larkAppId).config;
|
|
644
893
|
const effectiveCliId = sessionCliId(ds, botCfg);
|
|
645
|
-
const readUrl =
|
|
894
|
+
const readUrl = readableTerminalUrlFor(ds);
|
|
646
895
|
const title = ds.currentTurnTitle || ds.session.title || getCliDisplayName(effectiveCliId);
|
|
647
896
|
const status = ds.lastScreenStatus ?? 'idle';
|
|
648
897
|
const cardJson = buildPrivateSnapshotCard(readUrl, title, status, effectiveCliId, ds.currentImageKey, ds.lastScreenContent ?? '', ds.session.sessionId, sessionAnchorId(ds), localeForBot(ds.larkAppId), cardUsageLimit(ds));
|
|
@@ -716,6 +965,8 @@ export async function deliverWriteLinkCard(ds, operatorOpenId, cardJson) {
|
|
|
716
965
|
* ({@link deliverWritableTerminalCardTo}, behind the `/term` slash command).
|
|
717
966
|
*/
|
|
718
967
|
export function buildWritableTerminalCard(ds) {
|
|
968
|
+
if (!sessionSupportsWebTerminal(ds))
|
|
969
|
+
return null;
|
|
719
970
|
// Riff backend: the sandbox URL is the writable link — no local worker/token needed.
|
|
720
971
|
if (ds.riffAccessUrl) {
|
|
721
972
|
const botCfg = getBot(ds.larkAppId).config;
|
|
@@ -744,6 +995,9 @@ export function buildWritableTerminalCard(ds) {
|
|
|
744
995
|
* these private channels — it is never returned to the CLI caller / stdout.
|
|
745
996
|
*/
|
|
746
997
|
export async function deliverWriteLinkCardToOwners(ds) {
|
|
998
|
+
if (!sessionSupportsWebTerminal(ds)) {
|
|
999
|
+
return { ok: false, error: 'terminal_unsupported', delivered: 0, total: 0, channels: [] };
|
|
1000
|
+
}
|
|
747
1001
|
const cardJson = buildWritableTerminalCard(ds);
|
|
748
1002
|
if (!cardJson)
|
|
749
1003
|
return { ok: false, error: 'terminal_unavailable', delivered: 0, total: 0, channels: [] };
|
|
@@ -773,19 +1027,40 @@ export async function deliverWriteLinkCardToOwners(ds) {
|
|
|
773
1027
|
* 'not_ready' when the terminal isn't up yet, else the channel actually used.
|
|
774
1028
|
*/
|
|
775
1029
|
export async function deliverWritableTerminalCardTo(ds, operatorOpenId) {
|
|
1030
|
+
if (!sessionSupportsWebTerminal(ds))
|
|
1031
|
+
return 'unsupported';
|
|
776
1032
|
const cardJson = buildWritableTerminalCard(ds);
|
|
777
1033
|
if (!cardJson)
|
|
778
1034
|
return 'not_ready';
|
|
779
1035
|
return deliverWriteLinkCard(ds, operatorOpenId, cardJson);
|
|
780
1036
|
}
|
|
781
1037
|
/**
|
|
782
|
-
*
|
|
1038
|
+
* Build the private owner control card used by substitute-mode sessions.
|
|
1039
|
+
* Web-capable backends keep the writable-terminal card; backends without a
|
|
1040
|
+
* Web Terminal (currently ZMX) still need restart / close controls.
|
|
1041
|
+
*/
|
|
1042
|
+
function buildSubstituteControlCard(ds) {
|
|
1043
|
+
const writableCard = buildWritableTerminalCard(ds);
|
|
1044
|
+
if (writableCard)
|
|
1045
|
+
return writableCard;
|
|
1046
|
+
// A Web-capable backend returning null simply is not ready yet. Preserve the
|
|
1047
|
+
// existing retry-on-next-ready behavior instead of sending a partial card.
|
|
1048
|
+
if (sessionSupportsWebTerminal(ds))
|
|
1049
|
+
return null;
|
|
1050
|
+
const botCfg = getBot(ds.larkAppId).config;
|
|
1051
|
+
const effectiveCliId = sessionCliId(ds, botCfg);
|
|
1052
|
+
return buildSessionCard(ds.session.sessionId, sessionAnchorId(ds), '', // Manage-only: this backend intentionally has no Web Terminal URL.
|
|
1053
|
+
ds.session.title || getCliDisplayName(effectiveCliId), effectiveCliId, true, !!ds.adoptedFrom, localeForBot(ds.larkAppId), isLocalCliOpenReady(ds, { cliId: effectiveCliId }));
|
|
1054
|
+
}
|
|
1055
|
+
/**
|
|
1056
|
+
* DM a control card to the bot's owner(s) for a substitute-mode session.
|
|
1057
|
+
* ZMX receives a manage-only card because it has no Web Terminal surface.
|
|
783
1058
|
* Guards against duplicate sends via `session.substituteControlCardSent`.
|
|
784
1059
|
*/
|
|
785
1060
|
export async function deliverSubstituteControlCard(ds) {
|
|
786
1061
|
if (ds.session.substituteControlCardSent)
|
|
787
1062
|
return { sent: 0, total: 0 };
|
|
788
|
-
const cardJson =
|
|
1063
|
+
const cardJson = buildSubstituteControlCard(ds);
|
|
789
1064
|
if (!cardJson) {
|
|
790
1065
|
logger.warn(`[${tag(ds)}] substitute control card skipped: terminal not ready`);
|
|
791
1066
|
return { sent: 0, total: 0 };
|
|
@@ -863,6 +1138,12 @@ export async function deliverEphemeralOrReply(ds, operatorOpenId, content, msgTy
|
|
|
863
1138
|
* any previously queued value — only the latest state matters).
|
|
864
1139
|
*/
|
|
865
1140
|
export function scheduleCardPatch(ds, cardJson, turnId) {
|
|
1141
|
+
// Defense-in-depth transport gate: a no-transport session (apiOnly bot or HTTP
|
|
1142
|
+
// virtual chat) has no real Feishu card to PATCH. Callers already suppress via
|
|
1143
|
+
// managedAuxUiSuppressed, but guarding the flush entry too means a stray direct
|
|
1144
|
+
// call can never dial updateMessage on a synthetic id.
|
|
1145
|
+
if (!larkTransportEnabled({ chatId: ds.chatId, apiOnly: getBot(ds.larkAppId).config.apiOnly }))
|
|
1146
|
+
return;
|
|
866
1147
|
// Bot opted out of the streaming card — never patch one into existence.
|
|
867
1148
|
// Turn-exact when the caller has turn context (screen updates): a substitute
|
|
868
1149
|
// turn arriving mid-PATCH must not suppress a normal turn's card (or vice
|
|
@@ -1104,6 +1385,8 @@ export function ensureClaudeFolderTrust(workingDir, stateJsonPath = join(homedir
|
|
|
1104
1385
|
export function killWorker(ds) {
|
|
1105
1386
|
restartCoordinator.cancelSession(ds.session.sessionId);
|
|
1106
1387
|
clearUsageLimitState(ds);
|
|
1388
|
+
ds.workerReady = false;
|
|
1389
|
+
clearUsageRefreshTimer(ds);
|
|
1107
1390
|
ds.localProcessAttestation = undefined;
|
|
1108
1391
|
// A managed-turn capability belongs to one concrete worker generation.
|
|
1109
1392
|
// Retiring (or observing the absence of) that generation must revoke the
|
|
@@ -1114,9 +1397,10 @@ export function killWorker(ds) {
|
|
|
1114
1397
|
// must be invalidated so a late click cannot inject keys into a replacement
|
|
1115
1398
|
// worker (or into nothing, if no replacement comes).
|
|
1116
1399
|
invalidateStuckWarning(ds, 'killWorker');
|
|
1400
|
+
invalidateTuiPrompt(ds, 'killWorker');
|
|
1117
1401
|
if (!ds.worker || ds.worker.killed) {
|
|
1118
1402
|
// No live worker to receive {type:'close'}, so its destroySession() — which
|
|
1119
|
-
// tears down the persistent backing session (tmux/herdr/zellij) — never
|
|
1403
|
+
// tears down the persistent backing session (tmux/herdr/zellij/zmx) — never
|
|
1120
1404
|
// fires. Those sessions survive a worker exit BY DESIGN (idle-suspend /
|
|
1121
1405
|
// lazy-restore keep the CLI alive for later resume), so /close on such a
|
|
1122
1406
|
// session would leave an orphaned CLI running in tmux that still replies.
|
|
@@ -1129,6 +1413,8 @@ export function killWorker(ds) {
|
|
|
1129
1413
|
}
|
|
1130
1414
|
catch { /* IPC already closed */ }
|
|
1131
1415
|
const w = ds.worker;
|
|
1416
|
+
trackLifecycleRetirement(ds, w);
|
|
1417
|
+
armCloseFence(ds, w);
|
|
1132
1418
|
// riff:worker close 分支要有界 await 远端 task-cancel(destroySession 5s×2 重试,
|
|
1133
1419
|
// 外层 race 8s)。默认 2s SIGTERM backstop 会在取消发出前掐死进程,已关闭话题
|
|
1134
1420
|
// 的远端任务照跑——冻结为 riff 的会话放宽到 24s(层级:destroy 20s < worker 22s
|
|
@@ -1140,6 +1426,111 @@ export function killWorker(ds) {
|
|
|
1140
1426
|
ds.workerToken = null;
|
|
1141
1427
|
ds.workerViewToken = null;
|
|
1142
1428
|
}
|
|
1429
|
+
function clearTransferWorkerState(ds, worker, reason) {
|
|
1430
|
+
restartCoordinator.cancelSession(ds.session.sessionId);
|
|
1431
|
+
clearUsageLimitState(ds);
|
|
1432
|
+
ds.workerReady = false;
|
|
1433
|
+
ds.localProcessAttestation = undefined;
|
|
1434
|
+
ds.managedTurnOrigin = undefined;
|
|
1435
|
+
invalidateStuckWarning(ds, reason);
|
|
1436
|
+
invalidateTuiPrompt(ds, reason);
|
|
1437
|
+
if (!worker || ds.worker === worker || ds.worker === null) {
|
|
1438
|
+
ds.worker = null;
|
|
1439
|
+
ds.workerPort = null;
|
|
1440
|
+
ds.workerToken = null;
|
|
1441
|
+
ds.workerViewToken = null;
|
|
1442
|
+
}
|
|
1443
|
+
}
|
|
1444
|
+
/**
|
|
1445
|
+
* Retire the old worker generation during a routing transfer without applying
|
|
1446
|
+
* ordinary `/close` semantics to its backend.
|
|
1447
|
+
*
|
|
1448
|
+
* The replacement worker reuses the same logical session after this completion
|
|
1449
|
+
* fence. Sending `close` here would call `destroySession()` in the old worker
|
|
1450
|
+
* and race that replacement's persistent-backend reattach. A worker-less
|
|
1451
|
+
* session is already detached, so unlike killWorker() this path must never
|
|
1452
|
+
* destroy its orphaned backing resource.
|
|
1453
|
+
*/
|
|
1454
|
+
export async function detachWorkerForTransfer(ds, opts = {}) {
|
|
1455
|
+
const w = ds.worker;
|
|
1456
|
+
if (!w) {
|
|
1457
|
+
clearTransferWorkerState(ds, null, 'detachWorkerForTransfer:no_worker');
|
|
1458
|
+
return true;
|
|
1459
|
+
}
|
|
1460
|
+
if (w.killed && (w.exitCode !== null || w.signalCode !== null)) {
|
|
1461
|
+
clearTransferWorkerState(ds, w, 'detachWorkerForTransfer:already_exited');
|
|
1462
|
+
return true;
|
|
1463
|
+
}
|
|
1464
|
+
const requestId = randomUUID();
|
|
1465
|
+
transferRetiringWorkers.add(w);
|
|
1466
|
+
let acked = false;
|
|
1467
|
+
let exited = w.exitCode !== null || w.signalCode !== null;
|
|
1468
|
+
let finish;
|
|
1469
|
+
const completion = new Promise((resolve) => { finish = resolve; });
|
|
1470
|
+
const maybeFinish = () => {
|
|
1471
|
+
if (acked && exited)
|
|
1472
|
+
finish(true);
|
|
1473
|
+
};
|
|
1474
|
+
const onMessage = (raw) => {
|
|
1475
|
+
const msg = raw;
|
|
1476
|
+
if (msg.type !== 'transfer_detached' || msg.requestId !== requestId)
|
|
1477
|
+
return;
|
|
1478
|
+
acked = true;
|
|
1479
|
+
maybeFinish();
|
|
1480
|
+
};
|
|
1481
|
+
const onExit = () => {
|
|
1482
|
+
exited = true;
|
|
1483
|
+
maybeFinish();
|
|
1484
|
+
};
|
|
1485
|
+
w.on('message', onMessage);
|
|
1486
|
+
w.on('exit', onExit);
|
|
1487
|
+
const timeout = setTimeout(() => finish(false), opts.timeoutMs ?? TRANSFER_DETACH_FENCE_MS);
|
|
1488
|
+
timeout.unref?.();
|
|
1489
|
+
// Do not await ChildProcess.send() itself: its callback can stall forever.
|
|
1490
|
+
// The independent completion timer is the authority for this bounded fence.
|
|
1491
|
+
void sendWorkerIpc(w, { type: 'detach_for_transfer', requestId })
|
|
1492
|
+
.catch(() => finish(false));
|
|
1493
|
+
const completed = await completion;
|
|
1494
|
+
// A timed-out detach request may still be queued in Node's IPC channel and
|
|
1495
|
+
// arrive later. Never hand new input back to that uncertain worker. SIGKILL
|
|
1496
|
+
// skips its ordinary process-exit cleanup, preserving sandbox state and the
|
|
1497
|
+
// persistent backing for a source-route cold reattach.
|
|
1498
|
+
if (!completed && !exited) {
|
|
1499
|
+
logger.warn(`[${tag(ds)}] Transfer detach timed out; hard-retiring old worker`);
|
|
1500
|
+
const forcedExit = new Promise((resolve) => {
|
|
1501
|
+
let settled = false;
|
|
1502
|
+
let forcedTimeout;
|
|
1503
|
+
const finish = (didExit) => {
|
|
1504
|
+
if (settled)
|
|
1505
|
+
return;
|
|
1506
|
+
settled = true;
|
|
1507
|
+
clearTimeout(forcedTimeout);
|
|
1508
|
+
w.off('exit', onForcedExit);
|
|
1509
|
+
resolve(didExit);
|
|
1510
|
+
};
|
|
1511
|
+
const onForcedExit = () => finish(true);
|
|
1512
|
+
w.once('exit', onForcedExit);
|
|
1513
|
+
forcedTimeout = setTimeout(() => finish(false), TRANSFER_FORCE_EXIT_MS);
|
|
1514
|
+
forcedTimeout.unref?.();
|
|
1515
|
+
});
|
|
1516
|
+
try {
|
|
1517
|
+
w.kill('SIGKILL');
|
|
1518
|
+
}
|
|
1519
|
+
catch { /* already gone */ }
|
|
1520
|
+
exited = await forcedExit || exited;
|
|
1521
|
+
}
|
|
1522
|
+
clearTimeout(timeout);
|
|
1523
|
+
w.off('message', onMessage);
|
|
1524
|
+
w.off('exit', onExit);
|
|
1525
|
+
if (!completed || (ds.worker !== w && ds.worker !== null)) {
|
|
1526
|
+
if (exited)
|
|
1527
|
+
clearTransferWorkerState(ds, w, 'detachWorkerForTransfer:forced_exit');
|
|
1528
|
+
logger.warn(`[${tag(ds)}] Transfer detach fence failed; source routing remains unchanged`);
|
|
1529
|
+
return false;
|
|
1530
|
+
}
|
|
1531
|
+
clearTransferWorkerState(ds, w, 'detachWorkerForTransfer');
|
|
1532
|
+
return true;
|
|
1533
|
+
}
|
|
1143
1534
|
/**
|
|
1144
1535
|
* Whether a worker-less restart must first destroy the session's persistent
|
|
1145
1536
|
* backing pane. Adopt sessions are excluded — botmux never owned the user's
|
|
@@ -1150,7 +1541,7 @@ export function shouldDestroyPaneBeforeRestart(ds) {
|
|
|
1150
1541
|
return !ds.initConfig?.adoptMode && !ds.adoptedFrom;
|
|
1151
1542
|
}
|
|
1152
1543
|
/**
|
|
1153
|
-
* Destroy a still-alive persistent backing pane (tmux/herdr/zellij) before a
|
|
1544
|
+
* Destroy a still-alive persistent backing pane (tmux/herdr/zellij/zmx) before a
|
|
1154
1545
|
* worker-less restart forks a fresh worker. Without this, a session that lost
|
|
1155
1546
|
* its worker but kept its pane (the normal post-daemon-restart state) would let
|
|
1156
1547
|
* spawnCli REATTACH the surviving CLI instead of relaunching it — the CLI is
|
|
@@ -1183,7 +1574,7 @@ function destroyLivePaneBeforeRestart(ds) {
|
|
|
1183
1574
|
return;
|
|
1184
1575
|
const killOnce = () => {
|
|
1185
1576
|
try {
|
|
1186
|
-
killPersistentBackendTarget(target);
|
|
1577
|
+
killPersistentBackendTarget(target, ds.session.sessionId);
|
|
1187
1578
|
}
|
|
1188
1579
|
catch (err) {
|
|
1189
1580
|
// The primitives normally swallow their own errors; this only catches a
|
|
@@ -1224,11 +1615,34 @@ function destroyLivePaneBeforeRestart(ds) {
|
|
|
1224
1615
|
logger.info(`[${tag(ds)}] restart: ${target.backendType} pane missing after kill — CLI will physically relaunch`);
|
|
1225
1616
|
}
|
|
1226
1617
|
}
|
|
1618
|
+
/**
|
|
1619
|
+
* Live-worker /restart 携带的最新 per-bot env(bots.json `env`)。worker 收到
|
|
1620
|
+
* restart 时在 respawn 前全量覆盖 lastInitConfig.env —— 否则 live-worker 重启
|
|
1621
|
+
* 一直用 fork 时刻的旧快照,dashboard 改完 env(如切 provider 的
|
|
1622
|
+
* ANTHROPIC_BASE_URL/TOKEN)后 /restart 并不会生效(只有 refork 路径生效)。
|
|
1623
|
+
* 三分态返回:对象 = 最新 env;null = 明确清空(dashboard 已删);
|
|
1624
|
+
* undefined = 取不到(bot 已注销等异常),让 worker 保持快照不动(=旧行为)。
|
|
1625
|
+
* 只热更 env 一个字段:sandbox/backendType 是刻意 freeze-once 的设计(见
|
|
1626
|
+
* forkWorker init 注释),cliId 换 CLI 会踩 resume transcript 对齐,均不带。
|
|
1627
|
+
*/
|
|
1628
|
+
export function latestPerBotEnvForRestart(ds) {
|
|
1629
|
+
try {
|
|
1630
|
+
return getBot(ds.larkAppId).config.env ?? null;
|
|
1631
|
+
}
|
|
1632
|
+
catch {
|
|
1633
|
+
return undefined;
|
|
1634
|
+
}
|
|
1635
|
+
}
|
|
1227
1636
|
/** Join or start one correlated physical restart for a session. */
|
|
1228
1637
|
export function requestSessionRestart(ds, observer) {
|
|
1638
|
+
if (isSessionTransferring(ds)) {
|
|
1639
|
+
logger.warn(`[${tag(ds)}] Restart refused while routing transfer is in progress`);
|
|
1640
|
+
return undefined;
|
|
1641
|
+
}
|
|
1229
1642
|
return restartCoordinator.request(ds.session.sessionId, observer, attemptId => {
|
|
1230
1643
|
if (ds.worker && !ds.worker.killed) {
|
|
1231
|
-
ds.
|
|
1644
|
+
ds.workerReady = false;
|
|
1645
|
+
ds.worker.send({ type: 'restart', attemptId, env: latestPerBotEnvForRestart(ds) });
|
|
1232
1646
|
return;
|
|
1233
1647
|
}
|
|
1234
1648
|
// No live worker but the persistent pane may still be alive (e.g. after a
|
|
@@ -1246,7 +1660,7 @@ export function __testOnly_resetRestartCoordinator() {
|
|
|
1246
1660
|
restartCoordinator.reset();
|
|
1247
1661
|
}
|
|
1248
1662
|
/**
|
|
1249
|
-
* Tear down a persistent backing session (tmux/herdr/zellij) directly from the
|
|
1663
|
+
* Tear down a persistent backing session (tmux/herdr/zellij/zmx) directly from the
|
|
1250
1664
|
* daemon when there is no live worker to do it via the 'close' IPC. The session
|
|
1251
1665
|
* name is deterministic from the session UUID, and each killSession() is a no-op
|
|
1252
1666
|
* if the session is already gone.
|
|
@@ -1284,7 +1698,7 @@ function destroyOrphanedBackingSession(ds) {
|
|
|
1284
1698
|
if (!backendType)
|
|
1285
1699
|
return;
|
|
1286
1700
|
try {
|
|
1287
|
-
killPersistentBackendTarget(persistentBackendTargetForSession(ds));
|
|
1701
|
+
killPersistentBackendTarget(persistentBackendTargetForSession(ds), ds.session.sessionId);
|
|
1288
1702
|
logger.info(`[${tag(ds)}] killWorker: no live worker — destroyed orphaned ${backendType} backing session`);
|
|
1289
1703
|
}
|
|
1290
1704
|
catch (err) {
|
|
@@ -1312,14 +1726,21 @@ function reclaimParkedCrashDiagnostic(ds) {
|
|
|
1312
1726
|
catch { /* absent — benign */ }
|
|
1313
1727
|
}
|
|
1314
1728
|
export function suspendWorker(ds, reason = 'suspended_idle') {
|
|
1729
|
+
if (isSessionTransferring(ds)) {
|
|
1730
|
+
logger.warn(`[${tag(ds)}] Suspend refused while routing transfer is in progress`);
|
|
1731
|
+
return false;
|
|
1732
|
+
}
|
|
1315
1733
|
if (!ds.worker || ds.worker.killed) {
|
|
1734
|
+
ds.workerReady = false;
|
|
1316
1735
|
// There is no live generation that can still own this capability.
|
|
1317
1736
|
ds.managedTurnOrigin = undefined;
|
|
1737
|
+
invalidateTuiPrompt(ds, 'suspendWorker:no_worker');
|
|
1318
1738
|
return false;
|
|
1319
1739
|
}
|
|
1320
1740
|
if (!isSuspendableBackendType(ds.initConfig?.backendType))
|
|
1321
1741
|
return false;
|
|
1322
1742
|
const w = ds.worker;
|
|
1743
|
+
trackLifecycleRetirement(ds, w);
|
|
1323
1744
|
try {
|
|
1324
1745
|
w.send({ type: 'suspend' });
|
|
1325
1746
|
}
|
|
@@ -1331,6 +1752,7 @@ export function suspendWorker(ds, reason = 'suspended_idle') {
|
|
|
1331
1752
|
}
|
|
1332
1753
|
armWorkerKillBackstop(w, tag(ds));
|
|
1333
1754
|
ds.worker = null;
|
|
1755
|
+
ds.workerReady = false;
|
|
1334
1756
|
ds.localProcessAttestation = undefined;
|
|
1335
1757
|
ds.workerPort = null;
|
|
1336
1758
|
ds.workerToken = null;
|
|
@@ -1340,6 +1762,7 @@ export function suspendWorker(ds, reason = 'suspended_idle') {
|
|
|
1340
1762
|
// later. Invalidate any stuck-warning card so a late click cannot inject keys
|
|
1341
1763
|
// after the CLI is gone (or into a different CLI on resume).
|
|
1342
1764
|
invalidateStuckWarning(ds, 'suspendWorker');
|
|
1765
|
+
invalidateTuiPrompt(ds, 'suspendWorker');
|
|
1343
1766
|
// Screen state describes the process we just stopped. Keeping it would make
|
|
1344
1767
|
// the dashboard hydrate this process-less logical session as idle/working.
|
|
1345
1768
|
ds.lastScreenStatus = undefined;
|
|
@@ -1398,7 +1821,169 @@ function armWorkerKillBackstop(w, label, sigtermMs = WORKER_SIGTERM_BACKSTOP_MS)
|
|
|
1398
1821
|
clearTimeout(sigkill);
|
|
1399
1822
|
});
|
|
1400
1823
|
}
|
|
1401
|
-
|
|
1824
|
+
const closeFences = new Map();
|
|
1825
|
+
function closeFenceGeneration(ds) {
|
|
1826
|
+
return ds.workerGeneration ?? ds.session.workerGeneration ?? 0;
|
|
1827
|
+
}
|
|
1828
|
+
function closeFenceKey(sessionId, workerGeneration) {
|
|
1829
|
+
return `${sessionId}:${workerGeneration}`;
|
|
1830
|
+
}
|
|
1831
|
+
function closeFenceFor(sessionId, workerGeneration) {
|
|
1832
|
+
if (workerGeneration === undefined)
|
|
1833
|
+
return undefined;
|
|
1834
|
+
return closeFences.get(closeFenceKey(sessionId, workerGeneration))?.promise;
|
|
1835
|
+
}
|
|
1836
|
+
function armCloseFence(ds, worker) {
|
|
1837
|
+
const sessionId = ds.session.sessionId;
|
|
1838
|
+
const workerGeneration = closeFenceGeneration(ds);
|
|
1839
|
+
const key = closeFenceKey(sessionId, workerGeneration);
|
|
1840
|
+
const existing = closeFences.get(key);
|
|
1841
|
+
if (existing)
|
|
1842
|
+
return existing.promise;
|
|
1843
|
+
let resolveFence;
|
|
1844
|
+
const promise = new Promise(resolve => { resolveFence = resolve; });
|
|
1845
|
+
const fence = {
|
|
1846
|
+
sessionId,
|
|
1847
|
+
workerGeneration,
|
|
1848
|
+
promise,
|
|
1849
|
+
resolve: resolveFence,
|
|
1850
|
+
worker,
|
|
1851
|
+
};
|
|
1852
|
+
closeFences.set(key, fence);
|
|
1853
|
+
sessionStore.registerSessionBridgeSendMarkerCleanupFence(sessionId, promise);
|
|
1854
|
+
const timer = setTimeout(() => {
|
|
1855
|
+
logger.warn(`[${sessionId.substring(0, 8)}] worker close fence still waiting; `
|
|
1856
|
+
+ `generation=${workerGeneration}; bridge markers remain until ACK or worker exit`);
|
|
1857
|
+
}, CLOSE_FENCE_WARN_MS);
|
|
1858
|
+
timer.unref?.();
|
|
1859
|
+
worker.once('exit', resolveFence);
|
|
1860
|
+
if (worker.exitCode != null || worker.signalCode != null) {
|
|
1861
|
+
queueMicrotask(resolveFence);
|
|
1862
|
+
}
|
|
1863
|
+
void promise.finally(() => {
|
|
1864
|
+
clearTimeout(timer);
|
|
1865
|
+
worker.off('exit', resolveFence);
|
|
1866
|
+
if (closeFences.get(key) === fence)
|
|
1867
|
+
closeFences.delete(key);
|
|
1868
|
+
});
|
|
1869
|
+
return promise;
|
|
1870
|
+
}
|
|
1871
|
+
function resolveCloseFence(sessionId, workerGeneration) {
|
|
1872
|
+
closeFences.get(closeFenceKey(sessionId, workerGeneration))?.resolve();
|
|
1873
|
+
}
|
|
1874
|
+
function persistedDocCommentTarget(session, turnId) {
|
|
1875
|
+
return session.docCommentTargets?.[turnId];
|
|
1876
|
+
}
|
|
1877
|
+
/** Resolve the daemon-local target first, but fill cleanup identifiers from
|
|
1878
|
+
* the persisted per-turn route. After a daemon restart only the latter exists. */
|
|
1879
|
+
function resolveDocCommentTarget(ds, turnId) {
|
|
1880
|
+
const memory = ds.docCommentTurns?.get(turnId);
|
|
1881
|
+
const persisted = persistedDocCommentTarget(ds.session, turnId);
|
|
1882
|
+
if (!memory)
|
|
1883
|
+
return persisted;
|
|
1884
|
+
if (!persisted)
|
|
1885
|
+
return memory;
|
|
1886
|
+
return {
|
|
1887
|
+
...persisted,
|
|
1888
|
+
...memory,
|
|
1889
|
+
replyId: memory.replyId ?? persisted.replyId,
|
|
1890
|
+
reactionId: memory.reactionId ?? persisted.reactionId,
|
|
1891
|
+
};
|
|
1892
|
+
}
|
|
1893
|
+
/** Consume one successfully-delivered document turn from both runtime and
|
|
1894
|
+
* persisted state. Persistence is best-effort: a reply that already landed
|
|
1895
|
+
* must never be retried (and duplicated) merely because local cleanup failed. */
|
|
1896
|
+
function consumeDocCommentTurn(ds, turnId) {
|
|
1897
|
+
if (ds.docCommentTurns) {
|
|
1898
|
+
ds.docCommentTurns.delete(turnId);
|
|
1899
|
+
if (ds.docCommentTurns.size === 0)
|
|
1900
|
+
ds.docCommentTurns = undefined;
|
|
1901
|
+
}
|
|
1902
|
+
const targets = ds.session.docCommentTargets;
|
|
1903
|
+
if (targets?.[turnId]) {
|
|
1904
|
+
delete targets[turnId];
|
|
1905
|
+
if (Object.keys(targets).length === 0)
|
|
1906
|
+
ds.session.docCommentTargets = undefined;
|
|
1907
|
+
try {
|
|
1908
|
+
sessionStore.updateSession(ds.session);
|
|
1909
|
+
}
|
|
1910
|
+
catch { /* best-effort */ }
|
|
1911
|
+
}
|
|
1912
|
+
}
|
|
1913
|
+
function collectDocCommentReactionTargets(ds, stored) {
|
|
1914
|
+
const unique = new Map();
|
|
1915
|
+
const add = (target) => {
|
|
1916
|
+
if (!target.replyId || !target.reactionId)
|
|
1917
|
+
return;
|
|
1918
|
+
const key = `${target.fileToken}\0${target.commentId}\0${target.replyId}\0${target.reactionId}`;
|
|
1919
|
+
unique.set(key, target);
|
|
1920
|
+
};
|
|
1921
|
+
if (ds?.docCommentTurns) {
|
|
1922
|
+
for (const target of ds.docCommentTurns.values())
|
|
1923
|
+
add(target);
|
|
1924
|
+
}
|
|
1925
|
+
for (const target of Object.values(ds?.session.docCommentTargets ?? {}))
|
|
1926
|
+
add(target);
|
|
1927
|
+
if (stored && stored !== ds?.session) {
|
|
1928
|
+
for (const target of Object.values(stored.docCommentTargets ?? {}))
|
|
1929
|
+
add(target);
|
|
1930
|
+
}
|
|
1931
|
+
return [...unique.values()];
|
|
1932
|
+
}
|
|
1933
|
+
function clearAllDocCommentTurnState(ds, stored) {
|
|
1934
|
+
if (ds) {
|
|
1935
|
+
ds.docCommentTurns = undefined;
|
|
1936
|
+
ds.session.docCommentTargets = undefined;
|
|
1937
|
+
}
|
|
1938
|
+
if (stored && stored !== ds?.session)
|
|
1939
|
+
stored.docCommentTargets = undefined;
|
|
1940
|
+
}
|
|
1941
|
+
function sessionAnchorForStoredRow(session) {
|
|
1942
|
+
return session.scope === 'chat' ? session.chatId : session.rootMessageId;
|
|
1943
|
+
}
|
|
1944
|
+
function isLegacyApiDocSubscription(managedBy) {
|
|
1945
|
+
return managedBy === undefined || managedBy === 'subscribe-lark-doc';
|
|
1946
|
+
}
|
|
1947
|
+
/**
|
|
1948
|
+
* Perform the close-time teardown that must be proven before callers mutate
|
|
1949
|
+
* the active registry or persisted Session row.
|
|
1950
|
+
*
|
|
1951
|
+
* ZMX is deliberately fail-closed: killManagedSession verifies the managed
|
|
1952
|
+
* UUID/generation and throws when ownership is ambiguous. Most close paths go
|
|
1953
|
+
* through closeSession(), but repo replacement reuses the live DaemonSession
|
|
1954
|
+
* object and therefore cannot call closeSession() without deleting the routing
|
|
1955
|
+
* generation it is about to repopulate. Those paths call this synchronous seam
|
|
1956
|
+
* first, while every bit of old-session state is still intact.
|
|
1957
|
+
*
|
|
1958
|
+
* Other backends retain their existing worker-side graceful teardown. Adopted,
|
|
1959
|
+
* queued, and already-closed rows never own a ZMX process to destroy.
|
|
1960
|
+
*/
|
|
1961
|
+
function teardownAuthoritativePersistentBackingBeforeCloseImpl(target, closeWinsTransfer) {
|
|
1962
|
+
const ds = 'session' in target ? target : undefined;
|
|
1963
|
+
const session = ds?.session ?? target;
|
|
1964
|
+
if (ds && isSessionTransferring(ds)) {
|
|
1965
|
+
if (!closeWinsTransfer) {
|
|
1966
|
+
throw new Error('session_transferring');
|
|
1967
|
+
}
|
|
1968
|
+
// Explicit close wins over relay. The old worker may already be handling
|
|
1969
|
+
// detach_for_transfer concurrently, so daemon-side teardown is the only
|
|
1970
|
+
// deterministic way to prevent that detach path from skipping ordinary
|
|
1971
|
+
// close cleanup (notably a surviving tmux/Herdr/ZMX pane or Riff task).
|
|
1972
|
+
destroyOrphanedBackingSession(ds);
|
|
1973
|
+
}
|
|
1974
|
+
const backendType = ds ? getSessionPersistentBackendType(ds) : session.backendType;
|
|
1975
|
+
if (backendType !== 'zmx'
|
|
1976
|
+
|| ds?.initConfig?.adoptMode
|
|
1977
|
+
|| ds?.adoptedFrom
|
|
1978
|
+
|| session.adoptedFrom
|
|
1979
|
+
|| session.queued
|
|
1980
|
+
|| session.status === 'closed')
|
|
1981
|
+
return;
|
|
1982
|
+
killPersistentSession('zmx', persistentSessionName('zmx', session.sessionId), session.sessionId);
|
|
1983
|
+
}
|
|
1984
|
+
export function teardownAuthoritativePersistentBackingBeforeClose(target) {
|
|
1985
|
+
teardownAuthoritativePersistentBackingBeforeCloseImpl(target, false);
|
|
1986
|
+
}
|
|
1402
1987
|
/**
|
|
1403
1988
|
* Idempotent close: kill worker if alive, mark Session status='closed' + closedAt,
|
|
1404
1989
|
* publish session.exited (if a live worker was killed) and session.update
|
|
@@ -1409,29 +1994,64 @@ function armWorkerKillBackstop(w, label, sigtermMs = WORKER_SIGTERM_BACKSTOP_MS)
|
|
|
1409
1994
|
*/
|
|
1410
1995
|
export async function closeSession(sessionId) {
|
|
1411
1996
|
const ds = findActiveBySessionId(sessionId);
|
|
1997
|
+
const stored = sessionStore.getOwnedSession(sessionId);
|
|
1998
|
+
// Prove fail-closed ZMX teardown before any registry/store mutation. Repo
|
|
1999
|
+
// replacement paths reuse the same helper before their own state transition.
|
|
2000
|
+
const teardownTarget = ds ?? stored;
|
|
2001
|
+
if (teardownTarget) {
|
|
2002
|
+
teardownAuthoritativePersistentBackingBeforeCloseImpl(teardownTarget, true);
|
|
2003
|
+
}
|
|
1412
2004
|
let killedLive = false;
|
|
1413
2005
|
// 会话关闭即可回收其崩溃重启计数;否则每个曾崩溃过的 session 会在 daemon
|
|
1414
2006
|
// 生命周期内永久占位(restartCounts 此前无任何 delete)。
|
|
1415
2007
|
restartCounts.delete(sessionId);
|
|
2008
|
+
const hadLiveWorker = !!ds?.worker && !ds.worker.killed;
|
|
2009
|
+
const closeWorkerGeneration = ds ? closeFenceGeneration(ds) : undefined;
|
|
2010
|
+
// Snapshot ownership + transition state before mutating the live object:
|
|
2011
|
+
// sessionStore commonly holds the very same Session reference as `ds`.
|
|
2012
|
+
const known = !!ds || !!stored;
|
|
2013
|
+
const wasOpen = !!stored && stored.status !== 'closed';
|
|
2014
|
+
const storedHadDocCommentTargets = Object.keys(stored?.docCommentTargets ?? {}).length > 0;
|
|
2015
|
+
const docReactionTargets = collectDocCommentReactionTargets(ds, stored);
|
|
2016
|
+
// Per-turn comment routes are transient capabilities. Clear them from both
|
|
2017
|
+
// live and owner-scoped persisted objects inside the synchronous close
|
|
2018
|
+
// critical section, before any best-effort Lark cleanup can yield.
|
|
2019
|
+
clearAllDocCommentTurnState(ds, stored);
|
|
2020
|
+
// An idempotent re-close has no status transition for closeSession() to
|
|
2021
|
+
// persist, but stale per-turn capabilities must still be removed on disk.
|
|
2022
|
+
if (stored && !wasOpen && storedHadDocCommentTargets) {
|
|
2023
|
+
sessionStore.updateSession(stored);
|
|
2024
|
+
}
|
|
1416
2025
|
if (ds) {
|
|
1417
2026
|
// Usage ledger: flush the final delta before the worker goes away (a
|
|
1418
2027
|
// crash/limited turn may never have reached an idle edge).
|
|
1419
2028
|
recordUsageForDaemonSession(ds);
|
|
1420
2029
|
killWorker(ds);
|
|
1421
|
-
|
|
1422
|
-
|
|
1423
|
-
|
|
1424
|
-
|
|
1425
|
-
|
|
2030
|
+
const activeKey = activeSessionKey(ds);
|
|
2031
|
+
if (activeSessionsRegistry?.get(activeKey) === ds) {
|
|
2032
|
+
activeSessionsRegistry.delete(activeKey);
|
|
2033
|
+
}
|
|
2034
|
+
// Mark the captured object too. Async message/card paths may already hold a
|
|
2035
|
+
// reference to `ds`; deleting only the registry entry would not stop one of
|
|
2036
|
+
// those continuations from re-forking the closed session after its await.
|
|
2037
|
+
ds.session.status = 'closed';
|
|
2038
|
+
ds.session.closedAt ??= new Date().toISOString();
|
|
1426
2039
|
killedLive = true;
|
|
1427
2040
|
}
|
|
1428
|
-
//
|
|
1429
|
-
//
|
|
1430
|
-
|
|
1431
|
-
|
|
1432
|
-
|
|
1433
|
-
|
|
1434
|
-
|
|
2041
|
+
// Mutations are bot-owner scoped. getSession() has a read-only cross-file
|
|
2042
|
+
// fallback for agent CLI discovery, so it must not authorize close.
|
|
2043
|
+
if (wasOpen) {
|
|
2044
|
+
if (!ds && stored)
|
|
2045
|
+
destroyUnregisteredPersistentBacking(stored);
|
|
2046
|
+
sessionStore.closeSession(sessionId, {
|
|
2047
|
+
cleanupBridgeMarkers: !hadLiveWorker,
|
|
2048
|
+
});
|
|
2049
|
+
const after = sessionStore.getOwnedSession(sessionId);
|
|
2050
|
+
if (ds) {
|
|
2051
|
+
ds.session.status = 'closed';
|
|
2052
|
+
ds.session.closedAt = after?.closedAt ?? ds.session.closedAt;
|
|
2053
|
+
}
|
|
2054
|
+
}
|
|
1435
2055
|
if (ds) {
|
|
1436
2056
|
if (!ds.exitEventEmitted) {
|
|
1437
2057
|
ds.exitEventEmitted = true;
|
|
@@ -1442,53 +2062,106 @@ export async function closeSession(sessionId) {
|
|
|
1442
2062
|
emitSessionLifecycleHook(ds, 'session.exit', { reason: 'dashboard_close' });
|
|
1443
2063
|
}
|
|
1444
2064
|
}
|
|
1445
|
-
//
|
|
1446
|
-
//
|
|
1447
|
-
//
|
|
2065
|
+
// Persistence path — load → mark closed → save (delegated to sessionStore).
|
|
2066
|
+
// Mutations are bot-owner scoped. getSession() has a read-only cross-file
|
|
2067
|
+
// fallback for agent CLI discovery; using it here lets the wrong daemon see
|
|
2068
|
+
// another bot's active row, report a false successful close, then write only
|
|
2069
|
+
// its own file (a no-op). The CLI relies on alreadyClosed to decide whether a
|
|
2070
|
+
// legacy local fallback is safe, so owner proof must be real.
|
|
1448
2071
|
if (wasOpen) {
|
|
1449
|
-
const after = sessionStore.
|
|
1450
|
-
|
|
1451
|
-
type: 'session.update',
|
|
1452
|
-
body: {
|
|
1453
|
-
sessionId,
|
|
1454
|
-
patch: {
|
|
1455
|
-
status: 'closed',
|
|
1456
|
-
closedAt: after?.closedAt ? Date.parse(after.closedAt) : Date.now(),
|
|
1457
|
-
tokenUsage: after ? composeRowFromClosed(after).tokenUsage : null,
|
|
1458
|
-
},
|
|
1459
|
-
},
|
|
1460
|
-
});
|
|
2072
|
+
const after = sessionStore.getOwnedSession(sessionId);
|
|
2073
|
+
publishClosedSessionPatch(sessionId, after?.closedAt ? Date.parse(after.closedAt) : undefined, { tokenUsage: after ? composeRowFromClosed(after).tokenUsage : null });
|
|
1461
2074
|
}
|
|
1462
|
-
if (
|
|
1463
|
-
|
|
1464
|
-
|
|
1465
|
-
|
|
1466
|
-
|
|
2075
|
+
if (wasOpen && hadLiveWorker) {
|
|
2076
|
+
await closeFenceFor(sessionId, closeWorkerGeneration);
|
|
2077
|
+
sessionStore.cleanupSessionBridgeSendMarkersNow(sessionId);
|
|
2078
|
+
}
|
|
2079
|
+
// All authoritative map/status/store/event state above transitions
|
|
2080
|
+
// synchronously, before the first await. Lark reaction/unsubscribe cleanup is
|
|
2081
|
+
// best-effort and can be slow; it must not leave a resurrection window.
|
|
2082
|
+
const cleanupAppId = ds?.larkAppId ?? stored?.larkAppId;
|
|
2083
|
+
if (cleanupAppId) {
|
|
2084
|
+
for (const target of docReactionTargets) {
|
|
2085
|
+
try {
|
|
2086
|
+
await removeCommentReaction(cleanupAppId, { fileToken: target.fileToken, fileType: target.fileType }, target.commentId, target.replyId, target.reactionId);
|
|
2087
|
+
}
|
|
2088
|
+
catch (err) {
|
|
2089
|
+
logger.debug(`[doc-comment] close cleanup could not remove reaction ${target.reactionId}: ${err?.message ?? err}`);
|
|
2090
|
+
}
|
|
2091
|
+
}
|
|
2092
|
+
const anchor = ds ? sessionAnchorId(ds) : stored ? sessionAnchorForStoredRow(stored) : undefined;
|
|
2093
|
+
let subs = [];
|
|
1467
2094
|
try {
|
|
1468
|
-
|
|
1469
|
-
|
|
1470
|
-
for (const sub of subs) {
|
|
1471
|
-
if (sub.managedBy !== 'watch-comment') {
|
|
1472
|
-
await unsubscribeDocFile(ds.larkAppId, { fileToken: sub.fileToken, fileType: sub.fileType });
|
|
1473
|
-
}
|
|
1474
|
-
removeDocSubscription(config.session.dataDir, ds.larkAppId, sub.fileToken);
|
|
2095
|
+
if (anchor) {
|
|
2096
|
+
subs = listDocSubscriptionsForSession(config.session.dataDir, cleanupAppId, anchor);
|
|
1475
2097
|
}
|
|
1476
|
-
if (subs.length)
|
|
1477
|
-
logger.info(`[doc-comment] session ${sessionId.slice(0, 8)} closed → removed ${subs.length} doc binding(s)`);
|
|
1478
2098
|
}
|
|
1479
2099
|
catch (err) {
|
|
1480
|
-
logger.warn(`[doc-comment]
|
|
2100
|
+
logger.warn(`[doc-comment] failed to list bindings on close for ${sessionId.slice(0, 8)}: ${err?.message ?? err}`);
|
|
1481
2101
|
}
|
|
2102
|
+
for (const sub of subs) {
|
|
2103
|
+
try {
|
|
2104
|
+
// `/subscribe-lark-doc` (and pre-managedBy legacy rows) owns a
|
|
2105
|
+
// per-file remote subscription. `/watch-comment` is app-level event /
|
|
2106
|
+
// poller state and must only remove its local routing binding.
|
|
2107
|
+
if (isLegacyApiDocSubscription(sub.managedBy)) {
|
|
2108
|
+
await unsubscribeDocFile(cleanupAppId, { fileToken: sub.fileToken, fileType: sub.fileType });
|
|
2109
|
+
}
|
|
2110
|
+
}
|
|
2111
|
+
catch (err) {
|
|
2112
|
+
logger.warn(`[doc-comment] remote unsubscribe failed for ${sub.fileToken.slice(0, 12)} on close: ${err?.message ?? err}`);
|
|
2113
|
+
}
|
|
2114
|
+
finally {
|
|
2115
|
+
// Local ownership must always be released, even when one remote API
|
|
2116
|
+
// call fails; each item is isolated so later bindings still clean up.
|
|
2117
|
+
try {
|
|
2118
|
+
removeDocSubscription(config.session.dataDir, cleanupAppId, sub.fileToken);
|
|
2119
|
+
}
|
|
2120
|
+
catch (err) {
|
|
2121
|
+
logger.warn(`[doc-comment] local binding removal failed for ${sub.fileToken.slice(0, 12)} on close: ${err?.message ?? err}`);
|
|
2122
|
+
}
|
|
2123
|
+
}
|
|
2124
|
+
}
|
|
2125
|
+
if (subs.length)
|
|
2126
|
+
logger.info(`[doc-comment] session ${sessionId.slice(0, 8)} closed → removed ${subs.length} doc binding(s)`);
|
|
1482
2127
|
}
|
|
1483
2128
|
// alreadyClosed = nothing happened on either path.
|
|
1484
2129
|
const alreadyClosed = !killedLive && !wasOpen;
|
|
1485
|
-
return { ok: true, alreadyClosed };
|
|
2130
|
+
return { ok: true, alreadyClosed, known };
|
|
2131
|
+
}
|
|
2132
|
+
/**
|
|
2133
|
+
* Close can arrive through daemon IPC before startup restore has registered the
|
|
2134
|
+
* persisted row. In that window there is no DaemonSession for killWorker(), but
|
|
2135
|
+
* a stamped persistent backing may still be running. Tear down only backends
|
|
2136
|
+
* whose ownership is explicit; never touch adopted user panes, queued rows, or
|
|
2137
|
+
* legacy rows whose backend is unknown.
|
|
2138
|
+
*/
|
|
2139
|
+
export function destroyUnregisteredPersistentBacking(session, kill = killPersistentBackendTarget) {
|
|
2140
|
+
if (session.adoptedFrom || session.queued)
|
|
2141
|
+
return false;
|
|
2142
|
+
const backendType = session.backendType;
|
|
2143
|
+
if (!isSuspendableBackendType(backendType))
|
|
2144
|
+
return false;
|
|
2145
|
+
const target = resolvePersistentBackendTarget(backendType, session.sessionId, session.persistentBackendTarget);
|
|
2146
|
+
const targetName = target.backendType === 'herdr' && target.agentName
|
|
2147
|
+
? `${target.sessionName}/${target.agentName}`
|
|
2148
|
+
: target.sessionName;
|
|
2149
|
+
try {
|
|
2150
|
+
kill(target, session.sessionId);
|
|
2151
|
+
logger.info(`[${session.sessionId.substring(0, 8)}] Closed unregistered ${backendType} backing ${targetName}`);
|
|
2152
|
+
return true;
|
|
2153
|
+
}
|
|
2154
|
+
catch (err) {
|
|
2155
|
+
logger.warn(`[${session.sessionId.substring(0, 8)}] Failed to close unregistered ${backendType} backing ${targetName}: ` +
|
|
2156
|
+
`${err instanceof Error ? err.message : String(err)}`);
|
|
2157
|
+
return false;
|
|
2158
|
+
}
|
|
1486
2159
|
}
|
|
1487
2160
|
/**
|
|
1488
|
-
*
|
|
1489
|
-
*
|
|
1490
|
-
* `activeSessions.set(key, ds)` at sites where a silent overwrite
|
|
1491
|
-
* the prior entry's worker + leave its store row stuck
|
|
2161
|
+
* Compare-and-set an entry on an active-sessions Map. A different current
|
|
2162
|
+
* occupant always wins; callers must roll back the rejected incoming row.
|
|
2163
|
+
* Replaces bare `activeSessions.set(key, ds)` at sites where a silent overwrite
|
|
2164
|
+
* would leak the prior entry's worker + leave its store row stuck active.
|
|
1492
2165
|
*
|
|
1493
2166
|
* The Map is passed explicitly so callers operate on the same instance they
|
|
1494
2167
|
* already hold (restoreActiveSessions takes the daemon's Map as a parameter;
|
|
@@ -1496,24 +2169,290 @@ export async function closeSession(sessionId) {
|
|
|
1496
2169
|
* both refer to the same object — the daemon registers its Map at boot — but
|
|
1497
2170
|
* decoupling avoids module-state assumptions in tests.
|
|
1498
2171
|
*
|
|
1499
|
-
*
|
|
1500
|
-
*
|
|
1501
|
-
*
|
|
1502
|
-
*
|
|
1503
|
-
|
|
2172
|
+
* Registration is compare-and-set: a different current occupant always wins.
|
|
2173
|
+
* The caller owns rollback of its rejected incoming row. This is deliberately
|
|
2174
|
+
* non-destructive because the occupant may be a fresh live session created
|
|
2175
|
+
* while an older async restore/create continuation was in flight.
|
|
2176
|
+
*/
|
|
2177
|
+
function removeInactiveRegistration(map, key, ds) {
|
|
2178
|
+
if (ds.session.status === 'active')
|
|
2179
|
+
return false;
|
|
2180
|
+
// Only remove our exact stale object. A newer session may already own the
|
|
2181
|
+
// same routing key and must never be evicted by this continuation.
|
|
2182
|
+
if (map.get(key) === ds)
|
|
2183
|
+
map.delete(key);
|
|
2184
|
+
logger.warn(`[${tag(ds)}] Refusing to register an inactive session ` +
|
|
2185
|
+
`(status=${ds.session.status})`);
|
|
2186
|
+
return true;
|
|
2187
|
+
}
|
|
2188
|
+
function persistedActiveSessionKey(session, fallbackLarkAppId) {
|
|
2189
|
+
const larkAppId = session.larkAppId ?? fallbackLarkAppId;
|
|
2190
|
+
const anchor = session.vcMeetingReceiver
|
|
2191
|
+
? `vc-receiver:${session.sessionId}`
|
|
2192
|
+
: storedSessionAnchorId(session);
|
|
2193
|
+
return sessionKey(anchor, larkAppId);
|
|
2194
|
+
}
|
|
2195
|
+
/**
|
|
2196
|
+
* Return an active persisted row that deliberately reserves this routing key
|
|
2197
|
+
* after an inconclusive exact-backend teardown.
|
|
1504
2198
|
*
|
|
1505
|
-
*
|
|
2199
|
+
* The store is the durable authority rather than a process-local Set: a daemon
|
|
2200
|
+
* restart must not forget the quarantine and create a second runtime beside a
|
|
2201
|
+
* possibly-live ZMX/other persistent target. Closed rows are ignored so an
|
|
2202
|
+
* operator's successful close immediately releases the route.
|
|
1506
2203
|
*/
|
|
1507
|
-
export
|
|
1508
|
-
|
|
1509
|
-
|
|
1510
|
-
|
|
1511
|
-
|
|
1512
|
-
|
|
2204
|
+
export function findQuarantinedRoutingConflict(key, larkAppId, candidateSessionId) {
|
|
2205
|
+
try {
|
|
2206
|
+
return sessionStore.listSessions().find(session => session.status === 'active'
|
|
2207
|
+
&& !!session.restoreQuarantinedAt
|
|
2208
|
+
&& session.sessionId !== candidateSessionId
|
|
2209
|
+
&& persistedActiveSessionKey(session, larkAppId) === key);
|
|
2210
|
+
}
|
|
2211
|
+
catch {
|
|
2212
|
+
// Registration still retains its existing in-memory CAS semantics when the
|
|
2213
|
+
// store is temporarily unreadable. Restore marks/persists quarantine before
|
|
2214
|
+
// yielding, so the normal production path reaches the durable guard.
|
|
2215
|
+
return undefined;
|
|
2216
|
+
}
|
|
2217
|
+
}
|
|
2218
|
+
/**
|
|
2219
|
+
* Synchronous registration gate for creation paths that previously used a
|
|
2220
|
+
* bare Map.set(). A daemon close can update the shared Session object while
|
|
2221
|
+
* the creator is awaiting Lark/project metadata; never publish that now-closed
|
|
2222
|
+
* row back into the live routing map when the continuation resumes.
|
|
2223
|
+
*/
|
|
2224
|
+
export function setActiveSessionIfActive(map, key, ds) {
|
|
2225
|
+
if (removeInactiveRegistration(map, key, ds))
|
|
2226
|
+
return false;
|
|
2227
|
+
const current = map.get(key);
|
|
2228
|
+
if (current && current !== ds) {
|
|
2229
|
+
logger.warn(`[${tag(ds)}] Refusing to overwrite active routing occupant ` +
|
|
2230
|
+
`${current.session.sessionId.substring(0, 8)}`);
|
|
2231
|
+
return false;
|
|
2232
|
+
}
|
|
2233
|
+
const quarantined = findQuarantinedRoutingConflict(key, ds.larkAppId, ds.session.sessionId);
|
|
2234
|
+
if (quarantined) {
|
|
2235
|
+
logger.warn(`[${tag(ds)}] Refusing to occupy routing key reserved by quarantined session `
|
|
2236
|
+
+ `${quarantined.sessionId.substring(0, 8)}`);
|
|
2237
|
+
return false;
|
|
1513
2238
|
}
|
|
1514
2239
|
map.set(key, ds);
|
|
2240
|
+
if (ds.session.restoreQuarantinedAt) {
|
|
2241
|
+
delete ds.session.restoreQuarantinedAt;
|
|
2242
|
+
try {
|
|
2243
|
+
sessionStore.updateSession(ds.session);
|
|
2244
|
+
}
|
|
2245
|
+
catch (err) {
|
|
2246
|
+
// Runtime ownership is now authoritative and the Map prevents duplicates
|
|
2247
|
+
// for this process. Keep running; a later persistence update (or the next
|
|
2248
|
+
// restore attempt) will converge the stale on-disk marker.
|
|
2249
|
+
logger.warn(`[${tag(ds)}] Registered quarantined session but could not clear its persisted marker: `
|
|
2250
|
+
+ `${err instanceof Error ? err.message : String(err)}`);
|
|
2251
|
+
}
|
|
2252
|
+
}
|
|
2253
|
+
return true;
|
|
2254
|
+
}
|
|
2255
|
+
export async function setActiveSessionSafe(map, key, ds) {
|
|
2256
|
+
return setActiveSessionIfActive(map, key, ds);
|
|
2257
|
+
}
|
|
2258
|
+
/**
|
|
2259
|
+
* Roll back a freshly-created row rejected by the registration CAS, then read
|
|
2260
|
+
* the routing winner *after* that asynchronous rollback finishes.
|
|
2261
|
+
*
|
|
2262
|
+
* Message handlers use this to hand an already-deduped inbound event to a
|
|
2263
|
+
* concurrent HTTP/dashboard/restore winner instead of silently dropping it.
|
|
2264
|
+
* The post-await lookup matters: close cleanup must never return a stale object
|
|
2265
|
+
* that another continuation replaced while the rollback was in flight.
|
|
2266
|
+
*/
|
|
2267
|
+
export async function rollbackRejectedSessionAndGetWinner(map, key, rejected, rollback = closeSession) {
|
|
2268
|
+
await rollback(rejected.session.sessionId);
|
|
2269
|
+
const winner = map.get(key);
|
|
2270
|
+
if (!winner || winner === rejected || winner.session.status !== 'active')
|
|
2271
|
+
return undefined;
|
|
2272
|
+
return winner;
|
|
2273
|
+
}
|
|
2274
|
+
const transferInputGates = new WeakMap();
|
|
2275
|
+
// Only this module can mark the synchronous replacement fork. External callers
|
|
2276
|
+
// cannot forge an option that bypasses the transfer gate.
|
|
2277
|
+
const transferReplacementForkBypass = new WeakSet();
|
|
2278
|
+
export function isSessionTransferring(ds) {
|
|
2279
|
+
return transferInputGates.has(ds);
|
|
2280
|
+
}
|
|
2281
|
+
/** Register follow-up work that must run only after the relay gate is fully
|
|
2282
|
+
* released. Returns false when no transfer is active, so callers can proceed
|
|
2283
|
+
* immediately without a check-then-register race. */
|
|
2284
|
+
export function deferUntilSessionTransferSettled(ds, callback) {
|
|
2285
|
+
const gate = transferInputGates.get(ds);
|
|
2286
|
+
if (!gate)
|
|
2287
|
+
return false;
|
|
2288
|
+
gate.settledCallbacks.add(callback);
|
|
2289
|
+
return true;
|
|
2290
|
+
}
|
|
2291
|
+
function beginTransferInputGate(ds) {
|
|
2292
|
+
if (transferInputGates.has(ds))
|
|
2293
|
+
return undefined;
|
|
2294
|
+
const gate = {
|
|
2295
|
+
messages: [],
|
|
2296
|
+
needsWorker: false,
|
|
2297
|
+
released: false,
|
|
2298
|
+
flushing: false,
|
|
2299
|
+
settledCallbacks: new Set(),
|
|
2300
|
+
};
|
|
2301
|
+
transferInputGates.set(ds, gate);
|
|
2302
|
+
return gate;
|
|
2303
|
+
}
|
|
2304
|
+
function forkTransferReplacement(ds, forkWorkerImpl) {
|
|
2305
|
+
transferReplacementForkBypass.add(ds);
|
|
2306
|
+
try {
|
|
2307
|
+
forkWorkerImpl(ds, '', true);
|
|
2308
|
+
}
|
|
2309
|
+
finally {
|
|
2310
|
+
transferReplacementForkBypass.delete(ds);
|
|
2311
|
+
}
|
|
2312
|
+
}
|
|
2313
|
+
function bufferTransferInput(ds, message) {
|
|
2314
|
+
const gate = transferInputGates.get(ds);
|
|
2315
|
+
if (!gate)
|
|
2316
|
+
return false;
|
|
2317
|
+
gate.messages.push(message);
|
|
2318
|
+
logger.info(`[${tag(ds)}] Buffered ${message.type} input during routing transfer`
|
|
2319
|
+
+ `${'turnId' in message && message.turnId ? ` (turn ${message.turnId.substring(0, 8)})` : ''}`);
|
|
2320
|
+
// A committed transfer can retain its gate when the first replacement loses
|
|
2321
|
+
// IPC. The next accepted input is also a safe recovery trigger; before
|
|
2322
|
+
// commit, released=false prevents an eager fork on the source route.
|
|
2323
|
+
if (gate.released && gate.forkWorkerImpl) {
|
|
2324
|
+
queueMicrotask(() => {
|
|
2325
|
+
if (transferInputGates.get(ds) === gate) {
|
|
2326
|
+
releaseTransferInputGate(ds, gate, gate.forkWorkerImpl);
|
|
2327
|
+
}
|
|
2328
|
+
});
|
|
2329
|
+
}
|
|
2330
|
+
return true;
|
|
2331
|
+
}
|
|
2332
|
+
/** Route user/automation input through the transfer fence. Messages accepted
|
|
2333
|
+
* during detach are replayed byte-for-byte to the replacement generation.
|
|
2334
|
+
*/
|
|
2335
|
+
export function sendWorkerSessionInput(ds, message) {
|
|
2336
|
+
if (bufferTransferInput(ds, message))
|
|
2337
|
+
return true;
|
|
2338
|
+
if (!ds.worker || ds.worker.killed)
|
|
2339
|
+
return false;
|
|
2340
|
+
ds.worker.send(message);
|
|
2341
|
+
return true;
|
|
2342
|
+
}
|
|
2343
|
+
function settleTransferInputGate(ds, gate) {
|
|
2344
|
+
if (transferInputGates.get(ds) !== gate)
|
|
2345
|
+
return;
|
|
2346
|
+
transferInputGates.delete(ds);
|
|
2347
|
+
const callbacks = [...gate.settledCallbacks];
|
|
2348
|
+
gate.settledCallbacks.clear();
|
|
2349
|
+
for (const callback of callbacks) {
|
|
2350
|
+
queueMicrotask(() => {
|
|
2351
|
+
try {
|
|
2352
|
+
callback();
|
|
2353
|
+
}
|
|
2354
|
+
catch (err) {
|
|
2355
|
+
logger.error(`[${tag(ds)}] Post-transfer callback failed: `
|
|
2356
|
+
+ `${err instanceof Error ? err.message : String(err)}`);
|
|
2357
|
+
}
|
|
2358
|
+
});
|
|
2359
|
+
}
|
|
2360
|
+
}
|
|
2361
|
+
function releaseTransferInputGate(ds, gate, forkWorkerImpl, waitForWorkerExit) {
|
|
2362
|
+
gate.released = true;
|
|
2363
|
+
gate.forkWorkerImpl = forkWorkerImpl;
|
|
2364
|
+
const flush = () => {
|
|
2365
|
+
if (transferInputGates.get(ds) !== gate)
|
|
2366
|
+
return;
|
|
2367
|
+
if (gate.flushing)
|
|
2368
|
+
return;
|
|
2369
|
+
gate.flushing = true;
|
|
2370
|
+
try {
|
|
2371
|
+
if (ds.session.status === 'active'
|
|
2372
|
+
&& (gate.needsWorker || gate.messages.length > 0)
|
|
2373
|
+
&& currentDeviceIsolationFreezeLease()
|
|
2374
|
+
&& deferWorkerSpawnDuringDeviceIsolation(ds.session.sessionId, flush)) {
|
|
2375
|
+
logger.info(`[${tag(ds)}] Holding transfer-buffered input behind device-isolation freeze`);
|
|
2376
|
+
return;
|
|
2377
|
+
}
|
|
2378
|
+
if (ds.session.status !== 'active') {
|
|
2379
|
+
settleTransferInputGate(ds, gate);
|
|
2380
|
+
if (gate.messages.length > 0) {
|
|
2381
|
+
logger.warn(`[${tag(ds)}] Dropping ${gate.messages.length} transfer-buffered input(s); session is no longer active`);
|
|
2382
|
+
}
|
|
2383
|
+
return;
|
|
2384
|
+
}
|
|
2385
|
+
const workerUnavailable = () => !ds.worker || ds.worker.killed || ds.worker.connected === false;
|
|
2386
|
+
if (workerUnavailable() && (gate.needsWorker || gate.messages.length > 0)) {
|
|
2387
|
+
// Source-route timeout and successful target transfer both preserve CLI
|
|
2388
|
+
// history. Start an empty resume generation first, then replay the exact
|
|
2389
|
+
// frozen IPC messages in order behind its init message.
|
|
2390
|
+
try {
|
|
2391
|
+
forkTransferReplacement(ds, forkWorkerImpl);
|
|
2392
|
+
}
|
|
2393
|
+
catch (err) {
|
|
2394
|
+
gate.needsWorker = true;
|
|
2395
|
+
logger.error(`[${tag(ds)}] Replacement worker fork failed after routing transfer; `
|
|
2396
|
+
+ `retaining ${gate.messages.length} buffered input(s): `
|
|
2397
|
+
+ `${err instanceof Error ? err.message : String(err)}`);
|
|
2398
|
+
return;
|
|
2399
|
+
}
|
|
2400
|
+
}
|
|
2401
|
+
if (gate.messages.length === 0) {
|
|
2402
|
+
gate.needsWorker = false;
|
|
2403
|
+
settleTransferInputGate(ds, gate);
|
|
2404
|
+
return;
|
|
2405
|
+
}
|
|
2406
|
+
const worker = ds.worker;
|
|
2407
|
+
if (!worker || worker.killed || worker.connected === false) {
|
|
2408
|
+
gate.needsWorker = true;
|
|
2409
|
+
logger.error(`[${tag(ds)}] Unable to replay ${gate.messages.length} transfer-buffered input(s); `
|
|
2410
|
+
+ 'replacement worker has no live IPC channel, retaining them for recovery');
|
|
2411
|
+
return;
|
|
2412
|
+
}
|
|
2413
|
+
while (gate.messages.length > 0) {
|
|
2414
|
+
const message = gate.messages[0];
|
|
2415
|
+
try {
|
|
2416
|
+
worker.send(message);
|
|
2417
|
+
gate.messages.shift();
|
|
2418
|
+
}
|
|
2419
|
+
catch (err) {
|
|
2420
|
+
gate.needsWorker = true;
|
|
2421
|
+
logger.error(`[${tag(ds)}] Replacement worker IPC failed while replaying ${message.type}; `
|
|
2422
|
+
+ `retaining ${gate.messages.length} undelivered input(s): `
|
|
2423
|
+
+ `${err instanceof Error ? err.message : String(err)}`);
|
|
2424
|
+
if (typeof worker.once === 'function'
|
|
2425
|
+
&& worker.exitCode === null
|
|
2426
|
+
&& worker.signalCode === null) {
|
|
2427
|
+
worker.once('exit', () => {
|
|
2428
|
+
if (ds.worker === worker)
|
|
2429
|
+
ds.worker = null;
|
|
2430
|
+
flush();
|
|
2431
|
+
});
|
|
2432
|
+
}
|
|
2433
|
+
return;
|
|
2434
|
+
}
|
|
2435
|
+
}
|
|
2436
|
+
gate.needsWorker = false;
|
|
2437
|
+
settleTransferInputGate(ds, gate);
|
|
2438
|
+
}
|
|
2439
|
+
finally {
|
|
2440
|
+
gate.flushing = false;
|
|
2441
|
+
}
|
|
2442
|
+
};
|
|
2443
|
+
if (waitForWorkerExit
|
|
2444
|
+
&& waitForWorkerExit.exitCode === null
|
|
2445
|
+
&& waitForWorkerExit.signalCode === null) {
|
|
2446
|
+
// Hard retirement is fail-closed: keep buffering until the OS confirms the
|
|
2447
|
+
// uncertain old worker can no longer receive a late detach or user input.
|
|
2448
|
+
waitForWorkerExit.once('exit', () => {
|
|
2449
|
+
clearTransferWorkerState(ds, waitForWorkerExit, 'releaseTransferInputGate:late_forced_exit');
|
|
2450
|
+
flush();
|
|
2451
|
+
});
|
|
2452
|
+
return;
|
|
2453
|
+
}
|
|
2454
|
+
flush();
|
|
1515
2455
|
}
|
|
1516
|
-
// ─── Session transfer (cross-chat relay) ────────────────────────────────────
|
|
1517
2456
|
/**
|
|
1518
2457
|
* Transfer an active session from its current chat to a new chat. The CLI
|
|
1519
2458
|
* process keeps running inside its tmux session — only the routing fields
|
|
@@ -1580,6 +2519,19 @@ targetScope, opts) {
|
|
|
1580
2519
|
const ds = findActiveBySessionId(sessionId);
|
|
1581
2520
|
if (!ds)
|
|
1582
2521
|
return { ok: false, error: 'session_not_active' };
|
|
2522
|
+
const sourceSession = ds.session;
|
|
2523
|
+
const sourceLifecycleIdentity = JSON.stringify({
|
|
2524
|
+
sessionId: sourceSession.sessionId,
|
|
2525
|
+
cliId: sourceSession.cliId,
|
|
2526
|
+
cliSessionId: sourceSession.cliSessionId,
|
|
2527
|
+
backendType: sourceSession.backendType,
|
|
2528
|
+
persistentBackendTarget: sourceSession.persistentBackendTarget,
|
|
2529
|
+
riffParentTaskId: sourceSession.riffParentTaskId,
|
|
2530
|
+
workingDir: sourceSession.workingDir,
|
|
2531
|
+
adoptedFrom: sourceSession.adoptedFrom,
|
|
2532
|
+
runtimeWorkingDir: ds.workingDir,
|
|
2533
|
+
runtimeAdoptedFrom: ds.adoptedFrom,
|
|
2534
|
+
});
|
|
1583
2535
|
if (ds.session.vcMeetingReceiver)
|
|
1584
2536
|
return { ok: false, error: 'vc_receiver_not_relayable' };
|
|
1585
2537
|
// Anchor-based identity. A thread-scope session in the SAME chat (different
|
|
@@ -1591,6 +2543,48 @@ targetScope, opts) {
|
|
|
1591
2543
|
const targetAnchor = targetScope === 'chat' ? targetChatId : targetRootMessageId;
|
|
1592
2544
|
if (targetAnchor === sourceAnchor)
|
|
1593
2545
|
return { ok: false, error: 'same_anchor' };
|
|
2546
|
+
const sourceKey = sessionKey(sourceAnchor, ds.larkAppId);
|
|
2547
|
+
const targetKey = sessionKey(targetAnchor, ds.larkAppId);
|
|
2548
|
+
const validateSourceIdentity = () => {
|
|
2549
|
+
if (ds.session.status !== 'active'
|
|
2550
|
+
|| ds.session !== sourceSession
|
|
2551
|
+
|| ds.session.sessionId !== sessionId
|
|
2552
|
+
|| JSON.stringify({
|
|
2553
|
+
sessionId: ds.session.sessionId,
|
|
2554
|
+
cliId: ds.session.cliId,
|
|
2555
|
+
cliSessionId: ds.session.cliSessionId,
|
|
2556
|
+
backendType: ds.session.backendType,
|
|
2557
|
+
persistentBackendTarget: ds.session.persistentBackendTarget,
|
|
2558
|
+
riffParentTaskId: ds.session.riffParentTaskId,
|
|
2559
|
+
workingDir: ds.session.workingDir,
|
|
2560
|
+
adoptedFrom: ds.session.adoptedFrom,
|
|
2561
|
+
runtimeWorkingDir: ds.workingDir,
|
|
2562
|
+
runtimeAdoptedFrom: ds.adoptedFrom,
|
|
2563
|
+
}) !== sourceLifecycleIdentity
|
|
2564
|
+
|| activeSessionsRegistry?.get(sourceKey) !== ds) {
|
|
2565
|
+
return { ok: false, error: 'session_not_active' };
|
|
2566
|
+
}
|
|
2567
|
+
return undefined;
|
|
2568
|
+
};
|
|
2569
|
+
const validateAfterAwait = () => {
|
|
2570
|
+
const sourceError = validateSourceIdentity();
|
|
2571
|
+
if (sourceError)
|
|
2572
|
+
return sourceError;
|
|
2573
|
+
if (currentDeviceIsolationFreezeLease()) {
|
|
2574
|
+
return { ok: false, error: 'worker_busy' };
|
|
2575
|
+
}
|
|
2576
|
+
const targetOccupant = activeSessionsRegistry?.get(targetKey);
|
|
2577
|
+
if (targetOccupant && targetOccupant !== ds) {
|
|
2578
|
+
return { ok: false, error: 'target_chat_has_session' };
|
|
2579
|
+
}
|
|
2580
|
+
return undefined;
|
|
2581
|
+
};
|
|
2582
|
+
// The initial target scan below intentionally permits worker-less command
|
|
2583
|
+
// scratches and closes them. At entry only validate source ownership; after
|
|
2584
|
+
// those awaited closes the strict validator requires the target to be empty.
|
|
2585
|
+
const initialStateError = validateSourceIdentity();
|
|
2586
|
+
if (initialStateError)
|
|
2587
|
+
return initialStateError;
|
|
1594
2588
|
// pendingRepo: the user created a session via M0 but hasn't picked a repo
|
|
1595
2589
|
// yet, so worker is null and the CLI has never run. Relaying produces an
|
|
1596
2590
|
// empty new-chat session with no AI memory — refuse so the user finishes
|
|
@@ -1618,126 +2612,207 @@ targetScope, opts) {
|
|
|
1618
2612
|
// leader had already abort+report 'busy', producing reports that
|
|
1619
2613
|
// disagreed with reality. Cleaner contract: refuse on first miss, let
|
|
1620
2614
|
// the user retry when the turn settles.
|
|
2615
|
+
if (isSessionLifecycleInFlight(ds)) {
|
|
2616
|
+
return { ok: false, error: 'worker_busy' };
|
|
2617
|
+
}
|
|
1621
2618
|
const st = ds.lastScreenStatus;
|
|
1622
2619
|
if (ds.worker && !ds.worker.killed && st !== 'idle' && st !== 'limited') {
|
|
1623
2620
|
return { ok: false, error: 'worker_busy' };
|
|
1624
2621
|
}
|
|
1625
|
-
|
|
1626
|
-
|
|
1627
|
-
|
|
1628
|
-
|
|
1629
|
-
|
|
1630
|
-
|
|
1631
|
-
|
|
1632
|
-
|
|
1633
|
-
|
|
1634
|
-
|
|
1635
|
-
|
|
1636
|
-
|
|
1637
|
-
|
|
1638
|
-
|
|
1639
|
-
|
|
1640
|
-
|
|
1641
|
-
|
|
1642
|
-
|
|
1643
|
-
|
|
1644
|
-
|
|
1645
|
-
|
|
1646
|
-
|
|
1647
|
-
|
|
1648
|
-
|
|
1649
|
-
|
|
1650
|
-
|
|
1651
|
-
|
|
2622
|
+
if (currentDeviceIsolationFreezeLease()) {
|
|
2623
|
+
return { ok: false, error: 'worker_busy' };
|
|
2624
|
+
}
|
|
2625
|
+
const fkw = opts?.forkWorkerImpl ?? forkWorker;
|
|
2626
|
+
const detach = opts?.detachWorkerImpl ?? detachWorkerForTransfer;
|
|
2627
|
+
const transferGate = beginTransferInputGate(ds);
|
|
2628
|
+
if (!transferGate)
|
|
2629
|
+
return { ok: false, error: 'worker_busy' };
|
|
2630
|
+
let uncertainRetiringWorker;
|
|
2631
|
+
let sourceDetached = false;
|
|
2632
|
+
let routingCommitted = false;
|
|
2633
|
+
try {
|
|
2634
|
+
// Existing-session guard: a session sharing the *target anchor* would
|
|
2635
|
+
// collide on sessionKey(targetAnchor, larkAppId) after the rewrite, and
|
|
2636
|
+
// Map.set would silently orphan the prior entry's worker. We split the
|
|
2637
|
+
// collision predicate two ways:
|
|
2638
|
+
// - real session (worker !== null): refuse the transfer
|
|
2639
|
+
// - scratch session (worker === null): a daemon-command placeholder
|
|
2640
|
+
// (e.g. the /relay command itself created one when typed in this
|
|
2641
|
+
// chat); the slot is logically free, but the placeholder lingers in
|
|
2642
|
+
// the store with status='active'. Collect and close it so the post-
|
|
2643
|
+
// transfer Map.set doesn't silently overwrite it (which leaves the
|
|
2644
|
+
// scratch as a ghost-active on next daemon restart — exact bug we're
|
|
2645
|
+
// fixing).
|
|
2646
|
+
// Anchor-based: chat-scope anchors on chatId, thread-scope on rootMessageId.
|
|
2647
|
+
// Only a session at the target anchor collides — same-chat other-topic
|
|
2648
|
+
// sessions have a different anchor and are fine (enables同群话题间搬运).
|
|
2649
|
+
const scratchesToClose = [];
|
|
2650
|
+
if (activeSessionsRegistry) {
|
|
2651
|
+
for (const existing of activeSessionsRegistry.values()) {
|
|
2652
|
+
if (existing === ds)
|
|
2653
|
+
continue;
|
|
2654
|
+
if (existing.larkAppId !== ds.larkAppId)
|
|
2655
|
+
continue;
|
|
2656
|
+
if (sessionAnchorId(existing) !== targetAnchor)
|
|
2657
|
+
continue;
|
|
2658
|
+
if (isDisposableCommandScratch(existing)) {
|
|
2659
|
+
scratchesToClose.push(existing.session.sessionId);
|
|
2660
|
+
continue;
|
|
2661
|
+
}
|
|
2662
|
+
return { ok: false, error: 'target_chat_has_session' };
|
|
1652
2663
|
}
|
|
1653
|
-
return { ok: false, error: 'target_chat_has_session' };
|
|
1654
2664
|
}
|
|
1655
|
-
|
|
1656
|
-
|
|
1657
|
-
|
|
1658
|
-
|
|
1659
|
-
|
|
1660
|
-
|
|
1661
|
-
|
|
1662
|
-
|
|
1663
|
-
|
|
1664
|
-
|
|
1665
|
-
|
|
1666
|
-
|
|
1667
|
-
|
|
1668
|
-
|
|
1669
|
-
// card in the target chat. PATCH the source-chat card to an inert
|
|
1670
|
-
// snapshot so the user sees clearly it's historical, and so the buttons
|
|
1671
|
-
// are gone. Best-effort: on PATCH failure (card withdrawn, expired) we
|
|
1672
|
-
// log and continue; the relay itself must not depend on this.
|
|
1673
|
-
if (ds.streamCardId && ds.streamCardId !== CARD_POSTING_SENTINEL) {
|
|
2665
|
+
for (const sid of scratchesToClose) {
|
|
2666
|
+
await closeSession(sid);
|
|
2667
|
+
}
|
|
2668
|
+
const postScratchStateError = validateAfterAwait();
|
|
2669
|
+
if (postScratchStateError)
|
|
2670
|
+
return postScratchStateError;
|
|
2671
|
+
const tagPrefix = sessionId.substring(0, 8);
|
|
2672
|
+
const oldAnchor = sessionAnchorId(ds);
|
|
2673
|
+
const oldChatId = ds.chatId;
|
|
2674
|
+
// Detach only the worker/observer. Persistent backends and Riff keep the
|
|
2675
|
+
// owned CLI/task alive so the replacement can reattach below. PTY cannot
|
|
2676
|
+
// survive a worker exit and therefore retains its historical cold-resume.
|
|
2677
|
+
const sourceWorker = ds.worker ?? undefined;
|
|
2678
|
+
let detached;
|
|
1674
2679
|
try {
|
|
1675
|
-
|
|
1676
|
-
?? (() => { try {
|
|
1677
|
-
return getBot(ds.larkAppId).config.cliId;
|
|
1678
|
-
}
|
|
1679
|
-
catch {
|
|
1680
|
-
return undefined;
|
|
1681
|
-
} })();
|
|
1682
|
-
const frozenJson = buildRelayedFrozenCard(ds.currentTurnTitle || ds.session.title || '', cliId, ds.currentImageKey, localeForBot(ds.larkAppId));
|
|
1683
|
-
await updateMessage(ds.larkAppId, ds.streamCardId, frozenJson);
|
|
2680
|
+
detached = (await detach(ds)) !== false;
|
|
1684
2681
|
}
|
|
1685
|
-
catch
|
|
1686
|
-
|
|
2682
|
+
catch {
|
|
2683
|
+
detached = false;
|
|
2684
|
+
}
|
|
2685
|
+
if (!detached) {
|
|
2686
|
+
// The real detach timeout hard-retires the old worker before returning
|
|
2687
|
+
// false. Even with no buffered user input, the unchanged source route
|
|
2688
|
+
// therefore needs a cold reattach; otherwise an active session remains
|
|
2689
|
+
// silently workerless until some later message happens to wake it.
|
|
2690
|
+
transferGate.needsWorker = true;
|
|
2691
|
+
if (sourceWorker
|
|
2692
|
+
&& sourceWorker.exitCode === null
|
|
2693
|
+
&& sourceWorker.signalCode === null) {
|
|
2694
|
+
uncertainRetiringWorker = sourceWorker;
|
|
2695
|
+
}
|
|
2696
|
+
return { ok: false, error: 'worker_detach_timeout' };
|
|
2697
|
+
}
|
|
2698
|
+
sourceDetached = true;
|
|
2699
|
+
const postDetachStateError = validateAfterAwait();
|
|
2700
|
+
if (postDetachStateError)
|
|
2701
|
+
return postDetachStateError;
|
|
2702
|
+
// Build the source-card snapshot before clearing its runtime fields, but do
|
|
2703
|
+
// not PATCH it until the routing commit is irreversible. Marking the card
|
|
2704
|
+
// "relayed" before an awaited validation used to leave a false tombstone
|
|
2705
|
+
// when a target collision/device freeze won that race.
|
|
2706
|
+
let sourceCardFreeze;
|
|
2707
|
+
if (ds.streamCardId && ds.streamCardId !== CARD_POSTING_SENTINEL) {
|
|
2708
|
+
try {
|
|
2709
|
+
const cliId = ds.session.cliId
|
|
2710
|
+
?? (() => { try {
|
|
2711
|
+
return getBot(ds.larkAppId).config.cliId;
|
|
2712
|
+
}
|
|
2713
|
+
catch {
|
|
2714
|
+
return undefined;
|
|
2715
|
+
} })();
|
|
2716
|
+
sourceCardFreeze = {
|
|
2717
|
+
cardId: ds.streamCardId,
|
|
2718
|
+
cardJson: buildRelayedFrozenCard(ds.currentTurnTitle || ds.session.title || '', cliId, ds.currentImageKey, localeForBot(ds.larkAppId)),
|
|
2719
|
+
};
|
|
2720
|
+
}
|
|
2721
|
+
catch (err) {
|
|
2722
|
+
logger.warn(`[${tagPrefix}] build source-chat frozen card failed: ${err instanceof Error ? err.message : err}`);
|
|
2723
|
+
}
|
|
2724
|
+
}
|
|
2725
|
+
activeSessionsRegistry?.delete(sessionKey(oldAnchor, ds.larkAppId));
|
|
2726
|
+
// Rewrite routing fields per the requested target scope.
|
|
2727
|
+
// chat-scope: routes by chatId; `targetRootMessageId` (e.g. an M1 id) is
|
|
2728
|
+
// stored on rootMessageId but is purely audit/UX.
|
|
2729
|
+
// thread-scope: routes by rootMessageId; `targetRootMessageId` IS the
|
|
2730
|
+
// routing anchor (the Lark 话题 root) — replies reply_in_thread
|
|
2731
|
+
// to it, and future inbound messages in that 话题 resolve to
|
|
2732
|
+
// the same anchor.
|
|
2733
|
+
ds.session.chatId = targetChatId;
|
|
2734
|
+
ds.session.rootMessageId = targetRootMessageId;
|
|
2735
|
+
ds.session.scope = targetScope;
|
|
2736
|
+
ds.session.chatType = targetChatType;
|
|
2737
|
+
ds.session.lastMessageAt = new Date().toISOString();
|
|
2738
|
+
// Card state was pinned to the source chat — clear so the new worker posts
|
|
2739
|
+
// a fresh card in the target chat instead of trying to PATCH a message that
|
|
2740
|
+
// lives in another chat entirely (the source card was just frozen above).
|
|
2741
|
+
ds.session.streamCardId = undefined;
|
|
2742
|
+
ds.session.streamCardNonce = undefined;
|
|
2743
|
+
ds.session.currentImageKey = undefined;
|
|
2744
|
+
// Mirror onto runtime DaemonSession.
|
|
2745
|
+
ds.chatId = targetChatId;
|
|
2746
|
+
ds.chatType = targetChatType;
|
|
2747
|
+
ds.scope = targetScope;
|
|
2748
|
+
ds.streamCardId = undefined;
|
|
2749
|
+
ds.streamCardNonce = undefined;
|
|
2750
|
+
ds.currentImageKey = undefined;
|
|
2751
|
+
sessionStore.updateSession(ds.session);
|
|
2752
|
+
const newAnchor = sessionAnchorId(ds);
|
|
2753
|
+
if (activeSessionsRegistry) {
|
|
2754
|
+
if (!setActiveSessionIfActive(activeSessionsRegistry, sessionKey(newAnchor, ds.larkAppId), ds)) {
|
|
2755
|
+
return { ok: false, error: 'session_not_active' };
|
|
2756
|
+
}
|
|
2757
|
+
}
|
|
2758
|
+
routingCommitted = true;
|
|
2759
|
+
dashboardEventBus.publish({
|
|
2760
|
+
type: 'session.update',
|
|
2761
|
+
body: {
|
|
2762
|
+
sessionId,
|
|
2763
|
+
patch: {
|
|
2764
|
+
chatId: targetChatId,
|
|
2765
|
+
rootMessageId: targetRootMessageId,
|
|
2766
|
+
scope: targetScope,
|
|
2767
|
+
chatType: targetChatType,
|
|
2768
|
+
},
|
|
2769
|
+
},
|
|
2770
|
+
});
|
|
2771
|
+
// Best-effort only, and deliberately after the routing commit: an awaited
|
|
2772
|
+
// Lark PATCH must never make the source card claim a move that ultimately
|
|
2773
|
+
// failed. Do not await network I/O after commit either: transfer completion
|
|
2774
|
+
// and replacement startup must not reopen a target/close race window.
|
|
2775
|
+
if (sourceCardFreeze) {
|
|
2776
|
+
void updateMessage(ds.larkAppId, sourceCardFreeze.cardId, sourceCardFreeze.cardJson)
|
|
2777
|
+
.catch((err) => {
|
|
2778
|
+
logger.warn(`[${tagPrefix}] freeze source-chat card failed: ${err instanceof Error ? err.message : err}`);
|
|
2779
|
+
});
|
|
2780
|
+
}
|
|
2781
|
+
// forkWorker with resume=true — TmuxBackend.spawn detects the surviving
|
|
2782
|
+
// `bmx-<sessionId>` session and re-attaches instead of creating a new one.
|
|
2783
|
+
if (ds.session.status === 'active'
|
|
2784
|
+
&& (!activeSessionsRegistry
|
|
2785
|
+
|| activeSessionsRegistry.get(sessionKey(newAnchor, ds.larkAppId)) === ds)) {
|
|
2786
|
+
try {
|
|
2787
|
+
forkTransferReplacement(ds, fkw);
|
|
2788
|
+
}
|
|
2789
|
+
catch (err) {
|
|
2790
|
+
// Routing is already committed. A replacement-spawn failure is an
|
|
2791
|
+
// availability problem, not a failed move; keep the gate so the next
|
|
2792
|
+
// input can retry on the target route without falsifying the result.
|
|
2793
|
+
transferGate.needsWorker = true;
|
|
2794
|
+
logger.error(`[${tagPrefix}] replacement worker fork failed after transfer commit: `
|
|
2795
|
+
+ `${err instanceof Error ? err.message : String(err)}`);
|
|
2796
|
+
}
|
|
1687
2797
|
}
|
|
2798
|
+
logger.info(`[${tagPrefix}] transferred ${oldChatId} → ${targetChatId} ` +
|
|
2799
|
+
`(anchor ${oldAnchor.substring(0, 8)} → ${newAnchor.substring(0, 8)})`);
|
|
2800
|
+
return { ok: true };
|
|
1688
2801
|
}
|
|
1689
|
-
|
|
1690
|
-
|
|
1691
|
-
|
|
1692
|
-
|
|
1693
|
-
|
|
1694
|
-
|
|
1695
|
-
|
|
1696
|
-
|
|
1697
|
-
|
|
1698
|
-
|
|
1699
|
-
|
|
1700
|
-
|
|
1701
|
-
|
|
1702
|
-
ds.session.scope = targetScope;
|
|
1703
|
-
ds.session.chatType = targetChatType;
|
|
1704
|
-
ds.session.lastMessageAt = new Date().toISOString();
|
|
1705
|
-
// Card state was pinned to the source chat — clear so the new worker posts
|
|
1706
|
-
// a fresh card in the target chat instead of trying to PATCH a message that
|
|
1707
|
-
// lives in another chat entirely (the source card was just frozen above).
|
|
1708
|
-
ds.session.streamCardId = undefined;
|
|
1709
|
-
ds.session.streamCardNonce = undefined;
|
|
1710
|
-
ds.session.currentImageKey = undefined;
|
|
1711
|
-
// Mirror onto runtime DaemonSession.
|
|
1712
|
-
ds.chatId = targetChatId;
|
|
1713
|
-
ds.chatType = targetChatType;
|
|
1714
|
-
ds.scope = targetScope;
|
|
1715
|
-
ds.streamCardId = undefined;
|
|
1716
|
-
ds.streamCardNonce = undefined;
|
|
1717
|
-
ds.currentImageKey = undefined;
|
|
1718
|
-
sessionStore.updateSession(ds.session);
|
|
1719
|
-
const newAnchor = sessionAnchorId(ds);
|
|
1720
|
-
if (activeSessionsRegistry) {
|
|
1721
|
-
await setActiveSessionSafe(activeSessionsRegistry, sessionKey(newAnchor, ds.larkAppId), ds);
|
|
2802
|
+
finally {
|
|
2803
|
+
if (sourceDetached
|
|
2804
|
+
&& !routingCommitted
|
|
2805
|
+
&& ds.session.status === 'active'
|
|
2806
|
+
&& (!activeSessionsRegistry
|
|
2807
|
+
|| activeSessionsRegistry.get(sourceKey) === ds)) {
|
|
2808
|
+
// A target/device/lifecycle race after a successful detach leaves the
|
|
2809
|
+
// routing on the source. Reattach even when no user input happened
|
|
2810
|
+
// during the critical section; otherwise the active session silently
|
|
2811
|
+
// remains workerless until a later message arrives.
|
|
2812
|
+
transferGate.needsWorker = true;
|
|
2813
|
+
}
|
|
2814
|
+
releaseTransferInputGate(ds, transferGate, fkw, uncertainRetiringWorker);
|
|
1722
2815
|
}
|
|
1723
|
-
dashboardEventBus.publish({
|
|
1724
|
-
type: 'session.update',
|
|
1725
|
-
body: {
|
|
1726
|
-
sessionId,
|
|
1727
|
-
patch: {
|
|
1728
|
-
chatId: targetChatId,
|
|
1729
|
-
rootMessageId: targetRootMessageId,
|
|
1730
|
-
scope: targetScope,
|
|
1731
|
-
chatType: targetChatType,
|
|
1732
|
-
},
|
|
1733
|
-
},
|
|
1734
|
-
});
|
|
1735
|
-
// forkWorker with resume=true — TmuxBackend.spawn detects the surviving
|
|
1736
|
-
// `bmx-<sessionId>` session and re-attaches instead of creating a new one.
|
|
1737
|
-
fkw(ds, '', /*resume*/ true);
|
|
1738
|
-
logger.info(`[${tagPrefix}] transferred ${oldChatId} → ${targetChatId} ` +
|
|
1739
|
-
`(anchor ${oldAnchor.substring(0, 8)} → ${newAnchor.substring(0, 8)})`);
|
|
1740
|
-
return { ok: true };
|
|
1741
2816
|
}
|
|
1742
2817
|
// ─── Fork worker ────────────────────────────────────────────────────────────
|
|
1743
2818
|
/** True if `p` resolves (via realpath) to the user's home dir. Used to exclude
|
|
@@ -1752,6 +2827,18 @@ function resolvesToHome(p) {
|
|
|
1752
2827
|
return p === homedir();
|
|
1753
2828
|
}
|
|
1754
2829
|
}
|
|
2830
|
+
function canForkRegisteredSession(ds) {
|
|
2831
|
+
const key = activeSessionKey(ds);
|
|
2832
|
+
const registered = activeSessionsRegistry ? activeSessionsRegistry.get(key) : ds;
|
|
2833
|
+
if (ds.session.status === 'active' && registered === ds)
|
|
2834
|
+
return true;
|
|
2835
|
+
if (activeSessionsRegistry && ds.session.status !== 'active' && registered === ds) {
|
|
2836
|
+
activeSessionsRegistry.delete(key);
|
|
2837
|
+
}
|
|
2838
|
+
logger.warn(`[${tag(ds)}] Refusing to fork a closed or superseded session ` +
|
|
2839
|
+
`(status=${ds.session.status}, registered=${registered === ds})`);
|
|
2840
|
+
return false;
|
|
2841
|
+
}
|
|
1755
2842
|
function codexAppInputForSession(ds, input, turnId) {
|
|
1756
2843
|
if (!input)
|
|
1757
2844
|
return undefined;
|
|
@@ -1767,7 +2854,8 @@ function codexAppInputForSession(ds, input, turnId) {
|
|
|
1767
2854
|
* clean-input gate at message acceptance time. This freezes the sidecar onto
|
|
1768
2855
|
* the IPC item, so later config flips do not mutate an already queued turn. */
|
|
1769
2856
|
export function sendWorkerInput(ds, payload, turnId, opts = {}) {
|
|
1770
|
-
|
|
2857
|
+
const transferGate = transferInputGates.get(ds);
|
|
2858
|
+
if ((!ds.worker || ds.worker.killed) && !transferGate)
|
|
1771
2859
|
return false;
|
|
1772
2860
|
const normalized = typeof payload === 'string' ? { content: payload } : payload;
|
|
1773
2861
|
const codexAppInput = codexAppInputForSession(ds, normalized.codexAppInput, turnId);
|
|
@@ -1791,7 +2879,7 @@ export function sendWorkerInput(ds, payload, turnId, opts = {}) {
|
|
|
1791
2879
|
}
|
|
1792
2880
|
}
|
|
1793
2881
|
const vcMeetingImTurnOrigin = resolveVcMeetingImTurnOrigin(ds.session, turnId);
|
|
1794
|
-
|
|
2882
|
+
const message = {
|
|
1795
2883
|
type: 'message',
|
|
1796
2884
|
content: normalized.content,
|
|
1797
2885
|
...(codexAppInput ? { codexAppInput } : {}),
|
|
@@ -1802,10 +2890,41 @@ export function sendWorkerInput(ds, payload, turnId, opts = {}) {
|
|
|
1802
2890
|
...(vcMeetingImTurnOrigin
|
|
1803
2891
|
? { vcMeetingImTurnOrigin }
|
|
1804
2892
|
: {}),
|
|
1805
|
-
}
|
|
1806
|
-
return
|
|
2893
|
+
};
|
|
2894
|
+
return sendWorkerSessionInput(ds, message);
|
|
1807
2895
|
}
|
|
1808
2896
|
export function forkWorker(ds, promptInput, resumeOrTurnId = false) {
|
|
2897
|
+
const gatedPrompt = typeof promptInput === 'string' ? { content: promptInput } : promptInput;
|
|
2898
|
+
const transferGate = transferInputGates.get(ds);
|
|
2899
|
+
if (transferGate && !transferReplacementForkBypass.has(ds)) {
|
|
2900
|
+
if (gatedPrompt.content !== '') {
|
|
2901
|
+
const gatedTurnId = typeof resumeOrTurnId === 'string'
|
|
2902
|
+
? resumeOrTurnId
|
|
2903
|
+
: typeof resumeOrTurnId === 'object' && resumeOrTurnId !== null
|
|
2904
|
+
? resumeOrTurnId.turnId
|
|
2905
|
+
: undefined;
|
|
2906
|
+
const gatedDispatchAttempt = typeof resumeOrTurnId === 'object' && resumeOrTurnId !== null
|
|
2907
|
+
? resumeOrTurnId.dispatchAttempt
|
|
2908
|
+
: undefined;
|
|
2909
|
+
sendWorkerInput(ds, promptInput, gatedTurnId, {
|
|
2910
|
+
...(gatedDispatchAttempt !== undefined
|
|
2911
|
+
? { dispatchAttempt: gatedDispatchAttempt }
|
|
2912
|
+
: {}),
|
|
2913
|
+
});
|
|
2914
|
+
}
|
|
2915
|
+
else {
|
|
2916
|
+
transferGate.needsWorker = true;
|
|
2917
|
+
}
|
|
2918
|
+
logger.info(`[${tag(ds)}] Deferred ${gatedPrompt.content === '' ? 'empty ' : ''}worker refork behind routing transfer`);
|
|
2919
|
+
if (transferGate.released && transferGate.forkWorkerImpl) {
|
|
2920
|
+
queueMicrotask(() => {
|
|
2921
|
+
if (transferInputGates.get(ds) === transferGate) {
|
|
2922
|
+
releaseTransferInputGate(ds, transferGate, transferGate.forkWorkerImpl);
|
|
2923
|
+
}
|
|
2924
|
+
});
|
|
2925
|
+
}
|
|
2926
|
+
return;
|
|
2927
|
+
}
|
|
1809
2928
|
// Device enrollment briefly freezes every daemon before the one-way host
|
|
1810
2929
|
// marker is installed and legacy local CLIs are torn down. Do this before
|
|
1811
2930
|
// ANY session mutation or child fork. One deferred spawn per logical session
|
|
@@ -1819,6 +2938,8 @@ export function forkWorker(ds, promptInput, resumeOrTurnId = false) {
|
|
|
1819
2938
|
logger.info(`[${tag(ds)}] worker spawn deferred during device credential activation`);
|
|
1820
2939
|
return;
|
|
1821
2940
|
}
|
|
2941
|
+
if (!canForkRegisteredSession(ds))
|
|
2942
|
+
return;
|
|
1822
2943
|
const cb = requireCallbacks();
|
|
1823
2944
|
const bot = getBot(ds.larkAppId);
|
|
1824
2945
|
const botCfg = bot.config;
|
|
@@ -1838,6 +2959,7 @@ export function forkWorker(ds, promptInput, resumeOrTurnId = false) {
|
|
|
1838
2959
|
const workerPath = join(__dirname, '..', 'worker.js');
|
|
1839
2960
|
const t = tag(ds);
|
|
1840
2961
|
ds.localProcessAttestation = undefined;
|
|
2962
|
+
ds.workerReady = false;
|
|
1841
2963
|
let resume = false;
|
|
1842
2964
|
let initTurnId;
|
|
1843
2965
|
let initDispatchAttempt;
|
|
@@ -1921,16 +3043,19 @@ export function forkWorker(ds, promptInput, resumeOrTurnId = false) {
|
|
|
1921
3043
|
const workerGeneration = reserveWorkerGeneration(ds);
|
|
1922
3044
|
// Guard against double-fork: if a worker is already running, kill it first
|
|
1923
3045
|
if (ds.worker && !ds.worker.killed) {
|
|
1924
|
-
|
|
3046
|
+
const replacedWorker = ds.worker;
|
|
3047
|
+
logger.warn(`[${t}] Worker already running (pid: ${replacedWorker.pid}), killing before re-fork`);
|
|
3048
|
+
trackLifecycleRetirement(ds, replacedWorker);
|
|
1925
3049
|
try {
|
|
1926
|
-
|
|
3050
|
+
replacedWorker.send({ type: 'close' });
|
|
1927
3051
|
}
|
|
1928
3052
|
catch { /* ignore */ }
|
|
1929
3053
|
try {
|
|
1930
|
-
|
|
3054
|
+
replacedWorker.kill();
|
|
1931
3055
|
}
|
|
1932
3056
|
catch { /* ignore */ }
|
|
1933
3057
|
ds.worker = null;
|
|
3058
|
+
ds.workerReady = false;
|
|
1934
3059
|
ds.workerPort = null;
|
|
1935
3060
|
ds.workerToken = null;
|
|
1936
3061
|
ds.workerViewToken = null;
|
|
@@ -1991,9 +3116,12 @@ export function forkWorker(ds, promptInput, resumeOrTurnId = false) {
|
|
|
1991
3116
|
const familyAdapter = createCliAdapterSync(agentCfg.cliId, agentCfg.cliPathOverride);
|
|
1992
3117
|
if (familyAdapter.claudeStateJsonPath)
|
|
1993
3118
|
ensureClaudeFolderTrust(cwd, familyAdapter.claudeStateJsonPath);
|
|
1994
|
-
// Prepend
|
|
1995
|
-
//
|
|
1996
|
-
|
|
3119
|
+
// Prepend the botmux wrapper bin dir to PATH so CLIs can call `botmux send` etc.
|
|
3120
|
+
// Single source of truth (resolveBotmuxWrapperBinDir): core-only → dedicated
|
|
3121
|
+
// `<SESSION_DATA_DIR>/bin` (not the shared ~/.botmux/bin), matching where the
|
|
3122
|
+
// daemon WROTE the wrapper — else the worker PATH would miss it or a same-HOME
|
|
3123
|
+
// fleet wrapper would shadow it (codex P1).
|
|
3124
|
+
const botmuxBinDir = resolveBotmuxWrapperBinDir(process.env);
|
|
1997
3125
|
const pathWithBotmux = prependBotmuxBin(botmuxBinDir, process.env.PATH);
|
|
1998
3126
|
const forkEnv = workerForkEnv(process.env);
|
|
1999
3127
|
const worker = fork(workerPath, [], {
|
|
@@ -2008,7 +3136,11 @@ export function forkWorker(ds, promptInput, resumeOrTurnId = false) {
|
|
|
2008
3136
|
SESSION_DATA_DIR: config.session.dataDir,
|
|
2009
3137
|
BOTMUX_SESSION_ID: ds.session.sessionId,
|
|
2010
3138
|
LARK_APP_ID: botCfg.larkAppId,
|
|
2011
|
-
|
|
3139
|
+
// Withhold the real secret from the worker's own CLI env for a no-transport
|
|
3140
|
+
// session (apiOnly bot or HTTP virtual chat). SEPARATE leak from the
|
|
3141
|
+
// init-message larkAppSecret — the spawned CLI env carries it directly from
|
|
3142
|
+
// botCfg. Empty it here too so no secret reaches the worker/sandbox.
|
|
3143
|
+
LARK_APP_SECRET: larkTransportEnabled({ chatId: ds.chatId, apiOnly: botCfg.apiOnly }) ? botCfg.larkAppSecret : '',
|
|
2012
3144
|
},
|
|
2013
3145
|
});
|
|
2014
3146
|
const startupState = {
|
|
@@ -2076,6 +3208,7 @@ export function forkWorker(ds, promptInput, resumeOrTurnId = false) {
|
|
|
2076
3208
|
wrapperCli: agentCfg.wrapperCli,
|
|
2077
3209
|
launchShell: botCfg.launchShell,
|
|
2078
3210
|
model: agentCfg.model,
|
|
3211
|
+
reasoningEffort: agentCfg.reasoningEffort,
|
|
2079
3212
|
disableCliBypass: botCfg.disableCliBypass === true,
|
|
2080
3213
|
codexRpcInput: botCfg.codexRpcInput === true || config.codexRpcInputDefault,
|
|
2081
3214
|
// Startup commands run on every fresh spawn (incl. resume) so session-only
|
|
@@ -2096,7 +3229,16 @@ export function forkWorker(ds, promptInput, resumeOrTurnId = false) {
|
|
|
2096
3229
|
// Per-bot local read isolation (enforced worker-side; the worker gates it).
|
|
2097
3230
|
// Sibling data needs no app-id enumeration: per-bot dirs are denied wholesale
|
|
2098
3231
|
// and per-bot session files by filename pattern (see buildV2DenyPaths).
|
|
2099
|
-
|
|
3232
|
+
// HARD credential boundary for a no-transport session (apiOnly bot OR HTTP
|
|
3233
|
+
// virtual chat): force read isolation so the CLI physically cannot read the
|
|
3234
|
+
// full bots.json / sibling BOT_HOME / send-cred / lark-cli store — a model
|
|
3235
|
+
// that deletes/forges the ancestry marker or bypasses the CLI still cannot
|
|
3236
|
+
// build ANY (sibling) Lark client. The pid-marker gate is only friendly
|
|
3237
|
+
// early-reject; THIS is the fail-closed boundary. Reuses the existing unified
|
|
3238
|
+
// fs-policy (mac+Linux fail-closed); a backend that can't isolate locally
|
|
3239
|
+
// refuses to spawn rather than leak creds.
|
|
3240
|
+
readIsolation: botCfg.readIsolation === true
|
|
3241
|
+
|| !larkTransportEnabled({ chatId: ds.chatId, apiOnly: botCfg.apiOnly }),
|
|
2100
3242
|
readDenyExtraPaths: botCfg.readDenyExtraPaths ?? [],
|
|
2101
3243
|
// Identifies THIS daemon lifetime. Stamped onto isolated panes so the worker
|
|
2102
3244
|
// can tell a suspend→resume reattach (same boot id, still isolated) from a
|
|
@@ -2125,7 +3267,20 @@ export function forkWorker(ds, promptInput, resumeOrTurnId = false) {
|
|
|
2125
3267
|
ownerOpenId: ds.ownerOpenId,
|
|
2126
3268
|
webPort: ds.session.webPort,
|
|
2127
3269
|
larkAppId: botCfg.larkAppId,
|
|
2128
|
-
|
|
3270
|
+
// Freeze on the session transport capability: a no-transport session
|
|
3271
|
+
// (apiOnly bot OR HTTP virtual chat) must not even RECEIVE the real secret —
|
|
3272
|
+
// withholding it removes the capability rather than gating a tamperable flag
|
|
3273
|
+
// the sandboxed agent could flip. The worker then physically cannot dial
|
|
3274
|
+
// Feishu (uploader/cred-write are also skipped downstream on the same test).
|
|
3275
|
+
larkAppSecret: larkTransportEnabled({ chatId: ds.chatId, apiOnly: botCfg.apiOnly }) ? botCfg.larkAppSecret : '',
|
|
3276
|
+
apiOnly: botCfg.apiOnly,
|
|
3277
|
+
// Freeze the ACTUAL loaded bots-config path (getLoadedConfigPath) so a
|
|
3278
|
+
// no-transport worker's fs-policy denies it from a HOST-owned fact, not a
|
|
3279
|
+
// guess off BOTS_CONFIG env (which the agent could see/forge). When it lives
|
|
3280
|
+
// outside every botmux authority root, buildFsPolicy fails the spawn closed
|
|
3281
|
+
// rather than silently masking an arbitrary parent dir (codex P1). Omitted
|
|
3282
|
+
// from forkAdoptWorker below — its observe branch returns before fs-policy.
|
|
3283
|
+
loadedBotsConfigPath: getLoadedConfigPath(),
|
|
2129
3284
|
brand: normalizeBrand(botCfg.brand),
|
|
2130
3285
|
botName: bot.botName,
|
|
2131
3286
|
botOpenId: bot.botOpenId,
|
|
@@ -2141,6 +3296,16 @@ export function forkWorker(ds, promptInput, resumeOrTurnId = false) {
|
|
|
2141
3296
|
...(ds.session.runnerBuildId ? { persistedRunnerBuildId: ds.session.runnerBuildId } : {}),
|
|
2142
3297
|
...(restartAttemptId ? { restartAttemptId } : {}),
|
|
2143
3298
|
};
|
|
3299
|
+
// A persisted port from an older ZMX implementation must never revive the
|
|
3300
|
+
// removed Web TUI or get forwarded as a preferred listen port. The worker
|
|
3301
|
+
// will signal lifecycle readiness with port=0 instead.
|
|
3302
|
+
if (!backendSupportsWebTerminal(initMsg.backendType)) {
|
|
3303
|
+
initMsg.webPort = undefined;
|
|
3304
|
+
if (ds.session.webPort !== undefined) {
|
|
3305
|
+
ds.session.webPort = undefined;
|
|
3306
|
+
sessionStore.updateSession(ds.session);
|
|
3307
|
+
}
|
|
3308
|
+
}
|
|
2144
3309
|
worker.send(initMsg);
|
|
2145
3310
|
ds.initConfig = initMsg;
|
|
2146
3311
|
// Stamp cliId on the persisted session so the dashboard can show a CLI badge
|
|
@@ -2230,6 +3395,50 @@ export function invalidateStuckWarning(ds, reason) {
|
|
|
2230
3395
|
logger.debug(`[${t}] invalidateStuckWarning (${reason}): turn=${ds.stuckWarningTurnId ?? 'none'} nonce=${ds.stuckWarningNonce ?? 'none'}`);
|
|
2231
3396
|
clearStuckWarningAuthority(ds);
|
|
2232
3397
|
}
|
|
3398
|
+
function hasTuiPromptAuthority(ds) {
|
|
3399
|
+
return ds.tuiPromptCardId !== undefined
|
|
3400
|
+
|| ds.tuiPromptOptions !== undefined
|
|
3401
|
+
|| ds.tuiPromptMultiSelect !== undefined
|
|
3402
|
+
|| ds.tuiToggledIndices !== undefined
|
|
3403
|
+
|| !!ds.tuiPromptProcessing;
|
|
3404
|
+
}
|
|
3405
|
+
/**
|
|
3406
|
+
* Drop every daemon-side marker owned by one interactive TUI prompt. The
|
|
3407
|
+
* processing flag and option metadata are part of the same authority as the
|
|
3408
|
+
* card ID: retaining any subset lets a dead generation block or mutate the
|
|
3409
|
+
* next prompt.
|
|
3410
|
+
*/
|
|
3411
|
+
function clearTuiPromptAuthority(ds) {
|
|
3412
|
+
ds.tuiPromptCardId = undefined;
|
|
3413
|
+
ds.tuiPromptOptions = undefined;
|
|
3414
|
+
ds.tuiPromptMultiSelect = undefined;
|
|
3415
|
+
ds.tuiToggledIndices = undefined;
|
|
3416
|
+
ds.tuiPromptProcessing = false;
|
|
3417
|
+
}
|
|
3418
|
+
/**
|
|
3419
|
+
* Retire an outstanding TUI prompt when its worker/CLI lifetime ends. A worker
|
|
3420
|
+
* can die after receiving a card click but before acknowledging backend
|
|
3421
|
+
* delivery; resolving the card and clearing all authority here prevents that
|
|
3422
|
+
* narrow window from permanently occupying the session's prompt slot.
|
|
3423
|
+
*/
|
|
3424
|
+
function invalidateTuiPrompt(ds, reason, outcome = 'failed') {
|
|
3425
|
+
if (!hasTuiPromptAuthority(ds))
|
|
3426
|
+
return;
|
|
3427
|
+
const t = tag(ds);
|
|
3428
|
+
if (ds.tuiPromptCardId) {
|
|
3429
|
+
const locDs = localeForBot(ds.larkAppId);
|
|
3430
|
+
const cliId = ds.session.cliId ?? ds.initConfig?.cliId;
|
|
3431
|
+
const terminalCard = outcome === 'resolved'
|
|
3432
|
+
? buildTuiPromptResolvedCard(tr('card.action.tui_done', undefined, locDs), locDs)
|
|
3433
|
+
: buildTuiPromptFailedCard(tr('worker.tui_submit_failed', {
|
|
3434
|
+
cliName: cliId ? getCliDisplayName(cliId) : 'CLI',
|
|
3435
|
+
}, locDs), locDs);
|
|
3436
|
+
updateMessage(ds.larkAppId, ds.tuiPromptCardId, terminalCard).catch(err => logger.debug(`[${t}] Failed to update terminal TUI prompt card (${reason}): ${err}`));
|
|
3437
|
+
}
|
|
3438
|
+
logger.debug(`[${t}] invalidateTuiPrompt (${reason}): card=${ds.tuiPromptCardId ?? 'none'}`);
|
|
3439
|
+
clearTuiPromptAuthority(ds);
|
|
3440
|
+
publishAttentionPatch(ds);
|
|
3441
|
+
}
|
|
2233
3442
|
function setupWorkerHandlers(ds, worker, startupState = { ready: false, failureNotified: false }, reservedWorkerGeneration) {
|
|
2234
3443
|
const cb = requireCallbacks();
|
|
2235
3444
|
const t = tag(ds);
|
|
@@ -2239,11 +3448,25 @@ function setupWorkerHandlers(ds, worker, startupState = { ready: false, failureN
|
|
|
2239
3448
|
|| ds.session.workerGeneration !== workerGeneration) {
|
|
2240
3449
|
throw new Error('worker generation reservation changed before IPC setup');
|
|
2241
3450
|
}
|
|
3451
|
+
const handlerSession = ds.session;
|
|
3452
|
+
const handlerAnchor = sessionAnchorId(ds);
|
|
3453
|
+
const handlerLarkAppId = ds.larkAppId;
|
|
3454
|
+
const ownsWorkerSession = () => ds.worker === worker
|
|
3455
|
+
&& ds.workerGeneration === workerGeneration
|
|
3456
|
+
&& ds.session.workerGeneration === workerGeneration
|
|
3457
|
+
&& ds.session === handlerSession
|
|
3458
|
+
&& ds.session.status === 'active'
|
|
3459
|
+
&& ds.larkAppId === handlerLarkAppId
|
|
3460
|
+
&& sessionAnchorId(ds) === handlerAnchor
|
|
3461
|
+
&& (!activeSessionsRegistry
|
|
3462
|
+
|| activeSessionsRegistry.get(sessionKey(handlerAnchor, handlerLarkAppId)) === ds);
|
|
3463
|
+
const ownsLifecycleMutation = () => ownsWorkerSession() && !isSessionTransferring(ds);
|
|
2242
3464
|
// A new worker generation is starting. As a backstop, invalidate any
|
|
2243
3465
|
// stuck-warning card posted by the previous generation — explicit kill/suspend/
|
|
2244
3466
|
// exit paths should already have done this, but fork/refork/takeover paths
|
|
2245
3467
|
// can leave a stale card that would otherwise inject keys into the new CLI.
|
|
2246
3468
|
invalidateStuckWarning(ds, 'new_worker_generation');
|
|
3469
|
+
invalidateTuiPrompt(ds, 'new_worker_generation');
|
|
2247
3470
|
// Managed turn authority is issued by one concrete worker lifetime. A
|
|
2248
3471
|
// replacement must advertise a fresh capability before daemon-mediated
|
|
2249
3472
|
// exits may use it; carrying the old value across a restore/refork would
|
|
@@ -2270,6 +3493,15 @@ function setupWorkerHandlers(ds, worker, startupState = { ready: false, failureN
|
|
|
2270
3493
|
/** Auxiliary worker UI is never an authorized output channel for a dedicated
|
|
2271
3494
|
* VC receiver. Dashboard/audit state is still updated before these guards. */
|
|
2272
3495
|
const managedAuxUiSuppressed = (turnId, dispatchAttempt) => {
|
|
3496
|
+
// No-transport session (apiOnly bot or HTTP virtual chat): there is no real
|
|
3497
|
+
// Feishu chat to render a card/reaction into. Suppress ALL auxiliary UI at
|
|
3498
|
+
// the single source every aux-UI handler funnels through — this is the
|
|
3499
|
+
// authoritative fix, NOT a fake "success" message id from sessionReply (a
|
|
3500
|
+
// synthetic id would get stored as streamCardId and later scheduleCardPatch
|
|
3501
|
+
// → updateMessage would still dial Feishu). Dashboard/web-terminal state is
|
|
3502
|
+
// still updated before these guards, so the terminal view is unaffected.
|
|
3503
|
+
if (!larkTransportEnabled({ chatId: ds.chatId, apiOnly: getBot(ds.larkAppId).config.apiOnly }))
|
|
3504
|
+
return true;
|
|
2273
3505
|
if (isSilentScheduledTurn(ds, turnId))
|
|
2274
3506
|
return true;
|
|
2275
3507
|
if (ds.session.vcMeetingReceiver)
|
|
@@ -2335,6 +3567,14 @@ function setupWorkerHandlers(ds, worker, startupState = { ready: false, failureN
|
|
|
2335
3567
|
const isAdopt = !!ds.adoptedFrom;
|
|
2336
3568
|
const showTakeover = false;
|
|
2337
3569
|
worker.on('message', async (msg) => {
|
|
3570
|
+
// Every IPC message is scoped to the child generation that emitted it.
|
|
3571
|
+
// A replaced worker can drain queued messages after the new child has been
|
|
3572
|
+
// installed; never let those stale events mutate the replacement's cards,
|
|
3573
|
+
// tokens, readiness, transcript metadata, or durable turn state.
|
|
3574
|
+
if (ds.worker !== worker) {
|
|
3575
|
+
logger.debug(`[${t}] Ignored stale worker message: ${msg.type}`);
|
|
3576
|
+
return;
|
|
3577
|
+
}
|
|
2338
3578
|
const effectiveCliId = sessionCliId(ds, botCfg);
|
|
2339
3579
|
switch (msg.type) {
|
|
2340
3580
|
case 'persistent_backend_target': {
|
|
@@ -2354,6 +3594,9 @@ function setupWorkerHandlers(ds, worker, startupState = { ready: false, failureN
|
|
|
2354
3594
|
}
|
|
2355
3595
|
if (recordDispatchInputCommit(ds.session, msg.turnId, workerGeneration)) {
|
|
2356
3596
|
sessionStore.updateSession(ds.session);
|
|
3597
|
+
if (msg.turnId.startsWith('mlrp_turn_')) {
|
|
3598
|
+
markMessageListenerRunPreviewRunning(msg.turnId);
|
|
3599
|
+
}
|
|
2357
3600
|
}
|
|
2358
3601
|
else {
|
|
2359
3602
|
logger.warn(`[${t}] Ignored unbound input commit turn=${msg.turnId.slice(0, 16)}`);
|
|
@@ -2373,8 +3616,6 @@ function setupWorkerHandlers(ds, worker, startupState = { ready: false, failureN
|
|
|
2373
3616
|
// unlike .botmux-cli-pids it cannot be forged or deleted by the CLI.
|
|
2374
3617
|
// Bind it to the daemon's current worker generation so a late message
|
|
2375
3618
|
// from a replaced worker never attests the replacement process.
|
|
2376
|
-
if (ds.worker !== worker)
|
|
2377
|
-
break;
|
|
2378
3619
|
ds.localProcessAttestation = {
|
|
2379
3620
|
backendType: msg.backendType,
|
|
2380
3621
|
credentialIsolated: msg.credentialIsolated,
|
|
@@ -2387,33 +3628,56 @@ function setupWorkerHandlers(ds, worker, startupState = { ready: false, failureN
|
|
|
2387
3628
|
break;
|
|
2388
3629
|
}
|
|
2389
3630
|
case 'ready': {
|
|
3631
|
+
if (!ownsLifecycleMutation()) {
|
|
3632
|
+
logger.warn(`[${t}] Ignored ready from stale/transferring worker generation`);
|
|
3633
|
+
break;
|
|
3634
|
+
}
|
|
2390
3635
|
startupState.ready = true;
|
|
2391
|
-
ds.
|
|
2392
|
-
|
|
2393
|
-
|
|
2394
|
-
//
|
|
2395
|
-
|
|
3636
|
+
ds.workerReady = true;
|
|
3637
|
+
// Treat `ready` as a full state boundary for the usage refresh: clear
|
|
3638
|
+
// any timer inherited from a PRIOR worker generation up front, so the
|
|
3639
|
+
// managed/replyAlreadySent/disabled/recovery/sentinel early-breaks below
|
|
3640
|
+
// never keep a dead generation's interval alive. The authorized arm is
|
|
3641
|
+
// re-established after a successful card reuse / fresh POST (below). This
|
|
3642
|
+
// is the third arm point — without it, an auto-restart mid-`working`
|
|
3643
|
+
// (claude_exit rc<=3 → workerReady=false → tick self-clears) would reuse
|
|
3644
|
+
// the old card on ready then never re-arm, because the first post-restart
|
|
3645
|
+
// screen_update is working→working (statusChanged=false → early break).
|
|
3646
|
+
clearUsageRefreshTimer(ds);
|
|
3647
|
+
const webPort = Number.isInteger(msg.port) && msg.port > 0 ? msg.port : null;
|
|
3648
|
+
ds.workerPort = webPort;
|
|
3649
|
+
ds.workerToken = webPort ? msg.token : null;
|
|
3650
|
+
ds.workerViewToken = webPort ? (msg.viewToken ?? null) : null;
|
|
3651
|
+
// Persist a real port only. port=0 is the explicit ready-without-Web-
|
|
3652
|
+
// Terminal sentinel used by backends whose output is not raw ANSI.
|
|
3653
|
+
ds.session.webPort = webPort ?? undefined;
|
|
2396
3654
|
// Dashboard「复现命令」:worker 上报本次冷启的近似复现命令。只存内存字段、
|
|
2397
3655
|
// 绝不落盘(含凭证);warm reattach 时 worker 不重算,故为空——这是有意的
|
|
2398
3656
|
// (reattach 不代表本次新算命令真的执行了)。worker 每次 ready 会重报。
|
|
2399
3657
|
ds.spawnCommand = msg.spawnCommand;
|
|
2400
3658
|
sessionStore.updateSession(ds.session);
|
|
2401
|
-
const readOnlyUrl =
|
|
2402
|
-
|
|
2403
|
-
|
|
3659
|
+
const readOnlyUrl = readableTerminalUrlFor(ds);
|
|
3660
|
+
if (readOnlyUrl) {
|
|
3661
|
+
logger.info(`[${t}] Worker ready, terminal at ${readOnlyUrl.replace(/\?.*$/, '?viewToken=[redacted]')}`);
|
|
3662
|
+
}
|
|
3663
|
+
else {
|
|
3664
|
+
logger.info(`[${t}] Worker ready (Web Terminal unavailable for this backend)`);
|
|
3665
|
+
}
|
|
2404
3666
|
if (ds.usageLimit) {
|
|
2405
3667
|
ds.lastScreenStatus = 'limited';
|
|
2406
3668
|
armUsageLimitRetryTimer(ds);
|
|
2407
3669
|
}
|
|
2408
|
-
// Dashboard: surface the
|
|
3670
|
+
// Dashboard: surface the xterm port, or explicitly clear it for a
|
|
3671
|
+
// ready backend with no Web Terminal capability.
|
|
2409
3672
|
dashboardEventBus.publish({
|
|
2410
3673
|
type: 'session.update',
|
|
2411
3674
|
body: {
|
|
2412
3675
|
sessionId: ds.session.sessionId,
|
|
2413
|
-
patch: { webPort
|
|
3676
|
+
patch: { webPort },
|
|
2414
3677
|
},
|
|
2415
3678
|
});
|
|
2416
|
-
// Substitute-mode control card: DM owner(s)
|
|
3679
|
+
// Substitute-mode control card: DM owner(s) writable-terminal controls
|
|
3680
|
+
// when supported, or manage-only controls for no-Web backends.
|
|
2417
3681
|
// Consumed before any early break below so avatar-style (card-off) sessions
|
|
2418
3682
|
// still deliver the owner control card.
|
|
2419
3683
|
if (ds.pendingSubstituteControlCard) {
|
|
@@ -2478,8 +3742,10 @@ function setupWorkerHandlers(ds, worker, startupState = { ready: false, failureN
|
|
|
2478
3742
|
// undefined and fall back to 'starting' (unchanged behavior).
|
|
2479
3743
|
const initStatus = ds.usageLimit ? 'limited' : (ds.lastScreenStatus ?? 'starting');
|
|
2480
3744
|
const localCliReadyAtBuild = isLocalCliOpenReady(ds, { cliId: effectiveCliId });
|
|
2481
|
-
const streamCardJson = buildStreamingCard(ds.session.sessionId, sessionAnchorId(ds), readOnlyUrl, initTitle, ds.lastScreenContent ?? '', initStatus, effectiveCliId, ds.displayMode ?? 'hidden', ds.streamCardNonce, ds.currentImageKey, isAdopt, showTakeover, loc, initStatus === 'limited' ? ds.usageLimit : undefined, writableTerminalLinkFor(ds), localCliReadyAtBuild);
|
|
3745
|
+
const streamCardJson = buildStreamingCard(ds.session.sessionId, sessionAnchorId(ds), readOnlyUrl, initTitle, ds.lastScreenContent ?? '', initStatus, effectiveCliId, ds.displayMode ?? 'hidden', ds.streamCardNonce, ds.currentImageKey, isAdopt, showTakeover, loc, initStatus === 'limited' ? ds.usageLimit : undefined, writableTerminalLinkFor(ds), localCliReadyAtBuild, getDaemonStreamingCardUsageSnapshot(ds, effectiveCliId));
|
|
2482
3746
|
await updateMessage(ds.larkAppId, restoredCardId, streamCardJson);
|
|
3747
|
+
if (!ownsLifecycleMutation())
|
|
3748
|
+
break;
|
|
2483
3749
|
// Worker IPC handlers may run while the direct restore PATCH is in
|
|
2484
3750
|
// flight. Re-queue readiness after it completes so an older
|
|
2485
3751
|
// not-ready payload can never overwrite the cli_session_id PATCH.
|
|
@@ -2494,9 +3760,16 @@ function setupWorkerHandlers(ds, worker, startupState = { ready: false, failureN
|
|
|
2494
3760
|
// thread on each restart.
|
|
2495
3761
|
recallFrozenCards(ds);
|
|
2496
3762
|
logger.info(`[${t}] Reused existing streaming card ${restoredCardId.substring(0, 12)} after worker (re)start`);
|
|
3763
|
+
// Auto-restart recovery: if the reused card is a still-`working`
|
|
3764
|
+
// turn, re-arm the periodic usage refresh here. The first
|
|
3765
|
+
// post-restart screen_update is typically working→working
|
|
3766
|
+
// (statusChanged=false) and would break before the arm choke point.
|
|
3767
|
+
syncUsageRefreshTimer(ds);
|
|
2497
3768
|
break;
|
|
2498
3769
|
}
|
|
2499
3770
|
catch (err) {
|
|
3771
|
+
if (!ownsLifecycleMutation())
|
|
3772
|
+
break;
|
|
2500
3773
|
// PATCH failed (withdrawn, expired, etc.) — fall through to POST a fresh card.
|
|
2501
3774
|
logger.info(`[${t}] Failed to reuse existing streaming card (${err instanceof Error ? err.message : err}), posting new one`);
|
|
2502
3775
|
ds.streamCardId = undefined;
|
|
@@ -2525,8 +3798,13 @@ function setupWorkerHandlers(ds, worker, startupState = { ready: false, failureN
|
|
|
2525
3798
|
// from before the kill+fork — using it avoids a blank flash
|
|
2526
3799
|
// before the first screen_update lands. Fresh worker spawn
|
|
2527
3800
|
// has lastScreenContent undefined → '' (unchanged).
|
|
2528
|
-
ds.lastScreenContent ?? '', initStatus, effectiveCliId, ds.displayMode ?? 'hidden', ds.streamCardNonce, ds.currentImageKey, isAdopt, showTakeover, loc, initStatus === 'limited' ? ds.usageLimit : undefined, writableTerminalLinkFor(ds), isLocalCliOpenReady(ds, { cliId: effectiveCliId }));
|
|
2529
|
-
|
|
3801
|
+
ds.lastScreenContent ?? '', initStatus, effectiveCliId, ds.displayMode ?? 'hidden', ds.streamCardNonce, ds.currentImageKey, isAdopt, showTakeover, loc, initStatus === 'limited' ? ds.usageLimit : undefined, writableTerminalLinkFor(ds), isLocalCliOpenReady(ds, { cliId: effectiveCliId }), getDaemonStreamingCardUsageSnapshot(ds, effectiveCliId));
|
|
3802
|
+
const postedCardId = await scopedReply(streamCardJson, 'interactive', msg.turnId);
|
|
3803
|
+
if (!ownsLifecycleMutation()) {
|
|
3804
|
+
void deleteMessage(ds.larkAppId, postedCardId).catch(() => { });
|
|
3805
|
+
break;
|
|
3806
|
+
}
|
|
3807
|
+
ds.streamCardId = postedCardId;
|
|
2530
3808
|
// This card IS the current turn's live card — clear the new-turn flag
|
|
2531
3809
|
// so subsequent screen_updates PATCH it (starting → working) instead of
|
|
2532
3810
|
// POSTing a second card. Without this, a re-fork that happens while
|
|
@@ -2545,8 +3823,14 @@ function setupWorkerHandlers(ds, worker, startupState = { ready: false, failureN
|
|
|
2545
3823
|
recallFrozenCards(ds);
|
|
2546
3824
|
flushPendingLocalCliOpenReadinessPatch(ds);
|
|
2547
3825
|
flushPendingRiffUrlPatch(ds);
|
|
3826
|
+
// Fresh ready POST: if this turn is already `working` (e.g. relay
|
|
3827
|
+
// resume where the CLI kept running), arm here — same authorized arm
|
|
3828
|
+
// point as the reuse branch, now that streamCardId is the real id.
|
|
3829
|
+
syncUsageRefreshTimer(ds);
|
|
2548
3830
|
}
|
|
2549
3831
|
catch (err) {
|
|
3832
|
+
if (!ownsLifecycleMutation())
|
|
3833
|
+
break;
|
|
2550
3834
|
if (err instanceof MessageWithdrawnError) {
|
|
2551
3835
|
logger.warn(`[${t}] Root message withdrawn, closing stale session`);
|
|
2552
3836
|
killWorker(ds);
|
|
@@ -2563,6 +3847,10 @@ function setupWorkerHandlers(ds, worker, startupState = { ready: false, failureN
|
|
|
2563
3847
|
const localCliReadyAtBuild = isLocalCliOpenReady(ds, { cliId: effectiveCliId });
|
|
2564
3848
|
const cardJson = buildSessionCard(ds.session.sessionId, sessionAnchorId(ds), readOnlyUrl, ds.session.title || getCliDisplayName(effectiveCliId), effectiveCliId, undefined, !!ds.adoptedFrom, loc, localCliReadyAtBuild);
|
|
2565
3849
|
const fallbackCardId = await scopedReply(cardJson, 'interactive', msg.turnId);
|
|
3850
|
+
if (!ownsLifecycleMutation()) {
|
|
3851
|
+
void deleteMessage(ds.larkAppId, fallbackCardId).catch(() => { });
|
|
3852
|
+
break;
|
|
3853
|
+
}
|
|
2566
3854
|
if (!localCliReadyAtBuild && isLocalCliOpenEnabled()
|
|
2567
3855
|
&& isLocalCliOpenReady(ds, { cliId: effectiveCliId })) {
|
|
2568
3856
|
const readyCardJson = buildSessionCard(ds.session.sessionId, sessionAnchorId(ds), readOnlyUrl, ds.session.title || getCliDisplayName(effectiveCliId), effectiveCliId, undefined, !!ds.adoptedFrom, loc, true);
|
|
@@ -2575,6 +3863,8 @@ function setupWorkerHandlers(ds, worker, startupState = { ready: false, failureN
|
|
|
2575
3863
|
}
|
|
2576
3864
|
}
|
|
2577
3865
|
catch (fallbackErr) {
|
|
3866
|
+
if (!ownsLifecycleMutation())
|
|
3867
|
+
break;
|
|
2578
3868
|
if (fallbackErr instanceof MessageWithdrawnError) {
|
|
2579
3869
|
logger.warn(`[${t}] Root message withdrawn, closing stale session`);
|
|
2580
3870
|
killWorker(ds);
|
|
@@ -2616,7 +3906,7 @@ function setupWorkerHandlers(ds, worker, startupState = { ready: false, failureN
|
|
|
2616
3906
|
const followUpCodexAppInput = followUp?.codexAppInputGateFrozen
|
|
2617
3907
|
? followUp.codexAppInput
|
|
2618
3908
|
: codexAppInputForSession(ds, followUp?.codexAppInput);
|
|
2619
|
-
ds
|
|
3909
|
+
sendWorkerSessionInput(ds, {
|
|
2620
3910
|
type: 'raw_input',
|
|
2621
3911
|
content: rawInput,
|
|
2622
3912
|
...(rawTurnId ? { turnId: rawTurnId } : {}),
|
|
@@ -2633,6 +3923,7 @@ function setupWorkerHandlers(ds, worker, startupState = { ready: false, failureN
|
|
|
2633
3923
|
}
|
|
2634
3924
|
// CLI reached its prompt — any previously posted stuck warning is stale.
|
|
2635
3925
|
invalidateStuckWarning(ds, 'prompt_ready');
|
|
3926
|
+
invalidateTuiPrompt(ds, 'prompt_ready', 'resolved');
|
|
2636
3927
|
break;
|
|
2637
3928
|
}
|
|
2638
3929
|
case 'runner_build_ready': {
|
|
@@ -2692,24 +3983,32 @@ function setupWorkerHandlers(ds, worker, startupState = { ready: false, failureN
|
|
|
2692
3983
|
break;
|
|
2693
3984
|
}
|
|
2694
3985
|
case 'screen_update': {
|
|
2695
|
-
|
|
2696
|
-
|
|
2697
|
-
//
|
|
2698
|
-
//
|
|
2699
|
-
//
|
|
2700
|
-
|
|
2701
|
-
if (!ds.workerPort)
|
|
3986
|
+
if (!ownsLifecycleMutation())
|
|
3987
|
+
break;
|
|
3988
|
+
// Wait for worker init, independently of Web Terminal availability.
|
|
3989
|
+
// ZMX intentionally reports ready with port=0, but its plain-history
|
|
3990
|
+
// screenshots and idle/status transitions must keep flowing.
|
|
3991
|
+
if (!startupState.ready && !workerHasInitialized(ds))
|
|
2702
3992
|
break;
|
|
2703
3993
|
const prevStatus = ds.lastScreenStatus;
|
|
2704
3994
|
updateUsageLimitState(ds, msg.usageLimit);
|
|
2705
3995
|
ds.lastScreenContent = msg.content;
|
|
2706
3996
|
ds.lastScreenStatus = (msg.usageLimit ?? ds.usageLimit) ? 'limited' : msg.status;
|
|
3997
|
+
// State-boundary clear: the moment we leave `working` (→ idle/limited),
|
|
3998
|
+
// stop the periodic usage refresh immediately — BEFORE the aux-UI /
|
|
3999
|
+
// card-disabled / recovery early-breaks below, which would otherwise
|
|
4000
|
+
// skip the arm/clear choke point and leave the interval running until it
|
|
4001
|
+
// self-corrects or the worker is killed. Arm stays gated below (needs a
|
|
4002
|
+
// live card + UI gates); only the clear needs to be unconditional here.
|
|
4003
|
+
if (ds.lastScreenStatus !== 'working')
|
|
4004
|
+
clearUsageRefreshTimer(ds);
|
|
2707
4005
|
// Dashboard: publish a patch only when status truly transitioned, so
|
|
2708
4006
|
// SSE clients reflect real state changes (starting → working → idle)
|
|
2709
4007
|
// without flooding on every PTY tick. The screen analyzer is the
|
|
2710
4008
|
// upstream debouncer — by the time we get here, status flips are
|
|
2711
4009
|
// already coarse-grained.
|
|
2712
4010
|
if (prevStatus !== ds.lastScreenStatus) {
|
|
4011
|
+
const dashboardRow = composeRowFromActive(ds);
|
|
2713
4012
|
dashboardEventBus.publish({
|
|
2714
4013
|
type: 'session.update',
|
|
2715
4014
|
body: {
|
|
@@ -2717,7 +4016,14 @@ function setupWorkerHandlers(ds, worker, startupState = { ready: false, failureN
|
|
|
2717
4016
|
patch: {
|
|
2718
4017
|
status: ds.lastScreenStatus,
|
|
2719
4018
|
lastMessageAt: ds.lastMessageAt,
|
|
2720
|
-
tokenUsage:
|
|
4019
|
+
tokenUsage: dashboardRow.tokenUsage,
|
|
4020
|
+
previewUserText: dashboardRow.previewUserText,
|
|
4021
|
+
previewBotText: dashboardRow.previewBotText,
|
|
4022
|
+
previewUserFullText: dashboardRow.previewUserFullText,
|
|
4023
|
+
previewBotFullText: dashboardRow.previewBotFullText,
|
|
4024
|
+
previewUserAt: dashboardRow.previewUserAt,
|
|
4025
|
+
previewBotAt: dashboardRow.previewBotAt,
|
|
4026
|
+
previewBotState: dashboardRow.previewBotState,
|
|
2721
4027
|
},
|
|
2722
4028
|
},
|
|
2723
4029
|
});
|
|
@@ -2725,12 +4031,18 @@ function setupWorkerHandlers(ds, worker, startupState = { ready: false, failureN
|
|
|
2725
4031
|
source: 'screen_update',
|
|
2726
4032
|
content: msg.content,
|
|
2727
4033
|
});
|
|
2728
|
-
// Usage ledger
|
|
2729
|
-
//
|
|
2730
|
-
//
|
|
4034
|
+
// Usage ledger: any settle-to-idle/limited edge records the delta.
|
|
4035
|
+
// Turn reactions are stricter — only flip ✋→✅ after a real busy
|
|
4036
|
+
// period (working/analyzing). Cold-start starting→idle (or the first
|
|
4037
|
+
// prompt-ready before the turn has gone working) must NOT DONE a
|
|
4038
|
+
// message that is still about to be / just being processed. Grok
|
|
4039
|
+
// card-off sessions hit this when the ready-gate settle fired idle
|
|
4040
|
+
// ~seconds after GoGoGo while the CLI was still running the prompt.
|
|
2731
4041
|
if (ds.lastScreenStatus === 'idle' || ds.lastScreenStatus === 'limited') {
|
|
2732
4042
|
recordUsageForDaemonSession(ds);
|
|
2733
|
-
|
|
4043
|
+
if (prevStatus === 'working' || prevStatus === 'analyzing') {
|
|
4044
|
+
void finishTurnReactions(ds);
|
|
4045
|
+
}
|
|
2734
4046
|
}
|
|
2735
4047
|
if (ds.lastScreenStatus === 'idle'
|
|
2736
4048
|
&& msg.turnId
|
|
@@ -2746,20 +4058,26 @@ function setupWorkerHandlers(ds, worker, startupState = { ready: false, failureN
|
|
|
2746
4058
|
queueMicrotask(cb.enforceLiveSessionCap);
|
|
2747
4059
|
}
|
|
2748
4060
|
}
|
|
2749
|
-
if (managedAuxUiSuppressed(msg.turnId, msg.dispatchAttempt))
|
|
4061
|
+
if (managedAuxUiSuppressed(msg.turnId, msg.dispatchAttempt)) {
|
|
4062
|
+
clearUsageRefreshTimer(ds);
|
|
2750
4063
|
break;
|
|
4064
|
+
}
|
|
2751
4065
|
// Bot opted out of the streaming card — dashboard SSE above already got
|
|
2752
4066
|
// the status patch; just don't touch any Lark card. Turn-exact: a
|
|
2753
4067
|
// substitute turn's screen updates stay card-less even after a queued
|
|
2754
4068
|
// normal turn overwrote currentReplyTarget (and vice versa).
|
|
2755
|
-
if (streamingCardDisabled(ds, msg.turnId))
|
|
4069
|
+
if (streamingCardDisabled(ds, msg.turnId)) {
|
|
4070
|
+
clearUsageRefreshTimer(ds);
|
|
2756
4071
|
break;
|
|
4072
|
+
}
|
|
2757
4073
|
// Restart recovery: a restored worker may emit screen updates as the CLI
|
|
2758
4074
|
// redraws on resume. Stay silent (no post/patch) until the first real
|
|
2759
4075
|
// user turn clears the flag. Dashboard SSE above still reflects status.
|
|
2760
|
-
if (ds.suppressRecoveryCard)
|
|
4076
|
+
if (ds.suppressRecoveryCard) {
|
|
4077
|
+
clearUsageRefreshTimer(ds);
|
|
2761
4078
|
break;
|
|
2762
|
-
|
|
4079
|
+
}
|
|
4080
|
+
const readUrl = readableTerminalUrlFor(ds);
|
|
2763
4081
|
const turnTitle = ds.currentTurnTitle || ds.session.title || getCliDisplayName(effectiveCliId);
|
|
2764
4082
|
const mode = ds.displayMode ?? 'hidden';
|
|
2765
4083
|
if (ds.streamCardPending || !ds.streamCardId) {
|
|
@@ -2774,13 +4092,17 @@ function setupWorkerHandlers(ds, worker, startupState = { ready: false, failureN
|
|
|
2774
4092
|
// New turn → image_key from previous turn no longer valid
|
|
2775
4093
|
if (isNewTurn)
|
|
2776
4094
|
ds.currentImageKey = undefined;
|
|
2777
|
-
const cardJson = buildStreamingCard(ds.session.sessionId, sessionAnchorId(ds), readUrl, turnTitle, isNewTurn ? '' : msg.content, ds.lastScreenStatus, effectiveCliId, mode, ds.streamCardNonce, ds.currentImageKey, isAdopt, showTakeover, loc, cardUsageLimit(ds), writableTerminalLinkFor(ds), isLocalCliOpenReady(ds, { cliId: effectiveCliId }));
|
|
4095
|
+
const cardJson = buildStreamingCard(ds.session.sessionId, sessionAnchorId(ds), readUrl, turnTitle, isNewTurn ? '' : msg.content, ds.lastScreenStatus, effectiveCliId, mode, ds.streamCardNonce, ds.currentImageKey, isAdopt, showTakeover, loc, cardUsageLimit(ds), writableTerminalLinkFor(ds), isLocalCliOpenReady(ds, { cliId: effectiveCliId }), getDaemonStreamingCardUsageSnapshot(ds, effectiveCliId));
|
|
2778
4096
|
// Mark POST in-flight so subsequent screen_updates are dropped,
|
|
2779
4097
|
// not POSTed as duplicate cards.
|
|
2780
4098
|
ds.streamCardPending = false;
|
|
2781
4099
|
ds.streamCardId = CARD_POSTING_SENTINEL;
|
|
2782
4100
|
scopedReply(cardJson, 'interactive', msg.turnId)
|
|
2783
4101
|
.then(msgId => {
|
|
4102
|
+
if (!ownsLifecycleMutation()) {
|
|
4103
|
+
void deleteMessage(ds.larkAppId, msgId).catch(() => { });
|
|
4104
|
+
return;
|
|
4105
|
+
}
|
|
2784
4106
|
ds.streamCardId = msgId;
|
|
2785
4107
|
persistStreamCardState(ds);
|
|
2786
4108
|
// New card live — recall any cards parked by previous turns
|
|
@@ -2791,8 +4113,15 @@ function setupWorkerHandlers(ds, worker, startupState = { ready: false, failureN
|
|
|
2791
4113
|
recallFrozenCards(ds);
|
|
2792
4114
|
flushPendingLocalCliOpenReadinessPatch(ds);
|
|
2793
4115
|
flushPendingRiffUrlPatch(ds);
|
|
4116
|
+
// New-turn POST is the FIRST working screen_update of the turn —
|
|
4117
|
+
// the else (same-turn PATCH) branch never runs for it, so arm the
|
|
4118
|
+
// periodic usage refresh here (once the real card id exists, not
|
|
4119
|
+
// the POSTING sentinel). syncUsageRefreshTimer re-checks state.
|
|
4120
|
+
syncUsageRefreshTimer(ds);
|
|
2794
4121
|
})
|
|
2795
4122
|
.catch(err => {
|
|
4123
|
+
if (!ownsLifecycleMutation())
|
|
4124
|
+
return;
|
|
2796
4125
|
if (err instanceof MessageWithdrawnError) {
|
|
2797
4126
|
logger.warn(`[${t}] Root message withdrawn, closing stale session`);
|
|
2798
4127
|
killWorker(ds);
|
|
@@ -2811,8 +4140,17 @@ function setupWorkerHandlers(ds, worker, startupState = { ready: false, failureN
|
|
|
2811
4140
|
const statusChanged = prevStatus !== ds.lastScreenStatus;
|
|
2812
4141
|
if (!statusChanged)
|
|
2813
4142
|
break;
|
|
2814
|
-
const cardJson = buildStreamingCard(ds.session.sessionId, sessionAnchorId(ds), readUrl, turnTitle, msg.content, ds.lastScreenStatus, effectiveCliId, mode, ds.streamCardNonce, ds.currentImageKey, isAdopt, showTakeover, loc, cardUsageLimit(ds), writableTerminalLinkFor(ds), isLocalCliOpenReady(ds, { cliId: effectiveCliId })
|
|
4143
|
+
const cardJson = buildStreamingCard(ds.session.sessionId, sessionAnchorId(ds), readUrl, turnTitle, msg.content, ds.lastScreenStatus, effectiveCliId, mode, ds.streamCardNonce, ds.currentImageKey, isAdopt, showTakeover, loc, cardUsageLimit(ds), writableTerminalLinkFor(ds), isLocalCliOpenReady(ds, { cliId: effectiveCliId }),
|
|
4144
|
+
// Turn end (→ idle) reads the exact latest usage; intra-turn status
|
|
4145
|
+
// ticks ride the throttle. See getDaemonStreamingCardUsageSnapshot.
|
|
4146
|
+
getDaemonStreamingCardUsageSnapshot(ds, effectiveCliId, {
|
|
4147
|
+
fresh: ds.lastScreenStatus === 'idle',
|
|
4148
|
+
}));
|
|
2815
4149
|
scheduleCardPatch(ds, cardJson, msg.turnId);
|
|
4150
|
+
// Keep the live usage climbing during a long working phase; stop once
|
|
4151
|
+
// the turn settles (idle/limited). State-boundary invariant — one
|
|
4152
|
+
// choke point re-evaluates arm/clear after this status assignment.
|
|
4153
|
+
syncUsageRefreshTimer(ds);
|
|
2816
4154
|
}
|
|
2817
4155
|
break;
|
|
2818
4156
|
}
|
|
@@ -2831,29 +4169,40 @@ function setupWorkerHandlers(ds, worker, startupState = { ready: false, failureN
|
|
|
2831
4169
|
content: ds.lastScreenContent ?? '',
|
|
2832
4170
|
});
|
|
2833
4171
|
persistStreamCardState(ds);
|
|
2834
|
-
|
|
4172
|
+
// screenshot_uploaded never ARMS the usage refresh — screen_update owns
|
|
4173
|
+
// the authorized arm. Here we only tear it down: unconditionally on
|
|
4174
|
+
// leaving `working`, and on a managed/silent turn (below) that must not
|
|
4175
|
+
// keep a group-visible card ticking. Arming here would let a 12s tick
|
|
4176
|
+
// re-render the prior visible card with THIS managed/hidden turn's
|
|
4177
|
+
// content — the same leak class as substitute-turn card suppression.
|
|
4178
|
+
if (ds.lastScreenStatus !== 'working')
|
|
4179
|
+
clearUsageRefreshTimer(ds);
|
|
4180
|
+
if (managedAuxUiSuppressed(msg.turnId, msg.dispatchAttempt)) {
|
|
4181
|
+
clearUsageRefreshTimer(ds);
|
|
2835
4182
|
break;
|
|
4183
|
+
}
|
|
2836
4184
|
if ((ds.displayMode ?? 'hidden') !== 'screenshot')
|
|
2837
4185
|
break;
|
|
2838
|
-
if (!ds.streamCardId || ds.streamCardId === CARD_POSTING_SENTINEL || !ds
|
|
4186
|
+
if (!ds.streamCardId || ds.streamCardId === CARD_POSTING_SENTINEL || !workerHasInitialized(ds))
|
|
2839
4187
|
break;
|
|
2840
|
-
const readUrl =
|
|
4188
|
+
const readUrl = readableTerminalUrlFor(ds);
|
|
2841
4189
|
const turnTitle = ds.currentTurnTitle || ds.session.title || getCliDisplayName(effectiveCliId);
|
|
2842
|
-
const cardJson = buildStreamingCard(ds.session.sessionId, sessionAnchorId(ds), readUrl, turnTitle, ds.lastScreenContent ?? '', ds.lastScreenStatus, effectiveCliId, 'screenshot', ds.streamCardNonce, ds.currentImageKey, isAdopt, showTakeover, loc, cardUsageLimit(ds), writableTerminalLinkFor(ds), isLocalCliOpenReady(ds, { cliId: effectiveCliId }));
|
|
4190
|
+
const cardJson = buildStreamingCard(ds.session.sessionId, sessionAnchorId(ds), readUrl, turnTitle, ds.lastScreenContent ?? '', ds.lastScreenStatus, effectiveCliId, 'screenshot', ds.streamCardNonce, ds.currentImageKey, isAdopt, showTakeover, loc, cardUsageLimit(ds), writableTerminalLinkFor(ds), isLocalCliOpenReady(ds, { cliId: effectiveCliId }), getDaemonStreamingCardUsageSnapshot(ds, effectiveCliId, { fresh: ds.lastScreenStatus === 'idle' }));
|
|
2843
4191
|
scheduleCardPatch(ds, cardJson);
|
|
2844
4192
|
break;
|
|
2845
4193
|
}
|
|
2846
4194
|
case 'tui_prompt': {
|
|
2847
4195
|
// AI detected an interactive TUI prompt — post card to thread
|
|
2848
|
-
|
|
2849
|
-
|
|
4196
|
+
if (!ownsLifecycleMutation()) {
|
|
4197
|
+
logger.info(`[${t}] Ignored TUI prompt from stale worker generation`);
|
|
4198
|
+
break;
|
|
4199
|
+
}
|
|
4200
|
+
// Dedup across both posted and in-flight/cardless prompt state.
|
|
4201
|
+
if (hasTuiPromptAuthority(ds)) {
|
|
2850
4202
|
logger.debug(`[${t}] TUI prompt card already posted, skipping duplicate`);
|
|
2851
4203
|
break;
|
|
2852
4204
|
}
|
|
2853
4205
|
logger.info(`[${t}] TUI prompt detected: ${msg.description}${msg.multiSelect ? ' (multi-select)' : ''}`);
|
|
2854
|
-
ds.tuiPromptOptions = msg.options;
|
|
2855
|
-
ds.tuiPromptMultiSelect = msg.multiSelect;
|
|
2856
|
-
ds.tuiToggledIndices = [];
|
|
2857
4206
|
emitSessionLifecycleHook(ds, 'session.requires_attention', {
|
|
2858
4207
|
reason: 'tui_prompt',
|
|
2859
4208
|
description: msg.description,
|
|
@@ -2871,35 +4220,119 @@ function setupWorkerHandlers(ds, worker, startupState = { ready: false, failureN
|
|
|
2871
4220
|
// would temporarily overwrite a user-issued /rename until refresh.
|
|
2872
4221
|
ds.currentTurnTitle = msg.description;
|
|
2873
4222
|
if (managedAuxUiSuppressed(msg.turnId, msg.dispatchAttempt)) {
|
|
2874
|
-
logger.info(`[${t}] Managed/silent turn — TUI prompt kept in
|
|
4223
|
+
logger.info(`[${t}] Managed/silent turn — TUI prompt kept in lifecycle audit only`);
|
|
4224
|
+
break;
|
|
4225
|
+
}
|
|
4226
|
+
// Document-native sessions have no Lark chat/thread destination. Keep
|
|
4227
|
+
// the lifecycle audit/title above, but never send a card to their
|
|
4228
|
+
// internal `doc:<token>` routing anchor.
|
|
4229
|
+
if (isDocNativeSession(ds)) {
|
|
4230
|
+
logger.info(`[${t}] Doc-native session — suppressing TUI prompt card`);
|
|
2875
4231
|
break;
|
|
2876
4232
|
}
|
|
4233
|
+
// The array identity acts as this prompt's in-memory ownership token.
|
|
4234
|
+
// Clearing/replacing the prompt changes it, so a late card POST cannot
|
|
4235
|
+
// reclaim authority after prompt_ready, worker exit, or replacement.
|
|
4236
|
+
// Cardless silent/document prompts deliberately never occupy this slot.
|
|
4237
|
+
const promptOptions = msg.options;
|
|
4238
|
+
ds.tuiPromptOptions = promptOptions;
|
|
4239
|
+
ds.tuiPromptMultiSelect = msg.multiSelect;
|
|
4240
|
+
ds.tuiToggledIndices = [];
|
|
4241
|
+
ds.tuiPromptProcessing = false;
|
|
2877
4242
|
try {
|
|
2878
4243
|
const cardJson = buildTuiPromptCard(sessionAnchorId(ds), ds.session.sessionId, msg.description, msg.options, msg.multiSelect, undefined, loc);
|
|
2879
4244
|
const cardMsgId = await scopedReply(cardJson, 'interactive', msg.turnId);
|
|
4245
|
+
const stillOwnsLifecycle = ownsLifecycleMutation();
|
|
4246
|
+
if (!stillOwnsLifecycle || ds.tuiPromptOptions !== promptOptions) {
|
|
4247
|
+
const terminalCard = stillOwnsLifecycle
|
|
4248
|
+
? buildTuiPromptResolvedCard(tr('card.action.tui_done', undefined, loc), loc)
|
|
4249
|
+
: buildTuiPromptFailedCard(tr('worker.tui_submit_failed', {
|
|
4250
|
+
cliName: getCliDisplayName(effectiveCliId),
|
|
4251
|
+
}, loc), loc);
|
|
4252
|
+
updateMessage(handlerLarkAppId, cardMsgId, terminalCard).catch(err => logger.debug(`[${t}] Failed to resolve late TUI prompt card: ${err}`));
|
|
4253
|
+
break;
|
|
4254
|
+
}
|
|
2880
4255
|
ds.tuiPromptCardId = cardMsgId;
|
|
2881
4256
|
publishAttentionPatch(ds);
|
|
2882
4257
|
}
|
|
2883
4258
|
catch (err) {
|
|
2884
4259
|
logger.warn(`[${t}] Failed to post TUI prompt card: ${err}`);
|
|
4260
|
+
if (ownsLifecycleMutation() && ds.tuiPromptOptions === promptOptions) {
|
|
4261
|
+
clearTuiPromptAuthority(ds);
|
|
4262
|
+
publishAttentionPatch(ds);
|
|
4263
|
+
}
|
|
2885
4264
|
}
|
|
2886
4265
|
break;
|
|
2887
4266
|
}
|
|
2888
4267
|
case 'tui_prompt_resolved': {
|
|
4268
|
+
if (ds.worker !== worker
|
|
4269
|
+
|| ds.workerGeneration !== workerGeneration
|
|
4270
|
+
|| ds.session.workerGeneration !== workerGeneration) {
|
|
4271
|
+
logger.info(`[${t}] Ignored TUI resolved ACK from stale worker generation`);
|
|
4272
|
+
break;
|
|
4273
|
+
}
|
|
2889
4274
|
// TUI prompt is no longer showing — update card if it exists
|
|
2890
4275
|
logger.info(`[${t}] TUI prompt resolved${msg.selectedText ? `: ${msg.selectedText}` : ''}`);
|
|
4276
|
+
if (msg.cardMessageId && ds.tuiPromptCardId !== msg.cardMessageId) {
|
|
4277
|
+
logger.info(`[${t}] Ignored stale TUI resolved ACK for ${msg.cardMessageId} ` +
|
|
4278
|
+
`(active=${ds.tuiPromptCardId ?? 'none'})`);
|
|
4279
|
+
break;
|
|
4280
|
+
}
|
|
4281
|
+
const hadAuthority = hasTuiPromptAuthority(ds);
|
|
2891
4282
|
if (managedAuxUiSuppressed(msg.turnId, msg.dispatchAttempt)) {
|
|
2892
|
-
ds
|
|
2893
|
-
|
|
4283
|
+
clearTuiPromptAuthority(ds);
|
|
4284
|
+
if (hadAuthority)
|
|
4285
|
+
publishAttentionPatch(ds);
|
|
2894
4286
|
break;
|
|
2895
4287
|
}
|
|
2896
4288
|
if (ds.tuiPromptCardId) {
|
|
2897
4289
|
const resolvedCard = buildTuiPromptResolvedCard(msg.selectedText ?? tr('card.action.tui_done', undefined, loc), loc);
|
|
2898
4290
|
updateMessage(ds.larkAppId, ds.tuiPromptCardId, resolvedCard).catch(err => logger.debug(`[${t}] Failed to update TUI prompt card: ${err}`));
|
|
2899
|
-
|
|
2900
|
-
|
|
4291
|
+
}
|
|
4292
|
+
clearTuiPromptAuthority(ds);
|
|
4293
|
+
if (hadAuthority)
|
|
2901
4294
|
publishAttentionPatch(ds);
|
|
4295
|
+
break;
|
|
4296
|
+
}
|
|
4297
|
+
case 'tui_prompt_submit_failed': {
|
|
4298
|
+
if (ds.worker !== worker
|
|
4299
|
+
|| ds.workerGeneration !== workerGeneration
|
|
4300
|
+
|| ds.session.workerGeneration !== workerGeneration)
|
|
4301
|
+
break;
|
|
4302
|
+
const matchesTuiCard = !!msg.cardMessageId
|
|
4303
|
+
&& ds.tuiPromptCardId === msg.cardMessageId;
|
|
4304
|
+
const matchesStuckCard = msg.stuckNonce !== undefined
|
|
4305
|
+
&& ds.stuckWarningNonce === msg.stuckNonce
|
|
4306
|
+
&& !!ds.stuckWarningCardId;
|
|
4307
|
+
if (!matchesTuiCard && !matchesStuckCard) {
|
|
4308
|
+
logger.info(`[${t}] Ignored stale TUI submit failure ` +
|
|
4309
|
+
`(card=${msg.cardMessageId ?? 'none'}, stuckNonce=${msg.stuckNonce ?? 'none'})`);
|
|
4310
|
+
break;
|
|
4311
|
+
}
|
|
4312
|
+
const failureText = tr('worker.tui_submit_failed', {
|
|
4313
|
+
cliName: getCliDisplayName(effectiveCliId),
|
|
4314
|
+
}, loc);
|
|
4315
|
+
if (!managedAuxUiSuppressed(msg.turnId, msg.dispatchAttempt)) {
|
|
4316
|
+
const failedCard = buildTuiPromptFailedCard(failureText, loc);
|
|
4317
|
+
const failedCardId = matchesTuiCard
|
|
4318
|
+
? ds.tuiPromptCardId
|
|
4319
|
+
: ds.stuckWarningCardId;
|
|
4320
|
+
if (failedCardId) {
|
|
4321
|
+
updateMessage(ds.larkAppId, failedCardId, failedCard).catch(err => logger.debug(`[${t}] Failed to update TUI failure card: ${err}`));
|
|
4322
|
+
}
|
|
4323
|
+
try {
|
|
4324
|
+
await scopedReply(failureText, 'text', msg.turnId);
|
|
4325
|
+
}
|
|
4326
|
+
catch (err) {
|
|
4327
|
+
logger.error(`[${t}] Failed to deliver TUI submit failure: ${err.message}`);
|
|
4328
|
+
}
|
|
4329
|
+
}
|
|
4330
|
+
if (matchesTuiCard) {
|
|
4331
|
+
clearTuiPromptAuthority(ds);
|
|
2902
4332
|
}
|
|
4333
|
+
if (matchesStuckCard)
|
|
4334
|
+
clearStuckWarningAuthority(ds);
|
|
4335
|
+
publishAttentionPatch(ds);
|
|
2903
4336
|
break;
|
|
2904
4337
|
}
|
|
2905
4338
|
case 'stuck_warning': {
|
|
@@ -3037,7 +4470,8 @@ function setupWorkerHandlers(ds, worker, startupState = { ready: false, failureN
|
|
|
3037
4470
|
// CLI-generation authority must not outlive the concrete worker/CLI
|
|
3038
4471
|
// pair that issued it. A delayed message from a replaced Node worker
|
|
3039
4472
|
// must neither clear nor restart the replacement generation.
|
|
3040
|
-
|
|
4473
|
+
const workerSession = ds.session;
|
|
4474
|
+
if (ds.worker !== worker || ds.workerGeneration !== workerGeneration) {
|
|
3041
4475
|
logger.warn(`[${t}] Ignored claude_exit from stale worker generation`);
|
|
3042
4476
|
break;
|
|
3043
4477
|
}
|
|
@@ -3046,6 +4480,7 @@ function setupWorkerHandlers(ds, worker, startupState = { ready: false, failureN
|
|
|
3046
4480
|
// card before any replacement worker can be attached; otherwise a late
|
|
3047
4481
|
// click could inject its keys into the replacement CLI.
|
|
3048
4482
|
invalidateStuckWarning(ds, 'claude_exit');
|
|
4483
|
+
invalidateTuiPrompt(ds, 'claude_exit');
|
|
3049
4484
|
logger.info(`[${t}] ${getCliDisplayName(effectiveCliId)} exited (code: ${msg.code}, signal: ${msg.signal})`);
|
|
3050
4485
|
ds.hasHistory = true;
|
|
3051
4486
|
try {
|
|
@@ -3059,15 +4494,26 @@ function setupWorkerHandlers(ds, worker, startupState = { ready: false, failureN
|
|
|
3059
4494
|
catch (err) {
|
|
3060
4495
|
logger.error(`[${t}] Failed to reconcile CLI exit generation ${workerGeneration}: ${err.message}`);
|
|
3061
4496
|
}
|
|
4497
|
+
// onCliExit may persist/reconcile durable state and therefore yield.
|
|
4498
|
+
// A transfer, repo replacement, or worker replacement that won during
|
|
4499
|
+
// that await owns all later lifecycle decisions. Never restart/kill its
|
|
4500
|
+
// new worker from this stale CLI-exit continuation.
|
|
4501
|
+
if (ds.worker !== worker
|
|
4502
|
+
|| ds.workerGeneration !== workerGeneration
|
|
4503
|
+
|| ds.session !== workerSession
|
|
4504
|
+
|| isSessionTransferring(ds)) {
|
|
4505
|
+
logger.warn(`[${t}] Suppressed stale claude_exit lifecycle continuation`);
|
|
4506
|
+
break;
|
|
4507
|
+
}
|
|
3062
4508
|
const suppressExitUi = managedAuxUiSuppressed(msg.turnId, msg.dispatchAttempt);
|
|
3063
4509
|
// Do NOT auto-restart in adopt mode — there's nothing to restart
|
|
3064
4510
|
if (ds.adoptedFrom) {
|
|
3065
4511
|
logger.info(`[${t}] Adopted session ended`);
|
|
3066
4512
|
// Freeze the streaming card
|
|
3067
|
-
if (!suppressExitUi && ds.streamCardId && (ds
|
|
3068
|
-
const readUrl =
|
|
4513
|
+
if (!suppressExitUi && ds.streamCardId && workerHasInitialized(ds)) {
|
|
4514
|
+
const readUrl = readableTerminalUrlFor(ds);
|
|
3069
4515
|
const turnTitle = ds.currentTurnTitle || ds.session.title || getCliDisplayName(effectiveCliId);
|
|
3070
|
-
const frozenCard = buildStreamingCard(ds.session.sessionId, sessionAnchorId(ds), readUrl, turnTitle, ds.lastScreenContent ?? '', 'idle', effectiveCliId, ds.displayMode ?? 'hidden', ds.streamCardNonce, ds.currentImageKey, isAdopt, showTakeover, loc, undefined, writableTerminalLinkFor(ds), isLocalCliOpenReady(ds, { cliId: effectiveCliId }));
|
|
4516
|
+
const frozenCard = buildStreamingCard(ds.session.sessionId, sessionAnchorId(ds), readUrl, turnTitle, ds.lastScreenContent ?? '', 'idle', effectiveCliId, ds.displayMode ?? 'hidden', ds.streamCardNonce, ds.currentImageKey, isAdopt, showTakeover, loc, undefined, writableTerminalLinkFor(ds), isLocalCliOpenReady(ds, { cliId: effectiveCliId }), getDaemonStreamingCardUsageSnapshot(ds, effectiveCliId, { fresh: true }));
|
|
3071
4517
|
scheduleCardPatch(ds, frozenCard);
|
|
3072
4518
|
}
|
|
3073
4519
|
killWorker(ds);
|
|
@@ -3096,13 +4542,13 @@ function setupWorkerHandlers(ds, worker, startupState = { ready: false, failureN
|
|
|
3096
4542
|
if (rc.count > 3) {
|
|
3097
4543
|
logger.warn(`[${t}] ${getCliDisplayName(effectiveCliId)} crashed ${rc.count} times in 1 min, not auto-restarting`);
|
|
3098
4544
|
const keepDiagnosticWorker = !!msg.canParkDiagnostic && !!ds.worker && !ds.worker.killed;
|
|
3099
|
-
// Freeze the last streaming card so it doesn't stay at "working"
|
|
3100
|
-
//
|
|
3101
|
-
//
|
|
3102
|
-
if (!suppressExitUi && ds.streamCardId && ds
|
|
3103
|
-
const readUrl =
|
|
4545
|
+
// Freeze the last streaming card so it doesn't stay at "working"
|
|
4546
|
+
// forever. Backends without a Web Terminal pass an empty read URL;
|
|
4547
|
+
// the card keeps snapshot/manage controls and omits terminal links.
|
|
4548
|
+
if (!suppressExitUi && ds.streamCardId && workerHasInitialized(ds)) {
|
|
4549
|
+
const readUrl = readableTerminalUrlFor(ds);
|
|
3104
4550
|
const turnTitle = ds.currentTurnTitle || ds.session.title || getCliDisplayName(effectiveCliId);
|
|
3105
|
-
const frozenCard = buildStreamingCard(ds.session.sessionId, sessionAnchorId(ds), readUrl, turnTitle, ds.lastScreenContent ?? '', 'idle', effectiveCliId, ds.displayMode ?? 'hidden', ds.streamCardNonce, ds.currentImageKey, isAdopt, showTakeover, loc, undefined, writableTerminalLinkFor(ds), isLocalCliOpenReady(ds, { cliId: effectiveCliId }));
|
|
4551
|
+
const frozenCard = buildStreamingCard(ds.session.sessionId, sessionAnchorId(ds), readUrl, turnTitle, ds.lastScreenContent ?? '', 'idle', effectiveCliId, ds.displayMode ?? 'hidden', ds.streamCardNonce, ds.currentImageKey, isAdopt, showTakeover, loc, undefined, writableTerminalLinkFor(ds), isLocalCliOpenReady(ds, { cliId: effectiveCliId }), getDaemonStreamingCardUsageSnapshot(ds, effectiveCliId, { fresh: true }));
|
|
3106
4552
|
scheduleCardPatch(ds, frozenCard);
|
|
3107
4553
|
}
|
|
3108
4554
|
if (keepDiagnosticWorker) {
|
|
@@ -3111,9 +4557,14 @@ function setupWorkerHandlers(ds, worker, startupState = { ready: false, failureN
|
|
|
3111
4557
|
// don't pay for it). Keep its web server alive so the existing
|
|
3112
4558
|
// terminal URL can show the startup failure; the next user message
|
|
3113
4559
|
// tells that same worker to destroy the diagnostic shell and retry.
|
|
4560
|
+
ds.workerReady = false;
|
|
3114
4561
|
ds.worker.send({ type: 'park_diagnostic' });
|
|
3115
4562
|
restartCounts.delete(key);
|
|
3116
4563
|
ds.lastScreenStatus = 'idle';
|
|
4564
|
+
// Diagnostic park keeps the worker alive (no killWorker here), so the
|
|
4565
|
+
// periodic usage refresh must be stopped explicitly on this
|
|
4566
|
+
// working→idle boundary — the else branch's killWorker already does.
|
|
4567
|
+
clearUsageRefreshTimer(ds);
|
|
3117
4568
|
// Survive a daemon restart: mark this as a lazy cold-resume so
|
|
3118
4569
|
// restore keeps the session active (re-spawns the CLI on the next
|
|
3119
4570
|
// message) instead of zombie-closing it when the real bmx-<sid> is
|
|
@@ -3140,7 +4591,7 @@ function setupWorkerHandlers(ds, worker, startupState = { ready: false, failureN
|
|
|
3140
4591
|
await scopedReply(parts.join('\n\n'), 'text', undefined);
|
|
3141
4592
|
}
|
|
3142
4593
|
catch (replyErr) {
|
|
3143
|
-
if (replyErr instanceof MessageWithdrawnError) {
|
|
4594
|
+
if (replyErr instanceof MessageWithdrawnError && ownsLifecycleMutation()) {
|
|
3144
4595
|
logger.warn(`[${t}] Root message withdrawn, closing stale session`);
|
|
3145
4596
|
cb.closeSession(ds);
|
|
3146
4597
|
}
|
|
@@ -3148,10 +4599,13 @@ function setupWorkerHandlers(ds, worker, startupState = { ready: false, failureN
|
|
|
3148
4599
|
}
|
|
3149
4600
|
break;
|
|
3150
4601
|
}
|
|
3151
|
-
// Auto-restart CLI within the same worker
|
|
4602
|
+
// Auto-restart CLI within the same worker. 捎带最新 per-bot env:崩溃
|
|
4603
|
+
// 往往正是旧 env 配的错(如过期 token / 失效 proxy),用户改完 env 后
|
|
4604
|
+
// 下一轮 auto-restart 直接用新值恢复,不必再手工 /close。
|
|
3152
4605
|
if (ds.worker && !ds.worker.killed) {
|
|
3153
4606
|
logger.info(`[${t}] Auto-restarting ${getCliDisplayName(effectiveCliId)}...`);
|
|
3154
|
-
ds.
|
|
4607
|
+
ds.workerReady = false;
|
|
4608
|
+
ds.worker.send({ type: 'restart', env: latestPerBotEnvForRestart(ds) });
|
|
3155
4609
|
}
|
|
3156
4610
|
break;
|
|
3157
4611
|
}
|
|
@@ -3260,6 +4714,15 @@ function setupWorkerHandlers(ds, worker, startupState = { ready: false, failureN
|
|
|
3260
4714
|
sourceSessionIds.add(msg.sourceSessionId);
|
|
3261
4715
|
break;
|
|
3262
4716
|
}
|
|
4717
|
+
case 'explicit_reply_observed': {
|
|
4718
|
+
if (msg.turnId.startsWith('mlrp_turn_')) {
|
|
4719
|
+
markMessageListenerRunPreviewReplied(msg.turnId, {
|
|
4720
|
+
sessionId: ds.session.sessionId,
|
|
4721
|
+
replyMessageId: msg.messageId,
|
|
4722
|
+
});
|
|
4723
|
+
}
|
|
4724
|
+
break;
|
|
4725
|
+
}
|
|
3263
4726
|
case 'user_notify': {
|
|
3264
4727
|
logger.warn(`[${t}] Worker user_notify: ${msg.message}`);
|
|
3265
4728
|
emitSessionLifecycleHook(ds, 'session.requires_attention', {
|
|
@@ -3321,6 +4784,12 @@ function setupWorkerHandlers(ds, worker, startupState = { ready: false, failureN
|
|
|
3321
4784
|
// never let a projection/store failure crash the worker IPC loop.
|
|
3322
4785
|
logger.error(`[${t}] Failed to persist turn_terminal for ${msg.turnId.substring(0, 8)}: ${err.message}`);
|
|
3323
4786
|
}
|
|
4787
|
+
if (msg.turnId.startsWith('mlrp_turn_') && msg.status !== 'completed') {
|
|
4788
|
+
markMessageListenerRunPreviewFailed(msg.turnId, {
|
|
4789
|
+
sessionId: msg.sessionId,
|
|
4790
|
+
error: msg.errorCode ?? msg.status,
|
|
4791
|
+
});
|
|
4792
|
+
}
|
|
3324
4793
|
try {
|
|
3325
4794
|
await cb.onDeferredScheduleTurnSettled?.(ds, { turnId: msg.turnId, source: 'terminal' });
|
|
3326
4795
|
}
|
|
@@ -3399,6 +4868,10 @@ function setupWorkerHandlers(ds, worker, startupState = { ready: false, failureN
|
|
|
3399
4868
|
ds.managedTurnOrigin = undefined;
|
|
3400
4869
|
break;
|
|
3401
4870
|
}
|
|
4871
|
+
case 'session_close_ready': {
|
|
4872
|
+
resolveCloseFence(msg.sessionId, workerGeneration);
|
|
4873
|
+
break;
|
|
4874
|
+
}
|
|
3402
4875
|
case 'final_output': {
|
|
3403
4876
|
// Adopt-bridge: worker harvested the assistant turn from Claude Code's
|
|
3404
4877
|
// transcript JSONL and forwarded it to us. Dedup with a session-scoped
|
|
@@ -3436,7 +4909,10 @@ function setupWorkerHandlers(ds, worker, startupState = { ready: false, failureN
|
|
|
3436
4909
|
if (ds.lastScreenStatus === 'limited')
|
|
3437
4910
|
ds.lastScreenStatus = 'idle';
|
|
3438
4911
|
}
|
|
3439
|
-
|
|
4912
|
+
if (msg.turnId.startsWith('mlrp_turn_')) {
|
|
4913
|
+
markMessageListenerRunPreviewRunning(msg.turnId);
|
|
4914
|
+
}
|
|
4915
|
+
deliverFinalOutput(ds, msg, t, 0, ownsLifecycleMutation);
|
|
3440
4916
|
break;
|
|
3441
4917
|
}
|
|
3442
4918
|
case 'adopt_preamble': {
|
|
@@ -3466,6 +4942,7 @@ function setupWorkerHandlers(ds, worker, startupState = { ready: false, failureN
|
|
|
3466
4942
|
locale: localeForBot(ds.larkAppId),
|
|
3467
4943
|
workingDir: ds.workingDir,
|
|
3468
4944
|
localHomeLinkMode: daemonCardLocalHomeLinkMode(ds),
|
|
4945
|
+
usage: getDaemonReplyCardUsageSnapshot(ds, effectiveCliId),
|
|
3469
4946
|
});
|
|
3470
4947
|
scopedReply(cardJson, 'interactive', msg.turnId).catch((err) => {
|
|
3471
4948
|
logger.warn(`[${t}] Failed to deliver adopt_preamble to Lark: ${err.message}`);
|
|
@@ -3475,12 +4952,15 @@ function setupWorkerHandlers(ds, worker, startupState = { ready: false, failureN
|
|
|
3475
4952
|
}
|
|
3476
4953
|
});
|
|
3477
4954
|
worker.on('exit', (code, signal) => {
|
|
4955
|
+
const transferRetirement = transferRetiringWorkers.has(worker);
|
|
4956
|
+
transferRetiringWorkers.delete(worker);
|
|
4957
|
+
clearLifecycleRetirement(ds, worker);
|
|
3478
4958
|
logger.info(`[${t}] Worker process exited (code: ${code})`);
|
|
3479
4959
|
// Last-resort startup guard: syntax/import crashes and abrupt exits can
|
|
3480
4960
|
// happen before the worker sends either ready or a structured error. Do
|
|
3481
4961
|
// not leave the originating Lark message unanswered. Intentional close /
|
|
3482
4962
|
// replacement kills are excluded to avoid noisy false alarms.
|
|
3483
|
-
if (!startupState.ready && !startupState.failureNotified && !worker.killed && ds.session.status !== 'closed') {
|
|
4963
|
+
if (!transferRetirement && !startupState.ready && !startupState.failureNotified && !worker.killed && ds.session.status !== 'closed') {
|
|
3484
4964
|
const reason = tr('worker.start_exited_early', { code: code ?? 'null' }, loc);
|
|
3485
4965
|
// Carry the frozen init attribution so an abrupt pre-ready exit of a
|
|
3486
4966
|
// durable VC delivery is fenced to the receipt/lease chain, not replied
|
|
@@ -3494,11 +4974,16 @@ function setupWorkerHandlers(ds, worker, startupState = { ready: false, failureN
|
|
|
3494
4974
|
if (ds.worker === worker) {
|
|
3495
4975
|
restartCoordinator.failSession(ds.session.sessionId);
|
|
3496
4976
|
ds.worker = null;
|
|
4977
|
+
ds.workerReady = false;
|
|
3497
4978
|
ds.workerPort = null;
|
|
4979
|
+
// Dead worker generation — stop the periodic usage refresh immediately
|
|
4980
|
+
// instead of waiting a tick for it to self-clear on !workerHasInitialized.
|
|
4981
|
+
clearUsageRefreshTimer(ds);
|
|
3498
4982
|
ds.managedTurnOrigin = undefined;
|
|
3499
4983
|
// This worker generation is gone. Invalidate any stuck-warning card it
|
|
3500
4984
|
// posted so a late click cannot inject keys into a replacement worker.
|
|
3501
4985
|
invalidateStuckWarning(ds, 'worker_exit');
|
|
4986
|
+
invalidateTuiPrompt(ds, 'worker_exit');
|
|
3502
4987
|
// Fence this lifetime before a polling dispatcher can observe its last
|
|
3503
4988
|
// ACK. Keeping the old receipt is useful audit evidence, but the
|
|
3504
4989
|
// persisted current generation advances immediately so it cannot count
|
|
@@ -3510,24 +4995,29 @@ function setupWorkerHandlers(ds, worker, startupState = { ready: false, failureN
|
|
|
3510
4995
|
ds.session.pid = undefined;
|
|
3511
4996
|
sessionStore.updateSession(ds.session);
|
|
3512
4997
|
}
|
|
3513
|
-
|
|
3514
|
-
|
|
3515
|
-
|
|
3516
|
-
|
|
3517
|
-
|
|
3518
|
-
|
|
3519
|
-
|
|
3520
|
-
|
|
4998
|
+
if (!transferRetirement) {
|
|
4999
|
+
try {
|
|
5000
|
+
const notified = cb.onWorkerExit?.(ds, {
|
|
5001
|
+
sessionId: ds.session.sessionId,
|
|
5002
|
+
workerGeneration,
|
|
5003
|
+
code,
|
|
5004
|
+
signal,
|
|
5005
|
+
});
|
|
5006
|
+
void Promise.resolve(notified).catch((err) => {
|
|
5007
|
+
logger.error(`[${t}] Failed to reconcile worker exit generation ${workerGeneration}: ${err.message}`);
|
|
5008
|
+
});
|
|
5009
|
+
}
|
|
5010
|
+
catch (err) {
|
|
3521
5011
|
logger.error(`[${t}] Failed to reconcile worker exit generation ${workerGeneration}: ${err.message}`);
|
|
3522
|
-
}
|
|
5012
|
+
}
|
|
3523
5013
|
}
|
|
3524
|
-
|
|
3525
|
-
logger.
|
|
5014
|
+
else {
|
|
5015
|
+
logger.info(`[${t}] Suppressed external worker-exit effects for routing transfer`);
|
|
3526
5016
|
}
|
|
3527
5017
|
// Notify dashboard, but only once per session lifecycle. The
|
|
3528
5018
|
// dashboard-driven `closeSession()` path also publishes; whichever
|
|
3529
5019
|
// fires first wins, the other's emit is suppressed.
|
|
3530
|
-
if (!ds.exitEventEmitted) {
|
|
5020
|
+
if (!transferRetirement && !ds.exitEventEmitted) {
|
|
3531
5021
|
ds.exitEventEmitted = true;
|
|
3532
5022
|
dashboardEventBus.publish({
|
|
3533
5023
|
type: 'session.exited',
|
|
@@ -3577,7 +5067,8 @@ function shouldDropMismatchedHermesFinalOutput(ds, msg, t) {
|
|
|
3577
5067
|
/**
|
|
3578
5068
|
* Turn-end half of the two-phase turn reactions (auto-on for card-off sessions,
|
|
3579
5069
|
* i.e. streaming card disabled). The 冲! "received" reactions are added per-message at the daemon
|
|
3580
|
-
* acceptance point (`noteTurnReceived`);
|
|
5070
|
+
* acceptance point (`noteTurnReceived`); the screen_update handler calls this
|
|
5071
|
+
* only on working|analyzing → idle|limited (not cold-start starting→idle), to
|
|
3581
5072
|
* flip every pending ✋ on this session to ✅ DONE and clear the list. When
|
|
3582
5073
|
* silentTurnReactions is enabled after a ✋ has already landed, we only remove
|
|
3583
5074
|
* that received reaction and do not add DONE. Binding the start to the message
|
|
@@ -3623,7 +5114,10 @@ async function finishTurnReactions(ds) {
|
|
|
3623
5114
|
* daemon owns retries on transient failures. After 3 attempts we log
|
|
3624
5115
|
* and give up — the user's answer is lost; better than leaking memory
|
|
3625
5116
|
* via an unbounded retry loop. */
|
|
3626
|
-
function deliverFinalOutput(ds, msg, t, attempt) {
|
|
5117
|
+
function deliverFinalOutput(ds, msg, t, attempt, stillCurrent = () => true, frozenUsage) {
|
|
5118
|
+
if (!stillCurrent())
|
|
5119
|
+
return;
|
|
5120
|
+
let cardUsage = frozenUsage;
|
|
3627
5121
|
const managedReceiver = !!ds.session.vcMeetingReceiver;
|
|
3628
5122
|
// Wait Mode / HTTP Sync Override:
|
|
3629
5123
|
// If this turn is being waited for by an HTTP webhook request, intercept the
|
|
@@ -3643,10 +5137,12 @@ function deliverFinalOutput(ds, msg, t, attempt) {
|
|
|
3643
5137
|
asyncResult.status = 'completed';
|
|
3644
5138
|
asyncResult.content = msg.content;
|
|
3645
5139
|
asyncResult.completedAt = completedAt;
|
|
5140
|
+
if (msg.usage)
|
|
5141
|
+
asyncResult.usage = msg.usage;
|
|
3646
5142
|
// Durably persist the outcome so trigger-result can rebuild `completed`
|
|
3647
|
-
// (with content) after a daemon restart drops the in-memory Map.
|
|
3648
|
-
// owning bot for cross-bot isolation.
|
|
3649
|
-
asyncTriggerStore.recordCompleted(ds.session.sessionId, msg.turnId, msg.content, completedAt, ds.larkAppId);
|
|
5143
|
+
// (with content + usage) after a daemon restart drops the in-memory Map.
|
|
5144
|
+
// Stamp the owning bot for cross-bot isolation.
|
|
5145
|
+
asyncTriggerStore.recordCompleted(ds.session.sessionId, msg.turnId, msg.content, completedAt, ds.larkAppId, msg.usage);
|
|
3650
5146
|
ds.lastBridgeEmittedUuid = finalOutputDedupeKey(ds, msg);
|
|
3651
5147
|
logger.info(`[${t}] Captured final_output for Async HTTP request (turn ${msg.turnId.substring(0, 8)})`);
|
|
3652
5148
|
return;
|
|
@@ -3657,6 +5153,8 @@ function deliverFinalOutput(ds, msg, t, attempt) {
|
|
|
3657
5153
|
? { ...opts, sourceSessionId: ds.session.sessionId }
|
|
3658
5154
|
: opts);
|
|
3659
5155
|
setTimeout(async () => {
|
|
5156
|
+
if (!stillCurrent())
|
|
5157
|
+
return;
|
|
3660
5158
|
// Guard: if the user closed the session (or it was torn down for any
|
|
3661
5159
|
// other reason) between attempts, don't post a stale final answer to
|
|
3662
5160
|
// a closed thread.
|
|
@@ -3667,31 +5165,44 @@ function deliverFinalOutput(ds, msg, t, attempt) {
|
|
|
3667
5165
|
try {
|
|
3668
5166
|
// 文档评论入口分流:本轮若来自飞书文档评论(/watch-comment / /subscribe-lark-doc),把正文
|
|
3669
5167
|
// 发表为文档评论(而非飞书卡片),状态卡/占位卡仍留在飞书会话起点。
|
|
3670
|
-
const docTurn = managedReceiver ? undefined : ds
|
|
5168
|
+
const docTurn = managedReceiver ? undefined : resolveDocCommentTarget(ds, msg.turnId);
|
|
3671
5169
|
if (docTurn) {
|
|
3672
5170
|
// 嵌套回复到用户那条评论 thread(已挂在其下,无需再 ↪ 前缀)。这是兜底路径
|
|
3673
5171
|
// (模型没显式 botmux send),默认 @ 回原评论人,仅首块加。
|
|
3674
5172
|
const chunks = chunkCommentText(msg.content);
|
|
3675
5173
|
for (let i = 0; i < chunks.length; i++) {
|
|
5174
|
+
if (!stillCurrent())
|
|
5175
|
+
return;
|
|
3676
5176
|
await replyToDocComment(ds.larkAppId, { fileToken: docTurn.fileToken, fileType: docTurn.fileType }, docTurn.commentId, chunks[i], i === 0 ? docTurn.replyToOpenId : undefined);
|
|
5177
|
+
if (!stillCurrent())
|
|
5178
|
+
return;
|
|
3677
5179
|
}
|
|
3678
|
-
//
|
|
5180
|
+
// The user-visible reply is committed. Consume the route and dedupe
|
|
5181
|
+
// marker BEFORE best-effort reaction cleanup: a missing/expired
|
|
5182
|
+
// reaction must never retry and duplicate the document comment.
|
|
5183
|
+
consumeDocCommentTurn(ds, msg.turnId);
|
|
5184
|
+
ds.lastBridgeEmittedUuid = finalOutputDedupeKey(ds, msg);
|
|
3679
5185
|
if (docTurn.reactionId && docTurn.replyId) {
|
|
3680
|
-
await removeCommentReaction(ds.larkAppId, { fileToken: docTurn.fileToken, fileType: docTurn.fileType }, docTurn.commentId, docTurn.replyId, docTurn.reactionId);
|
|
3681
|
-
}
|
|
3682
|
-
ds.docCommentTurns?.delete(msg.turnId);
|
|
3683
|
-
// 同步清理磁盘上的 per-turn 落点,避免 session 文件堆积。
|
|
3684
|
-
if (ds.session.docCommentTargets && ds.session.docCommentTargets[msg.turnId]) {
|
|
3685
|
-
delete ds.session.docCommentTargets[msg.turnId];
|
|
3686
5186
|
try {
|
|
3687
|
-
|
|
5187
|
+
await removeCommentReaction(ds.larkAppId, { fileToken: docTurn.fileToken, fileType: docTurn.fileType }, docTurn.commentId, docTurn.replyId, docTurn.reactionId);
|
|
5188
|
+
}
|
|
5189
|
+
catch (err) {
|
|
5190
|
+
logger.debug(`[doc-comment] failed to remove completed reaction ${docTurn.reactionId}: ${err?.message ?? err}`);
|
|
3688
5191
|
}
|
|
3689
|
-
catch { /* best-effort */ }
|
|
3690
5192
|
}
|
|
3691
|
-
ds.lastBridgeEmittedUuid = finalOutputDedupeKey(ds, msg);
|
|
3692
5193
|
logger.info(`[${t}] doc-comment final_output → posted ${chunks.length} comment(s) on file=${docTurn.fileToken.slice(0, 12)} (turn ${msg.turnId.substring(0, 8)})`);
|
|
3693
5194
|
return;
|
|
3694
5195
|
}
|
|
5196
|
+
// A doc-native session has a virtual `doc:<token>` chat anchor, not a
|
|
5197
|
+
// Feishu chat/thread. If its per-turn target is missing or corrupt there
|
|
5198
|
+
// is no safe fallback destination; suppress instead of trying to post an
|
|
5199
|
+
// interactive card to the virtual id.
|
|
5200
|
+
if (isDocNativeSession(ds)) {
|
|
5201
|
+
ds.lastBridgeEmittedUuid = finalOutputDedupeKey(ds, msg);
|
|
5202
|
+
logger.error(`[${t}] Suppressed doc-native final_output without comment target ` +
|
|
5203
|
+
`(turn ${msg.turnId.substring(0, 8)})`);
|
|
5204
|
+
return;
|
|
5205
|
+
}
|
|
3695
5206
|
// Wrap the model's reply in the same card chrome `botmux send` uses
|
|
3696
5207
|
// (schema 2.0 + footer with botmux link + 发送给 owner) so a turn
|
|
3697
5208
|
// delivered via this fallback path looks identical in the Lark thread
|
|
@@ -3794,6 +5305,7 @@ function deliverFinalOutput(ds, msg, t, attempt) {
|
|
|
3794
5305
|
: imOrigin?.replyTargetSenderOpenId
|
|
3795
5306
|
?? daemonCardFooterRecipientOpenId(ds, effectiveCliId);
|
|
3796
5307
|
const localHomeLinkMode = daemonCardLocalHomeLinkMode(ds);
|
|
5308
|
+
cardUsage ??= getDaemonReplyCardUsageSnapshot(ds, effectiveCliId);
|
|
3797
5309
|
const cardJson = msg.kind === 'local-turn' || msg.kind === 'local-turn-headless'
|
|
3798
5310
|
? buildContextualReplyCard({
|
|
3799
5311
|
title: msg.kind === 'local-turn-headless'
|
|
@@ -3807,8 +5319,9 @@ function deliverFinalOutput(ds, msg, t, attempt) {
|
|
|
3807
5319
|
locale: localeForBot(ds.larkAppId),
|
|
3808
5320
|
workingDir: ds.workingDir,
|
|
3809
5321
|
localHomeLinkMode,
|
|
5322
|
+
usage: cardUsage,
|
|
3810
5323
|
})
|
|
3811
|
-
: buildMarkdownCard(safeAssistantText, recipientOpenId, renderBrandTemplate(resolveBrandLabel(ds.larkAppId), ds.workingDir), localeForBot(ds.larkAppId), ds.workingDir, localHomeLinkMode);
|
|
5324
|
+
: buildMarkdownCard(safeAssistantText, recipientOpenId, renderBrandTemplate(resolveBrandLabel(ds.larkAppId), ds.workingDir), localeForBot(ds.larkAppId), ds.workingDir, localHomeLinkMode, cardUsage);
|
|
3812
5325
|
const proposedOutput = {
|
|
3813
5326
|
targetChatId: ds.chatId,
|
|
3814
5327
|
...(imOrigin ? { quoteTargetId: imOrigin.larkMessageId } : {}),
|
|
@@ -3897,7 +5410,15 @@ function deliverFinalOutput(ds, msg, t, attempt) {
|
|
|
3897
5410
|
: {}),
|
|
3898
5411
|
}
|
|
3899
5412
|
: undefined);
|
|
5413
|
+
if (!stillCurrent())
|
|
5414
|
+
return;
|
|
3900
5415
|
recordPrimaryOutput(messageId);
|
|
5416
|
+
if (msg.turnId.startsWith('mlrp_turn_')) {
|
|
5417
|
+
markMessageListenerRunPreviewReplied(msg.turnId, {
|
|
5418
|
+
sessionId: ds.session.sessionId,
|
|
5419
|
+
replyMessageId: messageId,
|
|
5420
|
+
});
|
|
5421
|
+
}
|
|
3901
5422
|
if (preparedListenerReply?.kind === 'send' || preparedListenerReply?.kind === 'succeeded') {
|
|
3902
5423
|
finishVcMeetingImReply(config.session.dataDir, preparedListenerReply.ref, messageId);
|
|
3903
5424
|
}
|
|
@@ -3905,6 +5426,8 @@ function deliverFinalOutput(ds, msg, t, attempt) {
|
|
|
3905
5426
|
logger.info(`[${t}] Bridge final_output forwarded (turn ${msg.turnId.substring(0, 8)}, ${msg.content.length} chars, kind=${msg.kind ?? 'bridge'}, attempt ${attempt + 1})`);
|
|
3906
5427
|
}
|
|
3907
5428
|
catch (err) {
|
|
5429
|
+
if (!stillCurrent())
|
|
5430
|
+
return;
|
|
3908
5431
|
if (err instanceof MessageWithdrawnError) {
|
|
3909
5432
|
// Root message gone — no point retrying. Mark as emitted so any
|
|
3910
5433
|
// duplicate IPC is correctly deduped, and tear the session down.
|
|
@@ -3916,12 +5439,18 @@ function deliverFinalOutput(ds, msg, t, attempt) {
|
|
|
3916
5439
|
const next = attempt + 1;
|
|
3917
5440
|
if (next >= FINAL_OUTPUT_RETRY_BACKOFF_MS.length) {
|
|
3918
5441
|
logger.error(`[${t}] Bridge final_output gave up after ${next} attempts (turn ${msg.turnId.substring(0, 8)}): ${err.message}`);
|
|
5442
|
+
if (msg.turnId.startsWith('mlrp_turn_')) {
|
|
5443
|
+
markMessageListenerRunPreviewFailed(msg.turnId, {
|
|
5444
|
+
sessionId: ds.session.sessionId,
|
|
5445
|
+
error: err.message,
|
|
5446
|
+
});
|
|
5447
|
+
}
|
|
3919
5448
|
// Don't commit the dedup marker — leave room for any future
|
|
3920
5449
|
// retransmit (e.g. daemon restart that re-fires the IPC).
|
|
3921
5450
|
return;
|
|
3922
5451
|
}
|
|
3923
5452
|
logger.warn(`[${t}] Bridge final_output attempt ${next} failed (${err.message}); retrying in ${FINAL_OUTPUT_RETRY_BACKOFF_MS[next]}ms`);
|
|
3924
|
-
deliverFinalOutput(ds, msg, t, next);
|
|
5453
|
+
deliverFinalOutput(ds, msg, t, next, stillCurrent, cardUsage);
|
|
3925
5454
|
}
|
|
3926
5455
|
}, FINAL_OUTPUT_RETRY_BACKOFF_MS[attempt] ?? 0);
|
|
3927
5456
|
}
|
|
@@ -3929,6 +5458,7 @@ function deliverFinalOutput(ds, msg, t, attempt) {
|
|
|
3929
5458
|
* fork. Intentionally underscored to discourage non-test callers. */
|
|
3930
5459
|
export const __testOnly_deliverFinalOutput = deliverFinalOutput;
|
|
3931
5460
|
export const __testOnly_setupWorkerHandlers = setupWorkerHandlers;
|
|
5461
|
+
export const __testOnly_reserveWorkerGeneration = reserveWorkerGeneration;
|
|
3932
5462
|
export const __testOnly_finishTurnReactions = finishTurnReactions;
|
|
3933
5463
|
export const __testOnly_finalOutputDedupeKey = finalOutputDedupeKey;
|
|
3934
5464
|
// ─── Fork adopt worker ──────────────────────────────────────────────────────
|
|
@@ -3952,6 +5482,10 @@ function reserveWorkerGeneration(ds) {
|
|
|
3952
5482
|
ds.session.workerGeneration = previousSessionGeneration;
|
|
3953
5483
|
throw error;
|
|
3954
5484
|
}
|
|
5485
|
+
// Reservation is the first durable proof that the previous generation has
|
|
5486
|
+
// lost authority. Clear its TUI slot before any environment check, adapter
|
|
5487
|
+
// creation, or fork can throw and strand a clicked card in "processing".
|
|
5488
|
+
invalidateTuiPrompt(ds, 'reserve_worker_generation');
|
|
3955
5489
|
return workerGeneration;
|
|
3956
5490
|
}
|
|
3957
5491
|
/**
|
|
@@ -3975,10 +5509,25 @@ function reserveWorkerGeneration(ds) {
|
|
|
3975
5509
|
export function adoptSandboxBlocked(botCfg, session) {
|
|
3976
5510
|
return botCfg.sandbox === true
|
|
3977
5511
|
|| botCfg.readIsolation === true
|
|
5512
|
+
// A core-only (apiOnly) bot — or a session on a synthetic HTTP virtual chat —
|
|
5513
|
+
// must NOT adopt-observe a pre-existing external CLI: that CLI runs fully
|
|
5514
|
+
// unisolated (the adopt observe branch returns before any fs-policy build),
|
|
5515
|
+
// so it could read this host's bots.json / sibling creds on behalf of a
|
|
5516
|
+
// no-transport turn. Convert to cold-start instead (same as sandbox adopt).
|
|
5517
|
+
|| botCfg.apiOnly === true
|
|
5518
|
+
|| (typeof session?.chatId === 'string'
|
|
5519
|
+
&& (session.chatId.startsWith('http_async_') || session.chatId.startsWith('http_wait_')))
|
|
3978
5520
|
|| session?.sandbox === true
|
|
3979
5521
|
|| sandboxEnabled();
|
|
3980
5522
|
}
|
|
3981
5523
|
export function forkAdoptWorker(ds, opts) {
|
|
5524
|
+
if (isSessionTransferring(ds)) {
|
|
5525
|
+
logger.warn(`[${tag(ds)}] Adopt worker fork refused during routing transfer`);
|
|
5526
|
+
return;
|
|
5527
|
+
}
|
|
5528
|
+
if (!canForkRegisteredSession(ds))
|
|
5529
|
+
return;
|
|
5530
|
+
ds.workerReady = false;
|
|
3982
5531
|
const cb = requireCallbacks();
|
|
3983
5532
|
const workerPath = join(__dirname, '..', 'worker.js');
|
|
3984
5533
|
const t = tag(ds);
|
|
@@ -4018,16 +5567,19 @@ export function forkAdoptWorker(ds, opts) {
|
|
|
4018
5567
|
const workerGeneration = reserveWorkerGeneration(ds);
|
|
4019
5568
|
// Guard against double-fork
|
|
4020
5569
|
if (ds.worker && !ds.worker.killed) {
|
|
5570
|
+
const replacedWorker = ds.worker;
|
|
4021
5571
|
logger.warn(`[${t}] Worker already running, killing before adopt-fork`);
|
|
5572
|
+
trackLifecycleRetirement(ds, replacedWorker);
|
|
4022
5573
|
try {
|
|
4023
|
-
|
|
5574
|
+
replacedWorker.send({ type: 'close' });
|
|
4024
5575
|
}
|
|
4025
5576
|
catch { }
|
|
4026
5577
|
try {
|
|
4027
|
-
|
|
5578
|
+
replacedWorker.kill();
|
|
4028
5579
|
}
|
|
4029
5580
|
catch { }
|
|
4030
5581
|
ds.worker = null;
|
|
5582
|
+
ds.workerReady = false;
|
|
4031
5583
|
ds.workerPort = null;
|
|
4032
5584
|
ds.workerToken = null;
|
|
4033
5585
|
ds.workerViewToken = null;
|
|
@@ -4050,7 +5602,9 @@ export function forkAdoptWorker(ds, opts) {
|
|
|
4050
5602
|
CLAUDECODE: undefined,
|
|
4051
5603
|
BOTMUX: '1',
|
|
4052
5604
|
LARK_APP_ID: botCfg.larkAppId,
|
|
4053
|
-
|
|
5605
|
+
// Withhold the real secret from the adopt worker's CLI env for a
|
|
5606
|
+
// no-transport session — same rationale as forkWorker above.
|
|
5607
|
+
LARK_APP_SECRET: larkTransportEnabled({ chatId: ds.chatId, apiOnly: botCfg.apiOnly }) ? botCfg.larkAppSecret : '',
|
|
4054
5608
|
},
|
|
4055
5609
|
});
|
|
4056
5610
|
const startupState = { ready: false, failureNotified: false };
|
|
@@ -4169,7 +5723,13 @@ export function forkAdoptWorker(ds, opts) {
|
|
|
4169
5723
|
ownerOpenId: ds.ownerOpenId,
|
|
4170
5724
|
webPort: ds.session.webPort,
|
|
4171
5725
|
larkAppId: botCfg.larkAppId,
|
|
4172
|
-
|
|
5726
|
+
// Freeze on the session transport capability: a no-transport session
|
|
5727
|
+
// (apiOnly bot OR HTTP virtual chat) must not even RECEIVE the real secret —
|
|
5728
|
+
// withholding it removes the capability rather than gating a tamperable flag
|
|
5729
|
+
// the sandboxed agent could flip. The worker then physically cannot dial
|
|
5730
|
+
// Feishu (uploader/cred-write are also skipped downstream on the same test).
|
|
5731
|
+
larkAppSecret: larkTransportEnabled({ chatId: ds.chatId, apiOnly: botCfg.apiOnly }) ? botCfg.larkAppSecret : '',
|
|
5732
|
+
apiOnly: botCfg.apiOnly,
|
|
4173
5733
|
brand: normalizeBrand(botCfg.brand),
|
|
4174
5734
|
botName: bot.botName,
|
|
4175
5735
|
botOpenId: bot.botOpenId,
|
|
@@ -4340,8 +5900,15 @@ export function reapOrphanWorkers(opts = {}) {
|
|
|
4340
5900
|
return reaped;
|
|
4341
5901
|
}
|
|
4342
5902
|
// ─── Kill stale PIDs ────────────────────────────────────────────────────────
|
|
4343
|
-
export function killStalePids(activeSessions_) {
|
|
5903
|
+
export function killStalePids(activeSessions_, runtimeSessions) {
|
|
5904
|
+
// Startup restore runs concurrently with the already-live dispatcher. A
|
|
5905
|
+
// message may create/register a fresh worker for a snapshot row before this
|
|
5906
|
+
// stale-process sweep starts. Treat the runtime registry as authoritative:
|
|
5907
|
+
// never signal the PID of any logical session that is already registered.
|
|
5908
|
+
const runtimeSessionIds = new Set(runtimeSessions ? [...runtimeSessions.values()].map(ds => ds.session.sessionId) : []);
|
|
4344
5909
|
for (const session of activeSessions_) {
|
|
5910
|
+
if (runtimeSessionIds.has(session.sessionId))
|
|
5911
|
+
continue;
|
|
4345
5912
|
if (!session.pid)
|
|
4346
5913
|
continue;
|
|
4347
5914
|
try {
|
|
@@ -4363,15 +5930,84 @@ export function killStalePids(activeSessions_) {
|
|
|
4363
5930
|
// Process doesn't exist, nothing to clean up
|
|
4364
5931
|
}
|
|
4365
5932
|
}
|
|
4366
|
-
cleanupPersistentBackendSessions('tmux', activeSessions_);
|
|
4367
|
-
cleanupPersistentBackendSessions('herdr', activeSessions_);
|
|
5933
|
+
cleanupPersistentBackendSessions('tmux', activeSessions_, runtimeSessions);
|
|
5934
|
+
cleanupPersistentBackendSessions('herdr', activeSessions_, runtimeSessions);
|
|
5935
|
+
cleanupPersistentBackendSessions('zmx', activeSessions_, runtimeSessions);
|
|
5936
|
+
}
|
|
5937
|
+
/**
|
|
5938
|
+
* Sweep dead CLI-pid marker files out of `.botmux-cli-pids/`. Each marker is
|
|
5939
|
+
* named for the PID that wrote it; when that PID is gone the file is a landmine —
|
|
5940
|
+
* the kernel eventually recycles the number onto an unrelated process, and a
|
|
5941
|
+
* `botmux send` climbing its ancestry can then read a since-exited session's
|
|
5942
|
+
* marker and route the message into the WRONG bot's session. (Fix A already
|
|
5943
|
+
* rejects such a marker at read time by verifying procStart / the env session
|
|
5944
|
+
* id; this GC removes the file so the collision can't even be attempted, and
|
|
5945
|
+
* keeps the directory from growing without bound — graceful worker exit unlinks
|
|
5946
|
+
* its own marker, but SIGKILL / crash / force-kill do not.)
|
|
5947
|
+
*
|
|
5948
|
+
* Cross-daemon safe: with many daemons sharing one data dir, a DEAD pid cannot
|
|
5949
|
+
* belong to any live daemon's session, so unlinking its marker never races a
|
|
5950
|
+
* peer. A live pid's marker is always left untouched. The tiny window where a
|
|
5951
|
+
* PID is recycled between the liveness probe and unlink is benign — the new
|
|
5952
|
+
* owner rewrites its marker on the next turn, and Fix A makes a briefly-missing
|
|
5953
|
+
* marker fall back to the correct env id, never to a wrong session.
|
|
5954
|
+
*
|
|
5955
|
+
* `isPidAlive` is injectable so tests are deterministic regardless of what PIDs
|
|
5956
|
+
* the host has actually allocated (picking a "surely-dead" literal is unsafe —
|
|
5957
|
+
* it can be below pid_max and collide with a live process on a busy runner).
|
|
5958
|
+
* Production uses `process.kill(pid, 0)`: a signal-0 probe that never kills.
|
|
5959
|
+
* Conservative on every ambiguity — only a definitively-dead PID is swept.
|
|
5960
|
+
*/
|
|
5961
|
+
export function defaultPidLiveness(pid) {
|
|
5962
|
+
try {
|
|
5963
|
+
process.kill(pid, 0); // signal 0 = liveness probe, never kills
|
|
5964
|
+
return true; // alive → owned by some (possibly peer) daemon
|
|
5965
|
+
}
|
|
5966
|
+
catch (err) {
|
|
5967
|
+
if (err?.code === 'ESRCH')
|
|
5968
|
+
return false; // no such process → dead
|
|
5969
|
+
return true; // EPERM (alive, not ours) or unknown error → keep, never guess
|
|
5970
|
+
}
|
|
5971
|
+
}
|
|
5972
|
+
export function sweepDeadPidMarkers(dataDir = config.session.dataDir, isPidAlive = defaultPidLiveness) {
|
|
5973
|
+
const markersDir = join(dataDir, '.botmux-cli-pids');
|
|
5974
|
+
let entries;
|
|
5975
|
+
try {
|
|
5976
|
+
entries = readdirSync(markersDir);
|
|
5977
|
+
}
|
|
5978
|
+
catch {
|
|
5979
|
+
return 0;
|
|
5980
|
+
} // dir absent (fresh install) → nothing to sweep
|
|
5981
|
+
let removed = 0;
|
|
5982
|
+
for (const name of entries) {
|
|
5983
|
+
const pid = Number(name);
|
|
5984
|
+
if (!Number.isInteger(pid) || pid <= 1)
|
|
5985
|
+
continue; // ignore non-pid files
|
|
5986
|
+
if (isPidAlive(pid))
|
|
5987
|
+
continue; // keep live (incl. peer daemons)
|
|
5988
|
+
try {
|
|
5989
|
+
unlinkSync(join(markersDir, name));
|
|
5990
|
+
removed++;
|
|
5991
|
+
}
|
|
5992
|
+
catch { /* raced with another sweeper or the owner — fine */ }
|
|
5993
|
+
}
|
|
5994
|
+
if (removed > 0)
|
|
5995
|
+
logger.info(`Swept ${removed} dead CLI-pid marker(s) from ${markersDir}`);
|
|
5996
|
+
return removed;
|
|
4368
5997
|
}
|
|
4369
|
-
function cleanupPersistentBackendSessions(backendType, activeSessions_) {
|
|
5998
|
+
function cleanupPersistentBackendSessions(backendType, activeSessions_, runtimeSessions) {
|
|
5999
|
+
const storedSessions = sessionStore.listSessions();
|
|
6000
|
+
const runtimeSessionRows = runtimeSessions
|
|
6001
|
+
? [...runtimeSessions.values()].map(ds => ds.session)
|
|
6002
|
+
: [];
|
|
4370
6003
|
const anyBackend = getAllBots().some(b => (b.config.backendType ?? config.daemon.backendType) === backendType)
|
|
4371
|
-
|| config.daemon.backendType === backendType
|
|
6004
|
+
|| config.daemon.backendType === backendType
|
|
6005
|
+
|| activeSessions_.some(s => s.backendType === backendType)
|
|
6006
|
+
|| runtimeSessionRows.some(s => s.backendType === backendType)
|
|
6007
|
+
|| storedSessions.some(s => s.backendType === backendType);
|
|
4372
6008
|
if (!anyBackend)
|
|
4373
6009
|
return;
|
|
4374
|
-
const backend = backendType === 'tmux' ? TmuxBackend : HerdrBackend;
|
|
6010
|
+
const backend = backendType === 'tmux' ? TmuxBackend : backendType === 'zmx' ? ZmxBackend : HerdrBackend;
|
|
4375
6011
|
const multiBot = getAllBots().length > 1;
|
|
4376
6012
|
const cliIdFile = join(config.session.dataDir, backendType === 'tmux' ? 'last-cli-id' : `last-cli-id-${backendType}`);
|
|
4377
6013
|
let lastCliId;
|
|
@@ -4380,30 +6016,161 @@ function cleanupPersistentBackendSessions(backendType, activeSessions_) {
|
|
|
4380
6016
|
}
|
|
4381
6017
|
catch { /* first run */ }
|
|
4382
6018
|
const currentCliId = config.daemon.cliId;
|
|
6019
|
+
const belongsToBackend = (session) => session.backendType === backendType ||
|
|
6020
|
+
(session.backendType === undefined && backendType === 'tmux');
|
|
6021
|
+
const activeNames = new Set([...activeSessions_, ...runtimeSessionRows]
|
|
6022
|
+
.filter(belongsToBackend)
|
|
6023
|
+
.map(s => backend.sessionName(s.sessionId)));
|
|
6024
|
+
const runtimeNames = new Set(runtimeSessionRows.filter(belongsToBackend).map(s => backend.sessionName(s.sessionId)));
|
|
6025
|
+
const ownedSessions = [
|
|
6026
|
+
...storedSessions.filter(belongsToBackend),
|
|
6027
|
+
...activeSessions_.filter(belongsToBackend),
|
|
6028
|
+
];
|
|
6029
|
+
const ownedIdsByName = new Map();
|
|
6030
|
+
for (const session of ownedSessions) {
|
|
6031
|
+
const name = backend.sessionName(session.sessionId);
|
|
6032
|
+
const ids = ownedIdsByName.get(name) ?? new Set();
|
|
6033
|
+
ids.add(session.sessionId);
|
|
6034
|
+
ownedIdsByName.set(name, ids);
|
|
6035
|
+
}
|
|
6036
|
+
const ownedNames = new Set(ownedIdsByName.keys());
|
|
6037
|
+
const exactHerdrTarget = (session) => {
|
|
6038
|
+
const target = session.persistentBackendTarget;
|
|
6039
|
+
if (target?.backendType !== 'herdr')
|
|
6040
|
+
return undefined;
|
|
6041
|
+
const sessionName = target.sessionName.trim();
|
|
6042
|
+
const agentName = target.agentName?.trim();
|
|
6043
|
+
if (!sessionName || !agentName)
|
|
6044
|
+
return undefined;
|
|
6045
|
+
return { sessionName, agentName };
|
|
6046
|
+
};
|
|
6047
|
+
const exactHerdrTargetKey = (target) => JSON.stringify([target.sessionName, target.agentName]);
|
|
6048
|
+
const managedExactHerdrTargets = new Map();
|
|
6049
|
+
const adoptedExactHerdrTargetKeys = new Set();
|
|
6050
|
+
const activeExactHerdrTargetKeys = new Set();
|
|
6051
|
+
const runtimeExactHerdrTargetKeys = new Set();
|
|
6052
|
+
if (backendType === 'herdr') {
|
|
6053
|
+
// An adopted target is user-owned even if a corrupt/legacy managed row
|
|
6054
|
+
// happens to point at the same host+agent. Treat any such row as a veto.
|
|
6055
|
+
for (const session of [...storedSessions, ...activeSessions_, ...runtimeSessionRows]) {
|
|
6056
|
+
const target = exactHerdrTarget(session);
|
|
6057
|
+
if (!target)
|
|
6058
|
+
continue;
|
|
6059
|
+
const key = exactHerdrTargetKey(target);
|
|
6060
|
+
if (session.adoptedFrom)
|
|
6061
|
+
adoptedExactHerdrTargetKeys.add(key);
|
|
6062
|
+
}
|
|
6063
|
+
// The restore snapshot is the liveness authority for this sweep. Protect
|
|
6064
|
+
// every exact active target, including adopted targets and duplicate rows.
|
|
6065
|
+
for (const session of activeSessions_) {
|
|
6066
|
+
const target = exactHerdrTarget(session);
|
|
6067
|
+
if (target)
|
|
6068
|
+
activeExactHerdrTargetKeys.add(exactHerdrTargetKey(target));
|
|
6069
|
+
}
|
|
6070
|
+
// Dispatcher/IPC are already live while startup restore runs. A newly
|
|
6071
|
+
// registered runtime row belongs to the current daemon/current CLI and is
|
|
6072
|
+
// authoritative over the stale store snapshot in both ordinary and global
|
|
6073
|
+
// CLI-change cleanup.
|
|
6074
|
+
for (const session of runtimeSessionRows) {
|
|
6075
|
+
const target = exactHerdrTarget(session);
|
|
6076
|
+
if (target)
|
|
6077
|
+
runtimeExactHerdrTargetKeys.add(exactHerdrTargetKey(target));
|
|
6078
|
+
}
|
|
6079
|
+
// Only an explicit persisted agent target proves ownership inside a shared
|
|
6080
|
+
// host. Never derive an agent from the session id, and never treat a
|
|
6081
|
+
// host-only target as permission to stop the surrounding Herdr session.
|
|
6082
|
+
for (const session of ownedSessions) {
|
|
6083
|
+
if (session.adoptedFrom || session.queued)
|
|
6084
|
+
continue;
|
|
6085
|
+
const target = exactHerdrTarget(session);
|
|
6086
|
+
if (!target)
|
|
6087
|
+
continue;
|
|
6088
|
+
// Herdr's host is machine-wide. A historical short name
|
|
6089
|
+
// (`botmux-<sid8>`) is not enough authority for opportunistic startup
|
|
6090
|
+
// deletion: another daemon/data root can own the colliding live pane.
|
|
6091
|
+
// Require both the strong 128-bit-era format and the deterministic
|
|
6092
|
+
// binding to this complete session id. Legacy targets remain fully
|
|
6093
|
+
// supported for reattach and explicit user-requested close.
|
|
6094
|
+
if (!isStrongManagedHerdrAgentName(target.agentName)
|
|
6095
|
+
|| target.agentName !== managedHerdrAgentName(session.sessionId, config.session.dataDir))
|
|
6096
|
+
continue;
|
|
6097
|
+
managedExactHerdrTargets.set(exactHerdrTargetKey(target), target);
|
|
6098
|
+
}
|
|
6099
|
+
}
|
|
6100
|
+
const killManagedExactHerdrTargets = (preserveActive) => {
|
|
6101
|
+
if (backendType !== 'herdr')
|
|
6102
|
+
return;
|
|
6103
|
+
const agentNamesBySession = new Map();
|
|
6104
|
+
for (const [key, target] of managedExactHerdrTargets) {
|
|
6105
|
+
if (adoptedExactHerdrTargetKeys.has(key))
|
|
6106
|
+
continue;
|
|
6107
|
+
if (runtimeExactHerdrTargetKeys.has(key))
|
|
6108
|
+
continue;
|
|
6109
|
+
if (preserveActive && activeExactHerdrTargetKeys.has(key))
|
|
6110
|
+
continue;
|
|
6111
|
+
const agentNames = agentNamesBySession.get(target.sessionName) ?? new Set();
|
|
6112
|
+
agentNames.add(target.agentName);
|
|
6113
|
+
agentNamesBySession.set(target.sessionName, agentNames);
|
|
6114
|
+
}
|
|
6115
|
+
// Batch by host: one `agent list` per distinct positively-owned host, then
|
|
6116
|
+
// close only matching live panes. Historical rows that are already gone
|
|
6117
|
+
// therefore add no subprocess calls, and duplicate targets collapse.
|
|
6118
|
+
for (const [sessionName, agentNames] of agentNamesBySession) {
|
|
6119
|
+
logger.info(`Killing orphaned herdr agent(s) in ${sessionName}: ${[...agentNames].join(', ')}`);
|
|
6120
|
+
HerdrBackend.killAgents(sessionName, agentNames);
|
|
6121
|
+
}
|
|
6122
|
+
};
|
|
6123
|
+
const killOwnedBackendSession = (name, exactSessionId) => {
|
|
6124
|
+
if (backendType !== 'zmx') {
|
|
6125
|
+
backend.killSession(name);
|
|
6126
|
+
return;
|
|
6127
|
+
}
|
|
6128
|
+
const candidates = exactSessionId
|
|
6129
|
+
? new Set([exactSessionId])
|
|
6130
|
+
: ownedIdsByName.get(name);
|
|
6131
|
+
if (!candidates || candidates.size !== 1) {
|
|
6132
|
+
logger.warn(`Refusing ambiguous name-only ZMX cleanup for ${name}; ` +
|
|
6133
|
+
`matching stored session ids=${candidates?.size ?? 0}`);
|
|
6134
|
+
return;
|
|
6135
|
+
}
|
|
6136
|
+
try {
|
|
6137
|
+
ZmxBackend.killManagedSession(name, [...candidates][0]);
|
|
6138
|
+
}
|
|
6139
|
+
catch (err) {
|
|
6140
|
+
logger.warn(`Refusing unsafe ZMX cleanup for ${name}: ${err instanceof Error ? err.message : String(err)}`);
|
|
6141
|
+
}
|
|
6142
|
+
};
|
|
4383
6143
|
if (!multiBot && lastCliId && lastCliId !== currentCliId) {
|
|
4384
6144
|
logger.info(`CLI_ID changed (${lastCliId} → ${currentCliId}), killing all ${backendType} sessions`);
|
|
4385
6145
|
// Legacy per-topic hosts are still enumerable by bmx-* name.
|
|
4386
6146
|
for (const name of backend.listBotmuxSessions()) {
|
|
4387
|
-
|
|
4388
|
-
|
|
4389
|
-
|
|
4390
|
-
|
|
4391
|
-
|
|
4392
|
-
|
|
4393
|
-
|
|
4394
|
-
|
|
6147
|
+
// A dispatcher-created runtime session already runs the current CLI.
|
|
6148
|
+
// Never let the stale last-cli marker tear it down during restore.
|
|
6149
|
+
if (runtimeNames.has(name))
|
|
6150
|
+
continue;
|
|
6151
|
+
// ZMX_DIR is a user-wide namespace and bmx-* is deterministic, not an
|
|
6152
|
+
// ownership credential. Another checkout/data root can legitimately own
|
|
6153
|
+
// a bmx-* daemon, so never kill a name absent from this bot's store/map.
|
|
6154
|
+
if (backendType === 'zmx' && !ownedNames.has(name))
|
|
6155
|
+
continue;
|
|
6156
|
+
killOwnedBackendSession(name);
|
|
4395
6157
|
}
|
|
6158
|
+
// Machine-wide Herdr agents are not separate bmx-* sessions. Include
|
|
6159
|
+
// persisted inactive rows as well as the active restore snapshot so a CLI
|
|
6160
|
+
// switch cannot leave an old executable behind.
|
|
6161
|
+
killManagedExactHerdrTargets(false);
|
|
4396
6162
|
}
|
|
4397
6163
|
else {
|
|
4398
|
-
const activeNames = new Set(activeSessions_.map(s => backend.sessionName(s.sessionId)));
|
|
4399
|
-
const ownedNames = new Set(sessionStore.listSessions().map(s => backend.sessionName(s.sessionId)));
|
|
4400
6164
|
for (const name of backend.listBotmuxSessions()) {
|
|
4401
6165
|
if (ownedNames.has(name) && !activeNames.has(name)) {
|
|
4402
6166
|
logger.info(`Killing orphaned ${backendType} session: ${name}`);
|
|
4403
|
-
|
|
6167
|
+
killOwnedBackendSession(name);
|
|
4404
6168
|
}
|
|
4405
6169
|
}
|
|
6170
|
+
killManagedExactHerdrTargets(true);
|
|
4406
6171
|
for (const session of activeSessions_) {
|
|
6172
|
+
if (!belongsToBackend(session))
|
|
6173
|
+
continue;
|
|
4407
6174
|
const sessionCliId = session.cliId;
|
|
4408
6175
|
if (!sessionCliId || !session.larkAppId)
|
|
4409
6176
|
continue;
|
|
@@ -4416,11 +6183,31 @@ function cleanupPersistentBackendSessions(backendType, activeSessions_) {
|
|
|
4416
6183
|
}
|
|
4417
6184
|
if (botCliId && sessionCliId !== botCliId) {
|
|
4418
6185
|
const target = resolvePersistentBackendTarget(backendType, session.sessionId, session.persistentBackendTarget);
|
|
6186
|
+
const runtimeOwnsTarget = target.backendType === 'herdr' && target.agentName
|
|
6187
|
+
? runtimeExactHerdrTargetKeys.has(exactHerdrTargetKey({
|
|
6188
|
+
sessionName: target.sessionName,
|
|
6189
|
+
agentName: target.agentName,
|
|
6190
|
+
}))
|
|
6191
|
+
: runtimeNames.has(target.sessionName);
|
|
6192
|
+
if (runtimeOwnsTarget)
|
|
6193
|
+
continue;
|
|
4419
6194
|
const label = target.backendType === 'herdr' && target.agentName
|
|
4420
6195
|
? `${target.sessionName}/${target.agentName}`
|
|
4421
6196
|
: target.sessionName;
|
|
4422
6197
|
logger.info(`CLI mismatch for ${session.sessionId.substring(0, 8)} (session=${sessionCliId}, bot=${botCliId}), killing ${backendType} ${label}`);
|
|
4423
|
-
|
|
6198
|
+
try {
|
|
6199
|
+
killPersistentBackendTarget(target, session.sessionId);
|
|
6200
|
+
}
|
|
6201
|
+
catch (err) {
|
|
6202
|
+
// This sweep runs before restore has registered any persisted rows.
|
|
6203
|
+
// One inconclusive identity-verified teardown (notably ZMX) must not
|
|
6204
|
+
// abort restoration for every unrelated session. Keep this row active
|
|
6205
|
+
// and let closeActiveSessionIfCliMismatch() retry it through the
|
|
6206
|
+
// per-session fail-closed path later in the restore pass.
|
|
6207
|
+
logger.warn(`CLI mismatch cleanup deferred for ${session.sessionId.substring(0, 8)} `
|
|
6208
|
+
+ `(${backendType} ${label}): ${err instanceof Error ? err.message : String(err)}`);
|
|
6209
|
+
continue;
|
|
6210
|
+
}
|
|
4424
6211
|
}
|
|
4425
6212
|
}
|
|
4426
6213
|
}
|