* feat(session): support multiple PRs per session
A session can now own several pull requests (a root plus stacked
children) instead of being capped at one. The SQLite schema was already
1-session->many-PR (pr.url PK, session_id a plain FK), so this is a
behavioural change across the observe -> persist -> derive -> react
pipeline, not a migration.
- observe: the SCM observer discovers every open PR whose source branch
matches a session branch or descends from it ("branch/..." stacking),
attributing each to the owning session; the longest matching branch
wins so a child session claims its own stacked PRs.
- derive: session status is a worst-wins aggregate over all owned PRs,
with a stack model (B is a child of A iff B.target == A.source and A is
open) exposed via prs[] on every session read DTO.
- react: per-PR reactions; a stacked child blocked by an open parent is
exempt from the rebase/merge-conflict nudge (only the bottom of the
stack is eligible), and the session completes only when no PR is open
and at least one merged.
- tests: unit coverage across stack/status/observer/lifecycle, a
real-SQLite ListPRFactsForSession test for the stacked-PR read path,
and a functional end-to-end integration test driving the real store +
lifecycle + observer through attribution, completion, and stacked-child
nudge suppression.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
* fix(scm): ignore fork heads in PR attribution and persist discovered siblings before completion
Branch-prefix attribution now requires a discovered PR's head branch to live
in the project repo. A fork PR can reuse a session's branch name while its
commits live in the fork, so the previous code could auto-claim foreign work.
Carry head repo full_name from the REST list response and skip any PR whose
head repo is not the base repo.
discoverNewPRs also writes each newly discovered PR as an open baseline row
before the refresh/lifecycle pass runs. A session can own several PRs, and a
terminal observation triggers a completion check that reads all of the
session's PRs from the store. Without the early write, an open sibling found
in the same poll was not yet durable and the session could terminate while
that PR was still open.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
* fix(session): surface actionable signals from blocked stacked children; clarify worker prompt
Status aggregation previously dropped any open PR blocked by an open parent,
hiding actionable child signals (failing CI, draft, requested changes,
unresolved comments) behind the parent's status. A blocked child still cannot
merge, so its readiness signals (mergeable/approved/review-pending/open) stay
suppressed, but its problem signals now contribute to the worst-wins aggregate.
The all-blocked fallback is preserved so a session never goes dark.
The worker multi-PR prompt said independent PRs could branch off the base
branch as usual, which conflicts with branch-prefix attribution. Clarify that
a PR may target the base branch, but its source branch must stay under the
session branch namespace for AO to track it.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com>
Two clocks defaulted to local time.Now while the rest of the codebase writes
UTC, so ao session get showed created and updated in different timezones:
- session manager clock → spawn-stamped CreatedAt/UpdatedAt
- lifecycle manager clock → activity-driven LastActivityAt/UpdatedAt
A real spawn made this visible: createdAt came back UTC but updatedAt/
lastActivityAt were local once the agent reported activity. Default both clocks
to UTC.
Closes#214
* 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>