@atolis-hq/wake 0.2.30 → 0.2.32

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
  },
@@ -66,6 +66,14 @@ export function createGitHubClient(token) {
66
66
  }),
67
67
  });
68
68
  },
69
+ async getIssue(owner, repo, issueNumber) {
70
+ const { data } = await octokit.rest.issues.get({
71
+ owner,
72
+ repo,
73
+ issue_number: issueNumber,
74
+ });
75
+ return data;
76
+ },
69
77
  async createComment(owner, repo, issueNumber, body) {
70
78
  return octokit.rest.issues.createComment({
71
79
  owner,
@@ -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).
@@ -180,6 +185,9 @@ export async function readControlPlaneUiUrl(wakeRoot) {
180
185
  return undefined;
181
186
  }
182
187
  }
188
+ function isGitHubNotFound(error) {
189
+ return error instanceof Error && error.status === 404;
190
+ }
183
191
  export function formatGitHubError(error) {
184
192
  if (error instanceof Error) {
185
193
  const octokit = error;
@@ -210,6 +218,7 @@ export function formatWakeComment(payload, controlPlaneUrl) {
210
218
  const tokens = typeof payload.tokens === 'string' ? payload.tokens : undefined;
211
219
  const cost = typeof payload.cost === 'string' ? payload.cost : undefined;
212
220
  const workspacePath = typeof payload.workspacePath === 'string' ? payload.workspacePath : undefined;
221
+ const idempotencyMarker = wakeIdempotencyMarker(payload.idempotencyKey);
213
222
  const details = [
214
223
  action === undefined ? undefined : `stage \`${action}\``,
215
224
  runnerName === undefined ? undefined : `runner \`${runnerName}\``,
@@ -225,7 +234,7 @@ export function formatWakeComment(payload, controlPlaneUrl) {
225
234
  ? defaultAgentIdentity
226
235
  : `[${defaultAgentIdentity}](${controlPlaneUrl})`;
227
236
  const header = `**${name}** _(Wake ${wakeVersion}${details.length > 0 ? ` · ${details.join(' · ')}` : ''})_`;
228
- const sections = [wakeCommentMarker, header, body];
237
+ const sections = [wakeCommentMarker, idempotencyMarker, header, body].filter((section) => section !== undefined);
229
238
  if (kind === 'approval-request') {
230
239
  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
240
  }
@@ -257,6 +266,33 @@ export function formatWakeComment(payload, controlPlaneUrl) {
257
266
  }
258
267
  return sections.join('\n\n');
259
268
  }
269
+ function createIssueCommentPublishedEvent(input) {
270
+ return createEventEnvelope({
271
+ eventId: `${input.event.eventId}-published`,
272
+ workItemKey: input.event.workItemKey,
273
+ streamScope: 'work-item',
274
+ direction: 'outbound',
275
+ sourceSystem: 'github',
276
+ sourceEventType: 'ticket.reply.published',
277
+ sourceRefs: {
278
+ repo: input.repo,
279
+ issueNumber: input.issueNumber,
280
+ ...(input.commentId === undefined ? {} : { commentId: input.commentId }),
281
+ },
282
+ occurredAt: input.publishedAt,
283
+ ingestedAt: input.publishedAt,
284
+ trigger: 'context-only',
285
+ payload: {
286
+ intentEventId: input.event.eventId,
287
+ idempotencyKey: input.event.payload.idempotencyKey,
288
+ deliveryState: 'CONFIRMED',
289
+ kind: input.event.payload.kind,
290
+ body: input.event.payload.body,
291
+ providerEventType: 'github.issue.comment.published',
292
+ ...(input.commentId === undefined ? {} : { providerId: input.commentId }),
293
+ },
294
+ });
295
+ }
260
296
  export function createGitHubIssuesWorkSource(deps) {
261
297
  /** O(1): one shard read, then a direct projection read by work id. */
262
298
  async function readProjectionForIssue(repo, issueNumber) {
@@ -268,6 +304,71 @@ export function createGitHubIssuesWorkSource(deps) {
268
304
  return deps.stateStore.readIssueState(workItemKey);
269
305
  }
270
306
  return {
307
+ async refreshForDispatch(input) {
308
+ const repoRef = input.projection.issue.repo;
309
+ if (deps.client.getIssue === undefined) {
310
+ return null;
311
+ }
312
+ if (!deps.config.sources.github.repos.includes(repoRef)) {
313
+ return null;
314
+ }
315
+ const [owner, repo] = repoRef.split('/');
316
+ if (owner === undefined || repo === undefined) {
317
+ return null;
318
+ }
319
+ const ingestedAt = deps.now().toISOString();
320
+ let issue;
321
+ try {
322
+ issue = await deps.client.getIssue(owner, repo, input.projection.issue.number);
323
+ }
324
+ catch (error) {
325
+ if (isGitHubNotFound(error)) {
326
+ return {
327
+ events: [],
328
+ sourceRevision: `github:issue:${repoRef}#${input.projection.issue.number}@missing`,
329
+ sourceExists: false,
330
+ };
331
+ }
332
+ throw error;
333
+ }
334
+ const events = [];
335
+ if (input.projection.issue.updatedAt !== issue.updated_at) {
336
+ events.push(normalizeTicketUpsert({
337
+ repo: repoRef,
338
+ issue,
339
+ ingestedAt,
340
+ expectedEcho: isExpectedLabelEcho(issue, input.projection),
341
+ }));
342
+ }
343
+ const comments = await deps.client.listComments(owner, repo, input.projection.issue.number, deps.config.sources.github.polling.commentPageSize);
344
+ let latestCommentRevision = '';
345
+ for (const comment of comments) {
346
+ if (comment.updated_at > latestCommentRevision) {
347
+ latestCommentRevision = comment.updated_at;
348
+ }
349
+ const known = input.projection.comments.find((entry) => entry.id === String(comment.id));
350
+ if (known?.updatedAt === comment.updated_at) {
351
+ continue;
352
+ }
353
+ if (input.projection.wake.expectedEcho.commentIds.includes(String(comment.id))) {
354
+ continue;
355
+ }
356
+ events.push(normalizeTicketCommentEvent({
357
+ repo: repoRef,
358
+ issueNumber: input.projection.issue.number,
359
+ comment,
360
+ ingestedAt,
361
+ ...(deps.selfLogin === undefined ? {} : { selfLogin: deps.selfLogin }),
362
+ ...(known?.updatedAt === undefined ? {} : { existingUpdatedAt: known.updatedAt }),
363
+ }));
364
+ }
365
+ return {
366
+ events,
367
+ sourceRevision: latestCommentRevision.length > 0
368
+ ? `github:issue:${repoRef}#${issue.number}@${issue.updated_at};comments@${latestCommentRevision}`
369
+ : `github:issue:${repoRef}#${issue.number}@${issue.updated_at}`,
370
+ };
371
+ },
271
372
  async pollEvents(_input) {
272
373
  const ingestedAt = deps.now().toISOString();
273
374
  const events = [];
@@ -398,6 +499,8 @@ export function createGitHubIssuesWorkSource(deps) {
398
499
  trigger: 'context-only',
399
500
  payload: {
400
501
  intentEventId: input.event.eventId,
502
+ idempotencyKey: input.event.payload.idempotencyKey,
503
+ deliveryState: 'CONFIRMED',
401
504
  ...(nextStatusLabel !== undefined ? { statusLabel: nextStatusLabel } : {}),
402
505
  ...(nextStageLabel !== undefined ? { stageLabel: nextStageLabel } : {}),
403
506
  ...(nextWorkflowLabel !== undefined ? { workflowLabel: nextWorkflowLabel } : {}),
@@ -412,27 +515,75 @@ export function createGitHubIssuesWorkSource(deps) {
412
515
  const response = await deps.client.createComment(owner, repoName, issueNumber, formatWakeComment(input.event.payload, await readControlPlaneUiUrl(deps.config.paths.wakeRoot)));
413
516
  const commentId = extractCreatedCommentId(response);
414
517
  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
- },
518
+ createIssueCommentPublishedEvent({
519
+ event: input.event,
520
+ repo,
521
+ issueNumber,
522
+ publishedAt,
523
+ ...(commentId === undefined ? {} : { commentId }),
524
+ }),
525
+ ];
526
+ },
527
+ async reconcileIntent(input) {
528
+ const repo = input.event.sourceRefs.repo;
529
+ const issueNumber = input.event.sourceRefs.issueNumber;
530
+ if (repo === undefined || issueNumber === undefined) {
531
+ return [];
532
+ }
533
+ const [owner, repoName] = repo.split('/');
534
+ if (owner === undefined || repoName === undefined) {
535
+ return [];
536
+ }
537
+ const publishedAt = deps.now().toISOString();
538
+ if (input.event.sourceEventType === 'wake.labels.requested') {
539
+ const projection = await deps.stateStore.readIssueState(input.event.workItemKey);
540
+ const currentLabels = projection?.issue.labels ?? [];
541
+ const expected = [
542
+ input.event.payload.statusLabel,
543
+ input.event.payload.stageLabel,
544
+ input.event.payload.workflowLabel,
545
+ ].filter((label) => typeof label === 'string');
546
+ if (expected.every((label) => currentLabels.includes(label))) {
547
+ return [
548
+ createEventEnvelope({
549
+ eventId: `${input.event.eventId}-labels-updated`,
550
+ workItemKey: input.event.workItemKey,
551
+ streamScope: 'work-item',
552
+ direction: 'outbound',
553
+ sourceSystem: 'github',
554
+ sourceEventType: 'ticket.labels.updated',
555
+ sourceRefs: { repo, issueNumber },
556
+ occurredAt: publishedAt,
557
+ ingestedAt: publishedAt,
558
+ trigger: 'context-only',
559
+ payload: {
560
+ intentEventId: input.event.eventId,
561
+ idempotencyKey: input.event.payload.idempotencyKey,
562
+ deliveryState: 'CONFIRMED',
563
+ labels: currentLabels,
564
+ providerEventType: 'github.issue.labels.updated',
565
+ },
566
+ }),
567
+ ];
568
+ }
569
+ return [];
570
+ }
571
+ const marker = wakeIdempotencyMarker(input.event.payload.idempotencyKey);
572
+ if (marker === undefined) {
573
+ return [];
574
+ }
575
+ const comments = await deps.client.listComments(owner, repoName, issueNumber, deps.config.sources.github.polling.commentPageSize);
576
+ const existing = comments.find((comment) => (comment.body ?? '').includes(marker));
577
+ if (existing === undefined) {
578
+ return [];
579
+ }
580
+ return [
581
+ createIssueCommentPublishedEvent({
582
+ event: input.event,
583
+ repo,
584
+ issueNumber,
585
+ publishedAt,
586
+ commentId: String(existing.id),
436
587
  }),
437
588
  ];
438
589
  },
@@ -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
  }
@@ -4,6 +4,18 @@ export function createWorkSourceFanIn(sources) {
4
4
  const batches = await Promise.all(sources.map((source) => source.pollEvents(input)));
5
5
  return batches.flat();
6
6
  },
7
+ async refreshForDispatch(input) {
8
+ for (const source of sources) {
9
+ if (source.refreshForDispatch === undefined) {
10
+ continue;
11
+ }
12
+ const result = await source.refreshForDispatch(input);
13
+ if (result !== null) {
14
+ return result;
15
+ }
16
+ }
17
+ return null;
18
+ },
7
19
  };
8
20
  }
9
21
  function intentKind(event) {
@@ -38,36 +50,34 @@ function withSinkRef(event, sink) {
38
50
  },
39
51
  };
40
52
  }
53
+ function targetSinksForEvent(input) {
54
+ const targetSinks = new Set();
55
+ const origin = typeof input.event.payload.origin === 'string' ? input.event.payload.origin : undefined;
56
+ const sourceOrigin = input.event.sourceRefs.sink ?? origin;
57
+ if (input.event.sourceEventType === 'wake.labels.requested') {
58
+ const projectionOrigin = typeof input.event.payload.origin === 'string' ? input.event.payload.origin : undefined;
59
+ if (projectionOrigin !== undefined) {
60
+ targetSinks.add(projectionOrigin);
61
+ }
62
+ }
63
+ const resourceUri = input.event.sourceRefs.resourceUri;
64
+ if (input.event.sourceEventType === 'wake.publish.intent.requested' &&
65
+ sourceOrigin !== undefined) {
66
+ const resourceSink = resourceUri === undefined ? sourceOrigin : sinkNameForResourceUri(resourceUri, sourceOrigin);
67
+ targetSinks.add(input.sinksByName.has(resourceSink) ? resourceSink : sourceOrigin);
68
+ }
69
+ for (const [sinkName, sinkConfig] of Object.entries(input.config.sinks ?? {})) {
70
+ if (sinkConfig.subscribe.some((subscription) => subscriptionMatches(input.event, subscription))) {
71
+ targetSinks.add(sinkName);
72
+ }
73
+ }
74
+ return targetSinks;
75
+ }
41
76
  export function createOutboundSinkRouter(input) {
42
77
  const sinksByName = new Map(input.sinks.map((sink) => [sink.sink, sink]));
43
78
  return {
44
79
  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
- }
80
+ const targetSinks = targetSinksForEvent({ event, sinksByName, config: input.config });
71
81
  const deliveryEvents = [];
72
82
  for (const sinkName of targetSinks) {
73
83
  const sink = sinksByName.get(sinkName);
@@ -79,5 +89,18 @@ export function createOutboundSinkRouter(input) {
79
89
  }
80
90
  return deliveryEvents;
81
91
  },
92
+ async reconcileIntent({ event }) {
93
+ const targetSinks = targetSinksForEvent({ event, sinksByName, config: input.config });
94
+ const deliveryEvents = [];
95
+ for (const sinkName of targetSinks) {
96
+ const sink = sinksByName.get(sinkName);
97
+ if (sink?.reconcileIntent === undefined) {
98
+ continue;
99
+ }
100
+ const sinkDeliveryEvents = await sink.reconcileIntent({ event });
101
+ deliveryEvents.push(...sinkDeliveryEvents.map((deliveryEvent) => withSinkRef(deliveryEvent, sinkName)));
102
+ }
103
+ return deliveryEvents;
104
+ },
82
105
  };
83
106
  }
@@ -21,6 +21,15 @@ function latestHumanCommentId(candidate) {
21
21
  const human = candidate.comments.filter((c) => !c.isBotAuthored);
22
22
  return human.at(-1)?.id;
23
23
  }
24
+ function projectedSourceRevision(projection) {
25
+ const latestCommentUpdatedAt = projection.comments
26
+ .map((comment) => comment.updatedAt)
27
+ .sort()
28
+ .at(-1);
29
+ return latestCommentUpdatedAt === undefined
30
+ ? `${projection.issue.repo}#${projection.issue.number}@${projection.issue.updatedAt}`
31
+ : `${projection.issue.repo}#${projection.issue.number}@${projection.issue.updatedAt};comments@${latestCommentUpdatedAt}`;
32
+ }
24
33
  function isLateralReadOnlyAction(action, config) {
25
34
  return isCustomCommandAction(action, config);
26
35
  }
@@ -41,7 +50,7 @@ export function createTickRunner(deps) {
41
50
  resourceIndex: deps.resourceIndex,
42
51
  config: deps.config,
43
52
  });
44
- const { deliverOutboundEvent, retryUnconfirmedDeliveries } = createOutbox({
53
+ const { deliverOutboundEvent, retryUnconfirmedDeliveries, suppressOutboundEvent } = createOutbox({
45
54
  clock: deps.clock,
46
55
  stateStore: deps.stateStore,
47
56
  projectionUpdater,
@@ -137,6 +146,7 @@ export function createTickRunner(deps) {
137
146
  relation: 'primary',
138
147
  provenance: 'agent-reported',
139
148
  registeredBy: input.runId,
149
+ idempotencyKey: `${input.runId}:artifact-registration:${verified.resourceUri}`,
140
150
  },
141
151
  });
142
152
  const appended = await deps.stateStore.appendEventEnvelope(event);
@@ -220,6 +230,18 @@ export function createTickRunner(deps) {
220
230
  processStartedAt: record.workerProcessStartedAt,
221
231
  }));
222
232
  }
233
+ async function hasSchedulerCapacity(now) {
234
+ const runRecords = await deps.stateStore.listRunRecords();
235
+ for (const record of runRecords) {
236
+ if (record.status !== 'running') {
237
+ continue;
238
+ }
239
+ if (await isRunningRecordActive(record, now)) {
240
+ return false;
241
+ }
242
+ }
243
+ return true;
244
+ }
223
245
  async function parkConfigDriftedProjections(projections) {
224
246
  let parked = false;
225
247
  for (const projection of projections) {
@@ -313,10 +335,31 @@ export function createTickRunner(deps) {
313
335
  if (await parkConfigDriftedProjections(projections)) {
314
336
  return { status: 'processed' };
315
337
  }
316
- const candidate = projections.find((issue) => policy.resolveNextEligibleAction(issue, deps.config) !== null);
338
+ let candidate = projections.find((issue) => policy.resolveNextEligibleAction(issue, deps.config) !== null);
317
339
  if (candidate === undefined) {
318
340
  return { status: 'idle' };
319
341
  }
342
+ let sourceRevision = projectedSourceRevision(candidate);
343
+ let refresh;
344
+ try {
345
+ refresh = await deps.workSource.refreshForDispatch?.({ projection: candidate });
346
+ }
347
+ catch {
348
+ return { status: 'idle' };
349
+ }
350
+ if (refresh !== undefined && refresh !== null) {
351
+ if (refresh.sourceExists === false) {
352
+ return { status: 'idle' };
353
+ }
354
+ sourceRevision = refresh.sourceRevision;
355
+ if (refresh.events.length > 0) {
356
+ await ingestInboundEvents(refresh.events);
357
+ candidate = (await deps.stateStore.readIssueState(candidate.workItemKey)) ?? candidate;
358
+ }
359
+ }
360
+ if (policy.resolveNextEligibleAction(candidate, deps.config) === null) {
361
+ return { status: 'idle' };
362
+ }
320
363
  const workflow = workflowForProjection(candidate, deps.config);
321
364
  if (workflow === null) {
322
365
  return { status: 'idle' };
@@ -443,6 +486,9 @@ export function createTickRunner(deps) {
443
486
  if (routing === null) {
444
487
  return { status: 'idle' };
445
488
  }
489
+ if (!(await hasSchedulerCapacity(deps.clock.now()))) {
490
+ return { status: 'idle' };
491
+ }
446
492
  const runId = `run-${candidate.issue.number}-${deps.clock.now().getTime()}`;
447
493
  const lease = createRunLease({ clock: deps.clock, ownerInstanceId });
448
494
  const workerIdentity = currentProcessIdentity();
@@ -460,6 +506,9 @@ export function createTickRunner(deps) {
460
506
  lease,
461
507
  workerPid: workerIdentity.pid,
462
508
  workerProcessStartedAt: workerIdentity.processStartedAt,
509
+ metadata: {
510
+ sourceRevision,
511
+ },
463
512
  };
464
513
  await deps.stateStore.writeRunRecord(runningRecord);
465
514
  async function transitionRunLifecycle(lifecycle) {
@@ -488,6 +537,7 @@ export function createTickRunner(deps) {
488
537
  action,
489
538
  priorStage: candidate.wake.stage,
490
539
  claimedStage,
540
+ sourceRevision,
491
541
  },
492
542
  });
493
543
  await deps.stateStore.appendEventEnvelope(claimedEvent);
@@ -721,23 +771,31 @@ export function createTickRunner(deps) {
721
771
  workflowLabel: workflowLabelForWorkflowName(workflowName),
722
772
  occurredAt: finishedAt,
723
773
  }));
774
+ const publishIntent = createPublishIntentEvent({
775
+ projection: candidate,
776
+ runId,
777
+ action,
778
+ runnerResult,
779
+ parsedRunnerResult,
780
+ sentinel,
781
+ occurredAt: finishedAt,
782
+ startedAt: nowIso,
783
+ ...(workspacePath === undefined ? {} : { workspacePath }),
784
+ ...(typeof candidate.context.lastFailureClass === 'string'
785
+ ? { previousFailureClass: candidate.context.lastFailureClass }
786
+ : {}),
787
+ });
724
788
  if (shouldPublishRunResult({
725
789
  failureClass: runnerResult.failureClass,
726
790
  previousFailureClass: candidate.context.lastFailureClass,
727
791
  })) {
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
792
  await deliverOutboundEvent(publishIntent);
740
793
  }
794
+ else {
795
+ await suppressOutboundEvent(publishIntent, {
796
+ suppressedPublishReason: runnerResult.failureClass === 'quota' ? 'quota-failure' : 'repeated-infra-failure',
797
+ });
798
+ }
741
799
  return {
742
800
  status: 'processed',
743
801
  runId,
@@ -759,6 +817,7 @@ export function createTickRunner(deps) {
759
817
  executionOutcome: 'PROCESS_FAILED',
760
818
  summary: err instanceof Error ? err.message : String(err),
761
819
  metadata: {
820
+ ...failedRecord.metadata,
762
821
  failureClass: 'infra',
763
822
  },
764
823
  });
@@ -805,6 +864,7 @@ export function createTickRunner(deps) {
805
864
  result: errorMessage,
806
865
  model: 'unknown',
807
866
  cli: 'unknown',
867
+ failureClass: 'infra',
808
868
  };
809
869
  const parsedInfraFailureResult = parseRunnerResult(infraFailureResult.result);
810
870
  const failurePublishIntent = createPublishIntentEvent({
@@ -816,6 +876,9 @@ export function createTickRunner(deps) {
816
876
  sentinel,
817
877
  occurredAt: finishedAt,
818
878
  startedAt: nowIso,
879
+ ...(typeof candidate.context.lastFailureClass === 'string'
880
+ ? { previousFailureClass: candidate.context.lastFailureClass }
881
+ : {}),
819
882
  });
820
883
  if (shouldPublishRunResult({
821
884
  failureClass: 'infra',
@@ -823,6 +886,11 @@ export function createTickRunner(deps) {
823
886
  })) {
824
887
  await deliverOutboundEvent(failurePublishIntent);
825
888
  }
889
+ else {
890
+ await suppressOutboundEvent(failurePublishIntent, {
891
+ suppressedPublishReason: 'repeated-infra-failure',
892
+ });
893
+ }
826
894
  return {
827
895
  status: 'processed',
828
896
  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 = "gd481d17";
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.32",
4
4
  "description": "Local autonomous agent control plane for software development",
5
5
  "license": "Apache-2.0",
6
6
  "repository": {