@animalabs/agent-framework 0.23.3 → 0.24.1
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/README.md +212 -42
- package/dist/src/agent.d.ts +65 -9
- package/dist/src/agent.d.ts.map +1 -1
- package/dist/src/agent.js +98 -12
- package/dist/src/agent.js.map +1 -1
- package/dist/src/api/server.d.ts.map +1 -1
- package/dist/src/api/server.js +14 -12
- package/dist/src/api/server.js.map +1 -1
- package/dist/src/clock-rows.d.ts +115 -0
- package/dist/src/clock-rows.d.ts.map +1 -0
- package/dist/src/clock-rows.js +222 -0
- package/dist/src/clock-rows.js.map +1 -0
- package/dist/src/code-execution/engine.d.ts +177 -0
- package/dist/src/code-execution/engine.d.ts.map +1 -0
- package/dist/src/code-execution/engine.js +494 -0
- package/dist/src/code-execution/engine.js.map +1 -0
- package/dist/src/code-execution/index.d.ts +5 -10
- package/dist/src/code-execution/index.d.ts.map +1 -1
- package/dist/src/code-execution/index.js +5 -9
- package/dist/src/code-execution/index.js.map +1 -1
- package/dist/src/code-execution/isolate/calls.d.ts +114 -0
- package/dist/src/code-execution/isolate/calls.d.ts.map +1 -0
- package/dist/src/code-execution/isolate/calls.js +145 -0
- package/dist/src/code-execution/isolate/calls.js.map +1 -0
- package/dist/src/code-execution/isolate/child-entry.d.ts +2 -0
- package/dist/src/code-execution/isolate/child-entry.d.ts.map +1 -0
- package/dist/src/code-execution/isolate/child-entry.js +5 -0
- package/dist/src/code-execution/isolate/child-entry.js.map +1 -0
- package/dist/src/code-execution/isolate/child-main.d.ts +26 -0
- package/dist/src/code-execution/isolate/child-main.d.ts.map +1 -0
- package/dist/src/code-execution/isolate/child-main.js +132 -0
- package/dist/src/code-execution/isolate/child-main.js.map +1 -0
- package/dist/src/code-execution/isolate/child.d.ts +44 -0
- package/dist/src/code-execution/isolate/child.d.ts.map +1 -0
- package/dist/src/code-execution/isolate/child.js +315 -0
- package/dist/src/code-execution/isolate/child.js.map +1 -0
- package/dist/src/code-execution/isolate/frames.d.ts +80 -0
- package/dist/src/code-execution/isolate/frames.d.ts.map +1 -0
- package/dist/src/code-execution/isolate/frames.js +210 -0
- package/dist/src/code-execution/isolate/frames.js.map +1 -0
- package/dist/src/code-execution/isolate/output.d.ts +68 -0
- package/dist/src/code-execution/isolate/output.d.ts.map +1 -0
- package/dist/src/code-execution/isolate/output.js +143 -0
- package/dist/src/code-execution/isolate/output.js.map +1 -0
- package/dist/src/code-execution/isolate/prelude.d.ts +109 -0
- package/dist/src/code-execution/isolate/prelude.d.ts.map +1 -0
- package/dist/src/code-execution/isolate/prelude.js +401 -0
- package/dist/src/code-execution/isolate/prelude.js.map +1 -0
- package/dist/src/code-execution/isolate/protocol.d.ts +151 -0
- package/dist/src/code-execution/isolate/protocol.d.ts.map +1 -0
- package/dist/src/code-execution/isolate/protocol.js +150 -0
- package/dist/src/code-execution/isolate/protocol.js.map +1 -0
- package/dist/src/code-execution/isolate/render.d.ts +57 -0
- package/dist/src/code-execution/isolate/render.d.ts.map +1 -0
- package/dist/src/code-execution/isolate/render.js +214 -0
- package/dist/src/code-execution/isolate/render.js.map +1 -0
- package/dist/src/code-execution/isolate/runner.d.ts +250 -0
- package/dist/src/code-execution/isolate/runner.d.ts.map +1 -0
- package/dist/src/code-execution/isolate/runner.js +854 -0
- package/dist/src/code-execution/isolate/runner.js.map +1 -0
- package/dist/src/code-execution/isolate/scripts.d.ts +175 -0
- package/dist/src/code-execution/isolate/scripts.d.ts.map +1 -0
- package/dist/src/code-execution/isolate/scripts.js +357 -0
- package/dist/src/code-execution/isolate/scripts.js.map +1 -0
- package/dist/src/code-execution/quickjs.wasm +0 -0
- package/dist/src/code-execution/tool-definition.d.ts +93 -43
- package/dist/src/code-execution/tool-definition.d.ts.map +1 -1
- package/dist/src/code-execution/tool-definition.js +176 -142
- package/dist/src/code-execution/tool-definition.js.map +1 -1
- package/dist/src/deadline-timer.d.ts +3 -0
- package/dist/src/deadline-timer.d.ts.map +1 -1
- package/dist/src/deadline-timer.js +1 -1
- package/dist/src/deadline-timer.js.map +1 -1
- package/dist/src/framework.d.ts +1120 -457
- package/dist/src/framework.d.ts.map +1 -1
- package/dist/src/framework.js +4635 -2505
- package/dist/src/framework.js.map +1 -1
- package/dist/src/gate/event-gate.d.ts +28 -4
- package/dist/src/gate/event-gate.d.ts.map +1 -1
- package/dist/src/gate/event-gate.js +89 -13
- package/dist/src/gate/event-gate.js.map +1 -1
- package/dist/src/held-writes.d.ts +16 -3
- package/dist/src/held-writes.d.ts.map +1 -1
- package/dist/src/held-writes.js +16 -3
- package/dist/src/held-writes.js.map +1 -1
- package/dist/src/index.d.ts +8 -6
- package/dist/src/index.d.ts.map +1 -1
- package/dist/src/index.js +10 -8
- package/dist/src/index.js.map +1 -1
- package/dist/src/mcpl/channel-registry.d.ts +483 -115
- package/dist/src/mcpl/channel-registry.d.ts.map +1 -1
- package/dist/src/mcpl/channel-registry.js +1735 -425
- package/dist/src/mcpl/channel-registry.js.map +1 -1
- package/dist/src/mcpl/conversation-router.d.ts +19 -39
- package/dist/src/mcpl/conversation-router.d.ts.map +1 -1
- package/dist/src/mcpl/conversation-router.js +27 -59
- package/dist/src/mcpl/conversation-router.js.map +1 -1
- package/dist/src/mcpl/convert-block.d.ts.map +1 -1
- package/dist/src/mcpl/convert-block.js +9 -5
- package/dist/src/mcpl/convert-block.js.map +1 -1
- package/dist/src/mcpl/inbound-source.d.ts +50 -0
- package/dist/src/mcpl/inbound-source.d.ts.map +1 -1
- package/dist/src/mcpl/inbound-source.js +130 -0
- package/dist/src/mcpl/inbound-source.js.map +1 -1
- package/dist/src/mcpl/index.d.ts +2 -1
- package/dist/src/mcpl/index.d.ts.map +1 -1
- package/dist/src/mcpl/index.js +3 -2
- package/dist/src/mcpl/index.js.map +1 -1
- package/dist/src/mcpl/log-value.d.ts +6 -0
- package/dist/src/mcpl/log-value.d.ts.map +1 -1
- package/dist/src/mcpl/log-value.js +8 -0
- package/dist/src/mcpl/log-value.js.map +1 -1
- package/dist/src/mcpl/modern-connection.d.ts +15 -6
- package/dist/src/mcpl/modern-connection.d.ts.map +1 -1
- package/dist/src/mcpl/modern-connection.js +168 -53
- package/dist/src/mcpl/modern-connection.js.map +1 -1
- package/dist/src/mcpl/prose-grammar.d.ts +4 -5
- package/dist/src/mcpl/prose-grammar.d.ts.map +1 -1
- package/dist/src/mcpl/prose-grammar.js +4 -5
- package/dist/src/mcpl/prose-grammar.js.map +1 -1
- package/dist/src/mcpl/protocol-family.d.ts +97 -10
- package/dist/src/mcpl/protocol-family.d.ts.map +1 -1
- package/dist/src/mcpl/protocol-family.js +174 -17
- package/dist/src/mcpl/protocol-family.js.map +1 -1
- package/dist/src/mcpl/push-coalescer.d.ts +132 -14
- package/dist/src/mcpl/push-coalescer.d.ts.map +1 -1
- package/dist/src/mcpl/push-coalescer.js +227 -50
- package/dist/src/mcpl/push-coalescer.js.map +1 -1
- package/dist/src/mcpl/push-handler.d.ts +9 -20
- package/dist/src/mcpl/push-handler.d.ts.map +1 -1
- package/dist/src/mcpl/push-handler.js +7 -49
- package/dist/src/mcpl/push-handler.js.map +1 -1
- package/dist/src/mcpl/reference-fetcher.d.ts +2 -2
- package/dist/src/mcpl/reference-fetcher.d.ts.map +1 -1
- package/dist/src/mcpl/reference-fetcher.js +4 -4
- package/dist/src/mcpl/reference-fetcher.js.map +1 -1
- package/dist/src/mcpl/server-connection.d.ts +50 -21
- package/dist/src/mcpl/server-connection.d.ts.map +1 -1
- package/dist/src/mcpl/server-connection.js +158 -74
- package/dist/src/mcpl/server-connection.js.map +1 -1
- package/dist/src/mcpl/tags.d.ts +24 -0
- package/dist/src/mcpl/tags.d.ts.map +1 -1
- package/dist/src/mcpl/tags.js +33 -0
- package/dist/src/mcpl/tags.js.map +1 -1
- package/dist/src/mcpl/tool-result-normalize.d.ts +2 -1
- package/dist/src/mcpl/tool-result-normalize.d.ts.map +1 -1
- package/dist/src/mcpl/tool-result-normalize.js.map +1 -1
- package/dist/src/mcpl/transport.d.ts +55 -7
- package/dist/src/mcpl/transport.d.ts.map +1 -1
- package/dist/src/mcpl/transport.js +163 -34
- package/dist/src/mcpl/transport.js.map +1 -1
- package/dist/src/mcpl/types.d.ts +22 -6
- package/dist/src/mcpl/types.d.ts.map +1 -1
- package/dist/src/mcpl/types.js.map +1 -1
- package/dist/src/module-registry.d.ts +4 -2
- package/dist/src/module-registry.d.ts.map +1 -1
- package/dist/src/module-registry.js +100 -13
- package/dist/src/module-registry.js.map +1 -1
- package/dist/src/modules/discord/index.d.ts.map +1 -1
- package/dist/src/modules/discord/index.js +8 -13
- package/dist/src/modules/discord/index.js.map +1 -1
- package/dist/src/modules/history/index.d.ts.map +1 -1
- package/dist/src/modules/history/index.js +27 -4
- package/dist/src/modules/history/index.js.map +1 -1
- package/dist/src/modules/history/semantic.d.ts +15 -3
- package/dist/src/modules/history/semantic.d.ts.map +1 -1
- package/dist/src/modules/history/semantic.js +27 -5
- package/dist/src/modules/history/semantic.js.map +1 -1
- package/dist/src/modules/index.d.ts +1 -1
- package/dist/src/modules/index.d.ts.map +1 -1
- package/dist/src/modules/index.js.map +1 -1
- package/dist/src/modules/unknown-tool.d.ts +4 -1
- package/dist/src/modules/unknown-tool.d.ts.map +1 -1
- package/dist/src/modules/unknown-tool.js +4 -1
- package/dist/src/modules/unknown-tool.js.map +1 -1
- package/dist/src/modules/workspace/branch-intent.d.ts +3 -1
- package/dist/src/modules/workspace/branch-intent.d.ts.map +1 -1
- package/dist/src/modules/workspace/branch-intent.js +3 -1
- package/dist/src/modules/workspace/branch-intent.js.map +1 -1
- package/dist/src/modules/workspace/edit-plan.d.ts +265 -0
- package/dist/src/modules/workspace/edit-plan.d.ts.map +1 -0
- package/dist/src/modules/workspace/edit-plan.js +794 -0
- package/dist/src/modules/workspace/edit-plan.js.map +1 -0
- package/dist/src/modules/workspace/edit-text.d.ts +117 -0
- package/dist/src/modules/workspace/edit-text.d.ts.map +1 -0
- package/dist/src/modules/workspace/edit-text.js +584 -0
- package/dist/src/modules/workspace/edit-text.js.map +1 -0
- package/dist/src/modules/workspace/effects.d.ts +5 -4
- package/dist/src/modules/workspace/effects.d.ts.map +1 -1
- package/dist/src/modules/workspace/effects.js +78 -25
- package/dist/src/modules/workspace/effects.js.map +1 -1
- package/dist/src/modules/workspace/index.d.ts +493 -62
- package/dist/src/modules/workspace/index.d.ts.map +1 -1
- package/dist/src/modules/workspace/index.js +2216 -652
- package/dist/src/modules/workspace/index.js.map +1 -1
- package/dist/src/modules/workspace/observe.d.ts +213 -38
- package/dist/src/modules/workspace/observe.d.ts.map +1 -1
- package/dist/src/modules/workspace/observe.js +416 -36
- package/dist/src/modules/workspace/observe.js.map +1 -1
- package/dist/src/modules/workspace/reconcile.d.ts.map +1 -1
- package/dist/src/modules/workspace/reconcile.js +15 -8
- package/dist/src/modules/workspace/reconcile.js.map +1 -1
- package/dist/src/modules/workspace/text.d.ts +32 -0
- package/dist/src/modules/workspace/text.d.ts.map +1 -0
- package/dist/src/modules/workspace/text.js +67 -0
- package/dist/src/modules/workspace/text.js.map +1 -0
- package/dist/src/modules/workspace/types.d.ts +210 -6
- package/dist/src/modules/workspace/types.d.ts.map +1 -1
- package/dist/src/modules/workspace/types.js.map +1 -1
- package/dist/src/modules/workspace/watcher.d.ts +3 -2
- package/dist/src/modules/workspace/watcher.d.ts.map +1 -1
- package/dist/src/modules/workspace/watcher.js.map +1 -1
- package/dist/src/operator-log.d.ts +52 -24
- package/dist/src/operator-log.d.ts.map +1 -1
- package/dist/src/operator-log.js.map +1 -1
- package/dist/src/recovery/discord-awareness-outbox.d.ts +496 -72
- package/dist/src/recovery/discord-awareness-outbox.d.ts.map +1 -1
- package/dist/src/recovery/discord-awareness-outbox.js +1196 -289
- package/dist/src/recovery/discord-awareness-outbox.js.map +1 -1
- package/dist/src/recovery/offline-branch.d.ts +26 -1
- package/dist/src/recovery/offline-branch.d.ts.map +1 -1
- package/dist/src/recovery/offline-branch.js +164 -39
- package/dist/src/recovery/offline-branch.js.map +1 -1
- package/dist/src/recovery/recover-cli.js +23 -4
- package/dist/src/recovery/recover-cli.js.map +1 -1
- package/dist/src/send-tools.d.ts +11 -0
- package/dist/src/send-tools.d.ts.map +1 -0
- package/dist/src/send-tools.js +11 -0
- package/dist/src/send-tools.js.map +1 -0
- package/dist/src/timezone.d.ts +4 -2
- package/dist/src/timezone.d.ts.map +1 -1
- package/dist/src/timezone.js +4 -2
- package/dist/src/timezone.js.map +1 -1
- package/dist/src/tool-image-ledger.d.ts +46 -7
- package/dist/src/tool-image-ledger.d.ts.map +1 -1
- package/dist/src/tool-image-ledger.js +60 -7
- package/dist/src/tool-image-ledger.js.map +1 -1
- package/dist/src/tool-result-guard.d.ts +14 -11
- package/dist/src/tool-result-guard.d.ts.map +1 -1
- package/dist/src/tool-result-guard.js +17 -14
- package/dist/src/tool-result-guard.js.map +1 -1
- package/dist/src/tool-result-history.d.ts +3 -2
- package/dist/src/tool-result-history.d.ts.map +1 -1
- package/dist/src/tool-result-history.js +3 -2
- package/dist/src/tool-result-history.js.map +1 -1
- package/dist/src/tune-out/coordinator.d.ts +21 -0
- package/dist/src/tune-out/coordinator.d.ts.map +1 -1
- package/dist/src/tune-out/coordinator.js +142 -40
- package/dist/src/tune-out/coordinator.js.map +1 -1
- package/dist/src/types/agent.d.ts +31 -6
- package/dist/src/types/agent.d.ts.map +1 -1
- package/dist/src/types/events.d.ts +66 -9
- package/dist/src/types/events.d.ts.map +1 -1
- package/dist/src/types/framework.d.ts +69 -62
- package/dist/src/types/framework.d.ts.map +1 -1
- package/dist/src/types/module.d.ts +40 -8
- package/dist/src/types/module.d.ts.map +1 -1
- package/dist/src/types/trace.d.ts +111 -10
- package/dist/src/types/trace.d.ts.map +1 -1
- package/dist/src/usage/types.d.ts +16 -0
- package/dist/src/usage/types.d.ts.map +1 -1
- package/dist/src/usage/usage-tracker.d.ts +14 -1
- package/dist/src/usage/usage-tracker.d.ts.map +1 -1
- package/dist/src/usage/usage-tracker.js +81 -41
- package/dist/src/usage/usage-tracker.js.map +1 -1
- package/package.json +6 -8
- package/src/agent.ts +99 -12
- package/src/api/server.ts +14 -12
- package/src/clock-rows.ts +302 -0
- package/src/code-execution/engine.ts +595 -0
- package/src/code-execution/index.ts +5 -17
- package/src/code-execution/isolate/calls.ts +199 -0
- package/src/code-execution/isolate/child-entry.ts +5 -0
- package/src/code-execution/isolate/child-main.ts +132 -0
- package/src/code-execution/isolate/child.ts +327 -0
- package/src/code-execution/isolate/frames.ts +209 -0
- package/src/code-execution/isolate/output.ts +161 -0
- package/src/code-execution/isolate/prelude.ts +409 -0
- package/src/code-execution/isolate/protocol.ts +240 -0
- package/src/code-execution/isolate/render.ts +230 -0
- package/src/code-execution/isolate/runner.ts +911 -0
- package/src/code-execution/isolate/scripts.ts +479 -0
- package/src/code-execution/quickjs.wasm +0 -0
- package/src/code-execution/quickjs.wasm.LICENSES +149 -0
- package/src/code-execution/tool-definition.ts +236 -167
- package/src/deadline-timer.ts +1 -1
- package/src/framework.ts +4978 -2671
- package/src/gate/event-gate.ts +79 -17
- package/src/held-writes.ts +17 -3
- package/src/index.ts +32 -22
- package/src/mcpl/channel-registry.ts +1892 -471
- package/src/mcpl/conversation-router.ts +26 -72
- package/src/mcpl/convert-block.ts +9 -5
- package/src/mcpl/inbound-source.ts +131 -0
- package/src/mcpl/index.ts +4 -2
- package/src/mcpl/log-value.ts +9 -0
- package/src/mcpl/modern-connection.ts +162 -53
- package/src/mcpl/prose-grammar.ts +4 -5
- package/src/mcpl/protocol-family.ts +200 -22
- package/src/mcpl/push-coalescer.ts +306 -62
- package/src/mcpl/push-handler.ts +12 -54
- package/src/mcpl/reference-fetcher.ts +4 -4
- package/src/mcpl/server-connection.ts +180 -79
- package/src/mcpl/tags.ts +36 -0
- package/src/mcpl/tool-result-normalize.ts +2 -1
- package/src/mcpl/transport.ts +162 -33
- package/src/mcpl/types.ts +23 -6
- package/src/module-registry.ts +92 -13
- package/src/modules/discord/index.ts +8 -13
- package/src/modules/history/index.ts +25 -3
- package/src/modules/history/semantic.ts +29 -6
- package/src/modules/index.ts +1 -0
- package/src/modules/unknown-tool.ts +4 -1
- package/src/modules/workspace/branch-intent.ts +3 -1
- package/src/modules/workspace/edit-plan.ts +954 -0
- package/src/modules/workspace/edit-text.ts +618 -0
- package/src/modules/workspace/effects.ts +70 -19
- package/src/modules/workspace/index.ts +2293 -650
- package/src/modules/workspace/observe.ts +465 -56
- package/src/modules/workspace/reconcile.ts +14 -7
- package/src/modules/workspace/text.ts +68 -0
- package/src/modules/workspace/types.ts +211 -6
- package/src/modules/workspace/watcher.ts +3 -2
- package/src/operator-log.ts +47 -19
- package/src/recovery/discord-awareness-outbox.ts +1500 -356
- package/src/recovery/offline-branch.ts +189 -55
- package/src/recovery/recover-cli.ts +25 -4
- package/src/send-tools.ts +11 -0
- package/src/timezone.ts +4 -2
- package/src/tool-image-ledger.ts +62 -8
- package/src/tool-result-guard.ts +18 -15
- package/src/tool-result-history.ts +3 -2
- package/src/tune-out/coordinator.ts +166 -45
- package/src/types/agent.ts +32 -6
- package/src/types/events.ts +66 -9
- package/src/types/framework.ts +69 -62
- package/src/types/module.ts +40 -8
- package/src/types/trace.ts +121 -15
- package/src/usage/types.ts +16 -0
- package/src/usage/usage-tracker.ts +86 -43
- package/dist/src/code-execution/py-runner.d.ts +0 -191
- package/dist/src/code-execution/py-runner.d.ts.map +0 -1
- package/dist/src/code-execution/py-runner.js +0 -655
- package/dist/src/code-execution/py-runner.js.map +0 -1
- package/dist/src/code-execution/runtime-py.d.ts +0 -50
- package/dist/src/code-execution/runtime-py.d.ts.map +0 -1
- package/dist/src/code-execution/runtime-py.js +0 -677
- package/dist/src/code-execution/runtime-py.js.map +0 -1
- package/dist/src/mcpl/prose-stream-router.d.ts +0 -88
- package/dist/src/mcpl/prose-stream-router.d.ts.map +0 -1
- package/dist/src/mcpl/prose-stream-router.js +0 -291
- package/dist/src/mcpl/prose-stream-router.js.map +0 -1
- package/src/code-execution/py-runner.ts +0 -791
- package/src/code-execution/runtime-py.ts +0 -677
- package/src/mcpl/prose-stream-router.ts +0 -268
|
@@ -0,0 +1,911 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The parent's side of one script: its child process, the calls it makes, its
|
|
3
|
+
* output, and how it ends. One `ScriptRun` per script, and one child per run,
|
|
4
|
+
* which ends with it, so nothing a script held stays after it.
|
|
5
|
+
*
|
|
6
|
+
* Every frame from the child is decoded and checked before the parent acts on
|
|
7
|
+
* it, since the child is untrusted. A frame that breaks the protocol, a child
|
|
8
|
+
* that exits without saying how the script ended, or one that doesn't stop
|
|
9
|
+
* when asked, ends the run as crashed. A tool call goes to `onToolCall`, the
|
|
10
|
+
* bridge to the framework's dispatcher, and its outcome goes into the run's
|
|
11
|
+
* call record at dispatch, as running until it's answered.
|
|
12
|
+
*
|
|
13
|
+
* What the host holds for a script is bounded: at most MAX_CALLS_IN_FLIGHT of
|
|
14
|
+
* its calls wait for answers at once, and at most MAX_CALLS_IN_FLIGHT are out
|
|
15
|
+
* in the host, timed-out ones whose tools still run included, with any past
|
|
16
|
+
* those waiting here as their text. Each call's arguments are within
|
|
17
|
+
* MAX_ARGUMENT_BYTES and MAX_ARGUMENT_VALUES, and each answer is at most
|
|
18
|
+
* `maxResultBytes`. Answers wait for the child as their frames, written while
|
|
19
|
+
* its stdin takes them and after its 'drain' once it hasn't, so each is held
|
|
20
|
+
* once, in bytes.
|
|
21
|
+
*/
|
|
22
|
+
|
|
23
|
+
import { spawn, type ChildProcess } from 'node:child_process';
|
|
24
|
+
import { mkdtempSync, rmSync, writeFileSync } from 'node:fs';
|
|
25
|
+
import { tmpdir } from 'node:os';
|
|
26
|
+
import { extname, join, resolve } from 'node:path';
|
|
27
|
+
import { fileURLToPath } from 'node:url';
|
|
28
|
+
import type { Readable, Writable } from 'node:stream';
|
|
29
|
+
import { encodeFrame, encodeResultFrame, FrameDecoder, FrameError, RESULT_FRAME_OVERHEAD } from './frames.js';
|
|
30
|
+
import { MAX_BATCH_CHARS, WindowOutput, type OutputBounds, type OutputWindow } from './output.js';
|
|
31
|
+
import { WAKE_CALL_NAME, type PreludeTool } from './prelude.js';
|
|
32
|
+
import { CallLog, callBrief, type CallEntry, type CallOutcome, type CallSummary } from './calls.js';
|
|
33
|
+
import {
|
|
34
|
+
argumentsRefusal,
|
|
35
|
+
capText,
|
|
36
|
+
MAX_CHILD_FRAME_BYTES,
|
|
37
|
+
decodeChildMessage,
|
|
38
|
+
MAX_ARGUMENT_BYTES,
|
|
39
|
+
MAX_ARGUMENT_VALUES,
|
|
40
|
+
MAX_CALLS_IN_FLIGHT,
|
|
41
|
+
MAX_WAKE_PAYLOAD_BYTES,
|
|
42
|
+
RESULT_TOO_LARGE,
|
|
43
|
+
wakeRefusal,
|
|
44
|
+
type ChildMessage,
|
|
45
|
+
type GuestError,
|
|
46
|
+
type StopReason,
|
|
47
|
+
} from './protocol.js';
|
|
48
|
+
import { bounded, CUT_MARK_CHARS, formatMs, glimpse, NAME_CHARS, VALUE_CHARS } from './render.js';
|
|
49
|
+
|
|
50
|
+
/** A call's answer for the guest: `ok` resolves its promise with the parsed JSON, otherwise rejects with it. */
|
|
51
|
+
export interface CallAnswer {
|
|
52
|
+
ok: boolean;
|
|
53
|
+
json: string;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/** A tool call's answer from the bridge. */
|
|
57
|
+
export interface ToolAnswer extends CallAnswer {
|
|
58
|
+
/** The result was marked as an error. */
|
|
59
|
+
isError?: boolean;
|
|
60
|
+
/** The start of what the tool answered, as an agent would read it, for naming a call the script stopped waiting for. */
|
|
61
|
+
brief?: string;
|
|
62
|
+
/**
|
|
63
|
+
* No result came from the host in time, so the bridge answered for it: the
|
|
64
|
+
* tool may still be running, holding its work, or its result was lost, and
|
|
65
|
+
* the host can't tell which. Its place stays taken until the run ends.
|
|
66
|
+
*/
|
|
67
|
+
held?: boolean;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/** How a script ended. */
|
|
71
|
+
export type Ending =
|
|
72
|
+
| { how: 'finished'; value?: string }
|
|
73
|
+
| { how: 'failed'; error: GuestError; stalled?: boolean }
|
|
74
|
+
/** `short` when the child had to be ended, so its output so far may be short. */
|
|
75
|
+
| { how: 'stopped'; reason: StopReason; short?: boolean; why?: string }
|
|
76
|
+
| { how: 'crashed'; detail: string };
|
|
77
|
+
|
|
78
|
+
/** A run as it stands: its output since the last return, its calls, and its ending once it has one. */
|
|
79
|
+
export interface RunSnapshot {
|
|
80
|
+
output: string;
|
|
81
|
+
calls: CallSummary;
|
|
82
|
+
ending: Ending | null;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/** How many script children a host runs at once, counted until each child's process has exited. */
|
|
86
|
+
export class ChildCapacity {
|
|
87
|
+
private live = 0;
|
|
88
|
+
constructor(readonly max: number) {}
|
|
89
|
+
get running(): number {
|
|
90
|
+
return this.live;
|
|
91
|
+
}
|
|
92
|
+
/** Take a place, or false when the host is full. */
|
|
93
|
+
take(): boolean {
|
|
94
|
+
if (this.live >= this.max) return false;
|
|
95
|
+
this.live += 1;
|
|
96
|
+
return true;
|
|
97
|
+
}
|
|
98
|
+
release(): void {
|
|
99
|
+
this.live = Math.max(0, this.live - 1);
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
/**
|
|
104
|
+
* The child's entry, beside this module and with its extension: `.ts` where
|
|
105
|
+
* the host runs this package's source, as a Bun host does through the
|
|
106
|
+
* package's `bun` export, and `.js` where it runs the build. So the runtime
|
|
107
|
+
* is given a file that exists: given a `.js` path that it maps to the `.ts`
|
|
108
|
+
* beside it, Bun runs it as `bun run` would, with npm_* variables and a PATH
|
|
109
|
+
* of its own in the child's environment.
|
|
110
|
+
*/
|
|
111
|
+
export const CHILD_ENTRY = fileURLToPath(new URL(`./child-entry${extname(fileURLToPath(import.meta.url))}`, import.meta.url));
|
|
112
|
+
|
|
113
|
+
export interface ScriptRunOptions {
|
|
114
|
+
/** The child's entry: a script the runtime runs as the child process (CHILD_ENTRY, but for tests). */
|
|
115
|
+
childEntry: string;
|
|
116
|
+
/** Arguments after the entry, if any. */
|
|
117
|
+
childArgs?: readonly string[];
|
|
118
|
+
/** The runtime to run it with: by default the parent's own. */
|
|
119
|
+
execPath?: string;
|
|
120
|
+
memoryMaxBytes: number;
|
|
121
|
+
maxStackBytes?: number;
|
|
122
|
+
output: OutputBounds;
|
|
123
|
+
/** How often, at most, the child flushes output while its guest computes. */
|
|
124
|
+
flushMs: number;
|
|
125
|
+
/** The script's limit: at this age it's stopped, as "stopped at its limit". */
|
|
126
|
+
lifetimeMs: number;
|
|
127
|
+
/** How long a call may go unanswered before the script is told it timed out. */
|
|
128
|
+
toolCallTimeoutMs: number;
|
|
129
|
+
/** How long a stopped child has to report its ending before it's ended. */
|
|
130
|
+
stopGraceMs: number;
|
|
131
|
+
/** The least time between two of the script's wakes: an early one waits for its turn. */
|
|
132
|
+
wakeMinIntervalMs: number;
|
|
133
|
+
/** The most wakes one script may send; past it, a wake is refused. */
|
|
134
|
+
maxWakes: number;
|
|
135
|
+
/** The bridge: dispatch one tool call. Never rejects; a refusal is `ok: false`. */
|
|
136
|
+
onToolCall(name: string, argsJson: string): Promise<ToolAnswer>;
|
|
137
|
+
/** Send `wake_agent(payload)`, the script's `n`th wake, once its turn has come: answers the guest's promise. */
|
|
138
|
+
onWake(payloadJson: string, n: number): Promise<CallAnswer>;
|
|
139
|
+
/** Called once, when the run has an ending. */
|
|
140
|
+
onEnd?(ending: Ending): void;
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
/** The most of a child's stderr kept, for a crash's detail. */
|
|
144
|
+
const STDERR_KEEP_CHARS = 4_000;
|
|
145
|
+
/**
|
|
146
|
+
* The most values a frame from the child may hold. Its largest, a failure's
|
|
147
|
+
* ending, holds eight, since arguments, output and values travel as strings.
|
|
148
|
+
*/
|
|
149
|
+
const MAX_CHILD_FRAME_VALUES = 32;
|
|
150
|
+
/** How much of a refusal too long for the script's memory reaches it. */
|
|
151
|
+
const REFUSAL_GLIMPSE_CHARS = 2000;
|
|
152
|
+
|
|
153
|
+
/** Remove a run's directory. A failure leaves only an empty directory behind, which mustn't stop the run's ending. */
|
|
154
|
+
function removeDir(dir: string): void {
|
|
155
|
+
try {
|
|
156
|
+
rmSync(dir, { recursive: true, force: true });
|
|
157
|
+
} catch {
|
|
158
|
+
// Nothing else to do: the directory is left.
|
|
159
|
+
}
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
export { MAX_CALLS_IN_FLIGHT };
|
|
163
|
+
|
|
164
|
+
/**
|
|
165
|
+
* The largest result the parent sends a script: a third of its linear memory,
|
|
166
|
+
* since parsing one takes about three times its size there (a heap run under
|
|
167
|
+
* Bun 1.4.3 and Node 24.19.0). A larger one would fail in the guest anyway,
|
|
168
|
+
* after growing the child past its budget, so it's refused before it's sent,
|
|
169
|
+
* and the script's ResultTooLarge says the call took effect.
|
|
170
|
+
*/
|
|
171
|
+
export function maxResultBytes(memoryMaxBytes: number): number {
|
|
172
|
+
return Math.max(0, Math.floor(memoryMaxBytes / 3));
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
/** A refusal's own text, from its JSON, for a refusal that came with no brief. */
|
|
176
|
+
function refusalText(json: string): string {
|
|
177
|
+
try {
|
|
178
|
+
const message = (JSON.parse(json) as { message?: unknown }).message;
|
|
179
|
+
if (typeof message === 'string') return message;
|
|
180
|
+
} catch {
|
|
181
|
+
// Not a refusal's JSON: its text is what there is.
|
|
182
|
+
}
|
|
183
|
+
return json;
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
export class ScriptRun {
|
|
187
|
+
private child: ChildProcess | null = null;
|
|
188
|
+
private dir: string | null = null;
|
|
189
|
+
private readonly output: WindowOutput;
|
|
190
|
+
private readonly log = new CallLog();
|
|
191
|
+
/** The ids of the guest's calls the child named, reserved until it says the script has their answer. */
|
|
192
|
+
private readonly pendingIds = new Set<number>();
|
|
193
|
+
/** The calls answered and not yet seen: only these may the child say the script has. */
|
|
194
|
+
private readonly answeredIds = new Set<number>();
|
|
195
|
+
/** The script's tools, the only names its calls may have. */
|
|
196
|
+
private toolNames = new Set<string>();
|
|
197
|
+
/** Results sent to the guest and not yet seen: each is counted once the child says the script has it, and whether it fit. */
|
|
198
|
+
private readonly delivered = new Map<number, { entry: CallEntry; outcome: CallOutcome; brief: string }>();
|
|
199
|
+
private ending: Ending | null = null;
|
|
200
|
+
private readonly waiters = new Set<() => void>();
|
|
201
|
+
private stderr = '';
|
|
202
|
+
private lifetimeTimer: ReturnType<typeof setTimeout> | null = null;
|
|
203
|
+
private killTimer: ReturnType<typeof setTimeout> | null = null;
|
|
204
|
+
private processExited = false;
|
|
205
|
+
private stopAsked: { reason: StopReason; why?: string } | null = null;
|
|
206
|
+
/** Each unanswered call's timeout, cleared when it's answered or the run ends. */
|
|
207
|
+
private readonly callTimers = new Set<ReturnType<typeof setTimeout>>();
|
|
208
|
+
/** The calls holding a place among those in flight, by id, until the child says the script has their answer. */
|
|
209
|
+
private readonly inFlight = new Set<number>();
|
|
210
|
+
/**
|
|
211
|
+
* How many calls the bridge holds, until it answers each: a call that timed
|
|
212
|
+
* out in the script, and so left its place there, may still hold its
|
|
213
|
+
* arguments in the host. Counted, not keyed by id, since the child may name
|
|
214
|
+
* a timed-out call's id again once the script has seen its TimeoutError.
|
|
215
|
+
* At most MAX_CALLS_IN_FLIGHT; a call past them waits in `dispatchWaiting`,
|
|
216
|
+
* as its text, until one is answered. One the bridge answered for (`held`)
|
|
217
|
+
* keeps its place until the run ends.
|
|
218
|
+
*/
|
|
219
|
+
private hostCalls = 0;
|
|
220
|
+
/** Calls waiting for the bridge to hold fewer: each resolved with true when its turn comes, false when it won't be sent. */
|
|
221
|
+
private readonly dispatchWaiting = new Map<number, (go: boolean) => void>();
|
|
222
|
+
/** Wakes the child has sent, which the cap counts: those refused once a stop was asked included, so a child that ignores the stop is still bounded. */
|
|
223
|
+
private wakeFrames = 0;
|
|
224
|
+
/** Wakes handed to the host to deliver, whatever it answered: a wake's number, which the host is given. */
|
|
225
|
+
private wakes = 0;
|
|
226
|
+
/** Wakes the host delivered, its answer neither a refusal nor a failure. */
|
|
227
|
+
private delivered_wakes = 0;
|
|
228
|
+
/** Wakes waiting for their turn, which the run's end counts as never sent. */
|
|
229
|
+
private wakesWaiting = 0;
|
|
230
|
+
/** When the next wake may go out, on the monotonic clock: the spacing counts from when the last wake's delivery ended. */
|
|
231
|
+
private nextWakeAt = 0;
|
|
232
|
+
/** Settles once the latest wake has gone out, or won't: wakes go out in the order they were made. */
|
|
233
|
+
private wakeChain: Promise<void> = Promise.resolve();
|
|
234
|
+
/** Wakes waiting out the spacing, each released at its time or when the run ends. */
|
|
235
|
+
private readonly wakeWaiters = new Set<() => void>();
|
|
236
|
+
/**
|
|
237
|
+
* Results waiting for the child's stdin to drain, as their frames: each holds
|
|
238
|
+
* its JSON's bytes, which the result bound counts. A result's id becomes one
|
|
239
|
+
* the child may say it has seen once its frame is written.
|
|
240
|
+
*/
|
|
241
|
+
private readonly resultQueue: Array<{ id: number; frame: Buffer }> = [];
|
|
242
|
+
private draining = false;
|
|
243
|
+
private exitResolve!: () => void;
|
|
244
|
+
private endedResolve!: () => void;
|
|
245
|
+
/** Resolves when the run ends: a wake waiting behind another's delivery stops waiting then. */
|
|
246
|
+
private readonly endedSignal: Promise<void> = new Promise((resolve) => (this.endedResolve = resolve));
|
|
247
|
+
/** Resolves when the child's process has exited, or at once if it never started. */
|
|
248
|
+
readonly exited: Promise<void> = new Promise((resolve) => (this.exitResolve = resolve));
|
|
249
|
+
|
|
250
|
+
constructor(private readonly options: ScriptRunOptions) {
|
|
251
|
+
this.output = new WindowOutput(options.output);
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
/**
|
|
255
|
+
* Spawn the child and send it the script. A start frame too long for a frame
|
|
256
|
+
* throws before anything is spawned, with `exited` resolved, so nothing is
|
|
257
|
+
* left running and the place it took frees at once. A child that can't be
|
|
258
|
+
* spawned crashes the run.
|
|
259
|
+
*/
|
|
260
|
+
start(script: string, tools: readonly PreludeTool[]): void {
|
|
261
|
+
let dir: string | null = null;
|
|
262
|
+
let startFrame: Buffer;
|
|
263
|
+
try {
|
|
264
|
+
this.toolNames = new Set(tools.map((t) => t.name));
|
|
265
|
+
// Absolute, since the child starts in it: a relative TMPDIR would name
|
|
266
|
+
// the stop file from two different places.
|
|
267
|
+
dir = resolve(mkdtempSync(join(tmpdir(), 'af-script-')));
|
|
268
|
+
startFrame = encodeFrame({
|
|
269
|
+
t: 'start',
|
|
270
|
+
script,
|
|
271
|
+
tools: tools.map((t) => ({ name: t.name, description: t.description, ...(t.inputSchema === undefined ? {} : { inputSchema: t.inputSchema }) })),
|
|
272
|
+
memoryMaxBytes: this.options.memoryMaxBytes,
|
|
273
|
+
...(this.options.maxStackBytes === undefined ? {} : { maxStackBytes: this.options.maxStackBytes }),
|
|
274
|
+
stopFile: join(dir, 'stop'),
|
|
275
|
+
parentPid: process.pid,
|
|
276
|
+
flushMs: this.options.flushMs,
|
|
277
|
+
maxWakes: this.options.maxWakes,
|
|
278
|
+
});
|
|
279
|
+
} catch (err) {
|
|
280
|
+
if (dir) removeDir(dir);
|
|
281
|
+
this.exitResolve();
|
|
282
|
+
throw err;
|
|
283
|
+
}
|
|
284
|
+
this.dir = dir;
|
|
285
|
+
const runDir = dir;
|
|
286
|
+
// A process that never started never exits: the run crashes, and its place
|
|
287
|
+
// is free now. It counts as exited, so the ending arms no kill and holds no child.
|
|
288
|
+
const neverStarted = (why: unknown): void => {
|
|
289
|
+
this.processExited = true;
|
|
290
|
+
this.crash(`its process could not run: ${why instanceof Error ? why.message : String(why)}`);
|
|
291
|
+
removeDir(runDir);
|
|
292
|
+
this.exitResolve();
|
|
293
|
+
};
|
|
294
|
+
let child: ChildProcess;
|
|
295
|
+
try {
|
|
296
|
+
child = spawn(this.options.execPath ?? process.execPath, [this.options.childEntry, ...(this.options.childArgs ?? [])], {
|
|
297
|
+
// The parent's frames go in on stdin, and the child's come back on fd 3 (child-main.ts says why).
|
|
298
|
+
stdio: ['pipe', 'ignore', 'pipe', 'pipe'],
|
|
299
|
+
// The child is ours and reads nothing from its environment; the guest has none at all.
|
|
300
|
+
env: {},
|
|
301
|
+
// Its own directory, which holds nothing but the stop file: Bun reads a
|
|
302
|
+
// .env file, and its bunfig.toml, from the directory it starts in, so
|
|
303
|
+
// in the host's it would load the host's .env into the child.
|
|
304
|
+
cwd: runDir,
|
|
305
|
+
});
|
|
306
|
+
} catch (err) {
|
|
307
|
+
// Node throws here for some arguments it refuses at once.
|
|
308
|
+
neverStarted(err);
|
|
309
|
+
return;
|
|
310
|
+
}
|
|
311
|
+
// Node emits a spawn's 'error' on a later tick, which this takes. Bun's can
|
|
312
|
+
// come while spawn runs, before this is attached: spawn then throws it into
|
|
313
|
+
// the catch above, or returns with its pipes null, which the check below
|
|
314
|
+
// takes.
|
|
315
|
+
child.on('error', (err) => {
|
|
316
|
+
if (child.pid === undefined) neverStarted(err);
|
|
317
|
+
else this.crash(`its process could not run: ${err.message}`);
|
|
318
|
+
});
|
|
319
|
+
this.child = child;
|
|
320
|
+
const toChild = child.stdin as Writable | null;
|
|
321
|
+
const fromChild = child.stdio[3] as Readable | null;
|
|
322
|
+
if (!toChild || !fromChild) {
|
|
323
|
+
// Bun can return with no pipes and emit the spawn's 'error', which names
|
|
324
|
+
// the cause, just after: a turn of the loop lets it come first.
|
|
325
|
+
setImmediate(() => neverStarted(new Error('its pipes were never opened')));
|
|
326
|
+
return;
|
|
327
|
+
}
|
|
328
|
+
// A child's frames are small flat objects, its arguments and output
|
|
329
|
+
// carried as strings, so one holding more values isn't following the
|
|
330
|
+
// protocol, and is refused before it's parsed.
|
|
331
|
+
const decoder = new FrameDecoder(MAX_CHILD_FRAME_BYTES, false, MAX_CHILD_FRAME_VALUES);
|
|
332
|
+
fromChild.on('data', (chunk: Buffer) => {
|
|
333
|
+
let values: unknown[];
|
|
334
|
+
try {
|
|
335
|
+
values = decoder.push(chunk);
|
|
336
|
+
} catch (err) {
|
|
337
|
+
this.crash(`its process sent the host a message it couldn't read (${(err as FrameError).message})`);
|
|
338
|
+
return;
|
|
339
|
+
}
|
|
340
|
+
for (const value of values) {
|
|
341
|
+
const message = decodeChildMessage(value);
|
|
342
|
+
if ('invalid' in message) {
|
|
343
|
+
this.crash(`its process sent the host a message it couldn't read (${message.invalid})`);
|
|
344
|
+
return;
|
|
345
|
+
}
|
|
346
|
+
this.handle(message);
|
|
347
|
+
}
|
|
348
|
+
});
|
|
349
|
+
// Whether the child said how the script ended is known once its process has
|
|
350
|
+
// exited and fd 3 has ended, so every frame it wrote has been read: Node can
|
|
351
|
+
// emit 'exit' before it has read a child's last frames (1 in 2,700 runs of a
|
|
352
|
+
// probe under Node 20.20.2), and Bun 1.4.3 emits 'close' before an extra
|
|
353
|
+
// pipe like fd 3 has ended. A crash's detail waits for stderr's end too, for
|
|
354
|
+
// the same reason.
|
|
355
|
+
let exit: { code: number | null; signal: NodeJS.Signals | null } | null = null;
|
|
356
|
+
let channelEnded = false;
|
|
357
|
+
let stderrEnded = child.stderr === null;
|
|
358
|
+
const decide = (): void => {
|
|
359
|
+
if (!exit || !channelEnded || !stderrEnded || this.ending !== null) return;
|
|
360
|
+
const how = exit.signal ? `was ended by ${exit.signal}` : `exited with code ${exit.code}`;
|
|
361
|
+
if (this.stopAsked) this.end({ how: 'stopped', reason: this.stopAsked.reason, short: true, ...(this.stopAsked.why ? { why: this.stopAsked.why } : {}) });
|
|
362
|
+
else this.crash(`its process ${how} before the script ended${this.stderr ? `: ${this.stderr.trim()}` : ''}`);
|
|
363
|
+
};
|
|
364
|
+
const channelEnd = (): void => {
|
|
365
|
+
if (channelEnded) return;
|
|
366
|
+
channelEnded = true;
|
|
367
|
+
// A frame cut off at the channel's end can't be read: the child broke off
|
|
368
|
+
// mid-message. Once a stop was asked that's the stop's doing, since the
|
|
369
|
+
// grace's kill can cut a frame, and the run ends as stopped.
|
|
370
|
+
try {
|
|
371
|
+
decoder.end();
|
|
372
|
+
} catch (err) {
|
|
373
|
+
if (!this.stopAsked) this.crash(`its process sent the host a message it couldn't read (${(err as FrameError).message})`);
|
|
374
|
+
}
|
|
375
|
+
decide();
|
|
376
|
+
};
|
|
377
|
+
fromChild.on('end', channelEnd);
|
|
378
|
+
fromChild.on('close', channelEnd);
|
|
379
|
+
fromChild.on('error', () => {});
|
|
380
|
+
toChild.on('error', () => {});
|
|
381
|
+
const stderrEnd = (): void => {
|
|
382
|
+
stderrEnded = true;
|
|
383
|
+
decide();
|
|
384
|
+
};
|
|
385
|
+
child.stderr?.on('data', (chunk: Buffer) => {
|
|
386
|
+
this.stderr = (this.stderr + chunk.toString('utf8')).slice(-STDERR_KEEP_CHARS);
|
|
387
|
+
});
|
|
388
|
+
child.stderr?.on('end', stderrEnd);
|
|
389
|
+
child.stderr?.on('close', stderrEnd);
|
|
390
|
+
child.stderr?.on('error', () => {});
|
|
391
|
+
// The host's place for the child frees when its process exits. fd 3 and
|
|
392
|
+
// stderr have the grace to end after it, which only a process the child
|
|
393
|
+
// left holding them could stretch.
|
|
394
|
+
child.on('exit', (code, signal) => {
|
|
395
|
+
exit = { code, signal };
|
|
396
|
+
this.processExited = true;
|
|
397
|
+
if (this.killTimer) clearTimeout(this.killTimer);
|
|
398
|
+
removeDir(runDir);
|
|
399
|
+
this.exitResolve();
|
|
400
|
+
if (!channelEnded || !stderrEnded) {
|
|
401
|
+
setTimeout(() => {
|
|
402
|
+
// After the next poll, so what the child wrote before it exited is
|
|
403
|
+
// read first, even when this loop was too busy to read it in the grace.
|
|
404
|
+
setImmediate(() => {
|
|
405
|
+
fromChild.destroy();
|
|
406
|
+
child.stderr?.destroy();
|
|
407
|
+
channelEnd();
|
|
408
|
+
stderrEnd();
|
|
409
|
+
});
|
|
410
|
+
}, this.options.stopGraceMs).unref();
|
|
411
|
+
}
|
|
412
|
+
decide();
|
|
413
|
+
if (this.ending !== null) this.release();
|
|
414
|
+
});
|
|
415
|
+
this.write(startFrame);
|
|
416
|
+
this.lifetimeTimer = setTimeout(() => this.stop('limit'), this.options.lifetimeMs);
|
|
417
|
+
}
|
|
418
|
+
|
|
419
|
+
/** The run as it stands, its output since the last return included. */
|
|
420
|
+
snapshot(): RunSnapshot {
|
|
421
|
+
// Calls waiting for room in the host, and wakes waiting for their turn, are the script's too.
|
|
422
|
+
return { output: this.output.render(), calls: this.log.summary(this.dispatchWaiting.size + this.wakesWaiting), ending: this.ending };
|
|
423
|
+
}
|
|
424
|
+
|
|
425
|
+
/** The output since the last return, as it stands. */
|
|
426
|
+
outputWindow(): OutputWindow {
|
|
427
|
+
return this.output.window();
|
|
428
|
+
}
|
|
429
|
+
|
|
430
|
+
/** The output since the last return, starting a fresh stretch from here. */
|
|
431
|
+
takeOutput(): OutputWindow {
|
|
432
|
+
return this.output.take();
|
|
433
|
+
}
|
|
434
|
+
|
|
435
|
+
get ended(): boolean {
|
|
436
|
+
return this.ending !== null;
|
|
437
|
+
}
|
|
438
|
+
|
|
439
|
+
/** End the child's process now if it's still alive, whatever the run's state. Resolves once it has exited. */
|
|
440
|
+
kill(): Promise<void> {
|
|
441
|
+
if (!this.processExited) this.child?.kill('SIGKILL');
|
|
442
|
+
return this.exited;
|
|
443
|
+
}
|
|
444
|
+
|
|
445
|
+
/** The wakes the host delivered to the script's agent: those it answered ok and not as an error. A refused wake isn't one, nor a failed one, though a failure may have taken effect. */
|
|
446
|
+
get wakesDelivered(): number {
|
|
447
|
+
return this.delivered_wakes;
|
|
448
|
+
}
|
|
449
|
+
|
|
450
|
+
/** Resolves when the script has ended, or after `ms`, whichever comes first. */
|
|
451
|
+
wait(ms: number): Promise<RunSnapshot> {
|
|
452
|
+
if (this.ending) return Promise.resolve(this.snapshot());
|
|
453
|
+
return new Promise((resolve) => {
|
|
454
|
+
const done = () => {
|
|
455
|
+
clearTimeout(timer);
|
|
456
|
+
this.waiters.delete(done);
|
|
457
|
+
resolve(this.snapshot());
|
|
458
|
+
};
|
|
459
|
+
const timer = setTimeout(done, ms);
|
|
460
|
+
this.waiters.add(done);
|
|
461
|
+
});
|
|
462
|
+
}
|
|
463
|
+
|
|
464
|
+
/**
|
|
465
|
+
* Ask the script to stop: the stop file for a computing guest, a frame for an
|
|
466
|
+
* idle one. If the child hasn't said how it ended within the grace, it's
|
|
467
|
+
* ended, and the ending says its output so far may be short. The reason is
|
|
468
|
+
* the one asked here, whatever the child says.
|
|
469
|
+
*/
|
|
470
|
+
stop(reason: StopReason, why?: string): Promise<RunSnapshot> {
|
|
471
|
+
if (this.ending || !this.child) return Promise.resolve(this.snapshot());
|
|
472
|
+
// A process that has exited has no stop to take: its run ends as its last
|
|
473
|
+
// frames say, or as a crash, which a stop asked now mustn't take for its own.
|
|
474
|
+
if (!this.stopAsked && !this.processExited) {
|
|
475
|
+
this.stopAsked = { reason, ...(why ? { why } : {}) };
|
|
476
|
+
try {
|
|
477
|
+
if (this.dir) writeFileSync(join(this.dir, 'stop'), '');
|
|
478
|
+
} catch {
|
|
479
|
+
// The frame below still reaches an idle child, and the grace ends a computing one.
|
|
480
|
+
}
|
|
481
|
+
this.send({ t: 'stop' });
|
|
482
|
+
this.killTimer = setTimeout(() => {
|
|
483
|
+
this.child?.kill('SIGKILL');
|
|
484
|
+
}, this.options.stopGraceMs);
|
|
485
|
+
this.killTimer.unref?.();
|
|
486
|
+
}
|
|
487
|
+
return new Promise((resolve) => {
|
|
488
|
+
const done = () => {
|
|
489
|
+
this.waiters.delete(done);
|
|
490
|
+
resolve(this.snapshot());
|
|
491
|
+
};
|
|
492
|
+
if (this.ending) done();
|
|
493
|
+
else this.waiters.add(done);
|
|
494
|
+
});
|
|
495
|
+
}
|
|
496
|
+
|
|
497
|
+
private send(message: unknown): void {
|
|
498
|
+
this.write(encodeFrame(message));
|
|
499
|
+
}
|
|
500
|
+
|
|
501
|
+
/** A call's result, as a result frame once its turn comes: the JSON's bytes go to the engine as they are. */
|
|
502
|
+
private sendResult(id: number, ok: boolean, json: string): void {
|
|
503
|
+
this.resultQueue.push({ id, frame: encodeResultFrame(id, ok, json, maxResultBytes(this.options.memoryMaxBytes) + RESULT_FRAME_OVERHEAD) });
|
|
504
|
+
this.pump();
|
|
505
|
+
}
|
|
506
|
+
|
|
507
|
+
/** Write waiting results, one frame at a time: after a write the pipe didn't take whole, the next waits for 'drain'. */
|
|
508
|
+
private pump(): void {
|
|
509
|
+
const pipe = this.child?.stdin ?? undefined;
|
|
510
|
+
if (!pipe || pipe.destroyed) {
|
|
511
|
+
this.resultQueue.length = 0;
|
|
512
|
+
return;
|
|
513
|
+
}
|
|
514
|
+
while (!this.draining && this.resultQueue.length > 0) {
|
|
515
|
+
const { id, frame } = this.resultQueue.shift()!;
|
|
516
|
+
this.answeredIds.add(id);
|
|
517
|
+
if (!pipe.write(frame)) {
|
|
518
|
+
this.draining = true;
|
|
519
|
+
pipe.once('drain', () => {
|
|
520
|
+
this.draining = false;
|
|
521
|
+
this.pump();
|
|
522
|
+
});
|
|
523
|
+
}
|
|
524
|
+
}
|
|
525
|
+
}
|
|
526
|
+
|
|
527
|
+
private write(frame: Buffer): void {
|
|
528
|
+
const pipe = this.child?.stdin ?? undefined;
|
|
529
|
+
if (!pipe || pipe.destroyed) return;
|
|
530
|
+
pipe.write(frame);
|
|
531
|
+
}
|
|
532
|
+
|
|
533
|
+
|
|
534
|
+
private handle(message: ChildMessage): void {
|
|
535
|
+
if (this.ending && message.t !== 'out') return;
|
|
536
|
+
switch (message.t) {
|
|
537
|
+
case 'out':
|
|
538
|
+
// The child flushes output in pieces of at most a batch.
|
|
539
|
+
if (message.text.length > MAX_BATCH_CHARS) {
|
|
540
|
+
this.crash(`its process sent the host ${message.text.length.toLocaleString('en-US')} characters of output in one frame, past the ${MAX_BATCH_CHARS.toLocaleString('en-US')} it sends at most`);
|
|
541
|
+
return;
|
|
542
|
+
}
|
|
543
|
+
this.output.apply(message.text);
|
|
544
|
+
return;
|
|
545
|
+
case 'call':
|
|
546
|
+
void this.call(message.id, message.name, message.args);
|
|
547
|
+
return;
|
|
548
|
+
case 'seen': {
|
|
549
|
+
// The script has the answer the parent sent, or got ResultTooLarge in
|
|
550
|
+
// its place: the call's id and its place are free. A seen for a call
|
|
551
|
+
// whose answer the parent hasn't written isn't following the protocol.
|
|
552
|
+
if (!this.answeredIds.delete(message.id)) {
|
|
553
|
+
this.crash(`its process said the script had the answer to call ${message.id}, which the host never sent`);
|
|
554
|
+
return;
|
|
555
|
+
}
|
|
556
|
+
this.pendingIds.delete(message.id);
|
|
557
|
+
this.inFlight.delete(message.id);
|
|
558
|
+
const sent = this.delivered.get(message.id);
|
|
559
|
+
if (!sent) return;
|
|
560
|
+
this.delivered.delete(message.id);
|
|
561
|
+
// A refusal stays one though the script couldn't receive it: nothing was done either way.
|
|
562
|
+
this.log.answered(sent.entry, message.unfit && sent.outcome !== 'refused' ? 'too-large' : sent.outcome, sent.brief);
|
|
563
|
+
return;
|
|
564
|
+
}
|
|
565
|
+
case 'end': {
|
|
566
|
+
const { t: _t, ...said } = message;
|
|
567
|
+
// The child cuts the value and a failure's parts as a return shows them.
|
|
568
|
+
// The parent bounds them too, since an ended run is kept for a later
|
|
569
|
+
// wait, with room for the child's cut and its marker, so an honest
|
|
570
|
+
// child's cut, and the count it gives, pass as they are.
|
|
571
|
+
if (said.how === 'finished') {
|
|
572
|
+
this.end(said.value === undefined ? { how: 'finished' } : { how: 'finished', value: bounded(said.value, VALUE_CHARS + CUT_MARK_CHARS) });
|
|
573
|
+
return;
|
|
574
|
+
}
|
|
575
|
+
if (said.how === 'failed') {
|
|
576
|
+
const error = {
|
|
577
|
+
name: bounded(said.error.name, NAME_CHARS + CUT_MARK_CHARS),
|
|
578
|
+
message: bounded(said.error.message, VALUE_CHARS + CUT_MARK_CHARS),
|
|
579
|
+
...(said.error.stack === undefined ? {} : { stack: bounded(said.error.stack, VALUE_CHARS + CUT_MARK_CHARS) }),
|
|
580
|
+
};
|
|
581
|
+
this.end({ how: 'failed', error, ...(said.stalled ? { stalled: true } : {}) });
|
|
582
|
+
return;
|
|
583
|
+
}
|
|
584
|
+
if (!this.stopAsked) {
|
|
585
|
+
this.crash("its process sent the host a message it couldn't read (the script said it was stopped, and no stop was asked)");
|
|
586
|
+
return;
|
|
587
|
+
}
|
|
588
|
+
this.end({ how: 'stopped', reason: this.stopAsked.reason, ...(this.stopAsked.why ? { why: this.stopAsked.why } : {}) });
|
|
589
|
+
return;
|
|
590
|
+
}
|
|
591
|
+
}
|
|
592
|
+
}
|
|
593
|
+
|
|
594
|
+
private async call(id: number, name: string, args: string): Promise<void> {
|
|
595
|
+
const wake = name === WAKE_CALL_NAME;
|
|
596
|
+
// The prelude offers only the script's tools, so a call to another name
|
|
597
|
+
// isn't following it. The name is the child's, bounded by the protocol.
|
|
598
|
+
if (!wake && !this.toolNames.has(name)) {
|
|
599
|
+
this.crash(`its process sent the host a call to ${JSON.stringify(name)}, which isn't one of the script's tools`);
|
|
600
|
+
return;
|
|
601
|
+
}
|
|
602
|
+
if (this.pendingIds.has(id)) {
|
|
603
|
+
this.crash(`its process sent the host a message it couldn't read (call ${id} was named twice)`);
|
|
604
|
+
return;
|
|
605
|
+
}
|
|
606
|
+
this.pendingIds.add(id);
|
|
607
|
+
const tool = wake ? 'wake_agent' : name;
|
|
608
|
+
// Refused here, before anything is sent: the record counts it, and nothing was done.
|
|
609
|
+
const refuse = (refusal: { name: string; message: string }): void => {
|
|
610
|
+
this.delivered.set(id, { entry: this.log.start(tool, args), outcome: 'refused', brief: callBrief(refusal.message) });
|
|
611
|
+
this.answer(id, false, JSON.stringify(refusal));
|
|
612
|
+
};
|
|
613
|
+
let n = 0;
|
|
614
|
+
let wakeSent = (): void => {};
|
|
615
|
+
if (wake) {
|
|
616
|
+
// The child refuses a wake past the cap itself, and counts every wake it
|
|
617
|
+
// sends, one refused here included, so a child that sends more isn't
|
|
618
|
+
// following it. Counted before a stop's refusal, so wakes sent after a
|
|
619
|
+
// stop are bounded too.
|
|
620
|
+
if (this.wakeFrames >= this.options.maxWakes) {
|
|
621
|
+
this.crash(`its process sent the host more than ${this.options.maxWakes} wakes, the most a script may send`);
|
|
622
|
+
return;
|
|
623
|
+
}
|
|
624
|
+
// The child refuses a payload past its cap before a frame is built.
|
|
625
|
+
const tooLarge = wakeRefusal(args);
|
|
626
|
+
if (tooLarge) {
|
|
627
|
+
this.crash(`its process sent the host a wake of ${tooLarge.bytes.toLocaleString('en-US')} bytes, past the ${capText(MAX_WAKE_PAYLOAD_BYTES)} the script refuses itself`);
|
|
628
|
+
return;
|
|
629
|
+
}
|
|
630
|
+
this.wakeFrames += 1;
|
|
631
|
+
const stopping = this.stopRefusal('WakeRefused', tool);
|
|
632
|
+
if (stopping) {
|
|
633
|
+
refuse(stopping);
|
|
634
|
+
return;
|
|
635
|
+
}
|
|
636
|
+
// An early wake waits for its turn rather than being dropped: once the
|
|
637
|
+
// one before it has gone out, and the spacing since that one's delivery
|
|
638
|
+
// has passed. The run's end stops both waits.
|
|
639
|
+
const previous = this.wakeChain;
|
|
640
|
+
this.wakeChain = new Promise<void>((resolve) => (wakeSent = resolve));
|
|
641
|
+
this.wakesWaiting += 1;
|
|
642
|
+
await Promise.race([previous, this.endedSignal]);
|
|
643
|
+
await this.wakeTurn();
|
|
644
|
+
// A wake still waiting for its turn when the script ended, or once a
|
|
645
|
+
// stop was asked or its process exited, is never sent: the ending is the
|
|
646
|
+
// script's last word, and the endings that should wake its agent already
|
|
647
|
+
// do. The record counts it as never sent: the run's end counts those
|
|
648
|
+
// still waiting when it comes, and one dropped before it is counted here.
|
|
649
|
+
// (A call waiting in the script for a place is likewise never sent when a
|
|
650
|
+
// stop or a crash ends it; one whose script returns goes out, since the
|
|
651
|
+
// child waits for every call it made.)
|
|
652
|
+
if (this.ending) {
|
|
653
|
+
wakeSent();
|
|
654
|
+
return;
|
|
655
|
+
}
|
|
656
|
+
this.wakesWaiting -= 1;
|
|
657
|
+
if (this.stopAsked || this.processExited) {
|
|
658
|
+
wakeSent();
|
|
659
|
+
this.log.unsent();
|
|
660
|
+
return;
|
|
661
|
+
}
|
|
662
|
+
this.wakes += 1;
|
|
663
|
+
n = this.wakes;
|
|
664
|
+
} else {
|
|
665
|
+
// The prelude sends at most MAX_CALLS_IN_FLIGHT at once and keeps the
|
|
666
|
+
// rest waiting in the script, so a child that sends more isn't following it.
|
|
667
|
+
if (this.inFlight.size >= MAX_CALLS_IN_FLIGHT) {
|
|
668
|
+
this.crash(`its process sent the host more than ${MAX_CALLS_IN_FLIGHT} calls at once, the most a script may have out`);
|
|
669
|
+
return;
|
|
670
|
+
}
|
|
671
|
+
this.inFlight.add(id);
|
|
672
|
+
// The child refuses arguments past their caps before a frame is built.
|
|
673
|
+
const tooLarge = argumentsRefusal(name, args);
|
|
674
|
+
if (tooLarge) {
|
|
675
|
+
this.crash(
|
|
676
|
+
`its process sent the host a call to ${name} whose arguments pass their caps of ${capText(MAX_ARGUMENT_BYTES)} and ` +
|
|
677
|
+
`${MAX_ARGUMENT_VALUES.toLocaleString('en-US')} values, which the script refuses itself`,
|
|
678
|
+
);
|
|
679
|
+
return;
|
|
680
|
+
}
|
|
681
|
+
const refusal = this.stopRefusal('ToolRefused', tool);
|
|
682
|
+
if (refusal) {
|
|
683
|
+
refuse(refusal);
|
|
684
|
+
return;
|
|
685
|
+
}
|
|
686
|
+
}
|
|
687
|
+
// The guest has its answer: a result, or a TimeoutError in its place.
|
|
688
|
+
let settled = false;
|
|
689
|
+
const settle = (ok: boolean, json: string): void => {
|
|
690
|
+
settled = true;
|
|
691
|
+
this.answer(id, ok, json);
|
|
692
|
+
};
|
|
693
|
+
let entry: CallEntry | null = null;
|
|
694
|
+
// A call's time runs from when the script made it, a wait for room in the host included.
|
|
695
|
+
const timer = wake ? null : setTimeout(() => {
|
|
696
|
+
if (timer) this.callTimers.delete(timer);
|
|
697
|
+
if (settled || this.ending) return;
|
|
698
|
+
const waiting = this.dispatchWaiting.get(id);
|
|
699
|
+
if (waiting) {
|
|
700
|
+
// It never went out: no place in the host came free for it in time.
|
|
701
|
+
this.dispatchWaiting.delete(id);
|
|
702
|
+
waiting(false);
|
|
703
|
+
this.log.unsent();
|
|
704
|
+
settle(false, JSON.stringify({
|
|
705
|
+
name: 'TimeoutError',
|
|
706
|
+
message: `${name} was never sent: ${MAX_CALLS_IN_FLIGHT} of this script's earlier calls were still running in the host, timed-out ones ` +
|
|
707
|
+
`included, and its turn didn't come within ${formatMs(this.options.toolCallTimeoutMs)}, so nothing was done; calling it again may go through once one of them gets its result, ` +
|
|
708
|
+
'though a call whose tool the host stopped waiting for stays counted until this script ends, and a new script starts with none',
|
|
709
|
+
}));
|
|
710
|
+
return;
|
|
711
|
+
}
|
|
712
|
+
if (!entry) return;
|
|
713
|
+
this.log.timedOut(entry);
|
|
714
|
+
settle(false, JSON.stringify({
|
|
715
|
+
name: 'TimeoutError',
|
|
716
|
+
message: `${name} was sent, and no answer came within ${formatMs(this.options.toolCallTimeoutMs)}; it may still take effect`,
|
|
717
|
+
}));
|
|
718
|
+
}, this.options.toolCallTimeoutMs);
|
|
719
|
+
if (timer) this.callTimers.add(timer);
|
|
720
|
+
const clearTimer = (): void => {
|
|
721
|
+
if (!timer) return;
|
|
722
|
+
clearTimeout(timer);
|
|
723
|
+
this.callTimers.delete(timer);
|
|
724
|
+
};
|
|
725
|
+
if (!wake) {
|
|
726
|
+
// Past the calls the host holds, it waits as its text, and its place is
|
|
727
|
+
// taken for it when its turn comes (releasePlace).
|
|
728
|
+
if (this.hostCalls < MAX_CALLS_IN_FLIGHT) this.hostCalls += 1;
|
|
729
|
+
else if (!(await new Promise<boolean>((resolve) => this.dispatchWaiting.set(id, resolve)))) return;
|
|
730
|
+
// Once the run has ended or its process has exited, nothing it asks goes
|
|
731
|
+
// out: a frame the child wrote before it exited can be read after the
|
|
732
|
+
// exit, and a call waiting for room can be given its place after it. The
|
|
733
|
+
// record counts such a call as never sent: the script made it, and
|
|
734
|
+
// nothing was done. A stop asked while it waited refuses it, as it would
|
|
735
|
+
// have refused it then.
|
|
736
|
+
if (this.ending || this.processExited) {
|
|
737
|
+
clearTimer();
|
|
738
|
+
this.releasePlace();
|
|
739
|
+
this.log.unsent();
|
|
740
|
+
return;
|
|
741
|
+
}
|
|
742
|
+
const stopping = this.stopRefusal('ToolRefused', tool);
|
|
743
|
+
if (stopping) {
|
|
744
|
+
clearTimer();
|
|
745
|
+
this.releasePlace();
|
|
746
|
+
refuse(stopping);
|
|
747
|
+
return;
|
|
748
|
+
}
|
|
749
|
+
}
|
|
750
|
+
entry = this.log.start(tool, args);
|
|
751
|
+
let answer: ToolAnswer;
|
|
752
|
+
try {
|
|
753
|
+
answer = wake ? await this.options.onWake(args, n) : await this.options.onToolCall(name, args);
|
|
754
|
+
} catch (err) {
|
|
755
|
+
// The bridge never rejects. One that does is answered as a failure,
|
|
756
|
+
// which may have taken effect, never as an unhandled rejection.
|
|
757
|
+
const text = `${tool} failed in the host: ${err instanceof Error ? err.message : String(err)}; it may have taken effect`;
|
|
758
|
+
answer = { ok: true, isError: true, brief: text, json: JSON.stringify({ content: [{ type: 'text', text }], structuredContent: null, isError: true }) };
|
|
759
|
+
}
|
|
760
|
+
if (wake) {
|
|
761
|
+
this.nextWakeAt = performance.now() + this.options.wakeMinIntervalMs;
|
|
762
|
+
if (answer.ok && !answer.isError) this.delivered_wakes += 1;
|
|
763
|
+
wakeSent();
|
|
764
|
+
} else if (!answer.held) this.releasePlace();
|
|
765
|
+
// No result came, and the bridge answered for it, after the script's own
|
|
766
|
+
// timeout (the bridge waits longer): the call stays as the record had it
|
|
767
|
+
// (timed out, or uncertain at an ending), and its place stays taken. Had
|
|
768
|
+
// the script's timeout not come yet, its timer would still give it one.
|
|
769
|
+
if (answer.held) return;
|
|
770
|
+
clearTimer();
|
|
771
|
+
const bytes = Buffer.byteLength(answer.json, 'utf8');
|
|
772
|
+
const limit = maxResultBytes(this.options.memoryMaxBytes);
|
|
773
|
+
const tooLarge = bytes > limit;
|
|
774
|
+
// A refusal stays one however long its text: nothing was done.
|
|
775
|
+
const outcome: CallOutcome = !answer.ok ? 'refused' : tooLarge ? 'too-large' : answer.isError ? 'failed' : 'answered';
|
|
776
|
+
// Only its start is kept, as a copy: the record names a call by that much.
|
|
777
|
+
const brief = callBrief(answer.brief ?? answer.json);
|
|
778
|
+
if (settled || this.ending) {
|
|
779
|
+
// The script stopped waiting for it, or ended: the record takes the answer as it comes.
|
|
780
|
+
this.log.answered(entry, outcome, brief);
|
|
781
|
+
return;
|
|
782
|
+
}
|
|
783
|
+
this.delivered.set(id, { entry, outcome, brief });
|
|
784
|
+
if (tooLarge) {
|
|
785
|
+
settle(false, JSON.stringify(outcome === 'refused'
|
|
786
|
+
? {
|
|
787
|
+
name: wake ? 'WakeRefused' : 'ToolRefused',
|
|
788
|
+
message: `${glimpse(answer.brief ?? refusalText(answer.json), REFUSAL_GLIMPSE_CHARS)} (the refusal's text, ${bytes} bytes, is too large for this script's memory, so only its start is here; nothing was done)`,
|
|
789
|
+
}
|
|
790
|
+
: {
|
|
791
|
+
name: RESULT_TOO_LARGE,
|
|
792
|
+
message: `${name}'s result, ${bytes} bytes, is too large for this script's memory; the call took effect, and none of its result reached the script`,
|
|
793
|
+
bytes,
|
|
794
|
+
}));
|
|
795
|
+
return;
|
|
796
|
+
}
|
|
797
|
+
settle(answer.ok, answer.json);
|
|
798
|
+
}
|
|
799
|
+
|
|
800
|
+
/**
|
|
801
|
+
* A call no longer holds a place in the host: the bridge answered it, or it
|
|
802
|
+
* won't be sent. The first call waiting for room takes the place, in the
|
|
803
|
+
* same step, so no later call can take it first. Whatever lets a waiting
|
|
804
|
+
* call go takes it off the queue. One given its place once the run has
|
|
805
|
+
* ended or its process has exited isn't sent (the call's own check), and
|
|
806
|
+
* the end lets the calls still waiting go.
|
|
807
|
+
*/
|
|
808
|
+
private releasePlace(): void {
|
|
809
|
+
this.hostCalls -= 1;
|
|
810
|
+
const next = this.dispatchWaiting.entries().next();
|
|
811
|
+
if (next.done) return;
|
|
812
|
+
const [nextId, go] = next.value;
|
|
813
|
+
this.dispatchWaiting.delete(nextId);
|
|
814
|
+
this.hostCalls += 1;
|
|
815
|
+
go(true);
|
|
816
|
+
}
|
|
817
|
+
|
|
818
|
+
/** An answer to the call the child named `id`, sent once its turn comes. The id stays reserved until the child says the script has it. */
|
|
819
|
+
private answer(id: number, ok: boolean, json: string): void {
|
|
820
|
+
this.sendResult(id, ok, json);
|
|
821
|
+
}
|
|
822
|
+
|
|
823
|
+
/** A call or wake that comes once a stop has been asked isn't sent: the script is ending. */
|
|
824
|
+
private stopRefusal(name: 'ToolRefused' | 'WakeRefused', tool: string): { name: string; message: string } | null {
|
|
825
|
+
return this.stopAsked ? { name, message: `${tool}: the script is being stopped, so nothing was sent` } : null;
|
|
826
|
+
}
|
|
827
|
+
|
|
828
|
+
/** Resolves once the spacing since the last wake has passed, or at once when the run has ended. */
|
|
829
|
+
private wakeTurn(): Promise<void> {
|
|
830
|
+
const wait = this.nextWakeAt - performance.now();
|
|
831
|
+
if (wait <= 0 || this.ending) return Promise.resolve();
|
|
832
|
+
return new Promise<void>((resolve) => {
|
|
833
|
+
const release = (): void => {
|
|
834
|
+
clearTimeout(timer);
|
|
835
|
+
this.wakeWaiters.delete(release);
|
|
836
|
+
resolve();
|
|
837
|
+
};
|
|
838
|
+
const timer = setTimeout(release, wait);
|
|
839
|
+
this.wakeWaiters.add(release);
|
|
840
|
+
});
|
|
841
|
+
}
|
|
842
|
+
|
|
843
|
+
/**
|
|
844
|
+
* Once the process has exited and the run has ended, nothing more goes to
|
|
845
|
+
* the child: an ended run kept for a later wait holds none of it. Whichever
|
|
846
|
+
* of the two comes second calls this.
|
|
847
|
+
*/
|
|
848
|
+
private release(): void {
|
|
849
|
+
this.child?.stdin?.destroy();
|
|
850
|
+
this.child = null;
|
|
851
|
+
}
|
|
852
|
+
|
|
853
|
+
private crash(detail: string): void {
|
|
854
|
+
if (this.ending) return;
|
|
855
|
+
this.end({ how: 'crashed', detail });
|
|
856
|
+
this.child?.kill('SIGKILL');
|
|
857
|
+
}
|
|
858
|
+
|
|
859
|
+
private end(ending: Ending): void {
|
|
860
|
+
if (this.ending) return;
|
|
861
|
+
// Results sent and not yet seen were answered, and the script ended
|
|
862
|
+
// before it read them: each is named, a refusal counted. Calls still
|
|
863
|
+
// running may or may not take effect. A stop or a crash can leave either,
|
|
864
|
+
// whatever the ending: a script that had returned or thrown when a stop
|
|
865
|
+
// came ends as it did, with the calls its stop cut the wait for. So can a
|
|
866
|
+
// failure that escaped the script's promises, which ends the run at once,
|
|
867
|
+
// and a result the engine dropped as a stop came, which gets no seen. A
|
|
868
|
+
// TimeoutError the engine dropped so leaves its call named timed-out, as
|
|
869
|
+
// the host's timer had it, with no answer to name. Otherwise the child
|
|
870
|
+
// reports a finish or a failure only once every call it made is settled
|
|
871
|
+
// and seen. The host's count of the calls the bridge holds stays as it
|
|
872
|
+
// is, since those tools may still run: each frees its place as it
|
|
873
|
+
// answers, and one the bridge answered for with no result (held) never
|
|
874
|
+
// does.
|
|
875
|
+
for (const sent of this.delivered.values()) this.log.unseen(sent.entry, sent.outcome);
|
|
876
|
+
this.delivered.clear();
|
|
877
|
+
this.pendingIds.clear();
|
|
878
|
+
this.answeredIds.clear();
|
|
879
|
+
this.inFlight.clear();
|
|
880
|
+
this.log.ended();
|
|
881
|
+
this.ending = ending;
|
|
882
|
+
if (this.lifetimeTimer) clearTimeout(this.lifetimeTimer);
|
|
883
|
+
// A child exits on its own once it has said how its script ended. One that
|
|
884
|
+
// doesn't, such as one stuck in its cleanup, is ended after the stop's
|
|
885
|
+
// grace, so it can't hold its place or outlive its run.
|
|
886
|
+
if (this.child && !this.processExited && !this.killTimer) {
|
|
887
|
+
this.killTimer = setTimeout(() => this.child?.kill('SIGKILL'), this.options.stopGraceMs);
|
|
888
|
+
this.killTimer.unref?.();
|
|
889
|
+
}
|
|
890
|
+
for (const timer of this.callTimers) clearTimeout(timer);
|
|
891
|
+
this.callTimers.clear();
|
|
892
|
+
// Nothing more goes to the child: waiting results are dropped, and calls
|
|
893
|
+
// waiting for a place go nowhere, so an ended run that's kept for a later
|
|
894
|
+
// wait holds neither.
|
|
895
|
+
this.resultQueue.length = 0;
|
|
896
|
+
for (const release of [...this.wakeWaiters]) release();
|
|
897
|
+
this.endedResolve();
|
|
898
|
+
// Calls waiting for room in the host are never sent, and the record counts
|
|
899
|
+
// them so: the script made them, and nothing was done.
|
|
900
|
+
for (const [waitingId, go] of [...this.dispatchWaiting]) {
|
|
901
|
+
this.dispatchWaiting.delete(waitingId);
|
|
902
|
+
this.log.unsent();
|
|
903
|
+
go(false);
|
|
904
|
+
}
|
|
905
|
+
// So are wakes waiting for their turn: each sees the ending and goes.
|
|
906
|
+
for (; this.wakesWaiting > 0; this.wakesWaiting -= 1) this.log.unsent();
|
|
907
|
+
for (const waiter of [...this.waiters]) waiter();
|
|
908
|
+
if (this.processExited) this.release();
|
|
909
|
+
this.options.onEnd?.(ending);
|
|
910
|
+
}
|
|
911
|
+
}
|