@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.
Files changed (53) hide show
  1. package/README.md +39 -7
  2. package/lib/coordinator.d.ts +57 -5
  3. package/lib/coordinator.d.ts.map +1 -1
  4. package/lib/coordinator.js +508 -88
  5. package/lib/coordinator.js.map +1 -1
  6. package/lib/growth.d.ts +120 -0
  7. package/lib/growth.d.ts.map +1 -0
  8. package/lib/growth.js +383 -0
  9. package/lib/growth.js.map +1 -0
  10. package/lib/host-executors.d.ts +25 -0
  11. package/lib/host-executors.d.ts.map +1 -0
  12. package/lib/host-executors.js +181 -0
  13. package/lib/host-executors.js.map +1 -0
  14. package/lib/index.d.ts +1 -1
  15. package/lib/index.d.ts.map +1 -1
  16. package/lib/index.js +1 -1
  17. package/lib/index.js.map +1 -1
  18. package/lib/operator.d.ts +2 -0
  19. package/lib/operator.d.ts.map +1 -1
  20. package/lib/operator.js +6 -2
  21. package/lib/operator.js.map +1 -1
  22. package/lib/proposals.d.ts +2 -2
  23. package/lib/proposals.d.ts.map +1 -1
  24. package/lib/proposals.js +34 -22
  25. package/lib/proposals.js.map +1 -1
  26. package/lib/runner.d.ts +28 -2
  27. package/lib/runner.d.ts.map +1 -1
  28. package/lib/runner.js +525 -73
  29. package/lib/runner.js.map +1 -1
  30. package/lib/service.d.ts +110 -4
  31. package/lib/service.d.ts.map +1 -1
  32. package/lib/service.js +1021 -16
  33. package/lib/service.js.map +1 -1
  34. package/lib/sqlite.d.ts +1 -1
  35. package/lib/sqlite.d.ts.map +1 -1
  36. package/lib/sqlite.js +450 -2
  37. package/lib/sqlite.js.map +1 -1
  38. package/lib/store.d.ts +211 -1
  39. package/lib/store.d.ts.map +1 -1
  40. package/lib/store.js +2260 -58
  41. package/lib/store.js.map +1 -1
  42. package/lib/tools.d.ts.map +1 -1
  43. package/lib/tools.js +18 -6
  44. package/lib/tools.js.map +1 -1
  45. package/lib/types.d.ts +399 -9
  46. package/lib/types.d.ts.map +1 -1
  47. package/lib/types.js +13 -1
  48. package/lib/types.js.map +1 -1
  49. package/lib/version.d.ts +1 -1
  50. package/lib/version.d.ts.map +1 -1
  51. package/lib/version.js +1 -1
  52. package/lib/version.js.map +1 -1
  53. package/package.json +16 -5
@@ -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.store.recoverExpiredTasks({ now, limit: this.maxCatchUp });
78
- this.store.materializeDue({ now, misfireGraceMs: this.misfireGraceMs, maxCatchUp: this.maxCatchUp });
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.all([...this.active.values()].map(value => value.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
- const automation = this.store.get(claimed.automationId);
164
- const occurrence = this.store.getOccurrence(claimed.occurrenceId);
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
- const started = this.store.startTask({
171
- taskId: claimed.id,
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
- const timeout = setTimeout(() => {
180
- timedOut = true;
181
- controller.abort('timeout');
182
- }, automation.definition.timeoutMs);
183
- timeout.unref?.();
184
- const heartbeat = setInterval(() => {
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
- const now = this.now();
187
- this.store.renewDuty({
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
- this.store.heartbeatTask({
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
- leaseMs: this.taskLeaseMs,
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
- controller.abort('lease-lost');
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
- }, Math.max(250, Math.floor(Math.min(this.dutyLeaseMs, this.taskLeaseMs) / 3)));
205
- heartbeat.unref?.();
206
- let result;
207
- try {
208
- result = await this.runner.run({
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
- const outcome = timedOut
234
- ? 'timed_out'
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
- const artifactRef = this.artifacts.write(occurrence.id, {
238
- occurrenceId: occurrence.id,
239
- automationId: automation.id,
240
- taskId: started.id,
241
- outcome,
242
- sessionId: result.sessionId ?? freshSessionId,
243
- output: result.output,
244
- usage: result.usage,
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
- ...(evidenceSessionId === undefined
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
- clearInterval(heartbeat);
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
- this.dispatchRunEvidence(run);
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
- recorder.recordAutomationOutcome(run.evidence);
320
- this.store.completeRunEvidence({ runId: run.id, expectedStatus: 'pending', now: this.now() });
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
- this.dispatchRunDelivery(run);
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
- const automation = this.store.get(run.automationId);
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 (automation === undefined || bindingId === undefined)
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.enqueueBackground({
354
- sourceId: automation.id,
774
+ const outbox = this.delivery.enqueueAutomationResult({
775
+ automationId: automation.id,
776
+ runId: run.id,
355
777
  workspace: automation.definition.workspace,
356
778
  bindingId,
357
- idempotencyKey: `automation:${run.occurrenceId}:${bindingId}`,
358
- text: run.outputPreview,
359
- format: 'markdown',
779
+ outputPreview: run.outputPreview,
360
780
  });
361
781
  this.store.completeRunDelivery({
362
782
  runId: run.id,