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
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:
@@ -15,6 +15,7 @@ from scribe.mcp._context import current_user_id
|
||||
from scribe.services import dedup as dedup_svc
|
||||
from scribe.services import milestones as milestones_svc
|
||||
from scribe.services import notes as notes_svc
|
||||
from scribe.services import task_logs as task_logs_svc
|
||||
from scribe.services import rulebooks as rulebooks_svc
|
||||
from scribe.services import trash as trash_svc
|
||||
from scribe.services.record_refs import refuse_guessed_ids
|
||||
@@ -53,6 +54,11 @@ async def get_milestone(milestone_id: int) -> dict:
|
||||
what it is and where it stands, not its whole body — read a step in full
|
||||
with get_task(id). A plan with forty long steps is otherwise too big to
|
||||
arrive inline, and the design is in the milestone body.
|
||||
|
||||
Each step also carries `log_count`. A step's STATUS is set by hand and a
|
||||
plan is exactly where that goes stale; the count says which steps have a
|
||||
work log that would say otherwise, so `get_task(id)` goes to the step
|
||||
with a record rather than to each one in turn (#4241).
|
||||
"""
|
||||
uid = current_user_id()
|
||||
milestone = await milestones_svc.get_milestone(uid, milestone_id)
|
||||
@@ -65,11 +71,19 @@ async def get_milestone(milestone_id: int) -> dict:
|
||||
applicable = await rulebooks_svc.get_applicable_rules(
|
||||
project_id=milestone.project_id, user_id=uid,
|
||||
)
|
||||
log_counts = await task_logs_svc.log_counts_for_tasks(
|
||||
uid, [int(t.id) for t in steps]
|
||||
)
|
||||
out = milestone.to_dict()
|
||||
out.update(progress)
|
||||
return {
|
||||
"milestone": out,
|
||||
"steps": [notes_svc.brief_row(t, {milestone.id: milestone.title}) for t in steps],
|
||||
"steps": [
|
||||
notes_svc.brief_row(
|
||||
t, {milestone.id: milestone.title}, log_counts=log_counts
|
||||
)
|
||||
for t in steps
|
||||
],
|
||||
**rulebooks_svc.rules_payload(applicable, user_id=uid, source="get_milestone"),
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -1096,7 +1096,20 @@ async def get_note_for_user(
|
||||
# A field most rows leave empty (a one-line description, a parent, a due date)
|
||||
# is attached only when set: a hundred rows of `null` are a hundred chances to
|
||||
# learn to skip the key (#2483), and the bytes are the thing being cut.
|
||||
def brief_row(note: Note, milestone_titles: dict[int, str] | None = None) -> dict:
|
||||
def brief_row(
|
||||
note: Note,
|
||||
milestone_titles: dict[int, str] | None = None,
|
||||
log_counts: dict[int, int] | None = None,
|
||||
) -> dict:
|
||||
"""One task as a list row — what it is and where it stands, not its body.
|
||||
|
||||
`log_counts` is a whole page's work-log counts fetched in one aggregate
|
||||
(task_logs.log_counts_for_tasks). Passed in rather than looked up here so
|
||||
the N+1 stays impossible, and applied to tasks only: a note has no work
|
||||
log. Zero-filled when the mapping is given, because a row that omits the
|
||||
key says "no record" on every row rather than on the ones with none, and
|
||||
knowing WHICH rows carry a record is how a reader decides what to open.
|
||||
"""
|
||||
row = {
|
||||
"id": note.id,
|
||||
"title": note.title,
|
||||
@@ -1120,4 +1133,6 @@ def brief_row(note: Note, milestone_titles: dict[int, str] | None = None) -> dic
|
||||
row["parent_id"] = note.parent_id
|
||||
if note.due_date:
|
||||
row["due_date"] = iso(note.due_date)
|
||||
if log_counts is not None:
|
||||
row["log_count"] = log_counts.get(int(note.id), 0)
|
||||
return row
|
||||
|
||||
@@ -2,11 +2,12 @@
|
||||
import logging
|
||||
from datetime import datetime, timezone
|
||||
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy import func, select
|
||||
|
||||
from scribe.models import async_session
|
||||
from scribe.models.task_log import TaskLog
|
||||
from scribe.models.note import Note
|
||||
from scribe.services.access import can_read_note, readable_notes_clause
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
@@ -48,6 +49,81 @@ async def list_logs(user_id: int, task_id: int) -> list[TaskLog]:
|
||||
return list(result.scalars().all())
|
||||
|
||||
|
||||
async def logs_for_task(
|
||||
user_id: int, task_id: int, limit: int = 0
|
||||
) -> list[TaskLog]:
|
||||
"""Every work log on a task, NEWEST FIRST, whoever wrote it.
|
||||
|
||||
Two differences from `list_logs`, both deliberate:
|
||||
|
||||
1. Scoped by who may read the TASK, not by who wrote each entry. A work
|
||||
log belongs to the task, and on a shared task the owner's record is
|
||||
exactly what a collaborator needs — `list_logs`' `TaskLog.user_id ==
|
||||
user_id` returns them an empty list, which reads as "no work has been
|
||||
done" rather than "not yours". The permission question is asked of the
|
||||
note through `can_read_note` (rule #78), so this is not an unscoped
|
||||
read with the check left to the caller.
|
||||
2. Newest first, because the question a reader asks of a work log is
|
||||
"where does this actually stand", and the answer is the last entry.
|
||||
`list_logs` stays ascending — the web UI renders a narrative.
|
||||
|
||||
`limit` of 0 means all of them. An unreadable or missing task returns []
|
||||
the same way an empty one does: a work log is not a channel for proving a
|
||||
record exists.
|
||||
"""
|
||||
if not await can_read_note(user_id, task_id):
|
||||
return []
|
||||
async with async_session() as session:
|
||||
stmt = (
|
||||
select(TaskLog)
|
||||
.where(TaskLog.task_id == task_id)
|
||||
.order_by(TaskLog.created_at.desc(), TaskLog.id.desc())
|
||||
)
|
||||
if limit > 0:
|
||||
stmt = stmt.limit(limit)
|
||||
result = await session.execute(stmt)
|
||||
return list(result.scalars().all())
|
||||
|
||||
|
||||
async def count_logs_for_task(user_id: int, task_id: int) -> int:
|
||||
"""How many work logs a task carries. Same scoping as logs_for_task."""
|
||||
if not await can_read_note(user_id, task_id):
|
||||
return 0
|
||||
async with async_session() as session:
|
||||
result = await session.execute(
|
||||
select(func.count(TaskLog.id)).where(TaskLog.task_id == task_id)
|
||||
)
|
||||
return int(result.scalar() or 0)
|
||||
|
||||
|
||||
async def log_counts_for_tasks(
|
||||
user_id: int, task_ids: list[int]
|
||||
) -> dict[int, int]:
|
||||
"""Log counts for a whole page of tasks in ONE query.
|
||||
|
||||
A per-row lookup would be N+1 by construction — and so would a per-row
|
||||
`can_read_note` — which is the reason a list surface would otherwise keep
|
||||
omitting this and leave a reader to guess which rows carry a record. So
|
||||
the permission is expressed as set membership with
|
||||
`readable_notes_clause` and folded into the same statement, the pattern
|
||||
that function exists for.
|
||||
|
||||
Tasks with no logs are absent from the mapping; callers zero-fill, so
|
||||
"none" reads as a count rather than a missing key.
|
||||
"""
|
||||
ids = [int(t) for t in task_ids if t]
|
||||
if not ids:
|
||||
return {}
|
||||
async with async_session() as session:
|
||||
result = await session.execute(
|
||||
select(TaskLog.task_id, func.count(TaskLog.id))
|
||||
.join(Note, Note.id == TaskLog.task_id)
|
||||
.where(TaskLog.task_id.in_(ids), readable_notes_clause(user_id))
|
||||
.group_by(TaskLog.task_id)
|
||||
)
|
||||
return {int(tid): int(n) for tid, n in result.all()}
|
||||
|
||||
|
||||
async def update_log(
|
||||
user_id: int,
|
||||
log_id: int,
|
||||
|
||||
Reference in New Issue
Block a user