gooseloop 0.1.0__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.
- gooseloop/__init__.py +42 -0
- gooseloop/__main__.py +181 -0
- gooseloop/artifact.py +82 -0
- gooseloop/branch_policy.py +49 -0
- gooseloop/config.py +104 -0
- gooseloop/context_prepend.py +316 -0
- gooseloop/contrib/__init__.py +18 -0
- gooseloop/contrib/claude_handoff.py +74 -0
- gooseloop/contrib/customer_pipeline.py +98 -0
- gooseloop/engine.py +68 -0
- gooseloop/environment.py +31 -0
- gooseloop/extract.py +200 -0
- gooseloop/footer.py +75 -0
- gooseloop/goose.py +340 -0
- gooseloop/looper.py +581 -0
- gooseloop/phase.py +146 -0
- gooseloop/predicates.py +69 -0
- gooseloop/protocol.py +197 -0
- gooseloop/recipe_merge.py +152 -0
- gooseloop/session.py +41 -0
- gooseloop/text.py +60 -0
- gooseloop/toolkit.py +243 -0
- gooseloop-0.1.0.dist-info/METADATA +169 -0
- gooseloop-0.1.0.dist-info/RECORD +28 -0
- gooseloop-0.1.0.dist-info/WHEEL +5 -0
- gooseloop-0.1.0.dist-info/entry_points.txt +2 -0
- gooseloop-0.1.0.dist-info/licenses/LICENSE +21 -0
- gooseloop-0.1.0.dist-info/top_level.txt +1 -0
gooseloop/looper.py
ADDED
|
@@ -0,0 +1,581 @@
|
|
|
1
|
+
"""GooseLooper — drive an Engine's Pipeline for one pass.
|
|
2
|
+
|
|
3
|
+
Order per ADR 0006:
|
|
4
|
+
|
|
5
|
+
1. Build Context (model, session dir, base_env, environment).
|
|
6
|
+
2. engine.precheck(ctx). Raise aborts the pass.
|
|
7
|
+
3. pipeline = engine.pipeline(ctx). Must be a Pipeline dataclass.
|
|
8
|
+
4. Run review FIRST. The framework wraps the engine's review post_process
|
|
9
|
+
to also parse the deliverable JSON, validate the schema, build
|
|
10
|
+
child phases from routing[] via engine.branch_policies, and seed
|
|
11
|
+
the operator_actions ledger from the review's payload.
|
|
12
|
+
5. Drain the body queue. Review-spawned children sit at HEAD; engine
|
|
13
|
+
cadence phases follow. Body phase post_process may enqueue more.
|
|
14
|
+
6. Run summary LAST, with the full ledger and outputs available via ctx.
|
|
15
|
+
7. Print session footer.
|
|
16
|
+
|
|
17
|
+
The looper knows nothing about prospects, scoring, or any engine concept.
|
|
18
|
+
"""
|
|
19
|
+
|
|
20
|
+
import dataclasses
|
|
21
|
+
import os
|
|
22
|
+
import sys
|
|
23
|
+
import time
|
|
24
|
+
from collections import deque
|
|
25
|
+
from pathlib import Path
|
|
26
|
+
from typing import Any, Callable, Optional
|
|
27
|
+
|
|
28
|
+
from .branch_policy import BranchPolicy
|
|
29
|
+
from .config import LooperConfig
|
|
30
|
+
from .engine import Engine
|
|
31
|
+
from .environment import Environment
|
|
32
|
+
from .footer import print_session_footer
|
|
33
|
+
from .goose import run_goose_with_retry
|
|
34
|
+
from .phase import Context, Phase, Pipeline
|
|
35
|
+
from .predicates import file_nonempty
|
|
36
|
+
from .protocol import (
|
|
37
|
+
PROTOCOL_VERSION,
|
|
38
|
+
OperatorAction,
|
|
39
|
+
ProtocolVersionError,
|
|
40
|
+
ReviewOutput,
|
|
41
|
+
RoutingEntry,
|
|
42
|
+
validate_review,
|
|
43
|
+
)
|
|
44
|
+
from .session import log_step, new_session
|
|
45
|
+
from .extract import extract_json_with_provenance
|
|
46
|
+
from .text import Color, banner, colored
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
class GooseLooper:
|
|
50
|
+
"""Execution shell for an Engine's Pipeline.
|
|
51
|
+
|
|
52
|
+
Usage:
|
|
53
|
+
GooseLooper(engine=MyEngine(), environment=MyEnv()).begin_loop()
|
|
54
|
+
|
|
55
|
+
Parameters:
|
|
56
|
+
engine: the Engine instance driving this pass.
|
|
57
|
+
environment: the Environment instance (None = engine recipes that
|
|
58
|
+
never reference env_method are still runnable).
|
|
59
|
+
config: LooperConfig instance. None = LooperConfig.load() from cwd.
|
|
60
|
+
model: override the model. Falls back to engine.default_model(),
|
|
61
|
+
then to config.default_model.
|
|
62
|
+
save: write a session folder (default True).
|
|
63
|
+
validate: run engine.precheck() before the pipeline (default True).
|
|
64
|
+
review_only: stop after the review phase (default False).
|
|
65
|
+
review_overlays: list of CLI overlay paths for the review recipe.
|
|
66
|
+
summary_overlays: list of CLI overlay paths for the summary recipe.
|
|
67
|
+
"""
|
|
68
|
+
|
|
69
|
+
def __init__(
|
|
70
|
+
self,
|
|
71
|
+
engine: Engine,
|
|
72
|
+
*,
|
|
73
|
+
environment: Optional[Environment] = None,
|
|
74
|
+
config: Optional[LooperConfig] = None,
|
|
75
|
+
model: Optional[str] = None,
|
|
76
|
+
save: bool = True,
|
|
77
|
+
validate: bool = True,
|
|
78
|
+
review_only: bool = False,
|
|
79
|
+
review_overlays: Optional[list[Path]] = None,
|
|
80
|
+
summary_overlays: Optional[list[Path]] = None,
|
|
81
|
+
):
|
|
82
|
+
self.engine = engine
|
|
83
|
+
self.environment = environment
|
|
84
|
+
self.config = config or LooperConfig.load()
|
|
85
|
+
self.model = model or engine.default_model() or self.config.default_model
|
|
86
|
+
self.save = save
|
|
87
|
+
self.validate = validate
|
|
88
|
+
self.review_only = review_only
|
|
89
|
+
self.review_overlays = review_overlays or []
|
|
90
|
+
self.summary_overlays = summary_overlays or []
|
|
91
|
+
|
|
92
|
+
# ------------------------------------------------------------------
|
|
93
|
+
# public entry
|
|
94
|
+
|
|
95
|
+
def begin_loop(self) -> dict[str, Any]:
|
|
96
|
+
"""Run one pipeline pass. Returns accounting summary."""
|
|
97
|
+
runner_start = time.perf_counter()
|
|
98
|
+
goose_calls = 0
|
|
99
|
+
actions_ran = 0
|
|
100
|
+
actions_skipped = 0
|
|
101
|
+
|
|
102
|
+
session_dir = (new_session(self.config.sessions_dir, self.model, self.engine.name)
|
|
103
|
+
if self.save else None)
|
|
104
|
+
|
|
105
|
+
env_paths = self.environment.env_vars() if self.environment else {}
|
|
106
|
+
ctx = Context(
|
|
107
|
+
model=self.model,
|
|
108
|
+
session_dir=session_dir,
|
|
109
|
+
base_env={**env_paths, **self.engine.base_env()},
|
|
110
|
+
environment=self.environment,
|
|
111
|
+
)
|
|
112
|
+
|
|
113
|
+
if self.validate:
|
|
114
|
+
banner(f"{self.engine.name}: precheck", Color.CYAN)
|
|
115
|
+
try:
|
|
116
|
+
self.engine.precheck(ctx)
|
|
117
|
+
except Exception as e:
|
|
118
|
+
print(colored(f"\nPrecheck failed: {e}", Color.RED), file=sys.stderr)
|
|
119
|
+
raise
|
|
120
|
+
|
|
121
|
+
pipeline = self.engine.pipeline(ctx)
|
|
122
|
+
if not isinstance(pipeline, Pipeline):
|
|
123
|
+
raise TypeError(
|
|
124
|
+
f"Engine.pipeline() must return Pipeline, got {type(pipeline).__name__}"
|
|
125
|
+
)
|
|
126
|
+
|
|
127
|
+
# Step + planned counters. Methods bump them; banners read them
|
|
128
|
+
# to render `[step/planned]` progress in the phase header.
|
|
129
|
+
self._step = 0
|
|
130
|
+
self._planned = 1 + len(pipeline.body) + (1 if pipeline.summary else 0)
|
|
131
|
+
|
|
132
|
+
body_queue: deque[Phase] = deque()
|
|
133
|
+
|
|
134
|
+
# --- 1. review ------------------------------------------------
|
|
135
|
+
review_status = "done"
|
|
136
|
+
review_output: ReviewOutput | None = None
|
|
137
|
+
|
|
138
|
+
try:
|
|
139
|
+
review_calls, review_ok, review_status, review_output = self._run_review(
|
|
140
|
+
pipeline.review, ctx, body_queue,
|
|
141
|
+
)
|
|
142
|
+
goose_calls += review_calls
|
|
143
|
+
# Children the review spawned via routing[] need to count as
|
|
144
|
+
# planned work. Read straight from the queue before any cadence
|
|
145
|
+
# phases get appended.
|
|
146
|
+
self._planned += len(body_queue)
|
|
147
|
+
if review_ok:
|
|
148
|
+
actions_ran += 1
|
|
149
|
+
else:
|
|
150
|
+
actions_skipped += 1
|
|
151
|
+
except Exception as e:
|
|
152
|
+
print(colored(
|
|
153
|
+
f"\nReview failed: {type(e).__name__}: {e}",
|
|
154
|
+
Color.RED,
|
|
155
|
+
), file=sys.stderr)
|
|
156
|
+
if os.environ.get("GOOSELOOP_DEBUG"):
|
|
157
|
+
import traceback
|
|
158
|
+
traceback.print_exc(file=sys.stderr)
|
|
159
|
+
actions_skipped += 1
|
|
160
|
+
review_status = "error"
|
|
161
|
+
|
|
162
|
+
# --- 2. body --------------------------------------------------
|
|
163
|
+
if self.review_only:
|
|
164
|
+
if session_dir:
|
|
165
|
+
log_step(session_dir, "review-only: skipping body and summary")
|
|
166
|
+
elif review_status == "error":
|
|
167
|
+
if session_dir:
|
|
168
|
+
log_step(session_dir, "review status=error: skipping body and summary")
|
|
169
|
+
actions_skipped += len(pipeline.body) + (1 if pipeline.summary else 0)
|
|
170
|
+
else:
|
|
171
|
+
# Engine-declared cadence phases follow review-spawned children.
|
|
172
|
+
body_queue.extend(pipeline.body)
|
|
173
|
+
body_calls, body_ran, body_skipped = self._drain_body(body_queue, ctx)
|
|
174
|
+
goose_calls += body_calls
|
|
175
|
+
actions_ran += body_ran
|
|
176
|
+
actions_skipped += body_skipped
|
|
177
|
+
|
|
178
|
+
# --- 3. summary -------------------------------------------
|
|
179
|
+
if pipeline.summary is not None and review_status != "partial":
|
|
180
|
+
try:
|
|
181
|
+
summary_calls, summary_ok = self._run_phase(
|
|
182
|
+
pipeline.summary, ctx,
|
|
183
|
+
overlays=self.summary_overlays,
|
|
184
|
+
is_summary=True,
|
|
185
|
+
)
|
|
186
|
+
goose_calls += summary_calls
|
|
187
|
+
if summary_ok:
|
|
188
|
+
actions_ran += 1
|
|
189
|
+
else:
|
|
190
|
+
actions_skipped += 1
|
|
191
|
+
except RuntimeError as e:
|
|
192
|
+
print(colored(f"\nSummary failed: {e}", Color.RED), file=sys.stderr)
|
|
193
|
+
actions_skipped += 1
|
|
194
|
+
elif pipeline.summary is not None and review_status == "partial":
|
|
195
|
+
if session_dir:
|
|
196
|
+
log_step(session_dir, "review status=partial: skipping summary")
|
|
197
|
+
actions_skipped += 1
|
|
198
|
+
|
|
199
|
+
print_session_footer(
|
|
200
|
+
elapsed=time.perf_counter() - runner_start,
|
|
201
|
+
goose_calls=goose_calls,
|
|
202
|
+
actions_planned=self._planned,
|
|
203
|
+
actions_ran=actions_ran,
|
|
204
|
+
actions_skipped=actions_skipped,
|
|
205
|
+
outputs=list(ctx.artifacts.get("outputs_written", [])),
|
|
206
|
+
operator_actions=list(ctx.artifacts.get("operator_actions", [])),
|
|
207
|
+
session_dir=session_dir,
|
|
208
|
+
)
|
|
209
|
+
|
|
210
|
+
return {
|
|
211
|
+
"goose_calls": goose_calls,
|
|
212
|
+
"actions_planned": self._planned,
|
|
213
|
+
"actions_ran": actions_ran,
|
|
214
|
+
"actions_skipped": actions_skipped,
|
|
215
|
+
"outputs": list(ctx.artifacts.get("outputs_written", [])),
|
|
216
|
+
"operator_actions": list(ctx.artifacts.get("operator_actions", [])),
|
|
217
|
+
"review_status": review_status,
|
|
218
|
+
"review_output": review_output,
|
|
219
|
+
"session_dir": session_dir,
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
# ------------------------------------------------------------------
|
|
223
|
+
# progress
|
|
224
|
+
|
|
225
|
+
def _announce(self, phase_name: str, *,
|
|
226
|
+
total: str | None = None,
|
|
227
|
+
color: str = Color.MAGENTA) -> None:
|
|
228
|
+
"""Bump the step counter and print the phase banner with `[N/M]`.
|
|
229
|
+
|
|
230
|
+
N is monotonically increasing across the whole pass (review +
|
|
231
|
+
body + summary). M is the current planned total, which grows
|
|
232
|
+
when review-spawned or body-spawned children land in the queue.
|
|
233
|
+
Mid-run growth of M is expected: a `[3/5]` followed by `[4/7]`
|
|
234
|
+
means the previous phase spawned two more.
|
|
235
|
+
|
|
236
|
+
`total` overrides the M display when the caller knows the
|
|
237
|
+
planned count is structurally unknowable at this point — the
|
|
238
|
+
review phase, specifically, runs *before* its routing[] spawns
|
|
239
|
+
body children, so the framework can't truthfully claim a total
|
|
240
|
+
for it. Review calls with total="?".
|
|
241
|
+
"""
|
|
242
|
+
self._step += 1
|
|
243
|
+
total_display = total if total is not None else str(self._planned)
|
|
244
|
+
# · separator survives banner()'s word-rewrap (it splits on
|
|
245
|
+
# whitespace and rejoins with single spaces); double-space would
|
|
246
|
+
# collapse, leaving "review [1/2]" instead of the intended layout.
|
|
247
|
+
banner(
|
|
248
|
+
f"{self.engine.name}: {phase_name} · [{self._step}/{total_display}]",
|
|
249
|
+
color,
|
|
250
|
+
)
|
|
251
|
+
|
|
252
|
+
# ------------------------------------------------------------------
|
|
253
|
+
# review
|
|
254
|
+
|
|
255
|
+
def _run_review(self, review: Phase, ctx: Context,
|
|
256
|
+
body_queue: deque[Phase]) -> tuple[int, bool, str, ReviewOutput | None]:
|
|
257
|
+
"""Run review; parse output; seed ledger; spawn body children.
|
|
258
|
+
|
|
259
|
+
Returns (goose_calls, succeeded, status, parsed_output).
|
|
260
|
+
"""
|
|
261
|
+
# Review's planned total is structurally unknown: routing[] hasn't
|
|
262
|
+
# run yet. Display "?" instead of a misleading partial count.
|
|
263
|
+
self._announce(review.name, total="?")
|
|
264
|
+
# Wrap the review's success_predicate so the retry loop fails any
|
|
265
|
+
# attempt that didn't emit parseable wrapped JSON. Without this,
|
|
266
|
+
# a mid-stream truncation (e.g. provider stream decode error) gets
|
|
267
|
+
# accepted as "success" by the default transient-error check, the
|
|
268
|
+
# downstream parse fails, and retries are off the table by then.
|
|
269
|
+
# Engines that explicitly set their own predicate keep it.
|
|
270
|
+
review_with_guard = (
|
|
271
|
+
review if review.success_predicate is not None
|
|
272
|
+
else dataclasses.replace(review, success_predicate=_review_output_parseable)
|
|
273
|
+
)
|
|
274
|
+
output = self._invoke_recipe(review_with_guard, ctx, overlays=self.review_overlays)
|
|
275
|
+
if output is None:
|
|
276
|
+
return 0, False, "error", None
|
|
277
|
+
|
|
278
|
+
extracted = extract_json_with_provenance(output)
|
|
279
|
+
if extracted is None:
|
|
280
|
+
print(colored(
|
|
281
|
+
"Review did not emit recognisable wrapped JSON; cannot parse.",
|
|
282
|
+
Color.RED,
|
|
283
|
+
), file=sys.stderr)
|
|
284
|
+
if ctx.session_dir:
|
|
285
|
+
log_step(ctx.session_dir, "review: no wrapped JSON found by any recognizer")
|
|
286
|
+
return 1, False, "error", None
|
|
287
|
+
|
|
288
|
+
if not extracted.is_canonical:
|
|
289
|
+
msg = (
|
|
290
|
+
f"review parsed via {extracted.recognizer} (non-canonical wrapper). "
|
|
291
|
+
f"Tighten the review recipe to use <<<DELIVERABLE_JSON>>> / "
|
|
292
|
+
f"<<<END_DELIVERABLE>>> for stable runs."
|
|
293
|
+
)
|
|
294
|
+
print(colored(msg, Color.YELLOW), file=sys.stderr)
|
|
295
|
+
if ctx.session_dir:
|
|
296
|
+
log_step(ctx.session_dir, f"review: {msg}")
|
|
297
|
+
ctx.add_operator_action(
|
|
298
|
+
action="tighten review recipe to canonical sentinels",
|
|
299
|
+
why=f"review parsed via {extracted.recognizer}; "
|
|
300
|
+
f"weaker models will drift further if the recipe stays ambiguous",
|
|
301
|
+
recognizer=extracted.recognizer,
|
|
302
|
+
)
|
|
303
|
+
|
|
304
|
+
try:
|
|
305
|
+
review_output = validate_review(extracted.payload)
|
|
306
|
+
except ProtocolVersionError as e:
|
|
307
|
+
print(colored(f"Review protocol mismatch: {e}", Color.RED), file=sys.stderr)
|
|
308
|
+
if ctx.session_dir:
|
|
309
|
+
log_step(ctx.session_dir, f"review: protocol mismatch ({e})")
|
|
310
|
+
return 1, False, "error", None
|
|
311
|
+
except ValueError as e:
|
|
312
|
+
print(colored(f"Review schema invalid: {e}", Color.RED), file=sys.stderr)
|
|
313
|
+
if ctx.session_dir:
|
|
314
|
+
log_step(ctx.session_dir, f"review: schema invalid ({e})")
|
|
315
|
+
return 1, False, "error", None
|
|
316
|
+
|
|
317
|
+
# Stash the full payload for engine extensions to read.
|
|
318
|
+
ctx.artifacts["review_output"] = dict(review_output)
|
|
319
|
+
ctx.artifacts["review_routing"] = list(review_output.get("routing", []))
|
|
320
|
+
|
|
321
|
+
# Seed the ledger from the review's operator_actions. validate_review
|
|
322
|
+
# already normalised entries to {action: str, why: str, ...extras}, so
|
|
323
|
+
# the loop trusts that shape and just guards against an empty action.
|
|
324
|
+
for entry in review_output.get("operator_actions", []):
|
|
325
|
+
action = entry.get("action", "")
|
|
326
|
+
why = entry.get("why", "")
|
|
327
|
+
if not action:
|
|
328
|
+
continue
|
|
329
|
+
extras = {k: v for k, v in entry.items() if k not in ("action", "why")}
|
|
330
|
+
ctx.add_operator_action(action=action, why=why, **extras)
|
|
331
|
+
|
|
332
|
+
# Persist the review JSON to the session for downstream phases.
|
|
333
|
+
if ctx.session_dir:
|
|
334
|
+
actions_dir = ctx.session_dir / "actions"
|
|
335
|
+
actions_dir.mkdir(parents=True, exist_ok=True)
|
|
336
|
+
review_path = actions_dir / "review.json"
|
|
337
|
+
import json
|
|
338
|
+
review_path.write_text(json.dumps(review_output, indent=2))
|
|
339
|
+
ctx.base_env["REVIEW_JSON_PATH"] = str(review_path)
|
|
340
|
+
log_step(ctx.session_dir, f"review: wrote {review_path}")
|
|
341
|
+
|
|
342
|
+
# Spawn body children from routing[].
|
|
343
|
+
status = str(review_output.get("status", "done"))
|
|
344
|
+
if status == "done":
|
|
345
|
+
children = self._build_body_phases(review_output.get("routing", []))
|
|
346
|
+
body_queue.extend(children)
|
|
347
|
+
elif status == "partial":
|
|
348
|
+
if ctx.session_dir:
|
|
349
|
+
log_step(ctx.session_dir, "review status=partial: skipping routing")
|
|
350
|
+
|
|
351
|
+
# Engine post_process for the review (if any) runs after parsing.
|
|
352
|
+
if review.post_process is not None:
|
|
353
|
+
extra_children = review.post_process(output, ctx)
|
|
354
|
+
if extra_children:
|
|
355
|
+
body_queue.extend(extra_children)
|
|
356
|
+
|
|
357
|
+
return 1, True, status, review_output
|
|
358
|
+
|
|
359
|
+
def _build_body_phases(self, routing: list[RoutingEntry]) -> list[Phase]:
|
|
360
|
+
"""Build body Phases from review routing entries via BranchPolicy."""
|
|
361
|
+
out: list[Phase] = []
|
|
362
|
+
for entry in routing:
|
|
363
|
+
recipe = entry.get("recipe")
|
|
364
|
+
if not isinstance(recipe, str):
|
|
365
|
+
continue
|
|
366
|
+
params: dict[str, Any] = entry.get("params") or {}
|
|
367
|
+
policy = self.engine.branch_policies.get(recipe, BranchPolicy())
|
|
368
|
+
phase = self._phase_from_routing(recipe, params, policy)
|
|
369
|
+
out.append(phase)
|
|
370
|
+
return out
|
|
371
|
+
|
|
372
|
+
def _phase_from_routing(self, recipe: str, params: dict[str, Any],
|
|
373
|
+
policy: BranchPolicy) -> Phase:
|
|
374
|
+
recipe_path = self._resolve_recipe_path(recipe)
|
|
375
|
+
param_env = _params_to_env(params)
|
|
376
|
+
|
|
377
|
+
# If the policy can compute an output path, inject it as OUTPUT_PATH
|
|
378
|
+
# so the recipe writes to exactly the file the predicate later checks.
|
|
379
|
+
# Without this the recipe and the predicate could (and did) disagree
|
|
380
|
+
# on filenames — recipe wrote ${SHA}.md, predicate looked for
|
|
381
|
+
# <slug>-<sha8>.md, every successful write triggered a fake "transient
|
|
382
|
+
# error" retry until max_retries.
|
|
383
|
+
out_path: Path | None = None
|
|
384
|
+
if policy.output_path is not None:
|
|
385
|
+
out_path = policy.output_path(params)
|
|
386
|
+
if out_path is not None:
|
|
387
|
+
param_env["OUTPUT_PATH"] = str(out_path)
|
|
388
|
+
|
|
389
|
+
if policy.predicate is not None:
|
|
390
|
+
predicate: Optional[Callable[[str], bool]] = policy.predicate
|
|
391
|
+
elif out_path is not None:
|
|
392
|
+
predicate = file_nonempty(out_path)
|
|
393
|
+
else:
|
|
394
|
+
predicate = None
|
|
395
|
+
|
|
396
|
+
skip = None
|
|
397
|
+
if policy.skip_when is not None:
|
|
398
|
+
captured_params = dict(params)
|
|
399
|
+
skip = lambda _ctx, _p=captured_params: policy.skip_when(_p) # noqa: E731
|
|
400
|
+
|
|
401
|
+
post = None
|
|
402
|
+
if policy.output_path is not None:
|
|
403
|
+
captured_params = dict(params)
|
|
404
|
+
def post(_out: str, ctx: Context, _p=captured_params) -> None:
|
|
405
|
+
op = policy.output_path(_p) if policy.output_path else None
|
|
406
|
+
if op is not None:
|
|
407
|
+
ctx.record_output(op)
|
|
408
|
+
return None
|
|
409
|
+
|
|
410
|
+
label = f"{recipe}[{params.get('slug') or 'branch'}]" \
|
|
411
|
+
if params.get("slug") else None
|
|
412
|
+
|
|
413
|
+
return Phase(
|
|
414
|
+
name=f"branch:{recipe}",
|
|
415
|
+
recipe_path=str(recipe_path),
|
|
416
|
+
build_env=lambda _ctx, _e=param_env: dict(_e),
|
|
417
|
+
success_predicate=predicate,
|
|
418
|
+
post_process=post,
|
|
419
|
+
skip_if=skip,
|
|
420
|
+
label=label,
|
|
421
|
+
)
|
|
422
|
+
|
|
423
|
+
# ------------------------------------------------------------------
|
|
424
|
+
# body
|
|
425
|
+
|
|
426
|
+
def _drain_body(self, queue: deque[Phase], ctx: Context) -> tuple[int, int, int]:
|
|
427
|
+
"""Drain the body queue. Children spawned via post_process land at HEAD.
|
|
428
|
+
|
|
429
|
+
Bumps self._planned when post_process spawns new phases so the
|
|
430
|
+
`[step/planned]` banner stays accurate mid-run.
|
|
431
|
+
"""
|
|
432
|
+
calls = 0
|
|
433
|
+
ran = 0
|
|
434
|
+
skipped = 0
|
|
435
|
+
executed = 0
|
|
436
|
+
|
|
437
|
+
while queue:
|
|
438
|
+
if executed >= self.config.max_queue_depth:
|
|
439
|
+
print(colored(
|
|
440
|
+
f"\nQueue depth cap ({self.config.max_queue_depth}) hit; "
|
|
441
|
+
f"aborting remaining phases.",
|
|
442
|
+
Color.RED,
|
|
443
|
+
), file=sys.stderr)
|
|
444
|
+
break
|
|
445
|
+
|
|
446
|
+
phase = queue.popleft()
|
|
447
|
+
executed += 1
|
|
448
|
+
phase_calls, ok, children = self._run_phase_with_children(phase, ctx)
|
|
449
|
+
calls += phase_calls
|
|
450
|
+
if ok:
|
|
451
|
+
ran += 1
|
|
452
|
+
else:
|
|
453
|
+
skipped += 1
|
|
454
|
+
if children:
|
|
455
|
+
self._planned += len(children)
|
|
456
|
+
queue.extendleft(reversed(children))
|
|
457
|
+
|
|
458
|
+
return calls, ran, skipped
|
|
459
|
+
|
|
460
|
+
def _run_phase_with_children(self, phase: Phase, ctx: Context) -> tuple[int, bool, list[Phase]]:
|
|
461
|
+
self._announce(phase.name)
|
|
462
|
+
# skip_if check (after the banner so the operator sees which phase
|
|
463
|
+
# is being skipped and why).
|
|
464
|
+
skip_result = phase.skip_if(ctx) if phase.skip_if is not None else None
|
|
465
|
+
if skip_result:
|
|
466
|
+
msg = (f"Skipped phase {phase.name}: {skip_result}"
|
|
467
|
+
if isinstance(skip_result, str)
|
|
468
|
+
else f"Skipped phase {phase.name} (skip_if returned True)")
|
|
469
|
+
print(colored(msg, Color.YELLOW))
|
|
470
|
+
if ctx.session_dir:
|
|
471
|
+
log_step(ctx.session_dir, msg)
|
|
472
|
+
return 0, True, [] # skip counts as ok (handled by caller as skipped)
|
|
473
|
+
|
|
474
|
+
output = self._invoke_recipe(phase, ctx)
|
|
475
|
+
if output is None:
|
|
476
|
+
return 0, False, []
|
|
477
|
+
|
|
478
|
+
children: list[Phase] = []
|
|
479
|
+
if phase.post_process is not None:
|
|
480
|
+
try:
|
|
481
|
+
returned = phase.post_process(output, ctx)
|
|
482
|
+
if returned:
|
|
483
|
+
children = list(returned)
|
|
484
|
+
except Exception as e:
|
|
485
|
+
print(colored(f"\nPhase {phase.name} post_process raised: {e}", Color.RED),
|
|
486
|
+
file=sys.stderr)
|
|
487
|
+
if ctx.session_dir:
|
|
488
|
+
log_step(ctx.session_dir, f"Phase {phase.name} post_process raised: {e}")
|
|
489
|
+
|
|
490
|
+
if ctx.session_dir:
|
|
491
|
+
log_step(ctx.session_dir, f"Phase {phase.name} completed.")
|
|
492
|
+
return 1, True, children
|
|
493
|
+
|
|
494
|
+
# ------------------------------------------------------------------
|
|
495
|
+
# generic phase + recipe invocation
|
|
496
|
+
|
|
497
|
+
def _run_phase(self, phase: Phase, ctx: Context, *,
|
|
498
|
+
overlays: list[Path] | None = None,
|
|
499
|
+
is_summary: bool = False) -> tuple[int, bool]:
|
|
500
|
+
self._announce(phase.name)
|
|
501
|
+
output = self._invoke_recipe(phase, ctx, overlays=overlays)
|
|
502
|
+
if output is None:
|
|
503
|
+
return 0, False
|
|
504
|
+
if phase.post_process is not None:
|
|
505
|
+
try:
|
|
506
|
+
phase.post_process(output, ctx)
|
|
507
|
+
except Exception as e:
|
|
508
|
+
print(colored(f"\nPhase {phase.name} post_process raised: {e}", Color.RED),
|
|
509
|
+
file=sys.stderr)
|
|
510
|
+
if ctx.session_dir:
|
|
511
|
+
log_step(ctx.session_dir, f"Phase {phase.name} completed.")
|
|
512
|
+
return 1, True
|
|
513
|
+
|
|
514
|
+
def _invoke_recipe(self, phase: Phase, ctx: Context, *,
|
|
515
|
+
overlays: list[Path] | None = None) -> str | None:
|
|
516
|
+
phase_env = phase.build_env(ctx) if phase.build_env else {}
|
|
517
|
+
extra_env = {**ctx.base_env, **phase_env}
|
|
518
|
+
local_path = _local_overlay_for(Path(phase.recipe_path))
|
|
519
|
+
try:
|
|
520
|
+
return run_goose_with_retry(
|
|
521
|
+
phase.recipe_path,
|
|
522
|
+
self.model,
|
|
523
|
+
extra_env=extra_env,
|
|
524
|
+
max_retries=self.config.retry.max_retries,
|
|
525
|
+
base_delay=self.config.retry.base_delay,
|
|
526
|
+
success_predicate=phase.success_predicate,
|
|
527
|
+
label=phase.label,
|
|
528
|
+
environment=self.environment,
|
|
529
|
+
local_path=local_path,
|
|
530
|
+
overlay_paths=overlays or [],
|
|
531
|
+
)
|
|
532
|
+
except RuntimeError as e:
|
|
533
|
+
print(colored(f"\nPhase {phase.name} failed: {e}", Color.RED), file=sys.stderr)
|
|
534
|
+
if ctx.session_dir:
|
|
535
|
+
log_step(ctx.session_dir, f"Phase {phase.name} FAILED: {e}")
|
|
536
|
+
return None
|
|
537
|
+
|
|
538
|
+
# ------------------------------------------------------------------
|
|
539
|
+
|
|
540
|
+
def _resolve_recipe_path(self, recipe: str) -> str:
|
|
541
|
+
"""Look up a body recipe by name in the engine's recipes directory.
|
|
542
|
+
|
|
543
|
+
Accepts a bare name ("to-outreach") or a full path
|
|
544
|
+
("recipes/to-outreach.yaml"). Bare names resolve against
|
|
545
|
+
engine.recipes_dir() with .yaml suffix.
|
|
546
|
+
"""
|
|
547
|
+
if recipe.endswith(".yaml") or recipe.endswith(".yml") or "/" in recipe:
|
|
548
|
+
return recipe
|
|
549
|
+
return f"{self.engine.recipes_dir()}/{recipe}.yaml"
|
|
550
|
+
|
|
551
|
+
|
|
552
|
+
def _review_output_parseable(output: str) -> bool:
|
|
553
|
+
"""Default review success_predicate: at least extract_json must succeed.
|
|
554
|
+
|
|
555
|
+
Catches mid-stream truncation (provider decode error after partial
|
|
556
|
+
output) and other "looks fine to goose but doesn't parse" failures.
|
|
557
|
+
Validation of required keys + status enum + protocol version happens
|
|
558
|
+
in validate_review after extraction; this predicate's only job is
|
|
559
|
+
"is there enough output to try."
|
|
560
|
+
"""
|
|
561
|
+
return extract_json_with_provenance(output) is not None
|
|
562
|
+
|
|
563
|
+
|
|
564
|
+
def _params_to_env(params: dict[str, Any]) -> dict[str, str]:
|
|
565
|
+
"""Convert routing params to env vars. Keys uppercased; values stringified."""
|
|
566
|
+
out: dict[str, str] = {}
|
|
567
|
+
for k, v in params.items():
|
|
568
|
+
if v is None:
|
|
569
|
+
continue
|
|
570
|
+
out[str(k).upper()] = str(v)
|
|
571
|
+
return out
|
|
572
|
+
|
|
573
|
+
|
|
574
|
+
def _local_overlay_for(recipe_path: Path) -> Path | None:
|
|
575
|
+
"""Compute the conventional .local.yaml sibling for an overlay base."""
|
|
576
|
+
if recipe_path.suffix not in (".yaml", ".yml"):
|
|
577
|
+
return None
|
|
578
|
+
candidate = recipe_path.with_suffix("").with_name(
|
|
579
|
+
recipe_path.stem + ".local"
|
|
580
|
+
).with_suffix(recipe_path.suffix)
|
|
581
|
+
return candidate if candidate.exists() else None
|