Skip to content

Commit 0d64e23

Browse files
aliirzcodejunkie99
andauthored
fix(pi): rewrite adapter — inline TS hook, formula crash, decay tz bug (#24)
* fix(pi): rewrite adapter — inline TS hook, formula crash, decay tz bug Fixes four distinct issues found after PR #17 was merged: ## 1. ModuleNotFoundError crash on brew install (Formula) Formula/agentic-stack.rb pkgshare.install was missing harness_manager/. install.sh dispatches to `python3 -m harness_manager.cli` but the module was never copied into the cellar, causing an immediate crash for every brew user regardless of adapter. ## 2. memory-hook.ts — complete rewrite (approach was wrong) PR #17's hook used: - fileURLToPath(import.meta.url) → jiti leaves this undefined in CJS-transform mode, silently breaking HOOK_SCRIPT path resolution - spawn(python3, [pi_post_tool.py]) per tool_result → subprocess overhead + timeout complexity for every single tool call - No pre-filtering → Python was spawned for read/find/ls/grep too - Private cc._is_success / cc._importance etc. imports → breaks silently on any refactor of claude_code_post_tool.py New approach (matches the working life-international reference setup): - process.cwd() for all path resolution — no import.meta.url - All importance / pain_score / action / reflection logic is inline TypeScript — no Python subprocess per tool call - Direct fs.appendFileSync to AGENT_LEARNINGS.jsonl - Pre-filter: bash / edit / write only (mirrors Claude Code's "^(Bash|Edit|Write)$" matcher). Low-importance bash successes (imp <= 3) are also skipped to keep the log signal-rich. - Typed inputs via isBashToolResult / isEditToolResult / isWriteToolResult type guards (exported from pi's public API) - cachedSha typed as string | undefined so the git cache actually works - session_shutdown handler runs auto_dream.py on quit/new/resume, mirroring Claude Code's Stop hook. cron is now an optional fallback. ## 3. adapter.json — remove from_stack pi_post_tool.py entry The hook is self-contained TypeScript. pi_post_tool.py is no longer deployed as an explicitly-managed adapter file. It remains in the brain template for standalone/debug use. ## 4. decay.py — timezone-aware vs naive datetime crash auto_dream.py → decay_old_entries() compared a timezone-aware cutoff (datetime.now()) against naive timestamps from AGENT_LEARNINGS.jsonl, crashing with TypeError. Fixed by using datetime.now(timezone.utc) and normalising naive timestamps to UTC before comparison. Tested: fresh install on lumen-review, 41/41 tests pass, doctor green. * fix(pi): wire shutdown hook, fix edit reflection, normalise tz, atomic dream cycle Follow-up fixes for PR #24 surfaced by independent verification (memory-hook type cross-reference + codex review). Each fix is annotated with the failure mode it addresses. P0 — session_shutdown filter rejected every event Pi's `SessionShutdownEvent` is `{ type: "session_shutdown" }` with no `reason` field (verified in pi-coding-agent's types.d.ts and the emit site at agent-session.js:1638). The hook's `DREAM_REASONS.has(event.reason)` filter therefore evaluated `has(undefined) === false` for every event, so `auto_dream.py` never ran. Drop the filter; shutdown only fires once on process exit. Add a re-entrancy guard for defence in depth. P1 — edit reflection always lost the diff Hook accessed `event.input.edits[0]` but Pi's `EditToolInput` is flat `{ path, oldText, newText }` (no `edits` array — that's MultiEdit on Claude Code). Reflections silently degraded to `Edited <path>` with no old/new content for the dream-cycle clusterer to grip. Use the flat fields directly. P1 — naive-local Python timestamps + UTC decay = silent drift Decay's "naive == UTC" assumption is correct only if writers emit UTC. They didn't: post_execution, on_failure, learn, graduate, promote, review_state, render_lessons all wrote naive-local. Switch every writer to `datetime.now(timezone.utc).isoformat()` and teach every reader (salience, show._human_age / _daily_counts / failing_skills / last_dream_cycle, on_failure._count_recent_failures, review_state._age_factor, archive) to normalise naive timestamps to UTC before comparing. P1 — one bad user regex disabled every user pattern Pre-fix: build one combined RegExp per list, catch any error, return null for both. A single typo in `hook_patterns.json` silently dropped all user rules. Now: validate per-fragment, incremental merge — same posture as `claude_code_post_tool.py`'s `_filter_valid` / `_build_with_fallback`. P1 — auto_dream lost entries that landed mid-cycle Original PR fixed the truncate-before-lock race in `_write_entries` but not the read-modify-write window: an `append_jsonl()` between `_load_entries()` and `_write_entries(kept)` would be truncated away. Hold a single LOCK_EX on the episodic log across the entire cycle via `_episodic_locked()`. Mutually exclusive with `_episodic_io.append_jsonl` (same flock target). P1 — salience over-scored future-skewed legacy rows Legacy naive-local timestamps re-interpreted as UTC can read as a few hours in the future during the migration window. `timedelta.days` then went negative and `recency = 10 - age*0.3` exceeded the intended cap. Floor age at 0; clamp recency to ≤ 10. P2 — _cachedSha went stale across `git commit` inside a session Cache for the lifetime of pi was a perf win but recorded the pre-commit SHA on every entry after a mid-session commit. Invalidate on bash commands matching `git <subcmd>` for HEAD-moving subcommands. `[^|;&]*?` allows option flags between `git` and the subcommand (`git -c key=val checkout main`, `git -C path switch dev`). Includes `switch` which an earlier draft missed. P3 — test_pi_install_creates_symlink_and_syncs_hook misnamed Asserted that `pi_post_tool.py` was synced via from_stack — the entry PR #24 removed. Rename the test, drop the obsolete assertion, document in the comment that the .py still ships in the brain template for standalone use. P3 — decay archive filename used local date while cutoff was UTC Asymmetric. `archive_{date}.jsonl` now uses UTC date. Plus tests/test_decay_timezone.py — six tests pinning the shapes decay must handle: aware UTC old/recent, naive old, mixed mid-stream, malformed, archive-filename-uses-UTC. Catches the original crash and the migration-era silent-drift cases. Test suite: 47 / 47 pass (was 41 before; +6 new). Lock semantics verified manually (open-then-flock-then-ftruncate; full read-modify-write window held under a single fd). SHA-invalidation regex spot-checked against 12 git command shapes. --------- Co-authored-by: codejunkie99 <email@email.com>
1 parent 48fdc37 commit 0d64e23

20 files changed

Lines changed: 724 additions & 302 deletions

.agent/harness/hooks/on_failure.py

Lines changed: 6 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,7 @@
1212
def _count_recent_failures(skill_name):
1313
if not os.path.exists(EPISODIC):
1414
return 0
15-
cutoff = datetime.datetime.now() - datetime.timedelta(days=WINDOW_DAYS)
15+
cutoff = datetime.datetime.now(datetime.timezone.utc) - datetime.timedelta(days=WINDOW_DAYS)
1616
count = 0
1717
for line in open(EPISODIC):
1818
line = line.strip()
@@ -25,7 +25,10 @@ def _count_recent_failures(skill_name):
2525
if e.get("skill") != skill_name or e.get("result") != "failure":
2626
continue
2727
try:
28-
if datetime.datetime.fromisoformat(e["timestamp"]) > cutoff:
28+
ts = datetime.datetime.fromisoformat(e["timestamp"])
29+
if ts.tzinfo is None:
30+
ts = ts.replace(tzinfo=datetime.timezone.utc)
31+
if ts > cutoff:
2932
count += 1
3033
except (KeyError, ValueError):
3134
continue
@@ -48,7 +51,7 @@ def on_failure(skill_name, action, error, context="", confidence=0.9,
4851
# schema migration is recorded with its true importance and pain score;
4952
# the dream-cycle salience can't distinguish failure severity otherwise.
5053
entry = {
51-
"timestamp": datetime.datetime.now().isoformat(),
54+
"timestamp": datetime.datetime.now(datetime.timezone.utc).isoformat(),
5255
"skill": skill_name,
5356
"action": action[:200],
5457
"result": "failure",

.agent/harness/hooks/post_execution.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -19,7 +19,7 @@ def log_execution(skill_name, action, result, success, reflection="",
1919
if pain_score is None:
2020
pain_score = 2 if success else 7
2121
entry = {
22-
"timestamp": datetime.datetime.now().isoformat(),
22+
"timestamp": datetime.datetime.now(datetime.timezone.utc).isoformat(),
2323
"skill": skill_name,
2424
"action": action[:200],
2525
"result": "success" if success else "failure",

.agent/harness/salience.py

Lines changed: 8 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -8,12 +8,17 @@ def salience_score(entry: dict) -> float:
88
if not ts:
99
return 0.0
1010
try:
11-
age_days = (datetime.datetime.now()
12-
- datetime.datetime.fromisoformat(ts)).days
11+
parsed = datetime.datetime.fromisoformat(ts)
12+
if parsed.tzinfo is None:
13+
parsed = parsed.replace(tzinfo=datetime.timezone.utc)
14+
# Negative age can happen during the naive-→-UTC migration window
15+
# if a legacy naive-local timestamp now reads as a few hours in the
16+
# future. Floor at 0 so recency stays in [0, 10] instead of inflating.
17+
age_days = max(0, (datetime.datetime.now(datetime.timezone.utc) - parsed).days)
1318
except ValueError:
1419
age_days = 999
1520
pain = entry.get("pain_score", 5)
1621
importance = entry.get("importance", 5)
1722
recurrence = entry.get("recurrence_count", 1)
18-
recency = max(0.0, 10.0 - age_days * 0.3)
23+
recency = max(0.0, min(10.0, 10.0 - age_days * 0.3))
1924
return recency * (pain / 10.0) * (importance / 10.0) * min(recurrence, 3)

.agent/memory/archive.py

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -8,11 +8,12 @@ def archive_stale_workspace(working_dir, archive_dir):
88
workspace = os.path.join(working_dir, "WORKSPACE.md")
99
if not os.path.exists(workspace):
1010
return False
11-
mtime = datetime.datetime.fromtimestamp(os.path.getmtime(workspace))
12-
if (datetime.datetime.now() - mtime).days < STALE_DAYS:
11+
mtime = datetime.datetime.fromtimestamp(os.path.getmtime(workspace),
12+
tz=datetime.timezone.utc)
13+
if (datetime.datetime.now(datetime.timezone.utc) - mtime).days < STALE_DAYS:
1314
return False
1415
os.makedirs(archive_dir, exist_ok=True)
1516
dest = os.path.join(archive_dir,
16-
f"workspace_{datetime.date.today().isoformat()}.md")
17+
f"workspace_{datetime.datetime.now(datetime.timezone.utc).date().isoformat()}.md")
1718
shutil.move(workspace, dest)
1819
return True

.agent/memory/auto_dream.py

Lines changed: 114 additions & 31 deletions
Original file line numberDiff line numberDiff line change
@@ -13,13 +13,21 @@
1313
- promotion to LESSONS.md (graduate.py does that)
1414
- git commit (unattended repo writes are dangerous on a host hook)
1515
"""
16-
import json, os
16+
import contextlib, json, os
1717
from promote import cluster_and_extract, write_candidates
1818
from validate import heuristic_check
1919
from review_state import mark_rejected, write_review_queue_summary
2020
from decay import decay_old_entries
2121
from archive import archive_stale_workspace
2222

23+
# fcntl is POSIX-only. On Windows the dream cycle is best-effort: concurrent
24+
# writers there are rare (no shutdown hook = no parallel exits), and the lack
25+
# of locking matches the existing _episodic_io.py fallback.
26+
try:
27+
import fcntl # type: ignore[import-not-found]
28+
except ImportError: # pragma: no cover — Windows
29+
fcntl = None # type: ignore[assignment]
30+
2331
ROOT = os.path.abspath(os.path.dirname(__file__))
2432
EPISODIC = os.path.join(ROOT, "episodic/AGENT_LEARNINGS.jsonl")
2533
CANDIDATES = os.path.join(ROOT, "candidates")
@@ -29,11 +37,56 @@
2937
CLUSTER_SIMILARITY = 0.3
3038

3139

32-
def _load_entries():
33-
if not os.path.exists(EPISODIC):
34-
return []
40+
@contextlib.contextmanager
41+
def _episodic_locked():
42+
"""Hold an exclusive flock on AGENT_LEARNINGS.jsonl across the entire
43+
dream-cycle read-modify-write window.
44+
45+
Without a window-spanning lock, an `append_jsonl()` call that lands
46+
between `_load_entries_locked()` and `_write_entries_locked(kept)` is
47+
silently truncated away by the rewrite. With this context manager,
48+
every appender (`_episodic_io.append_jsonl`, which takes LOCK_EX on
49+
the same file) blocks until the dream cycle releases the lock.
50+
51+
Yields the open file descriptor so callers can read/write without
52+
racing on a second open(). On Windows (no fcntl) yields None and
53+
falls back to the historical best-effort behavior.
54+
"""
55+
if fcntl is None:
56+
yield None
57+
return
58+
os.makedirs(os.path.dirname(EPISODIC), exist_ok=True)
59+
fd = os.open(EPISODIC, os.O_RDWR | os.O_CREAT, 0o644)
60+
try:
61+
fcntl.flock(fd, fcntl.LOCK_EX)
62+
yield fd
63+
finally:
64+
try:
65+
fcntl.flock(fd, fcntl.LOCK_UN)
66+
finally:
67+
os.close(fd)
68+
69+
70+
def _load_entries_locked(fd):
71+
"""Read all entries from the locked fd, or fall back to plain read on
72+
Windows (fd is None when fcntl is unavailable).
73+
"""
3574
entries = []
36-
for line in open(EPISODIC):
75+
if fd is None:
76+
if not os.path.exists(EPISODIC):
77+
return entries
78+
with open(EPISODIC) as f:
79+
stream = f.read()
80+
else:
81+
os.lseek(fd, 0, os.SEEK_SET)
82+
chunks = []
83+
while True:
84+
buf = os.read(fd, 65536)
85+
if not buf:
86+
break
87+
chunks.append(buf)
88+
stream = b"".join(chunks).decode("utf-8", errors="replace")
89+
for line in stream.splitlines():
3790
line = line.strip()
3891
if not line:
3992
continue
@@ -44,10 +97,34 @@ def _load_entries():
4497
return entries
4598

4699

100+
def _write_entries_locked(fd, entries):
101+
"""Truncate-and-rewrite under the same lock _load_entries_locked used.
102+
103+
Holding one fd across read+write is what makes the operation atomic
104+
against concurrent `append_jsonl()` calls.
105+
"""
106+
payload = "".join(json.dumps(e) + "\n" for e in entries).encode("utf-8")
107+
if fd is None:
108+
# Windows: best-effort, matches _episodic_io fallback.
109+
with open(EPISODIC, "w") as f:
110+
f.write(payload.decode("utf-8"))
111+
return
112+
os.ftruncate(fd, 0)
113+
os.lseek(fd, 0, os.SEEK_SET)
114+
os.write(fd, payload)
115+
116+
117+
# Compatibility shims for any external caller that still imports the
118+
# pre-refactor names. Internal callers in run_dream_cycle use the locked
119+
# helpers directly so the lock spans the full cycle.
120+
def _load_entries():
121+
with _episodic_locked() as fd:
122+
return _load_entries_locked(fd)
123+
124+
47125
def _write_entries(entries):
48-
with open(EPISODIC, "w") as f:
49-
for e in entries:
50-
f.write(json.dumps(e) + "\n")
126+
with _episodic_locked() as fd:
127+
_write_entries_locked(fd, entries)
51128

52129

53130
def _heuristic_prefilter(candidates_dir, semantic_dir):
@@ -86,30 +163,36 @@ def _heuristic_prefilter(candidates_dir, semantic_dir):
86163

87164

88165
def run_dream_cycle():
89-
entries = _load_entries()
90-
if not entries:
91-
# Still refresh the review queue — candidates may have been staged in
92-
# a previous cycle and the host agent loads REVIEW_QUEUE.md into every
93-
# session via build_context, so a stale/missing file hides real work.
94-
pending = write_review_queue_summary(CANDIDATES, REVIEW_QUEUE)
95-
print(f"dream cycle: no entries (queue has {pending} pending)")
96-
return
97-
98-
patterns = cluster_and_extract(entries, threshold=CLUSTER_SIMILARITY)
99-
promotable = {k: p for k, p in patterns.items()
100-
if p.get("canonical_salience", 0) >= PROMOTION_THRESHOLD}
166+
# Hold the lock across the FULL read-modify-write window. Any
167+
# append_jsonl() call from another harness blocks until we release.
168+
# Without this, an append landing between read and rewrite would be
169+
# truncated away.
170+
with _episodic_locked() as fd:
171+
entries = _load_entries_locked(fd)
172+
if not entries:
173+
# Still refresh the review queue — candidates may have been staged
174+
# in a previous cycle and the host agent loads REVIEW_QUEUE.md
175+
# into every session via build_context, so a stale/missing file
176+
# hides real work.
177+
pending = write_review_queue_summary(CANDIDATES, REVIEW_QUEUE)
178+
print(f"dream cycle: no entries (queue has {pending} pending)")
179+
return
180+
181+
patterns = cluster_and_extract(entries, threshold=CLUSTER_SIMILARITY)
182+
promotable = {k: p for k, p in patterns.items()
183+
if p.get("canonical_salience", 0) >= PROMOTION_THRESHOLD}
184+
185+
staged = write_candidates(promotable, CANDIDATES)
186+
prefiltered = _heuristic_prefilter(CANDIDATES, SEMANTIC)
187+
188+
kept, archived = decay_old_entries(
189+
entries, archive_dir=os.path.join(ROOT, "episodic/snapshots"))
190+
_write_entries_locked(fd, kept)
191+
archive_stale_workspace(
192+
working_dir=os.path.join(ROOT, "working"),
193+
archive_dir=os.path.join(ROOT, "episodic/snapshots"))
101194

102-
staged = write_candidates(promotable, CANDIDATES)
103-
prefiltered = _heuristic_prefilter(CANDIDATES, SEMANTIC)
104-
105-
kept, archived = decay_old_entries(
106-
entries, archive_dir=os.path.join(ROOT, "episodic/snapshots"))
107-
_write_entries(kept)
108-
archive_stale_workspace(
109-
working_dir=os.path.join(ROOT, "working"),
110-
archive_dir=os.path.join(ROOT, "episodic/snapshots"))
111-
112-
pending = write_review_queue_summary(CANDIDATES, REVIEW_QUEUE)
195+
pending = write_review_queue_summary(CANDIDATES, REVIEW_QUEUE)
113196

114197
print(
115198
f"dream cycle: patterns={len(patterns)} staged={staged} "

.agent/memory/decay.py

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -10,12 +10,15 @@
1010

1111

1212
def decay_old_entries(entries, archive_dir):
13-
cutoff = datetime.datetime.now() - datetime.timedelta(days=DECAY_DAYS)
13+
cutoff = datetime.datetime.now(datetime.timezone.utc) - datetime.timedelta(days=DECAY_DAYS)
1414
kept, archived = [], []
1515
for e in entries:
1616
ts_str = e.get("timestamp", "")
1717
try:
1818
ts = datetime.datetime.fromisoformat(ts_str)
19+
# Normalise to UTC — entries may be naive (no tz) or aware.
20+
if ts.tzinfo is None:
21+
ts = ts.replace(tzinfo=datetime.timezone.utc)
1922
except ValueError:
2023
kept.append(e)
2124
continue
@@ -26,7 +29,9 @@ def decay_old_entries(entries, archive_dir):
2629

2730
if archived:
2831
os.makedirs(archive_dir, exist_ok=True)
29-
path = os.path.join(archive_dir, f"archive_{datetime.date.today()}.jsonl")
32+
# UTC date so archive filenames align with the UTC cutoff above.
33+
today_utc = datetime.datetime.now(datetime.timezone.utc).date()
34+
path = os.path.join(archive_dir, f"archive_{today_utc}.jsonl")
3035
with open(path, "a") as f:
3136
for e in archived:
3237
f.write(json.dumps(e) + "\n")

.agent/memory/promote.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -138,7 +138,7 @@ def write_candidates(patterns, candidates_dir):
138138
if not evidence_changed and blocker_still_present:
139139
continue
140140

141-
now = datetime.datetime.now().isoformat()
141+
now = datetime.datetime.now(datetime.timezone.utc).isoformat()
142142
decisions = prev.get("decisions", [])
143143
decisions.append({"ts": now, "action": "staged", "reviewer": "auto_dream"})
144144

.agent/memory/render_lessons.py

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -198,9 +198,9 @@ def migrate_legacy_bullets(semantic_dir):
198198
for L in load_lessons(semantic_dir)}
199199
try:
200200
accepted_at = datetime.datetime.fromtimestamp(
201-
os.path.getmtime(md_path)).isoformat()
201+
os.path.getmtime(md_path), tz=datetime.timezone.utc).isoformat()
202202
except OSError:
203-
accepted_at = datetime.datetime.now().isoformat()
203+
accepted_at = datetime.datetime.now(datetime.timezone.utc).isoformat()
204204

205205
migrated = 0
206206
for claim in bullets:

.agent/memory/review_state.py

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,7 @@
1313

1414

1515
def _now():
16-
return datetime.datetime.now().isoformat()
16+
return datetime.datetime.now(datetime.timezone.utc).isoformat()
1717

1818

1919
def _touch(candidate, action, reviewer, notes="", **fields):
@@ -177,7 +177,9 @@ def _age_factor(staged_at):
177177
staged = datetime.datetime.fromisoformat(staged_at)
178178
except (ValueError, TypeError):
179179
return 1.0
180-
age_days = (datetime.datetime.now() - staged).days
180+
if staged.tzinfo is None:
181+
staged = staged.replace(tzinfo=datetime.timezone.utc)
182+
age_days = (datetime.datetime.now(datetime.timezone.utc) - staged).days
181183
return 1.0 + min(1.0, age_days / 14.0)
182184

183185

.agent/tools/graduate.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -154,7 +154,7 @@ def main():
154154
# If we crash mid-graduation, the staged candidate remains and the
155155
# reviewer can retry. The retry-safety block above catches the
156156
# specific "lesson appended but candidate not moved" scenario.
157-
accepted_at = datetime.datetime.now().isoformat()
157+
accepted_at = datetime.datetime.now(datetime.timezone.utc).isoformat()
158158
lesson = {
159159
"id": lesson_id,
160160
"claim": cand.get("claim"),

0 commit comments

Comments
 (0)