@yemi33/minions 0.1.743 → 0.1.744

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,11 +1,12 @@
1
1
  # Changelog
2
2
 
3
- ## 0.1.743 (2026-04-09)
3
+ ## 0.1.744 (2026-04-09)
4
4
 
5
5
  ### Features
6
6
  - add prNumber field to pull-requests.json records (#711)
7
7
 
8
8
  ### Fixes
9
+ - handle max_turns exit in dispatch lifecycle cleanup (closes #716) (#735)
9
10
  - link plan files as artifacts on plan/plan-to-prd work items
10
11
  - escalate failed plan items instead of blocking indefinitely (closes #722) (#733)
11
12
  - handle NUL pseudo-file in Windows worktree cleanup (#731)
@@ -1560,7 +1560,7 @@ function classifyFailure(code, stdout = '', stderr = '') {
1560
1560
  }
1561
1561
 
1562
1562
  // Context window / max turns exhausted
1563
- if (/context window|max.*turns.*reached|token limit|conversation.*too long|context.*length.*exceeded/i.test(combined)) {
1563
+ if (/context window|max.*turns.*reached|error_max_turns|terminal_reason.*max_turns|token limit|conversation.*too long|context.*length.*exceeded/i.test(combined)) {
1564
1564
  return FAILURE_CLASS.OUT_OF_CONTEXT;
1565
1565
  }
1566
1566
 
@@ -176,6 +176,10 @@ proc.stdout.once('data', () => { gotFirstOutput = true; clearTimeout(startupTime
176
176
 
177
177
  proc.on('close', (code) => {
178
178
  clearTimeout(startupTimer);
179
+ // Write process-exit sentinel to stdout so the engine can detect completion (#716).
180
+ // This is a backup for cases where Claude CLI crashes without writing a result line.
181
+ // process.stdout.write is synchronous for pipes, so it will be captured by the parent.
182
+ try { process.stdout.write(`\n[process-exit] code=${code}\n`); } catch { /* stdout may be closed */ }
179
183
  fs.appendFileSync(debugPath, `EXIT: code=${code}\nSTDERR: ${stderrBuf.slice(0, 500)}\n`);
180
184
  process.exit(code || 0);
181
185
  });
package/engine/timeout.js CHANGED
@@ -177,27 +177,40 @@ function checkTimeouts(config) {
177
177
  const silentMs = Date.now() - lastActivity;
178
178
  const silentSec = Math.round(silentMs / 1000);
179
179
 
180
- // Check if the agent actually completed (result event in live output)
181
- // Optimization: only read file if recent activity (avoids reading stale 1MB logs)
180
+ // Check if the agent actually completed (result event in live output).
181
+ // Read the tail of the log (last 64KB) for efficiency — result JSON is always near the end.
182
+ // No time cap: a stuck dispatch that produced a result must always be detected (#716).
182
183
  let completedViaOutput = false;
183
- if (silentMs <= 600000) try {
184
- const liveLog = safeRead(liveLogPath);
185
- if (liveLog && liveLog.includes('"type":"result"')) {
184
+ try {
185
+ let liveLog;
186
+ try {
187
+ const fd = fs.openSync(liveLogPath, 'r');
188
+ const stat = fs.fstatSync(fd);
189
+ const TAIL_SIZE = 65536; // 64KB
190
+ const tailSize = Math.min(stat.size, TAIL_SIZE);
191
+ const buf = Buffer.alloc(tailSize);
192
+ fs.readSync(fd, buf, 0, tailSize, Math.max(0, stat.size - tailSize));
193
+ fs.closeSync(fd);
194
+ liveLog = buf.toString('utf8');
195
+ } catch { /* ENOENT or read failure — liveLog stays undefined */ }
196
+ if (liveLog && (liveLog.includes('"type":"result"') || liveLog.includes('[process-exit]'))) {
186
197
  completedViaOutput = true;
187
198
  const isSuccess = liveLog.includes('"subtype":"success"');
188
199
  log('info', `Agent ${item.agent} (${item.id}) completed via output detection (${isSuccess ? 'success' : 'error'})`);
189
200
 
190
- // Extract output text for the output.log
201
+ // Extract output text for the output.log — read full file for complete parsing
191
202
  const outputLogPath = path.join(AGENTS_DIR, item.agent, 'output.log');
192
203
  try {
193
- const { text } = shared.parseStreamJsonOutput(liveLog);
204
+ const fullLog = safeRead(liveLogPath) || liveLog;
205
+ const { text } = shared.parseStreamJsonOutput(fullLog);
194
206
  safeWrite(outputLogPath, `# Output for dispatch ${item.id}\n# Exit code: ${isSuccess ? 0 : 1}\n# Completed: ${ts()}\n# Detected via output scan\n\n## Result\n${text || '(no text)'}\n`);
195
207
  } catch (e) { log('warn', 'parse output result: ' + e.message); }
196
208
 
197
209
  completeDispatch(item.id, isSuccess ? DISPATCH_RESULT.SUCCESS : DISPATCH_RESULT.ERROR, 'Completed (detected from output)');
198
210
 
199
211
  // Run post-completion hooks via shared helper (async — fire and forget in timeout context)
200
- runPostCompletionHooks(item, item.agent, isSuccess ? 0 : 1, liveLog, config).catch(e => log('warn', 'post-completion hooks: ' + e.message));
212
+ const fullLogForHooks = safeRead(liveLogPath) || liveLog;
213
+ runPostCompletionHooks(item, item.agent, isSuccess ? 0 : 1, fullLogForHooks, config).catch(e => log('warn', 'post-completion hooks: ' + e.message));
201
214
 
202
215
  if (hasProcess) {
203
216
  shared.killImmediate(activeProcesses.get(item.id)?.proc);
@@ -213,6 +226,8 @@ function checkTimeouts(config) {
213
226
  // Check if agent is in a blocking tool call (TaskOutput block:true, Bash with long timeout, etc.)
214
227
  // These tools produce no stdout for extended periods — don't kill them prematurely
215
228
  // Check for BOTH tracked and untracked processes (orphan case after engine restart)
229
+ // Skip if agent already completed — blocking tool detection on stale tool calls
230
+ // would extend the timeout indefinitely for dead agents (#716).
216
231
  let isBlocking = false;
217
232
  let blockingTimeout = itemHeartbeat;
218
233
  let blockingTool = '';
@@ -220,6 +235,12 @@ function checkTimeouts(config) {
220
235
  try {
221
236
  const liveLog = safeRead(liveLogPath);
222
237
  if (liveLog) {
238
+ // If the output contains a result event or process-exit sentinel, the agent is done.
239
+ // Don't extend timeout for stale blocking tool calls from before the result (#716).
240
+ if (liveLog.includes('"type":"result"') || liveLog.includes('[process-exit]')) {
241
+ // Agent completed but close event didn't fire — let orphan/hung detection handle it.
242
+ // Don't set isBlocking — use base heartbeat timeout.
243
+ } else {
223
244
  // Find the last tool_use call in the output — check if it's a known blocking tool
224
245
  const lines = liveLog.split('\n');
225
246
  for (let i = lines.length - 1; i >= Math.max(0, lines.length - 30); i--) {
@@ -266,7 +287,8 @@ function checkTimeouts(config) {
266
287
  remainingMs: Math.max(0, blockingTimeout - silentMs),
267
288
  });
268
289
  }
269
- }
290
+ } // close else
291
+ } // close if (liveLog)
270
292
  } catch (e) { log('warn', 'blocking tool detection: ' + e.message); }
271
293
  }
272
294
  // Agent recovered from blocking state — clear annotation
package/engine.js CHANGED
@@ -723,7 +723,7 @@ async function spawnAgent(dispatchItem, config) {
723
723
  }
724
724
 
725
725
  // Re-attach to existing tracking
726
- activeProcesses.set(id, { proc: resumeProc, agentId, startedAt: procInfo.startedAt, sessionId: steerSessionId, _steeringAt: Date.now() });
726
+ activeProcesses.set(id, { proc: resumeProc, agentId, startedAt: procInfo.startedAt, sessionId: steerSessionId, _steeringAt: Date.now(), lastRealOutputAt: Date.now() });
727
727
 
728
728
  // Reset output buffers so post-completion parsing only sees the resumed session
729
729
  stdout = '';
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@yemi33/minions",
3
- "version": "0.1.743",
3
+ "version": "0.1.744",
4
4
  "description": "Multi-agent AI dev team that runs from ~/.minions/ — five autonomous agents share a single engine, dashboard, and knowledge base",
5
5
  "bin": {
6
6
  "minions": "bin/minions.js"