rocket-engine 0.0.2b1__py3-none-any.whl
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.
- rocket_engine/__init__.py +8 -0
- rocket_engine/__main__.py +12 -0
- rocket_engine/adapters/README.md +54 -0
- rocket_engine/adapters/__init__.py +7 -0
- rocket_engine/adapters/api.py +39 -0
- rocket_engine/adapters/base.py +46 -0
- rocket_engine/adapters/cace/__init__.py +163 -0
- rocket_engine/adapters/circulax/README.md +10 -0
- rocket_engine/adapters/circulax/__init__.py +112 -0
- rocket_engine/adapters/circulax/_driver.py +190 -0
- rocket_engine/adapters/circulax/adapter.py +538 -0
- rocket_engine/adapters/circulax/results.py +291 -0
- rocket_engine/adapters/cli/README.md +34 -0
- rocket_engine/adapters/cli/__init__.py +305 -0
- rocket_engine/adapters/connectivity/__init__.py +196 -0
- rocket_engine/adapters/connectivity/results.py +214 -0
- rocket_engine/adapters/cosim/README.md +9 -0
- rocket_engine/adapters/cosim/__init__.py +1390 -0
- rocket_engine/adapters/cosim/results.py +214 -0
- rocket_engine/adapters/devsim/__init__.py +171 -0
- rocket_engine/adapters/digital_flow/README.md +9 -0
- rocket_engine/adapters/digital_flow/__init__.py +1121 -0
- rocket_engine/adapters/digital_flow/results.py +227 -0
- rocket_engine/adapters/digital_flow/state.py +462 -0
- rocket_engine/adapters/drc/README.klayout.md +7 -0
- rocket_engine/adapters/drc/README.md +7 -0
- rocket_engine/adapters/drc/__init__.py +1 -0
- rocket_engine/adapters/drc/klayout.py +952 -0
- rocket_engine/adapters/drc/magic.py +287 -0
- rocket_engine/adapters/drc/results.py +184 -0
- rocket_engine/adapters/drc/results_kl.py +188 -0
- rocket_engine/adapters/elmer/__init__.py +163 -0
- rocket_engine/adapters/envelope.py +120 -0
- rocket_engine/adapters/equivalence/__init__.py +156 -0
- rocket_engine/adapters/errors.py +181 -0
- rocket_engine/adapters/extract_magic/README.md +15 -0
- rocket_engine/adapters/extract_magic/__init__.py +425 -0
- rocket_engine/adapters/extract_magic/results.py +209 -0
- rocket_engine/adapters/formal_sby/README.md +7 -0
- rocket_engine/adapters/formal_sby/__init__.py +773 -0
- rocket_engine/adapters/formal_sby/formal_edge.py +195 -0
- rocket_engine/adapters/formal_sby/results.py +185 -0
- rocket_engine/adapters/gdsfactory/__init__.py +194 -0
- rocket_engine/adapters/gdsfactory/_driver.py +459 -0
- rocket_engine/adapters/gdsfactory/adapter.py +609 -0
- rocket_engine/adapters/gdsfactory/results.py +181 -0
- rocket_engine/adapters/hdl_sim/README.md +7 -0
- rocket_engine/adapters/hdl_sim/__init__.py +369 -0
- rocket_engine/adapters/hdl_sim/ghdl.py +155 -0
- rocket_engine/adapters/hdl_sim/results.py +178 -0
- rocket_engine/adapters/hdl_sim/verilator.py +167 -0
- rocket_engine/adapters/http/README.md +15 -0
- rocket_engine/adapters/http/__init__.py +458 -0
- rocket_engine/adapters/http/events.py +63 -0
- rocket_engine/adapters/ipc/README.md +9 -0
- rocket_engine/adapters/ipc/__init__.py +111 -0
- rocket_engine/adapters/lvs_netgen/README.md +20 -0
- rocket_engine/adapters/lvs_netgen/__init__.py +889 -0
- rocket_engine/adapters/lvs_netgen/results.py +168 -0
- rocket_engine/adapters/mcp/README.md +19 -0
- rocket_engine/adapters/mcp/__init__.py +85 -0
- rocket_engine/adapters/mcp/api-schema.json +6107 -0
- rocket_engine/adapters/mcp/pull.py +246 -0
- rocket_engine/adapters/mcp/server.py +794 -0
- rocket_engine/adapters/meep/__init__.py +167 -0
- rocket_engine/adapters/meow/README.md +7 -0
- rocket_engine/adapters/meow/__init__.py +190 -0
- rocket_engine/adapters/meow/_driver.py +227 -0
- rocket_engine/adapters/meow/_envelope.py +108 -0
- rocket_engine/adapters/meow/adapter.py +491 -0
- rocket_engine/adapters/meow/results.py +218 -0
- rocket_engine/adapters/mutation/__init__.py +192 -0
- rocket_engine/adapters/ngspice/README.md +28 -0
- rocket_engine/adapters/ngspice/__init__.py +627 -0
- rocket_engine/adapters/ngspice/results.py +215 -0
- rocket_engine/adapters/postlayout/README.md +28 -0
- rocket_engine/adapters/postlayout/__init__.py +17 -0
- rocket_engine/adapters/postlayout/adapter.py +1062 -0
- rocket_engine/adapters/postlayout/results.py +330 -0
- rocket_engine/adapters/postlayout/translate.py +71 -0
- rocket_engine/adapters/profile.py +220 -0
- rocket_engine/adapters/sax/README.md +23 -0
- rocket_engine/adapters/sax/__init__.py +516 -0
- rocket_engine/adapters/sax/_driver.py +148 -0
- rocket_engine/adapters/sax/results.py +291 -0
- rocket_engine/adapters/stdio_console.py +1151 -0
- rocket_engine/adapters/syntax/README.md +5 -0
- rocket_engine/adapters/syntax/__init__.py +366 -0
- rocket_engine/adapters/syntax/results.py +181 -0
- rocket_engine/adapters/synth_yosys/README.md +7 -0
- rocket_engine/adapters/synth_yosys/__init__.py +655 -0
- rocket_engine/adapters/synth_yosys/amaranth.py +120 -0
- rocket_engine/adapters/synth_yosys/ghdl.py +128 -0
- rocket_engine/adapters/synth_yosys/results.py +173 -0
- rocket_engine/adapters/timing_sta/README.md +9 -0
- rocket_engine/adapters/timing_sta/__init__.py +726 -0
- rocket_engine/adapters/view_sync/__init__.py +185 -0
- rocket_engine/adapters/view_sync/results.py +189 -0
- rocket_engine/adapters/vlsir/__init__.py +142 -0
- rocket_engine/adapters/xschem/__init__.py +440 -0
- rocket_engine/adapters/xschem/deck.py +266 -0
- rocket_engine/clients/__init__.py +5 -0
- rocket_engine/clients/runtime.py +255 -0
- rocket_engine/composition.py +2279 -0
- rocket_engine/execution/README.md +30 -0
- rocket_engine/execution/__init__.py +150 -0
- rocket_engine/execution/apig/__init__.py +1 -0
- rocket_engine/execution/apig/pool.py +463 -0
- rocket_engine/execution/apig/submit.py +124 -0
- rocket_engine/execution/block_runs_scope.py +479 -0
- rocket_engine/execution/coalesce.py +414 -0
- rocket_engine/execution/console/__init__.py +1 -0
- rocket_engine/execution/console/apply_session.py +318 -0
- rocket_engine/execution/console/lifecycle.py +265 -0
- rocket_engine/execution/dispatch.py +205 -0
- rocket_engine/execution/errors.py +31 -0
- rocket_engine/execution/ports.py +797 -0
- rocket_engine/execution/pyexec/__init__.py +1 -0
- rocket_engine/execution/pyexec/apply_context.py +547 -0
- rocket_engine/execution/pyexec/approvals.py +173 -0
- rocket_engine/execution/pyexec/runner.py +404 -0
- rocket_engine/execution/pyexec/support.py +464 -0
- rocket_engine/execution/python_exec.py +897 -0
- rocket_engine/execution/queue.py +85 -0
- rocket_engine/execution/replay_safe.py +601 -0
- rocket_engine/execution/routing/__init__.py +1 -0
- rocket_engine/execution/routing/calls.py +364 -0
- rocket_engine/execution/routing/namespace.py +785 -0
- rocket_engine/execution/routing/ports.py +102 -0
- rocket_engine/execution/routing/tools.py +346 -0
- rocket_engine/execution/runner.py +311 -0
- rocket_engine/execution/runtime.py +1149 -0
- rocket_engine/execution/service.py +279 -0
- rocket_engine/execution/worker.py +464 -0
- rocket_engine/interfaces/README.md +26 -0
- rocket_engine/interfaces/__init__.py +58 -0
- rocket_engine/interfaces/apig_assets.py +407 -0
- rocket_engine/interfaces/celllink.py +461 -0
- rocket_engine/interfaces/documents.py +92 -0
- rocket_engine/interfaces/envelopes.py +131 -0
- rocket_engine/interfaces/errors.py +16 -0
- rocket_engine/interfaces/layout.py +410 -0
- rocket_engine/interfaces/layout_schema.yaml +278 -0
- rocket_engine/interfaces/paths.py +223 -0
- rocket_engine/interfaces/pdks.py +550 -0
- rocket_engine/interfaces/ports.py +72 -0
- rocket_engine/interfaces/references.py +115 -0
- rocket_engine/interfaces/refs.py +85 -0
- rocket_engine/interfaces/runparams.py +304 -0
- rocket_engine/interfaces/setup.py +552 -0
- rocket_engine/interfaces/setup_catalogue.yaml +243 -0
- rocket_engine/interfaces/setup_probes.py +66 -0
- rocket_engine/interfaces/vocabulary.py +58 -0
- rocket_engine/interfaces/yaml_dump.py +31 -0
- rocket_engine/library/README.md +22 -0
- rocket_engine/library/__init__.py +13 -0
- rocket_engine/library/apig_catalog.py +371 -0
- rocket_engine/library/catalog.py +185 -0
- rocket_engine/library/errors.py +16 -0
- rocket_engine/mutate/__init__.py +776 -0
- rocket_engine/mutate/operators.py +700 -0
- rocket_engine/ops/__init__.py +1 -0
- rocket_engine/ops/deferred.py +279 -0
- rocket_engine/ops/editors.py +404 -0
- rocket_engine/ops/evidence.py +194 -0
- rocket_engine/ops/gates.py +262 -0
- rocket_engine/ops/ports.py +76 -0
- rocket_engine/ops/queries.py +417 -0
- rocket_engine/ops/runs.py +553 -0
- rocket_engine/ops/submit.py +614 -0
- rocket_engine/project/README.md +27 -0
- rocket_engine/project/__init__.py +6 -0
- rocket_engine/project/canvas/__init__.py +55 -0
- rocket_engine/project/canvas/compile.py +109 -0
- rocket_engine/project/canvas/integrate.py +48 -0
- rocket_engine/project/canvas/schema.py +241 -0
- rocket_engine/project/canvas/templates.py +46 -0
- rocket_engine/project/canvas/validate.py +603 -0
- rocket_engine/project/edit/__init__.py +5 -0
- rocket_engine/project/edit/cas.py +87 -0
- rocket_engine/project/edit/transactions.py +282 -0
- rocket_engine/project/errors.py +16 -0
- rocket_engine/project/frontend13.py +685 -0
- rocket_engine/project/geometry/__init__.py +23 -0
- rocket_engine/project/geometry/ops.py +451 -0
- rocket_engine/project/geometry/snap.py +103 -0
- rocket_engine/project/ip/__init__.py +1 -0
- rocket_engine/project/ip/blocks.py +195 -0
- rocket_engine/project/ip/entry.schema.json +78 -0
- rocket_engine/project/ip/lifecycle.py +402 -0
- rocket_engine/project/ip/verbs.py +123 -0
- rocket_engine/project/layers/__init__.py +6 -0
- rocket_engine/project/layers/bindings.py +115 -0
- rocket_engine/project/layers/catalog.py +228 -0
- rocket_engine/project/layers/presets.py +43 -0
- rocket_engine/project/ledger/__init__.py +1 -0
- rocket_engine/project/ledger/docs.py +167 -0
- rocket_engine/project/ledger/entities.py +176 -0
- rocket_engine/project/ledger/events.py +29 -0
- rocket_engine/project/ledger/runs.py +257 -0
- rocket_engine/project/ledger/shared.py +174 -0
- rocket_engine/project/ledger/snapshots.py +385 -0
- rocket_engine/project/ledger/wires.py +552 -0
- rocket_engine/project/process/__init__.py +14 -0
- rocket_engine/project/process/model.py +193 -0
- rocket_engine/project/process/recipes.py +121 -0
- rocket_engine/project/schematic/__init__.py +1 -0
- rocket_engine/project/schematic/hierarchy.py +312 -0
- rocket_engine/project/schematic/instances.py +393 -0
- rocket_engine/project/schematic/sheets.py +198 -0
- rocket_engine/project/service.py +2941 -0
- rocket_engine/project/symbols/__init__.py +1 -0
- rocket_engine/project/symbols/cells.py +324 -0
- rocket_engine/project/symbols/linkage.py +356 -0
- rocket_engine/project/symbols/symbols.py +352 -0
- rocket_engine/project/templates/__init__.py +303 -0
- rocket_engine/project/templates/errors.py +25 -0
- rocket_engine/project/testbench/__init__.py +49 -0
- rocket_engine/project/testbench/blocks.py +619 -0
- rocket_engine/project/testbench/templates.py +134 -0
- rocket_engine/stores/README.md +12 -0
- rocket_engine/stores/__init__.py +6 -0
- rocket_engine/stores/ecs.py +1508 -0
- rocket_engine/stores/layers.py +1023 -0
- rocket_engine/stores/settings.py +1188 -0
- rocket_engine/stores/wireplace.py +1324 -0
- rocket_engine/workflows/README.md +18 -0
- rocket_engine/workflows/__init__.py +53 -0
- rocket_engine/workflows/capability_plan.py +102 -0
- rocket_engine/workflows/completion.py +228 -0
- rocket_engine/workflows/connectgraph.py +734 -0
- rocket_engine/workflows/connectgraph_emit.py +336 -0
- rocket_engine/workflows/gates.py +144 -0
- rocket_engine/workflows/photonic_wire.py +76 -0
- rocket_engine/workflows/stages.py +324 -0
- rocket_engine/workflows/viewsync.py +399 -0
- rocket_engine-0.0.2b1.dist-info/METADATA +200 -0
- rocket_engine-0.0.2b1.dist-info/RECORD +241 -0
- rocket_engine-0.0.2b1.dist-info/WHEEL +4 -0
- rocket_engine-0.0.2b1.dist-info/entry_points.txt +2 -0
- rocket_engine-0.0.2b1.dist-info/licenses/LICENSE +21 -0
|
@@ -0,0 +1,1149 @@
|
|
|
1
|
+
"""Deferred execution runtime (API-V1-CORE, ASYNC-SUBMIT, PUSH-EVENTS).
|
|
2
|
+
|
|
3
|
+
One EngineRuntime owns the ledger-facing admission state the
|
|
4
|
+
composition root will delegate to: durable request identity,
|
|
5
|
+
revision-pinned coalescing, advisory capacity, and ordered
|
|
6
|
+
lifecycle events. Submit acknowledges promptly with a run id and
|
|
7
|
+
no verdict; the deferred handle watches the run to completion
|
|
8
|
+
and every lifecycle step is observable through watch plus push
|
|
9
|
+
listeners.
|
|
10
|
+
|
|
11
|
+
Engine.submit integration seam (surface lane owns
|
|
12
|
+
composition.py, vocabulary.py, submit.py — this module never
|
|
13
|
+
imports them):
|
|
14
|
+
- Call: route solver operations to
|
|
15
|
+
`EngineRuntime.submit(operation, arguments, request_id)`
|
|
16
|
+
instead of the inline drain, returning the receipt
|
|
17
|
+
(`admitted`, `run_id`, `request_id`, `snapshot_id`,
|
|
18
|
+
`revision`, `event_cursor`) as the prompt acknowledgement.
|
|
19
|
+
- Mapping: merge the terminal completion back with
|
|
20
|
+
`execution.apig.submit.merge_ack_completion(ack, completion)`
|
|
21
|
+
so the ack shape plus verdict keeps the one run id; serve
|
|
22
|
+
`list_survivors`, `resume_run`, `discard_run`, `cancel_job`,
|
|
23
|
+
and `get_events_since` from `boot_report`, `resume`,
|
|
24
|
+
`discard`, `cancel`, and `watch`.
|
|
25
|
+
|
|
26
|
+
Caution: Runs never execute on acknowledgement — pump
|
|
27
|
+
explicitly or construct with auto_dispatch. A disconnect after
|
|
28
|
+
acknowledgement never discards the request: the request/run
|
|
29
|
+
mapping stays queryable until resume or discard decides it.
|
|
30
|
+
"""
|
|
31
|
+
|
|
32
|
+
import shutil
|
|
33
|
+
import threading
|
|
34
|
+
import uuid
|
|
35
|
+
|
|
36
|
+
from .coalesce import Coalescer, content_key
|
|
37
|
+
from .errors import ExecutionError
|
|
38
|
+
from .ports import PortBus
|
|
39
|
+
from .queue import DeferredQueue
|
|
40
|
+
|
|
41
|
+
#: Operations that never coalesce: mutating scripts and editor
|
|
42
|
+
#: transactions execute fresh even with identical content.
|
|
43
|
+
NEVER_COALESCE = frozenset({"python_execute", "write_doc",
|
|
44
|
+
"delete_doc", "apply_mutations",
|
|
45
|
+
"write_settings", "rewind"})
|
|
46
|
+
|
|
47
|
+
#: Lifecycle kinds every run emits, in order (frontend-tooling
|
|
48
|
+
#: spec prompt-ack plus job_queued/started/progress/finished).
|
|
49
|
+
LIFECYCLE_ORDER = ("job_queued", "job_started", "job_progress",
|
|
50
|
+
"job_finished")
|
|
51
|
+
|
|
52
|
+
#: Interruptions past this count quarantine the run key until an
|
|
53
|
+
#: explicit quarantine override resumes it.
|
|
54
|
+
QUARANTINE_AFTER = 2
|
|
55
|
+
|
|
56
|
+
#: Retained event window: cursors at or below the floor fail
|
|
57
|
+
#: with cursor_expired plus the resubscribe path, never gaps.
|
|
58
|
+
RETAINED_EVENTS = 4096
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def probe_host_tool(name, reason):
|
|
62
|
+
"""Probe one host tool without executing anything.
|
|
63
|
+
|
|
64
|
+
Args:
|
|
65
|
+
name: Binary to resolve on PATH.
|
|
66
|
+
reason: BLOCKED-needs-host reason stamped when the
|
|
67
|
+
binary is absent.
|
|
68
|
+
|
|
69
|
+
Returns:
|
|
70
|
+
Probe with available, name, path when found, else the
|
|
71
|
+
reason and a BLOCKED verdict — loud, never a
|
|
72
|
+
faked PASS.
|
|
73
|
+
"""
|
|
74
|
+
path = shutil.which(name)
|
|
75
|
+
if path is not None:
|
|
76
|
+
return {"available": True, "name": name, "path": path}
|
|
77
|
+
return {"available": False, "name": name, "path": None,
|
|
78
|
+
"reason": reason, "verdict": "BLOCKED-needs-host"}
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
class DeferredHandle:
|
|
82
|
+
"""Prompt acknowledgement watching one run to completion.
|
|
83
|
+
|
|
84
|
+
Args:
|
|
85
|
+
runtime: EngineRuntime the run was admitted to.
|
|
86
|
+
run_id: Admitted run id shared by every coalesced caller.
|
|
87
|
+
receipt: Prompt acknowledgement (admitted, run_id,
|
|
88
|
+
request_id, snapshot_id, revision, event_cursor —
|
|
89
|
+
no verdict until the run finishes).
|
|
90
|
+
"""
|
|
91
|
+
|
|
92
|
+
def __init__(self, runtime, run_id, receipt):
|
|
93
|
+
self._runtime = runtime
|
|
94
|
+
self._run_id = run_id
|
|
95
|
+
self._receipt = dict(receipt)
|
|
96
|
+
|
|
97
|
+
@property
|
|
98
|
+
def run_id(self):
|
|
99
|
+
"""Admitted run id for this handle.
|
|
100
|
+
|
|
101
|
+
Returns:
|
|
102
|
+
Run id shared by every coalesced caller.
|
|
103
|
+
"""
|
|
104
|
+
return self._run_id
|
|
105
|
+
|
|
106
|
+
@property
|
|
107
|
+
def receipt(self):
|
|
108
|
+
"""Prompt acknowledgement for this handle.
|
|
109
|
+
|
|
110
|
+
Returns:
|
|
111
|
+
Receipt copy (admitted plus identity fields, never
|
|
112
|
+
a verdict before completion).
|
|
113
|
+
"""
|
|
114
|
+
return dict(self._receipt)
|
|
115
|
+
|
|
116
|
+
def poll(self):
|
|
117
|
+
"""Read the current run state without blocking.
|
|
118
|
+
|
|
119
|
+
Returns:
|
|
120
|
+
Status with run_id, operation, state, and verdict
|
|
121
|
+
once judged.
|
|
122
|
+
"""
|
|
123
|
+
return self._runtime.status(self._run_id)
|
|
124
|
+
|
|
125
|
+
def wait(self, timeout=None):
|
|
126
|
+
"""Block until the run reaches a terminal state.
|
|
127
|
+
|
|
128
|
+
Args:
|
|
129
|
+
timeout: Maximum seconds to wait; waits with the
|
|
130
|
+
run when omitted.
|
|
131
|
+
|
|
132
|
+
Returns:
|
|
133
|
+
Terminal completion for the run.
|
|
134
|
+
|
|
135
|
+
Raises:
|
|
136
|
+
TimeoutError: The wait lapsed before the run
|
|
137
|
+
finished.
|
|
138
|
+
"""
|
|
139
|
+
return self._runtime.wait_for(self._run_id, timeout=timeout)
|
|
140
|
+
|
|
141
|
+
|
|
142
|
+
class EngineRuntime:
|
|
143
|
+
"""Deferred execution runtime: prompt ack, capacity, events.
|
|
144
|
+
|
|
145
|
+
Args:
|
|
146
|
+
project_id: Project this runtime admits runs for.
|
|
147
|
+
runner: Callable `(operation, arguments, snapshot_id,
|
|
148
|
+
request_id, run_id)` returning a completion mapping.
|
|
149
|
+
Runner exceptions become failed completions, never
|
|
150
|
+
raised, and the drain continues.
|
|
151
|
+
max_inflight: Maximum queued plus running runs;
|
|
152
|
+
admissions past it are refused retryable with
|
|
153
|
+
admitted False, never queued, never dropped
|
|
154
|
+
silently.
|
|
155
|
+
auto_dispatch: Dispatch admitted runs on a daemon
|
|
156
|
+
worker without a client round trip; False leaves
|
|
157
|
+
dispatch to explicit pump (deterministic tests).
|
|
158
|
+
schemas: Optional operation to allowed-argument-names
|
|
159
|
+
mapping for C01 request validation; None checks
|
|
160
|
+
shapes only.
|
|
161
|
+
operations: Optional known-operation names for C01
|
|
162
|
+
validation; None accepts any string name and
|
|
163
|
+
leaves catalog validation to the Engine seam.
|
|
164
|
+
"""
|
|
165
|
+
|
|
166
|
+
def __init__(self, project_id, runner, max_inflight=64,
|
|
167
|
+
auto_dispatch=True, schemas=None,
|
|
168
|
+
operations=None):
|
|
169
|
+
self._project_id = project_id
|
|
170
|
+
self._runner = runner
|
|
171
|
+
self._max_inflight = max_inflight
|
|
172
|
+
self._auto_dispatch = auto_dispatch
|
|
173
|
+
self._schemas = dict(schemas) if schemas else {}
|
|
174
|
+
self._operations = (frozenset(operations)
|
|
175
|
+
if operations is not None else None)
|
|
176
|
+
self._lock = threading.RLock()
|
|
177
|
+
self._queue = DeferredQueue(max_depth=max_inflight)
|
|
178
|
+
self._coalescer = Coalescer()
|
|
179
|
+
self._ports = PortBus()
|
|
180
|
+
self._requests = {}
|
|
181
|
+
self._runs = {}
|
|
182
|
+
self._events = []
|
|
183
|
+
self._seq_floor = 0
|
|
184
|
+
self._revision = 0
|
|
185
|
+
self._paused = False
|
|
186
|
+
self._listeners = []
|
|
187
|
+
self._interruptions = {}
|
|
188
|
+
self._quarantined = set()
|
|
189
|
+
self._pumping = False
|
|
190
|
+
|
|
191
|
+
def submit(self, operation, arguments, request_id=None):
|
|
192
|
+
"""Admit one run and acknowledge promptly without verdict.
|
|
193
|
+
|
|
194
|
+
Reused request ids replay their stored receipt without
|
|
195
|
+
re-executing (identical content) or fail with
|
|
196
|
+
request_conflict (changed content). Equivalent
|
|
197
|
+
in-flight observational runs share one execution across
|
|
198
|
+
callers; scripts and edits always execute fresh. Past
|
|
199
|
+
capacity the receipt refuses retryable with admitted
|
|
200
|
+
False and no run is queued.
|
|
201
|
+
|
|
202
|
+
Args:
|
|
203
|
+
operation: Cataloged operation name.
|
|
204
|
+
arguments: Operation arguments mapping.
|
|
205
|
+
request_id: Client envelope id; minted when omitted.
|
|
206
|
+
|
|
207
|
+
Returns:
|
|
208
|
+
DeferredHandle watching the admitted (or shared, or
|
|
209
|
+
refused) run. Refused handles carry admitted
|
|
210
|
+
False, code, retryable, depth, and bound.
|
|
211
|
+
|
|
212
|
+
Raises:
|
|
213
|
+
ExecutionError: invalid_command for a malformed
|
|
214
|
+
call, unsupported_operation for an unknown
|
|
215
|
+
name, request_conflict for a reused envelope id
|
|
216
|
+
with new content.
|
|
217
|
+
"""
|
|
218
|
+
request_id = str(request_id or uuid.uuid4().hex)
|
|
219
|
+
arguments = dict(arguments or {})
|
|
220
|
+
self.validate_request(operation, arguments)
|
|
221
|
+
with self._lock:
|
|
222
|
+
stored = self._requests.get(request_id)
|
|
223
|
+
if stored is not None:
|
|
224
|
+
if (stored["operation"] != operation or
|
|
225
|
+
stored["arguments"] != arguments):
|
|
226
|
+
raise ExecutionError(
|
|
227
|
+
"request_conflict",
|
|
228
|
+
f"Request {request_id} reused with new content.",
|
|
229
|
+
)
|
|
230
|
+
return DeferredHandle(
|
|
231
|
+
self, stored["run_id"], stored["receipt"])
|
|
232
|
+
key = content_key(operation, arguments)
|
|
233
|
+
if operation not in NEVER_COALESCE:
|
|
234
|
+
with self._coalescer.guard:
|
|
235
|
+
hit = self._coalescer.lookup(key,
|
|
236
|
+
self._revision)
|
|
237
|
+
if hit is not None:
|
|
238
|
+
waiter = self._coalescer.attach(
|
|
239
|
+
hit["run_id"], request_id)
|
|
240
|
+
receipt = self._ack(hit["run_id"],
|
|
241
|
+
request_id,
|
|
242
|
+
coalesced=True)
|
|
243
|
+
self._record_request(request_id,
|
|
244
|
+
operation,
|
|
245
|
+
arguments, key,
|
|
246
|
+
hit["run_id"],
|
|
247
|
+
receipt)
|
|
248
|
+
return DeferredHandle(self,
|
|
249
|
+
hit["run_id"],
|
|
250
|
+
receipt)
|
|
251
|
+
if self._inflight_locked() >= self._max_inflight:
|
|
252
|
+
self._paused = True
|
|
253
|
+
self._emit_locked("admission_paused", None,
|
|
254
|
+
{"depth": self._inflight_locked(),
|
|
255
|
+
"bound": self._max_inflight})
|
|
256
|
+
return DeferredHandle(self, "", {
|
|
257
|
+
"admitted": False, "code": "pool_full",
|
|
258
|
+
"retryable": True, "request_id": request_id,
|
|
259
|
+
"depth": self._inflight_locked(),
|
|
260
|
+
"bound": self._max_inflight,
|
|
261
|
+
"message": "Admission saturated: drain before "
|
|
262
|
+
"submitting more.",
|
|
263
|
+
})
|
|
264
|
+
run_id = uuid.uuid4().hex
|
|
265
|
+
snapshot_id = f"snapshot_{self._revision}"
|
|
266
|
+
record = {
|
|
267
|
+
"run_id": run_id, "operation": operation,
|
|
268
|
+
"arguments": dict(arguments),
|
|
269
|
+
"snapshot_id": snapshot_id,
|
|
270
|
+
"revision": self._revision,
|
|
271
|
+
"ingredients": {"revision": self._revision,
|
|
272
|
+
"operation": operation,
|
|
273
|
+
"arguments": dict(arguments)},
|
|
274
|
+
"request_ids": [request_id],
|
|
275
|
+
"state": "queued", "verdict": None,
|
|
276
|
+
"done": threading.Event(),
|
|
277
|
+
}
|
|
278
|
+
self._runs[run_id] = record
|
|
279
|
+
receipt = self._ack(run_id, request_id,
|
|
280
|
+
snapshot_id=snapshot_id)
|
|
281
|
+
self._record_request(request_id, operation,
|
|
282
|
+
arguments, key, run_id,
|
|
283
|
+
receipt)
|
|
284
|
+
if operation not in NEVER_COALESCE:
|
|
285
|
+
with self._coalescer.guard:
|
|
286
|
+
self._coalescer.register(key,
|
|
287
|
+
self._revision,
|
|
288
|
+
request_id, run_id,
|
|
289
|
+
snapshot_id)
|
|
290
|
+
self._queue.put({"run_id": run_id,
|
|
291
|
+
"request_id": request_id,
|
|
292
|
+
"operation": operation,
|
|
293
|
+
"arguments": dict(arguments),
|
|
294
|
+
"snapshot_id": snapshot_id})
|
|
295
|
+
self._emit_locked("job_queued", run_id, {})
|
|
296
|
+
kick = self._auto_dispatch
|
|
297
|
+
if kick:
|
|
298
|
+
self._kick_worker()
|
|
299
|
+
return DeferredHandle(self, run_id, receipt)
|
|
300
|
+
|
|
301
|
+
def validate_request(self, operation, arguments):
|
|
302
|
+
"""Validate one call against the C01 request schema.
|
|
303
|
+
|
|
304
|
+
Args:
|
|
305
|
+
operation: Cataloged operation name.
|
|
306
|
+
arguments: Operation arguments mapping.
|
|
307
|
+
|
|
308
|
+
Returns:
|
|
309
|
+
True when the call is well formed.
|
|
310
|
+
|
|
311
|
+
Raises:
|
|
312
|
+
ExecutionError: invalid_command for a non-mapping
|
|
313
|
+
payload or an unknown semantic field,
|
|
314
|
+
unsupported_operation for an unknown name.
|
|
315
|
+
"""
|
|
316
|
+
if not isinstance(arguments, dict):
|
|
317
|
+
raise ExecutionError(
|
|
318
|
+
"invalid_command",
|
|
319
|
+
f"Arguments must be a mapping, got {arguments!r}.",
|
|
320
|
+
)
|
|
321
|
+
if (self._operations is not None and
|
|
322
|
+
operation not in self._operations):
|
|
323
|
+
raise ExecutionError(
|
|
324
|
+
"unsupported_operation",
|
|
325
|
+
f"Unsupported operation: {operation}.",
|
|
326
|
+
)
|
|
327
|
+
allowed = self._schemas.get(operation)
|
|
328
|
+
if allowed is not None:
|
|
329
|
+
unknown = [key for key in arguments
|
|
330
|
+
if key not in allowed]
|
|
331
|
+
if unknown:
|
|
332
|
+
raise ExecutionError(
|
|
333
|
+
"invalid_command",
|
|
334
|
+
f"Unknown fields for {operation}: "
|
|
335
|
+
f"{sorted(unknown)}.",
|
|
336
|
+
fields=[{"fields": sorted(unknown)}],
|
|
337
|
+
)
|
|
338
|
+
return True
|
|
339
|
+
|
|
340
|
+
def pump(self, limit=None):
|
|
341
|
+
"""Dispatch queued runs oldest first through the runner.
|
|
342
|
+
|
|
343
|
+
Args:
|
|
344
|
+
limit: Maximum entries to drain; the whole backlog
|
|
345
|
+
when omitted.
|
|
346
|
+
|
|
347
|
+
Returns:
|
|
348
|
+
Completions in dispatch order.
|
|
349
|
+
"""
|
|
350
|
+
entries = self._queue.take_all()
|
|
351
|
+
if limit is not None:
|
|
352
|
+
requeue = entries[max(0, limit):]
|
|
353
|
+
entries = entries[:max(0, limit)]
|
|
354
|
+
for entry in reversed(requeue):
|
|
355
|
+
self._queue.put(entry)
|
|
356
|
+
completions = []
|
|
357
|
+
for entry in entries:
|
|
358
|
+
completions.append(self._drain_one(entry))
|
|
359
|
+
with self._lock:
|
|
360
|
+
if (self._paused and self._inflight_locked() <
|
|
361
|
+
self._max_inflight):
|
|
362
|
+
self._paused = False
|
|
363
|
+
self._emit_locked("admission_resumed", None,
|
|
364
|
+
{"depth":
|
|
365
|
+
self._inflight_locked()})
|
|
366
|
+
return completions
|
|
367
|
+
|
|
368
|
+
def status(self, run_id):
|
|
369
|
+
"""Read one run's state, verdict, and operation.
|
|
370
|
+
|
|
371
|
+
Args:
|
|
372
|
+
run_id: Run to inspect.
|
|
373
|
+
|
|
374
|
+
Returns:
|
|
375
|
+
Status with run_id, operation, state, and verdict
|
|
376
|
+
once judged.
|
|
377
|
+
|
|
378
|
+
Raises:
|
|
379
|
+
ExecutionError: unknown_run for an untracked id.
|
|
380
|
+
"""
|
|
381
|
+
with self._lock:
|
|
382
|
+
try:
|
|
383
|
+
record = self._runs[run_id]
|
|
384
|
+
except KeyError:
|
|
385
|
+
raise ExecutionError(
|
|
386
|
+
"unknown_run", f"Unknown run: {run_id}."
|
|
387
|
+
) from None
|
|
388
|
+
out = {"run_id": run_id,
|
|
389
|
+
"operation": record["operation"],
|
|
390
|
+
"state": record["state"]}
|
|
391
|
+
if record["verdict"] is not None:
|
|
392
|
+
out["verdict"] = dict(record["verdict"])
|
|
393
|
+
return out
|
|
394
|
+
|
|
395
|
+
def wait_for(self, run_id, timeout=None):
|
|
396
|
+
"""Block until one run reaches a terminal state.
|
|
397
|
+
|
|
398
|
+
Args:
|
|
399
|
+
run_id: Run to wait for.
|
|
400
|
+
timeout: Maximum seconds to wait; waits with the
|
|
401
|
+
run when omitted.
|
|
402
|
+
|
|
403
|
+
Returns:
|
|
404
|
+
Terminal completion for the run.
|
|
405
|
+
|
|
406
|
+
Raises:
|
|
407
|
+
ExecutionError: unknown_run for an untracked id.
|
|
408
|
+
TimeoutError: The wait lapsed before the run
|
|
409
|
+
finished.
|
|
410
|
+
"""
|
|
411
|
+
with self._lock:
|
|
412
|
+
try:
|
|
413
|
+
record = self._runs[run_id]
|
|
414
|
+
except KeyError:
|
|
415
|
+
raise ExecutionError(
|
|
416
|
+
"unknown_run", f"Unknown run: {run_id}."
|
|
417
|
+
) from None
|
|
418
|
+
done = record["done"]
|
|
419
|
+
if not done.wait(timeout):
|
|
420
|
+
raise TimeoutError(
|
|
421
|
+
f"Run did not finish in time: {run_id}."
|
|
422
|
+
)
|
|
423
|
+
return self._completion_locked(run_id)
|
|
424
|
+
|
|
425
|
+
def cancel(self, run_id, actor="unknown"):
|
|
426
|
+
"""Cancel one queued or running run with attribution.
|
|
427
|
+
|
|
428
|
+
Cancelling a finished run truthfully returns its
|
|
429
|
+
terminal state, never a fake cancelled. Cancellation
|
|
430
|
+
needs no capacity slot: control stays responsive while
|
|
431
|
+
admission is saturated.
|
|
432
|
+
|
|
433
|
+
Args:
|
|
434
|
+
run_id: Run to cancel.
|
|
435
|
+
actor: Attribution for the lifecycle event.
|
|
436
|
+
|
|
437
|
+
Returns:
|
|
438
|
+
Outcome with run_id, state, actor, and verdict
|
|
439
|
+
when judged.
|
|
440
|
+
|
|
441
|
+
Raises:
|
|
442
|
+
ExecutionError: unknown_run for an untracked id.
|
|
443
|
+
"""
|
|
444
|
+
with self._lock:
|
|
445
|
+
try:
|
|
446
|
+
record = self._runs[run_id]
|
|
447
|
+
except KeyError:
|
|
448
|
+
raise ExecutionError(
|
|
449
|
+
"unknown_run", f"Unknown run: {run_id}."
|
|
450
|
+
) from None
|
|
451
|
+
if record["state"] not in ("queued", "running"):
|
|
452
|
+
out = {"run_id": run_id,
|
|
453
|
+
"state": record["state"], "actor": actor}
|
|
454
|
+
if record["verdict"] is not None:
|
|
455
|
+
out["verdict"] = dict(record["verdict"])
|
|
456
|
+
return out
|
|
457
|
+
entry = self._queue.take(run_id)
|
|
458
|
+
record["state"] = "cancelled"
|
|
459
|
+
record["verdict"] = {"status": "NOT_RUN",
|
|
460
|
+
"reason": "cancelled"}
|
|
461
|
+
if entry is not None:
|
|
462
|
+
self._coalescer.discard(run_id)
|
|
463
|
+
else:
|
|
464
|
+
record["cancel_requested"] = True
|
|
465
|
+
self._emit_locked("job_cancelled", run_id,
|
|
466
|
+
{"actor": actor})
|
|
467
|
+
record["done"].set()
|
|
468
|
+
return {"run_id": run_id, "state": "cancelled",
|
|
469
|
+
"actor": actor,
|
|
470
|
+
"verdict": dict(record["verdict"])}
|
|
471
|
+
|
|
472
|
+
def report_progress(self, run_id, fraction):
|
|
473
|
+
"""Report one job_progress step for a live run.
|
|
474
|
+
|
|
475
|
+
Progress for a queued run is buffered and flushed in
|
|
476
|
+
order right after job_started, so the log always reads
|
|
477
|
+
queued, started, progress, finished. Progress for a
|
|
478
|
+
terminal run is refused instead of logged as a lie.
|
|
479
|
+
|
|
480
|
+
Args:
|
|
481
|
+
run_id: Live run to report progress for.
|
|
482
|
+
fraction: Completion fraction between 0 and 1.
|
|
483
|
+
|
|
484
|
+
Returns:
|
|
485
|
+
Emitted (or buffered) progress event.
|
|
486
|
+
|
|
487
|
+
Raises:
|
|
488
|
+
ExecutionError: unknown_run for an untracked id,
|
|
489
|
+
already_decided for a terminal run.
|
|
490
|
+
"""
|
|
491
|
+
with self._lock:
|
|
492
|
+
try:
|
|
493
|
+
record = self._runs[run_id]
|
|
494
|
+
except KeyError:
|
|
495
|
+
raise ExecutionError(
|
|
496
|
+
"unknown_run", f"Unknown run: {run_id}."
|
|
497
|
+
) from None
|
|
498
|
+
if record["state"] not in ("queued", "running"):
|
|
499
|
+
raise ExecutionError(
|
|
500
|
+
"already_decided",
|
|
501
|
+
f"Run already {record['state']}: {run_id}.",
|
|
502
|
+
)
|
|
503
|
+
record["progressed"] = True
|
|
504
|
+
if record["state"] == "queued":
|
|
505
|
+
pending = record.setdefault(
|
|
506
|
+
"pending_progress", [])
|
|
507
|
+
event = {"seq": None, "kind": "job_progress",
|
|
508
|
+
"run_id": run_id, "fraction": fraction,
|
|
509
|
+
"buffered": True}
|
|
510
|
+
pending.append(fraction)
|
|
511
|
+
return event
|
|
512
|
+
return self._emit_locked("job_progress", run_id,
|
|
513
|
+
{"fraction": fraction})
|
|
514
|
+
|
|
515
|
+
def request_ids_for(self, run_id):
|
|
516
|
+
"""List the request ids mapped to one run, owner first.
|
|
517
|
+
|
|
518
|
+
Args:
|
|
519
|
+
run_id: Run to inspect.
|
|
520
|
+
|
|
521
|
+
Returns:
|
|
522
|
+
Mapped request ids; empty once the run is unknown.
|
|
523
|
+
"""
|
|
524
|
+
with self._lock:
|
|
525
|
+
record = self._runs.get(run_id)
|
|
526
|
+
if record is None:
|
|
527
|
+
return []
|
|
528
|
+
return list(record["request_ids"])
|
|
529
|
+
|
|
530
|
+
def watch(self, cursor):
|
|
531
|
+
"""Read ordered lifecycle events after a cursor.
|
|
532
|
+
|
|
533
|
+
Args:
|
|
534
|
+
cursor: Opaque position from a previous read; 0
|
|
535
|
+
replays the retained log.
|
|
536
|
+
|
|
537
|
+
Returns:
|
|
538
|
+
Events appended after the cursor, oldest first,
|
|
539
|
+
plus the current cursor.
|
|
540
|
+
|
|
541
|
+
Raises:
|
|
542
|
+
ExecutionError: cursor_expired when the cursor is
|
|
543
|
+
behind the retained floor or past the head —
|
|
544
|
+
re-read the snapshot and resubscribe.
|
|
545
|
+
"""
|
|
546
|
+
with self._lock:
|
|
547
|
+
if not isinstance(cursor, int) or cursor < 0:
|
|
548
|
+
raise ExecutionError(
|
|
549
|
+
"invalid_command",
|
|
550
|
+
f"Invalid event cursor: {cursor!r}.",
|
|
551
|
+
)
|
|
552
|
+
head = self._seq_floor + len(self._events)
|
|
553
|
+
if cursor < self._seq_floor or cursor > head:
|
|
554
|
+
raise ExecutionError(
|
|
555
|
+
"cursor_expired",
|
|
556
|
+
f"Cursor {cursor} expired: re-read the "
|
|
557
|
+
f"snapshot and resubscribe from {head}.",
|
|
558
|
+
)
|
|
559
|
+
events = [dict(event) for event in
|
|
560
|
+
self._events[cursor - self._seq_floor:]]
|
|
561
|
+
return {"events": events, "cursor": head}
|
|
562
|
+
|
|
563
|
+
def add_listener(self, callback):
|
|
564
|
+
"""Subscribe one push listener to lifecycle events.
|
|
565
|
+
|
|
566
|
+
Args:
|
|
567
|
+
callback: Called synchronously with each emitted
|
|
568
|
+
event mapping. Listener exceptions are
|
|
569
|
+
contained so one bad subscriber never breaks
|
|
570
|
+
admission or dispatch.
|
|
571
|
+
|
|
572
|
+
Returns:
|
|
573
|
+
Unsubscribe callable removing the listener.
|
|
574
|
+
"""
|
|
575
|
+
with self._lock:
|
|
576
|
+
self._listeners.append(callback)
|
|
577
|
+
|
|
578
|
+
def unsubscribe():
|
|
579
|
+
"""Remove the subscribed push listener.
|
|
580
|
+
|
|
581
|
+
Returns:
|
|
582
|
+
True when the listener was still subscribed.
|
|
583
|
+
"""
|
|
584
|
+
with self._lock:
|
|
585
|
+
if callback in self._listeners:
|
|
586
|
+
self._listeners.remove(callback)
|
|
587
|
+
return True
|
|
588
|
+
return False
|
|
589
|
+
|
|
590
|
+
return unsubscribe
|
|
591
|
+
|
|
592
|
+
def pause_visible(self):
|
|
593
|
+
"""Check whether admission saturation is signalled.
|
|
594
|
+
|
|
595
|
+
Returns:
|
|
596
|
+
True while a refusal is outstanding against the
|
|
597
|
+
bound; False once capacity frees.
|
|
598
|
+
"""
|
|
599
|
+
with self._lock:
|
|
600
|
+
return self._paused
|
|
601
|
+
|
|
602
|
+
def attach_port(self, name, push=True, max_depth=None):
|
|
603
|
+
"""Attach one notify port to the lifecycle event log.
|
|
604
|
+
|
|
605
|
+
Every appended lifecycle event fans out to the port's
|
|
606
|
+
async queue; the writer's path never waits for it.
|
|
607
|
+
|
|
608
|
+
Args:
|
|
609
|
+
name: Port name (stdio, mcp, ide, loopback-http).
|
|
610
|
+
push: True for async push delivery; False leaves
|
|
611
|
+
the port pull-only over watch.
|
|
612
|
+
max_depth: Per-port queue ceiling; the bus default
|
|
613
|
+
when omitted.
|
|
614
|
+
|
|
615
|
+
Returns:
|
|
616
|
+
Attached port descriptor.
|
|
617
|
+
|
|
618
|
+
Raises:
|
|
619
|
+
ExecutionError: invalid_command for a bad name,
|
|
620
|
+
port_exists for an attached name.
|
|
621
|
+
"""
|
|
622
|
+
return self._ports.attach(name, push=push,
|
|
623
|
+
max_depth=max_depth)
|
|
624
|
+
|
|
625
|
+
def detach_port(self, name):
|
|
626
|
+
"""Detach one notify port; its backlog is dropped.
|
|
627
|
+
|
|
628
|
+
Args:
|
|
629
|
+
name: Attached port name to remove.
|
|
630
|
+
|
|
631
|
+
Returns:
|
|
632
|
+
Detached port descriptor.
|
|
633
|
+
|
|
634
|
+
Raises:
|
|
635
|
+
ExecutionError: unknown_port for a detached name.
|
|
636
|
+
"""
|
|
637
|
+
return self._ports.detach(name)
|
|
638
|
+
|
|
639
|
+
def port_state(self, name):
|
|
640
|
+
"""Read one port's engine-side bookkeeping.
|
|
641
|
+
|
|
642
|
+
Args:
|
|
643
|
+
name: Attached port name.
|
|
644
|
+
|
|
645
|
+
Returns:
|
|
646
|
+
Descriptor with name, push, enabled, state,
|
|
647
|
+
resync_required, backlog, cursor, acked, and
|
|
648
|
+
max_depth.
|
|
649
|
+
|
|
650
|
+
Raises:
|
|
651
|
+
ExecutionError: unknown_port for a detached name.
|
|
652
|
+
"""
|
|
653
|
+
return self._ports.port_state(name)
|
|
654
|
+
|
|
655
|
+
def port_pending(self, name):
|
|
656
|
+
"""Read one port's queued lifecycle events.
|
|
657
|
+
|
|
658
|
+
Args:
|
|
659
|
+
name: Attached port name.
|
|
660
|
+
|
|
661
|
+
Returns:
|
|
662
|
+
Queued event copies, oldest first.
|
|
663
|
+
|
|
664
|
+
Raises:
|
|
665
|
+
ExecutionError: unknown_port for a detached name.
|
|
666
|
+
"""
|
|
667
|
+
return self._ports.pending(name)
|
|
668
|
+
|
|
669
|
+
def port_ack(self, name, seq):
|
|
670
|
+
"""Acknowledge delivery through one ledger position.
|
|
671
|
+
|
|
672
|
+
Args:
|
|
673
|
+
name: Attached port name.
|
|
674
|
+
seq: Lifecycle position acknowledged.
|
|
675
|
+
|
|
676
|
+
Returns:
|
|
677
|
+
Ack record with port, acked position, and backlog.
|
|
678
|
+
|
|
679
|
+
Raises:
|
|
680
|
+
ExecutionError: unknown_port for a detached name,
|
|
681
|
+
invalid_command for a non-integer position.
|
|
682
|
+
"""
|
|
683
|
+
return self._ports.ack(name, seq)
|
|
684
|
+
|
|
685
|
+
def port_drain(self, name, handler, limit=None,
|
|
686
|
+
guarded=True):
|
|
687
|
+
"""Deliver one port's backlog through a handler.
|
|
688
|
+
|
|
689
|
+
Args:
|
|
690
|
+
name: Attached port name.
|
|
691
|
+
handler: Callable taking one event mapping.
|
|
692
|
+
limit: Maximum events to deliver; the whole
|
|
693
|
+
backlog when omitted.
|
|
694
|
+
guarded: True applies each position once through
|
|
695
|
+
the port's bound guard.
|
|
696
|
+
|
|
697
|
+
Returns:
|
|
698
|
+
Drain record with delivered and applied counts,
|
|
699
|
+
the acked position, and handler errors.
|
|
700
|
+
|
|
701
|
+
Raises:
|
|
702
|
+
ExecutionError: unknown_port for a detached name.
|
|
703
|
+
"""
|
|
704
|
+
return self._ports.drain(name, handler, limit=limit,
|
|
705
|
+
guarded=guarded)
|
|
706
|
+
|
|
707
|
+
def port_evaluate(self):
|
|
708
|
+
"""Run the staged no-ack ladder over every port.
|
|
709
|
+
|
|
710
|
+
Args:
|
|
711
|
+
None.
|
|
712
|
+
|
|
713
|
+
Returns:
|
|
714
|
+
Directive mappings in attach order.
|
|
715
|
+
"""
|
|
716
|
+
return self._ports.port_evaluate()
|
|
717
|
+
|
|
718
|
+
def port_resync(self, name, head=None):
|
|
719
|
+
"""Converge one port onto the current lifecycle head.
|
|
720
|
+
|
|
721
|
+
Args:
|
|
722
|
+
name: Attached port name.
|
|
723
|
+
head: Position to converge on; the bus head when
|
|
724
|
+
omitted.
|
|
725
|
+
|
|
726
|
+
Returns:
|
|
727
|
+
Resync receipt with port, cursor, and state.
|
|
728
|
+
|
|
729
|
+
Raises:
|
|
730
|
+
ExecutionError: unknown_port for a detached name,
|
|
731
|
+
invalid_command for a non-integer head.
|
|
732
|
+
"""
|
|
733
|
+
return self._ports.port_resync(name, head=head)
|
|
734
|
+
|
|
735
|
+
def set_port_enabled(self, name, enabled, local=False):
|
|
736
|
+
"""Enable or disable one port live (local-only).
|
|
737
|
+
|
|
738
|
+
Args:
|
|
739
|
+
name: Attached port name.
|
|
740
|
+
enabled: True for live delivery, False to park
|
|
741
|
+
the port (backlog dropped, resync flagged).
|
|
742
|
+
local: Caller asserts a local caller; anything
|
|
743
|
+
else is refused loudly.
|
|
744
|
+
|
|
745
|
+
Returns:
|
|
746
|
+
Updated port descriptor.
|
|
747
|
+
|
|
748
|
+
Raises:
|
|
749
|
+
ExecutionError: unknown_port for a detached name,
|
|
750
|
+
remote_toggle_denied for a non-local caller.
|
|
751
|
+
"""
|
|
752
|
+
return self._ports.set_enabled(name, enabled,
|
|
753
|
+
local=local)
|
|
754
|
+
|
|
755
|
+
def set_port_profile(self, names, local=False):
|
|
756
|
+
"""Swap the live port profile (local-only toggle).
|
|
757
|
+
|
|
758
|
+
Args:
|
|
759
|
+
names: Port names forming the live profile.
|
|
760
|
+
local: Caller asserts a local caller; anything
|
|
761
|
+
else is refused loudly.
|
|
762
|
+
|
|
763
|
+
Returns:
|
|
764
|
+
Profile mapping with the enabled names sorted.
|
|
765
|
+
|
|
766
|
+
Raises:
|
|
767
|
+
ExecutionError: remote_toggle_denied for a
|
|
768
|
+
non-local caller, invalid_command for a bad
|
|
769
|
+
profile.
|
|
770
|
+
"""
|
|
771
|
+
return self._ports.set_profile(names, local=local)
|
|
772
|
+
|
|
773
|
+
def apply_edit(self):
|
|
774
|
+
"""Advance the revision cursor for one editor commit.
|
|
775
|
+
|
|
776
|
+
Editor commits ride their own lane: they never wait
|
|
777
|
+
behind solver capacity and only move the coalescing
|
|
778
|
+
cursor so later submissions execute fresh.
|
|
779
|
+
|
|
780
|
+
Returns:
|
|
781
|
+
New revision.
|
|
782
|
+
"""
|
|
783
|
+
with self._lock:
|
|
784
|
+
self._revision += 1
|
|
785
|
+
return self._revision
|
|
786
|
+
|
|
787
|
+
def boot_report(self):
|
|
788
|
+
"""Report unfinished survivors without executing any.
|
|
789
|
+
|
|
790
|
+
Returns:
|
|
791
|
+
Survivors (admitted but unfinished run ids),
|
|
792
|
+
interrupted runs, and quarantined runs.
|
|
793
|
+
"""
|
|
794
|
+
with self._lock:
|
|
795
|
+
survivors = [run_id for run_id, record in
|
|
796
|
+
self._runs.items()
|
|
797
|
+
if record["state"] in ("queued",
|
|
798
|
+
"running")]
|
|
799
|
+
return {"survivors": survivors,
|
|
800
|
+
"interrupted": sorted(
|
|
801
|
+
run_id for run_id, count in
|
|
802
|
+
self._interruptions.items() if count),
|
|
803
|
+
"quarantined": sorted(self._quarantined)}
|
|
804
|
+
|
|
805
|
+
def resume(self, run_id, override_quarantine=False):
|
|
806
|
+
"""Explicitly resume one survivor through the runner.
|
|
807
|
+
|
|
808
|
+
Args:
|
|
809
|
+
run_id: Survivor to execute now.
|
|
810
|
+
override_quarantine: Required to resume a run
|
|
811
|
+
quarantined after repeated interruptions.
|
|
812
|
+
|
|
813
|
+
Returns:
|
|
814
|
+
Terminal completion for the run.
|
|
815
|
+
|
|
816
|
+
Raises:
|
|
817
|
+
ExecutionError: unknown_run for an untracked id,
|
|
818
|
+
poison_quarantined without an explicit
|
|
819
|
+
override, already_decided for a discarded run.
|
|
820
|
+
"""
|
|
821
|
+
with self._lock:
|
|
822
|
+
try:
|
|
823
|
+
record = self._runs[run_id]
|
|
824
|
+
except KeyError:
|
|
825
|
+
raise ExecutionError(
|
|
826
|
+
"unknown_run", f"Unknown run: {run_id}."
|
|
827
|
+
) from None
|
|
828
|
+
if record["state"] == "discarded":
|
|
829
|
+
raise ExecutionError(
|
|
830
|
+
"already_decided",
|
|
831
|
+
f"Run already discarded: {run_id}.",
|
|
832
|
+
)
|
|
833
|
+
if (run_id in self._quarantined and
|
|
834
|
+
not override_quarantine):
|
|
835
|
+
raise ExecutionError(
|
|
836
|
+
"poison_quarantined",
|
|
837
|
+
f"Run quarantined after repeated "
|
|
838
|
+
f"interruptions: {run_id}.",
|
|
839
|
+
)
|
|
840
|
+
if record["state"] not in ("queued", "running"):
|
|
841
|
+
return self._completion_locked(run_id)
|
|
842
|
+
entry = self._queue.take(run_id)
|
|
843
|
+
if entry is None:
|
|
844
|
+
entry = {"run_id": run_id,
|
|
845
|
+
"request_id":
|
|
846
|
+
record["request_ids"][0],
|
|
847
|
+
"operation": record["operation"],
|
|
848
|
+
"arguments": dict(
|
|
849
|
+
record["arguments"]),
|
|
850
|
+
"snapshot_id":
|
|
851
|
+
record["snapshot_id"]}
|
|
852
|
+
return self._drain_one(entry)
|
|
853
|
+
|
|
854
|
+
def discard(self, run_id):
|
|
855
|
+
"""Explicitly discard one survivor without executing it.
|
|
856
|
+
|
|
857
|
+
Args:
|
|
858
|
+
run_id: Survivor to discard.
|
|
859
|
+
|
|
860
|
+
Returns:
|
|
861
|
+
Discard record with run_id and discarded state.
|
|
862
|
+
|
|
863
|
+
Raises:
|
|
864
|
+
ExecutionError: unknown_run for an untracked id.
|
|
865
|
+
"""
|
|
866
|
+
with self._lock:
|
|
867
|
+
try:
|
|
868
|
+
record = self._runs[run_id]
|
|
869
|
+
except KeyError:
|
|
870
|
+
raise ExecutionError(
|
|
871
|
+
"unknown_run", f"Unknown run: {run_id}."
|
|
872
|
+
) from None
|
|
873
|
+
self._queue.take(run_id)
|
|
874
|
+
record["state"] = "discarded"
|
|
875
|
+
record["done"].set()
|
|
876
|
+
self._coalescer.discard(run_id)
|
|
877
|
+
self._emit_locked("job_cancelled", run_id,
|
|
878
|
+
{"reason": "discarded"})
|
|
879
|
+
return {"run_id": run_id, "state": "discarded"}
|
|
880
|
+
|
|
881
|
+
def note_interruption(self, run_id):
|
|
882
|
+
"""Record one ambiguous interruption toward quarantine.
|
|
883
|
+
|
|
884
|
+
Args:
|
|
885
|
+
run_id: Run interrupted before a terminal state.
|
|
886
|
+
|
|
887
|
+
Returns:
|
|
888
|
+
Interruption count for the run.
|
|
889
|
+
"""
|
|
890
|
+
with self._lock:
|
|
891
|
+
count = self._interruptions.get(run_id, 0) + 1
|
|
892
|
+
self._interruptions[run_id] = count
|
|
893
|
+
if count >= QUARANTINE_AFTER:
|
|
894
|
+
self._quarantined.add(run_id)
|
|
895
|
+
return count
|
|
896
|
+
|
|
897
|
+
def check_drift(self, run_id, current):
|
|
898
|
+
"""Compare pinned ingredients against current inputs.
|
|
899
|
+
|
|
900
|
+
Args:
|
|
901
|
+
run_id: Run whose pinned ingredients to check.
|
|
902
|
+
current: Current input mapping with revision,
|
|
903
|
+
operation, and arguments.
|
|
904
|
+
|
|
905
|
+
Returns:
|
|
906
|
+
Drift report with drifted, changed fields, pinned
|
|
907
|
+
snapshot, and current snapshot.
|
|
908
|
+
|
|
909
|
+
Raises:
|
|
910
|
+
ExecutionError: unknown_run for an untracked id.
|
|
911
|
+
"""
|
|
912
|
+
with self._lock:
|
|
913
|
+
try:
|
|
914
|
+
record = self._runs[run_id]
|
|
915
|
+
except KeyError:
|
|
916
|
+
raise ExecutionError(
|
|
917
|
+
"unknown_run", f"Unknown run: {run_id}."
|
|
918
|
+
) from None
|
|
919
|
+
pinned = record["ingredients"]
|
|
920
|
+
changed = sorted(
|
|
921
|
+
key for key in ("revision", "operation",
|
|
922
|
+
"arguments")
|
|
923
|
+
if pinned.get(key) != current.get(key))
|
|
924
|
+
return {"run_id": run_id, "drifted": bool(changed),
|
|
925
|
+
"changed": changed,
|
|
926
|
+
"pinned": dict(pinned), "current": dict(current)}
|
|
927
|
+
|
|
928
|
+
def _inflight_locked(self):
|
|
929
|
+
"""Count queued plus running runs under the lock.
|
|
930
|
+
|
|
931
|
+
Returns:
|
|
932
|
+
Undrained run count.
|
|
933
|
+
"""
|
|
934
|
+
return sum(1 for record in self._runs.values()
|
|
935
|
+
if record["state"] in ("queued", "running"))
|
|
936
|
+
|
|
937
|
+
def _ack(self, run_id, request_id, coalesced=False,
|
|
938
|
+
snapshot_id=None):
|
|
939
|
+
"""Build the prompt acknowledgement receipt.
|
|
940
|
+
|
|
941
|
+
Args:
|
|
942
|
+
run_id: Admitted run id.
|
|
943
|
+
request_id: Client envelope id.
|
|
944
|
+
coalesced: True when the caller joined a shared run.
|
|
945
|
+
snapshot_id: Evidence snapshot the run pins.
|
|
946
|
+
|
|
947
|
+
Returns:
|
|
948
|
+
Receipt with admitted, identity fields, queued
|
|
949
|
+
state, revision, and event cursor — never a
|
|
950
|
+
verdict.
|
|
951
|
+
"""
|
|
952
|
+
ack = {"admitted": True, "project_id":
|
|
953
|
+
self._project_id, "run_id": run_id,
|
|
954
|
+
"request_id": request_id,
|
|
955
|
+
"snapshot_id": snapshot_id or
|
|
956
|
+
f"snapshot_{self._revision}",
|
|
957
|
+
"state": "queued", "revision": self._revision,
|
|
958
|
+
"event_cursor": self._seq_floor +
|
|
959
|
+
len(self._events)}
|
|
960
|
+
if coalesced:
|
|
961
|
+
ack["coalesced"] = True
|
|
962
|
+
return ack
|
|
963
|
+
|
|
964
|
+
def _record_request(self, request_id, operation, arguments,
|
|
965
|
+
key, run_id, receipt):
|
|
966
|
+
"""Persist the durable request/run identity mapping.
|
|
967
|
+
|
|
968
|
+
Args:
|
|
969
|
+
request_id: Client envelope id.
|
|
970
|
+
operation: Admitted operation name.
|
|
971
|
+
arguments: Admitted arguments copy.
|
|
972
|
+
key: Content key for the submission.
|
|
973
|
+
run_id: Admitted run id.
|
|
974
|
+
receipt: Prompt acknowledgement for replays.
|
|
975
|
+
"""
|
|
976
|
+
self._requests[request_id] = {
|
|
977
|
+
"run_id": run_id, "operation": operation,
|
|
978
|
+
"arguments": dict(arguments), "key": key,
|
|
979
|
+
"receipt": dict(receipt),
|
|
980
|
+
}
|
|
981
|
+
record = self._runs.get(run_id)
|
|
982
|
+
if (record is not None and request_id not in
|
|
983
|
+
record["request_ids"]):
|
|
984
|
+
record["request_ids"].append(request_id)
|
|
985
|
+
|
|
986
|
+
def _emit_locked(self, kind, run_id, data):
|
|
987
|
+
"""Append one lifecycle event and push to listeners.
|
|
988
|
+
|
|
989
|
+
Args:
|
|
990
|
+
kind: Lifecycle kind (job_queued, job_started,
|
|
991
|
+
job_progress, job_finished, job_cancelled,
|
|
992
|
+
admission_paused, admission_resumed).
|
|
993
|
+
run_id: Run the event belongs to, if any.
|
|
994
|
+
data: Event payload fields.
|
|
995
|
+
|
|
996
|
+
Returns:
|
|
997
|
+
Emitted event with seq, kind, run_id, and cursor.
|
|
998
|
+
"""
|
|
999
|
+
seq = self._seq_floor + len(self._events) + 1
|
|
1000
|
+
event = {"seq": seq, "kind": kind, "run_id": run_id,
|
|
1001
|
+
"cursor": seq}
|
|
1002
|
+
event.update(dict(data or {}))
|
|
1003
|
+
record = self._runs.get(run_id) if run_id else None
|
|
1004
|
+
if record is not None:
|
|
1005
|
+
event.setdefault("operation",
|
|
1006
|
+
record["operation"])
|
|
1007
|
+
if record["request_ids"]:
|
|
1008
|
+
event.setdefault("request_id",
|
|
1009
|
+
record["request_ids"][0])
|
|
1010
|
+
self._events.append(event)
|
|
1011
|
+
if len(self._events) > RETAINED_EVENTS:
|
|
1012
|
+
overflow = len(self._events) - RETAINED_EVENTS
|
|
1013
|
+
del self._events[:overflow]
|
|
1014
|
+
self._seq_floor += overflow
|
|
1015
|
+
for callback in list(self._listeners):
|
|
1016
|
+
try:
|
|
1017
|
+
callback(dict(event))
|
|
1018
|
+
except Exception: # noqa: BLE001 — contained
|
|
1019
|
+
continue
|
|
1020
|
+
# Port fan-out rides the same appended event: ingest is
|
|
1021
|
+
# non-blocking and quarantines slow ports instead of
|
|
1022
|
+
# stalling the writer, so emission never fails here.
|
|
1023
|
+
self._ports.ingest([event])
|
|
1024
|
+
return dict(event)
|
|
1025
|
+
|
|
1026
|
+
def _completion_locked(self, run_id):
|
|
1027
|
+
"""Build the terminal completion for a finished run.
|
|
1028
|
+
|
|
1029
|
+
Args:
|
|
1030
|
+
run_id: Run to report.
|
|
1031
|
+
|
|
1032
|
+
Returns:
|
|
1033
|
+
Completion with run_id, state, operation, and
|
|
1034
|
+
verdict when judged.
|
|
1035
|
+
"""
|
|
1036
|
+
record = self._runs[run_id]
|
|
1037
|
+
out = {"run_id": run_id, "state": record["state"],
|
|
1038
|
+
"operation": record["operation"]}
|
|
1039
|
+
if record["verdict"] is not None:
|
|
1040
|
+
out["verdict"] = dict(record["verdict"])
|
|
1041
|
+
if record.get("reason") is not None:
|
|
1042
|
+
out["reason"] = record["reason"]
|
|
1043
|
+
return out
|
|
1044
|
+
|
|
1045
|
+
def _drain_one(self, entry):
|
|
1046
|
+
"""Run one queued entry through the runner, contained.
|
|
1047
|
+
|
|
1048
|
+
Args:
|
|
1049
|
+
entry: Queue entry with run_id, request_id,
|
|
1050
|
+
operation, arguments, and snapshot_id.
|
|
1051
|
+
|
|
1052
|
+
Returns:
|
|
1053
|
+
Terminal completion for the run.
|
|
1054
|
+
"""
|
|
1055
|
+
run_id = entry["run_id"]
|
|
1056
|
+
with self._lock:
|
|
1057
|
+
record = self._runs.get(run_id)
|
|
1058
|
+
if record is None or record["state"] != "queued":
|
|
1059
|
+
return self._completion_locked(run_id)
|
|
1060
|
+
record["state"] = "running"
|
|
1061
|
+
self._emit_locked("job_started", run_id, {})
|
|
1062
|
+
pending = record.pop("pending_progress", [])
|
|
1063
|
+
for fraction in pending:
|
|
1064
|
+
self._emit_locked("job_progress", run_id,
|
|
1065
|
+
{"fraction": fraction})
|
|
1066
|
+
if not record.get("progressed"):
|
|
1067
|
+
self._emit_locked("job_progress", run_id,
|
|
1068
|
+
{"fraction": 0.0,
|
|
1069
|
+
"synthetic": True})
|
|
1070
|
+
arguments = dict(entry["arguments"])
|
|
1071
|
+
snapshot_id = entry["snapshot_id"]
|
|
1072
|
+
request_id = entry["request_id"]
|
|
1073
|
+
operation = entry["operation"]
|
|
1074
|
+
try:
|
|
1075
|
+
completion = self._runner(operation, arguments,
|
|
1076
|
+
snapshot_id, request_id,
|
|
1077
|
+
run_id)
|
|
1078
|
+
except Exception as exc: # noqa: BLE001 — recorded
|
|
1079
|
+
completion = {"run_id": run_id, "state": "failed",
|
|
1080
|
+
"operation": operation,
|
|
1081
|
+
"error": f"{type(exc).__name__}: {exc}",
|
|
1082
|
+
"verdict": {"status": "NOT_RUN",
|
|
1083
|
+
"reason": str(exc)}}
|
|
1084
|
+
with self._lock:
|
|
1085
|
+
record = self._runs.get(run_id)
|
|
1086
|
+
if record is not None and record["state"] == \
|
|
1087
|
+
"running":
|
|
1088
|
+
if record.get("cancel_requested"):
|
|
1089
|
+
record["state"] = "cancelled"
|
|
1090
|
+
record["verdict"] = {"status": "NOT_RUN",
|
|
1091
|
+
"reason": "cancelled"}
|
|
1092
|
+
self._emit_locked("job_cancelled", run_id,
|
|
1093
|
+
{})
|
|
1094
|
+
else:
|
|
1095
|
+
record["state"] = completion.get(
|
|
1096
|
+
"state", "completed")
|
|
1097
|
+
record["verdict"] = completion.get(
|
|
1098
|
+
"verdict")
|
|
1099
|
+
if completion.get("reason") is not None:
|
|
1100
|
+
record["reason"] = completion[
|
|
1101
|
+
"reason"]
|
|
1102
|
+
self._emit_locked("job_finished", run_id,
|
|
1103
|
+
{"verdict":
|
|
1104
|
+
record["verdict"]})
|
|
1105
|
+
record["done"].set()
|
|
1106
|
+
if run_id in self._runs:
|
|
1107
|
+
self._coalescer.complete(
|
|
1108
|
+
run_id, self._completion_locked(run_id))
|
|
1109
|
+
if (self._paused and self._inflight_locked() <
|
|
1110
|
+
self._max_inflight):
|
|
1111
|
+
self._paused = False
|
|
1112
|
+
self._emit_locked("admission_resumed", None,
|
|
1113
|
+
{"depth":
|
|
1114
|
+
self._inflight_locked()})
|
|
1115
|
+
return self._completion_locked(run_id)
|
|
1116
|
+
|
|
1117
|
+
def _kick_worker(self):
|
|
1118
|
+
"""Start the background dispatcher when idle.
|
|
1119
|
+
|
|
1120
|
+
Returns:
|
|
1121
|
+
True when a worker was started.
|
|
1122
|
+
"""
|
|
1123
|
+
with self._lock:
|
|
1124
|
+
if self._pumping or not self._queue.pending():
|
|
1125
|
+
return False
|
|
1126
|
+
self._pumping = True
|
|
1127
|
+
|
|
1128
|
+
def _work():
|
|
1129
|
+
try:
|
|
1130
|
+
while self._queue.pending():
|
|
1131
|
+
entries = self._queue.take_all()
|
|
1132
|
+
for entry in entries:
|
|
1133
|
+
self._drain_one(entry)
|
|
1134
|
+
finally:
|
|
1135
|
+
with self._lock:
|
|
1136
|
+
self._pumping = False
|
|
1137
|
+
if (self._paused and
|
|
1138
|
+
self._inflight_locked() <
|
|
1139
|
+
self._max_inflight):
|
|
1140
|
+
self._paused = False
|
|
1141
|
+
self._emit_locked("admission_resumed",
|
|
1142
|
+
None, {"depth":
|
|
1143
|
+
self._inflight_locked()})
|
|
1144
|
+
|
|
1145
|
+
worker = threading.Thread(target=_work, daemon=True,
|
|
1146
|
+
name="engine-runtime-drain")
|
|
1147
|
+
worker.start()
|
|
1148
|
+
return True
|
|
1149
|
+
|