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/resolve.py
ADDED
|
@@ -0,0 +1,283 @@
|
|
|
1
|
+
"""Interactive escalation resolution.
|
|
2
|
+
|
|
3
|
+
When a run pauses on a CRITICAL escalation the agent that raised it is already
|
|
4
|
+
gone (its tmux window was killed on completion), so there is nothing to talk
|
|
5
|
+
to. This module instead launches a *fresh* interactive agent — the
|
|
6
|
+
`froid-loop-resolve` skill — attached to the caller's terminal, seeded with the
|
|
7
|
+
escalation detail and the frozen spec. The human and the agent disambiguate the
|
|
8
|
+
spec; the agent writes a `resolution.json` marker. The caller (cli.cmd_resolve)
|
|
9
|
+
then re-arms the story (runs.rearm_escalation) and resumes the run.
|
|
10
|
+
|
|
11
|
+
The orchestrator never parses the conversation: the durable output is the
|
|
12
|
+
edited frozen spec on disk plus the resolution marker.
|
|
13
|
+
"""
|
|
14
|
+
|
|
15
|
+
from __future__ import annotations
|
|
16
|
+
|
|
17
|
+
import json
|
|
18
|
+
import os
|
|
19
|
+
import subprocess
|
|
20
|
+
from pathlib import Path
|
|
21
|
+
from typing import Any
|
|
22
|
+
|
|
23
|
+
from .adapters.base import SessionSpec
|
|
24
|
+
from .model import RunState
|
|
25
|
+
from .platform_util import safe_segment
|
|
26
|
+
from .runs import (
|
|
27
|
+
redrive_base_ref,
|
|
28
|
+
spec_reaches_the_redrive,
|
|
29
|
+
task_spec_path,
|
|
30
|
+
task_stories_root,
|
|
31
|
+
validate_restore_latch,
|
|
32
|
+
)
|
|
33
|
+
|
|
34
|
+
RESOLVE_DIR = "resolve"
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def _story_dir(run_dir: Path, story_key: str) -> Path:
|
|
38
|
+
return run_dir / RESOLVE_DIR / safe_segment(story_key)
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def context_path(run_dir: Path, story_key: str) -> Path:
|
|
42
|
+
return _story_dir(run_dir, story_key) / "context.json"
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def resolution_path(run_dir: Path, story_key: str) -> Path:
|
|
46
|
+
return _story_dir(run_dir, story_key) / "resolution.json"
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
class ResolutionError(Exception):
|
|
50
|
+
"""resolution.json exists but cannot be used (unreadable / not a JSON object)."""
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def read_resolution(run_dir: Path, story_key: str) -> dict[str, Any] | None:
|
|
54
|
+
"""Parse the resolve agent's ``resolution.json`` marker. Returns None ONLY
|
|
55
|
+
when the marker is absent; a present-but-unusable marker (unreadable, bad
|
|
56
|
+
JSON, non-object top level) raises ResolutionError instead. The file is the
|
|
57
|
+
agent's recorded decision — possibly including a ``restore_patch`` (the
|
|
58
|
+
intent-gap patch-restore path, Froid Plane #2564) — and a re-arm consumes
|
|
59
|
+
the escalation, so collapsing corruption into "nothing recorded" would
|
|
60
|
+
silently downgrade a confirmed restore to an unrecoverable from-scratch
|
|
61
|
+
re-drive. The caller validates the ``restore_patch`` path itself before
|
|
62
|
+
acting on it."""
|
|
63
|
+
path = resolution_path(run_dir, story_key)
|
|
64
|
+
if not path.is_file():
|
|
65
|
+
return None
|
|
66
|
+
try:
|
|
67
|
+
doc = json.loads(path.read_text(encoding="utf-8"))
|
|
68
|
+
except (OSError, UnicodeDecodeError, json.JSONDecodeError) as e:
|
|
69
|
+
# UnicodeDecodeError is a ValueError, not an OSError — a non-UTF-8 marker
|
|
70
|
+
# must raise the clean ResolutionError, not crash (same class as the
|
|
71
|
+
# read_frontmatter / stories read-path hardening).
|
|
72
|
+
raise ResolutionError(f"resolution marker {path} is unreadable ({e})") from e
|
|
73
|
+
if not isinstance(doc, dict):
|
|
74
|
+
raise ResolutionError(f"resolution marker {path} is not a JSON object")
|
|
75
|
+
return doc
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
def _gather_escalations(run_dir: Path, state: RunState, story_key: str) -> list[dict[str, Any]]:
|
|
79
|
+
"""The CRITICAL escalations recorded by this story's sessions, newest first.
|
|
80
|
+
|
|
81
|
+
Reads each session's tasks/<task_id>/result.json (and escalation.json) — the
|
|
82
|
+
same files the engine inspected when it decided to pause."""
|
|
83
|
+
task = state.tasks.get(story_key)
|
|
84
|
+
found: list[dict[str, Any]] = []
|
|
85
|
+
if task is None:
|
|
86
|
+
return found
|
|
87
|
+
for session in reversed(task.sessions):
|
|
88
|
+
task_dir = run_dir / "tasks" / session.task_id
|
|
89
|
+
for fname in ("result.json", "escalation.json"):
|
|
90
|
+
fpath = task_dir / fname
|
|
91
|
+
if not fpath.is_file():
|
|
92
|
+
continue
|
|
93
|
+
try:
|
|
94
|
+
doc = json.loads(fpath.read_text(encoding="utf-8"))
|
|
95
|
+
except (OSError, json.JSONDecodeError):
|
|
96
|
+
continue
|
|
97
|
+
for esc in doc.get("escalations", []) if isinstance(doc, dict) else []:
|
|
98
|
+
if isinstance(esc, dict) and str(esc.get("severity", "")).upper() == "CRITICAL":
|
|
99
|
+
found.append(esc)
|
|
100
|
+
return found
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
def build_context(state: RunState, run_dir: Path, story_key: str, *, isolation: str) -> Path:
|
|
104
|
+
"""Write resolve/<story_key>/context.json for the resolve skill to read.
|
|
105
|
+
|
|
106
|
+
`isolation` is the LIVE policy's `scm.isolation`, and it is required rather than
|
|
107
|
+
defaulted for the reason this surface exists at all: three of the fields below —
|
|
108
|
+
`restore_supported`, `spec_reaches_the_redrive`, `redrive_base_ref` — are claims
|
|
109
|
+
about a re-drive that has NOT happened yet, and run state cannot answer them. The
|
|
110
|
+
mode is re-read at every resume and a mid-run change is journalled, never refused,
|
|
111
|
+
so the recorded `task.worktree_path` says only how the escalated attempt RAN. A
|
|
112
|
+
defaulted mode would hand the agent an in-place answer for a run that mounts (or the
|
|
113
|
+
reverse) with nothing to signal it — the defect being fixed, re-introduced at the
|
|
114
|
+
seam that reports it."""
|
|
115
|
+
task = state.tasks.get(story_key)
|
|
116
|
+
isolated_redrive = isolation == "worktree"
|
|
117
|
+
# Patch-restore availability (#2564): the shared `validate_restore_latch`
|
|
118
|
+
# verdict, not a local copy of one leg. Any of them — worktree isolation (the
|
|
119
|
+
# re-drive discards and re-mounts the unit's worktree), a spec-less escalation,
|
|
120
|
+
# a pre-planning sentinel wedge — means the orchestrator would reject a
|
|
121
|
+
# `restore_patch` after the session; told to the agent up front so it never
|
|
122
|
+
# negotiates a restore it can't honor.
|
|
123
|
+
restore_supported = task is not None and (
|
|
124
|
+
validate_restore_latch(state, task, story_key, worktree_isolation=isolated_redrive) is None
|
|
125
|
+
)
|
|
126
|
+
# Which tree holds this run's STORY MANIFEST — the workspace root, answered by
|
|
127
|
+
# `task_stories_root` rather than by `task_spec_root`. The latter answers a
|
|
128
|
+
# write-confinement question about `spec_file` and falls back to the project for an
|
|
129
|
+
# out-of-mount spec; borrowing it here pointed the sentinel and the stories block at
|
|
130
|
+
# the main checkout while `stories_engine._stories_folder` was still the mount, so
|
|
131
|
+
# one `context.json` could name two trees.
|
|
132
|
+
stories_root = task_stories_root(task, state)
|
|
133
|
+
context = {
|
|
134
|
+
"story_key": story_key,
|
|
135
|
+
"run_id": state.run_id,
|
|
136
|
+
# Absolute, matching the shape `froid-loop-resolve/SKILL.md` documents: an
|
|
137
|
+
# isolated unit's `spec_file` is persisted RELATIVE to the mounted worktree
|
|
138
|
+
# (`model.StoryTask._serialized_worktree_path`) and the agent session runs
|
|
139
|
+
# from the project root, where the main checkout carries the same
|
|
140
|
+
# `_froid-output/specs/...` layout — the raw value would name the wrong
|
|
141
|
+
# tree's copy. `task_spec_path` is the same re-anchor `rearm_escalation`
|
|
142
|
+
# writes through, so the agent edits the file the re-arm will flip.
|
|
143
|
+
# as_posix() for the same reason `resolution_path` below uses it — the
|
|
144
|
+
# context contract is one string on every OS — and because the value this
|
|
145
|
+
# replaces was ALREADY posix under isolation: `_serialized_worktree_path`
|
|
146
|
+
# persists the relative form with `.as_posix()`. It also normalizes the
|
|
147
|
+
# NON-isolated absolute case, which was previously emitted verbatim: on Windows
|
|
148
|
+
# that changes `C:\\...\\spec.md` to `C:/.../spec.md`. Deliberate — one
|
|
149
|
+
# spelling for every reader — and consumed by an agent, which accepts '/'.
|
|
150
|
+
"spec_file": (task_spec_path(task, state).as_posix() if task and task.spec_file else None),
|
|
151
|
+
"baseline_commit": task.baseline_commit if task else None,
|
|
152
|
+
"paused_reason": state.paused_reason,
|
|
153
|
+
"escalations": _gather_escalations(run_dir, state, story_key),
|
|
154
|
+
# as_posix so the context contract is the same string on every OS (the
|
|
155
|
+
# path is consumed by the agent, and Python/tools accept '/' on Windows).
|
|
156
|
+
"resolution_path": resolution_path(run_dir, story_key).as_posix(),
|
|
157
|
+
"restore_supported": restore_supported,
|
|
158
|
+
# Whether an edit to `spec_file` survives to the re-drive that reads it. Under
|
|
159
|
+
# isolation the mount is discarded by `engine._finish_inflight`, so the agent
|
|
160
|
+
# can otherwise spend a whole session editing a file with no future and see
|
|
161
|
+
# every write succeed. `rearm_escalation` already journals
|
|
162
|
+
# `rearm-spec-write-unreachable` on this same verdict; naming it here is what
|
|
163
|
+
# lets the session act on it instead of learning it afterwards.
|
|
164
|
+
# Guarded on `task.spec_file` exactly as `spec_file` above is: a verdict about
|
|
165
|
+
# whether an edit SURVIVES is meaningless beside a `"spec_file": null`, and
|
|
166
|
+
# emitting one invited the session to act on a reachability answer for a file
|
|
167
|
+
# the same document says does not exist. Both fields are one claim.
|
|
168
|
+
"spec_reaches_the_redrive": (
|
|
169
|
+
spec_reaches_the_redrive(task, state, isolated_redrive=isolated_redrive)
|
|
170
|
+
if task and task.spec_file
|
|
171
|
+
else None
|
|
172
|
+
),
|
|
173
|
+
# WHERE a correction has to land to be read. Emitted beside the verdict
|
|
174
|
+
# because on its own `spec_reaches_the_redrive: false` states a problem with
|
|
175
|
+
# no remedy: the session is told the edit is doomed, and the obvious repair
|
|
176
|
+
# (commit it) is wrong in two different ways for an isolated unit. Committing
|
|
177
|
+
# from the main checkout cannot include a file that lives in the linked unit
|
|
178
|
+
# worktree, and committing on the unit's own branch does not put it on the ref
|
|
179
|
+
# the replacement worktree is cut from. That ref is this one — the run's
|
|
180
|
+
# PINNED `target_branch` when the re-drive will MOUNT, `HEAD` otherwise — and
|
|
181
|
+
# it is the same value `rearm_escalation`'s unreachable-write record names, so
|
|
182
|
+
# the session and the orchestrator quote one answer.
|
|
183
|
+
#
|
|
184
|
+
# Keyed on the LIVE isolation mode, not on the recorded mount. This file is
|
|
185
|
+
# written by a SEPARATE process, before the resume runs, so it is the one
|
|
186
|
+
# consumer no amount of resume-time bookkeeping on `task.worktree_path` could
|
|
187
|
+
# reach: reading the mount here sent the session to commit on the pinned branch
|
|
188
|
+
# for a run whose policy had since flipped to `none`, where the re-drive reads
|
|
189
|
+
# `HEAD` in the main checkout and never looks — and answered `HEAD` for the
|
|
190
|
+
# mirror flip, where it mounts and never reads a working tree at all.
|
|
191
|
+
"redrive_base_ref": (
|
|
192
|
+
redrive_base_ref(state, isolated_redrive=isolated_redrive) if task else None
|
|
193
|
+
),
|
|
194
|
+
}
|
|
195
|
+
# Stories mode: hand the resolver the manifest intent (the story entry) and a
|
|
196
|
+
# sentinel indicator, so it sees WHAT the story is meant to do and WHETHER the
|
|
197
|
+
# frozen spec even exists yet (a sentinel has no plan to edit — resolve the
|
|
198
|
+
# underlying ambiguity instead). Sprint mode leaves the context unchanged.
|
|
199
|
+
if state.source == "stories":
|
|
200
|
+
stories_ctx = _stories_context(state, story_key, stories_root)
|
|
201
|
+
if stories_ctx:
|
|
202
|
+
context["stories"] = stories_ctx
|
|
203
|
+
path = context_path(run_dir, story_key)
|
|
204
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
205
|
+
path.write_text(json.dumps(context, indent=2), encoding="utf-8")
|
|
206
|
+
return path
|
|
207
|
+
|
|
208
|
+
|
|
209
|
+
def _stories_context(state: RunState, story_key: str, root: Path) -> dict[str, Any]:
|
|
210
|
+
"""The stories-mode extension of the resolve context: the spec folder, the
|
|
211
|
+
manifest entry for the story (title/description/checkpoint flags/invoke_dev_with),
|
|
212
|
+
and — when the escalated spec is a fixed-slug pre-planning-halt sentinel — a
|
|
213
|
+
sentinel indicator with its kind and recorded blocking condition. Best-effort:
|
|
214
|
+
an unreadable manifest just yields the folder (resolve still runs)."""
|
|
215
|
+
from . import stories
|
|
216
|
+
|
|
217
|
+
# `root`, not `Path(state.project)`: the caller resolved it with
|
|
218
|
+
# `task_stories_root`, so this block reads the manifest and sentinel out of the tree
|
|
219
|
+
# the RUN owns. One `context.json` that names two trees is worse than one that names
|
|
220
|
+
# the wrong one — `sentinel.path` and `blocking_condition` would otherwise describe a
|
|
221
|
+
# file the re-arm will never touch, or vanish entirely because the main checkout has
|
|
222
|
+
# no sentinel while the mount does.
|
|
223
|
+
folder = stories.resolve_spec_folder(root, state.spec_folder)
|
|
224
|
+
ctx: dict[str, Any] = {"spec_folder": state.spec_folder}
|
|
225
|
+
try:
|
|
226
|
+
entry = stories.load_stories(folder).get(story_key)
|
|
227
|
+
except (stories.StoriesError, OSError, UnicodeDecodeError):
|
|
228
|
+
entry = None
|
|
229
|
+
if entry is not None:
|
|
230
|
+
ctx["story"] = {
|
|
231
|
+
"id": entry.id,
|
|
232
|
+
"title": entry.title,
|
|
233
|
+
"description": entry.description,
|
|
234
|
+
"spec_checkpoint": entry.spec_checkpoint,
|
|
235
|
+
"done_checkpoint": entry.done_checkpoint,
|
|
236
|
+
"invoke_dev_with": entry.invoke_dev_with,
|
|
237
|
+
}
|
|
238
|
+
try:
|
|
239
|
+
st = stories.resolve_story_spec(folder, story_key)
|
|
240
|
+
except (OSError, UnicodeDecodeError):
|
|
241
|
+
st = None
|
|
242
|
+
if st is not None and st.kind == stories.KIND_SENTINEL and st.path is not None:
|
|
243
|
+
try:
|
|
244
|
+
condition = stories.recorded_blocking_condition(st.path.read_text(encoding="utf-8"))
|
|
245
|
+
except (OSError, UnicodeDecodeError):
|
|
246
|
+
condition = ""
|
|
247
|
+
ctx["sentinel"] = {
|
|
248
|
+
"kind": st.sentinel_kind,
|
|
249
|
+
"path": st.path.as_posix(),
|
|
250
|
+
"blocking_condition": condition,
|
|
251
|
+
}
|
|
252
|
+
return ctx
|
|
253
|
+
|
|
254
|
+
|
|
255
|
+
def run_session(adapter, project: Path, run_dir: Path, story_key: str, *, model: str = "") -> bool:
|
|
256
|
+
"""Launch the interactive resolve agent attached to the caller's terminal.
|
|
257
|
+
|
|
258
|
+
Blocks until the agent session exits. Returns whether the agent produced a
|
|
259
|
+
resolution marker. The context file must already be written (build_context).
|
|
260
|
+
"""
|
|
261
|
+
spec = SessionSpec(
|
|
262
|
+
task_id=f"{safe_segment(story_key)}-resolve-1",
|
|
263
|
+
role="dev",
|
|
264
|
+
prompt=f"/froid-loop-resolve {story_key}",
|
|
265
|
+
cwd=project,
|
|
266
|
+
env={
|
|
267
|
+
# deliberately NOT FROID_LOOP_MODE: this session is interactive, a
|
|
268
|
+
# human is present, the skill must be allowed to ask.
|
|
269
|
+
"FROID_LOOP_RUN_DIR": str(run_dir),
|
|
270
|
+
"FROID_LOOP_STORY_KEY": story_key,
|
|
271
|
+
"FROID_LOOP_RESOLVE_CONTEXT": str(context_path(run_dir, story_key)),
|
|
272
|
+
},
|
|
273
|
+
model=model,
|
|
274
|
+
)
|
|
275
|
+
# Drop any marker from a previous resolve of this story: otherwise the agent
|
|
276
|
+
# sees it and reports "already resolved", and a session that records nothing
|
|
277
|
+
# would still look like it produced a resolution.
|
|
278
|
+
marker = resolution_path(run_dir, story_key)
|
|
279
|
+
marker.unlink(missing_ok=True)
|
|
280
|
+
argv = adapter.interactive_argv(spec)
|
|
281
|
+
env = {**os.environ, **adapter.interactive_env(spec)}
|
|
282
|
+
subprocess.run(argv, cwd=str(project), env=env) # attached, inherited stdio
|
|
283
|
+
return marker.is_file()
|