@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.
- package/dist/src/adapters/fake/fake-workspace-manager.js +58 -4
- package/dist/src/adapters/git/git-workspace-manager.js +122 -4
- package/dist/src/core/policy-engine.js +5 -5
- package/dist/src/core/projection-updater.js +11 -0
- package/dist/src/core/stale-run-reconciler.js +40 -0
- package/dist/src/core/tick-runner.js +110 -0
- package/dist/src/domain/schema.js +22 -0
- package/dist/src/version.js +1 -1
- package/package.json +1 -1
|
@@ -1,17 +1,71 @@
|
|
|
1
1
|
import { mkdir, rm } from 'node:fs/promises';
|
|
2
2
|
import { join } from 'node:path';
|
|
3
|
-
export
|
|
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 {
|
|
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 {
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
|
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(),
|
package/dist/src/version.js
CHANGED