---
status: resolved
slug: more-done-sentinel-not-written
discovered: 2026-05-23
resolved: 2026-05-23
severity: high
labels: [live-agent, hooks, wrapper, sentinel, phase-25-d-01-regression]
trigger: User observation that .more-done is not appearing in active live-agent perch dirs
goal: find_and_fix
fix_surface: reader-side fallback (option 1) in src/live/wrapper/echo_fire.rs
---

# `.more-done` sentinel write path mismatch — Stop hook writes flat, wrapper reads nested

## Symptoms

- Active live agents (todlando — and prior doyle/phase25nestp/phase25relo) do NOT cause `.more-done` to appear in the path the Psyche wrapper polls.
- The wrapper's `[ECHO] gate idle: no sentinel present (awaiting next Stop)` heartbeat fires every poll iteration, meaning echo communes never trigger from the cadence path even though Self is completing turns.

## Investigation

### Step 1 — Locate the sentinel writer

`src/owl/hook_idle.rs:142`:
```rust
let sentinel = owlery::perch_dir(&psyche_id).join(".more-done");
let _ = std::fs::write(&sentinel, "");
```
→ Writes to **flat path** `owlery/<psyche_id>/.more-done` (e.g. `owlery/todlando-psyche/.more-done`).

Guard precondition at `src/owl/hook_idle.rs:136`:
```rust
if !owlery::ready_file(&psyche_id).exists() { return; }
```
→ Also checks **flat path** `owlery/<psyche_id>/ready`.

### Step 2 — Locate the sentinel reader

`src/live/wrapper/echo_fire.rs:112-113`:
```rust
let sentinel = owlery::nested_perch_dir(&self.self_id, &self.psyche_id)
    .join(".more-done");
```
→ Reads from **nested path** `owlery/<self_id>/nested/<psyche_id>/.more-done` (e.g. `owlery/todlando/nested/todlando-psyche/.more-done`).

### Step 3 — Confirm both paths on disk for live `todlando`

```
owlery/todlando-psyche/.more-done                       → EXISTS, mtime 2026-05-22 16:55 (STALE, last successful write was yesterday)
owlery/todlando/nested/todlando-psyche/.more-done       → DOES NOT EXIST
owlery/todlando/.idle-ready                             → EXISTS, mtime 2026-05-23 17:52 (FRESH — proves Stop hook IS firing)
owlery/todlando-psyche/ready                            → EXISTS (legacy ready file, hook guard passes)
owlery/todlando/nested/todlando-psyche/ready            → EXISTS
owlery/todlando/info.json                               → state: "live", session_id: cd6a1f1b... (legit Live agent)
owlery/todlando-psyche/info.json                        → state: "psyche", started 17:47 today (Psyche wrapper running)
```

The Stop hook IS reaching the sentinel-write code (proved by `.idle-ready` freshness on the path through `inbox::set_idle_ready` at hook_idle.rs:85, and by the May 22 sentinel evidence that the write succeeded previously). But its write is landing at the legacy flat path while the wrapper is polling the nested path. Net effect: wrapper never observes a sentinel.

### Step 4 — Git archaeology: what changed

Commit `4f8dfb4 feat(25.2-01): #4 wrapper_state_path_resolved + psyche-side nested swap` (2026-05-22) swapped 8 reader call sites in the wrapper from flat to nested per Phase 25 D-01 (psyche perches moved to `owlery/<self>/nested/<psyche>/`).

That commit's own message says:

> WRITERS keep inline flat path with migration-window comment block per Q2 RESOLVED (RESEARCH §Why reader-side, not writer-side)

… and introduced `wrapper_state_path_resolved(self_id, psyche_id)` (`src/common/wrapper_state.rs:154`) as a nested-first / flat-fallback resolver for wrapper-state.json reads — explicitly so readers tolerate both paths during the migration window.

**The bug:** `echo_fire.rs:112` was swapped to `nested_perch_dir(...)` **directly**, without going through a fallback resolver. The migration-window contract was honored for `wrapper-state.json` but not for `.more-done`. Meanwhile the writer (`hook_idle.rs:142`) stayed flat — exactly as the writer-side comment block in the 25.2-01 commit promised — but with no reader-side fallback to receive those writes the sentinel pipeline is severed.

## Root cause

Reader/writer path-asymmetry regression introduced by commit `4f8dfb4` (Phase 25.2-01 #4). Stop hook still writes `.more-done` to flat `owlery/<psyche_id>/`; wrapper reads it from nested `owlery/<self_id>/nested/<psyche_id>/` with no fallback. The two locations never meet, so the wrapper's echo-commune cadence gate stays permanently `SkipNoSentinel`.

## Suggested fix surfaces (one of)

1. **Reader-side fallback (mirror wrapper_state_path_resolved):** add a helper `more_done_sentinel_path_resolved(self_id, psyche_id)` that returns the nested path if its sentinel exists, else the flat path. Use it in `echo_fire.rs:112` for the gate check AND for the `std::fs::remove_file(&sentinel)` delete at line 150 (must delete BOTH so a stale flat sentinel can't linger). Mirrors the documented migration-window pattern. Minimum-surface; no writer changes.

2. **Writer-side swap to nested (matches Phase 25 D-01 canonical):** change `hook_idle.rs:136` and `hook_idle.rs:142` to use `nested_perch_dir(self_id, psyche_id)`. Self id is the resolved `owl_id`; psyche id is `format!("{}-psyche", owl_id)`. Need to also flip the `ready_file` guard to check nested-or-flat (because the gen-old wrapper may have only written the flat ready file). Aligns with the long-term canonical layout but introduces a flag-day risk if any wrapper is still on the flat-only path.

3. **Writer-side dual-write:** write to BOTH flat and nested paths. Cheap, no reader changes needed, fully back-compat. Mildly wasteful but the file is empty so disk cost is one inode per Stop.

Option 1 is the lowest-risk fix and matches the existing pattern. Option 3 is the most defensive. Plan-checker should pick.

## Reproduction

1. Start a Live agent: `$LIVE start todlando -p 8m` (or use any existing live agent).
2. Confirm psyche wrapper running: `owlery/<id>-psyche/info.json` shows `state: "psyche"`, `ready` exists in BOTH `owlery/<id>-psyche/` and `owlery/<id>/nested/<id>-psyche/`.
3. Drive Self with any prompt that ends a turn (any commune or manual user prompt).
4. After Stop fires, check both paths:
   - `owlery/<id>-psyche/.more-done` — newly created/refreshed.
   - `owlery/<id>/nested/<id>-psyche/.more-done` — STILL ABSENT.
5. Wrapper log will show `[ECHO] gate idle: no sentinel present (awaiting next Stop)` indefinitely.

## Evidence

- timestamp: 2026-05-23T17:53Z
  observation: legacy flat sentinel stale (mtime 2026-05-22 16:55), nested sentinel absent, todlando's .idle-ready is fresh (17:52) proving Stop hook IS firing
- timestamp: 2026-05-23T17:53Z
  observation: hook_idle.rs:142 writes to perch_dir(&psyche_id) (flat); echo_fire.rs:112 reads from nested_perch_dir(self_id, psyche_id)
- timestamp: 2026-05-23T17:53Z
  observation: commit 4f8dfb4 (2026-05-22) introduced reader-side nested swap explicitly leaving writers flat, but did not provide a fallback for the .more-done sentinel reader

## Resolution

**Root cause:** see above. Path-asymmetry regression in commit `4f8dfb4`.

**Fix applied (option 1 — reader-side fallback):** `src/live/wrapper/echo_fire.rs`

1. Added `pick_fresher_more_done(nested, flat) -> Option<PathBuf>` helper — mirrors the established `wrapper_state_path_resolved` resolver shape (prefers fresher mtime when both exist, tie-break to nested, soft-fail to nested on mtime read error, returns `None` only when neither exists).
2. `fire_echo_commune_if_due` now resolves nested-or-flat before calling `should_fire_decision`. The flat path written by the un-migrated `hook_idle.rs:142` writer is now visible to the wrapper's gate, closing the SkipNoSentinel-forever regression.
3. Fire-path delete removes BOTH paths — prevents a stale flat sentinel from re-firing the gate next iteration (matches the D-09 delete-before-spawn contract).
4. Added 4 unit tests covering neither / nested-only / flat-only (THE bug case) / both-exist tie-break to nested.

Writers untouched per the Phase 25.2-01 commit `4f8dfb4` migration-window contract. When/if writers are flipped to nested in a future phase, this resolver becomes a no-op fall-through to nested — no churn required.

**Verification:** 20/20 echo_fire unit tests pass (4 new). Full-suite delta vs baseline: 53 fail → 44 fail (pre-existing env-race flakes; zero new failures introduced).

**Deploy:** requires `powershell -ExecutionPolicy Bypass -File docs/DEPLOY.ps1` to ship the new owl.exe to active live agents (binary handoff via `installed_plugins.json` flip per Phase 18.5).
