`--resume` replays orphaned permission.requested events (no matching permission.completed) after mid-prompt process death
- Linguagem predominante
- Shell
- Estrelas
- 11.2k
- Forks
- 1.9k
- Merge médio
- 14h 16min
- PRs com merge (30d)
- 6
Descrição
### Describe the bug
On `--resume`, the CLI re-presents permission prompts that were never resolved in a prior run. These originate from `permission.requested` events in the session's `events.jsonl` that have no matching `permission.completed` event. The prompts replay on every resume, repeatedly asking to approve/cancel shell commands (and file writes) that are long dead, with no in-CLI way to clear them. `/tasks` shows no active tasks or subagents and no OS process is running.
This is effectively the read-side sibling of #3366 (orphan `tool_use`), but for the permission subsystem — it manifests as a nagging-prompt loop rather than a hard API 400 wedge.
### Affected version
`1.0.73`
### Steps to reproduce the behavior
1. Start a session and run a command that triggers a permission prompt (e.g. a shell command, or a file write).
2. While the prompt is pending, kill the CLI abruptly — reboot the host, or otherwise terminate the process before answering. (Also reproducible via backgrounded/detached shells — e.g. `nohup … &`, a `while pgrep` poll loop, or `timeout … &` — whose lifecycle doesn't get a terminal event before the session is interrupted.)
3. Run `copilot --resume` and select the session.
4. The unresolved prompt(s) replay. `/tasks` is empty, so there's nothing to cancel; they return on every subsequent resume.
Evidence from the affected session (pairing `permission.requested.data.requestId` with `permission.completed.data.requestId`):
```
total permission.requested: 3234 | completed: 3230 | DANGLING: 4
- write: Create file
- shell: df -h ...; ls -la .../*BF16* ...
- shell: dpkg upgrade history loop ...
- shell: unattended-upgrades log loop ...
```
Exactly 4 unmatched `permission.requested` events → exactly the 4 prompts replayed on resume. `command-history-state.json` was clean, confirming the source is the event log, not a live task registry.
### Expected behavior
On resume, a `permission.requested` with no matching `permission.completed` should be treated as stale and auto-resolved (cancelled/denied) rather than re-prompting — mirroring the write/read reconciliation asked for in #3366. Ideally the CLI would also write a terminal `permission.completed{status:"cancelled"}` during graceful shutdown and reconcile orphans on startup.
### Additional context
- OS: Ubuntu 24.04, x86_64, headless server over SSH, bash.
- No in-CLI recovery: `/tasks` empty, prompts persist across resumes; manual `events.jsonl` surgery is currently the only fix.
- Related: #3366 (orphan `tool_use`, same root family), #3559 (`fc_call_*` replay from session-state), #1696 (resume & previously granted permissions).
**Workaround we used**
Run this only after `/exit` (so the CLI isn't appending to the log). It backs up `events.jsonl`, then removes only the `permission.requested` lines whose `requestId` has no matching `permission.completed`; all other events are preserved byte-for-byte.
```bash
SESSION_DIR="$HOME/.copilot/session-state/"
cd "$SESSION_DIR"
cp -a events.jsonl "events.jsonl.bak.$(date +%s)"
python3 - <<'PY'
import json, os
f = "events.jsonl"
# Pass 1: collect every requestId that has a permission.completed
completed = set()
with open(f, encoding="utf-8", errors="replace") as fh:
for line in fh:
try:
o = json.loads(line)
except Exception:
continue
if o.get("type") == "permission.completed":
rid = (o.get("data") or {}).get("requestId")
if rid:
completed.add(rid)
# Pass 2: rewrite, dropping only permission.requested with no completion
kept = dropped = 0
tmp = f + ".tmp"
with open(f, encoding="utf-8", errors="replace") as fh, \
open(tmp, "w", encoding="utf-8") as out:
for line in fh:
drop = False
try:
o = json.loads(line)
except Exception:
o = None
if o and o.get("type") == "permission.requested":
rid = (o.get("data") or {}).get("requestId")
if rid not in completed:
drop = True
if drop:
dropped += 1
else:
out.write(line)
kept += 1
os.replace(tmp, f)
print(f"kept={kept} dropped={dropped}")
PY
```
Also delete the session's stale lock if its owning PID is dead:
```bash
# inuse..lock left behind when the CLI is killed (e.g. by reboot)
for lk in inuse.*.lock; do
pid=$(printf '%s\n' "$lk" | sed -E 's/inuse\.([0-9]+)\.lock/\1/')
if ! kill -0 "$pid" 2>/dev/null; then
echo "removing stale $lk (pid $pid dead)"; rm -f "$lk"
fi
done
```
After this, `copilot --resume` on the session comes up clean with no replayed prompts.
Guia de contribuição
Direção de pesquisa
Comece rastreando o caminho de --resume que lê os eventos da sessão em events.jsonl, concentrando-se na correspondência entre permission.requested e permission.completed por requestId; foi informado que command-history-state.json está limpo e não é a fonte. Reproduza um prompt interrompido e, em seguida, verifique se as solicitações órfãs são canceladas ou negadas ao retomar e não são mais reproduzidas em retomadas posteriores.
Escrita pelo modelo de indexação a partir do texto da issue.
Avaliação
- Stack de tecnologia
- shell
- Domínio
- cli
- Tipo de issue
- Bug
- Dificuldade
- 4/5
- Tempo estimado
- 3-5 dias
- Status de atividade
- Pouca atividade
- Clareza
- Razoavelmente clara
- Facilidade para iniciantes
- 48/100