Back to Blog
July 28, 2026

When GitHub Copilot Workspace Agents Stall: Debugging Complex Domain Logic in Browser Iterations

When the agent keeps polishing the screen but the bug lives three layers below it, stop prompting harder and give the logic somewhere observable to fail.

A browser coding agent paused beside a deterministic domain-logic test harness

You know the loop. You hand a browser-based coding agent a checkout bug. It edits the component, reloads the preview, changes the loading copy, and proudly reports that the page works. Then you try an annual subscriber with a grandfathered coupon at the end of a billing period, and the total is still wrong.

The agent did not become lazy. You gave it a problem whose visible surface is the browser while its truth lives in domain rules, database state, time boundaries, and API contracts. A screenshot can prove that a number rendered. It cannot prove that the number represents the correct proration, currency, entitlement, or version of the record.

One product-history note matters here. GitHub says the Copilot Workspace technical preview was sunset on May 30, 2025. The failure pattern is still relevant to Workspace projects and to GitHub's newer browser and cloud-agent experiences: an agent plans, edits, runs code in an isolated environment, and asks you to judge the result through a web interface. This guide uses “Workspace-style” for that browser-first iteration model, not as a claim that the old preview is still active.

The upside is real, but it has boundaries. GitHub's controlled study of 95 professional developers found that the Copilot group completed a JavaScript HTTP-server task 55% faster. The Stack Overflow 2024 Developer Survey found that 45% of professional developers rated AI tools bad or very bad at complex tasks. Those findings can coexist: acceleration on a bounded task does not guarantee correct reasoning across hidden business rules.

Why do browser-based Copilot agents stall on domain logic?

Stalls happen when the agent can see code and UI symptoms but cannot observe the business invariant, hidden state, or failing environment.

A browser is an observation layer. It exposes DOM, network calls, console messages, and user-visible state. Complex domain logic often depends on facts outside that layer: whether two commands are idempotent, which clock defines a billing boundary, whether a stale write should lose, how tax rounding works, or which migration created a nullable column.

The agent therefore optimizes what it can observe. If the only gate is “the preview looks right,” it can make the preview look right while preserving the bug. Repeated natural language corrections add more tokens, not more evidence. Your escape hatch is to translate the business complaint into a deterministic contract with an executable pass or fail.

This is also why large prompts often make the loop worse. “Fix subscription upgrades” hides at least five decisions: period arithmetic, money rounding, coupons, concurrency, and presentation. An agent may touch all five and validate none. A focused task such as “make this input produce 1,250 cents without changing the API shape; run this one command” has a finish line.

Browser loop versus a domain-gated loop

ConcernBrowser-only iterationDomain-gated iteration
PromptFix the wrong upgrade totalGiven fixture X, return 1250 cents; run test Y
Primary evidenceRendered text and screenshotTyped result, invariant, and assertion diff
Failure locationAnywhere behind the pageOne pure function or boundary adapter
Retry behaviorMore edits and another reloadSmallest edit, same command, new evidence
Done conditionLooks plausibleContract, integration, and browser gates all pass

The table is not an argument against browser tests. It is an argument for putting each fact at the cheapest layer that can prove it. If you are still designing your agent checks, this agentic quality-gates guide shows how to separate fast feedback from release confidence.

Build a failure packet before the next retry

A good agent task should be portable enough that another developer could reproduce it without reading your mind. Capture the smallest failing input, the expected result, the actual result, the exact command, and the relevant environment facts. For a billing bug, that means plan IDs, integer prices, timestamps with offsets, coupon state, record version, and currency—not a screenshot that says “total looks weird.”

Then map the failure across three boundaries. First, the domain boundary: does a pure function return the correct decision? Second, the adapter boundary: does the API validate, authorize, persist, and serialize that decision correctly? Third, the browser boundary: does the UI send the intended input and explain success or failure? Run them in that order. The first red boundary owns the next edit.

Include negative evidence too. Note which nearby cases already pass, which files are generated, and which API shape must remain compatible. Without those guardrails, an agent can “solve” a stale-write bug by removing optimistic concurrency, or make a rounding test green by switching every money value to a floating-point number. A narrow forbidden-changes list is often more useful than another paragraph of implementation advice.

Keep the packet safe. Replace customer data with a structurally equivalent fixture, remove secrets from logs, and preserve only identifiers needed to explain relationships. If the bug requires a private dependency or service, provide a deterministic local substitute or an explicit setup failure. A cloud agent cannot infer data it was never allowed to access, and broadening access is not a debugging technique.

Code example 1: Extract the rule into an observable contract

Imagine a side-project SaaS that prorates plan changes. The UI bug appears only when the change happens at the exact period end or a coupon would make the charge negative. Put that policy in a pure TypeScript module. Use integer cents, UTC calendar days, explicit error codes, and a result object the agent can inspect without booting the app.

// src/billing/calculate-plan-change.ts
export type Plan = { id: string; periodPriceCents: number }
export type PlanChange = {
  current: Plan
  next: Plan
  periodStart: string
  periodEnd: string
  changeAt: string
  couponPercent?: number
}
export type Quote = {
  creditCents: number
  chargeCents: number
  discountCents: number
  dueCents: number
}

export class QuoteError extends Error {
  constructor(
    public readonly code:
      | 'INVALID_MONEY'
      | 'INVALID_DATE'
      | 'OUTSIDE_PERIOD'
      | 'SAME_PLAN'
      | 'INVALID_COUPON',
    message: string,
  ) {
    super(message)
    this.name = 'QuoteError'
  }
}

function utcDay(value: string): number {
  const date = new Date(value)
  if (!Number.isFinite(date.getTime())) {
    throw new QuoteError('INVALID_DATE', 'Dates must be valid ISO-8601 values')
  }
  return Date.UTC(date.getUTCFullYear(), date.getUTCMonth(), date.getUTCDate())
}

function assertMoney(plan: Plan): void {
  if (!Number.isSafeInteger(plan.periodPriceCents) || plan.periodPriceCents < 0) {
    throw new QuoteError(
      'INVALID_MONEY',
      'Price for plan ' + plan.id + ' must be a non-negative integer in cents',
    )
  }
}

export function calculatePlanChange(input: PlanChange): Quote {
  assertMoney(input.current)
  assertMoney(input.next)
  if (input.current.id === input.next.id) {
    throw new QuoteError('SAME_PLAN', 'A plan change requires different plan IDs')
  }

  const start = utcDay(input.periodStart)
  const end = utcDay(input.periodEnd)
  const changed = utcDay(input.changeAt)
  if (end <= start) {
    throw new QuoteError('INVALID_DATE', 'periodEnd must be after periodStart')
  }
  if (changed < start || changed > end) {
    throw new QuoteError('OUTSIDE_PERIOD', 'changeAt must fall inside the billing period')
  }

  const coupon = input.couponPercent ?? 0
  if (!Number.isInteger(coupon) || coupon < 0 || coupon > 100) {
    throw new QuoteError('INVALID_COUPON', 'couponPercent must be an integer from 0 to 100')
  }

  const day = 86_400_000
  const periodDays = (end - start) / day
  const remainingDays = Math.max(0, (end - changed) / day)

  // At the exact period end, both prorated amounts are zero.
  const creditCents = Math.floor(
    (input.current.periodPriceCents * remainingDays) / periodDays,
  )
  const chargeCents = Math.ceil(
    (input.next.periodPriceCents * remainingDays) / periodDays,
  )
  const subtotalCents = Math.max(0, chargeCents - creditCents)
  const discountCents = Math.floor((subtotalCents * coupon) / 100)

  return {
    creditCents,
    chargeCents,
    discountCents,
    dueCents: Math.max(0, subtotalCents - discountCents),
  }
}

// Runnable probe: npx tsx src/billing/calculate-plan-change.ts
if (import.meta.url === new URL(process.argv[1], 'file:').href) {
  try {
    const quote = calculatePlanChange({
      current: { id: 'starter', periodPriceCents: 2000 },
      next: { id: 'pro', periodPriceCents: 5000 },
      periodStart: '2026-07-01T00:00:00Z',
      periodEnd: '2026-07-31T00:00:00Z',
      changeAt: '2026-07-16T23:30:00-07:00',
      couponPercent: 25,
    })
    console.log(JSON.stringify(quote, null, 2))
  } catch (error) {
    if (error instanceof QuoteError) {
      console.error(JSON.stringify({ code: error.code, message: error.message }))
      process.exitCode = 1
    } else {
      console.error('Unexpected quote failure', error)
      process.exitCode = 2
    }
  }
}

Why UTC days instead of dividing arbitrary timestamps? Daylight-saving transitions can make a local “day” 23 or 25 hours. Normalizing each input to a UTC calendar boundary makes the policy explicit. If your product bills by exact seconds instead, encode that rule and name it; do not accidentally inherit it from JavaScript date arithmetic.

Code example 2: Turn the bug report into a characterization suite

The next example gives the agent four sharp targets: a normal upgrade, an exact-boundary edge case, a maximum coupon, and malformed money. It also catches unexpected exceptions so the test output distinguishes a known domain rejection from a crash.

// src/billing/calculate-plan-change.test.ts
// Run: npx vitest run src/billing/calculate-plan-change.test.ts
import { describe, expect, it } from 'vitest'
import {
  calculatePlanChange,
  QuoteError,
  type PlanChange,
} from './calculate-plan-change'

const base: PlanChange = {
  current: { id: 'starter', periodPriceCents: 2000 },
  next: { id: 'pro', periodPriceCents: 5000 },
  periodStart: '2026-07-01T00:00:00Z',
  periodEnd: '2026-07-31T00:00:00Z',
  changeAt: '2026-07-16T00:00:00Z',
}

function quoteSafely(input: PlanChange) {
  try {
    return { ok: true as const, value: calculatePlanChange(input) }
  } catch (error) {
    if (error instanceof QuoteError) {
      return { ok: false as const, code: error.code, message: error.message }
    }
    throw new Error(
      'Unexpected calculatePlanChange failure: ' +
        (error instanceof Error ? error.stack : String(error)),
    )
  }
}

describe('calculatePlanChange', () => {
  it('charges only the remaining half of the period', () => {
    expect(quoteSafely(base)).toEqual({
      ok: true,
      value: {
        creditCents: 1000,
        chargeCents: 2500,
        discountCents: 0,
        dueCents: 1500,
      },
    })
  })

  it('returns zero at the exact end boundary instead of dividing or charging again', () => {
    const result = quoteSafely({ ...base, changeAt: base.periodEnd })
    expect(result).toMatchObject({
      ok: true,
      value: { creditCents: 0, chargeCents: 0, dueCents: 0 },
    })
  })

  it('never returns a negative amount with a 100 percent coupon', () => {
    const result = quoteSafely({ ...base, couponPercent: 100 })
    expect(result).toMatchObject({
      ok: true,
      value: { discountCents: 1500, dueCents: 0 },
    })
  })

  it('rejects fractional cents with a stable error code', () => {
    const result = quoteSafely({
      ...base,
      next: { id: 'pro', periodPriceCents: 4999.5 },
    })
    expect(result).toMatchObject({ ok: false, code: 'INVALID_MONEY' })
  })
})

Commit this failing test before asking the agent to try again. Now every iteration carries its own evidence. The agent cannot declare victory because the label changed; it has to preserve the typed behavior. This technique is especially powerful for legacy logic: characterize the behavior you must keep, isolate the behavior you want to change, and let the diff show which one moved.

How do you make the next agent iteration converge?

Turn the bug into one deterministic contract, one focused test command, and one stop condition before asking the agent to edit code.

Give the agent a constraint sandwich. Start with the invariant, then name the allowed scope, then end with the verification and stop condition. For example: “The renewal quote must never be negative. Modify only the billing module and its tests. Run the focused Vitest file. If the fixture or product rule is ambiguous, stop and report the ambiguity instead of changing the API.”

Put permanent facts in the repository, not in a disappearing chat. GitHub's current custom-instructions documentation supports repository-wide .github/copilot-instructions.md, path-specific instructions, and nearest-file AGENTS.md guidance for its cloud agent. Record the supported runtime, setup command, test order, generated files that must not be edited, and known broken commands. Keep task-specific expected values in the issue or test.

For more prompt patterns that produce inspectable changes, see our AI coding-agent debugging workflow. The punchline is consistent: ask for artifacts, commands, and diffs, not confidence.

Code example 3: Give the agent one diagnostic command

A cloud session wastes time when each retry rediscovers how to validate the repo. This dependency-free Node script runs three gates in order, applies a timeout, caps captured output, handles missing executables, and preserves the failing command's exit code. The edge case is a hung test process: it is aborted and reported instead of consuming the rest of the session.

// scripts/agent-check.mjs
// Run: node scripts/agent-check.mjs
import { spawn } from 'node:child_process'

const checks = [
  ['npm', ['run', 'typecheck']],
  ['npx', ['vitest', 'run', 'src/billing/calculate-plan-change.test.ts']],
  ['npx', ['playwright', 'test', 'tests/plan-change.spec.ts', '--reporter=line']],
]
const timeoutMs = 8 * 60 * 1000
const maxLogChars = 200_000

function run(command, args) {
  return new Promise((resolve, reject) => {
    const controller = new AbortController()
    const timer = setTimeout(() => controller.abort(), timeoutMs)
    let output = ''
    let settled = false

    const child = spawn(command, args, {
      cwd: process.cwd(),
      env: { ...process.env, CI: '1', FORCE_COLOR: '0' },
      signal: controller.signal,
      stdio: ['ignore', 'pipe', 'pipe'],
    })

    const append = (chunk) => {
      output = (output + chunk.toString()).slice(-maxLogChars)
    }
    child.stdout.on('data', append)
    child.stderr.on('data', append)

    child.on('error', (error) => {
      if (settled) return
      settled = true
      clearTimeout(timer)
      const reason =
        error.name === 'AbortError'
          ? 'timed out after ' + timeoutMs + 'ms'
          : 'could not start: ' + error.message
      reject(new Error(command + ' ' + args.join(' ') + ' ' + reason + '\n' + output))
    })

    child.on('close', (code, signal) => {
      if (settled) return
      settled = true
      clearTimeout(timer)
      if (code === 0) {
        resolve(output)
        return
      }
      reject(
        new Error(
          command +
            ' ' +
            args.join(' ') +
            ' failed with ' +
            (signal ? 'signal ' + signal : 'exit ' + String(code)) +
            '\n' +
            output,
        ),
      )
    })
  })
}

try {
  for (const [command, args] of checks) {
    console.log('\n> ' + command + ' ' + args.join(' '))
    await run(command, args)
    console.log('PASS')
  }
} catch (error) {
  console.error(error instanceof Error ? error.message : String(error))
  process.exitCode = 1
}

Add node scripts/agent-check.mjs to the repository instructions and the task. The ordering is intentional: do not spend browser minutes when types or domain tests are already red. In a monorepo, replace broad commands with workspace-scoped ones. If setup requires private packages, fail with the missing registry or variable name, never the secret value.

Code example 4: Keep one browser test for the wiring

Once the contract is stable, prove that the UI sends the right request and handles a stale record. This Playwright test intercepts the boundary, rejects malformed JSON, returns a realistic conflict, collects browser errors, and attaches them on failure. It avoidsnetworkidle, which can hang in apps with analytics, polling, or open sockets.

// tests/plan-change.spec.ts
// Run: npx playwright test tests/plan-change.spec.ts
import { test, expect } from '@playwright/test'

test.use({ timezoneId: 'Europe/Madrid' })

test('shows a recoverable conflict for a stale subscription version', async ({
  page,
}, testInfo) => {
  const browserErrors = []
  page.on('console', (message) => {
    if (message.type() === 'error') browserErrors.push(message.text())
  })
  page.on('pageerror', (error) => browserErrors.push(error.stack ?? error.message))

  await page.route('**/api/subscriptions/sub_123/plan', async (route) => {
    const request = route.request()
    if (request.method() !== 'POST') {
      await route.fulfill({ status: 405, json: { code: 'METHOD_NOT_ALLOWED' } })
      return
    }

    let body
    try {
      body = request.postDataJSON()
    } catch {
      await route.fulfill({ status: 400, json: { code: 'INVALID_JSON' } })
      return
    }

    if (body.planId !== 'pro' || !Number.isInteger(body.version)) {
      await route.fulfill({ status: 422, json: { code: 'INVALID_CHANGE_REQUEST' } })
      return
    }

    // Realistic edge case: another tab updated this subscription first.
    await route.fulfill({
      status: 409,
      json: {
        code: 'STALE_SUBSCRIPTION',
        message: 'Refresh before changing this plan.',
        currentVersion: body.version + 1,
      },
    })
  })

  try {
    await page.goto('/settings/billing')
    await page.getByRole('button', { name: 'Upgrade to Pro' }).click()
    await expect(page.getByRole('alert')).toContainText(
      'Your subscription changed in another session',
    )
    await expect(page.getByRole('button', { name: 'Refresh billing' })).toBeVisible()
    expect(browserErrors, 'unexpected browser errors').toEqual([])
  } catch (error) {
    await testInfo.attach('browser-errors', {
      body: Buffer.from(JSON.stringify(browserErrors, null, 2)),
      contentType: 'application/json',
    })
    throw error
  }
})

Notice what this test does not re-prove: the proration formula. That belongs in the fast suite. The browser test proves transport, user feedback, and recovery. If it fails, the agent has a narrow question: did the page send the wrong request, ignore the 409, or render the wrong recovery state?

Troubleshooting stalled browser iterations

  • The preview works, but the focused test fails. The UI may be using mock data, a fallback, or a different code path. Log the request payload and import path; search for duplicate implementations before editing either.
  • Tests pass locally but fail in the agent environment. Compare runtime versions, lockfiles, environment-variable names, timezone, architecture, and setup logs. GitHub's agent-environment documentation says a failing setup step skips later setup steps and the agent continues with the environment it has, so the first setup error is often the real cause.
  • The browser keeps showing old behavior. Check service workers, Next.js caches, persisted local storage, seeded database state, and whether the preview is on the agent's latest commit. A hard reload cannot repair a stale backend fixture.
  • Dependency installation or an API call fails. Inspect firewall warnings and package-registry access. GitHub's current cloud agent limits internet access with a firewall by default. Do not “fix” a blocked dependency by deleting the feature.
  • The agent edits adjacent services but never closes the loop. Current GitHub cloud-agent documentation says a task changes one selected repository, works on one branch, opens one pull request, and has a hard 59-minute session limit. Split cross-repo work into explicit contracts and separate tasks.
  • The result changes around midnight or month end. Freeze the clock, set the browser timezone, store timestamps with offsets, and test exact start and end boundaries. Never debug time policy using “now” as an invisible input.

Edge cases that deserve their own fixtures

Complex logic stalls are usually state-space problems wearing a UI costume. Add fixtures for zero and maximum values, missing optional fields, duplicate commands, stale versions, partial migrations, non-ASCII input, DST transitions, leap days, currencies without two decimal places, and records created under old product rules.

Also test failure ordering. If validation, authorization, and concurrency can all reject a request, decide which error wins. Otherwise the agent may “fix” a test by reordering guards and accidentally reveal whether a private record exists. For destructive actions, verify idempotency: a browser retry after a network timeout must not charge, email, or delete twice.

Finally, separate product ambiguity from implementation failure. If nobody can answer whether a coupon applies before or after a credit, the agent is not stalled; the specification is. Encode the decision in a named test once a human makes it. Until then, instruct the agent to stop with the competing interpretations instead of choosing the one that makes the current test easiest.

The indie-hacker playbook: shrink the truth, then ship

You do not need an enterprise platform to make browser agents reliable. You need a small, ruthless evidence chain: reproduce the wrong decision as data, extract the rule, write the focused test, add one diagnostic command, then keep a thin browser check for wiring. Put stable setup facts in repository instructions and task facts in the failing test.

This changes the vibe of agent iteration. Instead of watching a clever model improvise around a screenshot, you are steering a constrained search. Every failed run removes a hypothesis. Every passing gate proves one layer. The agent still gives you speed; your architecture decides whether that speed points toward the product you meant to build.

Ready to ship your next project faster?

Desplega.ai helps indie hackers and solopreneurs build and ship faster with AI-assisted quality gates for the flows that matter.

Get Started

Frequently Asked Questions

Is GitHub Copilot Workspace still available?

No. GitHub sunset the Copilot Workspace technical preview on May 30, 2025. Its browser-first loop lives on conceptually in newer cloud-agent workflows, so treat old references as historical.

Why does a coding agent fix the UI but miss the domain bug?

The DOM exposes rendered symptoms, not hidden invariants. Give the agent typed inputs, expected outputs, focused tests, and logs from the exact failing boundary.

What should I add before retrying a stalled Copilot task?

Add one minimal reproduction, a deterministic test command, repository instructions, and a stop condition. Then ask for the smallest change that makes the gate pass.

Should indie hackers replace browser tests with unit tests?

No. Put fast contract tests around domain decisions, then keep a thin browser test for wiring. The two layers catch different failures and make agent retries useful.