@atolis-hq/wake 0.1.12 → 0.1.13

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.
@@ -1,12 +1,8 @@
1
- import { randomUUID } from 'node:crypto';
2
- import { rm } from 'node:fs/promises';
3
- import { isAbsolute, join, relative } from 'node:path';
4
1
  import { createLifecycleService } from './lifecycle-service.js';
5
2
  import { createPolicyEngine } from './policy-engine.js';
6
3
  import { createProjectionUpdater } from './projection-updater.js';
7
4
  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';
5
+ import { CORRELATION_REGISTERED_EVENT, parseRunnerArtifacts, parseRunnerResult, } from '../domain/schema.js';
10
6
  import { maxConfiguredRunnerTimeoutMs, resolveRunnerRouting } from '../domain/runner-routing.js';
11
7
  import { awaitingApprovalRunnerSentinel, stageLabelForStage } from '../domain/stages.js';
12
8
  import { chooseAction as chooseWorkflowAction, isKnownWorkflowStage, workflowChangedBlockReason, workflowForProjection, workflowLabelForWorkflowName, workflowNameForProjection, } from '../domain/workflows.js';
@@ -14,42 +10,18 @@ import { createEventEnvelope } from '../lib/event-log.js';
14
10
  import { branchNameForIssue } from '../domain/branch-naming.js';
15
11
  import { customCommandWorkspace, isCustomCommandAction } from '../domain/custom-commands.js';
16
12
  import { resolveQuotaPauseUntil } from './quota-backoff.js';
13
+ import { createLabelsEvent, createPublishIntentEvent } from './event-builders.js';
14
+ import { createOutbox } from './outbox.js';
15
+ import { createEventResolver } from './event-resolver.js';
16
+ import { createStaleRunReconciler } from './stale-run-reconciler.js';
17
+ import { createWorkspaceCleanup } from './workspace-cleanup.js';
17
18
  function latestHumanCommentId(candidate) {
18
19
  const human = candidate.comments.filter((c) => !c.isBotAuthored);
19
20
  return human.at(-1)?.id;
20
21
  }
21
- // `latestComment` is a sticky, per-work-item field: projection-updater.ts's
22
- // comment fold overwrites it unconditionally on every inbound comment
23
- // (any surface) and nothing ever resets it. So it means "the last comment
24
- // this work item has ever received," not "the comment that triggered the
25
- // currently completing run." Several needsWakeAction trigger paths (first
26
- // run, quota-failure retry, first workflow-stage pass) complete a run
27
- // with no fresh comment driving it at all — in those cases latestComment
28
- // may still be pointing at an older, already-handled comment from a
29
- // different surface (e.g. a PR) and must not be trusted as this run's
30
- // trigger. This mirrors policy-engine.ts's needsWakeAction "is there an
31
- // unhandled human comment" check exactly, so a comment is only treated as
32
- // having driven this run when it's human-authored and not yet the
33
- // candidate's own lastHandledCommentId (read from the pre-completion
34
- // projection passed in as `candidate`/`projection`, since the completion
35
- // event that would update lastHandledCommentId for *this* run hasn't been
36
- // folded yet at the point this runs).
37
- function isReviewThreadResourceUri(resourceUri) {
38
- return resourceUri.split(':')[1] === 'pr-review-thread';
39
- }
40
22
  function isLateralReadOnlyAction(action, config) {
41
23
  return isCustomCommandAction(action, config);
42
24
  }
43
- function workspaceModeForCustomCommand(action, config) {
44
- return customCommandWorkspace(action, config);
45
- }
46
- function isFreshTriggeringComment(candidate) {
47
- const context = candidate.context;
48
- const handledCommentId = typeof context.lastHandledCommentId === 'string' ? context.lastHandledCommentId : undefined;
49
- return (candidate.latestComment !== undefined &&
50
- !candidate.latestComment.isBotAuthored &&
51
- candidate.latestComment.id !== handledCommentId);
52
- }
53
25
  export function createTickRunner(deps) {
54
26
  const policy = createPolicyEngine();
55
27
  const lifecycle = createLifecycleService();
@@ -58,131 +30,34 @@ export function createTickRunner(deps) {
58
30
  resourceIndex: deps.resourceIndex,
59
31
  config: deps.config,
60
32
  });
61
- function extractTokenCount(tokenUsage) {
62
- if (tokenUsage === undefined) {
63
- return undefined;
64
- }
65
- // Cache tokens dominate real usage and were previously dropped from this
66
- // total entirely, understating the reported figure by roughly an order of
67
- // magnitude (#135).
68
- return (tokenUsage.inputTokens +
69
- tokenUsage.outputTokens +
70
- (tokenUsage.cacheCreationInputTokens ?? 0) +
71
- (tokenUsage.cacheReadInputTokens ?? 0));
72
- }
73
- function formatCostUsd(costUsd) {
74
- return `$${costUsd.toFixed(costUsd < 1 ? 4 : 2)}`;
75
- }
76
- function formatDuration(startedAtStr, finishedAtStr) {
77
- const startedAt = new Date(startedAtStr);
78
- const finishedAt = new Date(finishedAtStr);
79
- const durationMs = finishedAt.getTime() - startedAt.getTime();
80
- if (durationMs < 0 || !isFinite(durationMs))
81
- return undefined;
82
- const totalSeconds = Math.floor(durationMs / 1000);
83
- const minutes = Math.floor(totalSeconds / 60);
84
- const seconds = totalSeconds % 60;
85
- if (minutes > 0) {
86
- return `${minutes}m${seconds}s`;
87
- }
88
- return `${seconds}s`;
89
- }
90
- function formatTokenCount(count) {
91
- if (count >= 1000000) {
92
- return `${(count / 1000000).toFixed(1)}M`;
93
- }
94
- if (count >= 1000) {
95
- return `${(count / 1000).toFixed(0)}k`;
96
- }
97
- return String(count);
98
- }
99
- function createPublishIntentEvent(input) {
100
- const tokenCount = extractTokenCount(input.runnerResult.tokenUsage);
101
- const duration = formatDuration(input.startedAt, input.occurredAt);
102
- return createEventEnvelope({
103
- eventId: `${input.runId}-publish-intent`,
104
- workItemKey: input.projection.workItemKey,
105
- streamScope: 'work-item',
106
- direction: 'outbound',
107
- sourceSystem: 'wake',
108
- sourceEventType: 'wake.publish.intent.requested',
109
- sourceRefs: {
110
- repo: input.projection.issue.repo,
111
- issueNumber: input.projection.issue.number,
112
- runId: input.runId,
113
- // Carries the triggering comment's surface (set only when the human
114
- // reply that woke this run came from a correlated PR/review-thread
115
- // resource, per the ad1cf45 comment fold) through to the sink
116
- // router, so createOutboundSinkRouter's kind-based routing (Task 11,
117
- // sink-router.ts) can send the reply back to that surface instead of
118
- // defaulting to the issue thread. Without this, every reply landed
119
- // on the origin sink regardless of which surface triggered the run.
120
- //
121
- // Gated on isFreshTriggeringComment: latestComment is sticky (see
122
- // that function's comment) and several run-completion paths (first
123
- // run, quota retry, first workflow-stage pass) have no fresh
124
- // comment behind them at all — for those, threading the stale
125
- // latestComment.resourceUri would misroute the reply to whatever
126
- // surface last happened to comment, even long after that comment
127
- // was already replied to.
128
- //
129
- // Never threads a pr-review-thread surface specifically: this is
130
- // Wake's own status, approval-request, or question card: a milestone
131
- // message, not a targeted reply to one inline comment — burying it
132
- // as a reply deep in a single review thread makes it easy to miss.
133
- // Omitting resourceUri here falls back to sourceOrigin in
134
- // sink-router.ts, landing it on the correlated issue (or, for a
135
- // standalone PR-only work item, GitHub's shared issue/PR comments
136
- // endpoint posts it as a top-level PR comment instead). Replies to
137
- // individual review threads are the agent's own job now — see
138
- // prompts/revise.md — via `gh api .../replies`, not this card.
139
- ...(input.projection.latestComment?.resourceUri === undefined ||
140
- !isFreshTriggeringComment(input.projection) ||
141
- isReviewThreadResourceUri(input.projection.latestComment.resourceUri)
142
- ? {}
143
- : { resourceUri: input.projection.latestComment.resourceUri }),
144
- },
145
- occurredAt: input.occurredAt,
146
- ingestedAt: input.occurredAt,
147
- trigger: 'context-only',
148
- payload: {
149
- kind: input.sentinel === 'BLOCKED'
150
- ? 'question'
151
- : input.sentinel === 'AWAITING_APPROVAL'
152
- ? 'approval-request'
153
- : input.sentinel === 'FAILED'
154
- ? 'failure'
155
- : 'status-update',
156
- origin: input.projection.origin ?? 'github',
157
- body: input.parsedRunnerResult.body,
158
- action: input.action,
159
- sentinel: input.sentinel,
160
- runId: input.runId,
161
- ...(input.runnerResult.session_id === undefined
162
- ? {}
163
- : { sessionId: input.runnerResult.session_id }),
164
- model: input.runnerResult.model,
165
- cli: input.runnerResult.cli,
166
- ...(input.runnerResult.routing === undefined
167
- ? {}
168
- : {
169
- runnerName: input.runnerResult.routing.runnerName,
170
- runnerKind: input.runnerResult.routing.runnerKind,
171
- runnerTier: input.runnerResult.routing.tier,
172
- runnerReason: input.runnerResult.routing.reason,
173
- }),
174
- ...(duration === undefined ? {} : { duration }),
175
- ...(tokenCount === undefined ? {} : { tokens: formatTokenCount(tokenCount) }),
176
- ...(input.runnerResult.tokenUsage?.costUsd === undefined
177
- ? {}
178
- : { cost: formatCostUsd(input.runnerResult.tokenUsage.costUsd) }),
179
- ...(input.workspacePath === undefined ? {} : { workspacePath: input.workspacePath }),
180
- },
181
- derivedHints: {
182
- stage: input.sentinel === 'DONE' ? 'done' : input.projection.wake.stage,
183
- },
184
- });
185
- }
33
+ const { deliverOutboundEvent, retryUnconfirmedDeliveries } = createOutbox({
34
+ clock: deps.clock,
35
+ stateStore: deps.stateStore,
36
+ projectionUpdater,
37
+ ...(deps.outboundSink === undefined ? {} : { outboundSink: deps.outboundSink }),
38
+ });
39
+ const { ingestInboundEvents } = createEventResolver({
40
+ clock: deps.clock,
41
+ config: deps.config,
42
+ stateStore: deps.stateStore,
43
+ resourceIndex: deps.resourceIndex,
44
+ projectionUpdater,
45
+ qualifiesForMint: policy.qualifiesForMint,
46
+ });
47
+ const { reconcileStaleRunningRecords } = createStaleRunReconciler({
48
+ config: deps.config,
49
+ stateStore: deps.stateStore,
50
+ projectionUpdater,
51
+ runnerTimeoutMs,
52
+ deliverOutboundEvent,
53
+ });
54
+ const { cleanupClosedIssueWorkspaces } = createWorkspaceCleanup({
55
+ clock: deps.clock,
56
+ config: deps.config,
57
+ stateStore: deps.stateStore,
58
+ workspaceManager: deps.workspaceManager,
59
+ projectionUpdater,
60
+ });
186
61
  function isAwaitingApproval(projection) {
187
62
  return projection.context.lastRunSentinel === awaitingApprovalRunnerSentinel;
188
63
  }
@@ -207,48 +82,6 @@ export function createTickRunner(deps) {
207
82
  function hasLabel(projection, label) {
208
83
  return projection.issue.labels.includes(label);
209
84
  }
210
- function shouldMarkPending(projection) {
211
- if (!policy.isEligible(projection, deps.config)) {
212
- return false;
213
- }
214
- const workflow = workflowForProjection(projection, deps.config);
215
- if (workflow === null || !isKnownWorkflowStage(projection.wake.stage, workflow)) {
216
- return false;
217
- }
218
- if (isAwaitingApproval(projection)) {
219
- return (policy.resolveCustomCommandRequest(projection, deps.config) !== null ||
220
- policy.resolveApprovalTransition(projection) !== null ||
221
- policy.resolvePendingReviewFeedback(projection) !== null);
222
- }
223
- const nextAction = policy.resolveCustomCommandRequest(projection, deps.config)?.action ??
224
- policy.chooseAction(projection, workflow) ??
225
- policy.chooseRetryActionAfterHumanReply(projection, workflow);
226
- return nextAction !== null && policy.needsWakeAction(projection, workflow);
227
- }
228
- function createLabelsEvent(input) {
229
- return createEventEnvelope({
230
- 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, '-')}`,
231
- workItemKey: input.projection.workItemKey,
232
- streamScope: 'work-item',
233
- direction: 'outbound',
234
- sourceSystem: 'wake',
235
- sourceEventType: 'wake.labels.requested',
236
- sourceRefs: {
237
- repo: input.projection.issue.repo,
238
- issueNumber: input.projection.issue.number,
239
- runId: input.runId,
240
- },
241
- occurredAt: input.occurredAt,
242
- ingestedAt: input.occurredAt,
243
- trigger: 'context-only',
244
- payload: {
245
- statusLabel: input.statusLabel,
246
- stageLabel: input.stageLabel,
247
- workflowLabel: input.workflowLabel,
248
- origin: input.projection.origin ?? 'github',
249
- },
250
- });
251
- }
252
85
  // Closes the loop on #82's review feedback: rather than scrape a PR link
253
86
  // out of the agent's free text, the agent emits a `wake-artifacts` fence
254
87
  // (domain/schema.ts's parseRunnerArtifacts) and Wake verifies each claim
@@ -342,7 +175,7 @@ export function createTickRunner(deps) {
342
175
  const statusLabel = statusLabelForStage(projection.wake.stage);
343
176
  const stageLabel = stageLabelForStage(projection.wake.stage);
344
177
  const workflowLabel = workflowLabelForWorkflowName(workflowNameForProjection(projection, deps.config));
345
- if (!shouldMarkPending(projection) ||
178
+ if (policy.resolveNextEligibleAction(projection, deps.config) === null ||
346
179
  (hasLabel(projection, statusLabel) &&
347
180
  hasLabel(projection, stageLabel) &&
348
181
  hasLabel(projection, workflowLabel))) {
@@ -358,391 +191,9 @@ export function createTickRunner(deps) {
358
191
  }));
359
192
  }
360
193
  }
361
- // Returns whichever of the two timestamps is unambiguously later, defaulting
362
- // to `left`. `right` wins only if it is later by BOTH the actual instant and
363
- // the lexicographic order rebuildFromEvents sorts on — the envelope schema is
364
- // `z.string().datetime({ offset: true })`, so a timestamp may legally carry a
365
- // non-UTC offset or differing sub-second precision, and lexicographic order
366
- // alone is not a reliable proxy for chronology across those formats. Falling
367
- // back to `left` (the source event's own exact string) is always safe: it
368
- // ties with that event under localeCompare, and the stable sort then preserves
369
- // append order, which puts the mint events after it — exactly what we need.
370
- function laterTimestamp(left, right) {
371
- const isLater = Date.parse(right) > Date.parse(left) && right.localeCompare(left) > 0;
372
- return isLater ? right : left;
373
- }
374
- // The two internal events a mint (or a heal) appends after the source event
375
- // that founded a work item: wake.workitem.created, then the
376
- // wake.correlation.registered that claims the originating resource as this
377
- // work item's primary representation. Their ids are derived from the work id,
378
- // so re-emitting them is idempotent — appendEventEnvelope dedups on the id.
379
- //
380
- // Ordering and timestamps matter, and not only for readability. Both fold
381
- // against the projection the source event creates, and applyEvent drops
382
- // anything that folds while `current === null`. So they must never sort
383
- // *before* that source event in rebuildFromEvents' globally-ordered replay —
384
- // if they did, replay would silently discard the registration, leaving
385
- // correlatedResources[] empty and the index unpopulated, while the events
386
- // still on record stop any later tick from re-registering. Permanent,
387
- // self-concealing loss (Task 5, round 3). Reading the clock here is already
388
- // after pollEvents(), but that alone is not a guarantee: the source event's
389
- // ingestedAt comes from the *source's* clock, which for a real source is
390
- // another machine's and can legitimately run ahead of ours. Anchoring on the
391
- // source event's own timestamp makes the ordering hold by construction rather
392
- // than by clock agreement; appending the source event first means a tie
393
- // resolves in its favour (the sort is stable, so equal timestamps keep append
394
- // order).
395
- function buildOriginCorrelationEvents(workItemKey, unkeyed, resourceUri) {
396
- const mintedAt = laterTimestamp(unkeyed.ingestedAt, eventStampNow());
397
- const sourceRefs = {
398
- ...unkeyed.sourceRefs,
399
- resourceUri,
400
- };
401
- const createdEvent = createEventEnvelope({
402
- eventId: `${workItemKey}-created`,
403
- workItemKey,
404
- streamScope: 'work-item',
405
- direction: 'internal',
406
- sourceSystem: 'wake',
407
- sourceEventType: WORK_ITEM_CREATED_EVENT,
408
- sourceRefs,
409
- occurredAt: mintedAt,
410
- ingestedAt: mintedAt,
411
- trigger: 'context-only',
412
- // The envelope's workItemKey already carries the identity.
413
- payload: {},
414
- });
415
- const registeredEvent = createEventEnvelope({
416
- eventId: `${workItemKey}-origin-correlation`,
417
- workItemKey,
418
- streamScope: 'work-item',
419
- direction: 'internal',
420
- sourceSystem: 'wake',
421
- sourceEventType: CORRELATION_REGISTERED_EVENT,
422
- sourceRefs,
423
- occurredAt: mintedAt,
424
- ingestedAt: mintedAt,
425
- trigger: 'context-only',
426
- payload: {
427
- resourceUri,
428
- role: 'representation',
429
- relation: 'primary',
430
- provenance: 'wake-created',
431
- },
432
- });
433
- return [createdEvent, registeredEvent];
434
- }
435
- // The central resolver (spec D1): sources name the *resource* an event came
436
- // from and never the work item, so between pollEvents() and the append every
437
- // inbound event's sourceRefs.resourceUri is resolved through the reverse
438
- // index to the canonical workItemKey, minting a work item on a miss. This is
439
- // the one mechanism — there is no founding-surface special case, and no
440
- // resolution is ever cached in process memory between ticks (CLAUDE.md: the
441
- // tick is a pure function of durable state; the index on disk *is* that
442
- // state).
443
- //
444
- // Each resolved event carries whether it is already `persisted`, so the
445
- // caller can skip re-appending it (appendEventEnvelope would only re-read it
446
- // and hand back the same envelope). Minting *is* registration, so a freshly
447
- // minted work item's correlatedResources[] is complete from its first event.
448
- async function resolveInboundEvent(unkeyed) {
449
- const { resourceUri } = unkeyed.sourceRefs;
450
- if (resourceUri === undefined) {
451
- // A programming error in the adapter, not a runtime condition to absorb.
452
- // Guessing an identity here would silently fork a duplicate work item
453
- // for work already in flight — exactly the corruption the reverse index
454
- // exists to prevent — so fail loudly instead.
455
- throw new Error(`cannot resolve inbound event ${unkeyed.eventId} from ${unkeyed.sourceSystem}: ` +
456
- 'sourceRefs.resourceUri is required for every unkeyed source event');
457
- }
458
- // An event we have already persisted was already resolved, on some earlier
459
- // tick, and its stamped key is the durable answer. Re-resolving it through
460
- // the index would be wrong as well as wasteful: if that work item has since
461
- // retracted this resource, the index no longer holds it, the lookup misses,
462
- // and a miss means mint — so a re-polled event (sources legitimately
463
- // re-emit the same eventId, e.g. an unchanged issue) would fork a duplicate
464
- // work item. Reusing the persisted key keeps resolution idempotent per
465
- // event id, which is what the append-only log already promises.
466
- const persisted = await deps.stateStore.readEventEnvelope(unkeyed.eventId);
467
- if (persisted !== null) {
468
- // Heal a partially minted work item. The index entry for a resource is
469
- // written only when its origin wake.correlation.registered event is
470
- // *folded*, several appends after the founding source event. A crash in
471
- // that window leaves the source event durable — so this branch suppresses
472
- // re-minting — while the index has no entry, and a later event on the
473
- // same resource would miss the index and fork a duplicate work item
474
- // (crash/restart safety, CLAUDE.md). If the index does not credit this
475
- // event's work item *and* its origin correlation never landed, re-emit
476
- // the mint tail (idempotent by id). The guard is the missing origin
477
- // event, not merely an empty index: a deliberately *retracted* resource
478
- // also resolves to undefined but keeps its origin-correlation event on
479
- // record, and must not be silently re-registered.
480
- const owner = await deps.resourceIndex.resolve(resourceUri);
481
- if (persisted.workItemKey !== UNRESOLVED_WORK_ITEM_KEY &&
482
- owner === undefined &&
483
- (await deps.stateStore.readEventEnvelope(`${persisted.workItemKey}-origin-correlation`)) ===
484
- null) {
485
- return [
486
- { envelope: persisted, persisted: true },
487
- ...buildOriginCorrelationEvents(persisted.workItemKey, unkeyed, resourceUri).map((envelope) => ({ envelope, persisted: false })),
488
- ];
489
- }
490
- return [{ envelope: persisted, persisted: true }];
491
- }
492
- const existingWorkItemKey = await deps.resourceIndex.resolve(resourceUri);
493
- if (existingWorkItemKey !== undefined) {
494
- return [
495
- {
496
- envelope: createEventEnvelope({ ...unkeyed, workItemKey: existingWorkItemKey }),
497
- persisted: false,
498
- },
499
- ];
500
- }
501
- // resourceUri itself misses the index (e.g. a review-thread comment's
502
- // resourceUri is unique per thread, never registered on its own), but the
503
- // adapter may have named a parent resource this one belongs to. Resolve
504
- // through that instead of minting — and register this exact resourceUri
505
- // as a secondary correlation so it's on record on the work item, even
506
- // though (being secondary) it still won't shortcut future lookups via the
507
- // index itself (ADR 0001 §5: the index is primary-only).
508
- if (unkeyed.sourceRefs.parentResourceUri !== undefined) {
509
- const parentWorkItemKey = await deps.resourceIndex.resolve(unkeyed.sourceRefs.parentResourceUri);
510
- if (parentWorkItemKey !== undefined) {
511
- const mintedAt = laterTimestamp(unkeyed.ingestedAt, eventStampNow());
512
- return [
513
- {
514
- envelope: createEventEnvelope({ ...unkeyed, workItemKey: parentWorkItemKey }),
515
- persisted: false,
516
- },
517
- {
518
- envelope: createEventEnvelope({
519
- eventId: `${parentWorkItemKey}-correlation-${resourceUri.replace(/[^a-z0-9]+/gi, '-')}`,
520
- workItemKey: parentWorkItemKey,
521
- streamScope: 'work-item',
522
- direction: 'internal',
523
- sourceSystem: 'wake',
524
- sourceEventType: CORRELATION_REGISTERED_EVENT,
525
- sourceRefs: unkeyed.sourceRefs,
526
- occurredAt: mintedAt,
527
- ingestedAt: mintedAt,
528
- trigger: 'context-only',
529
- payload: {
530
- resourceUri,
531
- role: 'review',
532
- relation: 'secondary',
533
- provenance: 'detected',
534
- },
535
- }),
536
- persisted: false,
537
- },
538
- ];
539
- }
540
- }
541
- if (!policy.qualifiesForMint(unkeyed, deps.config)) {
542
- return [
543
- {
544
- envelope: createEventEnvelope({ ...unkeyed, workItemKey: UNRESOLVED_WORK_ITEM_KEY }),
545
- persisted: false,
546
- },
547
- ];
548
- }
549
- const workItemKey = createWorkId();
550
- const keyed = createEventEnvelope({ ...unkeyed, workItemKey });
551
- return [
552
- { envelope: keyed, persisted: false },
553
- ...buildOriginCorrelationEvents(workItemKey, unkeyed, resourceUri).map((envelope) => ({
554
- envelope,
555
- persisted: false,
556
- })),
557
- ];
558
- }
559
- async function ingestInboundEvents(unkeyedEvents) {
560
- const ingested = [];
561
- for (const unkeyed of unkeyedEvents) {
562
- const resolved = await resolveInboundEvent(unkeyed);
563
- // Fold what was actually persisted, never the in-memory copy:
564
- // appendEventEnvelope is id-deduplicated and returns the *existing*
565
- // envelope when one is already on record. Folding our own copy instead
566
- // would let state/ diverge from events/ — and replay is defined by
567
- // events/, so the divergence would only surface after a rebuild. An event
568
- // already flagged `persisted` needs no second append: appendEventEnvelope
569
- // would only re-read it off disk and hand back the same envelope, and
570
- // every unchanged issue is re-polled every tick, so that read is the
571
- // dominant redundant cost this branch avoids.
572
- const events = [];
573
- for (const { envelope, persisted } of resolved) {
574
- events.push(persisted ? envelope : await deps.stateStore.appendEventEnvelope(envelope));
575
- }
576
- // Folded before the next event is resolved, because it is the fold of
577
- // the registration event that writes the index entry the *next* event on
578
- // the same resource resolves through. Deferring the fold to the end of
579
- // the batch would let a second event for the same ticket miss and mint a
580
- // duplicate work item. Every event in a poll batch shares the source's
581
- // one ingestedAt, so folding per event preserves exactly the order a
582
- // single batched fold would have produced.
583
- await projectionUpdater.rebuildFromEvents(events);
584
- ingested.push(...events);
585
- }
586
- return ingested;
587
- }
588
194
  function runnerTimeoutMs() {
589
195
  return maxConfiguredRunnerTimeoutMs(deps.config);
590
196
  }
591
- function isPerIssueWorkspacePath(workspacePath) {
592
- const workspacesRoot = join(deps.config.paths.wakeRoot, 'workspaces');
593
- const rel = relative(workspacesRoot, workspacePath);
594
- return !rel.startsWith('..') && !isAbsolute(rel) && rel.length > 0;
595
- }
596
- async function cleanupClosedIssueWorkspaces(projections) {
597
- for (const projection of projections) {
598
- const { workspacePath } = projection.wake;
599
- if (projection.issue.state === 'closed' &&
600
- workspacePath !== undefined &&
601
- isPerIssueWorkspacePath(workspacePath)) {
602
- try {
603
- await deps.workspaceManager.cleanupWorkspace({ workspacePath });
604
- if (!deps.config.transcripts.retainAfterWorkspaceCleanup) {
605
- await rm(deps.stateStore.paths.transcriptWorkDir(projection.workItemKey), {
606
- recursive: true,
607
- force: true,
608
- });
609
- }
610
- }
611
- catch (error) {
612
- const failedAt = eventStampNow();
613
- await deps.stateStore.appendEventEnvelope(createEventEnvelope({
614
- eventId: `workspace-cleanup-failed-${projection.issue.repo.replace(/[^a-z0-9]+/gi, '-')}-${projection.issue.number}`,
615
- workItemKey: projection.workItemKey,
616
- streamScope: 'work-item',
617
- direction: 'internal',
618
- sourceSystem: 'wake',
619
- sourceEventType: 'wake.workspace.cleanup-failed',
620
- sourceRefs: {
621
- repo: projection.issue.repo,
622
- issueNumber: projection.issue.number,
623
- },
624
- occurredAt: failedAt,
625
- ingestedAt: failedAt,
626
- trigger: 'context-only',
627
- payload: {
628
- workspacePath,
629
- error: error instanceof Error ? error.message : String(error),
630
- },
631
- }));
632
- continue;
633
- }
634
- const cleanedAt = eventStampNow();
635
- const cleanupEvent = createEventEnvelope({
636
- eventId: `workspace-cleaned-${projection.issue.repo.replace(/[^a-z0-9]+/gi, '-')}-${projection.issue.number}-${deps.clock.now().getTime()}`,
637
- workItemKey: projection.workItemKey,
638
- streamScope: 'work-item',
639
- direction: 'internal',
640
- sourceSystem: 'wake',
641
- sourceEventType: 'wake.workspace.cleaned',
642
- sourceRefs: {
643
- repo: projection.issue.repo,
644
- issueNumber: projection.issue.number,
645
- },
646
- occurredAt: cleanedAt,
647
- ingestedAt: cleanedAt,
648
- trigger: 'immediate',
649
- payload: { workspacePath },
650
- });
651
- await deps.stateStore.appendEventEnvelope(cleanupEvent);
652
- await projectionUpdater.rebuildFromEvents([cleanupEvent]);
653
- }
654
- }
655
- }
656
- function isStaleRunningRecord(record, now) {
657
- if (record.status !== 'running') {
658
- return false;
659
- }
660
- const startedAtMs = Date.parse(record.startedAt);
661
- if (!Number.isFinite(startedAtMs)) {
662
- return true;
663
- }
664
- return now.getTime() - startedAtMs >= runnerTimeoutMs();
665
- }
666
- async function reconcileStaleRunningRecords(now) {
667
- const finishedAt = now.toISOString();
668
- const runRecords = await deps.stateStore.listRunRecords();
669
- const staleRecords = runRecords.filter((record) => isStaleRunningRecord(record, now));
670
- for (const record of staleRecords) {
671
- // Run records carry the work item they belong to, so this is a direct
672
- // O(1) read — no scan, no index, no source ambiguity. The record's
673
- // repo/issueNumber are representation content and take no part in it.
674
- const projection = await deps.stateStore.readIssueState(record.workItemKey);
675
- const newerCompletedRun = runRecords.some((candidate) => candidate.workItemKey === record.workItemKey &&
676
- candidate.runId !== record.runId &&
677
- candidate.status !== 'running' &&
678
- Date.parse(candidate.startedAt) > Date.parse(record.startedAt));
679
- // Equivalent to the previous `projection?.wake.lastRunId !== record.runId`,
680
- // spelled out so the non-null projection is available below for its
681
- // workItemKey.
682
- if (projection === null || projection.wake.lastRunId !== record.runId || newerCompletedRun) {
683
- await deps.stateStore.writeRunRecord({
684
- ...record,
685
- status: 'superseded',
686
- finishedAt,
687
- summary: 'Stale running record was superseded by a newer run.',
688
- metadata: {
689
- ...record.metadata,
690
- reconciledBy: 'stale-running-record',
691
- supersededBy: projection?.wake.lastRunId,
692
- },
693
- });
694
- continue;
695
- }
696
- await deps.stateStore.writeRunRecord({
697
- ...record,
698
- status: 'failed',
699
- finishedAt,
700
- sentinel: 'FAILED',
701
- summary: `Run exceeded timeout while marked running and was reconciled by a later tick.`,
702
- metadata: {
703
- ...record.metadata,
704
- reconciledBy: 'stale-running-record',
705
- timeoutMs: runnerTimeoutMs(),
706
- },
707
- });
708
- const runCompletedEvent = createEventEnvelope({
709
- eventId: `${record.runId}-stale-reconciled`,
710
- workItemKey: projection.workItemKey,
711
- streamScope: 'work-item',
712
- direction: 'internal',
713
- sourceSystem: 'wake',
714
- sourceEventType: 'wake.run.completed',
715
- sourceRefs: {
716
- repo: record.repo,
717
- issueNumber: record.issueNumber,
718
- runId: record.runId,
719
- },
720
- occurredAt: finishedAt,
721
- ingestedAt: finishedAt,
722
- trigger: 'immediate',
723
- payload: {
724
- action: record.action,
725
- sentinel: 'FAILED',
726
- runId: record.runId,
727
- reason: 'runner:stale-timeout',
728
- ...(record.routing === undefined ? {} : { routing: record.routing }),
729
- },
730
- });
731
- await deps.stateStore.appendEventEnvelope(runCompletedEvent);
732
- await projectionUpdater.rebuildFromEvents([runCompletedEvent]);
733
- const updatedProjection = await deps.stateStore.readIssueState(projection.workItemKey);
734
- if (updatedProjection !== null) {
735
- await deliverOutboundEvent(createLabelsEvent({
736
- projection: updatedProjection,
737
- runId: record.runId,
738
- statusLabel: 'wake:status.failed',
739
- stageLabel: stageLabelForStage(updatedProjection.wake.stage),
740
- workflowLabel: workflowLabelForWorkflowName(workflowNameForProjection(updatedProjection, deps.config)),
741
- occurredAt: finishedAt,
742
- }));
743
- }
744
- }
745
- }
746
197
  async function parkConfigDriftedProjections(projections) {
747
198
  let parked = false;
748
199
  for (const projection of projections) {
@@ -792,120 +243,6 @@ export function createTickRunner(deps) {
792
243
  }
793
244
  return parked;
794
245
  }
795
- const outboxMaxAttempts = 3;
796
- async function recordDeliveryFailure(intentEvent, err) {
797
- const occurredAt = deps.clock.now().toISOString();
798
- const failureEvent = createEventEnvelope({
799
- eventId: `${intentEvent.eventId}-delivery-failed-${randomUUID()}`,
800
- workItemKey: intentEvent.workItemKey,
801
- streamScope: 'work-item',
802
- direction: 'internal',
803
- sourceSystem: 'wake',
804
- sourceEventType: 'wake.publish.failed',
805
- sourceRefs: intentEvent.sourceRefs,
806
- occurredAt,
807
- ingestedAt: occurredAt,
808
- trigger: 'context-only',
809
- payload: {
810
- intentEventId: intentEvent.eventId,
811
- intentEventType: intentEvent.sourceEventType,
812
- error: err instanceof Error ? err.message : String(err),
813
- },
814
- });
815
- await deps.stateStore.appendEventEnvelope(failureEvent);
816
- await projectionUpdater.rebuildFromEvents([failureEvent]);
817
- }
818
- // Outbound delivery (comments, labels) is attempted independently of run-outcome
819
- // recording: a delivery failure must never rewrite an already-recorded run result
820
- // (S1), and must always leave a durable, retryable trace instead of being lost
821
- // (E5). This never throws — failures become a `wake.publish.failed` event.
822
- async function attemptDelivery(event) {
823
- if (deps.outboundSink === undefined) {
824
- return;
825
- }
826
- try {
827
- const deliveryEvents = await deps.outboundSink.deliverIntent({ event });
828
- for (const deliveryEvent of deliveryEvents) {
829
- await deps.stateStore.appendEventEnvelope(deliveryEvent);
830
- }
831
- await projectionUpdater.rebuildFromEvents(deliveryEvents);
832
- if (deliveryEvents.length === 0) {
833
- // No confirmation event was produced (e.g. a no-op label update) but the
834
- // sink did not throw. Record that delivery was attempted successfully so
835
- // the outbox scan below does not retry it indefinitely.
836
- const confirmedAt = deps.clock.now().toISOString();
837
- const confirmedEvent = createEventEnvelope({
838
- eventId: `${event.eventId}-confirmed`,
839
- workItemKey: event.workItemKey,
840
- streamScope: 'work-item',
841
- direction: 'internal',
842
- sourceSystem: 'wake',
843
- sourceEventType: 'wake.publish.confirmed',
844
- sourceRefs: event.sourceRefs,
845
- occurredAt: confirmedAt,
846
- ingestedAt: confirmedAt,
847
- trigger: 'context-only',
848
- payload: { intentEventId: event.eventId },
849
- });
850
- await deps.stateStore.appendEventEnvelope(confirmedEvent);
851
- }
852
- }
853
- catch (err) {
854
- await recordDeliveryFailure(event, err);
855
- }
856
- }
857
- async function deliverOutboundEvent(event) {
858
- await deps.stateStore.appendEventEnvelope(event);
859
- await projectionUpdater.rebuildFromEvents([event]);
860
- await attemptDelivery(event);
861
- }
862
- const outboundIntentEventTypes = new Set([
863
- 'wake.publish.intent.requested',
864
- 'wake.labels.requested',
865
- ]);
866
- const outboundConfirmationEventTypes = new Set([
867
- 'ticket.reply.published',
868
- 'ticket.labels.updated',
869
- 'wake.publish.confirmed',
870
- 'pr.comment.reply.published',
871
- 'pr.review-comment.reply.published',
872
- ]);
873
- // Adopts the outbox pattern: an intent is only considered delivered once a
874
- // matching confirmation event exists. Anything left unconfirmed by a prior tick
875
- // (e.g. the process crashed mid-delivery) is retried here, bounded so a
876
- // permanently failing sink dead-letters instead of retrying forever.
877
- async function retryUnconfirmedDeliveries() {
878
- if (deps.outboundSink === undefined) {
879
- return;
880
- }
881
- const events = await deps.stateStore.listEventEnvelopes();
882
- const confirmedIntentIds = new Set();
883
- const failureAttempts = new Map();
884
- for (const event of events) {
885
- const intentEventId = event.payload.intentEventId;
886
- if (typeof intentEventId !== 'string') {
887
- continue;
888
- }
889
- if (outboundConfirmationEventTypes.has(event.sourceEventType)) {
890
- confirmedIntentIds.add(intentEventId);
891
- }
892
- if (event.sourceEventType === 'wake.publish.failed') {
893
- failureAttempts.set(intentEventId, (failureAttempts.get(intentEventId) ?? 0) + 1);
894
- }
895
- }
896
- for (const intent of events) {
897
- if (!outboundIntentEventTypes.has(intent.sourceEventType)) {
898
- continue;
899
- }
900
- if (confirmedIntentIds.has(intent.eventId)) {
901
- continue;
902
- }
903
- if ((failureAttempts.get(intent.eventId) ?? 0) >= outboxMaxAttempts) {
904
- continue;
905
- }
906
- await attemptDelivery(intent);
907
- }
908
- }
909
246
  async function runIntakeTick() {
910
247
  const lock = await acquireFileLock(deps.stateStore.paths.tickLockFile, {
911
248
  staleAfterMs: Math.min(runnerTimeoutMs(), 5 * 60 * 1000),
@@ -952,22 +289,7 @@ export function createTickRunner(deps) {
952
289
  if (await parkConfigDriftedProjections(projections)) {
953
290
  return { status: 'processed' };
954
291
  }
955
- const candidate = projections.find((issue) => {
956
- if (!policy.isEligible(issue, deps.config)) {
957
- return false;
958
- }
959
- const workflow = workflowForProjection(issue, deps.config);
960
- if (workflow === null || !isKnownWorkflowStage(issue.wake.stage, workflow)) {
961
- return false;
962
- }
963
- if (isAwaitingApproval(issue)) {
964
- return policy.needsWakeAction(issue, workflow);
965
- }
966
- const nextAction = policy.resolveCustomCommandRequest(issue, deps.config)?.action ??
967
- policy.chooseAction(issue, workflow) ??
968
- policy.chooseRetryActionAfterHumanReply(issue, workflow);
969
- return nextAction !== null && policy.needsWakeAction(issue, workflow);
970
- });
292
+ const candidate = projections.find((issue) => policy.resolveNextEligibleAction(issue, deps.config) !== null);
971
293
  if (candidate === undefined) {
972
294
  return { status: 'idle' };
973
295
  }
@@ -1079,7 +401,7 @@ export function createTickRunner(deps) {
1079
401
  command = policy.resolveCustomCommandRequest(candidate, deps.config)?.command;
1080
402
  claimedStage = workflowAction?.stage ?? candidate.wake.stage;
1081
403
  workspaceMode =
1082
- workspaceModeForCustomCommand(action, deps.config) ?? workflowAction?.workspace ?? 'none';
404
+ customCommandWorkspace(action, deps.config) ?? workflowAction?.workspace ?? 'none';
1083
405
  }
1084
406
  // Resolve routing (with sideways fallback across quota-paused runners,
1085
407
  // #67) before claiming a run, so a fully-paused tier costs nothing more