froid-loop 0.11.1__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.
Files changed (116) hide show
  1. froid_loop/__init__.py +11 -0
  2. froid_loop/__main__.py +12 -0
  3. froid_loop/adapters/__init__.py +3 -0
  4. froid_loop/adapters/base.py +254 -0
  5. froid_loop/adapters/entrypoints.py +63 -0
  6. froid_loop/adapters/env_fault.py +290 -0
  7. froid_loop/adapters/generic.py +2013 -0
  8. froid_loop/adapters/mock.py +49 -0
  9. froid_loop/adapters/multiplexer.py +914 -0
  10. froid_loop/adapters/opencode_http.py +1687 -0
  11. froid_loop/adapters/profile.py +650 -0
  12. froid_loop/adapters/psmux_backend.py +1428 -0
  13. froid_loop/adapters/registry.py +322 -0
  14. froid_loop/adapters/tmux_backend.py +35 -0
  15. froid_loop/adapters/tmux_base.py +630 -0
  16. froid_loop/checks.py +187 -0
  17. froid_loop/cli.py +5041 -0
  18. froid_loop/data/__init__.py +0 -0
  19. froid_loop/data/froid_loop_hook.py +228 -0
  20. froid_loop/data/froid_loop_probe_hook.py +88 -0
  21. froid_loop/data/plugins/example/plugin.toml +21 -0
  22. froid_loop/data/plugins/tea/plugin.toml +184 -0
  23. froid_loop/data/plugins/tea/tea_plugin.py +258 -0
  24. froid_loop/data/plugins/unity/plugin.toml +140 -0
  25. froid_loop/data/plugins/unity/unity_assets/FroidLoop.Unity.Editor.asmdef +16 -0
  26. froid_loop/data/plugins/unity/unity_assets/FroidLoop.Unity.Editor.asmdef.meta +7 -0
  27. froid_loop/data/plugins/unity/unity_assets/SceneAutoSaveGuard.cs +221 -0
  28. froid_loop/data/plugins/unity/unity_assets/SceneAutoSaveGuard.cs.meta +11 -0
  29. froid_loop/data/plugins/unity/unity_assets/_folders/Editor.meta +8 -0
  30. froid_loop/data/plugins/unity/unity_assets/_folders/FroidLoop.meta +8 -0
  31. froid_loop/data/plugins/unity/unity_cleanup.py +125 -0
  32. froid_loop/data/plugins/unity/unity_dialog_probe.py +239 -0
  33. froid_loop/data/plugins/unity/unity_facts.md +17 -0
  34. froid_loop/data/plugins/unity/unity_plugin.py +415 -0
  35. froid_loop/data/plugins/unity/unity_quiesce.py +234 -0
  36. froid_loop/data/plugins/unity/unity_ready.py +230 -0
  37. froid_loop/data/plugins/unity/unity_seed_assets.py +298 -0
  38. froid_loop/data/plugins/unity/unity_setup.py +551 -0
  39. froid_loop/data/plugins/unity/unity_teardown.py +362 -0
  40. froid_loop/data/profiles/antigravity.toml +52 -0
  41. froid_loop/data/profiles/claude.toml +85 -0
  42. froid_loop/data/profiles/codex.toml +22 -0
  43. froid_loop/data/profiles/copilot.toml +52 -0
  44. froid_loop/data/profiles/gemini.toml +26 -0
  45. froid_loop/data/profiles/opencode.toml +54 -0
  46. froid_loop/data/settings/core.toml +458 -0
  47. froid_loop/data/skills/README.md +93 -0
  48. froid_loop/data/skills/froid-loop-resolve/SKILL.md +288 -0
  49. froid_loop/data/skills/froid-loop-setup/SKILL.md +161 -0
  50. froid_loop/data/skills/froid-loop-setup/assets/module-help.csv +3 -0
  51. froid_loop/data/skills/froid-loop-setup/assets/module.yaml +19 -0
  52. froid_loop/data/skills/froid-loop-sweep/SKILL.md +100 -0
  53. froid_loop/data/skills/froid-loop-sweep/automation-mode.md +127 -0
  54. froid_loop/data/skills/froid-loop-sweep/deferred-work-format.md +302 -0
  55. froid_loop/data/skills/froid-loop-sweep/migration-mode.md +86 -0
  56. froid_loop/decisions.py +202 -0
  57. froid_loop/deferredwork.py +2282 -0
  58. froid_loop/devcontract.py +892 -0
  59. froid_loop/diagnostics.py +1104 -0
  60. froid_loop/documents.py +532 -0
  61. froid_loop/engine.py +7732 -0
  62. froid_loop/envvars.py +111 -0
  63. froid_loop/escalation.py +225 -0
  64. froid_loop/events.py +266 -0
  65. froid_loop/fences.py +103 -0
  66. froid_loop/froidconfig.py +226 -0
  67. froid_loop/frontmatter.py +526 -0
  68. froid_loop/gates.py +133 -0
  69. froid_loop/install.py +2936 -0
  70. froid_loop/journal.py +178 -0
  71. froid_loop/machine.py +148 -0
  72. froid_loop/model.py +898 -0
  73. froid_loop/operatoractions.py +474 -0
  74. froid_loop/platform_util.py +1490 -0
  75. froid_loop/plugins/__init__.py +64 -0
  76. froid_loop/plugins/bus.py +259 -0
  77. froid_loop/plugins/context.py +319 -0
  78. froid_loop/plugins/loader.py +145 -0
  79. froid_loop/plugins/manifest.py +279 -0
  80. froid_loop/plugins/model.py +296 -0
  81. froid_loop/plugins/registry.py +245 -0
  82. froid_loop/plugins/trust.py +75 -0
  83. froid_loop/policy.py +1569 -0
  84. froid_loop/probe.py +1044 -0
  85. froid_loop/process_host.py +408 -0
  86. froid_loop/recovery_flow.py +1561 -0
  87. froid_loop/resolve.py +283 -0
  88. froid_loop/runs.py +4715 -0
  89. froid_loop/runsetup.py +1293 -0
  90. froid_loop/sanitize.py +593 -0
  91. froid_loop/settings_schema.py +276 -0
  92. froid_loop/signals.py +160 -0
  93. froid_loop/sprintstatus.py +609 -0
  94. froid_loop/statemachine.py +57 -0
  95. froid_loop/stories.py +615 -0
  96. froid_loop/stories_engine.py +796 -0
  97. froid_loop/sweep.py +1892 -0
  98. froid_loop/tokens.py +196 -0
  99. froid_loop/tui/__init__.py +11 -0
  100. froid_loop/tui/app.py +1584 -0
  101. froid_loop/tui/data.py +840 -0
  102. froid_loop/tui/launch.py +1003 -0
  103. froid_loop/tui/screens/__init__.py +1 -0
  104. froid_loop/tui/screens/dashboard.py +1071 -0
  105. froid_loop/tui/screens/modals.py +943 -0
  106. froid_loop/tui/screens/settings_screen.py +477 -0
  107. froid_loop/tui/settings.py +135 -0
  108. froid_loop/tui/widgets.py +981 -0
  109. froid_loop/verify.py +4545 -0
  110. froid_loop/workspace.py +320 -0
  111. froid_loop/worktree_flow.py +2301 -0
  112. froid_loop-0.11.1.dist-info/METADATA +728 -0
  113. froid_loop-0.11.1.dist-info/RECORD +116 -0
  114. froid_loop-0.11.1.dist-info/WHEEL +4 -0
  115. froid_loop-0.11.1.dist-info/entry_points.txt +2 -0
  116. froid_loop-0.11.1.dist-info/licenses/LICENSE +30 -0
froid_loop/__init__.py ADDED
@@ -0,0 +1,11 @@
1
+ """Deterministic orchestrator for the FROID implementation phase.
2
+
3
+ The control loop is plain Python; LLMs only run inside disposable
4
+ coding-CLI sessions spawned per pipeline step. All durable state lives
5
+ on disk: sprint-status.yaml (the orchestrator is its sole writer while a
6
+ run is in flight — see :mod:`froid_loop.sprintstatus`; your own FROID skill
7
+ runs still generate and edit it outside one), spec files, and the per-run
8
+ directory under .froid-loop/runs/.
9
+ """
10
+
11
+ __version__ = "0.11.1"
froid_loop/__main__.py ADDED
@@ -0,0 +1,12 @@
1
+ """``python -m froid_loop`` entry point — delegates to the console-script main().
2
+
3
+ Mirrors the ``if __name__ == "__main__"`` guard at the foot of ``cli.py`` so the
4
+ module form and the installed ``froid-loop`` script share one dispatch path.
5
+ """
6
+
7
+ import sys
8
+
9
+ from froid_loop.cli import main
10
+
11
+ if __name__ == "__main__":
12
+ sys.exit(main())
@@ -0,0 +1,3 @@
1
+ from .base import CodingCLIAdapter, SessionHandle, SessionResult, SessionSpec
2
+
3
+ __all__ = ["CodingCLIAdapter", "SessionHandle", "SessionResult", "SessionSpec"]
@@ -0,0 +1,254 @@
1
+ """Coding-CLI adapter seam.
2
+
3
+ Adapters differ along three orthogonal capability axes, declared as class
4
+ attributes so the engine can reason about transport quality instead of
5
+ treating every CLI as a dumb terminal:
6
+
7
+ - injection: how a prompt reaches the CLI
8
+ "tmux-initial-prompt" | "launch-flag" | "http"
9
+ - observation: how turn/session completion is detected
10
+ "hook-signal" | "sse" | "transcript-poll"
11
+ - state: where session state is readable
12
+ "local-jsonl" | "local-json-tree" | "remote"
13
+ """
14
+
15
+ from __future__ import annotations
16
+
17
+ from abc import ABC, abstractmethod
18
+ from dataclasses import dataclass, field
19
+ from pathlib import Path
20
+ from typing import Any
21
+
22
+ from ..model import TokenUsage
23
+
24
+
25
+ @dataclass(frozen=True)
26
+ class SpecSnapshot:
27
+ """Launch-state fingerprint of a review session's spec, captured by the
28
+ engine immediately after the pre-review-launch marker strip
29
+ (``_reset_spec_for_review``) and threaded onto its ``SessionSpec``.
30
+
31
+ Lets the generic adapter's missing-marker fallback deterministically refuse
32
+ to synthesize from a candidate whose bytes are byte-identical to the spec's
33
+ launch state: such a spec is provably untouched by this session, so the
34
+ terminal ``status:`` it carries is the PRIOR pass's ``done`` (re-opened for
35
+ review), not proof this session finished (#276 M1).
36
+
37
+ Process-transient: it rides the live ``SessionSpec`` only and is deliberately
38
+ NOT persisted (no ``StoryTask.to_dict`` entry). A crash-resume that
39
+ reconstructs the ``SessionSpec`` therefore carries no snapshot, and the
40
+ fallback degrades to its conservative 2-observation fingerprint path."""
41
+
42
+ path: str
43
+ mtime_ns: int
44
+ sha256: str
45
+ fm_status: str
46
+
47
+
48
+ @dataclass(frozen=True)
49
+ class SessionSpec:
50
+ task_id: str
51
+ role: str # "dev" | "review" | "retro"
52
+ prompt: str
53
+ cwd: Path
54
+ env: dict[str, str] = field(default_factory=dict)
55
+ model: str = "" # empty = CLI default
56
+ # fallback only; real dev/review/retro sessions get limits.session_timeout_min * 60
57
+ timeout_s: float = 90 * 60
58
+ # total stall wake-nudges this session may ever receive; None (the raw
59
+ # constructor default) = unbounded. Unlike the adapter's refillable
60
+ # per-silence budget, this cap is monotonic — a session that keeps ending
61
+ # its turn without a result cannot re-earn nudges forever, because the
62
+ # nudge is itself a submitted turn whose reply refills the budget (#149).
63
+ # The engine sets it for every session it drives: workflow_stall_nudges_cap
64
+ # for injected workflow sessions, dev_stall_nudges_cap otherwise, so a
65
+ # missing completion artifact degrades to "stalled" instead of livelocking
66
+ # until timeout_s.
67
+ stall_nudges_cap: int | None = None
68
+ # Mid-session token-budget guard (#158): weighted per-session cap the wait
69
+ # loop samples cumulative usage against on its heartbeat cadence. None (the
70
+ # raw constructor default) or mode "off" leaves the guard inert, so adapters
71
+ # constructed outside the engine (tests, MockAdapter) are unaffected. The
72
+ # engine sets these from limits.max_tokens_per_session /
73
+ # limits.session_budget_mode / limits.session_budget_grace_s /
74
+ # limits.cache_read_weight for every session it drives.
75
+ token_budget: int | None = None
76
+ token_budget_mode: str = "off" # "off" | "warn" | "enforce"
77
+ token_budget_grace_s: float = 240.0
78
+ cache_read_weight: float = 0.1
79
+ # Launch-state snapshot of a review session's spec (#276 M1): captured by the
80
+ # engine right after the pre-review-launch marker strip and threaded here so
81
+ # the generic adapter's missing-marker fallback can deterministically refuse
82
+ # to synthesize from a spec still byte-identical to its launch state. None for
83
+ # every non-review session and on a crash-resume (process-transient — see
84
+ # SpecSnapshot). Kept LAST so positional SessionSpec constructions stay valid.
85
+ spec_snapshot: SpecSnapshot | None = None
86
+ # The spec path this session is REQUIRED to write, when the orchestrator
87
+ # already knows it (#261): `StoryTask.spec_file`, recorded by verify_dev /
88
+ # verify_dev_bundle on dev success and handed to the review session in its own
89
+ # prompt. Set for every leg with a recorded spec — always a review, and a dev
90
+ # retry — and None on a dev attempt 1, whose spec does not exist yet.
91
+ #
92
+ # When set, the generic adapter reads back from THIS path instead of scanning
93
+ # the implementation-artifacts dir for the newest qualifying `*.md`. That scan
94
+ # is shared with every concurrent run: a foreign story's spec landing there
95
+ # after launch (a merge-back into the main checkout, a human edit, a sweep)
96
+ # wins on mtime and is adopted as this session's result, so a review that
97
+ # produced nothing is scored `completed:done` and unreviewed code merges.
98
+ #
99
+ # Deliberately independent of `spec_snapshot`, which degrades to None on a torn
100
+ # read: the identity constraint must not silently disappear with it.
101
+ #
102
+ # Unlike SpecSnapshot this SURVIVES a crash-resume. The field itself is not
103
+ # persisted, but its source is: `StoryTask.spec_file` round-trips through
104
+ # state.json (stored relative to the worktree, re-absolutized by
105
+ # WorktreeFlow on resume), and the engine re-derives this on every launch. So a
106
+ # resumed run is protected too — always an absolute path by the time it lands
107
+ # here. Kept LAST alongside spec_snapshot so positional constructions stay valid.
108
+ expected_spec: str | None = None
109
+
110
+
111
+ @dataclass(frozen=True)
112
+ class SessionHandle:
113
+ task_id: str
114
+ native_id: str # tmux window id, HTTP session id, ...
115
+ launched_ns: int = 0 # wall-clock ns just before launch; floor for hook events
116
+
117
+
118
+ @dataclass(frozen=True)
119
+ class SessionResult:
120
+ # "aborted" is the in-session hard-stop verdict (#319): the wait loop saw a
121
+ # `mode: "hard"` stop-request.json and tore the session down. It is an abort,
122
+ # NEVER a completion — sessions complete only on hook Stop events or window
123
+ # death (AGENTS.md) — and it never escapes `Engine._run_session`, which
124
+ # unwinds it as a RunStopped before any SessionRecord is written.
125
+ status: str # "completed" | "stalled" | "timeout" | "crashed" | "over_budget" | "aborted"
126
+ result_json: dict[str, Any] | None = None
127
+ session_id: str | None = None
128
+ transcript_path: str | None = None
129
+ # wall time.time() when wait_for_completion declared the deadline elapsed;
130
+ # None unless this session's timeout actually fired (#157).
131
+ timeout_fired_at: float | None = None
132
+ # which clock(s) had expired at fire time: "monotonic" | "wall" | "both".
133
+ # "wall" alone is the suspend signature — a frozen monotonic clock.
134
+ timeout_expired_clock: str | None = None
135
+ # weighted usage sampled when the session-budget guard tripped (#158); None
136
+ # unless the guard tripped. Set on every post-trip exit — warn-mode sessions
137
+ # that run to completion carry it too — so the engine can journal it.
138
+ budget_weighted: int | None = None
139
+ # transport-failure classification (#194): True when a non-completed session
140
+ # was post-mortem-matched as an *environment fault* (the coding CLI lost its
141
+ # API connection and idled out the session clock instead of doing real work).
142
+ # Set by the _classify_env_fault hook; env_fault_evidence carries the matched,
143
+ # ANSI-stripped log line. New fields are APPENDED below these, never inserted
144
+ # among them, so every positional SessionResult construction stays valid.
145
+ env_fault: bool = False
146
+ env_fault_evidence: str | None = None
147
+ # Whether a `Stop` hook event arrived during this session — the hook half of the
148
+ # #261 proof-of-work gate (see `_ResultFileMixin._produced_work`). Deliberately
149
+ # NOT `session_id is not None`: SessionStart and SessionEnd populate that too,
150
+ # and both fire on a CLI that launched and wedged without doing anything. Stop
151
+ # is the only canonical event that means a turn actually ended.
152
+ stop_seen: bool = False
153
+ # Set on a `crashed` verdict when the mux no longer reports the SESSION, not
154
+ # just its window (#489) — see `GenericAdapter._session_vanished` for why the
155
+ # two are otherwise indistinguishable. Diagnostic label only: it changes the
156
+ # reason text, never the routing. Deliberately NOT carried by
157
+ # `_post_kill_reconcile`'s hand-built result — that path gates on
158
+ # stalled/timeout/over_budget, which this flag can never accompany; add it
159
+ # there if `crashed` ever joins that rescue set.
160
+ session_vanished: bool = False
161
+
162
+
163
+ class CodingCLIAdapter(ABC):
164
+ name: str = "abstract"
165
+ injection: str = ""
166
+ observation: str = ""
167
+ state: str = ""
168
+
169
+ @abstractmethod
170
+ def start_session(self, spec: SessionSpec) -> SessionHandle: ...
171
+
172
+ @abstractmethod
173
+ def wait_for_completion(self, handle: SessionHandle, spec: SessionSpec) -> SessionResult: ...
174
+
175
+ def send_text(self, handle: SessionHandle, text: str) -> None:
176
+ """Nudge a running session. Optional capability."""
177
+ raise NotImplementedError(f"{self.name} cannot inject into a running session")
178
+
179
+ def interactive_argv(self, spec: SessionSpec) -> list[str]:
180
+ """argv that launches the CLI agent attached to the caller's terminal,
181
+ seeded with spec.prompt. Used by the interactive escalation-resolution
182
+ flow; optional capability (e.g. HTTP adapters have no terminal)."""
183
+ raise NotImplementedError(f"{self.name} has no interactive (attached) session mode")
184
+
185
+ def interactive_env(self, spec: SessionSpec) -> dict[str, str]:
186
+ """Env vars to layer onto the caller's environment for interactive_argv."""
187
+ return dict(spec.env)
188
+
189
+ def kill(self, handle: SessionHandle) -> None: # optional cleanup
190
+ pass
191
+
192
+ def read_usage(self, result: SessionResult) -> TokenUsage | None:
193
+ return None
194
+
195
+ def run(self, spec: SessionSpec) -> SessionResult:
196
+ handle = self.start_session(spec)
197
+ try:
198
+ result = self.wait_for_completion(handle, spec)
199
+ finally:
200
+ self.kill(handle)
201
+ result = self._post_kill_reconcile(handle, spec, result)
202
+ return self._classify_env_fault(handle, spec, result)
203
+
204
+ def _post_kill_reconcile(
205
+ self, handle: SessionHandle, spec: SessionSpec, result: SessionResult
206
+ ) -> SessionResult:
207
+ """Last-chance reconcile after the session's window has been torn down.
208
+
209
+ Runs only on the normal return path — a raising wait_for_completion
210
+ still kills the window and propagates without reaching this hook.
211
+ Base behavior: identity. Adapters whose completion trust keys on
212
+ window death (see GenericDevAdapter) may re-inspect on-disk state here,
213
+ now that the kill has settled the liveness question a live-window
214
+ verdict had to leave open."""
215
+ return result
216
+
217
+ def _observe_tick(self, handle: SessionHandle, spec: SessionSpec) -> None:
218
+ """Heartbeat-cadence hook for mid-session on-disk observation, called from
219
+ the wait loop's heartbeat-throttled block (~every HEARTBEAT_INTERVAL_S; the
220
+ first tick always fires). Base behavior: nothing. Adapters that drive a
221
+ skill whose terminal on-disk state is heuristic to attribute may sample it
222
+ here (see GenericDevAdapter's `_DevSynthesisMixin`, which records the spec's
223
+ first non-terminal status transition to make a later terminal frontmatter
224
+ deterministic proof this session wrote it, #276 M2). An observation seam
225
+ only — it MUST NOT mutate session state or the spec, and any read failure is
226
+ a sample it silently skips, never a verdict."""
227
+ return None
228
+
229
+ def _classify_env_fault(
230
+ self, handle: SessionHandle, spec: SessionSpec, result: SessionResult
231
+ ) -> SessionResult:
232
+ """Last-chance post-mortem: label a non-completed session an environment
233
+ fault (#194) when the CLI never got usable work out of the provider —
234
+ connection lost, or quota/rate limit refused — and idled out the session
235
+ clock rather than doing real work.
236
+
237
+ Runs LAST in ``run()`` — after ``_post_kill_reconcile`` — so a reconcile
238
+ upgrade to ``completed`` is never re-classified, and only a genuinely
239
+ non-completed verdict (``result_json is None``) is ever inspected. Base
240
+ behavior: identity, like ``_post_kill_reconcile``, so an adapter with no
241
+ session log at all (mock) stays inert.
242
+
243
+ Any adapter that writes a per-task diagnostic log should mix in
244
+ ``EnvFaultMixin``, which matches profile patterns against the tail of the
245
+ file its ``ENV_FAULT_LOG_SUFFIX`` names and stamps ``env_fault`` /
246
+ ``env_fault_evidence`` onto the result. That covers the tmux adapters
247
+ (pane capture, ``<task_id>.log``) and the opencode HTTP adapter (the
248
+ serve process's stdout/stderr, ``<task_id>.server.out``, NOT its
249
+ conversation transcript) alike — the signal is the log, not the
250
+ transport. This docstring used to say HTTP adapters had no
251
+ post-mortem signal; that stopped being true once opencode_http began
252
+ teeing its server log, and the stale premise is why a provider quota
253
+ outage went unclassified and burned three stories' retry budgets."""
254
+ return result
@@ -0,0 +1,63 @@
1
+ """Shared recording for the three ``froid_loop.*`` entry-point scans.
2
+
3
+ The adapter registry (:mod:`~.registry`), the profile loader (:mod:`~.profile`)
4
+ and the multiplexer registry (:mod:`~.multiplexer`) each scan their own
5
+ entry-point group, and each degrades a broken third-party distribution to a
6
+ *recorded* reason rather than a crash. This module owns the one thing all three
7
+ recordings must agree on: how a failure becomes an entry in that map.
8
+
9
+ A leaf on purpose — standard library only, and **no import of a sibling
10
+ adapters module**. Those three do not import each other today, and the package's
11
+ builtins load lazily to keep that so; an edge from here into any of them would
12
+ put a cycle one refactor away.
13
+
14
+ Why the map stays keyed on the entry-point NAME. The key is what reaches
15
+ ``detail["entry_point"]`` in ``froid-loop validate --json``. That document is
16
+ schema-versioned and evolves additively (see ``documents.validate_document``,
17
+ which contracts ``check`` as the matchable identity and states outright that
18
+ ``message``/``detail`` are for humans) — so widening the key to
19
+ ``(distribution, name)`` would change a value consumers can already see, while
20
+ widening only the reason TEXT does not.
21
+
22
+ Why reasons accumulate instead of overwriting.
23
+ ``importlib.metadata.entry_points(group=...)`` does not deduplicate across
24
+ distributions: two installed packages may both advertise ``acme`` in one group,
25
+ and the scan yields both. A plain assignment let the second failure silently
26
+ overwrite the first, so an operator fixed one package and met the other on the
27
+ next run with no sign it had ever been there. Reasons are joined with ``"; "``
28
+ — reasons already contain colons, so a colon separator would be unreadable.
29
+
30
+ Why the distribution labels each reason. The entry-point name is not the name
31
+ you ``pip uninstall``, and two packages failing the same way otherwise render as
32
+ the same sentence twice, with nothing to tell the operator there are two.
33
+
34
+ The honest limit: a same-named collision still records ONE row, whose text now
35
+ carries both reasons. The row count does not double — that is the price of
36
+ leaving the key (and therefore ``--json``) untouched, and it still puts every
37
+ reason in front of the operator.
38
+ """
39
+
40
+ from __future__ import annotations
41
+
42
+ from typing import Any
43
+
44
+
45
+ def record_load_error(errors: dict[str, str], ep: Any, exc: BaseException) -> None:
46
+ """Record ``exc`` against ``ep``'s name in ``errors``, appending to whatever a
47
+ same-named entry point already recorded.
48
+
49
+ ``ep`` is annotated ``Any`` deliberately: the callers pass a real
50
+ ``importlib.metadata.EntryPoint`` but every test passes a hand-rolled double,
51
+ so naming ``EntryPoint`` here would claim a contract this function does not
52
+ require (it touches ``.name`` and, defensively, ``.dist.name``).
53
+
54
+ The doubled ``getattr`` tolerates a double with no ``dist`` attribute at all
55
+ as well as a real entry point whose ``dist`` is ``None``; the truthiness
56
+ check then treats an empty distribution name as absent, the same
57
+ normalization the scans' ``or ""`` sort keys apply."""
58
+ dist = getattr(getattr(ep, "dist", None), "name", None)
59
+ reason = f"{type(exc).__name__}: {exc}"
60
+ if dist:
61
+ reason = f"{dist}: {reason}"
62
+ prior = errors.get(ep.name)
63
+ errors[ep.name] = f"{prior}; {reason}" if prior else reason
@@ -0,0 +1,290 @@
1
+ """Post-mortem environment-fault classification, shared across transports (#194).
2
+
3
+ After a session's verdict and reconcile have settled, a single tail read of the
4
+ session log can reclassify a non-completed verdict as an *environment fault* —
5
+ the CLI never got usable work out of the provider, so the attempt proved nothing
6
+ about the story. The engine routes that to a PAUSE (``env_fault_pause_reason``)
7
+ instead of charging a dev attempt, and re-arming resets the budget.
8
+
9
+ This lives in its own module rather than on one adapter because the signal is
10
+ **transport-agnostic**: every adapter that writes a per-task diagnostic log can
11
+ be classified from it, whatever produced the bytes. Which file that is per
12
+ adapter is named by ``ENV_FAULT_LOG_SUFFIX``: the tmux adapters tee a pane
13
+ capture to ``logs/<task_id>.log`` (``mux.pipe_pane``); the opencode HTTP adapter
14
+ redirects the ``opencode serve`` process's own stdout/stderr to
15
+ ``logs/<task_id>.server.out`` — NOT its ``.log``, which is a model-written
16
+ conversation transcript. Keeping the classifier attached to a single adapter is
17
+ what let #194 ship covering only half the adapters, so the next sibling adapter
18
+ inherits this instead of re-omitting it.
19
+
20
+ Host-class contract: ``self.profile`` (a ``CLIProfile``), ``self.logs_dir``, and
21
+ ``_note_lifecycle`` (from ``_ResultFileMixin``). Mix in alongside that mixin.
22
+
23
+ Note the two log flavors differ in how *dirty* they are, which is why the
24
+ shipped patterns are anchored the way they are. A tmux pane capture contains the
25
+ model's own output — a story that implements rate limiting will print "429" and
26
+ "quota" in ordinary healthy work — whereas the opencode server log carries only
27
+ server and provider lines. That difference sets the bar per profile, and the two
28
+ bars are NOT the same. On a pane capture a pattern must reproduce a COMPLETE
29
+ captured CLI error sentence: an error-shaped token plus a cause on the same line
30
+ is precisely the shape a story writing ABOUT the error emits, so that weaker rule
31
+ classified 44 of this repo's own tracked lines and had to be withdrawn (#507).
32
+ The opencode server log tolerates the anchor-plus-cause form only because the
33
+ model cannot write to it. Seed either from a captured line, never a plausible
34
+ one; the profiles' own comments carry the provenance and
35
+ tests/test_env_fault_patterns.py pins both halves.
36
+ """
37
+
38
+ from __future__ import annotations
39
+
40
+ import dataclasses
41
+ import re
42
+ from functools import cached_property
43
+ from pathlib import Path
44
+ from typing import TYPE_CHECKING
45
+
46
+ import regex
47
+
48
+ from .base import SessionHandle, SessionResult, SessionSpec
49
+ from .profile import CLIProfile
50
+
51
+ # Post-mortem transport-failure classification (#194): how much of the tee'd
52
+ # session log's tail to scan, how long an evidence excerpt to keep, and which
53
+ # non-completed statuses are eligible. over_budget is excluded — a budget
54
+ # crossing proves real API traffic — and completed never reaches the scan.
55
+ ENV_FAULT_TAIL_BYTES = 64 * 1024
56
+ ENV_FAULT_EVIDENCE_MAX = 240
57
+ # How much of the line *before* the match to keep when the line is too long to
58
+ # quote whole. Truncating from the start instead loses the error on any log whose
59
+ # lines lead with metadata: an opencode `level=ERROR message="stream error" …`
60
+ # line carries ~250 characters of timestamp/provider/session fields before
61
+ # `error.error=`, so a head-truncated excerpt showed the operator every field
62
+ # except the failure. The evidence string is what lands in the pause reason and
63
+ # the ATTENTION file — it has to contain the thing that matched.
64
+ ENV_FAULT_EVIDENCE_LEAD = 40
65
+ ENV_FAULT_STATUSES = frozenset({"timeout", "stalled", "crashed"})
66
+ # Wall-clock bound on EACH pattern match (run via the `regex` module, not stdlib
67
+ # `re`, whose `search` has no timeout), so a pathological profile regex cannot hang
68
+ # run() teardown indefinitely. Note what this does and does not bound: it is
69
+ # per-search, so the worst case for a scan is lines × patterns × this, not this.
70
+ # A sane pattern over the ≤64 KiB tail matches in microseconds, and the first
71
+ # search to blow the bound aborts the whole scan (TimeoutError → decline to
72
+ # classify), so the realistic ceiling is one timeout — but an operator writing a
73
+ # pattern that backtracks on many lines without exceeding the per-line bound can
74
+ # still make teardown slow. Keep patterns anchored and non-nested.
75
+ ENV_FAULT_MATCH_TIMEOUT_S = 2.0
76
+ # Self-contained ANSI/terminal-control stripper for the log tail: CSI, OSC (BEL-
77
+ # or ST-terminated), other two-char ESC sequences, and raw C1 bytes. Deliberately
78
+ # NOT the TUI/pyte machinery — the classifier reads raw pane bytes best-effort and
79
+ # must not pull a terminal emulator into the adapter. Inert on a log that was
80
+ # never a terminal capture (the opencode server log), which costs one pass.
81
+ _ANSI_RE = re.compile(
82
+ r"""
83
+ \x1b\[ [0-?]* [ -/]* [@-~] # CSI ... final byte
84
+ | \x1b\] .*? (?: \x07 | \x1b\\ ) # OSC ... BEL or ST
85
+ | \x1b [@-Z\\-_] # 2-char ESC sequences (incl. C1 via ESC)
86
+ | [\x80-\x9f] # raw C1 control bytes
87
+ """,
88
+ re.VERBOSE,
89
+ )
90
+
91
+
92
+ class EnvFaultMixin:
93
+ """Classify a dead session as an environment fault from its log tail (#194).
94
+
95
+ Mixed into every adapter that writes a per-task diagnostic log; which file
96
+ is scanned is named by ``ENV_FAULT_LOG_SUFFIX``. Inert for a profile with no
97
+ ``env_fault_patterns``, so mixing it in is always safe."""
98
+
99
+ # Set by the concrete adapter's __init__; bare annotations (no runtime effect)
100
+ # tell the type checker which host attributes this mixin reads.
101
+ profile: CLIProfile
102
+ logs_dir: Path
103
+
104
+ # Which per-task file in logs_dir carries the CLI's DIAGNOSTIC output. Default
105
+ # ".log" is the tmux adapters' pane capture. Overridden by any adapter that
106
+ # writes its diagnostics elsewhere.
107
+ #
108
+ # This is a suffix and not a hardcoded path because the two are not the same
109
+ # file for every transport, and getting it wrong is silent: the scan simply
110
+ # finds nothing and every provider outage reads as a failed story. The opencode
111
+ # adapter split them (its ".log" became a curated conversation transcript,
112
+ # diagnostics moved to ".server.out"), and the classifier kept scanning ".log"
113
+ # — passing every unit test, because they write the fixture to whatever path
114
+ # this resolves to, and failing only end to end.
115
+ #
116
+ # It also carries the SAFETY property the shipped patterns depend on. A file
117
+ # containing the model's own output cannot be scanned with content-based
118
+ # patterns: a story that quotes a provider error verbatim is byte-identical to
119
+ # the real thing (see tests/test_env_fault_patterns.py). The tmux adapters
120
+ # accept that risk knowingly and their profile patterns are anchored for it;
121
+ # opencode's are anchored on the assumption of a model-free log, so pointing
122
+ # this at a transcript would make them unsound.
123
+ ENV_FAULT_LOG_SUFFIX = ".log"
124
+
125
+ def _env_fault_log_path(self, task_id: str) -> Path:
126
+ return self.logs_dir / f"{task_id}{self.ENV_FAULT_LOG_SUFFIX}"
127
+
128
+ if TYPE_CHECKING:
129
+ # Supplied by _ResultFileMixin, which every host of this mixin already
130
+ # uses. Declared rather than inherited: generic.py imports this module,
131
+ # so depending on _ResultFileMixin here would be a cycle.
132
+ def _note_lifecycle(self, task_id: str, event: str, **fields: object) -> None: ...
133
+
134
+ @cached_property
135
+ def _env_fault_patterns(self) -> tuple[regex.Pattern[str], ...]:
136
+ """Compiled once per adapter, on first classification. The profile
137
+ validated each pattern at parse time with this same engine, so
138
+ ``regex.compile`` cannot raise here; empty tuple = classification inert.
139
+
140
+ A ``cached_property`` rather than an ``__init__`` line so every adapter
141
+ gains the behavior by mixing the class in, with nothing to remember to
142
+ wire — the omission that caused this bug. Tests may still assign
143
+ ``adapter._env_fault_patterns`` directly; that shadows the property in
144
+ the instance ``__dict__``, as it did when this was a plain attribute.
145
+
146
+ CONSTRAINT: the cache is filled on first classification and never
147
+ invalidated, so reassigning ``self.profile`` afterwards leaves stale
148
+ patterns. Nothing in ``src/`` mutates ``profile`` after ``__init__`` (it
149
+ is constructor state), so this holds in production; a test that swaps the
150
+ profile must do so BEFORE the first classification, or assign
151
+ ``_env_fault_patterns`` directly instead."""
152
+ return tuple(regex.compile(p) for p in self.profile.env_fault_patterns)
153
+
154
+ def _classify_env_fault(
155
+ self, handle: SessionHandle, spec: SessionSpec, result: SessionResult
156
+ ) -> SessionResult:
157
+ """Post-mortem transport-failure classification (#194).
158
+
159
+ Runs last in ``run()`` (after ``_post_kill_reconcile``): only a
160
+ non-completed verdict (``result.status`` in ``ENV_FAULT_STATUSES``,
161
+ ``result_json is None``) with configured patterns is inspected, so a
162
+ reconcile upgrade to ``completed`` is never re-classified and adapters
163
+ without patterns stay inert. On a matching log-tail line, stamp
164
+ ``env_fault`` / ``env_fault_evidence`` and drop an ``env-fault-classified``
165
+ lifecycle breadcrumb. No match, no patterns, or an unreadable log leaves
166
+ the verdict untouched — best-effort, like ``_write_heartbeat``."""
167
+ if (
168
+ not self._env_fault_patterns
169
+ or result.status not in ENV_FAULT_STATUSES
170
+ or result.result_json is not None
171
+ ):
172
+ return result
173
+ evidence = self._env_fault_evidence(handle.task_id)
174
+ if evidence is None:
175
+ return result
176
+ self._note_lifecycle(
177
+ handle.task_id, "env-fault-classified", status=result.status, evidence=evidence
178
+ )
179
+ return dataclasses.replace(result, env_fault=True, env_fault_evidence=evidence)
180
+
181
+ def _env_fault_evidence(self, task_id: str) -> str | None:
182
+ """Scan the tail of the tee'd session log for a transport-failure pattern.
183
+
184
+ Reads the last ``ENV_FAULT_TAIL_BYTES`` (binary, decoded with
185
+ ``errors="replace"``, ``\\r``→``\\n``), strips ANSI, and matches each line
186
+ against the precompiled patterns under a per-match ``ENV_FAULT_MATCH_TIMEOUT_S``
187
+ bound. Returns the ANSI-stripped matching line (last match winning, windowed
188
+ to ``ENV_FAULT_EVIDENCE_MAX`` around the match), or None when nothing matches,
189
+ the log can't be read (any ``OSError``), or a pattern exceeds the match timeout
190
+ (``TimeoutError``) — no classification, the best-effort doctrine."""
191
+ try:
192
+ with self._env_fault_log_path(task_id).open("rb") as fh:
193
+ fh.seek(0, 2) # SEEK_END
194
+ size = fh.tell()
195
+ offset = max(0, size - ENV_FAULT_TAIL_BYTES)
196
+ if offset:
197
+ # The byte immediately BEFORE the window, read on its own so it
198
+ # enters neither `raw` nor the ENV_FAULT_TAIL_BYTES budget — it
199
+ # only answers whether the window opens mid-line. Inspected raw,
200
+ # ahead of the \r→\n normalization below: a pane capture is
201
+ # CR-terminated, so a \r here ends a line just as a \n does.
202
+ fh.seek(offset - 1)
203
+ boundary = fh.read(1)
204
+ else:
205
+ fh.seek(0)
206
+ boundary = b"\n" # not truncated: the window is the whole file
207
+ raw = fh.read()
208
+ except OSError:
209
+ return None
210
+ straddles = boundary not in (b"\n", b"\r")
211
+ text = _ANSI_RE.sub("", raw.decode("utf-8", errors="replace").replace("\r", "\n"))
212
+ lines = text.split("\n")
213
+ if straddles and any(lines[1:]):
214
+ # The window opened mid-line, so the first element is a fragment of
215
+ # whatever line straddled the edge — not a line. Matching it would quote
216
+ # a half-line as evidence, and (because the cut can land mid-codepoint)
217
+ # its head may be a U+FFFD from the errors="replace" decode. Drop it:
218
+ # one lost line at the far end of a 64 KiB tail cannot matter, and a
219
+ # fabricated line can.
220
+ #
221
+ # Both halves of the guard are load-bearing, and each was once wrong:
222
+ # * `straddles`, not "the read truncated": an offset that happens to
223
+ # land on the first byte after a newline yields a COMPLETE first line.
224
+ # Discarding that on truncation alone loses a whole line, and if it
225
+ # was the only provider-error match in the tail the outage goes
226
+ # unclassified and the run burns a story attempt for nothing.
227
+ # * `any(lines[1:])`, not `len(lines) > 1`: the guard means "do not
228
+ # discard if that leaves nothing to scan", and the length test does
229
+ # not say that. `split("\n")` always yields a trailing "", so a window
230
+ # holding one >64 KiB newline-terminated line splits to
231
+ # [fragment, ""] — length 2, dropped, leaving only "". Degenerate,
232
+ # but silently classifying nothing is exactly the failure this
233
+ # classifier exists to prevent.
234
+ lines = lines[1:]
235
+ match_line: str | None = None
236
+ match_pos = 0
237
+ try:
238
+ for line in lines:
239
+ for pat in self._env_fault_patterns:
240
+ hit = pat.search(line, timeout=ENV_FAULT_MATCH_TIMEOUT_S)
241
+ if hit is not None:
242
+ match_line, match_pos = line, hit.start() # last match wins
243
+ break
244
+ except TimeoutError:
245
+ return None # runaway pattern → decline to classify (best-effort, like OSError)
246
+ if match_line is None:
247
+ return None
248
+ return _excerpt(match_line, match_pos)
249
+
250
+
251
+ def _excerpt(line: str, match_pos: int) -> str:
252
+ """Quote at most ``ENV_FAULT_EVIDENCE_MAX`` characters of ``line``, keeping the
253
+ matched region rather than the line's head. Short lines are returned whole.
254
+
255
+ BOTH cut ends are marked with an ellipsis. Marking only the head (as this did
256
+ at first) makes a window that dropped a suffix read as a complete line that
257
+ simply ended there — which, for a string an operator reads out of a pause
258
+ reason to decide whether their run died of a provider outage, is the one thing
259
+ it must not do."""
260
+ stripped = line.strip()
261
+ if len(stripped) <= ENV_FAULT_EVIDENCE_MAX:
262
+ return stripped
263
+ usable = len(line.rstrip())
264
+ # The markers are spent FROM the budget, never added on top of it: this string
265
+ # is journalled and rendered in a pause reason, so ENV_FAULT_EVIDENCE_MAX has
266
+ # to be a real ceiling on what callers receive. But how many markers there are
267
+ # depends on the window, and the window depends on the budget, which depends on
268
+ # the markers. Resolve by fixed point over the only three possibilities — no
269
+ # marker, one, or two — taking the first that is self-consistent. Terminates in
270
+ # at most three passes; the final fallback (two markers) is always valid
271
+ # because a line long enough to reach here cannot need fewer than it reserves.
272
+ start, head, tail, budget = 0, False, False, ENV_FAULT_EVIDENCE_MAX
273
+ for reserved in (0, 1, 2):
274
+ budget = ENV_FAULT_EVIDENCE_MAX - reserved
275
+ # Prefer the match with LEAD chars of left context, but slide LEFT rather
276
+ # than return a short excerpt when the match sits near the end of the line.
277
+ # That is the common case here, not a corner: opencode's logfmt puts ~250
278
+ # characters of metadata before `error.error=`, so the match is near the
279
+ # end of almost every line this classifier quotes.
280
+ start = max(0, min(match_pos - ENV_FAULT_EVIDENCE_LEAD, usable - budget))
281
+ head = start > 0
282
+ tail = start + budget < usable
283
+ if (1 if head else 0) + (1 if tail else 0) == reserved:
284
+ break
285
+ window = line[start : start + budget].strip()
286
+ if head:
287
+ window = f"…{window}"
288
+ if tail:
289
+ window = f"{window}…"
290
+ return window