fix(tasks): never answer a board read with an empty result you could not obtain - #27
Merged
Merged
Conversation
…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.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Fixes the fail-loud violation reported in
t_a9753724: a board read that could notreach 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.
[], exit 0host:portactually tried, exit 1ensure_schema: CREATE TABLE IF NOT ...[][]Writes still self-provision the tables with
IF NOT EXISTS; that is unchanged.What was swallowing what
Connection.
TaskStore::connectreportedconnect to CQL: Connection refusedwithno indication of which address it used --
--cql-host,FORGE_CQL_HOST, a project.forge/config.toml, and~/.config/forge.tomlcan each supply it. It now names everycontact point tried, and separates "cannot reach" from "reached, but the board keyspace
is not usable there".
Row decoding.
list_tasksandboardranif 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_rowis strict about schema drift and lenient about data: a CQL NULL in anullable column keeps its documented default, while a missing column, a wrong CQL type,
an unknown status, or an absent
created_atis an error. An unknown status used to befiled under
triage, which put acompletetask back into the outstanding pile; amissing
created_atbecame epoch 0, which sorts to the bottom of every newest-firstread 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.tomlor~/.config/forge.tomlthat exists but cannot beread or parsed was discarded by
toml::from_str(..).ok()/read_to_string(..).ok(),so forge queried
127.0.0.1:9042while the user was looking at a file naming adifferent 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_STOPwith an unrecognised value is an error ratherthan a silent "off" -- the state the operator was trying to leave.
sheet-sync.
BoardSink::existing_statusreturnedself.store.get_task(id).ok(), soan 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
completeorarchivedtask to the sheet's status. It returns
anyhow::Result<Option<_>>now, backed by a newTaskStore::find_taskthat 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 asocket the test owns:
connecting_to_a_dead_port_is_an_error_that_names_the_contact_point-- binds anephemeral 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_triedan_empty_contact_point_list_is_an_error_not_a_silent_defaultcrates/cli/tests/task_reads_fail_loud.rs-- the reported command shape end to end, fortask list,task boardandtask get: non-zero exit, nothing on stdout, host namedon stderr.
crates/tasks/src/store.rs-- row decoding, includingone_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, anda_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 absentfile is not an error; a non-boolean
FORGE_DEBUG_STOPis an error.crates/sheet-sync--a_status_read_that_fails_stops_the_plan_instead_of_overwriting_the_status(pure planlevel) and
pull_fails_when_the_board_cannot_report_current_status(end to end, andasserts nothing was applied).
Verification
cargo clippy --workspace --all-targets -- -D warnings: clean.cargo fmt --all -- --check: clean.cargo doc --workspace --no-deps: clean.task list,task boardandtask get: no row fails to decode, so the strictness does notreject real data.
tools/liststill exposes all eight task tools,task_listagainst thelive host returns rows, and against a dead host returns
is_error: truewith thehost named.
The installed
~/.cargo/bin/frgwas 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, alongwith the remaining lower-severity swallows (
now_ms'sunwrap_or_default, and thedebug_stopalert'sto_value(..).unwrap_or(Value::Null)).