github / github/copilot-cli

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

未关闭
#4,259 0 条评论 0 个 reaction 已指派 0 人 在 GitHub 查看
area:permissions area:sessions
主要语言
Shell
星标
11.2k
派生
1.9k
平均合并
14 小时 16 分钟
30 天内合并 PR
6

描述

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

贡献指南

打开贡献指南

调研方向

首先跟踪从 events.jsonl 读取会话事件的 --resume 路径,重点检查 permission.requested 和 permission.completed 通过 requestId 进行配对的情况;据报告 command-history-state.json 是干净的,并不是问题来源。重现一个被中断的提示,然后验证孤立请求在恢复时会被取消或拒绝,并且在之后的恢复中不再重放。

由索引模型根据 Issue 内容生成。

评估

技术栈
shell
领域
cli
Issue 类型
缺陷
难度
4/5
预计耗时
3-5 天
活跃度
冷清
描述清晰度
基本清楚
新手友好度
48/100

把新 issue 发到你的邮箱

精选适合新手参与的 GitHub issue 摘要。