Skip to content

feat(roca-firstmate): add session-owned resident watch - #6

Merged
teseo merged 16 commits into
mainfrom
fm/laroca-fm-watch-residente
Aug 23, 2026
Merged

feat(roca-firstmate): add session-owned resident watch#6
teseo merged 16 commits into
mainfrom
fm/laroca-fm-watch-residente

Conversation

@teseo

@teseo teseo commented Aug 23, 2026

Copy link
Copy Markdown
Contributor

Intent

Wire the roca-firstmate watcher into the session-residency pattern: when a long-lived parent (roca mcp serve) owns stdin/stdout, it can raise roca-firstmate watch as a resident child that lives and dies with the session. This closes the hole where the plugin mirror went stale because watch is a foreground process, there is deliberately no daemon or KeepAlive, and nothing re-raises it after the installer terminal closes.

All raising logic belongs to this plugin repo. La Roca must gain zero plugin-specific code. Current La Roca only hardcodes a vector resident child and rejects unknown plugin.json fields, so this change does not add a companions key to the manifest and does not edit the La Roca product. The plugin implements the child contract so a generic session-companion seam, if added later, can exec watch with no firstmate mention in kernel code.

Required child contract:

  • stdin/stdout owned by the parent, no port, no pid file, dies when stdin closes
  • single-flight via the existing seats/lease machinery (do not invent a second lock): each candidate tries to take/renew the watch seat; the holder watches, others stand down; on holder death the next renewal inherits
  • on rising, run the Scribe fingerprint sweep first so writes made while nobody listened are absorbed, then stay on live FSEvents (polling fallback)
  • fail-soft inside the child: a watch crash is noted in telemetry and retried with backoff; it must not require a daemon
  • make the executable resolvable without PATH luck: place copies it into the plugin directory (plugin dir is fine); serve/parent should resolve from plugin registration, not PATH
  • telemetry to JSONL log files (never db tables): raise, lease-acquired, lease-lost, sweep counts, crash-retry
  • installer payload stays exactly plugin.json and firstmate.db (data-only)
  • tests use fabricated scratch homes only; never touch live ~/.roca, live plugin dbs, or live firstmate homes
  • lab e2e: start a session-owned watch, write a file, assert the row lands; kill the session (stdin close), assert the child is gone; write another file (nothing lands); start a new session, assert catch-up absorbs the missed write and live-watch resumes
  • two concurrent sessions: exactly one holder (lease), no duplicate rows; kill the holder, the other takes over within the lease window
  • PR bodies: product language, counts only, no local paths, usernames, session UUIDs, or corpus counts

What Changed

  • Make watch follow parent stdin lifetime, sweep before live monitoring, retry transient failures with backoff, and emit JSONL lifecycle and sweep telemetry.
  • Reuse seats for per-home single-flight leases, fenced Scribe commits, lease renewal, release, and standby takeover.
  • Add place for plugin-local executable resolution and fabricated-home coverage for shutdown, catch-up, concurrent-holder failover, and ingestion.

Risk Assessment

🚨 High: The change still has a breaking multi-home readiness protocol, does not prove the required takeover deadline, and can destructively replace a destination directory.

Testing

After confirming the target commit and clean baseline, targeted automated tests and a real subprocess e2e both passed; persisted database and telemetry artifacts demonstrate session death, missed-write catch-up, live resume, single-flight lease takeover, fail-soft retry coverage, executable placement, and unchanged data-only payload.

Evidence: Watcher JSONL telemetry
{"timestamp":"2026-08-23T14:42:53.826379Z","kind":"raise","home_id":"northwind-harbor","seat_id":"watch-northwind-harbor"}
{"timestamp":"2026-08-23T14:42:53.827761Z","kind":"lease-acquired","home_id":"northwind-harbor","seat_id":"watch-northwind-harbor"}
{"timestamp":"2026-08-23T14:42:53.832108Z","kind":"sweep","home_id":"northwind-harbor","seat_id":"watch-northwind-harbor","scanned":1,"inserted":1,"wakeups":1}
{"timestamp":"2026-08-23T14:42:59.947991Z","kind":"sweep","home_id":"northwind-harbor","seat_id":"watch-northwind-harbor","inserted":1,"wakeups":1}
{"timestamp":"2026-08-23T14:43:18.234878Z","kind":"lease-lost","home_id":"northwind-harbor","seat_id":"watch-northwind-harbor"}
{"timestamp":"2026-08-23T14:43:32.583351Z","kind":"raise","home_id":"northwind-harbor","seat_id":"watch-northwind-harbor"}
{"timestamp":"2026-08-23T14:43:32.583838Z","kind":"lease-acquired","home_id":"northwind-harbor","seat_id":"watch-northwind-harbor"}
{"timestamp":"2026-08-23T14:43:32.585646Z","kind":"sweep","home_id":"northwind-harbor","seat_id":"watch-northwind-harbor","scanned":1,"inserted":1,"wakeups":1}
{"timestamp":"2026-08-23T14:43:38.548114Z","kind":"sweep","home_id":"northwind-harbor","seat_id":"watch-northwind-harbor","inserted":1,"wakeups":1}
{"timestamp":"2026-08-23T14:43:52.074236Z","kind":"raise","home_id":"northwind-harbor","seat_id":"watch-northwind-harbor"}
{"timestamp":"2026-08-23T14:44:24.586203Z","kind":"lease-acquired","home_id":"northwind-harbor","seat_id":"watch-northwind-harbor"}
{"timestamp":"2026-08-23T14:44:24.589397Z","kind":"sweep","home_id":"northwind-harbor","seat_id":"watch-northwind-harbor","scanned":1,"unchanged":1}
{"timestamp":"2026-08-23T14:44:38.545221Z","kind":"sweep","home_id":"northwind-harbor","seat_id":"watch-northwind-harbor","inserted":1,"wakeups":1}
{"timestamp":"2026-08-23T14:44:52.485479Z","kind":"lease-lost","home_id":"northwind-harbor","seat_id":"watch-northwind-harbor"}
Evidence: Persisted watcher version history
[{"home_id":"northwind-harbor","relative_path":"captain.md","version":1,"is_current":0,"content":"# initial fabricated state\n"},
{"home_id":"northwind-harbor","relative_path":"captain.md","version":2,"is_current":0,"content":"# live session write\n"},
{"home_id":"northwind-harbor","relative_path":"captain.md","version":3,"is_current":0,"content":"# missed while no session owned watch\n"},
{"home_id":"northwind-harbor","relative_path":"captain.md","version":4,"is_current":0,"content":"# live write after session restart\n"},
{"home_id":"northwind-harbor","relative_path":"captain.md","version":5,"is_current":1,"content":"# live write after lease takeover\n"}]

Pipeline

Updates from git push no-mistakes

✅ **intent** - passed

✅ No issues found.

✅ **Rebase** - passed

✅ No issues found.

⚠️ **Review** - 4 errors
  • 🚨 cmd/roca-firstmate/resident.go:157 - The lease is acquired before the synchronous fingerprint sweep, but heartbeats do not start until the sweep finishes. If Backfill exceeds the two-minute lease, another session can acquire the expired row; the first session then activates its watcher without revalidating ownership. Renew during the sweep or fence activation with a final ownership check.
  • 🚨 cmd/roca-firstmate/resident.go:183 - A renewal error leaves the existing source active. If database access fails through the lease deadline, another session can acquire the expired lease while the original watcher continues consuming events until a later heartbeat. Stand down on renewal failure or enforce the locally known lease expiry before processing more events.
  • 🚨 cmd/roca-firstmate/resident.go:124 - The required contract says a watch crash must be “noted in telemetry and retried with backoff,” but an IngestPath failure is only logged; the home remains marked holding and no retry or recovery sweep is scheduled. Polling has already advanced its filesystem baseline, so a transient read/SQLite failure can leave the mirror stale indefinitely. Mark the home retryable and perform a catch-up sweep after backoff.
  • 🚨 cmd/roca-firstmate/resident.go:141 - The watcher lock is inserted as a live machine subscription seat. Consequently tick will suppress orphan delivery for all machine wakeups while any watcher is alive, although watch never drains them; after clean stdin shutdown, ReleaseSeat merely expires the row, so silenceClock later creates recurring captain seat-silent wakeups for an intentionally ended watcher. Separate watcher-lock rows from subscription/silence semantics while retaining the existing seats lease machinery.
  • ⚠️ internal/nerve/lease.go:16 - watch-<home-id> shares an unreserved namespace with the public arbitrary --seat-id. Registering a follow seat with that valid value overwrites the live watch token and destination, forcing the watcher to stand down. Reserve and reject the internal prefix at the explicit-seat boundary, or otherwise make watcher identities collision-proof.

🔧 Fix: Fence watcher leases and retry failed ingestion
1 error still open:

  • 🚨 cmd/roca-firstmate/resident.go:100 - Lease heartbeats share the event-loop goroutine with synchronous acquisition and ingestion work. If H1 is active while H2's catch-up sweep takes longer than the two-minute lease, H1 cannot renew; another session can acquire H1 while the original source remains active, and a queued H1 event may be consumed before the pending heartbeat detects the lost token. A dropped-event recovery Backfill reaches the same failure. Run renewal/ownership fencing independently so no sweep or ingestion can starve it.

🔧 Fix: Decouple lease heartbeats from watcher work
3 errors still open:

  • 🚨 internal/nerve/lease.go:86 - Lease expiry uses lexical comparison between variable-width RFC3339Nano strings. For example, SQLite considers ...02.12Z <= ...02.1Z true even though .12 is later, allowing a contender to overwrite a still-live lease; the old watcher remains active until its next heartbeat. Use a fixed-width timestamp representation or numeric SQLite time comparison at the shared lease boundary.
  • 🚨 cmd/roca-firstmate/resident.go:60 - The required fail-soft contract says a watch crash must be logged and retried with backoff, but any transient newIngester database/filesystem failure exits the child immediately. Because raise is logged only afterward, this path records neither raise nor crash-retry and requires the parent to re-raise the child. Move runtime initialization into the retry lifecycle while keeping permanent configuration errors fatal.
  • 🚨 cmd/roca-firstmate/resident_test.go:342 - The intent explicitly requires a lab e2e that starts a session-owned child, proves it exits with stdin, and verifies process-death takeover. startWatch only invokes runContext in a goroutine, so it tests in-process cancellation and clean lease release—not executable resolution, OS child lifetime, or lease-expiry inheritance after abrupt holder death. Add a subprocess-level test using the built/placed CLI and fabricated homes.

🔧 Fix: Harden lease timing and subprocess residency
6 issues (5 errors, 1 warning) still open:

  • 🚨 cmd/roca-firstmate/resident.go:129 - Event processing is not fenced by the lease deadline: watchHomeLease checks only local state. If the process pauses past expiry, a standby acquires the seat, and the original resumes with a queued event before its heartbeat observes token loss, the old holder can still call IngestPath. Enforce the renewed deadline locally or verify the holder token in the mirror transaction before committing.
  • 🚨 cmd/roca-firstmate/resident.go:156 - now is captured before newIngester. If initialization takes longer than the lease, TryAcquireSeat writes an already-expired lease and then activates the source/sweep, allowing an immediate competing acquisition. Re-sample time immediately before TryAcquireSeat.
  • 🚨 internal/nerve/lease.go:88 - The new fixed-width timestamps are converted through SQLite julianday, which cannot preserve all nine fractional digits. A contender just before a sub-millisecond expiry can compare equal and satisfy <=, stealing a live lease. Compare the canonical fixed-width UTC strings directly or use an exact integer timestamp.
  • 🚨 cmd/roca-firstmate/main.go:123 - The required “fail-soft inside the child” retry lifecycle still begins only after openDatabase. A transient open, ping, WAL pragma, or migration failure exits immediately with no raise/crash-retry telemetry, requiring the parent to re-raise the child. Move transient database initialization into the watch retry lifecycle while retaining fatal argument/configuration validation.
  • 🚨 cmd/roca-firstmate/resident.go:73 - Required JSONL telemetry is best-effort because every log.Append error is discarded. With an unwritable or full log directory, watch reports itself active while recording none of raise, lease, sweep, or crash-retry and emits no diagnostic. Define and enforce failure handling at a shared logging boundary.
  • ⚠️ cmd/roca-firstmate/resident.go:353 - Crash telemetry stores raw error strings even though the logger contract says counts and identities only. For example, a missing data directory from newIngester includes the absolute home path and is written verbatim. Record a bounded error code/message or redact configured paths before appending.

🔧 Fix: Fence watcher ingestion and harden retry telemetry
1 error still open:

  • 🚨 cmd/roca-firstmate/resident.go:102 - The retry loop treats every database-open failure as transient. A mistyped or missing --db path therefore keeps the resident alive indefinitely, emitting only JSONL retries and never reaching watching, despite the prescribed boundary that missing/invalid configuration remains fatal. Classify os.ErrNotExist and other permanent database validation errors before entering the retry loop; reserve retry for busy/ping/WAL-style transient failures.

🔧 Fix: Classify permanent watcher database failures
1 error still open:

  • 🚨 cmd/roca-firstmate/resident.go:223 - The ingester is created before lease acquisition and then cached across failed acquisitions and lease loss. Because scribe.New snapshots ingest_file_state, a standby can later acquire using stale state; a timestamp-preserving restore to that cached fingerprint makes Backfill return “unchanged” before consulting SQLite, even when another holder committed intervening content. Refresh/recreate the ingester after each successful acquisition and before the catch-up sweep.

🔧 Fix: Refresh Scribe state on watcher takeover
2 errors still open:

  • 🚨 cmd/roca-firstmate/main.go:193 - The required contract says a watch crash is retried with backoff, but after the initial path validation a transient removal/replacement between lines 99 and 111 makes openDatabase return os.ErrNotExist; this classifier rejects every non-SQLite error, so the already-raised child exits instead of retrying. Keep the initial configuration preflight fatal, but treat subsequent runtime open failures as retryable.
  • 🚨 cmd/roca-firstmate/resident.go:237 - Before acquiring the lease, the discarded factory call runs scribe.New, whose homes UPSERT updates label and kind. A valid standby using the same home ID with different metadata can therefore overwrite the active holder's home row despite failing lease acquisition. Pre-register only the missing FK row before acquisition, and apply mutable metadata only after ownership is established.

🔧 Fix: Separate watcher preflight and metadata ownership
2 errors still open:

  • 🚨 internal/scribe/scribe.go:189 - scribe.New commits the homes label/kind UPSERT without applying CommitFence. If holder A pauses past lease expiry, B can acquire and register B's metadata, then A can resume before its heartbeat runs and overwrite that metadata; A's later backfill fence fails, but the stale metadata remains committed. Fence the metadata registration transaction with the holder token.
  • 🚨 cmd/roca-firstmate/resident.go:367 - A renewal is treated as live using the timestamp captured before RenewSeat. If the database call or process is delayed longer than the configured lease, it can successfully write an already-expired deadline and leave the local source active; another session may then acquire immediately. After renewal, verify the deadline is still future or retry using a fresh timestamp before retaining the watcher.

🔧 Fix: Fence home metadata and renewal deadlines
4 errors still open:

  • 🚨 cmd/roca-firstmate/resident.go:141 - The required child contract says it “dies when stdin closes,” but shutdown calls stand-down with context.Background; ReleaseSeat then uses an uncancelled context. If another SQLite writer holds the database, this cleanup can block through the configured five-second busy timeout—or longer on stalled I/O—leaving the child alive after EOF. Bound cleanup or let the lease expire when prompt shutdown is required.
  • 🚨 cmd/roca-firstmate/resident.go:170 - The required takeover must occur “within the lease window,” but standby acquisition runs only on the fixed retry ticker. With defaults, if the holder dies immediately after renewal and a standby attempt occurs just before the two-minute expiry, its next attempt is thirty seconds later, exceeding the lease window. Schedule acquisition from the observed lease deadline or otherwise guarantee an attempt at expiry.
  • 🚨 cmd/roca-firstmate/resident.go:112 - The required lifecycle says raise should be recorded only after the child enters its retry lifecycle. A regular non-SQLite or corrupt file passes validateDatabasePath, emits raise, then openDatabase returns a non-retryable SQLite error and the child exits immediately. Complete permanent database validation before emitting raise, or defer it until open succeeds or is classified retryable.
  • 🚨 cmd/roca-firstmate/main.go:207 - The fail-soft open classifier omits SQLite CANTOPEN (14). A database that passes the path preflight but becomes temporarily unavailable during open/Ping—such as a transient mount or access race—therefore exits the already-raised child instead of logging crash-retry and backing off. Treat post-preflight availability errors as retryable while retaining fatal handling for corrupt/not-a-database inputs.

🔧 Fix: Harden watcher shutdown, takeover, and database retry
1 warning still open:

  • ⚠️ cmd/roca-firstmate/resident.go:142 - On stdin EOF, ctx is already canceled, so deriving the 250 ms cleanup context from it makes ReleaseSeat immediately fail with cancellation. A replacement session therefore remains standby until the production lease expires (up to two minutes), even when SQLite is available. Derive bounded cleanup from a fresh background context.

🔧 Fix: Release watcher leases within bounded shutdown cleanup
3 errors still open:

  • 🚨 cmd/roca-firstmate/resident.go:116 - The required child contract says it “dies when stdin closes,” but startup calls openDB synchronously without the session context. openDatabase uses Ping and migration with a five-second SQLite busy timeout, so closing stdin while startup waits on a writer lock cannot interrupt it and stalled I/O can delay exit indefinitely. Make database opening/migration context-aware or otherwise bounded by session cancellation.
  • 🚨 cmd/roca-firstmate/resident.go:341 - Home acquisition is sequential and performs the complete source setup and Backfill before trying the next home. If H1 has a slow sweep while H2’s holder dies, this standby cannot even attempt H2 until H1 finishes, exceeding H2’s lease window. Acquire and begin heartbeating all eligible seats before blocking sweeps, or run independent per-home acquisition lifecycles.
  • 🚨 cmd/roca-firstmate/resident.go:341 - The required child contract says it “dies when stdin closes,” but cancellation during Backfill is ineffective for an unchanged corpus: Scribe’s WalkDir callback never checks the context, and unchanged files return before any context-aware database operation. A large restart sweep can therefore continue after EOF and block shutdown. Enforce cancellation inside the shared Backfill walk, before fingerprint work.

🔧 Fix: Decouple home activation and enforce cancellation
4 issues (3 errors, 1 warning) still open:

  • 🚨 cmd/roca-firstmate/resident.go:604 - The resident reuses one Ingester's process-local fingerprint cache indefinitely. Concrete sequence: watch caches F1; an authorized attach/scribe process ingests F2; a timestamp-preserving restore returns the file to F1; the queued watch event returns “unchanged” from the stale cache before consulting SQLite, leaving F2 current while disk holds F1. Validate persisted cursor/current state at Scribe's shared unchanged boundary instead of relying solely on the cached map.
  • 🚨 cmd/roca-firstmate/resident.go:213 - Only the first successful activation is announced, and the renderer receives singleton slices. With two free homes, stdout reports watching for whichever sweep finishes first and never reports the second home's readiness, backend, or sweep counts, regressing the existing multi-home homes[] contract. Define per-home readiness events or aggregate all configured-home outcomes before emitting child-level readiness.
  • 🚨 cmd/roca-firstmate/resident.go:300 - A transient TryAcquireSeat error clears the previously observed lease deadline. If SQLITE_BUSY occurs at that deadline, the default retry moves the next attempt 30 seconds later, so a holder killed just after renewal is not inherited within the two-minute lease window. Preserve the observed deadline and retry promptly when it is due.
  • ⚠️ cmd/roca-firstmate/place.go:100 - os.Rename does not replace an existing destination on Windows, so a subsequent place invocation cannot update an already placed executable. Use a platform-appropriate replacement operation or a recoverable remove/rename sequence.

🔧 Fix: Harden watcher takeover, readiness, cursor, and placement
2 errors still open:

  • 🚨 cmd/roca-firstmate/resident.go:223 - activateWatchHome starts the event pump before the main loop accepts its success. A queued event can fail and stand the home down, yet the later success message still passes this source-only check and emits watching for a closed watcher. Carry the generation and revalidate the lease here, or delay pumping until activation is accepted.
  • 🚨 cmd/roca-firstmate/resident.go:228 - The requested multi-home contract says to “report all configured homes.” If one home's initial activation fails transiently while another succeeds, pendingActivations reaches zero, the successful subset is announced, and announced permanently prevents the retried home from appearing. Either wait for every configured home or define per-home readiness updates.

🔧 Fix: Revalidate and refresh multi-home watcher readiness
4 errors still open:

  • 🚨 cmd/roca-firstmate/verbs.go:584 - With multiple configured homes, if only one currently activates, this branch emits the single-home backfill envelope rather than homes[]; when another home later activates, stdout changes schema mid-session. Select the envelope from the configured-home count, not the active-summary count.
  • 🚨 cmd/roca-firstmate/resident.go:290 - After an announced home encounters a stream or ingestion failure, stand-down clears its active state but readiness is not recomputed because updates occur only on activation messages. The last watching envelope can therefore indefinitely claim a failed home remains active. Notify readiness from the shared stand-down transition.
  • 🚨 cmd/roca-firstmate/resident_test.go:811 - The required criterion says “kill the holder, the other takes over within the lease window,” but this test configures a 500ms lease and accepts takeover through the helper's eight-second timeout. A multi-second takeover regression would pass; assert elapsed takeover time against the lease with a small scheduling margin.
  • 🚨 cmd/roca-firstmate/place.go:116 - The replacement fallback treats any existing destination as replaceable. If roca-firstmate is a directory, it is renamed aside, replaced by the executable, and either deleted when empty or left under a random backup while the command reports failure. Refuse non-regular destinations before renaming.
✅ **Test** - passed

✅ No issues found.

  • git status --short --branch and git diff --stat/--name-status <base>..<target>
  • go test ./cmd/roca-firstmate -run '^(TestWatchSubprocessSessionLifecycleAndAbruptTakeover|TestWatchResidentRetriesFailedIngestWithCatchUp|TestWatchResidentRetriesRuntimeInitialization|TestPlaceCopiesExecutableIntoPluginDirectory)$' -count=1 -v
  • go test ./internal/nerve -run '^(TestTryAcquireSeatSingleFlightAndInherit|TestConcurrentTryAcquireSeatYieldsOneHolder|TestReleaseSeatLetsTheNextHolderTakeOverImmediately)$' -count=1 -v
  • go test ./internal/watchlog -run '^TestAppendWritesRaiseLeaseAndSweepEvents$' -count=1 -v
  • go test ./schema -run '^(TestChecksumsCoverExactlyTheInstallPayload|TestShippedDatabaseHasNoMirroredRows|TestTelemetryTablesRemainFreeForLaterAdditiveSchema)$' -count=1 -v
  • Built and ran roca-firstmate watch --db <fabricated-db> --json --poll-interval 50ms --home <fabricated-home> --home-id northwind-harbor with session-owned stdin; verified live ingest, EOF exit, idle non-ingest, restart catch-up, concurrent standby, abrupt holder death, takeover, and resumed ingest
  • Queried fabricated SQLite state to confirm one active watch seat during concurrency and exactly one persisted row per observed version
  • Ran roca-firstmate place --dir <fabricated-plugin-dir> and verified the placed file was executable and byte-identical
  • Exported final persisted history and JSONL telemetry to the dedicated evidence directory, then removed all worktree scratch artifacts
🔧 **Document** - 1 issue found → auto-fixed ✅
  • ⚠️ plugin.json:187 - Semantic metadata still describes seats only as workspace subscriptions, but resident watch holders now use the same table. Correcting this requires changing plugin.json and its package checksum, which this documentation-only phase cannot modify.

🔧 Fix: Document resident watch seat leases
✅ Re-checked - no issues remain.

✅ **Lint** - passed

✅ No issues found.

✅ **Push** - passed

✅ No issues found.

teseo added 16 commits August 23, 2026 11:42
Raise watch as a stdin-owned child that catch-up sweeps then listens.
Reuse seats for single-flight, log JSONL telemetry, and place the
executable in the plugin directory so a session parent can resolve it.
@teseo
teseo merged commit 05e9d24 into main Aug 23, 2026
1 check passed
@teseo
teseo deleted the fm/laroca-fm-watch-residente branch August 23, 2026 14:55
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant