@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 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
@@ -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({ intervalMs: 60 * 1000, maxIntervalMs: 5 * 60 * 1000 }),
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),
@@ -124,4 +124,4 @@ export function resolveWakeVersion(options = {}) {
124
124
  }
125
125
  return '0.1.0-dev';
126
126
  }
127
- export const wakeVersion = "g572b993";
127
+ export const wakeVersion = "g950074e";
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@atolis-hq/wake",
3
- "version": "0.2.52",
3
+ "version": "0.2.54",
4
4
  "description": "Local autonomous agent control plane for software development",
5
5
  "license": "Apache-2.0",
6
6
  "repository": {