Skip to content

[1.90] v1 run reducer — seq·ID 중복 제거와 terminal 불변 - #308

Merged
chowon442 merged 2 commits into
devfrom
worktree-task-1.90
Oct 20, 2026
Merged

chowon442 merged 2 commits into
devfrom
worktree-task-1.90

Conversation

@chowon442

@chowon442 chowon442 commented Oct 19, 2026 •

Copy link
Copy Markdown
Member

📌 관련 이슈

🏷️ PR 타입

  • ✨ 기능 추가 (Feature)
  • ✅ 테스트 추가 (Test)

📝 작업 내용

  • src/features/run/model/에 browser-run-v1 v1-only 순수 reducer·selector 추가 (React·네트워크 의존 없음, 1.88 공용 타입만 사용·변경 없음)
  • 입력 3종을 같은 runReducer로 처리: stream frame, 대화 조회 conversation(ConversationPage 초기화), heartbeat(no-op)
  • frame은 같은 runId이고 seq > lastSeq일 때만 반영 → 같은 event id 재전달·겹친 replay 무시. 마지막으로 받은 frame id를 cursor로 노출(내용을 무시한 frame도 cursor는 올림, heartbeat는 cursor 아님)
  • 대화 조회(conversation)는 cursor를 당기지 않고(이미 cursor가 있으면 유지) status를 PENDING→RUNNING→CANCELING으로만 옮긴다. resume: "replay"(기본)는 cursor 없이 seq 1부터 받아 Activity까지 복원, "cursor"는 activeRun.lastSeq를 cursor로 둔다
  • run 상태: run.created(PENDING) → activity/page frame(RUNNING) → run.canceling(CANCELING, 되돌리지 않음) → terminal. terminal 뒤 모든 입력은 같은 state 참조를 돌려준다
  • Activity: 처음 등장 순서 고정, 같은 ID 갱신·새 ID 누적, outcome(completed/failed/canceled) 뒤 이벤트 무시, started 없는 후속 이벤트 무시(S6). terminal 시점에 열린 Activity는 저장값은 그대로 두고 selectActivities가 run 결과로 닫아 closedByRun: true로 투영(1.80 메모)
  • canonical 응답은 run.completed payload(또는 대화 조회 COMPLETED message)에서만. response.page.*는 id·pageIndex·presentation·completed만 저장(제목·본문 없음). verification 진행은 stepProgress {checked,total}만
  • 실패(code·reason·message·recoverable)·취소(cause·message) 노출

state 인터페이스 (run_state.ts)

interface RunState {
  runId: Uuid | null;
  status: PublicRunStatus | null;      // 입력 전 null
  identity: RunIdentity;               // requestId·noteId·userMessageId·assistantMessageId·requestedTasks
  lastSeq: Seq;                        // 0 = 없음
  cursor: EventId | null;              // `{runId}:{lastSeq}` = Last-Event-ID
  activityOrder: string[];             // 처음 등장 순서
  activities: Record<string, ActivityEntry>;
  pages: ResponsePageBoundary[];       // { responsePageId, pageIndex, presentation, completed }
  response: CanonicalResponse | null;  // { assistantMessageId, responseDisposition, clarificationReason, assistantResponse, verificationSummary }
  failure: RunFailureInfo | null;      // { code, reason, message, recoverable }
  cancel: RunCancelInfo | null;        // { cause, message }
}

type RunAction =
  | { type: "frame"; frame: BrowserFrame }
  | { type: "conversation"; page: ConversationPage; runId?: Uuid; resume?: "replay" | "cursor" }
    // 대상 run: runId → 추적 중 state.runId → activeRun. resume 기본 "replay" (cursor 없음)
  | { type: "heartbeat" };

interface ActivityEntry {
  activityId; kind: ActivityKind; label; responsePageId: string | null;
  phase: "running" | "completed" | "failed" | "canceled";
  progressStatus: ActivityStatus | null;   // in_progress·waiting·retrying
  message: string | null; methodLabel: string | null;
  stepProgress: StepProgress | null;       // verification n/m
  pageProgress: { processedPages; totalPages } | null;
  targets: PageTarget[]; reason; assetOutcome; matchStatus; limitedPages: LimitedPage[];
  durationMs: number | null; failureCode: ActivityFailureCode | null;
  startedSeq: Seq; updatedSeq: Seq;
}
interface ActivityView extends ActivityEntry { closedByRun: boolean }

reducer·selector (run_reducer.ts, run_selectors.ts, barrel @/features/run/model)

createInitialRunState(): RunState
runReducer(state: RunState, action: RunAction): RunState
reduceFrames(frames: readonly BrowserFrame[], state?: RunState): RunState
isTerminalRunStatus(status): boolean

// selectActivities는 state별 memo(같은 state → 같은 배열), 바뀌지 않은 Activity는 같은 view 객체
selectRunStatus · selectIsTerminal · selectIsActive(PENDING·RUNNING·CANCELING) · selectIsCanceling
selectCanCancel(PENDING·RUNNING) · selectCursor · selectActivities(): ActivityView[]
selectPageBoundaries · selectResponse(COMPLETED만) · selectFailure · selectCancel

검증

  • pnpm test -- src/features/run/model: 147 passed (리뷰 반영 후) — fixture 10개 각각 처음부터 재생, 모든 중간 cursor 재개(겹침 포함), 중복 주입, 진행 중 재전달 시 같은 참조, terminal 뒤 불변, activeRun.lastSeq 초기화 후 이어 받기 / replay-source-unavailable의 replay·live는 available 외 동일 / 대화 조회 fixture 3개(active-run cursor = cursor 판정표 active lastDurableSeq 4, terminal message 결과 = 같은 run stream 결과) / 합성 frame으로 같은 ID 갱신·새 ID 누적·outcome 불변·열린 Activity 투영 3종 / run.completed 전 state에 본문·제목·plainText 없음, 퍼센트·delta·candidate·token 키 없음
  • seq 가드를 빼면 10개 테스트가 실패하는 것을 확인(mutation probe)
  • eslint src/features/run, tsc -p tsconfig.test.json --noEmit, pnpm run build 통과

✅ 체크리스트

공통

  • 셀프 리뷰를 완료했습니다
  • 코드 리뷰를 받을 준비가 완료되었습니다
  • pnpm tsc --noEmit 타입 체크를 통과했습니다
  • 브랜치명 컨벤션을 준수했습니다 (type/이슈번호-작업명) — 오케스트레이션 지정 브랜치 worktree-task-1.90 사용

📎 기타 참고사항

  • 재진입(1.93): 기본 resume: "replay"로 대화 조회를 넣으면 cursor가 null이고, cursor 없이 GET /events를 열어 seq 1부터 받으면 Activity·page까지 처음부터 받은 결과와 같습니다(spec 08:18, 테스트). resume: "cursor"는 activeRun.lastSeq부터 받으므로 그 앞에서 시작한 Activity는 복원되지 않습니다(대화 조회에 Activity 없음).
  • stream 수신 중 refetch(1.93): 대화 조회에 그 run의 terminal assistant message가 있으면 reducer는 바로 terminal로 확정하고, 그 뒤 오는 frame(run.completed 포함)은 무시합니다. 수신 중에는 terminal message가 담긴 refetch를 reducer에 넣지 않거나, stream이 끊겼을 때만 넣으세요.
  • 진행 중 refetch(focus·polling)를 같은 state에 넣어도 cursor·받는 중인 frame에 영향이 없습니다(리뷰 1회차 차단 [Chore] 프로젝트 초기 설정 #1 회귀 테스트).
  • 대화 조회의 terminal message로 terminal state를 만들 때 lastSeq·cursor는 기존 값을 유지합니다(빈 state면 0·null). terminal이라 재접속하지 않습니다.
  • "정정된 풀이" 판단, v2 토큰 누적, 문자열 fallback은 만들지 않았습니다.

🤖 Generated with Claude Code

chowon442 and others added 2 commits October 20, 2026 10:26
… 불변)

- frame과 대화 조회 초기화를 같은 순수 reducer로 처리한다(React·네트워크 독립)
- seq > lastSeq만 반영, 마지막 처리 frame id를 재접속 cursor로 노출, heartbeat는 no-op
- Activity는 처음 등장 순서·ID당 하나, terminal outcome 불변, terminal 시점 열린 Activity는 selector에서 run 결과로 투영
- canonical 응답은 run.completed(또는 대화 조회 COMPLETED message)에서만 만들고 page는 경계·순서만 둔다
- fixture 10개 시나리오: 처음부터·중간 cursor·중복 주입·대화 조회 초기화, replay/live 비교

Closes #300

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
- 대화 조회 conversation 액션: 이미 cursor가 있으면 lastSeq·cursor를 바꾸지 않고 status는 PENDING→RUNNING→CANCELING으로만 옮긴다
- resume 옵션 추가(기본 replay: cursor 없이 seq 1부터 받아 Activity 복원, cursor: activeRun.lastSeq 사용)
- 대상 run을 runId → 추적 중 run → activeRun 순서로 정해 run 종료 뒤 refetch가 terminal message를 반영한다
- 내용을 무시한 frame도 lastSeq·cursor는 올린다(마지막으로 받은 frame id)
- activity terminal에서 progressStatus를 지우고 message를 terminal payload 값으로 바꾼다
- selectActivities를 state·Activity 단위로 memo하고, selectIsActive가 ACTIVE_RUN_STATUSES를 쓴다

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@chowon442
chowon442 merged commit 509028a into dev Oct 20, 2026
1 check passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

[Feat] v1 run reducer — seq·ID 중복 제거와 terminal 불변 [1.90]

1 participant