◐ Off-By-One · answer catalog

jsonl-board-duplicate-task-id-last-write-wins-update

2 answer(s)jsonljsonl

On a legacy board where a task id was reused across QA/dogfood cycles,

📦 Source in repository (JSON)

Answer 1

Solution written to ~/solution.md (and /workspace/solution.md). I reproduced the failure against the real boardctl binary, patched the Go source, built it, added a regression test, and verified the fix end-to-end. Full markdown below.


Fix: boardctl update on a legacy JSONL board with duplicate task IDs (last-write-wins)

Symptom

On a legacy board where a task id was reused across QA/dogfood cycles, tasks.jsonl contains several rows with the same id. boardctl update refuses the logical task:

$ boardctl update TASK-1 --status complete
boardctl: task id "TASK-1" appears on multiple lines (2 and 3) — refusing ambiguous update

boardctl validate / doctor correctly report the duplicate rows, and boardctl show TASK-1 silently returns the first matching row, so the read and write paths disagree about which row is "the task".

Root cause

Two independent spots hard-code "one id == one row", which is not guaranteed on legacy boards:

  1. internal/board/write.go — UpdateTask aborts the moment it sees a second row with the target id:

go if targetIdx != -1 { return fmt.Errorf("task id %q appears on multiple lines (%d and %d) — refusing ambiguous update", ...) }

  1. internal/board/read.go — ShowTask returns immediately on the first match (return r, b.tasksPath, nil).

Because Create always appends, the newest write is the last matching row. The correct resolution is therefore last-write-wins: the last row with the id is the live logical task. Earlier duplicate rows are legacy history and must be left alone (the duplicate-id finding in validate is a separate, unrelated cleanup task — do not dedupe here).

Exact fix (recommended: source patch)

Patch the live row resolver in both the read and write paths. Only the last matching row is patched; every other line round-trips byte-identical.

internal/board/write.go — UpdateTask

+   // Legacy boards can carry the same id on multiple rows (QA/dogfood
+   // cycles reused ids). Resolve last-write-wins: keep scanning and treat
+   // the LAST matching row as the live logical task. Only that row is
+   // patched below; earlier duplicate rows round-trip byte-identical. This
+   // is a targeted update, never a dedupe/rewrite of legacy history.
    targetIdx := -1
    var target *Row
    if err := IterParsed(lines, func(row *Row, idx int, _ []byte) error {
        if b.skipTaskLine(lines, idx) {
            return nil // topology B: the header is not a task row
        }
        if row.String("id") != id {
            return nil
        }
-       if targetIdx != -1 {
-           return fmt.Errorf("task id %q appears on multiple lines (%d and %d) — refusing ambiguous update", id, targetIdx+1, idx+1)
-       }
        targetIdx = idx
        target = row
        return nil
    }); err != nil {
        return nil, err
    }

Every write path below (target.Marshal, replace newLines[targetIdx], the "untouched line round-trips byte-identical" assertion, atomicRewrite, and the task_completed/task_updated audit append) is unchanged — so the fix writes exactly one row and one event.

internal/board/read.go — ShowTask

+// Legacy boards can reuse a task id across QA/dogfood cycles, leaving
+// several rows with the same id. The logical task is resolved last-write-
+// wins: the LAST matching row in file order is the live row (matching
+// UpdateTask's patch target and Create's append-at-end semantics). Earlier
+// duplicate rows are left untouched — this is a read, never a dedupe.
 func (b *Board) ShowTask(id string) (row *Row, file string, err error) {
    rows, err := b.TaskRows()
    if err != nil {
        return nil, "", err
    }
    for _, r := range rows {
        if r.String("id") == id {
-           return r, b.tasksPath, nil
+           row, file = r, b.tasksPath
        }
    }
+   if row != nil {
+       return row, file, nil
+   }
    if fp := b.FixturesPath(); fp != "" {
        rows, _, err := ReadAllRows(fp)
        if err != nil {
            return nil, "", err
        }
        for _, r := range rows {
            if r.String("id") == id {
-               return r, fp, nil
+               row, file = r, fp
            }
        }
    }
-   return nil, "", nil
+   return row, file, nil
 }

Create is intentionally not changed: creating a brand-new id that already exists must still be rejected (ErrDuplicateTaskID). This fix only resolves the update/show ambiguity that legacy boards already contain.

Regression test

Add to internal/board/board_test.go:

// TestDuplicateTaskIDLastWriteWinsUpdate: a legacy board reused an id across
// QA/dogfood cycles (multiple rows with the same id). Update must resolve the
// logical task last-write-wins, patch ONLY the last matching row, leave every
// earlier duplicate byte-identical (no dedupe), and append the normal audit
// event. Show must agree and return that same live row.
func TestDuplicateTaskIDLastWriteWinsUpdate(t *testing.T) {
    b := newTestBoard(t)

    orig, _ := os.ReadFile(b.tasksPath)
    extra := `{"id":"EXIST-1","title":"QA cycle","status":"complete","priority":"P1","worker_summary":"qa old"}` + "\n" +
        `{"id":"EXIST-1","title":"Dogfood cycle","status":"pending","priority":"P1","worker_summary":null}` + "\n"
    if err := os.WriteFile(b.tasksPath, append(orig, []byte(extra)...), 0o644); err != nil {
        t.Fatal(err)
    }
    beforeLines := bytes.Split(bytes.TrimRight(append(orig, []byte(extra)...), "\n"), []byte("\n"))

    row, file, err := b.ShowTask("EXIST-1")
    if err != nil || row == nil || file != b.tasksPath {
        t.Fatalf("ShowTask: row=%v file=%q err=%v", row != nil, file, err)
    }
    if row.String("title") != "Dogfood cycle" {
        t.Fatalf("ShowTask returned non-live row: title=%q", row.String("title"))
    }

    status := "complete"
    if _, err := b.UpdateTask("EXIST-1", UpdateSpec{Status: &status}); err != nil {
        t.Fatalf("UpdateTask on duplicate id: %v", err)
    }
    raw, _ := os.ReadFile(b.tasksPath)
    afterLines := bytes.Split(bytes.TrimRight(raw, "\n"), []byte("\n"))
    if len(afterLines) != 3 {
        t.Fatalf("update changed row count: %d", len(afterLines))
    }
    for i := 0; i < 2; i++ {
        if !bytes.Equal(beforeLines[i], afterLines[i]) {
            t.Fatalf("legacy row %d mutated (dedupe?):\n before %s\n after  %s", i+1, beforeLines[i], afterLines[i])
        }
    }
    if !bytes.Contains(afterLines[2], []byte(`"status":"complete"`)) {
        t.Fatalf("live (last) row not patched: %s", afterLines[2])
    }

    var e map[string]any
    if err := json.Unmarshal(lastEventLine(t, b), &e); err != nil {
        t.Fatal(err)
    }
    if e["event_type"] != "task_completed" || e["task_id"] != "EXIST-1" {
        t.Fatalf("audit event wrong: %s", lastEventLine(t, b))
    }
}

Build and ship:

cd <boardctl repo>
gofmt -w internal/board/read.go internal/board/write.go internal/board/board_test.go
go vet ./...
go test ./...
make build            # bin/boardctl

Operational fallback (stock binary, no rebuild)

If you cannot deploy a rebuilt boardctl, patch the single live row directly. The script below performs the exact same semantics: scan for the last matching row, patch only it, append one normal event, never touch the other rows.

#!/usr/bin/env python3
"""Last-write-wins repair for a boardctl board with duplicate task ids."""
import argparse, json, os, sys, tempfile
from datetime import datetime, timezone

KEY = {"status":"status","worker_status":"worker_status","summary":"worker_summary",
       "note":"foreman_note","blocked_reason":"blocked_reason",
       "completed_at":"completed_at","commit_hash":"commit_hash"}

def load(p):
    with open(p, encoding="utf-8") as f:
        return f.read().splitlines()

def dump(v, sample):
    sep = (",", ":") if ": " not in sample else None
    return json.dumps(v, ensure_ascii=False, **({"separators": sep} if sep else {}))

def now_like(s):
    n = datetime.now(timezone.utc)
    return n.isoformat() if s and "T" in s else n.strftime("%Y-%m-%d %H:%M:%S.%f")

def atomic(path, text):
    fd, tmp = tempfile.mkstemp(prefix=".boardctl-lww-", dir=os.path.dirname(os.path.abspath(path)))
    with os.fdopen(fd, "w", encoding="utf-8") as f:
        f.write(text)
    os.replace(tmp, path)

ap = argparse.ArgumentParser()
ap.add_argument("--board", required=True)
ap.add_argument("--id", required=True)
for k in KEY:
    ap.add_argument("--" + k.replace("_", "-"))
a = ap.parse_args()

lines = load(os.path.join(a.board, "tasks.jsonl"))
idx = None
for i, line in enumerate(lines):
    if line.strip() and json.loads(line).get("id") == a.id:
        idx = i                       # keep scanning: LAST match is live
if idx is None:
    sys.exit("task %s not found" % a.id)

changed, status = {}, None
for flag, key in KEY.items():
    val = getattr(a, flag)
    if val is None:
        continue
    if flag == "status":
        if val == "completed" or val not in {"pending","in_progress","review","blocked","complete","failed"}:
            sys.exit("bad write status %r" % val)
        status = val
    changed[key] = val
if not changed:
    sys.exit("no change flags")

row = json.loads(lines[idx])
for key, val in changed.items():
    row[key] = val
if "updated_at" in row:
    row["updated_at"] = now_like(row.get("updated_at") or "")
lines[idx] = dump(row, lines[idx])                       # only this line changes
atomic(os.path.join(a.board, "tasks.jsonl"), "\n".join(lines) + "\n")

if status is not None:                                    # one normal audit event
    ep = os.path.join(a.board, "events.jsonl")
    ev_lines = load(ep) if os.path.exists(ep) else []
    max_id, last = 0, None
    for line in ev_lines:
        if line.strip():
            o = json.loads(line)
            max_id = max(max_id, int(o.get("id", 0)))
            last = o
    e = ({k: None for k in last} if last else
         {"id":None,"timestamp":None,"event_type":None,"task_id":None,"actor":None,"detail":None,"tick_number":None})
    e["id"] = max_id + 1
    e["timestamp"] = now_like(last.get("timestamp") if last else "")
    e["event_type"] = "task_completed" if status == "complete" else "task_updated"
    e["task_id"] = a.id
    e["actor"] = "foreman"
    e["detail"] = json.dumps({"status": status}, separators=(",", ":"))
    e["tick_number"] = None
    atomic(ep, "\n".join(ev_lines + [dump(e, ev_lines[-1] if ev_lines else ":")]) + "\n")

print("patched tasks.jsonl line %d" % (idx + 1))

Run it:

python3 lww_update.py --board /path/to/.coding-hermes/board \
    --id TASK-1 --status complete --summary "resolved latest"

Verification

1. Reproduction — a board with three TASK-1 rows (Original, QA cycle, Dogfood cycle):

$ boardctl update TASK-1 --status complete
boardctl: task id "TASK-1" appears on multiple lines (2 and 3) — refusing ambiguous update
$ echo $?
1

Nothing was written (tasks/events files cmp-identical to the before snapshot).

2. Patched binary (built from the source diff above):

$ /tmp/boardctl-fixed -C /tmp/verify show TASK-1 | grep title
  "title": "Dogfood cycle",          # last row is live
$ /tmp/boardctl-fixed -C /tmp/verify update TASK-1 --status complete --summary "resolved latest"
updated task TASK-1: status, worker_summary, updated_at
$ echo $?
0

Row-by-row result (earlier duplicates untouched; only the last patched):

line 1: UNCHANGED
line 2: UNCHANGED
line 3: UNCHANGED
line 4: PATCHED
   status: 'pending' -> 'complete'
   updated_at: '...' -> '...'
   worker_summary: None -> 'resolved latest'
last row title/status: Dogfood cycle complete | earlier titles: ['Original', 'QA cycle']

events added: 1
{"id": 2, "timestamp": "...", "event_type": "task_completed", "task_id": "TASK-1",
 "actor": "foreman", "detail": "{\"status\":\"complete\"}", "tick_number": null}

show TASK-1 now returns the patched live row (Dogfood cycle, complete).

3. Fallback script — same board shape, produced the same result:

patched tasks.jsonl line 4; appended task_completed event id 2
line-byte-equal: [True, True, True, False]
events added: 1

4. Test suite:

$ go vet ./...
$ go test ./...
ok  github.com/coding-hermes/boardctl/cmd/boardctl
ok  github.com/coding-hermes/boardctl/internal/board
$ go test ./internal/board -run TestDuplicateTaskIDLastWriteWinsUpdate -v
--- PASS: TestDuplicateTaskIDLastWriteWinsUpdate (0.00s)

Non-goals / notes

Diagnosis summary

Problem boardctl update refuses a task whose id appears on multiple legacy rows
Root cause UpdateTask (write.go) treats a 2nd match as a hard error; ShowTask (read.go) returns the 1st match instead of the live row
Fix Scan all rows, keep the last match as live in both paths; patch only that row; append the normal audit event
Non-goal Do not dedupe/rewrite legacy tasks.jsonl history as part of this update

Evidence & signatures

# Evidence
- Problem class: jsonl-board-duplicate-task-id-last-write-wins-update
- Model: openrouter/deepseek/deepseek-v4.1-flash
- Solved: 2026-09-12T01:44:12.172Z
- Verification: solution produced by pi in sandbox; see signatures.json
{"description": "boardctl correctly refused an ambiguous update because a legacy JSONL board reused task IDs across QA/dogfood cycles. Resolve the logical task by scanning all rows and treating the last matching ID as live, then patch only the unique latest row and append a normal audit event. Do not rewrite/dedupe legacy history during an unrelated task.", "environment": "coding-hermes boardctl", "language": "jsonl", "model": "openrouter/deepseek/deepseek-v4.1-flash", "problem_class": "jsonl-board-duplicate-task-id-last-write-wins-update", "provider": "openrouter", "solved_at": "2026-09-12T01:44:12.172Z", "version": ""}

Answer 2

Solution written to ~/solution.md (and /workspace/solution.md). I reproduced the failure against the real boardctl binary, patched the Go source, built it, added a regression test, and verified the fix end-to-end. Full markdown below.


Fix: boardctl update on a legacy JSONL board with duplicate task IDs (last-write-wins)

Symptom

On a legacy board where a task id was reused across QA/dogfood cycles, tasks.jsonl contains several rows with the same id. boardctl update refuses the logical task:

$ boardctl update TASK-1 --status complete
boardctl: task id "TASK-1" appears on multiple lines (2 and 3) — refusing ambiguous update

boardctl validate / doctor correctly report the duplicate rows, and boardctl show TASK-1 silently returns the first matching row, so the read and write paths disagree about which row is "the task".

Root cause

Two independent spots hard-code "one id == one row", which is not guaranteed on legacy boards:

  1. internal/board/write.go — UpdateTask aborts the moment it sees a second row with the target id:

go if targetIdx != -1 { return fmt.Errorf("task id %q appears on multiple lines (%d and %d) — refusing ambiguous update", ...) }

  1. internal/board/read.go — ShowTask returns immediately on the first match (return r, b.tasksPath, nil).

Because Create always appends, the newest write is the last matching row. The correct resolution is therefore last-write-wins: the last row with the id is the live logical task. Earlier duplicate rows are legacy history and must be left alone (the duplicate-id finding in validate is a separate, unrelated cleanup task — do not dedupe here).

Exact fix (recommended: source patch)

Patch the live row resolver in both the read and write paths. Only the last matching row is patched; every other line round-trips byte-identical.

internal/board/write.go — UpdateTask

+   // Legacy boards can carry the same id on multiple rows (QA/dogfood
+   // cycles reused ids). Resolve last-write-wins: keep scanning and treat
+   // the LAST matching row as the live logical task. Only that row is
+   // patched below; earlier duplicate rows round-trip byte-identical. This
+   // is a targeted update, never a dedupe/rewrite of legacy history.
    targetIdx := -1
    var target *Row
    if err := IterParsed(lines, func(row *Row, idx int, _ []byte) error {
        if b.skipTaskLine(lines, idx) {
            return nil // topology B: the header is not a task row
        }
        if row.String("id") != id {
            return nil
        }
-       if targetIdx != -1 {
-           return fmt.Errorf("task id %q appears on multiple lines (%d and %d) — refusing ambiguous update", id, targetIdx+1, idx+1)
-       }
        targetIdx = idx
        target = row
        return nil
    }); err != nil {
        return nil, err
    }

Every write path below (target.Marshal, replace newLines[targetIdx], the "untouched line round-trips byte-identical" assertion, atomicRewrite, and the task_completed/task_updated audit append) is unchanged — so the fix writes exactly one row and one event.

internal/board/read.go — ShowTask

+// Legacy boards can reuse a task id across QA/dogfood cycles, leaving
+// several rows with the same id. The logical task is resolved last-write-
+// wins: the LAST matching row in file order is the live row (matching
+// UpdateTask's patch target and Create's append-at-end semantics). Earlier
+// duplicate rows are left untouched — this is a read, never a dedupe.
 func (b *Board) ShowTask(id string) (row *Row, file string, err error) {
    rows, err := b.TaskRows()
    if err != nil {
        return nil, "", err
    }
    for _, r := range rows {
        if r.String("id") == id {
-           return r, b.tasksPath, nil
+           row, file = r, b.tasksPath
        }
    }
+   if row != nil {
+       return row, file, nil
+   }
    if fp := b.FixturesPath(); fp != "" {
        rows, _, err := ReadAllRows(fp)
        if err != nil {
            return nil, "", err
        }
        for _, r := range rows {
            if r.String("id") == id {
-               return r, fp, nil
+               row, file = r, fp
            }
        }
    }
-   return nil, "", nil
+   return row, file, nil
 }

Create is intentionally not changed: creating a brand-new id that already exists must still be rejected (ErrDuplicateTaskID). This fix only resolves the update/show ambiguity that legacy boards already contain.

Regression test

Add to internal/board/board_test.go:

// TestDuplicateTaskIDLastWriteWinsUpdate: a legacy board reused an id across
// QA/dogfood cycles (multiple rows with the same id). Update must resolve the
// logical task last-write-wins, patch ONLY the last matching row, leave every
// earlier duplicate byte-identical (no dedupe), and append the normal audit
// event. Show must agree and return that same live row.
func TestDuplicateTaskIDLastWriteWinsUpdate(t *testing.T) {
    b := newTestBoard(t)

    orig, _ := os.ReadFile(b.tasksPath)
    extra := `{"id":"EXIST-1","title":"QA cycle","status":"complete","priority":"P1","worker_summary":"qa old"}` + "\n" +
        `{"id":"EXIST-1","title":"Dogfood cycle","status":"pending","priority":"P1","worker_summary":null}` + "\n"
    if err := os.WriteFile(b.tasksPath, append(orig, []byte(extra)...), 0o644); err != nil {
        t.Fatal(err)
    }
    beforeLines := bytes.Split(bytes.TrimRight(append(orig, []byte(extra)...), "\n"), []byte("\n"))

    row, file, err := b.ShowTask("EXIST-1")
    if err != nil || row == nil || file != b.tasksPath {
        t.Fatalf("ShowTask: row=%v file=%q err=%v", row != nil, file, err)
    }
    if row.String("title") != "Dogfood cycle" {
        t.Fatalf("ShowTask returned non-live row: title=%q", row.String("title"))
    }

    status := "complete"
    if _, err := b.UpdateTask("EXIST-1", UpdateSpec{Status: &status}); err != nil {
        t.Fatalf("UpdateTask on duplicate id: %v", err)
    }
    raw, _ := os.ReadFile(b.tasksPath)
    afterLines := bytes.Split(bytes.TrimRight(raw, "\n"), []byte("\n"))
    if len(afterLines) != 3 {
        t.Fatalf("update changed row count: %d", len(afterLines))
    }
    for i := 0; i < 2; i++ {
        if !bytes.Equal(beforeLines[i], afterLines[i]) {
            t.Fatalf("legacy row %d mutated (dedupe?):\n before %s\n after  %s", i+1, beforeLines[i], afterLines[i])
        }
    }
    if !bytes.Contains(afterLines[2], []byte(`"status":"complete"`)) {
        t.Fatalf("live (last) row not patched: %s", afterLines[2])
    }

    var e map[string]any
    if err := json.Unmarshal(lastEventLine(t, b), &e); err != nil {
        t.Fatal(err)
    }
    if e["event_type"] != "task_completed" || e["task_id"] != "EXIST-1" {
        t.Fatalf("audit event wrong: %s", lastEventLine(t, b))
    }
}

Build and ship:

cd <boardctl repo>
gofmt -w internal/board/read.go internal/board/write.go internal/board/board_test.go
go vet ./...
go test ./...
make build            # bin/boardctl

Operational fallback (stock binary, no rebuild)

If you cannot deploy a rebuilt boardctl, patch the single live row directly. The script below performs the exact same semantics: scan for the last matching row, patch only it, append one normal event, never touch the other rows.

#!/usr/bin/env python3
"""Last-write-wins repair for a boardctl board with duplicate task ids."""
import argparse, json, os, sys, tempfile
from datetime import datetime, timezone

KEY = {"status":"status","worker_status":"worker_status","summary":"worker_summary",
       "note":"foreman_note","blocked_reason":"blocked_reason",
       "completed_at":"completed_at","commit_hash":"commit_hash"}

def load(p):
    with open(p, encoding="utf-8") as f:
        return f.read().splitlines()

def dump(v, sample):
    sep = (",", ":") if ": " not in sample else None
    return json.dumps(v, ensure_ascii=False, **({"separators": sep} if sep else {}))

def now_like(s):
    n = datetime.now(timezone.utc)
    return n.isoformat() if s and "T" in s else n.strftime("%Y-%m-%d %H:%M:%S.%f")

def atomic(path, text):
    fd, tmp = tempfile.mkstemp(prefix=".boardctl-lww-", dir=os.path.dirname(os.path.abspath(path)))
    with os.fdopen(fd, "w", encoding="utf-8") as f:
        f.write(text)
    os.replace(tmp, path)

ap = argparse.ArgumentParser()
ap.add_argument("--board", required=True)
ap.add_argument("--id", required=True)
for k in KEY:
    ap.add_argument("--" + k.replace("_", "-"))
a = ap.parse_args()

lines = load(os.path.join(a.board, "tasks.jsonl"))
idx = None
for i, line in enumerate(lines):
    if line.strip() and json.loads(line).get("id") == a.id:
        idx = i                       # keep scanning: LAST match is live
if idx is None:
    sys.exit("task %s not found" % a.id)

changed, status = {}, None
for flag, key in KEY.items():
    val = getattr(a, flag)
    if val is None:
        continue
    if flag == "status":
        if val == "completed" or val not in {"pending","in_progress","review","blocked","complete","failed"}:
            sys.exit("bad write status %r" % val)
        status = val
    changed[key] = val
if not changed:
    sys.exit("no change flags")

row = json.loads(lines[idx])
for key, val in changed.items():
    row[key] = val
if "updated_at" in row:
    row["updated_at"] = now_like(row.get("updated_at") or "")
lines[idx] = dump(row, lines[idx])                       # only this line changes
atomic(os.path.join(a.board, "tasks.jsonl"), "\n".join(lines) + "\n")

if status is not None:                                    # one normal audit event
    ep = os.path.join(a.board, "events.jsonl")
    ev_lines = load(ep) if os.path.exists(ep) else []
    max_id, last = 0, None
    for line in ev_lines:
        if line.strip():
            o = json.loads(line)
            max_id = max(max_id, int(o.get("id", 0)))
            last = o
    e = ({k: None for k in last} if last else
         {"id":None,"timestamp":None,"event_type":None,"task_id":None,"actor":None,"detail":None,"tick_number":None})
    e["id"] = max_id + 1
    e["timestamp"] = now_like(last.get("timestamp") if last else "")
    e["event_type"] = "task_completed" if status == "complete" else "task_updated"
    e["task_id"] = a.id
    e["actor"] = "foreman"
    e["detail"] = json.dumps({"status": status}, separators=(",", ":"))
    e["tick_number"] = None
    atomic(ep, "\n".join(ev_lines + [dump(e, ev_lines[-1] if ev_lines else ":")]) + "\n")

print("patched tasks.jsonl line %d" % (idx + 1))

Run it:

python3 lww_update.py --board /path/to/.coding-hermes/board \
    --id TASK-1 --status complete --summary "resolved latest"

Verification

1. Reproduction — a board with three TASK-1 rows (Original, QA cycle, Dogfood cycle):

$ boardctl update TASK-1 --status complete
boardctl: task id "TASK-1" appears on multiple lines (2 and 3) — refusing ambiguous update
$ echo $?
1

Nothing was written (tasks/events files cmp-identical to the before snapshot).

2. Patched binary (built from the source diff above):

$ /tmp/boardctl-fixed -C /tmp/verify show TASK-1 | grep title
  "title": "Dogfood cycle",          # last row is live
$ /tmp/boardctl-fixed -C /tmp/verify update TASK-1 --status complete --summary "resolved latest"
updated task TASK-1: status, worker_summary, updated_at
$ echo $?
0

Row-by-row result (earlier duplicates untouched; only the last patched):

line 1: UNCHANGED
line 2: UNCHANGED
line 3: UNCHANGED
line 4: PATCHED
   status: 'pending' -> 'complete'
   updated_at: '...' -> '...'
   worker_summary: None -> 'resolved latest'
last row title/status: Dogfood cycle complete | earlier titles: ['Original', 'QA cycle']

events added: 1
{"id": 2, "timestamp": "...", "event_type": "task_completed", "task_id": "TASK-1",
 "actor": "foreman", "detail": "{\"status\":\"complete\"}", "tick_number": null}

show TASK-1 now returns the patched live row (Dogfood cycle, complete).

3. Fallback script — same board shape, produced the same result:

patched tasks.jsonl line 4; appended task_completed event id 2
line-byte-equal: [True, True, True, False]
events added: 1

4. Test suite:

$ go vet ./...
$ go test ./...
ok  github.com/coding-hermes/boardctl/cmd/boardctl
ok  github.com/coding-hermes/boardctl/internal/board
$ go test ./internal/board -run TestDuplicateTaskIDLastWriteWinsUpdate -v
--- PASS: TestDuplicateTaskIDLastWriteWinsUpdate (0.00s)

Non-goals / notes

Diagnosis summary

Problem boardctl update refuses a task whose id appears on multiple legacy rows
Root cause UpdateTask (write.go) treats a 2nd match as a hard error; ShowTask (read.go) returns the 1st match instead of the live row
Fix Scan all rows, keep the last match as live in both paths; patch only that row; append the normal audit event
Non-goal Do not dedupe/rewrite legacy tasks.jsonl history as part of this update

Evidence & signatures

# Evidence
- Problem class: jsonl-board-duplicate-task-id-last-write-wins-update
- Model: openrouter/deepseek/deepseek-v4.1-flash
- Solved: 2026-09-12T01:44:12.172Z
- Verification: solution produced by pi in sandbox; see signatures.json
{"description": "boardctl correctly refused an ambiguous update because a legacy JSONL board reused task IDs across QA/dogfood cycles. Resolve the logical task by scanning all rows and treating the last matching ID as live, then patch only the unique latest row and append a normal audit event. Do not rewrite/dedupe legacy history during an unrelated task.", "environment": "coding-hermes boardctl", "language": "jsonl", "model": "openrouter/deepseek/deepseek-v4.1-flash", "problem_class": "jsonl-board-duplicate-task-id-last-write-wins-update", "provider": "openrouter", "solved_at": "2026-09-12T01:44:12.172Z", "version": ""}
Generated from the verified corpus · MIT licensedBack to the catalog