@mediagato/modelreins-channel 4.5.0 → 4.5.1

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.
@@ -1013,50 +1013,82 @@ async function heartbeat() {
1013
1013
  * 4. Claude reads it, executes the task, calls modelreins_complete when done
1014
1014
  */
1015
1015
  let _pollCount = 0;
1016
+ let _pollInProgress = false;
1016
1017
 
1017
1018
  async function pollJobs() {
1019
+ // setInterval does not await us, so a slow tick (e.g. a stalled API
1020
+ // call) can overlap with the next one. Guard against re-entry.
1021
+ if (_pollInProgress) return;
1018
1022
  // One job at a time — don't accept new work while something is running.
1019
1023
  if (currentJobId) return;
1024
+ _pollInProgress = true;
1020
1025
 
1021
- // Check killswitch every 12 polls (~60s at default POLL_MS).
1022
- _pollCount++;
1023
- if (_pollCount % 12 === 1) {
1024
- try {
1026
+ // Track the optimistic local claim so we can roll it back if the
1027
+ // claim PUT or the MCP notification fails before Claude sees the job.
1028
+ let claimedJobId = null;
1029
+
1030
+ try {
1031
+ // Check killswitch every 12 polls (~60s at default POLL_MS).
1032
+ _pollCount++;
1033
+ if (_pollCount % 12 === 1) {
1025
1034
  const ks = await api('GET', '/killswitch');
1026
- if (ks.data?.active) {
1035
+ if (ks.status >= 200 && ks.status < 300 && ks.data?.active) {
1027
1036
  const reason = ks.data.reason || 'killswitch active';
1028
1037
  await api('PUT', '/presence', { instance: WORKER_NAME, status: 'offline', details: `Killswitch: ${reason}` });
1029
1038
  process.exit(0);
1030
1039
  }
1031
- } catch (_) { /* network error — don't stop polling */ }
1032
- }
1040
+ }
1041
+
1042
+ const res = await api('GET', `/jobs?status=pending&assigned_to=${encodeURIComponent(WORKER_NAME)}&limit=1`);
1043
+ if (res.status < 200 || res.status >= 300) return;
1044
+ const jobs = res.data?.jobs || (Array.isArray(res.data) ? res.data : []);
1045
+
1046
+ if (jobs.length === 0) return;
1047
+
1048
+ const job = jobs[0];
1049
+ currentJobId = job.id;
1050
+ claimedJobId = job.id;
1051
+
1052
+ // Claim the job immediately so no other worker picks it up. If the
1053
+ // claim fails, roll back so a future poll can retry — without this,
1054
+ // a transient 5xx leaves currentJobId stuck and the channel deadlocks.
1055
+ const claim = await api('PUT', `/jobs/${job.id}`, { status: 'running' });
1056
+ if (claim.status < 200 || claim.status >= 300) {
1057
+ throw new Error(`claim PUT returned ${claim.status}: ${JSON.stringify(claim.data).slice(0, 120)}`);
1058
+ }
1033
1059
 
1034
- const res = await api('GET', `/jobs?status=pending&assigned_to=${encodeURIComponent(WORKER_NAME)}&limit=1`);
1035
- const jobs = res.data?.jobs || (Array.isArray(res.data) ? res.data : []);
1036
-
1037
- if (jobs.length === 0) return;
1038
-
1039
- const job = jobs[0];
1040
- currentJobId = job.id;
1041
-
1042
- // Claim the job immediately so no other worker picks it up.
1043
- await api('PUT', `/jobs/${job.id}`, { status: 'running' });
1044
-
1045
- // Inject the job prompt into Claude's context via the channel notification.
1046
- // Claude Code intercepts this and presents it as a <channel> tag in the session.
1047
- await mcp.notification({
1048
- method: 'notifications/claude/channel',
1049
- params: {
1050
- content: job.prompt,
1051
- meta: {
1052
- job_id: String(job.id),
1053
- priority: String(job.priority ?? 0),
1054
- model_preference: job.model_preference || '',
1055
- working_dir: job.working_dir || '',
1056
- source: 'modelreins-dashboard',
1060
+ // Inject the job prompt into Claude's context via the channel notification.
1061
+ // Claude Code intercepts this and presents it as a <channel> tag in the session.
1062
+ await mcp.notification({
1063
+ method: 'notifications/claude/channel',
1064
+ params: {
1065
+ content: job.prompt,
1066
+ meta: {
1067
+ job_id: String(job.id),
1068
+ priority: String(job.priority ?? 0),
1069
+ model_preference: job.model_preference || '',
1070
+ working_dir: job.working_dir || '',
1071
+ source: 'modelreins-dashboard',
1072
+ },
1057
1073
  },
1058
- },
1059
- });
1074
+ });
1075
+
1076
+ // Notification delivered — Claude now owns the job. modelreins_complete
1077
+ // will clear currentJobId when Claude reports back.
1078
+ claimedJobId = null;
1079
+ } catch (err) {
1080
+ // Roll back the optimistic local claim so the next poll can retry.
1081
+ if (claimedJobId !== null && currentJobId === claimedJobId) {
1082
+ currentJobId = null;
1083
+ // Best-effort: return the job to pending on the server too.
1084
+ // If this also fails, the server-side stale-running cleanup will
1085
+ // eventually reclaim it; we don't want to retry forever here.
1086
+ api('PUT', `/jobs/${claimedJobId}`, { status: 'pending' }).catch(() => {});
1087
+ }
1088
+ console.error(`[channel] pollJobs error: ${err?.message || err}`);
1089
+ } finally {
1090
+ _pollInProgress = false;
1091
+ }
1060
1092
  }
1061
1093
 
1062
1094
  // Kick off the background loops.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@mediagato/modelreins-channel",
3
- "version": "4.5.0",
3
+ "version": "4.5.1",
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",