botmux 3.10.0 → 3.11.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/herdr-backend.d.ts +4 -3
- package/dist/adapters/backend/herdr-backend.d.ts.map +1 -1
- package/dist/adapters/backend/herdr-backend.js +11 -8
- package/dist/adapters/backend/herdr-backend.js.map +1 -1
- package/dist/adapters/backend/pty-backend.d.ts +1 -1
- package/dist/adapters/backend/pty-backend.d.ts.map +1 -1
- package/dist/adapters/backend/pty-backend.js +4 -1
- package/dist/adapters/backend/pty-backend.js.map +1 -1
- package/dist/adapters/backend/riff-backend.d.ts +29 -5
- package/dist/adapters/backend/riff-backend.d.ts.map +1 -1
- package/dist/adapters/backend/riff-backend.js +302 -31
- package/dist/adapters/backend/riff-backend.js.map +1 -1
- package/dist/adapters/backend/sandbox.d.ts +6 -0
- package/dist/adapters/backend/sandbox.d.ts.map +1 -1
- package/dist/adapters/backend/sandbox.js +7 -0
- package/dist/adapters/backend/sandbox.js.map +1 -1
- package/dist/adapters/backend/tmux-backend.d.ts +1 -1
- package/dist/adapters/backend/tmux-backend.d.ts.map +1 -1
- package/dist/adapters/backend/tmux-backend.js +4 -1
- package/dist/adapters/backend/tmux-backend.js.map +1 -1
- package/dist/adapters/backend/tmux-pipe-backend.d.ts +1 -1
- package/dist/adapters/backend/tmux-pipe-backend.d.ts.map +1 -1
- package/dist/adapters/backend/tmux-pipe-backend.js +1 -1
- package/dist/adapters/backend/tmux-pipe-backend.js.map +1 -1
- package/dist/adapters/backend/types.d.ts +37 -3
- package/dist/adapters/backend/types.d.ts.map +1 -1
- package/dist/adapters/backend/types.js.map +1 -1
- package/dist/adapters/backend/zellij-backend.d.ts +3 -3
- package/dist/adapters/backend/zellij-backend.d.ts.map +1 -1
- package/dist/adapters/backend/zellij-backend.js +9 -3
- package/dist/adapters/backend/zellij-backend.js.map +1 -1
- package/dist/adapters/backend/zellij-observe-backend.d.ts +3 -3
- package/dist/adapters/backend/zellij-observe-backend.d.ts.map +1 -1
- package/dist/adapters/backend/zellij-observe-backend.js +12 -7
- package/dist/adapters/backend/zellij-observe-backend.js.map +1 -1
- package/dist/adapters/cli/codex-app.d.ts.map +1 -1
- package/dist/adapters/cli/codex-app.js +11 -2
- package/dist/adapters/cli/codex-app.js.map +1 -1
- package/dist/adapters/cli/read-isolation.d.ts +35 -2
- package/dist/adapters/cli/read-isolation.d.ts.map +1 -1
- package/dist/adapters/cli/read-isolation.js +88 -7
- package/dist/adapters/cli/read-isolation.js.map +1 -1
- package/dist/adapters/cli/runner-input.d.ts +15 -14
- package/dist/adapters/cli/runner-input.d.ts.map +1 -1
- package/dist/adapters/cli/runner-input.js +64 -26
- package/dist/adapters/cli/runner-input.js.map +1 -1
- package/dist/adapters/cli/types.d.ts +20 -5
- package/dist/adapters/cli/types.d.ts.map +1 -1
- package/dist/bot-registry.d.ts +24 -0
- package/dist/bot-registry.d.ts.map +1 -1
- package/dist/bot-registry.js +110 -5
- package/dist/bot-registry.js.map +1 -1
- package/dist/cli/fleet-shutdown.d.ts +47 -0
- package/dist/cli/fleet-shutdown.d.ts.map +1 -0
- package/dist/cli/fleet-shutdown.js +387 -0
- package/dist/cli/fleet-shutdown.js.map +1 -0
- package/dist/cli/pm2-descriptor-guard.d.ts +19 -0
- package/dist/cli/pm2-descriptor-guard.d.ts.map +1 -0
- package/dist/cli/pm2-descriptor-guard.js +122 -0
- package/dist/cli/pm2-descriptor-guard.js.map +1 -0
- package/dist/cli/pm2-exact-start.d.ts +16 -0
- package/dist/cli/pm2-exact-start.d.ts.map +1 -0
- package/dist/cli/pm2-exact-start.js +63 -0
- package/dist/cli/pm2-exact-start.js.map +1 -0
- package/dist/cli/pm2-god-admission.d.ts +8 -0
- package/dist/cli/pm2-god-admission.d.ts.map +1 -0
- package/dist/cli/pm2-god-admission.js +25 -0
- package/dist/cli/pm2-god-admission.js.map +1 -0
- package/dist/cli/pm2-jlist.d.ts +13 -0
- package/dist/cli/pm2-jlist.d.ts.map +1 -0
- package/dist/cli/pm2-jlist.js +108 -0
- package/dist/cli/pm2-jlist.js.map +1 -0
- package/dist/cli/pm2-preflight.d.ts +10 -0
- package/dist/cli/pm2-preflight.d.ts.map +1 -0
- package/dist/cli/pm2-preflight.js +23 -0
- package/dist/cli/pm2-preflight.js.map +1 -0
- package/dist/cli/pm2-shutdown-capability.d.ts +27 -0
- package/dist/cli/pm2-shutdown-capability.d.ts.map +1 -0
- package/dist/cli/pm2-shutdown-capability.js +161 -0
- package/dist/cli/pm2-shutdown-capability.js.map +1 -0
- package/dist/cli/pm2-start-transaction.d.ts +55 -0
- package/dist/cli/pm2-start-transaction.d.ts.map +1 -0
- package/dist/cli/pm2-start-transaction.js +220 -0
- package/dist/cli/pm2-start-transaction.js.map +1 -0
- package/dist/cli/send-dispatch.d.ts +12 -0
- package/dist/cli/send-dispatch.d.ts.map +1 -1
- package/dist/cli/send-dispatch.js +31 -4
- package/dist/cli/send-dispatch.js.map +1 -1
- package/dist/cli/session-list-liveness.d.ts +1 -0
- package/dist/cli/session-list-liveness.d.ts.map +1 -1
- package/dist/cli/session-list-liveness.js +6 -0
- package/dist/cli/session-list-liveness.js.map +1 -1
- package/dist/cli/session-picker-layout.d.ts +26 -0
- package/dist/cli/session-picker-layout.d.ts.map +1 -0
- package/dist/cli/session-picker-layout.js +123 -0
- package/dist/cli/session-picker-layout.js.map +1 -0
- package/dist/cli/session-picker-viewport.d.ts +49 -0
- package/dist/cli/session-picker-viewport.d.ts.map +1 -0
- package/dist/cli/session-picker-viewport.js +46 -0
- package/dist/cli/session-picker-viewport.js.map +1 -0
- package/dist/cli/supervisor-shutdown-client.d.ts +31 -0
- package/dist/cli/supervisor-shutdown-client.d.ts.map +1 -0
- package/dist/cli/supervisor-shutdown-client.js +146 -0
- package/dist/cli/supervisor-shutdown-client.js.map +1 -0
- package/dist/cli/terminal-width.d.ts +48 -0
- package/dist/cli/terminal-width.d.ts.map +1 -0
- package/dist/cli/terminal-width.js +119 -0
- package/dist/cli/terminal-width.js.map +1 -0
- package/dist/cli/vc-agent.d.ts.map +1 -1
- package/dist/cli/vc-agent.js +1 -1
- package/dist/cli/vc-agent.js.map +1 -1
- package/dist/cli.d.ts.map +1 -1
- package/dist/cli.js +2269 -716
- package/dist/cli.js.map +1 -1
- package/dist/codex-app-runner.js +1630 -125
- package/dist/codex-app-runner.js.map +1 -1
- package/dist/core/ask-api.d.ts +8 -1
- package/dist/core/ask-api.d.ts.map +1 -1
- package/dist/core/ask-api.js +18 -0
- package/dist/core/ask-api.js.map +1 -1
- package/dist/core/ask-broker.d.ts +20 -0
- package/dist/core/ask-broker.d.ts.map +1 -1
- package/dist/core/ask-broker.js +467 -47
- package/dist/core/ask-broker.js.map +1 -1
- package/dist/core/ask-persist-store.d.ts +126 -0
- package/dist/core/ask-persist-store.d.ts.map +1 -0
- package/dist/core/ask-persist-store.js +188 -0
- package/dist/core/ask-persist-store.js.map +1 -0
- package/dist/core/ask-types.d.ts +80 -0
- package/dist/core/ask-types.d.ts.map +1 -1
- package/dist/core/ask-types.js +62 -0
- package/dist/core/ask-types.js.map +1 -1
- package/dist/core/cli-session-id-publisher.d.ts +20 -0
- package/dist/core/cli-session-id-publisher.d.ts.map +1 -0
- package/dist/core/cli-session-id-publisher.js +21 -0
- package/dist/core/cli-session-id-publisher.js.map +1 -0
- package/dist/core/command-handler.d.ts.map +1 -1
- package/dist/core/command-handler.js +427 -255
- package/dist/core/command-handler.js.map +1 -1
- package/dist/core/cost-calculator.d.ts +13 -0
- package/dist/core/cost-calculator.d.ts.map +1 -1
- package/dist/core/cost-calculator.js +385 -26
- package/dist/core/cost-calculator.js.map +1 -1
- package/dist/core/dashboard-ipc-server.d.ts +9 -0
- package/dist/core/dashboard-ipc-server.d.ts.map +1 -1
- package/dist/core/dashboard-ipc-server.js +842 -288
- package/dist/core/dashboard-ipc-server.js.map +1 -1
- package/dist/core/explicit-session-backing-cleanup.d.ts +49 -0
- package/dist/core/explicit-session-backing-cleanup.d.ts.map +1 -0
- package/dist/core/explicit-session-backing-cleanup.js +76 -0
- package/dist/core/explicit-session-backing-cleanup.js.map +1 -0
- package/dist/core/idle-worker-sweeper.d.ts +20 -0
- package/dist/core/idle-worker-sweeper.d.ts.map +1 -1
- package/dist/core/idle-worker-sweeper.js +18 -0
- package/dist/core/idle-worker-sweeper.js.map +1 -1
- package/dist/core/inflight-input-tracker.d.ts +10 -0
- package/dist/core/inflight-input-tracker.d.ts.map +1 -1
- package/dist/core/inflight-input-tracker.js +7 -0
- package/dist/core/inflight-input-tracker.js.map +1 -1
- package/dist/core/managed-origin-attestation.d.ts +51 -0
- package/dist/core/managed-origin-attestation.d.ts.map +1 -0
- package/dist/core/managed-origin-attestation.js +208 -0
- package/dist/core/managed-origin-attestation.js.map +1 -0
- package/dist/core/managed-origin-capability.d.ts +62 -3
- package/dist/core/managed-origin-capability.d.ts.map +1 -1
- package/dist/core/managed-origin-capability.js +419 -9
- package/dist/core/managed-origin-capability.js.map +1 -1
- package/dist/core/pending-repo-journal.d.ts +6 -0
- package/dist/core/pending-repo-journal.d.ts.map +1 -0
- package/dist/core/pending-repo-journal.js +85 -0
- package/dist/core/pending-repo-journal.js.map +1 -0
- package/dist/core/persistent-backend.d.ts +6 -1
- package/dist/core/persistent-backend.d.ts.map +1 -1
- package/dist/core/persistent-backend.js +12 -6
- package/dist/core/persistent-backend.js.map +1 -1
- package/dist/core/process-start-identity.d.ts +3 -0
- package/dist/core/process-start-identity.d.ts.map +1 -0
- package/dist/core/process-start-identity.js +62 -0
- package/dist/core/process-start-identity.js.map +1 -0
- package/dist/core/raw-command-writer.d.ts +34 -0
- package/dist/core/raw-command-writer.d.ts.map +1 -0
- package/dist/core/raw-command-writer.js +48 -0
- package/dist/core/raw-command-writer.js.map +1 -0
- package/dist/core/reply-target.d.ts +85 -22
- package/dist/core/reply-target.d.ts.map +1 -1
- package/dist/core/reply-target.js +267 -32
- package/dist/core/reply-target.js.map +1 -1
- package/dist/core/resource-monitor/runtime.d.ts.map +1 -1
- package/dist/core/resource-monitor/runtime.js +4 -1
- package/dist/core/resource-monitor/runtime.js.map +1 -1
- package/dist/core/restart-report.d.ts +6 -1
- package/dist/core/restart-report.d.ts.map +1 -1
- package/dist/core/restart-report.js +22 -4
- package/dist/core/restart-report.js.map +1 -1
- package/dist/core/riff-shutdown-detach.d.ts +164 -0
- package/dist/core/riff-shutdown-detach.d.ts.map +1 -0
- package/dist/core/riff-shutdown-detach.js +787 -0
- package/dist/core/riff-shutdown-detach.js.map +1 -0
- package/dist/core/riff-worker-shutdown-readiness.d.ts +17 -0
- package/dist/core/riff-worker-shutdown-readiness.d.ts.map +1 -0
- package/dist/core/riff-worker-shutdown-readiness.js +21 -0
- package/dist/core/riff-worker-shutdown-readiness.js.map +1 -0
- package/dist/core/session-manager.d.ts +6 -6
- package/dist/core/session-manager.d.ts.map +1 -1
- package/dist/core/session-manager.js +1227 -594
- package/dist/core/session-manager.js.map +1 -1
- package/dist/core/session-marker.d.ts +1 -1
- package/dist/core/session-marker.d.ts.map +1 -1
- package/dist/core/session-marker.js +2 -2
- package/dist/core/session-marker.js.map +1 -1
- package/dist/core/session-mutation-guard.d.ts +17 -0
- package/dist/core/session-mutation-guard.d.ts.map +1 -0
- package/dist/core/session-mutation-guard.js +33 -0
- package/dist/core/session-mutation-guard.js.map +1 -0
- package/dist/core/shutdown-budgets.d.ts +27 -0
- package/dist/core/shutdown-budgets.d.ts.map +1 -0
- package/dist/core/shutdown-budgets.js +47 -0
- package/dist/core/shutdown-budgets.js.map +1 -0
- package/dist/core/supervisor-shutdown-ipc.d.ts +10 -0
- package/dist/core/supervisor-shutdown-ipc.d.ts.map +1 -0
- package/dist/core/supervisor-shutdown-ipc.js +10 -0
- package/dist/core/supervisor-shutdown-ipc.js.map +1 -0
- package/dist/core/supervisor-shutdown-protocol.d.ts +16 -0
- package/dist/core/supervisor-shutdown-protocol.d.ts.map +1 -0
- package/dist/core/supervisor-shutdown-protocol.js +16 -0
- package/dist/core/supervisor-shutdown-protocol.js.map +1 -0
- package/dist/core/trigger-final-suppression.d.ts +4 -0
- package/dist/core/trigger-final-suppression.d.ts.map +1 -1
- package/dist/core/trigger-final-suppression.js +6 -11
- package/dist/core/trigger-final-suppression.js.map +1 -1
- package/dist/core/trigger-session.d.ts +70 -0
- package/dist/core/trigger-session.d.ts.map +1 -1
- package/dist/core/trigger-session.js +923 -145
- package/dist/core/trigger-session.js.map +1 -1
- package/dist/core/types.d.ts +94 -1
- package/dist/core/types.d.ts.map +1 -1
- package/dist/core/types.js +9 -0
- package/dist/core/types.js.map +1 -1
- package/dist/core/worker-pool.d.ts +194 -13
- package/dist/core/worker-pool.d.ts.map +1 -1
- package/dist/core/worker-pool.js +2597 -547
- package/dist/core/worker-pool.js.map +1 -1
- package/dist/daemon.d.ts +76 -2
- package/dist/daemon.d.ts.map +1 -1
- package/dist/daemon.js +2255 -773
- package/dist/daemon.js.map +1 -1
- package/dist/dashboard/bot-payload.d.ts +20 -0
- package/dist/dashboard/bot-payload.d.ts.map +1 -1
- package/dist/dashboard/bot-payload.js +24 -0
- package/dist/dashboard/bot-payload.js.map +1 -1
- package/dist/dashboard/feed-groups.d.ts +17 -0
- package/dist/dashboard/feed-groups.d.ts.map +1 -0
- package/dist/dashboard/feed-groups.js +75 -0
- package/dist/dashboard/feed-groups.js.map +1 -0
- package/dist/dashboard/session-card-model.d.ts +1 -1
- package/dist/dashboard/session-card-model.d.ts.map +1 -1
- package/dist/dashboard/session-card-model.js +8 -5
- package/dist/dashboard/session-card-model.js.map +1 -1
- package/dist/dashboard/web/bot-defaults-page.d.ts +4 -0
- package/dist/dashboard/web/bot-defaults-page.d.ts.map +1 -1
- package/dist/dashboard/web/bot-defaults-page.js +139 -46
- package/dist/dashboard/web/bot-defaults-page.js.map +1 -1
- package/dist/dashboard/web/bot-defaults.d.ts +4 -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/connectors-page.d.ts.map +1 -1
- package/dist/dashboard/web/connectors-page.js +40 -3
- package/dist/dashboard/web/connectors-page.js.map +1 -1
- package/dist/dashboard/web/groups-page.d.ts.map +1 -1
- package/dist/dashboard/web/groups-page.js +95 -2
- 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 +86 -40
- package/dist/dashboard/web/i18n.js.map +1 -1
- package/dist/dashboard/web/kanban-model.js +1 -1
- package/dist/dashboard/web/kanban-model.js.map +1 -1
- package/dist/dashboard/web/overview-page.d.ts.map +1 -1
- package/dist/dashboard/web/overview-page.js +3 -2
- package/dist/dashboard/web/overview-page.js.map +1 -1
- package/dist/dashboard/web/overview.d.ts +3 -0
- package/dist/dashboard/web/overview.d.ts.map +1 -1
- package/dist/dashboard/web/overview.js +2 -0
- package/dist/dashboard/web/overview.js.map +1 -1
- package/dist/dashboard/web/sessions-kanban.d.ts.map +1 -1
- package/dist/dashboard/web/sessions-kanban.js +2 -0
- package/dist/dashboard/web/sessions-kanban.js.map +1 -1
- package/dist/dashboard/web/sessions-page.d.ts.map +1 -1
- package/dist/dashboard/web/sessions-page.js +103 -2
- package/dist/dashboard/web/sessions-page.js.map +1 -1
- package/dist/dashboard/web/sessions.d.ts.map +1 -1
- package/dist/dashboard/web/sessions.js +3 -2
- package/dist/dashboard/web/sessions.js.map +1 -1
- package/dist/dashboard/web/ui.d.ts +8 -0
- package/dist/dashboard/web/ui.d.ts.map +1 -1
- package/dist/dashboard/web/ui.js +15 -0
- package/dist/dashboard/web/ui.js.map +1 -1
- package/dist/dashboard-web/app.js +1 -1
- package/dist/dashboard-web/chunks/bot-defaults-page-GED4FEMS.js +10 -0
- package/dist/dashboard-web/chunks/chunk-6J5LS5AU.js +51 -0
- package/dist/dashboard-web/chunks/{chunk-ZU3UUKAR.js → chunk-HK7NIBJG.js} +1 -1
- package/dist/dashboard-web/chunks/{chunk-3UE7S77S.js → chunk-KRHWZEXU.js} +1 -1
- package/dist/dashboard-web/chunks/chunk-VBXJOJFH.js +1 -0
- package/dist/dashboard-web/chunks/connectors-page-EELM66G3.js +1 -0
- package/dist/dashboard-web/chunks/groups-page-WKUV6E42.js +8 -0
- package/dist/dashboard-web/chunks/{insights-page-T2ZX2RWL.js → insights-page-54UBPPOC.js} +1 -1
- package/dist/dashboard-web/chunks/{monitor-room-YNVBAGR6.js → monitor-room-PORHVKRH.js} +1 -1
- package/dist/dashboard-web/chunks/{monitoring-page-LHM55YWN.js → monitoring-page-XGA7AXNL.js} +1 -1
- package/dist/dashboard-web/chunks/{office-page-ATXVP7PW.js → office-page-AO3AFG45.js} +1 -1
- package/dist/dashboard-web/chunks/overview-page-F5BUTYR6.js +1 -0
- package/dist/dashboard-web/chunks/{roles-page-56VWM6CD.js → roles-page-M5GIASJU.js} +1 -1
- package/dist/dashboard-web/chunks/{schedules-page-S5K6U4EO.js → schedules-page-O232WQRR.js} +1 -1
- package/dist/dashboard-web/chunks/sessions-page-YFRPSTIL.js +6 -0
- package/dist/dashboard-web/chunks/{settings-page-QUZDQARR.js → settings-page-OJAZLZQT.js} +1 -1
- package/dist/dashboard-web/chunks/{skills-page-IFY7AEND.js → skills-page-EFZQPWV4.js} +1 -1
- package/dist/dashboard-web/chunks/{team-federation-page-C7FJQPFZ.js → team-federation-page-ZJVAP7HW.js} +1 -1
- package/dist/dashboard-web/chunks/{v3-page-2LMF3WWR.js → v3-page-GYWTRGO2.js} +1 -1
- package/dist/dashboard-web/chunks/{whiteboards-page-3XC6QRMY.js → whiteboards-page-MSDAG6P7.js} +1 -1
- package/dist/dashboard-web/style.css +255 -0
- package/dist/dashboard.js +172 -7
- package/dist/dashboard.js.map +1 -1
- package/dist/desktop/renderer/app.js +21 -0
- package/dist/desktop/renderer/app.js.map +1 -1
- package/dist/i18n/en.d.ts.map +1 -1
- package/dist/i18n/en.js +35 -9
- package/dist/i18n/en.js.map +1 -1
- package/dist/i18n/zh.d.ts.map +1 -1
- package/dist/i18n/zh.js +35 -9
- package/dist/i18n/zh.js.map +1 -1
- package/dist/im/lark/ask-card.d.ts +23 -0
- package/dist/im/lark/ask-card.d.ts.map +1 -1
- package/dist/im/lark/ask-card.js +94 -4
- package/dist/im/lark/ask-card.js.map +1 -1
- package/dist/im/lark/card-builder.d.ts +6 -1
- package/dist/im/lark/card-builder.d.ts.map +1 -1
- package/dist/im/lark/card-builder.js +112 -15
- package/dist/im/lark/card-builder.js.map +1 -1
- package/dist/im/lark/card-handler.d.ts +1 -1
- package/dist/im/lark/card-handler.d.ts.map +1 -1
- package/dist/im/lark/card-handler.js +496 -310
- package/dist/im/lark/card-handler.js.map +1 -1
- package/dist/im/lark/client.d.ts +7 -0
- package/dist/im/lark/client.d.ts.map +1 -1
- package/dist/im/lark/client.js +39 -26
- package/dist/im/lark/client.js.map +1 -1
- package/dist/im/lark/doc-comment.d.ts +7 -4
- package/dist/im/lark/doc-comment.d.ts.map +1 -1
- package/dist/im/lark/doc-comment.js +19 -6
- package/dist/im/lark/doc-comment.js.map +1 -1
- package/dist/im/lark/event-dispatcher.d.ts +16 -0
- package/dist/im/lark/event-dispatcher.d.ts.map +1 -1
- package/dist/im/lark/event-dispatcher.js +88 -23
- package/dist/im/lark/event-dispatcher.js.map +1 -1
- package/dist/im/lark/forward-followup-content.d.ts.map +1 -1
- package/dist/im/lark/forward-followup-content.js +9 -1
- package/dist/im/lark/forward-followup-content.js.map +1 -1
- package/dist/im/lark/grant-command.d.ts.map +1 -1
- package/dist/im/lark/grant-command.js +7 -4
- package/dist/im/lark/grant-command.js.map +1 -1
- package/dist/im/lark/md-card.d.ts +11 -0
- package/dist/im/lark/md-card.d.ts.map +1 -1
- package/dist/im/lark/md-card.js +46 -0
- package/dist/im/lark/md-card.js.map +1 -1
- package/dist/im/lark/message-parser.d.ts +23 -2
- package/dist/im/lark/message-parser.d.ts.map +1 -1
- package/dist/im/lark/message-parser.js +58 -3
- package/dist/im/lark/message-parser.js.map +1 -1
- package/dist/im/lark/overview-card.d.ts.map +1 -1
- package/dist/im/lark/overview-card.js +2 -1
- package/dist/im/lark/overview-card.js.map +1 -1
- package/dist/services/async-trigger-state.d.ts +6 -1
- package/dist/services/async-trigger-state.d.ts.map +1 -1
- package/dist/services/async-trigger-state.js +21 -0
- package/dist/services/async-trigger-state.js.map +1 -1
- package/dist/services/async-trigger-store.d.ts +23 -1
- package/dist/services/async-trigger-store.d.ts.map +1 -1
- package/dist/services/async-trigger-store.js +96 -13
- package/dist/services/async-trigger-store.js.map +1 -1
- package/dist/services/bridge-fallback-gate.d.ts +40 -0
- package/dist/services/bridge-fallback-gate.d.ts.map +1 -1
- package/dist/services/bridge-fallback-gate.js +124 -24
- package/dist/services/bridge-fallback-gate.js.map +1 -1
- package/dist/services/codex-app-runner-protocol.d.ts +8 -0
- package/dist/services/codex-app-runner-protocol.d.ts.map +1 -1
- package/dist/services/codex-app-runner-protocol.js +9 -2
- package/dist/services/codex-app-runner-protocol.js.map +1 -1
- package/dist/services/codex-bridge-queue.d.ts +1 -0
- package/dist/services/codex-bridge-queue.d.ts.map +1 -1
- package/dist/services/codex-bridge-queue.js +1 -0
- package/dist/services/codex-bridge-queue.js.map +1 -1
- package/dist/services/codex-service-tier.d.ts +2 -0
- package/dist/services/codex-service-tier.d.ts.map +1 -1
- package/dist/services/codex-service-tier.js +1 -0
- package/dist/services/codex-service-tier.js.map +1 -1
- package/dist/services/codex-transcript.d.ts +27 -0
- package/dist/services/codex-transcript.d.ts.map +1 -1
- package/dist/services/codex-transcript.js +340 -2
- package/dist/services/codex-transcript.js.map +1 -1
- package/dist/services/grant-policy.d.ts +3 -0
- package/dist/services/grant-policy.d.ts.map +1 -1
- package/dist/services/grant-policy.js +5 -0
- package/dist/services/grant-policy.js.map +1 -1
- package/dist/services/grant-prefs-store.d.ts +6 -3
- package/dist/services/grant-prefs-store.d.ts.map +1 -1
- package/dist/services/grant-prefs-store.js +42 -12
- package/dist/services/grant-prefs-store.js.map +1 -1
- package/dist/services/hook-runner.d.ts +20 -1
- package/dist/services/hook-runner.d.ts.map +1 -1
- package/dist/services/hook-runner.js +17 -13
- package/dist/services/hook-runner.js.map +1 -1
- package/dist/services/idempotency-store.d.ts +134 -0
- package/dist/services/idempotency-store.d.ts.map +1 -0
- package/dist/services/idempotency-store.js +299 -0
- package/dist/services/idempotency-store.js.map +1 -0
- package/dist/services/jsonl-cursor.d.ts +7 -3
- package/dist/services/jsonl-cursor.d.ts.map +1 -1
- package/dist/services/jsonl-cursor.js +55 -31
- package/dist/services/jsonl-cursor.js.map +1 -1
- package/dist/services/open-platform-rename.d.ts.map +1 -1
- package/dist/services/open-platform-rename.js +1 -108
- package/dist/services/open-platform-rename.js.map +1 -1
- package/dist/services/restart-intent-store.d.ts +22 -2
- package/dist/services/restart-intent-store.d.ts.map +1 -1
- package/dist/services/restart-intent-store.js +160 -18
- package/dist/services/restart-intent-store.js.map +1 -1
- package/dist/services/sandbox-store.d.ts +22 -0
- package/dist/services/sandbox-store.d.ts.map +1 -1
- package/dist/services/sandbox-store.js +37 -8
- package/dist/services/sandbox-store.js.map +1 -1
- package/dist/services/send-policy.d.ts +41 -33
- package/dist/services/send-policy.d.ts.map +1 -1
- package/dist/services/send-policy.js +39 -40
- package/dist/services/send-policy.js.map +1 -1
- package/dist/services/session-store.d.ts +66 -0
- package/dist/services/session-store.d.ts.map +1 -1
- package/dist/services/session-store.js +403 -67
- package/dist/services/session-store.js.map +1 -1
- package/dist/services/submit-notification.d.ts +3 -0
- package/dist/services/submit-notification.d.ts.map +1 -0
- package/dist/services/submit-notification.js +15 -0
- package/dist/services/submit-notification.js.map +1 -0
- package/dist/services/traex-transcript.d.ts +25 -2
- package/dist/services/traex-transcript.d.ts.map +1 -1
- package/dist/services/traex-transcript.js +133 -1
- package/dist/services/traex-transcript.js.map +1 -1
- package/dist/services/trigger-types.d.ts +13 -1
- package/dist/services/trigger-types.d.ts.map +1 -1
- package/dist/services/trigger-types.js +39 -0
- package/dist/services/trigger-types.js.map +1 -1
- package/dist/services/voice/index.d.ts +3 -2
- package/dist/services/voice/index.d.ts.map +1 -1
- package/dist/services/voice/index.js +5 -5
- package/dist/services/voice/index.js.map +1 -1
- package/dist/services/voice/openai.d.ts +2 -1
- package/dist/services/voice/openai.d.ts.map +1 -1
- package/dist/services/voice/openai.js +2 -1
- package/dist/services/voice/openai.js.map +1 -1
- package/dist/services/voice/sami.d.ts +5 -2
- package/dist/services/voice/sami.d.ts.map +1 -1
- package/dist/services/voice/sami.js +19 -4
- package/dist/services/voice/sami.js.map +1 -1
- package/dist/setup/bots-store.d.ts.map +1 -1
- package/dist/setup/bots-store.js +10 -4
- package/dist/setup/bots-store.js.map +1 -1
- package/dist/setup/lark-scopes.json +2 -0
- package/dist/setup/open-platform-automation.d.ts +9 -1
- package/dist/setup/open-platform-automation.d.ts.map +1 -1
- package/dist/setup/open-platform-automation.js +44 -15
- package/dist/setup/open-platform-automation.js.map +1 -1
- package/dist/setup/open-platform-visibility.d.ts +42 -0
- package/dist/setup/open-platform-visibility.d.ts.map +1 -0
- package/dist/setup/open-platform-visibility.js +133 -0
- package/dist/setup/open-platform-visibility.js.map +1 -0
- package/dist/setup/verify-permissions.d.ts.map +1 -1
- package/dist/setup/verify-permissions.js +6 -0
- package/dist/setup/verify-permissions.js.map +1 -1
- package/dist/skills/definitions.js +2 -2
- package/dist/types.d.ts +290 -13
- package/dist/types.d.ts.map +1 -1
- package/dist/utils/bot-routing.d.ts +1 -0
- package/dist/utils/bot-routing.d.ts.map +1 -1
- package/dist/utils/bot-routing.js +1 -1
- package/dist/utils/bot-routing.js.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 +9 -0
- package/dist/utils/child-env.js.map +1 -1
- package/dist/utils/codex-app-control.d.ts +438 -0
- package/dist/utils/codex-app-control.d.ts.map +1 -0
- package/dist/utils/codex-app-control.js +2336 -0
- package/dist/utils/codex-app-control.js.map +1 -0
- package/dist/utils/codex-app-dispatch-ledger.d.ts +78 -0
- package/dist/utils/codex-app-dispatch-ledger.d.ts.map +1 -0
- package/dist/utils/codex-app-dispatch-ledger.js +149 -0
- package/dist/utils/codex-app-dispatch-ledger.js.map +1 -0
- package/dist/utils/codex-app-turn-dispatch.d.ts +77 -0
- package/dist/utils/codex-app-turn-dispatch.d.ts.map +1 -0
- package/dist/utils/codex-app-turn-dispatch.js +141 -0
- package/dist/utils/codex-app-turn-dispatch.js.map +1 -0
- package/dist/utils/codex-app-turn-liveness.d.ts +125 -0
- package/dist/utils/codex-app-turn-liveness.d.ts.map +1 -0
- package/dist/utils/codex-app-turn-liveness.js +301 -0
- package/dist/utils/codex-app-turn-liveness.js.map +1 -0
- package/dist/utils/idle-detector.d.ts +12 -0
- package/dist/utils/idle-detector.d.ts.map +1 -1
- package/dist/utils/idle-detector.js +19 -0
- package/dist/utils/idle-detector.js.map +1 -1
- package/dist/utils/input-gate.d.ts +87 -0
- package/dist/utils/input-gate.d.ts.map +1 -1
- package/dist/utils/input-gate.js +86 -0
- package/dist/utils/input-gate.js.map +1 -1
- package/dist/utils/lark-upload.d.ts.map +1 -1
- package/dist/utils/lark-upload.js +52 -1
- package/dist/utils/lark-upload.js.map +1 -1
- package/dist/utils/pending-input-queue.d.ts +15 -0
- package/dist/utils/pending-input-queue.d.ts.map +1 -1
- package/dist/utils/pending-input-queue.js +5 -1
- package/dist/utils/pending-input-queue.js.map +1 -1
- package/dist/utils/user-token.d.ts.map +1 -1
- package/dist/utils/user-token.js +53 -2
- package/dist/utils/user-token.js.map +1 -1
- package/dist/worker-ipc-preload.d.ts +12 -0
- package/dist/worker-ipc-preload.d.ts.map +1 -0
- package/dist/worker-ipc-preload.js +44 -0
- package/dist/worker-ipc-preload.js.map +1 -0
- package/dist/worker.js +2604 -325
- package/dist/worker.js.map +1 -1
- package/dist/workflows/v3/session-relay-client.d.ts +1 -0
- package/dist/workflows/v3/session-relay-client.d.ts.map +1 -1
- package/dist/workflows/v3/session-relay-client.js +12 -3
- package/dist/workflows/v3/session-relay-client.js.map +1 -1
- package/package.json +1 -1
- package/dist/dashboard-web/chunks/bot-defaults-page-LTVOUDIS.js +0 -10
- package/dist/dashboard-web/chunks/chunk-DCS35UMD.js +0 -1
- package/dist/dashboard-web/chunks/chunk-MFQPCVHY.js +0 -51
- package/dist/dashboard-web/chunks/connectors-page-5KWDIMKC.js +0 -1
- package/dist/dashboard-web/chunks/groups-page-FHOKYXMW.js +0 -8
- package/dist/dashboard-web/chunks/overview-page-QYI3KBDD.js +0 -1
- package/dist/dashboard-web/chunks/sessions-page-WBP4N7D2.js +0 -6
package/dist/daemon.js
CHANGED
|
@@ -21,6 +21,8 @@ import { countHostOverload } from './im/lark/card-handler.js';
|
|
|
21
21
|
import { startMaintenance, stopMaintenance } from './core/maintenance.js';
|
|
22
22
|
import { selectCodexRuntimeUpdateTargets, startCliRuntimeUpdateMonitor, stopCliRuntimeUpdateMonitor, } from './core/cli-runtime-update.js';
|
|
23
23
|
import { sendRestartReportIfPending } from './core/restart-report.js';
|
|
24
|
+
import { SUPERVISOR_SHUTDOWN_PROTOCOL, } from './core/supervisor-shutdown-protocol.js';
|
|
25
|
+
import { readSupervisorProcessStartIdentity } from './core/process-start-identity.js';
|
|
24
26
|
import { statSync } from 'node:fs';
|
|
25
27
|
import { addReaction, deleteMessage, getChatContext, getChatMode, getChatNameAndMode, getMessageChatId, listChatMemberOpenIds, MessageWithdrawnError, replyMessage, resolveAllowedUsersWithMap, sendMessage, sendUserMessage, updateMessage } from './im/lark/client.js';
|
|
26
28
|
import { resolveGroupJoinPrompt, waitForAllowedUserInChat } from './core/auto-start.js';
|
|
@@ -38,7 +40,7 @@ import { migrateOverloadAlertAtStartup } from './services/overload-alert-migrati
|
|
|
38
40
|
import * as messageQueue from './services/message-queue.js';
|
|
39
41
|
import { emitHookEvent, emitHookEventLocal, HOOK_EVENTS } from './services/hook-runner.js';
|
|
40
42
|
import { setSessionLifecycleShutdown } from './services/session-lifecycle-hooks.js';
|
|
41
|
-
import { createImgNumberer, parseEventMessage, resolveNonsupportMessage, stripLeadingMentions } from './im/lark/message-parser.js';
|
|
43
|
+
import { createImgNumberer, extractPostAtParticipants, parseEventMessage, resolveNonsupportMessage, stripLeadingMentions } from './im/lark/message-parser.js';
|
|
42
44
|
import { expandMergeForward } from './im/lark/merge-forward.js';
|
|
43
45
|
import { bindResourcesToMessage, composeForwardFollowupContent, mergeMessageMentions } from './im/lark/forward-followup-content.js';
|
|
44
46
|
import { buildQuoteHint } from './im/lark/quote-hint.js';
|
|
@@ -47,6 +49,8 @@ import { logger } from './utils/logger.js';
|
|
|
47
49
|
import { gracefulProcessExitCode } from './pm2-graceful-exit.js';
|
|
48
50
|
import { applyAllowedUsersResolve } from './utils/allowed-users-apply.js';
|
|
49
51
|
import { withFileLock, withFileLockSync } from './utils/file-lock.js';
|
|
52
|
+
import { hasUnsettledCodexAppDispatch, retireCodexAppDispatchAfterBackingMissing, validateCodexAppManagedSendOrigin, } from './utils/codex-app-dispatch-ledger.js';
|
|
53
|
+
import { hasProtectedSessionMutationOwnership } from './core/session-mutation-guard.js';
|
|
50
54
|
import { delay } from './utils/timing.js';
|
|
51
55
|
import { BoundedMap } from './utils/bounded-map.js';
|
|
52
56
|
import { checkAllowedChatGroupsConfig } from './services/allowed-chat-groups.js';
|
|
@@ -55,7 +59,8 @@ import { scrubTmuxServerGlobalEnv } from './setup/ensure-tmux.js';
|
|
|
55
59
|
import { entryNeedsContactResolve } from './setup/bot-config-editor.js';
|
|
56
60
|
import { invalidWorkingDirs } from './utils/working-dir.js';
|
|
57
61
|
import { validateWorkingDir } from './core/working-dir.js';
|
|
58
|
-
import { activeSessionKey, sessionKey, sessionAnchorId, storedSessionAnchorId, larkTransportEnabled } from './core/types.js';
|
|
62
|
+
import { activeSessionKey, sessionKey, sessionAnchorId, storedSessionAnchorId, larkTransportEnabled, } from './core/types.js';
|
|
63
|
+
import { stagePendingRepoSetup, persistPendingRepoCardMessageId } from './core/pending-repo-journal.js';
|
|
59
64
|
import { setTerminalProxyPort, setTerminalExternalPort } from './core/terminal-url.js';
|
|
60
65
|
import { startTerminalProxy } from './core/terminal-proxy.js';
|
|
61
66
|
import { runtimeInstallationKey } from './adapters/cli/runtime.js';
|
|
@@ -68,8 +73,8 @@ import { isLocalCliOpenReady } from './services/local-cli-opener.js';
|
|
|
68
73
|
import { RECEIVED_REACTION_EMOJI_TYPE, SUBSTITUTE_RECEIVED_REACTION_EMOJI_TYPE } from './core/pending-response.js';
|
|
69
74
|
import { t as tr, localeForBot } from './i18n/index.js';
|
|
70
75
|
import { createCliAdapterSync } from './adapters/cli/registry.js';
|
|
71
|
-
import { initWorkerPool, setActiveSessionsRegistry, forkWorker, forkAdoptWorker, sendWorkerInput, sendWorkerSessionInput, killWorker, reapOrphanWorkers, scheduleCardPatch, setCurrentCliVersion, getCurrentCliVersion, CARD_POSTING_SENTINEL, parkStreamCard, closeSession as closeSessionHelper, setActiveSessionIfActive, rollbackRejectedSessionAndGetWinner, ensureCliEnv, sweepGlobalBotmuxSkills, writableTerminalLinkFor, workerHasInitialized, sessionSupportsWebTerminal, readableTerminalUrlFor, findActiveBySessionId, getDaemonBootId, getDaemonStreamingCardUsageSnapshot, isSessionTransferring, } from './core/worker-pool.js';
|
|
72
|
-
import { AbortDeadlineError, hasExactSafeJsonKeys, ipcRoute, isTrustedHostIpcRequest, JsonBodyTooLargeError, jsonRes, readJsonBody, runWithAbortDeadline, setBotName, setLarkAppId, startIpcServer, setBotRenamer, setBotAvatarChanger, armCoreOnlyReadinessGate, setCoreOnlyReady } from './core/dashboard-ipc-server.js';
|
|
76
|
+
import { initWorkerPool, setActiveSessionsRegistry, forkWorker, forkAdoptWorker, sendWorkerInput, promoteQueuedActivationTail, admitQueuedActivationTail, reserveQueuedActivationTailAdmission, codexAppCleanInputAcceptedForSession, hasQueuedActivationAdmissionGate, sendWorkerSessionInput, killWorker, reapOrphanWorkers, scheduleCardPatch, setCurrentCliVersion, getCurrentCliVersion, CARD_POSTING_SENTINEL, parkStreamCard, closeSession as closeSessionHelper, setActiveSessionIfActive, rollbackRejectedSessionAndGetWinner, ensureCliEnv, sweepGlobalBotmuxSkills, writableTerminalLinkFor, workerHasInitialized, sessionSupportsWebTerminal, readableTerminalUrlFor, findActiveBySessionId, withActiveSessionKeyLock, getDaemonBootId, getDaemonStreamingCardUsageSnapshot, isSessionTransferring, } from './core/worker-pool.js';
|
|
77
|
+
import { AbortDeadlineError, hasExactSafeJsonKeys, ipcRoute, isTrustedHostIpcRequest, JsonBodyTooLargeError, jsonRes, readJsonBody, runWithAbortDeadline, setBotName, setLarkAppId, startIpcServer, setBotRenamer, setBotAvatarChanger, armCoreOnlyReadinessGate, setCoreOnlyReady, setSupervisorShutdownHandler } from './core/dashboard-ipc-server.js';
|
|
73
78
|
import { setDeviceIsolationDaemonIdentity } from './core/device-isolation-daemon.js';
|
|
74
79
|
import { cancelSessionReadyAck, waitForSessionReadyAck, } from './core/session-ready-handshake.js';
|
|
75
80
|
import { loadOrCreateDashboardSecret } from './dashboard/auth.js';
|
|
@@ -83,12 +88,13 @@ import { findInheritablePeer } from './core/inherit-peer.js';
|
|
|
83
88
|
import { isCallbackUrl, handleCallbackUrl } from './utils/user-token.js';
|
|
84
89
|
import { consumeQuota, removeChatGrant, removeGlobalGrant } from './services/grant-store.js';
|
|
85
90
|
import { abortCharge, commitCharge, beginCharge } from './services/quota-dedup.js';
|
|
86
|
-
import { getSessionWorkingDir, getProjectScanDirs, expandHome, downloadResources, formatAttachmentsHint, buildNewTopicCliInput, buildFollowUpCliInput, buildBridgeInputContent, buildReforkCliInput, getAvailableBots, restoreActiveSessions, executeScheduledTask, persistStreamCardState, rememberLastCliInput, ensureTerminalWorkerPort, ensureSessionWhiteboard, } from './core/session-manager.js';
|
|
87
|
-
import { triggerSessionTurn } from './core/trigger-session.js';
|
|
91
|
+
import { getSessionWorkingDir, getProjectScanDirs, expandHome, downloadResources, formatAttachmentsHint, buildNewTopicCliInput, buildFollowUpCliInput, buildBridgeInputContent, buildReforkCliInput, getAvailableBots, restoreActiveSessions, executeScheduledTask, persistStreamCardState, rememberLastCliInput, ensureTerminalWorkerPort, ensureSessionWhiteboard, closeCliMismatchedSessionsForBot, } from './core/session-manager.js';
|
|
92
|
+
import { triggerSessionTurn, reconcileIdempotencyLeasesOnBoot, convergeIdempotentAsyncTurnOnWorkerExit } from './core/trigger-session.js';
|
|
93
|
+
import { runDetachedBotTurnMutation, tryWithBotTurnMutation, withBotTurnAdmission, withBotTurnMutation, } from './core/bot-turn-mutation-gate.js';
|
|
88
94
|
import { claimInitialUserTurn, isInitialUserTurnPending, releaseInitialUserTurn } from './core/initial-user-turn.js';
|
|
89
95
|
import { applyQueuedCodexAppLegacyFallback, mergeQueuedCodexAppTurn } from './core/session-create.js';
|
|
90
96
|
import { findOnlineDaemon, listOnlineDaemons } from './utils/daemon-discovery.js';
|
|
91
|
-
import { beginReplyTargetTurn, fallbackTurnId, isSubstituteTurn, resolveSessionReplyTarget, syncReplyTargetState } from './core/reply-target.js';
|
|
97
|
+
import { beginReplyTargetTurn, buildTurnParticipantsFrom, fallbackTurnId, isSubstituteTurn, resolveSessionReplyTarget, syncReplyTargetState } from './core/reply-target.js';
|
|
92
98
|
import { readDeferredTopicBinding } from './core/deferred-topic-binding.js';
|
|
93
99
|
import { buildBotmuxLarkNativeSessionTitle, extractBotmuxLarkNativeSessionTitlePrompt, } from './core/session-title.js';
|
|
94
100
|
import { settleDeferredScheduleRun } from './core/deferred-schedule-settlement.js';
|
|
@@ -98,7 +104,7 @@ import { TmuxBackend } from './adapters/backend/tmux-backend.js';
|
|
|
98
104
|
import { HerdrBackend } from './adapters/backend/herdr-backend.js';
|
|
99
105
|
import { ZellijBackend } from './adapters/backend/zellij-backend.js';
|
|
100
106
|
import { ZmxBackend } from './adapters/backend/zmx-backend.js';
|
|
101
|
-
import {
|
|
107
|
+
import { sweepIdleWorkersAfterTurnDrain, DEFAULT_MAX_LIVE_WORKERS } from './core/idle-worker-sweeper.js';
|
|
102
108
|
import { getSessionPersistentBackendType, killPersistentBackendTarget, killPersistentSession, probePersistentBackendTarget, probePersistentSession, resolvePersistentBackendTarget, resolvePairedSpawnBackendType, } from './core/persistent-backend.js';
|
|
103
109
|
import { handleCardAction, runAutoWorktreeCommit } from './im/lark/card-handler.js';
|
|
104
110
|
import { setIssueActivate } from './im/lark/issue-command-deps.js';
|
|
@@ -167,12 +173,16 @@ import { buildDocCommentTurnInput, buildDocWatchWarmupTurnInput } from './core/d
|
|
|
167
173
|
import { advanceDocCommentCursor, docCommentRepliesAfterCursor, latestDocCommentPollCursor } from './core/doc-comment-poller.js';
|
|
168
174
|
import { renderBufferedSenderBlock } from './core/session-manager.js';
|
|
169
175
|
import { shutdownBackendDisposition } from './core/persistent-backend.js';
|
|
176
|
+
import { abortRiffShutdownFleet, canAbortVerifiedExitedRiffPreparation, collectUniqueDaemonShutdownSessions, commitPreparedRiffShutdown, isPreparedRiffSessionCurrent, persistPreparedRiffShutdownFleet, prepareRiffFleetForShutdown, } from './core/riff-shutdown-detach.js';
|
|
177
|
+
import { BOT_TURN_MUTATION_SHUTDOWN_ACQUIRE_TIMEOUT_MS, DAEMON_SHUTDOWN_MAX_MS, DAEMON_WORKER_EXIT_GRACE_MS, } from './core/shutdown-budgets.js';
|
|
170
178
|
import { evaluateVcMeetingConsumerIsolation } from './services/vc-meeting-consumer-isolation.js';
|
|
171
179
|
import { markSessionActivity, announcePendingRepoSession, publishAttentionPatch, publishLastInputFromBotPatch, publishSessionMessagePreviewPatch, publishClosedSessionPatch, clearAgentAttention, } from './core/session-activity.js';
|
|
172
180
|
import { emitSessionLifecycleHook } from './services/session-lifecycle-hooks.js';
|
|
173
181
|
import { botAutoWorktreeEnabled } from './services/default-worktree.js';
|
|
174
|
-
import { setCardDispatcher as setAskCardDispatcher, setCanTalkChecker as setAskCanTalkChecker, registerAsk as registerAskBroker, findPendingAskByAnchor, submitCustomReply, } from './core/ask-broker.js';
|
|
182
|
+
import { setCardDispatcher as setAskCardDispatcher, setCanTalkChecker as setAskCanTalkChecker, setAskPersistStore as setAskPersistStoreBroker, registerAsk as registerAskBroker, restorePersistedAsks as restorePersistedAsksBroker, findPendingAskByAnchor, submitCustomReply, } from './core/ask-broker.js';
|
|
183
|
+
import { createAskPersistStore } from './core/ask-persist-store.js';
|
|
175
184
|
import { parseAskBody } from './core/ask-api.js';
|
|
185
|
+
import { shouldReturnAskStartupNotReady } from './core/ask-types.js';
|
|
176
186
|
import { computeCocoPickerKeys } from './core/coco-picker-keys.js';
|
|
177
187
|
import { createLarkAskCardDispatcher } from './im/lark/ask-card.js';
|
|
178
188
|
import { normalizeVcMeetingEvents } from './vc-agent/normalizer.js';
|
|
@@ -202,6 +212,13 @@ import { authorizeVcMeetingDaemonControlRequest, ensureVcMeetingDaemonAuthToken,
|
|
|
202
212
|
import { resolveDurableVcMeetingImRouting, runBoundedVcMeetingImCatchUp, } from './services/vc-meeting-im-routing.js';
|
|
203
213
|
// ─── State ───────────────────────────────────────────────────────────────────
|
|
204
214
|
const activeSessions = new Map();
|
|
215
|
+
/** False until restoreActiveSessions() finishes. During the startup window the
|
|
216
|
+
* IPC server is already listening but activeSessions is empty, so a reconnecting
|
|
217
|
+
* ask hook would fail session lookup and get a 403 origin_unproven — which the
|
|
218
|
+
* CLI would treat as permanent and passthrough into a stuck native picker
|
|
219
|
+
* (codex P1-2). While false, /api/asks returns a retryable 503 for unknown
|
|
220
|
+
* sessions instead, so the reconnecting hook keeps waiting through the restore. */
|
|
221
|
+
let sessionsRestored = false;
|
|
205
222
|
const VC_MEETING_DELIVERY_LEASE_MS = 15 * 60_000;
|
|
206
223
|
const VC_MEETING_DELIVERY_LEASE_SCAN_MS = 60_000;
|
|
207
224
|
const VC_MEETING_RUNTIME_EXPIRY_ACK_TIMEOUT_MS = 3_000;
|
|
@@ -260,6 +277,62 @@ function addVcMeetingReceiverRecoveryPending(key, scope) {
|
|
|
260
277
|
vcMeetingReceiverRecoveryScopes.set(key, scope);
|
|
261
278
|
refreshVcMeetingReceiverRecoveryReady();
|
|
262
279
|
}
|
|
280
|
+
let testOnlyVcMeetingBootDispatchRetirement;
|
|
281
|
+
/**
|
|
282
|
+
* Retire the exact daemon-side Codex App ownership record after the owned CLI
|
|
283
|
+
* backing has been proved absent. This is intentionally stronger than the
|
|
284
|
+
* ordinary worker `cancel` transition: a dead generation may have accepted a
|
|
285
|
+
* successor behind this VC attempt, so recovery must remove only the fenced
|
|
286
|
+
* identity without requiring the old worker to prove FIFO state.
|
|
287
|
+
*
|
|
288
|
+
* A missing entry is idempotent success (the live worker may already have
|
|
289
|
+
* durably cancelled it before exiting). Multiple matches are corruption and
|
|
290
|
+
* remain fail-closed. The in-memory mutation is rolled back if the session
|
|
291
|
+
* store write fails, so callers can keep the delivery fence armed and retry.
|
|
292
|
+
*/
|
|
293
|
+
function retireVcMeetingCodexAppDispatchAfterBackingMissing(sessionId, turnId, dispatchAttempt) {
|
|
294
|
+
if (testOnlyVcMeetingBootDispatchRetirement) {
|
|
295
|
+
return testOnlyVcMeetingBootDispatchRetirement(sessionId, turnId, dispatchAttempt);
|
|
296
|
+
}
|
|
297
|
+
const session = findActiveBySessionId(sessionId)?.session ?? sessionStore.getSession(sessionId);
|
|
298
|
+
if (!session)
|
|
299
|
+
return true;
|
|
300
|
+
const ledger = session.codexAppDispatchLedger ?? [];
|
|
301
|
+
const retired = retireCodexAppDispatchAfterBackingMissing(ledger, turnId, dispatchAttempt);
|
|
302
|
+
if (!retired.ok) {
|
|
303
|
+
logger.error(`[vc-delivery] exact Codex App dispatch retirement failed (${retired.error}) `
|
|
304
|
+
+ `session=${sessionId} turn=${turnId.slice(0, 12)} attempt=${dispatchAttempt}`);
|
|
305
|
+
return false;
|
|
306
|
+
}
|
|
307
|
+
if (retired.ledger.length === ledger.length)
|
|
308
|
+
return true;
|
|
309
|
+
const priorLedger = session.codexAppDispatchLedger;
|
|
310
|
+
session.codexAppDispatchLedger = retired.ledger;
|
|
311
|
+
try {
|
|
312
|
+
sessionStore.updateSession(session);
|
|
313
|
+
const cleanupAppId = session.larkAppId;
|
|
314
|
+
if (cleanupAppId && !hasUnsettledCodexAppDispatch(session.codexAppDispatchLedger)) {
|
|
315
|
+
// Backing-missing retirement bypasses the normal worker ACK path, but it
|
|
316
|
+
// still creates the same durable empty-ledger boundary. Run mismatch
|
|
317
|
+
// cleanup only after persistence; failure is logged and never rolls back
|
|
318
|
+
// an already-authoritative retirement.
|
|
319
|
+
void runDetachedBotTurnMutation(cleanupAppId, async () => {
|
|
320
|
+
await closeCliMismatchedSessionsForBot(cleanupAppId);
|
|
321
|
+
}).catch(err => {
|
|
322
|
+
logger.error(`[vc-delivery] post-retirement ledger-drain cleanup failed session=${sessionId}: `
|
|
323
|
+
+ `${err instanceof Error ? err.message : String(err)}`);
|
|
324
|
+
});
|
|
325
|
+
}
|
|
326
|
+
return true;
|
|
327
|
+
}
|
|
328
|
+
catch (err) {
|
|
329
|
+
session.codexAppDispatchLedger = priorLedger;
|
|
330
|
+
logger.error(`[vc-delivery] failed to persist exact Codex App dispatch retirement `
|
|
331
|
+
+ `session=${sessionId} turn=${turnId.slice(0, 12)} attempt=${dispatchAttempt}: `
|
|
332
|
+
+ `${err instanceof Error ? err.message : String(err)}`);
|
|
333
|
+
return false;
|
|
334
|
+
}
|
|
335
|
+
}
|
|
263
336
|
function isVcMeetingBootRecoveryBlocked(request) {
|
|
264
337
|
// The brief pre-enumeration interval is the only global barrier. Once boot
|
|
265
338
|
// has enumerated stale receipts, one broken receiver must not stall unrelated
|
|
@@ -339,9 +412,14 @@ function scheduleVcMeetingBootRecoveryProbe(key, sessionId, delayMs) {
|
|
|
339
412
|
return;
|
|
340
413
|
vcMeetingReceiverRecoveryTimers.delete(key);
|
|
341
414
|
if (vcMeetingBootBackingMissing(sessionId, true)) {
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
415
|
+
const scope = vcMeetingReceiverRecoveryScopes.get(key);
|
|
416
|
+
if (scope && retireVcMeetingCodexAppDispatchAfterBackingMissing(sessionId, scope.turnId, scope.dispatchAttempt)) {
|
|
417
|
+
logger.error(`[vc-delivery] boot receiver force-fenced session=${sessionId}`);
|
|
418
|
+
clearVcMeetingReceiverRecoveryPending(key);
|
|
419
|
+
return;
|
|
420
|
+
}
|
|
421
|
+
logger.error(`[vc-delivery] boot receiver backing missing but dispatch retirement unproven; `
|
|
422
|
+
+ `reprobe scheduled session=${sessionId}`);
|
|
345
423
|
}
|
|
346
424
|
logger.error(`[vc-delivery] boot receiver teardown unproven; reprobe scheduled session=${sessionId}`);
|
|
347
425
|
scheduleVcMeetingBootRecoveryProbe(key, sessionId, VC_MEETING_RUNTIME_EXPIRY_REPROBE_MS);
|
|
@@ -375,8 +453,13 @@ function acknowledgeVcMeetingReceiverRecovery(key) {
|
|
|
375
453
|
if (!sessionId)
|
|
376
454
|
return false;
|
|
377
455
|
if (vcMeetingBootBackingMissing(sessionId, false)) {
|
|
378
|
-
|
|
379
|
-
|
|
456
|
+
const scope = vcMeetingReceiverRecoveryScopes.get(key);
|
|
457
|
+
if (scope && retireVcMeetingCodexAppDispatchAfterBackingMissing(sessionId, scope.turnId, scope.dispatchAttempt)) {
|
|
458
|
+
clearVcMeetingReceiverRecoveryPending(key);
|
|
459
|
+
return true;
|
|
460
|
+
}
|
|
461
|
+
logger.error(`[vc-delivery] boot receiver ACK has missing backing but dispatch retirement is unproven `
|
|
462
|
+
+ `session=${sessionId}`);
|
|
380
463
|
}
|
|
381
464
|
logger.error(`[vc-delivery] boot receiver ACK lacked backing teardown proof session=${sessionId}`);
|
|
382
465
|
escalateVcMeetingBootRecovery(key, sessionId);
|
|
@@ -402,6 +485,8 @@ export const __testOnly_vcMeetingReceiverRecovery = {
|
|
|
402
485
|
meetingId: scope.meetingId ?? 'meeting_test',
|
|
403
486
|
memberId: scope.memberId ?? 'member_test',
|
|
404
487
|
memberEpoch: scope.memberEpoch ?? 1,
|
|
488
|
+
turnId,
|
|
489
|
+
dispatchAttempt,
|
|
405
490
|
});
|
|
406
491
|
armVcMeetingReceiverRecoveryTimeout(key, sessionId);
|
|
407
492
|
return key;
|
|
@@ -425,6 +510,12 @@ export const __testOnly_vcMeetingReceiverRecovery = {
|
|
|
425
510
|
},
|
|
426
511
|
setBackingMissingProbe(probe) {
|
|
427
512
|
testOnlyVcMeetingBootBackingMissing = probe;
|
|
513
|
+
// Test recovery lifecycles must not load or mutate the developer's real
|
|
514
|
+
// session store merely because they replaced the backing probe.
|
|
515
|
+
testOnlyVcMeetingBootDispatchRetirement ??= () => true;
|
|
516
|
+
},
|
|
517
|
+
setDispatchRetirement(retire) {
|
|
518
|
+
testOnlyVcMeetingBootDispatchRetirement = retire;
|
|
428
519
|
},
|
|
429
520
|
reset() {
|
|
430
521
|
for (const timer of vcMeetingReceiverRecoveryTimers.values())
|
|
@@ -436,6 +527,7 @@ export const __testOnly_vcMeetingReceiverRecovery = {
|
|
|
436
527
|
vcMeetingReceiverRecoveryReady = false;
|
|
437
528
|
vcMeetingReceiverRecoverySchedulingComplete = false;
|
|
438
529
|
testOnlyVcMeetingBootBackingMissing = undefined;
|
|
530
|
+
testOnlyVcMeetingBootDispatchRetirement = undefined;
|
|
439
531
|
},
|
|
440
532
|
};
|
|
441
533
|
function createVcMeetingRuntimeLeaseRecovery(deps) {
|
|
@@ -524,8 +616,18 @@ function createVcMeetingRuntimeLeaseRecovery(deps) {
|
|
|
524
616
|
}
|
|
525
617
|
// PTY has no surviving backing process once its worker has crossed the
|
|
526
618
|
// SIGKILL backstop. Persistent backends unlock only after every applicable
|
|
527
|
-
// deterministic name probes authoritatively missing.
|
|
619
|
+
// deterministic name probes authoritatively missing. The backing proof is
|
|
620
|
+
// not itself enough: atomically retire this exact old daemon-ledger attempt
|
|
621
|
+
// before admitting hub replay, or accepted/prepared N can be restored next
|
|
622
|
+
// to replayed N+1 by a replacement worker.
|
|
528
623
|
if (allMissing) {
|
|
624
|
+
if (!deps.retireDispatch(fence.receiverSessionId, fence.deliveryKey, fence.dispatchAttempt)) {
|
|
625
|
+
deps.error(`[vc-delivery] runtime lease backing missing but exact dispatch retirement failed; `
|
|
626
|
+
+ `delivery remains gated session=${fence.receiverSessionId} `
|
|
627
|
+
+ `attempt=${fence.dispatchAttempt}`);
|
|
628
|
+
scheduleReprobe(fence, `${reason} dispatch retirement`);
|
|
629
|
+
return false;
|
|
630
|
+
}
|
|
529
631
|
clearFence(fence);
|
|
530
632
|
deps.warn(`[vc-delivery] runtime lease fence cleared after ${reason} `
|
|
531
633
|
+ `session=${fence.receiverSessionId} attempt=${fence.dispatchAttempt}`);
|
|
@@ -751,6 +853,7 @@ const vcMeetingRuntimeLeaseRecovery = createVcMeetingRuntimeLeaseRecovery({
|
|
|
751
853
|
probePersistent: (backendType, sessionName, target) => target
|
|
752
854
|
? probePersistentBackendTarget(target)
|
|
753
855
|
: probePersistentSession(backendType, sessionName),
|
|
856
|
+
retireDispatch: retireVcMeetingCodexAppDispatchAfterBackingMissing,
|
|
754
857
|
warn: message => logger.warn(message),
|
|
755
858
|
error: message => logger.error(message),
|
|
756
859
|
});
|
|
@@ -2104,6 +2207,23 @@ async function sessionReply(anchor, content, msgType = 'text', larkAppId, turnId
|
|
|
2104
2207
|
return sendWithHookPolicy(chatId, content, msgType, opts.uuid);
|
|
2105
2208
|
}
|
|
2106
2209
|
}
|
|
2210
|
+
if (opts?.replyTarget?.mode === 'thread') {
|
|
2211
|
+
return replyWithHookPolicy(opts.replyTarget.rootMessageId, content, msgType, true, opts.uuid);
|
|
2212
|
+
}
|
|
2213
|
+
if (opts?.replyTarget?.mode === 'plain') {
|
|
2214
|
+
if (opts.replyTarget.chatId !== chatId) {
|
|
2215
|
+
throw new Error('frozen reply target does not match the session chat');
|
|
2216
|
+
}
|
|
2217
|
+
// Preserve the existing regular-group → topic conversion safety without
|
|
2218
|
+
// consulting the mutable per-turn target that may now belong to turn B.
|
|
2219
|
+
if (ds?.scope === 'chat' && ds.session.rootMessageId) {
|
|
2220
|
+
const mode = await getChatMode(appId, chatId, { forceRefresh: true });
|
|
2221
|
+
if (mode === 'topic') {
|
|
2222
|
+
return replyWithHookPolicy(ds.session.rootMessageId, content, msgType, true, opts.uuid);
|
|
2223
|
+
}
|
|
2224
|
+
}
|
|
2225
|
+
return sendWithHookPolicy(chatId, content, msgType, opts.uuid);
|
|
2226
|
+
}
|
|
2107
2227
|
if (opts?.placement === 'chat') {
|
|
2108
2228
|
return sendWithHookPolicy(chatId, content, msgType, opts.uuid);
|
|
2109
2229
|
}
|
|
@@ -2149,6 +2269,12 @@ async function sessionReply(anchor, content, msgType = 'text', larkAppId, turnId
|
|
|
2149
2269
|
return sendWithHookPolicy(chatId, content, msgType, opts?.uuid);
|
|
2150
2270
|
}
|
|
2151
2271
|
// Thread-scope (or unknown / legacy): reply in thread.
|
|
2272
|
+
if (opts?.replyTarget?.mode === 'plain') {
|
|
2273
|
+
throw new Error('plain frozen reply target is invalid for a thread-scoped session');
|
|
2274
|
+
}
|
|
2275
|
+
if (opts?.replyTarget?.mode === 'thread') {
|
|
2276
|
+
return replyWithHookPolicy(opts.replyTarget.rootMessageId, content, msgType, true, opts.uuid);
|
|
2277
|
+
}
|
|
2152
2278
|
return replyWithHookPolicy(anchor, content, msgType, true, opts?.uuid);
|
|
2153
2279
|
}
|
|
2154
2280
|
// Test seams: drive the real sessionReply (the chat-scope thread/top-level
|
|
@@ -2955,6 +3081,12 @@ async function prewarmDocCommentSession(ds, sub) {
|
|
|
2955
3081
|
const turnId = `doc-watch-${Date.now()}-${sub.fileToken.slice(0, 8)}`;
|
|
2956
3082
|
const sender = sub.ownerOpenId ? await resolveSender(ds.larkAppId, sub.ownerOpenId, 'user') : undefined;
|
|
2957
3083
|
ensureCurrentRoutingGeneration(generation, 'prewarm:sender');
|
|
3084
|
+
if ((!ds.worker || ds.worker.killed)
|
|
3085
|
+
&& (ds.pendingRepo
|
|
3086
|
+
|| ds.worktreeCreating
|
|
3087
|
+
|| hasProtectedSessionMutationOwnership(ds))) {
|
|
3088
|
+
throw new Error(`doc-watch warmup deferred: session ${ds.session.sessionId} has durable opening ownership`);
|
|
3089
|
+
}
|
|
2958
3090
|
beginNewTurn(ds, title);
|
|
2959
3091
|
ds.lastMessageAt = Date.now();
|
|
2960
3092
|
ds.session.lastMessageAt = new Date(ds.lastMessageAt).toISOString();
|
|
@@ -2977,7 +3109,9 @@ async function prewarmDocCommentSession(ds, sub) {
|
|
|
2977
3109
|
});
|
|
2978
3110
|
rememberLastCliInput(ds, promptContent, cliInput);
|
|
2979
3111
|
sessionStore.updateSession(ds.session);
|
|
2980
|
-
sendWorkerInput(ds, cliInput, turnId)
|
|
3112
|
+
if (!sendWorkerInput(ds, cliInput, turnId)) {
|
|
3113
|
+
throw new Error('doc-watch warmup worker input was not accepted');
|
|
3114
|
+
}
|
|
2981
3115
|
markSessionActivity(ds);
|
|
2982
3116
|
}
|
|
2983
3117
|
else {
|
|
@@ -2997,6 +3131,7 @@ async function prewarmDocCommentSession(ds, sub) {
|
|
|
2997
3131
|
}
|
|
2998
3132
|
logger.info(`[${tag(ds)}] doc-comment watch prewarm injected file=${sub.fileToken.slice(0, 12)}`);
|
|
2999
3133
|
}
|
|
3134
|
+
export const __testOnly_prewarmDocCommentSession = prewarmDocCommentSession;
|
|
3000
3135
|
// Dependencies passed to command-handler
|
|
3001
3136
|
const commandDeps = {
|
|
3002
3137
|
activeSessions,
|
|
@@ -3904,6 +4039,21 @@ ipcRoute('POST', '/api/asks', async (req, res) => {
|
|
|
3904
4039
|
if ('error' in parsed)
|
|
3905
4040
|
return jsonRes(res, 400, { ok: false, error: parsed.error });
|
|
3906
4041
|
const askSession = findActiveBySessionId(parsed.sessionId);
|
|
4042
|
+
// Startup window (codex P1-2): IPC is listening but sessions aren't restored
|
|
4043
|
+
// yet, so a reconnecting ask hook's session lookup misses and would otherwise
|
|
4044
|
+
// get a permanent 403. Return a RETRYABLE 503 so the hook keeps waiting
|
|
4045
|
+
// (blocking Claude, no native picker) until restore finishes and it can bind.
|
|
4046
|
+
// Predicate is a shared pure function (unit-tested directly — codex P1-2 seam).
|
|
4047
|
+
// NOTE: this gate does NOT exempt trusted-host callers — a normal unsandbox
|
|
4048
|
+
// hook IS the trusted-host path (HMAC fetchDaemonIpc), so exempting it would
|
|
4049
|
+
// let the very reconnecting hook we must protect register a lost
|
|
4050
|
+
// non-resumable ask during the restore window.
|
|
4051
|
+
if (shouldReturnAskStartupNotReady({
|
|
4052
|
+
hasSession: !!askSession,
|
|
4053
|
+
sessionsRestored,
|
|
4054
|
+
})) {
|
|
4055
|
+
return jsonRes(res, 503, { ok: false, error: 'startup_not_ready' });
|
|
4056
|
+
}
|
|
3907
4057
|
let boundAsk = parsed;
|
|
3908
4058
|
if (!isTrustedHostIpcRequest(req)) {
|
|
3909
4059
|
const body = raw && typeof raw === 'object' && !Array.isArray(raw)
|
|
@@ -3987,6 +4137,14 @@ ipcRoute('POST', '/api/asks', async (req, res) => {
|
|
|
3987
4137
|
questions: boundAsk.questions,
|
|
3988
4138
|
timeoutMs: boundAsk.timeoutMs,
|
|
3989
4139
|
chatType: askChatType,
|
|
4140
|
+
// Invocation identity (from the hook; enables cross-restart re-attach).
|
|
4141
|
+
requestId: boundAsk.requestId,
|
|
4142
|
+
originKind: boundAsk.originKind,
|
|
4143
|
+
// Authoritative persistence gate (codex P1-4): derive resumability from the
|
|
4144
|
+
// authenticated session's FROZEN backend, not the client's origin string. A
|
|
4145
|
+
// PTY-backed session dies with the daemon, so its ask must NOT persist; only
|
|
4146
|
+
// a restart-surviving mux backend (tmux/herdr/zellij/zmx) is resumable.
|
|
4147
|
+
backendSurvivesRestart: !!askSession && getSessionPersistentBackendType(askSession) !== undefined,
|
|
3990
4148
|
});
|
|
3991
4149
|
// CoCo 专属:它的 hook 不能用 directive 代答(hook 客户端永远 passthrough,CoCo 会
|
|
3992
4150
|
// 渲染原生 picker)。这里在 ask 结算为「已作答」时,把答案翻成按键序列下发给该会话
|
|
@@ -4076,6 +4234,16 @@ ipcRoute('POST', '/api/attention', async (req, res) => {
|
|
|
4076
4234
|
if (!verified.ok) {
|
|
4077
4235
|
return jsonRes(res, 403, { ok: false, error: verified.error });
|
|
4078
4236
|
}
|
|
4237
|
+
if (!ds?.managedTurnOrigin
|
|
4238
|
+
|| typeof raw.originTurnId !== 'string'
|
|
4239
|
+
|| raw.originTurnId !== ds.managedTurnOrigin.turnId
|
|
4240
|
+
|| claimedAttempt !== ds.managedTurnOrigin.dispatchAttempt) {
|
|
4241
|
+
return jsonRes(res, 403, { ok: false, error: 'origin_identity_mismatch' });
|
|
4242
|
+
}
|
|
4243
|
+
const codexDecision = validateCodexAppManagedSendOrigin(ds.session.codexAppDispatchLedger, ds.managedTurnOrigin, ds.initConfig?.cliId === 'codex-app' || ds.session.cliId === 'codex-app');
|
|
4244
|
+
if (!codexDecision.ok) {
|
|
4245
|
+
return jsonRes(res, 409, { ok: false, error: 'origin_not_sendable' });
|
|
4246
|
+
}
|
|
4079
4247
|
}
|
|
4080
4248
|
if (!ds)
|
|
4081
4249
|
return jsonRes(res, 404, { ok: false, error: 'session_not_found' });
|
|
@@ -4300,6 +4468,10 @@ ipcRoute('POST', '/api/hooks/emit', async (req, res) => {
|
|
|
4300
4468
|
if (!verified.ok) {
|
|
4301
4469
|
return jsonRes(res, 403, { ok: false, error: verified.error });
|
|
4302
4470
|
}
|
|
4471
|
+
const codexDecision = validateCodexAppManagedSendOrigin(ds.session.codexAppDispatchLedger, ds.managedTurnOrigin, ds.initConfig?.cliId === 'codex-app' || ds.session.cliId === 'codex-app');
|
|
4472
|
+
if (!codexDecision.ok) {
|
|
4473
|
+
return jsonRes(res, 409, { ok: false, error: 'origin_not_sendable' });
|
|
4474
|
+
}
|
|
4303
4475
|
// Do not let a valid capability for session A forge session B's identity
|
|
4304
4476
|
// inside the hook payload. Hook-specific fields remain caller supplied;
|
|
4305
4477
|
// routing/identity fields come only from the authenticated daemon session.
|
|
@@ -12797,6 +12969,86 @@ export const __testOnly_resolvePinnedWorkingDir = resolvePinnedWorkingDir;
|
|
|
12797
12969
|
// which unit tests calling handleCommand directly can never catch.
|
|
12798
12970
|
export const __testOnly_handleNewTopic = (data, ctx) => handleNewTopic(data, ctx);
|
|
12799
12971
|
export const __testOnly_handleThreadReply = (data, ctx) => handleThreadReply(data, ctx);
|
|
12972
|
+
export const __testOnly_computeCodexAppSteerable = computeCodexAppSteerable;
|
|
12973
|
+
/**
|
|
12974
|
+
* Claim the canonical routing slot for a newly-created daemon session.
|
|
12975
|
+
*
|
|
12976
|
+
* Restore/transfer intentionally use setActiveSessionSafe's replacement
|
|
12977
|
+
* policy to clean historical collisions. Live creation is different: once a
|
|
12978
|
+
* caller has won a slot, a concurrent creator must never close that winner
|
|
12979
|
+
* while the first caller is still preparing/forking it. First owner wins;
|
|
12980
|
+
* ledger-empty losers are closed before the caller can observe rejection.
|
|
12981
|
+
* Any incoming owner with an unsettled durable turn is preserved and fails
|
|
12982
|
+
* closed because choosing either generation would risk dropping accepted
|
|
12983
|
+
* work.
|
|
12984
|
+
*/
|
|
12985
|
+
async function claimNewDaemonSession(map, incoming) {
|
|
12986
|
+
const key = activeSessionKey(incoming);
|
|
12987
|
+
return withActiveSessionKeyLock(map, key, async () => {
|
|
12988
|
+
const incumbent = map.get(key);
|
|
12989
|
+
if (!incumbent || incumbent === incoming) {
|
|
12990
|
+
map.set(key, incoming);
|
|
12991
|
+
return { accepted: true, key, owner: incoming };
|
|
12992
|
+
}
|
|
12993
|
+
const incumbentPending = hasProtectedSessionMutationOwnership(incumbent);
|
|
12994
|
+
// A newly-created loser's transient initialStartPending is still owned by
|
|
12995
|
+
// this caller and can be rerouted to the incumbent after the empty row is
|
|
12996
|
+
// closed. Only durable session-level ownership makes the loser itself
|
|
12997
|
+
// irreplaceable here.
|
|
12998
|
+
const incomingPending = hasProtectedSessionMutationOwnership(incoming.session);
|
|
12999
|
+
if (incomingPending) {
|
|
13000
|
+
const reason = incumbentPending ? 'both_pending' : 'incoming_pending';
|
|
13001
|
+
logger.error(`[session-registration] refusing ${reason} collision key=${key} `
|
|
13002
|
+
+ `incumbent=${incumbent.session.sessionId} incoming=${incoming.session.sessionId}; `
|
|
13003
|
+
+ 'preserving both rows and failing closed');
|
|
13004
|
+
return {
|
|
13005
|
+
accepted: false,
|
|
13006
|
+
key,
|
|
13007
|
+
owner: incumbent,
|
|
13008
|
+
reason,
|
|
13009
|
+
preservedIncomingSessionId: incoming.session.sessionId,
|
|
13010
|
+
};
|
|
13011
|
+
}
|
|
13012
|
+
// All live creation paths reach this helper before a worker/worktree is
|
|
13013
|
+
// started, so persistence close is the complete loser cleanup. Do it while
|
|
13014
|
+
// holding the key lock: rejection never exposes an active ghost row.
|
|
13015
|
+
sessionStore.closeSession(incoming.session.sessionId);
|
|
13016
|
+
logger.warn(`[session-registration] canonical owner ${incumbent.session.sessionId.substring(0, 8)} `
|
|
13017
|
+
+ `kept for key=${key}; closed incoming ${incoming.session.sessionId.substring(0, 8)}`);
|
|
13018
|
+
return {
|
|
13019
|
+
accepted: false,
|
|
13020
|
+
key,
|
|
13021
|
+
owner: incumbent,
|
|
13022
|
+
reason: 'existing_owner',
|
|
13023
|
+
closedIncomingSessionId: incoming.session.sessionId,
|
|
13024
|
+
};
|
|
13025
|
+
});
|
|
13026
|
+
}
|
|
13027
|
+
export const __testOnly_claimNewDaemonSession = (map, incoming) => claimNewDaemonSession(map, incoming);
|
|
13028
|
+
/** Persist pending-repo N immediately after the caller wins the canonical
|
|
13029
|
+
* runtime slot and before any card/worktree/fork publication. Staging before
|
|
13030
|
+
* the claim creates a durable same-key loser that can poison restart with two
|
|
13031
|
+
* protected owners. A failed write unpublishes the not-yet-durably-admitted
|
|
13032
|
+
* runtime owner and fails loudly. This remains inside the documented residual
|
|
13033
|
+
* WS-claim → durable-admission gap; no redelivery guarantee is asserted here. */
|
|
13034
|
+
function stageClaimedPendingRepoSetup(map, ds, args) {
|
|
13035
|
+
try {
|
|
13036
|
+
stagePendingRepoSetup(ds, args);
|
|
13037
|
+
}
|
|
13038
|
+
catch (err) {
|
|
13039
|
+
const key = activeSessionKey(ds);
|
|
13040
|
+
if (map.get(key) === ds)
|
|
13041
|
+
map.delete(key);
|
|
13042
|
+
try {
|
|
13043
|
+
sessionStore.closeSession(ds.session.sessionId);
|
|
13044
|
+
}
|
|
13045
|
+
catch (closeErr) {
|
|
13046
|
+
logger.error(`[session-registration] setup staging and loser cleanup both failed for `
|
|
13047
|
+
+ `${ds.session.sessionId}: ${closeErr instanceof Error ? closeErr.message : String(closeErr)}`);
|
|
13048
|
+
}
|
|
13049
|
+
throw err;
|
|
13050
|
+
}
|
|
13051
|
+
}
|
|
12800
13052
|
/**
|
|
12801
13053
|
* 该新会话是否要走「仅默认目录 + 自动建 worktree」:pinned dir 来自本 bot 自己的
|
|
12802
13054
|
* defaultWorkingDir (layer 3) 且开关打开。为真时,spawn 路径不再同步 fork,而是把会话登记
|
|
@@ -12823,6 +13075,324 @@ function startAutoWorktreePending(ds, args) {
|
|
|
12823
13075
|
});
|
|
12824
13076
|
logger.info(`[${tag(ds)}] auto-worktree → pending, building worktree off ${args.baseDir}`);
|
|
12825
13077
|
}
|
|
13078
|
+
/** Materialize the opening input at the final synchronous boundary before
|
|
13079
|
+
* fork. All potentially-slow preparation must happen before this call; the
|
|
13080
|
+
* pendingFollowUps snapshot and fork then cannot be interleaved by a later
|
|
13081
|
+
* same-anchor handler. */
|
|
13082
|
+
function buildReservedInitialInput(ds, availableBots) {
|
|
13083
|
+
const selfBot = getBot(ds.larkAppId);
|
|
13084
|
+
const botCfg = selfBot.config;
|
|
13085
|
+
const built = buildNewTopicCliInput(ds.pendingPrompt ?? '', ds.session.sessionId, ds.session.cliId ?? botCfg.cliId, ds.session.cliPathOverride ?? botCfg.cliPathOverride, ds.pendingAttachments, ds.pendingMentions, availableBots, ds.pendingFollowUps, { name: selfBot.botName, openId: selfBot.botOpenId }, localeForBot(ds.larkAppId), ds.pendingSender, {
|
|
13086
|
+
larkAppId: ds.larkAppId,
|
|
13087
|
+
chatId: ds.chatId,
|
|
13088
|
+
whiteboardId: ds.session.whiteboardId,
|
|
13089
|
+
substituteTrigger: ds.pendingSubstituteTrigger,
|
|
13090
|
+
codexAppText: ds.pendingCodexAppText,
|
|
13091
|
+
codexAppApplicationContext: ds.pendingCodexAppApplicationContext,
|
|
13092
|
+
codexAppMessageContext: ds.pendingCodexAppMessageContext,
|
|
13093
|
+
codexAppFollowUps: ds.pendingCodexAppFollowUps,
|
|
13094
|
+
codexAppFollowUpContexts: ds.pendingCodexAppFollowUpContexts,
|
|
13095
|
+
// master: thread the joined-chat context into the opening prompt (group-join
|
|
13096
|
+
// auto-start passes this via pendingChatContext).
|
|
13097
|
+
chatContext: ds.pendingChatContext,
|
|
13098
|
+
});
|
|
13099
|
+
// R5-B1-1: COPY the frozen new-topic steer authorization onto the opening
|
|
13100
|
+
// payload — never inferred here. Only an explicit upstream `true` carries over.
|
|
13101
|
+
if (ds.pendingCodexAppSteerable === true)
|
|
13102
|
+
built.codexAppSteerable = true;
|
|
13103
|
+
return built;
|
|
13104
|
+
}
|
|
13105
|
+
function clearInitialStartBuffers(ds) {
|
|
13106
|
+
ds.pendingPrompt = undefined;
|
|
13107
|
+
ds.pendingCodexAppText = undefined;
|
|
13108
|
+
ds.pendingCodexAppApplicationContext = undefined;
|
|
13109
|
+
ds.pendingCodexAppMessageContext = undefined;
|
|
13110
|
+
ds.pendingAttachments = undefined;
|
|
13111
|
+
ds.pendingMentions = undefined;
|
|
13112
|
+
ds.pendingSubstituteTrigger = undefined;
|
|
13113
|
+
ds.pendingSender = undefined;
|
|
13114
|
+
ds.pendingCodexAppSteerable = undefined;
|
|
13115
|
+
ds.pendingFollowUps = undefined;
|
|
13116
|
+
ds.pendingFollowUpTurnIds = undefined;
|
|
13117
|
+
ds.pendingCodexAppFollowUps = undefined;
|
|
13118
|
+
ds.pendingCodexAppFollowUpContexts = undefined;
|
|
13119
|
+
ds.pendingCodexAppFollowUpGateAccepted = undefined;
|
|
13120
|
+
}
|
|
13121
|
+
function clearInitialStartClaim(ds, token) {
|
|
13122
|
+
if (token !== undefined && ds.initialStartClaimToken !== token)
|
|
13123
|
+
return false;
|
|
13124
|
+
ds.initialStartClaimToken = undefined;
|
|
13125
|
+
ds.initialStartPending = false;
|
|
13126
|
+
if ((ds.queuedActivationTailAdmissionsOutstanding ?? 0) === 0) {
|
|
13127
|
+
ds.queuedActivationTailReleasePending = undefined;
|
|
13128
|
+
if (ds.queuedActivationTailReleaseRetryTimer) {
|
|
13129
|
+
clearTimeout(ds.queuedActivationTailReleaseRetryTimer);
|
|
13130
|
+
ds.queuedActivationTailReleaseRetryTimer = undefined;
|
|
13131
|
+
}
|
|
13132
|
+
}
|
|
13133
|
+
return true;
|
|
13134
|
+
}
|
|
13135
|
+
/** Fence an arrival before any sender/prompt await. Besides reserving durable
|
|
13136
|
+
* FIFO order, keep the opening route owned until this reservation either lands
|
|
13137
|
+
* in the durable tail or fails. */
|
|
13138
|
+
function reserveAsyncQueuedActivationTailAdmission(ds) {
|
|
13139
|
+
const reservation = reserveQueuedActivationTailAdmission(ds);
|
|
13140
|
+
ds.queuedActivationTailAdmissionsOutstanding =
|
|
13141
|
+
(ds.queuedActivationTailAdmissionsOutstanding ?? 0) + 1;
|
|
13142
|
+
return reservation;
|
|
13143
|
+
}
|
|
13144
|
+
function scheduleQueuedActivationTailReleaseRetry(ds, acknowledgedToken) {
|
|
13145
|
+
if (!ds.initialStartPending || ds.queuedActivationTailReleaseRetryTimer)
|
|
13146
|
+
return;
|
|
13147
|
+
const timer = setTimeout(() => {
|
|
13148
|
+
if (ds.queuedActivationTailReleaseRetryTimer !== timer)
|
|
13149
|
+
return;
|
|
13150
|
+
ds.queuedActivationTailReleaseRetryTimer = undefined;
|
|
13151
|
+
if (!ds.initialStartPending)
|
|
13152
|
+
return;
|
|
13153
|
+
if (!releaseQueuedActivationReservation(ds, acknowledgedToken)) {
|
|
13154
|
+
scheduleQueuedActivationTailReleaseRetry(ds, acknowledgedToken);
|
|
13155
|
+
}
|
|
13156
|
+
}, 100);
|
|
13157
|
+
timer.unref?.();
|
|
13158
|
+
ds.queuedActivationTailReleaseRetryTimer = timer;
|
|
13159
|
+
}
|
|
13160
|
+
/** Complete one async reservation. If the predecessor ACK arrived during its
|
|
13161
|
+
* await, the final settler performs the deferred handoff immediately. */
|
|
13162
|
+
function settleAsyncQueuedActivationTailAdmission(ds) {
|
|
13163
|
+
const outstanding = Math.max(0, (ds.queuedActivationTailAdmissionsOutstanding ?? 0) - 1);
|
|
13164
|
+
ds.queuedActivationTailAdmissionsOutstanding = outstanding || undefined;
|
|
13165
|
+
if (outstanding > 0 || !ds.queuedActivationTailReleasePending)
|
|
13166
|
+
return;
|
|
13167
|
+
const pending = ds.queuedActivationTailReleasePending;
|
|
13168
|
+
ds.queuedActivationTailReleasePending = undefined;
|
|
13169
|
+
if (!releaseQueuedActivationReservation(ds, pending.acknowledgedToken)) {
|
|
13170
|
+
scheduleQueuedActivationTailReleaseRetry(ds, pending.acknowledgedToken);
|
|
13171
|
+
}
|
|
13172
|
+
}
|
|
13173
|
+
export const __testOnly_reserveAsyncQueuedActivationTailAdmission = reserveAsyncQueuedActivationTailAdmission;
|
|
13174
|
+
export const __testOnly_settleAsyncQueuedActivationTailAdmission = settleAsyncQueuedActivationTailAdmission;
|
|
13175
|
+
/** Release a queued activation's runtime route reservation only after the
|
|
13176
|
+
* worker ACKs actual adapter submission. Turns that arrived meanwhile are sent
|
|
13177
|
+
* as one ordered follow-up, never allowed to overtake the opening item. */
|
|
13178
|
+
function releaseQueuedActivationReservation(ds, acknowledgedToken) {
|
|
13179
|
+
if ((ds.queuedActivationTailAdmissionsOutstanding ?? 0) > 0) {
|
|
13180
|
+
// The worker may ACK while N+1 is still awaiting sender/resource prompt
|
|
13181
|
+
// construction. Keep the route gate and replay this release when the last
|
|
13182
|
+
// reserved arrival either persists or fails.
|
|
13183
|
+
ds.queuedActivationTailReleasePending = acknowledgedToken === undefined
|
|
13184
|
+
? {}
|
|
13185
|
+
: { acknowledgedToken };
|
|
13186
|
+
return false;
|
|
13187
|
+
}
|
|
13188
|
+
ds.queuedActivationTailReleasePending = undefined;
|
|
13189
|
+
// A prior callback retry may observe the successor already promoted. That is
|
|
13190
|
+
// success for the acknowledged predecessor; never append/send the same tail
|
|
13191
|
+
// head a second time while its fresh token owns the journal.
|
|
13192
|
+
if (ds.session.queuedActivationPending) {
|
|
13193
|
+
return acknowledgedToken !== undefined
|
|
13194
|
+
&& ds.session.queuedActivationToken !== acknowledgedToken;
|
|
13195
|
+
}
|
|
13196
|
+
const buffered = [...(ds.pendingFollowUps ?? [])];
|
|
13197
|
+
if (buffered.length > 0) {
|
|
13198
|
+
const bot = getBot(ds.larkAppId);
|
|
13199
|
+
const cliId = ds.session.cliId ?? bot.config.cliId;
|
|
13200
|
+
const rawCodexText = (ds.pendingCodexAppFollowUps ?? []).join('\n\n');
|
|
13201
|
+
const followUp = buildFollowUpCliInput(buffered.join('\n\n'), ds.session.sessionId, {
|
|
13202
|
+
cliId,
|
|
13203
|
+
cliPathOverride: ds.session.cliPathOverride ?? bot.config.cliPathOverride,
|
|
13204
|
+
locale: localeForBot(ds.larkAppId),
|
|
13205
|
+
larkAppId: ds.larkAppId,
|
|
13206
|
+
chatId: ds.chatId,
|
|
13207
|
+
whiteboardId: ds.session.whiteboardId,
|
|
13208
|
+
codexAppText: rawCodexText || buffered.join('\n\n'),
|
|
13209
|
+
codexAppApplicationContext: ds.pendingCodexAppApplicationContext,
|
|
13210
|
+
codexAppMessageContext: (ds.pendingCodexAppFollowUpContexts ?? [])
|
|
13211
|
+
.filter(Boolean)
|
|
13212
|
+
.join('\n\n'),
|
|
13213
|
+
});
|
|
13214
|
+
const bufferedTurnIds = ds.pendingFollowUpTurnIds ?? [];
|
|
13215
|
+
const turnId = bufferedTurnIds[bufferedTurnIds.length - 1]
|
|
13216
|
+
?? ds.currentReplyTarget?.turnId
|
|
13217
|
+
?? `queued-activation-followup-${randomUUID()}`;
|
|
13218
|
+
const reservation = reserveQueuedActivationTailAdmission(ds);
|
|
13219
|
+
const bufferedGateDecisions = ds.pendingCodexAppFollowUpGateAccepted ?? [];
|
|
13220
|
+
// Runtime buffers are rolling-upgrade compatibility only. If they carry an
|
|
13221
|
+
// arrival-time decision, preserve it; otherwise fail closed rather than
|
|
13222
|
+
// sampling a later ON setting and inventing a sidecar at ACK time.
|
|
13223
|
+
//
|
|
13224
|
+
// R7-B1/coalesced (codex ruling): `codexAppInputAccepted` here is the
|
|
13225
|
+
// clean-input FEATURE gate, NOT a steer authorization — it is deliberately
|
|
13226
|
+
// never used to derive `codexAppSteerable`. Coalescing N buffered messages
|
|
13227
|
+
// into one prompt / one ledger reservation / one final (turnId is the last
|
|
13228
|
+
// one) has already lost each message's per-message identity, so a single
|
|
13229
|
+
// steer flag could not safely express the batch. This coalesced opening
|
|
13230
|
+
// successor therefore stays FORCED-SERIAL by construction: the admit below
|
|
13231
|
+
// carries no `codexAppSteerable`, and nothing downstream re-derives one.
|
|
13232
|
+
reservation.codexAppInputAccepted = bufferedGateDecisions.length > 0
|
|
13233
|
+
&& bufferedGateDecisions.every(Boolean);
|
|
13234
|
+
try {
|
|
13235
|
+
admitQueuedActivationTail(ds, {
|
|
13236
|
+
userPrompt: rawCodexText || buffered.join('\n\n'),
|
|
13237
|
+
cliInput: followUp,
|
|
13238
|
+
turnId,
|
|
13239
|
+
}, reservation);
|
|
13240
|
+
}
|
|
13241
|
+
catch (err) {
|
|
13242
|
+
logger.error(`[${ds.session.sessionId.slice(0, 8)}] Failed to persist buffered activation successor: `
|
|
13243
|
+
+ `${err instanceof Error ? err.message : String(err)}`);
|
|
13244
|
+
return false;
|
|
13245
|
+
}
|
|
13246
|
+
// Ownership has moved into the durable exact FIFO. Clear the runtime source
|
|
13247
|
+
// only after its store write succeeds.
|
|
13248
|
+
ds.pendingFollowUps = undefined;
|
|
13249
|
+
ds.pendingFollowUpTurnIds = undefined;
|
|
13250
|
+
ds.pendingCodexAppFollowUps = undefined;
|
|
13251
|
+
ds.pendingCodexAppFollowUpContexts = undefined;
|
|
13252
|
+
ds.pendingCodexAppFollowUpGateAccepted = undefined;
|
|
13253
|
+
ds.pendingCodexAppApplicationContext = undefined;
|
|
13254
|
+
}
|
|
13255
|
+
// Migrate pre-durable runtime tails (and tests/rolling-upgrade state) before
|
|
13256
|
+
// advancing. Persistence happens before the volatile cursor is cleared.
|
|
13257
|
+
for (const staged of ds.pendingQueuedActivationFollowUps ?? []) {
|
|
13258
|
+
const reservation = reserveQueuedActivationTailAdmission(ds);
|
|
13259
|
+
try {
|
|
13260
|
+
admitQueuedActivationTail(ds, {
|
|
13261
|
+
userPrompt: staged.userPrompt,
|
|
13262
|
+
cliInput: staged.cliInput,
|
|
13263
|
+
turnId: staged.turnId,
|
|
13264
|
+
dispatchAttempt: staged.dispatchAttempt,
|
|
13265
|
+
}, reservation, { codexAppInputGateFrozen: true });
|
|
13266
|
+
}
|
|
13267
|
+
catch (err) {
|
|
13268
|
+
logger.error(`[${ds.session.sessionId.slice(0, 8)}] Failed to migrate activation successor: `
|
|
13269
|
+
+ `${err instanceof Error ? err.message : String(err)}`);
|
|
13270
|
+
return false;
|
|
13271
|
+
}
|
|
13272
|
+
}
|
|
13273
|
+
ds.pendingQueuedActivationFollowUps = undefined;
|
|
13274
|
+
if ((ds.session.queuedActivationTail?.length ?? 0) === 0) {
|
|
13275
|
+
clearInitialStartBuffers(ds);
|
|
13276
|
+
clearInitialStartClaim(ds);
|
|
13277
|
+
return true;
|
|
13278
|
+
}
|
|
13279
|
+
const head = [...ds.session.queuedActivationTail]
|
|
13280
|
+
.sort((a, b) => a.order - b.order || a.id.localeCompare(b.id))[0];
|
|
13281
|
+
const workerUnavailable = !ds.worker
|
|
13282
|
+
|| ds.worker.killed;
|
|
13283
|
+
if (!promoteQueuedActivationTail(ds, workerUnavailable ? { send: false } : {}))
|
|
13284
|
+
return false;
|
|
13285
|
+
try {
|
|
13286
|
+
rememberLastCliInput(ds, head.userPrompt, head.cliInput, {
|
|
13287
|
+
codexAppInputAccepted: !!head.cliInput.codexAppInput,
|
|
13288
|
+
});
|
|
13289
|
+
}
|
|
13290
|
+
catch (err) {
|
|
13291
|
+
logger.error(`[${ds.session.sessionId.slice(0, 8)}] Failed to persist promoted activation metadata: `
|
|
13292
|
+
+ `${err instanceof Error ? err.message : String(err)}`);
|
|
13293
|
+
}
|
|
13294
|
+
clearInitialStartBuffers(ds);
|
|
13295
|
+
if (workerUnavailable) {
|
|
13296
|
+
// N's child can exit after its ACK while N+1 is still being prepared. Park
|
|
13297
|
+
// the late exact head in the durable journal, but release the runtime claim
|
|
13298
|
+
// so the next inbound turn can refork that head before accepting N+2.
|
|
13299
|
+
clearInitialStartClaim(ds);
|
|
13300
|
+
}
|
|
13301
|
+
return true;
|
|
13302
|
+
}
|
|
13303
|
+
export const __testOnly_releaseQueuedActivationReservation = releaseQueuedActivationReservation;
|
|
13304
|
+
/** Exact production callback passed to initWorkerPool. Keep the boolean return:
|
|
13305
|
+
* false means the worker did not accept the staged FIFO head and the pool must
|
|
13306
|
+
* retry instead of silently dropping the activation reservation. */
|
|
13307
|
+
function onQueuedActivationSubmitted(ds, activationToken) {
|
|
13308
|
+
return releaseQueuedActivationReservation(ds, activationToken);
|
|
13309
|
+
}
|
|
13310
|
+
export const __testOnly_onQueuedActivationSubmitted = onQueuedActivationSubmitted;
|
|
13311
|
+
/** Fork an ordinary opening turn and release its route reservation. There is
|
|
13312
|
+
* deliberately no await between the final buffered-input snapshot, fork, and
|
|
13313
|
+
* release: a later handler either buffers before this block or observes the
|
|
13314
|
+
* live worker after it. */
|
|
13315
|
+
function forkReservedInitialSession(ds, availableBots) {
|
|
13316
|
+
const userPrompt = ds.pendingPrompt ?? '';
|
|
13317
|
+
const input = buildReservedInitialInput(ds, availableBots);
|
|
13318
|
+
rememberLastCliInput(ds, userPrompt, input);
|
|
13319
|
+
const turnId = ds.pendingTurnId ?? ds.session.pendingRepoSetup?.turnId;
|
|
13320
|
+
forkWorker(ds, input, turnId ? { turnId } : false);
|
|
13321
|
+
ds.pendingTurnId = undefined;
|
|
13322
|
+
// A no-project pendingRepo fallback enters forkWorker with `session.queued`
|
|
13323
|
+
// still set. forkWorker materializes the exact opening as a tokened
|
|
13324
|
+
// activation journal before returning; keep the route gate until the
|
|
13325
|
+
// adapter-level ACK so a live-worker follower cannot bypass its FIFO tail.
|
|
13326
|
+
const waitsForQueuedAck = ds.session.queuedActivationPending === true;
|
|
13327
|
+
ds.initialStartPending = waitsForQueuedAck
|
|
13328
|
+
|| (ds.session.queuedActivationTail?.length ?? 0) > 0
|
|
13329
|
+
|| (ds.queuedActivationTailAdmissionsOutstanding ?? 0) > 0;
|
|
13330
|
+
clearInitialStartBuffers(ds);
|
|
13331
|
+
if (!waitsForQueuedAck && ds.initialStartPending) {
|
|
13332
|
+
// A follower may have reserved FIFO order while the opening prompt was
|
|
13333
|
+
// still being built. Hand off now, or defer until its reservation settles.
|
|
13334
|
+
void releaseQueuedActivationReservation(ds);
|
|
13335
|
+
}
|
|
13336
|
+
}
|
|
13337
|
+
/** Raw slash-command cold starts type the raw command only after prompt_ready.
|
|
13338
|
+
* Buffer later same-anchor messages into the ordered follow-up payload carried
|
|
13339
|
+
* on that same raw_input IPC; a separate worker message could overtake the
|
|
13340
|
+
* command's text→Enter beat. */
|
|
13341
|
+
function forkReservedInitialRawSession(ds, availableBots) {
|
|
13342
|
+
const hasBufferedInput = (ds.pendingPrompt?.trim().length ?? 0) > 0
|
|
13343
|
+
|| (ds.pendingAttachments?.length ?? 0) > 0
|
|
13344
|
+
|| (ds.pendingFollowUps?.length ?? 0) > 0;
|
|
13345
|
+
if (hasBufferedInput) {
|
|
13346
|
+
ensureSessionWhiteboard(ds);
|
|
13347
|
+
const input = buildReservedInitialInput(ds, availableBots);
|
|
13348
|
+
const botCfg = getBot(ds.larkAppId).config;
|
|
13349
|
+
const bufferedGateDecisions = ds.pendingCodexAppFollowUpGateAccepted ?? [];
|
|
13350
|
+
const bufferedCodexAppInputAccepted = bufferedGateDecisions.length > 0
|
|
13351
|
+
? bufferedGateDecisions.every(Boolean)
|
|
13352
|
+
: codexAppCleanInputAcceptedForSession(ds);
|
|
13353
|
+
ds.pendingFollowUpInput = {
|
|
13354
|
+
userPrompt: ds.pendingCodexAppText !== undefined || ds.pendingCodexAppFollowUps
|
|
13355
|
+
? [ds.pendingCodexAppText ?? '', ...(ds.pendingCodexAppFollowUps ?? [])].filter(Boolean).join('\n\n')
|
|
13356
|
+
: ds.pendingPrompt || ds.pendingFollowUps?.join('\n\n') || '',
|
|
13357
|
+
cliInput: input.content,
|
|
13358
|
+
...((ds.session.cliId === 'codex-app' || botCfg.cliId === 'codex-app')
|
|
13359
|
+
&& bufferedCodexAppInputAccepted
|
|
13360
|
+
&& input.codexAppInput
|
|
13361
|
+
? { codexAppInput: input.codexAppInput }
|
|
13362
|
+
: {}),
|
|
13363
|
+
codexAppInputGateFrozen: true,
|
|
13364
|
+
};
|
|
13365
|
+
}
|
|
13366
|
+
const rawInput = ds.pendingRawInput ?? '';
|
|
13367
|
+
rememberLastCliInput(ds, rawInput, rawInput);
|
|
13368
|
+
// A non-owner turn can reserve/admit a durable successor while this literal
|
|
13369
|
+
// raw opening is still preparing. Arm the same tokened activation journal
|
|
13370
|
+
// used by pending-repo raw starts so that successor is released only after
|
|
13371
|
+
// both the raw text and Enter have crossed the adapter boundary.
|
|
13372
|
+
const armRawActivationAck = !ds.session.queuedActivationPending
|
|
13373
|
+
&& ((ds.session.queuedActivationTail?.length ?? 0) > 0
|
|
13374
|
+
|| (ds.queuedActivationTailAdmissionsOutstanding ?? 0) > 0);
|
|
13375
|
+
if (armRawActivationAck)
|
|
13376
|
+
ds.session.queued = true;
|
|
13377
|
+
try {
|
|
13378
|
+
forkWorker(ds, '', false);
|
|
13379
|
+
}
|
|
13380
|
+
catch (err) {
|
|
13381
|
+
// forkWorker's pre-init compensation restores its queued snapshot. This
|
|
13382
|
+
// synthetic marker belongs only to the raw ACK fence, not the Dashboard
|
|
13383
|
+
// backlog, so remove it while retaining pendingRawInput for a real retry.
|
|
13384
|
+
if (armRawActivationAck && ds.session.queued === true) {
|
|
13385
|
+
ds.session.queued = false;
|
|
13386
|
+
sessionStore.updateSession(ds.session);
|
|
13387
|
+
}
|
|
13388
|
+
throw err;
|
|
13389
|
+
}
|
|
13390
|
+
// Raw text and Enter are a single adapter-submission boundary. When this
|
|
13391
|
+
// cold start came through the durable pendingRepo fallback, retain the gate
|
|
13392
|
+
// through that ACK so a follower cannot be sent between those two beats.
|
|
13393
|
+
ds.initialStartPending = ds.session.queuedActivationPending === true;
|
|
13394
|
+
clearInitialStartBuffers(ds);
|
|
13395
|
+
}
|
|
12826
13396
|
async function replyInvalidWorkingDirs(anchor, larkAppId, ds) {
|
|
12827
13397
|
const bot = getBot(larkAppId);
|
|
12828
13398
|
const invalid = invalidWorkingDirs({
|
|
@@ -12832,7 +13402,9 @@ async function replyInvalidWorkingDirs(anchor, larkAppId, ds) {
|
|
|
12832
13402
|
if (invalid.length === 0)
|
|
12833
13403
|
return false;
|
|
12834
13404
|
ds.pendingRepo = false;
|
|
12835
|
-
|
|
13405
|
+
const key = activeSessionKey(ds);
|
|
13406
|
+
if (activeSessions.get(key) === ds)
|
|
13407
|
+
activeSessions.delete(key);
|
|
12836
13408
|
sessionStore.closeSession(ds.session.sessionId);
|
|
12837
13409
|
publishClosedSessionPatch(ds.session.sessionId, ds.session.closedAt ? Date.parse(ds.session.closedAt) : undefined);
|
|
12838
13410
|
const msg = tr('cmd.repo.working_dir_not_exist', {
|
|
@@ -12860,6 +13432,39 @@ function fastToggleUnsupportedBackend(ds) {
|
|
|
12860
13432
|
return true;
|
|
12861
13433
|
return ds.initConfig?.codexRpcInput === true;
|
|
12862
13434
|
}
|
|
13435
|
+
/** Three-state is-bot for a turn's SENDER (candidate display, not routing):
|
|
13436
|
+
* true = provably a bot (platform sender_type app/bot, OR a known peer via the
|
|
13437
|
+
* foreign-bot signal); false = provably human (sender_type === 'user'); and
|
|
13438
|
+
* undefined = unknown (sender_type missing/other AND not a recognised peer) —
|
|
13439
|
+
* must NOT be coerced to "human". Distinct from the routing/owner boolean
|
|
13440
|
+
* `isForeignBot*`, which is false for both "human" and "unknown". */
|
|
13441
|
+
function senderIsBotTriState(senderType, isForeignBot) {
|
|
13442
|
+
if (isForeignBot || senderType === 'app' || senderType === 'bot')
|
|
13443
|
+
return true;
|
|
13444
|
+
if (senderType === 'user')
|
|
13445
|
+
return false;
|
|
13446
|
+
return undefined;
|
|
13447
|
+
}
|
|
13448
|
+
/** Extract post rich-text inline `at` participants from the given message(s),
|
|
13449
|
+
* concatenated (NOT key/name-merged — the core dedupes by open_id, and stable
|
|
13450
|
+
* cross-message identity is open/app id, never the per-message key). Pass the
|
|
13451
|
+
* current message and, when a forward seed folds into the same turn, the seed
|
|
13452
|
+
* too. Kept separate from buildTurnParticipants so a registration-race loser's
|
|
13453
|
+
* winner can carry the ALREADY-extracted seed+follow-up set through prepared
|
|
13454
|
+
* without re-depending on ctx.forwardSeedData. */
|
|
13455
|
+
function collectPostAtMentions(...messages) {
|
|
13456
|
+
return messages.flatMap(m => extractPostAtParticipants(m));
|
|
13457
|
+
}
|
|
13458
|
+
/** Build the turn-window counterparts contributed by ONE inbound turn via the
|
|
13459
|
+
* pure {@link buildTurnParticipantsFrom}, supplying the daemon deps (self
|
|
13460
|
+
* open_id + self app_id for self-exclusion of app_id-form self @s + the
|
|
13461
|
+
* peer-bot predicate). `postAtMentions` are post inline-@ participants already
|
|
13462
|
+
* extracted (see collectPostAtMentions) — concatenated with structured
|
|
13463
|
+
* `mentions`. See that helper for the contract. */
|
|
13464
|
+
function buildTurnParticipants(larkAppId, senderOpenId, senderIsBot, mentions, senderName, postAtMentions) {
|
|
13465
|
+
const selfBot = getBot(larkAppId);
|
|
13466
|
+
return buildTurnParticipantsFrom({ openId: senderOpenId, isBot: senderIsBot, name: senderName }, [...(mentions ?? []), ...(postAtMentions ?? [])], selfBot.botOpenId, (openId) => isKnownPeerBot(config.session.dataDir, larkAppId, openId), selfBot.config.larkAppId);
|
|
13467
|
+
}
|
|
12863
13468
|
/** Preserve the established mid-session passthrough semantics when a cold-start
|
|
12864
13469
|
* scratch loses its registration race to a concurrently-created real session. */
|
|
12865
13470
|
function deliverPassthroughToExistingSession(ds, cmd, commandContent, anchor, larkAppId, turn) {
|
|
@@ -12873,9 +13478,14 @@ function deliverPassthroughToExistingSession(ds, cmd, commandContent, anchor, la
|
|
|
12873
13478
|
const substituteReplyMode = turn.substitute
|
|
12874
13479
|
? (getBot(larkAppId).config.substituteMode?.replyMode ?? 'thread')
|
|
12875
13480
|
: 'thread';
|
|
13481
|
+
// Passthrough is a raw CLI command (no @-mentions) — window is the sender only.
|
|
13482
|
+
const passthroughWindow = buildTurnParticipants(larkAppId, turn.senderOpenId, turn.senderIsBot, undefined);
|
|
12876
13483
|
beginReplyTargetTurn(ds, turn.replyRootId, turn.messageId, new Date().toISOString(), {
|
|
12877
13484
|
quoteOnly: substituteReplyMode === 'quote',
|
|
12878
13485
|
substitute: turn.substitute,
|
|
13486
|
+
senderOpenId: turn.senderOpenId,
|
|
13487
|
+
participants: passthroughWindow.participants,
|
|
13488
|
+
participantsIncomplete: passthroughWindow.incomplete,
|
|
12879
13489
|
});
|
|
12880
13490
|
if (turn.senderOpenId && ds.session.lastCallerOpenId !== turn.senderOpenId) {
|
|
12881
13491
|
ds.session.lastCallerOpenId = turn.senderOpenId;
|
|
@@ -12903,10 +13513,17 @@ function deliverPassthroughToExistingSession(ds, cmd, commandContent, anchor, la
|
|
|
12903
13513
|
}
|
|
12904
13514
|
export const __testOnly_deliverPassthroughToExistingSession = deliverPassthroughToExistingSession;
|
|
12905
13515
|
async function startInitialPassthroughSession(args) {
|
|
12906
|
-
const { larkAppId, chatId, chatType, scope, anchor, messageId, replyRootId, parsed, cmd, commandContent, senderOpenId, substitute, senderUnionId, memberUnionId, ownerOpenId, ownerUnionId, creatorOpenId, botSender, } = args;
|
|
13516
|
+
const { larkAppId, chatId, chatType, scope, anchor, messageId, replyRootId, parsed, cmd, commandContent, senderOpenId, substitute, senderUnionId, memberUnionId, ownerOpenId, ownerUnionId, creatorOpenId, botSender, senderIsBot, routeToCanonicalOwner, } = args;
|
|
12907
13517
|
if (!await enforceMessageQuotaForCliInput(larkAppId, chatId, senderOpenId, messageId, anchor, senderUnionId, memberUnionId, chatType, botSender)) {
|
|
12908
13518
|
return;
|
|
12909
13519
|
}
|
|
13520
|
+
// Reply attribution's is-bot. `resolvedSenderIsBot` is a BOOLEAN for the
|
|
13521
|
+
// legacy consumers that need one (pendingSender.type, quoteTargetSenderIsBot,
|
|
13522
|
+
// loser-handoff turn.senderIsBot). `resolvedSenderIsBotTriState` keeps
|
|
13523
|
+
// "unknown" as undefined for the participant candidate label — never coerce an
|
|
13524
|
+
// unknown sender to "human". NOT for quota (that stays on botSender above).
|
|
13525
|
+
const resolvedSenderIsBotTriState = senderIsBotTriState(parsed.senderType, senderIsBot === true);
|
|
13526
|
+
const resolvedSenderIsBot = senderIsBot ?? (parsed.senderType === 'app' || parsed.senderType === 'bot');
|
|
12910
13527
|
const botCfg = getBot(larkAppId).config;
|
|
12911
13528
|
refreshCliVersion(botCfg);
|
|
12912
13529
|
const directChatSender = chatType === 'p2p'
|
|
@@ -12918,7 +13535,7 @@ async function startInitialPassthroughSession(args) {
|
|
|
12918
13535
|
const initialPassthroughSender = directChatSender ?? (senderOpenId
|
|
12919
13536
|
? {
|
|
12920
13537
|
openId: senderOpenId,
|
|
12921
|
-
type:
|
|
13538
|
+
type: resolvedSenderIsBot ? 'bot' : 'user',
|
|
12922
13539
|
}
|
|
12923
13540
|
: undefined);
|
|
12924
13541
|
const rootIdForStore = scope === 'thread' ? anchor : messageId;
|
|
@@ -12932,12 +13549,10 @@ async function startInitialPassthroughSession(args) {
|
|
|
12932
13549
|
session.lastCallerOpenId = senderOpenId;
|
|
12933
13550
|
session.quoteTargetId = parsed.messageId;
|
|
12934
13551
|
session.quoteTargetSenderOpenId = senderOpenId;
|
|
12935
|
-
session.quoteTargetSenderIsBot =
|
|
13552
|
+
session.quoteTargetSenderIsBot = resolvedSenderIsBot;
|
|
12936
13553
|
session.lastMessageAt = new Date(now).toISOString();
|
|
12937
13554
|
session.scope = scope;
|
|
12938
13555
|
sessionStore.updateSession(session);
|
|
12939
|
-
messageQueue.ensureQueue(anchor);
|
|
12940
|
-
messageQueue.appendMessage(anchor, { ...parsed, content: commandContent });
|
|
12941
13556
|
const { pinnedWorkingDir, oncallEntry, inheritedFrom, pinnedFromBotDefault } = await resolvePinnedWorkingDir({ scope, anchor, chatId, chatType, larkAppId });
|
|
12942
13557
|
// Auto-worktree: register PENDING (router buffers, no force-fork) and build the
|
|
12943
13558
|
// worktree off the critical path (see willAutoWorktree / runAutoWorktreeCommit).
|
|
@@ -12955,6 +13570,7 @@ async function startInitialPassthroughSession(args) {
|
|
|
12955
13570
|
cliVersion: cliVersionCache.get(cliRuntimeVersionKey(botCfg))?.version ?? 'unknown',
|
|
12956
13571
|
lastMessageAt: now,
|
|
12957
13572
|
hasHistory: false,
|
|
13573
|
+
initialStartPending: true,
|
|
12958
13574
|
pendingRepo: !pinnedWorkingDir || autoWt,
|
|
12959
13575
|
pendingPrompt: '',
|
|
12960
13576
|
pendingRawInput: commandContent,
|
|
@@ -12968,30 +13584,30 @@ async function startInitialPassthroughSession(args) {
|
|
|
12968
13584
|
ds.session.workingDir = pinnedWorkingDir;
|
|
12969
13585
|
sessionStore.updateSession(ds.session);
|
|
12970
13586
|
}
|
|
12971
|
-
|
|
13587
|
+
const initialWindow = buildTurnParticipants(larkAppId, senderOpenId, resolvedSenderIsBotTriState, undefined, initialPassthroughSender?.name);
|
|
13588
|
+
beginReplyTargetTurn(ds, replyRootId, messageId, new Date().toISOString(), { senderOpenId, participants: initialWindow.participants, participantsIncomplete: initialWindow.incomplete });
|
|
12972
13589
|
sessionStore.updateSession(ds.session);
|
|
12973
|
-
const
|
|
12974
|
-
if (!
|
|
12975
|
-
|
|
12976
|
-
|
|
12977
|
-
|
|
12978
|
-
`${winner.session.sessionId.substring(0, 8)}; handing ${cmd} to routing winner`);
|
|
12979
|
-
deliverPassthroughToExistingSession(winner, cmd, commandContent, anchor, larkAppId, {
|
|
12980
|
-
messageId,
|
|
12981
|
-
replyRootId,
|
|
12982
|
-
senderOpenId,
|
|
12983
|
-
senderIsBot: parsed.senderType === 'app' || parsed.senderType === 'bot',
|
|
12984
|
-
substitute,
|
|
12985
|
-
});
|
|
12986
|
-
}
|
|
12987
|
-
else {
|
|
12988
|
-
void sessionReply(anchor, tr('daemon.cmd_needs_active_cli', { cmd }, localeForBot(larkAppId)), 'text', larkAppId);
|
|
13590
|
+
const registration = await claimNewDaemonSession(activeSessions, ds);
|
|
13591
|
+
if (!registration.accepted) {
|
|
13592
|
+
if (registration.reason === 'existing_owner'
|
|
13593
|
+
&& activeSessions.get(registration.key) === registration.owner) {
|
|
13594
|
+
await routeToCanonicalOwner();
|
|
12989
13595
|
}
|
|
12990
13596
|
return;
|
|
12991
13597
|
}
|
|
13598
|
+
if (ds.pendingRepo) {
|
|
13599
|
+
stageClaimedPendingRepoSetup(activeSessions, ds, {
|
|
13600
|
+
mode: autoWt ? 'auto_worktree' : 'picker',
|
|
13601
|
+
...(autoWt && pinnedWorkingDir ? { baseDir: pinnedWorkingDir } : {}),
|
|
13602
|
+
turnId: messageId,
|
|
13603
|
+
});
|
|
13604
|
+
}
|
|
13605
|
+
messageQueue.ensureQueue(anchor);
|
|
13606
|
+
messageQueue.appendMessage(anchor, { ...parsed, content: commandContent });
|
|
12992
13607
|
if (pinnedWorkingDir && autoWt) {
|
|
12993
13608
|
if (await replyInvalidWorkingDirs(anchor, larkAppId, ds))
|
|
12994
13609
|
return;
|
|
13610
|
+
ds.initialStartPending = false; // pendingRepo/worktree now owns buffering
|
|
12995
13611
|
// 挂起态提交:worktree 建好后经 runAutoWorktreeCommit → commitRepoSelection 拉起
|
|
12996
13612
|
// pendingRawInput 冷启动会话。detach → 立即返回,不阻塞本条消息处理。
|
|
12997
13613
|
startAutoWorktreePending(ds, { anchor, baseDir: pinnedWorkingDir, title: session.title, prompt: commandContent, operatorOpenId: ownerOpenId });
|
|
@@ -13000,10 +13616,8 @@ async function startInitialPassthroughSession(args) {
|
|
|
13000
13616
|
if (pinnedWorkingDir) {
|
|
13001
13617
|
if (await replyInvalidWorkingDirs(anchor, larkAppId, ds))
|
|
13002
13618
|
return;
|
|
13003
|
-
|
|
13004
|
-
|
|
13005
|
-
// boundary (prompt_ready -> raw_input), never during CLI startup.
|
|
13006
|
-
forkWorker(ds, '', false);
|
|
13619
|
+
const availableBots = await getAvailableBots(larkAppId, chatId);
|
|
13620
|
+
forkReservedInitialRawSession(ds, availableBots);
|
|
13007
13621
|
const reason = oncallEntry
|
|
13008
13622
|
? `oncall-bound chat ${chatId}`
|
|
13009
13623
|
: inheritedFrom
|
|
@@ -13017,16 +13631,18 @@ async function startInitialPassthroughSession(args) {
|
|
|
13017
13631
|
const scanDirs = getProjectScanDirs(ds).filter(d => existsSync(d));
|
|
13018
13632
|
const projects = scanDirs.length > 0 ? scanMultipleProjects(scanDirs, 3, repoPickerScanOptions()) : [];
|
|
13019
13633
|
if (projects.length > 0) {
|
|
13634
|
+
ds.initialStartPending = false; // pendingRepo/card now owns buffering
|
|
13020
13635
|
lastRepoScan.set(chatId, projects);
|
|
13021
13636
|
const cardJson = buildRepoSelectCard(projects, getSessionWorkingDir(ds), anchor, localeForBot(larkAppId), getBot(larkAppId).config.worktreeMultiPicker);
|
|
13022
13637
|
ds.repoCardMessageId = await sessionReply(anchor, cardJson, 'interactive', larkAppId);
|
|
13638
|
+
persistPendingRepoCardMessageId(ds, ds.repoCardMessageId);
|
|
13023
13639
|
announcePendingRepoSession(ds);
|
|
13024
13640
|
logger.info(`[${tag(ds)}] Waiting for repo selection before initial raw passthrough (${projects.length} projects)`);
|
|
13025
13641
|
return;
|
|
13026
13642
|
}
|
|
13027
13643
|
ds.pendingRepo = false;
|
|
13028
|
-
|
|
13029
|
-
|
|
13644
|
+
const availableBots = await getAvailableBots(larkAppId, chatId);
|
|
13645
|
+
forkReservedInitialRawSession(ds, availableBots);
|
|
13030
13646
|
logger.info(`[${tag(ds)}] No projects to select, queued initial raw passthrough ${commandContent.substring(0, 40)}`);
|
|
13031
13647
|
}
|
|
13032
13648
|
function vcMeetingApplicationContext(ctx) {
|
|
@@ -13046,6 +13662,34 @@ function mergeVcMeetingApplicationContext(existing, incoming) {
|
|
|
13046
13662
|
return `${[...new Set(lines)].join('\n')}\n`;
|
|
13047
13663
|
}
|
|
13048
13664
|
async function handleNewTopic(data, ctx) {
|
|
13665
|
+
return withBotTurnAdmission(ctx.larkAppId, () => handleNewTopicAdmitted(data, ctx));
|
|
13666
|
+
}
|
|
13667
|
+
/**
|
|
13668
|
+
* Codex App steer authorization (Blocking 1, decision A) from INBOUND SOURCE
|
|
13669
|
+
* FACTS only — the single source of truth shared by both admission twins
|
|
13670
|
+
* (handleNewTopicAdmitted + handleThreadReplyAdmitted), so a plain-human turn is
|
|
13671
|
+
* authorized identically whether it opens a new topic or continues one (R6/R7-B1).
|
|
13672
|
+
*
|
|
13673
|
+
* FAIL-CLOSED by construction (R7-B1): authorization requires a POSITIVE
|
|
13674
|
+
* `humanSender` (senderType === 'user' AND not a known peer bot) AND the absence
|
|
13675
|
+
* of every control-rewrite / dedicated-receiver signal. Excluding a list of
|
|
13676
|
+
* known non-human sources is NOT enough — an exclude-list is never complete, so
|
|
13677
|
+
* any un-enumerated non-user source would fail OPEN. The positive humanSender
|
|
13678
|
+
* gate makes the default deny: anything that is not provably a real human turn
|
|
13679
|
+
* with no special semantics stays forced-serial.
|
|
13680
|
+
*/
|
|
13681
|
+
function computeCodexAppSteerable(facts) {
|
|
13682
|
+
return facts.humanSender
|
|
13683
|
+
&& !facts.adopted
|
|
13684
|
+
&& !facts.isForeignBot
|
|
13685
|
+
&& !facts.isBotSenderType
|
|
13686
|
+
&& !facts.substituteTrigger
|
|
13687
|
+
&& !facts.controlRewrite
|
|
13688
|
+
&& !facts.messageListener
|
|
13689
|
+
&& !facts.vcMeetingReceiver
|
|
13690
|
+
&& !facts.vcMeetingImTurnOrigin;
|
|
13691
|
+
}
|
|
13692
|
+
async function handleNewTopicAdmitted(data, ctx) {
|
|
13049
13693
|
const { chatId, messageId, chatType, larkAppId, replyRootId, substituteTrigger, messageListener } = ctx;
|
|
13050
13694
|
// scope/anchor are mutable here: `/t` / `/topic` may flip a 普通群 chat-scope
|
|
13051
13695
|
// routing into thread-scope so the bot's first reply seeds a Lark thread.
|
|
@@ -13260,11 +13904,20 @@ async function handleNewTopic(data, ctx) {
|
|
|
13260
13904
|
senderUnionId: teamTrustUnionId,
|
|
13261
13905
|
memberUnionId: senderUnionId, // 原始 union(人腿),不锁 bot
|
|
13262
13906
|
botSender: isBotSenderType,
|
|
13907
|
+
// Reply attribution uses the cross-ref-resolved is-bot (foreign peer
|
|
13908
|
+
// bots included), matching the twin new-topic spawn path so冷启动
|
|
13909
|
+
// passthrough 也能让 bot→bot 的 --mention-back 直通不对称门禁。
|
|
13910
|
+
senderIsBot: isForeignBotSender,
|
|
13263
13911
|
// New-topic senders are humans here (mirrors the normal new-topic
|
|
13264
13912
|
// spawn path, which assigns ownership unconditionally too).
|
|
13265
13913
|
ownerOpenId: senderOpenId,
|
|
13266
13914
|
ownerUnionId: senderUnionId,
|
|
13267
13915
|
creatorOpenId: senderOpenId,
|
|
13916
|
+
routeToCanonicalOwner: () => handleThreadReplyAdmitted(data, {
|
|
13917
|
+
...ctx,
|
|
13918
|
+
scope,
|
|
13919
|
+
anchor,
|
|
13920
|
+
}),
|
|
13268
13921
|
});
|
|
13269
13922
|
return;
|
|
13270
13923
|
}
|
|
@@ -13338,7 +13991,7 @@ async function handleNewTopic(data, ctx) {
|
|
|
13338
13991
|
cmdPending = { pendingRepo: true, pendingPrompt: '', workingDir: pinnedWorkingDir };
|
|
13339
13992
|
}
|
|
13340
13993
|
sessionStore.updateSession(session);
|
|
13341
|
-
const
|
|
13994
|
+
const cmdDs = {
|
|
13342
13995
|
session,
|
|
13343
13996
|
worker: null,
|
|
13344
13997
|
workerPort: null,
|
|
@@ -13354,13 +14007,16 @@ async function handleNewTopic(data, ctx) {
|
|
|
13354
14007
|
ownerOpenId: senderOpenId,
|
|
13355
14008
|
...cmdPending,
|
|
13356
14009
|
};
|
|
13357
|
-
const
|
|
13358
|
-
if (!
|
|
13359
|
-
|
|
13360
|
-
if (!winner)
|
|
14010
|
+
const registration = await claimNewDaemonSession(activeSessions, cmdDs);
|
|
14011
|
+
if (!registration.accepted) {
|
|
14012
|
+
if (registration.reason !== 'existing_owner')
|
|
13361
14013
|
return;
|
|
13362
|
-
|
|
13363
|
-
|
|
14014
|
+
}
|
|
14015
|
+
else if (cmdDs.pendingRepo) {
|
|
14016
|
+
stageClaimedPendingRepoSetup(activeSessions, cmdDs, {
|
|
14017
|
+
mode: 'picker',
|
|
14018
|
+
turnId: messageId,
|
|
14019
|
+
});
|
|
13364
14020
|
}
|
|
13365
14021
|
// Pass mention-stripped content so /command argument parsing works.
|
|
13366
14022
|
await handleCommand(cmd, anchor, { ...parsed, content: commandContent }, commandDeps, larkAppId);
|
|
@@ -13462,8 +14118,6 @@ async function handleNewTopic(data, ctx) {
|
|
|
13462
14118
|
session.scope = scope;
|
|
13463
14119
|
session.nativeSessionTitle = buildBotmuxLarkNativeSessionTitle(parsed.content, parsed.mentions, groupChatName);
|
|
13464
14120
|
sessionStore.updateSession(session);
|
|
13465
|
-
messageQueue.ensureQueue(anchor);
|
|
13466
|
-
messageQueue.appendMessage(anchor, parsed);
|
|
13467
14121
|
// Control card compensates for the card-less (avatar-style) chat-scope
|
|
13468
14122
|
// substitute session. Topic-group substitute sessions (#475) are thread-scope
|
|
13469
14123
|
// and keep their normal streaming card — no compensation needed there.
|
|
@@ -13484,6 +14138,7 @@ async function handleNewTopic(data, ctx) {
|
|
|
13484
14138
|
cliVersion: cliVersionCache.get(cliRuntimeVersionKey(botCfg))?.version ?? 'unknown',
|
|
13485
14139
|
lastMessageAt: now,
|
|
13486
14140
|
hasHistory: false,
|
|
14141
|
+
initialStartPending: true,
|
|
13487
14142
|
pendingRepo: !pinnedWorkingDir || autoWt,
|
|
13488
14143
|
pendingPrompt: promptContent,
|
|
13489
14144
|
pendingTurnId: messageId,
|
|
@@ -13495,6 +14150,29 @@ async function handleNewTopic(data, ctx) {
|
|
|
13495
14150
|
pendingSubstituteTrigger: substituteTrigger,
|
|
13496
14151
|
pendingSubstituteControlCard: shouldSendSubstituteControlCard,
|
|
13497
14152
|
pendingSender: newTopicSender,
|
|
14153
|
+
// R6-B1: freeze the plain-human steer authorization into the NEW-TOPIC opening
|
|
14154
|
+
// metadata from inbound source facts (the same helper the follow-up twin uses).
|
|
14155
|
+
// A brand-new session has no VC receiver yet; foreign-bot maps to the bot-sender
|
|
14156
|
+
// fact here. buildReservedInitialInput COPIES this onto the opening payload;
|
|
14157
|
+
// forkReservedInitialSession never infers it.
|
|
14158
|
+
...(computeCodexAppSteerable({
|
|
14159
|
+
// R7-B1: positive real-human gate — senderType must be 'user' AND not a
|
|
14160
|
+
// known peer bot (cross-ref fallback, matching the thread twin's foreign-bot
|
|
14161
|
+
// definition; an anomalous/missing sender_type from a known peer must not
|
|
14162
|
+
// be authorized). controlRewrite reflects the ACTUAL v3-grill trigger, not
|
|
14163
|
+
// a hardcoded false — a rewritten /workflow prompt stays serial.
|
|
14164
|
+
humanSender: parsed.senderType === 'user'
|
|
14165
|
+
&& !isKnownPeerBot(config.session.dataDir, larkAppId, senderOpenId),
|
|
14166
|
+
adopted: false,
|
|
14167
|
+
isForeignBot: isBotSenderType
|
|
14168
|
+
|| isKnownPeerBot(config.session.dataDir, larkAppId, senderOpenId),
|
|
14169
|
+
isBotSenderType,
|
|
14170
|
+
substituteTrigger: !!substituteTrigger,
|
|
14171
|
+
controlRewrite: !!newTopicGrill,
|
|
14172
|
+
messageListener: !!messageListener,
|
|
14173
|
+
vcMeetingReceiver: false,
|
|
14174
|
+
vcMeetingImTurnOrigin: !!ctx.vcMeetingImTurnOrigin,
|
|
14175
|
+
}) ? { pendingCodexAppSteerable: true } : {}),
|
|
13498
14176
|
ownerOpenId: ownerOpenIdForSession,
|
|
13499
14177
|
currentTurnTitle: initialTurnTitle,
|
|
13500
14178
|
workingDir: pinnedWorkingDir,
|
|
@@ -13506,32 +14184,54 @@ async function handleNewTopic(data, ctx) {
|
|
|
13506
14184
|
const substituteReplyMode = substituteTrigger
|
|
13507
14185
|
? (botCfg.substituteMode?.replyMode ?? 'thread')
|
|
13508
14186
|
: 'thread';
|
|
13509
|
-
|
|
14187
|
+
// Post inline-@ participants from BOTH the current message and a folded
|
|
14188
|
+
// forward seed — extracted once so the CAS-loser handoff below can carry the
|
|
14189
|
+
// exact same set (a race-losing scratch's seed @s must not vanish).
|
|
14190
|
+
const newTopicPostAt = collectPostAtMentions(data?.message, ctx.forwardSeedData?.message);
|
|
14191
|
+
const newTopicWindow = buildTurnParticipants(larkAppId, senderOpenId, senderIsBotTriState(parsed.senderType, isForeignBotSender), parsed.mentions, newTopicSender?.name, newTopicPostAt);
|
|
14192
|
+
beginReplyTargetTurn(ds, replyRootId, messageId, new Date().toISOString(), { quoteOnly: substituteReplyMode === 'quote', substitute: !!substituteTrigger, senderOpenId, participants: newTopicWindow.participants, participantsIncomplete: newTopicWindow.incomplete });
|
|
13510
14193
|
sessionStore.updateSession(ds.session);
|
|
13511
|
-
const
|
|
13512
|
-
if (!
|
|
13513
|
-
|
|
13514
|
-
|
|
13515
|
-
|
|
13516
|
-
|
|
13517
|
-
|
|
14194
|
+
const registration = await claimNewDaemonSession(activeSessions, ds);
|
|
14195
|
+
if (!registration.accepted) {
|
|
14196
|
+
if (registration.reason === 'existing_owner'
|
|
14197
|
+
&& activeSessions.get(registration.key) === registration.owner) {
|
|
14198
|
+
// Registration-race loser → reroute to the canonical owner. Pass the FULLY
|
|
14199
|
+
// PREPARED reply (merged seed+follow-up structured mentions, resources,
|
|
14200
|
+
// attachments, resolved sender, pre-extracted post @s) so the winner rebinds
|
|
14201
|
+
// the turn with the COMPLETE window. Rerouting bare `handleThreadReplyAdmitted
|
|
14202
|
+
// (data, ctx)` would re-parse only `data` and drop the forward seed's
|
|
14203
|
+
// structured message.mentions[] (post @s alone are not enough) — under-
|
|
14204
|
+
// counting the turn-window and mis-releasing --mention-back. Direct-admitted
|
|
14205
|
+
// (not handleThreadReply) so we don't re-enter the #597 admission lock.
|
|
14206
|
+
await handleThreadReplyAdmitted(data, { ...ctx, scope, anchor }, {
|
|
13518
14207
|
parsed,
|
|
13519
14208
|
resources,
|
|
13520
14209
|
attachments,
|
|
13521
14210
|
quotaChecked: true,
|
|
13522
|
-
queueAlreadyAppended:
|
|
14211
|
+
queueAlreadyAppended: false,
|
|
13523
14212
|
senderResolved: true,
|
|
13524
14213
|
sender: newTopicSender,
|
|
14214
|
+
postParticipantMentions: newTopicPostAt,
|
|
13525
14215
|
});
|
|
13526
14216
|
}
|
|
13527
14217
|
return;
|
|
13528
14218
|
}
|
|
14219
|
+
if (ds.pendingRepo) {
|
|
14220
|
+
stageClaimedPendingRepoSetup(activeSessions, ds, {
|
|
14221
|
+
mode: autoWt ? 'auto_worktree' : 'picker',
|
|
14222
|
+
...(autoWt && pinnedWorkingDir ? { baseDir: pinnedWorkingDir } : {}),
|
|
14223
|
+
turnId: messageId,
|
|
14224
|
+
});
|
|
14225
|
+
}
|
|
14226
|
+
messageQueue.ensureQueue(anchor);
|
|
14227
|
+
messageQueue.appendMessage(anchor, parsed);
|
|
13529
14228
|
// Auto-worktree: session is registered PENDING; build the worktree off the
|
|
13530
14229
|
// critical path, then commitRepoSelection pins it + forks (folding in any
|
|
13531
14230
|
// messages buffered during creation). detach → return immediately.
|
|
13532
14231
|
if (pinnedWorkingDir && autoWt) {
|
|
13533
14232
|
if (await replyInvalidWorkingDirs(anchor, larkAppId, ds))
|
|
13534
14233
|
return;
|
|
14234
|
+
ds.initialStartPending = false; // pendingRepo/worktree now owns buffering
|
|
13535
14235
|
startAutoWorktreePending(ds, { anchor, baseDir: pinnedWorkingDir, title: session.title, prompt: promptContent, operatorOpenId: senderOpenId });
|
|
13536
14236
|
return;
|
|
13537
14237
|
}
|
|
@@ -13539,13 +14239,10 @@ async function handleNewTopic(data, ctx) {
|
|
|
13539
14239
|
if (pinnedWorkingDir) {
|
|
13540
14240
|
if (await replyInvalidWorkingDirs(anchor, larkAppId, ds))
|
|
13541
14241
|
return;
|
|
13542
|
-
const selfBot = getBot(larkAppId);
|
|
13543
14242
|
ensureSessionWhiteboard(ds);
|
|
13544
|
-
const
|
|
14243
|
+
const availableBots = await getAvailableBots(larkAppId, chatId);
|
|
13545
14244
|
await noteTurnReceived(ds, messageId, content, newTopicSender, messageId, substituteTrigger ? SUBSTITUTE_RECEIVED_REACTION_EMOJI_TYPE : undefined);
|
|
13546
|
-
|
|
13547
|
-
forkWorker(ds, prompt, { turnId: messageId });
|
|
13548
|
-
ds.pendingTurnId = undefined;
|
|
14245
|
+
forkReservedInitialSession(ds, availableBots);
|
|
13549
14246
|
const reason = oncallEntry
|
|
13550
14247
|
? `oncall-bound chat ${chatId}`
|
|
13551
14248
|
: inheritedFrom
|
|
@@ -13563,23 +14260,22 @@ async function handleNewTopic(data, ctx) {
|
|
|
13563
14260
|
projects = scanMultipleProjects(scanDirs, 3, repoPickerScanOptions());
|
|
13564
14261
|
}
|
|
13565
14262
|
if (projects.length > 0) {
|
|
14263
|
+
ds.initialStartPending = false; // pendingRepo/card now owns buffering
|
|
13566
14264
|
lastRepoScan.set(chatId, projects);
|
|
13567
14265
|
const currentCwd = getSessionWorkingDir(ds);
|
|
13568
14266
|
const cardJson = buildRepoSelectCard(projects, currentCwd, anchor, localeForBot(larkAppId), getBot(larkAppId).config.worktreeMultiPicker);
|
|
13569
14267
|
ds.repoCardMessageId = await sessionReply(anchor, cardJson, 'interactive', larkAppId);
|
|
14268
|
+
persistPendingRepoCardMessageId(ds, ds.repoCardMessageId);
|
|
13570
14269
|
announcePendingRepoSession(ds);
|
|
13571
14270
|
logger.info(`[${tag(ds)}] Waiting for repo selection (${projects.length} projects)`);
|
|
13572
14271
|
}
|
|
13573
14272
|
else {
|
|
13574
14273
|
// No projects found — skip repo selection, spawn directly
|
|
13575
14274
|
ds.pendingRepo = false;
|
|
13576
|
-
const selfBot = getBot(larkAppId);
|
|
13577
14275
|
ensureSessionWhiteboard(ds);
|
|
13578
|
-
const
|
|
14276
|
+
const availableBots = await getAvailableBots(larkAppId, chatId);
|
|
13579
14277
|
await noteTurnReceived(ds, messageId, content, newTopicSender, messageId, substituteTrigger ? SUBSTITUTE_RECEIVED_REACTION_EMOJI_TYPE : undefined);
|
|
13580
|
-
|
|
13581
|
-
forkWorker(ds, prompt, { turnId: messageId });
|
|
13582
|
-
ds.pendingTurnId = undefined;
|
|
14278
|
+
forkReservedInitialSession(ds, availableBots);
|
|
13583
14279
|
logger.info(`Session ${session.sessionId} ready (no projects to select), total active: ${getActiveCount()}`);
|
|
13584
14280
|
}
|
|
13585
14281
|
}
|
|
@@ -13814,6 +14510,7 @@ async function handleBotAdded(chatId, operatorOpenId, larkAppId, opts) {
|
|
|
13814
14510
|
cliVersion: cliVersionCache.get(cliRuntimeVersionKey(botCfg))?.version ?? 'unknown',
|
|
13815
14511
|
lastMessageAt: now,
|
|
13816
14512
|
hasHistory: false,
|
|
14513
|
+
initialStartPending: true,
|
|
13817
14514
|
pendingRepo: !pinnedWorkingDir || autoWt,
|
|
13818
14515
|
pendingPrompt: promptBody,
|
|
13819
14516
|
pendingCodexAppText: botCfg.cliId === 'codex-app' ? codexAppText : undefined,
|
|
@@ -13823,10 +14520,20 @@ async function handleBotAdded(chatId, operatorOpenId, larkAppId, opts) {
|
|
|
13823
14520
|
workingDir: pinnedWorkingDir,
|
|
13824
14521
|
pendingTurnId: undefined,
|
|
13825
14522
|
};
|
|
13826
|
-
|
|
13827
|
-
|
|
14523
|
+
const registration = await claimNewDaemonSession(activeSessions, ds);
|
|
14524
|
+
if (!registration.accepted) {
|
|
14525
|
+
if (activeSessions.get(registration.key) === registration.owner) {
|
|
14526
|
+
groupJoinAnchorByChat.set(chatLiveKey, registration.key);
|
|
14527
|
+
}
|
|
13828
14528
|
return;
|
|
13829
14529
|
}
|
|
14530
|
+
if (ds.pendingRepo) {
|
|
14531
|
+
stageClaimedPendingRepoSetup(activeSessions, ds, {
|
|
14532
|
+
mode: autoWt ? 'auto_worktree' : 'picker',
|
|
14533
|
+
...(autoWt && pinnedWorkingDir ? { baseDir: pinnedWorkingDir } : {}),
|
|
14534
|
+
turnId: anchor,
|
|
14535
|
+
});
|
|
14536
|
+
}
|
|
13830
14537
|
const joinBootstrapExternallyTakenOver = () => (activeSessions.get(dsKey) !== ds
|
|
13831
14538
|
|| ds.session.status !== 'active'
|
|
13832
14539
|
|| !!(ds.worker && !ds.worker.killed));
|
|
@@ -13921,14 +14628,13 @@ async function handleBotAdded(chatId, operatorOpenId, larkAppId, opts) {
|
|
|
13921
14628
|
// Register the anchor so a later duplicate bot.added for this chat is deduped
|
|
13922
14629
|
// even in 话题群 (where dsKey is the seed id, not chatId).
|
|
13923
14630
|
groupJoinAnchorByChat.set(chatLiveKey, dsKey);
|
|
13924
|
-
const selfBot = getBot(larkAppId);
|
|
13925
|
-
const buildPrompt = async () => buildNewTopicCliInput(promptBody, session.sessionId, botCfg.cliId, botCfg.cliPathOverride, undefined, undefined, await getAvailableBots(larkAppId, chatId), undefined, { name: selfBot.botName, openId: selfBot.botOpenId }, localeForBot(larkAppId), undefined, { larkAppId, chatId, whiteboardId: ds.session.whiteboardId, codexAppText, chatContext });
|
|
13926
14631
|
// Auto-worktree: register PENDING, build worktree off-path, commit+fork later.
|
|
13927
14632
|
if (pinnedWorkingDir && autoWt) {
|
|
13928
14633
|
if (await replyInvalidWorkingDirs(anchor, larkAppId, ds) || joinBootstrapWasTakenOver()) {
|
|
13929
14634
|
withdrawSharedReplySeed();
|
|
13930
14635
|
return;
|
|
13931
14636
|
}
|
|
14637
|
+
ds.initialStartPending = false; // pendingRepo/worktree now owns buffering
|
|
13932
14638
|
armSharedReplyTarget();
|
|
13933
14639
|
startAutoWorktreePending(ds, { anchor, baseDir: pinnedWorkingDir, title, prompt: promptBody, operatorOpenId });
|
|
13934
14640
|
return;
|
|
@@ -13940,7 +14646,7 @@ async function handleBotAdded(chatId, operatorOpenId, larkAppId, opts) {
|
|
|
13940
14646
|
return;
|
|
13941
14647
|
}
|
|
13942
14648
|
ensureSessionWhiteboard(ds);
|
|
13943
|
-
const
|
|
14649
|
+
const availableBots = await getAvailableBots(larkAppId, chatId);
|
|
13944
14650
|
if (joinBootstrapWasTakenOver()) {
|
|
13945
14651
|
withdrawSharedReplySeed();
|
|
13946
14652
|
return;
|
|
@@ -13951,8 +14657,7 @@ async function handleBotAdded(chatId, operatorOpenId, larkAppId, opts) {
|
|
|
13951
14657
|
return;
|
|
13952
14658
|
}
|
|
13953
14659
|
armSharedReplyTarget();
|
|
13954
|
-
|
|
13955
|
-
forkWorker(ds, prompt, sharedReplyRootId ? { turnId: sharedReplyRootId } : false);
|
|
14660
|
+
forkReservedInitialSession(ds, availableBots);
|
|
13956
14661
|
ds.pendingTurnId = undefined;
|
|
13957
14662
|
ds.pendingChatContext = undefined;
|
|
13958
14663
|
logger.info(`[auto-start:入群] ${chatId.substring(0, 12)} 自动开工(${mode}/${scope}),workingDir=${pinnedWorkingDir}`);
|
|
@@ -13966,6 +14671,7 @@ async function handleBotAdded(chatId, operatorOpenId, larkAppId, opts) {
|
|
|
13966
14671
|
const scanDirs = getProjectScanDirs(ds).filter(d => existsSync(d));
|
|
13967
14672
|
const projects = scanDirs.length > 0 ? scanMultipleProjects(scanDirs, 3, repoPickerScanOptions()) : [];
|
|
13968
14673
|
if (projects.length > 0) {
|
|
14674
|
+
ds.initialStartPending = false; // pendingRepo/card now owns buffering
|
|
13969
14675
|
lastRepoScan.set(chatId, projects);
|
|
13970
14676
|
const cardJson = buildRepoSelectCard(projects, getSessionWorkingDir(ds), anchor, localeForBot(larkAppId), getBot(larkAppId).config.worktreeMultiPicker);
|
|
13971
14677
|
// The repo card itself belongs under the shared join seed. Arm the target
|
|
@@ -13983,13 +14689,14 @@ async function handleBotAdded(chatId, operatorOpenId, larkAppId, opts) {
|
|
|
13983
14689
|
return;
|
|
13984
14690
|
}
|
|
13985
14691
|
ds.repoCardMessageId = repoCardMessageId;
|
|
14692
|
+
persistPendingRepoCardMessageId(ds, ds.repoCardMessageId);
|
|
13986
14693
|
announcePendingRepoSession(ds);
|
|
13987
14694
|
logger.info(`[auto-start:入群] ${chatId.substring(0, 12)} 无默认目录,弹 repo 选择卡(${projects.length} 个项目)`);
|
|
13988
14695
|
}
|
|
13989
14696
|
else {
|
|
13990
14697
|
ds.pendingRepo = false;
|
|
13991
14698
|
ensureSessionWhiteboard(ds);
|
|
13992
|
-
const
|
|
14699
|
+
const availableBots = await getAvailableBots(larkAppId, chatId);
|
|
13993
14700
|
if (joinBootstrapWasTakenOver()) {
|
|
13994
14701
|
withdrawSharedReplySeed();
|
|
13995
14702
|
return;
|
|
@@ -14000,8 +14707,7 @@ async function handleBotAdded(chatId, operatorOpenId, larkAppId, opts) {
|
|
|
14000
14707
|
return;
|
|
14001
14708
|
}
|
|
14002
14709
|
armSharedReplyTarget();
|
|
14003
|
-
|
|
14004
|
-
forkWorker(ds, prompt, sharedReplyRootId ? { turnId: sharedReplyRootId } : false);
|
|
14710
|
+
forkReservedInitialSession(ds, availableBots);
|
|
14005
14711
|
ds.pendingTurnId = undefined;
|
|
14006
14712
|
ds.pendingChatContext = undefined;
|
|
14007
14713
|
logger.info(`[auto-start:入群] ${chatId.substring(0, 12)} 无默认目录且无可选项目,直接开工`);
|
|
@@ -14049,6 +14755,14 @@ function lookupForeignBotName(senderOpenId, larkAppId) {
|
|
|
14049
14755
|
return 'Bot';
|
|
14050
14756
|
}
|
|
14051
14757
|
async function handleThreadReply(data, ctx, prepared) {
|
|
14758
|
+
// Admission is bot-wide but intentionally concurrent. Add a narrower FIFO
|
|
14759
|
+
// before entering it so two same-anchor deliveries can never invert while
|
|
14760
|
+
// quota/resource/sender preparation awaits. Synthetic keys share the same
|
|
14761
|
+
// lock registry without occupying or mutating an active-session slot.
|
|
14762
|
+
const deliveryKey = `\u0000thread-delivery:${ctx.larkAppId}:${ctx.scope}:${ctx.anchor}`;
|
|
14763
|
+
return withActiveSessionKeyLock(activeSessions, deliveryKey, () => withBotTurnAdmission(ctx.larkAppId, () => handleThreadReplyAdmitted(data, ctx, prepared)));
|
|
14764
|
+
}
|
|
14765
|
+
async function handleThreadReplyAdmitted(data, ctx, prepared) {
|
|
14052
14766
|
const { chatId: ctxChatId, chatType: ctxChatType, scope, anchor, larkAppId, replyRootId, substituteTrigger } = ctx;
|
|
14053
14767
|
await waitForAutoStartJoinReady(larkAppId, anchor);
|
|
14054
14768
|
if (!prepared)
|
|
@@ -14273,11 +14987,20 @@ async function handleThreadReply(data, ctx, prepared) {
|
|
|
14273
14987
|
senderUnionId: threadTeamTrustUnionId,
|
|
14274
14988
|
memberUnionId: threadSenderUnionId, // 原始 union(人腿),不锁 bot
|
|
14275
14989
|
botSender: isBotSenderType || isForeignBot,
|
|
14990
|
+
// Reply attribution: platform-stamped OR cross-ref-resolved bot →
|
|
14991
|
+
// treat as bot for --mention-back (never mis-attribute a peer bot as
|
|
14992
|
+
// human when飞书 sender_type 缺失/变值但已识别 peer).
|
|
14993
|
+
senderIsBot: isBotSenderType || isForeignBot,
|
|
14276
14994
|
// Bot-started cold starts get no human owner (mirrors the auto-create
|
|
14277
14995
|
// path) — see the ownership note on startInitialPassthroughSession.
|
|
14278
14996
|
ownerOpenId: isForeignBot ? undefined : threadSenderOpenId,
|
|
14279
14997
|
ownerUnionId: isForeignBot ? undefined : data?.sender?.sender_id?.union_id,
|
|
14280
14998
|
creatorOpenId: threadSenderOpenId,
|
|
14999
|
+
routeToCanonicalOwner: () => handleThreadReplyAdmitted(data, {
|
|
15000
|
+
...ctx,
|
|
15001
|
+
scope,
|
|
15002
|
+
anchor,
|
|
15003
|
+
}),
|
|
14281
15004
|
});
|
|
14282
15005
|
return;
|
|
14283
15006
|
}
|
|
@@ -14289,6 +15012,12 @@ async function handleThreadReply(data, ctx, prepared) {
|
|
|
14289
15012
|
// 拉起)。TODO(后续产品决策):是否把 CLI passthrough 也纳入 canOperate,
|
|
14290
15013
|
// 收紧到与 daemon 命令同档;这会同时改变真人 oncall 成员的现有行为,应单独评估。
|
|
14291
15014
|
const ds = existingDs;
|
|
15015
|
+
if (ds?.worker && !ds.worker.killed && hasQueuedActivationAdmissionGate(ds)) {
|
|
15016
|
+
sessionReply(anchor, tr('daemon.cmd_activation_pending', { cmd }, localeForBot(larkAppId)), 'text', larkAppId);
|
|
15017
|
+
logger.warn(`[${anchor.substring(0, 12)}] Refused passthrough ${cmd} before acceptance `
|
|
15018
|
+
+ 'while queued activation owns raw submission order');
|
|
15019
|
+
return;
|
|
15020
|
+
}
|
|
14292
15021
|
if (ds) {
|
|
14293
15022
|
// /fast fail-closed: on RPC-input / Riff backends the keystroke can't
|
|
14294
15023
|
// reach Codex's executor (see fastToggleUnsupportedBackend). Reject with
|
|
@@ -14360,7 +15089,7 @@ async function handleThreadReply(data, ctx, prepared) {
|
|
|
14360
15089
|
cmdPending = { pendingRepo: true, pendingPrompt: '', workingDir: pinnedWorkingDir };
|
|
14361
15090
|
}
|
|
14362
15091
|
sessionStore.updateSession(session);
|
|
14363
|
-
const
|
|
15092
|
+
const cmdDs = {
|
|
14364
15093
|
session,
|
|
14365
15094
|
worker: null,
|
|
14366
15095
|
workerPort: null,
|
|
@@ -14376,13 +15105,16 @@ async function handleThreadReply(data, ctx, prepared) {
|
|
|
14376
15105
|
ownerOpenId: threadSenderOpenId,
|
|
14377
15106
|
...cmdPending,
|
|
14378
15107
|
};
|
|
14379
|
-
const
|
|
14380
|
-
if (!
|
|
14381
|
-
|
|
14382
|
-
if (!winner)
|
|
15108
|
+
const registration = await claimNewDaemonSession(activeSessions, cmdDs);
|
|
15109
|
+
if (!registration.accepted) {
|
|
15110
|
+
if (registration.reason !== 'existing_owner')
|
|
14383
15111
|
return;
|
|
14384
|
-
|
|
14385
|
-
|
|
15112
|
+
}
|
|
15113
|
+
else if (cmdDs.pendingRepo) {
|
|
15114
|
+
stageClaimedPendingRepoSetup(activeSessions, cmdDs, {
|
|
15115
|
+
mode: 'picker',
|
|
15116
|
+
turnId: parsed.messageId,
|
|
15117
|
+
});
|
|
14386
15118
|
}
|
|
14387
15119
|
}
|
|
14388
15120
|
// Pass mention-stripped content so /command argument parsing works.
|
|
@@ -14454,395 +15186,160 @@ async function handleThreadReply(data, ctx, prepared) {
|
|
|
14454
15186
|
return;
|
|
14455
15187
|
}
|
|
14456
15188
|
}
|
|
14457
|
-
|
|
14458
|
-
|
|
14459
|
-
|
|
14460
|
-
|
|
14461
|
-
//
|
|
14462
|
-
//
|
|
14463
|
-
//
|
|
14464
|
-
//
|
|
14465
|
-
|
|
14466
|
-
|
|
14467
|
-
|
|
14468
|
-
|
|
14469
|
-
|
|
14470
|
-
|
|
14471
|
-
|
|
14472
|
-
|
|
14473
|
-
|
|
14474
|
-
|
|
14475
|
-
|
|
14476
|
-
|
|
14477
|
-
|
|
14478
|
-
|
|
14479
|
-
|
|
14480
|
-
|
|
14481
|
-
|
|
15189
|
+
// Reserve an existing worker:null owner before ANY post-routing await.
|
|
15190
|
+
// withBotTurnAdmission permits concurrent turns, so two
|
|
15191
|
+
// same-anchor handlers can both resolve the same cold owner and otherwise
|
|
15192
|
+
// race through resource/sender preparation into two forks. The token makes
|
|
15193
|
+
// one handler the opening owner; every other handler observes the gate and
|
|
15194
|
+
// buffers. Queued activations retain the token through adapter ACK, while an
|
|
15195
|
+
// ordinary refork releases it immediately after its buffered tail is handed
|
|
15196
|
+
// to the worker.
|
|
15197
|
+
let initialStartClaimToken;
|
|
15198
|
+
let initialStartClaimWaitsForQueuedAck = false;
|
|
15199
|
+
let retainInitialStartClaim = false;
|
|
15200
|
+
const tryAcquireInitialStartClaim = () => {
|
|
15201
|
+
if (initialStartClaimToken
|
|
15202
|
+
|| !ds
|
|
15203
|
+
|| (ds.worker && !ds.worker.killed)
|
|
15204
|
+
|| ds.pendingRepo
|
|
15205
|
+
|| ds.initialStartPending === true)
|
|
15206
|
+
return;
|
|
15207
|
+
initialStartClaimToken = randomUUID();
|
|
15208
|
+
ds.initialStartClaimToken = initialStartClaimToken;
|
|
15209
|
+
ds.initialStartPending = true;
|
|
15210
|
+
initialStartClaimWaitsForQueuedAck = ds.session.queued === true
|
|
15211
|
+
|| ds.session.queuedActivationPending === true
|
|
15212
|
+
|| ds.session.queuedActivationInput !== undefined;
|
|
15213
|
+
};
|
|
15214
|
+
tryAcquireInitialStartClaim();
|
|
15215
|
+
const ownsInitialStartClaim = () => !!(ds
|
|
15216
|
+
&& initialStartClaimToken
|
|
15217
|
+
&& ds.initialStartClaimToken === initialStartClaimToken);
|
|
15218
|
+
try {
|
|
15219
|
+
const quotaSenderOpenId = threadSenderOpenId;
|
|
15220
|
+
if (!prepared?.quotaChecked && !await enforceMessageQuotaForCliInput(larkAppId, ctxChatId ?? data?.message?.chat_id, quotaSenderOpenId, parsed.messageId, anchor, threadTeamTrustUnionId, threadSenderUnionId, ctxChatType, isBotSenderType || isForeignBot)) {
|
|
15221
|
+
return;
|
|
14482
15222
|
}
|
|
14483
|
-
|
|
14484
|
-
|
|
14485
|
-
|
|
14486
|
-
|
|
14487
|
-
|
|
14488
|
-
|
|
14489
|
-
|
|
14490
|
-
//
|
|
14491
|
-
|
|
14492
|
-
|
|
14493
|
-
|
|
14494
|
-
|
|
14495
|
-
ds.session.quoteTargetSenderIsBot = isForeignBot;
|
|
14496
|
-
publishLastInputFromBotPatch(ds);
|
|
14497
|
-
if (ds.session.vcMeetingReceiver) {
|
|
14498
|
-
ds.vcMeetingImTurnOrigin = ctx.vcMeetingImTurnOrigin;
|
|
14499
|
-
if (ctx.vcMeetingImTurnOrigin) {
|
|
14500
|
-
rememberVcMeetingImTurnOrigin(ds.session, ctx.vcMeetingImTurnOrigin);
|
|
14501
|
-
}
|
|
14502
|
-
}
|
|
14503
|
-
const substituteReplyMode = substituteTrigger
|
|
14504
|
-
? (getBot(larkAppId).config.substituteMode?.replyMode ?? 'thread')
|
|
14505
|
-
: 'thread';
|
|
14506
|
-
beginReplyTargetTurn(ds, replyRootId, parsed.messageId, new Date().toISOString(), { quoteOnly: substituteReplyMode === 'quote', substitute: !!substituteTrigger });
|
|
14507
|
-
if (callerOpenId && ds.session.lastCallerOpenId !== callerOpenId) {
|
|
14508
|
-
ds.session.lastCallerOpenId = callerOpenId;
|
|
15223
|
+
// When a command path rewrites the model prompt (for example /workflow),
|
|
15224
|
+
// keep the Lark-authored bytes visible and move the rewritten instruction
|
|
15225
|
+
// into hidden untrusted context. Simple quote/bot prefixes use only the
|
|
15226
|
+
// prefix as context, avoiding a duplicate copy of the user text.
|
|
15227
|
+
const codexAppMessageContext = rewrittenCodexAppMessageContext
|
|
15228
|
+
?? initialCodexAppMessageContext;
|
|
15229
|
+
const codexAppApplicationContext = initialCodexAppApplicationContext;
|
|
15230
|
+
// Download attachments
|
|
15231
|
+
const effectiveAppId = ds?.larkAppId ?? larkAppId;
|
|
15232
|
+
let attachments;
|
|
15233
|
+
if (prepared) {
|
|
15234
|
+
attachments = prepared.attachments;
|
|
14509
15235
|
}
|
|
14510
|
-
|
|
14511
|
-
|
|
14512
|
-
|
|
14513
|
-
|
|
14514
|
-
|
|
14515
|
-
const codexAppFollowUpContextParts = [];
|
|
14516
|
-
if (codexAppMessageContext)
|
|
14517
|
-
codexAppFollowUpContextParts.push(codexAppMessageContext);
|
|
14518
|
-
const attachmentHint = attachments.length > 0 ? formatAttachmentsHint(attachments) : '';
|
|
14519
|
-
let enriched = attachmentHint
|
|
14520
|
-
? `${promptContent}${attachmentHint}`
|
|
14521
|
-
: promptContent;
|
|
14522
|
-
if (attachmentHint)
|
|
14523
|
-
codexAppFollowUpContextParts.push(attachmentHint);
|
|
14524
|
-
if (parsed.mentions && parsed.mentions.length > 0) {
|
|
14525
|
-
const mentionLines = parsed.mentions.map(m => {
|
|
14526
|
-
const idPart = m.openId ? ` → open_id: ${m.openId}` : '';
|
|
14527
|
-
return `- @${m.name}${idPart}`;
|
|
14528
|
-
});
|
|
14529
|
-
const mentionContext = `${tr('daemon.enriched_mentions_label', undefined, localeForBot(larkAppId))}\n${mentionLines.join('\n')}`;
|
|
14530
|
-
enriched += `\n\n${mentionContext}`;
|
|
14531
|
-
codexAppFollowUpContextParts.push(mentionContext);
|
|
14532
|
-
}
|
|
14533
|
-
// Stamp a buffered follow-up with its own <sender> tag ONLY when it comes
|
|
14534
|
-
// from a different user than the first message (ds.pendingSender) — the
|
|
14535
|
-
// deferred spawn already carries that sender's <sender> block, and the
|
|
14536
|
-
// follow-ups now fold into the same <user_message>, so a same-user tag is
|
|
14537
|
-
// pure duplication. A differing sender still gets attributed so the CLI can
|
|
14538
|
-
// tell multi-user buffered messages apart after repo selection unlocks.
|
|
14539
|
-
// This branch is also the synchronization barrier for a pending first
|
|
14540
|
-
// worker. Do not await contact/name resolution here: a commit waiting on
|
|
14541
|
-
// getAvailableBots could otherwise resume first, snapshot stale buffers,
|
|
14542
|
-
// and fork before this message is appended. Event identity is sufficient
|
|
14543
|
-
// for caller equality and sender attribution; names remain optional.
|
|
14544
|
-
const followUpSender = callerOpenId
|
|
14545
|
-
? { openId: callerOpenId, type: isForeignBot ? 'bot' : 'user' }
|
|
14546
|
-
: undefined;
|
|
14547
|
-
const hadBufferedFollowUps = (ds.pendingFollowUps?.length ?? 0) > 0;
|
|
14548
|
-
if (followUpSender?.openId && followUpSender.openId !== ds.pendingSender?.openId) {
|
|
14549
|
-
// This buffer folds into the opening <user_message> after repo selection,
|
|
14550
|
-
// so pair the foreign sender tag with the cursor anti-echo note: without
|
|
14551
|
-
// the adjacent note a cursor session sees an inline ou_xxx:name with no
|
|
14552
|
-
// guard (the builder's own note only covers ds.pendingSender's top-level
|
|
14553
|
-
// tag, and is absent entirely when pendingSender is undefined).
|
|
14554
|
-
const followUpSenderBlock = renderBufferedSenderBlock(followUpSender, getBot(larkAppId).config.cliId, localeForBot(larkAppId));
|
|
14555
|
-
if (followUpSenderBlock) {
|
|
14556
|
-
enriched = `${followUpSenderBlock}\n${enriched}`;
|
|
14557
|
-
codexAppFollowUpContextParts.unshift(followUpSenderBlock);
|
|
14558
|
-
}
|
|
14559
|
-
}
|
|
14560
|
-
const sameInitialCaller = !!followUpSender?.openId
|
|
14561
|
-
&& followUpSender.openId === ds.pendingSender?.openId;
|
|
14562
|
-
if (ds.pendingRawInput) {
|
|
14563
|
-
if ((!hadBufferedFollowUps || ds.pendingFollowUpTurnId) && sameInitialCaller) {
|
|
14564
|
-
// The persisted quote target is a single latest-turn pointer. Treat a
|
|
14565
|
-
// same-caller raw command + buffered follow-ups as one batch and join
|
|
14566
|
-
// every staged PTY input to that latest turn. Keeping the raw command
|
|
14567
|
-
// on its older id would fail provenance as soon as the follow-up moves
|
|
14568
|
-
// the durable pointer forward.
|
|
14569
|
-
ds.pendingRawTurnId = parsed.messageId;
|
|
14570
|
-
ds.pendingFollowUpTurnId = parsed.messageId;
|
|
15236
|
+
else {
|
|
15237
|
+
const downloaded = await downloadResources(effectiveAppId, parsed.messageId, resources);
|
|
15238
|
+
attachments = downloaded.attachments;
|
|
15239
|
+
if (attachments.length > 0) {
|
|
15240
|
+
parsed.attachments = attachments;
|
|
14571
15241
|
}
|
|
14572
|
-
|
|
14573
|
-
|
|
14574
|
-
// entire batch rather than letting either identity borrow the other.
|
|
14575
|
-
ds.pendingRawTurnId = undefined;
|
|
14576
|
-
ds.pendingFollowUpTurnId = undefined;
|
|
15242
|
+
if (downloaded.needLogin) {
|
|
15243
|
+
sessionReply(anchor, tr('daemon.download_failed_need_login', undefined, localeForBot(effectiveAppId)), 'text', effectiveAppId);
|
|
14577
15244
|
}
|
|
14578
15245
|
}
|
|
14579
|
-
|
|
14580
|
-
|
|
14581
|
-
|
|
14582
|
-
|
|
14583
|
-
|
|
14584
|
-
|
|
14585
|
-
|
|
15246
|
+
// Update last message time + last caller (used by `botmux send` to address
|
|
15247
|
+
// reply cards to whoever triggered this turn — matters in oncall groups
|
|
15248
|
+
// where the caller is often not the session owner).
|
|
15249
|
+
const callerOpenId = parsed.senderId || data?.sender?.sender_id?.open_id;
|
|
15250
|
+
if (ds) {
|
|
15251
|
+
markSessionActivity(ds);
|
|
15252
|
+
// quoteTargetId changes every inbound message (always a new message_id), so
|
|
15253
|
+
// — unlike lastCallerOpenId — persist unconditionally. Powers `botmux send`'s
|
|
15254
|
+
// default chat-scope quote chain + --mention-back.
|
|
15255
|
+
ds.session.quoteTargetId = parsed.messageId;
|
|
15256
|
+
ds.session.quoteTargetSenderOpenId = callerOpenId;
|
|
15257
|
+
ds.session.quoteTargetSenderIsBot = isForeignBot;
|
|
15258
|
+
publishLastInputFromBotPatch(ds);
|
|
15259
|
+
if (ds.session.vcMeetingReceiver) {
|
|
15260
|
+
ds.vcMeetingImTurnOrigin = ctx.vcMeetingImTurnOrigin;
|
|
15261
|
+
if (ctx.vcMeetingImTurnOrigin) {
|
|
15262
|
+
rememberVcMeetingImTurnOrigin(ds.session, ctx.vcMeetingImTurnOrigin);
|
|
15263
|
+
}
|
|
14586
15264
|
}
|
|
14587
|
-
|
|
14588
|
-
|
|
14589
|
-
|
|
14590
|
-
|
|
14591
|
-
|
|
14592
|
-
|
|
14593
|
-
|
|
14594
|
-
|
|
14595
|
-
|
|
14596
|
-
|
|
14597
|
-
|
|
14598
|
-
|
|
14599
|
-
|
|
14600
|
-
|
|
14601
|
-
ds.
|
|
14602
|
-
|
|
14603
|
-
|
|
14604
|
-
|
|
14605
|
-
|
|
14606
|
-
const pendingReplyKey = (ds.worktreeCreating || ds.pendingRepoCommitInFlight)
|
|
14607
|
-
? 'daemon.worktree_building_wait'
|
|
14608
|
-
: 'daemon.choose_repo_first';
|
|
14609
|
-
await sessionReply(anchor, tr(pendingReplyKey, undefined, localeForBot(larkAppId)), 'text', larkAppId);
|
|
14610
|
-
return;
|
|
14611
|
-
}
|
|
14612
|
-
// Route to file queue (keyed by anchor: rootMessageId for thread, chatId for chat)
|
|
14613
|
-
messageQueue.ensureQueue(anchor);
|
|
14614
|
-
if (!prepared?.queueAlreadyAppended)
|
|
14615
|
-
messageQueue.appendMessage(anchor, parsed);
|
|
14616
|
-
if (ds)
|
|
14617
|
-
publishSessionMessagePreviewPatch(ds);
|
|
14618
|
-
if (!ds) {
|
|
14619
|
-
// No active session at this anchor — auto-create. This branch is mostly a
|
|
14620
|
-
// safety net; the dispatcher routes here only when isSessionOwner() returns
|
|
14621
|
-
// true, but races (between check and execution, or session-closed events)
|
|
14622
|
-
// can land us here.
|
|
14623
|
-
if (activeSessions.has(sessionKey(anchor, larkAppId))) {
|
|
14624
|
-
logger.info(`[${larkAppId}] Session already exists for ${scope}-scope ${anchor}, skipping auto-create`);
|
|
14625
|
-
return;
|
|
15265
|
+
const substituteReplyMode = substituteTrigger
|
|
15266
|
+
? (getBot(larkAppId).config.substituteMode?.replyMode ?? 'thread')
|
|
15267
|
+
: 'thread';
|
|
15268
|
+
// Sender name is best-effort here: getThreadSender() resolves later (this
|
|
15269
|
+
// hot path avoids an await before the buffering barrier), so the candidate
|
|
15270
|
+
// list may show open_id without a name — the ambiguity decision itself is
|
|
15271
|
+
// unaffected. Post inline-@s: on a prepared (registration-race loser) handoff
|
|
15272
|
+
// use the COMPLETE pre-extracted seed+follow-up set; otherwise extract from
|
|
15273
|
+
// this message. Include the forward-seed message too: a new-topic/auto-create
|
|
15274
|
+
// CAS loser routes here without re-passing prepared.postParticipantMentions, so
|
|
15275
|
+
// recompute from BOTH data and ctx.forwardSeedData or the seed's post @s vanish
|
|
15276
|
+
// on the double-race (matches the new-topic path's collectPostAtMentions args).
|
|
15277
|
+
const existingPostAt = prepared?.postParticipantMentions ?? collectPostAtMentions(data?.message, ctx.forwardSeedData?.message);
|
|
15278
|
+
const existingWindow = buildTurnParticipants(larkAppId, callerOpenId, senderIsBotTriState(parsed.senderType, isForeignBot), parsed.mentions, undefined, existingPostAt);
|
|
15279
|
+
beginReplyTargetTurn(ds, replyRootId, parsed.messageId, new Date().toISOString(), { quoteOnly: substituteReplyMode === 'quote', substitute: !!substituteTrigger, senderOpenId: callerOpenId, participants: existingWindow.participants, participantsIncomplete: existingWindow.incomplete });
|
|
15280
|
+
if (callerOpenId && ds.session.lastCallerOpenId !== callerOpenId) {
|
|
15281
|
+
ds.session.lastCallerOpenId = callerOpenId;
|
|
15282
|
+
}
|
|
15283
|
+
sessionStore.updateSession(ds.session);
|
|
14626
15284
|
}
|
|
14627
|
-
|
|
14628
|
-
|
|
14629
|
-
|
|
14630
|
-
|
|
14631
|
-
|
|
14632
|
-
|
|
14633
|
-
|
|
14634
|
-
|
|
14635
|
-
//
|
|
14636
|
-
//
|
|
14637
|
-
//
|
|
14638
|
-
|
|
14639
|
-
|
|
14640
|
-
|
|
14641
|
-
//
|
|
14642
|
-
//
|
|
14643
|
-
|
|
14644
|
-
|
|
14645
|
-
|
|
14646
|
-
|
|
14647
|
-
//
|
|
14648
|
-
//
|
|
14649
|
-
//
|
|
14650
|
-
|
|
14651
|
-
|
|
14652
|
-
|
|
14653
|
-
|
|
14654
|
-
|
|
14655
|
-
|
|
14656
|
-
|
|
14657
|
-
|
|
14658
|
-
|
|
14659
|
-
|
|
14660
|
-
|
|
14661
|
-
|
|
14662
|
-
|
|
14663
|
-
|
|
14664
|
-
|
|
14665
|
-
|
|
14666
|
-
|
|
14667
|
-
&& !botCfg.substituteMode?.disableControlCard
|
|
14668
|
-
&& !session.substituteControlCardSent;
|
|
14669
|
-
// Use the same layered oncall / inherit / default lookup as handleNewTopic
|
|
14670
|
-
// so stale inherited peers are ignored consistently in both spawn paths.
|
|
14671
|
-
const { pinnedWorkingDir, oncallEntry, inheritedFrom, pinnedFromBotDefault } = await resolvePinnedWorkingDir({
|
|
14672
|
-
scope,
|
|
14673
|
-
anchor,
|
|
14674
|
-
chatId: autoCreateChatId,
|
|
14675
|
-
chatType: autoCreateChatType,
|
|
14676
|
-
larkAppId,
|
|
15285
|
+
// The first owner may have failed while this handler was awaiting resource
|
|
15286
|
+
// preparation. Re-check once at the final routing boundary so the oldest
|
|
15287
|
+
// surviving follower atomically takes over instead of proceeding unclaimed.
|
|
15288
|
+
tryAcquireInitialStartClaim();
|
|
15289
|
+
// A published worker:null owner is not necessarily ready to refork: its
|
|
15290
|
+
// opening turn may still be preparing. Buffer both repo-pending and
|
|
15291
|
+
// initial-start-pending messages into that opening turn so a later prompt
|
|
15292
|
+
// cannot overtake it. Exception — a genuine external takeover: the worker went
|
|
15293
|
+
// LIVE via a reattach / concurrent start (e.g. before the group-join bootstrap
|
|
15294
|
+
// settled) with (a) no queued-activation submission in flight AND (b) no
|
|
15295
|
+
// active initial-start claim token (no concurrent turn is building an opening
|
|
15296
|
+
// for this session). Such a worker is ready to receive input directly;
|
|
15297
|
+
// buffering it into a queued-activation tail — only consumed by the worker:null
|
|
15298
|
+
// opening path — would silently drop it. When a claim token IS held (a
|
|
15299
|
+
// concurrent first turn owns the opening) or a queued-activation ACK is
|
|
15300
|
+
// pending, a live worker MUST keep buffering to preserve FIFO submission order.
|
|
15301
|
+
// Codex App steer authorization (Blocking 1, decision A) — computed ONCE here,
|
|
15302
|
+
// BEFORE every admission/fork branch below (R5-B1-1): the earliest tail-admit
|
|
15303
|
+
// (initialStartPending follower), pending-repo follower, worker-null refork,
|
|
15304
|
+
// and new-topic auto-create paths all need the same frozen value, and several
|
|
15305
|
+
// of them return before the later existing-owner branch. Explicit positive
|
|
15306
|
+
// ONLY for a plain-human-interactive turn (real human sender; none of
|
|
15307
|
+
// foreign-bot / bot-sender / substitute-rewrite / v3-grill / message-listener /
|
|
15308
|
+
// VC receiver / VC origin / adopt). Never inferred from the delivery sink;
|
|
15309
|
+
// ignored by acceptCodexAppDispatch for non-codex-app CLIs. A new-topic root
|
|
15310
|
+
// must FREEZE this into its opening payload — forkReservedInitialSession is
|
|
15311
|
+
// shared by bot-added / scheduler / system bootstrap and only COPIES an
|
|
15312
|
+
// explicit upstream true, never defaults it.
|
|
15313
|
+
const codexAppSteerable = computeCodexAppSteerable({
|
|
15314
|
+
// Positive real-human gate (R7-B1 fail-closed): senderType must be 'user'.
|
|
15315
|
+
// isForeignBot already folds in the isKnownPeerBot cross-ref fallback.
|
|
15316
|
+
humanSender: parsed.senderType === 'user',
|
|
15317
|
+
adopted: !!ds?.adoptedFrom,
|
|
15318
|
+
isForeignBot,
|
|
15319
|
+
isBotSenderType,
|
|
15320
|
+
substituteTrigger: !!substituteTrigger,
|
|
15321
|
+
controlRewrite: !!threadGrill,
|
|
15322
|
+
messageListener: !!ctx.messageListener,
|
|
15323
|
+
vcMeetingReceiver: ds?.session.vcMeetingReceiver !== undefined,
|
|
15324
|
+
vcMeetingImTurnOrigin: !!ctx.vcMeetingImTurnOrigin,
|
|
14677
15325
|
});
|
|
14678
|
-
const
|
|
14679
|
-
|
|
14680
|
-
|
|
14681
|
-
|
|
14682
|
-
|
|
14683
|
-
|
|
14684
|
-
|
|
14685
|
-
|
|
14686
|
-
|
|
14687
|
-
|
|
14688
|
-
|
|
14689
|
-
workerToken: null,
|
|
14690
|
-
larkAppId,
|
|
14691
|
-
chatId: autoCreateChatId,
|
|
14692
|
-
chatType: autoCreateChatType,
|
|
14693
|
-
scope,
|
|
14694
|
-
spawnedAt: Date.parse(session.createdAt) || now,
|
|
14695
|
-
cliVersion: cliVersionCache.get(cliRuntimeVersionKey(botCfg))?.version ?? 'unknown',
|
|
14696
|
-
lastMessageAt: now,
|
|
14697
|
-
hasHistory: false,
|
|
14698
|
-
pendingRepo: !pinnedWorkingDir || autoWt,
|
|
14699
|
-
pendingPrompt: promptContent,
|
|
14700
|
-
pendingTurnId: parsed.messageId,
|
|
14701
|
-
pendingCodexAppText: parsed.content,
|
|
14702
|
-
pendingCodexAppApplicationContext: codexAppApplicationContext,
|
|
14703
|
-
pendingCodexAppMessageContext: codexAppMessageContext,
|
|
14704
|
-
pendingAttachments: attachments.length > 0 ? attachments : undefined,
|
|
14705
|
-
pendingMentions: parsed.mentions,
|
|
14706
|
-
pendingSubstituteTrigger: substituteTrigger,
|
|
14707
|
-
pendingSubstituteControlCard: shouldSendSubstituteControlCard,
|
|
14708
|
-
pendingSender: autoCreateSender,
|
|
14709
|
-
ownerOpenId,
|
|
14710
|
-
currentTurnTitle: parsed.content.substring(0, 50),
|
|
14711
|
-
workingDir: pinnedWorkingDir,
|
|
14712
|
-
};
|
|
14713
|
-
if (pinnedWorkingDir) {
|
|
14714
|
-
newDs.session.workingDir = pinnedWorkingDir;
|
|
14715
|
-
sessionStore.updateSession(newDs.session);
|
|
14716
|
-
}
|
|
14717
|
-
const substituteReplyMode = substituteTrigger
|
|
14718
|
-
? (botCfg.substituteMode?.replyMode ?? 'thread')
|
|
14719
|
-
: 'thread';
|
|
14720
|
-
beginReplyTargetTurn(newDs, replyRootId, parsed.messageId, new Date().toISOString(), { quoteOnly: substituteReplyMode === 'quote', substitute: !!substituteTrigger });
|
|
14721
|
-
sessionStore.updateSession(newDs.session);
|
|
14722
|
-
const creationKey = sessionKey(anchor, larkAppId);
|
|
14723
|
-
if (!setActiveSessionIfActive(activeSessions, creationKey, newDs)) {
|
|
14724
|
-
const winner = await rollbackRejectedSessionAndGetWinner(activeSessions, creationKey, newDs);
|
|
14725
|
-
if (winner) {
|
|
14726
|
-
logger.info(`[${session.sessionId.substring(0, 8)}] Reply auto-create registration lost to ` +
|
|
14727
|
-
`${winner.session.sessionId.substring(0, 8)}; handing message to routing winner`);
|
|
14728
|
-
await handleThreadReply(data, ctx, {
|
|
14729
|
-
parsed,
|
|
14730
|
-
resources,
|
|
14731
|
-
attachments,
|
|
14732
|
-
quotaChecked: true,
|
|
14733
|
-
queueAlreadyAppended: true,
|
|
14734
|
-
senderResolved: true,
|
|
14735
|
-
sender: autoCreateSender,
|
|
14736
|
-
});
|
|
14737
|
-
}
|
|
14738
|
-
return;
|
|
14739
|
-
}
|
|
14740
|
-
// Auto-worktree: register PENDING, build worktree off-path, commit+fork later.
|
|
14741
|
-
if (pinnedWorkingDir && autoWt) {
|
|
14742
|
-
if (await replyInvalidWorkingDirs(anchor, larkAppId, newDs))
|
|
14743
|
-
return;
|
|
14744
|
-
startAutoWorktreePending(newDs, { anchor, baseDir: pinnedWorkingDir, title: parsed.content.substring(0, 50), prompt: promptContent, operatorOpenId: ownerOpenId });
|
|
14745
|
-
return;
|
|
14746
|
-
}
|
|
14747
|
-
// Pinned (oncall binding or inherited from peer bot in same thread):
|
|
14748
|
-
// spawn CLI immediately, skip repo selection.
|
|
14749
|
-
if (pinnedWorkingDir) {
|
|
14750
|
-
if (await replyInvalidWorkingDirs(anchor, larkAppId, newDs))
|
|
14751
|
-
return;
|
|
14752
|
-
const selfBot = getBot(larkAppId);
|
|
14753
|
-
ensureSessionWhiteboard(newDs);
|
|
14754
|
-
const prompt = buildNewTopicCliInput(promptContent, session.sessionId, botCfg.cliId, botCfg.cliPathOverride, attachments, parsed.mentions, await getAvailableBots(larkAppId, autoCreateChatId), undefined, { name: selfBot.botName, openId: selfBot.botOpenId }, localeForBot(larkAppId), autoCreateSender, { larkAppId, chatId: autoCreateChatId, whiteboardId: newDs.session.whiteboardId, substituteTrigger, codexAppText: parsed.content, codexAppApplicationContext, codexAppMessageContext });
|
|
14755
|
-
await noteTurnReceived(newDs, parsed.messageId, parsed.content, autoCreateSender, parsed.messageId, substituteTrigger ? SUBSTITUTE_RECEIVED_REACTION_EMOJI_TYPE : undefined);
|
|
14756
|
-
rememberLastCliInput(newDs, promptContent, prompt);
|
|
14757
|
-
forkWorker(newDs, prompt, { turnId: parsed.messageId });
|
|
14758
|
-
newDs.pendingTurnId = undefined;
|
|
14759
|
-
const reason = oncallEntry
|
|
14760
|
-
? `oncall-bound chat ${autoCreateChatId}`
|
|
14761
|
-
: inheritedFrom
|
|
14762
|
-
? `inherited from peer session ${inheritedFrom.sessionId.substring(0, 8)} (app=${inheritedFrom.larkAppId ?? 'unknown'})`
|
|
14763
|
-
: `bot defaultWorkingDir`;
|
|
14764
|
-
logger.info(`[${tag(newDs)}] ${reason} → workingDir=${pinnedWorkingDir}, skipped repo select`);
|
|
14765
|
-
return;
|
|
14766
|
-
}
|
|
14767
|
-
// Show repo selection card (same as handleNewTopic)
|
|
14768
|
-
if (await replyInvalidWorkingDirs(anchor, larkAppId, newDs))
|
|
14769
|
-
return;
|
|
14770
|
-
const scanDirs2 = getProjectScanDirs(newDs).filter(d => existsSync(d));
|
|
14771
|
-
let projects = [];
|
|
14772
|
-
if (scanDirs2.length > 0) {
|
|
14773
|
-
projects = scanMultipleProjects(scanDirs2, 3, repoPickerScanOptions());
|
|
14774
|
-
}
|
|
14775
|
-
if (projects.length > 0) {
|
|
14776
|
-
lastRepoScan.set(autoCreateChatId, projects);
|
|
14777
|
-
const currentCwd = getSessionWorkingDir(newDs);
|
|
14778
|
-
const cardJson = buildRepoSelectCard(projects, currentCwd, anchor, localeForBot(larkAppId), getBot(larkAppId).config.worktreeMultiPicker);
|
|
14779
|
-
newDs.repoCardMessageId = await sessionReply(anchor, cardJson, 'interactive', larkAppId);
|
|
14780
|
-
announcePendingRepoSession(newDs);
|
|
14781
|
-
logger.info(`[${tag(newDs)}] Waiting for repo selection (${projects.length} projects)`);
|
|
14782
|
-
}
|
|
14783
|
-
else {
|
|
14784
|
-
// No projects found — skip repo selection, spawn directly
|
|
14785
|
-
newDs.pendingRepo = false;
|
|
14786
|
-
const selfBot = getBot(larkAppId);
|
|
14787
|
-
ensureSessionWhiteboard(newDs);
|
|
14788
|
-
const prompt = buildNewTopicCliInput(promptContent, session.sessionId, botCfg.cliId, botCfg.cliPathOverride, attachments, parsed.mentions, await getAvailableBots(larkAppId, autoCreateChatId), undefined, { name: selfBot.botName, openId: selfBot.botOpenId }, localeForBot(larkAppId), autoCreateSender, { larkAppId, chatId: autoCreateChatId, whiteboardId: newDs.session.whiteboardId, substituteTrigger, codexAppText: parsed.content, codexAppApplicationContext, codexAppMessageContext });
|
|
14789
|
-
await noteTurnReceived(newDs, parsed.messageId, parsed.content, autoCreateSender, parsed.messageId, substituteTrigger ? SUBSTITUTE_RECEIVED_REACTION_EMOJI_TYPE : undefined);
|
|
14790
|
-
rememberLastCliInput(newDs, promptContent, prompt);
|
|
14791
|
-
forkWorker(newDs, prompt, { turnId: parsed.messageId });
|
|
14792
|
-
newDs.pendingTurnId = undefined;
|
|
14793
|
-
}
|
|
14794
|
-
return;
|
|
14795
|
-
}
|
|
14796
|
-
// Send message to worker via IPC
|
|
14797
|
-
if (ds.worker && !ds.worker.killed) {
|
|
14798
|
-
const dsBotCfgForMsg = getBot(ds.larkAppId).config;
|
|
14799
|
-
// Adopt mode: the adopted CLI is the user's external process and was
|
|
14800
|
-
// never injected with botmux's skill / system prompt. Sending it the
|
|
14801
|
-
// `<user_message>` / `<botmux_reminder>` / `<session_id>` wrappers
|
|
14802
|
-
// surfaces those tags verbatim in its UI (the user reported Codex
|
|
14803
|
-
// showing raw XML on every Lark message). Use the bridge raw-input
|
|
14804
|
-
// builder for ALL adopt sessions regardless of cliId — transcript
|
|
14805
|
-
// harvest (Claude bridge or Codex bridge) handles the reply path
|
|
14806
|
-
// out-of-band.
|
|
14807
|
-
const isBridge = !!ds.adoptedFrom;
|
|
14808
|
-
const selfBot = getBot(ds.larkAppId);
|
|
14809
|
-
if (!isBridge)
|
|
14810
|
-
ensureSessionWhiteboard(ds);
|
|
14811
|
-
const effectiveCliId = ds.session.cliId ?? dsBotCfgForMsg.cliId;
|
|
14812
|
-
// Empty-started session (repo select/skip/switch booted the CLI with no
|
|
14813
|
-
// turn): a LIVE worker is not proof the CLI ever saw botmux's opening
|
|
14814
|
-
// context — only `buildNewTopicCliInput` emits <botmux_routing> /
|
|
14815
|
-
// <botmux_builtin_skills> / <identity>. Probe (non-consuming) before the
|
|
14816
|
-
// awaits below, then claim SYNCHRONOUSLY right before building so two
|
|
14817
|
-
// near-simultaneous first messages can only produce one opener; the loser
|
|
14818
|
-
// degrades to an ordinary follow-up in queue order.
|
|
14819
|
-
const wantsOpening = !isBridge && isInitialUserTurnPending(ds);
|
|
14820
|
-
const openingBots = wantsOpening ? await getAvailableBots(larkAppId, ds.chatId) : undefined;
|
|
14821
|
-
const turnSender = await getThreadSender();
|
|
14822
|
-
const openingTurn = wantsOpening && claimInitialUserTurn(ds);
|
|
14823
|
-
const cliInput = isBridge
|
|
14824
|
-
? { content: buildBridgeInputContent(promptContent, {
|
|
14825
|
-
attachments,
|
|
14826
|
-
mentions: parsed.mentions,
|
|
14827
|
-
selfMention: { name: selfBot.botName, openId: selfBot.botOpenId },
|
|
14828
|
-
}) }
|
|
14829
|
-
: openingTurn
|
|
14830
|
-
? buildNewTopicCliInput(promptContent, ds.session.sessionId, effectiveCliId, ds.session.cliPathOverride ?? dsBotCfgForMsg.cliPathOverride, attachments, parsed.mentions, openingBots, undefined, { name: selfBot.botName, openId: selfBot.botOpenId }, localeForBot(larkAppId), turnSender, {
|
|
14831
|
-
larkAppId,
|
|
14832
|
-
chatId: ds.session.chatId,
|
|
14833
|
-
whiteboardId: ds.session.whiteboardId,
|
|
14834
|
-
substituteTrigger,
|
|
14835
|
-
codexAppText: parsed.content,
|
|
14836
|
-
codexAppApplicationContext,
|
|
14837
|
-
codexAppMessageContext,
|
|
14838
|
-
})
|
|
14839
|
-
: buildFollowUpCliInput(promptContent, ds.session.sessionId, {
|
|
15326
|
+
const liveTakeoverReady = !!ds?.worker && !ds.worker.killed
|
|
15327
|
+
&& !hasQueuedActivationAdmissionGate(ds)
|
|
15328
|
+
&& ds.initialStartClaimToken === undefined;
|
|
15329
|
+
const initialStartPending = ds?.initialStartPending === true
|
|
15330
|
+
&& !ownsInitialStartClaim()
|
|
15331
|
+
&& !liveTakeoverReady;
|
|
15332
|
+
if (ds && initialStartPending && !ds.pendingRepo) {
|
|
15333
|
+
const tailReservation = reserveAsyncQueuedActivationTailAdmission(ds);
|
|
15334
|
+
try {
|
|
15335
|
+
const botCfg = getBot(ds.larkAppId).config;
|
|
15336
|
+
const followUp = buildFollowUpCliInput(promptContent, ds.session.sessionId, {
|
|
14840
15337
|
attachments,
|
|
14841
15338
|
mentions: parsed.mentions,
|
|
14842
15339
|
isAdoptMode: false,
|
|
14843
|
-
cliId:
|
|
14844
|
-
cliPathOverride: ds.session.cliPathOverride ??
|
|
14845
|
-
sender:
|
|
15340
|
+
cliId: ds.session.cliId ?? botCfg.cliId,
|
|
15341
|
+
cliPathOverride: ds.session.cliPathOverride ?? botCfg.cliPathOverride,
|
|
15342
|
+
sender: await getThreadSender(),
|
|
14846
15343
|
larkAppId,
|
|
14847
15344
|
chatId: ds.session.chatId,
|
|
14848
15345
|
whiteboardId: ds.session.whiteboardId,
|
|
@@ -14851,180 +15348,804 @@ async function handleThreadReply(data, ctx, prepared) {
|
|
|
14851
15348
|
codexAppApplicationContext,
|
|
14852
15349
|
codexAppMessageContext,
|
|
14853
15350
|
});
|
|
14854
|
-
|
|
14855
|
-
|
|
14856
|
-
|
|
14857
|
-
|
|
14858
|
-
|
|
14859
|
-
|
|
14860
|
-
|
|
14861
|
-
|
|
14862
|
-
|
|
14863
|
-
|
|
14864
|
-
|
|
14865
|
-
|
|
14866
|
-
|
|
15351
|
+
// R5-B1-1: freeze the admission-time steer authorization onto this earliest
|
|
15352
|
+
// (initialStartPending follower) tail entry — the strip-proof admit rebuild
|
|
15353
|
+
// then preserves it through promote/repark. Only a plain-human turn is true.
|
|
15354
|
+
if (codexAppSteerable)
|
|
15355
|
+
followUp.codexAppSteerable = true;
|
|
15356
|
+
admitQueuedActivationTail(ds, {
|
|
15357
|
+
userPrompt: promptContent,
|
|
15358
|
+
cliInput: followUp,
|
|
15359
|
+
turnId: parsed.messageId,
|
|
15360
|
+
}, tailReservation);
|
|
15361
|
+
}
|
|
15362
|
+
finally {
|
|
15363
|
+
settleAsyncQueuedActivationTailAdmission(ds);
|
|
15364
|
+
}
|
|
15365
|
+
logger.info(`[${tag(ds)}] buffered same-anchor turn ${parsed.messageId.substring(0, 12)} `
|
|
15366
|
+
+ 'behind queued activation submission ACK');
|
|
15367
|
+
// Quarantined tail-only owner (restore promotion failed transiently): the
|
|
15368
|
+
// current turn is now safely appended BEHIND the retained tail via its
|
|
15369
|
+
// reservation above, so recover the OLD HEAD — never the current turn. A
|
|
15370
|
+
// blank fork here routes through forkWorker's central quarantine guard: it
|
|
15371
|
+
// retries the old head's promotion and, on success, cold-forks the promoted
|
|
15372
|
+
// head via the exact recovery args (Codex App through its ledger, non-Codex
|
|
15373
|
+
// by resubmitting queuedActivationInput). On failure the guard refuses the
|
|
15374
|
+
// fork (returns false) and the session stays quarantined with the gate held,
|
|
15375
|
+
// the current turn parked in the tail for a later retry.
|
|
15376
|
+
//
|
|
15377
|
+
// This deliberately does NOT call forkReservedInitialSession: that builds a
|
|
15378
|
+
// synthetic opening turn from an empty pendingPrompt (a non-empty new-topic
|
|
15379
|
+
// envelope), which would be accepted as a fresh dispatch and overtake the
|
|
15380
|
+
// promoted old head — the exact defect this recovery must avoid.
|
|
15381
|
+
if (ds.quarantinedActivationTailPromotion && (!ds.worker || ds.worker.killed)) {
|
|
15382
|
+
if (forkWorker(ds, '', true)) {
|
|
15383
|
+
logger.info(`[${tag(ds)}] Quarantined activation-tail promotion recovered on inbound; cold-forked promoted head`);
|
|
15384
|
+
}
|
|
15385
|
+
else {
|
|
15386
|
+
logger.warn(`[${tag(ds)}] Quarantined activation-tail promotion still failing on inbound; current turn parked behind old tail, staying quarantined`);
|
|
15387
|
+
}
|
|
15388
|
+
}
|
|
15389
|
+
return;
|
|
14867
15390
|
}
|
|
14868
|
-
|
|
14869
|
-
|
|
14870
|
-
|
|
14871
|
-
|
|
14872
|
-
|
|
15391
|
+
if (ds?.pendingRepo || initialStartPending) {
|
|
15392
|
+
const durableTailReservation = ds.pendingRepo
|
|
15393
|
+
? reserveAsyncQueuedActivationTailAdmission(ds)
|
|
15394
|
+
: undefined;
|
|
15395
|
+
const bufferedCodexAppInputAccepted = initialStartPending && !ds.pendingRepo
|
|
15396
|
+
? codexAppCleanInputAcceptedForSession(ds)
|
|
15397
|
+
: undefined;
|
|
15398
|
+
try {
|
|
15399
|
+
// Enrich content with attachment hints and mention metadata (same as normal send)
|
|
15400
|
+
const codexAppFollowUpContextParts = [];
|
|
15401
|
+
if (codexAppMessageContext)
|
|
15402
|
+
codexAppFollowUpContextParts.push(codexAppMessageContext);
|
|
15403
|
+
const attachmentHint = attachments.length > 0 ? formatAttachmentsHint(attachments) : '';
|
|
15404
|
+
let enriched = attachmentHint
|
|
15405
|
+
? `${promptContent}${attachmentHint}`
|
|
15406
|
+
: promptContent;
|
|
15407
|
+
if (attachmentHint)
|
|
15408
|
+
codexAppFollowUpContextParts.push(attachmentHint);
|
|
15409
|
+
if (parsed.mentions && parsed.mentions.length > 0) {
|
|
15410
|
+
const mentionLines = parsed.mentions.map(m => {
|
|
15411
|
+
const idPart = m.openId ? ` → open_id: ${m.openId}` : '';
|
|
15412
|
+
return `- @${m.name}${idPart}`;
|
|
15413
|
+
});
|
|
15414
|
+
const mentionContext = `${tr('daemon.enriched_mentions_label', undefined, localeForBot(larkAppId))}\n${mentionLines.join('\n')}`;
|
|
15415
|
+
enriched += `\n\n${mentionContext}`;
|
|
15416
|
+
codexAppFollowUpContextParts.push(mentionContext);
|
|
15417
|
+
}
|
|
15418
|
+
// Stamp a buffered follow-up with its own <sender> tag ONLY when it comes
|
|
15419
|
+
// from a different user than the first message (ds.pendingSender) — the
|
|
15420
|
+
// deferred spawn already carries that sender's <sender> block, and the
|
|
15421
|
+
// follow-ups now fold into the same <user_message>, so a same-user tag is
|
|
15422
|
+
// pure duplication. A differing sender still gets attributed so the CLI can
|
|
15423
|
+
// tell multi-user buffered messages apart after repo selection unlocks.
|
|
15424
|
+
// This branch is also the synchronization barrier for a pending first
|
|
15425
|
+
// worker. Do not await contact/name resolution here: a commit waiting on
|
|
15426
|
+
// getAvailableBots could otherwise resume first, snapshot stale buffers,
|
|
15427
|
+
// and fork before this message is appended. Event identity is sufficient
|
|
15428
|
+
// for caller equality and sender attribution; names remain optional.
|
|
15429
|
+
const followUpSender = callerOpenId
|
|
15430
|
+
? { openId: callerOpenId, type: isForeignBot ? 'bot' : 'user' }
|
|
15431
|
+
: undefined;
|
|
15432
|
+
const hadBufferedFollowUps = (ds.pendingFollowUps?.length ?? 0) > 0;
|
|
15433
|
+
const sameInitialCaller = !!followUpSender?.openId
|
|
15434
|
+
&& followUpSender.openId === ds.pendingSender?.openId;
|
|
15435
|
+
if (followUpSender?.openId && followUpSender.openId !== ds.pendingSender?.openId) {
|
|
15436
|
+
// This buffer folds into the opening <user_message> after repo selection,
|
|
15437
|
+
// so pair the foreign sender tag with the cursor anti-echo note: without
|
|
15438
|
+
// the adjacent note a cursor session sees an inline ou_xxx:name with no
|
|
15439
|
+
// guard (the builder's own note only covers ds.pendingSender's top-level
|
|
15440
|
+
// tag, and is absent entirely when pendingSender is undefined).
|
|
15441
|
+
const followUpSenderBlock = renderBufferedSenderBlock(followUpSender, getBot(larkAppId).config.cliId, localeForBot(larkAppId));
|
|
15442
|
+
if (followUpSenderBlock) {
|
|
15443
|
+
enriched = `${followUpSenderBlock}\n${enriched}`;
|
|
15444
|
+
codexAppFollowUpContextParts.unshift(followUpSenderBlock);
|
|
15445
|
+
}
|
|
15446
|
+
}
|
|
15447
|
+
if (ds.pendingRepo) {
|
|
15448
|
+
const hasOpening = (ds.pendingPrompt?.trim().length ?? 0) > 0
|
|
15449
|
+
|| (ds.pendingAttachments?.length ?? 0) > 0
|
|
15450
|
+
|| !!ds.pendingRawInput;
|
|
15451
|
+
ds.session.queued = true;
|
|
15452
|
+
if (!hasOpening) {
|
|
15453
|
+
// A bare /repo placeholder has no N yet. This inbound becomes the
|
|
15454
|
+
// durable opening instead of an impossible successor to an empty ACK.
|
|
15455
|
+
ds.pendingPrompt = promptContent;
|
|
15456
|
+
ds.pendingTurnId = parsed.messageId;
|
|
15457
|
+
ds.pendingCodexAppText = parsed.content;
|
|
15458
|
+
ds.pendingCodexAppApplicationContext = codexAppApplicationContext;
|
|
15459
|
+
ds.pendingCodexAppMessageContext = codexAppMessageContext;
|
|
15460
|
+
ds.pendingAttachments = attachments.length > 0 ? attachments : undefined;
|
|
15461
|
+
ds.pendingMentions = parsed.mentions;
|
|
15462
|
+
ds.pendingSender = followUpSender;
|
|
15463
|
+
// R6-B1: this inbound becomes the durable opening — freeze its plain-human
|
|
15464
|
+
// steer authorization so the repo-selected opening isn't forced-serial.
|
|
15465
|
+
ds.pendingCodexAppSteerable = codexAppSteerable ? true : undefined;
|
|
15466
|
+
const existingSetup = ds.session.pendingRepoSetup;
|
|
15467
|
+
stagePendingRepoSetup(ds, {
|
|
15468
|
+
mode: existingSetup?.mode ?? 'picker',
|
|
15469
|
+
...(existingSetup?.baseDir ? { baseDir: existingSetup.baseDir } : {}),
|
|
15470
|
+
turnId: parsed.messageId,
|
|
15471
|
+
});
|
|
15472
|
+
}
|
|
15473
|
+
else {
|
|
15474
|
+
const botCfg = getBot(ds.larkAppId).config;
|
|
15475
|
+
const exactFollowUp = buildFollowUpCliInput(promptContent, ds.session.sessionId, {
|
|
15476
|
+
attachments,
|
|
15477
|
+
mentions: parsed.mentions,
|
|
15478
|
+
isAdoptMode: false,
|
|
15479
|
+
cliId: ds.session.cliId ?? botCfg.cliId,
|
|
15480
|
+
cliPathOverride: ds.session.cliPathOverride ?? botCfg.cliPathOverride,
|
|
15481
|
+
sender: followUpSender,
|
|
15482
|
+
larkAppId,
|
|
15483
|
+
chatId: ds.session.chatId,
|
|
15484
|
+
whiteboardId: ds.session.whiteboardId,
|
|
15485
|
+
substituteTrigger,
|
|
15486
|
+
codexAppText: parsed.content,
|
|
15487
|
+
codexAppApplicationContext,
|
|
15488
|
+
codexAppMessageContext,
|
|
15489
|
+
});
|
|
15490
|
+
ds.session.queuedPrompt ??= ds.pendingPrompt;
|
|
15491
|
+
ds.session.queuedCodexAppText ??= ds.pendingCodexAppText;
|
|
15492
|
+
ds.session.queuedCodexAppMessageContext ??= ds.pendingCodexAppMessageContext;
|
|
15493
|
+
// R5-B1-1: freeze steer authorization onto the pending-repo follower tail.
|
|
15494
|
+
if (codexAppSteerable)
|
|
15495
|
+
exactFollowUp.codexAppSteerable = true;
|
|
15496
|
+
admitQueuedActivationTail(ds, {
|
|
15497
|
+
userPrompt: promptContent,
|
|
15498
|
+
cliInput: exactFollowUp,
|
|
15499
|
+
turnId: parsed.messageId,
|
|
15500
|
+
}, durableTailReservation);
|
|
15501
|
+
}
|
|
15502
|
+
const pendingReplyKey = ds.worktreeCreating
|
|
15503
|
+
? 'daemon.worktree_building_wait'
|
|
15504
|
+
: 'daemon.choose_repo_first';
|
|
15505
|
+
await sessionReply(anchor, tr(pendingReplyKey, undefined, localeForBot(larkAppId)), 'text', larkAppId);
|
|
15506
|
+
return;
|
|
15507
|
+
}
|
|
15508
|
+
// Initial-start compatibility path after repo selection: only the durable
|
|
15509
|
+
// queued-activation gate belongs here. pendingRepoCommitInFlight protects a
|
|
15510
|
+
// second repo selection, but must not divert ordinary turns away from the
|
|
15511
|
+
// already-forked worker into source buffers that will never be consumed.
|
|
15512
|
+
// (sameInitialCaller is computed once above, before the pendingRepo branch.)
|
|
15513
|
+
if (ds.pendingRawInput) {
|
|
15514
|
+
if ((!hadBufferedFollowUps || ds.pendingFollowUpTurnId) && sameInitialCaller) {
|
|
15515
|
+
// The persisted quote target is a single latest-turn pointer. Treat a
|
|
15516
|
+
// same-caller raw command + buffered follow-ups as one batch and join
|
|
15517
|
+
// every staged PTY input to that latest turn. Keeping the raw command
|
|
15518
|
+
// on its older id would fail provenance as soon as the follow-up moves
|
|
15519
|
+
// the durable pointer forward.
|
|
15520
|
+
ds.pendingRawTurnId = parsed.messageId;
|
|
15521
|
+
ds.pendingFollowUpTurnId = parsed.messageId;
|
|
15522
|
+
}
|
|
15523
|
+
else {
|
|
15524
|
+
// Mixed callers cannot share one durable turn pointer. Revoke the
|
|
15525
|
+
// entire batch rather than letting either identity borrow the other.
|
|
15526
|
+
ds.pendingRawTurnId = undefined;
|
|
15527
|
+
ds.pendingFollowUpTurnId = undefined;
|
|
15528
|
+
}
|
|
15529
|
+
}
|
|
15530
|
+
else if (ds.pendingTurnId) {
|
|
15531
|
+
// One deferred model prompt may fold several same-user messages together;
|
|
15532
|
+
// bind it to the newest exact message so the durable quote and marker
|
|
15533
|
+
// still join. If another user contributes, clear authority instead of
|
|
15534
|
+
// letting either identity authorize the combined prompt.
|
|
15535
|
+
if (sameInitialCaller) {
|
|
15536
|
+
ds.pendingTurnId = parsed.messageId;
|
|
15537
|
+
}
|
|
15538
|
+
else {
|
|
15539
|
+
ds.pendingTurnId = undefined;
|
|
15540
|
+
}
|
|
15541
|
+
}
|
|
15542
|
+
if (!ds.pendingFollowUps)
|
|
15543
|
+
ds.pendingFollowUps = [];
|
|
15544
|
+
ds.pendingFollowUps.push(enriched);
|
|
15545
|
+
if (!ds.pendingFollowUpTurnIds)
|
|
15546
|
+
ds.pendingFollowUpTurnIds = [];
|
|
15547
|
+
ds.pendingFollowUpTurnIds.push(parsed.messageId);
|
|
15548
|
+
if (!ds.pendingCodexAppFollowUps)
|
|
15549
|
+
ds.pendingCodexAppFollowUps = [];
|
|
15550
|
+
ds.pendingCodexAppFollowUps.push(parsed.content);
|
|
15551
|
+
if (!ds.pendingCodexAppFollowUpContexts)
|
|
15552
|
+
ds.pendingCodexAppFollowUpContexts = [];
|
|
15553
|
+
ds.pendingCodexAppFollowUpContexts.push(codexAppFollowUpContextParts.join('\n\n'));
|
|
15554
|
+
if (!ds.pendingCodexAppFollowUpGateAccepted) {
|
|
15555
|
+
ds.pendingCodexAppFollowUpGateAccepted = [];
|
|
15556
|
+
}
|
|
15557
|
+
ds.pendingCodexAppFollowUpGateAccepted.push(bufferedCodexAppInputAccepted === true);
|
|
15558
|
+
if (codexAppApplicationContext) {
|
|
15559
|
+
ds.pendingCodexAppApplicationContext = mergeVcMeetingApplicationContext(ds.pendingCodexAppApplicationContext, codexAppApplicationContext);
|
|
15560
|
+
}
|
|
15561
|
+
if (initialStartPending && !ds.pendingRepo) {
|
|
15562
|
+
logger.info(`[${tag(ds)}] buffered same-anchor turn ${parsed.messageId.substring(0, 12)} `
|
|
15563
|
+
+ 'behind reserved initial start');
|
|
15564
|
+
return;
|
|
15565
|
+
}
|
|
15566
|
+
// Auto-worktree pending (worktreeCreating) has no repo card to point at — the
|
|
15567
|
+
// message IS buffered (folded on commit), so just say "hold on, building worktree"
|
|
15568
|
+
// instead of the misleading "pick a repo from the card above".
|
|
15569
|
+
const pendingReplyKey = (ds.worktreeCreating || ds.pendingRepoCommitInFlight)
|
|
15570
|
+
? 'daemon.worktree_building_wait'
|
|
15571
|
+
: 'daemon.choose_repo_first';
|
|
15572
|
+
await sessionReply(anchor, tr(pendingReplyKey, undefined, localeForBot(larkAppId)), 'text', larkAppId);
|
|
15573
|
+
return;
|
|
15574
|
+
}
|
|
15575
|
+
finally {
|
|
15576
|
+
if (durableTailReservation)
|
|
15577
|
+
settleAsyncQueuedActivationTailAdmission(ds);
|
|
15578
|
+
}
|
|
14873
15579
|
}
|
|
14874
|
-
|
|
14875
|
-
|
|
14876
|
-
|
|
14877
|
-
|
|
14878
|
-
|
|
14879
|
-
|
|
14880
|
-
|
|
14881
|
-
|
|
14882
|
-
|
|
14883
|
-
|
|
14884
|
-
|
|
14885
|
-
|
|
14886
|
-
|
|
14887
|
-
|
|
14888
|
-
|
|
14889
|
-
|
|
14890
|
-
|
|
14891
|
-
|
|
14892
|
-
|
|
14893
|
-
|
|
14894
|
-
|
|
14895
|
-
|
|
14896
|
-
|
|
14897
|
-
|
|
14898
|
-
|
|
14899
|
-
|
|
14900
|
-
|
|
14901
|
-
|
|
14902
|
-
|
|
14903
|
-
|
|
14904
|
-
|
|
14905
|
-
|
|
14906
|
-
|
|
14907
|
-
|
|
14908
|
-
|
|
14909
|
-
|
|
14910
|
-
|
|
14911
|
-
|
|
14912
|
-
|
|
14913
|
-
|
|
14914
|
-
|
|
14915
|
-
|
|
14916
|
-
|
|
14917
|
-
|
|
14918
|
-
|
|
14919
|
-
|
|
14920
|
-
|
|
14921
|
-
|
|
14922
|
-
|
|
14923
|
-
|
|
14924
|
-
|
|
14925
|
-
|
|
14926
|
-
|
|
14927
|
-
|
|
14928
|
-
|
|
14929
|
-
|
|
14930
|
-
|
|
14931
|
-
|
|
14932
|
-
|
|
14933
|
-
currentText: parsed.content,
|
|
14934
|
-
currentMessageContext: codexAppMessageContext,
|
|
14935
|
-
});
|
|
14936
|
-
// Empty-started session that lost its worker (daemon restart, idle sweep,
|
|
14937
|
-
// CLI exit) — same rule as the live branch: this is still the FIRST real
|
|
14938
|
-
// user turn, so it must be built as a new topic. A queued(待办池) activation
|
|
14939
|
-
// is excluded: its queuedPrompt already owns the first turn.
|
|
14940
|
-
const wantsOpening = !ds.adoptedFrom && !queuedDashboardTurn && isInitialUserTurnPending(ds);
|
|
14941
|
-
const openingBots = wantsOpening ? await getAvailableBots(larkAppId, ds.chatId) : undefined;
|
|
14942
|
-
const reforkSender = await getThreadSender();
|
|
14943
|
-
// An empty-started CLI has nothing to resume: `hasHistory` is set
|
|
14944
|
-
// unconditionally by restoreActiveSessions (and by claude_exit /
|
|
14945
|
-
// suspendWorker), so it cannot tell "booted idle" from "has real history".
|
|
14946
|
-
// `session.lastCliInput` can: rememberLastCliInput writes it on EVERY real
|
|
14947
|
-
// CLI input, and the empty-start fork deliberately writes none. Snapshot it
|
|
14948
|
-
// BEFORE this turn's own rememberLastCliInput below, so a session some
|
|
14949
|
-
// non-IM path (scheduler / webhook trigger / doc comment) already fed keeps
|
|
14950
|
-
// its normal `--resume` instead of being cold-spawned over.
|
|
14951
|
-
const hadPriorCliInput = !!(ds.lastCliInput ?? ds.session.lastCliInput);
|
|
14952
|
-
const openingTurn = wantsOpening && claimInitialUserTurn(ds);
|
|
14953
|
-
const builtReforkInput = buildReforkCliInput(ds, reforkContent, {
|
|
14954
|
-
attachments,
|
|
14955
|
-
mentions: parsed.mentions,
|
|
14956
|
-
cliId: ds.session.cliId ?? dsBotCfgForFork.cliId,
|
|
14957
|
-
cliPathOverride: ds.session.cliPathOverride ?? dsBotCfgForFork.cliPathOverride,
|
|
14958
|
-
selfMention: { name: selfBot.botName, openId: selfBot.botOpenId },
|
|
14959
|
-
sender: reforkSender,
|
|
14960
|
-
substituteTrigger,
|
|
14961
|
-
codexAppText: reforkCodexApp.text,
|
|
14962
|
-
codexAppApplicationContext,
|
|
14963
|
-
codexAppMessageContext: reforkCodexApp.messageContext,
|
|
14964
|
-
});
|
|
14965
|
-
let wrappedInput = applyQueuedCodexAppLegacyFallback(builtReforkInput, {
|
|
14966
|
-
queued: queuedDashboardTurn,
|
|
14967
|
-
queuedText: queuedCodexAppText,
|
|
14968
|
-
});
|
|
14969
|
-
if (wrappedInput !== builtReforkInput && dsBotCfgForFork.codexAppCleanInput === true) {
|
|
14970
|
-
// Backlog sessions persisted before clean-input have no raw queued text.
|
|
14971
|
-
// Keep this activation entirely legacy: reforkContent already contains
|
|
14972
|
-
// queuedPrompt + the current reply, whereas a structured turn could only
|
|
14973
|
-
// contain the reply and would silently discard the original task.
|
|
14974
|
-
logger.warn(`[${tag(ds)}] Legacy queued dashboard task has no clean-input text; using the full legacy activation prompt`);
|
|
14975
|
-
}
|
|
14976
|
-
if (openingTurn) {
|
|
14977
|
-
// Replace the follow-up envelope built above with the real opening. The
|
|
14978
|
-
// discarded build is pure string assembly (no side effects) — keeping the
|
|
14979
|
-
// refork statement unconditional keeps the queued/substitute wiring, and
|
|
14980
|
-
// its guard test, on a single code path.
|
|
14981
|
-
wrappedInput = buildNewTopicCliInput(reforkContent, ds.session.sessionId, ds.session.cliId ?? dsBotCfgForFork.cliId, ds.session.cliPathOverride ?? dsBotCfgForFork.cliPathOverride, attachments, parsed.mentions, openingBots, undefined, { name: selfBot.botName, openId: selfBot.botOpenId }, localeForBot(larkAppId), reforkSender, {
|
|
15580
|
+
if (!ds) {
|
|
15581
|
+
// No active session at this anchor — auto-create. This branch is mostly a
|
|
15582
|
+
// safety net; the dispatcher routes here only when isSessionOwner() returns
|
|
15583
|
+
// true, but races (between check and execution, or session-closed events)
|
|
15584
|
+
// can land us here.
|
|
15585
|
+
if (activeSessions.has(sessionKey(anchor, larkAppId))) {
|
|
15586
|
+
logger.info(`[${larkAppId}] Session already exists for ${scope}-scope ${anchor}, routing to canonical owner`);
|
|
15587
|
+
await handleThreadReplyAdmitted(data, ctx);
|
|
15588
|
+
return;
|
|
15589
|
+
}
|
|
15590
|
+
const autoCreateChatId = ctxChatId ?? data?.message?.chat_id ?? '';
|
|
15591
|
+
const autoCreateChatType = ctxChatType ?? (data?.message?.chat_type === 'p2p' ? 'p2p' : 'group');
|
|
15592
|
+
const botCfg = getBot(larkAppId).config;
|
|
15593
|
+
const groupChatNamePromise = resolveGroupChatNameForNativeTitle(larkAppId, autoCreateChatId, autoCreateChatType, botCfg.cliId, parsed.content, parsed.mentions);
|
|
15594
|
+
logger.info(`No active session for ${scope}-scope ${anchor}, auto-creating new session...`);
|
|
15595
|
+
refreshCliVersion(botCfg);
|
|
15596
|
+
const senderOId = data.sender?.sender_id?.open_id;
|
|
15597
|
+
const senderUId = data.sender?.sender_id?.union_id;
|
|
15598
|
+
// For thread-scope: rootMessageId = anchor (real thread root).
|
|
15599
|
+
// For chat-scope: rootMessageId = the message_id that triggered this auto-create
|
|
15600
|
+
// (used as audit trail; routing key is chatId).
|
|
15601
|
+
const rootIdForStore = scope === 'thread' ? anchor : parsed.messageId;
|
|
15602
|
+
const session = sessionStore.createSession(autoCreateChatId, rootIdForStore, parsed.content.substring(0, 50), autoCreateChatType);
|
|
15603
|
+
const now = Date.now();
|
|
15604
|
+
// Bot-started handoff sessions have no human owner; keeping the bot as
|
|
15605
|
+
// owner makes daemon-generated footers wake that bot again.
|
|
15606
|
+
const ownerOpenId = isForeignBot ? undefined : senderOId;
|
|
15607
|
+
const ownerUnionId = isForeignBot ? undefined : senderUId;
|
|
15608
|
+
session.larkAppId = larkAppId;
|
|
15609
|
+
session.ownerOpenId = ownerOpenId;
|
|
15610
|
+
// creatorOpenId is the raw creating sender — set even for foreign-bot
|
|
15611
|
+
// sessions (unlike ownerOpenId, nulled above) so `botmux report` can find the
|
|
15612
|
+
// dispatch orchestrator on a no-`/repo` kickoff auto-create. See Session.creatorOpenId.
|
|
15613
|
+
session.creatorOpenId = senderOId;
|
|
15614
|
+
session.ownerUnionId = ownerUnionId;
|
|
15615
|
+
session.lastCallerOpenId = senderOId;
|
|
15616
|
+
session.quoteTargetId = parsed.messageId;
|
|
15617
|
+
session.quoteTargetSenderOpenId = senderOId;
|
|
15618
|
+
session.quoteTargetSenderIsBot = isForeignBot;
|
|
15619
|
+
session.lastMessageAt = new Date(now).toISOString();
|
|
15620
|
+
session.scope = scope;
|
|
15621
|
+
const groupChatName = await groupChatNamePromise;
|
|
15622
|
+
if (groupChatName)
|
|
15623
|
+
session.chatDisplayName = groupChatName;
|
|
15624
|
+
session.nativeSessionTitle = buildBotmuxLarkNativeSessionTitle(parsed.content, parsed.mentions, groupChatName);
|
|
15625
|
+
sessionStore.updateSession(session);
|
|
15626
|
+
// chat-scope only — see the handleNewTopic twin above (topic substitute
|
|
15627
|
+
// sessions keep their streaming card, no control-card compensation).
|
|
15628
|
+
const shouldSendSubstituteControlCard = substituteTrigger
|
|
15629
|
+
&& scope === 'chat'
|
|
15630
|
+
&& !botCfg.substituteMode?.disableControlCard
|
|
15631
|
+
&& !session.substituteControlCardSent;
|
|
15632
|
+
// Use the same layered oncall / inherit / default lookup as handleNewTopic
|
|
15633
|
+
// so stale inherited peers are ignored consistently in both spawn paths.
|
|
15634
|
+
const { pinnedWorkingDir, oncallEntry, inheritedFrom, pinnedFromBotDefault } = await resolvePinnedWorkingDir({
|
|
15635
|
+
scope,
|
|
15636
|
+
anchor,
|
|
15637
|
+
chatId: autoCreateChatId,
|
|
15638
|
+
chatType: autoCreateChatType,
|
|
14982
15639
|
larkAppId,
|
|
14983
|
-
|
|
14984
|
-
|
|
14985
|
-
|
|
15640
|
+
});
|
|
15641
|
+
const autoWt = willAutoWorktree(larkAppId, pinnedWorkingDir, pinnedFromBotDefault);
|
|
15642
|
+
// Now we know the message will spawn or pend a real session — resolve
|
|
15643
|
+
// sender (may await contact API budget) since every downstream branch
|
|
15644
|
+
// injects it either into the immediate prompt or stashes it on
|
|
15645
|
+
// pendingSender for the deferred spawn.
|
|
15646
|
+
const autoCreateSender = await getThreadSender();
|
|
15647
|
+
setDirectChatDisplayNameFromSender(session, autoCreateChatType, autoCreateSender);
|
|
15648
|
+
const newDs = {
|
|
15649
|
+
session,
|
|
15650
|
+
worker: null,
|
|
15651
|
+
workerPort: null,
|
|
15652
|
+
workerToken: null,
|
|
15653
|
+
larkAppId,
|
|
15654
|
+
chatId: autoCreateChatId,
|
|
15655
|
+
chatType: autoCreateChatType,
|
|
15656
|
+
scope,
|
|
15657
|
+
spawnedAt: Date.parse(session.createdAt) || now,
|
|
15658
|
+
cliVersion: cliVersionCache.get(cliRuntimeVersionKey(botCfg))?.version ?? 'unknown',
|
|
15659
|
+
lastMessageAt: now,
|
|
15660
|
+
hasHistory: false,
|
|
15661
|
+
initialStartPending: true,
|
|
15662
|
+
pendingRepo: !pinnedWorkingDir || autoWt,
|
|
15663
|
+
pendingPrompt: promptContent,
|
|
15664
|
+
pendingTurnId: parsed.messageId,
|
|
15665
|
+
pendingCodexAppText: parsed.content,
|
|
15666
|
+
pendingCodexAppApplicationContext: codexAppApplicationContext,
|
|
15667
|
+
pendingCodexAppMessageContext: codexAppMessageContext,
|
|
15668
|
+
pendingAttachments: attachments.length > 0 ? attachments : undefined,
|
|
15669
|
+
pendingMentions: parsed.mentions,
|
|
15670
|
+
pendingSubstituteTrigger: substituteTrigger,
|
|
15671
|
+
pendingSubstituteControlCard: shouldSendSubstituteControlCard,
|
|
15672
|
+
pendingSender: autoCreateSender,
|
|
15673
|
+
// R5-B1-1: freeze the plain-human steer authorization into the new-topic
|
|
15674
|
+
// opening metadata. buildReservedInitialInput COPIES this onto the built
|
|
15675
|
+
// opening payload; forkReservedInitialSession never infers it (that shared
|
|
15676
|
+
// helper is also used by bot-added / scheduler / system bootstrap).
|
|
15677
|
+
...(codexAppSteerable ? { pendingCodexAppSteerable: true } : {}),
|
|
15678
|
+
ownerOpenId,
|
|
15679
|
+
currentTurnTitle: parsed.content.substring(0, 50),
|
|
15680
|
+
workingDir: pinnedWorkingDir,
|
|
15681
|
+
};
|
|
15682
|
+
if (pinnedWorkingDir) {
|
|
15683
|
+
newDs.session.workingDir = pinnedWorkingDir;
|
|
15684
|
+
sessionStore.updateSession(newDs.session);
|
|
15685
|
+
}
|
|
15686
|
+
const substituteReplyMode = substituteTrigger
|
|
15687
|
+
? (botCfg.substituteMode?.replyMode ?? 'thread')
|
|
15688
|
+
: 'thread';
|
|
15689
|
+
const autoCreatePostAt = prepared?.postParticipantMentions ?? collectPostAtMentions(data?.message, ctx.forwardSeedData?.message);
|
|
15690
|
+
const autoCreateWindow = buildTurnParticipants(larkAppId, senderOId, senderIsBotTriState(parsed.senderType, isForeignBot), parsed.mentions, autoCreateSender?.name, autoCreatePostAt);
|
|
15691
|
+
beginReplyTargetTurn(newDs, replyRootId, parsed.messageId, new Date().toISOString(), { quoteOnly: substituteReplyMode === 'quote', substitute: !!substituteTrigger, senderOpenId: senderOId, participants: autoCreateWindow.participants, participantsIncomplete: autoCreateWindow.incomplete });
|
|
15692
|
+
sessionStore.updateSession(newDs.session);
|
|
15693
|
+
const registration = await claimNewDaemonSession(activeSessions, newDs);
|
|
15694
|
+
if (!registration.accepted) {
|
|
15695
|
+
if (registration.reason === 'existing_owner'
|
|
15696
|
+
&& activeSessions.get(registration.key) === registration.owner) {
|
|
15697
|
+
// Second CAS loser (auto-create). Reroute to the canonical owner with the
|
|
15698
|
+
// SAME fully-prepared reply — NOT a bare re-parse. On a double-race
|
|
15699
|
+
// (new-topic scratch → auto-create → auto-create loses again) a raw
|
|
15700
|
+
// reroute here would drop the seed's structured mentions + post @s that
|
|
15701
|
+
// survived the first prepared handoff. `parsed` already holds the merged
|
|
15702
|
+
// seed+follow-up mentions; autoCreatePostAt already folds
|
|
15703
|
+
// prepared.postParticipantMentions ?? forward-seed post @s.
|
|
15704
|
+
await handleThreadReplyAdmitted(data, ctx, {
|
|
15705
|
+
parsed,
|
|
15706
|
+
resources,
|
|
15707
|
+
attachments,
|
|
15708
|
+
quotaChecked: true,
|
|
15709
|
+
queueAlreadyAppended: false,
|
|
15710
|
+
senderResolved: true,
|
|
15711
|
+
sender: autoCreateSender,
|
|
15712
|
+
postParticipantMentions: autoCreatePostAt,
|
|
15713
|
+
});
|
|
15714
|
+
}
|
|
15715
|
+
return;
|
|
15716
|
+
}
|
|
15717
|
+
if (newDs.pendingRepo) {
|
|
15718
|
+
stageClaimedPendingRepoSetup(activeSessions, newDs, {
|
|
15719
|
+
mode: autoWt ? 'auto_worktree' : 'picker',
|
|
15720
|
+
...(autoWt && pinnedWorkingDir ? { baseDir: pinnedWorkingDir } : {}),
|
|
15721
|
+
turnId: parsed.messageId,
|
|
15722
|
+
});
|
|
15723
|
+
}
|
|
15724
|
+
// Route to file queue only after ownership is committed. A rejected
|
|
15725
|
+
// creator re-enters the canonical route above, which appends exactly once.
|
|
15726
|
+
messageQueue.ensureQueue(anchor);
|
|
15727
|
+
messageQueue.appendMessage(anchor, parsed);
|
|
15728
|
+
// Auto-worktree: register PENDING, build worktree off-path, commit+fork later.
|
|
15729
|
+
if (pinnedWorkingDir && autoWt) {
|
|
15730
|
+
if (await replyInvalidWorkingDirs(anchor, larkAppId, newDs))
|
|
15731
|
+
return;
|
|
15732
|
+
newDs.initialStartPending = false; // pendingRepo/worktree now owns buffering
|
|
15733
|
+
startAutoWorktreePending(newDs, { anchor, baseDir: pinnedWorkingDir, title: parsed.content.substring(0, 50), prompt: promptContent, operatorOpenId: ownerOpenId });
|
|
15734
|
+
return;
|
|
15735
|
+
}
|
|
15736
|
+
// Pinned (oncall binding or inherited from peer bot in same thread):
|
|
15737
|
+
// spawn CLI immediately, skip repo selection.
|
|
15738
|
+
if (pinnedWorkingDir) {
|
|
15739
|
+
if (await replyInvalidWorkingDirs(anchor, larkAppId, newDs))
|
|
15740
|
+
return;
|
|
15741
|
+
ensureSessionWhiteboard(newDs);
|
|
15742
|
+
const availableBots = await getAvailableBots(larkAppId, autoCreateChatId);
|
|
15743
|
+
await noteTurnReceived(newDs, parsed.messageId, parsed.content, autoCreateSender, parsed.messageId, substituteTrigger ? SUBSTITUTE_RECEIVED_REACTION_EMOJI_TYPE : undefined);
|
|
15744
|
+
forkReservedInitialSession(newDs, availableBots);
|
|
15745
|
+
const reason = oncallEntry
|
|
15746
|
+
? `oncall-bound chat ${autoCreateChatId}`
|
|
15747
|
+
: inheritedFrom
|
|
15748
|
+
? `inherited from peer session ${inheritedFrom.sessionId.substring(0, 8)} (app=${inheritedFrom.larkAppId ?? 'unknown'})`
|
|
15749
|
+
: `bot defaultWorkingDir`;
|
|
15750
|
+
logger.info(`[${tag(newDs)}] ${reason} → workingDir=${pinnedWorkingDir}, skipped repo select`);
|
|
15751
|
+
return;
|
|
15752
|
+
}
|
|
15753
|
+
// Show repo selection card (same as handleNewTopic)
|
|
15754
|
+
if (await replyInvalidWorkingDirs(anchor, larkAppId, newDs))
|
|
15755
|
+
return;
|
|
15756
|
+
const scanDirs2 = getProjectScanDirs(newDs).filter(d => existsSync(d));
|
|
15757
|
+
let projects = [];
|
|
15758
|
+
if (scanDirs2.length > 0) {
|
|
15759
|
+
projects = scanMultipleProjects(scanDirs2, 3, repoPickerScanOptions());
|
|
15760
|
+
}
|
|
15761
|
+
if (projects.length > 0) {
|
|
15762
|
+
newDs.initialStartPending = false; // pendingRepo/card now owns buffering
|
|
15763
|
+
lastRepoScan.set(autoCreateChatId, projects);
|
|
15764
|
+
const currentCwd = getSessionWorkingDir(newDs);
|
|
15765
|
+
const cardJson = buildRepoSelectCard(projects, currentCwd, anchor, localeForBot(larkAppId), getBot(larkAppId).config.worktreeMultiPicker);
|
|
15766
|
+
newDs.repoCardMessageId = await sessionReply(anchor, cardJson, 'interactive', larkAppId);
|
|
15767
|
+
persistPendingRepoCardMessageId(newDs, newDs.repoCardMessageId);
|
|
15768
|
+
announcePendingRepoSession(newDs);
|
|
15769
|
+
logger.info(`[${tag(newDs)}] Waiting for repo selection (${projects.length} projects)`);
|
|
15770
|
+
}
|
|
15771
|
+
else {
|
|
15772
|
+
// No projects found — skip repo selection, spawn directly
|
|
15773
|
+
newDs.pendingRepo = false;
|
|
15774
|
+
ensureSessionWhiteboard(newDs);
|
|
15775
|
+
const availableBots = await getAvailableBots(larkAppId, autoCreateChatId);
|
|
15776
|
+
await noteTurnReceived(newDs, parsed.messageId, parsed.content, autoCreateSender, parsed.messageId, substituteTrigger ? SUBSTITUTE_RECEIVED_REACTION_EMOJI_TYPE : undefined);
|
|
15777
|
+
forkReservedInitialSession(newDs, availableBots);
|
|
15778
|
+
}
|
|
15779
|
+
return;
|
|
15780
|
+
}
|
|
15781
|
+
// Existing-owner route: append once after all pending-repo early returns.
|
|
15782
|
+
messageQueue.ensureQueue(anchor);
|
|
15783
|
+
messageQueue.appendMessage(anchor, parsed);
|
|
15784
|
+
publishSessionMessagePreviewPatch(ds);
|
|
15785
|
+
// codexAppSteerable was computed ONCE above (R5-B1-1), before every admission /
|
|
15786
|
+
// fork branch; reuse that frozen value for the live-worker / worker-null split.
|
|
15787
|
+
// Send message to worker via IPC
|
|
15788
|
+
if (ds.worker && !ds.worker.killed) {
|
|
15789
|
+
const dsBotCfgForMsg = getBot(ds.larkAppId).config;
|
|
15790
|
+
// Adopt mode: the adopted CLI is the user's external process and was
|
|
15791
|
+
// never injected with botmux's skill / system prompt. Sending it the
|
|
15792
|
+
// `<user_message>` / `<botmux_reminder>` / `<session_id>` wrappers
|
|
15793
|
+
// surfaces those tags verbatim in its UI (the user reported Codex
|
|
15794
|
+
// showing raw XML on every Lark message). Use the bridge raw-input
|
|
15795
|
+
// builder for ALL adopt sessions regardless of cliId — transcript
|
|
15796
|
+
// harvest (Claude bridge or Codex bridge) handles the reply path
|
|
15797
|
+
// out-of-band.
|
|
15798
|
+
const isBridge = !!ds.adoptedFrom;
|
|
15799
|
+
const selfBot = getBot(ds.larkAppId);
|
|
15800
|
+
if (!isBridge)
|
|
15801
|
+
ensureSessionWhiteboard(ds);
|
|
15802
|
+
const effectiveCliId = ds.session.cliId ?? dsBotCfgForMsg.cliId;
|
|
15803
|
+
// Empty-started session (repo select/skip/switch booted the CLI with no
|
|
15804
|
+
// turn): a LIVE worker is not proof the CLI ever saw botmux's opening
|
|
15805
|
+
// context — only `buildNewTopicCliInput` emits <botmux_routing> /
|
|
15806
|
+
// <botmux_builtin_skills> / <identity>. Probe (non-consuming) before the
|
|
15807
|
+
// awaits below, then claim SYNCHRONOUSLY right before building so two
|
|
15808
|
+
// near-simultaneous first messages can only produce one opener; the loser
|
|
15809
|
+
// degrades to an ordinary follow-up in queue order.
|
|
15810
|
+
const wantsOpening = !isBridge && isInitialUserTurnPending(ds);
|
|
15811
|
+
const openingBots = wantsOpening ? await getAvailableBots(larkAppId, ds.chatId) : undefined;
|
|
15812
|
+
const turnSender = await getThreadSender();
|
|
15813
|
+
const openingTurn = wantsOpening && claimInitialUserTurn(ds);
|
|
15814
|
+
const cliInput = isBridge
|
|
15815
|
+
? { content: buildBridgeInputContent(promptContent, {
|
|
15816
|
+
attachments,
|
|
15817
|
+
mentions: parsed.mentions,
|
|
15818
|
+
selfMention: { name: selfBot.botName, openId: selfBot.botOpenId },
|
|
15819
|
+
}) }
|
|
15820
|
+
: openingTurn
|
|
15821
|
+
? buildNewTopicCliInput(promptContent, ds.session.sessionId, effectiveCliId, ds.session.cliPathOverride ?? dsBotCfgForMsg.cliPathOverride, attachments, parsed.mentions, openingBots, undefined, { name: selfBot.botName, openId: selfBot.botOpenId }, localeForBot(larkAppId), turnSender, {
|
|
15822
|
+
larkAppId,
|
|
15823
|
+
chatId: ds.session.chatId,
|
|
15824
|
+
whiteboardId: ds.session.whiteboardId,
|
|
15825
|
+
substituteTrigger,
|
|
15826
|
+
codexAppText: parsed.content,
|
|
15827
|
+
codexAppApplicationContext,
|
|
15828
|
+
codexAppMessageContext,
|
|
15829
|
+
})
|
|
15830
|
+
: buildFollowUpCliInput(promptContent, ds.session.sessionId, {
|
|
15831
|
+
attachments,
|
|
15832
|
+
mentions: parsed.mentions,
|
|
15833
|
+
isAdoptMode: false,
|
|
15834
|
+
cliId: effectiveCliId,
|
|
15835
|
+
cliPathOverride: ds.session.cliPathOverride ?? dsBotCfgForMsg.cliPathOverride,
|
|
15836
|
+
sender: turnSender,
|
|
15837
|
+
larkAppId,
|
|
15838
|
+
chatId: ds.session.chatId,
|
|
15839
|
+
whiteboardId: ds.session.whiteboardId,
|
|
15840
|
+
substituteTrigger,
|
|
15841
|
+
codexAppText: parsed.content,
|
|
15842
|
+
codexAppApplicationContext,
|
|
15843
|
+
codexAppMessageContext,
|
|
15844
|
+
});
|
|
15845
|
+
beginNewTurn(ds, parsed.content);
|
|
15846
|
+
await noteTurnReceived(ds, parsed.messageId, parsed.content, turnSender, parsed.messageId, substituteTrigger ? SUBSTITUTE_RECEIVED_REACTION_EMOJI_TYPE : undefined);
|
|
15847
|
+
// Codex App steer authorization was computed ONCE before the branch split
|
|
15848
|
+
// above (R4-B1); reuse the same frozen value here for the live-worker path.
|
|
15849
|
+
let accepted = false;
|
|
15850
|
+
try {
|
|
15851
|
+
accepted = sendWorkerInput(ds, cliInput, parsed.messageId, codexAppSteerable ? { codexAppSteerable: true } : {});
|
|
15852
|
+
// Record the input as the session's last real CLI turn ONLY after the
|
|
15853
|
+
// worker accepted it. Recording before delivery (the old order) persisted
|
|
15854
|
+
// lastCliInput / lastUserPrompt / Codex-App sidecar for a turn that never
|
|
15855
|
+
// reached the CLI; a rejected send then left that poison behind, and the
|
|
15856
|
+
// next message's worker-null refork would read it as `hadPriorCliInput`
|
|
15857
|
+
// and wrongly `--resume` a CLI that never took a real turn.
|
|
15858
|
+
if (accepted)
|
|
15859
|
+
rememberLastCliInput(ds, promptContent, cliInput);
|
|
15860
|
+
else
|
|
15861
|
+
logger.warn(`[${tag(ds)}] Inbound ${parsed.messageId} was not accepted by the live worker`);
|
|
15862
|
+
}
|
|
15863
|
+
finally {
|
|
15864
|
+
// The opening is one-shot: give it back when the worker died / refused,
|
|
15865
|
+
// so the next message re-opens instead of silently losing the context.
|
|
15866
|
+
if (openingTurn && !accepted)
|
|
15867
|
+
releaseInitialUserTurn(ds);
|
|
15868
|
+
}
|
|
15869
|
+
}
|
|
15870
|
+
else {
|
|
15871
|
+
// Worker not running — re-fork with resume. This is a NEW turn, so drop
|
|
15872
|
+
// any restored streaming-card reference; worker_ready will POST a fresh
|
|
15873
|
+
// card instead of PATCHing the previous turn's card in place.
|
|
15874
|
+
logger.info(`[${tag(ds)}] Worker not running, re-forking...`);
|
|
15875
|
+
// 飞书消息轮(非文档评论轮):docCommentTargets 是 per-turn map,本轮 turnId
|
|
15876
|
+
// 不会命中文档评论的 key,无需显式清盘。
|
|
15877
|
+
if (ds.usageLimitRetryTimer) {
|
|
15878
|
+
clearTimeout(ds.usageLimitRetryTimer);
|
|
15879
|
+
ds.usageLimitRetryTimer = undefined;
|
|
15880
|
+
}
|
|
15881
|
+
ds.usageLimit = undefined;
|
|
15882
|
+
ds.currentTurnTitle = parsed.content.substring(0, 50);
|
|
15883
|
+
// The cosmetic freeze step (above) is gated on a live worker. With no
|
|
15884
|
+
// worker we just park the current card in frozenCards — the upcoming
|
|
15885
|
+
// new POST will recall it. Parking instead of deleting preserves the
|
|
15886
|
+
// "old card stays until a new one is live" invariant: if fork /
|
|
15887
|
+
// worker_ready / POST fails, the user still sees the previous card.
|
|
15888
|
+
parkStreamCard(ds);
|
|
15889
|
+
ds.streamCardId = undefined;
|
|
15890
|
+
ds.streamCardNonce = undefined;
|
|
15891
|
+
// This is a new turn even though the worker is currently down. Force the
|
|
15892
|
+
// first screen_update from the re-forked worker to POST a fresh card and
|
|
15893
|
+
// drop any persisted screenshot from the previous turn. Otherwise a stale
|
|
15894
|
+
// image_key (for example an old Claude Code frame) can be reused on the
|
|
15895
|
+
// new Worker card until the next screenshot upload, which makes a fresh
|
|
15896
|
+
// @mention appear to resurrect the wrong CLI UI.
|
|
15897
|
+
ds.streamCardPending = true;
|
|
15898
|
+
ds.currentImageKey = undefined;
|
|
15899
|
+
persistStreamCardState(ds);
|
|
15900
|
+
// Wrap the user message in the same `<user_message>` / `<session_id>` /
|
|
15901
|
+
// `<botmux_reminder>` envelope as live-worker turns. Without this, the
|
|
15902
|
+
// initial prompt that worker queues for the freshly-spawned CLI is the
|
|
15903
|
+
// raw user text — the CLI sees no botmux routing context and stops calling
|
|
15904
|
+
// `botmux send`, posting answers to its own terminal instead. Hits resume
|
|
15905
|
+
// (after /close) and daemon-restart paths; both go through this branch
|
|
15906
|
+
// because worker=null at that point.
|
|
15907
|
+
const dsBotCfgForFork = getBot(ds.larkAppId).config;
|
|
15908
|
+
const selfBot = getBot(ds.larkAppId);
|
|
15909
|
+
// Adopted (bridge) sessions are the user's external CLI — don't attach a
|
|
15910
|
+
// botmux whiteboard on re-fork. The live-worker branch above skips ensure
|
|
15911
|
+
// for bridge sessions (isBridge); the re-fork path must match, else a
|
|
15912
|
+
// bridge session whose worker died would gain a whiteboard binding (and a
|
|
15913
|
+
// <whiteboard> block in its refork prompt) that its live turns never had.
|
|
15914
|
+
if (!ds.adoptedFrom)
|
|
15915
|
+
ensureSessionWhiteboard(ds);
|
|
15916
|
+
const stageCurrentBehindQueuedActivation = async () => {
|
|
15917
|
+
const tailReservation = reserveAsyncQueuedActivationTailAdmission(ds);
|
|
15918
|
+
try {
|
|
15919
|
+
const currentFollowUp = buildFollowUpCliInput(promptContent, ds.session.sessionId, {
|
|
15920
|
+
attachments,
|
|
15921
|
+
mentions: parsed.mentions,
|
|
15922
|
+
isAdoptMode: false,
|
|
15923
|
+
cliId: ds.session.cliId ?? dsBotCfgForFork.cliId,
|
|
15924
|
+
cliPathOverride: ds.session.cliPathOverride ?? dsBotCfgForFork.cliPathOverride,
|
|
15925
|
+
sender: await getThreadSender(),
|
|
15926
|
+
larkAppId,
|
|
15927
|
+
chatId: ds.session.chatId,
|
|
15928
|
+
whiteboardId: ds.session.whiteboardId,
|
|
15929
|
+
substituteTrigger,
|
|
15930
|
+
codexAppText: parsed.content,
|
|
15931
|
+
codexAppApplicationContext,
|
|
15932
|
+
codexAppMessageContext,
|
|
15933
|
+
});
|
|
15934
|
+
// R4-B1: freeze the admission-time steer authorization onto the queued
|
|
15935
|
+
// opening payload so the worker-null re-fork path carries it exactly like
|
|
15936
|
+
// the live-worker path (admission computed once at line ~18431; COPIED
|
|
15937
|
+
// here, never re-inferred). System/recovery openings keep it absent.
|
|
15938
|
+
if (codexAppSteerable)
|
|
15939
|
+
currentFollowUp.codexAppSteerable = true;
|
|
15940
|
+
admitQueuedActivationTail(ds, {
|
|
15941
|
+
userPrompt: promptContent,
|
|
15942
|
+
cliInput: currentFollowUp,
|
|
15943
|
+
turnId: parsed.messageId,
|
|
15944
|
+
}, tailReservation);
|
|
15945
|
+
}
|
|
15946
|
+
finally {
|
|
15947
|
+
settleAsyncQueuedActivationTailAdmission(ds);
|
|
15948
|
+
}
|
|
15949
|
+
ds.initialStartPending = true;
|
|
15950
|
+
await noteTurnReceived(ds, parsed.messageId, parsed.content, await getThreadSender(), parsed.messageId, substituteTrigger ? SUBSTITUTE_RECEIVED_REACTION_EMOJI_TYPE : undefined);
|
|
15951
|
+
};
|
|
15952
|
+
// Codex App may retain an ACK journal/FIFO head after its worker dies. A
|
|
15953
|
+
// new inbound turn wakes a pure recovery fork first; it stays in the exact
|
|
15954
|
+
// runtime tail until that recovered head produces its submission ACK.
|
|
15955
|
+
if (ds.session.queuedActivationPending) {
|
|
15956
|
+
await stageCurrentBehindQueuedActivation();
|
|
15957
|
+
try {
|
|
15958
|
+
const recoverThroughCodexLedger = (ds.session.cliId ?? dsBotCfgForFork.cliId) === 'codex-app';
|
|
15959
|
+
forkWorker(ds, recoverThroughCodexLedger ? '' : (ds.session.queuedActivationInput ?? ''), {
|
|
15960
|
+
resume: ds.session.queuedActivationResume ?? ds.hasHistory,
|
|
15961
|
+
turnId: ds.session.queuedActivationTurnId,
|
|
15962
|
+
dispatchAttempt: ds.session.queuedActivationDispatchAttempt,
|
|
15963
|
+
});
|
|
15964
|
+
if (ownsInitialStartClaim())
|
|
15965
|
+
retainInitialStartClaim = true;
|
|
15966
|
+
}
|
|
15967
|
+
catch (err) {
|
|
15968
|
+
ds.initialStartPending = false;
|
|
15969
|
+
throw err;
|
|
15970
|
+
}
|
|
15971
|
+
return;
|
|
15972
|
+
}
|
|
15973
|
+
// A worker that died before submitting a queued activation leaves the
|
|
15974
|
+
// exact opening payload parked in the durable journal. Wake that opening
|
|
15975
|
+
// unchanged and stage this new inbound turn behind it as a separate FIFO
|
|
15976
|
+
// item; rebuilding `queuedPrompt + current` here would lose any reply that
|
|
15977
|
+
// had already been folded into the retained activation payload.
|
|
15978
|
+
const retainedQueuedActivation = ds.session.queued === true
|
|
15979
|
+
? ds.session.queuedActivationInput
|
|
15980
|
+
: undefined;
|
|
15981
|
+
if (retainedQueuedActivation) {
|
|
15982
|
+
await stageCurrentBehindQueuedActivation();
|
|
15983
|
+
try {
|
|
15984
|
+
forkWorker(ds, retainedQueuedActivation, {
|
|
15985
|
+
resume: ds.hasHistory,
|
|
15986
|
+
turnId: ds.session.queuedActivationTurnId,
|
|
15987
|
+
dispatchAttempt: ds.session.queuedActivationDispatchAttempt,
|
|
15988
|
+
});
|
|
15989
|
+
if (ownsInitialStartClaim())
|
|
15990
|
+
retainInitialStartClaim = true;
|
|
15991
|
+
}
|
|
15992
|
+
catch (err) {
|
|
15993
|
+
// Keep the staged tail for a later activation attempt, but release the
|
|
15994
|
+
// route gate so another inbound turn can trigger that attempt.
|
|
15995
|
+
ds.initialStartPending = false;
|
|
15996
|
+
throw err;
|
|
15997
|
+
}
|
|
15998
|
+
return;
|
|
15999
|
+
}
|
|
16000
|
+
// 待办池(queued)会话:CLI 从没起过,暂存的任务内容(queuedPrompt,已按角色包装好)
|
|
16001
|
+
// 必须当首轮发出去——否则群里来的这第一条消息会顶替掉它、把用户分配的任务丢掉。
|
|
16002
|
+
// 把暂存任务前置、用户这条消息拼在后面,一并作为首轮。forkWorker 随后清 queued。
|
|
16003
|
+
const queuedDashboardTurn = !!(ds.session.queued && ds.session.queuedPrompt);
|
|
16004
|
+
// A restored pending-repo/backlog owner may already have durable N+1...
|
|
16005
|
+
// behind its unopened N. Appending this inbound to N would overtake those
|
|
16006
|
+
// persisted entries. Keep N exact and append the current turn after the
|
|
16007
|
+
// existing tail before starting the activation journal.
|
|
16008
|
+
const queuedHasDurableTail = queuedDashboardTurn
|
|
16009
|
+
&& (ds.session.queuedActivationTail?.length ?? 0) > 0;
|
|
16010
|
+
if (queuedHasDurableTail)
|
|
16011
|
+
await stageCurrentBehindQueuedActivation();
|
|
16012
|
+
const reforkContent = queuedDashboardTurn && !queuedHasDurableTail
|
|
16013
|
+
? `${ds.session.queuedPrompt}\n\n${promptContent}`
|
|
16014
|
+
: queuedHasDurableTail
|
|
16015
|
+
? ds.session.queuedPrompt
|
|
16016
|
+
: promptContent;
|
|
16017
|
+
const queuedCodexAppText = ds.session.queuedCodexAppText ?? ds.pendingCodexAppText;
|
|
16018
|
+
const reforkCodexApp = mergeQueuedCodexAppTurn({
|
|
16019
|
+
queued: queuedDashboardTurn,
|
|
16020
|
+
queuedText: queuedCodexAppText,
|
|
16021
|
+
queuedMessageContext: ds.session.queuedCodexAppMessageContext ?? ds.pendingCodexAppMessageContext,
|
|
16022
|
+
currentText: queuedHasDurableTail ? '' : parsed.content,
|
|
16023
|
+
currentMessageContext: queuedHasDurableTail ? undefined : codexAppMessageContext,
|
|
16024
|
+
});
|
|
16025
|
+
// Empty-started session that lost its worker (daemon restart, idle sweep,
|
|
16026
|
+
// CLI exit) — same rule as the live branch: this is still the FIRST real
|
|
16027
|
+
// user turn, so it must be built as a new topic. A queued(待办池) activation
|
|
16028
|
+
// is excluded: its queuedPrompt already owns the first turn.
|
|
16029
|
+
const wantsOpening = !ds.adoptedFrom && !queuedDashboardTurn && isInitialUserTurnPending(ds);
|
|
16030
|
+
const openingBots = wantsOpening ? await getAvailableBots(larkAppId, ds.chatId) : undefined;
|
|
16031
|
+
const reforkSender = await getThreadSender();
|
|
16032
|
+
// An empty-started CLI has nothing to resume: `hasHistory` is set
|
|
16033
|
+
// unconditionally by restoreActiveSessions (and by claude_exit /
|
|
16034
|
+
// suspendWorker), so it cannot tell "booted idle" from "has real history".
|
|
16035
|
+
// `session.lastCliInput` can: rememberLastCliInput writes it on EVERY real
|
|
16036
|
+
// CLI input, and the empty-start fork deliberately writes none. Snapshot it
|
|
16037
|
+
// BEFORE this turn's own rememberLastCliInput below, so a session some
|
|
16038
|
+
// non-IM path (scheduler / webhook trigger / doc comment) already fed keeps
|
|
16039
|
+
// its normal `--resume` instead of being cold-spawned over.
|
|
16040
|
+
const hadPriorCliInput = !!(ds.lastCliInput ?? ds.session.lastCliInput);
|
|
16041
|
+
const openingTurn = wantsOpening && claimInitialUserTurn(ds);
|
|
16042
|
+
const builtReforkInput = buildReforkCliInput(ds, reforkContent, {
|
|
16043
|
+
attachments: queuedHasDurableTail ? undefined : attachments,
|
|
16044
|
+
mentions: queuedHasDurableTail ? undefined : parsed.mentions,
|
|
16045
|
+
cliId: ds.session.cliId ?? dsBotCfgForFork.cliId,
|
|
16046
|
+
cliPathOverride: ds.session.cliPathOverride ?? dsBotCfgForFork.cliPathOverride,
|
|
16047
|
+
selfMention: { name: selfBot.botName, openId: selfBot.botOpenId },
|
|
16048
|
+
sender: queuedHasDurableTail ? undefined : reforkSender,
|
|
16049
|
+
substituteTrigger: queuedHasDurableTail ? undefined : substituteTrigger,
|
|
14986
16050
|
codexAppText: reforkCodexApp.text,
|
|
14987
|
-
codexAppApplicationContext,
|
|
16051
|
+
codexAppApplicationContext: queuedHasDurableTail ? undefined : codexAppApplicationContext,
|
|
14988
16052
|
codexAppMessageContext: reforkCodexApp.messageContext,
|
|
14989
16053
|
});
|
|
14990
|
-
|
|
14991
|
-
|
|
14992
|
-
|
|
14993
|
-
|
|
14994
|
-
|
|
14995
|
-
|
|
14996
|
-
|
|
14997
|
-
|
|
14998
|
-
|
|
14999
|
-
|
|
15000
|
-
// buildReforkCliInput above); forkAdoptWorker queues it and the adopt idle
|
|
15001
|
-
// detector flushes it to the observed pane. Adopt never --resumes a botmux
|
|
15002
|
-
// session, so the openingTurn/hadPriorCliInput resume logic doesn't apply.
|
|
15003
|
-
if (ds.adoptedFrom) {
|
|
15004
|
-
forkAdoptWorker(ds, { prompt: wrappedInput.content, turnId: parsed.messageId });
|
|
16054
|
+
let wrappedInput = applyQueuedCodexAppLegacyFallback(builtReforkInput, {
|
|
16055
|
+
queued: queuedDashboardTurn,
|
|
16056
|
+
queuedText: queuedCodexAppText,
|
|
16057
|
+
});
|
|
16058
|
+
if (wrappedInput !== builtReforkInput && dsBotCfgForFork.codexAppCleanInput === true) {
|
|
16059
|
+
// Backlog sessions persisted before clean-input have no raw queued text.
|
|
16060
|
+
// Keep this activation entirely legacy: reforkContent already contains
|
|
16061
|
+
// queuedPrompt + the current reply, whereas a structured turn could only
|
|
16062
|
+
// contain the reply and would silently discard the original task.
|
|
16063
|
+
logger.warn(`[${tag(ds)}] Legacy queued dashboard task has no clean-input text; using the full legacy activation prompt`);
|
|
15005
16064
|
}
|
|
15006
|
-
|
|
15007
|
-
|
|
15008
|
-
|
|
15009
|
-
|
|
15010
|
-
|
|
15011
|
-
|
|
16065
|
+
if (openingTurn) {
|
|
16066
|
+
// Replace the follow-up envelope built above with the real opening. The
|
|
16067
|
+
// discarded build is pure string assembly (no side effects) — keeping the
|
|
16068
|
+
// refork statement unconditional keeps the queued/substitute wiring, and
|
|
16069
|
+
// its guard test, on a single code path. (openingTurn is mutually
|
|
16070
|
+
// exclusive with queuedDashboardTurn/queuedHasDurableTail.)
|
|
16071
|
+
wrappedInput = buildNewTopicCliInput(reforkContent, ds.session.sessionId, ds.session.cliId ?? dsBotCfgForFork.cliId, ds.session.cliPathOverride ?? dsBotCfgForFork.cliPathOverride, attachments, parsed.mentions, openingBots, undefined, { name: selfBot.botName, openId: selfBot.botOpenId }, localeForBot(larkAppId), reforkSender, {
|
|
16072
|
+
larkAppId,
|
|
16073
|
+
chatId: ds.session.chatId,
|
|
16074
|
+
whiteboardId: ds.session.whiteboardId,
|
|
16075
|
+
substituteTrigger,
|
|
16076
|
+
codexAppText: reforkCodexApp.text,
|
|
16077
|
+
codexAppApplicationContext,
|
|
16078
|
+
codexAppMessageContext: reforkCodexApp.messageContext,
|
|
15012
16079
|
});
|
|
15013
16080
|
}
|
|
16081
|
+
if (!queuedHasDurableTail) {
|
|
16082
|
+
await noteTurnReceived(ds, parsed.messageId, parsed.content, reforkSender, parsed.messageId, substituteTrigger ? SUBSTITUTE_RECEIVED_REACTION_EMOJI_TYPE : undefined);
|
|
16083
|
+
}
|
|
16084
|
+
try {
|
|
16085
|
+
// Adopt sessions must re-fork via forkAdoptWorker, NOT forkWorker: the
|
|
16086
|
+
// latter would spawn a fresh botmux-managed bmx-* CLI in the adopt cwd,
|
|
16087
|
+
// losing the observe/bridge semantics and typing the wrapped prompt into a
|
|
16088
|
+
// brand-new CLI instead of the user's original external pane. This branch
|
|
16089
|
+
// is reachable whenever an adopt session's bridge worker has exited (crash,
|
|
16090
|
+
// or the "adopted session ended" kill path) and a new Lark turn arrives.
|
|
16091
|
+
// The turn's input rides in on the init prompt (bridge-formatted by
|
|
16092
|
+
// buildReforkCliInput above); forkAdoptWorker queues it and the adopt idle
|
|
16093
|
+
// detector flushes it to the observed pane. Adopt never --resumes a botmux
|
|
16094
|
+
// session, so the openingTurn/hadPriorCliInput resume logic doesn't apply.
|
|
16095
|
+
if (ds.adoptedFrom) {
|
|
16096
|
+
forkAdoptWorker(ds, { prompt: wrappedInput.content, turnId: parsed.messageId });
|
|
16097
|
+
}
|
|
16098
|
+
else {
|
|
16099
|
+
// R4-B1: freeze the admission-time steer authorization onto this opening
|
|
16100
|
+
// payload — but ONLY for a genuine interactive current turn, never a
|
|
16101
|
+
// queued-dashboard / durable-tail SYSTEM opening (those own the first
|
|
16102
|
+
// turn and are forced-serial). Admission computed once (~18431); COPIED.
|
|
16103
|
+
if (codexAppSteerable && !queuedDashboardTurn && !queuedHasDurableTail) {
|
|
16104
|
+
wrappedInput.codexAppSteerable = true;
|
|
16105
|
+
}
|
|
16106
|
+
forkWorker(ds, wrappedInput, {
|
|
16107
|
+
// See `hadPriorCliInput` above — an opening on a CLI that never took any
|
|
16108
|
+
// input cold-spawns rather than `--resume`-ing an empty session.
|
|
16109
|
+
resume: ds.hasHistory && !(openingTurn && !hadPriorCliInput),
|
|
16110
|
+
turnId: queuedHasDurableTail
|
|
16111
|
+
? (ds.session.queuedActivationTurnId ?? `queued-opening:${ds.session.sessionId}`)
|
|
16112
|
+
: parsed.messageId,
|
|
16113
|
+
});
|
|
16114
|
+
}
|
|
16115
|
+
}
|
|
16116
|
+
catch (e) {
|
|
16117
|
+
if (openingTurn)
|
|
16118
|
+
releaseInitialUserTurn(ds);
|
|
16119
|
+
throw e;
|
|
16120
|
+
}
|
|
16121
|
+
// Record the input as the session's last real CLI turn ONLY after the fork
|
|
16122
|
+
// succeeded. Recording before the fork (the old order) persisted lastCliInput
|
|
16123
|
+
// for a turn that never launched when forkWorker threw; the retry then read
|
|
16124
|
+
// that poison as `hadPriorCliInput` and wrongly `--resume`d a CLI that never
|
|
16125
|
+
// took a real turn — breaking the empty-start invariant. forkWorker itself
|
|
16126
|
+
// persists the session (clearing queued); this records last* + reply state.
|
|
16127
|
+
rememberLastCliInput(ds, queuedHasDurableTail ? ds.session.queuedPrompt : promptContent, wrappedInput);
|
|
16128
|
+
sessionStore.updateSession(ds.session);
|
|
16129
|
+
if (ownsInitialStartClaim()) {
|
|
16130
|
+
if (initialStartClaimWaitsForQueuedAck) {
|
|
16131
|
+
retainInitialStartClaim = true;
|
|
16132
|
+
}
|
|
16133
|
+
else {
|
|
16134
|
+
// Ordinary cold reforks have no adapter-submission ACK. The child owns
|
|
16135
|
+
// the opening init after forkWorker returns, so hand any concurrently
|
|
16136
|
+
// buffered followers to its IPC queue now in exact arrival order.
|
|
16137
|
+
retainInitialStartClaim = !releaseQueuedActivationReservation(ds);
|
|
16138
|
+
}
|
|
16139
|
+
}
|
|
15014
16140
|
}
|
|
15015
|
-
|
|
15016
|
-
|
|
15017
|
-
|
|
15018
|
-
|
|
16141
|
+
}
|
|
16142
|
+
finally {
|
|
16143
|
+
// Pre-fork quota/resource/persistence failures and any non-queued route
|
|
16144
|
+
// that returned without accepting a worker must not strand the route gate.
|
|
16145
|
+
// Fence by token so an older handler can never clear a newer generation.
|
|
16146
|
+
if (ownsInitialStartClaim() && !retainInitialStartClaim) {
|
|
16147
|
+
clearInitialStartClaim(ds, initialStartClaimToken);
|
|
15019
16148
|
}
|
|
15020
|
-
// Record the input as the session's last real CLI turn ONLY after the fork
|
|
15021
|
-
// succeeded. Recording before the fork (the old order) persisted lastCliInput
|
|
15022
|
-
// for a turn that never launched when forkWorker threw; the retry then read
|
|
15023
|
-
// that poison as `hadPriorCliInput` and wrongly `--resume`d a CLI that never
|
|
15024
|
-
// took a real turn — breaking the empty-start invariant. forkWorker itself
|
|
15025
|
-
// persists the session (clearing queued); this records last* + reply state.
|
|
15026
|
-
rememberLastCliInput(ds, promptContent, wrappedInput);
|
|
15027
|
-
sessionStore.updateSession(ds.session);
|
|
15028
16149
|
}
|
|
15029
16150
|
}
|
|
15030
16151
|
/**
|
|
@@ -15105,6 +16226,20 @@ async function autoCreateDocSession(sub, larkAppId, ctx) {
|
|
|
15105
16226
|
* success and advancing the poll cursor past a later failure.
|
|
15106
16227
|
*/
|
|
15107
16228
|
const handledDocCommentTurns = new BoundedMap(5_000);
|
|
16229
|
+
/**
|
|
16230
|
+
* Thrown by a doc-comment turn that must be DEFERRED (not delivered) because the
|
|
16231
|
+
* bound session still holds durable opening ownership (worker:null repo/worktree
|
|
16232
|
+
* setup or a protected activation/dispatch ledger). Deferral is retryable: the
|
|
16233
|
+
* outer handler releases the claim and returns false so the provider cursor
|
|
16234
|
+
* re-attempts this exact comment after the opening owner reconciles. Distinct
|
|
16235
|
+
* from a real delivery failure so it is not logged/handled as an error.
|
|
16236
|
+
*/
|
|
16237
|
+
class DocCommentDeferredError extends Error {
|
|
16238
|
+
constructor(reason) {
|
|
16239
|
+
super(reason);
|
|
16240
|
+
this.name = 'DocCommentDeferredError';
|
|
16241
|
+
}
|
|
16242
|
+
}
|
|
15108
16243
|
async function runClaimedDocCommentTurn(claimKey, work, onOwnerFailure) {
|
|
15109
16244
|
const existing = handledDocCommentTurns.get(claimKey);
|
|
15110
16245
|
if (existing)
|
|
@@ -15146,6 +16281,9 @@ export function __testOnly_resetDocCommentClaims() {
|
|
|
15146
16281
|
* 走 resume 重 fork);已 /close 的会话其订阅在关闭时已退订,这里查不到 ds 即跳过。
|
|
15147
16282
|
*/
|
|
15148
16283
|
async function handleDocComment(ctx) {
|
|
16284
|
+
return withBotTurnAdmission(ctx.larkAppId, () => handleDocCommentAdmitted(ctx));
|
|
16285
|
+
}
|
|
16286
|
+
async function handleDocCommentAdmitted(ctx) {
|
|
15149
16287
|
const { larkAppId, sub, commentId, text } = ctx;
|
|
15150
16288
|
const turnId = ctx.replyId || commentId;
|
|
15151
16289
|
const claimKey = `${larkAppId}:${sub.fileToken}:${turnId}`;
|
|
@@ -15218,6 +16356,19 @@ async function handleDocComment(ctx) {
|
|
|
15218
16356
|
const generation = captureRoutingGeneration(ds);
|
|
15219
16357
|
deliverySession = generation.session;
|
|
15220
16358
|
ensureCurrentRoutingGeneration(generation, 'comment:start');
|
|
16359
|
+
// Durable opening ownership (PR #597): a worker:null owner that is still
|
|
16360
|
+
// in repo/worktree setup or holds a protected activation/dispatch ledger
|
|
16361
|
+
// must NOT be reforked by a doc comment. The provider cursor is the retry
|
|
16362
|
+
// owner; deferring (throw → outer catch returns false + releases the
|
|
16363
|
+
// claim) leaves this exact comment available after the opening owner
|
|
16364
|
+
// reconciles, instead of clobbering its setup prompt. This throws BEFORE
|
|
16365
|
+
// any reaction / noteTurnReceived side effect, so nothing needs undoing.
|
|
16366
|
+
if ((!ds.worker || ds.worker.killed)
|
|
16367
|
+
&& (ds.pendingRepo
|
|
16368
|
+
|| ds.worktreeCreating
|
|
16369
|
+
|| hasProtectedSessionMutationOwnership(ds))) {
|
|
16370
|
+
throw new DocCommentDeferredError(`session ${ds.session.sessionId.slice(0, 8)} has durable opening ownership`);
|
|
16371
|
+
}
|
|
15221
16372
|
// 给用户的回复加 "Typing" reaction,让评论者知道 bot 正在处理。
|
|
15222
16373
|
if (userReplyId) {
|
|
15223
16374
|
reactionId = await addCommentReaction(larkAppId, { fileToken: sub.fileToken, fileType: sub.fileType }, commentId, userReplyId, 'Typing');
|
|
@@ -15330,10 +16481,44 @@ async function handleDocComment(ctx) {
|
|
|
15330
16481
|
}, cleanupFailedDelivery);
|
|
15331
16482
|
}
|
|
15332
16483
|
catch (err) {
|
|
16484
|
+
if (err instanceof DocCommentDeferredError) {
|
|
16485
|
+
// Retryable defer (durable opening ownership): claim was released above so
|
|
16486
|
+
// the provider cursor re-attempts this exact comment later. Not a failure.
|
|
16487
|
+
logger.warn(`[doc-comment] turn ${turnId.slice(0, 12)} deferred before acceptance `
|
|
16488
|
+
+ `(${err.message}); provider cursor retains retry ownership`);
|
|
16489
|
+
return false;
|
|
16490
|
+
}
|
|
15333
16491
|
logger.warn(`[doc-comment] delivery failed, claim released for retry file=${sub.fileToken.slice(0, 12)} turn=${turnId.slice(0, 12)} err=${err instanceof Error ? err.message : String(err)}`);
|
|
15334
16492
|
return false;
|
|
15335
16493
|
}
|
|
15336
16494
|
}
|
|
16495
|
+
export const __testOnly_handleDocComment = (ctx) => handleDocComment(ctx);
|
|
16496
|
+
/** Preserve accepted/pending work when a normal group is converted to topic
|
|
16497
|
+
* mode. The converted event is routed to a new thread key, so keeping the old
|
|
16498
|
+
* chat owner does not block the new session; it only keeps the durable owner
|
|
16499
|
+
* addressable for completion or explicit close. */
|
|
16500
|
+
function handleChatModeConverted(chatId, larkAppId) {
|
|
16501
|
+
const key = sessionKey(chatId, larkAppId);
|
|
16502
|
+
const owner = activeSessions.get(key);
|
|
16503
|
+
if (!owner) {
|
|
16504
|
+
logger.info(`[chat-mode-converted] ${chatId.substring(0, 12)} evicted=false; no chat-scope owner`);
|
|
16505
|
+
return false;
|
|
16506
|
+
}
|
|
16507
|
+
// Conversion preprocessing can run outside bot-turn admission. Converted
|
|
16508
|
+
// turns route by their new thread key, so retaining a pending owner at the
|
|
16509
|
+
// old chat key does not block them.
|
|
16510
|
+
const pending = hasProtectedSessionMutationOwnership(owner)
|
|
16511
|
+
|| owner.pendingRepo === true;
|
|
16512
|
+
if (pending) {
|
|
16513
|
+
logger.warn(`[chat-mode-converted] ${chatId.substring(0, 12)} preserved pending owner `
|
|
16514
|
+
+ `${owner.session.sessionId.substring(0, 8)}; converted turns route by thread key`);
|
|
16515
|
+
return false;
|
|
16516
|
+
}
|
|
16517
|
+
const evicted = activeSessions.get(key) === owner && activeSessions.delete(key);
|
|
16518
|
+
logger.info(`[chat-mode-converted] ${chatId.substring(0, 12)} evicted=${evicted}; worker (if any) keeps running until /close`);
|
|
16519
|
+
return evicted;
|
|
16520
|
+
}
|
|
16521
|
+
export const __testOnly_handleChatModeConverted = handleChatModeConverted;
|
|
15337
16522
|
let docCommentPollRunning = false;
|
|
15338
16523
|
/**
|
|
15339
16524
|
* `/watch-comment --all` 的应用身份增量轮询。
|
|
@@ -15806,6 +16991,21 @@ export async function startDaemon(botIndex) {
|
|
|
15806
16991
|
// 退化为纯 evaluateTalk(openId, chatType),与改动前语义一致。三条分派由
|
|
15807
16992
|
// test/ask-answer-talk-dispatch.test.ts 直接咬住。
|
|
15808
16993
|
setAskCanTalkChecker((appId, chatId, openId, chatType, actor) => evaluateAskAnswerTalk(appId, chatId, openId, chatType, actor));
|
|
16994
|
+
// Resume pending `botmux ask` cards that outlived a daemon restart. Each is
|
|
16995
|
+
// restored as a DORMANT ask (card still live in Feishu, no waiter yet). The
|
|
16996
|
+
// surviving CLI hook — whose /api/asks connection dropped on restart and is
|
|
16997
|
+
// retrying — re-registers the same ask by its stable key and re-attaches a
|
|
16998
|
+
// waiter, so the answer flows back through the normal hook directive instead
|
|
16999
|
+
// of the CLI falling into a stuck native picker. Scoped to this daemon's bot.
|
|
17000
|
+
try {
|
|
17001
|
+
// Bind the durable store to the real data dir (dependency-injected so unit
|
|
17002
|
+
// tests use a temp dir and never touch live data — codex P1-4).
|
|
17003
|
+
setAskPersistStoreBroker(createAskPersistStore(join(config.session.dataDir, 'asks')));
|
|
17004
|
+
restorePersistedAsksBroker(Date.now(), cfg.larkAppId);
|
|
17005
|
+
}
|
|
17006
|
+
catch (e) {
|
|
17007
|
+
logger.warn(`[ask] restorePersistedAsks failed: ${e instanceof Error ? e.message : String(e)}`);
|
|
17008
|
+
}
|
|
15809
17009
|
writePidFile();
|
|
15810
17010
|
const memoryDiagnostics = startMemoryDiagnostics();
|
|
15811
17011
|
// Publish self-descriptor for the dashboard registry. The dashboard sibling
|
|
@@ -15825,6 +17025,10 @@ export async function startDaemon(botIndex) {
|
|
|
15825
17025
|
// agent-facing, live-origin-gated endpoints. Internal control endpoints use
|
|
15826
17026
|
// a separate daemon-to-daemon credential and never trust this port marker.
|
|
15827
17027
|
process.env.BOTMUX_DAEMON_IPC_PORT = String(ipcPort);
|
|
17028
|
+
const daemonProcessStartIdentity = readSupervisorProcessStartIdentity(process.pid);
|
|
17029
|
+
if (!daemonProcessStartIdentity) {
|
|
17030
|
+
throw new Error('cannot bind daemon descriptor to a process-start identity');
|
|
17031
|
+
}
|
|
15828
17032
|
const desc = {
|
|
15829
17033
|
larkAppId: cfg.larkAppId,
|
|
15830
17034
|
botName: cfg.displayName ?? cfg.larkAppId,
|
|
@@ -15832,6 +17036,7 @@ export async function startDaemon(botIndex) {
|
|
|
15832
17036
|
botIndex: idx,
|
|
15833
17037
|
ipcPort,
|
|
15834
17038
|
pid: process.pid,
|
|
17039
|
+
processStartIdentity: daemonProcessStartIdentity,
|
|
15835
17040
|
startedAt: Date.now(),
|
|
15836
17041
|
bootInstanceId: generateWorkflowDaemonBootInstanceId(),
|
|
15837
17042
|
workflowIpcProtocol: 'v1',
|
|
@@ -15925,12 +17130,53 @@ export async function startDaemon(botIndex) {
|
|
|
15925
17130
|
// One cap implementation shared by event-driven checks (process start / idle
|
|
15926
17131
|
// edge) and the 60s safety-net timer below. Each daemon owns exactly one
|
|
15927
17132
|
// bot's activeSessions map, so the configured limit is per bot.
|
|
17133
|
+
let liveSessionCapSweepPending = false;
|
|
15928
17134
|
const enforceLiveSessionCap = (source) => {
|
|
15929
|
-
|
|
15930
|
-
|
|
15931
|
-
|
|
15932
|
-
|
|
15933
|
-
|
|
17135
|
+
// Coalesce spawn/idle/timer bursts. The detached mutation evaluates the
|
|
17136
|
+
// current map and current config only after every admitted turn has drained,
|
|
17137
|
+
// so one pending sweep subsumes all triggers that arrive before it runs.
|
|
17138
|
+
if (liveSessionCapSweepPending)
|
|
17139
|
+
return;
|
|
17140
|
+
liveSessionCapSweepPending = true;
|
|
17141
|
+
void (async () => {
|
|
17142
|
+
try {
|
|
17143
|
+
const maxLiveWorkers = getBot(cfg.larkAppId).config.maxLiveWorkers;
|
|
17144
|
+
const suspended = await sweepIdleWorkersAfterTurnDrain(cfg.larkAppId, activeSessions, { maxLiveWorkers });
|
|
17145
|
+
if (suspended.length > 0) {
|
|
17146
|
+
logger.info(`[idle-worker-sweeper] suspended ${suspended.length} session(s) over per-bot cap `
|
|
17147
|
+
+ `${maxLiveWorkers ?? DEFAULT_MAX_LIVE_WORKERS} source=${source}`);
|
|
17148
|
+
}
|
|
17149
|
+
}
|
|
17150
|
+
catch (err) {
|
|
17151
|
+
logger.warn(`[idle-worker-sweeper] cap enforcement failed: ${err instanceof Error ? err.message : String(err)}`);
|
|
17152
|
+
}
|
|
17153
|
+
finally {
|
|
17154
|
+
liveSessionCapSweepPending = false;
|
|
17155
|
+
}
|
|
17156
|
+
})();
|
|
17157
|
+
};
|
|
17158
|
+
// Converge an incomplete keyed idempotent async turn on CLI/worker exit, and if
|
|
17159
|
+
// the durable dispatch_unknown write itself FAILS (EIO/ENOSPC/foreign slot),
|
|
17160
|
+
// take the session to an OBSERVABLE fail-closed terminal by closing it —
|
|
17161
|
+
// trigger-result's closed-branch then resolves `failed` instead of stranding the
|
|
17162
|
+
// poller on `running` while the same Node worker auto-restarts to a healthy idle
|
|
17163
|
+
// CLI (codex #776 round-8 finding #2). Never throws into the exit handler.
|
|
17164
|
+
const failCloseIdempotentTurnIfConvergenceWriteFailed = (ds, exitingWorkerGeneration) => {
|
|
17165
|
+
let outcome;
|
|
17166
|
+
try {
|
|
17167
|
+
outcome = convergeIdempotentAsyncTurnOnWorkerExit(ds, exitingWorkerGeneration);
|
|
17168
|
+
}
|
|
17169
|
+
catch (err) {
|
|
17170
|
+
logger.error(`[idempotency] exit convergence threw for ${ds.session.sessionId.slice(0, 8)}: ${err instanceof Error ? err.message : err}`);
|
|
17171
|
+
outcome = 'write_failed';
|
|
17172
|
+
}
|
|
17173
|
+
if (outcome === 'write_failed') {
|
|
17174
|
+
// Durable terminal could not be written. Close the session so its persisted
|
|
17175
|
+
// status becomes `closed` → trigger-result resolves `failed` (soft terminal),
|
|
17176
|
+
// and resolveIdempotencyHit sees a not-live session on retry. This is the
|
|
17177
|
+
// observable fail-closed path the round-8 finding requires.
|
|
17178
|
+
void closeSessionHelper(ds.session.sessionId).catch(() => { });
|
|
17179
|
+
logger.error(`[idempotency] fail-closed session ${ds.session.sessionId.slice(0, 8)} after exit-convergence write failure`);
|
|
15934
17180
|
}
|
|
15935
17181
|
};
|
|
15936
17182
|
// Initialise worker pool with daemon callbacks
|
|
@@ -15942,10 +17188,26 @@ export async function startDaemon(botIndex) {
|
|
|
15942
17188
|
// Route through the dashboard-aware helper so session.exited / session.update
|
|
15943
17189
|
// events fire for withdrawn-message / crash / adopt-exit teardown paths too,
|
|
15944
17190
|
// matching the dashboard-driven close.
|
|
15945
|
-
|
|
15946
|
-
|
|
17191
|
+
const sessionId = ds.session.sessionId;
|
|
17192
|
+
return runDetachedBotTurnMutation(ds.larkAppId, async () => {
|
|
17193
|
+
const current = findActiveBySessionId(sessionId);
|
|
17194
|
+
if (current !== ds)
|
|
17195
|
+
return false;
|
|
17196
|
+
if (hasProtectedSessionMutationOwnership(current)) {
|
|
17197
|
+
logger.info(`[${sessionId.substring(0, 8)}] Withdraw auto-close deferred: Codex App FIFO became unsettled`);
|
|
17198
|
+
return false;
|
|
17199
|
+
}
|
|
17200
|
+
await closeSessionHelper(sessionId);
|
|
17201
|
+
logger.info(`[${sessionId.substring(0, 8)}] Session auto-closed (message withdrawn)`);
|
|
17202
|
+
return true;
|
|
17203
|
+
}).catch((err) => {
|
|
17204
|
+
logger.warn(`[${sessionId.substring(0, 8)}] Withdraw auto-close mutation failed; session retained: `
|
|
17205
|
+
+ `${err instanceof Error ? err.message : String(err)}`);
|
|
17206
|
+
return false;
|
|
17207
|
+
});
|
|
15947
17208
|
},
|
|
15948
17209
|
enforceLiveSessionCap: () => enforceLiveSessionCap('session_change'),
|
|
17210
|
+
onQueuedActivationSubmitted,
|
|
15949
17211
|
onTurnTerminal(ds, terminal, context) {
|
|
15950
17212
|
const enqueued = vcMeetingTerminalReconciler?.enqueue(terminal, context);
|
|
15951
17213
|
if (terminal.dispatchAttempt !== undefined && enqueued?.accepted) {
|
|
@@ -15956,7 +17218,17 @@ export async function startDaemon(botIndex) {
|
|
|
15956
17218
|
onDeferredScheduleTurnSettled(ds, context) {
|
|
15957
17219
|
scheduleDeferredScheduleSettlement(ds, context);
|
|
15958
17220
|
},
|
|
15959
|
-
onCliExit(
|
|
17221
|
+
onCliExit(ds, context) {
|
|
17222
|
+
// Same idempotent-async convergence as onWorkerExit: the MANAGED CLI can
|
|
17223
|
+
// exit inside a still-live Node worker (persistent-pane / codex-app
|
|
17224
|
+
// auto-restart), in which case onWorkerExit never fires. An incomplete
|
|
17225
|
+
// keyed async turn whose CLI died with no final_output must still converge
|
|
17226
|
+
// to a durable dispatch_unknown, or trigger-result polls `running` and a
|
|
17227
|
+
// same-key retry reuses the dead generation forever (codex #776 round-6
|
|
17228
|
+
// finding #1 — the onCliExit half of that path). Idempotent + generation-
|
|
17229
|
+
// gated: safe under the onCliExit/onWorkerExit double-callback race, and a
|
|
17230
|
+
// no-op once final_output cleared the stamp.
|
|
17231
|
+
failCloseIdempotentTurnIfConvergenceWriteFailed(ds, context.workerGeneration);
|
|
15960
17232
|
const result = handleVcMeetingWorkerGenerationExit(context, {
|
|
15961
17233
|
dataDir: config.session.dataDir,
|
|
15962
17234
|
selfAppId: cfg.larkAppId,
|
|
@@ -15966,7 +17238,13 @@ export async function startDaemon(botIndex) {
|
|
|
15966
17238
|
+ `session=${context.sessionId.slice(0, 8)} generation=${context.workerGeneration}`);
|
|
15967
17239
|
}
|
|
15968
17240
|
},
|
|
15969
|
-
onWorkerExit(
|
|
17241
|
+
onWorkerExit(ds, context) {
|
|
17242
|
+
// Converge an incomplete idempotent async turn (options.idempotencyKey):
|
|
17243
|
+
// a worker that died with no final_output would otherwise poll `running`
|
|
17244
|
+
// and let a same-key retry `reuse` the dead session forever, until the next
|
|
17245
|
+
// boot reconcile (codex #776 round-6 finding #1). Best-effort + never throws
|
|
17246
|
+
// into the exit handler; a durable-write failure fail-closes the session.
|
|
17247
|
+
failCloseIdempotentTurnIfConvergenceWriteFailed(ds, context.workerGeneration);
|
|
15970
17248
|
const result = handleVcMeetingWorkerGenerationExit(context, {
|
|
15971
17249
|
dataDir: config.session.dataDir,
|
|
15972
17250
|
selfAppId: cfg.larkAppId,
|
|
@@ -15991,10 +17269,39 @@ export async function startDaemon(botIndex) {
|
|
|
15991
17269
|
onDurableExpiryReady(_ds, context) {
|
|
15992
17270
|
vcMeetingRuntimeLeaseRecovery.acknowledge(context);
|
|
15993
17271
|
},
|
|
17272
|
+
async onCodexAppLedgerDrained(ds) {
|
|
17273
|
+
await withBotTurnMutation(ds.larkAppId, async () => {
|
|
17274
|
+
await closeCliMismatchedSessionsForBot(ds.larkAppId);
|
|
17275
|
+
});
|
|
17276
|
+
},
|
|
15994
17277
|
});
|
|
15995
17278
|
// Expose the activeSessions Map (owned by daemon) to worker-pool readers,
|
|
15996
17279
|
// so dashboard IPC and other consumers can list/lookup live sessions.
|
|
15997
17280
|
setActiveSessionsRegistry(activeSessions);
|
|
17281
|
+
// Idempotency boot reconcile — MUST run before startIpcServer binds (a normal
|
|
17282
|
+
// fleet has no core-only readiness gate, so a live /api/trigger could otherwise
|
|
17283
|
+
// interleave with the sweep and have its fresh lease mistaken for stale) and is
|
|
17284
|
+
// scoped to THIS bot (the dataDir is shared across bots). Converges leases left
|
|
17285
|
+
// by a previous boot: `attempting` → durable failed(dispatch_unknown) + close;
|
|
17286
|
+
// `reserved` → CAS-remove + close. Returns the sessionIds it terminalized/closed
|
|
17287
|
+
// so restoreActiveSessions can quarantine them from re-attach (else a session the
|
|
17288
|
+
// poller now sees `failed` could be reattached and keep running — state/exec
|
|
17289
|
+
// divergence). sessionStore is init'd + worker pool is up by here.
|
|
17290
|
+
//
|
|
17291
|
+
// FAIL-CLOSED: if reconcile throws (a lease it could not prove converged — a
|
|
17292
|
+
// strict-failed write that failed, a corrupt lease, an unlink/close that
|
|
17293
|
+
// errored), we must NOT bind the IPC server and restore sessions as if
|
|
17294
|
+
// everything converged — that is exactly the "poller hangs running / orphan
|
|
17295
|
+
// re-attach" this feature exists to prevent. Abort this bot's startup so an
|
|
17296
|
+
// operator/supervisor sees it, rather than fail-open into an inconsistent state.
|
|
17297
|
+
let idempotencyQuarantinedSessionIds;
|
|
17298
|
+
try {
|
|
17299
|
+
idempotencyQuarantinedSessionIds = await reconcileIdempotencyLeasesOnBoot(cfg.larkAppId, getDaemonBootId());
|
|
17300
|
+
}
|
|
17301
|
+
catch (err) {
|
|
17302
|
+
logger.error(`[idempotency] boot reconcile failed to converge — aborting bot startup (fail-closed): ${err instanceof Error ? err.message : err}`);
|
|
17303
|
+
throw err instanceof Error ? err : new Error(String(err));
|
|
17304
|
+
}
|
|
15998
17305
|
// Seed dashboard IPC botName with the custom displayName (falling back to the
|
|
15999
17306
|
// bot's config id); the friendly name from /bot/v3/info is wired into the
|
|
16000
17307
|
// registry descriptor (below) but the IPC server also needs its own copy for
|
|
@@ -16017,6 +17324,8 @@ export async function startDaemon(botIndex) {
|
|
|
16017
17324
|
// host-only shared secret on every daemon IPC route except the tiny
|
|
16018
17325
|
// capability-gated receiver/readiness apertures in dashboard-ipc-server.
|
|
16019
17326
|
loadOrCreateDashboardSecret(join(homedir(), '.botmux', '.dashboard-secret'));
|
|
17327
|
+
let markIpcReady;
|
|
17328
|
+
const ipcReady = new Promise((resolve) => { markIpcReady = resolve; });
|
|
16020
17329
|
const coreOnly = process.env.BOTMUX_CORE_ONLY === '1';
|
|
16021
17330
|
// Core-only keeps the trusted-host HMAC ON (codex P1: authRequired:false opened
|
|
16022
17331
|
// ALL 96 IPC routes — a co-resident model turn could read/perturb sessions,
|
|
@@ -16033,6 +17342,7 @@ export async function startDaemon(botIndex) {
|
|
|
16033
17342
|
port: ipcPort,
|
|
16034
17343
|
host: '127.0.0.1',
|
|
16035
17344
|
authRequired: true,
|
|
17345
|
+
ready: ipcReady,
|
|
16036
17346
|
coreOnlyPublicRoutes: coreOnly,
|
|
16037
17347
|
// Fleet: probe upward so a port race can't crash boot. Core-only: BIND-OR-FAIL
|
|
16038
17348
|
// on the exact BOTMUX_API_PORT — riff was handed that port and the service must
|
|
@@ -16050,6 +17360,23 @@ export async function startDaemon(botIndex) {
|
|
|
16050
17360
|
// emitted LATER, AFTER restoreActiveSessions completes — until then BOTH
|
|
16051
17361
|
// /healthz AND the public control routes (trigger/result/insight) return 503,
|
|
16052
17362
|
// so riff never triggers into a racing durable restore (codex P1).
|
|
17363
|
+
// Publish daemon ownership immediately after IPC binds, then perform the
|
|
17364
|
+
// first session-file load under SessionStore's cross-process lock. An
|
|
17365
|
+
// offline CLI either observes this descriptor and delegates, or it already
|
|
17366
|
+
// holds the file lock; in the latter case this load waits and sees its atomic
|
|
17367
|
+
// mutation. Never load a stale cache in an unadvertised startup window.
|
|
17368
|
+
desc.lastHeartbeat = Date.now();
|
|
17369
|
+
writeDaemonDescriptor(desc);
|
|
17370
|
+
sessionStore.listSessions();
|
|
17371
|
+
const descriptorHeartbeat = setInterval(() => {
|
|
17372
|
+
desc.lastHeartbeat = Date.now();
|
|
17373
|
+
try {
|
|
17374
|
+
writeDaemonDescriptor(desc);
|
|
17375
|
+
}
|
|
17376
|
+
catch { /* best effort */ }
|
|
17377
|
+
}, 30_000);
|
|
17378
|
+
if (typeof descriptorHeartbeat.unref === 'function')
|
|
17379
|
+
descriptorHeartbeat.unref();
|
|
16053
17380
|
// Single reverse-proxy port that fronts every session's web terminal under
|
|
16054
17381
|
// /s/{sessionId}, so dev-machine users forward one port (proxyBasePort+idx)
|
|
16055
17382
|
// instead of one per topic. Bound on the public host so `ssh -L` can reach it.
|
|
@@ -16076,11 +17403,26 @@ export async function startDaemon(botIndex) {
|
|
|
16076
17403
|
// Quiet-restart leaves sessions registered but worker-less until messaged.
|
|
16077
17404
|
// Wake the worker on terminal access so links open without a manual ping.
|
|
16078
17405
|
ensureWorkerPort: async (sessionId) => {
|
|
17406
|
+
let owner;
|
|
16079
17407
|
for (const ds of activeSessions.values()) {
|
|
16080
|
-
if (ds.session.sessionId === sessionId)
|
|
16081
|
-
|
|
17408
|
+
if (ds.session.sessionId === sessionId) {
|
|
17409
|
+
owner = ds;
|
|
17410
|
+
break;
|
|
17411
|
+
}
|
|
16082
17412
|
}
|
|
16083
|
-
|
|
17413
|
+
if (!owner)
|
|
17414
|
+
return undefined;
|
|
17415
|
+
return withBotTurnAdmission(owner.larkAppId, () => {
|
|
17416
|
+
// The gate may have held this request behind a mutation/close. Resolve
|
|
17417
|
+
// the canonical record again before a lazy fork; never wake a stale ds
|
|
17418
|
+
// captured before the wait.
|
|
17419
|
+
for (const current of activeSessions.values()) {
|
|
17420
|
+
if (current.session.sessionId === sessionId) {
|
|
17421
|
+
return ensureTerminalWorkerPort(current);
|
|
17422
|
+
}
|
|
17423
|
+
}
|
|
17424
|
+
return undefined;
|
|
17425
|
+
});
|
|
16084
17426
|
},
|
|
16085
17427
|
});
|
|
16086
17428
|
// Only mark the proxy live after a successful bind — buildTerminalUrl then
|
|
@@ -16100,20 +17442,6 @@ export async function startDaemon(botIndex) {
|
|
|
16100
17442
|
if (config.web.externalPort) {
|
|
16101
17443
|
logger.info(`[terminal-proxy] terminal links advertise external port ${config.web.externalPort + idx} (WEB_EXTERNAL_PORT ${config.web.externalPort} + bot ${idx})`);
|
|
16102
17444
|
}
|
|
16103
|
-
// Now that the IPC port is actually listening, publish the descriptor so
|
|
16104
|
-
// the dashboard can discover us and successfully fetch /api/sessions etc.
|
|
16105
|
-
desc.lastHeartbeat = Date.now();
|
|
16106
|
-
writeDaemonDescriptor(desc);
|
|
16107
|
-
const descriptorHeartbeat = setInterval(() => {
|
|
16108
|
-
desc.lastHeartbeat = Date.now();
|
|
16109
|
-
try {
|
|
16110
|
-
writeDaemonDescriptor(desc);
|
|
16111
|
-
}
|
|
16112
|
-
catch { /* best effort */ }
|
|
16113
|
-
}, 30_000);
|
|
16114
|
-
// Don't keep the event loop alive on this interval alone.
|
|
16115
|
-
if (typeof descriptorHeartbeat.unref === 'function')
|
|
16116
|
-
descriptorHeartbeat.unref();
|
|
16117
17445
|
// Reap a dead prior daemon's detached classifier before any prepared
|
|
16118
17446
|
// proposal can be recovered. Per-proposal generation locks still serialize
|
|
16119
17447
|
// live rolling-restart overlap; this sweep handles only dead-owner markers.
|
|
@@ -16129,6 +17457,10 @@ export async function startDaemon(botIndex) {
|
|
|
16129
17457
|
catch (err) {
|
|
16130
17458
|
logger.warn(`[v3-distillation] global commit recovery failed: ${stableV3DistillationErrorCode(err)}`);
|
|
16131
17459
|
}
|
|
17460
|
+
// Do not start any Lark dispatcher until durable sessions have been restored
|
|
17461
|
+
// into the routing registry. Otherwise an inbound same-anchor turn can create
|
|
17462
|
+
// a second owner while the old unsettled row exists only on disk.
|
|
17463
|
+
const startEventDispatchers = [];
|
|
16132
17464
|
// Per-bot initialization
|
|
16133
17465
|
for (const bot of getAllBots()) {
|
|
16134
17466
|
const cfg = bot.config;
|
|
@@ -16276,29 +17608,25 @@ export async function startDaemon(botIndex) {
|
|
|
16276
17608
|
logger.info(`[auto-start:入群] ${cfg.larkAppId} autoStartOnGroupJoin 已开启 —— ` +
|
|
16277
17609
|
`请确认飞书开放平台已订阅事件 im.chat.member.bot.added_v1 且开通群成员读取权限,否则被拉群不会触发。`);
|
|
16278
17610
|
}
|
|
16279
|
-
//
|
|
17611
|
+
// Build the dispatcher now for authorization replay, but start it only
|
|
17612
|
+
// after restore has published every durable route owner.
|
|
16280
17613
|
const botEventHandlers = {
|
|
16281
|
-
handleCardAction: (data, appId) => handleCardAction(data, cardDeps, appId),
|
|
17614
|
+
handleCardAction: (data, appId) => withBotTurnAdmission(appId, () => handleCardAction(data, cardDeps, appId)),
|
|
16282
17615
|
handleNewTopic: (data, ctx) => handleNewTopic(data, ctx),
|
|
16283
17616
|
handleThreadReply: (data, ctx) => handleThreadReply(data, ctx),
|
|
16284
|
-
handleBotAdded: (chatId, operatorOpenId, appId) => handleBotAdded(chatId, operatorOpenId, appId),
|
|
17617
|
+
handleBotAdded: (chatId, operatorOpenId, appId) => withBotTurnAdmission(appId, () => handleBotAdded(chatId, operatorOpenId, appId)),
|
|
16285
17618
|
handleDocComment: (ctx) => handleDocComment(ctx),
|
|
16286
|
-
handleVcMeetingPush: (ctx) => handleVcMeetingPush(ctx),
|
|
17619
|
+
handleVcMeetingPush: (ctx) => withBotTurnAdmission(ctx.larkAppId, () => handleVcMeetingPush(ctx)),
|
|
16287
17620
|
beforeSessionTurn: (data, ctx) => maybeCatchUpVcMeetingConsumerBeforeTurn(data, ctx),
|
|
16288
17621
|
isSessionOwner: (anchor, appId) => activeSessions.has(sessionKey(anchor, appId)),
|
|
16289
17622
|
resolveReplyThreadAlias: (rootId, chatId, appId) => findChatReplyAlias(rootId, chatId, appId),
|
|
16290
17623
|
// Chat was converted 普通群 → 话题群 while we held a chat-scope session.
|
|
16291
|
-
//
|
|
16292
|
-
//
|
|
16293
|
-
//
|
|
16294
|
-
//
|
|
16295
|
-
// path. Scheduler tasks tied to this session keep their `scope='chat'`
|
|
16296
|
-
// semantics — that's an edge case worth following up on, not blocking
|
|
16297
|
-
// the main fix.
|
|
17624
|
+
// Idle legacy owners are evicted so subsequent inbound messages land on
|
|
17625
|
+
// fresh thread-scope sessions. Owners with accepted/pending work remain
|
|
17626
|
+
// addressable; the dispatcher already reroutes converted turns by their
|
|
17627
|
+
// new thread root, so preserving that old chat key cannot steal them.
|
|
16298
17628
|
onChatModeConverted: (chatId, appId) => {
|
|
16299
|
-
|
|
16300
|
-
const evicted = activeSessions.delete(key);
|
|
16301
|
-
logger.info(`[chat-mode-converted] ${chatId.substring(0, 12)} evicted=${evicted}; worker (if any) keeps running until /close`);
|
|
17629
|
+
handleChatModeConverted(chatId, appId);
|
|
16302
17630
|
},
|
|
16303
17631
|
};
|
|
16304
17632
|
// 存起来供授权成功后重放消息用(replayGrantedMessage → replayMessageEvent)。
|
|
@@ -16306,7 +17634,7 @@ export async function startDaemon(botIndex) {
|
|
|
16306
17634
|
// apiOnly bots never subscribe to Feishu events → no WSClient. This is the
|
|
16307
17635
|
// core decoupling: the daemon serves the HTTP control API only.
|
|
16308
17636
|
if (!cfg.apiOnly) {
|
|
16309
|
-
startLarkEventDispatcher(cfg.larkAppId, cfg.larkAppSecret, botEventHandlers, normalizeBrand(cfg.brand));
|
|
17637
|
+
startEventDispatchers.push(() => startLarkEventDispatcher(cfg.larkAppId, cfg.larkAppSecret, botEventHandlers, normalizeBrand(cfg.brand)));
|
|
16310
17638
|
}
|
|
16311
17639
|
// A distillation command is durably prepared before its model run/card
|
|
16312
17640
|
// delivery. Resume active prepared/proposed allocations after a daemon
|
|
@@ -16326,7 +17654,11 @@ export async function startDaemon(botIndex) {
|
|
|
16326
17654
|
// See reapOrphanWorkers() in worker-pool.ts.
|
|
16327
17655
|
reapOrphanWorkers();
|
|
16328
17656
|
// Restore active sessions from previous run
|
|
16329
|
-
|
|
17657
|
+
// Restore active sessions from previous run
|
|
17658
|
+
await restoreActiveSessions(activeSessions, idempotencyQuarantinedSessionIds);
|
|
17659
|
+
// Restore complete → /api/asks may now safely 403 unknown sessions again; a
|
|
17660
|
+
// reconnecting ask hook that raced the restore got retryable 503s until here.
|
|
17661
|
+
sessionsRestored = true;
|
|
16330
17662
|
// Now that activeSessions is populated, release the forward-followup flush
|
|
16331
17663
|
// barrier. Persisted seeds were loaded into the buffer at dispatcher startup
|
|
16332
17664
|
// (so root-linked clarifications can pair with them), but their flushes wait
|
|
@@ -16334,6 +17666,12 @@ export async function startDaemon(botIndex) {
|
|
|
16334
17666
|
for (const bot of getAllBots()) {
|
|
16335
17667
|
markForwardFollowupsSessionsReady(bot.config.larkAppId);
|
|
16336
17668
|
}
|
|
17669
|
+
// The descriptor was intentionally published before restore so offline CLI
|
|
17670
|
+
// mutations delegate to this daemon. Release those queued IPC calls only
|
|
17671
|
+
// after every durable owner is visible in the canonical registry.
|
|
17672
|
+
markIpcReady();
|
|
17673
|
+
for (const startDispatcher of startEventDispatchers)
|
|
17674
|
+
startDispatcher();
|
|
16337
17675
|
try {
|
|
16338
17676
|
await reconcileVcMeetingManagedActionsOnBoot(cfg.larkAppId);
|
|
16339
17677
|
}
|
|
@@ -16367,7 +17705,11 @@ export async function startDaemon(botIndex) {
|
|
|
16367
17705
|
|| receiver.memberEpoch !== ref.memberEpoch) {
|
|
16368
17706
|
// Corrupt identity must not restart an unrelated session or silently
|
|
16369
17707
|
// un-gate delivery. Keep the daemon fail-closed for operator repair.
|
|
16370
|
-
addVcMeetingReceiverRecoveryPending(recoveryKey,
|
|
17708
|
+
addVcMeetingReceiverRecoveryPending(recoveryKey, {
|
|
17709
|
+
...vcMeetingDeliveryScopeFromRef(ref),
|
|
17710
|
+
turnId: ref.deliveryKey,
|
|
17711
|
+
dispatchAttempt: ref.dispatchAttempt,
|
|
17712
|
+
});
|
|
16371
17713
|
logger.error(`[vc-delivery] boot receiver identity conflict; delivery remains gated `
|
|
16372
17714
|
+ `session=${ref.receiverSessionId}`);
|
|
16373
17715
|
continue;
|
|
@@ -16378,7 +17720,11 @@ export async function startDaemon(botIndex) {
|
|
|
16378
17720
|
vcMeetingRuntimeLeaseRecovery.arm(ref, cfg.larkAppId);
|
|
16379
17721
|
continue;
|
|
16380
17722
|
}
|
|
16381
|
-
addVcMeetingReceiverRecoveryPending(recoveryKey,
|
|
17723
|
+
addVcMeetingReceiverRecoveryPending(recoveryKey, {
|
|
17724
|
+
...vcMeetingDeliveryScopeFromRef(ref),
|
|
17725
|
+
turnId: ref.deliveryKey,
|
|
17726
|
+
dispatchAttempt: ref.dispatchAttempt,
|
|
17727
|
+
});
|
|
16382
17728
|
armVcMeetingReceiverRecoveryTimeout(recoveryKey, ref.receiverSessionId);
|
|
16383
17729
|
try {
|
|
16384
17730
|
ds.worker.send({
|
|
@@ -16470,7 +17816,7 @@ export async function startDaemon(botIndex) {
|
|
|
16470
17816
|
// each filters to only execute tasks whose `larkAppId` matches its bot
|
|
16471
17817
|
// (unmatched tasks are handled by the owning bot's daemon instead; a
|
|
16472
17818
|
// missing larkAppId falls through to bot-0 as a legacy fallback).
|
|
16473
|
-
scheduler.setExecuteCallback((task) => executeScheduledTask(task, activeSessions, refreshCliVersion));
|
|
17819
|
+
scheduler.setExecuteCallback((task) => withBotTurnAdmission(task.larkAppId ?? cfg.larkAppId, () => executeScheduledTask(task, activeSessions, refreshCliVersion)));
|
|
16474
17820
|
scheduler.setOwnerFilter(cfg.larkAppId, idx === 0);
|
|
16475
17821
|
scheduler.startScheduler();
|
|
16476
17822
|
// Cross-daemon busy heartbeat: each daemon reports how many of its sessions
|
|
@@ -16606,129 +17952,265 @@ export async function startDaemon(botIndex) {
|
|
|
16606
17952
|
}, 30_000);
|
|
16607
17953
|
overloadTimer.unref?.();
|
|
16608
17954
|
}
|
|
16609
|
-
// Graceful shutdown.
|
|
16610
|
-
//
|
|
17955
|
+
// Graceful shutdown. Riff owners first run a three-phase non-cancelling drain
|
|
17956
|
+
// that durably ACKs every exact final task lineage as one fleet transaction.
|
|
17957
|
+
// No worker exits until every participant is prepared and fresh-verified.
|
|
17958
|
+
// Ordinary workers then receive SIGTERM / close IPC and the daemon waits up
|
|
17959
|
+
// to DAEMON_WORKER_EXIT_GRACE_MS for them to exit
|
|
16611
17960
|
// before sending SIGKILL to stragglers. Without the wait, daemon
|
|
16612
|
-
//
|
|
17961
|
+
// the final managed sentinel exit races worker signal delivery —
|
|
17962
|
+
// and any worker whose
|
|
16613
17963
|
// main thread is in a sync code path (e.g. the bridge fingerprint scan
|
|
16614
17964
|
// bug fixed in v2.9.2) loses the signal and survives as a ppid=1 orphan
|
|
16615
17965
|
// forever (we'd accumulated 841 such orphans across daemon restarts,
|
|
16616
17966
|
// consuming ~65 GB of RAM until manually SIGKILL'd).
|
|
16617
|
-
const SHUTDOWN_GRACE_MS = 3000;
|
|
16618
17967
|
let shuttingDown = false;
|
|
16619
17968
|
const shutdown = async () => {
|
|
16620
17969
|
if (shuttingDown)
|
|
16621
17970
|
return;
|
|
16622
17971
|
shuttingDown = true;
|
|
16623
|
-
|
|
16624
|
-
|
|
16625
|
-
|
|
16626
|
-
|
|
16627
|
-
|
|
16628
|
-
|
|
16629
|
-
|
|
16630
|
-
|
|
16631
|
-
|
|
16632
|
-
|
|
16633
|
-
|
|
16634
|
-
|
|
16635
|
-
|
|
16636
|
-
|
|
16637
|
-
|
|
16638
|
-
|
|
16639
|
-
|
|
16640
|
-
|
|
16641
|
-
|
|
16642
|
-
|
|
16643
|
-
|
|
16644
|
-
|
|
16645
|
-
|
|
16646
|
-
|
|
16647
|
-
|
|
16648
|
-
|
|
16649
|
-
clearTimeout(closing.timer);
|
|
16650
|
-
}
|
|
16651
|
-
vcMeetingClosingConsumerSessions.clear();
|
|
16652
|
-
for (const key of [...vcMeetingPendingInvites.keys()])
|
|
16653
|
-
deleteVcMeetingPendingInvite(key);
|
|
16654
|
-
clearInterval(descriptorHeartbeat);
|
|
16655
|
-
clearInterval(idleWorkerSweepTimer);
|
|
16656
|
-
if (memoryDiagnostics)
|
|
16657
|
-
clearInterval(memoryDiagnostics);
|
|
16658
|
-
removeDaemonDescriptor(cfg.larkAppId);
|
|
16659
|
-
ipcHandle.close().catch(() => { });
|
|
16660
|
-
if (terminalProxy)
|
|
16661
|
-
terminalProxy.close().catch(() => { });
|
|
16662
|
-
const pendingExits = [];
|
|
16663
|
-
const survivors = [];
|
|
16664
|
-
for (const [, ds] of activeSessions) {
|
|
16665
|
-
if (ds.worker && !ds.worker.killed) {
|
|
16666
|
-
logger.info(`Shutting down worker for session ${ds.session.sessionId}`);
|
|
16667
|
-
const w = ds.worker;
|
|
16668
|
-
// Capture the exit promise BEFORE killWorker nulls ds.worker.
|
|
16669
|
-
if (w.exitCode === null && w.signalCode === null) {
|
|
16670
|
-
pendingExits.push(new Promise(resolve => {
|
|
16671
|
-
w.once('exit', () => resolve());
|
|
16672
|
-
}));
|
|
16673
|
-
survivors.push(w);
|
|
17972
|
+
const shutdownDeadlineMs = Date.now() + DAEMON_SHUTDOWN_MAX_MS;
|
|
17973
|
+
const mutationResult = await tryWithBotTurnMutation(cfg.larkAppId, BOT_TURN_MUTATION_SHUTDOWN_ACQUIRE_TIMEOUT_MS, async () => {
|
|
17974
|
+
setSessionLifecycleShutdown(true);
|
|
17975
|
+
logger.info(`Daemon shutting down... (active: ${getActiveCount()})`);
|
|
17976
|
+
const initialShutdownFleet = collectUniqueDaemonShutdownSessions(activeSessions.values());
|
|
17977
|
+
if (!initialShutdownFleet.ok) {
|
|
17978
|
+
setSessionLifecycleShutdown(false);
|
|
17979
|
+
shuttingDown = false;
|
|
17980
|
+
logger.error(`Daemon remains online: ${initialShutdownFleet.error}`);
|
|
17981
|
+
return;
|
|
17982
|
+
}
|
|
17983
|
+
// Preflight Riff before stopping services/removing descriptors. A failed
|
|
17984
|
+
// drain or persistence write aborts every successfully prepared peer, so a
|
|
17985
|
+
// refused shutdown returns to a live fleet instead of leaving an early
|
|
17986
|
+
// participant workerless.
|
|
17987
|
+
const riffCandidates = initialShutdownFleet.sessions
|
|
17988
|
+
.filter(ds => shutdownBackendDisposition(ds) === 'riff-drain-detach');
|
|
17989
|
+
const riffPrepareResults = await prepareRiffFleetForShutdown(riffCandidates, {
|
|
17990
|
+
deadlineMs: shutdownDeadlineMs,
|
|
17991
|
+
});
|
|
17992
|
+
const riffPrepared = riffPrepareResults.filter((entry) => entry.result.ok);
|
|
17993
|
+
const riffFenced = riffPrepareResults.filter((entry) => entry.result.fence !== 'none');
|
|
17994
|
+
const abortRiffFleet = async (reason, retainFenced = new Set()) => {
|
|
17995
|
+
for (const ds of retainFenced) {
|
|
17996
|
+
logger.error(`[${ds.session.sessionId.slice(0, 8)}] Riff shutdown participant remains fenced: `
|
|
17997
|
+
+ 'durable/runtime ownership could not be reconciled safely');
|
|
16674
17998
|
}
|
|
16675
|
-
|
|
16676
|
-
|
|
16677
|
-
|
|
16678
|
-
|
|
16679
|
-
|
|
16680
|
-
|
|
16681
|
-
|
|
16682
|
-
|
|
16683
|
-
|
|
16684
|
-
|
|
16685
|
-
// `zellij delete-session -f`, permanently erasing the session and
|
|
16686
|
-
// breaking daemon-restart reattach (the blocker Codex flagged).
|
|
16687
|
-
try {
|
|
16688
|
-
w.kill('SIGTERM');
|
|
16689
|
-
}
|
|
16690
|
-
catch { /* ignore */ }
|
|
16691
|
-
ds.worker = null;
|
|
16692
|
-
ds.workerPort = null;
|
|
16693
|
-
ds.workerToken = null;
|
|
16694
|
-
ds.workerViewToken = null;
|
|
16695
|
-
ds.managedTurnOrigin = undefined;
|
|
17999
|
+
const aborts = await abortRiffShutdownFleet(riffFenced
|
|
18000
|
+
.filter(({ ds }) => !retainFenced.has(ds))
|
|
18001
|
+
.map(({ ds, result }) => ({ ds, result })), {
|
|
18002
|
+
deadlineMs: shutdownDeadlineMs,
|
|
18003
|
+
});
|
|
18004
|
+
for (const { ds, result } of aborts) {
|
|
18005
|
+
if (result.ok)
|
|
18006
|
+
continue;
|
|
18007
|
+
logger.error(`[${ds.session.sessionId.slice(0, 8)}] Riff shutdown rollback was not ACKed: `
|
|
18008
|
+
+ `${result.error ?? 'unknown'}; admission remains fail-closed`);
|
|
16696
18009
|
}
|
|
16697
|
-
|
|
16698
|
-
|
|
18010
|
+
setSessionLifecycleShutdown(false);
|
|
18011
|
+
shuttingDown = false;
|
|
18012
|
+
logger.error(`Daemon remains online: ${reason}`);
|
|
18013
|
+
};
|
|
18014
|
+
const riffPrepareFailures = riffPrepareResults.filter(entry => !entry.result.ok);
|
|
18015
|
+
if (riffPrepareFailures.length > 0) {
|
|
18016
|
+
for (const { ds, result } of riffPrepareFailures) {
|
|
18017
|
+
if (result.ok)
|
|
18018
|
+
continue;
|
|
18019
|
+
logger.error(`[${ds.session.sessionId.slice(0, 8)}] Daemon shutdown prepare refused: `
|
|
18020
|
+
+ `${result.error}${result.taskId ? ` (task ${result.taskId})` : ''}`);
|
|
18021
|
+
}
|
|
18022
|
+
await abortRiffFleet(`${riffPrepareFailures.length} Riff owner(s) could not be safely drained`);
|
|
18023
|
+
return;
|
|
18024
|
+
}
|
|
18025
|
+
const riffPersistence = persistPreparedRiffShutdownFleet(riffPrepared, {
|
|
18026
|
+
deadlineMs: shutdownDeadlineMs,
|
|
18027
|
+
});
|
|
18028
|
+
if (!riffPersistence.ok) {
|
|
18029
|
+
const retainIds = new Set(riffPersistence.retainFencedSessionIds);
|
|
18030
|
+
const retainFenced = new Set(riffPrepared
|
|
18031
|
+
.filter(({ ds }) => retainIds.has(ds.session.sessionId))
|
|
18032
|
+
.map(({ ds }) => ds));
|
|
18033
|
+
logger.error(`Daemon shutdown lineage verification refused: ${riffPersistence.error}`);
|
|
18034
|
+
await abortRiffFleet(`${riffPersistence.sessionIds.length} Riff lineage row(s) could not be fresh-verified`, retainFenced);
|
|
18035
|
+
return;
|
|
18036
|
+
}
|
|
18037
|
+
// Recheck generation ownership while every participant is still fenced and
|
|
18038
|
+
// daemon services remain live.
|
|
18039
|
+
const currentShutdownFleet = collectUniqueDaemonShutdownSessions(activeSessions.values());
|
|
18040
|
+
if (!currentShutdownFleet.ok) {
|
|
18041
|
+
const ambiguousSessionId = currentShutdownFleet.sessionId;
|
|
18042
|
+
await abortRiffFleet(`Riff ownership became ambiguous after shutdown preflight: ${currentShutdownFleet.error}`, new Set(riffPrepared
|
|
18043
|
+
.filter(({ ds }) => ds.session.sessionId === ambiguousSessionId)
|
|
18044
|
+
.map(({ ds }) => ds)));
|
|
18045
|
+
return;
|
|
18046
|
+
}
|
|
18047
|
+
const currentRiffOwners = currentShutdownFleet.sessions
|
|
18048
|
+
.filter(ds => shutdownBackendDisposition(ds) === 'riff-drain-detach');
|
|
18049
|
+
const riffPreparedOwners = new Set(riffPrepared.map(entry => entry.ds));
|
|
18050
|
+
const riffGenerationMismatch = currentRiffOwners.some(ds => !riffPreparedOwners.has(ds))
|
|
18051
|
+
|| riffPrepared.some(({ ds, result }) => !currentRiffOwners.includes(ds) || !isPreparedRiffSessionCurrent(ds, result));
|
|
18052
|
+
if (riffGenerationMismatch) {
|
|
18053
|
+
const retainFenced = new Set(riffPrepared
|
|
18054
|
+
.filter(({ ds, result }) => (!currentRiffOwners.includes(ds) || !isPreparedRiffSessionCurrent(ds, result))
|
|
18055
|
+
&& !canAbortVerifiedExitedRiffPreparation(ds, result))
|
|
18056
|
+
.map(({ ds }) => ds));
|
|
18057
|
+
await abortRiffFleet('Riff ownership changed after shutdown preflight', retainFenced);
|
|
18058
|
+
return;
|
|
18059
|
+
}
|
|
18060
|
+
// Validate-all above, then commit-all synchronously with no await or
|
|
18061
|
+
// callback boundary between peers.
|
|
18062
|
+
const riffRetiredWorkers = [];
|
|
18063
|
+
for (const { ds, result } of riffPrepared) {
|
|
18064
|
+
if (!commitPreparedRiffShutdown(ds, result)) {
|
|
18065
|
+
throw new Error(`Riff shutdown commit invariant lost for ${ds.session.sessionId}`);
|
|
16699
18066
|
}
|
|
18067
|
+
if (result.worker)
|
|
18068
|
+
riffRetiredWorkers.push(result.worker);
|
|
16700
18069
|
}
|
|
16701
|
-
|
|
16702
|
-
|
|
16703
|
-
|
|
16704
|
-
|
|
16705
|
-
|
|
16706
|
-
|
|
16707
|
-
|
|
16708
|
-
|
|
16709
|
-
|
|
16710
|
-
|
|
18070
|
+
scheduler.stopScheduler();
|
|
18071
|
+
stopMaintenance();
|
|
18072
|
+
vcMeetingTerminalReconciler?.stop();
|
|
18073
|
+
clearInterval(vcMeetingDeliveryLeaseTimer);
|
|
18074
|
+
for (const timer of vcMeetingReceiverRecoveryTimers.values())
|
|
18075
|
+
clearTimeout(timer);
|
|
18076
|
+
vcMeetingReceiverRecoveryTimers.clear();
|
|
18077
|
+
vcMeetingReceiverRecoveryPending.clear();
|
|
18078
|
+
vcMeetingReceiverRecoveryEscalating.clear();
|
|
18079
|
+
vcMeetingReceiverRecoveryScopes.clear();
|
|
18080
|
+
vcMeetingRuntimeLeaseRecovery.reset();
|
|
18081
|
+
for (const timer of deferredScheduleSettleTimers.values())
|
|
18082
|
+
clearTimeout(timer);
|
|
18083
|
+
deferredScheduleSettleTimers.clear();
|
|
18084
|
+
vcMeetingReceiverRecoveryReady = false;
|
|
18085
|
+
stopCliRuntimeUpdateMonitor();
|
|
18086
|
+
v3ProgressCardManager.close();
|
|
18087
|
+
clearInterval(maintenanceHeartbeat);
|
|
18088
|
+
clearInterval(docCommentPollTimer);
|
|
18089
|
+
for (const session of vcMeetingSessions.values())
|
|
18090
|
+
cleanupVcMeetingDaemonSession(session, 'daemon-shutdown');
|
|
18091
|
+
vcMeetingSessions.clear();
|
|
18092
|
+
for (const closing of vcMeetingClosingConsumerSessions.values()) {
|
|
18093
|
+
if (closing.timer)
|
|
18094
|
+
clearTimeout(closing.timer);
|
|
18095
|
+
}
|
|
18096
|
+
vcMeetingClosingConsumerSessions.clear();
|
|
18097
|
+
for (const key of [...vcMeetingPendingInvites.keys()])
|
|
18098
|
+
deleteVcMeetingPendingInvite(key);
|
|
18099
|
+
clearInterval(descriptorHeartbeat);
|
|
18100
|
+
clearInterval(idleWorkerSweepTimer);
|
|
18101
|
+
if (memoryDiagnostics)
|
|
18102
|
+
clearInterval(memoryDiagnostics);
|
|
18103
|
+
removeDaemonDescriptor(cfg.larkAppId);
|
|
18104
|
+
ipcHandle.close().catch(() => { });
|
|
18105
|
+
if (terminalProxy)
|
|
18106
|
+
terminalProxy.close().catch(() => { });
|
|
18107
|
+
const pendingExits = [];
|
|
18108
|
+
const survivors = [];
|
|
18109
|
+
const trackWorkerExit = (w) => {
|
|
18110
|
+
if (w.exitCode !== null || w.signalCode !== null)
|
|
18111
|
+
return;
|
|
18112
|
+
pendingExits.push(new Promise(resolve => {
|
|
18113
|
+
w.once('exit', () => resolve());
|
|
18114
|
+
// Close the check/listener race if exit landed between the first
|
|
18115
|
+
// status read and once().
|
|
18116
|
+
if (w.exitCode !== null || w.signalCode !== null)
|
|
18117
|
+
resolve();
|
|
18118
|
+
}));
|
|
18119
|
+
survivors.push(w);
|
|
18120
|
+
};
|
|
18121
|
+
for (const worker of riffRetiredWorkers)
|
|
18122
|
+
trackWorkerExit(worker);
|
|
18123
|
+
for (const ds of currentShutdownFleet.sessions) {
|
|
18124
|
+
if (ds.worker && !ds.worker.killed) {
|
|
18125
|
+
logger.info(`Shutting down worker for session ${ds.session.sessionId}`);
|
|
18126
|
+
const w = ds.worker;
|
|
18127
|
+
const disposition = shutdownBackendDisposition(ds);
|
|
18128
|
+
if (disposition === 'riff-drain-detach') {
|
|
18129
|
+
throw new Error(`undrained Riff generation after atomic commit: ${ds.session.sessionId}`);
|
|
18130
|
+
}
|
|
18131
|
+
// Capture the exit promise BEFORE killWorker nulls ds.worker.
|
|
18132
|
+
trackWorkerExit(w);
|
|
18133
|
+
// Branch by the session's FROZEN backend (stamped on Session.backendType
|
|
18134
|
+
// at spawn), NOT the bot's live config — a dashboard backendType edit must
|
|
18135
|
+
// not change how a running session is torn down, or we'd e.g. try to
|
|
18136
|
+
// detach-preserve a "herdr" session whose real pane is tmux (freeze-once).
|
|
18137
|
+
// undefined (frozen pty, or unresolvable legacy) → non-persistent → killWorker.
|
|
18138
|
+
if (disposition === 'detach') {
|
|
18139
|
+
// Persistent backends (tmux / herdr / zellij / zmx): just kill the worker process —
|
|
18140
|
+
// the multiplexer session survives for re-attach. The worker's SIGTERM
|
|
18141
|
+
// handler calls backend.kill(), which only DETACHES. Going through
|
|
18142
|
+
// killWorker() instead would send {type:'close'} → destroySession() →
|
|
18143
|
+
// `zellij delete-session -f`, permanently erasing the session and
|
|
18144
|
+
// breaking daemon-restart reattach (the blocker Codex flagged).
|
|
18145
|
+
try {
|
|
18146
|
+
w.kill('SIGTERM');
|
|
18147
|
+
}
|
|
18148
|
+
catch { /* ignore */ }
|
|
18149
|
+
ds.worker = null;
|
|
18150
|
+
ds.workerPort = null;
|
|
18151
|
+
ds.workerToken = null;
|
|
18152
|
+
ds.workerViewToken = null;
|
|
18153
|
+
ds.managedTurnOrigin = undefined;
|
|
18154
|
+
}
|
|
18155
|
+
else {
|
|
18156
|
+
killWorker(ds);
|
|
16711
18157
|
}
|
|
16712
|
-
catch { /* already dead */ }
|
|
16713
18158
|
}
|
|
16714
18159
|
}
|
|
16715
|
-
if (
|
|
16716
|
-
|
|
18160
|
+
if (pendingExits.length > 0) {
|
|
18161
|
+
const exitGraceMs = Math.min(DAEMON_WORKER_EXIT_GRACE_MS, Math.max(0, shutdownDeadlineMs - Date.now()));
|
|
18162
|
+
const timeout = new Promise(resolve => setTimeout(resolve, exitGraceMs));
|
|
18163
|
+
await Promise.race([Promise.all(pendingExits), timeout]);
|
|
18164
|
+
let stragglers = 0;
|
|
18165
|
+
for (const w of survivors) {
|
|
18166
|
+
if (w.exitCode === null && w.signalCode === null) {
|
|
18167
|
+
stragglers++;
|
|
18168
|
+
try {
|
|
18169
|
+
w.kill('SIGKILL');
|
|
18170
|
+
}
|
|
18171
|
+
catch { /* already dead */ }
|
|
18172
|
+
}
|
|
18173
|
+
}
|
|
18174
|
+
if (stragglers > 0) {
|
|
18175
|
+
logger.warn(`${stragglers}/${survivors.length} worker(s) didn't exit within ${exitGraceMs}ms — SIGKILL'd to prevent ppid=1 orphans.`);
|
|
18176
|
+
}
|
|
16717
18177
|
}
|
|
18178
|
+
// Flush any pending identity-cache writes before exit. The cache uses a
|
|
18179
|
+
// 2s debounce on disk persistence to dedupe writes from chatty groups; on
|
|
18180
|
+
// SIGTERM we want anything learned since the last flush to land.
|
|
18181
|
+
flushIdentityCacheSync();
|
|
18182
|
+
removePidFile();
|
|
18183
|
+
process.exit(gracefulProcessExitCode());
|
|
18184
|
+
});
|
|
18185
|
+
if (!mutationResult.acquired) {
|
|
18186
|
+
shuttingDown = false;
|
|
18187
|
+
logger.error(`Daemon remains online: could not acquire exclusive shutdown mutation lease `
|
|
18188
|
+
+ `(${mutationResult.reason})`);
|
|
16718
18189
|
}
|
|
16719
|
-
// Flush any pending identity-cache writes before exit. The cache uses a
|
|
16720
|
-
// 2s debounce on disk persistence to dedupe writes from chatty groups; on
|
|
16721
|
-
// SIGTERM we want anything learned since the last flush to land.
|
|
16722
|
-
flushIdentityCacheSync();
|
|
16723
|
-
removePidFile();
|
|
16724
|
-
process.exit(gracefulProcessExitCode());
|
|
16725
18190
|
};
|
|
16726
18191
|
process.on('SIGTERM', () => { shutdown().catch(err => { logger.error(`shutdown failed: ${err?.message ?? err}`); process.exit(1); }); });
|
|
16727
18192
|
process.on('SIGINT', () => { shutdown().catch(err => { logger.error(`shutdown failed: ${err?.message ?? err}`); process.exit(1); }); });
|
|
18193
|
+
// Capability publication is the final startup commit for supervisor-driven
|
|
18194
|
+
// shutdown. The early descriptor intentionally lacks it: a new CLI that
|
|
18195
|
+
// observes this daemon before both handlers/state closures exist must refuse
|
|
18196
|
+
// to signal. Atomic rewrite makes the capability visible only afterward.
|
|
18197
|
+
if (readSupervisorProcessStartIdentity(process.pid) !== desc.processStartIdentity) {
|
|
18198
|
+
throw new Error('daemon process-start identity changed before shutdown capability commit');
|
|
18199
|
+
}
|
|
18200
|
+
setSupervisorShutdownHandler({
|
|
18201
|
+
larkAppId: cfg.larkAppId,
|
|
18202
|
+
bootInstanceId: desc.bootInstanceId,
|
|
18203
|
+
processStartIdentity: desc.processStartIdentity,
|
|
18204
|
+
shutdown,
|
|
18205
|
+
});
|
|
18206
|
+
desc.supervisorShutdownProtocol = SUPERVISOR_SHUTDOWN_PROTOCOL;
|
|
18207
|
+
desc.lastHeartbeat = Date.now();
|
|
18208
|
+
writeDaemonDescriptor(desc);
|
|
16728
18209
|
// Best-effort cleanup on plain `exit` (e.g. uncaught fatal). No worker
|
|
16729
18210
|
// shutdown here since the process is already on its way out — just remove
|
|
16730
18211
|
// the descriptor so the dashboard doesn't see a phantom daemon.
|
|
16731
18212
|
process.on('exit', () => {
|
|
18213
|
+
setSupervisorShutdownHandler(null);
|
|
16732
18214
|
clearInterval(descriptorHeartbeat);
|
|
16733
18215
|
clearInterval(idleWorkerSweepTimer);
|
|
16734
18216
|
clearInterval(docCommentPollTimer);
|