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