makdoong2-team 2.2.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/agents/makdoong2-team-leader.md +42 -1
- package/agents/makdoong2-verifier.md +1 -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 +1 -1
- package/references/pr-template.md +2 -1
- package/scripts/run-tests.mts +1 -0
- package/stages/08-pr.md +27 -0
|
@@ -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/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;
|
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
// verifier-verdict.ts — dispatch_verifier 응답의 순수 분류기.
|
|
2
|
+
//
|
|
3
|
+
// 왜 별도 파일인가: opencode 의 플러그인 로더는 `src/opencode-plugin.ts` 의
|
|
4
|
+
// **모든 named export 를 plugin factory 로 호출**한다. 헬퍼를 거기에 export 하면
|
|
5
|
+
// 로더가 그것을 팩토리로 부르고 downstream `for (S of hooks) { S.auth }` 에서
|
|
6
|
+
// 터진다 (`test/plugin-exports-shape.test.ts` 가 export 집합을 고정한다).
|
|
7
|
+
// 그래서 순수 로직은 항상 별도 모듈에 두고 import 한다.
|
|
8
|
+
//
|
|
9
|
+
// ── 무엇을 고치는가 ────────────────────────────────────────────────────────
|
|
10
|
+
// 종전 dispatch_verifier 는 다섯 갈래의 결과를 `verdictSource` 로 **구분해 로그에
|
|
11
|
+
// 남기면서도** 툴 반환값의 `verdict` 는 전부 `REJECTED` 하나로 눌러 내보냈다.
|
|
12
|
+
// 그래서 호출자(team-leader)는 다음 둘을 구별할 수 없었다:
|
|
13
|
+
//
|
|
14
|
+
// (가) 콘텐츠 반려 — verifier 가 실제로 검사하고 산출물을 물렸다.
|
|
15
|
+
// → 올바른 조치: `.done=false` 로 되돌리고 stage 를 재실행한다.
|
|
16
|
+
// (나) 인프라 실패 — verifier 세션이 판정을 내리지 못하고 죽었다.
|
|
17
|
+
// → 올바른 조치: **verifier 만 재실행한다.** stage 산출물은 멀쩡하다.
|
|
18
|
+
//
|
|
19
|
+
// 실제로 (나)가 (가)로 보고되어, 마커가 전부 정상인 `1_planning.jira` 를
|
|
20
|
+
// team-leader 가 통째로 재실행했다 (planner 200초 재가동). 재검증 결과는
|
|
21
|
+
// VERIFIED 였다 — 원 작업에는 처음부터 결함이 없었다 (GitHub issue #7).
|
|
22
|
+
//
|
|
23
|
+
// 구분에 필요한 정보는 이미 전부 있었다. 계약에 반영하지 않았을 뿐이다.
|
|
24
|
+
const VERDICT_TAG_RE = /<verifier-verdict>\s*(VERIFIED|REJECTED)\s*<\/verifier-verdict>/i;
|
|
25
|
+
const VERDICT_JSON_RE = /"verdict"\s*:\s*"(VERIFIED|REJECTED)"/i;
|
|
26
|
+
const NEXT_ACTION_VERIFIED = "다음 substage 로 진행한다.";
|
|
27
|
+
const NEXT_ACTION_REJECTED = "검증이 산출물을 반려했다. 해당 substage 의 `.done` 을 false 로 되돌리고 " +
|
|
28
|
+
"dispatch_stage 를 재호출한다 (이전 실패 사유는 프롬프트에 자동 주입된다).";
|
|
29
|
+
const NEXT_ACTION_ERROR = "검증이 **수행되지 않았다** (verifier 세션 인프라 실패). 같은 인자로 " +
|
|
30
|
+
"dispatch_verifier 만 재호출한다. stage 를 재실행하지 말고 `.done` 을 false 로 " +
|
|
31
|
+
"되돌리지도 말 것 — 판정을 못 얻었을 뿐 stage 산출물은 그대로다. " +
|
|
32
|
+
"verifier 는 idempotent 하므로 재호출이 안전하다.";
|
|
33
|
+
/**
|
|
34
|
+
* verifier 세션 결과 → 판정 분류.
|
|
35
|
+
*
|
|
36
|
+
* 판정 태그는 세션 실패보다 우선한다: 태그를 이미 뱉은 뒤 세션 꼬리가 죽었다면
|
|
37
|
+
* 그 판정은 실제로 산출된 것이므로 존중한다.
|
|
38
|
+
*
|
|
39
|
+
* `malformed_output_default`(세션은 살아서 끝났는데 태그가 없음)는 `ERROR` 로
|
|
40
|
+
* 올리지 않고 종전대로 `REJECTED` 를 유지한다 — 형식 위반은 verifier 가 관측
|
|
41
|
+
* 가능한 콘텐츠 결함이고, `same_reason_streak` 이 이미 무한루프를 막는다.
|
|
42
|
+
* 여기서 완화 대상은 판정이 물리적으로 존재하지 않는 두 경로뿐이다.
|
|
43
|
+
*/
|
|
44
|
+
export function classifyVerifierOutcome(input) {
|
|
45
|
+
const tag = input.raw.match(VERDICT_TAG_RE);
|
|
46
|
+
if (tag) {
|
|
47
|
+
const verdict = tag[1].toUpperCase();
|
|
48
|
+
return {
|
|
49
|
+
verdict,
|
|
50
|
+
source: "verdict_tag",
|
|
51
|
+
retryable: false,
|
|
52
|
+
countsAsRejection: verdict === "REJECTED",
|
|
53
|
+
parsed: "verdict tag found",
|
|
54
|
+
nextAction: verdict === "VERIFIED" ? NEXT_ACTION_VERIFIED : NEXT_ACTION_REJECTED,
|
|
55
|
+
};
|
|
56
|
+
}
|
|
57
|
+
const json = input.raw.match(VERDICT_JSON_RE);
|
|
58
|
+
if (json) {
|
|
59
|
+
const verdict = json[1].toUpperCase();
|
|
60
|
+
return {
|
|
61
|
+
verdict,
|
|
62
|
+
source: "json_fallback",
|
|
63
|
+
retryable: false,
|
|
64
|
+
countsAsRejection: verdict === "REJECTED",
|
|
65
|
+
parsed: `verdict tag missing — extracted from JSON body (${verdict})`,
|
|
66
|
+
nextAction: verdict === "VERIFIED" ? NEXT_ACTION_VERIFIED : NEXT_ACTION_REJECTED,
|
|
67
|
+
};
|
|
68
|
+
}
|
|
69
|
+
if (input.sessionGone || !input.success) {
|
|
70
|
+
const source = input.sessionGone
|
|
71
|
+
? "session_gone_default"
|
|
72
|
+
: "session_failed_default";
|
|
73
|
+
return {
|
|
74
|
+
verdict: "ERROR",
|
|
75
|
+
source,
|
|
76
|
+
retryable: true,
|
|
77
|
+
countsAsRejection: false,
|
|
78
|
+
parsed: `verifier session failed — 판정 없음 (${source}): ${input.raw.slice(0, 120)}`,
|
|
79
|
+
nextAction: NEXT_ACTION_ERROR,
|
|
80
|
+
};
|
|
81
|
+
}
|
|
82
|
+
return {
|
|
83
|
+
verdict: "REJECTED",
|
|
84
|
+
source: "malformed_output_default",
|
|
85
|
+
retryable: false,
|
|
86
|
+
countsAsRejection: true,
|
|
87
|
+
parsed: "verdict tag missing — defaulted to REJECTED (verifier output malformed)",
|
|
88
|
+
nextAction: NEXT_ACTION_REJECTED,
|
|
89
|
+
};
|
|
90
|
+
}
|
|
91
|
+
/**
|
|
92
|
+
* 인프라 실패로 판정을 못 얻은 횟수의 연속 상한.
|
|
93
|
+
*
|
|
94
|
+
* `same_reason_streak`(REJECTED 무한루프 차단)의 ERROR 판 대응물이다. ERROR 는
|
|
95
|
+
* 집계에서 빠지므로 그쪽 상한이 걸리지 않는다 — 별도 상한이 없으면 verifier 만
|
|
96
|
+
* 재호출하는 루프가 영원히 돈다. 3회로 잡은 것은 재호출이 저렴하고(단일 read-only
|
|
97
|
+
* 세션) 3회 연속 실패면 모델·인프라 문제라 자동 재시도로 풀리지 않기 때문이다.
|
|
98
|
+
*/
|
|
99
|
+
export const VERIFIER_ERROR_STREAK_LIMIT = 3;
|
|
100
|
+
/** 이번 결과를 반영한 ERROR 연속 횟수. ERROR 가 아니면 0 으로 리셋된다. */
|
|
101
|
+
export function nextVerifierErrorStreak(previousStreak, classification) {
|
|
102
|
+
if (classification.verdict !== "ERROR")
|
|
103
|
+
return 0;
|
|
104
|
+
const prev = Number.isFinite(previousStreak) && previousStreak > 0 ? previousStreak : 0;
|
|
105
|
+
return prev + 1;
|
|
106
|
+
}
|
|
107
|
+
/** 상한 도달 여부. 도달하면 team-leader 는 재호출을 멈추고 사용자에게 보고한다. */
|
|
108
|
+
export function verifierErrorStreakExceeded(streak, limit = VERIFIER_ERROR_STREAK_LIMIT) {
|
|
109
|
+
return streak >= limit;
|
|
110
|
+
}
|
package/package.json
CHANGED
|
@@ -83,7 +83,8 @@
|
|
|
83
83
|
## 4. PR 생성 규칙
|
|
84
84
|
|
|
85
85
|
1. 반드시 **Draft 로 생성**한다 → `bitbucket_createPullRequest(..., draft=true)`.
|
|
86
|
-
2.
|
|
86
|
+
2. **토큰 소유자를 리뷰어로 추가한다** → §4-1 로 식별한 username 을 `reviewers` 에 전달한다. 자기 자신이 PR 작성자여서 Bitbucket DC 가 거부하면 `reviewer_self_skipped=true` 로 기록한다 (`stages/08-pr.md` §7-5).
|
|
87
|
+
> 종전 이 항목은 "리뷰어를 추가하지 않는다 (`reviewers` 파라미터 자체를 생략)" 였다. 같은 문서의 §4-1·§4-2 와도, `stages/08-pr.md` 와도, `gates/stage7-post-pr-verify.sh` 의 reviewer 마커 검사(`reviewer_added` XOR `reviewer_self_skipped`, 미기록 시 fail)와도 정면으로 어긋나 있었다. 게이트가 마커를 요구하는 이상 리뷰어는 추가하는 것이 맞다.
|
|
87
88
|
3. 생성 후 각 커밋별 diff 를 분석하여 **인라인 코멘트** 를 **개별 live 코멘트** 로 작성한다 (아래 §5–6).
|
|
88
89
|
|
|
89
90
|
### 4-1. 토큰 소유자 식별 (리뷰어 추가용)
|
package/scripts/run-tests.mts
CHANGED
|
@@ -50,6 +50,7 @@ const STEPS = [
|
|
|
50
50
|
"node --test test/doctor-phantom-scan.test.ts",
|
|
51
51
|
"node --test test/mcp-secret-injector.test.ts",
|
|
52
52
|
"node --test test/poll-sub-session.test.ts",
|
|
53
|
+
"node --test test/verifier-verdict.test.ts",
|
|
53
54
|
"node --test test/tmux-monitor.test.ts",
|
|
54
55
|
"node --test test/gate-already-done-block.test.ts",
|
|
55
56
|
"node --test test/gate-hybrid-first-entry.test.ts",
|
package/stages/08-pr.md
CHANGED
|
@@ -175,3 +175,30 @@ post-verify 실패 시:
|
|
|
175
175
|
|
|
176
176
|
|
|
177
177
|
|
|
178
|
+
|
|
179
|
+
## 7-7. publisher 세션이 반복 실패할 때 (대체 경로)
|
|
180
|
+
|
|
181
|
+
`dispatch_stage(3_delivery.pr)` 가 **연속 3회 이상** 실패 반환하면 (empty / session_gone / timeout — 즉 산출물 문제가 아니라 세션 자체가 결과를 못 낸 경우), 부장님(team-leader)은 아래 조건을 **모두** 확인한 뒤 남은 작업을 직접 완결할 수 있다.
|
|
182
|
+
|
|
183
|
+
> 근거: 실제로 이 단계가 4회 연속 empty 반환했고, 그 사이 `git push` 는 실제로 수행됐는데 PR 생성과 마커 기록만 미완으로 남았다. 재-dispatch 를 반복해도 같은 지점에서 멈춘다 — 남은 일에 git 명령이 없기 때문이다 (GitHub issue #7).
|
|
184
|
+
|
|
185
|
+
**진입 조건 (하나라도 false 면 진입 금지 — 사용자에게 보고하고 지시를 기다린다)**
|
|
186
|
+
|
|
187
|
+
| 항목 | 확인 방법 |
|
|
188
|
+
|---|---|
|
|
189
|
+
| 1 | `dispatch_stage(3_delivery.pr)` 가 3회 이상 `ok:false` 로 반환됐다 |
|
|
190
|
+
| 2 | `origin/<브랜치>` 가 이미 존재한다 (**push 가 끝나 있다**) — `git ls-remote` 는 부장님 권한 밖이므로 직전 publisher 세션 로그 또는 `hang_history` 로 확인 |
|
|
191
|
+
| 3 | 남은 작업에 **git 명령이 하나도 없다** — PR 생성(MCP)·마커 기록(`state.sh`)뿐이다 |
|
|
192
|
+
| 4 | `escalate: true` 를 받지 않았다 (받았으면 재디스패치·대체 실행 모두 금지, 사용자 에스컬레이션만) |
|
|
193
|
+
|
|
194
|
+
**허용 범위 (이것만 한다)**
|
|
195
|
+
|
|
196
|
+
- `bitbucket_createPullRequest` / `bitbucket_getPullRequest` / `bitbucket_updatePullRequest` — MCP 호출이며 git 명령이 아니다.
|
|
197
|
+
- `state.sh set` 으로 §완료 기록의 마커 기록.
|
|
198
|
+
- `gates/stage7-post-pr-verify.sh` 실행.
|
|
199
|
+
|
|
200
|
+
**금지 (조건 2 가 거짓이면 그냥 막힌다)**
|
|
201
|
+
|
|
202
|
+
- `git push` 를 비롯한 모든 git 명령. 부장님은 permission 이 deny 이고, 우회하지 않는다. push 가 남아 있으면 이 대체 경로를 쓸 수 없다 — 사용자에게 보고한다.
|
|
203
|
+
|
|
204
|
+
**보고**: `[3_delivery.pr PUBLISHER FALLBACK] publisher <N>회 실패 → 남은 작업(PR 생성·마커)에 git 없음을 확인하고 부장님이 직접 완결. push 는 <세션 ID> 에서 이미 완료됨`
|