@atolis-hq/wake 0.2.95 → 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.
@@ -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
- // Equivalent to the previous `projection?.wake.lastRunId !== record.runId`,
170
- // spelled out so the non-null projection is available below for its
171
- // workItemKey.
172
- if (projection === null || projection.wake.lastRunId !== record.runId || newerCompletedRun) {
173
- const fullRecord = (await deps.stateStore.readRunRecord(record.runId)) ?? record;
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 newest = matchingEvents.slice(cursorIndex + 1).at(-1);
1048
- if (newest !== undefined) {
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: newest.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.
@@ -124,4 +124,4 @@ export function resolveWakeVersion(options = {}) {
124
124
  }
125
125
  return '0.1.0-dev';
126
126
  }
127
- export const wakeVersion = "g803f372";
127
+ export const wakeVersion = "g49465cd";
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@atolis-hq/wake",
3
- "version": "0.2.95",
3
+ "version": "0.2.96",
4
4
  "description": "Local autonomous agent control plane for software development",
5
5
  "license": "Apache-2.0",
6
6
  "repository": {