Uh oh!
There was an error while loading. Please reload this page.
feat(runtime): session task ledger — TaskCreate/TaskUpdate + turn-tail injection (#15 P0-task) - #537
Conversation
…+ turn-tail injection (apache#15 P0-task) Main-agent session task ledger, the task-tracking slice of apache#15: - core: Task contract (4-state status), defensive normalize/validate, TaskLedgerStore interface, shared list renderer, 200-task hard cap. - storage: FileTaskLedgerStore at sessions/<id>/tasks.json — atomic tmp+rename writes serialized per session via chainWrite. Render reads degrade to empty; mutate reads fail closed (corrupt ledger is never used as a write base, so a transient read error cannot wipe tasks). - runtime: TaskCreate (batch) + TaskUpdate tools, permissionRequired false (pure local session state, cf. agent_list). Schemas encode the subject cap so the model learns constraints at validation time. Tool results render the post-mutation ledger returned from inside the write-queue critical section (no second read, no race). - desktop: tools wired into builtinTools; turn tail injects the current ledger via the existing volatile-tail path (durable system prefix untouched — prefix-cache discipline). Ledger text passes redactSecrets on both surfaces (tail + tool result) and strips literal task-ledger tags so a subject cannot escape the data envelope. No TaskList tool: the per-turn tail injection makes it redundant. Child agents do not inherit the tools (definition allowlists). Headless wiring and the UI panel are follow-ups.
There was a problem hiding this comment.
看了下 tag 防逃逸和读路径两块,跑了个实测,有两个建议合之前补一下,外加一个 P3。
tag 防逃逸的 strip 太窄(P2)
renderTaskLedgerTailFragment 里的 /<\/?task-ledger>/gi,我跑了几个变体:
</task-ledger>、</TASK-LEDGER>、<task-ledger>:被 strip</task-ledger >、<task-ledger x="1">、</task-ledger\t>、<task-ledger/>:全漏
</task-ledger >(> 前带空格)是合法闭合标签写法,模型会当包裹层边界,subject 里这串后面的文本会被读成包裹层外的指令。这正好是注释里声明要堵的口子("cannot close the data envelope early and smuggle instruction-level text"),等于声明防了没防住。
P2 不给 P1:要触发得靠 prompt injection(诱导模型写出带变体的 subject),不是默认态就坏。但 PR 自己把它列进威胁模型,留洞不合适,建议补上再合。
修法建议把 regex 扩成 /<\/?task-ledger[^>]*>/gi,盖住带属性、空格、自闭合的变体,又不碰其它角括号。不建议对 </> 全量转义或删除,prompt 是纯文本,转义成 < 模型会原样看到 <,删掉会改坏 a < b 这种合法 subject。另外 tool result 面(renderTaskLedger)现在根本不 strip,只有 tail 面窄 strip,两个面建议共用同一个 safe renderer。
读路径不重新施加 subject 约束(P2)
写了个 10000 字符 subject 的 tasks.json,store.list 原样返回(写侧上限 200,读侧没重新卡)。tab/换行/多空格的 subject 也不归一,原样读出。手动编辑过、或将来 schema 变更写进去的超长 subject,每轮 turn-tail 都会注入一遍,上下文膨胀。
建议读侧 normalizePersistedTask 复用 normalizeTaskSubject,无效逐条丢弃,跟现有"单条畸形丢弃"对齐。
源码 regex 契约测试(P3)
task-ledger-contract.test.ts 用 readMainTsSource() 配 regex 锁 main.ts 写法,等价重构(换变量名)会让测试挂但行为没变,脆。不过这是仓里既有模式(session-environment-prompt.test.ts 也这么干),不算本 PR 新债,P3 知道就行。
tag 逃逸和读路径这两条建议合之前补掉,要我把改法写成补丁贴上也行。
… faces The turn-tail strip was the narrow /<\/?task-ledger>/, which missed </task-ledger > (space before >), <task-ledger x="1"> (attributes), <task-ledger/>, and </task-ledger\t>. A model-authored subject carrying any of these could open or close the <task-ledger> data envelope early and smuggle instruction-level text, which is the escape the PR's own comment claims to block. Add a shared renderSafeTaskLedgerText in core (redact + strip /<\/?task-ledger[^>]*>/) and route both the tool-result face (renderTaskLedger) and the turn-tail face (renderTaskLedgerTailFragment) through it, so the two faces can no longer drift. Legitimate angle brackets in subjects (a < b) are preserved.
normalizePersistedTask only checked typeof subject === 'string', so a valid-JSON tasks.json with an overlong, blank, or non-normalized subject (from manual editing, a future schema change, or a legacy write) was loaded as-is and re-injected into the turn tail every turn, bloating context with no bound. Re-use normalizeTaskSubject on read (NFC, whitespace collapse, trim, 200-char cap, non-empty); invalid subjects drop the whole record, matching the existing single-malformed-entry-discarded semantic. The write path already enforced these, so this aligns the read path and stops the per-turn inflation.
The contract locked main.ts's task-ledger wiring with source-text regex
(createTaskLedgerStore(workspaceRoot), ...buildTaskLedgerTools({ store }),
the turnTailPrompt callback literal). An equivalent refactor that renames a
variable or restructures the callback breaks the test even though behavior is
unchanged — confirmed when the earlier tag-strip commit rewrote renderTaskLedger
and these regexes stopped matching.
Extract createMainTaskLedgerWiring (store + tools + system-prompt deps slice,
all sharing one store instance) and route main.ts through it. Replace the
source-regex test with a behavior test that:
- asserts TaskCreate/TaskUpdate are wired in,
- asserts the store is real and empty for a fresh workspace,
- asserts the system-prompt deps share the SAME store as the tools,
- calls TaskCreate through the wiring and checks the task lands in the
store the turn tail reads (mutate and read faces share one ledger).
Equivalent rewrites of main.ts no longer trip the contract; dropping the
tools, the store, or the deps-to-store link does.…formed tasks.json The write path enforces a 200-task cap and generates randomUUID ids, but the read path (normalizePersistedTask / decodeTasks) only checked typeof id === 'string' and never bounded the count. A hand-edited, legacy, or externally- written tasks.json could therefore: - carry more than TASK_LEDGER_MAX_TASKS valid records, which list() would inject into the turn tail every turn (unbounded context bloat), and which a subsequent create would silently truncate-and-overwrite; or - carry an id with a newline (breaks the formatTaskLedgerList line structure and injects text into the prompt), whitespace, or thousands of chars (unbounded bloat). Enforce the same total-task cap on read: decodeTasks throws when the valid record count exceeds TASK_LEDGER_MAX_TASKS, so readForRender degrades to [] (its caller already try/catches) and readForMutate stays fail-closed instead of truncating-and-overwriting. Constrain ids to the shape the write path produces -- non-empty, single-line (no whitespace), length <= 64 -- dropping records with unsafe ids per-record, matching the existing single-malformed- entry-discarded semantic. Not UUID-coupled so a future id format change doesn't need a read-path update.
…t corrupt them
isSafeTaskId only blocked whitespace, length, and empty, so a tag-like id
such as `a<task-ledger/>b` passed validation and entered the store. The shared
renderer strips `</?task-ledger[^>]*>` from the whole formatted output --
including the id -- so that id rendered as `(id: ab)` while the store still
held `a<task-ledger/>b`. A later TaskUpdate on the rendered id would miss the
recovered task (id mismatch), and any id carrying angle brackets, quotes,
parens, or equals would silently change shape between store and prompt.
Constrain ids to a stable-token whitelist /^[A-Za-z0-9][A-Za-z0-9._:-]{0,63}$/
(alphanumeric plus . _ : -, 1-64 chars). This excludes every character that
could break list-line structure, copy escaping, or the renderer's tag strip,
while still accepting randomUUID and simple ids like good-id_1:2. Records
with non-whitelisted ids are dropped per-record on read, matching the
existing single-malformed-entry-discarded semantic.…-to-end
createMainTaskLedgerWiring returned a systemPromptDeps slice that main.ts
never referenced -- it was dead production API. The contract's identity
assertion (systemPromptDeps.taskLedger === store) therefore proved nothing
about how main actually wires the store into the system prompt service.
Drop the field; the factory now returns only { store, tools }. Replace the
identity assertion with an end-to-end behavior test that builds the real
createSystemPromptMainService with wiring.store, writes a task via TaskCreate
through wiring.tools, and asserts the turn tail reads the same task. A
root-level split (tools bound to a store over a different workspace than the
tail reads) now fails the test; an instance-level split over the same root
correctly does not, since file-backed stores over one root are equivalent.… tool entry The runtime tool schema and the storage write path did not reuse the ledger's hard cap or the id stable-token contract at the front door: - TaskCreate.tasks was z.array(...).min(1) with no max, so a model could send TASK_LEDGER_MAX_TASKS + 1 drafts; storage.create() then generated a uuid for every draft inside drafts.map before the write-queue total-cap check rejected the batch -- wasteful processing, an error echo, and history bloat for a call that could never succeed. - TaskUpdate.id was z.string().min(1), so the tool entry accepted ids the read path now rejects (tag-like, newline, whitespace, overlong), letting an inconsistent id reach the store lookup. Lift the id stable-token rule to a core contract (TASK_ID_MAX_CHARS + isSafeTaskId) so storage and the runtime schema share one definition; storage drops its private copy. TaskCreate.tasks gets .max(TASK_LEDGER_MAX_TASKS) so an oversized batch is rejected at the schema. storage.create() rejects a batch larger than the per-batch cap before generating any id or normalizing drafts; the existing write-queue total-cap check stays for the existing + new total. TaskUpdate.id gets .max(TASK_ID_MAX_CHARS).refine(isSafeTaskId) so only ids the store would accept reach the lookup.
Two ways the rendered task ledger could diverge from what the store actually holds, both letting a later TaskUpdate miss: 1. Secret-shaped stable-token ids. isSafeTaskId accepted ghp_..., sk-..., a 40-char hex, AIza... (all match the charset/length rules), but the shared renderer runs redactSecrets over the whole formatted list including the id, so those ids rendered as (id: [redacted]) while the store kept the real id. Tighten isSafeTaskId to also require redactSecrets(id) === id, so only ids that survive redaction unchanged reach the store or the schema. The storage read path and the runtime TaskUpdate schema both use the shared rule. 2. Cross-line tag strip. The strip regex /<\/?task-ledger[^>]*>/ ran over the whole multi-line formatted list, and [^>]* crosses newlines, so an unclosed <task-ledger in one subject and a > in the next silently deleted the text between them -- collapsing two task lines into one and dropping the first id. Narrow [^>]* to [^\n>]* so a tag match cannot cross a line boundary; an unclosed <task-ledger stays as harmless literal text and same-line variants are still stripped.
TaskCreate and TaskUpdate returned renderTaskLedger(all) -- the whole ledger -- as their tool result. Tool results persist into session history and replay to the provider every turn, and the turn tail already re-injects the full ledger every turn, so each create/update wrote a second full copy of the ledger into history. Under a 200-task ledger, a few updates would duplicate the entire list several times, bloating context with no new information. Return only what the model needs to act next: TaskCreate returns the created tasks (with their ids, so the model can update them) and the new total; TaskUpdate returns the updated task and the new total. Both still go through the shared safe renderer (redact + strip), so secrets and tag variants are scrubbed from the echoed task. The full ledger stays the turn tail's job. The now-dead renderTaskLedger helper is removed.
decodeTasks pushed every record that passed normalizePersistedTask without checking for duplicate ids, so a hand-edited or legacy tasks.json with two records sharing an id would load both. The turn tail would then show two indistinguishable tasks, and TaskUpdate's first-match lookup would only ever touch the first -- the second is unreachable, and a mutate would silently keep both and rewrite a "half-correct" file (first updated, second stale). Track seen ids in decodeTasks and throw on a duplicate, so the file is treated as corrupt: the render path degrades to an empty list (its caller already try/catches) and the mutate path stays fail-closed instead of silently rewriting an ambiguous ledger. This mirrors the existing over-cap handling.
renderSafeTaskLedgerText used to redact + strip the whole joined formatTaskLedgerList(tasks) string in one pass. That made the rendered ledger diverge from the store in several ways, each patched separately over the last few rounds: a tag-like id was eaten by the strip, a secret-shaped id was redacted to [redacted], and an unclosed <task-ledger in one subject could eat text on the next subject's line (the strip regex crossed newlines). Every fix was another edge of the same root cause -- scrubbing the whole string instead of per-task fields, and treating the id (an internal handle, not model free text) as scrub-able. Render per-task: each subject is redacted + stripped independently (no cross- line interaction), and the id is rendered verbatim. The id is a redaction- stable stable token validated on both write and read, so redactSecrets(id) === id and the id contains no tag substring; running it through the scrub could only deform it and break TaskUpdate, so it must not be scrubbed. This makes "what the model sees == what the store holds" a single per-task invariant instead of a whole-string property that needs edge-by-edge patching. Drop the now-unused formatTaskLedgerList (only renderSafeTaskLedgerText called it) and its export.
…spoof the id The free-text bullet format `- [status] subject (id: real-id)` put the canonical id at the end of a line whose subject is unquoted model free text, so a subject like `做事 (id: fake-id)` produced a line with two id-like spans and the model could copy the wrong one, updating the wrong task or missing entirely. Render each task as a fielded line `id=<id> status=<status> subject=<JSON- stringified safe subject>`: the canonical id is a distinct leading field, and any id-like text in the subject stays inside the quoted JSON payload. The id is still emitted verbatim (a redaction-stable stable token validated on write and read, so scrubbing it could only deform it and break TaskUpdate). Each subject is still redacted and tag-stripped independently. Existing tests match on the subject/total/envelope, not the line format, so they pass unchanged; the cross-line test's id assertions move to the fielded shape, and a new test checks a subject cannot smuggle a fake id. Also sync isSafeTaskId's comment, which still described the old whole-string renderer that scrubbed the id -- the per-task renderer emits the id verbatim, so the comment now says the id must be redaction-stable because a renderer must never deform it, not because the current renderer scrubs it.
…rip to null and vanish normalizePersistedTask only checked typeof timestamp === 'number', so a hand-edited or legacy tasks.json with `createdAt: 1e999` (which JSON.parse reads as Infinity) loaded into the store. The next mutate would JSON.stringify the ledger with Infinity, which serializes to null, so the record silently turned into a null-timestamp entry and was dropped on the read after that -- data loss with no signal. Require Number.isFinite for both createdAt and updatedAt, dropping the record per-record on read (matching the existing single-malformed-entry semantic). The write path only ever produces Date.now(), so this only rejects externally authored bad data.
…al } / { updated, total }
TaskLedgerStore.create/update returned { created, all } / { updated, all },
handing the whole ledger to the caller. The runtime only needs the new total
(and the created/updated task), so returning the full array was an over-wide
API -- and it was exactly the footgun that let the old tool result replay the
entire ledger into history (fixed earlier). A caller that later reaches for
all again would reintroduce that bloat.
Narrow the contract to { created: Task[]; total: number } and
{ updated: Task; total: number }. Storage still computes the next array
internally to write the file, but no longer exposes it. The runtime tool result
already shows only the created/updated task + total, so behavior is unchanged;
the type system now prevents a future caller from grabbing the full ledger
through the mutation result.… created, total } return The convergence commits left three stale comments that described the old contracts, which could mislead a future maintainer into bringing back the full-ledger return or the old (id: ...) bullet format: - TaskLedgerStore's contract comment still said mutations return the full post-mutation ledger (all); they now return the changed task(s) + total, and the full ledger never leaves the store through the mutation result. - TASK_ID_MAX_CHARS's comment still referenced the turn-tail (id: ...) render; the renderer is now fielded as id=. - renderSafeTaskLedgerText's invariant said the model sees byte-identical to the store, but the subject is redacted and tag-stripped before output. Reword to: the canonical id is rendered verbatim, the subject is a safe rendered payload. Also drop a stray (id: ...) mention in the smuggling note so a grep for the old format stays clean. Comments only; no behavior change.
UncertaintyDeterminesYou4ndMe
commented
Jul 5, 2026
UI 切片已开 PR:#553(任务面板 + 取消 outcome 契约 + 竞态加固)。@Astro-Han 你评审的两条 P2(tag-strip 变体、读侧重规范化)确认已在合并版(renderSafeTaskLedgerText / decodeTasks 读侧 normalizeTaskSubject)覆盖,#553 在其上未再触碰这两处。 |
#15 的 task-tracking 切片(P0,model-facing)。issue 原文:「The model needs a structured way to create, update, list, and recover tasks during long work. This should be a typed, recoverable execution ledger for the main agent, not a UI-only checklist.」
设计
会话级任务台账:模型用
TaskCreate(批量建)/TaskUpdate(改状态/标题)维护一个扁平任务列表;每轮 turn tail 自动注入当前清单,所以不需要 TaskList 工具(list 由注入满足,recover 由持久化文件满足);重启后 tasks.json 还在,下一轮自然恢复。各层全部锚在现有先例上:
Task契约(pending/in_progress/completed/cancelled)+ 防御式 normalize +TaskLedgerStore接口 + 200 条硬上限sessions/<id>/tasks.json,原子 tmp+rename,per-sessionchainWrite串行化permissionRequired: false(纯本地状态,无外部副作用)子 agent 不会继承这两个工具(agent definition 的显式 allowlist 过滤)。headless 接线和 UI 面板是后续(UI 面板 PR 已在开发)。
关键语义(对抗式审查驱动)
本 PR 经两轮对抗审查(人工 + 16-agent 高强度独立审查,9 个 CONFIRMED 发现全部修复),以下语义是审查后收敛的:
redactSecrets。</task-ledger>标签(strip 在 redact 之后,保证无后续变换能复活标签)。验证
@maka/core677 pass、@maka/storage157 pass(含 corrupt fail-closed、未命中不写、上限四态)、@maka/runtime818 pass(含脱敏、schema 边界)、@maka/desktoptask-ledger 契约 7/7(锁定接线、空清单零注入、无 sessionId 零注入、权限豁免、脱敏、防逃逸)npm run typecheck/git diff --check干净