@atolis-hq/wake 0.1.0

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 (75) hide show
  1. package/LICENSE +201 -0
  2. package/README.md +140 -0
  3. package/dist/src/adapters/claude/claude-runner.js +388 -0
  4. package/dist/src/adapters/claude/prompt-templates.js +57 -0
  5. package/dist/src/adapters/codex/codex-runner.js +391 -0
  6. package/dist/src/adapters/cursor/cursor-runner.js +352 -0
  7. package/dist/src/adapters/docker/docker-cli.js +97 -0
  8. package/dist/src/adapters/fake/fake-artifact-verifier.js +14 -0
  9. package/dist/src/adapters/fake/fake-github-pull-request-activity-source.js +73 -0
  10. package/dist/src/adapters/fake/fake-resource-index.js +21 -0
  11. package/dist/src/adapters/fake/fake-runner.js +22 -0
  12. package/dist/src/adapters/fake/fake-ticketing-system.js +166 -0
  13. package/dist/src/adapters/fake/fake-workspace-manager.js +27 -0
  14. package/dist/src/adapters/fs/resource-index.js +84 -0
  15. package/dist/src/adapters/fs/self-update-ledger.js +17 -0
  16. package/dist/src/adapters/fs/state-store.js +364 -0
  17. package/dist/src/adapters/git/git-workspace-manager.js +168 -0
  18. package/dist/src/adapters/github/github-artifact-verifier.js +38 -0
  19. package/dist/src/adapters/github/github-auth.js +16 -0
  20. package/dist/src/adapters/github/github-client.js +100 -0
  21. package/dist/src/adapters/github/github-issues-work-source.js +410 -0
  22. package/dist/src/adapters/github/github-pull-request-activity-source.js +263 -0
  23. package/dist/src/adapters/http/ui-assets.js +302 -0
  24. package/dist/src/adapters/http/ui-data.js +389 -0
  25. package/dist/src/adapters/http/ui-server.js +151 -0
  26. package/dist/src/adapters/runner/cli-command.js +43 -0
  27. package/dist/src/adapters/runner/prompt-templates.js +76 -0
  28. package/dist/src/adapters/runner/runner-cli-adapter.js +112 -0
  29. package/dist/src/adapters/runner/runner-registry.js +141 -0
  30. package/dist/src/adapters/runner/stage-prompt.js +256 -0
  31. package/dist/src/adapters/runner/transcripts.js +25 -0
  32. package/dist/src/cli/correlate-command.js +62 -0
  33. package/dist/src/cli/init-command.js +11 -0
  34. package/dist/src/cli/locks-command.js +19 -0
  35. package/dist/src/cli/sandbox-command.js +191 -0
  36. package/dist/src/cli/sandbox-logging.js +30 -0
  37. package/dist/src/cli/sandbox-resume.js +77 -0
  38. package/dist/src/cli/scaffold-assets.js +173 -0
  39. package/dist/src/cli/self-update-command.js +158 -0
  40. package/dist/src/cli/startup-preflight.js +127 -0
  41. package/dist/src/cli/stop-command.js +42 -0
  42. package/dist/src/cli/ui-command.js +27 -0
  43. package/dist/src/config/defaults.js +11 -0
  44. package/dist/src/config/load-config.js +26 -0
  45. package/dist/src/core/contracts.js +1 -0
  46. package/dist/src/core/control-plane.js +127 -0
  47. package/dist/src/core/lifecycle-service.js +8 -0
  48. package/dist/src/core/policy-engine.js +200 -0
  49. package/dist/src/core/projection-updater.js +438 -0
  50. package/dist/src/core/quota-backoff.js +69 -0
  51. package/dist/src/core/sink-router.js +85 -0
  52. package/dist/src/core/tick-runner.js +1347 -0
  53. package/dist/src/domain/branch-naming.js +14 -0
  54. package/dist/src/domain/resource-uri.js +38 -0
  55. package/dist/src/domain/runner-routing.js +108 -0
  56. package/dist/src/domain/schema.js +685 -0
  57. package/dist/src/domain/sources.js +16 -0
  58. package/dist/src/domain/stages.js +39 -0
  59. package/dist/src/domain/types.js +1 -0
  60. package/dist/src/domain/workflows.js +83 -0
  61. package/dist/src/lib/clock.js +5 -0
  62. package/dist/src/lib/event-log.js +21 -0
  63. package/dist/src/lib/json-file.js +23 -0
  64. package/dist/src/lib/lock.js +118 -0
  65. package/dist/src/lib/paths.js +44 -0
  66. package/dist/src/lib/work-id.js +19 -0
  67. package/dist/src/main.js +523 -0
  68. package/dist/src/version.js +1 -0
  69. package/docker/Dockerfile +54 -0
  70. package/docker/entrypoint.sh +75 -0
  71. package/docker/log-command.sh +107 -0
  72. package/docker/setup.sh +72 -0
  73. package/package.json +52 -0
  74. package/prompts/implement.md +49 -0
  75. package/prompts/refine.md +44 -0
@@ -0,0 +1,1347 @@
1
+ import { randomUUID } from 'node:crypto';
2
+ import { rm } from 'node:fs/promises';
3
+ import { isAbsolute, join, relative } from 'node:path';
4
+ import { createLifecycleService } from './lifecycle-service.js';
5
+ import { createPolicyEngine } from './policy-engine.js';
6
+ import { createProjectionUpdater } from './projection-updater.js';
7
+ import { acquireFileLock } from '../lib/lock.js';
8
+ import { createWorkId } from '../lib/work-id.js';
9
+ import { CORRELATION_REGISTERED_EVENT, UNRESOLVED_WORK_ITEM_KEY, WORK_ITEM_CREATED_EVENT, parseRunnerArtifacts, parseRunnerResult, } from '../domain/schema.js';
10
+ import { maxConfiguredRunnerTimeoutMs, resolveRunnerRouting } from '../domain/runner-routing.js';
11
+ import { awaitingApprovalRunnerSentinel, stageLabelForStage } from '../domain/stages.js';
12
+ import { chooseAction as chooseWorkflowAction, isKnownWorkflowStage, workflowChangedBlockReason, workflowForProjection, workflowNameForProjection, } from '../domain/workflows.js';
13
+ import { createEventEnvelope } from '../lib/event-log.js';
14
+ import { branchNameForIssue } from '../domain/branch-naming.js';
15
+ import { resolveQuotaPauseUntil } from './quota-backoff.js';
16
+ function latestHumanCommentId(candidate) {
17
+ const human = candidate.comments.filter((c) => !c.isBotAuthored);
18
+ return human.at(-1)?.id;
19
+ }
20
+ // `latestComment` is a sticky, per-work-item field: projection-updater.ts's
21
+ // comment fold overwrites it unconditionally on every inbound comment
22
+ // (any surface) and nothing ever resets it. So it means "the last comment
23
+ // this work item has ever received," not "the comment that triggered the
24
+ // currently completing run." Several needsWakeAction trigger paths (first
25
+ // run, quota-failure retry, first workflow-stage pass) complete a run
26
+ // with no fresh comment driving it at all — in those cases latestComment
27
+ // may still be pointing at an older, already-handled comment from a
28
+ // different surface (e.g. a PR) and must not be trusted as this run's
29
+ // trigger. This mirrors policy-engine.ts's needsWakeAction "is there an
30
+ // unhandled human comment" check exactly, so a comment is only treated as
31
+ // having driven this run when it's human-authored and not yet the
32
+ // candidate's own lastHandledCommentId (read from the pre-completion
33
+ // projection passed in as `candidate`/`projection`, since the completion
34
+ // event that would update lastHandledCommentId for *this* run hasn't been
35
+ // folded yet at the point this runs).
36
+ function isFreshTriggeringComment(candidate) {
37
+ const context = candidate.context;
38
+ const handledCommentId = typeof context.lastHandledCommentId === 'string' ? context.lastHandledCommentId : undefined;
39
+ return (candidate.latestComment !== undefined &&
40
+ !candidate.latestComment.isBotAuthored &&
41
+ candidate.latestComment.id !== handledCommentId);
42
+ }
43
+ export function createTickRunner(deps) {
44
+ const policy = createPolicyEngine();
45
+ const lifecycle = createLifecycleService();
46
+ const projectionUpdater = createProjectionUpdater({
47
+ stateStore: deps.stateStore,
48
+ resourceIndex: deps.resourceIndex,
49
+ config: deps.config,
50
+ });
51
+ function extractTokenCount(tokenUsage) {
52
+ if (tokenUsage === undefined) {
53
+ return undefined;
54
+ }
55
+ // Cache tokens dominate real usage and were previously dropped from this
56
+ // total entirely, understating the reported figure by roughly an order of
57
+ // magnitude (#135).
58
+ return (tokenUsage.inputTokens +
59
+ tokenUsage.outputTokens +
60
+ (tokenUsage.cacheCreationInputTokens ?? 0) +
61
+ (tokenUsage.cacheReadInputTokens ?? 0));
62
+ }
63
+ function formatCostUsd(costUsd) {
64
+ return `$${costUsd.toFixed(costUsd < 1 ? 4 : 2)}`;
65
+ }
66
+ function formatDuration(startedAtStr, finishedAtStr) {
67
+ const startedAt = new Date(startedAtStr);
68
+ const finishedAt = new Date(finishedAtStr);
69
+ const durationMs = finishedAt.getTime() - startedAt.getTime();
70
+ if (durationMs < 0 || !isFinite(durationMs))
71
+ return undefined;
72
+ const totalSeconds = Math.floor(durationMs / 1000);
73
+ const minutes = Math.floor(totalSeconds / 60);
74
+ const seconds = totalSeconds % 60;
75
+ if (minutes > 0) {
76
+ return `${minutes}m${seconds}s`;
77
+ }
78
+ return `${seconds}s`;
79
+ }
80
+ function formatTokenCount(count) {
81
+ if (count >= 1000000) {
82
+ return `${(count / 1000000).toFixed(1)}M`;
83
+ }
84
+ if (count >= 1000) {
85
+ return `${(count / 1000).toFixed(0)}k`;
86
+ }
87
+ return String(count);
88
+ }
89
+ function createPublishIntentEvent(input) {
90
+ const tokenCount = extractTokenCount(input.runnerResult.tokenUsage);
91
+ const duration = formatDuration(input.startedAt, input.occurredAt);
92
+ return createEventEnvelope({
93
+ eventId: `${input.runId}-publish-intent`,
94
+ workItemKey: input.projection.workItemKey,
95
+ streamScope: 'work-item',
96
+ direction: 'outbound',
97
+ sourceSystem: 'wake',
98
+ sourceEventType: 'wake.publish.intent.requested',
99
+ sourceRefs: {
100
+ repo: input.projection.issue.repo,
101
+ issueNumber: input.projection.issue.number,
102
+ runId: input.runId,
103
+ // Carries the triggering comment's surface (set only when the human
104
+ // reply that woke this run came from a correlated PR/review-thread
105
+ // resource, per the ad1cf45 comment fold) through to the sink
106
+ // router, so createOutboundSinkRouter's kind-based routing (Task 11,
107
+ // sink-router.ts) can send the reply back to that surface instead of
108
+ // defaulting to the issue thread. Without this, every reply landed
109
+ // on the origin sink regardless of which surface triggered the run.
110
+ //
111
+ // Gated on isFreshTriggeringComment: latestComment is sticky (see
112
+ // that function's comment) and several run-completion paths (first
113
+ // run, quota retry, first workflow-stage pass) have no fresh
114
+ // comment behind them at all — for those, threading the stale
115
+ // latestComment.resourceUri would misroute the reply to whatever
116
+ // surface last happened to comment, even long after that comment
117
+ // was already replied to.
118
+ ...(input.projection.latestComment?.resourceUri === undefined ||
119
+ !isFreshTriggeringComment(input.projection)
120
+ ? {}
121
+ : { resourceUri: input.projection.latestComment.resourceUri }),
122
+ },
123
+ occurredAt: input.occurredAt,
124
+ ingestedAt: input.occurredAt,
125
+ trigger: 'context-only',
126
+ payload: {
127
+ kind: input.sentinel === 'BLOCKED'
128
+ ? 'question'
129
+ : input.sentinel === 'AWAITING_APPROVAL'
130
+ ? 'approval-request'
131
+ : input.sentinel === 'FAILED'
132
+ ? 'failure'
133
+ : 'status-update',
134
+ origin: input.projection.origin ?? 'github',
135
+ body: input.parsedRunnerResult.body,
136
+ action: input.action,
137
+ sentinel: input.sentinel,
138
+ runId: input.runId,
139
+ ...(input.runnerResult.session_id === undefined
140
+ ? {}
141
+ : { sessionId: input.runnerResult.session_id }),
142
+ model: input.runnerResult.model,
143
+ cli: input.runnerResult.cli,
144
+ ...(input.runnerResult.routing === undefined
145
+ ? {}
146
+ : {
147
+ runnerName: input.runnerResult.routing.runnerName,
148
+ runnerKind: input.runnerResult.routing.runnerKind,
149
+ runnerTier: input.runnerResult.routing.tier,
150
+ runnerReason: input.runnerResult.routing.reason,
151
+ }),
152
+ ...(duration === undefined ? {} : { duration }),
153
+ ...(tokenCount === undefined ? {} : { tokens: formatTokenCount(tokenCount) }),
154
+ ...(input.runnerResult.tokenUsage?.costUsd === undefined
155
+ ? {}
156
+ : { cost: formatCostUsd(input.runnerResult.tokenUsage.costUsd) }),
157
+ ...(input.workspacePath === undefined
158
+ ? {}
159
+ : { workspacePath: input.workspacePath }),
160
+ },
161
+ derivedHints: {
162
+ stage: input.sentinel === 'DONE' ? 'done' : input.projection.wake.stage,
163
+ },
164
+ });
165
+ }
166
+ function isAwaitingApproval(projection) {
167
+ return projection.context.lastRunSentinel === awaitingApprovalRunnerSentinel;
168
+ }
169
+ function statusLabelForStage(stage) {
170
+ if (stage === 'done') {
171
+ return 'wake:status.completed';
172
+ }
173
+ return 'wake:status.pending';
174
+ }
175
+ function statusLabelForOutcome(input) {
176
+ if (input.sentinel === 'AWAITING_APPROVAL') {
177
+ return 'wake:status.awaiting-approval';
178
+ }
179
+ if (input.sentinel === 'BLOCKED') {
180
+ return 'wake:status.blocked';
181
+ }
182
+ if (input.sentinel === 'FAILED') {
183
+ return 'wake:status.failed';
184
+ }
185
+ return statusLabelForStage(input.stage);
186
+ }
187
+ function hasLabel(projection, label) {
188
+ return projection.issue.labels.includes(label);
189
+ }
190
+ function shouldMarkPending(projection) {
191
+ if (!policy.isEligible(projection, deps.config)) {
192
+ return false;
193
+ }
194
+ const workflow = workflowForProjection(projection, deps.config);
195
+ if (workflow === null || !isKnownWorkflowStage(projection.wake.stage, workflow)) {
196
+ return false;
197
+ }
198
+ if (isAwaitingApproval(projection)) {
199
+ return policy.resolveApprovalTransition(projection) !== null;
200
+ }
201
+ const nextAction = policy.chooseAction(projection, workflow) ??
202
+ policy.chooseRetryActionAfterHumanReply(projection);
203
+ return nextAction !== null && policy.needsWakeAction(projection, workflow);
204
+ }
205
+ function createLabelsEvent(input) {
206
+ return createEventEnvelope({
207
+ eventId: `${input.runId}-labels-${input.statusLabel.replace(/[^a-z0-9]+/gi, '-')}-${input.stageLabel.replace(/[^a-z0-9]+/gi, '-')}`,
208
+ workItemKey: input.projection.workItemKey,
209
+ streamScope: 'work-item',
210
+ direction: 'outbound',
211
+ sourceSystem: 'wake',
212
+ sourceEventType: 'wake.labels.requested',
213
+ sourceRefs: {
214
+ repo: input.projection.issue.repo,
215
+ issueNumber: input.projection.issue.number,
216
+ runId: input.runId,
217
+ },
218
+ occurredAt: input.occurredAt,
219
+ ingestedAt: input.occurredAt,
220
+ trigger: 'context-only',
221
+ payload: {
222
+ statusLabel: input.statusLabel,
223
+ stageLabel: input.stageLabel,
224
+ origin: input.projection.origin ?? 'github',
225
+ },
226
+ });
227
+ }
228
+ // Closes the loop on #82's review feedback: rather than scrape a PR link
229
+ // out of the agent's free text, the agent emits a `wake-artifacts` fence
230
+ // (domain/schema.ts's parseRunnerArtifacts) and Wake verifies each claim
231
+ // against the real provider before trusting it. No verifier configured (no
232
+ // GitHub source) means no claim is ever trusted — this is a no-op, not a
233
+ // silent "believe the agent" fallback.
234
+ //
235
+ // Uses a plain appendEventEnvelope + rebuildFromEvents pair, not
236
+ // deliverOutboundEvent: wake.correlation.registered is not a publish-intent
237
+ // type, so attemptDelivery's outbound sink call would be a no-op anyway —
238
+ // this matches the same plain-append pattern used elsewhere for
239
+ // internal-only events (see buildOriginCorrelationEvents' callers).
240
+ async function registerReportedArtifacts(input) {
241
+ if (deps.artifactVerifier === undefined) {
242
+ return;
243
+ }
244
+ const { artifacts } = parseRunnerArtifacts(input.runnerResult.result);
245
+ for (const artifact of artifacts) {
246
+ const verified = await deps.artifactVerifier.verify(artifact, {
247
+ branch: input.branch,
248
+ repo: input.projection.issue.repo,
249
+ });
250
+ if (verified === null) {
251
+ continue;
252
+ }
253
+ const event = createEventEnvelope({
254
+ eventId: `${input.runId}-artifact-${artifact.kind}-${verified.resourceUri.replace(/[^a-z0-9]+/gi, '-')}`,
255
+ workItemKey: input.projection.workItemKey,
256
+ streamScope: 'work-item',
257
+ direction: 'internal',
258
+ sourceSystem: 'wake',
259
+ sourceEventType: CORRELATION_REGISTERED_EVENT,
260
+ sourceRefs: { runId: input.runId },
261
+ occurredAt: input.occurredAt,
262
+ ingestedAt: input.occurredAt,
263
+ trigger: 'context-only',
264
+ payload: {
265
+ resourceUri: verified.resourceUri,
266
+ role: 'implementation',
267
+ relation: 'primary',
268
+ provenance: 'agent-reported',
269
+ registeredBy: input.runId,
270
+ },
271
+ });
272
+ const appended = await deps.stateStore.appendEventEnvelope(event);
273
+ await projectionUpdater.rebuildFromEvents([appended]);
274
+ }
275
+ }
276
+ // Events are stamped by reading the clock at the moment of stamping, never
277
+ // from a frozen tick-start snapshot. `tickStartedAt` is the tick's *decision*
278
+ // clock (policy/staleness), and reusing it to date events inverts them
279
+ // against the work source's own poll-time ingestedAt — pollEvents() runs
280
+ // after tickStartedAt is captured, so in production every polled upsert is
281
+ // LATER than the tick's start. An event dated before the upsert that creates
282
+ // the projection it folds into sorts ahead of it in rebuildFromEvents' global
283
+ // replay, folds against `current === null`, and is silently dropped. Reading
284
+ // per event also stays correct once ticks work items in parallel, where a
285
+ // shared per-tick snapshot would tie every concurrent event on ingestedAt and
286
+ // leave append order as the only discriminator.
287
+ function eventStampNow() {
288
+ return deps.clock.now().toISOString();
289
+ }
290
+ // The watchlist is every resource currently correlated to an open work
291
+ // item, deduplicated by exact resourceUri. It is derived once per tick from
292
+ // the pre-poll projection snapshot (see runTick's ordering note) and handed
293
+ // to every configured WorkSource. Core never interprets these URIs — it's
294
+ // each source's own job to recognize and filter down to the resourceUri
295
+ // shapes it understands (e.g. the GitHub PR source only polls entries
296
+ // starting with `github:pr:`, ignoring everything else, including its own
297
+ // review-thread correlations) and never core's (CLAUDE.md: "Core compares
298
+ // resourceUri strings for equality and never parses a locator").
299
+ function deriveWatchlist(projections) {
300
+ const seen = new Set();
301
+ const watch = [];
302
+ for (const projection of projections) {
303
+ if (projection.issue.state !== 'open') {
304
+ continue;
305
+ }
306
+ for (const resource of projection.correlatedResources) {
307
+ if (seen.has(resource.resourceUri)) {
308
+ continue;
309
+ }
310
+ seen.add(resource.resourceUri);
311
+ watch.push({ resourceUri: resource.resourceUri });
312
+ }
313
+ }
314
+ return watch;
315
+ }
316
+ async function markPendingActionableIssues(projections) {
317
+ for (const projection of projections) {
318
+ const statusLabel = statusLabelForStage(projection.wake.stage);
319
+ const stageLabel = stageLabelForStage(projection.wake.stage);
320
+ if (!shouldMarkPending(projection) ||
321
+ (hasLabel(projection, statusLabel) && hasLabel(projection, stageLabel))) {
322
+ continue;
323
+ }
324
+ await deliverOutboundEvent(createLabelsEvent({
325
+ projection,
326
+ runId: `pending-${projection.workItemKey}-${deps.clock.now().getTime()}`,
327
+ statusLabel,
328
+ stageLabel,
329
+ occurredAt: eventStampNow(),
330
+ }));
331
+ }
332
+ }
333
+ // Returns whichever of the two timestamps is unambiguously later, defaulting
334
+ // to `left`. `right` wins only if it is later by BOTH the actual instant and
335
+ // the lexicographic order rebuildFromEvents sorts on — the envelope schema is
336
+ // `z.string().datetime({ offset: true })`, so a timestamp may legally carry a
337
+ // non-UTC offset or differing sub-second precision, and lexicographic order
338
+ // alone is not a reliable proxy for chronology across those formats. Falling
339
+ // back to `left` (the source event's own exact string) is always safe: it
340
+ // ties with that event under localeCompare, and the stable sort then preserves
341
+ // append order, which puts the mint events after it — exactly what we need.
342
+ function laterTimestamp(left, right) {
343
+ const isLater = Date.parse(right) > Date.parse(left) && right.localeCompare(left) > 0;
344
+ return isLater ? right : left;
345
+ }
346
+ // The two internal events a mint (or a heal) appends after the source event
347
+ // that founded a work item: wake.workitem.created, then the
348
+ // wake.correlation.registered that claims the originating resource as this
349
+ // work item's primary representation. Their ids are derived from the work id,
350
+ // so re-emitting them is idempotent — appendEventEnvelope dedups on the id.
351
+ //
352
+ // Ordering and timestamps matter, and not only for readability. Both fold
353
+ // against the projection the source event creates, and applyEvent drops
354
+ // anything that folds while `current === null`. So they must never sort
355
+ // *before* that source event in rebuildFromEvents' globally-ordered replay —
356
+ // if they did, replay would silently discard the registration, leaving
357
+ // correlatedResources[] empty and the index unpopulated, while the events
358
+ // still on record stop any later tick from re-registering. Permanent,
359
+ // self-concealing loss (Task 5, round 3). Reading the clock here is already
360
+ // after pollEvents(), but that alone is not a guarantee: the source event's
361
+ // ingestedAt comes from the *source's* clock, which for a real source is
362
+ // another machine's and can legitimately run ahead of ours. Anchoring on the
363
+ // source event's own timestamp makes the ordering hold by construction rather
364
+ // than by clock agreement; appending the source event first means a tie
365
+ // resolves in its favour (the sort is stable, so equal timestamps keep append
366
+ // order).
367
+ function buildOriginCorrelationEvents(workItemKey, unkeyed, resourceUri) {
368
+ const mintedAt = laterTimestamp(unkeyed.ingestedAt, eventStampNow());
369
+ const sourceRefs = {
370
+ ...unkeyed.sourceRefs,
371
+ resourceUri,
372
+ };
373
+ const createdEvent = createEventEnvelope({
374
+ eventId: `${workItemKey}-created`,
375
+ workItemKey,
376
+ streamScope: 'work-item',
377
+ direction: 'internal',
378
+ sourceSystem: 'wake',
379
+ sourceEventType: WORK_ITEM_CREATED_EVENT,
380
+ sourceRefs,
381
+ occurredAt: mintedAt,
382
+ ingestedAt: mintedAt,
383
+ trigger: 'context-only',
384
+ // The envelope's workItemKey already carries the identity.
385
+ payload: {},
386
+ });
387
+ const registeredEvent = createEventEnvelope({
388
+ eventId: `${workItemKey}-origin-correlation`,
389
+ workItemKey,
390
+ streamScope: 'work-item',
391
+ direction: 'internal',
392
+ sourceSystem: 'wake',
393
+ sourceEventType: CORRELATION_REGISTERED_EVENT,
394
+ sourceRefs,
395
+ occurredAt: mintedAt,
396
+ ingestedAt: mintedAt,
397
+ trigger: 'context-only',
398
+ payload: {
399
+ resourceUri,
400
+ role: 'representation',
401
+ relation: 'primary',
402
+ provenance: 'wake-created',
403
+ },
404
+ });
405
+ return [createdEvent, registeredEvent];
406
+ }
407
+ // The central resolver (spec D1): sources name the *resource* an event came
408
+ // from and never the work item, so between pollEvents() and the append every
409
+ // inbound event's sourceRefs.resourceUri is resolved through the reverse
410
+ // index to the canonical workItemKey, minting a work item on a miss. This is
411
+ // the one mechanism — there is no founding-surface special case, and no
412
+ // resolution is ever cached in process memory between ticks (CLAUDE.md: the
413
+ // tick is a pure function of durable state; the index on disk *is* that
414
+ // state).
415
+ //
416
+ // Each resolved event carries whether it is already `persisted`, so the
417
+ // caller can skip re-appending it (appendEventEnvelope would only re-read it
418
+ // and hand back the same envelope). Minting *is* registration, so a freshly
419
+ // minted work item's correlatedResources[] is complete from its first event.
420
+ async function resolveInboundEvent(unkeyed) {
421
+ const { resourceUri } = unkeyed.sourceRefs;
422
+ if (resourceUri === undefined) {
423
+ // A programming error in the adapter, not a runtime condition to absorb.
424
+ // Guessing an identity here would silently fork a duplicate work item
425
+ // for work already in flight — exactly the corruption the reverse index
426
+ // exists to prevent — so fail loudly instead.
427
+ throw new Error(`cannot resolve inbound event ${unkeyed.eventId} from ${unkeyed.sourceSystem}: ` +
428
+ 'sourceRefs.resourceUri is required for every unkeyed source event');
429
+ }
430
+ // An event we have already persisted was already resolved, on some earlier
431
+ // tick, and its stamped key is the durable answer. Re-resolving it through
432
+ // the index would be wrong as well as wasteful: if that work item has since
433
+ // retracted this resource, the index no longer holds it, the lookup misses,
434
+ // and a miss means mint — so a re-polled event (sources legitimately
435
+ // re-emit the same eventId, e.g. an unchanged issue) would fork a duplicate
436
+ // work item. Reusing the persisted key keeps resolution idempotent per
437
+ // event id, which is what the append-only log already promises.
438
+ const persisted = await deps.stateStore.readEventEnvelope(unkeyed.eventId);
439
+ if (persisted !== null) {
440
+ // Heal a partially minted work item. The index entry for a resource is
441
+ // written only when its origin wake.correlation.registered event is
442
+ // *folded*, several appends after the founding source event. A crash in
443
+ // that window leaves the source event durable — so this branch suppresses
444
+ // re-minting — while the index has no entry, and a later event on the
445
+ // same resource would miss the index and fork a duplicate work item
446
+ // (crash/restart safety, CLAUDE.md). If the index does not credit this
447
+ // event's work item *and* its origin correlation never landed, re-emit
448
+ // the mint tail (idempotent by id). The guard is the missing origin
449
+ // event, not merely an empty index: a deliberately *retracted* resource
450
+ // also resolves to undefined but keeps its origin-correlation event on
451
+ // record, and must not be silently re-registered.
452
+ const owner = await deps.resourceIndex.resolve(resourceUri);
453
+ if (persisted.workItemKey !== UNRESOLVED_WORK_ITEM_KEY &&
454
+ owner === undefined &&
455
+ (await deps.stateStore.readEventEnvelope(`${persisted.workItemKey}-origin-correlation`)) === null) {
456
+ return [
457
+ { envelope: persisted, persisted: true },
458
+ ...buildOriginCorrelationEvents(persisted.workItemKey, unkeyed, resourceUri).map((envelope) => ({ envelope, persisted: false })),
459
+ ];
460
+ }
461
+ return [{ envelope: persisted, persisted: true }];
462
+ }
463
+ const existingWorkItemKey = await deps.resourceIndex.resolve(resourceUri);
464
+ if (existingWorkItemKey !== undefined) {
465
+ return [
466
+ {
467
+ envelope: createEventEnvelope({ ...unkeyed, workItemKey: existingWorkItemKey }),
468
+ persisted: false,
469
+ },
470
+ ];
471
+ }
472
+ // resourceUri itself misses the index (e.g. a review-thread comment's
473
+ // resourceUri is unique per thread, never registered on its own), but the
474
+ // adapter may have named a parent resource this one belongs to. Resolve
475
+ // through that instead of minting — and register this exact resourceUri
476
+ // as a secondary correlation so it's on record on the work item, even
477
+ // though (being secondary) it still won't shortcut future lookups via the
478
+ // index itself (ADR 0001 §5: the index is primary-only).
479
+ if (unkeyed.sourceRefs.parentResourceUri !== undefined) {
480
+ const parentWorkItemKey = await deps.resourceIndex.resolve(unkeyed.sourceRefs.parentResourceUri);
481
+ if (parentWorkItemKey !== undefined) {
482
+ const mintedAt = laterTimestamp(unkeyed.ingestedAt, eventStampNow());
483
+ return [
484
+ {
485
+ envelope: createEventEnvelope({ ...unkeyed, workItemKey: parentWorkItemKey }),
486
+ persisted: false,
487
+ },
488
+ {
489
+ envelope: createEventEnvelope({
490
+ eventId: `${parentWorkItemKey}-correlation-${resourceUri.replace(/[^a-z0-9]+/gi, '-')}`,
491
+ workItemKey: parentWorkItemKey,
492
+ streamScope: 'work-item',
493
+ direction: 'internal',
494
+ sourceSystem: 'wake',
495
+ sourceEventType: CORRELATION_REGISTERED_EVENT,
496
+ sourceRefs: unkeyed.sourceRefs,
497
+ occurredAt: mintedAt,
498
+ ingestedAt: mintedAt,
499
+ trigger: 'context-only',
500
+ payload: {
501
+ resourceUri,
502
+ role: 'review',
503
+ relation: 'secondary',
504
+ provenance: 'detected',
505
+ },
506
+ }),
507
+ persisted: false,
508
+ },
509
+ ];
510
+ }
511
+ }
512
+ if (!policy.qualifiesForMint(unkeyed, deps.config)) {
513
+ return [
514
+ {
515
+ envelope: createEventEnvelope({ ...unkeyed, workItemKey: UNRESOLVED_WORK_ITEM_KEY }),
516
+ persisted: false,
517
+ },
518
+ ];
519
+ }
520
+ const workItemKey = createWorkId();
521
+ const keyed = createEventEnvelope({ ...unkeyed, workItemKey });
522
+ return [
523
+ { envelope: keyed, persisted: false },
524
+ ...buildOriginCorrelationEvents(workItemKey, unkeyed, resourceUri).map((envelope) => ({
525
+ envelope,
526
+ persisted: false,
527
+ })),
528
+ ];
529
+ }
530
+ async function ingestInboundEvents(unkeyedEvents) {
531
+ const ingested = [];
532
+ for (const unkeyed of unkeyedEvents) {
533
+ const resolved = await resolveInboundEvent(unkeyed);
534
+ // Fold what was actually persisted, never the in-memory copy:
535
+ // appendEventEnvelope is id-deduplicated and returns the *existing*
536
+ // envelope when one is already on record. Folding our own copy instead
537
+ // would let state/ diverge from events/ — and replay is defined by
538
+ // events/, so the divergence would only surface after a rebuild. An event
539
+ // already flagged `persisted` needs no second append: appendEventEnvelope
540
+ // would only re-read it off disk and hand back the same envelope, and
541
+ // every unchanged issue is re-polled every tick, so that read is the
542
+ // dominant redundant cost this branch avoids.
543
+ const events = [];
544
+ for (const { envelope, persisted } of resolved) {
545
+ events.push(persisted ? envelope : await deps.stateStore.appendEventEnvelope(envelope));
546
+ }
547
+ // Folded before the next event is resolved, because it is the fold of
548
+ // the registration event that writes the index entry the *next* event on
549
+ // the same resource resolves through. Deferring the fold to the end of
550
+ // the batch would let a second event for the same ticket miss and mint a
551
+ // duplicate work item. Every event in a poll batch shares the source's
552
+ // one ingestedAt, so folding per event preserves exactly the order a
553
+ // single batched fold would have produced.
554
+ await projectionUpdater.rebuildFromEvents(events);
555
+ ingested.push(...events);
556
+ }
557
+ return ingested;
558
+ }
559
+ function runnerTimeoutMs() {
560
+ return maxConfiguredRunnerTimeoutMs(deps.config);
561
+ }
562
+ function isPerIssueWorkspacePath(workspacePath) {
563
+ const workspacesRoot = join(deps.config.paths.wakeRoot, 'workspaces');
564
+ const rel = relative(workspacesRoot, workspacePath);
565
+ return !rel.startsWith('..') && !isAbsolute(rel) && rel.length > 0;
566
+ }
567
+ async function cleanupClosedIssueWorkspaces(projections) {
568
+ for (const projection of projections) {
569
+ const { workspacePath } = projection.wake;
570
+ if (projection.issue.state === 'closed' &&
571
+ workspacePath !== undefined &&
572
+ isPerIssueWorkspacePath(workspacePath)) {
573
+ try {
574
+ await deps.workspaceManager.cleanupWorkspace({ workspacePath });
575
+ if (!deps.config.transcripts.retainAfterWorkspaceCleanup) {
576
+ await rm(deps.stateStore.paths.transcriptWorkDir(projection.workItemKey), {
577
+ recursive: true,
578
+ force: true,
579
+ });
580
+ }
581
+ }
582
+ catch (error) {
583
+ const failedAt = eventStampNow();
584
+ await deps.stateStore.appendEventEnvelope(createEventEnvelope({
585
+ eventId: `workspace-cleanup-failed-${projection.issue.repo.replace(/[^a-z0-9]+/gi, '-')}-${projection.issue.number}`,
586
+ workItemKey: projection.workItemKey,
587
+ streamScope: 'work-item',
588
+ direction: 'internal',
589
+ sourceSystem: 'wake',
590
+ sourceEventType: 'wake.workspace.cleanup-failed',
591
+ sourceRefs: {
592
+ repo: projection.issue.repo,
593
+ issueNumber: projection.issue.number,
594
+ },
595
+ occurredAt: failedAt,
596
+ ingestedAt: failedAt,
597
+ trigger: 'context-only',
598
+ payload: {
599
+ workspacePath,
600
+ error: error instanceof Error ? error.message : String(error),
601
+ },
602
+ }));
603
+ continue;
604
+ }
605
+ const cleanedAt = eventStampNow();
606
+ const cleanupEvent = createEventEnvelope({
607
+ eventId: `workspace-cleaned-${projection.issue.repo.replace(/[^a-z0-9]+/gi, '-')}-${projection.issue.number}-${deps.clock.now().getTime()}`,
608
+ workItemKey: projection.workItemKey,
609
+ streamScope: 'work-item',
610
+ direction: 'internal',
611
+ sourceSystem: 'wake',
612
+ sourceEventType: 'wake.workspace.cleaned',
613
+ sourceRefs: {
614
+ repo: projection.issue.repo,
615
+ issueNumber: projection.issue.number,
616
+ },
617
+ occurredAt: cleanedAt,
618
+ ingestedAt: cleanedAt,
619
+ trigger: 'immediate',
620
+ payload: { workspacePath },
621
+ });
622
+ await deps.stateStore.appendEventEnvelope(cleanupEvent);
623
+ await projectionUpdater.rebuildFromEvents([cleanupEvent]);
624
+ }
625
+ }
626
+ }
627
+ function isStaleRunningRecord(record, now) {
628
+ if (record.status !== 'running') {
629
+ return false;
630
+ }
631
+ const startedAtMs = Date.parse(record.startedAt);
632
+ if (!Number.isFinite(startedAtMs)) {
633
+ return true;
634
+ }
635
+ return now.getTime() - startedAtMs >= runnerTimeoutMs();
636
+ }
637
+ async function reconcileStaleRunningRecords(now) {
638
+ const finishedAt = now.toISOString();
639
+ const runRecords = await deps.stateStore.listRunRecords();
640
+ const staleRecords = runRecords
641
+ .filter((record) => isStaleRunningRecord(record, now));
642
+ for (const record of staleRecords) {
643
+ // Run records carry the work item they belong to, so this is a direct
644
+ // O(1) read — no scan, no index, no source ambiguity. The record's
645
+ // repo/issueNumber are representation content and take no part in it.
646
+ const projection = await deps.stateStore.readIssueState(record.workItemKey);
647
+ const newerCompletedRun = runRecords.some((candidate) => candidate.workItemKey === record.workItemKey &&
648
+ candidate.runId !== record.runId &&
649
+ candidate.status !== 'running' &&
650
+ Date.parse(candidate.startedAt) > Date.parse(record.startedAt));
651
+ // Equivalent to the previous `projection?.wake.lastRunId !== record.runId`,
652
+ // spelled out so the non-null projection is available below for its
653
+ // workItemKey.
654
+ if (projection === null || projection.wake.lastRunId !== record.runId || newerCompletedRun) {
655
+ await deps.stateStore.writeRunRecord({
656
+ ...record,
657
+ status: 'superseded',
658
+ finishedAt,
659
+ summary: 'Stale running record was superseded by a newer run.',
660
+ metadata: {
661
+ ...record.metadata,
662
+ reconciledBy: 'stale-running-record',
663
+ supersededBy: projection?.wake.lastRunId,
664
+ },
665
+ });
666
+ continue;
667
+ }
668
+ await deps.stateStore.writeRunRecord({
669
+ ...record,
670
+ status: 'failed',
671
+ finishedAt,
672
+ sentinel: 'FAILED',
673
+ summary: `Run exceeded timeout while marked running and was reconciled by a later tick.`,
674
+ metadata: {
675
+ ...record.metadata,
676
+ reconciledBy: 'stale-running-record',
677
+ timeoutMs: runnerTimeoutMs(),
678
+ },
679
+ });
680
+ const runCompletedEvent = createEventEnvelope({
681
+ eventId: `${record.runId}-stale-reconciled`,
682
+ workItemKey: projection.workItemKey,
683
+ streamScope: 'work-item',
684
+ direction: 'internal',
685
+ sourceSystem: 'wake',
686
+ sourceEventType: 'wake.run.completed',
687
+ sourceRefs: {
688
+ repo: record.repo,
689
+ issueNumber: record.issueNumber,
690
+ runId: record.runId,
691
+ },
692
+ occurredAt: finishedAt,
693
+ ingestedAt: finishedAt,
694
+ trigger: 'immediate',
695
+ payload: {
696
+ action: record.action,
697
+ sentinel: 'FAILED',
698
+ runId: record.runId,
699
+ reason: 'runner:stale-timeout',
700
+ ...(record.routing === undefined ? {} : { routing: record.routing }),
701
+ },
702
+ });
703
+ await deps.stateStore.appendEventEnvelope(runCompletedEvent);
704
+ await projectionUpdater.rebuildFromEvents([runCompletedEvent]);
705
+ const updatedProjection = await deps.stateStore.readIssueState(projection.workItemKey);
706
+ if (updatedProjection !== null) {
707
+ await deliverOutboundEvent(createLabelsEvent({
708
+ projection: updatedProjection,
709
+ runId: record.runId,
710
+ statusLabel: 'wake:status.failed',
711
+ stageLabel: stageLabelForStage(updatedProjection.wake.stage),
712
+ occurredAt: finishedAt,
713
+ }));
714
+ }
715
+ }
716
+ }
717
+ async function parkConfigDriftedProjections(projections) {
718
+ let parked = false;
719
+ for (const projection of projections) {
720
+ if (projection.wake.blockReason === workflowChangedBlockReason) {
721
+ continue;
722
+ }
723
+ const workflow = workflowForProjection(projection, deps.config);
724
+ if (workflow !== null && isKnownWorkflowStage(projection.wake.stage, workflow)) {
725
+ continue;
726
+ }
727
+ const occurredAt = eventStampNow();
728
+ const eventId = `workflow-changed-${projection.workItemKey}-${deps.clock.now().getTime()}`;
729
+ const event = createEventEnvelope({
730
+ eventId,
731
+ workItemKey: projection.workItemKey,
732
+ streamScope: 'work-item',
733
+ direction: 'internal',
734
+ sourceSystem: 'wake',
735
+ sourceEventType: 'wake.run.completed',
736
+ sourceRefs: {
737
+ repo: projection.issue.repo,
738
+ issueNumber: projection.issue.number,
739
+ runId: eventId,
740
+ },
741
+ occurredAt,
742
+ ingestedAt: occurredAt,
743
+ trigger: 'immediate',
744
+ payload: {
745
+ sentinel: 'BLOCKED',
746
+ runId: eventId,
747
+ reason: workflowChangedBlockReason,
748
+ blockReason: workflowChangedBlockReason,
749
+ body: `Workflow configuration changed; stored workflow or stage is no longer configured.`,
750
+ },
751
+ });
752
+ await deps.stateStore.appendEventEnvelope(event);
753
+ await projectionUpdater.rebuildFromEvents([event]);
754
+ await deliverOutboundEvent(createLabelsEvent({
755
+ projection,
756
+ runId: eventId,
757
+ statusLabel: 'wake:status.blocked',
758
+ stageLabel: stageLabelForStage(projection.wake.stage),
759
+ occurredAt,
760
+ }));
761
+ parked = true;
762
+ }
763
+ return parked;
764
+ }
765
+ const outboxMaxAttempts = 3;
766
+ async function recordDeliveryFailure(intentEvent, err) {
767
+ const occurredAt = deps.clock.now().toISOString();
768
+ const failureEvent = createEventEnvelope({
769
+ eventId: `${intentEvent.eventId}-delivery-failed-${randomUUID()}`,
770
+ workItemKey: intentEvent.workItemKey,
771
+ streamScope: 'work-item',
772
+ direction: 'internal',
773
+ sourceSystem: 'wake',
774
+ sourceEventType: 'wake.publish.failed',
775
+ sourceRefs: intentEvent.sourceRefs,
776
+ occurredAt,
777
+ ingestedAt: occurredAt,
778
+ trigger: 'context-only',
779
+ payload: {
780
+ intentEventId: intentEvent.eventId,
781
+ intentEventType: intentEvent.sourceEventType,
782
+ error: err instanceof Error ? err.message : String(err),
783
+ },
784
+ });
785
+ await deps.stateStore.appendEventEnvelope(failureEvent);
786
+ await projectionUpdater.rebuildFromEvents([failureEvent]);
787
+ }
788
+ // Outbound delivery (comments, labels) is attempted independently of run-outcome
789
+ // recording: a delivery failure must never rewrite an already-recorded run result
790
+ // (S1), and must always leave a durable, retryable trace instead of being lost
791
+ // (E5). This never throws — failures become a `wake.publish.failed` event.
792
+ async function attemptDelivery(event) {
793
+ if (deps.outboundSink === undefined) {
794
+ return;
795
+ }
796
+ try {
797
+ const deliveryEvents = await deps.outboundSink.deliverIntent({ event });
798
+ for (const deliveryEvent of deliveryEvents) {
799
+ await deps.stateStore.appendEventEnvelope(deliveryEvent);
800
+ }
801
+ await projectionUpdater.rebuildFromEvents(deliveryEvents);
802
+ if (deliveryEvents.length === 0) {
803
+ // No confirmation event was produced (e.g. a no-op label update) but the
804
+ // sink did not throw. Record that delivery was attempted successfully so
805
+ // the outbox scan below does not retry it indefinitely.
806
+ const confirmedAt = deps.clock.now().toISOString();
807
+ const confirmedEvent = createEventEnvelope({
808
+ eventId: `${event.eventId}-confirmed`,
809
+ workItemKey: event.workItemKey,
810
+ streamScope: 'work-item',
811
+ direction: 'internal',
812
+ sourceSystem: 'wake',
813
+ sourceEventType: 'wake.publish.confirmed',
814
+ sourceRefs: event.sourceRefs,
815
+ occurredAt: confirmedAt,
816
+ ingestedAt: confirmedAt,
817
+ trigger: 'context-only',
818
+ payload: { intentEventId: event.eventId },
819
+ });
820
+ await deps.stateStore.appendEventEnvelope(confirmedEvent);
821
+ }
822
+ }
823
+ catch (err) {
824
+ await recordDeliveryFailure(event, err);
825
+ }
826
+ }
827
+ async function deliverOutboundEvent(event) {
828
+ await deps.stateStore.appendEventEnvelope(event);
829
+ await projectionUpdater.rebuildFromEvents([event]);
830
+ await attemptDelivery(event);
831
+ }
832
+ const outboundIntentEventTypes = new Set([
833
+ 'wake.publish.intent.requested',
834
+ 'wake.labels.requested',
835
+ ]);
836
+ const outboundConfirmationEventTypes = new Set([
837
+ 'ticket.reply.published',
838
+ 'ticket.labels.updated',
839
+ 'wake.publish.confirmed',
840
+ 'pr.comment.reply.published',
841
+ 'pr.review-comment.reply.published',
842
+ ]);
843
+ // Adopts the outbox pattern: an intent is only considered delivered once a
844
+ // matching confirmation event exists. Anything left unconfirmed by a prior tick
845
+ // (e.g. the process crashed mid-delivery) is retried here, bounded so a
846
+ // permanently failing sink dead-letters instead of retrying forever.
847
+ async function retryUnconfirmedDeliveries() {
848
+ if (deps.outboundSink === undefined) {
849
+ return;
850
+ }
851
+ const events = await deps.stateStore.listEventEnvelopes();
852
+ const confirmedIntentIds = new Set();
853
+ const failureAttempts = new Map();
854
+ for (const event of events) {
855
+ const intentEventId = event.payload.intentEventId;
856
+ if (typeof intentEventId !== 'string') {
857
+ continue;
858
+ }
859
+ if (outboundConfirmationEventTypes.has(event.sourceEventType)) {
860
+ confirmedIntentIds.add(intentEventId);
861
+ }
862
+ if (event.sourceEventType === 'wake.publish.failed') {
863
+ failureAttempts.set(intentEventId, (failureAttempts.get(intentEventId) ?? 0) + 1);
864
+ }
865
+ }
866
+ for (const intent of events) {
867
+ if (!outboundIntentEventTypes.has(intent.sourceEventType)) {
868
+ continue;
869
+ }
870
+ if (confirmedIntentIds.has(intent.eventId)) {
871
+ continue;
872
+ }
873
+ if ((failureAttempts.get(intent.eventId) ?? 0) >= outboxMaxAttempts) {
874
+ continue;
875
+ }
876
+ await attemptDelivery(intent);
877
+ }
878
+ }
879
+ async function runIntakeTick() {
880
+ const lock = await acquireFileLock(deps.stateStore.paths.tickLockFile, {
881
+ staleAfterMs: Math.min(runnerTimeoutMs(), 5 * 60 * 1000),
882
+ });
883
+ if (!lock.acquired) {
884
+ return { status: 'locked' };
885
+ }
886
+ try {
887
+ const tickStartedAt = deps.clock.now();
888
+ await reconcileStaleRunningRecords(tickStartedAt);
889
+ await retryUnconfirmedDeliveries();
890
+ const watchlistProjections = await deps.stateStore.listIssueStates();
891
+ const inboundEvents = await ingestInboundEvents(await deps.workSource.pollEvents({ watch: deriveWatchlist(watchlistProjections) }));
892
+ const projections = await deps.stateStore.listIssueStates();
893
+ await cleanupClosedIssueWorkspaces(projections);
894
+ if (inboundEvents.length > 0) {
895
+ await markPendingActionableIssues(projections);
896
+ }
897
+ return {
898
+ status: inboundEvents.length > 0 ? 'processed' : 'idle',
899
+ };
900
+ }
901
+ finally {
902
+ await lock.release();
903
+ }
904
+ }
905
+ async function runRunnerTick() {
906
+ const lock = await acquireFileLock(deps.stateStore.paths.runnerLockFile, {
907
+ staleAfterMs: runnerTimeoutMs(),
908
+ });
909
+ if (!lock.acquired) {
910
+ return { status: 'locked' };
911
+ }
912
+ try {
913
+ // The tick's *decision* clock: one consistent "now" for staleness and
914
+ // policy, so a single tick's decisions can't disagree with themselves.
915
+ // It is deliberately NOT an event-stamping clock — see eventStampNow().
916
+ // Its only remaining uses are the run records' startedAt (run records
917
+ // are not events and take no part in the rebuild fold).
918
+ const tickStartedAt = deps.clock.now();
919
+ const nowIso = tickStartedAt.toISOString();
920
+ await reconcileStaleRunningRecords(tickStartedAt);
921
+ const projections = await deps.stateStore.listIssueStates();
922
+ if (await parkConfigDriftedProjections(projections)) {
923
+ return { status: 'processed' };
924
+ }
925
+ const candidate = projections.find((issue) => {
926
+ if (!policy.isEligible(issue, deps.config)) {
927
+ return false;
928
+ }
929
+ const workflow = workflowForProjection(issue, deps.config);
930
+ if (workflow === null || !isKnownWorkflowStage(issue.wake.stage, workflow)) {
931
+ return false;
932
+ }
933
+ if (isAwaitingApproval(issue)) {
934
+ return policy.needsWakeAction(issue, workflow);
935
+ }
936
+ const nextAction = policy.chooseAction(issue, workflow) ??
937
+ policy.chooseRetryActionAfterHumanReply(issue);
938
+ return nextAction !== null && policy.needsWakeAction(issue, workflow);
939
+ });
940
+ if (candidate === undefined) {
941
+ return { status: 'idle' };
942
+ }
943
+ const workflow = workflowForProjection(candidate, deps.config);
944
+ if (workflow === null) {
945
+ return { status: 'idle' };
946
+ }
947
+ const workflowName = workflowNameForProjection(candidate, deps.config);
948
+ let action;
949
+ let claimedStage = candidate.wake.stage;
950
+ let workspaceMode = 'none';
951
+ if (isAwaitingApproval(candidate)) {
952
+ const approvalResolution = policy.resolveApprovalTransition(candidate);
953
+ if (approvalResolution === null) {
954
+ return { status: 'idle' };
955
+ }
956
+ if (approvalResolution.approved) {
957
+ const approvalId = `approval-${candidate.issue.number}-${deps.clock.now().getTime()}`;
958
+ const approvedAt = deps.clock.now().toISOString();
959
+ const nextStage = lifecycle.nextStageFromSentinel(candidate.wake.stage, 'DONE', workflow);
960
+ if (nextStage === null) {
961
+ return { status: 'idle' };
962
+ }
963
+ const approvalCompletedEvent = createEventEnvelope({
964
+ eventId: `${approvalId}-completed`,
965
+ workItemKey: candidate.workItemKey,
966
+ streamScope: 'work-item',
967
+ direction: 'internal',
968
+ sourceSystem: 'wake',
969
+ sourceEventType: 'wake.run.completed',
970
+ sourceRefs: {
971
+ repo: candidate.issue.repo,
972
+ issueNumber: candidate.issue.number,
973
+ runId: approvalId,
974
+ },
975
+ occurredAt: approvedAt,
976
+ ingestedAt: approvedAt,
977
+ trigger: 'immediate',
978
+ payload: {
979
+ action: approvalResolution.pendingAction,
980
+ sentinel: 'DONE',
981
+ nextStage,
982
+ runId: approvalId,
983
+ reason: 'human:approved',
984
+ handledCommentId: latestHumanCommentId(candidate),
985
+ },
986
+ });
987
+ await deps.stateStore.appendEventEnvelope(approvalCompletedEvent);
988
+ await projectionUpdater.rebuildFromEvents([approvalCompletedEvent]);
989
+ await deliverOutboundEvent(createLabelsEvent({
990
+ projection: candidate,
991
+ runId: approvalId,
992
+ statusLabel: statusLabelForStage(nextStage),
993
+ stageLabel: stageLabelForStage(nextStage),
994
+ occurredAt: approvedAt,
995
+ }));
996
+ return {
997
+ status: 'processed',
998
+ runId: approvalId,
999
+ sentinel: 'DONE',
1000
+ nextStage,
1001
+ };
1002
+ }
1003
+ action = approvalResolution.pendingAction;
1004
+ const workflowAction = chooseWorkflowAction(candidate, workflow);
1005
+ claimedStage = workflowAction?.stage ?? candidate.wake.stage;
1006
+ workspaceMode = workflowAction?.workspace ?? 'none';
1007
+ }
1008
+ else {
1009
+ const workflowAction = chooseWorkflowAction(candidate, workflow);
1010
+ const nextAction = workflowAction?.action ??
1011
+ policy.chooseRetryActionAfterHumanReply(candidate);
1012
+ if (nextAction === null) {
1013
+ return { status: 'idle' };
1014
+ }
1015
+ action = nextAction;
1016
+ claimedStage = workflowAction?.stage ?? candidate.wake.stage;
1017
+ workspaceMode = workflowAction?.workspace ?? 'none';
1018
+ }
1019
+ // Resolve routing (with sideways fallback across quota-paused runners,
1020
+ // #67) before claiming a run, so a fully-paused tier costs nothing more
1021
+ // than an idle tick instead of a claimed-but-doomed run record.
1022
+ const ledgerAtStart = await deps.stateStore.readLedger();
1023
+ const routing = resolveRunnerRouting({
1024
+ config: deps.config,
1025
+ stage: claimedStage,
1026
+ action,
1027
+ workflowName,
1028
+ now: tickStartedAt,
1029
+ ...(ledgerAtStart === null ? {} : { ledger: ledgerAtStart }),
1030
+ });
1031
+ if (routing === null) {
1032
+ return { status: 'idle' };
1033
+ }
1034
+ const runId = `run-${candidate.issue.number}-${deps.clock.now().getTime()}`;
1035
+ const runningRecord = {
1036
+ schemaVersion: 1,
1037
+ runId,
1038
+ workItemKey: candidate.workItemKey,
1039
+ repo: candidate.issue.repo,
1040
+ issueNumber: candidate.issue.number,
1041
+ action,
1042
+ status: 'running',
1043
+ startedAt: nowIso,
1044
+ };
1045
+ await deps.stateStore.writeRunRecord(runningRecord);
1046
+ const claimedAt = eventStampNow();
1047
+ const claimedEvent = createEventEnvelope({
1048
+ eventId: `${runId}-claimed`,
1049
+ workItemKey: candidate.workItemKey,
1050
+ streamScope: 'work-item',
1051
+ direction: 'internal',
1052
+ sourceSystem: 'wake',
1053
+ sourceEventType: 'wake.run.claimed',
1054
+ sourceRefs: {
1055
+ repo: candidate.issue.repo,
1056
+ issueNumber: candidate.issue.number,
1057
+ runId,
1058
+ },
1059
+ occurredAt: claimedAt,
1060
+ ingestedAt: claimedAt,
1061
+ trigger: 'immediate',
1062
+ payload: {
1063
+ action,
1064
+ priorStage: candidate.wake.stage,
1065
+ claimedStage,
1066
+ },
1067
+ });
1068
+ await deps.stateStore.appendEventEnvelope(claimedEvent);
1069
+ await projectionUpdater.rebuildFromEvents([claimedEvent]);
1070
+ await deliverOutboundEvent(createLabelsEvent({
1071
+ projection: candidate,
1072
+ runId,
1073
+ statusLabel: 'wake:status.working',
1074
+ stageLabel: stageLabelForStage(claimedStage),
1075
+ occurredAt: eventStampNow(),
1076
+ }));
1077
+ try {
1078
+ const prepareResult = workspaceMode === 'branch'
1079
+ ? await deps.workspaceManager.prepareWorkspace({
1080
+ workId: candidate.workItemKey,
1081
+ repo: candidate.issue.repo,
1082
+ issueNumber: candidate.issue.number,
1083
+ })
1084
+ : workspaceMode === 'read-only'
1085
+ ? await deps.workspaceManager.prepareReadOnlyClone({
1086
+ repo: candidate.issue.repo,
1087
+ })
1088
+ : {};
1089
+ const { workspacePath } = prepareResult;
1090
+ const mergeConflictDetected = 'mergeConflictDetected' in prepareResult ? prepareResult.mergeConflictDetected : false;
1091
+ const upstreamChanges = 'upstreamChanges' in prepareResult && typeof prepareResult.upstreamChanges === 'string'
1092
+ ? prepareResult.upstreamChanges
1093
+ : undefined;
1094
+ const recentEvents = await deps.stateStore.listEventEnvelopesForWorkItem(candidate.workItemKey, 6);
1095
+ const runnerResult = await deps.runner.run({
1096
+ action,
1097
+ projection: candidate,
1098
+ recentEvents,
1099
+ config: deps.config,
1100
+ runId,
1101
+ routing,
1102
+ workspaceMode,
1103
+ ...(workspacePath === undefined ? {} : { workspacePath }),
1104
+ ...(mergeConflictDetected ? { mergeConflictDetected: true } : {}),
1105
+ ...(upstreamChanges === undefined ? {} : { upstreamChanges }),
1106
+ });
1107
+ const parsedRunnerResult = parseRunnerResult(runnerResult.result);
1108
+ const rawSentinel = parsedRunnerResult.status;
1109
+ // Coerce DONE → AWAITING_APPROVAL when the stage requires human sign-off.
1110
+ // An agent that writes DONE but was told not to skip approval has violated
1111
+ // the protocol; treat it as AWAITING_APPROVAL so the gate is enforced.
1112
+ const skipApproval = runnerResult.metadata?.skipApproval;
1113
+ const sentinel = rawSentinel === 'DONE' && skipApproval === false
1114
+ ? 'AWAITING_APPROVAL'
1115
+ : rawSentinel;
1116
+ const nextStage = lifecycle.nextStageFromSentinel(claimedStage, sentinel, workflow);
1117
+ const finishedAt = deps.clock.now().toISOString();
1118
+ if (workspaceMode === 'branch') {
1119
+ await registerReportedArtifacts({
1120
+ projection: candidate,
1121
+ runId,
1122
+ runnerResult,
1123
+ branch: branchNameForIssue(candidate.issue.number),
1124
+ occurredAt: finishedAt,
1125
+ });
1126
+ }
1127
+ const failedRunnerName = runnerResult.routing?.runnerName ?? routing.runnerName;
1128
+ const existingRunners = ledgerAtStart?.runners ?? {};
1129
+ if (runnerResult.failureClass === 'quota') {
1130
+ const failureCount = (existingRunners[failedRunnerName]?.failureCount ?? 0) + 1;
1131
+ const quotaPause = resolveQuotaPauseUntil({
1132
+ result: runnerResult.result,
1133
+ now: new Date(finishedAt),
1134
+ failureCount,
1135
+ });
1136
+ await deps.stateStore.writeLedger({
1137
+ schemaVersion: 1,
1138
+ runners: {
1139
+ ...existingRunners,
1140
+ [failedRunnerName]: {
1141
+ pausedUntil: quotaPause.pausedUntil,
1142
+ pausedUntilSource: quotaPause.source,
1143
+ failureCount,
1144
+ lastFailureAt: finishedAt,
1145
+ },
1146
+ },
1147
+ });
1148
+ }
1149
+ else if ((existingRunners[failedRunnerName]?.failureCount ?? 0) > 0) {
1150
+ await deps.stateStore.writeLedger({
1151
+ schemaVersion: 1,
1152
+ runners: {
1153
+ ...existingRunners,
1154
+ [failedRunnerName]: { failureCount: 0 },
1155
+ },
1156
+ });
1157
+ }
1158
+ const resultMetadata = {
1159
+ ...runnerResult.metadata,
1160
+ envelope: parsedRunnerResult.envelope,
1161
+ ...(runnerResult.failureClass === undefined
1162
+ ? {}
1163
+ : { failureClass: runnerResult.failureClass }),
1164
+ ...(runnerResult.routing === undefined ? {} : { routing: runnerResult.routing }),
1165
+ };
1166
+ await deps.stateStore.writeRunRecord({
1167
+ ...runningRecord,
1168
+ status: sentinel === 'DONE'
1169
+ ? 'completed'
1170
+ : sentinel === 'BLOCKED'
1171
+ ? 'blocked'
1172
+ : sentinel === 'AWAITING_APPROVAL'
1173
+ ? 'awaiting-approval'
1174
+ : 'failed',
1175
+ finishedAt,
1176
+ sessionId: runnerResult.session_id,
1177
+ sentinel,
1178
+ summary: parsedRunnerResult.body,
1179
+ ...(runnerResult.routing === undefined ? {} : { routing: runnerResult.routing }),
1180
+ ...(runnerResult.tokenUsage === undefined ? {} : { tokenUsage: runnerResult.tokenUsage }),
1181
+ metadata: resultMetadata,
1182
+ });
1183
+ const runCompletedEvent = createEventEnvelope({
1184
+ eventId: `${runId}-completed`,
1185
+ workItemKey: candidate.workItemKey,
1186
+ streamScope: 'work-item',
1187
+ direction: 'internal',
1188
+ sourceSystem: 'wake',
1189
+ sourceEventType: 'wake.run.completed',
1190
+ sourceRefs: {
1191
+ repo: candidate.issue.repo,
1192
+ issueNumber: candidate.issue.number,
1193
+ runId,
1194
+ },
1195
+ occurredAt: finishedAt,
1196
+ ingestedAt: finishedAt,
1197
+ trigger: 'immediate',
1198
+ payload: {
1199
+ action,
1200
+ sentinel,
1201
+ ...(rawSentinel !== sentinel ? { rawSentinel } : {}),
1202
+ ...(nextStage !== null ? { nextStage } : {}),
1203
+ runId,
1204
+ sessionId: runnerResult.session_id,
1205
+ sessionCli: runnerResult.cli,
1206
+ workspacePath,
1207
+ reason: `runner:${sentinel.toLowerCase()}`,
1208
+ ...(runnerResult.routing === undefined ? {} : { routing: runnerResult.routing }),
1209
+ ...(runnerResult.failureClass === undefined
1210
+ ? {}
1211
+ : { failureClass: runnerResult.failureClass }),
1212
+ // Only mark the triggering comment handled when the run reached the
1213
+ // agent and produced a real outcome. Quota/infra failures are transient
1214
+ // blips, not an answer to the human's comment — leaving handledCommentId
1215
+ // unset lets the next tick retry instead of silently eating the request (S9).
1216
+ ...(runnerResult.failureClass === 'quota' || runnerResult.failureClass === 'infra'
1217
+ ? {}
1218
+ : { handledCommentId: latestHumanCommentId(candidate) }),
1219
+ body: parsedRunnerResult.body,
1220
+ envelope: parsedRunnerResult.envelope,
1221
+ },
1222
+ });
1223
+ await deps.stateStore.appendEventEnvelope(runCompletedEvent);
1224
+ await projectionUpdater.rebuildFromEvents([runCompletedEvent]);
1225
+ await deliverOutboundEvent(createLabelsEvent({
1226
+ projection: candidate,
1227
+ runId,
1228
+ statusLabel: statusLabelForOutcome({
1229
+ sentinel,
1230
+ stage: nextStage ?? claimedStage,
1231
+ }),
1232
+ stageLabel: stageLabelForStage(nextStage ?? claimedStage),
1233
+ occurredAt: finishedAt,
1234
+ }));
1235
+ if (runnerResult.failureClass !== 'quota') {
1236
+ const publishIntent = createPublishIntentEvent({
1237
+ projection: candidate,
1238
+ runId,
1239
+ action,
1240
+ runnerResult,
1241
+ parsedRunnerResult,
1242
+ sentinel,
1243
+ occurredAt: finishedAt,
1244
+ startedAt: nowIso,
1245
+ ...(workspacePath === undefined ? {} : { workspacePath }),
1246
+ });
1247
+ await deliverOutboundEvent(publishIntent);
1248
+ }
1249
+ return {
1250
+ status: 'processed',
1251
+ runId,
1252
+ sentinel,
1253
+ nextStage,
1254
+ };
1255
+ }
1256
+ catch (err) {
1257
+ const finishedAt = deps.clock.now().toISOString();
1258
+ const sentinel = 'FAILED';
1259
+ await deps.stateStore.writeRunRecord({
1260
+ ...runningRecord,
1261
+ status: 'failed',
1262
+ finishedAt,
1263
+ sentinel,
1264
+ summary: err instanceof Error ? err.message : String(err),
1265
+ metadata: {
1266
+ failureClass: 'infra',
1267
+ },
1268
+ });
1269
+ const runCompletedEvent = createEventEnvelope({
1270
+ eventId: `${runId}-completed`,
1271
+ workItemKey: candidate.workItemKey,
1272
+ streamScope: 'work-item',
1273
+ direction: 'internal',
1274
+ sourceSystem: 'wake',
1275
+ sourceEventType: 'wake.run.completed',
1276
+ sourceRefs: {
1277
+ repo: candidate.issue.repo,
1278
+ issueNumber: candidate.issue.number,
1279
+ runId,
1280
+ },
1281
+ occurredAt: finishedAt,
1282
+ ingestedAt: finishedAt,
1283
+ trigger: 'immediate',
1284
+ payload: {
1285
+ action,
1286
+ sentinel,
1287
+ runId,
1288
+ reason: 'runner:infrastructure-error',
1289
+ failureClass: 'infra',
1290
+ // Deliberately omit handledCommentId: an infra blip (CLI crash, timeout,
1291
+ // network error) never reached the agent, so it isn't an answer to the
1292
+ // triggering comment. Leaving it unset lets the next tick retry the same
1293
+ // request instead of silently eating it (S9).
1294
+ },
1295
+ });
1296
+ await deps.stateStore.appendEventEnvelope(runCompletedEvent);
1297
+ await projectionUpdater.rebuildFromEvents([runCompletedEvent]);
1298
+ await deliverOutboundEvent(createLabelsEvent({
1299
+ projection: candidate,
1300
+ runId,
1301
+ statusLabel: 'wake:status.failed',
1302
+ stageLabel: stageLabelForStage(claimedStage),
1303
+ occurredAt: finishedAt,
1304
+ }));
1305
+ const errorMessage = err instanceof Error ? err.message : String(err);
1306
+ const infraFailureResult = {
1307
+ result: errorMessage,
1308
+ model: 'unknown',
1309
+ cli: 'unknown',
1310
+ };
1311
+ const parsedInfraFailureResult = parseRunnerResult(infraFailureResult.result);
1312
+ const failurePublishIntent = createPublishIntentEvent({
1313
+ projection: candidate,
1314
+ runId,
1315
+ action,
1316
+ runnerResult: infraFailureResult,
1317
+ parsedRunnerResult: parsedInfraFailureResult,
1318
+ sentinel,
1319
+ occurredAt: finishedAt,
1320
+ startedAt: nowIso,
1321
+ });
1322
+ await deliverOutboundEvent(failurePublishIntent);
1323
+ return {
1324
+ status: 'processed',
1325
+ runId,
1326
+ sentinel,
1327
+ nextStage: null,
1328
+ };
1329
+ }
1330
+ }
1331
+ finally {
1332
+ await lock.release();
1333
+ }
1334
+ }
1335
+ return {
1336
+ runIntakeTick,
1337
+ runRunnerTick,
1338
+ async runTick() {
1339
+ const intakeResult = await runIntakeTick();
1340
+ if (intakeResult.status === 'locked') {
1341
+ return intakeResult;
1342
+ }
1343
+ const runnerResult = await runRunnerTick();
1344
+ return runnerResult;
1345
+ },
1346
+ };
1347
+ }