@davidvornholt/standards 0.15.1 → 0.16.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.
package/README.md CHANGED
@@ -35,7 +35,7 @@ standards poller --print-units --config <path>
35
35
 
36
36
  `structure` enforces the canonical monorepo structure contract: workspace scripts (`check-types`, `lint`, `lint:fix`, `test`), root gate scripts, filtered Turbo convenience aliases, internal `0.0.0`/`workspace:*` versioning, package `exports`, shared tsconfig inheritance, and browser a11y wiring. Only a workspace containing an explicit `*.a11y.ts` suite must provide a `test:a11y` script and direct `@axe-core/playwright` and `@playwright/test` dependencies. `check` includes `structure`, so these rules gate every consumer PR.
37
37
 
38
- `poller` runs one tick of the fix poller: host-level automation that turns maintainer-approved GitHub issues into verified draft PRs and maintainer-approved draft PRs into completed review-fix cycles, using headless Codex runs. All workflow state lives in GitHub labels, comments, and draft PRs (the labels are canonical declared settings — see `github --apply`), so ticks are stateless, re-runnable, and safe to schedule from any host. A tick claims work by label (`fix-in-progress` / `review-in-progress`), releases claims stale for longer than `staleClaimHours`, and fails loudly — a red tick means jobs stalled, never silence. Trust is verified per event, not per configuration: the approval labels (`approved-for-fix`, `approved-for-review`) only count when the applying user holds the `admin` or `maintain` role, bind to the exact approved issue revision or PR head, and are revalidated before publication; only admin/maintain comments are treated as answers to agent questions. The poller removes its direct GitHub token variables before launching Codex, but the approved run shares the host service identity and can read its other ambient credentials and logged-in tool state; that accepted visibility must not be treated as credential isolation. The run works in a throwaway git worktree, grants Codex write access to that worktree and its exact Git common directory so commits can update the linked repository metadata, hands results back through a structured outcome file, and then verifies effects (commits exist, no protected paths touched) before pushing, opening the draft PR, or posting the review — the required CI checks on the resulting PR remain the quality gate. Host mutation is declarative-only: `--print-units` emits a correctly escaped systemd service and timer for the polling host's infrastructure repository to adapt and deploy. That repository owns the service identity, writable HOME, PATH, GitHub token environment, lingering, and deployment. The service's tick budget (`TimeoutStartSec`) is derived from `maxJobsPerTick` × `runTimeoutMinutes` plus overhead, so re-render and redeploy the units after changing either value.
38
+ `poller` runs one tick of the fix poller: host-level automation that turns maintainer-approved GitHub issues into verified draft PRs and maintainer-approved draft PRs into completed review-fix cycles, using headless Codex runs. All workflow state lives in GitHub labels, comments, and draft PRs (the labels are canonical declared settings — see `github --apply`), so ticks are stateless, re-runnable, and safe to schedule from any host. A tick claims work by label (`fix-in-progress` / `review-in-progress`), releases claims stale for longer than `staleClaimHours`, and fails loudly — a red tick means jobs stalled, never silence. A separate acknowledgement tick validates pending approvals and posts one deduplicated queued message per approval generation; it never claims or starts work, so it can keep giving maintainers prompt feedback while the worker is occupied. When a worker claims a request, it posts a concise start message. The exact approval and distributed claim records remain in hidden comment metadata, and losing claim contenders remove their duplicate comments. Trust is verified per event, not per configuration: the approval labels (`approved-for-fix`, `approved-for-review`) only count when the applying user holds the `admin` or `maintain` role, bind to the exact approved issue revision or PR head, and are revalidated before publication; only admin/maintain comments are treated as answers to agent questions. The poller removes its direct GitHub token variables before launching Codex, but the approved run shares the host service identity and can read its other ambient credentials and logged-in tool state; that accepted visibility must not be treated as credential isolation. The run works in a throwaway git worktree, grants Codex write access to that worktree and its exact Git common directory so commits can update the linked repository metadata, hands results back through a structured outcome file, and then verifies effects (commits exist, no protected paths touched) before pushing, opening the draft PR, or posting the review — the required CI checks on the resulting PR remain the quality gate. Host mutation is declarative-only: `--print-units` emits four correctly escaped systemd units for the polling host's infrastructure repository to adapt and deploy: the worker service and timer plus an independent lightweight acknowledgement service and timer. That repository owns the service identity, writable HOME, PATH, GitHub token environment, lingering, and deployment. The worker service's tick budget (`TimeoutStartSec`) is derived from `maxJobsPerTick` × `runTimeoutMinutes` plus overhead, so re-render and redeploy the units after changing either value.
39
39
 
40
40
  `creds` is the credential broker: it mints scoped Cloudflare tokens, rotates and revokes those tokens, and places GitHub App credentials into SOPS-encrypted targets — secret values never touch stdout, argv, or an agent's context; commands print only metadata (token name, scopes, expiry, destination). Cloudflare reconciliation has exactly two sources of truth: the plaintext key structure of the SOPS files and the provider token list under the deterministic naming scheme `standards/<owner>/<repo>/<sops-target>/<dotted.key>`; there is no separate credential manifest. `plan` and `apply` reconcile Cloudflare only: each run uses the stored bootstrap credential to verify its current provider token ID, excludes that ID from reconciliation regardless of its current name, and aborts if the identity cannot be correlated with the complete account token list; a brokered token whose SOPS key was deleted is revoked, while one nearing expiry (30 days) is renewed by creating a fresh-expiry replacement with the live token's policy, durably writing and verifying the new value, and only then revoking the old token; secrets the broker did not mint are never touched. Without a manifest there is no desired-policy record, so live policy drift is not detected, and a repository rename or transfer changes the deterministic namespace and requires the visible old token to be re-minted under the new name and revoked. `add cloudflare` mints an account-owned API token (default TTL 90 days) scoped to the named permission groups (`permissions` lists the valid names) and writes it to `--dest <target>:<dotted.key>`, where `<target>` resolves like the canonical `just secrets` targets (`ci` → `secrets/ci.yaml`, `prod-1` → `infra/hosts/prod-1/secrets.yaml`); zone-scoped groups such as DNS Write are rejected because the current command supplies an account resource, not a zone resource. `--bucket <name>` switches the policy resource to a single R2 bucket; `--jurisdiction default|eu` selects the provider jurisdiction and defaults to `default`, producing `com.cloudflare.edge.r2.bucket.<account_id>_default_<bucket>` or `com.cloudflare.edge.r2.bucket.<account_id>_eu_<bucket>`. FedRAMP jurisdictions are not supported. Bucket destinations accept only bucket-scoped groups such as Workers R2 Storage Bucket Item Read/Write, and account-scoped groups are rejected. `--s3` requires `--bucket` and stores the S3-compatible credential pair Cloudflare derives from an API token — access key ID is the token ID, secret access key is the SHA-256 hex digest of the token value — as `<dotted.key>.access_key_id` and `<dotted.key>.secret_access_key` under the destination instead of the raw token value; the default endpoint is `https://<account_id>.r2.cloudflarestorage.com`, while EU uses `https://<account_id>.eu.r2.cloudflarestorage.com`. Reconciliation infers each destination's concrete write footprint from the plaintext SOPS key structure — a bearer owns the leaf at its key, while an S3 token owns both pair leaves — and aborts before mutation when managed footprints intersect. `plan` and `apply` renew and revoke S3 destinations like any other brokered token, but renewal first proves the stored access key ID matches the current provider token; an incomplete pair, unreadable value, or identity mismatch is surfaced as a finding and never mutated automatically. `add github` writes the broker GitHub App's `app_id` and `private_key` under the destination key; durable GitHub tokens cannot be created via API (PAT and OAuth-app creation are UI-only), so workflows mint short-lived installation tokens from the App at runtime (`actions/create-github-app-token`), scoped per repository and permission. The canonical weekly sync expects `bun standards creds add github --dest ci:ci.broker_app`, then mints a current-repository token with Contents read and Pull requests write; missing credentials or minting failures stop it without a fallback. Bootstrap is once per machine: `login github` runs the App manifest flow (a localhost page posts the pre-filled manifest; one "Create GitHub App" click; credentials return through the conversion endpoint — nothing is copied by hand) and opens the installation page, where the App must be bound to selected repositories rather than all repositories; `login cloudflare` guides the one dashboard paste Cloudflare requires for the first account token (named `standards-broker` to match the App, created from the "Create additional tokens" token template because its "Account API Tokens: Edit" permission is unavailable in the custom token builder, and holding that permission only), verifies that it is active, correlates its verified ID with the complete account token list and an active list record, and stores it; a bootstrap token named inside the minted `standards/` namespace is rejected before storage to keep its role unambiguous, while any other divergence from the recommended name only warns. `plan` and `apply` do not rotate GitHub App keys; create a replacement key on the App's settings page, atomically replace `github.private_key` in the protected machine-global broker store while preserving its `0600` mode, then update every intended SOPS destination, verify workflows can mint installation tokens, and revoke the old key manually. Root credentials live in the machine-global broker store (see Configuration); SOPS writes go through `sops edit` with a non-interactive editor, preserving comments and reusing the file's data key.
41
41
 
@@ -46,7 +46,7 @@ standards poller --print-units --config <path>
46
46
  - **`.github/dependabot.local.yml`** (optional) — repo-owned Dependabot extension composed with the canonical `.github/dependabot.base.yml` into the generated `.github/dependabot.yml`. May define `updates` and top-level `registries`; a block matching a canonical normalized target may only add `ignore` and/or `registries` entries. Other matching-block policy is rejected deliberately. A missing or comments-only file means no additions. CLI 0.10.1 requires the selected content ref to contain the canonical base and rejects older refs before mutation.
47
47
  - **`sync-standards.local.json`** (optional) — consumer-owned sync policy at the repo root, validated by `doctor`/`check` and every `init`/`sync` even when an explicit ref or local source makes its `"ref"` irrelevant. All fields optional; a missing file means the defaults. `"ref"` is a non-empty single-line string that pins a tag, branch, or full commit sha to sync from instead of `main` (an explicit `--ref` overrides it; a local-path `--from` source is used as-is and ignores only the validated pin). `"autoSync": false` is read by the standards-sync workflow, not the CLI, and skips the weekly scheduled run; deliberate syncs then run locally because the secret-bearing workflow is schedule-only. Version 0.7.0 removes the legacy `STANDARDS_AUTO_SYNC` and `STANDARDS_SYNC_REF` variable behavior; consumers must upgrade the package and lockfile before adopting a policy file.
48
48
  - **`.github/settings.local.json` `"rulesetEnforcement": "unavailable-on-plan"`** (optional) — declares that the repository's GitHub plan cannot enforce rulesets (private repositories on GitHub Free, both personal accounts and organizations). `github --check` and `--apply` then skip rulesets instead of comparing them, because a comparison cannot be trusted there: personal accounts answer ruleset reads with HTTP 403, and free-plan organizations report declared rulesets as active while silently not enforcing them. Plan-gated repository settings that only function alongside branch protection (`allow_auto_merge`) are skipped for the same reason — GitHub accepts a PATCH for them with HTTP 200 and silently keeps the old value. The remaining repository merge settings are still checked and converged, and both commands print an unprotected-branch notice on every run. The only accepted value is `"unavailable-on-plan"` (enforcement is the default; omit the key on paid plans), and combining the opt-out with additional local rulesets is rejected. After upgrading the plan, remove the declaration and run `bun standards github --apply` to restore enforcement.
49
- - **Poller config file** (required for `poller`, passed via `--config`) — host-level JSON, deliberately not repository state. Required: `"repos"` (1–12 unique `owner/repo` strings), `"model"` and `"reasoningEffort"` (passed to `codex exec`; the model choice is deliberate and has no default). The 12-repository maximum keeps the one-minute idle tick's baseline GitHub reads within a 5,000-request/hour API budget while retaining headroom for pagination, active jobs, writes, and other token users. Existing configs above the limit must be reduced or split across pollers with independent GitHub API budgets before upgrading. Optional: `"maxJobsPerTick"` (default 1 — the concurrency and rate-limit guard; subscription-authenticated Codex runs share the interactive rate window), `"staleClaimHours"` (default 6; must exceed `runTimeoutMinutes` so a sweep can never release the claim of a job that is still running), `"runTimeoutMinutes"` (default 240 — the timeout catches wedged agents, not honest multi-hour review runs), `"cacheDir"` (default `~/.cache/standards-poller`; holds bare mirrors and job worktrees; relative paths resolve against the config file), `"extraCodexArgs"` (extra `codex exec` arguments, e.g. enabling the collaboration feature for subagent fan-out). Unknown keys are rejected. The poller needs `git`, `codex` (authenticated — its rotating auth state must live in a writable location), and a GitHub token per the bullet below; the token needs issues, pull-requests, and contents write on the watched repos, and nothing else.
49
+ - **Poller config file** (required for `poller`, passed via `--config`) — host-level JSON, deliberately not repository state. Required: `"repos"` (1–12 unique `owner/repo` strings), `"model"` and `"reasoningEffort"` (passed to `codex exec`; the model choice is deliberate and has no default). The 12-repository maximum and five-minute acknowledgement cooldown keep supported mixed fix/review queues and persisted review-plan recovery below the 5,000-request/hour GitHub API budget with at least 1,400 requests/hour reserved for pagination, active jobs, writes, and other token users; the compound regression bound covers four acknowledged fixes plus four acknowledged reviews with persisted plans in every repository at no more than 3,312 requests/hour. Existing configs above the repository limit must be reduced or split across pollers with independent GitHub API budgets before upgrading. Optional: `"maxJobsPerTick"` (default 1 — the maximum number of new Codex runs the sequential worker may start in one tick), `"staleClaimHours"` (default 6; must exceed `runTimeoutMinutes` so a sweep can never release the claim of a job that is still running), `"runTimeoutMinutes"` (default 240 — the timeout catches wedged agents, not honest multi-hour review runs), `"cacheDir"` (default `~/.cache/standards-poller`; holds bare mirrors and job worktrees; relative paths resolve against the config file), `"extraCodexArgs"` (extra `codex exec` arguments, e.g. enabling the collaboration feature for subagent fan-out). Unknown keys are rejected. The poller needs `git`, `codex` (authenticated — its rotating auth state must live in a writable location), and a GitHub token per the bullet below; the token needs issues, pull-requests, and contents write on the watched repos, and nothing else.
50
50
  - **Broker store** (created by `creds login`) — machine-global root credentials at `$XDG_CONFIG_HOME/standards/broker.yaml` (default `~/.config/standards/broker.yaml`; override with `STANDARDS_BROKER_FILE`), holding the GitHub App identity (id, slug, client id, private key) and one bootstrap token per Cloudflare account. The file is plaintext with `0600` permissions — deliberately the same local-account trust boundary as the plaintext personal age identity at `~/.config/sops/age/keys.txt`, which unlocks the same secrets; SOPS-encrypting the store to that adjacent identity would not improve custody. Writes use an owner-only temporary file and atomic replacement, and simultaneous provider logins merge rather than overwrite each other; `creds status` warns when the final mode is broader. The store lives outside every repository, is never synced or backed up, and losing it is cheap: re-run the two login commands and revoke the old App key and bootstrap token. On an additional machine, do not create a second App: generate an additional private key from the existing App's settings page (GitHub Apps support several active keys; key generation has no API) and add it to that machine's store, so retiring a machine revokes one key, not the setup.
51
51
  - **`GH_TOKEN` / `GITHUB_TOKEN`** (optional) — GitHub API token for the `github` command and the GitHub portion of `check`. When neither is set, the token from the local `gh` CLI is used; with no token at all, reads still work on public repositories. `--apply` needs an admin token. GitHub's REST API reveals repo merge settings only to write-capable viewers — `check` retries REST-hidden keys over GraphQL, which serves them to read-only tokens — ruleset `bypass_actors` requires repository Administration read, and private-repository labels require Issues read (or Pull requests read); a token that still cannot see declared state fails the check rather than passing unverified. In CI, the canonical workflow decrypts `ci.github_settings_read_token` from the SOPS-encrypted `secrets/ci.yaml`: a repository-scoped fine-grained PAT with read-only "Administration" and "Issues" access.
52
52
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@davidvornholt/standards",
3
- "version": "0.15.1",
3
+ "version": "0.16.0",
4
4
  "description": "Bootstrap, synchronize, and validate davidvornholt/standards consumers",
5
5
  "license": "MIT",
6
6
  "repository": {
@@ -78,9 +78,11 @@
78
78
  "src/github-settings.ts",
79
79
  "src/managed-files.ts",
80
80
  "src/poller-approval.ts",
81
+ "src/poller-acknowledgements.ts",
81
82
  "src/poller-claim.ts",
82
83
  "src/poller-codex.ts",
83
84
  "src/poller-commands.ts",
85
+ "src/poller-comment-metadata.ts",
84
86
  "src/poller-config.ts",
85
87
  "src/poller-fix-outcome.ts",
86
88
  "src/poller-fix-output.ts",
@@ -97,13 +99,16 @@
97
99
  "src/poller-prompts.ts",
98
100
  "src/poller-protected-paths.ts",
99
101
  "src/poller-protocol.ts",
102
+ "src/poller-queue-marker.ts",
100
103
  "src/poller-review-publication.ts",
104
+ "src/poller-review-eligibility.ts",
101
105
  "src/poller-review-output.ts",
102
106
  "src/poller-review-artifacts.ts",
103
107
  "src/poller-review-execution.ts",
104
108
  "src/poller-review-state.ts",
105
109
  "src/poller-review-validation.ts",
106
110
  "src/poller-schedule.ts",
111
+ "src/poller-status.ts",
107
112
  "src/poller-review-run.ts",
108
113
  "src/poller-tick.ts",
109
114
  "src/poller-trust.ts",
package/src/cli.ts CHANGED
@@ -88,6 +88,7 @@ type CliOptions = {
88
88
  readonly profile: StructureProfile;
89
89
  readonly config: string | undefined;
90
90
  readonly printUnits: boolean;
91
+ readonly acknowledgeOnly: boolean;
91
92
  };
92
93
 
93
94
  const sha256 = (buf: Buffer): string =>
@@ -755,7 +756,9 @@ Options:
755
756
  --apply With github: converge the live repository (needs admin auth)
756
757
  --write With dependabot: regenerate the composed .github/dependabot.yml
757
758
  --config <path> With poller: the host-level poller config file (required)
758
- --print-units With poller: print declarative systemd unit content without touching the host`;
759
+ --print-units With poller: print declarative systemd unit content without touching the host
760
+ --acknowledge-only
761
+ With poller: acknowledge queued requests without starting work`;
759
762
 
760
763
  const commandFromArg = (arg: string): Command => {
761
764
  if (
@@ -811,6 +814,7 @@ type ParsedFlags = {
811
814
  readonly profile: StructureProfile | undefined;
812
815
  readonly config: string | undefined;
813
816
  readonly printUnits: boolean;
817
+ readonly acknowledgeOnly: boolean;
814
818
  };
815
819
 
816
820
  const assertPollerOptionUsage = (flags: ParsedFlags): void => {
@@ -820,6 +824,14 @@ const assertPollerOptionUsage = (flags: ParsedFlags): void => {
820
824
  if (flags.printUnits && flags.command !== 'poller') {
821
825
  throw new Error('--print-units is only valid with the poller command');
822
826
  }
827
+ if (flags.acknowledgeOnly && flags.command !== 'poller') {
828
+ throw new Error('--acknowledge-only is only valid with the poller command');
829
+ }
830
+ if (flags.acknowledgeOnly && flags.printUnits) {
831
+ throw new Error(
832
+ 'poller accepts only one of --acknowledge-only or --print-units',
833
+ );
834
+ }
823
835
  };
824
836
 
825
837
  // Every option is only meaningful with specific commands; reject the rest so a
@@ -864,6 +876,7 @@ const parseArgs = (argv: ReadonlyArray<string>): CliOptions => {
864
876
  let profile: StructureProfile | undefined;
865
877
  let config: string | undefined;
866
878
  let printUnits = false;
879
+ let acknowledgeOnly = false;
867
880
 
868
881
  for (let index = 0; index < argv.length; index += 1) {
869
882
  const arg = argv[index];
@@ -871,6 +884,9 @@ const parseArgs = (argv: ReadonlyArray<string>): CliOptions => {
871
884
  case '--apply':
872
885
  apply = true;
873
886
  break;
887
+ case '--acknowledge-only':
888
+ acknowledgeOnly = true;
889
+ break;
874
890
  case '--config':
875
891
  config = nextOptionValue(argv, index);
876
892
  index += 1;
@@ -921,6 +937,7 @@ const parseArgs = (argv: ReadonlyArray<string>): CliOptions => {
921
937
  profile,
922
938
  config,
923
939
  printUnits,
940
+ acknowledgeOnly,
924
941
  });
925
942
 
926
943
  return {
@@ -934,6 +951,7 @@ const parseArgs = (argv: ReadonlyArray<string>): CliOptions => {
934
951
  profile: profile ?? 'consumer',
935
952
  config,
936
953
  printUnits,
954
+ acknowledgeOnly,
937
955
  };
938
956
  };
939
957
 
@@ -1164,6 +1182,7 @@ const main = async (): Promise<void> => {
1164
1182
  profile,
1165
1183
  config,
1166
1184
  printUnits,
1185
+ acknowledgeOnly,
1167
1186
  } = parseArgs(process.argv.slice(2));
1168
1187
 
1169
1188
  if (command === undefined) {
@@ -1194,7 +1213,13 @@ const main = async (): Promise<void> => {
1194
1213
  }
1195
1214
 
1196
1215
  if (command === 'poller') {
1197
- if (!(await runPollerCommand({ configPath: config, printUnits }))) {
1216
+ if (
1217
+ !(await runPollerCommand({
1218
+ configPath: config,
1219
+ printUnits,
1220
+ acknowledgeOnly,
1221
+ }))
1222
+ ) {
1198
1223
  process.exitCode = 1;
1199
1224
  }
1200
1225
  return;
@@ -0,0 +1,159 @@
1
+ import {
2
+ issueRevision,
3
+ prRevision,
4
+ readApprovalBinding,
5
+ } from './poller-approval';
6
+ import type { PollerConfig } from './poller-config';
7
+ import { type IssueItem, listOpenIssuesWithLabel } from './poller-github';
8
+ import { getPullRequest } from './poller-github-pulls';
9
+ import type { JobDeps } from './poller-job-shared';
10
+ import { APPROVED_FOR_FIX, APPROVED_FOR_REVIEW } from './poller-protocol';
11
+ import {
12
+ inspectQueuedAcknowledgement,
13
+ type PollerJobKind,
14
+ } from './poller-queue-marker';
15
+ import { reviewEligibility } from './poller-review-eligibility';
16
+ import { readReviewPlan } from './poller-review-state';
17
+ import { acknowledgeQueuedJob } from './poller-status';
18
+ import type { RoleCache } from './poller-trust';
19
+
20
+ export type AcknowledgementReport = {
21
+ readonly lines: ReadonlyArray<string>;
22
+ readonly problems: ReadonlyArray<string>;
23
+ };
24
+
25
+ const approvalFor = async (
26
+ deps: JobDeps,
27
+ item: IssueItem,
28
+ kind: PollerJobKind,
29
+ ): Promise<Awaited<ReturnType<typeof readApprovalBinding>> | null> => {
30
+ let target: string;
31
+ if (kind === 'fix') {
32
+ target = issueRevision(item);
33
+ if (
34
+ await inspectQueuedAcknowledgement({
35
+ deps,
36
+ item,
37
+ kind,
38
+ target,
39
+ })
40
+ ) {
41
+ return null;
42
+ }
43
+ } else {
44
+ const pr = await getPullRequest(deps.token, deps.repo, item.number);
45
+ const currentTarget = prRevision(pr.baseRef, pr.baseSha, pr.headSha);
46
+ if (
47
+ pr.draft &&
48
+ pr.headRepo === deps.repo &&
49
+ (await inspectQueuedAcknowledgement({
50
+ deps,
51
+ item,
52
+ kind,
53
+ target: currentTarget,
54
+ }))
55
+ ) {
56
+ return null;
57
+ }
58
+ const plan = await readReviewPlan(deps, pr);
59
+ if (
60
+ reviewEligibility({
61
+ repo: deps.repo,
62
+ pr,
63
+ hasPlan: plan !== null,
64
+ }).kind !== 'eligible'
65
+ ) {
66
+ return null;
67
+ }
68
+ target = currentTarget;
69
+ }
70
+ return readApprovalBinding(
71
+ {
72
+ token: deps.token,
73
+ repo: deps.repo,
74
+ issueNumber: item.number,
75
+ },
76
+ kind === 'fix' ? APPROVED_FOR_FIX : APPROVED_FOR_REVIEW,
77
+ target,
78
+ );
79
+ };
80
+
81
+ const acknowledgeItems = async (options: {
82
+ readonly deps: JobDeps;
83
+ readonly items: ReadonlyArray<IssueItem>;
84
+ readonly kind: PollerJobKind;
85
+ readonly lines: Array<string>;
86
+ readonly problems: Array<string>;
87
+ }): Promise<void> => {
88
+ const { deps, items, kind, lines, problems } = options;
89
+ for (const item of items) {
90
+ try {
91
+ // biome-ignore lint/performance/noAwaitInLoops: acknowledgement writes are deliberately serialized to avoid GitHub secondary rate limits.
92
+ const approval = await approvalFor(deps, item, kind);
93
+ if (
94
+ approval !== null &&
95
+ typeof approval !== 'string' &&
96
+ (await acknowledgeQueuedJob(deps, item.number, approval, kind))
97
+ ) {
98
+ lines.push(
99
+ `${deps.repo} ${kind === 'fix' ? `#${item.number}` : `PR #${item.number}`}: acknowledged as queued`,
100
+ );
101
+ }
102
+ } catch (error) {
103
+ problems.push(
104
+ `${deps.repo}#${item.number}: ${error instanceof Error ? error.message : String(error)}`,
105
+ );
106
+ }
107
+ }
108
+ };
109
+
110
+ const acknowledgeRepository = async (
111
+ deps: JobDeps,
112
+ lines: Array<string>,
113
+ problems: Array<string>,
114
+ ): Promise<void> => {
115
+ const reviews = (
116
+ await listOpenIssuesWithLabel(deps.token, deps.repo, APPROVED_FOR_REVIEW)
117
+ ).filter((item) => item.isPullRequest);
118
+ const fixes = (
119
+ await listOpenIssuesWithLabel(deps.token, deps.repo, APPROVED_FOR_FIX)
120
+ ).filter((item) => !item.isPullRequest);
121
+ await acknowledgeItems({
122
+ deps,
123
+ items: reviews,
124
+ kind: 'review',
125
+ lines,
126
+ problems,
127
+ });
128
+ await acknowledgeItems({
129
+ deps,
130
+ items: fixes,
131
+ kind: 'fix',
132
+ lines,
133
+ problems,
134
+ });
135
+ };
136
+
137
+ export const runPollerAcknowledgementTick = async (
138
+ config: PollerConfig,
139
+ token: string | null,
140
+ ): Promise<AcknowledgementReport> => {
141
+ const lines: Array<string> = [];
142
+ const problems: Array<string> = [];
143
+ const roleCache: RoleCache = new Map();
144
+ for (const repo of config.repos) {
145
+ try {
146
+ // biome-ignore lint/performance/noAwaitInLoops: repository scans share a rate-limited GitHub token and keep writes serialized.
147
+ await acknowledgeRepository(
148
+ { config, token, repo, roleCache },
149
+ lines,
150
+ problems,
151
+ );
152
+ } catch (error) {
153
+ problems.push(
154
+ `${repo}: ${error instanceof Error ? error.message : String(error)}`,
155
+ );
156
+ }
157
+ }
158
+ return { lines, problems };
159
+ };
@@ -1,6 +1,11 @@
1
1
  import { randomUUID } from 'node:crypto';
2
2
  import { hasLabel } from './github-label-identity';
3
+ import { isRecord } from './github-settings-parse';
3
4
  import { type ApprovalBinding, readApprovalBinding } from './poller-approval';
5
+ import {
6
+ hiddenCommentMetadata,
7
+ parseHiddenCommentMetadata,
8
+ } from './poller-comment-metadata';
4
9
  import {
5
10
  collaboratorRole,
6
11
  getIssue,
@@ -8,8 +13,13 @@ import {
8
13
  lastLabelEvent,
9
14
  listIssueComments,
10
15
  } from './poller-github';
11
- import { createComment } from './poller-github-write';
12
- import { CLAIM_MARKER, isTrustedRole } from './poller-protocol';
16
+ import { createComment, deleteComment } from './poller-github-write';
17
+ import {
18
+ CLAIM_METADATA_MARKER,
19
+ FIX_IN_PROGRESS,
20
+ isTrustedRole,
21
+ REVIEW_IN_PROGRESS,
22
+ } from './poller-protocol';
13
23
 
14
24
  export type ClaimBinding = {
15
25
  readonly approval: ApprovalBinding;
@@ -28,38 +38,56 @@ type ClaimContext = {
28
38
  readonly issueNumber: number;
29
39
  };
30
40
 
41
+ const startedMessage = (claimLabel: string) => {
42
+ if (claimLabel === FIX_IN_PROGRESS) {
43
+ return '**Fix started**\n\nThe poller is working on this issue now. It’ll open a draft pull request when the fix is ready, or ask here if it needs your input.';
44
+ }
45
+ if (claimLabel === REVIEW_IN_PROGRESS) {
46
+ return '**Review started**\n\nThe poller is reviewing this pull request now. It’ll post the results and take the pull request out of draft when it’s finished, or ask here if it needs your input.';
47
+ }
48
+ throw new Error(`unsupported poller claim label: ${claimLabel}`);
49
+ };
50
+
31
51
  const markerBody = (marker: ClaimMarker): string =>
32
- `${CLAIM_MARKER}\n${JSON.stringify(marker)}`;
52
+ `${startedMessage(marker.claimLabel)}\n\n${hiddenCommentMetadata(
53
+ CLAIM_METADATA_MARKER,
54
+ marker,
55
+ )}`;
56
+
57
+ const isApprovalBinding = (value: unknown): value is ApprovalBinding =>
58
+ isRecord(value) &&
59
+ typeof value.id === 'string' &&
60
+ typeof value.repo === 'string' &&
61
+ typeof value.issueNumber === 'number' &&
62
+ typeof value.eventId === 'number' &&
63
+ typeof value.label === 'string' &&
64
+ typeof value.actorLogin === 'string' &&
65
+ typeof value.approvedAt === 'string' &&
66
+ typeof value.target === 'string';
33
67
 
34
68
  const parseMarker = (comment: IssueComment): ClaimMarker | null => {
35
- if (!comment.body.startsWith(`${CLAIM_MARKER}\n`)) {
69
+ const payload = parseHiddenCommentMetadata(
70
+ comment.body,
71
+ CLAIM_METADATA_MARKER,
72
+ );
73
+ if (!isRecord(payload)) {
36
74
  return null;
37
75
  }
38
- try {
39
- const raw = JSON.parse(comment.body.slice(CLAIM_MARKER.length + 1)) as {
40
- readonly approval?: ApprovalBinding;
41
- readonly claimLabel?: unknown;
42
- readonly claimEpoch?: unknown;
43
- readonly nonce?: unknown;
44
- };
45
- if (
46
- typeof raw.claimLabel !== 'string' ||
47
- typeof raw.claimEpoch !== 'string' ||
48
- typeof raw.nonce !== 'string' ||
49
- raw.approval === undefined ||
50
- typeof raw.approval.id !== 'string'
51
- ) {
52
- return null;
53
- }
54
- return {
55
- approval: raw.approval,
56
- claimLabel: raw.claimLabel,
57
- claimEpoch: raw.claimEpoch,
58
- nonce: raw.nonce,
59
- };
60
- } catch {
76
+ const raw = payload;
77
+ if (
78
+ typeof raw.claimLabel !== 'string' ||
79
+ typeof raw.claimEpoch !== 'string' ||
80
+ typeof raw.nonce !== 'string' ||
81
+ !isApprovalBinding(raw.approval)
82
+ ) {
61
83
  return null;
62
84
  }
85
+ return {
86
+ approval: raw.approval,
87
+ claimLabel: raw.claimLabel,
88
+ claimEpoch: raw.claimEpoch,
89
+ nonce: raw.nonce,
90
+ };
63
91
  };
64
92
 
65
93
  const winningMarkerId = async (
@@ -120,7 +148,11 @@ export const acquireClaim = async (
120
148
  markerBody({ ...provisional, nonce }),
121
149
  );
122
150
  const winner = await winningMarkerId(context, provisional);
123
- return winner === markerId ? { ...provisional, markerId } : null;
151
+ if (winner !== markerId) {
152
+ await deleteComment(context.token, context.repo, markerId);
153
+ return null;
154
+ }
155
+ return { ...provisional, markerId };
124
156
  };
125
157
 
126
158
  export const validateClaim = async (
@@ -6,6 +6,7 @@ import { existsSync } from 'node:fs';
6
6
  import { readFile } from 'node:fs/promises';
7
7
  import { dirname, resolve } from 'node:path';
8
8
  import { resolveToken } from './github-api';
9
+ import { runPollerAcknowledgementTick } from './poller-acknowledgements';
9
10
  import { type PollerConfig, parsePollerConfig } from './poller-config';
10
11
  import { runPollerTick } from './poller-tick';
11
12
  import { runPollerPrintUnits } from './poller-units';
@@ -13,6 +14,7 @@ import { runPollerPrintUnits } from './poller-units';
13
14
  export type PollerCommandOptions = {
14
15
  readonly configPath: string | undefined;
15
16
  readonly printUnits: boolean;
17
+ readonly acknowledgeOnly: boolean;
16
18
  };
17
19
 
18
20
  const loadConfig = async (configPath: string): Promise<PollerConfig> => {
@@ -62,7 +64,9 @@ export const runPollerCommand = async (
62
64
  );
63
65
  return false;
64
66
  }
65
- const { lines, problems } = await runPollerTick(config, token, Date.now());
67
+ const { lines, problems } = options.acknowledgeOnly
68
+ ? await runPollerAcknowledgementTick(config, token)
69
+ : await runPollerTick(config, token, Date.now());
66
70
  for (const line of lines) {
67
71
  console.log(` ${line}`);
68
72
  }
@@ -72,7 +76,7 @@ export const runPollerCommand = async (
72
76
  return false;
73
77
  }
74
78
  console.log(
75
- `standards poller: tick complete (${lines.length} event(s) across ${config.repos.length} repo(s))`,
79
+ `standards poller: ${options.acknowledgeOnly ? 'acknowledgement ' : ''}tick complete (${lines.length} event(s) across ${config.repos.length} repo(s))`,
76
80
  );
77
81
  return true;
78
82
  };
@@ -0,0 +1,25 @@
1
+ const commentStart = (marker: string): string => `<!-- ${marker}\n`;
2
+ const COMMENT_END = '\n-->';
3
+
4
+ export const hiddenCommentMetadata = (marker: string, value: unknown): string =>
5
+ `${commentStart(marker)}${JSON.stringify(value)}${COMMENT_END}`;
6
+
7
+ export const parseHiddenCommentMetadata = (
8
+ body: string,
9
+ marker: string,
10
+ ): unknown | null => {
11
+ const start = body.indexOf(commentStart(marker));
12
+ if (start === -1) {
13
+ return null;
14
+ }
15
+ const payloadStart = start + commentStart(marker).length;
16
+ const end = body.indexOf(COMMENT_END, payloadStart);
17
+ if (end === -1) {
18
+ return null;
19
+ }
20
+ try {
21
+ return JSON.parse(body.slice(payloadStart, end)) as unknown;
22
+ } catch {
23
+ return null;
24
+ }
25
+ };
@@ -31,6 +31,7 @@ import {
31
31
  FIX_FAILED,
32
32
  FIX_IN_PROGRESS,
33
33
  } from './poller-protocol';
34
+ import { acknowledgeQueuedJob } from './poller-status';
34
35
  import {
35
36
  createWorktree,
36
37
  ensureCacheClone,
@@ -86,6 +87,7 @@ export const runFixJob = async (
86
87
  );
87
88
  }
88
89
  if (sealed === null && !allowCodex) {
90
+ await acknowledgeQueuedJob(deps, issue.number, preamble.approval, 'fix');
89
91
  return {
90
92
  lines: [`#${issue.number}: waiting for run capacity`],
91
93
  ranCodex: false,
@@ -1,7 +1,13 @@
1
1
  // Write-side label and comment operations for the poller. Kept apart from the
2
2
  // read helpers so each module stays a single reviewable concern.
3
3
 
4
- import { apiError, HTTP_CREATED, HTTP_OK, request } from './github-api';
4
+ import {
5
+ apiError,
6
+ HTTP_CREATED,
7
+ HTTP_NO_CONTENT,
8
+ HTTP_OK,
9
+ request,
10
+ } from './github-api';
5
11
  import { isRecord } from './github-settings-parse';
6
12
 
7
13
  const HTTP_NOT_FOUND = 404;
@@ -85,3 +91,23 @@ export const createComment = async (
85
91
  }
86
92
  return response.body.id;
87
93
  };
94
+
95
+ export const deleteComment = async (
96
+ token: string | null,
97
+ repo: string,
98
+ commentId: number,
99
+ ): Promise<void> => {
100
+ const response = await request(
101
+ token,
102
+ 'DELETE',
103
+ `/repos/${repo}/issues/comments/${commentId}`,
104
+ );
105
+ if (
106
+ response.status !== HTTP_NO_CONTENT &&
107
+ response.status !== HTTP_NOT_FOUND
108
+ ) {
109
+ throw new Error(
110
+ apiError(`delete comment ${commentId} in ${repo}`, response),
111
+ );
112
+ }
113
+ };
@@ -16,7 +16,8 @@ export const DEFERRED_FINDING = 'deferred-finding';
16
16
  // questions and failure reports apart from human conversation.
17
17
  export const QUESTION_MARKER = '<!-- standards-poller:question -->';
18
18
  export const FAILURE_MARKER = '<!-- standards-poller:failure -->';
19
- export const CLAIM_MARKER = '<!-- standards-poller:claim -->';
19
+ export const CLAIM_METADATA_MARKER = 'standards-poller:claim';
20
+ export const QUEUE_METADATA_MARKER = 'standards-poller:queue';
20
21
  export const FIX_OUTPUT_MARKER = 'standards-poller:fix-output';
21
22
 
22
23
  // Repository roles trusted to approve automation and answer its questions.
@@ -0,0 +1,194 @@
1
+ import { hasLabel } from './github-label-identity';
2
+ import type { ApprovalBinding } from './poller-approval';
3
+ import { parseHiddenCommentMetadata } from './poller-comment-metadata';
4
+ import {
5
+ type IssueItem,
6
+ lastLabelEvent,
7
+ listIssueComments,
8
+ } from './poller-github';
9
+ import type { JobDeps } from './poller-job-shared';
10
+ import {
11
+ APPROVED_FOR_FIX,
12
+ APPROVED_FOR_REVIEW,
13
+ FIX_IN_PROGRESS,
14
+ isTrustedRole,
15
+ NEEDS_CLARIFICATION,
16
+ QUEUE_METADATA_MARKER,
17
+ REVIEW_IN_PROGRESS,
18
+ } from './poller-protocol';
19
+ import { cachedCollaboratorRole } from './poller-trust';
20
+
21
+ export type PollerJobKind = 'fix' | 'review';
22
+
23
+ type ApprovalCheckpoint = {
24
+ readonly actorLogin: string;
25
+ readonly approvedAt: string;
26
+ readonly eventId: number;
27
+ readonly target: string;
28
+ };
29
+
30
+ type QueueMarker = {
31
+ readonly approvalId: string;
32
+ readonly kind: PollerJobKind;
33
+ readonly approval: ApprovalCheckpoint | null;
34
+ };
35
+
36
+ export const inProgressLabelFor = (kind: PollerJobKind) =>
37
+ kind === 'fix' ? FIX_IN_PROGRESS : REVIEW_IN_PROGRESS;
38
+
39
+ export const approvalLabelFor = (kind: PollerJobKind) =>
40
+ kind === 'fix' ? APPROVED_FOR_FIX : APPROVED_FOR_REVIEW;
41
+
42
+ export const queueMarkerFor = (
43
+ approval: ApprovalBinding,
44
+ kind: PollerJobKind,
45
+ ): QueueMarker => ({
46
+ approvalId: approval.id,
47
+ kind,
48
+ approval: {
49
+ actorLogin: approval.actorLogin,
50
+ approvedAt: approval.approvedAt,
51
+ eventId: approval.eventId,
52
+ target: approval.target,
53
+ },
54
+ });
55
+
56
+ const queueMarker = (body: string): QueueMarker | null => {
57
+ const raw = parseHiddenCommentMetadata(body, QUEUE_METADATA_MARKER);
58
+ if (typeof raw !== 'object' || raw === null) {
59
+ return null;
60
+ }
61
+ const candidate = raw as {
62
+ readonly approvalId?: unknown;
63
+ readonly approval?: unknown;
64
+ readonly kind?: unknown;
65
+ };
66
+ if (
67
+ typeof candidate.approvalId !== 'string' ||
68
+ (candidate.kind !== 'fix' && candidate.kind !== 'review')
69
+ ) {
70
+ return null;
71
+ }
72
+ if (candidate.approval === undefined) {
73
+ return {
74
+ approvalId: candidate.approvalId,
75
+ kind: candidate.kind,
76
+ approval: null,
77
+ };
78
+ }
79
+ if (typeof candidate.approval !== 'object' || candidate.approval === null) {
80
+ return null;
81
+ }
82
+ const approval = candidate.approval as {
83
+ readonly actorLogin?: unknown;
84
+ readonly approvedAt?: unknown;
85
+ readonly eventId?: unknown;
86
+ readonly target?: unknown;
87
+ };
88
+ if (
89
+ typeof approval.actorLogin !== 'string' ||
90
+ typeof approval.approvedAt !== 'string' ||
91
+ typeof approval.eventId !== 'number' ||
92
+ typeof approval.target !== 'string'
93
+ ) {
94
+ return null;
95
+ }
96
+ return {
97
+ approvalId: candidate.approvalId,
98
+ kind: candidate.kind,
99
+ approval: {
100
+ actorLogin: approval.actorLogin,
101
+ approvedAt: approval.approvedAt,
102
+ eventId: approval.eventId,
103
+ target: approval.target,
104
+ },
105
+ };
106
+ };
107
+
108
+ export const matchingTrustedQueueCommentIds = async (
109
+ deps: JobDeps,
110
+ issueNumber: number,
111
+ approvalId: string,
112
+ kind: PollerJobKind,
113
+ ): Promise<ReadonlyArray<number>> => {
114
+ const comments = await listIssueComments(deps.token, deps.repo, issueNumber);
115
+ const ids: Array<number> = [];
116
+ for (const comment of comments) {
117
+ const marker = queueMarker(comment.body);
118
+ if (marker?.approvalId === approvalId && marker.kind === kind) {
119
+ // biome-ignore lint/performance/noAwaitInLoops: role reads use the shared per-tick cache; marker authorship is a trust boundary.
120
+ const role = await cachedCollaboratorRole(
121
+ {
122
+ token: deps.token,
123
+ repo: deps.repo,
124
+ issueNumber,
125
+ roleCache: deps.roleCache,
126
+ },
127
+ comment.authorLogin,
128
+ );
129
+ if (isTrustedRole(role)) {
130
+ ids.push(comment.id);
131
+ }
132
+ }
133
+ }
134
+ return ids;
135
+ };
136
+
137
+ export const inspectQueuedAcknowledgement = async (options: {
138
+ readonly deps: JobDeps;
139
+ readonly item: IssueItem;
140
+ readonly kind: PollerJobKind;
141
+ readonly target: string;
142
+ }): Promise<boolean> => {
143
+ const { deps, item, kind, target } = options;
144
+ const expectedLabel = approvalLabelFor(kind);
145
+ if (
146
+ !hasLabel(item.labels, expectedLabel) ||
147
+ hasLabel(item.labels, inProgressLabelFor(kind)) ||
148
+ hasLabel(item.labels, NEEDS_CLARIFICATION)
149
+ ) {
150
+ return false;
151
+ }
152
+ const comments = await listIssueComments(deps.token, deps.repo, item.number);
153
+ const checkpoints: Array<ApprovalCheckpoint> = [];
154
+ for (const comment of comments) {
155
+ const marker = queueMarker(comment.body);
156
+ if (
157
+ marker?.kind === kind &&
158
+ marker.approval !== null &&
159
+ marker.approval.target === target
160
+ ) {
161
+ // biome-ignore lint/performance/noAwaitInLoops: role reads use the shared per-tick cache; marker authorship is a trust boundary.
162
+ const role = await cachedCollaboratorRole(
163
+ {
164
+ token: deps.token,
165
+ repo: deps.repo,
166
+ issueNumber: item.number,
167
+ roleCache: deps.roleCache,
168
+ },
169
+ comment.authorLogin,
170
+ );
171
+ if (isTrustedRole(role)) {
172
+ checkpoints.push(marker.approval);
173
+ }
174
+ }
175
+ }
176
+ if (checkpoints.length === 0) {
177
+ return false;
178
+ }
179
+ const event = await lastLabelEvent(
180
+ deps.token,
181
+ deps.repo,
182
+ item.number,
183
+ expectedLabel,
184
+ );
185
+ return (
186
+ event !== null &&
187
+ checkpoints.some(
188
+ (checkpoint) =>
189
+ checkpoint.eventId === event.id &&
190
+ checkpoint.actorLogin === event.actorLogin &&
191
+ checkpoint.approvedAt === event.createdAt,
192
+ )
193
+ );
194
+ };
@@ -0,0 +1,33 @@
1
+ import type { PullRequest } from './poller-github-pulls';
2
+
3
+ export type ReviewEligibility =
4
+ | { readonly kind: 'eligible' }
5
+ | { readonly kind: 'recovering' }
6
+ | {
7
+ readonly kind: 'rejected';
8
+ readonly message: string;
9
+ readonly result: string;
10
+ };
11
+
12
+ export const reviewEligibility = (options: {
13
+ readonly repo: string;
14
+ readonly pr: PullRequest;
15
+ readonly hasPlan: boolean;
16
+ }): ReviewEligibility => {
17
+ const { repo, pr, hasPlan } = options;
18
+ if (!(pr.draft || hasPlan)) {
19
+ return {
20
+ kind: 'rejected',
21
+ message: 'automated review requires a draft PR',
22
+ result: 'rejected (not draft)',
23
+ };
24
+ }
25
+ if (pr.headRepo !== repo) {
26
+ return {
27
+ kind: 'rejected',
28
+ message: `this PR's head branch lives in ${pr.headRepo || 'an unknown repository'}; automated review runs only support same-repository branches`,
29
+ result: 'rejected (fork head)',
30
+ };
31
+ }
32
+ return { kind: hasPlan ? 'recovering' : 'eligible' };
33
+ };
@@ -21,6 +21,7 @@ import {
21
21
  REVIEW_FAILED,
22
22
  REVIEW_IN_PROGRESS,
23
23
  } from './poller-protocol';
24
+ import { reviewEligibility } from './poller-review-eligibility';
24
25
  import { executeReviewJob } from './poller-review-execution';
25
26
  import {
26
27
  readSealedReviewPlan,
@@ -28,6 +29,7 @@ import {
28
29
  } from './poller-review-output';
29
30
  import { resumeReviewedJob } from './poller-review-publication';
30
31
  import { publishReviewPlan, readReviewPlan } from './poller-review-state';
32
+ import { acknowledgeQueuedJob } from './poller-status';
31
33
  import { ensureCacheClone, localBranchExists } from './poller-workspace';
32
34
 
33
35
  const REVIEW_LABELS: JobLabels = {
@@ -73,30 +75,15 @@ export const runReviewJob = async (
73
75
  ranCodex: false,
74
76
  };
75
77
  }
76
- if (!pr.draft && plan === null) {
77
- await failJob(
78
- deps,
79
- REVIEW_LABELS,
80
- pr.number,
81
- 'automated review requires a draft PR',
82
- );
83
- return {
84
- lines: [`PR #${pr.number}: rejected (not draft)`],
85
- ranCodex: false,
86
- };
87
- }
88
- // Fork PRs are out of scope: their head branch lives in a repository the
89
- // poller must not push to, and pushing a same-named branch to the base repo
90
- // would fake a review. Fail explicitly instead of pretending.
91
- if (pr.headRepo !== repo) {
92
- await failJob(
93
- deps,
94
- REVIEW_LABELS,
95
- pr.number,
96
- `this PR's head branch lives in ${pr.headRepo || 'an unknown repository'}; automated review runs only support same-repository branches`,
97
- );
78
+ const eligibility = reviewEligibility({
79
+ repo,
80
+ pr,
81
+ hasPlan: plan !== null,
82
+ });
83
+ if (eligibility.kind === 'rejected') {
84
+ await failJob(deps, REVIEW_LABELS, pr.number, eligibility.message);
98
85
  return {
99
- lines: [`PR #${pr.number}: rejected (fork head)`],
86
+ lines: [`PR #${pr.number}: ${eligibility.result}`],
100
87
  ranCodex: false,
101
88
  };
102
89
  }
@@ -113,6 +100,7 @@ export const runReviewJob = async (
113
100
  throw new Error(`sealed output on ${outputBranch} is invalid`);
114
101
  }
115
102
  if (plan === null && sealed === null && !allowCodex) {
103
+ await acknowledgeQueuedJob(deps, pr.number, preamble.approval, 'review');
116
104
  return {
117
105
  lines: [`PR #${pr.number}: waiting for run capacity`],
118
106
  ranCodex: false,
@@ -0,0 +1,133 @@
1
+ import { hasLabel } from './github-label-identity';
2
+ import {
3
+ type ApprovalBinding,
4
+ issueRevision,
5
+ prRevision,
6
+ readApprovalBinding,
7
+ } from './poller-approval';
8
+ import { hiddenCommentMetadata } from './poller-comment-metadata';
9
+ import { getIssue } from './poller-github';
10
+ import { getPullRequest } from './poller-github-pulls';
11
+ import { createComment, deleteComment } from './poller-github-write';
12
+ import type { JobDeps } from './poller-job-shared';
13
+ import { NEEDS_CLARIFICATION, QUEUE_METADATA_MARKER } from './poller-protocol';
14
+ import {
15
+ approvalLabelFor,
16
+ inProgressLabelFor,
17
+ matchingTrustedQueueCommentIds,
18
+ type PollerJobKind,
19
+ queueMarkerFor,
20
+ } from './poller-queue-marker';
21
+ import { reviewEligibility } from './poller-review-eligibility';
22
+
23
+ const queuedMessage = (kind: PollerJobKind) =>
24
+ kind === 'fix'
25
+ ? '**Fix queued**\n\nThe poller has picked this up and will start work as soon as a run is available. You don’t need to do anything else.'
26
+ : '**Review queued**\n\nThe poller has picked this up and will start the review as soon as a run is available. You don’t need to do anything else.';
27
+
28
+ const isStillQueueable = async (
29
+ deps: JobDeps,
30
+ issueNumber: number,
31
+ approval: ApprovalBinding,
32
+ kind: PollerJobKind,
33
+ ): Promise<boolean> => {
34
+ const expectedApprovalLabel = approvalLabelFor(kind);
35
+ if (
36
+ approval.repo !== deps.repo ||
37
+ approval.issueNumber !== issueNumber ||
38
+ approval.label !== expectedApprovalLabel
39
+ ) {
40
+ return false;
41
+ }
42
+ const issue = await getIssue(deps.token, deps.repo, issueNumber);
43
+ if (
44
+ !hasLabel(issue.labels, expectedApprovalLabel) ||
45
+ hasLabel(issue.labels, inProgressLabelFor(kind)) ||
46
+ hasLabel(issue.labels, NEEDS_CLARIFICATION)
47
+ ) {
48
+ return false;
49
+ }
50
+ let target = issueRevision(issue);
51
+ if (kind === 'review') {
52
+ const pr = await getPullRequest(deps.token, deps.repo, issueNumber);
53
+ if (
54
+ reviewEligibility({
55
+ repo: deps.repo,
56
+ pr,
57
+ hasPlan: false,
58
+ }).kind !== 'eligible'
59
+ ) {
60
+ return false;
61
+ }
62
+ target = prRevision(pr.baseRef, pr.baseSha, pr.headSha);
63
+ }
64
+ if (target !== approval.target) {
65
+ return false;
66
+ }
67
+ const currentApproval = await readApprovalBinding(
68
+ {
69
+ token: deps.token,
70
+ repo: deps.repo,
71
+ issueNumber,
72
+ },
73
+ expectedApprovalLabel,
74
+ target,
75
+ );
76
+ return (
77
+ typeof currentApproval !== 'string' && currentApproval.id === approval.id
78
+ );
79
+ };
80
+
81
+ export const acknowledgeQueuedJob = async (
82
+ deps: JobDeps,
83
+ issueNumber: number,
84
+ approval: ApprovalBinding,
85
+ kind: PollerJobKind,
86
+ ): Promise<boolean> => {
87
+ const expected = queueMarkerFor(approval, kind);
88
+ const current = await getIssue(deps.token, deps.repo, issueNumber);
89
+ if (
90
+ hasLabel(current.labels, inProgressLabelFor(kind)) ||
91
+ hasLabel(current.labels, NEEDS_CLARIFICATION)
92
+ ) {
93
+ return false;
94
+ }
95
+ if (
96
+ (
97
+ await matchingTrustedQueueCommentIds(
98
+ deps,
99
+ issueNumber,
100
+ expected.approvalId,
101
+ kind,
102
+ )
103
+ ).length > 0
104
+ ) {
105
+ return false;
106
+ }
107
+ const markerId = await createComment(
108
+ deps.token,
109
+ deps.repo,
110
+ issueNumber,
111
+ `${queuedMessage(kind)}\n\n${hiddenCommentMetadata(
112
+ QUEUE_METADATA_MARKER,
113
+ expected,
114
+ )}`,
115
+ );
116
+ const [winner] = [
117
+ ...(await matchingTrustedQueueCommentIds(
118
+ deps,
119
+ issueNumber,
120
+ expected.approvalId,
121
+ kind,
122
+ )),
123
+ ].sort((left, right) => left - right);
124
+ if (winner !== markerId) {
125
+ await deleteComment(deps.token, deps.repo, markerId);
126
+ return false;
127
+ }
128
+ if (!(await isStillQueueable(deps, issueNumber, approval, kind))) {
129
+ await deleteComment(deps.token, deps.repo, markerId);
130
+ return false;
131
+ }
132
+ return true;
133
+ };
@@ -19,7 +19,7 @@ export type TrustContext = {
19
19
  readonly roleCache: RoleCache;
20
20
  };
21
21
 
22
- const cachedRole = async (
22
+ export const cachedCollaboratorRole = async (
23
23
  context: TrustContext,
24
24
  username: string,
25
25
  ): Promise<string> => {
@@ -53,7 +53,7 @@ const lastTrustedQuestionIndex = async (
53
53
  const comment = comments[index];
54
54
  if (comment !== undefined && hasQuestionMarker(comment)) {
55
55
  // biome-ignore lint/performance/noAwaitInLoops: role lookups go through the shared per-tick cache; parallel lookups would race it and duplicate API reads.
56
- const role = await cachedRole(context, comment.authorLogin);
56
+ const role = await cachedCollaboratorRole(context, comment.authorLogin);
57
57
  if (isTrustedRole(role)) {
58
58
  return index;
59
59
  }
@@ -87,7 +87,7 @@ export const answerState = async (
87
87
  );
88
88
  for (const comment of candidates) {
89
89
  // biome-ignore lint/performance/noAwaitInLoops: role lookups go through the shared per-tick cache; parallel lookups would race it and duplicate API reads.
90
- const role = await cachedRole(context, comment.authorLogin);
90
+ const role = await cachedCollaboratorRole(context, comment.authorLogin);
91
91
  if (isTrustedRole(role)) {
92
92
  answers.push(comment.body);
93
93
  }
@@ -8,11 +8,11 @@ import { fileURLToPath } from 'node:url';
8
8
  import type { PollerConfig } from './poller-config';
9
9
 
10
10
  const SERVICE_NAME = 'standards-poller';
11
- // Bounds idle pickup latency while preserving a full cooldown after each tick:
12
- // OnUnitInactiveSec starts counting when the oneshot service finishes.
13
- // AccuracySec matters at this scale: systemd's default 1min accuracy would
14
- // stretch a 1min interval into a 1-2min window.
15
- const TICK_INTERVAL_MINUTES = 1;
11
+ // OnUnitInactiveSec starts counting when each oneshot service finishes.
12
+ // Keep worker pickup prompt while giving the acknowledgement observer enough
13
+ // API headroom for mixed queues, recovery plans, pagination, and worker calls.
14
+ const WORKER_INTERVAL_MINUTES = 1;
15
+ export const ACKNOWLEDGEMENT_INTERVAL_MINUTES = 5;
16
16
  // Non-agent tick work: stale sweeps, clone/fetch, GitHub reads and writes.
17
17
  const TICK_OVERHEAD_MINUTES = 30;
18
18
  const MAX_ASCII_CONTROL_CODE = 31;
@@ -50,7 +50,12 @@ const tickBudgetMinutes = (config: PollerConfig): number =>
50
50
  export const renderUnits = (
51
51
  configPath: string,
52
52
  config: PollerConfig,
53
- ): { readonly service: string; readonly timer: string } => ({
53
+ ): {
54
+ readonly service: string;
55
+ readonly timer: string;
56
+ readonly acknowledgementService: string;
57
+ readonly acknowledgementTimer: string;
58
+ } => ({
54
59
  service: `[Unit]
55
60
  Description=Standards fix poller tick
56
61
  After=network-online.target
@@ -66,7 +71,29 @@ Description=Run the standards fix poller on an interval
66
71
 
67
72
  [Timer]
68
73
  OnBootSec=2min
69
- OnUnitInactiveSec=${TICK_INTERVAL_MINUTES}min
74
+ OnUnitInactiveSec=${WORKER_INTERVAL_MINUTES}min
75
+ AccuracySec=15s
76
+ Persistent=true
77
+
78
+ [Install]
79
+ WantedBy=timers.target
80
+ `,
81
+ acknowledgementService: `[Unit]
82
+ Description=Acknowledge queued standards poller requests
83
+ After=network-online.target
84
+ Wants=network-online.target
85
+
86
+ [Service]
87
+ Type=oneshot
88
+ ExecStart=${quoteSystemdExecArg(process.execPath)} ${quoteSystemdExecArg(cliEntryPath())} poller --acknowledge-only --config ${quoteSystemdExecArg(configPath)}
89
+ TimeoutStartSec=${TICK_OVERHEAD_MINUTES}min
90
+ `,
91
+ acknowledgementTimer: `[Unit]
92
+ Description=Check for newly queued standards poller requests
93
+
94
+ [Timer]
95
+ OnBootSec=1min
96
+ OnUnitInactiveSec=${ACKNOWLEDGEMENT_INTERVAL_MINUTES}min
70
97
  AccuracySec=15s
71
98
  Persistent=true
72
99
 
@@ -84,4 +111,8 @@ export const runPollerPrintUnits = (
84
111
  console.log(units.service);
85
112
  console.log(`# ${SERVICE_NAME}.timer`);
86
113
  console.log(units.timer);
114
+ console.log(`# ${SERVICE_NAME}-acknowledgements.service`);
115
+ console.log(units.acknowledgementService);
116
+ console.log(`# ${SERVICE_NAME}-acknowledgements.timer`);
117
+ console.log(units.acknowledgementTimer);
87
118
  };