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 <github@multica.ai>
This commit is contained in:
J
2026-06-08 14:11:53 +08:00
parent 2272ecee4e
commit 67c347518f
5 changed files with 203 additions and 20 deletions

View File

@@ -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 {

View File

@@ -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://<type>/<UUID>)` 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 {

View File

@@ -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)
}
}

View File

@@ -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:

View File

@@ -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