Skip to content

fix(tasks): never answer a board read with an empty result you could not obtain - #27

Merged
bkearns merged 1 commit into
mainfrom
fix/tasks-fail-loud-unreachable-board
Aug 24, 2026
Merged

fix(tasks): never answer a board read with an empty result you could not obtain#27
bkearns merged 1 commit into
mainfrom
fix/tasks-fail-loud-unreachable-board

Conversation

@bkearns

@bkearns bkearns commented Aug 21, 2026

Copy link
Copy Markdown
Contributor

Fixes the fail-loud violation reported in t_a9753724: a board read that could not
reach the database answered [] and exited 0.

The rule this restores

Three states must be distinguishable. Before this change the first and third were the
same two characters.

state before after
cannot reach the contact point [], exit 0 error naming the host:port actually tried, exit 1
reached it, keyspace unusable cryptic ensure_schema: CREATE TABLE IF NOT ... error naming the keyspace and the host
reached it, board is empty [] []

Writes still self-provision the tables with IF NOT EXISTS; that is unchanged.

What was swallowing what

Connection. TaskStore::connect reported connect to CQL: Connection refused with
no indication of which address it used -- --cql-host, FORGE_CQL_HOST, a project
.forge/config.toml, and ~/.config/forge.toml can each supply it. It now names every
contact point tried, and separates "cannot reach" from "reached, but the board keyspace
is not usable there".

Row decoding. list_tasks and board ran if let Ok(task) = parse_task_row(row),
so a row that would not decode simply did not appear. The caller got a shorter list and
no error -- the same silent emptiness, one row at a time. Rows now fail the read,
naming the row index and the task.

parse_task_row is strict about schema drift and lenient about data: a CQL NULL in a
nullable column keeps its documented default, while a missing column, a wrong CQL type,
an unknown status, or an absent created_at is an error. An unknown status used to be
filed under triage, which put a complete task back into the outstanding pile; a
missing created_at became epoch 0, which sorts to the bottom of every newest-first
read and drops the row out of any limited window. Comment rows no longer blank their
author and body when they fail to decode.

Config. A .forge/config.toml or ~/.config/forge.toml that exists but cannot be
read or parsed was discarded by toml::from_str(..).ok() / read_to_string(..).ok(),
so forge queried 127.0.0.1:9042 while the user was looking at a file naming a
different port. That is the config half of the same symptom, and it reproduces the
reported behaviour exactly. Malformed or unreadable config is now an error naming the
file; a file that is simply absent still falls through, which is what "not configured
at this layer" means. FORGE_DEBUG_STOP with an unrecognised value is an error rather
than a silent "off" -- the state the operator was trying to leave.

sheet-sync. BoardSink::existing_status returned self.store.get_task(id).ok(), so
an unreachable board looked like "no such task". That is precisely the answer that
disarms the never-move-backward rule and lets a pull reset a complete or archived
task to the sheet's status. It returns anyhow::Result<Option<_>> now, backed by a new
TaskStore::find_task that keeps absence (Ok(None)) and failure (Err) apart.

Tests

Written first; all four connect cases and all three CLI cases were red for the right
reason (the error did not name the contact point).

crates/tasks/tests/board_reads_fail_loud.rs -- no live cluster needed, every case is a
socket the test owns:

  • connecting_to_a_dead_port_is_an_error_that_names_the_contact_point -- binds an
    ephemeral port and drops the listener. The anchor case from the report.
  • connecting_to_a_live_port_that_is_not_cql_is_an_error_that_names_the_contact_point
    -- a port that accepts and hangs up, the shape of a stale podman/gvproxy forward.
    Also asserts it fails within a bounded time rather than hanging.
  • every_dead_contact_point_is_named_so_the_operator_knows_what_was_tried
  • an_empty_contact_point_list_is_an_error_not_a_silent_default

crates/cli/tests/task_reads_fail_loud.rs -- the reported command shape end to end, for
task list, task board and task get: non-zero exit, nothing on stdout, host named
on stderr.

crates/tasks/src/store.rs -- row decoding, including
one_unreadable_row_fails_the_whole_read_instead_of_vanishing_from_it,
an_unknown_status_is_an_error_not_a_task_filed_under_triage,
a_missing_timestamp_is_an_error_because_it_decides_the_read_order, and
a_null_in_a_nullable_column_keeps_its_documented_default (the lenient half).

crates/tasks/src/config.rs -- malformed config is an error naming the file; an absent
file is not an error; a non-boolean FORGE_DEBUG_STOP is an error.

crates/sheet-sync --
a_status_read_that_fails_stops_the_plan_instead_of_overwriting_the_status (pure plan
level) and pull_fails_when_the_board_cannot_report_current_status (end to end, and
asserts nothing was applied).

Verification

  • Workspace suite: 1330 passed, 0 failed.
  • cargo clippy --workspace --all-targets -- -D warnings: clean.
  • cargo fmt --all -- --check: clean. cargo doc --workspace --no-deps: clean.
  • Read the live 3-node board (2556 tasks) with the new strict parser via task list,
    task board and task get: no row fails to decode, so the strictness does not
    reject real data.
  • Over MCP: tools/list still exposes all eight task tools, task_list against the
    live host returns rows, and against a dead host returns is_error: true with the
    host named.

The installed ~/.cargo/bin/frg was not touched.

Known gap

The "connected, but the keyspace is absent" message was verified by construction only --
every CQL endpoint on this machine already has agent_memory. Tracked separately, along
with the remaining lower-severity swallows (now_ms's unwrap_or_default, and the
debug_stop alert's to_value(..).unwrap_or(Value::Null)).

…not obtain

`forge task list` printed `[]` and exited 0 against a board it had not read.
A *query* answered "there is nothing" when the truth was "I could not look",
and every consumer -- `/whats-next`, `/roadmap`, the defer-capture hook --
was confidently wrong in the same direction. The deferred-work rule's whole
premise is that captured work is durable and queryable; this is the failure
mode it exists to prevent.

Three states are now distinguishable at every read path:

  cannot reach the contact point   -> error naming the host:port actually tried
  reached it, keyspace unusable    -> error saying which keyspace, on which host
  reached it, board is empty       -> []

Connection: `TaskStore::connect` names every contact point it tried, and
separates "cannot reach" from "reached, but the board keyspace is not usable
there". An unreachable board previously reported `connect to CQL: Connection
refused` with no indication which of --cql-host / FORGE_CQL_HOST / project
config / global config had supplied the address.

Row decoding: `list_tasks` and `board` dropped rows that would not parse
(`if let Ok(task) = parse_task_row(row)`), so a drifted schema simply reported
fewer tasks with no error -- the same silent emptiness, one row at a time. Rows
now fail the read. `parse_task_row` is strict about schema drift and lenient
about data: a NULL in a nullable column keeps its documented default, while a
missing column, a wrong CQL type, an unknown status, or an absent `created_at`
is an error. An unknown status used to be filed under `triage`, which put a
`complete` task back into the outstanding pile. Comment rows no longer blank
their author and body when they fail to decode.

Config: a `.forge/config.toml` or `~/.config/forge.toml` that exists but cannot
be read or parsed was discarded by `.ok()`, so forge queried `127.0.0.1:9042`
while the user was looking at a file naming a different port -- the config half
of the same symptom. Malformed or unreadable config is now an error naming the
file; a file that is simply absent still falls through. `FORGE_DEBUG_STOP` with
an unrecognised value is an error rather than a silent "off", which is the
state the operator was trying to leave.

sheet-sync: `BoardSink::existing_status` returned `get_task(..).ok()`, so an
unreadable board looked like "no such task" -- exactly the answer that disarms
the never-move-backward rule and lets a pull reset a `complete` or `archived`
task to the sheet's status. It returns `Result<Option<_>>` now, backed by a new
`TaskStore::find_task` that keeps absence and failure apart.

Verified against the live 3-node board (2556 tasks across all columns) so the
stricter parser reads real data, and over MCP for both a live and a dead host.
@bkearns
bkearns added this pull request to the merge queue Aug 24, 2026
Merged via the queue into main with commit 7ff4399 Aug 24, 2026
5 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant