feat(scm): wire observer messenger + RepoOriginURL + persist dedup (#108)#114
Conversation
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>
…#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>
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>
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>
Greptile SummaryThis PR completes the SCM-observer end-to-end nudge path by landing three wiring fixes: threading the runtime messenger into the Lifecycle Manager, populating
Confidence Score: 5/5Safe to merge — all three wiring fixes are correct, the ordering invariant (WriteSCMObservation before ApplySCMObservation) is clearly maintained in the observer, and the new dedup persistence degrades gracefully under failure. The messenger wiring, RepoOriginURL population, and dedup-persistence changes are all well-scoped and independently tested. The one non-obvious assumption — that the pr row always exists when UpdatePRLastNudgeSignature is called — is guaranteed by the observer's write-before-notify ordering and is clearly documented. No data-loss or incorrect-state paths were found. No files require special attention; the hand-written sqlc functions in gen/pr.sql.go match the existing style and the SQL parameter ordering is correct. Important Files Changed
Sequence DiagramsequenceDiagram
participant Observer as SCM Observer
participant Store as SQLite Store
participant LCM as Lifecycle Manager
participant Messenger as Session Messenger
Observer->>Store: WriteSCMObservation(pr, checks, ...)
Store-->>Observer: ok (pr row now exists)
Observer->>LCM: ApplySCMObservation(sessionID, obs)
LCM->>LCM: scmToPRObservation(obs)
LCM->>LCM: sendOnce(prURL, key, sig, msg)
alt First touch of prURL this process
LCM->>Store: GetPRLastNudgeSignature(prURL)
Store-->>LCM: empty or JSON payload
LCM->>LCM: merge into react.seen / react.attempts
end
alt sig not in seen (new nudge)
LCM->>Messenger: Send(sessionID, msg)
Messenger-->>LCM: ok
LCM->>LCM: update react.seen[key], react.attempts[key]
LCM->>Store: UpdatePRLastNudgeSignature(prURL, JSON)
Store-->>LCM: ok
end
LCM-->>Observer: ok
Observer->>Store: WriteSCMObservation(finalPR, ...) [ack hashes]
Reviews (2): Last reviewed commit: "fix: silence nilerr, address reviewer no..." | Re-trigger Greptile |
- 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>
) * test(scm): end-to-end integration coverage for SCM observer (#109) Adds backend/internal/integration/scm_observer_test.go, the regression guard for the SCM observer wiring landed in PR #114. Drives scmobserve.Observer.Poll against a real sqlite.Store, a real lifecycle.Manager with a recording messenger spy, and a canned observe/scm.Provider, asserting the full observation -> reducer -> store -> messenger pipeline. Three table-driven subtests, each on its own tmpdir fixture: - A CI-failing observation persists the pr row (provider-neutral columns + semantic hashes), persists pr_checks mirroring the observation, delivers exactly one nudge with the failed-log tail, persists last_nudge_signature, and produces no additional nudge on an identical re-poll. - A Merged: true observation MarkTerminated's the session and sends no nudge. - A branch with no open PR writes nothing and sends no nudge. Closes #109 Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com> * test(scm): address review — drop string key indirection, document idempotency path - Key cannedSCMProvider.observations/reviews by PR number directly so the fake no longer carries a string key that resembled (but did not actually need to mirror) the observer's internal prKey. Every case in this test uses scmTestRepo, so number alone is unambiguous. - Add an explicit pointer in the CI-failing subtest noting it exercises the hash-match short-circuit in prepareForPersistence; the ETag-driven 304 short-circuit on the same SHA is covered by observe/scm/observer_test.go (Poll_RepoETag304SkipsDetectPR, Poll_CIETagChangeRefreshesWhenRepoUnchanged). Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com>
Summary
Closes the SCM-observer end-to-end nudge path called out in #108 by landing all three wiring fixes in one PR. Without these three pieces stacked together the observer runs and persists but never reaches the agent.
1) Thread the runtime messenger into the Lifecycle Manager
startLifecycleused to construct the LCM with a nil messenger, so every SCM-driven nudge dropped silently insidesendOnce(backend/internal/lifecycle/reactions.go). BuiltnewSessionMessengerbeforestartLifecycleindaemon.goand addedmessenger ports.AgentMessengerto thestartLifecyclesignature so the wired messenger reacheslifecycle.New. Closes the "Daemon constructs LCM with a real messenger (no nil at lifecycle_wiring.go)" acceptance item.2) Populate
RepoOriginURLat project add + lazy observer backfillproject.Service.Addnow shells out togit -C path remote get-url originand assigns the trimmed URL onto the row. A missing/erroring git invocation logsINFOand falls back to""soao project addnever fails on a remoteless or non-git path. To cover already-registered projects, the SCM observer'sdiscoverSubjectslazily backfillsRepoOriginURLvia the same shell-out and persists it back throughUpsertProject, so subsequent polls skip the fork-exec. Implements Option A from the issue. Closes the "populates RepoOriginURL" and "backfilled lazily" acceptance items.3) Persist reaction-dedup signatures across restart
Added migration
0005_pr_last_nudge_signature.sql(ALTER TABLE pr ADD COLUMN last_nudge_signature TEXT NOT NULL DEFAULT '') plus two scoped sqlc queries (GetPRLastNudgeSignature,UpdatePRLastNudgeSignature).lifecycle.Manager.sendOncenow loads the JSON payload ({"seen":{…},"attempts":{…}}) on first touch of each PR and persists after every successful send, so a bouncing daemon stops re-nudging the agent for still-failing CI / unresolved review threads on the first post-restart poll. Closes the "pr.last_nudge_signature column exists; sendOnce consults + updates it" acceptance item.Test plan
cd backend && go build ./...— cleancd backend && go test -race ./...— clean exceptTestSessionStreamsRealZellijPane(zellij UNIX-socket path > macOS 103-byte limit; pre-existing onmain, unrelated to this PR — verified by stashing changes and re-running)TestWiring_StartLifecycleThreadsMessengerIntoLCM— wires real LCM with acaptureMessenger, drives an SCM observation, asserts the messenger is invokedTestManager_AddPopulatesRepoOriginURL— table-driven: git repo with origin → populated; git repo without origin → emptyTestDiscoverSubjects_BackfillsRepoOriginURL/TestDiscoverSubjects_NonGitPathDoesNotBackfill— lazy backfill happy path + non-git fallbackTestPRObservation_DedupSurvivesManagerRestart— same signature suppressed across a fresh Manager over the same store; genuinely new signature still firesTestPRObservation_DedupPersistsAcrossPRs— payloads keyed per-PR, not collatedgo build + go test -raceis clean.Notes
internal/storage/sqlite/gen/pr.sql.goby hand in the existing sqlc style. The existinggen.PRstruct wasn't touched (no SELECT *; existing queries enumerate columns explicitly), so adding the column is non-breaking for unrelated code paths.Closes #108
🤖 Generated with Claude Code