@inetafrica/open-claudia 2.6.56 → 2.6.57

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/CHANGELOG.md CHANGED
@@ -1,5 +1,8 @@
1
1
  # Changelog
2
2
 
3
+ ## v2.6.57
4
+ - **Move the inactivity watchdog into the live runner.** Deep review found v2.6.56 put the watchdog in the legacy `bot-agent.js` monolith, but the LaunchAgent runs `bot.js`, which delegates heavy turns through `core/runner.js`. That meant the installed live path still only had the 6-hour hard timeout and could still wedge on a silent child. This release ports the watchdog to `core/runner.js`, updates activity on child stdout/stderr, terminates the process tree after `OC_TURN_IDLE_MS` of silence, suppresses the confusing partial/no-output final message, clears stuck busy state with a failsafe, and drains any queued follow-up messages instead of stranding them. Adds a static regression test that fails if the watchdog only exists in `bot-agent.js` again.
5
+
3
6
  ## v2.6.56
4
7
  - **Stop a single hung child from wedging the whole bot.** Agent mode is single-flight: a heavy turn spawns one child and sets a global `runningProcess` flag; while it's set, every new message is demoted to a lightweight side-chat instead of a full turn. That flag is *only* ever cleared by the child's `close`/`error` events — so a child that hangs (a wedged tool call that never returns) pins it forever. That's exactly what happened: one turn went silent, its child never exited, and from then on the bot stayed alive (health checks passed) but answered nothing of substance — every later message fell through to a side-chat that also couldn't make progress. The fix is an **inactivity watchdog** in `runClaude` (`bot-agent.js`): track `lastActivity`, bumped on every `stdout`/`stderr` chunk from the child, and inside the existing progress-update loop (which already ticks every 2–5s while a task runs) kill the child if it produces no output for longer than the idle limit — SIGTERM to the process group, SIGKILL 3s later, plus a 10s failsafe that force-clears the flag if `close` somehow never fires. The watchdog short-circuits the close handler so the user gets one clear "task stalled — stopped it, I'm responsive again, send /continue" message instead of a confusing "(no output)". Default idle limit is 10 minutes, tunable via `OC_TURN_IDLE_MS`. Deliberately scoped to the wedge only — the v2.6.55 polling fix is healthy and untouched.
5
8
 
package/core/runner.js CHANGED
@@ -1067,6 +1067,17 @@ async function runClaude(prompt, cwd, replyToMsgId, opts = {}) {
1067
1067
  const startTime = Date.now();
1068
1068
  let longRunningNotified = false;
1069
1069
 
1070
+ // Inactivity watchdog. The live bot runs through this modular runner
1071
+ // (`bot.js` → `core/runner.js`), so the guard must live here, not only in
1072
+ // the legacy monolith. `state.runningProcess` is single-flight and normally
1073
+ // clears only on child close/error; a silent wedged child can otherwise pin it
1074
+ // forever and leave every later message queued. Treat child stdout/stderr as
1075
+ // liveness and stop the process group if it goes quiet past the idle limit.
1076
+ const idleLimitMs = Math.max(60000, parseInt(config.OC_TURN_IDLE_MS || process.env.OC_TURN_IDLE_MS || "", 10) || 10 * 60 * 1000);
1077
+ let lastActivity = Date.now();
1078
+ let watchdogTripped = false;
1079
+ let watchdogNotice = null;
1080
+
1070
1081
  const processTimeout = setTimeout(() => {
1071
1082
  if (state.runningProcess === proc) {
1072
1083
  killProcessTree(proc.pid, "SIGTERM");
@@ -1081,8 +1092,60 @@ async function runClaude(prompt, cwd, replyToMsgId, opts = {}) {
1081
1092
  const interval = elapsed > 120 ? 5000 : 2000;
1082
1093
  state.streamInterval = setTimeout(() => chatContext.run(store, updateProgress), interval);
1083
1094
  };
1095
+
1096
+ const drainQueuedMessages = async () => {
1097
+ if (state.messageQueue.length > 0 && state.currentSession) {
1098
+ const drained = state.messageQueue.splice(0);
1099
+ if (drained.length === 1) {
1100
+ const only = drained[0];
1101
+ await runClaude(only.prompt, state.currentSession.dir, only.replyToMsgId, only.opts);
1102
+ } else {
1103
+ const fmtTime = (ts) => {
1104
+ const d = new Date(ts);
1105
+ const hh = String(d.getHours()).padStart(2, "0");
1106
+ const mm = String(d.getMinutes()).padStart(2, "0");
1107
+ const ss = String(d.getSeconds()).padStart(2, "0");
1108
+ return `${hh}:${mm}:${ss}`;
1109
+ };
1110
+ const numbered = drained.map((m, i) => `[${i + 1}] (${fmtTime(m.queuedAt || Date.now())}) ${m.prompt}`).join("\n\n");
1111
+ const batched =
1112
+ `While you were working the user sent these ${drained.length} follow-up messages (oldest first):\n\n` +
1113
+ `${numbered}\n\n` +
1114
+ `Treat each as a distinct follow-up request. Add them to your plan and handle them; ` +
1115
+ `if any contradicts an earlier one, prefer the newer one and call out the conflict.`;
1116
+ const last = drained[drained.length - 1];
1117
+ await runClaude(batched, state.currentSession.dir, last.replyToMsgId, last.opts);
1118
+ }
1119
+ }
1120
+ };
1121
+
1084
1122
  const updateProgress = async () => {
1085
1123
  const elapsed = Math.floor((Date.now() - startTime) / 1000);
1124
+ if (!watchdogTripped && state.runningProcess === proc && Date.now() - lastActivity > idleLimitMs) {
1125
+ watchdogTripped = true;
1126
+ const idleMin = Math.max(1, Math.floor((Date.now() - lastActivity) / 60000));
1127
+ watchdogNotice = `Task stalled — no activity for ${idleMin}min, so I stopped it and I'm responsive again. Send /continue to resume, or just re-ask.`;
1128
+ console.error(`[watchdog] child pid ${proc.pid} idle ${idleMin}min (limit ${Math.floor(idleLimitMs / 60000)}min) — killing`);
1129
+ try { killProcessTree(proc.pid, "SIGTERM"); }
1130
+ catch (e) { try { proc.kill("SIGTERM"); } catch (e2) {} }
1131
+ setTimeout(() => { try { killProcessTree(proc.pid, "SIGKILL"); } catch (e) {} }, 3000);
1132
+ setTimeout(() => chatContext.run(store, async () => {
1133
+ if (state.runningProcess === proc) {
1134
+ state.runningProcess = null;
1135
+ state.preparingRun = false;
1136
+ stopTyping();
1137
+ clearTimeout(state.streamInterval); state.streamInterval = null;
1138
+ clearTimeout(processTimeout);
1139
+ state.statusMessageId = null;
1140
+ if (settings.budget) settings.budget = null;
1141
+ saveState();
1142
+ await drainQueuedMessages();
1143
+ }
1144
+ }), 10000);
1145
+ try { await send(watchdogNotice, { replyTo: replyToMsgId }); } catch (e) {}
1146
+ state.statusMessageId = null;
1147
+ return;
1148
+ }
1086
1149
  try {
1087
1150
  if (adapter && channelId) adapter.typing(channelId).catch(() => {});
1088
1151
  const display = formatProgress(assistantText, toolUses, currentTool, elapsed, currentToolDetail);
@@ -1106,6 +1169,7 @@ async function runClaude(prompt, cwd, replyToMsgId, opts = {}) {
1106
1169
  scheduleUpdate();
1107
1170
 
1108
1171
  proc.stdout.on("data", (data) => {
1172
+ lastActivity = Date.now();
1109
1173
  state.streamBuffer += data.toString();
1110
1174
  const events = parseStreamEvents(state.streamBuffer);
1111
1175
  const lastNewline = state.streamBuffer.lastIndexOf("\n");
@@ -1257,6 +1321,7 @@ async function runClaude(prompt, cwd, replyToMsgId, opts = {}) {
1257
1321
 
1258
1322
  let stderrBuffer = "";
1259
1323
  proc.stderr.on("data", (d) => {
1324
+ lastActivity = Date.now();
1260
1325
  const chunk = d.toString();
1261
1326
  stderrBuffer += chunk;
1262
1327
  console.error("STDERR:", redactSensitive(chunk));
@@ -1269,6 +1334,15 @@ async function runClaude(prompt, cwd, replyToMsgId, opts = {}) {
1269
1334
  clearTimeout(state.streamInterval); state.streamInterval = null;
1270
1335
  clearTimeout(processTimeout);
1271
1336
 
1337
+ if (watchdogTripped) {
1338
+ const note = watchdogNotice || "Task stalled; watchdog stopped the child process.";
1339
+ appendProjectTranscript("system-note", note, { exitCode: code, kind: "watchdog" }, state, getActiveSessionId);
1340
+ if (settings.budget) settings.budget = null;
1341
+ state.statusMessageId = null;
1342
+ await drainQueuedMessages();
1343
+ return;
1344
+ }
1345
+
1272
1346
  if (settings.backend === "claude" && isClaudeAuthErrorText(stderrBuffer)) {
1273
1347
  const authText = claudeAuthRecoveryMessage(stderrBuffer.trim().slice(-800));
1274
1348
  appendProjectTranscript("system-note", authText, { kind: "auth-error" }, state, getActiveSessionId);
@@ -1465,29 +1539,7 @@ async function runClaude(prompt, cwd, replyToMsgId, opts = {}) {
1465
1539
  }
1466
1540
  }
1467
1541
 
1468
- if (state.messageQueue.length > 0 && state.currentSession) {
1469
- const drained = state.messageQueue.splice(0);
1470
- if (drained.length === 1) {
1471
- const only = drained[0];
1472
- await runClaude(only.prompt, state.currentSession.dir, only.replyToMsgId, only.opts);
1473
- } else {
1474
- const fmtTime = (ts) => {
1475
- const d = new Date(ts);
1476
- const hh = String(d.getHours()).padStart(2, "0");
1477
- const mm = String(d.getMinutes()).padStart(2, "0");
1478
- const ss = String(d.getSeconds()).padStart(2, "0");
1479
- return `${hh}:${mm}:${ss}`;
1480
- };
1481
- const numbered = drained.map((m, i) => `[${i + 1}] (${fmtTime(m.queuedAt || Date.now())}) ${m.prompt}`).join("\n\n");
1482
- const batched =
1483
- `While you were working the user sent these ${drained.length} follow-up messages (oldest first):\n\n` +
1484
- `${numbered}\n\n` +
1485
- `Treat each as a distinct follow-up request. Add them to your plan and handle them; ` +
1486
- `if any contradicts an earlier one, prefer the newer one and call out the conflict.`;
1487
- const last = drained[drained.length - 1];
1488
- await runClaude(batched, state.currentSession.dir, last.replyToMsgId, last.opts);
1489
- }
1490
- }
1542
+ await drainQueuedMessages();
1491
1543
  }));
1492
1544
 
1493
1545
  proc.on("error", (err) => chatContext.run(store, async () => {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@inetafrica/open-claudia",
3
- "version": "2.6.56",
3
+ "version": "2.6.57",
4
4
  "description": "Your always-on AI coding assistant — Claude Code, Cursor Agent, and OpenAI Codex via Telegram or Kazee Chat",
5
5
  "main": "bot.js",
6
6
  "bin": {
@@ -9,7 +9,7 @@
9
9
  "scripts": {
10
10
  "setup": "node setup.js",
11
11
  "start": "node bot.js",
12
- "test": "OPEN_CLAUDIA_TEST=1 WORKSPACE=/tmp/open-claudia-test CLAUDE_PATH=node node -e \"require('./vault'); console.log('OK')\" && OPEN_CLAUDIA_TEST=1 WORKSPACE=/tmp/open-claudia-test CLAUDE_PATH=node node test-usage-accounting.js && OPEN_CLAUDIA_TEST=1 WORKSPACE=/tmp/open-claudia-test CLAUDE_PATH=node node test-recall-engine.js && OPEN_CLAUDIA_TEST=1 WORKSPACE=/tmp/open-claudia-test CLAUDE_PATH=node node test-recall-graph.js && OPEN_CLAUDIA_TEST=1 WORKSPACE=/tmp/open-claudia-test CLAUDE_PATH=node node test-recall-discoverer.js && OPEN_CLAUDIA_TEST=1 WORKSPACE=/tmp/open-claudia-test CLAUDE_PATH=node node test-project-transcripts-smoke.js && OPEN_CLAUDIA_TEST=1 WORKSPACE=/tmp/open-claudia-test CLAUDE_PATH=node node test-abilities.js && OPEN_CLAUDIA_TEST=1 WORKSPACE=/tmp/open-claudia-test CLAUDE_PATH=node node test-ability-extraction.js && OPEN_CLAUDIA_TEST=1 WORKSPACE=/tmp/open-claudia-test CLAUDE_PATH=node node test-ability-couse.js && OPEN_CLAUDIA_TEST=1 WORKSPACE=/tmp/open-claudia-test CLAUDE_PATH=node node test-ability-transfer.js && OPEN_CLAUDIA_TEST=1 WORKSPACE=/tmp/open-claudia-test CLAUDE_PATH=node node test-ability-tiers.js && OPEN_CLAUDIA_TEST=1 WORKSPACE=/tmp/open-claudia-test CLAUDE_PATH=node node test-ability-merge-guard.js && OPEN_CLAUDIA_TEST=1 WORKSPACE=/tmp/open-claudia-test CLAUDE_PATH=node node test-learning-e2e.js && OPEN_CLAUDIA_TEST=1 WORKSPACE=/tmp/open-claudia-test CLAUDE_PATH=node node test-pack-nesting.js && OPEN_CLAUDIA_TEST=1 WORKSPACE=/tmp/open-claudia-test CLAUDE_PATH=node node test-read-signal.js && OPEN_CLAUDIA_TEST=1 WORKSPACE=/tmp/open-claudia-test CLAUDE_PATH=node node test-tools.js && OPEN_CLAUDIA_TEST=1 WORKSPACE=/tmp/open-claudia-test CLAUDE_PATH=node node test-tool-graph.js"
12
+ "test": "OPEN_CLAUDIA_TEST=1 WORKSPACE=/tmp/open-claudia-test CLAUDE_PATH=node node -e \"require('./vault'); console.log('OK')\" && OPEN_CLAUDIA_TEST=1 WORKSPACE=/tmp/open-claudia-test CLAUDE_PATH=node node test-runner-watchdog-static.js && OPEN_CLAUDIA_TEST=1 WORKSPACE=/tmp/open-claudia-test CLAUDE_PATH=node node test-usage-accounting.js && OPEN_CLAUDIA_TEST=1 WORKSPACE=/tmp/open-claudia-test CLAUDE_PATH=node node test-recall-engine.js && OPEN_CLAUDIA_TEST=1 WORKSPACE=/tmp/open-claudia-test CLAUDE_PATH=node node test-recall-graph.js && OPEN_CLAUDIA_TEST=1 WORKSPACE=/tmp/open-claudia-test CLAUDE_PATH=node node test-recall-discoverer.js && OPEN_CLAUDIA_TEST=1 WORKSPACE=/tmp/open-claudia-test CLAUDE_PATH=node node test-project-transcripts-smoke.js && OPEN_CLAUDIA_TEST=1 WORKSPACE=/tmp/open-claudia-test CLAUDE_PATH=node node test-abilities.js && OPEN_CLAUDIA_TEST=1 WORKSPACE=/tmp/open-claudia-test CLAUDE_PATH=node node test-ability-extraction.js && OPEN_CLAUDIA_TEST=1 WORKSPACE=/tmp/open-claudia-test CLAUDE_PATH=node node test-ability-couse.js && OPEN_CLAUDIA_TEST=1 WORKSPACE=/tmp/open-claudia-test CLAUDE_PATH=node node test-ability-transfer.js && OPEN_CLAUDIA_TEST=1 WORKSPACE=/tmp/open-claudia-test CLAUDE_PATH=node node test-ability-tiers.js && OPEN_CLAUDIA_TEST=1 WORKSPACE=/tmp/open-claudia-test CLAUDE_PATH=node node test-ability-merge-guard.js && OPEN_CLAUDIA_TEST=1 WORKSPACE=/tmp/open-claudia-test CLAUDE_PATH=node node test-learning-e2e.js && OPEN_CLAUDIA_TEST=1 WORKSPACE=/tmp/open-claudia-test CLAUDE_PATH=node node test-pack-nesting.js && OPEN_CLAUDIA_TEST=1 WORKSPACE=/tmp/open-claudia-test CLAUDE_PATH=node node test-read-signal.js && OPEN_CLAUDIA_TEST=1 WORKSPACE=/tmp/open-claudia-test CLAUDE_PATH=node node test-tools.js && OPEN_CLAUDIA_TEST=1 WORKSPACE=/tmp/open-claudia-test CLAUDE_PATH=node node test-tool-graph.js"
13
13
  },
14
14
  "files": [
15
15
  "bot.js",
@@ -30,6 +30,7 @@
30
30
  ".env.example",
31
31
  "README.md",
32
32
  "CHANGELOG.md",
33
+ "test-runner-watchdog-static.js",
33
34
  "test-usage-accounting.js",
34
35
  "test-recall-engine.js",
35
36
  "test-recall-graph.js",
@@ -0,0 +1,21 @@
1
+ #!/usr/bin/env node
2
+ const assert = require('assert');
3
+ const fs = require('fs');
4
+ const path = require('path');
5
+
6
+ const root = __dirname;
7
+ const runner = fs.readFileSync(path.join(root, 'core', 'runner.js'), 'utf8');
8
+ const monolith = fs.readFileSync(path.join(root, 'bot-agent.js'), 'utf8');
9
+
10
+ assert.match(runner, /OC_TURN_IDLE_MS/, 'live core/runner.js must read OC_TURN_IDLE_MS');
11
+ assert.match(runner, /watchdogTripped/, 'live core/runner.js must guard close handling after watchdog trip');
12
+ assert.match(runner, /lastActivity\s*=\s*Date\.now\(\)/, 'live core/runner.js must track last child activity');
13
+ assert.match(runner, /proc\.stdout\.on\("data",[\s\S]{0,120}\blastActivity\s*=\s*Date\.now\(\)/, 'stdout must refresh lastActivity');
14
+ assert.match(runner, /proc\.stderr\.on\("data",[\s\S]{0,120}\blastActivity\s*=\s*Date\.now\(\)/, 'stderr must refresh lastActivity');
15
+ assert.match(runner, /killProcessTree\(proc\.pid,\s*"SIGTERM"\)/, 'watchdog must terminate the child process tree first');
16
+ assert.match(runner, /killProcessTree\(proc\.pid,\s*"SIGKILL"\)/, 'watchdog must force-kill the child process tree after grace');
17
+ assert.match(runner, /await drainQueuedMessages\(\)/, 'watchdog path must not strand queued follow-up messages');
18
+
19
+ assert.match(monolith, /OC_TURN_IDLE_MS/, 'legacy bot-agent.js watchdog should remain for monolith users');
20
+
21
+ console.log('runner watchdog static OK');