elanous 0.2.27 → 0.2.28
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/{1454-a9c338f5bb556f42.js → 1454-29d1d773d5996a49.js} +1 -1
- package/apps/pwa/out/_next/static/chunks/{599-a914cc0468f56143.js → 599-56f2c0a5cc4a7295.js} +1 -1
- package/apps/pwa/out/_next/static/chunks/app/approvals/page-5ab1e47978577c5e.js +1 -0
- package/apps/pwa/out/_next/static/chunks/app/autopilot/{page-07110da72c45e68f.js → page-24453f6a302816b4.js} +1 -1
- package/apps/pwa/out/_next/static/chunks/app/board/{page-d2d76d4a7eae91bd.js → page-6c131d30bb1cf871.js} +1 -1
- package/apps/pwa/out/_next/static/chunks/app/botlab/page-fa46d4588e8442c2.js +1 -0
- package/apps/pwa/out/_next/static/chunks/app/bots/{page-e5a9302c105725a3.js → page-dc0772b11861af51.js} +1 -1
- package/apps/pwa/out/_next/static/chunks/app/ceo/page-66ad46ed0279feb9.js +1 -0
- package/apps/pwa/out/_next/static/chunks/app/chat/page-5159dc8752c6cd29.js +1 -0
- package/apps/pwa/out/_next/static/chunks/app/consult/page-c5a2478861c08b87.js +1 -0
- package/apps/pwa/out/_next/static/chunks/app/control/page-f722a9dec084e08b.js +1 -0
- package/apps/pwa/out/_next/static/chunks/app/decisions/{page-6a16a48d1aa6e91f.js → page-256ffd365d32e100.js} +1 -1
- package/apps/pwa/out/_next/static/chunks/app/design-check/page-e0de78ae7b99f01c.js +1 -0
- package/apps/pwa/out/_next/static/chunks/app/editor/page-cfbdc44b708d9801.js +1 -0
- package/apps/pwa/out/_next/static/chunks/app/exec/{page-39f82acf619cca23.js → page-4c741eb760f2f77c.js} +1 -1
- package/apps/pwa/out/_next/static/chunks/app/field/{page-7c2c8f81b185f9c0.js → page-e9ba98f5f1440a21.js} +1 -1
- package/apps/pwa/out/_next/static/chunks/app/inside/{page-ae7aff07e32676bb.js → page-dafc901bb1b72b5f.js} +1 -1
- package/apps/pwa/out/_next/static/chunks/app/intake/{page-482c60c1bc540d0a.js → page-2f6f303fa114ec24.js} +1 -1
- package/apps/pwa/out/_next/static/chunks/app/{layout-7cb996c1aa08b6f5.js → layout-1634081c5272006c.js} +1 -1
- package/apps/pwa/out/_next/static/chunks/app/live/{page-623e08fe4477d606.js → page-ced0bcfc6542b3fc.js} +1 -1
- package/apps/pwa/out/_next/static/chunks/app/live-run/{page-db460cd8aecb8490.js → page-bb1f05cd51c38944.js} +1 -1
- package/apps/pwa/out/_next/static/chunks/app/loops/{page-ff27902d0b11ef4f.js → page-646b50f63b6fe732.js} +1 -1
- package/apps/pwa/out/_next/static/chunks/app/market/page-db069b48a59c8c00.js +1 -0
- package/apps/pwa/out/_next/static/chunks/app/missions/[id]/{page-1917ea87ea71ab6b.js → page-f736324afa2117c1.js} +1 -1
- package/apps/pwa/out/_next/static/chunks/app/morning/{page-25a302273b054fd7.js → page-6f131b51193a36f9.js} +1 -1
- package/apps/pwa/out/_next/static/chunks/app/{not-found-670510eda8b9443f.js → not-found-435f5e2faf02bc01.js} +1 -1
- package/apps/pwa/out/_next/static/chunks/app/observatory/page-497e0eedf20e7890.js +1 -0
- package/apps/pwa/out/_next/static/chunks/app/ops/checklist/page-a8d28fdf995e16ea.js +1 -0
- package/apps/pwa/out/_next/static/chunks/app/ops/release/{page-4bb7556e2e97a750.js → page-8966452228753c5e.js} +1 -1
- package/apps/pwa/out/_next/static/chunks/app/ops/seats/page-fd5d18656cb432b6.js +1 -0
- package/apps/pwa/out/_next/static/chunks/app/outputs/page-5663779cb1a37609.js +1 -0
- package/apps/pwa/out/_next/static/chunks/app/{page-4eb96fc7241c2456.js → page-66a67eb19ebd976b.js} +1 -1
- package/apps/pwa/out/_next/static/chunks/app/reflection/page-3b492aa78cf3c4a6.js +1 -0
- package/apps/pwa/out/_next/static/chunks/app/scheduler/{page-0d64ff212dc55d03.js → page-c71267e7681da982.js} +1 -1
- package/apps/pwa/out/_next/static/chunks/app/sessions/{page-7f096248993fc2d4.js → page-a21d1e1797172692.js} +1 -1
- package/apps/pwa/out/_next/static/chunks/app/settings/devices/{page-e47fc72049149a38.js → page-cb249e81ed339318.js} +1 -1
- package/apps/pwa/out/_next/static/chunks/app/settings/{page-f7d808e31e005895.js → page-781cfc5114e097da.js} +1 -1
- package/apps/pwa/out/_next/static/chunks/app/setup/done/{page-5c2b24b5496dad73.js → page-478157214db6a023.js} +1 -1
- package/apps/pwa/out/_next/static/chunks/app/setup/{page-76cdb531668b5191.js → page-86141f12cf090b7f.js} +1 -1
- package/apps/pwa/out/_next/static/chunks/app/share/page-7256c73bb1c36751.js +1 -0
- package/apps/pwa/out/_next/static/chunks/app/showroom/page-5c738ca7adef3de5.js +1 -0
- package/apps/pwa/out/_next/static/chunks/app/tasks/page-0138c8091ec17224.js +1 -0
- package/apps/pwa/out/_next/static/chunks/app/term/page-cc2f24f16d85e735.js +1 -0
- package/apps/pwa/out/_next/static/chunks/app/today/{page-dab163b338e3b966.js → page-979d318a534e92da.js} +1 -1
- package/apps/pwa/out/_next/static/chunks/app/trace/page-182d394f7e6e096b.js +1 -0
- package/apps/pwa/out/_next/static/chunks/app/vault/{page-0260ffc7eda50db1.js → page-21050928a697ed7a.js} +1 -1
- package/apps/pwa/out/_next/static/chunks/app/workflows/chat-ui/page-44b9d807005a9030.js +1 -0
- package/apps/pwa/out/_next/static/chunks/app/workflows/{page-99c4e6c7345a9156.js → page-601fb77340e63c9a.js} +1 -1
- package/apps/pwa/out/_next/static/chunks/app/workspace/page-75d3fb5d47af0edf.js +1 -0
- package/apps/pwa/out/_next/static/chunks/app/worktrees/page-11bf9e5725a72c22.js +1 -0
- package/apps/pwa/out/approvals/index.html +1 -1
- package/apps/pwa/out/approvals/index.txt +13 -13
- package/apps/pwa/out/autopilot/index.html +1 -1
- package/apps/pwa/out/autopilot/index.txt +13 -13
- package/apps/pwa/out/board/index.html +1 -1
- package/apps/pwa/out/board/index.txt +13 -13
- package/apps/pwa/out/botlab/index.html +1 -1
- package/apps/pwa/out/botlab/index.txt +13 -13
- package/apps/pwa/out/bots/index.html +1 -1
- package/apps/pwa/out/bots/index.txt +13 -13
- package/apps/pwa/out/ceo/index.html +1 -1
- package/apps/pwa/out/ceo/index.txt +13 -13
- package/apps/pwa/out/chat/index.html +1 -1
- package/apps/pwa/out/chat/index.txt +13 -13
- package/apps/pwa/out/consult/index.html +1 -1
- package/apps/pwa/out/consult/index.txt +13 -13
- package/apps/pwa/out/control/index.html +1 -1
- package/apps/pwa/out/control/index.txt +13 -13
- package/apps/pwa/out/decisions/index.html +1 -1
- package/apps/pwa/out/decisions/index.txt +13 -13
- package/apps/pwa/out/design-check/index.html +1 -1
- package/apps/pwa/out/design-check/index.txt +13 -13
- package/apps/pwa/out/editor/index.html +1 -1
- package/apps/pwa/out/editor/index.txt +13 -13
- package/apps/pwa/out/exec/index.html +1 -1
- package/apps/pwa/out/exec/index.txt +13 -13
- package/apps/pwa/out/field/index.html +1 -1
- package/apps/pwa/out/field/index.txt +13 -13
- package/apps/pwa/out/healthz +1 -1
- package/apps/pwa/out/index.html +1 -1
- package/apps/pwa/out/index.txt +13 -13
- package/apps/pwa/out/inside/index.html +1 -1
- package/apps/pwa/out/inside/index.txt +13 -13
- package/apps/pwa/out/intake/index.html +1 -1
- package/apps/pwa/out/intake/index.txt +13 -13
- package/apps/pwa/out/live/index.html +1 -1
- package/apps/pwa/out/live/index.txt +13 -13
- package/apps/pwa/out/live-run/index.html +1 -1
- package/apps/pwa/out/live-run/index.txt +13 -13
- package/apps/pwa/out/loops/index.html +1 -1
- package/apps/pwa/out/loops/index.txt +13 -13
- package/apps/pwa/out/market/index.html +1 -1
- package/apps/pwa/out/market/index.txt +13 -13
- package/apps/pwa/out/missions/placeholder/index.html +1 -1
- package/apps/pwa/out/missions/placeholder/index.txt +13 -13
- package/apps/pwa/out/morning/index.html +1 -1
- package/apps/pwa/out/morning/index.txt +13 -13
- package/apps/pwa/out/observatory/index.html +1 -1
- package/apps/pwa/out/observatory/index.txt +13 -13
- package/apps/pwa/out/ops/checklist/index.html +1 -1
- package/apps/pwa/out/ops/checklist/index.txt +13 -13
- package/apps/pwa/out/ops/release/index.html +1 -1
- package/apps/pwa/out/ops/release/index.txt +13 -13
- package/apps/pwa/out/ops/seats/index.html +1 -1
- package/apps/pwa/out/ops/seats/index.txt +13 -13
- package/apps/pwa/out/outputs/index.html +1 -1
- package/apps/pwa/out/outputs/index.txt +13 -13
- package/apps/pwa/out/reflection/index.html +1 -1
- package/apps/pwa/out/reflection/index.txt +14 -14
- package/apps/pwa/out/scheduler/index.html +1 -1
- package/apps/pwa/out/scheduler/index.txt +13 -13
- package/apps/pwa/out/sessions/index.html +1 -1
- package/apps/pwa/out/sessions/index.txt +13 -13
- package/apps/pwa/out/settings/devices/index.html +1 -1
- package/apps/pwa/out/settings/devices/index.txt +13 -13
- package/apps/pwa/out/settings/index.html +1 -1
- package/apps/pwa/out/settings/index.txt +13 -13
- package/apps/pwa/out/setup/done/index.html +1 -1
- package/apps/pwa/out/setup/done/index.txt +13 -13
- package/apps/pwa/out/setup/index.html +1 -1
- package/apps/pwa/out/setup/index.txt +13 -13
- package/apps/pwa/out/share/index.html +1 -1
- package/apps/pwa/out/share/index.txt +13 -13
- package/apps/pwa/out/showroom/index.html +1 -1
- package/apps/pwa/out/showroom/index.txt +13 -13
- package/apps/pwa/out/tasks/index.html +1 -1
- package/apps/pwa/out/tasks/index.txt +13 -13
- package/apps/pwa/out/term/index.html +1 -1
- package/apps/pwa/out/term/index.txt +13 -13
- package/apps/pwa/out/today/index.html +1 -1
- package/apps/pwa/out/today/index.txt +13 -13
- package/apps/pwa/out/trace/index.html +1 -1
- package/apps/pwa/out/trace/index.txt +13 -13
- package/apps/pwa/out/vault/index.html +1 -1
- package/apps/pwa/out/vault/index.txt +13 -13
- package/apps/pwa/out/workflows/chat-ui/index.html +1 -1
- package/apps/pwa/out/workflows/chat-ui/index.txt +13 -13
- package/apps/pwa/out/workflows/index.html +1 -1
- package/apps/pwa/out/workflows/index.txt +13 -13
- package/apps/pwa/out/workspace/index.html +1 -1
- package/apps/pwa/out/workspace/index.txt +13 -13
- package/apps/pwa/out/worktrees/index.html +1 -1
- package/apps/pwa/out/worktrees/index.txt +13 -13
- package/graphs/implement-loop.yaml +38 -1
- package/graphs/steward/recipes.yaml +6 -0
- package/graphs/steward/release-day.yaml +23 -0
- package/graphs/steward/steward.yaml +9 -1
- package/graphs/work/code-pr.yaml +23 -0
- package/package.json +1 -1
- package/scripts/browser-observe-cycle.ts +76 -2
- package/scripts/ci-typecheck-changed.ts +50 -17
- package/scripts/detect-test-interference.ts +31 -1
- package/scripts/release-loop/docs-node.ts +51 -22
- package/scripts/release-loop/gate-node.ts +37 -4
- package/scripts/release-loop/node-verdict.ts +3 -1
- package/scripts/release-loop/publish-node.ts +3 -0
- package/scripts/release-loop/tui-node-checks.ts +5 -2
- package/scripts/skills/public-check.ts +5 -2
- package/scripts/test-deterministic.ts +520 -37
- package/scripts/ux-sim.ts +7 -1
- package/scripts/verify-kpack6-live.ts +230 -0
- package/src/acp/codex-plugins.ts +17 -0
- package/src/agent/task-notification.ts +2 -1
- package/src/agent-mission/in-agent-plugin.ts +8 -3
- package/src/agent-substrate/self-review-cli.ts +34 -10
- package/src/briefing/brief-items.ts +151 -35
- package/src/budget/llm-cost.ts +25 -2
- package/src/budget/usage-outcomes.ts +32 -0
- package/src/claims/claims-ledger.ts +23 -0
- package/src/claims/claims-render.ts +22 -2
- package/src/cli/brief-cli.ts +131 -10
- package/src/cli/claims-cli.ts +24 -4
- package/src/cli/msg-cli.ts +165 -9
- package/src/cli/plugin-cli.ts +11 -3
- package/src/cli/pr-cli.ts +80 -18
- package/src/cli/pr-land-refusal.ts +20 -0
- package/src/cli/release-cli.ts +2 -0
- package/src/cli/rfc-cli.ts +24 -0
- package/src/cli/tailscale-serve.ts +5 -8
- package/src/cli/tasks-cli.ts +393 -26
- package/src/cli/usage-cli.ts +9 -4
- package/src/dashboard/first-screen-band.ts +63 -13
- package/src/dashboard/index.ts +8 -3
- package/src/dashboard/skill-detection-runtime.ts +24 -4
- package/src/domains/morning-report.ts +3 -1
- package/src/github/pr-labels.ts +10 -4
- package/src/graph-runner/runner.ts +101 -14
- package/src/harness/harness-clean.ts +21 -3
- package/src/harness/harness-cli-command.ts +22 -6
- package/src/harness/harness-pod-dispatch.ts +11 -4
- package/src/hq/seats.ts +17 -3
- package/src/index.ts +34 -5
- package/src/intelligence-map/model-bench-scores.ts +102 -0
- package/src/loops/orchestrator/inbox.ts +73 -0
- package/src/loops/orchestrator/resync-shadow.ts +100 -12
- package/src/loops/orchestrator/tick.ts +73 -2
- package/src/maturity/feature-maturity.ts +1 -1
- package/src/msg/msg-sse-server.ts +141 -0
- package/src/msg/msg-store.ts +84 -14
- package/src/patents/ledger.ts +128 -0
- package/src/plugins/install/market-fetch.ts +32 -1
- package/src/pod-lease/admission.ts +116 -41
- package/src/release-loop/checklist.ts +11 -0
- package/src/release-loop/merged-pr-checklist.ts +24 -5
- package/src/release-loop/rubric.ts +70 -0
- package/src/rfc-registry/registry.ts +88 -0
- package/src/seat-dispatch/seat-request-ledger.ts +4 -0
- package/src/seat-loop/knowhow-lint.ts +233 -0
- package/src/seat-loop/seat-pack.ts +16 -4
- package/src/seat-loop/traffic-signal.ts +7 -0
- package/src/self-dev/ask-launch-flow.ts +48 -8
- package/src/self-dev/ask-path-candidates.ts +69 -14
- package/src/self-dev/decompose-proposal.ts +43 -1
- package/src/self-dev/graph-journey-nodes.ts +2 -0
- package/src/self-dev/launch-preflight-text.ts +5 -1
- package/src/self-dev/launch-preflight.ts +143 -10
- package/src/self-implement/gate-cli.ts +6 -2
- package/src/self-implement/goal-author.ts +2 -1
- package/src/self-implement/goal-new-paths.ts +4 -5
- package/src/self-implement/graph-authority.ts +17 -3
- package/src/self-implement/graph-yaml.ts +15 -7
- package/src/self-implement/headless-elanous-driver.ts +16 -1
- package/src/self-implement/host-regate.ts +44 -9
- package/src/self-implement/orchestrator.ts +3 -10
- package/src/self-implement/rework-policy.ts +80 -2
- package/src/self-implement/speed-claims.ts +145 -6
- package/src/skills/tools/persistent-grounding.ts +59 -4
- package/src/skills/tools/shell.ts +79 -1
- package/src/steward/rejudge-tick.ts +12 -1
- package/src/steward/release-day.ts +158 -0
- package/src/steward/resync-tick.ts +123 -0
- package/src/task-agent/actions.ts +1 -0
- package/src/task-agent/adopt-pr.ts +140 -0
- package/src/task-agent/board.ts +11 -1
- package/src/task-agent/card-evidence.ts +37 -5
- package/src/task-agent/cover.ts +84 -17
- package/src/task-agent/failure-notify.ts +61 -0
- package/src/task-agent/green-sweep.ts +170 -0
- package/src/task-agent/land-worktree.ts +52 -5
- package/src/task-agent/live-move-history.ts +1 -0
- package/src/task-agent/live-moves.ts +43 -12
- package/src/task-agent/parent-host.ts +1 -1
- package/src/task-agent/postland-card.ts +139 -0
- package/src/task-agent/rejudge-on-head.ts +106 -7
- package/src/task-agent/remote-rejudge-bind.ts +62 -0
- package/src/task-agent/repair-branch-cas.ts +182 -0
- package/src/task-agent/repair-cycle.ts +275 -0
- package/src/task-agent/repair-next-move.ts +31 -0
- package/src/task-agent/repair-review.ts +24 -0
- package/src/task-agent/repair-worker.ts +87 -0
- package/src/task-agent/review-result-capture.ts +2 -1
- package/src/task-agent/shadow.ts +13 -7
- package/src/task-agent/task-hand.ts +115 -6
- package/src/task-orchestrator/surfaces/pod-repair-commit.ts +96 -0
- package/src/task-orchestrator/surfaces/self-implement-pod.ts +70 -12
- package/src/telegram.ts +30 -1
- package/src/user-config.ts +8 -1
- package/src/ux-sim/fault-sqlite-busy.ts +50 -0
- package/src/version/packed-revision.json +1 -1
- package/apps/pwa/out/_next/static/chunks/app/approvals/page-a1ce306364f08da9.js +0 -1
- package/apps/pwa/out/_next/static/chunks/app/botlab/page-30007380f2e2a3c1.js +0 -1
- package/apps/pwa/out/_next/static/chunks/app/ceo/page-b93c04b5311c97ec.js +0 -1
- package/apps/pwa/out/_next/static/chunks/app/chat/page-718a49e86bf25f9c.js +0 -1
- package/apps/pwa/out/_next/static/chunks/app/consult/page-20662900d6b4b455.js +0 -1
- package/apps/pwa/out/_next/static/chunks/app/control/page-f707a509515ac50c.js +0 -1
- package/apps/pwa/out/_next/static/chunks/app/design-check/page-fcb06e305f515527.js +0 -1
- package/apps/pwa/out/_next/static/chunks/app/editor/page-6acf5ec4e6c6d1af.js +0 -1
- package/apps/pwa/out/_next/static/chunks/app/market/page-8397a5c6ca569bf4.js +0 -1
- package/apps/pwa/out/_next/static/chunks/app/observatory/page-d73c3f1e00d93986.js +0 -1
- package/apps/pwa/out/_next/static/chunks/app/ops/checklist/page-65caacf54389081e.js +0 -1
- package/apps/pwa/out/_next/static/chunks/app/ops/seats/page-77e04b4f8762c666.js +0 -1
- package/apps/pwa/out/_next/static/chunks/app/outputs/page-15c0a4095baddfba.js +0 -1
- package/apps/pwa/out/_next/static/chunks/app/reflection/page-63e272a4cd3b6634.js +0 -1
- package/apps/pwa/out/_next/static/chunks/app/share/page-e2b63345bba1a11f.js +0 -1
- package/apps/pwa/out/_next/static/chunks/app/showroom/page-d376e0c83edc041a.js +0 -1
- package/apps/pwa/out/_next/static/chunks/app/tasks/page-d6db55fe2c04d128.js +0 -1
- package/apps/pwa/out/_next/static/chunks/app/term/page-87b88f551f421d8b.js +0 -1
- package/apps/pwa/out/_next/static/chunks/app/trace/page-08019efef111ed2e.js +0 -1
- package/apps/pwa/out/_next/static/chunks/app/workflows/chat-ui/page-980ce6fe36ad95eb.js +0 -1
- package/apps/pwa/out/_next/static/chunks/app/workspace/page-0ab42e0f05823013.js +0 -1
- package/apps/pwa/out/_next/static/chunks/app/worktrees/page-ad9220422d20ba33.js +0 -1
- /package/apps/pwa/out/_next/static/{Ev2jivDMTSoEc-5rIXdcy → y1ltt7oCZxVpC6zVPhZ2O}/_buildManifest.js +0 -0
- /package/apps/pwa/out/_next/static/{Ev2jivDMTSoEc-5rIXdcy → y1ltt7oCZxVpC6zVPhZ2O}/_ssgManifest.js +0 -0
|
@@ -5,6 +5,9 @@ import { tmpdir } from 'node:os';
|
|
|
5
5
|
import { dirname, isAbsolute, join, resolve } from 'node:path';
|
|
6
6
|
import { debug } from '../../debug/log.js';
|
|
7
7
|
import { effectiveInstanceRoot } from '../../instance/resolve.js';
|
|
8
|
+
import { PR_LABEL_NAMES } from '../../github/pr-labels.js';
|
|
9
|
+
import { listRunLedgers } from '../../self-implement/run-ledger.js';
|
|
10
|
+
import { queryRunningRuns } from '../../self-implement/running-runs.js';
|
|
8
11
|
import {
|
|
9
12
|
defaultGitMergeSeam, defaultLlmResolve, mergeMainWithLlmResolve,
|
|
10
13
|
type LlmMergeOutcome, type MergeGitSeam,
|
|
@@ -16,6 +19,8 @@ interface ResyncShadowEntry {
|
|
|
16
19
|
at: string;
|
|
17
20
|
pr: number;
|
|
18
21
|
head: string;
|
|
22
|
+
beforeHead?: string;
|
|
23
|
+
afterHead?: string;
|
|
19
24
|
mode: ResyncShadowMode;
|
|
20
25
|
pushed: boolean;
|
|
21
26
|
labelRemoved: boolean;
|
|
@@ -27,7 +32,7 @@ interface ResyncShadowEntry {
|
|
|
27
32
|
}
|
|
28
33
|
export interface ResyncShadowSummary { candidates: number; tried: number; saved: number; savedRatio: number | null }
|
|
29
34
|
|
|
30
|
-
type OpenPr = { number: number; headRefName: string; mergeable: string; isDraft: boolean; labels: { name: string }[] };
|
|
35
|
+
type OpenPr = { number: number; headRefName: string; headRefOid?: string; mergeable: string; isDraft: boolean; labels: { name: string }[] };
|
|
31
36
|
export interface ResyncShadowDeps {
|
|
32
37
|
repoRoot?: string;
|
|
33
38
|
instanceRoot?: string;
|
|
@@ -37,6 +42,63 @@ export interface ResyncShadowDeps {
|
|
|
37
42
|
gitSeam?: MergeGitSeam;
|
|
38
43
|
runTests?: (cwd: string, files: string[]) => { ok: boolean; failedTests?: string[]; noTests?: boolean };
|
|
39
44
|
now?: () => Date;
|
|
45
|
+
/** Run ownership is checked before touching a branch; null means the observation failed. */
|
|
46
|
+
activePrNumbers?: (candidatePrs: readonly number[]) => ReadonlySet<number> | null;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
function activePrNumbers(candidatePrs: readonly OpenPr[], repoRoot: string, instanceRoot: string): ReadonlySet<number> | null {
|
|
50
|
+
const owners = new Map<number, { runId: string; branch: string; headCommit: string; terminal: boolean }[]>();
|
|
51
|
+
const branchRuns = new Map<string, Set<string>>();
|
|
52
|
+
const incompleteOwnerPrs = new Set<number>();
|
|
53
|
+
const candidateSet = new Set(candidatePrs.map(pr => pr.number));
|
|
54
|
+
try {
|
|
55
|
+
for (const match of listRunLedgers().matches) {
|
|
56
|
+
const latestStatus = match.entries.filter(entry => entry.event === 'run-status').at(-1)?.data.runStatus;
|
|
57
|
+
const terminal = latestStatus === 'completed' || latestStatus === 'failed' || latestStatus === 'cancelled';
|
|
58
|
+
for (const entry of match.entries) {
|
|
59
|
+
// A competing run can own the branch even when its PR number or worktree event is missing.
|
|
60
|
+
if ((entry.event === 'worktree' || entry.event === 'pr-opened') && typeof entry.data.branch === 'string') {
|
|
61
|
+
const ids = branchRuns.get(entry.data.branch) ?? new Set<string>();
|
|
62
|
+
ids.add(match.runId);
|
|
63
|
+
branchRuns.set(entry.data.branch, ids);
|
|
64
|
+
}
|
|
65
|
+
const number = entry.event === 'pr-opened' ? entry.data.number : undefined;
|
|
66
|
+
if (typeof number !== 'number' || !candidateSet.has(number)) continue;
|
|
67
|
+
const matches = owners.get(number) ?? [];
|
|
68
|
+
if (typeof entry.data.branch !== 'string' || typeof entry.data.headCommit !== 'string')
|
|
69
|
+
incompleteOwnerPrs.add(number);
|
|
70
|
+
else matches.push({ runId: match.runId, branch: entry.data.branch, headCommit: entry.data.headCommit, terminal });
|
|
71
|
+
owners.set(number, matches);
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
const runIds = [...new Set([...owners.values()].flatMap(rows => rows.map(row => row.runId)))];
|
|
75
|
+
if (runIds.length === 0) return new Set(candidatePrs.map(pr => pr.number));
|
|
76
|
+
const observed = queryRunningRuns({ runIds, caller: 'smart-merge.resync' });
|
|
77
|
+
if (observed.completeness !== 'complete') return null;
|
|
78
|
+
const active = new Set(observed.entries.filter(row => row.status === 'running' || row.status === 'probable-running'
|
|
79
|
+
|| row.status === 'unknown' || row.status === 'ended-unclosed').map(row => row.runId));
|
|
80
|
+
const priorPath = join(instanceRoot, 'orchestrator', 'resync-shadow.jsonl');
|
|
81
|
+
const prior = existsSync(priorPath) ? readFileSync(priorPath, 'utf8').split('\n').filter(Boolean).map(line => JSON.parse(line) as ResyncShadowEntry) : [];
|
|
82
|
+
const excluded = new Set<number>();
|
|
83
|
+
for (const pr of candidatePrs) {
|
|
84
|
+
const matches = owners.get(pr.number) ?? [];
|
|
85
|
+
if (incompleteOwnerPrs.has(pr.number) || matches.length !== 1 || !/^[a-f0-9]{40}$/i.test(pr.headRefOid ?? '')) { excluded.add(pr.number); continue; }
|
|
86
|
+
const owner = matches[0]!;
|
|
87
|
+
if (owner.branch !== pr.headRefName || !owner.terminal || !/^[a-f0-9]{40}$/i.test(owner.headCommit)
|
|
88
|
+
|| active.has(owner.runId) || [...(branchRuns.get(pr.headRefName) ?? [])].some(id => id !== owner.runId)) {
|
|
89
|
+
excluded.add(pr.number); continue;
|
|
90
|
+
}
|
|
91
|
+
const remote = command('git', ['ls-remote', 'origin', `refs/heads/${pr.headRefName}`], repoRoot).trim().split(/\s/)[0];
|
|
92
|
+
if (remote !== pr.headRefOid) { excluded.add(pr.number); continue; }
|
|
93
|
+
let proven = owner.headCommit;
|
|
94
|
+
for (const row of prior.filter(row => row.pr === pr.number && row.head === pr.headRefName && row.pushed)) {
|
|
95
|
+
if (row.beforeHead !== proven || !/^[a-f0-9]{40}$/i.test(row.afterHead ?? '')) { proven = ''; break; }
|
|
96
|
+
proven = row.afterHead!;
|
|
97
|
+
}
|
|
98
|
+
if (proven !== remote) excluded.add(pr.number);
|
|
99
|
+
}
|
|
100
|
+
return excluded;
|
|
101
|
+
} catch { return null; }
|
|
40
102
|
}
|
|
41
103
|
|
|
42
104
|
function command(file: string, args: string[], cwd: string, timeout = 60_000): string {
|
|
@@ -51,6 +113,15 @@ function changedTests(gh: (args: string[]) => string, number: number): string[]
|
|
|
51
113
|
path.endsWith('.test.ts') && !isAbsolute(path) && path !== '' && !path.split('/').some(part => part === '..' || part === '.' || part === '')))];
|
|
52
114
|
}
|
|
53
115
|
|
|
116
|
+
function listOpenPrs(gh: (args: string[]) => string): OpenPr[] {
|
|
117
|
+
const observed: unknown = JSON.parse(gh(['pr', 'list', '--state', 'open', '--json', 'number,headRefName,headRefOid,mergeable,isDraft,labels', '--limit', '1000']));
|
|
118
|
+
if (!Array.isArray(observed) || observed.length >= 1000 || observed.some(pr => !pr || !Number.isSafeInteger(pr.number)
|
|
119
|
+
|| typeof pr.headRefName !== 'string' || typeof pr.mergeable !== 'string' || typeof pr.isDraft !== 'boolean'
|
|
120
|
+
|| !Array.isArray(pr.labels) || pr.labels.some((label: unknown) => !label || typeof label !== 'object' || typeof (label as { name?: unknown }).name !== 'string')))
|
|
121
|
+
throw new Error('incomplete or invalid PR observation');
|
|
122
|
+
return observed as OpenPr[];
|
|
123
|
+
}
|
|
124
|
+
|
|
54
125
|
// A PR test must not inherit credentials, reach the network or write outside its own worktree.
|
|
55
126
|
// If the kernel does not provide the required isolation, fail closed before starting bun.
|
|
56
127
|
const TEST_SANDBOX = `import ctypes, os, sys
|
|
@@ -145,16 +216,16 @@ export async function runResyncShadow(max = 3, deps: ResyncShadowDeps = {}, mode
|
|
|
145
216
|
if (mode !== 'shadow' && mode !== 'live') throw new Error('mode must be shadow or live');
|
|
146
217
|
const repoRoot = deps.repoRoot ?? resolve(import.meta.dir, '../../..');
|
|
147
218
|
const gh = deps.runGh ?? ((args: string[]) => command('gh', args, repoRoot));
|
|
148
|
-
const
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
const
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
219
|
+
const eligible = listOpenPrs(gh).filter(pr => pr.headRefName.startsWith('self-impl/')
|
|
220
|
+
&& (mode === 'shadow' ? pr.mergeable === 'CONFLICTING' : pr.mergeable === 'MERGEABLE')
|
|
221
|
+
&& (mode === 'shadow' || pr.labels.some(label => label.name === PR_LABEL_NAMES.needsRebase))
|
|
222
|
+
&& !pr.labels.some(label => label.name === PR_LABEL_NAMES.running)
|
|
223
|
+
&& !pr.labels.some(label => label.name === PR_LABEL_NAMES.stalled || label.name === PR_LABEL_NAMES.superseded));
|
|
224
|
+
const observeActive = deps.activePrNumbers ?? ((numbers: readonly number[]) => activePrNumbers(
|
|
225
|
+
eligible.filter(pr => numbers.includes(pr.number)), repoRoot, deps.instanceRoot ?? effectiveInstanceRoot()));
|
|
226
|
+
const active = mode === 'live' && eligible.length ? observeActive(eligible.map(pr => pr.number)) : new Set<number>();
|
|
227
|
+
if (active === null) throw new Error('active run observation unavailable — resync stays read-only');
|
|
228
|
+
const candidates = eligible.filter(pr => !active.has(pr.number)).sort((a, b) => a.number - b.number).slice(0, max);
|
|
158
229
|
const ledger = join(deps.instanceRoot ?? effectiveInstanceRoot(), 'orchestrator', 'resync-shadow.jsonl');
|
|
159
230
|
let saved = 0;
|
|
160
231
|
let tried = 0;
|
|
@@ -169,6 +240,9 @@ export async function runResyncShadow(max = 3, deps: ResyncShadowDeps = {}, mode
|
|
|
169
240
|
// Explicit destination refspecs make origin/<head> and origin/main fresh even without remote.origin.fetch.
|
|
170
241
|
command('git', ['fetch', 'origin', `+refs/heads/${pr.headRefName}:refs/remotes/origin/${pr.headRefName}`,
|
|
171
242
|
'+refs/heads/main:refs/remotes/origin/main'], repoRoot);
|
|
243
|
+
entry.beforeHead = command('git', ['rev-parse', `origin/${pr.headRefName}`], repoRoot).trim();
|
|
244
|
+
if (mode === 'live' && !deps.activePrNumbers && entry.beforeHead !== pr.headRefOid)
|
|
245
|
+
throw new Error('PR head changed after owner proof — resync stays read-only');
|
|
172
246
|
entry.testFiles = changedTests(gh, pr.number);
|
|
173
247
|
tmp = realpathSync(mkdtempSync(join(tmpdir(), 'resync-shadow-')));
|
|
174
248
|
command('git', ['worktree', 'add', '--detach', tmp, `origin/${pr.headRefName}`], repoRoot);
|
|
@@ -189,10 +263,24 @@ export async function runResyncShadow(max = 3, deps: ResyncShadowDeps = {}, mode
|
|
|
189
263
|
} else entry.verdict = outcome.status === 'conflict-unresolved' || outcome.status === 'up-to-date' ? 'unresolved' : 'error';
|
|
190
264
|
if (mode === 'live' && entry.verdict === 'saved') {
|
|
191
265
|
try {
|
|
266
|
+
const lateActive = observeActive([pr.number]);
|
|
267
|
+
if (lateActive === null || lateActive.has(pr.number))
|
|
268
|
+
throw new Error('PR ownership changed before push — resync stays read-only');
|
|
269
|
+
const current = listOpenPrs(gh).filter(row => row.number === pr.number);
|
|
270
|
+
if (current.length !== 1 || current[0]!.headRefName !== pr.headRefName
|
|
271
|
+
|| current[0]!.headRefOid !== pr.headRefOid || current[0]!.mergeable !== 'MERGEABLE'
|
|
272
|
+
|| !current[0]!.labels.some(label => label.name === PR_LABEL_NAMES.needsRebase)
|
|
273
|
+
|| current[0]!.labels.some(label => label.name === PR_LABEL_NAMES.running
|
|
274
|
+
|| label.name === PR_LABEL_NAMES.stalled || label.name === PR_LABEL_NAMES.superseded))
|
|
275
|
+
throw new Error('PR state changed before push — resync stays read-only');
|
|
276
|
+
const immediateHead = command('git', ['ls-remote', 'origin', `refs/heads/${pr.headRefName}`], repoRoot).trim().split(/\s/)[0];
|
|
277
|
+
if (immediateHead !== entry.beforeHead)
|
|
278
|
+
throw new Error('remote head changed before push — resync stays read-only');
|
|
192
279
|
(deps.runGit ?? (args => { command('git', args, tmp!); }))(
|
|
193
280
|
['push', 'origin', `HEAD:refs/heads/${pr.headRefName}`]);
|
|
194
281
|
entry.pushed = true;
|
|
195
|
-
|
|
282
|
+
entry.afterHead = command('git', ['rev-parse', 'HEAD'], tmp).trim();
|
|
283
|
+
gh(['pr', 'edit', String(pr.number), '--remove-label', PR_LABEL_NAMES.needsRebase]);
|
|
196
284
|
entry.labelRemoved = true;
|
|
197
285
|
} catch (error) {
|
|
198
286
|
const message = error instanceof Error ? error.message : String(error);
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { withFileLockSync } from '../../storage/file-lock.js';
|
|
2
|
-
import { appendFileSync, existsSync, mkdirSync, readFileSync, readdirSync, statSync, writeFileSync } from 'node:fs';
|
|
2
|
+
import { appendFileSync, existsSync, mkdirSync, readFileSync, readdirSync, renameSync, statSync, writeFileSync } from 'node:fs';
|
|
3
3
|
import { dirname, join } from 'node:path';
|
|
4
4
|
import { CardStore, taskCardsDir, type TaskCard } from '../../task-cards/card-store.js';
|
|
5
5
|
import { Database } from 'bun:sqlite';
|
|
@@ -16,6 +16,7 @@ import { placeOnGrid, type GridHost } from './grid-place.js';
|
|
|
16
16
|
import { checkPodPool, parsePodPool, resolvePodPoolSpec } from '../../task-orchestrator/surfaces/pod-pool.js';
|
|
17
17
|
import { leaseKubectl } from '../../task-orchestrator/surfaces/pod-lease.js';
|
|
18
18
|
import { readTaskAgentState, TASK_SEATS, type HandTaskOptions, type TaskCard as TaskAgentCard, type HandTaskResult, type TaskLauncher, type TaskSeat } from '../../task-agent/task-hand.js';
|
|
19
|
+
import { runCoordinatorInbox } from './inbox.js';
|
|
19
20
|
|
|
20
21
|
export type Window = '08' | '12' | '18';
|
|
21
22
|
export type Node = 'intake' | 'split' | 'place' | 'delegate' | 'launch' | 'reconcile' | 'report';
|
|
@@ -31,6 +32,10 @@ export interface TickState {
|
|
|
31
32
|
/** The highest degradation actually applied in this run — the report keeps it even if the signal recovers before report. */
|
|
32
33
|
degradationPeak?: { level: 1 | 2; reason: string };
|
|
33
34
|
deferredCells?: Cell[];
|
|
35
|
+
/** A placement adapter rejected at least one cell; this run cannot claim full completion. */
|
|
36
|
+
placementFailed?: boolean;
|
|
37
|
+
/** Last confirmed live placement write in this run, carried across node processes. */
|
|
38
|
+
boardWrittenAt?: string;
|
|
34
39
|
/** place ran at L2 (skipped) in this run — its unplaced cells are carried even if the board is back by delegate. */
|
|
35
40
|
placeDegraded?: boolean;
|
|
36
41
|
delegatedKeys?: string[];
|
|
@@ -67,6 +72,8 @@ export interface TickDeps {
|
|
|
67
72
|
handToTaskAgentCell?: string;
|
|
68
73
|
handTask?: (opts: HandTaskOptions) => Promise<HandTaskResult>;
|
|
69
74
|
taskLauncher?: TaskLauncher;
|
|
75
|
+
/** Coordinator inbox seam; the real intake reads and replies on the durable message bus. */
|
|
76
|
+
coordinatorInbox?: () => { replied: number; acknowledged: number };
|
|
70
77
|
}
|
|
71
78
|
|
|
72
79
|
/** ORCH-TA-HAND ledger — one row per handed `orch:<card>:<cell>` key so a cell is handed once across ticks and processes. */
|
|
@@ -329,6 +336,41 @@ function stateFile(root: string, runId: string): string {
|
|
|
329
336
|
if (!/^[a-zA-Z0-9._-]+$/.test(runId) || runId === '.' || runId === '..') throw new Error('Invalid orchestrator runId');
|
|
330
337
|
return join(root, 'loop', 'orchestrator', `${runId}.json`);
|
|
331
338
|
}
|
|
339
|
+
/** COORD-HA ③ 진행 하트비트 — the last tick that finished its work and the last confirmed placement write. The guardian watches this
|
|
340
|
+
* (RFC-loop-agent-map §A8b: «마지막 틱 완료 ⊕ 보드 마지막 쓰기 시각»), not process liveness — a held node keeps pulsing
|
|
341
|
+
* healthy while this record goes stale, which is exactly the stall it exists to expose. */
|
|
342
|
+
export interface CompletionHeartbeat {
|
|
343
|
+
runId: string;
|
|
344
|
+
node: Node;
|
|
345
|
+
window: Window;
|
|
346
|
+
mode: Mode;
|
|
347
|
+
/** last confirmed live placement write; null before the first such write */
|
|
348
|
+
boardWrittenAt: string | null;
|
|
349
|
+
/** last fully completed run, or null while only board writes have succeeded */
|
|
350
|
+
tickCompletedAt: string | null;
|
|
351
|
+
}
|
|
352
|
+
/** Its own subdirectory, so the run-state scanners (`restorePrevious` · `previousDelegations` · `syncQueuedPoolHints`) never parse it as a TickState. */
|
|
353
|
+
function completionHeartbeatPath(root: string): string { return join(root, 'loop', 'orchestrator', 'heartbeat', 'completion.json'); }
|
|
354
|
+
/** COORD-HA ③ — last completed run and board write, independently. Undefined means neither has been recorded. */
|
|
355
|
+
export function readCompletionHeartbeat(root: string): CompletionHeartbeat | undefined {
|
|
356
|
+
const path = completionHeartbeatPath(root);
|
|
357
|
+
if (!existsSync(path)) return undefined;
|
|
358
|
+
try {
|
|
359
|
+
const { runId, node, window, mode, boardWrittenAt, tickCompletedAt } = JSON.parse(readFileSync(path, 'utf8')) as Partial<CompletionHeartbeat>;
|
|
360
|
+
if (typeof runId !== 'string' || typeof node !== 'string' || typeof window !== 'string' || typeof mode !== 'string'
|
|
361
|
+
|| (boardWrittenAt !== null && (typeof boardWrittenAt !== 'string' || !Number.isFinite(Date.parse(boardWrittenAt))))
|
|
362
|
+
|| (tickCompletedAt !== null && (typeof tickCompletedAt !== 'string' || !Number.isFinite(Date.parse(tickCompletedAt))))) return undefined;
|
|
363
|
+
return { runId, node: node as Node, window: window as Window, mode: mode as Mode, boardWrittenAt, tickCompletedAt };
|
|
364
|
+
} catch { return undefined; }
|
|
365
|
+
}
|
|
366
|
+
/** Temp + rename, so a guardian in another process never reads a torn record. */
|
|
367
|
+
function recordCompletionHeartbeat(root: string, completion: CompletionHeartbeat): void {
|
|
368
|
+
const path = completionHeartbeatPath(root);
|
|
369
|
+
mkdirSync(dirname(path), { recursive: true });
|
|
370
|
+
const temp = `${path}.${process.pid}.tmp`;
|
|
371
|
+
writeFileSync(temp, JSON.stringify(completion));
|
|
372
|
+
renameSync(temp, path);
|
|
373
|
+
}
|
|
332
374
|
function restorePrevious(root: string, day: string, mode: Mode): Pick<TickState, 'cards' | 'cells' | 'launched'> | null {
|
|
333
375
|
const dir = join(root, 'loop', 'orchestrator');
|
|
334
376
|
if (!existsSync(dir)) return null;
|
|
@@ -430,6 +472,15 @@ export async function runOrchestratorNode(node: Node, deps: TickDeps = {}): Prom
|
|
|
430
472
|
try { (deps.observe ?? ((name, payload) => debug.log('loop.orchestrator', name, payload)))(event, data); }
|
|
431
473
|
catch { /* observation must not change the run */ }
|
|
432
474
|
};
|
|
475
|
+
// A graph node may be replayed with the same runId, and 12/18 skip intake.
|
|
476
|
+
// Drain the bus on every tick command; the message transaction prevents duplicate answers.
|
|
477
|
+
if (mode !== 'off') {
|
|
478
|
+
const inbox = (deps.coordinatorInbox ?? (() => runCoordinatorInbox(undefined, 100, join(root, 'msg', 'messages.db'))))();
|
|
479
|
+
if (inbox.replied || inbox.acknowledged) {
|
|
480
|
+
observe('exchange', 'coordinator-inbox-replied', inbox.replied);
|
|
481
|
+
observe('exchange', 'coordinator-inbox-acknowledged', inbox.acknowledged);
|
|
482
|
+
}
|
|
483
|
+
}
|
|
433
484
|
if (mode !== 'off' && activeNode(node, window) && state.nodes[node] !== 'ok') {
|
|
434
485
|
if (node === 'intake') {
|
|
435
486
|
if (mode === 'live' && deps.cards) { state.cards = wishCards(deps.cards()); state.skippedCards = []; }
|
|
@@ -495,11 +546,19 @@ export async function runOrchestratorNode(node: Node, deps: TickDeps = {}): Prom
|
|
|
495
546
|
if (validVersion(placed?.version)) {
|
|
496
547
|
cell.version = placed.version;
|
|
497
548
|
state.placed++;
|
|
549
|
+
// The live placer has returned from its board write. A shadow dry run or null result is not a write.
|
|
550
|
+
if (mode === 'live') state.boardWrittenAt = currentTime().toISOString();
|
|
498
551
|
observe('exchange', mode === 'shadow' ? `would-place:${cell.id}:${cell.version}` : `placed:${cell.id}:${cell.version}`, 1);
|
|
499
|
-
} else {
|
|
552
|
+
} else {
|
|
553
|
+
state.unplaced = (state.unplaced ?? 0) + 1;
|
|
554
|
+
if (mode === 'live') state.placementFailed = true;
|
|
555
|
+
observe('exchange', 'unplaced-no-release', 1);
|
|
556
|
+
}
|
|
500
557
|
} catch (error) {
|
|
501
558
|
state.unplaced = (state.unplaced ?? 0) + 1;
|
|
559
|
+
state.placementFailed = true;
|
|
502
560
|
observe('exchange', `unplaced:${cell.id}:${String(error).slice(0, 160)}`, 1);
|
|
561
|
+
// Preserve the cell-by-cell recovery contract; the run completion record is withheld at report.
|
|
503
562
|
}
|
|
504
563
|
}
|
|
505
564
|
}
|
|
@@ -762,8 +821,20 @@ export async function runOrchestratorNode(node: Node, deps: TickDeps = {}): Prom
|
|
|
762
821
|
state.nodes[node] = 'skipped';
|
|
763
822
|
}
|
|
764
823
|
observe('tick', state.nodes[node], state.cells.length);
|
|
824
|
+
if (mode === 'live' && node === 'place' && state.boardWrittenAt) {
|
|
825
|
+
// A board write can succeed even when a later cell fails. Advance its clock without claiming run completion.
|
|
826
|
+
const previous = readCompletionHeartbeat(root);
|
|
827
|
+
recordCompletionHeartbeat(root, previous
|
|
828
|
+
? { ...previous, boardWrittenAt: state.boardWrittenAt }
|
|
829
|
+
: { runId, node, window, mode, boardWrittenAt: state.boardWrittenAt, tickCompletedAt: null });
|
|
830
|
+
}
|
|
765
831
|
mkdirSync(dirname(path), { recursive: true });
|
|
766
832
|
writeFileSync(path, JSON.stringify(state));
|
|
833
|
+
if (mode !== 'off' && node === 'report' && !state.placementFailed && !state.placeDegraded) {
|
|
834
|
+
// Only the last node can attest that the whole run completed. Carry the most recent confirmed board write.
|
|
835
|
+
const boardWrittenAt = state.boardWrittenAt ?? readCompletionHeartbeat(root)?.boardWrittenAt ?? null;
|
|
836
|
+
recordCompletionHeartbeat(root, { runId, node, window, mode, boardWrittenAt, tickCompletedAt: currentTime().toISOString() });
|
|
837
|
+
}
|
|
767
838
|
return state;
|
|
768
839
|
} catch (error) {
|
|
769
840
|
nodeError = error;
|
|
@@ -62,7 +62,7 @@ export const FEATURE_MATURITY = {
|
|
|
62
62
|
preview: 'beta', pty: 'beta', pr: 'beta', repo: 'beta', role: 'beta', machine: 'beta', image: 'beta', 'agent-env': 'beta',
|
|
63
63
|
where: 'stable', bench: 'beta', shadow: 'beta', llm: 'beta', storage: 'beta', leader: 'beta',
|
|
64
64
|
pod: 'beta', browser: 'beta', questions: 'beta', usage: 'stable', live: 'beta',
|
|
65
|
-
research: 'beta', release: 'beta', know: 'beta', freeze: 'beta', flow: 'beta', seat: 'beta', hq: 'beta', steward: 'beta', coord: 'beta', context: 'beta', 'model-watch': 'beta', doctor: 'stable',
|
|
65
|
+
research: 'beta', release: 'beta', rfc: 'beta', know: 'beta', freeze: 'beta', flow: 'beta', seat: 'beta', hq: 'beta', steward: 'beta', coord: 'beta', context: 'beta', 'model-watch': 'beta', doctor: 'stable',
|
|
66
66
|
control: 'beta', resources: 'beta', hooks: 'beta', setup: 'stable', start: 'stable',
|
|
67
67
|
grounding: 'beta', graph: 'beta', loop: 'stable', resource: 'beta', card: 'stable', 'launch-head': 'beta',
|
|
68
68
|
a2a: 'beta', plugin: 'stable', make: 'beta', skills: 'beta', connect: 'beta', import: 'beta',
|
|
@@ -0,0 +1,141 @@
|
|
|
1
|
+
import { timingSafeEqual } from 'node:crypto';
|
|
2
|
+
import { canonicalSeatId, type Message, type MsgStore } from './msg-store.js';
|
|
3
|
+
|
|
4
|
+
type Subscriber = { to: string; lastId: number; heartbeatAt: number; replaying: boolean; controller: ReadableStreamDefaultController<Uint8Array> };
|
|
5
|
+
const encoder = new TextEncoder();
|
|
6
|
+
|
|
7
|
+
function cursor(raw: string | null): number {
|
|
8
|
+
if (raw === null) return 0;
|
|
9
|
+
if (!/^(0|[1-9]\d*)$/.test(raw)) throw new Error('invalid message cursor');
|
|
10
|
+
const id = Number(raw);
|
|
11
|
+
if (!Number.isSafeInteger(id)) throw new Error('invalid message cursor');
|
|
12
|
+
return id;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
function authorized(request: Request, token: string): boolean {
|
|
16
|
+
const value = request.headers.get('authorization');
|
|
17
|
+
if (!value?.startsWith('Bearer ')) return false;
|
|
18
|
+
const received = Buffer.from(value.slice(7));
|
|
19
|
+
const expected = Buffer.from(token);
|
|
20
|
+
return expected.length > 0 && received.length === expected.length && timingSafeEqual(received, expected);
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
function addressed(message: Message, to: string): boolean {
|
|
24
|
+
return message.to === to || (message.to === 'ALL' && message.from !== to);
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/** The SQLite ledger remains authoritative; this independent process only accelerates delivery. */
|
|
28
|
+
export function startMsgSseServer(input: {
|
|
29
|
+
store: MsgStore;
|
|
30
|
+
token: string;
|
|
31
|
+
port: number;
|
|
32
|
+
hostname?: string;
|
|
33
|
+
pollMs?: number;
|
|
34
|
+
}): { port: number; stats: () => { subscribers: number; maxQueuedEvents: number }; stop: () => void } {
|
|
35
|
+
if (!input.token) throw new Error('message SSE token is required');
|
|
36
|
+
if (!Number.isSafeInteger(input.port) || input.port < 0 || input.port > 65535) throw new Error('invalid SSE port');
|
|
37
|
+
const hostname = input.hostname ?? '127.0.0.1';
|
|
38
|
+
if (!['127.0.0.1', 'localhost', '::1', '[::1]'].includes(hostname)) throw new Error('message SSE must bind to loopback');
|
|
39
|
+
const pollMs = input.pollMs ?? 250;
|
|
40
|
+
if (!Number.isSafeInteger(pollMs) || pollMs < 1 || pollMs > 500) throw new Error('SSE ledger poll must be 1–500ms');
|
|
41
|
+
const subscribers = new Set<Subscriber>();
|
|
42
|
+
let maxQueuedEvents = 0;
|
|
43
|
+
let scanned = input.store.latestId();
|
|
44
|
+
const send = (subscriber: Subscriber, message: Message) => {
|
|
45
|
+
if (message.id <= subscriber.lastId || !addressed(message, subscriber.to)) return;
|
|
46
|
+
if ((subscriber.controller.desiredSize ?? 0) < -256) throw new Error('slow SSE subscriber; reconnect for ledger replay');
|
|
47
|
+
subscriber.controller.enqueue(encoder.encode(`id: ${message.id}\ndata: ${JSON.stringify(message)}\n\n`));
|
|
48
|
+
maxQueuedEvents = Math.max(maxQueuedEvents, Math.max(0, 1 - (subscriber.controller.desiredSize ?? 0)));
|
|
49
|
+
subscriber.lastId = message.id;
|
|
50
|
+
};
|
|
51
|
+
const replay = (subscriber: Subscriber) => {
|
|
52
|
+
if (!subscriber.replaying) return;
|
|
53
|
+
try {
|
|
54
|
+
while ((subscriber.controller.desiredSize ?? 0) > 0) {
|
|
55
|
+
const batch = input.store.listByRecipient(subscriber.to, subscriber.lastId, 1000);
|
|
56
|
+
if (!batch.length) { subscriber.replaying = false; break; }
|
|
57
|
+
for (const message of batch) {
|
|
58
|
+
if ((subscriber.controller.desiredSize ?? 0) <= 0) break;
|
|
59
|
+
send(subscriber, message);
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
} catch (error) {
|
|
63
|
+
subscribers.delete(subscriber);
|
|
64
|
+
subscriber.controller.error(error);
|
|
65
|
+
}
|
|
66
|
+
};
|
|
67
|
+
const tick = () => {
|
|
68
|
+
try {
|
|
69
|
+
let batch: Message[];
|
|
70
|
+
let scans = 0;
|
|
71
|
+
do {
|
|
72
|
+
batch = input.store.listAfter(scanned);
|
|
73
|
+
for (const message of batch) {
|
|
74
|
+
scanned = message.id;
|
|
75
|
+
for (const subscriber of subscribers) {
|
|
76
|
+
if (subscriber.replaying) continue;
|
|
77
|
+
try { send(subscriber, message); } catch (error) {
|
|
78
|
+
subscribers.delete(subscriber);
|
|
79
|
+
try { subscriber.controller.error(error); } catch { /* already disconnected */ }
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
scans++;
|
|
84
|
+
} while (batch.length === 1000 && scans < 4);
|
|
85
|
+
const now = Date.now();
|
|
86
|
+
for (const subscriber of subscribers) {
|
|
87
|
+
if (subscriber.replaying || (subscriber.controller.desiredSize ?? 0) <= 0) continue;
|
|
88
|
+
if (now - subscriber.heartbeatAt < 1000) continue;
|
|
89
|
+
try {
|
|
90
|
+
subscriber.controller.enqueue(encoder.encode(': keepalive\n\n'));
|
|
91
|
+
subscriber.heartbeatAt = now;
|
|
92
|
+
} catch { subscribers.delete(subscriber); }
|
|
93
|
+
}
|
|
94
|
+
} catch (error) {
|
|
95
|
+
// The next tick retries from the last scanned ID; the durable ledger is unchanged.
|
|
96
|
+
console.error(`msg SSE scan failed: ${error instanceof Error ? error.message : String(error)}`);
|
|
97
|
+
}
|
|
98
|
+
};
|
|
99
|
+
const server = Bun.serve({
|
|
100
|
+
port: input.port,
|
|
101
|
+
hostname,
|
|
102
|
+
fetch(request) {
|
|
103
|
+
const url = new URL(request.url);
|
|
104
|
+
if (url.pathname !== '/stream' || request.method !== 'GET') return new Response('Not Found', { status: 404 });
|
|
105
|
+
if (!authorized(request, input.token)) return new Response('Unauthorized', { status: 401 });
|
|
106
|
+
let to: string;
|
|
107
|
+
let after: number;
|
|
108
|
+
try {
|
|
109
|
+
to = canonicalSeatId(url.searchParams.get('to') ?? '');
|
|
110
|
+
if (to === 'ALL') throw new Error('ALL is not a recipient');
|
|
111
|
+
after = Math.max(cursor(url.searchParams.get('since')), cursor(request.headers.get('last-event-id')));
|
|
112
|
+
} catch (error) { return new Response(error instanceof Error ? error.message : String(error), { status: 400 }); }
|
|
113
|
+
let subscriber: Subscriber;
|
|
114
|
+
const stream = new ReadableStream<Uint8Array>({
|
|
115
|
+
start(controller) {
|
|
116
|
+
subscriber = { to, lastId: after, heartbeatAt: Date.now(), replaying: true, controller };
|
|
117
|
+
subscribers.add(subscriber);
|
|
118
|
+
controller.enqueue(encoder.encode(': connected\n\n'));
|
|
119
|
+
},
|
|
120
|
+
pull() { replay(subscriber); },
|
|
121
|
+
cancel() { subscribers.delete(subscriber); },
|
|
122
|
+
});
|
|
123
|
+
request.signal.addEventListener('abort', () => subscribers.delete(subscriber), { once: true });
|
|
124
|
+
return new Response(stream, { headers: {
|
|
125
|
+
'Content-Type': 'text/event-stream; charset=utf-8',
|
|
126
|
+
'Cache-Control': 'no-cache, no-transform',
|
|
127
|
+
Connection: 'keep-alive',
|
|
128
|
+
} });
|
|
129
|
+
},
|
|
130
|
+
});
|
|
131
|
+
const timer = setInterval(tick, pollMs);
|
|
132
|
+
timer.unref();
|
|
133
|
+
return { port: server.port ?? input.port,
|
|
134
|
+
stats: () => ({ subscribers: subscribers.size, maxQueuedEvents }),
|
|
135
|
+
stop: () => {
|
|
136
|
+
clearInterval(timer);
|
|
137
|
+
for (const subscriber of subscribers) { try { subscriber.controller.close(); } catch { /* disconnected */ } }
|
|
138
|
+
subscribers.clear();
|
|
139
|
+
server.stop(true);
|
|
140
|
+
} };
|
|
141
|
+
}
|
package/src/msg/msg-store.ts
CHANGED
|
@@ -8,6 +8,7 @@ export interface MessageEnvelope {
|
|
|
8
8
|
to: string;
|
|
9
9
|
body: string;
|
|
10
10
|
kind?: string;
|
|
11
|
+
replyTo?: number;
|
|
11
12
|
}
|
|
12
13
|
|
|
13
14
|
export interface Message extends MessageEnvelope {
|
|
@@ -46,8 +47,16 @@ export function validateMessageEnvelope(input: unknown): MessageEnvelope {
|
|
|
46
47
|
if (record.kind !== undefined && (typeof record.kind !== 'string' || !/^[a-z][a-z0-9_-]{0,63}$/.test(record.kind))) {
|
|
47
48
|
throw new Error('invalid message kind');
|
|
48
49
|
}
|
|
49
|
-
|
|
50
|
-
|
|
50
|
+
const replyTo = record.replyTo;
|
|
51
|
+
if (replyTo !== undefined && (typeof replyTo !== 'number' || !Number.isSafeInteger(replyTo) || replyTo < 1)) {
|
|
52
|
+
throw new Error('replyTo must be a positive integer message ID');
|
|
53
|
+
}
|
|
54
|
+
if (Object.keys(record).some(key => !['from', 'to', 'body', 'kind', 'replyTo'].includes(key))) throw new Error('unknown message field');
|
|
55
|
+
return {
|
|
56
|
+
from, to, body: record.body,
|
|
57
|
+
...(record.kind === undefined ? {} : { kind: record.kind as string }),
|
|
58
|
+
...(replyTo === undefined ? {} : { replyTo }),
|
|
59
|
+
};
|
|
51
60
|
}
|
|
52
61
|
|
|
53
62
|
function validCursor(cursor: number): number {
|
|
@@ -68,28 +77,45 @@ interface MessageRow {
|
|
|
68
77
|
recipient: string;
|
|
69
78
|
body: string;
|
|
70
79
|
kind: string | null;
|
|
80
|
+
reply_to: number | null;
|
|
71
81
|
created_at: string;
|
|
72
82
|
}
|
|
73
83
|
|
|
74
84
|
function fromRow(row: MessageRow): Message {
|
|
75
85
|
return { id: row.id, from: row.sender, to: row.recipient, body: row.body,
|
|
76
|
-
...(row.kind === null ? {} : { kind: row.kind }),
|
|
86
|
+
...(row.kind === null ? {} : { kind: row.kind }),
|
|
87
|
+
...(row.reply_to == null ? {} : { replyTo: row.reply_to }),
|
|
88
|
+
createdAt: row.created_at };
|
|
77
89
|
}
|
|
78
90
|
|
|
79
91
|
/** SQLite owns both append order and per-recipient acknowledgement progress. */
|
|
80
92
|
export class MsgStore {
|
|
81
93
|
readonly db: Database;
|
|
82
94
|
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
this.db
|
|
95
|
+
// A read-only SSE connection may outlive another process's in-place migration.
|
|
96
|
+
private get hasReplyTo(): boolean {
|
|
97
|
+
return (this.db.query(`SELECT COUNT(*) AS count FROM pragma_table_info('msg_messages') WHERE name = 'reply_to'`)
|
|
98
|
+
.get() as { count: number }).count > 0;
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
private readWithSchema<T>(read: (hasReplyTo: boolean) => T): T {
|
|
102
|
+
// The schema projection and rows must share one SQLite snapshot. Otherwise a writer can
|
|
103
|
+
// migrate between them and a reader could advance past a reply with its link erased.
|
|
104
|
+
return this.db.transaction(() => read(this.hasReplyTo)).deferred();
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
constructor(path: string = defaultMsgStorePath(), options: { readOnly?: boolean } = {}) {
|
|
108
|
+
if (options.readOnly && (path === ':memory:' || !existsSync(path))) throw new Error('message ledger unavailable for read-only SSE service');
|
|
109
|
+
if (!options.readOnly && path !== ':memory:') mkdirSync(dirname(path), { recursive: true, mode: 0o700 });
|
|
110
|
+
this.db = options.readOnly ? new Database(path, { readonly: true }) : new Database(path);
|
|
86
111
|
this.db.exec('PRAGMA busy_timeout = 5000');
|
|
87
|
-
this.db.exec(`CREATE TABLE IF NOT EXISTS msg_messages (
|
|
112
|
+
if (!options.readOnly) this.db.exec(`CREATE TABLE IF NOT EXISTS msg_messages (
|
|
88
113
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
89
114
|
sender TEXT NOT NULL,
|
|
90
115
|
recipient TEXT NOT NULL,
|
|
91
116
|
body TEXT NOT NULL,
|
|
92
117
|
kind TEXT,
|
|
118
|
+
reply_to INTEGER,
|
|
93
119
|
created_at TEXT NOT NULL
|
|
94
120
|
);
|
|
95
121
|
CREATE INDEX IF NOT EXISTS idx_msg_messages_recipient_id ON msg_messages(recipient, id);
|
|
@@ -101,21 +127,28 @@ export class MsgStore {
|
|
|
101
127
|
provenance_key TEXT PRIMARY KEY,
|
|
102
128
|
message_id INTEGER NOT NULL REFERENCES msg_messages(id)
|
|
103
129
|
);`);
|
|
130
|
+
// Serialize the schema check and ALTER across processes opening an old ledger at once.
|
|
131
|
+
if (!options.readOnly) this.db.transaction(() => {
|
|
132
|
+
const replyToColumn = (this.db.query(`SELECT COUNT(*) AS count FROM pragma_table_info('msg_messages') WHERE name = 'reply_to'`)
|
|
133
|
+
.get() as { count: number }).count;
|
|
134
|
+
if (replyToColumn === 0) this.db.exec('ALTER TABLE msg_messages ADD COLUMN reply_to INTEGER');
|
|
135
|
+
this.db.exec('CREATE INDEX IF NOT EXISTS idx_msg_messages_reply_to ON msg_messages(reply_to, id)');
|
|
136
|
+
}).immediate();
|
|
104
137
|
}
|
|
105
138
|
|
|
106
139
|
append(input: MessageEnvelope): Message {
|
|
107
140
|
return this.insertMessage(input);
|
|
108
141
|
}
|
|
109
142
|
|
|
110
|
-
/** Append once for a
|
|
143
|
+
/** Append once for a coordination comment or a task failure incident. */
|
|
111
144
|
appendWithProvenance(input: MessageEnvelope, provenanceKey: string): Message {
|
|
112
|
-
if (typeof provenanceKey !== 'string' || !/^coord:[^:]+:[^:]+$/.test(provenanceKey)) {
|
|
145
|
+
if (typeof provenanceKey !== 'string' || !/^(?:coord|ta-failure):[^:]+:[^:]+$/.test(provenanceKey)) {
|
|
113
146
|
throw new Error('invalid message provenance key');
|
|
114
147
|
}
|
|
115
148
|
const msg = validateMessageEnvelope(input);
|
|
116
149
|
if (provenanceKey.split(':')[2] !== msg.to) throw new Error('provenance recipient does not match message recipient');
|
|
117
150
|
return this.db.transaction(() => {
|
|
118
|
-
const existing = this.db.query(`SELECT m.id, m.sender, m.recipient, m.body, m.kind, m.created_at
|
|
151
|
+
const existing = this.db.query(`SELECT m.id, m.sender, m.recipient, m.body, m.kind, m.reply_to, m.created_at
|
|
119
152
|
FROM msg_provenance p JOIN msg_messages m ON m.id = p.message_id WHERE p.provenance_key = ?`)
|
|
120
153
|
.get(provenanceKey) as MessageRow | null;
|
|
121
154
|
if (existing) return fromRow(existing);
|
|
@@ -128,9 +161,12 @@ export class MsgStore {
|
|
|
128
161
|
|
|
129
162
|
private insertMessage(input: MessageEnvelope): Message {
|
|
130
163
|
const msg = validateMessageEnvelope(input);
|
|
164
|
+
if (msg.replyTo !== undefined && this.db.query('SELECT 1 FROM msg_messages WHERE id = ?').get(msg.replyTo) === null) {
|
|
165
|
+
throw new Error(`reply target not found: #${msg.replyTo}`);
|
|
166
|
+
}
|
|
131
167
|
const createdAt = new Date().toISOString();
|
|
132
|
-
const result = this.db.query(`INSERT INTO msg_messages(sender, recipient, body, kind, created_at)
|
|
133
|
-
VALUES (?, ?, ?, ?, ?)`).run(msg.from, msg.to, msg.body, msg.kind ?? null, createdAt);
|
|
168
|
+
const result = this.db.query(`INSERT INTO msg_messages(sender, recipient, body, kind, reply_to, created_at)
|
|
169
|
+
VALUES (?, ?, ?, ?, ?, ?)`).run(msg.from, msg.to, msg.body, msg.kind ?? null, msg.replyTo ?? null, createdAt);
|
|
134
170
|
return { id: Number(result.lastInsertRowid), ...msg, createdAt };
|
|
135
171
|
}
|
|
136
172
|
|
|
@@ -142,15 +178,48 @@ export class MsgStore {
|
|
|
142
178
|
if (to === 'ALL') throw new Error('ALL is a topic, not a recipient');
|
|
143
179
|
validCursor(after);
|
|
144
180
|
if (!Number.isSafeInteger(limit) || limit < 1 || limit > 1000) throw new Error('limit must be between 1 and 1000');
|
|
145
|
-
return (this.db.query(`SELECT id, sender, recipient, body, kind, created_at FROM msg_messages
|
|
181
|
+
return this.readWithSchema(hasReplyTo => (this.db.query(`SELECT id, sender, recipient, body, kind, ${hasReplyTo ? 'reply_to' : 'NULL AS reply_to'}, created_at FROM msg_messages
|
|
146
182
|
WHERE id > ? AND (recipient = ? OR (recipient = 'ALL' AND sender <> ?))
|
|
147
|
-
ORDER BY id ASC LIMIT ?`).all(after, to, to, limit) as MessageRow[]).map(fromRow);
|
|
183
|
+
ORDER BY id ASC LIMIT ?`).all(after, to, to, limit) as MessageRow[]).map(fromRow));
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
/** One message by durable ID, or null when absent. */
|
|
187
|
+
getMessage(messageId: number): Message | null {
|
|
188
|
+
if (!Number.isSafeInteger(messageId) || messageId < 1) throw new Error('message ID must be a positive integer');
|
|
189
|
+
return this.readWithSchema(hasReplyTo => {
|
|
190
|
+
const row = this.db.query(`SELECT id, sender, recipient, body, kind, ${hasReplyTo ? 'reply_to' : 'NULL AS reply_to'}, created_at FROM msg_messages
|
|
191
|
+
WHERE id = ?`).get(messageId) as MessageRow | null;
|
|
192
|
+
return row === null ? null : fromRow(row);
|
|
193
|
+
});
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
/** Replies pointing at the supplied message, ordered by durable insertion ID. */
|
|
197
|
+
listReplies(messageId: number, limit: number = 100, afterId: number = 0): Message[] {
|
|
198
|
+
if (!Number.isSafeInteger(messageId) || messageId < 1) throw new Error('message ID must be a positive integer');
|
|
199
|
+
if (!Number.isSafeInteger(limit) || limit < 1 || limit > 1000) throw new Error('limit must be between 1 and 1000');
|
|
200
|
+
validCursor(afterId);
|
|
201
|
+
return this.readWithSchema(hasReplyTo => hasReplyTo
|
|
202
|
+
? (this.db.query(`SELECT id, sender, recipient, body, kind, reply_to, created_at FROM msg_messages
|
|
203
|
+
WHERE reply_to = ? AND id > ? ORDER BY id ASC LIMIT ?`).all(messageId, afterId, limit) as MessageRow[]).map(fromRow)
|
|
204
|
+
: []);
|
|
148
205
|
}
|
|
149
206
|
|
|
150
207
|
list(recipient: string, after: number = 0, limit: number = 100): Message[] {
|
|
151
208
|
return this.listByRecipient(recipient, after, limit);
|
|
152
209
|
}
|
|
153
210
|
|
|
211
|
+
/** One ordered scan for every SSE subscriber; recipient filtering happens in the server. */
|
|
212
|
+
listAfter(after: number, limit: number = 1000): Message[] {
|
|
213
|
+
validCursor(after);
|
|
214
|
+
if (!Number.isSafeInteger(limit) || limit < 1 || limit > 1000) throw new Error('limit must be between 1 and 1000');
|
|
215
|
+
return this.readWithSchema(hasReplyTo => (this.db.query(`SELECT id, sender, recipient, body, kind, ${hasReplyTo ? 'reply_to' : 'NULL AS reply_to'}, created_at FROM msg_messages
|
|
216
|
+
WHERE id > ? ORDER BY id ASC LIMIT ?`).all(after, limit) as MessageRow[]).map(fromRow));
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
latestId(): number {
|
|
220
|
+
return (this.db.query('SELECT COALESCE(MAX(id), 0) AS id FROM msg_messages').get() as { id: number }).id;
|
|
221
|
+
}
|
|
222
|
+
|
|
154
223
|
getCursor(recipient: string): number {
|
|
155
224
|
const to = canonicalSeatId(recipient);
|
|
156
225
|
if (to === 'ALL') throw new Error('ALL is a topic, not a recipient');
|
|
@@ -199,6 +268,7 @@ export class MsgStore {
|
|
|
199
268
|
|
|
200
269
|
export function defaultMsgStorePath(): string { return join(elanousStateRoot(), 'msg', 'messages.db'); }
|
|
201
270
|
export function openMsgStore(path?: string): MsgStore { return new MsgStore(path); }
|
|
271
|
+
export function openMsgStoreReadOnly(path?: string): MsgStore { return new MsgStore(path, { readOnly: true }); }
|
|
202
272
|
|
|
203
273
|
/** Observe one recipient without creating a store or advancing its acknowledgement. */
|
|
204
274
|
export function readUnreadCount(recipient: string, path: string = defaultMsgStorePath()): number | null {
|