Skip to content

feat(runtime): session task ledger — TaskCreate/TaskUpdate + turn-tail injection (#15 P0-task) - #537

Merged
Astro-Han merged 16 commits into
apache:mainfrom
UncertaintyDeterminesYou4ndMe:feat/task-ledger
Jul 5, 2026
Merged

feat(runtime): session task ledger — TaskCreate/TaskUpdate + turn-tail injection (#15 P0-task)#537
Astro-Han merged 16 commits into
apache:mainfrom
UncertaintyDeterminesYou4ndMe:feat/task-ledger

Conversation

@UncertaintyDeterminesYou4ndMe

Copy link
Copy Markdown
Contributor

#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 还在,下一轮自然恢复。

各层全部锚在现有先例上:

实现先例
coreTask 契约(pending/in_progress/completed/cancelled)+ 防御式 normalize + TaskLedgerStore 接口 + 200 条硬上限plan-reminders
storagesessions/<id>/tasks.json,原子 tmp+rename,per-session chainWrite 串行化plan-reminder-store + session-store 写队列
runtime双工具,permissionRequired: false(纯本地状态,无外部副作用)agent_list
desktopturn tail 注入(不碰 durable system prefix,prefix-cache 纪律不变)「本轮记忆变更」tail 片段

子 agent 不会继承这两个工具(agent definition 的显式 allowlist 过滤)。headless 接线和 UI 面板是后续(UI 面板 PR 已在开发)。

关键语义(对抗式审查驱动)

本 PR 经两轮对抗审查(人工 + 16-agent 高强度独立审查,9 个 CONFIRMED 发现全部修复),以下语义是审查后收敛的:

  • 读路径分裂:渲染读(list/turn-tail)遇损坏降级为空——台账坏了不能卡住会话;写路径 fail-closed——mutate 读到损坏文件或 I/O 错误直接抛错、拒绝覆盖,瞬时读错误不可能静默清空台账。
  • 双面脱敏:任务标题是模型自由文本且会被每轮重放(turn tail)+ 持久化重放(工具结果进会话历史),两个面都过 redactSecrets
  • 数据包裹层防逃逸:tail 渲染 strip 掉标题里的字面 </task-ledger> 标签(strip 在 redact 之后,保证无后续变换能复活标签)。
  • 约束前置:zod schema 编码 200 字符上限和 trim,模型在校验层就学到约束,不靠运行时试错。
  • 无二次读:工具结果渲染写队列临界区内算出的新列表,消除队列外重读的竞态。
  • 单条畸形记录沿用 plan-reminder-store 的逐条丢弃先例(整体损坏才 fail-closed)。

验证

…+ 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.

@Astro-HanAstro-Han left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

看了下 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 是纯文本,转义成 &lt; 模型会原样看到 &lt;,删掉会改坏 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.tsreadMainTsSource() 配 regex 锁 main.ts 写法,等价重构(换变量名)会让测试挂但行为没变,脆。不过这是仓里既有模式(session-environment-prompt.test.ts 也这么干),不算本 PR 新债,P3 知道就行。

tag 逃逸和读路径这两条建议合之前补掉,要我把改法写成补丁贴上也行。

Astro-Han added 15 commits July 5, 2026 12:02
… 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

Copy link
Copy Markdown
ContributorAuthor

UI 切片已开 PR:#553(任务面板 + 取消 outcome 契约 + 竞态加固)。@Astro-Han 你评审的两条 P2(tag-strip 变体、读侧重规范化)确认已在合并版(renderSafeTaskLedgerText / decodeTasks 读侧 normalizeTaskSubject)覆盖,#553 在其上未再触碰这两处。

Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants

@UncertaintyDeterminesYou4ndMe@likun666661@Astro-Han
, 'i'); if (__m === '*' || __re.test(location.href)) { // Add copy buttons to all
 blocks
(function() {
function addCopyButtons() {
document.querySelectorAll('pre code').forEach(function(codeBlock) {
if (codeBlock.parentElement.hasAttribute('data-copy-added')) return;
codeBlock.parentElement.setAttribute('data-copy-added', 'true');
var btn = document.createElement('button');
btn.textContent = 'Copy';
btn.style.cssText = 'position:absolute;top:4px;right:4px;padding:2px 8px;font-size:11px;background:#4ecdc4;border:none;border-radius:4px;color:#1a1a2e;cursor:pointer;opacity:0.7;transition:opacity 0.2s;';
btn.onmouseover = function() { this.style.opacity = '1'; };
btn.onmouseout = function() { this.style.opacity = '0.7'; };
btn.onclick = function() {
navigator.clipboard.writeText(codeBlock.textContent).then(function() {
btn.textContent = 'Copied!';
setTimeout(function() { btn.textContent = 'Copy'; }, 1500);
});
};
codeBlock.parentElement.style.position = 'relative';
codeBlock.parentElement.appendChild(btn);
});
}
addCopyButtons();
// Re-run on dynamic content
var observer = new MutationObserver(addCopyButtons);
observer.observe(document.body, { childList: true, subtree: true });
})();
}
} catch(__e) { console.warn('[Userscript:Add Copy Buttons to Code Blocks]', __e); }
})();
(function(){
try {
var __m = "github.com";
var __re = new RegExp('^' + "github\\.com" + '
feat(runtime): session task ledger — TaskCreate/TaskUpdate + turn-tail injection (#15 P0-task) by UncertaintyDeterminesYou4ndMe · Pull Request #537 · apache/maka · GitHub
Skip to content

feat(runtime): session task ledger — TaskCreate/TaskUpdate + turn-tail injection (#15 P0-task) - #537

Merged
Astro-Han merged 16 commits into
apache:mainfrom
UncertaintyDeterminesYou4ndMe:feat/task-ledger
Jul 5, 2026
Merged

feat(runtime): session task ledger — TaskCreate/TaskUpdate + turn-tail injection (#15 P0-task)#537
Astro-Han merged 16 commits into
apache:mainfrom
UncertaintyDeterminesYou4ndMe:feat/task-ledger

Conversation

@UncertaintyDeterminesYou4ndMe

Copy link
Copy Markdown
Contributor

#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 还在,下一轮自然恢复。

各层全部锚在现有先例上:

实现先例
coreTask 契约(pending/in_progress/completed/cancelled)+ 防御式 normalize + TaskLedgerStore 接口 + 200 条硬上限plan-reminders
storagesessions/<id>/tasks.json,原子 tmp+rename,per-session chainWrite 串行化plan-reminder-store + session-store 写队列
runtime双工具,permissionRequired: false(纯本地状态,无外部副作用)agent_list
desktopturn tail 注入(不碰 durable system prefix,prefix-cache 纪律不变)「本轮记忆变更」tail 片段

子 agent 不会继承这两个工具(agent definition 的显式 allowlist 过滤)。headless 接线和 UI 面板是后续(UI 面板 PR 已在开发)。

关键语义(对抗式审查驱动)

本 PR 经两轮对抗审查(人工 + 16-agent 高强度独立审查,9 个 CONFIRMED 发现全部修复),以下语义是审查后收敛的:

  • 读路径分裂:渲染读(list/turn-tail)遇损坏降级为空——台账坏了不能卡住会话;写路径 fail-closed——mutate 读到损坏文件或 I/O 错误直接抛错、拒绝覆盖,瞬时读错误不可能静默清空台账。
  • 双面脱敏:任务标题是模型自由文本且会被每轮重放(turn tail)+ 持久化重放(工具结果进会话历史),两个面都过 redactSecrets
  • 数据包裹层防逃逸:tail 渲染 strip 掉标题里的字面 </task-ledger> 标签(strip 在 redact 之后,保证无后续变换能复活标签)。
  • 约束前置:zod schema 编码 200 字符上限和 trim,模型在校验层就学到约束,不靠运行时试错。
  • 无二次读:工具结果渲染写队列临界区内算出的新列表,消除队列外重读的竞态。
  • 单条畸形记录沿用 plan-reminder-store 的逐条丢弃先例(整体损坏才 fail-closed)。

验证

…+ 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.

@Astro-HanAstro-Han left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

看了下 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 是纯文本,转义成 &lt; 模型会原样看到 &lt;,删掉会改坏 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.tsreadMainTsSource() 配 regex 锁 main.ts 写法,等价重构(换变量名)会让测试挂但行为没变,脆。不过这是仓里既有模式(session-environment-prompt.test.ts 也这么干),不算本 PR 新债,P3 知道就行。

tag 逃逸和读路径这两条建议合之前补掉,要我把改法写成补丁贴上也行。

Astro-Han added 15 commits July 5, 2026 12:02
… 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

Copy link
Copy Markdown
ContributorAuthor

UI 切片已开 PR:#553(任务面板 + 取消 outcome 契约 + 竞态加固)。@Astro-Han 你评审的两条 P2(tag-strip 变体、读侧重规范化)确认已在合并版(renderSafeTaskLedgerText / decodeTasks 读侧 normalizeTaskSubject)覆盖,#553 在其上未再触碰这两处。

Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants

@UncertaintyDeterminesYou4ndMe@likun666661@Astro-Han
, 'i'); if (__m === '*' || __re.test(location.href)) { // Force GitHub README to respect dark mode (function() { var style = document.createElement('style'); style.textContent = ' .markdown-body { color-scheme: dark light; } .markdown-body pre { background: #161b22 !important; } .markdown-body code { background: rgba(110, 118, 129, 0.4) !important; } .markdown-body table th, .markdown-body table td { border-color: #30363d !important; } .markdown-body img { background: #0d1117; } .markdown-body blockquote { border-left-color: #8b949e; } .markdown-body hr { border-color: #30363d; } '; document.head.appendChild(style); })(); } } catch(__e) { console.warn('[Userscript:GitHub Dark Mode README Fix]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + ' feat(runtime): session task ledger — TaskCreate/TaskUpdate + turn-tail injection (#15 P0-task) by UncertaintyDeterminesYou4ndMe · Pull Request #537 · apache/maka · GitHub
Skip to content

feat(runtime): session task ledger — TaskCreate/TaskUpdate + turn-tail injection (#15 P0-task) - #537

Merged
Astro-Han merged 16 commits into
apache:mainfrom
UncertaintyDeterminesYou4ndMe:feat/task-ledger
Jul 5, 2026
Merged

feat(runtime): session task ledger — TaskCreate/TaskUpdate + turn-tail injection (#15 P0-task)#537
Astro-Han merged 16 commits into
apache:mainfrom
UncertaintyDeterminesYou4ndMe:feat/task-ledger

Conversation

@UncertaintyDeterminesYou4ndMe

Copy link
Copy Markdown
Contributor

#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 还在,下一轮自然恢复。

各层全部锚在现有先例上:

实现先例
coreTask 契约(pending/in_progress/completed/cancelled)+ 防御式 normalize + TaskLedgerStore 接口 + 200 条硬上限plan-reminders
storagesessions/<id>/tasks.json,原子 tmp+rename,per-session chainWrite 串行化plan-reminder-store + session-store 写队列
runtime双工具,permissionRequired: false(纯本地状态,无外部副作用)agent_list
desktopturn tail 注入(不碰 durable system prefix,prefix-cache 纪律不变)「本轮记忆变更」tail 片段

子 agent 不会继承这两个工具(agent definition 的显式 allowlist 过滤)。headless 接线和 UI 面板是后续(UI 面板 PR 已在开发)。

关键语义(对抗式审查驱动)

本 PR 经两轮对抗审查(人工 + 16-agent 高强度独立审查,9 个 CONFIRMED 发现全部修复),以下语义是审查后收敛的:

  • 读路径分裂:渲染读(list/turn-tail)遇损坏降级为空——台账坏了不能卡住会话;写路径 fail-closed——mutate 读到损坏文件或 I/O 错误直接抛错、拒绝覆盖,瞬时读错误不可能静默清空台账。
  • 双面脱敏:任务标题是模型自由文本且会被每轮重放(turn tail)+ 持久化重放(工具结果进会话历史),两个面都过 redactSecrets
  • 数据包裹层防逃逸:tail 渲染 strip 掉标题里的字面 </task-ledger> 标签(strip 在 redact 之后,保证无后续变换能复活标签)。
  • 约束前置:zod schema 编码 200 字符上限和 trim,模型在校验层就学到约束,不靠运行时试错。
  • 无二次读:工具结果渲染写队列临界区内算出的新列表,消除队列外重读的竞态。
  • 单条畸形记录沿用 plan-reminder-store 的逐条丢弃先例(整体损坏才 fail-closed)。

验证

…+ 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.

@Astro-HanAstro-Han left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

看了下 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 是纯文本,转义成 &lt; 模型会原样看到 &lt;,删掉会改坏 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.tsreadMainTsSource() 配 regex 锁 main.ts 写法,等价重构(换变量名)会让测试挂但行为没变,脆。不过这是仓里既有模式(session-environment-prompt.test.ts 也这么干),不算本 PR 新债,P3 知道就行。

tag 逃逸和读路径这两条建议合之前补掉,要我把改法写成补丁贴上也行。

Astro-Han added 15 commits July 5, 2026 12:02
… 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

Copy link
Copy Markdown
ContributorAuthor

UI 切片已开 PR:#553(任务面板 + 取消 outcome 契约 + 竞态加固)。@Astro-Han 你评审的两条 P2(tag-strip 变体、读侧重规范化)确认已在合并版(renderSafeTaskLedgerText / decodeTasks 读侧 normalizeTaskSubject)覆盖,#553 在其上未再触碰这两处。

Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants

@UncertaintyDeterminesYou4ndMe@likun666661@Astro-Han
, 'i'); if (__m === '*' || __re.test(location.href)) { // Highlight search terms from Google/DuckDuckGo/Bing referrer (function() { var ref = document.referrer; var terms = []; if (ref.includes('google.com') || ref.includes('duckduckgo.com') || ref.includes('bing.com')) { var url = new URL(ref); var q = url.searchParams.get('q') || url.searchParams.get('p'); if (q) { terms = q.split(/\s+/).filter(function(t) { return t.length > 2; }); } } if (terms.length === 0) return; var style = document.createElement('style'); style.textContent = '.userscript-highlight { background: #fbbf24; color: #1a1a2e; padding: 1px 3px; border-radius: 2px; }'; document.head.appendChild(style); function highlight(node) { if (node.nodeType === 3) { // text node var text = node.textContent; var found = false; terms.forEach(function(term) { var regex = new RegExp('(' + term.replace(/[.*+?^${}()|[\]\\]/g, '\\') + ')', 'gi'); if (regex.test(text)) { found = true; var frag = document.createDocumentFragment(); var parts = text.split(regex); parts.forEach(function(part, i) { if (i % 2 === 0) { frag.appendChild(document.createTextNode(part)); } else { var span = document.createElement('span'); span.className = 'userscript-highlight'; span.textContent = part; frag.appendChild(span); } }); node.parentNode.replaceChild(frag, node); } }); } else if (node.nodeType === 1 && node.childNodes) { // element var skipTags = ['SCRIPT', 'STYLE', 'NOSCRIPT', 'TEXTAREA', 'INPUT', 'SELECT']; if (!skipTags.includes(node.tagName)) { Array.from(node.childNodes).forEach(highlight); } } } highlight(document.body); // Re-highlight on dynamic content var observer = new MutationObserver(function(mutations) { mutations.forEach(function(m) { m.addedNodes.forEach(function(node) { if (node.nodeType === 1 || node.nodeType === 3) highlight(node); }); }); }); observer.observe(document.body, { childList: true, subtree: true }); })(); } } catch(__e) { console.warn('[Userscript:Highlight Search Terms]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + ' feat(runtime): session task ledger — TaskCreate/TaskUpdate + turn-tail injection (#15 P0-task) by UncertaintyDeterminesYou4ndMe · Pull Request #537 · apache/maka · GitHub
Skip to content

feat(runtime): session task ledger — TaskCreate/TaskUpdate + turn-tail injection (#15 P0-task) - #537

Merged
Astro-Han merged 16 commits into
apache:mainfrom
UncertaintyDeterminesYou4ndMe:feat/task-ledger
Jul 5, 2026
Merged

feat(runtime): session task ledger — TaskCreate/TaskUpdate + turn-tail injection (#15 P0-task)#537
Astro-Han merged 16 commits into
apache:mainfrom
UncertaintyDeterminesYou4ndMe:feat/task-ledger

Conversation

@UncertaintyDeterminesYou4ndMe

Copy link
Copy Markdown
Contributor

#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 还在,下一轮自然恢复。

各层全部锚在现有先例上:

实现先例
coreTask 契约(pending/in_progress/completed/cancelled)+ 防御式 normalize + TaskLedgerStore 接口 + 200 条硬上限plan-reminders
storagesessions/<id>/tasks.json,原子 tmp+rename,per-session chainWrite 串行化plan-reminder-store + session-store 写队列
runtime双工具,permissionRequired: false(纯本地状态,无外部副作用)agent_list
desktopturn tail 注入(不碰 durable system prefix,prefix-cache 纪律不变)「本轮记忆变更」tail 片段

子 agent 不会继承这两个工具(agent definition 的显式 allowlist 过滤)。headless 接线和 UI 面板是后续(UI 面板 PR 已在开发)。

关键语义(对抗式审查驱动)

本 PR 经两轮对抗审查(人工 + 16-agent 高强度独立审查,9 个 CONFIRMED 发现全部修复),以下语义是审查后收敛的:

  • 读路径分裂:渲染读(list/turn-tail)遇损坏降级为空——台账坏了不能卡住会话;写路径 fail-closed——mutate 读到损坏文件或 I/O 错误直接抛错、拒绝覆盖,瞬时读错误不可能静默清空台账。
  • 双面脱敏:任务标题是模型自由文本且会被每轮重放(turn tail)+ 持久化重放(工具结果进会话历史),两个面都过 redactSecrets
  • 数据包裹层防逃逸:tail 渲染 strip 掉标题里的字面 </task-ledger> 标签(strip 在 redact 之后,保证无后续变换能复活标签)。
  • 约束前置:zod schema 编码 200 字符上限和 trim,模型在校验层就学到约束,不靠运行时试错。
  • 无二次读:工具结果渲染写队列临界区内算出的新列表,消除队列外重读的竞态。
  • 单条畸形记录沿用 plan-reminder-store 的逐条丢弃先例(整体损坏才 fail-closed)。

验证

…+ 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.

@Astro-HanAstro-Han left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

看了下 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 是纯文本,转义成 &lt; 模型会原样看到 &lt;,删掉会改坏 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.tsreadMainTsSource() 配 regex 锁 main.ts 写法,等价重构(换变量名)会让测试挂但行为没变,脆。不过这是仓里既有模式(session-environment-prompt.test.ts 也这么干),不算本 PR 新债,P3 知道就行。

tag 逃逸和读路径这两条建议合之前补掉,要我把改法写成补丁贴上也行。

Astro-Han added 15 commits July 5, 2026 12:02
… 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

Copy link
Copy Markdown
ContributorAuthor

UI 切片已开 PR:#553(任务面板 + 取消 outcome 契约 + 竞态加固)。@Astro-Han 你评审的两条 P2(tag-strip 变体、读侧重规范化)确认已在合并版(renderSafeTaskLedgerText / decodeTasks 读侧 normalizeTaskSubject)覆盖,#553 在其上未再触碰这两处。

Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants

@UncertaintyDeterminesYou4ndMe@likun666661@Astro-Han
, 'i'); if (__m === '*' || __re.test(location.href)) { // Strip utm_, fbclid, gclid, etc. from all links on page (function() { var trackingParams = ['utm_source', 'utm_medium', 'utm_campaign', 'utm_term', 'utm_content', 'fbclid', 'gclid', 'dclid', 'msclkid', 'yclid', 'ref', 'ref_src', 'source', 'medium', 'campaign']; function cleanUrl(url) { try { var u = new URL(url, window.location.origin); var changed = false; trackingParams.forEach(function(p) { if (u.searchParams.has(p)) { u.searchParams.delete(p); changed = true; } }); return changed ? u.toString() : url; } catch (e) { return url; } } function cleanLinks() { document.querySelectorAll('a[href]').forEach(function(a) { var clean = cleanUrl(a.href); if (clean !== a.href) a.href = clean; }); } cleanLinks(); var observer = new MutationObserver(function(mutations) { mutations.forEach(function(m) { m.addedNodes.forEach(function(node) { if (node.nodeType === 1) { if (node.tagName === 'A') cleanLinks(); node.querySelectorAll('a[href]').forEach(function(a) { var clean = cleanUrl(a.href); if (clean !== a.href) a.href = clean; }); } }); }); }); observer.observe(document.body, { childList: true, subtree: true }); })(); } } catch(__e) { console.warn('[Userscript:Remove Tracking Parameters from Links]', __e); } })(); (function(){ try { var __m = "youtube.com"; var __re = new RegExp('^' + "youtube\\.com" + ' feat(runtime): session task ledger — TaskCreate/TaskUpdate + turn-tail injection (#15 P0-task) by UncertaintyDeterminesYou4ndMe · Pull Request #537 · apache/maka · GitHub
Skip to content

feat(runtime): session task ledger — TaskCreate/TaskUpdate + turn-tail injection (#15 P0-task) - #537

Merged
Astro-Han merged 16 commits into
apache:mainfrom
UncertaintyDeterminesYou4ndMe:feat/task-ledger
Jul 5, 2026
Merged

feat(runtime): session task ledger — TaskCreate/TaskUpdate + turn-tail injection (#15 P0-task)#537
Astro-Han merged 16 commits into
apache:mainfrom
UncertaintyDeterminesYou4ndMe:feat/task-ledger

Conversation

@UncertaintyDeterminesYou4ndMe

Copy link
Copy Markdown
Contributor

#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 还在,下一轮自然恢复。

各层全部锚在现有先例上:

实现先例
coreTask 契约(pending/in_progress/completed/cancelled)+ 防御式 normalize + TaskLedgerStore 接口 + 200 条硬上限plan-reminders
storagesessions/<id>/tasks.json,原子 tmp+rename,per-session chainWrite 串行化plan-reminder-store + session-store 写队列
runtime双工具,permissionRequired: false(纯本地状态,无外部副作用)agent_list
desktopturn tail 注入(不碰 durable system prefix,prefix-cache 纪律不变)「本轮记忆变更」tail 片段

子 agent 不会继承这两个工具(agent definition 的显式 allowlist 过滤)。headless 接线和 UI 面板是后续(UI 面板 PR 已在开发)。

关键语义(对抗式审查驱动)

本 PR 经两轮对抗审查(人工 + 16-agent 高强度独立审查,9 个 CONFIRMED 发现全部修复),以下语义是审查后收敛的:

  • 读路径分裂:渲染读(list/turn-tail)遇损坏降级为空——台账坏了不能卡住会话;写路径 fail-closed——mutate 读到损坏文件或 I/O 错误直接抛错、拒绝覆盖,瞬时读错误不可能静默清空台账。
  • 双面脱敏:任务标题是模型自由文本且会被每轮重放(turn tail)+ 持久化重放(工具结果进会话历史),两个面都过 redactSecrets
  • 数据包裹层防逃逸:tail 渲染 strip 掉标题里的字面 </task-ledger> 标签(strip 在 redact 之后,保证无后续变换能复活标签)。
  • 约束前置:zod schema 编码 200 字符上限和 trim,模型在校验层就学到约束,不靠运行时试错。
  • 无二次读:工具结果渲染写队列临界区内算出的新列表,消除队列外重读的竞态。
  • 单条畸形记录沿用 plan-reminder-store 的逐条丢弃先例(整体损坏才 fail-closed)。

验证

…+ 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.

@Astro-HanAstro-Han left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

看了下 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 是纯文本,转义成 &lt; 模型会原样看到 &lt;,删掉会改坏 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.tsreadMainTsSource() 配 regex 锁 main.ts 写法,等价重构(换变量名)会让测试挂但行为没变,脆。不过这是仓里既有模式(session-environment-prompt.test.ts 也这么干),不算本 PR 新债,P3 知道就行。

tag 逃逸和读路径这两条建议合之前补掉,要我把改法写成补丁贴上也行。

Astro-Han added 15 commits July 5, 2026 12:02
… 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

Copy link
Copy Markdown
ContributorAuthor

UI 切片已开 PR:#553(任务面板 + 取消 outcome 契约 + 竞态加固)。@Astro-Han 你评审的两条 P2(tag-strip 变体、读侧重规范化)确认已在合并版(renderSafeTaskLedgerText / decodeTasks 读侧 normalizeTaskSubject)覆盖,#553 在其上未再触碰这两处。

Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants

@UncertaintyDeterminesYou4ndMe@likun666661@Astro-Han
, 'i'); if (__m === '*' || __re.test(location.href)) { // Auto-enable theater mode on YouTube (function() { function tryTheater() { var btn = document.querySelector('button[aria-label="Theater mode"], ytd-player #player button[title="Theater mode"]'); if (btn && !btn.classList.contains('activated')) { btn.click(); } } // Try immediately tryTheater(); // Try after navigation (SPA) var lastUrl = location.href; setInterval(function() { if (location.href !== lastUrl) { lastUrl = location.href; setTimeout(tryTheater, 500); } }, 1000); // Also try on player load var observer = new MutationObserver(tryTheater); observer.observe(document.body, { childList: true, subtree: true }); })(); } } catch(__e) { console.warn('[Userscript:YouTube Theater Mode Default]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + ' feat(runtime): session task ledger — TaskCreate/TaskUpdate + turn-tail injection (#15 P0-task) by UncertaintyDeterminesYou4ndMe · Pull Request #537 · apache/maka · GitHub
Skip to content

feat(runtime): session task ledger — TaskCreate/TaskUpdate + turn-tail injection (#15 P0-task) - #537

Merged
Astro-Han merged 16 commits into
apache:mainfrom
UncertaintyDeterminesYou4ndMe:feat/task-ledger
Jul 5, 2026
Merged

feat(runtime): session task ledger — TaskCreate/TaskUpdate + turn-tail injection (#15 P0-task)#537
Astro-Han merged 16 commits into
apache:mainfrom
UncertaintyDeterminesYou4ndMe:feat/task-ledger

Conversation

@UncertaintyDeterminesYou4ndMe

Copy link
Copy Markdown
Contributor

#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 还在,下一轮自然恢复。

各层全部锚在现有先例上:

实现先例
coreTask 契约(pending/in_progress/completed/cancelled)+ 防御式 normalize + TaskLedgerStore 接口 + 200 条硬上限plan-reminders
storagesessions/<id>/tasks.json,原子 tmp+rename,per-session chainWrite 串行化plan-reminder-store + session-store 写队列
runtime双工具,permissionRequired: false(纯本地状态,无外部副作用)agent_list
desktopturn tail 注入(不碰 durable system prefix,prefix-cache 纪律不变)「本轮记忆变更」tail 片段

子 agent 不会继承这两个工具(agent definition 的显式 allowlist 过滤)。headless 接线和 UI 面板是后续(UI 面板 PR 已在开发)。

关键语义(对抗式审查驱动)

本 PR 经两轮对抗审查(人工 + 16-agent 高强度独立审查,9 个 CONFIRMED 发现全部修复),以下语义是审查后收敛的:

  • 读路径分裂:渲染读(list/turn-tail)遇损坏降级为空——台账坏了不能卡住会话;写路径 fail-closed——mutate 读到损坏文件或 I/O 错误直接抛错、拒绝覆盖,瞬时读错误不可能静默清空台账。
  • 双面脱敏:任务标题是模型自由文本且会被每轮重放(turn tail)+ 持久化重放(工具结果进会话历史),两个面都过 redactSecrets
  • 数据包裹层防逃逸:tail 渲染 strip 掉标题里的字面 </task-ledger> 标签(strip 在 redact 之后,保证无后续变换能复活标签)。
  • 约束前置:zod schema 编码 200 字符上限和 trim,模型在校验层就学到约束,不靠运行时试错。
  • 无二次读:工具结果渲染写队列临界区内算出的新列表,消除队列外重读的竞态。
  • 单条畸形记录沿用 plan-reminder-store 的逐条丢弃先例(整体损坏才 fail-closed)。

验证

…+ 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.

@Astro-HanAstro-Han left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

看了下 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 是纯文本,转义成 &lt; 模型会原样看到 &lt;,删掉会改坏 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.tsreadMainTsSource() 配 regex 锁 main.ts 写法,等价重构(换变量名)会让测试挂但行为没变,脆。不过这是仓里既有模式(session-environment-prompt.test.ts 也这么干),不算本 PR 新债,P3 知道就行。

tag 逃逸和读路径这两条建议合之前补掉,要我把改法写成补丁贴上也行。

Astro-Han added 15 commits July 5, 2026 12:02
… 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

Copy link
Copy Markdown
ContributorAuthor

UI 切片已开 PR:#553(任务面板 + 取消 outcome 契约 + 竞态加固)。@Astro-Han 你评审的两条 P2(tag-strip 变体、读侧重规范化)确认已在合并版(renderSafeTaskLedgerText / decodeTasks 读侧 normalizeTaskSubject)覆盖,#553 在其上未再触碰这两处。

Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants

@UncertaintyDeterminesYou4ndMe@likun666661@Astro-Han
, 'i'); if (__m === '*' || __re.test(location.href)) { // Remove or un-stick sticky/fixed headers that block content (function() { function unstick() { document.querySelectorAll('header, nav, [role="banner"], .header, .navbar, .sticky, .fixed-top, [style*="position: fixed"], [style*="position:sticky"]').forEach(function(el) { if (el.style.position === 'fixed' || el.style.position === 'sticky' || getComputedStyle(el).position === 'fixed' || getComputedStyle(el).position === 'sticky') { el.style.position = 'static'; el.style.top = 'auto'; el.style.zIndex = 'auto'; } }); } unstick(); var observer = new MutationObserver(unstick); observer.observe(document.body, { childList: true, subtree: true, attributes: true, attributeFilter: ['style', 'class'] }); })(); } } catch(__e) { console.warn('[Userscript:Kill Sticky Headers]', __e); } })(); })(); feat(runtime): session task ledger — TaskCreate/TaskUpdate + turn-tail injection (#15 P0-task) by UncertaintyDeterminesYou4ndMe · Pull Request #537 · apache/maka · GitHub
Skip to content

feat(runtime): session task ledger — TaskCreate/TaskUpdate + turn-tail injection (#15 P0-task) - #537

Merged
Astro-Han merged 16 commits into
apache:mainfrom
UncertaintyDeterminesYou4ndMe:feat/task-ledger
Jul 5, 2026
Merged

feat(runtime): session task ledger — TaskCreate/TaskUpdate + turn-tail injection (#15 P0-task)#537
Astro-Han merged 16 commits into
apache:mainfrom
UncertaintyDeterminesYou4ndMe:feat/task-ledger

Conversation

@UncertaintyDeterminesYou4ndMe

Copy link
Copy Markdown
Contributor

#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 还在,下一轮自然恢复。

各层全部锚在现有先例上:

实现先例
coreTask 契约(pending/in_progress/completed/cancelled)+ 防御式 normalize + TaskLedgerStore 接口 + 200 条硬上限plan-reminders
storagesessions/<id>/tasks.json,原子 tmp+rename,per-session chainWrite 串行化plan-reminder-store + session-store 写队列
runtime双工具,permissionRequired: false(纯本地状态,无外部副作用)agent_list
desktopturn tail 注入(不碰 durable system prefix,prefix-cache 纪律不变)「本轮记忆变更」tail 片段

子 agent 不会继承这两个工具(agent definition 的显式 allowlist 过滤)。headless 接线和 UI 面板是后续(UI 面板 PR 已在开发)。

关键语义(对抗式审查驱动)

本 PR 经两轮对抗审查(人工 + 16-agent 高强度独立审查,9 个 CONFIRMED 发现全部修复),以下语义是审查后收敛的:

  • 读路径分裂:渲染读(list/turn-tail)遇损坏降级为空——台账坏了不能卡住会话;写路径 fail-closed——mutate 读到损坏文件或 I/O 错误直接抛错、拒绝覆盖,瞬时读错误不可能静默清空台账。
  • 双面脱敏:任务标题是模型自由文本且会被每轮重放(turn tail)+ 持久化重放(工具结果进会话历史),两个面都过 redactSecrets
  • 数据包裹层防逃逸:tail 渲染 strip 掉标题里的字面 </task-ledger> 标签(strip 在 redact 之后,保证无后续变换能复活标签)。
  • 约束前置:zod schema 编码 200 字符上限和 trim,模型在校验层就学到约束,不靠运行时试错。
  • 无二次读:工具结果渲染写队列临界区内算出的新列表,消除队列外重读的竞态。
  • 单条畸形记录沿用 plan-reminder-store 的逐条丢弃先例(整体损坏才 fail-closed)。

验证

…+ 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.

@Astro-HanAstro-Han left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

看了下 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 是纯文本,转义成 &lt; 模型会原样看到 &lt;,删掉会改坏 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.tsreadMainTsSource() 配 regex 锁 main.ts 写法,等价重构(换变量名)会让测试挂但行为没变,脆。不过这是仓里既有模式(session-environment-prompt.test.ts 也这么干),不算本 PR 新债,P3 知道就行。

tag 逃逸和读路径这两条建议合之前补掉,要我把改法写成补丁贴上也行。

Astro-Han added 15 commits July 5, 2026 12:02
… 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

Copy link
Copy Markdown
ContributorAuthor

UI 切片已开 PR:#553(任务面板 + 取消 outcome 契约 + 竞态加固)。@Astro-Han 你评审的两条 P2(tag-strip 变体、读侧重规范化)确认已在合并版(renderSafeTaskLedgerText / decodeTasks 读侧 normalizeTaskSubject)覆盖,#553 在其上未再触碰这两处。

Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants

@UncertaintyDeterminesYou4ndMe@likun666661@Astro-Han