mirror of
https://github.com/multica-ai/multica.git
synced 2026-06-28 18:09:14 +02:00
Compare commits
4 Commits
codex/agen
...
agent/j/e9
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
cd46429483 | ||
|
|
9cd17449cb | ||
|
|
67c347518f | ||
|
|
2272ecee4e |
@@ -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 /
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user