@atolis-hq/wake 0.2.27 → 0.2.29

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.
@@ -234,6 +234,12 @@ async function applyEvent(current, event, ctx, config) {
234
234
  ...(payload.sentinel === 'AWAITING_APPROVAL' && payload.action !== undefined
235
235
  ? { pendingApprovalAction: payload.action }
236
236
  : {}),
237
+ ...(payload.executionOutcome !== undefined
238
+ ? { lastExecutionOutcome: payload.executionOutcome }
239
+ : {}),
240
+ ...(payload.workflowOutcome !== undefined
241
+ ? { lastWorkflowOutcome: payload.workflowOutcome }
242
+ : {}),
237
243
  };
238
244
  if (payload.sentinel === 'BLOCKED' || payload.sentinel === 'FAILED') {
239
245
  nextContext.blockedFromStage = current.wake.stage;
@@ -8,22 +8,111 @@ import { createEventEnvelope } from '../lib/event-log.js';
8
8
  // `deliverOutboundEvent` is injected rather than imported so this module does
9
9
  // not depend on the outbox module directly.
10
10
  export function createStaleRunReconciler(deps) {
11
+ function isTerminalLifecycle(lifecycle) {
12
+ return lifecycle === 'TERMINAL';
13
+ }
14
+ function recoveryOutcomeForLifecycle(lifecycle) {
15
+ switch (lifecycle) {
16
+ case 'CREATED':
17
+ case 'CLAIMED':
18
+ case 'PREPARING':
19
+ return 'CANCELED_BY_RECONCILIATION';
20
+ case 'PROCESS_STARTING':
21
+ case 'RUNNING':
22
+ case 'FINALISING':
23
+ return 'STALLED';
24
+ case 'TERMINAL':
25
+ return 'SUPERSEDED';
26
+ }
27
+ }
11
28
  async function staleReason(record, now) {
12
29
  if (record.status !== 'running') {
13
30
  return null;
14
31
  }
15
- if (deps.isRunningRecordActive !== undefined && !(await deps.isRunningRecordActive(record))) {
32
+ if (isTerminalLifecycle(record.lifecycle)) {
33
+ return null;
34
+ }
35
+ if (deps.isRunningRecordActive === undefined) {
36
+ if (record.lifecycle !== 'CREATED' &&
37
+ record.lifecycle !== 'CLAIMED' &&
38
+ record.lifecycle !== 'PREPARING') {
39
+ const startedAtMs = Date.parse(record.startedAt);
40
+ if (!Number.isFinite(startedAtMs)) {
41
+ return 'timeout';
42
+ }
43
+ return now.getTime() - startedAtMs >= deps.runnerTimeoutMs() ? 'timeout' : null;
44
+ }
45
+ return 'startup-recovery';
46
+ }
47
+ const active = await deps.isRunningRecordActive(record);
48
+ if (active) {
49
+ const startedAtMs = Date.parse(record.startedAt);
50
+ if (!Number.isFinite(startedAtMs)) {
51
+ return 'timeout';
52
+ }
53
+ return now.getTime() - startedAtMs >= deps.runnerTimeoutMs() ? 'timeout' : null;
54
+ }
55
+ if (record.lifecycle === 'RUNNING' || record.lifecycle === 'PROCESS_STARTING') {
16
56
  return 'runner-lock-not-active';
17
57
  }
18
- const startedAtMs = Date.parse(record.startedAt);
19
- if (!Number.isFinite(startedAtMs)) {
20
- return 'timeout';
58
+ return 'startup-recovery';
59
+ }
60
+ async function deliverRecoveredLabels(projection, runId, finishedAt) {
61
+ await deps.deliverOutboundEvent(createLabelsEvent({
62
+ projection,
63
+ runId,
64
+ statusLabel: 'wake:status.failed',
65
+ stageLabel: stageLabelForStage(projection.wake.stage),
66
+ workflowLabel: workflowLabelForWorkflowName(workflowNameForProjection(projection, deps.config)),
67
+ occurredAt: finishedAt,
68
+ }));
69
+ }
70
+ async function recoverMissingRunRecordClaims(runRecords, finishedAt) {
71
+ const knownRunIds = new Set(runRecords.map((record) => record.runId));
72
+ const projections = await deps.stateStore.listIssueStates();
73
+ for (const projection of projections) {
74
+ const runId = projection.wake.lastRunId;
75
+ if (runId === undefined ||
76
+ knownRunIds.has(runId) ||
77
+ runRecords.some((record) => record.workItemKey === projection.workItemKey) ||
78
+ projection.context.lastRunSentinel !== undefined) {
79
+ continue;
80
+ }
81
+ const event = createEventEnvelope({
82
+ eventId: `${runId}-missing-run-record-recovered`,
83
+ workItemKey: projection.workItemKey,
84
+ streamScope: 'work-item',
85
+ direction: 'internal',
86
+ sourceSystem: 'wake',
87
+ sourceEventType: 'wake.run.completed',
88
+ sourceRefs: {
89
+ repo: projection.issue.repo,
90
+ issueNumber: projection.issue.number,
91
+ runId,
92
+ },
93
+ occurredAt: finishedAt,
94
+ ingestedAt: finishedAt,
95
+ trigger: 'immediate',
96
+ payload: {
97
+ action: projection.context.lastRunAction,
98
+ sentinel: 'FAILED',
99
+ executionOutcome: 'CANCELED_BY_RECONCILIATION',
100
+ runId,
101
+ reason: 'runner:missing-run-record',
102
+ },
103
+ });
104
+ await deps.stateStore.appendEventEnvelope(event);
105
+ await deps.projectionUpdater.rebuildFromEvents([event]);
106
+ const updatedProjection = await deps.stateStore.readIssueState(projection.workItemKey);
107
+ if (updatedProjection !== null) {
108
+ await deliverRecoveredLabels(updatedProjection, runId, finishedAt);
109
+ }
21
110
  }
22
- return now.getTime() - startedAtMs >= deps.runnerTimeoutMs() ? 'timeout' : null;
23
111
  }
24
112
  async function reconcileStaleRunningRecords(now) {
25
113
  const finishedAt = now.toISOString();
26
114
  const runRecords = await deps.stateStore.listRunRecords();
115
+ await recoverMissingRunRecordClaims(runRecords, finishedAt);
27
116
  const staleRecords = [];
28
117
  for (const record of runRecords) {
29
118
  const reason = await staleReason(record, now);
@@ -46,8 +135,10 @@ export function createStaleRunReconciler(deps) {
46
135
  if (projection === null || projection.wake.lastRunId !== record.runId || newerCompletedRun) {
47
136
  await deps.stateStore.writeRunRecord({
48
137
  ...record,
138
+ lifecycle: 'TERMINAL',
49
139
  status: 'superseded',
50
140
  finishedAt,
141
+ executionOutcome: 'SUPERSEDED',
51
142
  summary: 'Stale running record was superseded by a newer run.',
52
143
  metadata: {
53
144
  ...record.metadata,
@@ -57,15 +148,19 @@ export function createStaleRunReconciler(deps) {
57
148
  });
58
149
  continue;
59
150
  }
151
+ const staleExecutionOutcome = reason === 'timeout' ? 'TIMED_OUT' : recoveryOutcomeForLifecycle(record.lifecycle);
60
152
  await deps.stateStore.writeRunRecord({
61
153
  ...record,
154
+ lifecycle: 'TERMINAL',
62
155
  status: 'failed',
63
156
  finishedAt,
64
157
  sentinel: 'FAILED',
158
+ executionOutcome: staleExecutionOutcome,
65
159
  summary: `Run exceeded timeout while marked running and was reconciled by a later tick.`,
66
160
  metadata: {
67
161
  ...record.metadata,
68
162
  reconciledBy: 'stale-running-record',
163
+ recoveryLifecycle: record.lifecycle,
69
164
  staleReason: reason,
70
165
  timeoutMs: deps.runnerTimeoutMs(),
71
166
  },
@@ -88,8 +183,13 @@ export function createStaleRunReconciler(deps) {
88
183
  payload: {
89
184
  action: record.action,
90
185
  sentinel: 'FAILED',
186
+ executionOutcome: staleExecutionOutcome,
91
187
  runId: record.runId,
92
- reason: reason === 'timeout' ? 'runner:stale-timeout' : 'runner:orphaned-process',
188
+ reason: reason === 'timeout'
189
+ ? 'runner:stale-timeout'
190
+ : reason === 'startup-recovery'
191
+ ? `runner:recover-${record.lifecycle.toLowerCase()}`
192
+ : 'runner:orphaned-process',
93
193
  ...(record.routing === undefined ? {} : { routing: record.routing }),
94
194
  },
95
195
  });
@@ -97,14 +197,7 @@ export function createStaleRunReconciler(deps) {
97
197
  await deps.projectionUpdater.rebuildFromEvents([runCompletedEvent]);
98
198
  const updatedProjection = await deps.stateStore.readIssueState(projection.workItemKey);
99
199
  if (updatedProjection !== null) {
100
- await deps.deliverOutboundEvent(createLabelsEvent({
101
- projection: updatedProjection,
102
- runId: record.runId,
103
- statusLabel: 'wake:status.failed',
104
- stageLabel: stageLabelForStage(updatedProjection.wake.stage),
105
- workflowLabel: workflowLabelForWorkflowName(workflowNameForProjection(updatedProjection, deps.config)),
106
- occurredAt: finishedAt,
107
- }));
200
+ await deliverRecoveredLabels(updatedProjection, record.runId, finishedAt);
108
201
  }
109
202
  }
110
203
  }
@@ -446,10 +446,18 @@ export function createTickRunner(deps) {
446
446
  repo: candidate.issue.repo,
447
447
  issueNumber: candidate.issue.number,
448
448
  action,
449
+ lifecycle: 'CREATED',
449
450
  status: 'running',
450
451
  startedAt: nowIso,
452
+ routing,
451
453
  };
452
454
  await deps.stateStore.writeRunRecord(runningRecord);
455
+ async function transitionRunLifecycle(lifecycle) {
456
+ await deps.stateStore.writeRunRecord({
457
+ ...(await deps.stateStore.readRunRecord(runId)),
458
+ lifecycle,
459
+ });
460
+ }
453
461
  const claimedAt = eventStampNow();
454
462
  const claimedEvent = createEventEnvelope({
455
463
  eventId: `${runId}-claimed`,
@@ -473,6 +481,7 @@ export function createTickRunner(deps) {
473
481
  },
474
482
  });
475
483
  await deps.stateStore.appendEventEnvelope(claimedEvent);
484
+ await transitionRunLifecycle('CLAIMED');
476
485
  await projectionUpdater.rebuildFromEvents([claimedEvent]);
477
486
  await deliverOutboundEvent(createLabelsEvent({
478
487
  projection: candidate,
@@ -483,6 +492,7 @@ export function createTickRunner(deps) {
483
492
  occurredAt: eventStampNow(),
484
493
  }));
485
494
  try {
495
+ await transitionRunLifecycle('PREPARING');
486
496
  const prepareResult = workspaceMode === 'branch'
487
497
  ? await deps.workspaceManager.prepareWorkspace({
488
498
  workId: candidate.workItemKey,
@@ -499,7 +509,18 @@ export function createTickRunner(deps) {
499
509
  const upstreamChanges = 'upstreamChanges' in prepareResult && typeof prepareResult.upstreamChanges === 'string'
500
510
  ? prepareResult.upstreamChanges
501
511
  : undefined;
512
+ const preparedRecord = (await deps.stateStore.readRunRecord(runId));
513
+ await deps.stateStore.writeRunRecord({
514
+ ...preparedRecord,
515
+ lifecycle: 'PROCESS_STARTING',
516
+ metadata: {
517
+ ...preparedRecord.metadata,
518
+ ...(workspacePath === undefined ? {} : { workspacePath }),
519
+ workspaceMode,
520
+ },
521
+ });
502
522
  const recentEvents = await deps.stateStore.listEventEnvelopesForWorkItem(candidate.workItemKey, 6);
523
+ await transitionRunLifecycle('RUNNING');
503
524
  const runnerResult = await deps.runner.run({
504
525
  action,
505
526
  projection: candidate,
@@ -571,8 +592,23 @@ export function createTickRunner(deps) {
571
592
  : { failureClass: runnerResult.failureClass }),
572
593
  ...(runnerResult.routing === undefined ? {} : { routing: runnerResult.routing }),
573
594
  };
595
+ const executionOutcome = runnerResult.failureClass === 'quota'
596
+ ? 'QUOTA_EXHAUSTED'
597
+ : runnerResult.failureClass === 'infra'
598
+ ? 'PROCESS_FAILED'
599
+ : 'COMPLETED';
600
+ const workflowOutcome = sentinel === 'DONE'
601
+ ? 'DONE'
602
+ : sentinel === 'BLOCKED'
603
+ ? 'BLOCKED'
604
+ : sentinel === 'AWAITING_APPROVAL'
605
+ ? 'AWAITING_APPROVAL'
606
+ : undefined;
607
+ await transitionRunLifecycle('FINALISING');
608
+ const finalisingRecord = (await deps.stateStore.readRunRecord(runId));
574
609
  await deps.stateStore.writeRunRecord({
575
- ...runningRecord,
610
+ ...finalisingRecord,
611
+ lifecycle: 'TERMINAL',
576
612
  status: sentinel === 'DONE'
577
613
  ? 'completed'
578
614
  : sentinel === 'BLOCKED'
@@ -583,10 +619,15 @@ export function createTickRunner(deps) {
583
619
  finishedAt,
584
620
  sessionId: runnerResult.session_id,
585
621
  sentinel,
622
+ executionOutcome,
623
+ ...(workflowOutcome !== undefined ? { workflowOutcome } : {}),
586
624
  summary: parsedRunnerResult.body,
587
625
  ...(runnerResult.routing === undefined ? {} : { routing: runnerResult.routing }),
588
626
  ...(runnerResult.tokenUsage === undefined ? {} : { tokenUsage: runnerResult.tokenUsage }),
589
- metadata: resultMetadata,
627
+ metadata: {
628
+ ...finalisingRecord.metadata,
629
+ ...resultMetadata,
630
+ },
590
631
  });
591
632
  const runCompletedEvent = createEventEnvelope({
592
633
  eventId: `${runId}-completed`,
@@ -626,6 +667,8 @@ export function createTickRunner(deps) {
626
667
  : { handledCommentId: latestHumanCommentId(candidate) }),
627
668
  body: parsedRunnerResult.body,
628
669
  envelope: parsedRunnerResult.envelope,
670
+ executionOutcome,
671
+ ...(workflowOutcome !== undefined ? { workflowOutcome } : {}),
629
672
  },
630
673
  });
631
674
  await deps.stateStore.appendEventEnvelope(runCompletedEvent);
@@ -668,11 +711,14 @@ export function createTickRunner(deps) {
668
711
  catch (err) {
669
712
  const finishedAt = deps.clock.now().toISOString();
670
713
  const sentinel = 'FAILED';
714
+ const failedRecord = (await deps.stateStore.readRunRecord(runId)) ?? runningRecord;
671
715
  await deps.stateStore.writeRunRecord({
672
- ...runningRecord,
716
+ ...failedRecord,
717
+ lifecycle: 'TERMINAL',
673
718
  status: 'failed',
674
719
  finishedAt,
675
720
  sentinel,
721
+ executionOutcome: 'PROCESS_FAILED',
676
722
  summary: err instanceof Error ? err.message : String(err),
677
723
  metadata: {
678
724
  failureClass: 'infra',
@@ -699,6 +745,7 @@ export function createTickRunner(deps) {
699
745
  runId,
700
746
  reason: 'runner:infrastructure-error',
701
747
  failureClass: 'infra',
748
+ executionOutcome: 'PROCESS_FAILED',
702
749
  // Deliberately omit handledCommentId: an infra blip (CLI crash, timeout,
703
750
  // network error) never reached the agent, so it isn't an answer to the
704
751
  // triggering comment. Leaving it unset lets the next tick retry the same
@@ -15,6 +15,37 @@ export const wakeArtifactsEnvelopeSchema = z.object({
15
15
  artifacts: z.array(reportedArtifactSchema).default([]),
16
16
  });
17
17
  export const runnerSentinelSchema = z.enum(runnerSentinelValues);
18
+ export const executionOutcomeValues = [
19
+ 'COMPLETED',
20
+ 'STARTUP_FAILED',
21
+ 'PROCESS_FAILED',
22
+ 'TIMED_OUT',
23
+ 'STALLED',
24
+ 'CANCELED_BY_RECONCILIATION',
25
+ 'CANCELED_BY_OPERATOR',
26
+ 'QUOTA_EXHAUSTED',
27
+ 'MALFORMED_OUTPUT',
28
+ 'SUPERSEDED',
29
+ ];
30
+ export const workflowOutcomeValues = [
31
+ 'DONE',
32
+ 'BLOCKED',
33
+ 'AWAITING_APPROVAL',
34
+ 'AWAITING_INPUT',
35
+ 'CHANGES_REQUESTED',
36
+ ];
37
+ export const executionAttemptLifecycleValues = [
38
+ 'CREATED',
39
+ 'CLAIMED',
40
+ 'PREPARING',
41
+ 'PROCESS_STARTING',
42
+ 'RUNNING',
43
+ 'FINALISING',
44
+ 'TERMINAL',
45
+ ];
46
+ export const executionOutcomeSchema = z.enum(executionOutcomeValues);
47
+ export const workflowOutcomeSchema = z.enum(workflowOutcomeValues);
48
+ export const executionAttemptLifecycleSchema = z.enum(executionAttemptLifecycleValues);
18
49
  export const defaultAgentIdentity = 'Wake';
19
50
  export const defaultSmokePrompt = `This is ${defaultAgentIdentity}, reply with "hi ${defaultAgentIdentity} only"`;
20
51
  const modelOverridesSchema = z
@@ -270,7 +301,20 @@ const runTokenUsageSchema = z.object({
270
301
  costUsd: z.number().nonnegative().optional(),
271
302
  turns: z.number().nonnegative().optional(),
272
303
  });
273
- export const runRecordSchema = z.object({
304
+ function legacyRunLifecycle(input) {
305
+ if (input.lifecycle !== undefined) {
306
+ return input;
307
+ }
308
+ return {
309
+ ...input,
310
+ lifecycle: input.status === 'running' ? 'RUNNING' : 'TERMINAL',
311
+ };
312
+ }
313
+ export const runRecordSchema = z.preprocess((input) => {
314
+ return input !== null && typeof input === 'object'
315
+ ? legacyRunLifecycle(input)
316
+ : input;
317
+ }, z.object({
274
318
  schemaVersion: z.literal(1),
275
319
  runId: z.string(),
276
320
  // The work item this run belongs to. Required: run records are Wake-owned
@@ -285,16 +329,26 @@ export const runRecordSchema = z.object({
285
329
  repo: z.string(),
286
330
  issueNumber: z.number().int().positive(),
287
331
  action: identifierSchema,
288
- status: z.enum(['running', 'completed', 'awaiting-approval', 'blocked', 'failed', 'superseded']),
332
+ lifecycle: executionAttemptLifecycleSchema,
333
+ status: z.enum([
334
+ 'running',
335
+ 'completed',
336
+ 'awaiting-approval',
337
+ 'blocked',
338
+ 'failed',
339
+ 'superseded',
340
+ ]),
289
341
  startedAt: isoTimestampSchema,
290
342
  finishedAt: isoTimestampSchema.optional(),
291
343
  sessionId: z.string().optional(),
292
344
  sentinel: runnerSentinelSchema.optional(),
345
+ executionOutcome: executionOutcomeSchema.optional(),
346
+ workflowOutcome: workflowOutcomeSchema.optional(),
293
347
  summary: z.string().optional(),
294
348
  routing: runnerRoutingSchema.optional(),
295
349
  tokenUsage: runTokenUsageSchema.optional(),
296
350
  metadata: z.record(z.string(), z.unknown()).optional(),
297
- });
351
+ }));
298
352
  const runnerHealthEntrySchema = z.object({
299
353
  pausedUntil: isoTimestampSchema.optional(),
300
354
  // 'reported' = CLI told us the real reset time; 'estimated' = exponential
@@ -124,4 +124,4 @@ export function resolveWakeVersion(options = {}) {
124
124
  }
125
125
  return '0.1.0-dev';
126
126
  }
127
- export const wakeVersion = "g76dc1bc";
127
+ export const wakeVersion = "gf58d7a3";
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@atolis-hq/wake",
3
- "version": "0.2.27",
3
+ "version": "0.2.29",
4
4
  "description": "Local autonomous agent control plane for software development",
5
5
  "license": "Apache-2.0",
6
6
  "repository": {