@muggleai/works 5.14.0-staging.97 → 5.14.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/config/runtime-targets.json +3 -0
- package/dist/{chunk-7WL6LFIU.js → chunk-2YXBHSCP.js} +24 -11
- package/dist/cli.js +1 -1
- package/dist/index.js +1 -1
- package/dist/plugin/.claude-plugin/plugin.json +1 -1
- package/dist/plugin/.cursor-plugin/plugin.json +1 -1
- package/dist/plugin/scripts/pr-watch-guards.sh +30 -0
- package/dist/plugin/skills/muggle-pr-followup/arm-watcher.md +5 -1
- package/dist/release-manifest.json +4 -4
- package/package.json +2 -2
- package/plugin/.claude-plugin/plugin.json +1 -1
- package/plugin/.cursor-plugin/plugin.json +1 -1
- package/plugin/scripts/pr-watch-guards.sh +30 -0
- package/plugin/skills/muggle-pr-followup/arm-watcher.md +5 -1
|
@@ -10,6 +10,7 @@
|
|
|
10
10
|
"targets": {
|
|
11
11
|
"production": {
|
|
12
12
|
"promptServiceBaseUrl": "https://promptservice.muggle-ai.com",
|
|
13
|
+
"uiBaseUrl": "https://www.muggle-ai.com/muggleTestV0",
|
|
13
14
|
"auth0Domain": "login.muggle-ai.com",
|
|
14
15
|
"auth0ClientId": "UgG5UjoyLksxMciWWKqVpwfWrJ4rFvtT",
|
|
15
16
|
"auth0Audience": "https://muggleai.us.auth0.com/api/v2/",
|
|
@@ -17,6 +18,7 @@
|
|
|
17
18
|
},
|
|
18
19
|
"staging": {
|
|
19
20
|
"promptServiceBaseUrl": "https://staging.promptservice.muggle-ai.com",
|
|
21
|
+
"uiBaseUrl": "https://staging.muggle-ai.com/muggleTestV0",
|
|
20
22
|
"auth0Domain": "login.staging.muggle-ai.com",
|
|
21
23
|
"auth0ClientId": "C6rmJN3FeX3EuGZdY8qbHpbJVRadxsly",
|
|
22
24
|
"auth0Audience": "https://staging-muggleai.us.auth0.com/api/v2/",
|
|
@@ -24,6 +26,7 @@
|
|
|
24
26
|
},
|
|
25
27
|
"dev": {
|
|
26
28
|
"promptServiceBaseUrl": "http://localhost:5050",
|
|
29
|
+
"uiBaseUrl": "http://localhost:3999/muggleTestV0",
|
|
27
30
|
"auth0Domain": "dev-po4mxmz0rd8a0w8w.us.auth0.com",
|
|
28
31
|
"auth0ClientId": "GBvkMdTbCI80XJXnJ90MmbEvXwcWGUtw",
|
|
29
32
|
"auth0Audience": "https://dev-po4mxmz0rd8a0w8w.us.auth0.com/api/v2/",
|
|
@@ -490,10 +490,10 @@ function renderOverview(report) {
|
|
|
490
490
|
}
|
|
491
491
|
return lines.join("\n");
|
|
492
492
|
}
|
|
493
|
-
function renderTestDetails(test, projectId, testNumber) {
|
|
493
|
+
function renderTestDetails(test, projectId, testNumber, dashboardBaseUrl = DASHBOARD_URL_BASE) {
|
|
494
494
|
const summary = renderSummaryLine(test, testNumber);
|
|
495
495
|
const frameBlock = renderEndingFrame(test);
|
|
496
|
-
const resultLines = renderResultSummary(test, projectId);
|
|
496
|
+
const resultLines = renderResultSummary(test, projectId, dashboardBaseUrl);
|
|
497
497
|
const body = ["", "<br>", ""];
|
|
498
498
|
if (frameBlock) {
|
|
499
499
|
body.push(...frameBlock, "");
|
|
@@ -524,8 +524,8 @@ function renderEndingFrame(test) {
|
|
|
524
524
|
fullSizeImage(frame.url, test.name)
|
|
525
525
|
];
|
|
526
526
|
}
|
|
527
|
-
function renderResultSummary(test, projectId) {
|
|
528
|
-
const dashboardUrl = `${
|
|
527
|
+
function renderResultSummary(test, projectId, dashboardBaseUrl) {
|
|
528
|
+
const dashboardUrl = `${dashboardBaseUrl}/${projectId}/scripts?modal=script-details&testCaseId=${encodeURIComponent(test.testCaseId)}`;
|
|
529
529
|
const lines = [];
|
|
530
530
|
if (test.status === "passed") {
|
|
531
531
|
lines.push(`**Result:** \u2705 PASSED`);
|
|
@@ -554,7 +554,7 @@ function renderBody(report, opts) {
|
|
|
554
554
|
"_Full per-test details in the comment below \u2014 the PR description was too large to inline them._"
|
|
555
555
|
].join("\n");
|
|
556
556
|
}
|
|
557
|
-
const detailBlocks = report.tests.map((t, i) => renderTestDetails(t, report.projectId, i + 1));
|
|
557
|
+
const detailBlocks = report.tests.map((t, i) => renderTestDetails(t, report.projectId, i + 1, opts.dashboardBaseUrl ?? DASHBOARD_URL_BASE));
|
|
558
558
|
return [
|
|
559
559
|
overview,
|
|
560
560
|
"",
|
|
@@ -563,11 +563,11 @@ function renderBody(report, opts) {
|
|
|
563
563
|
detailBlocks.join("\n\n")
|
|
564
564
|
].join("\n");
|
|
565
565
|
}
|
|
566
|
-
function renderComment(report) {
|
|
566
|
+
function renderComment(report, opts = {}) {
|
|
567
567
|
if (report.tests.length === 0) {
|
|
568
568
|
return "";
|
|
569
569
|
}
|
|
570
|
-
const detailBlocks = report.tests.map((t, i) => renderTestDetails(t, report.projectId, i + 1));
|
|
570
|
+
const detailBlocks = report.tests.map((t, i) => renderTestDetails(t, report.projectId, i + 1, opts.dashboardBaseUrl ?? DASHBOARD_URL_BASE));
|
|
571
571
|
return [
|
|
572
572
|
"## E2E acceptance evidence (overflow)",
|
|
573
573
|
"",
|
|
@@ -579,7 +579,7 @@ function renderComment(report) {
|
|
|
579
579
|
|
|
580
580
|
// src/cli/pr-section/overflow.ts
|
|
581
581
|
function splitWithOverflow(report, opts) {
|
|
582
|
-
const inlineBody = renderBody(report, { inlineDetails: true });
|
|
582
|
+
const inlineBody = renderBody(report, { inlineDetails: true, dashboardBaseUrl: opts.dashboardBaseUrl });
|
|
583
583
|
const inlineBytes = Buffer.byteLength(inlineBody, "utf-8");
|
|
584
584
|
if (inlineBytes <= opts.maxBodyBytes) {
|
|
585
585
|
return { body: inlineBody, comment: null };
|
|
@@ -587,8 +587,8 @@ function splitWithOverflow(report, opts) {
|
|
|
587
587
|
if (report.tests.length === 0) {
|
|
588
588
|
return { body: inlineBody, comment: null };
|
|
589
589
|
}
|
|
590
|
-
const spilledBody = renderBody(report, { inlineDetails: false });
|
|
591
|
-
const comment = renderComment(report);
|
|
590
|
+
const spilledBody = renderBody(report, { inlineDetails: false, dashboardBaseUrl: opts.dashboardBaseUrl });
|
|
591
|
+
const comment = renderComment(report, { dashboardBaseUrl: opts.dashboardBaseUrl });
|
|
592
592
|
return {
|
|
593
593
|
body: spilledBody,
|
|
594
594
|
comment: comment.length > 0 ? comment : null
|
|
@@ -804,6 +804,18 @@ var DEFAULT_MAX_BODY_BYTES = 6e4;
|
|
|
804
804
|
var REPORT_SECTION_SENTINEL = "<!-- muggle-pr-section:v1 -->";
|
|
805
805
|
var withSentinel = (s) => s ? `${REPORT_SECTION_SENTINEL}
|
|
806
806
|
${s}` : s;
|
|
807
|
+
async function resolveDashboardBaseUrl(stderrWrite) {
|
|
808
|
+
try {
|
|
809
|
+
const mcps = await import('./src-KNYT7EHP.js');
|
|
810
|
+
return `${mcps.resolveActiveProfile().uiBaseUrl}/dashboard/projects`;
|
|
811
|
+
} catch (err) {
|
|
812
|
+
stderrWrite(
|
|
813
|
+
`build-pr-section: could not resolve the runtime target, linking to production: ${errMsg2(err)}
|
|
814
|
+
`
|
|
815
|
+
);
|
|
816
|
+
return DASHBOARD_URL_BASE;
|
|
817
|
+
}
|
|
818
|
+
}
|
|
807
819
|
async function readAll(stream) {
|
|
808
820
|
const chunks = [];
|
|
809
821
|
for await (const chunk of stream) {
|
|
@@ -851,7 +863,8 @@ ${err.issues.map((i) => ` - ${i.path.join(".")}: ${i.message}`).join("\n")}
|
|
|
851
863
|
const sentinelCost = Buffer.byteLength(`${REPORT_SECTION_SENTINEL}
|
|
852
864
|
`, "utf-8");
|
|
853
865
|
const renderedSection = buildPrSection(resolvedReport, {
|
|
854
|
-
maxBodyBytes: opts.maxBodyBytes - sentinelCost
|
|
866
|
+
maxBodyBytes: opts.maxBodyBytes - sentinelCost,
|
|
867
|
+
dashboardBaseUrl: await resolveDashboardBaseUrl(opts.stderrWrite)
|
|
855
868
|
});
|
|
856
869
|
opts.stdoutWrite(
|
|
857
870
|
JSON.stringify({
|
package/dist/cli.js
CHANGED
package/dist/index.js
CHANGED
|
@@ -1,2 +1,2 @@
|
|
|
1
|
-
export { src_exports as commands, createUnifiedMcpServer, server_exports as server } from './chunk-
|
|
1
|
+
export { src_exports as commands, createUnifiedMcpServer, server_exports as server } from './chunk-2YXBHSCP.js';
|
|
2
2
|
export { createChildLogger, e2e_exports as e2e, getConfig, getLocalQaTools, getLogger, getQaTools, local_exports as localQa, mcp_exports as mcp, e2e_exports as qa, src_exports as shared } from './chunk-H5UDFKG3.js';
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "muggle",
|
|
3
3
|
"description": "Run real-browser end-to-end (E2E) acceptance tests on your web app from any AI coding agent. Generate test scripts from plain English, replay them on localhost, capture screenshots, and validate user flows like signup, checkout, and dashboards. Works across Claude Code, Cursor, Codex, and Windsurf.",
|
|
4
|
-
"version": "5.13.
|
|
4
|
+
"version": "5.13.1",
|
|
5
5
|
"author": {
|
|
6
6
|
"name": "Muggle AI",
|
|
7
7
|
"email": "support@muggle-ai.com"
|
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
"name": "muggle",
|
|
3
3
|
"displayName": "Muggle AI",
|
|
4
4
|
"description": "Ship quality products with AI-powered end-to-end (E2E) acceptance testing that validates your web app like a real user — from Claude Code and Cursor to PR.",
|
|
5
|
-
"version": "5.13.
|
|
5
|
+
"version": "5.13.1",
|
|
6
6
|
"author": {
|
|
7
7
|
"name": "Muggle AI",
|
|
8
8
|
"email": "support@muggle-ai.com"
|
|
@@ -85,3 +85,33 @@ watcher_pid_alive() {
|
|
|
85
85
|
[ -n "$pid" ] || return 1
|
|
86
86
|
kill -0 "$pid" 2>/dev/null
|
|
87
87
|
}
|
|
88
|
+
|
|
89
|
+
# True when the slot's lease is held by a live loop that no longer belongs to the
|
|
90
|
+
# session arming now — the deaf-orphan case.
|
|
91
|
+
#
|
|
92
|
+
# A live PID proves the loop is running; it never proves anything is listening.
|
|
93
|
+
# On Windows a detached loop outlives the session that launched it, keeps
|
|
94
|
+
# polling, and keeps touching its heartbeat, while its stdout is the dead
|
|
95
|
+
# session's monitor pipe. Every liveness signal reads healthy and the events
|
|
96
|
+
# reach nobody. Because arming skipped on a live PID alone, that corpse blocked
|
|
97
|
+
# a live session from arming the PR for the whole lifetime cap — seven days
|
|
98
|
+
# since the default moved off six hours.
|
|
99
|
+
#
|
|
100
|
+
# Deliberately narrow. It answers only "is this lease held on behalf of some
|
|
101
|
+
# other session", which is decidable from state already on disk. It says nothing
|
|
102
|
+
# about a loop orphaned by its own session's monitor dying, where the owner still
|
|
103
|
+
# matches; that one needs a signal from the reader, which no file here carries.
|
|
104
|
+
#
|
|
105
|
+
# Conservative on every unknown — no watch.pid, a dead PID, no owner.json, or no
|
|
106
|
+
# current session id all answer false, so a slot is never reclaimed on a guess.
|
|
107
|
+
watcher_lease_is_foreign() {
|
|
108
|
+
local slot="$1" current_session="$2" owner_pid owner_session
|
|
109
|
+
[ -n "$current_session" ] || return 1
|
|
110
|
+
[ -f "${slot}/watch.pid" ] || return 1
|
|
111
|
+
owner_pid=$(tr -d ' \r\n' < "${slot}/watch.pid" 2>/dev/null)
|
|
112
|
+
watcher_pid_alive "$owner_pid" || return 1
|
|
113
|
+
[ -f "${slot}/owner.json" ] || return 1
|
|
114
|
+
owner_session=$(sed -n 's/.*"session_id"[[:space:]]*:[[:space:]]*"\([^"]*\)".*/\1/p' "${slot}/owner.json" | head -1)
|
|
115
|
+
[ -n "$owner_session" ] || return 1
|
|
116
|
+
[ "$owner_session" != "$current_session" ]
|
|
117
|
+
}
|
|
@@ -14,7 +14,11 @@ Read the `DRAIN` lines it prints: they are the outstanding work the monitor will
|
|
|
14
14
|
|
|
15
15
|
1. **Drain.** Run one tick per [`contract.md`](contract.md). It acts on everything already outstanding — actionable threads (`gitlab`: discussions), body-only reviews past the watermark (GitHub-only — GitLab has no review envelope), a stale branch, red CI — and finalizes a terminal PR. If the tick dispatched a cycle, stop here: the cycle's exit path settles the watch when it finishes.
|
|
16
16
|
2. **Seed the watermark.** *(`pr-watch-arm.sh` does this; the reasoning is kept because the floors are subtle and wrong ones are quiet.)* Resolve the provider once per [`../_shared/vcs/detect-vcs.md`](../_shared/vcs/detect-vcs.md) — every fetch in this sequence uses that provider's recipes. Write the slot's watch watermark ([`state-schemas.md`](state-schemas.md#watch-watermarkenv)) to the ids the **drain itself read** — the max review-id and comment-id observed at the drain's own fetch (Step 1), snapshotted at that read. Never let the loop capture its own baseline — the arming session writes it; and **never** from a fresh fetch taken after the drain, which would include a comment that arrived after the drain read the wave and mark it seen unread. Seeded to the drain's floor, anything landing after that read stays above the watermark and the monitor's first iteration surfaces it. Seed the CI floor (`CIRED`) from the same drain read: set it to the head SHA when the checks have **already settled red** at that read (no check pending, one or more in the `fail` bucket per [`../_shared/vcs/common/ci-rollup.md`](../_shared/vcs/common/ci-rollup.md)) — that red is what the drain just handled — and empty otherwise, so an escalated red head the drain already saw does not re-fire on the loop's first iteration. Seed the rebase floor (`REBASED`) the same way, from the drain's branch-standing read per [`../_shared/vcs/common/branch-standing.md`](../_shared/vcs/common/branch-standing.md): set it to the current `rebase_key` (`<head_sha>..<base_tip_sha>`) when the drain found the branch already behind or conflicting — that staleness is what the drain just handled — and empty otherwise, so a branch the drain already rebased or escalated does not re-fire on the loop's first iteration. Seed the blocked-CI floor (`BLOCKED_CIDIGEST`) to the blocked fingerprint's `ci_digest` when arming while `last_seen.blocked` is already set, and empty otherwise — empty is the not-blocked state, in which the loop's blocked-resume probe stays dormant.
|
|
17
|
-
3. **Dedup, then watch.** First read `<slot>/watch.pid` ([`state-schemas.md`](state-schemas.md#watchpid)): if it names a live process (`kill -0 "$pid"` succeeds)
|
|
17
|
+
3. **Dedup, then watch.** First read `<slot>/watch.pid` ([`state-schemas.md`](state-schemas.md#watchpid)): if it names a live process (`kill -0 "$pid"` succeeds) **and `owner.json` records this session**, a watcher already owns this slot — **skip arming, do not start a second**. This is what stops orphaned watchers from accumulating: the in-session monitor dying does not stop the OS loop it launched (on Windows a detached Git Bash loop keeps running and polling `gh` forever after the session ends), so checking a live task list is not enough — the PID lease is.
|
|
18
|
+
|
|
19
|
+
**A live lease owned by a different session does not count as owned** — check it with `watcher_lease_is_foreign` from [`../../scripts/pr-watch-guards.sh`](../../scripts/pr-watch-guards.sh). A live PID proves the loop is running, never that anything is listening: an orphan left by a dead session keeps polling and keeps touching its heartbeat while its stdout is that session's closed monitor pipe, so every liveness signal reads healthy and the events reach nobody. Skipping on the PID alone let such a corpse block this session from arming the PR for the whole lifetime cap — seven days, since the default moved off six hours. When the guard reports a foreign lease, kill that PID, delete `watch.pid`, and arm fresh; note it in the slot's `followup.log` so the reclaim is visible.
|
|
20
|
+
|
|
21
|
+
This is narrow on purpose. It reclaims only a lease held on behalf of *another* session, which is decidable from `owner.json` alone. A loop orphaned by its **own** session's monitor dying still reads as owned, and nothing on disk distinguishes it — that needs a signal from the reader, which no slot file carries. Treat an armed watcher as evidence a poller exists, never as proof a review will be seen.
|
|
18
22
|
|
|
19
23
|
Otherwise **claim the slot for this session** before starting anything: write `owner.json` ([`state-schemas.md`](state-schemas.md#ownerjson)) with `session_id` from `$CLAUDE_CODE_SESSION_ID` and `claimed_at` now. Arming is what establishes ownership, so every arming point records it here rather than each caller remembering to. If `$CLAUDE_CODE_SESSION_ID` is unset, write no `owner.json` — an unidentifiable owner is worse than none, since [`reconcile.md`](reconcile.md) would read a bogus id as some other session's claim and could never recover the slot.
|
|
20
24
|
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
|
-
"release": "5.13.
|
|
3
|
-
"buildId": "run-
|
|
4
|
-
"commitSha": "
|
|
5
|
-
"buildTime": "2026-09-
|
|
2
|
+
"release": "5.13.1",
|
|
3
|
+
"buildId": "run-103-1",
|
|
4
|
+
"commitSha": "0ca6cf7623407bc11836f39529640129081de7d0",
|
|
5
|
+
"buildTime": "2026-09-05T06:15:43Z",
|
|
6
6
|
"serviceName": "muggle-ai-works-mcp"
|
|
7
7
|
}
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@muggleai/works",
|
|
3
3
|
"mcpName": "io.github.multiplex-ai/muggle",
|
|
4
|
-
"version": "5.14.0
|
|
4
|
+
"version": "5.14.0",
|
|
5
5
|
"description": "Ship quality products with AI-powered E2E acceptance testing that validates your web app like a real user — from Claude Code and Cursor to PR.",
|
|
6
6
|
"type": "module",
|
|
7
7
|
"main": "dist/index.js",
|
|
@@ -53,7 +53,7 @@
|
|
|
53
53
|
"muggleConfig": {
|
|
54
54
|
"electronAppVersion": "1.10.3",
|
|
55
55
|
"downloadBaseUrl": "https://github.com/multiplex-ai/muggle-ai-works/releases/download",
|
|
56
|
-
"runtimeTargetDefault": "
|
|
56
|
+
"runtimeTargetDefault": "production",
|
|
57
57
|
"checksumsByStream": {
|
|
58
58
|
"production": {
|
|
59
59
|
"win32-x64": "58e2238b1609ceff65b836b4b724b1c4c341d737a15debaa8a71af21b70bc436",
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "muggle",
|
|
3
3
|
"description": "Run real-browser end-to-end (E2E) acceptance tests on your web app from any AI coding agent. Generate test scripts from plain English, replay them on localhost, capture screenshots, and validate user flows like signup, checkout, and dashboards. Works across Claude Code, Cursor, Codex, and Windsurf.",
|
|
4
|
-
"version": "5.13.
|
|
4
|
+
"version": "5.13.1",
|
|
5
5
|
"author": {
|
|
6
6
|
"name": "Muggle AI",
|
|
7
7
|
"email": "support@muggle-ai.com"
|
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
"name": "muggle",
|
|
3
3
|
"displayName": "Muggle AI",
|
|
4
4
|
"description": "Ship quality products with AI-powered end-to-end (E2E) acceptance testing that validates your web app like a real user — from Claude Code and Cursor to PR.",
|
|
5
|
-
"version": "5.13.
|
|
5
|
+
"version": "5.13.1",
|
|
6
6
|
"author": {
|
|
7
7
|
"name": "Muggle AI",
|
|
8
8
|
"email": "support@muggle-ai.com"
|
|
@@ -85,3 +85,33 @@ watcher_pid_alive() {
|
|
|
85
85
|
[ -n "$pid" ] || return 1
|
|
86
86
|
kill -0 "$pid" 2>/dev/null
|
|
87
87
|
}
|
|
88
|
+
|
|
89
|
+
# True when the slot's lease is held by a live loop that no longer belongs to the
|
|
90
|
+
# session arming now — the deaf-orphan case.
|
|
91
|
+
#
|
|
92
|
+
# A live PID proves the loop is running; it never proves anything is listening.
|
|
93
|
+
# On Windows a detached loop outlives the session that launched it, keeps
|
|
94
|
+
# polling, and keeps touching its heartbeat, while its stdout is the dead
|
|
95
|
+
# session's monitor pipe. Every liveness signal reads healthy and the events
|
|
96
|
+
# reach nobody. Because arming skipped on a live PID alone, that corpse blocked
|
|
97
|
+
# a live session from arming the PR for the whole lifetime cap — seven days
|
|
98
|
+
# since the default moved off six hours.
|
|
99
|
+
#
|
|
100
|
+
# Deliberately narrow. It answers only "is this lease held on behalf of some
|
|
101
|
+
# other session", which is decidable from state already on disk. It says nothing
|
|
102
|
+
# about a loop orphaned by its own session's monitor dying, where the owner still
|
|
103
|
+
# matches; that one needs a signal from the reader, which no file here carries.
|
|
104
|
+
#
|
|
105
|
+
# Conservative on every unknown — no watch.pid, a dead PID, no owner.json, or no
|
|
106
|
+
# current session id all answer false, so a slot is never reclaimed on a guess.
|
|
107
|
+
watcher_lease_is_foreign() {
|
|
108
|
+
local slot="$1" current_session="$2" owner_pid owner_session
|
|
109
|
+
[ -n "$current_session" ] || return 1
|
|
110
|
+
[ -f "${slot}/watch.pid" ] || return 1
|
|
111
|
+
owner_pid=$(tr -d ' \r\n' < "${slot}/watch.pid" 2>/dev/null)
|
|
112
|
+
watcher_pid_alive "$owner_pid" || return 1
|
|
113
|
+
[ -f "${slot}/owner.json" ] || return 1
|
|
114
|
+
owner_session=$(sed -n 's/.*"session_id"[[:space:]]*:[[:space:]]*"\([^"]*\)".*/\1/p' "${slot}/owner.json" | head -1)
|
|
115
|
+
[ -n "$owner_session" ] || return 1
|
|
116
|
+
[ "$owner_session" != "$current_session" ]
|
|
117
|
+
}
|
|
@@ -14,7 +14,11 @@ Read the `DRAIN` lines it prints: they are the outstanding work the monitor will
|
|
|
14
14
|
|
|
15
15
|
1. **Drain.** Run one tick per [`contract.md`](contract.md). It acts on everything already outstanding — actionable threads (`gitlab`: discussions), body-only reviews past the watermark (GitHub-only — GitLab has no review envelope), a stale branch, red CI — and finalizes a terminal PR. If the tick dispatched a cycle, stop here: the cycle's exit path settles the watch when it finishes.
|
|
16
16
|
2. **Seed the watermark.** *(`pr-watch-arm.sh` does this; the reasoning is kept because the floors are subtle and wrong ones are quiet.)* Resolve the provider once per [`../_shared/vcs/detect-vcs.md`](../_shared/vcs/detect-vcs.md) — every fetch in this sequence uses that provider's recipes. Write the slot's watch watermark ([`state-schemas.md`](state-schemas.md#watch-watermarkenv)) to the ids the **drain itself read** — the max review-id and comment-id observed at the drain's own fetch (Step 1), snapshotted at that read. Never let the loop capture its own baseline — the arming session writes it; and **never** from a fresh fetch taken after the drain, which would include a comment that arrived after the drain read the wave and mark it seen unread. Seeded to the drain's floor, anything landing after that read stays above the watermark and the monitor's first iteration surfaces it. Seed the CI floor (`CIRED`) from the same drain read: set it to the head SHA when the checks have **already settled red** at that read (no check pending, one or more in the `fail` bucket per [`../_shared/vcs/common/ci-rollup.md`](../_shared/vcs/common/ci-rollup.md)) — that red is what the drain just handled — and empty otherwise, so an escalated red head the drain already saw does not re-fire on the loop's first iteration. Seed the rebase floor (`REBASED`) the same way, from the drain's branch-standing read per [`../_shared/vcs/common/branch-standing.md`](../_shared/vcs/common/branch-standing.md): set it to the current `rebase_key` (`<head_sha>..<base_tip_sha>`) when the drain found the branch already behind or conflicting — that staleness is what the drain just handled — and empty otherwise, so a branch the drain already rebased or escalated does not re-fire on the loop's first iteration. Seed the blocked-CI floor (`BLOCKED_CIDIGEST`) to the blocked fingerprint's `ci_digest` when arming while `last_seen.blocked` is already set, and empty otherwise — empty is the not-blocked state, in which the loop's blocked-resume probe stays dormant.
|
|
17
|
-
3. **Dedup, then watch.** First read `<slot>/watch.pid` ([`state-schemas.md`](state-schemas.md#watchpid)): if it names a live process (`kill -0 "$pid"` succeeds)
|
|
17
|
+
3. **Dedup, then watch.** First read `<slot>/watch.pid` ([`state-schemas.md`](state-schemas.md#watchpid)): if it names a live process (`kill -0 "$pid"` succeeds) **and `owner.json` records this session**, a watcher already owns this slot — **skip arming, do not start a second**. This is what stops orphaned watchers from accumulating: the in-session monitor dying does not stop the OS loop it launched (on Windows a detached Git Bash loop keeps running and polling `gh` forever after the session ends), so checking a live task list is not enough — the PID lease is.
|
|
18
|
+
|
|
19
|
+
**A live lease owned by a different session does not count as owned** — check it with `watcher_lease_is_foreign` from [`../../scripts/pr-watch-guards.sh`](../../scripts/pr-watch-guards.sh). A live PID proves the loop is running, never that anything is listening: an orphan left by a dead session keeps polling and keeps touching its heartbeat while its stdout is that session's closed monitor pipe, so every liveness signal reads healthy and the events reach nobody. Skipping on the PID alone let such a corpse block this session from arming the PR for the whole lifetime cap — seven days, since the default moved off six hours. When the guard reports a foreign lease, kill that PID, delete `watch.pid`, and arm fresh; note it in the slot's `followup.log` so the reclaim is visible.
|
|
20
|
+
|
|
21
|
+
This is narrow on purpose. It reclaims only a lease held on behalf of *another* session, which is decidable from `owner.json` alone. A loop orphaned by its **own** session's monitor dying still reads as owned, and nothing on disk distinguishes it — that needs a signal from the reader, which no slot file carries. Treat an armed watcher as evidence a poller exists, never as proof a review will be seen.
|
|
18
22
|
|
|
19
23
|
Otherwise **claim the slot for this session** before starting anything: write `owner.json` ([`state-schemas.md`](state-schemas.md#ownerjson)) with `session_id` from `$CLAUDE_CODE_SESSION_ID` and `claimed_at` now. Arming is what establishes ownership, so every arming point records it here rather than each caller remembering to. If `$CLAUDE_CODE_SESSION_ID` is unset, write no `owner.json` — an unidentifiable owner is worse than none, since [`reconcile.md`](reconcile.md) would read a bogus id as some other session's claim and could never recover the slot.
|
|
20
24
|
|