@atolis-hq/wake 0.1.12 → 0.1.14
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.
- package/dist/src/core/event-builders.js +139 -0
- package/dist/src/core/event-resolver.js +249 -0
- package/dist/src/core/outbox.js +123 -0
- package/dist/src/core/policy-engine.js +39 -1
- package/dist/src/core/stale-run-reconciler.js +102 -0
- package/dist/src/core/tick-runner.js +37 -715
- package/dist/src/core/workspace-cleanup.js +77 -0
- package/dist/src/lib/format.js +41 -0
- package/dist/src/version.js +1 -1
- package/package.json +1 -1
|
@@ -0,0 +1,139 @@
|
|
|
1
|
+
import { createEventEnvelope } from '../lib/event-log.js';
|
|
2
|
+
import { extractTokenCount, formatCostUsd, formatDuration, formatTokenCount, } from '../lib/format.js';
|
|
3
|
+
function isReviewThreadResourceUri(resourceUri) {
|
|
4
|
+
return resourceUri.split(':')[1] === 'pr-review-thread';
|
|
5
|
+
}
|
|
6
|
+
// `latestComment` is a sticky, per-work-item field: projection-updater.ts's
|
|
7
|
+
// comment fold overwrites it unconditionally on every inbound comment
|
|
8
|
+
// (any surface) and nothing ever resets it. So it means "the last comment
|
|
9
|
+
// this work item has ever received," not "the comment that triggered the
|
|
10
|
+
// currently completing run." Several needsWakeAction trigger paths (first
|
|
11
|
+
// run, quota-failure retry, first workflow-stage pass) complete a run
|
|
12
|
+
// with no fresh comment driving it at all — in those cases latestComment
|
|
13
|
+
// may still be pointing at an older, already-handled comment from a
|
|
14
|
+
// different surface (e.g. a PR) and must not be trusted as this run's
|
|
15
|
+
// trigger. This mirrors policy-engine.ts's needsWakeAction "is there an
|
|
16
|
+
// unhandled human comment" check exactly, so a comment is only treated as
|
|
17
|
+
// having driven this run when it's human-authored and not yet the
|
|
18
|
+
// candidate's own lastHandledCommentId (read from the pre-completion
|
|
19
|
+
// projection passed in as `candidate`/`projection`, since the completion
|
|
20
|
+
// event that would update lastHandledCommentId for *this* run hasn't been
|
|
21
|
+
// folded yet at the point this runs).
|
|
22
|
+
function isFreshTriggeringComment(candidate) {
|
|
23
|
+
const context = candidate.context;
|
|
24
|
+
const handledCommentId = typeof context.lastHandledCommentId === 'string' ? context.lastHandledCommentId : undefined;
|
|
25
|
+
return (candidate.latestComment !== undefined &&
|
|
26
|
+
!candidate.latestComment.isBotAuthored &&
|
|
27
|
+
candidate.latestComment.id !== handledCommentId);
|
|
28
|
+
}
|
|
29
|
+
export function createPublishIntentEvent(input) {
|
|
30
|
+
const tokenCount = extractTokenCount(input.runnerResult.tokenUsage);
|
|
31
|
+
const duration = formatDuration(input.startedAt, input.occurredAt);
|
|
32
|
+
return createEventEnvelope({
|
|
33
|
+
eventId: `${input.runId}-publish-intent`,
|
|
34
|
+
workItemKey: input.projection.workItemKey,
|
|
35
|
+
streamScope: 'work-item',
|
|
36
|
+
direction: 'outbound',
|
|
37
|
+
sourceSystem: 'wake',
|
|
38
|
+
sourceEventType: 'wake.publish.intent.requested',
|
|
39
|
+
sourceRefs: {
|
|
40
|
+
repo: input.projection.issue.repo,
|
|
41
|
+
issueNumber: input.projection.issue.number,
|
|
42
|
+
runId: input.runId,
|
|
43
|
+
// Carries the triggering comment's surface (set only when the human
|
|
44
|
+
// reply that woke this run came from a correlated PR/review-thread
|
|
45
|
+
// resource, per the ad1cf45 comment fold) through to the sink
|
|
46
|
+
// router, so createOutboundSinkRouter's kind-based routing (Task 11,
|
|
47
|
+
// sink-router.ts) can send the reply back to that surface instead of
|
|
48
|
+
// defaulting to the issue thread. Without this, every reply landed
|
|
49
|
+
// on the origin sink regardless of which surface triggered the run.
|
|
50
|
+
//
|
|
51
|
+
// Gated on isFreshTriggeringComment: latestComment is sticky (see
|
|
52
|
+
// that function's comment) and several run-completion paths (first
|
|
53
|
+
// run, quota retry, first workflow-stage pass) have no fresh
|
|
54
|
+
// comment behind them at all — for those, threading the stale
|
|
55
|
+
// latestComment.resourceUri would misroute the reply to whatever
|
|
56
|
+
// surface last happened to comment, even long after that comment
|
|
57
|
+
// was already replied to.
|
|
58
|
+
//
|
|
59
|
+
// Never threads a pr-review-thread surface specifically: this is
|
|
60
|
+
// Wake's own status, approval-request, or question card: a milestone
|
|
61
|
+
// message, not a targeted reply to one inline comment — burying it
|
|
62
|
+
// as a reply deep in a single review thread makes it easy to miss.
|
|
63
|
+
// Omitting resourceUri here falls back to sourceOrigin in
|
|
64
|
+
// sink-router.ts, landing it on the correlated issue (or, for a
|
|
65
|
+
// standalone PR-only work item, GitHub's shared issue/PR comments
|
|
66
|
+
// endpoint posts it as a top-level PR comment instead). Replies to
|
|
67
|
+
// individual review threads are the agent's own job now — see
|
|
68
|
+
// prompts/revise.md — via `gh api .../replies`, not this card.
|
|
69
|
+
...(input.projection.latestComment?.resourceUri === undefined ||
|
|
70
|
+
!isFreshTriggeringComment(input.projection) ||
|
|
71
|
+
isReviewThreadResourceUri(input.projection.latestComment.resourceUri)
|
|
72
|
+
? {}
|
|
73
|
+
: { resourceUri: input.projection.latestComment.resourceUri }),
|
|
74
|
+
},
|
|
75
|
+
occurredAt: input.occurredAt,
|
|
76
|
+
ingestedAt: input.occurredAt,
|
|
77
|
+
trigger: 'context-only',
|
|
78
|
+
payload: {
|
|
79
|
+
kind: input.sentinel === 'BLOCKED'
|
|
80
|
+
? 'question'
|
|
81
|
+
: input.sentinel === 'AWAITING_APPROVAL'
|
|
82
|
+
? 'approval-request'
|
|
83
|
+
: input.sentinel === 'FAILED'
|
|
84
|
+
? 'failure'
|
|
85
|
+
: 'status-update',
|
|
86
|
+
origin: input.projection.origin ?? 'github',
|
|
87
|
+
body: input.parsedRunnerResult.body,
|
|
88
|
+
action: input.action,
|
|
89
|
+
sentinel: input.sentinel,
|
|
90
|
+
runId: input.runId,
|
|
91
|
+
...(input.runnerResult.session_id === undefined
|
|
92
|
+
? {}
|
|
93
|
+
: { sessionId: input.runnerResult.session_id }),
|
|
94
|
+
model: input.runnerResult.model,
|
|
95
|
+
cli: input.runnerResult.cli,
|
|
96
|
+
...(input.runnerResult.routing === undefined
|
|
97
|
+
? {}
|
|
98
|
+
: {
|
|
99
|
+
runnerName: input.runnerResult.routing.runnerName,
|
|
100
|
+
runnerKind: input.runnerResult.routing.runnerKind,
|
|
101
|
+
runnerTier: input.runnerResult.routing.tier,
|
|
102
|
+
runnerReason: input.runnerResult.routing.reason,
|
|
103
|
+
}),
|
|
104
|
+
...(duration === undefined ? {} : { duration }),
|
|
105
|
+
...(tokenCount === undefined ? {} : { tokens: formatTokenCount(tokenCount) }),
|
|
106
|
+
...(input.runnerResult.tokenUsage?.costUsd === undefined
|
|
107
|
+
? {}
|
|
108
|
+
: { cost: formatCostUsd(input.runnerResult.tokenUsage.costUsd) }),
|
|
109
|
+
...(input.workspacePath === undefined ? {} : { workspacePath: input.workspacePath }),
|
|
110
|
+
},
|
|
111
|
+
derivedHints: {
|
|
112
|
+
stage: input.sentinel === 'DONE' ? 'done' : input.projection.wake.stage,
|
|
113
|
+
},
|
|
114
|
+
});
|
|
115
|
+
}
|
|
116
|
+
export function createLabelsEvent(input) {
|
|
117
|
+
return createEventEnvelope({
|
|
118
|
+
eventId: `${input.runId}-labels-${input.statusLabel.replace(/[^a-z0-9]+/gi, '-')}-${input.stageLabel.replace(/[^a-z0-9]+/gi, '-')}-${input.workflowLabel.replace(/[^a-z0-9]+/gi, '-')}`,
|
|
119
|
+
workItemKey: input.projection.workItemKey,
|
|
120
|
+
streamScope: 'work-item',
|
|
121
|
+
direction: 'outbound',
|
|
122
|
+
sourceSystem: 'wake',
|
|
123
|
+
sourceEventType: 'wake.labels.requested',
|
|
124
|
+
sourceRefs: {
|
|
125
|
+
repo: input.projection.issue.repo,
|
|
126
|
+
issueNumber: input.projection.issue.number,
|
|
127
|
+
runId: input.runId,
|
|
128
|
+
},
|
|
129
|
+
occurredAt: input.occurredAt,
|
|
130
|
+
ingestedAt: input.occurredAt,
|
|
131
|
+
trigger: 'context-only',
|
|
132
|
+
payload: {
|
|
133
|
+
statusLabel: input.statusLabel,
|
|
134
|
+
stageLabel: input.stageLabel,
|
|
135
|
+
workflowLabel: input.workflowLabel,
|
|
136
|
+
origin: input.projection.origin ?? 'github',
|
|
137
|
+
},
|
|
138
|
+
});
|
|
139
|
+
}
|
|
@@ -0,0 +1,249 @@
|
|
|
1
|
+
import { createWorkId } from '../lib/work-id.js';
|
|
2
|
+
import { CORRELATION_REGISTERED_EVENT, UNRESOLVED_WORK_ITEM_KEY, WORK_ITEM_CREATED_EVENT, } from '../domain/schema.js';
|
|
3
|
+
import { createEventEnvelope } from '../lib/event-log.js';
|
|
4
|
+
// How an unkeyed source event becomes a work item — the correlation-resolution
|
|
5
|
+
// subsystem described in docs/adrs/0001-correlating-external-resources-to-work-items.md.
|
|
6
|
+
export function createEventResolver(deps) {
|
|
7
|
+
// Events are stamped by reading the clock at the moment of stamping, never
|
|
8
|
+
// from a frozen tick-start snapshot. `tickStartedAt` is the tick's *decision*
|
|
9
|
+
// clock (policy/staleness), and reusing it to date events inverts them
|
|
10
|
+
// against the work source's own poll-time ingestedAt — pollEvents() runs
|
|
11
|
+
// after tickStartedAt is captured, so in production every polled upsert is
|
|
12
|
+
// LATER than the tick's start. An event dated before the upsert that creates
|
|
13
|
+
// the projection it folds into sorts ahead of it in rebuildFromEvents' global
|
|
14
|
+
// replay, folds against `current === null`, and is silently dropped. Reading
|
|
15
|
+
// per event also stays correct once ticks work items in parallel, where a
|
|
16
|
+
// shared per-tick snapshot would tie every concurrent event on ingestedAt and
|
|
17
|
+
// leave append order as the only discriminator.
|
|
18
|
+
function eventStampNow() {
|
|
19
|
+
return deps.clock.now().toISOString();
|
|
20
|
+
}
|
|
21
|
+
// Returns whichever of the two timestamps is unambiguously later, defaulting
|
|
22
|
+
// to `left`. `right` wins only if it is later by BOTH the actual instant and
|
|
23
|
+
// the lexicographic order rebuildFromEvents sorts on — the envelope schema is
|
|
24
|
+
// `z.string().datetime({ offset: true })`, so a timestamp may legally carry a
|
|
25
|
+
// non-UTC offset or differing sub-second precision, and lexicographic order
|
|
26
|
+
// alone is not a reliable proxy for chronology across those formats. Falling
|
|
27
|
+
// back to `left` (the source event's own exact string) is always safe: it
|
|
28
|
+
// ties with that event under localeCompare, and the stable sort then preserves
|
|
29
|
+
// append order, which puts the mint events after it — exactly what we need.
|
|
30
|
+
function laterTimestamp(left, right) {
|
|
31
|
+
const isLater = Date.parse(right) > Date.parse(left) && right.localeCompare(left) > 0;
|
|
32
|
+
return isLater ? right : left;
|
|
33
|
+
}
|
|
34
|
+
// The two internal events a mint (or a heal) appends after the source event
|
|
35
|
+
// that founded a work item: wake.workitem.created, then the
|
|
36
|
+
// wake.correlation.registered that claims the originating resource as this
|
|
37
|
+
// work item's primary representation. Their ids are derived from the work id,
|
|
38
|
+
// so re-emitting them is idempotent — appendEventEnvelope dedups on the id.
|
|
39
|
+
//
|
|
40
|
+
// Ordering and timestamps matter, and not only for readability. Both fold
|
|
41
|
+
// against the projection the source event creates, and applyEvent drops
|
|
42
|
+
// anything that folds while `current === null`. So they must never sort
|
|
43
|
+
// *before* that source event in rebuildFromEvents' globally-ordered replay —
|
|
44
|
+
// if they did, replay would silently discard the registration, leaving
|
|
45
|
+
// correlatedResources[] empty and the index unpopulated, while the events
|
|
46
|
+
// still on record stop any later tick from re-registering. Permanent,
|
|
47
|
+
// self-concealing loss (Task 5, round 3). Reading the clock here is already
|
|
48
|
+
// after pollEvents(), but that alone is not a guarantee: the source event's
|
|
49
|
+
// ingestedAt comes from the *source's* clock, which for a real source is
|
|
50
|
+
// another machine's and can legitimately run ahead of ours. Anchoring on the
|
|
51
|
+
// source event's own timestamp makes the ordering hold by construction rather
|
|
52
|
+
// than by clock agreement; appending the source event first means a tie
|
|
53
|
+
// resolves in its favour (the sort is stable, so equal timestamps keep append
|
|
54
|
+
// order).
|
|
55
|
+
function buildOriginCorrelationEvents(workItemKey, unkeyed, resourceUri) {
|
|
56
|
+
const mintedAt = laterTimestamp(unkeyed.ingestedAt, eventStampNow());
|
|
57
|
+
const sourceRefs = {
|
|
58
|
+
...unkeyed.sourceRefs,
|
|
59
|
+
resourceUri,
|
|
60
|
+
};
|
|
61
|
+
const createdEvent = createEventEnvelope({
|
|
62
|
+
eventId: `${workItemKey}-created`,
|
|
63
|
+
workItemKey,
|
|
64
|
+
streamScope: 'work-item',
|
|
65
|
+
direction: 'internal',
|
|
66
|
+
sourceSystem: 'wake',
|
|
67
|
+
sourceEventType: WORK_ITEM_CREATED_EVENT,
|
|
68
|
+
sourceRefs,
|
|
69
|
+
occurredAt: mintedAt,
|
|
70
|
+
ingestedAt: mintedAt,
|
|
71
|
+
trigger: 'context-only',
|
|
72
|
+
// The envelope's workItemKey already carries the identity.
|
|
73
|
+
payload: {},
|
|
74
|
+
});
|
|
75
|
+
const registeredEvent = createEventEnvelope({
|
|
76
|
+
eventId: `${workItemKey}-origin-correlation`,
|
|
77
|
+
workItemKey,
|
|
78
|
+
streamScope: 'work-item',
|
|
79
|
+
direction: 'internal',
|
|
80
|
+
sourceSystem: 'wake',
|
|
81
|
+
sourceEventType: CORRELATION_REGISTERED_EVENT,
|
|
82
|
+
sourceRefs,
|
|
83
|
+
occurredAt: mintedAt,
|
|
84
|
+
ingestedAt: mintedAt,
|
|
85
|
+
trigger: 'context-only',
|
|
86
|
+
payload: {
|
|
87
|
+
resourceUri,
|
|
88
|
+
role: 'representation',
|
|
89
|
+
relation: 'primary',
|
|
90
|
+
provenance: 'wake-created',
|
|
91
|
+
},
|
|
92
|
+
});
|
|
93
|
+
return [createdEvent, registeredEvent];
|
|
94
|
+
}
|
|
95
|
+
// The central resolver (spec D1): sources name the *resource* an event came
|
|
96
|
+
// from and never the work item, so between pollEvents() and the append every
|
|
97
|
+
// inbound event's sourceRefs.resourceUri is resolved through the reverse
|
|
98
|
+
// index to the canonical workItemKey, minting a work item on a miss. This is
|
|
99
|
+
// the one mechanism — there is no founding-surface special case, and no
|
|
100
|
+
// resolution is ever cached in process memory between ticks (CLAUDE.md: the
|
|
101
|
+
// tick is a pure function of durable state; the index on disk *is* that
|
|
102
|
+
// state).
|
|
103
|
+
//
|
|
104
|
+
// Each resolved event carries whether it is already `persisted`, so the
|
|
105
|
+
// caller can skip re-appending it (appendEventEnvelope would only re-read it
|
|
106
|
+
// and hand back the same envelope). Minting *is* registration, so a freshly
|
|
107
|
+
// minted work item's correlatedResources[] is complete from its first event.
|
|
108
|
+
async function resolveInboundEvent(unkeyed) {
|
|
109
|
+
const { resourceUri } = unkeyed.sourceRefs;
|
|
110
|
+
if (resourceUri === undefined) {
|
|
111
|
+
// A programming error in the adapter, not a runtime condition to absorb.
|
|
112
|
+
// Guessing an identity here would silently fork a duplicate work item
|
|
113
|
+
// for work already in flight — exactly the corruption the reverse index
|
|
114
|
+
// exists to prevent — so fail loudly instead.
|
|
115
|
+
throw new Error(`cannot resolve inbound event ${unkeyed.eventId} from ${unkeyed.sourceSystem}: ` +
|
|
116
|
+
'sourceRefs.resourceUri is required for every unkeyed source event');
|
|
117
|
+
}
|
|
118
|
+
// An event we have already persisted was already resolved, on some earlier
|
|
119
|
+
// tick, and its stamped key is the durable answer. Re-resolving it through
|
|
120
|
+
// the index would be wrong as well as wasteful: if that work item has since
|
|
121
|
+
// retracted this resource, the index no longer holds it, the lookup misses,
|
|
122
|
+
// and a miss means mint — so a re-polled event (sources legitimately
|
|
123
|
+
// re-emit the same eventId, e.g. an unchanged issue) would fork a duplicate
|
|
124
|
+
// work item. Reusing the persisted key keeps resolution idempotent per
|
|
125
|
+
// event id, which is what the append-only log already promises.
|
|
126
|
+
const persisted = await deps.stateStore.readEventEnvelope(unkeyed.eventId);
|
|
127
|
+
if (persisted !== null) {
|
|
128
|
+
// Heal a partially minted work item. The index entry for a resource is
|
|
129
|
+
// written only when its origin wake.correlation.registered event is
|
|
130
|
+
// *folded*, several appends after the founding source event. A crash in
|
|
131
|
+
// that window leaves the source event durable — so this branch suppresses
|
|
132
|
+
// re-minting — while the index has no entry, and a later event on the
|
|
133
|
+
// same resource would miss the index and fork a duplicate work item
|
|
134
|
+
// (crash/restart safety, CLAUDE.md). If the index does not credit this
|
|
135
|
+
// event's work item *and* its origin correlation never landed, re-emit
|
|
136
|
+
// the mint tail (idempotent by id). The guard is the missing origin
|
|
137
|
+
// event, not merely an empty index: a deliberately *retracted* resource
|
|
138
|
+
// also resolves to undefined but keeps its origin-correlation event on
|
|
139
|
+
// record, and must not be silently re-registered.
|
|
140
|
+
const owner = await deps.resourceIndex.resolve(resourceUri);
|
|
141
|
+
if (persisted.workItemKey !== UNRESOLVED_WORK_ITEM_KEY &&
|
|
142
|
+
owner === undefined &&
|
|
143
|
+
(await deps.stateStore.readEventEnvelope(`${persisted.workItemKey}-origin-correlation`)) ===
|
|
144
|
+
null) {
|
|
145
|
+
return [
|
|
146
|
+
{ envelope: persisted, persisted: true },
|
|
147
|
+
...buildOriginCorrelationEvents(persisted.workItemKey, unkeyed, resourceUri).map((envelope) => ({ envelope, persisted: false })),
|
|
148
|
+
];
|
|
149
|
+
}
|
|
150
|
+
return [{ envelope: persisted, persisted: true }];
|
|
151
|
+
}
|
|
152
|
+
const existingWorkItemKey = await deps.resourceIndex.resolve(resourceUri);
|
|
153
|
+
if (existingWorkItemKey !== undefined) {
|
|
154
|
+
return [
|
|
155
|
+
{
|
|
156
|
+
envelope: createEventEnvelope({ ...unkeyed, workItemKey: existingWorkItemKey }),
|
|
157
|
+
persisted: false,
|
|
158
|
+
},
|
|
159
|
+
];
|
|
160
|
+
}
|
|
161
|
+
// resourceUri itself misses the index (e.g. a review-thread comment's
|
|
162
|
+
// resourceUri is unique per thread, never registered on its own), but the
|
|
163
|
+
// adapter may have named a parent resource this one belongs to. Resolve
|
|
164
|
+
// through that instead of minting — and register this exact resourceUri
|
|
165
|
+
// as a secondary correlation so it's on record on the work item, even
|
|
166
|
+
// though (being secondary) it still won't shortcut future lookups via the
|
|
167
|
+
// index itself (ADR 0001 §5: the index is primary-only).
|
|
168
|
+
if (unkeyed.sourceRefs.parentResourceUri !== undefined) {
|
|
169
|
+
const parentWorkItemKey = await deps.resourceIndex.resolve(unkeyed.sourceRefs.parentResourceUri);
|
|
170
|
+
if (parentWorkItemKey !== undefined) {
|
|
171
|
+
const mintedAt = laterTimestamp(unkeyed.ingestedAt, eventStampNow());
|
|
172
|
+
return [
|
|
173
|
+
{
|
|
174
|
+
envelope: createEventEnvelope({ ...unkeyed, workItemKey: parentWorkItemKey }),
|
|
175
|
+
persisted: false,
|
|
176
|
+
},
|
|
177
|
+
{
|
|
178
|
+
envelope: createEventEnvelope({
|
|
179
|
+
eventId: `${parentWorkItemKey}-correlation-${resourceUri.replace(/[^a-z0-9]+/gi, '-')}`,
|
|
180
|
+
workItemKey: parentWorkItemKey,
|
|
181
|
+
streamScope: 'work-item',
|
|
182
|
+
direction: 'internal',
|
|
183
|
+
sourceSystem: 'wake',
|
|
184
|
+
sourceEventType: CORRELATION_REGISTERED_EVENT,
|
|
185
|
+
sourceRefs: unkeyed.sourceRefs,
|
|
186
|
+
occurredAt: mintedAt,
|
|
187
|
+
ingestedAt: mintedAt,
|
|
188
|
+
trigger: 'context-only',
|
|
189
|
+
payload: {
|
|
190
|
+
resourceUri,
|
|
191
|
+
role: 'review',
|
|
192
|
+
relation: 'secondary',
|
|
193
|
+
provenance: 'detected',
|
|
194
|
+
},
|
|
195
|
+
}),
|
|
196
|
+
persisted: false,
|
|
197
|
+
},
|
|
198
|
+
];
|
|
199
|
+
}
|
|
200
|
+
}
|
|
201
|
+
if (!deps.qualifiesForMint(unkeyed, deps.config)) {
|
|
202
|
+
return [
|
|
203
|
+
{
|
|
204
|
+
envelope: createEventEnvelope({ ...unkeyed, workItemKey: UNRESOLVED_WORK_ITEM_KEY }),
|
|
205
|
+
persisted: false,
|
|
206
|
+
},
|
|
207
|
+
];
|
|
208
|
+
}
|
|
209
|
+
const workItemKey = createWorkId();
|
|
210
|
+
const keyed = createEventEnvelope({ ...unkeyed, workItemKey });
|
|
211
|
+
return [
|
|
212
|
+
{ envelope: keyed, persisted: false },
|
|
213
|
+
...buildOriginCorrelationEvents(workItemKey, unkeyed, resourceUri).map((envelope) => ({
|
|
214
|
+
envelope,
|
|
215
|
+
persisted: false,
|
|
216
|
+
})),
|
|
217
|
+
];
|
|
218
|
+
}
|
|
219
|
+
async function ingestInboundEvents(unkeyedEvents) {
|
|
220
|
+
const ingested = [];
|
|
221
|
+
for (const unkeyed of unkeyedEvents) {
|
|
222
|
+
const resolved = await resolveInboundEvent(unkeyed);
|
|
223
|
+
// Fold what was actually persisted, never the in-memory copy:
|
|
224
|
+
// appendEventEnvelope is id-deduplicated and returns the *existing*
|
|
225
|
+
// envelope when one is already on record. Folding our own copy instead
|
|
226
|
+
// would let state/ diverge from events/ — and replay is defined by
|
|
227
|
+
// events/, so the divergence would only surface after a rebuild. An event
|
|
228
|
+
// already flagged `persisted` needs no second append: appendEventEnvelope
|
|
229
|
+
// would only re-read it off disk and hand back the same envelope, and
|
|
230
|
+
// every unchanged issue is re-polled every tick, so that read is the
|
|
231
|
+
// dominant redundant cost this branch avoids.
|
|
232
|
+
const events = [];
|
|
233
|
+
for (const { envelope, persisted } of resolved) {
|
|
234
|
+
events.push(persisted ? envelope : await deps.stateStore.appendEventEnvelope(envelope));
|
|
235
|
+
}
|
|
236
|
+
// Folded before the next event is resolved, because it is the fold of
|
|
237
|
+
// the registration event that writes the index entry the *next* event on
|
|
238
|
+
// the same resource resolves through. Deferring the fold to the end of
|
|
239
|
+
// the batch would let a second event for the same ticket miss and mint a
|
|
240
|
+
// duplicate work item. Every event in a poll batch shares the source's
|
|
241
|
+
// one ingestedAt, so folding per event preserves exactly the order a
|
|
242
|
+
// single batched fold would have produced.
|
|
243
|
+
await deps.projectionUpdater.rebuildFromEvents(events);
|
|
244
|
+
ingested.push(...events);
|
|
245
|
+
}
|
|
246
|
+
return ingested;
|
|
247
|
+
}
|
|
248
|
+
return { ingestInboundEvents };
|
|
249
|
+
}
|
|
@@ -0,0 +1,123 @@
|
|
|
1
|
+
import { randomUUID } from 'node:crypto';
|
|
2
|
+
import { createEventEnvelope } from '../lib/event-log.js';
|
|
3
|
+
const outboxMaxAttempts = 3;
|
|
4
|
+
const outboundIntentEventTypes = new Set([
|
|
5
|
+
'wake.publish.intent.requested',
|
|
6
|
+
'wake.labels.requested',
|
|
7
|
+
]);
|
|
8
|
+
const outboundConfirmationEventTypes = new Set([
|
|
9
|
+
'ticket.reply.published',
|
|
10
|
+
'ticket.labels.updated',
|
|
11
|
+
'wake.publish.confirmed',
|
|
12
|
+
'pr.comment.reply.published',
|
|
13
|
+
'pr.review-comment.reply.published',
|
|
14
|
+
]);
|
|
15
|
+
// The outbox: outbound delivery (comments, labels) attempted independently of
|
|
16
|
+
// run-outcome recording, with a durable, bounded retry trace. Extracted from
|
|
17
|
+
// tick-runner.ts so it can be exercised in isolation; it has the cleanest
|
|
18
|
+
// boundary of the tick's collaborators — no dependency on candidate selection.
|
|
19
|
+
export function createOutbox(deps) {
|
|
20
|
+
async function recordDeliveryFailure(intentEvent, err) {
|
|
21
|
+
const occurredAt = deps.clock.now().toISOString();
|
|
22
|
+
const failureEvent = createEventEnvelope({
|
|
23
|
+
eventId: `${intentEvent.eventId}-delivery-failed-${randomUUID()}`,
|
|
24
|
+
workItemKey: intentEvent.workItemKey,
|
|
25
|
+
streamScope: 'work-item',
|
|
26
|
+
direction: 'internal',
|
|
27
|
+
sourceSystem: 'wake',
|
|
28
|
+
sourceEventType: 'wake.publish.failed',
|
|
29
|
+
sourceRefs: intentEvent.sourceRefs,
|
|
30
|
+
occurredAt,
|
|
31
|
+
ingestedAt: occurredAt,
|
|
32
|
+
trigger: 'context-only',
|
|
33
|
+
payload: {
|
|
34
|
+
intentEventId: intentEvent.eventId,
|
|
35
|
+
intentEventType: intentEvent.sourceEventType,
|
|
36
|
+
error: err instanceof Error ? err.message : String(err),
|
|
37
|
+
},
|
|
38
|
+
});
|
|
39
|
+
await deps.stateStore.appendEventEnvelope(failureEvent);
|
|
40
|
+
await deps.projectionUpdater.rebuildFromEvents([failureEvent]);
|
|
41
|
+
}
|
|
42
|
+
// Outbound delivery (comments, labels) is attempted independently of run-outcome
|
|
43
|
+
// recording: a delivery failure must never rewrite an already-recorded run result
|
|
44
|
+
// (S1), and must always leave a durable, retryable trace instead of being lost
|
|
45
|
+
// (E5). This never throws — failures become a `wake.publish.failed` event.
|
|
46
|
+
async function attemptDelivery(event) {
|
|
47
|
+
if (deps.outboundSink === undefined) {
|
|
48
|
+
return;
|
|
49
|
+
}
|
|
50
|
+
try {
|
|
51
|
+
const deliveryEvents = await deps.outboundSink.deliverIntent({ event });
|
|
52
|
+
for (const deliveryEvent of deliveryEvents) {
|
|
53
|
+
await deps.stateStore.appendEventEnvelope(deliveryEvent);
|
|
54
|
+
}
|
|
55
|
+
await deps.projectionUpdater.rebuildFromEvents(deliveryEvents);
|
|
56
|
+
if (deliveryEvents.length === 0) {
|
|
57
|
+
// No confirmation event was produced (e.g. a no-op label update) but the
|
|
58
|
+
// sink did not throw. Record that delivery was attempted successfully so
|
|
59
|
+
// the outbox scan below does not retry it indefinitely.
|
|
60
|
+
const confirmedAt = deps.clock.now().toISOString();
|
|
61
|
+
const confirmedEvent = createEventEnvelope({
|
|
62
|
+
eventId: `${event.eventId}-confirmed`,
|
|
63
|
+
workItemKey: event.workItemKey,
|
|
64
|
+
streamScope: 'work-item',
|
|
65
|
+
direction: 'internal',
|
|
66
|
+
sourceSystem: 'wake',
|
|
67
|
+
sourceEventType: 'wake.publish.confirmed',
|
|
68
|
+
sourceRefs: event.sourceRefs,
|
|
69
|
+
occurredAt: confirmedAt,
|
|
70
|
+
ingestedAt: confirmedAt,
|
|
71
|
+
trigger: 'context-only',
|
|
72
|
+
payload: { intentEventId: event.eventId },
|
|
73
|
+
});
|
|
74
|
+
await deps.stateStore.appendEventEnvelope(confirmedEvent);
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
catch (err) {
|
|
78
|
+
await recordDeliveryFailure(event, err);
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
async function deliverOutboundEvent(event) {
|
|
82
|
+
await deps.stateStore.appendEventEnvelope(event);
|
|
83
|
+
await deps.projectionUpdater.rebuildFromEvents([event]);
|
|
84
|
+
await attemptDelivery(event);
|
|
85
|
+
}
|
|
86
|
+
// Adopts the outbox pattern: an intent is only considered delivered once a
|
|
87
|
+
// matching confirmation event exists. Anything left unconfirmed by a prior tick
|
|
88
|
+
// (e.g. the process crashed mid-delivery) is retried here, bounded so a
|
|
89
|
+
// permanently failing sink dead-letters instead of retrying forever.
|
|
90
|
+
async function retryUnconfirmedDeliveries() {
|
|
91
|
+
if (deps.outboundSink === undefined) {
|
|
92
|
+
return;
|
|
93
|
+
}
|
|
94
|
+
const events = await deps.stateStore.listEventEnvelopes();
|
|
95
|
+
const confirmedIntentIds = new Set();
|
|
96
|
+
const failureAttempts = new Map();
|
|
97
|
+
for (const event of events) {
|
|
98
|
+
const intentEventId = event.payload.intentEventId;
|
|
99
|
+
if (typeof intentEventId !== 'string') {
|
|
100
|
+
continue;
|
|
101
|
+
}
|
|
102
|
+
if (outboundConfirmationEventTypes.has(event.sourceEventType)) {
|
|
103
|
+
confirmedIntentIds.add(intentEventId);
|
|
104
|
+
}
|
|
105
|
+
if (event.sourceEventType === 'wake.publish.failed') {
|
|
106
|
+
failureAttempts.set(intentEventId, (failureAttempts.get(intentEventId) ?? 0) + 1);
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
for (const intent of events) {
|
|
110
|
+
if (!outboundIntentEventTypes.has(intent.sourceEventType)) {
|
|
111
|
+
continue;
|
|
112
|
+
}
|
|
113
|
+
if (confirmedIntentIds.has(intent.eventId)) {
|
|
114
|
+
continue;
|
|
115
|
+
}
|
|
116
|
+
if ((failureAttempts.get(intent.eventId) ?? 0) >= outboxMaxAttempts) {
|
|
117
|
+
continue;
|
|
118
|
+
}
|
|
119
|
+
await attemptDelivery(intent);
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
return { attemptDelivery, deliverOutboundEvent, retryUnconfirmedDeliveries };
|
|
123
|
+
}
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { awaitingApprovalRunnerSentinel, failedRunnerSentinel } from '../domain/stages.js';
|
|
2
2
|
import { resolveCustomCommand } from '../domain/custom-commands.js';
|
|
3
|
-
import { builtInDefaultWorkflowDefinition, chooseAction as chooseWorkflowAction, selectWorkflowForEvent, } from '../domain/workflows.js';
|
|
3
|
+
import { builtInDefaultWorkflowDefinition, chooseAction as chooseWorkflowAction, isKnownWorkflowStage, selectWorkflowForEvent, workflowForProjection, } from '../domain/workflows.js';
|
|
4
4
|
function isAwaitingApproval(issue) {
|
|
5
5
|
const context = issue.context;
|
|
6
6
|
return context.lastRunSentinel === awaitingApprovalRunnerSentinel;
|
|
@@ -186,6 +186,44 @@ export function createPolicyEngine() {
|
|
|
186
186
|
resolveCustomCommandRequest(issue, config) {
|
|
187
187
|
return resolveCustomCommand(issue, config);
|
|
188
188
|
},
|
|
189
|
+
// The single eligibility predicate: is this issue open, on a known workflow
|
|
190
|
+
// stage, and does it have a next action Wake should run? Returns the
|
|
191
|
+
// resolved action plus the workflow it belongs to, or null when there is
|
|
192
|
+
// nothing to do. This used to be duplicated as two independently-drifting
|
|
193
|
+
// copies (the pending-label marker and the runner's candidate `find`), which
|
|
194
|
+
// is exactly the class of "a rule change didn't propagate everywhere" that
|
|
195
|
+
// caused the #258 incident — keep it as the one source of truth.
|
|
196
|
+
resolveNextEligibleAction(issue, config) {
|
|
197
|
+
if (!this.isEligible(issue, config)) {
|
|
198
|
+
return null;
|
|
199
|
+
}
|
|
200
|
+
const workflow = workflowForProjection(issue, config);
|
|
201
|
+
if (workflow === null || !isKnownWorkflowStage(issue.wake.stage, workflow)) {
|
|
202
|
+
return null;
|
|
203
|
+
}
|
|
204
|
+
if (isAwaitingApproval(issue)) {
|
|
205
|
+
const customCommand = resolveCustomCommand(issue, config);
|
|
206
|
+
if (customCommand !== null) {
|
|
207
|
+
return { action: customCommand.action, workflow };
|
|
208
|
+
}
|
|
209
|
+
const approval = this.resolveApprovalTransition(issue);
|
|
210
|
+
if (approval !== null) {
|
|
211
|
+
return { action: approval.pendingAction, workflow };
|
|
212
|
+
}
|
|
213
|
+
const reviewAction = this.resolvePendingReviewFeedback(issue);
|
|
214
|
+
if (reviewAction !== null) {
|
|
215
|
+
return { action: reviewAction, workflow };
|
|
216
|
+
}
|
|
217
|
+
return null;
|
|
218
|
+
}
|
|
219
|
+
const nextAction = resolveCustomCommand(issue, config)?.action ??
|
|
220
|
+
this.chooseAction(issue, workflow) ??
|
|
221
|
+
this.chooseRetryActionAfterHumanReply(issue, workflow);
|
|
222
|
+
if (nextAction === null || !this.needsWakeAction(issue, workflow)) {
|
|
223
|
+
return null;
|
|
224
|
+
}
|
|
225
|
+
return { action: nextAction, workflow };
|
|
226
|
+
},
|
|
189
227
|
qualifiesForMint(unresolved, config) {
|
|
190
228
|
const resourceUri = unresolved.sourceRefs.resourceUri;
|
|
191
229
|
if (resourceUri === undefined) {
|
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
import { createLabelsEvent } from './event-builders.js';
|
|
2
|
+
import { stageLabelForStage } from '../domain/stages.js';
|
|
3
|
+
import { workflowLabelForWorkflowName, workflowNameForProjection } from '../domain/workflows.js';
|
|
4
|
+
import { createEventEnvelope } from '../lib/event-log.js';
|
|
5
|
+
// Reconciles run records still marked `running` past the runner timeout: a
|
|
6
|
+
// record whose work item has already moved on is superseded, otherwise it is
|
|
7
|
+
// failed and a completion event replayed so the projection stops waiting on it.
|
|
8
|
+
// `deliverOutboundEvent` is injected rather than imported so this module does
|
|
9
|
+
// not depend on the outbox module directly.
|
|
10
|
+
export function createStaleRunReconciler(deps) {
|
|
11
|
+
function isStaleRunningRecord(record, now) {
|
|
12
|
+
if (record.status !== 'running') {
|
|
13
|
+
return false;
|
|
14
|
+
}
|
|
15
|
+
const startedAtMs = Date.parse(record.startedAt);
|
|
16
|
+
if (!Number.isFinite(startedAtMs)) {
|
|
17
|
+
return true;
|
|
18
|
+
}
|
|
19
|
+
return now.getTime() - startedAtMs >= deps.runnerTimeoutMs();
|
|
20
|
+
}
|
|
21
|
+
async function reconcileStaleRunningRecords(now) {
|
|
22
|
+
const finishedAt = now.toISOString();
|
|
23
|
+
const runRecords = await deps.stateStore.listRunRecords();
|
|
24
|
+
const staleRecords = runRecords.filter((record) => isStaleRunningRecord(record, now));
|
|
25
|
+
for (const record of staleRecords) {
|
|
26
|
+
// Run records carry the work item they belong to, so this is a direct
|
|
27
|
+
// O(1) read — no scan, no index, no source ambiguity. The record's
|
|
28
|
+
// repo/issueNumber are representation content and take no part in it.
|
|
29
|
+
const projection = await deps.stateStore.readIssueState(record.workItemKey);
|
|
30
|
+
const newerCompletedRun = runRecords.some((candidate) => candidate.workItemKey === record.workItemKey &&
|
|
31
|
+
candidate.runId !== record.runId &&
|
|
32
|
+
candidate.status !== 'running' &&
|
|
33
|
+
Date.parse(candidate.startedAt) > Date.parse(record.startedAt));
|
|
34
|
+
// Equivalent to the previous `projection?.wake.lastRunId !== record.runId`,
|
|
35
|
+
// spelled out so the non-null projection is available below for its
|
|
36
|
+
// workItemKey.
|
|
37
|
+
if (projection === null || projection.wake.lastRunId !== record.runId || newerCompletedRun) {
|
|
38
|
+
await deps.stateStore.writeRunRecord({
|
|
39
|
+
...record,
|
|
40
|
+
status: 'superseded',
|
|
41
|
+
finishedAt,
|
|
42
|
+
summary: 'Stale running record was superseded by a newer run.',
|
|
43
|
+
metadata: {
|
|
44
|
+
...record.metadata,
|
|
45
|
+
reconciledBy: 'stale-running-record',
|
|
46
|
+
supersededBy: projection?.wake.lastRunId,
|
|
47
|
+
},
|
|
48
|
+
});
|
|
49
|
+
continue;
|
|
50
|
+
}
|
|
51
|
+
await deps.stateStore.writeRunRecord({
|
|
52
|
+
...record,
|
|
53
|
+
status: 'failed',
|
|
54
|
+
finishedAt,
|
|
55
|
+
sentinel: 'FAILED',
|
|
56
|
+
summary: `Run exceeded timeout while marked running and was reconciled by a later tick.`,
|
|
57
|
+
metadata: {
|
|
58
|
+
...record.metadata,
|
|
59
|
+
reconciledBy: 'stale-running-record',
|
|
60
|
+
timeoutMs: deps.runnerTimeoutMs(),
|
|
61
|
+
},
|
|
62
|
+
});
|
|
63
|
+
const runCompletedEvent = createEventEnvelope({
|
|
64
|
+
eventId: `${record.runId}-stale-reconciled`,
|
|
65
|
+
workItemKey: projection.workItemKey,
|
|
66
|
+
streamScope: 'work-item',
|
|
67
|
+
direction: 'internal',
|
|
68
|
+
sourceSystem: 'wake',
|
|
69
|
+
sourceEventType: 'wake.run.completed',
|
|
70
|
+
sourceRefs: {
|
|
71
|
+
repo: record.repo,
|
|
72
|
+
issueNumber: record.issueNumber,
|
|
73
|
+
runId: record.runId,
|
|
74
|
+
},
|
|
75
|
+
occurredAt: finishedAt,
|
|
76
|
+
ingestedAt: finishedAt,
|
|
77
|
+
trigger: 'immediate',
|
|
78
|
+
payload: {
|
|
79
|
+
action: record.action,
|
|
80
|
+
sentinel: 'FAILED',
|
|
81
|
+
runId: record.runId,
|
|
82
|
+
reason: 'runner:stale-timeout',
|
|
83
|
+
...(record.routing === undefined ? {} : { routing: record.routing }),
|
|
84
|
+
},
|
|
85
|
+
});
|
|
86
|
+
await deps.stateStore.appendEventEnvelope(runCompletedEvent);
|
|
87
|
+
await deps.projectionUpdater.rebuildFromEvents([runCompletedEvent]);
|
|
88
|
+
const updatedProjection = await deps.stateStore.readIssueState(projection.workItemKey);
|
|
89
|
+
if (updatedProjection !== null) {
|
|
90
|
+
await deps.deliverOutboundEvent(createLabelsEvent({
|
|
91
|
+
projection: updatedProjection,
|
|
92
|
+
runId: record.runId,
|
|
93
|
+
statusLabel: 'wake:status.failed',
|
|
94
|
+
stageLabel: stageLabelForStage(updatedProjection.wake.stage),
|
|
95
|
+
workflowLabel: workflowLabelForWorkflowName(workflowNameForProjection(updatedProjection, deps.config)),
|
|
96
|
+
occurredAt: finishedAt,
|
|
97
|
+
}));
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
return { reconcileStaleRunningRecords };
|
|
102
|
+
}
|