From 525f11923dcca8044ffeb8ab3b6b0100223a6abd Mon Sep 17 00:00:00 2001 From: Ian Gulliver Date: Thu, 15 Oct 2026 17:03:08 -0700 Subject: [PATCH] Credit the user who forked a task as a co-author on its merge --- TODO.md | 2 -- docs/operations.md | 2 +- internal/agents/manager_test.go | 9 +++++++++ internal/store/store.go | 2 +- 4 files changed, 11 insertions(+), 4 deletions(-) diff --git a/TODO.md b/TODO.md index f2cc89b..b1445b1 100644 --- a/TODO.md +++ b/TODO.md @@ -11,8 +11,6 @@ # M4 — Merging and history -- Co-author trailers for humans who trigger forks - Squash commits already carry a `Co-authored-by` trailer for every other human with a message bubble in the task's transcript (see "Closing a task" in `docs/operations.md`). Once forks exist, a human who triggers a fork of the task should count too. - Edit the drafted merge message before confirming - Merge-resolution stage A stage triggered when merging the default branch into a task branch conflicts; its result goes to review. diff --git a/docs/operations.md b/docs/operations.md index 500b95d..b7d4e28 100644 --- a/docs/operations.md +++ b/docs/operations.md @@ -35,7 +35,7 @@ A task ends `merged` or `discarded`; either way its sessions are stopped, its sn A task's `merge_title` is the title of the squash commit Merge makes. It is the task title until the agent calls the `set_merge_title` MCP tool (`internal/agents/mergein.go`), which it may do at any point: one line of at most 72 characters (`maxMergeTitle`), stored in `tasks.merge_title` (empty there means "use the title"), recorded as a `task.merge_title` event that the transcript shows as a note. Malformed titles are rejected with a corrective message, and so is a call once the task is closed. The Merge confirm dialog quotes the title the commit will get and lists its co-authors. -A task's participants are the users who put a message bubble with their name in its transcript, derived from its events (`Store.Participants`): the actors of its `user.message` events, joined to `users` so `byre` and `agent` drop out. That covers messages sent to the agent and inbox answers, whose delivery message carries the name of the first responder in it (`Deliver` in `internal/agents/inbox.go`); dismissing an item puts nothing in the transcript and doesn't count. They are ordered by their first such message. `GET /api/tasks/{id}/co-authors` returns the participants other than the caller, with their git name and email from `PUT /api/me` (empty for a user who hasn't set one). The Merge confirm fetches it and lists `Name ` for each co-author, and separately those with no git identity, who get no trailer. +A task's participants are the users who put a message bubble with their name in its transcript, derived from its events (`Store.Participants`): the actors of its `user.message` events, joined to `users` so `byre` and `agent` drop out. That covers messages sent to the agent and inbox answers, whose delivery message carries the name of the first responder in it (`Deliver` in `internal/agents/inbox.go`); dismissing an item puts nothing in the transcript and doesn't count. A fork's participants also include the user who forked it, the actor of its `task.created` event (whose payload carries `forked_from`), whose "forked this task" note opens its transcript. They are ordered by their first such event. `GET /api/tasks/{id}/co-authors` returns the participants other than the caller, with their git name and email from `PUT /api/me` (empty for a user who hasn't set one). The Merge confirm fetches it and lists `Name ` for each co-author, and separately those with no git identity, who get no trailer. `POST /api/tasks/{id}/merge` starts the same turn and records in `tasks.merging_by` that the task is to be merged by the requesting user, who must have a git identity (400 otherwise). Every turn end of the task's session reads and clears it. It survives a byre restart during the turn: startup resumes the session with the restart notice, and the merge happens when that resumed turn ends. When that turn ends normally (not interrupted, not an error), byre squashes the worktree, now containing the default branch, onto it with `git merge-tree --merge-base`, makes one commit titled with the task's `merge_title` with `git commit-tree`, authored and committed by the merging user with no agent attribution, its body one `Co-authored-by: Name ` trailer per co-author with a git identity (`squashMessage` in `internal/agents/mergein.go`; the co-authors are computed when the squash runs, relative to the user who clicked Merge), and pushes that commit to the default branch on the `origin` remote of the project's checkout (`git push --porcelain origin :refs/heads/`, run in the checkout). Only once the push is accepted does it run `git merge --ff-only` in the project's checkout (which must have the default branch checked out) and close the task; the `task.merged` event carries the commit and `pushed` (`origin/`), and the transcript shows both. When the merge changes nothing, no commit is made and nothing is pushed. diff --git a/internal/agents/manager_test.go b/internal/agents/manager_test.go index cc1be09..25c932f 100644 --- a/internal/agents/manager_test.go +++ b/internal/agents/manager_test.go @@ -1148,6 +1148,15 @@ func TestForkSnapshot(t *testing.T) { t.Fatalf("fork session %+v", sess) } + coAuthors, err := m.CoAuthors(e.ctx, fork.Id, "carol@example.com") + if err != nil || len(coAuthors) != 1 || coAuthors[0].Login != login { + t.Fatalf("fork co-authors %+v %v, want the forker %s", coAuthors, err, login) + } + coAuthors, err = m.CoAuthors(e.ctx, e.task.Id, "carol@example.com") + if err != nil || len(coAuthors) != 1 || coAuthors[0].Login != login { + t.Fatalf("source co-authors %+v %v", coAuthors, err) + } + _, err = m.Send(e.ctx, sess.Id, login, "Without using any tools, list the contents of every file you have created in this conversation, one per line, and nothing else.", "") if err != nil { t.Fatalf("send to fork: %v", err) diff --git a/internal/store/store.go b/internal/store/store.go index 93827e9..80f61b2 100644 --- a/internal/store/store.go +++ b/internal/store/store.go @@ -795,7 +795,7 @@ func (s *Store) Participants(ctx context.Context, taskID string) ([]api.Particip rows, err := s.db.QueryContext(ctx, ` SELECT u.login, u.display_name, u.git_name, u.git_email FROM events e JOIN users u ON u.login = e.actor - WHERE e.task_id = ? AND e.kind = 'user.message' + WHERE e.task_id = ? AND (e.kind = 'user.message' OR (e.kind = 'task.created' AND json_extract(e.payload, '$.forked_from') IS NOT NULL)) GROUP BY u.login ORDER BY min(e.seq) `, taskID) if err != nil {