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