@atolis-hq/wake 0.2.52 → 0.2.54
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
|
|
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
|
|
@@ -56,6 +56,9 @@ function hasConfirmedExternalSideEffect(projection) {
|
|
|
56
56
|
return projection.correlatedResources.some((resource) => /^[a-z0-9-]+:pr:/.test(resource.resourceUri));
|
|
57
57
|
}
|
|
58
58
|
function failurePhaseForRecord(record) {
|
|
59
|
+
if (record.metadata?.failureSource === 'wake-workspace-validation') {
|
|
60
|
+
return 'workspace-validation';
|
|
61
|
+
}
|
|
59
62
|
if (record.agentPid !== undefined || record.agentProcessStartedAt !== undefined) {
|
|
60
63
|
return 'running';
|
|
61
64
|
}
|
|
@@ -67,6 +70,12 @@ function failurePhaseForRecord(record) {
|
|
|
67
70
|
}
|
|
68
71
|
return 'unknown';
|
|
69
72
|
}
|
|
73
|
+
function isWorkspaceValidationFailure(error) {
|
|
74
|
+
return (error !== null &&
|
|
75
|
+
typeof error === 'object' &&
|
|
76
|
+
'failureSource' in error &&
|
|
77
|
+
error.failureSource === 'wake-workspace-validation');
|
|
78
|
+
}
|
|
70
79
|
function classifyFailedRun(input) {
|
|
71
80
|
const processStarted = input.record.agentPid !== undefined || input.record.agentProcessStartedAt !== undefined;
|
|
72
81
|
const workspaceChanged = input.workspacePath !== undefined;
|
|
@@ -484,6 +493,25 @@ export function createTickRunner(deps) {
|
|
|
484
493
|
function runnerTimeoutMs() {
|
|
485
494
|
return maxConfiguredRunnerTimeoutMs(deps.config);
|
|
486
495
|
}
|
|
496
|
+
// Counted from durable run records (never an in-memory counter, per the
|
|
497
|
+
// "tick is a pure function of durable state" invariant), so this holds
|
|
498
|
+
// across process restarts and is a backstop independent of any specific
|
|
499
|
+
// dispatch-eligibility bug (stuck watcher, misconfigured cron, a dedupe gap
|
|
500
|
+
// not yet caught) - it caps total damage rather than preventing a cause.
|
|
501
|
+
async function exceedsDispatchRateLimit(now) {
|
|
502
|
+
const { windowMs, maxDispatches } = deps.config.scheduler.dispatchRateLimit;
|
|
503
|
+
const windowStartMs = now.getTime() - windowMs;
|
|
504
|
+
const runRecords = await deps.stateStore.listRunRecords();
|
|
505
|
+
const recentCount = runRecords.reduce((count, record) => {
|
|
506
|
+
const startedAtMs = Date.parse(record.startedAt);
|
|
507
|
+
return Number.isFinite(startedAtMs) &&
|
|
508
|
+
startedAtMs >= windowStartMs &&
|
|
509
|
+
startedAtMs <= now.getTime()
|
|
510
|
+
? count + 1
|
|
511
|
+
: count;
|
|
512
|
+
}, 0);
|
|
513
|
+
return recentCount >= maxDispatches;
|
|
514
|
+
}
|
|
487
515
|
function cancellationReasonForIneligibleProjection(projection) {
|
|
488
516
|
if (projection === null || projection.issue.state !== 'open') {
|
|
489
517
|
return 'CANCELED_BY_SOURCE_CLOSED';
|
|
@@ -701,6 +729,15 @@ export function createTickRunner(deps) {
|
|
|
701
729
|
}
|
|
702
730
|
async function nextWatcherDispatch(projections, now) {
|
|
703
731
|
for (const projection of projections) {
|
|
732
|
+
// A closed issue can still carry a stale status label (e.g. squash-merged
|
|
733
|
+
// outside Wake's own merge gate, before label reconciliation caught up),
|
|
734
|
+
// so this must be checked explicitly - unlike deriveWatchlist and
|
|
735
|
+
// cancellationReasonForIneligibleProjection, watcher dispatch has no
|
|
736
|
+
// other path that excludes closed issues. Without this, a closed issue
|
|
737
|
+
// whose last-seen local status matches `watcher.while.status` (e.g.
|
|
738
|
+
// awaiting-approval) re-fires its watcher workflow every tick forever.
|
|
739
|
+
if (projection.issue.state !== 'open')
|
|
740
|
+
continue;
|
|
704
741
|
const parentWorkflow = workflowForProjection(projection, deps.config);
|
|
705
742
|
if (parentWorkflow === null)
|
|
706
743
|
continue;
|
|
@@ -853,6 +890,33 @@ export function createTickRunner(deps) {
|
|
|
853
890
|
if (candidate === undefined) {
|
|
854
891
|
return { status: 'idle' };
|
|
855
892
|
}
|
|
893
|
+
if (await exceedsDispatchRateLimit(tickStartedAt)) {
|
|
894
|
+
const rateLimitedWorkflowName = workflowNameForProjection(candidate, deps.config);
|
|
895
|
+
const rateLimitedWorkflow = workflowForProjection(candidate, deps.config);
|
|
896
|
+
const blockedAt = eventStampNow();
|
|
897
|
+
await appendAuditEvent({
|
|
898
|
+
eventId: `dispatch-rate-limited-${candidate.workItemKey}-${tickStartedAt.getTime()}`,
|
|
899
|
+
decisionType: 'dispatch.rate-limited',
|
|
900
|
+
workItemKey: candidate.workItemKey,
|
|
901
|
+
runId: `dispatch-rate-limited-${candidate.workItemKey}-${tickStartedAt.getTime()}`,
|
|
902
|
+
workflowRevision: rateLimitedWorkflow === null
|
|
903
|
+
? 'sha256:unavailable'
|
|
904
|
+
: await computeWorkflowRevision({
|
|
905
|
+
config: deps.config,
|
|
906
|
+
workflowName: rateLimitedWorkflowName,
|
|
907
|
+
workflow: rateLimitedWorkflow,
|
|
908
|
+
}),
|
|
909
|
+
inputsConsidered: {
|
|
910
|
+
windowMs: deps.config.scheduler.dispatchRateLimit.windowMs,
|
|
911
|
+
maxDispatches: deps.config.scheduler.dispatchRateLimit.maxDispatches,
|
|
912
|
+
watcherRun,
|
|
913
|
+
},
|
|
914
|
+
outcome: { dispatched: false, reason: 'dispatch-rate-limit-exceeded' },
|
|
915
|
+
timestamp: blockedAt,
|
|
916
|
+
sourceRefs: { repo: candidate.issue.repo, issueNumber: candidate.issue.number },
|
|
917
|
+
});
|
|
918
|
+
return { status: 'idle' };
|
|
919
|
+
}
|
|
856
920
|
let sourceRevision = projectedSourceRevision(candidate);
|
|
857
921
|
let refresh;
|
|
858
922
|
try {
|
|
@@ -1269,6 +1333,9 @@ export function createTickRunner(deps) {
|
|
|
1269
1333
|
...preparedRecord.metadata,
|
|
1270
1334
|
...(workspacePath === undefined ? {} : { workspacePath }),
|
|
1271
1335
|
workspaceMode,
|
|
1336
|
+
...(prepareResult.validation === undefined
|
|
1337
|
+
? {}
|
|
1338
|
+
: { workspaceValidation: prepareResult.validation }),
|
|
1272
1339
|
},
|
|
1273
1340
|
});
|
|
1274
1341
|
const recentEvents = await deps.stateStore.listEventEnvelopesForWorkItem(candidate.workItemKey, 6);
|
|
@@ -1398,6 +1465,22 @@ export function createTickRunner(deps) {
|
|
|
1398
1465
|
? null
|
|
1399
1466
|
: lifecycle.nextStageFromSentinel(claimedStage, sentinel, workflow);
|
|
1400
1467
|
const finishedAt = deps.clock.now().toISOString();
|
|
1468
|
+
let workspaceBookkeeping;
|
|
1469
|
+
if (workspacePath !== undefined) {
|
|
1470
|
+
try {
|
|
1471
|
+
workspaceBookkeeping = {
|
|
1472
|
+
status: 'recorded',
|
|
1473
|
+
result: await deps.workspaceManager.recordWorkspaceBookkeeping({ workspacePath }),
|
|
1474
|
+
};
|
|
1475
|
+
}
|
|
1476
|
+
catch (error) {
|
|
1477
|
+
workspaceBookkeeping = {
|
|
1478
|
+
status: 'failed',
|
|
1479
|
+
failureSource: 'wake-workspace-bookkeeping',
|
|
1480
|
+
error: error instanceof Error ? error.message : String(error),
|
|
1481
|
+
};
|
|
1482
|
+
}
|
|
1483
|
+
}
|
|
1401
1484
|
let prReviewTargetResourceUri = null;
|
|
1402
1485
|
if (watcherRun) {
|
|
1403
1486
|
// Artifact correlation is intentionally scoped to any watcher-dispatched
|
|
@@ -1543,6 +1626,7 @@ export function createTickRunner(deps) {
|
|
|
1543
1626
|
...(runnerResult.tokenUsage === undefined ? {} : { tokenUsage: runnerResult.tokenUsage }),
|
|
1544
1627
|
metadata: {
|
|
1545
1628
|
...finalisingRecord.metadata,
|
|
1629
|
+
...(workspaceBookkeeping === undefined ? {} : { workspaceBookkeeping }),
|
|
1546
1630
|
...resultMetadata,
|
|
1547
1631
|
},
|
|
1548
1632
|
});
|
|
@@ -1680,6 +1764,9 @@ export function createTickRunner(deps) {
|
|
|
1680
1764
|
projection: candidate,
|
|
1681
1765
|
record: failedRecord,
|
|
1682
1766
|
failureClass: 'infra',
|
|
1767
|
+
...(isWorkspaceValidationFailure(err)
|
|
1768
|
+
? { failurePhase: 'workspace-validation' }
|
|
1769
|
+
: {}),
|
|
1683
1770
|
...(failedRecordWorkspacePath === undefined
|
|
1684
1771
|
? {}
|
|
1685
1772
|
: { workspacePath: failedRecordWorkspacePath }),
|
|
@@ -1696,6 +1783,9 @@ export function createTickRunner(deps) {
|
|
|
1696
1783
|
metadata: {
|
|
1697
1784
|
...failedRecord.metadata,
|
|
1698
1785
|
failureClass: 'infra',
|
|
1786
|
+
...(isWorkspaceValidationFailure(err)
|
|
1787
|
+
? { failureSource: 'wake-workspace-validation' }
|
|
1788
|
+
: {}),
|
|
1699
1789
|
...failureContext,
|
|
1700
1790
|
},
|
|
1701
1791
|
});
|
|
@@ -327,6 +327,7 @@ const runLeaseSchema = z.object({
|
|
|
327
327
|
expiresAt: isoTimestampSchema,
|
|
328
328
|
});
|
|
329
329
|
export const failurePhaseSchema = z.enum([
|
|
330
|
+
'workspace-validation',
|
|
330
331
|
'workspace-prep',
|
|
331
332
|
'process-starting',
|
|
332
333
|
'running',
|
|
@@ -596,8 +597,27 @@ const wakeConfigBaseSchema = z.object({
|
|
|
596
597
|
.int()
|
|
597
598
|
.positive()
|
|
598
599
|
.default(5 * 60 * 1000),
|
|
600
|
+
// Deterministic circuit breaker independent of any specific dispatch-eligibility
|
|
601
|
+
// bug: caps total runner invocations (main path + watchers) in a trailing
|
|
602
|
+
// window, counted from durable run records, not in-memory state. A storm
|
|
603
|
+
// (stuck watcher, misconfigured cron, dedupe bug) hits this ceiling and idles
|
|
604
|
+
// instead of compounding cost indefinitely.
|
|
605
|
+
dispatchRateLimit: z
|
|
606
|
+
.object({
|
|
607
|
+
windowMs: z
|
|
608
|
+
.number()
|
|
609
|
+
.int()
|
|
610
|
+
.positive()
|
|
611
|
+
.default(60 * 60 * 1000),
|
|
612
|
+
maxDispatches: z.number().int().positive().default(20),
|
|
613
|
+
})
|
|
614
|
+
.default({ windowMs: 60 * 60 * 1000, maxDispatches: 20 }),
|
|
599
615
|
})
|
|
600
|
-
.default({
|
|
616
|
+
.default({
|
|
617
|
+
intervalMs: 60 * 1000,
|
|
618
|
+
maxIntervalMs: 5 * 60 * 1000,
|
|
619
|
+
dispatchRateLimit: { windowMs: 60 * 60 * 1000, maxDispatches: 20 },
|
|
620
|
+
}),
|
|
601
621
|
transcripts: z
|
|
602
622
|
.object({
|
|
603
623
|
enabled: z.boolean().default(false),
|
package/dist/src/version.js
CHANGED