Recipes

Preflight 도구 호출 카운팅 수정: 늦은 게시물 수정, 프로세스별 카운트된 id, 더 긴 TTL

README 링크에서 발견 · today

Research & KnowledgeSoftware & DevOps

각 도구 호출이 재시작 전반에 걸쳐 정확히 한 번만 실제 결과와 함께 집계되도록 하세요

What they did

작성자는 newrelic-experimental/preflight의 이슈 #1040을 수정했습니다. 여기서 도구 호출이 두 번 집계되거나, 잘못된 세션에 귀속되거나, 아예 집계되지 않을 수 있었습니다. 작성자는 TLA+ 모델인 HookDrainPairing.tla에서 시작했으며, 이 모델은 호출을 이중 집계하거나 잘못 귀속시키는 네 가지 인터리빙을 발견했고, 각각을 upstream/main 6d173ace를 대상으로 실제 모듈에서 재현했습니다. 수정은 일곱 차례의 개정을 거쳤고, 각 개정은 리뷰되었으며 회귀가 발견되어 수정되었습니다. 첫 번째 개정은 늦게 도착한 post를 버리고 성공을 영구 타임아웃으로 바꿨습니다. 이후 라운드에서는 승인된 긴 Bash 호출이 거부된 것으로 집계되는 문제, 세션 id가 없는 post가 페어링에 실패하는 문제, 반복되거나 매칭되지 않는 amend가 성공률을 1 위로 밀어올리는 문제, 재시작 시 id가 메모리에서 사라졌거나 디스크상 최신 256개보다 오래된 늦은 post를 다시 집계하는 문제를 수정했습니다. 최종 설계는 다음과 같습니다. 늦은 post는 src/storage/types.ts의 onRecordAmend와 amendRecord()를 통해 자신의 pre가 방출한 레코드를 수정하므로, outcome, duration, error type은 post에서 오고 activeSkill 같은 intake 필드는 유지됩니다. SessionTracker는 레코드 id로 수정하며, 성공 및 오류 카운트, toolErrorsByType, Bash 종료 코드, 타임라인 항목을 모두 포괄하는 단일 applyOutcome() 함수를 통해 처리합니다. TTL은 늘어났습니다. Bash pend는 11분, Agent와 Task는 30분이며, 여기에 5분의 프롬프트 대기가 추가됩니다. shutdown()은 최종 persistSession() 전에 이벤트 프로세서를 중지하므로 진행 중인 호출은 타임아웃으로 저장됩니다. 집계된 id는 persistCountedIdsOfSavedSessions()를 통해 해당 레코드가 저장된 후에만 디스크에 도달하며, 각 프로세스는 counted/<sessionId>.<pid>.json을 작성하고 리더들은 그 합집합을 취합니다. 스토어 교체 시 채택된 세션의 pending pre는 유지되고, 다른 모든 세션의 pending pre는 각자의 세션 아래에서 타임아웃으로 방출됩니다. 두 번째 TLA+ 모델인 PendingEngineCounting.tla가 그 결과를 검증했습니다.

How it went

PR에는 각 신규 테스트가 이전 리비전에서는 실패하고 이 브랜치에서는 통과한다는 테스트 계획이 포함되어 있습니다: 31분 후에 수정된 Agent pre가 onWorkflowAgent에 timeout으로 도달한 뒤 success로 처리되는 경우, store 교체 시 다른 세션의 pre가 하나의 timeout으로 방출되면서 자신의 Bash 호출은 하나의 4000 ms success로 페어링되는 경우, 4분짜리 프롬프트 후 승인되어 8분간 실행된 Bash 호출이 720000 ms에서 success로 집계되는 경우, 그리고 재시작된 processor가 이미 집계된 호출의 늦은 post를 드롭하는 경우입니다. PendingEngineCounting.tla는 알려진 #980 two-writer 제한을 제외한 모든 safety 규칙과 모든 quiescence 규칙을 담고 있습니다. 작성자는 pending-engine 변경을 stacked #1048로 옮겨 PR을 크기 예산 내로 유지했습니다.

Worth knowing

설계상 몇 가지 간극이 남아 있습니다. 재시작이나 다른 세션의 호출이 store 교체 시점에 진행 중인 호출은 실제 결과가 아니라 타임아웃으로 한 번 집계됩니다. 거부된 Bash 호출은 5분이 아니라 16분 후에 집계되는데, 프로세서가 승인되어 실행 중인 호출과 거부된 호출을 구분할 수 없기 때문입니다. 세션 쓰기가 실패해도 id가 집계된 것으로 표시될 수 있어, 디스크가 가득 찬 뒤 크래시가 나면 늦은 post를 누락시키고 0으로 집계합니다. 누락된 post는 장시간 실행되는 호출을 전체 TTL 동안 pending 상태로 유지하므로, 그 구간에서 API 실패가 발생하면 실행 중인 도구가 없는데도 duringToolExecution으로 읽힙니다. toolUseId가 없는 늦은 post는 해당 도구의 가장 오래된 타임아웃된 pre를 수정하는데, 이는 잘못된 호출에 적용될 수 있습니다. New Relic AiToolCall 이벤트는 수정되지 않으며, OTel span, TaskDetector, LatencyTracker, RetryDetector, TurnTracker, 비용 귀속, 알림 윈도우, QualityProxyTracker는 타임아웃 기록을 유지합니다. pending 엔진은 #1048 전까지 여전히 drained 호출을 main과 마찬가지로 집계합니다.

Wiring

preflight repo의 hook 이벤트 파이프라인에서 작업이 이루어진다. HookEventProcessor는 tool call이 시작될 때 pre record를 emit하고, 이미 timeout 또는 rejected로 emit된 pre에 대한 늦은 post가 들어오면 이전 record id로 onRecordAmend를 발생시킨다. amendRecord()는 src/storage/types.ts에서 ToolCallRecord 옆에 위치하며 post를 저장된 record에 병합한다. LocalSessionAggregator와 MCP toolCallBuffer는 record를 제자리에서 교체하고, amend된 Agent call은 WorkflowRunTracker와 subagent type fallback에도 도달한다. SessionTracker는 cap(DEFAULT_MAX_PENDING, event processor와 일치)으로 생성되며 amend 가능한 timed-out 및 rejected record를 유지하고 applyOutcome()을 통해 변경을 적용한다. TTL은 tool별로 구성된다: Bash 11분, Agent와 Task 30분, 그리고 5분의 prompt wait. shutdown()은 persistSession() 전에 event processor의 stop을 호출한다. persistSession()은 각 session 저장 직후 persistCountedIdsOfSavedSessions(sessionIds)를 호출하며, 각 process는 counted/<sessionId>.<pid>.json을 작성하고 lookup 시 union read와 일일 GC를 수행한다. replaceStore()는 채택된 session의 pre를 pending으로 유지하고 다른 session의 pre는 shutdown-flush 경로를 통해 timeout으로 flush한다. 검증은 TLA+ 모델 HookDrainPairing.tla와 PendingEngineCounting.tla(pec-fix2-*)를 vitest suite event-processor.test.ts 및 local-session-aggregator.test.ts와 함께 실행한다.

Try it yourself

I can't do this task as specified. Let me explain why, and what I can offer instead.

## Why I can't complete this

**1. I don't have access to the repository.** You've described a specific codebase (`src/storage/types.ts`, `HookEventProcessor`, `SessionTracker`, `HookDrainPairing.tla`, `PendingEngineCounting.tla`, `replaceStore()`, `shutdown()`, etc.), but no code was provided. I can't read the TLA+ models, can't list the interleavings that break `EachCallCountedOnce`, can't write tests that fail on the previous revision, and can't run the models. Producing any of that would be fabrication.

**2. The request is a multi-file refactor with correctness guarantees.** Items (1)–(6) touch concurrency, persistence ordering, TTL semantics, and cross-process coordination. Getting these right requires seeing the actual invariants, the existing state machine, and how `pending`/`counted` are currently threaded through. Guessing at signatures and merge semantics would produce plausible-looking code that almost certainly violates the very invariant you're trying to fix.

**3. "Run the TLA+ models to confirm" is not something I can do.** I have no execution environment here, and even with one, confirming quiescence and safety requires the actual model files and a TLC run whose output I'd need to see.

## What I can do if you paste the code

If you share the relevant files, I can work through this concretely:

- **Read `HookDrainPairing.tla` and `PendingEngineCounting.tla`** and enumerate the specific interleavings that violate `EachCallCountedOnce` — e.g. drain-before-record, record-before-drain with a stale session id, double-drain on retry, amend-after-count, cross-session pending adoption at `replaceStore()`, and the counted-file write racing the session save.
- **Design `onRecordAmend` / `amendRecord`** with an explicit field-merge table (post wins on `outcome`, `duration`, `errorType`; intake wins on `activeSkill` and other intake fields), and specify idempotency (amend must be a no-op if the record was already amended or already counted).
- **Refactor `SessionTracker`** around a single `applyOutcome(record)` that is the only writer of `success`, `error`, `toolErrorsByType`, Bash exit codes, and the timeline entry — with a guard so a second application for the same id is rejected.
- **TTL changes** (Bash 11 min, Agent/Task 30 min, +5 min prompt wait) with a note on how the prompt wait composes with the drain deadline.
- **Shutdown ordering**: stop the processor, then `persistSession()`, then write `counted/<sessionId>.<pid>.json`; readers take the union across pids.
- **`replaceStore()` semantics**: adopted session's pending stays pending; every other session's pending is emitted as a timeout under its own session id.
- **Tests** that fail on the prior revision for each path (double-count, wrong-session, zero-count, amend-after-count, cross-session adoption, counted-file union).
- **TLA+ guidance**: which invariants to check (`EachCalledCountedOnce`, quiescence), what to add to the model to capture the amend path and the counted-file union, and how to run TLC — but you'd run it and paste the output for me to interpret.

Paste the files (or the relevant excerpts) and I'll start with the interleaving analysis, since that determines the shape of everything else.

Source

English

Tools in this stack