From 03fed232f4a1cd9865b300190e8da60b9f62ac00 Mon Sep 17 00:00:00 2001 From: Seungpyo1007 Date: Wed, 5 Aug 2026 11:43:33 +0900 Subject: [PATCH] =?UTF-8?q?=EB=8F=99=EC=8B=9C=EC=97=90=20=EC=98=A8=20?= =?UTF-8?q?=EC=8A=B9=EC=9D=B8=20=EB=AC=BC=EC=9D=8C=EC=9D=B4=20=EC=95=9E?= =?UTF-8?q?=EC=9D=98=20=EC=8B=A4=ED=96=89=EC=9D=84=20=EC=98=81=EC=9B=90?= =?UTF-8?q?=ED=9E=88=20=EB=A7=A4=EB=8B=AC=EC=95=84=20=EB=91=90=EB=8D=98=20?= =?UTF-8?q?=EA=B2=83=EC=9D=84=20=EA=B3=A0=EC=B9=9C=EB=8B=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit askRunApproval 이 resolve 를 필드 하나(_askRunResolve)에 담고 있었다. 실행이 하나뿐일 때는 맞는데, 이 앱은 위임으로 여러 실행이 동시에 돈다 — 한 라운드에 delegate_task 가 둘이면 하위 실행 둘이 나란히 돌고, 둘 다 셸 명령이나 엔진 작업 승인을 물을 수 있다. 그때 뒤에 온 물음이 앞의 resolve 를 덮었다. 사용자가 답하면 뒤엣것만 풀리고 앞의 실행은 영원히 await 에 매달린다. 상태는 "편집 중" 인 채로 멈추고, 그 실행이 쥔 파일 락은 finally 에 도달하지 못해 풀리지 않으며, 화면에는 아무 말도 안 나온다. 그 파일을 건드리려는 다른 에이전트는 계속 "작업 중입니다" 만 듣는다. engine/askQueue.ts 로 줄을 세운다. 하나씩 보여 주고, 답한 것만 풀고, 다음을 올린다. 모든 물음은 정확히 한 번 풀린다. 에이전트 중지는 이제 보이는 물음뿐 아니라 줄에서 기다리는 그 에이전트의 물음까지 걷어낸다. 안 그러면 중지한 에이전트가 나중에 모달로 되살아나고 그 실행은 계속 매달려 있다. 전체 중지는 줄 전체를 거절로 푼다. 단위 9개 추가(963개 통과). 실제 앱 회귀 확인 5/5. --- ide/src/App.tsx | 37 ++++++++++---- ide/src/engine/askQueue.test.ts | 91 +++++++++++++++++++++++++++++++++ ide/src/engine/askQueue.ts | 60 ++++++++++++++++++++++ 3 files changed, 177 insertions(+), 11 deletions(-) create mode 100644 ide/src/engine/askQueue.test.ts create mode 100644 ide/src/engine/askQueue.ts diff --git a/ide/src/App.tsx b/ide/src/App.tsx index f1be11a..5506d5e 100644 --- a/ide/src/App.tsx +++ b/ide/src/App.tsx @@ -31,6 +31,7 @@ import { import { resolveRenameTarget, isMove } from "./engine/movePath"; import { applyProposal } from "./engine/editApply"; import { planRun, langFor, LANGS as RUN_LANGS } from "./engine/runFile"; +import { AskQueue } from "./engine/askQueue"; import { getRunOverride, getRunOverrides, setRunOverride } from "./settings"; import { emptyNav, push as navPush, back as navBack, forward as navForward, current as navCurrent, dropMissing as navDropMissing, type NavState } from "./engine/navHistory"; import { shouldProbeQuota } from "./engine/quotaPoll"; @@ -256,6 +257,9 @@ function accel(s: string): string { return [hasCtrl && "Ctrl", hasAlt && "Alt", hasShift && "Shift", key].filter(Boolean).join("+"); } +/** 승인 물음 하나. okLabel/cancelLabel 은 자리에 맞는 문구가 있을 때만 채운다(없으면 기본 허용/거부). */ +interface AskRunItem { command: string; rationale: string; agent: string; okLabel?: string; cancelLabel?: string } + interface S { statusKey: "idle" | "thinking" | "tool" | "review" | "stopped"; running: boolean; @@ -366,9 +370,9 @@ interface S { extAskPicked: number[]; /** validateInput 이 돌려준 문구. 있으면 확인을 막는다. */ extAskErr: string | null; - /** 실행 승인 대기 중인 명령 (수동 정책일 때) */ - /** 승인 대기. okLabel/cancelLabel 은 자리에 맞는 문구가 있을 때만 채운다(없으면 기본 허용/거부). */ - askRun: { command: string; rationale: string; agent: string; okLabel?: string; cancelLabel?: string } | null; + /** 실행 승인 대기 중인 명령 (수동 정책일 때). + * 줄 맨 앞의 하나만 화면에 있다 — 뒤엣것은 AskQueue 가 들고 기다린다. */ + askRun: AskRunItem | null; /** 제안 카드에서 diff 를 펼친 것 (id → true) */ openDiffs: Record; /** 트랜스크립트에서 펼친 도구 줄 */ @@ -2037,7 +2041,10 @@ export class App extends React.Component<{ playOpening?: boolean }, S> { private _runIds = new Set(); /** 백그라운드 서버 — 프리뷰 탭 rel → runId. 에이전트 중지와 수명을 분리한다. */ private _bgRuns = new Map(); - private _askRunResolve: ((ok: boolean) => void) | null = null; + /** 승인 물음은 줄을 세운다 — 위임으로 실행이 동시에 여럿 돌기 때문이다. + * 예전엔 resolve 를 필드 하나에 담아, 두 번째 물음이 첫 번째를 덮고 그 실행을 + * 영원히 await 에 매달아 뒀다(파일 락도 안 풀린 채로). askQueue.ts 에 사연이 있다. */ + private _askRunQueue = new AskQueue(); private _confirmResolve: ((ok: boolean) => void) | null = null; /** 인앱 확인 — window.confirm 을 대신한다. @@ -2147,14 +2154,19 @@ export class App extends React.Component<{ playOpening?: boolean }, S> { * labels 를 주면 버튼 문구를 갈아끼운다: 커밋 게이트처럼 "허용/거부" 가 어색한 자리를 위해. */ private askRunApproval(command: string, rationale: string, agent: string, labels?: { ok: string; cancel: string }): Promise { return new Promise(resolve => { - this._askRunResolve = resolve; - this.setState({ askRun: { command, rationale, agent, okLabel: labels?.ok, cancelLabel: labels?.cancel } }); + const item: AskRunItem = { command, rationale, agent, okLabel: labels?.ok, cancelLabel: labels?.cancel }; + this._askRunQueue.add(item, resolve); + // 이미 무언가 물어보는 중이면 화면은 그대로 두고 줄에서 기다린다. + this.setState({ askRun: this._askRunQueue.current() }); }); } private answerRun(ok: boolean) { - const r = this._askRunResolve; - this._askRunResolve = null; - this.setState({ askRun: null }, () => r?.(ok)); + // 화면을 먼저 비우고, 답을 푼 뒤, 다음 물음을 올린다 — 그래야 모달이 갈아끼워지는 + // 것이 보이고, 답한 것과 다음 것을 헷갈리지 않는다. + this.setState({ askRun: null }, () => { + const next = this._askRunQueue.answer(ok); + if (next) this.setState({ askRun: next }); + }); } /** 켤 때 잔여 할당량 조회 — 헤더는 요청을 보내야 오므로 1토큰짜리 최소 요청을 한 번 던진다. @@ -2394,7 +2406,9 @@ export class App extends React.Component<{ playOpening?: boolean }, S> { if (!runId) return; // 승인 대기는 abort 로 안 깨진다(answerRun 만 resolve 한다). 중지가 그걸 거절로 풀어주지 // 않으면 그 실행의 finally 가 영영 안 오고, running 이 모달을 답할 때까지 잡힌다. - if (this.state.askRun?.agent === id) this.answerRun(false); + // 보이는 것뿐 아니라 **줄에서 기다리는 것까지** 걷어낸다. 뒤에 서 있던 물음만 + // 남으면, 중지한 에이전트가 나중에 모달로 되살아나고 그 실행은 계속 매달려 있다. + this.setState({ askRun: this._askRunQueue.cancelWhere(a => a.agent === id) }); for (const [rel, holder] of [...this.fileLocks.entries()]) if (holder === runId) this.fileLocks.delete(rel); this.setAgent(id, { status: "stop", file: null }); // 인라인 편집·MCP 생성은 세지 않는다 — 예전엔 abortCtls.size 라 그것들까지 셌고, @@ -2412,7 +2426,8 @@ export class App extends React.Component<{ playOpening?: boolean }, S> { // 전역 중지 — 역할을 가리지 않고 전부(인라인 편집·MCP 생성 포함). // 레코드는 남긴다: 각 루프의 finally 가 finish() 로 자기 정리를 마무리한다. this.engine.runs.cancelAll(); - if (this._askRunResolve) this.answerRun(false); + this._askRunQueue.cancelAll(false); // 줄 전체를 거절로 풀어 준다 — 매달린 실행을 남기지 않는다 + this.setState({ askRun: null }); this.abortCtls.clear(); this.fileLocks.clear(); this.clearTimers(); diff --git a/ide/src/engine/askQueue.test.ts b/ide/src/engine/askQueue.test.ts new file mode 100644 index 0000000..24a7ef6 --- /dev/null +++ b/ide/src/engine/askQueue.test.ts @@ -0,0 +1,91 @@ +import { describe, it, expect } from "vitest"; +import { AskQueue } from "./askQueue"; + +/** 물음 하나를 세우고, 언제 어떻게 풀렸는지 볼 수 있게 감싼다. */ +function ask(q: AskQueue, item: T) { + const log: boolean[] = []; + q.add(item, ok => log.push(ok)); + return log; +} + +describe("AskQueue", () => { + it("먼저 온 물음을 보여 준다", () => { + const q = new AskQueue(); + ask(q, "a"); ask(q, "b"); + expect(q.current()).toBe("a"); + expect(q.size).toBe(2); + }); + + it("빈 줄에서는 보여 줄 것이 없다", () => { + const q = new AskQueue(); + expect(q.current()).toBeNull(); + expect(q.answer(true)).toBeNull(); + }); + + // 이것이 이 파일의 이유다. 예전 구현은 resolve 를 필드 하나에 담아서, 두 번째 + // 물음이 첫 번째의 resolve 를 덮었다 — 첫 실행은 영원히 await 에 매달렸다. + it("나중 물음이 앞의 물음을 덮어 매달아 두지 않는다", () => { + const q = new AskQueue(); + const a = ask(q, "a"); + const b = ask(q, "b"); + expect(q.answer(true)).toBe("b"); // a 에 답 → 다음은 b + expect(a).toEqual([true]); + expect(b).toEqual([]); // 아직 안 풀렸다(매달린 게 아니라 대기 중) + expect(q.answer(false)).toBeNull(); + expect(b).toEqual([false]); + }); + + it("답은 지금 보이는 물음에만 간다", () => { + const q = new AskQueue(); + const a = ask(q, "a"); + const b = ask(q, "b"); + q.answer(false); + expect(a).toEqual([false]); + expect(b).toEqual([]); + }); + + it("한 물음이 두 번 풀리지 않는다", () => { + const q = new AskQueue(); + const a = ask(q, "a"); + q.answer(true); + q.answer(true); // 빈 줄에 대고 답해도 a 를 다시 풀지 않는다 + q.cancelAll(); + expect(a).toEqual([true]); + }); + + it("그 에이전트의 물음만 걷어내고 나머지는 남긴다", () => { + const q = new AskQueue<{ agent: string }>(); + const a = ask(q, { agent: "A" }); + const b = ask(q, { agent: "B" }); + const a2 = ask(q, { agent: "A" }); + expect(q.cancelWhere(x => x.agent === "A")).toEqual({ agent: "B" }); + expect(a).toEqual([false]); + expect(a2).toEqual([false]); + expect(b).toEqual([]); + expect(q.size).toBe(1); + }); + + it("보이던 것이 아닌 뒤쪽만 걷히면 보이는 물음은 그대로다", () => { + const q = new AskQueue<{ agent: string }>(); + const a = ask(q, { agent: "A" }); + ask(q, { agent: "B" }); + expect(q.cancelWhere(x => x.agent === "B")).toEqual({ agent: "A" }); + expect(a).toEqual([]); + }); + + it("정리하면 매달린 물음이 하나도 남지 않는다", () => { + const q = new AskQueue(); + const logs = [ask(q, "a"), ask(q, "b"), ask(q, "c")]; + q.cancelAll(); + expect(q.size).toBe(0); + expect(q.current()).toBeNull(); + for (const l of logs) expect(l).toEqual([false]); + }); + + it("정리 값을 승인으로 줄 수도 있다(자율 모드 전환 등)", () => { + const q = new AskQueue(); + const a = ask(q, "a"); + q.cancelAll(true); + expect(a).toEqual([true]); + }); +}); diff --git a/ide/src/engine/askQueue.ts b/ide/src/engine/askQueue.ts new file mode 100644 index 0000000..4704f94 --- /dev/null +++ b/ide/src/engine/askQueue.ts @@ -0,0 +1,60 @@ +// 승인 요청 줄 세우기. +// +// askRunApproval 은 resolve 를 필드 하나(`_askRunResolve`)에 담고 있었다. 실행이 하나뿐일 +// 때는 맞는데, 이 앱은 위임으로 **여러 실행이 동시에 돈다.** 한 라운드에 delegate_task 가 +// 둘이면 하위 실행 둘이 나란히 돌고, 둘 다 셸 명령 승인을 물을 수 있다. +// +// 그때 뒤에 온 물음이 앞의 resolve 를 덮어썼다. 사용자가 답하면 **뒤엣것만** 풀리고, +// 앞의 실행은 영원히 await 에 매달린다 — 상태는 "편집 중" 인 채로 멈추고, 그 실행이 +// 쥔 파일 락은 finally 에 도달하지 못해 풀리지 않으며, 화면에는 아무 말도 안 나온다. +// 그 파일을 건드리려는 다른 에이전트는 계속 "작업 중입니다" 만 듣는다. +// +// 그래서 덮지 않고 줄을 세운다. 하나씩 보여 주고, 답한 것만 풀고, 다음을 올린다. +// 모든 물음은 정확히 한 번 풀린다 — 그게 여기서 지켜야 할 전부다. + +export interface Pending { + readonly item: T; + readonly resolve: (ok: boolean) => void; +} + +export class AskQueue { + private q: Pending[] = []; + + /** 물음을 줄 끝에 세운다. 앞엣것을 덮지 않는다. */ + add(item: T, resolve: (ok: boolean) => void): void { + this.q.push({ item, resolve }); + } + + /** 지금 보여 줄 물음. 없으면 null. */ + current(): T | null { + const head = this.q[0]; + return head ? head.item : null; + } + + get size(): number { return this.q.length; } + + /** 지금 보이는 물음에 답한다 → 다음 물음(없으면 null)을 돌려준다. */ + answer(ok: boolean): T | null { + const head = this.q.shift(); + head?.resolve(ok); + return this.current(); + } + + /** 조건에 맞는 물음을 전부 거절로 걷어낸다(예: 그 에이전트를 중지했을 때). + * 보이던 것이 걷혀 나갔을 수 있으므로 지금 보여 줄 물음을 돌려준다. */ + cancelWhere(pred: (item: T) => boolean, ok = false): T | null { + const keep: Pending[] = []; + const drop: Pending[] = []; + for (const p of this.q) (pred(p.item) ? drop : keep).push(p); + this.q = keep; + for (const p of drop) p.resolve(ok); + return this.current(); + } + + /** 전부 거절로 정리한다. 매달린 물음을 남기고 떠나지 않는다. */ + cancelAll(ok = false): void { + const all = this.q; + this.q = []; + for (const p of all) p.resolve(ok); + } +}