fix(tasks): correct the change-feed contract and its documentation
Review found the `onTasksChanged` teardown reasoning inverted. The comment claimed every registration is an effect on the registry's own fiber, so listeners would be gone before service disposal empties the store — but the traceable proxy rebinds `this.ctx` to the CALLER, which this package's own HMR-safety test already proves. The only shipped consumer registers from the api-proxy mux stream, so it was still listening and simply kept the rows it last received. Service disposal now announces the emptied set, and teardown announces its stopping transition immediately instead of leaving an observer on `running` for however long a slow producer takes to release. Two documentation claims were false in the opposite direction: the Agent Note and the ui-task README both said an unowned task is invisible in the header, while `list(caller)` returns unowned tasks to every caller, the carrier fans their changes out to every subscribed session, and this PR's own test asserts exactly that. The note even contradicted itself two sections earlier. Both sides now state the real asymmetries — another session's tasks, and the process-local registry emptying on restart. The "no Web path calls the consuming `ctx.tasks.read()`" invariant claimed a test that did not exist; the carrier suite's producer had no `readOutput` at all, so a stray read would have failed nothing. Its producer now counts cursor consumption and the lifecycle and baseline paths both assert zero. Also: a session created after the mux opened now receives the task baseline it missed, the popover samples its clock when it opens rather than at mount, and a failed task's unbounded producer detail elides instead of widening the row.
This commit is contained in:
@@ -2,5 +2,5 @@
|
||||
# side as of the last confirmed-consistent state. Both languages carry equal authority;
|
||||
# after editing either side, bring the other along and re-record with:
|
||||
# pnpm run verify-translation-pairing --write packages/client/ui-task/README.md
|
||||
README.md: 318984808838cb7a336a4c34c95a2f27db578423
|
||||
README.zh.md: 7adeb254662fd23cf4bd1efc8618471cf74df7c0
|
||||
README.md: a1430db55c7519c612e5d39de4627c750976d2d5
|
||||
README.zh.md: 5e29c939806f9a6500e78324c5a3322b6fd94539
|
||||
|
||||
@@ -21,4 +21,4 @@ None; the package never assembles or sends provider requests.
|
||||
## Known Limitations and Deferred Work
|
||||
|
||||
- **Rows are read-only** — a task's streamed output and a human-initiated cancellation are separate phases. Cancellation additionally owes a model-facing decision the seam does not answer today: `kill()` marks terminal delivery reported, so an interrupt written against the current contract would leave the model believing its task is still running.
|
||||
- **The list is not the registry's own set** — it shows what the owning session can see through the wire view, so an unowned task (one started without a live `Agent`) is invisible here while `task_list` still reports it to the model.
|
||||
- **The list is not the registry's own set** — it shows what one session can see through the wire view, so a task owned by another session never appears here, and a process restart empties the list while the transcript keeps the `run_in_background` cards that started those tasks. An unowned task (one started without a live `Agent`) is the opposite case: it reaches every session's list, matching what `list(caller)` reports to every caller.
|
||||
|
||||
@@ -21,4 +21,4 @@ Escape 关闭列表并把焦点交还触发器,在其外部按下指针同理
|
||||
## Known Limitations and Deferred Work
|
||||
|
||||
- **行是只读的** —— 任务的流式输出与人类发起的中断是各自独立的阶段。中断还额外欠一个 seam 目前没有回答的、面向模型的决策:`kill()` 会把终态投递标为已上报,所以照当前契约写出来的中断会让模型一直以为它的任务还在跑。
|
||||
- **列表不等于注册表自己的集合** —— 它展示的是拥有它的会话通过线路视图能看到的东西,因此一个无主任务(在没有活体 `Agent` 时启动的任务)在这里不可见,而 `task_list` 仍会把它报告给模型。
|
||||
- **列表不等于注册表自己的集合** —— 它展示的是「一个会话通过线路视图能看到什么」,所以别的会话拥有的任务在这里永远不出现;而进程重启会清空列表,transcript 里启动这些任务的 `run_in_background` 卡片却还在。无主任务(在没有活体 `Agent` 时启动的)是反过来的情形:它会进入每一个会话的列表,与 `list(caller)` 对每个调用方的报告一致。
|
||||
|
||||
@@ -111,6 +111,15 @@
|
||||
line-height: 18px;
|
||||
}
|
||||
|
||||
/* A failed task's detail is the producer's raw error text, so it has no bound;
|
||||
without this it widens the row past the menu instead of eliding like .label. */
|
||||
.status {
|
||||
max-width: 40%;
|
||||
overflow: hidden;
|
||||
white-space: nowrap;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.duration {
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
|
||||
@@ -148,7 +148,14 @@ export function TaskListAction({ sessionId, useSessions, t }: TaskListActionProp
|
||||
className={css.trigger}
|
||||
aria-expanded={open}
|
||||
aria-label={countLabel}
|
||||
onClick={() => { setOpen(current => !current) }}
|
||||
onClick={() => {
|
||||
// Sample the clock in the same commit that opens the list: the
|
||||
// mount-time value predates every task, so the first painted frame
|
||||
// would otherwise clamp a long-running row to zero until the
|
||||
// open effect corrects it a frame later.
|
||||
setNow(Date.now())
|
||||
setOpen(current => !current)
|
||||
}}
|
||||
>
|
||||
{liveCount > 0 ? <StateDot state="ongoing" className={css.triggerDot} /> : null}
|
||||
<span className={css.count}>{countLabel}</span>
|
||||
@@ -167,7 +174,7 @@ export function TaskListAction({ sessionId, useSessions, t }: TaskListActionProp
|
||||
<StateDot state={dotState(task.status)} className={css.rowDot} />
|
||||
<span className={css.kind}>{task.kind}</span>
|
||||
<span className={css.label} title={task.label}>{task.label}</span>
|
||||
<span className={css.status}>{task.detail ?? status}</span>
|
||||
<span className={css.status} title={task.detail ?? status}>{task.detail ?? status}</span>
|
||||
<span
|
||||
className={css.duration}
|
||||
title={t(live ? 'duration.title.live' : 'duration.title.done', { duration })}
|
||||
|
||||
Reference in New Issue
Block a user