@matt82198/aesop 0.2.0 → 0.3.2
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.
- package/CHANGELOG.md +39 -1
- package/README.md +60 -263
- package/bin/cli.js +7 -3
- package/daemons/install-tasks.ps1 +193 -0
- package/daemons/run-hidden.vbs +39 -0
- package/docs/HOOK-INSTALL.md +15 -56
- package/docs/INSTALL.md +48 -3
- package/docs/PORTING.md +166 -0
- package/docs/TEAM-STATE.md +16 -184
- package/docs/reproduce.md +33 -3
- package/driver/CLAUDE.md +50 -48
- package/driver/codex_driver.py +59 -12
- package/driver/openai_transport.py +33 -0
- package/driver/wave_bridge.py +9 -1
- package/driver/wave_loop.py +437 -70
- package/driver/wave_scheduler.py +890 -0
- package/hooks/pre-push-policy.sh +22 -5
- package/monitor/collect-signals.mjs +69 -0
- package/package.json +6 -4
- package/state_store/__init__.py +2 -1
- package/state_store/projections.py +63 -0
- package/state_store/read_api.py +156 -0
- package/state_store/write_api.py +462 -0
- package/tools/cost_ceiling.py +106 -15
- package/tools/cost_projection.py +559 -0
- package/tools/crossos_drift.py +394 -0
- package/tools/eod_sweep.py +137 -33
- package/tools/mutation_test.py +130 -8
- package/tools/proposals.mjs +47 -2
- package/tools/reproduce.js +405 -0
- package/tools/secret_scan.py +73 -18
- package/tools/stall_check.py +247 -16
- package/tools/stateapi_lint.py +325 -0
- package/tools/test_battery.py +173 -0
- package/tools/verify_cost_panel.py +345 -0
- package/tools/wave_preflight.py +296 -29
- package/tools/wave_templates.py +170 -45
- package/ui/collectors.py +81 -0
- package/ui/handler.py +230 -85
- package/ui/sse.py +3 -3
- package/ui/wave_telemetry.py +24 -18
- package/ui/web/dist/assets/{index-DqZLgwNg.css → index-CNQxaiOW.css} +1 -1
- package/ui/web/dist/assets/index-CP68RIh3.js +9 -0
- package/ui/web/dist/index.html +2 -2
- package/docs/QUICKSTART.md +0 -80
- package/ui/web/dist/assets/index-BGbgw2Nh.js +0 -9
|
@@ -0,0 +1,890 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""Wave scheduler: single-cycle orchestration of backlog intake, manifest build, and wave dispatch.
|
|
3
|
+
|
|
4
|
+
WS3a pilot: deterministic one-cycle loop with CRITICAL GUARDRAILS:
|
|
5
|
+
1. Intakes up to N file-disjoint todo items from tracker.json (respects ownsFiles)
|
|
6
|
+
2. Validates required fields + path normalization (platform-independent, no symlink TOCTOU)
|
|
7
|
+
3. Builds a run_wave manifest via wave_templates conventions
|
|
8
|
+
4. Invokes driver.wave_loop.run_wave with recovery journal + git ship config
|
|
9
|
+
5. STOPS before merge: emits Report JSON for human/orchestrator review
|
|
10
|
+
6. Double-dispatch prevention: write "in_progress" status to tracker.json (atomic, conflict-detecting)
|
|
11
|
+
7. Bounded by: HALT file check (final gate before dispatch) + cost ceiling check
|
|
12
|
+
|
|
13
|
+
SINGLE-WRITER ASSUMPTION: This pilot assumes tracker.json is NOT edited concurrently by other
|
|
14
|
+
processes. Concurrent-writer safety checks detect conflicts and abort; full lock/StateAPI
|
|
15
|
+
integration is filed for next wave. Do NOT run multiple schedulers against the same tracker.
|
|
16
|
+
|
|
17
|
+
CLI: python driver/wave_scheduler.py --tracker <path> --max-items N --dry-run|--execute
|
|
18
|
+
|
|
19
|
+
stdlib-only, ASCII-only, Windows + Linux safe.
|
|
20
|
+
"""
|
|
21
|
+
|
|
22
|
+
import argparse
|
|
23
|
+
import hashlib
|
|
24
|
+
import json
|
|
25
|
+
import os
|
|
26
|
+
import sys
|
|
27
|
+
import tempfile
|
|
28
|
+
import uuid
|
|
29
|
+
from datetime import datetime, timezone
|
|
30
|
+
from pathlib import Path
|
|
31
|
+
from typing import Dict, List, Any, Optional, Set, Tuple
|
|
32
|
+
|
|
33
|
+
# Add driver/ and tools/ to path
|
|
34
|
+
REPO = Path(__file__).resolve().parent.parent
|
|
35
|
+
DRIVER_DIR = REPO / "driver"
|
|
36
|
+
TOOLS_DIR = REPO / "tools"
|
|
37
|
+
if str(DRIVER_DIR) not in sys.path:
|
|
38
|
+
sys.path.insert(0, str(DRIVER_DIR))
|
|
39
|
+
if str(TOOLS_DIR) not in sys.path:
|
|
40
|
+
sys.path.insert(0, str(TOOLS_DIR))
|
|
41
|
+
|
|
42
|
+
# Import core modules
|
|
43
|
+
from wave_loop import run_wave
|
|
44
|
+
from agent_driver import AgentDriver
|
|
45
|
+
from verification_policy import verification_policy
|
|
46
|
+
|
|
47
|
+
# Import safety gates (P1-3: fail-closed if unavailable)
|
|
48
|
+
try:
|
|
49
|
+
import halt
|
|
50
|
+
except ImportError:
|
|
51
|
+
halt = None
|
|
52
|
+
|
|
53
|
+
try:
|
|
54
|
+
import cost_ceiling
|
|
55
|
+
except ImportError:
|
|
56
|
+
cost_ceiling = None
|
|
57
|
+
|
|
58
|
+
try:
|
|
59
|
+
from common import get_state_dir
|
|
60
|
+
except ImportError:
|
|
61
|
+
from tools.common import get_state_dir
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
# ========================================================================
|
|
65
|
+
# Path Normalization (P1-2: PLATFORM-INDEPENDENT)
|
|
66
|
+
# ========================================================================
|
|
67
|
+
|
|
68
|
+
def _normalize_path(path: str) -> str:
|
|
69
|
+
"""Normalize a path for comparison: posixify, strip ./, casefold ALWAYS (P1-2).
|
|
70
|
+
|
|
71
|
+
CRITICAL: Casefolding ALWAYS (not just on Windows) ensures platform-independent
|
|
72
|
+
ownership semantics — same tracker selects identically on all OS.
|
|
73
|
+
|
|
74
|
+
Args:
|
|
75
|
+
path: file path (potentially with backslashes, ./ prefix)
|
|
76
|
+
|
|
77
|
+
Returns:
|
|
78
|
+
normalized path (forward slashes, no leading ./, lowercased)
|
|
79
|
+
"""
|
|
80
|
+
# Replace backslashes with forward slashes (posixify)
|
|
81
|
+
normalized = path.replace("\\", "/")
|
|
82
|
+
|
|
83
|
+
# Strip leading ./
|
|
84
|
+
if normalized.startswith("./"):
|
|
85
|
+
normalized = normalized[2:]
|
|
86
|
+
|
|
87
|
+
# Casefold ALWAYS for platform-independent semantics (P1-2)
|
|
88
|
+
normalized = normalized.lower()
|
|
89
|
+
|
|
90
|
+
return normalized
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
def _is_valid_owned_path(path: str) -> bool:
|
|
94
|
+
"""Validate an ownsFiles entry: reject absolute paths and traversal attacks (P5).
|
|
95
|
+
|
|
96
|
+
Args:
|
|
97
|
+
path: normalized file path
|
|
98
|
+
|
|
99
|
+
Returns:
|
|
100
|
+
True iff path is relative, has no .. traversal, and is safe to dispatch
|
|
101
|
+
"""
|
|
102
|
+
# Reject absolute paths (starting with /)
|
|
103
|
+
if path.startswith("/"):
|
|
104
|
+
return False
|
|
105
|
+
|
|
106
|
+
# Reject traversal attacks (.. after normalization)
|
|
107
|
+
if ".." in path:
|
|
108
|
+
return False
|
|
109
|
+
|
|
110
|
+
return True
|
|
111
|
+
|
|
112
|
+
|
|
113
|
+
# ========================================================================
|
|
114
|
+
# Tracker & Manifest Loading
|
|
115
|
+
# ========================================================================
|
|
116
|
+
|
|
117
|
+
def load_tracker_items(tracker_path: str) -> List[Dict[str, Any]]:
|
|
118
|
+
"""Load tracker.json items.
|
|
119
|
+
|
|
120
|
+
Args:
|
|
121
|
+
tracker_path: absolute path to tracker.json
|
|
122
|
+
|
|
123
|
+
Returns:
|
|
124
|
+
list of item dicts, or [] if file missing/invalid
|
|
125
|
+
"""
|
|
126
|
+
p = Path(tracker_path)
|
|
127
|
+
if not p.exists():
|
|
128
|
+
return []
|
|
129
|
+
try:
|
|
130
|
+
with open(p, "r", encoding="utf-8") as f:
|
|
131
|
+
data = json.load(f)
|
|
132
|
+
# Handle both {"items": [...]} and [...]
|
|
133
|
+
if isinstance(data, dict):
|
|
134
|
+
return data.get("items", [])
|
|
135
|
+
return data if isinstance(data, list) else []
|
|
136
|
+
except (json.JSONDecodeError, IOError):
|
|
137
|
+
return []
|
|
138
|
+
|
|
139
|
+
|
|
140
|
+
def _read_tracker_with_hash(tracker_path: str) -> Tuple[List[Dict], Optional[str]]:
|
|
141
|
+
"""Load tracker.json and compute content hash for conflict detection (P6).
|
|
142
|
+
|
|
143
|
+
Returns:
|
|
144
|
+
(items_list, content_hash)
|
|
145
|
+
"""
|
|
146
|
+
items = load_tracker_items(tracker_path)
|
|
147
|
+
p = Path(tracker_path)
|
|
148
|
+
|
|
149
|
+
if p.exists():
|
|
150
|
+
try:
|
|
151
|
+
with open(p, "rb") as f:
|
|
152
|
+
content_hash = hashlib.sha256(f.read()).hexdigest()
|
|
153
|
+
except IOError:
|
|
154
|
+
content_hash = None
|
|
155
|
+
else:
|
|
156
|
+
content_hash = None
|
|
157
|
+
|
|
158
|
+
return items, content_hash
|
|
159
|
+
|
|
160
|
+
|
|
161
|
+
def _validate_item(item: Dict[str, Any]) -> Tuple[bool, Optional[str]]:
|
|
162
|
+
"""Validate required fields and ownsFiles for an item (P1-6, P5).
|
|
163
|
+
|
|
164
|
+
Args:
|
|
165
|
+
item: tracker item dict
|
|
166
|
+
|
|
167
|
+
Returns:
|
|
168
|
+
(is_valid: bool, error_reason: str|None)
|
|
169
|
+
"""
|
|
170
|
+
# Check ownsFiles first (P1-1: special handling for empty)
|
|
171
|
+
owns = item.get("ownsFiles")
|
|
172
|
+
if not owns or (isinstance(owns, list) and len(owns) == 0):
|
|
173
|
+
return False, "no_file_ownership"
|
|
174
|
+
|
|
175
|
+
# Ensure all entries in ownsFiles are non-empty strings and valid paths (P5)
|
|
176
|
+
if isinstance(owns, list):
|
|
177
|
+
for entry in owns:
|
|
178
|
+
if not isinstance(entry, str) or not entry:
|
|
179
|
+
return False, "invalid_ownsFiles_entries"
|
|
180
|
+
# Normalize and validate (reject absolute paths, traversal)
|
|
181
|
+
normalized = _normalize_path(entry)
|
|
182
|
+
if not _is_valid_owned_path(normalized):
|
|
183
|
+
return False, "invalid_path"
|
|
184
|
+
|
|
185
|
+
# Check other required fields
|
|
186
|
+
required = ["slug", "prompt", "testCmd"]
|
|
187
|
+
for field in required:
|
|
188
|
+
if field not in item or not item[field]:
|
|
189
|
+
return False, f"missing_or_empty_{field}"
|
|
190
|
+
|
|
191
|
+
return True, None
|
|
192
|
+
|
|
193
|
+
|
|
194
|
+
def filter_todo_items(items: List[Dict[str, Any]]) -> List[Dict[str, Any]]:
|
|
195
|
+
"""Filter items to only status=todo, sorted by priority then creation date.
|
|
196
|
+
|
|
197
|
+
Priority order: P1 > P2 > P3
|
|
198
|
+
Within same priority: oldest first
|
|
199
|
+
"""
|
|
200
|
+
todo = [item for item in items if item.get("status") == "todo"]
|
|
201
|
+
|
|
202
|
+
# Sort by priority (P1=0, P2=1, P3=2), then by createdAt
|
|
203
|
+
def priority_rank(item):
|
|
204
|
+
prio = item.get("priority", "P3")
|
|
205
|
+
rank = {"P1": 0, "P2": 1, "P3": 2}.get(prio, 2)
|
|
206
|
+
created = item.get("createdAt", "2999-01-01")
|
|
207
|
+
return (rank, created)
|
|
208
|
+
|
|
209
|
+
return sorted(todo, key=priority_rank)
|
|
210
|
+
|
|
211
|
+
|
|
212
|
+
def select_disjoint_items(
|
|
213
|
+
items: List[Dict[str, Any]], max_count: int
|
|
214
|
+
) -> Tuple[List[Dict[str, Any]], List[str]]:
|
|
215
|
+
"""Greedily select up to max_count items with no file overlap (P1-1, P1-2).
|
|
216
|
+
|
|
217
|
+
Greedy algorithm: sort by (file_count, priority), pick items that don't
|
|
218
|
+
overlap ownsFiles (after normalization) with already-selected items.
|
|
219
|
+
|
|
220
|
+
Returns:
|
|
221
|
+
(selected_items, skipped_item_ids) — skipped due to overlap
|
|
222
|
+
"""
|
|
223
|
+
selected = []
|
|
224
|
+
used_files: Set[str] = set()
|
|
225
|
+
skipped = []
|
|
226
|
+
|
|
227
|
+
# Sort by file count (ascending) to pack smaller items first
|
|
228
|
+
def item_sort_key(item):
|
|
229
|
+
files = item.get("ownsFiles", [])
|
|
230
|
+
prio = item.get("priority", "P3")
|
|
231
|
+
rank = {"P1": 0, "P2": 1, "P3": 2}.get(prio, 2)
|
|
232
|
+
return (len(files), rank)
|
|
233
|
+
|
|
234
|
+
items_to_process = sorted(items, key=item_sort_key)
|
|
235
|
+
|
|
236
|
+
for item in items_to_process:
|
|
237
|
+
if len(selected) >= max_count:
|
|
238
|
+
break
|
|
239
|
+
|
|
240
|
+
owns = [_normalize_path(f) for f in item.get("ownsFiles", [])]
|
|
241
|
+
owns_set = set(owns)
|
|
242
|
+
|
|
243
|
+
# Check for overlap
|
|
244
|
+
if owns_set & used_files:
|
|
245
|
+
skipped.append(item.get("id", "unknown"))
|
|
246
|
+
continue
|
|
247
|
+
|
|
248
|
+
# No overlap, select it
|
|
249
|
+
selected.append(item)
|
|
250
|
+
used_files.update(owns_set)
|
|
251
|
+
|
|
252
|
+
return selected, skipped
|
|
253
|
+
|
|
254
|
+
|
|
255
|
+
def _check_halt_file(state_dir: Optional[Path] = None) -> Tuple[bool, Optional[str]]:
|
|
256
|
+
"""Check if .HALT file exists (P1-3, P1-4).
|
|
257
|
+
|
|
258
|
+
Returns:
|
|
259
|
+
(is_halted: bool, reason: str|None)
|
|
260
|
+
|
|
261
|
+
Raises:
|
|
262
|
+
RuntimeError if halt module is unavailable or check fails
|
|
263
|
+
"""
|
|
264
|
+
if halt is None:
|
|
265
|
+
raise RuntimeError("halt module unavailable (import failed)")
|
|
266
|
+
|
|
267
|
+
try:
|
|
268
|
+
if halt.is_halted(state_dir):
|
|
269
|
+
info = halt.get_halt_info(state_dir)
|
|
270
|
+
reason = info.get("reason", "Unknown halt") if info else "Unknown halt"
|
|
271
|
+
return True, reason
|
|
272
|
+
except Exception as e:
|
|
273
|
+
# P1-4: gate check error = halt, not pass
|
|
274
|
+
raise RuntimeError(f"halt file check failed: {e}")
|
|
275
|
+
|
|
276
|
+
return False, None
|
|
277
|
+
|
|
278
|
+
|
|
279
|
+
def _check_cost_ceiling(state_dir: Optional[Path] = None) -> Tuple[bool, Optional[str]]:
|
|
280
|
+
"""Check cost ceiling (P1-3, P1-4, P2a).
|
|
281
|
+
|
|
282
|
+
Returns:
|
|
283
|
+
(ceiling_exceeded: bool, reason: str|None)
|
|
284
|
+
|
|
285
|
+
Raises:
|
|
286
|
+
RuntimeError if cost_ceiling module is unavailable or check fails
|
|
287
|
+
"""
|
|
288
|
+
if cost_ceiling is None:
|
|
289
|
+
raise RuntimeError("cost_ceiling module unavailable (import failed)")
|
|
290
|
+
|
|
291
|
+
try:
|
|
292
|
+
# P2a: use trip=False to enforce (fail-closed on exceeded)
|
|
293
|
+
result = cost_ceiling.check(spent=None, period="wave", state_dir=state_dir, trip=False)
|
|
294
|
+
if result.get("exceeded"):
|
|
295
|
+
return True, f"Cost ceiling exceeded: {result.get('spent', 0)}/{result.get('ceiling', 0)} tokens"
|
|
296
|
+
except Exception as e:
|
|
297
|
+
# P1-4: gate check error = halt, not pass
|
|
298
|
+
raise RuntimeError(f"cost ceiling check failed: {e}")
|
|
299
|
+
|
|
300
|
+
return False, None
|
|
301
|
+
|
|
302
|
+
|
|
303
|
+
def _verify_gate_availability() -> Tuple[bool, Optional[str]]:
|
|
304
|
+
"""Pre-flight gate availability check (P1-3).
|
|
305
|
+
|
|
306
|
+
Returns:
|
|
307
|
+
(gates_available: bool, error_reason: str|None)
|
|
308
|
+
"""
|
|
309
|
+
if halt is None:
|
|
310
|
+
return False, "halt module unavailable"
|
|
311
|
+
if cost_ceiling is None:
|
|
312
|
+
return False, "cost_ceiling module unavailable"
|
|
313
|
+
return True, None
|
|
314
|
+
|
|
315
|
+
|
|
316
|
+
# ========================================================================
|
|
317
|
+
# Manifest Building
|
|
318
|
+
# ========================================================================
|
|
319
|
+
|
|
320
|
+
def build_wave_manifest(
|
|
321
|
+
selected_items: List[Dict[str, Any]], driver: AgentDriver
|
|
322
|
+
) -> Tuple[Dict[str, Any], List[str]]:
|
|
323
|
+
"""Build a wave manifest from selected tracker items (P1-6).
|
|
324
|
+
|
|
325
|
+
Returns:
|
|
326
|
+
(manifest_dict, items_failed_build_ids)
|
|
327
|
+
|
|
328
|
+
Items that fail to build are excluded from manifest and reported separately.
|
|
329
|
+
"""
|
|
330
|
+
try:
|
|
331
|
+
from wave_bridge import build_manifest_item
|
|
332
|
+
except ImportError:
|
|
333
|
+
from driver.wave_bridge import build_manifest_item
|
|
334
|
+
|
|
335
|
+
manifest_items = []
|
|
336
|
+
failed_ids = []
|
|
337
|
+
|
|
338
|
+
for item in selected_items:
|
|
339
|
+
try:
|
|
340
|
+
m_item = build_manifest_item(driver, item)
|
|
341
|
+
manifest_items.append(m_item)
|
|
342
|
+
except Exception as e:
|
|
343
|
+
# P1-6: failed builds are recorded, not selected
|
|
344
|
+
failed_ids.append(item.get("id", "unknown"))
|
|
345
|
+
print(f"[wave_scheduler] Failed to build manifest for {item.get('id')}: {e}", file=sys.stderr)
|
|
346
|
+
continue
|
|
347
|
+
|
|
348
|
+
wave_id = str(uuid.uuid4())
|
|
349
|
+
return (
|
|
350
|
+
{
|
|
351
|
+
"wave_id": wave_id,
|
|
352
|
+
"items": manifest_items,
|
|
353
|
+
"wave_description": f"WS3a pilot wave {wave_id[:8]}",
|
|
354
|
+
},
|
|
355
|
+
failed_ids,
|
|
356
|
+
)
|
|
357
|
+
|
|
358
|
+
|
|
359
|
+
# ========================================================================
|
|
360
|
+
# Tracker Update (P1-5, P6: ATOMIC WITH CONFLICT DETECTION)
|
|
361
|
+
# ========================================================================
|
|
362
|
+
|
|
363
|
+
def _write_tracker_status_atomic(
|
|
364
|
+
tracker_path: str, items_to_update: List[str], new_status: str, wave_id: str,
|
|
365
|
+
expected_hash: Optional[str] = None,
|
|
366
|
+
) -> Tuple[bool, Optional[str]]:
|
|
367
|
+
"""Write item status updates to tracker.json atomically (P1-5, P6, HIGH).
|
|
368
|
+
|
|
369
|
+
Uses tempfile.NamedTemporaryFile + os.replace for atomicity and TOCTOU safety.
|
|
370
|
+
Detects concurrent writes via content-hash comparison.
|
|
371
|
+
|
|
372
|
+
Args:
|
|
373
|
+
tracker_path: path to tracker.json
|
|
374
|
+
items_to_update: list of item IDs to mark "in_progress"
|
|
375
|
+
new_status: new status (should be "in_progress" for pilot)
|
|
376
|
+
wave_id: wave ID to record in notes
|
|
377
|
+
expected_hash: content hash from intake; if current content differs, abort with conflict
|
|
378
|
+
|
|
379
|
+
Returns:
|
|
380
|
+
(success: bool, error_reason: str|None)
|
|
381
|
+
"""
|
|
382
|
+
try:
|
|
383
|
+
# Load current tracker
|
|
384
|
+
p = Path(tracker_path)
|
|
385
|
+
if not p.exists():
|
|
386
|
+
return True, None # No tracker to update (dry-run or first pass)
|
|
387
|
+
|
|
388
|
+
# P6: Detect concurrent writes (conflict detection)
|
|
389
|
+
if expected_hash:
|
|
390
|
+
try:
|
|
391
|
+
with open(p, "rb") as f:
|
|
392
|
+
current_content = f.read()
|
|
393
|
+
current_hash = hashlib.sha256(current_content).hexdigest()
|
|
394
|
+
if current_hash != expected_hash:
|
|
395
|
+
return False, "tracker_conflict"
|
|
396
|
+
except IOError:
|
|
397
|
+
return False, "tracker_conflict"
|
|
398
|
+
|
|
399
|
+
# Load tracker for mutation
|
|
400
|
+
with open(p, "r", encoding="utf-8") as f:
|
|
401
|
+
data = json.load(f)
|
|
402
|
+
|
|
403
|
+
# Normalize to items list
|
|
404
|
+
if isinstance(data, dict):
|
|
405
|
+
items = data.get("items", [])
|
|
406
|
+
elif isinstance(data, list):
|
|
407
|
+
items = data
|
|
408
|
+
else:
|
|
409
|
+
return False, "invalid_tracker_format"
|
|
410
|
+
|
|
411
|
+
# Update items
|
|
412
|
+
items_to_update_set = set(items_to_update)
|
|
413
|
+
for item in items:
|
|
414
|
+
if item.get("id") in items_to_update_set:
|
|
415
|
+
item["status"] = new_status
|
|
416
|
+
notes = item.get("notes", "")
|
|
417
|
+
item["notes"] = f"{notes} [wave {wave_id[:8]}]".strip()
|
|
418
|
+
|
|
419
|
+
# P1-5, HIGH: Write atomically using tempfile.NamedTemporaryFile (TOCTOU safe)
|
|
420
|
+
# Do NOT use predictable .tmp suffix (symlink vulnerability)
|
|
421
|
+
temp_fd = None
|
|
422
|
+
try:
|
|
423
|
+
temp_fd, temp_path = tempfile.mkstemp(dir=p.parent, prefix=".tracker-", suffix=".tmp")
|
|
424
|
+
temp_path = Path(temp_path)
|
|
425
|
+
|
|
426
|
+
# Write to temp file
|
|
427
|
+
with os.fdopen(temp_fd, "w", encoding="utf-8") as f:
|
|
428
|
+
if isinstance(data, dict):
|
|
429
|
+
data["items"] = items
|
|
430
|
+
json.dump(data, f, indent=2)
|
|
431
|
+
else:
|
|
432
|
+
json.dump(items, f, indent=2)
|
|
433
|
+
temp_fd = None
|
|
434
|
+
|
|
435
|
+
# Atomic replace
|
|
436
|
+
os.replace(temp_path, p)
|
|
437
|
+
return True, None
|
|
438
|
+
|
|
439
|
+
except Exception as e:
|
|
440
|
+
if temp_fd is not None:
|
|
441
|
+
os.close(temp_fd)
|
|
442
|
+
# Clean up temp file if it exists
|
|
443
|
+
try:
|
|
444
|
+
temp_path.unlink(missing_ok=True)
|
|
445
|
+
except Exception:
|
|
446
|
+
pass
|
|
447
|
+
return False, f"Failed to update tracker: {e}"
|
|
448
|
+
|
|
449
|
+
except Exception as e:
|
|
450
|
+
return False, f"Failed to update tracker: {e}"
|
|
451
|
+
|
|
452
|
+
|
|
453
|
+
# ========================================================================
|
|
454
|
+
# Reporting
|
|
455
|
+
# ========================================================================
|
|
456
|
+
|
|
457
|
+
def emit_report(
|
|
458
|
+
phase: str,
|
|
459
|
+
wave_id: str,
|
|
460
|
+
items_selected: List[str],
|
|
461
|
+
items_shipped: Optional[List[Dict[str, Any]]] = None,
|
|
462
|
+
items_failed_build: Optional[List[str]] = None,
|
|
463
|
+
items_skipped: Optional[List[Dict[str, str]]] = None,
|
|
464
|
+
branch: Optional[str] = None,
|
|
465
|
+
sha: Optional[str] = None,
|
|
466
|
+
halt_reason: Optional[str] = None,
|
|
467
|
+
ceiling_reason: Optional[str] = None,
|
|
468
|
+
error: Optional[str] = None,
|
|
469
|
+
tracker_update_error: Optional[str] = None,
|
|
470
|
+
tracker_update_attempted: bool = False,
|
|
471
|
+
tracker_unmapped_slugs: Optional[List[str]] = None,
|
|
472
|
+
success: bool = False,
|
|
473
|
+
merged: bool = False,
|
|
474
|
+
) -> Dict[str, Any]:
|
|
475
|
+
"""Emit a Report JSON structure (GATE-1 HANDOFF KIT).
|
|
476
|
+
|
|
477
|
+
Per-item observability: items_shipped includes full details {slug, backend, tier, verified, testExit}.
|
|
478
|
+
|
|
479
|
+
Returns:
|
|
480
|
+
report dict (ready to serialize)
|
|
481
|
+
"""
|
|
482
|
+
report = {
|
|
483
|
+
"phase": phase,
|
|
484
|
+
"wave_id": wave_id,
|
|
485
|
+
"items_selected": items_selected,
|
|
486
|
+
"items_shipped": items_shipped or [],
|
|
487
|
+
"merged": merged, # P2c: explicit merged=false in pilot
|
|
488
|
+
"timestamp": datetime.now(timezone.utc).isoformat(),
|
|
489
|
+
"success": success,
|
|
490
|
+
}
|
|
491
|
+
|
|
492
|
+
if items_failed_build:
|
|
493
|
+
report["items_failed_build"] = items_failed_build
|
|
494
|
+
if items_skipped:
|
|
495
|
+
report["items_skipped"] = items_skipped
|
|
496
|
+
if branch:
|
|
497
|
+
report["branch"] = branch
|
|
498
|
+
if sha:
|
|
499
|
+
report["sha"] = sha
|
|
500
|
+
if halt_reason:
|
|
501
|
+
report["halt_reason"] = halt_reason
|
|
502
|
+
if ceiling_reason:
|
|
503
|
+
report["ceiling_reason"] = ceiling_reason
|
|
504
|
+
if error:
|
|
505
|
+
report["error"] = error
|
|
506
|
+
if tracker_update_error:
|
|
507
|
+
report["tracker_update_error"] = tracker_update_error
|
|
508
|
+
if tracker_update_attempted:
|
|
509
|
+
report["tracker_update_attempted"] = True
|
|
510
|
+
if tracker_unmapped_slugs:
|
|
511
|
+
report["tracker_unmapped_slugs"] = tracker_unmapped_slugs
|
|
512
|
+
|
|
513
|
+
return report
|
|
514
|
+
|
|
515
|
+
|
|
516
|
+
# ========================================================================
|
|
517
|
+
# Main Orchestrator
|
|
518
|
+
# ========================================================================
|
|
519
|
+
|
|
520
|
+
def run_wave_scheduler(
|
|
521
|
+
tracker_path: str,
|
|
522
|
+
max_items: int = 5,
|
|
523
|
+
dry_run: bool = False,
|
|
524
|
+
driver: Optional[AgentDriver] = None,
|
|
525
|
+
state_dir: Optional[Path] = None,
|
|
526
|
+
) -> Dict[str, Any]:
|
|
527
|
+
"""Run one complete wave cycle (intake -> manifest -> dispatch -> report).
|
|
528
|
+
|
|
529
|
+
Args:
|
|
530
|
+
tracker_path: path to tracker.json
|
|
531
|
+
max_items: max items to select
|
|
532
|
+
dry_run: if True, print manifest without dispatch
|
|
533
|
+
driver: AgentDriver instance (defaults to FakeDriver for testing)
|
|
534
|
+
state_dir: state directory (defaults to ./state)
|
|
535
|
+
|
|
536
|
+
Returns:
|
|
537
|
+
Report dict (phase, wave_id, items_selected, items_shipped, etc.)
|
|
538
|
+
"""
|
|
539
|
+
if state_dir is None:
|
|
540
|
+
try:
|
|
541
|
+
state_dir = get_state_dir()
|
|
542
|
+
except Exception:
|
|
543
|
+
state_dir = Path("./state")
|
|
544
|
+
|
|
545
|
+
state_dir = Path(state_dir)
|
|
546
|
+
wave_id = str(uuid.uuid4())
|
|
547
|
+
|
|
548
|
+
# ====== PHASE 0: GATE AVAILABILITY CHECK (P1-3) ======
|
|
549
|
+
gates_ok, gate_error = _verify_gate_availability()
|
|
550
|
+
if not gates_ok:
|
|
551
|
+
return emit_report(
|
|
552
|
+
phase="gate_unavailable",
|
|
553
|
+
wave_id=wave_id,
|
|
554
|
+
items_selected=[],
|
|
555
|
+
error=gate_error,
|
|
556
|
+
success=False,
|
|
557
|
+
)
|
|
558
|
+
|
|
559
|
+
# ====== PHASE 1: HALT CHECK ======
|
|
560
|
+
try:
|
|
561
|
+
is_halted, halt_reason = _check_halt_file(state_dir)
|
|
562
|
+
except RuntimeError as e:
|
|
563
|
+
return emit_report(
|
|
564
|
+
phase="halt",
|
|
565
|
+
wave_id=wave_id,
|
|
566
|
+
items_selected=[],
|
|
567
|
+
error=str(e),
|
|
568
|
+
success=False,
|
|
569
|
+
)
|
|
570
|
+
|
|
571
|
+
if is_halted:
|
|
572
|
+
return emit_report(
|
|
573
|
+
phase="halt",
|
|
574
|
+
wave_id=wave_id,
|
|
575
|
+
items_selected=[],
|
|
576
|
+
halt_reason=halt_reason,
|
|
577
|
+
success=False,
|
|
578
|
+
)
|
|
579
|
+
|
|
580
|
+
# ====== PHASE 2: INTAKE + VALIDATION (P1-6, P5) ======
|
|
581
|
+
# P6: Read tracker with hash for conflict detection
|
|
582
|
+
all_items, intake_hash = _read_tracker_with_hash(tracker_path)
|
|
583
|
+
todo_items = filter_todo_items(all_items)
|
|
584
|
+
|
|
585
|
+
# Separate valid from invalid items
|
|
586
|
+
valid_items = []
|
|
587
|
+
items_skipped = []
|
|
588
|
+
for item in todo_items:
|
|
589
|
+
is_valid, error_reason = _validate_item(item)
|
|
590
|
+
if is_valid:
|
|
591
|
+
valid_items.append(item)
|
|
592
|
+
else:
|
|
593
|
+
items_skipped.append({"id": item.get("id", "unknown"), "reason": error_reason})
|
|
594
|
+
|
|
595
|
+
if not valid_items:
|
|
596
|
+
return emit_report(
|
|
597
|
+
phase="intake",
|
|
598
|
+
wave_id=wave_id,
|
|
599
|
+
items_selected=[],
|
|
600
|
+
items_skipped=items_skipped if items_skipped else None,
|
|
601
|
+
success=True,
|
|
602
|
+
)
|
|
603
|
+
|
|
604
|
+
# ====== PHASE 3: DISJOINT SELECTION ======
|
|
605
|
+
selected_items, skipped_ids = select_disjoint_items(valid_items, max_items)
|
|
606
|
+
|
|
607
|
+
if not selected_items:
|
|
608
|
+
return emit_report(
|
|
609
|
+
phase="intake",
|
|
610
|
+
wave_id=wave_id,
|
|
611
|
+
items_selected=[],
|
|
612
|
+
items_skipped=items_skipped if items_skipped else None,
|
|
613
|
+
success=True,
|
|
614
|
+
)
|
|
615
|
+
|
|
616
|
+
selected_ids = [item.get("id", "unknown") for item in selected_items]
|
|
617
|
+
|
|
618
|
+
# ====== PHASE 4: MANIFEST BUILD (P1-6) ======
|
|
619
|
+
if driver is None:
|
|
620
|
+
from tests.test_wave_loop import FakeDriver
|
|
621
|
+
driver = FakeDriver()
|
|
622
|
+
|
|
623
|
+
try:
|
|
624
|
+
manifest, failed_build_ids = build_wave_manifest(selected_items, driver)
|
|
625
|
+
except Exception as e:
|
|
626
|
+
return emit_report(
|
|
627
|
+
phase="manifest",
|
|
628
|
+
wave_id=wave_id,
|
|
629
|
+
items_selected=selected_ids,
|
|
630
|
+
error=str(e),
|
|
631
|
+
success=False,
|
|
632
|
+
)
|
|
633
|
+
|
|
634
|
+
# Remove failed items from selected
|
|
635
|
+
if failed_build_ids:
|
|
636
|
+
selected_ids = [id for id in selected_ids if id not in failed_build_ids]
|
|
637
|
+
|
|
638
|
+
# ====== PHASE 5: DRY-RUN CHECK ======
|
|
639
|
+
if dry_run:
|
|
640
|
+
return emit_report(
|
|
641
|
+
phase="manifest",
|
|
642
|
+
wave_id=wave_id,
|
|
643
|
+
items_selected=selected_ids,
|
|
644
|
+
items_failed_build=failed_build_ids if failed_build_ids else None,
|
|
645
|
+
success=True,
|
|
646
|
+
)
|
|
647
|
+
|
|
648
|
+
# ====== PHASE 6: COST CEILING CHECK (P2b: before final HALT) ======
|
|
649
|
+
try:
|
|
650
|
+
ceiling_exceeded, ceiling_reason = _check_cost_ceiling(state_dir)
|
|
651
|
+
except RuntimeError as e:
|
|
652
|
+
return emit_report(
|
|
653
|
+
phase="ceiling",
|
|
654
|
+
wave_id=wave_id,
|
|
655
|
+
items_selected=selected_ids,
|
|
656
|
+
error=str(e),
|
|
657
|
+
success=False,
|
|
658
|
+
)
|
|
659
|
+
|
|
660
|
+
if ceiling_exceeded:
|
|
661
|
+
return emit_report(
|
|
662
|
+
phase="ceiling",
|
|
663
|
+
wave_id=wave_id,
|
|
664
|
+
items_selected=selected_ids,
|
|
665
|
+
ceiling_reason=ceiling_reason,
|
|
666
|
+
success=False,
|
|
667
|
+
)
|
|
668
|
+
|
|
669
|
+
# ====== PHASE 7: FINAL HALT CHECK (P2b, P4: immediately before dispatch) ======
|
|
670
|
+
try:
|
|
671
|
+
is_halted, halt_reason = _check_halt_file(state_dir)
|
|
672
|
+
except RuntimeError as e:
|
|
673
|
+
return emit_report(
|
|
674
|
+
phase="halt",
|
|
675
|
+
wave_id=wave_id,
|
|
676
|
+
items_selected=selected_ids,
|
|
677
|
+
error=str(e),
|
|
678
|
+
success=False,
|
|
679
|
+
)
|
|
680
|
+
|
|
681
|
+
if is_halted:
|
|
682
|
+
return emit_report(
|
|
683
|
+
phase="halt",
|
|
684
|
+
wave_id=wave_id,
|
|
685
|
+
items_selected=selected_ids,
|
|
686
|
+
halt_reason=halt_reason,
|
|
687
|
+
success=False,
|
|
688
|
+
)
|
|
689
|
+
|
|
690
|
+
# ====== PHASE 8: RUN WAVE ======
|
|
691
|
+
try:
|
|
692
|
+
state_dir.mkdir(parents=True, exist_ok=True)
|
|
693
|
+
|
|
694
|
+
wave_result = run_wave(
|
|
695
|
+
driver=driver,
|
|
696
|
+
manifest=manifest,
|
|
697
|
+
state_dir=state_dir,
|
|
698
|
+
git={"expectTopLevel": str(REPO)},
|
|
699
|
+
resume_journal=True,
|
|
700
|
+
)
|
|
701
|
+
|
|
702
|
+
# P2c: verify no merged=True, record merged=false
|
|
703
|
+
# GATE-1: per-item observability {slug, backend, tier, verified, testExit}.
|
|
704
|
+
# REAL run_wave shape (live-pilot fix): "shipped" is a list of SLUG
|
|
705
|
+
# STRINGS; the per-item records live in "built". Join them.
|
|
706
|
+
backend_name = driver.probe_capabilities().name
|
|
707
|
+
built_by_slug = {
|
|
708
|
+
b.get("slug"): b for b in (wave_result.get("built") or []) if isinstance(b, dict)
|
|
709
|
+
}
|
|
710
|
+
items_shipped = []
|
|
711
|
+
shipped_slugs = []
|
|
712
|
+
for slug in wave_result.get("shipped", []) or []:
|
|
713
|
+
if isinstance(slug, dict): # tolerate dict-shaped fakes
|
|
714
|
+
slug = slug.get("slug", "unknown")
|
|
715
|
+
shipped_slugs.append(slug)
|
|
716
|
+
b = built_by_slug.get(slug, {})
|
|
717
|
+
items_shipped.append({
|
|
718
|
+
"slug": slug,
|
|
719
|
+
"backend": backend_name,
|
|
720
|
+
# tier None = no build record (unknown), never a fabricated 1.
|
|
721
|
+
"tier": b.get("verificationTier") if b else None,
|
|
722
|
+
# verified False = NOT PROVEN (conservative), see REPORT-CONTRACT.
|
|
723
|
+
"verified": b.get("verified", False),
|
|
724
|
+
"testExit": b.get("testExit"),
|
|
725
|
+
"buildRecord": bool(b),
|
|
726
|
+
})
|
|
727
|
+
|
|
728
|
+
# TRACKER UPDATE FIRST (live-pilot lesson: a crash in Report assembly
|
|
729
|
+
# must never leave shipped-but-unmarked items). Attempted as soon as
|
|
730
|
+
# shipped_slugs is known; outcome fields survive into ANY report,
|
|
731
|
+
# including the exception envelope below.
|
|
732
|
+
tracker_update_attempted = False
|
|
733
|
+
tracker_update_error = None
|
|
734
|
+
tracker_unmapped = []
|
|
735
|
+
if shipped_slugs:
|
|
736
|
+
tracker_update_attempted = True
|
|
737
|
+
try:
|
|
738
|
+
slug_to_id = {it.get("slug"): it.get("id") for it in selected_items}
|
|
739
|
+
shipped_item_ids = []
|
|
740
|
+
for s_ in shipped_slugs:
|
|
741
|
+
id_ = slug_to_id.get(s_)
|
|
742
|
+
if id_:
|
|
743
|
+
shipped_item_ids.append(id_)
|
|
744
|
+
else:
|
|
745
|
+
# LOUD, never silent: an unmapped shipped slug means the
|
|
746
|
+
# tracker cannot be marked -> double-dispatch risk.
|
|
747
|
+
tracker_unmapped.append(s_)
|
|
748
|
+
if shipped_item_ids:
|
|
749
|
+
success_update, update_error = _write_tracker_status_atomic(
|
|
750
|
+
tracker_path,
|
|
751
|
+
shipped_item_ids,
|
|
752
|
+
"in_progress",
|
|
753
|
+
wave_id,
|
|
754
|
+
expected_hash=intake_hash, # P6: conflict detection
|
|
755
|
+
)
|
|
756
|
+
if not success_update:
|
|
757
|
+
tracker_update_error = update_error
|
|
758
|
+
if tracker_unmapped and tracker_update_error is None:
|
|
759
|
+
tracker_update_error = "unmapped_shipped_slugs"
|
|
760
|
+
except Exception as te:
|
|
761
|
+
tracker_update_error = f"tracker_update_exception: {te}"
|
|
762
|
+
|
|
763
|
+
# Ship sha comes from the per-repo ship results (no top-level sha key).
|
|
764
|
+
repo_results = wave_result.get("shipped_repos") or []
|
|
765
|
+
sha = next((r.get("sha") for r in repo_results if isinstance(r, dict) and r.get("sha")), None)
|
|
766
|
+
branch = None # run_wave ships on the current branch; scheduler does not switch branches
|
|
767
|
+
|
|
768
|
+
# run_wave has NO top-level "success" key: derive honestly. success
|
|
769
|
+
# additionally requires EVERY shipped item verified (contract: a
|
|
770
|
+
# shipped-but-unproven item is not a successful wave).
|
|
771
|
+
wave_ok = bool(wave_result.get("preflight_ok")) and not wave_result.get("aborted")
|
|
772
|
+
all_verified = all(i.get("verified") for i in items_shipped) if items_shipped else True
|
|
773
|
+
return emit_report(
|
|
774
|
+
phase="dispatch",
|
|
775
|
+
wave_id=wave_id,
|
|
776
|
+
items_selected=selected_ids,
|
|
777
|
+
items_shipped=items_shipped,
|
|
778
|
+
items_failed_build=failed_build_ids if failed_build_ids else None,
|
|
779
|
+
branch=branch,
|
|
780
|
+
sha=sha,
|
|
781
|
+
tracker_update_attempted=tracker_update_attempted,
|
|
782
|
+
tracker_update_error=tracker_update_error,
|
|
783
|
+
tracker_unmapped_slugs=tracker_unmapped if tracker_unmapped else None,
|
|
784
|
+
success=wave_ok and all_verified and tracker_update_error is None,
|
|
785
|
+
merged=False, # P2c: pilot stops before merge
|
|
786
|
+
)
|
|
787
|
+
|
|
788
|
+
except Exception as e:
|
|
789
|
+
# The exception envelope must never hide ship-vs-tracker divergence:
|
|
790
|
+
# carry whatever tracker outcome was reached before the crash.
|
|
791
|
+
return emit_report(
|
|
792
|
+
phase="dispatch",
|
|
793
|
+
wave_id=wave_id,
|
|
794
|
+
items_selected=selected_ids,
|
|
795
|
+
error=str(e),
|
|
796
|
+
tracker_update_attempted=locals().get("tracker_update_attempted", False),
|
|
797
|
+
tracker_update_error=locals().get("tracker_update_error"),
|
|
798
|
+
success=False,
|
|
799
|
+
merged=False,
|
|
800
|
+
)
|
|
801
|
+
|
|
802
|
+
|
|
803
|
+
# ========================================================================
|
|
804
|
+
# CLI
|
|
805
|
+
# ========================================================================
|
|
806
|
+
|
|
807
|
+
def main():
|
|
808
|
+
"""CLI entry point (GATE-1: driver injection, --driver claude|codex)."""
|
|
809
|
+
parser = argparse.ArgumentParser(
|
|
810
|
+
description="Wave scheduler: intake -> manifest -> dispatch -> report"
|
|
811
|
+
)
|
|
812
|
+
parser.add_argument(
|
|
813
|
+
"--tracker",
|
|
814
|
+
required=True,
|
|
815
|
+
help="Path to tracker.json",
|
|
816
|
+
)
|
|
817
|
+
parser.add_argument(
|
|
818
|
+
"--max-items",
|
|
819
|
+
type=int,
|
|
820
|
+
default=5,
|
|
821
|
+
help="Maximum items to select (default: 5)",
|
|
822
|
+
)
|
|
823
|
+
parser.add_argument(
|
|
824
|
+
"--dry-run",
|
|
825
|
+
action="store_true",
|
|
826
|
+
help="Print manifest without dispatch",
|
|
827
|
+
)
|
|
828
|
+
parser.add_argument(
|
|
829
|
+
"--execute",
|
|
830
|
+
action="store_true",
|
|
831
|
+
help="Execute the wave (default: dry-run)",
|
|
832
|
+
)
|
|
833
|
+
parser.add_argument(
|
|
834
|
+
"--state-dir",
|
|
835
|
+
help="State directory (default: ./state)",
|
|
836
|
+
)
|
|
837
|
+
parser.add_argument(
|
|
838
|
+
"--driver",
|
|
839
|
+
choices=["claude", "codex"],
|
|
840
|
+
default="claude",
|
|
841
|
+
help="Backend driver (claude|codex, default: claude)",
|
|
842
|
+
)
|
|
843
|
+
|
|
844
|
+
args = parser.parse_args()
|
|
845
|
+
|
|
846
|
+
dry_run = not args.execute
|
|
847
|
+
|
|
848
|
+
# GATE-1: driver injection (instantiate based on --driver flag)
|
|
849
|
+
driver = None
|
|
850
|
+
if args.driver == "codex":
|
|
851
|
+
# CodexDriver requires OPENAI_API_KEY for execute; --dry-run works without it
|
|
852
|
+
try:
|
|
853
|
+
from codex_driver import CodexDriver
|
|
854
|
+
if args.execute and not os.environ.get("OPENAI_API_KEY"):
|
|
855
|
+
print(
|
|
856
|
+
"ERROR: --driver codex --execute requires OPENAI_API_KEY environment variable",
|
|
857
|
+
file=sys.stderr,
|
|
858
|
+
)
|
|
859
|
+
sys.exit(1)
|
|
860
|
+
driver = CodexDriver()
|
|
861
|
+
except ImportError:
|
|
862
|
+
print("ERROR: --driver codex requires codex_driver.py", file=sys.stderr)
|
|
863
|
+
sys.exit(1)
|
|
864
|
+
else:
|
|
865
|
+
# Default: Claude Code driver
|
|
866
|
+
try:
|
|
867
|
+
from claude_code_driver import ClaudeCodeDriver
|
|
868
|
+
driver = ClaudeCodeDriver()
|
|
869
|
+
except ImportError:
|
|
870
|
+
print("ERROR: claude_code_driver.py not found", file=sys.stderr)
|
|
871
|
+
sys.exit(1)
|
|
872
|
+
|
|
873
|
+
# Run scheduler
|
|
874
|
+
report = run_wave_scheduler(
|
|
875
|
+
tracker_path=args.tracker,
|
|
876
|
+
max_items=args.max_items,
|
|
877
|
+
dry_run=dry_run,
|
|
878
|
+
driver=driver,
|
|
879
|
+
state_dir=Path(args.state_dir) if args.state_dir else None,
|
|
880
|
+
)
|
|
881
|
+
|
|
882
|
+
# Output report as JSON
|
|
883
|
+
print(json.dumps(report, indent=2))
|
|
884
|
+
|
|
885
|
+
# Exit with success/failure
|
|
886
|
+
sys.exit(0 if report.get("success") else 1)
|
|
887
|
+
|
|
888
|
+
|
|
889
|
+
if __name__ == "__main__":
|
|
890
|
+
main()
|