@mediagato/modelreins-channel 4.6.1 → 4.9.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/modelreins-channel.mjs +46 -0
- package/package.json +2 -5
package/modelreins-channel.mjs
CHANGED
|
@@ -256,6 +256,14 @@ async function api(method, path, body = null) {
|
|
|
256
256
|
* report the correct status in heartbeats.
|
|
257
257
|
*/
|
|
258
258
|
let currentJobId = null;
|
|
259
|
+
// Wall-clock timestamp of the last claim. The stuck-job watchdog uses this
|
|
260
|
+
// to detect Claude crashing mid-stream or silently dropping the channel
|
|
261
|
+
// notification (modelreins_complete never gets called). Without this, a
|
|
262
|
+
// missed completion leaves currentJobId set forever and pollJobs early-
|
|
263
|
+
// returns on every tick — the channel goes dead until restart. Reset to 0
|
|
264
|
+
// alongside every currentJobId = null.
|
|
265
|
+
let _jobClaimedAt = 0;
|
|
266
|
+
const STUCK_JOB_MS = 30 * 60 * 1000; // 30 min — generous Claude completion window
|
|
259
267
|
|
|
260
268
|
// ── MCP Server ────────────────────────────────────────────────────────────────
|
|
261
269
|
//
|
|
@@ -484,6 +492,7 @@ mcp.setRequestHandler(CallToolRequestSchema, async (req) => {
|
|
|
484
492
|
|
|
485
493
|
// Clear current job slot so the poll loop can accept the next job.
|
|
486
494
|
currentJobId = null;
|
|
495
|
+
_jobClaimedAt = 0;
|
|
487
496
|
|
|
488
497
|
return {
|
|
489
498
|
content: [{
|
|
@@ -1048,11 +1057,19 @@ async function pollJobs() {
|
|
|
1048
1057
|
const job = jobs[0];
|
|
1049
1058
|
currentJobId = job.id;
|
|
1050
1059
|
claimedJobId = job.id;
|
|
1060
|
+
_jobClaimedAt = Date.now();
|
|
1051
1061
|
|
|
1052
1062
|
// Claim the job immediately so no other worker picks it up. If the
|
|
1053
1063
|
// claim fails, roll back so a future poll can retry — without this,
|
|
1054
1064
|
// a transient 5xx leaves currentJobId stuck and the channel deadlocks.
|
|
1065
|
+
// 409 is the new atomic-claim conflict — another worker won this job
|
|
1066
|
+
// first (same WORKER_NAME on a parallel process). Same treatment as
|
|
1067
|
+
// any other claim failure: roll back local state, let the next poll
|
|
1068
|
+
// try fresh.
|
|
1055
1069
|
const claim = await api('PUT', `/jobs/${job.id}`, { status: 'running' });
|
|
1070
|
+
if (claim.status === 409) {
|
|
1071
|
+
throw new Error(`claim conflict: job ${job.id} already claimed by another worker`);
|
|
1072
|
+
}
|
|
1056
1073
|
if (claim.status < 200 || claim.status >= 300) {
|
|
1057
1074
|
throw new Error(`claim PUT returned ${claim.status}: ${JSON.stringify(claim.data).slice(0, 120)}`);
|
|
1058
1075
|
}
|
|
@@ -1080,6 +1097,7 @@ async function pollJobs() {
|
|
|
1080
1097
|
// Roll back the optimistic local claim so the next poll can retry.
|
|
1081
1098
|
if (claimedJobId !== null && currentJobId === claimedJobId) {
|
|
1082
1099
|
currentJobId = null;
|
|
1100
|
+
_jobClaimedAt = 0;
|
|
1083
1101
|
// Best-effort: return the job to pending on the server too.
|
|
1084
1102
|
// If this also fails, the server-side stale-running cleanup will
|
|
1085
1103
|
// eventually reclaim it; we don't want to retry forever here.
|
|
@@ -1091,9 +1109,37 @@ async function pollJobs() {
|
|
|
1091
1109
|
}
|
|
1092
1110
|
}
|
|
1093
1111
|
|
|
1112
|
+
// Stuck-job watchdog: if currentJobId stays set for STUCK_JOB_MS without
|
|
1113
|
+
// modelreins_complete being called, clear it so the channel can accept
|
|
1114
|
+
// new work. Defends against Claude crashing mid-stream or silently
|
|
1115
|
+
// dropping the channel notification. Reports the stranded job as failed
|
|
1116
|
+
// best-effort so the dashboard doesn't show a phantom "running" forever.
|
|
1117
|
+
// See: 2026-04-26 ultrareview finding "MCP channel session permanently
|
|
1118
|
+
// stuck if Claude never calls modelreins_complete" (preserved in
|
|
1119
|
+
// modelreins#161 audit).
|
|
1120
|
+
async function stuckJobWatchdog() {
|
|
1121
|
+
if (!currentJobId) return;
|
|
1122
|
+
if (Date.now() - _jobClaimedAt < STUCK_JOB_MS) return;
|
|
1123
|
+
const staleJobId = currentJobId;
|
|
1124
|
+
console.error(`[channel] watchdog: job ${staleJobId} stale >${STUCK_JOB_MS/60000}min — clearing`);
|
|
1125
|
+
currentJobId = null;
|
|
1126
|
+
_jobClaimedAt = 0;
|
|
1127
|
+
try {
|
|
1128
|
+
await api('PUT', `/jobs/${staleJobId}`, {
|
|
1129
|
+
status: 'failed',
|
|
1130
|
+
exit_code: 1,
|
|
1131
|
+
error: `channel watchdog: modelreins_complete never called within ${STUCK_JOB_MS/60000}min`,
|
|
1132
|
+
error_type: 'channel_stuck',
|
|
1133
|
+
});
|
|
1134
|
+
} catch (e) {
|
|
1135
|
+
console.error(`[channel] watchdog: failed to report stale job ${staleJobId}: ${e?.message || e}`);
|
|
1136
|
+
}
|
|
1137
|
+
}
|
|
1138
|
+
|
|
1094
1139
|
// Kick off the background loops.
|
|
1095
1140
|
setInterval(heartbeat, 30_000);
|
|
1096
1141
|
setInterval(pollJobs, POLL_MS);
|
|
1142
|
+
setInterval(stuckJobWatchdog, 60_000);
|
|
1097
1143
|
|
|
1098
1144
|
// Initial heartbeat fires immediately so the worker shows as online right away.
|
|
1099
1145
|
heartbeat();
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@mediagato/modelreins-channel",
|
|
3
|
-
"version": "4.
|
|
3
|
+
"version": "4.9.0",
|
|
4
4
|
"description": "ModelReins MCP Channel — turn any Claude Code session into a ModelReins worker",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "modelreins-channel.mjs",
|
|
@@ -18,8 +18,5 @@
|
|
|
18
18
|
"ai-orchestration"
|
|
19
19
|
],
|
|
20
20
|
"license": "BSL-1.1",
|
|
21
|
-
"
|
|
22
|
-
"type": "git",
|
|
23
|
-
"url": "https://github.com/mediagato/modelreins"
|
|
24
|
-
}
|
|
21
|
+
"homepage": "https://modelreins.com"
|
|
25
22
|
}
|