52. Three-Source Permission Asks with a Timed Answer for Background Work¶
Date: 2026-10-12
Status: Accepted
Context¶
Permission asks used to resolve over two sources — direct input (the TUI prompt queue / the ACP client's dialog) and remote oversight (Telegram), first answer wins. Two failure modes fell out of that model:
- Unattended contexts failed closed without asking. The doom-loop gate (3 identical tool-call batches) in a background job or workflow step terminated the turn immediately ("this context has no way to ask for confirmation"), and the TUI didn't even wire
WithBackgroundPermissionAsk— job tool asks were denied outright. Silence was treated as absence of intent rather than absence of the user. - The remote surface was the only escape hatch, and its timeout was the wrong knob.
timeout_actiondecides what a remote-side silence means for a foreground ask; reusing it for background asks would have made two timers with two configs race to decide the same question.
The user-facing requirement: surface background permissions to the main TUI too — never fail immediately when a human could answer — and bound the wait with a third, explicit answer source: a timed answer that applies a configured default (deny by default) at a deadline (360s by default: long enough to notice and answer, short enough not to stall a long unattended task). The default must be settable via config and an ad-hoc slash command, and the safety default (doom-loop confirmations) must be independently settable so one allow cannot silently re-enable unattended runaway loops.
Decision¶
cmd/milk/permask.go is the single ask layer. Every ask races up to three answer sources; the first real answer wins:
| Source | Applies to | Semantics |
|---|---|---|
| direct input | all asks | TUI prompt queue / ACP session/request_permission |
| remote oversight | all asks | real Telegram y/n replies only — a timeout is not a reply |
| timed answer | background asks only | at ask_time + deadline, the configured default applies |
- Foreground asks (live turns) are unchanged: sources 1–2, no deadline, and remote-side silence still resolves with the remote's
timeout_action(remoteTimeoutResolvesinaskPolicy).oversight.Notifier.AskPermissionnow returns(decision, answered)so the layer can tell a reply from a silence;TimeoutActionapplication stays in the backend but its result is only used where the policy says so. This also fixed a latent bug whereoversight.NoopansweredPermAllowand made the claude-cli race auto-allow everything with oversight disabled. - Background asks — background-job tool asks, workflow-step tool asks (routed through
bgPermAskbyAsWorkflowExecutor), and the doom-loop gate's unattended confirmations (WithSafetyAsk→local.SafetyOutcome: approved / declined / timed-out / unreachable) — surface to all surfaces and resolve with the configured default at the deadline. Only when no surface exists at all (single-prompt mode) does the gate fail closed without waiting. - Prompt queue UX (user-approved): background asks join the TUI's existing prompt queue with attribution and a countdown, the timed answer labeled in the prompt ("auto-deny in 6m0s"), bulk keys
a/d(allow/deny all) to defuse bursts, a queue cap (5) whose overflow resolves straight to the timed default, and the user's input draft stashed/restored around prompts. A prompt withdrawn because it was answered elsewhere gets a one-line(answered elsewhere)note (permDismissMsg). - Config (
permissionsin~/.milk/config.json):background_timeout_secs(360),background_default(deny),safety_timeout_secs(360),safety_default(deny);safety_default: "allow"warns at validation. Session overrides via/permissions default allow|deny [safety]and/permissions timeout <secs> [safety](never persisted, like/skip-permissions). - Invariants kept:
dangerously_skip_permissionsstill auto-approves tool asks everywhere (including background) and still does not bypass the doom-loop gate; a remote decline is a real answer and wins over the timer; the remote side of asks stays serialized process-wide (oversightPermMu— Telegram has a single prompt slot).
Alternatives considered¶
- Fail closed as before (no third source) — rejected: "nobody answered instantly" is not "no"; it silently discards work and hides the question from the only people who could answer it.
- Remote-only asks for unattended contexts (the interim shape after PR #217) — rejected as the permanent design: it made Telegram a single point of failure for every safety decision and still never asked the primary surface.
- One shared default knob for tool and safety asks — rejected: a single
background_default: "allow"would auto-allow runaway-loop confirmations as a side effect. - Non-blocking
/permissions-only answer path for background asks — deferred: it would need stable ask IDs and an answering workflow of its own; the queue keeps one answering model everywhere, and its deadline bounds the input trap. Revisit if prompt bursts prove annoying in practice.