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
@@ -0,0 +1,2013 @@
1
+ """Generic coding-CLI driver: interactive sessions in tmux windows, observed via hooks.
2
+
3
+ Each pipeline step gets a fresh tmux window running the full interactive CLI
4
+ with the skill invocation as the initial prompt. Completion is detected
5
+ exclusively through hook-written event files (Stop/SessionEnd) plus the
6
+ presence of the skill-written result.json — the pane log's *contents* never
7
+ drive the wait loop (only tee'd for human debugging), though its *growth*
8
+ (mtime/size, never the bytes — see ``_log_activity_key``) is read as a liveness
9
+ signal to re-arm the dev-stall grace window. The one exception is post-mortem:
10
+ after the verdict and reconcile have settled, a single tail read of the log
11
+ classifies a transport-failure environment fault (#194, see
12
+ ``_classify_env_fault``) — it labels the result, it never drives the wait loop.
13
+
14
+ Everything CLI-specific (binary, prompt rendering, bypass flags, usage
15
+ parser) comes from a declarative CLIProfile; each CLI's hook config registers
16
+ the shared relay script under its native event names but passes the canonical
17
+ event name as argv, so this adapter only ever sees canonical events. CLIs
18
+ without a SessionEnd hook (e.g. Codex) are covered by the window-death
19
+ fallback.
20
+ """
21
+
22
+ from __future__ import annotations
23
+
24
+ import enum
25
+ import hashlib
26
+ import json
27
+ import shlex
28
+ import time
29
+ from collections.abc import Callable
30
+ from pathlib import Path
31
+ from typing import TYPE_CHECKING
32
+
33
+ from .. import devcontract, gates, runs
34
+ from ..froidconfig import ProjectPaths
35
+ from ..journal import LOGS_DIR
36
+ from ..model import TokenUsage
37
+ from ..policy import Policy
38
+ from ..process_host import ProcessHostError, get_process_host
39
+ from ..signals import SignalWatcher
40
+ from ..tokens import read_usage as tally_usage
41
+ from ..verify import read_frontmatter, status_of
42
+ from .base import CodingCLIAdapter, SessionHandle, SessionResult, SessionSpec, SpecSnapshot
43
+
44
+ # Re-exported for importers that predate the env_fault module split (#194 landed
45
+ # these names on this module); the definitions now live in .env_fault. The
46
+ # redundant `X as X` form is the explicit-re-export spelling — it tells the linter
47
+ # these are deliberate pass-throughs, without an `__all__` that would read as a
48
+ # statement of this module's public API and understate it (callers also import
49
+ # GenericTmuxAdapter, the *_NUDGE_TEXT constants and HEARTBEAT_INTERVAL_S).
50
+ #
51
+ # READ-ONLY. An import copies the object binding, so these names are aliases, not
52
+ # a window onto env_fault's globals: reading them is exact, but REBINDING one here
53
+ # (`monkeypatch.setattr(generic, "ENV_FAULT_MATCH_TIMEOUT_S", ...)`) is invisible to
54
+ # the classifier, which resolves the constant from its own module at call time. That
55
+ # is not hypothetical — it silently defused the pathological-regex test the split
56
+ # inherited. Override at the definition site (`env_fault.<NAME>`) instead.
57
+ from .env_fault import _ANSI_RE as _ANSI_RE
58
+ from .env_fault import ENV_FAULT_EVIDENCE_MAX as ENV_FAULT_EVIDENCE_MAX
59
+ from .env_fault import ENV_FAULT_MATCH_TIMEOUT_S as ENV_FAULT_MATCH_TIMEOUT_S
60
+ from .env_fault import ENV_FAULT_STATUSES as ENV_FAULT_STATUSES
61
+ from .env_fault import ENV_FAULT_TAIL_BYTES as ENV_FAULT_TAIL_BYTES
62
+ from .env_fault import EnvFaultMixin
63
+ from .multiplexer import MultiplexerError, TerminalMultiplexer, get_multiplexer
64
+ from .profile import CLIProfile
65
+
66
+ if TYPE_CHECKING:
67
+ from ..process_host import ProcessHost
68
+
69
+ # Pane geometry for agent windows; mirrored in tui.data for log emulation.
70
+ PANE_COLUMNS = 220
71
+ PANE_LINES = 50
72
+ RESULT_GRACE_S = 15.0
73
+ RESULT_POLL_S = 0.5
74
+ KILL_POLL_S = 0.5
75
+ # Missing-marker fallback (#224): how many consecutive resultless-Stop
76
+ # observations of an IDENTICAL (path, mtime, status) fingerprint a marker-less
77
+ # terminal spec must survive before it is synthesized as this session's result.
78
+ # One observation is not enough: right after a review launch the spec still
79
+ # carries the dev pass's `done` frontmatter, and the review's first write can
80
+ # bump its mtime past the launch floor before the status flips to `in-review` —
81
+ # harvesting on that single sighting would score a review that never ran (#261).
82
+ # Two stable sightings bracket a full stall-grace + nudge with zero writes, which
83
+ # a session mid-edit cannot produce. A dead window skips the counter entirely:
84
+ # the kill settled liveness, so the frontmatter is as final as it will ever get.
85
+ FM_FALLBACK_MIN_OBS = 2
86
+ # Proof-of-work gate (#261): pane-log size, in bytes, above which a session counts
87
+ # as having produced SOMETHING — the floor a dead session must clear before a
88
+ # read-back artifact may upgrade its verdict to `completed`. Not zero: the three
89
+ # wedged sessions in #261 left logs of 0 and 2 bytes, so `size > 0` would have
90
+ # cleared one of them. The separation is wide in the observed data — that run's
91
+ # working dev session logged 1.4 MB against the wedged reviews' 0 and 2 — so the
92
+ # exact value is not load-bearing; it only has to sit above the noise a pane can
93
+ # accumulate without the CLI rendering anything. Note the floor measures the CLI's
94
+ # OWN output: the orchestrator's prompt is delivered by send-keys and a program
95
+ # that never echoes it leaves the log empty (measured), so this is not a proxy for
96
+ # "the session was launched" — only for "the CLI rendered something".
97
+ PROOF_OF_WORK_MIN_LOG_BYTES = 256
98
+
99
+
100
+ class _SnapVerdict(enum.Enum):
101
+ """Launch-snapshot (#276 M1/M2) decision, shared by the mtime-scan fallback and
102
+ the stories read-back so the two completion paths can never drift.
103
+
104
+ NEUTRAL — no snapshot, a different file, or bytes changed since launch: fall
105
+ through to the path's normal accept logic.
106
+ PROVEN — a mid-session status transition (M2) was observed for this spec:
107
+ single-sighting harvest, and it OUTRANKS a byte-identical hash (a clean review
108
+ can round-trip back to the launch bytes yet provably ran).
109
+ REFUSE — bytes still byte-identical to the review-launch snapshot AND no
110
+ transition was observed (M1): the documented dead-window false positive
111
+ (a `done` spec re-opened for review, mtime-bumped but never re-driven).
112
+ """
113
+
114
+ NEUTRAL = "neutral"
115
+ PROVEN = "proven"
116
+ REFUSE = "refuse"
117
+
118
+
119
+ # min spacing between heartbeat.json overwrites in wait_for_completion; the
120
+ # heartbeat's staleness is what makes a frozen orchestrator (#157) diagnosable.
121
+ HEARTBEAT_INTERVAL_S = 30.0
122
+ EVENT_KINDS = {"SessionStart", "Stop", "SessionEnd"}
123
+ NUDGE_TEXT = (
124
+ "You are running in froid-loop automation mode. Finish the workflow now: "
125
+ "complete any remaining steps and write the result JSON file to "
126
+ "$FROID_LOOP_RUN_DIR/tasks/$FROID_LOOP_TASK_ID/result.json, then end your turn."
127
+ )
128
+ # Wake an idle dev session whose grace window elapsed with no output. froid-loop
129
+ # has no background-completion re-invocation, so a turn ended to await a slow
130
+ # background process (a Unity PlayMode run, a long test) would otherwise wait
131
+ # forever; this nudge IS that re-invocation. Skill-agnostic: it must not assume a
132
+ # result.json (the froid-build-auto skill writes none — see GenericDevAdapter).
133
+ STALL_NUDGE_TEXT = (
134
+ "You appear idle in froid-loop automation mode, which cannot re-invoke you when "
135
+ "a background process finishes. If you are waiting on one (e.g. a Unity PlayMode "
136
+ "run or a long test), check its status now and continue the workflow; if it is "
137
+ "done, finalize the work and end your turn. If you are stuck, say so and stop. "
138
+ "Note: a prose reply cannot end this session — only your workflow's completion "
139
+ "artifact (the spec's terminal status / result file) does; if the work is "
140
+ "already complete, write it before ending your turn."
141
+ )
142
+ # Wrap-up demand for a session that crossed its token budget (#158, enforce
143
+ # mode): the guard arms a bounded grace window right after sending this, so the
144
+ # session must converge now — it will be terminated over_budget otherwise.
145
+ BUDGET_NUDGE_TEXT = (
146
+ "You have exceeded this session's token budget in froid-loop automation mode. "
147
+ "Stop exploring and wrap up now: commit whatever is finished, write your "
148
+ "workflow's completion artifact (the spec's terminal status / result file), "
149
+ "and end your turn. Note: a prose reply cannot end this session — only the "
150
+ "completion artifact does; if you cannot finish, mark the work blocked in it "
151
+ "and end your turn."
152
+ )
153
+ # Targeted contract-repair nudge (#276 M4): a Stop found the spec at
154
+ # {spec_path} finalized to terminal frontmatter status {status} but WITHOUT the
155
+ # `## Auto Run Result` section froid-loop's harvest scan keys on. Ask the skill to
156
+ # append that section itself so the omission is fixed at the source (a compliant
157
+ # append is then harvested by the normal scan; harness-side frontmatter synthesis
158
+ # stays the backstop). Sent at most once per session and never re-armed, so it is
159
+ # safe to be specific and directive. Guarded ("if this spec is not yours or the
160
+ # work is unfinished") so a session legitimately mid-workflow is not derailed.
161
+ CONTRACT_NUDGE_TEXT = (
162
+ "You are running in froid-loop automation mode. The spec at {spec_path} now "
163
+ "carries a terminal frontmatter `status: {status}`, but it is missing the "
164
+ "`## Auto Run Result` section your contract requires — froid-loop harvests "
165
+ "that section, not the frontmatter, so without it this finished story looks "
166
+ "unfinished. If this spec is yours and the work is done, append the section "
167
+ "to the spec now — the `## Auto Run Result` heading, a `Status: {status}` "
168
+ "line matching the frontmatter, and a brief summary — then end your turn. If "
169
+ "this spec is not yours, or the work is not actually finished, ignore this "
170
+ "and continue your workflow instead."
171
+ )
172
+
173
+
174
+ class _ResultFileMixin:
175
+ """Result-file read-back and verdict finalization: acquire the
176
+ skill-written result dict and fold it into the session's final
177
+ ``SessionResult``. Transport-agnostic — shared by the tmux adapters and
178
+ any adapter whose skill writes ``tasks/<task_id>/result.json``; needs
179
+ only ``self.tasks_dir`` and ``self.run_dir``."""
180
+
181
+ # Set by the concrete adapter's __init__; bare annotations (no runtime
182
+ # effect) tell the type checker the host attributes this mixin reads.
183
+ tasks_dir: Path
184
+ run_dir: Path
185
+
186
+ # Whether `_final` applies the #261 proof-of-work gate to its read-back. False
187
+ # here, and that is not a conservative default — it is the correct answer for
188
+ # this mixin's own read-back. `tasks/<task_id>/result.json` is task-unique and
189
+ # `start_session` unlinks it before launch, so its presence is already proof
190
+ # THIS session wrote it; a foreign writer cannot reach it. Gating it could only
191
+ # ever downgrade an authoritative completion. Overridden True by
192
+ # `_DevSynthesisMixin`, whose read-back scans a directory shared with every
193
+ # concurrent run — the one place a result can belong to somebody else.
194
+ _READBACK_NEEDS_PROOF_OF_WORK = False
195
+
196
+ def _hard_stop_requested(self) -> bool:
197
+ """Has an operator lodged a *hard* stop request that this session must
198
+ honor (#319)? Either this run's own, or the owning run's.
199
+
200
+ Polled twice per wait-loop iteration by both real adapters — on either
201
+ side of the loop's own blocking wait — so a
202
+ ``froid-loop stop`` is honored mid-session on platforms where the
203
+ engine's SIGTERM path is unreachable. Read-only by contract: the
204
+ adapter never unlinks ``stop-request.json`` — the engine consumes it
205
+ when it raises, and must still see it to attribute the stop. A torn or
206
+ modeless read already leans ``"graceful"`` inside
207
+ ``read_stop_request_mode``, so this can never abort a session
208
+ spuriously.
209
+
210
+ Both dirs are read because a nested auto-sweep is a first-class run *and*
211
+ somebody else's child: it mints its own id and appears in ``list``, so
212
+ ``stop <child-id>`` must still reach it, while ``stop <parent-id>`` lodges
213
+ in a dir this adapter would otherwise never look at. The owner leg is
214
+ hard-only, like this whole predicate — a graceful request already
215
+ suppresses a child sweep from *starting*, and letting one already in flight
216
+ finish is exactly what graceful means."""
217
+ if runs.read_stop_request_mode(self.run_dir) == "hard":
218
+ return True
219
+ owner = runs.owner_run_dir()
220
+ # `!=` is a cheap dedupe for the common top-level case, not a correctness
221
+ # dependency: two spellings of one dir cost a redundant read, same answer.
222
+ return (
223
+ owner is not None
224
+ and owner != self.run_dir
225
+ and runs.read_stop_request_mode(owner) == "hard"
226
+ )
227
+
228
+ def _result_json(self, handle: SessionHandle, spec: SessionSpec, *, wait: bool) -> dict | None:
229
+ """Acquire this session's result dict. Base behavior: read the
230
+ skill-written ``result.json`` (briefly awaiting it on the Stop event,
231
+ reading once otherwise). Subclasses whose skill writes no result.json
232
+ (GenericDevAdapter) override this to synthesize the dict from another
233
+ on-disk artifact."""
234
+ return self._await_result(handle.task_id) if wait else self._read_result(handle.task_id)
235
+
236
+ def _produced_work(self, handle: SessionHandle, stop_seen: bool) -> bool:
237
+ """Whether this session shows ANY evidence it actually ran, for the #261
238
+ proof-of-work gate. Deliberately a very low bar — it separates "the CLI
239
+ wedged before it did anything" from "the CLI worked", not good work from bad.
240
+
241
+ Two independent signals, ORed, because each has a known blind spot: a `Stop`
242
+ event having arrived covers an adapter whose pane sink is misbound (#254/#217,
243
+ where a HEALTHY session still logs zero bytes), and pane-log growth covers a
244
+ profile whose hooks never fire. Requiring both to be absent is what makes the
245
+ gate safe to apply to a `completed` upgrade.
246
+
247
+ The hook signal is `Stop` specifically — a turn that ENDED — not "a hook
248
+ event arrived". Of the three canonical events, `SessionStart` fires before
249
+ the session does anything and `SessionEnd` fires when it stops being one;
250
+ both are emitted by a CLI that launched and wedged, so accepting either
251
+ would leave the gate satisfied in exactly the case it exists to catch. The
252
+ #254/#217 rationale is unaffected: a healthy session ends its turn.
253
+
254
+ Unknown never blocks: `_log_evidence` returns None when there is no signal at
255
+ all (no pane log — the opencode-http transport, and every unit-test fixture),
256
+ and that reads as evidence-present, preserving current behavior exactly."""
257
+ if stop_seen:
258
+ return True
259
+ evidence = self._log_evidence(handle)
260
+ return True if evidence is None else evidence
261
+
262
+ def _log_evidence(self, handle: SessionHandle) -> bool | None:
263
+ """Tristate pane-log proof-of-work signal: True = the log grew past a
264
+ trivial floor, False = the log exists and did not, None = no such signal for
265
+ this transport. Base: None (inert). Overridden by `GenericAdapter`, which
266
+ tees a pane log."""
267
+ return None
268
+
269
+ def _session_vanished(self) -> bool:
270
+ """Whether the whole multiplexer session is gone, asked only once a
271
+ crash verdict has already been reached (#489). Base: False — an adapter
272
+ with no session to lose (opencode-http) never vanishes. Overridden by
273
+ `GenericAdapter`.
274
+
275
+ Same failure convention as `_window_alive`: `MultiplexerError` is the
276
+ seam's declared "couldn't ask" and the override swallows it to False.
277
+ Anything else propagates, exactly as it does from the liveness probe —
278
+ this is a label on a verdict already made, so it degrades rather than
279
+ second-guessing the verdict, but it does not swallow unknown faults."""
280
+ return False
281
+
282
+ def _final(
283
+ self,
284
+ handle: SessionHandle,
285
+ spec: SessionSpec,
286
+ fallback: str,
287
+ session_id: str | None,
288
+ transcript: str | None,
289
+ *,
290
+ accept_result: bool = True,
291
+ budget_weighted: int | None = None,
292
+ stop_seen: bool = False,
293
+ ) -> SessionResult:
294
+ """Session is gone or done responding: completed if the result file
295
+ landed anyway, otherwise the fallback status. ``accept_result=False``
296
+ (a stall verdict reached under a live window) pins the fallback: an
297
+ artifact that appeared without a Stop or window death is not trusted.
298
+ ``budget_weighted`` (a tripped session-budget guard's sample) rides
299
+ every exit so the engine can journal it whatever the verdict.
300
+ ``stop_seen`` is the proof-of-work hook signal, threaded separately from
301
+ ``session_id``/``transcript`` because those are also set by a mere launch."""
302
+ result_json = self._result_json(handle, spec, wait=False) if accept_result else None
303
+ if (
304
+ result_json is not None
305
+ and self._READBACK_NEEDS_PROOF_OF_WORK
306
+ and not self._produced_work(handle, stop_seen)
307
+ ):
308
+ # Proof-of-work gate (#261): this session is gone and produced no
309
+ # observable output at all — no turn ever ended AND its pane log never
310
+ # grew. A read-back artifact is then not evidence THIS session finished;
311
+ # it is evidence that SOMETHING wrote a qualifying file in a directory we
312
+ # share. Keep the fallback verdict rather than upgrade a dead-on-arrival
313
+ # session to `completed`.
314
+ self._note_lifecycle(
315
+ handle.task_id,
316
+ "readback-refused-no-proof-of-work",
317
+ fallback=fallback,
318
+ spec=str(result_json.get("spec_file", "")),
319
+ status=str(result_json.get("status", "")),
320
+ )
321
+ result_json = None
322
+ status = "completed" if result_json is not None else fallback
323
+ # Diagnose the crash verdict only (#489) — see `_session_vanished`. A
324
+ # read-back upgrade to `completed` is deliberately not diagnosed: a
325
+ # session reaped AFTER flushing its result did produce something, and the
326
+ # verdict it earned is the honest one. `crashed` also covers the
327
+ # `SessionEnd` arm of `GenericAdapter.run()`, where the CLI announced
328
+ # its own exit rather than the window dying — the label stays truthful
329
+ # there because it reports what the mux answered, not how the window
330
+ # ended.
331
+ vanished = status == "crashed" and self._session_vanished()
332
+ if vanished:
333
+ # Evidence rides along like every neighbouring crumb: which session
334
+ # went missing (several runs share a host) and what verdict it lands.
335
+ # getattr because the mixin does not declare `session_name` (opencode-
336
+ # http has none) and only a mux-backed adapter can reach this branch
337
+ # (the base `_session_vanished` is a constant False). No default — an
338
+ # override on an adapter without a session name must fail loud here,
339
+ # not write evidence-free crumbs.
340
+ self._note_lifecycle(
341
+ handle.task_id,
342
+ "session-vanished",
343
+ session=getattr(self, "session_name"),
344
+ status=status,
345
+ )
346
+ return SessionResult(
347
+ status=status,
348
+ result_json=result_json,
349
+ session_id=session_id,
350
+ transcript_path=transcript,
351
+ budget_weighted=budget_weighted,
352
+ stop_seen=stop_seen,
353
+ session_vanished=vanished,
354
+ )
355
+
356
+ def _result_path(self, task_id: str) -> Path:
357
+ return self.tasks_dir / task_id / "result.json"
358
+
359
+ def _append_diag_jsonl(self, task_id: str, filename: str, payload: dict) -> None:
360
+ """Append ``payload`` as one JSON line to ``tasks/<task_id>/<filename>``.
361
+ Pure observability, best-effort: an unwritable run dir must never break
362
+ the completion loop. ``ensure_ascii=False`` is why the guard names more
363
+ than OSError: it leaves a lone surrogate — what a POSIX filename holding
364
+ a non-UTF-8 byte becomes, surrogate-escaped — in the dumped str, which
365
+ then hits the UTF-8 encode inside ``fh.write`` as a UnicodeEncodeError.
366
+ That is a ValueError, not an OSError; ``UnicodeError`` covers it and the
367
+ decode direction both (#380)."""
368
+ try:
369
+ path = self.tasks_dir / task_id / filename
370
+ path.parent.mkdir(parents=True, exist_ok=True)
371
+ line = json.dumps(payload, ensure_ascii=False)
372
+ with path.open("a", encoding="utf-8") as fh:
373
+ fh.write(line + "\n")
374
+ except (OSError, UnicodeError):
375
+ pass
376
+
377
+ def _note_resultless_stop(self, task_id: str, verdict: str, detail: str = "") -> None:
378
+ """Append a diagnostic breadcrumb when a Stop's artifact read-back gives
379
+ up empty: one JSON line ({ts, verdict, detail}) in
380
+ ``tasks/<task_id>/resultless-stops.jsonl`` — the #149 nudge livelock
381
+ was undiagnosable because nothing recorded *why* each Stop read as
382
+ result-less."""
383
+ self._append_diag_jsonl(
384
+ task_id,
385
+ "resultless-stops.jsonl",
386
+ {"ts": time.time_ns(), "verdict": verdict, "detail": detail},
387
+ )
388
+
389
+ def _note_lifecycle(self, task_id: str, event: str, **fields) -> None:
390
+ """Append a session-lifecycle breadcrumb ({ts, event, ...}) to
391
+ ``tasks/<task_id>/session-lifecycle.jsonl`` — issue #157's timeout fired
392
+ with zero record of *when* the adapter declared it or which clock had
393
+ elapsed, so a 2h19 journaling gap was unattributable."""
394
+ self._append_diag_jsonl(
395
+ task_id,
396
+ "session-lifecycle.jsonl",
397
+ {"ts": time.time_ns(), "event": event, **fields},
398
+ )
399
+
400
+ def _write_heartbeat(self, task_id: str, payload: dict) -> None:
401
+ """Best-effort overwrite of ``tasks/<task_id>/heartbeat.json``: the wait
402
+ loop's proof-of-life. A heartbeat much staler than HEARTBEAT_INTERVAL_S
403
+ under a still-running session means the orchestrator itself was frozen
404
+ (host starvation, macOS sleep — #157), not the CLI."""
405
+ try:
406
+ (self.tasks_dir / task_id / "heartbeat.json").write_text(
407
+ json.dumps(payload, ensure_ascii=False), encoding="utf-8"
408
+ )
409
+ except OSError:
410
+ pass
411
+
412
+ def _read_result(self, task_id: str) -> dict | None:
413
+ path = self._result_path(task_id)
414
+ if not path.is_file():
415
+ return None
416
+ try:
417
+ data = json.loads(path.read_text(encoding="utf-8"))
418
+ except (json.JSONDecodeError, OSError):
419
+ return None
420
+ return data if isinstance(data, dict) else None
421
+
422
+ def _await_result(self, task_id: str, grace_s: float = RESULT_GRACE_S) -> dict | None:
423
+ deadline = time.monotonic() + grace_s
424
+ while True:
425
+ result = self._read_result(task_id)
426
+ if result is not None:
427
+ return result
428
+ if time.monotonic() >= deadline:
429
+ self._note_resultless_stop(
430
+ task_id, "no-result-json", f"no readable {self._result_path(task_id)}"
431
+ )
432
+ return None
433
+ time.sleep(RESULT_POLL_S)
434
+
435
+
436
+ class GenericAdapter(_ResultFileMixin, EnvFaultMixin, CodingCLIAdapter):
437
+ injection = "tmux-initial-prompt"
438
+ observation = "hook-signal"
439
+ state = "local-jsonl"
440
+
441
+ def __init__(
442
+ self,
443
+ run_dir: Path,
444
+ policy: Policy,
445
+ profile: CLIProfile,
446
+ binary: str | None = None,
447
+ extra_args: tuple[str, ...] | None = None,
448
+ usage_grace_s: float | None = None,
449
+ stop_without_result_nudges: int | None = None,
450
+ mux: TerminalMultiplexer | None = None,
451
+ events_dir: Path | None = None,
452
+ ):
453
+ self.run_dir = run_dir
454
+ self.policy = policy
455
+ self.profile = profile
456
+ # env-fault patterns compile lazily off self.profile — see EnvFaultMixin.
457
+ self.mux = mux or get_multiplexer()
458
+ # None = use the profile's default bypass flags; a tuple replaces them
459
+ self.extra_args = extra_args
460
+ # Effective timing knobs: an explicit [adapter]/[adapter.<stage>] override
461
+ # wins, else the CLI profile's shipped default, else the global fallback.
462
+ self._usage_grace_s = usage_grace_s if usage_grace_s is not None else profile.usage_grace_s
463
+ self._stop_nudges = (
464
+ stop_without_result_nudges
465
+ if stop_without_result_nudges is not None
466
+ else (
467
+ profile.stop_without_result_nudges
468
+ if profile.stop_without_result_nudges is not None
469
+ else policy.limits.stop_without_result_nudges
470
+ )
471
+ )
472
+ # Grace for a result-less Stop before declaring a stall. 0 (base default)
473
+ # keeps the fail-fast behavior; the dev adapter raises it so a session
474
+ # that ended its turn awaiting a background process isn't mis-stalled.
475
+ self._stall_grace_s = 0.0
476
+ # Wake-nudges to spend on grace expiry before stalling. 0 here is moot for
477
+ # the base adapter (grace 0 never opens the window); the dev adapter sets
478
+ # it from policy so an idle wait is re-invoked rather than killed outright.
479
+ self._stall_nudges = 0
480
+ self.name = f"{profile.name}-tmux"
481
+ self.binary = binary or profile.binary
482
+ self.session_name = f"froid-loop-{run_dir.name}"
483
+ # The run's hook-event channel (#494): the out-of-tree directory the run
484
+ # bootstrap resolved, plus the legacy in-tree one kept under poll so a
485
+ # project whose installed relay predates the move still completes its
486
+ # sessions. `events_dir` is handed in rather than derived here because
487
+ # deriving it needs the PROJECT, and the only project this class can
488
+ # reach is `run_dir.parents[2]` — a shape real run dirs have and test run
489
+ # dirs do not, so a derivation would key the watcher off a directory that
490
+ # is not the project (see `_ensure_session`, which accepts exactly that
491
+ # weakness for a session tag but must not for the completion channel).
492
+ # Defaulting to the legacy dir keeps direct construction (tests, any
493
+ # caller outside `runsetup.make_adapters`) working unchanged; the
494
+ # bootstrap always passes one, pinned by a test.
495
+ self.watcher = SignalWatcher(events_dir or run_dir / "events", run_dir / "events")
496
+ self.tasks_dir = run_dir / "tasks"
497
+ self.logs_dir = run_dir / LOGS_DIR
498
+ self.tasks_dir.mkdir(parents=True, exist_ok=True)
499
+ self.logs_dir.mkdir(parents=True, exist_ok=True)
500
+
501
+ # --------------------------------------------------------- multiplexer
502
+
503
+ def _ensure_session(self, cwd: Path) -> None:
504
+ if not self.mux.has_session(self.session_name):
505
+ self.mux.new_session(self.session_name, cwd, PANE_COLUMNS, PANE_LINES)
506
+ # Tag the session with its project so a cleanup in another project
507
+ # never prunes this run (run_dir = <project>/.froid-loop/runs/<id>).
508
+ project = self.run_dir.parents[2]
509
+ self.mux.set_session_option(
510
+ self.session_name, runs.PROJECT_OPTION, runs.project_tag(project)
511
+ )
512
+
513
+ def interactive_argv(self, spec: SessionSpec) -> list[str]:
514
+ extra = self.extra_args
515
+ if extra is None:
516
+ extra = self.profile.bypass_args
517
+ argv = [
518
+ self.binary,
519
+ *self.profile.launch_args,
520
+ self.profile.render_prompt(spec.prompt),
521
+ *extra,
522
+ ]
523
+ if spec.model:
524
+ argv += [self.profile.model_flag, spec.model]
525
+ return argv
526
+
527
+ def interactive_env(self, spec: SessionSpec) -> dict[str, str]:
528
+ # The pin chokepoint (runs.pin_state_root): the profile's [env] table
529
+ # must not be able to move a session off this process's state root —
530
+ # including when no root derives, where there is no pin key for a mere
531
+ # spread ordering to protect. `start_session`'s window merge applies
532
+ # the same rule.
533
+ return runs.pin_state_root({**self.profile.env, **spec.env})
534
+
535
+ def build_command(self, spec: SessionSpec) -> str:
536
+ return " ".join(shlex.quote(a) for a in self.interactive_argv(spec))
537
+
538
+ # --------------------------------------------------------------- adapter
539
+
540
+ def start_session(self, spec: SessionSpec) -> SessionHandle:
541
+ task_dir = self.tasks_dir / spec.task_id
542
+ task_dir.mkdir(parents=True, exist_ok=True)
543
+ (task_dir / "prompt.txt").write_text(spec.prompt + "\n", encoding="utf-8")
544
+ # A re-armed/resumed run reuses task_ids; drop any prior cycle's result
545
+ # so a session that writes nothing can't be read as a stale completion.
546
+ (task_dir / "result.json").unlink(missing_ok=True)
547
+
548
+ self._ensure_session(spec.cwd)
549
+ # Stamped before launch: hook events carry wall-clock ns, and
550
+ # wait_for_completion ignores anything older than this floor so a reused
551
+ # task_id's earlier Stop event cannot replay.
552
+ launched_ns = time.time_ns()
553
+ log_file = self.logs_dir / f"{spec.task_id}.log"
554
+ # A re-armed run reuses task_ids and both mux backends append; drop the prior
555
+ # cycle's tee so the #194 tail scan can't match a stale transport error (mirrors
556
+ # the result.json unlink above; journal.py already assumes "next session replaces it").
557
+ log_file.unlink(missing_ok=True)
558
+ # ...then create it EMPTY, before the window exists. `pipe_pane` below tolerates
559
+ # a window that already died and then attaches no tee, so without this a
560
+ # dead-on-arrival session leaves NO log at all — and an absent log is the
561
+ # `_log_evidence` "this transport has no pane signal" state, which the #261
562
+ # proof-of-work gate treats as inert. The gate would fail OPEN in exactly the
563
+ # case it exists to catch. A 0-byte file says something truer and stronger:
564
+ # this transport does tee a pane, and this session rendered nothing into it.
565
+ # (Both backends append, so pre-creating cannot truncate a live tee. Stall
566
+ # detection is unaffected: `_log_activity_key` reports (mtime, 0) instead of
567
+ # None, and every reader compares signatures rather than testing existence.)
568
+ log_file.touch()
569
+ window_id = self.mux.new_window(
570
+ self.session_name,
571
+ spec.task_id[-40:],
572
+ spec.cwd,
573
+ # Same merge as interactive_env, same pin chokepoint: the profile's
574
+ # [env] table must not move the window off this process's state root.
575
+ runs.pin_state_root({**self.profile.env, **spec.env}),
576
+ self.build_command(spec),
577
+ )
578
+ # pipe_pane tolerates the window having already died (a CLI that crashes on
579
+ # launch can take it down before the tee attaches); the dead window is then
580
+ # reported as a crash in wait_for_completion.
581
+ self.mux.pipe_pane(window_id, log_file)
582
+ return SessionHandle(task_id=spec.task_id, native_id=window_id, launched_ns=launched_ns)
583
+
584
+ def wait_for_completion(self, handle: SessionHandle, spec: SessionSpec) -> SessionResult:
585
+ deadline = time.monotonic() + spec.timeout_s
586
+ # Wall-clock co-bound (#157): a host suspend freezes time.monotonic(),
587
+ # silently extending the monotonic deadline by the nap's length. The
588
+ # wall clock keeps counting through a suspend, so it may EXPIRE the
589
+ # deadline — never extend it; all sub-waits below stay monotonic (a
590
+ # wall clock stepped backward must not stretch the session).
591
+ wall_deadline = time.time() + spec.timeout_s
592
+ session_id: str | None = None
593
+ transcript_path: str | None = None
594
+ nudges_left = self._stop_nudges
595
+ # Positive grace arms at launch for dev/review sessions, so a CLI that
596
+ # goes silent before its first Stop cannot burn the full wall timeout. A
597
+ # fresh Stop or later pane growth re-arms it; None = grace disabled.
598
+ stall_deadline = time.monotonic() + self._stall_grace_s if self._stall_grace_s > 0 else None
599
+ # pane-log activity signature captured when the grace window is armed; a
600
+ # session streaming output (a long productive turn, a streaming subagent)
601
+ # advances it and re-arms the window, so only genuine silence stalls.
602
+ last_activity = (
603
+ self._log_activity_key(handle.task_id) if stall_deadline is not None else None
604
+ )
605
+ # wake-nudges left to spend when the grace window elapses in silence: the
606
+ # session likely ended its turn awaiting a background process, so we prod
607
+ # it (froid-loop has no background re-invocation) instead of stalling. A
608
+ # fresh Stop — proof it woke and acted — restores the budget; only an
609
+ # unresponsive session burns through it. Bounded overall by spec.timeout_s.
610
+ stall_nudges_left = self._stall_nudges
611
+ # monotonic total of stall nudges sent this session — never restored,
612
+ # unlike stall_nudges_left. When spec.stall_nudges_cap is set (the
613
+ # engine sets it for every session it drives), a session that keeps
614
+ # ending its turn without a result cannot ride the fresh-Stop refill
615
+ # forever: after cap total nudges it is declared stalled. cap=None
616
+ # (raw constructor default) skips the check.
617
+ stall_nudges_sent = 0
618
+ # latched on the first accepted `Stop`: the hook half of the #261 proof-of-work
619
+ # gate. Tracked apart from session_id/transcript_path — those are populated by
620
+ # SessionStart and SessionEnd too, which a CLI that launched and wedged emits
621
+ # without doing any work. Rides out on every exit (see SessionResult.stop_seen)
622
+ # so `_post_kill_reconcile` reads the same signal after run() kills the window.
623
+ stop_seen = False
624
+ # internal observability counter: counts ticks where the liveness probe
625
+ # raised a transport error (e.g. a 30s tmux hang). It deliberately does
626
+ # NOT escalate to "crashed" — a transient transport hiccup is not proof
627
+ # of death; spec.timeout_s already bounds a persistent failure to a
628
+ # timeout.
629
+ probe_failures = 0
630
+ # monotonic ts of the last heartbeat.json overwrite; None = not yet
631
+ # written, so the first tick always stamps one.
632
+ last_heartbeat: float | None = None
633
+ # Session-budget guard (#158): latched on the first cap crossing — the
634
+ # warn/nudge fires at most once per session. budget_deadline is the
635
+ # enforce-mode monotonic grace expiry (None = not armed); checked every
636
+ # tick, unlike the heartbeat-throttled sampling that arms it. The wall
637
+ # deadline is the #157 co-bound: a host suspend freezes
638
+ # time.monotonic(), silently stretching the "bounded" wrap-up window,
639
+ # so the wall clock may EXPIRE the grace — never extend it.
640
+ budget_tripped = False
641
+ budget_weighted: int | None = None
642
+ budget_deadline: float | None = None
643
+ budget_wall_deadline: float | None = None
644
+
645
+ while True:
646
+ remaining = deadline - time.monotonic()
647
+ wall_expired = time.time() >= wall_deadline
648
+ if remaining <= 0 or wall_expired:
649
+ if remaining <= 0 and wall_expired:
650
+ expired = "both"
651
+ elif remaining <= 0:
652
+ expired = "monotonic"
653
+ else:
654
+ # wall-only expiry with monotonic time to spare: the
655
+ # monotonic clock stood still — the suspend signature.
656
+ expired = "wall"
657
+ self._note_lifecycle(
658
+ handle.task_id,
659
+ "timeout-fired",
660
+ expired_clock=expired,
661
+ timeout_s=spec.timeout_s,
662
+ mono_remaining_s=round(remaining, 3),
663
+ )
664
+ return SessionResult(
665
+ status="timeout",
666
+ session_id=session_id,
667
+ transcript_path=transcript_path,
668
+ timeout_fired_at=time.time(),
669
+ timeout_expired_clock=expired,
670
+ budget_weighted=budget_weighted,
671
+ stop_seen=stop_seen,
672
+ )
673
+ # Hard-stop poll (#319), per-iteration and deliberately NOT inside
674
+ # the heartbeat throttle below: the loop's own wait is capped at 5s
675
+ # (`watcher.wait_for(..., timeout_s=min(remaining, 5.0))`), so a stop
676
+ # normally lands well inside `stop_run`'s 10s grace window, while riding
677
+ # the 30s HEARTBEAT_INTERVAL_S would be worse than the status quo. Read
678
+ # that as the common case, not a bound: an iteration that goes on to
679
+ # wait RESULT_GRACE_S for an artifact, or to block on a tmux call under
680
+ # TMUX_TIMEOUT_S, exceeds the grace window on its own. See the second
681
+ # poll after the wait below for how the interval is split, and why it
682
+ # still cannot be made unconditionally short. Return the verdict — never raise `RunStopped` here: that would
683
+ # skip `run()`'s finally-kill + `_post_kill_reconcile`. The file is
684
+ # left on disk for the engine to consume and attribute the stop.
685
+ if self._hard_stop_requested():
686
+ self._note_lifecycle(handle.task_id, "stop-abort-fired")
687
+ return SessionResult(
688
+ status="aborted",
689
+ session_id=session_id,
690
+ transcript_path=transcript_path,
691
+ budget_weighted=budget_weighted,
692
+ stop_seen=stop_seen,
693
+ )
694
+ now = time.monotonic()
695
+ if last_heartbeat is None or now - last_heartbeat >= HEARTBEAT_INTERVAL_S:
696
+ last_heartbeat = now
697
+ self._write_heartbeat(
698
+ handle.task_id,
699
+ {
700
+ "ts": time.time(),
701
+ "remaining_s": round(remaining, 3),
702
+ "stall_armed": stall_deadline is not None,
703
+ "stall_nudges_sent": stall_nudges_sent,
704
+ },
705
+ )
706
+ # Mid-session spec-status transition sampling (#276 M2) rides the
707
+ # same heartbeat cadence — a no-op unless this adapter drives the
708
+ # generic skill and the engine threaded a launch snapshot.
709
+ self._observe_tick(handle, spec)
710
+ # Budget sampling rides the heartbeat cadence — no extra knob.
711
+ # transcript_path is unknown until the first hook event carries
712
+ # it (SessionStart for claude); until then the guard is inert.
713
+ if (
714
+ not budget_tripped
715
+ and spec.token_budget is not None
716
+ and spec.token_budget_mode in ("warn", "enforce")
717
+ and transcript_path
718
+ ):
719
+ weighted = self._sample_weighted_usage(transcript_path, spec)
720
+ if weighted is not None and weighted > spec.token_budget:
721
+ budget_tripped = True
722
+ budget_weighted = weighted
723
+ self._note_lifecycle(
724
+ handle.task_id,
725
+ "budget-tripped",
726
+ weighted=weighted,
727
+ budget=spec.token_budget,
728
+ mode=spec.token_budget_mode,
729
+ )
730
+ try:
731
+ gates.notify(
732
+ self.policy,
733
+ self.run_dir,
734
+ "froid-loop session over token budget",
735
+ f"{handle.task_id}: weighted spend {weighted} crossed the "
736
+ f"{spec.token_budget} per-session cap "
737
+ f"(mode={spec.token_budget_mode})",
738
+ )
739
+ except OSError:
740
+ # observe-degrade: an unwritable ATTENTION file is
741
+ # observability, never a reason to break the loop
742
+ # (the _write_heartbeat doctrine).
743
+ pass
744
+ # nosec below: bandit B105 pattern-matches the "token"
745
+ # in token_budget_mode as a hardcoded-password compare;
746
+ # it is a mode enum, not a credential.
747
+ if spec.token_budget_mode == "enforce": # nosec B105
748
+ if spec.token_budget_grace_s <= 0:
749
+ # zero grace = terminate at trip, no nudge — but
750
+ # window death still wins (artifact honored via
751
+ # the crash path), exactly like grace expiry; a
752
+ # transport error is not proof of death.
753
+ try:
754
+ if not self._window_alive(handle):
755
+ return self._final(
756
+ handle,
757
+ spec,
758
+ "crashed",
759
+ session_id,
760
+ transcript_path,
761
+ budget_weighted=weighted,
762
+ stop_seen=stop_seen,
763
+ )
764
+ except MultiplexerError:
765
+ pass
766
+ self._note_lifecycle(
767
+ handle.task_id,
768
+ "over-budget-fired",
769
+ weighted=weighted,
770
+ budget=spec.token_budget,
771
+ grace_s=spec.token_budget_grace_s,
772
+ zero_grace=True,
773
+ )
774
+ return SessionResult(
775
+ status="over_budget",
776
+ session_id=session_id,
777
+ transcript_path=transcript_path,
778
+ budget_weighted=weighted,
779
+ stop_seen=stop_seen,
780
+ )
781
+ try:
782
+ self.send_text(handle, BUDGET_NUDGE_TEXT)
783
+ except MultiplexerError:
784
+ # a dead/hung window can't take the nudge; the
785
+ # grace still arms — the next tick's liveness
786
+ # probe scores a dead window crashed.
787
+ pass
788
+ budget_deadline = time.monotonic() + spec.token_budget_grace_s
789
+ budget_wall_deadline = time.time() + spec.token_budget_grace_s
790
+ if budget_deadline is not None and (
791
+ time.monotonic() >= budget_deadline
792
+ or (budget_wall_deadline is not None and time.time() >= budget_wall_deadline)
793
+ ):
794
+ # Grace expired with no completion (wall co-bound included: a
795
+ # suspend-frozen monotonic clock must not stretch the window,
796
+ # #157). Window death is authoritative (its artifact is honored
797
+ # via the crash path); under a live window the session ends
798
+ # over_budget WITHOUT reading the result file — an artifact
799
+ # under a live window is never trusted (#48/#53). A transport
800
+ # error is not proof of death, so it falls through to the
801
+ # over_budget verdict.
802
+ try:
803
+ if not self._window_alive(handle):
804
+ return self._final(
805
+ handle,
806
+ spec,
807
+ "crashed",
808
+ session_id,
809
+ transcript_path,
810
+ budget_weighted=budget_weighted,
811
+ stop_seen=stop_seen,
812
+ )
813
+ except MultiplexerError:
814
+ pass
815
+ self._note_lifecycle(
816
+ handle.task_id,
817
+ "over-budget-fired",
818
+ weighted=budget_weighted,
819
+ budget=spec.token_budget,
820
+ grace_s=spec.token_budget_grace_s,
821
+ zero_grace=False,
822
+ )
823
+ return SessionResult(
824
+ status="over_budget",
825
+ session_id=session_id,
826
+ transcript_path=transcript_path,
827
+ budget_weighted=budget_weighted,
828
+ stop_seen=stop_seen,
829
+ )
830
+ event = self.watcher.wait_for(
831
+ handle.task_id,
832
+ EVENT_KINDS,
833
+ timeout_s=min(remaining, 5.0),
834
+ since_ns=handle.launched_ns,
835
+ )
836
+ # Second poll, and the reason there are two (#319). The arm at the top of
837
+ # the loop is separated from its next run by everything between: the 5s
838
+ # wait above, plus whichever dispatch leg the event selects — a
839
+ # `_window_alive` or `send_text` bounded only by TMUX_TIMEOUT_S (30s), or
840
+ # a `_result_json(wait=True)` that waits RESULT_GRACE_S (15s) for an
841
+ # artifact. The last of those alone outlasts `stop_run`'s 10s grace on a
842
+ # perfectly healthy box, with no transport fault anywhere. Polling here
843
+ # splits the iteration so at most one leg sits between two checks. It
844
+ # cannot make the interval unconditionally short — an in-flight
845
+ # subprocess is not interruptible from this thread — so a leg that does
846
+ # outlast the window still degrades to `stop_run`'s force-kill backstop:
847
+ # the pre-#319 outcome, never a worse one.
848
+ if self._hard_stop_requested():
849
+ self._note_lifecycle(handle.task_id, "stop-abort-fired")
850
+ return SessionResult(
851
+ status="aborted",
852
+ session_id=session_id,
853
+ transcript_path=transcript_path,
854
+ budget_weighted=budget_weighted,
855
+ stop_seen=stop_seen,
856
+ )
857
+ if event is None:
858
+ try:
859
+ alive = self._window_alive(handle)
860
+ except MultiplexerError:
861
+ # transport hiccup (e.g. a 30s tmux hang), not proof of
862
+ # death: never roll back a possibly-working session. Skip the
863
+ # crash check this tick; hook events still complete it, and
864
+ # spec.timeout_s bounds a persistent transport failure to an
865
+ # honest "timeout".
866
+ probe_failures += 1
867
+ continue
868
+ probe_failures = 0
869
+ if not alive:
870
+ # died without a SessionEnd hook (killed, crashed hard)
871
+ return self._final(
872
+ handle,
873
+ spec,
874
+ "crashed",
875
+ session_id,
876
+ transcript_path,
877
+ budget_weighted=budget_weighted,
878
+ stop_seen=stop_seen,
879
+ )
880
+ if stall_deadline is not None:
881
+ # No artifact shortcut here: the window is alive on this tick
882
+ # (a dead one returned "crashed" above), and a terminal
883
+ # artifact under a live window is advisory only — the agent
884
+ # may still be mid-turn (or the artifact stale from a prior
885
+ # drive), and run()'s finally-kill would terminate it before
886
+ # its remaining work flushes. Only a Stop event or window
887
+ # death completes the session.
888
+ # The grace window measures inactivity, not time-since-Stop:
889
+ # a session still streaming to the tee'd pane log (a long
890
+ # productive turn building a diff, a streaming subagent) is
891
+ # working, not stalled. Re-arm on any pane growth so only
892
+ # genuine silence for the full grace trips the stall below.
893
+ key = self._log_activity_key(handle.task_id)
894
+ if key is not None and key != last_activity:
895
+ last_activity = key
896
+ stall_deadline = time.monotonic() + self._stall_grace_s
897
+ continue
898
+ if stall_deadline is not None and time.monotonic() >= stall_deadline:
899
+ if stall_nudges_left > 0 and (
900
+ spec.stall_nudges_cap is None or stall_nudges_sent < spec.stall_nudges_cap
901
+ ):
902
+ # The wake nudge IS the re-invocation froid-loop otherwise
903
+ # lacks: prod the idle session and re-arm. Budget is
904
+ # restored only by a fresh Stop (a real turn-end), so the
905
+ # nudge's own echoed keystrokes can't be mistaken for the
906
+ # agent waking; an unresponsive session keeps draining it.
907
+ stall_nudges_left -= 1
908
+ stall_nudges_sent += 1
909
+ try:
910
+ self.send_text(handle, STALL_NUDGE_TEXT)
911
+ except MultiplexerError:
912
+ # A dead/hung window cannot take the nudge. The
913
+ # bounded attempt is still spent, and the next tick's
914
+ # ordinary liveness probe owns the verdict.
915
+ pass
916
+ stall_deadline = time.monotonic() + self._stall_grace_s
917
+ last_activity = self._log_activity_key(handle.task_id)
918
+ continue
919
+ # Re-probe liveness before finalizing: this return exits the
920
+ # loop, so a hard death (no SessionEnd) in the gap since the
921
+ # top-of-tick probe would otherwise never be caught. Window
922
+ # death is authoritative — a now-dead window flows through the
923
+ # crash path (which honors its artifact via accept_result=True)
924
+ # instead of a stall that discards a just-flushed result. A
925
+ # transport error is not proof of death (as at the top of the
926
+ # tick); fall through to the stall — spec.timeout_s bounds a
927
+ # persistent failure.
928
+ try:
929
+ if not self._window_alive(handle):
930
+ return self._final(
931
+ handle,
932
+ spec,
933
+ "crashed",
934
+ session_id,
935
+ transcript_path,
936
+ budget_weighted=budget_weighted,
937
+ stop_seen=stop_seen,
938
+ )
939
+ except MultiplexerError:
940
+ pass
941
+ # Still alive: an artifact on disk cannot upgrade the stall to
942
+ # completed — it may be stale or mid-write; only a Stop or
943
+ # window death vouches for it.
944
+ return self._final(
945
+ handle,
946
+ spec,
947
+ "stalled",
948
+ session_id,
949
+ transcript_path,
950
+ accept_result=False,
951
+ budget_weighted=budget_weighted,
952
+ stop_seen=stop_seen,
953
+ )
954
+ continue
955
+ if (
956
+ event.event == "Stop"
957
+ and self.profile.subagent_stop_without_transcript
958
+ and not event.transcript_path
959
+ ):
960
+ # Copilot fires agentStop for each subagent turn with an empty
961
+ # transcriptPath and a tool-use session id; that is not the main
962
+ # session's turn-end. Ignore it (before accumulating the junk
963
+ # session id) so a subagent's premature Stop is not read as a
964
+ # result-less completion -> false stall, and the main session's
965
+ # real transcript is preserved for usage tallying.
966
+ continue
967
+ session_id = event.session_id or session_id
968
+ transcript_path = event.transcript_path or transcript_path
969
+
970
+ if event.event == "SessionStart":
971
+ continue
972
+ if event.event == "Stop":
973
+ # A turn ENDED — the one canonical event that proves the CLI did
974
+ # something, and so the hook half of the #261 proof-of-work gate.
975
+ # Latched after the subagent filter above, which rejects a stop that
976
+ # is not the main session's turn-end. Never cleared.
977
+ stop_seen = True
978
+ result_json = self._result_json(handle, spec, wait=True)
979
+ if result_json is not None:
980
+ return SessionResult(
981
+ status="completed",
982
+ result_json=result_json,
983
+ session_id=session_id,
984
+ transcript_path=transcript_path,
985
+ budget_weighted=budget_weighted,
986
+ stop_seen=stop_seen,
987
+ )
988
+ if nudges_left > 0:
989
+ nudges_left -= 1
990
+ try:
991
+ self.send_text(handle, NUDGE_TEXT)
992
+ except MultiplexerError:
993
+ # The next deterministic liveness probe decides whether
994
+ # the un-nudgeable window is dead or merely unavailable.
995
+ pass
996
+ continue
997
+ if self._stall_grace_s <= 0:
998
+ return self._final(
999
+ handle,
1000
+ spec,
1001
+ "stalled",
1002
+ session_id,
1003
+ transcript_path,
1004
+ budget_weighted=budget_weighted,
1005
+ stop_seen=stop_seen,
1006
+ )
1007
+ # A result-less Stop, but the session may have ended its turn to
1008
+ # await a background process (a Unity PlayMode run, a slow test)
1009
+ # and expects to be re-invoked on completion. Open/re-arm an idle-
1010
+ # grace window — a later Stop lands here again and resets it, so
1011
+ # only a genuinely idle gap (handled in the no-event branch above)
1012
+ # is a stall. Bounded overall by spec.timeout_s.
1013
+ stall_deadline = time.monotonic() + self._stall_grace_s
1014
+ last_activity = self._log_activity_key(handle.task_id)
1015
+ # a real turn-end proves the session is responsive: restore the
1016
+ # wake-nudge budget so a slow-but-cooperative session can keep
1017
+ # waiting (up to spec.timeout_s), unlike a truly unresponsive one.
1018
+ stall_nudges_left = self._stall_nudges
1019
+ continue
1020
+ if event.event == "SessionEnd":
1021
+ return self._final(
1022
+ handle,
1023
+ spec,
1024
+ "crashed",
1025
+ session_id,
1026
+ transcript_path,
1027
+ budget_weighted=budget_weighted,
1028
+ stop_seen=stop_seen,
1029
+ )
1030
+
1031
+ def _log_evidence(self, handle: SessionHandle) -> bool | None:
1032
+ """Pane-log half of the #261 proof-of-work gate (see
1033
+ `_ResultFileMixin._produced_work`). The pane is tee'd to a stable inode, so
1034
+ its size is a direct measure of how much the session emitted.
1035
+
1036
+ True iff the log exceeded `PROOF_OF_WORK_MIN_LOG_BYTES` (see that constant for
1037
+ why the floor is not zero, and for what it does and does not prove). This
1038
+ measures rendering, not liveness, which is why `_produced_work` ORs it with
1039
+ the turn-ended signal rather than trusting it alone.
1040
+
1041
+ None when the log does not exist — no signal, gate inert. `start_session`
1042
+ creates it empty before the window exists, precisely so a session that died
1043
+ on arrival reports False (rendered nothing) rather than None (no such signal):
1044
+ the DOA case is the gate's whole purpose and must not read as unknown. What
1045
+ is left in the None state is a handle this adapter never launched — unit
1046
+ fixtures — for which "unknown never blocks" is the right and only answer."""
1047
+ try:
1048
+ size = (self.logs_dir / f"{handle.task_id}.log").stat().st_size
1049
+ except OSError:
1050
+ return None
1051
+ return size > PROOF_OF_WORK_MIN_LOG_BYTES
1052
+
1053
+ def _log_activity_key(self, task_id: str) -> tuple[int, int] | None:
1054
+ """Activity signature of the tee'd pane log: (mtime_ns, size), or None if
1055
+ it does not yet exist. The pane is piped via append to a stable inode, so
1056
+ a growing size (and advancing mtime) is a reliable signal the session is
1057
+ still producing output even when no hook event fires."""
1058
+ try:
1059
+ st = (self.logs_dir / f"{task_id}.log").stat()
1060
+ except OSError:
1061
+ return None
1062
+ return (st.st_mtime_ns, st.st_size)
1063
+
1064
+ def _window_alive(self, handle: SessionHandle) -> bool:
1065
+ return handle.native_id in self.mux.list_window_ids(self.session_name)
1066
+
1067
+ def _session_vanished(self) -> bool:
1068
+ # The disambiguating probe (#489): `list_window_ids` returns [] for a
1069
+ # dead window AND for a session that no longer exists, so a plain
1070
+ # window-death verdict cannot tell an exited CLI from a session destroyed
1071
+ # under the run. Only `has_session` separates them.
1072
+ #
1073
+ # The destroyer is NOT necessarily foreign. Candidates: an external
1074
+ # reaper (psmux/psmux#546), a concurrent
1075
+ # `runs.kill_session` from this tool's own prune/stop/crash paths or the
1076
+ # TUI, an operator `kill-session`, a mux server crash, the host sleeping.
1077
+ # The reason text stays neutral about which, because this probe cannot
1078
+ # tell them apart — it reports that the mux no longer answers for the
1079
+ # session, nothing more.
1080
+ #
1081
+ # Safe to ask this late: `run()`'s teardown kills the WINDOW, never the
1082
+ # session, so our own kill cannot fake a vanishing, and a session once
1083
+ # gone stays gone.
1084
+ try:
1085
+ return not self.mux.has_session(self.session_name)
1086
+ except MultiplexerError:
1087
+ # Unknown is not vanished — the same rule the liveness probe follows.
1088
+ return False
1089
+
1090
+ def send_text(self, handle: SessionHandle, text: str) -> None:
1091
+ self.mux.send_text(handle.native_id, text)
1092
+
1093
+ def kill(self, handle: SessionHandle) -> None:
1094
+ grace = float(self.policy.limits.teardown_grace_s)
1095
+ if grace <= 0:
1096
+ # Legacy single strike: no harvest, no wait, no pid reads. grace 0 is the
1097
+ # documented opt-out — teardown stays exactly today's best-effort kill.
1098
+ self.mux.kill_window(handle.native_id)
1099
+ return
1100
+ try:
1101
+ host = get_process_host()
1102
+ except ProcessHostError:
1103
+ # An explicit-but-bogus FROID_LOOP_PROCESS_HOST override raises loudly
1104
+ # (deliberate doctrine — never silently mis-signal). But the lookup now
1105
+ # precedes the first strike, so the window must not be left alive behind
1106
+ # the raise: strike once (today's teardown), then re-raise.
1107
+ self.mux.kill_window(handle.native_id)
1108
+ raise
1109
+ # Harvest the pane roots AND their whole descendant tree NOW, while the
1110
+ # window is provably alive, stamping a pid-reuse identity per member. This
1111
+ # pre-kill snapshot is load-bearing (#183): once the window dies a detached
1112
+ # straggler (setsid, a double-fork survivor) reparents to init and is no
1113
+ # longer reachable from the pane pids, and a late-read pid risks reuse — so
1114
+ # every destructive strike below is identity-guarded via alive_and_ours,
1115
+ # never a bare pid. The snapshot is a point-in-time read: a process that
1116
+ # detached (double-fork/setsid) BEFORE the harvest — or in the TOCTOU window
1117
+ # AFTER it, before/around kill_window — has no pane-pid ancestor to be found
1118
+ # by, so it is out of reach by construction (accepted, documented #183 limit).
1119
+ # An empty list = the backend offers no pids (herdr) → degrade to the window
1120
+ # kill alone.
1121
+ # Descendant identities ride along from the enumeration itself — the same
1122
+ # /proc read (Linux) or the same psutil Process object, revalidated against
1123
+ # its construction-bound ident (macOS/win32), so no post-hoc stamp can race
1124
+ # a reuse;
1125
+ # only the pane ROOT is stamped separately, which is safe: the live window
1126
+ # pins the root pid until kill_window below, so it cannot be recycled here.
1127
+ tree: dict[int, float | None] = {}
1128
+ for pid in self.mux.window_pane_pids(handle.native_id):
1129
+ tree.setdefault(pid, host.identity(pid))
1130
+ for child, identity in host.descendants(pid).items():
1131
+ tree.setdefault(child, identity)
1132
+ # First strike stays the plain best-effort window kill; everything below
1133
+ # verifies it landed and chases the harvested tree.
1134
+ self.mux.kill_window(handle.native_id)
1135
+ deadline = time.monotonic() + grace
1136
+ while True:
1137
+ try:
1138
+ dead = not self._window_alive(handle)
1139
+ except MultiplexerError:
1140
+ dead = False # transport hiccup — liveness unknown this tick, keep polling
1141
+ if dead:
1142
+ # Window died within grace (the normal case): reap any harvested
1143
+ # straggler that outlived the pane pgid, sharing this deadline.
1144
+ self._reap_straggler_tree(handle, host, tree, deadline)
1145
+ return
1146
+ if time.monotonic() >= deadline:
1147
+ break
1148
+ time.sleep(KILL_POLL_S)
1149
+ # The window outlived the grace: the kill was ignored (a wedged CLI, a shell
1150
+ # that trapped the hangup). Re-read the pane pids (freshest view; the live
1151
+ # Popen-free window pins them, so they are safe to force-kill directly) and
1152
+ # force-kill them, plus every harvested descendant still alive-and-ours. The
1153
+ # descendant force-kill is identity-guarded AND skips members with no recorded
1154
+ # identity: alive_and_ours(pid, None) degrades to bare is_alive, so a reused
1155
+ # pid would pass — the ProcessHost contract forbids force-killing it, exactly
1156
+ # like the clean-path reap below. Then re-strike the window.
1157
+ repane = self.mux.window_pane_pids(handle.native_id)
1158
+ self._note_lifecycle(handle.task_id, "kill-escalated", pids=repane)
1159
+ for pid in repane:
1160
+ try:
1161
+ host.force_kill(pid)
1162
+ except Exception: # nosec B110 - already-gone races are fine
1163
+ pass
1164
+ for pid, identity in tree.items():
1165
+ if pid not in repane and identity is not None and host.alive_and_ours(pid, identity):
1166
+ try:
1167
+ host.force_kill(pid)
1168
+ except Exception: # nosec B110 - already-gone races are fine
1169
+ pass
1170
+ self.mux.kill_window(handle.native_id)
1171
+ try:
1172
+ alive: bool | None = self._window_alive(handle)
1173
+ except MultiplexerError:
1174
+ alive = None # unknown is not dead — record it honestly
1175
+ self._note_lifecycle(handle.task_id, "kill-outcome", alive=alive, escalated=True)
1176
+
1177
+ def _reap_straggler_tree(
1178
+ self,
1179
+ handle: SessionHandle,
1180
+ host: ProcessHost,
1181
+ tree: dict[int, float | None],
1182
+ deadline: float,
1183
+ ) -> None:
1184
+ """Reap harvested straggler pids the window death left behind — a
1185
+ setsid/double-fork survivor escapes the pane pgid, so the window's death is
1186
+ not the tree's. Filter to still-alive-and-ours identity-CONFIRMED members,
1187
+ then terminate → poll → force-kill within the SAME grace ``deadline`` (one
1188
+ budget, two phases): terminate first so a mid-write process can flush
1189
+ before SIGKILL. A member whose recorded identity is None is unconfirmable
1190
+ (a possible reuse), so it is never signalled AT ALL — not terminated, not
1191
+ polled against the deadline, not force-killed; even a SIGTERM to a recycled
1192
+ pid kills an innocent process. It only surfaces in the ``unreaped`` field
1193
+ via the bare-liveness degrade. Nothing alive at all → silent return: the
1194
+ clean-end path leaves no breadcrumb."""
1195
+
1196
+ def _confirmed_survivors() -> list[int]:
1197
+ return [
1198
+ pid
1199
+ for pid, identity in tree.items()
1200
+ if identity is not None and host.alive_and_ours(pid, identity)
1201
+ ]
1202
+
1203
+ survivors = _confirmed_survivors()
1204
+ unconfirmed = [
1205
+ pid for pid, identity in tree.items() if identity is None and host.is_alive(pid)
1206
+ ]
1207
+ if not survivors and not unconfirmed:
1208
+ return
1209
+ forced: list[int] = []
1210
+ if survivors:
1211
+ self._note_lifecycle(handle.task_id, "straggler-reap", pids=survivors)
1212
+ for pid in survivors:
1213
+ try:
1214
+ host.terminate(pid)
1215
+ except OSError:
1216
+ pass # already-gone race — the poll below settles it
1217
+ while True:
1218
+ survivors = _confirmed_survivors()
1219
+ if not survivors or time.monotonic() >= deadline:
1220
+ break
1221
+ time.sleep(KILL_POLL_S)
1222
+ for pid in survivors:
1223
+ try:
1224
+ host.force_kill(pid)
1225
+ forced.append(pid)
1226
+ except Exception: # nosec B110 - already-gone races are fine
1227
+ pass
1228
+ unreaped = [pid for pid, identity in tree.items() if host.alive_and_ours(pid, identity)]
1229
+ # Distinct field name (`unreaped`, a pid list) from the wedged branch's
1230
+ # `alive` (bool|None): reusing `alive` for both would give one key an
1231
+ # unstable type across kill-outcome lines — a footgun for jsonl tailers.
1232
+ self._note_lifecycle(
1233
+ handle.task_id, "kill-outcome", reaped=True, forced=forced, unreaped=unreaped
1234
+ )
1235
+
1236
+ def _sample_weighted_usage(self, transcript_path: str, spec: SessionSpec) -> int | None:
1237
+ """Cumulative weighted spend of the live session's transcript, or None
1238
+ when the guard must stay inert this tick (parser "none", nothing
1239
+ tallied yet, an unreadable file). Sampling must never break the wait
1240
+ loop — the liveness-probe tolerance model, for the usage read. The
1241
+ transcript is a LIVE file being appended mid-turn: a flush boundary
1242
+ can split a multibyte UTF-8 character, so the torn read raises
1243
+ UnicodeDecodeError (a ValueError) — as tolerated as an OSError."""
1244
+ try:
1245
+ usage = tally_usage(self.profile.usage_parser, Path(transcript_path))
1246
+ except (OSError, ValueError):
1247
+ return None
1248
+ if usage is None:
1249
+ return None
1250
+ return usage.weighted_total(spec.cache_read_weight)
1251
+
1252
+ def read_usage(self, result: SessionResult) -> TokenUsage | None:
1253
+ if not result.transcript_path:
1254
+ return None
1255
+ path = Path(result.transcript_path)
1256
+ # Some CLIs flush their token totals only on shutdown (Copilot writes
1257
+ # modelMetrics in the trailing session.shutdown line, ~1s after the
1258
+ # turn-end hook). Poll up to the effective grace so we don't sample the
1259
+ # transcript before the totals land. grace 0 = read once (today's path).
1260
+ deadline = time.monotonic() + self._usage_grace_s
1261
+ while True:
1262
+ usage = tally_usage(self.profile.usage_parser, path)
1263
+ if usage is not None or time.monotonic() >= deadline:
1264
+ return usage
1265
+ time.sleep(RESULT_POLL_S)
1266
+
1267
+
1268
+ class _DevSynthesisMixin(_ResultFileMixin):
1269
+ """Result synthesis for the generic ``froid-build-auto`` skill, shared by
1270
+ every transport that drives it (tmux today; see GenericDevAdapter for the
1271
+ skill contract). Locates the terminal spec the skill leaves on disk and
1272
+ synthesizes the legacy result dict via :mod:`devcontract`. Hosts provide
1273
+ ``self.paths`` (a :class:`ProjectPaths`), the ``self.policy`` knobs read
1274
+ by ``_configure_dev_knobs``, and the ``_probe_alive`` liveness seam."""
1275
+
1276
+ # Set by the concrete adapter's __init__ (see docstring); bare annotations
1277
+ # (no runtime effect) tell the type checker the host attributes this reads.
1278
+ paths: ProjectPaths
1279
+ policy: Policy
1280
+ # The concrete adapter's real transport (GenericDevAdapter/OpencodeDevAdapter
1281
+ # both define `def send_text`). Declared here as a BARE annotation, never a
1282
+ # `def`: the mixin precedes the concrete adapter in MRO, so a stub method
1283
+ # would shadow the real one on both adapters. The contract nudge (#276 M4)
1284
+ # sends through it.
1285
+ send_text: Callable[[SessionHandle, str], None]
1286
+
1287
+ # This mixin's read-back is where #261 lives: the skill writes no task-scoped
1288
+ # result.json, so a result is synthesized from a *.md in an implementation-
1289
+ # artifacts dir shared with every concurrent run and with the human. A pinned
1290
+ # `expected_spec` closes that for the sessions the orchestrator can name, but
1291
+ # dev attempt 1 (no spec exists yet) and the labeled-workflow marker still scan.
1292
+ # There a qualifying file can belong to someone else, so a dead session must
1293
+ # show it ran before its "result" is honored. See `_produced_work`.
1294
+ _READBACK_NEEDS_PROOF_OF_WORK = True
1295
+
1296
+ def _configure_dev_knobs(self) -> None:
1297
+ """Override the base result-file knobs for the froid-build-auto contract;
1298
+ hosts call this at the end of ``__init__``."""
1299
+ # The generic skill never writes result.json, so the base "write the
1300
+ # result JSON file" nudge is meaningless — and actively misleading — for
1301
+ # it. A Stop without a terminal spec is a stall *unless* the session
1302
+ # merely ended its turn to await a background process and will be re-
1303
+ # invoked on completion; the idle-grace window distinguishes the two.
1304
+ self._stop_nudges = 0
1305
+ self._stall_grace_s = float(self.policy.limits.dev_stall_grace_s)
1306
+ self._stall_nudges = int(self.policy.limits.dev_stall_nudges)
1307
+ # Missing-marker fingerprint observations (#224):
1308
+ # task_id -> (path, mtime_ns, frontmatter status, observation count).
1309
+ # Task ids are unique per session, so entries never need resetting
1310
+ # between sessions; the dict lives for the adapter's lifetime.
1311
+ self._fm_fallback_obs: dict[str, tuple[str, int, str, int]] = {}
1312
+ # First mid-session spec-status transition observed per session (#276 M2):
1313
+ # task_id -> normalized status. Recorded by `_observe_tick` when the spec's
1314
+ # frontmatter first moves off its launch status to a non-terminal state (in
1315
+ # practice `in-review`), which makes a later terminal frontmatter proof THIS
1316
+ # session wrote it. Same lifetime doctrine as `_fm_fallback_obs` — task_ids
1317
+ # are unique per session, so entries are recorded once and never cleared.
1318
+ self._fm_transition_obs: dict[str, str] = {}
1319
+ # Targeted contract-nudge budget (#276 M4): task_ids that have already been
1320
+ # sent the one CONTRACT_NUDGE_TEXT nudge. A set, never cleared, so the nudge
1321
+ # fires at most once per session even though an mtime bump resets the
1322
+ # `_fm_fallback_obs` observation counter to 1 (#149's refill hazard cannot
1323
+ # apply — this budget is not a counter and touches no stall counters).
1324
+ self._contract_nudge_sent: set[str] = set()
1325
+ self._contract_nudge_enabled = self.policy.limits.dev_contract_nudge
1326
+
1327
+ def _probe_alive(self, handle: SessionHandle) -> bool | None:
1328
+ """Liveness of the session's native surface (tmux window, server
1329
+ process) for ``_post_kill_reconcile``: True = alive, False = provably
1330
+ dead, None = liveness unknown (a transport hiccup — unknown is not
1331
+ dead, so the caller keeps its verdict)."""
1332
+ raise NotImplementedError
1333
+
1334
+ def _artifact_dirs(self, cwd: Path) -> list[Path]:
1335
+ # In worktree isolation the skill runs with cwd set to the worktree and
1336
+ # writes its terminal spec under the worktree's rebased implementation-
1337
+ # artifacts dir, not the main checkout's. Resolve the search dir from the
1338
+ # live session cwd (a no-op in place, where cwd == the project root, and
1339
+ # for artifact dirs configured outside the project tree, which rebased()
1340
+ # leaves put). Keep the configured dir as a defensive fallback.
1341
+ primary = self.paths.rebased(cwd).implementation_artifacts
1342
+ dirs = [primary]
1343
+ if self.paths.implementation_artifacts != primary:
1344
+ dirs.append(self.paths.implementation_artifacts)
1345
+ return dirs
1346
+
1347
+ def _result_json(self, handle: SessionHandle, spec: SessionSpec, *, wait: bool) -> dict | None:
1348
+ sr = self._synth_result(handle, spec, wait=wait)
1349
+ return sr.result_json if sr is not None else None
1350
+
1351
+ def _synth_result(
1352
+ self, handle: SessionHandle, spec: SessionSpec, *, wait: bool, dead_window: bool = False
1353
+ ) -> devcontract.SynthResult | None:
1354
+ # Stories mode (folder+id dispatch): the story spec lives at a
1355
+ # deterministic id-keyed path, so resolve it directly instead of the
1356
+ # mtime-floor scan. The engine exports FROID_LOOP_SPEC_FOLDER only for
1357
+ # stories runs, so sprint/sweep runs keep the scan path below unchanged.
1358
+ # (dead_window is a scan-path refinement — stories read-back is already
1359
+ # frontmatter-authoritative, so it needs no missing-marker fallback.)
1360
+ if spec.env.get("FROID_LOOP_SPEC_FOLDER"):
1361
+ return self._stories_synth_result(handle, spec, wait=wait)
1362
+ # Authoritative-path read-back (#261): when the orchestrator already knows
1363
+ # which spec this session owes — every review leg and every dev retry, see
1364
+ # SessionSpec.expected_spec — read THAT file and never the directory scan
1365
+ # below. The scan asks "what is the newest qualifying *.md here" of a
1366
+ # directory shared with every concurrent run; the question is "what did THIS
1367
+ # session write for THIS story", and here we were told the answer at launch.
1368
+ if spec.expected_spec:
1369
+ # The engine always threads an absolute path (StoryTask.spec_file is
1370
+ # re-absolutized against the worktree on resume). Rebase a relative one
1371
+ # against spec.cwd anyway, the same way the stories read-back handles
1372
+ # FROID_LOOP_SPEC_FOLDER: a path resolved against the process CWD would
1373
+ # simply miss and read as "the session wrote nothing", turning this
1374
+ # guard into the silent work-losing failure it exists to avoid.
1375
+ owed = Path(spec.expected_spec)
1376
+ if not owed.is_absolute():
1377
+ owed = Path(spec.cwd) / owed
1378
+ return self._known_spec_synth_result(
1379
+ handle, spec, owed, wait=wait, dead_window=dead_window
1380
+ )
1381
+ # Dev attempt 1 only: no spec exists yet (the skill creates it), so the
1382
+ # mtime-floor scan is the sole way to find it.
1383
+ # Mirror the base _await_result poll: the skill's terminal spec may not be
1384
+ # flushed to disk the instant the Stop event fires, so briefly await it when
1385
+ # wait=True instead of reading once and mis-reporting a stall.
1386
+ deadline = time.monotonic() + RESULT_GRACE_S
1387
+ search_dirs = self._artifact_dirs(spec.cwd)
1388
+ while True:
1389
+ for artifacts in search_dirs:
1390
+ spec_path = devcontract.find_result_artifact(artifacts, since_ns=handle.launched_ns)
1391
+ if spec_path is not None:
1392
+ return self._synthesize_from(spec_path, spec)
1393
+ if not wait or time.monotonic() >= deadline:
1394
+ return self._frontmatter_fallback(
1395
+ handle, spec, search_dirs, wait=wait, dead_window=dead_window
1396
+ )
1397
+ time.sleep(RESULT_POLL_S)
1398
+
1399
+ def _known_spec_synth_result(
1400
+ self,
1401
+ handle: SessionHandle,
1402
+ spec: SessionSpec,
1403
+ path: Path,
1404
+ *,
1405
+ wait: bool,
1406
+ dead_window: bool,
1407
+ ) -> devcontract.SynthResult | None:
1408
+ """Read back from the ONE spec the session was required to write (#261).
1409
+
1410
+ Structurally the scan path with the candidate source replaced: poll the
1411
+ marker predicate on this single file over the same ``RESULT_GRACE_S`` flush
1412
+ window, then hand the same file to the missing-marker fallback (#224) via
1413
+ its ``only`` seam, so the stability fingerprint, the M2 transition
1414
+ observation and the M1 launch-snapshot gate all still apply — scoped to the
1415
+ one legitimate path instead of a shared directory.
1416
+
1417
+ No launch-snapshot gate is needed on the marker branch itself: the
1418
+ pre-review-launch strip (`Engine._reset_spec_for_review`) REMOVES the
1419
+ marker, so a spec carrying one again has necessarily changed bytes since the
1420
+ snapshot and the gate would be a no-op (`_snapshot_verdict` → NEUTRAL).
1421
+
1422
+ Note this deliberately does NOT fall back to the scan when the expected spec
1423
+ yields nothing: a session that did not write the spec it owed produced no
1424
+ result, and any other qualifying file in that directory belongs to someone
1425
+ else. Returning None routes to the dev-stall grace / crashed verdict — the
1426
+ safe direction, and the verdict the two control stories in #261 received."""
1427
+ deadline = time.monotonic() + RESULT_GRACE_S
1428
+ while True:
1429
+ if devcontract.is_result_artifact(path, since_ns=handle.launched_ns):
1430
+ return self._synthesize_from(path, spec)
1431
+ if not wait or time.monotonic() >= deadline:
1432
+ return self._frontmatter_fallback(
1433
+ handle, spec, [], wait=wait, dead_window=dead_window, only=path
1434
+ )
1435
+ time.sleep(RESULT_POLL_S)
1436
+
1437
+ def _synthesize_from(self, spec_path: Path, spec: SessionSpec) -> devcontract.SynthResult:
1438
+ """Shared synthesis call for the marker scan and the missing-marker
1439
+ fallback, so both stamp the session's story key and — for bundle dev
1440
+ sessions, where the orchestrator exports the bundle's owned dw ids (the
1441
+ generic skill never authors them) — the dw_ids verify_dev_bundle
1442
+ cross-checks."""
1443
+ story_key = spec.env.get("FROID_LOOP_STORY_KEY") or None
1444
+ raw_dw_ids = (spec.env.get("FROID_LOOP_DW_IDS") or "").split(",")
1445
+ dw_ids = [tok for tok in (i.strip() for i in raw_dw_ids) if tok]
1446
+ return devcontract.synthesize_result(spec_path, story_key=story_key, dw_ids=dw_ids or None)
1447
+
1448
+ def _observe_tick(self, handle: SessionHandle, spec: SessionSpec) -> None:
1449
+ """Mid-session status-transition observation (#276 M2), called each
1450
+ heartbeat tick (~every HEARTBEAT_INTERVAL_S; the first tick fires too).
1451
+ Records the FIRST spec frontmatter status this session drives off its
1452
+ launch state to a live, non-terminal value (in practice ``in-review``)
1453
+ into ``_fm_transition_obs``. That single sighting is what lets
1454
+ ``_frontmatter_fallback`` treat a later terminal frontmatter as
1455
+ deterministic proof THIS session wrote it (``transition_proven``), so it
1456
+ can synthesize on ONE terminal sighting instead of the 2-observation
1457
+ fingerprint.
1458
+
1459
+ A pure sampling path, never a verdict path: it needs a launch snapshot to
1460
+ observe against, fires at most once per session (task_ids are unique;
1461
+ entries are never cleared), and any unreadable/torn read is a skipped
1462
+ sample (silent OSError return), never evidence. Blank/torn parses (``s ==
1463
+ ""``) and terminal states (``done``/``blocked``) are NOT recorded — a
1464
+ terminal frontmatter is the Stop harvest's business, and the launch status
1465
+ itself (the ``done`` a review re-opens) is not a transition. A transition
1466
+ that flips entirely between two ticks is simply missed, and the fallback
1467
+ keeps its conservative 2-observation path.
1468
+
1469
+ Both sides of the comparison read through ``status_of``, so a blank/
1470
+ YAML-null ``status:`` normalizes to ``""`` here AND in the snapshot
1471
+ ``_reset_spec_for_review`` captures. That pairing is load-bearing and must
1472
+ stay symmetric: normalizing only the snapshot leaves a bare-status spec at
1473
+ ``snap.fm_status == ""`` while this tick reads the stringified ``"none"``,
1474
+ which is neither blank nor terminal nor equal to the snapshot — a fabricated
1475
+ transition, hence a false ``transition_proven`` and a premature
1476
+ single-sighting frontmatter synthesis. A blank is also not an observed live
1477
+ status in its own right: a skill that ERASES a previously-set status
1478
+ mid-session records nothing (the ``s != ""`` guard), where the old
1479
+ ``"none"`` reading slipped past as if it were a value."""
1480
+ task_id = handle.task_id
1481
+ snap = spec.spec_snapshot
1482
+ if snap is None or task_id in self._fm_transition_obs:
1483
+ return
1484
+ try:
1485
+ s = status_of(read_frontmatter(Path(snap.path)))
1486
+ except OSError:
1487
+ return
1488
+ if s != "" and s not in (devcontract.DONE, devcontract.BLOCKED) and s != snap.fm_status:
1489
+ self._fm_transition_obs[task_id] = s
1490
+ self._note_lifecycle(
1491
+ task_id, "spec-status-transition-observed", spec=snap.path, status=s
1492
+ )
1493
+
1494
+ @staticmethod
1495
+ def _same_spec(candidate: Path, snap_path: str) -> bool:
1496
+ """Whether ``candidate`` and the snapshot's recorded path are the SAME file
1497
+ by filesystem identity (#276 M1), not raw string spelling. ``snap_path`` is
1498
+ the engine's ``str(task.spec_file)``; a ``..`` segment, a symlinked artifacts
1499
+ dir, or a case-variant alias makes an equivalent path compare unequal
1500
+ lexically and would silently disable the hash/transition gate. ``resolve()``
1501
+ (the repo's identity convention, non-strict) collapses those; an unresolvable
1502
+ path degrades to "not the same file" — conservative, the gate stays inert
1503
+ rather than ever falsely refusing."""
1504
+ try:
1505
+ return candidate.resolve() == Path(snap_path).resolve()
1506
+ except OSError:
1507
+ return False
1508
+
1509
+ def _snapshot_verdict(
1510
+ self,
1511
+ *,
1512
+ same_file: bool,
1513
+ snap: SpecSnapshot | None,
1514
+ task_id: str,
1515
+ digest: str | None,
1516
+ ) -> _SnapVerdict:
1517
+ """Shared M1/M2 launch-snapshot decision (see ``_SnapVerdict``). Pure logic,
1518
+ no I/O: each caller precomputes ``same_file`` (via ``_same_spec``) and, only
1519
+ when it holds, ``digest`` (so an unrelated spec is never hashed), preserving
1520
+ each path's own read-error semantics. A recorded transition (M2) outranks the
1521
+ content hash (M1); the hash gate refuses only when no transition was observed
1522
+ and the bytes are still identical to the launch snapshot."""
1523
+ if snap is None or not same_file:
1524
+ return _SnapVerdict.NEUTRAL
1525
+ if task_id in self._fm_transition_obs:
1526
+ return _SnapVerdict.PROVEN
1527
+ if digest is not None and digest == snap.sha256:
1528
+ return _SnapVerdict.REFUSE
1529
+ return _SnapVerdict.NEUTRAL
1530
+
1531
+ def _frontmatter_fallback(
1532
+ self,
1533
+ handle: SessionHandle,
1534
+ spec: SessionSpec,
1535
+ search_dirs: list[Path],
1536
+ *,
1537
+ wait: bool,
1538
+ dead_window: bool,
1539
+ only: Path | None = None,
1540
+ ) -> devcontract.SynthResult | None:
1541
+ """Missing-marker rescue (#224): synthesize from a spec this session
1542
+ finalized to a terminal frontmatter ``status:`` without appending the
1543
+ ``## Auto Run Result`` marker the scan keys on. Without this, such a
1544
+ spec is invisible to the harvest: every Stop reads ``no-artifact``, the
1545
+ stall nudges re-invoke a skill that has already exited its workflow, and
1546
+ a finished story rides to timeout — where the engine's review RETRY
1547
+ strips the spec and reproduces the omission until the story defers.
1548
+
1549
+ Trust model: a terminal frontmatter under a live window is weaker
1550
+ evidence than the marker, so the live path harvests only a fingerprint
1551
+ (path, mtime, status) that held stable across ``FM_FALLBACK_MIN_OBS``
1552
+ resultless Stops; a dead window (post-kill reconcile) harvests on one
1553
+ sighting, liveness having been settled by the kill. A recorded mid-session
1554
+ transition (#276 M2) is a third single-sighting route, live or dead: having
1555
+ observed this session drive the spec off its launch ``status:`` to a live
1556
+ non-terminal state (``in-review``) proves the terminal frontmatter it now
1557
+ carries is this session's own write, not a stale prior ``done``, so one
1558
+ terminal sighting suffices — the ``transition=`` flag on the synthesized
1559
+ crumb marks it. A transition that flips entirely between two ticks is simply
1560
+ missed, and this stays on the conservative 2-observation fingerprint.
1561
+ Several candidates mean the scan cannot know which spec is this session's —
1562
+ refuse to guess. The launch-snapshot hash (#276 M1) and the transition (M2)
1563
+ interact via ``_snapshot_verdict``: when the engine threaded a
1564
+ ``spec_snapshot`` (review sessions) and the candidate's bytes still hash
1565
+ equal to it, synthesis is deterministically REFUSED in every mode, including
1566
+ the dead window (the ``unmodified-since-launch`` verdict /
1567
+ ``frontmatter-unmodified-refused`` crumb) — the ``done`` spec re-opened for
1568
+ review, mtime-bumped but never re-driven. A recorded transition OUTRANKS the
1569
+ hash, though: a clean review can round-trip ``done -> in-review -> done`` back
1570
+ to the launch bytes while still omitting its marker, and the observed
1571
+ ``in-review`` proves it ran — so REFUSE fires only when NO transition was
1572
+ seen. Every synthesized result still runs the engine's full
1573
+ deterministic verify downstream, the same #61 trust model as the
1574
+ post-kill rescue. With this in place a marker-less ``done`` spec
1575
+ completes here and never reaches the review-timeout path, so the
1576
+ ``review.on_timeout`` salvage (#271) only ever sees the complementary
1577
+ case: a review that died with a NON-terminal frontmatter.
1578
+
1579
+ Owns the give-up breadcrumb: exactly one of ``no-artifact``,
1580
+ ``ambiguous-frontmatter``, ``unmodified-since-launch``, or
1581
+ ``terminal-frontmatter-pending`` per wait=True pass (none on a harvest).
1582
+ A plain wait=False read (the crash path) is compare-only — it may harvest
1583
+ an already-stable fingerprint but never records observations or
1584
+ breadcrumbs; the hash gate is the one wait=False path that leaves a crumb,
1585
+ and only under a dead window (``frontmatter-unmodified-refused``).
1586
+
1587
+ On the FIRST ``terminal-frontmatter-pending`` observation (wait=True, one
1588
+ candidate, not the hash-gate refusal, transition not yet proven) it also
1589
+ fires the #276 M4 contract nudge when ``limits.dev_contract_nudge`` is on:
1590
+ one ``CONTRACT_NUDGE_TEXT`` send asking the skill to append the marker it
1591
+ owed, then repair at the source rather than only synthesizing here. It is
1592
+ bounded by the never-cleared ``_contract_nudge_sent`` set (marked before
1593
+ the send, ``MultiplexerError`` swallowed) — exactly once per session,
1594
+ touching no stall counters, so an mtime bump that resets ``observations``
1595
+ to 1 never re-nudges. A compliant append is harvested by the ordinary
1596
+ marker scan on a later Stop, leaving synthesis as the backstop.
1597
+
1598
+ ``only`` (#261) replaces the directory scan with the single spec the
1599
+ orchestrator knows this session owed: the candidate set becomes that file
1600
+ if it qualifies, else empty, and ``search_dirs`` is unused. Every gate
1601
+ below is unchanged — the point is purely that a foreign story's
1602
+ marker-less terminal spec, sitting in the same shared artifacts dir, is no
1603
+ longer a candidate at all, so the "refuse to guess between several" branch
1604
+ becomes unreachable.
1605
+ """
1606
+ task_id = handle.task_id
1607
+ candidates: list[Path] = []
1608
+ if only is not None:
1609
+ # Authoritative-path mode (#261): the caller knows the ONE spec this
1610
+ # session owed, so the candidate set is that file if it qualifies and
1611
+ # nothing otherwise. `len(candidates) > 1` is unreachable here — the
1612
+ # "refuse to guess" branch exists for the scan, which cannot know which
1613
+ # of several specs is this session's; with a known path there is nothing
1614
+ # to guess between.
1615
+ if devcontract.is_frontmatter_candidate(only, since_ns=handle.launched_ns):
1616
+ candidates.append(only)
1617
+ where = str(only)
1618
+ else:
1619
+ for artifacts in search_dirs:
1620
+ candidates.extend(
1621
+ devcontract.find_frontmatter_candidates(artifacts, since_ns=handle.launched_ns)
1622
+ )
1623
+ where = ", ".join(str(d) for d in search_dirs)
1624
+ if not candidates:
1625
+ # No marker-less terminal spec either (the common resultless Stop —
1626
+ # e.g. a review that flipped to `in-review` and is mid-work): clear
1627
+ # any stale fingerprint so a later terminal state starts over.
1628
+ self._fm_fallback_obs.pop(task_id, None)
1629
+ if wait:
1630
+ self._note_resultless_stop(
1631
+ task_id,
1632
+ "no-artifact",
1633
+ "no result artifact newer than session launch under: " + where,
1634
+ )
1635
+ return None
1636
+ if len(candidates) > 1:
1637
+ if wait:
1638
+ self._note_resultless_stop(
1639
+ task_id,
1640
+ "ambiguous-frontmatter",
1641
+ f"{len(candidates)} terminal marker-less candidates: "
1642
+ + ", ".join(str(p) for p in candidates),
1643
+ )
1644
+ return None
1645
+ path = candidates[0]
1646
+ snap = spec.spec_snapshot
1647
+ same_file = snap is not None and self._same_spec(path, snap.path)
1648
+ try:
1649
+ mtime_ns = path.stat().st_mtime_ns
1650
+ fm_status = status_of(read_frontmatter(path))
1651
+ # Content hash only when the candidate IS the snapshotted spec (compared
1652
+ # by filesystem identity) — an unrelated marker-less spec under the same
1653
+ # artifacts dir shares no launch state, so hashing it is meaningless work.
1654
+ digest = hashlib.sha256(path.read_bytes()).hexdigest() if same_file else None
1655
+ except OSError:
1656
+ # Torn mid-write read: not evidence of anything — same degrade as
1657
+ # the read-back doctrine everywhere else on this path.
1658
+ if wait:
1659
+ self._note_resultless_stop(
1660
+ task_id, "no-artifact", f"unreadable marker-less candidate {path}"
1661
+ )
1662
+ return None
1663
+ # Launch-snapshot verdict (#276 M1/M2), shared with the stories read-back.
1664
+ # REFUSE (M1) — bytes byte-identical to the review-launch snapshot with NO
1665
+ # transition observed — is the documented dead-window false positive (a
1666
+ # `done` spec re-opened for review, mtime-bumped but never re-driven);
1667
+ # refuse in EVERY mode, including `dead_window`. A PROVEN transition (M2)
1668
+ # outranks it and falls through to synthesis below. No observation is
1669
+ # recorded or popped on REFUSE — an unchanged spec is neither progress nor a
1670
+ # stall.
1671
+ verdict = self._snapshot_verdict(
1672
+ same_file=same_file, snap=snap, task_id=task_id, digest=digest
1673
+ )
1674
+ if verdict is _SnapVerdict.REFUSE:
1675
+ assert snap is not None # REFUSE is returned only for a matched snapshot
1676
+ if wait:
1677
+ self._note_resultless_stop(
1678
+ task_id,
1679
+ "unmodified-since-launch",
1680
+ f"{path} byte-identical to review-launch snapshot "
1681
+ f"(snapshot mtime_ns={snap.mtime_ns}, candidate mtime_ns={mtime_ns}); "
1682
+ "refusing frontmatter synthesis",
1683
+ )
1684
+ elif dead_window:
1685
+ self._note_lifecycle(
1686
+ task_id,
1687
+ "frontmatter-unmodified-refused",
1688
+ spec=str(path),
1689
+ status=fm_status,
1690
+ dead_window=True,
1691
+ )
1692
+ return None
1693
+ fingerprint = (str(path), mtime_ns, fm_status)
1694
+ prev = self._fm_fallback_obs.get(task_id)
1695
+ stable = prev is not None and prev[:3] == fingerprint
1696
+ observations = (prev[3] + 1) if (stable and prev is not None) else 1
1697
+ # A recorded mid-session transition (#276 M2) proves the terminal frontmatter
1698
+ # is this session's write → single-sighting harvest, like a dead window.
1699
+ transition_proven = verdict is _SnapVerdict.PROVEN
1700
+ if dead_window or transition_proven or (stable and observations >= FM_FALLBACK_MIN_OBS):
1701
+ sr = self._synthesize_from(path, spec)
1702
+ if sr.result_json is not None:
1703
+ sr.result_json["synthesized_from_frontmatter"] = True
1704
+ self._note_lifecycle(
1705
+ task_id,
1706
+ "frontmatter-synthesized",
1707
+ spec=str(path),
1708
+ status=fm_status,
1709
+ dead_window=dead_window,
1710
+ transition=transition_proven,
1711
+ )
1712
+ return sr
1713
+ if wait:
1714
+ self._fm_fallback_obs[task_id] = (*fingerprint, observations)
1715
+ self._note_resultless_stop(
1716
+ task_id,
1717
+ "terminal-frontmatter-pending",
1718
+ f"{path} frontmatter status={fm_status!r} with no '## Auto Run Result'"
1719
+ f" marker; observation {observations}/{FM_FALLBACK_MIN_OBS} before synthesis",
1720
+ )
1721
+ # Contract nudge (#276 M4): at the FIRST pending observation, ask the
1722
+ # skill to append the `## Auto Run Result` section it owed so the
1723
+ # omission is repaired at the source (a compliant append is then
1724
+ # harvested by the normal marker scan on a later Stop; synthesis stays
1725
+ # the backstop). Exactly once per session: the task_id is marked BEFORE
1726
+ # the send so a raising transport still satisfies exactly-once, and the
1727
+ # never-cleared set — not the mtime-resettable observation counter — is
1728
+ # the budget, so the #149 refill hazard cannot apply. Touches no stall
1729
+ # counters.
1730
+ if (
1731
+ self._contract_nudge_enabled
1732
+ and observations == 1
1733
+ and task_id not in self._contract_nudge_sent
1734
+ ):
1735
+ self._contract_nudge_sent.add(task_id)
1736
+ self._note_lifecycle(
1737
+ task_id, "contract-nudge-sent", spec=str(path), status=fm_status
1738
+ )
1739
+ try:
1740
+ self.send_text(
1741
+ handle,
1742
+ CONTRACT_NUDGE_TEXT.format(spec_path=path, status=fm_status),
1743
+ )
1744
+ except MultiplexerError:
1745
+ pass
1746
+ return None
1747
+
1748
+ def _stories_synth_result(
1749
+ self, handle: SessionHandle, spec: SessionSpec, *, wait: bool
1750
+ ) -> devcontract.SynthResult | None:
1751
+ """Deterministic stories-mode read-back: resolve ``<spec-folder>/stories/
1752
+ <id>-*.md`` by id (never the mtime scan) and synthesize from it.
1753
+
1754
+ ``FROID_LOOP_SPEC_FOLDER`` carries the project-relative (or absolute) spec
1755
+ folder; rebase a relative one against ``spec.cwd`` exactly like
1756
+ ``_artifact_dirs`` so worktree isolation resolves inside the live checkout.
1757
+ A PRESENT or SENTINEL spec synthesizes (a blocked sentinel becomes a
1758
+ CRITICAL escalation → PAUSE, same as any block) — but only when the spec was
1759
+ (re)written by THIS session: like the mtime-scan path's ``since_ns`` floor, a
1760
+ spec whose mtime predates ``handle.launched_ns`` is a stale prior artifact
1761
+ (e.g. the dev's ``done`` spec a follow-up review session re-opens) and must
1762
+ not be read as this session's result. A still-PENDING spec, an AMBIGUOUS
1763
+ match (>1 file — an anomaly no wait can collapse; ``_pick_next`` re-classifies
1764
+ it into an actionable wedge), or a stale terminal spec → None (a result-less
1765
+ Stop the dev-stall grace handles).
1766
+
1767
+ On a plan-halt leg (``FROID_LOOP_PLAN_HALT`` set by the engine for a
1768
+ spec_checkpoint story's first dispatch) the skill HALTs at
1769
+ ``ready-for-dev``; pass ``plan_halt=True`` so synthesize treats that as a
1770
+ successful terminal (marked ``plan_halt``) rather than died-mid-flight.
1771
+
1772
+ A review session also carries a launch ``spec_snapshot``, so before
1773
+ synthesizing this applies the shared ``_snapshot_verdict`` gate (#276 M1/M2):
1774
+ an unmodified-since-launch ``done`` spec with no observed transition is
1775
+ REFUSED (the ``unmodified-since-launch`` verdict), closing the same
1776
+ false-positive completion the mtime-scan fallback closes — a review that only
1777
+ bumped the mtime of the stripped launch spec no longer reads as done. A dev
1778
+ leg carries no snapshot → the gate is inert (mtime-floor accept). Identity is
1779
+ filesystem-based (``_same_spec``): under worktree isolation, if ``base``
1780
+ resolves into the worktree but the snapshot path is the main checkout the two
1781
+ differ and the gate stays inert — conservative (no false accept, just no
1782
+ extra protection)."""
1783
+ from .. import stories
1784
+
1785
+ story_key = spec.env.get("FROID_LOOP_STORY_KEY") or ""
1786
+ folder = Path(spec.env["FROID_LOOP_SPEC_FOLDER"])
1787
+ base = folder if folder.is_absolute() else Path(spec.cwd) / folder
1788
+ plan_halt = bool(spec.env.get("FROID_LOOP_PLAN_HALT"))
1789
+ deadline = time.monotonic() + RESULT_GRACE_S
1790
+ while True:
1791
+ state = stories.resolve_story_spec(base, story_key)
1792
+ if state.kind == stories.KIND_AMBIGUOUS:
1793
+ # >1 matching file — waiting can't make it collapse to one. Return now
1794
+ # (don't burn the grace); the engine's next _pick_next re-classifies
1795
+ # AMBIGUOUS and raises the actionable wedge for resolve.
1796
+ if wait:
1797
+ self._note_resultless_stop(
1798
+ handle.task_id,
1799
+ "ambiguous",
1800
+ f"{len(state.paths)} specs match id {story_key!r} under {base}",
1801
+ )
1802
+ return None
1803
+ # Classify this pass for the result-less breadcrumb; overwritten
1804
+ # below when the spec is present but not (yet) this session's
1805
+ # terminal output.
1806
+ verdict, detail = state.kind, str(state.path or base)
1807
+ if state.kind in (stories.KIND_PRESENT, stories.KIND_SENTINEL) and state.path:
1808
+ if not self._written_this_session(state.path, handle.launched_ns):
1809
+ verdict = "stale-mtime"
1810
+ detail = f"{state.path} predates session launch"
1811
+ else:
1812
+ # Launch-snapshot gate (#276 M1/M2), shared with the mtime-scan
1813
+ # fallback so the stories read-back can't false-complete on an
1814
+ # unmodified `done` spec. Only bites on a review session (the
1815
+ # engine threads `spec_snapshot` there); a dev leg leaves it None
1816
+ # → NEUTRAL → the mtime-floor accept below.
1817
+ snap = spec.spec_snapshot
1818
+ same_file = snap is not None and self._same_spec(state.path, snap.path)
1819
+ digest = None
1820
+ if same_file:
1821
+ try:
1822
+ digest = hashlib.sha256(state.path.read_bytes()).hexdigest()
1823
+ except OSError:
1824
+ digest = None # torn read → NEUTRAL; synthesize keeps its degrade
1825
+ snap_verdict = self._snapshot_verdict(
1826
+ same_file=same_file, snap=snap, task_id=handle.task_id, digest=digest
1827
+ )
1828
+ if snap_verdict is _SnapVerdict.REFUSE:
1829
+ assert snap is not None # REFUSE implies a matched snapshot
1830
+ # Byte-identical to the review-launch snapshot with no
1831
+ # transition observed — the same dead-window false positive
1832
+ # the scan path refuses. Fall through to keep polling the
1833
+ # grace (a real mid-grace write flips the verdict), then
1834
+ # breadcrumb + None on the deadline, like `stale-mtime`.
1835
+ verdict = "unmodified-since-launch"
1836
+ detail = (
1837
+ f"{state.path} byte-identical to review-launch snapshot "
1838
+ f"(snapshot mtime_ns={snap.mtime_ns}); refusing stories synthesis"
1839
+ )
1840
+ else:
1841
+ try:
1842
+ sr = devcontract.synthesize_result(
1843
+ state.path, story_key=story_key or None, plan_halt=plan_halt
1844
+ )
1845
+ except UnicodeDecodeError:
1846
+ # A non-UTF-8 read is either a torn glimpse of a spec still
1847
+ # being written (keep polling — a later pass sees the finished
1848
+ # write) or a genuinely corrupt file: then the grace expires
1849
+ # result-less and the next _pick_next re-classifies it as a
1850
+ # wedge (resolve_story_spec degrades an undecodable PRESENT
1851
+ # spec to status "" → pause for resolve), never a crash of
1852
+ # the read-back poll.
1853
+ sr = None
1854
+ if sr is not None and sr.result_json is not None:
1855
+ return sr
1856
+ verdict = "not-terminal"
1857
+ detail = (
1858
+ f"{state.path} has no terminal status (frontmatter {state.status!r})"
1859
+ )
1860
+ if not wait or time.monotonic() >= deadline:
1861
+ if wait:
1862
+ self._note_resultless_stop(handle.task_id, verdict, detail)
1863
+ return None
1864
+ time.sleep(RESULT_POLL_S)
1865
+
1866
+ @staticmethod
1867
+ def _written_this_session(spec_path: Path, launched_ns: int) -> bool:
1868
+ """Whether ``spec_path`` was (re)written at/after the session launched — the
1869
+ same launch-floor guard ``devcontract.find_result_artifact`` applies on the
1870
+ scan path, so a stale terminal spec from a prior step (a dev ``done`` a
1871
+ follow-up review re-opens) is not mistaken for this session's output. A spec
1872
+ that vanished between resolve and stat is treated as not-yet-written."""
1873
+ try:
1874
+ return spec_path.stat().st_mtime_ns >= launched_ns
1875
+ except OSError:
1876
+ return False
1877
+
1878
+ def _post_kill_reconcile(
1879
+ self, handle: SessionHandle, spec: SessionSpec, result: SessionResult
1880
+ ) -> SessionResult:
1881
+ """Rescue a finished-but-unvouched session once its window is dead (#61).
1882
+
1883
+ A session that wrote its terminal spec but whose final Stop event was
1884
+ lost ends ``stalled`` (nudge-unresponsive under a live window, where
1885
+ the artifact is advisory — the #48/#53 invariant), or ``timeout`` when
1886
+ no hook event ever arrived (hook misconfig, events-dir write failure —
1887
+ that path never arms the stall grace at all). Both verdicts discard
1888
+ the on-disk result solely because the window was alive to distrust;
1889
+ ``run()``'s kill has since settled that the way window death already
1890
+ vouches for the crash path. So: re-probe, and only on a provably dead
1891
+ window re-run the same read-back a delivered Stop would have run.
1892
+
1893
+ The gate is deliberately stricter than the crash path's
1894
+ accept-any-terminal: the synthesis must be self-consistent
1895
+ (``status_consistent`` — "no active disagreement"; a blank frontmatter
1896
+ with prose ``done`` passes, exactly what a delivered Stop would have
1897
+ synthesized, and the engine's reconcile repairs the lag) and a
1898
+ *successful* terminal — ``done``, or the stories plan-halt leg (a
1899
+ deliberate widening of #61's literal done-only wording). A ``blocked``
1900
+ terminal is never rescued: it carries no finished work, and
1901
+ blocked-plus-nudge-unresponsive is weak evidence of anything. Every
1902
+ rescue still runs the engine's full deterministic verify downstream,
1903
+ so a bogus upgrade degrades into an ordinary verify-failed retry. A
1904
+ cap-exhausted injected-workflow stall whose marker landed before the
1905
+ kill is rescued by the same trust model. ``over_budget`` joins the set
1906
+ (#158): an artifact the wrap-up nudge flushed at kill-time is honored
1907
+ the same way.
1908
+
1909
+ ``aborted`` joins it too (#319): an operator's hard stop kills the
1910
+ window mid-wait, so a Stop event that had already landed — or was one
1911
+ tick away — is never read, leaving exactly the same evidence problem.
1912
+ The same trust model settles it: a provably dead window plus a
1913
+ self-consistent *successful* terminal plus proof-of-work means the
1914
+ session did finish, and discarding that work would misreport what
1915
+ happened rather than be cautious about it. The upgrade to
1916
+ ``completed`` does NOT resume the run — the engine re-reads the
1917
+ hard-stop file after saving the rescued session and stops there, so a
1918
+ rescue records the finished work and still honors the stop."""
1919
+ if (
1920
+ result.status not in ("stalled", "timeout", "over_budget", "aborted")
1921
+ or result.result_json is not None
1922
+ ):
1923
+ return result
1924
+ alive = self._probe_alive(handle)
1925
+ if alive:
1926
+ # The kill silently failed (best-effort teardown): the window
1927
+ # is still alive, so the live-window invariant still applies.
1928
+ return result
1929
+ if alive is None:
1930
+ return result # liveness unknowable: unknown is not dead
1931
+ try:
1932
+ # dead_window: the probe above settled liveness, so the missing-
1933
+ # marker fallback (#224) may synthesize from a terminal frontmatter
1934
+ # on a single sighting — the gates below still refuse anything but
1935
+ # a self-consistent, escalation-free successful terminal.
1936
+ sr = self._synth_result(handle, spec, wait=False, dead_window=True)
1937
+ except (OSError, UnicodeDecodeError):
1938
+ # An unreadable artifact is not evidence a session finished. This
1939
+ # hook runs right after run()'s finally-kill — the moment a spec the
1940
+ # CLI was mid-write is truncated, possibly through a multi-byte UTF-8
1941
+ # sequence — so a corrupt read is the *expected* fault here, not an
1942
+ # anomaly. Keep the verdict: a best-effort rescue must never escalate
1943
+ # a clean stall/timeout into an exception, which the engine does not
1944
+ # contain per-task (it fails the whole run). UnicodeDecodeError is a
1945
+ # ValueError, so both must be named.
1946
+ return result
1947
+ if sr is None or sr.result_json is None or not sr.status_consistent:
1948
+ return result
1949
+ rj = sr.result_json
1950
+ if rj.get("escalations") or not (
1951
+ rj.get("status") == devcontract.DONE or rj.get("plan_halt") is True
1952
+ ):
1953
+ return result
1954
+ # Proof-of-work gate (#261), the same one the crash path applies in `_final`:
1955
+ # this rescue exists for a session that finished but lost its Stop, not for
1956
+ # one that never ran. A session that ended no turn and whose pane log never
1957
+ # grew produced nothing, so a qualifying artifact is not its output — keep
1958
+ # the stall/timeout verdict. This is the call path the incident's second
1959
+ # occurrence took.
1960
+ if not self._produced_work(handle, result.stop_seen):
1961
+ self._note_lifecycle(
1962
+ handle.task_id,
1963
+ "readback-refused-no-proof-of-work",
1964
+ fallback=result.status,
1965
+ spec=str(rj.get("spec_file", "")),
1966
+ status=str(rj.get("status", "")),
1967
+ dead_window=True,
1968
+ )
1969
+ return result
1970
+ rj["post_kill_reconciled"] = True
1971
+ return SessionResult(
1972
+ status="completed",
1973
+ result_json=rj,
1974
+ session_id=result.session_id,
1975
+ transcript_path=result.transcript_path,
1976
+ # a rescued timeout upgrades the outcome, not the timing evidence:
1977
+ # the deadline did fire on this session, and that record must
1978
+ # survive the rescue (#157). Same for a tripped budget's sample.
1979
+ timeout_fired_at=result.timeout_fired_at,
1980
+ timeout_expired_clock=result.timeout_expired_clock,
1981
+ budget_weighted=result.budget_weighted,
1982
+ stop_seen=result.stop_seen,
1983
+ )
1984
+
1985
+
1986
+ class GenericDevAdapter(_DevSynthesisMixin, GenericAdapter):
1987
+ """Dev adapter for Alex Verhovsky's generic ``froid-build-auto`` skill.
1988
+
1989
+ That skill writes NO ``result.json`` — its outcome lives in the spec it
1990
+ leaves on disk (frontmatter ``status:`` plus an appended ``## Auto Run
1991
+ Result``, or, when it never created a spec, a ``froid-build-auto-result-*.md``
1992
+ — ``froid-dev-auto-result-*.md`` pre-rename — fallback). On the Stop event we
1993
+ locate that artifact and synthesize the legacy result dict from it via
1994
+ :mod:`devcontract`, so verify/escalation and the rest of the pipeline
1995
+ consume it unchanged. Selected by
1996
+ ``policy.dev.skill == "froid-dev-auto"`` (see ``cli._make_adapters``).
1997
+ """
1998
+
1999
+ def __init__(self, *args, paths: ProjectPaths, **kwargs):
2000
+ super().__init__(*args, **kwargs)
2001
+ self.paths = paths
2002
+ self._configure_dev_knobs()
2003
+
2004
+ def _probe_alive(self, handle: SessionHandle) -> bool | None:
2005
+ try:
2006
+ return self._window_alive(handle)
2007
+ except MultiplexerError:
2008
+ return None
2009
+
2010
+
2011
+ # Back-compat alias: the adapter was ``GenericTmuxAdapter`` before tmux moved
2012
+ # behind the multiplexer seam. Keeps existing imports stable.
2013
+ GenericTmuxAdapter = GenericAdapter