@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.
- package/dist/src/core/tick-runner.js +55 -0
- package/dist/src/domain/schema.js +20 -1
- package/dist/src/version.js +1 -1
- package/package.json +1 -1
|
@@ -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({
|
|
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),
|
package/dist/src/version.js
CHANGED