@atolis-hq/wake 0.2.30 → 0.2.31

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.
@@ -125,6 +125,8 @@ export function createFakeTicketingSystem(options) {
125
125
  trigger: 'context-only',
126
126
  payload: {
127
127
  intentEventId: input.event.eventId,
128
+ idempotencyKey: input.event.payload.idempotencyKey,
129
+ deliveryState: 'CONFIRMED',
128
130
  labels,
129
131
  },
130
132
  }),
@@ -149,6 +151,9 @@ export function createFakeTicketingSystem(options) {
149
151
  trigger: 'context-only',
150
152
  payload: {
151
153
  intentEventId: input.event.eventId,
154
+ idempotencyKey: input.event.payload.idempotencyKey,
155
+ deliveryState: 'CONFIRMED',
156
+ providerId: commentId,
152
157
  kind: input.event.payload.kind,
153
158
  body: input.event.payload.body,
154
159
  },
@@ -18,6 +18,11 @@ const pollOverlapMs = 60 * 60 * 1000;
18
18
  // expectedEcho bookkeeping surviving a crash (#145).
19
19
  const wakeCommentMarker = '<!-- wake:agent -->';
20
20
  const githubSource = 'github';
21
+ export function wakeIdempotencyMarker(idempotencyKey) {
22
+ return typeof idempotencyKey === 'string'
23
+ ? `<!-- wake:idempotency ${idempotencyKey} -->`
24
+ : undefined;
25
+ }
21
26
  function normalizeTicketUpsert(input) {
22
27
  // Names the resource, never the work item — the resolver stamps the
23
28
  // canonical workItemKey after the poll (spec D1).
@@ -210,6 +215,7 @@ export function formatWakeComment(payload, controlPlaneUrl) {
210
215
  const tokens = typeof payload.tokens === 'string' ? payload.tokens : undefined;
211
216
  const cost = typeof payload.cost === 'string' ? payload.cost : undefined;
212
217
  const workspacePath = typeof payload.workspacePath === 'string' ? payload.workspacePath : undefined;
218
+ const idempotencyMarker = wakeIdempotencyMarker(payload.idempotencyKey);
213
219
  const details = [
214
220
  action === undefined ? undefined : `stage \`${action}\``,
215
221
  runnerName === undefined ? undefined : `runner \`${runnerName}\``,
@@ -225,7 +231,7 @@ export function formatWakeComment(payload, controlPlaneUrl) {
225
231
  ? defaultAgentIdentity
226
232
  : `[${defaultAgentIdentity}](${controlPlaneUrl})`;
227
233
  const header = `**${name}** _(Wake ${wakeVersion}${details.length > 0 ? ` · ${details.join(' · ')}` : ''})_`;
228
- const sections = [wakeCommentMarker, header, body];
234
+ const sections = [wakeCommentMarker, idempotencyMarker, header, body].filter((section) => section !== undefined);
229
235
  if (kind === 'approval-request') {
230
236
  sections.push('_To approve this work, reply with `/approved`. To request changes, reply with `/changes` followed by your feedback. To ask a question without requesting changes, reply with `/ask` followed by your question._');
231
237
  }
@@ -257,6 +263,33 @@ export function formatWakeComment(payload, controlPlaneUrl) {
257
263
  }
258
264
  return sections.join('\n\n');
259
265
  }
266
+ function createIssueCommentPublishedEvent(input) {
267
+ return createEventEnvelope({
268
+ eventId: `${input.event.eventId}-published`,
269
+ workItemKey: input.event.workItemKey,
270
+ streamScope: 'work-item',
271
+ direction: 'outbound',
272
+ sourceSystem: 'github',
273
+ sourceEventType: 'ticket.reply.published',
274
+ sourceRefs: {
275
+ repo: input.repo,
276
+ issueNumber: input.issueNumber,
277
+ ...(input.commentId === undefined ? {} : { commentId: input.commentId }),
278
+ },
279
+ occurredAt: input.publishedAt,
280
+ ingestedAt: input.publishedAt,
281
+ trigger: 'context-only',
282
+ payload: {
283
+ intentEventId: input.event.eventId,
284
+ idempotencyKey: input.event.payload.idempotencyKey,
285
+ deliveryState: 'CONFIRMED',
286
+ kind: input.event.payload.kind,
287
+ body: input.event.payload.body,
288
+ providerEventType: 'github.issue.comment.published',
289
+ ...(input.commentId === undefined ? {} : { providerId: input.commentId }),
290
+ },
291
+ });
292
+ }
260
293
  export function createGitHubIssuesWorkSource(deps) {
261
294
  /** O(1): one shard read, then a direct projection read by work id. */
262
295
  async function readProjectionForIssue(repo, issueNumber) {
@@ -398,6 +431,8 @@ export function createGitHubIssuesWorkSource(deps) {
398
431
  trigger: 'context-only',
399
432
  payload: {
400
433
  intentEventId: input.event.eventId,
434
+ idempotencyKey: input.event.payload.idempotencyKey,
435
+ deliveryState: 'CONFIRMED',
401
436
  ...(nextStatusLabel !== undefined ? { statusLabel: nextStatusLabel } : {}),
402
437
  ...(nextStageLabel !== undefined ? { stageLabel: nextStageLabel } : {}),
403
438
  ...(nextWorkflowLabel !== undefined ? { workflowLabel: nextWorkflowLabel } : {}),
@@ -412,27 +447,75 @@ export function createGitHubIssuesWorkSource(deps) {
412
447
  const response = await deps.client.createComment(owner, repoName, issueNumber, formatWakeComment(input.event.payload, await readControlPlaneUiUrl(deps.config.paths.wakeRoot)));
413
448
  const commentId = extractCreatedCommentId(response);
414
449
  return [
415
- createEventEnvelope({
416
- eventId: `${input.event.eventId}-published`,
417
- workItemKey: input.event.workItemKey,
418
- streamScope: 'work-item',
419
- direction: 'outbound',
420
- sourceSystem: 'github',
421
- sourceEventType: 'ticket.reply.published',
422
- sourceRefs: {
423
- repo,
424
- issueNumber,
425
- ...(commentId === undefined ? {} : { commentId }),
426
- },
427
- occurredAt: publishedAt,
428
- ingestedAt: publishedAt,
429
- trigger: 'context-only',
430
- payload: {
431
- intentEventId: input.event.eventId,
432
- kind: input.event.payload.kind,
433
- body: input.event.payload.body,
434
- providerEventType: 'github.issue.comment.published',
435
- },
450
+ createIssueCommentPublishedEvent({
451
+ event: input.event,
452
+ repo,
453
+ issueNumber,
454
+ publishedAt,
455
+ ...(commentId === undefined ? {} : { commentId }),
456
+ }),
457
+ ];
458
+ },
459
+ async reconcileIntent(input) {
460
+ const repo = input.event.sourceRefs.repo;
461
+ const issueNumber = input.event.sourceRefs.issueNumber;
462
+ if (repo === undefined || issueNumber === undefined) {
463
+ return [];
464
+ }
465
+ const [owner, repoName] = repo.split('/');
466
+ if (owner === undefined || repoName === undefined) {
467
+ return [];
468
+ }
469
+ const publishedAt = deps.now().toISOString();
470
+ if (input.event.sourceEventType === 'wake.labels.requested') {
471
+ const projection = await deps.stateStore.readIssueState(input.event.workItemKey);
472
+ const currentLabels = projection?.issue.labels ?? [];
473
+ const expected = [
474
+ input.event.payload.statusLabel,
475
+ input.event.payload.stageLabel,
476
+ input.event.payload.workflowLabel,
477
+ ].filter((label) => typeof label === 'string');
478
+ if (expected.every((label) => currentLabels.includes(label))) {
479
+ return [
480
+ createEventEnvelope({
481
+ eventId: `${input.event.eventId}-labels-updated`,
482
+ workItemKey: input.event.workItemKey,
483
+ streamScope: 'work-item',
484
+ direction: 'outbound',
485
+ sourceSystem: 'github',
486
+ sourceEventType: 'ticket.labels.updated',
487
+ sourceRefs: { repo, issueNumber },
488
+ occurredAt: publishedAt,
489
+ ingestedAt: publishedAt,
490
+ trigger: 'context-only',
491
+ payload: {
492
+ intentEventId: input.event.eventId,
493
+ idempotencyKey: input.event.payload.idempotencyKey,
494
+ deliveryState: 'CONFIRMED',
495
+ labels: currentLabels,
496
+ providerEventType: 'github.issue.labels.updated',
497
+ },
498
+ }),
499
+ ];
500
+ }
501
+ return [];
502
+ }
503
+ const marker = wakeIdempotencyMarker(input.event.payload.idempotencyKey);
504
+ if (marker === undefined) {
505
+ return [];
506
+ }
507
+ const comments = await deps.client.listComments(owner, repoName, issueNumber, deps.config.sources.github.polling.commentPageSize);
508
+ const existing = comments.find((comment) => (comment.body ?? '').includes(marker));
509
+ if (existing === undefined) {
510
+ return [];
511
+ }
512
+ return [
513
+ createIssueCommentPublishedEvent({
514
+ event: input.event,
515
+ repo,
516
+ issueNumber,
517
+ publishedAt,
518
+ commentId: String(existing.id),
436
519
  }),
437
520
  ];
438
521
  },
@@ -1,6 +1,6 @@
1
1
  import { buildResourceUri } from '../../domain/resource-uri.js';
2
2
  import { createUnkeyedEventEnvelope, createEventEnvelope } from '../../lib/event-log.js';
3
- import { formatGitHubError, formatWakeComment, readControlPlaneUiUrl, } from './github-issues-work-source.js';
3
+ import { formatGitHubError, formatWakeComment, readControlPlaneUiUrl, wakeIdempotencyMarker, } from './github-issues-work-source.js';
4
4
  const githubPrSource = 'github-pr';
5
5
  const wakeCommentMarker = '<!-- wake:agent -->';
6
6
  function prResourceUri(repo, number) {
@@ -61,6 +61,27 @@ export function createGitHubPullRequestActivitySource(deps) {
61
61
  }
62
62
  return { owner, repo, repoRef: `${owner}/${repo}`, number: Number(numberStr) };
63
63
  }
64
+ function reviewThreadRefFromUri(resourceUri) {
65
+ const locator = resourceUri.split(':').slice(2).join(':');
66
+ const match = /^([^/]+)\/([^#]+)#(\d+)\/rt_(\d+)$/.exec(locator);
67
+ if (match === null) {
68
+ return null;
69
+ }
70
+ const [, owner, repo, numberStr, rootIdStr] = match;
71
+ if (owner === undefined ||
72
+ repo === undefined ||
73
+ numberStr === undefined ||
74
+ rootIdStr === undefined) {
75
+ return null;
76
+ }
77
+ return {
78
+ owner,
79
+ repo,
80
+ repoRef: `${owner}/${repo}`,
81
+ number: Number(numberStr),
82
+ rootId: Number(rootIdStr),
83
+ };
84
+ }
64
85
  async function discoverPullRequests(ingestedAt) {
65
86
  const seenPrData = new Map();
66
87
  const confirmedOpenRepos = new Set();
@@ -404,19 +425,11 @@ export function createGitHubPullRequestActivitySource(deps) {
404
425
  }
405
426
  const publishedAt = deps.now().toISOString();
406
427
  if (resourceUri.startsWith('github:pr-review-thread:')) {
407
- const locator = resourceUri.split(':').slice(2).join(':');
408
- const match = /^([^/]+)\/([^#]+)#(\d+)\/rt_(\d+)$/.exec(locator);
409
- if (match === null) {
410
- throw new Error(`cannot deliver intent ${input.event.eventId}: malformed review-thread uri ${resourceUri}`);
411
- }
412
- const [, owner, repo, numberStr, rootIdStr] = match;
413
- if (owner === undefined ||
414
- repo === undefined ||
415
- numberStr === undefined ||
416
- rootIdStr === undefined) {
428
+ const ref = reviewThreadRefFromUri(resourceUri);
429
+ if (ref === null) {
417
430
  throw new Error(`cannot deliver intent ${input.event.eventId}: malformed review-thread uri ${resourceUri}`);
418
431
  }
419
- const response = await deps.client.replyToReviewComment(owner, repo, Number(numberStr), Number(rootIdStr), formatWakeComment(input.event.payload, await readControlPlaneUiUrl(deps.config.paths.wakeRoot)));
432
+ const response = await deps.client.replyToReviewComment(ref.owner, ref.repo, ref.number, ref.rootId, formatWakeComment(input.event.payload, await readControlPlaneUiUrl(deps.config.paths.wakeRoot)));
420
433
  return [
421
434
  createEventEnvelope({
422
435
  eventId: `${input.event.eventId}-published`,
@@ -434,8 +447,11 @@ export function createGitHubPullRequestActivitySource(deps) {
434
447
  trigger: 'context-only',
435
448
  payload: {
436
449
  intentEventId: input.event.eventId,
450
+ idempotencyKey: input.event.payload.idempotencyKey,
451
+ deliveryState: 'CONFIRMED',
437
452
  kind: input.event.payload.kind,
438
453
  body: input.event.payload.body,
454
+ providerId: response?.id,
439
455
  },
440
456
  }),
441
457
  ];
@@ -444,7 +460,79 @@ export function createGitHubPullRequestActivitySource(deps) {
444
460
  if (ref === null) {
445
461
  throw new Error(`cannot deliver intent ${input.event.eventId}: malformed pr uri ${resourceUri}`);
446
462
  }
447
- await deps.client.createComment(ref.owner, ref.repo, ref.number, formatWakeComment(input.event.payload, await readControlPlaneUiUrl(deps.config.paths.wakeRoot)));
463
+ const response = await deps.client.createComment(ref.owner, ref.repo, ref.number, formatWakeComment(input.event.payload, await readControlPlaneUiUrl(deps.config.paths.wakeRoot)));
464
+ return [
465
+ createEventEnvelope({
466
+ eventId: `${input.event.eventId}-published`,
467
+ workItemKey: input.event.workItemKey,
468
+ streamScope: 'work-item',
469
+ direction: 'outbound',
470
+ sourceSystem: githubPrSource,
471
+ sourceEventType: 'pr.comment.reply.published',
472
+ sourceRefs: { repo: ref.repoRef, resourceUri },
473
+ occurredAt: publishedAt,
474
+ ingestedAt: publishedAt,
475
+ trigger: 'context-only',
476
+ payload: {
477
+ intentEventId: input.event.eventId,
478
+ idempotencyKey: input.event.payload.idempotencyKey,
479
+ deliveryState: 'CONFIRMED',
480
+ kind: input.event.payload.kind,
481
+ body: input.event.payload.body,
482
+ providerId: response?.data?.id,
483
+ },
484
+ }),
485
+ ];
486
+ },
487
+ async reconcileIntent(input) {
488
+ const resourceUri = input.event.sourceRefs.resourceUri;
489
+ const marker = wakeIdempotencyMarker(input.event.payload.idempotencyKey);
490
+ if (resourceUri === undefined || marker === undefined) {
491
+ return [];
492
+ }
493
+ const publishedAt = deps.now().toISOString();
494
+ if (resourceUri.startsWith('github:pr-review-thread:')) {
495
+ const ref = reviewThreadRefFromUri(resourceUri);
496
+ if (ref === null) {
497
+ return [];
498
+ }
499
+ const comments = await deps.client.listReviewComments(ref.owner, ref.repo, ref.number, deps.config.sources.github.pullRequests.commentPageSize);
500
+ const existing = comments.find((comment) => (comment.body ?? '').includes(marker));
501
+ if (existing === undefined) {
502
+ return [];
503
+ }
504
+ return [
505
+ createEventEnvelope({
506
+ eventId: `${input.event.eventId}-published`,
507
+ workItemKey: input.event.workItemKey,
508
+ streamScope: 'work-item',
509
+ direction: 'outbound',
510
+ sourceSystem: githubPrSource,
511
+ sourceEventType: 'pr.review-comment.reply.published',
512
+ sourceRefs: { resourceUri, sourceUrl: existing.html_url },
513
+ occurredAt: publishedAt,
514
+ ingestedAt: publishedAt,
515
+ trigger: 'context-only',
516
+ payload: {
517
+ intentEventId: input.event.eventId,
518
+ idempotencyKey: input.event.payload.idempotencyKey,
519
+ deliveryState: 'CONFIRMED',
520
+ kind: input.event.payload.kind,
521
+ body: input.event.payload.body,
522
+ providerId: existing.id,
523
+ },
524
+ }),
525
+ ];
526
+ }
527
+ const ref = repoAndNumberFromPrUri(resourceUri);
528
+ if (ref === null) {
529
+ return [];
530
+ }
531
+ const comments = await deps.client.listComments(ref.owner, ref.repo, ref.number, deps.config.sources.github.pullRequests.commentPageSize);
532
+ const existing = comments.find((comment) => (comment.body ?? '').includes(marker));
533
+ if (existing === undefined) {
534
+ return [];
535
+ }
448
536
  return [
449
537
  createEventEnvelope({
450
538
  eventId: `${input.event.eventId}-published`,
@@ -459,8 +547,11 @@ export function createGitHubPullRequestActivitySource(deps) {
459
547
  trigger: 'context-only',
460
548
  payload: {
461
549
  intentEventId: input.event.eventId,
550
+ idempotencyKey: input.event.payload.idempotencyKey,
551
+ deliveryState: 'CONFIRMED',
462
552
  kind: input.event.payload.kind,
463
553
  body: input.event.payload.body,
554
+ providerId: existing.id,
464
555
  },
465
556
  }),
466
557
  ];
@@ -29,6 +29,8 @@ function isFreshTriggeringComment(candidate) {
29
29
  export function createPublishIntentEvent(input) {
30
30
  const tokenCount = extractTokenCount(input.runnerResult.tokenUsage);
31
31
  const duration = formatDuration(input.startedAt, input.occurredAt);
32
+ const failureRepeated = input.runnerResult.failureClass !== undefined &&
33
+ input.previousFailureClass === input.runnerResult.failureClass;
32
34
  return createEventEnvelope({
33
35
  eventId: `${input.runId}-publish-intent`,
34
36
  workItemKey: input.projection.workItemKey,
@@ -107,6 +109,12 @@ export function createPublishIntentEvent(input) {
107
109
  ? {}
108
110
  : { cost: formatCostUsd(input.runnerResult.tokenUsage.costUsd) }),
109
111
  ...(input.workspacePath === undefined ? {} : { workspacePath: input.workspacePath }),
112
+ idempotencyKey: `${input.runId}:result-comment`,
113
+ deliveryState: 'PENDING',
114
+ ...(failureRepeated ? { failureRepeated } : {}),
115
+ ...(input.previousFailureClass === undefined
116
+ ? {}
117
+ : { previousFailureClass: input.previousFailureClass }),
110
118
  },
111
119
  derivedHints: {
112
120
  stage: input.sentinel === 'DONE' ? 'done' : input.projection.wake.stage,
@@ -114,6 +122,7 @@ export function createPublishIntentEvent(input) {
114
122
  });
115
123
  }
116
124
  export function createLabelsEvent(input) {
125
+ const labelKind = input.statusLabel === 'wake:status.working' ? 'working-label' : 'completion-label';
117
126
  return createEventEnvelope({
118
127
  eventId: `${input.runId}-labels-${input.statusLabel.replace(/[^a-z0-9]+/gi, '-')}-${input.stageLabel.replace(/[^a-z0-9]+/gi, '-')}-${input.workflowLabel.replace(/[^a-z0-9]+/gi, '-')}`,
119
128
  workItemKey: input.projection.workItemKey,
@@ -134,6 +143,8 @@ export function createLabelsEvent(input) {
134
143
  stageLabel: input.stageLabel,
135
144
  workflowLabel: input.workflowLabel,
136
145
  origin: input.projection.origin ?? 'github',
146
+ idempotencyKey: `${input.runId}:${labelKind}`,
147
+ deliveryState: 'PENDING',
137
148
  },
138
149
  });
139
150
  }
@@ -12,6 +12,10 @@ const outboundConfirmationEventTypes = new Set([
12
12
  'pr.comment.reply.published',
13
13
  'pr.review-comment.reply.published',
14
14
  ]);
15
+ function intentId(event) {
16
+ const intentEventId = event.payload.intentEventId;
17
+ return typeof intentEventId === 'string' ? intentEventId : undefined;
18
+ }
15
19
  // The outbox: outbound delivery (comments, labels) attempted independently of
16
20
  // run-outcome recording, with a durable, bounded retry trace. Extracted from
17
21
  // tick-runner.ts so it can be exercised in isolation; it has the cleanest
@@ -33,12 +37,60 @@ export function createOutbox(deps) {
33
37
  payload: {
34
38
  intentEventId: intentEvent.eventId,
35
39
  intentEventType: intentEvent.sourceEventType,
40
+ idempotencyKey: intentEvent.payload.idempotencyKey,
41
+ deliveryState: 'PENDING',
36
42
  error: err instanceof Error ? err.message : String(err),
37
43
  },
38
44
  });
39
45
  await deps.stateStore.appendEventEnvelope(failureEvent);
40
46
  await deps.projectionUpdater.rebuildFromEvents([failureEvent]);
41
47
  }
48
+ async function recordSentUnconfirmed(intentEvent) {
49
+ const occurredAt = deps.clock.now().toISOString();
50
+ const sentEvent = createEventEnvelope({
51
+ eventId: `${intentEvent.eventId}-sent-unconfirmed-${randomUUID()}`,
52
+ workItemKey: intentEvent.workItemKey,
53
+ streamScope: 'work-item',
54
+ direction: 'internal',
55
+ sourceSystem: 'wake',
56
+ sourceEventType: 'wake.publish.sent-unconfirmed',
57
+ sourceRefs: intentEvent.sourceRefs,
58
+ occurredAt,
59
+ ingestedAt: occurredAt,
60
+ trigger: 'context-only',
61
+ payload: {
62
+ intentEventId: intentEvent.eventId,
63
+ intentEventType: intentEvent.sourceEventType,
64
+ idempotencyKey: intentEvent.payload.idempotencyKey,
65
+ deliveryState: 'SENT_UNCONFIRMED',
66
+ },
67
+ });
68
+ await deps.stateStore.appendEventEnvelope(sentEvent);
69
+ await deps.projectionUpdater.rebuildFromEvents([sentEvent]);
70
+ }
71
+ async function recordConfirmed(intentEvent, payload = {}) {
72
+ const confirmedAt = deps.clock.now().toISOString();
73
+ const confirmedEvent = createEventEnvelope({
74
+ eventId: `${intentEvent.eventId}-confirmed`,
75
+ workItemKey: intentEvent.workItemKey,
76
+ streamScope: 'work-item',
77
+ direction: 'internal',
78
+ sourceSystem: 'wake',
79
+ sourceEventType: 'wake.publish.confirmed',
80
+ sourceRefs: intentEvent.sourceRefs,
81
+ occurredAt: confirmedAt,
82
+ ingestedAt: confirmedAt,
83
+ trigger: 'context-only',
84
+ payload: {
85
+ intentEventId: intentEvent.eventId,
86
+ idempotencyKey: intentEvent.payload.idempotencyKey,
87
+ deliveryState: 'CONFIRMED',
88
+ ...payload,
89
+ },
90
+ });
91
+ const appended = await deps.stateStore.appendEventEnvelope(confirmedEvent);
92
+ await deps.projectionUpdater.rebuildFromEvents([appended]);
93
+ }
42
94
  // Outbound delivery (comments, labels) is attempted independently of run-outcome
43
95
  // recording: a delivery failure must never rewrite an already-recorded run result
44
96
  // (S1), and must always leave a durable, retryable trace instead of being lost
@@ -48,6 +100,7 @@ export function createOutbox(deps) {
48
100
  return;
49
101
  }
50
102
  try {
103
+ await recordSentUnconfirmed(event);
51
104
  const deliveryEvents = await deps.outboundSink.deliverIntent({ event });
52
105
  for (const deliveryEvent of deliveryEvents) {
53
106
  await deps.stateStore.appendEventEnvelope(deliveryEvent);
@@ -57,21 +110,7 @@ export function createOutbox(deps) {
57
110
  // No confirmation event was produced (e.g. a no-op label update) but the
58
111
  // sink did not throw. Record that delivery was attempted successfully so
59
112
  // the outbox scan below does not retry it indefinitely.
60
- const confirmedAt = deps.clock.now().toISOString();
61
- const confirmedEvent = createEventEnvelope({
62
- eventId: `${event.eventId}-confirmed`,
63
- workItemKey: event.workItemKey,
64
- streamScope: 'work-item',
65
- direction: 'internal',
66
- sourceSystem: 'wake',
67
- sourceEventType: 'wake.publish.confirmed',
68
- sourceRefs: event.sourceRefs,
69
- occurredAt: confirmedAt,
70
- ingestedAt: confirmedAt,
71
- trigger: 'context-only',
72
- payload: { intentEventId: event.eventId },
73
- });
74
- await deps.stateStore.appendEventEnvelope(confirmedEvent);
113
+ await recordConfirmed(event);
75
114
  }
76
115
  }
77
116
  catch (err) {
@@ -83,6 +122,18 @@ export function createOutbox(deps) {
83
122
  await deps.projectionUpdater.rebuildFromEvents([event]);
84
123
  await attemptDelivery(event);
85
124
  }
125
+ async function suppressOutboundEvent(event, input) {
126
+ const suppressedEvent = await deps.stateStore.appendEventEnvelope({
127
+ ...event,
128
+ payload: {
129
+ ...event.payload,
130
+ deliveryState: 'CONFIRMED',
131
+ suppressedPublishReason: input.suppressedPublishReason,
132
+ },
133
+ });
134
+ await deps.projectionUpdater.rebuildFromEvents([suppressedEvent]);
135
+ await recordConfirmed(event, { suppressedPublishReason: input.suppressedPublishReason });
136
+ }
86
137
  // Adopts the outbox pattern: an intent is only considered delivered once a
87
138
  // matching confirmation event exists. Anything left unconfirmed by a prior tick
88
139
  // (e.g. the process crashed mid-delivery) is retried here, bounded so a
@@ -94,9 +145,10 @@ export function createOutbox(deps) {
94
145
  const events = await deps.stateStore.listEventEnvelopes();
95
146
  const confirmedIntentIds = new Set();
96
147
  const failureAttempts = new Map();
148
+ const uncertainIntentIds = new Set();
97
149
  for (const event of events) {
98
- const intentEventId = event.payload.intentEventId;
99
- if (typeof intentEventId !== 'string') {
150
+ const intentEventId = intentId(event);
151
+ if (intentEventId === undefined) {
100
152
  continue;
101
153
  }
102
154
  if (outboundConfirmationEventTypes.has(event.sourceEventType)) {
@@ -105,6 +157,9 @@ export function createOutbox(deps) {
105
157
  if (event.sourceEventType === 'wake.publish.failed') {
106
158
  failureAttempts.set(intentEventId, (failureAttempts.get(intentEventId) ?? 0) + 1);
107
159
  }
160
+ if (event.sourceEventType === 'wake.publish.sent-unconfirmed') {
161
+ uncertainIntentIds.add(intentEventId);
162
+ }
108
163
  }
109
164
  for (const intent of events) {
110
165
  if (!outboundIntentEventTypes.has(intent.sourceEventType)) {
@@ -116,8 +171,30 @@ export function createOutbox(deps) {
116
171
  if ((failureAttempts.get(intent.eventId) ?? 0) >= outboxMaxAttempts) {
117
172
  continue;
118
173
  }
174
+ if (uncertainIntentIds.has(intent.eventId) &&
175
+ deps.outboundSink.reconcileIntent !== undefined) {
176
+ try {
177
+ const reconciledEvents = await deps.outboundSink.reconcileIntent({ event: intent });
178
+ for (const reconciledEvent of reconciledEvents) {
179
+ await deps.stateStore.appendEventEnvelope(reconciledEvent);
180
+ }
181
+ await deps.projectionUpdater.rebuildFromEvents(reconciledEvents);
182
+ if (reconciledEvents.length > 0) {
183
+ continue;
184
+ }
185
+ }
186
+ catch (err) {
187
+ await recordDeliveryFailure(intent, err);
188
+ continue;
189
+ }
190
+ }
119
191
  await attemptDelivery(intent);
120
192
  }
121
193
  }
122
- return { attemptDelivery, deliverOutboundEvent, retryUnconfirmedDeliveries };
194
+ return {
195
+ attemptDelivery,
196
+ deliverOutboundEvent,
197
+ retryUnconfirmedDeliveries,
198
+ suppressOutboundEvent,
199
+ };
123
200
  }
@@ -38,36 +38,34 @@ function withSinkRef(event, sink) {
38
38
  },
39
39
  };
40
40
  }
41
+ function targetSinksForEvent(input) {
42
+ const targetSinks = new Set();
43
+ const origin = typeof input.event.payload.origin === 'string' ? input.event.payload.origin : undefined;
44
+ const sourceOrigin = input.event.sourceRefs.sink ?? origin;
45
+ if (input.event.sourceEventType === 'wake.labels.requested') {
46
+ const projectionOrigin = typeof input.event.payload.origin === 'string' ? input.event.payload.origin : undefined;
47
+ if (projectionOrigin !== undefined) {
48
+ targetSinks.add(projectionOrigin);
49
+ }
50
+ }
51
+ const resourceUri = input.event.sourceRefs.resourceUri;
52
+ if (input.event.sourceEventType === 'wake.publish.intent.requested' &&
53
+ sourceOrigin !== undefined) {
54
+ const resourceSink = resourceUri === undefined ? sourceOrigin : sinkNameForResourceUri(resourceUri, sourceOrigin);
55
+ targetSinks.add(input.sinksByName.has(resourceSink) ? resourceSink : sourceOrigin);
56
+ }
57
+ for (const [sinkName, sinkConfig] of Object.entries(input.config.sinks ?? {})) {
58
+ if (sinkConfig.subscribe.some((subscription) => subscriptionMatches(input.event, subscription))) {
59
+ targetSinks.add(sinkName);
60
+ }
61
+ }
62
+ return targetSinks;
63
+ }
41
64
  export function createOutboundSinkRouter(input) {
42
65
  const sinksByName = new Map(input.sinks.map((sink) => [sink.sink, sink]));
43
66
  return {
44
67
  async deliverIntent({ event }) {
45
- const targetSinks = new Set();
46
- const origin = typeof event.payload.origin === 'string' ? event.payload.origin : undefined;
47
- const sourceOrigin = event.sourceRefs.sink ?? origin;
48
- if (event.sourceEventType === 'wake.labels.requested') {
49
- const projectionOrigin = typeof event.payload.origin === 'string' ? event.payload.origin : undefined;
50
- if (projectionOrigin !== undefined) {
51
- targetSinks.add(projectionOrigin);
52
- }
53
- }
54
- const resourceUri = event.sourceRefs.resourceUri;
55
- if (event.sourceEventType === 'wake.publish.intent.requested' && sourceOrigin !== undefined) {
56
- const resourceSink = resourceUri === undefined
57
- ? sourceOrigin
58
- : sinkNameForResourceUri(resourceUri, sourceOrigin);
59
- // A resource-derived sink name (e.g. a PR surface) may not be
60
- // registered — the source that owns it can be disabled independently
61
- // of the origin sink. Falling back to sourceOrigin here, rather than
62
- // silently skipping an unregistered sink below, is what keeps a reply
63
- // from being dropped-but-marked-delivered when that happens.
64
- targetSinks.add(sinksByName.has(resourceSink) ? resourceSink : sourceOrigin);
65
- }
66
- for (const [sinkName, sinkConfig] of Object.entries(input.config.sinks ?? {})) {
67
- if (sinkConfig.subscribe.some((subscription) => subscriptionMatches(event, subscription))) {
68
- targetSinks.add(sinkName);
69
- }
70
- }
68
+ const targetSinks = targetSinksForEvent({ event, sinksByName, config: input.config });
71
69
  const deliveryEvents = [];
72
70
  for (const sinkName of targetSinks) {
73
71
  const sink = sinksByName.get(sinkName);
@@ -79,5 +77,18 @@ export function createOutboundSinkRouter(input) {
79
77
  }
80
78
  return deliveryEvents;
81
79
  },
80
+ async reconcileIntent({ event }) {
81
+ const targetSinks = targetSinksForEvent({ event, sinksByName, config: input.config });
82
+ const deliveryEvents = [];
83
+ for (const sinkName of targetSinks) {
84
+ const sink = sinksByName.get(sinkName);
85
+ if (sink?.reconcileIntent === undefined) {
86
+ continue;
87
+ }
88
+ const sinkDeliveryEvents = await sink.reconcileIntent({ event });
89
+ deliveryEvents.push(...sinkDeliveryEvents.map((deliveryEvent) => withSinkRef(deliveryEvent, sinkName)));
90
+ }
91
+ return deliveryEvents;
92
+ },
82
93
  };
83
94
  }
@@ -41,7 +41,7 @@ export function createTickRunner(deps) {
41
41
  resourceIndex: deps.resourceIndex,
42
42
  config: deps.config,
43
43
  });
44
- const { deliverOutboundEvent, retryUnconfirmedDeliveries } = createOutbox({
44
+ const { deliverOutboundEvent, retryUnconfirmedDeliveries, suppressOutboundEvent } = createOutbox({
45
45
  clock: deps.clock,
46
46
  stateStore: deps.stateStore,
47
47
  projectionUpdater,
@@ -137,6 +137,7 @@ export function createTickRunner(deps) {
137
137
  relation: 'primary',
138
138
  provenance: 'agent-reported',
139
139
  registeredBy: input.runId,
140
+ idempotencyKey: `${input.runId}:artifact-registration:${verified.resourceUri}`,
140
141
  },
141
142
  });
142
143
  const appended = await deps.stateStore.appendEventEnvelope(event);
@@ -721,23 +722,31 @@ export function createTickRunner(deps) {
721
722
  workflowLabel: workflowLabelForWorkflowName(workflowName),
722
723
  occurredAt: finishedAt,
723
724
  }));
725
+ const publishIntent = createPublishIntentEvent({
726
+ projection: candidate,
727
+ runId,
728
+ action,
729
+ runnerResult,
730
+ parsedRunnerResult,
731
+ sentinel,
732
+ occurredAt: finishedAt,
733
+ startedAt: nowIso,
734
+ ...(workspacePath === undefined ? {} : { workspacePath }),
735
+ ...(typeof candidate.context.lastFailureClass === 'string'
736
+ ? { previousFailureClass: candidate.context.lastFailureClass }
737
+ : {}),
738
+ });
724
739
  if (shouldPublishRunResult({
725
740
  failureClass: runnerResult.failureClass,
726
741
  previousFailureClass: candidate.context.lastFailureClass,
727
742
  })) {
728
- const publishIntent = createPublishIntentEvent({
729
- projection: candidate,
730
- runId,
731
- action,
732
- runnerResult,
733
- parsedRunnerResult,
734
- sentinel,
735
- occurredAt: finishedAt,
736
- startedAt: nowIso,
737
- ...(workspacePath === undefined ? {} : { workspacePath }),
738
- });
739
743
  await deliverOutboundEvent(publishIntent);
740
744
  }
745
+ else {
746
+ await suppressOutboundEvent(publishIntent, {
747
+ suppressedPublishReason: runnerResult.failureClass === 'quota' ? 'quota-failure' : 'repeated-infra-failure',
748
+ });
749
+ }
741
750
  return {
742
751
  status: 'processed',
743
752
  runId,
@@ -805,6 +814,7 @@ export function createTickRunner(deps) {
805
814
  result: errorMessage,
806
815
  model: 'unknown',
807
816
  cli: 'unknown',
817
+ failureClass: 'infra',
808
818
  };
809
819
  const parsedInfraFailureResult = parseRunnerResult(infraFailureResult.result);
810
820
  const failurePublishIntent = createPublishIntentEvent({
@@ -816,6 +826,9 @@ export function createTickRunner(deps) {
816
826
  sentinel,
817
827
  occurredAt: finishedAt,
818
828
  startedAt: nowIso,
829
+ ...(typeof candidate.context.lastFailureClass === 'string'
830
+ ? { previousFailureClass: candidate.context.lastFailureClass }
831
+ : {}),
819
832
  });
820
833
  if (shouldPublishRunResult({
821
834
  failureClass: 'infra',
@@ -823,6 +836,11 @@ export function createTickRunner(deps) {
823
836
  })) {
824
837
  await deliverOutboundEvent(failurePublishIntent);
825
838
  }
839
+ else {
840
+ await suppressOutboundEvent(failurePublishIntent, {
841
+ suppressedPublishReason: 'repeated-infra-failure',
842
+ });
843
+ }
826
844
  return {
827
845
  status: 'processed',
828
846
  runId,
@@ -124,4 +124,4 @@ export function resolveWakeVersion(options = {}) {
124
124
  }
125
125
  return '0.1.0-dev';
126
126
  }
127
- export const wakeVersion = "gfb459ce";
127
+ export const wakeVersion = "g18ca5a7";
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@atolis-hq/wake",
3
- "version": "0.2.30",
3
+ "version": "0.2.31",
4
4
  "description": "Local autonomous agent control plane for software development",
5
5
  "license": "Apache-2.0",
6
6
  "repository": {