matrx-graph 0.2.330__py3-none-any.whl → 0.2.332__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.
matrx_graph/compile.py CHANGED
@@ -18,7 +18,7 @@ from matrx_graph.errors import CompilationError
18
18
  from matrx_graph.executor.schema_validation import schema_validity_errors
19
19
  from matrx_graph.executor.registry import NodeRegistry, default_registry
20
20
  from matrx_graph.types.channel import ChannelSpec
21
- from matrx_graph.types.compiled import CompiledEdge, CompiledGraph, CompiledNode
21
+ from matrx_graph.types.compiled import CompiledEdge, CompiledGraph, CompiledNode, StepClock
22
22
  from matrx_graph.types.input_surface import InputSpec, hint_values
23
23
  from matrx_graph.types.definition import Definition
24
24
  from matrx_graph.types.primitives import EdgeKind, RetryPolicy
@@ -285,6 +285,10 @@ def compile( # noqa: A001 — mirrors matplotlib-style top-level compile()
285
285
  except Exception as e: # noqa: BLE001
286
286
  raise CompilationError(f"node '{node.id}' has an invalid retry_policy: {e}") from e
287
287
 
288
+ # Per-node wall clock. Authored as data.step_clock = {seconds, knob?:
289
+ # {feature, key}}; a bad value fails compilation (loud), never a run.
290
+ step_clock = _compile_step_clock(node.id, node.data.get("step_clock"))
291
+
288
292
  # Per-node output-channel binding. Authored as
289
293
  # data.output_channel = "<channel name>": the scheduler writes the
290
294
  # node's full output dump to that channel after success/mock, so
@@ -393,6 +397,7 @@ def compile( # noqa: A001 — mirrors matplotlib-style top-level compile()
393
397
  writes_channels=writes,
394
398
  mock_response=mock_response,
395
399
  retry_policy=retry_policy,
400
+ step_clock=step_clock,
396
401
  effective_output_schema=effective_output_schema,
397
402
  output_channel=output_channel,
398
403
  input_kind=input_kind,
@@ -562,3 +567,39 @@ def _build_input_surface(
562
567
  declared_by[var.name] = "the workflow's declared variables"
563
568
 
564
569
  return surface
570
+
571
+
572
+ def _compile_step_clock(node_id: str, raw: Any) -> StepClock | None:
573
+ """``data.step_clock`` → :class:`StepClock`, or a CompilationError naming the node."""
574
+ if raw is None:
575
+ return None
576
+ from matrx_graph.knobs import MAX_STEP_SECONDS, MIN_STEP_SECONDS
577
+
578
+ if not isinstance(raw, dict):
579
+ raise CompilationError(f"node '{node_id}' has an invalid step_clock: expected an object, got {raw!r}")
580
+ seconds = raw.get("seconds")
581
+ if isinstance(seconds, bool) or not isinstance(seconds, int) or not (
582
+ MIN_STEP_SECONDS <= seconds <= MAX_STEP_SECONDS
583
+ ):
584
+ raise CompilationError(
585
+ f"node '{node_id}' has an invalid step_clock.seconds {seconds!r}: a whole number "
586
+ f"from {MIN_STEP_SECONDS} to {MAX_STEP_SECONDS}"
587
+ )
588
+ knob = raw.get("knob")
589
+ unknown = set(raw) - {"seconds", "knob"}
590
+ if unknown:
591
+ raise CompilationError(f"node '{node_id}' step_clock has unknown keys {sorted(unknown)}")
592
+ if knob is None:
593
+ return StepClock(seconds=seconds)
594
+ if not (
595
+ isinstance(knob, dict)
596
+ and isinstance(knob.get("feature"), str)
597
+ and knob["feature"].strip()
598
+ and isinstance(knob.get("key"), str)
599
+ and knob["key"].strip()
600
+ and set(knob) <= {"feature", "key"}
601
+ ):
602
+ raise CompilationError(
603
+ f"node '{node_id}' has an invalid step_clock.knob {knob!r}: expected {{feature, key}}"
604
+ )
605
+ return StepClock(seconds=seconds, knob_feature=knob["feature"], knob_key=knob["key"])
@@ -2181,6 +2181,11 @@ class Scheduler:
2181
2181
  try:
2182
2182
  while open_members:
2183
2183
  task, result, result_step = await done_queue.get()
2184
+ if task.cancelled() and _job_lease().lost():
2185
+ # A member stopped because this copy lost its job lease (``JobLeaseLost`` is a cancellation):
2186
+ # the whole copy stops here, never routing or checkpointing on (lane K1b, B2).
2187
+ lease = _job_lease().current()
2188
+ raise _job_lease().JobLeaseLost(f"job lease lost: {lease.why if lease else ''}")
2184
2189
  if id(result) in member_order and result_step == step:
2185
2190
  # Kept in frontier order by insertion — O(log n) search per finished member, never a re-sort.
2186
2191
  bisect.insort(finished_members, result, key=lambda r: member_order[id(r)])
@@ -3261,7 +3266,9 @@ class Scheduler:
3261
3266
  # ``matrx_graph.knobs`` § THE STEP WALL CLOCK.
3262
3267
  from matrx_graph.knobs import step_timeout_seconds
3263
3268
 
3264
- clock_seconds, clock_source = await step_timeout_seconds(spec.timeout_ms)
3269
+ clock_seconds, clock_source = await step_timeout_seconds(
3270
+ spec.timeout_ms, node_clock=node.step_clock
3271
+ )
3265
3272
  try:
3266
3273
  try:
3267
3274
  output = await asyncio.wait_for(
@@ -6488,3 +6495,10 @@ def _settle_default_on_decision_gate(
6488
6495
  logger.info("decision gate settled by its default %r → %s", raw, shaped["answer"])
6489
6496
  value["answer"] = shaped["answer"]
6490
6497
  return value
6498
+
6499
+
6500
+ def _job_lease():
6501
+ """``matrx_graph.workers.job_lease``, imported on use (the workers package imports the executor)."""
6502
+ from matrx_graph.workers import job_lease
6503
+
6504
+ return job_lease
matrx_graph/knobs.py CHANGED
@@ -432,12 +432,34 @@ async def default_step_seconds() -> int:
432
432
  )
433
433
 
434
434
 
435
- async def step_timeout_seconds(declared_timeout_ms: int | None) -> tuple[float, str]:
435
+ async def step_timeout_seconds(
436
+ declared_timeout_ms: int | None, *, node_clock: Any = None
437
+ ) -> tuple[float, str]:
436
438
  """``(seconds, source)`` for one node attempt.
437
439
 
438
- ``source`` is the word the failure message uses, so a person reading a timed-out
439
- run can tell a ceiling the workflow's own author set from the platform's.
440
+ Precedence: the node's own ``data.step_clock`` (its knob row when it
441
+ resolves, else its declared seconds) → the NodeSpec's ``timeout_ms`` → the
442
+ platform ceiling. ``source`` is the word the failure message uses, so a
443
+ person reading a timed-out run can tell a ceiling the workflow's own author
444
+ set from the platform's.
440
445
  """
446
+ if node_clock is not None:
447
+ seconds = int(node_clock.seconds)
448
+ if node_clock.knob_feature and node_clock.knob_key:
449
+ values = await _feature_values(node_clock.knob_feature)
450
+ if values is not None and node_clock.knob_key in values:
451
+ seconds = _coerce_int(
452
+ values[node_clock.knob_key], seconds, MIN_STEP_SECONDS, MAX_STEP_SECONDS
453
+ )
454
+ else:
455
+ logger.error(
456
+ "[matrx_graph] step clock knob %s.%s unresolved — the step keeps its "
457
+ "declared %ss. REMEDY: seed that platform.feature_knob row.",
458
+ node_clock.knob_feature,
459
+ node_clock.knob_key,
460
+ seconds,
461
+ )
462
+ return float(max(MIN_STEP_SECONDS, min(MAX_STEP_SECONDS, seconds))), "declared"
441
463
  if declared_timeout_ms is not None and declared_timeout_ms > 0:
442
464
  return declared_timeout_ms / 1000.0, "declared"
443
465
  return float(await default_step_seconds()), "platform"
@@ -11,6 +11,22 @@ from matrx_graph.types.review import ReviewMark
11
11
  from matrx_graph.types.variables import VariableSpec
12
12
 
13
13
 
14
+ class StepClock(BaseModel):
15
+ """One node's OWN wall-clock limit (``NodeDef.data.step_clock``).
16
+
17
+ ``seconds`` is the declared starting limit; ``knob`` (``{feature, key}``)
18
+ names the ``platform.feature_knob`` row an admin turns. The row wins when it
19
+ resolves; an unreadable row falls back to ``seconds`` — never to the
20
+ platform ceiling and never to "no limit".
21
+ """
22
+
23
+ model_config = ConfigDict(frozen=True, extra="forbid")
24
+
25
+ seconds: int = Field(ge=1)
26
+ knob_feature: str | None = None
27
+ knob_key: str | None = None
28
+
29
+
14
30
  class CompiledNode(BaseModel):
15
31
  """Scheduler-ready view of a node.
16
32
 
@@ -48,6 +64,11 @@ class CompiledNode(BaseModel):
48
64
  # inspector without editing the action's code. None = inherit the
49
65
  # spec default.
50
66
  retry_policy: RetryPolicy | None = None
67
+ # Per-node wall clock (``data.step_clock``). When set it wins over the
68
+ # NodeSpec's ``timeout_ms`` and the platform ceiling for THIS node only —
69
+ # a long step (a podcast produced end to end) declares its own limit
70
+ # instead of the platform default being raised for every step.
71
+ step_clock: StepClock | None = None
51
72
  # Set when the node's NodeSpec has ``dynamic_output=True`` (e.g.
52
73
  # io.user_input): the JSON Schema computed from this node's config at
53
74
  # compile time. Used to generate the run form and for downstream type
@@ -17,6 +17,7 @@ and `workflow.job`'s post-reorg baseline schema on 2026-08-09.
17
17
  - **Jobs run in parallel, bounded, each in its own task (2026-09-17).** `WorkerRunner` holds an `asyncio.Semaphore(max_concurrent)`: it keeps claiming while slots are free, waits on the *clock* (`poll_interval`) only when the queue is empty, and waits on a *slot* when every one is busy. Each claimed job runs as its own `asyncio.Task` with its own lease renewer, and `complete`/`fail`/cancel semantics are per job and unchanged. **`max_concurrent` defaults to 1** — a host that has not thought about the bound keeps exactly the behavior it had; aidream passes `workflow_worker.max_concurrent_jobs` (a `platform.feature_knob` row, currently 8, read once at worker start). Why: the loop was strictly serial, so ONE slow job held every other workflow on the platform. Measured in production 2026-09-17, five simultaneous Cloud Browser activations (each a workflow job that waits on a container coming up) came up as a 5.5-second-per-run ladder, and for those 22 seconds nothing else in the queue moved; at the 50-run fleet ceiling that is a ~4.5-minute tail. **Per-job isolation is structural, not conventional**: a job task is created from the claim loop's context, so `contextvars` it sets (an ambient `AppContext`, an ORM connection) belong to that task alone — they cannot reach a sibling job and cannot outlive the job in the loop. Under the old serial `await` they did: aidream's `_run_one` installs an ambient AppContext at the tail of every job, and that context stayed installed in the claim loop afterwards. Proof: `tests/test_workers.py` — the wall-clock test, the peak-concurrency test, the neighbour-isolation test, the two drain tests, and the context-isolation test (each demonstrated failing against the serial loop before the change).
18
18
  - **Stopping is a bounded drain.** `stop()` takes no new claims; `run_forever()` then waits out the jobs already running for `stop_grace_seconds` (aidream passes its share of THE DEPLOY WINDOW) and cancels whatever is left. A canceled job fails retryable and its run resumes from its last checkpoint — the same outcome a serial worker's single job always had when the container was replaced.
19
19
  - **A worker only claims what it can run (2026-10-12, `capability.py`).** Every server — deployed fleet and developer laptops — shares one database, so a job may belong to a graph whose node types this build has not shipped (live: a local build's `brand.setup.land_logo` run was resumed by production and errored "Unknown node type"). `enqueue()` / `enqueue_claimed()` stamp `workflow.job.metadata.required_node_types` (canonical spellings, removed types excluded) from the run's stored definition; `claim_one(capabilities=...)` adds `COALESCE(j.metadata->'required_node_types','[]') <@ caps` (`matrx_orm.JsonbContainedBy`), so an unstamped legacy job stays claimable by anyone. `WorkerRunner` and `run_single_job` pass `worker_capabilities()` (the default registry) on every claim. Belt two: a host job runner that finds a gap at run time raises `JobNeedsCapability`; the runner calls `defer_for_capability()` (un-claim, attempt refunded, requirement stamped, immediately visible) — never `fail()`, never `complete()`, never a run-status write. The run says what it waits for in `workflow.run.metadata._awaiting_capability` (`missing`, `message`, `since`, `checked_at`, `checked_by`) plus one `workflow.run_log` line (`run_awaiting_capability`); every incapable worker refreshes it via `stamp_runs_awaiting_capability()` and `checked_at` is a lifecycle-watchdog liveness signal, so a waiting run is not force-failed as stranded while a fleet is watching it. The first capable worker clears the marker. Proof: `tests/test_capability_aware_claim.py` (incl. the predicate evaluated in live Postgres; fails against the pre-change queue/runner).
20
+ - **A copy that loses its lease stops itself (lane K1, 2026-10-21).** A renewal that answers "not ours" (reaped — including a FALSE "dead" after a database outage longer than the lease, live job c1d89868 — or settled elsewhere) marks the copy's `job_lease.JobLease` lost and cancels its task; the host skips its terminal write (the copy that holds the job owns the run); many-item work calls `job_lease.check()` before each item (it also reads the job row, at most every tenth of a renewal beat). The reaper does not try to tell an outage from a death: a false "dead" is harmless by construction. Proof: aidream `aidream/services/tests/test_a_reaped_run_never_waits_on_itself.py`. **Lane K1b:** whoever notices the loss first (the renewal or the work's own `check`) cancels the copy's owner task, and run-ahead re-raises a member that ended cancelled on a lost lease (it used to swallow it and route on). A job the reaper only DEAD-LETTERED (a false "dead" on its last attempt) is taken back by the live copy (`queue.take_back_false_dead_letter`, compare-and-set) and finished — stopping would strand the run, since nothing claims a dead-lettered job; a requeued or re-claimed job still stops the old copy. A renewal landing after `complete()` is not a loss (`JobLease.settling`). Each copy's `JobLease.generation` (the job's attempt) fences what it holds (import slots). A platform-wide outage longer than the lease plus grace cancels EVERY in-flight job at once and they all restart from their checkpoints together: the same spend per job as before, but as one burst. Proof: `tests/test_a_lost_lease_never_strands_or_lingers.py`.
20
21
  - **Recovery starts before work.** `WorkerRunner.run_forever()` calls `reap_expired_leases()` before its first claim. The host's recurring reaper remains a backstop.
21
22
  - **A transient claim outage never kills the worker.** A database timeout or
22
23
  connection failure while claiming `workflow.job` is captured durably and
@@ -0,0 +1,155 @@
1
+ """The job lease a running execution holds, as seen from inside the work (lane K1, 2026-10-21).
2
+
3
+ Live, 2026-10-21 (run 8749d414, job c1d89868): the database dropped every connection for ten minutes, the reaper
4
+ read the expired lease as a dead worker and requeued the job, and the SAME process started the run again while the
5
+ first execution was still alive (hung on a store call). The first copy logged "finishing the in-flight run anyway"
6
+ and kept its organization import slot, so the second copy waited on itself.
7
+
8
+ The rule now (Arman, 2026-10-21): **a copy that loses its job lease stops itself.** The runner publishes the lease
9
+ of the job it executes on this ContextVar; long, many-item work (an import writes item after item) calls
10
+ :func:`check` before each item. Once the lease is gone — the renewal answered "not ours", or the job row says
11
+ another claim holds it — ``check`` raises :class:`JobLeaseLost`, the copy stops without writing again, and the
12
+ host skips its terminal write (the new copy owns the run). The runner also cancels the lost copy's task, so a copy
13
+ parked between items stops too.
14
+
15
+ ``JobLeaseLost`` is a ``CancelledError``: to the engine and the host a lost lease is a cancellation of THIS copy,
16
+ never a failure of the run (a failure would be written over the new copy's run).
17
+ """
18
+
19
+ from __future__ import annotations
20
+
21
+ import asyncio
22
+ import contextlib
23
+ import logging
24
+ import time
25
+ from collections.abc import Iterator
26
+ from contextvars import ContextVar
27
+ from dataclasses import dataclass, field
28
+ from uuid import UUID
29
+
30
+ log = logging.getLogger(__name__)
31
+
32
+ class JobLeaseLost(asyncio.CancelledError):
33
+ """This copy's job lease was reaped or taken by another claim: stop now, write nothing more."""
34
+
35
+
36
+ @dataclass
37
+ class JobLease:
38
+ job_id: str
39
+ run_id: str
40
+ claimed_by: str | None
41
+ #: fewest seconds between two reads of the job row by ``check`` (a many-item run checks before every item); the
42
+ #: runner sets it from its lease (a tenth of the renewal beat), and the renewal's own answer is used in between
43
+ look_every: float = 0.0
44
+ #: the task running this copy of the job: whoever notices the loss first (the renewal, or the work's own
45
+ #: ``check``) cancels it, so the WHOLE copy stops at once (lane K1b, B2: run-ahead swallowed a ``JobLeaseLost``
46
+ #: raised inside one node, and the copy kept checkpointing and routing until the next renewal)
47
+ owner: asyncio.Task | None = field(default=None, repr=False)
48
+ #: the copy finished its work and is settling the job (``complete``): a renewal that lands after the settle
49
+ #: commits answers "not ours", and that is not a loss (K1b, M1)
50
+ settling: bool = False
51
+ #: per-copy fencing token for anything this copy holds (an import slot): a newer copy's is always greater
52
+ generation: float = field(default_factory=time.time)
53
+ visibility_timeout: int = 600
54
+ lost: bool = False
55
+ why: str = ""
56
+ _looked: float = field(default=0.0, repr=False)
57
+
58
+ def mark_lost(self, why: str) -> None:
59
+ if self.settling:
60
+ log.info("job %s: settled while a renewal was in flight; not a lost lease", self.job_id[:8])
61
+ return
62
+ if not self.lost:
63
+ log.error("job %s (run %s) lost its lease (%s): this copy stops and writes nothing more",
64
+ self.job_id[:8], self.run_id[:8], why)
65
+ self.lost, self.why = True, why
66
+ owner = self.owner
67
+ if owner is not None and not owner.done() and owner is not _running_task():
68
+ owner.cancel(f"job {self.job_id[:8]} lease lost: {why}")
69
+
70
+ async def still_held(self) -> bool | None:
71
+ """The job row's answer: True while this claim holds it, False once it does not, None when unreadable."""
72
+ from matrx_graph.db._registry import get_model
73
+
74
+ try:
75
+ rows = await get_model("WfJob").filter(id=str(UUID(self.job_id))).all(use_cache=False)
76
+ except Exception: # noqa: BLE001 — unreadable (an outage): the lease is not judged on a store that is down
77
+ log.warning("could not read job %s to check its lease", self.job_id[:8], exc_info=True)
78
+ return None
79
+ if not rows:
80
+ return False
81
+ row = rows[0]
82
+ get = row.get if isinstance(row, dict) else lambda k: getattr(row, k, None)
83
+ if get("claimed_at") is None:
84
+ return False
85
+ # every claim stamps a token: a token that is not this copy's (or one where this copy had none) is another
86
+ # claim (K1b, M4: a token-less copy used to read every later claim as its own)
87
+ return get("claimed_by") == self.claimed_by
88
+
89
+ async def not_ours(self, why: str) -> bool:
90
+ """The queue says this copy no longer holds its job. When the reaper only DEAD-LETTERED it (a false "dead" on
91
+ the last attempt), no other copy can ever own the run: take the job back and keep going, so the run is
92
+ finished and its terminal status written (K1b, B1). Otherwise mark the lease lost (which stops the copy).
93
+ Returns True when the copy keeps the job."""
94
+ from matrx_graph.workers.queue import take_back_false_dead_letter
95
+
96
+ if self.settling:
97
+ return True
98
+ try:
99
+ kept = await take_back_false_dead_letter(
100
+ self.job_id, claimed_by=self.claimed_by, visibility_timeout_seconds=self.visibility_timeout)
101
+ except Exception: # noqa: BLE001 — unknown: the copy stops (the safe side for a run another copy may own)
102
+ log.warning("job %s: could not try to take back a dead-lettered job", self.job_id[:8], exc_info=True)
103
+ kept = False
104
+ if not kept:
105
+ self.mark_lost(why)
106
+ return kept
107
+
108
+ async def check(self) -> None:
109
+ """Raise :class:`JobLeaseLost` when this copy no longer holds its job; read the row at most every
110
+ ``look_every`` seconds."""
111
+ if not self.lost and time.monotonic() - self._looked >= self.look_every:
112
+ self._looked = time.monotonic()
113
+ if await self.still_held() is False:
114
+ await self.not_ours("the job row is claimed by another copy or was requeued")
115
+ if self.lost:
116
+ raise JobLeaseLost(f"job {self.job_id[:8]} lease lost: {self.why}")
117
+
118
+
119
+ def _running_task() -> asyncio.Task | None:
120
+ try:
121
+ return asyncio.current_task()
122
+ except RuntimeError:
123
+ return None
124
+
125
+
126
+ _current: ContextVar[JobLease | None] = ContextVar("matrx_graph_job_lease", default=None)
127
+
128
+
129
+ def current() -> JobLease | None:
130
+ """The lease of the job this task executes; None outside a queued job (an inline run has no job lease)."""
131
+ return _current.get()
132
+
133
+
134
+ async def check() -> None:
135
+ """Before each item of many-item work: raises :class:`JobLeaseLost` once this copy's job lease is gone."""
136
+ lease = _current.get()
137
+ if lease is not None:
138
+ await lease.check()
139
+
140
+
141
+ def lost() -> bool:
142
+ lease = _current.get()
143
+ return bool(lease is not None and lease.lost)
144
+
145
+
146
+ @contextlib.contextmanager
147
+ def holding(lease: JobLease) -> Iterator[JobLease]:
148
+ token = _current.set(lease)
149
+ try:
150
+ yield lease
151
+ finally:
152
+ _current.reset(token)
153
+
154
+
155
+ __all__ = ["JobLease", "JobLeaseLost", "check", "current", "holding", "lost"]
@@ -707,19 +707,89 @@ async def renew_lease(
707
707
  return result.rows_affected > 0
708
708
 
709
709
 
710
+ async def take_back_false_dead_letter(
711
+ job_id: str,
712
+ *,
713
+ claimed_by: str | None,
714
+ visibility_timeout_seconds: int = 600,
715
+ ) -> bool:
716
+ """A live copy whose job the reaper DEAD-LETTERED (a false "dead": an outage outlasted the lease on the job's last
717
+ attempt) takes the job back, so it finishes the run and writes its terminal status (lane K1b, B1, 2026-10-21).
718
+
719
+ Without this the live copy read "not ours", cancelled itself and skipped its terminal write, and no other copy would
720
+ ever claim a dead-lettered job: the run stayed non-terminal (stranded) for every job kind. A dead-lettered job has
721
+ no other owner, so taking it back can never run two copies. Compare-and-set: only a row that is still unclaimed,
722
+ still dead-lettered BY THE REAPER (``last_error.type == 'lease_expired'``) and still parked in the far future
723
+ (an operator's retry moves ``visible_at`` back) is taken; anything else answers False and the copy stops.
724
+ """
725
+ from matrx_graph.db._registry import get_model
726
+
727
+ model = get_model("WfJob")
728
+ try:
729
+ rows = await model.filter(id=str(UUID(job_id))).all(use_cache=False)
730
+ except Exception: # noqa: BLE001 — unreadable: not taken back (the caller decides on the renewal's answer)
731
+ log.warning("could not read job %s to see whether the reaper dead-lettered it", job_id[:8], exc_info=True)
732
+ return False
733
+ if not rows:
734
+ return False
735
+ row = rows[0]
736
+ get = row.get if isinstance(row, dict) else lambda k: getattr(row, k, None)
737
+ note = _parse_json(get("last_error")) or {}
738
+ if get("claimed_at") is not None or not isinstance(note, dict):
739
+ return False
740
+ if note.get("queue_state") != "dead_letter" or note.get("type") != "lease_expired":
741
+ return False
742
+ now = datetime.now(UTC)
743
+ taken = (
744
+ await model.filter(
745
+ id=str(UUID(job_id)),
746
+ claimed_at__isnull=True,
747
+ visible_at__gt=now + timedelta(days=_FAR_FUTURE_DAYS // 2),
748
+ ).update(
749
+ claimed_at=now,
750
+ claimed_by=claimed_by,
751
+ visible_at=now + timedelta(seconds=visibility_timeout_seconds),
752
+ last_error={
753
+ "queue_state": "in_progress",
754
+ "type": "false_dead_taken_back",
755
+ "message": "the reaper dead-lettered a job its worker was still running (an outage outlasted the "
756
+ "lease); the live copy took it back to finish the run",
757
+ },
758
+ )
759
+ ).updated_rows
760
+ if taken:
761
+ log.warning("job %s: the reaper dead-lettered it while this worker still ran it; taken back to finish the run",
762
+ job_id[:8])
763
+ return bool(taken)
764
+
765
+
710
766
  async def reap_expired_leases(*, grace_seconds: int = 120) -> int:
711
767
  """Requeue jobs whose lease expired ≥ ``grace_seconds`` ago (audit P1-14).
712
768
 
713
769
  A worker that dies mid-job leaves ``claimed_at`` set forever — only
714
770
  complete/fail/release null it — so the job is orphaned: never re-run,
715
771
  never dead-lettered, invisible to claim_one. This reaper is the one
716
- mechanism that recovers those rows. Safe against false positives
717
- because WorkerRunner renews the lease every lease/3 seconds while a
718
- job executes: an expired-by-more-than-grace lease means the renewal
719
- loop (and therefore the worker) is dead.
772
+ mechanism that recovers those rows. WorkerRunner renews the lease every
773
+ lease/3 seconds while a job executes, so an expired-by-more-than-grace
774
+ lease USUALLY means the worker is dead — but not always: a database
775
+ outage longer than the lease fails every renewal of every live worker
776
+ at once (live 2026-10-21, 15:49–15:59Z: job c1d89868 was requeued from
777
+ a worker that was alive).
778
+
779
+ Decision (lane K1, 2026-10-21): the reaper does NOT try to tell an outage
780
+ from a death (an outage-detection rule would itself have to read a store
781
+ that just failed, and would delay real recovery); instead a false "dead"
782
+ is made HARMLESS by the copies themselves: a copy whose renewal answers
783
+ "not ours" marks its lease lost, is cancelled and skips its terminal
784
+ write (``runner._renew_loop`` + ``job_lease``); many-item work checks the
785
+ lease before each item and each store attempt; and a slot held for the
786
+ run (``work_claims.hold_work_slot(holder=run_id)``) is taken over by the
787
+ new copy at once, so the run never waits on its own earlier copy.
720
788
 
721
789
  Jobs with attempts left are requeued (claim_one re-claims and bumps
722
- ``attempt``); exhausted jobs are dead-lettered with the same far-future
790
+ ``attempt``); exhausted jobs are dead-lettered (a live copy whose job was
791
+ falsely dead-lettered takes it back, ``take_back_false_dead_letter``, lane
792
+ K1b, so its run is never stranded) with the same far-future
723
793
  visible_at shape ``fail()`` uses. Returns how many rows were touched.
724
794
  """
725
795
  from matrx_graph.db._registry import get_model
@@ -30,6 +30,7 @@ from typing import TYPE_CHECKING, Any
30
30
 
31
31
  from matrx_utils import capture_error
32
32
 
33
+ from matrx_graph.workers import job_lease
33
34
  from matrx_graph.workers.capability import (
34
35
  JobNeedsCapability,
35
36
  mark_run_awaiting_capability,
@@ -320,14 +321,36 @@ class WorkerRunner:
320
321
  await asyncio.gather(*still_running, return_exceptions=True)
321
322
 
322
323
  async def _process(self, job: Job) -> None:
323
- renewer = asyncio.create_task(self._renew_loop(job.job_id, claimed_by=job.claimed_by))
324
+ # The lease this copy holds, published to the work (``job_lease.check`` before each item) and marked lost by
325
+ # the renewal the moment the queue says the job is no longer ours (lane K1, 2026-10-21).
326
+ lease = job_lease.JobLease(
327
+ job_id=job.job_id,
328
+ run_id=job.run_id,
329
+ claimed_by=job.claimed_by,
330
+ look_every=max(self._visibility_timeout // 3, 10) / 10,
331
+ owner=asyncio.current_task(),
332
+ visibility_timeout=self._visibility_timeout,
333
+ # the job's attempt: every re-claim bumps it, so a later copy's fencing token is always greater (K1b, B5)
334
+ generation=float(job.attempt or 0),
335
+ )
336
+ renewer = asyncio.create_task(
337
+ self._renew_loop(job.job_id, claimed_by=job.claimed_by, lease=lease, owner=asyncio.current_task())
338
+ )
324
339
  try:
325
340
  app = await self._app_factory(job)
326
- await self._run_job(job, app)
341
+ with job_lease.holding(lease):
342
+ await self._run_job(job, app)
343
+ lease.settling = True # a renewal answering "not ours" after this settles is not a loss (K1b, M1)
327
344
  await complete(job.job_id, claimed_by=job.claimed_by)
328
345
  except JobNeedsCapability as need:
329
346
  await hand_back_for_capability(job, need)
330
347
  except asyncio.CancelledError as exc:
348
+ if lease.lost:
349
+ # A copy whose lease was reaped stops here and settles NOTHING: the job row and the run belong to the
350
+ # copy that holds the job now (its fail() would be fenced anyway; this keeps the log honest).
351
+ log.warning("job %s: this copy stopped after losing its lease (%s); the current holder owns the run",
352
+ job.job_id[:8], lease.why)
353
+ raise
331
354
  try:
332
355
  await asyncio.shield(
333
356
  fail(
@@ -396,7 +419,14 @@ class WorkerRunner:
396
419
  except asyncio.CancelledError:
397
420
  pass
398
421
 
399
- async def _renew_loop(self, job_id: str, *, claimed_by: str | None) -> None:
422
+ async def _renew_loop(
423
+ self,
424
+ job_id: str,
425
+ *,
426
+ claimed_by: str | None,
427
+ lease: job_lease.JobLease | None = None,
428
+ owner: asyncio.Task[Any] | None = None,
429
+ ) -> None:
400
430
  # Lease/3 keeps two renewal misses of headroom before expiry; the
401
431
  # reaper adds its own grace on top, so only a dead worker is reaped.
402
432
  interval = max(self._visibility_timeout // 3, 10)
@@ -421,16 +451,27 @@ class WorkerRunner:
421
451
  )
422
452
  continue
423
453
  if not still_ours:
424
- # Reaped or completed elsewhere. Don't cancel the in-flight
425
- # run — killing a paid LLM call mid-flight loses the spend
426
- # AND the result; the run-store terminal-status guards
427
- # arbitrate if a second worker re-claims and both finish.
454
+ # Reaped (a false "dead" after a database outage looks exactly like this) or settled elsewhere.
455
+ # Arman, 2026-10-21: a copy that loses its job lease STOPS ITSELF. Finishing "anyway" (the old rule)
456
+ # left two copies of one run alive: the old one, hung on a store call, kept its organization import
457
+ # slot and the restarted copy waited on itself (run 8749d414). Another copy may own the run now, so
458
+ # this one stops: the lease is marked lost (many-item work checks it before each item), the job
459
+ # task is cancelled, and the host skips its terminal write.
460
+ # Lane K1b, B1: when the reaper only DEAD-LETTERED the job (a false "dead" on its last attempt), no
461
+ # other copy can ever own the run, so this copy takes the job back and finishes it (stopping would
462
+ # strand the run: nothing would ever write its terminal status).
463
+ if lease is not None:
464
+ if lease.settling:
465
+ return
466
+ if await lease.not_ours("the queue reaped or settled the job"):
467
+ continue
428
468
  log.error(
429
469
  "job %s lease is no longer ours (reaped or deleted) — "
430
- "finishing the in-flight run anyway; status guards "
431
- "arbitrate duplicate terminal writes",
470
+ "this copy stops; the current holder owns the run",
432
471
  job_id,
433
472
  )
473
+ if owner is not None and not owner.done():
474
+ owner.cancel()
434
475
  return
435
476
 
436
477
 
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: matrx-graph
3
- Version: 0.2.330
3
+ Version: 0.2.332
4
4
  Summary: Channel-based, super-step workflow orchestration engine for the Matrx ecosystem
5
5
  Project-URL: Homepage, https://github.com/AI-Matrix-Engine/aidream-current
6
6
  Project-URL: Repository, https://github.com/AI-Matrix-Engine/aidream-current
@@ -19,7 +19,7 @@ Requires-Python: >=3.13
19
19
  Requires-Dist: croniter>=2.0
20
20
  Requires-Dist: httpx>=0.27
21
21
  Requires-Dist: jsonschema>=4.20
22
- Requires-Dist: matrx-connect>=0.1.235
22
+ Requires-Dist: matrx-connect>=0.1.236
23
23
  Requires-Dist: matrx-utils>=2.0.83
24
24
  Requires-Dist: pydantic>=2.12
25
25
  Requires-Dist: pyyaml>=6.0
@@ -1,7 +1,7 @@
1
1
  matrx_graph/__init__.py,sha256=6-2kvFL4Q1xbj21ZOqIGAvErgwYTStenEqzyrMdmY5o,7803
2
2
  matrx_graph/candidate.py,sha256=nWyD4FkELyT0aGAJG13OkcGjyLlqO_M7Jct4IIe8oJk,10497
3
3
  matrx_graph/checks.py,sha256=F5mDzoDAj5ArNuuehN88DntSk_iIWAq1WlBaMTXIUFE,12187
4
- matrx_graph/compile.py,sha256=Zm81mHPxOEyAyTVjp86pTZvOfa0h7Ba08IEWTy6WlQA,25630
4
+ matrx_graph/compile.py,sha256=ewfpjIZtNBPvl0Kj_1jYx12KA1cB9NekdmuWWw_SOqA,27436
5
5
  matrx_graph/composition.py,sha256=PX3XAnH1v6jOJ1kYGVlJH3hzqQFTEtxR2uNzRObtdWA,9069
6
6
  matrx_graph/contract_kinds.py,sha256=xOlhlbwijClxeDzj12mxpbWqMc6k8bsz6UyHcjROqsc,11818
7
7
  matrx_graph/decision_spec.py,sha256=5QGYYa0xLqXidHbILwTjTkE9WbZzyKxLjz25CYVr2Kw,15764
@@ -15,7 +15,7 @@ matrx_graph/input_bindings.py,sha256=mweDa6SKo-I9PU7qJIz_jgruciUhtp7bV1KBBjla0TU
15
15
  matrx_graph/kind_control_keys.py,sha256=35LcweBvfqU2pz6Eu8vFn7kM-vh9FK1B4QPArZ76WS0,9444
16
16
  matrx_graph/kind_fields.py,sha256=6F9jmJNz9-104FSAqIwx8xcy5HqeeYxedgyoyPJf0mk,21123
17
17
  matrx_graph/kinds.py,sha256=Uh1KmVBL46EpC4ojKCq7eOom8SVleEROUmRYzz27BU4,37751
18
- matrx_graph/knobs.py,sha256=5C6wOykirXW6JuoCB1Zp8W6SjO1ga_aoAuvBa0D--Rc,21388
18
+ matrx_graph/knobs.py,sha256=tZMlF7YV3GZw3liccEnFdOkYBbvOeFxgc25oetdEOGo,22463
19
19
  matrx_graph/mapping_sources.py,sha256=8VGTbPmib7uyh2L8ZnjsJa6h57lKgz0JOpJ9fmkcYxU,3910
20
20
  matrx_graph/overview.py,sha256=3_hxx3dFZQtZkVE-9TcdU8udK4GWndnHKKPNU__aTws,24406
21
21
  matrx_graph/patch.py,sha256=DCd7MioacEfcaPxuvrDZS9uxfUE-3EKN7FBL4vLc9y0,10675
@@ -147,7 +147,7 @@ matrx_graph/executor/resume_chain.py,sha256=YJv8P8Ca1wSAsUEjeii3Tg6Fgoy5N9Tf2Gs4
147
147
  matrx_graph/executor/resume_preflight.py,sha256=vz9_01QINvgwK9u2y7NcF7B9J2Yv7K1jHalRuZD7siw,11977
148
148
  matrx_graph/executor/review_flags.py,sha256=hMR8tPrSd_-vgsLiTc82MmEaIx-sMkC5yf53zIbhh8M,5542
149
149
  matrx_graph/executor/run_stop.py,sha256=HpdGE83MabnU_4rnf_Vn8zRyk5LBF3qbZQ2Ouvc36hY,3004
150
- matrx_graph/executor/scheduler.py,sha256=nqpaj7ZllrDf3twN8SHcl8BlXm4Ak8Ht593zJkDXwvM,322508
150
+ matrx_graph/executor/scheduler.py,sha256=1ezi7TYpY-tLSk-dsGjuk38WIK3ivdPw8wUDFVCdLfw,323193
151
151
  matrx_graph/executor/schema_validation.py,sha256=X2CbDX7_vZGBtcw_L0BQcjDD-KEAxnGA8ruhm9Qp5CI,5250
152
152
  matrx_graph/nodes/__init__.py,sha256=ikOsSIu8aaSYAZXa_Uks-RW_BkIjwiO1TwTptLom0bY,10118
153
153
  matrx_graph/nodes/_sandbox.py,sha256=Uizb2QIbW9MgZ_4MtWoJlmjR1mMXZNb9oMYGhXNXeQ0,10517
@@ -229,7 +229,7 @@ matrx_graph/observability/__init__.py,sha256=h8iYxtLuNQg2CFepjbVzvx_dLOeA-iG3H-1
229
229
  matrx_graph/types/__init__.py,sha256=DsU_RAigVdPhLwzRDFU9M0bZ10uhYOhVveQOBh2vPr8,2335
230
230
  matrx_graph/types/canonical_payloads.py,sha256=c_R_EdjbXUyXcDUxMYTbi66oFJzzyTFmTIAKdQIB2gw,4925
231
231
  matrx_graph/types/channel.py,sha256=Tr0RjeUkhaXwW-DYnEhloWV2Zw-xbT6VmRbFqo6u0Zg,2385
232
- matrx_graph/types/compiled.py,sha256=GMjYLuPRde0f8kkmzegkokW9sbHB51jr24VK-JDhbf8,11164
232
+ matrx_graph/types/compiled.py,sha256=9oCymfDLXEmxrezmfzTPV3EHK8ELtjdAG_u_cjXwsYM,12048
233
233
  matrx_graph/types/context.py,sha256=4AamW22VTE3K3IhGONg4gR3q8M9hrbMC9sFiMbYQU7M,18213
234
234
  matrx_graph/types/definition.py,sha256=noLq0ThxQQ1_N8ucLPXnXHXHmnyeKDwvSRcbSyNn2QU,12935
235
235
  matrx_graph/types/deliverables.py,sha256=-3kOWPSAKbSmqEFBhcVjYpjJ75mCycVrhOOSo3gEBOM,12550
@@ -246,14 +246,15 @@ matrx_graph/types/send.py,sha256=sKE_moS4uTB4P3L02Q7uKj0WhTzABm3vTk2o54EpoPA,254
246
246
  matrx_graph/types/usl.py,sha256=-zXXiHZpxpv7V7n_xgdcnY2l8XlWoM9ZUQNkwHYmqiQ,3376
247
247
  matrx_graph/types/value_objects.py,sha256=ylVpyj-_YQ2xhMAHVW2AdRo5yqzgXLajGUuaIZhJFQY,1295
248
248
  matrx_graph/types/variables.py,sha256=pCECEaDP3y2qwZkIlhpICRucFTMHRLW8wU3zXQBooFY,10896
249
- matrx_graph/workers/FEATURE.md,sha256=Hd6OFiBVG0HkzFt5WgJkTiDF6idX4t5CFYWLIRXqjME,16864
249
+ matrx_graph/workers/FEATURE.md,sha256=yZXybUsAbUkYDwXBNnzoJXGCedQmVK_BQCZ3AXDlogk,18502
250
250
  matrx_graph/workers/__init__.py,sha256=G8e24ryKLafH_NggiiGIb9EZ7YparOJ5DaBOw0bbPv0,2888
251
251
  matrx_graph/workers/capability.py,sha256=9kFjZCfuie5GkevLLTBJVJ3m9NeeS36RJhbjK0r86fw,11058
252
252
  matrx_graph/workers/cron_watcher.py,sha256=JVzUJ7Wmtz9PPwUtkxnSyqkheK3nEM-7lHUlecdazYY,3988
253
253
  matrx_graph/workers/event_watcher.py,sha256=DYk4RgapiJJDyKY8zirBpp3HAlqi7BWExf4rWW97FHY,6263
254
- matrx_graph/workers/queue.py,sha256=pYt9Xl_eqJi6m_7-hN77I_XXR3UWMb-e33aS6PU8k6w,28756
255
- matrx_graph/workers/runner.py,sha256=Dn5WPfKT4iD_dSbKnfBE5O5pjUA24lnTdmJvOJWurZU,21479
254
+ matrx_graph/workers/job_lease.py,sha256=pYZidqPB4hgHa8KKZqSJDGetsW7Wm_aVwSXM0ycrrfY,7213
255
+ matrx_graph/workers/queue.py,sha256=GGzuJju1DvrZFPbfvGQEMuWay-2m18NzZPNlWligS4Q,32413
256
+ matrx_graph/workers/runner.py,sha256=6ALoNst6LY9pK-8I7r0QPLjdKpSfyimp5JwFb1PFdmI,23946
256
257
  matrx_graph/workers/sleep_wake.py,sha256=1V-opM2zYtuN3n4ez85C4trYHoEapVwz8F7_MAex8HI,5970
257
- matrx_graph-0.2.330.dist-info/METADATA,sha256=E5NDq1pV9sKxeZvfKiVkcCAWoIXPDDUoS91bvYscY6c,5480
258
- matrx_graph-0.2.330.dist-info/WHEEL,sha256=W3fkpkm7-wf9vBI5Z-7s0eWkeM-spu78I8Neb98DeEg,87
259
- matrx_graph-0.2.330.dist-info/RECORD,,
258
+ matrx_graph-0.2.332.dist-info/METADATA,sha256=Xtv8QD6KJcjVl9GHAwEdySTwocg45udFubgG2r2VWYk,5480
259
+ matrx_graph-0.2.332.dist-info/WHEEL,sha256=W3fkpkm7-wf9vBI5Z-7s0eWkeM-spu78I8Neb98DeEg,87
260
+ matrx_graph-0.2.332.dist-info/RECORD,,