@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
package/hooks/pre-push-policy.sh
CHANGED
|
@@ -252,7 +252,7 @@ check_branch_policy() {
|
|
|
252
252
|
# Finding 3: Handle tty mode and final line without trailing newline
|
|
253
253
|
if [ -t 0 ]; then
|
|
254
254
|
# Running interactively on a tty; skip stdin processing with note
|
|
255
|
-
# but still check current branch as fallback
|
|
255
|
+
# but still check current branch as fallback. Note: main() blocks tty before this can matter.
|
|
256
256
|
:
|
|
257
257
|
else
|
|
258
258
|
# Not a tty; read stdin normally
|
|
@@ -323,7 +323,9 @@ get_commit_range() {
|
|
|
323
323
|
local saw_any_tuple=0
|
|
324
324
|
|
|
325
325
|
if [ -t 0 ]; then
|
|
326
|
-
# Running interactively on a tty; no stdin to parse
|
|
326
|
+
# Running interactively on a tty; no stdin to parse → fail-closed (direct hook invocation)
|
|
327
|
+
# main() blocks tty before this is reached via the normal flow; nearly dead code but tests exercise it
|
|
328
|
+
printf 'Error: No stdin on tty; cannot parse pre-push ref tuples (interactive hook invocation)\n' >&2
|
|
327
329
|
return 1
|
|
328
330
|
fi
|
|
329
331
|
|
|
@@ -623,6 +625,13 @@ run_test_mode() {
|
|
|
623
625
|
(
|
|
624
626
|
export AESOP_ROOT="$tmpdir/aesop"
|
|
625
627
|
mkdir -p "$AESOP_ROOT/state"
|
|
628
|
+
# SCOPED (identity-polluter class): quoted-name check runs inside an
|
|
629
|
+
# isolated fixture repo; must NEVER touch the live repo config.
|
|
630
|
+
SELFTEST_REPO="$tmpdir/selftest_identity_repo"
|
|
631
|
+
mkdir -p "$SELFTEST_REPO"
|
|
632
|
+
cd "$SELFTEST_REPO" || exit 1
|
|
633
|
+
git init -q
|
|
634
|
+
git config user.email "test@example.com"
|
|
626
635
|
git config user.name 'John "Jack" Doe'
|
|
627
636
|
log_block "reason_with_backslash\\test"
|
|
628
637
|
|
|
@@ -1038,15 +1047,23 @@ main() {
|
|
|
1038
1047
|
exit $?
|
|
1039
1048
|
fi
|
|
1040
1049
|
|
|
1050
|
+
# Option B: Fail-closed TTY semantics — block interactive invocation before stdin capture.
|
|
1051
|
+
# git pre-push ALWAYS pipes stdin; tty means human ran hook directly (not via git push).
|
|
1052
|
+
# Refusing to skip security checks in interactive mode closes the window where empty stdin
|
|
1053
|
+
# could be misinterpreted as a legitimate up-to-date push.
|
|
1054
|
+
if [ -t 0 ]; then
|
|
1055
|
+
printf 'Error: interactive invocation: this hook runs under git push; refusing to skip security checks (fail-closed)\n' >&2
|
|
1056
|
+
log_block "interactive_invocation_blocked"
|
|
1057
|
+
exit 1
|
|
1058
|
+
fi
|
|
1059
|
+
|
|
1041
1060
|
# git pre-push provides ref info on stdin, and BOTH check_branch_policy and
|
|
1042
1061
|
# check_secret_scan (via get_commit_range) need to read every ref tuple.
|
|
1043
1062
|
# Capture the real pipe ONCE here and hand each consumer its own here-string
|
|
1044
1063
|
# copy -- reading a pipe twice on the same fd starves the second reader
|
|
1045
1064
|
# (once check_branch_policy drains it, get_commit_range would see nothing
|
|
1046
1065
|
# but EOF and fail-closed on every push). A here-string preserves each
|
|
1047
|
-
# function's existing tty-vs-pipe read semantics unchanged
|
|
1048
|
-
# tty yields no captured content, which both functions already treat the
|
|
1049
|
-
# same way as "no ref tuples" via their existing fallback/fail-closed paths).
|
|
1066
|
+
# function's existing tty-vs-pipe read semantics unchanged.
|
|
1050
1067
|
local prepush_stdin=""
|
|
1051
1068
|
if [ ! -t 0 ]; then
|
|
1052
1069
|
prepush_stdin=$(cat)
|
|
@@ -613,6 +613,58 @@ function detectIsolationViolations() {
|
|
|
613
613
|
return { violations, count: violations.length };
|
|
614
614
|
}
|
|
615
615
|
|
|
616
|
+
// 12) Agent stall detection
|
|
617
|
+
// Calls tools/stall_check.py --json with bounded timeout; fails gracefully with NOT-AVAILABLE signal
|
|
618
|
+
function checkAgentStalls() {
|
|
619
|
+
let stallCheckPy = path.join(path.dirname(MON), 'tools', 'stall_check.py');
|
|
620
|
+
|
|
621
|
+
// Fallback: look in SCRIPTS_ROOT if not found in tools
|
|
622
|
+
if (!fs.existsSync(stallCheckPy)) {
|
|
623
|
+
stallCheckPy = path.join(SCRIPTS_ROOT, 'stall_check.py');
|
|
624
|
+
}
|
|
625
|
+
|
|
626
|
+
// Fallback: look in the actual aesop source directory (for tests/CI environments)
|
|
627
|
+
if (!fs.existsSync(stallCheckPy)) {
|
|
628
|
+
const realMonitorCharter = path.join(__dirname, 'CHARTER.md');
|
|
629
|
+
if (fs.existsSync(realMonitorCharter)) {
|
|
630
|
+
stallCheckPy = path.join(__dirname, '..', 'tools', 'stall_check.py');
|
|
631
|
+
}
|
|
632
|
+
}
|
|
633
|
+
|
|
634
|
+
if (!fs.existsSync(stallCheckPy)) {
|
|
635
|
+
// stall_check.py not available; return NOT-AVAILABLE signal
|
|
636
|
+
return { available: false, count: 0, summary: 'Tool not found', stalls: [] };
|
|
637
|
+
}
|
|
638
|
+
|
|
639
|
+
try {
|
|
640
|
+
// Invoke stall_check.py with 5-second timeout (bounded, fail-open)
|
|
641
|
+
const result = spawnSync('python', [stallCheckPy, '--json'], {
|
|
642
|
+
encoding: 'utf8',
|
|
643
|
+
timeout: 5000,
|
|
644
|
+
stdio: ['ignore', 'pipe', 'pipe'],
|
|
645
|
+
});
|
|
646
|
+
|
|
647
|
+
if (result.status !== 0 || result.error) {
|
|
648
|
+
// Tool execution failed; return NOT-AVAILABLE signal
|
|
649
|
+
return { available: false, count: 0, summary: 'Tool execution failed', stalls: [] };
|
|
650
|
+
}
|
|
651
|
+
|
|
652
|
+
const stalls = JSON.parse(result.stdout || '[]');
|
|
653
|
+
const stalledCount = stalls.filter(s => s.verdict && ['stale', 'dead'].includes(s.verdict)).length;
|
|
654
|
+
|
|
655
|
+
return {
|
|
656
|
+
available: true,
|
|
657
|
+
count: stalledCount,
|
|
658
|
+
total: stalls.length,
|
|
659
|
+
summary: stalledCount > 0 ? `${stalledCount} agent(s) stalled` : 'No stalls detected',
|
|
660
|
+
stalls: stalls,
|
|
661
|
+
};
|
|
662
|
+
} catch (e) {
|
|
663
|
+
// Parse error or other exception; return NOT-AVAILABLE signal
|
|
664
|
+
return { available: false, count: 0, summary: `Error: ${e.message}`, stalls: [] };
|
|
665
|
+
}
|
|
666
|
+
}
|
|
667
|
+
|
|
616
668
|
// === AUTO Actions ===
|
|
617
669
|
// Log rotation: invoke rotate_logs.py if available and log needs rotation
|
|
618
670
|
function performAutoLogRotation(logFiles, actionsLogPath) {
|
|
@@ -839,6 +891,7 @@ const gitState = checkGitState();
|
|
|
839
891
|
const memory = checkMemoryFreshness();
|
|
840
892
|
const logFiles = checkLogFiles(securityAlertsContent);
|
|
841
893
|
const isolationViolations = detectIsolationViolations();
|
|
894
|
+
const agentStalls = checkAgentStalls();
|
|
842
895
|
|
|
843
896
|
// Extended signal checks (5, 6, 8, 10) — skipped if extended_signals is OFF
|
|
844
897
|
const junk = extendedSignals ? detectJunkScripts() : { skipped: true };
|
|
@@ -888,6 +941,7 @@ const signals = {
|
|
|
888
941
|
costTick,
|
|
889
942
|
unreviewedPrompts,
|
|
890
943
|
isolationViolations,
|
|
944
|
+
agentStalls,
|
|
891
945
|
};
|
|
892
946
|
|
|
893
947
|
const brief = [];
|
|
@@ -946,6 +1000,21 @@ if (isolationViolations.count === 0) {
|
|
|
946
1000
|
}
|
|
947
1001
|
brief.push('');
|
|
948
1002
|
|
|
1003
|
+
brief.push('## Agent stalls');
|
|
1004
|
+
if (!agentStalls.available) {
|
|
1005
|
+
brief.push(`NOT-AVAILABLE: ${agentStalls.summary}`);
|
|
1006
|
+
} else if (agentStalls.count === 0) {
|
|
1007
|
+
brief.push('✓ No agent stalls detected.');
|
|
1008
|
+
} else {
|
|
1009
|
+
brief.push(`🚨 **${agentStalls.count} agent(s) stalled** (${agentStalls.total} total scanned):`);
|
|
1010
|
+
for (const stall of agentStalls.stalls) {
|
|
1011
|
+
if (['stale', 'dead'].includes(stall.verdict)) {
|
|
1012
|
+
brief.push(` - ${stall.agent}: ${stall.verdict} (${stall.mtime_age_s}s old)`);
|
|
1013
|
+
}
|
|
1014
|
+
}
|
|
1015
|
+
}
|
|
1016
|
+
brief.push('');
|
|
1017
|
+
|
|
949
1018
|
// Extended signals section (if disabled, just note they're off; if enabled, show details)
|
|
950
1019
|
if (extendedSignals) {
|
|
951
1020
|
brief.push('## Junk-script sprawl (temp/scratch)');
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@matt82198/aesop",
|
|
3
|
-
"version": "0.2
|
|
4
|
-
"description": "Multi-agent orchestration for AI coding agents, designed to survive failure
|
|
3
|
+
"version": "0.3.2",
|
|
4
|
+
"description": "Multi-agent orchestration for AI coding agents, designed to survive failure — a plain-file brain, git as the only durable layer, cheap subagent fleets, and guardrails enforced in code.",
|
|
5
5
|
"bin": {
|
|
6
6
|
"aesop": "bin/cli.js"
|
|
7
7
|
},
|
|
@@ -9,6 +9,8 @@
|
|
|
9
9
|
"files": [
|
|
10
10
|
"bin/*.js",
|
|
11
11
|
"daemons/*.sh",
|
|
12
|
+
"daemons/*.ps1",
|
|
13
|
+
"daemons/*.vbs",
|
|
12
14
|
"dash/*.mjs",
|
|
13
15
|
"dash/*.sh",
|
|
14
16
|
"monitor/collect-signals.mjs",
|
|
@@ -70,8 +72,8 @@
|
|
|
70
72
|
"scripts": {
|
|
71
73
|
"test": "node --test --test-force-exit --test-timeout=60000 tests/*.test.mjs",
|
|
72
74
|
"test:node": "node --test --test-force-exit --test-timeout=60000 tests/*.test.mjs",
|
|
73
|
-
"test:sh": "bash tests/test_pre_push_policy.sh && bash tests/test-run-watchdog.sh && bash tests/test-run-watchdog-halt.sh && bash tests/test-run-watchdog-lockguard.sh && bash tests/backup-fleet.test.sh && bash tests/test_reconstitute.sh && bash tests/test_reconstitute_fixes.sh && bash tests/test_agent_forensics.sh && bash tests/test-selfheal.sh && bash hooks/pre-push-policy.sh --test
|
|
75
|
+
"test:sh": "bash tests/test_pre_push_policy.sh && bash tests/test-run-watchdog.sh && bash tests/test-run-watchdog-halt.sh && bash tests/test-run-watchdog-lockguard.sh && bash tests/backup-fleet.test.sh && bash tests/test_reconstitute.sh && bash tests/test_reconstitute_fixes.sh && bash tests/test_agent_forensics.sh && bash tests/test-selfheal.sh && bash hooks/pre-push-policy.sh --test",
|
|
74
76
|
"test:py": "python -m unittest discover -s tests",
|
|
75
77
|
"test:all": "npm run test:node && npm run test:sh && npm run test:py"
|
|
76
78
|
}
|
|
77
|
-
}
|
|
79
|
+
}
|
package/state_store/__init__.py
CHANGED
|
@@ -12,7 +12,7 @@ in state_store/CLAUDE.md.
|
|
|
12
12
|
from .api import StateAPI
|
|
13
13
|
from .export import export_tracker
|
|
14
14
|
from .ingest import ingest_tracker_json
|
|
15
|
-
from .projections import project_tracker
|
|
15
|
+
from .projections import project_tracker, project_agent_lifecycle
|
|
16
16
|
from .store import EventStore, ConcurrencyConflict
|
|
17
17
|
|
|
18
18
|
__all__ = [
|
|
@@ -20,6 +20,7 @@ __all__ = [
|
|
|
20
20
|
"StateAPI",
|
|
21
21
|
"ConcurrencyConflict",
|
|
22
22
|
"project_tracker",
|
|
23
|
+
"project_agent_lifecycle",
|
|
23
24
|
"export_tracker",
|
|
24
25
|
"ingest_tracker_json",
|
|
25
26
|
]
|
|
@@ -124,3 +124,66 @@ def save_snapshot(store, stream: str, event_version: int, projection: dict) -> N
|
|
|
124
124
|
projection: the materialized state dict (e.g. full tracker projection)
|
|
125
125
|
"""
|
|
126
126
|
store.save_snapshot(stream, event_version, projection)
|
|
127
|
+
|
|
128
|
+
|
|
129
|
+
AGENT_LIFECYCLE_VERSION = 1
|
|
130
|
+
|
|
131
|
+
|
|
132
|
+
def project_agent_lifecycle(events: list) -> dict:
|
|
133
|
+
"""Project current agent lifecycle state from agent_* events.
|
|
134
|
+
|
|
135
|
+
Folds agent lifecycle events into a map of agent_id -> {state, transitions, last_activity}.
|
|
136
|
+
|
|
137
|
+
Event types:
|
|
138
|
+
- agent_dispatched: payload {"agent_id", "timestamp"}; marks dispatch start
|
|
139
|
+
- agent_working: payload {"agent_id", "timestamp"}; marks work in progress
|
|
140
|
+
- agent_done: payload {"agent_id", "timestamp"}; marks completion
|
|
141
|
+
- agent_stalled: payload {"agent_id", "timestamp"}; marks stall/error
|
|
142
|
+
|
|
143
|
+
Returns a dict: {"version": 1, "agents": [{"id", "state", "transitions", "last_activity"}, ...]}
|
|
144
|
+
where transitions is a list of {state, at (ISO 8601 timestamp)} in chronological order.
|
|
145
|
+
"""
|
|
146
|
+
agents = {}
|
|
147
|
+
|
|
148
|
+
for ev in events:
|
|
149
|
+
etype = ev.get("type")
|
|
150
|
+
payload = ev.get("payload") or {}
|
|
151
|
+
|
|
152
|
+
if etype in ("agent_dispatched", "agent_working", "agent_done", "agent_stalled"):
|
|
153
|
+
agent_id = payload.get("agent_id")
|
|
154
|
+
timestamp = payload.get("timestamp")
|
|
155
|
+
|
|
156
|
+
if agent_id is None:
|
|
157
|
+
continue
|
|
158
|
+
|
|
159
|
+
# Initialize agent if first time seeing it
|
|
160
|
+
if agent_id not in agents:
|
|
161
|
+
agents[agent_id] = {
|
|
162
|
+
"id": agent_id,
|
|
163
|
+
"state": None,
|
|
164
|
+
"transitions": [],
|
|
165
|
+
"last_activity": None,
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
# Map event type to state
|
|
169
|
+
state_map = {
|
|
170
|
+
"agent_dispatched": "dispatch",
|
|
171
|
+
"agent_working": "working",
|
|
172
|
+
"agent_done": "done",
|
|
173
|
+
"agent_stalled": "stalled",
|
|
174
|
+
}
|
|
175
|
+
new_state = state_map.get(etype)
|
|
176
|
+
|
|
177
|
+
# Only append transition if state actually changed
|
|
178
|
+
if new_state and new_state != agents[agent_id]["state"]:
|
|
179
|
+
agents[agent_id]["state"] = new_state
|
|
180
|
+
if timestamp:
|
|
181
|
+
agents[agent_id]["transitions"].append({
|
|
182
|
+
"state": new_state,
|
|
183
|
+
"at": timestamp,
|
|
184
|
+
})
|
|
185
|
+
agents[agent_id]["last_activity"] = timestamp
|
|
186
|
+
|
|
187
|
+
# Return as versioned list (ordered by agent_id for determinism)
|
|
188
|
+
agent_list = sorted(agents.values(), key=lambda a: a["id"])
|
|
189
|
+
return {"version": AGENT_LIFECYCLE_VERSION, "agents": agent_list}
|
|
@@ -0,0 +1,156 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""
|
|
3
|
+
state_store.read_api — Typed read-only facade for state surfaces.
|
|
4
|
+
|
|
5
|
+
Consolidates read patterns for: tracker snapshot, orchestrator-status,
|
|
6
|
+
heartbeat freshness, and ledger rows. This facade allows the underlying
|
|
7
|
+
state representation to change (git → SQLite → Postgres) without altering
|
|
8
|
+
caller code.
|
|
9
|
+
|
|
10
|
+
Callers use:
|
|
11
|
+
api = ReadAPI(state_dir)
|
|
12
|
+
tracker = api.read_tracker_snapshot()
|
|
13
|
+
status = api.read_orchestrator_status()
|
|
14
|
+
is_fresh = api.is_orchestrator_status_fresh(threshold_s=300)
|
|
15
|
+
is_fresh = api.check_heartbeat_fresh(".watchdog-heartbeat", 200)
|
|
16
|
+
rows = api.read_ledger_rows()
|
|
17
|
+
"""
|
|
18
|
+
import json
|
|
19
|
+
import sys
|
|
20
|
+
from datetime import datetime, timezone
|
|
21
|
+
from pathlib import Path
|
|
22
|
+
|
|
23
|
+
# Ensure tools module is importable (sys.path fix for bootstrapping)
|
|
24
|
+
repo_root = Path(__file__).parent.parent
|
|
25
|
+
if str(repo_root) not in sys.path:
|
|
26
|
+
sys.path.insert(0, str(repo_root))
|
|
27
|
+
|
|
28
|
+
from tools.common import check_heartbeat_staleness
|
|
29
|
+
from tools.fleet_ledger import parse_ledger_rows as parse_ledger_rows_impl
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
class ReadAPI:
|
|
33
|
+
"""Read-only facade over state surfaces (tracker, orchestrator-status, heartbeats, ledger).
|
|
34
|
+
|
|
35
|
+
Designed to be swappable: backends can change (git → SQLite → Postgres) without
|
|
36
|
+
altering call sites. Current implementation reads from filesystem; future may
|
|
37
|
+
read from SQLite projections or API.
|
|
38
|
+
"""
|
|
39
|
+
|
|
40
|
+
def __init__(self, state_dir):
|
|
41
|
+
"""Initialize the read API with a state directory.
|
|
42
|
+
|
|
43
|
+
Args:
|
|
44
|
+
state_dir: Path to the state directory (e.g., "state" or "/absolute/path/state")
|
|
45
|
+
"""
|
|
46
|
+
self.state_dir = Path(state_dir)
|
|
47
|
+
self.state_dir.mkdir(parents=True, exist_ok=True)
|
|
48
|
+
|
|
49
|
+
def read_tracker_snapshot(self):
|
|
50
|
+
"""Read the current tracker snapshot.
|
|
51
|
+
|
|
52
|
+
Returns the tracker.json file, or an empty dict if missing/unreadable.
|
|
53
|
+
In the future, this may read from the SQLite projection instead.
|
|
54
|
+
|
|
55
|
+
Returns:
|
|
56
|
+
dict: Tracker snapshot (items, metadata, etc.) or {} if unavailable.
|
|
57
|
+
"""
|
|
58
|
+
tracker_file = self.state_dir / "tracker.json"
|
|
59
|
+
if not tracker_file.exists():
|
|
60
|
+
return {}
|
|
61
|
+
|
|
62
|
+
try:
|
|
63
|
+
content = tracker_file.read_text(encoding="utf-8")
|
|
64
|
+
return json.loads(content)
|
|
65
|
+
except Exception:
|
|
66
|
+
# Malformed JSON or read error: return empty dict (fail-open)
|
|
67
|
+
return {}
|
|
68
|
+
|
|
69
|
+
def read_orchestrator_status(self):
|
|
70
|
+
"""Read orchestrator-status.json if present.
|
|
71
|
+
|
|
72
|
+
Returns:
|
|
73
|
+
dict with "updated_at" (required) and optional "phase", "activity" fields,
|
|
74
|
+
or None if file missing/unreadable.
|
|
75
|
+
"""
|
|
76
|
+
status_file = self.state_dir / "orchestrator-status.json"
|
|
77
|
+
if not status_file.exists():
|
|
78
|
+
return None
|
|
79
|
+
|
|
80
|
+
try:
|
|
81
|
+
content = status_file.read_text(encoding="utf-8")
|
|
82
|
+
data = json.loads(content)
|
|
83
|
+
# Only require updated_at field for freshness checks;
|
|
84
|
+
# phase is optional (may not be set when status.json created before phase exists)
|
|
85
|
+
# Don't validate here—let callers decide what fields are required for their use case
|
|
86
|
+
return data
|
|
87
|
+
except Exception:
|
|
88
|
+
return None
|
|
89
|
+
|
|
90
|
+
def is_orchestrator_status_fresh(self, threshold_s=300):
|
|
91
|
+
"""Check if orchestrator-status.json exists and is fresh.
|
|
92
|
+
|
|
93
|
+
Reads updated_at timestamp and compares to current time.
|
|
94
|
+
Returns False if file missing, unparseable, or stale.
|
|
95
|
+
|
|
96
|
+
Args:
|
|
97
|
+
threshold_s: Staleness threshold in seconds (default: 300s)
|
|
98
|
+
|
|
99
|
+
Returns:
|
|
100
|
+
bool: True if file is fresh, False if stale/missing/malformed.
|
|
101
|
+
"""
|
|
102
|
+
status = self.read_orchestrator_status()
|
|
103
|
+
if status is None:
|
|
104
|
+
return False
|
|
105
|
+
|
|
106
|
+
try:
|
|
107
|
+
updated_at_str = status.get("updated_at")
|
|
108
|
+
if not updated_at_str:
|
|
109
|
+
return False
|
|
110
|
+
|
|
111
|
+
# Parse ISO format timestamp (handle both "Z" and "+00:00")
|
|
112
|
+
normalized = updated_at_str.replace("Z", "+00:00")
|
|
113
|
+
updated_at = datetime.fromisoformat(normalized)
|
|
114
|
+
|
|
115
|
+
now = datetime.now(timezone.utc)
|
|
116
|
+
age = (now - updated_at).total_seconds()
|
|
117
|
+
|
|
118
|
+
# Treat future-dated timestamps as stale (fail-closed)
|
|
119
|
+
if age < -120: # More than 2min in future = clock skew, treat as stale
|
|
120
|
+
return False
|
|
121
|
+
|
|
122
|
+
# Clamp small negative ages to 0
|
|
123
|
+
age = max(0, age)
|
|
124
|
+
|
|
125
|
+
return age < threshold_s
|
|
126
|
+
except Exception:
|
|
127
|
+
return False
|
|
128
|
+
|
|
129
|
+
def check_heartbeat_fresh(self, heartbeat_filename, threshold_s=200):
|
|
130
|
+
"""Check if a heartbeat file is fresh.
|
|
131
|
+
|
|
132
|
+
Delegates to tools.common.check_heartbeat_staleness (single source of truth).
|
|
133
|
+
|
|
134
|
+
Args:
|
|
135
|
+
heartbeat_filename: Filename in state_dir (e.g., ".watchdog-heartbeat")
|
|
136
|
+
threshold_s: Staleness threshold in seconds (default: 200s)
|
|
137
|
+
|
|
138
|
+
Returns:
|
|
139
|
+
bool: True if file exists and is fresh, False otherwise.
|
|
140
|
+
"""
|
|
141
|
+
hb_file = self.state_dir / heartbeat_filename
|
|
142
|
+
# Use the shared implementation from common.py; invert staleness to freshness
|
|
143
|
+
is_stale, _, _ = check_heartbeat_staleness(hb_file, threshold_s)
|
|
144
|
+
return not is_stale
|
|
145
|
+
|
|
146
|
+
def read_ledger_rows(self):
|
|
147
|
+
"""Read OUTCOMES-LEDGER.md and parse rows.
|
|
148
|
+
|
|
149
|
+
Delegates to tools.fleet_ledger.parse_ledger_rows() (single source of truth).
|
|
150
|
+
Returns empty list if file missing or unparseable.
|
|
151
|
+
|
|
152
|
+
Returns:
|
|
153
|
+
list: List of row dicts parsed from the ledger table, or [].
|
|
154
|
+
"""
|
|
155
|
+
# Use the shared implementation from fleet_ledger.py
|
|
156
|
+
return parse_ledger_rows_impl()
|