fix(tasks): add_task_log wrote to a surface no agent could read back (#4241)
CI & Build / Python lint (push) Successful in 2s
CI & Build / Plugin hooks (push) Successful in 13s
CI & Build / TypeScript typecheck (push) Successful in 55s
CI & Build / integration (push) Successful in 1m4s
CI & Build / Python tests (push) Failing after 1m14s
CI & Build / Build & push image (push) Skipped

The work log reached the web UI through routes/task_logs.py and nothing
else. get_task returned only the body — a claim written once, before the
work — with the record written during it invisible beside it. So a stale
body arrived with nothing to contradict it, and this session rebuilt work
that had already shipped, with the evidence sitting in the task's own logs.

Read side, scoped through the access layer (rule 78):
  - logs_for_task / count_logs_for_task / log_counts_for_tasks in
    services/task_logs.py. Scoped by who may read the TASK rather than by
    who wrote the entry: list_logs filters TaskLog.user_id == user_id,
    which hands a shared collaborator an empty list reading as "no work
    has been done". The page query folds readable_notes_clause into the
    same statement so the permission does not become an N+1.
  - get_task returns work_log; list_tasks and get_milestone steps carry
    log_count, zero-filled so "none" is a count and not a missing key.

Elision keeps both ends. The newest entry arrives whole to 4000 chars
because it answers "where does this stand"; older ones are shortened from
the MIDDLE, never the head. A head cut selects what a reader sees by
character position, which is uncorrelated with what matters — an entry
closing with "so this shipped in 04775c3" loses the one sentence that
answers the question, and a truncated flag says something went, never
whether it mattered. The gap states how many characters it covers.

conftest gains an autouse stub for the new read arm, same reasoning as
_no_rule_arm: three widely-called tools grew a database read, and the
existing call sites should not each have to learn about it.

Raised while reviewing this: search() has the same shape and worse —
body[:240] with no marker at all, while the chunk that actually matched
sits unused in the row that won. Filed as #4243, not fixed here.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01821k5B3Ysecp9fNYs92Kuy
This commit is contained in:
2026-09-21 08:45:43 -04:00
co-authored by Claude Opus 5
parent f8e53c1c35
commit 4f2977b848
6 changed files with 660 additions and 6 deletions
+151 -3
View File
@@ -3,7 +3,13 @@
Tasks are notes with a non-null `status` — same model, different filter.
Wrappers call services/notes.py for CRUD with is_task=True and add the
task-specific fields (status, priority, due_date, parent_id), plus
services/task_logs.py for add_task_log.
services/task_logs.py for add_task_log AND for reading those logs back —
`get_task` returns them and `list_tasks` counts them. For most of this
module's life `add_task_log` wrote to a surface no agent could read: the
entries reached the web UI and nothing else, so a session opening a task
saw only the body — a claim written before the work — with the record
written during it invisible beside it. A stale body then had nothing to
contradict it, and shipped work got rebuilt (#4241).
There is no delete_task — matches the existing fable-mcp surface.
Cancel by updating status to "cancelled".
@@ -41,6 +47,92 @@ from scribe.services.note_usage import record_pulled
from scribe.services.record_refs import refuse_guessed_ids
# A work log entry is prose, often long — the discipline asks for what was
# decided and why, not a line of status. Two caps, not one, because the entries
# are not equally useful: the NEWEST answers "where does this actually stand",
# which is the question the block exists for, so it arrives whole up to a
# generous ceiling. Older entries are there to say what happened and when, and
# a headline does that.
_WORK_LOG_ENTRIES = 3
_WORK_LOG_CHARS = 800
_WORK_LOG_LATEST_CHARS = 4000
_WORK_LOG_ADVICE = (
"The body is a CLAIM, written once before the work. These entries are the "
"RECORD, written during and after it. Where the two disagree the log is "
"later — read it before acting on what the body says the status is."
)
def elide(text: str, budget: int) -> tuple[str, bool]:
"""Cut to `budget` characters from the MIDDLE, keeping both ends.
A head-only cut — `text[:800]` — decides what a reader sees by character
position, which is uncorrelated with what matters. Prose does not put its
conclusion first: a log entry that opens with what was tried and closes
with "so this shipped in 04775c3" loses exactly the sentence that answers
the question, and the reader cannot tell, because a truncation marker says
that something was removed and never whether it mattered.
So keep the opening (what this entry is about) AND the closing (where it
landed), and say in between how much went. Two thirds to the head because
that is where the subject is established; the tail needs less to carry a
conclusion. `budget` of 0 means no cut.
"""
if budget <= 0 or len(text) <= budget:
return text, False
head_len = max(1, budget * 2 // 3)
tail_len = max(1, budget - head_len)
omitted = len(text) - head_len - tail_len
head = text[:head_len].rstrip()
tail = text[-tail_len:].lstrip()
return f"{head}\n\n[… {omitted} characters omitted …]\n\n{tail}", True
def work_log_payload(
logs: list, total: int, chars: int, latest_chars: int = _WORK_LOG_LATEST_CHARS
) -> dict:
"""The `work_log` block: recent entries newest-first, plus what was elided.
`total` is the count of ALL entries, not of `logs` — a reader has to be
able to tell "this task has no record" from "you were shown the last three
of nine", and those are the same response if the count comes from the
entries handed over.
"""
entries = []
for i, log in enumerate(logs):
row = log.to_dict() if hasattr(log, "to_dict") else dict(log)
row.pop("updated_at", None)
content = row.get("content") or ""
# The first row IS the newest — logs_for_task orders descending.
budget = latest_chars if i == 0 else chars
if chars <= 0:
budget = 0
text, cut = elide(content, budget)
row["content"] = text
if cut:
row["full_length"] = len(content)
row["truncated"] = True
entries.append(row)
out: dict = {"total": total, "entries": entries}
if total == 0:
return out
out["advice"] = _WORK_LOG_ADVICE
not_shown = total - len(entries)
if not_shown > 0:
out["not_shown"] = not_shown
if not_shown > 0 or any(e.get("truncated") for e in entries):
out["read_all"] = (
"Entries were shortened or omitted. "
"get_task(task_id, log_limit=0, log_chars=0) returns every entry "
"in full — reach for it rather than judging from what is here, "
"which was selected by recency and length, not by relevance."
)
return out
async def list_tasks(
limit: int = 20,
offset: int = 0,
@@ -75,10 +167,27 @@ async def list_tasks(
offset=max(0, offset),
)
titles = await milestones_svc.titles_for({n.milestone_id for n in rows})
return {"tasks": [notes_svc.brief_row(n, titles) for n in rows], "total": total}
# One aggregate for the page, zero-filled: a reader scanning a list needs
# to know WHICH rows carry a record before choosing what to open, and a
# missing key would read as "no logs" on every row rather than on the
# rows that have none.
log_counts = await task_logs_svc.log_counts_for_tasks(
uid, [int(n.id) for n in rows]
)
return {
"tasks": [
notes_svc.brief_row(n, titles, log_counts=log_counts) for n in rows
],
"total": total,
}
async def get_task(task_id: int, project_id: int = 0) -> dict:
async def get_task(
task_id: int,
project_id: int = 0,
log_limit: int = _WORK_LOG_ENTRIES,
log_chars: int = _WORK_LOG_CHARS,
) -> dict:
"""Fetch a single Scribe task by ID.
Returns id, title, body, status, priority, tags, project_id, milestone_id,
@@ -89,6 +198,27 @@ async def get_task(task_id: int, project_id: int = 0) -> dict:
kind=plan tasks, the response also includes the project's applicable_rules
and project_rules (new plans are milestones — use get_milestone for those).
AND `work_log` — the entries add_task_log wrote, newest first, with
`total` for how many exist. READ IT BEFORE TRUSTING THE BODY. A body is
written once, at the start, when the least is known; the log is written
during the work and after it. A task whose body says "not started" and
whose log records a partial ship is not a contradiction to resolve — the
log is simply later. This block exists because its absence cost a session
a day of rebuilding work that had already shipped (#4241).
The NEWEST entry arrives whole (to 4000 characters); older ones are
shortened to a headline. Anything shortened is cut from the MIDDLE, so
the opening and the closing both survive — a log entry's conclusion is at
its end — and the gap states how many characters went. An entry that was
cut says so and carries its `full_length`, and the block as a whole says
when it is showing you less than the record holds.
Args:
log_limit: How many of the most recent entries to include (default 3).
0 returns every entry.
log_chars: Budget for the OLDER entries (default 800). 0 returns
every entry in full, the newest included.
A task another user shared with you also carries `shared`, `owner` and
`permission` — it's their work item, not one you took on.
@@ -127,6 +257,17 @@ async def get_task(task_id: int, project_id: int = 0) -> dict:
await systems_tools.attach_systems(
uid, getattr(note, "user_id", uid) or uid, data, note.id, note.project_id
)
# Counted separately rather than inferred from the rows handed back: with a
# limit applied, len(entries) is the size of the window, not of the record,
# and "3 entries" and "the last 3 of 9" have to read differently.
log_rows = await task_logs_svc.logs_for_task(
uid, int(note.id), limit=max(0, log_limit)
)
log_total = (
len(log_rows) if log_limit <= 0
else await task_logs_svc.count_logs_for_task(uid, int(note.id))
)
data["work_log"] = work_log_payload(log_rows, log_total, max(0, log_chars))
record_pulled(
user_id=uid, note_id=int(note.id),
source="mcp_get_task", project_id=project_id,
@@ -353,6 +494,13 @@ async def add_task_log(task_id: int, content: str) -> dict:
without overwriting the task's main body. Each entry is stored separately
and shown chronologically in the task view.
What you write here comes back from `get_task` as `work_log`, newest
first, and is counted on every row of `list_tasks` and every step of
`get_milestone` — so write for the session that opens this task next, not
for a reader who already knows what you were doing. What that reader
cannot get from the body or the diff is what you tried, what you ruled
out, and where it actually stands.
The response shows the task's `systems` — or, if the task is an untagged
project record, the `systems_hint` question: logging work IS working in
some area, so answer it (update_task with system_ids, or create_system