@atolis-hq/wake 0.2.94 → 0.2.96
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.
|
@@ -268,6 +268,10 @@ async function applyEvent(current, event, ctx, config) {
|
|
|
268
268
|
config !== undefined &&
|
|
269
269
|
isCustomCommandAction(payload.action, config);
|
|
270
270
|
const shouldClearSession = isForwardProgression || isFailed;
|
|
271
|
+
const hasPendingChangesRequested = (typeof current.context.changesRequestedCount === 'number' &&
|
|
272
|
+
current.context.changesRequestedCount > 0) ||
|
|
273
|
+
current.context.changesRequestedFeedback !== undefined;
|
|
274
|
+
const resolvesChangesRequested = sentinel === doneRunnerSentinel && (!hasPendingChangesRequested || !approvalGated);
|
|
271
275
|
const currentFailureCount = typeof current.context.failureCount === 'number' &&
|
|
272
276
|
Number.isInteger(current.context.failureCount)
|
|
273
277
|
? current.context.failureCount
|
|
@@ -327,9 +331,11 @@ async function applyEvent(current, event, ctx, config) {
|
|
|
327
331
|
approvalGated,
|
|
328
332
|
}),
|
|
329
333
|
}),
|
|
330
|
-
//
|
|
331
|
-
//
|
|
332
|
-
|
|
334
|
+
// An approval-gated DONE after changes-requested only resubmits work for
|
|
335
|
+
// review; the next reviewer verdict decides whether the request was
|
|
336
|
+
// actually resolved. Keep the retry counter through that gate so
|
|
337
|
+
// repeated rejections can hit the configured escalation cap.
|
|
338
|
+
...(resolvesChangesRequested
|
|
333
339
|
? { changesRequestedCount: 0, changesRequestedFeedback: undefined }
|
|
334
340
|
: {}),
|
|
335
341
|
};
|
|
@@ -1,7 +1,10 @@
|
|
|
1
1
|
import { createLabelsEvent } from './event-builders.js';
|
|
2
2
|
import { RUN_COMPLETED_EVENT } from '../domain/event-types.js';
|
|
3
3
|
import { labelsForWorkItem } from '../domain/work-item-labels.js';
|
|
4
|
+
import { readJsonFile, writeJsonFile } from '../lib/json-file.js';
|
|
5
|
+
import { isMissingPathError } from '../lib/state-health.js';
|
|
4
6
|
import { createEventEnvelope } from '../lib/event-log.js';
|
|
7
|
+
import { join } from 'node:path';
|
|
5
8
|
// Reconciles run records still marked `running` past the runner timeout: a
|
|
6
9
|
// record whose work item has already moved on is superseded, otherwise it is
|
|
7
10
|
// failed and a completion event replayed so the projection stops waiting on it.
|
|
@@ -56,6 +59,97 @@ export function createStaleRunReconciler(deps) {
|
|
|
56
59
|
retrySafety,
|
|
57
60
|
};
|
|
58
61
|
}
|
|
62
|
+
function watcherStateFile(key) {
|
|
63
|
+
return join(deps.stateStore.paths.dataRoot, 'watchers', `${key}.json`);
|
|
64
|
+
}
|
|
65
|
+
function isWatcherTrigger(value) {
|
|
66
|
+
if (value === null || typeof value !== 'object')
|
|
67
|
+
return false;
|
|
68
|
+
const record = value;
|
|
69
|
+
return ((record.kind === 'event' && typeof record.eventId === 'string') ||
|
|
70
|
+
(record.kind === 'schedule' && typeof record.slot === 'string'));
|
|
71
|
+
}
|
|
72
|
+
function watcherMetadata(record) {
|
|
73
|
+
const metadata = record.metadata;
|
|
74
|
+
if (metadata?.watcher !== true)
|
|
75
|
+
return {};
|
|
76
|
+
return {
|
|
77
|
+
...(typeof metadata.watcherStateKey === 'string'
|
|
78
|
+
? { stateKey: metadata.watcherStateKey }
|
|
79
|
+
: {}),
|
|
80
|
+
...(isWatcherTrigger(metadata.watcherTrigger) ? { trigger: metadata.watcherTrigger } : {}),
|
|
81
|
+
};
|
|
82
|
+
}
|
|
83
|
+
async function readWatcherState(key) {
|
|
84
|
+
try {
|
|
85
|
+
const raw = await readJsonFile(watcherStateFile(key));
|
|
86
|
+
if (raw === null || typeof raw !== 'object')
|
|
87
|
+
return null;
|
|
88
|
+
const record = raw;
|
|
89
|
+
return record.schemaVersion === 1 &&
|
|
90
|
+
record.key === key &&
|
|
91
|
+
typeof record.updatedAt === 'string'
|
|
92
|
+
? {
|
|
93
|
+
schemaVersion: 1,
|
|
94
|
+
key,
|
|
95
|
+
...(typeof record.lastDispatchedEventId === 'string'
|
|
96
|
+
? { lastDispatchedEventId: record.lastDispatchedEventId }
|
|
97
|
+
: {}),
|
|
98
|
+
...(typeof record.lastDispatchedSlot === 'string'
|
|
99
|
+
? { lastDispatchedSlot: record.lastDispatchedSlot }
|
|
100
|
+
: {}),
|
|
101
|
+
...(typeof record.failureCount === 'number' &&
|
|
102
|
+
Number.isInteger(record.failureCount) &&
|
|
103
|
+
record.failureCount >= 0
|
|
104
|
+
? { failureCount: record.failureCount }
|
|
105
|
+
: {}),
|
|
106
|
+
updatedAt: record.updatedAt,
|
|
107
|
+
}
|
|
108
|
+
: null;
|
|
109
|
+
}
|
|
110
|
+
catch (error) {
|
|
111
|
+
if (isMissingPathError(error))
|
|
112
|
+
return null;
|
|
113
|
+
throw error;
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
async function writeWatcherState(key, patch, updatedAt) {
|
|
117
|
+
const current = await readWatcherState(key);
|
|
118
|
+
await writeJsonFile(watcherStateFile(key), {
|
|
119
|
+
schemaVersion: 1,
|
|
120
|
+
key,
|
|
121
|
+
...(current?.lastDispatchedEventId === undefined
|
|
122
|
+
? {}
|
|
123
|
+
: { lastDispatchedEventId: current.lastDispatchedEventId }),
|
|
124
|
+
...(current?.lastDispatchedSlot === undefined
|
|
125
|
+
? {}
|
|
126
|
+
: { lastDispatchedSlot: current.lastDispatchedSlot }),
|
|
127
|
+
...(current?.failureCount === undefined ? {} : { failureCount: current.failureCount }),
|
|
128
|
+
...patch,
|
|
129
|
+
updatedAt,
|
|
130
|
+
});
|
|
131
|
+
}
|
|
132
|
+
function watcherCursorPatch(trigger) {
|
|
133
|
+
return {
|
|
134
|
+
...(trigger.kind === 'event'
|
|
135
|
+
? { lastDispatchedEventId: trigger.eventId }
|
|
136
|
+
: { lastDispatchedSlot: trigger.slot }),
|
|
137
|
+
failureCount: 0,
|
|
138
|
+
};
|
|
139
|
+
}
|
|
140
|
+
async function updateWatcherStateAfterCompletion(input) {
|
|
141
|
+
if (input.stateKey === undefined || input.trigger === undefined)
|
|
142
|
+
return;
|
|
143
|
+
if (input.retrySafety === 'SAFE_TO_RETRY' || input.retrySafety === 'SAFE_TO_RESUME') {
|
|
144
|
+
const current = await readWatcherState(input.stateKey);
|
|
145
|
+
const failureCount = (current?.failureCount ?? 0) + 1;
|
|
146
|
+
if (failureCount < deps.config.retry.maxFailureRetries) {
|
|
147
|
+
await writeWatcherState(input.stateKey, { failureCount }, input.updatedAt);
|
|
148
|
+
return;
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
await writeWatcherState(input.stateKey, watcherCursorPatch(input.trigger), input.updatedAt);
|
|
152
|
+
}
|
|
59
153
|
async function staleReason(record, now) {
|
|
60
154
|
if (record.status !== 'running') {
|
|
61
155
|
return null;
|
|
@@ -166,11 +260,15 @@ export function createStaleRunReconciler(deps) {
|
|
|
166
260
|
candidate.runId !== record.runId &&
|
|
167
261
|
candidate.status !== 'running' &&
|
|
168
262
|
Date.parse(candidate.startedAt) > Date.parse(record.startedAt));
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
263
|
+
const fullRecord = (await deps.stateStore.readRunRecord(record.runId)) ?? record;
|
|
264
|
+
const watcher = watcherMetadata(fullRecord);
|
|
265
|
+
const watcherRun = watcher.trigger !== undefined;
|
|
266
|
+
// Parent-stage runs must still be the projection's latest run. Watcher
|
|
267
|
+
// runs are isolated child runs, so their claim events intentionally do
|
|
268
|
+
// not replace the parent's wake.lastRunId.
|
|
269
|
+
if (projection === null ||
|
|
270
|
+
(!watcherRun && projection.wake.lastRunId !== record.runId) ||
|
|
271
|
+
newerCompletedRun) {
|
|
174
272
|
await deps.stateStore.writeRunRecord({
|
|
175
273
|
...fullRecord,
|
|
176
274
|
lifecycle: 'TERMINAL',
|
|
@@ -188,7 +286,6 @@ export function createStaleRunReconciler(deps) {
|
|
|
188
286
|
}
|
|
189
287
|
const staleExecutionOutcome = reason === 'timeout' ? 'TIMED_OUT' : recoveryOutcomeForLifecycle(record.lifecycle);
|
|
190
288
|
const failureContext = classifyReconciledFailure(record);
|
|
191
|
-
const fullRecord = (await deps.stateStore.readRunRecord(record.runId)) ?? record;
|
|
192
289
|
await deps.stateStore.writeRunRecord({
|
|
193
290
|
...fullRecord,
|
|
194
291
|
lifecycle: 'TERMINAL',
|
|
@@ -234,12 +331,19 @@ export function createStaleRunReconciler(deps) {
|
|
|
234
331
|
? `runner:recover-${record.lifecycle.toLowerCase()}`
|
|
235
332
|
: 'runner:orphaned-process',
|
|
236
333
|
...(record.routing === undefined ? {} : { routing: record.routing }),
|
|
334
|
+
...(watcherRun ? { watcherRun: true, watcherTrigger: watcher.trigger } : {}),
|
|
237
335
|
},
|
|
238
336
|
});
|
|
239
337
|
await deps.stateStore.appendEventEnvelope(runCompletedEvent);
|
|
240
338
|
await deps.projectionUpdater.rebuildFromEvents([runCompletedEvent]);
|
|
339
|
+
await updateWatcherStateAfterCompletion({
|
|
340
|
+
stateKey: watcher.stateKey,
|
|
341
|
+
trigger: watcher.trigger,
|
|
342
|
+
retrySafety: failureContext.retrySafety,
|
|
343
|
+
updatedAt: finishedAt,
|
|
344
|
+
});
|
|
241
345
|
const updatedProjection = await deps.stateStore.readIssueState(projection.workItemKey);
|
|
242
|
-
if (updatedProjection !== null) {
|
|
346
|
+
if (!watcherRun && updatedProjection !== null) {
|
|
243
347
|
await deliverRecoveredLabels(updatedProjection, record.runId, finishedAt);
|
|
244
348
|
}
|
|
245
349
|
}
|
|
@@ -978,6 +978,11 @@ export function createTickRunner(deps) {
|
|
|
978
978
|
...(typeof record.lastDispatchedSlot === 'string'
|
|
979
979
|
? { lastDispatchedSlot: record.lastDispatchedSlot }
|
|
980
980
|
: {}),
|
|
981
|
+
...(typeof record.failureCount === 'number' &&
|
|
982
|
+
Number.isInteger(record.failureCount) &&
|
|
983
|
+
record.failureCount >= 0
|
|
984
|
+
? { failureCount: record.failureCount }
|
|
985
|
+
: {}),
|
|
981
986
|
updatedAt: record.updatedAt,
|
|
982
987
|
}
|
|
983
988
|
: null;
|
|
@@ -999,10 +1004,36 @@ export function createTickRunner(deps) {
|
|
|
999
1004
|
...(current?.lastDispatchedSlot === undefined
|
|
1000
1005
|
? {}
|
|
1001
1006
|
: { lastDispatchedSlot: current.lastDispatchedSlot }),
|
|
1007
|
+
...(current?.failureCount === undefined ? {} : { failureCount: current.failureCount }),
|
|
1002
1008
|
...patch,
|
|
1003
1009
|
updatedAt: deps.clock.now().toISOString(),
|
|
1004
1010
|
});
|
|
1005
1011
|
}
|
|
1012
|
+
function watcherCursorPatch(trigger) {
|
|
1013
|
+
return {
|
|
1014
|
+
...(trigger.kind === 'event'
|
|
1015
|
+
? { lastDispatchedEventId: trigger.eventId }
|
|
1016
|
+
: { lastDispatchedSlot: trigger.slot }),
|
|
1017
|
+
failureCount: 0,
|
|
1018
|
+
};
|
|
1019
|
+
}
|
|
1020
|
+
function isRetryEligibleWatcherFailure(input) {
|
|
1021
|
+
return (input.sentinel === 'FAILED' &&
|
|
1022
|
+
(input.retrySafety === 'SAFE_TO_RETRY' || input.retrySafety === 'SAFE_TO_RESUME'));
|
|
1023
|
+
}
|
|
1024
|
+
async function updateWatcherStateAfterCompletion(input) {
|
|
1025
|
+
if (input.key === undefined || input.trigger === undefined)
|
|
1026
|
+
return;
|
|
1027
|
+
if (isRetryEligibleWatcherFailure(input)) {
|
|
1028
|
+
const current = await readWatcherState(input.key);
|
|
1029
|
+
const failureCount = (current?.failureCount ?? 0) + 1;
|
|
1030
|
+
if (failureCount < deps.config.retry.maxFailureRetries) {
|
|
1031
|
+
await writeWatcherState(input.key, { failureCount });
|
|
1032
|
+
return;
|
|
1033
|
+
}
|
|
1034
|
+
}
|
|
1035
|
+
await writeWatcherState(input.key, watcherCursorPatch(input.trigger));
|
|
1036
|
+
}
|
|
1006
1037
|
async function nextWatcherDispatch(projections, now) {
|
|
1007
1038
|
for (const projection of projections) {
|
|
1008
1039
|
// A closed issue can still carry a stale status label (e.g. squash-merged
|
|
@@ -1044,15 +1075,16 @@ export function createTickRunner(deps) {
|
|
|
1044
1075
|
const cursorIndex = state?.lastDispatchedEventId === undefined
|
|
1045
1076
|
? -1
|
|
1046
1077
|
: matchingEvents.findIndex((event) => event.eventId === state.lastDispatchedEventId);
|
|
1047
|
-
const
|
|
1048
|
-
|
|
1078
|
+
const undispatched = matchingEvents.slice(cursorIndex + 1);
|
|
1079
|
+
const next = (state?.failureCount ?? 0) > 0 ? undispatched.at(0) : undispatched.at(-1);
|
|
1080
|
+
if (next !== undefined) {
|
|
1049
1081
|
return {
|
|
1050
1082
|
projection,
|
|
1051
1083
|
parentWorkflowName,
|
|
1052
1084
|
parentStage: projection.wake.stage,
|
|
1053
1085
|
watcherIndex,
|
|
1054
1086
|
targetWorkflowName: watcher.workflow,
|
|
1055
|
-
trigger: { kind: 'event', eventId:
|
|
1087
|
+
trigger: { kind: 'event', eventId: next.eventId },
|
|
1056
1088
|
};
|
|
1057
1089
|
}
|
|
1058
1090
|
}
|
|
@@ -1492,6 +1524,7 @@ export function createTickRunner(deps) {
|
|
|
1492
1524
|
? {
|
|
1493
1525
|
watcher: true,
|
|
1494
1526
|
watcherWorkflow: workflowName,
|
|
1527
|
+
watcherStateKey: watcherStateKeyForRun,
|
|
1495
1528
|
watcherTrigger: watcherTriggerForRun,
|
|
1496
1529
|
}
|
|
1497
1530
|
: {}),
|
|
@@ -1587,11 +1620,6 @@ export function createTickRunner(deps) {
|
|
|
1587
1620
|
}
|
|
1588
1621
|
await transitionRunLifecycle('CLAIMED');
|
|
1589
1622
|
await projectionUpdater.rebuildFromEvents([claimedEvent]);
|
|
1590
|
-
if (watcherStateKeyForRun !== undefined && watcherTriggerForRun !== undefined) {
|
|
1591
|
-
await writeWatcherState(watcherStateKeyForRun, watcherTriggerForRun.kind === 'event'
|
|
1592
|
-
? { lastDispatchedEventId: watcherTriggerForRun.eventId }
|
|
1593
|
-
: { lastDispatchedSlot: watcherTriggerForRun.slot });
|
|
1594
|
-
}
|
|
1595
1623
|
// Watcher runs execute a *different* workflow's stage (e.g. plan-review)
|
|
1596
1624
|
// against the same parent projection, not a stage transition of the
|
|
1597
1625
|
// parent's own workflow — writing labels here would stamp the child
|
|
@@ -2055,6 +2083,12 @@ export function createTickRunner(deps) {
|
|
|
2055
2083
|
});
|
|
2056
2084
|
await deps.stateStore.appendEventEnvelope(runCompletedEvent);
|
|
2057
2085
|
await projectionUpdater.rebuildFromEvents([runCompletedEvent]);
|
|
2086
|
+
await updateWatcherStateAfterCompletion({
|
|
2087
|
+
key: watcherStateKeyForRun,
|
|
2088
|
+
trigger: watcherTriggerForRun,
|
|
2089
|
+
sentinel,
|
|
2090
|
+
retrySafety: failureContext?.retrySafety,
|
|
2091
|
+
});
|
|
2058
2092
|
if (!watcherRun) {
|
|
2059
2093
|
await deliverOutboundEvent(createLabelsEvent({
|
|
2060
2094
|
projection: candidate,
|
|
@@ -2254,6 +2288,12 @@ export function createTickRunner(deps) {
|
|
|
2254
2288
|
});
|
|
2255
2289
|
await deps.stateStore.appendEventEnvelope(runCompletedEvent);
|
|
2256
2290
|
await projectionUpdater.rebuildFromEvents([runCompletedEvent]);
|
|
2291
|
+
await updateWatcherStateAfterCompletion({
|
|
2292
|
+
key: watcherStateKeyForRun,
|
|
2293
|
+
trigger: watcherTriggerForRun,
|
|
2294
|
+
sentinel,
|
|
2295
|
+
retrySafety: failureContext.retrySafety,
|
|
2296
|
+
});
|
|
2257
2297
|
// See the matching guard on the claim path above: a watcher run's
|
|
2258
2298
|
// failure is not the parent's own action failing, so it must not
|
|
2259
2299
|
// stamp the child workflow's stage/workflow onto the parent's labels.
|
|
@@ -701,9 +701,9 @@ const wakeConfigBaseSchema = z.object({
|
|
|
701
701
|
retry: z
|
|
702
702
|
.object({
|
|
703
703
|
maxFailureRetries: z.number().int().positive().default(5),
|
|
704
|
-
maxChangesRequestedRetries: z.number().int().positive().default(
|
|
704
|
+
maxChangesRequestedRetries: z.number().int().positive().default(3),
|
|
705
705
|
})
|
|
706
|
-
.default({ maxFailureRetries: 5, maxChangesRequestedRetries:
|
|
706
|
+
.default({ maxFailureRetries: 5, maxChangesRequestedRetries: 3 }),
|
|
707
707
|
runners: z.record(z.string(), runnerEntrySchema).default({
|
|
708
708
|
fake: { kind: 'fake', cli: 'Fake' },
|
|
709
709
|
'claude-haiku': {
|
package/dist/src/version.js
CHANGED