@plurnk/plurnk-service 1.3.12 → 1.5.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.env.defaults +95 -91
- package/INSTALL.md +54 -28
- package/README.md +25 -10
- package/SPEC.md +2426 -942
- package/dist/Paths.d.ts +4 -1
- package/dist/Paths.d.ts.map +1 -1
- package/dist/Paths.js +24 -23
- package/dist/Paths.js.map +1 -1
- package/dist/build-info.json +1 -1
- package/dist/content/edit-receipt.d.ts +13 -28
- package/dist/content/edit-receipt.d.ts.map +1 -1
- package/dist/content/edit-receipt.js +285 -104
- package/dist/content/edit-receipt.js.map +1 -1
- package/dist/content/edited-span.d.ts.map +1 -1
- package/dist/content/edited-span.js +2 -5
- package/dist/content/edited-span.js.map +1 -1
- package/dist/content/index.d.ts +4 -3
- package/dist/content/index.d.ts.map +1 -1
- package/dist/content/index.js +2 -1
- package/dist/content/index.js.map +1 -1
- package/dist/content/line-marker.d.ts +10 -9
- package/dist/content/line-marker.d.ts.map +1 -1
- package/dist/content/line-marker.js +8 -11
- package/dist/content/line-marker.js.map +1 -1
- package/dist/content/matcher.d.ts +15 -26
- package/dist/content/matcher.d.ts.map +1 -1
- package/dist/content/matcher.js +77 -107
- package/dist/content/matcher.js.map +1 -1
- package/dist/content/mimetype-binary.d.ts +2 -2
- package/dist/content/mimetype-binary.d.ts.map +1 -1
- package/dist/content/mimetype-binary.js +10 -10
- package/dist/content/mimetype-binary.js.map +1 -1
- package/dist/content/path-mimetype.js +3 -3
- package/dist/content/path-mimetype.js.map +1 -1
- package/dist/content/read-projector.d.ts +14 -0
- package/dist/content/read-projector.d.ts.map +1 -0
- package/dist/content/read-projector.js +86 -0
- package/dist/content/read-projector.js.map +1 -0
- package/dist/content/read-resolve.d.ts +6 -7
- package/dist/content/read-resolve.d.ts.map +1 -1
- package/dist/content/read-resolve.js +36 -59
- package/dist/content/read-resolve.js.map +1 -1
- package/dist/core/BranchReceipt.d.ts +6 -0
- package/dist/core/BranchReceipt.d.ts.map +1 -0
- package/dist/core/BranchReceipt.js +24 -0
- package/dist/core/BranchReceipt.js.map +1 -0
- package/dist/core/BudgetOverflow.d.ts +20 -0
- package/dist/core/BudgetOverflow.d.ts.map +1 -0
- package/dist/core/BudgetOverflow.js +49 -0
- package/dist/core/BudgetOverflow.js.map +1 -0
- package/dist/core/BudgetReadout.d.ts +8 -0
- package/dist/core/BudgetReadout.d.ts.map +1 -0
- package/dist/core/BudgetReadout.js +79 -0
- package/dist/core/BudgetReadout.js.map +1 -0
- package/dist/core/ChannelWrite.d.ts +31 -9
- package/dist/core/ChannelWrite.d.ts.map +1 -1
- package/dist/core/ChannelWrite.js +30 -23
- package/dist/core/ChannelWrite.js.map +1 -1
- package/dist/core/ChannelWrite.sql +14 -12
- package/dist/core/CoreSchemeServices.d.ts +18 -4
- package/dist/core/CoreSchemeServices.d.ts.map +1 -1
- package/dist/core/CoreSchemeServices.js +7 -1
- package/dist/core/CoreSchemeServices.js.map +1 -1
- package/dist/core/Dispatcher.d.ts +26 -21
- package/dist/core/Dispatcher.d.ts.map +1 -1
- package/dist/core/Dispatcher.js +1626 -671
- package/dist/core/Dispatcher.js.map +1 -1
- package/dist/core/DurableStatement.d.ts +6 -0
- package/dist/core/DurableStatement.d.ts.map +1 -0
- package/dist/core/DurableStatement.js +51 -0
- package/dist/core/DurableStatement.js.map +1 -0
- package/dist/core/Engine.d.ts +64 -29
- package/dist/core/Engine.d.ts.map +1 -1
- package/dist/core/Engine.js +1348 -835
- package/dist/core/Engine.js.map +1 -1
- package/dist/core/Engine.sql +329 -234
- package/dist/core/EnvFlags.js +1 -1
- package/dist/core/EnvFlags.js.map +1 -1
- package/dist/core/ErrorDetail.d.ts +6 -0
- package/dist/core/ErrorDetail.d.ts.map +1 -0
- package/dist/core/ErrorDetail.js +20 -0
- package/dist/core/ErrorDetail.js.map +1 -0
- package/dist/core/ExecutorRegistry.d.ts +14 -3
- package/dist/core/ExecutorRegistry.d.ts.map +1 -1
- package/dist/core/ExecutorRegistry.js +56 -30
- package/dist/core/ExecutorRegistry.js.map +1 -1
- package/dist/core/GitBranch.d.ts +20 -0
- package/dist/core/GitBranch.d.ts.map +1 -0
- package/dist/core/GitBranch.js +110 -0
- package/dist/core/GitBranch.js.map +1 -0
- package/dist/core/JournalTurn.d.ts +9 -0
- package/dist/core/JournalTurn.d.ts.map +1 -0
- package/dist/core/JournalTurn.js +14 -0
- package/dist/core/JournalTurn.js.map +1 -0
- package/dist/core/JournalTurn.sql +10 -0
- package/dist/core/LogBody.d.ts +18 -0
- package/dist/core/LogBody.d.ts.map +1 -0
- package/dist/core/LogBody.js +180 -0
- package/dist/core/LogBody.js.map +1 -0
- package/dist/core/LoopFlagsReader.d.ts +7 -0
- package/dist/core/LoopFlagsReader.d.ts.map +1 -0
- package/dist/core/LoopFlagsReader.js +33 -0
- package/dist/core/LoopFlagsReader.js.map +1 -0
- package/dist/core/LoopLifecycle.d.ts +8 -2
- package/dist/core/LoopLifecycle.d.ts.map +1 -1
- package/dist/core/LoopLifecycle.js +54 -11
- package/dist/core/LoopLifecycle.js.map +1 -1
- package/dist/core/LoopLifecycle.sql +10 -7
- package/dist/core/NoticeChannel.d.ts +13 -0
- package/dist/core/NoticeChannel.d.ts.map +1 -0
- package/dist/core/NoticeChannel.js +40 -0
- package/dist/core/NoticeChannel.js.map +1 -0
- package/dist/core/Owner.d.ts +0 -1
- package/dist/core/Owner.d.ts.map +1 -1
- package/dist/core/Owner.js +2 -5
- package/dist/core/Owner.js.map +1 -1
- package/dist/core/PacketBuilder.d.ts +27 -25
- package/dist/core/PacketBuilder.d.ts.map +1 -1
- package/dist/core/PacketBuilder.js +321 -383
- package/dist/core/PacketBuilder.js.map +1 -1
- package/dist/core/ProblemLog.d.ts +26 -0
- package/dist/core/ProblemLog.d.ts.map +1 -0
- package/dist/core/ProblemLog.js +58 -0
- package/dist/core/ProblemLog.js.map +1 -0
- package/dist/core/ProposalLifecycle.d.ts +15 -21
- package/dist/core/ProposalLifecycle.d.ts.map +1 -1
- package/dist/core/ProposalLifecycle.js +291 -78
- package/dist/core/ProposalLifecycle.js.map +1 -1
- package/dist/core/ProviderInstantiate.d.ts.map +1 -1
- package/dist/core/ProviderInstantiate.js +51 -48
- package/dist/core/ProviderInstantiate.js.map +1 -1
- package/dist/core/SchemeRegistry.d.ts +14 -8
- package/dist/core/SchemeRegistry.d.ts.map +1 -1
- package/dist/core/SchemeRegistry.js +189 -114
- package/dist/core/SchemeRegistry.js.map +1 -1
- package/dist/core/ServiceTeardown.d.ts +11 -0
- package/dist/core/ServiceTeardown.d.ts.map +1 -0
- package/dist/core/ServiceTeardown.js +70 -0
- package/dist/core/ServiceTeardown.js.map +1 -0
- package/dist/core/StoredPacket.d.ts +29 -0
- package/dist/core/StoredPacket.d.ts.map +1 -0
- package/dist/core/StoredPacket.js +147 -0
- package/dist/core/StoredPacket.js.map +1 -0
- package/dist/core/StrikeRail.d.ts +6 -3
- package/dist/core/StrikeRail.d.ts.map +1 -1
- package/dist/core/StrikeRail.js +27 -56
- package/dist/core/StrikeRail.js.map +1 -1
- package/dist/core/TerminalResult.d.ts +16 -0
- package/dist/core/TerminalResult.d.ts.map +1 -0
- package/dist/core/TerminalResult.js +49 -0
- package/dist/core/TerminalResult.js.map +1 -0
- package/dist/core/WorkerControlAddress.d.ts +17 -0
- package/dist/core/WorkerControlAddress.d.ts.map +1 -0
- package/dist/core/WorkerControlAddress.js +44 -0
- package/dist/core/WorkerControlAddress.js.map +1 -0
- package/dist/core/WorkerName.d.ts +31 -0
- package/dist/core/WorkerName.d.ts.map +1 -0
- package/dist/core/WorkerName.js +95 -0
- package/dist/core/WorkerName.js.map +1 -0
- package/dist/core/WorkerName.sql +36 -0
- package/dist/core/WorkspaceGate.d.ts +14 -0
- package/dist/core/WorkspaceGate.d.ts.map +1 -0
- package/dist/core/WorkspaceGate.js +155 -0
- package/dist/core/WorkspaceGate.js.map +1 -0
- package/dist/core/caps/CapsResolve.d.ts +5 -1
- package/dist/core/caps/CapsResolve.d.ts.map +1 -1
- package/dist/core/caps/CapsResolve.js +8 -4
- package/dist/core/caps/CapsResolve.js.map +1 -1
- package/dist/core/caps/DbChannelCaps.d.ts +5 -11
- package/dist/core/caps/DbChannelCaps.d.ts.map +1 -1
- package/dist/core/caps/DbChannelCaps.js +30 -12
- package/dist/core/caps/DbChannelCaps.js.map +1 -1
- package/dist/core/caps/DbEntryCaps.d.ts +5 -14
- package/dist/core/caps/DbEntryCaps.d.ts.map +1 -1
- package/dist/core/caps/DbEntryCaps.js +27 -8
- package/dist/core/caps/DbEntryCaps.js.map +1 -1
- package/dist/core/caps/DbNotifyCaps.d.ts +1 -1
- package/dist/core/caps/DbNotifyCaps.d.ts.map +1 -1
- package/dist/core/caps/DbNotifyCaps.js +18 -15
- package/dist/core/caps/DbNotifyCaps.js.map +1 -1
- package/dist/core/caps/DbProjectionCaps.d.ts +5 -5
- package/dist/core/caps/DbProjectionCaps.d.ts.map +1 -1
- package/dist/core/caps/DbProjectionCaps.js +21 -4
- package/dist/core/caps/DbProjectionCaps.js.map +1 -1
- package/dist/core/caps/DbSubscriptionCaps.d.ts +4 -6
- package/dist/core/caps/DbSubscriptionCaps.d.ts.map +1 -1
- package/dist/core/caps/DbSubscriptionCaps.js +75 -66
- package/dist/core/caps/DbSubscriptionCaps.js.map +1 -1
- package/dist/core/caps/DbTagCaps.d.ts +5 -12
- package/dist/core/caps/DbTagCaps.d.ts.map +1 -1
- package/dist/core/caps/DbTagCaps.js +20 -11
- package/dist/core/caps/DbTagCaps.js.map +1 -1
- package/dist/core/caps/SchemeCtxImpl.d.ts +6 -1
- package/dist/core/caps/SchemeCtxImpl.d.ts.map +1 -1
- package/dist/core/caps/SchemeCtxImpl.js +6 -6
- package/dist/core/caps/SchemeCtxImpl.js.map +1 -1
- package/dist/core/content-hash.js +1 -1
- package/dist/core/content-hash.js.map +1 -1
- package/dist/core/env-defaults.d.ts.map +1 -1
- package/dist/core/env-defaults.js.map +1 -1
- package/dist/core/fork.d.ts.map +1 -1
- package/dist/core/fork.js +55 -30
- package/dist/core/fork.js.map +1 -1
- package/dist/core/fork.sql +43 -29
- package/dist/core/git-env.d.ts +2 -0
- package/dist/core/git-env.d.ts.map +1 -1
- package/dist/core/git-env.js +13 -18
- package/dist/core/git-env.js.map +1 -1
- package/dist/core/git-iso.d.ts +3 -2
- package/dist/core/git-iso.d.ts.map +1 -1
- package/dist/core/git-iso.js +36 -27
- package/dist/core/git-iso.js.map +1 -1
- package/dist/core/git-membership.d.ts +2 -1
- package/dist/core/git-membership.d.ts.map +1 -1
- package/dist/core/git-membership.js +276 -162
- package/dist/core/git-membership.js.map +1 -1
- package/dist/core/git-state.d.ts +8 -1
- package/dist/core/git-state.d.ts.map +1 -1
- package/dist/core/git-state.js +44 -26
- package/dist/core/git-state.js.map +1 -1
- package/dist/core/namespace.d.ts +1 -0
- package/dist/core/namespace.d.ts.map +1 -1
- package/dist/core/namespace.js +20 -0
- package/dist/core/namespace.js.map +1 -1
- package/dist/core/optimistic-settlement.d.ts +2 -0
- package/dist/core/optimistic-settlement.d.ts.map +1 -0
- package/dist/core/optimistic-settlement.js +14 -0
- package/dist/core/optimistic-settlement.js.map +1 -0
- package/dist/core/owner.sql +1 -1
- package/dist/core/packet-inject.d.ts.map +1 -1
- package/dist/core/packet-inject.js +7 -11
- package/dist/core/packet-inject.js.map +1 -1
- package/dist/core/packet-wire.d.ts +3 -21
- package/dist/core/packet-wire.d.ts.map +1 -1
- package/dist/core/packet-wire.js +305 -385
- package/dist/core/packet-wire.js.map +1 -1
- package/dist/core/plurnk-uri.d.ts +12 -1
- package/dist/core/plurnk-uri.d.ts.map +1 -1
- package/dist/core/plurnk-uri.js +49 -29
- package/dist/core/plurnk-uri.js.map +1 -1
- package/dist/core/results.d.ts +18 -5
- package/dist/core/results.d.ts.map +1 -1
- package/dist/core/results.js +33 -18
- package/dist/core/results.js.map +1 -1
- package/dist/core/ruler_count.d.ts +4 -0
- package/dist/core/ruler_count.d.ts.map +1 -0
- package/dist/core/ruler_count.js +7 -0
- package/dist/core/ruler_count.js.map +1 -0
- package/dist/core/scheme-types.d.ts +3 -17
- package/dist/core/scheme-types.d.ts.map +1 -1
- package/dist/core/scheme-types.js.map +1 -1
- package/dist/core/search-gate.d.ts.map +1 -1
- package/dist/core/search-gate.js +3 -11
- package/dist/core/search-gate.js.map +1 -1
- package/dist/core/teaching-corpus.d.ts +6 -0
- package/dist/core/teaching-corpus.d.ts.map +1 -0
- package/dist/core/teaching-corpus.js +23 -0
- package/dist/core/teaching-corpus.js.map +1 -0
- package/dist/core/teaching.d.ts +0 -1
- package/dist/core/teaching.d.ts.map +1 -1
- package/dist/core/teaching.js +2 -8
- package/dist/core/teaching.js.map +1 -1
- package/dist/core/token-ruler.d.ts.map +1 -1
- package/dist/core/token-ruler.js +3 -16
- package/dist/core/token-ruler.js.map +1 -1
- package/dist/core/turn-scheduler.d.ts +1 -1
- package/dist/core/turn-scheduler.d.ts.map +1 -1
- package/dist/core/turn-scheduler.js +1 -1
- package/dist/core/turn-scheduler.js.map +1 -1
- package/dist/core/worker-cap.d.ts +2 -4
- package/dist/core/worker-cap.d.ts.map +1 -1
- package/dist/core/worker-cap.js +13 -4
- package/dist/core/worker-cap.js.map +1 -1
- package/dist/core/{run-ops.sql → worker-ops.sql} +14 -14
- package/dist/core/workspace-settings.d.ts.map +1 -1
- package/dist/core/workspace-settings.js +17 -9
- package/dist/core/workspace-settings.js.map +1 -1
- package/dist/digest/Digest.d.ts +2 -1
- package/dist/digest/Digest.d.ts.map +1 -1
- package/dist/digest/Digest.js +510 -149
- package/dist/digest/Digest.js.map +1 -1
- package/dist/digest/digest.sql +23 -10
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +3 -5
- package/dist/index.js.map +1 -1
- package/dist/observe/api.d.ts +6 -0
- package/dist/observe/api.d.ts.map +1 -0
- package/dist/observe/api.js +11 -0
- package/dist/observe/api.js.map +1 -0
- package/dist/observe/init.d.ts +7 -0
- package/dist/observe/init.d.ts.map +1 -0
- package/dist/observe/init.js +159 -0
- package/dist/observe/init.js.map +1 -0
- package/dist/observe/metrics.d.ts +6 -0
- package/dist/observe/metrics.d.ts.map +1 -0
- package/dist/observe/metrics.js +15 -0
- package/dist/observe/metrics.js.map +1 -0
- package/dist/observe/spans.d.ts +4 -0
- package/dist/observe/spans.d.ts.map +1 -0
- package/dist/observe/spans.js +51 -0
- package/dist/observe/spans.js.map +1 -0
- package/dist/schemes/EffectPolicy.d.ts +1 -0
- package/dist/schemes/EffectPolicy.d.ts.map +1 -1
- package/dist/schemes/EffectPolicy.js +5 -2
- package/dist/schemes/EffectPolicy.js.map +1 -1
- package/dist/schemes/Exec.d.ts +9 -14
- package/dist/schemes/Exec.d.ts.map +1 -1
- package/dist/schemes/Exec.js +402 -262
- package/dist/schemes/Exec.js.map +1 -1
- package/dist/schemes/ExecOutputScheme.d.ts +9 -9
- package/dist/schemes/ExecOutputScheme.d.ts.map +1 -1
- package/dist/schemes/ExecOutputScheme.js +49 -18
- package/dist/schemes/ExecOutputScheme.js.map +1 -1
- package/dist/schemes/File.d.ts +7 -13
- package/dist/schemes/File.d.ts.map +1 -1
- package/dist/schemes/File.js +280 -151
- package/dist/schemes/File.js.map +1 -1
- package/dist/schemes/Log.d.ts +8 -12
- package/dist/schemes/Log.d.ts.map +1 -1
- package/dist/schemes/Log.js +402 -233
- package/dist/schemes/Log.js.map +1 -1
- package/dist/schemes/Log.sql +71 -32
- package/dist/schemes/Prompt.d.ts +3 -3
- package/dist/schemes/Prompt.d.ts.map +1 -1
- package/dist/schemes/Prompt.js +10 -10
- package/dist/schemes/Prompt.js.map +1 -1
- package/dist/schemes/Skill.d.ts +2 -3
- package/dist/schemes/Skill.d.ts.map +1 -1
- package/dist/schemes/Skill.js +2 -6
- package/dist/schemes/Skill.js.map +1 -1
- package/dist/schemes/Worker.d.ts +11 -19
- package/dist/schemes/Worker.d.ts.map +1 -1
- package/dist/schemes/Worker.js +210 -89
- package/dist/schemes/Worker.js.map +1 -1
- package/dist/schemes/_entry-chunk.d.ts.map +1 -1
- package/dist/schemes/_entry-chunk.js +12 -5
- package/dist/schemes/_entry-chunk.js.map +1 -1
- package/dist/schemes/_entry-crud.d.ts +11 -9
- package/dist/schemes/_entry-crud.d.ts.map +1 -1
- package/dist/schemes/_entry-crud.js +72 -13
- package/dist/schemes/_entry-crud.js.map +1 -1
- package/dist/schemes/_entry-crud.sql +28 -10
- package/dist/schemes/_entry-find.d.ts +49 -18
- package/dist/schemes/_entry-find.d.ts.map +1 -1
- package/dist/schemes/_entry-find.js +348 -195
- package/dist/schemes/_entry-find.js.map +1 -1
- package/dist/schemes/_entry-find.sql +20 -8
- package/dist/schemes/_entry-graph.d.ts +4 -2
- package/dist/schemes/_entry-graph.d.ts.map +1 -1
- package/dist/schemes/_entry-graph.js +55 -31
- package/dist/schemes/_entry-graph.js.map +1 -1
- package/dist/schemes/_entry-graph.sql +37 -22
- package/dist/schemes/_entry-manifest.d.ts +10 -15
- package/dist/schemes/_entry-manifest.d.ts.map +1 -1
- package/dist/schemes/_entry-manifest.js +50 -268
- package/dist/schemes/_entry-manifest.js.map +1 -1
- package/dist/schemes/_entry-ops.d.ts +14 -15
- package/dist/schemes/_entry-ops.d.ts.map +1 -1
- package/dist/schemes/_entry-ops.js +121 -110
- package/dist/schemes/_entry-ops.js.map +1 -1
- package/dist/schemes/_entry-ops.sql +2 -3
- package/dist/schemes/_entry-semantic.d.ts +22 -9
- package/dist/schemes/_entry-semantic.d.ts.map +1 -1
- package/dist/schemes/_entry-semantic.js +127 -67
- package/dist/schemes/_entry-semantic.js.map +1 -1
- package/dist/schemes/_entry-semantic.sql +63 -45
- package/dist/schemes/_entry-send.d.ts +3 -4
- package/dist/schemes/_entry-send.d.ts.map +1 -1
- package/dist/schemes/_entry-send.js +48 -23
- package/dist/schemes/_entry-send.js.map +1 -1
- package/dist/schemes/_path-scope.d.ts +23 -0
- package/dist/schemes/_path-scope.d.ts.map +1 -0
- package/dist/schemes/_path-scope.js +61 -0
- package/dist/schemes/_path-scope.js.map +1 -0
- package/dist/schemes/_search-candidate.d.ts +20 -0
- package/dist/schemes/_search-candidate.d.ts.map +1 -0
- package/dist/schemes/_search-candidate.js +19 -0
- package/dist/schemes/_search-candidate.js.map +1 -0
- package/dist/schemes/_search-exclusion.d.ts +7 -0
- package/dist/schemes/_search-exclusion.d.ts.map +1 -0
- package/dist/schemes/_search-exclusion.js +23 -0
- package/dist/schemes/_search-exclusion.js.map +1 -0
- package/dist/schemes/_search-index.d.ts +6 -0
- package/dist/schemes/_search-index.d.ts.map +1 -0
- package/dist/schemes/_search-index.js +328 -0
- package/dist/schemes/_search-index.js.map +1 -0
- package/dist/schemes/cosine.js +1 -1
- package/dist/schemes/cosine.js.map +1 -1
- package/dist/schemes/exec-abort.js +3 -3
- package/dist/schemes/exec-abort.js.map +1 -1
- package/dist/schemes/exec-env.js +3 -3
- package/dist/schemes/exec-env.js.map +1 -1
- package/dist/server/BranchBatches.d.ts +49 -0
- package/dist/server/BranchBatches.d.ts.map +1 -0
- package/dist/server/BranchBatches.js +592 -0
- package/dist/server/BranchBatches.js.map +1 -0
- package/dist/server/Daemon.d.ts +39 -71
- package/dist/server/Daemon.d.ts.map +1 -1
- package/dist/server/Daemon.js +1133 -491
- package/dist/server/Daemon.js.map +1 -1
- package/dist/server/DaemonModule.d.ts +31 -0
- package/dist/server/DaemonModule.d.ts.map +1 -0
- package/dist/server/DaemonModule.js +2 -0
- package/dist/server/DaemonModule.js.map +1 -0
- package/dist/server/branch-batch.sql +154 -0
- package/dist/server/client-input.d.ts +13 -0
- package/dist/server/client-input.d.ts.map +1 -1
- package/dist/server/client-input.js +268 -57
- package/dist/server/client-input.js.map +1 -1
- package/dist/server/dispatch-as-plurnk.d.ts +1 -1
- package/dist/server/dispatch-as-plurnk.d.ts.map +1 -1
- package/dist/server/dispatch-as-plurnk.js +15 -7
- package/dist/server/dispatch-as-plurnk.js.map +1 -1
- package/dist/server/drain.sql +96 -65
- package/dist/server/envelope.d.ts +4 -7
- package/dist/server/envelope.d.ts.map +1 -1
- package/dist/server/envelope.js +118 -95
- package/dist/server/envelope.js.map +1 -1
- package/dist/server/envelope.sql +18 -23
- package/dist/server/exec-poll-backoff.js +1 -1
- package/dist/server/exec-poll-backoff.js.map +1 -1
- package/dist/server/lifecycle-recovery.sql +112 -2
- package/dist/server/logEntry.d.ts +4 -2
- package/dist/server/logEntry.d.ts.map +1 -1
- package/dist/server/logEntry.js +6 -3
- package/dist/server/logEntry.js.map +1 -1
- package/dist/server/logEntry.sql +1 -1
- package/dist/server/loop-model.d.ts.map +1 -1
- package/dist/server/loop-model.js +22 -9
- package/dist/server/loop-model.js.map +1 -1
- package/dist/server/loopDocs.d.ts.map +1 -1
- package/dist/server/loopDocs.js +5 -4
- package/dist/server/loopDocs.js.map +1 -1
- package/dist/server/seam-entry-read.sql +15 -10
- package/dist/server/seam-log-read.sql +2 -2
- package/dist/server/seam-loop.sql +1 -1
- package/dist/server/seam-proposal-list.sql +17 -7
- package/dist/service.d.ts.map +1 -1
- package/dist/service.js +79 -61
- package/dist/service.js.map +1 -1
- package/migrations/001_schema.sql +1010 -0
- package/package.json +65 -32
- package/dist/core/PluginLoader.d.ts +0 -20
- package/dist/core/PluginLoader.d.ts.map +0 -1
- package/dist/core/PluginLoader.js +0 -142
- package/dist/core/PluginLoader.js.map +0 -1
- package/dist/core/TelemetryChannel.d.ts +0 -37
- package/dist/core/TelemetryChannel.d.ts.map +0 -1
- package/dist/core/TelemetryChannel.js +0 -80
- package/dist/core/TelemetryChannel.js.map +0 -1
- package/dist/core/path-decode.d.ts +0 -3
- package/dist/core/path-decode.d.ts.map +0 -1
- package/dist/core/path-decode.js +0 -9
- package/dist/core/path-decode.js.map +0 -1
- package/dist/core/plugin-attribution.d.ts +0 -5
- package/dist/core/plugin-attribution.d.ts.map +0 -1
- package/dist/core/plugin-attribution.js +0 -39
- package/dist/core/plugin-attribution.js.map +0 -1
- package/dist/core/world-state.d.ts +0 -10
- package/dist/core/world-state.d.ts.map +0 -1
- package/dist/core/world-state.js +0 -35
- package/dist/core/world-state.js.map +0 -1
- package/dist/core/world-state.sql +0 -35
- package/dist/core/zero-pin.d.ts +0 -3
- package/dist/core/zero-pin.d.ts.map +0 -1
- package/dist/core/zero-pin.js +0 -16
- package/dist/core/zero-pin.js.map +0 -1
- package/dist/server/auto.d.ts +0 -6
- package/dist/server/auto.d.ts.map +0 -1
- package/dist/server/auto.js +0 -63
- package/dist/server/auto.js.map +0 -1
- package/dist/server/clientTurn.d.ts +0 -6
- package/dist/server/clientTurn.d.ts.map +0 -1
- package/dist/server/clientTurn.js +0 -22
- package/dist/server/clientTurn.js.map +0 -1
- package/dist/server/clientTurn.sql +0 -10
- package/dist/server/noProposals.d.ts +0 -6
- package/dist/server/noProposals.d.ts.map +0 -1
- package/dist/server/noProposals.js +0 -37
- package/dist/server/noProposals.js.map +0 -1
- package/dist/server/version-info.d.ts +0 -14
- package/dist/server/version-info.d.ts.map +0 -1
- package/dist/server/version-info.js +0 -69
- package/dist/server/version-info.js.map +0 -1
- package/migrations/0000-00-00.01_schema.sql +0 -536
- package/migrations/0004_loop-provider.sql +0 -3
- package/migrations/0005_subscription-published-channel.sql +0 -3
- package/migrations/0006_loop-max-turns.sql +0 -3
package/dist/core/Engine.js
CHANGED
|
@@ -1,14 +1,17 @@
|
|
|
1
|
-
import { PlurnkParser, PlurnkParseError } from "@plurnk/plurnk-
|
|
1
|
+
import { PlurnkParser, PlurnkParseError, UNKNOWN_POSITION } from "@plurnk/plurnk-contracts";
|
|
2
|
+
import { RuntimeTag } from "@plurnk/plurnk-execs";
|
|
2
3
|
import Owner from "./Owner.js";
|
|
4
|
+
const TERMINAL_SEND_SIGNALS = new Set([102, 200, 202, 300, 499]);
|
|
5
|
+
const comparePosition = (a, b) => a.line - b.line || a.column - b.column;
|
|
3
6
|
import { Mimetypes, emptyRegistry } from "@plurnk/plurnk-mimetypes";
|
|
7
|
+
import Meta from "@plurnk/plurnk-meta";
|
|
4
8
|
import EntryCrud from "../schemes/_entry-crud.js";
|
|
5
|
-
import
|
|
6
|
-
import { markTerminal } from "../schemes/Worker.js";
|
|
9
|
+
import SearchIndex from "../schemes/_search-index.js";
|
|
7
10
|
import GitMembership from "./git-membership.js";
|
|
8
11
|
import GitState from "./git-state.js";
|
|
9
12
|
import WorkspaceSettings from "./workspace-settings.js";
|
|
10
13
|
import { editedSpan } from "../content/index.js";
|
|
11
|
-
import { promptPathname, promptLoopPrefix } from "./plurnk-uri.js";
|
|
14
|
+
import { promptPathname, promptLoopPrefix, renderTarget } from "./plurnk-uri.js";
|
|
12
15
|
import { rulerCount } from "./token-ruler.js";
|
|
13
16
|
import SearchGate from "./search-gate.js";
|
|
14
17
|
import LiveSubscriptions from "./LiveSubscriptions.js";
|
|
@@ -19,28 +22,75 @@ import { setTimeout as delay } from "node:timers/promises";
|
|
|
19
22
|
// Shared module imported by both Engine and bin/digest.ts, so wire
|
|
20
23
|
// projection and digest projection are structurally one function — no
|
|
21
24
|
// drift between wire and digest possible.
|
|
22
|
-
// Format: markdown (user pick over rummy's XML alternative, 2026-05-22).
|
|
23
25
|
import PacketWire from "./packet-wire.js";
|
|
26
|
+
import Results, { OperationFailureError } from "./results.js";
|
|
27
|
+
import BranchReceipt from "./BranchReceipt.js";
|
|
28
|
+
import TerminalResult from "./TerminalResult.js";
|
|
29
|
+
import BudgetOverflow from "./BudgetOverflow.js";
|
|
30
|
+
import WorkerControlAddress from "./WorkerControlAddress.js";
|
|
31
|
+
import JournalTurn from "./JournalTurn.js";
|
|
24
32
|
// The engine's collaborators — each owns one machine; Engine owns the loop/turn
|
|
25
33
|
// lifecycle and wires them together as the public facade.
|
|
26
|
-
import
|
|
34
|
+
import NoticeChannel from "./NoticeChannel.js";
|
|
35
|
+
import ProblemLog from "./ProblemLog.js";
|
|
27
36
|
import StrikeRail from "./StrikeRail.js";
|
|
28
37
|
import PacketBuilder from "./PacketBuilder.js";
|
|
38
|
+
import StoredPacket from "./StoredPacket.js";
|
|
29
39
|
import ProposalLifecycle from "./ProposalLifecycle.js";
|
|
30
40
|
import Dispatcher from "./Dispatcher.js";
|
|
41
|
+
import { observed, observedSync } from "../observe/spans.js";
|
|
42
|
+
import { OPS_DISPATCHED, PROVIDER_CALLS, recordCounter } from "../observe/metrics.js";
|
|
31
43
|
import { scheduleTurnOps } from "./turn-scheduler.js";
|
|
44
|
+
import { readOptimisticSettlementMs } from "./optimistic-settlement.js";
|
|
32
45
|
const DEFAULT_MAX_STRIKES = 3;
|
|
33
|
-
|
|
46
|
+
const RECORD_STREAM_MIMETYPES = new Set(["application/jsonl", "application/x-ndjson"]);
|
|
47
|
+
const baseMimetype = (mimetype) => mimetype.split(";", 1)[0].trim().toLowerCase();
|
|
48
|
+
// {§exec-stream} Active streams publish only independently meaningful units.
|
|
49
|
+
// Atomic documents wait for close; JSONL stops after its last complete record.
|
|
50
|
+
const streamPublicationEnd = (content, mimetype, cursor, closed) => {
|
|
51
|
+
const type = baseMimetype(mimetype);
|
|
52
|
+
if (closed || type.startsWith("text/"))
|
|
53
|
+
return content.length;
|
|
54
|
+
if (!RECORD_STREAM_MIMETYPES.has(type))
|
|
55
|
+
return cursor;
|
|
56
|
+
return Math.max(cursor, content.lastIndexOf("\n") + 1);
|
|
57
|
+
};
|
|
58
|
+
const ENGINE_PROBLEMS = Object.freeze({
|
|
59
|
+
max_commands_exceeded: {
|
|
60
|
+
status: 429,
|
|
61
|
+
code: "max-commands-exceeded",
|
|
62
|
+
detail: "Later operations were not executed because the turn exceeded its operation limit.",
|
|
63
|
+
},
|
|
64
|
+
idle_turn: {
|
|
65
|
+
status: 409,
|
|
66
|
+
code: "idle-turn",
|
|
67
|
+
detail: "SEND[102] was emitted without an operation to continue from.",
|
|
68
|
+
},
|
|
69
|
+
});
|
|
70
|
+
// The prompt entry target - prompt:///<loop>/<N>, self-only ({§prompt-self-only}):
|
|
34
71
|
// the owner rides the owner_id column, the address carries only the loop coordinate.
|
|
35
|
-
const promptTarget = (
|
|
72
|
+
const promptTarget = (loopSeq, turnSeq) => {
|
|
36
73
|
const storage = promptPathname(loopSeq, turnSeq);
|
|
37
74
|
return {
|
|
38
75
|
kind: "url", raw: `prompt://${storage}`,
|
|
39
76
|
scheme: "prompt", username: null, password: null,
|
|
40
77
|
hostname: null, port: null,
|
|
41
|
-
pathname: storage,
|
|
78
|
+
pathname: storage, query: null, fragment: null,
|
|
42
79
|
};
|
|
43
80
|
};
|
|
81
|
+
const assertOpenPaths = (value, source) => {
|
|
82
|
+
if (!Array.isArray(value) || value.some((path) => typeof path !== "string" || path.length === 0)) {
|
|
83
|
+
throw new TypeError(`${source}: expected an array of non-empty strings`);
|
|
84
|
+
}
|
|
85
|
+
return value;
|
|
86
|
+
};
|
|
87
|
+
const parsePromptAttributes = (encoded, source) => {
|
|
88
|
+
const attributes = JSON.parse(encoded);
|
|
89
|
+
if (attributes === null || typeof attributes !== "object" || Array.isArray(attributes)) {
|
|
90
|
+
throw new TypeError(`${source}: expected a JSON object`);
|
|
91
|
+
}
|
|
92
|
+
return attributes;
|
|
93
|
+
};
|
|
44
94
|
const readMaxStrikes = () => {
|
|
45
95
|
const raw = process.env.PLURNK_SERVICE_MAX_STRIKES;
|
|
46
96
|
if (raw === undefined || raw.length === 0)
|
|
@@ -50,10 +100,11 @@ const readMaxStrikes = () => {
|
|
|
50
100
|
return DEFAULT_MAX_STRIKES;
|
|
51
101
|
return n;
|
|
52
102
|
};
|
|
53
|
-
// Per-emission
|
|
54
|
-
// generated op dispatches. Runaway degeneration is a sampler concern (repetition penalty),
|
|
103
|
+
// Per-emission action ceiling — OFF by default. `-1` (or unset/non-positive) = no cap:
|
|
104
|
+
// every generated op dispatches. Runaway degeneration is a sampler concern (repetition penalty),
|
|
55
105
|
// not grounds to drop already-generated work. A positive value is an operator ceiling a
|
|
56
|
-
// workspace's maxCommands may tighten (min wins), never widen
|
|
106
|
+
// workspace's maxCommands may tighten (min wins), never widen
|
|
107
|
+
// ({§operator-config-workspace-max-commands}).
|
|
57
108
|
const readMaxCommands = () => {
|
|
58
109
|
const raw = process.env.PLURNK_SERVICE_MAX_COMMANDS;
|
|
59
110
|
if (raw === undefined || raw.length === 0)
|
|
@@ -63,8 +114,8 @@ const readMaxCommands = () => {
|
|
|
63
114
|
return Number.POSITIVE_INFINITY;
|
|
64
115
|
return n;
|
|
65
116
|
};
|
|
66
|
-
// PLURNK_SERVICE_FILES_ITEMS — the turn-0
|
|
67
|
-
// -1 = the
|
|
117
|
+
// PLURNK_SERVICE_FILES_ITEMS — the turn-0 catalog preview. null = off;
|
|
118
|
+
// -1 = the ordinary markerless page; positive N explicitly caps file rows. 0 / unset = off.
|
|
68
119
|
const normalizeFilesItems = (n) => (!Number.isFinite(n) || n === 0 ? null : n < 0 ? -1 : n);
|
|
69
120
|
const readFilesItems = () => {
|
|
70
121
|
const raw = process.env.PLURNK_SERVICE_FILES_ITEMS;
|
|
@@ -72,16 +123,14 @@ const readFilesItems = () => {
|
|
|
72
123
|
return null;
|
|
73
124
|
return normalizeFilesItems(Number.parseInt(raw, 10));
|
|
74
125
|
};
|
|
75
|
-
import { ProviderError, scopeEnvToAlias, resolveActiveAlias } from "@plurnk/plurnk-providers";
|
|
126
|
+
import { ProviderError, providerCostFor, providerCostUsd, validateProviderCost, scopeEnvToAlias, resolveActiveAlias } from "@plurnk/plurnk-providers";
|
|
76
127
|
import { validateGbnf } from "@plurnk/gbnf";
|
|
77
128
|
import ProviderInstantiate from "./ProviderInstantiate.js";
|
|
78
|
-
//
|
|
79
|
-
//
|
|
80
|
-
//
|
|
129
|
+
// Runtime normalization for a disposition the engine refuses or resolves as a
|
|
130
|
+
// continue after dispatch ({§send}). Every admitted emission itself ends in an
|
|
131
|
+
// explicit disposition SEND ({§emission-admission}).
|
|
81
132
|
const TURN_STATUS_IMPLICIT_CONTINUE = 102;
|
|
82
|
-
|
|
83
|
-
// action routes through telemetry.errors[] (§telemetry, §telemetry-no-error-scheme — never an error:// scheme).
|
|
84
|
-
const TURN_STATUS_NO_OPS = 422;
|
|
133
|
+
const INVALID_EMISSION_RECOVERY_MESSAGE = "Your previous response contained an unrecoverable syntax error. No operations were performed. Try again.";
|
|
85
134
|
const DEFAULT_MIN_CYCLES = 3;
|
|
86
135
|
const DEFAULT_MAX_CYCLE_PERIOD = 4;
|
|
87
136
|
const readPositiveInt = (envVar, fallback) => {
|
|
@@ -93,7 +142,15 @@ const readPositiveInt = (envVar, fallback) => {
|
|
|
93
142
|
return fallback;
|
|
94
143
|
return n;
|
|
95
144
|
};
|
|
96
|
-
|
|
145
|
+
const readEmissionAttempts = () => {
|
|
146
|
+
const raw = process.env.PLURNK_SERVICE_EMISSION_ATTEMPTS;
|
|
147
|
+
const value = Number.parseInt(raw ?? "", 10);
|
|
148
|
+
if (!Number.isInteger(value) || value < 1) {
|
|
149
|
+
throw new Error(`PLURNK_SERVICE_EMISSION_ATTEMPTS must be a positive integer; got ${raw}`);
|
|
150
|
+
}
|
|
151
|
+
return value;
|
|
152
|
+
};
|
|
153
|
+
// {§operator-config-loop-timeout} — the loop's wall-clock budget (PLURNK_SERVICE_LOOP_TIMEOUT).
|
|
97
154
|
const DEFAULT_LOOP_TIMEOUT_MS = 86400000;
|
|
98
155
|
const readLoopTimeoutMs = () => readPositiveInt("PLURNK_SERVICE_LOOP_TIMEOUT", DEFAULT_LOOP_TIMEOUT_MS);
|
|
99
156
|
// The wall's abort reason — runLoop branches a mid-turn teardown to the 504 terminal on it.
|
|
@@ -109,30 +166,26 @@ export default class Engine {
|
|
|
109
166
|
#lifecycle;
|
|
110
167
|
#schemes;
|
|
111
168
|
#mimetypes;
|
|
112
|
-
//
|
|
113
|
-
//
|
|
114
|
-
// the Daemon; a divisor tripwire stands in only for bare/standalone
|
|
115
|
-
// construction before a provider is wired (same boot affordance as
|
|
116
|
-
// Mimetypes, §mimetype-surface). Real counts come from provider.countTokens.
|
|
169
|
+
// {§tokenomics-agnostic-ruler} — the stable model-independent ruler used
|
|
170
|
+
// for write-time, catalog, receipt, and packet weights.
|
|
117
171
|
#tokenize;
|
|
118
172
|
// Boot-discovered runtime executors. Daemon builds + sets via
|
|
119
173
|
// setExecutors at start(); undefined until then (and in bare tests).
|
|
120
174
|
#executors;
|
|
121
|
-
// §send-premature-terminate/[
|
|
175
|
+
// {§send-premature-terminate}/SEND[202]<T> — park deadlines by loopId, written at dispatch (the
|
|
122
176
|
// marker's seconds; -1 = indefinite), consumed by the daemon's drain park-exit to schedule
|
|
123
177
|
// the deadline wake. In-memory: a daemon restart drops pending deadlines (documented).
|
|
124
178
|
parkDeadlines = new Map();
|
|
125
|
-
// §join-blocking-collect
|
|
126
|
-
// blocking join. The dispatcher arms this on the READ; the turn's bare SEND[102] parks on it
|
|
127
|
-
// (indefinite) instead of continuing, and any SEND clears it. Twin of parkDeadlines.
|
|
179
|
+
// Per-turn running-worker READ obligations. {§join-blocking-collect}
|
|
128
180
|
joinTargets = new Set();
|
|
129
181
|
// The collaborators. Engine constructs them (they share its deps via
|
|
130
182
|
// thunks where the value is late-injected — executors, loop signals)
|
|
131
183
|
// and fronts their public surface.
|
|
132
|
-
#
|
|
184
|
+
#notices;
|
|
185
|
+
#problems;
|
|
133
186
|
#strikes;
|
|
134
|
-
// §grinder-hard-413-recovery
|
|
135
|
-
// fitting turn
|
|
187
|
+
// {§grinder-hard-413-recovery} - loops granted their one over-ceiling recovery turn. Cleared on a
|
|
188
|
+
// fitting turn so a later independent overflow can earn a fresh recovery, and at loop cleanup.
|
|
136
189
|
#hardOverflowRecovery = new Set();
|
|
137
190
|
#packets;
|
|
138
191
|
searchGate = new SearchGate();
|
|
@@ -145,9 +198,10 @@ export default class Engine {
|
|
|
145
198
|
// Streaming schemes (exec) chain their per-spawn controllers off
|
|
146
199
|
// ctx.signal so cancelled loops tear down their background spawns.
|
|
147
200
|
#loopAborts = new Map();
|
|
148
|
-
// §
|
|
149
|
-
//
|
|
150
|
-
|
|
201
|
+
// {§prompt-loop-containment}: one worker's prompt-frame allocation and
|
|
202
|
+
// persistence is a serial critical section. A completed later frame can
|
|
203
|
+
// therefore never overtake or replace an earlier concurrent arrival.
|
|
204
|
+
#promptWriteLocks = new Map();
|
|
151
205
|
// One coalesced warm per workspace. Creation/membership changes start it as soon
|
|
152
206
|
// as content exists; the first model turn joins it, so no operation observes
|
|
153
207
|
// partial graph/vector coverage. A request arriving mid-pass marks the workspace
|
|
@@ -168,14 +222,20 @@ export default class Engine {
|
|
|
168
222
|
if (!invalidate && this.#workspaceWarmStatus.get(workspaceId)?.phase === "complete") {
|
|
169
223
|
return Promise.resolve();
|
|
170
224
|
}
|
|
171
|
-
const state = {
|
|
172
|
-
|
|
225
|
+
const state = {
|
|
226
|
+
dirty: false,
|
|
227
|
+
materialize,
|
|
228
|
+
ctx,
|
|
229
|
+
abort: new AbortController(),
|
|
230
|
+
promise: Promise.resolve(),
|
|
231
|
+
};
|
|
232
|
+
// Register before publishing the first synchronous Notice. A
|
|
173
233
|
// listener may request another warm from that callback; it must join
|
|
174
234
|
// this state rather than opening a second pump in the re-entrant gap.
|
|
175
235
|
this.#workspaceWarms.set(workspaceId, state);
|
|
176
236
|
const publish = (current, status) => {
|
|
177
237
|
this.#workspaceWarmStatus.set(workspaceId, status);
|
|
178
|
-
current.
|
|
238
|
+
current.pushNotice?.({
|
|
179
239
|
source: "engine:derivation", kind: "embed_progress", ...status,
|
|
180
240
|
});
|
|
181
241
|
};
|
|
@@ -191,25 +251,29 @@ export default class Engine {
|
|
|
191
251
|
completed: 0, total: 1, percent: 0, level: "info",
|
|
192
252
|
});
|
|
193
253
|
try {
|
|
254
|
+
const signal = current.signal === undefined
|
|
255
|
+
? state.abort.signal
|
|
256
|
+
: AbortSignal.any([current.signal, state.abort.signal]);
|
|
257
|
+
const cancellable = { ...current, signal };
|
|
194
258
|
if (shouldMaterialize)
|
|
195
|
-
await GitMembership.indexGitMembership(
|
|
196
|
-
await
|
|
197
|
-
...
|
|
198
|
-
|
|
199
|
-
if (
|
|
200
|
-
&& typeof
|
|
201
|
-
&& typeof
|
|
202
|
-
&& typeof
|
|
259
|
+
await GitMembership.indexGitMembership(cancellable);
|
|
260
|
+
await SearchIndex.maintain({
|
|
261
|
+
...cancellable,
|
|
262
|
+
pushNotice: (notice) => {
|
|
263
|
+
if (notice.kind === "embed_progress"
|
|
264
|
+
&& typeof notice.completed === "number"
|
|
265
|
+
&& typeof notice.total === "number"
|
|
266
|
+
&& typeof notice.percent === "number") {
|
|
203
267
|
this.#workspaceWarmStatus.set(workspaceId, {
|
|
204
268
|
phase: "indexing",
|
|
205
|
-
completed:
|
|
206
|
-
total:
|
|
207
|
-
percent:
|
|
208
|
-
message:
|
|
209
|
-
level:
|
|
269
|
+
completed: notice.completed,
|
|
270
|
+
total: notice.total,
|
|
271
|
+
percent: notice.percent,
|
|
272
|
+
message: notice.message ?? "Indexing repository semantics",
|
|
273
|
+
level: notice.level === "error" ? "error" : "info",
|
|
210
274
|
});
|
|
211
275
|
}
|
|
212
|
-
current.
|
|
276
|
+
current.pushNotice?.(notice);
|
|
213
277
|
},
|
|
214
278
|
});
|
|
215
279
|
}
|
|
@@ -237,14 +301,36 @@ export default class Engine {
|
|
|
237
301
|
workspaceDerivationStatus(workspaceId) {
|
|
238
302
|
return this.#workspaceWarmStatus.get(workspaceId) ?? null;
|
|
239
303
|
}
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
304
|
+
cancelDerivations(reason = new DOMException("derivations cancelled", "AbortError")) {
|
|
305
|
+
for (const state of this.#workspaceWarms.values()) {
|
|
306
|
+
if (!state.abort.signal.aborted)
|
|
307
|
+
state.abort.abort(reason);
|
|
308
|
+
}
|
|
309
|
+
}
|
|
310
|
+
// Awaited by Daemon.stop before the db closes. Shutdown supplies the exact
|
|
311
|
+
// cancellation reason it owns; every unrelated failure remains visible.
|
|
312
|
+
async drainDerivations(ignoredReason) {
|
|
313
|
+
const results = await Promise.allSettled([...this.#workspaceWarms.values()].map((state) => state.promise));
|
|
314
|
+
const errors = results
|
|
315
|
+
.filter((result) => result.status === "rejected")
|
|
316
|
+
.flatMap((result) => result.reason instanceof AggregateError
|
|
317
|
+
? [...result.reason.errors]
|
|
318
|
+
: [result.reason])
|
|
319
|
+
.filter((error) => error !== ignoredReason);
|
|
320
|
+
if (errors.length === 1)
|
|
321
|
+
throw errors[0];
|
|
322
|
+
if (errors.length > 1)
|
|
323
|
+
throw new AggregateError(errors, "derivation drain failed");
|
|
324
|
+
}
|
|
325
|
+
async drainWorkspaceDerivations(workspaceId) {
|
|
326
|
+
await this.#workspaceWarms.get(workspaceId)?.promise;
|
|
243
327
|
}
|
|
244
328
|
#streamEventNotify;
|
|
245
329
|
#wakeWorkerNotify;
|
|
246
|
-
|
|
247
|
-
#
|
|
330
|
+
#acquireWorkspaceTurn;
|
|
331
|
+
#workspaceTurnCompleted;
|
|
332
|
+
// Configured grammar text is cached by variant after its first load.
|
|
333
|
+
#gbnfCache = new Map();
|
|
248
334
|
// {§rail-truth-engine-verdict} — the verify GAP (a configured grammar @plurnk/gbnf can't
|
|
249
335
|
// parse): warn once per message, never per turn; the turn records railsVerdict "unverifiable".
|
|
250
336
|
static #railGapWarned = new Set();
|
|
@@ -254,42 +340,52 @@ export default class Engine {
|
|
|
254
340
|
Engine.#railGapWarned.add(message);
|
|
255
341
|
process.stderr.write(`plurnk-engine: rail verdict unavailable — the configured grammar did not parse in @plurnk/gbnf (${message})\n`);
|
|
256
342
|
}
|
|
257
|
-
|
|
343
|
+
static #requireGrammarEvidence(response) {
|
|
344
|
+
const evidence = response.grammarEvidence;
|
|
345
|
+
if (evidence === undefined) {
|
|
346
|
+
throw new Error("provider contract violation: configured GBNF response omitted grammar evidence");
|
|
347
|
+
}
|
|
348
|
+
const input = [...evidence.input];
|
|
349
|
+
if (!Number.isInteger(evidence.contentStart)
|
|
350
|
+
|| evidence.contentStart < 0
|
|
351
|
+
|| evidence.contentStart > input.length
|
|
352
|
+
|| typeof evidence.transported !== "boolean"
|
|
353
|
+
|| input.slice(evidence.contentStart).join("") !== response.assistant.content) {
|
|
354
|
+
throw new Error("provider contract violation: grammar evidence does not map exactly to assistant.content");
|
|
355
|
+
}
|
|
356
|
+
return evidence;
|
|
357
|
+
}
|
|
358
|
+
constructor({ db, schemes, mimetypes, streamEventNotify, wakeWorkerNotify, injectWorker, branchWorker, branchCompletionGate, cancelWorker, cancelDescendants, acquireWorkspaceTurn, workspaceTurnCompleted, noticeNotify, tokenize }) {
|
|
258
359
|
this.#db = db;
|
|
259
360
|
this.#lifecycle = new LoopLifecycle(db);
|
|
260
361
|
this.#schemes = schemes;
|
|
261
362
|
this.#streamEventNotify = streamEventNotify;
|
|
262
363
|
this.#wakeWorkerNotify = wakeWorkerNotify;
|
|
364
|
+
this.#acquireWorkspaceTurn = acquireWorkspaceTurn ?? (async () => () => { });
|
|
365
|
+
this.#workspaceTurnCompleted = workspaceTurnCompleted;
|
|
263
366
|
// Default to empty discovery — standalone Engine construction (in
|
|
264
367
|
// tests) gets no handlers, and content flows through the framework's
|
|
265
368
|
// raw-content fitContent fallback. Daemon-managed Engine receives a
|
|
266
369
|
// production-configured Mimetypes via the constructor arg.
|
|
267
370
|
this.#mimetypes = mimetypes ?? new Mimetypes({
|
|
268
|
-
discovery: { registry: emptyRegistry(), handlers: new Map() },
|
|
371
|
+
discovery: { registry: emptyRegistry(), handlers: new Map(), skipped: [] },
|
|
269
372
|
});
|
|
270
|
-
//
|
|
271
|
-
// the
|
|
272
|
-
// wired by the Daemon. Real counts come from provider.countTokens.
|
|
373
|
+
// {§tokenomics-agnostic-ruler} — standalone construction and the daemon
|
|
374
|
+
// use the same default; provider counting is confined to physical admission.
|
|
273
375
|
this.#tokenize = tokenize ?? rulerCount;
|
|
274
376
|
const executors = () => this.#executors;
|
|
275
377
|
const loopSignal = (loopId) => this.#loopAborts.get(loopId)?.signal;
|
|
276
|
-
this.#
|
|
277
|
-
|
|
378
|
+
this.#notices = new NoticeChannel({ notify: noticeNotify });
|
|
379
|
+
this.#problems = new ProblemLog(db);
|
|
380
|
+
this.#strikes = new StrikeRail();
|
|
381
|
+
this.#packets = new PacketBuilder({
|
|
278
382
|
db,
|
|
279
|
-
|
|
383
|
+
schemes,
|
|
384
|
+
problems: this.#problems,
|
|
280
385
|
executors,
|
|
281
|
-
tokenize: this.#tokenize,
|
|
282
|
-
streamEventNotify,
|
|
283
|
-
wakeWorkerNotify,
|
|
284
|
-
injectWorker,
|
|
285
|
-
pushTelemetry: (workspaceId, loopId, event) => this.#telemetry.push(workspaceId, loopId, event),
|
|
286
|
-
defaultChannelFor: (scheme) => schemes.defaultChannelFor(scheme),
|
|
287
|
-
liveSubscriptions: this.#liveSubscriptions,
|
|
288
386
|
});
|
|
289
|
-
this.#strikes = new StrikeRail();
|
|
290
|
-
this.#packets = new PacketBuilder({ db, schemes, telemetry: this.#telemetry, executors });
|
|
291
387
|
this.#proposals = new ProposalLifecycle({
|
|
292
|
-
db, schemes,
|
|
388
|
+
db, schemes, notices: this.#notices,
|
|
293
389
|
streamEventNotify, wakeWorkerNotify,
|
|
294
390
|
tokenize: this.#tokenize, mimetypes: this.#mimetypes, executors, loopSignal,
|
|
295
391
|
liveSubscriptions: this.#liveSubscriptions,
|
|
@@ -297,52 +393,52 @@ export default class Engine {
|
|
|
297
393
|
this.#dispatcher = new Dispatcher({ searchGate: this.searchGate,
|
|
298
394
|
db, schemes, mimetypes: this.#mimetypes,
|
|
299
395
|
tokenize: this.#tokenize,
|
|
300
|
-
|
|
396
|
+
notices: this.#notices, proposals: this.#proposals,
|
|
301
397
|
executors, loopSignal,
|
|
302
|
-
streamEventNotify, wakeWorkerNotify, injectWorker, cancelWorker, cancelDescendants,
|
|
398
|
+
streamEventNotify, wakeWorkerNotify, injectWorker, branchWorker, branchCompletionGate, cancelWorker, cancelDescendants,
|
|
303
399
|
parkDeadlines: this.parkDeadlines,
|
|
304
400
|
joinTargets: this.joinTargets,
|
|
305
401
|
liveSubscriptions: this.#liveSubscriptions,
|
|
306
402
|
});
|
|
403
|
+
schemes.bindCore({
|
|
404
|
+
db,
|
|
405
|
+
mimetypes: this.#mimetypes,
|
|
406
|
+
executors,
|
|
407
|
+
tokenize: this.#tokenize,
|
|
408
|
+
streamEventNotify,
|
|
409
|
+
wakeWorkerNotify,
|
|
410
|
+
injectWorker,
|
|
411
|
+
pushNotice: (workspaceId, loopId, notice) => this.#notices.push(workspaceId, loopId, notice),
|
|
412
|
+
defaultChannelFor: (scheme) => schemes.defaultChannelFor(scheme),
|
|
413
|
+
readExecSource: (statement, ctx) => this.#dispatcher.readExecSource(statement, ctx),
|
|
414
|
+
liveSubscriptions: this.#liveSubscriptions,
|
|
415
|
+
});
|
|
307
416
|
}
|
|
308
417
|
// Late injection: the executor registry is async-built at daemon start()
|
|
309
418
|
// (discover + probe), after Engine construction.
|
|
310
419
|
setExecutors(executors) {
|
|
311
420
|
this.#executors = executors;
|
|
312
421
|
}
|
|
313
|
-
//
|
|
314
|
-
//
|
|
315
|
-
//
|
|
316
|
-
|
|
317
|
-
// same reserved/cross-family arbitration boot uses. Fail-hard if the registry isn't wired yet — a
|
|
318
|
-
// hotload before daemon start() is a caller bug, not a silent no-op.
|
|
319
|
-
hotloadRuntime(tag, entry) {
|
|
422
|
+
// Register a module-owned runtime on the same two registries as boot discovery.
|
|
423
|
+
// An optional same-name scheme handler lets one capability own both execution
|
|
424
|
+
// and addressable state without teaching core its protocol.
|
|
425
|
+
registerRuntime(tag, entry, scheme) {
|
|
320
426
|
if (this.#executors === undefined)
|
|
321
|
-
throw new Error("
|
|
322
|
-
|
|
323
|
-
//
|
|
324
|
-
//
|
|
325
|
-
this.#
|
|
427
|
+
throw new Error("registerRuntime: executor registry not wired yet");
|
|
428
|
+
RuntimeTag.assert(tag, "module runtime");
|
|
429
|
+
// Preflight both owners before either write; synchronous registration
|
|
430
|
+
// then cannot leave a half-claimed namespace. {§plugin-namespace-arbitration}
|
|
431
|
+
this.#executors.assertCanRegister(tag, entry.namespaceOwner);
|
|
432
|
+
this.#schemes.assertRuntimeClaim(tag, entry.namespaceOwner);
|
|
433
|
+
this.#schemes.registerRuntimeScheme(tag, entry.executor, entry.namespaceOwner, scheme);
|
|
326
434
|
this.#executors.register(tag, entry);
|
|
327
435
|
}
|
|
328
|
-
//
|
|
329
|
-
//
|
|
330
|
-
// backend that explicitly supports constrained sampling.
|
|
436
|
+
// Supply an explicitly configured local constraint; ANTLR remains the
|
|
437
|
+
// language authority. {§grammar-enforcement-verified-at-boot}
|
|
331
438
|
async #grammarConstraint(provider) {
|
|
332
|
-
//
|
|
333
|
-
//
|
|
334
|
-
//
|
|
335
|
-
// disables the optional constraint.
|
|
336
|
-
//
|
|
337
|
-
// PER ALIAS (#353): resolved PLURNK_PROVIDERS_GBNF_<alias> over the bare fallback (providers'
|
|
338
|
-
// scopeEnvToAlias), scoped by the alias that built this provider. GBNF only helps backends
|
|
339
|
-
// that constrain sampling (llama-server). The bare default is unset and
|
|
340
|
-
// local-model aliases opt in via a PLURNK_PROVIDERS_GBNF_<alias> suffix.
|
|
341
|
-
// #488 — the rail must be VERIFIABLE, never silently off. Two guards:
|
|
342
|
-
// (1) the alias fallback is only trusted when NO per-alias GBNF opt-ins exist: an
|
|
343
|
-
// unregistered provider in a process that configured suffixed rails could fall back
|
|
344
|
-
// to a DIFFERENT active alias, miss the suffix, and run unconstrained — the silent
|
|
345
|
-
// severance class run78 demonstrated (free decode, fabricated logs, CLEAN telemetry).
|
|
439
|
+
// Resolve through the registered or active alias; ambiguity and load
|
|
440
|
+
// failures never degrade to unconstrained generation.
|
|
441
|
+
// {§grammar-enforcement-verified-at-boot}
|
|
346
442
|
const registered = ProviderInstantiate.aliasOf(provider);
|
|
347
443
|
const fallback = registered === undefined ? resolveActiveAlias(process.env)?.alias : undefined;
|
|
348
444
|
if (registered === undefined && fallback === undefined && Object.keys(process.env).some((k) => k.startsWith("PLURNK_PROVIDERS_GBNF_"))) {
|
|
@@ -357,52 +453,66 @@ export default class Engine {
|
|
|
357
453
|
return hit;
|
|
358
454
|
const path = variant.startsWith("/") || variant.startsWith(".")
|
|
359
455
|
? variant
|
|
360
|
-
: fileURLToPath(import.meta.resolve(`@plurnk/plurnk-
|
|
456
|
+
: fileURLToPath(import.meta.resolve(`@plurnk/plurnk-contracts/${variant}`));
|
|
361
457
|
const text = await readFile(path, "utf8"); // unresolvable/unreadable throws — a configured rail never silently degrades
|
|
362
458
|
this.#gbnfCache.set(variant, text);
|
|
363
459
|
process.stderr.write(`plurnk-engine: GBNF constraint: ${alias || "(bare)"} → ${variant} (${text.length} chars)\n`);
|
|
364
460
|
return text;
|
|
365
461
|
}
|
|
366
|
-
//
|
|
367
|
-
//
|
|
368
|
-
// token/cost numbers. costUsd is the stored USD unit.
|
|
369
|
-
// #345 — the client-facing budget denominator, ONE meaning on every surface: the prompt
|
|
370
|
-
// budget the packet actually lives under (effective window minus the partition reserves),
|
|
371
|
-
// the same number loop-usage stores per turn. providers.list advertised the raw KV and the
|
|
372
|
-
// client's gauge rendered a window the model can never fill.
|
|
373
|
-
// #522 — the PRIMARY worker of a turn's lineage: the no-parent root reached by walking
|
|
374
|
-
// parent_worker_id up. A no-parent worker is its OWN primary (stamped on the primary's own
|
|
375
|
-
// turns). Supplied on the first-party metadata channel alongside Worker-Id; the endpoint routes
|
|
376
|
-
// primary→strong / spawned→cheap by `Worker-Primary == Worker-Id` equality. Fail-hard if a
|
|
377
|
-
// worker resolves to no root — that is a corrupt lineage (a cycle the parent!=id CHECK forbids),
|
|
378
|
-
// never a silent "assume primary."
|
|
462
|
+
// A lineage's no-parent root; a root worker resolves to itself. Fail hard
|
|
463
|
+
// when corruption leaves a worker without one. {§worker-primary}
|
|
379
464
|
async resolveWorkerPrimary(workerId) {
|
|
380
465
|
const root = await this.#db.engine_worker_lineage_root.get({ worker_id: workerId });
|
|
381
466
|
if (root === undefined)
|
|
382
|
-
throw new Error(`resolveWorkerPrimary: worker ${workerId} has no lineage root — corrupt parent chain
|
|
467
|
+
throw new Error(`resolveWorkerPrimary: worker ${workerId} has no lineage root — corrupt parent chain`);
|
|
383
468
|
return root.id;
|
|
384
469
|
}
|
|
385
470
|
promptBudgetFor(provider) {
|
|
386
471
|
return this.#packets.promptBudgetFor(provider);
|
|
387
472
|
}
|
|
473
|
+
async #attemptAttributions(provider, context) {
|
|
474
|
+
const tags = Meta.composeAttributions(this.#schemes.attributions(context), this.#executors?.attributions(context) ?? [], await this.#mimetypes.attributions(context), provider.attributions?.(context) ?? []);
|
|
475
|
+
return [...tags];
|
|
476
|
+
}
|
|
477
|
+
// {§attribution} — reporting derives from exact provider-request evidence;
|
|
478
|
+
// malformed durable tags fail here instead of being silently filtered.
|
|
479
|
+
async loopAttributions(loopId) {
|
|
480
|
+
const rows = await this.#db.engine_loop_attributions.all({ loop_id: loopId });
|
|
481
|
+
const tags = rows.map(({ attribution }, index) => {
|
|
482
|
+
if (typeof attribution !== "string" || attribution.length === 0) {
|
|
483
|
+
throw new TypeError(`loop ${loopId} attribution row ${index} is not a non-empty string`);
|
|
484
|
+
}
|
|
485
|
+
return attribution;
|
|
486
|
+
});
|
|
487
|
+
return [...Meta.composeAttributions(tags)];
|
|
488
|
+
}
|
|
489
|
+
// Loop totals are billing evidence; the latest-turn pair is the client
|
|
490
|
+
// occupancy gauge. {§tokenomics-client-gauge}, {§notifications-loop-terminated}
|
|
388
491
|
async loopUsage(loopId) {
|
|
389
492
|
const row = await this.#db.engine_loop_usage.get({ loop_id: loopId });
|
|
493
|
+
if (row === undefined)
|
|
494
|
+
throw new Error(`loopUsage: loop ${loopId} does not exist`);
|
|
495
|
+
const parsedCosts = JSON.parse(row?.costs ?? "[]");
|
|
496
|
+
if (!Array.isArray(parsedCosts))
|
|
497
|
+
throw new TypeError(`loop ${loopId} monetary evidence is not an array`);
|
|
498
|
+
const costs = parsedCosts.map((cost) => validateProviderCost(cost));
|
|
390
499
|
return {
|
|
391
500
|
promptTokens: row?.prompt ?? 0,
|
|
392
501
|
completionTokens: row?.completion ?? 0,
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
|
|
502
|
+
reasoningTokens: row?.reasoning ?? 0,
|
|
503
|
+
cachedTokens: row?.cached ?? 0,
|
|
504
|
+
costUsd: row?.cost_usd ?? null,
|
|
505
|
+
costs,
|
|
506
|
+
// Latest provider attempt on the latest turn, not the billed total.
|
|
396
507
|
contextTokens: row?.context ?? 0,
|
|
397
|
-
//
|
|
508
|
+
// Latest effective packet allowance; null when uncapped or unknown.
|
|
398
509
|
promptBudget: row?.context_size ?? null,
|
|
399
|
-
//
|
|
400
|
-
// provider returned no meta. The service forwards it; it never reads a field within.
|
|
510
|
+
// Latest turn's opaque provider metadata. {§meta-passthrough}
|
|
401
511
|
meta: JSON.parse(row?.meta ?? "{}"),
|
|
402
512
|
};
|
|
403
513
|
}
|
|
404
|
-
// A
|
|
405
|
-
//
|
|
514
|
+
// A mapped rail divergence is a CODE-POINT offset into the model's content;
|
|
515
|
+
// the snippet/notices surface speaks the parser-point convention. {§parser-position}
|
|
406
516
|
// Convert over code points (not UTF-16 units) so an astral char doesn't skew the line,
|
|
407
517
|
// clamping out-of-range offsets to the content's end.
|
|
408
518
|
#offsetToLineColumn(content, offset) {
|
|
@@ -426,7 +536,7 @@ export default class Engine {
|
|
|
426
536
|
// Its ceiling therefore counts every prior turn, not merely this process-local
|
|
427
537
|
// execution segment.
|
|
428
538
|
const turnIds = await this.#lifecycle.turnIds(loopId);
|
|
429
|
-
|
|
539
|
+
let invalidEmissionRecoveryEntryId = null;
|
|
430
540
|
// Per-loop AbortController for scheme-side cancellation propagation.
|
|
431
541
|
// Chained from the caller's `signal` so an external abort cascades.
|
|
432
542
|
const loopAbort = new AbortController();
|
|
@@ -437,23 +547,24 @@ export default class Engine {
|
|
|
437
547
|
signal.addEventListener("abort", () => loopAbort.abort(signal.reason), { once: true });
|
|
438
548
|
}
|
|
439
549
|
this.#loopAborts.set(loopId, loopAbort);
|
|
440
|
-
// §operator-config-loop-timeout — the wall-clock budget. Expiry aborts the loop signal, so a
|
|
550
|
+
// {§operator-config-loop-timeout} — the wall-clock budget. Expiry aborts the loop signal, so a
|
|
441
551
|
// mid-flight provider call (generate rides this signal) and in-flight spawns tear down; the
|
|
442
|
-
// loop terminates 504 (kin to the exec <T> reap's 504, §exec-timeout) — a legible engine
|
|
552
|
+
// loop terminates 504 (kin to the exec <T> reap's 504, {§exec-timeout}) — a legible engine
|
|
443
553
|
// terminal, never an outside kill. unref'd: the wall never holds the process open.
|
|
444
554
|
const wall = setTimeout(() => loopAbort.abort(LOOP_TIMEOUT_REASON), readLoopTimeoutMs());
|
|
445
555
|
wall.unref();
|
|
446
556
|
const timedOut = () => loopAbort.signal.aborted && loopAbort.signal.reason === LOOP_TIMEOUT_REASON;
|
|
447
557
|
const ruleTimeout = async () => {
|
|
448
|
-
|
|
449
|
-
|
|
450
|
-
|
|
451
|
-
|
|
452
|
-
message: `loop abandoned 504: the loop wall expired after ${turnIds.length} turns`,
|
|
558
|
+
const failure = Results.failure("engine:rails", "loop-timeout", 504, `The loop exceeded its wall-clock deadline after ${turnIds.length} turns.`, {}, {
|
|
559
|
+
turns: turnIds.length,
|
|
560
|
+
stage: "loop",
|
|
561
|
+
retryable: false,
|
|
453
562
|
});
|
|
454
|
-
await this.#lifecycle.finish(loopId,
|
|
563
|
+
const result = await this.#lifecycle.finish(loopId, failure);
|
|
564
|
+
if (result === null)
|
|
565
|
+
throw new Error(`loop ${loopId} became terminal before timeout settlement`);
|
|
455
566
|
cleanup("forceful", "loop_timeout");
|
|
456
|
-
return { turnIds,
|
|
567
|
+
return { turnIds, result, hitMaxTurns: false, reason: "loop_timeout" };
|
|
457
568
|
};
|
|
458
569
|
// Cleanup splits by termination kind:
|
|
459
570
|
// - "graceful" (SEND[202] Accepted): in-flight streaming-scheme spawns
|
|
@@ -471,23 +582,19 @@ export default class Engine {
|
|
|
471
582
|
this.#strikes.delete(loopId);
|
|
472
583
|
this.searchGate.cleanup(loopId);
|
|
473
584
|
this.#hardOverflowRecovery.delete(loopId);
|
|
474
|
-
this.#
|
|
585
|
+
this.#notices.delete(loopId);
|
|
475
586
|
};
|
|
476
587
|
while (true) {
|
|
477
|
-
// The wall fired between turns — rule 504 before anything else reads the loop.
|
|
478
|
-
if (timedOut())
|
|
479
|
-
return await ruleTimeout();
|
|
480
|
-
signal?.throwIfAborted();
|
|
481
588
|
const row = await this.#db.engine_loop_status.get({ loop_id: loopId });
|
|
482
589
|
if (row === undefined)
|
|
483
590
|
throw new Error(`Engine.runLoop: loop ${loopId} not found`);
|
|
484
591
|
if (row.status === 100) {
|
|
485
592
|
// NOT a terminal — a wake re-queued this loop while its own live drain was
|
|
486
593
|
// between turns (a child concluded in the gap between our 202 write and this
|
|
487
|
-
// check, §worker-lifecycle-wake-requeue-not-terminal). The wake's intent is KEEP
|
|
594
|
+
// check, {§worker-lifecycle-wake-requeue-not-terminal}). The wake's intent is KEEP
|
|
488
595
|
// RUNNING: re-claim atomically and continue — the injected prompt is already
|
|
489
596
|
// this loop's next turn. Returning it as "external" broadcast a QUEUED loop
|
|
490
|
-
// as
|
|
597
|
+
// as a terminal result with status 100 — the delegation-flags race.
|
|
491
598
|
await this.#db.engine_reclaim_queued_loop.run({ loop_id: loopId });
|
|
492
599
|
continue; // claimed (or a racer flipped it first — the re-read decides)
|
|
493
600
|
}
|
|
@@ -496,34 +603,38 @@ export default class Engine {
|
|
|
496
603
|
// contract (E.4). Every other terminal, 200 included, reaps: "done"
|
|
497
604
|
// must not leak running execs. Trust the code's declared intent.
|
|
498
605
|
cleanup(row.status === 202 ? "graceful" : "forceful", `loop_terminal_${row.status}`);
|
|
499
|
-
|
|
606
|
+
if (row.status === 202) {
|
|
607
|
+
return { turnIds, result: { status: 202 }, hitMaxTurns: false, reason: "external" };
|
|
608
|
+
}
|
|
609
|
+
const result = await this.#lifecycle.result(loopId);
|
|
610
|
+
if (result === null) {
|
|
611
|
+
throw new Error(`terminal loop ${loopId} status ${row.status} has no operation result`);
|
|
612
|
+
}
|
|
613
|
+
return { turnIds, result, hitMaxTurns: false, reason: "external" };
|
|
500
614
|
}
|
|
615
|
+
// Durable disposition outranks a later process-local cancellation observation.
|
|
616
|
+
// SEND may commit 202 immediately before daemon shutdown aborts this drain; reading
|
|
617
|
+
// the abort first launders that lawful park into 499 under load. Only a still-running
|
|
618
|
+
// 102 loop can be cancelled or time out at this boundary.
|
|
619
|
+
if (timedOut())
|
|
620
|
+
return await ruleTimeout();
|
|
621
|
+
signal?.throwIfAborted();
|
|
501
622
|
if (maxTurns >= 0 && turnIds.length >= maxTurns) {
|
|
502
|
-
|
|
503
|
-
|
|
504
|
-
|
|
505
|
-
|
|
506
|
-
this.#telemetry.push(workspaceId, loopId, {
|
|
507
|
-
source: "engine:rails", kind: "max_turns", level: "warn",
|
|
508
|
-
message: `loop ended 429: the configured turn ceiling (${maxTurns}) is exhausted`,
|
|
623
|
+
const failure = Results.failure("engine:rails", "max-turns", 429, `The configured turn ceiling (${maxTurns}) is exhausted.`, {}, {
|
|
624
|
+
maximumTurns: maxTurns,
|
|
625
|
+
stage: "loop",
|
|
626
|
+
retryable: false,
|
|
509
627
|
});
|
|
510
|
-
await this.#lifecycle.finish(loopId,
|
|
628
|
+
const result = await this.#lifecycle.finish(loopId, failure);
|
|
629
|
+
if (result === null)
|
|
630
|
+
throw new Error(`loop ${loopId} became terminal before max-turn settlement`);
|
|
511
631
|
cleanup("forceful", "max_turns");
|
|
512
|
-
return { turnIds,
|
|
632
|
+
return { turnIds, result, hitMaxTurns: true, reason: "max_turns" };
|
|
513
633
|
}
|
|
514
|
-
// PLURNK_SERVICE_EXEC_WAIT_MS — a post-EXEC breath: if a spawn from the prior turn
|
|
515
|
-
// is still in flight, give it a tunable beat to land in THIS turn's packet
|
|
516
|
-
// before we assemble it. A fixed grace beat, never a wait-for-completion;
|
|
517
|
-
// 0/unset = off. Abortable with the loop signal.
|
|
518
634
|
const execHandler = this.#schemes.get("exec");
|
|
519
|
-
// §exec-hold-until-concluded —
|
|
520
|
-
//
|
|
521
|
-
//
|
|
522
|
-
// the model never gets a turn it can only waste asking "are we there yet". Bounded by
|
|
523
|
-
// PLURNK_SERVICE_EXEC_HOLD_MS and FAIL-OPEN: at the cap the standard cycle resumes
|
|
524
|
-
// (the stream stays live; parks/wakes/polls all still apply). Zero grammar or teaching
|
|
525
|
-
// change — the model emits EXEC + SEND[102] as ever; the next packet simply contains
|
|
526
|
-
// the finished digest, open and final.
|
|
635
|
+
// {§exec-hold-until-concluded} — hold matching runtime/effect
|
|
636
|
+
// streams until conclusion or the fail-open cap, then resume the
|
|
637
|
+
// ordinary cycle without altering stream state.
|
|
527
638
|
const holdSet = new Set((process.env.PLURNK_SERVICE_EXEC_HOLD ?? "").split(",").map((x) => x.trim()).filter((x) => x.length > 0));
|
|
528
639
|
const holdCapMs = Number(process.env.PLURNK_SERVICE_EXEC_HOLD_MS ?? "300000");
|
|
529
640
|
if (holdSet.size > 0 && holdCapMs > 0 && execHandler?.hasActiveHoldSpawns !== undefined) {
|
|
@@ -532,16 +643,24 @@ export default class Engine {
|
|
|
532
643
|
await delay(150, undefined, { signal });
|
|
533
644
|
}
|
|
534
645
|
}
|
|
535
|
-
const execWaitMs = Number(process.env.PLURNK_SERVICE_EXEC_WAIT_MS ?? "0");
|
|
536
|
-
if (execWaitMs > 0) {
|
|
537
|
-
if (execHandler?.hasActiveSpawns?.(workerId) === true)
|
|
538
|
-
await delay(execWaitMs, undefined, { signal });
|
|
539
|
-
}
|
|
540
646
|
let turn;
|
|
647
|
+
const releaseWorkspace = await this.#acquireWorkspaceTurn(workspaceId, workerId);
|
|
541
648
|
try {
|
|
542
|
-
turn = await
|
|
543
|
-
|
|
544
|
-
|
|
649
|
+
turn = await observed(// {§observability-boundary}
|
|
650
|
+
"loop.turn", { workerId, "loop.id": loopId }, async (span) => {
|
|
651
|
+
const t = await this.runTurn({
|
|
652
|
+
provider, messages, requirements, workspaceId, workerId, loopId, origin, signal, onDispatch,
|
|
653
|
+
turnNumber: turnIds.length + 1, maxTurns,
|
|
654
|
+
invalidEmissionRecoveryEntryId,
|
|
655
|
+
});
|
|
656
|
+
span.setAttribute("turn.id", t.turnId);
|
|
657
|
+
return t;
|
|
658
|
+
});
|
|
659
|
+
await this.#workspaceTurnCompleted?.({
|
|
660
|
+
workspaceId,
|
|
661
|
+
workerId,
|
|
662
|
+
loopId,
|
|
663
|
+
turnId: turn.turnId,
|
|
545
664
|
});
|
|
546
665
|
}
|
|
547
666
|
catch (err) {
|
|
@@ -551,121 +670,162 @@ export default class Engine {
|
|
|
551
670
|
return await ruleTimeout();
|
|
552
671
|
throw err;
|
|
553
672
|
}
|
|
673
|
+
finally {
|
|
674
|
+
releaseWorkspace();
|
|
675
|
+
}
|
|
554
676
|
turnIds.push(turn.turnId);
|
|
555
|
-
//
|
|
556
|
-
|
|
557
|
-
|
|
558
|
-
|
|
559
|
-
|
|
560
|
-
|
|
561
|
-
|
|
677
|
+
// {§invalid-emission-attempts} Invalid provider emissions are retried beneath this turn and never
|
|
678
|
+
// reach the strike rail. The first consecutive exhaustion has already
|
|
679
|
+
// exposed its bounded lifeline through ordinary next-turn state. A
|
|
680
|
+
// second exhaustion is terminal; an admitted turn clears the sequence.
|
|
681
|
+
if (turn.emissionExhausted) {
|
|
682
|
+
if (invalidEmissionRecoveryEntryId === null) {
|
|
683
|
+
if (turn.rejectedModelEntryId === undefined) {
|
|
684
|
+
throw new Error("an admitted invalid-emission recovery requires its rejected model-entry identity");
|
|
685
|
+
}
|
|
686
|
+
invalidEmissionRecoveryEntryId = turn.rejectedModelEntryId;
|
|
687
|
+
continue;
|
|
688
|
+
}
|
|
689
|
+
const failure = Results.failure("engine:generation", "invalid-emission-exhausted", 500, `No valid PLAN...SEND turn was received after ${turn.emissionAttempts} emission attempts.`, {}, {
|
|
690
|
+
attempts: turn.emissionAttempts,
|
|
691
|
+
stage: "emission-validation",
|
|
692
|
+
retryable: false,
|
|
562
693
|
});
|
|
563
|
-
await this.#lifecycle.finish(loopId,
|
|
694
|
+
const result = await this.#lifecycle.finish(loopId, failure);
|
|
695
|
+
if (result === null)
|
|
696
|
+
throw new Error(`loop ${loopId} became terminal before invalid-emission settlement`);
|
|
697
|
+
cleanup("forceful", "invalid_emission");
|
|
698
|
+
return { turnIds, result, hitMaxTurns: false, reason: "invalid_emission" };
|
|
699
|
+
}
|
|
700
|
+
invalidEmissionRecoveryEntryId = null;
|
|
701
|
+
// SPEC {§grinder}: budget hard-stop — packet won't fit even collapsed → abandon.
|
|
702
|
+
if (turn.budgetHardStop) {
|
|
703
|
+
if (turn.budget === undefined) {
|
|
704
|
+
throw new Error("a budget hard-stop requires its measured overflow");
|
|
705
|
+
}
|
|
706
|
+
const failure = BudgetOverflow.result(turn.budget.usage, turn.budget.ceiling, false);
|
|
707
|
+
const result = await this.#lifecycle.finish(loopId, failure);
|
|
708
|
+
if (result === null)
|
|
709
|
+
throw new Error(`loop ${loopId} became terminal before budget settlement`);
|
|
564
710
|
cleanup("forceful", "budget_overflow");
|
|
565
|
-
return { turnIds,
|
|
711
|
+
return { turnIds, result, hitMaxTurns: false, reason: "budget_overflow" };
|
|
566
712
|
}
|
|
567
|
-
//
|
|
568
|
-
// grinder/steer coupling, hard
|
|
713
|
+
// {§engine-rails} — per-turn strike accounting (cycle detection, the
|
|
714
|
+
// grinder/steer coupling, hard operation outcomes). StrikeRail owns the
|
|
569
715
|
// bookkeeping; runLoop owns abandonment.
|
|
570
716
|
const verdict = this.#strikes.assess(loopId, {
|
|
571
717
|
fingerprint: turn.fingerprint,
|
|
572
|
-
|
|
573
|
-
noOps: turn.status === TURN_STATUS_NO_OPS,
|
|
718
|
+
outcomes: turn.outcomes,
|
|
574
719
|
budgetStruck: turn.budgetStruck,
|
|
575
720
|
steerStruck: turn.steerStruck,
|
|
576
721
|
minCycles, maxCyclePeriod, maxStrikes,
|
|
577
722
|
});
|
|
578
723
|
if (verdict.thresholdCrossed) {
|
|
579
|
-
// §
|
|
580
|
-
//
|
|
581
|
-
// Internal Server Error). The straw that crossed the threshold picks it.
|
|
724
|
+
// {§engine-rails} — the source on the crossing turn classifies
|
|
725
|
+
// the engine verdict: cycle-driven is 508; every other strike is 500.
|
|
582
726
|
const status = verdict.cycleDetected ? 508 : 500;
|
|
583
|
-
|
|
584
|
-
|
|
585
|
-
|
|
586
|
-
|
|
587
|
-
|
|
588
|
-
|
|
589
|
-
message: verdict.cycleDetected
|
|
590
|
-
? `loop abandoned ${status}: strike threshold crossed after ${turnIds.length} turns — the model was spinning in place (cycle detected)`
|
|
591
|
-
: `loop abandoned ${status}: strike threshold crossed after ${turnIds.length} turns — repeated failed or no-op turns`,
|
|
727
|
+
const failure = Results.failure("engine:rails", "strike-threshold", status, verdict.cycleDetected
|
|
728
|
+
? `The loop reached its strike threshold after ${turnIds.length} turns because its operation pattern repeated.`
|
|
729
|
+
: `The loop reached its strike threshold after ${turnIds.length} turns because consecutive turns failed.`, {}, {
|
|
730
|
+
turns: turnIds.length,
|
|
731
|
+
stage: "loop",
|
|
732
|
+
retryable: false,
|
|
592
733
|
});
|
|
593
|
-
await this.#lifecycle.finish(loopId,
|
|
734
|
+
const result = await this.#lifecycle.finish(loopId, failure);
|
|
735
|
+
if (result === null)
|
|
736
|
+
throw new Error(`loop ${loopId} became terminal before strike settlement`);
|
|
594
737
|
cleanup("forceful", "strike_threshold");
|
|
595
|
-
return { turnIds,
|
|
596
|
-
}
|
|
597
|
-
// Sudden-death threshold is engine-internal — abandonment
|
|
598
|
-
// happens at maxTurns regardless. Per gamification policy:
|
|
599
|
-
// we don't warn the model that it's nearing our limit.
|
|
600
|
-
if (turnIds.length >= suddenDeathThreshold && turnIds.length < maxTurns) {
|
|
601
|
-
// Threshold tripped; engine bookkeeping only.
|
|
738
|
+
return { turnIds, result, hitMaxTurns: false, reason: "strike_threshold" };
|
|
602
739
|
}
|
|
603
740
|
}
|
|
604
741
|
}
|
|
605
|
-
async runTurn({ provider, messages, requirements = "", workspaceId, workerId, loopId, origin = "model", signal, onDispatch, turnNumber = 1, maxTurns = 50, }) {
|
|
742
|
+
async runTurn({ provider, messages, requirements = "", workspaceId, workerId, loopId, origin = "model", signal, onDispatch, turnNumber = 1, maxTurns = 50, invalidEmissionRecoveryEntryId, }) {
|
|
743
|
+
const allowInvalidEmissionRecovery = invalidEmissionRecoveryEntryId === null;
|
|
744
|
+
const transientOpenLogEntryId = typeof invalidEmissionRecoveryEntryId === "number"
|
|
745
|
+
? invalidEmissionRecoveryEntryId
|
|
746
|
+
: null;
|
|
606
747
|
// === Turn-as-container model ===
|
|
607
748
|
//
|
|
608
749
|
// Turn rows are created at runTurn OPEN (status=102, placeholder
|
|
609
750
|
// packet) so things can be written into the turn before the model
|
|
610
751
|
// is called: the user prompt on turn 1; later, system signals or
|
|
611
|
-
// injected
|
|
752
|
+
// injected Notices on any turn. The turn is CLOSED at
|
|
612
753
|
// the end with the final packet + status + usage stats.
|
|
613
754
|
//
|
|
614
755
|
// sequence is "ordinal of stuff in this turn." Pre-model
|
|
615
756
|
// writes consume low indices; model ops continue from there.
|
|
616
757
|
const seqRow = await this.#db.engine_next_turn_sequence.get({ loop_id: loopId });
|
|
617
758
|
const seq = seqRow.next;
|
|
618
|
-
//
|
|
619
|
-
//
|
|
620
|
-
// first turn; per-loop foists
|
|
759
|
+
// loops.sequence is the loop's ordinal within the worker. Turn-0 foists that belong to the
|
|
760
|
+
// WORKER (manifest preview, AGENTS, operator docs) gate on the worker's first loop, not every loop's
|
|
761
|
+
// first turn ({§actor-boundary-catalog-preview}); per-loop foists such as
|
|
762
|
+
// {§prompt-entry} still fire each loop. Read once, turn-1 only.
|
|
621
763
|
const loopRow = seq === 1
|
|
622
764
|
? await this.#db.engine_get_loop_prompt.get({ loop_id: loopId })
|
|
623
765
|
: undefined;
|
|
624
|
-
const
|
|
766
|
+
const workerFirstLoop = (loopRow?.sequence ?? 0) === 1;
|
|
625
767
|
const openRow = await this.#db.engine_open_turn.get({
|
|
626
768
|
loop_id: loopId, sequence: seq,
|
|
627
769
|
});
|
|
628
770
|
if (openRow === undefined)
|
|
629
771
|
throw new Error("Engine.runTurn: turn open returned no row");
|
|
630
772
|
const turnId = openRow.id;
|
|
631
|
-
//
|
|
632
|
-
//
|
|
633
|
-
//
|
|
773
|
+
// {§env-delta-log-pull} — establish a fresh worker's observation
|
|
774
|
+
// baseline immediately after its first turn opens. A fork already has
|
|
775
|
+
// its parent's cursor, so the NULL-guarded statement leaves it intact.
|
|
776
|
+
// Events committed after this statement belong to this or a later
|
|
777
|
+
// closed pull window; pre-existing state is read through the ordinary
|
|
778
|
+
// shared-world projections in this first packet.
|
|
779
|
+
await this.#db.engine_initialize_ambient_cursor.get({ workspace_id: workspaceId, worker_id: workerId });
|
|
780
|
+
// Threaded per turn, never engine state, so concurrent loops on
|
|
781
|
+
// different providers each read their own honest tokenizer values.
|
|
782
|
+
const systemCtx = {
|
|
783
|
+
db: this.#db, workspaceId, workerId, loopId, turnId,
|
|
784
|
+
writer: "plurnk",
|
|
785
|
+
signal: this.#loopAborts.get(loopId)?.signal,
|
|
786
|
+
streamEventNotify: this.#streamEventNotify,
|
|
787
|
+
wakeWorkerNotify: this.#wakeWorkerNotify,
|
|
788
|
+
tokenize: this.#tokenize,
|
|
789
|
+
mimetypes: this.#mimetypes,
|
|
790
|
+
defaultChannelFor: (s) => this.#schemes.defaultChannelFor(s),
|
|
791
|
+
pushNotice: (notice) => this.#notices.push(workspaceId, loopId, notice),
|
|
792
|
+
};
|
|
793
|
+
// Pre-model writes. Each prompt the model has not seen yet becomes an
|
|
794
|
+
// actionless `prompt` log row whose target is its durable prompt:// entry:
|
|
634
795
|
// - Turn 1: loop.prompt is the initial user prompt.
|
|
635
796
|
// - Turn N>1: only if Engine.inject (or wake-on-completion via
|
|
636
797
|
// daemon.inject) wrote a prompt entry for this turn slot
|
|
637
798
|
// between turn N-1 and N. Inject writes directly to entries;
|
|
638
799
|
// we DON'T re-foist here for N>1.
|
|
639
|
-
//
|
|
640
|
-
// from sequence=2 onward on prompt-foisted turns; 1 onward
|
|
641
|
-
// otherwise.
|
|
800
|
+
// Model ops dispatch after these pre-model rows.
|
|
642
801
|
let nextActionIndex = 1;
|
|
643
|
-
|
|
802
|
+
const turnOpenPaths = [];
|
|
803
|
+
// {§model-entry} — the worker's first turn opens with the model's own turn-0, mirrored OPEN: a
|
|
644
804
|
// worked turn PLAN → the environment FINDs the foist ACTUALLY dispatches → SEND[102]. Built
|
|
645
805
|
// from the real ops below (not a static print — we lean into the genuine echo paradigm) and
|
|
646
806
|
// written at sequence 1, so it reads first as the emission with the foisted results following.
|
|
647
807
|
const turnZeroMoves = [];
|
|
648
808
|
if (seq === 1) {
|
|
649
|
-
if (
|
|
809
|
+
if (workerFirstLoop)
|
|
650
810
|
nextActionIndex = 2; // reserve sequence 1 for the turn-0 echo
|
|
651
|
-
// Operator doc READs (PLURNK_SERVICE_MD_<ALIAS>, §actor-boundary-doc-injection). The docs were materialized
|
|
652
|
-
// as plurnk
|
|
653
|
-
// §actor-boundary keystone); foist a READ of each into THIS turn-0 so the model
|
|
811
|
+
// Operator doc READs (PLURNK_SERVICE_MD_<ALIAS>, {§actor-boundary-doc-injection}). The docs were materialized
|
|
812
|
+
// as worker://plurnk/<entry> entries by the plurnk worker (LoopDocs, via the
|
|
813
|
+
// {§actor-boundary} keystone); foist a READ of each into THIS turn-0 so the model
|
|
654
814
|
// reads them inline. It sees only the READ — the materializing EDIT
|
|
655
815
|
// lives in the plurnk worker's log, never the model's.
|
|
656
|
-
//
|
|
657
|
-
// each materialized plurnk
|
|
816
|
+
// {§operator-config-workspace-md-docs} — env docs union the workspace's client docs; foist a READ of
|
|
817
|
+
// each materialized worker://plurnk/<alias>.md (LoopDocs materialized the same set).
|
|
658
818
|
const { mdDocs } = await WorkspaceSettings.read(this.#db, workspaceId);
|
|
659
|
-
//
|
|
660
|
-
for (const doc of
|
|
819
|
+
// {§actor-boundary-doc-injection} — operator docs appear on the worker's first loop.
|
|
820
|
+
for (const doc of workerFirstLoop ? await WorkspaceSettings.resolveDocs(mdDocs) : []) {
|
|
661
821
|
const docTarget = {
|
|
662
822
|
kind: "url", raw: `worker://plurnk/${doc.entryName}`, scheme: "worker",
|
|
663
823
|
username: null, password: null, hostname: "plurnk", port: null,
|
|
664
|
-
pathname: `/${doc.entryName}`,
|
|
824
|
+
pathname: `/${doc.entryName}`, query: null, fragment: null,
|
|
665
825
|
};
|
|
666
826
|
const docRead = {
|
|
667
827
|
op: "READ", suffix: "", signal: null, target: docTarget,
|
|
668
|
-
lineMarker: null, body: null, position:
|
|
828
|
+
lineMarker: null, body: null, position: UNKNOWN_POSITION,
|
|
669
829
|
};
|
|
670
830
|
await this.dispatch({
|
|
671
831
|
statement: docRead, workspaceId, workerId, loopId, turnId,
|
|
@@ -673,95 +833,72 @@ export default class Engine {
|
|
|
673
833
|
});
|
|
674
834
|
nextActionIndex++;
|
|
675
835
|
}
|
|
676
|
-
const promptRow = loopRow; //
|
|
836
|
+
const promptRow = loopRow; // {§prompt-entry} — per-loop; fires every loop's turn 1
|
|
677
837
|
if (promptRow !== undefined && typeof promptRow.prompt === "string" && promptRow.prompt.length > 0) {
|
|
678
|
-
const
|
|
679
|
-
const
|
|
680
|
-
const
|
|
681
|
-
|
|
682
|
-
|
|
683
|
-
|
|
838
|
+
const openPaths = assertOpenPaths(JSON.parse(promptRow.open_paths), `Loop ${loopId} open_paths`);
|
|
839
|
+
const promptLoopSeq = promptRow.sequence; // the loop's per-worker sequence — model-facing, matching log coordinates (owner: the db id read as prompt/2/1)
|
|
840
|
+
const promptPath = promptTarget(promptLoopSeq, seq);
|
|
841
|
+
const entry = {
|
|
842
|
+
channels: { body: { content: promptRow.prompt, mimetype: "text/markdown" } },
|
|
843
|
+
tags: [],
|
|
844
|
+
attributes: { openPaths },
|
|
684
845
|
};
|
|
685
|
-
|
|
686
|
-
|
|
687
|
-
|
|
688
|
-
|
|
689
|
-
|
|
690
|
-
|
|
691
|
-
|
|
692
|
-
|
|
693
|
-
|
|
694
|
-
await this.#db.engine_fold_log_entry.run({ id: promptLogId });
|
|
695
|
-
nextActionIndex++;
|
|
696
|
-
// §prompt-auto-read (owner): the prompt's body reaches the model as a foisted
|
|
697
|
-
// READ of its own entry — first 12 lines (<1,12>), or the whole prompt (<1,-1>)
|
|
698
|
-
// when it runs fewer than 12 (whole-read form doubles as teaching). Prior prompts
|
|
699
|
-
// stay listed by path in the system packet's User Prompts section — reachable,
|
|
700
|
-
// never silently lost.
|
|
701
|
-
// §arrival-law (#499) — the preview bound is the knob (was a hardcoded 12). The
|
|
702
|
-
// line-slice is an EFFICIENCY (do not materialize hundreds of lines into rx); the
|
|
703
|
-
// RENDER is the law — packet-wire's arrival preview cuts the char dimension there,
|
|
704
|
-
// which a line marker structurally cannot (a 1-line slice of a 1-line bomb is 416).
|
|
705
|
-
const previewLines = Number(process.env.PLURNK_SERVICE_ARRIVAL_PREVIEW_LINES ?? "16");
|
|
706
|
-
const promptLineCount = promptRow.prompt.split("\n").length;
|
|
707
|
-
const promptRead = {
|
|
708
|
-
op: "READ", suffix: "", signal: null, target: promptPath,
|
|
709
|
-
lineMarker: { marks: promptLineCount >= previewLines ? [1, previewLines] : [1, -1] },
|
|
710
|
-
body: null, position: { line: 1, column: 1 },
|
|
711
|
-
};
|
|
712
|
-
await this.dispatch({
|
|
713
|
-
statement: promptRead, workspaceId, workerId, loopId, turnId,
|
|
714
|
-
sequence: nextActionIndex, origin: "plurnk", onDispatch,
|
|
846
|
+
await EntryCrud.writeEntry(promptPath.pathname, entry, systemCtx, "prompt", workerId);
|
|
847
|
+
turnOpenPaths.push(...openPaths);
|
|
848
|
+
const promptLogId = await this.#writePromptLog({
|
|
849
|
+
workerId,
|
|
850
|
+
loopId,
|
|
851
|
+
turnId,
|
|
852
|
+
sequence: nextActionIndex,
|
|
853
|
+
target: promptPath,
|
|
854
|
+
content: promptRow.prompt,
|
|
715
855
|
});
|
|
856
|
+
onDispatch?.(promptLogId);
|
|
716
857
|
nextActionIndex++;
|
|
717
858
|
}
|
|
718
859
|
}
|
|
719
|
-
// §prompt-
|
|
720
|
-
//
|
|
721
|
-
//
|
|
722
|
-
|
|
723
|
-
if (seq > 1) {
|
|
860
|
+
// {§prompt-loop-containment}: the loop contains every prompt that arrived
|
|
861
|
+
// while it ran. Publish each undelivered frame as a prompt row, oldest
|
|
862
|
+
// first, so rapid arrivals reach the model together exactly once.
|
|
863
|
+
{
|
|
724
864
|
const loopSeqRow = await this.#db.engine_loop_sequence.get({ loop_id: loopId });
|
|
725
865
|
const loopSeq = loopSeqRow?.sequence ?? loopId;
|
|
726
|
-
const
|
|
866
|
+
const prefix = promptLoopPrefix(loopSeq);
|
|
867
|
+
const undelivered = (await this.#db.drain_undelivered_prompts_for_loop.all({
|
|
868
|
+
owner_id: workerId,
|
|
869
|
+
pattern: `${prefix}%`,
|
|
870
|
+
prefix_len: prefix.length,
|
|
871
|
+
loop_id: loopId,
|
|
872
|
+
}))
|
|
727
873
|
.filter((r) => typeof r.content === "string" && r.content.length > 0);
|
|
728
874
|
for (const injectedRow of undelivered) {
|
|
729
|
-
const
|
|
875
|
+
const attributes = parsePromptAttributes(injectedRow.attributes, `Prompt ${injectedRow.pathname} attributes`);
|
|
876
|
+
const encodedOpenPaths = attributes.openPaths;
|
|
877
|
+
if (encodedOpenPaths !== undefined) {
|
|
878
|
+
turnOpenPaths.push(...assertOpenPaths(encodedOpenPaths, `Prompt ${injectedRow.pathname} openPaths`));
|
|
879
|
+
}
|
|
730
880
|
const ordinal = Number(injectedRow.pathname.split("/").filter(Boolean).at(-1));
|
|
731
|
-
const injTarget = promptTarget(
|
|
732
|
-
const
|
|
733
|
-
|
|
734
|
-
|
|
735
|
-
|
|
736
|
-
|
|
737
|
-
|
|
738
|
-
|
|
739
|
-
sequence: nextActionIndex, origin: "plurnk", onDispatch,
|
|
881
|
+
const injTarget = promptTarget(loopSeq, ordinal);
|
|
882
|
+
const promptLogId = await this.#writePromptLog({
|
|
883
|
+
workerId,
|
|
884
|
+
loopId,
|
|
885
|
+
turnId,
|
|
886
|
+
sequence: nextActionIndex,
|
|
887
|
+
target: injTarget,
|
|
888
|
+
content: injectedRow.content,
|
|
740
889
|
});
|
|
890
|
+
onDispatch?.(promptLogId);
|
|
741
891
|
nextActionIndex++;
|
|
742
892
|
}
|
|
743
893
|
}
|
|
744
|
-
// The
|
|
745
|
-
// every entry
|
|
746
|
-
//
|
|
747
|
-
// not dispatched. There is no
|
|
748
|
-
// on demand by FIND
|
|
749
|
-
//
|
|
750
|
-
//
|
|
751
|
-
//
|
|
752
|
-
// each read their own honest numbers.
|
|
753
|
-
const systemCtx = {
|
|
754
|
-
db: this.#db, workspaceId, workerId, loopId, turnId,
|
|
755
|
-
writer: "plurnk",
|
|
756
|
-
signal: this.#loopAborts.get(loopId)?.signal,
|
|
757
|
-
streamEventNotify: this.#streamEventNotify,
|
|
758
|
-
wakeWorkerNotify: this.#wakeWorkerNotify,
|
|
759
|
-
tokenize: this.#tokenize,
|
|
760
|
-
mimetypes: this.#mimetypes,
|
|
761
|
-
defaultChannelFor: (s) => this.#schemes.defaultChannelFor(s),
|
|
762
|
-
pushTelemetry: (event) => this.#telemetry.push(workspaceId, loopId, event),
|
|
763
|
-
};
|
|
764
|
-
// SPEC §membership D4/D5 — git-ls-files workspace membership, resolved at
|
|
894
|
+
// The persistent search-index pass (_search-index.maintain) attaches
|
|
895
|
+
// every readable entry/log projection to complete graph/FTS/vector derivations.
|
|
896
|
+
// NOT an action: no log entry, no sequence slot,
|
|
897
|
+
// not dispatched. There is no materialized manifest entry — the catalog
|
|
898
|
+
// is served on demand by FIND: recursive when asked, shallow-mapped below.
|
|
899
|
+
// {§semantic-embed-dedup} — one pass-wide semantic plan binds every
|
|
900
|
+
// chunk counter to the derivation identity it produces.
|
|
901
|
+
// SPEC {§membership} D4/D5 — git-ls-files workspace membership, resolved at
|
|
765
902
|
// prompt-composition (EMI is eager + exhaustive — git is the only bound). When the
|
|
766
903
|
// workspace's project_root is a git working tree, tracked files are
|
|
767
904
|
// members without a client `add`; active members are materialized
|
|
@@ -774,431 +911,618 @@ export default class Engine {
|
|
|
774
911
|
// The warm materialized membership before deriving. This second pass is the
|
|
775
912
|
// ordinary cheap change detector and captures any drift that landed meanwhile.
|
|
776
913
|
const fsDivergences = await GitMembership.indexGitMembership(systemCtx);
|
|
777
|
-
|
|
914
|
+
// {§packet-git-status} — one post-reconciliation snapshot supplies both
|
|
915
|
+
// the compact packet summary and each causal file event's exact XY state.
|
|
916
|
+
const gitStatus = await GitState.status(this.#db, workspaceId, this.#loopAborts.get(loopId)?.signal);
|
|
917
|
+
await this.#logFsFictions(workspaceId, fsDivergences, gitStatus);
|
|
778
918
|
// The refresh above may have changed bodies (including model/client edits
|
|
779
919
|
// since the startup warm). Re-derive to completion before packet/model
|
|
780
920
|
// construction. Membership is already current, so this pass does not
|
|
781
921
|
// consume the filesystem divergences a second time.
|
|
782
922
|
await this.#queueWorkspaceWarm(systemCtx, true, false);
|
|
783
|
-
// Turn-0 catalog preview (PLURNK_SERVICE_FILES_ITEMS, §actor-boundary-catalog-preview):
|
|
784
|
-
//
|
|
785
|
-
//
|
|
786
|
-
//
|
|
787
|
-
//
|
|
923
|
+
// Turn-0 catalog preview (PLURNK_SERVICE_FILES_ITEMS, {§actor-boundary-catalog-preview}):
|
|
924
|
+
// FIND surveys foisted into the worker's first model turn so it opens with its catalog.
|
|
925
|
+
// Folder-capable surfaces reveal one level with `*`; each deeper directory is an
|
|
926
|
+
// actionable `dir/**` aggregate. The curated kernel docs remain recursive, so the
|
|
927
|
+
// opening packet demonstrates both navigation forms. Empty results are orientation.
|
|
788
928
|
if (seq === 1) {
|
|
789
|
-
//
|
|
929
|
+
// {§operator-config-workspace-files-items} — workspace filesItems replaces the env default.
|
|
790
930
|
const { filesItems: workspaceMI } = await WorkspaceSettings.read(this.#db, workspaceId);
|
|
791
931
|
const filesItems = workspaceMI !== null ? normalizeFilesItems(workspaceMI) : readFilesItems();
|
|
792
|
-
if (filesItems !== null &&
|
|
793
|
-
// engine_scheme_catalog_summary is the scheme source:
|
|
794
|
-
// one row per
|
|
932
|
+
if (filesItems !== null && workerFirstLoop) { // {§actor-boundary-catalog-preview} — once per worker
|
|
933
|
+
// engine_scheme_catalog_summary is the workspace-bounded scheme source: ordered,
|
|
934
|
+
// one row per stored entry scheme. log:// is absent —
|
|
795
935
|
// it lives in log_entries, not the catalog (present-mode, the # Log section).
|
|
796
936
|
const catalogSchemes = await this.#db.engine_scheme_catalog_summary.all({ workspace_id: workspaceId });
|
|
797
|
-
//
|
|
798
|
-
|
|
799
|
-
|
|
800
|
-
|
|
801
|
-
|
|
802
|
-
//
|
|
803
|
-
|
|
804
|
-
|
|
805
|
-
|
|
937
|
+
// Entry-bearing plugin schemes foist alongside the four structural surveys below.
|
|
938
|
+
const foistSchemes = catalogSchemes
|
|
939
|
+
.filter((catalog) => catalog.scheme !== "prompt" && catalog.scheme !== "worker")
|
|
940
|
+
.map(({ scheme, shallow_items }) => ({ scheme, shallow_items }));
|
|
941
|
+
// Commons + project files always foist. An empty result establishes that the
|
|
942
|
+
// surface exists and currently contains nothing.
|
|
943
|
+
foistSchemes.push({
|
|
944
|
+
scheme: "worker",
|
|
945
|
+
shallow_items: catalogSchemes.find((c) => c.scheme === "worker")?.shallow_items ?? 0,
|
|
946
|
+
});
|
|
806
947
|
if (!foistSchemes.some((c) => c.scheme === "file"))
|
|
807
|
-
foistSchemes.push({ scheme: "file",
|
|
808
|
-
for (const { scheme,
|
|
809
|
-
const
|
|
810
|
-
const
|
|
811
|
-
// Only the
|
|
812
|
-
//
|
|
813
|
-
|
|
814
|
-
|
|
815
|
-
// scheme → no cap. (file in this loop always has entries>0, so no degenerate <1,0>.)
|
|
816
|
-
const cap = isFile && filesItems > 0 ? Math.min(filesItems, entries) : null;
|
|
948
|
+
foistSchemes.push({ scheme: "file", shallow_items: 0 }); // Empty project surface still receives its orienting FIND.
|
|
949
|
+
for (const { scheme, shallow_items: shallowItems } of foistSchemes) {
|
|
950
|
+
const isFile = scheme === "file";
|
|
951
|
+
const pattern = this.#schemes.manifestFor(scheme)?.folderScopes === true ? "*" : "**";
|
|
952
|
+
// Only the file map takes PLURNK_SERVICE_FILES_ITEMS as an explicit first-N
|
|
953
|
+
// cap. Every other ordinary survey uses FIND's markerless first page.
|
|
954
|
+
const cap = isFile && filesItems > 0 && shallowItems > 0 ? Math.min(filesItems, shallowItems) : null;
|
|
955
|
+
const catalogMarker = cap === null ? null : { marks: [1, cap] };
|
|
817
956
|
// The file survey foists as the BARE relative glob — the path shape plurnk.md
|
|
818
|
-
// teaches (`src/**`, `**/notes.md`; bare = project-relative) — so the turn-0
|
|
957
|
+
// teaches (`*`, `src/**`, `**/notes.md`; bare = project-relative) — so the turn-0
|
|
819
958
|
// exemplar and the log rows the model reads never train a leading-slash or
|
|
820
959
|
// file:/// habit the rest of the teaching contradicts.
|
|
821
960
|
const catalogFind = {
|
|
822
961
|
op: "FIND", suffix: "", signal: null,
|
|
823
|
-
target: isFile ? { kind: "local", raw:
|
|
962
|
+
target: isFile ? { kind: "local", raw: pattern } : {
|
|
824
963
|
kind: "url",
|
|
825
|
-
raw: `${
|
|
826
|
-
scheme
|
|
964
|
+
raw: `${scheme}:///${pattern}`,
|
|
965
|
+
scheme,
|
|
827
966
|
username: null, password: null, hostname: null, port: null,
|
|
828
|
-
pathname:
|
|
829
|
-
|
|
967
|
+
pathname: `/${pattern}`,
|
|
968
|
+
query: null, fragment: null,
|
|
830
969
|
},
|
|
831
970
|
body: null,
|
|
832
|
-
lineMarker:
|
|
833
|
-
position:
|
|
971
|
+
lineMarker: catalogMarker,
|
|
972
|
+
position: UNKNOWN_POSITION,
|
|
834
973
|
};
|
|
835
974
|
await this.dispatch({
|
|
836
975
|
statement: catalogFind, workspaceId, workerId, loopId, turnId,
|
|
837
976
|
sequence: nextActionIndex, origin: "plurnk", onDispatch,
|
|
838
977
|
});
|
|
839
978
|
nextActionIndex++;
|
|
840
|
-
// §model-entry — the same FIND, rendered back to DSL for the turn-0 echo
|
|
841
|
-
// own survey, mirrored OPEN).
|
|
842
|
-
|
|
979
|
+
// {§model-entry} — the same FIND, rendered back to DSL for the turn-0 echo
|
|
980
|
+
// (the model's own survey, mirrored OPEN). An explicit file cap rides as
|
|
981
|
+
// `<1,N>`; ordinary surveys teach the safe markerless default by example.
|
|
982
|
+
const target = isFile ? pattern : `${scheme}:///${pattern}`;
|
|
983
|
+
turnZeroMoves.push(`<<FIND(${target})${cap === null ? "" : `<1,${cap}>`}::FIND`);
|
|
843
984
|
}
|
|
844
985
|
// The kernel's self-documenting surface — FIND(worker://plurnk/docs/**), uncapped,
|
|
845
|
-
// always (
|
|
986
|
+
// always ({§schemes-directory}, published under {§entry-owner}).
|
|
846
987
|
await Owner.kernelId(this.#db, workspaceId); // the row exists even before docs materialize — the empty survey is orienting, never 404
|
|
847
988
|
const kernelDocsFind = {
|
|
848
989
|
op: "FIND", suffix: "", signal: null,
|
|
849
|
-
target: { kind: "url", raw: "worker://plurnk/docs/**", scheme: "worker", username: null, password: null, hostname: "plurnk", port: null, pathname: "/docs/**",
|
|
850
|
-
body: null, lineMarker:
|
|
990
|
+
target: { kind: "url", raw: "worker://plurnk/docs/**", scheme: "worker", username: null, password: null, hostname: "plurnk", port: null, pathname: "/docs/**", query: null, fragment: null },
|
|
991
|
+
body: null, lineMarker: { marks: [1, -1] }, position: UNKNOWN_POSITION,
|
|
851
992
|
};
|
|
852
993
|
await this.dispatch({ statement: kernelDocsFind, workspaceId, workerId, loopId, turnId, sequence: nextActionIndex, origin: "plurnk", onDispatch });
|
|
853
994
|
nextActionIndex++;
|
|
854
|
-
turnZeroMoves.push("<<FIND(worker://plurnk/docs/**)
|
|
855
|
-
// §worker-scheme —
|
|
856
|
-
//
|
|
857
|
-
//
|
|
858
|
-
|
|
859
|
-
|
|
860
|
-
|
|
861
|
-
|
|
862
|
-
op: "FIND", suffix: "", signal: null,
|
|
863
|
-
target: { kind: "url", raw: "worker://~/**", scheme: "worker", username: null, password: null, hostname: "~", port: null, pathname: "/**", params: {}, fragment: null },
|
|
864
|
-
body: null, lineMarker: null, position: { line: 1, column: 1 },
|
|
865
|
-
};
|
|
866
|
-
await this.dispatch({ statement: runFind, workspaceId, workerId, loopId, turnId, sequence: nextActionIndex, origin: "plurnk", onDispatch });
|
|
867
|
-
nextActionIndex++;
|
|
868
|
-
turnZeroMoves.push("<<FIND(worker://~/**)::FIND"); // §model-entry — the own-space survey, into the turn-0 echo
|
|
869
|
-
}
|
|
870
|
-
}
|
|
871
|
-
// #260 — foist a turn-0 READ of each client-passed @file path so its content sits in front
|
|
872
|
-
// of the model. Daemon owns the workspace → a normal file:/// member READ; a missing or
|
|
873
|
-
// non-member path surfaces its READ outcome (4xx) in the log, visible to the model.
|
|
874
|
-
const openPathsRow = await this.#db.engine_get_loop_open_paths.get({ loop_id: loopId });
|
|
875
|
-
for (const raw of JSON.parse(openPathsRow?.open_paths ?? "[]")) {
|
|
876
|
-
const pathname = raw.startsWith("/") ? raw : `/${raw}`;
|
|
877
|
-
const fileRead = {
|
|
878
|
-
op: "READ", suffix: "", signal: null, lineMarker: null,
|
|
879
|
-
target: {
|
|
880
|
-
kind: "url", raw: `file://${pathname}`, scheme: "file",
|
|
881
|
-
username: null, password: null, hostname: null, port: null,
|
|
882
|
-
pathname, params: {}, fragment: null,
|
|
883
|
-
},
|
|
884
|
-
body: null, position: { line: 1, column: 1 },
|
|
995
|
+
turnZeroMoves.push("<<FIND(worker://plurnk/docs/**)<1,-1>::FIND");
|
|
996
|
+
// {§worker-scheme} — the building worker's own scratch gets the same markerless
|
|
997
|
+
// one-level survey in its perspective alone. It always executes: an empty private
|
|
998
|
+
// space is useful orientation, not grounds to hide the surface.
|
|
999
|
+
const ownFind = {
|
|
1000
|
+
op: "FIND", suffix: "", signal: null,
|
|
1001
|
+
target: { kind: "url", raw: "worker://~/*", scheme: "worker", username: null, password: null, hostname: "~", port: null, pathname: "/*", query: null, fragment: null },
|
|
1002
|
+
body: null, lineMarker: null, position: UNKNOWN_POSITION,
|
|
885
1003
|
};
|
|
886
|
-
await this.dispatch({
|
|
887
|
-
statement: fileRead, workspaceId, workerId, loopId, turnId,
|
|
888
|
-
sequence: nextActionIndex, origin: "plurnk", onDispatch,
|
|
889
|
-
});
|
|
1004
|
+
await this.dispatch({ statement: ownFind, workspaceId, workerId, loopId, turnId, sequence: nextActionIndex, origin: "plurnk", onDispatch });
|
|
890
1005
|
nextActionIndex++;
|
|
1006
|
+
turnZeroMoves.push("<<FIND(worker://~/*)::FIND"); // {§model-entry} — the own-space survey, into the turn-0 echo
|
|
891
1007
|
}
|
|
892
|
-
// §model-entry — mirror the model's turn-0 OPEN at sequence 1: PLAN → the FINDs actually
|
|
1008
|
+
// {§model-entry} — mirror the model's turn-0 OPEN at sequence 1: PLAN → the FINDs actually
|
|
893
1009
|
// foisted above (real, their results already in the log) → SEND[102]. Dynamic — it reflects
|
|
894
1010
|
// the true survey, never a frozen print — and OPEN: the worked example the model orients on,
|
|
895
1011
|
// so the grammar can stay thin. Subsequent turns mirror the model's real output, folded.
|
|
896
|
-
if (
|
|
897
|
-
const emission = ["<<PLAN:
|
|
1012
|
+
if (workerFirstLoop) {
|
|
1013
|
+
const emission = ["<<PLAN:Survey context, then address the prompt.:PLAN", ...turnZeroMoves, "<<SEND[102]:Next, address the prompt using the survey.:SEND"].join("\n");
|
|
898
1014
|
await this.#dispatcher.writeModelEntry({ verbatim: emission, workerId, loopId, turnId, sequence: 1, folded: false, origin: "plurnk" });
|
|
899
1015
|
}
|
|
900
1016
|
}
|
|
901
|
-
// §
|
|
902
|
-
//
|
|
903
|
-
//
|
|
904
|
-
|
|
905
|
-
|
|
906
|
-
|
|
1017
|
+
// {§methods-loop-run-open-paths}: selected workspace paths belong to
|
|
1018
|
+
// the prompt frame. Publish the frame, then dispatch ordinary core READs
|
|
1019
|
+
// in that same turn; missing/non-member paths retain their normal 4xx.
|
|
1020
|
+
for (const raw of turnOpenPaths) {
|
|
1021
|
+
const pathname = raw.startsWith("/") ? raw : `/${raw}`;
|
|
1022
|
+
const fileRead = {
|
|
1023
|
+
op: "READ", suffix: "", signal: null, lineMarker: null,
|
|
1024
|
+
target: {
|
|
1025
|
+
kind: "url", raw: `file://${pathname}`, scheme: "file",
|
|
1026
|
+
username: null, password: null, hostname: null, port: null,
|
|
1027
|
+
pathname, query: null, fragment: null,
|
|
1028
|
+
},
|
|
1029
|
+
body: null, position: UNKNOWN_POSITION,
|
|
1030
|
+
};
|
|
1031
|
+
await this.dispatch({
|
|
1032
|
+
statement: fileRead, workspaceId, workerId, loopId, turnId,
|
|
1033
|
+
sequence: nextActionIndex, origin: "plurnk", onDispatch,
|
|
1034
|
+
});
|
|
1035
|
+
nextActionIndex++;
|
|
1036
|
+
}
|
|
1037
|
+
// {§env-delta-log-pull} — materialize ambient observations before packet
|
|
1038
|
+
// composition and reserve their action indices. {§exec-stream} owns the
|
|
1039
|
+
// distinct byte-cursor path for this worker's streams.
|
|
1040
|
+
// {§exec-poll} — EXEC `<0>` is turn-scoped: reap the worker's open turn-scoped streams (necessarily
|
|
907
1041
|
// from a prior turn — this runs before the turn's own spawns) so a `<0>` never survives into
|
|
908
1042
|
// the subsequent turn. The terminal output then surfaces born-OPEN via the stream-delta path.
|
|
909
1043
|
await this.#reapTurnScopedStreams(workerId);
|
|
910
1044
|
nextActionIndex += await this.#materializeEnvironmentDeltas({ workspaceId, workerId, loopId, turnId, fromSequence: nextActionIndex });
|
|
911
1045
|
nextActionIndex += await this.#materializeStreamDeltas({ workerId, loopId, turnId, fromSequence: nextActionIndex });
|
|
912
|
-
//
|
|
913
|
-
//
|
|
914
|
-
//
|
|
915
|
-
|
|
916
|
-
|
|
1046
|
+
// The post-reconciliation Git snapshot above is threaded into the packet
|
|
1047
|
+
// and every budget rebuild; overflow never shells again.
|
|
1048
|
+
// Notices are non-terminal observations, never operation-failure truth.
|
|
1049
|
+
// Drain once and thread the same set through every grinder rebuild.
|
|
1050
|
+
const notices = this.#notices.drain(loopId)
|
|
1051
|
+
.filter((event) => event.level !== "info");
|
|
1052
|
+
// Build the model request packet ({§packet-stored-shape}). The log build
|
|
917
1053
|
// queries log_entries scoped to the worker — the prompt entry just
|
|
918
1054
|
// written (if turn 1) is part of that query result.
|
|
919
1055
|
let requestPacket = await this.#packets.buildRequestPacket({
|
|
920
1056
|
initialMessages: messages, requirements, workspaceId, workerId, loopId,
|
|
921
|
-
currentTurnSeq: seq, provider, gitStatus,
|
|
1057
|
+
currentTurnSeq: seq, provider, gitStatus, notices, transientOpenLogEntryId,
|
|
922
1058
|
});
|
|
923
|
-
// SPEC §grinder — budget grinder, pre-LLM: reclaim window on actual overflow.
|
|
1059
|
+
// SPEC {§grinder} — budget grinder, pre-LLM: reclaim window on actual overflow.
|
|
924
1060
|
const enforced = await this.#packets.enforceBudget({
|
|
925
1061
|
packet: requestPacket, provider, workerId, loopId, turnId,
|
|
926
1062
|
// The overflow error row is minted at the turn's running sequence (nextActionIndex), pre-generate;
|
|
927
1063
|
// runTurn advances the counter past it below so the post-generate dispatch rows never collide.
|
|
928
1064
|
mintSequence: nextActionIndex,
|
|
929
|
-
//
|
|
930
|
-
//
|
|
931
|
-
// ephemeral buffer is empty pre-generate (events drain on the next turn's build).
|
|
1065
|
+
// Rebuilds re-derive durable errors and retain the one drained notice
|
|
1066
|
+
// set; neither path can duplicate or swallow a product failure.
|
|
932
1067
|
rebuild: () => this.#packets.buildRequestPacket({
|
|
933
1068
|
initialMessages: messages, requirements, workspaceId, workerId, loopId,
|
|
934
|
-
currentTurnSeq: seq, provider, gitStatus,
|
|
1069
|
+
currentTurnSeq: seq, provider, gitStatus, notices, transientOpenLogEntryId,
|
|
935
1070
|
}),
|
|
936
1071
|
});
|
|
937
|
-
if (enforced.
|
|
938
|
-
nextActionIndex += 1; //
|
|
1072
|
+
if (enforced.recorded)
|
|
1073
|
+
nextActionIndex += 1; // a fold-to-fit Problem consumed the reserved sequence
|
|
939
1074
|
requestPacket = enforced.packet;
|
|
940
1075
|
if (!enforced.fit) {
|
|
941
|
-
// §grinder-hard-413-recovery
|
|
942
|
-
//
|
|
943
|
-
//
|
|
944
|
-
|
|
945
|
-
|
|
946
|
-
|
|
947
|
-
|
|
948
|
-
|
|
949
|
-
|
|
950
|
-
|
|
951
|
-
|
|
952
|
-
|
|
953
|
-
|
|
954
|
-
|
|
955
|
-
|
|
956
|
-
|
|
957
|
-
|
|
958
|
-
|
|
959
|
-
scheme: null, username: null, password: null, hostname: null, port: null,
|
|
960
|
-
pathname: null, params: null, fragment: null, lineMarker: null,
|
|
961
|
-
tx: "", mimetype_tx: "text/plain",
|
|
962
|
-
rx: JSON.stringify({ status: 413, kind: "budget_overflow", message: "the packet exceeds the budget even after the newest turn folded — this is your ONE recovery turn: KILL or FOLD history items now (the budget table lists the heaviest) to reclaim room; a second consecutive overflow terminates the loop" }),
|
|
963
|
-
mimetype_rx: "application/json", status_rx: 413, tokens: 0, state: "failed", outcome: "budget_overflow",
|
|
964
|
-
attrs: "{}",
|
|
1076
|
+
// {§grinder-hard-413-recovery}/{§grinder-hard-413-abort} — admit
|
|
1077
|
+
// one physically sendable informed recovery turn; a physical
|
|
1078
|
+
// overflow or consecutive policy overflow terminates immediately.
|
|
1079
|
+
let physicalAdmission = await this.#packets.physicalAdmission(requestPacket, provider, this.#loopAborts.get(loopId)?.signal);
|
|
1080
|
+
let recoveryAdmitted = false;
|
|
1081
|
+
if (physicalAdmission.admitted && !this.#hardOverflowRecovery.has(loopId)) {
|
|
1082
|
+
const ceiling = this.#packets.ceilingFor(provider);
|
|
1083
|
+
if (ceiling === null) {
|
|
1084
|
+
throw new Error("an unbounded prompt budget cannot enter budget recovery");
|
|
1085
|
+
}
|
|
1086
|
+
await this.#problems.record({
|
|
1087
|
+
workerId,
|
|
1088
|
+
loopId,
|
|
1089
|
+
turnId,
|
|
1090
|
+
sequence: nextActionIndex++,
|
|
1091
|
+
origin: "model",
|
|
1092
|
+
source: "engine",
|
|
1093
|
+
result: BudgetOverflow.result(requestPacket.tokens, ceiling, true),
|
|
965
1094
|
});
|
|
966
1095
|
// Rebuild so the recovery-steer row just minted renders in THIS packet's log +
|
|
967
1096
|
// errors sections (the same re-derive contract the soft grind uses).
|
|
968
|
-
nextActionIndex += 1;
|
|
969
1097
|
requestPacket = await this.#packets.buildRequestPacket({
|
|
970
1098
|
initialMessages: messages, requirements, workspaceId, workerId, loopId,
|
|
971
|
-
currentTurnSeq: seq, provider, gitStatus,
|
|
1099
|
+
currentTurnSeq: seq, provider, gitStatus, notices, transientOpenLogEntryId,
|
|
972
1100
|
});
|
|
1101
|
+
physicalAdmission = await this.#packets.physicalAdmission(requestPacket, provider, this.#loopAborts.get(loopId)?.signal);
|
|
1102
|
+
if (physicalAdmission.admitted) {
|
|
1103
|
+
this.#hardOverflowRecovery.add(loopId);
|
|
1104
|
+
recoveryAdmitted = true;
|
|
1105
|
+
}
|
|
973
1106
|
}
|
|
974
|
-
|
|
975
|
-
// Hard 413: physically unsendable, or
|
|
1107
|
+
if (!recoveryAdmitted) {
|
|
1108
|
+
// Hard 413: physically unsendable, or still over after the informed recovery turn.
|
|
1109
|
+
const ceiling = this.#packets.ceilingFor(provider);
|
|
1110
|
+
if (ceiling === null) {
|
|
1111
|
+
throw new Error("an unbounded prompt budget cannot hard-stop");
|
|
1112
|
+
}
|
|
1113
|
+
if (!enforced.recorded || !physicalAdmission.admitted) {
|
|
1114
|
+
await this.#problems.record({
|
|
1115
|
+
workerId,
|
|
1116
|
+
loopId,
|
|
1117
|
+
turnId,
|
|
1118
|
+
sequence: nextActionIndex++,
|
|
1119
|
+
origin: "plurnk",
|
|
1120
|
+
source: "engine",
|
|
1121
|
+
result: BudgetOverflow.result(requestPacket.tokens, ceiling, false, physicalAdmission.admitted
|
|
1122
|
+
? undefined
|
|
1123
|
+
: {
|
|
1124
|
+
reason: physicalAdmission.reason,
|
|
1125
|
+
detail: physicalAdmission.detail,
|
|
1126
|
+
capacity: physicalAdmission.capacity,
|
|
1127
|
+
tokens: physicalAdmission.measurement?.tokens,
|
|
1128
|
+
tokenKind: physicalAdmission.measurement?.kind,
|
|
1129
|
+
tokenSource: physicalAdmission.measurement?.source,
|
|
1130
|
+
}),
|
|
1131
|
+
});
|
|
1132
|
+
requestPacket = await this.#packets.buildRequestPacket({
|
|
1133
|
+
initialMessages: messages, requirements, workspaceId, workerId, loopId,
|
|
1134
|
+
currentTurnSeq: seq, provider, gitStatus, notices, transientOpenLogEntryId,
|
|
1135
|
+
});
|
|
1136
|
+
}
|
|
976
1137
|
// Skip the LLM, close the turn, and let runLoop abandon.
|
|
977
|
-
const hardPacket = this.#packets.completePacket(requestPacket, { content: "", ops: [], reasoning: null }, null, provider);
|
|
978
1138
|
await this.#db.engine_close_turn.run({
|
|
979
|
-
id: turnId, status: 413, packet:
|
|
980
|
-
|
|
981
|
-
|
|
1139
|
+
id: turnId, status: 413, packet: StoredPacket.stringify(requestPacket),
|
|
1140
|
+
// The attempted turn retains its effective allowance even
|
|
1141
|
+
// when no provider exchange completed. {§tokenomics-client-gauge}
|
|
1142
|
+
usage_prompt_budget: this.#packets.promptBudgetFor(provider),
|
|
982
1143
|
finish_reason: "budget_hard_stop", model: provider.model, meta: "{}",
|
|
983
1144
|
});
|
|
984
|
-
return {
|
|
1145
|
+
return {
|
|
1146
|
+
turnId,
|
|
1147
|
+
status: 413,
|
|
1148
|
+
outcomes: [],
|
|
1149
|
+
fingerprint: "",
|
|
1150
|
+
budgetStruck: enforced.struck,
|
|
1151
|
+
budgetHardStop: true,
|
|
1152
|
+
steerStruck: false,
|
|
1153
|
+
emissionAttempts: 0,
|
|
1154
|
+
emissionExhausted: false,
|
|
1155
|
+
budget: BudgetOverflow.measure(requestPacket.tokens, ceiling),
|
|
1156
|
+
};
|
|
985
1157
|
}
|
|
986
1158
|
}
|
|
987
1159
|
else {
|
|
988
|
-
// A fitting turn clears the recovery grant
|
|
989
|
-
//
|
|
1160
|
+
// A fitting turn clears the recovery grant; a later independent overflow can earn
|
|
1161
|
+
// a fresh recovery turn (chronic overflow still strikes out via the rail).
|
|
990
1162
|
this.#hardOverflowRecovery.delete(loopId);
|
|
991
1163
|
}
|
|
992
1164
|
const modelMessages = PacketWire.packetToWireMessages(requestPacket);
|
|
993
|
-
//
|
|
994
|
-
// the
|
|
995
|
-
//
|
|
996
|
-
// from our budget conflated the two and guillotined a reasoning model's
|
|
997
|
-
// out-of-band reasoning as the packet filled (`ceiling - packet` → near-zero
|
|
998
|
-
// decode → finish=length mid-reasoning → no emission → strike spiral). The
|
|
999
|
-
// provider enforces its physical wall on its own.
|
|
1165
|
+
// Packet pressure and provider generation are independent. The grinder governs
|
|
1166
|
+
// only the request packet; maxTokens comes only from the provider envelope and
|
|
1167
|
+
// never shrinks as the virtual prompt budget fills.
|
|
1000
1168
|
let response;
|
|
1169
|
+
let splitResponse;
|
|
1001
1170
|
let railGrammar;
|
|
1002
|
-
|
|
1003
|
-
|
|
1004
|
-
|
|
1005
|
-
|
|
1006
|
-
|
|
1007
|
-
|
|
1008
|
-
|
|
1171
|
+
let railEvidence;
|
|
1172
|
+
let emissionAttempts = 0;
|
|
1173
|
+
let providerCallInFlight = false;
|
|
1174
|
+
let providerAttemptSequence = 0;
|
|
1175
|
+
let providerAttemptId = null;
|
|
1176
|
+
let providerAttemptAttributions = [];
|
|
1177
|
+
const providerSignal = this.#loopAborts.get(loopId)?.signal ?? signal;
|
|
1178
|
+
// {§client-metadata}
|
|
1009
1179
|
const { client } = await WorkspaceSettings.read(this.#db, workspaceId);
|
|
1180
|
+
const observeProviderAttempt = async (id, attemptResponse) => {
|
|
1181
|
+
const attemptUsage = attemptResponse.assistant.usage;
|
|
1182
|
+
await this.#db.engine_observe_turn_attempt_response.run({
|
|
1183
|
+
id,
|
|
1184
|
+
response: JSON.stringify(attemptResponse),
|
|
1185
|
+
usage_prompt: attemptUsage.prompt,
|
|
1186
|
+
usage_completion: attemptUsage.completion,
|
|
1187
|
+
usage_reasoning: attemptUsage.reasoning,
|
|
1188
|
+
usage_cached: attemptUsage.cached,
|
|
1189
|
+
usage_cost: JSON.stringify({
|
|
1190
|
+
kind: "unknown",
|
|
1191
|
+
reason: "provider response retained before monetary classification",
|
|
1192
|
+
}),
|
|
1193
|
+
finish_reason: attemptResponse.assistant.finishReason,
|
|
1194
|
+
model: attemptResponse.assistant.model,
|
|
1195
|
+
});
|
|
1196
|
+
};
|
|
1197
|
+
const classifyProviderAttempt = async (id, attemptResponse, attemptSplit, sequence, accepted, failure = null) => {
|
|
1198
|
+
const attemptUsage = attemptSplit.callMetadata.usage;
|
|
1199
|
+
const attemptCost = providerCostFor(provider, attemptUsage, attemptResponse.charge);
|
|
1200
|
+
const attemptCostUsd = providerCostUsd(attemptCost);
|
|
1201
|
+
await this.#db.engine_classify_turn_attempt_response.run({
|
|
1202
|
+
id,
|
|
1203
|
+
accepted: accepted ? 1 : 0,
|
|
1204
|
+
parse_errors: JSON.stringify(attemptSplit.parseErrors),
|
|
1205
|
+
failure: failure === null ? null : JSON.stringify(failure),
|
|
1206
|
+
usage_cost: JSON.stringify(attemptCost),
|
|
1207
|
+
usage_cost_usd: attemptCostUsd,
|
|
1208
|
+
});
|
|
1209
|
+
emissionAttempts = sequence;
|
|
1210
|
+
};
|
|
1010
1211
|
try {
|
|
1011
|
-
// §turn-lifecycle
|
|
1012
|
-
// committed op is provider latency + a full first-turn generation, ~70s local): a static
|
|
1013
|
-
// screen there is indistinguishable from a hang. Bracket generate() with two telemetry beats
|
|
1014
|
-
// so a client can show "awaiting model" the instant the turn starts and flip to "parsing" when
|
|
1015
|
-
// ops are about to land. Base telemetry/event channel (the embed_progress precedent, §tokenomics
|
|
1016
|
-
// clients already render it unconditionally); the abort guard keeps a cancelled loop silent.
|
|
1212
|
+
// {§turn-lifecycle}: bracket the complete provider-attempt window with liveness notices.
|
|
1017
1213
|
if (!signal?.aborted)
|
|
1018
|
-
this.#
|
|
1019
|
-
// generate rides the LOOP signal (already chained from the caller's), so a loop-level
|
|
1020
|
-
// abort — the §operator-config-loop-timeout wall — cancels a stuck provider call, not
|
|
1021
|
-
// just the schemes. Bare runTurn (no runLoop) has no loop entry → the caller's signal.
|
|
1022
|
-
// #391 — the turn COORDINATE (workspace + loop-seq/turn-seq of the turn being generated)
|
|
1023
|
-
// rides as first-party metadata (Plurnk-Workspace-Id/Loop/Turn) so the endpoint flywheel keys
|
|
1024
|
-
// on an AUTHORITATIVE hierarchy, not a scraped context-log approximation. The daemon owns
|
|
1025
|
-
// the value; providers stamps the header (same split as Worker-Id, #26). loopSeq (the 1-based
|
|
1026
|
-
// coordinate, not the DB id) resolves the same way the prompt-slot path does (§log coords).
|
|
1214
|
+
this.#notices.push(workspaceId, loopId, { source: "engine:turn", kind: "turn_awaiting_model", level: "info", message: "awaiting model response" });
|
|
1027
1215
|
const loopSeq = (await this.#db.engine_loop_sequence.get({ loop_id: loopId }))?.sequence ?? loopId;
|
|
1028
1216
|
railGrammar = await this.#grammarConstraint(provider);
|
|
1029
|
-
|
|
1217
|
+
const primaryWorkerId = String(await this.resolveWorkerPrimary(workerId));
|
|
1218
|
+
const attemptLimit = readEmissionAttempts();
|
|
1219
|
+
const maxTokens = this.#packets.maxTokensFor(provider) ?? undefined;
|
|
1220
|
+
const strikeStreak = this.#strikes.streak(loopId);
|
|
1221
|
+
for (let attempt = 1; attempt <= attemptLimit; attempt++) {
|
|
1222
|
+
// Every attempt carries the exact same model packet, coordinates,
|
|
1223
|
+
// limits, and engine-strike state. Plugin-authored tags are pulled
|
|
1224
|
+
// for the attempt and do not alter the model messages. No failed
|
|
1225
|
+
// emission is appended and no new engine turn opens between calls.
|
|
1226
|
+
providerAttemptSequence = attempt;
|
|
1227
|
+
const attributionContext = Object.freeze({
|
|
1228
|
+
workspaceId: String(workspaceId),
|
|
1229
|
+
workerId: String(workerId),
|
|
1230
|
+
primaryWorkerId,
|
|
1231
|
+
loop: loopSeq,
|
|
1232
|
+
turn: seq,
|
|
1233
|
+
attempt,
|
|
1234
|
+
});
|
|
1235
|
+
providerAttemptAttributions = await this.#attemptAttributions(provider, attributionContext);
|
|
1236
|
+
requestPacket = { ...requestPacket, attributions: providerAttemptAttributions };
|
|
1237
|
+
const attemptRow = await this.#db.engine_open_turn_attempt.get({
|
|
1238
|
+
turn_id: turnId,
|
|
1239
|
+
sequence: attempt,
|
|
1240
|
+
attributions: JSON.stringify(providerAttemptAttributions),
|
|
1241
|
+
model: provider.model,
|
|
1242
|
+
});
|
|
1243
|
+
if (attemptRow === undefined) {
|
|
1244
|
+
throw new Error(`Engine.runTurn: provider attempt ${attempt} did not open`);
|
|
1245
|
+
}
|
|
1246
|
+
providerAttemptId = attemptRow.id;
|
|
1247
|
+
providerCallInFlight = true;
|
|
1248
|
+
const completedResponse = await observed(// {§observability-boundary}
|
|
1249
|
+
"provider.generate", { model: provider.model, attempt }, async (span) => {
|
|
1250
|
+
const generated = await provider.generate({
|
|
1251
|
+
messages: modelMessages,
|
|
1252
|
+
workerId: String(workerId),
|
|
1253
|
+
primaryWorkerId,
|
|
1254
|
+
signal: providerSignal,
|
|
1255
|
+
grammar: railGrammar,
|
|
1256
|
+
maxTokens,
|
|
1257
|
+
strikes: strikeStreak,
|
|
1258
|
+
attributions: providerAttemptAttributions.length > 0
|
|
1259
|
+
? providerAttemptAttributions
|
|
1260
|
+
: undefined,
|
|
1261
|
+
client: client ?? undefined,
|
|
1262
|
+
workspaceId: String(workspaceId),
|
|
1263
|
+
loop: loopSeq,
|
|
1264
|
+
turn: seq,
|
|
1265
|
+
}); // {§provider-surface-generate} {§provider-guarantees-signal-wired} {§provider-guarantees-serial-attempts} {§attribution} {§client-metadata}
|
|
1266
|
+
providerCallInFlight = false;
|
|
1267
|
+
recordCounter(PROVIDER_CALLS, {
|
|
1268
|
+
model: provider.model,
|
|
1269
|
+
attempt,
|
|
1270
|
+
status: "resolved",
|
|
1271
|
+
});
|
|
1272
|
+
span.setAttribute("status", "resolved");
|
|
1273
|
+
return generated;
|
|
1274
|
+
});
|
|
1275
|
+
response = completedResponse;
|
|
1276
|
+
await observeProviderAttempt(attemptRow.id, completedResponse);
|
|
1277
|
+
railEvidence = railGrammar === undefined
|
|
1278
|
+
? undefined
|
|
1279
|
+
: Engine.#requireGrammarEvidence(completedResponse);
|
|
1280
|
+
splitResponse = this.#splitResponse(completedResponse);
|
|
1281
|
+
await classifyProviderAttempt(attemptRow.id, completedResponse, splitResponse, attempt, splitResponse.emissionValid);
|
|
1282
|
+
if (splitResponse.emissionValid)
|
|
1283
|
+
break;
|
|
1284
|
+
}
|
|
1030
1285
|
if (!signal?.aborted)
|
|
1031
|
-
this.#
|
|
1286
|
+
this.#notices.push(workspaceId, loopId, { source: "engine:turn", kind: "turn_generated", level: "info", message: "parsing model response" });
|
|
1032
1287
|
}
|
|
1033
1288
|
catch (err) {
|
|
1034
|
-
//
|
|
1035
|
-
//
|
|
1036
|
-
|
|
1037
|
-
|
|
1038
|
-
|
|
1039
|
-
|
|
1040
|
-
|
|
1041
|
-
|
|
1042
|
-
|
|
1043
|
-
|
|
1044
|
-
|
|
1045
|
-
|
|
1289
|
+
// This handler owns only provider-call failures. Parser, cost, SQL,
|
|
1290
|
+
// and engine-contract failures retain their original source.
|
|
1291
|
+
if (!providerCallInFlight)
|
|
1292
|
+
throw err;
|
|
1293
|
+
providerCallInFlight = false;
|
|
1294
|
+
if (providerAttemptId === null) {
|
|
1295
|
+
throw new Error("provider call failed without a durable attempt identity", { cause: err });
|
|
1296
|
+
}
|
|
1297
|
+
const failure = err instanceof ProviderError
|
|
1298
|
+
? { status: err.problem.status, problem: err.problem }
|
|
1299
|
+
: providerSignal?.aborted === true
|
|
1300
|
+
? Results.failure("engine:provider", providerSignal.reason === LOOP_TIMEOUT_REASON ? "provider-call-timeout" : "provider-call-cancelled", providerSignal.reason === LOOP_TIMEOUT_REASON ? 504 : 499, providerSignal.reason === LOOP_TIMEOUT_REASON
|
|
1301
|
+
? "The provider call was interrupted by the loop deadline."
|
|
1302
|
+
: "The provider call was interrupted by loop cancellation.", {}, { stage: "provider-request", retryable: false })
|
|
1303
|
+
: (() => {
|
|
1304
|
+
console.error("Provider failed outside its Problem Details contract:", err);
|
|
1305
|
+
return Results.failure("engine:provider", "provider-contract-violation", 502, "The provider failed without returning its required Problem Details.", {}, {
|
|
1306
|
+
stage: "provider-request",
|
|
1307
|
+
retryable: false,
|
|
1308
|
+
});
|
|
1309
|
+
})();
|
|
1310
|
+
// {§provider-interrupted-attempt} — a provider-declared interruption
|
|
1311
|
+
// carries response evidence without becoming a completed exchange.
|
|
1312
|
+
// Persist it as an unaccepted attempt before settling the failure.
|
|
1313
|
+
if (err instanceof ProviderError && err.attempt !== undefined) {
|
|
1314
|
+
response = err.attempt;
|
|
1315
|
+
await observeProviderAttempt(providerAttemptId, response);
|
|
1316
|
+
splitResponse = this.#splitResponse(response);
|
|
1317
|
+
await classifyProviderAttempt(providerAttemptId, response, splitResponse, providerAttemptSequence, false, failure);
|
|
1318
|
+
}
|
|
1319
|
+
else {
|
|
1320
|
+
const unknownCost = {
|
|
1321
|
+
kind: "unknown",
|
|
1322
|
+
reason: "provider call failed without response-bearing charge evidence",
|
|
1323
|
+
};
|
|
1324
|
+
await this.#db.engine_fail_turn_attempt.run({
|
|
1325
|
+
id: providerAttemptId,
|
|
1326
|
+
failure: JSON.stringify(failure),
|
|
1327
|
+
usage_cost: JSON.stringify(unknownCost),
|
|
1328
|
+
});
|
|
1329
|
+
emissionAttempts = providerAttemptSequence;
|
|
1330
|
+
}
|
|
1331
|
+
// {§turn-never-blank} — a ProviderError means no completed exchange exists.
|
|
1332
|
+
// Persist its exact RFC 9457 result before propagating it. Grammar evidence
|
|
1333
|
+
// and its engine-owned verdict exist only on completed responses
|
|
1334
|
+
// ({§rail-truth-engine-verdict}). Cancellation is lifecycle truth, not a
|
|
1335
|
+
// provider failure. Close the
|
|
1336
|
+
// attempted turn without inventing an assistant response, then let
|
|
1337
|
+
// runLoop/Daemon settle the exact 504/499 loop result.
|
|
1338
|
+
if (providerSignal?.aborted) {
|
|
1339
|
+
await this.#db.engine_close_turn.run({
|
|
1340
|
+
id: turnId,
|
|
1341
|
+
status: providerSignal.reason === LOOP_TIMEOUT_REASON ? 504 : 499,
|
|
1342
|
+
packet: StoredPacket.stringify(requestPacket),
|
|
1343
|
+
usage_prompt_budget: this.#packets.promptBudgetFor(provider),
|
|
1344
|
+
finish_reason: splitResponse?.callMetadata.finishReason ?? null,
|
|
1345
|
+
model: splitResponse?.callMetadata.model ?? provider.model,
|
|
1346
|
+
meta: JSON.stringify(response?.meta ?? {}),
|
|
1347
|
+
});
|
|
1348
|
+
throw err;
|
|
1046
1349
|
}
|
|
1047
|
-
|
|
1350
|
+
const recorded = await this.#problems.record({
|
|
1351
|
+
workerId,
|
|
1352
|
+
loopId,
|
|
1353
|
+
turnId,
|
|
1354
|
+
sequence: nextActionIndex,
|
|
1355
|
+
origin: "plurnk",
|
|
1356
|
+
source: "provider",
|
|
1357
|
+
result: failure,
|
|
1358
|
+
});
|
|
1359
|
+
// The provider call was attempted, but no completed exchange exists.
|
|
1360
|
+
// Persist the exact request half and failure status; omitting assistant
|
|
1361
|
+
// is materially different from fabricating an empty model turn.
|
|
1362
|
+
await this.#db.engine_close_turn.run({
|
|
1363
|
+
id: turnId,
|
|
1364
|
+
status: recorded.result.status,
|
|
1365
|
+
packet: StoredPacket.stringify(requestPacket),
|
|
1366
|
+
usage_prompt_budget: this.#packets.promptBudgetFor(provider),
|
|
1367
|
+
finish_reason: splitResponse?.callMetadata.finishReason ?? null,
|
|
1368
|
+
model: splitResponse?.callMetadata.model ?? provider.model,
|
|
1369
|
+
meta: JSON.stringify(response?.meta ?? {}),
|
|
1370
|
+
});
|
|
1371
|
+
throw new OperationFailureError(recorded.result, { cause: err });
|
|
1048
1372
|
}
|
|
1049
|
-
|
|
1050
|
-
|
|
1051
|
-
|
|
1052
|
-
|
|
1053
|
-
|
|
1054
|
-
|
|
1055
|
-
|
|
1056
|
-
|
|
1057
|
-
|
|
1058
|
-
|
|
1059
|
-
|
|
1060
|
-
|
|
1061
|
-
|
|
1062
|
-
|
|
1063
|
-
|
|
1064
|
-
|
|
1065
|
-
|
|
1066
|
-
|
|
1067
|
-
|
|
1068
|
-
|
|
1069
|
-
|
|
1070
|
-
|
|
1071
|
-
|
|
1072
|
-
|
|
1073
|
-
|
|
1074
|
-
|
|
1075
|
-
|
|
1076
|
-
|
|
1077
|
-
|
|
1078
|
-
|
|
1079
|
-
|
|
1373
|
+
if (response === undefined || splitResponse === undefined) {
|
|
1374
|
+
throw new Error("provider attempt loop completed without a response");
|
|
1375
|
+
}
|
|
1376
|
+
if (!splitResponse.emissionValid) {
|
|
1377
|
+
// {§invalid-emission-attempts} The first consecutive exhaustion
|
|
1378
|
+
// publishes only the raw final response and generic recovery fact.
|
|
1379
|
+
let rejectedModelEntryId;
|
|
1380
|
+
if (allowInvalidEmissionRecovery) {
|
|
1381
|
+
rejectedModelEntryId = await this.#dispatcher.writeModelEntry({
|
|
1382
|
+
verbatim: splitResponse.packetAssistant.content,
|
|
1383
|
+
workerId,
|
|
1384
|
+
loopId,
|
|
1385
|
+
turnId,
|
|
1386
|
+
sequence: nextActionIndex,
|
|
1387
|
+
folded: true,
|
|
1388
|
+
admission: "rejected",
|
|
1389
|
+
});
|
|
1390
|
+
this.#notices.push(workspaceId, loopId, {
|
|
1391
|
+
source: "engine:grammar",
|
|
1392
|
+
kind: "invalid_emission",
|
|
1393
|
+
level: "error",
|
|
1394
|
+
message: INVALID_EMISSION_RECOVERY_MESSAGE,
|
|
1395
|
+
});
|
|
1396
|
+
}
|
|
1397
|
+
await this.#db.engine_close_turn.run({
|
|
1398
|
+
id: turnId,
|
|
1399
|
+
status: allowInvalidEmissionRecovery ? TURN_STATUS_IMPLICIT_CONTINUE : 500,
|
|
1400
|
+
packet: StoredPacket.stringify(requestPacket),
|
|
1401
|
+
usage_prompt_budget: this.#packets.promptBudgetFor(provider),
|
|
1402
|
+
finish_reason: splitResponse.callMetadata.finishReason,
|
|
1403
|
+
model: splitResponse.callMetadata.model,
|
|
1404
|
+
meta: JSON.stringify(response.meta ?? {}),
|
|
1405
|
+
});
|
|
1406
|
+
return {
|
|
1407
|
+
turnId,
|
|
1408
|
+
status: allowInvalidEmissionRecovery ? TURN_STATUS_IMPLICIT_CONTINUE : 500,
|
|
1409
|
+
outcomes: [],
|
|
1410
|
+
fingerprint: "",
|
|
1411
|
+
budgetStruck: enforced.struck,
|
|
1412
|
+
budgetHardStop: false,
|
|
1413
|
+
steerStruck: false,
|
|
1414
|
+
emissionAttempts,
|
|
1415
|
+
emissionExhausted: true,
|
|
1416
|
+
...(rejectedModelEntryId === undefined ? {} : { rejectedModelEntryId }),
|
|
1417
|
+
};
|
|
1418
|
+
}
|
|
1419
|
+
// {§packet-stored-shape} — admitted emission data extends the packet;
|
|
1420
|
+
// provider-call metadata remains on the Turn row.
|
|
1421
|
+
const { packetAssistant, callMetadata, parseNotices, recoverableParseErrors, } = splitResponse; // raw assistant content is opaque — split, never interpreted — {§provider-guarantees-assistantraw-opaque}
|
|
1422
|
+
for (const notice of parseNotices) {
|
|
1423
|
+
this.#notices.push(workspaceId, loopId, notice);
|
|
1424
|
+
}
|
|
1425
|
+
// Non-fatal provider transport notices on an accepted turn. Forward each
|
|
1426
|
+
// Notice with a content-offset `line:col`;
|
|
1427
|
+
// the model resolves it against its own emission — READ the folded model-emission row at the
|
|
1428
|
+
// cited lines ({§model-entry}) — not an embedded snippet that would duplicate the emission.
|
|
1429
|
+
for (const notice of response.notices ?? []) {
|
|
1430
|
+
const located = typeof notice.position === "number"
|
|
1431
|
+
? this.#offsetToLineColumn(packetAssistant.content, notice.position)
|
|
1080
1432
|
: null;
|
|
1081
|
-
this.#
|
|
1082
|
-
source:
|
|
1083
|
-
kind:
|
|
1084
|
-
message:
|
|
1085
|
-
level:
|
|
1433
|
+
this.#notices.push(workspaceId, loopId, {
|
|
1434
|
+
source: notice.source,
|
|
1435
|
+
kind: notice.kind,
|
|
1436
|
+
message: notice.message ?? "",
|
|
1437
|
+
level: notice.level,
|
|
1086
1438
|
...(located !== null
|
|
1087
1439
|
? { position: { type: "content-offset", line: located.line, column: located.column } }
|
|
1088
1440
|
: {}),
|
|
1089
1441
|
});
|
|
1090
1442
|
}
|
|
1091
|
-
//
|
|
1092
|
-
//
|
|
1093
|
-
// through provider metadata; absence of local configuration says nothing about them.
|
|
1443
|
+
// Grade configured local evidence independently. Endpoint-owned
|
|
1444
|
+
// constraints remain provider observations. {§rail-truth-engine-verdict}
|
|
1094
1445
|
let railKeys;
|
|
1095
1446
|
if (railGrammar !== undefined) {
|
|
1447
|
+
if (railEvidence === undefined)
|
|
1448
|
+
throw new Error("configured GBNF response has no final grammar evidence");
|
|
1096
1449
|
let verdict = null;
|
|
1097
1450
|
try {
|
|
1098
|
-
verdict = validateGbnf(railGrammar,
|
|
1451
|
+
verdict = validateGbnf(railGrammar, railEvidence.input);
|
|
1099
1452
|
}
|
|
1100
1453
|
catch (cause) {
|
|
1101
1454
|
Engine.#warnRailVerdictGapOnce(cause.message);
|
|
1102
1455
|
}
|
|
1103
|
-
railKeys = {
|
|
1104
|
-
|
|
1105
|
-
|
|
1106
|
-
|
|
1107
|
-
|
|
1456
|
+
railKeys = {
|
|
1457
|
+
railsAttached: railEvidence.transported ? "client" : "withheld",
|
|
1458
|
+
railsVerdict: verdict?.status ?? "unverifiable",
|
|
1459
|
+
};
|
|
1460
|
+
if (verdict !== null && verdict.status !== "accept") {
|
|
1461
|
+
const contentPosition = verdict.pos >= railEvidence.contentStart
|
|
1462
|
+
? verdict.pos - railEvidence.contentStart
|
|
1463
|
+
: null;
|
|
1464
|
+
const located = contentPosition === null
|
|
1465
|
+
? null
|
|
1466
|
+
: this.#offsetToLineColumn(packetAssistant.content, contentPosition);
|
|
1467
|
+
this.#notices.push(workspaceId, loopId, {
|
|
1108
1468
|
source: "engine:rails",
|
|
1109
1469
|
kind: "grammar_unenforced",
|
|
1110
1470
|
message: verdict.status === "reject"
|
|
1111
|
-
? `emission rejects the grammar at code point ${verdict.pos}`
|
|
1112
|
-
: `emission is an incomplete grammar sentence (ends
|
|
1471
|
+
? `emission rejects the grammar at raw code point ${verdict.pos}`
|
|
1472
|
+
: `emission is an incomplete grammar sentence (ends at raw code point ${verdict.pos})`,
|
|
1113
1473
|
level: "warn",
|
|
1114
|
-
|
|
1474
|
+
...(located === null
|
|
1475
|
+
? {}
|
|
1476
|
+
: { position: { type: "content-offset", line: located.line, column: located.column } }),
|
|
1115
1477
|
});
|
|
1116
1478
|
}
|
|
1117
1479
|
}
|
|
1118
1480
|
const opsCount = packetAssistant.ops.length;
|
|
1119
|
-
|
|
1120
|
-
|
|
1121
|
-
|
|
1122
|
-
|
|
1123
|
-
|
|
1124
|
-
|
|
1125
|
-
|
|
1126
|
-
|
|
1481
|
+
const finalOp = packetAssistant.ops.at(-1);
|
|
1482
|
+
if (finalOp?.op !== "SEND") {
|
|
1483
|
+
// Text emissions cannot reach this point without a disposition;
|
|
1484
|
+
// this fail-hard guard also keeps Mock's trusted pre-parsed seam
|
|
1485
|
+
// from creating runtime states that production admission forbids.
|
|
1486
|
+
throw new Error("an admitted emission must end in a disposition SEND");
|
|
1487
|
+
}
|
|
1488
|
+
const dispositionSignal = finalOp.signal;
|
|
1489
|
+
if (typeof dispositionSignal !== "number" || !TERMINAL_SEND_SIGNALS.has(dispositionSignal)) {
|
|
1490
|
+
throw new Error("an admitted emission must end in a disposition SEND");
|
|
1491
|
+
}
|
|
1492
|
+
const sendOp = finalOp;
|
|
1493
|
+
// {§send} the terminal contract — engine error states verify a terminal claim against loop
|
|
1494
|
+
// state, never trusting the model's code. They strike via turn.steerStruck
|
|
1495
|
+
// ({§engine-rails}): the loop continues, the model sees the steering hint not the strike
|
|
1127
1496
|
// count, and a non-resolver spins out to the engine's 500.
|
|
1128
1497
|
let steerStruck = false;
|
|
1129
1498
|
// Engine errors raised this turn, minted as op='error' log rows after dispatch (they share the
|
|
1130
|
-
// post-dispatch sequence counter). §
|
|
1499
|
+
// post-dispatch sequence counter). {§operation-result-uniform-error-channel}
|
|
1131
1500
|
const pendingEngineErrors = [];
|
|
1132
|
-
// Terminal adjudication moved to the DISPATCHER (§send-premature-terminate, the unified
|
|
1501
|
+
// Terminal adjudication moved to the DISPATCHER ({§send-premature-terminate}, the unified
|
|
1133
1502
|
// pending set): the terminal SEND is judged AT ITS OWN DISPATCH — after the emission's
|
|
1134
1503
|
// earlier ops executed — so a same-turn KILL+[200] repairs in one turn and a same-turn
|
|
1135
|
-
// WORK+[200] is caught. A refused
|
|
1136
|
-
//
|
|
1137
|
-
|
|
1138
|
-
// Provisional here — a terminal REFUSED at dispatch (the pending-set 409, only knowable
|
|
1139
|
-
// post-dispatch) demotes the turn back to a continue below: the SEND's signal stays on the
|
|
1140
|
-
// row (the un-erased record), but the loop never went terminal, so the turn didn't either.
|
|
1141
|
-
// §broken-packet-no-dispatch (#566) — a BROKEN PACKET is structurally incomplete: the
|
|
1142
|
-
// provider GUILLOTINED the emission at the completion cap (finish=length) and it failed the
|
|
1143
|
-
// parse (empty, or cut mid-op). Its parsed "ops" are a severed frame — garbage (run42: an
|
|
1144
|
-
// unclosed `<<PLAN` swallowed a 57k-char runaway into one bogus status-200 PLAN). Nothing
|
|
1145
|
-
// dispatches and the turn is a NO-OPS strike, so a repeated runaway can't spin as a 102
|
|
1146
|
-
// "continue". The turn still records — the folded `model` mirror + the output_truncated 413 +
|
|
1147
|
-
// the parse-error rows — so the model sees WHY next turn and re-emits through the existing
|
|
1148
|
-
// error channel (no same-turn re-generate). A "flubbed op" is DIFFERENT: a well-framed turn
|
|
1149
|
-
// (finish=stop) whose single op erred dispatches its valid ops and steers on the bad one (the
|
|
1150
|
-
// recovery rail); only a severed FRAME is refused wholesale. The formal framing-vs-op-local
|
|
1151
|
-
// error split is grammar's to tag.
|
|
1152
|
-
const brokenPacket = callMetadata.finishReason === "length"
|
|
1153
|
-
&& (packetAssistant.content.trim().length === 0 || (parseErrors?.length ?? 0) > 0);
|
|
1154
|
-
let turnStatus = brokenPacket
|
|
1155
|
-
? TURN_STATUS_NO_OPS
|
|
1156
|
-
: sendOp !== undefined
|
|
1157
|
-
? sendOp.signal
|
|
1158
|
-
: realOpsCount === 0 ? TURN_STATUS_NO_OPS : TURN_STATUS_IMPLICIT_CONTINUE;
|
|
1504
|
+
// WORK+[200] is caught. A refused final disposition (409) strikes via
|
|
1505
|
+
// the dispatch-loop check below.
|
|
1506
|
+
let turnStatus = dispositionSignal;
|
|
1159
1507
|
// Idle turn: an implicit-continue (102) that did no WORK — its ops are only PLAN/SEND, no mid op.
|
|
1160
|
-
// The model continued with nothing to do.
|
|
1161
|
-
|
|
1162
|
-
|
|
1163
|
-
|
|
1164
|
-
|
|
1165
|
-
|
|
1166
|
-
// emitted-idle shape unemittable (grammar SPEC, no-idle-102); this 409 stays the truly-idle backstop.
|
|
1167
|
-
const midOpsCount = packetAssistant.ops.filter((op) => op.op !== "PLAN" && op.op !== "SEND").length;
|
|
1168
|
-
if (!steerStruck && turnStatus === TURN_STATUS_IMPLICIT_CONTINUE && midOpsCount === 0 && parseErrors.length === 0) {
|
|
1169
|
-
// One grace turn after a retrieval-only 409 (admins specimen): the refusal steer says
|
|
1170
|
-
// "continuing in order to receive results" — a model that obediently waits one bare
|
|
1171
|
-
// [102] turn is following OUR advice, and the idle rail was executing it for that.
|
|
1172
|
-
// The grace is exactly one turn; a second consecutive idle strikes as ever.
|
|
1173
|
-
if (this.#retrievalRefusalGrace.delete(loopId)) {
|
|
1174
|
-
// graced — the wait the steer asked for
|
|
1175
|
-
}
|
|
1176
|
-
else {
|
|
1177
|
-
steerStruck = true;
|
|
1178
|
-
pendingEngineErrors.push("idle_turn");
|
|
1179
|
-
}
|
|
1180
|
-
}
|
|
1181
|
-
else {
|
|
1182
|
-
this.#retrievalRefusalGrace.delete(loopId); // a working turn consumes any pending grace
|
|
1508
|
+
// The model continued with nothing to do.
|
|
1509
|
+
const midOpsCount = packetAssistant.ops.filter((op) => op.op !== "PLAN" && op.op !== "SEND").length
|
|
1510
|
+
+ recoverableParseErrors.length;
|
|
1511
|
+
if (!steerStruck && turnStatus === TURN_STATUS_IMPLICIT_CONTINUE && midOpsCount === 0) {
|
|
1512
|
+
steerStruck = true;
|
|
1513
|
+
pendingEngineErrors.push("idle_turn");
|
|
1183
1514
|
}
|
|
1184
1515
|
// Close the turn with the final packet, status, and usage stats.
|
|
1185
|
-
const packet =
|
|
1186
|
-
const { usage, finishReason, model } = callMetadata;
|
|
1516
|
+
const packet = StoredPacket.admit(requestPacket, packetAssistant, response.assistantRaw);
|
|
1187
1517
|
await this.#db.engine_close_turn.run({
|
|
1188
1518
|
id: turnId,
|
|
1189
1519
|
status: turnStatus,
|
|
1190
|
-
packet:
|
|
1191
|
-
|
|
1192
|
-
|
|
1193
|
-
|
|
1194
|
-
|
|
1195
|
-
|
|
1196
|
-
usage_prompt_budget: this.#packets.promptBudgetFor(provider), // #274 — the PROMPT BUDGET (window − reserves): the raw n_ctx overstated usable room by the reserve total
|
|
1197
|
-
finish_reason: finishReason,
|
|
1198
|
-
model,
|
|
1199
|
-
// #252 — opaque provider→client metadata passthrough (for example, the
|
|
1200
|
-
// provider normalized), plus the ONE service-authored carve-out: the engine's rail
|
|
1201
|
-
// keys ({§rail-truth-engine-verdict}) merge over any transitional provider railsMeta.
|
|
1520
|
+
packet: StoredPacket.stringify(packet),
|
|
1521
|
+
usage_prompt_budget: this.#packets.promptBudgetFor(provider), // {§tokenomics-client-gauge}
|
|
1522
|
+
finish_reason: callMetadata.finishReason,
|
|
1523
|
+
model: callMetadata.model,
|
|
1524
|
+
// Opaque provider metadata plus engine-authored rail keys.
|
|
1525
|
+
// {§meta-passthrough}, {§rail-truth-engine-verdict}
|
|
1202
1526
|
meta: JSON.stringify({ ...(response.meta ?? {}), ...(railKeys ?? {}) }),
|
|
1203
1527
|
});
|
|
1204
1528
|
// Dispatch model ops starting at nextActionIndex (continues the
|
|
@@ -1208,149 +1532,179 @@ export default class Engine {
|
|
|
1208
1532
|
// A degenerate op-loop is a sampler failure guarded at generation, not by dropping
|
|
1209
1533
|
// already-generated work post-hoc. The ceiling is an OPT-IN operator/client bound:
|
|
1210
1534
|
// when set, overflow ops drop without per-op log entries (no forensics flood) and the
|
|
1211
|
-
// model gets one
|
|
1212
|
-
//
|
|
1535
|
+
// model gets one notices signal next packet.
|
|
1536
|
+
// {§operator-config-workspace-max-commands} — workspace maxCommands min()s the env ceiling.
|
|
1213
1537
|
const maxCommands = Math.min(readMaxCommands(), (await WorkspaceSettings.read(this.#db, workspaceId)).maxCommands ?? Number.POSITIVE_INFINITY);
|
|
1214
|
-
// PLAN (
|
|
1215
|
-
//
|
|
1216
|
-
// bounds real actions only; maxCommands:0 still admits a plan and a
|
|
1538
|
+
// PLAN (intended goals) and the final disposition SEND are not actions —
|
|
1539
|
+
// they always dispatch and never count against the cap. maxCommands
|
|
1540
|
+
// bounds real actions only; maxCommands:0 still admits a plan and a disposition
|
|
1217
1541
|
// (the PLAN/SEND ops, zero actions), which is its only coherent meaning.
|
|
1218
|
-
// §broken-packet-no-dispatch (#566) — the severed frame dispatches NOTHING (rationale +
|
|
1219
|
-
// status handling at the brokenPacket definition above).
|
|
1220
1542
|
let realCommands = 0;
|
|
1221
|
-
const admittedOps =
|
|
1222
|
-
|
|
1223
|
-
|
|
1543
|
+
const admittedOps = packetAssistant.ops.filter((op) => {
|
|
1544
|
+
return op.op === "PLAN"
|
|
1545
|
+
|| op === sendOp
|
|
1546
|
+
|| realCommands++ < maxCommands;
|
|
1547
|
+
});
|
|
1224
1548
|
const opsToDispatch = scheduleTurnOps(admittedOps);
|
|
1225
1549
|
await this.#dispatcher.prepareEditBatches(opsToDispatch.filter((statement) => statement.op === "EDIT"), {
|
|
1226
1550
|
workspaceId, workerId, loopId, turnId,
|
|
1227
1551
|
origin, onDispatch,
|
|
1228
|
-
turnParseErrors: parseErrors?.length ?? 0,
|
|
1229
1552
|
});
|
|
1230
|
-
|
|
1231
|
-
|
|
1232
|
-
const droppedCount = brokenPacket ? 0 : opsCount - opsToDispatch.length;
|
|
1233
|
-
const statuses = [];
|
|
1553
|
+
const droppedCount = opsCount - opsToDispatch.length;
|
|
1554
|
+
const outcomes = [];
|
|
1234
1555
|
// Running counter — a multi-file READ writes N rows from one statement (rowsWritten),
|
|
1235
1556
|
// so the next op's sequence picks up after them. Collapses to nextActionIndex+i when
|
|
1236
1557
|
// every op writes one row (the common case).
|
|
1237
1558
|
let rowSeq = nextActionIndex;
|
|
1559
|
+
let parseErrorsRecorded = false;
|
|
1560
|
+
const recordRecoverableParseErrors = async () => {
|
|
1561
|
+
if (parseErrorsRecorded)
|
|
1562
|
+
return;
|
|
1563
|
+
parseErrorsRecorded = true;
|
|
1564
|
+
for (const error of recoverableParseErrors) {
|
|
1565
|
+
const recorded = await this.#problems.record({
|
|
1566
|
+
workerId,
|
|
1567
|
+
loopId,
|
|
1568
|
+
turnId,
|
|
1569
|
+
sequence: rowSeq++,
|
|
1570
|
+
origin: "model",
|
|
1571
|
+
source: "grammar",
|
|
1572
|
+
result: Results.failure("grammar:parser", "invalid-operation-syntax", 400, error.message, {}, {
|
|
1573
|
+
line: error.line,
|
|
1574
|
+
column: error.column,
|
|
1575
|
+
source: error.source,
|
|
1576
|
+
stage: "parse",
|
|
1577
|
+
recovery: "Correct only the failed operation; sibling operations were retained.",
|
|
1578
|
+
retryable: false,
|
|
1579
|
+
}),
|
|
1580
|
+
});
|
|
1581
|
+
outcomes.push({ op: null, status: recorded.result.status });
|
|
1582
|
+
onDispatch?.(recorded.id);
|
|
1583
|
+
}
|
|
1584
|
+
};
|
|
1238
1585
|
for (const statement of opsToDispatch) {
|
|
1239
|
-
|
|
1240
|
-
|
|
1241
|
-
|
|
1242
|
-
|
|
1243
|
-
// §
|
|
1244
|
-
//
|
|
1245
|
-
|
|
1586
|
+
if (statement.op === "SEND"
|
|
1587
|
+
&& typeof statement.signal === "number"
|
|
1588
|
+
&& TERMINAL_SEND_SIGNALS.has(statement.signal)) {
|
|
1589
|
+
await recordRecoverableParseErrors();
|
|
1590
|
+
// {§worker-optimistic-settlement} — SEND judges the refreshed
|
|
1591
|
+
// lifecycle after this turn's own fast streams receive one
|
|
1592
|
+
// bounded opportunity to conclude. This is not a sleep and
|
|
1593
|
+
// does not delay a later turn for older monitored streams.
|
|
1594
|
+
const execHandler = this.#schemes.get("exec");
|
|
1595
|
+
await execHandler?.settleTurnSpawns?.(workerId, turnId, readOptimisticSettlementMs(), providerSignal);
|
|
1596
|
+
}
|
|
1597
|
+
const result = await observed(// {§observability-boundary}
|
|
1598
|
+
"op.dispatch", { op: statement.op }, async (span) => {
|
|
1599
|
+
const dispatchResult = await this.#dispatcher.dispatch({
|
|
1600
|
+
statement, workspaceId, workerId, loopId, turnId,
|
|
1601
|
+
sequence: rowSeq,
|
|
1602
|
+
origin, onDispatch,
|
|
1603
|
+
});
|
|
1604
|
+
span.setAttribute("status", dispatchResult.status);
|
|
1605
|
+
recordCounter(OPS_DISPATCHED, { op: statement.op, status: dispatchResult.status });
|
|
1606
|
+
return dispatchResult;
|
|
1246
1607
|
});
|
|
1247
|
-
|
|
1248
|
-
|
|
1249
|
-
|
|
1250
|
-
|
|
1251
|
-
|
|
1252
|
-
|
|
1253
|
-
|
|
1254
|
-
|
|
1255
|
-
|
|
1608
|
+
outcomes.push({ op: statement.op, status: result.status });
|
|
1609
|
+
for (const normalization of result.scopeNormalizations ?? []) {
|
|
1610
|
+
this.#notices.push(workspaceId, loopId, {
|
|
1611
|
+
source: "engine:slicer",
|
|
1612
|
+
kind: "scope_normalized",
|
|
1613
|
+
level: "warn",
|
|
1614
|
+
message: `Scope <${normalization.requested.join(",")}> was normalized to <${normalization.canonical.join(",")}>.`,
|
|
1615
|
+
});
|
|
1616
|
+
}
|
|
1617
|
+
// {§engine-rails} — a refused final disposition leaves both loop
|
|
1618
|
+
// and turn continuing, and its 409 steering ruling strikes once.
|
|
1256
1619
|
if (statement === sendOp && result.status === 409) {
|
|
1257
|
-
|
|
1258
|
-
steerStruck = true;
|
|
1259
|
-
else
|
|
1260
|
-
this.#retrievalRefusalGrace.add(loopId); // the steer says "continuing to receive" — the NEXT turn's obedient wait must not idle-strike
|
|
1620
|
+
steerStruck = true;
|
|
1261
1621
|
turnStatus = TURN_STATUS_IMPLICIT_CONTINUE;
|
|
1262
|
-
await this.#db.
|
|
1622
|
+
await this.#db.engine_reconcile_turn_status.run({ id: turnId, status: turnStatus });
|
|
1263
1623
|
}
|
|
1264
|
-
//
|
|
1624
|
+
// {§send-300-choices}: a question resolves through the proposal system; whatever the
|
|
1265
1625
|
// resolution (answer/reject/timeout), the LOOP continues to the turn where the model
|
|
1266
1626
|
// reads it; the turn record is a continue, never a 300 terminal.
|
|
1267
1627
|
if (statement === sendOp && sendOp.signal === 300 && result.status !== 409) {
|
|
1268
1628
|
turnStatus = TURN_STATUS_IMPLICIT_CONTINUE;
|
|
1269
|
-
await this.#db.
|
|
1629
|
+
await this.#db.engine_reconcile_turn_status.run({ id: turnId, status: turnStatus });
|
|
1630
|
+
}
|
|
1631
|
+
// A broadcast [202] is a conditional wait, not an unconditional turn status:
|
|
1632
|
+
// live work parks at 202; completed-but-unobserved work continues at 102; an
|
|
1633
|
+
// empty join completes at 200. Persist and return the dispatcher's actual ruling.
|
|
1634
|
+
if (statement === sendOp
|
|
1635
|
+
&& result.status !== 409
|
|
1636
|
+
&& sendOp.target === null
|
|
1637
|
+
&& sendOp.signal === 202
|
|
1638
|
+
&& result.status !== 202) {
|
|
1639
|
+
turnStatus = result.status;
|
|
1640
|
+
await this.#db.engine_reconcile_turn_status.run({ id: turnId, status: turnStatus });
|
|
1270
1641
|
}
|
|
1271
1642
|
rowSeq += result.rowsWritten ?? 1;
|
|
1272
1643
|
}
|
|
1273
|
-
|
|
1274
|
-
// log
|
|
1275
|
-
//
|
|
1644
|
+
await recordRecoverableParseErrors();
|
|
1645
|
+
// Engine rail failures mint as op='error' log rows at the turn's next
|
|
1646
|
+
// free sequence. Bounded syntax failures were recorded in their
|
|
1647
|
+
// authored turn before its terminal disposition.
|
|
1276
1648
|
let errSeq = rowSeq;
|
|
1277
1649
|
// max_commands_exceeded IS model-facing: dropped ops the model emitted that didn't run.
|
|
1278
1650
|
if (droppedCount > 0)
|
|
1279
1651
|
pendingEngineErrors.push("max_commands_exceeded");
|
|
1280
|
-
for (const kind of pendingEngineErrors)
|
|
1281
|
-
|
|
1282
|
-
|
|
1283
|
-
|
|
1284
|
-
|
|
1285
|
-
|
|
1286
|
-
|
|
1287
|
-
|
|
1288
|
-
|
|
1289
|
-
|
|
1290
|
-
|
|
1291
|
-
|
|
1292
|
-
|
|
1293
|
-
|
|
1294
|
-
|
|
1295
|
-
|
|
1296
|
-
|
|
1297
|
-
|
|
1298
|
-
|
|
1299
|
-
|
|
1300
|
-
:
|
|
1301
|
-
|
|
1302
|
-
|
|
1303
|
-
origin: "model", source: "engine", op: "error", suffix: "", signal: null,
|
|
1304
|
-
scheme: null, username: null, password: null, hostname: null, port: null,
|
|
1305
|
-
pathname: null, params: null, fragment: null, lineMarker: null,
|
|
1306
|
-
tx: "", mimetype_tx: "text/plain",
|
|
1307
|
-
rx: JSON.stringify({ status: 413, kind: "output_truncated", message }),
|
|
1308
|
-
mimetype_rx: "application/json", status_rx: 413, tokens: 0, state: "failed", outcome: "output_truncated",
|
|
1309
|
-
attrs: "{}",
|
|
1310
|
-
});
|
|
1311
|
-
}
|
|
1312
|
-
// Parse errors carry the parser message + a content-offset line:col (a ContentOffset position),
|
|
1313
|
-
// resolved against the model's folded mirror row (§model-entry) — origin 'model', not engine.
|
|
1314
|
-
for (const { message, line, column, source } of parseErrors ?? []) {
|
|
1315
|
-
await this.#db.engine_insert_log_entry.get({
|
|
1316
|
-
worker_id: workerId, loop_id: loopId, turn_id: turnId, sequence: errSeq++,
|
|
1317
|
-
origin: "model", source: "grammar", op: "error", suffix: "", signal: null,
|
|
1318
|
-
scheme: null, username: null, password: null, hostname: null, port: null,
|
|
1319
|
-
pathname: null, params: null, fragment: null, lineMarker: null,
|
|
1320
|
-
tx: "", mimetype_tx: "text/plain",
|
|
1321
|
-
// The error carries the parser message + a content-offset `line:col`; the model READs
|
|
1322
|
-
// its own folded mirror row (§model-entry) at the cited lines, so no snippet is
|
|
1323
|
-
// embedded. The derived errors-section pointer stays minimal (status + coordinate).
|
|
1324
|
-
rx: JSON.stringify({ message, position: { type: "content-offset", line, column }, parserSource: source }),
|
|
1325
|
-
mimetype_rx: "application/json",
|
|
1326
|
-
status_rx: 400, tokens: 0, state: "resolved", outcome: null, attrs: "{}",
|
|
1652
|
+
for (const kind of pendingEngineErrors) {
|
|
1653
|
+
const problem = ENGINE_PROBLEMS[kind];
|
|
1654
|
+
const extensions = kind === "max_commands_exceeded"
|
|
1655
|
+
? {
|
|
1656
|
+
operationLimit: maxCommands,
|
|
1657
|
+
omittedOperations: droppedCount,
|
|
1658
|
+
stage: "dispatch-admission",
|
|
1659
|
+
recovery: "Continue with no more than the configured operation limit.",
|
|
1660
|
+
retryable: false,
|
|
1661
|
+
}
|
|
1662
|
+
: {
|
|
1663
|
+
stage: "turn",
|
|
1664
|
+
recovery: "Perform an operation before continuing with SEND[102].",
|
|
1665
|
+
retryable: false,
|
|
1666
|
+
};
|
|
1667
|
+
await this.#problems.record({
|
|
1668
|
+
workerId,
|
|
1669
|
+
loopId,
|
|
1670
|
+
turnId,
|
|
1671
|
+
sequence: errSeq++,
|
|
1672
|
+
origin: "plurnk",
|
|
1673
|
+
source: "rail",
|
|
1674
|
+
result: Results.failure("engine:rail", problem.code, problem.status, problem.detail, {}, extensions),
|
|
1327
1675
|
});
|
|
1328
1676
|
}
|
|
1329
|
-
// §
|
|
1677
|
+
// {§log-row-self-explains} — model-operation failures remain on their
|
|
1678
|
+
// own rows; genuine engine faults fail hard and mint no substitute row.
|
|
1679
|
+
// {§model-entry} — mirror this turn's verbatim emission back as a `model` row, so the NEXT
|
|
1330
1680
|
// packet shows the model exactly what it last produced. ALWAYS born FOLDED — the old
|
|
1331
1681
|
// born-OPEN-on-error auto-trigger was conditional helpfulness that bred its own hazards
|
|
1332
1682
|
// (a 24k-char ramble mirrored open re-injects itself into the next packet: cost,
|
|
1333
|
-
// contamination, pressure feedback).
|
|
1334
|
-
//
|
|
1335
|
-
//
|
|
1336
|
-
|
|
1337
|
-
|
|
1338
|
-
|
|
1339
|
-
// multi-id turn serves N entities, not a collapsed first. Providers already expose the array.
|
|
1340
|
-
const reasoningItems = sealed !== undefined && sealed.length > 0 ? sealed : undefined;
|
|
1683
|
+
// contamination, pressure feedback).
|
|
1684
|
+
// {§encrypted-reasoning-carrier} — core relays every provider-normalized
|
|
1685
|
+
// item unchanged.
|
|
1686
|
+
const reasoningItems = response.assistant.reasoningEncrypted?.length
|
|
1687
|
+
? response.assistant.reasoningEncrypted
|
|
1688
|
+
: undefined;
|
|
1341
1689
|
if (packetAssistant.content.trim().length > 0 || reasoningItems !== undefined) {
|
|
1342
1690
|
await this.#dispatcher.writeModelEntry({ verbatim: packetAssistant.content, workerId, loopId, turnId, sequence: errSeq++, folded: true, ...(reasoningItems !== undefined ? { reasoningItems } : {}) });
|
|
1343
1691
|
}
|
|
1344
|
-
|
|
1345
|
-
|
|
1346
|
-
|
|
1347
|
-
|
|
1348
|
-
|
|
1692
|
+
return {
|
|
1693
|
+
turnId,
|
|
1694
|
+
status: turnStatus,
|
|
1695
|
+
outcomes,
|
|
1696
|
+
fingerprint: StrikeRail.fingerprintTurn(packetAssistant.ops),
|
|
1697
|
+
budgetStruck: enforced.struck,
|
|
1698
|
+
budgetHardStop: false,
|
|
1699
|
+
steerStruck,
|
|
1700
|
+
emissionAttempts,
|
|
1701
|
+
emissionExhausted: false,
|
|
1702
|
+
};
|
|
1349
1703
|
}
|
|
1350
1704
|
// Split the wire-level ProviderResponse into the two destinations:
|
|
1351
1705
|
// packet.assistant gets the model's emission (content, ops, reasoning);
|
|
1352
1706
|
// Turn columns get the call-metadata (usage, finishReason, model).
|
|
1353
|
-
//
|
|
1707
|
+
// {§provider-surface} Text-fragment scraping policy lives
|
|
1354
1708
|
// here — engine owns the parse and the scraping rule, providers stay
|
|
1355
1709
|
// grammar-unaware.
|
|
1356
1710
|
//
|
|
@@ -1363,29 +1717,48 @@ export default class Engine {
|
|
|
1363
1717
|
const preParsedOps = assistant.ops;
|
|
1364
1718
|
const ops = [];
|
|
1365
1719
|
// PLAN is an ordinary op — emitted by the model, dispatched, and passed to the
|
|
1366
|
-
// client as a log entry. No special hoisting into the reasoning field
|
|
1367
|
-
//
|
|
1368
|
-
//
|
|
1369
|
-
//
|
|
1370
|
-
// Full PlurnkParseError context (line/column/source) is preserved
|
|
1371
|
-
// here so runTurn can build TelemetryEvent envelopes per the
|
|
1372
|
-
// grammar 0.17.0 protocol — model needs position info to locate
|
|
1373
|
-
// its own offending content on the next turn.
|
|
1720
|
+
// client as a log entry. No special hoisting into the reasoning field. Only
|
|
1721
|
+
// structured operations are executable; interstitial text is not an operation.
|
|
1722
|
+
// Full PlurnkParseError context is preserved on rejected attempt evidence;
|
|
1723
|
+
// warnings remain admissible Notices. {§parse-diagnostics}
|
|
1374
1724
|
const parseErrors = [];
|
|
1725
|
+
let hasUnparsedTail = false;
|
|
1726
|
+
const parseNotices = [];
|
|
1375
1727
|
if (preParsedOps !== undefined) {
|
|
1376
1728
|
ops.push(...preParsedOps);
|
|
1377
1729
|
}
|
|
1378
1730
|
else {
|
|
1379
|
-
|
|
1731
|
+
// {§observability-boundary} — the parse is observed without its input;
|
|
1732
|
+
// only the resulting statement count is attributable.
|
|
1733
|
+
const parsed = observedSync("contracts.parse", {}, (span) => {
|
|
1734
|
+
const result = PlurnkParser.parse(assistant.content);
|
|
1735
|
+
span.setAttribute("statements", result.items.filter((item) => item.kind === "statement").length);
|
|
1736
|
+
return result;
|
|
1737
|
+
});
|
|
1380
1738
|
for (const item of parsed.items) {
|
|
1381
1739
|
if (item.kind === "statement") {
|
|
1382
1740
|
ops.push(item.statement);
|
|
1383
1741
|
}
|
|
1384
|
-
// Free text (kind "text") is dropped — #free-text-capture retired (above).
|
|
1385
1742
|
else if (item.kind === "error") {
|
|
1386
1743
|
const err = item.error;
|
|
1387
1744
|
if (err instanceof PlurnkParseError) {
|
|
1388
|
-
|
|
1745
|
+
if (err.severity === "warning") {
|
|
1746
|
+
parseNotices.push({
|
|
1747
|
+
source: "grammar",
|
|
1748
|
+
kind: "parse_advisory",
|
|
1749
|
+
level: "warn",
|
|
1750
|
+
message: err.message,
|
|
1751
|
+
position: {
|
|
1752
|
+
type: "content-offset",
|
|
1753
|
+
line: err.line,
|
|
1754
|
+
column: err.column,
|
|
1755
|
+
},
|
|
1756
|
+
parserSource: err.source,
|
|
1757
|
+
});
|
|
1758
|
+
}
|
|
1759
|
+
else {
|
|
1760
|
+
parseErrors.push({ message: err.message, line: err.line, column: err.column, source: err.source });
|
|
1761
|
+
}
|
|
1389
1762
|
}
|
|
1390
1763
|
else {
|
|
1391
1764
|
const msg = err?.message ?? "parse error";
|
|
@@ -1393,72 +1766,117 @@ export default class Engine {
|
|
|
1393
1766
|
}
|
|
1394
1767
|
}
|
|
1395
1768
|
}
|
|
1396
|
-
//
|
|
1397
|
-
//
|
|
1398
|
-
// names the op AND the fix ("…never closed — add `:READ`"), where
|
|
1399
|
-
// the item-level error only says "expected close tag" for a tag the
|
|
1400
|
-
// model thinks it already wrote. Surface it — phenomenal messages
|
|
1401
|
-
// the model can self-correct from are the whole point of the DSL.
|
|
1769
|
+
// Boundary loss is the parser's one public fact from `unparsedTail.from` onward;
|
|
1770
|
+
// preserve it with the rejected forensic attempt. {§unparsed-tail-boundary}
|
|
1402
1771
|
const tail = parsed.unparsedTail;
|
|
1403
1772
|
if (tail !== undefined) {
|
|
1773
|
+
hasUnparsedTail = true;
|
|
1404
1774
|
parseErrors.push({ message: tail.reason, line: tail.from.line, column: tail.from.column, source: "grammar" });
|
|
1405
1775
|
}
|
|
1406
1776
|
}
|
|
1777
|
+
const plan = ops[0]?.op === "PLAN" ? ops[0] : undefined;
|
|
1778
|
+
const finalOp = ops.at(-1);
|
|
1779
|
+
const terminalSend = finalOp?.op === "SEND"
|
|
1780
|
+
&& typeof finalOp.signal === "number"
|
|
1781
|
+
&& TERMINAL_SEND_SIGNALS.has(finalOp.signal)
|
|
1782
|
+
? finalOp
|
|
1783
|
+
: undefined;
|
|
1784
|
+
const recoverableParseErrors = plan !== undefined && terminalSend !== undefined && !hasUnparsedTail
|
|
1785
|
+
? parseErrors.filter((error) => comparePosition(error, plan.position) > 0
|
|
1786
|
+
&& comparePosition(error, terminalSend.position) < 0).toSorted(comparePosition)
|
|
1787
|
+
: [];
|
|
1788
|
+
const emissionValid = preParsedOps !== undefined
|
|
1789
|
+
|| (plan !== undefined
|
|
1790
|
+
&& terminalSend !== undefined
|
|
1791
|
+
&& !hasUnparsedTail
|
|
1792
|
+
&& recoverableParseErrors.length === parseErrors.length);
|
|
1407
1793
|
const reasoning = assistant.reasoning ?? null;
|
|
1408
1794
|
return {
|
|
1409
1795
|
packetAssistant: { content: assistant.content, ops, reasoning },
|
|
1410
1796
|
callMetadata: { usage: assistant.usage, finishReason: assistant.finishReason, model: assistant.model },
|
|
1411
1797
|
parseErrors,
|
|
1798
|
+
recoverableParseErrors: emissionValid ? recoverableParseErrors : [],
|
|
1799
|
+
parseNotices,
|
|
1800
|
+
// The ANTLR model-turn parser is authoritative. A trustworthy
|
|
1801
|
+
// PLAN...SEND frame admits bounded interior statement failures so
|
|
1802
|
+
// they become durable operation results. Missing boundaries,
|
|
1803
|
+
// errors outside the frame, and an unparsed tail reject wholesale.
|
|
1804
|
+
// Pre-parsed ops are Mock's trusted test seam.
|
|
1805
|
+
emissionValid,
|
|
1412
1806
|
};
|
|
1413
1807
|
}
|
|
1414
1808
|
// #note12 — the plugin-provided reference docs (schemes' + execs' `documentation`),
|
|
1415
|
-
// materialized at plurnk
|
|
1809
|
+
// materialized at worker://plurnk/docs/<name>.md by LoopDocs (like operator docs).
|
|
1416
1810
|
docEntries(workspaceId) {
|
|
1417
1811
|
return this.#packets.docEntries(workspaceId);
|
|
1418
1812
|
}
|
|
1419
|
-
// §env-delta
|
|
1420
|
-
//
|
|
1421
|
-
//
|
|
1422
|
-
// entries since this worker's prior turn — real cross-worker edits and the plurnk worker's
|
|
1423
|
-
// fs-sync fictions — and materialize each as a FOLDED delta reusing the row's span +
|
|
1424
|
-
// cause. Returns the count so the caller advances nextActionIndex past the deltas.
|
|
1813
|
+
// {§env-delta-log-pull} — materialize one closed interval of the ambient
|
|
1814
|
+
// occurrence journal into this worker's self-contained log. #67 owns only
|
|
1815
|
+
// the remaining model-facing actor-name projection.
|
|
1425
1816
|
async #materializeEnvironmentDeltas(args) {
|
|
1426
1817
|
const { workspaceId, workerId, loopId, turnId, fromSequence } = args;
|
|
1427
|
-
const
|
|
1428
|
-
const
|
|
1429
|
-
if (
|
|
1430
|
-
|
|
1431
|
-
const rows = await this.#db.engine_pull_env_deltas.all({ workspace_id: workspaceId, worker_id: workerId, since });
|
|
1818
|
+
const rows = await this.#db.engine_pull_ambient_events.all({ workspace_id: workspaceId, worker_id: workerId });
|
|
1819
|
+
const window = rows[0];
|
|
1820
|
+
if (window === undefined)
|
|
1821
|
+
throw new Error(`ambient pull: worker ${workerId} has no observation window`);
|
|
1432
1822
|
let written = 0;
|
|
1433
1823
|
for (const r of rows) {
|
|
1434
|
-
|
|
1435
|
-
|
|
1436
|
-
|
|
1437
|
-
|
|
1438
|
-
|
|
1439
|
-
|
|
1440
|
-
|
|
1441
|
-
|
|
1442
|
-
|
|
1443
|
-
|
|
1444
|
-
|
|
1445
|
-
|
|
1446
|
-
|
|
1447
|
-
|
|
1824
|
+
if (r.event_id === null || r.producer_worker_id === null || r.producer_worker_name === null || r.kind === null
|
|
1825
|
+
|| r.op === null || r.status_rx === null)
|
|
1826
|
+
continue;
|
|
1827
|
+
const termination = r.kind === "loop_termination";
|
|
1828
|
+
if (r.rx === null)
|
|
1829
|
+
throw new Error(`ambient event ${r.event_id} has no materializable result`);
|
|
1830
|
+
const terminal = termination
|
|
1831
|
+
? TerminalResult.parse(r.rx, `ambient loop-termination event ${r.event_id}`)
|
|
1832
|
+
: null;
|
|
1833
|
+
if (terminal !== null && terminal.status !== r.status_rx) {
|
|
1834
|
+
throw new Error(`ambient loop-termination event ${r.event_id} status ${r.status_rx} does not match its terminal result status ${terminal.status}`);
|
|
1835
|
+
}
|
|
1836
|
+
let attrs = r.attrs ?? "{}";
|
|
1837
|
+
if (terminal !== null) {
|
|
1838
|
+
const inherited = JSON.parse(attrs);
|
|
1839
|
+
if (inherited === null || typeof inherited !== "object" || Array.isArray(inherited)) {
|
|
1840
|
+
throw new TypeError(`ambient loop-termination event ${r.event_id} attrs must be an object`);
|
|
1841
|
+
}
|
|
1842
|
+
const receipt = await BranchReceipt.render(this.#db, r.producer_worker_id);
|
|
1843
|
+
attrs = JSON.stringify({
|
|
1844
|
+
...inherited,
|
|
1845
|
+
kind: "loop_termination",
|
|
1846
|
+
...(r.terminated_by === null ? {} : { terminatedBy: r.terminated_by }),
|
|
1847
|
+
...(receipt === null ? {} : { receipt }),
|
|
1848
|
+
});
|
|
1849
|
+
}
|
|
1850
|
+
const inserted = await this.#db.engine_insert_ambient_delta.get({
|
|
1448
1851
|
worker_id: workerId, loop_id: loopId, turn_id: turnId, sequence: fromSequence + written,
|
|
1449
|
-
|
|
1450
|
-
|
|
1451
|
-
|
|
1852
|
+
event_id: r.event_id,
|
|
1853
|
+
source: r.source ?? WorkerControlAddress.render(r.producer_worker_name),
|
|
1854
|
+
op: r.op,
|
|
1855
|
+
scheme: r.scheme,
|
|
1856
|
+
hostname: r.hostname,
|
|
1857
|
+
pathname: r.pathname,
|
|
1858
|
+
rx: r.rx,
|
|
1859
|
+
mimetype_rx: "application/json",
|
|
1860
|
+
status: r.status_rx,
|
|
1861
|
+
expanded: terminal !== null && terminal.status >= 200 && terminal.status < 300 ? 1 : 0,
|
|
1862
|
+
attrs,
|
|
1452
1863
|
});
|
|
1453
|
-
|
|
1864
|
+
if (inserted !== undefined)
|
|
1865
|
+
written++;
|
|
1454
1866
|
}
|
|
1867
|
+
await this.#db.engine_advance_ambient_cursor.get({
|
|
1868
|
+
workspace_id: workspaceId,
|
|
1869
|
+
worker_id: workerId,
|
|
1870
|
+
cursor: window.cursor,
|
|
1871
|
+
boundary: window.boundary,
|
|
1872
|
+
});
|
|
1455
1873
|
return written;
|
|
1456
1874
|
}
|
|
1457
|
-
// §exec-poll — EXEC `<0>` is turn-scoped: abort the worker's open turn-scoped streams via their
|
|
1875
|
+
// {§exec-poll} — EXEC `<0>` is turn-scoped: abort the worker's open turn-scoped streams via their
|
|
1458
1876
|
// owning scheme (the same registry-routed abort the total reap uses). Called at each pre-turn
|
|
1459
1877
|
// before the turn's own spawns, so every open turn-scoped sub here is from a prior turn — it
|
|
1460
1878
|
// never survives into the subsequent turn. Fire-and-forget: the spawn finalizes async and its
|
|
1461
|
-
// terminal output surfaces born-OPEN through the stream-delta path (§exec-stream).
|
|
1879
|
+
// terminal output surfaces born-OPEN through the stream-delta path ({§exec-stream}).
|
|
1462
1880
|
async #reapTurnScopedStreams(workerId) {
|
|
1463
1881
|
const open = await this.#db.find_open_turn_scoped_subscriptions_for_worker.all({ worker_id: workerId });
|
|
1464
1882
|
await Promise.all(open.map(({ id }) => this.#liveSubscriptions.cancel(id)));
|
|
@@ -1466,11 +1884,11 @@ export default class Engine {
|
|
|
1466
1884
|
cancelSubscription(subscriptionId) {
|
|
1467
1885
|
return this.#liveSubscriptions.cancel(subscriptionId);
|
|
1468
1886
|
}
|
|
1469
|
-
// §env-delta — exec streams as an instance of the ambient-observe machine:
|
|
1470
|
-
// each turn, emit each owned channel's
|
|
1471
|
-
// while the channel streams
|
|
1472
|
-
//
|
|
1473
|
-
//
|
|
1887
|
+
// {§env-delta} — exec streams as an instance of the ambient-observe machine:
|
|
1888
|
+
// each turn, emit each owned channel's next publishable content as a foisted READ row. It is
|
|
1889
|
+
// 200 while the channel streams and preserves the exact terminal result when closed. Ongoing
|
|
1890
|
+
// observations fold; the terminal observation auto-OPENs. The cursor is the streamEnd recorded
|
|
1891
|
+
// on the channel's prior observation. {§exec-stream}
|
|
1474
1892
|
async #materializeStreamDeltas(args) {
|
|
1475
1893
|
const { workerId, loopId, turnId, fromSequence } = args;
|
|
1476
1894
|
const channels = await this.#db.engine_worker_stream_channels.all({ worker_id: workerId });
|
|
@@ -1490,26 +1908,60 @@ export default class Engine {
|
|
|
1490
1908
|
const priorAttrs = prior !== undefined ? JSON.parse(prior.attrs) : {};
|
|
1491
1909
|
const cursor = priorAttrs.streamEnd ?? 0;
|
|
1492
1910
|
const closed = ch.state === "closed" || ch.state === "errored";
|
|
1493
|
-
|
|
1494
|
-
|
|
1911
|
+
const terminal = closed
|
|
1912
|
+
? Results.assert(JSON.parse(ch.close_result ?? "null"))
|
|
1913
|
+
: null;
|
|
1914
|
+
const terminalResult = async (fields, sequence) => {
|
|
1915
|
+
if (terminal === null)
|
|
1916
|
+
throw new Error(`closed subscription ${ch.subscription_id} has no terminal result`);
|
|
1917
|
+
const result = Results.assert({
|
|
1918
|
+
...terminal,
|
|
1919
|
+
...(terminal.problem === undefined ? {} : { problem: { ...terminal.problem } }),
|
|
1920
|
+
...fields,
|
|
1921
|
+
});
|
|
1922
|
+
if (result.problem !== undefined) {
|
|
1923
|
+
const seqs = await this.#db.engine_loop_turn_seqs.get({
|
|
1924
|
+
loop_id: loopId,
|
|
1925
|
+
turn_id: turnId,
|
|
1926
|
+
});
|
|
1927
|
+
if (seqs === undefined)
|
|
1928
|
+
throw new Error(`stream delta has no log coordinate for loop=${loopId} turn=${turnId}`);
|
|
1929
|
+
Results.attachInstance(result, `log:///${seqs.loop_seq}/${seqs.turn_seq}/${sequence}/READ`);
|
|
1930
|
+
}
|
|
1931
|
+
return result;
|
|
1932
|
+
};
|
|
1933
|
+
const publishEnd = streamPublicationEnd(ch.content, ch.mimetype, cursor, closed);
|
|
1934
|
+
if (publishEnd <= cursor) {
|
|
1935
|
+
// The cursor-terminal race: a channel written in one final
|
|
1495
1936
|
// burst gets fully shown FOLDED while still active; the close then has zero new
|
|
1496
|
-
//
|
|
1937
|
+
// content and the auto-OPEN terminal observation never fired — the model was never shown
|
|
1497
1938
|
// the conclusion of a stream whose result it already holds folded. The same
|
|
1498
|
-
// observation is required when the
|
|
1499
|
-
// information independently of payload.
|
|
1500
|
-
// terse
|
|
1939
|
+
// observation is required when the channel produced no publishable content:
|
|
1940
|
+
// completion is information independently of payload. Text streams retain their
|
|
1941
|
+
// terse marker; structured channels emit a bodyless typed conclusion.
|
|
1501
1942
|
if (closed && priorAttrs.terminal !== true) {
|
|
1943
|
+
const streamTarget = renderTarget({
|
|
1944
|
+
scheme: ch.runtime,
|
|
1945
|
+
pathname: ch.coord,
|
|
1946
|
+
fragment: visibleFragment,
|
|
1947
|
+
});
|
|
1948
|
+
if (streamTarget === null)
|
|
1949
|
+
throw new Error(`stream ${ch.subscription_id} has no renderable address`);
|
|
1502
1950
|
const pointer = cursor > 0
|
|
1503
|
-
? `full output already delivered above; READ ${
|
|
1951
|
+
? `full output already delivered above; READ ${streamTarget} to revisit`
|
|
1504
1952
|
: "stream produced no output";
|
|
1953
|
+
const sequence = fromSequence + written;
|
|
1954
|
+
const content = baseMimetype(ch.mimetype).startsWith("text/")
|
|
1955
|
+
? `[ stream closed (${ch.close_status ?? 200}) - ${pointer} ]`
|
|
1956
|
+
: "";
|
|
1505
1957
|
await this.#db.engine_insert_stream_delta.run({
|
|
1506
|
-
worker_id: workerId, loop_id: loopId, turn_id: turnId, sequence
|
|
1958
|
+
worker_id: workerId, loop_id: loopId, turn_id: turnId, sequence,
|
|
1507
1959
|
scheme: ch.runtime, pathname: ch.coord, fragment: visibleFragment,
|
|
1508
|
-
rx: JSON.stringify({
|
|
1509
|
-
|
|
1510
|
-
|
|
1511
|
-
|
|
1512
|
-
|
|
1960
|
+
rx: JSON.stringify(await terminalResult({
|
|
1961
|
+
content,
|
|
1962
|
+
mimetype: ch.mimetype,
|
|
1963
|
+
}, sequence)),
|
|
1964
|
+
status: terminal?.status ?? 200,
|
|
1513
1965
|
attrs: JSON.stringify({ streamEnd: ch.content.length, terminal: true }),
|
|
1514
1966
|
expanded: 1,
|
|
1515
1967
|
});
|
|
@@ -1518,91 +1970,136 @@ export default class Engine {
|
|
|
1518
1970
|
continue;
|
|
1519
1971
|
}
|
|
1520
1972
|
// startLine continues the line count across turns: a multi-turn stream's deltas number
|
|
1521
|
-
// into one sequence (lines N..M, then M+1..), not N independent "1:" restarts. §exec-stream
|
|
1973
|
+
// into one sequence (lines N..M, then M+1..), not N independent "1:" restarts. {§exec-stream}
|
|
1522
1974
|
const startLine = (ch.content.slice(0, cursor).match(/\n/g)?.length ?? 0) + 1;
|
|
1975
|
+
const sequence = fromSequence + written;
|
|
1976
|
+
const content = ch.content.slice(cursor, publishEnd);
|
|
1977
|
+
const terminalDelivery = closed && publishEnd === ch.content.length;
|
|
1978
|
+
const fields = { content, mimetype: ch.mimetype, startLine };
|
|
1979
|
+
const result = terminalDelivery
|
|
1980
|
+
? await terminalResult(fields, sequence)
|
|
1981
|
+
: { status: 200, ...fields };
|
|
1523
1982
|
await this.#db.engine_insert_stream_delta.run({
|
|
1524
|
-
worker_id: workerId, loop_id: loopId, turn_id: turnId, sequence
|
|
1983
|
+
worker_id: workerId, loop_id: loopId, turn_id: turnId, sequence,
|
|
1525
1984
|
scheme: ch.runtime, pathname: ch.coord, fragment: visibleFragment,
|
|
1526
|
-
rx: JSON.stringify(
|
|
1527
|
-
|
|
1528
|
-
|
|
1985
|
+
rx: JSON.stringify(result),
|
|
1986
|
+
status: result.status,
|
|
1987
|
+
attrs: JSON.stringify({ streamEnd: publishEnd, terminal: terminalDelivery }),
|
|
1988
|
+
expanded: terminalDelivery ? 1 : 0, // {§exec-stream} — terminal observation auto-OPENs; ongoing folds
|
|
1529
1989
|
});
|
|
1530
1990
|
written++;
|
|
1531
1991
|
}
|
|
1532
1992
|
return written;
|
|
1533
1993
|
}
|
|
1534
|
-
// §env-delta
|
|
1535
|
-
//
|
|
1536
|
-
|
|
1537
|
-
// world changed," so the fiction keeps its perspective aligned with what its tooling
|
|
1538
|
-
// would show. The fiction lives in the plurnk worker's log; every other run pulls it
|
|
1539
|
-
// through the one delta path, exactly like a sibling's real edit.
|
|
1540
|
-
// §membership-emi-divergence-signal — disk divergences logged as the plurnk worker's source=file EDIT fictions
|
|
1541
|
-
async #logFsFictions(workspaceId, divergences) {
|
|
1994
|
+
// {§env-delta-filesystem-narration} {§membership-emi-divergence-signal}
|
|
1995
|
+
// — journal project-file divergence once through the reserved actor.
|
|
1996
|
+
async #logFsFictions(workspaceId, divergences, gitStatus) {
|
|
1542
1997
|
if (divergences.length === 0)
|
|
1543
1998
|
return;
|
|
1544
|
-
const
|
|
1999
|
+
const gitByPath = new Map(gitStatus?.files.map(({ path, status }) => [path, status]) ?? []);
|
|
2000
|
+
const worker = await this.#db.envelope_get_worker_by_name.get({ workspace_id: workspaceId, name: "plurnk" })
|
|
1545
2001
|
?? await this.#db.envelope_insert_worker.get({ workspace_id: workspaceId, name: "plurnk", origin: "plurnk" });
|
|
1546
|
-
if (
|
|
2002
|
+
if (worker === undefined)
|
|
1547
2003
|
throw new Error("logFsFictions: plurnk worker resolution returned no row");
|
|
1548
|
-
const loop = await this.#db.envelope_insert_client_loop.get({ worker_id:
|
|
2004
|
+
const loop = await this.#db.envelope_insert_client_loop.get({ worker_id: worker.id });
|
|
1549
2005
|
if (loop === undefined)
|
|
1550
2006
|
throw new Error("logFsFictions: loop insert returned no row");
|
|
1551
|
-
const
|
|
1552
|
-
const turn = await this.#db.client_turn_insert.get({ loop_id: loop.id, sequence: seq?.next ?? 1, packet: "{}" });
|
|
1553
|
-
if (turn === undefined)
|
|
1554
|
-
throw new Error("logFsFictions: turn insert returned no row");
|
|
2007
|
+
const turn = await JournalTurn.insert(this.#db, loop.id);
|
|
1555
2008
|
let sequence = 1;
|
|
1556
2009
|
for (const d of divergences) {
|
|
1557
2010
|
const span = editedSpan(d.before, d.after);
|
|
1558
2011
|
await this.#db.engine_insert_log_entry.get({
|
|
1559
|
-
worker_id:
|
|
2012
|
+
worker_id: worker.id, loop_id: loop.id, turn_id: turn.id, sequence: sequence++,
|
|
1560
2013
|
origin: "plurnk", source: "file", op: "EDIT", suffix: "", signal: null,
|
|
1561
|
-
//
|
|
1562
|
-
//
|
|
1563
|
-
|
|
1564
|
-
|
|
1565
|
-
pathname: d.pathname, params: null, fragment: null, lineMarker: null,
|
|
2014
|
+
// Match Dispatcher.#extractTarget: a bare file address has NULL scheme
|
|
2015
|
+
// only in log target metadata; its entry identity remains `file`.
|
|
2016
|
+
scheme: null, username: null, password: null, hostname: null, port: null,
|
|
2017
|
+
pathname: d.pathname, query: null, fragment: null, lineMarker: null,
|
|
1566
2018
|
tx: "", mimetype_tx: "text/plain",
|
|
1567
2019
|
rx: JSON.stringify({ status: 200, entryId: d.entryId, channel: d.channel, span }), mimetype_rx: "application/json",
|
|
1568
|
-
status_rx: 200, tokens: 0, state: "resolved", outcome: null,
|
|
2020
|
+
status_rx: 200, tokens: 0, state: "resolved", outcome: null,
|
|
2021
|
+
attrs: gitByPath.has(d.pathname)
|
|
2022
|
+
? JSON.stringify({ git: gitByPath.get(d.pathname) })
|
|
2023
|
+
: "{}",
|
|
1569
2024
|
});
|
|
1570
2025
|
}
|
|
1571
2026
|
}
|
|
1572
2027
|
async dispatch(context) {
|
|
1573
|
-
|
|
1574
|
-
|
|
1575
|
-
|
|
1576
|
-
|
|
1577
|
-
|
|
2028
|
+
return observed(// {§observability-boundary}
|
|
2029
|
+
"op.dispatch", { op: context.statement.op }, async (span) => {
|
|
2030
|
+
if (context.statement.op === "EDIT") {
|
|
2031
|
+
const { statement, sequence: _sequence, ...batchContext } = context;
|
|
2032
|
+
await this.#dispatcher.prepareEditBatches([statement], batchContext);
|
|
2033
|
+
}
|
|
2034
|
+
const result = await this.#dispatcher.dispatch(context);
|
|
2035
|
+
span.setAttribute("status", result.status);
|
|
2036
|
+
return result;
|
|
2037
|
+
});
|
|
1578
2038
|
}
|
|
1579
|
-
// op
|
|
1580
|
-
// log_entries row: the client's off-run inspection primitive. {§op-look}
|
|
2039
|
+
// {§op-look}: resolve a READ without writing a log_entries row.
|
|
1581
2040
|
async look(context) {
|
|
1582
2041
|
return this.#dispatcher.look(context);
|
|
1583
2042
|
}
|
|
1584
|
-
|
|
1585
|
-
|
|
1586
|
-
|
|
1587
|
-
|
|
2043
|
+
async resolveEntryAddress(context) {
|
|
2044
|
+
return this.#dispatcher.resolveEntryAddress(context);
|
|
2045
|
+
}
|
|
2046
|
+
async #writePromptLog({ workerId, loopId, turnId, sequence, target, content, }) {
|
|
2047
|
+
const row = await this.#db.engine_insert_log_entry.get({
|
|
2048
|
+
worker_id: workerId,
|
|
2049
|
+
loop_id: loopId,
|
|
2050
|
+
turn_id: turnId,
|
|
2051
|
+
sequence,
|
|
2052
|
+
origin: "plurnk",
|
|
2053
|
+
source: null,
|
|
2054
|
+
op: "prompt",
|
|
2055
|
+
suffix: "",
|
|
2056
|
+
signal: null,
|
|
2057
|
+
scheme: target.scheme,
|
|
2058
|
+
username: target.username,
|
|
2059
|
+
password: target.password,
|
|
2060
|
+
hostname: target.hostname,
|
|
2061
|
+
port: target.port,
|
|
2062
|
+
pathname: target.pathname,
|
|
2063
|
+
query: target.query,
|
|
2064
|
+
fragment: target.fragment,
|
|
2065
|
+
lineMarker: null,
|
|
2066
|
+
tx: "",
|
|
2067
|
+
mimetype_tx: "text/plain",
|
|
2068
|
+
rx: JSON.stringify({ content, mimetype: "text/markdown" }),
|
|
2069
|
+
mimetype_rx: "application/json",
|
|
2070
|
+
status_rx: 200,
|
|
2071
|
+
tokens: this.#tokenize(content),
|
|
2072
|
+
state: "resolved",
|
|
2073
|
+
outcome: null,
|
|
2074
|
+
attrs: "{}",
|
|
2075
|
+
});
|
|
2076
|
+
if (row === undefined)
|
|
2077
|
+
throw new Error("Engine.#writePromptLog: INSERT ... RETURNING produced no row");
|
|
2078
|
+
return row.id;
|
|
2079
|
+
}
|
|
2080
|
+
// External API to feed a resolution into a pending proposal — the client-interface
|
|
2081
|
+
// seam, core-owned disposition, or the timeout watcher.
|
|
2082
|
+
// {§worker-lifecycle-total-reap}: release every stopped-world waiter before joining drains.
|
|
1588
2083
|
cancelAllProposals(outcome) {
|
|
1589
2084
|
this.#proposals.cancelAll(outcome);
|
|
1590
2085
|
}
|
|
1591
2086
|
resolveProposal(logEntryId, resolution) {
|
|
1592
2087
|
this.#proposals.resolve(logEntryId, resolution);
|
|
1593
2088
|
}
|
|
1594
|
-
// Snapshot of pending proposals
|
|
2089
|
+
// Snapshot of pending proposals for client-interface discovery.
|
|
1595
2090
|
pendingProposalIds() {
|
|
1596
2091
|
return this.#proposals.pendingIds();
|
|
1597
2092
|
}
|
|
1598
|
-
// Subscribe to proposal-pending
|
|
1599
|
-
//
|
|
1600
|
-
// registers one that auto-resolves.
|
|
2093
|
+
// Subscribe to proposal-pending observations. Automatic settlement is
|
|
2094
|
+
// core-owned and happens before observers run.
|
|
1601
2095
|
onProposalPending(listener) {
|
|
1602
2096
|
this.#proposals.onPending(listener);
|
|
1603
2097
|
}
|
|
2098
|
+
async pendingProposals(workspaceId) {
|
|
2099
|
+
return this.#proposals.list(workspaceId);
|
|
2100
|
+
}
|
|
1604
2101
|
// Used by wake-on-completion (daemon side): "is there any loop in this
|
|
1605
|
-
//
|
|
2102
|
+
// worker still accepting turns?" If yes, skip the wake — the active loop
|
|
1606
2103
|
// will pick up the channel transition at its next turn boundary. If no,
|
|
1607
2104
|
// the daemon opens a fresh loop with the wake prompt.
|
|
1608
2105
|
async hasActiveLoopForWorker(workerId) {
|
|
@@ -1610,7 +2107,7 @@ export default class Engine {
|
|
|
1610
2107
|
return (row?.n ?? 0) > 0;
|
|
1611
2108
|
}
|
|
1612
2109
|
// Workspace-scope eager warm: creation and membership changes start the
|
|
1613
|
-
// exhaustive graph/FTS/vector derivation immediately. The
|
|
2110
|
+
// exhaustive graph/FTS/vector derivation immediately. The seam call returns while
|
|
1614
2111
|
// progress live-fans-out at loopId 0; a model turn joins this same coalesced
|
|
1615
2112
|
// promise and cannot reach its provider until coverage is complete.
|
|
1616
2113
|
async warmWorkspaceDerivations(workspaceId) {
|
|
@@ -1623,23 +2120,33 @@ export default class Engine {
|
|
|
1623
2120
|
tokenize: this.#tokenize,
|
|
1624
2121
|
mimetypes: this.#mimetypes,
|
|
1625
2122
|
defaultChannelFor: (s) => this.#schemes.defaultChannelFor(s),
|
|
1626
|
-
|
|
2123
|
+
pushNotice: (notice) => this.#notices.notify(workspaceId, 0, notice),
|
|
1627
2124
|
};
|
|
1628
2125
|
await this.#queueWorkspaceWarm(ctx); // materialize first; overlapping requests coalesce and rescan
|
|
1629
2126
|
}
|
|
1630
|
-
// Inject a prompt into the worker's
|
|
1631
|
-
//
|
|
1632
|
-
//
|
|
1633
|
-
//
|
|
1634
|
-
// first.
|
|
2127
|
+
// Inject a prompt into the worker's current non-terminal loop. Writes the
|
|
2128
|
+
// next owner-keyed prompt:///<loop>/<N> entry; the next turn publishes it
|
|
2129
|
+
// as one actionless prompt row. Prompt-frame writes serialize per worker,
|
|
2130
|
+
// so concurrent arrivals retain distinct ordered ordinals.
|
|
1635
2131
|
//
|
|
1636
|
-
// Returns null when no loop in the worker is
|
|
2132
|
+
// Returns null when no loop in the worker is active or parked (102/202).
|
|
1637
2133
|
// The daemon-side inject path then enqueues a fresh loop with this
|
|
1638
2134
|
// prompt; engine doesn't open loops itself.
|
|
1639
|
-
|
|
1640
|
-
|
|
1641
|
-
|
|
1642
|
-
|
|
2135
|
+
inject(workerId, prompt, openPaths = []) {
|
|
2136
|
+
return this.#withPromptWriteLock(workerId, () => this.#injectPrompt(workerId, prompt, openPaths));
|
|
2137
|
+
}
|
|
2138
|
+
#withPromptWriteLock(workerId, write) {
|
|
2139
|
+
const previous = this.#promptWriteLocks.get(workerId) ?? Promise.resolve();
|
|
2140
|
+
const run = previous.then(write, write);
|
|
2141
|
+
const tail = run.catch(() => { });
|
|
2142
|
+
this.#promptWriteLocks.set(workerId, tail);
|
|
2143
|
+
void tail.then(() => {
|
|
2144
|
+
if (this.#promptWriteLocks.get(workerId) === tail)
|
|
2145
|
+
this.#promptWriteLocks.delete(workerId);
|
|
2146
|
+
});
|
|
2147
|
+
return run;
|
|
2148
|
+
}
|
|
2149
|
+
async #injectPrompt(workerId, prompt, openPaths) {
|
|
1643
2150
|
const loopRow = await this.#db.drain_current_loop_for_worker.get({ worker_id: workerId });
|
|
1644
2151
|
if (loopRow === undefined)
|
|
1645
2152
|
return null;
|
|
@@ -1648,11 +2155,16 @@ export default class Engine {
|
|
|
1648
2155
|
const turnSeq = turnRow?.next ?? 1;
|
|
1649
2156
|
const workspaceRow = await this.#db.drain_get_worker_workspace.get({ worker_id: workerId });
|
|
1650
2157
|
if (workspaceRow === undefined)
|
|
1651
|
-
throw new Error(`Engine.inject:
|
|
2158
|
+
throw new Error(`Engine.inject: worker ${workerId} not found`);
|
|
1652
2159
|
// {§prompt-loop-containment} — the frame is the loop's NEXT prompt ordinal, never a turn
|
|
1653
2160
|
// slot: rapid arrivals land as N and N+1, both contained, nothing superseded.
|
|
1654
|
-
const
|
|
1655
|
-
const
|
|
2161
|
+
const prefix = promptLoopPrefix(loopRow.sequence);
|
|
2162
|
+
const ordinalRow = await this.#db.drain_next_prompt_ordinal_for_loop.get({
|
|
2163
|
+
owner_id: workerId,
|
|
2164
|
+
pattern: `${prefix}%`,
|
|
2165
|
+
prefix_len: prefix.length,
|
|
2166
|
+
});
|
|
2167
|
+
const pathname = promptPathname(loopRow.sequence, ordinalRow?.next ?? 2);
|
|
1656
2168
|
const ctx = {
|
|
1657
2169
|
db: this.#db, workspaceId: workspaceRow.workspace_id, workerId, loopId,
|
|
1658
2170
|
turnId: 0, // no turn open at inject time; entries don't pin turnId
|
|
@@ -1661,11 +2173,12 @@ export default class Engine {
|
|
|
1661
2173
|
streamEventNotify: this.#streamEventNotify,
|
|
1662
2174
|
wakeWorkerNotify: this.#wakeWorkerNotify,
|
|
1663
2175
|
tokenize: this.#tokenize,
|
|
1664
|
-
|
|
2176
|
+
pushNotice: (notice) => this.#notices.push(workspaceRow.workspace_id, loopId, notice),
|
|
1665
2177
|
};
|
|
1666
2178
|
const entry = {
|
|
1667
2179
|
channels: { body: { content: prompt, mimetype: "text/markdown" } },
|
|
1668
2180
|
tags: [],
|
|
2181
|
+
attributes: { openPaths },
|
|
1669
2182
|
};
|
|
1670
2183
|
await EntryCrud.writeEntry(pathname, entry, ctx, "prompt", workerId);
|
|
1671
2184
|
return { loopId, turnSeq };
|