`--resume` replays orphaned permission.requested events (no matching permission.completed) after mid-prompt process death
- Dominant language
- Shell
- Stars
- 11.2k
- Forks
- 1.9k
- Avg merge
- 14h 16m
- Merged PRs (30d)
- 6
Description
### 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.
Contributor guide
Research direction
Start by tracing the --resume path that reads the session's events.jsonl, focusing on permission.requested and permission.completed requestId pairing; command-history-state.json is reported clean and is not the source. Reproduce an interrupted prompt, then verify that orphaned requests are cancelled or denied on resume and no longer replay on later resumes.
Written by the indexing model from the issue text.
Assessment
- Tech stack
- shell
- Domain
- cli
- Issue type
- Bug
- Difficulty
- 4/5
- Estimated time
- 3-5 days
- Activity status
- Quiet
- Clarity
- Mostly clear
- Newbie friendliness
- 48/100