froid-loop 0.11.1__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- froid_loop/__init__.py +11 -0
- froid_loop/__main__.py +12 -0
- froid_loop/adapters/__init__.py +3 -0
- froid_loop/adapters/base.py +254 -0
- froid_loop/adapters/entrypoints.py +63 -0
- froid_loop/adapters/env_fault.py +290 -0
- froid_loop/adapters/generic.py +2013 -0
- froid_loop/adapters/mock.py +49 -0
- froid_loop/adapters/multiplexer.py +914 -0
- froid_loop/adapters/opencode_http.py +1687 -0
- froid_loop/adapters/profile.py +650 -0
- froid_loop/adapters/psmux_backend.py +1428 -0
- froid_loop/adapters/registry.py +322 -0
- froid_loop/adapters/tmux_backend.py +35 -0
- froid_loop/adapters/tmux_base.py +630 -0
- froid_loop/checks.py +187 -0
- froid_loop/cli.py +5041 -0
- froid_loop/data/__init__.py +0 -0
- froid_loop/data/froid_loop_hook.py +228 -0
- froid_loop/data/froid_loop_probe_hook.py +88 -0
- froid_loop/data/plugins/example/plugin.toml +21 -0
- froid_loop/data/plugins/tea/plugin.toml +184 -0
- froid_loop/data/plugins/tea/tea_plugin.py +258 -0
- froid_loop/data/plugins/unity/plugin.toml +140 -0
- froid_loop/data/plugins/unity/unity_assets/FroidLoop.Unity.Editor.asmdef +16 -0
- froid_loop/data/plugins/unity/unity_assets/FroidLoop.Unity.Editor.asmdef.meta +7 -0
- froid_loop/data/plugins/unity/unity_assets/SceneAutoSaveGuard.cs +221 -0
- froid_loop/data/plugins/unity/unity_assets/SceneAutoSaveGuard.cs.meta +11 -0
- froid_loop/data/plugins/unity/unity_assets/_folders/Editor.meta +8 -0
- froid_loop/data/plugins/unity/unity_assets/_folders/FroidLoop.meta +8 -0
- froid_loop/data/plugins/unity/unity_cleanup.py +125 -0
- froid_loop/data/plugins/unity/unity_dialog_probe.py +239 -0
- froid_loop/data/plugins/unity/unity_facts.md +17 -0
- froid_loop/data/plugins/unity/unity_plugin.py +415 -0
- froid_loop/data/plugins/unity/unity_quiesce.py +234 -0
- froid_loop/data/plugins/unity/unity_ready.py +230 -0
- froid_loop/data/plugins/unity/unity_seed_assets.py +298 -0
- froid_loop/data/plugins/unity/unity_setup.py +551 -0
- froid_loop/data/plugins/unity/unity_teardown.py +362 -0
- froid_loop/data/profiles/antigravity.toml +52 -0
- froid_loop/data/profiles/claude.toml +85 -0
- froid_loop/data/profiles/codex.toml +22 -0
- froid_loop/data/profiles/copilot.toml +52 -0
- froid_loop/data/profiles/gemini.toml +26 -0
- froid_loop/data/profiles/opencode.toml +54 -0
- froid_loop/data/settings/core.toml +458 -0
- froid_loop/data/skills/README.md +93 -0
- froid_loop/data/skills/froid-loop-resolve/SKILL.md +288 -0
- froid_loop/data/skills/froid-loop-setup/SKILL.md +161 -0
- froid_loop/data/skills/froid-loop-setup/assets/module-help.csv +3 -0
- froid_loop/data/skills/froid-loop-setup/assets/module.yaml +19 -0
- froid_loop/data/skills/froid-loop-sweep/SKILL.md +100 -0
- froid_loop/data/skills/froid-loop-sweep/automation-mode.md +127 -0
- froid_loop/data/skills/froid-loop-sweep/deferred-work-format.md +302 -0
- froid_loop/data/skills/froid-loop-sweep/migration-mode.md +86 -0
- froid_loop/decisions.py +202 -0
- froid_loop/deferredwork.py +2282 -0
- froid_loop/devcontract.py +892 -0
- froid_loop/diagnostics.py +1104 -0
- froid_loop/documents.py +532 -0
- froid_loop/engine.py +7732 -0
- froid_loop/envvars.py +111 -0
- froid_loop/escalation.py +225 -0
- froid_loop/events.py +266 -0
- froid_loop/fences.py +103 -0
- froid_loop/froidconfig.py +226 -0
- froid_loop/frontmatter.py +526 -0
- froid_loop/gates.py +133 -0
- froid_loop/install.py +2936 -0
- froid_loop/journal.py +178 -0
- froid_loop/machine.py +148 -0
- froid_loop/model.py +898 -0
- froid_loop/operatoractions.py +474 -0
- froid_loop/platform_util.py +1490 -0
- froid_loop/plugins/__init__.py +64 -0
- froid_loop/plugins/bus.py +259 -0
- froid_loop/plugins/context.py +319 -0
- froid_loop/plugins/loader.py +145 -0
- froid_loop/plugins/manifest.py +279 -0
- froid_loop/plugins/model.py +296 -0
- froid_loop/plugins/registry.py +245 -0
- froid_loop/plugins/trust.py +75 -0
- froid_loop/policy.py +1569 -0
- froid_loop/probe.py +1044 -0
- froid_loop/process_host.py +408 -0
- froid_loop/recovery_flow.py +1561 -0
- froid_loop/resolve.py +283 -0
- froid_loop/runs.py +4715 -0
- froid_loop/runsetup.py +1293 -0
- froid_loop/sanitize.py +593 -0
- froid_loop/settings_schema.py +276 -0
- froid_loop/signals.py +160 -0
- froid_loop/sprintstatus.py +609 -0
- froid_loop/statemachine.py +57 -0
- froid_loop/stories.py +615 -0
- froid_loop/stories_engine.py +796 -0
- froid_loop/sweep.py +1892 -0
- froid_loop/tokens.py +196 -0
- froid_loop/tui/__init__.py +11 -0
- froid_loop/tui/app.py +1584 -0
- froid_loop/tui/data.py +840 -0
- froid_loop/tui/launch.py +1003 -0
- froid_loop/tui/screens/__init__.py +1 -0
- froid_loop/tui/screens/dashboard.py +1071 -0
- froid_loop/tui/screens/modals.py +943 -0
- froid_loop/tui/screens/settings_screen.py +477 -0
- froid_loop/tui/settings.py +135 -0
- froid_loop/tui/widgets.py +981 -0
- froid_loop/verify.py +4545 -0
- froid_loop/workspace.py +320 -0
- froid_loop/worktree_flow.py +2301 -0
- froid_loop-0.11.1.dist-info/METADATA +728 -0
- froid_loop-0.11.1.dist-info/RECORD +116 -0
- froid_loop-0.11.1.dist-info/WHEEL +4 -0
- froid_loop-0.11.1.dist-info/entry_points.txt +2 -0
- froid_loop-0.11.1.dist-info/licenses/LICENSE +30 -0
froid_loop/envvars.py
ADDED
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
"""Central registry of the ``FROID_LOOP_*`` runtime environment variables.
|
|
2
|
+
|
|
3
|
+
These operator/test override knobs used to be read inline at scattered call
|
|
4
|
+
sites (`engine`, `adapters.multiplexer`, `cli`, `process_host`), which left them
|
|
5
|
+
undiscoverable and undocumented. This module is the one place each is named,
|
|
6
|
+
typed, and given a reader; the operator-facing table lives in the README under
|
|
7
|
+
"Environment variables".
|
|
8
|
+
|
|
9
|
+
Each reader preserves its call site's exact semantics — same parse, same
|
|
10
|
+
fallback — so routing a site through here changes nothing observable. Only the
|
|
11
|
+
core operator/test knobs belong here (the count is deliberately not stated — it
|
|
12
|
+
has already changed once); the `FROID_LOOP_UNITY_*` / `FROID_LOOP_ENGINE_*` family
|
|
13
|
+
read by the bundled Unity plugin's stand-alone helper scripts is that plugin's
|
|
14
|
+
own contract (documented in the game-engine guide) and stays with it. Nor do the
|
|
15
|
+
session-protocol vars the engine *injects* into a child session
|
|
16
|
+
(`FROID_LOOP_RUN_DIR`, `FROID_LOOP_TASK_ID`, …): those have a producing side inside
|
|
17
|
+
the orchestrator, and the stdlib-only relays that read them back cannot import
|
|
18
|
+
this module at all.
|
|
19
|
+
|
|
20
|
+
Note what the carve-out is and is not: "has a producing side" does not qualify a
|
|
21
|
+
name, "is read only by a stdlib-only relay that cannot import this module" does.
|
|
22
|
+
Anything core orchestration reads back belongs here, and
|
|
23
|
+
`test_portability_guard.test_froid_loop_env_reads_only_in_the_registry` is the
|
|
24
|
+
enforced form of that rule.
|
|
25
|
+
"""
|
|
26
|
+
|
|
27
|
+
from __future__ import annotations
|
|
28
|
+
|
|
29
|
+
import math
|
|
30
|
+
import os
|
|
31
|
+
|
|
32
|
+
#: Overrides the per-session wall-clock budget, in seconds (test / E2E hook).
|
|
33
|
+
SESSION_TIMEOUT_S = "FROID_LOOP_SESSION_TIMEOUT_S"
|
|
34
|
+
#: Forces the terminal-multiplexer backend by registered name.
|
|
35
|
+
MUX_BACKEND = "FROID_LOOP_MUX_BACKEND"
|
|
36
|
+
#: Forces the process-host implementation by registered name (test / override).
|
|
37
|
+
PROCESS_HOST = "FROID_LOOP_PROCESS_HOST"
|
|
38
|
+
#: Overrides the user-scoped state root that per-run control-plane state lives
|
|
39
|
+
#: under (see :func:`runs.state_root`), replacing the whole platform cascade.
|
|
40
|
+
STATE_DIR = "FROID_LOOP_STATE_DIR"
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def session_timeout_s() -> float | None:
|
|
44
|
+
"""The per-session wall-clock override in seconds, or ``None`` when unset.
|
|
45
|
+
|
|
46
|
+
Anything that is not a finite positive number of seconds reads as ``None``
|
|
47
|
+
(ignored), and the guard is deliberately two-sided. Rejecting zero and
|
|
48
|
+
negatives keeps a fat-fingered override from silently *shortening* a real
|
|
49
|
+
run's budget; rejecting non-finite values keeps ``inf`` / ``1e999`` /
|
|
50
|
+
``Infinity`` — all of which ``float()`` accepts and all of which pass a bare
|
|
51
|
+
``> 0`` — from silently *removing* it. That second half matters more than it
|
|
52
|
+
looks: both adapters build their monotonic and wall-clock deadlines by
|
|
53
|
+
adding this to the current time, so a non-finite budget yields a deadline
|
|
54
|
+
that can never expire, and this is the outer bound every stall-grace and
|
|
55
|
+
wake-nudge window defers to. Losing it means an unattended run can wedge
|
|
56
|
+
with no backstop left.
|
|
57
|
+
|
|
58
|
+
A very large *finite* value is still honoured. It is a duration, however
|
|
59
|
+
unwise, and an operator asking for one is expressing intent; ``inf`` is not
|
|
60
|
+
a duration at all.
|
|
61
|
+
"""
|
|
62
|
+
raw = os.environ.get(SESSION_TIMEOUT_S)
|
|
63
|
+
if raw is None:
|
|
64
|
+
return None
|
|
65
|
+
try:
|
|
66
|
+
value = float(raw)
|
|
67
|
+
except ValueError:
|
|
68
|
+
return None
|
|
69
|
+
if not math.isfinite(value) or value <= 0:
|
|
70
|
+
return None
|
|
71
|
+
return value
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
def mux_backend() -> str | None:
|
|
75
|
+
"""The forced terminal-multiplexer backend name, or ``None`` when unset.
|
|
76
|
+
|
|
77
|
+
Returned verbatim (callers test truthiness and resolve the name), so the
|
|
78
|
+
forced-selection semantics match the raw env read exactly.
|
|
79
|
+
"""
|
|
80
|
+
return os.environ.get(MUX_BACKEND)
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
def process_host() -> str | None:
|
|
84
|
+
"""The forced process-host name, or ``None`` when unset."""
|
|
85
|
+
return os.environ.get(PROCESS_HOST)
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
def state_dir() -> str | None:
|
|
89
|
+
"""The overriding froid-loop state root, or ``None`` when unset.
|
|
90
|
+
|
|
91
|
+
Verbatim like the two name readers above — :func:`runs.state_root` uses the
|
|
92
|
+
value as the state root itself, so an operator who names a directory gets that
|
|
93
|
+
directory. Silently ignoring a stated override in favour of the platform
|
|
94
|
+
cascade would be the same failure :func:`mux_backend` refuses: a loud
|
|
95
|
+
misconfiguration turned into a quiet auto-select, discoverable only by
|
|
96
|
+
noticing where a run's events did *not* appear.
|
|
97
|
+
|
|
98
|
+
Reading verbatim is not the same as accepting anything: this reader reports
|
|
99
|
+
what is set, and :func:`runs.state_root` judges it. A **relative** value is
|
|
100
|
+
refused there rather than resolved, because the root is read by two processes
|
|
101
|
+
with different working directories — see that function for the full reason.
|
|
102
|
+
The split is deliberate; a reader that silently rewrote its variable would
|
|
103
|
+
make the refusal impossible to state.
|
|
104
|
+
|
|
105
|
+
The one value not passed through is the empty string, which reads as unset.
|
|
106
|
+
``export FROID_LOOP_STATE_DIR=`` is what an unset-looking export leaves behind,
|
|
107
|
+
and it is not a directory an operator can have meant: ``Path("")`` is the
|
|
108
|
+
*current directory*, so honouring it would silently root the control plane at
|
|
109
|
+
whatever cwd the loop happened to be launched from.
|
|
110
|
+
"""
|
|
111
|
+
return os.environ.get(STATE_DIR) or None
|
froid_loop/escalation.py
ADDED
|
@@ -0,0 +1,225 @@
|
|
|
1
|
+
"""Retry budgets and typed escalation.
|
|
2
|
+
|
|
3
|
+
CRITICAL escalations pause the run for a human; PREFERENCE escalations are
|
|
4
|
+
journaled and the run continues. Exhausted budgets plateau-defer: the story
|
|
5
|
+
is skipped and the run stays alive.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
from dataclasses import dataclass
|
|
11
|
+
from enum import StrEnum
|
|
12
|
+
from typing import Any
|
|
13
|
+
|
|
14
|
+
from .adapters.base import SessionResult
|
|
15
|
+
from .model import StoryTask, VerifyOutcome
|
|
16
|
+
from .policy import Policy
|
|
17
|
+
|
|
18
|
+
SEVERITY_CRITICAL = "CRITICAL"
|
|
19
|
+
SEVERITY_PREFERENCE = "PREFERENCE"
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
class Action(StrEnum):
|
|
23
|
+
PROCEED = "proceed"
|
|
24
|
+
RETRY = "retry"
|
|
25
|
+
DEFER = "defer"
|
|
26
|
+
PAUSE = "pause"
|
|
27
|
+
# review.on_timeout = "salvage-if-done" only (#271): the engine attempts to
|
|
28
|
+
# commit the already-finalized dev product instead of burning another review
|
|
29
|
+
# cycle; when salvage is not applicable it falls back through
|
|
30
|
+
# `review_retry_or_exhaust`. Produced only by `decide_review_session`.
|
|
31
|
+
SALVAGE = "salvage"
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
# Timeout-like review verdicts review.on_timeout governs (#271): deliberately the
|
|
35
|
+
# same set `_post_kill_reconcile` treats as rescue-eligible. `crashed` is excluded
|
|
36
|
+
# — a hard window death is cheap to retry and already honors on-disk artifacts via
|
|
37
|
+
# the crash-path read-back — and env-fault (#194) short-circuits before this.
|
|
38
|
+
REVIEW_TIMEOUT_STATUSES = frozenset({"timeout", "stalled", "over_budget"})
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
@dataclass(frozen=True)
|
|
42
|
+
class Decision:
|
|
43
|
+
action: Action
|
|
44
|
+
reason: str = ""
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def critical_escalations(result_json: dict[str, Any] | None) -> list[dict[str, Any]]:
|
|
48
|
+
if not result_json:
|
|
49
|
+
return []
|
|
50
|
+
return [
|
|
51
|
+
e
|
|
52
|
+
for e in result_json.get("escalations", [])
|
|
53
|
+
if isinstance(e, dict) and str(e.get("severity", "")).upper() == SEVERITY_CRITICAL
|
|
54
|
+
]
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def preference_escalations(result_json: dict[str, Any] | None) -> list[dict[str, Any]]:
|
|
58
|
+
if not result_json:
|
|
59
|
+
return []
|
|
60
|
+
return [
|
|
61
|
+
e
|
|
62
|
+
for e in result_json.get("escalations", [])
|
|
63
|
+
if isinstance(e, dict) and str(e.get("severity", "")).upper() != SEVERITY_CRITICAL
|
|
64
|
+
]
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
def env_fault_detail(result: SessionResult) -> str:
|
|
68
|
+
"""The evidence excerpt for an environment-fault pause reason, or a generic
|
|
69
|
+
fallback when the adapter classified a fault but kept no line (#194). Shared
|
|
70
|
+
by every site that pauses a transport-failed session — dev/review here, plus
|
|
71
|
+
fix/workflow/sweep in the engine — so the reason wording stays uniform:
|
|
72
|
+
``environment fault: <role> session <status> (<detail>)``."""
|
|
73
|
+
return result.env_fault_evidence or "transport-failure pattern in session log"
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
def env_fault_pause_reason(role: str, result: SessionResult) -> str:
|
|
77
|
+
"""The uniform pause/escalation reason for a transport-failed session (#194).
|
|
78
|
+
`role` is the descriptor placed before "session" — e.g. "dev", "review",
|
|
79
|
+
"fix", "migration", "triage", or a richer "blocking workflow 'x' (y)". Keeps
|
|
80
|
+
the wording identical across escalation/engine/sweep (see env_fault_detail).
|
|
81
|
+
|
|
82
|
+
Composed over `session_failure_reason` so the two diagnoses cannot cancel
|
|
83
|
+
each other out (#489): `crashed` is in `ENV_FAULT_STATUSES`, and both deciders
|
|
84
|
+
test `env_fault` FIRST, so a session destroyed under the run whose pane-log
|
|
85
|
+
tail also matches a transport pattern would otherwise pause blaming only the
|
|
86
|
+
provider. Both facts hold and the operator needs both — a lost session is not
|
|
87
|
+
evidence about the API, and a log pattern is not evidence the session
|
|
88
|
+
survived."""
|
|
89
|
+
return f"environment fault: {session_failure_reason(role, result)} ({env_fault_detail(result)})"
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
def session_failure_reason(role: str, result: SessionResult) -> str:
|
|
93
|
+
"""The reason text for a non-completed session: ``<role> session <status>``,
|
|
94
|
+
plus the lost-session diagnosis (#489).
|
|
95
|
+
|
|
96
|
+
Without the suffix a session destroyed under the run reads exactly like a CLI
|
|
97
|
+
that ran and produced nothing, and the operator debugs the agent instead of
|
|
98
|
+
the host. Routing is unchanged either way — the verdict was already correct,
|
|
99
|
+
only its explanation was missing.
|
|
100
|
+
|
|
101
|
+
Only a ``crashed`` verdict can ever carry the suffix (``session_vanished`` is
|
|
102
|
+
stamped nowhere else), so on the timeout/stall paths the suffix never appears.
|
|
103
|
+
|
|
104
|
+
The wording states what the evidence *withdraws*, not what it proves. All the
|
|
105
|
+
probe establishes is that a session lookup came back negative — see
|
|
106
|
+
``TerminalMultiplexer.has_session``, whose False is "the backend did not
|
|
107
|
+
confirm it", not "the session provably no longer exists". That is enough to
|
|
108
|
+
stop an operator reading window death as a CLI exit, and not enough to assert
|
|
109
|
+
the session was destroyed."""
|
|
110
|
+
reason = f"{role} session {result.status}"
|
|
111
|
+
if result.session_vanished:
|
|
112
|
+
return (
|
|
113
|
+
f"{reason}: the multiplexer no longer reports the session, so the window's "
|
|
114
|
+
"disappearance is not evidence the CLI exited"
|
|
115
|
+
)
|
|
116
|
+
return reason
|
|
117
|
+
|
|
118
|
+
|
|
119
|
+
def decide_dev(
|
|
120
|
+
task: StoryTask,
|
|
121
|
+
result: SessionResult,
|
|
122
|
+
outcome: VerifyOutcome | None,
|
|
123
|
+
policy: Policy,
|
|
124
|
+
) -> Decision:
|
|
125
|
+
"""After a dev session (and its verification, when the session completed)."""
|
|
126
|
+
crits = critical_escalations(result.result_json)
|
|
127
|
+
if crits:
|
|
128
|
+
details = "; ".join(str(e.get("detail", e.get("type", "?"))) for e in crits)
|
|
129
|
+
return Decision(Action.PAUSE, f"CRITICAL escalation from dev session: {details}")
|
|
130
|
+
|
|
131
|
+
budget_left = task.attempt < policy.limits.max_dev_attempts
|
|
132
|
+
exhausted = _exhausted_action(task)
|
|
133
|
+
|
|
134
|
+
if result.status != "completed":
|
|
135
|
+
if result.env_fault:
|
|
136
|
+
# A transport/API failure (the CLI never reached the API, #194): the
|
|
137
|
+
# attempt did no real work, so pause for a human instead of charging
|
|
138
|
+
# it — re-arm resets the budget, exactly like a verify env-fault (rc
|
|
139
|
+
# 126/127). The crits check above already ran (env-fault results carry
|
|
140
|
+
# result_json=None, so it found nothing).
|
|
141
|
+
return Decision(
|
|
142
|
+
Action.PAUSE,
|
|
143
|
+
env_fault_pause_reason("dev", result),
|
|
144
|
+
)
|
|
145
|
+
reason = session_failure_reason("dev", result)
|
|
146
|
+
if budget_left:
|
|
147
|
+
return Decision(Action.RETRY, reason)
|
|
148
|
+
return Decision(exhausted, _exhaust_reason(task, reason))
|
|
149
|
+
|
|
150
|
+
assert outcome is not None
|
|
151
|
+
if outcome.ok:
|
|
152
|
+
return Decision(Action.PROCEED)
|
|
153
|
+
if outcome.severity == SEVERITY_CRITICAL:
|
|
154
|
+
return Decision(Action.PAUSE, outcome.reason)
|
|
155
|
+
if budget_left:
|
|
156
|
+
return Decision(Action.RETRY, outcome.reason)
|
|
157
|
+
return Decision(exhausted, _exhaust_reason(task, outcome.reason))
|
|
158
|
+
|
|
159
|
+
|
|
160
|
+
def decide_review_session(task: StoryTask, result: SessionResult, policy: Policy) -> Decision:
|
|
161
|
+
"""After a review session returns, before interpreting its done/followup status."""
|
|
162
|
+
crits = critical_escalations(result.result_json)
|
|
163
|
+
if crits:
|
|
164
|
+
details = "; ".join(str(e.get("detail", e.get("type", "?"))) for e in crits)
|
|
165
|
+
return Decision(Action.PAUSE, f"CRITICAL escalation from review session: {details}")
|
|
166
|
+
|
|
167
|
+
if result.status != "completed":
|
|
168
|
+
if result.env_fault:
|
|
169
|
+
# transport/API failure (#194): pause rather than charge a review
|
|
170
|
+
# cycle for a session that never reached the API (see decide_dev).
|
|
171
|
+
return Decision(
|
|
172
|
+
Action.PAUSE,
|
|
173
|
+
env_fault_pause_reason("review", result),
|
|
174
|
+
)
|
|
175
|
+
reason = session_failure_reason("review", result)
|
|
176
|
+
if result.status in REVIEW_TIMEOUT_STATUSES:
|
|
177
|
+
mode = policy.review.on_timeout
|
|
178
|
+
if mode == "defer":
|
|
179
|
+
return Decision(
|
|
180
|
+
_exhausted_action(task),
|
|
181
|
+
_exhaust_reason(task, f"{reason} (review.on_timeout=defer)"),
|
|
182
|
+
)
|
|
183
|
+
if mode == "salvage-if-done":
|
|
184
|
+
return Decision(Action.SALVAGE, reason)
|
|
185
|
+
return review_retry_or_exhaust(task, policy, reason)
|
|
186
|
+
return Decision(Action.PROCEED)
|
|
187
|
+
|
|
188
|
+
|
|
189
|
+
def review_retry_or_exhaust(task: StoryTask, policy: Policy, reason: str) -> Decision:
|
|
190
|
+
"""The default routing for a failed review session: RETRY while the outer
|
|
191
|
+
cycle budget lasts, then plateau-defer (or re-escalate mid re-drive). Module-
|
|
192
|
+
level so the engine can fall back through it when a SALVAGE attempt turns out
|
|
193
|
+
not to be applicable (#271)."""
|
|
194
|
+
if task.review_cycle < policy.limits.max_review_cycles:
|
|
195
|
+
return Decision(Action.RETRY, reason)
|
|
196
|
+
return review_exhausted(task, reason)
|
|
197
|
+
|
|
198
|
+
|
|
199
|
+
def review_exhausted(task: StoryTask, reason: str) -> Decision:
|
|
200
|
+
"""Terminal review-side failure routing without spending another session.
|
|
201
|
+
|
|
202
|
+
Used when a deterministic post-review repair has exhausted its own local
|
|
203
|
+
retry bound and launching another reviewer would be unsafe. It preserves the
|
|
204
|
+
same resolved-CRITICAL re-drive rule as ordinary review-budget exhaustion.
|
|
205
|
+
"""
|
|
206
|
+
return Decision(_exhausted_action(task), _exhaust_reason(task, reason))
|
|
207
|
+
|
|
208
|
+
|
|
209
|
+
def _exhausted_action(task: StoryTask) -> Action:
|
|
210
|
+
"""What a budget-exhausted, non-CRITICAL failure resolves to. Normally a
|
|
211
|
+
plateau-defer (skip the story, keep the run alive). But a story mid re-drive
|
|
212
|
+
of a human-resolved CRITICAL escalation (``resolved_redrive`` latched, not
|
|
213
|
+
yet re-committed) must NOT silently downgrade to a defer — that would file an
|
|
214
|
+
unresolved escalation as deferred work and roll back the human's correction.
|
|
215
|
+
Re-escalate so the human sees it again; ``_escalate`` preserves the tree."""
|
|
216
|
+
return Action.PAUSE if task.resolved_redrive else Action.DEFER
|
|
217
|
+
|
|
218
|
+
|
|
219
|
+
def _exhaust_reason(task: StoryTask, reason: str) -> str:
|
|
220
|
+
if task.resolved_redrive:
|
|
221
|
+
return (
|
|
222
|
+
"resolved-escalation re-drive did not converge — re-escalating "
|
|
223
|
+
f"instead of deferring: {reason}"
|
|
224
|
+
)
|
|
225
|
+
return reason
|
froid_loop/events.py
ADDED
|
@@ -0,0 +1,266 @@
|
|
|
1
|
+
"""Importable twin of the hardened event write in ``data/froid_loop_hook.py``.
|
|
2
|
+
|
|
3
|
+
The hook script is COPIED into every target project and runs inside the coding
|
|
4
|
+
CLI's process under whatever interpreter the host has, so it is stdlib-only by
|
|
5
|
+
contract (its docstring says so) and cannot import ``froid_loop`` to reach this
|
|
6
|
+
module — and this module cannot import it back, since it ships as package DATA
|
|
7
|
+
rather than as an importable module. Hence a twin rather than shared code:
|
|
8
|
+
``_LINK_REPARSE_TAGS``, ``_first_workspace``, ``_is_link_like``, ``_write_all``
|
|
9
|
+
and ``_write_event`` below are byte-identical copies of the hook's, pinned that
|
|
10
|
+
way by ``tests/test_events.py::test_the_twinned_source_is_identical`` — which
|
|
11
|
+
AST-extracts both sides and compares the source segments, so a fix applied to one
|
|
12
|
+
writer of the events control plane and not the other cannot pass review silently.
|
|
13
|
+
|
|
14
|
+
Because they are byte-identical, their docstrings and comments are written from
|
|
15
|
+
the hook script's vantage point ("this relay runs under whatever interpreter the
|
|
16
|
+
host has") and stay that way on purpose: rewording either side to suit its own
|
|
17
|
+
file breaks parity, and diverging is exactly what two separately-hardened writers
|
|
18
|
+
of one control plane must not do. What the shared text says about the attack, the
|
|
19
|
+
platform branches, and the residual Windows windows holds for both.
|
|
20
|
+
|
|
21
|
+
The rest of the module is this side's own: the payload shaping the hook does
|
|
22
|
+
inline in its ``main()``, and :func:`relay`, which backs ``froid-loop relay
|
|
23
|
+
<Event>`` — the #461 Phase 2 hook target, an installed console script instead of
|
|
24
|
+
a file path inside the workspace that a branch switch can take away.
|
|
25
|
+
"""
|
|
26
|
+
|
|
27
|
+
from __future__ import annotations
|
|
28
|
+
|
|
29
|
+
import json
|
|
30
|
+
import os
|
|
31
|
+
import stat
|
|
32
|
+
import time
|
|
33
|
+
from typing import IO, Any
|
|
34
|
+
|
|
35
|
+
# Windows reparse tags that make a directory entry REDIRECT somewhere else,
|
|
36
|
+
# compared against os.lstat().st_reparse_tag (Windows, 3.8+). Deliberately not
|
|
37
|
+
# os.path.isjunction(), which is 3.12+ — this relay runs under whatever
|
|
38
|
+
# interpreter the host has, not under the orchestrator's. Deliberately not "any
|
|
39
|
+
# reparse tag" either: cloud placeholders (OneDrive) and dedup stubs are reparse
|
|
40
|
+
# points too, and refusing those would stall a legitimate run. Empty on POSIX.
|
|
41
|
+
_LINK_REPARSE_TAGS = tuple(
|
|
42
|
+
tag
|
|
43
|
+
for tag in (
|
|
44
|
+
getattr(stat, "IO_REPARSE_TAG_SYMLINK", None),
|
|
45
|
+
getattr(stat, "IO_REPARSE_TAG_MOUNT_POINT", None),
|
|
46
|
+
)
|
|
47
|
+
if tag is not None
|
|
48
|
+
)
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def _first_workspace(payload):
|
|
52
|
+
paths = payload.get("workspacePaths")
|
|
53
|
+
if isinstance(paths, list) and paths and isinstance(paths[0], str):
|
|
54
|
+
return paths[0]
|
|
55
|
+
return None
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def _is_link_like(path):
|
|
59
|
+
"""True when `path` redirects elsewhere: a POSIX symlink, or a Windows
|
|
60
|
+
symlink OR DIRECTORY JUNCTION.
|
|
61
|
+
|
|
62
|
+
`os.path.islink()` is False for a junction — junctions are a distinct
|
|
63
|
+
reparse kind, which is why `os.path.isjunction()` exists at all. On Windows
|
|
64
|
+
the junction is the arm that matters: `mklink /J` needs no elevation, while
|
|
65
|
+
a directory symlink needs SeCreateSymbolicLinkPrivilege or Developer Mode —
|
|
66
|
+
so the unprivileged attack is exactly the one `islink()` misses.
|
|
67
|
+
"""
|
|
68
|
+
if os.path.islink(path):
|
|
69
|
+
return True
|
|
70
|
+
try:
|
|
71
|
+
return getattr(os.lstat(path), "st_reparse_tag", 0) in _LINK_REPARSE_TAGS
|
|
72
|
+
except OSError:
|
|
73
|
+
return False
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
def _write_all(fd, data):
|
|
77
|
+
"""Write every byte of `data` to `fd`.
|
|
78
|
+
|
|
79
|
+
`os.write()` may write FEWER bytes than asked and simply return the count. A
|
|
80
|
+
truncated event file is not merely retried, it is lost: `SignalWatcher.poll`
|
|
81
|
+
adds a filename to its consumed set BEFORE parsing it (signals.py), so
|
|
82
|
+
malformed JSON is skipped and never re-read — the session's Stop signal is
|
|
83
|
+
gone for good and the run waits out `session_timeout_min`. The buffered
|
|
84
|
+
`open()` this replaced looped internally; the raw fd needed for
|
|
85
|
+
O_NOFOLLOW/dir_fd does not, so loop here.
|
|
86
|
+
"""
|
|
87
|
+
view = memoryview(data)
|
|
88
|
+
while view:
|
|
89
|
+
written = os.write(fd, view)
|
|
90
|
+
if written <= 0: # not observed in practice; a spinning hook is worse
|
|
91
|
+
raise OSError("short write to the event file")
|
|
92
|
+
view = view[written:]
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
def _write_event(events_dir, name, event):
|
|
96
|
+
"""Write one event file into `events_dir`, refusing to follow a redirect.
|
|
97
|
+
|
|
98
|
+
The events dir is the orchestrator's control plane. A driven session has
|
|
99
|
+
write access to the project, so it could plant `<run_dir>/events` as a
|
|
100
|
+
symlink (or, on Windows, a junction) and redirect — or swallow — the
|
|
101
|
+
completion signal, stalling the run to `session_timeout_min` instead of
|
|
102
|
+
completing. `os.makedirs(exist_ok=True)` `isdir()`-checks THROUGH such a
|
|
103
|
+
link, so the refusal has to come before it. That refusal works on every
|
|
104
|
+
platform.
|
|
105
|
+
|
|
106
|
+
Where the platform has them, the create+replace is anchored to a dir_fd
|
|
107
|
+
opened O_NOFOLLOW: every later operation goes through that fd, so a swap
|
|
108
|
+
after the check cannot reach the write. Windows has neither
|
|
109
|
+
O_NOFOLLOW/O_DIRECTORY nor a handle-relative open (`os.supports_dir_fd` is
|
|
110
|
+
empty — dir_fd is implemented with the POSIX `*at` calls), so its fallback
|
|
111
|
+
re-resolves the path and the check-to-write window stays open there. It is
|
|
112
|
+
NARROWED, not closed: the redirect check runs again after the payload is
|
|
113
|
+
written and before it is published, so a swap still in place is refused and
|
|
114
|
+
the temp file removed. Two windows stay open on that path (#494), both
|
|
115
|
+
measured: a swap-and-restore around the create is undetectable from stdlib
|
|
116
|
+
Python, and a swap landing after the second check leaves the path-based
|
|
117
|
+
publish unable to find the temp file it wrote — it either raises or renames
|
|
118
|
+
a file the attacker planted inside the attacker's own directory. Neither
|
|
119
|
+
redirects the payload, and both end where a refusal ends: no event, so the
|
|
120
|
+
run waits out session_timeout_min. That is the same outcome an attacker gets
|
|
121
|
+
for free by leaving a redirect in place, which is refused without any race —
|
|
122
|
+
winning the race buys no capability, which is why the residual is accepted
|
|
123
|
+
rather than chased into ctypes/NtCreateFile inside a stdlib-only relay.
|
|
124
|
+
|
|
125
|
+
Mode is 0o600 (narrowed from the umask-derived mode an ordinary `open()`
|
|
126
|
+
produced): only the operator running the loop reads these.
|
|
127
|
+
|
|
128
|
+
Raises OSError on any refusal or failure; the caller degrades to a no-op.
|
|
129
|
+
"""
|
|
130
|
+
if _is_link_like(events_dir):
|
|
131
|
+
raise OSError(f"refusing to write events into a redirected directory: {events_dir}")
|
|
132
|
+
os.makedirs(events_dir, exist_ok=True)
|
|
133
|
+
data = json.dumps(event).encode("utf-8")
|
|
134
|
+
tmp = name + ".tmp"
|
|
135
|
+
o_nofollow = getattr(os, "O_NOFOLLOW", 0)
|
|
136
|
+
o_directory = getattr(os, "O_DIRECTORY", 0)
|
|
137
|
+
# O_BINARY is a no-op flag on POSIX; on Windows it stops the fd from
|
|
138
|
+
# newline-translating what os.write() puts through it.
|
|
139
|
+
create = os.O_WRONLY | os.O_CREAT | os.O_EXCL | o_nofollow | getattr(os, "O_BINARY", 0)
|
|
140
|
+
# Probe os.rename, not os.replace: CPython omits os.replace from
|
|
141
|
+
# supports_dir_fd on Linux even though it accepts src_dir_fd/dst_dir_fd, so
|
|
142
|
+
# probing it would leave this whole branch dead everywhere. This branch is
|
|
143
|
+
# POSIX-only by construction, and there rename(2) IS the atomic-replace
|
|
144
|
+
# primitive os.replace wraps — probe the function actually called.
|
|
145
|
+
if o_nofollow and o_directory and {os.open, os.rename} <= os.supports_dir_fd:
|
|
146
|
+
dir_fd = os.open(events_dir, os.O_RDONLY | o_directory | o_nofollow)
|
|
147
|
+
try:
|
|
148
|
+
fd = os.open(tmp, create, 0o600, dir_fd=dir_fd)
|
|
149
|
+
try:
|
|
150
|
+
_write_all(fd, data)
|
|
151
|
+
finally:
|
|
152
|
+
os.close(fd)
|
|
153
|
+
os.rename(tmp, name, src_dir_fd=dir_fd, dst_dir_fd=dir_fd)
|
|
154
|
+
finally:
|
|
155
|
+
os.close(dir_fd)
|
|
156
|
+
return
|
|
157
|
+
# Fallback (Windows): no dir_fd to anchor to, so the create below re-resolves
|
|
158
|
+
# events_dir by path. A swap into a junction between the check above and this
|
|
159
|
+
# create would have put the temp file inside the attacker's directory. Check
|
|
160
|
+
# again before publishing, so a swap that is still in place is refused rather
|
|
161
|
+
# than followed — the realistic shape, since a junction has to persist to
|
|
162
|
+
# capture the events the attacker is after.
|
|
163
|
+
tmp_path = os.path.join(events_dir, tmp)
|
|
164
|
+
fd = os.open(tmp_path, create, 0o600)
|
|
165
|
+
try:
|
|
166
|
+
_write_all(fd, data)
|
|
167
|
+
finally:
|
|
168
|
+
os.close(fd)
|
|
169
|
+
if _is_link_like(events_dir):
|
|
170
|
+
try:
|
|
171
|
+
os.unlink(tmp_path)
|
|
172
|
+
except OSError:
|
|
173
|
+
pass
|
|
174
|
+
raise OSError(f"events directory was redirected mid-write: {events_dir}")
|
|
175
|
+
os.replace(tmp_path, os.path.join(events_dir, name))
|
|
176
|
+
|
|
177
|
+
|
|
178
|
+
# --------------------------------------------------------------- this side only
|
|
179
|
+
|
|
180
|
+
|
|
181
|
+
def event_file_name(ts: int, task_id: str, event_name: str) -> str:
|
|
182
|
+
"""The event file's name. Sorted-by-time by construction (``ts`` first, fixed
|
|
183
|
+
width in practice), and carrying the task id so ``SignalWatcher`` can attribute
|
|
184
|
+
a file without opening it. Mirrors the hook's f-string exactly; the twin above
|
|
185
|
+
stops at the write, so this and :func:`shape_event` are pinned behaviorally
|
|
186
|
+
instead (``test_relay_and_hook_produce_the_same_event``)."""
|
|
187
|
+
return f"{ts}-{task_id}-{event_name}.json"
|
|
188
|
+
|
|
189
|
+
|
|
190
|
+
def shape_event(ts: int, event_name: str, task_id: str, payload: dict[str, Any]) -> dict[str, Any]:
|
|
191
|
+
"""The event record the orchestrator consumes, built from one hook payload."""
|
|
192
|
+
return {
|
|
193
|
+
"ts": ts,
|
|
194
|
+
"event": event_name,
|
|
195
|
+
"task_id": task_id,
|
|
196
|
+
# Payload keys vary by CLI: snake_case (claude/codex), conversation_id
|
|
197
|
+
# (cursor), or camelCase (copilot's sessionId/transcriptPath, agy's
|
|
198
|
+
# conversationId). Try each.
|
|
199
|
+
"session_id": (
|
|
200
|
+
payload.get("session_id")
|
|
201
|
+
or payload.get("conversation_id")
|
|
202
|
+
or payload.get("sessionId")
|
|
203
|
+
or payload.get("conversationId")
|
|
204
|
+
),
|
|
205
|
+
"transcript_path": payload.get("transcript_path") or payload.get("transcriptPath"),
|
|
206
|
+
# agy sends no cwd — it sends workspacePaths, a list of workspace roots.
|
|
207
|
+
"cwd": payload.get("cwd") or _first_workspace(payload),
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
|
|
211
|
+
def _read_payload(stdin: IO[str]) -> dict[str, Any]:
|
|
212
|
+
"""The hook payload, or an empty dict for anything unreadable.
|
|
213
|
+
|
|
214
|
+
A hook that fires with nothing on stdin, half a JSON document, undecodable
|
|
215
|
+
bytes, or a bare list is not an error the operator can act on — the event still
|
|
216
|
+
has to be written, because the run's completion signal rides on it. Every
|
|
217
|
+
non-dict outcome collapses to ``{}`` and the shaped event simply carries nulls.
|
|
218
|
+
``UnicodeDecodeError`` and ``json.JSONDecodeError`` are both ``ValueError``
|
|
219
|
+
subclasses and ride the same arm; ``OSError`` covers a closed or unreadable
|
|
220
|
+
descriptor.
|
|
221
|
+
"""
|
|
222
|
+
try:
|
|
223
|
+
payload = json.load(stdin)
|
|
224
|
+
except (ValueError, OSError):
|
|
225
|
+
return {}
|
|
226
|
+
return payload if isinstance(payload, dict) else {}
|
|
227
|
+
|
|
228
|
+
|
|
229
|
+
def relay(event_name: str, stdin: IO[str]) -> int:
|
|
230
|
+
"""Write one event file for the session this process was spawned inside.
|
|
231
|
+
|
|
232
|
+
The contract is the hook script's, because the hook config points at one or
|
|
233
|
+
the other and the orchestrator must not be able to tell which ran: a silent
|
|
234
|
+
no-op when the session was not spawned by froid-loop (the env vars are the
|
|
235
|
+
detector), garbage stdin tolerated, and any ``OSError`` from a hostile or
|
|
236
|
+
broken events dir degrading to rc 0. Never anything on stdout — the CLI hosts
|
|
237
|
+
parse hook stdout — and never a non-zero rc, which several of them surface as
|
|
238
|
+
a failed tool call inside the very session whose completion this reports.
|
|
239
|
+
|
|
240
|
+
Returns 0 unconditionally. Nothing here is worth failing a session over: the
|
|
241
|
+
orchestrator's fallback for a missing event is ``session_timeout_min``, and an
|
|
242
|
+
attacker who can suppress the event can already get that outcome by planting
|
|
243
|
+
the redirect ``_write_event`` refuses.
|
|
244
|
+
"""
|
|
245
|
+
run_dir = os.environ.get("FROID_LOOP_RUN_DIR")
|
|
246
|
+
task_id = os.environ.get("FROID_LOOP_TASK_ID")
|
|
247
|
+
if not run_dir or not task_id:
|
|
248
|
+
return 0
|
|
249
|
+
ts = time.time_ns()
|
|
250
|
+
event = shape_event(ts, event_name, task_id, _read_payload(stdin))
|
|
251
|
+
# $FROID_LOOP_EVENTS_DIR when the orchestrator names one (#494 moved the
|
|
252
|
+
# channel out of the project tree), else the legacy in-tree location — the
|
|
253
|
+
# same preference, spelled the same way, as the copied hook script's `main()`.
|
|
254
|
+
# `or`, not a presence test: an exported-but-empty value names the launch cwd.
|
|
255
|
+
# The no-op detector above stays RUN_DIR + TASK_ID for the reason the hook's
|
|
256
|
+
# docstring gives: an older orchestrator sets neither the new variable nor any
|
|
257
|
+
# expectation that this relay needs it, and its sessions must still complete.
|
|
258
|
+
events_dir = os.environ.get("FROID_LOOP_EVENTS_DIR") or os.path.join(run_dir, "events")
|
|
259
|
+
try:
|
|
260
|
+
_write_event(events_dir, event_file_name(ts, task_id, event_name), event)
|
|
261
|
+
except OSError:
|
|
262
|
+
# A hostile or broken events dir must degrade to the orchestrator's normal
|
|
263
|
+
# session_timeout_min path, never surface as a hook failure that fails the
|
|
264
|
+
# CLI window (mirrors the hook script's own write wrap).
|
|
265
|
+
return 0
|
|
266
|
+
return 0
|