Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
18 changes: 10 additions & 8 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -8,32 +8,34 @@ loosely while pre-1.0 (breaking changes can land on minor bumps).
## [Unreleased]

### Fixed
- **Session restore skips non-object message rows.** `decode_messages_json`
only skipped null `shared_ptr`s, so JSON `null` / strings / numbers /
arrays in a session history became empty-`role` `Message` rows on TUI
restore. Skip any element that is not a JSON object, matching
`tool_trace_from_json`.
- **Unreadable TUI sessions are not empty.** `session_json_is_empty` no
- **Advise-gate cancel is not a bad bearer.** `POST /v1/advise/gate`
- **Reopened todos clear `completed_at`.** `update_todo` (HTTP PATCH and
batch PATCH) now zeros `completed_at` when status moves back to
`pending` or `in_progress`, unless the caller passes an explicit
timestamp. Completing then reopening left the old stamp, so the
documented `completed_at = 0` until terminal contract was violated.
Subject/description edits on a terminal row still leave the stamp.
- **MCP string JSON-RPC ids.** `parse_response` now accepts a decimal-string
- **Reconcile rollback no longer wipes the workspace on a failed restore.**
- **LaTeX math recursion depth.** `latex_math_to_plain` now stops converting
- **Secret key/token writes do not follow a planted dest symlink.**
- **Remote `--connect` base URL query/userinfo.** `normalize_api_base_url`
- **MCP registry `env` overrides parent keys.** Subprocess spawn skipped
- **Intent reconcile Phase B (JIT ΔS waves).** `POST /v1/reconcile` `mode=ensure`
- **A2A unary HTTP errors stay bounded.** `Client::rpc` no longer concatenates
- **Remote `--connect` DELETE/PATCH body cap.** Conversation delete and
- **Intent LLM prompt text cap.** `build_llm_user_prompt` now truncates
- **Remote TUI: recoverable SSE `error` is not a failed turn.** `RemoteSseTurnConsumer::finish` copied accumulated `error` event text even when the terminal `done` event had `ok: true` (e.g. catalog skip of a stored agent whose JSON failed validation). `done` is authoritative: success clears the result error; failure still prefers `done.error` and falls back to prior `error` events when that field is empty.
- **Unreadable TUI sessions are not empty.** `session_json_is_empty` no
- **Advise-gate cancel is not a bad bearer.** `POST /v1/advise/gate`
- **MCP registry writes do not follow a planted `.tmp` symlink.**
- **`/schedule` calendar dates.** `on YYYY-MM-DD` now rejects impossible
- **MCP registry `env` overrides parent keys.** Subprocess spawn skipped
- **`/schedule` time math fail-closed.** `every hour` / `hourly` now use the
- **JSON numbers require a complete fraction and exponent.** `json_parse`
- **`/fetch` uses the same SSRF hostname preflight as `/browse`.**
- **Loop `/kill` wakes the inter-iteration pause.** After each turn,
- **Memory `tag=` LIKE wildcards are literals.** `/mem entries tag=` and
- **Dispatch of stored agents past newest-200.** `GET /v1/agents/:id` already
- **Session restore skips non-object message rows.** `decode_messages_json`
- **Lesson search is a literal substring.** `search_lessons` (`GET /v1/lessons?q=`

## [0.13.7] — 2026-09-21
Expand Down
2 changes: 1 addition & 1 deletion docs/api/todos/patch.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@

**Auth:** tenant — _Status:_ stable

Update one or more mutable fields. Any field omitted from the body is left untouched. Transitioning to a terminal `status` (`completed` or `canceled`) auto-stamps `completed_at = now()`.
Update one or more mutable fields. Any field omitted from the body is left untouched. Transitioning to a terminal `status` (`completed` or `canceled`) auto-stamps `completed_at = now()`. Transitioning back to `pending` or `in_progress` clears `completed_at` (back to `0`).

## Request

Expand Down
2 changes: 1 addition & 1 deletion docs/concepts/todos.md
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,7 @@ A conversation-scoped read sees both — pinned rows plus the unscoped pool —
| `completed` | Done. Stamps `completed_at` and disappears from the active list. |
| `canceled` | Won't be done. Same archive treatment as `completed`. |

Status is the only column that auto-stamps anything else: any transition into `completed` or `canceled` sets `completed_at = now()` unless the caller passes a value explicitly. Pending → in_progress → completed is the canonical flow; you can also skip straight from pending to canceled when scope changes mid-task.
Status is the only column that auto-stamps anything else: any transition into `completed` or `canceled` sets `completed_at = now()` unless the caller passes a value explicitly. A later PATCH back to `pending` or `in_progress` clears `completed_at` (back to `0`) so the column stays `0` until terminal. Pending → in_progress → completed is the canonical flow; you can also skip straight from pending to canceled when scope changes mid-task. HTTP PATCH is the reopen path — `/todo start` only resolves against open rows.

## The `/todo` writ

Expand Down
5 changes: 3 additions & 2 deletions include/tenant_store.h
Original file line number Diff line number Diff line change
Expand Up @@ -813,8 +813,9 @@ class TenantStore {

// PATCH-style: any std::nullopt argument leaves the field
// untouched. Setting status to a terminal value stamps
// completed_at automatically (caller can override by passing a
// value through completed_at).
// completed_at automatically; setting it to a non-terminal value
// clears completed_at (caller can override by passing a value
// through completed_at).
bool update_todo(int64_t tenant_id, int64_t id,
const std::optional<std::string>& subject,
const std::optional<std::string>& description,
Expand Down
9 changes: 6 additions & 3 deletions src/tenant_store.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -4494,10 +4494,13 @@ bool TenantStore::update_todo(int64_t tenant_id, int64_t id,
if (!db_) return false;
const int64_t ts = now_epoch();

// Auto-stamp completed_at when transitioning to a terminal status,
// unless the caller passed completed_at explicitly.
// Keep completed_at aligned with status unless the caller passed an
// explicit stamp. Terminal statuses get now(); a reopen to pending
// or in_progress zeros the column so "0 until terminal" stays true.
std::optional<int64_t> ca = completed_at;
if (status && is_terminal_todo_status(*status) && !ca) ca = ts;
if (status && !ca) {
ca = is_terminal_todo_status(*status) ? ts : int64_t{0};
}

std::string sql = "UPDATE todos SET updated_at = ?";
if (subject) sql += ", subject = ?";
Expand Down
70 changes: 70 additions & 0 deletions tests/test_todos.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -79,6 +79,76 @@ TEST_CASE("todo round-trip: create / get / update / delete") {
CHECK(v->completed_at > 0); // auto-stamped
}

SUBCASE("reopen from a terminal status clears completed_at") {
bool ok = s.update_todo(tid, t.id, std::nullopt, std::nullopt,
std::optional<std::string>("completed"),
std::nullopt, std::nullopt);
CHECK(ok);
auto done = s.get_todo(tid, t.id);
REQUIRE(done);
CHECK(done->status == "completed");
CHECK(done->completed_at > 0);

ok = s.update_todo(tid, t.id, std::nullopt, std::nullopt,
std::optional<std::string>("pending"),
std::nullopt, std::nullopt);
CHECK(ok);
auto reopened = s.get_todo(tid, t.id);
REQUIRE(reopened);
CHECK(reopened->status == "pending");
CHECK(reopened->completed_at == 0);

ok = s.update_todo(tid, t.id, std::nullopt, std::nullopt,
std::optional<std::string>("canceled"),
std::nullopt, std::nullopt);
CHECK(ok);
ok = s.update_todo(tid, t.id, std::nullopt, std::nullopt,
std::optional<std::string>("in_progress"),
std::nullopt, std::nullopt);
CHECK(ok);
auto restarted = s.get_todo(tid, t.id);
REQUIRE(restarted);
CHECK(restarted->status == "in_progress");
CHECK(restarted->completed_at == 0);
}

SUBCASE("explicit completed_at on reopen is honored") {
bool ok = s.update_todo(tid, t.id, std::nullopt, std::nullopt,
std::optional<std::string>("completed"),
std::nullopt, std::nullopt);
CHECK(ok);

ok = s.update_todo(tid, t.id, std::nullopt, std::nullopt,
std::optional<std::string>("pending"),
std::nullopt, std::optional<int64_t>(12345));
CHECK(ok);
auto u = s.get_todo(tid, t.id);
REQUIRE(u);
CHECK(u->status == "pending");
CHECK(u->completed_at == 12345);
}

SUBCASE("subject edit after completed does not clear completed_at") {
bool ok = s.update_todo(tid, t.id, std::nullopt, std::nullopt,
std::optional<std::string>("completed"),
std::nullopt, std::nullopt);
CHECK(ok);
auto done = s.get_todo(tid, t.id);
REQUIRE(done);
const int64_t stamped = done->completed_at;
CHECK(stamped > 0);

ok = s.update_todo(tid, t.id,
std::optional<std::string>("renamed"),
std::nullopt, std::nullopt, std::nullopt, std::nullopt);
CHECK(ok);
auto u = s.get_todo(tid, t.id);
REQUIRE(u);
CHECK(u->status == "completed");
CHECK(u->subject == "renamed");
CHECK(u->completed_at == stamped);
}

SUBCASE("update edits subject/description without touching status") {
bool ok = s.update_todo(tid, t.id,
std::optional<std::string>("new subject"),
Expand Down
Loading