Skip to content
Merged
Show file tree
Hide file tree
Changes from 1 commit
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
14 changes: 7 additions & 7 deletions docs/trajectories/typescript-bun-migration/RESUME.md
Original file line number Diff line number Diff line change
@@ -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
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down
24 changes: 24 additions & 0 deletions docs/trajectories/typescript-bun-migration/slice-audits.md
Original file line number Diff line number Diff line change
Expand Up @@ -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).
Comment thread
AceHack marked this conversation as resolved.
Outdated

### 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**:
Expand Down
146 changes: 146 additions & 0 deletions tools/git/push-with-retry.ts
Original file line number Diff line number Diff line change
@@ -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);
Comment thread
AceHack marked this conversation as resolved.
Outdated
}

function runOnce(args: readonly string[]): {
status: number;
stderr: string;
} {
// eslint-disable-next-line sonarjs/no-os-command-from-path
Comment thread
AceHack marked this conversation as resolved.
const result = spawnSync("git", ["push", ...args], {
stdio: ["inherit", "inherit", "pipe"],
encoding: "utf8",
maxBuffer: 64 * 1024 * 1024,

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Avoid aborting pushes on stderr buffer overflow

Using spawnSync with maxBuffer: 64 * 1024 * 1024 introduces a hard cap that the previous shell implementation did not have (tee wrote to a temp file). git push writes progress to stderr, so large/verbose pushes can exceed 64 MiB; when that happens spawnSync returns with a spawn error (for example ENOBUFS) and the wrapper reports a generic failure instead of propagating the real git push outcome or retrying based on the remote error. This turns valid long-running pushes into deterministic wrapper failures.

Useful? React with 👍 / 👎.

});
const stderr = result.stderr;
// Replay captured stderr to the user (bash original streams via
// tee; we batch).
if (stderr.length > 0) {
Comment thread
AceHack marked this conversation as resolved.
process.stderr.write(stderr);
}
return { status: result.status ?? 1, stderr };
}
Comment thread
AceHack marked this conversation as resolved.

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)));
}
Loading