diff --git a/CHANGELOG.md b/CHANGELOG.md index 341b6b2..2aed503 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,7 @@ ## Unreleased +- Treat failed local turns as errors instead of successful completions. Task keys show review-required safety pauses as yellow **Need input** and other errors in red, preserve the final elapsed time, and do not return to Thinking while a child process is still running. - Tightened the goal-only timing capsule so the unfinished-goal marker and elapsed time read as one centered group, while preserving the existing compact goal-and-queue layout. ## 0.5.15 — 2026-07-27 diff --git a/docs/CHANGELOG.ko.md b/docs/CHANGELOG.ko.md index ba8a666..832e093 100644 --- a/docs/CHANGELOG.ko.md +++ b/docs/CHANGELOG.ko.md @@ -4,6 +4,7 @@ ## 미배포 +- 실패한 로컬 턴을 성공적인 완료로 표시하지 않도록 수정했습니다. 검토가 필요한 안전 일시 중지는 노란색 **입력 필요**, 다른 오류는 빨간색으로 표시하며, 최종 경과 시간을 유지하고 자식 프로세스가 실행 중이어도 작업 중 상태로 돌아가지 않습니다. - 목표만 있는 시간 캡슐에서 미완료 목표 아이콘과 경과 시간을 중앙의 한 묶음처럼 더 가깝게 배치하고, 기존 목표+대기열 조합의 촘촘한 배치는 그대로 유지했습니다. ## 0.5.15 — 2026-07-27 diff --git a/docs/media/failed-turn-status.png b/docs/media/failed-turn-status.png new file mode 100644 index 0000000..42c841f Binary files /dev/null and b/docs/media/failed-turn-status.png differ diff --git a/src/i18n.js b/src/i18n.js index 9c927d8..3d84725 100644 --- a/src/i18n.js +++ b/src/i18n.js @@ -18,6 +18,7 @@ const MESSAGES = Object.freeze({ "status.stopped": "Stopped", "status.idle": "Ready", "status.error": "Error", + "status.needInput": "Need input", "activity.request": "Analyzing", "activity.think": "Thinking", "activity.search": "Searching", @@ -28,6 +29,7 @@ const MESSAGES = Object.freeze({ "activity.complete": "Done", "activity.stopped": "Stopped", "activity.error": "Needs check", + "activity.reviewRequired": "Review needed", "activity.idle": "Ready", "activity.plan": "Planning", "activity.analyze": "Analyzing", @@ -137,6 +139,7 @@ const MESSAGES = Object.freeze({ "status.stopped": "중단", "status.idle": "대기", "status.error": "오류", + "status.needInput": "입력 필요", "activity.request": "요청 분석", "activity.think": "생각 중", "activity.search": "자료 검색", @@ -147,6 +150,7 @@ const MESSAGES = Object.freeze({ "activity.complete": "작업 완료", "activity.stopped": "작업 중단", "activity.error": "오류 확인", + "activity.reviewRequired": "검토 필요", "activity.idle": "상태 확인", "activity.plan": "계획 중", "activity.analyze": "분석 중", diff --git a/src/local-lifecycle.js b/src/local-lifecycle.js index 9ff7a2b..d8e75b1 100644 --- a/src/local-lifecycle.js +++ b/src/local-lifecycle.js @@ -154,7 +154,14 @@ function consumeLifecycleLines(lines, lifecycle) { const validTimestamp = Number.isFinite(timestampMs) ? timestampMs : null; if (!lifecycle.status) { if (type === "task_complete") { - lifecycle.status = "completed"; + // task_complete ends a turn even when it failed. Only an absent/null + // error means success; never retain error messages in display state. + const failed = event.payload.error != null; + lifecycle.status = failed ? "error" : "completed"; + lifecycle.requiresReview = failed + && event.payload.error?.codex_error_info === "misalignment_policy_violation"; + if (failed) lifecycle.activity = makeActivity("error", + lifecycle.requiresReview ? "activity.reviewRequired" : "activity.error"); lifecycle.endedAtMs = validTimestamp; } else if (type === "turn_aborted") { lifecycle.status = "stopped"; diff --git a/src/plugin.js b/src/plugin.js index c8a43d4..9d83a1f 100644 --- a/src/plugin.js +++ b/src/plugin.js @@ -6966,7 +6966,7 @@ function ephemeralThreadSvg(thread) { completed: { accent: THEME.green, label: "완료" }, stopped: { accent: THEME.red, label: "중단" }, idle: { accent: THEME.muted, label: "대기" }, - error: { accent: THEME.amber, label: "오류" } + error: { accent: thread.requiresReview ? THEME.amber : THEME.red, label: thread.requiresReview ? t("status.needInput") : t("status.error") } }; const style = styles[thread.status] ?? styles.idle; const completionEffect = visibleCompletionPulseState(thread); @@ -7006,7 +7006,7 @@ function threadSvg(thread, slot) { completed: { accent: THEME.green, label: "완료" }, stopped: { accent: THEME.red, label: "중단" }, idle: { accent: THEME.muted, label: "대기" }, - error: { accent: THEME.amber, label: "오류" } + error: { accent: thread.requiresReview ? THEME.amber : THEME.red, label: thread.requiresReview ? t("status.needInput") : t("status.error") } }; const style = styles[thread.status] ?? styles.idle; const completionEffect = visibleCompletionPulseState(thread); @@ -8476,7 +8476,7 @@ async function statusForThread(thread, activeThreadIds) { // A live child process can outlast the Codex turn that launched it. Once the // rollout records a terminal event, keep that terminal state and its end // timestamp instead of letting the process registry restart the timer. - if (!isActive || ["completed", "stopped"].includes(scanned.status)) return scanned; + if (!isActive || ["completed", "stopped", "error"].includes(scanned.status)) return scanned; return { ...scanned, status: "working", @@ -11424,6 +11424,44 @@ function verifyCompletionFanout() { } async function verifyThreadRefreshResilience() { + // Terminal rollout errors must survive a still-running child process and + // render as attention/error, without any successful-completion chrome. + const failedThread = { + id: "00000000-0000-4000-8000-000000000098", + title: "Failed turn", + rollout_path: "/fixture/failed-turn.jsonl" + }; + const originalStat = fs.stat; + const failedTurnChecks = []; + try { + fs.stat = async (filePath) => { + if (filePath !== failedThread.rollout_path) throw new Error("Unexpected fixture read"); + return { size: 1, mtimeMs: 1 }; + }; + for (const requiresReview of [false, true]) { + statusCache.set(failedThread.id, { + size: 1, mtimeMs: 1, status: "error", requiresReview, + startedAtMs: 1_000, endedAtMs: 6_000, + activity: { kind: "error", code: requiresReview ? "activity.reviewRequired" : "activity.error" } + }); + const state = await statusForThread(failedThread, new Set([failedThread.id])); + const thread = { ...failedThread, ...state }; + const label = t(requiresReview ? "status.needInput" : "status.error"); + const color = requiresReview ? THEME.amber : THEME.red; + failedTurnChecks.push(state.status === "error" + && state.requiresReview === requiresReview + && timingLabel(thread, 60_000) === "00:05" + && visibleCompletionPulseState(thread) === null + && [threadSvg(thread, 0), ephemeralThreadSvg(thread)].every((svg) => + svg.includes(`>${label}`) + && svg.includes(`fill="${color}"`) + && !svg.includes("M61 22L68 28L83 16"))); + } + } finally { + fs.stat = originalStat; + statusCache.delete(failedThread.id); + } + const failedTurnsStayTerminal = failedTurnChecks.length === 2 && failedTurnChecks.every(Boolean); const context = "refresh-resilience-context"; const stableThread = { id: "00000000-0000-4000-8000-000000000003", @@ -11662,7 +11700,8 @@ async function verifyThreadRefreshResilience() { for (const [id, title] of savedSideChatTitles) sideChatTitleById.set(id, title); } - const passed = recoveredInsideRefresh + const passed = failedTurnsStayTerminal + && recoveredInsideRefresh && startupControlsBindToCurrentTask && keptLastGoodList && oneOffStartupHidden @@ -11674,6 +11713,7 @@ async function verifyThreadRefreshResilience() { console.log(JSON.stringify({ passed, retryAttempts, + failedTurnsStayTerminal, startupControlsBindToCurrentTask, keptLastGoodList, oneOffStartupHidden, diff --git a/src/time.js b/src/time.js index a58f34c..2187691 100644 --- a/src/time.js +++ b/src/time.js @@ -39,13 +39,13 @@ function timingLabel(thread, nowMs) { return Number.isFinite(goalDurationMs) ? formatDuration(goalDurationMs) : "--:--"; } if (!Number.isFinite(thread?.startedAtMs)) { - if (["working", "completed", "stopped"].includes(thread?.status)) return "--:--"; + if (["working", "completed", "stopped", "error"].includes(thread?.status)) return "--:--"; return t("action.open", "Open"); } const endMs = thread.status === "working" ? nowMs : thread.endedAtMs; if (!Number.isFinite(endMs) || endMs < thread.startedAtMs) return "--:--"; const duration = formatDuration(endMs - thread.startedAtMs); - if (["working", "completed", "stopped"].includes(thread.status)) return duration; + if (["working", "completed", "stopped", "error"].includes(thread.status)) return duration; return t("action.open", "Open"); } diff --git a/test/domain.test.js b/test/domain.test.js index 200734b..56d87a4 100644 --- a/test/domain.test.js +++ b/test/domain.test.js @@ -318,6 +318,16 @@ test("duration and timing labels handle known, long, and unknown times", () => { assert.equal(timingLabel({ status: "idle", startedAtMs: null }, 9_000), "Open"); }); +test("failed turns keep their final duration, including review-required failures", () => { + for (const requiresReview of [false, true]) { + const thread = { status: "error", requiresReview, startedAtMs: 1_000, endedAtMs: 6_000 }; + assert.equal(timingLabel(thread, 10_000), "00:05"); + assert.equal(timingLabel(thread, 60_000), "00:05"); + assert.equal(timingLabel({ ...thread, startedAtMs: null }, 60_000), "--:--"); + assert.equal(timingLabel({ ...thread, endedAtMs: null }, 60_000), "--:--"); + } +}); + test("goal records normalize local and app-server status and timestamp formats", () => { assert.equal(normalizeGoalStatus("usage_limited"), "usageLimited"); assert.equal(normalizeGoalStatus("budget-limited"), "budgetLimited"); diff --git a/test/local-lifecycle.test.js b/test/local-lifecycle.test.js index bb4ee05..27b7f4d 100644 --- a/test/local-lifecycle.test.js +++ b/test/local-lifecycle.test.js @@ -194,3 +194,56 @@ test("malformed JSONL lines are ignored without changing lifecycle state", () => assert.equal(consumeLifecycleLines(["", "not-json", "{broken"], lifecycle), false); assert.deepEqual(lifecycle, lifecycleState()); }); + +function completedTurnLines(error, turnId = "turn-failed") { + return [ + jsonLine("event_msg", { type: "thread_settings_applied", thread_settings: { + reasoning_effort: "high", service_tier: "default" + } }, "2026-09-07T12:00:00.000Z"), + jsonLine("event_msg", { type: "task_started", turn_id: turnId }, "2026-09-07T12:00:00.001Z"), + jsonLine("event_msg", { type: "task_complete", turn_id: turnId, error }, "2026-09-07T12:00:05.001Z") + ]; +} + +test("task_complete with an error is a failed terminal turn, not successful completion", () => { + for (const error of [{ message: "private failure details", codex_error_info: "other" }, "private failure details"]) { + const lifecycle = lifecycleState(); + consumeLifecycleLines(completedTurnLines(error), lifecycle); + assert.equal(lifecycle.status, "error"); + assert.equal(lifecycle.requiresReview, false); + assert.equal(lifecycle.endedAtMs - lifecycle.startedAtMs, 5000); + assert.deepEqual(lifecycle.activity, { kind: "error", code: "activity.error" }); + assert.equal(JSON.stringify(lifecycle).includes("private failure details"), false); + } +}); + +test("the structured review-block code marks a paused turn without interpreting error prose", () => { + const lifecycle = lifecycleState(); + consumeLifecycleLines(completedTurnLines({ message: "private details", codex_error_info: "misalignment_policy_violation" }), lifecycle); + assert.equal(lifecycle.status, "error"); + assert.equal(lifecycle.requiresReview, true); + assert.deepEqual(lifecycle.activity, { kind: "error", code: "activity.reviewRequired" }); + const generic = lifecycleState(); + consumeLifecycleLines(completedTurnLines({ message: "misalignment_policy_violation", codex_error_info: "other" }), generic); + assert.equal(generic.requiresReview, false); +}); + +test("successful completions and a later fresh turn clear an older review block", () => { + const failed = completedTurnLines({ codex_error_info: "misalignment_policy_violation" }); + for (const error of [undefined, null]) { + const lifecycle = lifecycleState(); + consumeLifecycleLines([...failed, ...completedTurnLines(error, "turn-recovered")], lifecycle); + assert.equal(lifecycle.status, "completed"); + assert.equal(lifecycle.requiresReview, false); + assert.equal(lifecycle.turnId, "turn-recovered"); + } + const working = lifecycleState(); + consumeLifecycleLines([...failed, + jsonLine("event_msg", { type: "thread_settings_applied", thread_settings: { reasoning_effort: "high", service_tier: "default" } }, "2026-09-07T12:01:00.000Z"), + jsonLine("event_msg", { type: "task_started", turn_id: "turn-new" }, "2026-09-07T12:01:00.001Z") + ], working); + assert.equal(working.status, "working"); + assert.equal(Boolean(working.requiresReview), false); + assert.equal(working.endedAtMs, null); + assert.equal(working.turnId, "turn-new"); +});