From d73405f937095df7f32004dd4cb27b2f67ee1cfa Mon Sep 17 00:00:00 2001 From: Aaron Stainback Date: Thu, 30 Apr 2026 01:21:05 -0400 Subject: [PATCH 1/2] =?UTF-8?q?ts(B-0086):=20port=201=20git=20script=20(.s?= =?UTF-8?q?h=E2=86=92.ts)=20=E2=80=94=20slice=2013=20of=20TS/Bun=20migrati?= =?UTF-8?q?on?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * ts(slice-13, wip 1/N): port git/push-with-retry (.sh→.ts) First script of slice 13. Thin retry wrapper over `git push` for transient GitHub 5xx errors. DST-ACCEPTED-BOUNDARY classification preserved (Otto-168, network I/O + retry-on-failure). Byte-equivalent on env-validation paths. Network-dependent retry path tested locally only (success-path requires real push; 5xx- retry-path requires real 5xx). Mechanical changes: - bash `[[ =~ $int_re ]]` regex → POSITIVE_INT_RE.test() - bash mktemp + tee + grep tmp file → spawnSync stderr-capture + TRANSIENT_5XX_RE.test() against captured string - bash `set +e; git push; exit_code=$?; set -e` → result.status ?? 1 - bash `sleep $backoff` → Atomics.wait(view, 0, 0, seconds * 1000) synchronous-busy-wait (preserves the script's synchronous flow that the bash original assumes; async setTimeout would change exit-code semantics) - Exponential backoff doubling preserved (`backoff *= 2`) Behavioural note: bash uses `tee` for live + capture; TS uses spawnSync for capture-then-replay. UX difference invisible for typical git-push runtimes (seconds). Documented in port header. Lint-clean: bun --bun tsc --noEmit + eslint strictTypeChecked + sonarjs all pass on the new file. * trajectory(ts-bun): slice 13 audit substrate + RESUME tracker - slice-audits.md: append slice-13 audit (1 port — git/push-with-retry). - RESUME.md: bump slice-11-merged → slice-12-merged (#885, commit cfb5964). Milestone 33 → 34 (33 ported + 1 in-flight in slice-13). Bucket B 10 → 9. Bucket D ported list grew to 33 entries. --- .../typescript-bun-migration/RESUME.md | 14 +- .../typescript-bun-migration/slice-audits.md | 24 +++ tools/git/push-with-retry.ts | 146 ++++++++++++++++++ 3 files changed, 177 insertions(+), 7 deletions(-) create mode 100644 tools/git/push-with-retry.ts diff --git a/docs/trajectories/typescript-bun-migration/RESUME.md b/docs/trajectories/typescript-bun-migration/RESUME.md index 778d78832..b9f863d0d 100644 --- a/docs/trajectories/typescript-bun-migration/RESUME.md +++ b/docs/trajectories/typescript-bun-migration/RESUME.md @@ -1,9 +1,9 @@ # Trajectory — TypeScript / Bun migration -**Status**: Active (Lane B slice 11 merged — [#884](https://github.com/Lucent-Financial-Group/Zeta/pull/884), commit `9237756`) -**Milestone**: 32 ported + 1 in-flight = 33 total (2 from #849 + 3 from #866 + 3 from #868 + 3 from #870 + 2 from #872 + 3 from #874 + 3 from #876 + 3 from #878 + 3 from #880 + 3 from #882 + 2 from #883 + 2 from #884 = 32 merged; +1 in-flight in slice-12). Slice-12 opens **backlog index regenerator** (backlog/generate-index). 10 Bucket B files remain. +**Status**: Active (Lane B slice 12 merged — [#885](https://github.com/Lucent-Financial-Group/Zeta/pull/885), commit `cfb5964`) +**Milestone**: 33 ported + 1 in-flight = 34 total (2 from #849 + 3 from #866 + 3 from #868 + 3 from #870 + 2 from #872 + 3 from #874 + 3 from #876 + 3 from #878 + 3 from #880 + 3 from #882 + 2 from #883 + 2 from #884 + 1 from #885 = 33 merged; +1 in-flight in slice-13). Slice-13 opens **git-cluster** (git/push-with-retry). 9 Bucket B files remain. **Current blocker**: None. -**Next concrete action**: Pick a coherent next slice from Bucket B (10 files remaining). Per Gate B: read-only scope first, then re-verify the layered baseline currency before first mutating action. +**Next concrete action**: Pick a coherent next slice from Bucket B (9 files remaining). Per Gate B: read-only scope first, then re-verify the layered baseline currency before first mutating action. **Last updated**: 2026-04-30 ## Why this trajectory exists @@ -64,17 +64,16 @@ tools/profile.sh Rationale: TS/Bun is itself one of the things `install.sh` installs. These scripts cannot depend on Bun. -### Bucket B — Should become TypeScript (10 files remaining) +### Bucket B — Should become TypeScript (9 files remaining) -Post-install scripts that operate on the repo (lints, audits, hygiene checks, peer-call wrappers, budget reports, git ops). Same shape as the scripts ported in #849, #866, #868, #870, #872, #874, #876, #878, #880, #882, #883, #884. The originally-listed audit/lint scripts have progressively ported (1 in slice-12 in flight); the bash originals remain in-tree as the equivalence reference and will retire once the TS ports have soaked. +Post-install scripts that operate on the repo (lints, audits, hygiene checks, peer-call wrappers, budget reports, git ops). Same shape as the scripts ported in #849, #866, #868, #870, #872, #874, #876, #878, #880, #882, #883, #884, #885. The originally-listed audit/lint scripts have progressively ported (1 in slice-13 in flight — git/push-with-retry); the bash originals remain in-tree as the equivalence reference and will retire once the TS ports have soaked. ```text -tools/backlog/generate-index.sh # in flight (slice 12) tools/budget/daily-cost-report.sh tools/budget/project-runway.sh tools/budget/snapshot-burn.sh tools/git/batch-resolve-pr-threads.sh -tools/git/push-with-retry.sh +tools/git/push-with-retry.sh # in flight (slice 13) tools/peer-call/codex.sh tools/peer-call/gemini.sh tools/peer-call/grok.sh @@ -133,6 +132,7 @@ tools/hygiene/counterweight-audit.sh # ported in #883 tools/hygiene/append-tick-history-row.sh # ported in #883 tools/skill-catalog/backfill_dv2_frontmatter.sh # ported in #884 tools/audit-packages.sh # ported in #884 +tools/backlog/generate-index.sh # ported in #885 ``` ## Recommended next slice diff --git a/docs/trajectories/typescript-bun-migration/slice-audits.md b/docs/trajectories/typescript-bun-migration/slice-audits.md index 94192acf3..bbc024800 100644 --- a/docs/trajectories/typescript-bun-migration/slice-audits.md +++ b/docs/trajectories/typescript-bun-migration/slice-audits.md @@ -411,6 +411,30 @@ Per-port pattern checklist: Slice 6 passes audit. No new patterns recorded — all reused from prior slices. +## Slice 13 — 1 port (git/push-with-retry — git-cluster opens) (PR pending — `lane-b/ts-bun-slice-13-push-with-retry-2026-04-30`) + +**Slice files**: + +- `tools/git/push-with-retry.{sh→ts}` (`git push` retry wrapper for transient GitHub 5xx) + +**Comparison points**: identical to slice 12. Within Gate B 30-day window. tsc gate now active in CI per PR #890. + +### Code-pattern audit (per-port) + +- **`push-with-retry.ts`** (129 → 138 lines): bash `[[ =~ $int_re ]]` regex → `POSITIVE_INT_RE.test()`. Bash `mktemp` + tee + grep on tmp file → `spawnSync` with `stdio: ['inherit', 'inherit', 'pipe']` capturing stderr in-memory; `TRANSIENT_5XX_RE.test()` against captured string. Bash `set +e; git push; exit_code=$?; set -e` → straightforward `result.status ?? 1`. Bash `sleep "$backoff"` → `Atomics.wait(view, 0, 0, seconds * 1000)` synchronous-busy-wait pattern (preserves the script's synchronous flow; async setTimeout would change exit-code semantics). Exponential backoff doubling preserved (`backoff *= 2`). DST-ACCEPTED-BOUNDARY classification preserved in header comment (Otto-168 boundary registry §3). + +### Equivalence audit + +- **`push-with-retry`**: byte-equivalent on env-validation paths (invalid `GIT_PUSH_MAX_ATTEMPTS=foo` → exit 2 with same message; invalid `GIT_PUSH_BACKOFF_S=-1` → exit 2). Network-dependent retry path can't be tested without an actual 5xx; success path tests would need a real push (deferred to live-fire usage). + +### Behavioural note vs bash original + +The bash version uses `tee` to BOTH capture stderr AND stream it live during each attempt. The TS port uses `spawnSync` which captures-then-replays stderr after each attempt completes. For typical git-push runtimes (seconds) the UX difference is invisible; for long pushes the stderr appears in batches per attempt rather than streaming live. Documented in port file header. + +### Outcome + +Slice 13 passes audit. **Git-cluster opens** (first of 2 git scripts). Bucket B 10 → 9. The remaining cluster — peer-call trio + budget trio + git/batch-resolve + pr-preservation/archive-pr — all need careful design (LLM non-determinism, shared-state mutation, gh API mutation). + ## Slice 12 — 1 port (backlog index regenerator) (PR pending — `lane-b/ts-bun-slice-12-backlog-generate-index-2026-04-30`) **Slice files**: diff --git a/tools/git/push-with-retry.ts b/tools/git/push-with-retry.ts new file mode 100644 index 000000000..ae658461a --- /dev/null +++ b/tools/git/push-with-retry.ts @@ -0,0 +1,146 @@ +#!/usr/bin/env bun +// push-with-retry.ts — `git push` retry wrapper for transient 5xx. +// +// TypeScript+Bun port of push-with-retry.sh, slice 13 of the TS+Bun +// migration. See docs/best-practices/repo-scripting.md. +// +// Why this exists: +// The factory observed recurring transient GitHub 500s during +// autonomous-loop tick-close commits (multiple occurrences +// 2026-04-23). Manual retry burns tick budget; this wrapper makes +// the retry uniform. +// +// DST classification: ACCEPTED_BOUNDARY (external network I/O + +// retry-on-failure) per the boundary registry at +// `docs/research/dst-accepted-boundaries.md` §3 and Otto-168. +// +// Usage: +// bun tools/git/push-with-retry.ts [git push args...] +// bun tools/git/push-with-retry.ts +// bun tools/git/push-with-retry.ts --set-upstream origin my-branch +// +// Exit codes: +// 0 push succeeded (possibly after retries) +// 1 all retries exhausted on transient 5xx +// 2 environment validation failed (non-integer env value) +// N non-transient error — propagates `git push`'s own exit code +// +// Environment: +// GIT_PUSH_MAX_ATTEMPTS override retry count (default 3; positive int) +// GIT_PUSH_BACKOFF_S override initial backoff seconds (default 2; +// doubles each retry; non-negative int) +// +// Behavioural note vs bash original: +// The bash version uses `tee` to BOTH capture stderr AND stream it +// live. This port uses spawnSync which captures-then-replays stderr +// after each attempt completes. For typical git-push runtimes +// (seconds) the UX difference is invisible; for long pushes the +// stderr appears in batches per attempt rather than streaming. + +import { spawnSync } from "node:child_process"; + +const POSITIVE_INT_RE = /^\d+$/; + +const TRANSIENT_5XX_RE = + /(500|502|503|504|Internal Server Error|Bad Gateway|Service Unavailable|Gateway Timeout)/; + +interface Validated { + readonly maxAttempts: number; + readonly initialBackoff: number; +} + +function validateEnv(): Validated | { readonly error: string } { + const maxAttemptsRaw = process.env.GIT_PUSH_MAX_ATTEMPTS ?? "3"; + const backoffRaw = process.env.GIT_PUSH_BACKOFF_S ?? "2"; + + if (!POSITIVE_INT_RE.test(maxAttemptsRaw)) { + return { + error: `push-with-retry: GIT_PUSH_MAX_ATTEMPTS must be a positive integer; got '${maxAttemptsRaw}'`, + }; + } + const maxAttempts = Number.parseInt(maxAttemptsRaw, 10); + if (maxAttempts < 1) { + return { + error: `push-with-retry: GIT_PUSH_MAX_ATTEMPTS must be a positive integer; got '${maxAttemptsRaw}'`, + }; + } + + if (!POSITIVE_INT_RE.test(backoffRaw)) { + return { + error: `push-with-retry: GIT_PUSH_BACKOFF_S must be a non-negative integer; got '${backoffRaw}'`, + }; + } + const initialBackoff = Number.parseInt(backoffRaw, 10); + + return { maxAttempts, initialBackoff }; +} + +function sleepSeconds(seconds: number): void { + // Synchronous busy-wait via Atomics.wait on a SharedArrayBuffer. + // Bun supports Atomics.wait on a shared int32 view; falls back to + // a tight loop if not available. We need a synchronous sleep + // because the bash original uses `sleep` between attempts inside + // a synchronous loop, and entry guard / exit-code semantics + // depend on the script staying synchronous. + const sab = new SharedArrayBuffer(4); + const view = new Int32Array(sab); + Atomics.wait(view, 0, 0, seconds * 1000); +} + +function runOnce(args: readonly string[]): { + status: number; + stderr: string; +} { + // eslint-disable-next-line sonarjs/no-os-command-from-path + const result = spawnSync("git", ["push", ...args], { + stdio: ["inherit", "inherit", "pipe"], + encoding: "utf8", + maxBuffer: 64 * 1024 * 1024, + }); + const stderr = result.stderr; + // Replay captured stderr to the user (bash original streams via + // tee; we batch). + if (stderr.length > 0) { + process.stderr.write(stderr); + } + return { status: result.status ?? 1, stderr }; +} + +export function main(args: readonly string[]): number { + const validated = validateEnv(); + if ("error" in validated) { + process.stderr.write(`${validated.error}\n`); + return 2; + } + + let backoff = validated.initialBackoff; + for (let attempt = 1; attempt <= validated.maxAttempts; attempt++) { + const { status, stderr } = runOnce(args); + if (status === 0) return 0; + + if (TRANSIENT_5XX_RE.test(stderr)) { + if (attempt < validated.maxAttempts) { + process.stderr.write( + `push-with-retry: transient 5xx on attempt ${String(attempt)}/${String(validated.maxAttempts)}; retrying in ${String(backoff)}s...\n`, + ); + if (backoff > 0) sleepSeconds(backoff); + backoff *= 2; + continue; + } + process.stderr.write( + `push-with-retry: failed after ${String(validated.maxAttempts)} attempts on transient 5xx\n`, + ); + return 1; + } + + // Non-transient error — propagate git push's own exit code. + return status; + } + + // Unreachable (the loop always returns), but TS needs an exit. + return 1; +} + +if (import.meta.main) { + process.exit(main(process.argv.slice(2))); +} From 3d48e2d6517179cf0c1040dd8cbf7a45f00c5941 Mon Sep 17 00:00:00 2001 From: Aaron Stainback Date: Thu, 30 Apr 2026 01:28:55 -0400 Subject: [PATCH 2/2] review(slice-13): address Codex P2 + Copilot P1/P2 threads on #892 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Five real findings from automated reviewers: Codex P2 — Guard null stderr from failed spawnSync: When spawnSync cannot start git (ENOENT etc.), result.stderr is null at runtime even though @types/node claims `string`. Added `?? ""` guard with eslint-disable + rationale comment so the downstream regex match is safe. Copilot P1 — spawnSync failure modes weren't being classified: Added classifySpawnFailure helper handling 4 cases: - status !== null: passthrough - error.code === ENOENT: return 127 (matches bash command-not-found) - other error.code: return 1 with error message - signal !== null: return 1 with signal name - otherwise: return 1 with "no exit code" note Each case writes a contextual message to stderr. Copilot P1 — sleepSeconds comment claimed nonexistent fallback: Comment claimed "falls back to a tight loop if not available" but no fallback was implemented. Reframed comment to state the actual Bun-vs-Node main-thread Atomics.wait behavior. No fallback needed: the shebang pins Bun, which supports main-thread Atomics.wait. Copilot P2 — eslint-disable lacked justification: Added rationale comment ABOVE the disable directive (not below — eslint-disable-next-line applies to the literal next line, so the rationale must precede the directive, not split it from the code). Same security posture as bash `git push "$@"`. Copilot P1 — line-count drift in audit: Audit said "129 → 138 lines" but the new TS file is now 184 lines after the spawn-failure classification. Updated the audit. --- .../typescript-bun-migration/slice-audits.md | 2 +- tools/git/push-with-retry.ts | 57 ++++++++++++++++--- 2 files changed, 50 insertions(+), 9 deletions(-) diff --git a/docs/trajectories/typescript-bun-migration/slice-audits.md b/docs/trajectories/typescript-bun-migration/slice-audits.md index bbc024800..656995e1d 100644 --- a/docs/trajectories/typescript-bun-migration/slice-audits.md +++ b/docs/trajectories/typescript-bun-migration/slice-audits.md @@ -421,7 +421,7 @@ Slice 6 passes audit. No new patterns recorded — all reused from prior slices. ### Code-pattern audit (per-port) -- **`push-with-retry.ts`** (129 → 138 lines): bash `[[ =~ $int_re ]]` regex → `POSITIVE_INT_RE.test()`. Bash `mktemp` + tee + grep on tmp file → `spawnSync` with `stdio: ['inherit', 'inherit', 'pipe']` capturing stderr in-memory; `TRANSIENT_5XX_RE.test()` against captured string. Bash `set +e; git push; exit_code=$?; set -e` → straightforward `result.status ?? 1`. Bash `sleep "$backoff"` → `Atomics.wait(view, 0, 0, seconds * 1000)` synchronous-busy-wait pattern (preserves the script's synchronous flow; async setTimeout would change exit-code semantics). Exponential backoff doubling preserved (`backoff *= 2`). DST-ACCEPTED-BOUNDARY classification preserved in header comment (Otto-168 boundary registry §3). +- **`push-with-retry.ts`** (129 → 184 lines): bash `[[ =~ $int_re ]]` regex → `POSITIVE_INT_RE.test()`. Bash `mktemp` + tee + grep on tmp file → `spawnSync` with `stdio: ['inherit', 'inherit', 'pipe']` capturing stderr in-memory; `TRANSIENT_5XX_RE.test()` against captured string. Bash `set +e; git push; exit_code=$?; set -e` → `classifySpawnFailure` helper handling 4 cases: status set / ENOENT (return 127 like bash command-not-found) / other spawn error / signal-terminated. Bash `sleep "$backoff"` → `Atomics.wait(view, 0, 0, seconds * 1000)` synchronous-sleep pattern (preserves the script's synchronous flow; async setTimeout would change exit-code semantics). Exponential backoff doubling preserved (`backoff *= 2`). DST-ACCEPTED-BOUNDARY classification preserved in header comment (Otto-168 boundary registry §3). ### Equivalence audit diff --git a/tools/git/push-with-retry.ts b/tools/git/push-with-retry.ts index ae658461a..c9333fd2d 100644 --- a/tools/git/push-with-retry.ts +++ b/tools/git/push-with-retry.ts @@ -76,34 +76,75 @@ function validateEnv(): Validated | { readonly error: string } { } function sleepSeconds(seconds: number): void { - // Synchronous busy-wait via Atomics.wait on a SharedArrayBuffer. - // Bun supports Atomics.wait on a shared int32 view; falls back to - // a tight loop if not available. We need a synchronous sleep - // because the bash original uses `sleep` between attempts inside - // a synchronous loop, and entry guard / exit-code semantics - // depend on the script staying synchronous. + // Synchronous sleep via Atomics.wait on a SharedArrayBuffer. The + // shebang pins this to Bun, which supports main-thread Atomics.wait + // (Node's main thread throws TypeError; off-thread workers in either + // runtime work too). We need synchronous because the entry guard + // `process.exit(main(...))` depends on `main` returning synchronously. const sab = new SharedArrayBuffer(4); const view = new Int32Array(sab); Atomics.wait(view, 0, 0, seconds * 1000); } +interface SpawnError { + readonly code?: string; +} + +function classifySpawnFailure( + status: number | null, + signal: string | null, + error: SpawnError | undefined, +): { readonly status: number; readonly note: string } { + if (status !== null) return { status, note: "" }; + // status === null implies either a signal termination or a spawn + // failure (ENOENT / EACCES / EAGAIN). Match bash exit-code conventions + // where practical: 127 for command-not-found, 1 otherwise. + if (error?.code === "ENOENT") { + return { status: 127, note: "git not found on PATH (ENOENT)" }; + } + if (error?.code !== undefined) { + return { status: 1, note: `spawn failed (${error.code})` }; + } + if (signal !== null) { + return { status: 1, note: `git terminated by signal ${signal}` }; + } + return { status: 1, note: "git terminated without exit code" }; +} + function runOnce(args: readonly string[]): { status: number; stderr: string; } { + // `git` invocation: no shell interpolation (args passed as + // separate array elements); user-provided args go directly to git + // push, not the shell. Same security posture as the bash original + // which runs `git push "$@"` in an unquoted-glob-safe way. // eslint-disable-next-line sonarjs/no-os-command-from-path const result = spawnSync("git", ["push", ...args], { stdio: ["inherit", "inherit", "pipe"], encoding: "utf8", maxBuffer: 64 * 1024 * 1024, }); - const stderr = result.stderr; + // Defensive: @types/node typings claim `stderr: string` (when + // encoding is set), but the runtime can return `null` when the + // child cannot start (ENOENT/EACCES). Guard with `?? ""` so the + // downstream regex match against the empty string is safe. + // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition + const stderr = result.stderr ?? ""; // Replay captured stderr to the user (bash original streams via // tee; we batch). if (stderr.length > 0) { process.stderr.write(stderr); } - return { status: result.status ?? 1, stderr }; + const classified = classifySpawnFailure( + result.status, + result.signal, + result.error as SpawnError | undefined, + ); + if (classified.note.length > 0) { + process.stderr.write(`push-with-retry: ${classified.note}\n`); + } + return { status: classified.status, stderr }; } export function main(args: readonly string[]): number {