@oh-my-matrix/autopilot 2.1.2 → 3.0.2

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.
Files changed (64) hide show
  1. package/README.md +125 -10
  2. package/dist/index.d.ts +1 -1
  3. package/dist/index.d.ts.map +1 -1
  4. package/dist/index.js +338 -93
  5. package/dist/index.js.map +1 -1
  6. package/dist/src/autopilot-state.d.ts +17 -0
  7. package/dist/src/autopilot-state.d.ts.map +1 -1
  8. package/dist/src/autopilot-state.js +52 -8
  9. package/dist/src/autopilot-state.js.map +1 -1
  10. package/dist/src/command-runner.d.ts +2 -6
  11. package/dist/src/command-runner.d.ts.map +1 -1
  12. package/dist/src/command-runner.js +4 -34
  13. package/dist/src/command-runner.js.map +1 -1
  14. package/dist/src/completion-detector.d.ts.map +1 -1
  15. package/dist/src/completion-detector.js +16 -8
  16. package/dist/src/completion-detector.js.map +1 -1
  17. package/dist/src/effort-injection.d.ts +21 -2
  18. package/dist/src/effort-injection.d.ts.map +1 -1
  19. package/dist/src/effort-injection.js +37 -9
  20. package/dist/src/effort-injection.js.map +1 -1
  21. package/dist/src/event-shape.contract.d.ts +21 -0
  22. package/dist/src/event-shape.contract.d.ts.map +1 -0
  23. package/dist/src/event-shape.contract.js +10 -0
  24. package/dist/src/event-shape.contract.js.map +1 -0
  25. package/dist/src/logger.d.ts.map +1 -1
  26. package/dist/src/logger.js +42 -7
  27. package/dist/src/logger.js.map +1 -1
  28. package/dist/src/model-routing.d.ts +36 -0
  29. package/dist/src/model-routing.d.ts.map +1 -0
  30. package/dist/src/model-routing.js +106 -0
  31. package/dist/src/model-routing.js.map +1 -0
  32. package/dist/src/orchestrator.d.ts +34 -1
  33. package/dist/src/orchestrator.d.ts.map +1 -1
  34. package/dist/src/orchestrator.js +113 -12
  35. package/dist/src/orchestrator.js.map +1 -1
  36. package/dist/src/projection.d.ts +8 -2
  37. package/dist/src/projection.d.ts.map +1 -1
  38. package/dist/src/projection.js +15 -1
  39. package/dist/src/projection.js.map +1 -1
  40. package/dist/src/stall-detector.d.ts +4 -1
  41. package/dist/src/stall-detector.d.ts.map +1 -1
  42. package/dist/src/stall-detector.js +7 -3
  43. package/dist/src/stall-detector.js.map +1 -1
  44. package/dist/src/tool-error-tracker.d.ts +16 -0
  45. package/dist/src/tool-error-tracker.d.ts.map +1 -1
  46. package/dist/src/tool-error-tracker.js +16 -0
  47. package/dist/src/tool-error-tracker.js.map +1 -1
  48. package/dist/src/types.d.ts +67 -5
  49. package/dist/src/types.d.ts.map +1 -1
  50. package/dist/src/types.js +45 -1
  51. package/dist/src/types.js.map +1 -1
  52. package/dist/src/workflow-config.d.ts.map +1 -1
  53. package/dist/src/workflow-config.js +108 -14
  54. package/dist/src/workflow-config.js.map +1 -1
  55. package/openclaw.plugin.json +81 -4
  56. package/package.json +14 -5
  57. package/dist/src/audit-persister.d.ts +0 -25
  58. package/dist/src/audit-persister.d.ts.map +0 -1
  59. package/dist/src/audit-persister.js +0 -162
  60. package/dist/src/audit-persister.js.map +0 -1
  61. package/dist/src/permission-policy.d.ts +0 -36
  62. package/dist/src/permission-policy.d.ts.map +0 -1
  63. package/dist/src/permission-policy.js +0 -265
  64. package/dist/src/permission-policy.js.map +0 -1
package/dist/index.js CHANGED
@@ -11,6 +11,7 @@ const continuation_engine_1 = require("./src/continuation-engine");
11
11
  const tool_error_tracker_1 = require("./src/tool-error-tracker");
12
12
  const stall_detector_1 = require("./src/stall-detector");
13
13
  const effort_injection_1 = require("./src/effort-injection");
14
+ const model_routing_1 = require("./src/model-routing");
14
15
  const logger_1 = require("./src/logger");
15
16
  const autopilot_state_1 = require("./src/autopilot-state");
16
17
  const goal_manager_1 = require("./src/goal-manager");
@@ -48,7 +49,9 @@ function validateWorkspacePath(p) {
48
49
  }
49
50
  exports.id = 'autopilot';
50
51
  exports.name = 'Autopilot Continuous Mode';
51
- exports.version = '2.0.0';
52
+ // S14 (audit 2026-06-30): keep in sync with package.json + openclaw.plugin.json.
53
+ // These three are the release-time single source of truth for the plugin version.
54
+ exports.version = '3.0.0';
52
55
  /** GAP-25: Maximum number of concurrent run states before eviction kicks in */
53
56
  const MAX_RUN_STATES = 50;
54
57
  /** GAP-26: Health check threshold — sessions inactive for 24h are orphaned */
@@ -57,7 +60,51 @@ let stateByRun = new Map();
57
60
  let sessionIdToKey = new Map();
58
61
  let sessionKeyToRunId = new Map();
59
62
  let canaryFired = new Set();
63
+ // S10: one-shot "host not reporting usage" warn per session — avoids log spam
64
+ // while making the silent tokenBudget no-op observable to operators.
65
+ let noUsageWarned = new Set();
60
66
  let stallInterval = null;
67
+ let enqueueInjectionFn;
68
+ /**
69
+ * PROD-7 / LOGIC-4: actuator for stall / programmatic-resume recovery. The
70
+ * reducer moves a run to `claimed` (retry_due or resume_requested) and marks
71
+ * needsCrossTurnResume, but a claimed run cannot start an agent turn on its own —
72
+ * the host only fires agent_turn_prepare when it dispatches a turn. For a
73
+ * genuinely dead agent (stall) or a gateway resume with no follow-up user
74
+ * message, no turn ever comes, so the run would sit in `claimed` until the 24h
75
+ * orphan sweep. This kicks a cross-turn injection so the host actually restarts
76
+ * execution. idempotencyKey (keyed on lastActivityAt) dedupes double-injection
77
+ * of the same recovery event while allowing successive retries to each fire.
78
+ */
79
+ function kickResumedTurn(runId, state) {
80
+ if (state.orchestrationState !== 'claimed')
81
+ return;
82
+ const enqueue = enqueueInjectionFn;
83
+ if (typeof enqueue !== 'function')
84
+ return;
85
+ void Promise.resolve(enqueue({
86
+ sessionKey: state.sessionKey,
87
+ text: (0, continuation_engine_1.buildRetryInstruction)(state),
88
+ idempotencyKey: `autopilot-resume-${runId}-${state.lastActivityAt ?? state.totalContinuations}`,
89
+ placement: 'prepend_context',
90
+ // REV-2 fix: match the stall timeout, not the raw default. A no-budget run
91
+ // stalls at ×2 (600s); the injection TTL must outlive it or the host boots
92
+ // a resumed turn after the injection already expired.
93
+ ttlMs: state.workflow?.stallTimeoutMs ?? defaultStallTimeoutMs(!!state.tokenBudget),
94
+ })).catch((err) => (0, logger_1.warn)(`[autopilot] resume kick enqueue failed for session=${state.sessionKey}: ${err}`));
95
+ }
96
+ /**
97
+ * L3: resolve the stall timeout fallback when a run has no per-run config.
98
+ * Runs without a token budget get double the default (undocumented ops tuning —
99
+ * no-budget runs tend to be long-running and tolerate longer silence before
100
+ * being declared stalled). Extracted to a named helper so the ×2 rationale is
101
+ * documented in one place, not duplicated at two call sites.
102
+ */
103
+ function defaultStallTimeoutMs(hasTokenBudget) {
104
+ return hasTokenBudget
105
+ ? workflow_config_1.DEFAULT_WORKFLOW_CONFIG.stallTimeoutMs
106
+ : workflow_config_1.DEFAULT_WORKFLOW_CONFIG.stallTimeoutMs * 2;
107
+ }
61
108
  /**
62
109
  * before_tool_call priority — must be higher than matrixassistant-audit (priority 9).
63
110
  * Ensures autopilot audit trail is recorded before audit can short-circuit.
@@ -97,6 +144,8 @@ function _resetForTest() {
97
144
  sessionIdToKey = new Map();
98
145
  sessionKeyToRunId = new Map();
99
146
  canaryFired = new Set();
147
+ noUsageWarned = new Set();
148
+ enqueueInjectionFn = undefined;
100
149
  if (stallInterval) {
101
150
  clearInterval(stallInterval);
102
151
  stallInterval = null;
@@ -161,28 +210,37 @@ function _triggerRetryCheckForTest(overrides) {
161
210
  state.retry.nextRetryAt <= now) {
162
211
  const updated = (0, orchestrator_1.orchestratorReducer)(state, { type: 'retry_due', runId, now });
163
212
  setState(runId, updated);
213
+ // PROD-7: retry_due→claimed is a dead end without an actuator — kick a turn.
214
+ if (updated.orchestrationState === 'claimed')
215
+ kickResumedTurn(runId, updated);
164
216
  }
165
217
  return stateByRun.get(runId);
166
218
  }
167
- /** GAP-25: Evict oldest runs when Map exceeds MAX_RUN_STATES */
219
+ /** GAP-25: Evict least-recently-active runs when Map exceeds MAX_RUN_STATES */
168
220
  function evictOldestRuns() {
169
221
  while (stateByRun.size > MAX_RUN_STATES) {
170
- // Find the run with the earliest startedAt (FIFO eviction)
222
+ // Find the run with the earliest lastActivityAt (LRU eviction).
223
+ // Falls back to startedAt when lastActivityAt is unset (pre-orchestrator runs).
171
224
  let oldestRunId = null;
172
225
  let oldestAt = Infinity;
173
226
  for (const [runId, state] of stateByRun) {
174
- if ((state.startedAt ?? Infinity) < oldestAt) {
175
- oldestAt = state.startedAt ?? Infinity;
227
+ const at = state.lastActivityAt ?? state.startedAt ?? Infinity;
228
+ if (at < oldestAt) {
229
+ oldestAt = at;
176
230
  oldestRunId = runId;
177
231
  }
178
232
  }
179
233
  if (oldestRunId == null)
180
234
  break;
181
235
  const oldestState = stateByRun.get(oldestRunId);
236
+ // S8: release audit refCount for an evicted still-running run before delete.
237
+ if (oldestState?.status === 'running')
238
+ setAuditMode('active');
182
239
  stateByRun.delete(oldestRunId);
183
240
  if (oldestState) {
184
241
  sessionKeyToRunId.delete(oldestState.sessionKey);
185
242
  canaryFired.delete(oldestState.sessionKey);
243
+ noUsageWarned.delete(oldestState.sessionKey);
186
244
  // GAP-25: also clean up sessionIdToKey to prevent orphaned sid→skey entries
187
245
  for (const [sid, skey] of sessionIdToKey) {
188
246
  if (skey === oldestState.sessionKey) {
@@ -200,15 +258,20 @@ function setState(runId, state) {
200
258
  }
201
259
  /** GAP-23: Cleanup all state on shutdown */
202
260
  function cleanupAll() {
203
- // Release audit monitor refCount for every active run before clearing state.
204
- // Each run acquired one refCount via setAuditMode('monitor'), so each needs a matching release.
205
- for (let i = 0; i < stateByRun.size; i++) {
206
- setAuditMode('active');
261
+ // Release audit monitor refCount ONLY for runs that still hold one. A run
262
+ // acquires it on activate and releases on pause/complete/stop, so only
263
+ // status==='running' runs still hold an unreleased refCount. Releasing for
264
+ // paused/done/idle runs would over-release — the audit plugin's refCount could
265
+ // go negative depending on its clamp semantics.
266
+ for (const state of stateByRun.values()) {
267
+ if (state.status === 'running')
268
+ setAuditMode('active');
207
269
  }
208
270
  stateByRun.clear();
209
271
  sessionIdToKey.clear();
210
272
  sessionKeyToRunId.clear();
211
273
  canaryFired.clear();
274
+ noUsageWarned.clear();
212
275
  if (stallInterval) {
213
276
  clearInterval(stallInterval);
214
277
  stallInterval = null;
@@ -221,18 +284,63 @@ function findRunBySession(sessionKey) {
221
284
  const state = stateByRun.get(runId);
222
285
  return state ? [runId, state] : undefined;
223
286
  }
287
+ /**
288
+ * Resolve an autopilot run for a session key, transparently following a
289
+ * subagent key back to its parent run. Subagent keys have the shape
290
+ * `agent:<main>:subagent:<sub>`; the parent prefix is the run owner.
291
+ * Used by before_model_resolve and llm_output so both treat subagent
292
+ * activity as belonging to the parent run.
293
+ */
294
+ function findRunBySessionOrParent(sessionKey) {
295
+ return findRunBySession(sessionKey)
296
+ ?? ((0, model_routing_1.isSubagentSession)(sessionKey)
297
+ ? findRunBySession((0, model_routing_1.extractParentSessionKey)(sessionKey) ?? '')
298
+ : undefined);
299
+ }
300
+ // ponytail: production passes sessionKey on ctx, test mocks put it on event — one helper handles both
301
+ function resolveSessionKey(event, ctx) {
302
+ // Session key can arrive on ctx (production host) or event (test mocks + some
303
+ // hook signatures where the host populates the event payload). Both are
304
+ // host-controlled surfaces — the event is NOT user-influenceable. Code-review
305
+ // M1 flagged the dual-source as a foot-gun, but removing the event fallback
306
+ // breaks 8 tests that simulate the production pattern (some hooks only
307
+ // populate sessionKey on event). Leaving as-is until a deeper audit of
308
+ // per-hook sessionKey provenance is done.
309
+ const c = ctx;
310
+ const e = event;
311
+ const sessionId = c?.sessionId ?? e?.sessionId;
312
+ return c?.sessionKey ?? e?.sessionKey ?? (sessionId != null ? sessionIdToKey.get(sessionId) : undefined);
313
+ }
314
+ const CROSS_TURN_FALLBACK_TEXT = 'Continue from where you left off.';
315
+ function buildCrossTurnReviseFallback(runId, state, instruction) {
316
+ const fallbackState = { ...(0, autopilot_state_1.incrementTotal)(state), turnAttempts: 0 };
317
+ setState(runId, fallbackState);
318
+ return {
319
+ action: 'revise',
320
+ retry: {
321
+ instruction: instruction || CROSS_TURN_FALLBACK_TEXT,
322
+ idempotencyKey: `autopilot-${runId}-${fallbackState.totalContinuations}`,
323
+ maxAttempts: fallbackState.maxAttemptsPerTurn,
324
+ },
325
+ };
326
+ }
224
327
  /** Generate a unique run ID using crypto.randomUUID (exported for testing). */
225
328
  function _generateRunIdForTest() {
226
329
  return `run-${crypto.randomUUID()}`;
227
330
  }
228
331
  function generateRunId() {
229
- return _generateRunIdForTest();
332
+ return `run-${crypto.randomUUID()}`;
230
333
  }
231
334
  function register(api) {
335
+ // PROD-7 / LOGIC-4: stash the cross-turn injection actuator so the stall
336
+ // interval and gateway resume (both outside per-call closures) can restart a
337
+ // claimed run. Optional-chained: undefined on hosts without the 5.28+ facade.
338
+ enqueueInjectionFn = api.session?.workflow?.enqueueNextTurnInjection;
232
339
  // Read user config from OpenClaw, merge with defaults
233
340
  // pluginConfig is Record<string, unknown> — coerce values to expected types
234
341
  const uc = api.pluginConfig ?? {};
235
342
  const numOrUndefined = (v) => typeof v === 'number' ? v : undefined;
343
+ const modelRouting = (0, model_routing_1.parseModelRouting)(uc.modelRouting);
236
344
  const config = {
237
345
  ...types_1.DEFAULT_CONFIG,
238
346
  ...(numOrUndefined(uc.maxAttemptsPerTurn) != null ? { maxAttemptsPerTurn: numOrUndefined(uc.maxAttemptsPerTurn) } : {}),
@@ -242,6 +350,11 @@ function register(api) {
242
350
  ...(Array.isArray(uc.highRiskTools) ? { highRiskTools: uc.highRiskTools } : {}),
243
351
  ...(numOrUndefined(uc.tokenBudget) != null ? { tokenBudget: numOrUndefined(uc.tokenBudget) } : {}),
244
352
  ...(numOrUndefined(uc.maxConcurrentAutopilot) != null ? { maxConcurrentAutopilot: numOrUndefined(uc.maxConcurrentAutopilot) } : {}),
353
+ ...(typeof uc.thinkingIntensity === 'string' && ['low', 'medium', 'high'].includes(uc.thinkingIntensity)
354
+ ? { thinkingIntensity: uc.thinkingIntensity }
355
+ : {}),
356
+ ...(modelRouting ? { modelRouting } : {}),
357
+ ...(typeof uc.trustWorkspace === 'boolean' ? { trustWorkspace: uc.trustWorkspace } : {}),
245
358
  };
246
359
  (0, logger_1.log)(`[autopilot] config: maxAttemptsPerTurn=${config.maxAttemptsPerTurn} maxTotalContinuations=${config.maxTotalContinuations} toolErrorThreshold=${config.toolErrorThreshold} excludedAgents=${JSON.stringify(config.excludedAgents)} highRiskTools=${JSON.stringify(config.highRiskTools)} tokenBudget=${config.tokenBudget}`);
247
360
  // --- Hooks (use api.on for typed hooks when available, registerHook as fallback) ---
@@ -251,7 +364,7 @@ function register(api) {
251
364
  return;
252
365
  }
253
366
  registerHook('before_agent_finalize', async (event) => {
254
- const sessionKey = event.sessionKey ?? sessionIdToKey.get(event.sessionId);
367
+ const sessionKey = resolveSessionKey(event);
255
368
  if (sessionKey)
256
369
  canaryFired.add(sessionKey);
257
370
  if (!sessionKey)
@@ -273,6 +386,15 @@ function register(api) {
273
386
  });
274
387
  (0, logger_1.log)(`[autopilot] before_agent_finalize: session=${sessionKey} action=${decision.action} turn=${state.turnAttempts}/${state.maxAttemptsPerTurn} total=${state.totalContinuations}/${state.maxTotalContinuations}`);
275
388
  switch (decision.action) {
389
+ case 'finalize': {
390
+ // S3 (audit 2026-06-30): decideContinuation returns 'finalize' when the
391
+ // run is disabled/non-running, or when stopHookActive is set (user hit
392
+ // stop). Previously this fell through to default and was silently
393
+ // rewritten to 'continue', leaving status='running' so stall/agent_end
394
+ // could revive a run the user had asked to stop. Match pause/complete:
395
+ // emit {action:'finalize'} so the host stops injecting revisions.
396
+ return { action: 'finalize' };
397
+ }
276
398
  case 'revise': {
277
399
  const updated = (0, autopilot_state_1.incrementTotal)((0, autopilot_state_1.incrementTurn)(state));
278
400
  setState(runId, updated);
@@ -295,49 +417,21 @@ function register(api) {
295
417
  text: decision.retryInstruction || 'Continue from where you left off.',
296
418
  idempotencyKey: `autopilot-cross-${sessionKey}-${updated.totalContinuations}`,
297
419
  placement: 'prepend_context',
298
- ttlMs: 300000,
420
+ ttlMs: workflow_config_1.DEFAULT_WORKFLOW_CONFIG.stallTimeoutMs,
299
421
  });
300
422
  if (result && typeof result === 'object' && result.enqueued === false) {
301
423
  (0, logger_1.warn)(`[autopilot] enqueueNextTurnInjection rejected for session=${sessionKey}, falling back to revise`);
302
- const fallbackState = { ...(0, autopilot_state_1.incrementTotal)(state), turnAttempts: 0 };
303
- setState(runId, fallbackState);
304
- return {
305
- action: 'revise',
306
- retry: {
307
- instruction: decision.retryInstruction || 'Continue from where you left off.',
308
- idempotencyKey: `autopilot-${runId}-${fallbackState.totalContinuations}`,
309
- maxAttempts: fallbackState.maxAttemptsPerTurn,
310
- },
311
- };
424
+ return buildCrossTurnReviseFallback(runId, state, decision.retryInstruction);
312
425
  }
313
426
  setState(runId, updated);
314
427
  return { action: 'finalize' };
315
428
  }
316
429
  catch (err) {
317
430
  (0, logger_1.warn)(`[autopilot] enqueueNextTurnInjection failed for session=${sessionKey}: ${err}, falling back to revise`);
318
- const fallbackState = { ...(0, autopilot_state_1.incrementTotal)(state), turnAttempts: 0 };
319
- setState(runId, fallbackState);
320
- return {
321
- action: 'revise',
322
- retry: {
323
- instruction: decision.retryInstruction || '请从上次中断的位置继续执行。',
324
- idempotencyKey: `autopilot-${runId}-${fallbackState.totalContinuations}`,
325
- maxAttempts: fallbackState.maxAttemptsPerTurn,
326
- },
327
- };
431
+ return buildCrossTurnReviseFallback(runId, state, decision.retryInstruction);
328
432
  }
329
433
  }
330
- // Fallback: injection API unavailable, use same-turn revise instead
331
- const fallbackState = { ...(0, autopilot_state_1.incrementTotal)(state), turnAttempts: 0 };
332
- setState(runId, fallbackState);
333
- return {
334
- action: 'revise',
335
- retry: {
336
- instruction: decision.retryInstruction || '请从上次中断的位置继续执行。',
337
- idempotencyKey: `autopilot-${runId}-${fallbackState.totalContinuations}`,
338
- maxAttempts: fallbackState.maxAttemptsPerTurn,
339
- },
340
- };
434
+ return buildCrossTurnReviseFallback(runId, state, decision.retryInstruction);
341
435
  }
342
436
  case 'pause': {
343
437
  setState(runId, (0, autopilot_state_1.pause)(state, decision.pauseReason));
@@ -363,14 +457,24 @@ function register(api) {
363
457
  });
364
458
  }
365
459
  catch (err) {
366
- // Fail-open: if evidence evaluation throws, treat as skipped to avoid zombie sessions
367
- (0, logger_1.warn)(`[autopilot] evidence gate error (failing open): ${err} session=${sessionKey}`);
460
+ // Fail-open (skipped) to avoid zombie sessions — runValidationCommands
461
+ // never throws and evaluateEvidence is pure, so this only trips on a
462
+ // future bug. But do NOT let that bug pass silently: emit it at error
463
+ // level with the failureReason so a monitor can tell an evaluation
464
+ // failure apart from a normal no-commands skip (both end as 'skipped').
465
+ (0, logger_1.logWithContext)('error', 'evidence gate evaluation error (failing open → skipped)', { sessionKey, runId, error: String(err) });
368
466
  evidenceSummary = { status: 'skipped', diffSummary: '', commands: [], completedAt: now, failureReason: 'evaluation error' };
369
467
  }
370
- // Dispatch evidence events only when orchestration state machine is active (released state)
468
+ // Dispatch orchestrator events to advance orchState to 'done'.
469
+ // Close the agent turn (running → released) so evidence events can fire.
371
470
  let updated = state;
372
- if (state.orchestrationState === 'released') {
373
- updated = (0, orchestrator_1.orchestratorReducer)(state, { type: 'evidence_started', runId, now });
471
+ if (updated.orchestrationState === 'running') {
472
+ updated = (0, orchestrator_1.orchestratorReducer)(updated, {
473
+ type: 'agent_turn_finished', runId, success: true, now,
474
+ });
475
+ }
476
+ if (updated.orchestrationState === 'released') {
477
+ updated = (0, orchestrator_1.orchestratorReducer)(updated, { type: 'evidence_started', runId, now });
374
478
  updated = (0, orchestrator_1.orchestratorReducer)(updated, {
375
479
  type: 'evidence_finished',
376
480
  runId,
@@ -378,13 +482,29 @@ function register(api) {
378
482
  evidence: evidenceSummary,
379
483
  });
380
484
  }
381
- // Apply evidence to state + mark done.
382
- // Guard (H1): evidence_finished (passed/skipped) already sets status:'done' via orchestrator.
383
- // complete() requires status='running' and would throw skip it when orchestrator completed.
384
- setState(runId, updated.status === 'done'
485
+ // Apply evidence result to state. The decision branches on evidence outcome:
486
+ // passed/skipped mark the run done; failed → retry or block (NOT done).
487
+ // Before this fix (H1), the code checked `updated.status === 'done'`, but the
488
+ // reducer leaves status='running' on evidence FAILURE, so failed evidence fell
489
+ // into complete() — producing a false 'done' + enabled:false, which stranded
490
+ // the run (the stall interval's retry_due guard checks state.enabled).
491
+ // See docs/audits/autopilot-correctness-review-2026-07-04.md HIGH finding.
492
+ setState(runId,
493
+ // failed → the reducer moved to retry_queued (will retry) or blocked (max
494
+ // retries). Keep the run enabled in retry_queued so the stall interval can
495
+ // fire retry_due. When blocked, the reducer already set orchState='blocked'
496
+ // + blockedReason + derived status='paused' — do NOT call pause() (it would
497
+ // throw: pause() requires status='running' but the reducer derived 'paused').
498
+ // Just attach the evidence summary; the state is already correct.
499
+ evidenceSummary.status === 'failed'
385
500
  ? { ...updated, evidence: evidenceSummary }
386
- : (0, autopilot_state_1.complete)({ ...updated, evidence: evidenceSummary }));
387
- (0, logger_1.logWithContext)('info', 'evidence gate result', { sessionKey, runId, evidenceStatus: evidenceSummary.status });
501
+ // passed/skipped reducer already set orchState='done' + status='done'
502
+ // when the evidence reducer ran; if it didn't run (orchState unchanged),
503
+ // complete() advances status to done. Either way the run finishes.
504
+ : updated.status === 'done'
505
+ ? { ...updated, evidence: evidenceSummary }
506
+ : (0, autopilot_state_1.complete)({ ...updated, evidence: evidenceSummary }));
507
+ (0, logger_1.logWithContext)('info', 'evidence gate result', { sessionKey, runId, evidenceStatus: evidenceSummary.status, failureReason: evidenceSummary.failureReason });
388
508
  // Release audit monitor when task completes — session is done, no more tool calls needed.
389
509
  setAuditMode('active');
390
510
  return { action: 'finalize' };
@@ -393,8 +513,8 @@ function register(api) {
393
513
  return { action: 'continue' };
394
514
  }
395
515
  });
396
- registerHook('after_tool_call', (event) => {
397
- const sessionKey = event.sessionKey ?? sessionIdToKey.get(event.sessionId);
516
+ registerHook('after_tool_call', (event, ctx) => {
517
+ const sessionKey = resolveSessionKey(event, ctx);
398
518
  if (!sessionKey)
399
519
  return;
400
520
  const entry = findRunBySession(sessionKey);
@@ -419,10 +539,10 @@ function register(api) {
419
539
  error: (event.error ?? '').substring(0, 200),
420
540
  });
421
541
  setState(runId, withError);
422
- (0, logger_1.log)(`[autopilot] after_tool_call error: session=${sessionKey} tool=${event.toolName} errCount=${withError.toolErrorCount}/${state.toolErrorThreshold}`);
542
+ (0, logger_1.warn)(`[autopilot] after_tool_call error: session=${sessionKey} tool=${event.toolName} errCount=${withError.toolErrorCount}/${state.toolErrorThreshold}`);
423
543
  });
424
- registerHook('before_compaction', (event) => {
425
- const sessionKey = event.sessionKey ?? sessionIdToKey.get(event.sessionId);
544
+ registerHook('before_compaction', (_event, ctx) => {
545
+ const sessionKey = resolveSessionKey(_event, ctx);
426
546
  if (!sessionKey)
427
547
  return;
428
548
  const entry = findRunBySession(sessionKey);
@@ -431,8 +551,8 @@ function register(api) {
431
551
  setState(entry[0], (0, goal_manager_1.preserveGoalBeforeCompaction)(entry[1]));
432
552
  }
433
553
  });
434
- registerHook('after_compaction', (event) => {
435
- const sessionKey = event.sessionKey ?? sessionIdToKey.get(event.sessionId);
554
+ registerHook('after_compaction', (_event, ctx) => {
555
+ const sessionKey = resolveSessionKey(_event, ctx);
436
556
  if (!sessionKey)
437
557
  return;
438
558
  const entry = findRunBySession(sessionKey);
@@ -442,7 +562,7 @@ function register(api) {
442
562
  }
443
563
  });
444
564
  registerHook('agent_turn_prepare', (event, ctx) => {
445
- const sessionKey = ctx?.sessionKey ?? event.sessionKey;
565
+ const sessionKey = ctx?.sessionKey;
446
566
  if (!sessionKey)
447
567
  return;
448
568
  const entry = findRunBySession(sessionKey);
@@ -456,6 +576,9 @@ function register(api) {
456
576
  runId,
457
577
  now: Date.now(),
458
578
  });
579
+ // Persist the orchState transition (claimed → running) immediately.
580
+ if (updated !== state)
581
+ setState(runId, updated);
459
582
  // Capture goal from first user prompt if not already set
460
583
  if (!updated.goal && event.prompt) {
461
584
  updated = (0, goal_manager_1.captureGoal)(updated, event.prompt);
@@ -489,14 +612,52 @@ function register(api) {
489
612
  }
490
613
  if (parts.length === 0)
491
614
  return;
492
- // Effort injection: ensure high effort for every autopilot turn (TD-1)
493
- const effortCtx = (0, effort_injection_1.buildEffortInjection)(updated.status);
615
+ // Effort injection: graduated intensity by execution phase (TD-1)
616
+ const intensity = (0, effort_injection_1.resolveThinkingIntensity)(updated.totalContinuations, updated.evidence?.status, config.thinkingIntensity);
617
+ const effortCtx = (0, effort_injection_1.buildEffortInjection)(updated.status, intensity);
494
618
  if (effortCtx)
495
619
  parts.push(effortCtx);
496
620
  // Completion awareness instruction
497
621
  parts.push('[Autopilot] When all tasks are complete, explicitly state "All tasks completed".');
498
622
  return { appendContext: parts.join('\n') };
499
623
  });
624
+ // Model routing: override model per execution phase. Consumed by Gateway via
625
+ // before_model_resolve -> { modelOverride }. No modelIds => no override (inherit).
626
+ // Read-only: state is read without a lock while other hooks mutate it — a turn
627
+ // straddling an evidence-status transition may pick the wrong tier for one turn.
628
+ // Acceptable for a routing heuristic (no data loss, self-corrects next turn).
629
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
630
+ registerHook('before_model_resolve', (_event, ctx) => {
631
+ const sessionKey = ctx?.sessionKey;
632
+ if (!sessionKey)
633
+ return;
634
+ // Find the autopilot run: direct, or via parent session for subagents
635
+ // (subagent keys: agent:<main>:subagent:<sub>).
636
+ const entry = findRunBySessionOrParent(sessionKey);
637
+ if (!entry?.[1].enabled || entry[1].status !== 'running')
638
+ return;
639
+ const [, state] = entry;
640
+ // WORKFLOW.md model_routing wins over plugin config when present.
641
+ const routing = state.workflow?.modelRouting ?? modelRouting;
642
+ if (!routing?.modelIds)
643
+ return;
644
+ // INT-3 (ADR-017): a subagent's own declared model wins over the parent
645
+ // run's subagentTier. The host resolves the child's `.prose model:` (or
646
+ // agent-definition model) BEFORE firing this hook and surfaces it via
647
+ // ctx.modelId. When a subagent carries an explicit declared model, we
648
+ // return without an override so the host keeps it; the parent
649
+ // subagentTier then only applies to children that declared nothing.
650
+ if ((0, model_routing_1.isSubagentSession)(sessionKey) && ctx.modelId) {
651
+ (0, logger_1.log)(`[autopilot] before_model_resolve: session=${sessionKey} inherit child model=${ctx.modelId}`);
652
+ return;
653
+ }
654
+ const tier = (0, model_routing_1.resolveModelTier)(state.totalContinuations, state.evidence?.status, (0, model_routing_1.isSubagentSession)(sessionKey), routing);
655
+ const modelId = (0, model_routing_1.resolveModelId)(tier, routing);
656
+ if (modelId) {
657
+ (0, logger_1.log)(`[autopilot] before_model_resolve: session=${sessionKey} tier=${tier} model=${modelId}`);
658
+ return { modelOverride: modelId };
659
+ }
660
+ });
500
661
  // eslint-disable-next-line @typescript-eslint/no-explicit-any
501
662
  registerHook('before_agent_run', (_event, ctx) => {
502
663
  const sessionKey = ctx?.sessionKey;
@@ -556,6 +717,10 @@ function register(api) {
556
717
  commandClass,
557
718
  outcome: decision.outcome,
558
719
  reason: decision.reason,
720
+ // F3: persist the per-call working dir so the audit trail is forensically
721
+ // useful — aligns with dynamic-workflows' audit entry. eventCwd already
722
+ // resolved above (params.workdir / cd / git -C); fall back to the workspace.
723
+ cwd: eventCwd ?? state.workspace?.path,
559
724
  };
560
725
  const MAX_AUDIT = 200;
561
726
  const prevAudit = withActivity.permissionAudit ?? [];
@@ -575,7 +740,7 @@ function register(api) {
575
740
  // Using requireApproval+timeoutMs:1 was broken — it still walked the approval
576
741
  // pipeline which has no handler, causing 10s+ "Approval timed out" errors.
577
742
  if (decision.outcome === 'block') {
578
- (0, logger_1.logWithContext)('info', 'before_tool_call BLOCKED', { sessionKey, runId, toolName, reason: decision.reason });
743
+ (0, logger_1.logWithContext)('warn', 'before_tool_call BLOCKED', { sessionKey, runId, toolName, reason: decision.reason });
579
744
  return {
580
745
  block: true,
581
746
  blockReason: decision.message,
@@ -586,12 +751,25 @@ function register(api) {
586
751
  const sessionKey = ctx?.sessionKey;
587
752
  if (!sessionKey)
588
753
  return;
589
- const entry = findRunBySession(sessionKey);
754
+ // Count subagent tokens toward the PARENT run's budget. before_model_resolve
755
+ // already resolves subagents via their parent (agent:<main>:subagent:<id>);
756
+ // token accounting must match, or a run's tokenBudget under-counts real spend
757
+ // (the budget is enforced at the parent's before_agent_finalize turn boundary).
758
+ const entry = findRunBySessionOrParent(sessionKey);
590
759
  if (!entry?.[1].enabled)
591
760
  return;
592
761
  const usage = event.usage;
593
- if (!usage?.total)
762
+ if (!usage?.total) {
763
+ // S10: when the host omits usage on a run WITH a configured tokenBudget,
764
+ // the budget silently never enforces (totalTokensUsed stays 0). Warn once
765
+ // per session so operators can diagnose "budget not working" instead of
766
+ // silently burning tokens. Skip when no budget is configured (irrelevant).
767
+ if (entry[1].tokenBudget && !noUsageWarned.has(sessionKey)) {
768
+ noUsageWarned.add(sessionKey);
769
+ (0, logger_1.warn)(`[autopilot] llm_output: host did not report token usage for session=${sessionKey} but a tokenBudget is configured — budget enforcement is a no-op until the host reports usage`);
770
+ }
594
771
  return;
772
+ }
595
773
  // H4: Guard against NaN / negative / non-finite token counts
596
774
  const added = typeof usage.total === 'number' && Number.isFinite(usage.total) && usage.total >= 0
597
775
  ? usage.total : 0;
@@ -622,15 +800,20 @@ function register(api) {
622
800
  return;
623
801
  const entry = findRunBySession(sessionKey);
624
802
  if (entry) {
625
- const [runId] = entry;
803
+ const [runId, state] = entry;
804
+ // S8: release the audit monitor refCount for still-running runs before
805
+ // deleting state, mirroring cleanupAll. A leak here pins monitor mode.
806
+ if (state.status === 'running')
807
+ setAuditMode('active');
626
808
  stateByRun.delete(runId);
627
809
  sessionKeyToRunId.delete(sessionKey);
628
810
  canaryFired.delete(sessionKey);
811
+ noUsageWarned.delete(sessionKey);
629
812
  }
630
813
  (0, logger_1.log)(`[autopilot] session_end: session=${sessionKey} state cleaned up`);
631
814
  });
632
- registerHook('agent_end', async (event) => {
633
- const sessionKey = event.sessionKey ?? sessionIdToKey.get(event.sessionId);
815
+ registerHook('agent_end', async (event, ctx) => {
816
+ const sessionKey = resolveSessionKey(event, ctx);
634
817
  if (!sessionKey)
635
818
  return;
636
819
  const entry = findRunBySession(sessionKey);
@@ -660,7 +843,7 @@ function register(api) {
660
843
  text: (0, continuation_engine_1.buildRetryInstruction)(continued),
661
844
  idempotencyKey: `autopilot-degraded-${sessionKey}-${continued.totalContinuations}`,
662
845
  placement: 'prepend_context',
663
- ttlMs: 300000,
846
+ ttlMs: workflow_config_1.DEFAULT_WORKFLOW_CONFIG.stallTimeoutMs,
664
847
  });
665
848
  if (injectResult && typeof injectResult === 'object' && injectResult.enqueued === false) {
666
849
  (0, logger_1.warn)(`[autopilot] agent_end: degraded fallback enqueue rejected for session=${sessionKey}`);
@@ -728,7 +911,7 @@ function register(api) {
728
911
  return undefined;
729
912
  const entry = findRunBySession(ctx.sessionKey);
730
913
  if (entry)
731
- return (0, projection_1.projectState)(entry[1]);
914
+ return (0, projection_1.projectState)(entry[1], config);
732
915
  return {
733
916
  status: 'idle',
734
917
  enabled: false,
@@ -746,8 +929,17 @@ function register(api) {
746
929
  cleanup: (ctx) => {
747
930
  if (ctx.sessionKey) {
748
931
  const entry = findRunBySession(ctx.sessionKey);
749
- if (entry)
932
+ if (entry) {
933
+ // S8: release audit refCount for still-running runs before delete.
934
+ if (entry[1].status === 'running')
935
+ setAuditMode('active');
750
936
  stateByRun.delete(entry[0]);
937
+ }
938
+ // PROD-2: clear the reverse-index and canary set too, else session-key
939
+ // teardown (without a session_end) leaves dangling map entries.
940
+ sessionKeyToRunId.delete(ctx.sessionKey);
941
+ canaryFired.delete(ctx.sessionKey);
942
+ noUsageWarned.delete(ctx.sessionKey);
751
943
  }
752
944
  },
753
945
  });
@@ -769,6 +961,9 @@ function register(api) {
769
961
  const payloadMaxContinuations = ctx.maxTotalContinuations;
770
962
  const payloadWorkspacePath = validateWorkspacePath(ctx.workspacePath);
771
963
  const payloadTokenBudget = typeof ctx.tokenBudget === 'number' && ctx.tokenBudget > 0 ? ctx.tokenBudget : undefined;
964
+ // S1-residual A: per-activate opt-in to execute workspace-sourced validation
965
+ // commands. Undefined → fall back to plugin config; both undefined → false.
966
+ const payloadTrustWorkspace = typeof ctx.trustWorkspace === 'boolean' ? ctx.trustWorkspace : undefined;
772
967
  // Concurrency guard: count sessions with status === 'running'
773
968
  const maxConcurrent = config.maxConcurrentAutopilot ?? 5;
774
969
  const runningCount = Array.from(stateByRun.values()).filter(s => s.status === 'running').length;
@@ -791,21 +986,42 @@ function register(api) {
791
986
  };
792
987
  // GAP-6: Load workflow config from WORKFLOW.md
793
988
  const applyWorkflowConfig = (s) => {
989
+ // S1-residual A: workspace-sourced validation commands (WORKFLOW.md +
990
+ // auto-detected `npm test` / `node …`) are NOT executed unless the operator
991
+ // trusts this workspace. Untrusted → commands empty + warning. This is the
992
+ // root-cause boundary: the binary allowlist cannot stop `npm run <script>` /
993
+ // `node evil.js` when the workspace owns the script.
994
+ const trustWorkspace = payloadTrustWorkspace ?? config.trustWorkspace ?? false;
794
995
  try {
795
996
  // Use payloadWorkspacePath (validated in outer scope by validateWorkspacePath) rather than
796
997
  // re-reading ctx.workspacePath raw — prevents path-traversal via WORKFLOW.md loading.
797
998
  const result = (0, workflow_config_1.loadWorkflowConfig)(process.cwd(), payloadWorkspacePath);
798
- // R-3: Auto-fill validation commands when WORKFLOW.md has none.
799
- // Only auto-detect when an explicit workspace path is provided (not cwd fallback)
800
- // to avoid running project tests in unexpected directories during tests/CI.
801
- const autoCommands = result.config.validation.commands.length === 0 && payloadWorkspacePath
802
- ? (0, project_detector_1.detectValidationCommands)(payloadWorkspacePath)
803
- : result.config.validation.commands;
999
+ let commands = result.config.validation.commands;
1000
+ if (!trustWorkspace) {
1001
+ commands = [];
1002
+ }
1003
+ else if (commands.length === 0 && payloadWorkspacePath) {
1004
+ // R-3: Auto-fill validation commands when WORKFLOW.md has none.
1005
+ // Only auto-detect when an explicit workspace path is provided AND trusted
1006
+ // (not cwd fallback) to avoid running project tests in unexpected dirs.
1007
+ commands = (0, project_detector_1.detectValidationCommands)(payloadWorkspacePath);
1008
+ }
1009
+ const warnings = trustWorkspace
1010
+ ? result.warnings
1011
+ : [...result.warnings, 'untrusted workspace — validation commands + model_routing disabled (enable via trustWorkspace:true)'];
1012
+ // L1: an untrusted workspace must not influence model selection either.
1013
+ // validation.commands are cleared above (RCE boundary); model_routing
1014
+ // from the same WORKFLOW.md is dropped here so an attacker-controlled
1015
+ // workspace cannot force a different/cheaper model tier. Plugin-level
1016
+ // modelRouting (operator config) still applies regardless.
1017
+ const { modelRouting: _ignoredModelRouting, ...configWithoutRouting } = result.config;
1018
+ const trustedConfig = trustWorkspace ? result.config : configWithoutRouting;
804
1019
  return {
805
1020
  ...s,
806
1021
  workflow: {
807
- ...result.config,
808
- validation: { ...result.config.validation, commands: autoCommands },
1022
+ ...trustedConfig,
1023
+ validation: { ...result.config.validation, commands },
1024
+ warnings,
809
1025
  },
810
1026
  maxTotalContinuations: s.maxTotalContinuations,
811
1027
  };
@@ -826,13 +1042,23 @@ function register(api) {
826
1042
  // A stuck run would otherwise block every future activation until a
827
1043
  // gateway restart, because the stall handler leaves status='running'.
828
1044
  // Genuinely-active runs (recent activity) still fall through to reject.
829
- const stuckRecovery = (0, autopilot_state_1.isRunStuck)(state, Date.now(), config.tokenBudget ? 300_000 : 600_000);
1045
+ // M1: use the run's own stallTimeoutMs if configured, not just the global default.
1046
+ const stuckStallMs = state.workflow?.stallTimeoutMs
1047
+ ?? defaultStallTimeoutMs(!!config.tokenBudget);
1048
+ const stuckRecovery = (0, autopilot_state_1.isRunStuck)(state, Date.now(), stuckStallMs);
830
1049
  if (state.status === 'idle' || state.status === 'done' || stuckRecovery) {
831
1050
  if (stuckRecovery) {
832
1051
  (0, logger_1.warn)(`[autopilot] activate: recovering stuck session=${sessionKey} (status=${state.status}, orchState=${state.orchestrationState ?? 'none'}) — discarding stale run ${oldRunId}`);
833
1052
  }
834
- // Release audit monitor for the old run before discarding it.
835
- setAuditMode('active');
1053
+ // Release audit monitor refcount ONLY if the old run still holds one.
1054
+ // Per GAP-23 invariant: only status==='running' runs hold an unreleased
1055
+ // refCount (acquire on activate, release on pause/complete/stop). The
1056
+ // stuckRecovery branch is the only one where oldRunId may still be
1057
+ // 'running'. idle/done runs were already released at complete/stop —
1058
+ // releasing again would over-release the shared audit refcount (S8).
1059
+ if (stuckRecovery) {
1060
+ setAuditMode('active');
1061
+ }
836
1062
  stateByRun.delete(oldRunId);
837
1063
  sessionKeyToRunId.delete(sessionKey);
838
1064
  const runId = generateRunId();
@@ -850,7 +1076,7 @@ function register(api) {
850
1076
  });
851
1077
  setState(runId, newState);
852
1078
  sessionKeyToRunId.set(sessionKey, runId);
853
- (0, logger_1.log)(`[autopilot] activate: session=${sessionKey} new run=${runId} (was ${state.status}, goal=${goalForEvent ?? 'none'})`);
1079
+ (0, logger_1.log)(`[autopilot] activate: session=${sessionKey} new run=${runId} (was ${state.status}, goalLen=${goalForEvent?.length ?? 0})`);
854
1080
  }
855
1081
  else {
856
1082
  (0, logger_1.warn)(`[autopilot] activate rejected: session=${sessionKey} status=${state.status}`);
@@ -859,7 +1085,7 @@ function register(api) {
859
1085
  }
860
1086
  }
861
1087
  else {
862
- const runId = `run-${Math.random().toString(36).slice(2, 10)}`;
1088
+ const runId = generateRunId();
863
1089
  let state = (0, autopilot_state_1.activate)((0, types_1.createInitialState)(sessionKey, runId, config));
864
1090
  state = (0, orchestrator_1.orchestratorReducer)(state, { type: 'activate_requested', sessionKey, goal: payloadGoal, now: Date.now() });
865
1091
  state = applyPayload(state);
@@ -897,7 +1123,11 @@ function register(api) {
897
1123
  }
898
1124
  // M2: Dispatch resume_requested through orchestrator reducer
899
1125
  const orchestrated = (0, orchestrator_1.orchestratorReducer)(state, { type: 'resume_requested', runId, now: Date.now() });
900
- setState(runId, (0, autopilot_state_1.resume)(orchestrated));
1126
+ const resumed = (0, autopilot_state_1.resume)(orchestrated);
1127
+ setState(runId, resumed);
1128
+ // LOGIC-4: a programmatic resume has no follow-up user message to trigger
1129
+ // agent_turn_prepare, so kick a cross-turn injection to actually continue.
1130
+ kickResumedTurn(runId, resumed);
901
1131
  (0, logger_1.log)(`[autopilot] resume: session=${sessionKey} paused→running, errors reset`);
902
1132
  // Re-acquire audit monitor mode on resume.
903
1133
  setAuditMode('monitor');
@@ -928,7 +1158,7 @@ function register(api) {
928
1158
  api.registerGatewayMethod('autopilot.status', async ({ params: ctx, respond }) => {
929
1159
  const sessionKey = ctx.sessionKey;
930
1160
  const entry = sessionKey ? findRunBySession(sessionKey) : undefined;
931
- const projection = entry ? (0, projection_1.projectState)(entry[1]) : undefined;
1161
+ const projection = entry ? (0, projection_1.projectState)(entry[1], config) : undefined;
932
1162
  // Also expose raw state fields not in projection (progress, permissionAudit)
933
1163
  const state = entry ? entry[1] : undefined;
934
1164
  respond(true, {
@@ -960,7 +1190,7 @@ function register(api) {
960
1190
  }
961
1191
  const [runId, state] = entry;
962
1192
  setState(runId, (0, autopilot_state_1.setGoal)(state, goal));
963
- (0, logger_1.log)(`[autopilot] setGoal: session=${sessionKey} goal="${goal.substring(0, 80)}"`);
1193
+ (0, logger_1.log)(`[autopilot] setGoal: session=${sessionKey} goalLen=${goal.length}`);
964
1194
  respond(true, { ok: true });
965
1195
  });
966
1196
  // GAP-23: Cleanup action for graceful shutdown
@@ -974,8 +1204,7 @@ function register(api) {
974
1204
  // Periodically check all active runs for stall (no activity for stallTimeoutMs).
975
1205
  // When a stall is detected, dispatch stall_timeout through the orchestrator reducer.
976
1206
  const stallCheckIntervalMs = 60_000; // Check every 60 seconds
977
- const stallTimeoutMs = config.tokenBudget ? 300_000 : 600_000; // 5min or 10min default
978
- // M-7: Clear previous interval before creating new one (HMR / double-register safety)
1207
+ // M-7: Clear previous interval before creating a new one (HMR / double-register safety)
979
1208
  if (stallInterval) {
980
1209
  clearInterval(stallInterval);
981
1210
  stallInterval = null;
@@ -984,8 +1213,13 @@ function register(api) {
984
1213
  const now = Date.now();
985
1214
  const orphanRunIds = [];
986
1215
  for (const [runId, state] of stateByRun.entries()) {
987
- // GAP-4: Stall detection for active runs
988
- if (state.enabled && state.status === 'running' && state.orchestrationState === 'running') {
1216
+ // GAP-4: Stall detection for active runs.
1217
+ // M1 fix: read stallTimeoutMs PER-RUN from the run's workflow config, not
1218
+ // the global default. An operator setting `stall_timeout_ms` in WORKFLOW.md
1219
+ // expects it to take effect; the global fallback was silently ignoring it.
1220
+ const stallTimeoutMs = state.workflow?.stallTimeoutMs
1221
+ ?? defaultStallTimeoutMs(!!config.tokenBudget);
1222
+ if (state.enabled && state.status === 'running' && (state.orchestrationState === 'running' || state.orchestrationState === 'claimed')) {
989
1223
  const stallResult = (0, stall_detector_1.checkStall)({
990
1224
  lastActivityAt: state.lastActivityAt ?? state.startedAt ?? now,
991
1225
  now,
@@ -1011,6 +1245,10 @@ function register(api) {
1011
1245
  now,
1012
1246
  });
1013
1247
  setState(runId, updated);
1248
+ // PROD-7: a claimed run cannot self-start a turn — kick one so a genuinely
1249
+ // dead agent restarts instead of sitting in claimed until the 24h sweep.
1250
+ if (updated.orchestrationState === 'claimed')
1251
+ kickResumedTurn(runId, updated);
1014
1252
  (0, logger_1.log)(`[autopilot] retry_due: session=${state.sessionKey} run=${runId} attempt=${state.retry?.attempt ?? 1}`);
1015
1253
  }
1016
1254
  // GAP-26: Health check — detect orphaned sessions (no activity for 24h)
@@ -1024,11 +1262,18 @@ function register(api) {
1024
1262
  const state = stateByRun.get(runId);
1025
1263
  if (state) {
1026
1264
  (0, logger_1.warn)(`[autopilot] health check: cleaning orphaned session=${state.sessionKey} run=${runId}`);
1265
+ // S8: release audit refCount for orphaned still-running runs before delete.
1266
+ if (state.status === 'running')
1267
+ setAuditMode('active');
1027
1268
  stateByRun.delete(runId);
1028
1269
  sessionKeyToRunId.delete(state.sessionKey);
1029
1270
  canaryFired.delete(state.sessionKey);
1271
+ noUsageWarned.delete(state.sessionKey);
1030
1272
  }
1031
1273
  }
1032
1274
  }, stallCheckIntervalMs);
1275
+ // PROD-6: don't let the stall timer keep the event loop alive if the host
1276
+ // exits without calling cleanup — unref so the process can drain naturally.
1277
+ stallInterval?.unref?.();
1033
1278
  }
1034
1279
  //# sourceMappingURL=index.js.map