@atolis-hq/wake 0.2.53 → 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.
@@ -493,6 +493,25 @@ export function createTickRunner(deps) {
493
493
  function runnerTimeoutMs() {
494
494
  return maxConfiguredRunnerTimeoutMs(deps.config);
495
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
+ }
496
515
  function cancellationReasonForIneligibleProjection(projection) {
497
516
  if (projection === null || projection.issue.state !== 'open') {
498
517
  return 'CANCELED_BY_SOURCE_CLOSED';
@@ -710,6 +729,15 @@ export function createTickRunner(deps) {
710
729
  }
711
730
  async function nextWatcherDispatch(projections, now) {
712
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;
713
741
  const parentWorkflow = workflowForProjection(projection, deps.config);
714
742
  if (parentWorkflow === null)
715
743
  continue;
@@ -862,6 +890,33 @@ export function createTickRunner(deps) {
862
890
  if (candidate === undefined) {
863
891
  return { status: 'idle' };
864
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
+ }
865
920
  let sourceRevision = projectedSourceRevision(candidate);
866
921
  let refresh;
867
922
  try {
@@ -597,8 +597,27 @@ const wakeConfigBaseSchema = z.object({
597
597
  .int()
598
598
  .positive()
599
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 }),
600
615
  })
601
- .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
+ }),
602
621
  transcripts: z
603
622
  .object({
604
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 = "g6c20d18";
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.53",
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": {