Test: qa-hub-09-task-state-machine (matrix HUB-09)
Date: 2026-05-12
Runner: Docker (sg docker)

Result: PASS
Runtime: ~12s warm, ~30s cold

Coverage (9 steps hard-asserted):
- [0-2] hub + admin + network + ntok + report_status(idle) — session row
- [3] BRANCH replied: send + send_reply(replied) → status=replied + result + completed_at
- [4] BRANCH failed: send + send_reply(failed) → status=failed + completed_at
- [5] BRANCH cancelled: send + cancel_task → status=cancelled + result contains reason
- [6] cancelled inbox auto-acked — get_inbox no longer returns it
- [7] PIN: send_reply on terminal replied task — silent no-op, task row unchanged
- [8] PIN: cancel_task on terminal cancelled task — returns ok:false, DB unchanged
- [9] all 3 terminal tasks have completed_at set + correct final status

Contracts pinned:

1. Three terminal states: replied / failed / cancelled
   send_reply zod enum: ["replied", "failed", "cancelled"] (tools.ts L595)
   cancel_task always writes 'cancelled'
   Future new terminal (e.g. 'timeout') needs enum + state-machine update.

2. Terminal-state non-reversibility:
   - send_reply on terminal: structured error (response.ok=false,
     error=reply_task_terminal), task unchanged
   - cancel_task on terminal: ok:false (changes=0 path)

3. Cancelled inbox auto-acked:
   cancel_task also runs `UPDATE inbox SET acked=1 WHERE id=task_id`
   (tools.ts L820-826). Prevents agent get_inbox from picking it up.

Additional contract surfaced by R14 (real test discovery):

4. cancel_task requires network-scoped writer (NTOK), NOT utok admin:
   UTOK without network context returns {ok:false, error:"permission_denied"}
   tools.ts L811-812:
     const effectiveNetId = getNetworkId(null);
     if (!canWrite(effectiveNetId)) return permission_denied;
   getNetworkId(null) returns the token's bound network. UTOK has none.

   SDK design gap: admin / dashboard wanting to cancel a task must:
     - mint an NTOK for the target network and use that, OR
     - hub needs to extend cancel_task to accept network_id arg + admin path
   File a question to @通信龙 / Vincent.

Resources:
  - Docker (sg docker)
  - node:20-slim + bun + jq + unzip + procps
  - @sleep2agi/agent-network@preview from npm
  - 0 LLM API calls
