Compare commits

...

4 Commits

Author SHA1 Message Date
J
cd46429483 fix(squad): trim stall guidance prompts
Co-authored-by: multica-agent <github@multica.ai>
2026-06-08 14:59:33 +08:00
J
9cd17449cb fix(squad): clarify active worker briefing copy
Co-authored-by: multica-agent <github@multica.ai>
2026-06-08 14:35:17 +08:00
J
67c347518f 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>
2026-06-08 14:11:53 +08:00
J
2272ecee4e feat(runtime): mark issue blocked when an agent stops mid-task
When an agent does an interim/partial report but cannot continue (blocked,
waiting on input, or stopping with work unfinished), its task ends and
nothing resumes automatically. A natural-language "I'll continue later"
comment left the issue looking like it was still progressing, so squad
leaders and users could not tell the session had stopped (MUL-3114).

Add a narrow blocked-on-stall exception to both the comment-triggered and
assignment-triggered workflow steps: set `issue status blocked` and pin
`blocked_reason` / `waiting_on` so the stall is machine-readable. The
comment-triggered "do not change status unless asked" guardrail is kept.

Co-authored-by: multica-agent <github@multica.ai>
2026-06-08 14:11:42 +08:00
7 changed files with 332 additions and 117 deletions

View File

@@ -611,7 +611,7 @@ func buildMetaSkillContent(provider string, ctx TaskContextForEnv) string {
b.WriteString("7. **If you reply, post it as a comment — this step is mandatory when you reply.** Text in your terminal or run logs is NOT delivered to the user. ")
b.WriteString(BuildCommentReplyInstructions(provider, ctx.IssueID, ctx.TriggerCommentID))
b.WriteString("8. Before exiting: only if this run produced a fact that clears the high bar (important AND likely to be re-read by future runs on this same issue, e.g. a new PR URL or deploy URL), or you noticed a metadata key from entry that is now stale, pin or clear it via `multica issue metadata set`/`delete`. Most runs write nothing here — that is the expected outcome, not a gap. When in doubt, do not write. See the `## Issue Metadata` section above for the full bar.\n")
b.WriteString("9. Do NOT change the issue status unless the comment explicitly asks for it\n\n")
fmt.Fprintf(&b, "9. Do NOT change the issue status unless the comment explicitly asks for it — except when you did real work and must stop before the task is finished. In that case, run `multica issue status %s blocked`, pin `blocked_reason` and `waiting_on` when relevant, and explain the blocker in your reply. Skip this only if your Agent Identity forbids status changes.\n\n", ctx.IssueID)
} else {
// Assignment-triggered: defer to agent Skills for workflow specifics.
b.WriteString("You are responsible for managing the issue status throughout your work, unless your Agent Identity forbids issue status changes.\n\n")
@@ -627,7 +627,7 @@ func buildMetaSkillContent(provider string, ctx TaskContextForEnv) string {
}
b.WriteString("7. Before exiting: only if this run produced a fact that clears the high bar (important AND likely to be re-read by future runs on this same issue, e.g. a new PR URL or deploy URL), or you noticed a metadata key from entry that is now stale, pin or clear it via `multica issue metadata set`/`delete`. Most runs write nothing here — that is the expected outcome, not a gap. When in doubt, do not write. See the `## Issue Metadata` section above for the full bar.\n")
fmt.Fprintf(&b, "8. When done, run `multica issue status %s in_review` unless your Agent Identity forbids issue status changes; if it does, skip this step.\n", ctx.IssueID)
fmt.Fprintf(&b, "9. If blocked, run `multica issue status %s blocked` unless your Agent Identity forbids issue status changes. Post a comment explaining the blocker unless your Agent Identity forbids issue comments.\n\n", ctx.IssueID)
fmt.Fprintf(&b, "9. If blocked, or if you must stop with unfinished work and no automatic continuation, run `multica issue status %s blocked` unless your Agent Identity forbids status changes. Pin `blocked_reason` and `waiting_on` when relevant, and post a comment explaining the blocker unless issue comments are forbidden.\n\n", ctx.IssueID)
}
// Sub-issue creation semantics — the only piece of the old Parent /

View File

@@ -156,6 +156,45 @@ func TestCommentTriggeredProtocolDoesNotForceInReview(t *testing.T) {
}
}
// MUL-3114: a worker that stops mid-task (interim report, blocked, waiting on
// input) must mark the issue `blocked` rather than leaving it looking like
// work is still progressing. Both the comment-triggered and assignment-
// triggered workflows must carry this rule.
func TestBriefCarriesBlockedOnStallGuidance(t *testing.T) {
t.Parallel()
const issueID = "55555555-6666-7777-8888-999999999999"
// Comment-triggered: the "do not change status" guardrail keeps its
// narrow blocked-on-stall exception.
comment := buildMetaSkillContent("claude", TaskContextForEnv{
IssueID: issueID,
TriggerCommentID: "66666666-7777-8888-9999-aaaaaaaaaaaa",
})
for _, want := range []string{
"Do NOT change the issue status unless the comment explicitly asks for it",
"must stop before the task is finished",
"multica issue status " + issueID + " blocked",
"blocked_reason",
} {
if !strings.Contains(comment, want) {
t.Errorf("comment-triggered brief missing %q\n--- brief ---\n%s", want, comment)
}
}
// Assignment-triggered: the blocked step also covers stopping with the
// task unfinished and nothing continuing automatically.
assignment := buildMetaSkillContent("claude", TaskContextForEnv{IssueID: issueID})
for _, want := range []string{
"run `multica issue status " + issueID + " blocked`",
"must stop with unfinished work",
"blocked_reason",
} {
if !strings.Contains(assignment, want) {
t.Errorf("assignment-triggered brief missing %q\n--- brief ---\n%s", want, assignment)
}
}
}
// The CLAUDE.md workflow surface must carry the same issue-wide since-delta
// new-comment hint as the per-turn prompt. PR #2816 requires the two surfaces
// stay in sync.
@@ -275,7 +314,8 @@ func TestAssignmentTriggeredProtocolHonorsAgentIdentity(t *testing.T) {
"Complete the task within your Agent Identity boundaries.",
"Do not investigate, implement, create issues, update issues, or delegate if your Agent Identity forbids that action",
"When done, run `multica issue status " + issueID + " in_review` unless your Agent Identity forbids issue status changes; if it does, skip this step.",
"If blocked, run `multica issue status " + issueID + " blocked` unless your Agent Identity forbids issue status changes.",
"If blocked, or if you must stop with unfinished work and no automatic continuation",
"run `multica issue status " + issueID + " blocked` unless your Agent Identity forbids status changes.",
} {
if !strings.Contains(out, want) {
t.Errorf("assignment-triggered brief missing identity-bound workflow text %q\n---\n%s", want, out)

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"
)
@@ -53,11 +55,11 @@ Your responsibilities, in order:
- a delegated member posts an update or asks you a question;
- a delegated member finishes and the issue moves forward;
- 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.
5. **Re-evaluate on each trigger.** Read the new activity and the
**Current Execution State** snapshot. If no worker task is active and
the issue is not done, delegate the next step, mark ` + "`" + `blocked` + "`" + `, or
escalate; do not silently record ` + "`" + `no_action` + "`" + `. Use ` + "`" + `no_action` + "`" + `
only when no follow-up is needed.
Hard rules:
- EVERY delegation MUST use the full mention markdown syntax
@@ -90,23 +92,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 +126,59 @@ 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 task is currently active on this issue.** ")
sb.WriteString("Nothing will continue automatically unless you delegate a new step. Before `no_action`, confirm the issue is done or waiting on a human; otherwise delegate, mark `blocked`, or escalate.\n")
return sb.String()
}
fmt.Fprintf(&sb, "**%d worker task(s) currently active on this issue:**\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("\nUsually wait for active worker tasks to finish. Re-delegate only if the active work is wrong/stuck or you have an 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{
@@ -226,34 +226,34 @@ func TestBuildSquadLeaderBriefing_MentionsRoundTrip(t *testing.T) {
// claimAndDecodeAgent runs ClaimTaskByRuntime for the given runtime and
// returns the agent block of the response. Fails the test on non-200.
func claimAndDecodeAgent(t *testing.T, runtimeID string) *TaskAgentData {
t.Helper()
w := httptest.NewRecorder()
req := newDaemonTokenRequest("POST", "/api/daemon/runtimes/"+runtimeID+"/claim", nil, testWorkspaceID, "test-claim-squad-briefing")
req = withURLParam(req, "runtimeId", runtimeID)
testHandler.ClaimTaskByRuntime(w, req)
if w.Code != http.StatusOK {
t.Fatalf("ClaimTaskByRuntime: %d %s", w.Code, w.Body.String())
}
var resp struct {
Task *struct {
Agent *TaskAgentData `json:"agent"`
} `json:"task"`
}
if err := json.NewDecoder(w.Body).Decode(&resp); err != nil {
t.Fatalf("decode: %v", err)
}
if resp.Task == nil || resp.Task.Agent == nil {
t.Fatalf("expected task.agent in response, got: %s", w.Body.String())
}
return resp.Task.Agent
t.Helper()
w := httptest.NewRecorder()
req := newDaemonTokenRequest("POST", "/api/daemon/runtimes/"+runtimeID+"/claim", nil, testWorkspaceID, "test-claim-squad-briefing")
req = withURLParam(req, "runtimeId", runtimeID)
testHandler.ClaimTaskByRuntime(w, req)
if w.Code != http.StatusOK {
t.Fatalf("ClaimTaskByRuntime: %d %s", w.Code, w.Body.String())
}
var resp struct {
Task *struct {
Agent *TaskAgentData `json:"agent"`
} `json:"task"`
}
if err := json.NewDecoder(w.Body).Decode(&resp); err != nil {
t.Fatalf("decode: %v", err)
}
if resp.Task == nil || resp.Task.Agent == nil {
t.Fatalf("expected task.agent in response, got: %s", w.Body.String())
}
return resp.Task.Agent
}
// queueSquadIssueTaskFor creates an issue assigned to the squad and a queued
// task for the given (agentID, runtimeID). Returns the issue + task IDs.
func queueSquadIssueTaskFor(t *testing.T, squadID, agentID, runtimeID string, issueNumber int) (issueID, taskID string) {
t.Helper()
ctx := context.Background()
if err := testPool.QueryRow(ctx, `
t.Helper()
ctx := context.Background()
if err := testPool.QueryRow(ctx, `
INSERT INTO issue (
workspace_id, title, status, priority, creator_id, creator_type,
assignee_type, assignee_id, number, position
@@ -261,102 +261,198 @@ assignee_type, assignee_id, number, position
'squad', $3, $4, 0)
RETURNING id
`, testWorkspaceID, testUserID, 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) })
t.Fatalf("create squad-assigned issue: %v", err)
}
t.Cleanup(func() { testPool.Exec(ctx, `DELETE FROM issue WHERE id = $1`, issueID) })
if err := testPool.QueryRow(ctx, `
if err := testPool.QueryRow(ctx, `
INSERT INTO agent_task_queue (agent_id, runtime_id, issue_id, status, priority)
VALUES ($1, $2, $3, 'queued', 0)
RETURNING id
`, agentID, runtimeID, issueID).Scan(&taskID); err != nil {
t.Fatalf("queue task: %v", err)
}
t.Cleanup(func() { testPool.Exec(ctx, `DELETE FROM agent_task_queue WHERE id = $1`, taskID) })
return
t.Fatalf("queue task: %v", err)
}
t.Cleanup(func() { testPool.Exec(ctx, `DELETE FROM agent_task_queue WHERE id = $1`, taskID) })
return
}
// TestClaimTask_LeaderGetsBriefing — when the squad leader claims a task on
// a squad-assigned issue, the response's agent.instructions must include
// the Operating Protocol + Roster + user instructions.
func TestClaimTask_LeaderGetsBriefing(t *testing.T) {
if testHandler == nil {
t.Skip("database not available")
}
ctx := context.Background()
if testHandler == nil {
t.Skip("database not available")
}
ctx := context.Background()
var leaderID, runtimeID string
if err := testPool.QueryRow(ctx,
`SELECT id, runtime_id FROM agent WHERE workspace_id = $1 ORDER BY created_at ASC LIMIT 1`,
testWorkspaceID,
).Scan(&leaderID, &runtimeID); err != nil {
t.Fatalf("get leader agent: %v", err)
}
var leaderID, runtimeID string
if err := testPool.QueryRow(ctx,
`SELECT id, runtime_id FROM agent WHERE workspace_id = $1 ORDER BY created_at ASC LIMIT 1`,
testWorkspaceID,
).Scan(&leaderID, &runtimeID); err != nil {
t.Fatalf("get leader agent: %v", err)
}
squad := seedSquadForBriefing(t, leaderID, "Briefing Claim Squad", "Be terse.")
squad := seedSquadForBriefing(t, leaderID, "Briefing Claim Squad", "Be terse.")
helper := createHandlerTestAgent(t, "Briefing Helper", []byte("[]"))
addAgentMember(t, squad.ID, helper, "implementer")
helper := createHandlerTestAgent(t, "Briefing Helper", []byte("[]"))
addAgentMember(t, squad.ID, helper, "implementer")
queueSquadIssueTaskFor(t, util.UUIDToString(squad.ID), leaderID, runtimeID, 95001)
queueSquadIssueTaskFor(t, util.UUIDToString(squad.ID), leaderID, runtimeID, 95001)
agent := claimAndDecodeAgent(t, runtimeID)
for _, want := range []string{
"## Squad Operating Protocol",
"## Squad Roster",
"Leader (you):",
"## Squad Instructions (Briefing Claim Squad)",
"Be terse.",
"`[@Briefing Helper](mention://agent/" + helper + ")`",
} {
if !strings.Contains(agent.Instructions, want) {
t.Errorf("expected agent.instructions to contain %q\n--- instructions ---\n%s", want, agent.Instructions)
}
}
agent := claimAndDecodeAgent(t, runtimeID)
for _, want := range []string{
"## Squad Operating Protocol",
"## Squad Roster",
"Leader (you):",
"## Squad Instructions (Briefing Claim Squad)",
"Be terse.",
"`[@Briefing Helper](mention://agent/" + helper + ")`",
} {
if !strings.Contains(agent.Instructions, want) {
t.Errorf("expected agent.instructions to contain %q\n--- instructions ---\n%s", want, agent.Instructions)
}
}
}
// TestClaimTask_NonLeaderGetsNoBriefing — when a non-leader squad member
// claims a task on a squad-assigned issue, NO briefing is injected.
func TestClaimTask_NonLeaderGetsNoBriefing(t *testing.T) {
if testHandler == nil {
t.Skip("database not available")
}
ctx := context.Background()
if testHandler == nil {
t.Skip("database not available")
}
ctx := context.Background()
var leaderID string
if err := testPool.QueryRow(ctx,
`SELECT id FROM agent WHERE workspace_id = $1 ORDER BY created_at ASC LIMIT 1`,
testWorkspaceID,
).Scan(&leaderID); err != nil {
t.Fatalf("get leader agent: %v", err)
var leaderID string
if err := testPool.QueryRow(ctx,
`SELECT id FROM agent WHERE workspace_id = $1 ORDER BY created_at ASC LIMIT 1`,
testWorkspaceID,
).Scan(&leaderID); err != nil {
t.Fatalf("get leader agent: %v", err)
}
squad := seedSquadForBriefing(t, leaderID, "Non-Leader Squad", "Squad guidance.")
// Create a second agent (NOT the leader) with its own runtime so the
// claim path picks its task without ambiguity.
helperID := createHandlerTestAgent(t, "Non Leader Helper", []byte("[]"))
addAgentMember(t, squad.ID, helperID, "")
var helperRuntime string
if err := testPool.QueryRow(ctx,
`SELECT runtime_id FROM agent WHERE id = $1`, helperID,
).Scan(&helperRuntime); err != nil {
t.Fatalf("get helper runtime: %v", err)
}
queueSquadIssueTaskFor(t, util.UUIDToString(squad.ID), helperID, helperRuntime, 95002)
agent := claimAndDecodeAgent(t, helperRuntime)
for _, mustNot := range []string{
"Squad Operating Protocol",
"Squad Roster",
"Squad Instructions (Non-Leader Squad)",
} {
if strings.Contains(agent.Instructions, mustNot) {
t.Errorf("non-leader claim should NOT contain %q\n--- instructions ---\n%s", mustNot, agent.Instructions)
}
}
}
squad := seedSquadForBriefing(t, leaderID, "Non-Leader Squad", "Squad guidance.")
// Create a second agent (NOT the leader) with its own runtime so the
// claim path picks its task without ambiguity.
helperID := createHandlerTestAgent(t, "Non Leader Helper", []byte("[]"))
addAgentMember(t, squad.ID, helperID, "")
var helperRuntime string
if err := testPool.QueryRow(ctx,
`SELECT runtime_id FROM agent WHERE id = $1`, helperID,
).Scan(&helperRuntime); err != nil {
t.Fatalf("get helper runtime: %v", err)
// 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)
}
queueSquadIssueTaskFor(t, util.UUIDToString(squad.ID), helperID, helperRuntime, 95002)
agent := claimAndDecodeAgent(t, helperRuntime)
for _, mustNot := range []string{
"Squad Operating Protocol",
"Squad Roster",
"Squad Instructions (Non-Leader Squad)",
} {
if strings.Contains(agent.Instructions, mustNot) {
t.Errorf("non-leader claim should NOT contain %q\n--- instructions ---\n%s", mustNot, agent.Instructions)
}
}
// 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) })
}
// Avoid "imported and not used: pgtype" if helpers above are the only users.
var _ pgtype.UUID
// 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 task is currently active 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 task is currently active 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 task(s) currently active 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