DOCEXESIV.DEVREV2026.07
← RETURN // FIELD NOTES

Inside the issue-reporter modal

Ref
FLD-46BD95
Published
2026-04-26
Kind
DEVLOG
Project
Sidebar Agent
Author
Roman Sanine

The issue reporter is the smallest possible product surface around an idea that turns out to be load-bearing: let the user file a bug, but make the file → fix loop autonomous. No tracker, no Jira ticket, no "we'll get to it." The user types what's wrong, the agent triages it, classifies it as hotfix or feature, kicks off a runner, and posts back when it's done. This entry is the technical anatomy of that surface.

The shape, top to bottom

┌─────────────────────────────────────────────┐
│  IssueReporterModal.vue        (654 lines)  │  ← user-facing
│   ↳ IssueReporterIssueCard     (267 lines)  │
│   ↳ IssueReporterConversation  ( 90 lines)  │
│   ↳ IssueReporterPendingQuestion( 31 lines) │
│   ↳ useIssueReporter()                      │  ← composable
└─────────────────────────────────────────────┘
                  │ tRPC + SSE
                  ▼
┌─────────────────────────────────────────────┐
│  /api/issue-reports/[id].get  .patch        │  ← REST surface
│  /api/issue-reports/stream.get              │  ← SSE
│  issue-events.ts        (in-proc bus)       │
│  issue-runner.ts        (spawns runner)     │
│  issue-runner-events.ts (event reducer)     │
│  issue-queue-watchdog   (nitro plugin)      │
└─────────────────────────────────────────────┘
                  │ child_process.spawn()
                  ▼
┌─────────────────────────────────────────────┐
│  scripts/issue-agent-runner.ts              │  ← detached worker
│   ↳ runs Claude Code session                │
│   ↳ HMAC-signed callbacks                   │
│   ↳ writes status events back               │
└─────────────────────────────────────────────┘
                  │ logs
                  ▼
        .issue-reports/runs/<id>.log

Four layers: UI, event bus, runner, worker. Each is replaceable in isolation.

The composable

useIssueReporter() is the only thing the modal sees. It exposes:

const {
  isOpen, prompt, issues, loading, editingId,
  attachments, pageContexts,
  open, close, submit, deleteIssue,
  startEdit, cancelEdit, updateIssue,
  postMessage, answerQuestion, forceStart, recheckQueues,
} = useIssueReporter()

The composable owns:

  • Local form state — the textarea, attachments (screenshots/voice clips), and the implicit "page contexts" (URL, route, page tools list, scrollback) captured at submission time.
  • Polled list of issues — refetched when SSE pushes an update event.
  • Edit mode — turning an existing issue back into an editable draft.
  • Question-answer loop — when an issue's runner asks a clarifying question (AskUserQuestion), the answer comes through this path.
  • Force-start and recheck — manual overrides for stuck queues.

State lives in a Pinia-style provide/inject pattern so opening the modal from anywhere in the app shares the same draft and issue list.

Issue lifecycle

An issue moves through a state machine:

Status Meaning
open Filed, not yet picked up by a runner
active A runner is processing it
awaiting-user Runner is blocked on a clarification question
closed Done, fix applied + verified
failed Runner exited non-zero or hit an unrecoverable error
cancelled User killed it before completion

There's also an orthogonal workStatus for the active phase: reading | writing | testing. The UI shows this as a small label next to the status — "active · reading" tells the user the agent is still gathering context, hasn't started writing yet.

Triage and the page-tag

Every issue has a targetTag field that scopes it to a route. Filed from /admin/agents? Tagged /admin/agents. The UI shows page-scoped issues in the modal by default but lets you switch to a global view:

type Scope = 'page' | 'global' | 'issues-modal'
const pageIssues = computed(() =>
  issues.value.filter(issue =>
    issue.targetTag === currentTargetTag.value
    || (!issue.targetTag && issue.page.replace(/^https?:\/\/[^/]+/, '').split('?')[0] === route.path),
  ),
)

This matters because filing "this is broken" without context is what kills bug trackers. The targetTag plus the captured page context (route params, scroll position, what the user was looking at) make the report self-contained.

SSE for live status

The modal stays in sync with running issues over a single SSE connection:

// /api/issue-reports/stream.get.ts
setResponseHeaders(event, {
  'Content-Type': 'text/event-stream',
  'Cache-Control': 'no-cache',
  'Connection': 'keep-alive',
  'X-Accel-Buffering': 'no',
})
res.write(':ok\n\n')

const heartbeat = setInterval(() => {
  if (!res.writable) { clearInterval(heartbeat); return }
  res.write(':heartbeat\n\n')
}, 15_000)

const unsub = onIssueUpdate(() => {
  res.write('event: update\ndata: {}\n\n')
})

The payload is intentionally minimal — just {}. The client's response is "refetch the issue list." This avoids two failure modes: stale partial state (where the SSE event diverges from the database) and oversized SSE messages (which proxies sometimes truncate).

The onIssueUpdate bus is in-process:

// issue-events.ts
const listeners = new Set<() => void>()
export function emitIssueUpdate() {
  for (const fn of listeners) {
    try { fn() } catch {}
  }
}
export function onIssueUpdate(fn: () => void): () => void {
  listeners.add(fn)
  return () => listeners.delete(fn)
}

Anything that writes to an issue file calls emitIssueUpdate(). The SSE handler subscribes, the heartbeat keeps the proxy from dropping the connection, the request-close cleanup unsubscribes.

The runner subsystem

This is the part that does the actual work. When an issue moves from open → active, the server spawns a detached child process:

// issue-runner.ts (sketch)
const child = spawn('node', ['--loader', 'tsx', runnerScriptPath(), id], {
  detached: true,
  stdio: ['ignore', logFd, logFd],
  env: {
    ...process.env,
    ISSUE_ID: id,
    ISSUE_CALLBACK_URL: callbackUrl,
    ISSUE_TOKEN: mintIssueToken(id),
  },
})
child.unref()

A few things matter here:

  • Detached. The runner outlives the parent. If the dev server restarts, in-flight runners keep going.
  • Log file per run. .issue-reports/runs/<id>.log captures stdout+stderr, viewable in the issue card.
  • HMAC token. The callback URL the runner uses to post status updates is authenticated with a token signed against a per-installation secret stored at .issue-reports/.secret (file mode 0600). The runner can talk to the server without the user being logged in; nobody else can.
  • Headless agent. The runner script runs Claude Code in headless mode against the project, with system prompts that point it at the issue file and the constitution.

Event reducer

The runner emits structured events; the server reduces them onto the issue record:

// issue-runner-events.ts
export function applyIssueRunnerEvent(
  issue: IssueReport,
  event: IssueRunnerEvent,
  now = new Date().toISOString(),
): IssueReport {
  if (event.type === 'runner')      return { ...issue, autoloopRunner: { ... } }
  if (event.type === 'workStatus')  return { ...issue, workStatus: event.workStatus }
  if (event.type === 'headline')    return { ...issue, headline: event.headline }
  if (event.type === 'message')     return { ...issue, messages: [...issue.messages, event.message] }
  if (event.type === 'question')    return { ...issue, status: 'awaiting-user', ... }
  if (event.type === 'completed')   return { ...issue, status: 'closed', ... }
  // ...
}

Pure function, no I/O, easy to test. The handler that calls it owns the file write + emitIssueUpdate().

The question-answer loop

When the runner needs clarification, it emits a question event:

{
  type: 'question',
  question: {
    id: 'q-7f3a...',
    text: 'Should I match the existing button styling, or follow the new design system?',
    options: [
      { id: 'a', label: 'Existing button styling' },
      { id: 'b', label: 'New design system' },
    ],
  }
}

The reducer pushes this into the message thread and flips status to awaiting-user. The modal's IssueReporterPendingQuestion component renders the options. When the user picks one, answerQuestion(issueId, questionId, answer) POSTs back, the server appends the answer to the message thread, the runner — which has been polling its issue file — sees the answer and resumes.

The polling-not-pushing direction matters. The runner is a Node child process that doesn't have a persistent connection back to the server; it polls its issue file every few seconds for new question-answers. Slightly less elegant than push, but recoverable if the runner crashes mid-question.

The watchdog

issue-queue-watchdog.ts is a nitro plugin that runs on server start:

export default defineNitroPlugin(async () => {
  // On boot: scan .issue-reports for issues with status=active but no live runner
  // → mark as failed OR re-spawn, depending on staleness
  // Then: every 30s, recheck queues for orphaned issues
})

This catches the failure modes that scare people about detached processes:

  • Server restarts mid-issue → next boot detects orphaned active issues, re-spawns or fails them.
  • Runner segfaults → the child watcher in the parent process catches the exit code and writes failed + stderr tail.
  • Issue stuck in awaiting-user for >24h → marked cancelled to keep the queue clean.

The watchdog's heuristics live in QueueHealth records (also in issue-runner.ts):

interface QueueHealth {
  pendingCount: number
  activeCount: number
  backlogCount: number
  oldestPendingAgeMs?: number
  oldestActiveAgeMs?: number
  stalePending: boolean
  staleActive: boolean
  blocked: boolean
}

The UI exposes a "recheck queues" button that triggers a manual sweep — useful when something looks wrong and you don't want to wait for the next interval.

Storage

Everything lives in .issue-reports/ at the project root:

.issue-reports/
  .secret              # HMAC secret, mode 0600
  queue-exec.md        # legacy, retained for back-compat
  <issue-id>.json      # one file per issue, full history
  runs/
    <issue-id>.log     # combined stdout/stderr from runner

No database. The trade-off is deliberate: issue records need to survive dev-server restarts and be inspectable from a shell, neither of which a Mongo collection gives you for free during agent debugging. When the issue volume justifies it, this layer can move to a collection without changing the rest of the architecture.

What this enables

  • A real-time bug-fix loop. File a bug at 2pm, get a closed status at 2:14pm with the diff and the test that proves it. Or get an awaiting-user and answer a clarifying question. Either way, the user is never the bottleneck.
  • Survives restarts. Detached runners + watchdog + file-backed storage means dev-server crashes don't lose work.
  • Auditable. Every issue's full conversation, runner log, status transitions, and answers are on disk. .issue-reports/<id>.json is the system of record.
  • Composable with the architecture system. A closed issue triggers the .architecture/ integration: trivial fixes update the bug ledger, complex ones expand into feature folders. The triage step happens inside the runner, so by the time the user sees closed, the architecture state is already consistent.

Friction

A few things that aren't smooth yet:

  • Runner-to-server polling for question answers is the design choice that costs the most. A websocket would be cleaner, but the recoverability story for "runner reconnects after parent restart" gets significantly more complex. The current poll-every-2s is the pragmatic tradeoff.
  • HMAC tokens have no expiry. A token minted for a runner that lives 30 minutes is valid for that whole window. Acceptable now (single-host installations); needs replacing with short-lived tokens before this goes multi-tenant.
  • Log file growth. .issue-reports/runs/<id>.log doesn't rotate. After a few hundred issues, the directory is a few hundred MB. Not urgent, but not zero.

Where this is going

Two directions:

  1. Issue templates per route. A complaint filed from the agents page should pre-populate "agent: , route: /admin/agents/, last action: ." The plumbing for this is half-built — pageContexts already captures most of what's needed.
  2. Multi-runner orchestration. Right now each issue gets one runner. Some issues — especially "audit this whole feature" type tasks — would benefit from a parent runner that spawns N child runners on subtasks. The runner spawn machinery is already a function call; it's the supervision protocol that's missing.

The current system is the smallest viable shape. It works, it's recoverable, and it's a shape that can grow.

Sign in to react
Comments
No comments yet.
Comments are reviewed before appearing.
EXESIV.DEV — EXESIV lab builds agentic systems and components for the self-running business — autonomous, policy-driven operations, logged in the open across four sections: Almanac, Works, Field Notes, Seeds.