agentpace 0.9.2__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.
agentpace/forecasts.py ADDED
@@ -0,0 +1,284 @@
1
+ """A local ledger of forecasts, so a user can later see how often their own estimates held.
2
+
3
+ `estimate --record` appends one forecast record to `~/.agentpace/forecasts.jsonl`. Scores are
4
+ appended later as separate records, never edited in place. The file is local only: `share` never
5
+ reads it. It may hold free text, such as task ids and a quoted human-scale timeline, but no file
6
+ paths, no data sources and nothing derived from the local secret.
7
+
8
+ A quote such as "3 weeks" is calendar time as someone said it. It is stored as given, and turned
9
+ into seconds only when it parses exactly, by fixed units. No other conversion is ever applied.
10
+ """
11
+
12
+ from __future__ import annotations
13
+
14
+ import hashlib
15
+ import json
16
+ import math
17
+ import re
18
+ import statistics
19
+ from collections.abc import Mapping, Sequence
20
+ from dataclasses import dataclass, field
21
+ from datetime import datetime, timezone
22
+ from pathlib import Path
23
+ from typing import Any
24
+
25
+ Record = dict[str, Any]
26
+
27
+ FORECAST, SCORE = "forecast", "score"
28
+ _UNITS = {
29
+ "min": 60,
30
+ "minute": 60,
31
+ "minutes": 60,
32
+ "h": 3600,
33
+ "hour": 3600,
34
+ "hours": 3600,
35
+ "d": 86_400,
36
+ "day": 86_400,
37
+ "days": 86_400,
38
+ "w": 7 * 86_400,
39
+ "week": 7 * 86_400,
40
+ "weeks": 7 * 86_400,
41
+ "mo": 30 * 86_400,
42
+ "month": 30 * 86_400,
43
+ "months": 30 * 86_400,
44
+ }
45
+ _DURATION = re.compile(
46
+ r"^\s*(\d+(?:\.\d+)?)\s*(min|minutes?|h|hours?|d|days?|w|weeks?|mo|months?)\s*$",
47
+ re.IGNORECASE,
48
+ )
49
+
50
+
51
+ def parse_duration(text: str) -> int | None:
52
+ """Seconds for an exact "<number> <unit>", by fixed units (1 d = 86,400 s, 1 w = 7 d,
53
+ 1 mo = 30 d). Anything else is None, never a guess."""
54
+ match = _DURATION.match(text)
55
+ if match is None:
56
+ return None
57
+ seconds = float(match.group(1)) * _UNITS[match.group(2).lower()]
58
+ return round(seconds) if seconds >= 1 else None
59
+
60
+
61
+ def utc_now() -> str:
62
+ return datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ")
63
+
64
+
65
+ def _canonical(record: Mapping[str, Any]) -> str:
66
+ return json.dumps(record, sort_keys=True, separators=(",", ":"), ensure_ascii=True)
67
+
68
+
69
+ def record_id(record: Mapping[str, Any]) -> str:
70
+ """Derived from the content and the time it was issued, so no random draw is needed."""
71
+ body = {key: value for key, value in record.items() if key != "id"}
72
+ return hashlib.sha256((record["issued_at"] + _canonical(body)).encode("utf-8")).hexdigest()[:8]
73
+
74
+
75
+ def forecast_record(
76
+ *,
77
+ issued_at: str,
78
+ version: str,
79
+ plan_document: Mapping[str, Any],
80
+ result: Mapping[str, Any],
81
+ quoted: str | None,
82
+ ) -> Record:
83
+ """`result` is the dict `render.plan_json` prints, so the ledger holds what the user saw."""
84
+ record: Record = {
85
+ "kind": FORECAST,
86
+ "issued_at": issued_at,
87
+ "version": version,
88
+ "method": result["method"],
89
+ "seed": result["seed"],
90
+ "draws": result["draws"],
91
+ "rows_used": result["rows_used"],
92
+ "rows_excluded": result["rows_excluded"],
93
+ "plan": plan_document,
94
+ "p50": result["p50"],
95
+ "p80": result["p80"],
96
+ "p90": result["p90"],
97
+ "tasks": [
98
+ {"id": t["id"], "p50": t["p50"], "p80": t["p80"], "p90": t["p90"], "cell": t["cell"]}
99
+ for t in result["tasks"]
100
+ ],
101
+ "human": result["human"],
102
+ }
103
+ if quoted is not None:
104
+ record["quoted"] = quoted
105
+ seconds = parse_duration(quoted)
106
+ if seconds is not None:
107
+ record["quoted_s"] = seconds
108
+ record["id"] = record_id(record)
109
+ return record
110
+
111
+
112
+ def append(path: Path, record: Mapping[str, Any]) -> None:
113
+ path.parent.mkdir(parents=True, exist_ok=True)
114
+ with path.open("a", encoding="utf-8", newline="\n") as handle:
115
+ handle.write(_canonical(record) + "\n")
116
+
117
+
118
+ @dataclass
119
+ class Ledger:
120
+ forecasts: dict[str, Record] = field(default_factory=dict)
121
+ scores: list[Record] = field(default_factory=list)
122
+ skipped: int = 0
123
+
124
+
125
+ def _valid(record: Any) -> bool:
126
+ if not isinstance(record, dict) or not isinstance(record.get("id"), str):
127
+ return False
128
+ if record.get("kind") == FORECAST:
129
+ return all(isinstance(record.get(key), (int, float)) for key in ("p50", "p80", "p90"))
130
+ if record.get("kind") == SCORE:
131
+ return isinstance(record.get("actual_s"), int)
132
+ return False
133
+
134
+
135
+ def read(path: Path) -> Ledger:
136
+ """Every valid record. A damaged or unknown line is skipped and counted, never fatal."""
137
+ ledger = Ledger()
138
+ if not path.exists():
139
+ return ledger
140
+ with path.open(encoding="utf-8") as handle:
141
+ for line in handle:
142
+ if not line.strip():
143
+ continue
144
+ try:
145
+ record = json.loads(line)
146
+ except ValueError:
147
+ record = None
148
+ if not _valid(record):
149
+ ledger.skipped += 1
150
+ elif record["kind"] == FORECAST:
151
+ ledger.forecasts[record["id"]] = record
152
+ else:
153
+ ledger.scores.append(record)
154
+ return ledger
155
+
156
+
157
+ def latest_scores(scores: Sequence[Record]) -> dict[str, Record]:
158
+ """The latest score per forecast wins, so a correction never needs an edit in place."""
159
+ latest: dict[str, Record] = {}
160
+ for score in scores:
161
+ latest[score["id"]] = score
162
+ return latest
163
+
164
+
165
+ # Scoring. A score compares a forecast with what happened. With a session, the actual is that
166
+ # session's span on its own clock: agent runs plus the gaps between them, each gap up to the idle
167
+ # cap. That is the same quantity the forecast predicts and the backtest scores. It is not wall
168
+ # clock, and no allowance for the human part is added to it. With `--actual`, the user attests the
169
+ # figure themselves.
170
+
171
+ MIN_RATED = 5
172
+ QUANTILE_KEYS = (("p50", 0.5), ("p80", 0.8), ("p90", 0.9))
173
+
174
+
175
+ class ScoreRefused(ValueError):
176
+ """A score that cannot be made honestly, with the reason as its message."""
177
+
178
+
179
+ def parse_issued(text: str) -> float:
180
+ return datetime.strptime(text, "%Y-%m-%dT%H:%M:%SZ").replace(tzinfo=timezone.utc).timestamp()
181
+
182
+
183
+ def iso_week_of(epoch: float) -> str:
184
+ year, week, _ = datetime.fromtimestamp(epoch, tz=timezone.utc).isocalendar()
185
+ return f"{year}-W{week:02d}"
186
+
187
+
188
+ def session_span(rows: Sequence[Mapping[str, Any]]) -> int:
189
+ """`max(offset + duration) - min(offset)` on the session clock, as in the backtest."""
190
+ end = max(int(row["offset_s"]) + int(row["duration_s"]) for row in rows)
191
+ return max(1, end - min(int(row["offset_s"]) for row in rows))
192
+
193
+
194
+ def _quote_closer(forecast: Mapping[str, Any], actual_s: int) -> bool | None:
195
+ """Whether the quoted timeline was closer to the actual than the P50, by ratio."""
196
+ quoted = forecast.get("quoted_s")
197
+ if not isinstance(quoted, int) or quoted <= 0 or forecast["p50"] <= 0 or actual_s <= 0:
198
+ return None
199
+ return abs(math.log(quoted / actual_s)) < abs(math.log(forecast["p50"] / actual_s))
200
+
201
+
202
+ def score_record(
203
+ forecast: Mapping[str, Any],
204
+ *,
205
+ actual_s: int,
206
+ source: str,
207
+ verified: bool,
208
+ scored_at: str,
209
+ session: str | None = None,
210
+ ) -> Record:
211
+ record: Record = {
212
+ "kind": SCORE,
213
+ "id": forecast["id"],
214
+ "scored_at": scored_at,
215
+ "actual_s": actual_s,
216
+ "source": source,
217
+ "verified": verified,
218
+ "quote_closer": _quote_closer(forecast, actual_s),
219
+ }
220
+ if session is not None:
221
+ record["session"] = session
222
+ return record
223
+
224
+
225
+ def check_session(
226
+ forecast: Mapping[str, Any], rows: Sequence[Mapping[str, Any]], first_start: float | None
227
+ ) -> int:
228
+ """The actual for a session score, or a refusal. A session scores only when it provably
229
+ started after the forecast was issued."""
230
+ if len(rows) < 2:
231
+ raise ScoreRefused(
232
+ "that session has fewer than 2 completed local runs; run `agentpace ingest`"
233
+ )
234
+ issued = parse_issued(forecast["issued_at"])
235
+ if min(row["week"] for row in rows) < iso_week_of(issued):
236
+ raise ScoreRefused("that session ran before the forecast was issued, so it cannot score it")
237
+ if first_start is None:
238
+ raise ScoreRefused(
239
+ "cannot verify this session started after the forecast; use --actual to attest it"
240
+ )
241
+ if first_start < issued:
242
+ raise ScoreRefused("that session started before the forecast was issued")
243
+ return session_span(rows)
244
+
245
+
246
+ def _pinball(actual: float, predicted: float, q: float) -> float:
247
+ gap = actual - predicted
248
+ return max(q * gap, (q - 1) * gap)
249
+
250
+
251
+ @dataclass(frozen=True)
252
+ class Summary:
253
+ scored: int
254
+ attested: int
255
+ within: dict[str, float] | None
256
+ pinball: float | None
257
+ quotes: int
258
+ quote_closer: float | None
259
+ quote_ratio: float | None
260
+
261
+
262
+ def summarize(ledger: Ledger) -> Summary:
263
+ pairs = [
264
+ (ledger.forecasts[key], score)
265
+ for key, score in latest_scores(ledger.scores).items()
266
+ if key in ledger.forecasts
267
+ ]
268
+ attested = sum(not score["verified"] for _, score in pairs)
269
+ within = pinball = None
270
+ if len(pairs) >= MIN_RATED:
271
+ within = {
272
+ key: sum(score["actual_s"] <= forecast[key] for forecast, score in pairs) / len(pairs)
273
+ for key, _ in QUANTILE_KEYS
274
+ }
275
+ pinball = sum(
276
+ sum(_pinball(score["actual_s"], forecast[key], q) for key, q in QUANTILE_KEYS)
277
+ for forecast, score in pairs
278
+ ) / len(pairs)
279
+ quoted = [(f, s) for f, s in pairs if isinstance(f.get("quoted_s"), int)]
280
+ closer = ratio = None
281
+ if len(quoted) >= MIN_RATED:
282
+ closer = sum(bool(s["quote_closer"]) for _, s in quoted) / len(quoted)
283
+ ratio = statistics.median(f["quoted_s"] / s["actual_s"] for f, s in quoted)
284
+ return Summary(len(pairs), attested, within, pinball, len(quoted), closer, ratio)
agentpace/ingest.py ADDED
@@ -0,0 +1,337 @@
1
+ """Turn `RawRun` records into normalized rows, one code path for every harness."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ import os
7
+ import re
8
+ import time
9
+ from bisect import bisect_right
10
+ from collections import defaultdict
11
+ from collections.abc import Iterator, Mapping, Sequence
12
+ from contextlib import contextmanager, suppress
13
+ from dataclasses import dataclass, field, replace
14
+ from datetime import datetime, timezone
15
+ from pathlib import Path
16
+
17
+ from agentpace import classify, pseudonym, schema
18
+ from agentpace.adapters.base import Adapter, ParseStats, RawRun, Source
19
+ from agentpace.cache import RunCache
20
+ from agentpace.schema import Row
21
+
22
+ DEFAULT_IDLE_CAP_S = 900
23
+ _EFFORTS = frozenset(schema.enum("effort"))
24
+
25
+ _FAMILIES = (
26
+ re.compile(r"claude-(?:opus|sonnet|haiku|fable)-\d+(?:-\d{1,2})?(?!\d)"),
27
+ re.compile(r"claude-\d(?:-\d)?-(?:opus|sonnet|haiku)"),
28
+ re.compile(r"gpt-\d+(?:\.\d+)?(?:-[a-z]+)?"),
29
+ re.compile(r"gemini-\d(?:\.\d)?-[a-z]+(?:-lite)?"),
30
+ re.compile(r"(?<![a-z0-9])o\d(?:-[a-z]+)?(?![a-z0-9])"),
31
+ )
32
+ # Some providers, such as OpenRouter or Copilot, write a dotted Claude version,
33
+ # `claude-sonnet-4.5`, which the families above would cut to `claude-sonnet-4`. Rewrite only
34
+ # that shape to the dashed family, for every harness.
35
+ _DOTTED_CLAUDE = re.compile(r"(claude-(?:opus|sonnet|haiku|fable)-\d+)\.(\d{1,2})(?!\d)")
36
+ _DOTTED_CLAUDE_OLD = re.compile(r"(claude-\d)\.(\d)-")
37
+
38
+
39
+ def _canonical_model(model: str) -> str:
40
+ return _DOTTED_CLAUDE_OLD.sub(r"\1-\2-", _DOTTED_CLAUDE.sub(r"\1-\2", model))
41
+
42
+
43
+ def normalize_model(raw: str) -> str:
44
+ """Keep a public model family. ARNs and proxy names can leak account or customer names."""
45
+ lowered = _canonical_model(raw.lower())
46
+ for family in _FAMILIES:
47
+ match = family.search(lowered)
48
+ if match:
49
+ return match.group(0)
50
+ return "other"
51
+
52
+
53
+ def iso_week(epoch: float) -> str:
54
+ year, week, _ = datetime.fromtimestamp(epoch, tz=timezone.utc).isocalendar()
55
+ return f"{year}-W{week:02d}"
56
+
57
+
58
+ @dataclass
59
+ class IngestReport:
60
+ files: int = 0
61
+ rows: int = 0
62
+ zero_duration: int = 0
63
+ still_running: int = 0
64
+ cached: int = 0
65
+ cache_rebuilt: bool = False
66
+ parse: ParseStats = field(default_factory=ParseStats)
67
+
68
+ def lines(self) -> list[str]:
69
+ parse = self.parse
70
+ lines = [
71
+ f"files seen: {self.files}",
72
+ f"read from cache: {self.cached}" + (" (cache rebuilt)" if self.cache_rebuilt else ""),
73
+ f"rows produced: {self.rows}",
74
+ f"empty runs: {parse.empty_runs}",
75
+ f"unreadable files: {parse.unreadable_files}",
76
+ f"unparsed lines: {parse.unparsed_lines}",
77
+ f"zero-duration: {self.zero_duration}",
78
+ f"still running: {self.still_running}",
79
+ ]
80
+ if parse.skipped_files:
81
+ lines.append(f"skipped files: {parse.skipped_files}")
82
+ if parse.skipped_runs:
83
+ lines.append(f"skipped runs: {parse.skipped_runs}")
84
+ return lines
85
+
86
+
87
+ class SessionClock:
88
+ """Maps real time onto a session's own clock, with idle gaps clipped."""
89
+
90
+ def __init__(self, runs: Sequence[RawRun], idle_cap_s: int) -> None:
91
+ merged: list[list[float]] = []
92
+ for start, end in sorted(span for run in runs for span in run.spans):
93
+ if merged and start <= merged[-1][1]:
94
+ merged[-1][1] = max(merged[-1][1], end)
95
+ else:
96
+ merged.append([start, end])
97
+ self._starts = [start for start, _ in merged]
98
+ self._ends = [end for _, end in merged]
99
+ self._elapsed = [0.0]
100
+ for i in range(1, len(merged)):
101
+ busy = self._ends[i - 1] - self._starts[i - 1]
102
+ gap = self._starts[i] - self._ends[i - 1]
103
+ self._elapsed.append(self._elapsed[-1] + busy + min(gap, idle_cap_s))
104
+
105
+ def at(self, time: float) -> int:
106
+ index = max(bisect_right(self._starts, time) - 1, 0)
107
+ return round(self._elapsed[index] + max(time - self._starts[index], 0.0))
108
+
109
+
110
+ def peak_overlaps(runs: Sequence[RawRun]) -> dict[int, int]:
111
+ """Peak number of runs alive at once during each run, including itself."""
112
+ events = sorted(
113
+ (time, order, index)
114
+ for index, run in enumerate(runs)
115
+ for time, order in ((run.start, 0), (run.end, 1))
116
+ )
117
+ peaks = dict.fromkeys(range(len(runs)), 1)
118
+ live: set[int] = set()
119
+ for _, order, index in events:
120
+ if order == 1:
121
+ live.discard(index)
122
+ continue
123
+ live.add(index)
124
+ for member in live:
125
+ peaks[member] = max(peaks[member], len(live))
126
+ return peaks
127
+
128
+
129
+ def load_labels(path: Path) -> dict[str, dict[str, str]]:
130
+ if not path.exists():
131
+ return {}
132
+ labels: dict[str, dict[str, str]] = json.loads(path.read_text(encoding="utf-8"))
133
+ return labels
134
+
135
+
136
+ def apply_labels(row: Row, labels: Mapping[str, Mapping[str, str]]) -> None:
137
+ label = labels.get(run_ref(row))
138
+ if label:
139
+ row["task_kind"] = label.get("kind", row["task_kind"])
140
+ row["risk"] = label.get("risk", row["risk"])
141
+
142
+
143
+ def run_ref(row: Row) -> str:
144
+ """A short handle for labeling one run."""
145
+ return f"{row['batch']}:{row['offset_s']}"
146
+
147
+
148
+ def build_rows(
149
+ runs: Sequence[RawRun],
150
+ secret: bytes,
151
+ idle_cap_s: int = DEFAULT_IDLE_CAP_S,
152
+ kind_map: Mapping[str, str] | None = None,
153
+ labels: Mapping[str, Mapping[str, str]] | None = None,
154
+ now: float | None = None,
155
+ ) -> tuple[list[Row], int, int]:
156
+ """Return schema-valid rows, how many runs had zero duration and were dropped, and how many
157
+ were withheld because they may still be running.
158
+
159
+ A run that has not completed and was active within the idle cap may still be going. Storing
160
+ it would leave a partial row behind for good, because its duration is part of the key, so it
161
+ is withheld until a later ingest finds it settled. It still feeds the session clock and the
162
+ overlap count, so its siblings get the same offsets they will have once it finishes. A
163
+ completed sibling that starts in the same second is held back too, because tie-breaking
164
+ between the two can only be settled once both are final.
165
+ """
166
+ now = time.time() if now is None else now
167
+ sessions: dict[tuple[str, str], list[RawRun]] = defaultdict(list)
168
+ for run in runs:
169
+ sessions[(run.project, run.session)].append(run)
170
+ contributor = pseudonym.contributor(secret)
171
+ rows: list[Row] = []
172
+ dropped = withheld = 0
173
+ for (project, session), members in sessions.items():
174
+ clock = SessionClock(members, idle_cap_s)
175
+ peaks = peak_overlaps(members)
176
+ batch = pseudonym.batch(secret, session)
177
+ project_hash = pseudonym.project(secret, project)
178
+ taken: set[tuple[int, int]] = set()
179
+ order = sorted(range(len(members)), key=lambda i: (members[i].start, members[i].end))
180
+ base = {i: clock.at(members[i].start) for i in order}
181
+ running = {
182
+ i
183
+ for i in order
184
+ if members[i].outcome != "completed" and now - members[i].end < idle_cap_s
185
+ }
186
+ # A sibling that starts in the same second as a running run is held back with it, since
187
+ # the tie nudge below would give it a different key once the running one finishes.
188
+ blocked = {base[i] for i in running}
189
+ for index in order:
190
+ run = members[index]
191
+ if run.active_s < 1:
192
+ dropped += 1
193
+ continue
194
+ if index in running or base[index] in blocked:
195
+ withheld += 1
196
+ continue
197
+ # Agents launched together can tie on (start, duration) to the second. A one-second
198
+ # nudge keeps both runs apart under the duplicate key, deterministically.
199
+ offset = base[index]
200
+ while (offset, run.active_s) in taken:
201
+ offset += 1
202
+ taken.add((offset, run.active_s))
203
+ kind = classify.classify_kind(run.agent_type, kind_map)
204
+ row: Row = {
205
+ "harness": run.harness,
206
+ "model": normalize_model(run.model),
207
+ "task_kind": kind,
208
+ "risk": classify.classify_risk(run.agent_type, kind),
209
+ "size": classify.size_bucket(run.edit_calls),
210
+ "duration_s": run.active_s,
211
+ "tool_calls": run.tool_calls,
212
+ "retries": run.errors,
213
+ "outcome": run.outcome,
214
+ "parallel_agents": peaks[index],
215
+ "contributor": contributor,
216
+ "week": iso_week(run.start),
217
+ "batch": batch,
218
+ "project": project_hash,
219
+ "offset_s": offset,
220
+ }
221
+ if run.tokens is not None:
222
+ row["tokens"] = run.tokens
223
+ if run.effort in _EFFORTS:
224
+ row["effort"] = run.effort
225
+ if labels:
226
+ apply_labels(row, labels)
227
+ problems = schema.check_row(row)
228
+ if problems:
229
+ raise ValueError(f"adapter produced an invalid row: {problems}")
230
+ rows.append(row)
231
+ return rows, dropped, withheld
232
+
233
+
234
+ def _stamp(path: str) -> list[int] | None:
235
+ try:
236
+ stamp = os.stat(path)
237
+ except OSError:
238
+ return None
239
+ return [stamp.st_size, stamp.st_mtime_ns]
240
+
241
+
242
+ def _parse(
243
+ adapter: Adapter,
244
+ source: Source,
245
+ idle_cap_s: int,
246
+ report: IngestReport,
247
+ cache: RunCache | None,
248
+ ) -> list[RawRun]:
249
+ if cache is None:
250
+ return adapter.parse(source, idle_cap_s, report.parse)
251
+ try:
252
+ stamp = os.stat(source.path)
253
+ except OSError:
254
+ report.parse.unreadable_files += 1
255
+ return []
256
+ stamps = [[stamp.st_size, stamp.st_mtime_ns], *map(_stamp, source.companions)]
257
+ hit, runs = cache.lookup(source.path, stamps)
258
+ if hit:
259
+ report.cached += 1
260
+ if not runs:
261
+ report.parse.empty_runs += 1
262
+ return runs
263
+ unreadable = report.parse.unreadable_files
264
+ runs = adapter.parse(source, idle_cap_s, report.parse)
265
+ if report.parse.unreadable_files == unreadable:
266
+ # Normalize before storing, so no raw model name (an ARN, a proxy) sits on disk.
267
+ runs = [replace(run, model=normalize_model(run.model)) for run in runs]
268
+ cache.store(source.path, stamps, runs)
269
+ return runs
270
+
271
+
272
+ def ingest(
273
+ adapter: Adapter,
274
+ path: Path,
275
+ secret: bytes,
276
+ idle_cap_s: int = DEFAULT_IDLE_CAP_S,
277
+ kind_map: Mapping[str, str] | None = None,
278
+ labels: Mapping[str, Mapping[str, str]] | None = None,
279
+ now: float | None = None,
280
+ cache: RunCache | None = None,
281
+ ) -> tuple[list[Row], IngestReport]:
282
+ """Parse every run under `path` into rows. With a cache, unchanged files are not re-read."""
283
+ report = IngestReport(cache_rebuilt=cache is not None and cache.rebuilt)
284
+ runs: list[RawRun] = []
285
+ seen: set[str] = set()
286
+ for source in adapter.discover(path):
287
+ report.files += 1
288
+ seen.add(source.path)
289
+ runs.extend(_parse(adapter, source, idle_cap_s, report, cache))
290
+ if cache is not None:
291
+ sessions = {(run.project, run.session) for run in runs}
292
+ runs += cache.vanished(sessions, seen)
293
+ cache.save()
294
+ rows, report.zero_duration, report.still_running = build_rows(
295
+ runs, secret, idle_cap_s, kind_map, labels, now
296
+ )
297
+ report.rows = len(rows)
298
+ return rows, report
299
+
300
+
301
+ def merge_into(path: Path, rows: Sequence[Row]) -> int:
302
+ """Add rows to a store. Old rows survive, since harnesses delete their transcripts."""
303
+ existing = schema.read_rows(path) if path.exists() else []
304
+ merged = {schema.dedupe_key(row): row for row in [*existing, *rows]}
305
+ return schema.write_rows(path, (merged[key] for key in sorted(merged)))
306
+
307
+
308
+ class StoreBusy(Exception):
309
+ """Another ingest holds the store."""
310
+
311
+
312
+ @contextmanager
313
+ def store_lock(store: Path, wait_s: float = 5.0, stale_s: float = 900.0) -> Iterator[None]:
314
+ """Hold `<store>.lock` while reading, merging and writing, so a hook and a manual ingest
315
+ never interleave. A lock older than `stale_s` is taken to be left by a crash. That is longer
316
+ than a cold ingest takes, so a live holder is never mistaken for a dead one."""
317
+ lock = store.with_name(store.name + ".lock")
318
+ lock.parent.mkdir(parents=True, exist_ok=True)
319
+ deadline = time.monotonic() + wait_s
320
+ while True:
321
+ try:
322
+ os.close(os.open(lock, os.O_CREAT | os.O_EXCL | os.O_WRONLY))
323
+ break
324
+ except (FileExistsError, PermissionError):
325
+ # Windows reports PermissionError while another holder's lock is being deleted.
326
+ with suppress(OSError):
327
+ if time.time() - lock.stat().st_mtime > stale_s:
328
+ lock.unlink()
329
+ continue
330
+ if time.monotonic() > deadline:
331
+ raise StoreBusy(str(lock)) from None
332
+ time.sleep(0.05)
333
+ try:
334
+ yield
335
+ finally:
336
+ with suppress(FileNotFoundError):
337
+ lock.unlink()
agentpace/paths.py ADDED
@@ -0,0 +1,62 @@
1
+ """Where agentpace finds its files.
2
+
3
+ Installed wheels carry the schema and seed rows under `_bundled`. A source checkout reads the
4
+ same files from the repo root, so there is never a second copy to drift.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ import os
10
+ from pathlib import Path
11
+
12
+ _PACKAGE = Path(__file__).resolve().parent
13
+ _REPO_ROOT = _PACKAGE.parent.parent
14
+
15
+
16
+ def home() -> Path:
17
+ """Directory for the local secret, labels and ingested rows."""
18
+ override = os.environ.get("AGENTPACE_HOME")
19
+ return Path(override) if override else Path.home() / ".agentpace"
20
+
21
+
22
+ def schema_file() -> Path:
23
+ bundled = _PACKAGE / "_bundled" / "run.schema.json"
24
+ return bundled if bundled.exists() else _REPO_ROOT / "schema" / "run.schema.json"
25
+
26
+
27
+ def turn_schema_file() -> Path:
28
+ bundled = _PACKAGE / "_bundled" / "turn.schema.json"
29
+ return bundled if bundled.exists() else _REPO_ROOT / "schema" / "turn.schema.json"
30
+
31
+
32
+ def seed_dir() -> Path:
33
+ bundled = _PACKAGE / "_bundled" / "runs"
34
+ return bundled if bundled.exists() else _REPO_ROOT / "data" / "runs"
35
+
36
+
37
+ def example_plan() -> Path:
38
+ """The plan the README's Try it runs, shipped so it needs no clone."""
39
+ bundled = _PACKAGE / "_bundled" / "example-plan.json"
40
+ return bundled if bundled.exists() else _REPO_ROOT / "examples" / "plan.json"
41
+
42
+
43
+ def local_runs() -> Path:
44
+ return home() / "runs.jsonl"
45
+
46
+
47
+ def turns_file() -> Path:
48
+ """Waits on the human, from main sessions. Local only, never shared."""
49
+ return home() / "turns.jsonl"
50
+
51
+
52
+ def forecasts_file() -> Path:
53
+ """Recorded forecasts and their scores. Local only, never shared."""
54
+ return home() / "forecasts.jsonl"
55
+
56
+
57
+ def labels_file() -> Path:
58
+ return home() / "labels.json"
59
+
60
+
61
+ def cache_file(adapter: str) -> Path:
62
+ return home() / f"cache-{adapter}.json"