triflux 10.10.0 → 10.11.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/bin/triflux.mjs +5 -1
- package/hub/bridge.mjs +133 -1
- package/hub/lib/tfx-route-args.mjs +222 -0
- package/hub/server.mjs +1 -1
- package/hub/team/git-preflight.mjs +2 -2
- package/hub/team/retry-state-machine.mjs +260 -0
- package/hub/team/runtime-strategy.mjs +1 -5
- package/hub/team/tui-lite.mjs +0 -4
- package/hub/team/tui.mjs +0 -3
- package/hub/team/wt-manager.mjs +1 -1
- package/hub/workers/codex-app-server-worker.mjs +1 -1
- package/hud/context-monitor.mjs +2 -4
- package/package.json +1 -1
- package/scripts/__tests__/tfx-doctor-diagnose.test.mjs +0 -2
- package/scripts/claudemd-sync.mjs +33 -5
- package/scripts/config-audit.mjs +4 -4
- package/scripts/preinstall.mjs +1 -1
- package/scripts/remote-spawn.mjs +1 -1
- package/scripts/session-spawn-helper.mjs +0 -1
- package/scripts/setup.mjs +4 -1
- package/skills/tfx-auto/SKILL.md +74 -5
- package/skills/tfx-auto-codex/SKILL.md +16 -12
- package/skills/tfx-autoroute/SKILL.md +24 -9
- package/skills/tfx-interview/SKILL.md +37 -2
- package/skills/tfx-persist/SKILL.md +23 -10
package/bin/triflux.mjs
CHANGED
|
@@ -882,7 +882,11 @@ function getClaudeRoutingSyncSummary(results) {
|
|
|
882
882
|
(summary, result) => ({
|
|
883
883
|
changed:
|
|
884
884
|
summary.changed +
|
|
885
|
-
(result.action === "created" ||
|
|
885
|
+
(result.action === "created" ||
|
|
886
|
+
result.action === "updated" ||
|
|
887
|
+
result.action === "removed"
|
|
888
|
+
? 1
|
|
889
|
+
: 0),
|
|
886
890
|
skipped: summary.skipped + (result.skipped ? 1 : 0),
|
|
887
891
|
}),
|
|
888
892
|
{ changed: 0, skipped: 0 },
|
package/hub/bridge.mjs
CHANGED
|
@@ -21,6 +21,12 @@ import { fileURLToPath } from "node:url";
|
|
|
21
21
|
import { parseArgs as nodeParseArgs } from "node:util";
|
|
22
22
|
|
|
23
23
|
import { getPipelineStateDbPath } from "./pipeline/state.mjs";
|
|
24
|
+
import {
|
|
25
|
+
createRetryStateMachine,
|
|
26
|
+
DEFAULT_ESCALATION_CHAIN,
|
|
27
|
+
loadSnapshot,
|
|
28
|
+
saveSnapshot,
|
|
29
|
+
} from "./team/retry-state-machine.mjs";
|
|
24
30
|
|
|
25
31
|
const HUB_PID_FILE = join(homedir(), ".claude", "cache", "tfx-hub", "hub.pid");
|
|
26
32
|
const HUB_TOKEN_FILE = join(homedir(), ".claude", ".tfx-hub-token");
|
|
@@ -409,6 +415,10 @@ export function parseArgs(argv) {
|
|
|
409
415
|
done: { type: "boolean" },
|
|
410
416
|
"mcp-profile": { type: "string" },
|
|
411
417
|
"session-key": { type: "string" },
|
|
418
|
+
snapshot: { type: "string" },
|
|
419
|
+
"snapshot-file": { type: "string" },
|
|
420
|
+
event: { type: "string" },
|
|
421
|
+
"max-iterations": { type: "string" },
|
|
412
422
|
},
|
|
413
423
|
allowPositionals: true,
|
|
414
424
|
strict: false,
|
|
@@ -1079,6 +1089,124 @@ async function cmdHitlPending() {
|
|
|
1079
1089
|
return emitJson(outcome?.result || unavailableResult());
|
|
1080
1090
|
}
|
|
1081
1091
|
|
|
1092
|
+
// ---------------------------------------------------------------------------
|
|
1093
|
+
// retry-run / retry-status — Phase 3 Step C2 bridge 서브커맨드.
|
|
1094
|
+
// retry-state-machine.mjs 를 multi-process safe 하게 외부 호출용 wrap.
|
|
1095
|
+
// 사용자 워크플로우:
|
|
1096
|
+
// 1) 첫 호출: retry-run --snapshot X --mode ralph --event start
|
|
1097
|
+
// → 새 SM 생성, PLANNING → EXECUTING transition, snapshot 저장
|
|
1098
|
+
// 2) verify 성공 시: retry-run --snapshot X --event verify-success
|
|
1099
|
+
// → DONE, 종료 판단 반환
|
|
1100
|
+
// 3) verify 실패 시: retry-run --snapshot X --event verify-fail --reason R
|
|
1101
|
+
// → DIAGNOSING 또는 STUCK/BUDGET_EXCEEDED, 종료 판단 반환
|
|
1102
|
+
// 4) 다음 iter 시작: retry-run --snapshot X --event start
|
|
1103
|
+
// 출력: {ok, current, iterations, done, shouldStop, reason?, cli?} JSON.
|
|
1104
|
+
// ---------------------------------------------------------------------------
|
|
1105
|
+
|
|
1106
|
+
function buildRetrySmFromArgs(args, snapshot) {
|
|
1107
|
+
const mode = args.mode || snapshot?.mode || "bounded";
|
|
1108
|
+
const maxIterations =
|
|
1109
|
+
args["max-iterations"] !== undefined
|
|
1110
|
+
? Number(args["max-iterations"])
|
|
1111
|
+
: snapshot?.maxIterations;
|
|
1112
|
+
const sessionId = args["session-id"] || snapshot?.sessionId || null;
|
|
1113
|
+
const cliChain = snapshot?.cliChain;
|
|
1114
|
+
|
|
1115
|
+
const sm = createRetryStateMachine({
|
|
1116
|
+
mode,
|
|
1117
|
+
maxIterations,
|
|
1118
|
+
sessionId,
|
|
1119
|
+
cliChain,
|
|
1120
|
+
});
|
|
1121
|
+
if (snapshot) sm.applySnapshot(snapshot);
|
|
1122
|
+
return sm;
|
|
1123
|
+
}
|
|
1124
|
+
|
|
1125
|
+
async function cmdRetryRun(args) {
|
|
1126
|
+
const snapshotFile = args.snapshot || args["snapshot-file"];
|
|
1127
|
+
const event = args.event;
|
|
1128
|
+
const reason = args.reason || "";
|
|
1129
|
+
|
|
1130
|
+
if (!snapshotFile) {
|
|
1131
|
+
console.error("--snapshot <path> required");
|
|
1132
|
+
return false;
|
|
1133
|
+
}
|
|
1134
|
+
if (!event) {
|
|
1135
|
+
console.error("--event <start|verify-success|verify-fail> required");
|
|
1136
|
+
return false;
|
|
1137
|
+
}
|
|
1138
|
+
|
|
1139
|
+
const existing = loadSnapshot(snapshotFile);
|
|
1140
|
+
const sm = buildRetrySmFromArgs(args, existing);
|
|
1141
|
+
|
|
1142
|
+
let result;
|
|
1143
|
+
switch (event) {
|
|
1144
|
+
case "start":
|
|
1145
|
+
result = sm.startIteration();
|
|
1146
|
+
break;
|
|
1147
|
+
case "verify-success":
|
|
1148
|
+
result = sm.reportVerifySuccess();
|
|
1149
|
+
break;
|
|
1150
|
+
case "verify-fail":
|
|
1151
|
+
result = sm.reportVerifyFail(reason || "unspecified");
|
|
1152
|
+
break;
|
|
1153
|
+
default:
|
|
1154
|
+
console.error(`unknown --event: ${event}`);
|
|
1155
|
+
return false;
|
|
1156
|
+
}
|
|
1157
|
+
|
|
1158
|
+
const snap = sm.serialize();
|
|
1159
|
+
saveSnapshot(snapshotFile, snap);
|
|
1160
|
+
|
|
1161
|
+
const terminal = ["DONE", "STUCK", "BUDGET_EXCEEDED"].includes(snap.current);
|
|
1162
|
+
const cli = snap.cliChain?.[snap.cliIndex] || null;
|
|
1163
|
+
const out = {
|
|
1164
|
+
ok: true,
|
|
1165
|
+
current: snap.current,
|
|
1166
|
+
iterations: snap.iterations,
|
|
1167
|
+
cliIndex: snap.cliIndex,
|
|
1168
|
+
cli,
|
|
1169
|
+
done: snap.current === "DONE",
|
|
1170
|
+
shouldStop: terminal,
|
|
1171
|
+
stuckCounter: snap.stuckCounter,
|
|
1172
|
+
lastFailureReason: snap.lastFailureReason,
|
|
1173
|
+
transition: result,
|
|
1174
|
+
};
|
|
1175
|
+
console.log(JSON.stringify(out));
|
|
1176
|
+
return true;
|
|
1177
|
+
}
|
|
1178
|
+
|
|
1179
|
+
async function cmdRetryStatus(args) {
|
|
1180
|
+
const snapshotFile = args.snapshot || args["snapshot-file"];
|
|
1181
|
+
if (!snapshotFile) {
|
|
1182
|
+
console.error("--snapshot <path> required");
|
|
1183
|
+
return false;
|
|
1184
|
+
}
|
|
1185
|
+
const snap = loadSnapshot(snapshotFile);
|
|
1186
|
+
if (!snap) {
|
|
1187
|
+
console.log(JSON.stringify({ ok: true, exists: false }));
|
|
1188
|
+
return true;
|
|
1189
|
+
}
|
|
1190
|
+
const terminal = ["DONE", "STUCK", "BUDGET_EXCEEDED"].includes(snap.current);
|
|
1191
|
+
const cli = snap.cliChain?.[snap.cliIndex] || null;
|
|
1192
|
+
console.log(
|
|
1193
|
+
JSON.stringify({
|
|
1194
|
+
ok: true,
|
|
1195
|
+
exists: true,
|
|
1196
|
+
current: snap.current,
|
|
1197
|
+
iterations: snap.iterations,
|
|
1198
|
+
maxIterations: snap.maxIterations,
|
|
1199
|
+
cliIndex: snap.cliIndex,
|
|
1200
|
+
cli,
|
|
1201
|
+
mode: snap.mode,
|
|
1202
|
+
shouldStop: terminal,
|
|
1203
|
+
stuckCounter: snap.stuckCounter,
|
|
1204
|
+
lastFailureReason: snap.lastFailureReason,
|
|
1205
|
+
}),
|
|
1206
|
+
);
|
|
1207
|
+
return true;
|
|
1208
|
+
}
|
|
1209
|
+
|
|
1082
1210
|
export async function main(argv = process.argv.slice(2)) {
|
|
1083
1211
|
const cmd = argv[0];
|
|
1084
1212
|
const args = parseArgs(argv.slice(1));
|
|
@@ -1138,9 +1266,13 @@ export async function main(argv = process.argv.slice(2)) {
|
|
|
1138
1266
|
return await cmdHitlSubmit(args);
|
|
1139
1267
|
case "hitl-pending":
|
|
1140
1268
|
return await cmdHitlPending(args);
|
|
1269
|
+
case "retry-run":
|
|
1270
|
+
return await cmdRetryRun(args);
|
|
1271
|
+
case "retry-status":
|
|
1272
|
+
return await cmdRetryStatus(args);
|
|
1141
1273
|
default:
|
|
1142
1274
|
console.error(
|
|
1143
|
-
"사용법: bridge.mjs <register|result|control|handoff|publish|send-input|context|deregister|assign-async|assign-result|assign-status|assign-retry|team-info|team-task-list|team-task-update|team-send-message|pipeline-state|pipeline-advance|pipeline-init|pipeline-list|ping|delegator-delegate|delegator-reply|delegator-status|hitl-request|hitl-submit|hitl-pending> [--옵션]",
|
|
1275
|
+
"사용법: bridge.mjs <register|result|control|handoff|publish|send-input|context|deregister|assign-async|assign-result|assign-status|assign-retry|team-info|team-task-list|team-task-update|team-send-message|pipeline-state|pipeline-advance|pipeline-init|pipeline-list|ping|delegator-delegate|delegator-reply|delegator-status|hitl-request|hitl-submit|hitl-pending|retry-run|retry-status> [--옵션]",
|
|
1144
1276
|
);
|
|
1145
1277
|
process.exit(1);
|
|
1146
1278
|
}
|
|
@@ -0,0 +1,222 @@
|
|
|
1
|
+
// hub/lib/tfx-route-args.mjs
|
|
2
|
+
// Phase 3 Step B — tfx-auto / tfx-route 플래그 파서.
|
|
3
|
+
// 설계 문서: .triflux/plans/phase3-lead-codex-ralph-escalate.md
|
|
4
|
+
//
|
|
5
|
+
// 입력: ARGUMENTS 문자열 또는 토큰 배열.
|
|
6
|
+
// 출력: {cli, mode, parallel, retry, isolation, remote, lead, noClaudeNative,
|
|
7
|
+
// maxIterations, task, warnings}.
|
|
8
|
+
//
|
|
9
|
+
// 기존 플래그 (Phase 2 v10.9.33+):
|
|
10
|
+
// --cli {auto|codex|gemini|claude}
|
|
11
|
+
// --mode {quick|deep|consensus}
|
|
12
|
+
// --parallel {1|N|swarm}
|
|
13
|
+
// --retry {0|1|ralph|auto-escalate} (Phase 3 에서 ralph/auto-escalate 신규)
|
|
14
|
+
// --isolation {none|worktree}
|
|
15
|
+
// --remote {none|<host>}
|
|
16
|
+
//
|
|
17
|
+
// Phase 3 신규:
|
|
18
|
+
// --lead {claude|codex} (tfx-auto-codex 의미 흡수)
|
|
19
|
+
// --no-claude-native (Claude native sub-agent 경로 disable)
|
|
20
|
+
// --max-iterations <N> (ralph / auto-escalate 상한, 0=unlimited)
|
|
21
|
+
|
|
22
|
+
export const DEFAULT_OPTIONS = Object.freeze({
|
|
23
|
+
cli: "auto",
|
|
24
|
+
mode: "quick",
|
|
25
|
+
parallel: "1",
|
|
26
|
+
retry: "1",
|
|
27
|
+
isolation: "none",
|
|
28
|
+
remote: "none",
|
|
29
|
+
lead: "claude",
|
|
30
|
+
noClaudeNative: false,
|
|
31
|
+
maxIterations: 0,
|
|
32
|
+
});
|
|
33
|
+
|
|
34
|
+
const VALID_VALUES = Object.freeze({
|
|
35
|
+
cli: ["auto", "codex", "gemini", "claude"],
|
|
36
|
+
mode: ["quick", "deep", "consensus"],
|
|
37
|
+
retry: ["0", "1", "ralph", "auto-escalate"],
|
|
38
|
+
isolation: ["none", "worktree"],
|
|
39
|
+
lead: ["claude", "codex"],
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
const VALUE_FLAGS = new Set([
|
|
43
|
+
"--cli",
|
|
44
|
+
"--mode",
|
|
45
|
+
"--parallel",
|
|
46
|
+
"--retry",
|
|
47
|
+
"--isolation",
|
|
48
|
+
"--remote",
|
|
49
|
+
"--lead",
|
|
50
|
+
"--max-iterations",
|
|
51
|
+
]);
|
|
52
|
+
|
|
53
|
+
const BOOL_FLAGS = new Set(["--no-claude-native"]);
|
|
54
|
+
|
|
55
|
+
export function parseArgs(input) {
|
|
56
|
+
const tokens = Array.isArray(input)
|
|
57
|
+
? input.slice()
|
|
58
|
+
: tokenize(String(input || ""));
|
|
59
|
+
const opts = { ...DEFAULT_OPTIONS };
|
|
60
|
+
const warnings = [];
|
|
61
|
+
const taskTokens = [];
|
|
62
|
+
|
|
63
|
+
let i = 0;
|
|
64
|
+
while (i < tokens.length) {
|
|
65
|
+
const raw = tokens[i];
|
|
66
|
+
|
|
67
|
+
if (BOOL_FLAGS.has(raw)) {
|
|
68
|
+
applyBool(opts, raw);
|
|
69
|
+
i += 1;
|
|
70
|
+
continue;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
// --flag=value 지원
|
|
74
|
+
const eqIdx = raw.indexOf("=");
|
|
75
|
+
let flag = raw;
|
|
76
|
+
let value = null;
|
|
77
|
+
if (eqIdx > 0 && raw.startsWith("--")) {
|
|
78
|
+
flag = raw.slice(0, eqIdx);
|
|
79
|
+
value = raw.slice(eqIdx + 1);
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
if (VALUE_FLAGS.has(flag)) {
|
|
83
|
+
if (value === null) {
|
|
84
|
+
const next = tokens[i + 1];
|
|
85
|
+
if (next === undefined || next.startsWith("--")) {
|
|
86
|
+
warnings.push(`${flag} needs a value`);
|
|
87
|
+
i += 1;
|
|
88
|
+
continue;
|
|
89
|
+
}
|
|
90
|
+
value = next;
|
|
91
|
+
i += 2;
|
|
92
|
+
} else {
|
|
93
|
+
i += 1;
|
|
94
|
+
}
|
|
95
|
+
applyValue(opts, flag, value, warnings);
|
|
96
|
+
continue;
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
if (raw.startsWith("--")) {
|
|
100
|
+
warnings.push(`unknown flag: ${raw}`);
|
|
101
|
+
i += 1;
|
|
102
|
+
continue;
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
taskTokens.push(raw);
|
|
106
|
+
i += 1;
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
validate(opts, warnings);
|
|
110
|
+
|
|
111
|
+
return {
|
|
112
|
+
...opts,
|
|
113
|
+
task: taskTokens.join(" ").trim(),
|
|
114
|
+
warnings,
|
|
115
|
+
};
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
function tokenize(str) {
|
|
119
|
+
const tokens = [];
|
|
120
|
+
let cur = "";
|
|
121
|
+
let quote = null;
|
|
122
|
+
for (const ch of str) {
|
|
123
|
+
if (quote) {
|
|
124
|
+
if (ch === quote) {
|
|
125
|
+
quote = null;
|
|
126
|
+
} else {
|
|
127
|
+
cur += ch;
|
|
128
|
+
}
|
|
129
|
+
continue;
|
|
130
|
+
}
|
|
131
|
+
if (ch === '"' || ch === "'") {
|
|
132
|
+
quote = ch;
|
|
133
|
+
continue;
|
|
134
|
+
}
|
|
135
|
+
if (/\s/.test(ch)) {
|
|
136
|
+
if (cur) {
|
|
137
|
+
tokens.push(cur);
|
|
138
|
+
cur = "";
|
|
139
|
+
}
|
|
140
|
+
continue;
|
|
141
|
+
}
|
|
142
|
+
cur += ch;
|
|
143
|
+
}
|
|
144
|
+
if (cur) tokens.push(cur);
|
|
145
|
+
return tokens;
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
function applyBool(opts, flag) {
|
|
149
|
+
switch (flag) {
|
|
150
|
+
case "--no-claude-native":
|
|
151
|
+
opts.noClaudeNative = true;
|
|
152
|
+
break;
|
|
153
|
+
}
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
function applyValue(opts, flag, value, warnings) {
|
|
157
|
+
switch (flag) {
|
|
158
|
+
case "--cli":
|
|
159
|
+
opts.cli = value;
|
|
160
|
+
break;
|
|
161
|
+
case "--mode":
|
|
162
|
+
opts.mode = value;
|
|
163
|
+
break;
|
|
164
|
+
case "--parallel":
|
|
165
|
+
opts.parallel = value;
|
|
166
|
+
break;
|
|
167
|
+
case "--retry":
|
|
168
|
+
opts.retry = value;
|
|
169
|
+
break;
|
|
170
|
+
case "--isolation":
|
|
171
|
+
opts.isolation = value;
|
|
172
|
+
break;
|
|
173
|
+
case "--remote":
|
|
174
|
+
opts.remote = value;
|
|
175
|
+
break;
|
|
176
|
+
case "--lead":
|
|
177
|
+
opts.lead = value;
|
|
178
|
+
break;
|
|
179
|
+
case "--max-iterations": {
|
|
180
|
+
const n = Number.parseInt(value, 10);
|
|
181
|
+
if (Number.isNaN(n) || n < 0) {
|
|
182
|
+
warnings.push(
|
|
183
|
+
`invalid --max-iterations=${value}, expected non-negative integer (0=unlimited)`,
|
|
184
|
+
);
|
|
185
|
+
} else {
|
|
186
|
+
opts.maxIterations = n;
|
|
187
|
+
}
|
|
188
|
+
break;
|
|
189
|
+
}
|
|
190
|
+
}
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
function validate(opts, warnings) {
|
|
194
|
+
for (const key of Object.keys(VALID_VALUES)) {
|
|
195
|
+
if (!VALID_VALUES[key].includes(opts[key])) {
|
|
196
|
+
warnings.push(
|
|
197
|
+
`invalid --${key}=${opts[key]}, expected one of ${VALID_VALUES[key].join("|")}`,
|
|
198
|
+
);
|
|
199
|
+
}
|
|
200
|
+
}
|
|
201
|
+
if (
|
|
202
|
+
opts.parallel !== "1" &&
|
|
203
|
+
opts.parallel !== "swarm" &&
|
|
204
|
+
!/^\d+$/.test(opts.parallel)
|
|
205
|
+
) {
|
|
206
|
+
warnings.push(`invalid --parallel=${opts.parallel}, expected 1|N|swarm`);
|
|
207
|
+
}
|
|
208
|
+
const parallelOne = opts.parallel === "1" || opts.parallel === 1;
|
|
209
|
+
if (parallelOne && opts.isolation === "worktree") {
|
|
210
|
+
warnings.push(
|
|
211
|
+
"--isolation worktree requires --parallel >=2 or swarm; forcing isolation=none",
|
|
212
|
+
);
|
|
213
|
+
opts.isolation = "none";
|
|
214
|
+
}
|
|
215
|
+
if (opts.remote !== "none" && opts.parallel !== "swarm") {
|
|
216
|
+
warnings.push(
|
|
217
|
+
`--remote ${opts.remote} ignored (requires --parallel swarm)`,
|
|
218
|
+
);
|
|
219
|
+
}
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
export { VALID_VALUES };
|
package/hub/server.mjs
CHANGED
|
@@ -1057,7 +1057,7 @@ export async function startHub({
|
|
|
1057
1057
|
if (path === "/synapse/heartbeat" && req.method === "POST") {
|
|
1058
1058
|
try {
|
|
1059
1059
|
const body = await parseBody(req);
|
|
1060
|
-
const { sessionId,
|
|
1060
|
+
const { sessionId, partial } = body || {};
|
|
1061
1061
|
const ok = synapseRegistry.heartbeat(sessionId, partial);
|
|
1062
1062
|
if (!ok) {
|
|
1063
1063
|
throw new Error("heartbeat failed");
|
|
@@ -80,12 +80,12 @@ export function createGitPreflight(opts = {}) {
|
|
|
80
80
|
}
|
|
81
81
|
|
|
82
82
|
function otherActiveSessions(active, sessionId) {
|
|
83
|
-
return active.filter((s) => s
|
|
83
|
+
return active.filter((s) => s?.sessionId && s.sessionId !== sessionId);
|
|
84
84
|
}
|
|
85
85
|
|
|
86
86
|
function otherLeases(snapshot, workerId) {
|
|
87
87
|
return snapshot.filter(
|
|
88
|
-
(entry) => entry
|
|
88
|
+
(entry) => entry?.workerId && entry.workerId !== workerId,
|
|
89
89
|
);
|
|
90
90
|
}
|
|
91
91
|
|
|
@@ -0,0 +1,260 @@
|
|
|
1
|
+
// hub/team/retry-state-machine.mjs
|
|
2
|
+
// Phase 3 Step A — true ralph / auto-escalate retry state machine.
|
|
3
|
+
// 설계 문서: .triflux/plans/phase3-lead-codex-ralph-escalate.md
|
|
4
|
+
//
|
|
5
|
+
// Modes:
|
|
6
|
+
// bounded — --retry 1 (기본): maxIterations 도달 시 BUDGET_EXCEEDED.
|
|
7
|
+
// ralph — --retry ralph: maxIterations=0 이면 unlimited. stuck detector 만 종료.
|
|
8
|
+
// auto-escalate — --retry auto-escalate: BUDGET_EXCEEDED 시 다음 CLI 로 전이.
|
|
9
|
+
//
|
|
10
|
+
// 상태 전이:
|
|
11
|
+
// PLANNING → EXECUTING → VERIFYING.success → DONE
|
|
12
|
+
// → VERIFYING.fail → DIAGNOSING → EXECUTING …
|
|
13
|
+
// → stuckCounter≥3 → STUCK
|
|
14
|
+
// → iter≥max → BUDGET_EXCEEDED (escalate 모드는 다음 CLI)
|
|
15
|
+
|
|
16
|
+
import { EventEmitter } from "node:events";
|
|
17
|
+
import {
|
|
18
|
+
appendFileSync,
|
|
19
|
+
existsSync,
|
|
20
|
+
mkdirSync,
|
|
21
|
+
readFileSync,
|
|
22
|
+
writeFileSync,
|
|
23
|
+
} from "node:fs";
|
|
24
|
+
import { dirname } from "node:path";
|
|
25
|
+
|
|
26
|
+
export const STATES = Object.freeze({
|
|
27
|
+
PLANNING: "PLANNING",
|
|
28
|
+
EXECUTING: "EXECUTING",
|
|
29
|
+
DIAGNOSING: "DIAGNOSING",
|
|
30
|
+
STUCK: "STUCK",
|
|
31
|
+
BUDGET_EXCEEDED: "BUDGET_EXCEEDED",
|
|
32
|
+
DONE: "DONE",
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
export const MODES = Object.freeze({
|
|
36
|
+
BOUNDED: "bounded",
|
|
37
|
+
RALPH: "ralph",
|
|
38
|
+
ESCALATE: "auto-escalate",
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
const DEFAULT_ESCALATION_CHAIN = Object.freeze([
|
|
42
|
+
Object.freeze({ cli: "codex", model: "gpt-5-mini" }),
|
|
43
|
+
Object.freeze({ cli: "codex", model: "gpt-5" }),
|
|
44
|
+
Object.freeze({ cli: "claude", model: "sonnet-4-6" }),
|
|
45
|
+
Object.freeze({ cli: "claude", model: "opus-4-7" }),
|
|
46
|
+
]);
|
|
47
|
+
|
|
48
|
+
const STUCK_THRESHOLD = 3;
|
|
49
|
+
|
|
50
|
+
export function createRetryStateMachine(options = {}) {
|
|
51
|
+
const mode = options.mode || MODES.BOUNDED;
|
|
52
|
+
const cliChain = Array.isArray(options.cliChain)
|
|
53
|
+
? options.cliChain.slice()
|
|
54
|
+
: DEFAULT_ESCALATION_CHAIN.slice();
|
|
55
|
+
const maxIterationsInput =
|
|
56
|
+
typeof options.maxIterations === "number" ? options.maxIterations : null;
|
|
57
|
+
const maxIterations =
|
|
58
|
+
maxIterationsInput !== null
|
|
59
|
+
? maxIterationsInput
|
|
60
|
+
: mode === MODES.BOUNDED
|
|
61
|
+
? 3
|
|
62
|
+
: 0;
|
|
63
|
+
const stateFile = options.stateFile || null;
|
|
64
|
+
const sessionId = options.sessionId || null;
|
|
65
|
+
|
|
66
|
+
const emitter = new EventEmitter();
|
|
67
|
+
const state = {
|
|
68
|
+
current: STATES.PLANNING,
|
|
69
|
+
iterations: 0,
|
|
70
|
+
maxIterations,
|
|
71
|
+
stuckCounter: 0,
|
|
72
|
+
lastFailureReason: null,
|
|
73
|
+
cliIndex: 0,
|
|
74
|
+
cliChain,
|
|
75
|
+
mode,
|
|
76
|
+
sessionId,
|
|
77
|
+
stateFile,
|
|
78
|
+
history: [],
|
|
79
|
+
};
|
|
80
|
+
|
|
81
|
+
function transition(next, meta = {}) {
|
|
82
|
+
const prev = state.current;
|
|
83
|
+
state.current = next;
|
|
84
|
+
const entry = {
|
|
85
|
+
t: Date.now(),
|
|
86
|
+
from: prev,
|
|
87
|
+
to: next,
|
|
88
|
+
iteration: state.iterations,
|
|
89
|
+
cliIndex: state.cliIndex,
|
|
90
|
+
...meta,
|
|
91
|
+
};
|
|
92
|
+
state.history.push(entry);
|
|
93
|
+
if (stateFile) persistTransition(stateFile, entry);
|
|
94
|
+
emitter.emit("transition", entry);
|
|
95
|
+
return entry;
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
function startIteration() {
|
|
99
|
+
state.iterations += 1;
|
|
100
|
+
return transition(STATES.EXECUTING, { iteration: state.iterations });
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
function reportVerifySuccess() {
|
|
104
|
+
return transition(STATES.DONE);
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
function reportVerifyFail(failureReason) {
|
|
108
|
+
const reason = String(failureReason || "unknown");
|
|
109
|
+
if (reason === state.lastFailureReason) {
|
|
110
|
+
state.stuckCounter += 1;
|
|
111
|
+
} else {
|
|
112
|
+
state.stuckCounter = 1;
|
|
113
|
+
state.lastFailureReason = reason;
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
if (state.stuckCounter >= STUCK_THRESHOLD) {
|
|
117
|
+
return transition(STATES.STUCK, {
|
|
118
|
+
reason,
|
|
119
|
+
stuckCounter: state.stuckCounter,
|
|
120
|
+
});
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
if (state.maxIterations > 0 && state.iterations >= state.maxIterations) {
|
|
124
|
+
if (state.mode === MODES.ESCALATE) {
|
|
125
|
+
return escalate();
|
|
126
|
+
}
|
|
127
|
+
return transition(STATES.BUDGET_EXCEEDED, {
|
|
128
|
+
reason,
|
|
129
|
+
iterations: state.iterations,
|
|
130
|
+
});
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
return transition(STATES.DIAGNOSING, { reason });
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
function escalate() {
|
|
137
|
+
if (state.cliIndex + 1 >= state.cliChain.length) {
|
|
138
|
+
return transition(STATES.BUDGET_EXCEEDED, {
|
|
139
|
+
reason: "escalation-chain-exhausted",
|
|
140
|
+
chain: state.cliChain,
|
|
141
|
+
});
|
|
142
|
+
}
|
|
143
|
+
state.cliIndex += 1;
|
|
144
|
+
state.iterations = 0;
|
|
145
|
+
state.stuckCounter = 0;
|
|
146
|
+
state.lastFailureReason = null;
|
|
147
|
+
return transition(STATES.EXECUTING, {
|
|
148
|
+
cli: state.cliChain[state.cliIndex],
|
|
149
|
+
escalated: true,
|
|
150
|
+
});
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
function getCurrent() {
|
|
154
|
+
return {
|
|
155
|
+
current: state.current,
|
|
156
|
+
iterations: state.iterations,
|
|
157
|
+
maxIterations: state.maxIterations,
|
|
158
|
+
stuckCounter: state.stuckCounter,
|
|
159
|
+
lastFailureReason: state.lastFailureReason,
|
|
160
|
+
cliIndex: state.cliIndex,
|
|
161
|
+
cliChain: state.cliChain.slice(),
|
|
162
|
+
mode: state.mode,
|
|
163
|
+
sessionId: state.sessionId,
|
|
164
|
+
history: state.history.slice(),
|
|
165
|
+
};
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
function on(event, listener) {
|
|
169
|
+
emitter.on(event, listener);
|
|
170
|
+
return () => emitter.off(event, listener);
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
function serialize() {
|
|
174
|
+
return {
|
|
175
|
+
version: 1,
|
|
176
|
+
current: state.current,
|
|
177
|
+
iterations: state.iterations,
|
|
178
|
+
maxIterations: state.maxIterations,
|
|
179
|
+
stuckCounter: state.stuckCounter,
|
|
180
|
+
lastFailureReason: state.lastFailureReason,
|
|
181
|
+
cliIndex: state.cliIndex,
|
|
182
|
+
cliChain: state.cliChain.slice(),
|
|
183
|
+
mode: state.mode,
|
|
184
|
+
sessionId: state.sessionId,
|
|
185
|
+
history: state.history.slice(),
|
|
186
|
+
};
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
function applySnapshot(snapshot) {
|
|
190
|
+
if (!snapshot || typeof snapshot !== "object") return;
|
|
191
|
+
state.current = snapshot.current || STATES.PLANNING;
|
|
192
|
+
state.iterations = Number(snapshot.iterations) || 0;
|
|
193
|
+
state.maxIterations =
|
|
194
|
+
snapshot.maxIterations !== undefined
|
|
195
|
+
? Number(snapshot.maxIterations)
|
|
196
|
+
: state.maxIterations;
|
|
197
|
+
state.stuckCounter = Number(snapshot.stuckCounter) || 0;
|
|
198
|
+
state.lastFailureReason = snapshot.lastFailureReason || null;
|
|
199
|
+
state.cliIndex = Number(snapshot.cliIndex) || 0;
|
|
200
|
+
if (Array.isArray(snapshot.cliChain) && snapshot.cliChain.length > 0) {
|
|
201
|
+
state.cliChain = snapshot.cliChain.slice();
|
|
202
|
+
}
|
|
203
|
+
if (snapshot.mode) state.mode = snapshot.mode;
|
|
204
|
+
if (snapshot.sessionId !== undefined) state.sessionId = snapshot.sessionId;
|
|
205
|
+
if (Array.isArray(snapshot.history))
|
|
206
|
+
state.history = snapshot.history.slice();
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
return {
|
|
210
|
+
STATES,
|
|
211
|
+
MODES,
|
|
212
|
+
getCurrent,
|
|
213
|
+
startIteration,
|
|
214
|
+
reportVerifySuccess,
|
|
215
|
+
reportVerifyFail,
|
|
216
|
+
escalate,
|
|
217
|
+
on,
|
|
218
|
+
serialize,
|
|
219
|
+
applySnapshot,
|
|
220
|
+
};
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
function persistTransition(stateFile, entry) {
|
|
224
|
+
const dir = dirname(stateFile);
|
|
225
|
+
if (!existsSync(dir)) mkdirSync(dir, { recursive: true });
|
|
226
|
+
appendFileSync(stateFile, `${JSON.stringify(entry)}\n`, "utf8");
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
export function resumeFromStateFile(stateFile) {
|
|
230
|
+
if (!existsSync(stateFile)) return null;
|
|
231
|
+
const raw = readFileSync(stateFile, "utf8").trim();
|
|
232
|
+
if (!raw) return null;
|
|
233
|
+
const lines = raw.split("\n").filter(Boolean);
|
|
234
|
+
if (lines.length === 0) return null;
|
|
235
|
+
return JSON.parse(lines[lines.length - 1]);
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
// Full-snapshot 기반 stateFile I/O — bridge retry-run 에서 multi-process state 복원용.
|
|
239
|
+
// transition event log (resumeFromStateFile) 와 별도 파일로 관리하는 것을 권장.
|
|
240
|
+
export function loadSnapshot(snapshotFile) {
|
|
241
|
+
if (!existsSync(snapshotFile)) return null;
|
|
242
|
+
const raw = readFileSync(snapshotFile, "utf8").trim();
|
|
243
|
+
if (!raw) return null;
|
|
244
|
+
const parsed = JSON.parse(raw);
|
|
245
|
+
if (!parsed || typeof parsed !== "object") return null;
|
|
246
|
+
if (parsed.version !== 1) {
|
|
247
|
+
throw new Error(
|
|
248
|
+
`unsupported snapshot version: ${parsed.version} (expected 1)`,
|
|
249
|
+
);
|
|
250
|
+
}
|
|
251
|
+
return parsed;
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
export function saveSnapshot(snapshotFile, snapshot) {
|
|
255
|
+
const dir = dirname(snapshotFile);
|
|
256
|
+
if (!existsSync(dir)) mkdirSync(dir, { recursive: true });
|
|
257
|
+
writeFileSync(snapshotFile, JSON.stringify(snapshot), "utf8");
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
export { DEFAULT_ESCALATION_CHAIN, STUCK_THRESHOLD };
|
|
@@ -3,11 +3,7 @@ import {
|
|
|
3
3
|
killPsmuxSession,
|
|
4
4
|
psmuxSessionExists,
|
|
5
5
|
} from "./psmux.mjs";
|
|
6
|
-
import {
|
|
7
|
-
killSession as killTmuxSession,
|
|
8
|
-
listSessions,
|
|
9
|
-
tmuxExec,
|
|
10
|
-
} from "./session.mjs";
|
|
6
|
+
import { tmuxExec } from "./session.mjs";
|
|
11
7
|
|
|
12
8
|
/**
|
|
13
9
|
* @typedef {object} RuntimeStatus
|