@muggleai/works 5.29.0-staging.255 → 5.29.0-staging.257
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/.claude-plugin/marketplace.json +1 -1
- package/dist/plugin/.claude-plugin/plugin.json +1 -1
- package/dist/plugin/.codex-mcp.json +1 -1
- package/dist/plugin/.codex-plugin/plugin.json +1 -1
- package/dist/plugin/.cursor-plugin/plugin.json +1 -1
- package/dist/plugin/.mcp.json +1 -1
- package/dist/plugin/scripts/guardrail-dispatch.sh +6 -0
- package/dist/plugin/scripts/guardrail-e2e-gate.sh +24 -0
- package/dist/plugin/scripts/guardrail-lib.sh +1 -0
- package/dist/plugin/scripts/guardrails.mjs +99 -14
- package/dist/plugin/scripts/pr-watch-arm.sh +14 -10
- package/dist/plugin/scripts/pr-watch-events.sh +14 -5
- package/dist/plugin/scripts/pr-watch-fetch.sh +1 -1
- package/dist/plugin/scripts/pr-watch-loop.sh +6 -5
- package/dist/plugin/scripts/pr-watch-state.jq +15 -5
- package/dist/plugin/scripts/pr-watch-verdict.mjs +22 -7
- package/dist/plugin/skills/_shared/ci-check-to-command.md +1 -1
- package/dist/plugin/skills/do/e2e-acceptance.md +1 -1
- package/dist/plugin/skills/do/fix-ci.md +9 -1
- package/dist/plugin/skills/muggle-pr-followup/arm-watcher.md +2 -2
- package/dist/plugin/skills/muggle-pr-followup/bootstrap.md +2 -0
- package/dist/plugin/skills/muggle-pr-followup/contract.md +2 -2
- package/dist/plugin/skills/muggle-pr-followup/state-schemas.md +12 -12
- package/dist/release-manifest.json +3 -3
- package/package.json +1 -1
- package/plugin/.claude-plugin/plugin.json +1 -1
- package/plugin/.codex-mcp.json +1 -1
- package/plugin/.codex-plugin/plugin.json +1 -1
- package/plugin/.cursor-plugin/plugin.json +1 -1
- package/plugin/.mcp.json +1 -1
- package/plugin/scripts/guardrail-dispatch.sh +6 -0
- package/plugin/scripts/guardrail-e2e-gate.sh +24 -0
- package/plugin/scripts/guardrail-lib.sh +1 -0
- package/plugin/scripts/guardrails.mjs +99 -14
- package/plugin/scripts/pr-watch-arm.sh +14 -10
- package/plugin/scripts/pr-watch-events.sh +14 -5
- package/plugin/scripts/pr-watch-fetch.sh +1 -1
- package/plugin/scripts/pr-watch-loop.sh +6 -5
- package/plugin/scripts/pr-watch-state.jq +15 -5
- package/plugin/scripts/pr-watch-verdict.mjs +22 -7
- package/plugin/skills/_shared/ci-check-to-command.md +1 -1
- package/plugin/skills/do/e2e-acceptance.md +1 -1
- package/plugin/skills/do/fix-ci.md +9 -1
- package/plugin/skills/muggle-pr-followup/arm-watcher.md +2 -2
- package/plugin/skills/muggle-pr-followup/bootstrap.md +2 -0
- package/plugin/skills/muggle-pr-followup/contract.md +2 -2
- package/plugin/skills/muggle-pr-followup/state-schemas.md +12 -12
|
@@ -9,7 +9,7 @@
|
|
|
9
9
|
"name": "muggleai",
|
|
10
10
|
"source": "./plugin",
|
|
11
11
|
"description": "Real-browser E2E acceptance for your coding agent: test the flows you changed and open the PR with screenshots. Built for flows other tools can't reach: managed test identities with live inboxes (magic links, email OTP and 2FA, verification mail, password resets), an encrypted secret store for login and test credentials, and a test graph that runs prerequisite flows first. Plain-English tests become replayable scripts on localhost or preview URLs.",
|
|
12
|
-
"version": "5.29.0-staging.
|
|
12
|
+
"version": "5.29.0-staging.257"
|
|
13
13
|
}
|
|
14
14
|
]
|
|
15
15
|
}
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "muggle",
|
|
3
3
|
"description": "Real-browser E2E acceptance for your coding agent: test the flows you changed and open the PR with screenshots. Built for flows other tools can't reach: managed test identities with live inboxes (magic links, email OTP and 2FA, verification mail, password resets), an encrypted secret store for login and test credentials, and a test graph that runs prerequisite flows first. Plain-English tests become replayable scripts on localhost or preview URLs.",
|
|
4
|
-
"version": "5.29.0-staging.
|
|
4
|
+
"version": "5.29.0-staging.257",
|
|
5
5
|
"author": {
|
|
6
6
|
"name": "Muggle AI",
|
|
7
7
|
"email": "support@muggle-ai.com"
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "muggle",
|
|
3
|
-
"version": "5.29.0-staging.
|
|
3
|
+
"version": "5.29.0-staging.257",
|
|
4
4
|
"description": "Real-browser E2E acceptance for your coding agent: test the flows you changed and open the PR with screenshots. Built for flows other tools can't reach: managed test identities with live inboxes (magic links, email OTP and 2FA, verification mail, password resets), an encrypted secret store for login and test credentials, and a test graph that runs prerequisite flows first. Plain-English tests become replayable scripts on localhost or preview URLs.",
|
|
5
5
|
"author": {
|
|
6
6
|
"name": "Muggle AI",
|
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
"name": "muggle",
|
|
3
3
|
"displayName": "Muggle AI",
|
|
4
4
|
"description": "Real-browser E2E acceptance for your coding agent: test the flows you changed and open the PR with screenshots. Built for flows other tools can't reach: managed test identities with live inboxes (magic links, email OTP and 2FA, verification mail, password resets), an encrypted secret store for login and test credentials, and a test graph that runs prerequisite flows first. Plain-English tests become replayable scripts on localhost or preview URLs.",
|
|
5
|
-
"version": "5.29.0-staging.
|
|
5
|
+
"version": "5.29.0-staging.257",
|
|
6
6
|
"author": {
|
|
7
7
|
"name": "Muggle AI",
|
|
8
8
|
"email": "support@muggle-ai.com"
|
package/dist/plugin/.mcp.json
CHANGED
|
@@ -23,8 +23,14 @@ guardrail_read_payload
|
|
|
23
23
|
firing_subcommands=()
|
|
24
24
|
for gate_script in "$@"; do
|
|
25
25
|
GUARDRAIL_SUBCOMMAND=""
|
|
26
|
+
unset -f guardrail_shell_verdict
|
|
26
27
|
. "$guardrail_script_dir/$gate_script" || continue
|
|
27
28
|
if [[ -n $GUARDRAIL_SUBCOMMAND ]] && guardrail_wants; then
|
|
29
|
+
# A gate that can answer in-shell blocks the turn here, before Node's cold start can outlast the hook's cap;
|
|
30
|
+
# the gates after it run on the turn end that follows.
|
|
31
|
+
if declare -F guardrail_shell_verdict >/dev/null && guardrail_shell_verdict; then
|
|
32
|
+
exit 0
|
|
33
|
+
fi
|
|
28
34
|
firing_subcommands+=("$GUARDRAIL_SUBCOMMAND")
|
|
29
35
|
fi
|
|
30
36
|
done
|
|
@@ -28,6 +28,30 @@ guardrail_wants() {
|
|
|
28
28
|
&& ! guardrail_state_has '"e2eRun": true'
|
|
29
29
|
}
|
|
30
30
|
|
|
31
|
+
# Returns the block guardrails.mjs rendered ahead of time (writeE2eStopVerdict), without starting Node, while the
|
|
32
|
+
# budget it shares with Node's own blocks lasts. A Stop hook that outlives its one-second cap is dropped and the turn
|
|
33
|
+
# ends unblocked, and Node's cold start alone has taken seconds on a loaded Windows machine, so a block that waits on
|
|
34
|
+
# Node is one that can silently never land. Returns 1, leaving the decision and the release to Node, when nothing is
|
|
35
|
+
# rendered or the budget is spent.
|
|
36
|
+
guardrail_shell_verdict() {
|
|
37
|
+
local verdict_file="${GUARDRAIL_STATE_FILE%.json}.e2e-stop-verdict"
|
|
38
|
+
local shell_blocks_file="${GUARDRAIL_STATE_FILE%.json}.e2e-shell-blocks"
|
|
39
|
+
[[ -f $verdict_file ]] || return 1
|
|
40
|
+
local verdict=""
|
|
41
|
+
IFS= read -r -d '' verdict <"$verdict_file" || true
|
|
42
|
+
local max_blocks="${verdict%%$'\n'*}"
|
|
43
|
+
local hook_output="${verdict#*$'\n'}"
|
|
44
|
+
[[ $max_blocks =~ ^[0-9]+$ && -n $hook_output && $hook_output != "$verdict" ]] || return 1
|
|
45
|
+
local recorded_blocks_pattern='"e2eBlockCount"[[:space:]]*:[[:space:]]*([0-9]+)'
|
|
46
|
+
local recorded_blocks=0
|
|
47
|
+
[[ $GUARDRAIL_STATE =~ $recorded_blocks_pattern ]] && recorded_blocks="${BASH_REMATCH[1]}"
|
|
48
|
+
local shell_blocks=""
|
|
49
|
+
[[ -f $shell_blocks_file ]] && { IFS= read -r -d '' shell_blocks <"$shell_blocks_file" || true; }
|
|
50
|
+
((recorded_blocks + ${#shell_blocks} < max_blocks)) || return 1
|
|
51
|
+
{ printf 'x' >>"$shell_blocks_file"; } 2>/dev/null || return 1
|
|
52
|
+
printf '%s' "$hook_output"
|
|
53
|
+
}
|
|
54
|
+
|
|
31
55
|
if guardrail_is_hook_script; then
|
|
32
56
|
guardrail_run_standalone "$GUARDRAIL_SUBCOMMAND"
|
|
33
57
|
fi
|
|
@@ -105,6 +105,10 @@ var SUBAGENT_DISPATCH_TOOL_NAMES = ["Agent", "Task"];
|
|
|
105
105
|
var ACCEPTANCE_TESTER_SUBAGENT_TYPE = /(?:^|:)acceptance-tester$/;
|
|
106
106
|
var ACCEPTANCE_REPORT_MARKERS = ["## Test Summary", "**Overall:**"];
|
|
107
107
|
var DELEGATED_E2E_WINDOW_MS = 60 * 6e4;
|
|
108
|
+
var E2E_STOP_VERDICT_SUFFIX = ".e2e-stop-verdict";
|
|
109
|
+
var E2E_SHELL_BLOCKS_SUFFIX = ".e2e-shell-blocks";
|
|
110
|
+
var AUTO_E2E_TEST_PREFERENCE_KEY = "autoE2ETest";
|
|
111
|
+
var POST_PR_VISUAL_WALKTHROUGH_PREFERENCE_KEY = "postPRVisualWalkthrough";
|
|
108
112
|
|
|
109
113
|
// src/guardrails/store/fileLock.ts
|
|
110
114
|
function isProcessAlive(pid) {
|
|
@@ -220,6 +224,25 @@ function readPrStateScan(sessionId2, dirOverride) {
|
|
|
220
224
|
function writePrStateScan(sessionId2, scan, dirOverride) {
|
|
221
225
|
writeAtomically(fileFor(sessionId2, dirOverride, PR_STATE_SCAN_SUFFIX), scan, dirOverride);
|
|
222
226
|
}
|
|
227
|
+
function writeE2eStopVerdict(sessionId2, maxBlocks, hookOutput, dirOverride) {
|
|
228
|
+
mkdirSync(baseDir(dirOverride), { recursive: true });
|
|
229
|
+
const target = fileFor(sessionId2, dirOverride, E2E_STOP_VERDICT_SUFFIX);
|
|
230
|
+
const staging = `${target}.${process.pid}.tmp`;
|
|
231
|
+
writeFileSync(staging, `${maxBlocks}
|
|
232
|
+
${hookOutput}`);
|
|
233
|
+
renameSync(staging, target);
|
|
234
|
+
writeFileSync(fileFor(sessionId2, dirOverride, E2E_SHELL_BLOCKS_SUFFIX), "", { flag: "a" });
|
|
235
|
+
}
|
|
236
|
+
function readE2eShellBlockCount(sessionId2, dirOverride) {
|
|
237
|
+
try {
|
|
238
|
+
return readFileSync(fileFor(sessionId2, dirOverride, E2E_SHELL_BLOCKS_SUFFIX), "utf-8").length;
|
|
239
|
+
} catch {
|
|
240
|
+
return 0;
|
|
241
|
+
}
|
|
242
|
+
}
|
|
243
|
+
function clearE2eShellBlocks(sessionId2, dirOverride) {
|
|
244
|
+
rmSync(fileFor(sessionId2, dirOverride, E2E_SHELL_BLOCKS_SUFFIX), { force: true });
|
|
245
|
+
}
|
|
223
246
|
var lockFor = (sessionId2, dirOverride) => `${fileFor(sessionId2, dirOverride)}.lock`;
|
|
224
247
|
function commitUnderHeldLock(sessionId2, mutate, dirOverride) {
|
|
225
248
|
const current = readState(sessionId2, dirOverride);
|
|
@@ -1816,10 +1839,14 @@ function applyStateMutation(state, mutation) {
|
|
|
1816
1839
|
function commitStateMutation(sessionId2, mutation, observedState) {
|
|
1817
1840
|
if (observedState && applyStateMutation(observedState, mutation) === observedState) return observedState;
|
|
1818
1841
|
const committed = commitState(sessionId2, (state) => applyStateMutation(state, mutation));
|
|
1819
|
-
if (committed) return committed;
|
|
1842
|
+
if (committed) return withShellBlocksReset(committed);
|
|
1820
1843
|
deferStateMutation(sessionId2, mutation);
|
|
1821
1844
|
startBackgroundJob("commit-deferred-state" /* CommitDeferredState */, [sessionId2]);
|
|
1822
|
-
return applyStateMutation(readState(sessionId2), mutation);
|
|
1845
|
+
return withShellBlocksReset(applyStateMutation(readState(sessionId2), mutation));
|
|
1846
|
+
}
|
|
1847
|
+
function withShellBlocksReset(state) {
|
|
1848
|
+
if (state.e2eRun === true) clearE2eShellBlocks(state.sessionId);
|
|
1849
|
+
return state;
|
|
1823
1850
|
}
|
|
1824
1851
|
function commitDeferredSessionState(sessionId2) {
|
|
1825
1852
|
commitDeferredStateMutations(sessionId2, applyStateMutation);
|
|
@@ -1925,19 +1952,52 @@ function isE2ERun(input2) {
|
|
|
1925
1952
|
if (MUGGLE_E2E_RUN_TOOL.test(input2.tool_name ?? "")) return true;
|
|
1926
1953
|
return MUGGLE_SKILL_EMIT_TOOL.test(input2.tool_name ?? "") && input2.tool_input?.skillName === MUGGLE_TEST_SKILL_NAME;
|
|
1927
1954
|
}
|
|
1955
|
+
|
|
1956
|
+
// src/guardrails/e2eOwedReason.ts
|
|
1957
|
+
var describePreference = (preferenceKey, configuredValue) => `${preferenceKey}=${configuredValue ?? "unset"}`;
|
|
1958
|
+
function buildE2eOwedReason(input2) {
|
|
1959
|
+
const isE2eMandated = input2.isE2eMandatedByUser;
|
|
1960
|
+
const isWalkthroughMandated = input2.postPRVisualWalkthrough === ALWAYS_PREFERENCE_VALUE;
|
|
1961
|
+
const preferenceSummary = `${describePreference(AUTO_E2E_TEST_PREFERENCE_KEY, input2.autoE2ETest)}, ${describePreference(POST_PR_VISUAL_WALKTHROUGH_PREFERENCE_KEY, input2.postPRVisualWalkthrough)} (~/.muggle-ai/preferences.json)`;
|
|
1962
|
+
const walkthroughStep = isWalkthroughMandated ? `, then post the run's walkthrough to the PR via ${input2.walkthroughSkill}` : "";
|
|
1963
|
+
const offeredSkipCodes = isE2eMandated ? E2E_SKIP_CODES.filter((skipCode) => skipCode !== "NOT_APPLICABLE" /* NotApplicable */) : E2E_SKIP_CODES;
|
|
1964
|
+
const skipClause = `To skip, run \`echo "MUGGLE_E2E_SKIP: <CODE>: <detail>"\` citing one of ${offeredSkipCodes.join(", ")}. Muggle verifies each code itself, so one that does not hold leaves this gate blocked.`;
|
|
1965
|
+
if (input2.blockCount > 1) {
|
|
1966
|
+
return `E2E acceptance run still owed (reminder ${input2.blockCount}/${input2.maxBlocks}; ${preferenceSummary}): run ${input2.muggleTestSkill}${walkthroughStep}, or cite a verifiable code via \`echo "MUGGLE_E2E_SKIP: <CODE>: <detail>"\`.`;
|
|
1967
|
+
}
|
|
1968
|
+
const owedSentence = `Do not end the turn yet. This session went unit-green or opened a PR, but no E2E acceptance run has happened. Preferences: ${preferenceSummary}. `;
|
|
1969
|
+
if (isE2eMandated) {
|
|
1970
|
+
return owedSentence + `That is the user's standing answer: do not ask them whether to test, and do not end the turn handing the decision back. Run change-driven E2E now via ${input2.muggleTestSkill}${walkthroughStep}, then finish. ${skipClause} A judgment that the change is not worth a run is not a skip.`;
|
|
1971
|
+
}
|
|
1972
|
+
return owedSentence + `Run change-driven E2E now via ${input2.muggleTestSkill}${walkthroughStep}, then finish. ${skipClause} Only when no verifiable code fits and a browser run genuinely cannot exercise the change, cite ${"NOT_APPLICABLE" /* NotApplicable */} with the reason \u2014 the PR shows it as your unverified judgment.`;
|
|
1973
|
+
}
|
|
1928
1974
|
function parseMaxCatchUpRebases(configuredValue) {
|
|
1929
1975
|
if (configuredValue === UNBOUNDED_PREFERENCE_VALUE) return null;
|
|
1930
1976
|
const spelled = typeof configuredValue === "number" ? String(configuredValue) : configuredValue;
|
|
1931
1977
|
return typeof spelled === "string" && POSITIVE_INTEGER.test(spelled) ? Number(spelled) : void 0;
|
|
1932
1978
|
}
|
|
1979
|
+
var readUserPreferences = (homeDir) => asRecord(readJsonRecord(join(homeDir, ...USER_PREFERENCES_PATH_SEGMENTS)).preferences);
|
|
1980
|
+
function readPreferenceLayers(homeDir, pluginRoot) {
|
|
1981
|
+
return [readUserPreferences(homeDir), readJsonRecord(join(pluginRoot, ...PREFERENCE_DEFAULTS_PATH_SEGMENTS))];
|
|
1982
|
+
}
|
|
1983
|
+
function pickPreferenceValue(preferenceLayers, preferenceKey) {
|
|
1984
|
+
const configuredValue = preferenceLayers.map((layer) => layer[preferenceKey]).find((layerValue) => layerValue != null);
|
|
1985
|
+
return typeof configuredValue === "string" ? configuredValue : void 0;
|
|
1986
|
+
}
|
|
1987
|
+
function resolvePreferenceValue(preferenceKey, homeDir = homedir(), pluginRoot = resolvePluginRoot()) {
|
|
1988
|
+
return pickPreferenceValue(readPreferenceLayers(homeDir, pluginRoot), preferenceKey);
|
|
1989
|
+
}
|
|
1990
|
+
function resolveUserPreferenceValue(preferenceKey, homeDir = homedir()) {
|
|
1991
|
+
return pickPreferenceValue([readUserPreferences(homeDir)], preferenceKey);
|
|
1992
|
+
}
|
|
1933
1993
|
function resolveWatchPreferences(homeDir = homedir(), pluginRoot = resolvePluginRoot()) {
|
|
1934
|
-
const
|
|
1935
|
-
const userPreferences =
|
|
1936
|
-
const autoRebase =
|
|
1994
|
+
const preferenceLayers = readPreferenceLayers(homeDir, pluginRoot);
|
|
1995
|
+
const [userPreferences, shippedDefaults] = preferenceLayers;
|
|
1996
|
+
const autoRebase = pickPreferenceValue(preferenceLayers, AUTO_REBASE_PREFERENCE_KEY);
|
|
1937
1997
|
const userCap = parseMaxCatchUpRebases(userPreferences[MAX_CATCH_UP_REBASES_PREFERENCE_KEY]);
|
|
1938
1998
|
const shippedCap = parseMaxCatchUpRebases(shippedDefaults[MAX_CATCH_UP_REBASES_PREFERENCE_KEY]);
|
|
1939
1999
|
return {
|
|
1940
|
-
autoRebase
|
|
2000
|
+
autoRebase,
|
|
1941
2001
|
maxCatchUpRebases: userCap !== void 0 ? userCap : shippedCap ?? null
|
|
1942
2002
|
};
|
|
1943
2003
|
}
|
|
@@ -2216,7 +2276,12 @@ var verifyNoAppLogicChange = (context, detail) => {
|
|
|
2216
2276
|
if (appLogicPaths.length > 0) return refuted(`app-logic paths changed since ${sha}: ${namePaths(appLogicPaths)}`);
|
|
2217
2277
|
return verified;
|
|
2218
2278
|
};
|
|
2219
|
-
var verifyNotApplicable = (
|
|
2279
|
+
var verifyNotApplicable = (context, detail) => {
|
|
2280
|
+
if (context.userAutoE2ETest === ALWAYS_PREFERENCE_VALUE) {
|
|
2281
|
+
return refuted(
|
|
2282
|
+
`autoE2ETest=always in ~/.muggle-ai/preferences.json, so a judgment that the change needs no run is not a skip the user allows \u2014 run the E2E, or cite a code Muggle can verify`
|
|
2283
|
+
);
|
|
2284
|
+
}
|
|
2220
2285
|
const words = detail.trim().split(/\s+/).filter(Boolean).length;
|
|
2221
2286
|
if (words >= MIN_NOT_APPLICABLE_REASON_WORDS) return verified;
|
|
2222
2287
|
return refuted(
|
|
@@ -2261,12 +2326,14 @@ function explainRejection(judged) {
|
|
|
2261
2326
|
return `The skip cited ${judged.claimedCode}, but that could not be verified: ${judged.failure}. E2E is still owed \u2014 cite a code that holds, or run it.`;
|
|
2262
2327
|
}
|
|
2263
2328
|
function judgeE2eSkip(input2, state, probes = defaultSkipProbes) {
|
|
2329
|
+
const homeDir = homedir();
|
|
2264
2330
|
return resolveSkipDeclaration(input2.tool_input?.command ?? "", {
|
|
2265
2331
|
cwd: input2.cwd,
|
|
2266
2332
|
prsHandled: state.prsHandled,
|
|
2267
2333
|
transcriptPath: input2.transcript_path,
|
|
2268
|
-
homeDir
|
|
2269
|
-
probe: probes
|
|
2334
|
+
homeDir,
|
|
2335
|
+
probe: probes,
|
|
2336
|
+
userAutoE2ETest: resolveUserPreferenceValue(AUTO_E2E_TEST_PREFERENCE_KEY, homeDir)
|
|
2270
2337
|
});
|
|
2271
2338
|
}
|
|
2272
2339
|
|
|
@@ -2688,6 +2755,7 @@ function prOpened() {
|
|
|
2688
2755
|
prUrl: url,
|
|
2689
2756
|
prChange
|
|
2690
2757
|
});
|
|
2758
|
+
renderE2eStopVerdict(stateAfterOpen);
|
|
2691
2759
|
const declaredSkip = isPrCoveredBySkip(stateAfterOpen, url) ? stateAfterOpen.e2eSkipDeclaration : void 0;
|
|
2692
2760
|
if (declaredSkip) {
|
|
2693
2761
|
startBackgroundJob("settle-walkthrough-skipped" /* SettleWalkthroughSkipped */, [url, declaredSkip.code, declaredSkip.detail]);
|
|
@@ -2766,6 +2834,7 @@ function recordTests() {
|
|
|
2766
2834
|
failedRunId: detectFailedRunId(input)
|
|
2767
2835
|
};
|
|
2768
2836
|
const next = commitStateMutation(sessionId, outcome);
|
|
2837
|
+
if (outcome.run.unitTestPassed) renderE2eStopVerdict(next);
|
|
2769
2838
|
startWalkthroughScanOnGateArming(stateBefore, next, walkthroughPosted);
|
|
2770
2839
|
if (acceptedSkip) recordSkipReasonOnPrs(next, acceptedSkip);
|
|
2771
2840
|
if (judgedSkip && !judgedSkip.accepted) {
|
|
@@ -2869,14 +2938,30 @@ function debugPathGate() {
|
|
|
2869
2938
|
const reason = decision.blockCount === 1 ? `Do not end the turn yet. These runs failed and never went through the debug path: ${runList}. muggle-test Step 7C makes that mandatory \u2014 route each through _shared/debug-failed-run.md: gather the attempted steps and the failing screenshot, diagnose the bucket per _shared/failure-mode-handling.md \xA7B/\xA7C with its classified telemetry emit, and present the offer in which "give feedback & rerun" is always available. A summarized-and-dropped failure is the run a reviewer most needs to see. If a run genuinely cannot be debugged, run \`echo "MUGGLE_DEBUG_SKIP: <runId> <reason>"\` \u2014 that clears just that run.` : `Failed runs still owe the debug path (reminder ${decision.blockCount}/${MAX_DEBUG_BLOCKS}): ${runList}. Route each through _shared/debug-failed-run.md, or record a legitimate skip via \`echo "MUGGLE_DEBUG_SKIP: <runId> <reason>"\`.`;
|
|
2870
2939
|
return blockStop(reason, host);
|
|
2871
2940
|
}
|
|
2941
|
+
function e2eOwedReason(blockCount) {
|
|
2942
|
+
return buildE2eOwedReason({
|
|
2943
|
+
blockCount,
|
|
2944
|
+
maxBlocks: MAX_E2E_BLOCKS,
|
|
2945
|
+
autoE2ETest: resolvePreferenceValue(AUTO_E2E_TEST_PREFERENCE_KEY),
|
|
2946
|
+
postPRVisualWalkthrough: resolvePreferenceValue(POST_PR_VISUAL_WALKTHROUGH_PREFERENCE_KEY),
|
|
2947
|
+
isE2eMandatedByUser: resolveUserPreferenceValue(AUTO_E2E_TEST_PREFERENCE_KEY) === ALWAYS_PREFERENCE_VALUE,
|
|
2948
|
+
muggleTestSkill: muggleSkill("/muggle:muggle-test"),
|
|
2949
|
+
walkthroughSkill: muggleSkill("/muggle:muggle-pr-visual-walkthrough")
|
|
2950
|
+
});
|
|
2951
|
+
}
|
|
2952
|
+
function renderE2eStopVerdict(state) {
|
|
2953
|
+
if (!shouldRunE2E(state)) return;
|
|
2954
|
+
writeE2eStopVerdict(sessionId, MAX_E2E_BLOCKS, blockStop(e2eOwedReason(1), host));
|
|
2955
|
+
}
|
|
2872
2956
|
function e2eGate() {
|
|
2873
2957
|
const state = readState(sessionId);
|
|
2874
|
-
const
|
|
2958
|
+
const shellBlockCount = readE2eShellBlockCount(sessionId);
|
|
2959
|
+
const decision = e2eGateDecision({ ...state, e2eBlockCount: (state.e2eBlockCount ?? 0) + shellBlockCount });
|
|
2875
2960
|
if (decision.action === "release" /* Release */) return releaseGate("e2eReleased");
|
|
2876
2961
|
if (decision.action === "none" /* None */) return "{}";
|
|
2877
|
-
recordGateBlock("e2eBlockCount", decision.blockCount);
|
|
2878
|
-
|
|
2879
|
-
return blockStop(
|
|
2962
|
+
recordGateBlock("e2eBlockCount", decision.blockCount - shellBlockCount);
|
|
2963
|
+
renderE2eStopVerdict(state);
|
|
2964
|
+
return blockStop(e2eOwedReason(decision.blockCount), host);
|
|
2880
2965
|
}
|
|
2881
2966
|
function watchGate() {
|
|
2882
2967
|
const state = readState(sessionId);
|
|
@@ -2928,7 +3013,7 @@ function watchFollowThroughGate() {
|
|
|
2928
3013
|
const holdInstruction = `Only the user can hold it: their own message containing \`${WATCH_HOLD_PHRASE_PREFIX}<pr-number>\` for that PR. Ask if you must; never decide it for them.`;
|
|
2929
3014
|
const reason = decision.blockCount === 1 ? `Do not end the turn yet. A muggle-pr-followup watch this session owns found work due that nothing acted on:
|
|
2930
3015
|
${owedList}
|
|
2931
|
-
autoRebase=always makes a due rebase binding, and how long a CI run or an eval takes is never a reason to wait. Act now \u2014 if
|
|
3016
|
+
autoRebase=always makes a due rebase binding, and how long a CI run or an eval takes is never a reason to wait. Act now: run the cycle the tick dispatches \u2014 if it finds actionable review feedback it dispatches that first. That cycle's first step records its attempt (conflict_resolve_attempts / ci_fix_attempts), which, like a new head, clears this. Writing the count without running the cycle \u2014 or for an E2E run queued for a different PR \u2014 leaves the PR red and the watch quiet. An escalation entry with no attempt behind it does not clear this, and neither does re-arming the watch. ${holdInstruction}` : `Watcher work still owed (reminder ${decision.blockCount}):
|
|
2932
3017
|
${owedList}
|
|
2933
3018
|
${holdInstruction}`;
|
|
2934
3019
|
return blockStop(reason, host);
|
|
@@ -13,10 +13,12 @@
|
|
|
13
13
|
# seen without ever being reported. A re-arm keeps the review, comment and thread
|
|
14
14
|
# floors the session already holds, for the same reason.
|
|
15
15
|
#
|
|
16
|
-
# A stale branch
|
|
17
|
-
#
|
|
18
|
-
#
|
|
19
|
-
#
|
|
16
|
+
# A stale branch is floored only when the slot proves a rebase ran for it, and a
|
|
17
|
+
# red head only once fix-ci is finished with it — escalated, or out of attempts.
|
|
18
|
+
# Flooring whatever was outstanding at arm time let a session that never rebased
|
|
19
|
+
# or fixed anything re-arm at every rollover and silence the loop each time; the
|
|
20
|
+
# drain line said "handled" and nothing checked. A bare attempt count is no
|
|
21
|
+
# better proof for CI: writing it costs nothing and changes nothing on the PR.
|
|
20
22
|
set -uo pipefail
|
|
21
23
|
|
|
22
24
|
script_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
|
@@ -77,6 +79,7 @@ latest_comment="${STATE_FIELDS[5]-}"
|
|
|
77
79
|
unresolved_threads="${STATE_FIELDS[6]-}"
|
|
78
80
|
pending_checks="${STATE_FIELDS[7]-}"
|
|
79
81
|
failed_checks="${STATE_FIELDS[8]-}"
|
|
82
|
+
ci_red_key="$(watch_ci_red_key "$head_sha" "${STATE_FIELDS[10]-}")"
|
|
80
83
|
|
|
81
84
|
if [ "$pr_state" = "MERGED" ] || [ "$pr_state" = "CLOSED" ]; then
|
|
82
85
|
echo "TERMINAL pr=$pr_number state=$pr_state — nothing to arm"
|
|
@@ -92,7 +95,8 @@ rebase_due_key=""
|
|
|
92
95
|
behind_count="$(watch_fetch_behind_count "$repo" "$base_branch" "$head_sha" "$slot")"
|
|
93
96
|
watch_branch_needs_rebase "$mergeable" "$behind_count" && rebase_due_key="$rebase_key"
|
|
94
97
|
|
|
95
|
-
# Whether a
|
|
98
|
+
# Whether a rebase already ran for this key, whether fix-ci is done with this
|
|
99
|
+
# head, and the blocked
|
|
96
100
|
# fingerprint's digest. A slot re-armed while blocked keeps its resume probe
|
|
97
101
|
# live: empty is the not-blocked state, so seeding it empty would leave the loop
|
|
98
102
|
# asleep through the very CI move the block is waiting on.
|
|
@@ -105,14 +109,14 @@ watch_branch_needs_rebase "$mergeable" "$behind_count" && rebase_due_key="$rebas
|
|
|
105
109
|
last_seen_file="${slot}/last_seen.json"
|
|
106
110
|
[ -f "$last_seen_file" ] || last_seen_file=/dev/null
|
|
107
111
|
rebase_attempted=0
|
|
108
|
-
|
|
112
|
+
ci_settled=0
|
|
109
113
|
blocked_digest_floor=""
|
|
110
114
|
rebase_budget_exhausted=0
|
|
111
115
|
while IFS= read -r evidence_line; do
|
|
112
116
|
evidence_line="${evidence_line%$'\r'}"
|
|
113
117
|
case "$evidence_line" in
|
|
114
118
|
REBASE_ATTEMPTED=*) rebase_attempted="${evidence_line#REBASE_ATTEMPTED=}" ;;
|
|
115
|
-
|
|
119
|
+
CI_SETTLED=*) ci_settled="${evidence_line#CI_SETTLED=}" ;;
|
|
116
120
|
BLOCKED_CIDIGEST=*) blocked_digest_floor="${evidence_line#BLOCKED_CIDIGEST=}" ;;
|
|
117
121
|
REBASE_BUDGET_EXHAUSTED=*) rebase_budget_exhausted="${evidence_line#REBASE_BUDGET_EXHAUSTED=}" ;;
|
|
118
122
|
esac
|
|
@@ -120,7 +124,7 @@ done < <(node "${script_dir}/pr-watch-verdict.mjs" arm-floors --pr-key "${repo}#
|
|
|
120
124
|
--head "$head_sha" --rebase-key "$rebase_key" < "$last_seen_file" 2>/dev/null)
|
|
121
125
|
|
|
122
126
|
ci_red_floor=""
|
|
123
|
-
[ -n "$ci_red_head" ] && [ "$
|
|
127
|
+
[ -n "$ci_red_head" ] && [ "$ci_settled" = "1" ] && ci_red_floor="$ci_red_key"
|
|
124
128
|
rebase_floor=""
|
|
125
129
|
[ -n "$rebase_due_key" ] && [ "$rebase_attempted" = "1" ] && rebase_floor="$rebase_due_key"
|
|
126
130
|
|
|
@@ -145,9 +149,9 @@ echo "DRAIN unresolved-threads=$thread_count latest-review=${latest_review:-0} l
|
|
|
145
149
|
echo "DRAIN checks pending=${pending_checks:-0} failed=${failed_checks:-0} behind-base=${behind_count:-unknown} mergeable=${mergeable:-UNKNOWN}"
|
|
146
150
|
if [ -n "$ci_red_head" ]; then
|
|
147
151
|
if [ -n "$ci_red_floor" ]; then
|
|
148
|
-
echo "DRAIN ci red at arm time —
|
|
152
|
+
echo "DRAIN ci red at arm time — fix-ci escalated or spent its attempts on head ${head_sha:0:8}, floored"
|
|
149
153
|
else
|
|
150
|
-
echo "DRAIN ci red at arm time —
|
|
154
|
+
echo "DRAIN ci red at arm time — fix-ci not finished with head ${head_sha:0:8}, not floored: dispatch fix-ci"
|
|
151
155
|
fi
|
|
152
156
|
fi
|
|
153
157
|
if [ -n "$rebase_due_key" ]; then
|
|
@@ -80,15 +80,24 @@ watch_checks_settled_red() {
|
|
|
80
80
|
[ "${failed_count:-0}" -gt 0 ] 2>/dev/null
|
|
81
81
|
}
|
|
82
82
|
|
|
83
|
+
# The identity of one red verdict: the head plus the run ids of its failed
|
|
84
|
+
# checks (the state line's red-check-runs).
|
|
85
|
+
watch_ci_red_key() {
|
|
86
|
+
printf '%s@%s' "$1" "$2"
|
|
87
|
+
}
|
|
88
|
+
|
|
83
89
|
# The head's checks having settled red (watch_checks_settled_red).
|
|
84
90
|
#
|
|
85
|
-
# The floor is
|
|
86
|
-
# not monotonic: it flips green to red and resets on every push.
|
|
87
|
-
#
|
|
91
|
+
# The floor is a red key (watch_ci_red_key) rather than a monotonic id because a
|
|
92
|
+
# check rollup is not monotonic: it flips green to red and resets on every push.
|
|
93
|
+
# The head alone is not enough: a fix-ci cycle that settles the walkthrough or
|
|
94
|
+
# reruns a check keeps the head, and when that new run comes back red too it is
|
|
95
|
+
# news the cycle is waiting on. A head-only floor swallowed it, so the re-run's
|
|
96
|
+
# verdict never reached anyone.
|
|
88
97
|
watch_wake_ci_red() {
|
|
89
|
-
local pr="$1" pending_count="$2" failed_count="$3" head_sha="$4"
|
|
98
|
+
local pr="$1" pending_count="$2" failed_count="$3" head_sha="$4" red_key="$5" floor="$6"
|
|
90
99
|
[ -n "$head_sha" ] || return 1
|
|
91
|
-
[ "$
|
|
100
|
+
[ "$red_key" != "$floor" ] || return 1
|
|
92
101
|
watch_checks_settled_red "$pending_count" "$failed_count" || return 1
|
|
93
102
|
echo "EVENT pr=$pr checks settled red head=$head_sha"
|
|
94
103
|
}
|
|
@@ -25,7 +25,7 @@ query($owner: String!, $name: String!, $number: Int!) {
|
|
|
25
25
|
contexts(first: 100) {
|
|
26
26
|
nodes {
|
|
27
27
|
__typename
|
|
28
|
-
... on CheckRun { name status conclusion }
|
|
28
|
+
... on CheckRun { databaseId name status conclusion }
|
|
29
29
|
... on StatusContext { context state }
|
|
30
30
|
}
|
|
31
31
|
}
|
|
@@ -178,6 +178,7 @@ while :; do
|
|
|
178
178
|
pending_checks="${STATE_FIELDS[7]-}"
|
|
179
179
|
failed_checks="${STATE_FIELDS[8]-}"
|
|
180
180
|
ci_digest="${STATE_FIELDS[9]-}"
|
|
181
|
+
ci_red_key="$(watch_ci_red_key "$head_sha" "${STATE_FIELDS[10]-}")"
|
|
181
182
|
|
|
182
183
|
if [ "$pr_state" = "MERGED" ] || [ "$pr_state" = "CLOSED" ]; then
|
|
183
184
|
echo "TERMINAL pr=$pr_number state=$pr_state"
|
|
@@ -206,12 +207,12 @@ while :; do
|
|
|
206
207
|
done
|
|
207
208
|
fi
|
|
208
209
|
|
|
209
|
-
# Either floor quiets the
|
|
210
|
-
# it, the reported one when a loop already told the session it
|
|
210
|
+
# Either floor quiets the red verdict: the watermark's when fix-ci settled
|
|
211
|
+
# it, the reported one when a loop already told the session about it.
|
|
211
212
|
ci_red_floor="$watermark_ci_red"
|
|
212
|
-
[ "$floor_ci_red" = "$
|
|
213
|
-
if watch_wake_ci_red "$pr_number" "$pending_checks" "$failed_checks" "$head_sha" "$ci_red_floor"; then
|
|
214
|
-
floor_ci_red="$
|
|
213
|
+
[ "$floor_ci_red" = "$ci_red_key" ] && ci_red_floor="$floor_ci_red"
|
|
214
|
+
if watch_wake_ci_red "$pr_number" "$pending_checks" "$failed_checks" "$head_sha" "$ci_red_key" "$ci_red_floor"; then
|
|
215
|
+
floor_ci_red="$ci_red_key"
|
|
215
216
|
has_reported=1
|
|
216
217
|
fi
|
|
217
218
|
|
|
@@ -5,7 +5,14 @@
|
|
|
5
5
|
#
|
|
6
6
|
# Field order is the contract pr-watch-loop.sh reads positionally:
|
|
7
7
|
# state, headRefOid, base-tip, mergeable, latest-review, latest-comment,
|
|
8
|
-
# unresolved-thread-ids, pending-checks, failed-checks, check-digest
|
|
8
|
+
# unresolved-thread-ids, pending-checks, failed-checks, check-digest,
|
|
9
|
+
# red-check-runs
|
|
10
|
+
#
|
|
11
|
+
# red-check-runs names each failed check with its run id (`name#id`, sorted).
|
|
12
|
+
# A settle, a rerun or a comment-triggered re-check publishes a new run on the
|
|
13
|
+
# same head, so this is what tells a fresh red verdict from the one already
|
|
14
|
+
# reported. check-digest cannot carry the ids: it must equal the digest the
|
|
15
|
+
# blocked tick builds from `gh pr checks`, which has none.
|
|
9
16
|
#
|
|
10
17
|
# base-tip is where the base branch points now, the base half of the rebase key.
|
|
11
18
|
# Not baseRefOid: GitHub moves that only when the head is pushed, so a key built
|
|
@@ -19,9 +26,11 @@ def loop_marked: ((.body // "") | contains("<!-- muggle-do:bot -->"));
|
|
|
19
26
|
| (($pr.commits.nodes[0].commit.statusCheckRollup.contexts.nodes) // []) as $contexts
|
|
20
27
|
| ($contexts | map(
|
|
21
28
|
if .__typename == "CheckRun"
|
|
22
|
-
then {name: .name, verdict: (if .status != "COMPLETED" then "PENDING" else (.conclusion // "NEUTRAL") end)}
|
|
23
|
-
else {name: .context, verdict: (.state // "PENDING")}
|
|
29
|
+
then {name: .name, runId: (.databaseId // ""), verdict: (if .status != "COMPLETED" then "PENDING" else (.conclusion // "NEUTRAL") end)}
|
|
30
|
+
else {name: .context, runId: "", verdict: (.state // "PENDING")}
|
|
24
31
|
end)) as $checks
|
|
32
|
+
| ($checks | map(select(.verdict == "FAILURE" or .verdict == "ERROR" or .verdict == "TIMED_OUT" or .verdict == "STARTUP_FAILURE")))
|
|
33
|
+
as $failedChecks
|
|
25
34
|
|
|
26
35
|
# Every comment on a live thread that belongs to a submitted review. A PENDING
|
|
27
36
|
# review is the reviewer's own draft and is not feedback yet.
|
|
@@ -53,6 +62,7 @@ def loop_marked: ((.body // "") | contains("<!-- muggle-do:bot -->"));
|
|
|
53
62
|
([$pr.reviewThreads.nodes[] | select(.isResolved == false) | select(.isOutdated == false)
|
|
54
63
|
| select((.comments.nodes[0].pullRequestReview.state // "SUBMITTED") != "PENDING") | .id] | join(";")),
|
|
55
64
|
($checks | map(select(.verdict == "PENDING")) | length),
|
|
56
|
-
($
|
|
57
|
-
($checks | sort_by(.name) | map(.name + ":" + .verdict) | join(","))
|
|
65
|
+
($failedChecks | length),
|
|
66
|
+
($checks | sort_by(.name) | map(.name + ":" + .verdict) | join(",")),
|
|
67
|
+
($failedChecks | map(.name + "#" + (.runId | tostring)) | sort | join(","))
|
|
58
68
|
] | @tsv
|
|
@@ -48,6 +48,12 @@ function isRebaseKeyEscalated(lastSeenEntry, rebaseKey) {
|
|
|
48
48
|
function isCiHeadEscalated(lastSeenEntry, headSha) {
|
|
49
49
|
return (lastSeenEntry.ci_escalated_shas ?? []).includes(headSha) && isCiHeadAttempted(lastSeenEntry, headSha);
|
|
50
50
|
}
|
|
51
|
+
function isCiHeadBudgetSpent(lastSeenEntry, headSha) {
|
|
52
|
+
return ciFixAttemptsFor(lastSeenEntry, headSha) >= MAX_CI_FIX_ATTEMPTS_PER_HEAD;
|
|
53
|
+
}
|
|
54
|
+
function isCiHeadSettled(lastSeenEntry, headSha) {
|
|
55
|
+
return isCiHeadEscalated(lastSeenEntry, headSha) || isCiHeadBudgetSpent(lastSeenEntry, headSha);
|
|
56
|
+
}
|
|
51
57
|
function isRebaseBudgetExhausted(lastSeenEntry, maxCatchUpRebases) {
|
|
52
58
|
const catchUpRebases = lastSeenEntry.catch_up_rebases;
|
|
53
59
|
return maxCatchUpRebases !== null && typeof catchUpRebases === "number" && catchUpRebases >= maxCatchUpRebases;
|
|
@@ -58,7 +64,7 @@ function resolveArmFloorEvidence(lastSeenEntry, headSha, rebaseKey, maxCatchUpRe
|
|
|
58
64
|
const blockedCiDigest = lastSeenEntry.blocked?.fingerprint?.ci_digest;
|
|
59
65
|
return {
|
|
60
66
|
isRebaseKeyAttempted: rebaseKey !== "" && isRebaseKeyAttempted(lastSeenEntry, rebaseKey),
|
|
61
|
-
|
|
67
|
+
isCiHeadSettled: headSha !== "" && isCiHeadSettled(lastSeenEntry, headSha),
|
|
62
68
|
isRebaseBudgetExhausted: isRebaseBudgetExhausted(lastSeenEntry, maxCatchUpRebases),
|
|
63
69
|
blockedCiDigest: typeof blockedCiDigest === "string" && !LINE_BREAK.test(blockedCiDigest) ? blockedCiDigest : ""
|
|
64
70
|
};
|
|
@@ -104,7 +110,8 @@ function resolveWatchTickVerdict(lastSeenEntry, observation, preferences) {
|
|
|
104
110
|
if (isCiHeadBudgetLeft && !isCiHeadEscalated(lastSeenEntry, headSha)) return "dispatch-fix-ci" /* DispatchFixCi */;
|
|
105
111
|
}
|
|
106
112
|
if (isRebaseKeyEscalated(lastSeenEntry, rebaseKey)) return "block-conflict-escalated" /* BlockConflictEscalated */;
|
|
107
|
-
|
|
113
|
+
const isRedHeadOutOfAttempts = observation.ciBucket === "red" /* Red */ && isCiHeadBudgetSpent(lastSeenEntry, headSha);
|
|
114
|
+
if (isCiHeadEscalated(lastSeenEntry, headSha) || isRedHeadOutOfAttempts) return "block-ci-escalated" /* BlockCiEscalated */;
|
|
108
115
|
if ((lastSeenEntry.escalated_review_ids ?? []).length > 0) return "block-reviews-escalated" /* BlockReviewsEscalated */;
|
|
109
116
|
return "idle" /* Idle */;
|
|
110
117
|
}
|
|
@@ -141,7 +148,7 @@ function runPrWatchVerdictCommand(commandLine, lastSeenJson, resolvePreferences)
|
|
|
141
148
|
const evidence = resolveArmFloorEvidence(lastSeenEntry, headSha, rebaseKey, resolvePreferences().maxCatchUpRebases);
|
|
142
149
|
return {
|
|
143
150
|
stdout: `REBASE_ATTEMPTED=${evidence.isRebaseKeyAttempted ? 1 : 0}
|
|
144
|
-
|
|
151
|
+
CI_SETTLED=${evidence.isCiHeadSettled ? 1 : 0}
|
|
145
152
|
BLOCKED_CIDIGEST=${evidence.blockedCiDigest}
|
|
146
153
|
REBASE_BUDGET_EXHAUSTED=${evidence.isRebaseBudgetExhausted ? 1 : 0}
|
|
147
154
|
`,
|
|
@@ -173,14 +180,22 @@ function parseMaxCatchUpRebases(configuredValue) {
|
|
|
173
180
|
const spelled = typeof configuredValue === "number" ? String(configuredValue) : configuredValue;
|
|
174
181
|
return typeof spelled === "string" && POSITIVE_INTEGER.test(spelled) ? Number(spelled) : void 0;
|
|
175
182
|
}
|
|
183
|
+
var readUserPreferences = (homeDir) => asRecord(readJsonRecord(join(homeDir, ...USER_PREFERENCES_PATH_SEGMENTS)).preferences);
|
|
184
|
+
function readPreferenceLayers(homeDir, pluginRoot) {
|
|
185
|
+
return [readUserPreferences(homeDir), readJsonRecord(join(pluginRoot, ...PREFERENCE_DEFAULTS_PATH_SEGMENTS))];
|
|
186
|
+
}
|
|
187
|
+
function pickPreferenceValue(preferenceLayers, preferenceKey) {
|
|
188
|
+
const configuredValue = preferenceLayers.map((layer) => layer[preferenceKey]).find((layerValue) => layerValue != null);
|
|
189
|
+
return typeof configuredValue === "string" ? configuredValue : void 0;
|
|
190
|
+
}
|
|
176
191
|
function resolveWatchPreferences(homeDir = homedir(), pluginRoot = resolvePluginRoot()) {
|
|
177
|
-
const
|
|
178
|
-
const userPreferences =
|
|
179
|
-
const autoRebase =
|
|
192
|
+
const preferenceLayers = readPreferenceLayers(homeDir, pluginRoot);
|
|
193
|
+
const [userPreferences, shippedDefaults] = preferenceLayers;
|
|
194
|
+
const autoRebase = pickPreferenceValue(preferenceLayers, AUTO_REBASE_PREFERENCE_KEY);
|
|
180
195
|
const userCap = parseMaxCatchUpRebases(userPreferences[MAX_CATCH_UP_REBASES_PREFERENCE_KEY]);
|
|
181
196
|
const shippedCap = parseMaxCatchUpRebases(shippedDefaults[MAX_CATCH_UP_REBASES_PREFERENCE_KEY]);
|
|
182
197
|
return {
|
|
183
|
-
autoRebase
|
|
198
|
+
autoRebase,
|
|
184
199
|
maxCatchUpRebases: userCap !== void 0 ? userCap : shippedCap ?? null
|
|
185
200
|
};
|
|
186
201
|
}
|
|
@@ -7,6 +7,6 @@ Map a red CI check to the local command that reproduces and fixes it. Repo-agnos
|
|
|
7
7
|
| lint / format | Run the repo's lint `--fix` / formatter; restage. |
|
|
8
8
|
| typecheck | Run the typecheck script; read the errors; edit the types. |
|
|
9
9
|
| unit / test | Run the suite; read the failures; fix the code or the test. |
|
|
10
|
-
| `muggle-walkthrough` — the Muggle E2E walkthrough check | In scope. Its check-run summary names each failed or inconclusive case with the failing step and a link to the run; the walkthrough comment's hidden `muggle-e2e-result` record carries the same cases with their test case ids. Repair each, re-run the **whole** E2E suite, and settle the walkthrough comment with the new result — editing the comment re-runs the check. A summary saying the comment is still empty means no run was ever posted: run the suite and post it. |
|
|
10
|
+
| `muggle-walkthrough` — the Muggle E2E walkthrough check | In scope. Its check-run summary names each failed or inconclusive case with the failing step and a link to the run; the walkthrough comment's hidden `muggle-e2e-result` record carries the same cases with their test case ids. Repair each, re-run the **whole** E2E suite, and settle the walkthrough comment with the new result — editing the comment re-runs the check. A summary saying the comment is still empty means no run was ever posted: run the suite and post it, or — when the session holds no E2E plan or a `unit-only`/`skip` one — settle it with the verified skip code that holds. Deterministic: it reads the comment, so re-running it unchanged returns the same verdict. |
|
|
11
11
|
| non-deterministic — an LLM-judged eval gate (e.g. `skill-routing-eval`, `skill-gate-eval`, `agent-gate-eval`) or another check whose verdict can flip with nothing changed | Re-run its failed jobs once per head, without a push: `gh run rerun <run-id> --failed` (GitLab: retry the failed job). The re-run counts as a fix attempt. Escalate only if it fails again on the same head. How long the check takes is never a reason to skip the re-run. |
|
|
12
12
|
| out of scope — any other E2E-in-CI, build/deploy infra, or unknown | Do **not** attempt; record for escalation. |
|
|
@@ -57,7 +57,7 @@ You receive everything from `state.md` already — pre-flight resolved it:
|
|
|
57
57
|
|
|
58
58
|
Read `state.md`.
|
|
59
59
|
|
|
60
|
-
**No `## Pre-flight answers` block at all** → the session was seeded poll-only (e.g. by auto-track, [`../muggle-pr-followup/auto-track.md`](../muggle-pr-followup/auto-track.md)). Treat `Validation` as `skip`: emit a `SKIPPED` report citing `
|
|
60
|
+
**No `## Pre-flight answers` block at all** → the session was seeded poll-only (e.g. by auto-track, [`../muggle-pr-followup/auto-track.md`](../muggle-pr-followup/auto-track.md)). Treat `Validation` as `skip`: emit a `SKIPPED` report citing the code that holds — `NO_WEB_SURFACE` when the repo serves nothing to drive, `NOT_APPLICABLE` when no browser run can reach the change — and exit cleanly. Never `NO_PR`: this cycle is on a PR, so the guardrail refutes it and the walkthrough stays unsettled. When a browser run *could* reach the change, no code holds: report `SKIPPED` without declaring one and leave the walkthrough to the owner's E2E decision. The watcher owns no E2E context by design; "no context" is a clean skip, not a failure.
|
|
61
61
|
|
|
62
62
|
Otherwise the block was seeded by pre-flight or bootstrap per [`../_shared/resolve-e2e-validation-context.md`](../_shared/resolve-e2e-validation-context.md) — read it the same way regardless of seeder. The persisted `Validation` field (`local-e2e`, `staging-replay`, `unit-only`, `skip`) picks execution vs early-exit below. In a forward run, [`autoE2ETest`](../muggle-preferences/preference-gates/autoE2ETest.md) `ask` was resolved by pre-flight Q13; in a watcher cycle there is no per-tick pre-flight, so `Validation` **is** the standing decision — don't re-resolve `ask`.
|
|
63
63
|
|
|
@@ -31,6 +31,8 @@ Check out the PR branch in the session's working tree (per `state.md`). Capture
|
|
|
31
31
|
|
|
32
32
|
Then increment `last_seen.ci_fix_attempts[red_sha]` — before any fix, re-run or escalation, as a whole-file rewrite (Read → change field → Write) per [`../_shared/session-state-writes.md`](../_shared/session-state-writes.md), never the Edit tool. Every dispatched cycle is an attempt, including one that only re-runs a check or escalates without pushing. That count is the only record the watcher and its turn-end gate accept that this red head was acted on; an escalation written without it is ignored and fix-ci is dispatched again ([`../muggle-pr-followup/state-schemas.md`](../muggle-pr-followup/state-schemas.md#last_seenjson)).
|
|
33
33
|
|
|
34
|
+
The increment opens a cycle this turn carries through Step 5 or Step 6 — it never stands in for one. The single exception is a red `muggle-walkthrough` whose E2E run for **this** PR is already queued or running (its `state.md` shows `E2E dispatch: queued` or `running`): that run is the cycle, and its report settles the comment. Writing the count for a PR with no such run, or because another PR's run is in flight, clears the turn-end gate while the PR stays red; the watch never asks again until the head moves, and the PR can merge red.
|
|
35
|
+
|
|
34
36
|
### Step 2 — Map each failing check to a local command
|
|
35
37
|
|
|
36
38
|
Per [`../_shared/ci-check-to-command.md`](../_shared/ci-check-to-command.md). Fix the in-scope checks in the working tree; record out-of-scope checks for escalation (Step 6). A red `muggle-walkthrough` check takes Step 2.5; a red non-deterministic check takes Step 2.6.
|
|
@@ -45,7 +47,13 @@ How long a check takes to run — a 45-minute eval included — is never a reaso
|
|
|
45
47
|
4. **Order the settle after the push.** If Step 2 changed code, run Steps 3–4 below first, so the new head carries the fix. Then settle the walkthrough comment with the re-run's report via [`../muggle-pr-visual-walkthrough/SKILL.md`](../muggle-pr-visual-walkthrough/SKILL.md) Mode A; editing the comment re-runs the check against the head. With no code change (only retries and regenerations) there is nothing to push — the comment edit alone re-runs the check.
|
|
46
48
|
5. **Wait for the fresh verdict** before Step 5's respawn: poll the check run until one completes later than the `completed_at` recorded in item 1, for up to 5 minutes. A watcher respawned sooner reads the stale red verdict and spends a fix attempt on a run already repaired. On timeout, respawn anyway — the per-SHA budget still bounds it.
|
|
47
49
|
|
|
48
|
-
An empty-comment failure (no run ever posted) skips items 1–2
|
|
50
|
+
An empty-comment failure (no run ever posted) skips items 1–2. Pick the branch from `state.md`:
|
|
51
|
+
|
|
52
|
+
- **`local-e2e` or `staging-replay`** → run the suite and settle the comment with its report.
|
|
53
|
+
- **`unit-only`, `skip`, or no `## Pre-flight answers` block at all** (a poll-only session — auto-track, or bootstrap on a PR it found no testable surface for) → settle it with the skip code that holds per [`e2e-acceptance.md`](e2e-acceptance.md): `NO_WEB_SURFACE` when the repo serves nothing to drive, `NO_APP_LOGIC_CHANGE` (detail opening with the PR's merge-base commit) when every path the PR changed is docs, tests, lint or CI config, else `NOT_APPLICABLE` with the reason no browser run can reach the change. Run `echo "MUGGLE_E2E_SKIP: <CODE>: <detail>"` from the PR's working tree. The write runs detached, so confirm it landed: run the foreground command the walkthrough gate's block names until it prints `{"settled":true}`, never a hand-written PATCH. Never `NO_PR`: the guardrail refutes it whenever the session handled a PR.
|
|
54
|
+
- **A poll-only PR whose change a browser run *can* reach** → no code holds, so do not cite one. Escalate per Step 6, naming the missing E2E decision; the owner chooses run, project or waive.
|
|
55
|
+
|
|
56
|
+
`muggle-walkthrough` is never a Step 2.6 check: it reads the comment, so re-running it without editing the comment returns the same verdict.
|
|
49
57
|
|
|
50
58
|
### Step 2.6 — Red non-deterministic check: re-run once per head
|
|
51
59
|
|