Skip to content

Bootstrap-gate deadlock regression (#1021/v4.4.39): premature inbox witness from TaskUpdate(owner) defeats the secretary-spawn carve-out under standard CLI choreography #1023

Description

@michael-wojcik

Summary

PR #1021 (the #1019 config-less Desktop/SDK bootstrap-deadlock fix, v4.4.39) re-introduces a bootstrap-gate deadlock under the standard CLI PACT choreography. The first fresh session to bootstrap under 4.4.39 (session-b6dc25b7) hit it: the canonical secretary spawn was DENIED by bootstrap_gate, and the session only bootstrapped after a manual rm of a platform inbox file.

This is a new regression distinct from #1019#1019's original deadlock was Desktop/config-less; this one breaks the ordinary CLI path that worked pre-#1019.

Severity

High — blocks bootstrap on the first turn of every fresh CLI session running 4.4.39. The session self-heals one turn late (marker written via a premature witness on the next user prompt), but the bootstrap turn's secretary spawn fails, and the self-heal path writes a "bootstrap-complete" marker for a secretary that may never have been spawned (secondary concern below).

Mechanism — indirect regression through a shared predicate

#1021 edited only bootstrap_marker_writer.py (bootstrap_gate.py was untouched), but it widened the semantics of _team_has_secretary() by adding a config-less inbox-witness fallback:

PRIMARY:  members[] roster contains "secretary"        (CLI)
FALLBACK: teams/<team>/inboxes/secretary.json exists    (config-less Desktop/SDK — added in #1021)

_team_has_secretary() has two consumers:

  1. bootstrap_marker_writer — the intended target (Desktop has no config.json → needs the inbox witness). ✓
  2. bootstrap_gate._is_canonical_secretary_spawn, binding 5: return not _team_has_secretary(expected_team)not re-examined by fix(hooks): config-less Desktop/SDK bootstrap deadlock (#1019) #1021.

The PACT bootstrap choreography (commands/bootstrap.md Step 2 → Step 3) is:

Step 1  TaskCreate(secretary briefing)
Step 2  TaskUpdate(taskId, owner="secretary")   # platform delivers a task_assignment -> CREATES inboxes/secretary.json
Step 3  Agent(name="secretary", ...)            # the carve-out must fire HERE

So by Step 3 the inbox already exists_team_has_secretary returns True (via the new fallback arm, since members[] doesn't yet contain the secretary) → binding 5 (not True) → False → carve-out does not fire → bootstrap_gate DENIES the very spawn the carve-out exists to permit. Deadlock, because no marker exists yet on the bootstrap turn either.

The load-bearing assumption is falsified by ground truth

The #1021 docstring justifying the inbox witness asserts:

"the platform writes the secretary's inbox file only on delivery of a message to an already-dispatched secretary; it cannot predate the spawn within PACT's choreography."

This is false. TaskUpdate(owner=...) is part of the choreography (Step 2) and the platform delivers a task_assignment notification to the assignee's inbox, creating inboxes/secretary.json before Step 3's spawn.

Reproduction

  1. Start a fresh CLI session under v4.4.39 (in-process or tmux).
  2. Run the bootstrap ritual: TaskCreate secretary task → TaskUpdate(owner="secretary")Agent(name="secretary", subagent_type="pact-secretary").
  3. The Agent call is denied: PACT bootstrap required. Invoke Skill("PACT:bootstrap") first... (the canonical _DENY_REASON).

Evidence (from session-b6dc25b7, before the manual workaround)

Why it slipped / why this is the first session to hit it

Root cause

A single predicate was widened for consumer 1's correctness question ("was the secretary dispatched?") while consumer 2 needs a different question answered ("has the secretary actually joined the roster yet?"). Dispatch-witness (inbox) ≠ join-witness (members[]). The carve-out needs a join witness; only the marker writer should accept the dispatch/inbox fallback.

Fix direction (for plan-mode / orchestrate — not yet decided)

Decouple the carve-out from the marker writer's widened predicate. Candidate: give bootstrap_gate binding 5 a members[]-only join witness (e.g. a distinct _team_has_secretary_member() or an inline _iter_members check), leaving _team_has_secretary's inbox fallback for the marker writer alone. This restores pre-#1019 carve-out semantics under CLI while preserving #1019's Desktop marker fix.

Interactions to weigh during design:

  • Dual-mode contract: under config-less Desktop, members[] is always empty → a members-only carve-out would "always allow secretary spawn pre-marker." Need to confirm that's acceptable (the marker write closes the window; spawn is one-shot in practice) and doesn't lose a needed one-shot guarantee.
  • Empty-SSOT fail-closed gate (get_team_name short-circuit, test_empty_ssot_team_fails_closed_both_modes) must remain intact.
  • Both-modes test matrix is a standing merge gate.

Secondary concern (corollary — may warrant its own issue)

Because the premature inbox satisfies the marker writer too, on the next user prompt the marker writer can write a "bootstrap-complete" marker for a secretary that was only task-assigned, never spawned/joined. The inbox witness here proves task assignment, which is weaker than even "dispatched." If the Agent(secretary) call is denied and never retried, the gate unblocks (marker present) with no live secretary — memory queries would then hang on an absent secretary (surfaced as a stuck SendMessage reply, not silent corruption, per the liveness contract). Worth deciding whether the marker writer's inbox witness should exclude task-assignment-only inboxes.

Non-vacuity note

A regression test must run the real TaskUpdate(owner) → Agent sequence (or a faithful fixture that creates the inbox via task-assignment), not a synthetic pre-placed inbox — the synthetic shortcut is exactly what let this slip through #1019's verification.


Source: PR #1021 / commit e371ac9 / v4.4.39. Distinct from #1019 (Desktop config-less deadlock) and #1020 (silent HANDOFF-harvest loss).

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type
    No fields configured for issues without a type.

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions