diff options
| author | Adam Malczewski <[email protected]> | 2026-06-30 01:30:06 +0900 |
|---|---|---|
| committer | Adam Malczewski <[email protected]> | 2026-06-30 01:30:06 +0900 |
| commit | bf74aeab143a49005c380706ae9847cf064fd2f2 (patch) | |
| tree | c9e93dc0ebe818e7c0d0aafeba8387afd161da3f /broken-chat-repair-handoff.md | |
| parent | 6dd9ea9b935e5011c16faed6c869c976cf5ff172 (diff) | |
| download | dispatch-main.tar.gz dispatch-main.zip | |
Removed 40+ markdown files that were cluttering the repo root:
- frontend-*-handoff.md (28 files) — historical API contract handoffs, features all implemented
- backend-to-fe-handoff.md, backend-to-fe-handoff-2.md — old handoff docs
- broken-chat-repair-handoff.md — old repair handoff
- PLAN-mcp.md, PLAN-per-edit-diagnostics.md — old planning docs
- ai-review-report.md, crash-review-report.md — one-time review reports
- tasks.md, HANDOFF.md — outdated status docs (git log is the source of truth)
Kept: AGENTS.md, GLOSSARY.md, ORCHESTRATOR.md, README.md
Also: gitignored ai-review-report.md so future Gemini reviews don't commit it
Diffstat (limited to 'broken-chat-repair-handoff.md')
| -rw-r--r-- | broken-chat-repair-handoff.md | 180 |
1 files changed, 0 insertions, 180 deletions
diff --git a/broken-chat-repair-handoff.md b/broken-chat-repair-handoff.md deleted file mode 100644 index 12deec0..0000000 --- a/broken-chat-repair-handoff.md +++ /dev/null @@ -1,180 +0,0 @@ -# Handoff → orchestrator (bcb5): broken-chat self-repair - -> From: diagnostic session. Agent/conversation `77574596` -> (`77574596-3e7b-46f8-8d67-c9e17a529dee`) "broke unrecoverably." User goal: -> **chats must self-heal when broken so they can continue.** Implement the fixes -> below. Full diagnosis + plan also in `reports/broken-chat-repair-diagnosis.md`. - -## 0. Your job (TL;DR) - -`reconcile()` only repairs orphaned tool-calls. The production DB has **two other -broken states** it doesn't handle, and they make a chat uncontinuable. Implement a -read-time repair so broken chats auto-heal on next open — **no DB surgery** -(append-only durability preserved; repair is a turn-path transform that runs on -every `load()`). Three units, two repos: - -- **Wave 1 (arch-rewrite, PARALLEL — disjoint packages):** - - `conversation-store` — extend `reconcile` (Layer 1) + harden `load()`. - - `openai-stream` — harden `convertMessages` args (Layer 2). -- **Wave 2 (separate repo `../claude`, SEPARATE agent):** - - `provider-anthropic` — harden its `safeJson` (Layer 2 equivalent). - -**Key architectural insight that shapes the waves:** Layer 1 lives in -`conversation-store.reconcile`, which runs in `load()` BEFORE any provider sees -the messages. So the Layer 1 fix protects **every** provider (openai-compat AND -anthropic) — the Claude plugin needs **no** Layer 1 change. Layer 2 (malformed -tool-call args) is **per-provider** serialization safety, so it must be applied -in each provider's converter (openai-stream + provider-anthropic). - -## 1. The break (what actually happened in `77574596`) - -Production DB: `/var/lib/dispatch/dispatch.db` (systemd `dispatch.service`). -136 chunks; seq counter = 136; **all JSON valid; no orphaned tool-calls** — so -`reconcile()` finds nothing wrong, yet the chat is uncontinuable. The tail: - -| seq | role | type | note | -|---|---|---|---| -| 133 | assistant | text | "Wave 0 fully verified…" | -| 134 | assistant | tool-call | `todo_write`, `input` = **malformed JSON** (`json_type=text`, raw string) | -| 135 | tool | tool-result | isError: "todo_write args must be an object with a `todos` array" | -| 136 | assistant | **error** | `HTTP 400: unexpected character: line 1 column 1413 (char 1412). Received Model Group=glm-5.2` | - -### Root cause (confirmed byte-for-byte) -- seq 134's `input` is a raw string. Parsing it fails - `Expecting ':' delimiter: line 1 column 1413 (char 1412)` — an **exact match** to - the provider's `unexpected character: line 1 column 1413`. The **model emitted - malformed JSON as the `todo_write` arguments**. -- Chain: model emits text + malformed-args tool-call (step 5) → kernel dispatches - the tool, which returns an error result (seq 135) → kernel calls the provider - again (step 6); the request re-includes the assistant message carrying the - malformed `arguments` → provider 400s → persisted as an `error` chunk (seq 136). - -### Why it's "unrecoverable" -- `openai-stream` `convertAssistantMessage` serializes tool-call args as - `typeof c.input === "string" ? c.input : JSON.stringify(c.input)` — passes the - malformed string straight through as the OpenAI `arguments` field → provider - 400s on **every** continuation. -- The trailing `assistant` message whose only chunk is `error` serializes to - `content:""` + no tool_calls (error chunk is filtered out, leaving an empty - assistant message) → also uncontinuable. -- `reconcile()` touches neither. `load()` also has no try/catch on - `JSON.parse(value)` — a single corrupt row would throw and brick the chat. - -### Scope (production DB, 140 conversations) -- **6 conversations end in a trailing `error` chunk:** `102587c0`(seq2, HTTP 401 - model-not-supported), `2bf78252`(seq2), `61127511`(seq250), `77574596`(seq136), - `d0d85eca`(seq2), `e1ee0989`(seq20). -- **2 tool-calls total** carry a raw malformed-string `input`. -- `102587c0` has **only** the trailing-error break (no args, no tool-calls) — - proving Layer 1 is independently necessary. `77574596` has **both**. - -## 2. The fix - -### Layer 1 — `conversation-store` `reconcile.ts` (structural repair) -Extend `reconcileWithReport` to: -1. **Strip `error` chunks from assistant messages.** An `error` chunk is a - failed-generation marker, never valid provider content (no provider understands - an "error" content type) — provider-agnostic. -2. **Drop any assistant message left with no `text` and no `tool-call` chunks** - (the now-empty error-only message). This is what unblocks continuation. **Safe:** - an error-only step ends with no tool-calls, so it is never followed by a `tool` - message — no "tool-without-preceding-assistant-tool_calls" 400 can result. Keep - the existing orphaned-tool-call synthesis unchanged. -3. Extend `ReconcileReport` with counts of stripped error chunks / dropped messages - (for the existing `reconcile.repair` boot/log span). - -Why here: the constitution designates `reconcile` as "the pure function run on load -that repairs any partial turn." A trailing error-only assistant message IS a -partial/broken turn. Pure, provider-agnostic, runs on every `load()` → auto-repairs -all 6 broken chats. Repair is read-time only; storage (append-only) untouched. -`loadSince` (FE reads) is intentionally NOT reconciled, so the user still SEES the -error while the provider gets clean history. - -### Hardening — `conversation-store` `store.ts` `load()` (same unit) -Wrap the per-chunk `JSON.parse(value)` in try/catch: on a corrupt/unparseable row, -log + skip it (don't throw) so `reconcile` can still run on the rest. Today a single -bad row makes `load()` throw → unrecoverable. (0 such rows today; "never leave the -system broken" asks for it.) - -### Layer 2 — `openai-stream` `convert-messages.ts` (serialization safety) -In `convertAssistantMessage`, ensure a tool-call's `arguments` is **always a valid -JSON string**: if `input` is a string, `JSON.parse` it; on failure substitute a -valid fallback object (e.g. `JSON.stringify({})` or a wrapped -`{ _malformed_arguments: <truncated> }`). Objects pass through `JSON.stringify` as -today. This neutralizes already-stored malformed args (seq 134) so the provider -stops 400ing on continuation. Follow the SAME semantics as the Claude fix below -(isolation over DRY: each provider reimplements locally, same behavior). - -### Layer 2 (equivalent) — `../claude` `provider-anthropic` `convert.ts` (SEPARATE agent) -The Claude plugin already has a `safeJson(s)` helper (line ~115) used at -`input: typeof c.input === "string" ? safeJson(c.input) : c.input`. But its fallback -**returns the raw string `s` on parse failure** — for Anthropic, `tool_use.input` -must be an object, so a raw string can still 400 when a historical malformed tool_use -is re-sent. Fix: make `safeJson` return a **valid object fallback** (e.g. `{}`) on -parse failure instead of the raw string. (Layer 1 does NOT apply here — the -arch-rewrite `reconcile` already strips error chunks before the Claude provider sees -the messages, so the Claude converter never receives error-only assistant messages.) - -## 3. Waves & summoning - -- **Wave 1 (arch-rewrite, PARALLEL):** `conversation-store` (Layer 1 + `load()` - hardening) and `openai-stream` (Layer 2). Disjoint packages, no contract/type - change, both depend only on already-built `@dispatch/kernel` contracts. Standard - summon per ORCHESTRATOR §2/§3 (attach the scoped rules: conversation-store gets - `pure-core.md`+`no-internal-mocks.md`+`typed-handles.md`+`extension-logging.md`; - openai-stream gets `pure-core.md`+`no-internal-mocks.md`+`extension-logging.md`; - both get `one-owner.md`+`isolation-over-dry.md`+`biome-clean.md`+`package-agent.md`+ - `extension-agent.md`). -- **Wave 2 (separate repo, SEPARATE agent):** summon against - `--cwd /home/tradam/projects/dispatch/claude` for `packages/provider-anthropic` - (`convert.ts` `safeJson`). That repo has its own `AGENTS.md`; attach the - arch-rewrite `package-agent.md`+scoped rules as needed. Can run in parallel with - Wave 1 (different repo, no shared files). - -## 4. Why this auto-heals `77574596` (and the other 5) — no DB surgery -On next open/continue, `load()` returns history ending at seq 135 (the tool-result): -Layer 1 strips the seq-136 error message; Layer 2 sanitizes the seq-134 args to -valid JSON. The provider receives -`[…, assistant{text+tool-call(args:{})}, tool{error result}]` — a valid "continue -after a tool result" state. The model sees its `todo_write` failed and adjusts. -Chat continues. Same auto-repair applies to the other 5 (Layer 1 alone for the -401/empty cases; Layer 1+2 for any malformed-args case). - -## 5. Test requirements (regression scar tissue) - -**conversation-store `reconcile.test.ts`:** -- `reconcile strips error-only trailing assistant message` (the 77574596/102587c0 - shape: `[user, assistant{error}]` → `[user]`). -- `reconcile strips error chunk but keeps sibling text` - (`assistant{text,error}` → `assistant{text}`). -- `reconcile drops assistant message left empty after stripping error` - (`assistant{error}` only → dropped). -- `reconcile keeps tool-call + strips error` (`assistant{tool-call,error}` with a - matching result → `assistant{tool-call}`). -- existing orphaned-tool-call behavior unchanged (regression). -- (hardening) corrupt-JSON chunk row is skipped, rest load + reconcile. - -**openai-stream `convert-messages.test.ts`:** -- `arguments is valid JSON when input is a malformed string` (seed from seq 134's - raw string → output `JSON.parse`s, no throw). -- `arguments passes through valid string input` and `stringifies object input` - (regression). - -**provider-anthropic `convert.test.ts` (claude repo):** -- `safeJson returns a valid object fallback on malformed string` (raw malformed - string → `{}` or wrapped object, not the raw string). -- `safeJson parses valid string input` (regression). - -## 6. Verify (ORCHESTRATOR §4) -`bun run typecheck && bun run test && bun run check` whole-project green; both agents -in-lane (`git status --short`); zero internal mocks in the pure-core units. Live-spot: -open `77574596` against a probe/`bin/up` and confirm it now continues past the tool -result instead of 400-looping. - -## 7. Notes / out of scope -- **Parse-time prevention** (openai-stream / provider-anthropic could reject or - repair malformed args when the model emits them, instead of storing a raw - string) is a deeper follow-up; Layer 2 is the safety net that also repairs - already-stored data. -- Deploying the fix auto-repairs the 6 broken production chats on next load — no - migration needed. |
