From 67c347518ffe119fa8294f3219a42e2dc211f14d Mon Sep 17 00:00:00 2001 From: J Date: Mon, 8 Jun 2026 14:11:53 +0800 Subject: [PATCH] feat(squad): surface active-worker execution state in leader briefing The squad leader briefing carried no signal about whether any delegated agent was still running on the issue, so a leader triggered by a worker's interim report could silently record `no_action` while the session had actually completed and nothing was continuing (MUL-3114). Add a claim-time "Current Execution State" snapshot to the leader briefing that reports whether any worker (non-leader) task is still active on the issue. The leader's own claim is excluded via is_leader_task, and the section is omitted for quick-create runs with no issue yet. The operating protocol now tells the leader to consult this snapshot before settling on `no_action`. Updates the multica-squads skill docs to match. Co-authored-by: multica-agent --- server/internal/handler/daemon.go | 8 +- server/internal/handler/squad_briefing.go | 83 +++++++++++++- .../internal/handler/squad_briefing_test.go | 108 +++++++++++++++++- .../builtin_skills/multica-squads/SKILL.md | 7 ++ .../references/squad-source-map.md | 17 ++- 5 files changed, 203 insertions(+), 20 deletions(-) diff --git a/server/internal/handler/daemon.go b/server/internal/handler/daemon.go index 0651be1e3..35c152eb9 100644 --- a/server/internal/handler/daemon.go +++ b/server/internal/handler/daemon.go @@ -1185,7 +1185,7 @@ func (h *Handler) ClaimTaskByRuntime(w http.ResponseWriter, r *http.Request) { ID: issue.AssigneeID, WorkspaceID: issue.WorkspaceID, }); err == nil && uuidToString(squad.LeaderID) == resp.Agent.ID { - briefing := buildSquadLeaderBriefing(r.Context(), h.Queries, squad) + briefing := buildSquadLeaderBriefing(r.Context(), h.Queries, squad, issue.ID) if strings.TrimSpace(resp.Agent.Instructions) == "" { resp.Agent.Instructions = briefing } else { @@ -1540,7 +1540,11 @@ func (h *Handler) ClaimTaskByRuntime(w http.ResponseWriter, r *http.Request) { ID: squadUUID, WorkspaceID: wsUUID, }); err == nil && uuidToString(squad.LeaderID) == resp.Agent.ID { - briefing := buildSquadLeaderBriefing(r.Context(), h.Queries, squad) + // Quick-create runs have no issue yet, so there is no + // active-task snapshot to take — pass a zero issue ID + // and buildSquadLeaderBriefing omits the execution-state + // section. + briefing := buildSquadLeaderBriefing(r.Context(), h.Queries, squad, pgtype.UUID{}) if strings.TrimSpace(resp.Agent.Instructions) == "" { resp.Agent.Instructions = briefing } else { diff --git a/server/internal/handler/squad_briefing.go b/server/internal/handler/squad_briefing.go index 291c86b13..e02a8a777 100644 --- a/server/internal/handler/squad_briefing.go +++ b/server/internal/handler/squad_briefing.go @@ -2,8 +2,10 @@ package handler import ( "context" + "fmt" "strings" + "github.com/jackc/pgx/v5/pgtype" "github.com/multica-ai/multica/server/internal/util" db "github.com/multica-ai/multica/server/pkg/db/generated" ) @@ -55,9 +57,16 @@ Your responsibilities, in order: - someone @mentions you again on this issue. 5. **Re-evaluate on each trigger.** When you wake up again, read the new activity and decide whether to delegate the next step, escalate to - the human reporter, or close the loop. If no action is needed - (e.g. a member posted a progress update that requires no response), - record ` + "`" + `no_action` + "`" + ` and exit silently. + the human reporter, or close the loop. Before you settle on + ` + "`" + `no_action` + "`" + `, consult the **Current Execution State** snapshot + below: a delegated agent's task ends the moment it stops producing + output, so an interim "I'll continue later" report does NOT mean a + session is still running. If no worker session is active and the issue + is not actually done, the work has stalled — delegate the next step, + mark the issue ` + "`" + `blocked` + "`" + `, or escalate to a human rather than + silently recording ` + "`" + `no_action` + "`" + `. Record ` + "`" + `no_action` + "`" + ` and exit + silently only when no action genuinely is needed (e.g. a worker is + still running, or a member posted an update that requires no response). Hard rules: - EVERY delegation MUST use the full mention markdown syntax @@ -90,23 +99,31 @@ Hard rules: // buildSquadLeaderBriefing composes the full system briefing appended to a // squad leader's Instructions when it claims a task on a squad-assigned -// issue. The returned string contains three sections: +// issue. The returned string contains up to four sections: // // 1. Squad Operating Protocol (constant, system-level rules). // 2. Squad Roster (data — leader self-row + members with literal // `[@Name](mention:///)` strings ready to paste). -// 3. Squad Instructions (user-defined `squad.instructions`, omitted when +// 3. Current Execution State (a claim-time snapshot of whether any worker +// agent task is still running on this issue; omitted when issueID is +// invalid, e.g. quick-create runs where no issue exists yet). +// 4. Squad Instructions (user-defined `squad.instructions`, omitted when // empty so we don't leave a dangling heading). // // Archived agent members are skipped — there's no point asking the leader // to delegate to a retired agent. Members whose underlying record can't be // loaded (deleted user/agent races, FK weirdness) are also skipped silently. -func buildSquadLeaderBriefing(ctx context.Context, q *db.Queries, squad db.Squad) string { +func buildSquadLeaderBriefing(ctx context.Context, q *db.Queries, squad db.Squad, issueID pgtype.UUID) string { var sb strings.Builder sb.WriteString(squadOperatingProtocol) sb.WriteString("\n\n") sb.WriteString(buildSquadRoster(ctx, q, squad)) + if execState := buildSquadExecutionState(ctx, q, issueID); execState != "" { + sb.WriteString("\n\n") + sb.WriteString(execState) + } + if trimmed := strings.TrimSpace(squad.Instructions); trimmed != "" { sb.WriteString("\n\n## Squad Instructions (") sb.WriteString(squad.Name) @@ -116,6 +133,60 @@ func buildSquadLeaderBriefing(ctx context.Context, q *db.Queries, squad db.Squad return sb.String() } +// buildSquadExecutionState renders the "## Current Execution State" section: a +// claim-time snapshot of whether any worker agent task is still active on this +// issue. It exists because a delegated agent's task ends the moment it stops +// producing output — an interim "I'll continue later" comment does not mean a +// session is still running (MUL-3114). Surfacing the machine truth lets the +// leader avoid silently recording no_action on an issue that has actually +// stalled. +// +// "Worker session" = an active task that is NOT a leader task. The leader's own +// claim is itself active at this point, and any queued leader re-trigger is +// coordination rather than execution — neither counts as a worker running. +// +// Returns "" when issueID is invalid (quick-create runs have no issue yet) or +// the lookup fails — the briefing simply omits the section rather than guessing. +func buildSquadExecutionState(ctx context.Context, q *db.Queries, issueID pgtype.UUID) string { + if !issueID.Valid { + return "" + } + tasks, err := q.ListActiveTasksByIssue(ctx, issueID) + if err != nil { + return "" + } + + workers := make([]db.AgentTaskQueue, 0, len(tasks)) + for _, t := range tasks { + if t.IsLeaderTask { + continue + } + workers = append(workers, t) + } + + var sb strings.Builder + sb.WriteString("## Current Execution State\n\n") + sb.WriteString("This is a live snapshot taken when you were triggered — read it before you decide.\n\n") + + if len(workers) == 0 { + sb.WriteString("**No worker session is currently running on this issue.** ") + sb.WriteString("No delegated agent is executing right now. A worker's task ends the moment it stops producing output, so a recent comment that reads like work is still in progress (\"I'll continue later\", \"still verifying\", a partial/interim report) does NOT mean anything is running — nothing resumes automatically. ") + sb.WriteString("Before you record `no_action`, confirm the issue is genuinely done or genuinely waiting on a human. If work remains, delegate the next step, or mark the issue `blocked` and escalate. An interim report with no active session is a stalled issue, not a progressing one.\n") + return sb.String() + } + + fmt.Fprintf(&sb, "**%d worker session(s) currently running on this issue.** A delegated agent is executing right now:\n", len(workers)) + for _, t := range workers { + name := "a squad member" + if ag, err := q.GetAgent(ctx, t.AgentID); err == nil { + name = ag.Name + } + fmt.Fprintf(&sb, "- %s — status `%s`\n", name, t.Status) + } + sb.WriteString("\nYou usually do NOT need to delegate the same work again while a session is in flight — recording `no_action` and waiting for it to finish is appropriate. Re-delegate only if the running work is clearly wrong or stuck, or hand off a genuinely independent next step.\n") + return sb.String() +} + // buildSquadRoster renders the "## Squad Roster" section: a leader self-row // plus one row per non-archived member, with literal mention markdown. func buildSquadRoster(ctx context.Context, q *db.Queries, squad db.Squad) string { diff --git a/server/internal/handler/squad_briefing_test.go b/server/internal/handler/squad_briefing_test.go index 6fdccc980..e64370aad 100644 --- a/server/internal/handler/squad_briefing_test.go +++ b/server/internal/handler/squad_briefing_test.go @@ -124,7 +124,7 @@ func TestBuildSquadLeaderBriefing_FullSquad(t *testing.T) { _ = memberRowID addHumanMember(t, squad.ID, userID, "reviewer") - out := buildSquadLeaderBriefing(ctx, testHandler.Queries, squad) + out := buildSquadLeaderBriefing(ctx, testHandler.Queries, squad, pgtype.UUID{}) for _, want := range []string{ "## Squad Operating Protocol", @@ -155,7 +155,7 @@ func TestBuildSquadLeaderBriefing_OnlyLeader(t *testing.T) { leaderID, _ := seededLeaderAgent(t) squad := seedSquadForBriefing(t, leaderID, "Solo Squad", "") - out := buildSquadLeaderBriefing(ctx, testHandler.Queries, squad) + out := buildSquadLeaderBriefing(ctx, testHandler.Queries, squad, pgtype.UUID{}) if !strings.Contains(out, "Members: (none — you are the only member of this squad)") { t.Errorf("expected lone-leader fallback line, got:\n%s", out) } @@ -179,7 +179,7 @@ func TestBuildSquadLeaderBriefing_SkipsArchivedAgent(t *testing.T) { t.Fatalf("archive agent: %v", err) } - out := buildSquadLeaderBriefing(ctx, testHandler.Queries, squad) + out := buildSquadLeaderBriefing(ctx, testHandler.Queries, squad, pgtype.UUID{}) if strings.Contains(out, "Retired Bot") { t.Errorf("archived agent should not appear in roster:\n%s", out) } @@ -204,7 +204,7 @@ func TestBuildSquadLeaderBriefing_MentionsRoundTrip(t *testing.T) { _ = memberRowID addHumanMember(t, squad.ID, userID, "") - out := buildSquadLeaderBriefing(ctx, testHandler.Queries, squad) + out := buildSquadLeaderBriefing(ctx, testHandler.Queries, squad, pgtype.UUID{}) mentions := util.ParseMentions(out) wantIDs := map[string]string{ @@ -358,5 +358,101 @@ t.Errorf("non-leader claim should NOT contain %q\n--- instructions ---\n%s", mus } } -// Avoid "imported and not used: pgtype" if helpers above are the only users. -var _ pgtype.UUID +// seedSquadIssue creates a squad-assigned issue and returns its UUID. +func seedSquadIssue(t *testing.T, squadID pgtype.UUID, issueNumber int) pgtype.UUID { + t.Helper() + ctx := context.Background() + var issueID string + if err := testPool.QueryRow(ctx, ` + INSERT INTO issue ( + workspace_id, title, status, priority, creator_id, creator_type, + assignee_type, assignee_id, number, position + ) VALUES ($1, 'Exec-state briefing test', 'in_progress', 'medium', $2, 'member', + 'squad', $3, $4, 0) + RETURNING id + `, testWorkspaceID, testUserID, util.UUIDToString(squadID), issueNumber).Scan(&issueID); err != nil { + t.Fatalf("create squad-assigned issue: %v", err) + } + t.Cleanup(func() { testPool.Exec(ctx, `DELETE FROM issue WHERE id = $1`, issueID) }) + return util.MustParseUUID(issueID) +} + +// insertActiveTask inserts a running task on the issue for the given agent and +// is_leader_task flag, registering cleanup. Mirrors the "agent live" rows the +// execution-state snapshot reads. +func insertActiveTask(t *testing.T, agentID string, issueID pgtype.UUID, isLeader bool) { + t.Helper() + ctx := context.Background() + var runtimeID string + if err := testPool.QueryRow(ctx, `SELECT runtime_id FROM agent WHERE id = $1`, agentID).Scan(&runtimeID); err != nil { + t.Fatalf("get agent runtime: %v", err) + } + var taskID string + if err := testPool.QueryRow(ctx, ` + INSERT INTO agent_task_queue (agent_id, runtime_id, issue_id, status, priority, is_leader_task) + VALUES ($1, $2, $3, 'running', 0, $4) + RETURNING id + `, agentID, runtimeID, util.UUIDToString(issueID), isLeader).Scan(&taskID); err != nil { + t.Fatalf("insert active task: %v", err) + } + t.Cleanup(func() { testPool.Exec(ctx, `DELETE FROM agent_task_queue WHERE id = $1`, taskID) }) +} + +// TestBuildSquadLeaderBriefing_ExecutionState verifies the "Current Execution +// State" snapshot (MUL-3114): the leader must be told whether a *worker* +// session is running. The leader's own claim must NOT be counted as a worker. +func TestBuildSquadLeaderBriefing_ExecutionState(t *testing.T) { + if testHandler == nil { + t.Skip("database not available") + } + ctx := context.Background() + leaderID, _ := seededLeaderAgent(t) + squad := seedSquadForBriefing(t, leaderID, "Exec State Squad", "") + issueID := seedSquadIssue(t, squad.ID, 95010) + + // No active task at all → no worker session. + out := buildSquadLeaderBriefing(ctx, testHandler.Queries, squad, issueID) + if !strings.Contains(out, "## Current Execution State") { + t.Fatalf("expected Current Execution State section, got:\n%s", out) + } + if !strings.Contains(out, "No worker session is currently running on this issue.") { + t.Errorf("expected no-worker-session line, got:\n%s", out) + } + + // The leader's own active task must NOT count as a worker session. + insertActiveTask(t, leaderID, issueID, true) + out = buildSquadLeaderBriefing(ctx, testHandler.Queries, squad, issueID) + if !strings.Contains(out, "No worker session is currently running on this issue.") { + t.Errorf("leader's own task must not count as a worker session, got:\n%s", out) + } + + // A non-leader worker task IS counted, with its name and status. + worker := createHandlerTestAgent(t, "Exec Worker", []byte("[]")) + insertActiveTask(t, worker, issueID, false) + out = buildSquadLeaderBriefing(ctx, testHandler.Queries, squad, issueID) + for _, want := range []string{ + "worker session(s) currently running on this issue.", + "Exec Worker", + "status `running`", + } { + if !strings.Contains(out, want) { + t.Errorf("expected running-worker briefing to contain %q, got:\n%s", want, out) + } + } +} + +// TestBuildSquadLeaderBriefing_NoExecutionStateWithoutIssue — quick-create +// runs have no issue yet, so the execution-state section is omitted entirely. +func TestBuildSquadLeaderBriefing_NoExecutionStateWithoutIssue(t *testing.T) { + if testHandler == nil { + t.Skip("database not available") + } + ctx := context.Background() + leaderID, _ := seededLeaderAgent(t) + squad := seedSquadForBriefing(t, leaderID, "No Issue Squad", "") + + out := buildSquadLeaderBriefing(ctx, testHandler.Queries, squad, pgtype.UUID{}) + if strings.Contains(out, "## Current Execution State") { + t.Errorf("quick-create (no issue) briefing must omit execution state, got:\n%s", out) + } +} diff --git a/server/internal/service/builtin_skills/multica-squads/SKILL.md b/server/internal/service/builtin_skills/multica-squads/SKILL.md index 39f1fb75c..e392061e0 100644 --- a/server/internal/service/builtin_skills/multica-squads/SKILL.md +++ b/server/internal/service/builtin_skills/multica-squads/SKILL.md @@ -135,11 +135,18 @@ agent instructions. The briefing includes: - Squad Operating Protocol; - Squad Roster; +- Current Execution State, only when the task is issue-bound (omitted for + quick-create runs that have no issue yet); - Squad Instructions, only when `instructions` is non-empty. Roster entries include member name, member type, mention markdown, and non-empty role. Archived agent members are skipped from the briefing roster. +Current Execution State is a claim-time snapshot telling the leader whether any +worker (non-leader) agent task is still active on the issue, so it does not +silently record `no_action` when a session has actually stopped. The leader's +own claim is excluded from the count. + ## Issue assignment behavior Issues can be assigned to squads with: diff --git a/server/internal/service/builtin_skills/multica-squads/references/squad-source-map.md b/server/internal/service/builtin_skills/multica-squads/references/squad-source-map.md index a5a43a78e..7b58c9d53 100644 --- a/server/internal/service/builtin_skills/multica-squads/references/squad-source-map.md +++ b/server/internal/service/builtin_skills/multica-squads/references/squad-source-map.md @@ -81,18 +81,23 @@ Contracts: Source: ```text -server/internal/handler/squad_briefing.go # buildSquadLeaderBriefing ~104, buildSquadRoster ~121, renderMemberRow ~169 -server/internal/handler/daemon.go # briefing injection ~1187, ~1530 +server/internal/handler/squad_briefing.go # buildSquadLeaderBriefing ~116, buildSquadExecutionState ~150, buildSquadRoster ~192, renderMemberRow ~240 +server/internal/handler/daemon.go # briefing injection ~1187 (issue.ID), ~1530 (quick-create, zero issue ID) +server/pkg/db/queries/agent.sql # ListActiveTasksByIssue ~580 (queued/dispatched/running/waiting_local_directory) ``` Contracts: - squad leader tasks append briefing to leader agent instructions (daemon.go:1187, 1530); -- briefing includes operating protocol, roster, and optional instructions - (squad_briefing.go:104-117); -- `instructions` section appears only when non-empty (squad_briefing.go:110-112); -- archived agent members are skipped from roster (squad_briefing.go:178-179); +- briefing includes operating protocol, roster, optional execution state, and + optional instructions (squad_briefing.go buildSquadLeaderBriefing); +- Current Execution State is a claim-time snapshot of active worker tasks on the + issue; a "worker session" is an active task with `is_leader_task = false`, so + the leader's own claim is excluded; section is omitted when the issue ID is + invalid (quick-create) or the lookup fails (buildSquadExecutionState); +- `instructions` section appears only when non-empty (squad_briefing.go); +- archived agent members are skipped from roster (squad_briefing.go); - no traced behavior injects `instructions` into every squad member. ## Issue Assignment