From 8a1f554d1e2d2e43bc807b540d64711c1dea4eeb Mon Sep 17 00:00:00 2001 From: omartelo Date: Wed, 21 Oct 2026 16:04:09 -0300 Subject: [PATCH] feat(relay): tell the session that opened one in the merged checkout --- CHANGELOG.md | 4 +++ docs/ceilings.md | 3 +- internal/relay/merged.go | 57 +++++++++++++++++++++++++++-------- internal/relay/merged_test.go | 36 ++++++++++++++++++++++ 4 files changed, 86 insertions(+), 14 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index bf14c0c38..3fa74b5fc 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -12,6 +12,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - The Pulls screen shows a changed image in a pull request's Files tab, before and after side by side, as the Review tab does, where it used to say only "Binary file". An edited Markdown file there has the preview button too, where only an added one had it. lich reads the file from your clone when it holds the commit and from GitHub otherwise. - `lich mcp` answers MCP clients on protocol revision 2026-07-28, which name the version on each request instead of opening with `initialize`, and serves their `server/discover`. Clients that open with `initialize` are answered exactly as before. +### Changed + +- A merge on the Pulls screen tells the session that opened the merged branch's session too, wherever that session runs, as it already told the sessions in the merged checkout. A session that hands work out learns its pull request merged without you saying so. + ### Fixed - A long Markdown file, such as lich's own CHANGELOG.md, previews in the Code and Review tabs without freezing the window: the preview draws only the part on screen, where it used to lay out the whole file on every frame. diff --git a/docs/ceilings.md b/docs/ceilings.md index 9b0414992..030080a70 100644 --- a/docs/ceilings.md +++ b/docs/ceilings.md @@ -311,7 +311,8 @@ work when nobody knows it and that the call site never shows. The mechanism and - **Only a merge lich makes is announced, and only to a card running at that moment** (`internal/relay`, `AnnounceMerge`; called from the Pulls screen's merge): a pull request merged on github.com, with `gh` in a terminal or by an agent leaves every session in its checkout believing it is still open, and so does one merged - while the card was parked or never opened. The notice also lands as a delivery like any other, so a cleanup + while the card was parked or never opened. The session that opened one of the checkout's sessions hears it + too, wherever it runs, but only that one: the session that opened the opener does not. The notice also lands as a delivery like any other, so a cleanup clicked in the second before it starts a turn finds no session mid-turn, asks nothing, and removes the checkout with the notice still on its way. - **Only a Claude Code subagent becomes a lich card** (lich-plugin's `hooks/agent-cards.js`, its diff --git a/internal/relay/merged.go b/internal/relay/merged.go index d53572d25..7792aa6d3 100644 --- a/internal/relay/merged.go +++ b/internal/relay/merged.go @@ -6,13 +6,16 @@ import ( "github.com/lichdotdev/lich/internal/prompt" "github.com/lichdotdev/lich/internal/providers" + "github.com/lichdotdev/lich/internal/store" ) // AnnounceMerge tells every agent running in checkout that the pull request on // its branch was merged from lich's own screen. An agent keeps notes on the work // it did there, and without this the last thing they say about it is that it is // waiting on a merge, which a later session reads back as true long after the -// checkout is gone. +// checkout is gone. The session that opened one of those agents hears it too, +// wherever it runs: it handed the work out and keeps the same kind of notes on +// it. // // Only provider sessions hear it: a terminal card in the same checkout is a // shell, where a typed notice would run as a command. A card with no process @@ -31,25 +34,53 @@ func (s *Service) AnnounceMerge(checkout string, number int, title, branch, base message := mergeNotice(lang, number, title, branch, base) summary := fmt.Sprintf(prompt.For(lang).MergeSummary, number, base) note := &Notification{Status: NotifyCompleted, Summary: summary} + for _, sess := range s.mergeAudience(projects, checkout) { + go func(id, label string) { + if err := s.deliver(id, message, note); err != nil { + slog.Warn("relay: merge notice not delivered", "session", label, "err", err) + } + }(sess.ID, sess.Label) + } + return nil +} + +// mergeAudience is the live provider sessions running in checkout, and the live +// provider sessions that opened any session there, each once. An opener hears +// it even when the session it opened is parked: the work it handed out is +// done either way. +func (s *Service) mergeAudience(projects []store.Project, checkout string) []store.Session { + byID := map[string]store.Session{} + var inCheckout []store.Session for _, p := range projects { for _, sess := range p.Sessions { - if !s.runsAgentIn(sess.ID, sess.Kind, sessionCwd(sess.Path, p.Path), checkout) { - continue + byID[sess.ID] = sess + if sessionCwd(sess.Path, p.Path) == checkout { + inCheckout = append(inCheckout, sess) } - go func(id, label string) { - if err := s.deliver(id, message, note); err != nil { - slog.Warn("relay: merge notice not delivered", "session", label, "err", err) - } - }(sess.ID, sess.Label) } } - return nil + told := map[string]bool{} + var audience []store.Session + add := func(sess store.Session) { + if !told[sess.ID] && s.runsAgent(sess.ID, sess.Kind) { + told[sess.ID] = true + audience = append(audience, sess) + } + } + for _, sess := range inCheckout { + add(sess) + } + for _, sess := range inCheckout { + if origin, ok := byID[sess.OriginSessionID]; ok { + add(origin) + } + } + return audience } -// runsAgentIn is whether a session is a live provider session whose checkout is -// the one given. -func (s *Service) runsAgentIn(id, kind, cwd, checkout string) bool { - return cwd == checkout && providers.Known(kind) && s.term.Live(id) +// runsAgent is whether a session is a live provider session. +func (s *Service) runsAgent(id, kind string) bool { + return providers.Known(kind) && s.term.Live(id) } // sessionCwd is the directory a session runs in: its worktree, or the project's diff --git a/internal/relay/merged_test.go b/internal/relay/merged_test.go index 9575934ae..947224164 100644 --- a/internal/relay/merged_test.go +++ b/internal/relay/merged_test.go @@ -73,6 +73,42 @@ func TestAnnounceMergeReachesSessionsInTheProjectsOwnDirectory(t *testing.T) { } } +func TestAnnounceMergeTellsTheSessionThatOpenedOneThere(t *testing.T) { + t.Parallel() + sessions := fakeSessions{projects: []store.Project{ + {ID: "p1", Name: "lich", Path: "/src/lich", Sessions: []store.Session{ + {ID: "lead", Label: "main", Kind: "claude"}, + {ID: "bystander", Label: "other", Kind: "codex", Path: "/wt/other"}, + }}, + {ID: "p2", Name: "site", Path: "/src/site", Sessions: []store.Session{ + {ID: "worker", Label: "feat", Kind: "codex", Path: "/wt/feat", OriginSessionID: "lead"}, + {ID: "parked", Label: "feat-2", Kind: "codex", Path: "/wt/feat", OriginSessionID: "lead"}, + {ID: "nested", Label: "feat-3", Kind: "codex", Path: "/wt/feat", OriginSessionID: "worker"}, + }}, + }} + term := newFakeTerminal("lead", "bystander", "worker", "nested") + svc := newRelay(sessions, term, nil) + + if err := svc.AnnounceMerge("/wt/feat", 42, "Add merge notice", "feat/merge", "main"); err != nil { + t.Fatalf("AnnounceMerge: %v", err) + } + + for _, id := range []string{"lead", "worker", "nested"} { + if !awaitWritten(term, id, "#42") { + t.Fatalf("typed at %s = %q, want the merge notice", id, term.written(id)) + } + } + if got := strings.Count(term.written("lead"), "#42"); got != 1 { + t.Fatalf("lead heard the notice %d times, want once for its two sessions there", got) + } + if got := strings.Count(term.written("worker"), "#42"); got != 1 { + t.Fatalf("worker heard the notice %d times, want once: it runs there and opened nested", got) + } + if got := term.written("bystander"); got != "" { + t.Fatalf("typed at bystander = %q, want nothing", got) + } +} + func TestAnnounceMergeReportsAStoreFailure(t *testing.T) { t.Parallel() svc := newRelay(fakeSessions{err: errors.New("disk gone")}, newFakeTerminal(), nil)