@atolis-hq/wake 0.2.51 → 0.2.53

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,17 +1,71 @@
1
1
  import { mkdir, rm } from 'node:fs/promises';
2
2
  import { join } from 'node:path';
3
- export function createFakeWorkspaceManager(root) {
3
+ export class FakeWorkspaceValidationError extends Error {
4
+ failureSource = 'wake-workspace-validation';
5
+ constructor(message) {
6
+ super(message);
7
+ this.name = 'FakeWorkspaceValidationError';
8
+ }
9
+ }
10
+ export function createFakeWorkspaceManager(root, options = {}) {
11
+ function validation(input) {
12
+ if (options.failValidation === true) {
13
+ throw new FakeWorkspaceValidationError('fake workspace validation failed');
14
+ }
15
+ return {
16
+ repo: input.repo,
17
+ expectedBranch: input.branch,
18
+ actualBranch: input.branch,
19
+ expectedRemoteUrl: input.remoteUrl,
20
+ actualRemoteUrl: input.remoteUrl,
21
+ baseRevision: 'fake-base',
22
+ headRevision: 'fake-head',
23
+ clean: true,
24
+ remoteAvailable: true,
25
+ };
26
+ }
4
27
  return {
5
- async prepareWorkspace({ workId }) {
28
+ async prepareWorkspace({ workId, repo, issueNumber, }) {
6
29
  // Keyed on the work id, symmetrically with the real git-backed manager.
7
30
  const workspacePath = join(root, workId);
8
31
  await mkdir(workspacePath, { recursive: true });
9
- return { workspacePath, mergeConflictDetected: false };
32
+ return {
33
+ workspacePath,
34
+ mergeConflictDetected: false,
35
+ validation: validation({
36
+ repo,
37
+ branch: `wake/issue-${issueNumber}`,
38
+ remoteUrl: `https://github.com/${repo}.git`,
39
+ }),
40
+ };
10
41
  },
11
42
  async prepareReadOnlyClone({ repo }) {
12
43
  const workspacePath = join(root, repo.replace(/[\\/]/g, '__'), 'canonical');
13
44
  await mkdir(workspacePath, { recursive: true });
14
- return { workspacePath };
45
+ return {
46
+ workspacePath,
47
+ validation: validation({
48
+ repo,
49
+ branch: 'main',
50
+ remoteUrl: `https://github.com/${repo}.git`,
51
+ }),
52
+ };
53
+ },
54
+ async recordWorkspaceBookkeeping() {
55
+ if (options.failBookkeeping === true) {
56
+ throw new Error('fake workspace bookkeeping failed');
57
+ }
58
+ return {
59
+ branch: 'wake/issue-fake',
60
+ headRevision: 'fake-head-after-run',
61
+ diffSummary: '',
62
+ untrackedFiles: [],
63
+ unpushedCommits: {
64
+ hasUpstream: false,
65
+ count: 0,
66
+ commits: [],
67
+ },
68
+ };
15
69
  },
16
70
  async cleanupWorkspace({ workspacePath }) {
17
71
  // Retry on Windows EBUSY/EPERM (AV/indexer holding a brief handle) to
@@ -19,6 +19,28 @@ async function git(args, cwd) {
19
19
  });
20
20
  return { stdout: result.stdout.trim(), stderr: result.stderr.trim() };
21
21
  }
22
+ async function gitExitCode(args, cwd) {
23
+ try {
24
+ await execFile('git', args, {
25
+ cwd,
26
+ env: process.env,
27
+ encoding: 'utf8',
28
+ maxBuffer: 1024 * 1024 * 16,
29
+ });
30
+ return 0;
31
+ }
32
+ catch (error) {
33
+ const maybeExit = error;
34
+ return typeof maybeExit.code === 'number' ? maybeExit.code : 1;
35
+ }
36
+ }
37
+ export class WorkspaceValidationError extends Error {
38
+ failureSource = 'wake-workspace-validation';
39
+ constructor(message) {
40
+ super(message);
41
+ this.name = 'WorkspaceValidationError';
42
+ }
43
+ }
22
44
  async function detectDefaultBranch(repoPath) {
23
45
  await git(['remote', 'set-head', 'origin', '--auto'], repoPath);
24
46
  const { stdout } = await git(['symbolic-ref', '--short', 'refs/remotes/origin/HEAD'], repoPath);
@@ -95,6 +117,76 @@ async function tryUpdateFromDefaultBranch(workspacePath) {
95
117
  return { mergeConflictDetected: false };
96
118
  }
97
119
  }
120
+ async function validateWorkspace(input) {
121
+ await git(['fetch', 'origin'], input.workspacePath);
122
+ const { stdout: actualRemoteUrl } = await git(['remote', 'get-url', 'origin'], input.workspacePath);
123
+ const { stdout: actualBranch } = await git(['rev-parse', '--abbrev-ref', 'HEAD'], input.workspacePath);
124
+ const { stdout: baseRevision } = await git(['merge-base', 'HEAD', input.expectedBaseRef], input.workspacePath);
125
+ const { stdout: headRevision } = await git(['rev-parse', 'HEAD'], input.workspacePath);
126
+ const { stdout: status } = await git(['status', '--porcelain'], input.workspacePath);
127
+ const remoteExit = await gitExitCode(['ls-remote', '--exit-code', 'origin', 'HEAD'], input.workspacePath);
128
+ const validation = {
129
+ repo: input.repo,
130
+ expectedBranch: input.expectedBranch,
131
+ actualBranch,
132
+ expectedRemoteUrl: input.expectedRemoteUrl,
133
+ actualRemoteUrl,
134
+ baseRevision,
135
+ headRevision,
136
+ clean: status.length === 0,
137
+ remoteAvailable: remoteExit === 0,
138
+ };
139
+ const failures = [];
140
+ if (actualRemoteUrl !== input.expectedRemoteUrl) {
141
+ failures.push(`expected origin ${input.expectedRemoteUrl}, found ${actualRemoteUrl}`);
142
+ }
143
+ if (actualBranch !== input.expectedBranch) {
144
+ failures.push(`expected branch ${input.expectedBranch}, found ${actualBranch}`);
145
+ }
146
+ if (!validation.clean) {
147
+ failures.push('working tree has uncommitted or untracked changes');
148
+ }
149
+ if (!validation.remoteAvailable) {
150
+ failures.push('origin remote is not available');
151
+ }
152
+ if (failures.length > 0) {
153
+ throw new WorkspaceValidationError(`Workspace validation failed: ${failures.join('; ')}`);
154
+ }
155
+ return validation;
156
+ }
157
+ async function recordWorkspaceBookkeeping(workspacePath) {
158
+ const { stdout: branch } = await git(['rev-parse', '--abbrev-ref', 'HEAD'], workspacePath);
159
+ const { stdout: headRevision } = await git(['rev-parse', 'HEAD'], workspacePath);
160
+ const { stdout: diffSummary } = await git(['diff', '--stat', 'HEAD'], workspacePath);
161
+ const { stdout: untracked } = await git(['ls-files', '--others', '--exclude-standard'], workspacePath);
162
+ let hasUpstream = true;
163
+ let count;
164
+ let commits;
165
+ try {
166
+ const { stdout: upstream } = await git(['rev-parse', '--abbrev-ref', '--symbolic-full-name', '@{u}'], workspacePath);
167
+ const { stdout: ahead } = await git(['rev-list', '--count', `${upstream}..HEAD`], workspacePath);
168
+ count = parseInt(ahead, 10);
169
+ const { stdout: commitLog } = await git(['log', '--pretty=format:%h %s', `${upstream}..HEAD`], workspacePath);
170
+ commits = commitLog.length === 0 ? [] : commitLog.split('\n');
171
+ }
172
+ catch {
173
+ hasUpstream = false;
174
+ const { stdout: commitLog } = await git(['log', '--pretty=format:%h %s'], workspacePath);
175
+ commits = commitLog.length === 0 ? [] : commitLog.split('\n');
176
+ count = commits.length;
177
+ }
178
+ return {
179
+ branch,
180
+ headRevision,
181
+ diffSummary,
182
+ untrackedFiles: untracked.length === 0 ? [] : untracked.split('\n'),
183
+ unpushedCommits: {
184
+ hasUpstream,
185
+ count,
186
+ commits,
187
+ },
188
+ };
189
+ }
98
190
  export function createGitWorkspaceManager(options) {
99
191
  const paths = createWakePaths(options.wakeRoot);
100
192
  const remoteUrlForRepo = options.remoteUrlForRepo ?? defaultRemoteUrlForRepo;
@@ -131,7 +223,15 @@ export function createGitWorkspaceManager(options) {
131
223
  const workspacePath = paths.workspaceDir(workId);
132
224
  if (await pathExists(workspacePath)) {
133
225
  const updateResult = await tryUpdateFromDefaultBranch(workspacePath);
134
- return { workspacePath, ...updateResult };
226
+ const defaultBranch = await detectDefaultBranch(workspacePath);
227
+ const validation = await validateWorkspace({
228
+ workspacePath,
229
+ repo,
230
+ expectedBranch: branchNameForIssue(issueNumber),
231
+ expectedRemoteUrl: remoteUrlForRepo(repo),
232
+ expectedBaseRef: `origin/${defaultBranch}`,
233
+ });
234
+ return { workspacePath, ...updateResult, validation };
135
235
  }
136
236
  const { repoPath, defaultBranch } = await ensureCanonicalClone(repo);
137
237
  const remoteUrl = remoteUrlForRepo(repo);
@@ -144,14 +244,32 @@ export function createGitWorkspaceManager(options) {
144
244
  const branch = branchNameForIssue(issueNumber);
145
245
  await git(['remote', 'set-url', 'origin', remoteUrl], workspacePath);
146
246
  await git(['checkout', '-B', branch], workspacePath);
147
- return { workspacePath, mergeConflictDetected: false };
247
+ const validation = await validateWorkspace({
248
+ workspacePath,
249
+ repo,
250
+ expectedBranch: branch,
251
+ expectedRemoteUrl: remoteUrl,
252
+ expectedBaseRef: `origin/${defaultBranch}`,
253
+ });
254
+ return { workspacePath, mergeConflictDetected: false, validation };
148
255
  },
149
- async prepareReadOnlyClone({ repo }) {
256
+ async prepareReadOnlyClone({ repo, }) {
150
257
  // Refine only reads the issue and, at most, the canonical clone -
151
258
  // it never gets a per-issue branch/workspace of its own (only
152
259
  // 'implement' pays that cost).
153
260
  const { repoPath } = await ensureCanonicalClone(repo);
154
- return { workspacePath: repoPath };
261
+ const defaultBranch = await detectDefaultBranch(repoPath);
262
+ const validation = await validateWorkspace({
263
+ workspacePath: repoPath,
264
+ repo,
265
+ expectedBranch: defaultBranch,
266
+ expectedRemoteUrl: remoteUrlForRepo(repo),
267
+ expectedBaseRef: `origin/${defaultBranch}`,
268
+ });
269
+ return { workspacePath: repoPath, validation };
270
+ },
271
+ async recordWorkspaceBookkeeping({ workspacePath, }) {
272
+ return recordWorkspaceBookkeeping(workspacePath);
155
273
  },
156
274
  async cleanupWorkspace({ workspacePath }) {
157
275
  // On Windows, a just-exited git subprocess (or AV/indexer) can hold a brief
@@ -110,7 +110,7 @@ export function createPolicyEngine() {
110
110
  const handledCommentId = typeof context.lastHandledCommentId === 'string' ? context.lastHandledCommentId : undefined;
111
111
  const lastCompletedAction = typeof context.lastCompletedAction === 'string' ? context.lastCompletedAction : undefined;
112
112
  const lastRunSentinel = typeof context.lastRunSentinel === 'string' ? context.lastRunSentinel : undefined;
113
- const lastFailureClass = typeof context.lastFailureClass === 'string' ? context.lastFailureClass : undefined;
113
+ const lastRetrySafety = typeof context.lastRetrySafety === 'string' ? context.lastRetrySafety : undefined;
114
114
  if (issue.wake.lastRunId === undefined) {
115
115
  return true;
116
116
  }
@@ -122,15 +122,15 @@ export function createPolicyEngine() {
122
122
  if (isAwaitingApproval(issue)) {
123
123
  return false;
124
124
  }
125
- if (lastRunSentinel === failedRunnerSentinel && lastFailureClass !== 'quota') {
125
+ if (lastRunSentinel === failedRunnerSentinel) {
126
+ if (lastRetrySafety === 'SAFE_TO_RETRY' || lastRetrySafety === 'SAFE_TO_RESUME') {
127
+ return belowFailureRetryLimit(issue, config);
128
+ }
126
129
  return false;
127
130
  }
128
131
  if (lastRunSentinel === 'BLOCKED') {
129
132
  return false;
130
133
  }
131
- if (lastFailureClass === 'quota') {
132
- return belowFailureRetryLimit(issue, config);
133
- }
134
134
  const workflowAction = chooseWorkflowAction(issue, workflow);
135
135
  return workflowAction !== null && lastCompletedAction !== workflowAction.action;
136
136
  },
@@ -253,6 +253,17 @@ async function applyEvent(current, event, ctx, config) {
253
253
  ...(payload.workflowOutcome !== undefined
254
254
  ? { lastWorkflowOutcome: payload.workflowOutcome }
255
255
  : {}),
256
+ ...(payload.failurePhase !== undefined ? { lastFailurePhase: payload.failurePhase } : {}),
257
+ ...(payload.processStarted !== undefined
258
+ ? { lastProcessStarted: payload.processStarted }
259
+ : {}),
260
+ ...(payload.workspaceChanged !== undefined
261
+ ? { lastWorkspaceChanged: payload.workspaceChanged }
262
+ : {}),
263
+ ...(payload.externalSideEffects !== undefined
264
+ ? { lastExternalSideEffects: payload.externalSideEffects }
265
+ : {}),
266
+ ...(payload.retrySafety !== undefined ? { lastRetrySafety: payload.retrySafety } : {}),
256
267
  };
257
268
  if (payload.sentinel === 'BLOCKED' || payload.sentinel === 'FAILED') {
258
269
  nextContext.blockedFromStage = current.wake.stage;
@@ -26,6 +26,37 @@ export function createStaleRunReconciler(deps) {
26
26
  return 'SUPERSEDED';
27
27
  }
28
28
  }
29
+ function failurePhaseForLifecycle(lifecycle) {
30
+ switch (lifecycle) {
31
+ case 'CREATED':
32
+ case 'CLAIMED':
33
+ case 'PREPARING':
34
+ return 'workspace-prep';
35
+ case 'PROCESS_STARTING':
36
+ return 'process-starting';
37
+ case 'RUNNING':
38
+ case 'FINALISING':
39
+ return 'running';
40
+ case 'TERMINAL':
41
+ return 'unknown';
42
+ }
43
+ }
44
+ function classifyReconciledFailure(record) {
45
+ const processStarted = record.agentPid !== undefined ||
46
+ record.agentProcessStartedAt !== undefined ||
47
+ record.lifecycle === 'RUNNING' ||
48
+ record.lifecycle === 'FINALISING';
49
+ const metadata = record.metadata;
50
+ const workspaceChanged = typeof metadata?.workspacePath === 'string';
51
+ const retrySafety = processStarted || workspaceChanged ? 'REQUIRES_RECONCILIATION' : 'SAFE_TO_RETRY';
52
+ return {
53
+ failurePhase: failurePhaseForLifecycle(record.lifecycle),
54
+ processStarted,
55
+ workspaceChanged,
56
+ externalSideEffects: processStarted ? 'unknown' : 'none',
57
+ retrySafety,
58
+ };
59
+ }
29
60
  async function staleReason(record, now) {
30
61
  if (record.status !== 'running') {
31
62
  return null;
@@ -94,6 +125,11 @@ export function createStaleRunReconciler(deps) {
94
125
  action: projection.context.lastRunAction,
95
126
  sentinel: 'FAILED',
96
127
  executionOutcome: 'CANCELED_BY_RECONCILIATION',
128
+ failurePhase: 'unknown',
129
+ processStarted: false,
130
+ workspaceChanged: false,
131
+ externalSideEffects: 'unknown',
132
+ retrySafety: 'MANUAL_REVIEW_REQUIRED',
97
133
  runId,
98
134
  reason: 'runner:missing-run-record',
99
135
  },
@@ -146,6 +182,7 @@ export function createStaleRunReconciler(deps) {
146
182
  continue;
147
183
  }
148
184
  const staleExecutionOutcome = reason === 'timeout' ? 'TIMED_OUT' : recoveryOutcomeForLifecycle(record.lifecycle);
185
+ const failureContext = classifyReconciledFailure(record);
149
186
  await deps.stateStore.writeRunRecord({
150
187
  ...record,
151
188
  lifecycle: 'TERMINAL',
@@ -153,6 +190,7 @@ export function createStaleRunReconciler(deps) {
153
190
  finishedAt,
154
191
  sentinel: 'FAILED',
155
192
  executionOutcome: staleExecutionOutcome,
193
+ ...failureContext,
156
194
  summary: `Run exceeded timeout while marked running and was reconciled by a later tick.`,
157
195
  metadata: {
158
196
  ...record.metadata,
@@ -160,6 +198,7 @@ export function createStaleRunReconciler(deps) {
160
198
  recoveryLifecycle: record.lifecycle,
161
199
  staleReason: reason,
162
200
  timeoutMs: deps.runnerTimeoutMs(),
201
+ ...failureContext,
163
202
  },
164
203
  });
165
204
  const runCompletedEvent = createEventEnvelope({
@@ -181,6 +220,7 @@ export function createStaleRunReconciler(deps) {
181
220
  action: record.action,
182
221
  sentinel: 'FAILED',
183
222
  executionOutcome: staleExecutionOutcome,
223
+ ...failureContext,
184
224
  runId: record.runId,
185
225
  reason: reason === 'timeout'
186
226
  ? 'runner:stale-timeout'
@@ -52,6 +52,63 @@ function shouldPublishRunResult(input) {
52
52
  }
53
53
  return input.failureClass !== input.previousFailureClass;
54
54
  }
55
+ function hasConfirmedExternalSideEffect(projection) {
56
+ return projection.correlatedResources.some((resource) => /^[a-z0-9-]+:pr:/.test(resource.resourceUri));
57
+ }
58
+ function failurePhaseForRecord(record) {
59
+ if (record.metadata?.failureSource === 'wake-workspace-validation') {
60
+ return 'workspace-validation';
61
+ }
62
+ if (record.agentPid !== undefined || record.agentProcessStartedAt !== undefined) {
63
+ return 'running';
64
+ }
65
+ if (record.lifecycle === 'PROCESS_STARTING' || record.lifecycle === 'RUNNING') {
66
+ return 'process-starting';
67
+ }
68
+ if (record.lifecycle === 'PREPARING') {
69
+ return 'workspace-prep';
70
+ }
71
+ return 'unknown';
72
+ }
73
+ function isWorkspaceValidationFailure(error) {
74
+ return (error !== null &&
75
+ typeof error === 'object' &&
76
+ 'failureSource' in error &&
77
+ error.failureSource === 'wake-workspace-validation');
78
+ }
79
+ function classifyFailedRun(input) {
80
+ const processStarted = input.record.agentPid !== undefined || input.record.agentProcessStartedAt !== undefined;
81
+ const workspaceChanged = input.workspacePath !== undefined;
82
+ const externalSideEffects = hasConfirmedExternalSideEffect(input.projection)
83
+ ? 'confirmed'
84
+ : processStarted
85
+ ? 'unknown'
86
+ : 'none';
87
+ const failurePhase = input.failurePhase ??
88
+ (input.envelope === 'degraded' && input.sentinel === 'FAILED'
89
+ ? 'result-parsing'
90
+ : failurePhaseForRecord(input.record));
91
+ let retrySafety;
92
+ if (input.failureClass === 'task') {
93
+ retrySafety = 'NOT_RETRYABLE';
94
+ }
95
+ else if (externalSideEffects === 'confirmed' || workspaceChanged) {
96
+ retrySafety = 'REQUIRES_RECONCILIATION';
97
+ }
98
+ else if (processStarted) {
99
+ retrySafety = 'SAFE_TO_RESUME';
100
+ }
101
+ else {
102
+ retrySafety = 'SAFE_TO_RETRY';
103
+ }
104
+ return {
105
+ failurePhase,
106
+ processStarted,
107
+ workspaceChanged,
108
+ externalSideEffects,
109
+ retrySafety,
110
+ };
111
+ }
55
112
  export function createTickRunner(deps) {
56
113
  const policy = createPolicyEngine();
57
114
  const lifecycle = createLifecycleService();
@@ -1221,6 +1278,9 @@ export function createTickRunner(deps) {
1221
1278
  ...preparedRecord.metadata,
1222
1279
  ...(workspacePath === undefined ? {} : { workspacePath }),
1223
1280
  workspaceMode,
1281
+ ...(prepareResult.validation === undefined
1282
+ ? {}
1283
+ : { workspaceValidation: prepareResult.validation }),
1224
1284
  },
1225
1285
  });
1226
1286
  const recentEvents = await deps.stateStore.listEventEnvelopesForWorkItem(candidate.workItemKey, 6);
@@ -1350,6 +1410,22 @@ export function createTickRunner(deps) {
1350
1410
  ? null
1351
1411
  : lifecycle.nextStageFromSentinel(claimedStage, sentinel, workflow);
1352
1412
  const finishedAt = deps.clock.now().toISOString();
1413
+ let workspaceBookkeeping;
1414
+ if (workspacePath !== undefined) {
1415
+ try {
1416
+ workspaceBookkeeping = {
1417
+ status: 'recorded',
1418
+ result: await deps.workspaceManager.recordWorkspaceBookkeeping({ workspacePath }),
1419
+ };
1420
+ }
1421
+ catch (error) {
1422
+ workspaceBookkeeping = {
1423
+ status: 'failed',
1424
+ failureSource: 'wake-workspace-bookkeeping',
1425
+ error: error instanceof Error ? error.message : String(error),
1426
+ };
1427
+ }
1428
+ }
1353
1429
  let prReviewTargetResourceUri = null;
1354
1430
  if (watcherRun) {
1355
1431
  // Artifact correlation is intentionally scoped to any watcher-dispatched
@@ -1464,6 +1540,16 @@ export function createTickRunner(deps) {
1464
1540
  : undefined;
1465
1541
  await transitionRunLifecycle('FINALISING');
1466
1542
  const finalisingRecord = (await deps.stateStore.readRunRecord(runId));
1543
+ const failureContext = sentinel === 'FAILED'
1544
+ ? classifyFailedRun({
1545
+ projection: candidate,
1546
+ record: finalisingRecord,
1547
+ failureClass: runnerResult.failureClass ?? 'task',
1548
+ ...(workspacePath === undefined ? {} : { workspacePath }),
1549
+ envelope: parsedRunnerResult.envelope,
1550
+ sentinel,
1551
+ })
1552
+ : undefined;
1467
1553
  await deps.stateStore.writeRunRecord({
1468
1554
  ...finalisingRecord,
1469
1555
  lifecycle: 'TERMINAL',
@@ -1479,11 +1565,13 @@ export function createTickRunner(deps) {
1479
1565
  sentinel,
1480
1566
  executionOutcome,
1481
1567
  ...(workflowOutcome !== undefined ? { workflowOutcome } : {}),
1568
+ ...(failureContext === undefined ? {} : failureContext),
1482
1569
  summary: parsedRunnerResult.body,
1483
1570
  ...(runnerResult.routing === undefined ? {} : { routing: runnerResult.routing }),
1484
1571
  ...(runnerResult.tokenUsage === undefined ? {} : { tokenUsage: runnerResult.tokenUsage }),
1485
1572
  metadata: {
1486
1573
  ...finalisingRecord.metadata,
1574
+ ...(workspaceBookkeeping === undefined ? {} : { workspaceBookkeeping }),
1487
1575
  ...resultMetadata,
1488
1576
  },
1489
1577
  });
@@ -1519,6 +1607,7 @@ export function createTickRunner(deps) {
1519
1607
  ...(runnerResult.failureClass === undefined
1520
1608
  ? {}
1521
1609
  : { failureClass: runnerResult.failureClass }),
1610
+ ...(failureContext === undefined ? {} : failureContext),
1522
1611
  // Only mark the triggering comment handled when the run reached the
1523
1612
  // agent and produced a real outcome. Quota/infra failures are transient
1524
1613
  // blips, not an answer to the human's comment — leaving handledCommentId
@@ -1612,6 +1701,21 @@ export function createTickRunner(deps) {
1612
1701
  const finishedAt = deps.clock.now().toISOString();
1613
1702
  const sentinel = 'FAILED';
1614
1703
  const failedRecord = (await deps.stateStore.readRunRecord(runId)) ?? runningRecord;
1704
+ const failedRecordMetadata = failedRecord.metadata;
1705
+ const failedRecordWorkspacePath = typeof failedRecordMetadata?.workspacePath === 'string'
1706
+ ? failedRecordMetadata.workspacePath
1707
+ : undefined;
1708
+ const failureContext = classifyFailedRun({
1709
+ projection: candidate,
1710
+ record: failedRecord,
1711
+ failureClass: 'infra',
1712
+ ...(isWorkspaceValidationFailure(err)
1713
+ ? { failurePhase: 'workspace-validation' }
1714
+ : {}),
1715
+ ...(failedRecordWorkspacePath === undefined
1716
+ ? {}
1717
+ : { workspacePath: failedRecordWorkspacePath }),
1718
+ });
1615
1719
  await deps.stateStore.writeRunRecord({
1616
1720
  ...failedRecord,
1617
1721
  lifecycle: 'TERMINAL',
@@ -1619,10 +1723,15 @@ export function createTickRunner(deps) {
1619
1723
  finishedAt,
1620
1724
  sentinel,
1621
1725
  executionOutcome: 'PROCESS_FAILED',
1726
+ ...failureContext,
1622
1727
  summary: err instanceof Error ? err.message : String(err),
1623
1728
  metadata: {
1624
1729
  ...failedRecord.metadata,
1625
1730
  failureClass: 'infra',
1731
+ ...(isWorkspaceValidationFailure(err)
1732
+ ? { failureSource: 'wake-workspace-validation' }
1733
+ : {}),
1734
+ ...failureContext,
1626
1735
  },
1627
1736
  });
1628
1737
  const runCompletedEvent = createEventEnvelope({
@@ -1646,6 +1755,7 @@ export function createTickRunner(deps) {
1646
1755
  runId,
1647
1756
  reason: 'runner:infrastructure-error',
1648
1757
  failureClass: 'infra',
1758
+ ...failureContext,
1649
1759
  executionOutcome: 'PROCESS_FAILED',
1650
1760
  // Deliberately omit handledCommentId: an infra blip (CLI crash, timeout,
1651
1761
  // network error) never reached the agent, so it isn't an answer to the
@@ -326,6 +326,23 @@ const runLeaseSchema = z.object({
326
326
  lastRenewedAt: isoTimestampSchema,
327
327
  expiresAt: isoTimestampSchema,
328
328
  });
329
+ export const failurePhaseSchema = z.enum([
330
+ 'workspace-validation',
331
+ 'workspace-prep',
332
+ 'process-starting',
333
+ 'running',
334
+ 'result-parsing',
335
+ 'publishing',
336
+ 'unknown',
337
+ ]);
338
+ export const externalSideEffectsSchema = z.enum(['none', 'confirmed', 'unknown']);
339
+ export const retrySafetySchema = z.enum([
340
+ 'SAFE_TO_RETRY',
341
+ 'SAFE_TO_RESUME',
342
+ 'REQUIRES_RECONCILIATION',
343
+ 'MANUAL_REVIEW_REQUIRED',
344
+ 'NOT_RETRYABLE',
345
+ ]);
329
346
  function legacyRunLifecycle(input) {
330
347
  if (input.lifecycle !== undefined) {
331
348
  return input;
@@ -369,6 +386,11 @@ export const runRecordSchema = z.preprocess((input) => {
369
386
  sentinel: runnerSentinelSchema.optional(),
370
387
  executionOutcome: executionOutcomeSchema.optional(),
371
388
  workflowOutcome: workflowOutcomeSchema.optional(),
389
+ failurePhase: failurePhaseSchema.optional(),
390
+ processStarted: z.boolean().optional(),
391
+ workspaceChanged: z.boolean().optional(),
392
+ externalSideEffects: externalSideEffectsSchema.optional(),
393
+ retrySafety: retrySafetySchema.optional(),
372
394
  summary: z.string().optional(),
373
395
  routing: runnerRoutingSchema.optional(),
374
396
  lease: runLeaseSchema.optional(),
@@ -124,4 +124,4 @@ export function resolveWakeVersion(options = {}) {
124
124
  }
125
125
  return '0.1.0-dev';
126
126
  }
127
- export const wakeVersion = "g1a18fca";
127
+ export const wakeVersion = "g6c20d18";
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@atolis-hq/wake",
3
- "version": "0.2.51",
3
+ "version": "0.2.53",
4
4
  "description": "Local autonomous agent control plane for software development",
5
5
  "license": "Apache-2.0",
6
6
  "repository": {