mirror of
https://github.com/multica-ai/multica.git
synced 2026-07-05 05:19:30 +02:00
Compare commits
2 Commits
agent/j/46
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
5901997bf6 | ||
|
|
cc1f5cda8a |
@@ -1431,7 +1431,7 @@ func (h *Handler) advanceIssueToDone(ctx context.Context, issue db.Issue, worksp
|
||||
// it here would leave the parent silent for the dominant completion path.
|
||||
// notifyParentOfChildDone re-checks every guard (prev != done, parent
|
||||
// exists, parent not terminal), so calling it unconditionally is safe.
|
||||
h.notifyParentOfChildDone(ctx, issue, updated, "system", "")
|
||||
h.notifyParentOfChildDone(ctx, issue, updated)
|
||||
|
||||
prefix := h.getIssuePrefix(ctx, issue.WorkspaceID)
|
||||
resp := issueToResponse(updated, prefix)
|
||||
|
||||
@@ -2618,7 +2618,7 @@ func (h *Handler) UpdateIssue(w http.ResponseWriter, r *http.Request) {
|
||||
// loops in PR #2918). The helper guards on transition + parent state and
|
||||
// fails best-effort.
|
||||
if statusChanged {
|
||||
h.notifyParentOfChildDone(r.Context(), prevIssue, issue, actorType, actorID)
|
||||
h.notifyParentOfChildDone(r.Context(), prevIssue, issue)
|
||||
}
|
||||
|
||||
writeJSON(w, http.StatusOK, resp)
|
||||
@@ -3114,7 +3114,7 @@ func (h *Handler) BatchUpdateIssues(w http.ResponseWriter, r *http.Request) {
|
||||
// Platform-driven parent notification, mirrored from UpdateIssue
|
||||
// (MUL-2538). Best-effort; failure does not abort the batch.
|
||||
if statusChanged {
|
||||
h.notifyParentOfChildDone(r.Context(), prevIssue, issue, actorType, actorID)
|
||||
h.notifyParentOfChildDone(r.Context(), prevIssue, issue)
|
||||
}
|
||||
|
||||
updated++
|
||||
|
||||
@@ -65,7 +65,7 @@ import (
|
||||
// Errors are logged at warn level and swallowed: this is a best-effort
|
||||
// notification on the side of a successful status update; failing it must
|
||||
// not roll back the user's status change.
|
||||
func (h *Handler) notifyParentOfChildDone(ctx context.Context, prev, issue db.Issue, actorType, actorID string) {
|
||||
func (h *Handler) notifyParentOfChildDone(ctx context.Context, prev, issue db.Issue) {
|
||||
if !issue.ParentIssueID.Valid {
|
||||
return
|
||||
}
|
||||
@@ -144,15 +144,7 @@ func (h *Handler) notifyParentOfChildDone(ctx context.Context, prev, issue db.Is
|
||||
if staged {
|
||||
closedStage := issue.Stage.Int32
|
||||
summary, nextStage := stageProgressSummary(children, closedStage)
|
||||
var advance string
|
||||
if nextStage > 0 {
|
||||
advance = fmt.Sprintf(
|
||||
" Stage %d is next. Review the full layout with `multica issue children %s`, and if Stage %d's dependencies are satisfied promote its `backlog` sub-issues to `todo` to continue. Read each sub-issue's description first and only promote items whose stated dependencies are already met — do not rely on this parent's higher-level breakdown alone. If a description conflicts with that breakdown, leave it `backlog` and post a comment to confirm first.",
|
||||
nextStage, parentID, nextStage,
|
||||
)
|
||||
} else {
|
||||
advance = " This was the final stage. Wrap up the parent — synthesize the results and move it forward, or close it out if nothing remains."
|
||||
}
|
||||
advance := stageAdvanceInstruction(nextStage, parentID)
|
||||
content = fmt.Sprintf(
|
||||
"%sStage %d of this issue is complete — its last sub-issue [%s](mention://issue/%s) — \"%s\" — just finished. Stage progress — %s.%s",
|
||||
mentionPrefix, closedStage, identifier, childID, title, summary, advance,
|
||||
@@ -198,7 +190,7 @@ func (h *Handler) notifyParentOfChildDone(ctx context.Context, prev, issue db.Is
|
||||
// author_type='system'); this keeps smuggled mentions from the child
|
||||
// title inert and gives the platform a single place to apply the loop
|
||||
// and idempotency guards.
|
||||
h.dispatchParentAssigneeTrigger(ctx, parent, comment, actorType, actorID)
|
||||
h.dispatchParentAssigneeTrigger(ctx, parent, comment)
|
||||
}
|
||||
|
||||
// isTerminalChildStatus reports whether a child issue status counts as
|
||||
@@ -300,6 +292,32 @@ func stageProgressSummary(children []db.Issue, closedStage int32) (summary strin
|
||||
return strings.Join(parts, "; "), nextStage
|
||||
}
|
||||
|
||||
// stageAdvanceInstruction returns the trailing instruction appended to a
|
||||
// staged child-done system comment, given the next stage with pending work
|
||||
// among the sub-issues that currently exist (nextStage, 0 = none).
|
||||
//
|
||||
// - nextStage > 0: a later stage with unfinished work already exists, so
|
||||
// point the leader at it.
|
||||
// - nextStage == 0: no later stage exists *among the sub-issues created so
|
||||
// far*. This deliberately does NOT assert that the workflow is finished.
|
||||
// The server has no declarative workflow model — stages are agent-driven
|
||||
// and often created lazily (stage N+1's sub-issues are only written after
|
||||
// stage N produces the inputs they depend on), so an intermediate stage in
|
||||
// such a pipeline reaches nextStage == 0 exactly like a true final stage
|
||||
// does. The old wording ("This was the final stage. Wrap up the parent")
|
||||
// asserted a finality the server cannot know and pushed leaders to wrap up
|
||||
// mid-workflow (MUL-4062 / #4927). The message now names both possibilities
|
||||
// and hands the create-next-vs-wrap-up decision back to the leader.
|
||||
func stageAdvanceInstruction(nextStage int32, parentID string) string {
|
||||
if nextStage > 0 {
|
||||
return fmt.Sprintf(
|
||||
" Stage %d is next. Review the full layout with `multica issue children %s`, and if Stage %d's dependencies are satisfied promote its `backlog` sub-issues to `todo` to continue. Read each sub-issue's description first and only promote items whose stated dependencies are already met — do not rely on this parent's higher-level breakdown alone. If a description conflicts with that breakdown, leave it `backlog` and post a comment to confirm first.",
|
||||
nextStage, parentID, nextStage,
|
||||
)
|
||||
}
|
||||
return " Completing this stage does not mean the whole issue is done. Decide whether the issue is actually complete — if so, wrap up the parent (synthesize the results and move it forward, or close it out) — or whether the next stage still needs to be created, in which case create that stage and its sub-issues now."
|
||||
}
|
||||
|
||||
// sanitizeChildTitleForSystemComment removes mention-style markdown from a
|
||||
// child issue's title before it is embedded into the parent's system
|
||||
// comment. Smuggled mentions are already harmless on the listener path
|
||||
@@ -390,7 +408,10 @@ func sanitizeMentionLabel(name string) string {
|
||||
// Unlike a human @squad mention, this does NOT fan out to squad members
|
||||
// — child-done is a coordination signal, the leader decides whether
|
||||
// and how to wake the rest of the squad. Documented here so reviewers
|
||||
// don't read "system mention" as inheriting the full member fan-out.
|
||||
// don't read "system mention" as inheriting the full member fan-out. The
|
||||
// actor that closed the child is irrelevant to routing: the target is the
|
||||
// parent's own leader, chosen (and permission-checked) at squad-assign
|
||||
// time, so no actor identity is threaded in — see triggerChildDoneSquad.
|
||||
// - notification_preference is not consulted: this is a platform routing
|
||||
// signal targeted at the assignee that already owns the parent, not a
|
||||
// general notification. Per-user mute settings are evaluated by the
|
||||
@@ -421,7 +442,7 @@ func sanitizeMentionLabel(name string) string {
|
||||
// itself push a child back into a terminal transition.
|
||||
// - Readiness: archived agents / missing runtimes are silently skipped
|
||||
// so a closed-out agent does not surface as a phantom assignee.
|
||||
func (h *Handler) dispatchParentAssigneeTrigger(ctx context.Context, parent db.Issue, systemComment db.Comment, actorType, actorID string) {
|
||||
func (h *Handler) dispatchParentAssigneeTrigger(ctx context.Context, parent db.Issue, systemComment db.Comment) {
|
||||
if !parent.AssigneeType.Valid || !parent.AssigneeID.Valid {
|
||||
return
|
||||
}
|
||||
@@ -430,7 +451,7 @@ func (h *Handler) dispatchParentAssigneeTrigger(ctx context.Context, parent db.I
|
||||
case "agent":
|
||||
h.triggerChildDoneAgent(ctx, parent, systemComment.ID)
|
||||
case "squad":
|
||||
h.triggerChildDoneSquad(ctx, parent, systemComment.ID, actorType, actorID)
|
||||
h.triggerChildDoneSquad(ctx, parent, systemComment.ID)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -475,18 +496,31 @@ func (h *Handler) triggerChildDoneAgent(ctx context.Context, parent db.Issue, tr
|
||||
}
|
||||
|
||||
// triggerChildDoneSquad enqueues a leader-role task for the parent's squad
|
||||
// assignee. Like the agent path (see triggerChildDoneAgent) it applies NO
|
||||
// self-trigger guard: even when the finished child is owned by the same squad
|
||||
// or by another squad sharing this leader, the leader must still be woken on
|
||||
// the PARENT to advance the next stage or wrap up. The prior same-squad /
|
||||
// shared-leader guards assumed the leader had already observed the child via
|
||||
// its own coordination cycle, but that wake lands on the CHILD and never
|
||||
// carries the parent-level stage-barrier instruction, so it stranded the
|
||||
// common "squad decomposes its parent into sub-issues assigned to its own
|
||||
// squad" pattern (MUL-3969). Re-triggering is bounded by the
|
||||
// HasPendingTaskForIssueAndAgent idempotency check below, exactly as the
|
||||
// agent path relies on it.
|
||||
func (h *Handler) triggerChildDoneSquad(ctx context.Context, parent db.Issue, triggerCommentID pgtype.UUID, actorType, actorID string) {
|
||||
// assignee. It mirrors the agent path (see triggerChildDoneAgent) exactly:
|
||||
//
|
||||
// - NO self-trigger guard: even when the finished child is owned by the same
|
||||
// squad or by another squad sharing this leader, the leader must still be
|
||||
// woken on the PARENT to advance the next stage or wrap up. The prior
|
||||
// same-squad / shared-leader guards assumed the leader had already observed
|
||||
// the child via its own coordination cycle, but that wake lands on the
|
||||
// CHILD and never carries the parent-level stage-barrier instruction, so it
|
||||
// stranded the common "squad decomposes its parent into sub-issues assigned
|
||||
// to its own squad" pattern (MUL-3969).
|
||||
// - NO leader-invocation gate. Waking the parent's OWN squad leader on
|
||||
// child-done is a coordination handoff on an issue the leader already owns,
|
||||
// not a fresh invocation — invocation permission was already enforced when
|
||||
// the parent was assigned to the squad (validateAssigneePair). The agent
|
||||
// path has never gated this. Re-checking it here on behalf of the child's
|
||||
// completer — an agent/system actor with no resolvable human originator —
|
||||
// failed closed for the DEFAULT private leader, silently stranding every
|
||||
// process-squad pipeline after its first stage while direct-to-leader-agent
|
||||
// parents advanced fine (MUL-4063 / GH #4928). Removed so agent and squad
|
||||
// child-done follow one path; if invocation permission is ever reintroduced
|
||||
// it must be added to BOTH paths together.
|
||||
//
|
||||
// Re-triggering is bounded by the HasPendingTaskForIssueAndAgent idempotency
|
||||
// check below, exactly as the agent path relies on it.
|
||||
func (h *Handler) triggerChildDoneSquad(ctx context.Context, parent db.Issue, triggerCommentID pgtype.UUID) {
|
||||
squad, err := h.Queries.GetSquadInWorkspace(ctx, db.GetSquadInWorkspaceParams{
|
||||
ID: parent.AssigneeID,
|
||||
WorkspaceID: parent.WorkspaceID,
|
||||
@@ -495,18 +529,6 @@ func (h *Handler) triggerChildDoneSquad(ctx context.Context, parent db.Issue, tr
|
||||
return
|
||||
}
|
||||
|
||||
// Private-leader gate: deny if the actor cannot invoke the leader. Member
|
||||
// actors are their own originator; agent/system child-done triggers have
|
||||
// no resolvable human here, so canInvokeAgent fails closed for member/team
|
||||
// targets while still admitting workspace-target leaders.
|
||||
leaderOriginator := ""
|
||||
if actorType == "member" {
|
||||
leaderOriginator = actorID
|
||||
}
|
||||
if !h.canEnqueueSquadLeader(ctx, squad.LeaderID, actorType, actorID, leaderOriginator, uuidToString(parent.WorkspaceID)) {
|
||||
return
|
||||
}
|
||||
|
||||
agent, err := h.Queries.GetAgent(ctx, squad.LeaderID)
|
||||
if err != nil || !agent.RuntimeID.Valid || agent.ArchivedAt.Valid {
|
||||
return
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
package handler
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/jackc/pgx/v5/pgtype"
|
||||
@@ -146,6 +147,40 @@ func TestStageProgressSummary_SkipsUnstaged(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// stageAdvanceInstruction must point at a known next stage when one exists,
|
||||
// and — the core of MUL-4062 — must NOT assert finality when no later stage
|
||||
// exists yet, because a lazily-created intermediate stage reaches nextStage==0
|
||||
// exactly like a true final stage does.
|
||||
func TestStageAdvanceInstruction(t *testing.T) {
|
||||
const parentID = "parent-uuid"
|
||||
|
||||
t.Run("a known next stage points the leader at it", func(t *testing.T) {
|
||||
got := stageAdvanceInstruction(3, parentID)
|
||||
if !strings.Contains(got, "Stage 3 is next") {
|
||||
t.Fatalf("expected next-stage instruction, got %q", got)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("no created next stage does not assert finality", func(t *testing.T) {
|
||||
got := stageAdvanceInstruction(0, parentID)
|
||||
// Regression guard for MUL-4062: an intermediate stage in a lazily
|
||||
// created workflow also reaches nextStage==0, so the message must not
|
||||
// claim this was definitively the final stage.
|
||||
if strings.Contains(got, "This was the final stage") {
|
||||
t.Fatalf("must not assert finality when the workflow shape is unknown, got %q", got)
|
||||
}
|
||||
// It must make clear that finishing the stage != the whole issue is
|
||||
// done, and hand both paths (wrap up / create the next stage) to the
|
||||
// leader.
|
||||
if !strings.Contains(got, "does not mean the whole issue is done") {
|
||||
t.Fatalf("expected stage-done != issue-done framing, got %q", got)
|
||||
}
|
||||
if !strings.Contains(got, "next stage") {
|
||||
t.Fatalf("expected create-next-stage guidance, got %q", got)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// A stage can close because its last open child is *cancelled*, not only
|
||||
// done — a cancelled sibling never finishes, so it must not hold the stage open.
|
||||
func TestStageBarrierClosed_CancelledClosesStage(t *testing.T) {
|
||||
|
||||
@@ -197,10 +197,15 @@ func TestComment_SquadPrivateLeader_PlainMemberNoEnqueue(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// TestChildDone_SquadPrivateLeader_PlainMemberNoEnqueue verifies that when
|
||||
// TestChildDone_SquadPrivateLeader_PlainMemberWakesLeader verifies that when
|
||||
// a plain member completes a child issue whose parent is assigned to a
|
||||
// private-leader squad, the leader is NOT enqueued.
|
||||
func TestChildDone_SquadPrivateLeader_PlainMemberNoEnqueue(t *testing.T) {
|
||||
// private-leader squad, the leader IS woken. Child-done no longer re-checks
|
||||
// leader invocation permission (MUL-4063 / GH #4928): the parent was already
|
||||
// assigned to the squad — which passed the invocation gate — so waking that
|
||||
// squad's own leader to advance the next stage is a coordination handoff, not
|
||||
// a fresh invocation. This mirrors the ungated agent-parent path
|
||||
// (triggerChildDoneAgent); agent and squad child-done now follow one path.
|
||||
func TestChildDone_SquadPrivateLeader_PlainMemberWakesLeader(t *testing.T) {
|
||||
if testHandler == nil || testPool == nil {
|
||||
t.Skip("database not available")
|
||||
}
|
||||
@@ -278,7 +283,8 @@ func TestChildDone_SquadPrivateLeader_PlainMemberNoEnqueue(t *testing.T) {
|
||||
t.Fatalf("UpdateIssue (child done): expected 200, got %d: %s", w.Code, w.Body.String())
|
||||
}
|
||||
|
||||
// The private leader must NOT have a queued task on the parent.
|
||||
// The private leader MUST have a queued task on the parent — child-done
|
||||
// wakes the parent's own leader regardless of who closed the child.
|
||||
var count int
|
||||
if err := testPool.QueryRow(ctx,
|
||||
`SELECT count(*) FROM agent_task_queue WHERE issue_id = $1 AND agent_id = $2 AND status = 'queued'`,
|
||||
@@ -286,8 +292,125 @@ func TestChildDone_SquadPrivateLeader_PlainMemberNoEnqueue(t *testing.T) {
|
||||
).Scan(&count); err != nil {
|
||||
t.Fatalf("count tasks: %v", err)
|
||||
}
|
||||
if count != 0 {
|
||||
t.Fatalf("private leader got %d queued tasks from plain member child-done; want 0", count)
|
||||
if count == 0 {
|
||||
t.Fatalf("private leader got 0 queued tasks from plain member child-done; want >=1")
|
||||
}
|
||||
}
|
||||
|
||||
// TestChildDone_SquadPrivateLeader_AgentActorWakesLeader is the core MUL-4063
|
||||
// regression: an AGENT (a squad worker) closes a child under a private-leader
|
||||
// squad parent, and the child's completing agent has NO human originator who
|
||||
// could invoke the private leader. This is the exact process-squad pipeline
|
||||
// shape that used to strand — the removed canEnqueueSquadLeader gate failed
|
||||
// closed here — while a direct-to-leader-agent parent advanced fine. The
|
||||
// leader must now be woken.
|
||||
func TestChildDone_SquadPrivateLeader_AgentActorWakesLeader(t *testing.T) {
|
||||
if testHandler == nil || testPool == nil {
|
||||
t.Skip("database not available")
|
||||
}
|
||||
ctx := context.Background()
|
||||
|
||||
agentID, ownerID, memberID := privateAgentTestFixture(t)
|
||||
workerAgentID := createHandlerTestAgent(t, "squad-private-leader-childdone-worker", nil)
|
||||
|
||||
var squadID string
|
||||
if err := testPool.QueryRow(ctx, `
|
||||
INSERT INTO squad (workspace_id, name, description, leader_id, creator_id)
|
||||
VALUES ($1, 'Private Leader ChildDone AgentActor Test', '', $2, $3)
|
||||
RETURNING id
|
||||
`, testWorkspaceID, agentID, testUserID).Scan(&squadID); err != nil {
|
||||
t.Fatalf("create squad: %v", err)
|
||||
}
|
||||
t.Cleanup(func() {
|
||||
testPool.Exec(context.Background(), `DELETE FROM squad WHERE id = $1`, squadID)
|
||||
})
|
||||
|
||||
// Parent assigned to the squad by the agent owner (allowed under MUL-3963).
|
||||
w := httptest.NewRecorder()
|
||||
r := newRequestAs(ownerID, "POST", "/api/issues?workspace_id="+testWorkspaceID, map[string]any{
|
||||
"title": "parent with private-leader squad (agent child-done)",
|
||||
"assignee_type": "squad",
|
||||
"assignee_id": squadID,
|
||||
})
|
||||
testHandler.CreateIssue(w, r)
|
||||
if w.Code != http.StatusCreated {
|
||||
t.Fatalf("create parent: expected 201, got %d: %s", w.Code, w.Body.String())
|
||||
}
|
||||
var parent IssueResponse
|
||||
if err := json.NewDecoder(w.Body).Decode(&parent); err != nil {
|
||||
t.Fatalf("decode parent: %v", err)
|
||||
}
|
||||
t.Cleanup(func() {
|
||||
testPool.Exec(context.Background(), `DELETE FROM agent_task_queue WHERE issue_id = $1`, parent.ID)
|
||||
testPool.Exec(context.Background(), `DELETE FROM comment WHERE issue_id = $1`, parent.ID)
|
||||
testPool.Exec(context.Background(), `DELETE FROM issue WHERE parent_issue_id = $1`, parent.ID)
|
||||
testPool.Exec(context.Background(), `DELETE FROM issue WHERE id = $1`, parent.ID)
|
||||
})
|
||||
|
||||
// Clear the leader task the assign enqueued so the child-done wake is the
|
||||
// only thing that can create one.
|
||||
testPool.Exec(ctx, `DELETE FROM agent_task_queue WHERE issue_id = $1`, parent.ID)
|
||||
|
||||
// Child assigned to the worker agent, in progress.
|
||||
w = httptest.NewRecorder()
|
||||
r = newRequest("POST", "/api/issues?workspace_id="+testWorkspaceID, map[string]any{
|
||||
"title": "child task worked by an agent",
|
||||
"parent_issue_id": parent.ID,
|
||||
"assignee_type": "agent",
|
||||
"assignee_id": workerAgentID,
|
||||
"status": "in_progress",
|
||||
})
|
||||
testHandler.CreateIssue(w, r)
|
||||
if w.Code != http.StatusCreated {
|
||||
t.Fatalf("create child: expected 201, got %d: %s", w.Code, w.Body.String())
|
||||
}
|
||||
var child IssueResponse
|
||||
if err := json.NewDecoder(w.Body).Decode(&child); err != nil {
|
||||
t.Fatalf("decode child: %v", err)
|
||||
}
|
||||
t.Cleanup(func() {
|
||||
testPool.Exec(context.Background(), `DELETE FROM agent_task_queue WHERE issue_id = $1`, child.ID)
|
||||
testPool.Exec(context.Background(), `DELETE FROM issue WHERE id = $1`, child.ID)
|
||||
})
|
||||
|
||||
// The worker agent's running task on the CHILD. Its originator is the plain
|
||||
// member — who cannot invoke the private leader — proving the wake no longer
|
||||
// depends on the completer being able to invoke the leader.
|
||||
var workerTaskID string
|
||||
if err := testPool.QueryRow(ctx, `
|
||||
INSERT INTO agent_task_queue (agent_id, runtime_id, status, priority, issue_id, originator_user_id)
|
||||
VALUES ($1, (SELECT runtime_id FROM agent WHERE id = $1), 'running', 0, $2, $3)
|
||||
RETURNING id
|
||||
`, workerAgentID, child.ID, memberID).Scan(&workerTaskID); err != nil {
|
||||
t.Fatalf("create worker task: %v", err)
|
||||
}
|
||||
t.Cleanup(func() {
|
||||
testPool.Exec(context.Background(), `DELETE FROM agent_task_queue WHERE id = $1`, workerTaskID)
|
||||
})
|
||||
|
||||
// Worker agent moves the child to done (agent actor via X-Agent-ID/X-Task-ID).
|
||||
w = httptest.NewRecorder()
|
||||
r = newRequest("PATCH", "/api/issues/"+child.ID, map[string]any{
|
||||
"status": "done",
|
||||
})
|
||||
r.Header.Set("X-Agent-ID", workerAgentID)
|
||||
r.Header.Set("X-Task-ID", workerTaskID)
|
||||
r = withURLParam(r, "id", child.ID)
|
||||
testHandler.UpdateIssue(w, r)
|
||||
if w.Code != http.StatusOK {
|
||||
t.Fatalf("UpdateIssue (child done): expected 200, got %d: %s", w.Code, w.Body.String())
|
||||
}
|
||||
|
||||
// The private leader MUST have a queued task on the parent.
|
||||
var count int
|
||||
if err := testPool.QueryRow(ctx,
|
||||
`SELECT count(*) FROM agent_task_queue WHERE issue_id = $1 AND agent_id = $2 AND status = 'queued'`,
|
||||
parent.ID, agentID,
|
||||
).Scan(&count); err != nil {
|
||||
t.Fatalf("count tasks: %v", err)
|
||||
}
|
||||
if count == 0 {
|
||||
t.Fatalf("private leader got 0 queued tasks from agent child-done; want >=1 (MUL-4063)")
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -124,8 +124,8 @@ These are all silent no-ops — no error, no run:
|
||||
(`RuntimeID` invalid or `ArchivedAt` set).
|
||||
- **A private agent you cannot access:** skipped — the mention path gates on
|
||||
`canAccessPrivateAgent` directly for both `@agent` and `@squad` (the
|
||||
`canEnqueueSquadLeader` wrapper is the assignment/child-done path, not this
|
||||
one).
|
||||
`canEnqueueSquadLeader` wrapper is the squad assignment/promote path, not this
|
||||
one; the child-done wake is ungated — see the multica-squads skill).
|
||||
|
||||
## Incorrect → Correct
|
||||
|
||||
|
||||
@@ -87,7 +87,7 @@ a pointer.
|
||||
| already-pending dedup (agent) → shared pending-task helper → `continue` | `server/internal/handler/comment.go:1459-1463` |
|
||||
| already-pending dedup (squad leader) → shared pending-task helper → `continue` | `server/internal/handler/comment.go:1429-1433` |
|
||||
| `canAccessPrivateAgent` definition | `server/internal/handler/agent_access.go` (search `func (h *Handler) canAccessPrivateAgent`) |
|
||||
| `canEnqueueSquadLeader` (loads leader, delegates to `canAccessPrivateAgent`) | `server/internal/handler/agent_access.go:82-91` |
|
||||
| `canEnqueueSquadLeader` (loads leader, delegates to `canInvokeAgent`) | `server/internal/handler/agent_access.go:261-267` |
|
||||
|
||||
## @all broadcast and assignee-trigger suppression
|
||||
|
||||
|
||||
@@ -189,29 +189,45 @@ Contracts:
|
||||
only carrier of the stage-barrier "advance / wrap up" instruction (MUL-3969,
|
||||
mirrors the agent path from MUL-2808). Re-triggering is bounded only by
|
||||
`HasPendingTaskForIssueAndAgent` (idempotent per parent issue + agent).
|
||||
- no leader-invocation gate: child-done does NOT re-check whether the child's
|
||||
completer can invoke the leader. The parent was already permission-checked at
|
||||
squad-assign time (`validateAssigneePair`), so waking its own leader is a
|
||||
coordination handoff, not a fresh invocation. Re-checking it here failed
|
||||
closed for the DEFAULT private leader (the child's completer is an
|
||||
agent/system actor with no resolvable human originator), stranding every
|
||||
process-squad pipeline after stage 1 while direct-to-leader-agent parents
|
||||
advanced fine (MUL-4063 / GH #4928). Agent and squad child-done now share one
|
||||
ungated path; any future invocation gate must be added to BOTH together.
|
||||
|
||||
## Private Leader Access
|
||||
|
||||
Source:
|
||||
|
||||
```text
|
||||
server/internal/handler/agent_access.go # canAccessPrivateAgent ~25-40, canEnqueueSquadLeader ~82-91
|
||||
server/internal/handler/squad.go # enqueueSquadLeaderTask gate ~1037
|
||||
server/internal/handler/agent_access.go # canInvokeAgent ~48-108, canEnqueueSquadLeader ~261-267
|
||||
server/internal/handler/squad.go # enqueueSquadLeaderTask gate ~955-974
|
||||
```
|
||||
|
||||
Contracts:
|
||||
Contracts (invocation gate, MUL-3963 — this is the *trigger* gate, distinct from
|
||||
the view gate `canAccessPrivateAgent`):
|
||||
|
||||
- public leaders pass — `canAccessPrivateAgent` returns true when
|
||||
`agent.Visibility != "private"` (agent_access.go:26-28);
|
||||
- agent-to-agent traffic is allowed — `actorType == "agent"` short-circuits
|
||||
(agent_access.go:29-31);
|
||||
- private leader access for members is limited to owner/admin or agent owner
|
||||
(agent_access.go:32-39);
|
||||
- system triggers are treated like agent triggers for squad leader enqueue:
|
||||
`canEnqueueSquadLeader` remaps `actorType == "system"` to `"agent"` before
|
||||
delegating to `canAccessPrivateAgent` (agent_access.go:87-90). This is wired
|
||||
into `enqueueSquadLeaderTask`, which denies the enqueue when the actor cannot
|
||||
access the leader (squad.go:1037).
|
||||
- `canEnqueueSquadLeader` loads the leader and delegates to `canInvokeAgent`
|
||||
(agent_access.go:261-267);
|
||||
- `canInvokeAgent` judges by the *effective invoking user*: a member actor is
|
||||
itself; an agent/system actor is the top-of-chain human originator
|
||||
(`originatorUserID`), which is `""` when none resolved (agent_access.go:48-54);
|
||||
- the agent owner may always invoke their own agent (agent_access.go:57-59);
|
||||
- `permission_mode != "public_to"` (i.e. private) is deny-by-default — no admin
|
||||
bypass, no A2A bypass; only the owner branch passes (agent_access.go:61-65);
|
||||
- `public_to` consults the invocation-target allow-list: a `workspace` target
|
||||
admits any workspace member AND workspace-internal agent/system principals even
|
||||
with no resolved human (`workspaceBroad`); `member` targets require the
|
||||
resolved human to match; `team` targets are inert in V1 (agent_access.go:82-106);
|
||||
- wired into `enqueueSquadLeaderTask` (squad.go:955-974): the squad
|
||||
assign/promote path denies the enqueue when the actor cannot invoke the leader
|
||||
(member authors are their own originator; agent-authored triggers pass `""`).
|
||||
- NOTE: the child-done wake does NOT use this gate anymore — see "Child-done
|
||||
Parent Trigger" above (MUL-4063).
|
||||
|
||||
## Tests
|
||||
|
||||
|
||||
Reference in New Issue
Block a user