makdoong2-team 2.1.0 → 2.3.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +48 -3
- package/agents/makdoong2-team-leader.md +42 -1
- package/agents/makdoong2-verifier.md +1 -1
- package/bin/cli.js +0 -1
- package/bin/cli.ts +0 -1
- package/dist/opencode-plugin.js +69 -28
- package/dist/poll-sub-session.d.ts +1 -0
- package/dist/poll-sub-session.js +101 -13
- package/dist/verifier-verdict.d.ts +62 -0
- package/dist/verifier-verdict.js +110 -0
- package/package.json +6 -6
- package/postinstall.mjs +0 -2
- package/references/pr-template.md +2 -1
- package/scripts/install-lib.mjs +0 -2
- package/scripts/install-lib.mts +0 -2
- package/scripts/model-policy.mjs +0 -2
- package/scripts/model-policy.mts +0 -2
- package/scripts/run-tests.mts +3 -4
- package/scripts/smoke-test.mts +0 -1
- package/scripts/test-postinstall.mts +0 -1
- package/stages/08-pr.md +27 -0
- package/scripts/run-tests.mjs +0 -155
- package/scripts/smoke-test.mjs +0 -178
- package/scripts/test-postinstall.mjs +0 -144
package/README.md
CHANGED
|
@@ -250,7 +250,52 @@ Resolved chain (primary → fallbacks):
|
|
|
250
250
|
|
|
251
251
|
---
|
|
252
252
|
|
|
253
|
-
## 5.
|
|
253
|
+
## 5. 개발 환경과 빌드
|
|
254
|
+
|
|
255
|
+
이 저장소에는 **소스 파일만 커밋한다.** 손으로 쓴 JavaScript 도, 커밋된 빌드
|
|
256
|
+
산출물도 없다 — 구현은 TypeScript(`.ts`/`.mts`)와 셸(`.sh`)이고, `.js`/`.mjs`
|
|
257
|
+
와 `dist/` 는 소스에서 생성되며 `.gitignore` 대상이다.
|
|
258
|
+
|
|
259
|
+
### 요구 도구
|
|
260
|
+
|
|
261
|
+
| 도구 | 버전 | 용도 |
|
|
262
|
+
|---|---|---|
|
|
263
|
+
| **Node.js** | **≥ 22.18** | 진입점·테스트 실행 (네이티브 type-stripping). 클라이언트 환경은 node 24. |
|
|
264
|
+
| npm | Node 동봉 | 스크립트·의존성 |
|
|
265
|
+
| TypeScript | `devDependencies` 고정 (5.9.x) | 플러그인·진입점 빌드, 타입 검사 |
|
|
266
|
+
| jq, git | 시스템 | 런타임(state.sh·게이트) |
|
|
267
|
+
| tmux | ≥ 3.0 | 서브에이전트 pane (선택) |
|
|
268
|
+
| docker | 선택 | 비-Linux 호스트의 Ubuntu 교차 검증 |
|
|
269
|
+
|
|
270
|
+
> **왜 node ≥ 22.18 인가.** 개발 중에는 CLI(`bin/cli.ts`)·`postinstall.mts`·
|
|
271
|
+
> 테스트(`test/*.test.ts`)를 컴파일하지 않고 `node`가 확장자를 보고 타입을 지워
|
|
272
|
+
> 소스를 바로 실행한다(22.18/23.6부터 기본 활성). 배포된 패키지는 `node_modules`
|
|
273
|
+
> 안에서 실행되는데 node 는 거기서는 type-stripping 을 하지 않으므로, `bin`·
|
|
274
|
+
> `postinstall`은 배포 시 빌드된 `.js`가 실행된다. 플러그인 런타임은 opencode(Bun)가
|
|
275
|
+
> `dist/`를 로드하므로 이 하한과 무관하다.
|
|
276
|
+
|
|
277
|
+
### 빌드 — 개발엔 불필요, 배포 시 자동
|
|
278
|
+
|
|
279
|
+
```bash
|
|
280
|
+
npm run build # src/** → dist/** (opencode 플러그인. tsc)
|
|
281
|
+
npm run build:entry # 진입점 4개 → bin/cli.js · postinstall.mjs · scripts/{install-lib,model-policy}.mjs
|
|
282
|
+
npm run typecheck # 타입 검사만 (noEmit)
|
|
283
|
+
npm run clean # dist/ 제거
|
|
284
|
+
```
|
|
285
|
+
|
|
286
|
+
**개발 중에는 빌드가 필요 없다** — 소스를 직접 실행한다:
|
|
287
|
+
|
|
288
|
+
```bash
|
|
289
|
+
node bin/cli.ts doctor
|
|
290
|
+
node scripts/run-tests.mts
|
|
291
|
+
```
|
|
292
|
+
|
|
293
|
+
빌드 산출물은 커밋하지 않는다. 배포(`npm publish`) 시 `prepack`이 `build` +
|
|
294
|
+
`build:entry`를 돌려 tarball에만 담는다. 근거·경계는 ARCHITECTURE.md §12.6.
|
|
295
|
+
|
|
296
|
+
---
|
|
297
|
+
|
|
298
|
+
## 6. 테스트
|
|
254
299
|
|
|
255
300
|
```bash
|
|
256
301
|
npm test # 전체
|
|
@@ -267,7 +312,7 @@ MAKDOONG2_SKIP_LINUX_CHECK=1 git push # 그 push 는 Linux 검증 없이 나
|
|
|
267
312
|
|
|
268
313
|
---
|
|
269
314
|
|
|
270
|
-
##
|
|
315
|
+
## 7. 배포 (Maintainer)
|
|
271
316
|
|
|
272
317
|
두 경로 모두 **승인 게이트 2회**를 통과해야 배포된다.
|
|
273
318
|
|
|
@@ -289,7 +334,7 @@ npm run release:major # 1.3.1 → 2.0.0 (breaking)
|
|
|
289
334
|
|
|
290
335
|
---
|
|
291
336
|
|
|
292
|
-
##
|
|
337
|
+
## 8. 문서
|
|
293
338
|
|
|
294
339
|
| 문서 | 내용 |
|
|
295
340
|
|---|---|
|
|
@@ -98,6 +98,24 @@ permission:
|
|
|
98
98
|
|
|
99
99
|
`MAX_ATTEMPTS` 는 dispatch_stage **호출 1회 내부**의 예산이므로 재호출하면 리셋된다. `hang_history` 누적 상한은 그 리셋을 무력화하기 위한 **호출 간(cross-call) 차단막**이며, 부장님이 우회하면 무한 루프가 된다.
|
|
100
100
|
|
|
101
|
+
## verdict 는 셋이다 — REJECTED 와 ERROR 를 절대 섞지 말 것 (hardrule)
|
|
102
|
+
|
|
103
|
+
`dispatch_verifier` 의 `verdict` 는 `VERIFIED` / `REJECTED` / `ERROR` 세 값이다.
|
|
104
|
+
|
|
105
|
+
| verdict | 뜻 | 올바른 조치 |
|
|
106
|
+
|---|---|---|
|
|
107
|
+
| `VERIFIED` | 검증했고 통과 | 다음 substage 로 진행 |
|
|
108
|
+
| `REJECTED` | 검증했고 **산출물을 반려** | `.done=false` 로 되돌리고 `dispatch_stage` 재호출 |
|
|
109
|
+
| `ERROR` | **검증이 수행되지 않음** (verifier 세션 인프라 실패) | **`dispatch_verifier` 만 재호출.** stage 재실행 금지 |
|
|
110
|
+
|
|
111
|
+
`ERROR` 에서 stage 를 재실행하면 **아무 문제 없는 작업을 통째로 다시 시킨다.** 실제로 마커가 전부 정상이던 `1_planning.jira` 가 planner 200초 재가동으로 이어졌고, 재검증 결과는 `VERIFIED` 였다 — 원 작업에는 처음부터 결함이 없었다 (GitHub issue #7).
|
|
112
|
+
|
|
113
|
+
판정 근거는 `verdict_source` 에, 조치는 `next_action` 에 실려 온다. **`next_action` 을 그대로 따른다** (하드룰 4). `raw` / `parsed` 의 문구를 보고 스스로 해석하지 않는다 — 그 오판이 이 사고의 직접 원인이었다.
|
|
114
|
+
|
|
115
|
+
- `retryable: true` 는 "같은 인자로 verifier 만 다시 부르면 된다" 는 뜻이다. stage 재실행 신호가 **아니다**.
|
|
116
|
+
- `counts_as_rejection: false` 인 판정은 `rejected_count` / `same_reason_streak` 에 집계되지 않는다.
|
|
117
|
+
- ERROR 는 자체 연속 상한을 갖는다: `verifier_error_streak_exceeded: true` (3회 연속) 이면 재호출을 멈추고 사용자에게 보고한다.
|
|
118
|
+
|
|
101
119
|
## REJECTED 재시도 정책 (신규)
|
|
102
120
|
|
|
103
121
|
`dispatch_verifier` 가 `verdict: "REJECTED"` 를 반환하면 다음 규약을 따른다:
|
|
@@ -111,7 +129,18 @@ permission:
|
|
|
111
129
|
|
|
112
130
|
```
|
|
113
131
|
verdict = dispatch_verifier(issue, target_stage, worktree, result.output)
|
|
114
|
-
if verdict.verdict == "
|
|
132
|
+
if verdict.verdict == "ERROR":
|
|
133
|
+
# 검증이 수행되지 않았다 (인프라 실패). stage 산출물은 멀쩡하다.
|
|
134
|
+
# state.json 은 건드리지 않는다 — last_verdict_reason 도 기록되지 않았다.
|
|
135
|
+
if verdict.verifier_error_streak_exceeded == True:
|
|
136
|
+
# 3회 연속 판정 실패 — 모델·인프라 문제. 자동 재시도로 풀리지 않는다.
|
|
137
|
+
[사용자에게 verdict.verdict_source + verdict.verifier_error_streak 보고]
|
|
138
|
+
[세션 종료 후 사용자 결정 대기]
|
|
139
|
+
else:
|
|
140
|
+
# verifier 는 idempotent — 같은 인자로 그대로 재호출한다.
|
|
141
|
+
# .done 을 false 로 되돌리지 않는다. dispatch_stage 를 부르지 않는다.
|
|
142
|
+
[dispatch_verifier(issue, target_stage, worktree, result.output) 재호출]
|
|
143
|
+
elif verdict.verdict == "REJECTED":
|
|
115
144
|
# (자동) dispatch_verifier 가 state.json 에 사유 기록 완료
|
|
116
145
|
if verdict.same_reason_streak_exceeded == True:
|
|
117
146
|
# 동일 사유 5회 연속 — 무한루프 의심. 사용자 개입 필요.
|
|
@@ -214,6 +243,16 @@ loop (max_substage_retries=3 per substage):
|
|
|
214
243
|
# 2차 검증 (Verifier)
|
|
215
244
|
verdict = dispatch_verifier(issue, next.target_stage, worktree, result.output)
|
|
216
245
|
|
|
246
|
+
if verdict.verdict == "ERROR":
|
|
247
|
+
# 검증 미수행 (인프라 실패). verdict.next_action 이 지시하는 그대로 따른다.
|
|
248
|
+
# stage 재실행 금지 — 산출물에는 아무 문제가 없다.
|
|
249
|
+
if verdict.verifier_error_streak_exceeded == true:
|
|
250
|
+
[verdict.verdict_source + verdict.verifier_error_streak 사용자 보고]
|
|
251
|
+
[세션 종료 후 사용자 결정 대기]
|
|
252
|
+
continue
|
|
253
|
+
[dispatch_verifier 재호출 — 같은 인자, state.json 변경 없음]
|
|
254
|
+
continue
|
|
255
|
+
|
|
217
256
|
if verdict.verdict == "REJECTED":
|
|
218
257
|
# dispatch_verifier 가 state.json 에 verdict.raw / hash / streak 자동 기록 완료
|
|
219
258
|
# dispatch_stage 재호출 시 last_verdict_reason 이 자동으로 프롬프트에 주입됨.
|
|
@@ -246,6 +285,8 @@ loop (max_substage_retries=3 per substage):
|
|
|
246
285
|
- 모델 폴백 시: `[fallback] <agent>: <primary> → <fallback> (reason: ...)`
|
|
247
286
|
- `retry_disallowed` 감지 시: `[substage X RETRY DISALLOWED] outcome=timeout, transient_failures=0 — sub-agent hang. <retry_disallowed_reason>`
|
|
248
287
|
- `session_gone` 최종 실패 시: `[substage X SESSION_GONE gone_reason=<message_stall|status_absent>] dispatch_stage 3회 자동 redispatch 후 실패. attempts=<N>, previous_session_ids=[...]`
|
|
288
|
+
- verifier ERROR 시: `[substage X VERIFIER ERROR source=<verdict_source>] 검증 미수행 — verifier 만 재호출 (streak=<N>/3). stage 는 건드리지 않음`
|
|
289
|
+
- verifier ERROR streak 초과 시: `[substage X VERIFIER ERROR STREAK EXCEEDED] 판정 3회 연속 실패. source=<verdict_source>. 사용자 개입 필요.`
|
|
249
290
|
- REJECTED 재시도 시: `[substage X REJECTED retry] streak=<N>, reason_prefix="<40자>" → dispatch_stage 재호출`
|
|
250
291
|
- REJECTED streak 5회 초과 시: `[substage X REJECTED STREAK EXCEEDED] 동일 사유 <streak>회 연속 실패. verdict.raw:\n<verdict.raw 전문>\n\n사용자 개입 필요.`
|
|
251
292
|
- HITL opt-in 커밋 게이트 시: `[3_delivery.commit HUMAN GATE] auto_approve=false — 변경 보고서 작성, 사용자 승인 대기` (기본 흐름에서는 발생하지 않음)
|
|
@@ -37,7 +37,7 @@ bash 명령은 **실행 후 결과로 판단**한다. 실행 전 permission 을
|
|
|
37
37
|
<verifier-verdict>REJECTED</verifier-verdict>
|
|
38
38
|
```
|
|
39
39
|
|
|
40
|
-
|
|
40
|
+
정상 종료했는데 이 태그가 없으면 `dispatch_verifier` 는 `source=malformed_output_default` 로 **REJECTED 를 확정**하고 team-leader 재작업 loop 를 트리거한다. **검증 결과가 통과였더라도 태그를 잊으면 REJECTED 다.** (세션이 아예 죽어 판정 자체를 못 낸 경우는 `source=session_failed_default` / `session_gone_default` 로 `verdict: "ERROR"` 가 되며, 이때 team-leader 는 stage 를 재실행하지 않고 verifier 만 재호출한다 — 그 경로는 이 에이전트가 제어할 수 없다.)
|
|
41
41
|
|
|
42
42
|
### 종료 직전 필수 자기 점검 (매 turn 마다)
|
|
43
43
|
|
package/bin/cli.js
CHANGED
package/bin/cli.ts
CHANGED
package/dist/opencode-plugin.js
CHANGED
|
@@ -22,6 +22,7 @@ import { basename as pathBasename, dirname as pathDirname, join, resolve as path
|
|
|
22
22
|
import { tool } from "@opencode-ai/plugin";
|
|
23
23
|
import { appendSessionIndex, findWorktreeRoot, lookupSessionFromIndex } from "./session-index.js";
|
|
24
24
|
import { computeVerdictHash } from "./verdict-hash.js";
|
|
25
|
+
import { classifyVerifierOutcome, nextVerifierErrorStreak, verifierErrorStreakExceeded, VERIFIER_ERROR_STREAK_LIMIT, } from "./verifier-verdict.js";
|
|
25
26
|
import { nextModel, applyConfigOverrides, POLICIES } from "./model-fallback-policy.js";
|
|
26
27
|
import { agentForStage, STAGE_SPEC_FILES } from "./agent-stage-config.js";
|
|
27
28
|
import { shouldEscalateStall } from "./stall-escalation.js";
|
|
@@ -742,6 +743,11 @@ export const Makdoong2TeamPlugin = async ({ $, client, directory, worktree }) =>
|
|
|
742
743
|
const last = sessionLastToolExecuteAt.get(sessionId);
|
|
743
744
|
return typeof last === "number" && Date.now() - last < TOOL_EXECUTE_ALIVE_WINDOW_MS;
|
|
744
745
|
},
|
|
746
|
+
// "지금 이 순간 툴이 실행 중인가" — before 훅에서 올리고 after 훅에서 내리는
|
|
747
|
+
// 활성 카운터 그대로다. isRecentlyActive 의 5분 창과 달리 순간값이라 완료
|
|
748
|
+
// 판정에 쓸 수 있다. 폴러가 읽는 메시지 스냅샷보다 항상 앞선다 (issue #7:
|
|
749
|
+
// tool.execute.before 발화 110ms 뒤의 폴이 tool part 를 아직 못 봤다).
|
|
750
|
+
isToolExecuting: () => (sessionActiveToolCount.get(sessionId) ?? 0) > 0,
|
|
745
751
|
});
|
|
746
752
|
// ── sessionID → agent 매핑. chat.params hook에서 채우고, tool.execute.before
|
|
747
753
|
// hook에서 조회한다. 이 매핑을 통해 hook input에 없는 agent 식별을 우회한다.
|
|
@@ -2087,10 +2093,17 @@ export const Makdoong2TeamPlugin = async ({ $, client, directory, worktree }) =>
|
|
|
2087
2093
|
* just reported done. Read-only sub-agent reads state.json self_check markers,
|
|
2088
2094
|
* the stage spec, and the dispatcher's output, and emits a structured verdict
|
|
2089
2095
|
* of `<verifier-verdict>VERIFIED</verifier-verdict>` or `REJECTED`.
|
|
2096
|
+
*
|
|
2097
|
+
* 반환 verdict 는 셋이다. `ERROR` 는 "검증이 수행되지 않았다"(세션 인프라
|
|
2098
|
+
* 실패)로, 콘텐츠 반려인 `REJECTED` 와 조치가 정반대다 — 전자는 verifier 만
|
|
2099
|
+
* 재호출, 후자는 stage 재실행. 판정 근거는 `verdict_source`, 지시는
|
|
2100
|
+
* `next_action` 이 싣는다 (src/verifier-verdict.ts).
|
|
2090
2101
|
*/
|
|
2091
2102
|
dispatch_verifier: tool({
|
|
2092
2103
|
description: "Spawn the makdoong2-verifier sub-agent to second-check a stage's completion. " +
|
|
2093
|
-
"Returns { ok, verdict: 'VERIFIED' | 'REJECTED',
|
|
2104
|
+
"Returns { ok, verdict: 'VERIFIED' | 'REJECTED' | 'ERROR', verdict_source, retryable, " +
|
|
2105
|
+
"next_action, raw, session_id }. verdict='ERROR' means the verifier session failed " +
|
|
2106
|
+
"before producing a verdict — re-run dispatch_verifier only; do NOT re-run the stage.",
|
|
2094
2107
|
args: {
|
|
2095
2108
|
issue: tool.schema.string().describe("Jira issue key, e.g. PROJ-12345"),
|
|
2096
2109
|
stage: tool.schema.enum(STAGE_ORDER),
|
|
@@ -2224,8 +2237,8 @@ export const Makdoong2TeamPlugin = async ({ $, client, directory, worktree }) =>
|
|
|
2224
2237
|
? " (message_stall detected)"
|
|
2225
2238
|
: "";
|
|
2226
2239
|
logger.warn(`[dispatch_verifier] SESSION_GONE session=${subSessionID} stage=${args.stage}` +
|
|
2227
|
-
`${stallReason} —
|
|
2228
|
-
`(verifier is idempotent, team-leader can redispatch)`);
|
|
2240
|
+
`${stallReason} — verdict=ERROR (판정 없음), no retry ` +
|
|
2241
|
+
`(verifier is idempotent, team-leader can redispatch the verifier alone)`);
|
|
2229
2242
|
const vHangEntry = JSON.stringify({
|
|
2230
2243
|
attempt: 1,
|
|
2231
2244
|
at: new Date().toISOString(),
|
|
@@ -2268,30 +2281,48 @@ export const Makdoong2TeamPlugin = async ({ $, client, directory, worktree }) =>
|
|
|
2268
2281
|
skipSessionOps: verifierSessionGone,
|
|
2269
2282
|
});
|
|
2270
2283
|
}
|
|
2271
|
-
const
|
|
2272
|
-
|
|
2273
|
-
|
|
2274
|
-
|
|
2275
|
-
|
|
2276
|
-
|
|
2277
|
-
|
|
2278
|
-
const verdictSource = verifierSessionGone
|
|
2279
|
-
? "session_gone_default"
|
|
2280
|
-
: m
|
|
2281
|
-
? "verdict_tag"
|
|
2282
|
-
: jsonFallback
|
|
2283
|
-
? "json_fallback"
|
|
2284
|
-
: success
|
|
2285
|
-
? "malformed_output_default"
|
|
2286
|
-
: "session_failed_default";
|
|
2284
|
+
const classification = classifyVerifierOutcome({
|
|
2285
|
+
raw,
|
|
2286
|
+
success,
|
|
2287
|
+
sessionGone: verifierSessionGone,
|
|
2288
|
+
});
|
|
2289
|
+
const verdict = classification.verdict;
|
|
2290
|
+
const verdictSource = classification.source;
|
|
2287
2291
|
logger.debug(`[dispatch_verifier] VERDICT ${verdict} — issue=${args.issue} stage=${args.stage} ` +
|
|
2288
|
-
`session=${subSessionID} source=${verdictSource} success=${success}`
|
|
2292
|
+
`session=${subSessionID} source=${verdictSource} success=${success} ` +
|
|
2293
|
+
`retryable=${classification.retryable} counts_as_rejection=${classification.countsAsRejection}`);
|
|
2289
2294
|
const stageBase = stageJqPath(args.stage);
|
|
2290
2295
|
let sameReasonStreak = 0;
|
|
2291
2296
|
let sameReasonStreakExceeded = false;
|
|
2292
2297
|
let rejectedCount = 0;
|
|
2298
|
+
let verifierErrorStreak = 0;
|
|
2299
|
+
let verifierErrorStreakHit = false;
|
|
2293
2300
|
const SAME_REASON_STREAK_LIMIT = 5;
|
|
2294
|
-
if (verdict === "
|
|
2301
|
+
if (verdict === "ERROR") {
|
|
2302
|
+
// 검증이 **수행되지 않았다**. 반려 집계에 넣지 않는다.
|
|
2303
|
+
//
|
|
2304
|
+
// 여기서 REJECTED 로 집계하면 stage 산출물에 아무 문제가 없는데도
|
|
2305
|
+
// `rejected_count` 가 오르고 `last_verdict_reason` 에 "verifier session
|
|
2306
|
+
// failed" 가 박힌다. team-leader 는 그것을 반려로 읽고 stage 를 통째로
|
|
2307
|
+
// 재실행한다 — 실제로 마커가 전부 정상인 1_planning.jira 가 planner
|
|
2308
|
+
// 200초 재가동으로 이어졌고 재검증은 VERIFIED 였다 (issue #7).
|
|
2309
|
+
//
|
|
2310
|
+
// 대신 ERROR 전용 연속 카운터로 무한 재호출만 막는다.
|
|
2311
|
+
const prevErrR = await $ `bash ${SCRIPTS_DIR}/state.sh get ${args.issue} ${stageBase + ".verifier_error_streak"}`
|
|
2312
|
+
.cwd(args.worktree).quiet().nothrow();
|
|
2313
|
+
const prevErr = prevErrR.exitCode === 0
|
|
2314
|
+
? (parseInt(prevErrR.stdout?.toString().trim() || "0", 10) || 0)
|
|
2315
|
+
: 0;
|
|
2316
|
+
verifierErrorStreak = nextVerifierErrorStreak(prevErr, classification);
|
|
2317
|
+
verifierErrorStreakHit = verifierErrorStreakExceeded(verifierErrorStreak);
|
|
2318
|
+
await $ `bash ${SCRIPTS_DIR}/state.sh set ${args.issue} ${stageBase + ".verifier_error_streak"} ${String(verifierErrorStreak)}`
|
|
2319
|
+
.cwd(args.worktree).quiet().nothrow();
|
|
2320
|
+
logger.warn(`[dispatch_verifier] VERIFIER_ERROR — issue=${args.issue} stage=${args.stage} ` +
|
|
2321
|
+
`source=${verdictSource} streak=${verifierErrorStreak}/${VERIFIER_ERROR_STREAK_LIMIT} ` +
|
|
2322
|
+
`— 판정 없음. verifier 재호출만이 올바른 조치다 (stage 재실행 금지)` +
|
|
2323
|
+
(verifierErrorStreakHit ? ` ERROR_STREAK_EXCEEDED` : ``));
|
|
2324
|
+
}
|
|
2325
|
+
else if (classification.countsAsRejection) {
|
|
2295
2326
|
const reasonText = raw.trim().slice(0, 4000);
|
|
2296
2327
|
const reasonHash = computeVerdictHash(raw, args.stage);
|
|
2297
2328
|
const prevHashR = await $ `bash ${SCRIPTS_DIR}/state.sh get ${args.issue} ${stageBase + ".last_verdict_reason_hash"}`
|
|
@@ -2336,11 +2367,25 @@ export const Makdoong2TeamPlugin = async ({ $, client, directory, worktree }) =>
|
|
|
2336
2367
|
await $ `bash ${SCRIPTS_DIR}/state.sh set ${args.issue} ${stageBase + ".same_reason_streak"} 0`
|
|
2337
2368
|
.cwd(args.worktree).quiet().nothrow();
|
|
2338
2369
|
}
|
|
2370
|
+
// 판정을 얻었으면(VERIFIED / REJECTED 무관) ERROR 연속 카운터는 리셋한다.
|
|
2371
|
+
// 인프라가 회복됐다는 뜻이므로 직전 실패를 계속 들고 갈 이유가 없다.
|
|
2372
|
+
if (verdict !== "ERROR") {
|
|
2373
|
+
await $ `bash ${SCRIPTS_DIR}/state.sh set ${args.issue} ${stageBase + ".verifier_error_streak"} 0`
|
|
2374
|
+
.cwd(args.worktree).quiet().nothrow();
|
|
2375
|
+
}
|
|
2339
2376
|
await $ `bash ${SCRIPTS_DIR}/log-event.sh ${args.issue} verifier_verdict stage=${args.stage} verdict=${verdict} session=${subSessionID}`
|
|
2340
2377
|
.cwd(args.worktree).quiet().nothrow();
|
|
2341
2378
|
return JSON.stringify({
|
|
2342
2379
|
ok: success,
|
|
2343
2380
|
verdict,
|
|
2381
|
+
// 종전에는 이 세 값이 없어서 호출자가 "왜 REJECTED 인지" 를 알 수 없었다.
|
|
2382
|
+
// verdictSource 는 이미 계산되어 로그에만 남고 있었다 (issue #7).
|
|
2383
|
+
verdict_source: verdictSource,
|
|
2384
|
+
// retryable=true 는 "같은 인자로 verifier 만 다시 부르면 된다" 는 뜻이다.
|
|
2385
|
+
// stage 재실행 신호가 아니다 — next_action 이 그 점을 못 박는다.
|
|
2386
|
+
retryable: classification.retryable,
|
|
2387
|
+
counts_as_rejection: classification.countsAsRejection,
|
|
2388
|
+
next_action: classification.nextAction,
|
|
2344
2389
|
stage: args.stage,
|
|
2345
2390
|
agent: verifierId,
|
|
2346
2391
|
model: modelFull,
|
|
@@ -2349,13 +2394,9 @@ export const Makdoong2TeamPlugin = async ({ $, client, directory, worktree }) =>
|
|
|
2349
2394
|
same_reason_streak: sameReasonStreak,
|
|
2350
2395
|
same_reason_streak_exceeded: sameReasonStreakExceeded,
|
|
2351
2396
|
rejected_count: rejectedCount,
|
|
2352
|
-
|
|
2353
|
-
|
|
2354
|
-
|
|
2355
|
-
? `verdict tag missing — extracted from JSON body (${jsonFallback[1]})`
|
|
2356
|
-
: success
|
|
2357
|
-
? "verdict tag missing — defaulted to REJECTED (verifier output malformed)"
|
|
2358
|
-
: `verifier session failed (${raw.slice(0, 120)})`,
|
|
2397
|
+
verifier_error_streak: verifierErrorStreak,
|
|
2398
|
+
verifier_error_streak_exceeded: verifierErrorStreakHit,
|
|
2399
|
+
parsed: classification.parsed,
|
|
2359
2400
|
});
|
|
2360
2401
|
},
|
|
2361
2402
|
}),
|
|
@@ -106,6 +106,7 @@ export interface PollOptions {
|
|
|
106
106
|
messageStallThresholdMs?: number;
|
|
107
107
|
statusAbsentGraceMs?: number;
|
|
108
108
|
isRecentlyActive?: () => boolean;
|
|
109
|
+
isToolExecuting?: () => boolean;
|
|
109
110
|
contentStableCompletionMs?: number;
|
|
110
111
|
preambleOnlyTextThreshold?: number;
|
|
111
112
|
nudgeAtFraction?: number;
|
package/dist/poll-sub-session.js
CHANGED
|
@@ -123,6 +123,7 @@ export async function pollSubSession(client, sessionId, options = {}) {
|
|
|
123
123
|
const contentStableCompletionMs = options.contentStableCompletionMs;
|
|
124
124
|
const preambleOnlyTextThreshold = options.preambleOnlyTextThreshold;
|
|
125
125
|
const isRecentlyActive = options.isRecentlyActive;
|
|
126
|
+
const isToolExecuting = options.isToolExecuting;
|
|
126
127
|
const permissionCheckIntervalPolls = options.permissionCheckIntervalPolls ?? 5;
|
|
127
128
|
let pollCount = 0;
|
|
128
129
|
let transientFailures = 0;
|
|
@@ -133,6 +134,10 @@ export async function pollSubSession(client, sessionId, options = {}) {
|
|
|
133
134
|
let lastAssistantSig = "";
|
|
134
135
|
let nudged = false;
|
|
135
136
|
let firstGoneObservedAt = null;
|
|
137
|
+
// finish 단독 완료 신호를 처음 관측한 폴의 content 시그니처. 같은 시그니처를
|
|
138
|
+
// 연속 두 폴에서 볼 때만 완료로 확정한다 (아래 finishOnlyCompletion 참조).
|
|
139
|
+
let finishConfirmSig = null;
|
|
140
|
+
let toolStallExemptLogged = false;
|
|
136
141
|
dbg?.(`[pollSubSession] START session=${sessionId} timeoutMs=${timeoutMs}`);
|
|
137
142
|
while (now() < deadline) {
|
|
138
143
|
await sleep(pollIntervalMs);
|
|
@@ -227,6 +232,12 @@ export async function pollSubSession(client, sessionId, options = {}) {
|
|
|
227
232
|
: Boolean(lastAssistant);
|
|
228
233
|
const hasFinish = lastAssistant?.info?.finish != null;
|
|
229
234
|
const hasPendingToolCall = !!lastAssistant?.parts?.some(isInFlightToolPart);
|
|
235
|
+
// 메시지 스냅샷(hasPendingToolCall)과 실시간 훅 신호(isToolExecuting)의 합집합.
|
|
236
|
+
// 둘 중 하나라도 "툴이 떠 있다" 고 하면 완료로 판정하지 않는다 — 스냅샷은
|
|
237
|
+
// 서버 반영이 늦고(issue #7), 훅 신호는 훅이 안 붙은 환경에서 아예 없다.
|
|
238
|
+
// OR 로 묶어야 각자의 사각지대를 서로 덮는다.
|
|
239
|
+
const toolExecuting = isToolExecuting?.() === true;
|
|
240
|
+
const toolInFlight = hasPendingToolCall || toolExecuting;
|
|
230
241
|
const stalledMs = now() - lastProgressAt;
|
|
231
242
|
// session_gone (status_absent) has two admission paths with different
|
|
232
243
|
// strictness:
|
|
@@ -253,7 +264,7 @@ export async function pollSubSession(client, sessionId, options = {}) {
|
|
|
253
264
|
// wall-clock semantics.
|
|
254
265
|
const activeSignal = isRecentlyActive?.() === true;
|
|
255
266
|
const goneAdmitted = !activeSignal &&
|
|
256
|
-
!
|
|
267
|
+
!toolInFlight &&
|
|
257
268
|
!messagesChanged &&
|
|
258
269
|
!status &&
|
|
259
270
|
(sessionEverAppeared || (sessionAliveByMessages && hasProducedAssistantMessage));
|
|
@@ -261,7 +272,7 @@ export async function pollSubSession(client, sessionId, options = {}) {
|
|
|
261
272
|
if (firstGoneObservedAt === null) {
|
|
262
273
|
firstGoneObservedAt = now();
|
|
263
274
|
dbg?.(`[pollSubSession] GONE_ADMIT session=${sessionId} poll=${pollCount} ` +
|
|
264
|
-
`sig="${currentAsstSig}" pending_tool=${hasPendingToolCall} ` +
|
|
275
|
+
`sig="${currentAsstSig}" pending_tool=${hasPendingToolCall} tool_exec=${toolExecuting} ` +
|
|
265
276
|
`session_ever_appeared=${sessionEverAppeared} alive_by_msgs=${sessionAliveByMessages} ` +
|
|
266
277
|
`messages=${messages.length} grace_ms=${statusAbsentGraceMs} ` +
|
|
267
278
|
`— gone admission started; will fire in ${statusAbsentGraceMs}ms if condition persists`);
|
|
@@ -282,7 +293,7 @@ export async function pollSubSession(client, sessionId, options = {}) {
|
|
|
282
293
|
else {
|
|
283
294
|
if (firstGoneObservedAt !== null) {
|
|
284
295
|
dbg?.(`[pollSubSession] GONE_ADMIT_RESET session=${sessionId} poll=${pollCount} ` +
|
|
285
|
-
`active_signal=${activeSignal} pending_tool=${hasPendingToolCall} ` +
|
|
296
|
+
`active_signal=${activeSignal} pending_tool=${hasPendingToolCall} tool_exec=${toolExecuting} ` +
|
|
286
297
|
`messages_changed=${messagesChanged} status=${status?.type ?? "absent"} ` +
|
|
287
298
|
`— gone admission cleared before grace elapsed`);
|
|
288
299
|
}
|
|
@@ -321,7 +332,29 @@ export async function pollSubSession(client, sessionId, options = {}) {
|
|
|
321
332
|
}
|
|
322
333
|
}
|
|
323
334
|
}
|
|
324
|
-
|
|
335
|
+
// Tool-call stall — "툴 호출이 떠 있는데 아무 진전이 없다".
|
|
336
|
+
//
|
|
337
|
+
// 이 휴리스틱이 잡으려는 것은 **서브에이전트 컨텍스트에서 답할 수 없는 권한
|
|
338
|
+
// 요청에 막힌** 툴 호출이다. 실행 자체가 오래 걸리는 툴(sbt·docker·gradle
|
|
339
|
+
// 빌드)은 대상이 아니다 — 그런데 둘은 `stalledMs` 만 보면 구분되지 않는다.
|
|
340
|
+
// tool part 의 state 변화는 시그니처(`id:partsLen:textLen`)를 바꾸지 않으므로
|
|
341
|
+
// 5분짜리 빌드도 "진전 없음" 으로 보이고, 기본 임계 60초에서 abort 된다.
|
|
342
|
+
// `hasPendingToolCall` 이 프로덕션에서 항상 false 였던 동안에는 이 경로가 한
|
|
343
|
+
// 번도 발화하지 않아 드러나지 않았고, 그 값을 고치는 순간 무장됐다.
|
|
344
|
+
//
|
|
345
|
+
// `isToolExecuting()` 이 그 둘을 가른다. before 훅이 발화하고 after 훅이 아직
|
|
346
|
+
// 안 돈 상태 = 툴이 실제로 **실행 중**이다. 이때는 죽이지 않는다. 진짜 권한
|
|
347
|
+
// 대기는 매 폴 도는 `permission.list()` 경로가 요청 ID·패턴까지 짚어 정확히
|
|
348
|
+
// 잡아내고, 그마저 실패해도 절대 타임아웃이 받쳐준다. 이 모듈의 위험 선호와
|
|
349
|
+
// 같은 방향이다 — 미탐(살아있는 세션을 죽임)이 오탐(타임아웃까지 기다림)보다
|
|
350
|
+
// 비싸다.
|
|
351
|
+
if (hasPendingToolCall && toolExecuting && stalledMs >= toolCallStallThresholdMs && !toolStallExemptLogged) {
|
|
352
|
+
toolStallExemptLogged = true;
|
|
353
|
+
dbg?.(`[pollSubSession] TOOL_STALL_EXEMPT session=${sessionId} poll=${pollCount} ` +
|
|
354
|
+
`stalled_ms=${stalledMs} threshold_ms=${toolCallStallThresholdMs} ` +
|
|
355
|
+
`— 툴이 실행 중이므로 permission_stall 로 판정하지 않는다`);
|
|
356
|
+
}
|
|
357
|
+
if (hasPendingToolCall && !toolExecuting && stalledMs >= toolCallStallThresholdMs) {
|
|
325
358
|
err(`[pollSubSession] PERMISSION_STALL session=${sessionId} polls=${pollCount} stalledMs=${stalledMs}`);
|
|
326
359
|
await client.session.abort({ path: { id: sessionId } }).catch(() => undefined);
|
|
327
360
|
return {
|
|
@@ -365,7 +398,7 @@ export async function pollSubSession(client, sessionId, options = {}) {
|
|
|
365
398
|
if (messageStallThresholdMs !== undefined &&
|
|
366
399
|
(sessionEverAppeared || sessionAliveByMessages) &&
|
|
367
400
|
busyIndicated &&
|
|
368
|
-
!
|
|
401
|
+
!toolInFlight &&
|
|
369
402
|
stalledFromProgress >= messageStallThresholdMs) {
|
|
370
403
|
const hangMode = lastAssistant ? "mid_stream" : "bootstrap";
|
|
371
404
|
err(`[pollSubSession] MESSAGE_STALL session=${sessionId} polls=${pollCount} ` +
|
|
@@ -380,7 +413,7 @@ export async function pollSubSession(client, sessionId, options = {}) {
|
|
|
380
413
|
reason: "message_stall",
|
|
381
414
|
};
|
|
382
415
|
}
|
|
383
|
-
const finishComplete = hasFinish && !
|
|
416
|
+
const finishComplete = hasFinish && !toolInFlight && properOrdering;
|
|
384
417
|
// INV-1: `!status` alone is not idle. Require `status.type === "idle"` AND
|
|
385
418
|
// that we saw the session in the status map at least once (or that the
|
|
386
419
|
// messages list already contains an assistant response).
|
|
@@ -393,12 +426,46 @@ export async function pollSubSession(client, sessionId, options = {}) {
|
|
|
393
426
|
// statusIdle nor finishComplete ever fires (worktree-CWD, no finish field).
|
|
394
427
|
const contentStable = contentStableCompletionMs !== undefined &&
|
|
395
428
|
hasProducedAssistantMessage &&
|
|
396
|
-
!
|
|
429
|
+
!toolInFlight &&
|
|
397
430
|
stalledFromProgress >= contentStableCompletionMs;
|
|
398
|
-
|
|
431
|
+
// finish 단독 완료는 한 폴 더 확인하고 결론낸다.
|
|
432
|
+
//
|
|
433
|
+
// `finish` 는 "이번 assistant 메시지의 생성이 끝났다" 는 뜻이지 "세션이 끝났다"
|
|
434
|
+
// 가 아니다. 모델이 tool call 로 턴을 마치면 그 순간 finish 가 붙고 곧바로 툴이
|
|
435
|
+
// 실행된다. 그 사이(관측 110ms)에 폴이 끼면 tool part 는 아직 안 보이고 finish
|
|
436
|
+
// 만 보여 **완료로 오판**한다 (issue #7). statusIdle(서버가 직접 idle 이라고
|
|
437
|
+
// 말함)이나 contentStable(5분 무변화)이 함께 서 있으면 그런 오판이 아니므로
|
|
438
|
+
// 즉시 결론내고, finish 뿐일 때만 한 폴(기본 2s) 뒤 재확인한다. 그 시점이면
|
|
439
|
+
// tool part 가 메시지에 반영돼 있거나 isToolExecuting 이 참이라 판정이 취소된다.
|
|
440
|
+
//
|
|
441
|
+
// 재확인의 기준은 **content 시그니처 동일**이다. 시그니처가 바뀌었다는 것은
|
|
442
|
+
// 이번 폴에서 내용이 움직였다는 뜻이고, 움직이는 중에는 결론내지 않는다.
|
|
443
|
+
// (관측된 오판 2건 모두 `contentStable=false` — 내용이 아직 안정되지 않은
|
|
444
|
+
// 상태에서 완료 판정이 내려졌다.)
|
|
445
|
+
//
|
|
446
|
+
// 비용은 finish 단독 경로에서 폴 1회다. worktree-CWD 세션(status map 이 CWD
|
|
447
|
+
// 필터링되어 statusIdle 이 영영 안 뜬다)이 이 경로를 상시로 타지만, substage
|
|
448
|
+
// 하나가 수 분인 것에 비하면 2초는 무시할 수 있다.
|
|
449
|
+
const finishOnlyCompletion = finishComplete && !statusIdle && !contentStable;
|
|
450
|
+
let deferredForConfirm = false;
|
|
451
|
+
if (finishOnlyCompletion) {
|
|
452
|
+
if (finishConfirmSig !== currentAsstSig) {
|
|
453
|
+
finishConfirmSig = currentAsstSig;
|
|
454
|
+
deferredForConfirm = true;
|
|
455
|
+
}
|
|
456
|
+
}
|
|
457
|
+
else {
|
|
458
|
+
finishConfirmSig = null;
|
|
459
|
+
}
|
|
460
|
+
const looksComplete = !deferredForConfirm && (statusIdle || finishComplete || contentStable);
|
|
399
461
|
dbg?.(`[pollSubSession] POLL session=${sessionId} poll=${pollCount} status=${status?.type ?? "absent"} ` +
|
|
400
462
|
`messages=${messages.length} finishComplete=${finishComplete} statusIdle=${statusIdle} ` +
|
|
401
|
-
`contentStable=${contentStable} sessionEverAppeared=${sessionEverAppeared}`
|
|
463
|
+
`contentStable=${contentStable} sessionEverAppeared=${sessionEverAppeared} ` +
|
|
464
|
+
`pendingTool=${hasPendingToolCall} toolExec=${toolExecuting} deferredForConfirm=${deferredForConfirm}`);
|
|
465
|
+
if (deferredForConfirm) {
|
|
466
|
+
dbg?.(`[pollSubSession] FINISH_CONFIRM_PENDING session=${sessionId} poll=${pollCount} ` +
|
|
467
|
+
`sig="${currentAsstSig}" — finish 단독 완료 신호. 한 폴 뒤 재확인한다`);
|
|
468
|
+
}
|
|
402
469
|
if (!nudged && options.nudgeAtFraction != null && options.onNudge) {
|
|
403
470
|
const elapsedMs = now() - startTime;
|
|
404
471
|
if (elapsedMs >= options.nudgeAtFraction * timeoutMs) {
|
|
@@ -440,13 +507,24 @@ export async function pollSubSession(client, sessionId, options = {}) {
|
|
|
440
507
|
.filter(p => p.type === "text" && typeof p.text === "string" && p.text.length > 0)
|
|
441
508
|
.map(p => p.text)
|
|
442
509
|
.join("\n");
|
|
443
|
-
|
|
510
|
+
// 공백만 있는 text part 는 "짧은 서두" 가 아니라 "아직 텍스트가 없음" 이다.
|
|
511
|
+
// 종전에는 `text.length > 0` 만 보고 preamble 분기에 들어갔고, trim 길이 0 은
|
|
512
|
+
// 임계 미만이라 그대로 preamble_only 로 확정됐다 — 로그의 `textLen=0` 이 바로
|
|
513
|
+
// 이 경로다 (issue #7). 스트리밍 초기 상태를 "서두만 쓰고 끝냈다" 로 읽는
|
|
514
|
+
// 것이라 오판의 방향이 정반대다. 정상 반환된 서브에이전트 출력들도 모두
|
|
515
|
+
// `\n\n` 으로 시작한다는 관측이 이 경로가 상시 노출돼 있었음을 말한다.
|
|
516
|
+
//
|
|
517
|
+
// 텍스트 없음으로 떨어뜨리면 dispatch_stage 는 "작업을 다시 하라" 는 action
|
|
518
|
+
// 재프롬프트 대신 요약 재프롬프트를 보내고, `.done=true` override 도 그대로
|
|
519
|
+
// 걸린다 — 이미 끝난 작업을 되풀이시키지 않는 쪽이다.
|
|
520
|
+
const trimmedLen = text.trim().length;
|
|
521
|
+
if (trimmedLen > 0) {
|
|
444
522
|
if (preambleOnlyTextThreshold !== undefined &&
|
|
445
523
|
preambleOnlyTextThreshold > 0 &&
|
|
446
|
-
|
|
447
|
-
!
|
|
524
|
+
trimmedLen < preambleOnlyTextThreshold &&
|
|
525
|
+
!toolInFlight) {
|
|
448
526
|
dbg?.(`[pollSubSession] preamble-only detected session=${sessionId} ` +
|
|
449
|
-
`textLen=${
|
|
527
|
+
`textLen=${trimmedLen} threshold=${preambleOnlyTextThreshold} — ` +
|
|
450
528
|
`reclassifying text outcome as empty`);
|
|
451
529
|
return {
|
|
452
530
|
kind: "empty",
|
|
@@ -457,6 +535,16 @@ export async function pollSubSession(client, sessionId, options = {}) {
|
|
|
457
535
|
}
|
|
458
536
|
return { kind: "text", text, polls: pollCount, elapsedMs: now() - startTime };
|
|
459
537
|
}
|
|
538
|
+
if (text.length > 0) {
|
|
539
|
+
dbg?.(`[pollSubSession] whitespace-only text session=${sessionId} raw_len=${text.length} — ` +
|
|
540
|
+
`텍스트 없음으로 처리 (preamble_only 아님)`);
|
|
541
|
+
return {
|
|
542
|
+
kind: "empty",
|
|
543
|
+
reason: "whitespace_only_text",
|
|
544
|
+
polls: pollCount,
|
|
545
|
+
elapsedMs: now() - startTime,
|
|
546
|
+
};
|
|
547
|
+
}
|
|
460
548
|
return {
|
|
461
549
|
kind: "empty",
|
|
462
550
|
reason: "assistant message has no text parts",
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
/** verdict 가 어떤 경로로 정해졌는지. 종전에도 로그에는 이 값이 남고 있었다. */
|
|
2
|
+
export type VerifierVerdictSource =
|
|
3
|
+
/** 응답에 `<verifier-verdict>` 태그가 있었다 — 유일하게 권위 있는 경로. */
|
|
4
|
+
"verdict_tag"
|
|
5
|
+
/** 태그는 없지만 본문 JSON 에 `"verdict"` 키가 있었다. */
|
|
6
|
+
| "json_fallback"
|
|
7
|
+
/** 세션은 정상 종료했는데 판정을 못 읽었다 — verifier 출력 형식 위반. */
|
|
8
|
+
| "malformed_output_default"
|
|
9
|
+
/** 세션이 실패했다 (abort / empty / timeout). 판정 자체가 없다. */
|
|
10
|
+
| "session_failed_default"
|
|
11
|
+
/** 세션이 사라졌다 (status_absent / message_stall). 판정 자체가 없다. */
|
|
12
|
+
| "session_gone_default";
|
|
13
|
+
/**
|
|
14
|
+
* `ERROR` 는 "검증이 수행되지 않았다" 는 제3의 값이다. `REJECTED`(반려)와
|
|
15
|
+
* 섞으면 안 된다 — 조치가 정반대다.
|
|
16
|
+
*/
|
|
17
|
+
export type VerifierVerdict = "VERIFIED" | "REJECTED" | "ERROR";
|
|
18
|
+
export interface VerifierOutcomeInput {
|
|
19
|
+
/** verifier 세션의 최종 텍스트 (pollOutcomeToLegacy 의 text). */
|
|
20
|
+
raw: string;
|
|
21
|
+
/** pollOutcomeToLegacy 의 success — 세션이 텍스트를 산출했는가. */
|
|
22
|
+
success: boolean;
|
|
23
|
+
/** pollSubSession 이 session_gone 을 반환했는가. */
|
|
24
|
+
sessionGone: boolean;
|
|
25
|
+
}
|
|
26
|
+
export interface VerifierClassification {
|
|
27
|
+
verdict: VerifierVerdict;
|
|
28
|
+
source: VerifierVerdictSource;
|
|
29
|
+
/** true 면 같은 입력으로 verifier 를 다시 돌리는 것이 올바른 조치다. */
|
|
30
|
+
retryable: boolean;
|
|
31
|
+
/** true 일 때만 `rejected_count` / `same_reason_streak` 에 집계한다. */
|
|
32
|
+
countsAsRejection: boolean;
|
|
33
|
+
/** 사람이 읽는 한 줄 설명 (툴 반환값의 `parsed`). */
|
|
34
|
+
parsed: string;
|
|
35
|
+
/** 호출자가 그대로 따라야 할 조치. 애매하면 여기서 못을 박는다. */
|
|
36
|
+
nextAction: string;
|
|
37
|
+
}
|
|
38
|
+
/**
|
|
39
|
+
* verifier 세션 결과 → 판정 분류.
|
|
40
|
+
*
|
|
41
|
+
* 판정 태그는 세션 실패보다 우선한다: 태그를 이미 뱉은 뒤 세션 꼬리가 죽었다면
|
|
42
|
+
* 그 판정은 실제로 산출된 것이므로 존중한다.
|
|
43
|
+
*
|
|
44
|
+
* `malformed_output_default`(세션은 살아서 끝났는데 태그가 없음)는 `ERROR` 로
|
|
45
|
+
* 올리지 않고 종전대로 `REJECTED` 를 유지한다 — 형식 위반은 verifier 가 관측
|
|
46
|
+
* 가능한 콘텐츠 결함이고, `same_reason_streak` 이 이미 무한루프를 막는다.
|
|
47
|
+
* 여기서 완화 대상은 판정이 물리적으로 존재하지 않는 두 경로뿐이다.
|
|
48
|
+
*/
|
|
49
|
+
export declare function classifyVerifierOutcome(input: VerifierOutcomeInput): VerifierClassification;
|
|
50
|
+
/**
|
|
51
|
+
* 인프라 실패로 판정을 못 얻은 횟수의 연속 상한.
|
|
52
|
+
*
|
|
53
|
+
* `same_reason_streak`(REJECTED 무한루프 차단)의 ERROR 판 대응물이다. ERROR 는
|
|
54
|
+
* 집계에서 빠지므로 그쪽 상한이 걸리지 않는다 — 별도 상한이 없으면 verifier 만
|
|
55
|
+
* 재호출하는 루프가 영원히 돈다. 3회로 잡은 것은 재호출이 저렴하고(단일 read-only
|
|
56
|
+
* 세션) 3회 연속 실패면 모델·인프라 문제라 자동 재시도로 풀리지 않기 때문이다.
|
|
57
|
+
*/
|
|
58
|
+
export declare const VERIFIER_ERROR_STREAK_LIMIT = 3;
|
|
59
|
+
/** 이번 결과를 반영한 ERROR 연속 횟수. ERROR 가 아니면 0 으로 리셋된다. */
|
|
60
|
+
export declare function nextVerifierErrorStreak(previousStreak: number, classification: Pick<VerifierClassification, "verdict">): number;
|
|
61
|
+
/** 상한 도달 여부. 도달하면 team-leader 는 재호출을 멈추고 사용자에게 보고한다. */
|
|
62
|
+
export declare function verifierErrorStreakExceeded(streak: number, limit?: number): boolean;
|