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,276 @@
|
|
|
1
|
+
"""Schema-driven settings: the presentation layer over policy.toml.
|
|
2
|
+
|
|
3
|
+
The settings UI used to carry a hand-written list of field descriptors. That
|
|
4
|
+
list now lives in ``data/settings/core.toml`` as a *schema* — widget kind,
|
|
5
|
+
label, help text, options, bounds — while defaults and select options are
|
|
6
|
+
*referenced* from the ``policy.py`` dataclasses/enum-sets rather than copied, so
|
|
7
|
+
``policy.py`` stays the single source of truth for the runtime model and a sync
|
|
8
|
+
test guarantees the schema can never drift from it.
|
|
9
|
+
|
|
10
|
+
Two shapes mirror the old screen vocabulary:
|
|
11
|
+
|
|
12
|
+
* ``SettingSpec`` — one field (the old ``_Field``): section, key, widget kind,
|
|
13
|
+
options, resolved default, placeholder, numeric bounds, label, description.
|
|
14
|
+
``widget_id`` is unchanged so every existing widget id (``#limits-max_review_cycles``,
|
|
15
|
+
``#adapter-review-model`` …) is byte-identical.
|
|
16
|
+
* ``SectionSpec`` — a collapsible group: name (the TOML section key), display
|
|
17
|
+
label, description, its fields, and the owning plugin ("" for core).
|
|
18
|
+
|
|
19
|
+
``load_core_schema()`` parses the bundled core schema; ``build_registry(project,
|
|
20
|
+
policy)`` returns it plus a ``PluginInfo`` for every *discovered* plugin — its
|
|
21
|
+
enable state (membership in ``[plugins] enabled``), whether enabling it grants
|
|
22
|
+
trust (a ``[python]`` module), and its ``[[settings]]`` fields. The screen groups
|
|
23
|
+
these under a dedicated Plugins area; reading manifests never imports plugin
|
|
24
|
+
Python. The registry is what the settings screen consumes — it never reaches
|
|
25
|
+
back into this module's internals.
|
|
26
|
+
"""
|
|
27
|
+
|
|
28
|
+
from __future__ import annotations
|
|
29
|
+
|
|
30
|
+
import tomllib
|
|
31
|
+
from dataclasses import dataclass, field, replace
|
|
32
|
+
from importlib import resources
|
|
33
|
+
from pathlib import Path
|
|
34
|
+
from typing import Any
|
|
35
|
+
|
|
36
|
+
from . import policy as policy_mod
|
|
37
|
+
from .plugins import load_plugins
|
|
38
|
+
from .plugins.model import SettingSpec as PluginSettingSpec
|
|
39
|
+
from .policy import STAGES
|
|
40
|
+
|
|
41
|
+
# plugin [[settings]] types -> settings-screen widget kinds. The screen knows
|
|
42
|
+
# switch/int/float/str/select; a plugin declares bool/int/float/str/select.
|
|
43
|
+
_PLUGIN_KIND = {"bool": "switch", "int": "int", "float": "float", "str": "str", "select": "select"}
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
@dataclass(frozen=True)
|
|
47
|
+
class SettingSpec:
|
|
48
|
+
"""One settings field. Same vocabulary as the screen's former ``_Field`` so
|
|
49
|
+
the generic compose/collect/save logic is untouched."""
|
|
50
|
+
|
|
51
|
+
section: str
|
|
52
|
+
key: str
|
|
53
|
+
kind: str # select | int | float | str | switch | lines | args
|
|
54
|
+
options: tuple[str, ...] = ()
|
|
55
|
+
default: Any = None
|
|
56
|
+
placeholder: str = ""
|
|
57
|
+
minimum: float | None = None
|
|
58
|
+
maximum: float | None = None
|
|
59
|
+
label: str = "" # display override; falls back to key when empty
|
|
60
|
+
description: str = "" # muted caption shown below the field row
|
|
61
|
+
|
|
62
|
+
@property
|
|
63
|
+
def widget_id(self) -> str:
|
|
64
|
+
return f"{self.section}-{self.key}".replace(".", "-")
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
@dataclass(frozen=True)
|
|
68
|
+
class SectionSpec:
|
|
69
|
+
"""A collapsible settings group. ``name`` is the policy.toml section key
|
|
70
|
+
(may be dotted, e.g. ``adapter.dev``). Core only — plugin settings are
|
|
71
|
+
carried by ``PluginInfo``."""
|
|
72
|
+
|
|
73
|
+
name: str
|
|
74
|
+
fields: tuple[SettingSpec, ...]
|
|
75
|
+
label: str = "" # display label; falls back to name
|
|
76
|
+
description: str = ""
|
|
77
|
+
|
|
78
|
+
@property
|
|
79
|
+
def title(self) -> str:
|
|
80
|
+
base = self.label or self.name
|
|
81
|
+
return f"{base} — {self.description}" if self.description else base
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
@dataclass(frozen=True)
|
|
85
|
+
class PluginInfo:
|
|
86
|
+
"""One discovered plugin as the settings screen sees it: an enable toggle
|
|
87
|
+
(when trust-gated) plus its settings fields.
|
|
88
|
+
|
|
89
|
+
``trust_needed`` is True for a plugin that declares an in-process ``[python]``
|
|
90
|
+
module — enabling it grants trust (its name enters ``[plugins] enabled``).
|
|
91
|
+
Data-only plugins are always active and carry no toggle. ``fields`` are this
|
|
92
|
+
plugin's settings under ``plugins.<name>``; they render whether or not the
|
|
93
|
+
plugin is enabled (the screen greys them until enabled)."""
|
|
94
|
+
|
|
95
|
+
name: str
|
|
96
|
+
description: str
|
|
97
|
+
enabled: bool
|
|
98
|
+
trust_needed: bool
|
|
99
|
+
fields: tuple[SettingSpec, ...] = ()
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
@dataclass(frozen=True)
|
|
103
|
+
class SettingsRegistry:
|
|
104
|
+
"""Core schema plus a PluginInfo per discovered plugin — the settings
|
|
105
|
+
screen's source.
|
|
106
|
+
|
|
107
|
+
``plugin_schemas`` maps each plugin that contributes settings to its declared
|
|
108
|
+
specs so the screen can pass them to ``policy.loads`` for typed validation of
|
|
109
|
+
the ``[plugins.<name>]`` tables on save (every discovered plugin, since
|
|
110
|
+
settings are data that may be set whether or not the plugin is enabled).
|
|
111
|
+
"""
|
|
112
|
+
|
|
113
|
+
sections: tuple[SectionSpec, ...]
|
|
114
|
+
plugins: tuple[PluginInfo, ...] = ()
|
|
115
|
+
plugin_schemas: dict[str, tuple[PluginSettingSpec, ...]] = field(default_factory=dict)
|
|
116
|
+
|
|
117
|
+
def fields(self) -> tuple[SettingSpec, ...]:
|
|
118
|
+
core = tuple(f for s in self.sections for f in s.fields)
|
|
119
|
+
plugin = tuple(f for p in self.plugins for f in p.fields)
|
|
120
|
+
return core + plugin
|
|
121
|
+
|
|
122
|
+
|
|
123
|
+
# --------------------------------------------------------------- ref resolution
|
|
124
|
+
|
|
125
|
+
|
|
126
|
+
def _resolve_ref(ref: str) -> Any:
|
|
127
|
+
"""``"ScmPolicy.merge_strategy"`` -> the dataclass default value;
|
|
128
|
+
``"GATE_MODES"`` -> the module-level enum set (caller sorts it)."""
|
|
129
|
+
cls_name, _, attr = ref.partition(".")
|
|
130
|
+
target = getattr(policy_mod, cls_name)
|
|
131
|
+
return getattr(target, attr) if attr else target
|
|
132
|
+
|
|
133
|
+
|
|
134
|
+
def _resolve_field(section: str, raw: dict) -> SettingSpec:
|
|
135
|
+
if "options_ref" in raw:
|
|
136
|
+
options = tuple(sorted(_resolve_ref(raw["options_ref"])))
|
|
137
|
+
else:
|
|
138
|
+
options = tuple(str(o) for o in raw.get("options", ()))
|
|
139
|
+
if "default_ref" in raw:
|
|
140
|
+
default = _resolve_ref(raw["default_ref"])
|
|
141
|
+
else:
|
|
142
|
+
default = raw.get("default")
|
|
143
|
+
return SettingSpec(
|
|
144
|
+
section=section,
|
|
145
|
+
key=str(raw["key"]),
|
|
146
|
+
kind=str(raw["kind"]),
|
|
147
|
+
options=options,
|
|
148
|
+
default=default,
|
|
149
|
+
placeholder=str(raw.get("placeholder", "")),
|
|
150
|
+
minimum=raw.get("minimum"),
|
|
151
|
+
maximum=raw.get("maximum"),
|
|
152
|
+
label=str(raw.get("label", "")),
|
|
153
|
+
description=str(raw.get("description", "")),
|
|
154
|
+
)
|
|
155
|
+
|
|
156
|
+
|
|
157
|
+
def _expand_section(raw: dict) -> list[SectionSpec]:
|
|
158
|
+
"""One core.toml ``[[section]]`` -> one SectionSpec, except a stage template
|
|
159
|
+
(``expand_stages``) fans out to one section per STAGES entry, substituting
|
|
160
|
+
``{stage}`` in the name/label/description and reusing the same field set."""
|
|
161
|
+
fields_raw = raw.get("field", [])
|
|
162
|
+
if raw.get("expand_stages"):
|
|
163
|
+
sections = []
|
|
164
|
+
for stage in STAGES:
|
|
165
|
+
name = str(raw["name"]).format(stage=stage)
|
|
166
|
+
sections.append(
|
|
167
|
+
SectionSpec(
|
|
168
|
+
name=name,
|
|
169
|
+
fields=tuple(_resolve_field(name, f) for f in fields_raw),
|
|
170
|
+
label=str(raw.get("label", "")).format(stage=stage),
|
|
171
|
+
description=str(raw.get("description", "")).format(stage=stage),
|
|
172
|
+
)
|
|
173
|
+
)
|
|
174
|
+
return sections
|
|
175
|
+
name = str(raw["name"])
|
|
176
|
+
return [
|
|
177
|
+
SectionSpec(
|
|
178
|
+
name=name,
|
|
179
|
+
fields=tuple(_resolve_field(name, f) for f in fields_raw),
|
|
180
|
+
label=str(raw.get("label", "")),
|
|
181
|
+
description=str(raw.get("description", "")),
|
|
182
|
+
)
|
|
183
|
+
]
|
|
184
|
+
|
|
185
|
+
|
|
186
|
+
def load_core_schema() -> tuple[SectionSpec, ...]:
|
|
187
|
+
"""Parse the bundled core schema into ordered SectionSpecs (render order)."""
|
|
188
|
+
text = (
|
|
189
|
+
resources.files("froid_loop.data").joinpath("settings/core.toml").read_text(encoding="utf-8")
|
|
190
|
+
)
|
|
191
|
+
doc = tomllib.loads(text)
|
|
192
|
+
sections: list[SectionSpec] = []
|
|
193
|
+
for raw in doc.get("section", []):
|
|
194
|
+
sections.extend(_expand_section(raw))
|
|
195
|
+
return tuple(sections)
|
|
196
|
+
|
|
197
|
+
|
|
198
|
+
# ------------------------------------------------------------- plugin sections
|
|
199
|
+
|
|
200
|
+
|
|
201
|
+
def _plugin_fields(name: str, specs: tuple[PluginSettingSpec, ...]) -> tuple[SettingSpec, ...]:
|
|
202
|
+
section = f"plugins.{name}"
|
|
203
|
+
return tuple(
|
|
204
|
+
SettingSpec(
|
|
205
|
+
section=section,
|
|
206
|
+
key=s.key,
|
|
207
|
+
kind=_PLUGIN_KIND.get(s.type, "str"),
|
|
208
|
+
options=s.options,
|
|
209
|
+
default=s.default,
|
|
210
|
+
minimum=s.min,
|
|
211
|
+
maximum=s.max,
|
|
212
|
+
label=s.label,
|
|
213
|
+
description=s.help,
|
|
214
|
+
)
|
|
215
|
+
for s in specs
|
|
216
|
+
)
|
|
217
|
+
|
|
218
|
+
|
|
219
|
+
def _inject_mux_backends(sections: tuple[SectionSpec, ...], policy: Any) -> tuple[SectionSpec, ...]:
|
|
220
|
+
"""Populate the ``mux.backend`` select with the backends registered on THIS host.
|
|
221
|
+
|
|
222
|
+
Registered backends are a runtime fact (not a policy enum set), so the option
|
|
223
|
+
list is built per screen-open from ``detect_multiplexers()`` rather than baked
|
|
224
|
+
into ``core.toml``. Any value already present in policy is unioned in, so a
|
|
225
|
+
backend forced via ``froid-loop mux set --force <name>`` on a host where it is not
|
|
226
|
+
registered is never silently dropped when the settings screen saves (a blank
|
|
227
|
+
Select would collect to ``None`` = delete the key). The empty string (auto-select)
|
|
228
|
+
stays the implicit blank choice and is not listed."""
|
|
229
|
+
from .adapters.multiplexer import detect_multiplexers
|
|
230
|
+
|
|
231
|
+
current = getattr(getattr(policy, "mux", None), "backend", "") or ""
|
|
232
|
+
names = [info.name for info in detect_multiplexers()]
|
|
233
|
+
options = tuple(name for name in dict.fromkeys([*names, current]) if name)
|
|
234
|
+
out: list[SectionSpec] = []
|
|
235
|
+
for section in sections:
|
|
236
|
+
if section.name != "mux":
|
|
237
|
+
out.append(section)
|
|
238
|
+
continue
|
|
239
|
+
fields = tuple(
|
|
240
|
+
replace(f, options=options) if f.key == "backend" else f for f in section.fields
|
|
241
|
+
)
|
|
242
|
+
out.append(replace(section, fields=fields))
|
|
243
|
+
return tuple(out)
|
|
244
|
+
|
|
245
|
+
|
|
246
|
+
def build_registry(project: Path | None = None, policy: Any = None) -> SettingsRegistry:
|
|
247
|
+
"""Core schema, plus a PluginInfo for every *discovered* plugin.
|
|
248
|
+
|
|
249
|
+
Every plugin found by discovery is surfaced so the screen can offer an enable
|
|
250
|
+
toggle (membership in ``[plugins] enabled``) and group its settings. Reading
|
|
251
|
+
manifests never imports/executes plugin Python — that stays trust-gated for
|
|
252
|
+
hooks. A plugin's settings are data that render whether or not it is enabled
|
|
253
|
+
(the screen greys them until enabled), so ``plugin_schemas`` covers every
|
|
254
|
+
plugin that contributes settings, making each ``[plugins.<name>]`` table
|
|
255
|
+
typed on save regardless of toggle state."""
|
|
256
|
+
sections = _inject_mux_backends(load_core_schema(), policy)
|
|
257
|
+
enabled = set(getattr(getattr(policy, "plugins", None), "enabled", ()) or ())
|
|
258
|
+
manifests = load_plugins(project)
|
|
259
|
+
plugins: list[PluginInfo] = []
|
|
260
|
+
plugin_schemas: dict[str, tuple[PluginSettingSpec, ...]] = {}
|
|
261
|
+
for name in sorted(manifests):
|
|
262
|
+
manifest = manifests[name]
|
|
263
|
+
plugins.append(
|
|
264
|
+
PluginInfo(
|
|
265
|
+
name=name,
|
|
266
|
+
description=manifest.description,
|
|
267
|
+
enabled=name in enabled,
|
|
268
|
+
trust_needed=manifest.python is not None,
|
|
269
|
+
fields=_plugin_fields(name, manifest.settings),
|
|
270
|
+
)
|
|
271
|
+
)
|
|
272
|
+
if manifest.settings:
|
|
273
|
+
plugin_schemas[name] = manifest.settings
|
|
274
|
+
return SettingsRegistry(
|
|
275
|
+
sections=sections, plugins=tuple(plugins), plugin_schemas=plugin_schemas
|
|
276
|
+
)
|
froid_loop/signals.py
ADDED
|
@@ -0,0 +1,160 @@
|
|
|
1
|
+
"""Watch the per-run events directory for hook-written event files.
|
|
2
|
+
|
|
3
|
+
The hook script (data/froid_loop_hook.py) and its importable twin (:mod:`events`,
|
|
4
|
+
behind ``froid-loop relay``) write one JSON file per event, atomically (tmp +
|
|
5
|
+
rename), named "<ts_ns>-<task_id>-<event>.json". Plain polling of a near-empty
|
|
6
|
+
directory is cheap and crash-safe; no inotify.
|
|
7
|
+
|
|
8
|
+
Two directories, not one (#494). The channel now lives out of the project tree,
|
|
9
|
+
under the user-scoped state root (``runs.events_dir_for``), because a branch
|
|
10
|
+
switch, a worktree mount or a rollback must not be able to take a live run's
|
|
11
|
+
control plane away. The *legacy* in-tree ``<run_dir>/events`` stays polled as
|
|
12
|
+
well, and that is the whole version-skew guard: the relay a target project has
|
|
13
|
+
installed is a COPY taken at init time, so an upgraded orchestrator routinely
|
|
14
|
+
drives sessions whose hook only knows the old location. Without the second poll
|
|
15
|
+
that pairing loses every Stop event and every session stalls to
|
|
16
|
+
``session_timeout_min`` — the loudest possible regression, delivered silently.
|
|
17
|
+
"""
|
|
18
|
+
|
|
19
|
+
from __future__ import annotations
|
|
20
|
+
|
|
21
|
+
import json
|
|
22
|
+
import time
|
|
23
|
+
from dataclasses import dataclass
|
|
24
|
+
from pathlib import Path
|
|
25
|
+
from typing import Callable
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
@dataclass(frozen=True)
|
|
29
|
+
class HookEvent:
|
|
30
|
+
ts: int
|
|
31
|
+
event: str # Stop | SessionStart | SessionEnd | PreCompact
|
|
32
|
+
task_id: str
|
|
33
|
+
session_id: str | None
|
|
34
|
+
transcript_path: str | None
|
|
35
|
+
path: Path
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
class SignalWatcher:
|
|
39
|
+
"""Poll one or two event directories for a run's hook events.
|
|
40
|
+
|
|
41
|
+
``events_dir`` is the primary — the out-of-tree channel this orchestrator
|
|
42
|
+
directs its sessions to via ``FROID_LOOP_EVENTS_DIR``, and the only one
|
|
43
|
+
created here. ``legacy_dir`` is the pre-#494 in-tree ``<run_dir>/events``,
|
|
44
|
+
polled when given so a project carrying an older installed relay still
|
|
45
|
+
completes its sessions (see the module docstring). It is deliberately NOT
|
|
46
|
+
created: an orchestrator that recreated the in-tree directory would undo the
|
|
47
|
+
move for the operator's `git status` while gaining nothing — a legacy relay
|
|
48
|
+
that writes there makes the directory itself.
|
|
49
|
+
|
|
50
|
+
The single-positional-argument form is unchanged, and stays the shape the
|
|
51
|
+
probe (``probe.py``, watching its own capture dir) and the unit tests use.
|
|
52
|
+
"""
|
|
53
|
+
|
|
54
|
+
def __init__(self, events_dir: Path, legacy_dir: Path | None = None):
|
|
55
|
+
self.events_dir = events_dir
|
|
56
|
+
self.legacy_dir = legacy_dir
|
|
57
|
+
# Keyed by (directory, filename), not by filename: one name identifies an
|
|
58
|
+
# event only WITHIN a directory, and the two dirs are written by two
|
|
59
|
+
# independent relays. Keying on the name alone would let a file consumed
|
|
60
|
+
# from one dir mask a different event of the same name in the other, and a
|
|
61
|
+
# masked event here is a lost Stop — the run then waits out
|
|
62
|
+
# session_timeout_min with its completion signal sitting on disk.
|
|
63
|
+
self._consumed: set[tuple[str, str]] = set()
|
|
64
|
+
self._pending: list[HookEvent] = [] # polled but not yet delivered via wait_for
|
|
65
|
+
events_dir.mkdir(parents=True, exist_ok=True)
|
|
66
|
+
|
|
67
|
+
def _dirs(self) -> list[Path]:
|
|
68
|
+
"""Primary first, then the legacy dir when there is a distinct one. The
|
|
69
|
+
equality check keeps a caller that passes the same path twice from
|
|
70
|
+
double-scanning; it cannot produce duplicate events either way (the
|
|
71
|
+
``_consumed`` key would repeat), but the second scan would be pure waste."""
|
|
72
|
+
if self.legacy_dir is None or self.legacy_dir == self.events_dir:
|
|
73
|
+
return [self.events_dir]
|
|
74
|
+
return [self.events_dir, self.legacy_dir]
|
|
75
|
+
|
|
76
|
+
def poll(self) -> list[HookEvent]:
|
|
77
|
+
"""Return new, well-formed events since the last poll, oldest first.
|
|
78
|
+
|
|
79
|
+
Ordering is by the parsed ``ts`` across BOTH directories, so which relay
|
|
80
|
+
wrote an event never affects where it lands in the sequence.
|
|
81
|
+
|
|
82
|
+
A missing *legacy* directory is the normal case (nothing installed writes
|
|
83
|
+
there any more) and is skipped silently. A missing *primary* still raises,
|
|
84
|
+
as it always has: this watcher created it, so its absence means something
|
|
85
|
+
removed the live control plane out from under the run.
|
|
86
|
+
"""
|
|
87
|
+
events: list[HookEvent] = []
|
|
88
|
+
dirs = self._dirs()
|
|
89
|
+
for directory in dirs:
|
|
90
|
+
try:
|
|
91
|
+
entries = list(directory.iterdir())
|
|
92
|
+
except OSError:
|
|
93
|
+
if directory == self.events_dir:
|
|
94
|
+
raise
|
|
95
|
+
continue # legacy dir absent — the ordinary case
|
|
96
|
+
for entry in entries:
|
|
97
|
+
key = (str(directory), entry.name)
|
|
98
|
+
if key in self._consumed or entry.suffix != ".json":
|
|
99
|
+
continue
|
|
100
|
+
self._consumed.add(key)
|
|
101
|
+
try:
|
|
102
|
+
data = json.loads(entry.read_text(encoding="utf-8"))
|
|
103
|
+
except (json.JSONDecodeError, OSError):
|
|
104
|
+
continue
|
|
105
|
+
if not isinstance(data, dict) or "event" not in data or "task_id" not in data:
|
|
106
|
+
continue
|
|
107
|
+
events.append(
|
|
108
|
+
HookEvent(
|
|
109
|
+
ts=int(data.get("ts", 0)),
|
|
110
|
+
event=str(data["event"]),
|
|
111
|
+
task_id=str(data["task_id"]),
|
|
112
|
+
session_id=data.get("session_id"),
|
|
113
|
+
transcript_path=data.get("transcript_path"),
|
|
114
|
+
path=entry,
|
|
115
|
+
)
|
|
116
|
+
)
|
|
117
|
+
events.sort(key=lambda e: e.ts)
|
|
118
|
+
return events
|
|
119
|
+
|
|
120
|
+
def wait_for(
|
|
121
|
+
self,
|
|
122
|
+
task_id: str,
|
|
123
|
+
kinds: set[str],
|
|
124
|
+
timeout_s: float,
|
|
125
|
+
poll_interval: float = 1.0,
|
|
126
|
+
clock: Callable[[], float] = time.monotonic,
|
|
127
|
+
sleep: Callable[[float], None] = time.sleep,
|
|
128
|
+
since_ns: int = 0,
|
|
129
|
+
) -> HookEvent | None:
|
|
130
|
+
"""Block until an event for task_id with kind in `kinds` arrives, or timeout.
|
|
131
|
+
|
|
132
|
+
Events polled but not matched stay buffered for later wait_for calls —
|
|
133
|
+
several events often land in one poll (e.g. SessionStart + Stop) and
|
|
134
|
+
none may be lost.
|
|
135
|
+
|
|
136
|
+
Events older than `since_ns` (wall-clock ns, the session's launch time)
|
|
137
|
+
are dropped: a resumed run reuses task_ids, and a fresh watcher re-sees the
|
|
138
|
+
events directory from scratch, so a prior cycle's Stop event would
|
|
139
|
+
otherwise replay instantly and the old result.json be read as a bogus
|
|
140
|
+
completion. Sessions run sequentially, so since_ns only advances; anything
|
|
141
|
+
below the current floor is genuinely stale and safe to discard.
|
|
142
|
+
|
|
143
|
+
A re-ARMED run no longer reuses them — `runs.rearm_escalation` bumps
|
|
144
|
+
`StoryTask.generation`, which `engine._session_task_id` folds into the id
|
|
145
|
+
(#705). That removes one source of collision; it does not make this floor
|
|
146
|
+
redundant, because a plain resume re-mints the SAME id by design (that is
|
|
147
|
+
how crash replay finds its record) and is the case this guard was written
|
|
148
|
+
for.
|
|
149
|
+
"""
|
|
150
|
+
deadline = clock() + timeout_s
|
|
151
|
+
while True:
|
|
152
|
+
self._pending.extend(self.poll())
|
|
153
|
+
if since_ns:
|
|
154
|
+
self._pending = [e for e in self._pending if e.ts >= since_ns]
|
|
155
|
+
for i, event in enumerate(self._pending):
|
|
156
|
+
if event.task_id == task_id and event.event in kinds:
|
|
157
|
+
return self._pending.pop(i)
|
|
158
|
+
if clock() >= deadline:
|
|
159
|
+
return None
|
|
160
|
+
sleep(poll_interval)
|