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.
- froid_loop/__init__.py +11 -0
- froid_loop/__main__.py +12 -0
- froid_loop/adapters/__init__.py +3 -0
- froid_loop/adapters/base.py +254 -0
- froid_loop/adapters/entrypoints.py +63 -0
- froid_loop/adapters/env_fault.py +290 -0
- froid_loop/adapters/generic.py +2013 -0
- froid_loop/adapters/mock.py +49 -0
- froid_loop/adapters/multiplexer.py +914 -0
- froid_loop/adapters/opencode_http.py +1687 -0
- froid_loop/adapters/profile.py +650 -0
- froid_loop/adapters/psmux_backend.py +1428 -0
- froid_loop/adapters/registry.py +322 -0
- froid_loop/adapters/tmux_backend.py +35 -0
- froid_loop/adapters/tmux_base.py +630 -0
- froid_loop/checks.py +187 -0
- froid_loop/cli.py +5041 -0
- froid_loop/data/__init__.py +0 -0
- froid_loop/data/froid_loop_hook.py +228 -0
- froid_loop/data/froid_loop_probe_hook.py +88 -0
- froid_loop/data/plugins/example/plugin.toml +21 -0
- froid_loop/data/plugins/tea/plugin.toml +184 -0
- froid_loop/data/plugins/tea/tea_plugin.py +258 -0
- froid_loop/data/plugins/unity/plugin.toml +140 -0
- froid_loop/data/plugins/unity/unity_assets/FroidLoop.Unity.Editor.asmdef +16 -0
- froid_loop/data/plugins/unity/unity_assets/FroidLoop.Unity.Editor.asmdef.meta +7 -0
- froid_loop/data/plugins/unity/unity_assets/SceneAutoSaveGuard.cs +221 -0
- froid_loop/data/plugins/unity/unity_assets/SceneAutoSaveGuard.cs.meta +11 -0
- froid_loop/data/plugins/unity/unity_assets/_folders/Editor.meta +8 -0
- froid_loop/data/plugins/unity/unity_assets/_folders/FroidLoop.meta +8 -0
- froid_loop/data/plugins/unity/unity_cleanup.py +125 -0
- froid_loop/data/plugins/unity/unity_dialog_probe.py +239 -0
- froid_loop/data/plugins/unity/unity_facts.md +17 -0
- froid_loop/data/plugins/unity/unity_plugin.py +415 -0
- froid_loop/data/plugins/unity/unity_quiesce.py +234 -0
- froid_loop/data/plugins/unity/unity_ready.py +230 -0
- froid_loop/data/plugins/unity/unity_seed_assets.py +298 -0
- froid_loop/data/plugins/unity/unity_setup.py +551 -0
- froid_loop/data/plugins/unity/unity_teardown.py +362 -0
- froid_loop/data/profiles/antigravity.toml +52 -0
- froid_loop/data/profiles/claude.toml +85 -0
- froid_loop/data/profiles/codex.toml +22 -0
- froid_loop/data/profiles/copilot.toml +52 -0
- froid_loop/data/profiles/gemini.toml +26 -0
- froid_loop/data/profiles/opencode.toml +54 -0
- froid_loop/data/settings/core.toml +458 -0
- froid_loop/data/skills/README.md +93 -0
- froid_loop/data/skills/froid-loop-resolve/SKILL.md +288 -0
- froid_loop/data/skills/froid-loop-setup/SKILL.md +161 -0
- froid_loop/data/skills/froid-loop-setup/assets/module-help.csv +3 -0
- froid_loop/data/skills/froid-loop-setup/assets/module.yaml +19 -0
- froid_loop/data/skills/froid-loop-sweep/SKILL.md +100 -0
- froid_loop/data/skills/froid-loop-sweep/automation-mode.md +127 -0
- froid_loop/data/skills/froid-loop-sweep/deferred-work-format.md +302 -0
- froid_loop/data/skills/froid-loop-sweep/migration-mode.md +86 -0
- froid_loop/decisions.py +202 -0
- froid_loop/deferredwork.py +2282 -0
- froid_loop/devcontract.py +892 -0
- froid_loop/diagnostics.py +1104 -0
- froid_loop/documents.py +532 -0
- froid_loop/engine.py +7732 -0
- froid_loop/envvars.py +111 -0
- froid_loop/escalation.py +225 -0
- froid_loop/events.py +266 -0
- froid_loop/fences.py +103 -0
- froid_loop/froidconfig.py +226 -0
- froid_loop/frontmatter.py +526 -0
- froid_loop/gates.py +133 -0
- froid_loop/install.py +2936 -0
- froid_loop/journal.py +178 -0
- froid_loop/machine.py +148 -0
- froid_loop/model.py +898 -0
- froid_loop/operatoractions.py +474 -0
- froid_loop/platform_util.py +1490 -0
- froid_loop/plugins/__init__.py +64 -0
- froid_loop/plugins/bus.py +259 -0
- froid_loop/plugins/context.py +319 -0
- froid_loop/plugins/loader.py +145 -0
- froid_loop/plugins/manifest.py +279 -0
- froid_loop/plugins/model.py +296 -0
- froid_loop/plugins/registry.py +245 -0
- froid_loop/plugins/trust.py +75 -0
- froid_loop/policy.py +1569 -0
- froid_loop/probe.py +1044 -0
- froid_loop/process_host.py +408 -0
- froid_loop/recovery_flow.py +1561 -0
- froid_loop/resolve.py +283 -0
- froid_loop/runs.py +4715 -0
- froid_loop/runsetup.py +1293 -0
- froid_loop/sanitize.py +593 -0
- froid_loop/settings_schema.py +276 -0
- froid_loop/signals.py +160 -0
- froid_loop/sprintstatus.py +609 -0
- froid_loop/statemachine.py +57 -0
- froid_loop/stories.py +615 -0
- froid_loop/stories_engine.py +796 -0
- froid_loop/sweep.py +1892 -0
- froid_loop/tokens.py +196 -0
- froid_loop/tui/__init__.py +11 -0
- froid_loop/tui/app.py +1584 -0
- froid_loop/tui/data.py +840 -0
- froid_loop/tui/launch.py +1003 -0
- froid_loop/tui/screens/__init__.py +1 -0
- froid_loop/tui/screens/dashboard.py +1071 -0
- froid_loop/tui/screens/modals.py +943 -0
- froid_loop/tui/screens/settings_screen.py +477 -0
- froid_loop/tui/settings.py +135 -0
- froid_loop/tui/widgets.py +981 -0
- froid_loop/verify.py +4545 -0
- froid_loop/workspace.py +320 -0
- froid_loop/worktree_flow.py +2301 -0
- froid_loop-0.11.1.dist-info/METADATA +728 -0
- froid_loop-0.11.1.dist-info/RECORD +116 -0
- froid_loop-0.11.1.dist-info/WHEEL +4 -0
- froid_loop-0.11.1.dist-info/entry_points.txt +2 -0
- 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,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
|