Skip to content

--resume replays orphaned permission.requested events (no matching permission.completed) after mid-prompt process death #4259

Description

@bradrlaw

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

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.

SESSION_DIR="$HOME/.copilot/session-state/<your-session-uuid>"
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:

# inuse.<pid>.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.

Metadata

Metadata

Assignees

No one assigned

    Labels

    area:permissionsTool approval, security boundaries, sandbox mode, and directory restrictionsarea:sessionsSession management, resume, history, session picker, and session state

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions