Compare commits

...

2 Commits

Author SHA1 Message Date
Bohan Jiang
5901997bf6 fix(squad): wake private-leader squad parent leader on child-done (MUL-4063) (#4934)
* fix(squad): wake private-leader squad parent leader on child-done

The child-done parent wake routed squad leaders through
canEnqueueSquadLeader/canInvokeAgent, while the agent-parent path
(triggerChildDoneAgent) has never gated. Agents default to private
visibility, so a default squad leader is private; when a child is closed
by an agent/system actor (the normal process-squad pipeline) there is no
resolvable human originator, the gate fails closed, and the leader is
never woken -- stranding every multi-stage squad pipeline after its first
stage. Assigning the parent directly to the leader agent worked only
because that path is ungated.

Remove the child-done leader-invocation gate so agent and squad
child-done follow one path. The parent was already permission-checked at
squad-assign time (validateAssigneePair); waking its own leader to
advance the next stage is a coordination handoff, not a fresh
invocation, and grants no new privilege -- the actor can only wake the
leader on the specific parent that leader already owns. If invocation
permission is ever reintroduced it must be added to both paths together.

Also drops the now-dead actor plumbing threaded solely for the gate,
flips the plain-member child-done test to assert the leader is woken,
adds an agent-actor regression, and updates the squad / mentioning skill
docs.

MUL-4063

Co-authored-by: multica-agent <github@multica.ai>

* docs(squad): refresh Private Leader Access source map to canInvokeAgent

The squad + mentioning source maps still described the old
canAccessPrivateAgent model (visibility!=private, agent short-circuit,
system->agent remap). The trigger gate is canInvokeAgent (MUL-3963);
update both to match and note the child-done wake is now ungated
(MUL-4063). Review nit follow-up, docs only.

Co-authored-by: multica-agent <github@multica.ai>

---------

Co-authored-by: J <j@multica.ai>
Co-authored-by: multica-agent <github@multica.ai>
2026-07-05 02:35:19 +08:00
Bohan Jiang
cc1f5cda8a fix(issues): don't call an intermediate stage final in child-done comment (MUL-4062) (#4932)
The staged child-done system comment derived its "final stage vs next
stage" wording from stageProgressSummary over the sub-issues that
currently exist. The server has no declarative workflow model — stages
are agent-driven and often created lazily (stage N+1's sub-issues are
written only after stage N produces the inputs they depend on), so an
intermediate stage reaches nextStage==0 exactly like a true final stage.
The old else branch then asserted "This was the final stage. Wrap up the
parent", pushing leaders/humans to wrap up mid-workflow (GH #4927).

Extract the trailing instruction into stageAdvanceInstruction and, when
no later stage exists among the created sub-issues, stop asserting
finality: name both possibilities (create the next stage, or wrap up)
and hand the decision back to the leader. Add a unit test locking in
that the nextStage==0 message never claims a definitive final stage.

Co-authored-by: J <j@multica.ai>
Co-authored-by: multica-agent <github@multica.ai>
2026-07-05 01:35:00 +08:00
8 changed files with 260 additions and 64 deletions

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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