@zenspc/pi-pstack 0.1.0

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.
Files changed (128) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +49 -0
  3. package/agents/comment-sicko.md +34 -0
  4. package/agents/poteto-agent.md +13 -0
  5. package/extensions/pstack/config.ts +228 -0
  6. package/extensions/pstack/index.ts +211 -0
  7. package/package.json +57 -0
  8. package/skills/architect/SKILL.md +82 -0
  9. package/skills/architect/references/design-red-flags.md +33 -0
  10. package/skills/architect/references/rationale-template.md +35 -0
  11. package/skills/architect/references/runner-prompt.md +20 -0
  12. package/skills/arena/SKILL.md +70 -0
  13. package/skills/automate-me/SKILL.md +114 -0
  14. package/skills/blast-radius/SKILL.md +49 -0
  15. package/skills/bro/SKILL.md +6 -0
  16. package/skills/create-verification-skill/SKILL.md +43 -0
  17. package/skills/create-verification-skill/references/feature-map-example/README.md +47 -0
  18. package/skills/create-verification-skill/references/feature-map-example/create-note.md +39 -0
  19. package/skills/create-verification-skill/references/feature-map-example/search.md +45 -0
  20. package/skills/figure-it-out/SKILL.md +54 -0
  21. package/skills/how/SKILL.md +134 -0
  22. package/skills/how/references/critic-prompt.md +59 -0
  23. package/skills/how/references/critique-rubric.md +58 -0
  24. package/skills/how/references/explainer-prompt.md +55 -0
  25. package/skills/how/references/explorer-prompt.md +52 -0
  26. package/skills/interrogate/SKILL.md +109 -0
  27. package/skills/interrogate/references/code-quality-review.md +47 -0
  28. package/skills/interrogate/references/lead-judgment.md +58 -0
  29. package/skills/interrogate/references/reviewer-prompt.md +72 -0
  30. package/skills/interrogate/references/rubric.md +77 -0
  31. package/skills/maintain-verification-skill/SKILL.md +38 -0
  32. package/skills/no-comments/SKILL.md +23 -0
  33. package/skills/poteto-mode/SKILL.md +140 -0
  34. package/skills/poteto-mode/playbooks/authoring-a-skill.md +12 -0
  35. package/skills/poteto-mode/playbooks/autonomous-run.md +13 -0
  36. package/skills/poteto-mode/playbooks/autopilot-full.md +13 -0
  37. package/skills/poteto-mode/playbooks/autopilot-stack.md +31 -0
  38. package/skills/poteto-mode/playbooks/babysit.md +27 -0
  39. package/skills/poteto-mode/playbooks/bug-fix.md +17 -0
  40. package/skills/poteto-mode/playbooks/eval.md +27 -0
  41. package/skills/poteto-mode/playbooks/feature.md +21 -0
  42. package/skills/poteto-mode/playbooks/hillclimb.md +21 -0
  43. package/skills/poteto-mode/playbooks/investigation.md +14 -0
  44. package/skills/poteto-mode/playbooks/multi-phase-plan.md +3 -0
  45. package/skills/poteto-mode/playbooks/opening-a-pr.md +35 -0
  46. package/skills/poteto-mode/playbooks/orchestrate.md +113 -0
  47. package/skills/poteto-mode/playbooks/pause-safely.md +10 -0
  48. package/skills/poteto-mode/playbooks/perf-issue.md +24 -0
  49. package/skills/poteto-mode/playbooks/prototype.md +14 -0
  50. package/skills/poteto-mode/playbooks/refactoring.md +16 -0
  51. package/skills/poteto-mode/playbooks/runtime-forensics.md +11 -0
  52. package/skills/poteto-mode/playbooks/session-pickup.md +13 -0
  53. package/skills/poteto-mode/playbooks/shipping.md +40 -0
  54. package/skills/poteto-mode/playbooks/trace-forensics.md +14 -0
  55. package/skills/poteto-mode/playbooks/visual-parity.md +11 -0
  56. package/skills/poteto-mode/playbooks/worktree-cleanup.md +14 -0
  57. package/skills/poteto-mode/references/bugbot-triage.md +142 -0
  58. package/skills/poteto-mode/references/plan.md +105 -0
  59. package/skills/poteto-mode/scripts/bootstrap.ts +62 -0
  60. package/skills/poteto-mode/scripts/bun.lock +67 -0
  61. package/skills/poteto-mode/scripts/orch/orch.test.ts +634 -0
  62. package/skills/poteto-mode/scripts/orch/orch.ts +578 -0
  63. package/skills/poteto-mode/scripts/orch/store.ts +1607 -0
  64. package/skills/poteto-mode/scripts/package.json +16 -0
  65. package/skills/poteto-mode/scripts/watch-pr/cli.test.ts +224 -0
  66. package/skills/poteto-mode/scripts/watch-pr/cli.ts +223 -0
  67. package/skills/poteto-mode/scripts/watch-pr/fakes.test-helper.ts +118 -0
  68. package/skills/poteto-mode/scripts/watch-pr/github.test.ts +306 -0
  69. package/skills/poteto-mode/scripts/watch-pr/github.ts +699 -0
  70. package/skills/poteto-mode/scripts/watch-pr/policy.test.ts +420 -0
  71. package/skills/poteto-mode/scripts/watch-pr/policy.ts +832 -0
  72. package/skills/poteto-mode/scripts/watch-pr/render.ts +169 -0
  73. package/skills/poteto-mode/scripts/watch-pr/tsconfig.json +13 -0
  74. package/skills/poteto-mode/scripts/watch-pr/types.compile.ts +93 -0
  75. package/skills/poteto-mode/scripts/watch-pr/types.ts +401 -0
  76. package/skills/poteto-mode/scripts/watch-pr/watch-pr +6 -0
  77. package/skills/poteto-mode/scripts/worktree-audit.sh +85 -0
  78. package/skills/principle-boundary-discipline/SKILL.md +33 -0
  79. package/skills/principle-build-the-lever/SKILL.md +22 -0
  80. package/skills/principle-encode-lessons-in-structure/SKILL.md +30 -0
  81. package/skills/principle-exhaust-the-design-space/SKILL.md +20 -0
  82. package/skills/principle-experience-first/SKILL.md +18 -0
  83. package/skills/principle-fix-root-causes/SKILL.md +22 -0
  84. package/skills/principle-foundational-thinking/SKILL.md +20 -0
  85. package/skills/principle-guard-the-context-window/SKILL.md +16 -0
  86. package/skills/principle-laziness-protocol/SKILL.md +17 -0
  87. package/skills/principle-make-operations-idempotent/SKILL.md +23 -0
  88. package/skills/principle-migrate-callers-then-delete-legacy-apis/SKILL.md +21 -0
  89. package/skills/principle-minimize-reader-load/SKILL.md +22 -0
  90. package/skills/principle-model-the-domain/SKILL.md +25 -0
  91. package/skills/principle-never-block-on-the-human/SKILL.md +22 -0
  92. package/skills/principle-outcome-oriented-execution/SKILL.md +21 -0
  93. package/skills/principle-prove-it-works/SKILL.md +32 -0
  94. package/skills/principle-redesign-from-first-principles/SKILL.md +15 -0
  95. package/skills/principle-separate-before-serializing-shared-state/SKILL.md +15 -0
  96. package/skills/principle-sequence-verifiable-units/SKILL.md +21 -0
  97. package/skills/principle-subtract-before-you-add/SKILL.md +21 -0
  98. package/skills/principle-type-system-discipline/SKILL.md +30 -0
  99. package/skills/recall/SKILL.md +40 -0
  100. package/skills/reflect/SKILL.md +77 -0
  101. package/skills/reflect/references/divergent-reviewer.md +43 -0
  102. package/skills/reflect/references/judgment-reviewer.md +42 -0
  103. package/skills/reflect/references/synthesizer.md +56 -0
  104. package/skills/reflect/references/tooling-reviewer.md +57 -0
  105. package/skills/setup-pstack/SKILL.md +28 -0
  106. package/skills/show-me-your-work/SKILL.md +89 -0
  107. package/skills/show-me-your-work/references/decision-log-template.tsv +1 -0
  108. package/skills/show-me-your-work/scripts/log.sh +40 -0
  109. package/skills/swarm/SKILL.md +45 -0
  110. package/skills/tdd/SKILL.md +43 -0
  111. package/skills/teach/SKILL.md +20 -0
  112. package/skills/technical-writing/SKILL.md +129 -0
  113. package/skills/typescript-best-practices/SKILL.md +28 -0
  114. package/skills/typescript-best-practices/references/patterns.md +292 -0
  115. package/skills/unslop/SKILL.md +80 -0
  116. package/skills/why/SKILL.md +229 -0
  117. package/skills/why/references/epistemics.md +144 -0
  118. package/skills/why/references/investigator-prompt.md +103 -0
  119. package/skills/why/references/source-playbook.md +17 -0
  120. package/skills/why/references/sources/code-archaeology.md +88 -0
  121. package/skills/why/references/sources/databricks.md +70 -0
  122. package/skills/why/references/sources/datadog.md +99 -0
  123. package/skills/why/references/sources/incident-postmortem.md +15 -0
  124. package/skills/why/references/sources/linear.md +48 -0
  125. package/skills/why/references/sources/notion.md +55 -0
  126. package/skills/why/references/sources/sentry.md +100 -0
  127. package/skills/why/references/sources/slack.md +54 -0
  128. package/skills/why/references/synthesizer-prompt.md +135 -0
@@ -0,0 +1,832 @@
1
+ import { WatcherQueryError, resolveChecks } from "./github.ts";
2
+ import type * as T from "./types.ts";
3
+ import { nonEmpty } from "./types.ts";
4
+ export function assessGitHubMerge(args: {
5
+ readonly mergeStateStatus: T.MergeStateStatus;
6
+ readonly headRollupState: T.RollupState;
7
+ }): T.GitHubMergeAssessment {
8
+ if (args.mergeStateStatus === "BLOCKED") {
9
+ if (args.headRollupState === "ERROR" || args.headRollupState === "FAILURE")
10
+ return {
11
+ kind: "refused",
12
+ mergeStateStatus: args.mergeStateStatus,
13
+ headRollupState: args.headRollupState,
14
+ };
15
+ return {
16
+ kind: "allowed",
17
+ basis: "rollup",
18
+ mergeStateStatus: args.mergeStateStatus,
19
+ headRollupState: args.headRollupState,
20
+ };
21
+ }
22
+ return {
23
+ kind: "allowed",
24
+ basis: "merge-state",
25
+ mergeStateStatus: args.mergeStateStatus,
26
+ headRollupState: args.headRollupState,
27
+ };
28
+ }
29
+ async function mergeAssessment(
30
+ reader: T.GitHubReader,
31
+ facts: T.PullRequestFacts
32
+ ) {
33
+ const commits = await reader.commitRollups(facts.context);
34
+ const headRollupState =
35
+ facts.headRefOid === null
36
+ ? null
37
+ : (commits.find((commit) => commit.oid === facts.headRefOid)?.state ??
38
+ null);
39
+ return {
40
+ hadPreviousPassingCi: commits.some(
41
+ (commit) => commit.oid !== facts.headRefOid && commit.state === "SUCCESS"
42
+ ),
43
+ github: assessGitHubMerge({
44
+ mergeStateStatus: facts.mergeStateStatus,
45
+ headRollupState,
46
+ }),
47
+ };
48
+ }
49
+ const AUTOMATION_TOKENS = [
50
+ "bugbot",
51
+ "security review",
52
+ "pr review automation",
53
+ "review automation",
54
+ ] as const;
55
+ export async function readSnapshot(args: {
56
+ readonly reader: T.GitHubReader;
57
+ readonly context: T.PrContext;
58
+ readonly pendingHistory: "include" | "omit";
59
+ readonly allowDraft: boolean;
60
+ }): Promise<T.PrSnapshot> {
61
+ const facts = await args.reader.pullRequest(args.context);
62
+ if (facts.state === "MERGED" || facts.mergedAt !== null)
63
+ return { kind: "merged", context: args.context, facts };
64
+ if (facts.state === "CLOSED")
65
+ return { kind: "closed", context: args.context, facts };
66
+ const threads = await args.reader.reviewThreads(args.context);
67
+ const checks = await resolveChecks(args.reader, args.context);
68
+ const failed = nonEmpty(
69
+ checks.checks.filter(
70
+ (check): check is T.FailedCheck => check.kind === "failed"
71
+ )
72
+ );
73
+ const pending = nonEmpty(
74
+ checks.checks.filter(
75
+ (check): check is T.PendingCheck => check.kind === "pending"
76
+ )
77
+ );
78
+ let ci: T.CiState;
79
+ if (failed === null && pending !== null && args.pendingHistory === "omit")
80
+ ci = {
81
+ kind: "ci-pending",
82
+ source: checks.source,
83
+ all: checks.checks,
84
+ failed: [],
85
+ pending,
86
+ hadPreviousPassingCi: false,
87
+ };
88
+ else {
89
+ const merge = await mergeAssessment(args.reader, facts);
90
+ const base = {
91
+ source: checks.source,
92
+ all: checks.checks,
93
+ hadPreviousPassingCi: merge.hadPreviousPassingCi,
94
+ };
95
+ if (failed !== null)
96
+ ci = {
97
+ ...base,
98
+ kind: "ci-failing",
99
+ failed,
100
+ pending: pending ?? [],
101
+ github: merge.github,
102
+ };
103
+ else if (merge.github.kind === "refused")
104
+ ci = {
105
+ ...base,
106
+ kind: "ci-github-rejected",
107
+ failed: [],
108
+ pending: pending ?? [],
109
+ github: merge.github,
110
+ };
111
+ else if (pending !== null)
112
+ ci = { ...base, kind: "ci-pending", failed: [], pending };
113
+ else
114
+ ci = {
115
+ ...base,
116
+ kind: "ci-clean",
117
+ failed: [],
118
+ pending: [],
119
+ github: merge.github,
120
+ };
121
+ }
122
+ return {
123
+ kind: "open",
124
+ context: args.context,
125
+ facts,
126
+ threads,
127
+ ci,
128
+ reviewAutomationRunning: checks.checks.some(
129
+ (check) =>
130
+ check.kind === "pending" &&
131
+ AUTOMATION_TOKENS.some((token) =>
132
+ check.name.toLowerCase().includes(token)
133
+ )
134
+ ),
135
+ };
136
+ }
137
+ const conflictBlocker = (row: T.PrSnapshot): T.MergeBlocker | null =>
138
+ row.kind === "open" &&
139
+ (row.facts.mergeable === "CONFLICTING" ||
140
+ row.facts.mergeStateStatus === "DIRTY" ||
141
+ row.facts.mergeStateStatus === "CONFLICTING")
142
+ ? { kind: "merge-conflicts", pr: row.context, facts: row.facts }
143
+ : null;
144
+ function threadBlocker(row: T.PrSnapshot): T.MergeBlocker | null {
145
+ if (row.kind !== "open") return null;
146
+ const threads = nonEmpty(row.threads);
147
+ return threads === null
148
+ ? null
149
+ : { kind: "review-threads", pr: row.context, threads };
150
+ }
151
+ const ciBlocker = (row: T.PrSnapshot): T.MergeBlocker | null =>
152
+ row.kind === "open" &&
153
+ (row.ci.kind === "ci-failing" || row.ci.kind === "ci-github-rejected")
154
+ ? { kind: "failing-checks", pr: row.context, ci: row.ci }
155
+ : null;
156
+ function gateReason(
157
+ row: T.PrSnapshot,
158
+ allowDraft: boolean
159
+ ): T.MergeGateReason | null {
160
+ if (row.kind === "merged") return null;
161
+ if (row.kind === "closed") return "closed-without-merge";
162
+ if (row.facts.isDraft && !allowDraft) return "draft-pr";
163
+ return row.facts.reviewDecision === "CHANGES_REQUESTED"
164
+ ? "changes-requested"
165
+ : null;
166
+ }
167
+ function gateBlocker(
168
+ row: T.PrSnapshot,
169
+ allowDraft: boolean
170
+ ): T.MergeBlocker | null {
171
+ const reason = gateReason(row, allowDraft);
172
+ return reason === null ||
173
+ (reason === "draft-pr" &&
174
+ row.kind === "open" &&
175
+ row.ci.kind === "ci-pending")
176
+ ? null
177
+ : { kind: "merge-gate", pr: row.context, reason };
178
+ }
179
+ function readyContribution(
180
+ row: T.PrSnapshot,
181
+ allowDraft: boolean
182
+ ): T.ReadyPr | T.MergedPr | null {
183
+ if (row.kind === "merged")
184
+ return {
185
+ kind: "merged-pr",
186
+ context: row.context,
187
+ mergedAt: row.facts.mergedAt,
188
+ };
189
+ if (
190
+ row.kind !== "open" ||
191
+ row.ci.kind !== "ci-clean" ||
192
+ row.threads.length !== 0 ||
193
+ conflictBlocker(row) !== null ||
194
+ gateReason(row, allowDraft) !== null
195
+ )
196
+ return null;
197
+ const reviewDecision = row.facts.reviewDecision;
198
+ if (reviewDecision === "CHANGES_REQUESTED") return null;
199
+ return {
200
+ kind: "ready-pr",
201
+ context: row.context,
202
+ proof: {
203
+ mergeability: "clear",
204
+ threads: [],
205
+ ci: row.ci,
206
+ gate: {
207
+ state: "OPEN",
208
+ reviewDecision,
209
+ draft: row.facts.isDraft ? "draft-allowed" : "not-draft",
210
+ },
211
+ },
212
+ };
213
+ }
214
+ export function classifyPr(
215
+ row: T.PrSnapshot,
216
+ allowDraft = false
217
+ ): T.PrDecision {
218
+ for (const blocker of [
219
+ conflictBlocker(row),
220
+ threadBlocker(row),
221
+ ciBlocker(row),
222
+ gateBlocker(row, allowDraft),
223
+ ])
224
+ if (blocker !== null) return { kind: "blocker", blocker };
225
+ if (row.kind === "open" && row.ci.kind === "ci-pending")
226
+ return { kind: "waiting", frontier: row.context, pending: row.ci.pending };
227
+ const ready = readyContribution(row, allowDraft);
228
+ if (ready === null) throw new Error("snapshot has no classified decision");
229
+ return ready.kind === "merged-pr"
230
+ ? { kind: "merged", pr: ready }
231
+ : { kind: "ready", pr: ready };
232
+ }
233
+ export function selectTierMajorStackDecision(
234
+ rows: T.NonEmpty<T.PrSnapshot>,
235
+ allowDraft = false
236
+ ): T.StackDecision {
237
+ for (const tier of [conflictBlocker, threadBlocker, ciBlocker])
238
+ for (const row of rows) {
239
+ const blocker = tier(row);
240
+ if (blocker !== null) return { kind: "blocker", blocker };
241
+ }
242
+ for (const row of rows) {
243
+ const blocker = gateBlocker(row, allowDraft);
244
+ if (blocker !== null) return { kind: "blocker", blocker };
245
+ }
246
+ for (const row of rows)
247
+ if (row.kind === "open" && row.ci.kind === "ci-pending")
248
+ return {
249
+ kind: "waiting",
250
+ frontier: row.context,
251
+ pending: row.ci.pending,
252
+ };
253
+ const prs = nonEmpty(
254
+ rows
255
+ .map((row) => readyContribution(row, allowDraft))
256
+ .filter((row): row is T.ReadyPr | T.MergedPr => row !== null)
257
+ );
258
+ if (prs === null || prs.length !== rows.length)
259
+ throw new Error("stack has no classified decision");
260
+ return { kind: "clear", prs };
261
+ }
262
+ export const queryBackoffSeconds = (
263
+ interval: number,
264
+ failures: number
265
+ ): number => Math.min(Math.max(interval, 60) * 2 ** (failures - 1), 300);
266
+ interface Envelope<M extends T.WatchMode> {
267
+ readonly schemaVersion: 1;
268
+ readonly sequence: number;
269
+ readonly observedAt: string;
270
+ readonly mode: M;
271
+ }
272
+ type Payload<V> = V extends unknown
273
+ ? Omit<V, keyof Envelope<T.WatchMode>>
274
+ : never;
275
+ type VerdictPayload = Payload<T.WatcherVerdict>;
276
+ export interface VerdictStamp<M extends T.WatchMode = T.WatchMode> {
277
+ <const P extends VerdictPayload>(payload: P): Envelope<M> & P;
278
+ <const P extends VerdictPayload, M2 extends T.WatchMode>(
279
+ payload: P,
280
+ mode: M2
281
+ ): Envelope<M2> & P;
282
+ }
283
+ export function verdictFactory<M extends T.WatchMode>(
284
+ clock: WatchClock,
285
+ mode: M
286
+ ): VerdictStamp<M> {
287
+ let sequence = 0;
288
+ function stamp<const P extends VerdictPayload>(payload: P): Envelope<M> & P;
289
+ function stamp<const P extends VerdictPayload, M2 extends T.WatchMode>(
290
+ payload: P,
291
+ mode: M2
292
+ ): Envelope<M2> & P;
293
+ function stamp<const P extends VerdictPayload>(
294
+ payload: P,
295
+ override?: T.WatchMode
296
+ ): Envelope<T.WatchMode> & P {
297
+ return {
298
+ schemaVersion: 1,
299
+ sequence: (sequence += 1),
300
+ observedAt: clock.observedAt(),
301
+ mode: override ?? mode,
302
+ ...payload,
303
+ };
304
+ }
305
+ return stamp;
306
+ }
307
+ function blockerVerdict(
308
+ stamp: VerdictStamp,
309
+ blocker: T.MergeBlocker
310
+ ): T.BlockerVerdict {
311
+ switch (blocker.kind) {
312
+ case "merge-conflicts":
313
+ return stamp({ kind: "BLOCKER", terminal: true, exitCode: 2, blocker });
314
+ case "review-threads":
315
+ return stamp({ kind: "BLOCKER", terminal: true, exitCode: 3, blocker });
316
+ case "failing-checks":
317
+ return stamp({ kind: "BLOCKER", terminal: true, exitCode: 4, blocker });
318
+ case "merge-gate":
319
+ return stamp({ kind: "BLOCKER", terminal: true, exitCode: 6, blocker });
320
+ default: {
321
+ const exhaustive: never = blocker;
322
+ return exhaustive;
323
+ }
324
+ }
325
+ }
326
+ export function statusQueryVerdict(
327
+ stamp: VerdictStamp,
328
+ failures: number,
329
+ failure: T.QueryFailure
330
+ ): T.BlockerVerdict {
331
+ return stamp({
332
+ kind: "BLOCKER",
333
+ terminal: true,
334
+ exitCode: 7,
335
+ blocker: { kind: "status-query", failures, failure },
336
+ });
337
+ }
338
+ export interface WatchClock {
339
+ now(): number;
340
+ observedAt(): string;
341
+ sleep(seconds: number): Promise<void>;
342
+ }
343
+ export interface RunDependencies {
344
+ readonly reader: T.GitHubReader;
345
+ readonly clock: WatchClock;
346
+ readonly emit: (verdict: T.ProgressVerdict) => void;
347
+ }
348
+ const deadlinePassed = (
349
+ started: number,
350
+ options: T.PollingOptions,
351
+ now: number
352
+ ): boolean => options.timeout > 0 && now - started >= options.timeout;
353
+ type StepResult<V> =
354
+ | { readonly kind: "terminal"; readonly verdict: V }
355
+ | {
356
+ readonly kind: "sleep";
357
+ readonly seconds: number;
358
+ readonly onDeadline?: () => V;
359
+ }
360
+ | { readonly kind: "continue" };
361
+ async function pollUntilTerminal<V>(args: {
362
+ readonly dependencies: RunDependencies;
363
+ readonly options: T.PollingOptions;
364
+ readonly stamp: VerdictStamp;
365
+ readonly step: () => Promise<StepResult<V>>;
366
+ }): Promise<V | T.BlockerVerdict | T.TimeoutVerdict> {
367
+ let failures = 0;
368
+ const started = args.dependencies.clock.now();
369
+ while (true) {
370
+ let result: StepResult<V>;
371
+ try {
372
+ result = await args.step();
373
+ failures = 0;
374
+ } catch (error) {
375
+ if (!(error instanceof WatcherQueryError)) throw error;
376
+ failures += 1;
377
+ if (!error.failure.retryable || failures >= args.options.maxQueryErrors)
378
+ return statusQueryVerdict(args.stamp, failures, error.failure);
379
+ const retryInSeconds = queryBackoffSeconds(
380
+ args.options.interval,
381
+ failures
382
+ );
383
+ args.dependencies.emit(
384
+ args.stamp({
385
+ kind: "RETRY",
386
+ terminal: false,
387
+ failure: error.failure,
388
+ consecutiveFailures: failures,
389
+ retryInSeconds,
390
+ })
391
+ );
392
+ if (deadlinePassed(started, args.options, args.dependencies.clock.now()))
393
+ return args.stamp({
394
+ kind: "TIMEOUT",
395
+ terminal: true,
396
+ exitCode: 5,
397
+ reason: { kind: "status-unavailable", failure: error.failure },
398
+ });
399
+ await args.dependencies.clock.sleep(retryInSeconds);
400
+ continue;
401
+ }
402
+ if (result.kind === "terminal") return result.verdict;
403
+ if (result.kind === "sleep") {
404
+ if (
405
+ result.onDeadline !== undefined &&
406
+ deadlinePassed(started, args.options, args.dependencies.clock.now())
407
+ )
408
+ return result.onDeadline();
409
+ await args.dependencies.clock.sleep(result.seconds);
410
+ }
411
+ }
412
+ }
413
+ export async function runSimple(args: {
414
+ readonly dependencies: RunDependencies;
415
+ readonly contexts: T.NonEmpty<T.PrContext>;
416
+ readonly mode: T.WatchMode;
417
+ readonly statusOnly: boolean;
418
+ readonly options: T.PollingOptions;
419
+ }): Promise<T.TerminalVerdict> {
420
+ const stamp = verdictFactory(args.dependencies.clock, args.mode);
421
+ const step = async (): Promise<StepResult<T.TerminalVerdict>> => {
422
+ const rows: T.PrSnapshot[] = [];
423
+ for (const context of args.contexts)
424
+ rows.push(
425
+ await readSnapshot({
426
+ reader: args.dependencies.reader,
427
+ context,
428
+ pendingHistory: "include",
429
+ allowDraft: args.options.allowDraft,
430
+ })
431
+ );
432
+ const complete = nonEmpty(rows);
433
+ if (complete === null) throw new Error("watch context cannot be empty");
434
+ if (args.statusOnly)
435
+ return {
436
+ kind: "terminal",
437
+ verdict: stamp({
438
+ kind: "STATUS",
439
+ terminal: true,
440
+ exitCode: 0,
441
+ reason: "status-only",
442
+ rows: complete,
443
+ }),
444
+ };
445
+ if (args.mode === "queued-stack")
446
+ throw new Error("queued-stack requires status-only in the simple runner");
447
+ if (args.mode === "stack")
448
+ args.dependencies.emit(
449
+ stamp(
450
+ { kind: "STATUS", terminal: false, reason: "poll", rows: complete },
451
+ args.mode
452
+ )
453
+ );
454
+ const decision =
455
+ args.mode === "single"
456
+ ? classifyPr(complete[0], args.options.allowDraft)
457
+ : selectTierMajorStackDecision(complete, args.options.allowDraft);
458
+ if (decision.kind === "blocker")
459
+ return {
460
+ kind: "terminal",
461
+ verdict: blockerVerdict(stamp, decision.blocker),
462
+ };
463
+ if (decision.kind === "ready" || decision.kind === "merged")
464
+ return {
465
+ kind: "terminal",
466
+ verdict: stamp(
467
+ {
468
+ kind: "READY",
469
+ terminal: true,
470
+ exitCode: 0,
471
+ scope: { kind: "single", pr: decision.pr },
472
+ },
473
+ args.mode
474
+ ),
475
+ };
476
+ if (decision.kind === "clear")
477
+ return {
478
+ kind: "terminal",
479
+ verdict: stamp(
480
+ {
481
+ kind: "READY",
482
+ terminal: true,
483
+ exitCode: 0,
484
+ scope: { kind: "stack", prs: decision.prs },
485
+ },
486
+ args.mode
487
+ ),
488
+ };
489
+ args.dependencies.emit(
490
+ stamp({
491
+ kind: "WAITING",
492
+ terminal: false,
493
+ frontier: decision.frontier,
494
+ reason: { kind: "pending-checks", pending: decision.pending },
495
+ })
496
+ );
497
+ return {
498
+ kind: "sleep",
499
+ seconds: args.options.interval,
500
+ onDeadline: () =>
501
+ stamp({
502
+ kind: "TIMEOUT",
503
+ terminal: true,
504
+ exitCode: 5,
505
+ reason: { kind: "pending-checks", pending: decision.pending },
506
+ }),
507
+ };
508
+ };
509
+ return pollUntilTerminal({
510
+ dependencies: args.dependencies,
511
+ options: args.options,
512
+ stamp,
513
+ step,
514
+ });
515
+ }
516
+ export type QueueWork =
517
+ | {
518
+ readonly kind: "whole-stack-sweep";
519
+ readonly remaining: T.NonEmpty<T.PrContext>;
520
+ }
521
+ | { readonly kind: "frontier-poll"; readonly frontier: T.PrContext };
522
+ export interface QueueState {
523
+ readonly queue: T.NonEmpty<T.PrContext>;
524
+ readonly snapshots: ReadonlyMap<T.PrNumber, T.PrSnapshot>;
525
+ readonly work: QueueWork | null;
526
+ readonly nextSweepAt: number;
527
+ readonly frontier: T.PrContext | null;
528
+ readonly lastWaitKey: string | null;
529
+ readonly startedAt: number;
530
+ }
531
+ export const createQueueState = (
532
+ queue: T.NonEmpty<T.PrContext>,
533
+ now: number
534
+ ): QueueState => ({
535
+ queue,
536
+ snapshots: new Map(),
537
+ work: { kind: "whole-stack-sweep", remaining: queue },
538
+ nextSweepAt: now,
539
+ frontier: null,
540
+ lastWaitKey: null,
541
+ startedAt: now,
542
+ });
543
+ const orderedRows = (state: QueueState): T.PrSnapshot[] =>
544
+ state.queue.flatMap((context) => {
545
+ const row = state.snapshots.get(context.number);
546
+ return row === undefined ? [] : [row];
547
+ });
548
+ const activeRows = (state: QueueState): T.PrSnapshot[] =>
549
+ orderedRows(state).filter((row) => row.kind !== "merged");
550
+ export function planQueue(state: QueueState, now: number): QueueState {
551
+ if (state.work !== null) return state;
552
+ if (state.snapshots.size === 0 || now >= state.nextSweepAt) {
553
+ const remaining = nonEmpty(
554
+ state.queue.filter(
555
+ (context) => state.snapshots.get(context.number)?.kind !== "merged"
556
+ )
557
+ );
558
+ if (remaining !== null)
559
+ return { ...state, work: { kind: "whole-stack-sweep", remaining } };
560
+ }
561
+ const frontier = activeRows(state)[0]?.context;
562
+ return frontier === undefined
563
+ ? state
564
+ : { ...state, work: { kind: "frontier-poll", frontier } };
565
+ }
566
+ export interface QueueSnapshotResult {
567
+ readonly state: QueueState;
568
+ readonly completedSweepRows: T.NonEmpty<T.PrSnapshot> | null;
569
+ }
570
+ export function applyQueueSnapshot(
571
+ state: QueueState,
572
+ snapshot: T.PrSnapshot,
573
+ now: number,
574
+ options: T.PollingOptions
575
+ ): QueueSnapshotResult {
576
+ if (state.work === null) throw new Error("queue has no read in flight");
577
+ const snapshots = new Map(state.snapshots);
578
+ snapshots.set(snapshot.context.number, snapshot);
579
+ const base = { ...state, snapshots };
580
+ if (state.work.kind === "frontier-poll")
581
+ return { state: { ...base, work: null }, completedSweepRows: null };
582
+ const [head, ...tail] = state.work.remaining;
583
+ if (head.number !== snapshot.context.number)
584
+ throw new Error("snapshot does not match sweep head");
585
+ const remaining = nonEmpty(tail);
586
+ if (remaining !== null)
587
+ return {
588
+ state: { ...base, work: { kind: "whole-stack-sweep", remaining } },
589
+ completedSweepRows: null,
590
+ };
591
+ const rows = nonEmpty(
592
+ state.queue.flatMap((context) => {
593
+ const row = snapshots.get(context.number);
594
+ return row === undefined ? [] : [row];
595
+ })
596
+ );
597
+ if (rows === null || rows.length !== state.queue.length)
598
+ throw new Error("sweep completed without every snapshot");
599
+ return {
600
+ state: { ...base, work: null, nextSweepAt: now + options.sweepInterval },
601
+ completedSweepRows: rows,
602
+ };
603
+ }
604
+ export type QueueEvaluation =
605
+ | {
606
+ readonly kind: "complete";
607
+ readonly state: QueueState;
608
+ readonly merged: T.NonEmpty<T.MergedPr>;
609
+ }
610
+ | {
611
+ readonly kind: "blocker";
612
+ readonly state: QueueState;
613
+ readonly blocker: T.MergeBlocker;
614
+ }
615
+ | {
616
+ readonly kind: "advance";
617
+ readonly state: QueueState;
618
+ readonly merged: T.PrContext;
619
+ readonly frontier: T.PrContext;
620
+ readonly remaining: number;
621
+ }
622
+ | {
623
+ readonly kind: "timeout";
624
+ readonly state: QueueState;
625
+ readonly frontier: T.PrContext;
626
+ readonly unmergedCount: number;
627
+ }
628
+ | {
629
+ readonly kind: "waiting";
630
+ readonly state: QueueState;
631
+ readonly frontier: T.PrContext;
632
+ readonly reason:
633
+ | {
634
+ readonly kind: "pending-checks";
635
+ readonly pending: T.NonEmpty<T.PendingCheck>;
636
+ }
637
+ | { readonly kind: "merge-queue"; readonly unmergedCount: number };
638
+ readonly emit: boolean;
639
+ };
640
+ export function evaluateQueue(
641
+ state: QueueState,
642
+ now: number,
643
+ options: T.PollingOptions
644
+ ): QueueEvaluation {
645
+ const active = activeRows(state);
646
+ if (active.length === 0) {
647
+ const merged = nonEmpty(
648
+ orderedRows(state).flatMap((row) =>
649
+ row.kind === "merged"
650
+ ? [
651
+ {
652
+ kind: "merged-pr" as const,
653
+ context: row.context,
654
+ mergedAt: row.facts.mergedAt,
655
+ },
656
+ ]
657
+ : []
658
+ )
659
+ );
660
+ if (merged === null) throw new Error("empty queue cannot complete");
661
+ return { kind: "complete", state, merged };
662
+ }
663
+ const rows = nonEmpty(active);
664
+ if (rows === null) throw new Error("active queue cannot be empty");
665
+ const decision = selectTierMajorStackDecision(rows, options.allowDraft);
666
+ if (decision.kind === "blocker")
667
+ return { kind: "blocker", state, blocker: decision.blocker };
668
+ const frontier = rows[0].context;
669
+ if (state.frontier !== null && state.frontier.number !== frontier.number)
670
+ return {
671
+ kind: "advance",
672
+ state: { ...state, frontier, lastWaitKey: null },
673
+ merged: state.frontier,
674
+ frontier,
675
+ remaining: active.length,
676
+ };
677
+ if (deadlinePassed(state.startedAt, options, now))
678
+ return {
679
+ kind: "timeout",
680
+ state: { ...state, frontier },
681
+ frontier,
682
+ unmergedCount: active.length,
683
+ };
684
+ const row = rows[0];
685
+ const pending =
686
+ row.kind === "open" && row.ci.kind === "ci-pending" ? row.ci.pending : null;
687
+ const reason =
688
+ pending === null
689
+ ? ({ kind: "merge-queue", unmergedCount: active.length } as const)
690
+ : ({ kind: "pending-checks", pending } as const);
691
+ const key =
692
+ reason.kind === "pending-checks"
693
+ ? `pending:${frontier.number}:${reason.pending.length}`
694
+ : `queue:${frontier.number}:${reason.unmergedCount}`;
695
+ return {
696
+ kind: "waiting",
697
+ state: { ...state, frontier, lastWaitKey: key },
698
+ frontier,
699
+ reason,
700
+ emit: state.lastWaitKey !== key,
701
+ };
702
+ }
703
+ export async function runQueued(args: {
704
+ readonly dependencies: RunDependencies;
705
+ readonly contexts: T.NonEmpty<T.PrContext>;
706
+ readonly options: T.PollingOptions;
707
+ }): Promise<T.QueueTerminalVerdict> {
708
+ let state = createQueueState(args.contexts, args.dependencies.clock.now());
709
+ const stamp = verdictFactory(args.dependencies.clock, "queued-stack");
710
+ args.dependencies.emit(
711
+ stamp({ kind: "QUEUE", terminal: false, queue: args.contexts })
712
+ );
713
+ const step = async (): Promise<StepResult<T.QueueTerminalVerdict>> => {
714
+ state = planQueue(state, args.dependencies.clock.now());
715
+ if (state.work === null) {
716
+ const complete = evaluateQueue(
717
+ state,
718
+ args.dependencies.clock.now(),
719
+ args.options
720
+ );
721
+ if (complete.kind !== "complete")
722
+ throw new Error("queue has no work while active");
723
+ return {
724
+ kind: "terminal",
725
+ verdict: stamp({
726
+ kind: "COMPLETE",
727
+ terminal: true,
728
+ exitCode: 0,
729
+ queue: state.queue,
730
+ merged: complete.merged,
731
+ }),
732
+ };
733
+ }
734
+ const context =
735
+ state.work.kind === "whole-stack-sweep"
736
+ ? state.work.remaining[0]
737
+ : state.work.frontier;
738
+ const snapshot = await readSnapshot({
739
+ reader: args.dependencies.reader,
740
+ context,
741
+ pendingHistory: "omit",
742
+ allowDraft: args.options.allowDraft,
743
+ });
744
+ const applied = applyQueueSnapshot(
745
+ state,
746
+ snapshot,
747
+ args.dependencies.clock.now(),
748
+ args.options
749
+ );
750
+ state = applied.state;
751
+ if (applied.completedSweepRows !== null)
752
+ args.dependencies.emit(
753
+ stamp({
754
+ kind: "STATUS",
755
+ terminal: false,
756
+ reason: "whole-stack-sweep",
757
+ rows: applied.completedSweepRows,
758
+ })
759
+ );
760
+ if (state.work !== null) return { kind: "continue" };
761
+ const evaluation = evaluateQueue(
762
+ state,
763
+ args.dependencies.clock.now(),
764
+ args.options
765
+ );
766
+ state = evaluation.state;
767
+ switch (evaluation.kind) {
768
+ case "complete":
769
+ return {
770
+ kind: "terminal",
771
+ verdict: stamp({
772
+ kind: "COMPLETE",
773
+ terminal: true,
774
+ exitCode: 0,
775
+ queue: state.queue,
776
+ merged: evaluation.merged,
777
+ }),
778
+ };
779
+ case "blocker":
780
+ return {
781
+ kind: "terminal",
782
+ verdict: blockerVerdict(stamp, evaluation.blocker),
783
+ };
784
+ case "advance":
785
+ args.dependencies.emit(
786
+ stamp({
787
+ kind: "ADVANCE",
788
+ terminal: false,
789
+ merged: evaluation.merged,
790
+ frontier: evaluation.frontier,
791
+ remaining: evaluation.remaining,
792
+ })
793
+ );
794
+ return { kind: "continue" };
795
+ case "timeout":
796
+ return {
797
+ kind: "terminal",
798
+ verdict: stamp({
799
+ kind: "TIMEOUT",
800
+ terminal: true,
801
+ exitCode: 5,
802
+ reason: {
803
+ kind: "queued-stack",
804
+ frontier: evaluation.frontier,
805
+ unmergedCount: evaluation.unmergedCount,
806
+ },
807
+ }),
808
+ };
809
+ case "waiting":
810
+ if (evaluation.emit)
811
+ args.dependencies.emit(
812
+ stamp({
813
+ kind: "WAITING",
814
+ terminal: false,
815
+ frontier: evaluation.frontier,
816
+ reason: evaluation.reason,
817
+ })
818
+ );
819
+ return { kind: "sleep", seconds: args.options.interval };
820
+ default: {
821
+ const exhaustive: never = evaluation;
822
+ return exhaustive;
823
+ }
824
+ }
825
+ };
826
+ return pollUntilTerminal({
827
+ dependencies: args.dependencies,
828
+ options: args.options,
829
+ stamp,
830
+ step,
831
+ });
832
+ }