@oh-my-matrix/autopilot 2.1.2 → 3.0.0
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/README.md +105 -10
- package/dist/index.d.ts +1 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +308 -92
- package/dist/index.js.map +1 -1
- package/dist/src/autopilot-state.d.ts +17 -0
- package/dist/src/autopilot-state.d.ts.map +1 -1
- package/dist/src/autopilot-state.js +52 -8
- package/dist/src/autopilot-state.js.map +1 -1
- package/dist/src/command-runner.d.ts +2 -6
- package/dist/src/command-runner.d.ts.map +1 -1
- package/dist/src/command-runner.js +4 -34
- package/dist/src/command-runner.js.map +1 -1
- package/dist/src/completion-detector.d.ts.map +1 -1
- package/dist/src/completion-detector.js +16 -8
- package/dist/src/completion-detector.js.map +1 -1
- package/dist/src/effort-injection.d.ts +21 -2
- package/dist/src/effort-injection.d.ts.map +1 -1
- package/dist/src/effort-injection.js +33 -9
- package/dist/src/effort-injection.js.map +1 -1
- package/dist/src/event-shape.contract.d.ts +21 -0
- package/dist/src/event-shape.contract.d.ts.map +1 -0
- package/dist/src/event-shape.contract.js +10 -0
- package/dist/src/event-shape.contract.js.map +1 -0
- package/dist/src/logger.d.ts.map +1 -1
- package/dist/src/logger.js +42 -7
- package/dist/src/logger.js.map +1 -1
- package/dist/src/model-routing.d.ts +36 -0
- package/dist/src/model-routing.d.ts.map +1 -0
- package/dist/src/model-routing.js +106 -0
- package/dist/src/model-routing.js.map +1 -0
- package/dist/src/orchestrator.d.ts +34 -1
- package/dist/src/orchestrator.d.ts.map +1 -1
- package/dist/src/orchestrator.js +113 -12
- package/dist/src/orchestrator.js.map +1 -1
- package/dist/src/projection.d.ts +8 -2
- package/dist/src/projection.d.ts.map +1 -1
- package/dist/src/projection.js +15 -1
- package/dist/src/projection.js.map +1 -1
- package/dist/src/stall-detector.d.ts +4 -1
- package/dist/src/stall-detector.d.ts.map +1 -1
- package/dist/src/stall-detector.js +7 -3
- package/dist/src/stall-detector.js.map +1 -1
- package/dist/src/tool-error-tracker.d.ts +16 -0
- package/dist/src/tool-error-tracker.d.ts.map +1 -1
- package/dist/src/tool-error-tracker.js +16 -0
- package/dist/src/tool-error-tracker.js.map +1 -1
- package/dist/src/types.d.ts +67 -5
- package/dist/src/types.d.ts.map +1 -1
- package/dist/src/types.js +45 -1
- package/dist/src/types.js.map +1 -1
- package/dist/src/workflow-config.d.ts.map +1 -1
- package/dist/src/workflow-config.js +108 -14
- package/dist/src/workflow-config.js.map +1 -1
- package/openclaw.plugin.json +34 -2
- package/package.json +23 -13
- package/LICENSE +0 -21
- package/dist/src/audit-persister.d.ts +0 -25
- package/dist/src/audit-persister.d.ts.map +0 -1
- package/dist/src/audit-persister.js +0 -162
- package/dist/src/audit-persister.js.map +0 -1
- package/dist/src/permission-policy.d.ts +0 -36
- package/dist/src/permission-policy.d.ts.map +0 -1
- package/dist/src/permission-policy.js +0 -265
- 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
|
-
|
|
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
|
|
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
|
|
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
|
-
|
|
175
|
-
|
|
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
|
|
204
|
-
//
|
|
205
|
-
|
|
206
|
-
|
|
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,43 @@ function findRunBySession(sessionKey) {
|
|
|
221
284
|
const state = stateByRun.get(runId);
|
|
222
285
|
return state ? [runId, state] : undefined;
|
|
223
286
|
}
|
|
287
|
+
// ponytail: production passes sessionKey on ctx, test mocks put it on event — one helper handles both
|
|
288
|
+
function resolveSessionKey(event, ctx) {
|
|
289
|
+
const c = ctx;
|
|
290
|
+
const e = event;
|
|
291
|
+
const sessionId = c?.sessionId ?? e?.sessionId;
|
|
292
|
+
return c?.sessionKey ?? e?.sessionKey ?? (sessionId != null ? sessionIdToKey.get(sessionId) : undefined);
|
|
293
|
+
}
|
|
294
|
+
const CROSS_TURN_FALLBACK_TEXT = 'Continue from where you left off.';
|
|
295
|
+
function buildCrossTurnReviseFallback(runId, state, instruction) {
|
|
296
|
+
const fallbackState = { ...(0, autopilot_state_1.incrementTotal)(state), turnAttempts: 0 };
|
|
297
|
+
setState(runId, fallbackState);
|
|
298
|
+
return {
|
|
299
|
+
action: 'revise',
|
|
300
|
+
retry: {
|
|
301
|
+
instruction: instruction || CROSS_TURN_FALLBACK_TEXT,
|
|
302
|
+
idempotencyKey: `autopilot-${runId}-${fallbackState.totalContinuations}`,
|
|
303
|
+
maxAttempts: fallbackState.maxAttemptsPerTurn,
|
|
304
|
+
},
|
|
305
|
+
};
|
|
306
|
+
}
|
|
224
307
|
/** Generate a unique run ID using crypto.randomUUID (exported for testing). */
|
|
225
308
|
function _generateRunIdForTest() {
|
|
226
309
|
return `run-${crypto.randomUUID()}`;
|
|
227
310
|
}
|
|
228
311
|
function generateRunId() {
|
|
229
|
-
return
|
|
312
|
+
return `run-${crypto.randomUUID()}`;
|
|
230
313
|
}
|
|
231
314
|
function register(api) {
|
|
315
|
+
// PROD-7 / LOGIC-4: stash the cross-turn injection actuator so the stall
|
|
316
|
+
// interval and gateway resume (both outside per-call closures) can restart a
|
|
317
|
+
// claimed run. Optional-chained: undefined on hosts without the 5.28+ facade.
|
|
318
|
+
enqueueInjectionFn = api.session?.workflow?.enqueueNextTurnInjection;
|
|
232
319
|
// Read user config from OpenClaw, merge with defaults
|
|
233
320
|
// pluginConfig is Record<string, unknown> — coerce values to expected types
|
|
234
321
|
const uc = api.pluginConfig ?? {};
|
|
235
322
|
const numOrUndefined = (v) => typeof v === 'number' ? v : undefined;
|
|
323
|
+
const modelRouting = (0, model_routing_1.parseModelRouting)(uc.modelRouting);
|
|
236
324
|
const config = {
|
|
237
325
|
...types_1.DEFAULT_CONFIG,
|
|
238
326
|
...(numOrUndefined(uc.maxAttemptsPerTurn) != null ? { maxAttemptsPerTurn: numOrUndefined(uc.maxAttemptsPerTurn) } : {}),
|
|
@@ -242,6 +330,11 @@ function register(api) {
|
|
|
242
330
|
...(Array.isArray(uc.highRiskTools) ? { highRiskTools: uc.highRiskTools } : {}),
|
|
243
331
|
...(numOrUndefined(uc.tokenBudget) != null ? { tokenBudget: numOrUndefined(uc.tokenBudget) } : {}),
|
|
244
332
|
...(numOrUndefined(uc.maxConcurrentAutopilot) != null ? { maxConcurrentAutopilot: numOrUndefined(uc.maxConcurrentAutopilot) } : {}),
|
|
333
|
+
...(typeof uc.thinkingIntensity === 'string' && ['low', 'medium', 'high'].includes(uc.thinkingIntensity)
|
|
334
|
+
? { thinkingIntensity: uc.thinkingIntensity }
|
|
335
|
+
: {}),
|
|
336
|
+
...(modelRouting ? { modelRouting } : {}),
|
|
337
|
+
...(typeof uc.trustWorkspace === 'boolean' ? { trustWorkspace: uc.trustWorkspace } : {}),
|
|
245
338
|
};
|
|
246
339
|
(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
340
|
// --- Hooks (use api.on for typed hooks when available, registerHook as fallback) ---
|
|
@@ -251,7 +344,7 @@ function register(api) {
|
|
|
251
344
|
return;
|
|
252
345
|
}
|
|
253
346
|
registerHook('before_agent_finalize', async (event) => {
|
|
254
|
-
const sessionKey =
|
|
347
|
+
const sessionKey = resolveSessionKey(event);
|
|
255
348
|
if (sessionKey)
|
|
256
349
|
canaryFired.add(sessionKey);
|
|
257
350
|
if (!sessionKey)
|
|
@@ -273,6 +366,15 @@ function register(api) {
|
|
|
273
366
|
});
|
|
274
367
|
(0, logger_1.log)(`[autopilot] before_agent_finalize: session=${sessionKey} action=${decision.action} turn=${state.turnAttempts}/${state.maxAttemptsPerTurn} total=${state.totalContinuations}/${state.maxTotalContinuations}`);
|
|
275
368
|
switch (decision.action) {
|
|
369
|
+
case 'finalize': {
|
|
370
|
+
// S3 (audit 2026-06-30): decideContinuation returns 'finalize' when the
|
|
371
|
+
// run is disabled/non-running, or when stopHookActive is set (user hit
|
|
372
|
+
// stop). Previously this fell through to default and was silently
|
|
373
|
+
// rewritten to 'continue', leaving status='running' so stall/agent_end
|
|
374
|
+
// could revive a run the user had asked to stop. Match pause/complete:
|
|
375
|
+
// emit {action:'finalize'} so the host stops injecting revisions.
|
|
376
|
+
return { action: 'finalize' };
|
|
377
|
+
}
|
|
276
378
|
case 'revise': {
|
|
277
379
|
const updated = (0, autopilot_state_1.incrementTotal)((0, autopilot_state_1.incrementTurn)(state));
|
|
278
380
|
setState(runId, updated);
|
|
@@ -295,49 +397,21 @@ function register(api) {
|
|
|
295
397
|
text: decision.retryInstruction || 'Continue from where you left off.',
|
|
296
398
|
idempotencyKey: `autopilot-cross-${sessionKey}-${updated.totalContinuations}`,
|
|
297
399
|
placement: 'prepend_context',
|
|
298
|
-
ttlMs:
|
|
400
|
+
ttlMs: workflow_config_1.DEFAULT_WORKFLOW_CONFIG.stallTimeoutMs,
|
|
299
401
|
});
|
|
300
402
|
if (result && typeof result === 'object' && result.enqueued === false) {
|
|
301
403
|
(0, logger_1.warn)(`[autopilot] enqueueNextTurnInjection rejected for session=${sessionKey}, falling back to revise`);
|
|
302
|
-
|
|
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
|
-
};
|
|
404
|
+
return buildCrossTurnReviseFallback(runId, state, decision.retryInstruction);
|
|
312
405
|
}
|
|
313
406
|
setState(runId, updated);
|
|
314
407
|
return { action: 'finalize' };
|
|
315
408
|
}
|
|
316
409
|
catch (err) {
|
|
317
410
|
(0, logger_1.warn)(`[autopilot] enqueueNextTurnInjection failed for session=${sessionKey}: ${err}, falling back to revise`);
|
|
318
|
-
|
|
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
|
-
};
|
|
411
|
+
return buildCrossTurnReviseFallback(runId, state, decision.retryInstruction);
|
|
328
412
|
}
|
|
329
413
|
}
|
|
330
|
-
|
|
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
|
-
};
|
|
414
|
+
return buildCrossTurnReviseFallback(runId, state, decision.retryInstruction);
|
|
341
415
|
}
|
|
342
416
|
case 'pause': {
|
|
343
417
|
setState(runId, (0, autopilot_state_1.pause)(state, decision.pauseReason));
|
|
@@ -363,14 +437,24 @@ function register(api) {
|
|
|
363
437
|
});
|
|
364
438
|
}
|
|
365
439
|
catch (err) {
|
|
366
|
-
// Fail-open
|
|
367
|
-
|
|
440
|
+
// Fail-open (skipped) to avoid zombie sessions — runValidationCommands
|
|
441
|
+
// never throws and evaluateEvidence is pure, so this only trips on a
|
|
442
|
+
// future bug. But do NOT let that bug pass silently: emit it at error
|
|
443
|
+
// level with the failureReason so a monitor can tell an evaluation
|
|
444
|
+
// failure apart from a normal no-commands skip (both end as 'skipped').
|
|
445
|
+
(0, logger_1.logWithContext)('error', 'evidence gate evaluation error (failing open → skipped)', { sessionKey, runId, error: String(err) });
|
|
368
446
|
evidenceSummary = { status: 'skipped', diffSummary: '', commands: [], completedAt: now, failureReason: 'evaluation error' };
|
|
369
447
|
}
|
|
370
|
-
// Dispatch
|
|
448
|
+
// Dispatch orchestrator events to advance orchState to 'done'.
|
|
449
|
+
// Close the agent turn (running → released) so evidence events can fire.
|
|
371
450
|
let updated = state;
|
|
372
|
-
if (
|
|
373
|
-
updated = (0, orchestrator_1.orchestratorReducer)(
|
|
451
|
+
if (updated.orchestrationState === 'running') {
|
|
452
|
+
updated = (0, orchestrator_1.orchestratorReducer)(updated, {
|
|
453
|
+
type: 'agent_turn_finished', runId, success: true, now,
|
|
454
|
+
});
|
|
455
|
+
}
|
|
456
|
+
if (updated.orchestrationState === 'released') {
|
|
457
|
+
updated = (0, orchestrator_1.orchestratorReducer)(updated, { type: 'evidence_started', runId, now });
|
|
374
458
|
updated = (0, orchestrator_1.orchestratorReducer)(updated, {
|
|
375
459
|
type: 'evidence_finished',
|
|
376
460
|
runId,
|
|
@@ -378,13 +462,29 @@ function register(api) {
|
|
|
378
462
|
evidence: evidenceSummary,
|
|
379
463
|
});
|
|
380
464
|
}
|
|
381
|
-
// Apply evidence to state
|
|
382
|
-
//
|
|
383
|
-
//
|
|
384
|
-
|
|
465
|
+
// Apply evidence result to state. The decision branches on evidence outcome:
|
|
466
|
+
// passed/skipped → mark the run done; failed → retry or block (NOT done).
|
|
467
|
+
// Before this fix (H1), the code checked `updated.status === 'done'`, but the
|
|
468
|
+
// reducer leaves status='running' on evidence FAILURE, so failed evidence fell
|
|
469
|
+
// into complete() — producing a false 'done' + enabled:false, which stranded
|
|
470
|
+
// the run (the stall interval's retry_due guard checks state.enabled).
|
|
471
|
+
// See docs/audits/autopilot-correctness-review-2026-07-04.md HIGH finding.
|
|
472
|
+
setState(runId,
|
|
473
|
+
// failed → the reducer moved to retry_queued (will retry) or blocked (max
|
|
474
|
+
// retries). Keep the run enabled in retry_queued so the stall interval can
|
|
475
|
+
// fire retry_due. When blocked, the reducer already set orchState='blocked'
|
|
476
|
+
// + blockedReason + derived status='paused' — do NOT call pause() (it would
|
|
477
|
+
// throw: pause() requires status='running' but the reducer derived 'paused').
|
|
478
|
+
// Just attach the evidence summary; the state is already correct.
|
|
479
|
+
evidenceSummary.status === 'failed'
|
|
385
480
|
? { ...updated, evidence: evidenceSummary }
|
|
386
|
-
|
|
387
|
-
|
|
481
|
+
// passed/skipped → reducer already set orchState='done' + status='done'
|
|
482
|
+
// when the evidence reducer ran; if it didn't run (orchState unchanged),
|
|
483
|
+
// complete() advances status to done. Either way the run finishes.
|
|
484
|
+
: updated.status === 'done'
|
|
485
|
+
? { ...updated, evidence: evidenceSummary }
|
|
486
|
+
: (0, autopilot_state_1.complete)({ ...updated, evidence: evidenceSummary }));
|
|
487
|
+
(0, logger_1.logWithContext)('info', 'evidence gate result', { sessionKey, runId, evidenceStatus: evidenceSummary.status, failureReason: evidenceSummary.failureReason });
|
|
388
488
|
// Release audit monitor when task completes — session is done, no more tool calls needed.
|
|
389
489
|
setAuditMode('active');
|
|
390
490
|
return { action: 'finalize' };
|
|
@@ -393,8 +493,8 @@ function register(api) {
|
|
|
393
493
|
return { action: 'continue' };
|
|
394
494
|
}
|
|
395
495
|
});
|
|
396
|
-
registerHook('after_tool_call', (event) => {
|
|
397
|
-
const sessionKey = event
|
|
496
|
+
registerHook('after_tool_call', (event, ctx) => {
|
|
497
|
+
const sessionKey = resolveSessionKey(event, ctx);
|
|
398
498
|
if (!sessionKey)
|
|
399
499
|
return;
|
|
400
500
|
const entry = findRunBySession(sessionKey);
|
|
@@ -419,10 +519,10 @@ function register(api) {
|
|
|
419
519
|
error: (event.error ?? '').substring(0, 200),
|
|
420
520
|
});
|
|
421
521
|
setState(runId, withError);
|
|
422
|
-
(0, logger_1.
|
|
522
|
+
(0, logger_1.warn)(`[autopilot] after_tool_call error: session=${sessionKey} tool=${event.toolName} errCount=${withError.toolErrorCount}/${state.toolErrorThreshold}`);
|
|
423
523
|
});
|
|
424
|
-
registerHook('before_compaction', (
|
|
425
|
-
const sessionKey =
|
|
524
|
+
registerHook('before_compaction', (_event, ctx) => {
|
|
525
|
+
const sessionKey = resolveSessionKey(_event, ctx);
|
|
426
526
|
if (!sessionKey)
|
|
427
527
|
return;
|
|
428
528
|
const entry = findRunBySession(sessionKey);
|
|
@@ -431,8 +531,8 @@ function register(api) {
|
|
|
431
531
|
setState(entry[0], (0, goal_manager_1.preserveGoalBeforeCompaction)(entry[1]));
|
|
432
532
|
}
|
|
433
533
|
});
|
|
434
|
-
registerHook('after_compaction', (
|
|
435
|
-
const sessionKey =
|
|
534
|
+
registerHook('after_compaction', (_event, ctx) => {
|
|
535
|
+
const sessionKey = resolveSessionKey(_event, ctx);
|
|
436
536
|
if (!sessionKey)
|
|
437
537
|
return;
|
|
438
538
|
const entry = findRunBySession(sessionKey);
|
|
@@ -442,7 +542,7 @@ function register(api) {
|
|
|
442
542
|
}
|
|
443
543
|
});
|
|
444
544
|
registerHook('agent_turn_prepare', (event, ctx) => {
|
|
445
|
-
const sessionKey = ctx?.sessionKey
|
|
545
|
+
const sessionKey = ctx?.sessionKey;
|
|
446
546
|
if (!sessionKey)
|
|
447
547
|
return;
|
|
448
548
|
const entry = findRunBySession(sessionKey);
|
|
@@ -456,6 +556,9 @@ function register(api) {
|
|
|
456
556
|
runId,
|
|
457
557
|
now: Date.now(),
|
|
458
558
|
});
|
|
559
|
+
// Persist the orchState transition (claimed → running) immediately.
|
|
560
|
+
if (updated !== state)
|
|
561
|
+
setState(runId, updated);
|
|
459
562
|
// Capture goal from first user prompt if not already set
|
|
460
563
|
if (!updated.goal && event.prompt) {
|
|
461
564
|
updated = (0, goal_manager_1.captureGoal)(updated, event.prompt);
|
|
@@ -489,14 +592,46 @@ function register(api) {
|
|
|
489
592
|
}
|
|
490
593
|
if (parts.length === 0)
|
|
491
594
|
return;
|
|
492
|
-
// Effort injection:
|
|
493
|
-
const
|
|
595
|
+
// Effort injection: graduated intensity by execution phase (TD-1)
|
|
596
|
+
const intensity = (0, effort_injection_1.resolveThinkingIntensity)(updated.totalContinuations, updated.evidence?.status, config.thinkingIntensity);
|
|
597
|
+
const effortCtx = (0, effort_injection_1.buildEffortInjection)(updated.status, intensity);
|
|
494
598
|
if (effortCtx)
|
|
495
599
|
parts.push(effortCtx);
|
|
496
600
|
// Completion awareness instruction
|
|
497
601
|
parts.push('[Autopilot] When all tasks are complete, explicitly state "All tasks completed".');
|
|
498
602
|
return { appendContext: parts.join('\n') };
|
|
499
603
|
});
|
|
604
|
+
// Model routing: override model per execution phase. Consumed by Gateway via
|
|
605
|
+
// before_model_resolve -> { modelOverride }. No modelIds => no override (inherit).
|
|
606
|
+
// Read-only: state is read without a lock while other hooks mutate it — a turn
|
|
607
|
+
// straddling an evidence-status transition may pick the wrong tier for one turn.
|
|
608
|
+
// Acceptable for a routing heuristic (no data loss, self-corrects next turn).
|
|
609
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
610
|
+
registerHook('before_model_resolve', (_event, ctx) => {
|
|
611
|
+
const sessionKey = ctx?.sessionKey;
|
|
612
|
+
if (!sessionKey)
|
|
613
|
+
return;
|
|
614
|
+
// Find the autopilot run: direct, or via parent session for subagents
|
|
615
|
+
// (subagent keys: agent:<main>:subagent:<sub>).
|
|
616
|
+
const direct = findRunBySession(sessionKey);
|
|
617
|
+
const entry = direct
|
|
618
|
+
?? ((0, model_routing_1.isSubagentSession)(sessionKey)
|
|
619
|
+
? findRunBySession((0, model_routing_1.extractParentSessionKey)(sessionKey) ?? '')
|
|
620
|
+
: undefined);
|
|
621
|
+
if (!entry?.[1].enabled || entry[1].status !== 'running')
|
|
622
|
+
return;
|
|
623
|
+
const [, state] = entry;
|
|
624
|
+
// WORKFLOW.md model_routing wins over plugin config when present.
|
|
625
|
+
const routing = state.workflow?.modelRouting ?? modelRouting;
|
|
626
|
+
if (!routing?.modelIds)
|
|
627
|
+
return;
|
|
628
|
+
const tier = (0, model_routing_1.resolveModelTier)(state.totalContinuations, state.evidence?.status, (0, model_routing_1.isSubagentSession)(sessionKey), routing);
|
|
629
|
+
const modelId = (0, model_routing_1.resolveModelId)(tier, routing);
|
|
630
|
+
if (modelId) {
|
|
631
|
+
(0, logger_1.log)(`[autopilot] before_model_resolve: session=${sessionKey} tier=${tier} model=${modelId}`);
|
|
632
|
+
return { modelOverride: modelId };
|
|
633
|
+
}
|
|
634
|
+
});
|
|
500
635
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
501
636
|
registerHook('before_agent_run', (_event, ctx) => {
|
|
502
637
|
const sessionKey = ctx?.sessionKey;
|
|
@@ -556,6 +691,10 @@ function register(api) {
|
|
|
556
691
|
commandClass,
|
|
557
692
|
outcome: decision.outcome,
|
|
558
693
|
reason: decision.reason,
|
|
694
|
+
// F3: persist the per-call working dir so the audit trail is forensically
|
|
695
|
+
// useful — aligns with dynamic-workflows' audit entry. eventCwd already
|
|
696
|
+
// resolved above (params.workdir / cd / git -C); fall back to the workspace.
|
|
697
|
+
cwd: eventCwd ?? state.workspace?.path,
|
|
559
698
|
};
|
|
560
699
|
const MAX_AUDIT = 200;
|
|
561
700
|
const prevAudit = withActivity.permissionAudit ?? [];
|
|
@@ -575,7 +714,7 @@ function register(api) {
|
|
|
575
714
|
// Using requireApproval+timeoutMs:1 was broken — it still walked the approval
|
|
576
715
|
// pipeline which has no handler, causing 10s+ "Approval timed out" errors.
|
|
577
716
|
if (decision.outcome === 'block') {
|
|
578
|
-
(0, logger_1.logWithContext)('
|
|
717
|
+
(0, logger_1.logWithContext)('warn', 'before_tool_call BLOCKED', { sessionKey, runId, toolName, reason: decision.reason });
|
|
579
718
|
return {
|
|
580
719
|
block: true,
|
|
581
720
|
blockReason: decision.message,
|
|
@@ -586,12 +725,29 @@ function register(api) {
|
|
|
586
725
|
const sessionKey = ctx?.sessionKey;
|
|
587
726
|
if (!sessionKey)
|
|
588
727
|
return;
|
|
589
|
-
|
|
728
|
+
// Count subagent tokens toward the PARENT run's budget. before_model_resolve
|
|
729
|
+
// already resolves subagents via their parent (agent:<main>:subagent:<id>);
|
|
730
|
+
// token accounting must match, or a run's tokenBudget under-counts real spend
|
|
731
|
+
// (the budget is enforced at the parent's before_agent_finalize turn boundary).
|
|
732
|
+
const direct = findRunBySession(sessionKey);
|
|
733
|
+
const entry = direct
|
|
734
|
+
?? ((0, model_routing_1.isSubagentSession)(sessionKey)
|
|
735
|
+
? findRunBySession((0, model_routing_1.extractParentSessionKey)(sessionKey) ?? '')
|
|
736
|
+
: undefined);
|
|
590
737
|
if (!entry?.[1].enabled)
|
|
591
738
|
return;
|
|
592
739
|
const usage = event.usage;
|
|
593
|
-
if (!usage?.total)
|
|
740
|
+
if (!usage?.total) {
|
|
741
|
+
// S10: when the host omits usage on a run WITH a configured tokenBudget,
|
|
742
|
+
// the budget silently never enforces (totalTokensUsed stays 0). Warn once
|
|
743
|
+
// per session so operators can diagnose "budget not working" instead of
|
|
744
|
+
// silently burning tokens. Skip when no budget is configured (irrelevant).
|
|
745
|
+
if (entry[1].tokenBudget && !noUsageWarned.has(sessionKey)) {
|
|
746
|
+
noUsageWarned.add(sessionKey);
|
|
747
|
+
(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`);
|
|
748
|
+
}
|
|
594
749
|
return;
|
|
750
|
+
}
|
|
595
751
|
// H4: Guard against NaN / negative / non-finite token counts
|
|
596
752
|
const added = typeof usage.total === 'number' && Number.isFinite(usage.total) && usage.total >= 0
|
|
597
753
|
? usage.total : 0;
|
|
@@ -622,15 +778,20 @@ function register(api) {
|
|
|
622
778
|
return;
|
|
623
779
|
const entry = findRunBySession(sessionKey);
|
|
624
780
|
if (entry) {
|
|
625
|
-
const [runId] = entry;
|
|
781
|
+
const [runId, state] = entry;
|
|
782
|
+
// S8: release the audit monitor refCount for still-running runs before
|
|
783
|
+
// deleting state, mirroring cleanupAll. A leak here pins monitor mode.
|
|
784
|
+
if (state.status === 'running')
|
|
785
|
+
setAuditMode('active');
|
|
626
786
|
stateByRun.delete(runId);
|
|
627
787
|
sessionKeyToRunId.delete(sessionKey);
|
|
628
788
|
canaryFired.delete(sessionKey);
|
|
789
|
+
noUsageWarned.delete(sessionKey);
|
|
629
790
|
}
|
|
630
791
|
(0, logger_1.log)(`[autopilot] session_end: session=${sessionKey} state cleaned up`);
|
|
631
792
|
});
|
|
632
|
-
registerHook('agent_end', async (event) => {
|
|
633
|
-
const sessionKey = event
|
|
793
|
+
registerHook('agent_end', async (event, ctx) => {
|
|
794
|
+
const sessionKey = resolveSessionKey(event, ctx);
|
|
634
795
|
if (!sessionKey)
|
|
635
796
|
return;
|
|
636
797
|
const entry = findRunBySession(sessionKey);
|
|
@@ -660,7 +821,7 @@ function register(api) {
|
|
|
660
821
|
text: (0, continuation_engine_1.buildRetryInstruction)(continued),
|
|
661
822
|
idempotencyKey: `autopilot-degraded-${sessionKey}-${continued.totalContinuations}`,
|
|
662
823
|
placement: 'prepend_context',
|
|
663
|
-
ttlMs:
|
|
824
|
+
ttlMs: workflow_config_1.DEFAULT_WORKFLOW_CONFIG.stallTimeoutMs,
|
|
664
825
|
});
|
|
665
826
|
if (injectResult && typeof injectResult === 'object' && injectResult.enqueued === false) {
|
|
666
827
|
(0, logger_1.warn)(`[autopilot] agent_end: degraded fallback enqueue rejected for session=${sessionKey}`);
|
|
@@ -728,7 +889,7 @@ function register(api) {
|
|
|
728
889
|
return undefined;
|
|
729
890
|
const entry = findRunBySession(ctx.sessionKey);
|
|
730
891
|
if (entry)
|
|
731
|
-
return (0, projection_1.projectState)(entry[1]);
|
|
892
|
+
return (0, projection_1.projectState)(entry[1], config);
|
|
732
893
|
return {
|
|
733
894
|
status: 'idle',
|
|
734
895
|
enabled: false,
|
|
@@ -746,8 +907,17 @@ function register(api) {
|
|
|
746
907
|
cleanup: (ctx) => {
|
|
747
908
|
if (ctx.sessionKey) {
|
|
748
909
|
const entry = findRunBySession(ctx.sessionKey);
|
|
749
|
-
if (entry)
|
|
910
|
+
if (entry) {
|
|
911
|
+
// S8: release audit refCount for still-running runs before delete.
|
|
912
|
+
if (entry[1].status === 'running')
|
|
913
|
+
setAuditMode('active');
|
|
750
914
|
stateByRun.delete(entry[0]);
|
|
915
|
+
}
|
|
916
|
+
// PROD-2: clear the reverse-index and canary set too, else session-key
|
|
917
|
+
// teardown (without a session_end) leaves dangling map entries.
|
|
918
|
+
sessionKeyToRunId.delete(ctx.sessionKey);
|
|
919
|
+
canaryFired.delete(ctx.sessionKey);
|
|
920
|
+
noUsageWarned.delete(ctx.sessionKey);
|
|
751
921
|
}
|
|
752
922
|
},
|
|
753
923
|
});
|
|
@@ -769,6 +939,9 @@ function register(api) {
|
|
|
769
939
|
const payloadMaxContinuations = ctx.maxTotalContinuations;
|
|
770
940
|
const payloadWorkspacePath = validateWorkspacePath(ctx.workspacePath);
|
|
771
941
|
const payloadTokenBudget = typeof ctx.tokenBudget === 'number' && ctx.tokenBudget > 0 ? ctx.tokenBudget : undefined;
|
|
942
|
+
// S1-residual A: per-activate opt-in to execute workspace-sourced validation
|
|
943
|
+
// commands. Undefined → fall back to plugin config; both undefined → false.
|
|
944
|
+
const payloadTrustWorkspace = typeof ctx.trustWorkspace === 'boolean' ? ctx.trustWorkspace : undefined;
|
|
772
945
|
// Concurrency guard: count sessions with status === 'running'
|
|
773
946
|
const maxConcurrent = config.maxConcurrentAutopilot ?? 5;
|
|
774
947
|
const runningCount = Array.from(stateByRun.values()).filter(s => s.status === 'running').length;
|
|
@@ -791,21 +964,35 @@ function register(api) {
|
|
|
791
964
|
};
|
|
792
965
|
// GAP-6: Load workflow config from WORKFLOW.md
|
|
793
966
|
const applyWorkflowConfig = (s) => {
|
|
967
|
+
// S1-residual A: workspace-sourced validation commands (WORKFLOW.md +
|
|
968
|
+
// auto-detected `npm test` / `node …`) are NOT executed unless the operator
|
|
969
|
+
// trusts this workspace. Untrusted → commands empty + warning. This is the
|
|
970
|
+
// root-cause boundary: the binary allowlist cannot stop `npm run <script>` /
|
|
971
|
+
// `node evil.js` when the workspace owns the script.
|
|
972
|
+
const trustWorkspace = payloadTrustWorkspace ?? config.trustWorkspace ?? false;
|
|
794
973
|
try {
|
|
795
974
|
// Use payloadWorkspacePath (validated in outer scope by validateWorkspacePath) rather than
|
|
796
975
|
// re-reading ctx.workspacePath raw — prevents path-traversal via WORKFLOW.md loading.
|
|
797
976
|
const result = (0, workflow_config_1.loadWorkflowConfig)(process.cwd(), payloadWorkspacePath);
|
|
798
|
-
|
|
799
|
-
|
|
800
|
-
|
|
801
|
-
|
|
802
|
-
|
|
803
|
-
:
|
|
977
|
+
let commands = result.config.validation.commands;
|
|
978
|
+
if (!trustWorkspace) {
|
|
979
|
+
commands = [];
|
|
980
|
+
}
|
|
981
|
+
else if (commands.length === 0 && payloadWorkspacePath) {
|
|
982
|
+
// R-3: Auto-fill validation commands when WORKFLOW.md has none.
|
|
983
|
+
// Only auto-detect when an explicit workspace path is provided AND trusted
|
|
984
|
+
// (not cwd fallback) to avoid running project tests in unexpected dirs.
|
|
985
|
+
commands = (0, project_detector_1.detectValidationCommands)(payloadWorkspacePath);
|
|
986
|
+
}
|
|
987
|
+
const warnings = trustWorkspace
|
|
988
|
+
? result.warnings
|
|
989
|
+
: [...result.warnings, 'untrusted workspace — validation commands disabled (enable via trustWorkspace:true)'];
|
|
804
990
|
return {
|
|
805
991
|
...s,
|
|
806
992
|
workflow: {
|
|
807
993
|
...result.config,
|
|
808
|
-
validation: { ...result.config.validation, commands
|
|
994
|
+
validation: { ...result.config.validation, commands },
|
|
995
|
+
warnings,
|
|
809
996
|
},
|
|
810
997
|
maxTotalContinuations: s.maxTotalContinuations,
|
|
811
998
|
};
|
|
@@ -826,13 +1013,23 @@ function register(api) {
|
|
|
826
1013
|
// A stuck run would otherwise block every future activation until a
|
|
827
1014
|
// gateway restart, because the stall handler leaves status='running'.
|
|
828
1015
|
// Genuinely-active runs (recent activity) still fall through to reject.
|
|
829
|
-
|
|
1016
|
+
// M1: use the run's own stallTimeoutMs if configured, not just the global default.
|
|
1017
|
+
const stuckStallMs = state.workflow?.stallTimeoutMs
|
|
1018
|
+
?? defaultStallTimeoutMs(!!config.tokenBudget);
|
|
1019
|
+
const stuckRecovery = (0, autopilot_state_1.isRunStuck)(state, Date.now(), stuckStallMs);
|
|
830
1020
|
if (state.status === 'idle' || state.status === 'done' || stuckRecovery) {
|
|
831
1021
|
if (stuckRecovery) {
|
|
832
1022
|
(0, logger_1.warn)(`[autopilot] activate: recovering stuck session=${sessionKey} (status=${state.status}, orchState=${state.orchestrationState ?? 'none'}) — discarding stale run ${oldRunId}`);
|
|
833
1023
|
}
|
|
834
|
-
// Release audit monitor
|
|
835
|
-
|
|
1024
|
+
// Release audit monitor refcount ONLY if the old run still holds one.
|
|
1025
|
+
// Per GAP-23 invariant: only status==='running' runs hold an unreleased
|
|
1026
|
+
// refCount (acquire on activate, release on pause/complete/stop). The
|
|
1027
|
+
// stuckRecovery branch is the only one where oldRunId may still be
|
|
1028
|
+
// 'running'. idle/done runs were already released at complete/stop —
|
|
1029
|
+
// releasing again would over-release the shared audit refcount (S8).
|
|
1030
|
+
if (stuckRecovery) {
|
|
1031
|
+
setAuditMode('active');
|
|
1032
|
+
}
|
|
836
1033
|
stateByRun.delete(oldRunId);
|
|
837
1034
|
sessionKeyToRunId.delete(sessionKey);
|
|
838
1035
|
const runId = generateRunId();
|
|
@@ -850,7 +1047,7 @@ function register(api) {
|
|
|
850
1047
|
});
|
|
851
1048
|
setState(runId, newState);
|
|
852
1049
|
sessionKeyToRunId.set(sessionKey, runId);
|
|
853
|
-
(0, logger_1.log)(`[autopilot] activate: session=${sessionKey} new run=${runId} (was ${state.status},
|
|
1050
|
+
(0, logger_1.log)(`[autopilot] activate: session=${sessionKey} new run=${runId} (was ${state.status}, goalLen=${goalForEvent?.length ?? 0})`);
|
|
854
1051
|
}
|
|
855
1052
|
else {
|
|
856
1053
|
(0, logger_1.warn)(`[autopilot] activate rejected: session=${sessionKey} status=${state.status}`);
|
|
@@ -859,7 +1056,7 @@ function register(api) {
|
|
|
859
1056
|
}
|
|
860
1057
|
}
|
|
861
1058
|
else {
|
|
862
|
-
const runId =
|
|
1059
|
+
const runId = generateRunId();
|
|
863
1060
|
let state = (0, autopilot_state_1.activate)((0, types_1.createInitialState)(sessionKey, runId, config));
|
|
864
1061
|
state = (0, orchestrator_1.orchestratorReducer)(state, { type: 'activate_requested', sessionKey, goal: payloadGoal, now: Date.now() });
|
|
865
1062
|
state = applyPayload(state);
|
|
@@ -897,7 +1094,11 @@ function register(api) {
|
|
|
897
1094
|
}
|
|
898
1095
|
// M2: Dispatch resume_requested through orchestrator reducer
|
|
899
1096
|
const orchestrated = (0, orchestrator_1.orchestratorReducer)(state, { type: 'resume_requested', runId, now: Date.now() });
|
|
900
|
-
|
|
1097
|
+
const resumed = (0, autopilot_state_1.resume)(orchestrated);
|
|
1098
|
+
setState(runId, resumed);
|
|
1099
|
+
// LOGIC-4: a programmatic resume has no follow-up user message to trigger
|
|
1100
|
+
// agent_turn_prepare, so kick a cross-turn injection to actually continue.
|
|
1101
|
+
kickResumedTurn(runId, resumed);
|
|
901
1102
|
(0, logger_1.log)(`[autopilot] resume: session=${sessionKey} paused→running, errors reset`);
|
|
902
1103
|
// Re-acquire audit monitor mode on resume.
|
|
903
1104
|
setAuditMode('monitor');
|
|
@@ -928,7 +1129,7 @@ function register(api) {
|
|
|
928
1129
|
api.registerGatewayMethod('autopilot.status', async ({ params: ctx, respond }) => {
|
|
929
1130
|
const sessionKey = ctx.sessionKey;
|
|
930
1131
|
const entry = sessionKey ? findRunBySession(sessionKey) : undefined;
|
|
931
|
-
const projection = entry ? (0, projection_1.projectState)(entry[1]) : undefined;
|
|
1132
|
+
const projection = entry ? (0, projection_1.projectState)(entry[1], config) : undefined;
|
|
932
1133
|
// Also expose raw state fields not in projection (progress, permissionAudit)
|
|
933
1134
|
const state = entry ? entry[1] : undefined;
|
|
934
1135
|
respond(true, {
|
|
@@ -960,7 +1161,7 @@ function register(api) {
|
|
|
960
1161
|
}
|
|
961
1162
|
const [runId, state] = entry;
|
|
962
1163
|
setState(runId, (0, autopilot_state_1.setGoal)(state, goal));
|
|
963
|
-
(0, logger_1.log)(`[autopilot] setGoal: session=${sessionKey}
|
|
1164
|
+
(0, logger_1.log)(`[autopilot] setGoal: session=${sessionKey} goalLen=${goal.length}`);
|
|
964
1165
|
respond(true, { ok: true });
|
|
965
1166
|
});
|
|
966
1167
|
// GAP-23: Cleanup action for graceful shutdown
|
|
@@ -974,8 +1175,7 @@ function register(api) {
|
|
|
974
1175
|
// Periodically check all active runs for stall (no activity for stallTimeoutMs).
|
|
975
1176
|
// When a stall is detected, dispatch stall_timeout through the orchestrator reducer.
|
|
976
1177
|
const stallCheckIntervalMs = 60_000; // Check every 60 seconds
|
|
977
|
-
|
|
978
|
-
// M-7: Clear previous interval before creating new one (HMR / double-register safety)
|
|
1178
|
+
// M-7: Clear previous interval before creating a new one (HMR / double-register safety)
|
|
979
1179
|
if (stallInterval) {
|
|
980
1180
|
clearInterval(stallInterval);
|
|
981
1181
|
stallInterval = null;
|
|
@@ -984,8 +1184,13 @@ function register(api) {
|
|
|
984
1184
|
const now = Date.now();
|
|
985
1185
|
const orphanRunIds = [];
|
|
986
1186
|
for (const [runId, state] of stateByRun.entries()) {
|
|
987
|
-
// GAP-4: Stall detection for active runs
|
|
988
|
-
|
|
1187
|
+
// GAP-4: Stall detection for active runs.
|
|
1188
|
+
// M1 fix: read stallTimeoutMs PER-RUN from the run's workflow config, not
|
|
1189
|
+
// the global default. An operator setting `stall_timeout_ms` in WORKFLOW.md
|
|
1190
|
+
// expects it to take effect; the global fallback was silently ignoring it.
|
|
1191
|
+
const stallTimeoutMs = state.workflow?.stallTimeoutMs
|
|
1192
|
+
?? defaultStallTimeoutMs(!!config.tokenBudget);
|
|
1193
|
+
if (state.enabled && state.status === 'running' && (state.orchestrationState === 'running' || state.orchestrationState === 'claimed')) {
|
|
989
1194
|
const stallResult = (0, stall_detector_1.checkStall)({
|
|
990
1195
|
lastActivityAt: state.lastActivityAt ?? state.startedAt ?? now,
|
|
991
1196
|
now,
|
|
@@ -1011,6 +1216,10 @@ function register(api) {
|
|
|
1011
1216
|
now,
|
|
1012
1217
|
});
|
|
1013
1218
|
setState(runId, updated);
|
|
1219
|
+
// PROD-7: a claimed run cannot self-start a turn — kick one so a genuinely
|
|
1220
|
+
// dead agent restarts instead of sitting in claimed until the 24h sweep.
|
|
1221
|
+
if (updated.orchestrationState === 'claimed')
|
|
1222
|
+
kickResumedTurn(runId, updated);
|
|
1014
1223
|
(0, logger_1.log)(`[autopilot] retry_due: session=${state.sessionKey} run=${runId} attempt=${state.retry?.attempt ?? 1}`);
|
|
1015
1224
|
}
|
|
1016
1225
|
// GAP-26: Health check — detect orphaned sessions (no activity for 24h)
|
|
@@ -1024,11 +1233,18 @@ function register(api) {
|
|
|
1024
1233
|
const state = stateByRun.get(runId);
|
|
1025
1234
|
if (state) {
|
|
1026
1235
|
(0, logger_1.warn)(`[autopilot] health check: cleaning orphaned session=${state.sessionKey} run=${runId}`);
|
|
1236
|
+
// S8: release audit refCount for orphaned still-running runs before delete.
|
|
1237
|
+
if (state.status === 'running')
|
|
1238
|
+
setAuditMode('active');
|
|
1027
1239
|
stateByRun.delete(runId);
|
|
1028
1240
|
sessionKeyToRunId.delete(state.sessionKey);
|
|
1029
1241
|
canaryFired.delete(state.sessionKey);
|
|
1242
|
+
noUsageWarned.delete(state.sessionKey);
|
|
1030
1243
|
}
|
|
1031
1244
|
}
|
|
1032
1245
|
}, stallCheckIntervalMs);
|
|
1246
|
+
// PROD-6: don't let the stall timer keep the event loop alive if the host
|
|
1247
|
+
// exits without calling cleanup — unref so the process can drain naturally.
|
|
1248
|
+
stallInterval?.unref?.();
|
|
1033
1249
|
}
|
|
1034
1250
|
//# sourceMappingURL=index.js.map
|