@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.
- package/README.md +125 -10
- package/dist/index.d.ts +1 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +338 -93
- 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 +37 -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 +81 -4
- package/package.json +14 -5
- 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,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
|
|
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 =
|
|
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:
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
367
|
-
|
|
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
|
|
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 (
|
|
373
|
-
updated = (0, orchestrator_1.orchestratorReducer)(
|
|
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
|
|
382
|
-
//
|
|
383
|
-
//
|
|
384
|
-
|
|
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
|
-
|
|
387
|
-
|
|
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
|
|
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.
|
|
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', (
|
|
425
|
-
const sessionKey =
|
|
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', (
|
|
435
|
-
const sessionKey =
|
|
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
|
|
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:
|
|
493
|
-
const
|
|
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)('
|
|
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
|
-
|
|
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
|
|
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:
|
|
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
|
-
|
|
799
|
-
|
|
800
|
-
|
|
801
|
-
|
|
802
|
-
|
|
803
|
-
:
|
|
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
|
-
...
|
|
808
|
-
validation: { ...result.config.validation, commands
|
|
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
|
-
|
|
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
|
|
835
|
-
|
|
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},
|
|
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 =
|
|
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
|
-
|
|
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}
|
|
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
|
-
|
|
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
|
-
|
|
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
|