Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
37 changes: 26 additions & 11 deletions ide/src/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -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<string, boolean>;
/** 트랜스크립트에서 펼친 도구 줄 */
Expand Down Expand Up @@ -2037,7 +2041,10 @@ export class App extends React.Component<{ playOpening?: boolean }, S> {
private _runIds = new Set<string>();
/** 백그라운드 서버 — 프리뷰 탭 rel → runId. 에이전트 중지와 수명을 분리한다. */
private _bgRuns = new Map<string, string>();
private _askRunResolve: ((ok: boolean) => void) | null = null;
/** 승인 물음은 줄을 세운다 — 위임으로 실행이 동시에 여럿 돌기 때문이다.
* 예전엔 resolve 를 필드 하나에 담아, 두 번째 물음이 첫 번째를 덮고 그 실행을
* 영원히 await 에 매달아 뒀다(파일 락도 안 풀린 채로). askQueue.ts 에 사연이 있다. */
private _askRunQueue = new AskQueue<AskRunItem>();
private _confirmResolve: ((ok: boolean) => void) | null = null;

/** 인앱 확인 — window.confirm 을 대신한다.
Expand Down Expand Up @@ -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<boolean> {
return new Promise<boolean>(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토큰짜리 최소 요청을 한 번 던진다.
Expand Down Expand Up @@ -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 라 그것들까지 셌고,
Expand All @@ -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();
Expand Down
91 changes: 91 additions & 0 deletions ide/src/engine/askQueue.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,91 @@
import { describe, it, expect } from "vitest";
import { AskQueue } from "./askQueue";

/** 물음 하나를 세우고, 언제 어떻게 풀렸는지 볼 수 있게 감싼다. */
function ask<T>(q: AskQueue<T>, item: T) {
const log: boolean[] = [];
q.add(item, ok => log.push(ok));
return log;
}

describe("AskQueue", () => {
it("먼저 온 물음을 보여 준다", () => {
const q = new AskQueue<string>();
ask(q, "a"); ask(q, "b");
expect(q.current()).toBe("a");
expect(q.size).toBe(2);
});

it("빈 줄에서는 보여 줄 것이 없다", () => {
const q = new AskQueue<string>();
expect(q.current()).toBeNull();
expect(q.answer(true)).toBeNull();
});

// 이것이 이 파일의 이유다. 예전 구현은 resolve 를 필드 하나에 담아서, 두 번째
// 물음이 첫 번째의 resolve 를 덮었다 — 첫 실행은 영원히 await 에 매달렸다.
it("나중 물음이 앞의 물음을 덮어 매달아 두지 않는다", () => {
const q = new AskQueue<string>();
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<string>();
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<string>();
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<string>();
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<string>();
const a = ask(q, "a");
q.cancelAll(true);
expect(a).toEqual([true]);
});
});
60 changes: 60 additions & 0 deletions ide/src/engine/askQueue.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,60 @@
// 승인 요청 줄 세우기.
//
// askRunApproval 은 resolve 를 필드 하나(`_askRunResolve`)에 담고 있었다. 실행이 하나뿐일
// 때는 맞는데, 이 앱은 위임으로 **여러 실행이 동시에 돈다.** 한 라운드에 delegate_task 가
// 둘이면 하위 실행 둘이 나란히 돌고, 둘 다 셸 명령 승인을 물을 수 있다.
//
// 그때 뒤에 온 물음이 앞의 resolve 를 덮어썼다. 사용자가 답하면 **뒤엣것만** 풀리고,
// 앞의 실행은 영원히 await 에 매달린다 — 상태는 "편집 중" 인 채로 멈추고, 그 실행이
// 쥔 파일 락은 finally 에 도달하지 못해 풀리지 않으며, 화면에는 아무 말도 안 나온다.
// 그 파일을 건드리려는 다른 에이전트는 계속 "작업 중입니다" 만 듣는다.
//
// 그래서 덮지 않고 줄을 세운다. 하나씩 보여 주고, 답한 것만 풀고, 다음을 올린다.
// 모든 물음은 정확히 한 번 풀린다 — 그게 여기서 지켜야 할 전부다.

export interface Pending<T> {
readonly item: T;
readonly resolve: (ok: boolean) => void;
}

export class AskQueue<T> {
private q: Pending<T>[] = [];

/** 물음을 줄 끝에 세운다. 앞엣것을 덮지 않는다. */
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<T>[] = [];
const drop: Pending<T>[] = [];
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);
}
}
Loading