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,85 @@
|
|
|
1
|
+
"""Bounded deferred FIFO for prompt-ack admission (ASYNC-SUBMIT).
|
|
2
|
+
|
|
3
|
+
The queue owns order only: admission truth (durable request
|
|
4
|
+
mapping, coalescing, capacity) lives in `EngineRuntime`. Entries
|
|
5
|
+
wait here between the prompt acknowledgement and `pump`.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
import threading
|
|
9
|
+
from collections import deque
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
class DeferredQueue:
|
|
13
|
+
"""Thread-safe FIFO of admitted-but-undispatched run entries.
|
|
14
|
+
|
|
15
|
+
Args:
|
|
16
|
+
max_depth: Maximum entries held; admissions past it are
|
|
17
|
+
refused by the caller, never queued.
|
|
18
|
+
"""
|
|
19
|
+
|
|
20
|
+
def __init__(self, max_depth=64):
|
|
21
|
+
self._max_depth = max_depth
|
|
22
|
+
self._lock = threading.Lock()
|
|
23
|
+
self._entries = deque()
|
|
24
|
+
|
|
25
|
+
@property
|
|
26
|
+
def max_depth(self):
|
|
27
|
+
"""Bound refused past, never queued past.
|
|
28
|
+
|
|
29
|
+
Returns:
|
|
30
|
+
Maximum entries held.
|
|
31
|
+
"""
|
|
32
|
+
return self._max_depth
|
|
33
|
+
|
|
34
|
+
def __len__(self):
|
|
35
|
+
with self._lock:
|
|
36
|
+
return len(self._entries)
|
|
37
|
+
|
|
38
|
+
def put(self, entry):
|
|
39
|
+
"""Append one admitted entry at the tail.
|
|
40
|
+
|
|
41
|
+
Args:
|
|
42
|
+
entry: Mapping with at least a run_id key.
|
|
43
|
+
|
|
44
|
+
Returns:
|
|
45
|
+
Depth after the append.
|
|
46
|
+
"""
|
|
47
|
+
with self._lock:
|
|
48
|
+
self._entries.append(entry)
|
|
49
|
+
return len(self._entries)
|
|
50
|
+
|
|
51
|
+
def pending(self):
|
|
52
|
+
"""List queued run ids, oldest first.
|
|
53
|
+
|
|
54
|
+
Returns:
|
|
55
|
+
Run ids in dispatch order.
|
|
56
|
+
"""
|
|
57
|
+
with self._lock:
|
|
58
|
+
return [entry["run_id"] for entry in self._entries]
|
|
59
|
+
|
|
60
|
+
def take(self, run_id):
|
|
61
|
+
"""Remove and return one queued entry by run id.
|
|
62
|
+
|
|
63
|
+
Args:
|
|
64
|
+
run_id: Entry to remove.
|
|
65
|
+
|
|
66
|
+
Returns:
|
|
67
|
+
Entry, or None when no queued entry names the id.
|
|
68
|
+
"""
|
|
69
|
+
with self._lock:
|
|
70
|
+
for entry in self._entries:
|
|
71
|
+
if entry["run_id"] == run_id:
|
|
72
|
+
self._entries.remove(entry)
|
|
73
|
+
return entry
|
|
74
|
+
return None
|
|
75
|
+
|
|
76
|
+
def take_all(self):
|
|
77
|
+
"""Remove and return every queued entry, oldest first.
|
|
78
|
+
|
|
79
|
+
Returns:
|
|
80
|
+
Entries in dispatch order.
|
|
81
|
+
"""
|
|
82
|
+
with self._lock:
|
|
83
|
+
entries = list(self._entries)
|
|
84
|
+
self._entries.clear()
|
|
85
|
+
return entries
|
|
@@ -0,0 +1,601 @@
|
|
|
1
|
+
"""Report-not-run survivors + explicit resume + poison quarantine (REPLAY-SAFE).
|
|
2
|
+
|
|
3
|
+
Boot replay that auto-executes is a grief loop: a hung deck gets
|
|
4
|
+
pkill'd, the engine reboots, and faithfully re-runs the exact job
|
|
5
|
+
that hung. The safe default when escape and accident are
|
|
6
|
+
indistinguishable is: don't act, report. The ledger holding the jobs
|
|
7
|
+
IS the 0%-drop guarantee; replay is pure convenience, and this module
|
|
8
|
+
keeps every replay surface gated behind an explicit user decision.
|
|
9
|
+
|
|
10
|
+
Survivors are ledger-admitted runs (`job_queued`) with no completion
|
|
11
|
+
event (`run_completed`/`run_failed`) and no discard event
|
|
12
|
+
(`run_discarded`): `boot()` lists them and NEVER executes. Each
|
|
13
|
+
survivor carries one taxonomy kind: RETRYABLE (never started),
|
|
14
|
+
AMBIGUOUS (started, no completion — outage or poison), or POISON (its
|
|
15
|
+
content key was interrupted twice — quarantined, resume only with
|
|
16
|
+
force). Interruption counts live in the ledger as `run_interrupted`
|
|
17
|
+
events keyed by content key, so a rebooted process classifies
|
|
18
|
+
correctly with no in-memory state.
|
|
19
|
+
|
|
20
|
+
Finished runs are NEVER re-executed, and no transcript of completed
|
|
21
|
+
job ids is ever re-run: runs are reproducible from (snapshot + deck
|
|
22
|
+
+ params), so resubmit-when-wanted covers everything.
|
|
23
|
+
"""
|
|
24
|
+
|
|
25
|
+
import hashlib
|
|
26
|
+
import json
|
|
27
|
+
import logging
|
|
28
|
+
|
|
29
|
+
try:
|
|
30
|
+
from .errors import ExecutionError
|
|
31
|
+
except ImportError: # standalone verification before install
|
|
32
|
+
from rocket_engine.execution.errors import ExecutionError
|
|
33
|
+
|
|
34
|
+
REPLAY_SAFE_VERSION = 1
|
|
35
|
+
|
|
36
|
+
# Twice-interrupted content keys quarantine: the deck that killed you
|
|
37
|
+
# can never auto-run again.
|
|
38
|
+
POISON_THRESHOLD = 2
|
|
39
|
+
|
|
40
|
+
RETRYABLE = "retryable"
|
|
41
|
+
AMBIGUOUS = "ambiguous"
|
|
42
|
+
POISON = "poison"
|
|
43
|
+
|
|
44
|
+
JOB_QUEUED = "job_queued"
|
|
45
|
+
RUN_STARTED = "run_started"
|
|
46
|
+
RUN_INTERRUPTED = "run_interrupted"
|
|
47
|
+
RUN_DISCARDED = "run_discarded"
|
|
48
|
+
_RUN_OUTCOMES = ("run_completed", "run_failed")
|
|
49
|
+
|
|
50
|
+
# Typed refusal codes -> user-facing messages. Friendly, no internals:
|
|
51
|
+
# never a run id, content key, seq, or traceback. Full context goes to
|
|
52
|
+
# the dev-side log at each refusal site instead.
|
|
53
|
+
USER_MESSAGES = {
|
|
54
|
+
"unknown_survivor": (
|
|
55
|
+
"We couldn't find that unfinished run. "
|
|
56
|
+
"It may have already been handled."
|
|
57
|
+
),
|
|
58
|
+
"already_completed": (
|
|
59
|
+
"That run already finished, so there is nothing to resume."
|
|
60
|
+
),
|
|
61
|
+
"already_decided": "That run was already handled in this session.",
|
|
62
|
+
"poison_quarantined": (
|
|
63
|
+
"This run is paused for safety after repeated interruptions. "
|
|
64
|
+
"Please review it before retrying."
|
|
65
|
+
),
|
|
66
|
+
"stale_revision": (
|
|
67
|
+
"The project changed since this run was admitted, so it "
|
|
68
|
+
"cannot resume on the old inputs. Review the change, then "
|
|
69
|
+
"retry with force if the old inputs are still right."
|
|
70
|
+
),
|
|
71
|
+
"ambiguous_requires_explicit_resume": (
|
|
72
|
+
"This run stopped partway through, so it needs your individual "
|
|
73
|
+
"review before resuming."
|
|
74
|
+
),
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
logger = logging.getLogger(__name__)
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
def content_key_for(operation, arguments):
|
|
81
|
+
"""Stable identity for one run's normalized content.
|
|
82
|
+
|
|
83
|
+
The key covers the operation plus its arguments (deck hash rides
|
|
84
|
+
inside the arguments), so identical resubmits share one key and
|
|
85
|
+
one interruption count while any argument change starts fresh.
|
|
86
|
+
|
|
87
|
+
Args:
|
|
88
|
+
operation: Cataloged operation name.
|
|
89
|
+
arguments: Operation arguments mapping.
|
|
90
|
+
|
|
91
|
+
Returns:
|
|
92
|
+
Content key (`ck_` plus 16 hex chars), stable over key order.
|
|
93
|
+
"""
|
|
94
|
+
payload = json.dumps(
|
|
95
|
+
{"operation": operation, "arguments": arguments or {}},
|
|
96
|
+
sort_keys=True,
|
|
97
|
+
separators=(",", ":"),
|
|
98
|
+
ensure_ascii=True,
|
|
99
|
+
default=str,
|
|
100
|
+
)
|
|
101
|
+
digest = hashlib.sha256(payload.encode("utf-8")).hexdigest()[:16]
|
|
102
|
+
return f"ck_{digest}"
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
def interrupt_counts_from_events(events):
|
|
106
|
+
"""Admitted->interrupted transitions per content key.
|
|
107
|
+
|
|
108
|
+
Args:
|
|
109
|
+
events: Ledger events with event and data.content_key entries.
|
|
110
|
+
|
|
111
|
+
Returns:
|
|
112
|
+
Mapping of content key to its `run_interrupted` count.
|
|
113
|
+
"""
|
|
114
|
+
counts = {}
|
|
115
|
+
for event in events:
|
|
116
|
+
if event.get("event") != RUN_INTERRUPTED:
|
|
117
|
+
continue
|
|
118
|
+
key = (event.get("data") or {}).get("content_key")
|
|
119
|
+
if key:
|
|
120
|
+
counts[key] = counts.get(key, 0) + 1
|
|
121
|
+
return counts
|
|
122
|
+
|
|
123
|
+
|
|
124
|
+
def survivors_from_events(events, resolve=None):
|
|
125
|
+
"""Ledger-admitted runs with no completion or discard, oldest first.
|
|
126
|
+
|
|
127
|
+
Kinds: never-started runs are RETRYABLE; started-but-uncompleted
|
|
128
|
+
runs are AMBIGUOUS; runs whose content key reached
|
|
129
|
+
POISON_THRESHOLD interruptions are POISON (quarantined). A missing
|
|
130
|
+
content key never quarantines: without a key there is no count.
|
|
131
|
+
|
|
132
|
+
Args:
|
|
133
|
+
events: Ledger events with seq, event, request_id, and
|
|
134
|
+
data.run_id entries; `job_queued` data may carry
|
|
135
|
+
content_key (post-wiring ledgers).
|
|
136
|
+
resolve: Optional `request_id -> (command, ack)` for
|
|
137
|
+
pre-wiring ledgers whose `job_queued` events lack
|
|
138
|
+
content_key. May raise or return None per id; either
|
|
139
|
+
leaves that survivor keyless, never skipped.
|
|
140
|
+
|
|
141
|
+
Returns:
|
|
142
|
+
Survivor mappings in admit order, each with run_id,
|
|
143
|
+
request_id, operation, content_key (None when
|
|
144
|
+
unresolvable), kind, interrupts, quarantined, and the
|
|
145
|
+
always-offered actions ["resume", "discard"].
|
|
146
|
+
"""
|
|
147
|
+
queued = {}
|
|
148
|
+
order = []
|
|
149
|
+
completed = set()
|
|
150
|
+
discarded = set()
|
|
151
|
+
started = set()
|
|
152
|
+
for event in sorted(events, key=lambda e: e.get("seq", 0)):
|
|
153
|
+
name = event.get("event")
|
|
154
|
+
data = event.get("data") or {}
|
|
155
|
+
run_id = data.get("run_id")
|
|
156
|
+
if run_id is None:
|
|
157
|
+
continue
|
|
158
|
+
if name == JOB_QUEUED:
|
|
159
|
+
if run_id not in queued:
|
|
160
|
+
order.append(run_id)
|
|
161
|
+
queued[run_id] = {
|
|
162
|
+
"request_id": event.get("request_id"),
|
|
163
|
+
"operation": data.get("operation"),
|
|
164
|
+
"content_key": data.get("content_key"),
|
|
165
|
+
}
|
|
166
|
+
elif name in _RUN_OUTCOMES:
|
|
167
|
+
completed.add(run_id)
|
|
168
|
+
elif name == RUN_DISCARDED:
|
|
169
|
+
discarded.add(run_id)
|
|
170
|
+
elif name == RUN_STARTED:
|
|
171
|
+
started.add(run_id)
|
|
172
|
+
counts = interrupt_counts_from_events(events)
|
|
173
|
+
survivors = []
|
|
174
|
+
for run_id in order:
|
|
175
|
+
if run_id in completed or run_id in discarded:
|
|
176
|
+
continue
|
|
177
|
+
entry = queued[run_id]
|
|
178
|
+
key = entry["content_key"]
|
|
179
|
+
if key is None and resolve is not None:
|
|
180
|
+
try:
|
|
181
|
+
resolved = resolve(entry["request_id"])
|
|
182
|
+
except Exception:
|
|
183
|
+
resolved = None
|
|
184
|
+
if resolved is not None:
|
|
185
|
+
command, _ack = resolved
|
|
186
|
+
key = content_key_for(
|
|
187
|
+
command.get("operation"),
|
|
188
|
+
command.get("arguments") or {},
|
|
189
|
+
)
|
|
190
|
+
interrupts = counts.get(key, 0) if key else 0
|
|
191
|
+
if key is not None and interrupts >= POISON_THRESHOLD:
|
|
192
|
+
kind = POISON
|
|
193
|
+
elif run_id in started:
|
|
194
|
+
kind = AMBIGUOUS
|
|
195
|
+
else:
|
|
196
|
+
kind = RETRYABLE
|
|
197
|
+
survivors.append({
|
|
198
|
+
"run_id": run_id,
|
|
199
|
+
"request_id": entry["request_id"],
|
|
200
|
+
"operation": entry["operation"],
|
|
201
|
+
"content_key": key,
|
|
202
|
+
"kind": kind,
|
|
203
|
+
"interrupts": interrupts,
|
|
204
|
+
"quarantined": kind == POISON,
|
|
205
|
+
"actions": ["resume", "discard"],
|
|
206
|
+
})
|
|
207
|
+
return survivors
|
|
208
|
+
|
|
209
|
+
|
|
210
|
+
def boot_message(unfinished):
|
|
211
|
+
"""Pkill-then-boot message: what happened, what did not run.
|
|
212
|
+
|
|
213
|
+
Args:
|
|
214
|
+
unfinished: Survivor count from the boot report.
|
|
215
|
+
|
|
216
|
+
Returns:
|
|
217
|
+
Session-ended message naming the unfinished count plus the
|
|
218
|
+
nothing-re-ran promise (or the clean-session variant when
|
|
219
|
+
zero), always pointing at per-run Resume/Discard.
|
|
220
|
+
"""
|
|
221
|
+
if unfinished <= 0:
|
|
222
|
+
return (
|
|
223
|
+
"Last session ended cleanly. "
|
|
224
|
+
"No unfinished runs \u2014 nothing to resume."
|
|
225
|
+
)
|
|
226
|
+
noun = "run" if unfinished == 1 else "runs"
|
|
227
|
+
return (
|
|
228
|
+
f"Last session ended unexpectedly. {unfinished} {noun} "
|
|
229
|
+
"unfinished \u2014 nothing re-ran automatically. "
|
|
230
|
+
"Review each run to Resume or Discard it."
|
|
231
|
+
)
|
|
232
|
+
|
|
233
|
+
|
|
234
|
+
def user_message_for(code):
|
|
235
|
+
"""Friendly user-facing message for one refusal code.
|
|
236
|
+
|
|
237
|
+
Args:
|
|
238
|
+
code: Typed refusal code.
|
|
239
|
+
|
|
240
|
+
Returns:
|
|
241
|
+
User-facing message with no internals; a generic
|
|
242
|
+
review-prompt for unknown codes (never a KeyError).
|
|
243
|
+
"""
|
|
244
|
+
return USER_MESSAGES.get(
|
|
245
|
+
code, "This run needs your review before it can continue."
|
|
246
|
+
)
|
|
247
|
+
|
|
248
|
+
|
|
249
|
+
def refuse(code, dev_detail):
|
|
250
|
+
"""Log full dev context, then raise the friendly typed refusal.
|
|
251
|
+
|
|
252
|
+
Args:
|
|
253
|
+
code: Typed refusal code (keys USER_MESSAGES).
|
|
254
|
+
dev_detail: Dev-side context (ids, keys, counts) for the
|
|
255
|
+
log only — never surfaced to the user.
|
|
256
|
+
|
|
257
|
+
Raises:
|
|
258
|
+
ExecutionError: Always, carrying the code plus the
|
|
259
|
+
user-facing message.
|
|
260
|
+
"""
|
|
261
|
+
logger.warning("replay_safe refusal code=%s detail=%s", code, dev_detail)
|
|
262
|
+
raise ExecutionError(code, user_message_for(code))
|
|
263
|
+
|
|
264
|
+
|
|
265
|
+
def _latest_seq(events, name, run_id):
|
|
266
|
+
"""Latest seq of one event name for one run (-1 when absent)."""
|
|
267
|
+
best = -1
|
|
268
|
+
for event in events:
|
|
269
|
+
if event.get("event") != name:
|
|
270
|
+
continue
|
|
271
|
+
if (event.get("data") or {}).get("run_id") != run_id:
|
|
272
|
+
continue
|
|
273
|
+
seq = event.get("seq", 0)
|
|
274
|
+
if seq > best:
|
|
275
|
+
best = seq
|
|
276
|
+
return best
|
|
277
|
+
|
|
278
|
+
|
|
279
|
+
class ReplaySafe:
|
|
280
|
+
"""Explicit-decision replay controller over a durable ledger.
|
|
281
|
+
|
|
282
|
+
Boot reports, the user decides, and only then does anything run:
|
|
283
|
+
`boot()` never executes, `discard()` only records, and the
|
|
284
|
+
executor fires solely inside an explicit `resume()` or
|
|
285
|
+
`resume_all_retryable()` call.
|
|
286
|
+
|
|
287
|
+
Args:
|
|
288
|
+
project: Ledger port with `events_since(cursor)`,
|
|
289
|
+
`command_for(request_id)`, and
|
|
290
|
+
`append_replay_event(event, request_id, data)`.
|
|
291
|
+
executor: Callable `(operation, arguments, snapshot_id,
|
|
292
|
+
request_id, run_id)` returning a completion mapping.
|
|
293
|
+
It fires only on explicit resume, never on boot.
|
|
294
|
+
|
|
295
|
+
Caution: One instance per boot: per-run decisions
|
|
296
|
+
(resumed/discarded) are tracked in memory so a second
|
|
297
|
+
action on the same run refuses instead of double-running.
|
|
298
|
+
"""
|
|
299
|
+
|
|
300
|
+
def __init__(self, project, executor):
|
|
301
|
+
self._project = project
|
|
302
|
+
self._executor = executor
|
|
303
|
+
self._decided = {}
|
|
304
|
+
|
|
305
|
+
def _resolve_soft(self, request_id):
|
|
306
|
+
"""Admitted (command, ack), or None when unresolvable."""
|
|
307
|
+
try:
|
|
308
|
+
return self._project.command_for(request_id)
|
|
309
|
+
except Exception:
|
|
310
|
+
return None
|
|
311
|
+
|
|
312
|
+
def _live_survivors(self, events):
|
|
313
|
+
by_id = {}
|
|
314
|
+
for survivor in survivors_from_events(
|
|
315
|
+
events, self._resolve_soft
|
|
316
|
+
):
|
|
317
|
+
by_id[survivor["run_id"]] = survivor
|
|
318
|
+
return by_id
|
|
319
|
+
|
|
320
|
+
def _completed_runs(self, events):
|
|
321
|
+
"""Run ids with a completion event."""
|
|
322
|
+
return {
|
|
323
|
+
(event.get("data") or {}).get("run_id")
|
|
324
|
+
for event in events
|
|
325
|
+
if event.get("event") in _RUN_OUTCOMES
|
|
326
|
+
} - {None}
|
|
327
|
+
|
|
328
|
+
def _check_decided(self, run_id):
|
|
329
|
+
"""Refuse resumed/discarded runs; completed ones stay live."""
|
|
330
|
+
if run_id in self._decided:
|
|
331
|
+
refuse(
|
|
332
|
+
"already_decided",
|
|
333
|
+
f"run_id={run_id} decision={self._decided[run_id]}",
|
|
334
|
+
)
|
|
335
|
+
|
|
336
|
+
def boot(self):
|
|
337
|
+
"""Report crash survivors without executing anything.
|
|
338
|
+
|
|
339
|
+
Started-but-uncompleted runs gain one ledger
|
|
340
|
+
`run_interrupted` mark each (idempotent: a mark counts until
|
|
341
|
+
the next `run_started`), so the taxonomy reflects this boot's
|
|
342
|
+
outages before the report is built.
|
|
343
|
+
|
|
344
|
+
Returns:
|
|
345
|
+
Report with version, the pkill-then-boot message, the
|
|
346
|
+
unfinished count, survivors in admit order,
|
|
347
|
+
quarantined run ids, and unresolved entries
|
|
348
|
+
(pre-wiring admissions whose command is gone — each
|
|
349
|
+
with request_id, run_id, and reason).
|
|
350
|
+
|
|
351
|
+
Caution: Report-only by construction: the executor is never
|
|
352
|
+
invoked here, only inside an explicit resume call.
|
|
353
|
+
"""
|
|
354
|
+
events = self._project.events_since(0)
|
|
355
|
+
request_run = {}
|
|
356
|
+
for event in events:
|
|
357
|
+
if event.get("event") == JOB_QUEUED:
|
|
358
|
+
request_run[event.get("request_id")] = (
|
|
359
|
+
event.get("data") or {}
|
|
360
|
+
).get("run_id")
|
|
361
|
+
unresolved = []
|
|
362
|
+
|
|
363
|
+
def _resolve(request_id):
|
|
364
|
+
try:
|
|
365
|
+
return self._project.command_for(request_id)
|
|
366
|
+
except Exception as exc:
|
|
367
|
+
unresolved.append({
|
|
368
|
+
"request_id": request_id,
|
|
369
|
+
"run_id": request_run.get(request_id),
|
|
370
|
+
"reason": f"{type(exc).__name__}: {exc}",
|
|
371
|
+
})
|
|
372
|
+
return None
|
|
373
|
+
|
|
374
|
+
survivors = survivors_from_events(events, _resolve)
|
|
375
|
+
for survivor in survivors:
|
|
376
|
+
key = survivor["content_key"]
|
|
377
|
+
if key is None:
|
|
378
|
+
continue
|
|
379
|
+
run_id = survivor["run_id"]
|
|
380
|
+
if _latest_seq(events, RUN_STARTED, run_id) > _latest_seq(
|
|
381
|
+
events, RUN_INTERRUPTED, run_id
|
|
382
|
+
):
|
|
383
|
+
self._project.append_replay_event(
|
|
384
|
+
RUN_INTERRUPTED,
|
|
385
|
+
survivor["request_id"],
|
|
386
|
+
{
|
|
387
|
+
"run_id": run_id,
|
|
388
|
+
"content_key": key,
|
|
389
|
+
"operation": survivor["operation"],
|
|
390
|
+
},
|
|
391
|
+
)
|
|
392
|
+
logger.info(
|
|
393
|
+
"replay_safe interrupted run_id=%s key=%s",
|
|
394
|
+
run_id,
|
|
395
|
+
key,
|
|
396
|
+
)
|
|
397
|
+
events = self._project.events_since(0)
|
|
398
|
+
survivors = survivors_from_events(events, _resolve)
|
|
399
|
+
quarantined = [
|
|
400
|
+
survivor["run_id"]
|
|
401
|
+
for survivor in survivors
|
|
402
|
+
if survivor["quarantined"]
|
|
403
|
+
]
|
|
404
|
+
logger.info(
|
|
405
|
+
"replay_safe boot unfinished=%d quarantined=%d",
|
|
406
|
+
len(survivors),
|
|
407
|
+
len(quarantined),
|
|
408
|
+
)
|
|
409
|
+
return {
|
|
410
|
+
"version": REPLAY_SAFE_VERSION,
|
|
411
|
+
"message": boot_message(len(survivors)),
|
|
412
|
+
"unfinished": len(survivors),
|
|
413
|
+
"survivors": survivors,
|
|
414
|
+
"quarantined": quarantined,
|
|
415
|
+
"unresolved": unresolved,
|
|
416
|
+
}
|
|
417
|
+
|
|
418
|
+
def resume(self, run_id, force=False):
|
|
419
|
+
"""Explicitly resume one survivor through the executor.
|
|
420
|
+
|
|
421
|
+
Quarantined (POISON) runs resume only with force; every other
|
|
422
|
+
survivor resumes on the explicit call. A `run_started` mark is
|
|
423
|
+
appended first, so a kill mid-resume classifies AMBIGUOUS (or
|
|
424
|
+
POISON) at the next boot instead of vanishing. A head that
|
|
425
|
+
moved past the admission revision refuses as stale — the
|
|
426
|
+
stored inputs no longer describe the project — and only
|
|
427
|
+
force resumes them.
|
|
428
|
+
|
|
429
|
+
Args:
|
|
430
|
+
run_id: Survivor to resume.
|
|
431
|
+
force: Required for quarantined runs and for runs
|
|
432
|
+
whose admission revision drifted; refused loudly
|
|
433
|
+
without it. Force bypasses quarantine and drift
|
|
434
|
+
only — the run still executes for real.
|
|
435
|
+
|
|
436
|
+
Returns:
|
|
437
|
+
Executor completion for the run.
|
|
438
|
+
|
|
439
|
+
Raises:
|
|
440
|
+
ExecutionError: already_decided for a run handled this
|
|
441
|
+
session, already_completed for a run that finished
|
|
442
|
+
since the report, unknown_survivor for an id with
|
|
443
|
+
no unfinished run, poison_quarantined for a
|
|
444
|
+
quarantined run resumed without force,
|
|
445
|
+
stale_revision for a run admitted before the
|
|
446
|
+
project head moved on.
|
|
447
|
+
|
|
448
|
+
Caution: This executes the run — the one replay surface
|
|
449
|
+
that does, and only on this explicit call.
|
|
450
|
+
"""
|
|
451
|
+
events = self._project.events_since(0)
|
|
452
|
+
self._check_decided(run_id)
|
|
453
|
+
if run_id in self._completed_runs(events):
|
|
454
|
+
refuse(
|
|
455
|
+
"already_completed",
|
|
456
|
+
f"run_id={run_id} completed_since_report=True",
|
|
457
|
+
)
|
|
458
|
+
survivor = self._live_survivors(events).get(run_id)
|
|
459
|
+
if survivor is None:
|
|
460
|
+
refuse("unknown_survivor", f"run_id={run_id}")
|
|
461
|
+
if survivor["quarantined"] and not force:
|
|
462
|
+
refuse(
|
|
463
|
+
"poison_quarantined",
|
|
464
|
+
f"run_id={run_id} key={survivor['content_key']} "
|
|
465
|
+
f"interrupts={survivor['interrupts']} force={force}",
|
|
466
|
+
)
|
|
467
|
+
try:
|
|
468
|
+
command, ack = self._project.command_for(
|
|
469
|
+
survivor["request_id"]
|
|
470
|
+
)
|
|
471
|
+
except Exception as exc:
|
|
472
|
+
refuse(
|
|
473
|
+
"unknown_survivor",
|
|
474
|
+
f"run_id={run_id} "
|
|
475
|
+
f"request_id={survivor['request_id']} "
|
|
476
|
+
f"resolve_failed={type(exc).__name__}: {exc}",
|
|
477
|
+
)
|
|
478
|
+
admitted = (ack or {}).get("current_revision")
|
|
479
|
+
if admitted is not None and not force:
|
|
480
|
+
head = self._project.snapshot()["current_revision"]
|
|
481
|
+
if head != admitted:
|
|
482
|
+
refuse(
|
|
483
|
+
"stale_revision",
|
|
484
|
+
f"run_id={run_id} "
|
|
485
|
+
f"admitted_revision={admitted} "
|
|
486
|
+
f"current_revision={head} force=False",
|
|
487
|
+
)
|
|
488
|
+
self._project.append_replay_event(
|
|
489
|
+
RUN_STARTED,
|
|
490
|
+
survivor["request_id"],
|
|
491
|
+
{
|
|
492
|
+
"run_id": run_id,
|
|
493
|
+
"content_key": survivor["content_key"],
|
|
494
|
+
"operation": survivor["operation"],
|
|
495
|
+
},
|
|
496
|
+
)
|
|
497
|
+
completion = self._executor(
|
|
498
|
+
command.get("operation"),
|
|
499
|
+
dict(command.get("arguments") or {}),
|
|
500
|
+
ack.get("snapshot_id", ""),
|
|
501
|
+
survivor["request_id"],
|
|
502
|
+
run_id,
|
|
503
|
+
)
|
|
504
|
+
self._decided[run_id] = "resumed"
|
|
505
|
+
logger.info(
|
|
506
|
+
"replay_safe resumed run_id=%s kind=%s forced=%s",
|
|
507
|
+
run_id,
|
|
508
|
+
survivor["kind"],
|
|
509
|
+
bool(force),
|
|
510
|
+
)
|
|
511
|
+
return completion
|
|
512
|
+
|
|
513
|
+
def discard(self, run_id):
|
|
514
|
+
"""Explicitly discard one survivor, never executing it.
|
|
515
|
+
|
|
516
|
+
The run gains a `run_discarded` ledger event and leaves all
|
|
517
|
+
future survivor reports.
|
|
518
|
+
|
|
519
|
+
Args:
|
|
520
|
+
run_id: Survivor to discard.
|
|
521
|
+
|
|
522
|
+
Returns:
|
|
523
|
+
Discard record with run_id, discarded state, and the
|
|
524
|
+
run's operation.
|
|
525
|
+
|
|
526
|
+
Raises:
|
|
527
|
+
ExecutionError: already_decided for a run handled this
|
|
528
|
+
session, already_completed for a finished run,
|
|
529
|
+
unknown_survivor for an id with no unfinished run.
|
|
530
|
+
"""
|
|
531
|
+
events = self._project.events_since(0)
|
|
532
|
+
self._check_decided(run_id)
|
|
533
|
+
if run_id in self._completed_runs(events):
|
|
534
|
+
refuse(
|
|
535
|
+
"already_completed",
|
|
536
|
+
f"run_id={run_id} completed_since_report=True",
|
|
537
|
+
)
|
|
538
|
+
survivor = self._live_survivors(events).get(run_id)
|
|
539
|
+
if survivor is None:
|
|
540
|
+
refuse("unknown_survivor", f"run_id={run_id}")
|
|
541
|
+
self._project.append_replay_event(
|
|
542
|
+
RUN_DISCARDED,
|
|
543
|
+
survivor["request_id"],
|
|
544
|
+
{"run_id": run_id, "operation": survivor["operation"]},
|
|
545
|
+
)
|
|
546
|
+
self._decided[run_id] = "discarded"
|
|
547
|
+
logger.info("replay_safe discarded run_id=%s", run_id)
|
|
548
|
+
return {
|
|
549
|
+
"run_id": run_id,
|
|
550
|
+
"state": "discarded",
|
|
551
|
+
"operation": survivor["operation"],
|
|
552
|
+
}
|
|
553
|
+
|
|
554
|
+
def resume_all_retryable(self):
|
|
555
|
+
"""Resume every RETRYABLE survivor, oldest first.
|
|
556
|
+
|
|
557
|
+
AMBIGUOUS runs need individual review and POISON runs need
|
|
558
|
+
per-run force, so both are skipped loudly — never swept up.
|
|
559
|
+
|
|
560
|
+
Returns:
|
|
561
|
+
Batch with resumed run ids, their completions, and
|
|
562
|
+
skipped entries (each with run_id, typed code, and
|
|
563
|
+
user-facing message).
|
|
564
|
+
|
|
565
|
+
Raises:
|
|
566
|
+
ExecutionError: Propagates the first executor failure
|
|
567
|
+
unchanged — earlier resumes in the batch stand,
|
|
568
|
+
later ones never fire.
|
|
569
|
+
|
|
570
|
+
Caution: This executes runs — only the RETRYABLE,
|
|
571
|
+
non-quarantined survivors, and only on this call.
|
|
572
|
+
"""
|
|
573
|
+
events = self._project.events_since(0)
|
|
574
|
+
resumed = []
|
|
575
|
+
completions = []
|
|
576
|
+
skipped = []
|
|
577
|
+
for survivor in survivors_from_events(events, self._resolve_soft):
|
|
578
|
+
run_id = survivor["run_id"]
|
|
579
|
+
if run_id in self._decided:
|
|
580
|
+
code = "already_decided"
|
|
581
|
+
elif survivor["quarantined"]:
|
|
582
|
+
code = "poison_quarantined"
|
|
583
|
+
elif survivor["kind"] != RETRYABLE:
|
|
584
|
+
code = "ambiguous_requires_explicit_resume"
|
|
585
|
+
else:
|
|
586
|
+
completions.append(self.resume(run_id))
|
|
587
|
+
resumed.append(run_id)
|
|
588
|
+
continue
|
|
589
|
+
skipped.append({
|
|
590
|
+
"run_id": run_id,
|
|
591
|
+
"code": code,
|
|
592
|
+
"message": user_message_for(code),
|
|
593
|
+
})
|
|
594
|
+
logger.info(
|
|
595
|
+
"replay_safe skipped run_id=%s code=%s", run_id, code
|
|
596
|
+
)
|
|
597
|
+
return {
|
|
598
|
+
"resumed": resumed,
|
|
599
|
+
"completions": completions,
|
|
600
|
+
"skipped": skipped,
|
|
601
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
"""Canonical console routing homes: calls, namespace, tools."""
|