Skip to content

fix(branch): make branch create success mean the branch is usable - #201

Merged
jwfing merged 4 commits into
InsForge:mainfrom
iPLAYCAFE-dev:fix/branch-create-readiness
Jul 23, 2026
Merged

fix(branch): make branch create success mean the branch is usable#201
jwfing merged 4 commits into
InsForge:mainfrom
iPLAYCAFE-dev:fix/branch-create-readiness

Conversation

@iPLAYCAFE-dev

@iPLAYCAFE-dev iPLAYCAFE-dev commented Jul 22, 2026

Copy link
Copy Markdown

Follow-up to InsForge/InsForge#1790, fixing the half of it that lives in this repo. The root cause of the readiness gap is cloud-side, but the CLI is where it becomes a broken user experience, and all three of these are fixable here.

Found while automating branch creation on ap-southeast. Every measurement below is from real branches, all of which were deleted afterwards.

1. ready is a control-plane state, not readiness

pollUntilReady returns the moment branch_state === 'ready' and never contacts the branch's own host. But branch_state flips when the provisioning job returns, while the instance is still coming up — every request to https://<appkey>.<region>.insforge.app resets until it does.

Measured: the host started serving at t+2m in one case and t+11.5m in another, with branch_state reading ready the whole time.

Because create then auto-switches the directory onto that host, the failure surfaces on the user's next command rather than here:

$ insforge db query "select 1"
{"error":"fetch failed","code":"UNKNOWN_ERROR"}

Now: once the control plane says ready, poll GET /api/health on the branch itself until it answers, and say so in the spinner. If it never answers within the budget, the command reports that honestly instead of claiming success — the branch's name and id are still printed, because it is real and it is billing.

2. The 5-minute poll ceiling was below the observed provisioning time

POLL_TIMEOUT_MS was 5 minutes, so the 11.5-minute branch was reported as "still in 'creating' state" when it was simply not finished yet. Raised to 15, with a separate 10-minute budget for the data-plane wait.

3. A failed create can leave a live branch behind

createBranchApi carries no idempotency key. A transport failure on the response leg — the POST arrived and the branch was created — throws before created is bound, so the CLI exits non-zero with no id and no name while a branch exists and bills. We hit exactly this:

$ insforge branch create <name> --mode schema-only --no-switch
{"error":"Connection to api.insforge.dev was reset. A proxy, VPN, or firewall may be interfering."}
# exit 1
$ insforge branch list
# the branch is there, branch_state "creating"

It then refused delete for about five minutes with "Branch is currently busy (creating or merging)." before the state flipped and the delete succeeded.

Now: on a create failure, ask branch list — a control-plane call, so it still works while the branch's own host is unreachable — whether a branch exists under that name, and adopt it if so. The original error is rethrown unchanged when nothing was created.

Also

ossFetch called fetch unguarded, unlike platformFetch, so a dead data plane surfaced as the generic UNKNOWN_ERROR instead of naming the host. The new probeBackendHealth routes its errors through formatFetchError, so Connection to <host> was reset is what a caller sees. That one line cost us a lot of triage time.

Notes for review

  • New tests cover the not-serving path, the adopt path, and that a genuine failure with nothing created still exits non-zero.
  • The existing create tests needed a mock for the new probe: unmocked, it makes a real request to a fake host and then polls. beforeEach resets it explicitly, because clearAllMocks keeps implementations and an unreachable branch would otherwise leak into every later test.
  • npm run build passes; vitest passes for everything I touched. Four unrelated failures (flyctl ×3, migrations ×1) reproduce on a clean main on this machine (Windows) and are untouched by this change.
  • Behaviour change worth calling out: a branch that reports ready but never serves now stops the spinner with an error frame instead of a success line. That seemed right — it is the case where continuing would break the user's next command — but say the word if you would rather it warn and exit 0.

Agent skills

insforge-cli/references/branch/overview.md says a branch takes 30–120s and that ready means "usable — can be switched, modified, merged, or reset", and its post-create checklist goes straight to functions deploy. Both look worth updating in InsForge/agent-skills once you have confirmed the timing on your side — happy to open that PR alongside this one.

Open question this does not answer

Is there a branch-scoped signal that reflects the branch's own data plane rather than the provisioning job — or would you accept waitForCompletion=true on POST /projects/v1/{id}/branches, matching the convention already used by projects restart and backups create? /api/health is the best thing available to a client today, but it is a static handler that never touches Postgres, so it proves the process is listening rather than that the branch is usable. If a better signal exists or is planned, this polling should key off that instead.


Summary by cubic

Make branch create report success only when the branch is actually usable. It now waits for the branch host to serve, fails on stuck provisioning, and adopts an existing branch only in safe, verified cases after transport errors.

  • Bug Fixes
    • Wait for the data plane: after control‑plane ready, poll the branch host’s /api/health; include serving in --json, print the branch identity first, and exit non‑zero if not serving within 10 min.
    • Honest exit on stuck provisioning: exit non‑zero if the branch never reaches ready within the 15‑minute poll budget.
    • Longer budgets: control‑plane poll raised to 15 min; health probe every 5s with a 10‑min cap.
    • Safe adoption on transport failures: only adopt after a tagged network error (NETWORK_ERROR_CODE), only if a same‑name branch was created at/after the request time (60s skew) and with a matching mode. Never adopt on API rejections (e.g., duplicate name) or pre‑existing branches.
    • Clearer connectivity errors: new probeBackendHealth routes through formatFetchError, so messages name the branch host.

Written for commit ea18b81. Summary will update on new commits.

Review in cubic

Summary by CodeRabbit

  • Bug Fixes
    • Improved branch create by adopting a matching existing branch when failures are due to transient connection issues.
    • Added stricter readiness checks: the CLI now confirms the branch host is actually reachable before reporting success or switching.
    • Updated outcomes and messaging when the branch is “ready” but not yet serving, including correct non-zero exit behavior for timeout/failed provisioning.
    • Preserved original failures when adoption isn’t applicable (e.g., duplicate-name rejections or no existing branch found).
  • Tests
    • Expanded coverage for edge cases around provisioning/adoption and serving reachability timeouts.

Note

Fix branch create to only report success when the branch is ready and serving

  • Adds a waitUntilServing step after provisioning completes: polls /api/health on the branch host every 5 seconds for up to 10 minutes before reporting success.
  • Increases the provisioning poll timeout from 5 to 15 minutes and exits non-zero if the branch never becomes ready or never starts serving.
  • Introduces createBranchOrAdopt: on a transport-layer failure during creation, lists branches and adopts a matching one (by name, mode, and created_at) instead of failing immediately.
  • Tags fetch-level errors with NETWORK_ERROR_CODE in platformFetch so callers can distinguish transport failures from HTTP errors.
  • In JSON mode, emits branch identity before raising any failure so callers still get the branch reference on non-zero exit.
  • Behavioral Change: branch create now exits non-zero if the branch provisions but never serves within the health timeout, where previously it would exit zero after provisioning.

Macroscope summarized ea18b81.

Three defects that share one theme: the command reports success on signals that
do not mean what a caller needs them to mean. Found while automating branch
creation against ap-southeast; details and measurements in InsForge/InsForge#1790.

1. `ready` is a control-plane state, not readiness.

   `pollUntilReady` returns the moment `branch_state === 'ready'` and never
   contacts the branch's own host. But `branch_state` flips when the
   provisioning job returns, while the instance is still coming up — every
   request to https://<appkey>.<region>.insforge.app resets until it does.
   Measured on ap-southeast: the host started serving at t+2m in one case and
   t+11.5m in another, `branch_state` reading 'ready' the whole time.

   Because create then auto-switches the directory onto that host, the failure
   surfaces on the user's NEXT command rather than here, as
   {"error":"fetch failed","code":"UNKNOWN_ERROR"} from whatever they ran.

   Now: after the control plane says ready, poll GET /api/health on the branch
   itself until it answers, and say so in the spinner. If it never answers
   within the budget the command reports that honestly instead of claiming
   success — the branch still exists and its name and id are still printed,
   because it is real and it is billing.

2. The 5-minute poll ceiling was below the observed provisioning time.

   POLL_TIMEOUT_MS was 5 minutes, so the 11.5-minute branch was reported as
   "still in 'creating' state" when it was simply not finished. Raised to 15,
   with a separate 10-minute budget for the data-plane wait.

3. A failed create can leave a live branch behind.

   `createBranchApi` carries no idempotency key. A transport failure on the
   RESPONSE leg — the POST arrived and the branch was created — throws before
   `created` is bound, so the CLI exits non-zero with no id and no name while a
   branch exists and bills. We hit exactly this:

     $ insforge branch create <name> --mode schema-only --no-switch
     {"error":"Connection to api.insforge.dev was reset. A proxy, VPN, or
      firewall may be interfering."}   # exit 1
     $ insforge branch list            # the branch is there, state "creating"

   Now: on a create failure, ask `branch list` — a control-plane call that still
   works while the branch's own host is unreachable — whether the branch exists
   under that name, and adopt it if so. The original error is rethrown unchanged
   when nothing was created.

Also: `ossFetch` called `fetch` unguarded, unlike `platformFetch`, so a dead
data plane surfaced as the generic UNKNOWN_ERROR instead of naming the host.
The new `probeBackendHealth` routes its errors through `formatFetchError`, so
"Connection to <host> was reset" is what a caller sees.

Tests cover the not-serving path, the adopt path, and that a genuine failure
with nothing created still exits non-zero. The existing create tests needed a
mock for the new probe — an unmocked one makes a real request to a fake host and
then polls, so beforeEach resets it explicitly (clearAllMocks keeps
implementations, which would otherwise leak an unreachable branch into every
later test).

Agent skills: `insforge-cli/references/branch/overview.md` says a branch takes
30-120s and that `ready` means "usable — can be switched, modified, merged, or
reset", and its post-create checklist goes straight to `functions deploy`. Both
are worth updating in InsForge/agent-skills once the timing here is confirmed;
happy to open that PR alongside if you want it.
@agent-zhang-beihai

Copy link
Copy Markdown
Contributor

Thanks for the PR, @iPLAYCAFE-dev! A quick note on our workflow: we ask contributors to open an issue first, get it assigned, then submit a PR that links it (e.g. "Closes #123"). This PR isn't linked to any issue. It'll still be reviewed, but please open an issue and claim it (comment that you'd like it assigned to you) so the work is tracked.

@coderabbitai

coderabbitai Bot commented Jul 22, 2026

Copy link
Copy Markdown

Review Change Stack

Walkthrough

Branch creation now adopts an existing branch after transport failures and performs a separate OSS health check after control-plane readiness. Success and auto-switching require the branch host to be serving, with tests covering unavailable hosts, provisioning timeouts, and adoption failure paths.

Changes

Branch readiness and adoption

Layer / File(s) Summary
Transport error classification
src/lib/api/platform.ts
Adds a dedicated network error code to fetch failures so transport errors can be handled separately from API rejections.
Backend health probe
src/lib/api/oss.ts
Adds a non-throwing /api/health probe with timeout, status, reachability, and formatted failure details.
Creation adoption and serving validation
src/commands/branch/create.ts
Adds transport-failure adoption, extends provisioning polling, verifies data-plane serving, and updates success, exit, and stop messages.
Readiness and failure-path coverage
src/commands/branch/create.test.ts
Mocks health checks and tests unavailable serving, provisioning timeouts, adoption eligibility, error propagation, and branch output.

Estimated code review effort: 4 (Complex) | ~45 minutes

Possibly related issues

Possibly related PRs

  • InsForge/CLI#203 — Directly overlaps the serving readiness polling and transport-failure adoption logic.
  • InsForge/CLI#112 — Modifies the same branch creation, polling, auto-switch, and spinner flow.

Suggested reviewers: jwfing, fermionic-lyu

Poem

A rabbit checks the branch-host gate,
“Ready” alone must now await.
If transport trips, we search the field,
And adopt the branch that was concealed.
Health hops green before we cheer—
The burrow’s serving, success is here!

🚥 Pre-merge checks | ✅ 5
✅ Passed checks (5 passed)
Check name Status Explanation
Docstring Coverage ✅ Passed No functions found in the changed files to evaluate docstring coverage. Skipping docstring coverage check.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title clearly captures the main change: branch create now only succeeds when the branch is actually usable.
✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@greptile-apps

greptile-apps Bot commented Jul 22, 2026

Copy link
Copy Markdown

Greptile Summary

This PR makes branch creation succeed only after the new branch is usable. The main changes are:

  • Poll the branch host after control-plane provisioning completes.
  • Return a failure while preserving branch identity when readiness times out.
  • Recover branches created during ambiguous transport failures.
  • Tag platform transport errors separately from HTTP rejections.
  • Extend provisioning and health-check timeouts.

Confidence Score: 5/5

The latest fixes look safe to merge.

  • Health timeouts now return a non-zero exit status.
  • Definite API rejections no longer enter the adoption path.
  • Recent-branch adoption now checks the requested mode and creation time.
  • No additional blocking issue met the follow-up review scope.

Important Files Changed

Filename Overview
src/commands/branch/create.ts Adds data-plane readiness checks, failure exits, and guarded recovery after ambiguous create failures.
src/lib/api/oss.ts Adds a bounded health probe for an explicit branch host.
src/lib/api/platform.ts Tags fetch-level failures so callers can distinguish them from HTTP responses.
src/commands/branch/create.test.ts Adds coverage for readiness failures and guarded branch adoption.
package-lock.json Updates the package version to 0.2.0.

Reviews (4): Last reviewed commit: "fix(branch): also require a matching mod..." | Re-trigger Greptile

Comment thread src/commands/branch/create.ts Outdated
Comment thread src/commands/branch/create.ts Outdated

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (1)
src/commands/branch/create.ts (1)

79-129: 🎯 Functional Correctness | 🟠 Major | ⚡ Quick win

Downstream success/JSON/exit-code checks still key off ready.branch_state, not provisioned — the "not serving" downgrade doesn't actually change command outcome.

Line 84's ready = { ...ready, branch_state: ready.branch_state } is a no-op (there's no Branch.branch_state value for "ready but not serving"), so ready.branch_state stays 'ready' after provisioned is downgraded to false. The final block (lines 117-129) branches on ready.branch_state === 'ready' instead of provisioned, which causes two real problems when serving never comes up:

  • Non-JSON with --switch (default): line 120-124 still prints the "Re-source your dev server env" message, even though the switch was skipped at line 88 (provisioned && opts.switch) — misleading the user into thinking they're now on the new branch.
  • JSON mode: outputJson({ branch: ready }) fires unconditionally on ready.branch_state === 'ready', so automation consumers can't tell serving failed from the payload.
  • More importantly, nothing throws or sets process.exitCode for this case — the spinner?.stop(msg, 1) on line 98-101 only affects the displayed icon/symbol in @clack/prompts (confirmed via clack's own issue tracker: the code argument is used purely to pick between "Something went wrong" and cancel-style messaging), it does not fail the process. So the CLI likely still exits 0 even though the branch host never came up — the exact outcome this PR set out to prevent.

Use the provisioned flag (already tracked) consistently in the final block, and throw/return a non-zero signal when serving never comes up.

🐛 Suggested fix
         if (json) {
           outputJson({ branch: ready });
-        } else if (ready.branch_state === 'ready') {
+        } else if (provisioned) {
           if (opts.switch) {
             outputInfo(
               '⚠ Re-source your dev server env (.env) to pick up the new INSFORGE_URL / ANON_KEY.',
             );
           }
+        } else if (ready.branch_state === 'ready') {
+          throw new CLIError(`Branch '${name}' reports ready but is not serving yet — retry your next command shortly`);
         } else {
           outputInfo(
             `Branch '${name}' is still in '${ready.branch_state}' state. Run \`insforge branch list\` to check.`,
           );
         }

Separately, this scenario also isn't covered by the test at lines 296-324 of src/commands/branch/create.test.ts — that test only checks the spinner call, not the actual exit code or JSON payload, so it wouldn't have caught this gap.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/commands/branch/create.ts` around lines 79 - 129, Use the `provisioned`
flag as the source of truth in the final output block instead of
`ready.branch_state`, so a branch that never starts serving does not print
success guidance or appear successful in JSON. Replace the no-op `ready`
assignment in the `waitUntilServing` failure path with an explicit non-success
outcome, and propagate failure from the command so it exits non-zero while
preserving the existing spinner message and successful behavior.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Outside diff comments:
In `@src/commands/branch/create.ts`:
- Around line 79-129: Use the `provisioned` flag as the source of truth in the
final output block instead of `ready.branch_state`, so a branch that never
starts serving does not print success guidance or appear successful in JSON.
Replace the no-op `ready` assignment in the `waitUntilServing` failure path with
an explicit non-success outcome, and propagate failure from the command so it
exits non-zero while preserving the existing spinner message and successful
behavior.

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro

Run ID: f481b80e-99d8-4e11-8b4d-07dc7cd065c1

📥 Commits

Reviewing files that changed from the base of the PR and between 64c0859 and 7fa4829.

⛔ Files ignored due to path filters (1)
  • package-lock.json is excluded by !**/package-lock.json
📒 Files selected for processing (3)
  • src/commands/branch/create.test.ts
  • src/commands/branch/create.ts
  • src/lib/api/oss.ts

@cubic-dev-ai cubic-dev-ai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

All reported issues were addressed across 4 files

Reply with feedback, questions, or to request a fix.

Re-trigger cubic

Comment thread src/commands/branch/create.ts
Comment thread src/commands/branch/create.ts Outdated
…host never serves

Both review findings are real; fixing rather than arguing.

1. Adoption was too broad (greptile P1/security, cubic P1).

   The catch adopted a same-name branch after ANY create failure, so a
   duplicate-name rejection — a refusal, not a lost response — could switch the
   caller into a pre-existing branch with a different mode and different data.

   Two guards now, either of which is sufficient:
   - only a TAGGED transport failure is eligible. platformFetch's fetch-level
     catch now throws CLIError with code NETWORK_ERROR, so "the server said no"
     and "we never heard back" are finally distinguishable by callers. Every
     HTTP/API rejection rethrows untouched, and the test asserts that
     listBranchesApi is not even called in that case.
   - the candidate must have been created at or after the moment we sent the
     request, so a pre-existing same-name branch can never be adopted. A 60s
     skew allowance keeps a genuinely-just-created branch eligible when the
     local clock differs from the control plane's; being slightly wide risks
     adopting a branch someone made seconds ago under the same name, while
     being too narrow re-opens the orphaned-billing bug this exists to fix.

2. A branch that never serves exited 0 (greptile P1, cubic P1).

   I stopped the spinner with an error frame and left it there: the process
   still exited 0, --json still emitted a ready-looking branch with no serving
   field, and the non-JSON path still advised re-sourcing the env. That is
   exactly the "exit 0 does not mean ready" defect this PR is meant to remove,
   reproduced inside the fix for it.

   Now: `serving` is tracked separately from `provisioned`, --json emits
   { branch, serving }, the env hint only prints when the host actually answers,
   and the command throws afterwards so the exit is non-zero. The identity is
   emitted BEFORE the throw on purpose — the branch exists and is billing, so a
   caller has to be able to find and delete it even as the command fails.

Three tests added: an API rejection is not adopted, a branch predating the
request is not adopted, and the never-serving path exits 1 while still printing
the branch id and serving:false.
@iPLAYCAFE-dev

Copy link
Copy Markdown
Author

Closes #202.

@agent-zhang-beihai — apologies, I submitted before reading the contribution workflow. Issue #202 is open now with the measurements and repro, and I've asked for it to be assigned. Happy to close this PR and resubmit after assignment if you'd prefer the order kept strictly.


Both review findings were right, and both are fixed in 552cf50. Thanks — the second one in particular is the same defect this PR exists to remove, reproduced inside the fix for it.

1. Adoption was too broad — @greptile-apps (P1/security), @cubic-dev-ai (P1)

A duplicate-name response is a refusal, not a lost response, and adopting on it could switch the caller into a pre-existing branch with a different mode and different data. Two guards now, either sufficient on its own:

  • Only a tagged transport failure is eligible. platformFetch's fetch-level catch now throws CLIError with code NETWORK_ERROR, so "the server said no" and "we never heard back" are finally distinguishable by callers — previously both were a bare CLIError, which is why I could not narrow this correctly the first time. Every HTTP/API rejection rethrows untouched, and the test asserts listBranchesApi is not even called in that case.
  • The candidate must post-date the request. branch_created_at >= requestedAt, so a pre-existing same-name branch can never be adopted regardless of how the error is classified.

On the skew allowance: I used 60s. Being slightly wide risks adopting a branch someone else created seconds ago under the same name; being too narrow re-opens the orphaned-billing bug. If you'd rather have zero tolerance and accept the clock-skew false negative, say so and I'll drop it — it's a one-line change and your call, since you know how the control plane stamps branch_created_at.

2. A branch that never serves exited 0 — @greptile-apps (P1), @cubic-dev-ai (P1)

Correct, and it was worse than the comments say: I stopped the spinner with an error frame and left it there, so the process still exited 0, --json still emitted a ready-looking branch with no serving field, and the non-JSON path still advised re-sourcing the env.

Now serving is tracked separately from provisioned, --json emits { branch, serving }, the env hint only prints when the host actually answers, and the command throws afterwards so the exit is non-zero.

The identity is emitted before the throw on purpose: the branch exists and is billing, so a caller must be able to find and delete it even as the command fails. That's also why the not-serving path prints the branch delete command rather than just an error.

Verification

  • 12 tests in create.test.ts, all passing — including the three new ones (API rejection not adopted, pre-existing branch not adopted, never-serving exits 1 while still printing the id and serving:false).
  • npm run build passes; tsc --noEmit reports nothing for the three files I touched.
  • Four unrelated failures (flyctl ×3, migrations ×1) reproduce on a clean main on this machine (Windows) and are untouched.

Still open, and it's a maintainer question

/api/health is the best signal available to a client today, but it's a static handler that never touches Postgres — it proves the process is listening, not that the branch is usable. If there's a branch-scoped readiness signal, or you'd accept waitForCompletion=true on POST /projects/v1/{id}/branches (matching projects restart / backups create), this polling should key off that instead. Asked on InsForge/InsForge#1790 as well.

@greptile-apps

greptile-apps Bot commented Jul 22, 2026

Copy link
Copy Markdown

Both findings are fully addressed. Here's my assessment after reading 552cf50:

Finding 1 (adoption too broad): The two-guard approach is well-implemented. isTransportFailure uses the new NETWORK_ERROR_CODE to distinguish transport failures from API rejections, and branch_created_at >= requestedAt (with the skew offset applied at request time, not at list time) prevents adoption of pre-existing branches. The test asserting listBranchesApi is not even called on an API rejection is exactly the right coverage.

Finding 2 (never-serves exits 0): serving is now tracked independently from provisioned. Identity is emitted via outputJson({ branch: ready, serving }) before the throw, the env hint is gated on serving, and the final throw new CLIError(...) guarantees non-zero exit. The test asserting exitCode === 1 while JSON output still contains branch-id and "serving": false confirms both properties hold simultaneously.

One minor observation: in waitUntilServing, when HEALTH_TIMEOUT_MS expires the loop exits without a final probe — the branch could theoretically become reachable in the gap between the last poll iteration and timeout. Given 5s intervals against a 10-minute budget this is negligible and not worth a change.

On the skew question: 60s is defensible. The one edge case worth knowing about is whether branch_created_at is stamped at request receipt or at job completion — if it's the latter, a loaded control plane could stamp it later than the actual creation, making the window legitimately tight. If you know the stamping semantics it's worth a sanity check, but absent that 60s is a reasonable default.

Both threads resolved. LGTM on 552cf50.

Tip: You can customize Greptile's behavior for this repo with .greptile/rules.md and .greptile/config.json.

@iPLAYCAFE-dev

Copy link
Copy Markdown
Author

Thanks for the re-review. Both threads resolved on cubic as well, and all four checks are green on 552cf50.

On the stamping semantics — I have data for this, and it settles the skew question.

branch_created_at is stamped at creation, not at job completion. Two observations from the spike, both from raw branch create / branch list payloads:

  • branch create returns branch_created_at in its own response, alongside branch_state: "creating" — so the row is stamped before provisioning has done anything.
  • One branch was created at 2026-07-22T05:08:25.682Z and was still branch_state: "creating" when listed at 05:13 (its updated_at had moved to 05:13:07.124Z). branch_created_at stayed at 05:08:25.682Z throughout, and created_at === branch_created_at on that row.

So the loaded-control-plane case you were worried about doesn't apply: the stamp can't drift later than the actual creation, and it lands within the same request. That leaves only genuine clock skew between the client and the control plane, which is what the 60s window is for. I'll leave it at 60s unless a maintainer wants it tighter.

On the missing final probe after HEALTH_TIMEOUT_MS — agreed on both counts: the gap is one 5s interval against a 10-minute budget, and the failure direction is safe (we report not serving for a branch that just became reachable, and the branch's identity is still emitted so nothing is orphaned). I considered adding a probe after the loop for an exact postcondition and decided against it — you've already LGTM'd this commit and the change would be churn for a sub-1% timing window. Happy to add it if a maintainer would rather have the loop's postcondition be exact.

Awaiting human review. Tracking issue is #202 as requested — and to be explicit about what this PR does and doesn't do: it stops the CLI reporting readiness it hasn't verified, but the underlying gap (a branch reporting ready minutes before its host serves) is cloud-side, and the question of whether a branch-scoped readiness signal exists — or whether waitForCompletion=true on POST /projects/v1/{id}/branches would be accepted, matching projects restart / backups create — is still open on InsForge/InsForge#1790. If either lands, this polling should key off it instead of /api/health, which only proves the process is listening.

@iPLAYCAFE-dev

Copy link
Copy Markdown
Author

Process box ticked: #202 is now assigned to me (thanks @agent-zhang-beihai), and this PR closes it — so the issue-first workflow is satisfied for anyone reviewing later.

Current state, so a human reviewer doesn't have to reconstruct it:

  • All four checks green on 552cf50 — CodeRabbit, Greptile, cubic, Macroscope (skipped).
  • Both P1 findings fixed and re-reviewed. cubic marks each "✅ Addressed"; Greptile: "Both threads resolved. LGTM on 552cf50."
  • Greptile's follow-up question is answered with data in the comment above: branch_created_at is stamped at creation, not at job completion — branch create returns it in its own response alongside branch_state: "creating", and on a branch that stayed creating for five minutes the value never moved. So the skew window only has to absorb client↔control-plane clock drift, which is what the 60s is for.

Two things still open that only a maintainer can decide, both already stated above and neither blocking:

  1. whether to tighten or drop the 60s branch_created_at skew allowance;
  2. whether a branch-scoped readiness signal exists or waitForCompletion=true on POST /projects/v1/{id}/branches would be accepted — if so this should poll that instead of /api/health, which only proves the process is listening.

Nothing further from me unless review turns something up.

@jwfing jwfing left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Review — fix(branch): make branch create success mean the branch is usable

Summary: A well-scoped, well-tested fix that makes branch create gate success on data-plane readiness, safely adopts a branch orphaned by a transport failure, and raises the poll budget to match observed provisioning times — no blocking issues found.

Requirements context

No matching spec/plan found under docs/specs/ (the only specs there cover diagnose and db-migrations; this repo has no docs/superpowers/). Assessed against the PR description and the linked issue InsForge/InsForge#1790. The three stated problems (ready ≠ serving, sub-provisioning poll ceiling, orphaned branch on response-leg failure) are each addressed, and the implementation choices match the description faithfully.

Findings

Critical

(none)

Suggestion

Functionality — inconsistent exit semantics for the other "not usable" outcome. src/commands/branch/create.ts:158-164 now correctly exits non-zero when a branch is ready but never serves. But the sibling path — pollUntilReady times out with the branch still in a non-terminal creating state (create.ts:149-153) — still emits only an info line and exits 0. Given this PR's explicit goal ("success means usable"), a branch stuck provisioning past the 15-minute budget is equally unusable, yet automation reading the exit code sees success. Consider making that path exit non-zero too (or documenting why the two are treated differently). Low blast radius, hence not blocking.

Functionality — /api/health proves "listening", not "usable". waitUntilServing (create.ts:228-245) treats any res.ok from probeBackendHealth as serving. As you already note in the PR's open question, /api/health is a static handler that never touches Postgres, so a branch can pass this probe before its DB/schema is actually ready — a narrower version of the very gap this PR closes. This is the best client-side signal available today and you've flagged it honestly; noting it so it isn't lost. If waitForCompletion=true (matching projects restart / backups create) or a data-plane-scoped signal lands cloud-side, this poll should key off that instead.

Information

Software engineering — export const interleaved in the import block. src/lib/api/platform.ts:4-6 places export const NETWORK_ERROR_CODE between two import statements. It's legal (imports hoist) and the lint gate passes (no import/first rule in eslint.config.js), but conventionally declarations sit below the import block — worth relocating for readability.

Software engineering — double error surface on the not-serving path (non-JSON). In the ready-but-not-serving case the spinner stops with an error frame (create.ts:114-118) and then the thrown CLIError also prints Error: … via handleError. Two messages for one condition; harmless and arguably informative, just noting.

Notes on the good parts

  • Adoption guards are exactly right. createBranchOrAdopt (create.ts:197-219) adopts only on a tagged NETWORK_ERROR_CODE transport failure and only for a same-name branch created at/after requestedAt (with a deliberate 60s skew), and rethrows the original error when listBranchesApi finds nothing or itself fails (.catch(() => undefined)). API rejections (duplicate name/quota/auth) correctly fall through untouched. The NETWORK_ERROR_CODE tagging in platform.ts:101-115 is the clean way to distinguish transport failure from an HTTP response.
  • Test coverage matches the behavior changes — not-serving exit, adopt-on-transport-failure, rethrow-when-nothing-created, no-adopt-on-API-rejection, no-adopt-on-predating-branch, and identity-emitted-before-failure are all exercised. The beforeEach re-stub of probeBackendHealth (test file) with the explanatory comment about clearAllMocks keeping implementations is a good catch that prevents cross-test leakage.
  • Security: no new user input reaches SQL/shell; probeBackendHealth fetches an unauthenticated URL built from server-assigned appkey/region with a bounded 10s AbortSignal.timeout; no secrets logged (errors routed through formatFetchError, which emits host + code only). No auth/authorization changes.
  • Performance: both poll loops are sequential and bounded (health: 5s × up to 10 min; provisioning: 3s × up to 15 min) — appropriate for a CLI, no hot-path concerns. package-lock.json change is only the 0.2.0 version sync (matches package.json), so no npm ci breakage.

Verdict

approved (informational — no Critical findings; the two Suggestions and Information notes are non-blocking). Human green-check remains a separate action.

…OR_CODE export

Addresses the review on InsForge#201 (jwfing, approved):

- Functionality: `branch create` now exits non-zero when the branch never
  reaches 'ready' within the poll budget, matching the existing ready-but-
  not-serving exit. Both outcomes are "not usable", and this PR's goal is that
  success means usable — so automation reading the exit code must not see 0 for
  a branch stuck provisioning. Test added (getBranchApi stuck in 'creating' →
  exit 1), with the shared mock impl restored so it can't leak the full poll
  budget into later tests.
- Software engineering: moved `export const NETWORK_ERROR_CODE` below the import
  block in platform.ts (it was interleaved between two imports — legal via
  hoisting, but conventionally declarations sit under the imports).

The `/api/health` "listening ≠ usable" note is acknowledged as the best
client-side signal available today and left as the PR's documented open
question, to key off a data-plane-scoped signal if one lands cloud-side.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
@iPLAYCAFE-dev

Copy link
Copy Markdown
Author

Thanks for the thorough review — all four points addressed in 42f9be6.

Suggestion — inconsistent exit semantics. Fixed. The stuck-provisioning path (create.ts, branch never reaches 'ready' within POLL_TIMEOUT_MS) now throws a CLIError and exits non-zero, matching the ready-but-not-serving path. You're right that "success means usable" has to cover both unusable outcomes; a branch still creating past the 15-min budget is as unusable as one that's ready but never serves. Added a test (getBranchApi stuck in 'creating' → exit 1), restoring the shared mock implementation in a finally so the 'creating' stub can't leak the full poll budget into later tests.

Suggestion — /api/health proves "listening", not "usable". Acknowledged and left as the PR's documented open question. It's the best client-side signal available today; if waitForCompletion=true or a data-plane-scoped readiness signal lands cloud-side, waitUntilServing should key off that instead. Noted so it isn't lost.

Information — export const interleaved in the import block. Fixed — NETWORK_ERROR_CODE now sits below the full import block in platform.ts.

Information — double error surface on the not-serving path. Left as-is intentionally: the spinner error frame names the branch state and the CLIError gives the actionable "did not start serving within N minutes" line, so the two messages carry different information. Happy to collapse them if you'd prefer a single surface.

Build + lint clean; the new test and the existing branch-create suite pass. (The 4 unrelated flyctl/migrations failures are pre-existing Windows path-separator issues — confirmed they fail identically without this change — and pass on Linux CI.)

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🧹 Nitpick comments (1)
src/commands/branch/create.test.ts (1)

354-359: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Repeated process.exit/process.stderr.write mocking boilerplate.

This override/restore pattern (lines 354-359 here, and repeated at 470-475, 519-522, 555-559) is duplicated verbatim across four new tests. Consider extracting a small helper (e.g. withCapturedExit(fn)) to reduce copy-paste risk and shrink each test body.

♻️ Example helper
async function withCapturedExit(fn: () => Promise<void>): Promise<number | undefined> {
  let exitCode: number | undefined;
  const origExit = process.exit;
  const origStderr = process.stderr.write.bind(process.stderr);
  process.exit = ((code?: number) => {
    exitCode = code;
    throw new Error('__exit__');
  }) as typeof process.exit;
  process.stderr.write = (() => true) as typeof process.stderr.write;
  try {
    await fn();
  } finally {
    process.exit = origExit;
    process.stderr.write = origStderr;
  }
  return exitCode;
}
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/commands/branch/create.test.ts` around lines 354 - 359, Extract the
repeated process.exit and process.stderr.write override/restore logic from the
affected tests into a shared helper such as withCapturedExit. Ensure the helper
captures the exit code, restores both originals in a finally block, and returns
the captured code; update the four tests to execute their assertions through
this helper.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Nitpick comments:
In `@src/commands/branch/create.test.ts`:
- Around line 354-359: Extract the repeated process.exit and
process.stderr.write override/restore logic from the affected tests into a
shared helper such as withCapturedExit. Ensure the helper captures the exit
code, restores both originals in a finally block, and returns the captured code;
update the four tests to execute their assertions through this helper.

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro

Run ID: 1901de5a-a469-4e42-9ca4-ca747bafe653

📥 Commits

Reviewing files that changed from the base of the PR and between 7fa4829 and 42f9be6.

📒 Files selected for processing (3)
  • src/commands/branch/create.test.ts
  • src/commands/branch/create.ts
  • src/lib/api/platform.ts

@cubic-dev-ai cubic-dev-ai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

All reported issues were addressed across 3 files (changes from recent commits).

Tip: Review your code locally with the cubic CLI to iterate faster.

Re-trigger cubic

Comment thread src/commands/branch/create.ts
…it-capture test helper

Addresses the two follow-up reviews on InsForge#201:

- cubic (P2): a transport failure whose response leg is lost could, in the 60s
  skew window, adopt a collaborator's SAME-NAME branch — and a default --switch
  would then move local context onto it. createBranchOrAdopt now also requires
  the candidate's mode to match the requested mode, narrowing the collision to an
  even more specific coincidence (same name AND same mode AND the same ~60s AND
  our transport failure). The real fix is a server-issued idempotency token
  (InsForge/InsForge#1790); this is the tightest client-side guard until then.
  Test added: a same-name, different-mode branch in the window is NOT adopted.

- CodeRabbit (nitpick): extracted `withCapturedExit(fn)` for the repeated
  process.exit/stderr override-restore boilerplate, and applied it to the tests
  this PR added (incl. the fake-timer provisioning-timeout one). The older
  pre-existing blocks are left as-is to keep this diff scoped to the PR's surface.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
@iPLAYCAFE-dev

Copy link
Copy Markdown
Author

Both follow-up reviews addressed in ea18b81.

cubic (P2) — collaborator same-name adoption in the skew window. Valid, fixed. createBranchOrAdopt now also requires the candidate's mode to match the requested mode, so adoption needs same name AND same mode AND creation inside the window AND our own tagged transport failure — a much more specific coincidence than name alone. Test added: a same-name, different-mode branch created in the window is not adopted (the transport error propagates → non-zero exit). On the "use the actual send time" half: I've kept the 60s backward skew, for the reason stated earlier in the thread — branch_created_at is stamped at creation and never drifts, so the window only has to absorb client↔control-plane clock skew; dropping it risks excluding our own legitimately-created branch. The mode match closes the collision cubic is pointing at without reintroducing that clock-skew fragility. The real fix remains a server-issued idempotency/request token on createBranchApi (flagged in InsForge/InsForge#1790); this is the tightest client-side guard until that exists.

CodeRabbit (nitpick) — repeated process.exit/stderr boilerplate. Done. Extracted withCapturedExit(fn) and applied it to the tests this PR added (including the fake-timer provisioning-timeout one, where the timer/mock lifecycle stays with the caller and the helper just owns the exit/stderr swap). I left the three pre-existing blocks (rejects when no project linked, invalid-mode, switch-failure) as-is to keep this diff scoped to the PR's surface — happy to sweep them in the same pass if you'd prefer full consistency.

Build + lint clean; create.test.ts is 14/14. (The unrelated flyctl/migrations failures remain pre-existing Windows path-separator issues that pass on Linux CI.)

@iPLAYCAFE-dev

Copy link
Copy Markdown
Author

@jwfing — re-review request (I don't have permission to re-request formally from a fork, so flagging here). Two commits landed after your review:

  • 42f9be6 — your review's two Suggestions: branch create now exits non-zero on a stuck-provisioning timeout too (matching the not-serving exit), and the NETWORK_ERROR_CODE export moved below the import block.
  • ea18b81 — cubic's follow-up P2: createBranchOrAdopt now also requires a matching mode before adopting, so a collaborator's same-name-but-different-mode branch in the skew window can't be adopted (test added); plus CodeRabbit's nitpick — a withCapturedExit test helper.

Net: the diff since your pass adds the mode guard + the timeout exit + test/lint tidy; nothing else changed. create.test.ts is 14/14, build + lint clean. Would appreciate your green-check on the final state when you have a moment — no rush.

@jwfing
jwfing merged commit bbc5c78 into InsForge:main Jul 23, 2026
4 checks passed
shobhitsahani added a commit to shobhitsahani/CLI that referenced this pull request Jul 23, 2026
shobhitsahani added a commit to shobhitsahani/CLI that referenced this pull request Jul 23, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

needs-issue PR isn't linked to any issue — open and claim an issue first, then link it

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants