@mediagato/modelreins-channel 4.5.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.
@@ -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,19 +1,22 @@
1
- {
2
- "name": "@mediagato/modelreins-channel",
3
- "version": "4.5.1",
4
- "description": "ModelReins MCP Channel — turn any Claude Code session into a ModelReins worker",
5
- "type": "module",
6
- "main": "modelreins-channel.mjs",
7
- "bin": {
8
- "modelreins-channel": "./modelreins-channel.mjs"
9
- },
10
- "dependencies": {
11
- "@modelcontextprotocol/sdk": "^1.12.0"
12
- },
13
- "keywords": ["modelreins", "mcp", "claude-code", "channel", "ai-orchestration"],
14
- "license": "BSL-1.1",
15
- "repository": {
16
- "type": "git",
17
- "url": "https://github.com/mediagato/modelreins"
18
- }
19
- }
1
+ {
2
+ "name": "@mediagato/modelreins-channel",
3
+ "version": "4.9.0",
4
+ "description": "ModelReins MCP Channel — turn any Claude Code session into a ModelReins worker",
5
+ "type": "module",
6
+ "main": "modelreins-channel.mjs",
7
+ "bin": {
8
+ "modelreins-channel": "./modelreins-channel.mjs"
9
+ },
10
+ "dependencies": {
11
+ "@modelcontextprotocol/sdk": "^1.12.0"
12
+ },
13
+ "keywords": [
14
+ "modelreins",
15
+ "mcp",
16
+ "claude-code",
17
+ "channel",
18
+ "ai-orchestration"
19
+ ],
20
+ "license": "BSL-1.1",
21
+ "homepage": "https://modelreins.com"
22
+ }