* fix(codex): deliver activity hooks via -c session flags, trust worktree at launch
Codex (0.136+) never loads hook config from AO's per-session worktrees:
project-local .codex/ layers only load from trusted directories, and for
linked git worktrees codex sources hook declarations from the matching
folder in the root checkout — so the workspace-local .codex/hooks.json AO
wrote was dead config and codex sessions never reported activity.
Deliver the hooks on the launch/resume command instead:
- -c 'hooks.<Event>=[...]' session-flag config for SessionStart,
UserPromptSubmit, PermissionRequest, and Stop; the session-flags layer
is not trust-gated and aggregates with the user's own hooks. The
existing --dangerously-bypass-hook-trust flag lets them run without a
persisted trust hash.
- -c 'projects={"<worktree>"={trust_level="trusted"}}' (inline-table
form; the dotted projects."<path>".trust_level key is corrupted by
codex's naive -c dot-split) so spawns into never-trusted repos don't
hang invisibly on the interactive directory-trust prompt. Both the
literal and symlink-resolved worktree paths are trusted.
- -c notice.hide_rate_limit_model_nudge=true so the "switch to a cheaper
model?" dialog can't hang a headless pane and swallow the spawn prompt.
GetAgentHooks no longer writes workspace files (worktrees stay clean); it
only strips entries older AO versions left in .codex/hooks.json,
preserving user hooks. UninstallHooks/AreHooksInstalled now operate on
those legacy files only.
Verified with a real spawn into a fresh untrusted repo: activity
transitions idle -> active -> idle hands-free, no .codex dir in the
worktree, no hook delivery failures.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* feat(sessions): activity-signal watchdog + hook delivery hardening
A codex upgrade broke activity tracking silently: sessions showed a
confident "idle" forever while the agent worked. This bundle makes hook
delivery verifiable end to end and makes any future breakage loud
instead of invisible.
Watchdog (no_signal status):
- sessions.first_signal_at (migration 0010) records the FIRST hook
callback per spawn/restore — raw signal receipt, independent of the
derived activity state. lifecycle.ApplyActivitySignal stamps it (and
writes through same-state repeats until stamped, e.g. Codex
SessionStart reporting idle on an idle-seeded row); MarkSpawned clears
it so every relaunch re-proves its hook pipeline.
- deriveStatus downgrades a live session with no receipt to the new
no_signal display status after a 90s grace, instead of idle.
Terminated/PR-derived statuses still win. The sessions CDC update
trigger now also fires on first-signal receipt so the dashboard
transition is pushed live.
- frontend maps no_signal -> needs_you (a human should look at the pane).
Hook callback hardening (re-landed from the closed redesign PR #156):
- the session manager pins each spawned session's PATH with the daemon
executable's directory first, so the bare `ao` in hook commands
resolves to the daemon that installed them, with a spawn-time warning
when the pin cannot apply.
- `ao hooks` failures append to $AO_DATA_DIR/hooks.log (size-capped);
`ao doctor` gains a hooks-log check that warns on failures from the
last 24h, and an ao-binary identity check.
Codex launch-surface canary:
- `ao doctor` gains codex-launch-flags: it runs probes exported by the
codex adapter (built from the same flag builders as the real spawn
argv) against the installed binary, warning when codex rejects the
hook-trust bypass flag or AO's -c session-flag overrides.
- codex hook callback timeout drops 30s -> 5s so a hung daemon cannot
stall the agent's turn.
Docs: the agent PRD callback section now describes the implemented flow
(derive state, POST /sessions/{id}/activity, hooks.log) instead of the
unbuilt SQLite/metadata merge, and notes that hook-derived metadata
persistence (codex resume) is still not implemented.
Frontend note: main's renderer test suite has 7 pre-existing failing
files and a vite-config typecheck error unrelated to this change;
workspace.test.ts (the only frontend file touched) passes 26/26.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* test(store): restore TestSessionWorktreesRoundTrip lost in the re-landing port
The branch ported store_test.go wholesale from the closed redesign
branch, whose copy predates #165 — silently dropping the
session-worktrees round-trip test #165 added. Restore main's file and
re-apply only this branch's addition (TestSessionFirstSignalRoundTrip).
No other ported file lost main-side content (audited per-file against
main; the remaining deletions are this branch's intended refactors).
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* fix(status): only derive no_signal for harnesses that have a hook pipeline
Review finding: the no_signal downgrade had no harness-capability gate, but
first_signal_at can only ever be stamped by an `ao hooks` callback. Ten
spawnable harnesses (amp, aider, crush, grok, kimi, devin, auggie, continue,
vibe, pi) install no hooks at all, so every live session of theirs would have
flipped from idle to a permanent no_signal -> needs_you after the 90s grace.
The session service now takes a SignalCapable predicate; daemon wiring injects
activitydispatch.SupportsHarness (the deriver registry is the source of truth
for "this harness can signal"). Left nil, the service never claims no_signal.
A new dispatch test pins that every deriver token is a known harness name.
Also from the same review:
- lifecycle/manager.go and the 0010 migration claimed Codex's SessionStart
reports idle as the first signal; both codex and claude-code derivers
deliberately return no signal for session-start, so the comments now cite a
real case (a lost "active" POST followed by a Stop hook landing idle).
- docs/agent/README.md documents the gate and the restore caveat: a restored
session the user never prompts has nothing to signal, so it shows no_signal
after the grace until a receipt-only session-start signal exists.
- 0010 migration uses DROP TRIGGER IF EXISTS per house style.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
---------
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
* refactor(observe): extract shared observer skeleton
Move the observer-pattern-general pieces of the SCM observer into a new
backend/internal/observe package so the tracker observer (issue #35) can
build on the same primitives:
- StartPollLoop: goroutine supervisor with immediate-first-poll + ticker
+ ctx-done exit. SCM Observer.Start now delegates to it.
- CheckCredentialsOnce: lazy first-poll credential gate driven by a
CredentialProbe closure. SCM observer keeps credentialsChecked/disabled
as Observer fields; the shared helper mutates them via pointer so
state ownership stays single-source.
- CacheSet[V any] / CacheDelete[V any]: one generic bounded-FIFO helper
replaces the three near-identical cacheSet{String,Time,Bool} bodies
and the standalone evictStrings. The SCM-side methods are now
one-line wrappers that thread o.Cache.max into the shared helper, so
existing call sites and tests are untouched.
SCM behavior is unchanged. The full 21-test SCM suite (including the
end-to-end test added in PR #115) plus 577 backend tests stay green
under `go test -race`.
Part of #112.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
* feat(tracker): ports.TrackerObservation DTO + ApplyTrackerFacts reducer
Land the contract that the future Tracker observer (issue #35) and its
provider adapters must satisfy. No observer is wired in this PR — the
DTO + reducer are the deliverable, and locking the shape now lets the
observer + adapter work happen in small follow-up PRs.
DTO (backend/internal/ports/tracker_observations.go):
- TrackerObservation mirrors ports.SCMObservation: Fetched bool,
ObservedAt time.Time, Provider/Host/Repo, normalized Issue facts,
Comments, and a Changed{State, Assignee, Comments} discriminator.
- TrackerIssueObservation carries the minimal facts lifecycle needs
today (state, assignee, title, body, timestamps); richer
per-provider metadata stays inside each adapter.
- TrackerCommentObservation carries the comment fields needed for the
bot-mention nudge (Author, Body, IsBot, ID for dedup).
Reducer (backend/internal/lifecycle/reactions.go):
- ApplyTrackerFacts(ctx, sessionID, ports.TrackerObservation) error,
mirroring ApplySCMObservation's "Fetched gate → terminal-state →
per-bucket reactions" shape.
- Three initial reactions:
* Issue state == done | cancelled → MarkTerminated (idempotent).
* Changed.Assignee → log only via slog.Default(). The "assignee
changed away from AO" policy is reserved for #40.
* Changed.Comments with bot comments → one-time nudge with
strings.Join'd bot bodies, deduped by comment IDs.
- The nudge path reuses sendOnce with an empty prURL so the in-memory
dedup applies but the PR-row persistence path is skipped. Tracker
signature persistence will land with #35 alongside issue-row storage.
Tests in backend/internal/lifecycle/manager_test.go cover each branch:
terminate (done + cancelled), log-only assignee, nudge fires on new
bot comment, nudge suppressed on repeat, new bot comment id refires,
not-fetched is no-op, terminated session ignores observations.
Part of #112.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
* fix(observe): rename CacheSet param to avoid shadowing built-in max
golangci-lint revive flagged the CacheSet generic helper's max
parameter as shadowing the built-in max() function. Rename to
maxEntries; signature change is internal to the observe package and
the SCM observer's one-line wrappers pass the value positionally, so
no call sites need updating.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
* fix: honour disabled state in CheckCredentialsOnce + tighten bot-comment filter
Two P1 review findings on #116:
1. observe.CheckCredentialsOnce was returning (true, nil) on every
call after the gate ran, even when the probe had marked the
observer disabled, because the *checked short-circuit ignored
*disabled. The SCM observer didn't surface this in practice — its
Poll method has an independent `if o.disabled { return nil }`
guard that runs first — but a future Tracker observer that relies
on the helper's documented contract ("Observer stays disabled")
would silently flip back to "credentials available" after the
first poll. Change the short-circuit to `return !*disabled, nil`
and lock the behavior with a regression test that issues repeat
calls after the probe reported unavailable.
2. lifecycle.newBotCommentContent's "skip uninteresting comments"
filter used && where it needed ||. A bot comment with an empty ID
but a non-empty body slipped through and appended "" to the ids
slice. If every bot comment in the observation had an empty ID,
strings.Join produced "" — which matches the zero value of the
in-memory dedup map, so sendOnce treated the nudge as
already-sent and silently suppressed it forever. Switch to || so
any comment missing either an ID or a body is dropped, and add a
regression test that an empty-ID bot comment never nudges (and
does not pollute the dedup state for a follow-up comment that has
a real ID).
586 tests pass with -race.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
* test(observe): capture deadline once in poll-error spin-wait
The `TestStartPollLoop_LogsPollErrorWithoutPanic` spin-wait was
computing the loop bound as `time.Now().Before(time.Now().Add(200ms))`
on every iteration, which is permanently true — the loop could only
exit via the `break`. Under a scheduler delay (heavy CI load or
`GOMAXPROCS=1`) where two polls never land in time, the test would
hang until the wall-clock kill rather than failing fast.
Capture the deadline once before the loop, and tighten the assertion
to actually require two polls + done-channel closure within a bounded
window, matching `TestStartPollLoop_FirstPollImmediateThenTicks`.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com>
* feat: add ao hooks activity command
* fix(activity): address review nits
- lcm: sameActivity ignores LastActivityAt so same-state repeats no-op
and don't churn UpdatedAt / CDC events.
- cli/hooks: surface stdin read errors to stderr for parity with the
daemon-error path; still exit 0 so a failed hook can't break the agent.
- claudecode: GetAgentHooks docstring covers Notification + SessionEnd
(the slice already included them; only the comment was stale).
---------
Co-authored-by: harshitsinghbhandari <dev@theharshitsingh.com>
* feat(daemon): thread runtime messenger into Lifecycle Manager (#108)
The daemon used to construct the LCM with a nil messenger, so every
SCM-driven nudge dropped silently inside sendOnce. Move newSessionMessenger
above startLifecycle and pass the real messenger through, so CI-failure,
review-feedback, and merge-conflict nudges actually reach the agent.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
* feat(project): populate RepoOriginURL at add + lazy observer backfill (#108)
project.Add now shells out to `git -C path remote get-url origin` and
captures the result on the new project row, so the SCM observer can parse
it on the first poll. A missing remote falls back to "" rather than failing
project add — non-git roots and remoteless repos stay registerable.
To cover projects added before this change, the observer's discoverSubjects
lazily backfills RepoOriginURL via the same shell-out and persists it
through UpsertProject, so subsequent polls skip the fork-exec.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
* feat(lifecycle): persist reaction-dedup signatures across restart (#108)
Add migration 0005 with `pr.last_nudge_signature TEXT NOT NULL DEFAULT ''`
and two scoped sqlc queries (Get/UpdatePRLastNudgeSignature). Lifecycle
serialises the per-PR slice of its seen/attempts maps to that column as a
small JSON document; sendOnce loads it lazily on first touch of each PR
and persists after every successful send.
This closes the post-restart re-nudge gap: the daemon used to lose the
seen map on bounce, so a still-failing CI re-prompted the agent on the
first post-restart observer poll even when it had already been told.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
* fix(lifecycle): silence nilerr on intentional corrupt-payload swallow
golangci-lint's nilerr flagged the `if err := json.Unmarshal(...); err != nil { return nil }`
path in loadPRSignaturesLocked. The swallow is deliberate (a corrupt persisted
payload should not crash the lifecycle write path), so compare against nil
directly so no `err` is bound and the lint goes quiet.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
* fix: silence nilerr, address reviewer notes, drop task-tagged comments
- reactions.go: discard the json.Unmarshal error explicitly via `_ =` so
golangci-lint's nilerr stops flagging the intentional corrupt-payload
swallow; behavior unchanged.
- reactions.go: document the Send → memory → persist order in sendOnce so
the "one extra nudge on restart after a transient persist failure"
trade-off is explicit (vs. the inverse risk of losing a real nudge).
- service.go: stop reaching for slog.Default() in resolveGitOriginURL;
align with the observer's identical helper that just returns "" on git
failure rather than logging through the global logger.
- tests: drop "issue #108" / "guards the regression from #X" framing in
test docstrings — explain WHAT the test asserts, not the PR context.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Introduces backend/.golangci.yml (27 linters across correctness, dead-code/
boilerplate, style, and security), wires it into CI as a blocking job, and
fixes every finding so the tree starts at zero.
Config:
- 27 linters: errcheck, govet, staticcheck, errorlint, bodyclose,
sqlclosecheck, rowserrcheck, nilerr, makezero, unused, unparam, unconvert,
wastedassign, copyloopvar, prealloc, dupl, revive (incl. exported-symbol doc
comments), gocritic, misspell, usestdlibvars, predeclared, nakedret, gosec, …
- Tuned for signal over noise: govet/shadow and gocritic hugeParam/rangeValCopy/
unnamedResult disabled (idiomatic-Go false positives); sqlc-generated code and
tests get scoped exclusions; gosec G304 excluded (paths are config/run-file/
worktree-derived, not user input); nilerr excluded in cli/status.go (probe
failures are the reported status, not a command error).
CI:
- New blocking lint job (golangci-lint-action, latest binary for Go-version
compatibility).
- go-version now read from go.mod (was pinned 1.22 while go.mod declares 1.25).
Cleanup to reach zero (no behavior change):
- errcheck: wrap deferred/inline Close()/Remove()/Rollback() with `_ =`.
- gosec: tighten dir/file perms (0755->0750, 0644->0600).
- unparam: drop always-nil error return from startLifecycle; drop unused
shellPath param (zellij PowerShell) and always-500 fallbackStatus param
(writeProjectError).
- gocritic: regexp \d, s != "", switch->if, combined appends.
- revive: doc comments on all exported symbols; rename project.ProjectRow ->
project.Row (stutter); rename `max` locals shadowing the builtin.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Each PR-child table (pr / pr_checks / pr_comment) had three near-identical
structs — gen.* (generated), sqlite.*Row, and ports.* — with wiring.Adapter
copying field-by-field between them. Collapse to one shared definition per
table in domain (PRRow / PRCheckRow / PRComment), used by both the PRWriter
port and the sqlite store; gen.* stays sealed inside the storage layer.
- *sqlite.Store now satisfies ports.SessionStore + ports.PRWriter directly,
so the entire wiring.Adapter package is deleted (lifecycle.New(store, store)).
- The bool PR state <-> single state column, int<->int64, and enum-default
translation now lives only at the gen<->domain boundary in pr_store.go.
- WritePRObservation renamed WritePR to match the port; the integration test
and composition root drop their adapter copies.
Net -280 lines, behaviour unchanged. go test -race ./... green.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Addresses review on PR-observation persistence:
- pr_checks now has an AFTER UPDATE CDC trigger (guarded on status change), so a
check flipping in_progress->failed on the same commit emits change_log instead
of updating silently. Restores symmetry with the sessions/pr triggers.
- writePR persists scalar facts + checks + comments in ONE transaction via
Store.WritePRObservation, so a mid-write failure can't leave the pr row (and
its CDC event) committed while checks/comments are partial. Collapses the
PRWriter port's three write methods into one WritePR.
- db.go: record why modernc.org/sqlite (pure-Go, CGO-free static binary) at the
import site.
Regression tests for both the update-trigger (emit on change, suppress no-op
re-poll) and the transactional write. go test -race ./... green.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Reworks the LCM, reactions, session manager, reaper, and boot wiring onto
the redesigned domain model — collapsing the runtime axis to is_alive,
moving PR facts to the pr table (read back as PRFacts), replacing the
free-form SessionReason with a typed terminal-only TerminationReason, and
dropping Revision/EventType/durable reaction-trackers (CDC is trigger-driven,
escalation budgets are in-memory).
- ports: SessionStore + PRWriter interfaces; PRObservation/RuntimeFacts/
ActivitySignal DTOs; drop LifecycleStore/EventType/ReactionStore.
- lifecycle: single-writer reducer over is_alive; ApplyPRObservation writes
the pr tables and reacts; CI-fix-loop brake derived from pr_checks history;
review comments injected into the agent regardless of author (no bot
detection); merge auto-terminates with pr_merged.
- session: store-assigned "{project}-{n}" ids; folded metadata; status
derived from PRFacts on read.
- reaper: reports the four-valued probe vocabulary unchanged.
- boot: trigger -> poller -> broadcaster; storeAdapter bridges *sqlite.Store.
Lane shrinks 6218 -> 2803 LOC. go build/vet/test -race green.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The first storage cut modelled two side tables as free-form blobs. This
replaces both with opinionated, statically-typed schema so what a session
can carry is fixed by the schema, not by convention.
session_metadata: was a (session_id, key, value) KV bag with six
convention-only keys. Now a 1:1 table of named, typed columns. The domain
currency is a typed domain.SessionMetadata struct (was map[string]string),
threaded through ports.LifecycleStore, the LCM, the Session Manager and the
reaper, so an unknown key is a compile error rather than a silently-dropped
write. PatchMetadata keeps its non-destructive merge ("empty = leave
unchanged"). The off-canonical invariant is now enforced at the type level
via json:"-" on SessionRecord.Metadata, removing the manual `Metadata = nil`
scrub the change_log/snapshot paths had to remember; the Meta* string-key
constants are deleted.
pr_enrichment -> pr (+ pr_check, pr_comment): the scalar facts are now
typed columns with CHECK-constrained enums (review_decision, mergeability,
ci_state) and integer CI counts instead of opaque TEXT. The two list facts
the old `pending_comments`/ci_summary strings smuggled are normalized into
child tables (pr_check, pr_comment) that cascade from pr. The store exposes
UpsertPR/GetPR plus atomic ReplacePRChecks/ReplacePRComments + List.
Both tables remain off the canonical CDC path. sqlc regenerated; migrations
0001/0002 revised in place (nothing released). gofmt/vet clean; go test
-race green; daemon smoke-boots and creates the new schema.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Add the two real outbound adapters that replace the in-memory fakeStore:
internal/storage/sqlite (persistence satisfying ports.LifecycleStore) and
internal/cdc (transactional-outbox publisher, JSONL delivery, durable
consumer). Wire them into main.go alongside the Lifecycle Manager and reaper
so the write path is live end-to-end: LCM.Upsert -> store -> outbox -> JSONL
-> broadcaster.
Storage (internal/storage/sqlite):
- modernc.org/sqlite (pure Go, no CGO) for clean cross-compile; goose
embedded migrations; sqlc-generated typed queries under gen/.
- Atomic Upsert: session row + change_log + outbox written in one tx.
- revision is an optimistic-concurrency (CAS) check: insert requires
revision 0 and persists 1; update requires loaded revision == stored and
bumps +1; zero rows affected returns a revision-mismatch error.
- Metadata is an opaque map in session_metadata, off the CDC path.
- Durable reaction_trackers (fixes the in-memory-only escalation budget that
re-fired human pages on restart).
CDC (internal/cdc):
- Publisher drains the outbox to a JSONL log; size-based rotation with a
reset marker.
- Consumer tails via byte cursor, detects rotation (os.SameFile), resyncs
from a full-state snapshot on gaps, and tracks a durable consumer_offsets
cursor.
- Janitor reclaims acknowledged outbox rows.
- Broadcaster is the in-process fan-out port the FE transport will subscribe
to (WS/SSE wiring deferred).
Composition root (main.go + *_wiring.go):
- startCDC stands up publisher/consumer/janitor + broadcaster.
- startLifecycle constructs the LCM, makes escalation budgets durable via
WithReactionStore, teaches it to enumerate sessions via WithSessionLister,
and starts the reaper.
- Notifier, AgentMessenger, and the reaper's runtime registry are TEMPORARY
no-op/empty stubs (lifecycle_wiring.go) with TODO markers; see the PR
description for how to fill them in.
Tests: contract-parity, revision CAS, outbox atomicity, CDC ordering and
idempotency, rotation/resync, janitor vacuum, reaction durability across a
simulated restart, and composition-root adapters. gofmt/build/vet clean and
go test -race ./... green.
Address blocker found in self-review (B1 + I1):
- Manager.RunningSessions previously filtered to runtime.State == RuntimeAlive,
but a session enters Detecting with runtime axis = RuntimeProbeFailed (failed
probe path, decide_bridge.go:72) or RuntimeMissing (detectingLC in
manager_test.go:539). The filter silently parked every Detecting session, so
the recovery path proved by manager_test.go:59 ("healthy probe recovers
liveness-owned detecting -> working") and the terminal path proved by
manager_test.go:79 ("dead+dead with no recent activity concludes killed")
were both unreachable through the reaper. Broaden the predicate to "session
is not in a terminal state" (mirrors the LCM's existing isTerminal helper)
and document the wider semantics.
- reaper.probeOne now reports every probe result — including alive — back to
the LCM as ApplyRuntimeObservation facts. The previous skip-alive
optimization was a layering violation: the reaper has no business deciding
what counts as a no-op. The LCM's ApplyRuntimeObservation already diffs
against canonical and only Upserts on actual change, so steady-state alive
stays cheap. With the broadened poll set, an alive probe for a Detecting
session IS the recovery fact.
- Add unit tests for Manager.RunningSessions covering: nil-lister no-op, lister
error propagation, and the full canonical state matrix (working/idle/
needs_input/detecting-probefailed/detecting-missing/not_started included;
terminated/done excluded).
- Update reaper tests: alive case now asserts the alive fact is reported; new
"detecting session: alive probe reported so LCM can recover from quarantine"
case locks in the recovery path; multi-runtime case now asserts both runtime
facts flow through.
- Bump "session in poll set without handle metadata" log from Debug to Warn —
it is an anomaly (OnSpawnCompleted should have written both keys), not a
routine event.
- Document WithSessionLister must be called before any reaper attached to the
Manager starts running (it is a bare field read; concurrent re-injection is
meaningless anyway).
The reaper sits OUTSIDE the LCM's per-session serial loop. On every tick it:
1. Fires lcm.TickEscalations(now) — the duration-based escalation heartbeat
a non-polling LCM cannot wake itself to drive.
2. Asks lcm.RunningSessions for the snapshot of sessions whose runtime axis is
alive, then calls runtime.IsAlive(handle) per session via a RuntimeRegistry
that dispatches by RuntimeHandle.RuntimeName (so a single reaper covers
tmux + zellij side by side).
3. Reports any non-alive result back as a fact via ApplyRuntimeObservation —
dead -> RuntimeProbeDead, probe error -> RuntimeProbeFailed (never
collapsed to alive: failed probe ≠ dead, but it ≠ alive either). Steady-
state alive is skipped so we don't churn the LCM with no-op load/diff work.
The reaper REPORTS facts; the LCM owns DECIDE (anti-flap Detecting quarantine,
terminal-session rules). The reaper never writes.
Open-question resolution: add RunningSessions(ctx) to ports.LifecycleManager
(option a). The Manager implements it via an injectable session lister
(Manager.WithSessionLister) so the LCM itself does not require a new
LifecycleStore method — Tom's store contract is untouched, daemon wiring (lane
#10) will inject the production lister at startup.
Scope: reaper goroutine + the minimum LCM seam. No activity ingest, no FS
watcher, no daemon wiring, no new schema fields, no store changes.
Four narrowly-scoped fixes against the LCM + Session Manager lane
from an external review of the current backend state. R2 (failed-restore
lifecycle stranding) is intentionally deferred to PR #15, which already
closes it via the new OnSpawnInitiated path; R3 also stays on that PR.
- R1 (BLOCKER): Manager.Spawn never persisted AgentSessionID, so
Manager.Restore's hard-required metadata key was always missing and
every restore failed. Persist the assembled launch prompt as
MetaPrompt at spawn time and add a fresh-launch fallback to Restore
that uses Agent.GetLaunchCommand with the seeded prompt when the
captured agent session id is absent (the id-capture hook is a separate
path that may never have run). Restore still fails fast when neither
the id nor a prompt is on hand — there is nothing to relaunch from.
- RA (BLOCKER): adapters/workspace/gitworktree/commands.go's
worktreeRemoveForceArgs passed --force, which deletes uncommitted
agent work. Renamed to worktreeRemoveArgs and dropped --force so the
post-prune "still registered" guard in Workspace.Destroy surfaces the
refusal to Manager.Cleanup, which routes the session to Skipped
instead of destroying in-progress changes.
- R11 (SHOULD-FIX): reactions.go's two Notifier.Notify call sites
(executeReaction's notify and escalate) built OrchestratorEvent
without ProjectID. Captured projectID on the transition (via a
store.Get in mutate) and on reactionTracker (so TickEscalations can
still populate it on duration-based escalations), and threaded it
through executeReaction/sendToAgent/escalate.
- RB (SHOULD-FIX): gitworktree.Workspace.managedPath used filepath.Join
which cleans .. segments before validateManagedPath ran, so
session=\"../other\" stayed inside managedRoot while breaking
per-project isolation. validateConfig now rejects path separators and
the . / .. components on ProjectID and SessionID at the source.
go build ./..., go vet ./..., and go test -race ./... all pass.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Address PR #2 Copilot review comments on the merged LCM+SM lane:
- session: validate runtime handle + workspace path before Kill/Cleanup
teardown; refuse (ErrIncompleteTeardownMetadata) or skip rather than
hand empty args to a real adapter's Destroy (unsafe delete).
- session: reject Restore unless the session is terminal
(ErrNotRestorable) so a live session can't spawn a duplicate
runtime/workspace.
- ports: document SpawnConfig.OpenTerminal as reserved/not yet honored.
- lifecycle: remove the unread reactionConfig.auto field; note
approved-and-green is notify-only (human decides to merge).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Implements ports.SessionManager against fakes for the outbound ports. The SM is
the explicit-mutation half of the lane: it drives Runtime/Agent/Workspace, seeds
the initial lifecycle, and routes outcomes to the LCM (OnSpawnCompleted /
OnKillRequested). It never derives observed state and is the single producer of
the derived display status (attached on read, never persisted).
- Spawn: Workspace.Create -> Runtime.Create (AO_* identity env) -> Seed ->
OnSpawnCompleted, with eager rollback of completed steps on failure.
- Kill: OnKillRequested first -> Runtime.Destroy -> Workspace.Destroy, honoring
the worktree-remove safety (refusal surfaced, never forced).
- List/Get: derive status via DeriveLegacyStatus. Send: via AgentMessenger.
Restore: re-seed (reopen) + relaunch via GetRestoreCommand. Cleanup: reclaim
terminal sessions, skip worktrees holding uncommitted work.
Store-contract additions (co-owned with Tom's persistence layer, flagged for
review): LifecycleStore.Seed (explicit create-with-identity; OnSpawnCompleted
requires a seeded record) and LifecycleStore.Get (single record-with-identity
read; Load is lifecycle-only). Lifecycle test fake updated to satisfy both.
Tests route through the real LCM Manager (wrapped to record call order).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
- OnKillRequested now clears the session's escalation trackers after a
successful kill, so a later duration-based TickEscalations can't emit
reaction.escalated for a dead session (dispatch is still skipped).
- sendToAgent rolls back the attempt (and firstAttemptAt when it set it) on a
messenger.Send error, so undelivered messages don't march a reaction toward
escalation — honoring "send failures retry next tick" (§4.3).
- Duration escalation now uses an inclusive boundary (>=) in both shouldEscalate
and TickEscalations, so a 30m reaction escalates at exactly 30m instead of
waiting for the next tick.
Tests: kill clears trackers + no post-kill escalation; repeated failed delivery
never escalates; duration escalation fires at exactly escalateAfter.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Address review finding #1: the persistent ci-failed tracker leaked and could
stale-silence a future regression. It was only cleared when leaving the
ci-failed reaction AND incidentOver held at that moment — so a recovery to
another open-PR state (ci-failed -> approved -> merged) never cleared it.
- react() now clears ALL of a session's trackers when the state REACHED is
incident-over (PR resolved / session terminal) OR a genuine recovery
(approved/mergeable, which the open-PR ladder guarantees means CI is no
longer failing). Keyed on the state reached, not the one left, since the
recovery transition is typically review_pending->approved (empty beforeKey).
- Persistent ci-failed still survives the ambiguous review_pending limbo, so
fail->pending->fail keeps one shared budget (§4.2).
- Document the out-of-lock react() dispatch caveat for the daemon integration
step (review #2) and the intentionally-skipped agent-stuck 10m threshold.
Tests: re-arm after a genuine recovery (regression re-nudges, not silenced);
all session trackers cleared once the incident is over.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Add the ACT half of the LCM: map persisted status transitions to reactions
(send-to-agent / notify / auto-merge) and drive escalation.
- reactions.go: the §4.2 default reaction table, reactionEventFor (mirrors
DeriveLegacyStatus for the ACT layer), in-memory per-(session,reaction)
escalation trackers, the react() dispatch chokepoint, and a real
TickEscalations for duration-based escalations the synchronous LCM can't
wake itself for. auto-merge action exists but is off by default;
bugbot-comments/merge-conflicts are configured but dormant (no decide-core
producer yet).
- manager.go: mutate now returns a transition; each Apply* path fires the
mapped reaction after persist via the single synchronous react() seam.
OnKillRequested intentionally does not react (explicit kill != inferred
event). Split-A load->decide->diff->persist behavior is unchanged.
ci-failed budget is persistent across fail->pending->fail oscillation;
non-persistent trackers reset when the status leaves the triggering state.
Escalation silences further auto-dispatch until the condition clears.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Address Harshit's PR #5 review (approve w/ design confirms + polish):
- #1 (design decision): a valid activity signal is proof of life, so it now
resolves a detecting session — writes the activity-mapped session state and
clears the quarantine memory. Scoped to detecting only; a liveness-escalated
stuck stays the probe pipeline's to resolve. Terminal still never reopens.
- #2: document why a merged/closed PR parks the session axis even over an
activity-owned needs_input/blocked (a merge is a milestone), unlike the
open-PR path that defers to activity.
- #3: map plain idle activity to a neutral session reason instead of the
misleading research_complete (kept for ready, which implies completion).
- #6: cover all three kill kinds (manual/cleanup/error), the open-PR review
branches (changes_requested/mergeable/review_pending), and the neutral idle
reason. Coverage 86.5% -> 88.6%.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
- shouldWriteSessionRuntime: never resurrect a terminal session; an
observation may refresh the runtime axis but must touch neither the
session axis nor the detecting memory (gated in ApplyRuntimeObservation).
- OnSpawnCompleted: error on an unseeded session instead of fabricating a
partial record (SM must seed first — a missing seed is a contract violation).
- OnKillRequested: no-op on an unknown/already-gone session (benign race)
instead of fabricating a terminal record.
- keyedMutex: reference-count entries and evict on last release so the lock
map stays bounded in a long-running daemon.
- runtimeSubstateFromFacts: map RuntimeProbeIndeterminate to RuntimeUnknown
with a neutral reason, distinct from the probe_error of a failed probe.
Adds tests for terminal non-resurrection, unseeded spawn-completed error,
and unknown-session kill no-op.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Implements ports.LifecycleManager as a synchronous observe->decide->persist
reducer. Every entrypoint runs the shared pipeline under a per-session lock:
load canonical -> run the matching pure decider -> diff into a sparse
merge-patch -> persist. Never polls, never writes the display status.
- ApplyRuntimeObservation -> probe decider; always writes the runtime axis.
- ApplySCMObservation -> open-PR / terminal-PR deciders (failed fetch is a
no-op: failed probe != "no PR"). Open PRs write only the PR axis.
- ApplyActivitySignal -> updates the activity axis + maps onto the session
axis; only valid-confidence signals are authoritative.
- OnSpawnCompleted -> runtime alive + handles to metadata; session stays
not_started (display: spawning).
- OnKillRequested -> SM's explicit terminal-write authority.
- TickEscalations -> no-op stub (reaction/escalation engine is split B).
Composition rule (#1): liveness owns the runtime + death axis; activity owns
the working/idle/waiting axis. A healthy probe verdict writes the session axis
only to recover a liveness-owned state, so it never clobbers an activity-owned
needs_input/blocked. Activity is the mirror: it stays off the death axis.
Detecting clear (#3): a non-detecting probe verdict clears stale detecting
memory so the next probe reads no phantom Prior.
Built/tested against in-memory fakes (LifecycleStore with full merge-patch +
ExpectedRevision, recording Notifier/AgentMessenger). Per-session
serialisation verified under -race.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>