elanous 0.2.21 → 0.2.22
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/apps/pwa/out/404/index.html +1 -1
- package/apps/pwa/out/404.html +1 -1
- package/apps/pwa/out/_next/static/chunks/1307-08b716476f1d50af.js +1 -0
- package/apps/pwa/out/_next/static/chunks/{2489-ff55a35b202c0f94.js → 2489-d238d3e2eb5b2bf5.js} +1 -1
- package/apps/pwa/out/_next/static/chunks/3857-5985236d9307f676.js +1 -0
- package/apps/pwa/out/_next/static/chunks/5633-d6852385000738ef.js +1 -0
- package/apps/pwa/out/_next/static/chunks/5977-5b9e8a45a5b0c823.js +1 -0
- package/apps/pwa/out/_next/static/chunks/8857-aa1cc4ac90af4d40.js +1 -0
- package/apps/pwa/out/_next/static/chunks/9247-55bcfbb0e8057f59.js +1 -0
- package/apps/pwa/out/_next/static/chunks/app/approvals/page-88459e0903da2803.js +1 -0
- package/apps/pwa/out/_next/static/chunks/app/autopilot/{page-d781be3e3a9cfd74.js → page-d85c43f46f58716b.js} +1 -1
- package/apps/pwa/out/_next/static/chunks/app/board/{page-b0a80997903852d8.js → page-4e645600579ae952.js} +1 -1
- package/apps/pwa/out/_next/static/chunks/app/botlab/{page-2b1263165630d9c8.js → page-87edbfd19117823b.js} +1 -1
- package/apps/pwa/out/_next/static/chunks/app/bots/page-7f0c2b7801ac6a54.js +1 -0
- package/apps/pwa/out/_next/static/chunks/app/ceo/page-bf1f030a9d1adffd.js +1 -0
- package/apps/pwa/out/_next/static/chunks/app/chat/{page-e2d6bed48ac20120.js → page-cbaf66b1e1804adf.js} +1 -1
- package/apps/pwa/out/_next/static/chunks/app/consult/{page-111cabe4d1fd69ae.js → page-80ed511e02c81412.js} +1 -1
- package/apps/pwa/out/_next/static/chunks/app/control/{page-544dd4bd4847eeb7.js → page-9f5610caaa06178c.js} +1 -1
- package/apps/pwa/out/_next/static/chunks/app/decisions/{page-3ee191319bf97d87.js → page-ec8f3575e339de74.js} +1 -1
- package/apps/pwa/out/_next/static/chunks/app/design-check/page-86d753e460f1a989.js +1 -0
- package/apps/pwa/out/_next/static/chunks/app/editor/page-4166a7d51cf0a57d.js +1 -0
- package/apps/pwa/out/_next/static/chunks/app/exec/{page-a449f1ed472b81c2.js → page-2a19c5cd3b562580.js} +1 -1
- package/apps/pwa/out/_next/static/chunks/app/field/{page-9045094d6d3b5cd0.js → page-b6a09cde2e07521a.js} +1 -1
- package/apps/pwa/out/_next/static/chunks/app/inside/{page-c06673596849ef45.js → page-f7eae1518ca0c353.js} +1 -1
- package/apps/pwa/out/_next/static/chunks/app/intake/page-456a8573ad811381.js +1 -0
- package/apps/pwa/out/_next/static/chunks/app/{layout-9f81233f29dcd409.js → layout-8462d6a543cbb2d9.js} +1 -1
- package/apps/pwa/out/_next/static/chunks/app/live/page-2974cdfb87c0f9c9.js +1 -0
- package/apps/pwa/out/_next/static/chunks/app/live-run/page-f927ffa25f3c86d1.js +1 -0
- package/apps/pwa/out/_next/static/chunks/app/loops/page-abd86cd832aaa011.js +1 -0
- package/apps/pwa/out/_next/static/chunks/app/market/page-79daf68f6256adaa.js +1 -0
- package/apps/pwa/out/_next/static/chunks/app/missions/[id]/page-8c3f168fc3f91939.js +1 -0
- package/apps/pwa/out/_next/static/chunks/app/morning/{page-7a12f61be4a579d1.js → page-786fb4504d0f2fa9.js} +1 -1
- package/apps/pwa/out/_next/static/chunks/app/{not-found-c85b0589591a99c9.js → not-found-75209f14597dcb8f.js} +1 -1
- package/apps/pwa/out/_next/static/chunks/app/observatory/{page-725a4da02dd129eb.js → page-bf30affe3a85af7e.js} +1 -1
- package/apps/pwa/out/_next/static/chunks/app/ops/checklist/page-1187a44b7515434b.js +1 -0
- package/apps/pwa/out/_next/static/chunks/app/ops/release/page-3d0910185a66ed54.js +1 -0
- package/apps/pwa/out/_next/static/chunks/app/ops/seats/page-f401ee1dd5a26e25.js +1 -0
- package/apps/pwa/out/_next/static/chunks/app/outputs/page-5dfe7604fd842784.js +1 -0
- package/apps/pwa/out/_next/static/chunks/app/{page-21bec4afeb2f0917.js → page-76cf838050a8d681.js} +1 -1
- package/apps/pwa/out/_next/static/chunks/app/reflection/{page-dc39ee7a36d26385.js → page-7ec8e289b4c14df6.js} +1 -1
- package/apps/pwa/out/_next/static/chunks/app/scheduler/{page-84ae759f76053672.js → page-8c8ac17d52be2a79.js} +1 -1
- package/apps/pwa/out/_next/static/chunks/app/sessions/{page-3db58ddb61fe2be9.js → page-d359ab048d63a9d9.js} +1 -1
- package/apps/pwa/out/_next/static/chunks/app/settings/devices/{page-c5315e30f3528dc4.js → page-b8b345c880bf78d1.js} +1 -1
- package/apps/pwa/out/_next/static/chunks/app/settings/{page-2fe368efb6de3b6a.js → page-f151cc10ad026940.js} +1 -1
- package/apps/pwa/out/_next/static/chunks/app/setup/done/page-9875f103057da433.js +1 -0
- package/apps/pwa/out/_next/static/chunks/app/setup/page-9789328af9a9aa62.js +1 -0
- package/apps/pwa/out/_next/static/chunks/app/share/{page-c69762987b751174.js → page-bf495a1e284f4e00.js} +1 -1
- package/apps/pwa/out/_next/static/chunks/app/showroom/page-a263d94bca4be22a.js +1 -0
- package/apps/pwa/out/_next/static/chunks/app/tasks/page-3962eab8ec58d16e.js +1 -0
- package/apps/pwa/out/_next/static/chunks/app/term/page-9abcfac07e02a542.js +1 -0
- package/apps/pwa/out/_next/static/chunks/app/today/page-bee49f927c333abb.js +1 -0
- package/apps/pwa/out/_next/static/chunks/app/trace/page-bac2d56dcb02d955.js +1 -0
- package/apps/pwa/out/_next/static/chunks/app/vault/page-937cbcc13176295c.js +1 -0
- package/apps/pwa/out/_next/static/chunks/app/workflows/chat-ui/page-d09495594f7f480d.js +1 -0
- package/apps/pwa/out/_next/static/chunks/app/workflows/{page-7f8d7fcdf058a845.js → page-69cb93d1a2e57e0e.js} +1 -1
- package/apps/pwa/out/_next/static/chunks/app/workspace/{page-22163556cd38bc88.js → page-72ec543928d67c3e.js} +1 -1
- package/apps/pwa/out/_next/static/chunks/app/worktrees/page-82906eeda0186a7b.js +1 -0
- package/apps/pwa/out/_next/static/css/f2bd5257e6af88d8.css +1 -0
- package/apps/pwa/out/approvals/index.html +1 -1
- package/apps/pwa/out/approvals/index.txt +14 -14
- package/apps/pwa/out/autopilot/index.html +1 -1
- package/apps/pwa/out/autopilot/index.txt +14 -14
- package/apps/pwa/out/board/index.html +1 -1
- package/apps/pwa/out/board/index.txt +14 -14
- package/apps/pwa/out/botlab/index.html +1 -1
- package/apps/pwa/out/botlab/index.txt +14 -14
- package/apps/pwa/out/bots/index.html +1 -1
- package/apps/pwa/out/bots/index.txt +14 -14
- package/apps/pwa/out/ceo/index.html +1 -1
- package/apps/pwa/out/ceo/index.txt +14 -14
- package/apps/pwa/out/chat/index.html +1 -1
- package/apps/pwa/out/chat/index.txt +14 -14
- package/apps/pwa/out/consult/index.html +1 -1
- package/apps/pwa/out/consult/index.txt +14 -14
- package/apps/pwa/out/control/index.html +1 -1
- package/apps/pwa/out/control/index.txt +14 -14
- package/apps/pwa/out/decisions/index.html +1 -1
- package/apps/pwa/out/decisions/index.txt +14 -14
- package/apps/pwa/out/design-check/index.html +1 -1
- package/apps/pwa/out/design-check/index.txt +14 -14
- package/apps/pwa/out/editor/index.html +1 -1
- package/apps/pwa/out/editor/index.txt +14 -14
- package/apps/pwa/out/exec/index.html +1 -1
- package/apps/pwa/out/exec/index.txt +14 -14
- package/apps/pwa/out/field/index.html +1 -1
- package/apps/pwa/out/field/index.txt +14 -14
- package/apps/pwa/out/healthz +1 -1
- package/apps/pwa/out/index.html +1 -1
- package/apps/pwa/out/index.txt +14 -14
- package/apps/pwa/out/inside/index.html +1 -1
- package/apps/pwa/out/inside/index.txt +14 -14
- package/apps/pwa/out/intake/index.html +1 -1
- package/apps/pwa/out/intake/index.txt +14 -14
- package/apps/pwa/out/live/index.html +1 -1
- package/apps/pwa/out/live/index.txt +14 -14
- package/apps/pwa/out/live-run/index.html +1 -1
- package/apps/pwa/out/live-run/index.txt +14 -14
- package/apps/pwa/out/loops/index.html +1 -1
- package/apps/pwa/out/loops/index.txt +14 -14
- package/apps/pwa/out/market/index.html +1 -1
- package/apps/pwa/out/market/index.txt +14 -14
- package/apps/pwa/out/missions/placeholder/index.html +1 -1
- package/apps/pwa/out/missions/placeholder/index.txt +14 -14
- package/apps/pwa/out/morning/index.html +1 -1
- package/apps/pwa/out/morning/index.txt +14 -14
- package/apps/pwa/out/observatory/index.html +1 -1
- package/apps/pwa/out/observatory/index.txt +14 -14
- package/apps/pwa/out/ops/checklist/index.html +1 -1
- package/apps/pwa/out/ops/checklist/index.txt +14 -14
- package/apps/pwa/out/ops/release/index.html +1 -1
- package/apps/pwa/out/ops/release/index.txt +14 -14
- package/apps/pwa/out/ops/seats/index.html +1 -1
- package/apps/pwa/out/ops/seats/index.txt +14 -14
- package/apps/pwa/out/outputs/index.html +1 -1
- package/apps/pwa/out/outputs/index.txt +14 -14
- package/apps/pwa/out/reflection/index.html +1 -1
- package/apps/pwa/out/reflection/index.txt +15 -15
- package/apps/pwa/out/scheduler/index.html +1 -1
- package/apps/pwa/out/scheduler/index.txt +14 -14
- package/apps/pwa/out/sessions/index.html +1 -1
- package/apps/pwa/out/sessions/index.txt +14 -14
- package/apps/pwa/out/settings/devices/index.html +1 -1
- package/apps/pwa/out/settings/devices/index.txt +14 -14
- package/apps/pwa/out/settings/index.html +1 -1
- package/apps/pwa/out/settings/index.txt +14 -14
- package/apps/pwa/out/setup/done/index.html +1 -1
- package/apps/pwa/out/setup/done/index.txt +14 -14
- package/apps/pwa/out/setup/index.html +1 -1
- package/apps/pwa/out/setup/index.txt +14 -14
- package/apps/pwa/out/share/index.html +1 -1
- package/apps/pwa/out/share/index.txt +14 -14
- package/apps/pwa/out/showroom/index.html +1 -1
- package/apps/pwa/out/showroom/index.txt +14 -14
- package/apps/pwa/out/tasks/index.html +1 -1
- package/apps/pwa/out/tasks/index.txt +14 -14
- package/apps/pwa/out/term/index.html +1 -1
- package/apps/pwa/out/term/index.txt +14 -14
- package/apps/pwa/out/today/index.html +1 -1
- package/apps/pwa/out/today/index.txt +14 -14
- package/apps/pwa/out/trace/index.html +1 -1
- package/apps/pwa/out/trace/index.txt +14 -14
- package/apps/pwa/out/vault/index.html +1 -1
- package/apps/pwa/out/vault/index.txt +14 -14
- package/apps/pwa/out/workflows/chat-ui/index.html +1 -1
- package/apps/pwa/out/workflows/chat-ui/index.txt +14 -14
- package/apps/pwa/out/workflows/index.html +1 -1
- package/apps/pwa/out/workflows/index.txt +14 -14
- package/apps/pwa/out/workspace/index.html +1 -1
- package/apps/pwa/out/workspace/index.txt +14 -14
- package/apps/pwa/out/worktrees/index.html +1 -1
- package/apps/pwa/out/worktrees/index.txt +14 -14
- package/graphs/catalog/catalog.yaml +1 -1
- package/graphs/drafts/recipes.yaml +2 -2
- package/graphs/test-diet/recipes.yaml +3 -0
- package/graphs/test-diet/test-diet.yaml +3 -1
- package/package.json +1 -1
- package/scripts/f-session-monitor.ts +69 -0
- package/scripts/hq/standby-snapshot.ts +7 -3
- package/scripts/knowledge-compress.ts +11 -0
- package/scripts/nightly-docops.ts +90 -3
- package/scripts/research-coo-admin-dates.ts +39 -0
- package/scripts/skills/public-check.ts +7 -2
- package/scripts/smart-merge/resync-shadow.ts +5 -3
- package/scripts/test-diet/lib.ts +81 -1
- package/scripts/test-diet/node.ts +48 -2
- package/scripts/verify-coo-admin-evidence.ts +83 -0
- package/src/acp/client.ts +4 -2
- package/src/agent/run-context.ts +4 -0
- package/src/agent-env/profile.ts +25 -5
- package/src/agent-mission/driver.ts +26 -12
- package/src/agent-mission/supervisor-metrics.ts +17 -2
- package/src/agent-substrate/acp-reviewer.ts +12 -5
- package/src/agent-substrate/pr-reviewer.ts +11 -4
- package/src/agent-substrate/review-intent.ts +102 -3
- package/src/autopilot/build/llm-conflict-merge.ts +22 -8
- package/src/autopilot/pr-manager.ts +18 -8
- package/src/away/release-watch.ts +47 -8
- package/src/briefing/brief-items.ts +104 -11
- package/src/chat/index.ts +1 -0
- package/src/cli/brief-cli.ts +20 -2
- package/src/cli/config-classify.ts +83 -0
- package/src/cli/control-cli.ts +14 -0
- package/src/cli/decisions-cli.ts +26 -16
- package/src/cli/logs-model-input.ts +1 -1
- package/src/cli/pr-cli.ts +65 -8
- package/src/cli/release-cli.ts +21 -4
- package/src/cli/tasks-cli.ts +108 -1
- package/src/connectors/types.ts +6 -6
- package/src/context-bus/context-now-surfaces.ts +18 -0
- package/src/context-bus/coord-events.ts +32 -4
- package/src/context-bus/long-term-memory.ts +23 -6
- package/src/context-bus/nightly-condense.ts +169 -21
- package/src/control/control-status.ts +87 -0
- package/src/control-plane/light-member.ts +5 -3
- package/src/control-plane/member.ts +172 -7
- package/src/core-turn/run-goal-loop.ts +22 -1
- package/src/dashboard/slash-runtime/dashboard-handlers.ts +9 -0
- package/src/decisions/decision-cards.ts +20 -1
- package/src/decisions/decision-ledger.ts +11 -8
- package/src/decisions/web-push-decision-cards.ts +19 -3
- package/src/discord-self-message.ts +2 -2
- package/src/domains/coo-admin-dates.evidence.json +876 -0
- package/src/domains/coo-admin-tool.ts +48 -14
- package/src/domains/ops-status.ts +53 -0
- package/src/domains/outbound-alert.ts +24 -1
- package/src/exec-requests/planner.ts +6 -0
- package/src/graph-kinds/registry.ts +6 -6
- package/src/graph-runner/graph-cli.ts +34 -2
- package/src/graph-runner/graph-run-api.ts +7 -0
- package/src/graph-runner/graph-tick.ts +73 -12
- package/src/graph-runner/runner.ts +43 -11
- package/src/graph-unify/wf-to-graph.ts +79 -0
- package/src/graph-wizard/generate.ts +34 -2
- package/src/graph-wizard/steps.ts +13 -0
- package/src/harness/harness-cli-command.ts +121 -23
- package/src/harness/harness-queue.ts +58 -14
- package/src/harness/launch-advice.ts +23 -0
- package/src/index.ts +36 -7
- package/src/knowledge/conclusion-compress.ts +93 -0
- package/src/knowledge/know-find.ts +9 -2
- package/src/knowledge/query.ts +20 -0
- package/src/llm.ts +26 -16
- package/src/loops/neighbor-runtime.ts +88 -0
- package/src/loops/orchestrator/author-depth.ts +29 -11
- package/src/loops/orchestrator/overlap.ts +7 -1
- package/src/loops/orchestrator/resync-shadow.ts +1 -1
- package/src/loops/orchestrator/tick.ts +35 -2
- package/src/loops/orchestrator/traffic.ts +2 -0
- package/src/loops/registry.ts +5 -0
- package/src/market/publish.ts +17 -1
- package/src/maturity/feature-maturity.ts +2 -1
- package/src/mss/logging/redaction.ts +17 -0
- package/src/nexus/api/draft-metrics.ts +22 -8
- package/src/nexus/api/graph-peer-edit.ts +77 -0
- package/src/nexus/api/graph-wizard-api.ts +9 -1
- package/src/nexus/api/graphs-api.ts +145 -7
- package/src/nexus/api/http-server.ts +93 -8
- package/src/nexus/api/plugins-market.ts +24 -0
- package/src/nexus/index.ts +3 -2
- package/src/oauth/claude-pty-maturity.ts +24 -0
- package/src/oauth/fallback-chain.ts +27 -14
- package/src/plugins/maker/market-bundle.ts +9 -0
- package/src/plugins/maker/plugin-maker.ts +9 -3
- package/src/plugins/maker/wizard-generate.ts +73 -9
- package/src/policy/llm-policy.ts +11 -6
- package/src/release-loop/checklist-board.ts +27 -0
- package/src/release-loop/checklist.ts +38 -0
- package/src/seat-address/seat-address.ts +1 -0
- package/src/seat-loop/seat-loop.ts +174 -20
- package/src/seat-loop/seat-report.ts +29 -1
- package/src/self-dev/dev-cli.ts +90 -8
- package/src/self-dev/dev-pipeline.ts +20 -1
- package/src/self-dev/draft-sweep.ts +45 -8
- package/src/self-dev/orchestrate.ts +3 -0
- package/src/self-dev/review-provider-fallback.ts +83 -0
- package/src/self-dev/run-supervisor.ts +5 -1
- package/src/self-implement/harness-policy-gates.ts +7 -5
- package/src/self-implement/host-regate.ts +28 -14
- package/src/self-implement/l8-merge-queue.ts +80 -8
- package/src/self-implement/orchestrator.ts +68 -27
- package/src/self-implement/seams.ts +5 -3
- package/src/self-implement/self-implement-cli.ts +4 -0
- package/src/shell-env-bootstrap.ts +19 -4
- package/src/steward/triage.ts +25 -8
- package/src/storage/file-lock.ts +33 -4
- package/src/task-agent/completion-evidence.ts +31 -3
- package/src/task-agent/cover.ts +61 -0
- package/src/task-agent/live-moves.ts +126 -3
- package/src/task-agent/shadow.ts +19 -4
- package/src/task-agent/task-hand.ts +2 -0
- package/src/task-orchestrator/surfaces/pod-pool.ts +138 -15
- package/src/task-orchestrator/surfaces/self-implement.ts +7 -0
- package/src/telegram-commands.ts +8 -0
- package/src/telegram-project-command.ts +6 -0
- package/src/telegram.ts +2 -2
- package/src/typecheck-ratchet.ts +2 -1
- package/src/user-config.ts +35 -4
- package/src/version/packed-revision.json +1 -1
- package/src/workflow-runtime/executor.ts +24 -2
- package/src/workflow-runtime/triggers/registry.ts +27 -0
- package/src/workflow-runtime/triggers/schedule-source.ts +30 -3
- package/apps/pwa/out/_next/static/chunks/1307-c97eba9fb72a9a5e.js +0 -1
- package/apps/pwa/out/_next/static/chunks/3857-995ea9f5ef33d71e.js +0 -1
- package/apps/pwa/out/_next/static/chunks/5977-59fedea50d13c762.js +0 -1
- package/apps/pwa/out/_next/static/chunks/8857-f1762140ef9a96b1.js +0 -1
- package/apps/pwa/out/_next/static/chunks/9247-7581825d511a64c0.js +0 -1
- package/apps/pwa/out/_next/static/chunks/9497-b0e758d1cd4ae8d7.js +0 -1
- package/apps/pwa/out/_next/static/chunks/app/approvals/page-937c92292a4ab4f0.js +0 -1
- package/apps/pwa/out/_next/static/chunks/app/bots/page-ea998f7f5d156bea.js +0 -1
- package/apps/pwa/out/_next/static/chunks/app/ceo/page-33c328b64ca508f8.js +0 -1
- package/apps/pwa/out/_next/static/chunks/app/design-check/page-e4bcb2c503dab04b.js +0 -1
- package/apps/pwa/out/_next/static/chunks/app/editor/page-300897dcbbd5ed76.js +0 -1
- package/apps/pwa/out/_next/static/chunks/app/intake/page-103fa3191c1b2e08.js +0 -1
- package/apps/pwa/out/_next/static/chunks/app/live/page-be8b4615c919e36c.js +0 -1
- package/apps/pwa/out/_next/static/chunks/app/live-run/page-c685b104e0dfa609.js +0 -1
- package/apps/pwa/out/_next/static/chunks/app/loops/page-8d693331cfbb6add.js +0 -1
- package/apps/pwa/out/_next/static/chunks/app/market/page-cef4f30a4c22fae1.js +0 -1
- package/apps/pwa/out/_next/static/chunks/app/missions/[id]/page-66ea1b54fc83fa15.js +0 -1
- package/apps/pwa/out/_next/static/chunks/app/ops/checklist/page-8cc707bd09a497d9.js +0 -1
- package/apps/pwa/out/_next/static/chunks/app/ops/release/page-90b55be1f15ce0e8.js +0 -1
- package/apps/pwa/out/_next/static/chunks/app/ops/seats/page-cb16606e08be65db.js +0 -1
- package/apps/pwa/out/_next/static/chunks/app/outputs/page-16dc64b74ed6a40a.js +0 -1
- package/apps/pwa/out/_next/static/chunks/app/setup/done/page-8d04132dd0ac5e10.js +0 -1
- package/apps/pwa/out/_next/static/chunks/app/setup/page-f23484de467c9ea3.js +0 -1
- package/apps/pwa/out/_next/static/chunks/app/showroom/page-5456692bda39aafb.js +0 -1
- package/apps/pwa/out/_next/static/chunks/app/tasks/page-d75f7cd65845ce98.js +0 -1
- package/apps/pwa/out/_next/static/chunks/app/term/page-5603f5e568b25ce3.js +0 -1
- package/apps/pwa/out/_next/static/chunks/app/today/page-d2cc0f4e89f42028.js +0 -1
- package/apps/pwa/out/_next/static/chunks/app/trace/page-a6eb73b8a4840f1c.js +0 -1
- package/apps/pwa/out/_next/static/chunks/app/vault/page-c0a3f4d10edb83fa.js +0 -1
- package/apps/pwa/out/_next/static/chunks/app/workflows/chat-ui/page-6c0b88068c143f15.js +0 -1
- package/apps/pwa/out/_next/static/chunks/app/worktrees/page-141436fd26dd56fc.js +0 -1
- package/apps/pwa/out/_next/static/css/e81df11490b4413b.css +0 -1
- /package/apps/pwa/out/_next/static/{MObU8ujkMciAKr6ljAV05 → aBi6EyMtLsrl7J8eH4kF7}/_buildManifest.js +0 -0
- /package/apps/pwa/out/_next/static/{MObU8ujkMciAKr6ljAV05 → aBi6EyMtLsrl7J8eH4kF7}/_ssgManifest.js +0 -0
package/src/storage/file-lock.ts
CHANGED
|
@@ -27,9 +27,13 @@ const STALE_MS = 30_000;
|
|
|
27
27
|
const RETRY_BUSY_MS = 25;
|
|
28
28
|
const MAX_TRIES = 240; // ~6s upper bound
|
|
29
29
|
|
|
30
|
+
const SLEEP_CELL = new Int32Array(new SharedArrayBuffer(4));
|
|
31
|
+
|
|
30
32
|
function busyWaitMs(ms: number): void {
|
|
33
|
+
// ⭐ A real sleep, not a CPU spin — a waiter may wait minutes (pod image ship lock, 10-08 flood).
|
|
34
|
+
try { Atomics.wait(SLEEP_CELL, 0, 0, ms); return; }
|
|
35
|
+
catch { /* Atomics.wait unavailable on this thread · fall back to the spin */ }
|
|
31
36
|
const target = Date.now() + ms;
|
|
32
|
-
// Trivial busy-loop. Nothing else to do synchronously.
|
|
33
37
|
while (Date.now() < target) { /* spin */ }
|
|
34
38
|
}
|
|
35
39
|
|
|
@@ -37,6 +41,22 @@ export interface LockOpts {
|
|
|
37
41
|
staleMs?: number;
|
|
38
42
|
retryBusyMs?: number;
|
|
39
43
|
maxTries?: number;
|
|
44
|
+
/**
|
|
45
|
+
* Sync lock only: also break the lock when the pid on its first line is a dead process
|
|
46
|
+
* (a holder killed with -9 must not wedge waiters for `staleMs`).
|
|
47
|
+
*/
|
|
48
|
+
breakDeadHolder?: boolean;
|
|
49
|
+
/** Sync lock only: called once, the first time the lock is found held by someone else. */
|
|
50
|
+
onWait?: (holder: string) => void;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
function holderIsDead(lockPath: string): boolean {
|
|
54
|
+
let pid: number;
|
|
55
|
+
try { pid = Number.parseInt(readFileSync(lockPath, 'utf8').split(/[\n:]/)[0] ?? '', 10); }
|
|
56
|
+
catch { return false; }
|
|
57
|
+
if (!Number.isInteger(pid) || pid <= 0 || pid === process.pid) return false;
|
|
58
|
+
try { process.kill(pid, 0); return false; }
|
|
59
|
+
catch (err) { return (err as NodeJS.ErrnoException).code === 'ESRCH'; }
|
|
40
60
|
}
|
|
41
61
|
|
|
42
62
|
export type ReleaseLock = () => void;
|
|
@@ -45,6 +65,7 @@ export function acquireLockSync(lockPath: string, opts: LockOpts = {}): ReleaseL
|
|
|
45
65
|
const staleMs = opts.staleMs ?? STALE_MS;
|
|
46
66
|
const retryBusyMs = opts.retryBusyMs ?? RETRY_BUSY_MS;
|
|
47
67
|
const maxTries = opts.maxTries ?? MAX_TRIES;
|
|
68
|
+
let waited = false;
|
|
48
69
|
|
|
49
70
|
for (let attempt = 0; attempt < maxTries; attempt += 1) {
|
|
50
71
|
try {
|
|
@@ -63,15 +84,23 @@ export function acquireLockSync(lockPath: string, opts: LockOpts = {}): ReleaseL
|
|
|
63
84
|
if (code !== 'EEXIST') throw err;
|
|
64
85
|
// Lock exists · check for staleness.
|
|
65
86
|
try {
|
|
66
|
-
const
|
|
67
|
-
|
|
68
|
-
|
|
87
|
+
const seen = statSync(lockPath);
|
|
88
|
+
const age = Date.now() - seen.mtimeMs;
|
|
89
|
+
if (age > staleMs || (opts.breakDeadHolder && holderIsDead(lockPath))) {
|
|
90
|
+
// Narrow the race: only unlink the file we judged (a fresh holder may have replaced it meanwhile).
|
|
91
|
+
try { if (statSync(lockPath).ino === seen.ino) unlinkSync(lockPath); } catch { /* race · retry */ }
|
|
69
92
|
continue;
|
|
70
93
|
}
|
|
71
94
|
} catch {
|
|
72
95
|
// Race: lock disappeared between EEXIST and stat → retry.
|
|
73
96
|
continue;
|
|
74
97
|
}
|
|
98
|
+
if (!waited && opts.onWait) {
|
|
99
|
+
waited = true;
|
|
100
|
+
let holder = '';
|
|
101
|
+
try { holder = readFileSync(lockPath, 'utf8').trim(); } catch { /* gone */ }
|
|
102
|
+
try { opts.onWait(holder); } catch { /* observer only */ }
|
|
103
|
+
}
|
|
75
104
|
busyWaitMs(retryBusyMs);
|
|
76
105
|
}
|
|
77
106
|
}
|
|
@@ -1,10 +1,10 @@
|
|
|
1
|
-
import { readFileSync, statSync } from 'node:fs';
|
|
1
|
+
import { readFileSync, realpathSync, statSync } from 'node:fs';
|
|
2
2
|
import { extname, isAbsolute, relative, resolve, sep } from 'node:path';
|
|
3
3
|
import type { Database } from 'bun:sqlite';
|
|
4
4
|
import type { DecisionLedger } from '../decisions/decision-ledger.js';
|
|
5
5
|
import { getLastStatus } from '../domains/schedule-registry.js';
|
|
6
6
|
|
|
7
|
-
export type CompletionEvidenceKind = 'research-report' | 'artifact' | 'content' | 'ops-action' | 'watch-brief' | 'decision-support';
|
|
7
|
+
export type CompletionEvidenceKind = 'research-report' | 'artifact' | 'content' | 'ops-action' | 'watch-brief' | 'decision-support' | 'deliverable';
|
|
8
8
|
export interface CompletionEvidenceDeps {
|
|
9
9
|
ledger?: Pick<DecisionLedger, 'list'>;
|
|
10
10
|
schedulesDb?: Database;
|
|
@@ -13,7 +13,7 @@ export interface CompletionEvidenceDeps {
|
|
|
13
13
|
/** A missing record is negative evidence; a failed read is explicitly unmeasured. No store is opened or mutated here. */
|
|
14
14
|
export function checkCompletionEvidence(
|
|
15
15
|
kind: CompletionEvidenceKind,
|
|
16
|
-
input: { dir: string; ref: string },
|
|
16
|
+
input: { dir: string; ref: string; receipt?: boolean },
|
|
17
17
|
deps: CompletionEvidenceDeps = {},
|
|
18
18
|
): { ok: boolean; ref: string; missing: string[] } {
|
|
19
19
|
const missing: string[] = [];
|
|
@@ -38,6 +38,34 @@ export function checkCompletionEvidence(
|
|
|
38
38
|
};
|
|
39
39
|
|
|
40
40
|
switch (kind) {
|
|
41
|
+
case 'deliverable': {
|
|
42
|
+
const ref = input.ref.trim();
|
|
43
|
+
if (/^https?:\/\//i.test(ref)) {
|
|
44
|
+
try {
|
|
45
|
+
const url = new URL(ref);
|
|
46
|
+
if (!url.hostname || !['http:', 'https:'].includes(url.protocol)) add('산출물 링크');
|
|
47
|
+
} catch { add('산출물 링크'); }
|
|
48
|
+
} else if (!ref || /^[a-z][a-z\d+.-]*:/i.test(ref)) {
|
|
49
|
+
add('산출물 경로 또는 링크');
|
|
50
|
+
} else {
|
|
51
|
+
const path = pathFor(ref);
|
|
52
|
+
if (!path) add('못 쟀다: 경로');
|
|
53
|
+
else {
|
|
54
|
+
try {
|
|
55
|
+
const root = realpathSync(input.dir);
|
|
56
|
+
const actual = realpathSync(path);
|
|
57
|
+
const rel = relative(root, actual);
|
|
58
|
+
if (rel === '..' || rel.startsWith(`..${sep}`)) add('못 쟀다: 경로');
|
|
59
|
+
else {
|
|
60
|
+
const stat = statSync(actual);
|
|
61
|
+
if (!stat.isFile() || stat.size === 0) add('산출물 파일');
|
|
62
|
+
}
|
|
63
|
+
} catch (error) { add((error as NodeJS.ErrnoException).code === 'ENOENT' ? '산출물 파일' : '못 쟀다: 산출물 파일 읽기'); }
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
if (input.receipt !== true) add('수신 확인');
|
|
67
|
+
break;
|
|
68
|
+
}
|
|
41
69
|
case 'research-report': {
|
|
42
70
|
const path = file();
|
|
43
71
|
if (!path) break;
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
import type { NextActionKind } from './actions.js';
|
|
2
|
+
|
|
3
|
+
type CoverVerb = 'land' | 'review' | 'retry' | 'green';
|
|
4
|
+
export interface CoverRow {
|
|
5
|
+
verb: CoverVerb;
|
|
6
|
+
/** null means the TASK-AGENT action source could not be fully read. */
|
|
7
|
+
byTaskAgent: number | null;
|
|
8
|
+
/** Total work by all seats; null when no independent denominator is available. */
|
|
9
|
+
total: number | null;
|
|
10
|
+
/** TASK-AGENT events observed for this verb, including non-done outcomes; null if unreadable. */
|
|
11
|
+
observedActions: number | null;
|
|
12
|
+
ratio: number | null;
|
|
13
|
+
state: 'measured' | 'no-denominator' | 'unreadable';
|
|
14
|
+
/** Non-shadow action events, including failures; null if the action source is incomplete. */
|
|
15
|
+
liveActions: number | null;
|
|
16
|
+
/** Shadow action events; null if the action source is incomplete. */
|
|
17
|
+
shadowActions: number | null;
|
|
18
|
+
/** A transition is observable only with an independent denominator and a completed TA action. */
|
|
19
|
+
stewardTransition: 'observed' | 'not-observed' | 'unmeasured';
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
/** null means the source could not be read; [] means it was read and had no matching events. */
|
|
23
|
+
export interface TaskAgentCoverInput {
|
|
24
|
+
taskAgentActions: readonly { kind: NextActionKind; result: string }[] | null;
|
|
25
|
+
allLands: readonly { step: string; ok: boolean }[] | null;
|
|
26
|
+
/** A source reached the query safety limit; its aggregate is not known to be complete. */
|
|
27
|
+
truncated?: { taskAgentActions: boolean; allLands: boolean };
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/** Only land has an independent denominator. The other three verbs have no hand-work source yet. */
|
|
31
|
+
export function measureTaskAgentCover(input: TaskAgentCoverInput): CoverRow[] {
|
|
32
|
+
const verbs: CoverVerb[] = ['land', 'review', 'retry', 'green'];
|
|
33
|
+
return verbs.map((verb) => {
|
|
34
|
+
const actions = input.taskAgentActions?.filter((action) => action.kind === verb);
|
|
35
|
+
const actionsUnreadable = actions === undefined || input.truncated?.taskAgentActions === true;
|
|
36
|
+
const byTaskAgent = actionsUnreadable ? null : actions.filter((action) => action.result === 'done').length;
|
|
37
|
+
const observedActions = actionsUnreadable ? null : actions.length;
|
|
38
|
+
const liveActions = actionsUnreadable ? null : actions.filter((action) => action.result !== 'shadow').length;
|
|
39
|
+
const shadowActions = actionsUnreadable ? null : actions.filter((action) => action.result === 'shadow').length;
|
|
40
|
+
const counts = { liveActions, shadowActions };
|
|
41
|
+
if (verb !== 'land') return {
|
|
42
|
+
verb, byTaskAgent, total: null, observedActions, ratio: null,
|
|
43
|
+
state: actionsUnreadable ? 'unreadable' : 'no-denominator',
|
|
44
|
+
...counts, stewardTransition: 'unmeasured' as const,
|
|
45
|
+
};
|
|
46
|
+
const total = input.allLands === null || input.truncated?.allLands
|
|
47
|
+
? null : input.allLands.filter((land) => land.step === 'merge' && land.ok === true).length;
|
|
48
|
+
const state = actionsUnreadable || total === null ? 'unreadable'
|
|
49
|
+
: total === 0 ? 'no-denominator' : 'measured';
|
|
50
|
+
const ratio = state === 'measured' && byTaskAgent !== null && total !== null ? byTaskAgent / total : null;
|
|
51
|
+
return { verb, byTaskAgent, total, observedActions, ratio, state, ...counts,
|
|
52
|
+
stewardTransition: ratio === null ? 'unmeasured' : byTaskAgent !== null && byTaskAgent > 0 ? 'observed' : 'not-observed',
|
|
53
|
+
};
|
|
54
|
+
});
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/** A single human-readable line per verb; unknown numbers are never rendered as zero. */
|
|
58
|
+
export function formatTaskAgentCover(row: CoverRow): string {
|
|
59
|
+
const value = (count: number | null): string => count === null ? '-' : String(count);
|
|
60
|
+
return `${row.verb}: TA ${value(row.byTaskAgent)}/${value(row.total)} (${row.ratio === null ? '-' : `${(row.ratio * 100).toFixed(1)}%`}) · live ${value(row.liveActions)} · shadow ${value(row.shadowActions)} · steward-transition ${row.stewardTransition}`;
|
|
61
|
+
}
|
|
@@ -1,9 +1,10 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* TA-JUDGE-LIVE-SAFE —
|
|
2
|
+
* TA-JUDGE-LIVE-SAFE — 설정에서 명시한 review · propose-green · propose-land 만 실제로 한다.
|
|
3
3
|
*
|
|
4
4
|
* - `review`: 수확 가능 런의 PR 에 elanous 리뷰를 «한 번» 요청한다 — PR 머리(sha)마다 한 번(카드 `reviewRequests` 이력).
|
|
5
5
|
* 요청만 한다: 결과로 착지·재발사로 잇지 않는다(실행부 `executeNextAction` 의 review→land 사슬을 타지 않는다).
|
|
6
6
|
* - `propose-green`: 카드에 green «제안»(`greenProposal`)만 적는다 — 체크리스트는 손대지 않는다.
|
|
7
|
+
* - `propose-land`: 카드·현재 머리의 self review pass·non-draft 를 확인하고 기존 `pr land` 를 머리 고정으로 한 번 부른다.
|
|
7
8
|
* - 설정 `taskAgent.liveMoves`(config > env `ELANOUS_TASK_AGENT_LIVE_MOVES` > 없음). 모르는 항목은 경고하고 버린다.
|
|
8
9
|
* - 관측: 실행한 수마다 `debug.log('task-agent', 'live-move', {kind, card, ok, detail, live:true})`.
|
|
9
10
|
*/
|
|
@@ -16,7 +17,7 @@ import { getUserConfig } from '../user-config.js';
|
|
|
16
17
|
import type { DetachedSpawn } from '../cli/tasks-cli.js';
|
|
17
18
|
import { taskAgentStatePath, updateTaskCard, type TaskCard } from './task-hand.js';
|
|
18
19
|
|
|
19
|
-
export const TASK_AGENT_LIVE_MOVES = ['review', 'propose-green'] as const;
|
|
20
|
+
export const TASK_AGENT_LIVE_MOVES = ['review', 'propose-green', 'propose-land'] as const;
|
|
20
21
|
export type TaskAgentLiveMove = typeof TASK_AGENT_LIVE_MOVES[number];
|
|
21
22
|
export const TASK_AGENT_LIVE_MOVES_ENV = 'ELANOUS_TASK_AGENT_LIVE_MOVES';
|
|
22
23
|
|
|
@@ -62,12 +63,16 @@ export interface LiveMoveDeps {
|
|
|
62
63
|
repoCandidates?: (card: TaskCard) => readonly ReviewRepoCandidate[];
|
|
63
64
|
/** 리뷰 요청(떼어 띄운다) — 시험은 반드시 주입한다. */
|
|
64
65
|
requestReview?: (pr: number, intent: string, cwd?: string) => Promise<void>;
|
|
66
|
+
/** Fresh PR head and draft flag for landing; separate from the unchanged review lookup. */
|
|
67
|
+
landPrHead?: (pr: number, cwd: string) => Promise<(PrHeadView & { isDraft: boolean }) | null>;
|
|
68
|
+
/** One existing pr land operation; inject in tests instead of invoking the CLI. */
|
|
69
|
+
land?: (pr: number, head: string, cwd: string) => Promise<{ status: number; stdout: string; stderr?: string }>;
|
|
65
70
|
log?: (category: string, event: string, data: Record<string, unknown>) => void;
|
|
66
71
|
}
|
|
67
72
|
|
|
68
73
|
/** `executed` = 부작용을 실제로 냈다(리뷰를 띄웠다 · 제안을 적었다). ok 이지만 이미 한 수(같은 머리·이미 제안)면 false. */
|
|
69
74
|
export interface LiveMoveResult {
|
|
70
|
-
kind: TaskAgentLiveMove; card: string | null; ok: boolean; executed: boolean; detail: string;
|
|
75
|
+
kind: TaskAgentLiveMove | 'land'; card: string | null; ok: boolean; executed: boolean; detail: string;
|
|
71
76
|
/** TA-LIVE-REVIEW-POD — 작업 트리 없이 리뷰 저장소를 찾았을 때(또는 못 찾았을 때)의 근거 한 줄. */
|
|
72
77
|
reviewRepo?: { resolved: boolean; source?: ReviewRepoCandidate['source']; cwd?: string; proof?: 'head-sha' | 'branch'; reason: string };
|
|
73
78
|
}
|
|
@@ -183,6 +188,52 @@ export async function defaultRequestReview(pr: number, intent: string, cwd?: str
|
|
|
183
188
|
await spawnDetachedConfirmed(opts.spawn ?? (spawn as unknown as DetachedSpawn), process.execPath, liveReviewArgs(opts.entry ?? ELANOUS_CLI_ENTRY, configDir, pr, intent), undefined, cwd);
|
|
184
189
|
}
|
|
185
190
|
|
|
191
|
+
export function liveLandArgs(entry: string, configDir: string, pr: number, head: string, cwd: string): string[] {
|
|
192
|
+
return [resolve(entry), '--config-dir', configDir, 'pr', 'land', '--cwd', cwd, '--pr', String(pr), '--expected-head', head];
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
/** The CLI owns the freeze, gate and merge checks; no direct gh merge or bypass flags. */
|
|
196
|
+
export async function defaultLand(pr: number, head: string, cwd: string): Promise<{ status: number; stdout: string; stderr?: string }> {
|
|
197
|
+
const { ELANOUS_CLI_ENTRY } = await import('../cli/tasks-cli.js');
|
|
198
|
+
const { getElanousConfigDirOverride } = await import('../elanous-config-dir.js');
|
|
199
|
+
const { effectiveInstanceRoot } = await import('../instance/resolve.js');
|
|
200
|
+
const args = liveLandArgs(ELANOUS_CLI_ENTRY, getElanousConfigDirOverride() ?? effectiveInstanceRoot(), pr, head, cwd);
|
|
201
|
+
return new Promise((resolveResult, reject) => {
|
|
202
|
+
const child = spawn(process.execPath, args, { cwd, stdio: ['ignore', 'pipe', 'pipe'] });
|
|
203
|
+
let stdout = '';
|
|
204
|
+
let stderr = '';
|
|
205
|
+
child.stdout.on('data', (chunk: Buffer) => { stdout = (stdout + chunk.toString()).slice(-300); });
|
|
206
|
+
child.stderr.on('data', (chunk: Buffer) => { stderr = (stderr + chunk.toString()).slice(-300); });
|
|
207
|
+
child.once('error', reject);
|
|
208
|
+
child.once('close', (code) => {
|
|
209
|
+
if (code !== 0) { resolveResult({ status: code ?? 1, stdout, stderr }); return; }
|
|
210
|
+
void defaultLandPrHead(pr, cwd).then((view) => {
|
|
211
|
+
resolveResult(view?.state === 'MERGED'
|
|
212
|
+
? { status: 0, stdout, stderr }
|
|
213
|
+
: { status: 1, stdout, stderr: (stderr || stdout || 'landing completed without confirmed merge').slice(-300) });
|
|
214
|
+
}).catch((error) => resolveResult({ status: 1, stdout, stderr: String(error).slice(-300) }));
|
|
215
|
+
});
|
|
216
|
+
});
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
export async function defaultLandPrHead(pr: number, cwd: string): Promise<(PrHeadView & { isDraft: boolean }) | null> {
|
|
220
|
+
const view = (env: NodeJS.ProcessEnv) => runGh(['pr', 'view', String(pr), '--json', 'headRefOid,headRefName,state,url,isDraft'], { env, cwd });
|
|
221
|
+
let out = view(process.env);
|
|
222
|
+
if (out.status !== 0) {
|
|
223
|
+
const noProxy = { ...process.env };
|
|
224
|
+
for (const key of ['HTTPS_PROXY', 'https_proxy', 'HTTP_PROXY', 'http_proxy']) delete noProxy[key];
|
|
225
|
+
out = view(noProxy);
|
|
226
|
+
}
|
|
227
|
+
if (out.status !== 0) return null;
|
|
228
|
+
try {
|
|
229
|
+
const value = JSON.parse(out.stdout) as { headRefOid?: unknown; headRefName?: unknown; state?: unknown; url?: unknown; isDraft?: unknown };
|
|
230
|
+
if (typeof value.headRefOid !== 'string' || !/^[a-f0-9]{40}$/i.test(value.headRefOid) || typeof value.state !== 'string' || typeof value.isDraft !== 'boolean') return null;
|
|
231
|
+
return { head: value.headRefOid, state: value.state, isDraft: value.isDraft,
|
|
232
|
+
...(typeof value.headRefName === 'string' ? { branch: value.headRefName } : {}),
|
|
233
|
+
...(typeof value.url === 'string' ? { url: value.url } : {}) };
|
|
234
|
+
} catch { return null; }
|
|
235
|
+
}
|
|
236
|
+
|
|
186
237
|
export function liveReviewIntent(card: TaskCard, runId: string | null, stopReason: string): string {
|
|
187
238
|
const goal = card.text.split('\n')[0]!.trim().slice(0, 300);
|
|
188
239
|
return [
|
|
@@ -255,6 +306,78 @@ export async function executeLiveReview(
|
|
|
255
306
|
return observe(deps, withRepo({ kind, executed: true, card: card.id, ok: true, detail: `review requested for #${pr} head ${head.head.slice(0, 12)}` }));
|
|
256
307
|
}
|
|
257
308
|
|
|
309
|
+
const landedCycles = new Set<string>();
|
|
310
|
+
|
|
311
|
+
/** `propose-land` — only a passing self review of this exact OPEN, non-draft head may invoke pr land. */
|
|
312
|
+
export async function executeLiveLand(
|
|
313
|
+
card: TaskCard | undefined, pr: number | undefined,
|
|
314
|
+
ctx: { runId?: string | null; cycleId?: string; cwd?: string; produced?: { headCommit?: string; branch?: string; prUrl?: string }; review?: { verdict: 'pass' | 'fail'; head: string } },
|
|
315
|
+
deps: LiveMoveDeps = {},
|
|
316
|
+
): Promise<LiveMoveResult> {
|
|
317
|
+
const kind = 'land' as const;
|
|
318
|
+
const reject = (cardId: string | null, detail: string, reviewRepo?: LiveMoveResult['reviewRepo']) =>
|
|
319
|
+
observe(deps, { kind, card: cardId, ok: false, executed: false, detail, ...(reviewRepo ? { reviewRepo } : {}) });
|
|
320
|
+
if (!card) return reject(null, 'no task card — stays shadow');
|
|
321
|
+
if (!pr || !Number.isSafeInteger(pr) || pr < 1) return reject(card.id, 'no PR — stays shadow');
|
|
322
|
+
let cwd = ctx.cwd;
|
|
323
|
+
let reviewRepo: LiveMoveResult['reviewRepo'];
|
|
324
|
+
if (!cwd) {
|
|
325
|
+
const found = await resolveReviewRepo(card, pr, ctx.produced, deps);
|
|
326
|
+
if (!found.resolved) return reject(card.id, `PR #${pr} worktree unknown — stays shadow`, { resolved: false, reason: found.reason });
|
|
327
|
+
cwd = found.cwd;
|
|
328
|
+
reviewRepo = { resolved: true, source: found.source, cwd, proof: found.proof, reason: found.reason };
|
|
329
|
+
}
|
|
330
|
+
const withRepo = (result: LiveMoveResult): LiveMoveResult => reviewRepo ? { ...result, reviewRepo } : result;
|
|
331
|
+
let view: (PrHeadView & { isDraft: boolean }) | null;
|
|
332
|
+
try { view = await (deps.landPrHead ?? defaultLandPrHead)(pr, cwd); } catch { view = null; }
|
|
333
|
+
if (!view || !/^[0-9a-f]{40}$/i.test(view.head)) return reject(card.id, `PR #${pr} head unknown`, reviewRepo);
|
|
334
|
+
if (view.state !== 'OPEN') return reject(card.id, `PR #${pr} is ${view.state}`, reviewRepo);
|
|
335
|
+
if (view.isDraft !== false) return reject(card.id, `PR #${pr} is draft or draft status unknown`, reviewRepo);
|
|
336
|
+
if (ctx.produced?.prUrl && (!view.url || view.url.trim().replace(/\/+$/, '').toLowerCase() !== ctx.produced.prUrl.trim().replace(/\/+$/, '').toLowerCase())) {
|
|
337
|
+
return reject(card.id, `PR #${pr} URL mismatches run evidence`, reviewRepo);
|
|
338
|
+
}
|
|
339
|
+
if (ctx.review?.verdict !== 'pass') return reject(card.id, `PR #${pr} self review pass missing`, reviewRepo);
|
|
340
|
+
if (!/^[0-9a-f]{40}$/i.test(ctx.review.head) || ctx.review.head.toLowerCase() !== view.head.toLowerCase()) {
|
|
341
|
+
return reject(card.id, `PR #${pr} review head mismatch: ${ctx.review.head.slice(0, 12)} ≠ ${view.head.slice(0, 12)}`, reviewRepo);
|
|
342
|
+
}
|
|
343
|
+
const cycle = ctx.cycleId ?? ctx.runId;
|
|
344
|
+
if (cycle && landedCycles.has(cycle)) return reject(card.id, `cycle ${cycle} already landed — stays shadow`, reviewRepo);
|
|
345
|
+
const path = deps.statePath ?? taskAgentStatePath();
|
|
346
|
+
const at = (deps.now ?? (() => new Date()))().toISOString();
|
|
347
|
+
let claimed = false;
|
|
348
|
+
let missing = false;
|
|
349
|
+
try {
|
|
350
|
+
updateTaskCard(path, card.id, (current) => {
|
|
351
|
+
if (!current) { missing = true; return undefined; }
|
|
352
|
+
if ((current.landAttempts ?? []).some(entry => entry.pr === pr && entry.head.toLowerCase() === view!.head.toLowerCase())) return undefined;
|
|
353
|
+
claimed = true;
|
|
354
|
+
return { ...current, landAttempts: [...(current.landAttempts ?? []), { pr, head: view!.head, at }] };
|
|
355
|
+
});
|
|
356
|
+
} catch (error) {
|
|
357
|
+
return reject(card.id, `land attempt could not be recorded: ${String(error).slice(-250)}`, reviewRepo);
|
|
358
|
+
}
|
|
359
|
+
if (missing) return reject(card.id, 'task card not in state file', reviewRepo);
|
|
360
|
+
if (!claimed) return observe(deps, withRepo({ kind, card: card.id, ok: true, executed: false, detail: `already attempted land for #${pr} head ${view.head.slice(0, 12)}` }));
|
|
361
|
+
if (cycle) landedCycles.add(cycle);
|
|
362
|
+
let ok = false;
|
|
363
|
+
let detail: string;
|
|
364
|
+
try {
|
|
365
|
+
// The CLI rechecks the pinned PR/head immediately before merge, after its own gates and freeze admission.
|
|
366
|
+
const outcome = await (deps.land ?? defaultLand)(pr, view.head, cwd);
|
|
367
|
+
ok = outcome.status === 0;
|
|
368
|
+
detail = (ok ? outcome.stdout || `land merged #${pr} head ${view.head.slice(0, 12)}` : outcome.stderr || outcome.stdout || `exit ${outcome.status}`).slice(-300);
|
|
369
|
+
} catch (error) { detail = (error instanceof Error ? error.message : String(error)).slice(-300); }
|
|
370
|
+
try {
|
|
371
|
+
updateTaskCard(path, card.id, (current) => current?.landAttempts?.some(entry => entry.pr === pr && entry.head === view!.head && entry.at === at)
|
|
372
|
+
? { ...current, landAttempts: current.landAttempts.map(entry => entry.pr === pr && entry.head === view!.head && entry.at === at ? { ...entry, ok, detail } : entry) }
|
|
373
|
+
: undefined);
|
|
374
|
+
} catch (error) {
|
|
375
|
+
return observe(deps, withRepo({ kind, card: card.id, ok: false, executed: true,
|
|
376
|
+
detail: `land ${ok ? 'succeeded' : 'failed'} but card update failed: ${String(error).slice(-230)}` }));
|
|
377
|
+
}
|
|
378
|
+
return observe(deps, withRepo({ kind, card: card.id, ok, executed: true, detail }));
|
|
379
|
+
}
|
|
380
|
+
|
|
258
381
|
/** `propose-green` — 비지 않은 근거가 있을 때만 카드에 제안을 적는다(한 번). 체크리스트는 쓰지 않는다. */
|
|
259
382
|
export function executeLiveGreenProposal(card: TaskCard | undefined, evidence: Record<string, string>, deps: LiveMoveDeps = {}): LiveMoveResult {
|
|
260
383
|
const kind = 'propose-green' as const;
|
package/src/task-agent/shadow.ts
CHANGED
|
@@ -16,12 +16,16 @@ import { readTaskAgentState, readTaskCard, taskAgentStatePath, type CompletionKi
|
|
|
16
16
|
import type { SupervisorJobResult, SupervisorStopReason } from '../self-dev/run-supervisor.js';
|
|
17
17
|
import { executeNextAction, type DeliveryKind, type NextAction } from './actions.js';
|
|
18
18
|
import { judgeNextMove, type TaskJudgeInput, type TaskJudgement } from './judge.js';
|
|
19
|
-
import { configuredTaskAgentLiveMoves, executeLiveGreenProposal, executeLiveReview, type LiveMoveDeps, type LiveMoveResult, type TaskAgentLiveMove } from './live-moves.js';
|
|
19
|
+
import { configuredTaskAgentLiveMoves, executeLiveGreenProposal, executeLiveLand, executeLiveReview, type LiveMoveDeps, type LiveMoveResult, type TaskAgentLiveMove } from './live-moves.js';
|
|
20
20
|
|
|
21
21
|
export interface TaskAgentShadowInput {
|
|
22
22
|
runId: string | null;
|
|
23
23
|
stopReason: SupervisorStopReason;
|
|
24
24
|
results: readonly SupervisorJobResult[];
|
|
25
|
+
/** One judgement cycle can attempt at most one land, even across PRs. */
|
|
26
|
+
cycleId?: string;
|
|
27
|
+
/** Reviewed SHA is mandatory for land; a bare pass cannot authorize merging. */
|
|
28
|
+
selfReview?: { verdict: 'pass' | 'fail'; head: string };
|
|
25
29
|
}
|
|
26
30
|
|
|
27
31
|
export interface TaskAgentShadowMove {
|
|
@@ -47,7 +51,7 @@ export interface TaskAgentShadowDeps {
|
|
|
47
51
|
readCard?: (taskId: string) => TaskCard | undefined;
|
|
48
52
|
/** Explicit live invocation only; supervisor's default hook always uses shadow. */
|
|
49
53
|
mode?: 'shadow' | 'live';
|
|
50
|
-
/** TA-JUDGE-LIVE-SAFE — 실제로 할
|
|
54
|
+
/** TA-JUDGE-LIVE-SAFE — 실제로 할 수(`review`·`propose-green`·`propose-land`). 없으면 설정 `taskAgent.liveMoves` 를 읽는다. */
|
|
51
55
|
liveMoves?: ReadonlySet<TaskAgentLiveMove>;
|
|
52
56
|
/** live 수의 부작용(리뷰 띄우기·PR 머리 조회·상태 파일) — 시험은 주입한다. */
|
|
53
57
|
live?: LiveMoveDeps;
|
|
@@ -161,6 +165,7 @@ export async function recordTaskAgentShadowMove(input: TaskAgentShadowInput, dep
|
|
|
161
165
|
}
|
|
162
166
|
const kind = card?.completion;
|
|
163
167
|
const judgeInput = shadowJudgeInput(input.stopReason, input.results, kind);
|
|
168
|
+
if (input.selfReview) judgeInput.review = input.selfReview.verdict;
|
|
164
169
|
const delivery = successful?.worktreePath && card?.project?.target && isDeliveryKind(kind)
|
|
165
170
|
? { kind, worktreePath: successful.worktreePath, projectTarget: card.project.target, files: selectDeliveryCandidates(successful.worktreePath, kind) }
|
|
166
171
|
: undefined;
|
|
@@ -209,8 +214,8 @@ export async function recordTaskAgentShadowMove(input: TaskAgentShadowInput, dep
|
|
|
209
214
|
move.reason = wouldDo ?? judgement.reason;
|
|
210
215
|
move.wouldDo = null;
|
|
211
216
|
}
|
|
212
|
-
const liveKind: TaskAgentLiveMove | null = move.move === 'review' || move.move === 'propose-green' ? move.move : null;
|
|
213
|
-
// 판단마다 해석한다 —
|
|
217
|
+
const liveKind: TaskAgentLiveMove | null = move.move === 'review' || move.move === 'propose-green' || move.move === 'propose-land' ? move.move : null;
|
|
218
|
+
// 판단마다 해석한다 — 허용되지 않은 수를 판단할 때도 모르는 항목 경고가 남게.
|
|
214
219
|
const liveMoves = deps.liveMoves ?? await configuredTaskAgentLiveMoves();
|
|
215
220
|
const live = liveKind !== null && liveMoves.has(liveKind);
|
|
216
221
|
// 카드 id 는 모든 판단 관측에 싣는다(그림자 일치율을 카드로 잇는다) — 읽기 전용 조회.
|
|
@@ -237,6 +242,16 @@ export async function recordTaskAgentShadowMove(input: TaskAgentShadowInput, dep
|
|
|
237
242
|
...(prResult.prUrl ? { prUrl: prResult.prUrl } : {}),
|
|
238
243
|
} } : {}),
|
|
239
244
|
}, { ...(deps.log ? { log: deps.log } : {}), ...deps.live });
|
|
245
|
+
} else if (liveKind === 'propose-land') {
|
|
246
|
+
liveMove = await executeLiveLand(boundCard, prResult?.prNumber,
|
|
247
|
+
{ runId: input.runId, cycleId: input.cycleId, review: input.selfReview,
|
|
248
|
+
...(prResult?.worktreePath ? { cwd: prResult.worktreePath } : {}),
|
|
249
|
+
...(prResult && !prResult.worktreePath ? { produced: {
|
|
250
|
+
...(prResult.checkedHeadCommit !== undefined ? { headCommit: prResult.checkedHeadCommit } : {}),
|
|
251
|
+
...(prResult.branch ? { branch: prResult.branch } : {}),
|
|
252
|
+
...(prResult.prUrl ? { prUrl: prResult.prUrl } : {}),
|
|
253
|
+
} } : {}),
|
|
254
|
+
}, { ...(deps.log ? { log: deps.log } : {}), ...deps.live });
|
|
240
255
|
} else {
|
|
241
256
|
const evidence: Record<string, string> = {};
|
|
242
257
|
if (boundCard && !delivery && judgeInput.prState === 'MERGED' && prResult) evidence[boundCard.id] = `#${prResult.prNumber}`;
|
|
@@ -83,6 +83,8 @@ export interface TaskCard {
|
|
|
83
83
|
greenProposal?: { at: string; checklistId: string | null; evidence: Record<string, string> };
|
|
84
84
|
/** TA-JUDGE-LIVE-SAFE — live `review` 수가 리뷰를 요청한 PR 머리들(머리마다 한 번 · 띄우기 실패면 error · 이력은 지우지 않는다). */
|
|
85
85
|
reviewRequests?: Array<{ pr: number; head: string; at: string; error?: string }>;
|
|
86
|
+
/** Live land attempts, claimed before invocation, once per PR head even if the command fails. */
|
|
87
|
+
landAttempts?: Array<{ pr: number; head: string; at: string; ok?: boolean; detail?: string }>;
|
|
86
88
|
}
|
|
87
89
|
|
|
88
90
|
/** 런 원장에서 묶은 PR — url 은 원장(pr-opened · Pod job-finished)에 있을 때만(지어내지 않는다). */
|
|
@@ -16,13 +16,14 @@
|
|
|
16
16
|
* 풀을 안 주면 종전처럼 «현재 컨텍스트» 하나다(동작 불변).
|
|
17
17
|
*/
|
|
18
18
|
import { spawn, spawnSync } from 'node:child_process';
|
|
19
|
-
import { existsSync, mkdtempSync, rmSync } from 'node:fs';
|
|
19
|
+
import { existsSync, mkdirSync, mkdtempSync, rmSync, utimesSync } from 'node:fs';
|
|
20
20
|
import { tmpdir } from 'node:os';
|
|
21
21
|
import { join, resolve } from 'node:path';
|
|
22
22
|
import { debug } from '../../debug/log.js';
|
|
23
23
|
import { PodLeaseAdmission, type PodLeasePredecessor, type PodLeaseRelease } from '../../pod-lease/admission.js';
|
|
24
24
|
import { predecessorState, type PredecessorState } from '../../pod-lease/dependency-state.js';
|
|
25
|
-
import { HostPoolLease, leaseHasPendingPod } from '../../pod-lease/host-lease.js';
|
|
25
|
+
import { HostPoolLease, hostLeaseBaseDir, leaseHasPendingPod } from '../../pod-lease/host-lease.js';
|
|
26
|
+
import { acquireLockSync } from '../../storage/file-lock.js';
|
|
26
27
|
import { harnessCpuSlotsBesideGate, measurePoolLease, recommendConcurrency, type PodLeaseMember, type PoolDnsProbe, type PoolLeaseRecommendation } from './pod-lease.js';
|
|
27
28
|
|
|
28
29
|
export interface PodPoolMember {
|
|
@@ -473,13 +474,64 @@ type LocalImageInspect = (image: string) => { status: number | null; stdout: str
|
|
|
473
474
|
/** `docker save | ssh docker load` 한 번. 시험이 실제 전송 대신 호출 횟수만 센다. */
|
|
474
475
|
type ImageShip = (member: PodPoolMember, image: string) => { status: number | null; stderr: string };
|
|
475
476
|
|
|
477
|
+
const IMAGE_SHIP_TIMEOUT_MS = 1_800_000;
|
|
478
|
+
const REMOTE_RUN_TIMEOUT_MS = 900_000;
|
|
479
|
+
|
|
480
|
+
/**
|
|
481
|
+
* Runs a shell pipeline in its «own process group» and kills the whole group, not just bash (flood 10-08:
|
|
482
|
+
* 186 orphaned `docker save | gzip | ssh` pipes after runs timed out or died).
|
|
483
|
+
* - `perl setpgrp` gives the pipeline a group of its own, so `kill 0` cannot reach the caller.
|
|
484
|
+
* - The pipeline runs in the background and bash `wait`s, because bash defers a trap until a «foreground»
|
|
485
|
+
* command finishes — a TERM from the spawnSync timeout would otherwise wait out `docker save`.
|
|
486
|
+
* - A watchdog in the group polls the parent pid: if the caller dies (kill -9 included), the group goes too.
|
|
487
|
+
* - On a normal finish the group is swept as well (watchdog and its `sleep`).
|
|
488
|
+
* ⚠️ The background pipeline's stdin is /dev/null (non-interactive bash) — fine for `docker save`.
|
|
489
|
+
*/
|
|
490
|
+
export function runProcessGroupPipeline(pipeline: string, opts: { timeoutMs: number; parentPid?: number; watchIntervalSec?: number }): { status: number | null; stderr: string; timedOut: boolean } {
|
|
491
|
+
const script = [
|
|
492
|
+
`trap 'trap "" TERM HUP INT; kill -TERM 0 2>/dev/null; exit 143' TERM HUP INT`,
|
|
493
|
+
`( while kill -0 "$ELANOUS_PIPE_PARENT" 2>/dev/null; do sleep "$ELANOUS_PIPE_WATCH"; done; kill -TERM 0 2>/dev/null ) &`,
|
|
494
|
+
`( set -o pipefail; eval "$ELANOUS_PIPE_CMD" ) &`,
|
|
495
|
+
'wait $!',
|
|
496
|
+
'rc=$?',
|
|
497
|
+
`trap "" TERM HUP INT`,
|
|
498
|
+
'kill -TERM 0 2>/dev/null',
|
|
499
|
+
'exit $rc',
|
|
500
|
+
].join('\n');
|
|
501
|
+
const r = spawnSync('perl', ['-e', 'setpgrp(0,0) or die "setpgrp: $!"; exec @ARGV or die "exec: $!"', 'bash', '-c', script], {
|
|
502
|
+
encoding: 'utf8',
|
|
503
|
+
timeout: opts.timeoutMs,
|
|
504
|
+
killSignal: 'SIGTERM',
|
|
505
|
+
env: { ...process.env, ELANOUS_PIPE_CMD: pipeline, ELANOUS_PIPE_PARENT: String(opts.parentPid ?? process.pid), ELANOUS_PIPE_WATCH: String(opts.watchIntervalSec ?? 2) },
|
|
506
|
+
});
|
|
507
|
+
const timedOut = (r.error as NodeJS.ErrnoException | undefined)?.code === 'ETIMEDOUT';
|
|
508
|
+
return { status: r.status, stderr: (r.stderr ?? '') + (timedOut ? ` · timed out after ${opts.timeoutMs}ms (process group killed)` : r.error ? ` · ${String(r.error)}` : ''), timedOut };
|
|
509
|
+
}
|
|
510
|
+
|
|
476
511
|
function defaultImageShip(member: PodPoolMember, image: string): { status: number | null; stderr: string } {
|
|
477
|
-
const ship =
|
|
478
|
-
return { status: ship.status, stderr: ship.stderr
|
|
512
|
+
const ship = runProcessGroupPipeline(`docker save ${image} | gzip -1 | ssh -o BatchMode=yes -o ConnectTimeout=10 ${member.sshHost} 'export PATH=/opt/homebrew/bin:/usr/local/bin:$HOME/.orbstack/bin:$PATH; gunzip | docker load'`, { timeoutMs: IMAGE_SHIP_TIMEOUT_MS });
|
|
513
|
+
return { status: ship.status, stderr: ship.stderr };
|
|
514
|
+
}
|
|
515
|
+
|
|
516
|
+
/** This machine's image ID (`sha256:…` — `docker save | docker load` keeps it). Null when unreadable. */
|
|
517
|
+
function defaultLocalImageId(image: string): { status: number | null; stdout: string } {
|
|
518
|
+
const r = spawnSync('docker', ['image', 'inspect', image, '--format', '{{.Id}}'], { encoding: 'utf8', timeout: 30_000 });
|
|
519
|
+
return { status: r.status, stdout: r.stdout ?? '' };
|
|
520
|
+
}
|
|
521
|
+
|
|
522
|
+
function imageIdFrom(r: { status: number | null; stdout: string }): string | null {
|
|
523
|
+
const v = r.status === 0 ? r.stdout.trim() : '';
|
|
524
|
+
return /^sha256:[0-9a-f]{64}$/.test(v) ? v : null;
|
|
525
|
+
}
|
|
526
|
+
|
|
527
|
+
/** The remote node's image ID. Null when unreadable — never read as «same». */
|
|
528
|
+
export function remoteImageId(member: PodPoolMember, image: string, run: RemoteRun = defaultRemoteRun): string | null {
|
|
529
|
+
if (!member.sshHost) return null;
|
|
530
|
+
return imageIdFrom(run(member.sshHost, `docker image inspect ${image} --format '{{.Id}}'`));
|
|
479
531
|
}
|
|
480
532
|
|
|
481
533
|
export function defaultRemoteRun(host: string, script: string): { status: number | null; stdout: string; stderr: string } {
|
|
482
|
-
const r = spawnSync('ssh', ['-o', 'BatchMode=yes', '-o', 'ConnectTimeout=10', host, `export PATH=/opt/homebrew/bin:/usr/local/bin:$HOME/.orbstack/bin:$PATH; unset HTTPS_PROXY https_proxy HTTP_PROXY http_proxy ALL_PROXY all_proxy; ${script}`], { encoding: 'utf8', timeout:
|
|
534
|
+
const r = spawnSync('ssh', ['-o', 'BatchMode=yes', '-o', 'ConnectTimeout=10', host, `export PATH=/opt/homebrew/bin:/usr/local/bin:$HOME/.orbstack/bin:$PATH; unset HTTPS_PROXY https_proxy HTTP_PROXY http_proxy ALL_PROXY all_proxy; ${script}`], { encoding: 'utf8', timeout: REMOTE_RUN_TIMEOUT_MS });
|
|
483
535
|
return { status: r.status, stdout: r.stdout ?? '', stderr: (r.stderr ?? '') + (r.error ? String(r.error) : '') };
|
|
484
536
|
}
|
|
485
537
|
|
|
@@ -491,29 +543,100 @@ export function remoteImageCommit(member: PodPoolMember, image: string, run: Rem
|
|
|
491
543
|
return v && v !== '<no value>' ? v : null;
|
|
492
544
|
}
|
|
493
545
|
|
|
546
|
+
/** Cross-process lock options for one image ship per host (tests shrink them). */
|
|
547
|
+
export interface ImageShipLockOptions {
|
|
548
|
+
/** Directory for the per-host lock files. Default = the machine-wide pod lease dir (shared by every universe on this machine). */
|
|
549
|
+
dir?: string;
|
|
550
|
+
retryMs?: number;
|
|
551
|
+
/** A lock older than this is a crash leftover. Default = ship timeout ⊕ import timeout ⊕ 2 min. */
|
|
552
|
+
staleMs?: number;
|
|
553
|
+
maxTries?: number;
|
|
554
|
+
}
|
|
555
|
+
|
|
556
|
+
export function imageShipLockPath(host: string, dir: string = join(hostLeaseBaseDir(), 'image-ship')): string {
|
|
557
|
+
return join(dir, `${host.replace(/[^A-Za-z0-9._-]/g, '_').slice(0, 120) || 'host'}.lock`);
|
|
558
|
+
}
|
|
559
|
+
|
|
494
560
|
/**
|
|
495
561
|
* 원격 노드에 이 기계의 이미지를 보낸다(판이 다를 때만) — `docker save | ssh docker load` ⊕ `k3d image import`.
|
|
496
562
|
* ⛔ Pod 의 elanous 는 이미지 판이다(피드백: Pod 는 main 이 아니라 이미지를 돈다) — 노드마다 판이 다르면 같은 골이 노드마다 다른 코드로 돈다.
|
|
563
|
+
* 🩸 10-08 flood: many runs saw the same stale label at once and all shipped together — ssh refused them
|
|
564
|
+
* (`Session open refused by peer` · `kex_exchange_identification`). Now:
|
|
565
|
+
* ① «same image» = commit label OR (label missing) the same image ID on both sides — never ship it twice.
|
|
566
|
+
* ② one ship per host, under a cross-process lock; whoever gets the lock re-checks and skips if another process shipped.
|
|
567
|
+
* ③ the ship pipeline runs in its own process group (`runProcessGroupPipeline`).
|
|
497
568
|
*/
|
|
498
|
-
export function syncPoolImage(member: PodPoolMember, image: string, localCommit: string | null, run: RemoteRun | { run?: RemoteRun; inspect?: LocalImageInspect; transfer?: ImageShip } = defaultRemoteRun): { ok: boolean; action: 'local' | 'fresh' | 'built' | 'shipped' | 'failed'; detail: string } {
|
|
499
|
-
const opts = typeof run === 'function' ? { run } : { run: run.run ?? defaultRemoteRun, inspect: run.inspect, transfer: run.transfer };
|
|
569
|
+
export function syncPoolImage(member: PodPoolMember, image: string, localCommit: string | null, run: RemoteRun | { run?: RemoteRun; inspect?: LocalImageInspect; inspectId?: LocalImageInspect; transfer?: ImageShip; lock?: ImageShipLockOptions } = defaultRemoteRun): { ok: boolean; action: 'local' | 'fresh' | 'built' | 'shipped' | 'failed'; detail: string } {
|
|
570
|
+
const opts = typeof run === 'function' ? { run } : { run: run.run ?? defaultRemoteRun, inspect: run.inspect, inspectId: run.inspectId, transfer: run.transfer, lock: run.lock };
|
|
500
571
|
const remote = opts.run ?? defaultRemoteRun;
|
|
501
572
|
const inspect = opts.inspect ?? defaultLocalImageInspect;
|
|
573
|
+
// An injected label inspect without an ID inspect = a hermetic caller: the ID check is off (no real docker call).
|
|
574
|
+
const inspectId = opts.inspectId ?? (opts.inspect ? null : defaultLocalImageId);
|
|
502
575
|
const transfer = opts.transfer ?? defaultImageShip;
|
|
503
576
|
if (!member.sshHost) return { ok: true, action: 'local', detail: 'this machine' };
|
|
577
|
+
const host = member.sshHost;
|
|
504
578
|
const before = remoteImageCommit(member, image, remote);
|
|
505
579
|
if (localCommit && before === localCommit) return { ok: true, action: 'fresh', detail: before.slice(0, 12) };
|
|
506
580
|
// 통째 전송은 이 기계 이미지의 커밋 라벨이 목표와 같을 때만. 다르면 낡은 판을 보내지 않는다.
|
|
507
581
|
const here = localImageCommit(image, inspect);
|
|
508
582
|
if (localCommit && here !== localCommit) return { ok: false, action: 'failed', detail: '로컬 이미지 판이 다르다 · 보내지 않음' };
|
|
509
|
-
const
|
|
510
|
-
|
|
511
|
-
const
|
|
512
|
-
|
|
513
|
-
|
|
514
|
-
|
|
515
|
-
|
|
516
|
-
|
|
583
|
+
const localId = inspectId ? imageIdFrom(inspectId(image)) : null;
|
|
584
|
+
/** Fresh = the commit label matches, or the very same image (ID) is already there. Null = not fresh. */
|
|
585
|
+
const freshDetail = (label: string | null): string | null => {
|
|
586
|
+
if (localCommit && label === localCommit) return label.slice(0, 12);
|
|
587
|
+
if (localId && remoteImageId(member, image, remote) === localId) return `same image id ${localId.slice(7, 19)}`;
|
|
588
|
+
return null;
|
|
589
|
+
};
|
|
590
|
+
const sameId = freshDetail(before);
|
|
591
|
+
if (sameId) {
|
|
592
|
+
debug.log('pod.pool', 'image-ship-skipped-same-id', { host, image, localCommit, remoteCommit: before, detail: sameId });
|
|
593
|
+
return { ok: true, action: 'fresh', detail: sameId };
|
|
594
|
+
}
|
|
595
|
+
|
|
596
|
+
const lockOpts = opts.lock ?? {};
|
|
597
|
+
const retryMs = lockOpts.retryMs ?? 2_000;
|
|
598
|
+
const staleMs = lockOpts.staleMs ?? IMAGE_SHIP_TIMEOUT_MS + REMOTE_RUN_TIMEOUT_MS + 120_000;
|
|
599
|
+
const lockDir = lockOpts.dir ?? join(hostLeaseBaseDir(), 'image-ship');
|
|
600
|
+
const lockPath = imageShipLockPath(host, lockDir);
|
|
601
|
+
const t0 = Date.now();
|
|
602
|
+
let waited = false;
|
|
603
|
+
let release: () => void;
|
|
604
|
+
try {
|
|
605
|
+
mkdirSync(lockDir, { recursive: true });
|
|
606
|
+
release = acquireLockSync(lockPath, {
|
|
607
|
+
staleMs,
|
|
608
|
+
retryBusyMs: retryMs,
|
|
609
|
+
maxTries: lockOpts.maxTries ?? Math.ceil(staleMs / retryMs) + 10,
|
|
610
|
+
breakDeadHolder: true,
|
|
611
|
+
onWait: (holder) => { waited = true; debug.log('pod.pool', 'image-ship-lock-wait', { host, image, lockPath, holder }); },
|
|
612
|
+
});
|
|
613
|
+
} catch (error) {
|
|
614
|
+
const reason = error instanceof Error ? error.message : String(error);
|
|
615
|
+
debug.log('pod.pool', 'image-ship-lock-failed', { host, image, lockPath, reason }, { level: 'warn' });
|
|
616
|
+
return { ok: false, action: 'failed', detail: `image ship lock: ${reason}` };
|
|
617
|
+
}
|
|
618
|
+
try {
|
|
619
|
+
debug.log('pod.pool', 'image-ship-lock-acquired', { host, image, lockPath, waited, waitedMs: Date.now() - t0 });
|
|
620
|
+
// ⭐ Re-check after the lock: the holder we waited for may have shipped this very image.
|
|
621
|
+
const labelNow = waited ? remoteImageCommit(member, image, remote) : before;
|
|
622
|
+
const fresh = waited ? freshDetail(labelNow) : null;
|
|
623
|
+
if (fresh) {
|
|
624
|
+
debug.log('pod.pool', 'image-ship-skipped-fresh-after-wait', { host, image, localCommit, remoteCommit: labelNow, detail: fresh, waitedMs: Date.now() - t0 });
|
|
625
|
+
return { ok: true, action: 'fresh', detail: `${fresh} (shipped by another process while waiting)` };
|
|
626
|
+
}
|
|
627
|
+
const ship = transfer(member, image);
|
|
628
|
+
if (ship.status !== 0) return { ok: false, action: 'failed', detail: `docker load rc=${ship.status}: ${(ship.stderr ?? '').slice(-300)}` };
|
|
629
|
+
// The import phase gets its own stale window (the lock file's age restarts here).
|
|
630
|
+
try { const now = new Date(); utimesSync(lockPath, now, now); } catch { /* lock gone · finish anyway */ }
|
|
631
|
+
const imp = remote(host, `k3d image import ${image} -c ${member.k3dCluster}`);
|
|
632
|
+
if (imp.status !== 0) return { ok: false, action: 'failed', detail: `k3d import rc=${imp.status}: ${imp.stderr.slice(-300)}` };
|
|
633
|
+
const after = remoteImageCommit(member, image, remote);
|
|
634
|
+
return after && (!localCommit || after === localCommit)
|
|
635
|
+
? { ok: true, action: 'shipped', detail: `${labelNow?.slice(0, 12) ?? '없음'} → ${after.slice(0, 12)}` }
|
|
636
|
+
: { ok: false, action: 'failed', detail: `보낸 뒤 판이 ${after ?? '없음'} — 기대 ${localCommit ?? '?'}` };
|
|
637
|
+
} finally {
|
|
638
|
+
release();
|
|
639
|
+
}
|
|
517
640
|
}
|
|
518
641
|
|
|
519
642
|
export type PoolKubectl = (args: readonly string[], input?: string) => { status: number | null; stdout: string; stderr: string };
|