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/sweep.py
ADDED
|
@@ -0,0 +1,1892 @@
|
|
|
1
|
+
"""Deferred-work sweep: triage the ledger, decide, execute bundles.
|
|
2
|
+
|
|
3
|
+
A sweep is its own run type. One LLM triage session classifies every open
|
|
4
|
+
deferred-work entry (verified against actual code — ledger statuses are
|
|
5
|
+
unreliable); the orchestrator validates the result deterministically, asks
|
|
6
|
+
the human about decision items (interactive runs only), then drives each
|
|
7
|
+
work bundle through the inherited dev -> review -> verify -> commit pipeline.
|
|
8
|
+
The orchestrator performs all ledger edits it can do deterministically and
|
|
9
|
+
gates on the ones it delegates (verify.verify_review_bundle).
|
|
10
|
+
"""
|
|
11
|
+
|
|
12
|
+
from __future__ import annotations
|
|
13
|
+
|
|
14
|
+
import json
|
|
15
|
+
import re
|
|
16
|
+
from dataclasses import dataclass
|
|
17
|
+
from pathlib import Path
|
|
18
|
+
from typing import Any, Callable, Iterable
|
|
19
|
+
|
|
20
|
+
from . import deferredwork, gates, verify
|
|
21
|
+
from .engine import Engine, RunPaused, _ArmedClose, _LedgerAnchor
|
|
22
|
+
from .escalation import critical_escalations, env_fault_pause_reason, session_failure_reason
|
|
23
|
+
from .model import PAUSE_STORY_GATE, Phase, StoryTask
|
|
24
|
+
from .platform_util import (
|
|
25
|
+
atomic_write_text,
|
|
26
|
+
atomic_write_text_confined,
|
|
27
|
+
neutralize_surrogates,
|
|
28
|
+
safe_segment,
|
|
29
|
+
)
|
|
30
|
+
from .runs import _project_of_run_dir
|
|
31
|
+
from .statemachine import advance
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def _read_json(path: Path) -> Any:
|
|
35
|
+
return json.loads(path.read_text(encoding="utf-8"))
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
TRIAGE_KEY = "sweep-triage"
|
|
39
|
+
TRIAGE_WORKFLOW = "deferred-sweep-triage"
|
|
40
|
+
MIGRATE_KEY = "sweep-migrate"
|
|
41
|
+
MIGRATE_WORKFLOW = "deferred-sweep-migrate"
|
|
42
|
+
BUNDLE_NAME_RE = re.compile(r"^[a-z0-9][a-z0-9-]{1,39}\Z")
|
|
43
|
+
_BUNDLE_NAME_MAX_LENGTH = 40
|
|
44
|
+
_BUNDLE_NAME_INITIAL_CHARS = frozenset("abcdefghijklmnopqrstuvwxyz0123456789")
|
|
45
|
+
_BUNDLE_NAME_SAFE_CHARS = frozenset("abcdefghijklmnopqrstuvwxyz0123456789-")
|
|
46
|
+
# the inverse of SweepEngine._bundle_key: "dw-<name>" (cycle 1) / "dw<N>-<name>".
|
|
47
|
+
# A cycle-1 key always has "-" straight after "dw", so the cycle group matches
|
|
48
|
+
# empty and the split stays unambiguous even for a bundle named "2fix".
|
|
49
|
+
BUNDLE_KEY_RE = re.compile(r"^dw(\d*)-(.+)\Z")
|
|
50
|
+
DECISION_EFFECTS = ("build", "close", "keep-open")
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
@dataclass(frozen=True)
|
|
54
|
+
class _BundleNameRepair:
|
|
55
|
+
field: str
|
|
56
|
+
original: str
|
|
57
|
+
normalized: str
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def _normalize_bundle_names(rj: dict[str, Any] | None) -> tuple[_BundleNameRepair, ...]:
|
|
61
|
+
"""Truncate overlong bundle-name fields only when their shape is already safe."""
|
|
62
|
+
if rj is None:
|
|
63
|
+
return ()
|
|
64
|
+
|
|
65
|
+
repairs: list[_BundleNameRepair] = []
|
|
66
|
+
|
|
67
|
+
def normalize(container: dict[str, Any], key: str, field: str) -> None:
|
|
68
|
+
raw = container.get(key)
|
|
69
|
+
if not isinstance(raw, str) or len(raw) <= _BUNDLE_NAME_MAX_LENGTH:
|
|
70
|
+
return
|
|
71
|
+
if raw[0] not in _BUNDLE_NAME_INITIAL_CHARS or any(
|
|
72
|
+
char not in _BUNDLE_NAME_SAFE_CHARS for char in raw[1:]
|
|
73
|
+
):
|
|
74
|
+
return
|
|
75
|
+
normalized = raw[:_BUNDLE_NAME_MAX_LENGTH]
|
|
76
|
+
container[key] = normalized
|
|
77
|
+
repairs.append(_BundleNameRepair(field, raw, normalized))
|
|
78
|
+
|
|
79
|
+
bundles = rj.get("bundles", [])
|
|
80
|
+
if isinstance(bundles, list):
|
|
81
|
+
for bundle_index, bundle in enumerate(bundles):
|
|
82
|
+
if isinstance(bundle, dict):
|
|
83
|
+
normalize(bundle, "name", f"bundles[{bundle_index}].name")
|
|
84
|
+
|
|
85
|
+
decisions = rj.get("decisions", [])
|
|
86
|
+
if isinstance(decisions, list):
|
|
87
|
+
for decision_index, decision in enumerate(decisions):
|
|
88
|
+
if not isinstance(decision, dict):
|
|
89
|
+
continue
|
|
90
|
+
options = decision.get("options", [])
|
|
91
|
+
if not isinstance(options, list):
|
|
92
|
+
continue
|
|
93
|
+
for option_index, option in enumerate(options):
|
|
94
|
+
if isinstance(option, dict):
|
|
95
|
+
normalize(
|
|
96
|
+
option,
|
|
97
|
+
"bundle_name",
|
|
98
|
+
f"decisions[{decision_index}].options[{option_index}].bundle_name",
|
|
99
|
+
)
|
|
100
|
+
return tuple(repairs)
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
# ------------------------------------------------------------- triage plan
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
@dataclass(frozen=True)
|
|
107
|
+
class ResolvedEntry:
|
|
108
|
+
id: str
|
|
109
|
+
evidence: str
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
@dataclass(frozen=True)
|
|
113
|
+
class Bundle:
|
|
114
|
+
name: str
|
|
115
|
+
dw_ids: tuple[str, ...]
|
|
116
|
+
intent: str
|
|
117
|
+
decision_note: str = "" # human-decision context appended to the intent file
|
|
118
|
+
|
|
119
|
+
|
|
120
|
+
@dataclass(frozen=True)
|
|
121
|
+
class DecisionOption:
|
|
122
|
+
key: str
|
|
123
|
+
label: str
|
|
124
|
+
effect: str # build | close | keep-open
|
|
125
|
+
intent: str = "" # required when effect == "build"
|
|
126
|
+
resolution: str = "" # optional when effect == "close"
|
|
127
|
+
bundle_name: str = "" # optional name override for the built bundle
|
|
128
|
+
|
|
129
|
+
|
|
130
|
+
@dataclass(frozen=True)
|
|
131
|
+
class Decision:
|
|
132
|
+
id: str
|
|
133
|
+
question: str
|
|
134
|
+
context: str
|
|
135
|
+
options: tuple[DecisionOption, ...]
|
|
136
|
+
recommendation: str
|
|
137
|
+
|
|
138
|
+
def option(self, key: str) -> DecisionOption | None:
|
|
139
|
+
for opt in self.options:
|
|
140
|
+
if opt.key == key:
|
|
141
|
+
return opt
|
|
142
|
+
return None
|
|
143
|
+
|
|
144
|
+
|
|
145
|
+
@dataclass(frozen=True)
|
|
146
|
+
class TriagePlan:
|
|
147
|
+
open_ids: frozenset[str]
|
|
148
|
+
already_resolved: tuple[ResolvedEntry, ...] = ()
|
|
149
|
+
bundles: tuple[Bundle, ...] = ()
|
|
150
|
+
blocked: tuple[tuple[str, str], ...] = () # (id, blocker)
|
|
151
|
+
skip: tuple[tuple[str, str], ...] = () # (id, reason)
|
|
152
|
+
decisions: tuple[Decision, ...] = ()
|
|
153
|
+
|
|
154
|
+
|
|
155
|
+
def validate_triage(
|
|
156
|
+
rj: dict[str, Any] | None, expected_open_ids: set[str] | None
|
|
157
|
+
) -> tuple[TriagePlan | None, list[str]]:
|
|
158
|
+
"""Deterministic validation of the triage session's result.json. Returns
|
|
159
|
+
(plan, []) or (None, errors). expected_open_ids=None skips the ledger
|
|
160
|
+
equality check (used when reloading a previously validated plan)."""
|
|
161
|
+
errors: list[str] = []
|
|
162
|
+
rj = rj or {}
|
|
163
|
+
_normalize_bundle_names(rj)
|
|
164
|
+
if rj.get("workflow") != TRIAGE_WORKFLOW:
|
|
165
|
+
return None, [f"workflow must be {TRIAGE_WORKFLOW!r}: got {rj.get('workflow')!r}"]
|
|
166
|
+
|
|
167
|
+
claimed_open = {str(i) for i in rj.get("open_ids", [])}
|
|
168
|
+
if expected_open_ids is not None and claimed_open != expected_open_ids:
|
|
169
|
+
missed = sorted(expected_open_ids - claimed_open)
|
|
170
|
+
invented = sorted(claimed_open - expected_open_ids)
|
|
171
|
+
return None, [
|
|
172
|
+
"open_ids do not match the ledger's open entries"
|
|
173
|
+
+ (f"; missing: {', '.join(missed)}" if missed else "")
|
|
174
|
+
+ (f"; not open in the ledger: {', '.join(invented)}" if invented else "")
|
|
175
|
+
]
|
|
176
|
+
universe = expected_open_ids if expected_open_ids is not None else claimed_open
|
|
177
|
+
|
|
178
|
+
seen: dict[str, str] = {} # id -> category that claimed it
|
|
179
|
+
|
|
180
|
+
def claim(dw_id: str, category: str) -> None:
|
|
181
|
+
if dw_id not in universe:
|
|
182
|
+
errors.append(f"{category} references unknown/closed id {dw_id}")
|
|
183
|
+
elif dw_id in seen:
|
|
184
|
+
errors.append(f"{dw_id} appears in both {seen[dw_id]} and {category}")
|
|
185
|
+
else:
|
|
186
|
+
seen[dw_id] = category
|
|
187
|
+
|
|
188
|
+
resolved = []
|
|
189
|
+
for item in rj.get("already_resolved", []):
|
|
190
|
+
dw_id = str(item.get("id", ""))
|
|
191
|
+
evidence = str(item.get("evidence", "")).strip()
|
|
192
|
+
claim(dw_id, "already_resolved")
|
|
193
|
+
if not evidence:
|
|
194
|
+
errors.append(f"already_resolved {dw_id} has no evidence")
|
|
195
|
+
resolved.append(ResolvedEntry(dw_id, evidence))
|
|
196
|
+
|
|
197
|
+
bundles = []
|
|
198
|
+
names: set[str] = set()
|
|
199
|
+
for item in rj.get("bundles", []):
|
|
200
|
+
name = str(item.get("name", ""))
|
|
201
|
+
if not BUNDLE_NAME_RE.match(name):
|
|
202
|
+
errors.append(f"bundle name {name!r} invalid (want {BUNDLE_NAME_RE.pattern})")
|
|
203
|
+
# The one rule BUNDLE_NAME_RE cannot express. A cycle-1 bundle's name IS its
|
|
204
|
+
# directory (`_write_intent`), and the reserved Windows device basenames --
|
|
205
|
+
# CON, NUL, AUX, PRN, COM<N>, LPT<N> -- are `[a-z0-9-]`-legal names that no
|
|
206
|
+
# Windows filesystem will accept as one (matched case-insensitively, so
|
|
207
|
+
# lowercase is no reprieve). Testing `safe_segment` identity rather than a
|
|
208
|
+
# hand-written device list keeps this gate in lockstep with the sanitizer
|
|
209
|
+
# that defines the set: the identical idiom, for the identical reason, as
|
|
210
|
+
# `runs.is_valid_run_id`. Guarded on the match above so one bad name yields
|
|
211
|
+
# one error and not two.
|
|
212
|
+
if BUNDLE_NAME_RE.match(name) and safe_segment(name) != name:
|
|
213
|
+
errors.append(f"bundle name {name!r} is not a legal path segment")
|
|
214
|
+
if name in names:
|
|
215
|
+
errors.append(f"duplicate bundle name {name!r}")
|
|
216
|
+
names.add(name)
|
|
217
|
+
dw_ids = [str(i) for i in item.get("dw_ids", [])]
|
|
218
|
+
if not dw_ids:
|
|
219
|
+
errors.append(f"bundle {name!r} has no dw_ids")
|
|
220
|
+
for dw_id in dw_ids:
|
|
221
|
+
claim(dw_id, f"bundle {name!r}")
|
|
222
|
+
intent = str(item.get("intent", "")).strip()
|
|
223
|
+
if not intent:
|
|
224
|
+
errors.append(f"bundle {name!r} has no intent")
|
|
225
|
+
bundles.append(Bundle(name, tuple(dw_ids), intent))
|
|
226
|
+
|
|
227
|
+
blocked = []
|
|
228
|
+
for item in rj.get("blocked", []):
|
|
229
|
+
dw_id = str(item.get("id", ""))
|
|
230
|
+
blocker = str(item.get("blocker", "")).strip()
|
|
231
|
+
claim(dw_id, "blocked")
|
|
232
|
+
if not blocker:
|
|
233
|
+
errors.append(f"blocked {dw_id} names no blocker")
|
|
234
|
+
blocked.append((dw_id, blocker))
|
|
235
|
+
|
|
236
|
+
skip = []
|
|
237
|
+
for item in rj.get("skip", []):
|
|
238
|
+
dw_id = str(item.get("id", ""))
|
|
239
|
+
reason = str(item.get("reason", "")).strip()
|
|
240
|
+
claim(dw_id, "skip")
|
|
241
|
+
if not reason:
|
|
242
|
+
errors.append(f"skip {dw_id} gives no reason")
|
|
243
|
+
skip.append((dw_id, reason))
|
|
244
|
+
|
|
245
|
+
decisions = []
|
|
246
|
+
for item in rj.get("decisions", []):
|
|
247
|
+
dw_id = str(item.get("id", ""))
|
|
248
|
+
claim(dw_id, "decisions")
|
|
249
|
+
question = str(item.get("question", "")).strip()
|
|
250
|
+
if not question:
|
|
251
|
+
errors.append(f"decision {dw_id} has no question")
|
|
252
|
+
options = []
|
|
253
|
+
keys: set[str] = set()
|
|
254
|
+
decision_bundle_names: set[str] = set()
|
|
255
|
+
for raw in item.get("options", []):
|
|
256
|
+
key = str(raw.get("key", ""))
|
|
257
|
+
effect = str(raw.get("effect", ""))
|
|
258
|
+
intent = str(raw.get("intent", "")).strip()
|
|
259
|
+
if not key or key in keys:
|
|
260
|
+
errors.append(f"decision {dw_id}: missing/duplicate option key {key!r}")
|
|
261
|
+
keys.add(key)
|
|
262
|
+
if effect not in DECISION_EFFECTS:
|
|
263
|
+
errors.append(f"decision {dw_id} option {key}: bad effect {effect!r}")
|
|
264
|
+
if effect == "build" and not intent:
|
|
265
|
+
errors.append(f"decision {dw_id} option {key}: effect 'build' needs intent")
|
|
266
|
+
bundle_name = str(raw.get("bundle_name", ""))
|
|
267
|
+
if bundle_name and not BUNDLE_NAME_RE.match(bundle_name):
|
|
268
|
+
errors.append(f"decision {dw_id} option {key}: bad bundle_name {bundle_name!r}")
|
|
269
|
+
# The second site that mints a bundle directory, gated for the reason
|
|
270
|
+
# stated at the `bundles` loop above. A build-effect option's
|
|
271
|
+
# `bundle_name` becomes `Bundle.name` in `_materialize_bundles`, so it
|
|
272
|
+
# reaches `_write_intent`'s cycle-1 directory by the identical path --
|
|
273
|
+
# `BUNDLE_NAME_RE` is no more able to express the rule here than there.
|
|
274
|
+
# Guarded on the match above so one bad name yields one error, and on
|
|
275
|
+
# nothing else: an absent `bundle_name` fails that match already.
|
|
276
|
+
if BUNDLE_NAME_RE.match(bundle_name) and safe_segment(bundle_name) != bundle_name:
|
|
277
|
+
errors.append(
|
|
278
|
+
f"decision {dw_id} option {key}: bundle_name {bundle_name!r} "
|
|
279
|
+
"is not a legal path segment"
|
|
280
|
+
)
|
|
281
|
+
if effect == "build" and bundle_name:
|
|
282
|
+
if bundle_name in names:
|
|
283
|
+
errors.append(f"duplicate bundle name {bundle_name!r}")
|
|
284
|
+
decision_bundle_names.add(bundle_name)
|
|
285
|
+
options.append(
|
|
286
|
+
DecisionOption(
|
|
287
|
+
key=key,
|
|
288
|
+
label=str(raw.get("label", "")).strip() or key,
|
|
289
|
+
effect=effect,
|
|
290
|
+
intent=intent,
|
|
291
|
+
resolution=str(raw.get("resolution", "")).strip(),
|
|
292
|
+
bundle_name=bundle_name,
|
|
293
|
+
)
|
|
294
|
+
)
|
|
295
|
+
names.update(decision_bundle_names)
|
|
296
|
+
if len(options) < 2:
|
|
297
|
+
errors.append(f"decision {dw_id} needs at least 2 options")
|
|
298
|
+
recommendation = str(item.get("recommendation", ""))
|
|
299
|
+
if recommendation not in keys:
|
|
300
|
+
errors.append(f"decision {dw_id}: recommendation {recommendation!r} not an option")
|
|
301
|
+
decisions.append(
|
|
302
|
+
Decision(
|
|
303
|
+
dw_id,
|
|
304
|
+
question,
|
|
305
|
+
str(item.get("context", "")).strip(),
|
|
306
|
+
tuple(options),
|
|
307
|
+
recommendation,
|
|
308
|
+
)
|
|
309
|
+
)
|
|
310
|
+
|
|
311
|
+
unclaimed = sorted(universe - set(seen))
|
|
312
|
+
if unclaimed:
|
|
313
|
+
errors.append(f"open entries not triaged: {', '.join(unclaimed)}")
|
|
314
|
+
|
|
315
|
+
if errors:
|
|
316
|
+
return None, errors
|
|
317
|
+
return (
|
|
318
|
+
TriagePlan(
|
|
319
|
+
open_ids=frozenset(universe),
|
|
320
|
+
already_resolved=tuple(resolved),
|
|
321
|
+
bundles=tuple(bundles),
|
|
322
|
+
blocked=tuple(blocked),
|
|
323
|
+
skip=tuple(skip),
|
|
324
|
+
decisions=tuple(decisions),
|
|
325
|
+
),
|
|
326
|
+
[],
|
|
327
|
+
)
|
|
328
|
+
|
|
329
|
+
|
|
330
|
+
# ---------------------------------------------------------- migration plan
|
|
331
|
+
|
|
332
|
+
|
|
333
|
+
@dataclass(frozen=True)
|
|
334
|
+
class PreCanonical:
|
|
335
|
+
"""What a pre-existing canonical entry is held to across a migration.
|
|
336
|
+
|
|
337
|
+
Status alone was the whole snapshot until #519, and that is what let a
|
|
338
|
+
rewrite drop a ``gate:`` line and pass: the gate is the one field whose loss
|
|
339
|
+
is both silent and unsafe. ``deferred-work-format.md`` calls removing it
|
|
340
|
+
"the exact failure this field exists to prevent", and
|
|
341
|
+
``Engine._refuse_gated_story`` then dispatches the story the entry was
|
|
342
|
+
holding back.
|
|
343
|
+
|
|
344
|
+
``gate_tokens`` unions :attr:`~froid_loop.deferredwork.EntryGates.tokens`
|
|
345
|
+
with ``malformed`` because the question is "did a token the entry declared
|
|
346
|
+
survive", not "was it enforceable". A malformed ``gate: 3.2`` gates nothing,
|
|
347
|
+
but it reads to anyone scanning the entry as a gate in force and ``validate``
|
|
348
|
+
reports it (``deferred.hard-gate-unstructured``); dropping it retires that
|
|
349
|
+
report silently, which is the same failure one level down.
|
|
350
|
+
|
|
351
|
+
The counts ``EntryGates`` also carries — ``lines``, ``empty``, ``near_miss``
|
|
352
|
+
— are deliberately NOT snapshotted. None of them names a story, so losing one
|
|
353
|
+
cannot change which story is gated, and they are exactly what a legitimate
|
|
354
|
+
reflow of a multi-line declaration moves.
|
|
355
|
+
"""
|
|
356
|
+
|
|
357
|
+
status: str
|
|
358
|
+
gate_tokens: tuple[str, ...]
|
|
359
|
+
|
|
360
|
+
|
|
361
|
+
def snapshot_canonical(text: str) -> dict[str, PreCanonical]:
|
|
362
|
+
"""The pre-migration state ``validate_migration`` holds the rewrite to.
|
|
363
|
+
|
|
364
|
+
A named function rather than a comprehension inlined at its one call site so
|
|
365
|
+
that the tests grade the snapshot production actually builds: a hand-written
|
|
366
|
+
``{"DW-1": PreCanonical("open", ("3-2",))}`` would pass whatever the parser
|
|
367
|
+
really produces for that entry, and the bug being fixed here lived in the
|
|
368
|
+
snapshot, not in the comparison.
|
|
369
|
+
|
|
370
|
+
Keying by id is safe only because ``_ensure_migration`` refuses a ledger
|
|
371
|
+
that carries duplicate canonical ids before any rewrite is attempted. Do
|
|
372
|
+
not soften that refusal into per-id collapse-hardening here: tokens and
|
|
373
|
+
status snapshotted independently describe an entry that never existed, and
|
|
374
|
+
each half patched on its own opens the next cross-product (a token
|
|
375
|
+
harvested from a ``done`` twin paired with an ``open`` twin's status both
|
|
376
|
+
refuses a faithful rewrite and newly gates a story that was not gated).
|
|
377
|
+
"""
|
|
378
|
+
snapshot: dict[str, PreCanonical] = {}
|
|
379
|
+
for e in deferredwork.parse_ledger(text):
|
|
380
|
+
g = deferredwork.gates(e)
|
|
381
|
+
snapshot[e.id] = PreCanonical(e.status, g.tokens + g.malformed)
|
|
382
|
+
return snapshot
|
|
383
|
+
|
|
384
|
+
|
|
385
|
+
def duplicate_ids(entries: Iterable[deferredwork.DWEntry]) -> list[str]:
|
|
386
|
+
"""The DW ids naming more than one entry, sorted.
|
|
387
|
+
|
|
388
|
+
One function for both sides of a migration on purpose: the rewrite is
|
|
389
|
+
refused when the ledger it STARTED from carries duplicates and when the
|
|
390
|
+
ledger it produced does, and two detectors that disagreed about what counts
|
|
391
|
+
as a duplicate would leave exactly the gap between them open.
|
|
392
|
+
"""
|
|
393
|
+
seen: set[str] = set()
|
|
394
|
+
dupes: set[str] = set()
|
|
395
|
+
for e in entries:
|
|
396
|
+
(dupes if e.id in seen else seen).add(e.id)
|
|
397
|
+
return sorted(dupes)
|
|
398
|
+
|
|
399
|
+
|
|
400
|
+
def validate_migration(
|
|
401
|
+
rj: dict[str, Any] | None,
|
|
402
|
+
manifest: list[dict[str, Any]],
|
|
403
|
+
pre_canonical: dict[str, PreCanonical],
|
|
404
|
+
new_text: str,
|
|
405
|
+
) -> list[str]:
|
|
406
|
+
"""Deterministic validation of a legacy-ledger migration session: the
|
|
407
|
+
rewritten ledger must contain zero legacy items, preserve every
|
|
408
|
+
pre-existing canonical entry's status and every ``gate:`` token it
|
|
409
|
+
declared, continue DW numbering, and the result.json mapping must cover
|
|
410
|
+
the manifest exactly. Returns errors, empty on success."""
|
|
411
|
+
rj = rj or {}
|
|
412
|
+
if rj.get("workflow") != MIGRATE_WORKFLOW:
|
|
413
|
+
return [f"workflow must be {MIGRATE_WORKFLOW!r}: got {rj.get('workflow')!r}"]
|
|
414
|
+
errors: list[str] = []
|
|
415
|
+
|
|
416
|
+
leftovers = deferredwork.parse_legacy(new_text)
|
|
417
|
+
if leftovers:
|
|
418
|
+
listed = "; ".join(f"{e.section or 'top level'}: {e.title[:60]}" for e in leftovers[:10])
|
|
419
|
+
errors.append(f"{len(leftovers)} legacy item(s) still parse as legacy: {listed}")
|
|
420
|
+
|
|
421
|
+
parsed = deferredwork.parse_ledger(new_text)
|
|
422
|
+
entries: dict[str, deferredwork.DWEntry] = {e.id: e for e in parsed}
|
|
423
|
+
dupes = duplicate_ids(parsed)
|
|
424
|
+
if dupes:
|
|
425
|
+
errors.append("duplicate DW ids: " + ", ".join(dupes))
|
|
426
|
+
|
|
427
|
+
def first_word(status: str) -> str:
|
|
428
|
+
return status.split()[0] if status.split() else ""
|
|
429
|
+
|
|
430
|
+
pre_max = max((int(i.split("-")[1]) for i in pre_canonical), default=0)
|
|
431
|
+
for dw_id, pre in pre_canonical.items():
|
|
432
|
+
e = entries.get(dw_id)
|
|
433
|
+
if e is None:
|
|
434
|
+
errors.append(f"pre-existing {dw_id} disappeared")
|
|
435
|
+
continue
|
|
436
|
+
if first_word(e.status) != first_word(pre.status):
|
|
437
|
+
errors.append(f"pre-existing {dw_id} status changed: {pre.status!r} -> {e.status!r}")
|
|
438
|
+
# Drops and edits only; an ADDED token is deliberately accepted. The two
|
|
439
|
+
# directions are not the same failure: a dropped token un-gates a story
|
|
440
|
+
# silently, which is what #519 is about, while an added one over-blocks
|
|
441
|
+
# loudly and in the safe direction — the operator meets a refusal naming
|
|
442
|
+
# the entry. Refusing an addition would spend one of the two migration
|
|
443
|
+
# attempts on the only direction that cannot cause the failure this
|
|
444
|
+
# guard exists to stop. An EDITED token is caught here anyway: an edit
|
|
445
|
+
# is a drop plus an add, and the drop half is what this reads.
|
|
446
|
+
post = deferredwork.gates(e)
|
|
447
|
+
kept = set(post.tokens) | set(post.malformed)
|
|
448
|
+
lost = [t for t in pre.gate_tokens if t not in kept]
|
|
449
|
+
if lost:
|
|
450
|
+
errors.append(f"pre-existing {dw_id} lost gate token(s): {', '.join(lost)}")
|
|
451
|
+
for dw_id, e in entries.items():
|
|
452
|
+
if dw_id in pre_canonical:
|
|
453
|
+
continue
|
|
454
|
+
if int(dw_id.split("-")[1]) <= pre_max:
|
|
455
|
+
errors.append(f"new entry {dw_id} does not continue numbering past DW-{pre_max}")
|
|
456
|
+
if first_word(e.status) not in ("open", "done"):
|
|
457
|
+
errors.append(f"new entry {dw_id} has status {e.status!r}; want open or done")
|
|
458
|
+
|
|
459
|
+
manifest_by_key = {str(m["key"]): m for m in manifest}
|
|
460
|
+
mapping = rj.get("mapping", [])
|
|
461
|
+
if not isinstance(mapping, list):
|
|
462
|
+
return errors + ["mapping must be a list of {key, dw_id}"]
|
|
463
|
+
seen_keys: set[str] = set()
|
|
464
|
+
for item in mapping:
|
|
465
|
+
key = str(item.get("key", "")) if isinstance(item, dict) else ""
|
|
466
|
+
dw_id = str(item.get("dw_id", "")) if isinstance(item, dict) else ""
|
|
467
|
+
source = manifest_by_key.get(key)
|
|
468
|
+
if source is None:
|
|
469
|
+
errors.append(f"mapping invents unknown key {key!r}")
|
|
470
|
+
continue
|
|
471
|
+
if key in seen_keys:
|
|
472
|
+
errors.append(f"mapping repeats key {key!r}")
|
|
473
|
+
seen_keys.add(key)
|
|
474
|
+
target = entries.get(dw_id)
|
|
475
|
+
if target is None:
|
|
476
|
+
errors.append(f"mapping {key} -> {dw_id}: no such entry in the ledger")
|
|
477
|
+
elif (first_word(target.status) == "done") != bool(source["done"]):
|
|
478
|
+
want = "done" if source["done"] else "open"
|
|
479
|
+
errors.append(f"mapping {key} -> {dw_id}: manifest says {want}, ledger disagrees")
|
|
480
|
+
missing = sorted(set(manifest_by_key) - seen_keys)
|
|
481
|
+
if missing:
|
|
482
|
+
errors.append("manifest keys not mapped: " + ", ".join(missing))
|
|
483
|
+
return errors
|
|
484
|
+
|
|
485
|
+
|
|
486
|
+
# --------------------------------------------------------------- prompting
|
|
487
|
+
|
|
488
|
+
|
|
489
|
+
class DecisionPrompter:
|
|
490
|
+
"""Walks the human through pending decisions on the terminal. Injection
|
|
491
|
+
points exist so tests can script answers.
|
|
492
|
+
|
|
493
|
+
The interactive terminal prompt is the v1 protocol: observers (the TUI
|
|
494
|
+
dashboard, ATTENTION watchers) learn a sweep is blocked from the
|
|
495
|
+
decision-pending journal event written just before ask() and attach to
|
|
496
|
+
the sweep's tmux window to answer. A decisions-file protocol — engine
|
|
497
|
+
writes the pending question to a file and polls for an answer the TUI
|
|
498
|
+
could write in-app — is deliberately deferred to v2; it needs timeout +
|
|
499
|
+
ownership semantics this run-blocking prompt avoids."""
|
|
500
|
+
|
|
501
|
+
def __init__(
|
|
502
|
+
self,
|
|
503
|
+
input_fn: Callable[[str], str] = input,
|
|
504
|
+
print_fn: Callable[[str], None] = print,
|
|
505
|
+
):
|
|
506
|
+
self.input_fn = input_fn
|
|
507
|
+
self.print_fn = print_fn
|
|
508
|
+
|
|
509
|
+
def ask(self, decision: Decision) -> DecisionOption:
|
|
510
|
+
p = self.print_fn
|
|
511
|
+
p("")
|
|
512
|
+
p(f"── decision needed: {decision.id} " + "─" * 30)
|
|
513
|
+
p(decision.question)
|
|
514
|
+
if decision.context:
|
|
515
|
+
p("")
|
|
516
|
+
p(decision.context)
|
|
517
|
+
p("")
|
|
518
|
+
for opt in decision.options:
|
|
519
|
+
marker = " (recommended)" if opt.key == decision.recommendation else ""
|
|
520
|
+
p(f" [{opt.key}] {opt.label} — {opt.effect}{marker}")
|
|
521
|
+
if opt.intent:
|
|
522
|
+
p(f" {opt.intent}")
|
|
523
|
+
keys = [o.key for o in decision.options]
|
|
524
|
+
while True:
|
|
525
|
+
raw = self.input_fn(
|
|
526
|
+
f"choice [{'/'.join(keys)}] (enter = {decision.recommendation}): "
|
|
527
|
+
).strip()
|
|
528
|
+
if not raw:
|
|
529
|
+
raw = decision.recommendation
|
|
530
|
+
chosen = decision.option(raw)
|
|
531
|
+
if chosen is not None:
|
|
532
|
+
return chosen
|
|
533
|
+
p(f" invalid choice {raw!r}")
|
|
534
|
+
|
|
535
|
+
|
|
536
|
+
# ------------------------------------------------------------ sweep engine
|
|
537
|
+
|
|
538
|
+
|
|
539
|
+
class SweepEngine(Engine):
|
|
540
|
+
"""Engine variant whose loop processes the deferred-work ledger instead
|
|
541
|
+
of sprint-status. Bundles reuse the inherited story pipeline through the
|
|
542
|
+
override seams; the triage session has its own phase pair."""
|
|
543
|
+
|
|
544
|
+
def __init__(
|
|
545
|
+
self,
|
|
546
|
+
*args: Any,
|
|
547
|
+
triage_adapter: Any = None,
|
|
548
|
+
prompting: bool = False,
|
|
549
|
+
decisions_only: bool = False,
|
|
550
|
+
max_bundles: int | None = None,
|
|
551
|
+
repeat: bool | None = None,
|
|
552
|
+
max_cycles: int | None = None,
|
|
553
|
+
prompter: DecisionPrompter | None = None,
|
|
554
|
+
**kwargs: Any,
|
|
555
|
+
):
|
|
556
|
+
super().__init__(*args, **kwargs)
|
|
557
|
+
self.adapters["triage"] = (
|
|
558
|
+
triage_adapter if triage_adapter is not None else self.adapters["dev"]
|
|
559
|
+
)
|
|
560
|
+
self.prompting = prompting
|
|
561
|
+
self.decisions_only = decisions_only
|
|
562
|
+
self.max_bundles = max_bundles if max_bundles is not None else self.policy.sweep.max_bundles
|
|
563
|
+
self.repeat = repeat if repeat is not None else self.policy.sweep.repeat
|
|
564
|
+
self.max_cycles = max_cycles if max_cycles is not None else self.policy.sweep.max_cycles
|
|
565
|
+
self.prompter = prompter or DecisionPrompter()
|
|
566
|
+
# decisions already journaled as skipped this process; without it a
|
|
567
|
+
# persistent decision item would notify once per repeat cycle
|
|
568
|
+
self._skipped_decisions: set[str] = set()
|
|
569
|
+
self.state.run_type = "sweep"
|
|
570
|
+
|
|
571
|
+
def _remaining_estimate(self) -> int | None:
|
|
572
|
+
"""Sweep override of the graceful-stop hint: how many deferred-work
|
|
573
|
+
entries are still open in the ledger — the work a resume would pick up.
|
|
574
|
+
Like the base, a hint only: the whole body is guarded so an
|
|
575
|
+
unreadable/invalid ledger returns None rather than derailing the stop."""
|
|
576
|
+
try:
|
|
577
|
+
ledger = self.workspace.paths.deferred_work
|
|
578
|
+
text = ledger.read_text(encoding="utf-8") if ledger.is_file() else ""
|
|
579
|
+
return len(deferredwork.open_ids(text))
|
|
580
|
+
except Exception: # a hint must never break the stop
|
|
581
|
+
return None
|
|
582
|
+
|
|
583
|
+
# ------------------------------------------------------------ main loop
|
|
584
|
+
|
|
585
|
+
def _loop(self) -> None:
|
|
586
|
+
ledger = self.workspace.paths.deferred_work
|
|
587
|
+
cycle = max(1, self.state.sweep_cycle)
|
|
588
|
+
if self._finish_inflight_bundles():
|
|
589
|
+
# a recovered bundle's ledger restore can leave the tree dirty, and
|
|
590
|
+
# triage plus the first bundle baseline need a clean one. Guarded on
|
|
591
|
+
# a non-empty pass so a fresh sweep never commits the user's dirt.
|
|
592
|
+
self._commit_ledger("chore(sweep): commit ledger after recovering in-flight bundles")
|
|
593
|
+
while True:
|
|
594
|
+
# First statement of the loop body: covers the boundary right after
|
|
595
|
+
# _finish_inflight_bundles on resume and between repeat cycles. A
|
|
596
|
+
# request during a cycle is caught before the next _run_bundle (see
|
|
597
|
+
# _cycle); one landing between cycles stops here before cycle N+1
|
|
598
|
+
# re-triages.
|
|
599
|
+
self._check_stop_request()
|
|
600
|
+
self.state.sweep_cycle = cycle
|
|
601
|
+
self._save()
|
|
602
|
+
text = ledger.read_text(encoding="utf-8") if ledger.is_file() else ""
|
|
603
|
+
if deferredwork.has_legacy(text):
|
|
604
|
+
if cycle > 1:
|
|
605
|
+
# freeform text appeared mid-run; _ensure_migration assumes
|
|
606
|
+
# one migration per run, so hand off to a fresh sweep
|
|
607
|
+
self.journal.append(
|
|
608
|
+
"sweep-repeat-done", cycles=cycle - 1, reason="legacy-appeared"
|
|
609
|
+
)
|
|
610
|
+
gates.notify(
|
|
611
|
+
self.policy,
|
|
612
|
+
self.run_dir,
|
|
613
|
+
"legacy ledger entries appeared mid-sweep",
|
|
614
|
+
"run a fresh `froid-loop sweep` to migrate them",
|
|
615
|
+
)
|
|
616
|
+
return
|
|
617
|
+
self._ensure_migration(text)
|
|
618
|
+
text = ledger.read_text(encoding="utf-8") if ledger.is_file() else ""
|
|
619
|
+
open_now = deferredwork.open_ids(text)
|
|
620
|
+
if not open_now:
|
|
621
|
+
if cycle == 1:
|
|
622
|
+
self.journal.append("sweep-nothing-open", ledger=str(ledger))
|
|
623
|
+
else:
|
|
624
|
+
self.journal.append("sweep-repeat-done", cycles=cycle - 1, reason="no-open")
|
|
625
|
+
return
|
|
626
|
+
if cycle > 1:
|
|
627
|
+
self.journal.append("sweep-cycle", cycle=cycle, open=len(open_now))
|
|
628
|
+
progressed = self._cycle(cycle, open_now)
|
|
629
|
+
if self.decisions_only or not self.repeat:
|
|
630
|
+
return
|
|
631
|
+
if not progressed:
|
|
632
|
+
self.journal.append("sweep-repeat-done", cycles=cycle, reason="no-progress")
|
|
633
|
+
return
|
|
634
|
+
if cycle >= self.max_cycles:
|
|
635
|
+
self.journal.append("sweep-repeat-done", cycles=cycle, reason="max-cycles")
|
|
636
|
+
return
|
|
637
|
+
# a deferred bundle's ledger restore can leave the tree dirty; the
|
|
638
|
+
# next cycle's triage and bundle baselines need a clean tree
|
|
639
|
+
self._commit_ledger("chore(sweep): commit ledger before next sweep cycle")
|
|
640
|
+
cycle += 1
|
|
641
|
+
|
|
642
|
+
def _finish_inflight_bundles(self) -> int:
|
|
643
|
+
"""Re-drive every bundle this run left in flight, keyed on the task's own
|
|
644
|
+
persisted story_key. Returns how many were recovered.
|
|
645
|
+
|
|
646
|
+
The base Engine._loop opens with _finish_inflight for exactly this reason;
|
|
647
|
+
the sweep loop used to recover a bundle only from inside _run_bundle, which
|
|
648
|
+
a cycle reaches only after re-deriving the bundle's key from the *current*
|
|
649
|
+
triage plan. A re-armed bundle therefore survived a resume solely because
|
|
650
|
+
the cached triage.json reloaded and re-emitted the same bundle name — lose
|
|
651
|
+
that cache and a fresh triage partitions the ids under new names, silently
|
|
652
|
+
orphaning the human's resolution (#94).
|
|
653
|
+
|
|
654
|
+
Runs before the ledger is read, so a bundle it closes leaves the open set
|
|
655
|
+
and no fresh triage can re-bundle those ids (validate_triage rejects a plan
|
|
656
|
+
whose open_ids disagree with the ledger). A recovered bundle that defers or
|
|
657
|
+
escalates keeps its ids open. A dev-leg discard never closes them; an
|
|
658
|
+
in-place post-acceptance defer reopens this run's close, while an isolated
|
|
659
|
+
unit's close dies with its unmerged worktree. The existing failed_ids filter
|
|
660
|
+
then drops the fresh plan's overlapping bundle."""
|
|
661
|
+
recovered = 0
|
|
662
|
+
for task in list(self.state.tasks.values()):
|
|
663
|
+
if task.terminal or not BUNDLE_KEY_RE.match(task.story_key):
|
|
664
|
+
continue
|
|
665
|
+
recovered += 1
|
|
666
|
+
self.journal.append(
|
|
667
|
+
"sweep-inflight-redrive",
|
|
668
|
+
story_key=task.story_key,
|
|
669
|
+
phase=str(task.phase),
|
|
670
|
+
rearmed=task.rearmed, # read before the recovery clears the latch
|
|
671
|
+
)
|
|
672
|
+
if self._recover_inflight_bundle(task):
|
|
673
|
+
continue
|
|
674
|
+
self._ensure_bundle_intent(task)
|
|
675
|
+
self._save()
|
|
676
|
+
self._emit("pre_bundle", task)
|
|
677
|
+
self._run_story(task)
|
|
678
|
+
self._emit("post_bundle", task)
|
|
679
|
+
return recovered
|
|
680
|
+
|
|
681
|
+
def _warn_stranded_bundles(self) -> None:
|
|
682
|
+
"""Invariant: _finish_inflight_bundles has driven every persisted bundle to
|
|
683
|
+
a terminal phase before a cycle picks new work. A survivor means a bundle
|
|
684
|
+
would be silently dropped — say so loudly rather than sweep past it."""
|
|
685
|
+
stranded = [
|
|
686
|
+
t.story_key
|
|
687
|
+
for t in self.state.tasks.values()
|
|
688
|
+
if BUNDLE_KEY_RE.match(t.story_key) and not t.terminal
|
|
689
|
+
]
|
|
690
|
+
if not stranded:
|
|
691
|
+
return
|
|
692
|
+
self.journal.append("sweep-inflight-stranded", story_keys=stranded)
|
|
693
|
+
gates.notify(
|
|
694
|
+
self.policy,
|
|
695
|
+
self.run_dir,
|
|
696
|
+
f"{len(stranded)} sweep bundle(s) left in flight",
|
|
697
|
+
"not re-driven by this cycle: " + ", ".join(stranded),
|
|
698
|
+
)
|
|
699
|
+
|
|
700
|
+
def _cycle(self, cycle: int, open_now: set[str]) -> bool:
|
|
701
|
+
"""One triage -> close -> decide -> bundle pass. Returns whether the
|
|
702
|
+
cycle completed any addressable work — the repeat loop's progress
|
|
703
|
+
predicate. Caveat: on crash-resume of a cycle whose only progress was
|
|
704
|
+
already-resolved closes, the replayed (idempotent) closes report 0 and
|
|
705
|
+
the run stops with no-progress; errs toward stopping, never loops."""
|
|
706
|
+
self._emit("pre_sweep_cycle", phase=str(cycle))
|
|
707
|
+
self._warn_stranded_bundles()
|
|
708
|
+
plan = self._ensure_triage(open_now, cycle)
|
|
709
|
+
closed = self._close_resolved(plan)
|
|
710
|
+
answers, decisions_closed = self._decisions_phase(plan)
|
|
711
|
+
bundles = self._materialize_bundles(plan, answers)
|
|
712
|
+
if self.decisions_only:
|
|
713
|
+
self.journal.append("sweep-decisions-only", bundles_not_run=len(bundles))
|
|
714
|
+
self._prune_pre_answers()
|
|
715
|
+
self._emit("post_sweep_cycle", phase=str(cycle))
|
|
716
|
+
return False
|
|
717
|
+
for bundle in bundles:
|
|
718
|
+
# Item boundary: a request during bundle N lets N finish through
|
|
719
|
+
# commit; bundle N+1 never starts. A request landing during triage
|
|
720
|
+
# reaches the first iteration here, so triage completes but zero
|
|
721
|
+
# bundles run. Mid-cycle stop is resume-safe: sweep_cycle is
|
|
722
|
+
# persisted, triage.json is cached, closes are idempotent, and
|
|
723
|
+
# terminal tasks are skipped on re-drive.
|
|
724
|
+
self._check_stop_request()
|
|
725
|
+
self._run_bundle(bundle, cycle)
|
|
726
|
+
bundles_done = sum(
|
|
727
|
+
1
|
|
728
|
+
for b in bundles
|
|
729
|
+
if self.state.tasks[self._bundle_key(b.name, cycle)].phase == Phase.DONE
|
|
730
|
+
)
|
|
731
|
+
self._prune_pre_answers()
|
|
732
|
+
self._emit("post_sweep_cycle", phase=str(cycle))
|
|
733
|
+
return closed > 0 or decisions_closed > 0 or bundles_done > 0
|
|
734
|
+
|
|
735
|
+
def _prune_pre_answers(self) -> None:
|
|
736
|
+
"""Drop consumed pre-answers — entries built or closed this cycle have
|
|
737
|
+
left the open set. Keeps the store from re-applying a stale answer (and a
|
|
738
|
+
keep-open answer's audit line) on the next sweep."""
|
|
739
|
+
from . import decisions as decisions_store # lazy: decisions imports sweep
|
|
740
|
+
|
|
741
|
+
ledger = self.workspace.paths.deferred_work
|
|
742
|
+
text = ledger.read_text(encoding="utf-8") if ledger.is_file() else ""
|
|
743
|
+
# The store lives under the project that owns `run_dir`, never
|
|
744
|
+
# `self.workspace.root`: under the `repo_root` override the two diverge
|
|
745
|
+
# and a workspace-rooted prune trimmed a store that does not exist,
|
|
746
|
+
# leaving consumed entries behind (the comment in `_decisions_phase`
|
|
747
|
+
# says why the run dir is the stable anchor). The ledger read above is
|
|
748
|
+
# unaffected — `deferred_work` hangs off `implementation_artifacts`,
|
|
749
|
+
# which stays project-rooted under the override.
|
|
750
|
+
dropped = decisions_store.prune_pre_answers(
|
|
751
|
+
_project_of_run_dir(self.run_dir), deferredwork.open_ids(text)
|
|
752
|
+
)
|
|
753
|
+
if dropped:
|
|
754
|
+
self.journal.append("decision-preanswers-pruned", dw_ids=dropped)
|
|
755
|
+
self._commit_ledger("chore(sweep): drop consumed deferred-work pre-answers")
|
|
756
|
+
|
|
757
|
+
def _drive_story(self, task: StoryTask) -> None:
|
|
758
|
+
# no spec-approval gate for bundles: the bundle intent came from the
|
|
759
|
+
# validated triage plan (and, for decision bundles, from the human).
|
|
760
|
+
# The base _run_story wraps this in a worktree when isolation=worktree.
|
|
761
|
+
if self._dev_phase(task):
|
|
762
|
+
self._review_and_commit(task)
|
|
763
|
+
|
|
764
|
+
# cycle 1 keeps the legacy key so pre-repeat paused runs resume unchanged;
|
|
765
|
+
# "dw{N}-" (not "dw-c{N}-") so a cycle-1 bundle named "c2-foo" can never
|
|
766
|
+
# collide with a cycle-2 bundle named "foo"
|
|
767
|
+
def _bundle_key(self, name: str, cycle: int) -> str:
|
|
768
|
+
return f"dw-{name}" if cycle == 1 else f"dw{cycle}-{name}"
|
|
769
|
+
|
|
770
|
+
def _run_bundle(self, bundle: Bundle, cycle: int) -> None:
|
|
771
|
+
key = self._bundle_key(bundle.name, cycle)
|
|
772
|
+
task = self.state.tasks.get(key)
|
|
773
|
+
if task is not None and task.terminal:
|
|
774
|
+
return # finished (or adjudicated) in a previous resume cycle
|
|
775
|
+
if task is None:
|
|
776
|
+
task = StoryTask(story_key=key, epic=0, dw_ids=list(bundle.dw_ids))
|
|
777
|
+
self.state.tasks[key] = task
|
|
778
|
+
self.journal.append("bundle-start", story_key=key, dw_ids=list(bundle.dw_ids))
|
|
779
|
+
elif self._recover_inflight_bundle(task):
|
|
780
|
+
return
|
|
781
|
+
dirname = bundle.name if cycle == 1 else f"c{cycle}-{bundle.name}"
|
|
782
|
+
task.bundle_file = str(self._write_intent(bundle, dirname))
|
|
783
|
+
self._save()
|
|
784
|
+
self._emit("pre_bundle", task)
|
|
785
|
+
self._run_story(task)
|
|
786
|
+
self._emit("post_bundle", task)
|
|
787
|
+
|
|
788
|
+
def _recover_inflight_bundle(self, task: StoryTask) -> bool:
|
|
789
|
+
"""Recover a bundle task interrupted mid-flight (or re-armed after a
|
|
790
|
+
human resolved its escalation via `froid-loop resolve`): the same recovery
|
|
791
|
+
as Engine._finish_inflight, including the re-drive latch so a
|
|
792
|
+
human-resolved escalation is protected through every reset (mirrors
|
|
793
|
+
engine.py:1163-1169).
|
|
794
|
+
|
|
795
|
+
Returns True when the persisted PROCEED receipt carried the bundle all
|
|
796
|
+
the way through accepted sync, review, and commit — the caller is done.
|
|
797
|
+
Returns False once the task has been reset to PENDING, leaving the caller
|
|
798
|
+
to dispatch it. A bare DEV_VERIFY + spec_file shape is insufficient: the
|
|
799
|
+
pre-action decision save has the same shape for rejected decisions.
|
|
800
|
+
|
|
801
|
+
Deliberately narrower than the base _finish_inflight: no
|
|
802
|
+
`_resumable_session` arm, so a bundle whose host died in the
|
|
803
|
+
post-session window still restarts rather than replaying its recorded
|
|
804
|
+
result. Lifting that is a resume-fidelity change of its own. The
|
|
805
|
+
COMMITTING window IS recovered, though — same as the base engine's
|
|
806
|
+
resume-commit arm (#115)."""
|
|
807
|
+
if task.worktree_path:
|
|
808
|
+
# The same re-anchor `Engine._finish_inflight` makes, for the same reason
|
|
809
|
+
# and in the same position — ABOVE the `isolated` gate. Sweep does not
|
|
810
|
+
# inherit it: `SweepEngine` replaces `_loop` wholesale and `Engine._loop`
|
|
811
|
+
# is the only caller of `_finish_inflight`, so nothing on this path had
|
|
812
|
+
# re-absolutized the persisted spelling. Both legs below need it. The
|
|
813
|
+
# restart arm discards the mount and clears `worktree_path` before the
|
|
814
|
+
# caller saves, which would strand the mount-RELATIVE value beside an
|
|
815
|
+
# empty `worktree_path` (`_serialized_worktree_path` only relativizes
|
|
816
|
+
# while that field is set); and the gate is live policy, so an
|
|
817
|
+
# `isolation` flip across a resume drops the `elif task.baseline_commit`
|
|
818
|
+
# and the two non-isolated arms onto never-re-anchored paths. Either way
|
|
819
|
+
# the raw value resolves against the MAIN checkout — same layout, so
|
|
820
|
+
# `recovery_flow._attempt_owned_spec` finds one candidate,
|
|
821
|
+
# `spec_within_roots` accepts it, and the snapshot restore rewrites the
|
|
822
|
+
# operator's own copy.
|
|
823
|
+
task.rebase_spec_paths_on(Path(task.worktree_path))
|
|
824
|
+
isolated = self._isolated and task.worktree_path
|
|
825
|
+
if task.phase == Phase.COMMITTING:
|
|
826
|
+
# the gate+advance save landed pre-death; finish the commit
|
|
827
|
+
# instead of rolling verified bundle work back (see
|
|
828
|
+
# Engine._finalize_commit_phase for the re-drive contract).
|
|
829
|
+
self.journal.append("resume-commit", story_key=task.story_key)
|
|
830
|
+
if isolated:
|
|
831
|
+
unit = self._reopen_unit(task)
|
|
832
|
+
prev = self.workspace
|
|
833
|
+
self.workspace = unit.workspace
|
|
834
|
+
try:
|
|
835
|
+
self._finalize_commit_phase(task)
|
|
836
|
+
finally:
|
|
837
|
+
self.workspace = prev
|
|
838
|
+
self._integrate_unit(task, unit)
|
|
839
|
+
else:
|
|
840
|
+
self._finalize_commit_phase(task)
|
|
841
|
+
return True
|
|
842
|
+
self.journal.append("resume-restart", story_key=task.story_key, phase=str(task.phase))
|
|
843
|
+
if (
|
|
844
|
+
task.phase == Phase.DEV_VERIFY
|
|
845
|
+
and task.spec_file
|
|
846
|
+
and self._accepted_dev_session_matches(task)
|
|
847
|
+
):
|
|
848
|
+
self._save()
|
|
849
|
+
if isolated:
|
|
850
|
+
unit = self._reopen_unit(task)
|
|
851
|
+
prev = self.workspace
|
|
852
|
+
self.workspace = unit.workspace
|
|
853
|
+
try:
|
|
854
|
+
self._resume_after_dev_verify(task)
|
|
855
|
+
finally:
|
|
856
|
+
self.workspace = prev
|
|
857
|
+
self._integrate_unit(task, unit)
|
|
858
|
+
else:
|
|
859
|
+
self._resume_after_dev_verify(task)
|
|
860
|
+
return True
|
|
861
|
+
if isolated:
|
|
862
|
+
# drop the half-built worktree; _run_story mounts a fresh one
|
|
863
|
+
self._discard_unit_for_restart(task)
|
|
864
|
+
elif task.baseline_commit:
|
|
865
|
+
# latch resolved_redrive so the corrected spec + restored diff stay
|
|
866
|
+
# protected through every reset of this re-drive, not just this
|
|
867
|
+
# first one; cause="resolved" keeps a human-initiated re-arm
|
|
868
|
+
# pause-free regardless of scm.rollback_on_failure
|
|
869
|
+
task.resolved_redrive = task.resolved_redrive or task.rearmed
|
|
870
|
+
self._rollback_or_pause(task, cause="resolved" if task.rearmed else "stopped")
|
|
871
|
+
task.rearmed = False # past rollback (only reached when not paused)
|
|
872
|
+
task.phase = Phase.PENDING # deliberate reset, not a normal transition
|
|
873
|
+
return False
|
|
874
|
+
|
|
875
|
+
# ------------------------------------------------------------ migration
|
|
876
|
+
|
|
877
|
+
def _ensure_migration(self, text: str) -> None:
|
|
878
|
+
"""Pre-DW-format ledger content (older FROID-method projects) blocks a
|
|
879
|
+
sweep: open_ids() cannot see it and mark_done() cannot flip it. One
|
|
880
|
+
LLM session rewrites the legacy items into canonical DW entries; the
|
|
881
|
+
orchestrator pins exactly what to convert (a manifest from
|
|
882
|
+
parse_legacy), validates the rewrite deterministically, and restores
|
|
883
|
+
the original ledger before any retry."""
|
|
884
|
+
ledger = self.workspace.paths.deferred_work
|
|
885
|
+
task = self.state.tasks.get(MIGRATE_KEY)
|
|
886
|
+
if task is None:
|
|
887
|
+
task = StoryTask(story_key=MIGRATE_KEY, epic=0)
|
|
888
|
+
self.state.tasks[MIGRATE_KEY] = task
|
|
889
|
+
elif task.phase != Phase.PENDING:
|
|
890
|
+
# resumed mid-migration or retrying after an escalation: restart
|
|
891
|
+
self.journal.append("resume-restart", story_key=MIGRATE_KEY, phase=str(task.phase))
|
|
892
|
+
if task.phase == Phase.ESCALATED:
|
|
893
|
+
task.attempt = 0 # the human resumed deliberately; fresh budget
|
|
894
|
+
if task.baseline_commit and not verify.worktree_clean(self.workspace.root):
|
|
895
|
+
self._safe_reset(task) # a session died mid-rewrite; restore our ledger
|
|
896
|
+
text = ledger.read_text(encoding="utf-8") if ledger.is_file() else ""
|
|
897
|
+
task.phase = Phase.PENDING # deliberate reset, not a normal transition
|
|
898
|
+
# **The invariant: a refusal that dispatches nothing leaves this task
|
|
899
|
+
# owning NO baseline.** It takes both halves below. Sitting above the
|
|
900
|
+
# stamp keeps a fresh entry from taking one; clearing handles the entry
|
|
901
|
+
# that arrives already holding one, which the resume-after-escalation
|
|
902
|
+
# branch above does. Either way the next resume re-stamps the repaired
|
|
903
|
+
# HEAD. Leave a baseline behind and it names the PRE-repair tree: the
|
|
904
|
+
# operator renumbers and resumes, and when that migration session
|
|
905
|
+
# env-faults, the next resume's `_safe_reset` rewinds to that stale
|
|
906
|
+
# baseline — destroying the repair and any commits beside it, and
|
|
907
|
+
# landing back on this same pause. Safe to clear because nothing of
|
|
908
|
+
# ours is outstanding here: no session has run, and the branch above
|
|
909
|
+
# has already restored the tree if a previous one died mid-rewrite.
|
|
910
|
+
# This still sits BELOW that branch, because the ledger it reads must
|
|
911
|
+
# be the restored one.
|
|
912
|
+
#
|
|
913
|
+
# Refused BEFORE a rewrite is dispatched, not after one is graded (#519).
|
|
914
|
+
# A ledger where one id names two entries is corrupt in a way the format
|
|
915
|
+
# cannot express, and there is no automatic outcome that is right: this
|
|
916
|
+
# mode is required to keep pre-existing entries byte-identical, so the
|
|
917
|
+
# only rewrite that preserves the pair trips `duplicate DW ids` in
|
|
918
|
+
# validate_migration, and the only rewrite that passes is a collapse
|
|
919
|
+
# that drops one twin's `gate:` silently. Grading the collapse instead
|
|
920
|
+
# cannot be made safe — a snapshot keyed by id describes a merged entry
|
|
921
|
+
# that never existed, and each half hardened on its own opens the next
|
|
922
|
+
# cross-product. So a human renumbers, which is the call
|
|
923
|
+
# `_apply_deferred_closes` already makes on a duplicate id (#286).
|
|
924
|
+
# Paused rather than ESCALATED, and the task stays PENDING on purpose:
|
|
925
|
+
# that is what makes the refusal re-askable the way `_refuse_gated_story`
|
|
926
|
+
# is. The operator renumbers the ids and resumes, and this re-reads the
|
|
927
|
+
# ledger and lets the migration run — an ESCALATED task would also spend
|
|
928
|
+
# the migration attempt budget on a rewrite that never happened.
|
|
929
|
+
#
|
|
930
|
+
# PAUSE_STORY_GATE, NOT PAUSE_ESCALATION, and the pairing is the point:
|
|
931
|
+
# the stage picks the recovery UI, and every escalation action
|
|
932
|
+
# (`runs.rearm_story`, the TUI's Resolve) requires the task to be
|
|
933
|
+
# Phase.ESCALATED, which this one deliberately is not — so an escalation
|
|
934
|
+
# stage here would offer the operator only actions that must fail. The
|
|
935
|
+
# gate stage routes to a viewer whose single action is "resume", which
|
|
936
|
+
# is the whole remedy once the ids are renumbered. `_refuse_gated_story`
|
|
937
|
+
# already pauses this way with a task that is not escalated: same
|
|
938
|
+
# contract — the deferred-work ledger blocks the run, a human edits it,
|
|
939
|
+
# the resume re-reads it.
|
|
940
|
+
dupes = duplicate_ids(deferredwork.parse_ledger(text))
|
|
941
|
+
if dupes:
|
|
942
|
+
reason = (
|
|
943
|
+
f"{ledger.name} carries duplicate DW ids: {', '.join(dupes)} — one id "
|
|
944
|
+
"names more than one entry, so no migration of it can both preserve "
|
|
945
|
+
"the entries and produce a valid ledger; renumber or merge them by "
|
|
946
|
+
"hand and COMMIT the fix, then resume"
|
|
947
|
+
)
|
|
948
|
+
self.journal.append("migrate-duplicate-ids", story_key=MIGRATE_KEY, dw_ids=list(dupes))
|
|
949
|
+
gates.notify(
|
|
950
|
+
self.policy,
|
|
951
|
+
self.run_dir,
|
|
952
|
+
f"migration refused: {ledger.name}",
|
|
953
|
+
f"{reason} — then `froid-loop resume {self.state.run_id}`",
|
|
954
|
+
)
|
|
955
|
+
task.baseline_commit = None
|
|
956
|
+
task.baseline_untracked = None
|
|
957
|
+
self._save()
|
|
958
|
+
raise RunPaused(reason, PAUSE_STORY_GATE, MIGRATE_KEY)
|
|
959
|
+
|
|
960
|
+
if not task.baseline_commit:
|
|
961
|
+
# `self.workspace.root`, which is `paths.repo_root` — the same anchor the
|
|
962
|
+
# dev writer (`Engine._dev_phase`), the re-arm writer
|
|
963
|
+
# (`runs.rearm_escalation`) and every proof-of-work probe in
|
|
964
|
+
# `verify._verify_shared_gates` use. Under the `repo_root` override it is
|
|
965
|
+
# NOT `paths.project`, and a baseline stamped in one tree and measured in
|
|
966
|
+
# the other names a commit the measuring repo has never heard of (#716).
|
|
967
|
+
task.baseline_commit = verify.rev_parse_head(self.workspace.root)
|
|
968
|
+
task.baseline_untracked = sorted(verify.untracked_files(self.workspace.root))
|
|
969
|
+
|
|
970
|
+
legacy = deferredwork.parse_legacy(text)
|
|
971
|
+
pre_canonical = snapshot_canonical(text)
|
|
972
|
+
manifest = [
|
|
973
|
+
{
|
|
974
|
+
"key": e.key,
|
|
975
|
+
"id": e.id,
|
|
976
|
+
"title": e.title,
|
|
977
|
+
"section": e.section,
|
|
978
|
+
"done": e.done,
|
|
979
|
+
"severity": e.severity,
|
|
980
|
+
}
|
|
981
|
+
for e in legacy
|
|
982
|
+
]
|
|
983
|
+
manifest_path = self.run_dir / "migrate-manifest.json"
|
|
984
|
+
manifest_path.write_text(json.dumps(manifest, indent=2), encoding="utf-8")
|
|
985
|
+
|
|
986
|
+
feedback: Path | None = None
|
|
987
|
+
while True:
|
|
988
|
+
task.attempt += 1
|
|
989
|
+
advance(task, Phase.TRIAGE_RUNNING)
|
|
990
|
+
self._save()
|
|
991
|
+
result = self._run_session(
|
|
992
|
+
task,
|
|
993
|
+
role="triage",
|
|
994
|
+
prompt=self._migrate_prompt(manifest_path, feedback),
|
|
995
|
+
seq=task.attempt,
|
|
996
|
+
session_stage="pre_migrate_session",
|
|
997
|
+
)
|
|
998
|
+
advance(task, Phase.TRIAGE_VERIFY)
|
|
999
|
+
self._save()
|
|
1000
|
+
crits = critical_escalations(result.result_json)
|
|
1001
|
+
if crits:
|
|
1002
|
+
details = "; ".join(str(e.get("detail", e.get("type", "?"))) for e in crits)
|
|
1003
|
+
self._escalate(task, f"CRITICAL escalation from migration session: {details}")
|
|
1004
|
+
# Split so ABSENCE survives: `new_text` stays `str` for
|
|
1005
|
+
# `validate_migration`, while `rewrite` keeps the difference between
|
|
1006
|
+
# "the session emptied the ledger" and "the session deleted it". On
|
|
1007
|
+
# an untracked ledger the restore below has no blob to anchor on and
|
|
1008
|
+
# this rejected rewrite — the exact text this attempt graded — is the
|
|
1009
|
+
# anchor instead, so flattening `None` to `""` here would make the
|
|
1010
|
+
# deleted-ledger case indistinguishable from a rival's empty write.
|
|
1011
|
+
rewrite = ledger.read_text(encoding="utf-8") if ledger.is_file() else None
|
|
1012
|
+
new_text = rewrite if rewrite is not None else ""
|
|
1013
|
+
if result.status != "completed":
|
|
1014
|
+
errors = [session_failure_reason("migration", result)]
|
|
1015
|
+
else:
|
|
1016
|
+
errors = validate_migration(result.result_json, manifest, pre_canonical, new_text)
|
|
1017
|
+
self.journal.append(
|
|
1018
|
+
"migrate-decision",
|
|
1019
|
+
attempt=task.attempt,
|
|
1020
|
+
session_status=result.status,
|
|
1021
|
+
ok=not errors,
|
|
1022
|
+
errors=errors,
|
|
1023
|
+
env_fault=result.env_fault,
|
|
1024
|
+
)
|
|
1025
|
+
if result.status != "completed" and result.env_fault:
|
|
1026
|
+
# The migration session's CLI lost its API connection (#194): it did
|
|
1027
|
+
# no rewrite work, so pause (the ESCALATED-resume above resets
|
|
1028
|
+
# task.attempt to 0 — fresh budget) instead of charging a migration
|
|
1029
|
+
# attempt. Escalate BEFORE the _safe_reset/attempt-cap path; that
|
|
1030
|
+
# resume also restores the ledger if the worktree is dirty.
|
|
1031
|
+
self._escalate(
|
|
1032
|
+
task,
|
|
1033
|
+
env_fault_pause_reason("migration", result),
|
|
1034
|
+
)
|
|
1035
|
+
if not errors:
|
|
1036
|
+
advance(task, Phase.DONE)
|
|
1037
|
+
self._save()
|
|
1038
|
+
(self.run_dir / "migrate-result.json").write_text(
|
|
1039
|
+
json.dumps(result.result_json, indent=2), encoding="utf-8"
|
|
1040
|
+
)
|
|
1041
|
+
self._commit_ledger(
|
|
1042
|
+
"chore(sweep): migrate legacy deferred-work entries to DW format"
|
|
1043
|
+
)
|
|
1044
|
+
post = deferredwork.parse_ledger(new_text)
|
|
1045
|
+
self.journal.append(
|
|
1046
|
+
"sweep-migrated",
|
|
1047
|
+
converted=len(manifest),
|
|
1048
|
+
entries_now=len(post),
|
|
1049
|
+
open_now=sum(1 for e in post if e.open),
|
|
1050
|
+
)
|
|
1051
|
+
self._emit("post_migrate", task)
|
|
1052
|
+
return
|
|
1053
|
+
# never re-prompt over a half-broken rewrite; the baseline reset
|
|
1054
|
+
# covers tracked files, the explicit write covers an untracked
|
|
1055
|
+
# ledger that `git reset` cannot restore
|
|
1056
|
+
self._safe_reset(task)
|
|
1057
|
+
# The WRITE anchor derives from the committed blob, never from an
|
|
1058
|
+
# observation of the tree taken after the very reset it would attest
|
|
1059
|
+
# to: a rival writing a tracked ledger inside that window would BE
|
|
1060
|
+
# the observation, and this restore would overwrite it (#735). Probed
|
|
1061
|
+
# BEFORE the lock — it spawns git, and `ledger_lock` may cover file
|
|
1062
|
+
# I/O only, which no reset window can (#286). A ledger git does not
|
|
1063
|
+
# own has no blob to anchor on, and `reset --hard` cannot have
|
|
1064
|
+
# touched it either, so there the anchor is the rejected rewrite this
|
|
1065
|
+
# attempt actually graded — down to `None == None` when the session
|
|
1066
|
+
# deleted the ledger outright. No anchor at all withholds the write.
|
|
1067
|
+
anchor, committed = self._ledger_baseline_text(task)
|
|
1068
|
+
expected = committed if committed is not None else rewrite
|
|
1069
|
+
diverged = False
|
|
1070
|
+
with deferredwork.ledger_lock(ledger):
|
|
1071
|
+
# PURE TEXT ONLY under the hold — `ledger_lock` is not reentrant
|
|
1072
|
+
# and every mutator takes it.
|
|
1073
|
+
current = ledger.read_text(encoding="utf-8") if ledger.is_file() else None
|
|
1074
|
+
if anchor is _LedgerAnchor.NO_RESET_CONTENT and current == text:
|
|
1075
|
+
# ALREADY the text this restore exists to write, so it is
|
|
1076
|
+
# done and there is nothing to escalate. Reachable without
|
|
1077
|
+
# any rival: a session that atomic-SAVES the ledger replaces
|
|
1078
|
+
# a tracked symlink with a regular file, `reset --hard` puts
|
|
1079
|
+
# the link back, and the external target it cannot reach was
|
|
1080
|
+
# never rewritten — so the ledger is correct while `rewrite`,
|
|
1081
|
+
# read off the regular file, is not what is on disk. Demanding
|
|
1082
|
+
# the anchor here would escalate a finished restore and spend
|
|
1083
|
+
# the attempt budget on it.
|
|
1084
|
+
#
|
|
1085
|
+
# Scoped to NO_RESET_CONTENT deliberately. On a BASELINE
|
|
1086
|
+
# anchor the reset republishes the committed text, so
|
|
1087
|
+
# `current == text` is the ORDINARY post-reset state and
|
|
1088
|
+
# accepting it there would retire the divergence check and
|
|
1089
|
+
# the probe-fault escalation along with it. Only where the
|
|
1090
|
+
# reset restored no text of its own is "already correct"
|
|
1091
|
+
# information the anchor cannot supply.
|
|
1092
|
+
pass
|
|
1093
|
+
# Either anchor will do below, unlike the engine's two restores:
|
|
1094
|
+
# this site supplies its own text for the no-reset-content case
|
|
1095
|
+
# (`rewrite`, which it graded), so `expected` is never the bare
|
|
1096
|
+
# `None` that would read a rival's deletion as the reset's work.
|
|
1097
|
+
elif anchor is not _LedgerAnchor.NONE and current == expected:
|
|
1098
|
+
ledger.parent.mkdir(parents=True, exist_ok=True)
|
|
1099
|
+
atomic_write_text(ledger, text)
|
|
1100
|
+
else:
|
|
1101
|
+
diverged = True
|
|
1102
|
+
if diverged:
|
|
1103
|
+
# No merge and no silent skip. The comment above is the reason:
|
|
1104
|
+
# leaving the rejected rewrite standing IS re-prompting over a
|
|
1105
|
+
# half-broken ledger, and the migration input a human must fix is
|
|
1106
|
+
# no longer the one this attempt was graded against — the same
|
|
1107
|
+
# call `migrate-duplicate-ids` makes about a corrupt ledger.
|
|
1108
|
+
# A baseline probe that could not answer lands here too, and
|
|
1109
|
+
# deliberately: without an anchor there is no proof the text on
|
|
1110
|
+
# disk is the reset's own work rather than somebody's live write,
|
|
1111
|
+
# and an unprovable restore is exactly the overwrite this arm
|
|
1112
|
+
# exists to refuse. The escalation is the right recovery for both
|
|
1113
|
+
# — the resume above resets the attempt budget and re-reads the
|
|
1114
|
+
# ledger, which is what a rival-corrupted migration input needs.
|
|
1115
|
+
# Journaled outside the hold; `_escalate` raises.
|
|
1116
|
+
self.journal.append(
|
|
1117
|
+
"sweep-migration-restore-diverged",
|
|
1118
|
+
story_key=MIGRATE_KEY,
|
|
1119
|
+
ledger=str(ledger),
|
|
1120
|
+
)
|
|
1121
|
+
self._escalate(
|
|
1122
|
+
task,
|
|
1123
|
+
"the ledger changed underneath the failed migration attempt — "
|
|
1124
|
+
"re-run the sweep",
|
|
1125
|
+
)
|
|
1126
|
+
if task.attempt >= self.policy.sweep.max_migration_attempts:
|
|
1127
|
+
self._escalate(
|
|
1128
|
+
task, "migration failed deterministic validation: " + "; ".join(errors)
|
|
1129
|
+
)
|
|
1130
|
+
feedback = self._write_feedback(
|
|
1131
|
+
task,
|
|
1132
|
+
"The legacy-ledger migration failed deterministic validation:\n- "
|
|
1133
|
+
+ "\n- ".join(errors),
|
|
1134
|
+
)
|
|
1135
|
+
|
|
1136
|
+
def _migrate_prompt(self, manifest: Path, feedback: Path | None) -> str:
|
|
1137
|
+
prompt = f"/froid-loop-sweep --migrate {manifest}"
|
|
1138
|
+
if feedback is not None:
|
|
1139
|
+
prompt += f" --feedback {feedback}"
|
|
1140
|
+
return prompt
|
|
1141
|
+
|
|
1142
|
+
# --------------------------------------------------------------- triage
|
|
1143
|
+
|
|
1144
|
+
def _ensure_triage(self, open_now: set[str], cycle: int = 1) -> TriagePlan:
|
|
1145
|
+
suffix = "" if cycle == 1 else f"-{cycle}"
|
|
1146
|
+
triage_path = self.run_dir / f"triage{suffix}.json"
|
|
1147
|
+
triage_key = TRIAGE_KEY + suffix
|
|
1148
|
+
if triage_path.is_file():
|
|
1149
|
+
# already validated this run; the ledger has moved since (closes,
|
|
1150
|
+
# decisions), so skip the open-set equality re-check. A cache we
|
|
1151
|
+
# cannot read or that is not a JSON object degrades to a fresh
|
|
1152
|
+
# triage — a truncated file must not crash the whole run.
|
|
1153
|
+
try:
|
|
1154
|
+
cached = _read_json(triage_path)
|
|
1155
|
+
except (json.JSONDecodeError, OSError, UnicodeDecodeError) as exc:
|
|
1156
|
+
self.journal.append("sweep-triage-reload-failed", errors=[f"unreadable: {exc}"])
|
|
1157
|
+
else:
|
|
1158
|
+
if isinstance(cached, dict):
|
|
1159
|
+
plan, errors = validate_triage(cached, None)
|
|
1160
|
+
else:
|
|
1161
|
+
plan, errors = None, [f"not a JSON object: {type(cached).__name__}"]
|
|
1162
|
+
if plan is not None:
|
|
1163
|
+
return plan
|
|
1164
|
+
self.journal.append("sweep-triage-reload-failed", errors=errors)
|
|
1165
|
+
|
|
1166
|
+
task = self.state.tasks.get(triage_key)
|
|
1167
|
+
if task is None:
|
|
1168
|
+
task = StoryTask(story_key=triage_key, epic=0)
|
|
1169
|
+
self.state.tasks[triage_key] = task
|
|
1170
|
+
elif task.phase != Phase.PENDING:
|
|
1171
|
+
# resumed mid-triage or retrying after an escalation: restart
|
|
1172
|
+
self.journal.append("resume-restart", story_key=triage_key, phase=str(task.phase))
|
|
1173
|
+
if task.phase == Phase.ESCALATED:
|
|
1174
|
+
task.attempt = 0 # the human resumed deliberately; fresh budget
|
|
1175
|
+
task.phase = Phase.PENDING # deliberate reset, not a normal transition
|
|
1176
|
+
|
|
1177
|
+
feedback: Path | None = None
|
|
1178
|
+
while True:
|
|
1179
|
+
task.attempt += 1
|
|
1180
|
+
advance(task, Phase.TRIAGE_RUNNING)
|
|
1181
|
+
self._save()
|
|
1182
|
+
result = self._run_session(
|
|
1183
|
+
task,
|
|
1184
|
+
role="triage",
|
|
1185
|
+
prompt=self._triage_prompt(feedback),
|
|
1186
|
+
seq=task.attempt,
|
|
1187
|
+
)
|
|
1188
|
+
advance(task, Phase.TRIAGE_VERIFY)
|
|
1189
|
+
self._save()
|
|
1190
|
+
crits = critical_escalations(result.result_json)
|
|
1191
|
+
if crits:
|
|
1192
|
+
details = "; ".join(str(e.get("detail", e.get("type", "?"))) for e in crits)
|
|
1193
|
+
self._escalate(task, f"CRITICAL escalation from triage session: {details}")
|
|
1194
|
+
if result.status != "completed":
|
|
1195
|
+
plan, errors = None, [session_failure_reason("triage", result)]
|
|
1196
|
+
else:
|
|
1197
|
+
repairs = _normalize_bundle_names(result.result_json)
|
|
1198
|
+
plan, errors = validate_triage(result.result_json, open_now)
|
|
1199
|
+
for repair in repairs:
|
|
1200
|
+
self.journal.append(
|
|
1201
|
+
"sweep-bundle-name-normalized",
|
|
1202
|
+
field=repair.field,
|
|
1203
|
+
original=repair.original,
|
|
1204
|
+
normalized=repair.normalized,
|
|
1205
|
+
)
|
|
1206
|
+
self.journal.append(
|
|
1207
|
+
"triage-decision",
|
|
1208
|
+
attempt=task.attempt,
|
|
1209
|
+
session_status=result.status,
|
|
1210
|
+
ok=plan is not None,
|
|
1211
|
+
errors=errors,
|
|
1212
|
+
env_fault=result.env_fault,
|
|
1213
|
+
)
|
|
1214
|
+
if result.status != "completed" and result.env_fault:
|
|
1215
|
+
# transport/API failure (#194): pause rather than charge a triage
|
|
1216
|
+
# attempt for a session that never reached the API. The
|
|
1217
|
+
# ESCALATED-resume above resets task.attempt to 0 (fresh budget).
|
|
1218
|
+
self._escalate(
|
|
1219
|
+
task,
|
|
1220
|
+
env_fault_pause_reason("triage", result),
|
|
1221
|
+
)
|
|
1222
|
+
if plan is not None:
|
|
1223
|
+
advance(task, Phase.DONE)
|
|
1224
|
+
self._save()
|
|
1225
|
+
triage_path.write_text(json.dumps(result.result_json, indent=2), encoding="utf-8")
|
|
1226
|
+
self.journal.append(
|
|
1227
|
+
"sweep-triage-result",
|
|
1228
|
+
bundles=len(plan.bundles),
|
|
1229
|
+
decisions=len(plan.decisions),
|
|
1230
|
+
already_resolved=len(plan.already_resolved),
|
|
1231
|
+
blocked=len(plan.blocked),
|
|
1232
|
+
skip=len(plan.skip),
|
|
1233
|
+
)
|
|
1234
|
+
self._emit("post_triage", task)
|
|
1235
|
+
return plan
|
|
1236
|
+
if task.attempt >= self.policy.sweep.max_triage_attempts:
|
|
1237
|
+
self._escalate(task, "triage output failed validation: " + "; ".join(errors))
|
|
1238
|
+
feedback = self._write_feedback(
|
|
1239
|
+
task,
|
|
1240
|
+
"The triage result.json failed deterministic validation:\n- " + "\n- ".join(errors),
|
|
1241
|
+
)
|
|
1242
|
+
|
|
1243
|
+
def _triage_prompt(self, feedback: Path | None) -> str:
|
|
1244
|
+
prompt = "/froid-loop-sweep"
|
|
1245
|
+
if feedback is not None:
|
|
1246
|
+
prompt += f" --feedback {feedback}"
|
|
1247
|
+
return prompt
|
|
1248
|
+
|
|
1249
|
+
# ------------------------------------------------------ ledger phases
|
|
1250
|
+
|
|
1251
|
+
def _close_resolved(self, plan: TriagePlan) -> int:
|
|
1252
|
+
self._emit("pre_close_resolved")
|
|
1253
|
+
ledger = self.workspace.paths.deferred_work
|
|
1254
|
+
# ONE locked read->edit->write for the whole batch (#286/#469). The
|
|
1255
|
+
# per-entry `mark_done` loop this replaces took the cross-process ledger
|
|
1256
|
+
# lock once per id, leaving a rival writer — a live run's harvest, the TUI
|
|
1257
|
+
# decision modal, `sweep --archive` — a window between every pair of
|
|
1258
|
+
# closures, so half this phase's closures could be lost while the other
|
|
1259
|
+
# half landed and the journal claimed all of them. `notes=` carries the
|
|
1260
|
+
# per-entry evidence the loop passed positionally, so the resulting ledger
|
|
1261
|
+
# text and the returned ids (order preserved, skips dropped) are unchanged.
|
|
1262
|
+
closed = deferredwork.mark_done_many(
|
|
1263
|
+
ledger,
|
|
1264
|
+
[entry.id for entry in plan.already_resolved],
|
|
1265
|
+
self._today(),
|
|
1266
|
+
"already resolved",
|
|
1267
|
+
notes=[f"already resolved: {entry.evidence}" for entry in plan.already_resolved],
|
|
1268
|
+
)
|
|
1269
|
+
if closed:
|
|
1270
|
+
self.journal.append("sweep-resolved-closed", dw_ids=closed)
|
|
1271
|
+
self._commit_ledger("chore(sweep): close resolved deferred-work entries")
|
|
1272
|
+
self._emit("post_close_resolved")
|
|
1273
|
+
return len(closed)
|
|
1274
|
+
|
|
1275
|
+
def _decisions_phase(self, plan: TriagePlan) -> tuple[dict[str, dict[str, str]], int]:
|
|
1276
|
+
from . import decisions as decisions_store # lazy: decisions imports sweep
|
|
1277
|
+
|
|
1278
|
+
decisions_path = self.run_dir / "decisions.json"
|
|
1279
|
+
# The project that OWNS `run_dir`, not `self.workspace.root`: under the
|
|
1280
|
+
# supported `repo_root` override (isolation = "none") the workspace root
|
|
1281
|
+
# is the separate code repo while the run dir — and the project-level
|
|
1282
|
+
# pre-answer store — stay under the PROJECT, so a workspace-rooted
|
|
1283
|
+
# confinement refused every write here and a workspace-rooted read
|
|
1284
|
+
# silently ignored the store. Derived from the run dir's own shape, which
|
|
1285
|
+
# no workspace swap moves.
|
|
1286
|
+
project_root = _project_of_run_dir(self.run_dir)
|
|
1287
|
+
answers: dict[str, dict[str, str]] = (
|
|
1288
|
+
_read_json(decisions_path) if decisions_path.is_file() else {}
|
|
1289
|
+
)
|
|
1290
|
+
closed = 0
|
|
1291
|
+
# Adopt out-of-band pre-answers (a human answered decisions an earlier
|
|
1292
|
+
# unattended/abandoned sweep left). The ledger edits were already applied
|
|
1293
|
+
# when they answered, so here we only take the answer onboard — this run
|
|
1294
|
+
# won't re-prompt/re-skip and build answers materialize into bundles.
|
|
1295
|
+
pre = decisions_store.load_pre_answers(project_root)
|
|
1296
|
+
seeded = False
|
|
1297
|
+
for decision in plan.decisions:
|
|
1298
|
+
if decision.id in answers or decision.id not in pre:
|
|
1299
|
+
continue
|
|
1300
|
+
answers[decision.id] = pre[decision.id]
|
|
1301
|
+
self.journal.append(
|
|
1302
|
+
"decision-preanswered",
|
|
1303
|
+
dw_id=decision.id,
|
|
1304
|
+
effect=pre[decision.id].get("effect"),
|
|
1305
|
+
)
|
|
1306
|
+
seeded = True
|
|
1307
|
+
if seeded:
|
|
1308
|
+
# Same helper as `decisions._write_store` (#363), but NOT for #363's
|
|
1309
|
+
# reason: `decisions_path` here is the PER-RUN file under
|
|
1310
|
+
# `.froid-loop/runs/<id>/`, which init gitignores, so a stranded temp
|
|
1311
|
+
# was never untracked and never held `worktree_clean` False. The
|
|
1312
|
+
# project-level `.froid-loop/decisions.json` is a different file with a
|
|
1313
|
+
# near-identical temp name — that is the exposed one. Taken anyway for
|
|
1314
|
+
# the fsync and the unique temp name, which two writers of one key
|
|
1315
|
+
# would otherwise collide on. Confined to the project root (#593):
|
|
1316
|
+
# replacing the NAME, as the bare replace did, left `.froid-loop/` and
|
|
1317
|
+
# `runs/` above it resolved by name, and a link planted at either
|
|
1318
|
+
# aimed this write out of the project. The root has to be the PROJECT
|
|
1319
|
+
# (`project_root` above; its comment says why not
|
|
1320
|
+
# `self.workspace.root`) — and not `self.run_dir` either: a file
|
|
1321
|
+
# confined against its own parent walks no components at all, which
|
|
1322
|
+
# would refuse nothing.
|
|
1323
|
+
atomic_write_text_confined(
|
|
1324
|
+
decisions_path,
|
|
1325
|
+
json.dumps(answers, indent=2),
|
|
1326
|
+
confine_root=project_root,
|
|
1327
|
+
)
|
|
1328
|
+
pending = [d for d in plan.decisions if d.id not in answers]
|
|
1329
|
+
answered_interactively = False
|
|
1330
|
+
if not self.prompting:
|
|
1331
|
+
pending = [d for d in pending if d.id not in self._skipped_decisions]
|
|
1332
|
+
for decision in pending:
|
|
1333
|
+
self.journal.append("decision-skipped-unattended", dw_id=decision.id)
|
|
1334
|
+
self._skipped_decisions.add(decision.id)
|
|
1335
|
+
if pending:
|
|
1336
|
+
gates.notify(
|
|
1337
|
+
self.policy,
|
|
1338
|
+
self.run_dir,
|
|
1339
|
+
f"{len(pending)} deferred-work decisions pending",
|
|
1340
|
+
"run `froid-loop sweep` interactively to answer them",
|
|
1341
|
+
)
|
|
1342
|
+
else:
|
|
1343
|
+
for decision in pending:
|
|
1344
|
+
# announce before blocking on input so observers (TUI, ATTENTION
|
|
1345
|
+
# watchers) can tell a sweep is waiting on a human
|
|
1346
|
+
self.journal.append(
|
|
1347
|
+
"decision-pending", dw_id=decision.id, question=decision.question
|
|
1348
|
+
)
|
|
1349
|
+
gates.notify(
|
|
1350
|
+
self.policy,
|
|
1351
|
+
self.run_dir,
|
|
1352
|
+
f"decision needed: {decision.id}",
|
|
1353
|
+
decision.question,
|
|
1354
|
+
)
|
|
1355
|
+
self._emit("pre_decision", story_key=decision.id)
|
|
1356
|
+
option = self.prompter.ask(decision)
|
|
1357
|
+
answers[decision.id] = {
|
|
1358
|
+
"key": option.key,
|
|
1359
|
+
"label": option.label,
|
|
1360
|
+
"effect": option.effect,
|
|
1361
|
+
"answered_at": self._today(),
|
|
1362
|
+
}
|
|
1363
|
+
atomic_write_text_confined( # same file, same reasoning as above (#363, #593)
|
|
1364
|
+
decisions_path,
|
|
1365
|
+
json.dumps(answers, indent=2),
|
|
1366
|
+
confine_root=project_root,
|
|
1367
|
+
)
|
|
1368
|
+
self.journal.append(
|
|
1369
|
+
"decision-answered",
|
|
1370
|
+
dw_id=decision.id,
|
|
1371
|
+
key=option.key,
|
|
1372
|
+
effect=option.effect,
|
|
1373
|
+
)
|
|
1374
|
+
self._apply_decision_effect(decision, option)
|
|
1375
|
+
self._emit("post_decision", story_key=decision.id, decision_action=option.effect)
|
|
1376
|
+
answered_interactively = True
|
|
1377
|
+
if option.effect == "close":
|
|
1378
|
+
closed += 1
|
|
1379
|
+
self._commit_ledger("chore(sweep): record deferred-work decisions")
|
|
1380
|
+
if answered_interactively:
|
|
1381
|
+
self._return_after_decisions()
|
|
1382
|
+
return answers, closed
|
|
1383
|
+
|
|
1384
|
+
def _return_after_decisions(self) -> None:
|
|
1385
|
+
"""Once the human has answered this cycle's decisions over an attached
|
|
1386
|
+
terminal, hand it back so the sweep runs its bundles in the background —
|
|
1387
|
+
detach a plain-shell client, switch a tmux client back to its origin. A
|
|
1388
|
+
plain foreground sweep (nobody attached, no return target) is untouched.
|
|
1389
|
+
|
|
1390
|
+
We then go unattended for the rest of the run: a later --repeat cycle's
|
|
1391
|
+
input() would otherwise block forever in a window no one is viewing. New
|
|
1392
|
+
decisions defer via the unattended path instead, recorded for
|
|
1393
|
+
`froid-loop decisions` or the next attended sweep.
|
|
1394
|
+
|
|
1395
|
+
The trigger for that is "nobody can be relied on to answer here any
|
|
1396
|
+
more", NOT "the hand-back succeeded" — the two come apart on a failed
|
|
1397
|
+
return, in opposite directions. A *refused* switch is evidence the
|
|
1398
|
+
client is still in this window with a human in front of it (ATTENDED:
|
|
1399
|
+
keep prompting, which is the whole point of #227). Everything else
|
|
1400
|
+
reports only that no hand-back was verified — a detach that found
|
|
1401
|
+
nothing attached, an effect the backend cannot observe, no detach verb
|
|
1402
|
+
at all, or a switch the backend cannot vouch for (a timed-out verb, an
|
|
1403
|
+
unreadable client count, nothing attached to move) — and under that
|
|
1404
|
+
uncertainty going unattended is the outcome that does not strand a
|
|
1405
|
+
--repeat cycle on input(); the decisions it defers stay reachable via
|
|
1406
|
+
`froid-loop decisions`. The `sweep-return-no-client` record keeps its
|
|
1407
|
+
name across that widening: it has always meant "no hand-back verified",
|
|
1408
|
+
which is what an unvouched switch reports too. Only a real return is
|
|
1409
|
+
announced: UNREACHABLE prints nothing, since there may be no one to
|
|
1410
|
+
read it."""
|
|
1411
|
+
from .tui import launch # import-light: launch.py has no textual imports
|
|
1412
|
+
|
|
1413
|
+
outcome = launch.return_attached_client()
|
|
1414
|
+
if outcome is launch.ReturnOutcome.ATTENDED:
|
|
1415
|
+
return
|
|
1416
|
+
self.prompting = False
|
|
1417
|
+
if outcome is launch.ReturnOutcome.RETURNED:
|
|
1418
|
+
self.journal.append("sweep-returned-after-decisions")
|
|
1419
|
+
self.prompter.print_fn("✓ decisions recorded — sweep continues in the background")
|
|
1420
|
+
else:
|
|
1421
|
+
self.journal.append("sweep-return-no-client")
|
|
1422
|
+
|
|
1423
|
+
def _apply_decision_effect(self, decision: Decision, option: DecisionOption) -> None:
|
|
1424
|
+
ledger = self.workspace.paths.deferred_work
|
|
1425
|
+
detail = option.resolution or option.intent
|
|
1426
|
+
close_note = None
|
|
1427
|
+
if option.effect == "close":
|
|
1428
|
+
close_note = "closed by human decision" + (
|
|
1429
|
+
f": {option.resolution}" if option.resolution else ""
|
|
1430
|
+
)
|
|
1431
|
+
# ONE locked read->edit->write (#286/#469). As the `append_decision` +
|
|
1432
|
+
# `mark_done` pair it was two acquisitions with a window between them, and
|
|
1433
|
+
# a rival writer landing there saw an entry whose decision line says "close
|
|
1434
|
+
# it" and whose status still says open — a human answer half-recorded. The
|
|
1435
|
+
# bytes are identical to the pair's: `record_decision` inserts the decision
|
|
1436
|
+
# line before it applies the close, which is the order the pair produced.
|
|
1437
|
+
deferredwork.record_decision(
|
|
1438
|
+
ledger, decision.id, self._today(), option.label, detail, close_note=close_note
|
|
1439
|
+
)
|
|
1440
|
+
|
|
1441
|
+
def _commit_ledger(self, message: str) -> None:
|
|
1442
|
+
"""Commit pending orchestrator ledger edits; bundles need a clean
|
|
1443
|
+
baseline. No-op when the tree is already clean."""
|
|
1444
|
+
if verify.worktree_clean(self.workspace.root):
|
|
1445
|
+
return
|
|
1446
|
+
sha = verify.commit_story(self.workspace.root, message)
|
|
1447
|
+
self.journal.append("sweep-ledger-commit", message=message, commit=sha)
|
|
1448
|
+
|
|
1449
|
+
# ---------------------------------------------------------- bundles
|
|
1450
|
+
|
|
1451
|
+
def _materialize_bundles(
|
|
1452
|
+
self, plan: TriagePlan, answers: dict[str, dict[str, str]]
|
|
1453
|
+
) -> list[Bundle]:
|
|
1454
|
+
self._emit("pre_materialize_bundles")
|
|
1455
|
+
bundles = list(plan.bundles)
|
|
1456
|
+
for decision in plan.decisions:
|
|
1457
|
+
answer = answers.get(decision.id)
|
|
1458
|
+
if not answer or answer.get("effect") != "build":
|
|
1459
|
+
continue
|
|
1460
|
+
# An in-run answer maps cleanly to a current option; a pre-answer
|
|
1461
|
+
# (answered out of band against an earlier triage) may not — a fresh
|
|
1462
|
+
# triage can renumber options — so fall back to the stored option
|
|
1463
|
+
# semantics carried in the answer itself.
|
|
1464
|
+
option = decision.option(str(answer.get("key")))
|
|
1465
|
+
intent = (option.intent if option else "") or str(answer.get("intent", ""))
|
|
1466
|
+
if not intent:
|
|
1467
|
+
continue
|
|
1468
|
+
label = (option.label if option else "") or str(answer.get("label", "")) or "build"
|
|
1469
|
+
bundle_name = (option.bundle_name if option else "") or str(
|
|
1470
|
+
answer.get("bundle_name", "")
|
|
1471
|
+
)
|
|
1472
|
+
# A pre-answer's bundle_name never passed `validate_triage` — it was
|
|
1473
|
+
# answered out of band against an earlier triage, and a fresh one can
|
|
1474
|
+
# renumber or drop the option it named — so this fallback lane was the
|
|
1475
|
+
# one route by which a name failing the two option-site gates (#637)
|
|
1476
|
+
# still reached `_write_intent` as a directory. Gate it with the same
|
|
1477
|
+
# two rules, but by DISCARD rather than by error: the human's build
|
|
1478
|
+
# decision is the payload and `decision-<id>` below is the always-legal
|
|
1479
|
+
# name it falls back to anyway, so the discard is journaled the way
|
|
1480
|
+
# `_normalize_bundle_names`'s repairs are and the sweep proceeds.
|
|
1481
|
+
if bundle_name and (
|
|
1482
|
+
not BUNDLE_NAME_RE.match(bundle_name) or safe_segment(bundle_name) != bundle_name
|
|
1483
|
+
):
|
|
1484
|
+
self.journal.append(
|
|
1485
|
+
"sweep-bundle-name-discarded",
|
|
1486
|
+
decision=decision.id,
|
|
1487
|
+
original=bundle_name,
|
|
1488
|
+
)
|
|
1489
|
+
bundle_name = ""
|
|
1490
|
+
key = (option.key if option else "") or str(answer.get("key", "")) or "?"
|
|
1491
|
+
name = bundle_name or "decision-" + decision.id.lower()
|
|
1492
|
+
bundles.append(
|
|
1493
|
+
Bundle(
|
|
1494
|
+
name=name,
|
|
1495
|
+
dw_ids=(decision.id,),
|
|
1496
|
+
intent=intent,
|
|
1497
|
+
decision_note=(
|
|
1498
|
+
f"The human chose option {key} ({label}) for the "
|
|
1499
|
+
f"question: {decision.question}"
|
|
1500
|
+
),
|
|
1501
|
+
)
|
|
1502
|
+
)
|
|
1503
|
+
# ids a prior bundle already failed on: re-triaging them would rebuild
|
|
1504
|
+
# the same hopeless bundle every repeat cycle (and a cached build-effect
|
|
1505
|
+
# decision answer would re-materialize its bundle each cycle)
|
|
1506
|
+
failed_ids = {
|
|
1507
|
+
i
|
|
1508
|
+
for t in self.state.tasks.values()
|
|
1509
|
+
if t.story_key.startswith("dw") and t.phase in (Phase.DEFERRED, Phase.ESCALATED)
|
|
1510
|
+
for i in t.dw_ids
|
|
1511
|
+
}
|
|
1512
|
+
# ids a human explicitly chose to keep open: a later triage must not
|
|
1513
|
+
# override that answer (bundle dev sessions mark their dw_ids done)
|
|
1514
|
+
keep_open_ids = {dw_id for dw_id, a in answers.items() if a.get("effect") == "keep-open"}
|
|
1515
|
+
kept = []
|
|
1516
|
+
for b in bundles:
|
|
1517
|
+
overlap = sorted(set(b.dw_ids) & (failed_ids | keep_open_ids))
|
|
1518
|
+
if overlap:
|
|
1519
|
+
self.journal.append(
|
|
1520
|
+
"sweep-bundle-skipped",
|
|
1521
|
+
name=b.name,
|
|
1522
|
+
dw_ids=overlap,
|
|
1523
|
+
reason=(
|
|
1524
|
+
"failed-or-escalated-earlier"
|
|
1525
|
+
if set(b.dw_ids) & failed_ids
|
|
1526
|
+
else "human-chose-keep-open"
|
|
1527
|
+
),
|
|
1528
|
+
)
|
|
1529
|
+
continue
|
|
1530
|
+
kept.append(b)
|
|
1531
|
+
bundles = kept
|
|
1532
|
+
if len(bundles) > self.max_bundles:
|
|
1533
|
+
dropped = [b.name for b in bundles[self.max_bundles :]]
|
|
1534
|
+
self.journal.append("sweep-bundles-truncated", dropped=dropped)
|
|
1535
|
+
bundles = bundles[: self.max_bundles]
|
|
1536
|
+
self._emit("post_materialize_bundles")
|
|
1537
|
+
return bundles
|
|
1538
|
+
|
|
1539
|
+
def _write_intent(self, bundle: Bundle, dirname: str) -> Path:
|
|
1540
|
+
ledger = self.workspace.paths.deferred_work
|
|
1541
|
+
text = ledger.read_text(encoding="utf-8") if ledger.is_file() else ""
|
|
1542
|
+
entries = {e.id: e for e in deferredwork.parse_ledger(text)}
|
|
1543
|
+
blocks = [entries[i].body.rstrip() for i in bundle.dw_ids if i in entries]
|
|
1544
|
+
lines = [
|
|
1545
|
+
f"# Deferred-work bundle: {bundle.name}",
|
|
1546
|
+
"",
|
|
1547
|
+
f"bundle_name: {bundle.name}",
|
|
1548
|
+
f"dw_ids: {', '.join(bundle.dw_ids)}",
|
|
1549
|
+
"",
|
|
1550
|
+
"## Intent",
|
|
1551
|
+
"",
|
|
1552
|
+
bundle.intent,
|
|
1553
|
+
]
|
|
1554
|
+
if bundle.decision_note:
|
|
1555
|
+
lines += ["", "## Human decision", "", bundle.decision_note]
|
|
1556
|
+
lines += ["", "## Ledger entries (verbatim)", "", "\n\n".join(blocks), ""]
|
|
1557
|
+
path = self.run_dir / "bundles" / dirname / "intent.md"
|
|
1558
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
1559
|
+
# Surrogates are neutralized over the whole document, not per field: the
|
|
1560
|
+
# triage-authored `intent`/`decision_note` are the ones that can revive one
|
|
1561
|
+
# (#329), but a document-wide pass covers whatever prose is added here
|
|
1562
|
+
# later. Line breaks are deliberately *kept* — this file is markdown, so
|
|
1563
|
+
# `_one_line`'s collapse would be damage, and the ledger blocks are read
|
|
1564
|
+
# back from a strict-UTF-8 file and so pass through byte-unchanged.
|
|
1565
|
+
atomic_write_text(path, neutralize_surrogates("\n".join(lines)))
|
|
1566
|
+
return path
|
|
1567
|
+
|
|
1568
|
+
def _ensure_bundle_intent(self, task: StoryTask) -> None:
|
|
1569
|
+
"""Guarantee a recovered bundle has the intent file its dev prompt points
|
|
1570
|
+
at. The rendered intent.md persists in the run dir and the prompt consumes
|
|
1571
|
+
nothing else from the plan, so the normal case is to reuse it untouched.
|
|
1572
|
+
|
|
1573
|
+
Only when it is gone do we rebuild a degraded one from the task itself.
|
|
1574
|
+
The triage session's authored intent prose is the single unrecoverable
|
|
1575
|
+
piece; the verbatim ledger entries _write_intent re-attaches carry the
|
|
1576
|
+
actual work, so say plainly that they are now the contract."""
|
|
1577
|
+
if task.bundle_file and Path(task.bundle_file).is_file():
|
|
1578
|
+
return
|
|
1579
|
+
match = BUNDLE_KEY_RE.match(task.story_key)
|
|
1580
|
+
if match is None: # pragma: no cover - callers filter on BUNDLE_KEY_RE
|
|
1581
|
+
return
|
|
1582
|
+
cycle = int(match.group(1)) if match.group(1) else 1
|
|
1583
|
+
name = match.group(2)
|
|
1584
|
+
bundle = Bundle(
|
|
1585
|
+
name=name,
|
|
1586
|
+
dw_ids=tuple(task.dw_ids),
|
|
1587
|
+
intent=(
|
|
1588
|
+
"Resolve the deferred-work entries reproduced below. This bundle's "
|
|
1589
|
+
"original triage intent did not survive the run it was written in, "
|
|
1590
|
+
"so the verbatim ledger entries are the authoritative statement of "
|
|
1591
|
+
"the work."
|
|
1592
|
+
),
|
|
1593
|
+
)
|
|
1594
|
+
dirname = name if cycle == 1 else f"c{cycle}-{name}"
|
|
1595
|
+
task.bundle_file = str(self._write_intent(bundle, dirname))
|
|
1596
|
+
self.journal.append(
|
|
1597
|
+
"sweep-intent-regenerated",
|
|
1598
|
+
story_key=task.story_key,
|
|
1599
|
+
dw_ids=list(task.dw_ids),
|
|
1600
|
+
path=task.bundle_file,
|
|
1601
|
+
)
|
|
1602
|
+
|
|
1603
|
+
# ------------------------------------------------------ override seams
|
|
1604
|
+
|
|
1605
|
+
def _dispatched_spec_for_attempt(self, task: StoryTask) -> str | None:
|
|
1606
|
+
"""Sweep dispatch owns intent.md, never an accepted bundle spec."""
|
|
1607
|
+
return None
|
|
1608
|
+
|
|
1609
|
+
def _requires_dispatched_spec_snapshot(self, task: StoryTask, prompt: str) -> bool:
|
|
1610
|
+
"""Keep explicit bundle-spec routing separate from recovery ownership.
|
|
1611
|
+
|
|
1612
|
+
Repair and patch-restore prompts name the accepted spec so deterministic
|
|
1613
|
+
read-back follows it, but Sweep still owns ``intent.md`` as its dispatched
|
|
1614
|
+
input and must never promote that result artifact into attempt ownership.
|
|
1615
|
+
"""
|
|
1616
|
+
return False
|
|
1617
|
+
|
|
1618
|
+
def _retains_dispatched_spec_snapshot_on_repair(self) -> bool:
|
|
1619
|
+
"""Sweep repairs remain owned by intent.md, not the accepted spec."""
|
|
1620
|
+
return False
|
|
1621
|
+
|
|
1622
|
+
def _dev_prompt(self, task: StoryTask, feedback: Path | None) -> str:
|
|
1623
|
+
return self._generic_bundle_prompt(task, feedback)
|
|
1624
|
+
|
|
1625
|
+
def _generic_bundle_prompt(self, task: StoryTask, feedback: Path | None) -> str:
|
|
1626
|
+
"""Bundle invocation for the generic dev primitive (disk-resolved, see
|
|
1627
|
+
``Engine._dev_skill``): the self-contained
|
|
1628
|
+
intent.md (intent + verbatim ledger entries) is handed over as freeform
|
|
1629
|
+
intent. The orchestrator owns the deferred-work ledger — the skill is told
|
|
1630
|
+
not to edit it — and records resolution itself in `_post_dev_accepted_sync`.
|
|
1631
|
+
On a repair the bundle spec is re-opened first (B6) so step-01 resumes.
|
|
1632
|
+
|
|
1633
|
+
A patch-restore re-drive (#2564, #75) must point at the bundle spec
|
|
1634
|
+
explicitly: only step-01's spec-pointer intent check EARLY EXITs on the
|
|
1635
|
+
`in-review` status the re-arm set — before step-01's version-control
|
|
1636
|
+
sanity check, which would otherwise HALT `blocked` on the diff
|
|
1637
|
+
`_restore_patch` just laid onto the tree. The freeform intent.md pointer
|
|
1638
|
+
takes the path where that dirty-tree check runs first."""
|
|
1639
|
+
bundle_ref = task.bundle_file or task.story_key
|
|
1640
|
+
if feedback is None:
|
|
1641
|
+
if task.restore_patch and task.spec_file:
|
|
1642
|
+
return (
|
|
1643
|
+
f"/{self._dev_skill()} Resume review of the in-review spec at "
|
|
1644
|
+
f"`{task.spec_file}` for the deferred-work bundle `{bundle_ref}`. "
|
|
1645
|
+
f"The attempted change was restored onto the working tree after "
|
|
1646
|
+
f"an intent-gap resolution; review it against the amended spec. "
|
|
1647
|
+
f"Do NOT edit the deferred-work ledger; the orchestrator records "
|
|
1648
|
+
f"resolution."
|
|
1649
|
+
)
|
|
1650
|
+
return (
|
|
1651
|
+
f"/{self._dev_skill()} Implement the deferred-work bundle described in "
|
|
1652
|
+
f"`{bundle_ref}` — it carries the intent and the verbatim ledger "
|
|
1653
|
+
f"entries to resolve. Do NOT edit the deferred-work ledger; the "
|
|
1654
|
+
f"orchestrator records resolution."
|
|
1655
|
+
)
|
|
1656
|
+
self._reset_spec_for_repair(task)
|
|
1657
|
+
spec_ref = task.spec_file or bundle_ref
|
|
1658
|
+
return (
|
|
1659
|
+
f"/{self._dev_skill()} Resume the autonomous dev session on the in-progress "
|
|
1660
|
+
f"spec at `{spec_ref}` for the deferred-work bundle `{bundle_ref}`. The "
|
|
1661
|
+
f"previous session's work failed deterministic verification; repair the "
|
|
1662
|
+
f"working tree so verification passes without changing the frozen intent "
|
|
1663
|
+
f"contract or editing the deferred-work ledger. Verification evidence is "
|
|
1664
|
+
f"in `{feedback}`."
|
|
1665
|
+
)
|
|
1666
|
+
|
|
1667
|
+
def _post_dev_state_sync(self, task: StoryTask, result_json: dict | None) -> None:
|
|
1668
|
+
"""No-op: bundles have no sprint-status row for the pre-gate sync.
|
|
1669
|
+
|
|
1670
|
+
This override and the accepted-only override below are one behavior
|
|
1671
|
+
change. Leaving the former close here as well would run bundle closure
|
|
1672
|
+
twice at two different gate positions.
|
|
1673
|
+
"""
|
|
1674
|
+
return
|
|
1675
|
+
|
|
1676
|
+
def _post_dev_accepted_sync(self, task: StoryTask, result_json: dict | None) -> None:
|
|
1677
|
+
"""Generic-path ledger single-writer for bundles. The decoupled
|
|
1678
|
+
froid-build-auto skill does not touch the ledger, so the orchestrator marks
|
|
1679
|
+
each dw id the bundle owns ``done`` once the bundle's spec reaches the
|
|
1680
|
+
terminal status for the current stage. No-op on the legacy path.
|
|
1681
|
+
|
|
1682
|
+
This runs only after the artifact gate, verify commands, and ``decide_dev``
|
|
1683
|
+
have accepted the attempt. In particular, ``outcome.ok`` is insufficient:
|
|
1684
|
+
a CRITICAL escalation in the session result preempts that outcome. The
|
|
1685
|
+
review gate later requires these entries closed; ``_verify_review`` retains
|
|
1686
|
+
its separate reclose because a review session can rewrite the ledger.
|
|
1687
|
+
"""
|
|
1688
|
+
if not self._generic_dev():
|
|
1689
|
+
return
|
|
1690
|
+
spec_file = (result_json or {}).get("spec_file")
|
|
1691
|
+
if not spec_file:
|
|
1692
|
+
return
|
|
1693
|
+
success_status = "in-review" if self._dev_review_enabled() else "done"
|
|
1694
|
+
self._close_bundle_ledger_when_spec_status(task, str(spec_file), success_status)
|
|
1695
|
+
|
|
1696
|
+
def _bundle_close_operation_id(self, task: StoryTask) -> str:
|
|
1697
|
+
"""Stable identity for a close and its possible defer-time undo."""
|
|
1698
|
+
return f"{self.state.run_id}/{task.story_key}"
|
|
1699
|
+
|
|
1700
|
+
def _bundle_close_note(self, task: StoryTask) -> str:
|
|
1701
|
+
"""Resolution note shared by a bundle close and its possible undo."""
|
|
1702
|
+
return f"resolved by sweep bundle {task.story_key}"
|
|
1703
|
+
|
|
1704
|
+
def _close_declared_deferred(
|
|
1705
|
+
self, task: StoryTask, snapshot: list[_ArmedClose] | None = None
|
|
1706
|
+
) -> None:
|
|
1707
|
+
"""No-op: a bundle's ledger closure is owned by
|
|
1708
|
+
``_close_bundle_ledger_when_spec_status``, which runs after accepted dev
|
|
1709
|
+
because ``verify_review_bundle`` *requires* those entries closed before the
|
|
1710
|
+
later commit boundary. Letting the base class's commit-boundary hook (#234)
|
|
1711
|
+
also fire here would re-derive closure for a task whose ids come from
|
|
1712
|
+
``task.dw_ids``, not from a ``closes_deferred:`` declaration."""
|
|
1713
|
+
|
|
1714
|
+
def _close_bundle_ledger_when_spec_status(
|
|
1715
|
+
self,
|
|
1716
|
+
task: StoryTask,
|
|
1717
|
+
spec_file: str,
|
|
1718
|
+
success_status: str,
|
|
1719
|
+
kind: str = "sweep-bundle-closed",
|
|
1720
|
+
) -> None:
|
|
1721
|
+
spec_path = verify.resolve_spec_path(spec_file, self.workspace.paths)
|
|
1722
|
+
if not spec_path.is_file():
|
|
1723
|
+
return
|
|
1724
|
+
fm = self._observed_frontmatter(spec_path, task.story_key, "bundle-ledger-close")
|
|
1725
|
+
if fm is None:
|
|
1726
|
+
return
|
|
1727
|
+
if verify.status_of(fm) != success_status:
|
|
1728
|
+
return
|
|
1729
|
+
ledger = self.workspace.paths.deferred_work
|
|
1730
|
+
note = self._bundle_close_note(task)
|
|
1731
|
+
# Record the intended ids, never only `marked`. This method is called once
|
|
1732
|
+
# after accepted dev and again by the review-leg reclose. The second call
|
|
1733
|
+
# normally finds every entry already done, so `marked` is empty; deriving
|
|
1734
|
+
# the record from it would erase exactly the state a landing bundle needs.
|
|
1735
|
+
task.bundle_closes_intended = list(task.dw_ids)
|
|
1736
|
+
marked = deferredwork.mark_done_many_reopenable(
|
|
1737
|
+
ledger,
|
|
1738
|
+
task.dw_ids,
|
|
1739
|
+
self._today(),
|
|
1740
|
+
note,
|
|
1741
|
+
self._bundle_close_operation_id(task),
|
|
1742
|
+
)
|
|
1743
|
+
if marked:
|
|
1744
|
+
self.journal.append(kind, story_key=task.story_key, dw_ids=marked)
|
|
1745
|
+
|
|
1746
|
+
def _reopen_ledger_after_defer(self, task: StoryTask) -> None:
|
|
1747
|
+
"""Reopen only this run's bundle closes after its code was discarded.
|
|
1748
|
+
|
|
1749
|
+
``Engine._defer`` deliberately restores the whole post-review ledger after
|
|
1750
|
+
rollback so harvested findings survive. That restore can also replay a
|
|
1751
|
+
bundle close whose code no longer exists. The operation-specific undo marker
|
|
1752
|
+
makes this "undo my close", not "open these ids": human, legacy, and earlier
|
|
1753
|
+
run closures remain untouched. Replaying this method is idempotent.
|
|
1754
|
+
"""
|
|
1755
|
+
ledger = self.workspace.paths.deferred_work
|
|
1756
|
+
note = self._bundle_close_note(task)
|
|
1757
|
+
operation_id = self._bundle_close_operation_id(task)
|
|
1758
|
+
# ONE locked read->edit->write (#286/#469): the per-id `mark_open`
|
|
1759
|
+
# comprehension this replaces took the lock once per id, and a rollback
|
|
1760
|
+
# that leaves some closes undone and others standing is the one shape this
|
|
1761
|
+
# method exists to prevent. Order and skip semantics are `mark_open_many`'s
|
|
1762
|
+
# own, which are the comprehension's.
|
|
1763
|
+
reopened = deferredwork.mark_open_many(ledger, list(task.dw_ids), note, operation_id)
|
|
1764
|
+
if reopened:
|
|
1765
|
+
self.journal.append("sweep-bundle-reopened", story_key=task.story_key, dw_ids=reopened)
|
|
1766
|
+
|
|
1767
|
+
def _carry_isolated_ledger_writes(self, task: StoryTask) -> None:
|
|
1768
|
+
"""The base hook's sweep half: re-apply this bundle's ledger CLOSES to the
|
|
1769
|
+
MAIN checkout after an isolated unit lands, the base having first carried
|
|
1770
|
+
the harvest.
|
|
1771
|
+
|
|
1772
|
+
``super()`` runs FIRST, and that is a contract rather than a style choice.
|
|
1773
|
+
``deferredwork.append_entry``'s idempotence scan is OPEN-ONLY, so a close
|
|
1774
|
+
applied ahead of the harvest hides an already-filed row from it and mints a
|
|
1775
|
+
duplicate under a fresh id. The base hook now ends with a CLOSE of its own
|
|
1776
|
+
(``_carry_story_deferred_closes``, #458) and still satisfies the rule, since
|
|
1777
|
+
both of its appends precede it; the two closes never coexist on one task,
|
|
1778
|
+
because ``_close_declared_deferred`` is a no-op here and a story run has no
|
|
1779
|
+
bundle. ``_carry_harvested_deferrals`` defends the same
|
|
1780
|
+
hazard a second time with its own status-agnostic pre-scan, which is exactly
|
|
1781
|
+
why the order is pinned by a test: with that second line of defence in place
|
|
1782
|
+
a reversal is silent today and would only surface if the pre-scan were ever
|
|
1783
|
+
narrowed back to the writer's semantics.
|
|
1784
|
+
|
|
1785
|
+
``_close_bundle_ledger_when_spec_status`` writes
|
|
1786
|
+
``self.workspace.paths.deferred_work`` — under ``scm.isolation = "worktree"``
|
|
1787
|
+
that is the unit worktree's copy. The shape this rescues is a GITIGNORED
|
|
1788
|
+
ledger named in ``scm.worktree_seed``: the flip lands in the worktree, then
|
|
1789
|
+
``finalize_commit``'s ``git add -A`` skips the ignored path in silence, so it
|
|
1790
|
+
never rides the unit branch and the merge brings nothing over. The bundle's
|
|
1791
|
+
entries stay ``open``, ``deferredwork.open_ids`` re-bundles them, and every
|
|
1792
|
+
later sweep re-triages work that is already done — an unbounded loop rather
|
|
1793
|
+
than a one-time drop, which is what makes this worth a carry.
|
|
1794
|
+
|
|
1795
|
+
An UNSEEDED gitignored ledger is a different, still-open hole this cannot
|
|
1796
|
+
reach: a worktree checks out tracked files only, so the ledger is absent
|
|
1797
|
+
there entirely, ``verify_review_bundle`` (which reads the WORKTREE's copy)
|
|
1798
|
+
never sees the ids ``done``, and the unit DEFERS on a fixable retry instead
|
|
1799
|
+
of landing. That shape is loud where this one is silent, and no DONE-leg
|
|
1800
|
+
carry helps a unit that never reaches DONE (#426).
|
|
1801
|
+
|
|
1802
|
+
DONE leg only, deliberately: ``Engine._defer``'s isolated arm calls
|
|
1803
|
+
``_carry_harvested_deferrals`` directly and never this hook, and
|
|
1804
|
+
``_replay_unlatched_ledger_carries``'s DEFERRED leg matches it for the same
|
|
1805
|
+
reason. A defer discarded the code the close claims to have RESOLVED, and a
|
|
1806
|
+
close is the most expensive engine-side write to leave behind — ``open_ids``
|
|
1807
|
+
re-bundles only ``open`` entries, so a wrong ``done`` is invisible to every
|
|
1808
|
+
future sweep.
|
|
1809
|
+
|
|
1810
|
+
Keyed on ``task.bundle_closes_intended``, never on the ids the in-worktree
|
|
1811
|
+
close managed to flip: those are exactly empty in the broken case above.
|
|
1812
|
+
The close is written reopenable, with the same note and operation id as the
|
|
1813
|
+
in-worktree close, so a carried row is indistinguishable from one that
|
|
1814
|
+
arrived through the merge instead of a second row shape for one event.
|
|
1815
|
+
|
|
1816
|
+
No ``_generic_dev()`` guard, unlike the two ledger WRITERS: the record is
|
|
1817
|
+
the guard. ``bundle_closes_intended`` is assigned only by
|
|
1818
|
+
``_close_bundle_ledger_when_spec_status``, which ``_post_dev_accepted_sync``
|
|
1819
|
+
reaches on the generic path alone, so on the legacy path — where the session
|
|
1820
|
+
owns the ledger — it is empty and this returns before touching anything. A
|
|
1821
|
+
second predicate saying the same thing would be a branch no test can redden.
|
|
1822
|
+
|
|
1823
|
+
The commit is best effort, where ``_carry_harvested_deferrals`` re-raises on
|
|
1824
|
+
a ledger git can own. The asymmetry is deliberate: that method's raise is
|
|
1825
|
+
backed by ``harvest_carry_commit_pending``, so a replay still owes the commit
|
|
1826
|
+
after dedup empties its carried list. This carry has no such latch and its
|
|
1827
|
+
flips are idempotent, so a replay finds nothing left to commit — raising here
|
|
1828
|
+
would cost the run its ``integrate_unit`` over bookkeeping whose real work is
|
|
1829
|
+
already done. The flips themselves are unguarded: losing them is the hazard
|
|
1830
|
+
this exists to prevent.
|
|
1831
|
+
"""
|
|
1832
|
+
super()._carry_isolated_ledger_writes(task)
|
|
1833
|
+
if not task.bundle_closes_intended:
|
|
1834
|
+
return
|
|
1835
|
+
ledger = self.paths.deferred_work
|
|
1836
|
+
carried = deferredwork.mark_done_many_reopenable(
|
|
1837
|
+
ledger,
|
|
1838
|
+
task.bundle_closes_intended,
|
|
1839
|
+
self._today(),
|
|
1840
|
+
self._bundle_close_note(task),
|
|
1841
|
+
self._bundle_close_operation_id(task),
|
|
1842
|
+
)
|
|
1843
|
+
if carried:
|
|
1844
|
+
try:
|
|
1845
|
+
verify.commit_paths(
|
|
1846
|
+
self.paths.repo_root,
|
|
1847
|
+
f"chore(deferred-work): close {task.story_key}'s bundle ids",
|
|
1848
|
+
[ledger],
|
|
1849
|
+
)
|
|
1850
|
+
except verify.GitError as e:
|
|
1851
|
+
self.journal.append(
|
|
1852
|
+
"sweep-bundle-close-carry-uncommitted",
|
|
1853
|
+
story_key=task.story_key,
|
|
1854
|
+
dw_ids=carried,
|
|
1855
|
+
error=str(e),
|
|
1856
|
+
)
|
|
1857
|
+
self.journal.append("sweep-bundle-close-carried", story_key=task.story_key, dw_ids=carried)
|
|
1858
|
+
|
|
1859
|
+
def _verify_dev_artifacts(self, task: StoryTask, result_json: dict | None):
|
|
1860
|
+
return verify.verify_dev_bundle(
|
|
1861
|
+
task,
|
|
1862
|
+
self.workspace.paths,
|
|
1863
|
+
result_json,
|
|
1864
|
+
review_enabled=self._dev_review_enabled(),
|
|
1865
|
+
engine_written=self._harvest_gate_exclude(task),
|
|
1866
|
+
)
|
|
1867
|
+
|
|
1868
|
+
def _verify_review(self, task: StoryTask):
|
|
1869
|
+
# Generic bundle dev sessions are told not to edit deferred-work.md; the
|
|
1870
|
+
# orchestrator is the ledger writer. A follow-up review can rewrite the
|
|
1871
|
+
# ledger from its own snapshot and re-open entries that were already
|
|
1872
|
+
# closed after dev. Re-apply that idempotent closure immediately before
|
|
1873
|
+
# verify_review_bundle requires those entries. The distinct journal kind
|
|
1874
|
+
# makes "a review rewrote the ledger" greppable when diagnosing runs.
|
|
1875
|
+
if self._generic_dev() and task.spec_file:
|
|
1876
|
+
self._close_bundle_ledger_when_spec_status(
|
|
1877
|
+
task, task.spec_file, "done", kind="sweep-bundle-reclosed"
|
|
1878
|
+
)
|
|
1879
|
+
return verify.verify_review_bundle(task, self.workspace.paths, self.policy)
|
|
1880
|
+
|
|
1881
|
+
def _operator_park_enabled(self) -> bool:
|
|
1882
|
+
# A bundle carries no sprint-status entry, so the pair a park is verified
|
|
1883
|
+
# against does not exist, and `verify_review_bundle` gates on closed dw
|
|
1884
|
+
# ids instead. Whether a deferred-work bundle can owe a human action is a
|
|
1885
|
+
# separate question from whether a story can; not answered here.
|
|
1886
|
+
return False
|
|
1887
|
+
|
|
1888
|
+
def _commit_message(self, task: StoryTask) -> str:
|
|
1889
|
+
rendered = self._render_commit_template(task)
|
|
1890
|
+
if rendered is not None:
|
|
1891
|
+
return rendered
|
|
1892
|
+
return f"sweep {task.story_key}: {', '.join(task.dw_ids)} via froid-loop"
|