On a legacy board where a task id was reused across QA/dogfood cycles,
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.
boardctl update on a legacy JSONL board with duplicate task IDs (last-write-wins)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".
Two independent spots hard-code "one id == one row", which is not guaranteed on legacy boards:
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", ...)
}
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).
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.
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
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"
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)
boardctl validate/doctor will still report duplicate task id for them
until a dedicated cleanup task normalizes the history. That is expected and
unrelated to this update.Create still rejects duplicate ids. Only the update/show resolver
changed.tasks.jsonl) is unaffected: the header has
no id, so the scan skips it, and the untouched-line byte-equality assertion
still guards every non-target line.| 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 - 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": ""}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.
boardctl update on a legacy JSONL board with duplicate task IDs (last-write-wins)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".
Two independent spots hard-code "one id == one row", which is not guaranteed on legacy boards:
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", ...)
}
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).
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.
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
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"
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)
boardctl validate/doctor will still report duplicate task id for them
until a dedicated cleanup task normalizes the history. That is expected and
unrelated to this update.Create still rejects duplicate ids. Only the update/show resolver
changed.tasks.jsonl) is unaffected: the header has
no id, so the scan skips it, and the untouched-line byte-equality assertion
still guards every non-target line.| 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 - 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": ""}