@atolis-hq/wake 0.2.28 → 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.
@@ -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,6 +135,7 @@ 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,
51
141
  executionOutcome: 'SUPERSEDED',
@@ -58,9 +148,10 @@ export function createStaleRunReconciler(deps) {
58
148
  });
59
149
  continue;
60
150
  }
61
- const staleExecutionOutcome = reason === 'timeout' ? 'TIMED_OUT' : 'STALLED';
151
+ const staleExecutionOutcome = reason === 'timeout' ? 'TIMED_OUT' : recoveryOutcomeForLifecycle(record.lifecycle);
62
152
  await deps.stateStore.writeRunRecord({
63
153
  ...record,
154
+ lifecycle: 'TERMINAL',
64
155
  status: 'failed',
65
156
  finishedAt,
66
157
  sentinel: 'FAILED',
@@ -69,6 +160,7 @@ export function createStaleRunReconciler(deps) {
69
160
  metadata: {
70
161
  ...record.metadata,
71
162
  reconciledBy: 'stale-running-record',
163
+ recoveryLifecycle: record.lifecycle,
72
164
  staleReason: reason,
73
165
  timeoutMs: deps.runnerTimeoutMs(),
74
166
  },
@@ -93,7 +185,11 @@ export function createStaleRunReconciler(deps) {
93
185
  sentinel: 'FAILED',
94
186
  executionOutcome: staleExecutionOutcome,
95
187
  runId: record.runId,
96
- 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',
97
193
  ...(record.routing === undefined ? {} : { routing: record.routing }),
98
194
  },
99
195
  });
@@ -101,14 +197,7 @@ export function createStaleRunReconciler(deps) {
101
197
  await deps.projectionUpdater.rebuildFromEvents([runCompletedEvent]);
102
198
  const updatedProjection = await deps.stateStore.readIssueState(projection.workItemKey);
103
199
  if (updatedProjection !== null) {
104
- await deps.deliverOutboundEvent(createLabelsEvent({
105
- projection: updatedProjection,
106
- runId: record.runId,
107
- statusLabel: 'wake:status.failed',
108
- stageLabel: stageLabelForStage(updatedProjection.wake.stage),
109
- workflowLabel: workflowLabelForWorkflowName(workflowNameForProjection(updatedProjection, deps.config)),
110
- occurredAt: finishedAt,
111
- }));
200
+ await deliverRecoveredLabels(updatedProjection, record.runId, finishedAt);
112
201
  }
113
202
  }
114
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,
@@ -583,8 +604,11 @@ export function createTickRunner(deps) {
583
604
  : sentinel === 'AWAITING_APPROVAL'
584
605
  ? 'AWAITING_APPROVAL'
585
606
  : undefined;
607
+ await transitionRunLifecycle('FINALISING');
608
+ const finalisingRecord = (await deps.stateStore.readRunRecord(runId));
586
609
  await deps.stateStore.writeRunRecord({
587
- ...runningRecord,
610
+ ...finalisingRecord,
611
+ lifecycle: 'TERMINAL',
588
612
  status: sentinel === 'DONE'
589
613
  ? 'completed'
590
614
  : sentinel === 'BLOCKED'
@@ -600,7 +624,10 @@ export function createTickRunner(deps) {
600
624
  summary: parsedRunnerResult.body,
601
625
  ...(runnerResult.routing === undefined ? {} : { routing: runnerResult.routing }),
602
626
  ...(runnerResult.tokenUsage === undefined ? {} : { tokenUsage: runnerResult.tokenUsage }),
603
- metadata: resultMetadata,
627
+ metadata: {
628
+ ...finalisingRecord.metadata,
629
+ ...resultMetadata,
630
+ },
604
631
  });
605
632
  const runCompletedEvent = createEventEnvelope({
606
633
  eventId: `${runId}-completed`,
@@ -684,8 +711,10 @@ export function createTickRunner(deps) {
684
711
  catch (err) {
685
712
  const finishedAt = deps.clock.now().toISOString();
686
713
  const sentinel = 'FAILED';
714
+ const failedRecord = (await deps.stateStore.readRunRecord(runId)) ?? runningRecord;
687
715
  await deps.stateStore.writeRunRecord({
688
- ...runningRecord,
716
+ ...failedRecord,
717
+ lifecycle: 'TERMINAL',
689
718
  status: 'failed',
690
719
  finishedAt,
691
720
  sentinel,
@@ -34,8 +34,18 @@ export const workflowOutcomeValues = [
34
34
  'AWAITING_INPUT',
35
35
  'CHANGES_REQUESTED',
36
36
  ];
37
+ export const executionAttemptLifecycleValues = [
38
+ 'CREATED',
39
+ 'CLAIMED',
40
+ 'PREPARING',
41
+ 'PROCESS_STARTING',
42
+ 'RUNNING',
43
+ 'FINALISING',
44
+ 'TERMINAL',
45
+ ];
37
46
  export const executionOutcomeSchema = z.enum(executionOutcomeValues);
38
47
  export const workflowOutcomeSchema = z.enum(workflowOutcomeValues);
48
+ export const executionAttemptLifecycleSchema = z.enum(executionAttemptLifecycleValues);
39
49
  export const defaultAgentIdentity = 'Wake';
40
50
  export const defaultSmokePrompt = `This is ${defaultAgentIdentity}, reply with "hi ${defaultAgentIdentity} only"`;
41
51
  const modelOverridesSchema = z
@@ -291,7 +301,20 @@ const runTokenUsageSchema = z.object({
291
301
  costUsd: z.number().nonnegative().optional(),
292
302
  turns: z.number().nonnegative().optional(),
293
303
  });
294
- 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({
295
318
  schemaVersion: z.literal(1),
296
319
  runId: z.string(),
297
320
  // The work item this run belongs to. Required: run records are Wake-owned
@@ -306,7 +329,15 @@ export const runRecordSchema = z.object({
306
329
  repo: z.string(),
307
330
  issueNumber: z.number().int().positive(),
308
331
  action: identifierSchema,
309
- 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
+ ]),
310
341
  startedAt: isoTimestampSchema,
311
342
  finishedAt: isoTimestampSchema.optional(),
312
343
  sessionId: z.string().optional(),
@@ -317,7 +348,7 @@ export const runRecordSchema = z.object({
317
348
  routing: runnerRoutingSchema.optional(),
318
349
  tokenUsage: runTokenUsageSchema.optional(),
319
350
  metadata: z.record(z.string(), z.unknown()).optional(),
320
- });
351
+ }));
321
352
  const runnerHealthEntrySchema = z.object({
322
353
  pausedUntil: isoTimestampSchema.optional(),
323
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 = "g73487cf";
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.28",
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": {