@yemi33/minions 0.1.1684 → 0.1.1685

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,10 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.1.1685 (2026-05-02)
4
+
5
+ ### Features
6
+ - harden agent completion sentinel (#1990)
7
+
3
8
  ## 0.1.1684 (2026-05-02)
4
9
 
5
10
  ### Other
@@ -1,5 +1,5 @@
1
1
  {
2
2
  "runtime": "copilot",
3
3
  "models": null,
4
- "cachedAt": "2026-05-02T16:47:10.817Z"
4
+ "cachedAt": "2026-05-02T18:11:21.378Z"
5
5
  }
@@ -128,6 +128,57 @@ function normalizeRuntimeExit(code, signal) {
128
128
  return 1;
129
129
  }
130
130
 
131
+ const PROCESS_EXIT_SENTINEL_FLUSH_TIMEOUT_MS = 2000;
132
+
133
+ function formatProcessExitSentinel(exitCode, signal) {
134
+ return `\n[process-exit] code=${exitCode}${signal ? ` signal=${signal}` : ''}\n`;
135
+ }
136
+
137
+ function _appendSentinelFallback(outputPath, sentinel) {
138
+ if (!outputPath) return false;
139
+ try {
140
+ fs.appendFileSync(outputPath, sentinel);
141
+ return true;
142
+ } catch {
143
+ return false;
144
+ }
145
+ }
146
+
147
+ function _writeStdoutWithTimeout(stdout, sentinel, timeoutMs) {
148
+ return new Promise((resolve) => {
149
+ let settled = false;
150
+ const finish = (flushed) => {
151
+ if (settled) return;
152
+ settled = true;
153
+ clearTimeout(timer);
154
+ resolve(flushed);
155
+ };
156
+ const timer = setTimeout(() => finish(false), Math.max(0, timeoutMs));
157
+ try {
158
+ if (!stdout || typeof stdout.write !== 'function') {
159
+ finish(false);
160
+ return;
161
+ }
162
+ stdout.write(sentinel, () => finish(true));
163
+ } catch {
164
+ finish(false);
165
+ }
166
+ });
167
+ }
168
+
169
+ async function writeProcessExitSentinel({
170
+ exitCode,
171
+ signal = null,
172
+ stdout = process.stdout,
173
+ outputPath = process.env.MINIONS_LIVE_OUTPUT_PATH,
174
+ timeoutMs = PROCESS_EXIT_SENTINEL_FLUSH_TIMEOUT_MS,
175
+ } = {}) {
176
+ const sentinel = formatProcessExitSentinel(exitCode, signal);
177
+ const stdoutFlushed = await _writeStdoutWithTimeout(stdout, sentinel, timeoutMs);
178
+ const outputPathWritten = stdoutFlushed ? false : _appendSentinelFallback(outputPath, sentinel);
179
+ return { sentinel, stdoutFlushed, outputPathWritten };
180
+ }
181
+
131
182
  // ─── Main script execution ──────────────────────────────────────────────────
132
183
 
133
184
  function _installHint(name, runtime) {
@@ -287,20 +338,23 @@ function main() {
287
338
  }, MCP_STARTUP_TIMEOUT);
288
339
  proc.stdout.once('data', () => { gotFirstOutput = true; clearTimeout(startupTimer); });
289
340
 
290
- proc.on('close', (code, signal) => {
341
+ proc.on('close', async (code, signal) => {
291
342
  clearTimeout(startupTimer);
292
343
  const exitCode = normalizeRuntimeExit(code, signal);
293
- // Write process-exit sentinel to stdout so the engine can detect completion (#716).
294
- try { process.stdout.write(`\n[process-exit] code=${exitCode}${signal ? ` signal=${signal}` : ''}\n`); } catch { /* stdout may be closed */ }
344
+ const sentinelResult = await writeProcessExitSentinel({ exitCode, signal });
295
345
  fs.appendFileSync(debugPath, `EXIT: code=${exitCode}${signal ? ` signal=${signal}` : ''}\nSTDERR: ${stderrBuf.slice(0, 500)}\n`);
346
+ if (!sentinelResult.stdoutFlushed && sentinelResult.outputPathWritten) {
347
+ fs.appendFileSync(debugPath, `EXIT SENTINEL FALLBACK: ${process.env.MINIONS_LIVE_OUTPUT_PATH}\n`);
348
+ }
296
349
  process.exit(exitCode);
297
350
  });
298
- proc.on('error', (err) => {
351
+ proc.on('error', async (err) => {
299
352
  fs.appendFileSync(debugPath, `ERROR: ${err.message}\n`);
353
+ await writeProcessExitSentinel({ exitCode: 1 });
300
354
  process.exit(1);
301
355
  });
302
356
  }
303
357
 
304
- module.exports = { parseSpawnArgs, buildSpawnInvocation, normalizeRuntimeExit };
358
+ module.exports = { parseSpawnArgs, buildSpawnInvocation, normalizeRuntimeExit, writeProcessExitSentinel };
305
359
 
306
360
  if (require.main === module) main();
package/engine/timeout.js CHANGED
@@ -217,6 +217,38 @@ function parseProcessExitCode(logText) {
217
217
  return lastMatch[1] === 'spawn-failed' ? -1 : parseInt(lastMatch[1], 10);
218
218
  }
219
219
 
220
+ function terminalResultIndicatesError(obj) {
221
+ const subtype = String(obj?.subtype || '');
222
+ const terminalReason = String(obj?.terminal_reason || obj?.terminalReason || '');
223
+ return obj?.is_error === true ||
224
+ /^error/i.test(subtype) ||
225
+ /max[_-]?turns|error|fail|cancel|timeout/i.test(terminalReason);
226
+ }
227
+
228
+ function parseTerminalResultFallbackExitCode(logText) {
229
+ if (!logText) return null;
230
+ let exitCode = null;
231
+ for (const line of String(logText).split(/\r?\n/)) {
232
+ const trimmed = line.trim();
233
+ if (!trimmed || !trimmed.includes('"result"') ||
234
+ (!trimmed.includes('terminal_reason') && !trimmed.includes('terminalReason'))) continue;
235
+
236
+ try {
237
+ const obj = JSON.parse(trimmed);
238
+ if (obj?.type === 'result' && (obj.terminal_reason || obj.terminalReason) && terminalResultIndicatesError(obj)) {
239
+ exitCode = 1;
240
+ }
241
+ continue;
242
+ } catch { /* fall through to regex fallback for diagnostic-prefixed JSON */ }
243
+
244
+ if (/"type"\s*:\s*"result"/.test(trimmed) &&
245
+ /"terminal_?reason"\s*:\s*"[^"]*(?:max[_-]?turns|error|fail|cancel|timeout)[^"]*"/i.test(trimmed)) {
246
+ exitCode = 1;
247
+ }
248
+ }
249
+ return exitCode;
250
+ }
251
+
220
252
  function checkTimeouts(config) {
221
253
  const activeProcesses = engine().activeProcesses;
222
254
  const engineRestartGraceUntil = engine().engineRestartGraceUntil;
@@ -411,6 +443,12 @@ function checkTimeouts(config) {
411
443
  completeFromOutput(item, liveLogPath, processExitCode, fullLog, hasProcess);
412
444
  continue;
413
445
  }
446
+ const terminalResultExitCode = parseTerminalResultFallbackExitCode(fullLog);
447
+ if (terminalResultExitCode !== null) {
448
+ log('info', `Agent ${item.agent} (${item.id}) completed via stale terminal result fallback (exit code ${terminalResultExitCode})`);
449
+ completeFromOutput(item, liveLogPath, terminalResultExitCode, fullLog, hasProcess);
450
+ continue;
451
+ }
414
452
  } catch (e) { log('warn', 'orphan final output completion scan: ' + e.message); }
415
453
 
416
454
  // No tracked process AND no recent output past stale-orphan timeout AND (grace period expired OR confirmed-dead at restart) → orphaned
package/engine.js CHANGED
@@ -995,6 +995,7 @@ async function spawnAgent(dispatchItem, config) {
995
995
  // 2. Log has stub only → process started but died before its first write
996
996
  // 3. Log has stub + ... → process alive but hung (the only case that warrants orphan kill+retry)
997
997
  const liveOutputPath = path.join(AGENTS_DIR, agentId, 'live-output.log');
998
+ childEnv.MINIONS_LIVE_OUTPUT_PATH = liveOutputPath;
998
999
 
999
1000
  // Rotate previous live output to preserve session history (fixes #543: orphan recovery overwrites)
1000
1001
  // Only rotate if the existing file has meaningful content (beyond just the header stub)
@@ -1186,6 +1187,7 @@ async function spawnAgent(dispatchItem, config) {
1186
1187
  const spawnScript = path.join(ENGINE_DIR, 'spawn-agent.js');
1187
1188
  const childEnv = shared.cleanChildEnv();
1188
1189
  if (completionReportPath) childEnv.MINIONS_COMPLETION_REPORT = completionReportPath;
1190
+ childEnv.MINIONS_LIVE_OUTPUT_PATH = liveOutputPath;
1189
1191
  // Inject cached ADO token for steering session too (#998)
1190
1192
  try {
1191
1193
  const adoToken = await getAdoToken();
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@yemi33/minions",
3
- "version": "0.1.1684",
3
+ "version": "0.1.1685",
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"