makdoong2-team 2.2.0 → 2.3.1
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-planner.md +7 -3
- package/agents/makdoong2-team-leader.md +46 -1
- package/agents/makdoong2-verifier.md +1 -1
- package/bin/cli.js +17 -4
- package/bin/cli.ts +22 -3
- package/dist/opencode-plugin.js +98 -36
- package/dist/poll-sub-session.d.ts +2 -0
- package/dist/poll-sub-session.js +125 -16
- package/dist/verifier-verdict.d.ts +62 -0
- package/dist/verifier-verdict.js +110 -0
- package/gates/stage3-scope-verify.sh +7 -1
- package/package.json +1 -1
- package/references/pr-template.md +2 -1
- package/scripts/run-tests.mts +1 -0
- package/stages/01-planning.md +2 -0
- package/stages/02-requirements.md +2 -0
- package/stages/08-pr.md +27 -0
|
@@ -245,7 +245,9 @@ bash <SCRIPTS_DIR>/state.sh set {ISSUE_KEY} '.stages."1_planning".substages."sco
|
|
|
245
245
|
- 범주 하향(major→minor) — escalation만 허용.
|
|
246
246
|
- 다음 phase 작업(dev/test) 선행.
|
|
247
247
|
- **outer-world 에이전트(Sisyphus / Explore / Librarian / oh-my-openagent 계열 카테고리 등) 위임 금지.** 본 에이전트에는 `Task` 툴이 프론트매터에서 제거되어 있어 물리적으로 스폰 불가. 조사가 필요하면 반드시 `skill_mcp` 로 `jira-research` / `confluence-research` / `bitbucket-research` / `github-oss-research` 스킬만 사용. 이는 planning phase가 makdoong2 서브에이전트 체계 내에서 봉인되어야 한다는 아키텍처 원칙이다.
|
|
248
|
-
- **bash를 통한 파일 쓰기 리디렉션 일체 금지 (READ-ONLY 원칙).**
|
|
248
|
+
- **bash를 통한 파일 쓰기 리디렉션 일체 금지 (READ-ONLY 원칙).** 예외 2가지:
|
|
249
|
+
- `<SCRIPTS_DIR>/state.sh set ...` 을 통한 state.json 마커 기록.
|
|
250
|
+
- **planning 산출물(`.makdoong2-team/<이슈키>/` 아래 `*.md`·`*.json` — `requirements-draft.md` 등)은 `write` 툴로 직접 생성·갱신한다.** 이것은 READ-ONLY 위반이 아니라 stage spec(02-requirements.md §2-0b)이 부과한 **의무**이며, 훅도 이 경로의 `write` 를 명시적으로 허용한다. 단 같은 경로라도 bash 리디렉션·`apply_patch` 는 차단되므로 반드시 `write` 툴을 쓴다.
|
|
249
251
|
- **금지 패턴**: `echo >`, `cat > file`, `cat <<EOF > file`, `tee file`, `sed -i`, `awk ... > file`, `printf > file`, `> file`, `>> file`, `python -c "... open(..., 'w') ..."`, `node -e "... fs.writeFileSync(...) ..."`
|
|
250
252
|
- **위반 예시**:
|
|
251
253
|
```bash
|
|
@@ -264,5 +266,7 @@ bash <SCRIPTS_DIR>/state.sh set {ISSUE_KEY} '.stages."1_planning".substages."sco
|
|
|
264
266
|
# ✅ 허용: state.json 마커 기록 (state.sh 경유)
|
|
265
267
|
bash <SCRIPTS_DIR>/state.sh set PROJ-123 '.stages."1_planning".substages."scope".done' 'true'
|
|
266
268
|
```
|
|
267
|
-
- **위반 결과**: verifier가 `git status`로 untracked 파일 감지 → REJECTED 판정 → 워크플로우 중단
|
|
268
|
-
- **올바른 절차**:
|
|
269
|
+
- **위반 결과**: 훅이 bash 쓰기·`apply_patch` 를 차단하고, 우회에 성공하더라도 verifier가 `git status`로 untracked 파일 감지 → REJECTED 판정 → 워크플로우 중단
|
|
270
|
+
- **올바른 절차**:
|
|
271
|
+
- **planning 산출물(요구사항 초안 등 `.makdoong2-team/<이슈키>/*.md|json`)은 본인이 `write` 툴로 직접 생성한다.** team-leader 반환·dev 위임 대상이 아니다 — 그 시점 파이프라인에는 초안을 대신 만들 역할이 없어 워크플로가 정지한다 (issue #8).
|
|
272
|
+
- **소스 코드** 파일 생성·변경이 필요하면 spec을 team-leader에게 반환하여 dev 단계로 위임한다. Planning 단계는 "무엇을 만들지"만 결정하고, "실제로 만드는 것"은 implementation 단계의 책임이다.
|
|
@@ -98,6 +98,28 @@ permission:
|
|
|
98
98
|
|
|
99
99
|
`MAX_ATTEMPTS` 는 dispatch_stage **호출 1회 내부**의 예산이므로 재호출하면 리셋된다. `hang_history` 누적 상한은 그 리셋을 무력화하기 위한 **호출 간(cross-call) 차단막**이며, 부장님이 우회하면 무한 루프가 된다.
|
|
100
100
|
|
|
101
|
+
## 서브에이전트 능력을 frontmatter `tools:` 목록으로 판정하지 말 것 (hardrule)
|
|
102
|
+
|
|
103
|
+
에이전트 정의 파일의 frontmatter `tools:` 는 **whitelist 가 아니다** — 목록에 없는 툴도 permission 설정이 허용하면 실행된다. 실제로 planner frontmatter 에 `Write` 가 없다는 이유로 "planner 는 구조적으로 파일 생성이 불가능하다" 고 단정하고 워크플로를 중단시킨 오진단이 있었다 — 로그상 planner 는 `write` 를 정상 실행해 왔고 차단 이력이 0건이었다 (GitHub issue #8). 서브에이전트가 파일을 못 만들었다면 원인은 **훅 차단 메시지·permission 프롬프트 대기(PERMISSION_STALL)·프롬프트 지시** 중에 있다. frontmatter 를 근거로 사용자에게 에이전트 정의 수정을 요구하지 말고, 실패한 세션의 실제 차단 로그를 근거로 판단하라.
|
|
104
|
+
|
|
105
|
+
## verdict 는 셋이다 — REJECTED 와 ERROR 를 절대 섞지 말 것 (hardrule)
|
|
106
|
+
|
|
107
|
+
`dispatch_verifier` 의 `verdict` 는 `VERIFIED` / `REJECTED` / `ERROR` 세 값이다.
|
|
108
|
+
|
|
109
|
+
| verdict | 뜻 | 올바른 조치 |
|
|
110
|
+
|---|---|---|
|
|
111
|
+
| `VERIFIED` | 검증했고 통과 | 다음 substage 로 진행 |
|
|
112
|
+
| `REJECTED` | 검증했고 **산출물을 반려** | `.done=false` 로 되돌리고 `dispatch_stage` 재호출 |
|
|
113
|
+
| `ERROR` | **검증이 수행되지 않음** (verifier 세션 인프라 실패) | **`dispatch_verifier` 만 재호출.** stage 재실행 금지 |
|
|
114
|
+
|
|
115
|
+
`ERROR` 에서 stage 를 재실행하면 **아무 문제 없는 작업을 통째로 다시 시킨다.** 실제로 마커가 전부 정상이던 `1_planning.jira` 가 planner 200초 재가동으로 이어졌고, 재검증 결과는 `VERIFIED` 였다 — 원 작업에는 처음부터 결함이 없었다 (GitHub issue #7).
|
|
116
|
+
|
|
117
|
+
판정 근거는 `verdict_source` 에, 조치는 `next_action` 에 실려 온다. **`next_action` 을 그대로 따른다** (하드룰 4). `raw` / `parsed` 의 문구를 보고 스스로 해석하지 않는다 — 그 오판이 이 사고의 직접 원인이었다.
|
|
118
|
+
|
|
119
|
+
- `retryable: true` 는 "같은 인자로 verifier 만 다시 부르면 된다" 는 뜻이다. stage 재실행 신호가 **아니다**.
|
|
120
|
+
- `counts_as_rejection: false` 인 판정은 `rejected_count` / `same_reason_streak` 에 집계되지 않는다.
|
|
121
|
+
- ERROR 는 자체 연속 상한을 갖는다: `verifier_error_streak_exceeded: true` (3회 연속) 이면 재호출을 멈추고 사용자에게 보고한다.
|
|
122
|
+
|
|
101
123
|
## REJECTED 재시도 정책 (신규)
|
|
102
124
|
|
|
103
125
|
`dispatch_verifier` 가 `verdict: "REJECTED"` 를 반환하면 다음 규약을 따른다:
|
|
@@ -111,7 +133,18 @@ permission:
|
|
|
111
133
|
|
|
112
134
|
```
|
|
113
135
|
verdict = dispatch_verifier(issue, target_stage, worktree, result.output)
|
|
114
|
-
if verdict.verdict == "
|
|
136
|
+
if verdict.verdict == "ERROR":
|
|
137
|
+
# 검증이 수행되지 않았다 (인프라 실패). stage 산출물은 멀쩡하다.
|
|
138
|
+
# state.json 은 건드리지 않는다 — last_verdict_reason 도 기록되지 않았다.
|
|
139
|
+
if verdict.verifier_error_streak_exceeded == True:
|
|
140
|
+
# 3회 연속 판정 실패 — 모델·인프라 문제. 자동 재시도로 풀리지 않는다.
|
|
141
|
+
[사용자에게 verdict.verdict_source + verdict.verifier_error_streak 보고]
|
|
142
|
+
[세션 종료 후 사용자 결정 대기]
|
|
143
|
+
else:
|
|
144
|
+
# verifier 는 idempotent — 같은 인자로 그대로 재호출한다.
|
|
145
|
+
# .done 을 false 로 되돌리지 않는다. dispatch_stage 를 부르지 않는다.
|
|
146
|
+
[dispatch_verifier(issue, target_stage, worktree, result.output) 재호출]
|
|
147
|
+
elif verdict.verdict == "REJECTED":
|
|
115
148
|
# (자동) dispatch_verifier 가 state.json 에 사유 기록 완료
|
|
116
149
|
if verdict.same_reason_streak_exceeded == True:
|
|
117
150
|
# 동일 사유 5회 연속 — 무한루프 의심. 사용자 개입 필요.
|
|
@@ -214,6 +247,16 @@ loop (max_substage_retries=3 per substage):
|
|
|
214
247
|
# 2차 검증 (Verifier)
|
|
215
248
|
verdict = dispatch_verifier(issue, next.target_stage, worktree, result.output)
|
|
216
249
|
|
|
250
|
+
if verdict.verdict == "ERROR":
|
|
251
|
+
# 검증 미수행 (인프라 실패). verdict.next_action 이 지시하는 그대로 따른다.
|
|
252
|
+
# stage 재실행 금지 — 산출물에는 아무 문제가 없다.
|
|
253
|
+
if verdict.verifier_error_streak_exceeded == true:
|
|
254
|
+
[verdict.verdict_source + verdict.verifier_error_streak 사용자 보고]
|
|
255
|
+
[세션 종료 후 사용자 결정 대기]
|
|
256
|
+
continue
|
|
257
|
+
[dispatch_verifier 재호출 — 같은 인자, state.json 변경 없음]
|
|
258
|
+
continue
|
|
259
|
+
|
|
217
260
|
if verdict.verdict == "REJECTED":
|
|
218
261
|
# dispatch_verifier 가 state.json 에 verdict.raw / hash / streak 자동 기록 완료
|
|
219
262
|
# dispatch_stage 재호출 시 last_verdict_reason 이 자동으로 프롬프트에 주입됨.
|
|
@@ -246,6 +289,8 @@ loop (max_substage_retries=3 per substage):
|
|
|
246
289
|
- 모델 폴백 시: `[fallback] <agent>: <primary> → <fallback> (reason: ...)`
|
|
247
290
|
- `retry_disallowed` 감지 시: `[substage X RETRY DISALLOWED] outcome=timeout, transient_failures=0 — sub-agent hang. <retry_disallowed_reason>`
|
|
248
291
|
- `session_gone` 최종 실패 시: `[substage X SESSION_GONE gone_reason=<message_stall|status_absent>] dispatch_stage 3회 자동 redispatch 후 실패. attempts=<N>, previous_session_ids=[...]`
|
|
292
|
+
- verifier ERROR 시: `[substage X VERIFIER ERROR source=<verdict_source>] 검증 미수행 — verifier 만 재호출 (streak=<N>/3). stage 는 건드리지 않음`
|
|
293
|
+
- verifier ERROR streak 초과 시: `[substage X VERIFIER ERROR STREAK EXCEEDED] 판정 3회 연속 실패. source=<verdict_source>. 사용자 개입 필요.`
|
|
249
294
|
- REJECTED 재시도 시: `[substage X REJECTED retry] streak=<N>, reason_prefix="<40자>" → dispatch_stage 재호출`
|
|
250
295
|
- REJECTED streak 5회 초과 시: `[substage X REJECTED STREAK EXCEEDED] 동일 사유 <streak>회 연속 실패. verdict.raw:\n<verdict.raw 전문>\n\n사용자 개입 필요.`
|
|
251
296
|
- 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
|
@@ -20,7 +20,7 @@ import { fileURLToPath } from "node:url";
|
|
|
20
20
|
import { dirname, join, resolve } from "node:path";
|
|
21
21
|
import { execFileSync } from "node:child_process";
|
|
22
22
|
import { buildPoliciesFromConfig, DEFAULT_ALLOWED_PRIMARIES, } from "../scripts/model-policy.mjs";
|
|
23
|
-
import { install, uninstall, resolveConfigDir, parseJsonc, readCachedVersion, opencodeCacheRoot, } from "../scripts/install-lib.mjs";
|
|
23
|
+
import { install, uninstall, resolveConfigDir, parseJsonc, readCachedVersion, opencodeCacheRoot, computeExternalDirPaths, } from "../scripts/install-lib.mjs";
|
|
24
24
|
const HERE = dirname(fileURLToPath(import.meta.url));
|
|
25
25
|
const PKG_ROOT = resolve(HERE, "..");
|
|
26
26
|
const PKG = JSON.parse(readFileSync(join(PKG_ROOT, "package.json"), "utf8"));
|
|
@@ -135,7 +135,14 @@ function doDoctor(flags) {
|
|
|
135
135
|
check(existsSync(join(PKG_ROOT, d)), `${d}/ in package`, `${d}/ missing in package (reinstall: npm install -g makdoong2-team)`);
|
|
136
136
|
}
|
|
137
137
|
const ocPath = join(DEST, "opencode.json");
|
|
138
|
-
|
|
138
|
+
// 설치가 심도록 설계된 external_directory 시드. 에이전트 프롬프트가 서브에이전트
|
|
139
|
+
// 세션에 워크스페이스 외부 경로(<pkgRoot>/stages/*.md 등) 읽기를 지시하므로,
|
|
140
|
+
// 이 allow 가 없으면 서브에이전트가 응답 불가한 권한 프롬프트에 걸려
|
|
141
|
+
// PERMISSION_STALL 로 중단된다 (issue #8). 파일 배포만 성공하고 opencode.json
|
|
142
|
+
// 패치가 남지 않은 부분 설치를 doctor 가 잡아야 한다.
|
|
143
|
+
const requiredExtDirPaths = computeExternalDirPaths(PKG_ROOT, DEST);
|
|
144
|
+
let pluginOk = false, toolsOk = false, extDirOk = false, ocParseOk = false, ocParseErr = "";
|
|
145
|
+
let extDirMissing = requiredExtDirPaths;
|
|
139
146
|
if (existsSync(ocPath)) {
|
|
140
147
|
try {
|
|
141
148
|
const oc = parseJsonc(readFileSync(ocPath, "utf8"));
|
|
@@ -155,14 +162,20 @@ function doDoctor(flags) {
|
|
|
155
162
|
});
|
|
156
163
|
const ocTools = oc.tools;
|
|
157
164
|
toolsOk = !!ocTools && TOOLS.every((t) => ocTools[t] === true);
|
|
165
|
+
const extDir = oc.permission?.external_directory;
|
|
166
|
+
extDirMissing = requiredExtDirPaths.filter((p) => !extDir || typeof extDir !== "object" || extDir[p] !== "allow");
|
|
167
|
+
extDirOk = extDirMissing.length === 0;
|
|
158
168
|
}
|
|
159
169
|
catch (e) {
|
|
160
170
|
ocParseErr = e instanceof Error ? e.message : String(e);
|
|
161
171
|
}
|
|
162
172
|
}
|
|
163
173
|
check(!existsSync(ocPath) || ocParseOk, "opencode.json parses (JSONC tolerated)", `opencode.json is unparseable — install silently skips patching it: ${ocParseErr}`);
|
|
164
|
-
check(pluginOk, "opencode.json registers the plugin", "opencode.json missing the plugin entry");
|
|
165
|
-
check(toolsOk, "opencode.json enables custom tools", "opencode.json missing custom tools");
|
|
174
|
+
check(pluginOk, "opencode.json registers the plugin", "opencode.json missing the plugin entry (run: npx makdoong2-team install)");
|
|
175
|
+
check(toolsOk, "opencode.json enables custom tools", "opencode.json missing custom tools (run: npx makdoong2-team install)");
|
|
176
|
+
check(extDirOk, "opencode.json allows external_directory (pkgRoot + configDir)", `opencode.json missing permission.external_directory allow(s): ${extDirMissing.join(", ")} ` +
|
|
177
|
+
`— sub-agents will hit unanswerable permission prompts and abort with PERMISSION_STALL ` +
|
|
178
|
+
`(run: npx makdoong2-team install)`);
|
|
166
179
|
// The plugin opencode actually loads lives in the `<name>@latest` cache dir,
|
|
167
180
|
// not in the globally installed npm module. When those drift the user runs
|
|
168
181
|
// old code while `npm ls -g` reports the new version — invisible without
|
package/bin/cli.ts
CHANGED
|
@@ -33,6 +33,7 @@ import {
|
|
|
33
33
|
parseJsonc,
|
|
34
34
|
readCachedVersion,
|
|
35
35
|
opencodeCacheRoot,
|
|
36
|
+
computeExternalDirPaths,
|
|
36
37
|
} from "../scripts/install-lib.mts";
|
|
37
38
|
|
|
38
39
|
const HERE = dirname(fileURLToPath(import.meta.url));
|
|
@@ -150,7 +151,14 @@ function doDoctor(flags: Flags): never {
|
|
|
150
151
|
}
|
|
151
152
|
|
|
152
153
|
const ocPath = join(DEST, "opencode.json");
|
|
153
|
-
|
|
154
|
+
// 설치가 심도록 설계된 external_directory 시드. 에이전트 프롬프트가 서브에이전트
|
|
155
|
+
// 세션에 워크스페이스 외부 경로(<pkgRoot>/stages/*.md 등) 읽기를 지시하므로,
|
|
156
|
+
// 이 allow 가 없으면 서브에이전트가 응답 불가한 권한 프롬프트에 걸려
|
|
157
|
+
// PERMISSION_STALL 로 중단된다 (issue #8). 파일 배포만 성공하고 opencode.json
|
|
158
|
+
// 패치가 남지 않은 부분 설치를 doctor 가 잡아야 한다.
|
|
159
|
+
const requiredExtDirPaths = computeExternalDirPaths(PKG_ROOT, DEST);
|
|
160
|
+
let pluginOk = false, toolsOk = false, extDirOk = false, ocParseOk = false, ocParseErr = "";
|
|
161
|
+
let extDirMissing: string[] = requiredExtDirPaths;
|
|
154
162
|
if (existsSync(ocPath)) {
|
|
155
163
|
try {
|
|
156
164
|
const oc = parseJsonc(readFileSync(ocPath, "utf8"));
|
|
@@ -168,12 +176,23 @@ function doDoctor(flags: Flags): never {
|
|
|
168
176
|
});
|
|
169
177
|
const ocTools = oc.tools;
|
|
170
178
|
toolsOk = !!ocTools && TOOLS.every((t) => ocTools[t] === true);
|
|
179
|
+
const extDir = oc.permission?.external_directory;
|
|
180
|
+
extDirMissing = requiredExtDirPaths.filter(
|
|
181
|
+
(p: string) => !extDir || typeof extDir !== "object" || extDir[p] !== "allow",
|
|
182
|
+
);
|
|
183
|
+
extDirOk = extDirMissing.length === 0;
|
|
171
184
|
} catch (e) { ocParseErr = e instanceof Error ? e.message : String(e); }
|
|
172
185
|
}
|
|
173
186
|
check(!existsSync(ocPath) || ocParseOk, "opencode.json parses (JSONC tolerated)",
|
|
174
187
|
`opencode.json is unparseable — install silently skips patching it: ${ocParseErr}`);
|
|
175
|
-
check(pluginOk, "opencode.json registers the plugin",
|
|
176
|
-
|
|
188
|
+
check(pluginOk, "opencode.json registers the plugin",
|
|
189
|
+
"opencode.json missing the plugin entry (run: npx makdoong2-team install)");
|
|
190
|
+
check(toolsOk, "opencode.json enables custom tools",
|
|
191
|
+
"opencode.json missing custom tools (run: npx makdoong2-team install)");
|
|
192
|
+
check(extDirOk, "opencode.json allows external_directory (pkgRoot + configDir)",
|
|
193
|
+
`opencode.json missing permission.external_directory allow(s): ${extDirMissing.join(", ")} ` +
|
|
194
|
+
`— sub-agents will hit unanswerable permission prompts and abort with PERMISSION_STALL ` +
|
|
195
|
+
`(run: npx makdoong2-team install)`);
|
|
177
196
|
|
|
178
197
|
// The plugin opencode actually loads lives in the `<name>@latest` cache dir,
|
|
179
198
|
// not in the globally installed npm module. When those drift the user runs
|
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 식별을 우회한다.
|
|
@@ -1157,9 +1163,12 @@ export const Makdoong2TeamPlugin = async ({ $, client, directory, worktree }) =>
|
|
|
1157
1163
|
`${filePath ? `"${filePath}" 에 ` : ""}쓸 수 없다.\n` +
|
|
1158
1164
|
(allowed === null
|
|
1159
1165
|
? `이 에이전트는 파일을 쓰지 않는다 — 조사 결과는 최종 응답 텍스트로 반환하라.\n`
|
|
1160
|
-
: `허용된 산출물은 .makdoong2-team/<이슈키>/ 아래의 지정된
|
|
1161
|
-
`(패턴: ${allowed.source}).\n`
|
|
1162
|
-
|
|
1166
|
+
: `허용된 산출물은 .makdoong2-team/<이슈키>/ 아래의 지정된 파일뿐이다 ` +
|
|
1167
|
+
`(패턴: ${allowed.source}).\n` +
|
|
1168
|
+
`그 경로에는 지금 즉시 'write' 툴(filePath 인자)로 쓸 수 있다 — ` +
|
|
1169
|
+
`bash 리디렉션·apply_patch 우회가 차단된 것이지 산출물 쓰기 자체가 금지된 것이 아니다. ` +
|
|
1170
|
+
`산출물이 필요하면 포기하지 말고 write 툴로 재시도하라.\n`) +
|
|
1171
|
+
`소스 코드 변경이 필요하면 그 사실을 산출물/응답에 적고 engineer 단계로 넘겨라.`);
|
|
1163
1172
|
}
|
|
1164
1173
|
}
|
|
1165
1174
|
// ── Leader hardrule 1: 직접 파일 편집·생성 금지 (write/edit/patch/multiedit) ──
|
|
@@ -2020,11 +2029,29 @@ export const Makdoong2TeamPlugin = async ({ $, client, directory, worktree }) =>
|
|
|
2020
2029
|
}
|
|
2021
2030
|
promptPromise.catch(() => { });
|
|
2022
2031
|
if (success) {
|
|
2023
|
-
|
|
2024
|
-
|
|
2025
|
-
|
|
2026
|
-
|
|
2027
|
-
|
|
2032
|
+
// hang_history 리셋 조건은 "dispatch 정상 반환" 이 아니라 "substage
|
|
2033
|
+
// 실제 완료(done=true)" 다. 종전에는 세션이 텍스트만 뱉고 done=false
|
|
2034
|
+
// 로 끝나도 리셋됐고, 재-dispatch 를 반복하는 동안 이력이 매번
|
|
2035
|
+
// 비워져 stall_escalate_threshold 가 사실상 도달 불가였다 (issue #8).
|
|
2036
|
+
// cwd 는 effectiveWorktree — 이 substage 의 다른 state.json 접근과
|
|
2037
|
+
// 같은 파일을 봐야 한다 (args.worktree 를 쓰면 교정 발동 시 갈린다).
|
|
2038
|
+
const resetDonePath = `${stageJqPath(args.target_stage)}.done`;
|
|
2039
|
+
const resetDoneR = await $ `bash ${SCRIPTS_DIR}/state.sh get ${args.issue} ${resetDonePath}`
|
|
2040
|
+
.cwd(effectiveWorktree).quiet().nothrow();
|
|
2041
|
+
const resetDoneValue = resetDoneR.exitCode === 0
|
|
2042
|
+
? (resetDoneR.stdout?.toString().trim() ?? null)
|
|
2043
|
+
: null;
|
|
2044
|
+
if (resetDoneValue === "true") {
|
|
2045
|
+
const resetPath = `${stageJqPath(args.target_stage)}.hang_history`;
|
|
2046
|
+
const resetR = await $ `bash ${SCRIPTS_DIR}/state.sh set ${args.issue} ${resetPath} ${"[]"}`
|
|
2047
|
+
.cwd(effectiveWorktree).quiet().nothrow();
|
|
2048
|
+
logger.debug(`[hang_history] reset issue=${args.issue} stage=${args.target_stage} ` +
|
|
2049
|
+
`exit=${resetR.exitCode} — substage done=true`);
|
|
2050
|
+
}
|
|
2051
|
+
else {
|
|
2052
|
+
logger.debug(`[hang_history] reset skipped issue=${args.issue} stage=${args.target_stage} ` +
|
|
2053
|
+
`done=${resetDoneValue} — dispatch 는 정상 반환했지만 substage 미완료`);
|
|
2054
|
+
}
|
|
2028
2055
|
}
|
|
2029
2056
|
const retryDisallowed = finalOutcome.kind === "timeout" &&
|
|
2030
2057
|
finalOutcome.transientFailures === 0;
|
|
@@ -2087,10 +2114,17 @@ export const Makdoong2TeamPlugin = async ({ $, client, directory, worktree }) =>
|
|
|
2087
2114
|
* just reported done. Read-only sub-agent reads state.json self_check markers,
|
|
2088
2115
|
* the stage spec, and the dispatcher's output, and emits a structured verdict
|
|
2089
2116
|
* of `<verifier-verdict>VERIFIED</verifier-verdict>` or `REJECTED`.
|
|
2117
|
+
*
|
|
2118
|
+
* 반환 verdict 는 셋이다. `ERROR` 는 "검증이 수행되지 않았다"(세션 인프라
|
|
2119
|
+
* 실패)로, 콘텐츠 반려인 `REJECTED` 와 조치가 정반대다 — 전자는 verifier 만
|
|
2120
|
+
* 재호출, 후자는 stage 재실행. 판정 근거는 `verdict_source`, 지시는
|
|
2121
|
+
* `next_action` 이 싣는다 (src/verifier-verdict.ts).
|
|
2090
2122
|
*/
|
|
2091
2123
|
dispatch_verifier: tool({
|
|
2092
2124
|
description: "Spawn the makdoong2-verifier sub-agent to second-check a stage's completion. " +
|
|
2093
|
-
"Returns { ok, verdict: 'VERIFIED' | 'REJECTED',
|
|
2125
|
+
"Returns { ok, verdict: 'VERIFIED' | 'REJECTED' | 'ERROR', verdict_source, retryable, " +
|
|
2126
|
+
"next_action, raw, session_id }. verdict='ERROR' means the verifier session failed " +
|
|
2127
|
+
"before producing a verdict — re-run dispatch_verifier only; do NOT re-run the stage.",
|
|
2094
2128
|
args: {
|
|
2095
2129
|
issue: tool.schema.string().describe("Jira issue key, e.g. PROJ-12345"),
|
|
2096
2130
|
stage: tool.schema.enum(STAGE_ORDER),
|
|
@@ -2224,8 +2258,8 @@ export const Makdoong2TeamPlugin = async ({ $, client, directory, worktree }) =>
|
|
|
2224
2258
|
? " (message_stall detected)"
|
|
2225
2259
|
: "";
|
|
2226
2260
|
logger.warn(`[dispatch_verifier] SESSION_GONE session=${subSessionID} stage=${args.stage}` +
|
|
2227
|
-
`${stallReason} —
|
|
2228
|
-
`(verifier is idempotent, team-leader can redispatch)`);
|
|
2261
|
+
`${stallReason} — verdict=ERROR (판정 없음), no retry ` +
|
|
2262
|
+
`(verifier is idempotent, team-leader can redispatch the verifier alone)`);
|
|
2229
2263
|
const vHangEntry = JSON.stringify({
|
|
2230
2264
|
attempt: 1,
|
|
2231
2265
|
at: new Date().toISOString(),
|
|
@@ -2268,30 +2302,48 @@ export const Makdoong2TeamPlugin = async ({ $, client, directory, worktree }) =>
|
|
|
2268
2302
|
skipSessionOps: verifierSessionGone,
|
|
2269
2303
|
});
|
|
2270
2304
|
}
|
|
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";
|
|
2305
|
+
const classification = classifyVerifierOutcome({
|
|
2306
|
+
raw,
|
|
2307
|
+
success,
|
|
2308
|
+
sessionGone: verifierSessionGone,
|
|
2309
|
+
});
|
|
2310
|
+
const verdict = classification.verdict;
|
|
2311
|
+
const verdictSource = classification.source;
|
|
2287
2312
|
logger.debug(`[dispatch_verifier] VERDICT ${verdict} — issue=${args.issue} stage=${args.stage} ` +
|
|
2288
|
-
`session=${subSessionID} source=${verdictSource} success=${success}`
|
|
2313
|
+
`session=${subSessionID} source=${verdictSource} success=${success} ` +
|
|
2314
|
+
`retryable=${classification.retryable} counts_as_rejection=${classification.countsAsRejection}`);
|
|
2289
2315
|
const stageBase = stageJqPath(args.stage);
|
|
2290
2316
|
let sameReasonStreak = 0;
|
|
2291
2317
|
let sameReasonStreakExceeded = false;
|
|
2292
2318
|
let rejectedCount = 0;
|
|
2319
|
+
let verifierErrorStreak = 0;
|
|
2320
|
+
let verifierErrorStreakHit = false;
|
|
2293
2321
|
const SAME_REASON_STREAK_LIMIT = 5;
|
|
2294
|
-
if (verdict === "
|
|
2322
|
+
if (verdict === "ERROR") {
|
|
2323
|
+
// 검증이 **수행되지 않았다**. 반려 집계에 넣지 않는다.
|
|
2324
|
+
//
|
|
2325
|
+
// 여기서 REJECTED 로 집계하면 stage 산출물에 아무 문제가 없는데도
|
|
2326
|
+
// `rejected_count` 가 오르고 `last_verdict_reason` 에 "verifier session
|
|
2327
|
+
// failed" 가 박힌다. team-leader 는 그것을 반려로 읽고 stage 를 통째로
|
|
2328
|
+
// 재실행한다 — 실제로 마커가 전부 정상인 1_planning.jira 가 planner
|
|
2329
|
+
// 200초 재가동으로 이어졌고 재검증은 VERIFIED 였다 (issue #7).
|
|
2330
|
+
//
|
|
2331
|
+
// 대신 ERROR 전용 연속 카운터로 무한 재호출만 막는다.
|
|
2332
|
+
const prevErrR = await $ `bash ${SCRIPTS_DIR}/state.sh get ${args.issue} ${stageBase + ".verifier_error_streak"}`
|
|
2333
|
+
.cwd(args.worktree).quiet().nothrow();
|
|
2334
|
+
const prevErr = prevErrR.exitCode === 0
|
|
2335
|
+
? (parseInt(prevErrR.stdout?.toString().trim() || "0", 10) || 0)
|
|
2336
|
+
: 0;
|
|
2337
|
+
verifierErrorStreak = nextVerifierErrorStreak(prevErr, classification);
|
|
2338
|
+
verifierErrorStreakHit = verifierErrorStreakExceeded(verifierErrorStreak);
|
|
2339
|
+
await $ `bash ${SCRIPTS_DIR}/state.sh set ${args.issue} ${stageBase + ".verifier_error_streak"} ${String(verifierErrorStreak)}`
|
|
2340
|
+
.cwd(args.worktree).quiet().nothrow();
|
|
2341
|
+
logger.warn(`[dispatch_verifier] VERIFIER_ERROR — issue=${args.issue} stage=${args.stage} ` +
|
|
2342
|
+
`source=${verdictSource} streak=${verifierErrorStreak}/${VERIFIER_ERROR_STREAK_LIMIT} ` +
|
|
2343
|
+
`— 판정 없음. verifier 재호출만이 올바른 조치다 (stage 재실행 금지)` +
|
|
2344
|
+
(verifierErrorStreakHit ? ` ERROR_STREAK_EXCEEDED` : ``));
|
|
2345
|
+
}
|
|
2346
|
+
else if (classification.countsAsRejection) {
|
|
2295
2347
|
const reasonText = raw.trim().slice(0, 4000);
|
|
2296
2348
|
const reasonHash = computeVerdictHash(raw, args.stage);
|
|
2297
2349
|
const prevHashR = await $ `bash ${SCRIPTS_DIR}/state.sh get ${args.issue} ${stageBase + ".last_verdict_reason_hash"}`
|
|
@@ -2336,11 +2388,25 @@ export const Makdoong2TeamPlugin = async ({ $, client, directory, worktree }) =>
|
|
|
2336
2388
|
await $ `bash ${SCRIPTS_DIR}/state.sh set ${args.issue} ${stageBase + ".same_reason_streak"} 0`
|
|
2337
2389
|
.cwd(args.worktree).quiet().nothrow();
|
|
2338
2390
|
}
|
|
2391
|
+
// 판정을 얻었으면(VERIFIED / REJECTED 무관) ERROR 연속 카운터는 리셋한다.
|
|
2392
|
+
// 인프라가 회복됐다는 뜻이므로 직전 실패를 계속 들고 갈 이유가 없다.
|
|
2393
|
+
if (verdict !== "ERROR") {
|
|
2394
|
+
await $ `bash ${SCRIPTS_DIR}/state.sh set ${args.issue} ${stageBase + ".verifier_error_streak"} 0`
|
|
2395
|
+
.cwd(args.worktree).quiet().nothrow();
|
|
2396
|
+
}
|
|
2339
2397
|
await $ `bash ${SCRIPTS_DIR}/log-event.sh ${args.issue} verifier_verdict stage=${args.stage} verdict=${verdict} session=${subSessionID}`
|
|
2340
2398
|
.cwd(args.worktree).quiet().nothrow();
|
|
2341
2399
|
return JSON.stringify({
|
|
2342
2400
|
ok: success,
|
|
2343
2401
|
verdict,
|
|
2402
|
+
// 종전에는 이 세 값이 없어서 호출자가 "왜 REJECTED 인지" 를 알 수 없었다.
|
|
2403
|
+
// verdictSource 는 이미 계산되어 로그에만 남고 있었다 (issue #7).
|
|
2404
|
+
verdict_source: verdictSource,
|
|
2405
|
+
// retryable=true 는 "같은 인자로 verifier 만 다시 부르면 된다" 는 뜻이다.
|
|
2406
|
+
// stage 재실행 신호가 아니다 — next_action 이 그 점을 못 박는다.
|
|
2407
|
+
retryable: classification.retryable,
|
|
2408
|
+
counts_as_rejection: classification.countsAsRejection,
|
|
2409
|
+
next_action: classification.nextAction,
|
|
2344
2410
|
stage: args.stage,
|
|
2345
2411
|
agent: verifierId,
|
|
2346
2412
|
model: modelFull,
|
|
@@ -2349,13 +2415,9 @@ export const Makdoong2TeamPlugin = async ({ $, client, directory, worktree }) =>
|
|
|
2349
2415
|
same_reason_streak: sameReasonStreak,
|
|
2350
2416
|
same_reason_streak_exceeded: sameReasonStreakExceeded,
|
|
2351
2417
|
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)})`,
|
|
2418
|
+
verifier_error_streak: verifierErrorStreak,
|
|
2419
|
+
verifier_error_streak_exceeded: verifierErrorStreakHit,
|
|
2420
|
+
parsed: classification.parsed,
|
|
2359
2421
|
});
|
|
2360
2422
|
},
|
|
2361
2423
|
}),
|
|
@@ -83,6 +83,7 @@ export type PollOutcome = {
|
|
|
83
83
|
stalledMs: number;
|
|
84
84
|
permissionID?: string;
|
|
85
85
|
permissionType?: string;
|
|
86
|
+
permissionPatterns?: string[];
|
|
86
87
|
} | {
|
|
87
88
|
kind: "session_gone";
|
|
88
89
|
polls: number;
|
|
@@ -106,6 +107,7 @@ export interface PollOptions {
|
|
|
106
107
|
messageStallThresholdMs?: number;
|
|
107
108
|
statusAbsentGraceMs?: number;
|
|
108
109
|
isRecentlyActive?: () => boolean;
|
|
110
|
+
isToolExecuting?: () => boolean;
|
|
109
111
|
contentStableCompletionMs?: number;
|
|
110
112
|
preambleOnlyTextThreshold?: number;
|
|
111
113
|
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
|
}
|
|
@@ -317,18 +328,57 @@ export async function pollSubSession(client, sessionId, options = {}) {
|
|
|
317
328
|
stalledMs,
|
|
318
329
|
permissionID: p.id,
|
|
319
330
|
permissionType: p.permission,
|
|
331
|
+
permissionPatterns: p.patterns,
|
|
320
332
|
};
|
|
321
333
|
}
|
|
322
334
|
}
|
|
323
335
|
}
|
|
324
|
-
|
|
325
|
-
|
|
336
|
+
// Tool-call stall — "툴 호출이 떠 있는데 아무 진전이 없다".
|
|
337
|
+
//
|
|
338
|
+
// 이 휴리스틱이 잡으려는 것은 **서브에이전트 컨텍스트에서 답할 수 없는 권한
|
|
339
|
+
// 요청에 막힌** 툴 호출이다. 실행 자체가 오래 걸리는 툴(sbt·docker·gradle
|
|
340
|
+
// 빌드)은 대상이 아니다 — 그런데 둘은 `stalledMs` 만 보면 구분되지 않는다.
|
|
341
|
+
// tool part 의 state 변화는 시그니처(`id:partsLen:textLen`)를 바꾸지 않으므로
|
|
342
|
+
// 5분짜리 빌드도 "진전 없음" 으로 보이고, 기본 임계 60초에서 abort 된다.
|
|
343
|
+
// `hasPendingToolCall` 이 프로덕션에서 항상 false 였던 동안에는 이 경로가 한
|
|
344
|
+
// 번도 발화하지 않아 드러나지 않았고, 그 값을 고치는 순간 무장됐다.
|
|
345
|
+
//
|
|
346
|
+
// `isToolExecuting()` 이 그 둘을 가른다. before 훅이 발화하고 after 훅이 아직
|
|
347
|
+
// 안 돈 상태 = 툴이 실제로 **실행 중**이다. 이때는 죽이지 않는다. 진짜 권한
|
|
348
|
+
// 대기는 매 폴 도는 `permission.list()` 경로가 요청 ID·패턴까지 짚어 정확히
|
|
349
|
+
// 잡아내고, 그마저 실패해도 절대 타임아웃이 받쳐준다. 이 모듈의 위험 선호와
|
|
350
|
+
// 같은 방향이다 — 미탐(살아있는 세션을 죽임)이 오탐(타임아웃까지 기다림)보다
|
|
351
|
+
// 비싸다.
|
|
352
|
+
if (hasPendingToolCall && toolExecuting && stalledMs >= toolCallStallThresholdMs && !toolStallExemptLogged) {
|
|
353
|
+
toolStallExemptLogged = true;
|
|
354
|
+
dbg?.(`[pollSubSession] TOOL_STALL_EXEMPT session=${sessionId} poll=${pollCount} ` +
|
|
355
|
+
`stalled_ms=${stalledMs} threshold_ms=${toolCallStallThresholdMs} ` +
|
|
356
|
+
`— 툴이 실행 중이므로 permission_stall 로 판정하지 않는다`);
|
|
357
|
+
}
|
|
358
|
+
if (hasPendingToolCall && !toolExecuting && stalledMs >= toolCallStallThresholdMs) {
|
|
359
|
+
// abort 전에 대기 중인 permission 요청을 1회 조회해 어떤 카테고리·경로가
|
|
360
|
+
// 대기 중인지 abort 메시지에 남긴다 (issue #8 제안 2). 종전에는 폴러
|
|
361
|
+
// 로그만으로 대기 대상을 특정할 수 없어 원인 규명에 코드 독해가 필요했다.
|
|
362
|
+
// best-effort — client.permission 이 없거나(구버전 SDK) 조회가 실패해도
|
|
363
|
+
// 종전과 동일한 무정보 outcome 으로 진행한다.
|
|
364
|
+
let stalledPerm;
|
|
365
|
+
if (client.permission) {
|
|
366
|
+
const permResult = await client.permission.list().catch(() => null);
|
|
367
|
+
stalledPerm = (permResult?.data ?? []).find(p => p.sessionID === sessionId);
|
|
368
|
+
}
|
|
369
|
+
err(`[pollSubSession] PERMISSION_STALL session=${sessionId} polls=${pollCount} stalledMs=${stalledMs}` +
|
|
370
|
+
(stalledPerm
|
|
371
|
+
? ` pending permissionID=${stalledPerm.id} type=${stalledPerm.permission} patterns=${JSON.stringify(stalledPerm.patterns)}`
|
|
372
|
+
: ` pending permission unknown (permission.list unavailable or empty)`));
|
|
326
373
|
await client.session.abort({ path: { id: sessionId } }).catch(() => undefined);
|
|
327
374
|
return {
|
|
328
375
|
kind: "permission_stall",
|
|
329
376
|
polls: pollCount,
|
|
330
377
|
elapsedMs: now() - startTime,
|
|
331
378
|
stalledMs,
|
|
379
|
+
permissionID: stalledPerm?.id,
|
|
380
|
+
permissionType: stalledPerm?.permission,
|
|
381
|
+
permissionPatterns: stalledPerm?.patterns,
|
|
332
382
|
};
|
|
333
383
|
}
|
|
334
384
|
// Message stall: sub-agent registered (sessionEverAppeared) and appears
|
|
@@ -365,7 +415,7 @@ export async function pollSubSession(client, sessionId, options = {}) {
|
|
|
365
415
|
if (messageStallThresholdMs !== undefined &&
|
|
366
416
|
(sessionEverAppeared || sessionAliveByMessages) &&
|
|
367
417
|
busyIndicated &&
|
|
368
|
-
!
|
|
418
|
+
!toolInFlight &&
|
|
369
419
|
stalledFromProgress >= messageStallThresholdMs) {
|
|
370
420
|
const hangMode = lastAssistant ? "mid_stream" : "bootstrap";
|
|
371
421
|
err(`[pollSubSession] MESSAGE_STALL session=${sessionId} polls=${pollCount} ` +
|
|
@@ -380,7 +430,7 @@ export async function pollSubSession(client, sessionId, options = {}) {
|
|
|
380
430
|
reason: "message_stall",
|
|
381
431
|
};
|
|
382
432
|
}
|
|
383
|
-
const finishComplete = hasFinish && !
|
|
433
|
+
const finishComplete = hasFinish && !toolInFlight && properOrdering;
|
|
384
434
|
// INV-1: `!status` alone is not idle. Require `status.type === "idle"` AND
|
|
385
435
|
// that we saw the session in the status map at least once (or that the
|
|
386
436
|
// messages list already contains an assistant response).
|
|
@@ -393,12 +443,46 @@ export async function pollSubSession(client, sessionId, options = {}) {
|
|
|
393
443
|
// statusIdle nor finishComplete ever fires (worktree-CWD, no finish field).
|
|
394
444
|
const contentStable = contentStableCompletionMs !== undefined &&
|
|
395
445
|
hasProducedAssistantMessage &&
|
|
396
|
-
!
|
|
446
|
+
!toolInFlight &&
|
|
397
447
|
stalledFromProgress >= contentStableCompletionMs;
|
|
398
|
-
|
|
448
|
+
// finish 단독 완료는 한 폴 더 확인하고 결론낸다.
|
|
449
|
+
//
|
|
450
|
+
// `finish` 는 "이번 assistant 메시지의 생성이 끝났다" 는 뜻이지 "세션이 끝났다"
|
|
451
|
+
// 가 아니다. 모델이 tool call 로 턴을 마치면 그 순간 finish 가 붙고 곧바로 툴이
|
|
452
|
+
// 실행된다. 그 사이(관측 110ms)에 폴이 끼면 tool part 는 아직 안 보이고 finish
|
|
453
|
+
// 만 보여 **완료로 오판**한다 (issue #7). statusIdle(서버가 직접 idle 이라고
|
|
454
|
+
// 말함)이나 contentStable(5분 무변화)이 함께 서 있으면 그런 오판이 아니므로
|
|
455
|
+
// 즉시 결론내고, finish 뿐일 때만 한 폴(기본 2s) 뒤 재확인한다. 그 시점이면
|
|
456
|
+
// tool part 가 메시지에 반영돼 있거나 isToolExecuting 이 참이라 판정이 취소된다.
|
|
457
|
+
//
|
|
458
|
+
// 재확인의 기준은 **content 시그니처 동일**이다. 시그니처가 바뀌었다는 것은
|
|
459
|
+
// 이번 폴에서 내용이 움직였다는 뜻이고, 움직이는 중에는 결론내지 않는다.
|
|
460
|
+
// (관측된 오판 2건 모두 `contentStable=false` — 내용이 아직 안정되지 않은
|
|
461
|
+
// 상태에서 완료 판정이 내려졌다.)
|
|
462
|
+
//
|
|
463
|
+
// 비용은 finish 단독 경로에서 폴 1회다. worktree-CWD 세션(status map 이 CWD
|
|
464
|
+
// 필터링되어 statusIdle 이 영영 안 뜬다)이 이 경로를 상시로 타지만, substage
|
|
465
|
+
// 하나가 수 분인 것에 비하면 2초는 무시할 수 있다.
|
|
466
|
+
const finishOnlyCompletion = finishComplete && !statusIdle && !contentStable;
|
|
467
|
+
let deferredForConfirm = false;
|
|
468
|
+
if (finishOnlyCompletion) {
|
|
469
|
+
if (finishConfirmSig !== currentAsstSig) {
|
|
470
|
+
finishConfirmSig = currentAsstSig;
|
|
471
|
+
deferredForConfirm = true;
|
|
472
|
+
}
|
|
473
|
+
}
|
|
474
|
+
else {
|
|
475
|
+
finishConfirmSig = null;
|
|
476
|
+
}
|
|
477
|
+
const looksComplete = !deferredForConfirm && (statusIdle || finishComplete || contentStable);
|
|
399
478
|
dbg?.(`[pollSubSession] POLL session=${sessionId} poll=${pollCount} status=${status?.type ?? "absent"} ` +
|
|
400
479
|
`messages=${messages.length} finishComplete=${finishComplete} statusIdle=${statusIdle} ` +
|
|
401
|
-
`contentStable=${contentStable} sessionEverAppeared=${sessionEverAppeared}`
|
|
480
|
+
`contentStable=${contentStable} sessionEverAppeared=${sessionEverAppeared} ` +
|
|
481
|
+
`pendingTool=${hasPendingToolCall} toolExec=${toolExecuting} deferredForConfirm=${deferredForConfirm}`);
|
|
482
|
+
if (deferredForConfirm) {
|
|
483
|
+
dbg?.(`[pollSubSession] FINISH_CONFIRM_PENDING session=${sessionId} poll=${pollCount} ` +
|
|
484
|
+
`sig="${currentAsstSig}" — finish 단독 완료 신호. 한 폴 뒤 재확인한다`);
|
|
485
|
+
}
|
|
402
486
|
if (!nudged && options.nudgeAtFraction != null && options.onNudge) {
|
|
403
487
|
const elapsedMs = now() - startTime;
|
|
404
488
|
if (elapsedMs >= options.nudgeAtFraction * timeoutMs) {
|
|
@@ -440,13 +524,24 @@ export async function pollSubSession(client, sessionId, options = {}) {
|
|
|
440
524
|
.filter(p => p.type === "text" && typeof p.text === "string" && p.text.length > 0)
|
|
441
525
|
.map(p => p.text)
|
|
442
526
|
.join("\n");
|
|
443
|
-
|
|
527
|
+
// 공백만 있는 text part 는 "짧은 서두" 가 아니라 "아직 텍스트가 없음" 이다.
|
|
528
|
+
// 종전에는 `text.length > 0` 만 보고 preamble 분기에 들어갔고, trim 길이 0 은
|
|
529
|
+
// 임계 미만이라 그대로 preamble_only 로 확정됐다 — 로그의 `textLen=0` 이 바로
|
|
530
|
+
// 이 경로다 (issue #7). 스트리밍 초기 상태를 "서두만 쓰고 끝냈다" 로 읽는
|
|
531
|
+
// 것이라 오판의 방향이 정반대다. 정상 반환된 서브에이전트 출력들도 모두
|
|
532
|
+
// `\n\n` 으로 시작한다는 관측이 이 경로가 상시 노출돼 있었음을 말한다.
|
|
533
|
+
//
|
|
534
|
+
// 텍스트 없음으로 떨어뜨리면 dispatch_stage 는 "작업을 다시 하라" 는 action
|
|
535
|
+
// 재프롬프트 대신 요약 재프롬프트를 보내고, `.done=true` override 도 그대로
|
|
536
|
+
// 걸린다 — 이미 끝난 작업을 되풀이시키지 않는 쪽이다.
|
|
537
|
+
const trimmedLen = text.trim().length;
|
|
538
|
+
if (trimmedLen > 0) {
|
|
444
539
|
if (preambleOnlyTextThreshold !== undefined &&
|
|
445
540
|
preambleOnlyTextThreshold > 0 &&
|
|
446
|
-
|
|
447
|
-
!
|
|
541
|
+
trimmedLen < preambleOnlyTextThreshold &&
|
|
542
|
+
!toolInFlight) {
|
|
448
543
|
dbg?.(`[pollSubSession] preamble-only detected session=${sessionId} ` +
|
|
449
|
-
`textLen=${
|
|
544
|
+
`textLen=${trimmedLen} threshold=${preambleOnlyTextThreshold} — ` +
|
|
450
545
|
`reclassifying text outcome as empty`);
|
|
451
546
|
return {
|
|
452
547
|
kind: "empty",
|
|
@@ -457,6 +552,16 @@ export async function pollSubSession(client, sessionId, options = {}) {
|
|
|
457
552
|
}
|
|
458
553
|
return { kind: "text", text, polls: pollCount, elapsedMs: now() - startTime };
|
|
459
554
|
}
|
|
555
|
+
if (text.length > 0) {
|
|
556
|
+
dbg?.(`[pollSubSession] whitespace-only text session=${sessionId} raw_len=${text.length} — ` +
|
|
557
|
+
`텍스트 없음으로 처리 (preamble_only 아님)`);
|
|
558
|
+
return {
|
|
559
|
+
kind: "empty",
|
|
560
|
+
reason: "whitespace_only_text",
|
|
561
|
+
polls: pollCount,
|
|
562
|
+
elapsedMs: now() - startTime,
|
|
563
|
+
};
|
|
564
|
+
}
|
|
460
565
|
return {
|
|
461
566
|
kind: "empty",
|
|
462
567
|
reason: "assistant message has no text parts",
|
|
@@ -499,8 +604,12 @@ export function pollOutcomeToLegacy(outcome) {
|
|
|
499
604
|
case "permission_stall":
|
|
500
605
|
return {
|
|
501
606
|
text: outcome.permissionType
|
|
502
|
-
? `(permission_stall: sub-agent blocked on ${outcome.permissionType} permission
|
|
503
|
-
|
|
607
|
+
? `(permission_stall: sub-agent blocked on ${outcome.permissionType} permission ` +
|
|
608
|
+
`(id=${outcome.permissionID}${outcome.permissionPatterns ? `, patterns=${JSON.stringify(outcome.permissionPatterns)}` : ""}) ` +
|
|
609
|
+
`— cannot be answered in subagent context; aborted after ${outcome.stalledMs}ms)`
|
|
610
|
+
: `(permission_stall: sub-agent tool call stalled for ${outcome.stalledMs}ms — likely waiting for a permission approval ` +
|
|
611
|
+
`that cannot be answered in subagent context; pending permission could not be identified (permission.list unavailable or empty). ` +
|
|
612
|
+
`점검: opencode.json 의 permission.external_directory 시드 존재 여부 (npx makdoong2-team doctor))`,
|
|
504
613
|
success: false,
|
|
505
614
|
};
|
|
506
615
|
case "session_gone":
|
|
@@ -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
|
+
}
|
|
@@ -54,8 +54,14 @@ if [ "$SPEC_HASH" != "__MISSING__" ] && [ "$SPEC_HASH" != "null" ] && [ -n "$SPE
|
|
|
54
54
|
fi
|
|
55
55
|
fail "spec_hash 는 기록됐는데 draft_path 마커도 확정 명세 파일도 없다 — requirements substage 를 재작업하라 (stages/02-requirements.md §2-0, §2-5 9번)"
|
|
56
56
|
fi
|
|
57
|
+
# "생성된 적 없음" 을 안내에서 빼면 복구 방향을 잘못 잡는다 — 실제로 가장 흔한
|
|
58
|
+
# 경우인데 종전 메시지는 동기화 누락·삭제만 언급해 리더가 동기화 문제부터
|
|
59
|
+
# 의심했다 (issue #8).
|
|
57
60
|
[ -f "$ROOT/$DRAFT" ] \
|
|
58
|
-
|| fail "draft_path=${DRAFT} 마커는 있으나 파일이 없다 (기준 경로 ${ROOT})
|
|
61
|
+
|| fail "draft_path=${DRAFT} 마커는 있으나 파일이 없다 (기준 경로 ${ROOT}). 가능한 원인 순서대로:
|
|
62
|
+
(1) 애초에 생성된 적 없음 — planner 가 마커만 기록하고 파일 생성에 실패한 경우. requirements substage 를 재작업해 write 툴로 초안부터 생성하라 (stages/02-requirements.md §2-0b),
|
|
63
|
+
(2) worktree 동기화 누락 — 다른 cwd(main repo/worktree)의 같은 상대경로에 파일이 있는지 확인,
|
|
64
|
+
(3) 파일이 삭제됨 — 삭제 경위 확인 후 requirements 재작업"
|
|
59
65
|
ACTUAL="$(sha256sum "$ROOT/$DRAFT" | cut -d' ' -f1)"
|
|
60
66
|
[ "$ACTUAL" = "$SPEC_HASH" ] \
|
|
61
67
|
|| fail "확정 명세 무단 변경 감지 (spec drift) — 동결 후 변경은 사용자 재승인 + spec_hash 재기록 절차만 허용 (stages/02-requirements.md §2-4a)"
|
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/01-planning.md
CHANGED
|
@@ -89,6 +89,8 @@ mkdir -p .makdoong2-team/<이슈키>
|
|
|
89
89
|
|
|
90
90
|
파일 (repo/worktree root 기준 상대경로): `.makdoong2-team/<이슈키>/requirements-draft.md`
|
|
91
91
|
|
|
92
|
+
**초안 파일은 반드시 `write` 툴(filePath 인자)로 생성·갱신한다.** bash 리디렉션(`cat > …`, `printf > …` 등)과 `apply_patch` 는 같은 경로라도 훅이 차단한다 — planner 의 유일한 파일 쓰기 수단은 `write` 다. 이 경로의 `write` 는 planner READ-ONLY 원칙의 명시적 예외이며 훅이 허용한다 (issue #8).
|
|
93
|
+
|
|
92
94
|
초안 구조:
|
|
93
95
|
```markdown
|
|
94
96
|
# 요구사항 초안 — <이슈키>
|
|
@@ -62,6 +62,8 @@ mkdir -p .makdoong2-team/<ISSUE_KEY>
|
|
|
62
62
|
|
|
63
63
|
파일 경로 (repo/worktree root 기준 상대경로): `.makdoong2-team/<ISSUE_KEY>/requirements-draft.md`
|
|
64
64
|
|
|
65
|
+
**초안 파일은 반드시 `write` 툴(filePath 인자)로 생성·갱신한다.** bash 리디렉션(`cat > …`, `printf > …` 등)과 `apply_patch` 는 같은 경로라도 훅이 차단한다 — planner 의 유일한 파일 쓰기 수단은 `write` 다. 이 경로의 `write` 는 planner READ-ONLY 원칙의 명시적 예외이며 훅이 허용한다 (issue #8).
|
|
66
|
+
|
|
65
67
|
초안 초기 구조:
|
|
66
68
|
```markdown
|
|
67
69
|
# 요구사항 초안 — <ISSUE_KEY>
|
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> 에서 이미 완료됨`
|