@atolis-hq/wake 0.2.95 → 0.2.97

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
  }
@@ -482,6 +482,38 @@ export function createTickRunner(deps) {
482
482
  }
483
483
  return { blocked: false };
484
484
  }
485
+ async function applyApprovedPrMergePolicy(input) {
486
+ if (input.approvalResolution.targetResourceUri === undefined || input.mergePolicy === null) {
487
+ return null;
488
+ }
489
+ const risk = await evaluatePrMergeRisk({
490
+ projection: input.projection,
491
+ targetResourceUri: input.approvalResolution.targetResourceUri,
492
+ policy: input.mergePolicy,
493
+ });
494
+ if (!risk.passed) {
495
+ return await publishPrMergePolicyBlock({
496
+ projection: input.projection,
497
+ approvalResolution: input.approvalResolution,
498
+ reason: risk.reason,
499
+ workflowName: input.workflowName,
500
+ });
501
+ }
502
+ const mergeActionsResult = await performApprovedPrMergeActions({
503
+ projection: input.projection,
504
+ approvalResolution: input.approvalResolution,
505
+ mergePolicy: input.mergePolicy,
506
+ });
507
+ if (mergeActionsResult.blocked) {
508
+ return await publishPrMergePolicyBlock({
509
+ projection: input.projection,
510
+ approvalResolution: input.approvalResolution,
511
+ reason: mergeActionsResult.reason,
512
+ workflowName: input.workflowName,
513
+ });
514
+ }
515
+ return null;
516
+ }
485
517
  // The one deterministic approval transition: /approved, wake:auto, and a
486
518
  // watcher child's onSuccess.approve all resolve a pending approval through
487
519
  // this same event shape, so replay folds them identically.
@@ -978,6 +1010,11 @@ export function createTickRunner(deps) {
978
1010
  ...(typeof record.lastDispatchedSlot === 'string'
979
1011
  ? { lastDispatchedSlot: record.lastDispatchedSlot }
980
1012
  : {}),
1013
+ ...(typeof record.failureCount === 'number' &&
1014
+ Number.isInteger(record.failureCount) &&
1015
+ record.failureCount >= 0
1016
+ ? { failureCount: record.failureCount }
1017
+ : {}),
981
1018
  updatedAt: record.updatedAt,
982
1019
  }
983
1020
  : null;
@@ -999,10 +1036,36 @@ export function createTickRunner(deps) {
999
1036
  ...(current?.lastDispatchedSlot === undefined
1000
1037
  ? {}
1001
1038
  : { lastDispatchedSlot: current.lastDispatchedSlot }),
1039
+ ...(current?.failureCount === undefined ? {} : { failureCount: current.failureCount }),
1002
1040
  ...patch,
1003
1041
  updatedAt: deps.clock.now().toISOString(),
1004
1042
  });
1005
1043
  }
1044
+ function watcherCursorPatch(trigger) {
1045
+ return {
1046
+ ...(trigger.kind === 'event'
1047
+ ? { lastDispatchedEventId: trigger.eventId }
1048
+ : { lastDispatchedSlot: trigger.slot }),
1049
+ failureCount: 0,
1050
+ };
1051
+ }
1052
+ function isRetryEligibleWatcherFailure(input) {
1053
+ return (input.sentinel === 'FAILED' &&
1054
+ (input.retrySafety === 'SAFE_TO_RETRY' || input.retrySafety === 'SAFE_TO_RESUME'));
1055
+ }
1056
+ async function updateWatcherStateAfterCompletion(input) {
1057
+ if (input.key === undefined || input.trigger === undefined)
1058
+ return;
1059
+ if (isRetryEligibleWatcherFailure(input)) {
1060
+ const current = await readWatcherState(input.key);
1061
+ const failureCount = (current?.failureCount ?? 0) + 1;
1062
+ if (failureCount < deps.config.retry.maxFailureRetries) {
1063
+ await writeWatcherState(input.key, { failureCount });
1064
+ return;
1065
+ }
1066
+ }
1067
+ await writeWatcherState(input.key, watcherCursorPatch(input.trigger));
1068
+ }
1006
1069
  async function nextWatcherDispatch(projections, now) {
1007
1070
  for (const projection of projections) {
1008
1071
  // A closed issue can still carry a stale status label (e.g. squash-merged
@@ -1044,15 +1107,16 @@ export function createTickRunner(deps) {
1044
1107
  const cursorIndex = state?.lastDispatchedEventId === undefined
1045
1108
  ? -1
1046
1109
  : matchingEvents.findIndex((event) => event.eventId === state.lastDispatchedEventId);
1047
- const newest = matchingEvents.slice(cursorIndex + 1).at(-1);
1048
- if (newest !== undefined) {
1110
+ const undispatched = matchingEvents.slice(cursorIndex + 1);
1111
+ const next = (state?.failureCount ?? 0) > 0 ? undispatched.at(0) : undispatched.at(-1);
1112
+ if (next !== undefined) {
1049
1113
  return {
1050
1114
  projection,
1051
1115
  parentWorkflowName,
1052
1116
  parentStage: projection.wake.stage,
1053
1117
  watcherIndex,
1054
1118
  targetWorkflowName: watcher.workflow,
1055
- trigger: { kind: 'event', eventId: newest.eventId },
1119
+ trigger: { kind: 'event', eventId: next.eventId },
1056
1120
  };
1057
1121
  }
1058
1122
  }
@@ -1301,34 +1365,14 @@ export function createTickRunner(deps) {
1301
1365
  }
1302
1366
  }
1303
1367
  else if (approvalResolution.approved) {
1304
- const mergePolicy = approvedMergePolicyForStage(workflow.stages[candidate.wake.stage]);
1305
- if (approvalResolution.targetResourceUri !== undefined && mergePolicy !== null) {
1306
- const risk = await evaluatePrMergeRisk({
1307
- projection: candidate,
1308
- targetResourceUri: approvalResolution.targetResourceUri,
1309
- policy: mergePolicy,
1310
- });
1311
- if (!risk.passed) {
1312
- return await publishPrMergePolicyBlock({
1313
- projection: candidate,
1314
- approvalResolution,
1315
- reason: risk.reason,
1316
- workflowName,
1317
- });
1318
- }
1319
- const mergeActionsResult = await performApprovedPrMergeActions({
1320
- projection: candidate,
1321
- approvalResolution,
1322
- mergePolicy,
1323
- });
1324
- if (mergeActionsResult.blocked) {
1325
- return await publishPrMergePolicyBlock({
1326
- projection: candidate,
1327
- approvalResolution,
1328
- reason: mergeActionsResult.reason,
1329
- workflowName,
1330
- });
1331
- }
1368
+ const mergePolicyBlock = await applyApprovedPrMergePolicy({
1369
+ projection: candidate,
1370
+ approvalResolution,
1371
+ workflowName,
1372
+ mergePolicy: approvedMergePolicyForStage(workflow.stages[candidate.wake.stage]),
1373
+ });
1374
+ if (mergePolicyBlock !== null) {
1375
+ return mergePolicyBlock;
1332
1376
  }
1333
1377
  const approvalId = `approval-${candidate.issue.number}-${deps.clock.now().getTime()}`;
1334
1378
  const approvedAt = deps.clock.now().toISOString();
@@ -1492,6 +1536,7 @@ export function createTickRunner(deps) {
1492
1536
  ? {
1493
1537
  watcher: true,
1494
1538
  watcherWorkflow: workflowName,
1539
+ watcherStateKey: watcherStateKeyForRun,
1495
1540
  watcherTrigger: watcherTriggerForRun,
1496
1541
  }
1497
1542
  : {}),
@@ -1587,11 +1632,6 @@ export function createTickRunner(deps) {
1587
1632
  }
1588
1633
  await transitionRunLifecycle('CLAIMED');
1589
1634
  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
1635
  // Watcher runs execute a *different* workflow's stage (e.g. plan-review)
1596
1636
  // against the same parent projection, not a stage transition of the
1597
1637
  // parent's own workflow — writing labels here would stamp the child
@@ -2055,6 +2095,12 @@ export function createTickRunner(deps) {
2055
2095
  });
2056
2096
  await deps.stateStore.appendEventEnvelope(runCompletedEvent);
2057
2097
  await projectionUpdater.rebuildFromEvents([runCompletedEvent]);
2098
+ await updateWatcherStateAfterCompletion({
2099
+ key: watcherStateKeyForRun,
2100
+ trigger: watcherTriggerForRun,
2101
+ sentinel,
2102
+ retrySafety: failureContext?.retrySafety,
2103
+ });
2058
2104
  if (!watcherRun) {
2059
2105
  await deliverOutboundEvent(createLabelsEvent({
2060
2106
  projection: candidate,
@@ -2099,6 +2145,71 @@ export function createTickRunner(deps) {
2099
2145
  idempotencyKey: `${runId}:pr-review-verdict-comment`,
2100
2146
  },
2101
2147
  });
2148
+ const approvalId = `${runId}-parent-approval`;
2149
+ const parentWorkflow = watcherDispatch === null
2150
+ ? undefined
2151
+ : deps.config.workflows[watcherDispatch.parentWorkflowName];
2152
+ if (sentinel === 'DONE' &&
2153
+ watcherDispatch !== null &&
2154
+ isAwaitingApproval(candidate) &&
2155
+ typeof pendingApprovalAction === 'string' &&
2156
+ parentWorkflow !== undefined &&
2157
+ (await deps.stateStore.readEventEnvelope(`${approvalId}-completed`)) === null) {
2158
+ const approvedAt = eventStampNow();
2159
+ const approvalResolution = {
2160
+ approved: true,
2161
+ pendingAction: pendingApprovalAction,
2162
+ targetResourceUri: prReviewTargetResourceUri,
2163
+ triggeringCommentId: approvalId,
2164
+ triggeringCommentBody: parsedRunnerResult.body,
2165
+ };
2166
+ const mergePolicyBlock = await applyApprovedPrMergePolicy({
2167
+ projection: candidate,
2168
+ approvalResolution,
2169
+ workflowName: watcherDispatch.parentWorkflowName,
2170
+ mergePolicy: approvedMergePolicyForStage(parentWorkflow.stages[watcherDispatch.parentStage]),
2171
+ });
2172
+ if (mergePolicyBlock !== null) {
2173
+ return mergePolicyBlock;
2174
+ }
2175
+ const applied = await applyApprovalTransition({
2176
+ projection: candidate,
2177
+ pendingAction: pendingApprovalAction,
2178
+ approvalId,
2179
+ approvedAt,
2180
+ reason: 'watcher:approved',
2181
+ workflow: parentWorkflow,
2182
+ workflowName: watcherDispatch.parentWorkflowName,
2183
+ });
2184
+ if (applied !== null) {
2185
+ await appendAuditEvent({
2186
+ eventId: `${approvalId}-audit-watcher-approval`,
2187
+ decisionType: 'approval.watcher-resolved',
2188
+ workItemKey: candidate.workItemKey,
2189
+ runId,
2190
+ workflowRevision: await computeWorkflowRevision({
2191
+ config: deps.config,
2192
+ workflowName: watcherDispatch.parentWorkflowName,
2193
+ workflow: parentWorkflow,
2194
+ action: pendingApprovalAction,
2195
+ }),
2196
+ inputsConsidered: {
2197
+ watcherWorkflow: watcherDispatch.targetWorkflowName,
2198
+ pendingAction: pendingApprovalAction,
2199
+ childRunId: runId,
2200
+ childSentinel: sentinel,
2201
+ verifiedTargetResourceUri: prReviewTargetResourceUri,
2202
+ },
2203
+ outcome: { approved: true, nextStage: applied.nextStage },
2204
+ timestamp: approvedAt,
2205
+ sourceRefs: {
2206
+ repo: candidate.issue.repo,
2207
+ issueNumber: candidate.issue.number,
2208
+ resourceUri: prReviewTargetResourceUri,
2209
+ },
2210
+ });
2211
+ }
2212
+ }
2102
2213
  }
2103
2214
  else if (watcherDispatch !== null &&
2104
2215
  watcherSuccessPolicy?.approve === true &&
@@ -2254,6 +2365,12 @@ export function createTickRunner(deps) {
2254
2365
  });
2255
2366
  await deps.stateStore.appendEventEnvelope(runCompletedEvent);
2256
2367
  await projectionUpdater.rebuildFromEvents([runCompletedEvent]);
2368
+ await updateWatcherStateAfterCompletion({
2369
+ key: watcherStateKeyForRun,
2370
+ trigger: watcherTriggerForRun,
2371
+ sentinel,
2372
+ retrySafety: failureContext.retrySafety,
2373
+ });
2257
2374
  // See the matching guard on the claim path above: a watcher run's
2258
2375
  // failure is not the parent's own action failing, so it must not
2259
2376
  // 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 = "g17e7db1";
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.97",
4
4
  "description": "Local autonomous agent control plane for software development",
5
5
  "license": "Apache-2.0",
6
6
  "repository": {