@dsh-enhanced/assistant-automations 0.1.6 → 0.1.12
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 +39 -7
- package/lib/coordinator.d.ts +57 -5
- package/lib/coordinator.d.ts.map +1 -1
- package/lib/coordinator.js +508 -88
- package/lib/coordinator.js.map +1 -1
- package/lib/growth.d.ts +120 -0
- package/lib/growth.d.ts.map +1 -0
- package/lib/growth.js +383 -0
- package/lib/growth.js.map +1 -0
- package/lib/host-executors.d.ts +25 -0
- package/lib/host-executors.d.ts.map +1 -0
- package/lib/host-executors.js +181 -0
- package/lib/host-executors.js.map +1 -0
- package/lib/index.d.ts +1 -1
- package/lib/index.d.ts.map +1 -1
- package/lib/index.js +1 -1
- package/lib/index.js.map +1 -1
- package/lib/operator.d.ts +2 -0
- package/lib/operator.d.ts.map +1 -1
- package/lib/operator.js +6 -2
- package/lib/operator.js.map +1 -1
- package/lib/proposals.d.ts +2 -2
- package/lib/proposals.d.ts.map +1 -1
- package/lib/proposals.js +34 -22
- package/lib/proposals.js.map +1 -1
- package/lib/runner.d.ts +28 -2
- package/lib/runner.d.ts.map +1 -1
- package/lib/runner.js +525 -73
- package/lib/runner.js.map +1 -1
- package/lib/service.d.ts +110 -4
- package/lib/service.d.ts.map +1 -1
- package/lib/service.js +1021 -16
- package/lib/service.js.map +1 -1
- package/lib/sqlite.d.ts +1 -1
- package/lib/sqlite.d.ts.map +1 -1
- package/lib/sqlite.js +450 -2
- package/lib/sqlite.js.map +1 -1
- package/lib/store.d.ts +211 -1
- package/lib/store.d.ts.map +1 -1
- package/lib/store.js +2260 -58
- package/lib/store.js.map +1 -1
- package/lib/tools.d.ts.map +1 -1
- package/lib/tools.js +18 -6
- package/lib/tools.js.map +1 -1
- package/lib/types.d.ts +399 -9
- package/lib/types.d.ts.map +1 -1
- package/lib/types.js +13 -1
- package/lib/types.js.map +1 -1
- package/lib/version.d.ts +1 -1
- package/lib/version.d.ts.map +1 -1
- package/lib/version.js +1 -1
- package/lib/version.js.map +1 -1
- package/package.json +16 -5
package/lib/coordinator.js
CHANGED
|
@@ -1,8 +1,20 @@
|
|
|
1
|
+
import { HostAutomationExecutorRegistry } from './host-executors.js';
|
|
1
2
|
import { AutomationStoreError } from './store.js';
|
|
3
|
+
import { isHostAutomationDefinition, legacyAutomationExecutionDiagnostic } from './types.js';
|
|
2
4
|
export class AutomationRunnerAmbiguousError extends Error {
|
|
5
|
+
diagnostic;
|
|
3
6
|
constructor(message, options) {
|
|
4
7
|
super(message, options);
|
|
5
8
|
this.name = 'AutomationRunnerAmbiguousError';
|
|
9
|
+
this.diagnostic = options?.diagnostic ?? legacyAutomationExecutionDiagnostic;
|
|
10
|
+
}
|
|
11
|
+
}
|
|
12
|
+
export class AutomationRunnerFailureError extends Error {
|
|
13
|
+
diagnostic;
|
|
14
|
+
constructor(message, diagnostic, options) {
|
|
15
|
+
super(message, options);
|
|
16
|
+
this.diagnostic = diagnostic;
|
|
17
|
+
this.name = 'AutomationRunnerFailureError';
|
|
6
18
|
}
|
|
7
19
|
}
|
|
8
20
|
function sessionId(task) {
|
|
@@ -22,7 +34,32 @@ function boundedText(value, maximumBytes) {
|
|
|
22
34
|
return undefined;
|
|
23
35
|
return normalized;
|
|
24
36
|
}
|
|
37
|
+
function incidentAlertText(incident) {
|
|
38
|
+
return [
|
|
39
|
+
`Automation incident ${incident.id}`,
|
|
40
|
+
`automation: ${incident.automationId}`,
|
|
41
|
+
`state: ${incident.state}`,
|
|
42
|
+
`stage: ${incident.stage}`,
|
|
43
|
+
`failure: ${incident.failureClass}/${incident.failurePhase}/${incident.failureCode}`,
|
|
44
|
+
`side effects: ${incident.sideEffectState}`,
|
|
45
|
+
`retryability: ${incident.retryability}`,
|
|
46
|
+
].join('\n');
|
|
47
|
+
}
|
|
25
48
|
const exposureReceiptTimeoutMs = 2_000;
|
|
49
|
+
const evaluationMaxAttempts = 8;
|
|
50
|
+
const evaluationRetryBaseMs = 1_000;
|
|
51
|
+
const evaluationRetryMaxMs = 3_600_000;
|
|
52
|
+
const evaluationRecorderTimeoutMs = 2_000;
|
|
53
|
+
function recorderErrorCode(error) {
|
|
54
|
+
if (typeof error === 'object' && error !== null && 'code' in error) {
|
|
55
|
+
const code = error.code;
|
|
56
|
+
if (typeof code === 'string' && /^[A-Za-z0-9._:-]{1,64}$/u.test(code))
|
|
57
|
+
return code;
|
|
58
|
+
}
|
|
59
|
+
if (error instanceof Error && /^[A-Za-z][A-Za-z0-9]{0,63}$/u.test(error.name))
|
|
60
|
+
return error.name;
|
|
61
|
+
return 'recorder-error';
|
|
62
|
+
}
|
|
26
63
|
export class AutomationCoordinator {
|
|
27
64
|
store;
|
|
28
65
|
artifacts;
|
|
@@ -35,9 +72,12 @@ export class AutomationCoordinator {
|
|
|
35
72
|
maxCatchUp;
|
|
36
73
|
maxConcurrency;
|
|
37
74
|
tickIntervalMs;
|
|
75
|
+
hostExecutors;
|
|
38
76
|
active = new Map();
|
|
77
|
+
activeEvaluations = new Map();
|
|
39
78
|
delivery;
|
|
40
79
|
outcomeRecorder;
|
|
80
|
+
evaluationRecorder;
|
|
41
81
|
fencingToken;
|
|
42
82
|
timer;
|
|
43
83
|
stopped = false;
|
|
@@ -56,6 +96,7 @@ export class AutomationCoordinator {
|
|
|
56
96
|
this.maxCatchUp = options.maxCatchUp;
|
|
57
97
|
this.maxConcurrency = options.maxConcurrency;
|
|
58
98
|
this.tickIntervalMs = options.tickIntervalMs ?? Math.max(1_000, Math.floor(options.dutyLeaseMs / 3));
|
|
99
|
+
this.hostExecutors = options.hostExecutors ?? new HostAutomationExecutorRegistry();
|
|
59
100
|
}
|
|
60
101
|
start() {
|
|
61
102
|
if (this.stopped)
|
|
@@ -72,16 +113,27 @@ export class AutomationCoordinator {
|
|
|
72
113
|
const now = this.now();
|
|
73
114
|
if (!this.ensureDuty(now))
|
|
74
115
|
return;
|
|
116
|
+
this.store.recoverExpiredCircuitProbes({ now });
|
|
117
|
+
this.store.recoverExpiredTasks({ now, limit: this.maxCatchUp });
|
|
118
|
+
this.dispatchPendingEvaluations();
|
|
75
119
|
this.dispatchPendingEvidence();
|
|
76
120
|
this.dispatchPendingDeliveries();
|
|
77
|
-
this.
|
|
78
|
-
this.store.
|
|
121
|
+
this.dispatchPendingIncidentAlerts();
|
|
122
|
+
const materializeAvailability = this.hostAvailability(this.store.listDueHostExecutionRequirements({ now }), 'materialize');
|
|
123
|
+
this.store.materializeDue({
|
|
124
|
+
now,
|
|
125
|
+
misfireGraceMs: this.misfireGraceMs,
|
|
126
|
+
maxCatchUp: this.maxCatchUp,
|
|
127
|
+
hostAvailability: materializeAvailability,
|
|
128
|
+
});
|
|
79
129
|
while (this.active.size < this.maxConcurrency) {
|
|
130
|
+
const claimAvailability = this.hostAvailability(this.store.listClaimableHostExecutionRequirements(), 'claim');
|
|
80
131
|
const claimed = this.store.claimNextTask({
|
|
81
132
|
ownerId: this.ownerId,
|
|
82
133
|
fencingToken: this.fencingToken,
|
|
83
134
|
now,
|
|
84
135
|
leaseMs: this.taskLeaseMs,
|
|
136
|
+
hostAvailability: claimAvailability,
|
|
85
137
|
});
|
|
86
138
|
if (claimed === undefined)
|
|
87
139
|
break;
|
|
@@ -90,6 +142,7 @@ export class AutomationCoordinator {
|
|
|
90
142
|
this.abortRequestedActive();
|
|
91
143
|
this.launch(claimed);
|
|
92
144
|
}
|
|
145
|
+
this.dispatchPendingIncidentAlerts();
|
|
93
146
|
}
|
|
94
147
|
setDeliveryDispatcher(delivery) {
|
|
95
148
|
this.delivery = delivery;
|
|
@@ -104,9 +157,18 @@ export class AutomationCoordinator {
|
|
|
104
157
|
setOutcomeRecorder(recorder) {
|
|
105
158
|
this.outcomeRecorder = recorder;
|
|
106
159
|
}
|
|
160
|
+
/** Attach the optional unified Evaluation sink without coupling it to Evolution. */
|
|
161
|
+
setEvaluationRecorder(recorder) {
|
|
162
|
+
this.evaluationRecorder = recorder;
|
|
163
|
+
if (recorder !== undefined && !this.stopped)
|
|
164
|
+
this.dispatchPendingEvaluations();
|
|
165
|
+
}
|
|
107
166
|
async whenIdle() {
|
|
108
|
-
while (this.active.size > 0) {
|
|
109
|
-
await Promise.
|
|
167
|
+
while (this.active.size > 0 || this.activeEvaluations.size > 0) {
|
|
168
|
+
await Promise.allSettled([
|
|
169
|
+
...[...this.active.values()].map(value => value.promise),
|
|
170
|
+
...this.activeEvaluations.values(),
|
|
171
|
+
]);
|
|
110
172
|
}
|
|
111
173
|
}
|
|
112
174
|
async cancel(taskId) {
|
|
@@ -124,6 +186,32 @@ export class AutomationCoordinator {
|
|
|
124
186
|
for (const value of this.active.values())
|
|
125
187
|
value.controller.abort('coordinator-stop');
|
|
126
188
|
await this.whenIdle();
|
|
189
|
+
if (this.fencingToken !== undefined) {
|
|
190
|
+
try {
|
|
191
|
+
this.store.releaseDuty({ ownerId: this.ownerId, fencingToken: this.fencingToken, now: this.now() });
|
|
192
|
+
}
|
|
193
|
+
catch (error) {
|
|
194
|
+
// A successor that already advanced the fence owns the row. The
|
|
195
|
+
// stopped coordinator must not interfere with it.
|
|
196
|
+
if (!(error instanceof AutomationStoreError) || error.code !== 'stale-fence')
|
|
197
|
+
throw error;
|
|
198
|
+
}
|
|
199
|
+
finally {
|
|
200
|
+
this.fencingToken = undefined;
|
|
201
|
+
}
|
|
202
|
+
}
|
|
203
|
+
}
|
|
204
|
+
hostAvailability(requirements, stage) {
|
|
205
|
+
return requirements.map(requirement => {
|
|
206
|
+
const proof = this.hostExecutors.prove(requirement.execution);
|
|
207
|
+
return Object.freeze({
|
|
208
|
+
automationId: requirement.automationId,
|
|
209
|
+
definitionHash: requirement.definitionHash,
|
|
210
|
+
stage,
|
|
211
|
+
available: proof.available,
|
|
212
|
+
reasonCode: proof.available ? 'host-executor-available' : proof.reasonCode,
|
|
213
|
+
});
|
|
214
|
+
});
|
|
127
215
|
}
|
|
128
216
|
ensureDuty(now) {
|
|
129
217
|
if (this.fencingToken !== undefined) {
|
|
@@ -160,89 +248,221 @@ export class AutomationCoordinator {
|
|
|
160
248
|
}
|
|
161
249
|
}
|
|
162
250
|
async execute(claimed, controller) {
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
if (automation === undefined || occurrence === undefined || this.fencingToken === undefined) {
|
|
166
|
-
throw new Error('assistant-automations: claimed task snapshot is missing');
|
|
167
|
-
}
|
|
251
|
+
if (this.fencingToken === undefined)
|
|
252
|
+
throw new Error('assistant-automations: duty fence is missing');
|
|
168
253
|
const fence = this.fencingToken;
|
|
169
254
|
const freshSessionId = sessionId(claimed);
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
ownerId: this.ownerId,
|
|
173
|
-
fencingToken: fence,
|
|
174
|
-
now: this.now(),
|
|
175
|
-
leaseMs: this.taskLeaseMs,
|
|
176
|
-
sessionId: freshSessionId,
|
|
177
|
-
});
|
|
255
|
+
let timeout;
|
|
256
|
+
let heartbeat;
|
|
178
257
|
let timedOut = false;
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
258
|
+
try {
|
|
259
|
+
const started = this.store.startTask({
|
|
260
|
+
taskId: claimed.id,
|
|
261
|
+
ownerId: this.ownerId,
|
|
262
|
+
fencingToken: fence,
|
|
263
|
+
now: this.now(),
|
|
264
|
+
leaseMs: this.taskLeaseMs,
|
|
265
|
+
sessionId: freshSessionId,
|
|
266
|
+
});
|
|
267
|
+
let automation;
|
|
268
|
+
let occurrence;
|
|
185
269
|
try {
|
|
186
|
-
|
|
187
|
-
this.store.
|
|
270
|
+
automation = this.store.getTaskExecutionSnapshot(claimed.id);
|
|
271
|
+
occurrence = this.store.getOccurrence(claimed.occurrenceId);
|
|
272
|
+
}
|
|
273
|
+
catch {
|
|
274
|
+
this.store.quarantineInvalidExecutionSnapshot({
|
|
275
|
+
taskId: started.id,
|
|
188
276
|
ownerId: this.ownerId,
|
|
189
277
|
fencingToken: fence,
|
|
190
|
-
now,
|
|
191
|
-
leaseMs: this.dutyLeaseMs,
|
|
278
|
+
now: this.now(),
|
|
192
279
|
});
|
|
193
|
-
|
|
280
|
+
return;
|
|
281
|
+
}
|
|
282
|
+
if (automation === undefined || occurrence === undefined) {
|
|
283
|
+
this.store.quarantineInvalidExecutionSnapshot({
|
|
194
284
|
taskId: started.id,
|
|
195
285
|
ownerId: this.ownerId,
|
|
196
286
|
fencingToken: fence,
|
|
197
|
-
now,
|
|
198
|
-
|
|
287
|
+
now: this.now(),
|
|
288
|
+
});
|
|
289
|
+
return;
|
|
290
|
+
}
|
|
291
|
+
const hostExecution = isHostAutomationDefinition(automation.definition);
|
|
292
|
+
timeout = setTimeout(() => {
|
|
293
|
+
timedOut = true;
|
|
294
|
+
controller.abort('timeout');
|
|
295
|
+
}, automation.definition.timeoutMs);
|
|
296
|
+
timeout.unref?.();
|
|
297
|
+
heartbeat = setInterval(() => {
|
|
298
|
+
try {
|
|
299
|
+
const now = this.now();
|
|
300
|
+
this.store.renewDuty({
|
|
301
|
+
ownerId: this.ownerId,
|
|
302
|
+
fencingToken: fence,
|
|
303
|
+
now,
|
|
304
|
+
leaseMs: this.dutyLeaseMs,
|
|
305
|
+
});
|
|
306
|
+
this.store.heartbeatTask({
|
|
307
|
+
taskId: started.id,
|
|
308
|
+
ownerId: this.ownerId,
|
|
309
|
+
fencingToken: fence,
|
|
310
|
+
now,
|
|
311
|
+
leaseMs: this.taskLeaseMs,
|
|
312
|
+
});
|
|
313
|
+
}
|
|
314
|
+
catch {
|
|
315
|
+
controller.abort('lease-lost');
|
|
316
|
+
}
|
|
317
|
+
}, Math.max(250, Math.floor(Math.min(this.dutyLeaseMs, this.taskLeaseMs) / 3)));
|
|
318
|
+
heartbeat.unref?.();
|
|
319
|
+
let result;
|
|
320
|
+
try {
|
|
321
|
+
const admission = this.store.acquireCircuitExecutionForTask({
|
|
322
|
+
taskId: started.id,
|
|
323
|
+
now: this.now(),
|
|
324
|
+
leaseMs: Math.min(86_400_000, Math.max(this.taskLeaseMs, automation.definition.timeoutMs)),
|
|
199
325
|
});
|
|
326
|
+
if (admission.kind === 'blocked') {
|
|
327
|
+
result = {
|
|
328
|
+
outcome: 'failed',
|
|
329
|
+
output: `execution circuit is ${admission.circuit.state} for immutable definition ${admission.circuit.definitionHash}`,
|
|
330
|
+
usage: {},
|
|
331
|
+
diagnostic: Object.freeze({
|
|
332
|
+
schemaVersion: 1,
|
|
333
|
+
failureClass: admission.circuit.failureClass,
|
|
334
|
+
failurePhase: 'preflight',
|
|
335
|
+
failureCode: 'circuit-open',
|
|
336
|
+
promptSubmissionState: hostExecution ? 'not-applicable' : 'not-submitted',
|
|
337
|
+
sideEffectState: 'none',
|
|
338
|
+
retryability: 'after-intervention',
|
|
339
|
+
budgetSettlementState: automation.definition.budgetId === undefined ? 'not-required' : 'not-reserved',
|
|
340
|
+
}),
|
|
341
|
+
};
|
|
342
|
+
}
|
|
343
|
+
else {
|
|
344
|
+
const runnerPromise = Promise.resolve().then(() => this.runner.run({
|
|
345
|
+
automation,
|
|
346
|
+
occurrence,
|
|
347
|
+
task: started,
|
|
348
|
+
sessionId: freshSessionId,
|
|
349
|
+
signal: controller.signal,
|
|
350
|
+
}));
|
|
351
|
+
// The runner owns Agent flush/disposal. It may violate cancellation,
|
|
352
|
+
// so its late settlement is deliberately detached from terminal I/O.
|
|
353
|
+
const settledRunner = runnerPromise.then(value => ({ kind: 'result', value }), error => ({ kind: 'error', error }));
|
|
354
|
+
let removeAbort = () => { };
|
|
355
|
+
const aborted = new Promise(resolve => {
|
|
356
|
+
const finish = () => resolve({ kind: 'aborted' });
|
|
357
|
+
if (controller.signal.aborted)
|
|
358
|
+
finish();
|
|
359
|
+
else {
|
|
360
|
+
controller.signal.addEventListener('abort', finish, { once: true });
|
|
361
|
+
removeAbort = () => controller.signal.removeEventListener('abort', finish);
|
|
362
|
+
}
|
|
363
|
+
});
|
|
364
|
+
const settled = await Promise.race([settledRunner, aborted]);
|
|
365
|
+
removeAbort();
|
|
366
|
+
void settledRunner.then(() => { });
|
|
367
|
+
if (settled.kind === 'aborted' || controller.signal.aborted) {
|
|
368
|
+
const isTimeout = timedOut;
|
|
369
|
+
result = {
|
|
370
|
+
outcome: isTimeout ? 'timed_out' : 'cancelled',
|
|
371
|
+
output: isTimeout ? 'automation execution timed out' : 'automation execution was cancelled',
|
|
372
|
+
usage: {},
|
|
373
|
+
diagnostic: Object.freeze({
|
|
374
|
+
schemaVersion: 1,
|
|
375
|
+
failureClass: isTimeout ? 'timeout' : 'cancelled',
|
|
376
|
+
failurePhase: 'unknown',
|
|
377
|
+
failureCode: isTimeout ? 'execution-timeout' : 'execution-cancelled',
|
|
378
|
+
promptSubmissionState: hostExecution ? 'not-applicable' : 'unknown',
|
|
379
|
+
sideEffectState: 'unknown',
|
|
380
|
+
retryability: 'unsafe',
|
|
381
|
+
budgetSettlementState: automation.definition.budgetId === undefined ? 'not-required' : 'unknown',
|
|
382
|
+
}),
|
|
383
|
+
};
|
|
384
|
+
}
|
|
385
|
+
else if (settled.kind === 'error') {
|
|
386
|
+
const error = settled.error;
|
|
387
|
+
const diagnostic = error instanceof AutomationRunnerFailureError
|
|
388
|
+
|| error instanceof AutomationRunnerAmbiguousError
|
|
389
|
+
? error.diagnostic
|
|
390
|
+
: legacyAutomationExecutionDiagnostic;
|
|
391
|
+
result = {
|
|
392
|
+
outcome: error instanceof AutomationRunnerAmbiguousError ? 'unknown' : 'failed',
|
|
393
|
+
output: error instanceof Error ? error.message : String(error),
|
|
394
|
+
usage: {},
|
|
395
|
+
diagnostic,
|
|
396
|
+
};
|
|
397
|
+
}
|
|
398
|
+
else {
|
|
399
|
+
result = settled.value;
|
|
400
|
+
}
|
|
401
|
+
}
|
|
200
402
|
}
|
|
201
403
|
catch {
|
|
202
|
-
|
|
404
|
+
result = {
|
|
405
|
+
outcome: 'unknown',
|
|
406
|
+
output: 'automation circuit admission could not be proven',
|
|
407
|
+
usage: {},
|
|
408
|
+
diagnostic: Object.freeze({
|
|
409
|
+
schemaVersion: 1,
|
|
410
|
+
failureClass: 'infrastructure',
|
|
411
|
+
failurePhase: 'preflight',
|
|
412
|
+
failureCode: 'circuit-admission-failed',
|
|
413
|
+
promptSubmissionState: hostExecution ? 'not-applicable' : 'not-submitted',
|
|
414
|
+
sideEffectState: 'none',
|
|
415
|
+
retryability: 'after-intervention',
|
|
416
|
+
budgetSettlementState: automation.definition.budgetId === undefined ? 'not-required' : 'not-reserved',
|
|
417
|
+
}),
|
|
418
|
+
};
|
|
203
419
|
}
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
automation,
|
|
210
|
-
occurrence,
|
|
211
|
-
task: started,
|
|
212
|
-
sessionId: freshSessionId,
|
|
213
|
-
signal: controller.signal,
|
|
214
|
-
});
|
|
215
|
-
}
|
|
216
|
-
catch (error) {
|
|
217
|
-
result = {
|
|
218
|
-
outcome: controller.signal.aborted
|
|
219
|
-
? 'cancelled'
|
|
220
|
-
: error instanceof AutomationRunnerAmbiguousError ? 'unknown' : 'failed',
|
|
221
|
-
output: error instanceof Error ? error.message : String(error),
|
|
222
|
-
usage: {},
|
|
223
|
-
};
|
|
224
|
-
}
|
|
225
|
-
finally {
|
|
226
|
-
clearTimeout(timeout);
|
|
227
|
-
}
|
|
228
|
-
try {
|
|
229
|
-
const evidenceSessionId = result.sessionId === freshSessionId ? freshSessionId : undefined;
|
|
420
|
+
if (timeout !== undefined)
|
|
421
|
+
clearTimeout(timeout);
|
|
422
|
+
const evidenceSessionId = occurrence.dryRun === false && result.sessionId === freshSessionId
|
|
423
|
+
? freshSessionId
|
|
424
|
+
: undefined;
|
|
230
425
|
const exposure = evidenceSessionId === undefined
|
|
231
426
|
? undefined
|
|
232
427
|
: await this.getExposure(automation, evidenceSessionId);
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
: controller.signal.aborted ? 'cancelled' : result.outcome;
|
|
428
|
+
let outcome = result.outcome;
|
|
429
|
+
let diagnostic = result.diagnostic ?? legacyAutomationExecutionDiagnostic;
|
|
236
430
|
const outputPreview = preview(result.output);
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
431
|
+
let artifactRef;
|
|
432
|
+
try {
|
|
433
|
+
artifactRef = this.artifacts.write(occurrence.id, {
|
|
434
|
+
occurrenceId: occurrence.id,
|
|
435
|
+
automationId: automation.id,
|
|
436
|
+
taskId: started.id,
|
|
437
|
+
outcome,
|
|
438
|
+
sessionId: result.sessionId ?? freshSessionId,
|
|
439
|
+
output: result.output,
|
|
440
|
+
usage: result.usage,
|
|
441
|
+
diagnostic,
|
|
442
|
+
});
|
|
443
|
+
}
|
|
444
|
+
catch {
|
|
445
|
+
// The execution may already have crossed prompt/tool boundaries. Commit
|
|
446
|
+
// a content-free unknown recovery receipt instead of waiting for lease
|
|
447
|
+
// expiry or pretending the missing artifact did not matter.
|
|
448
|
+
outcome = 'unknown';
|
|
449
|
+
diagnostic = Object.freeze({
|
|
450
|
+
schemaVersion: 1,
|
|
451
|
+
failureClass: 'infrastructure',
|
|
452
|
+
failurePhase: 'artifact-write',
|
|
453
|
+
failureCode: 'artifact-write-failed',
|
|
454
|
+
promptSubmissionState: diagnostic.promptSubmissionState,
|
|
455
|
+
sideEffectState: diagnostic.sideEffectState,
|
|
456
|
+
retryability: diagnostic.promptSubmissionState === 'not-submitted'
|
|
457
|
+
&& diagnostic.sideEffectState === 'none'
|
|
458
|
+
&& (diagnostic.budgetSettlementState === 'not-required'
|
|
459
|
+
|| diagnostic.budgetSettlementState === 'not-reserved'
|
|
460
|
+
|| diagnostic.budgetSettlementState === 'released')
|
|
461
|
+
? 'safe'
|
|
462
|
+
: 'unsafe',
|
|
463
|
+
budgetSettlementState: diagnostic.budgetSettlementState,
|
|
464
|
+
});
|
|
465
|
+
}
|
|
246
466
|
const run = this.store.completeTask({
|
|
247
467
|
taskId: started.id,
|
|
248
468
|
ownerId: this.ownerId,
|
|
@@ -250,10 +470,11 @@ export class AutomationCoordinator {
|
|
|
250
470
|
now: this.now(),
|
|
251
471
|
outcome,
|
|
252
472
|
sessionId: result.sessionId ?? freshSessionId,
|
|
253
|
-
artifactRef,
|
|
254
|
-
outputPreview,
|
|
255
|
-
usage: result.usage,
|
|
256
|
-
|
|
473
|
+
...(artifactRef === undefined ? {} : { artifactRef }),
|
|
474
|
+
outputPreview: artifactRef === undefined ? 'artifact persistence failed' : outputPreview,
|
|
475
|
+
usage: artifactRef === undefined ? {} : result.usage,
|
|
476
|
+
diagnostic,
|
|
477
|
+
...(artifactRef === undefined || evidenceSessionId === undefined
|
|
257
478
|
? {}
|
|
258
479
|
: {
|
|
259
480
|
evidenceAttribution: {
|
|
@@ -264,9 +485,13 @@ export class AutomationCoordinator {
|
|
|
264
485
|
});
|
|
265
486
|
this.dispatchRunDelivery(run);
|
|
266
487
|
this.dispatchRunEvidence(run);
|
|
488
|
+
this.dispatchRunEvaluation(run.id);
|
|
267
489
|
}
|
|
268
490
|
finally {
|
|
269
|
-
|
|
491
|
+
if (timeout !== undefined)
|
|
492
|
+
clearTimeout(timeout);
|
|
493
|
+
if (heartbeat !== undefined)
|
|
494
|
+
clearInterval(heartbeat);
|
|
270
495
|
}
|
|
271
496
|
}
|
|
272
497
|
async getExposure(automation, actualSessionId) {
|
|
@@ -308,16 +533,36 @@ export class AutomationCoordinator {
|
|
|
308
533
|
dispatchPendingEvidence() {
|
|
309
534
|
if (this.outcomeRecorder === undefined)
|
|
310
535
|
return;
|
|
311
|
-
for (const run of this.store.listPendingEvidence(100))
|
|
312
|
-
|
|
536
|
+
for (const run of this.store.listPendingEvidence(100)) {
|
|
537
|
+
try {
|
|
538
|
+
this.dispatchRunEvidence(run);
|
|
539
|
+
}
|
|
540
|
+
catch {
|
|
541
|
+
// One corrupt/concurrently-settled row must not starve its peers.
|
|
542
|
+
}
|
|
543
|
+
}
|
|
313
544
|
}
|
|
314
545
|
dispatchRunEvidence(run) {
|
|
315
546
|
const recorder = this.outcomeRecorder;
|
|
316
547
|
if (recorder === undefined || run.evidenceStatus !== 'pending' || run.evidence === undefined)
|
|
317
548
|
return;
|
|
549
|
+
let proven;
|
|
318
550
|
try {
|
|
319
|
-
|
|
320
|
-
|
|
551
|
+
proven = this.store.getProvenProductionRun(run.id);
|
|
552
|
+
}
|
|
553
|
+
catch {
|
|
554
|
+
this.store.suppressRunEvidence({ runId: run.id, expectedStatus: 'pending', now: this.now() });
|
|
555
|
+
return;
|
|
556
|
+
}
|
|
557
|
+
if (proven === undefined
|
|
558
|
+
|| (proven.run.status !== 'succeeded' && proven.run.status !== 'failed' && proven.run.status !== 'timed_out')
|
|
559
|
+
|| proven.run.evidenceStatus !== 'pending' || proven.run.evidence === undefined) {
|
|
560
|
+
this.store.suppressRunEvidence({ runId: run.id, expectedStatus: 'pending', now: this.now() });
|
|
561
|
+
return;
|
|
562
|
+
}
|
|
563
|
+
try {
|
|
564
|
+
recorder.recordAutomationOutcome(proven.run.evidence);
|
|
565
|
+
this.store.completeRunEvidence({ runId: proven.run.id, expectedStatus: 'pending', now: this.now() });
|
|
321
566
|
}
|
|
322
567
|
catch {
|
|
323
568
|
// Evidence is an outbox lane: the run remains terminal and a later leader
|
|
@@ -331,32 +576,207 @@ export class AutomationCoordinator {
|
|
|
331
576
|
}
|
|
332
577
|
}
|
|
333
578
|
}
|
|
579
|
+
dispatchPendingEvaluations() {
|
|
580
|
+
if (this.evaluationRecorder === undefined)
|
|
581
|
+
return;
|
|
582
|
+
const now = this.now();
|
|
583
|
+
for (const entry of this.store.listPendingEvaluations(100, now))
|
|
584
|
+
this.dispatchEvaluation(entry);
|
|
585
|
+
}
|
|
586
|
+
dispatchRunEvaluation(runId) {
|
|
587
|
+
if (this.evaluationRecorder === undefined)
|
|
588
|
+
return;
|
|
589
|
+
const entry = this.store.getPendingEvaluationForRun(runId, this.now());
|
|
590
|
+
if (entry !== undefined)
|
|
591
|
+
this.dispatchEvaluation(entry);
|
|
592
|
+
}
|
|
593
|
+
dispatchEvaluation(entry) {
|
|
594
|
+
const recorder = this.evaluationRecorder;
|
|
595
|
+
if (recorder === undefined || entry.status !== 'pending' || this.activeEvaluations.has(entry.id))
|
|
596
|
+
return;
|
|
597
|
+
const authoritativeMode = this.store.getRunExecutionMode(entry.runId);
|
|
598
|
+
const payloadMode = entry.payload.executionMode;
|
|
599
|
+
if (authoritativeMode !== 'production' || (payloadMode !== undefined && payloadMode !== 'production')) {
|
|
600
|
+
// Repair old preview rows without ever presenting them to a trusted sink.
|
|
601
|
+
// `recorded` here means the durable outbox item was conclusively handled;
|
|
602
|
+
// no Evaluation outcome is invented.
|
|
603
|
+
this.store.completeEvaluation({ id: entry.id, expectedStatus: 'pending', now: this.now() });
|
|
604
|
+
return;
|
|
605
|
+
}
|
|
606
|
+
const normalizedEntry = payloadMode === 'production'
|
|
607
|
+
? entry
|
|
608
|
+
: Object.freeze({ ...entry, payload: Object.freeze({ ...entry.payload, executionMode: 'production' }) });
|
|
609
|
+
const promise = this.recordEvaluation(recorder, normalizedEntry)
|
|
610
|
+
.finally(() => this.activeEvaluations.delete(entry.id));
|
|
611
|
+
this.activeEvaluations.set(entry.id, promise);
|
|
612
|
+
void promise.catch(() => { });
|
|
613
|
+
}
|
|
614
|
+
async recordEvaluation(recorder, entry) {
|
|
615
|
+
let timeout;
|
|
616
|
+
try {
|
|
617
|
+
await Promise.race([
|
|
618
|
+
Promise.resolve(recorder.append(entry.payload)),
|
|
619
|
+
new Promise((_resolve, reject) => {
|
|
620
|
+
timeout = setTimeout(() => reject(Object.assign(new Error('evaluation recorder timed out'), {
|
|
621
|
+
code: 'recorder-timeout',
|
|
622
|
+
})), evaluationRecorderTimeoutMs);
|
|
623
|
+
timeout.unref?.();
|
|
624
|
+
}),
|
|
625
|
+
]);
|
|
626
|
+
this.store.completeEvaluation({ id: entry.id, expectedStatus: 'pending', now: this.now() });
|
|
627
|
+
}
|
|
628
|
+
catch (error) {
|
|
629
|
+
// The payload is immutable and has its own idempotency key. A crash after
|
|
630
|
+
// append but before settlement therefore replays safely without rerunning
|
|
631
|
+
// the Automation or coupling this lane to Evolution evidence.
|
|
632
|
+
try {
|
|
633
|
+
const now = this.now();
|
|
634
|
+
const delay = Math.min(evaluationRetryMaxMs, evaluationRetryBaseMs * 2 ** Math.min(entry.attemptCount, 12));
|
|
635
|
+
this.store.deferEvaluation({
|
|
636
|
+
id: entry.id,
|
|
637
|
+
expectedStatus: 'pending',
|
|
638
|
+
now,
|
|
639
|
+
retryAt: now + delay,
|
|
640
|
+
maxAttempts: evaluationMaxAttempts,
|
|
641
|
+
errorCode: recorderErrorCode(error),
|
|
642
|
+
});
|
|
643
|
+
}
|
|
644
|
+
catch {
|
|
645
|
+
// A concurrent settlement or shutdown already owns the next step.
|
|
646
|
+
}
|
|
647
|
+
}
|
|
648
|
+
finally {
|
|
649
|
+
if (timeout !== undefined)
|
|
650
|
+
clearTimeout(timeout);
|
|
651
|
+
}
|
|
652
|
+
}
|
|
334
653
|
dispatchPendingDeliveries() {
|
|
335
|
-
for (const run of this.store.listPendingDeliveries(100))
|
|
336
|
-
|
|
654
|
+
for (const run of this.store.listPendingDeliveries(100)) {
|
|
655
|
+
try {
|
|
656
|
+
this.dispatchRunDelivery(run);
|
|
657
|
+
}
|
|
658
|
+
catch {
|
|
659
|
+
// Per-row isolation: the next pending delivery remains dispatchable.
|
|
660
|
+
}
|
|
661
|
+
}
|
|
662
|
+
}
|
|
663
|
+
dispatchPendingIncidentAlerts() {
|
|
664
|
+
const delivery = this.delivery;
|
|
665
|
+
if (delivery === undefined)
|
|
666
|
+
return;
|
|
667
|
+
for (const incident of this.store.listPendingIncidentAlerts(100)) {
|
|
668
|
+
try {
|
|
669
|
+
const target = this.store.getIncidentNotificationTarget(incident.id);
|
|
670
|
+
if (target === undefined)
|
|
671
|
+
continue;
|
|
672
|
+
const lifecycleKey = `automation-incident:${incident.id}:g${incident.lifecycleGeneration}`;
|
|
673
|
+
let outbox;
|
|
674
|
+
if (target.kind === 'owner-route') {
|
|
675
|
+
const enqueue = delivery.enqueueBackgroundRoute;
|
|
676
|
+
const publish = delivery.publishDeliveryPresentation;
|
|
677
|
+
if (enqueue === undefined || publish === undefined)
|
|
678
|
+
continue;
|
|
679
|
+
outbox = incident.alertRef === undefined
|
|
680
|
+
? enqueue.call(delivery, {
|
|
681
|
+
sourceId: 'assistant-automations-incidents',
|
|
682
|
+
authorityId: target.authorityId,
|
|
683
|
+
idempotencyKey: lifecycleKey,
|
|
684
|
+
// Immutable bootstrap text. The typed presentation below owns all
|
|
685
|
+
// mutable details and eventually replaces this exact message.
|
|
686
|
+
text: `Automation incident ${incident.id}`,
|
|
687
|
+
format: 'plain',
|
|
688
|
+
})
|
|
689
|
+
: { id: incident.alertRef, status: 'enqueued' };
|
|
690
|
+
publish.call(delivery, {
|
|
691
|
+
presentationKey: lifecycleKey,
|
|
692
|
+
originalOutboxIdempotencyKey: lifecycleKey,
|
|
693
|
+
revision: incident.presentationRevision,
|
|
694
|
+
presentation: {
|
|
695
|
+
kind: 'automation-incident',
|
|
696
|
+
incidentId: incident.id,
|
|
697
|
+
automationId: incident.automationId,
|
|
698
|
+
definitionHash: incident.definitionHash,
|
|
699
|
+
stage: incident.stage,
|
|
700
|
+
state: incident.state,
|
|
701
|
+
failureClass: incident.failureClass,
|
|
702
|
+
failurePhase: incident.failurePhase,
|
|
703
|
+
failureCode: incident.failureCode,
|
|
704
|
+
sideEffectState: incident.sideEffectState,
|
|
705
|
+
retryability: incident.retryability,
|
|
706
|
+
lifecycleGeneration: incident.lifecycleGeneration,
|
|
707
|
+
incidentRevision: incident.presentationRevision,
|
|
708
|
+
openedAt: incident.openedAt,
|
|
709
|
+
updatedAt: incident.updatedAt,
|
|
710
|
+
...(incident.resolvedAt === undefined ? {} : { resolvedAt: incident.resolvedAt }),
|
|
711
|
+
},
|
|
712
|
+
});
|
|
713
|
+
}
|
|
714
|
+
else {
|
|
715
|
+
// Agent approval bindings are Conversation bindings, not Host owner-
|
|
716
|
+
// route authorities. Emit one idempotent content-free update per
|
|
717
|
+
// durable revision; Delivery intentionally reserves mutable incident
|
|
718
|
+
// presentations for stable Host owner routes.
|
|
719
|
+
outbox = delivery.enqueueBackground({
|
|
720
|
+
sourceId: 'assistant-automations-incidents',
|
|
721
|
+
workspace: target.workspace,
|
|
722
|
+
bindingId: target.bindingId,
|
|
723
|
+
idempotencyKey: `${lifecycleKey}:r${incident.presentationRevision}`,
|
|
724
|
+
text: incidentAlertText(incident),
|
|
725
|
+
format: 'plain',
|
|
726
|
+
});
|
|
727
|
+
}
|
|
728
|
+
this.store.completeIncidentAlert({
|
|
729
|
+
incidentId: incident.id,
|
|
730
|
+
expectedStatus: 'pending',
|
|
731
|
+
expectedLifecycleGeneration: incident.lifecycleGeneration,
|
|
732
|
+
expectedPresentationRevision: incident.presentationRevision,
|
|
733
|
+
expectedVersion: incident.version,
|
|
734
|
+
alertRef: outbox.id,
|
|
735
|
+
now: this.now(),
|
|
736
|
+
});
|
|
737
|
+
}
|
|
738
|
+
catch {
|
|
739
|
+
// Emergency stop / Policy refusal keeps the durable incident and its
|
|
740
|
+
// pending alert. A later authorized tick uses the same idempotency key.
|
|
741
|
+
}
|
|
742
|
+
}
|
|
337
743
|
}
|
|
338
744
|
dispatchRunDelivery(run) {
|
|
339
745
|
if (run.deliveryStatus !== 'pending')
|
|
340
746
|
return;
|
|
341
|
-
|
|
747
|
+
let proof;
|
|
748
|
+
try {
|
|
749
|
+
proof = this.store.getProvenProductionRun(run.id);
|
|
750
|
+
}
|
|
751
|
+
catch {
|
|
752
|
+
this.store.suppressRunDelivery({ runId: run.id, expectedStatus: 'pending', now: this.now() });
|
|
753
|
+
return;
|
|
754
|
+
}
|
|
755
|
+
const automation = proof?.automation;
|
|
756
|
+
const evidence = proof?.run.evidence;
|
|
342
757
|
const bindingId = automation?.definition.deliveryBindingId;
|
|
343
|
-
if (
|
|
758
|
+
if (proof === undefined || proof.run.status !== 'succeeded'
|
|
759
|
+
|| evidence === undefined || automation === undefined || bindingId === undefined) {
|
|
760
|
+
// Unknown/legacy mode, missing immutable snapshot, hash mismatch, and a
|
|
761
|
+
// missing binding all reduce monotonically to a terminal no-send state.
|
|
762
|
+
this.store.suppressRunDelivery({ runId: run.id, expectedStatus: 'pending', now: this.now() });
|
|
344
763
|
return;
|
|
764
|
+
}
|
|
765
|
+
run = proof.run;
|
|
345
766
|
const normalizedOutput = run.outputPreview.normalize('NFC').trim();
|
|
346
767
|
if (normalizedOutput === '' || automation.definition.deliverySuppressExact?.includes(normalizedOutput) === true) {
|
|
347
768
|
this.store.suppressRunDelivery({ runId: run.id, expectedStatus: 'pending', now: this.now() });
|
|
348
769
|
return;
|
|
349
770
|
}
|
|
350
|
-
if (this.delivery === undefined)
|
|
771
|
+
if (this.delivery?.enqueueAutomationResult === undefined)
|
|
351
772
|
return;
|
|
352
773
|
try {
|
|
353
|
-
const outbox = this.delivery.
|
|
354
|
-
|
|
774
|
+
const outbox = this.delivery.enqueueAutomationResult({
|
|
775
|
+
automationId: automation.id,
|
|
776
|
+
runId: run.id,
|
|
355
777
|
workspace: automation.definition.workspace,
|
|
356
778
|
bindingId,
|
|
357
|
-
|
|
358
|
-
text: run.outputPreview,
|
|
359
|
-
format: 'markdown',
|
|
779
|
+
outputPreview: run.outputPreview,
|
|
360
780
|
});
|
|
361
781
|
this.store.completeRunDelivery({
|
|
362
782
|
runId: run.id,
|