manta-node 0.6b5.dev762__py3-none-any.whl → 0.6b5.dev768__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.
@@ -4,7 +4,7 @@ This file is auto-generated at build time by scripts/generate_version.py.
4
4
  Do not edit manually.
5
5
  """
6
6
 
7
- __version__ = "2026.10.07.6c5d7e8c"
7
+ __version__ = "2026.10.07.bfaeeedd"
8
8
  __build_date__ = "2026.10.07"
9
- __commit_id__ = "6c5d7e8c"
9
+ __commit_id__ = "bfaeeedd"
10
10
  __package_version__ = "0.6b5" # release-series base, moved by scripts/bump_version.py
@@ -2,7 +2,9 @@ import asyncio
2
2
  import base64
3
3
  import inspect
4
4
  import json
5
+ import os
5
6
  import re
7
+ import time
6
8
  from collections.abc import Awaitable, Callable
7
9
  from datetime import datetime, timezone
8
10
  from logging import getLogger
@@ -11,7 +13,13 @@ from typing import cast
11
13
 
12
14
  import aiomqtt
13
15
  from manta_common.base_mqtt import MqttBase
14
- from manta_common.build.common.mqtt import MqMetricsSnapshot, MqTaskUpdate
16
+ from manta_common.build.common.mqtt import (
17
+ MqMetricsSnapshot,
18
+ MqNodeUpdate,
19
+ MqNodeUpdateStatus,
20
+ MqTaskUpdate,
21
+ NodeUpdateState,
22
+ )
15
23
  from manta_common.build.common.tasks import MqttTask, TaskStatus, TaskUpdate
16
24
  from manta_common.conversions import ID, b2x
17
25
  from manta_common.errors import (
@@ -24,13 +32,17 @@ from manta_common.retry import RetryPolicy
24
32
  from manta_common.traces import Tracer
25
33
 
26
34
  from ...seam import (
35
+ UPDATING_ENV,
27
36
  SeamClient,
28
37
  SeamError,
38
+ SeamState,
29
39
  SwitchOutcome,
30
40
  UpdateRequest,
41
+ is_safe_version_name,
31
42
  )
32
43
  from ...task_manager import LOG_FLUSH_INTERVAL, LogStream, TaskManager
33
44
  from ...tasks import TaskProgression
45
+ from ...update_path import is_host_owned
34
46
  from ..filesystem.dataset_manager import DatasetManager
35
47
  from ..grpc.client import NodeClient
36
48
  from ..metrics.collector import Collector
@@ -42,6 +54,19 @@ TIMEOUT = 1 # s
42
54
  MAX_LOG_UPLOAD_ATTEMPTS = 5
43
55
  MAX_MQTT_CONNECT_FAILURES = 3
44
56
 
57
+ # A restarted node replays the request that restarted it until the supervisor
58
+ # answers with the terminal outcome. The supervisor holds the request in flight
59
+ # for the return window (~40 s by default) before it can answer, so the replay
60
+ # polls across that window with headroom; past it the platform's own deadline
61
+ # settles the request.
62
+ REPLAY_POLL_SECONDS = 2.0
63
+ REPLAY_TIMEOUT_SECONDS = 120.0
64
+
65
+
66
+ def _node_update_state(state) -> NodeUpdateState:
67
+ """Map a seam state to the wire enum; they mirror 1:1 (manta-common#291)."""
68
+ return NodeUpdateState[state.name]
69
+
45
70
 
46
71
  def _is_permanent_upload_rejection(exc: Exception) -> bool:
47
72
  """Whether the gRPC status carries a rejection the retry policy will not retry."""
@@ -312,14 +337,34 @@ class CommandHandler(Collector, MqttBase):
312
337
  """
313
338
  self.tracer.debug("Starting background task checker")
314
339
  checker = asyncio.create_task(self._ping_loop())
340
+ replayer = self._start_update_replay()
315
341
  try:
316
342
  await MqttBase.main(self)
317
343
  finally:
318
344
  checker.cancel()
319
- try:
320
- await checker
321
- except asyncio.CancelledError:
322
- pass
345
+ if replayer is not None:
346
+ replayer.cancel()
347
+ for task in (checker, replayer):
348
+ if task is None:
349
+ continue
350
+ try:
351
+ await task
352
+ except asyncio.CancelledError:
353
+ pass
354
+
355
+ def _start_update_replay(self) -> asyncio.Task | None:
356
+ """Start the replay of the update that restarted this node, if any.
357
+
358
+ The supervisor set ``MANTA_NODE_UPDATING`` on this boot when it restarted
359
+ the child for an in-flight update; the node asks for that request's
360
+ outcome once its MQTT loop is up (NF-8). No supervisor, or no such boot,
361
+ means no replay.
362
+ """
363
+ request_id = os.environ.get(UPDATING_ENV)
364
+ if not request_id or self._seam_client is None:
365
+ return None
366
+ self.tracer.info(f"Replaying in-flight update {request_id} for its outcome")
367
+ return asyncio.create_task(self.report_inflight_update(request_id))
323
368
 
324
369
  async def loop_tasks(self):
325
370
  """
@@ -703,35 +748,129 @@ class CommandHandler(Collector, MqttBase):
703
748
  async def update_node(self, payload: bytes):
704
749
  """Handle an `update_node` command from the Manager (the Manager path).
705
750
 
706
- The Manager declares a pair and the node relays it to its supervisor over
707
- the local seam. The payload carries the target pair and the fields the
708
- seam's `UpdateRequest` needs; the node computes no ordering (mechanism
709
- §6.2). With no supervisor the relay is inert, and the node reports that
710
- rather than applying anything itself.
751
+ The Manager declares a target `manta-node` version in an `MqNodeUpdate`
752
+ (manta-common#291); the node validates it and relays it to its supervisor
753
+ over the local seam. The node computes no version ordering (mechanism
754
+ §6.2): it passes the declared sequence through and re-checks only
755
+ equality and staleness. With no supervisor the relay is inert, and the
756
+ node reports that rather than applying anything itself.
757
+
758
+ A host-owned node refuses the command before relaying (ADR-0042 D5) and
759
+ reports the refusal, so the platform's one-path rule holds even if a
760
+ command reaches it.
711
761
  """
712
- try:
713
- data = json.loads(payload)
714
- request = UpdateRequest(
715
- request_id=data["request_id"],
716
- target=data["target"],
717
- force=bool(data.get("force", False)),
718
- declared_sequence=data.get("declared_sequence"),
762
+ request = self._parse_update(payload)
763
+ # One update path per node (ADR-0042 D5). The platform never sends to a
764
+ # host-owned node (it fails closed on the reported path); if one arrives
765
+ # anyway, refuse it here and report the refusal rather than applying it.
766
+ if is_host_owned():
767
+ await self.publish_update_status(
768
+ SwitchOutcome(
769
+ request_id=request.request_id,
770
+ state=SeamState.REFUSED,
771
+ reason="this node is host-updated; the platform does not update it",
772
+ )
719
773
  )
720
- except (ValueError, UnicodeDecodeError, KeyError, TypeError) as exc:
721
- # A malformed payload — invalid JSON, missing request_id/target, or a
722
- # wrong shape — is reported the same way, not left to escape as a
723
- # bare KeyError past the dispatch's MantaError handling.
724
- raise MantaMQTTError(f"Malformed update_node payload: {exc}") from exc
774
+ return
725
775
  outcome = await self.relay_update(request)
726
776
  if outcome is None:
727
777
  raise MantaNodeError(
728
778
  "update_node received but no supervisor serves the seam"
729
779
  )
780
+ await self.publish_update_status(outcome)
781
+
782
+ @staticmethod
783
+ def _parse_update(payload: bytes) -> UpdateRequest:
784
+ """Validate an `MqNodeUpdate` and turn it into the seam's request.
785
+
786
+ A malformed payload — undecodable, or missing the version or the request
787
+ id — is a `MantaMQTTError`, not a bare decode error escaping the
788
+ dispatch's MantaError handling.
789
+ """
790
+ try:
791
+ command = MqNodeUpdate().parse(payload)
792
+ except Exception as exc:
793
+ raise MantaMQTTError(f"Malformed update_node payload: {exc}") from exc
794
+ if not command.node_package_version or not command.request_id:
795
+ raise MantaMQTTError(
796
+ "Malformed update_node payload: node_package_version and"
797
+ " request_id are both required"
798
+ )
799
+ if not is_safe_version_name(command.node_package_version):
800
+ # The declared version names a directory in the supervisor's version
801
+ # tree; a separator, `..` or an absolute path must never reach it.
802
+ raise MantaMQTTError(
803
+ "Malformed update_node payload: node_package_version is not a"
804
+ " valid version name"
805
+ )
806
+ return UpdateRequest(
807
+ request_id=command.request_id,
808
+ target=command.node_package_version,
809
+ force=bool(command.force),
810
+ declared_sequence=command.declared_sequence,
811
+ )
812
+
813
+ async def publish_update_status(self, outcome: SwitchOutcome) -> None:
814
+ """Publish an update outcome on `manager/update_node_status`.
815
+
816
+ The payload carries the node token, as every node → manager message does
817
+ (the manager validates it). QoS 1: the outcome is worth a redelivery
818
+ attempt, and the node may have just restarted.
819
+ """
730
820
  await self.publish_message(
731
821
  "manager/update_node_status",
732
- outcome.to_bytes(),
822
+ MqNodeUpdateStatus(
823
+ token=self.node_client.secured_token,
824
+ request_id=outcome.request_id,
825
+ state=_node_update_state(outcome.state),
826
+ reason=outcome.reason,
827
+ ),
828
+ qos=1,
733
829
  )
734
830
 
831
+ async def report_inflight_update(self, request_id: str) -> None:
832
+ """Report the outcome of the update that restarted this node (NF-8).
833
+
834
+ The switch restarted the child, so the synchronous seam reply never
835
+ reached the process that sent the request. The supervisor named the
836
+ request in `MANTA_NODE_UPDATING` on this boot; the node replays it over
837
+ the seam and publishes the terminal outcome. While the switch is still in
838
+ flight the supervisor answers REFUSED, so the replay polls.
839
+
840
+ If no outcome ever arrives (the supervisor no longer holds one), nothing
841
+ is published: the platform's deadline settles the request. That is the
842
+ accepted behaviour, stated in the PR.
843
+ """
844
+ if self._seam_client is None:
845
+ return
846
+ deadline = time.monotonic() + REPLAY_TIMEOUT_SECONDS
847
+ while True:
848
+ outcome = await self._replay_once(request_id)
849
+ if outcome is not None and outcome.state is not SeamState.REFUSED:
850
+ self.tracer.info(
851
+ f"Reporting outcome for in-flight update {request_id}:"
852
+ f" {outcome.state.value}"
853
+ )
854
+ await self.publish_update_status(outcome)
855
+ return
856
+ if time.monotonic() >= deadline:
857
+ self.tracer.warning(
858
+ f"No outcome for in-flight update {request_id} within"
859
+ f" {REPLAY_TIMEOUT_SECONDS}s; the platform's deadline settles it"
860
+ )
861
+ return
862
+ await asyncio.sleep(REPLAY_POLL_SECONDS)
863
+
864
+ async def _replay_once(self, request_id: str) -> SwitchOutcome | None:
865
+ """Ask the supervisor once for a request's outcome; None if unreachable."""
866
+ try:
867
+ return await self._seam_client.request_update(
868
+ UpdateRequest(request_id=request_id, target="", replay=True)
869
+ )
870
+ except SeamError as exc:
871
+ self.tracer.warning(f"Update outcome replay failed: {exc}")
872
+ return None
873
+
735
874
  async def on_task_setup_failed(self, task: MqttTask, exc: Exception):
736
875
  """
737
876
  Publish a FAILED task update when task setup fails.
@@ -16,6 +16,7 @@ from manta_common.build.common.informations import (
16
16
  NodeOverview,
17
17
  NodeStatus,
18
18
  NodeStatusEnum,
19
+ NodeUpdatePath,
19
20
  )
20
21
  from manta_common.build.common.system import Empty
21
22
  from manta_common.conversions import HEX_SIZE, ID, oid
@@ -38,9 +39,10 @@ from .infrastructure.mqtt.command_handler import CommandHandler
38
39
  from .infrastructure.process.executor import ProcessExecutor
39
40
  from .infrastructure.security.auth_agent import AuthAgent
40
41
  from .infrastructure.security.token_provider import InMemoryTokenProvider
41
- from .seam import seam_socket_path
42
+ from .seam import UPDATING_ENV, seam_socket_path
42
43
  from .supervisor.seam import SocketSeamClient
43
44
  from .task_manager import TaskManager
45
+ from .update_path import update_path as node_update_path
44
46
 
45
47
  __all__ = ["Node", "NodeFatalError"]
46
48
 
@@ -514,6 +516,7 @@ class Node:
514
516
  await self.check_manager_availability()
515
517
  try:
516
518
  version_info = get_full_version_info()
519
+ path = node_update_path()
517
520
  identification = NodeOverview(
518
521
  self.metrics_collector.get_platform_info(
519
522
  _execution_mode_from_config(self.config)
@@ -522,6 +525,11 @@ class Node:
522
525
  node_package_version=version_info["package_version"],
523
526
  node_build_version=version_info["version"],
524
527
  common_core_version=get_common_core_version(),
528
+ # Set at install; unknown is UNSPECIFIED, which the platform
529
+ # fails closed on (never sends an update).
530
+ update_path=(
531
+ NodeUpdatePath[path.upper()] if path else NodeUpdatePath.UNSPECIFIED
532
+ ),
525
533
  )
526
534
  registration = await self.node_client.register_node(identification)
527
535
 
@@ -691,7 +699,7 @@ class Node:
691
699
  # attributed to the update, not read as a fleet disconnect
692
700
  # (NF-8). The manager correlates the id; the node does not
693
701
  # treat this as an unexpected loss.
694
- updating = os.environ.get("MANTA_NODE_UPDATING")
702
+ updating = os.environ.get(UPDATING_ENV)
695
703
  if updating:
696
704
  self.tracer.info(
697
705
  f"Restarting for in-flight update {updating};"
manta_node/seam.py CHANGED
@@ -13,8 +13,8 @@ reason. Keeping it total (messages and the abstract client) means both sides can
13
13
  depend on it without a cycle.
14
14
 
15
15
  **Contract** (mechanism §6.4). The request carries `request_id` (idempotency),
16
- the `target` (a pair or set id — the node computes no ordering), `force`, and the
17
- `declared_sequence` the Manager passed through. The supervisor answers with a
16
+ the `target` (the `manta-node` version — the node computes no ordering), `force`,
17
+ and the `declared_sequence` the Manager passed through. The supervisor answers with a
18
18
  `state` and a `reason`. The switch restarts the child, so the outcome across a
19
19
  switch is observed **asynchronously** after the node comes back (NF-8); the seam
20
20
  carries initiation synchronously and the immediate outcome.
@@ -23,6 +23,7 @@ carries initiation synchronously and the immediate outcome.
23
23
  from __future__ import annotations
24
24
 
25
25
  import json
26
+ import re
26
27
  from dataclasses import dataclass, field
27
28
  from enum import Enum
28
29
  from pathlib import Path
@@ -32,6 +33,35 @@ from typing import Protocol, runtime_checkable
32
33
  # 0600 on the socket and its parent. It is local and same-user only (NF-9/NF-10).
33
34
  SEAM_SOCKET_NAME = "supervisor.sock"
34
35
 
36
+ # The supervisor sets this on the child's env when it restarts the child for an
37
+ # in-flight update. Its value is the request id; the restarted node replays that
38
+ # request over the seam to learn the outcome the restart cut off (NF-8/NF-6: the
39
+ # node never reads the supervisor's in-flight record; it asks).
40
+ UPDATING_ENV = "MANTA_NODE_UPDATING"
41
+
42
+ # A replay names the request it is asking about rather than a target. The
43
+ # supervisor answers a replay from the completed outcome it holds, never by
44
+ # running the lifecycle: a replay carries no target a switch could apply.
45
+ REPLAY_IN_FLIGHT_REASON = "the request is still in flight"
46
+ REPLAY_UNKNOWN_REASON = "no outcome for this request"
47
+
48
+ # A target names a directory in the version tree. It arrives over the network
49
+ # (the Manager's declared version), so it must be ONE safe path component: no
50
+ # separator, no `..`, no absolute path, or `versions_dir() / target` would
51
+ # resolve outside the tree. PEP 440 versions and the legacy pair id
52
+ # (`manta-node@X+manta-common-core@Y`) both fit this positive set.
53
+ _VERSION_NAME_RE = re.compile(r"^[A-Za-z0-9][A-Za-z0-9._+@-]*$")
54
+ MAX_VERSION_NAME = 200
55
+
56
+
57
+ def is_safe_version_name(name: str) -> bool:
58
+ """Whether ``name`` is safe as a single version-tree directory component."""
59
+ return (
60
+ isinstance(name, str)
61
+ and 0 < len(name) <= MAX_VERSION_NAME
62
+ and _VERSION_NAME_RE.match(name) is not None
63
+ )
64
+
35
65
 
36
66
  def seam_socket_path() -> Path:
37
67
  """The Unix-socket path for the seam (in the node's state directory)."""
@@ -60,16 +90,19 @@ class SeamState(str, Enum):
60
90
  class UpdateRequest:
61
91
  """A request from the running node to its supervisor.
62
92
 
63
- ``target`` is the pair (or a set id that resolves to one); the node computes
64
- no ordering, it passes the declaration through. ``declared_sequence`` is the
65
- Manager's order, opaque here. ``request_id`` de-duplicates: a repeat of a
66
- request already in flight or completed is a no-op (NF-5).
93
+ ``target`` is the ``manta-node`` version the Manager declared; the node
94
+ computes no ordering, it passes the declaration through. ``declared_sequence``
95
+ is the Manager's order, opaque here. ``request_id`` de-duplicates: a repeat of
96
+ a request already in flight or completed is a no-op (NF-5). ``replay`` marks a
97
+ restarted node asking for the outcome of the request that restarted it: the
98
+ supervisor answers from the completed outcome and never runs the lifecycle.
67
99
  """
68
100
 
69
101
  request_id: str
70
102
  target: str
71
103
  force: bool = False
72
104
  declared_sequence: int | None = None
105
+ replay: bool = False
73
106
 
74
107
  def to_bytes(self) -> bytes:
75
108
  return json.dumps(
@@ -78,6 +111,7 @@ class UpdateRequest:
78
111
  "target": self.target,
79
112
  "force": self.force,
80
113
  "declared_sequence": self.declared_sequence,
114
+ "replay": self.replay,
81
115
  }
82
116
  ).encode()
83
117
 
@@ -89,6 +123,7 @@ class UpdateRequest:
89
123
  target=data["target"],
90
124
  force=bool(data.get("force", False)),
91
125
  declared_sequence=data.get("declared_sequence"),
126
+ replay=bool(data.get("replay", False)),
92
127
  )
93
128
 
94
129
 
@@ -3,11 +3,10 @@
3
3
  Mechanism §6.4's "executor" side. ``run_switch`` (``lifecycle.py``) is the state
4
4
  machine; this module supplies its four verbs as concrete system actions:
5
5
 
6
- - **fetch** — pull the candidate pair from PyPI into a version dir (NF-10, pull
7
- only; ADR-0042 D2 says PyPI by default). At this stage the fetch materialises a
8
- version dir naming the candidate and its interpreter; the wheel install itself is
9
- the deploy-side apply (manta-deploy#1172), so the executor keeps the *decision*
10
- and the *record* honest without duplicating the installer.
6
+ - **fetch** — install the declared manta-node version into a version dir (NF-10, pull
7
+ only; ADR-0042 D2 says PyPI by default). The fetch either reuses a version dir
8
+ the fleet apply already materialised, or installs the declared version itself and
9
+ lets the wheel's pin pull the tested common-core (#509).
11
10
  - **verify** — NF-2: import the candidate entrypoint and call the reporting path in
12
11
  the candidate's own environment. The full closure check is the same subprocess
13
12
  check; a failure leaves the node on its current version.
@@ -30,7 +29,13 @@ import time
30
29
  from collections.abc import Awaitable, Callable
31
30
  from dataclasses import dataclass
32
31
 
33
- from ..seam import SeamState, SwitchOutcome, UpdateRequest
32
+ from ..seam import (
33
+ SeamState,
34
+ SwitchOutcome,
35
+ UpdateRequest,
36
+ is_safe_version_name,
37
+ )
38
+ from . import sequence
34
39
  from . import switch as version_tree
35
40
  from .bounds import Bounds, load_bounds
36
41
  from .lifecycle import run_switch
@@ -90,7 +95,7 @@ def make_executor(
90
95
  *,
91
96
  bounds: Bounds | None = None,
92
97
  verify: Callable[[str, str | None], None] = verify_candidate,
93
- child_python: str | None = None,
98
+ install: Callable[[str], str] = version_tree.install_version,
94
99
  ) -> Callable[[UpdateRequest], Awaitable[SwitchOutcome]]:
95
100
  """Build the seam handler: a request in, the lifecycle's outcome out.
96
101
 
@@ -110,16 +115,15 @@ def make_executor(
110
115
  switching = threading.Lock()
111
116
 
112
117
  def fetch(target: str) -> str:
113
- # Pull-only (NF-10). If the deploy apply has already materialised the
114
- # candidate's version dir (with its interpreter), use that interpreter —
115
- # so verification runs in the candidate's own environment, not merely the
116
- # supervisor's. Otherwise the candidate will run from the interpreter the
117
- # supervisor boots children with. The wheel install is the deploy apply;
118
- # the version dir is (re)materialised at STAGE.
118
+ # Pull-only (NF-10). A version dir the fleet path already materialised
119
+ # (with its interpreter) is reused, so verification runs in the
120
+ # candidate's own environment. Otherwise INSTALL it: the Manager path
121
+ # installs the declared manta-node version into a fresh version dir and
122
+ # lets the wheel's pin pull the common-core it was tested with (#509).
119
123
  manifest = version_tree.read_manifest(version_tree.versions_dir() / target)
120
124
  if manifest is not None:
121
125
  return manifest.python
122
- return child_python or sys.executable
126
+ return install(target)
123
127
 
124
128
  def restart() -> None:
125
129
  control.restart()
@@ -134,6 +138,17 @@ def make_executor(
134
138
  return control.is_alive()
135
139
 
136
140
  async def handler(request: UpdateRequest) -> SwitchOutcome:
141
+ # The target names a directory in the version tree and arrives over the
142
+ # network, so refuse anything that is not one safe component before the
143
+ # path is ever built (a replay never reaches here: the seam server
144
+ # answers it from the outcome). Defence in depth behind the MQTT
145
+ # handler's own check.
146
+ if not is_safe_version_name(request.target):
147
+ return SwitchOutcome(
148
+ request_id=request.request_id,
149
+ state=SeamState.REFUSED,
150
+ reason="target is not a valid version name",
151
+ )
137
152
  # Refuse a second switch while one is in flight (NF-5): a switch restarts
138
153
  # the child and moves `current`, so concurrency there is a race, not a
139
154
  # queue. `acquire(blocking=False)` makes the check atomic.
@@ -144,6 +159,21 @@ def make_executor(
144
159
  reason="a switch is already in flight",
145
160
  )
146
161
  try:
162
+ # STALE — refuse a request whose declared sequence is lower than the
163
+ # last accepted one (ADR-0042 D4). The sequence orders REQUESTS, not
164
+ # versions; the node computes no version ordering. A replay carries
165
+ # no sequence and is answered by the seam server before reaching here.
166
+ if request.declared_sequence is not None and not sequence.accept_sequence(
167
+ request.declared_sequence
168
+ ):
169
+ return SwitchOutcome(
170
+ request_id=request.request_id,
171
+ state=SeamState.REFUSED,
172
+ reason=(
173
+ "stale request: declared_sequence"
174
+ f" {request.declared_sequence} is lower than the last accepted"
175
+ ),
176
+ )
147
177
  # The lifecycle is synchronous (it blocks through the return window);
148
178
  # run it in a worker thread so it does not block the seam's event
149
179
  # loop, which is serving the socket the request arrived on.
@@ -21,12 +21,22 @@ from pathlib import Path
21
21
  from .records import read_json, write_json_atomic
22
22
 
23
23
  INFLIGHT_NAME = "inflight.json"
24
+ # The last terminal outcome, written when a switch resolves and read when a
25
+ # restarted node replays its request. It outlives the in-flight record and a
26
+ # supervisor restart, so a replay is answered from the fact rather than from
27
+ # memory alone (NF-1/NF-8). The node never reads this file: it asks the
28
+ # supervisor over the seam (ADR-0041 D11).
29
+ OUTCOME_NAME = "update_outcome.json"
24
30
 
25
31
 
26
32
  def inflight_path() -> Path:
27
33
  return Path.home() / ".manta" / "nodes" / INFLIGHT_NAME
28
34
 
29
35
 
36
+ def outcome_path() -> Path:
37
+ return Path.home() / ".manta" / "nodes" / OUTCOME_NAME
38
+
39
+
30
40
  @dataclass(frozen=True)
31
41
  class InFlight:
32
42
  """The durable record of a switch in progress."""
@@ -80,3 +90,50 @@ def clear_inflight(path: Path | None = None) -> None:
80
90
  def deadline_from_now(window: float) -> float:
81
91
  """The deadline for a window that starts now (the window is about the switch)."""
82
92
  return time.time() + window
93
+
94
+
95
+ @dataclass(frozen=True)
96
+ class UpdateOutcome:
97
+ """The terminal outcome of one update request, durable for a replay.
98
+
99
+ ``state`` is a ``SeamState`` value string; the seam server converts it back
100
+ when it answers. Only a terminal (non-REFUSED) outcome is stored, so a replay
101
+ cannot read back a transient refusal as a verdict.
102
+ """
103
+
104
+ request_id: str
105
+ state: str
106
+ reason: str
107
+ detail: dict
108
+
109
+ def to_dict(self) -> dict:
110
+ return {
111
+ "request_id": self.request_id,
112
+ "state": self.state,
113
+ "reason": self.reason,
114
+ "detail": self.detail,
115
+ }
116
+
117
+ @classmethod
118
+ def from_dict(cls, data: dict) -> UpdateOutcome:
119
+ return cls(
120
+ request_id=data["request_id"],
121
+ state=data["state"],
122
+ reason=data.get("reason", ""),
123
+ detail=data.get("detail", {}),
124
+ )
125
+
126
+
127
+ def write_outcome(outcome: UpdateOutcome, path: Path | None = None) -> None:
128
+ write_json_atomic(path or outcome_path(), outcome.to_dict())
129
+
130
+
131
+ def read_outcome(path: Path | None = None) -> UpdateOutcome | None:
132
+ """The last terminal outcome, or None when there is none/one is malformed."""
133
+ data = read_json(path or outcome_path())
134
+ if not data:
135
+ return None
136
+ try:
137
+ return UpdateOutcome.from_dict(data)
138
+ except (KeyError, TypeError, ValueError):
139
+ return None
@@ -50,7 +50,7 @@ class SwitchContext:
50
50
 
51
51
 
52
52
  class Fetcher(Protocol):
53
- """Fetch a candidate pair; return the interpreter it will run from.
53
+ """Fetch a candidate version; return the interpreter it will run from.
54
54
 
55
55
  The interpreter names the candidate's **own environment**, which VERIFY runs
56
56
  in (NF-2). The version dir is materialised at STAGE, after verification.
@@ -109,12 +109,12 @@ def run_switch(
109
109
  """
110
110
  bounds = bounds or Bounds()
111
111
 
112
- # NO-OP — applying the pair already current changes nothing (NF-5). Return
112
+ # NO-OP — applying the version already current changes nothing (NF-5). Return
113
113
  # before fetch/verify/stage/commit, so the child is NOT restarted: under B1 a
114
114
  # re-apply would otherwise be a full switch and restart the node for no change
115
115
  # (the daily loop that never converges). `force` does not bypass this: it is
116
116
  # the not-newer override, and a deliberate bounce is a unit restart.
117
- if version_tree.is_current_pair(request.target):
117
+ if version_tree.is_current_version(request.target):
118
118
  return _outcome(request, SeamState.SKIPPED, "target is already current")
119
119
 
120
120
  last_good_dir = version_tree.selected()