codexloop 0.2.0__py3-none-any.whl → 0.3.0__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.
codexloop/__init__.py CHANGED
@@ -1,3 +1,3 @@
1
1
  """codexloop — onion-architected, autonomous OpenAI Codex session runner."""
2
2
 
3
- __version__ = "0.2.0"
3
+ __version__ = "0.3.0"
@@ -0,0 +1,75 @@
1
+ """Application interfaces -- every seam implemented by infrastructure/ and
2
+ never imported from it.
3
+
4
+ One module per collaborator family so a reader finds a seam by what it does
5
+ rather than by scrolling one long file. `application/ports.py` re-exports
6
+ this package unchanged, so existing imports keep working.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ from codexloop.application.interfaces.agent import (
12
+ AgentGateway,
13
+ CapacityProbe,
14
+ RunResources,
15
+ ThreadCatalog,
16
+ )
17
+ from codexloop.application.interfaces.api import (
18
+ ApiGateway,
19
+ )
20
+ from codexloop.application.interfaces.control import (
21
+ ControlInbox,
22
+ RunControl,
23
+ )
24
+ from codexloop.application.interfaces.doctor import (
25
+ DoctorCheck,
26
+ DoctorEnvironment,
27
+ DoctorReport,
28
+ )
29
+ from codexloop.application.interfaces.observability import (
30
+ AuditLog,
31
+ Logger,
32
+ Notifier,
33
+ ProgressReporter,
34
+ RunEventSink,
35
+ StateBus,
36
+ )
37
+ from codexloop.application.interfaces.permissions import (
38
+ PermissionMode,
39
+ )
40
+ from codexloop.application.interfaces.storage import (
41
+ RunSnapshotSink,
42
+ RunStateStore,
43
+ SavePointStore,
44
+ SessionLock,
45
+ )
46
+ from codexloop.application.interfaces.system import (
47
+ Clock,
48
+ Sleeper,
49
+ )
50
+
51
+ __all__ = [
52
+ "AgentGateway",
53
+ "ApiGateway",
54
+ "AuditLog",
55
+ "CapacityProbe",
56
+ "Clock",
57
+ "ControlInbox",
58
+ "DoctorCheck",
59
+ "DoctorEnvironment",
60
+ "DoctorReport",
61
+ "Logger",
62
+ "Notifier",
63
+ "PermissionMode",
64
+ "ProgressReporter",
65
+ "RunControl",
66
+ "RunEventSink",
67
+ "RunResources",
68
+ "RunSnapshotSink",
69
+ "RunStateStore",
70
+ "SavePointStore",
71
+ "SessionLock",
72
+ "Sleeper",
73
+ "StateBus",
74
+ "ThreadCatalog",
75
+ ]
@@ -0,0 +1,46 @@
1
+ """The vendor session seam: sending turns, probing capacity, enumerating
2
+ sessions, and the run-scoped resources attached to one."""
3
+
4
+ from __future__ import annotations
5
+
6
+ from collections.abc import Mapping, Sequence
7
+ from typing import Protocol, runtime_checkable
8
+
9
+ from codexloop.application.dto import ProbeResult, TurnOutcome
10
+ from codexloop.application.interfaces.permissions import PermissionMode
11
+ from codexloop.domain.model_profile import ModelEffortProfile
12
+ from codexloop.domain.session import ThreadRef
13
+
14
+
15
+ @runtime_checkable
16
+ class AgentGateway(Protocol):
17
+ """Vendor-agnostic agent session (exec and app-server adapters implement this)."""
18
+
19
+ async def send_turn(self, prompt: str) -> TurnOutcome: ...
20
+ async def close(self) -> None: ...
21
+ async def set_profile(self, profile: ModelEffortProfile) -> None: ...
22
+ async def set_permission_mode(self, mode: PermissionMode) -> None: ...
23
+ async def set_cwd(self, path: str) -> None: ...
24
+ async def set_session_resources(self, resources: Mapping[str, object]) -> None: ...
25
+ def resolve_tool_approval(self, request_id: str, *, allow: bool, reason: str = "") -> bool: ...
26
+
27
+
28
+ @runtime_checkable
29
+ class CapacityProbe(Protocol):
30
+ async def probe(self) -> ProbeResult: ...
31
+
32
+
33
+ @runtime_checkable
34
+ class ThreadCatalog(Protocol):
35
+ """Our run registry keyed by ``thread_id``, not vendor session discovery."""
36
+
37
+ def list_threads(self) -> Sequence[ThreadRef]: ...
38
+ def get(self, thread_id: str) -> ThreadRef | None: ...
39
+ def record(self, ref: ThreadRef) -> None: ...
40
+
41
+
42
+ @runtime_checkable
43
+ class RunResources(Protocol):
44
+ """Placeholder for run-scoped attachments applied mid-run."""
45
+
46
+ def as_payload(self) -> Mapping[str, object]: ...
@@ -0,0 +1,10 @@
1
+ """The generated REST surface seam."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from typing import Protocol, runtime_checkable
6
+
7
+
8
+ @runtime_checkable
9
+ class ApiGateway(Protocol):
10
+ def invoke(self, method_path: str, **kwargs: object) -> object: ...
@@ -0,0 +1,21 @@
1
+ """The out-of-band control plane: commands an operator drops for a live run."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from collections.abc import Sequence
6
+ from pathlib import Path
7
+ from typing import Protocol, runtime_checkable
8
+
9
+ from codexloop.domain.control import ControlCommand
10
+
11
+
12
+ @runtime_checkable
13
+ class RunControl(Protocol):
14
+ def poll(self) -> Sequence[ControlCommand]: ...
15
+
16
+
17
+ @runtime_checkable
18
+ class ControlInbox(Protocol):
19
+ """Where an out-of-band command is dropped for a live run to pick up."""
20
+
21
+ def enqueue(self, command: ControlCommand) -> Path: ...
@@ -0,0 +1,35 @@
1
+ """What `doctor` needs from the outside world, and the vocabulary it answers in.
2
+
3
+ The result types live here alongside the Protocol: they are part of the seam's
4
+ vocabulary, not of the use case that consumes it.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ from collections.abc import Mapping
10
+ from dataclasses import dataclass, field
11
+ from pathlib import Path
12
+ from typing import Protocol, runtime_checkable
13
+
14
+
15
+ @dataclass(frozen=True, slots=True)
16
+ class DoctorCheck:
17
+ name: str
18
+ passed: bool
19
+ detail: str
20
+
21
+
22
+ @dataclass(frozen=True, slots=True)
23
+ class DoctorReport:
24
+ checks: tuple[DoctorCheck, ...]
25
+ auth_mode: str
26
+ probe_strategies: Mapping[str, bool] = field(default_factory=dict)
27
+
28
+ @property
29
+ def all_passed(self) -> bool:
30
+ return all(check.passed for check in self.checks)
31
+
32
+
33
+ @runtime_checkable
34
+ class DoctorEnvironment(Protocol):
35
+ def diagnose(self, *, cwd: Path) -> DoctorReport: ...
@@ -0,0 +1,42 @@
1
+ """Everything the run emits outward: logs, audit records, progress, events,
2
+ state publications, usage reads, and operator notifications."""
3
+
4
+ from __future__ import annotations
5
+
6
+ from collections.abc import Callable, Mapping
7
+ from typing import Protocol, runtime_checkable
8
+
9
+
10
+ @runtime_checkable
11
+ class ProgressReporter(Protocol):
12
+ def report(self, event: str, **detail: object) -> None: ...
13
+
14
+
15
+ @runtime_checkable
16
+ class AuditLog(Protocol):
17
+ def append(self, event_type: str, payload: Mapping[str, object]) -> None: ...
18
+
19
+
20
+ @runtime_checkable
21
+ class Notifier(Protocol):
22
+ def notify(self, title: str, body: str) -> None: ...
23
+
24
+
25
+ @runtime_checkable
26
+ class Logger(Protocol):
27
+ def bind(self, **kwargs: object) -> Logger: ...
28
+ def debug(self, event: str, **kwargs: object) -> None: ...
29
+ def info(self, event: str, **kwargs: object) -> None: ...
30
+ def warning(self, event: str, **kwargs: object) -> None: ...
31
+ def error(self, event: str, **kwargs: object) -> None: ...
32
+
33
+
34
+ @runtime_checkable
35
+ class RunEventSink(Protocol):
36
+ def emit(self, event: Mapping[str, object]) -> None: ...
37
+
38
+
39
+ @runtime_checkable
40
+ class StateBus(Protocol):
41
+ def publish(self, event_type: str, payload: Mapping[str, object]) -> None: ...
42
+ def subscribe(self, callback: Callable[[str, Mapping[str, object]], None]) -> None: ...
@@ -0,0 +1,14 @@
1
+ """Permission vocabulary shared across the seam, so vendor CLI flag strings
2
+ never leak up into the application layer."""
3
+
4
+ from __future__ import annotations
5
+
6
+ from enum import StrEnum
7
+
8
+
9
+ class PermissionMode(StrEnum):
10
+ """Autonomy posture at the application boundary — not Codex CLI flag strings."""
11
+
12
+ AUTONOMOUS = "autonomous"
13
+ READ_ONLY = "read_only"
14
+ FULL_ACCESS = "full_access"
@@ -0,0 +1,31 @@
1
+ """Durable run state: the state store, the run lock, git save points, and the
2
+ snapshot sink."""
3
+
4
+ from __future__ import annotations
5
+
6
+ from collections.abc import Mapping, Sequence
7
+ from typing import Protocol, runtime_checkable
8
+
9
+
10
+ @runtime_checkable
11
+ class RunStateStore(Protocol):
12
+ def load(self, run_id: str) -> dict[str, object] | None: ...
13
+ def save(self, run_id: str, state: Mapping[str, object]) -> None: ...
14
+
15
+
16
+ @runtime_checkable
17
+ class SessionLock(Protocol):
18
+ def acquire(self, thread_id: str) -> bool: ...
19
+ def release(self, thread_id: str) -> None: ...
20
+
21
+
22
+ @runtime_checkable
23
+ class SavePointStore(Protocol):
24
+ def create(self, run_id: str, label: str) -> str: ...
25
+ def list(self, run_id: str) -> Sequence[str]: ...
26
+ def unwind(self, run_id: str, to: str) -> None: ...
27
+
28
+
29
+ @runtime_checkable
30
+ class RunSnapshotSink(Protocol):
31
+ def write(self, snapshot: Mapping[str, object]) -> None: ...
@@ -0,0 +1,16 @@
1
+ """Time and sleeping -- the two ambient effects the run loop needs faked."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from datetime import datetime
6
+ from typing import Protocol, runtime_checkable
7
+
8
+
9
+ @runtime_checkable
10
+ class Clock(Protocol):
11
+ def now(self) -> datetime: ...
12
+
13
+
14
+ @runtime_checkable
15
+ class Sleeper(Protocol):
16
+ async def sleep_until(self, when: datetime) -> None: ...
@@ -1,19 +1,38 @@
1
- """Application ports — Protocols implemented by infrastructure, never imported from it.
1
+ """Backwards-compatible re-export of `application.interfaces`.
2
2
 
3
- ``application/`` knows the shape of a collaborator, never a concrete adapter.
3
+ The Protocols moved into `application/interfaces/` so every seam lives in
4
+ one discoverable place, one module per collaborator family. This shim keeps
5
+ the old `from codexloop.application.ports import X` path working; new code
6
+ should import from `codexloop.application.interfaces`.
4
7
  """
5
8
 
6
9
  from __future__ import annotations
7
10
 
8
- from collections.abc import Callable, Mapping, Sequence
9
- from datetime import datetime
10
- from enum import StrEnum
11
- from typing import Protocol, runtime_checkable
12
-
13
- from codexloop.application.dto import ProbeResult, TurnOutcome
14
- from codexloop.domain.control import ControlCommand
15
- from codexloop.domain.model_profile import ModelEffortProfile
16
- from codexloop.domain.session import ThreadRef
11
+ from codexloop.application.interfaces import (
12
+ AgentGateway,
13
+ ApiGateway,
14
+ AuditLog,
15
+ CapacityProbe,
16
+ Clock,
17
+ ControlInbox,
18
+ DoctorCheck,
19
+ DoctorEnvironment,
20
+ DoctorReport,
21
+ Logger,
22
+ Notifier,
23
+ PermissionMode,
24
+ ProgressReporter,
25
+ RunControl,
26
+ RunEventSink,
27
+ RunResources,
28
+ RunSnapshotSink,
29
+ RunStateStore,
30
+ SavePointStore,
31
+ SessionLock,
32
+ Sleeper,
33
+ StateBus,
34
+ ThreadCatalog,
35
+ )
17
36
 
18
37
  __all__ = [
19
38
  "AgentGateway",
@@ -21,6 +40,10 @@ __all__ = [
21
40
  "AuditLog",
22
41
  "CapacityProbe",
23
42
  "Clock",
43
+ "ControlInbox",
44
+ "DoctorCheck",
45
+ "DoctorEnvironment",
46
+ "DoctorReport",
24
47
  "Logger",
25
48
  "Notifier",
26
49
  "PermissionMode",
@@ -36,123 +59,3 @@ __all__ = [
36
59
  "StateBus",
37
60
  "ThreadCatalog",
38
61
  ]
39
-
40
-
41
- class PermissionMode(StrEnum):
42
- """Autonomy posture at the application boundary — not Codex CLI flag strings."""
43
-
44
- AUTONOMOUS = "autonomous"
45
- READ_ONLY = "read_only"
46
- FULL_ACCESS = "full_access"
47
-
48
-
49
- @runtime_checkable
50
- class Clock(Protocol):
51
- def now(self) -> datetime: ...
52
-
53
-
54
- @runtime_checkable
55
- class Sleeper(Protocol):
56
- async def sleep_until(self, when: datetime) -> None: ...
57
-
58
-
59
- @runtime_checkable
60
- class AgentGateway(Protocol):
61
- """Vendor-agnostic agent session (exec and app-server adapters implement this)."""
62
-
63
- async def send_turn(self, prompt: str) -> TurnOutcome: ...
64
- async def close(self) -> None: ...
65
- async def set_profile(self, profile: ModelEffortProfile) -> None: ...
66
- async def set_permission_mode(self, mode: PermissionMode) -> None: ...
67
- async def set_cwd(self, path: str) -> None: ...
68
- async def set_session_resources(self, resources: Mapping[str, object]) -> None: ...
69
- def resolve_tool_approval(self, request_id: str, *, allow: bool, reason: str = "") -> bool: ...
70
-
71
-
72
- @runtime_checkable
73
- class CapacityProbe(Protocol):
74
- async def probe(self) -> ProbeResult: ...
75
-
76
-
77
- @runtime_checkable
78
- class ThreadCatalog(Protocol):
79
- """Our run registry keyed by ``thread_id``, not vendor session discovery."""
80
-
81
- def list_threads(self) -> Sequence[ThreadRef]: ...
82
- def get(self, thread_id: str) -> ThreadRef | None: ...
83
- def record(self, ref: ThreadRef) -> None: ...
84
-
85
-
86
- @runtime_checkable
87
- class ProgressReporter(Protocol):
88
- def report(self, event: str, **detail: object) -> None: ...
89
-
90
-
91
- @runtime_checkable
92
- class AuditLog(Protocol):
93
- def append(self, event_type: str, payload: Mapping[str, object]) -> None: ...
94
-
95
-
96
- @runtime_checkable
97
- class Notifier(Protocol):
98
- def notify(self, title: str, body: str) -> None: ...
99
-
100
-
101
- @runtime_checkable
102
- class Logger(Protocol):
103
- def bind(self, **kwargs: object) -> Logger: ...
104
- def info(self, event: str, **kwargs: object) -> None: ...
105
- def warning(self, event: str, **kwargs: object) -> None: ...
106
- def error(self, event: str, **kwargs: object) -> None: ...
107
-
108
-
109
- @runtime_checkable
110
- class RunStateStore(Protocol):
111
- def load(self, run_id: str) -> dict[str, object] | None: ...
112
- def save(self, run_id: str, state: Mapping[str, object]) -> None: ...
113
-
114
-
115
- @runtime_checkable
116
- class SessionLock(Protocol):
117
- def acquire(self, thread_id: str) -> bool: ...
118
- def release(self, thread_id: str) -> None: ...
119
-
120
-
121
- @runtime_checkable
122
- class RunControl(Protocol):
123
- def poll(self) -> Sequence[ControlCommand]: ...
124
-
125
-
126
- @runtime_checkable
127
- class RunEventSink(Protocol):
128
- def emit(self, event: Mapping[str, object]) -> None: ...
129
-
130
-
131
- @runtime_checkable
132
- class StateBus(Protocol):
133
- def publish(self, event_type: str, payload: Mapping[str, object]) -> None: ...
134
- def subscribe(self, callback: Callable[[str, Mapping[str, object]], None]) -> None: ...
135
-
136
-
137
- @runtime_checkable
138
- class SavePointStore(Protocol):
139
- def create(self, run_id: str, label: str) -> str: ...
140
- def list(self, run_id: str) -> Sequence[str]: ...
141
- def unwind(self, run_id: str, to: str) -> None: ...
142
-
143
-
144
- @runtime_checkable
145
- class RunSnapshotSink(Protocol):
146
- def write(self, snapshot: Mapping[str, object]) -> None: ...
147
-
148
-
149
- @runtime_checkable
150
- class ApiGateway(Protocol):
151
- def invoke(self, method_path: str, **kwargs: object) -> object: ...
152
-
153
-
154
- @runtime_checkable
155
- class RunResources(Protocol):
156
- """Placeholder for run-scoped attachments applied mid-run."""
157
-
158
- def as_payload(self) -> Mapping[str, object]: ...
@@ -12,6 +12,7 @@ from codexloop.application.ports import (
12
12
  AgentGateway,
13
13
  CapacityProbe,
14
14
  Clock,
15
+ Logger,
15
16
  Notifier,
16
17
  PermissionMode,
17
18
  ProgressReporter,
@@ -45,6 +46,14 @@ from codexloop.domain.control import (
45
46
  Stop,
46
47
  )
47
48
  from codexloop.domain.error_codes import ErrorClass, classify_code
49
+ from codexloop.domain.forecast import (
50
+ BurnRate,
51
+ WindDown,
52
+ WindDownPolicy,
53
+ forecast,
54
+ should_wind_down,
55
+ )
56
+ from codexloop.domain.handoff_marker import HandoffMarker
48
57
  from codexloop.domain.loop import (
49
58
  BackoffUntil,
50
59
  Drain,
@@ -55,6 +64,7 @@ from codexloop.domain.loop import (
55
64
  RunState,
56
65
  SendTurn,
57
66
  WaitUntil,
67
+ WindDownAndFinish,
58
68
  )
59
69
  from codexloop.domain.model_profile import Effort, ModelEffortProfile
60
70
  from codexloop.domain.plan import WorkPlan
@@ -66,6 +76,26 @@ _FAR_FUTURE = timedelta(days=3650)
66
76
  _ZERO = timedelta(0)
67
77
 
68
78
 
79
+ class _NullLogger:
80
+ """Diagnostics are optional; a run must not require them to be wired."""
81
+
82
+ def bind(self, **kwargs: object) -> _NullLogger:
83
+ del kwargs
84
+ return self
85
+
86
+ def debug(self, event: str, **kwargs: object) -> None:
87
+ del event, kwargs
88
+
89
+ def info(self, event: str, **kwargs: object) -> None:
90
+ del event, kwargs
91
+
92
+ def warning(self, event: str, **kwargs: object) -> None:
93
+ del event, kwargs
94
+
95
+ def error(self, event: str, **kwargs: object) -> None:
96
+ del event, kwargs
97
+
98
+
69
99
  def _discard_artifact(name: str, content: str) -> None:
70
100
  del name, content
71
101
 
@@ -90,6 +120,9 @@ class RunnerContext:
90
120
  )
91
121
  wait_policy: AdaptiveWaitPolicy | None = None
92
122
  max_wait: timedelta | None = None
123
+ logger: Logger | None = None
124
+ handoff_marker_writer: Callable[[HandoffMarker], None] | None = None
125
+ wind_down_policy: WindDownPolicy = field(default_factory=WindDownPolicy)
93
126
  run_id: str = "anonymous"
94
127
  cwd: str = "."
95
128
  model: str = "unknown"
@@ -110,6 +143,9 @@ class AutonomousRunner:
110
143
  self._notifier = ctx.notifier
111
144
  self._reporter = ctx.reporter
112
145
  self._budget = ctx.budget
146
+ self._log = ctx.logger or _NullLogger()
147
+ self._handoff_marker_writer = ctx.handoff_marker_writer
148
+ self._wind_down_policy = ctx.wind_down_policy
113
149
  self._wait_policy = ctx.wait_policy or AdaptiveWaitPolicy(WaitConfig(), rand=lambda: 0.0)
114
150
  self._max_wait = ctx.max_wait
115
151
  self._run_id = ctx.run_id
@@ -188,7 +224,11 @@ class AutonomousRunner:
188
224
  self._record_thread(thread_id, started)
189
225
  wait_attempt = 0
190
226
  self._quota_notified = False
191
- outcome = LoopOutcome(capacity=capacity, completion=completion)
227
+ outcome = LoopOutcome(
228
+ capacity=capacity,
229
+ completion=completion,
230
+ wind_down=self._project_wind_down(capacity),
231
+ )
192
232
  case Probe():
193
233
  probed = await self._probe.probe()
194
234
  outcome = LoopOutcome(capacity=probed.outcome)
@@ -207,6 +247,22 @@ class AutonomousRunner:
207
247
  first_turn_done=not first_turn,
208
248
  plan_text=plan_text,
209
249
  )
250
+ case WindDownAndFinish() as wound_down:
251
+ self._write_stop_summary(thread_id, remaining)
252
+ self._persist(
253
+ key=thread_id,
254
+ remaining=remaining,
255
+ first_turn_done=not first_turn,
256
+ plan_text=plan_text,
257
+ reason=f"wind-down: {wound_down.reason}",
258
+ )
259
+ self._write_handoff_marker(wound_down, thread_id, remaining)
260
+ return RunResult(
261
+ success=False,
262
+ reason=f"wind-down: {wound_down.reason}",
263
+ turns=self._ledger.turns,
264
+ thread_id=thread_id,
265
+ )
210
266
  case Finish() as finish:
211
267
  self._persist(
212
268
  key=thread_id,
@@ -401,6 +457,64 @@ class AutonomousRunner:
401
457
  )
402
458
  self._write_artifact("stop-summary.md", body)
403
459
 
460
+ def _project_wind_down(self, capacity: CapacityState) -> WindDown | None:
461
+ """Forecast remaining capacity, but only while the vendor says we are
462
+ not already blocked.
463
+
464
+ Returning None for every non-Available state is what keeps the plan
465
+ windows from ever influencing whether a turn is *sent*: once a real
466
+ rejection lands, the state machine's capacity routing owns it.
467
+ """
468
+ if not isinstance(capacity, Available):
469
+ return None
470
+ now = self._clock.now()
471
+ turns = self._ledger.turns
472
+ projection = forecast(
473
+ capacity,
474
+ turns_spent=turns,
475
+ max_turns=self._budget.max_turns,
476
+ dollars_spent=self._ledger.dollars,
477
+ max_dollars=self._budget.max_dollars,
478
+ observed=BurnRate(
479
+ turns=turns,
480
+ elapsed_seconds=self._ledger.elapsed.total_seconds(),
481
+ dollars=self._ledger.dollars,
482
+ ),
483
+ capacity_as_of=now,
484
+ now=now,
485
+ policy=self._wind_down_policy,
486
+ )
487
+ self._log.debug(
488
+ "capacity.forecast",
489
+ headroom=projection.binding.fraction,
490
+ source=projection.binding.source,
491
+ turns_until_exhaustion=projection.turns_until_exhaustion,
492
+ )
493
+ return should_wind_down(projection, self._wind_down_policy, turns_spent=turns)
494
+
495
+ def _write_handoff_marker(
496
+ self, decision: WindDownAndFinish, thread_id: str | None, remaining: list[str]
497
+ ) -> None:
498
+ """Written last, after the summary and state it names, so that a marker
499
+ on disk means everything it points at is on disk."""
500
+ if self._handoff_marker_writer is None:
501
+ return
502
+ binding = decision.forecast.binding
503
+ self._handoff_marker_writer(
504
+ HandoffMarker(
505
+ run_id=self._run_id,
506
+ reason=decision.reason,
507
+ produced_at=self._clock.now(),
508
+ headroom=binding.fraction,
509
+ headroom_source=binding.source,
510
+ resets_at=binding.resets_at,
511
+ session_id=thread_id,
512
+ turns_spent=self._ledger.turns,
513
+ dollars_spent=self._ledger.dollars,
514
+ remaining_work=tuple(remaining),
515
+ )
516
+ )
517
+
404
518
 
405
519
  def _permission_mode(sandbox: SandboxMode) -> PermissionMode:
406
520
  match sandbox: